代码重用是编程概念之一,它可能比最初看起来要复杂得多。 然而,人们很容易认为应该不惜一切代价避免代码复制,并且只要可能,就应该始终重用和共享实现-重要的是要记住,我们最终引入的所有抽象都有一定的代价。

因此,编写实用的、可重用的代码往往要在尽可能减少重复、同时还要避免为了统一不同的实现而引入过多的抽象层或复杂性。

在设计和构建可重用库时,达到这样的平衡变得特别重要(也可以说是困难的)——因此,本周,让我们看看一些原则和技术,在这样做时,记住这些原则和技术是很好的。

Packaging up a generic concept

无论我们是在单一代码基础上工作,还是在多个代码基础上工作,我们都有机会通过将范围狭窄的逻辑片段提取到单独的库中来共享代码。 除了代码重用之外,这样做还使我们能够独立地测试和迭代逻辑——这是非常有用的,特别是在大型代码库中,整个构建时间很长。

在决定将哪种代码提取到单独的库中时,通常最好选择一些可以实现为真正通用概念的代码。这并不一定意味着代码本身需要完全通用,而是逻辑本身不绑定到任何特定的特性或领域。

举个例子,假设我们正在开发一个大量使用标签的应用程序,以便对各种内容进行排序、过滤和提供推荐。虽然我们在应用程序中使用这些标签的方式可能是特定于我们特定领域的, 标签的概念本身肯定可以概括为一个可重用的库——TagKit,如果您愿意的话。

假设我们的应用程序的标签逻辑的核心部分是使用一个Tagged协议实现的,以及一个TaggedCollection,它允许我们根据标签存储和检索元素:

protocol Tagged: Hashable {
    var tags: [String] { get }
}

struct TaggedCollection<Element: Tagged> {
    private var elements = [String : Set<Element>]()

    mutating func add(_ element: Element) {
        for tag in element.tags {
            elements[tag, default: []].insert(element)
        }
    }

    mutating func remove(_ element: Element) {
        for tag in element.tags {
            elements[tag]?.remove(element)
        }
    }

    func elements(taggedWith tag: String) -> Set<Element> {
        elements[tag] ?? []
    }
}

让我们开始创建TagKit的过程,将上述协议移动到一个单独的库中,并将其创建为一个Swift包。一旦我们建立了包本身,第一步就是将所有我们希望成为库公共接口一部分的api标记为public,这样它们就可以在library模块之外访问了——像这样:

public protocol Tagged: Hashable {
    var tags: [String] { get }
}

public struct TaggedCollection<Element: Tagged> {
    private var elements = [String : Set<Element>]()
    
    // Note that we have to explicitly add a public initializer
    // in order to be able to initialize a type outside of
    // the module that it's declared in:
    public init() {}

    public mutating func add(_ element: Element) {
        for tag in element.tags {
            elements[tag, default: []].insert(element)
        }
    }

    public mutating func remove(_ element: Element) {
        for tag in element.tags {
            elements[tag]?.remove(element)
        }
    }

    public func elements(taggedWith tag: String) -> Set<Element> {
        elements[tag] ?? []
    }
}

虽然上述类型的更改是快速和容易的,但库开发的一个关键方面是仔细考虑哪些是我们的公共API的一部分,而哪些是库本身内部的。

举个例子,假设我们现在希望使用Swift内置的可编码API对TaggedCollection类型的实例进行编码和解码。为了实现这一点,我们集合的所有元素都需要是可编码的,一种方法是添加该协议作为符合Tagged的额外需求-这样我们就可以简单地将TaggedCollection标记为可编码的:

public protocol Tagged: Hashable, Codable {
    var tags: [String] { get }
}

public struct TaggedCollection<Element: Tagged>: Codable {
    ...
}

然而,在这里,我们必须先戴上“API设计师的帽子”,考虑一下要求所有标记类型也符合可编程的要求是否真的是个好主意。毕竟,编码和解码与支持标签有什么关系呢?

尽管建立这种关联似乎不是什么大问题,但添加比绝对需要的更多的需求可能会使我们的库更不灵活,更难以采用——特别是如果我们选择遵循这种设计,我们可能会在未来继续添加更多的需求。

值得庆幸的是,在这种情况下,我们还可以采用另一种方法,那就是使用Swift的条件一致性特性——这使我们能够在TaggedCollection的元素类型也符合给定协议的情况下,使我们的TaggedCollection符合给定协议。

使用它——同时也将符合Hashable的要求移动到我们的TaggedCollection本身——让我们简化了我们的Tagged协议,现在只需要一个属性,而不牺牲任何功能:

public protocol Tagged {
    var tags: [String] { get }
}

public struct TaggedCollection<Element: Tagged & Hashable> {
    ...
}

extension TaggedCollection: Codable where Element: Codable {}

