Swift 不完全函数第 1 部分:如何避免

来源:互联网 发布:手机mac地址全为0 编辑:程序博客网 时间:2024/06/06 05:39

原文:Partial functions in Swift, Part 1: Avoidance
作者:Matt Gallagher
译者:kmyhy

作为我重返 Cocoa with Love 的第一篇正式文章,我想介绍的是“不完全函数”(带前置条件的函数)。

这是一个不寻常的 App 编程博客主题,它超出了 API 设计或者按合同设计的范围,带前置条件的函数并没有在大范围内经过讨论。并不是说我们的函数没有前置条件。而是因为我们习惯于在小范围内测试 App,我们永远不会考虑传递给函数的所有可能值。我们的函数可能包含了许多隐含的条件(包括对更大范围内的程序状态的依赖),我们只是忽略了它们或者没有在文档中加以说明(因此极易出现违反的情况)。

实际上,预处理并避免不完全函数能够让我们的程序无论在如何情况下都能可靠地运行。

*目录:
背景: 类型约束 VS 运行时期望
背景: 前置条件
背景: Swift 中的前置条件
不完全函数
隐式不完全函数
不完全函数带来的问题
用完全函数取代不完全函数
构造可以失败,调用不能失败
避免不完全函数的其它方法
不完全函数存在的必要性
结论*

背景:类型约束 VS 运行时期望

任何一个函数,都需要满足两个条件:

  1. 类型约束:一个函数的参数类型和返回值必须和其签名相符。编译器会强制要求类型约束,确保调用者和函数都符合这个要求。
  2. 运行时期望:通过推断,描述函数最终要达到的目标。确保运行时期望符合函数式编程(及测试)的原则。

如果两者发生冲突时会怎样?

让我们来看一个 Int 转换成 Bool 例子(这是类型约束)。与 C 语言中 0 表示 false 而非 0 表示 true 不同,这个函数要严格一点:0 表示 false,1 表示 true(这是运行时期望)。

func toBool(x: Int) -> Bool {    if x == 0 {        return false    } else if x == 1 {        return true    }}

这个函数满足运行时期望:0 转换成 false,1 转换成 true,但会在最后的 } 符号处提示错误:

Missing return in a function expected to return ‘Bool’

编译器认为我们没有处理每一种 x 值的可能情况,函数有可能跳过两个 if 而不会返回值(违反了类型约束)。

我们可以修改这个函数,让它处理 x 的每种可能取值:

func toBool(x: Int) -> Bool {    if x == 0 {        return false    }    return true}

但现在,我们将 -1 之类的值转换成 true 了,这样又违反了运行时期望。

这样,类型约束和运行时期望发生了自相矛盾。

背景:前置条件

这种冲突的出现是因为运行时期望中隐含了一个额外的条件,这个条件没有包含在类型约束中。我们把这个额外的条件称之为前置条件。在我们简单的 toBool 函数中,这个前置条件是:x 的值要么是 0 要么是 1。

本文主要讨论参数的前置条件(对于简单例子,它更容易理解)。但有很多前置条件实际上是依赖于外部程序状态。比如:要调用这个方法,必须先初始化一个模块,这就是一种前置条件。如果在发起请求之前需要先启动服务器,也是一个前置条件。如果只允许对某个对象的值一次设置一个,那也是一个前置条件。

这个问题说起来简单,但却导致一个问题:编译器不知道前提条件,因此前提条件有可能在运行时被违反。如果前置条件不满足,这个函数该怎么做?

唯一的、安全的做法是触发一个致命错误(中断程序)。

这听起来也不太“安全”,但这是唯一的防止情况变得更严重的做法。如果函数的运行时期望和实际结果不符,这表明依赖于这个函数的所有事情都处于一种不确定的状态。一旦程序处于一种不确定状态,任何部分都会往不正确的方向滑下去,任何动作都会是错的。可能 toBool 函数会用于询问“你想删除磁盘上的所有数据吗?”,也可能是用于决定是否要退出循环,但现在它仍然处于循环之中,正在不停地消耗内存直到计算机崩溃。

我们想让程序停止,这样可以将我们的注意力集中到程序出现错误的地方——而不用强行将我们带到后面的问题中去,导致我们不得不一点点追溯到问题的源头。一个致命错误不但简化了调试步骤,而且让问题一发生就能被捕获。

背景: Swift 中的前置条件

因此我们需要在前置条件不满足的情况下触发一个致命错误。Swift 有一个函数 precondition 就是用来干这个的:测试条件是否满足,并在不满足的情况下触发一个致命错误。我们的函数可以改成:

func toBool(x: Int) -> Bool {    precondition(x == 0 || x == 1, "This function can only convert 0 or 1 to Bool")    if x == 0 {        return false    }    /* x == 1 */    return true}

注:本文大部分地方我会使用 precodition 函数,但还有许多类似的能够触发致命错误的函数,包括 assert、assertionFailure、precondition、preconditionFailure、fatalError,或者其它标准库中用于“捕获错误”的内联函数,比如Builtin.int_trap 和 Builtin.condfail。

现在,这段代码看起来有点别扭。我故意让类型约束和运行时期望发生冲突,然后又故意不修改它们任何一个,勉为其难地要用前置条件来解决问题。你可能会想,谁会这样写函数啊,你从来不会像这样来用一个函数。

事实上在 Swift 标准库中,几乎每个 Swift 程序都在间接地使用这种方式,包括了各种和 precodition 函数类似的断言。比如在 Swift 中最常见的 Array 的下标操作(它带有一前置条件:数组下标必须在范围内),又比如 Swift 的 ! 操作(它也有一个前置条件:self 不能为 nil),以及任何隐式解包操作(同样有一个 self 不能为 nil 的前置条件)和默认的整数运算和转换(也会触发致命错误)。

在你的代码中可以使用各种其它前置条件:当某些隐含的、未经检查的条件不能满足时,会导致函数的行为异常或出现不致命的错误。要将每样东西都掌握在手中是非常困难的,但你需要考虑到函数中是否有不明显的、未经检查的条件,然后用一个 precondition 检查来记录该条件,以确保以后你不会违反它。

不完全函数

前置条件会让函数中的某个参数的取值范围缩小为函数签名中指明的范围的一部分。也就是数学中的“不完全函数”。

接下来会介绍一些数学名词。正确使用这些术语是很重要的。接下来的内容很精彩,请敛声屏气以待。

在数学术语中,不完全函数是一种函数,它将一个域(输入值的可能集合)映射到一个陪域(输出值的可能集合),其中(输入域中)一部分值可能未定义(没有被映射)。在不完全函数中,已定义的输入值的子集称作已定义域。而所有输入值都已定义的函数,则叫做完全函数。

不完全函数的例子是除法。一个 5 除以任何实数的函数如下所示:

f : ℝ → ℝ   where   f : x ↦ 5 x

当 x 等于 0 时,函数未定义,因为在经典数学中,任何数除 0 都是没有意义的。

如果用 Swift 实现这个函数,对于“未定义”的这部分值,我们可以用前置条件(precondition)强制要求函数只对 x 的 “已定义域” 进行计算:

func divideFiveBy(x: Real) -> Real {    precondition(x != 0)    return 5 / x}

隐式的不完全函数

在 Swift 标准库中并没有 Real 类型。我们可以使用 Double 类型,但 Double 不能用在这个地方(请看后面的“改变行为”一节)。不过这里我们可以用 Int 来代替 Real:

func divideFiveBy(x: Int) -> Int {    return 5 / x}

我们的前置条件呢?它仍然存在。我们不需要写 precondition 语句是因为它已经包含在 / 操作符中了。Int 的中缀运算符 / 使用了“经过检查的”除法(在标准库中实现为 _overflowChecked),因此如果进行除 0 运算会引发一个致命的错误。因为在 Swift 中,Int 类型就像数学中的 Real 类型,除 0 都是没有意义的。

下面是另一个不完全函数的例子,当 someArrayIndex 参数等于某些值时,会触发致命错误:

func someArrayFunction(someArrayIndex: Int) -> Element {    return myArray[someArrayIndex]}

类似的例子还有下面这个,当实例对象处于某种状态时,会触发致命错误:

struct someStructWithAnOptionalMember {    var optionalSomeType: SomeType?    func accessor() -> SomeType {        return optionalSomeType!    }}

不完全函数带来的问题

我怎么知道 Int 的 / 运算符使用了“经过检查的”除法,以及当第二个操作数为 0 时它会导致一个致命错误呢?

唯一的答案是查看文档。Swift 编程语言将除法运算的约束描述为:

在 Swift 中,数学运算符默认不进行溢出处理。溢出会被当做错误抛出。

也就是说,如果你在做整数除法时将 0 作为第二个操作数时,这个函数会向标准输出中输出一条错误信息,然后中断程序的执行。

