翻译@API Design Guidelines(Swift API设计指南)
向开发者提供统一完整的使用体验,是Switf 3.0 release的目标之一。其中,API的风格和命名扮演着关键角色。本文通过介绍一系列规范,阐述了开发者如何将自己的代码融入整个Swift生态体系。
目录
- 基础
- 命名
- 意图清晰
- 力求流畅
- 慎用术语
- 惯例
- 一般惯例
- 形参
- 实参标签
- 特别说明
基础
使用时能够清晰表达设计者的意图,是最重要的目标。诸如方法(methods)和属性(properties)之类的实体(Entities)一经声明,就会被重复使用。好的API设计,会让这些实体的使用变得简洁明了。考察一个设计的好坏,仅仅阅读API是不够的;要将其放到实际用例中去,结合上下文,检查含义是否明确。
意图的清晰传达远胜于文字的简洁。虽然Swift代码可以非常紧凑,但尽可能少的使用字符书写代码并非我们的目标。Swift代码中所体现出的简洁,是强类型语言和Swift本身某些节省模版代码的特性产生的附加效果。
为每个API添加注释。添加注释有助于加深对API的理解,从而其设计产生深远影响。所以,别犯懒。
如果你发现很难用简单的语言概括API的功能,那么就说明:API的设计可能是错误的。
ℹ️
- **使用Swift内建的Markdown语法**。
- **以对实体的概括作为开头**。通常,用户通过阅读API的声明和概括注释,就可以完全理解其用途。
```
// 返回一个完全相同的'self',只是元素的顺序完全相反
func reversed() -> ReverseCollection
```
ℹ️
- **专注于概括**,这是注释中最重要的部分。许多注释仅包含高质量的概括,就足以让它们成为优秀的注释。
- **使用句子中最关键的部分**,如果可以,末尾加上句号。不要使用整句。
- **描述一个函数或方法做什么,返回什么**,如果什么都不做,或什么都不返回,则略过。
```
/// 把`newHead`插入到`self`的起始位置
mutating func prepend(_ newHead: Int)
/// 返回一个`List`,以`head`开头,随后是`self`中的元素
func prepending(_ head: Element) -> List
/// 移除并返回`self`中的第一个元素,如果有的话;否则返回`nil`
mutating func popFirst() -> Element?
```
注意:在极少数情况下,例如上面的`popFirst`,概括由多个句子组成,由分号隔开。
- 描述下标访问的内容:
```
/// 访问第`index`个元素。
subscript(index: Int) -> Element { get set }
```
- 描述构造函数创建了什么:
```
/// 创建一个包含`n`个`x`的实例。
init(count n: Int, repeatedElement x: Element)
```
- 其他API,描述所声明的实体是什么。
```
/// 一个支持在任意位置同效率插入/移除元素的集合。
struct List {
/// 位于'self'起始位置的元素,如果'self'为空,则为'nil'
var first: Element?
}
```
- 此外,也可以继续添加一段或多段讨论,同时罗列要点。段落由空行隔开,使用整句。
```
/// 将`items`中每个元素的文字表示写入标准输出。
///
/// 每个元素`x`的文字表示通过表达式`String(x)`生成。
///
///
/// - 参数 separator: 两项之间的文字
/// - 参数 terminator: 末尾的文字
///
/// - 注意: 想要省略末尾的换行符,为`terminator`传入""
///
/// - 其他参考: `CustomDebugStringConvertible`, `CustomStringConvertible`, `debugPrint`。
public func print(_ items: Any..., separator: String = " ", terminator: String = "\n")
```
ℹ️
- 使用公认的[文档符号标记元素](https://developer.apple.com/library/prerelease/mac/documentation/Xcode/Reference/xcode_markup_formatting_ref/SymbolDocumentation.html#//apple_ref/doc/uid/TP40016497-CH51-SW1),为注释添加概括以外的信息。
- 了解[符号命令语法](https://developer.apple.com/library/prerelease/mac/documentation/Xcode/Reference/xcode_markup_formatting_ref/SymbolDocumentation.html#//apple_ref/doc/uid/TP40016497-CH51-SW13),使用公认的项目符号。当下流行的开发工具,例如Xcode,对以下面关键字开头的项目符号做特殊处理:
命名
意图清晰
-
单词的用量以消除歧义为准,以便能够过通过阅读代码理解API含义。
ℹ️
假设有方法移除集合中某个位置的元素。
✅ extension List { public mutating func remove(at position: Index) -> Element } employees.remove(at: x)
如果省略
at
,则会让人误以为在集合中搜索元素x
,并移除;而非位置为x
的元素。❌ emplyees.remove(x) // 歧义:删除的是元素x?
-
省略不需要的单词。在用例中,组成API的每个单词都必须传达相应信息。
ℹ️
准确传达意图,消除歧义,意味着更多的单词;然而,携带重复信息的冗余单词,应该省略。特别是那些单纯重复类型信息的词语。
❌ public mutating func removeElement(_ member: Element) -> Element? allViews.removeElement(cancelButton)
上面的代码中,
Element
在未提供任何有效信息。这个API应修改为:✅ public mutating func remove(_ member: Element) -> Element? allViews.remove(cancelButton) // 更简明
有时,为了消除歧义,不得不重复类型信息;但总的来说,最好描述参数的角色而非类型。更多信息参考下一条规则。
-
根据变量,参数,关联类型的角色为其命名,而非类型。
ℹ️
❌ var string = "Hello" protocol ViewController { associatedtype ViewType : View } class ProductionLine { func restock(from widgetFactory: WidgetFactory) }
单纯重复类型名无助于意图的清晰传达,更不能提升表达性。所以,最好采用能够描述实体在API中所扮演的角色的命名:
✅ var greeting = "Hello" protocol ViewController { associatedtype ContentView : View } class ProductionLine { func restock(from supplier: WidgetFactory) }
对于关联类型来说,如果其类型名就是其在协议中扮演的角色,那么在后面加上
Type
,防止冲突:protocol Sequence { associatedtype IteratorType : Iterator }
-
为弱类型添加补充信息,明确参数的角色。
ℹ️
尤其是当参数类型为
NSObject
,Any
,AnyObject
,或诸如Int
或String
这样的基础类型时,仅靠上下文和类型信息可能不足以传达意图。例如,下面的代码中,方法声明看起来意图还算清晰,但实际使用时却不是这样。❌ func add(_ observer: NSObject, for keyPath: String) grid.add(self, for: graphics) // 语义模糊
为此,可以使用一个名词去描述弱类型参数的角色:
✅ func addObserver(_ observer: NSObject, forKeyPath path: String) grid.addObserver(self, forKeyPath: graphics) // 清晰
力求流畅
-
方法和函数被调用时,最好能够组成英语短语。
ℹ️
✅ x.insert(y, at: z) // "x, insert y at z" x.subViews(havingColor: y) // "x's subviews having color y" x.capitalizingNouns() // x, capitalizing nouns
❌ // 无法组成英语句子 x.insert(y, position: z) x.subViews(color: y) x.nounCapitalize()
为了流畅,可以牺牲后面几个参数的命名质量(即除了第一个或第二个参数,随后的命名不必严格遵守英语语法),只要这些它们不是整个API语义的关键所在:
AudioUnit.instantiate(with: description, options: [.inProcess], completionHandler: stopProgressbar)
工厂方法以"make"开头。例如,
x.makeIterator()
。-
调用构造函数和工厂方法时,组成的短语不包含第一个参数名。例如,
x.makeWidget(cogCount: 47)
。ℹ️
例如,下面调用所组成的短语,不包含第一个参数名:
✅ let foreground = Color(red: 32, green: 64, blue: 128) let newPart = factory.makewidget(gears: 42, spindles: 14)
下面的例子中,API作者试图将第一个参数名也纳入短语。
❌ let foreground = Color(havingRGBValuesRed: 32, green: 64, andBlue: 128) let newPart = factory.makeWidget(havingGearCount: 42, andSpindleCount: 14)
实际上,本规则连同实参标签的相关规则,意味着第一个参数一般都会有标签,除非执行的是值保留类型转换操作。
let rgbForeground = RGBColor(cmykForeground)
-
根据函数和方法的附加效果为其命名。
没有附加效果的(不可变方法),读起来应该像名词短语,例如,
x.distance(to: y)
,i.successor()
有附加效果的(可变方法),读起来应该像祈使动词,例如,
print(x)
,x.sort()
,x.append(y)
-
可变/不可变方法的命名要成对出现。一个可变方法通常都有一个不可变方法与之对应,二者的语义相近,区别在于前者直接更新实例,后者返回一个新值。
- 当一项操作恰好能够被一个动词描述时,使用动词原型为可变方法命名;使用动词的过去分词或现在分词,为不可变方法命名。
可变方法 | 不可变方法
------------- | -------------
x.sort() | z = x.sorted()
x.append(y) | z = x.appending(y)
ℹ️
- 命名不可变方法,最好使用过去分词,即后缀"ed":
```
/// 原地倒序`self`
mutating func reverse()
/// 返回一个倒序后的`self`的副本
func reversed() -> Self
...
x.reverse()
let y = x.reversed()
```
- 如果由于动词后面直接跟名词,无法添加"ed"时,则使用现在分词命名不可变方法,即后缀"ing"。
```c
/// 移除`self`中的所有换行符
mutating func stripNewlines()
/// 返回一个`self`副本,移除了所有换行符
func strippingNewlines() -> String
...
s.stripNewlines()
let oneLine = t.strippingNewlines()
```
- 当一项操作恰好能够被一个名词描述时,使用名词为不可变方法命名;加前缀"form",为可变方法命名。
可变方法 |不可变方法
------------- | -------------
y.formUnion(z) | x = y.union()
c.formSuccessor(&i) | j = c.successor(i)
- 作为不可变方法,如果返回布尔值的方法或属性,读起来应该像是对被调用对象的断言。例如,
x.isEmpty
,line1.intersects(line2)
。 -
描述事物的协议,读起来应该像名词(例如,
Collection
)。 -
描述能力的协议,应该使用后缀
able
,ible
或ing
(例如,Equatable
,ProgressReporting
)。 - 其他类型,属性,变量以及常量的名称,读起来应该像名词。
慎用术语
Term of Art 名词 - 在某个领域或行业内,有着明确特殊含义的词或短语。
避免使用晦涩的术语,特别是如果有一个常见词汇能够表达同样意义时。例如,如果”皮肤“能够满足表述需求,就不要使用“表皮”。术语是重要的交流工具,但应该仅在其他表述方式会丢失关键意义时使用。
-
如果使用术语,则应该紧扣其公认的既定含义。
ℹ️
使用术语而非常见词汇的唯一原因,是其能够准确表述事物,否则含义便会模糊,甚至造成歧义。因此,API应该严格按照既定含义使用术语。
- 不要激怒专家:对术语熟悉的人将会感到惊讶甚至愤怒,如果他发现API的设计者为一个术语发明了新的含义。
- 不要迷惑新手:尝试学习术语的人一般都会通过网络搜索的方式查询术语的含义。
-
避免缩写。缩写,特别是非标准缩写,实际上是术语,因为对缩写的理解建立在正确将其翻译为全称的基础上。
API使用到的缩写,其含义必须能够在互联网上轻松找到。
-
遵循先例。如果现有术语已经能够完美表述一个含义,那么就不要为了迁就新手,打破这种先例。
ℹ️
例如,最好将一个连续的数据结构命名为
Array
,而非更简单的List
,虽然对于新手来说,后者的含义更容易掌握。数组是现代计算机科学的基础数据结构,所以每个程序员都知道——或者很快就会学到——什么是数组。使用大多数程序员所熟悉的术语,这样,即便有问题,互联网和其他人也能够提供帮助。在某些特定的编程领域,例如数学, 诸如
sin(x)
这样已经广为人们所接受的术语,要比诸如verticalPositionOnUnitCircleAtOriginOfEndOfRadiusWithAngle(x)
这样解释性的命名好的多。注意,这里先例打破了避免缩写的规则:尽管单词的完整拼写是sine
,但"sin(x)"已经被程序员使用了数十年,在数学中更是数百年。
惯例
一般惯例
对于复杂度不是O(1)的计算型属性,要通过注释特别说明。人们总是认为属性访问不牵扯大量计算,因为访问的是实例变量(存储型属性)。当这个惯例被打破时,有必要提醒他们。
-
优先选择方法或属性,而非函数。后者只在下述情况中使用:
ℹ️
-
没有明显的
self
:min(x, y, z)
-
函数是不受限的范型函数:
print(x)
-
函数的语法在特定领域中约定俗成:
sin(x)
-
-
遵守大小写的惯例。类型和协议名称使用
以大写字母开头的驼峰命名法
。其他名称使用以小写字母开头的驼峰命名法
。ℹ️
对于那些在美语中全部以大写的形式出现的首字母缩写,要根据大小写惯例统一大写或小写:
var utf8Bytes: [UTF8.CodeUnit] var isRepresentableAsASCII = true var userSMTPServer: SecureSMTPServer
其他缩写作为普通单词对待:
var radarDetector: RadarScanner var enjoyScubaDiving = true
-
如果若干方法的基本含义一致,或者干脆是在不同的领域中使用的同类方法,那么它们可以共享一个基础方法名。
ℹ️
例如,下面的命名方式是恰当的,因为这些方法本质上是在做同一件事:
✅ extension share { /// 当且仅当'other'位于'self'的区域内,返回'true' func contains(_ other: Point) -> Bool { ... } /// 当且仅当'other'完全位于'self'的区域内,返回'true' func contains(_ other: Shape) -> Bool { ... } /// 当且仅当'other'位于'self'的区域内,返回'true' func contains(_ other: LineSegment) -> Bool { ... } }
由于几何类型和集合类型所处的领域不同,下面的命名方式也是可以的:
✅ extension Collection where Element : Equatable { /// 当且仅当'self'包含一个和'sought'相同的元素时,返回'true' func contains(_ sought: Element) -> Bool { ... } }
然而,下面的
index
方法含义各不相同,应区别命名:❌ extension DataBase { /// 重新建立数据库的索引 func index() { ... } /// 返回对应表的第'n'行 func index(_ n: Int, inTable: TableID) -> TableRow { ... } }
最后,避免“重载返回类型”,因为这样会导致
类型推断系统
产生歧义。❌ extension Box { /// 返回'self'中保存的'Int',如有,否则返回'nil' func value() -> Int? { ... } /// 返回'self'中保存的'Int',如有,否则返回'nil' func value() -> String? { ... } }
形参
func move(from start: Point, to end: Point)
-
选择具有说明作用的形参名。虽然形参名在函数或方法调用时并不出现,但它们扮演着重要的解释作用。
ℹ️
选择能够提升文档可读性的名称。下面的例子中,形参名使得文档读起来自然流畅:
✅ /// 返回一个`Array`,包含`self`中所有满足`predicate`的元素 func filter(_ predicate: (Element) -> Bool) -> [Generator.Element] /// 将给定的`subRange`中的元素替换为`newElements` mutating func replaceRange(_ subRange: Range, with newElements: [E])
而下面的文档读起来很别扭,不符合文法:
❌ /// 返回一个`Array`,包含`self`中所有满足`includedInResult`的元素 func filter(_ includedInResult: (Element) -> Bool) -> [Generator.Element] /// 将`r`所指代的范围内的元素替换为`with`中的内容 mutating func replaceRange(_ r: Range, with: [E])
-
利用默认参数简化用例。如果参数有一个常用值,就可以为其提供一个默认参数。
ℹ️
通过隐藏无关信息,默认参数能够提升可读性。例如:
❌ let order = lastName.compare(royalFamilyName, options [], range: nil, locale: nil)
通过默认参数,化繁为简:
✅ let order = lastName.compare(royalFamilyName)
默认参数通常适用于方法族, 大大减轻了理解API的负担。
✅ extension String { public func compare (_ other: String, options: CompareOptions = [], range: Range? = nil, locale: Locale? = nil) -> Ordering }
上述方法看起来可能没那么简单,但和下面比较呢:
❌ 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 }
每个方法都要分开注释;为了选择使用哪一个,用户必须全部理解,并搞清它们之间的关系。有时,这些关系让人感到诧异,例如
foo(bar: nil)
和foo()
的作用并不总是相同——试图在文档中寻找这种微妙区别会变是很恶心的。利用默认参数,简化为一个方法,极大提升了用户体验。 将具有默认参数的参数项放到方法最后。从语义上来说,没有默认参数的参数项对于方法来说更为重要,并且可以在调用时提供稳定的格式。
实参标签
func move(from start: Point, to end: Point)
x.move(from: x, to: y)
如果不需要区分参数,则可以省略所有实参标签。例如:
min(number1, number2)
,zip(sequence1, sequence2)
。-
如果构造函数进行的是值保留类型转换操作,则省略第一个实参标签。例如:
Int64(someUint32)
。ℹ️
第一个参数是要转换的内容。
extension String { /// 根据`radix`,将`x`转换为文字表示。 init(_ x: BigInt, radix: Int = 10) // 注意最开始的下划线 } text = "The value is: " text += String(veryLargeNumber) text += " and in hexadecimal, it's" text += String(veryLargeNumber, radix: 16)
而对于“值省略类型转换”来说,最好使用第一个标签描述所省略的内容。
extension Uint32 { /// 根据`value`创建实例 init(_ value: Int16) // 值保留,所以没有实参标签 /// 根据`source`的低32位创建实例 init(truncating source: Uint64) /// 根据`valueToApproximate`最接近的近似值,创建实例 init(saturating valueToApproximate: UInt64) }
>值保留类型转换是[单态](https://en.wikipedia.org/wiki/Monomorphism),即一个值对应一个结果。例如,将一个`Int8`值转换为一个`Int64`值属于这种操作,因为不同的`Int8`值都对应不同的`Int64`值。反过来就不是:`Int64`可能的值要比`Int8`能够表示的值多得多。
注意:能否追溯原始值,同是不是值保留类型转换没有联系。
-
如果第一个参数参与组成介词短语,那么要使用标签。实参标签一般起介词的作用。例如,
x.removeBoxed(havingLength: 12)
。ℹ️
一个例外:如果前两个或多个参数共同组成一个抽象概念。
❌ a.move(toX: b, y: c) a.fade(fromRed: b, green: c, blue: d)
这时,将介词提前,放在基础名中,概念会更清晰。
✅ a.moveTo(x: b, y: c) a.fadeFrom(red: b, green: c, blue: d)
-
否则,如果第一个参数组成的是一个常规短语,则省略标签,在基础名中补全短语。例如,
x.addSubView(y)
。ℹ️
本规则意味着如果第一个参数不组成任何短语,应该给其加上标签。
✅ view.dismiss(animated: false) let text = words.split(maxSplits: 12) let studentByName = students.sorted(isOrderedBefore: Student.namePrecedes)
注意,短语传达的含义要正确。下述短语的含义错误。
❌ view.dismiss(false) // 不要dismiss?还是dismiss一个布尔值? words.split(12) // 查分一个数字12?
另外,有默认值的参数可以省略,因此这些参数不参与短语的组成,所以它们总是有标签。
其他参数都需要加上标签。
特别说明
-
如果API使用使用了闭包和元组,则为闭包参数和元组成员添加标签。
ℹ️
这些标签具有解释作用,可以在编写注释时引用,还可以用来访问元组成员。
/// 确保至少分配了`requestedCapacity`个元素的存储空间。 /// /// 如果需要更多存储空间,`allocate`会被调用,分配`byteCount`个最大对齐字节。 /// /// - 返回 /// - reallocated: 当且仅当新的内存非配成功,返回`true` /// - capacityChanged: 当且仅当`capacity`被更新时,返回`true` mutating func ensureUniqueStorage(minimumCapacity requestedCapacity: Int, allocate: (byteCount: Int) -> UnsafePointer<void>) -> (reallocated: Bool, capacityChanged: Bool)
虽然严格来说闭包的参数名实际上是实参标签,但API设计者应该按照形参名那样去挑选和使用它们。在函数中调用闭包时,构成的短语与一般方法调用时一样,也不包含第一个标签。(译者:经常是,上述函数的写法已经不可用,Swift3.01中不允许闭包包含argument label,所以,上述闭包的正确写法应该是:
allocate: (_ byteCount: Int) -> Int
)allocate(byteCount: newCount * elementSize)
-
使用弱类型时,避免重载产生歧义。例如,
Any
,AnyObject
及不受限的范型参数。ℹ️
考虑如下一组重载方法:
❌ struct Array { /// 在`self.endIndex`中插入`newElement`。 public mutating func append(_ newElement: Element) /// 将`newElements`中的内容按序插入`self.endIndex`中。 public mutating func append(_ newElement: S) where S.Generator.Element == Element }
这些方法从语义上构成一个方法族,参数的类型乍一看也有很大区别。但是,如果
Element
的类型是Any
,那么一个Element
就和一组Element
有着相同的类型(即一个和一组都是Any
)。❌ var values: [Any] = [1, "a"] values.append([2, 3, 4]) // 结果是[1, "a", [2, 3, 4]]还是[1, "a", 2, 3, 4]?
为了消除歧义,重新命名第二个方法,赋予其更多含义。
✅ struct Array { /// 在`self.endIndex`中插入`newElement`。 public mutating func append(_ newElement: Element) /// 将`newElements`中的内容按序插入`self.endIndex`中。 public mutating func append(contentsOf newElement: S) where S.Generator.Element == Element }
注意第二个方法的实参标签是如何同文档呼应的。这时,通过书写文档,API设计者能够注意到潜在的问题。