有了上述改变,我们的API现在可以很好地扩展了——从简单地采用我们的标记协议,到在TaggedCollection中使用它,再到能够编码和解码集合实例——只有在需要时才逐渐引入需求。

Strong types and escape hatches

目前,我们的标签系统使用原始字符串来表示每个标签,当该系统专门为单个应用程序实现时,这可能是完全正确的 -但是因为我们现在想把它变成一个独立的库,所以我们可能想让它更强类型一些。

如何实现这一目标的最初想法可能是对我们目前作为枚举使用的所有标记建模,每个标记用一个case,然后更新我们的Tagged协议和TaggedCollection来使用该类型而不是字符串值:

public enum Tag: String, Codable {
    case newRelease
    case onSale
    case promoted
    ...
}

public protocol Tagged {
    var tags: [Tag] { get }
}

public struct TaggedCollection<Element: Tagged & Hashable> {
    private var elements = [Tag : Set<Element>]()

    ...

    public func elements(taggedWith tag: Tag) -> Set<Element> {
        ...
    }
}

然而,尽管上述方法在单个应用程序中可能非常有效,但在可重用库的上下文中却存在相当大的问题 - 因为库本身将需要包含任何使用它的应用程序所需要的所有标签,从长远来看,这不是非常可持续的。

解决这个问题的一种方法是在上面的枚举中添加一个“逃生通道”——也就是说,在需要的时候,一个允许我们实现自定义标记的API-例如通过引入一个自定义案例,像这样:

public enum Tag: Hashable, Codable {
    case newRelease
    case onSale
    case promoted
    ...
    case custom(String)
}

虽然上述方法确实有效,在某些情况下甚至可能是正确的设计决策, 它的缺点是,我们都必须实现自己的字符串之间的转换(因为我们的enum不再由原始字符串值支持),而且我们还必须手动遵循可编码。

相反,让我们采用一种不同的方法,将标记类型实现为struct——这使我们能够使用属性跟踪每个标记的底层字符串值。我们还将利用这个机会,让标记值也可以用字符串字面值轻松地表示:

public struct Tag: Hashable, Codable {
    public var string: String

    public init(_ string: String) {
        self.string = string
    }
}

extension Tag: ExpressibleByStringLiteral {
    public init(stringLiteral value: String) {
        string = value
    }
}

上述方法的优点在于,我们现在不仅可以自由地以任何我们想要的方式定义特定于应用程序的标记值 -我们仍然可以为我们最常用的标签添加静态计算属性来启用相同的“枚举式”点语法,例如:

public extension Tag {
    static var newRelease: Self { #function }
    static var onSale: Self { #function }
    static var promoted: Self { #function }
    ...
}

上面的#function在编译时将自动扩展为其外围属性的名称,为我们提供与枚举提供的完全相同的原始字符串映射。

有了上面的内容,我们现在可以方便地使用原始字符串定义标记,同时还可以获得强类型提供的额外类型安全性和自文档化质量。

The importance of testing the public API

公平地说,构建一个可靠的库的主要部分是在适当的地方放置足够的自动化测试,以确保它的各种功能和行为在继续发展的过程中能够像预期的那样工作。

举个例子,假设我们的新标签库还包含一个recommationengine,它可以让我们从一组带标签的元素中快速生成一组推荐。 为了简单起见,我们将使用下面的实现——它通过对所有匹配给定标记的元素进行洗牌来生成它的建议,然后返回前三个匹配项:

public struct RecommendationEngine<Element: Tagged & Hashable> {
    private let collection: TaggedCollection<Element>

    public init(collection: TaggedCollection<Element>) {
        self.collection = collection
    }

    public func recommendations(forTag tag: Tag) -> [Element] {
        let elements = collection.elements(taggedWith: tag)
        return Array(elements.shuffled().prefix(3))
    }
}

有趣的事实:虽然上面只是一个例子,它实际上非常接近这个网站的推荐系统的第一个版本是如何实现的。如果他们最终完成了任务,那么使用简单的算法也没有什么错。

虽然上面的实现确实很简单,但从测试的角度来看,它实际上有很大的问题,因为它包含了一个随机性元素(通过它使用shuffled())。 解决这个问题的一种方法是将随机源提取到一个闭包中,然后在我们的测试中重写——例如这样:

public struct RecommendationEngine<Element: Tagged & Hashable> {
    internal var sorting: (Set<Element>) -> [Element] = { $0.shuffled() }

    private let collection: TaggedCollection<Element>

    public init(collection: TaggedCollection<Element>) {
        self.collection = collection
    }

    public func recommendations(forTag tag: Tag) -> [Element] {
        let elements = collection.elements(taggedWith: tag)
        return Array(sorting(elements).prefix(3))
    }
}

我们的新排序属性被标记为internal,因为我们目前认为它是库的实现细节,而不是它的公共API的一部分。 然后,我们可以在测试中使用@testable导入命令访问该属性——这让我们可以访问所有被导入模块的内部api,以及它的公共api。这样,我们就可以编写这样的测试:

@testable import TagKit

class RecommendationEngineTests: XCTestCase {
    func testReturningFirstThreeMatchedElements() {
        let articles = (0..<5).map { index in
            Article(
                title: "Article-\(index)",
                tags: ["tag"]
            )
        }

        var collection = TaggedCollection<Article>()
        articles.forEach { collection.add($0) }

        var engine = RecommendationEngine(collection: collection)

        engine.sorting = { array in
            array.sorted(by: { $0.title < $1.title })
        }

        let recommendations = engine.recommendations(forTag: "tag")
        XCTAssertEqual(recommendations, Array(articles[..<3]))
    }
}

尽管上述方法有效,而且是为应用程序目标编写单元测试的一种非常常见的方法,但它是否适合测试库还是个问题。

使用内部api来编写库测试的问题是,这些功能将不能用于使用我们的库的实际生产代码——这反过来很容易忽略设计缺陷和api的局限性。毕竟,如果我们需要一个特定的API来测试我们的库,那么至少有一个使用它的应用程序也需要这个API。

所以,让我们把新的排序API转换成一个合适的公共API。虽然我们当然可以只将该属性的访问级别更改为public,但在将其作为官方API的一部分公开之前,让我们稍微调整一下它。

就像我们前面介绍的表示标签的专用类型一样,我们在这里做同样的事情——创建一个排序类型,它将作为一个封闭包的薄包装,它将执行实际的排序:

public struct Sorting<Element: Hashable> {
    public typealias Body = (Set<Element>) -> [Element]

    public var body: Body

    public init(body: @escaping Body) {
        self.body = body
    }
}

为了方便起见,让我们使用之前使用的基于静态属性的技术,为我们的新排序类型提供一个默认的shuffle实现:

public extension Sorting {
    static var shuffled: Self {
        .init { $0.shuffled() }
    }
}

以上内容都准备好了,现在让我们回到我们的recommendations engine,并让它接受我们的新排序类型的实例作为其初始化器的一部分。我们也将利用这个机会来参数化我们的maxElementCount——在不牺牲任何便利的情况下,进一步使我们的公共API更加可定制和强大:

public struct RecommendationEngine<Element: Tagged & Hashable> {
    private let collection: TaggedCollection<Element>
    private let sorting: Sorting<Element>
    private let maxElementCount: Int

    public init(collection: TaggedCollection<Element>,
                sorting: Sorting<Element> = .shuffled,
                maxElementCount: Int = 3) {
        self.collection = collection
        self.sorting = sorting
        self.maxElementCount = maxElementCount
    }

    public func recommendations(forTag tag: Tag) -> [Element] {
        let elements = collection.elements(taggedWith: tag)
        return Array(sorting.body(elements).prefix(maxElementCount))
    }
}

上述更改的一个真正整洁的副作用是,我们现在可以继续实现不同的排序变体-无论是在我们的库本身,还是在我们的应用项目外部。例如,这是另一个实现,它根据键路径对给定的集合进行排序:

public extension Sorting {
    static func basedOn<V: Comparable>(
        _ keyPath: KeyPath<Element, V>
    ) -> Self {
        .init { set in
            set.sorted {
                $0[keyPath: keyPath] < $1[keyPath: keyPath]
            }
        }
    }
}

有了上面的部分,我们现在可以从单元测试的import语句中删除@testable的前缀,并使用我们的产品代码可以访问的一套完全相同的api来编写我们的测试——像这样:

import TagKit

class RecommendationEngineTests: XCTestCase {
    func testReturningFirstThreeMatchedElements() {
        let articles = (0..<5).map { index in
            Article(
                title: "Article-\(index)",
                tags: ["tag"]
            )
        }

        var collection = TaggedCollection<Article>()
        articles.forEach { collection.add($0) }

        let engine = RecommendationEngine(
            collection: collection,
            sorting: .basedOn(\.title)
        )

        let recommendations = engine.recommendations(forTag: "tag")
        XCTAssertEqual(recommendations, Array(articles[..<3]))
    }
}

一般来说,在开发库的同时使用单元测试是一种“狗粮”我们所有api并找出如何处理各种边缘情况的好方法。因为到最后,如果我们的库很难测试,那么它可能也很难在生产中使用。

Conclusion

虽然本文没有涵盖库设计和开发的每个方面,但我希望它能对如何构建独立的Swift库提供一些有用的见解。

同样重要的是要指出,并非所有的可重用的组件需要实现为单独的库 -有时简单地共享一段逻辑作为内部类或结构已经足够好了-特别是库也是需要管理、更新和维护的依赖。但是,如果有必要的话,将一个完全可重用的系统构建为它自己的库肯定会有很多好处。

原文链接