[译] Swift 模块中的 API 污染

Swift 模块中的 API 污染

当你将一个模块导入 Swift 代码中时,你但愿它们产生的效果是叠加的,也就是说,你不须要什么代价就可使用新功能,仅仅 app 的大小会增长一点。前端

导入 NaturalLanguage 框架,你的 app 就能够 肯定文本的语言。导入 CoreMotion,你的应用能够 响应设备方向的变化。可是若是进行语言本地化的功能干扰到手机检测设备方向的功能,那就太难以想象了。android

虽然这个特殊的例子有点极端,但在某些状况下,Swift 依赖库能够改变你 app 的一些行为方式,即便你不直接使用它也是如此ios

在本周的文章中,咱们将介绍导入模块能够静默更改现有代码行为的几种方法,并提供当你做为一个 API 生产者有关如何防止这种状况的发生以及做为 API 调用者如何减轻这种状况带来的影响的一些建议。git

模块污染

这是一个和 <time.h> 同样古老的故事:有两个东西叫作 Foo,而且编译器须要决定作什么。github

几乎全部具备代码重用机制的语言都必须以某种方式处理命名冲突。在 Swift 里,你可使用显式的声明来区分模块 A 中的 Foo 类型(A.Foo)和模块 B 中的 Foo 类型(B.Foo)。可是,Swift 具备一些独特的风格会致使编译器忽视其余可能存在的歧义,这会致使导入模块时对现有行为进行更改。express

在本文中,咱们使用 “污染” 这个术语来描述由导入编译器未显现的 Swift 模块引发的这种反作用。咱们并不彻底认可这个术语,因此若是你有其余更好的任何建议,请 联系咱们swift

运算符重载

在 Swift 里,+ 运算符表示两个数组链接。一个数组加上另外一个数组产生一个新数组,其中前一个数组的元素后面跟着后一个数组的元素。后端

let oneTwoThree: [Int] = [1, 2, 3]
let fourFiveSix: [Int] = [4, 5, 6]
oneTwoThree + fourFiveSix // [1, 2, 3, 4, 5, 6]
复制代码

若是咱们查看运算符在 标准库中的声明,咱们能够看到它已经提供了在 Array 的 extension 中:api

extension Array {
    @inlinable public static func + (lhs: Array, rhs: Array) -> Array {...}
}
复制代码

Swift 编译器负责解析对其相应实现的 API 调用。若是调用与多个声明匹配,则编译器会选择最具体的声明。数组

为了阐释这一点,请考虑在 Array 上使用如下条件扩展,它定义了 + 运算符,以便对元素遵循 Numeric 的数组执行加法运算:

extension Array where Element: Numeric {
    public static func + (lhs: Array, rhs: Array) -> Array {
        return Array(zip(lhs, rhs).map {$0 + $1})
    }
}

oneTwoThree + fourFiveSix // [5, 7, 9] 😕
复制代码

由于 extension 中 Element: Numeric 规定了数组元素必须为数字,这比标准库里没有进行显示的声明更加具体,因此 Swift 编译器在遇到元素为数字的数组时会将 + 解析为咱们定义的以上函数。

如今这些新语义也许能够接受的,确实它们更加可取,但得在你知道它们怎么用的时候才行。问题是若是你像 import 同样导入这样一个模块,你能够在不知情的状况下改变整个应用程序的行为。

然而这个问题不只局限于语义问题。

函数的阴影

在 Swift 中,函数声明时能够为参数指定默认值,使这些参数在调用时也能够不传入值。例如,top-level 下的函数 dump(_:name:indent:maxDepth:maxItems:) 有特别多的参数:

@discardableResult func dump<T>(_ value: T, name: String? = nil, indent: Int = 0, maxDepth: Int = .max, maxItems: Int = .max) -> T
复制代码

可是多亏了参数默认值,你只须要在调用的时候指定第一个参数:

dump("🏭💨") // "🏭💨"
复制代码

但是当方法签名重叠时,这种便利来源可能会变得比较混乱。

假设咱们有一个模块,你并不熟悉内置的 dump 函数,所以定义了一个 dump(_:) 来打印字符串的 UTF-8 代码单元。

public func dump(_ string: String) {
    print(string.utf8.map {$0})
}
复制代码

在 Swift 标准库中声明的 dump 函数在其第一个参数(其实是“Any”)中采用了一个泛型 T 参数。由于 String 是一个更具体的类型,因此当有更具体的函数声明时,Swift 编译器将会选择咱们本身的 dump(_:) 方法。

dump("🏭💨") // [240, 159, 143, 173, 240, 159, 146, 168]
复制代码

与前面的例子不一样的是,与之竞争的声明中存在任何歧义并不彻底清楚。毕竟开发人员有什么理由认为他们的 dump(_:) 方法可能会以任何方式与 dump(_:name:indent:maxDepth:maxItems:) 相混淆呢?

这引出了咱们最后的例子,它多是最使人困惑的...

字符串插值污染

在 Swift 中,你能够经过在字符串文字中的插值来拼接两个字符串,做为级联的替代方法。

let name = "Swift"
let greeting = "Hello, \(name)!" // "Hello, Swift!"
复制代码

