×
广告

翻译@API Design Guidelines(Swift API设计指南)

96
fever105
2017.05.31 01:13* 字数 4900

翻译@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,对以下面关键字开头的项目符号做特殊处理:
Attention Author Authors Bug
Complexity CopyRight Date Experiment
Important Invariant Note Parameter
Parameters Postcondition Precondition Remark
Requires Returns SeeAlso Since
Throws Todo Version Warning

命名

意图清晰

  • 单词的用量以消除歧义为准,以便能够过通过阅读代码理解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
    }
    
  • 为弱类型添加补充信息,明确参数的角色。

    ℹ️

    尤其是当参数类型为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) // 清晰
    

力求流畅

  • 方法和函数被调用时,最好能够组成英语短语

    ℹ️

    ✅
    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.isEmptyline1.intersects(line2)
  • 描述事物的协议,读起来应该像名词(例如,Collection)。
  • 描述能力的协议,应该使用后缀ableibleing(例如,EquatableProgressReporting)。
  • 其他类型,属性,变量以及常量的名称,读起来应该像名词

慎用术语

Term of Art 名词 - 在某个领域或行业内,有着明确特殊含义的词或短语。

  • 避免使用晦涩的术语,特别是如果有一个常见词汇能够表达同样意义时。例如,如果”皮肤“能够满足表述需求,就不要使用“表皮”。术语是重要的交流工具,但应该仅在其他表述方式会丢失关键意义时使用。

  • 如果使用术语,则应该紧扣其公认的既定含义

    ℹ️

    使用术语而非常见词汇的唯一原因,是其能够准确表述事物,否则含义便会模糊,甚至造成歧义。因此,API应该严格按照既定含义使用术语。

    • 不要激怒专家:对术语熟悉的人将会感到惊讶甚至愤怒,如果他发现API的设计者为一个术语发明了新的含义。
    • 不要迷惑新手:尝试学习术语的人一般都会通过网络搜索的方式查询术语的含义。
  • 避免缩写。缩写,特别是非标准缩写,实际上是术语,因为对缩写的理解建立在正确将其翻译为全称的基础上。

    API使用到的缩写,其含义必须能够在互联网上轻松找到。
    
  • 遵循先例。如果现有术语已经能够完美表述一个含义,那么就不要为了迁就新手,打破这种先例。

    ℹ️

    例如,最好将一个连续的数据结构命名为Array,而非更简单的List,虽然对于新手来说,后者的含义更容易掌握。数组是现代计算机科学的基础数据结构,所以每个程序员都知道——或者很快就会学到——什么是数组。使用大多数程序员所熟悉的术语,这样,即便有问题,互联网和其他人也能够提供帮助。

    在某些特定的编程领域,例如数学, 诸如sin(x)这样已经广为人们所接受的术语,要比诸如verticalPositionOnUnitCircleAtOriginOfEndOfRadiusWithAngle(x)这样解释性的命名好的多。注意,这里先例打破了避免缩写的规则:尽管单词的完整拼写是sine,但"sin(x)"已经被程序员使用了数十年,在数学中更是数百年。


惯例

一般惯例

  • 对于复杂度不是O(1)的计算型属性,要通过注释特别说明。人们总是认为属性访问不牵扯大量计算,因为访问的是实例变量(存储型属性)。当这个惯例被打破时,有必要提醒他们。

  • 优先选择方法或属性,而非函数。后者只在下述情况中使用:

    ℹ️

    1. 没有明显的self:

      min(x, y, z)
      
    2. 函数是不受限的范型函数:

      print(x)
      
    3. 函数的语法在特定领域中约定俗成:

      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)
    
  • 使用弱类型时,避免重载产生歧义。例如,AnyAnyObject及不受限的范型参数。

    ℹ️

    考虑如下一组重载方法:

    ❌
    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设计者能够注意到潜在的问题。

Swift
Web note ad 1