当开始编写一个新的类、结构或其他类型时,我们脑海中通常有一个非常具体的目标或用例。我们可能需要一个新模型来表示我们正在处理的一些数据,或者我们可能想要封装为我们正在构建的新特性量身定制的逻辑片段。

然而,随着时间的推移,我们经常发现自己想要使用相同类型或逻辑的高度相似版本,但用于完全不同的内容。我们可能想要重用一个现有的模型,但是用一种稍微不同的方式处理它 - 或者我们可以尝试完成一个与我们已经解决的问题非常相似的任务,但是结果会有所不同。

然后问题就变成了——如何利用我们现有的代码,并重构它,使其更加通用和可重用——而不会导致混乱或没有重点。 本周,让我们来看看这样做的一种技术——它涉及到使某些类型变得越来越可配置。

Initially specific

为单个用例编写特定的代码绝对没有什么错——事实上,可以肯定地说,这是任何代码最初或多或少应该创建的方式——以尽可能简单的方式解决一个非常真实的用例。假设我们正在开发一个笔记应用程序,我们希望添加一个功能,允许

用户从文件夹中包含的一组文本文件中导入一组外部笔记。 对于该特性的初始版本,我们决定只处理纯文本文件或使用Markdown格式化的注释-所以我们在编写第一个实现时,只考虑了这两个用例,像这样:

struct NoteImporter {
    func importNotes(from folder: Folder) throws -> [Note] {
        // Iterate over all the files contained within the
        // folder, and only handle the ones that have an
        // extension matching what we're supporting for now:
        return try folder.files.compactMap { file in
            switch file.extension {
            case "txt":
                return try importPlainTextNote(from: file)
            case "md", "markdown":
                return try importMarkdownNote(from: file)
            default:
                return nil
            }
        }
    }
}

作为初始实现,上面的方法很可能已经足够好了。然而,随着我们不断增加对越来越多文本格式的支持,我们总是需要返回并修改上面的NoteImporter的核心逻辑——从灵活性的角度来看,这不是很好。

理想情况下,我们希望NoteImporter只关心编排实际导入的任务,而不是绑定到非常特定的文件格式-否则以上的switch语句或多或少保证迟早会失去控制。

撇开不同的文件格式不谈,让我们说下一件事,我们想要添加对基于音频的笔记的支持——同时也允许用户使用相同的导入功能,将他们现有的音频文件导入我们的应用程序。当我们这样做的时候,如果用户也能导入照片——或者其他类型的媒体,那就太好了。

在我们当前的设置中,每一种新的导入都需要一个全新的实现——这给了我们一组不同的类型,它们都使用高度相似的api执行高度相似的任务:

struct AudioImporter {
    func importAudio(from folder: Folder) throws -> [Audio] {
        ...
    }
}

struct PhotoImporter {
    func importPhotos(from folder: Folder) throws -> [Photo] {
        ...
    }
}

虽然对不同的用例有不同的实现在某种程度上是件好事——它分离了关注点,并使我们能够针对每个特定的用例优化每种类型。 然而,没有任何类型的共享抽象或公共API,我们也错过了大量潜在的代码重用——而且我们使得为任何类型的文件导入器编写共享API变得非常困难。

Time for some protocol-oriented programming?

解决这个问题的最初想法可能是使用面向协议的方法——并创建一个所有文件导入器都能遵循的协议:

protocol FileImporting {
    associatedtype Result
    func importFiles(from folder: Folder) throws -> [Result]
}

使用上述方法,我们可以让每种类型的导入器保持独立,同时使它们都符合相同的协议——为我们提供共享和一致的API,同时仍然允许每种importer为每个用例量身定做:

extension AudioImporter: FileImporting { ... }
extension PhotoImporter: FileImporting { ... }
extension PlainTextImporter: FileImporting { ... }
extension MarkdownImporter: FileImporting { ... }

面向协议的编程确实很棒,但在这种情况下使用它确实有一些缺点。虽然我们现在已经改进了代码的一致性,但我们仍然会以大量的重复告终 - 特别是考虑到我们的大部分逻辑(实际上是遍历文件并处理它们)对于我们协议的所有实现者都是完全一样的。

Configurable types

与其创建一个具有多个实现的抽象,不如尝试创建一个单一的FileImporter类型,我们将使其具有足够的可配置性,以适用于我们当前(以及未来)的所有用例。

因为我们不同的文件导入器之间唯一不同的逻辑是如何处理每个文件, 让我们把这一部分变成可配置的——而对其他部分使用相同的实现。 在这种情况下,我们可以创建一个结构体,其中包含一个处理程序字典作为它的唯一属性:

struct FileImporter<Result> {
    typealias FileType = String

    var handlers: [FileType : (File) throws -> Result]
}

然后,我们将实现一个单独的importFiles方法——它只包含遍历文件夹中所有文件的逻辑,对于使用基于文件扩展名匹配的任何处理程序来处理每个文件,如下所示:

extension FileImporter {
    func importFiles(from folder: Folder) throws -> [Result] {
        return try folder.files.compactMap { file in
            guard let handler = handlers[file.extension] else {
                return nil
            }

            return try handler(file)
        }
    }
}

有了上面的内容,我们现在就有了一个更通用的实现,然后可以对其进行专门化以解决特定的用例。 但我们不希望每个调用站点都必须手动指定所有处理程序(这会很快导致大量不一致), 因此,我们将在共享的静态工厂方法中执行专门化。通过这种方式,我们可以为每种特定类型的导入创建一个工厂方法——就像下面这个注释:

