[譯] Swift 模組中的 API 汙染

掘金翻譯計劃發表於2019-04-18

當你將一個模組匯入 Swift 程式碼中時,你希望它們產生的效果是疊加的,也就是說,你不需要什麼代價就可以使用新功能,僅僅 app 的大小會增加一點。

匯入 NaturalLanguage 框架,你的 app 就可以 確定文字的語言。匯入 CoreMotion,你的應用可以 響應裝置方向的變化。但是如果進行語言本地化的功能干擾到手機檢測裝置方向的功能,那就太不可思議了。

雖然這個特殊的例子有點極端,但在某些情況下,Swift 依賴庫可以改變你 app 的一些行為方式,即使你不直接使用它也是如此

在本週的文章中,我們將介紹匯入模組可以靜默更改現有程式碼行為的幾種方法,並提供當你作為一個 API 生產者有關如何防止這種情況的發生以及作為 API 呼叫者如何減輕這種情況帶來的影響的一些建議。

模組汙染

這是一個和 <time.h> 一樣古老的故事:有兩個東西叫做 Foo,並且編譯器需要決定做什麼。

幾乎所有具有程式碼重用機制的語言都必須以某種方式處理命名衝突。在 Swift 裡,你可以使用顯式的宣告來區分模組 A 中的 Foo 型別(A.Foo)和模組 B 中的 Foo 型別(B.Foo)。但是,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 中:

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前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章