本文由泊学翻译
摘自:https://github.com/Boxue/swift-api-design-guidelines/blob/master/SE-0023%20swift-api-guidelines.md
API设计原则
第一部分 基本原则
1.1 简以至用
这是最重要的设计目标。方法(method)和属性(property)只被声明一次,却会被无数次反复使用。API的简洁和清晰是要体现在它们被使用的时候。当评估一个设计的时候,仅仅去读它的声明是远远不够的,要反复在它的使用环境里推敲这个设计,并确保它足够简洁;
1.2 明确重于简短
尽管Swift代码可以很简短,但用最少的字符去编程并不是Swift的初衷。Swift中出现的简短代码只是强类型语言带来的一个副作用而已,因为可以在类型约束下推导出很多代码,因此省略了很多不必要的代码模板;
1.3 用文档的风格写注释
在每一个声明前,你应该如此。写文档时获得的灵感可以对你的设计思路有极大启发,所以,不要想着编码后回来补文档。
实际上,如果你无法三三两两就描述出API要完成的任务,你的API也许设计错了。
另外,对于文档注释,还有以下补充说明:
1.3.1 使用[Swift Markdown方言]编写注释
1.3.2 在任何声明之前,(让注释)以一个摘要开始
通常,应该让读者可以根据这个摘要和声明完全理解API的功能。例如:
/// Returns a "view" of `self` containing the same elements in
/// reverse order.
func reversed() -> ReverseCollection
- 专注于写摘要
这是(所有注释部分中)最重要的构成。很多出色的注释文档只有一条出色的摘要。
- 使用一个单一的语句片段
如果可能,尽量避免在摘要中使用一个完整的句子。使用一个表意清晰的语句片段即可,并且使用点号(即 .)表示结束。
- 采用“描述一个函数或方法会做那些操作、返回什么”这样的句式
如果函数只是简单返回内容,就忽略掉做什么的部分;如果函数返回Void
,就忽略返回值的描述,例如:
/// Inserts `newHead` at the beginning of `self`.
mutating func prepend(newHead: Int)
/// Returns a `List` containing `head` followed by the elements
/// of `self`.
func prepending(head: Element) -> List
/// Removes and returns the first element of `self` if non-empty;
/// returns `nil` otherwise.
mutating func popFirst() -> Element?
当然,popFirst
方法出现了一些例外,我们使用了两句话描述了摘要。当遇到这种情况时,使用分号分隔开多个语句。
- 描述一个下标具体访问的元素类型,例如:
/// Accesses the `index`th element.
subscript(index: Int) -> Element { get set }
- 描述一个
init
方法究竟创建了什么**,例如:
/// Creates an instance containing `n` repetitions of `x`.
init(count n: Int, repeatedElement x: Element)
- 对于其它的声明来说,描述它们是什么,例如:
/// A collection that supports equally efficient insertion/removal
/// at any position.
struct List {
/// The element at the beginning of `self`, or `nil` if self is
/// empty.
var first: Element?...
1.3.3 这条是可选的,(在summary之后),用一系列段落和列表项来对声明进行补充说明
段落之间用一个空行表示,并且在每一段中使用完整的语句描述。例如:
/// Writes the textual representation of each ← Summary
/// element of `items` to the standard output.
/// ← Blank line/// The textual representation for each item `x` ← Additional discussion
/// is generated by the expression `String(x)`.
///
/// - Parameter separator: text to be printed ⎫
/// between items. ⎟
/// - Parameter terminator: text to be printed ⎬ Parameters section
/// at the end. ⎟
/// ⎭
/// - Note: To print without a trailing ⎫
/// newline, pass `terminator: ""` ⎟
/// ⎬ Symbol commands
/// - SeeAlso: `CustomDebugStringConvertible`, ⎟
/// `CustomStringConvertible`, `debugPrint`. ⎭
public func print(
items: Any..., separator: String = " ", terminator: String = "\n")
- 合适的时候,在注释中使用Markdown标记注释文档;
- 了解并使用symbol command syntax在文档突出特定内容
一些流行的开发工具,例如:Xcode,可以对文档中的下列关键字做特殊处理,并突出显示它们:
Attention | Author | Authors | Bug Complexity | Copyright | Date | Experiment Important | Invariant | Note | ParameterParameters | Postcondition | Precondition | Remark Requires | Returns | SeeAlso | Since Throws | Todo | Version | Warning
第二部分 命名规则
2.1 为了更清晰的用法而改进
2.1.1 包含所有为了避免歧义的单词
包含所有当阅读代码时会引起歧义的单词。例如:定义一个在集合中删除元素的方法:
extension List {
public mutating func remove(at position: Index) -> Element
}
employees.remove(at: x)
如果忽略掉函数签名中的at,remove就会被人误解为在集合中搜索x并且删除,而不是把x当成要删除元素的索引。
employees.remove(x) // unclear: are we removing x?
2.1.2 忽略不需要的单词
每一个单词都应该为它自己的表意尽职尽责。用更多单词来表明含义或避免混淆是没问题的。但是,对于那些读者已经可以顺利推断出含义的词,应该被忽略,特别是那些仅仅用来重复类型信息的词。例如:
public mutating func removeElement(member: Element) -> Element?
allViews.removeElement(cancelButton)
在这个例子里,Element
没有在调用时传递更多有用的信息。把API设计成这样会更好:
public mutating func remove(member: Element) -> Element?
allViews.remove(cancelButton) // clearer
有时,重复类型信息是必要的,它可以避免混淆,但大多数时候,我们应该用一个单词来描述参数承担的角色而不是它的类型。具体参考下一条。
2.1.3 根据变量、参数以及关联类型(associated type)的角色为它们命名,而不是根据它们的类型约束为它们命名
例如:
var string = "Hello"
protocol ViewController {
associatedtype ViewType : View
}
class ProductionLine { func restock(from widgetFactory: WidgetFactory)
}
像上面这样把类型当成名称来用并不会提高代码的简洁性和可读性。我们应该基于一个实体的角色来为它命名,像这样:
var greeting = "Hello"
protocol ViewController {
associatedtype ContentView : View
}
class ProductionLine {
func restock(from supplier: WidgetFactory)
}
如果associatedtype
和protocol
关联性非常强,甚至protocol
的名称就是associatedtype
要承担的角色。此时,为了避免名称冲突,应该在associatedtype
的名称末尾添加Type
后缀,例如:
protocol Sequence {
associatedtype IteratorType : Iterator
}
2.1.4 为弱类型信息参数承担的角色提供补偿
特别是一个参数的类型是NSObject
,Any
,AnyObject
或者是Int
或String
这样的基本类型时,类型自带信息和使用它们的上下文不能充分表达它们的使用意图。例如,对于下面这个例子来说,看声明是清晰的,但用起来,确是含糊的。
func add(observer: NSObject, for keyPath: String)
grid.add(self, for: graphics) // vague
为了解决这个问题,在每个弱类型信息参数前面,加上一个表示角色的名词:
func addObserver(_ observer: NSObject, forKeyPath path: String)
grid.addObserver(self, forKeyPath: graphics) // clear
2.2 尽全力做到使用连贯
2.2.1 倾向于那些在使用时可以形成正确英语语法的名字
例如,下面这些表达方式是正确的:
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()
通常,对于那些在调用时,不影响方法核心语义的参数,为了表意的连贯,让他们的参数有更简单的形式也是可接受的。例如:
AudioUnit.instantiate(
with: description,
options: [.inProcess], completionHandler: stopProgressBar)
2.2.2 在工厂方法的名字前使用make
,例如:x.makeIterator()
;
2.2.2.1 对于构造函数和工厂方法来说,调用它们时形成的英文语句中不应包含第一个参数,例如,下面的代码是正确的:
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)
实际上,这条原则和下面对参数命名的原则一起,决定了绝大多数时候,(构造函数和工厂方法)的第一个参数总是会有external name的。除非,调用时体现的是一个“无损类型转换(full-width type conversion)”
let rgbForeground = RGBColor(cmykForeground)
2.2.2.2 根据函数或方法的副作用为它们命名
- 没有副作用的方法或函数应该使用一个名词表达方式,例如:
x.distance(to: y)
或i.successor()
; - 有副作用的方法或函数应该使用一个使役动词命名,例如:
print(x)
,x.sort()
,x.append(y)
; -
总是成对命名mutating和non mutating方法。一个mutating方法通常会伴随一个non mutating变体,它们表达类似的语义,但是non mutating版本返回一个新对象,而不是在原有对象上就地处理;
当方法原生通过动词表现时,用这个使役动词命名方法的mutating版本,在这个使役动词后面添加ed
或ing
后缀命名对应的non mutating版本;例如:mutating -
x.sort()
,non mutating -x.sorted()
例如:mutating -
x.append(y)
,non mutating -x.appedning(y)
当方法原生通过名词表现时,使用名词来命名方法的non mutating版本,在名词前添加
form
定义mutating版本;例如:non mutating -
x.union(z)
,mutating -y.formUnion(z)
例如:non mutating -
c.successor(i)
,mutating -y.formSuccessor(&i)
关于ed
和ing
的补充说明
应优先考虑使用动词的过去分词为non mutating方法命名(通常是ed
结尾)
例如:
/// Reverses self in-place.
mutating func reverse()
/// Returns a reversed copy of self.
func reversed() -> Self
...
x.reverse()
let y = x.reversed()
当动词带有一个直接宾语导致添加ed会造成语法错误时,使用动词的现在进行时(添加ing
)为non mutating方法命名
例如:
/// 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()
2.2.2.3 使用Bool
语义的方法或属性时,当这是一个non mutating操作时,它们读上去应该像是对调用对象的断言
例如:x.isEmpty
,line1.intersects(line2)
。
2.2.2.4 如果protocol
用于为某种事物进行定义,这个protocol应该使用名词命名
例如:Collecion
。
2.2.2.5 如果protocol
用于表达某种事物的能力,这个protocol
的名字,应该使用able
,ible
或ing
后缀
例如:Equatable
,ProgressReporting
。
2.2.2.6 其它的类型、属性、变量和常量应该使用名词;
2.3 正确使用专业术语
Term of Art
Term of Art - 指一个在特定领域或专业里,有明确特殊含义的名词或短语(fn)。
2.3.1 避免使用复杂晦涩的单词
如果有更为常用的单词表达同样的含义,就避免使用复杂晦涩的单词。例如,(当我们要表达“表皮”这个含义的时候),如果“skin”已经充分表达意图了,就不要使用“epidermis”。虽然专业术语是重要的沟通方式,但只有需要表达重要含义,使用普通词会导致信息丢失的时候,才使用专业术语。
2.3.2 如果使用term of art,就始终坚持它在特定领域的含义
唯一一个需要使用专业术语,而不是普通单词的场景,就是需要严谨准确的表达某些含义,而使用非术语会带来歧义的时候。
因此,如果API中使用了专业术语,就要坚持使用这个术语被广为接受的含义(fn)。
- 不要让专家感到意外:不要使用专业术语单词的其它含义,这会让特定领域的专业人员赶到意外,甚至愤怒;
- 不要让初学者感到困惑:当一些初学者尝试理解专业术语时,他们可能会在Web上找到这些单词的非专业领域含义(注:言外之意也就是仅在必要的时候才使用专业术语);
2.3.3 避免使用缩写
尤其是对于那些仅在特定领域里才使用的缩写,他们就像特定人群使用的暗号一样,高效并且有局限性。是否能正确理解它们的含义,完全取决于是否可以正确的“猜”出缩写的各个单词。
因此,当一定要使用缩写时,至少让你的缩写可以很方便的在搜索引擎找到(没有歧义)的解释。
2.3.4 尊重专业习惯
不要以牺牲专业习惯为代价,尝试让新手更容易理解专业术语。例如:
- 把一段(内存)连续的数据结构定义为
Array
比定义成List
要好的多。尽管对于新手来说,List
可能更容易表现出“一列事物”这样的概念。在现代计算机中,Array
是很基础的概念,所以每一个开发者都应该知道,或者立即去了解,什么是一个Array
。尝试使用开发者都熟知的词汇,初学者会在提问和使用搜索引擎时,更容易获得帮助; - 在某个特定领域里,例如数学,使用
sin(x)
表示正弦函数依然是众所
周知的事情,使用诸如
verticalPositionOnUnitCircleAtOriginOfEndOfRadiusWithAngle(x)
这样的方式反而让人觉得不知所云(尽管这是在解释什么是正弦)。另外,尽管我们刚刚说过避免使用缩写,却又使用了sin(x)
替代了sine(x)
。是的,这次,专业习惯在决策考量中占据了更大的比重。数十年来,任何和数学打交道的从业者们已然习惯了使用sin(x)
,我们应该尊重这个习惯。
第三部分 约定俗成
3.1 一般习俗
3.1.1 使用文档明确标注哪些算法复杂度不是O(1)的computed property
人们通常会认为访问一个computed property不会带来严重的性能损耗,因为在使用它们的时候,和使用一个stored property是感受不到区别的。因此,当computed property会引起性能问题时,要明确告知开发者。
3.1.2 相比自由函数(free function),更倾向于使用方法和属性解决问题
仅在一些特定的情况使用自由函数。例如:
1.要完成的任务不针对特定的self
对象:min(x, y, z)
;
2.函数用于执行通用性功能:print(x)
;
3.调用函数的语法在特定领域里属于“习惯性用法”:sin(x)
;
3.1.3 使用以下命名方式
类型和protocols使用UpperCamelCase
进行命名,其它都使用lowerCamelCase
命名。对于“首字母缩略词(Acronyms and Initialisms)”则采用以下原则:
- 美式英语中常用的由全大写字母构成的的“首字母缩略词”应该根据使用的上下文环境,统一使用大写或小写字母,例如:
var utf8Bytes: [UTF8.CodeUnit]
var isRepresentableAsASCII = true
var userSMTPServer: SecureSMTPServer
- 其它的“首字母缩略词”应按照一般单词处理,例如:
var radarDetector: RadarScanner
var enjoysScubaDiving = true
3.1.4 方法可以共享一个公共的名字
当方法表达相似的含义或它们用在不同的领域时,可以共享同一个名字。例如:
- 下面的方法是被提倡的,因为它们本质上表达的含义是相同的:
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 { ... }
}
并且,由于几何和集合属于两个不同的技术领域,因此,同一个程序中,在集合中使用contains
也是没问题的:
extension Collection where Element : Equatable {
/// Returns `true` iff `self` contains an element equal to
/// `sought`.
func contains(sought: Element) -> Bool { ... }
}
但是,在下面的例子中,不同的index
方法表达完全不同的语义,它们应该使用不同的名字:
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 { ... }
}
最后,避免“通过方法的返回值”实施重载行为,它们会在使用type inference
的时候,带来歧义。
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? { ... }
}
3.2 关于参数
func move(from start: Point, to end: Point)
3.2.1 让参数名为文档服务
尽管在函数或方法被调用的时候,参数名并不会出现,但它们对解释函数或方法的用途有重要作用。
因此,选择那些可以让文档更易读的参数名。例如,下面的例子里,注释文档读起来就很自然:
/// Return an `Array` containing the elements of `self`
/// that satisfy `predicate`.
func filter(_ predicate: (Element) -> Bool) -> [Generator.Element]
/// Replace the given `subRange` of elements with `newElements`.
mutating func replaceRange(_ subRange: Range, with newElements: [E])
而下面这些名字编写的文档,既不易读,还会带来语法错误:
/// Return an `Array` containing the elements of `self`
/// that satisfy `includedInResult`.
func filter(_ includedInResult: (Element) -> Bool) -> [Generator.Element]
/// Replace the range of elements indicated by `r` with
/// the contents of `with`.
mutating func replaceRange(_ r: Range, with: [E])
3.2.2 使用默认参数简化绝大多数的应用场景
如果一个参数在绝大多数时候都会使用同一个值,应该考虑为它指定默认值。
参数默认值可以通过隐藏次要信息提高代码的可读性,例如:
let order = lastName.compare(
royalFamilyName, options: [], range: nil, locale: nil)
(如果后三个参数都有默认值),compare调用读起来就会简单的多:
let order = lastName.compare(royalFamilyName)
另外,如果你之前使用过方法家族(method families),你就更应该考虑使用是否可以用带有默认参数的方法来替代它们。这不仅可以帮助开发者更容易了解API的用法,也可以减轻对“家族成员”的认知负担。例如:
extension String {
/// ...description...
public func compare(
other: String, options: CompareOptions = [],
range: Range? = nil, locale: Locale? = nil
) -> Ordering
}
尽管上面的compare
看上去并不简单,但它总比下面的这个方法家族看上去简单多了:
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()
之间的差别吓一跳。因此,在一堆“看上去都差不多”的方法中,指出到底调用了哪个方法是一件让人头疼的事情。而用一个方法,合理搭配上默认参数,可以极大改进开发体验。
3.2.3 倾向于从参数列表的末尾开始安排带有默认值的参数
通常,如果参数没有默认值,它会对方法的执行语义有更为重要的影响。并且,把带有默认值的参数放在最后,可以让方法的调用方式更为一致。
@3.3 关于参数Label
func move(from start: Point, to end: Point)
x.move(from: x, to: y)
3.3.1 当区分参数没有意义时,忽略所有的参数label
例如在下面的两个方法里,区分参数时没意义的,它们的所有参数都不应该带有label:
min(number1, number2), zip(sequence1, sequence2)
3.3.2 如果初始化方法执行了不会带来信息丢失的类型转换,应该忽略初始化方法第一个参数的label
例如:Int64(someUint32)
如果函数的功能是执行类型转换,那么第一个参数应该总是源类型(source of conversion),例如:
extension String {
// Convert `x` into its textual representation in the given radix
init(_ x: BigInt, radix: Int = 10) ← Note the initial underscore
}
text = "The value is: "
text += String(veryLargeNumber)
text += " and in hexadecimal, it's"
text += String(veryLargeNumber, radix: 16)
如果类型转换会带来信息丢失,应该在参数前面添加一个描述信息处理方式的label,例如:
extension UInt32 {
/// Creates an instance having the specified `value`.
init(_ value: Int16) ← Widening, so no label
/// Creates an instance having the lowest 32 bits of `source`.
init(truncating source: UInt64)
/// Creates an instance having the nearest representable
/// approximation of `valueToApproximate`.
init(saturating valueToApproximate: UInt64)
}
3.3.3 如果第一个参数在方法被调用时形成了介词短语,应该给第一个参数添加label
并且,参数的label也应该用介词开头,例如:x.removeBoxes(havingLength: 12)
。
但是这条也有例外:当方法的前两个参数一起表达一个动作的抽象时,应该忽略第一个参数的label前面的介词。
例如,下面的这两个例子,它们的第一个参数都不应该以介词开头:
a.move(toX: b, y: c)
a.fade(fromRed: b, green: c, blue: d)
遇到这种情况时,我们应该把介词放到参数label前面,这样可以更清楚的表达要执行的动作,例如:
a.moveTo(x: b, y: c)
a.fadeFrom(red: b, green: c, blue: d)
3.3.4 否则,如果第一个参数(在方法调用时)形成了一个语法正确的短语,就忽略第一个参数的label
并且,应该在方法名后面添加必要的前置单词。例如:x.addSubview(y)
。
这条指南的另外一个含义是,如果第一个参数不能在调用时和函数名形成语法正确的短语,它就应该有一个label,例如:
view.dismiss(animated: false)
let text = words.split(maxSplits: 12)
let studentsByName = students.sorted(
isOrderedBefore: Student.namePrecedes)
这里还有一点要强调的是,在调用时(忽略掉第一个参数的label)形成的短语不仅要语法正确,还要表达正确、无歧义的语义。例如,下面这些例子,虽然语法正确,但却是有歧义的(因此,我们不应该忽略第一个参数的label):
view.dismiss(false) Don't dismiss? Dismiss a Bool?
words.split(12) Split the number 12
3.3.5(除了第一个参数之外),为所有其它参数设置label
第四部分 特殊情况
4.1 在你的API里,为closure的参数和tuple成员设置label
为这些元素设置的label好处有二:
1.可以极大提升文档注释的表达能力;
2.让访问tuple成员的代码更易读;
例如:
/// Ensure that we hold uniquely-referenced storage for at least
/// `requestedCapacity` elements.
///
/// If more storage is needed, `allocate` is called with
/// `byteCount` equal to the number of maximally-aligned
/// bytes to allocate.
///
/// - Returns:
/// - reallocated: `true` iff a new block of memory
/// was allocated.
/// - capacityChanged: `true` iff `capacity` was updated.
mutating func ensureUniqueStorage(
minimumCapacity requestedCapacity: Int,
allocate: (byteCount: Int) -> UnsafePointer<Void>
) -> (reallocated: Bool, capacityChanged: Bool)
尽管从技术上来说,我们在closure中使用的是参数label(这里指byteCount),但在注释文档中,我们应该把它当做参数名称来使用。
在函数内部调用这个closure时,这个调用形成的表达方式,和调用一个“名称中不包含第一个参数(注:这应该是相对于名称中会带有介词的函数名而言的)”的函数是一致的:
allocate(byteCount: newCount * elementSize)
4.2 留意那些无约束类型带来的多态效果(unconstrained polymorphism)
特别是Any
,AnyObject
或者泛型参数,它们很容易在方法重载时意外带来歧义。例如,考虑下面两个重载的方法:
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<
S : SequenceType where S.Generator.Element == Element
>(newElements: S)
}
这两个方法形成了一个语义家族(semantic family),它们的参数看上去是截然不同的。但是,当Array
中Element
的类型是Any
时,一个单一的元素有可能和一个元素集合的类型是相同的。
例如,在下面的例子里,我们究竟应该认为[2, 3, 4]
是一个Any
,还是把它理解为是一个[Any]
呢?
var values: [Any] = [1, "a"]
values.append([2, 3, 4]) // [1, "a", [2, 3, 4]] or [1, "a", 2, 3, 4]?
为了避免这个歧义,我们可以给第二个重载版本的参数,添加一个label:
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<
S : SequenceType where S.Generator.Element == Element
> (contentsOf newElements: S)}
注意到新添加的参数label是如何和文档注释匹配的了么?这样,编写文档注释实际上也是在提醒作者每个API的用途。