官方 Swift API 设计指南(译)

2018/10/10 Swift

原文:Swift API Design Guidelines

其他:Google Swift Style Guide中文版

基本原则

  • 使用时能够清晰表达设计者的意图,是最重要的目标。做出 API 设计、声明后要检查在上下文中是否足够清晰明白。

  • 清晰比简洁重要。虽然 swift 代码可以被写得很简短,但是让代码尽量少不是 swift 的目标。简洁的代码来源于安全、强大的类型系统和其他一些语言特性减少了不必要的模板代码。而不是主观上写出最少的代码。

  • 为每个 API 添加注释。添加注释有助于加深对 API 的理解,从而其设计产生深远影响。所以,别犯懒

    如果您无法用简单的术语描述 API 的功能,那么您可能设计了错误的 API。

    • 使用 Swift 的 Markdown

    • 首先描述要声明的实体的摘要。通常,API 可以从其声明及其摘要中完全理解。

      /// Returns a "view" of `self` containing the same elements in
      /// reverse order.
      func reversed() -> ReverseCollection
      
      • 专注于总结 ; 这是最重要的部分。许多优秀的文档注释只包含一个很棒的摘要。

      • 如果可能,使用单个句子片段,以句点结束。不要使用完整的句子。

      • 描述函数或方法的**作用**和 返回的内容,省略 null 效果并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上述极少数情况下,摘要由分号分隔的多个句子片段组成。

      • 描述下标**访问的内容**:

      • 描述初始化程序**创建的内容**:

      • 对于所有其他声明,请描述声明的实体**是**什么

    • 继续使用一个或多个段落和项目符号项。段落用空行分隔并使用完整的句子(可选)

      /// 将`items`中每个元素的文字表示写入标准输出。
      ///
      /// 每个元素`x`的文字表示通过表达式`String(x)`生成。
      ///
      ///
      /// - 参数 separator: 两项之间的文字
      /// - 参数 terminator: 末尾的文字
      ///
      /// - 注意: 想要省略末尾的换行符,为`terminator`传入""
      ///
      /// - 其他参考: `CustomDebugStringConvertible`, `CustomStringConvertible`, `debugPrint`。
      
      public func print(
        _ items: Any..., separator: String = " ", terminator: String = "\n")
      
      • 在适当的时候,使用已识别的 符号文档标记 元素在摘要之外添加信息

      • 使用符号命令语法了解并使用已识别的项目 符号流行的开发工具(如 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

命名

意图清晰

  • 保证命名让使用的人不会产生歧义

    比如在集合中有一个方法,根据给定的位置移除元素:

    
    extension List {
      public mutating func remove(at position: Index) -> Element
    }
    employees.remove(at: 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) // 更清晰
    
  • 根据变量,参数,关联类型的角色为其命名,而非类型。

    
    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 Sequence {
      associatedtype Iterator : IteratorProtocol
    }
    protocol IteratorProtocol { ... }
    
  • 为弱类型添加补充信息,明确参数的作用。

    尤其是当参数类型为,AnyAnyObject,或诸如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) // clear
    

让代码更加流畅

  • 尽量让方法、函数名使用的时候代码语句接近正常的语法。

    
    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)
  • 如果是创建型的工厂方法,用 “make” 开头。比如:x.makeIterator()

  • 调用构造函数和工厂方法时,组成的短语不包含第一个参数名。例如,x.makeWidget(cogCount: 47)

    例如,下面的情况第一个参数命名时都不需要考虑作为一个句子的部分:

    
    let foreground = Color(red: 32, green: 64, blue: 128)
    let newPart = factory.makeWidget(gears: 42, spindles: 14)
    let ref = Link(target: destination)
    

    如果为了句子的连贯性就会声明成下面这样(但是并不推荐这样做):

    
    let foreground = Color(havingRGBValuesRed: 32, green: 64, andBlue: 128)
    let newPart = factory.makeWidget(havingGearCount: 42, andSpindleCount: 14)
    let ref = Link(to: destination)
    

    实际上,此准则以及参数标签的准则 意味着第一个参数将具有标签,除非调用执行的是值保留类型转换

    let rgbForeground = RGBColor(cmykForeground)
    
  • 函数、方法命名时要参考自身的副作用。

    • 没有副作用的名字读起来应该像一个名词的短语。比如: x.distance(to: y), i.successor()

    • 有副作用的读起来应该是一个祈使式的动词短语,比如:print(x), x.sort(), x.append(y)

    • 可变/不可变方法的命名要成对出现。一个可变方法通常都有一个不可变方法与之对应,二者的语义相近,区别在于前者直接更新实例,后者返回一个新值。

      • 如果描述操作的是一个动词,使用动词的祈使态表示 mutating,nonmutating 在动词后加上 “ed” 或 “ing” 表示。

        可变方法 不可变方法
        x.sort() z = x.sorted()
        x.append(y) z = x.appending(y)
      • 当一项操作恰好能够被一个名词描述时,使用名词为不可变方法命名;加前缀”form”,为可变方法命名。

        不可变方法 可变方法
        x = y.union(z) y.formUnion(z)
        j = c.successor(i) c.formSuccessor(&i)
  • 作为不可变方法,如果返回布尔值的方法或属性,读起来应该像是对被调用对象的断言。例如,x.isEmptyline1.intersects(line2)

  • 表示是什么的 Protocol 读起来应该是一个名词。比如:Collection

  • 表示能力的 Protocol 后缀应该用 able、ible 或者 ing 修饰。比如:Equatable, ProgressReporting

  • 其他形式的类型、属性、变量、常量都应该用名词命名。

慎用术语

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

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

  • 如果使用术语,严格的使用术语本来的含义。

    使用技术术语的原因就是它比常用的词语能够更精确的表达含义,因此 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 enjoysScubaDiving = true
    
  • 当方法共享相同的基本含义或在不同的域中操作时,方法可以共享基本名称

    下面这种方式是被鼓励的,因为所有的方法的目的都是一样的:

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

参数(Parameters)

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()的作用并不总是相同——试图在文档中寻找这种微妙区别会变是很恶心的。利用默认参数,简化为一个方法,极大提升了用户体验。

  • 将具有默认参数的参数项放到方法最后。从语义上来说,没有默认参数的参数项对于方法来说更为重要,并且可以在调用时提供稳定的格式。

参数标签(Argument Labels)

func move(from start: Point, to end: Point)
x.move(from: x, to: y)
  • 如果不需要区分参数,则可以省略所有实参标签。例如:min(number1, number2), zip(sequence1, sequence2)

  • 如果构造函数进行的是值保留类型转换操作,则省略第一个实参标签。例如:Int64(someUint32)

    第一个参数应该始终是转换的数据源。

    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)
    

    而对于“值省略类型转换”来说,最好使用第一个标签描述所省略的内容。

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

值保留类型转换是单态,即一个值对应一个结果。例如,将一个Int8值转换为一个Int64值属于这种操作,因为不同的Int8值都对应不同的Int64值。反过来就不是:Int64可能的值要比Int8能够表示的值多得多。

注意:能否追溯原始值,同是不是值保留类型转换没有联系。

  • 当第一个参数构成介词短语的一部分时 ,给它一个参数标签。参数标签通常应该以介词开头 ,例如x.removeBoxes(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)
    

    闭包参数的命名规则和正常的函数参数规则一样,但是参数标签还不支持闭包。

  • 使用弱类型时,避免重载产生歧义。例如,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 设计者能够注意到潜在的问题。

Search

    Table of Contents