这就让不完全函数的使用极其依赖文档和测试(二者都极易导致疏忽大意):

  1. 必须在文档中清晰描述不完全函数的约束
  2. 函数的调用者必须阅读和理解该文档
  3. 必须在指定的取值范围内对所有可能的值进行广泛测试以保证函数被正确使用

除了满足第一条和第二条(至少会在调试的时候尽量注意),我们还需要注意第三条。

不幸的是:在一个复杂程序中,调试和测试不可能检查出所有可能的问题。调试和测试非常适合用于检验某种既定的场景,但除非你测试得非常彻底,否则使用者总是能够碰到你从来没有测试过的问题。如果你的程序使用了不完全函数,就有可能发生运行时错误。

不完全函数的最大风险是在发布版本中。因为它们并不会像测试时一样出现那么多的问题。在测试时,我们总是想尽量早和多地发现问题。在测试代码中我们可以对数组下标进行操作、对Optional类型进行强制解包 ! 以及使用一些好用但恶劣的不完全函数。

用完全函数取代不完全函数

出于以下原因,我们应当避免不完全函数:

  1. 它们带有编译器无法校验的约束
  2. 它们能通过测试,但如果发布之后数据发生改变,它们还是有可能发生致命错误

说得更清楚一点:并非我们不要检查前置条件。而是当函数中包含前置条件时,我们应当立即对它们进行检验,否则你的程序就会变得“不确定”。

问题在于前置条件的存在。

如果一个函数带有前置条件时,这个函数就是不完全函数。我们应该将函数设计成不带前置条件的完全函数。也就是说,我们需要为每一个可能的输入值映射一个明确的结果。

我们回顾一下 5 除以任何实数的函数。前面我们将它设计为一个不完全函数,对于 x = 5 的情况,我们缺少了定义。我们将它修改成一个完全函数:

f:X→ℝ where X=x∈ℝx≠0 , f:x↦5x

用对程序员更友好的方式表达:我们改变了函数的类型签名。我们不再使用任意值的 Real 作为参数,我们定义了一个新类型 X,用它表示除了 0 以外的任意实数。 现在,这个函数已经对 X 中的所有可能取值进行了映射,因此函数就变成了完全函数。

用 Swift 语言表示大致为:

struct NonZeroInt {    let value: Int    init?(fromInt: Int) {        guard fromInt != 0 else { return nil }        value = fromInt    }}func divideFiveBy(x: NonZeroInt) -> Int {    return 5 / x.value}

divideFiveBy 函数的运行时约束不存在了,我们用一个新的类型 NonZeroInt 替换了它,NonZeroInt 在编译时即可满足函数的运行时约束。

你应该明白我前面所说的了,你只需要将前置条件看成是类型签名的完整集合的子集就可以了。我们通过定义一个新的类型来取代前置条件,在这个类型中,我们要去掉前置条件中应该排除的那些值。

构造可以失败,调用不能失败

在命令式语言比如 Swift 中,不完全函数是一个不常见的术语,但在函数式语言比如 Haskell 中,这就很常见了。毫无疑问,在 Haskell 中也有许多关于如何避免不完全函数的内容。

在前面的例子中,我们创建了一个新类型 NonZeroInt,但它的构造函数有可能构造不成功(返回了空值)。换句话说,我们将校验的功能从 divideFiveBy 函数中拿开,然后放到别的地方。当然,这带来两个好处:

  1. 编译器会强制要求我们要检查 NonZeroInt?(fromInt:) 方法的返回值
  2. 我们在构造的时候就检查,而不是在使用的时候才检查

第一点避免函数成为不完全函数,第二点不过是凑数的。

实际上,我们不应当将一个 Int 构造为 NonZeroInt 紧接着传递给 divideFiveBy,相反我们应当完全不使用 Int;NonZeroInt 应当从某个源构造。这个源可能来自于一个 settings 文件,可能来自于使用者输入,有可能来自于网络连接。无论如何,这个值一出现,我们就立马知道它是不是有效。如果无效,我们就可以提示发生了什么问题。相比较于将一个无效的 0 传来传去,一直到传递给 divideFiveBy 函数,却发现早就无法知道这个参数是哪里传来的,这已经是一个巨大的进步了。

假设数据传递的路径是一个管道:如果数据无法通过管道,则在管道的一开始就被拒绝,而不是进到一半的时候才发现无法通过。理论上,只有构造时会出现失败,而调用是则是一个“完全函数”(不可能失败)。

其它防止不完全函数的方法

避免不完全函数包括满足类型约束以及运行时期望。