extension FileImporter where Result == Note {
    static func notes() -> FileImporter {
        return FileImporter(handlers: [
            "txt": importPlainTextNote,
            "text": importPlainTextNote,
            "md": importMarkdownNote,
            "markdown": importMarkdownNote
        ])
    }

    private static func importPlainTextNote(from file: File) throws -> Note {
        ...
    }

    private static func importMarkdownNote(from file: File) throws -> Note {
        ...
    }
}

上面我们使用了一个相同类型的约束来扩展FileImporter,它具有特定于导入notes的功能。您可以在本文中了解有关Swift语言特性的更多信息。

请注意,我们现在可以很容易地添加对新文件格式的支持(无需更新我们的核心文件导入器逻辑),并且还可以向我们的笔记绑定扩展添加特定的私有实用程序方法-这仍然让我们能够以一种整洁的方式构造我们的代码,尽管它现在更加解耦了。

创建文件导入器实例仍然很容易,就像我们为每种导入使用专用类型一样-我们所要做的就是为我们想要导入的模型类型调用factory方法,然后Swift的类型推断会找出剩下的事情:

let notesImporter = FileImporter.notes()
let audioImporter = FileImporter.audio()
let photoImporter = FileImporter.photos()

现在,我们可以使用静态函数创建相同类型的不同配置,其好处在于,我们还可以轻松地添加特定于每个用例的参数。例如,假设我们想尝试添加对OGG音频格式的支持 -现在可以这样做,而不影响任何其他文件导入代码,同时仍然建立在非常相同的文件导入功能:

extension FileImporter where Result == Audio {
    static func audio(
        includeOggFiles: Bool = FeatureFlags.Audio.enableOggImports
    ) -> FileImporter {
        var handlers = [
            "mp3": importMp3Audio,
            "aac": importAacAudio
        ]

        if includeOggFiles {
            handlers["ogg"] = importOggAudio
        }

        return FileImporter(handlers: handlers)
    }
}

上面我们使用了一个特性标志来控制是否应该导入OGG文件。要了解更多关于使用各种特性标记的知识,请查看“Swift特性标记”。

通过使我们的文件导入器类型可配置,我们现在获得了大量的灵活性,同时也减少了代码重复——这是一个相当大的胜利!但也许我们可以更进一步?🤔

To infinity, and beyond!

虽然我们当前的实现已经是相当可配置的,但它确实限制每个导入完全基于要导入的文件的扩展名——这可能不是我们在所有情况下想要的。

例如,假设我们想要将一些当前的导入机制合并到单个特性中—允许用户一次性导入各种内容。要做到这一点,我们需要一次遍历一个文件夹,并将找到的每个兼容文件都转换为该enum的成员:

enum FileImportResult {
    case note(Note)
    case audio(Audio)
    case photo(Photo)
}

这在我们当前的设置中并不容易做到,因为我们只能为每个文件扩展名定制使用什么处理程序 - 我们还不能完全控制实际的文件处理。让我们解决这个问题!

我们不需要将文件导入器硬连接到一个处理程序字典中,而是使用一个单独的处理程序来配置,每个导入文件都将被传递到这个处理程序中:

struct FileImporter<Result> {
    typealias Handler = (File) throws -> Result?
    var handler: Handler
}

然后,我们可以对上述新版本的FileImporter进行改进,使其仍然可以使用处理程序字典进行配置,方法是使用一个与之前的初始化器匹配的初始化器来扩展它:

extension FileImporter {
    typealias FileType = String

    init(handlers: [FileType : Handler]) {
        handler = { file in
            try handlers[file.extension]?(file) ?? nil
        }
    }
}

通过上面的改变,我们的importFiles方法现在变得非常简单——因为它只需要使用它现在包含的单个处理程序在给定文件夹的文件上进行压缩映射:

extension FileImporter {
    func importFiles(from folder: Folder) throws -> [Result] {
        return try folder.files.compactMap(handler)
    }
}

通过重构我们的文件导入器,使其使用一个函数而不是一组具体的选项,我们或多或少地使其“无限可配置”,至少在遍历文件夹内文件的约束下是这样- 既然我们现在可以完全控制每个文件的处理方式,如果我们想的话。

使用这个新功能,我们现在能够构建我们的组合文件导入器——通过尝试使用每个底层导入器处理每个文件,所有的时间复杂度都是线性的——像这样:

extension FileImporter where Result == FileImportResult {
    static func combined() -> FileImporter {
        let importers = (
            notes: FileImporter<Note>.notes(),
            audio: FileImporter<Audio>.audio(),
            photos: FileImporter<Photo>.photos()
        )

        return FileImporter { file in
            try importers.notes.handler(file).map(Result.note) ??
                importers.audio.handler(file).map(Result.audio) ??
                importers.photos.handler(file).map(Result.photo)
        }
    }
}

在最后一步中,我们实际上所做的是将文件导入器行为的核心部分提取到一个可配置函数中 -同时仍然提供向后兼容的默认值。这个组合非常强大,因为它让我们可以根据当前的需要轻松地设置任何FileImporter实例,同时仍然构建在共享的默认设置和基本逻辑的基础上。

Conclusion

允许以各种方式配置我们的一些类型,可以让我们为已经编写的代码解锁新的用例——而不会让它变得非常复杂或难以维护。

虽然特定于单个用例的硬编码实现可能总是最简单的解决方案,可配置类型给我们带来的额外灵活性通常是值得付出努力的-特别是当我们想在一个共享的基础上建立多个功能的时候。

对于我们过去可能使用过协议的一些用例,也值得考虑使用可配置类型-因为这样做可以使整体结构更简单,需要维护的类型更少。

原文链接