Swift 官方 API 设计准则

96
Thermod
2015.12.05 10:04* 字数 4647

原文 API Design Guidelines

翻译自苹果 Swift 官方网站

译者 星夜暮晨(QQ:412027805)

2015年12月5日

注:与《Swift 3 API 设计准则》的区别在于,那片文章只是一个概览说明,此文章是苹果官方发布的 API 设计准则,虽然还处于样稿阶段,但是有一定的参考价值。

感谢 gviridis 对“参数”一节翻译表述的指正~


注意:本 API 指南是 Swift 3.0 工作的一部分,还只是一份样例。

基本原则

  • 清晰明了地使用代码是您最重要的目标。因为读代码的次数远远多于写代码的次数。

  • 代码直观远比精简更重要。虽然 Swift 代码可以写得很紧凑,但是这并不意味着我们希望您用最少的字符写出最短的代码出来。出现在 Swift 中的精简代码只不过是强类型系统的副效应(side-effect),以及为了减少样板代码(boilerplate)的一个正常特性罢了。

  • 写一个漂亮的文档注释,通过 Swift 的 Markdown 分支语法为每一个方法或者属性撰写注释。在理想情况下,应该能够通过其字面意思理解 API 的作用,并且注释最好在一到两句话之间就可以阐述:

/// 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

命名

提倡清晰的用法

  • 包含所有必需的词组以避免歧义,当人们读代码的时候,命名就显得十分重要。

举个例子,比如说有这样一个方法,它用来在一个集合内移除给定位置的元素。

public mutating func removeAt(position: Index) -> Element

使用方法如下:

employees.removeAt(x)

如果我们省略了这个方法名中 At 这个词,就会暗示读者这个方法是用来检索并移除 x 元素的,而不是使用 x 来表明要移除元素的位置。

employees.remove(x) // 有歧义:是移除元素 x 吗?
  • 省略多余的词组。命名中的每一个词组都应该表达关于使用方法的重要信息。

多余的词组只用来澄清意图或者消除歧义,但如果读者已经明白这些信息的话,那么这些词组就会变得十分冗余,应该被省略掉。通常情况下,应该要省略掉仅仅只重复类型信息的词组:

public mutating func removeElement(member: Element) -> Element?

allViews.removeElement(cancelButton)

在这个例子当中,词组 Element 在这个调用语句中并没有增添任何有意义的信息。这样的 API 会显得更好一些:

public mutating func remove(member: Element) -> Element?

allViews.remove(cancelButton) // 更为直观

有时候,为了避免歧义的出现,重复类型信息是非常有必要的,但是通常情况下使用词组来描述参数的作用会更好一些,而不是描述它的类型。下一点会对此进行详细描述。

  • 对弱类型(Weak Type)信息进行补充是必要的,这样可以清晰描述参数的作用

当参数类型是弱类型,尤其是 NSObjectAnyAnyObject或者是诸如IntString之类的基本类型的时候,类型信息和使用点的上下文信息可能就不能表达完整的意图。在下面这个例子中,函数声明的意图十分清晰,但是使用的时候作用就会很不明确。

func add(observer: NSObject, for keyPath: String)

grid.add(self, for: graphics) // 不明确的表达

为了保证清晰度,在每个弱类型参数的前面加上描述其作用的名词

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()

如果没有合适的名词短语的话,那么用命令式动词替代也是可以的:

let firstAndLast = fullName.split() // 这是可以的
  • Mutating 方法是用一个动词命名的话,那么命名其对应的非 Mutating 的方法的时候,可以根据 "ed/ing" 的语法规则来进行命名,例如:x.sort()x.append(y)的非 Mutating 版本应该是x.sorted()以及x.appending(y)

通常情况下,Mutating 方法会拥有一个非 Mutating 的变体方法(variant),返回和方法作用域相同或者相似的类型。

  • 通常使用动词的过去式(通常在动词末尾添加"ed")来命名非 Mutating 的变体:
/// 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 的变体:
/// 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.isEmptyline1.intersects(line2)

  • 描绘某个类特征协议应该以名词的形式命名(例如Collection)。描绘某个类作用的协议应该以ableible或者ing后缀的形式命名(例如EquatableProgressReporting)。

  • 其余类型、属性、变量以及常量应该以名词的形式命名。

善用术语