定义一个新的,更具体的类型,用于将对数据的运行时期望的所有条件都封装起来,是解决这个问题的最好方式。如我所说的,将任何数据放在构造的时候检查,才是处理错误条件的最好时机。

但是,有大量不属于最佳体验的情况:

  1. 从算法上很难实现提前对数据约束进行检查
  2. 无法在构造时访问要检查的数据的状态信息
  3. 无法控制数据管道的早期设计
  4. 你会在多处地方构造数据,但只在一个地方使用它,因此在使用的时候改变它要比在构造的时候改变它容易。

幸好我们还有其它办法。

改变返回类型

将不完全函数改变成完全函数的最简单办法是,是修改返回类型的类型签名,让它传递一个失败条件。我们用不着触发致命错误,我们可以将条件传回给调用者,由调用者负责对结果进行处理。

Swift 的 Optional 就是一个很好的例子,我们用 toBool 函数进行说明:

func toBool(x: Int) -> Bool? {    switch x {    case 0: return false    case 1: return true    default: nil    }}

在 Swift 标准库中有一个例子,是字典的下标操作符。和数组下标不同,字典下标操作符返回的是一个 ELement?。也就是说,允许你访问一个不存在的键。

我喜欢对 CollectionType 进行扩展,从而让数组或其它集合类型能够返回 Optional 类型。