从 Swift 的第一个版本开始就是如此。自从 Swift 5 中新的 ExpressibleByStringInterpolation 协议的到来,这种行为再也不是理所固然的。

考虑 String 的默认插值类型的如下扩展:

extension DefaultStringInterpolation {
    public mutating func appendInterpolation<T>(_ value: T) where T: StringProtocol {
        self.appendInterpolation(value.uppercased() as TextOutputStreamable)
    }
}
复制代码

StringProtocol 遵循了 一些协议,其中包括 TextOutputStreamableCustomStringConvertible,使其比 经过 DefaultStringInterpolation 声明的 appendInterpolation 方法 更加具体,若是没有声明,插入 String 值的时候就会调用它们。

public struct DefaultStringInterpolation: StringInterpolationProtocol {
    @inlinable public mutating func appendInterpolation<T>(_ value: T) where T: TextOutputStreamable, T: CustomStringConvertible {...}
}
复制代码

再一次地,Swift 编译器的特异性致使咱们预期的行为变得不可控。

若是 app 中的任何模块均可以跨越访问之前别模块中的声明,这就会更改全部插值字符串值的行为。

let greeting = "Hello, \(name)!" // "Hello, SWIFT!"
复制代码

不能否认,这最后一个例子有点作做,实现这个函数时必须尽全力确保其实非递归。但请注意这是一个不明显的例子,这个例子更可能真实地发生在现实应用场景中。

鉴于语言的快速迭代,指望这些问题在将来的某个时刻获得解决并不是没有道理。

可是在此期间咱们要作什么呢?如下是做为 API 使用者和 API 提供者管理此行为的一些建议。

API 使用者的策略

做为 API 使用者,你在不少方面都会受到导入依赖项所施加的约束。它确实 不该该 是你要解决的问题,但至少有一些补救措施可供你使用。

向编译器添加提示

一般,让编译器按照你的意愿执行操做的最有效方法是将参数显式地转换为与你要调用的方法匹配的类型。

以咱们以前的 dump(_:) 方法为例:经过从 String 向下转换为 CustomStringConvertible,咱们可让编译器解析调用以使用标准库函数。

dump("🏭💨") // [240, 159, 143, 173, 240, 159, 146, 168]
dump("🏭💨" as CustomStringConvertible) // "🏭💨"
复制代码

范围导入声明

上一篇文章 中所述,你可使用 Swift 导入声明来解决命名冲突。

不幸的是,对模块中某些 API 的导入范围目前不会阻止扩展应用于现有类型。也就是说,你不能只导入 adding(_:) 方法而不导入在该模块中声明 + 运算符的重载。

Fork 依赖库

若是全部其余方法都失败了,你能够随时将问题掌握在本身手中。

若是你对第三方依赖库不满意,只需 fork 它的源代码,而后去除你不想要的东西再使用它。你甚至能够尝试让他们上游作出一些改变。

不幸的是,这种策略不适用于闭源模块,包括 Apple 的 SDK 中的模块。“雷达或GTFO”。我想你能够试试 “Radar or GTFO”

API 提供者的策略

做为开发 API 的人,你有在设计决策中慎重考虑的最终责任。当你考虑你的操做的影响时,请注意如下事项:

对使用泛型约束更加谨慎

未指定的 <T> 泛型约束与 Any 相同。若是这样作有意义,请考虑使你的约束更具体,以减小与其余不相关声明重叠的可能性。

从便利性中分离核心功能

做为普适规则,代码应组成模块而负责单一的责任。

若是这样作是有意义的,请考虑模块中类型和方法提供的打包功能,你须要将该模块与你为内置类型提供的任何扩展分开,以提升其可用性。在能够从模块中挑选和选择咱们想要的功能以前,最好的解决方案是让调用者能够选择在可能致使下游问题的状况下选择性地加入功能。

Avoid Collisions Altogether彻底避免碰撞

固然,若是你可以知情地避免冲突,那就太棒了...可是这会进入整个 “不知之不知”,咱们如今没有时间讨论认识论。

因此如今让咱们假设,若是你知道某些事情可能会产生冲突,一个好的选择是彻底避免使用它。

例如,若是你担忧某人可能会对你重载基本算术运算符感到不满,你能够选择另外一个,好比 .+

infix operator .+: AdditionPrecedence

extension Array where Element: Numeric {
    static func .+ (lhs: Array, rhs: Array) -> Array {
        return Array(zip(lhs, rhs).map {$0 + $1})
    }
}

oneTwoThree + fourFiveSix // [1, 2, 3, 4, 5, 6]
oneTwoThree .+ fourFiveSix // [5, 7, 9]
复制代码

做为开发者,咱们可能不太习惯于考虑咱们决策的深远影响。代码是看不见的,没有重量的,因此很容易忘记它在咱们发布后忘记它的存在。

可是在 Swift 中,咱们的决策产生的影响超出了人们的直接理解,因此考虑咱们如何履行 API 管理员的责任这一点很是重要。

NSMutableHipster

若是你有其余问题,欢迎给咱们提 Issuespull requests

这篇文章使用 Swift 5.0.。你能够在 状态页面 上查找全部文章的状态信息。

若是发现译文存在错误或其余须要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可得到相应奖励积分。文章开头的 本文永久连接 即为本文在 GitHub 上的 MarkDown 连接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索