专业术语(Term of Art):名词 - 描述特定领域或者行业中某一准确、特殊意义的单词或者词组。

  • 避免使用晦涩的术语。如果有一个同样能很好地传递相同意思的常用词的话,那么请使用这个常用词。如果用 "skin"(皮肤) 就能很好地表达您的意思的话,那么请不要使用 "epidermis"(表皮)。专业术语是一个必不可少的交流工具,但是应该仅在需要表达关键信息的时候使用,不然的话就不能达到交流的目的了。

  • 遵循公认的涵义。如果在使用专业术语的话,请使用最常见的那个意思。

使用术语而不是使用常用词的唯一理由是:需要精确描述某物,否则就可能会导致涵义模糊或者不清晰。因此,API 应当严格地使用术语,秉承该术语所公认的涵义。

  • 不要试图新造词意:任何熟悉此术语的人在看到您新造词意的时候都会感到非常惊愕,并且很可能会被这种做法所惹怒。

  • 不要迷惑初学者:每个新学此术语的人都会去网上查询该术语的意思,他们看到的会是其传统的涵义。

  • 避免使用缩写。缩写,尤其是非标准的缩写形式,实际上也是“术语”的一部分,因为理解它们的涵义需要建立在能够将其还原为原有形式的基础上。

任何您使用的缩写和其原意都应当能够简单地被网络检索出来。

  • 循规蹈矩:不要为了让初学者能更好理解,就要以破坏既有文化的代价来优化术语。

一个数据连续的数据结构最好应该命名为 Array 而不是使用诸如 List 之类的简单形式,即使初学者可能更容易理解 List 的涵义。Array 这个单词在现代计算机科学当中是一个基础结构,因此每一位程序员都知道(或者即将学到)这个单词的意思。使用大多数程序员都熟悉的术语,这样也可以提高初学者网络搜索和提问成功率。

在某些特定的编程领域中,比如说数学编程,有一个广泛使用的术语 sin(x) 通常会以一种解释型的术语表达:

verticalPositionOnUnitCircleAtOriginOfEndOfRadiusWithAngle(x)

注意在本例当中,原先的术语 sin 比这个语句更能够避免歧义:即便其完整的单词应该是 sine,但是由于几十年来,"sin(x)" 一直被广大程序员沿用,并且数学家也用了几个世纪,因此应该使用 sin 而不是那串冗余的代码。

约定

一般约定

  • 任何复杂度不为 O(1)的计算型属性都应当将复杂度标注出来。由于人们可以将属性存储为内部模型,因此他们会通常假定属性访问不会涉及到任何重要的计算。因此,当您的属性访问涉及到复杂计算的话,请给人们予以提示。

  • 优先考虑方法和属性,而不是自由函数。自由函数(Free Function)只在几种特殊情况下才能使用:

  • 当没有明显的self对象的时候:

min(x, y, z)
  1. 当函数是一个不受约束的泛型(unconstrained generic)的时候:
print(x)
  1. 当函数语法是某个领域中公认的符号时:
sin(x)
  • 遵循大小写约定:类型、协议和枚举的命名应该是 UpperCamelCase 的形式,其他所有的命名全部是 lowerCamelCase 的形式。

  • 当多个方法拥有相同的基础含义的时候,可以使用相同的名字(重载),特别是对于有不同的参数类型,或者在不同的作用域范围内的方法。

例如,下面的表示方法是提倡的,因为这些方法本质上做的都是相同的事情:

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 { ... }
}

此外,由于几何类型和集合类型拥有不同的作用域,因此在相同程序中使用重载也是允许的:

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 { ... }
}

最后,要避免“返回类型的重载”,因为这会导致在类型推断的时候出现歧义:

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? { ... }
}

参数

  • 充分利用参数默认值,这样可以简化常用的操作。一般情况下,如果一个参数经常使用某一个值的话,那么可以考虑将这个值设置为默认值。

默认参数通过隐藏不相关的信息提高了方法的可读性。例如:

let order = lastName.compare(
  royalFamilyName, options: [], range: nil, locale: nil)

可以简化为:

let order = lastName.compare(royalFamilyName)

通常情况下,默认参数非常适于用在一族相似的方法(method family)当中,因为它能够帮助人们更好地理解 API 的作用,降低理解难度:

extension String {
  /// *...description...*
  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
}