extension CollectionType {    /// Returns the element at the specified index iff it is within bounds, otherwise nil.    public func at(index: Index) -> Generator.Element? {        return indices.contains(index) ? self[index] : nil    }}

这里,at 这个词来自于某个 C++ 函数,该函数在某个值有值的时候返回值,为空则抛出异常。Swift 的 throws 方法和 C++ 的 throws 类似,但这里通过一个单行语句返回一个Optional值,语法看起来更紧凑,这种方案来自于 Dictionary。

当然我们也可以用 Swift 的错误处理机制。比如:

enum ArtithmeticError { case DivideByZero }func divideFiveBy(x: Int) throws -> Int {    switch x {    case 0: throw ArtithmeticError.DivideByZero    default: return 5 / x    }}

在 O-C 中,异常(通常)是值不可恢复的错误(比如不完全函数)。但是在 Swift 中,错误则意味着可以被捕获的对象,实际上它们也必须被捕获。而且,在 Swift 中抛出一个错误只需要提供一个不同的返回类型——除了写法不同,它的语义和返回一个Optional类型类似。

改变行为

根据情况,这可能是指修改运行时期望,让每个输入都能映射到一个有效的输出。在第一个例子里,如果我们对布尔值的定义采用的是 C 语言的方式(任何非 0 值都等于 true),则我们的 toBool 函数根本不需要前置条件。

我们还可以改变 divideFiveBy 函数,让它做一些不完全准确但也可以说得通的事情,这取决于调用的方式:

func divideFiveBy(x: Int) -> Int {    switch x {    case 0: return Int.max    default: return 5 / x    }}

下面模仿 Swift 标准库的 Double 除法:

func divideFiveBy(x: Double) -> Double {    return 5 / x}

和 Int 的版本不同,这个函数是一个完全函数,而不是不完全函数。

如果 x==0,Double 的 / 操作符会返回 Double.infinity (IEEE 754 “正无穷大”) ,这和数学里面的定义不一样,但仍然解决了某些问题。当然,用这种方式去改变行为,可能会掩盖这样一个事实:当前结果是“不正确”的(例如:你更应该处理分母为 0 的情况,而不是将其放大到“正无穷大”)。

将依赖组件放到一起

使用不完全函数有一个常见的理由,你正在使用数据的两个部分,二者需要一致(比如一个数组和一个下标索引),但你将它们单独创建和保存,这就破坏它们的一致性———可能它们是分开创建的,它们的构造函数违反了原来的一致性。

对于需要保持同步的分离数据,我们可以不使用前置条件,而是将所需的数据保存到一个能够满足这个约束的数据结构中。

在下面的通过下标对数组进行索引的方法中,我们让下标无论何时都有效并防止下标越界。

enum AlwaysValidArrayIndexError: ErrorType { case NoAcceptableIndex }struct AlwaysValidArrayIndex<T> {    // Store the index    var index: Int    // Together with the array    let array: Array<T>    // Construction gives the first index in the array (or throws if the array is empty)    init(firstIndexInArray a: Array<T>) throws {        guard !a.isEmpty else { throw AlwaysValidArrayIndexError.NoAcceptableIndex }        array = a        index = array.startIndex    }    // Only allow the index to be advanced if there's somewhere to advance    mutating func advance() throws {        guard array.count > index else { throw AlwaysValidArrayIndexError.NoAcceptableIndex }        index += 1    }    // We can deference using the index alone since the array is held internally    func elementAtIndex() -> T {        return array[index]    }}

看起来有点奇怪,但这和 Swift 字符串的 advance 类似。在使用 StringString.CharacterView.Index 时,你需要通过 String.startIndex 来构造一个 Index,而这个 Index 是保存在字符串的内部的 _StringCore 中,这样就可以正确地遍历每一个 Unicode 字符,同时保持 Index 有效。

关于对 Swift 字符串索引方式的一点抱怨

悲催的是,尽管内部保存了 _StringCore,而且也会随时进行校验,当前进到字符串最后一个字符之后的字符时,仍然会导致一个致命错误(而不是优雅地返回一个 nil)。而更糟糕的是:不是通过符串自身来访问字符,而是需要指定一个下标来访问字符。第二个问题使 String.CharacterView.Index 和 String 再次变得不同步了(因为你可以将一个字符串的 Index 用于另一个字符串上),导致潜在的致命错误(下标越界)或者像下面这个例子一样产生无效的 Unicode字符。下面这个例子中,“Unrelated string” 的 Index 被偏移了 1,然后这个偏移被用于访问一个 Emoji 字符串的中字符,从而导致这个偏移在 Emoji 字符串中是无效的。

译者注:不同的字符串 advance 的结果是不同的。比如说单字节字符串一次 advance 的结果导致 Index + 1,而 Unicode 字符一次 advance 后的结果导致 Index + 2,因此你用 “Unrelated string” advance 之后的结果去访问 Emoji 字符串当然导致乱码(二者相差 1 )。正确的做法是用 Emoji 字符串 advance 之后的 Index 去访问 Emoji 字符串。

print("����"["Unrelated string".startIndex.advancedBy(1)])// 输出结果不是 "Unrelated" 中的 'n',也不是 Emoji 字符串中的第二个 "Imp" // 而是无效的 Unicode 字符 '�'

但愿这个问题能在以后的 Swift 标准库中得到解决(哪怕是能够在使用错误的 Indx 进行字符索引时断言失败也行)。

改变设计

最后一种避免不完全函数的方法是不要使用某些常见的设计模式。意思是说:只使用函数库中的完全函数。如果我们的程序中只使用完全函数,则我们的函数很可能也是完全函数。

最简单的例子是在操作数组时,用 for … in 循环、map 和 filter 函数而不要使用下标索引。还有,尽量用 if let、switch 和 flatMap 代替Optional类型的强制解包,以避免任何潜在的问题。

不完全函数存在的必要性

我曾经说了“不完全函数的坏话”。也介绍了许多避免它们的方法。

但为什么还会有不完全函数存在?这是因为几个原因。当然我不完全认可这些原因。

审美学

使用不完全函数的最大理由是审美原因:接口的设计者实在是不想定义新的类型、返回Optional或者声明一个会抛出异常的函数。

出于这种原因,在 Swift 标准库中存在大量的不完全函数,它们看起来就像古典的 C 运算符函数,它们会在 C 语言可能出现不安全的内存行为的地方进行一些透明的安全检查。包括数组下标操作,ImplicitlyUnwarppedOptional 和可溢出的算术运算;它们被设计成和它们的 C 语言版本一样,在内部使用了运行时检查。存在一些历史的或社会的原因:人们希望通过数组索引返回不为空的值。人们希望在他们需要的时候能够强制解包Optional类型。人们不想在进行数学计算时考虑溢出问题。

使用前置条件要比返回一个 Optional(或者其他方法)更危险和更容易崩溃,但这就是人类的思考方式。

带简单条件的内部函数

为了减少额外的工作,前置条件包含需要被同步的多个值,或者某个对象的方法以指定顺序调用。对于内部函数——我们是唯一需要遵循和知道这些前置条件的人——根本不值得花功夫去替换前置条件,尤其是这些前置条件既简单又明显,我们确定我们不会违反它。

唯一要明确的是,一旦使用了前置条件,就不应当违反它。

方法重写

如果有一个可重写的方法要求父类做某些事情(比如调用 super),我们经常要依赖于前置条件或者其它类似检查以确保条件满足。

有一个限制是面向对象编程构成接口的方式:子类完全在控制之内,而父类仅仅是接受子类给它的控制。如果父类想在子类中加入一个约束,它只能在事后检查这个约束(即“后置条件”,但技术上仍然是通过前置条件来实现)。

实际上不可能到达的代码路径

真正的需要执行某些代码的条件过于复杂,以至于这些代码基本不可能执行。尤其当我们对函数返回值进行检查时:我们确实想检查所有的错误结果,但我们无法设计出真正抵达这些错误路径的测试案例。与其写一个无法测试的可恢复的代码,我们还不如在这个代码路径上用一个 preconditionFailure 或者 fatalError,以明确说明这个分支不可能执行。

例如,在某些 C 函数中,存在内存分配失败的返回路径。在现代操作系统中,通常不可能出现内存分配失败(在内存分配失败之前,OS 会 kill 掉这个进程),因此编写检查和处理这种情况的代码完全是在浪费我们的时间。

强制性正确

某些情况下接口设计者想随意地让使用者看到一些错误。有一种观点认为,保守性编程将粗心的使用者拒之于门外,实际上鼓励了糟糕的编程,并让使用者不能理解到底为什么出错;相反,我们应当强制让糟糕的程序员去解决他们犯下的错误。

我认为这种观点在 C 语言类的语言中是有用的,C 语言返回的 int 类型的错误经常被使用者忽略,只有致命错误才会引起他们注意。在 Swift 中,我认为这种做法是不恰当的。使用者不可能忽略一个 Swift Optional 或者 throws,他们会通过返回结果中的“无效的参数”知道自己犯错了,就像他们从前置条件失败中知道的一样——事实上,哪怕使用者不知道有前置条件错误存在,但 throws 关键字是必需被处理的,因此一个从来没见过的错误仍然会在运行时得到正确的处理。

逻辑检查

assert 函数通常用于测试“软”后置条件(它返回 false 而不是更严重的 failure)以及其它的编程逻辑。

如果你不加以注意,assert 在 Debug 模式(用 ‘-Onone’ 进行编译)下和 precondition 没任何区别,但在 Release 模式下它没有作用(用 ‘-O’ 进行编译)。这种不同的行为导致的影响比较复杂,但本质上 assert 仍然适用于在 Debug 下的致命的测试条件,因此它在函数中的作用仍然等于不完全函数。

实际上,assert 在前置条件和逻辑测试之间摇摆不定,前者不适合于在 Release 下进行测试(导致不确定的行为),后者只应当在你的测试代码中而不是正常的代码中。

我个人觉得,如果 Release 下当前置条件的计算量太过于繁重时 assert 是个不错的选择。在其他情况下,你应当用 precondition(因为你确实想让条件成立)或者将代码移到测试代码中(这不是一个真正的前置条件,你可以用特定的方式进行校验)。

结论

带有一个或多个前置条件的函数是不完全函数(只有类型签名中指定的值的子集是有效的)。在使用不完全函数时,每个前置条件都代表了一个你可能会犯下的潜在的错误。和类型约束不同(程序员可以在编译时发现这个错误),前置条件错误会在运行时引发一个致命错误。

致命错误是不对的,你可以通过避免不完全函数来避免错误的发生。

不要在不检查前置条件的情况下取消不完全函数 因为:这会比让程序崩溃更加可怕。不检查前置条件会导致不确定的行为,导致错误传播,可能导致“更糟糕”的情况发生。它还会妨碍我们进行调试,使错误不能被快速找到。如果你的函数有约束条件,请检查它们!

我们可以通过修改设计和消灭前置条件来避免不完全函数。

只有在类型约束允许的值不能满足运行时期望的时候才需要前置条件。如果你修改了类型约束(让输入类型中的每个值都满足运行时期望)或者改变运行时期望(处理类型约束中的每一个值),则可以不需要前置条件。

也可以简单地返回一个 Optional 而不是一个简单值。或者定义一个输入类型,让它在构造时就检查是否满足条件(如果不满足则返回 nil)。使用 Swift 特有的条件语句进行解包操作和错误处理,大量使用这种条件语句的代价非常低,因此不完全函数应该是非常少见的。

尽管如此,不完全函数仍然有存在的必要。有一些很奇特的原因,它们必须存在,而在另外一些场景下,它们使用得频繁。由于在 Swift 标准库中使用了不完全函数,所以在某种程度上,几乎所有的 Swift 程序都在使用不完全函数,因此你需要注意。

因此,你可能会需要创建自己的不完全函数。在下一部分,我将展示通过捕捉 precondition 来测试不完全函数,这样你就可以确认所创建的不完全函数是否和预期一样能够触发致命错误。

0 0