注:與《Swift 3 API 設計準則》的區別在於,那片文章只是一個概覽說明,此文章是蘋果官方釋出的 API 設計準則,雖然還處於樣稿階段,但是有一定的參考價值。
注意:本 API 指南是 Swift 3.0 工作的一部分,還只是一份樣例。
基本原則
- 清晰明瞭地使用程式碼是您最重要的目標。因為讀程式碼的次數遠遠多於寫程式碼的次數。
- 程式碼直觀遠比精簡更重要。雖然 Swift 程式碼可以寫得很緊湊,但是這並不意味著我們希望您用最少的字元寫出最短的程式碼出來。出現在 Swift 中的精簡程式碼只不過是強型別系統的副效應(side-effect),以及為了減少樣板程式碼(boilerplate)的一個正常特性罷了。
- 寫一個漂亮的文件註釋,通過 Swift 的 Markdown 分支語法為每一個方法或者屬性撰寫註釋。在理想情況下,應該能夠通過其字面意思理解 API 的作用,並且註釋最好在一到兩句話之間就可以闡述:
1 2 3 4 5 |
/// Returns the first index where `element` appears in `self`, /// or `nil` if `element` is not found. /// /// - Complexity: O(`self.count`). public func indexOf(element: Generator.Element) -> Index? { |
通過撰寫文件可以幫助您更好地分析 API 的設計方式,文件越早編寫,產生的影響也就越大,因此請保持寫文件註釋的好習慣。
如果您發現很難用簡單的術語來描述您的 API 功能,那麼您很可能設計了錯誤的 API。
命名
提倡清晰的用法
- 包含所有必需的片語以避免歧義,當人們讀程式碼的時候,命名就顯得十分重要。
舉個例子,比如說有這樣一個方法,它用來在一個集合內移除給定位置的元素。
1 |
public mutating func removeAt(position: Index) -> Element |
使用方法如下:
1 |
employees.removeAt(x) |
如果我們省略了這個方法名中 At
這個詞,就會暗示讀者這個方法是用來檢索並移除 x
元素的,而不是使用 x
來表明要移除元素的位置。
1 |
employees.remove(x) // 有歧義:是移除元素 x 嗎? |
- 省略多餘的片語。命名中的每一個片語都應該表達關於使用方法的重要資訊。
多餘的片語只用來澄清意圖或者消除歧義,但如果讀者已經明白這些資訊的話,那麼這些片語就會變得十分冗餘,應該被省略掉。通常情況下,應該要省略掉僅僅只重複型別資訊的片語:
1 2 3 |
public mutating func removeElement(member: Element) -> Element? allViews.removeElement(cancelButton) |
在這個例子當中,片語 Element
在這個呼叫語句中並沒有增添任何有意義的資訊。這樣的 API 會顯得更好一些:
1 2 3 |
public mutating func remove(member: Element) -> Element? allViews.remove(cancelButton) // 更為直觀 |
有時候,為了避免歧義的出現,重複型別資訊是非常有必要的,但是通常情況下使用片語來描述引數的作用
會更好一些,而不是描述它的型別。下一點會對此進行詳細描述。
- 對弱型別(Weak Type)資訊進行補充是必要的,這樣可以清晰描述引數的作用。
當引數型別是弱型別,尤其是 NSObject
、Any
、AnyObject
或者是諸如Int
、String
之類的基本型別的時候,型別資訊和使用點的上下文資訊可能就不能表達完整的意圖。在下面這個例子中,函式宣告的意圖十分清晰,但是使用的時候作用就會很不明確。
1 2 3 |
func add(observer: NSObject, for keyPath: String) grid.add(self, for: graphics) // 不明確的表達 |
為了保證清晰度,在每個弱型別引數的前面加上描述其作用的名詞:
1 2 |
func addObserver(_ observer: NSObject, forKeyPath path: String) grid.addObserver(self, forKeyPath: graphics) // 直觀的作用 |
遵循英文文法
- Mutating 方法應該以祈使句的形式命名,例如:
x.reverse()
、x.sort()
、x.append(y)
。 - 儘可能讓非 Mutating 方法的以名詞短語的形式命名,例如:
x.distanceTo(y)
、i.successor()
。
如果沒有合適的名詞短語的話,那麼用命令式動詞替代也是可以的:
1 |
let firstAndLast = fullName.split() // 這是可以的 |
- 當 Mutating 方法是用一個動詞命名的話,那麼命名其對應的非 Mutating 的方法的時候,可以根據 “ed/ing” 的語法規則來進行命名,例如:
x.sort()
和x.append(y)
的非 Mutating 版本應該是x.sorted()
以及x.appending(y)
。
通常情況下,Mutating 方法會擁有一個非 Mutating 的變體方法(variant),返回和方法作用域相同或者相似的型別。
- 通常使用動詞的過去式(通常在動詞末尾新增”ed”)來命名非 Mutating 的變體:
1 2 3 4 5 6 7 8 |
/// Reverses `self` in-place. mutating func reverse() /// Returns a reversed copy of `self`. func reversed() -> Self ... x.reverse() let y = x.reversed() |
- 如果動詞擁有一個直接賓語的話,那麼新增”ed”將不符合英文文法,這時候應該使用動詞的動名詞形式(通常在動詞末尾新增”ing”)來命名非 Mutating 的變體:
1 2 3 4 5 6 7 8 |
/// Strips all the newlines from `self` mutating func stripNewlines() /// Returns a copy of `self` with all the newlines stripped. func strippingNewlines() -> String ... s.stripNewlines() let oneLine = t.strippingNewlines() |
- 非 Mutating 的布林方法和屬性應該以關於作用域斷言(assertions)的形式命名,例如,
x.isEmpty
、line1.intersects(line2)
。 - 描繪某個類特徵的協議應該以名詞的形式命名(例如
Collection
)。描繪某個類作用的協議應該以able
、ible
或者ing
字尾的形式命名(例如Equatable
、ProgressReporting
)。 - 其餘型別、屬性、變數以及常量應該以名詞的形式命名。
善用術語
專業術語(Term of Art):名詞 – 描述特定領域或者行業中某一準確、特殊意義的單詞或者片語。
- 避免使用晦澀的術語。如果有一個同樣能很好地傳遞相同意思的常用詞的話,那麼請使用這個常用詞。如果用 “skin”(皮膚) 就能很好地表達您的意思的話,那麼請不要使用 “epidermis”(表皮)。專業術語是一個必不可少的交流工具,但是應該僅在需要表達關鍵資訊的時候使用,不然的話就不能達到交流的目的了。
- 遵循公認的涵義。如果在使用專業術語的話,請使用最常見的那個意思。
使用術語而不是使用常用詞的唯一理由是:需要精確描述某物,否則就可能會導致涵義模糊或者不清晰。因此,API 應當嚴格地使用術語,秉承該術語所公認的涵義。
- 不要試圖新造詞意:任何熟悉此術語的人在看到您新造詞意的時候都會感到非常驚愕,並且很可能會被這種做法所惹怒。
- 不要迷惑初學者:每個新學此術語的人都會去網上查詢該術語的意思,他們看到的會是其傳統的涵義。
- 避免使用縮寫。縮寫,尤其是非標準的縮寫形式,實際上也是“術語”的一部分,因為理解它們的涵義需要建立在能夠將其還原為原有形式的基礎上。
任何您使用的縮寫和其原意都應當能夠簡單地被網路檢索出來。
- 循規蹈矩:不要為了讓初學者能更好理解,就要以破壞既有文化的代價來優化術語。
一個資料連續的資料結構最好應該命名為 Array
而不是使用諸如 List
之類的簡單形式,即使初學者可能更容易理解 List
的涵義。Array 這個單詞在現代電腦科學當中是一個基礎結構,因此每一位程式設計師都知道(或者即將學到)這個單詞的意思。使用大多數程式設計師都熟悉的術語,這樣也可以提高初學者網路搜尋和提問成功率。
在某些特定的程式設計領域中,比如說數學程式設計,有一個廣泛使用的術語 sin(x)
通常會以一種解釋型的術語表達:
1 |
verticalPositionOnUnitCircleAtOriginOfEndOfRadiusWithAngle(x) |
注意在本例當中,原先的術語 sin
比這個語句更能夠避免歧義:即便其完整的單詞應該是 sine
,但是由於幾十年來,”sin(x)” 一直被廣大程式設計師沿用,並且數學家也用了幾個世紀,因此應該使用 sin
而不是那串冗餘的程式碼。
約定
一般約定
- 任何複雜度不為 O(1)的計算型屬性都應當將複雜度標註出來。由於人們可以將屬性儲存為內部模型,因此他們會通常假定屬性訪問不會涉及到任何重要的計算。因此,當您的屬性訪問涉及到複雜計算的話,請給人們予以提示。
- 優先考慮方法和屬性,而不是自由函式。自由函式(Free Function)只在幾種特殊情況下才能使用:
- 當沒有明顯的
self
物件的時候:
1 |
min(x, y, z) |
- 當函式是一個不受約束的泛型(unconstrained generic)的時候:
1 |
print(x) |
- 當函式語法是某個領域中公認的符號時:
1 |
sin(x) |
- 遵循大小寫約定:型別、協議和列舉的命名應該是
UpperCamelCase
的形式,其他所有的命名全部是lowerCamelCase
的形式。 - 當多個方法擁有相同的基礎含義的時候,可以使用相同的名字(過載),特別是對於有不同的引數型別,或者在不同的作用域範圍內的方法。
例如,下面的表示方法是提倡的,因為這些方法本質上做的都是相同的事情:
1 2 3 4 5 6 7 8 9 10 |
extension Shape { /// Returns `true` iff `other` is within the area of `self`. func contains(other: Point) -> Bool { ... } /// Returns `true` iff `other` is entirely within the area of `self`. func contains(other: Shape) -> Bool { ... } /// Returns `true` iff `other` is within the area of `self`. func contains(other: LineSegment) -> Bool { ... } } |
此外,由於幾何型別和集合型別擁有不同的作用域,因此在相同程式中使用過載也是允許的:
1 2 3 4 5 |
extension Collection where Element : Equatable { /// Returns `true` iff `self` contains an element equal to /// `sought`. func contains(sought: Element) -> Bool { ... } } |
然而,下面這幾個 index
方法都擁有不同的詞義,因此應該用不同的名字進行命名:
1 2 3 4 5 6 7 |
extension Database { /// Rebuilds the database's search index func index() { ... } /// Returns the `n`th row in the given table. func index(n: Int, inTable: TableID) -> TableRow { ... } } |
最後,要避免“返回型別的過載”,因為這會導致在型別推斷的時候出現歧義:
1 2 3 4 5 6 7 8 9 |
extension Box { /// Returns the `Int` stored in `self`, if any, and /// `nil` otherwise. func value() -> Int? { ... } /// Returns the `String` stored in `self`, if any, and /// `nil` otherwise. func value() -> String? { ... } } |
引數
- 充分利用引數預設值,這樣可以簡化常用的操作。一般情況下,如果一個引數經常使用某一個值的話,那麼可以考慮將這個值設定為預設值。
預設引數通過隱藏不相關的資訊提高了方法的可讀性。例如:
1 2 |
let order = lastName.compare( royalFamilyName, options: [], range: nil, locale: nil) |
可以簡化為:
1 |
let order = lastName.compare(royalFamilyName) |
通常情況下,預設引數非常適於用在一族相似的方法(method family)當中,因為它能夠幫助人們更好地理解 API 的作用,降低理解難度:
1 2 3 4 5 6 7 |
extension String { /// *...description...* public func compare( other: String, options: CompareOptions = [], range: Range? = nil, locale: Locale? = nil ) -> Ordering } |
上面的這個方法看起來可能比較複雜,但是遠遠比閱讀下面這段程式碼要輕鬆很多:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
extension String { /// *...description 1...* public func compare(other: String) -> Ordering /// *...description 2...* public func compare(other: String, options: CompareOptions) -> Ordering /// *...description 3...* public func compare( other: String, options: CompareOptions, range: Range) -> Ordering /// *...description 4...* public func compare( other: String, options: StringCompareOptions, range: Range, locale: Locale) -> Ordering } |
一個方法族的每個成員都需要為其新增不同的文件,並且還要讓使用者牢記它們的使用方法。使用者需要理解所有的方法才能決定用哪一個才比較合適,並且這樣還會產生一些奇怪的關係——例如,fooWithBar(nil) 和 foo() 並不一定就是同義詞,讓使用者在幾乎相同的文件當中辨別這個細小的區別會使使用者覺得十分枯燥乏味。相反,使用一個帶有預設值的單獨方法可以提供非常良好的使用者體驗。
- 最好將帶有預設值的引數放在引數列表的最後面。不帶預設值的引數對於方法來說更重要,並且當方法呼叫的時候可以提供一個穩定的初始化正規化(initial pattern)。
- 最好遵循 Swift 關於外部引數名(argument labels)的預設約定
換句話說,這意味著:
- 方法和函式的第一個引數不應該有外部引數名
- 方法和函式的其他引數都應該有外部引數名
- 所有構造器(initializer)中的引數都需要有外部引數名
如果每個引數都以下面的形式進行宣告的話,那麼它需要遵循上面的約定,以確定其是否需要使用外部引數名。
1 |
identifier: Type |
當然,存在幾個特例:
- 在構造器當中,應該以全寬(full-width)型別進行轉換,第一個引數應該是轉換源(source of the conversion),並且不應該有外部引數名。
1 2 3 4 5 6 7 8 9 |
extension String { // Convert `x` into its textual representation in the given radix init(_ x: BigInt, radix: Int = 10) // Note the initial separate underscore } text = "The value is: " text += String(veryLargeNumber) text += " and in hexadecimal, it's" text += String(veryLargeNumber, radix: 16) |
對於“特定”型別的轉換,推薦使用外部引數名來描述這個特定型別:
1 2 3 4 5 |
extension UInt32 { init(_ value: Int16) // 拓寬型(widening),無需外部引數名 init(truncating bits: UInt64) init(saturating value: UInt64) } |
- 當所有的引數都能相互配對,以致無法有效區分的時候,這些引數都不應該新增外部引數名。最常見的例子就是
min(number1, number2)
以及zip(sequence1, sequence2)
了。 - 當第一個引數有預設值的時候,應該為之新增一個不同的外部引數名。
1 2 3 4 5 |
extension Document { func close(completionHandler completion: ((Bool) -> Void)? = nil) } doc1.close() doc2.close(completionHandler: app.quit) |
正如您所見,不管是否明確地傳入了引數,這個範例都能夠執行正確地讀取引數並執行。而如果您省略了外部引數名的話,語句呼叫就可能會出現歧義,它可能會將引數認做是”語句”的直接賓語:
1 2 3 4 |
extension Document { func close(completion: ((Bool) -> Void)? = nil) } doc.close(app.quit) // 是要關閉這個退出函式 ? |
如果您將此外部引數名放到方法名當中的話,當使用預設值的時候就會變得很“詭異”:
1 2 3 4 |
extension Document { func closeWithCompletionHandler(completion: ((Bool) -> Void)? = nil) } doc.closeWithCompletionHandler() // 完成後處理什麼呢? |
特別說明
- 要特別小心無約束的多型型別(unconstrained polymorphism)(比如說
Any
、AnyObject
以及其它未加約束的泛型引數),避免過載集(Overload Set)中出現的歧義。
例如,有這樣一個過載集:
1 2 3 4 5 6 7 8 |
struct Array { /// Inserts `newElement` at `self.endIndex`. public mutating func append(newElement: Element) /// Inserts the contents of `newElements`, in order, at /// `self.endIndex`. public mutating func append(newElements: S) } |
這兩個方法雖然都有相同的方法名字,但是它們的第一個引數型別卻是截然不同的。然而,當 Element
的型別是 Any
的時候,單一元素和元素序列的型別就會變成相同的了:
1 2 |
var values: [Any] = [1, "a"] values.append([2, 3, 4]) // [1, "a", [2, 3, 4]] 還是 [1, "a", 2, 3, 4]? |
為了消除歧義,第二個過載方法的命名需要更加明確:
1 2 3 4 5 6 7 8 |
struct Array { /// Inserts `newElement` at `self.endIndex`. public mutating func append(newElement: Element) /// Inserts the contents of `newElements`, in order, at /// `self.endIndex`. public mutating func appendContentsOf(newElements: S) } |
請注意新的方法名稱是如何更好地與文件註釋相匹配的。在這個例子中,編寫文件註釋的行為實際上給 API 的命名帶來了有效的幫助。
- 編寫對工具友好的文件註釋;文件註釋會被自動提取,用以生成富文字格式的公共文件,這些註釋資訊會出現在 Xcode 生成的介面、快速幫助以及程式碼完成當中。
我們的 Markdown 處理器對下列列表中列出的關鍵字進行了特殊的處理:
-Attention: | -Important: | -Requires: |
---|---|---|
-Author: | -Invariant: | -See: |
-Authors: | -Note: | -Since: |
-Bug: | -Postcondition: | -Throws: |
-Complexity: | -Precondition: | -TODO: |
-Copyright: | -Remark: | -Version: |
-Date: | -Remarks: | -Warning: |
-Experiment: | -Returns: |
比起使用這些關鍵詞來說,一個好的簡單註釋更為重要。
如果某個方法的名字加上一行簡單註釋,就已經可以完全表達這個方法的作用的話,那麼可以忽略為每個引數和它的返回值撰寫詳細的文件註釋。比如說:
1 2 |
/// Append `newContent` to this stream. mutating func write(newContent: String) |