一个方法族的每个成员都需要为其添加不同的文档,并且还要让用户牢记它们的使用方法。用户需要理解所有的方法才能决定用哪一个才比较合适,并且这样还会产生一些奇怪的关系——例如,fooWithBar(nil) 和 foo() 并不一定就是同义词,让用户在几乎相同的文档当中辨别这个细小的区别会使用户觉得十分枯燥乏味。相反,使用一个带有默认值的单独方法可以提供非常良好的用户体验。

  • 最好将带有默认值的参数放在参数列表的最后面。不带默认值的参数对于方法来说更重要,并且当方法调用的时候可以提供一个稳定的初始化范式(initial pattern)。

  • 最好遵循 Swift 关于外部参数名(argument labels)的默认约定

换句话说,这意味着:

  • 方法和函数的第一个参数不应该有外部参数名
  • 方法和函数的其他参数都应该有外部参数名
  • 所有构造器(initializer)中的参数都需要有外部参数名

如果每个参数都以下面的形式进行声明的话,那么它需要遵循上面的约定,以确定其是否需要使用外部参数名。

identifier: Type

当然,存在几个特例:

  • 对于执行“无损(full-width)类型转换”的构造器方法来说,第一个参数应该是待转换的类型,并且这个参数不应该写有外部参数名。

注:gviridis 解释“无损类型转换”的意思是,对于执行类型转换(参数是待转换的类型,构造器所在的类是要转换的目标类型)的构造器来说,“无损”意味着待转换类型的存储空间小于等于目标类型所占用的存储空间,比如说 Int32 转换为 Int64,编译器直接将 Int32 放到 Int64 所在的内存当中,不足的部分会自动用 0 补齐。

同理,“有损转换”就意味着编译器需要删除待转换类型多余的空间以便存放到目标类型当中,比如说 Int64 转换为 Int32,编译器会将 Int64 多余的32位数据砍掉,才能放到 Int32当中。

extension String {
  // Convert `x` into its textual representation in the given radix
  init(_ x: BigInt, radix: Int = 10) // 注意到构造器方法中那个下划线
}

text = "The value is: "
text += String(veryLargeNumber)
text += " and in hexadecimal, it's"
text += String(veryLargeNumber, radix: 16)

对于“有损(narrowing)”类型的转换,推荐使用外部参数名来描述这个特定类型:

extension UInt32 {
  init(_ value: Int16)            // 无损(widening)转换,无需外部参数名
  init(truncating bits: UInt64)
  init(saturating value: UInt64)
}
  • 当所有的参数都能相互配对,以致无法有效区分的时候,这些参数都不应该添加外部参数名。最常见的例子就是 min(number1, number2) 以及 zip(sequence1, sequence2)了。

  • 当第一个参数有默认值的时候,应该为之添加一个不同的外部参数名

extension Document {
  func close(completionHandler completion: ((Bool) -> Void)? = nil)
}
doc1.close()
doc2.close(completionHandler: app.quit)

正如您所见,不管是否明确地传入了参数,这个范例都能够执行正确地读取参数并执行。而如果您省略了外部参数名的话,语句调用就可能会出现歧义,它可能会将参数认做是"语句"的直接宾语:

extension Document {
  func close(completion: ((Bool) -> Void)? = nil)
}
doc.close(app.quit)              // 是要关闭这个退出函数 ?

如果您将此外部参数名放到方法名当中的话,当使用默认值的时候就会变得很“诡异”:

extension Document {
  func closeWithCompletionHandler(completion: ((Bool) -> Void)? = nil)
}
doc.closeWithCompletionHandler()   // 完成后处理什么呢?

特别说明

  • 要特别小心无约束的多态类型(unconstrained polymorphism)(比如说 AnyAnyObject 以及其它未加约束的泛型参数),避免重载集(Overload Set)中出现的歧义。

例如,有这样一个重载集:

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)
}

这两个方法虽然都有相同的方法名字,但是它们的第一个参数类型却是截然不同的。然而,当 Element 的类型是 Any 的时候,单一元素和元素序列的类型就会变成相同的了:

var values: [Any] = [1, "a"]
values.append([2, 3, 4]) // [1, "a", [2, 3, 4]] 还是 [1, "a", 2, 3, 4]?

为了消除歧义,第二个重载方法的命名需要更加明确:

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<
    S : SequenceType where S.Generator.Element == Element
  >(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:

比起使用这些关键词来说,一个好的简单注释更为重要

如果某个方法的名字加上一行简单注释,就已经可以完全表达这个方法的作用的话,那么可以忽略为每个参数和它的返回值撰写详细的文档注释。比如说:

/// Append `newContent` to this stream.
mutating func write(newContent: String)
 技术文档翻译