原文:Optionals in Swift - AndyBargh.com
Automatically update if let and guard let for Swift 5.7
Swift 编程语言带来了许多新功能,使开发应用程序比以前更迅捷、更容易、更安全。其中一个新特性就是可选类型( Optionals)。它们在 Swift 中随处可见,但对于刚接触这门语言的人来说可能会感到困惑,而对于不完全了解它的人来说则会感到沮丧。这篇文章的主要目的是通过深入了解 Optionals,为这个可能令人困惑的世界带来一丝光明。
可选类型解决了什么问题?
首先让我们看一下可选类型所要解决的问题。
在代码中处理值不存在情况的问题从计算机诞生之初就存在了。在一个理想的世界里,我们的代码总是与完整且定义良好的数据一起工作,但这并不总是如此。在许多编程语言中,程序员在这种情况下会使用特殊值,他们用这些(特殊)值来试图表示一个值的缺失。一个常用的例子是 nil
,它经常被用来表示没有任何值。然而,这也有问题。
在大多数编程语言中,nil
只能用来表示引用类型,一般不支持用 nil
来表示值类型。其结果是,对于值类型,开发者仍然不得不发明自己的编码来表示一个变量没有值的情况。
除此之外,尽管开发者能够想出这些特殊的编码,但大多数语言中都没有内置任何规范来描述何时可以或不可以使用这些编码。一般来说,在函数或方法的声明中,没有任何东西可以强调是否接受 nil
值,同样也没有任何东西可以表明这些函数或方法是否可以返回一个 nil
值。
在许多情况下,这种类型的信息的唯一来源是文档。但很多时候,文档要么没有,要么根本不完整。其结果是,我们经常要猜测到底如何使用一个特定的函数或方法,这就不可避免地导致了错误和难以发现的问题。
有了 Swift,情况就不同了。Swift 试图通过在语言中直接包含特殊语法来解决这些问题,以处理值不存在的情况。这就是 Optionals
出现的原由。
什么是可选类型
在 Swift 中,可选类型用来描述一种类型可能不存在值的情况。
可选类型表示两件事的结合。它们表示如果当前类型有值,这个值将是一个指定的类型,同时也表示可能根本没有值。与其他语言类似,Swift 使用 nil
来表示根本没有值的情况。
通过将可选类型直接纳入语言,Swift 迫使我们清楚地了解一个特定的变量、常量、参数或返回值是否可以为零,并在代码中这样做,而不是迫使我们依靠文档来获得这类信息。
如果一个参数或返回值的类型被标记为可选类型,那么一个值可能是 nil
。如果不是,那么我们可以 100% 确定返回值永远不会是 nil
,而且编译器会实际执行这一事实。
这样做有很多好处。
首先,它节省了我们阅读 Swift 代码时的大量心理负担,大大减少了我们对文档的依赖性。
其次,通过迫使我们在编写代码时考虑并处理潜在的 nil
值,有助于消除以前可能在运行时才会出现的问题。
最后,通过明确说明什么时候值可能是或不可能是 nil
,我们还可以让编译器替我们执行一些检查。这最后一点是一个巨大的优势。
无论我们喜欢与否,人类大脑对代码可执行众多路径的思考能力是有限的。在许多情况下,要识别所有的路径和相关的边缘情况可能是非常困难的。再加上识别其中哪些可能导致一个值为零的任务,这几乎成为不可能的事。然而,编译器在这方面比我们强得多,它可以帮助识别和检查这些边缘情况,寻找我们可能遗漏的任何 nil
值。这样做可以防止一大类运行时问题的发生。
所以,我们已经了解了什么是可选类型,我们也知道了在语言中加入可选类型的一些好处,但我们实际上如何声明它们呢?让我们接下来看看这个问题。
声明可选类型
在 Swift 中,我们通过在普通类型的末尾添加一个问号(?
)来声明一个可选类型。我们可以对 Swift 中的任何类型的值都这样做,无论是引用类型还是值类型。
例如,如果我想声明一个整数类型,我通常会通过声明常量或变量为 Int
类型来表明这一点。如果我想声明这个常量或变量是可选的,我会在类型后面添加一个 ?
来声明这个常量或变量是一个可选的整数类型:
swift
var optionalInt: Int?
上面的代码将 optionalInt
变量声明为 Int
类型,这表明它可以包含一个 Int
类型的值,但在某些情况下可以包含 nil
。
现在,有一件事我应该在这一点上指出来。可选类型与非可选类型不是同一类型。如果我们尝试运行下面的代码,我们就可以看到这一点:
swift
var a: Int = 1
var b: Int? = 2
var c = a + b // 编译器会显示以下错误
// Value of optional type 'Int?' must be unwrapped to a value of type 'Int'
从编译器的角度来看,可选类型和非可选类型是两种不同的类型,不能在同一个表达式中一起使用。如果你仔细想想,这其实是符合逻辑的。试图将一个整数值添加到可能包含也可能不包含整数值的另一个实体中,其实是没有意义的。
从本质上讲,我们可以把可选类型看作是一个盒子,它可能包含也可能不包含我们指定类型的值。为了使用这个盒子里的值,我们必须先把它打开,检查是否真的有一个值存在。在 Swift 中,这个打开盒子的过程被称为解包(unwrapping),我们可以通过三种主要方式来实现它。
解包可选类型
强制解包可选类型
在 Swift 中,解包可选类型的第一种方法叫做强制解包(force unwrapping) 。强制解包使用强制解包操作符,我们把它写成一个感叹号(!
),直接写在可选常量名或变量名的后面。
感叹号告诉编译器直接解包可选类型并使用它。没有 "如果",没有 "但是",也没有检查。从本质上讲,它关闭了编译器为确保盒子内实际包含一个值而进行的所有正常检查,而将这一责任转移给了作为程序员的你。这种责任带来了一些相关的风险。
很多时候,我们并不真正知道一个可选类型是否包含值,如果我们使用强制解包操作符,结果发现可选类型不包含一个值,我们就会无意中触发运行时异常,从而导致应用程序崩溃。
如果我们想使用这个强制解包操作符,谨慎的做法是在强制解包之前,首先检查可选类型是否包含一个值。最简单的方法是使用一个 if
语句来实现:
swift
var c: Int = 3
var d: Int? = 4
var result: Int
if (d != nil) {
result = c + d!
}
如你所见,在 Swift 中执行这些检查是很常见的,而且到处写 if
语句并不理想,所以 Swift 为我们提供了一种替代语法,几乎不需要使用强制解包。这就是所谓的可选绑定(optional binding)。
可选绑定
可选绑定是 Swift 中解包可选类型的第二种方式,并且由语法直接构建在语言中来支持它。
可选的绑定允许我们在一行代码中同时检查一个可选项并将其值(如果存在的话)提取到一个临时常量或变量中。Swift 中的 if
和 while
语句都提供了这方面的支持。让我们看一个例子:
swift
if let e = d {
// 在 if 语句块中,如果可选类型 `d` 不为 nil,
// `e` 就包含解包后的值
result = c + e
}
正如你所看到的,可选绑定的语法是相对简单的。
我们使用 if
语句,后面是 var
或 let
关键字(取决于我们是否要声明一个临时变量或常量),后面是赋值运算符,然后是我们要检查的可选类型名称。
简单的说,这段代码可以读作。"如果可选类型 d 包含一个值,则将一个名为 e 的新常量设置为该可选类型中包含的值"。 如果可选类型包含一个值(我们使用 if
语句来检查),那么临时常量 e
就可以作为 if
语句主体中的一个局部常量来使用,这样我们就不需要强制解包 d
中的值了。
阴影?
这看起来很奇怪,但是当我们用这种方式解包一个可选类型时,我们也可以将可选类型解包成一个新的临时常量,其名称与原可选类型完全相同。
swift
if let d = d {
// Inside the if statement `d` contains the unwrapped value
// from the original optional `d` if it is not nil.
result = c + d
}
这是一个被称为 阴影(shadowing) 的例子,其效果是创建了一个新的临时常数,叫做 d
,在 if
语句块的内部可用,在外部内隐藏或阴影了变量 d
。
如果你检查一下值的类型,你就可以看到这一点。而内部作用域中的 d
是 Int
类型(一个正常的值类型)。通过使用阴影,这意味着你不必使用强制解包就可以在内部作用域中使用 d
中的值。
不过这种方法有优点也有缺点。
有些人喜欢这种方法,因为这意味着他们不必为解包后的值考虑或记住一个新的变量名。
但也有人不喜欢这种方法,因为他们认为解包后的版本应该有一个与原来不同的名字(主要是为了突出可选类型已被解包的事实)。
归根结底,这个问题的答案没有对错之分。我更倾向于使用不同的变量或常量名称,因为我认为这能使事情更清晰,但这纯粹是一种风格上的选择,你必须自己选择。
在我们结束可选的绑定之前,我还想给你看几个其他的例子。
在一行代码中绑定多个可选项
Swift 刚推出的时候,我们一次只能对一个选项进行绑定。这就导致了与下面类似的情况(通常被称为厄运金字塔(pyramid of doom )),随着我们想要绑定的选项数量的增加,我们会得到越来越多的嵌套 if
语句。
swift
var k : Int? = 4
var l: Int? = 8
if let m = k {
if let n = l {
// Do something with m and n
} else {
print("l contained nil")
}
} else {
print("k contained nil")
}
然而,Swift 1.2 添加了在一行代码中绑定多个可选类型的能力。这有助于我们以更紧凑的形式编写代码,if
语句的第一个分支只有在所有可选绑定执行成功后才会执行:
swift
if let m = k, n = l {
print(m + n)
} else {
print("k or l contained nil")
}
// prints "12"
我们还能够使用 where
子句将一个或多个绑定与一个可选的布尔表达式结合起来。同样,所有这些都在一行代码中。在这种情况下,只有当所有的可选表达式都包含一个值并且 where
子句判定为真时,绑定才会发生:
swift
var o : Int? = 4
if let p = o where o > 2 {
print(p)
}
// prints "4"
隐式解包可选类型
如果你开始长期使用可选类型,你很快就会注意到与之相关的额外的语法层,这层语法往往会使我们的代码更加难以阅读。但在某些特殊情况下,当我们知道我们的可选类型将包含一个非 nil
值时,Swift 允许我们免除额外的可选语法,允许我们像其他常量或变量一样使用选项。但要做到这一点,我们必须将我们的可选类型标记为隐式解包(implicitly unwrapped),这是我们在 Swift 中解包可选类型的第三个机制。
隐式解包可选类型是一个有点奇怪的野兽。一方面,它的行为类似于可选类型(因为它们可以被设置为 nil
,而且我们可以检查它是否为 nil
),但另一方面,编译器会在每次访问它时自动解包,这使得我们可以省去到目前为止一直使用的所有解包可选类型的语法。
为了将一个可选类型标记为隐式解包(而不是普通的可选类型),我们在类型后面使用感叹号(!
),而不是问号(?
) 。你可以在下面的例子中看到这一点:
swift
// 不使用隐式解包
let possibleInt : Int? = 4
let forcedInt: Int = possibleInt!
// 使用隐式解包
let assumedInt : Int! = 4
let implicitInt = assumedInt
正如你所看到的,通过将 assumedInt
变量标记为隐式解包,我们不再需要使用在本例第一部分中使用的强制解包操作。相反,我们可以简单地访问该变量,就像它是一个非可选类型一样。
但是有一个问题。
通过将一个可选类型标记为隐式解包,我们向编译器承诺,当访问该可选类型时,该可选类型将始终包含一个非零值。
与我们之前看到的强制解包操作符类似,如果我们破坏了这个承诺(并且隐含解包的可选类型在被访问时不包含一个值),Swift 将触发一个运行时错误,并导致应用程序崩溃。
swift
// 声明了一个隐式解包可选类型
var greeting: String! = "hello world"
greeting.capitalizedString // Returns "Hello World"
// 某些情况下,该隐式解包可选类型为 nil 时,直接访问就会导致应用崩溃
greeting = nil // As it's an optional we can set its value to nil
greeting.capitalizedString // CRASH! - Can't send messages to nil!
基于这一点,隐式解包可选类型与强制解包的注意事项是一样的。
只有当我们 100% 确定一个可选类型在被访问时不会为零时,我们才应该将其标记为隐式解包,如果有任何疑问,我们应该使用一个非可选类型的值(如果可以的话)或者一个正常的可选类型。
一般来说,隐式解包可选类型是非常危险的野兽,有导致运行时异常的高风险,但话虽如此,在 Swift 的一些特定情况下,使用它们是必不可少的。接下来让我们来看看这些情况。
在构造器中使用隐式解包可选类型
说到 Swift 中类的初始化,有一些相当严格的规则。其中一条规则(我直接引用 Swift 编程语言指南 中的内容)是:
Classes and structures must set all of their stored properties to an appropriate initial value by the time an instance of that class or structure is created. Stored properties cannot be left in an indeterminate state
类和结构体必须在创建该类或结构体的实例时将其所有的存储属性设置为适当的初始值。存储属性不能被留在不确定的状态中。
在现实中,始终满足这种说法是相当棘手的。这其中有很多原因。
有时我们在创建一个类或结构体时没有足够的信息来提供一套合理的初始值。有时,初始化这些属性是没有意义的。不管是什么原因,在没有完全初始化类或结构体的情况下退出初始化阶段是很常见的。UIViewControllers
就是这样的一个例子。
对于 UIViewController
(以及许多其他基于视图的类)来说,初始化被分成两个不同的阶段。
在第一阶段,UIViewController
类的实例被创建,初始值被分配给该实例的不同属性(通常是通过它们的 init
函数的一些变体)。但问题是,在这一点上,该类的 IBOutlets
还没有被连接,因为该类的视图还没有被加载。这使得该类在 init
函数结束时被部分初始化。
直到初始化的第二阶段,该类的视图才被加载。此时,视图和任何在 loadView
或 viewDidLoad
方法中创建的子视图都被添加到视图层次中,IBOutlets
在被呈现在屏幕上之前被连接起来。这里需要注意的关键点是,这是在类的主要初始化完成后进行的。
那么问题来了,如何满足 Swift 的要求,也就是说:即使我们还不能连接视图和 IBOutlets
,在初始化结束时,类的所有存储属性都有适当的初始值。这就是隐式解包可选类型的用武之地。
通过将 IBOutlet
属性定义为隐式解包,我们能够满足 Swift 的要求。这是因为作为可选类型,它们的初始值默认为 nil
,因此在 Swift 编译器眼中被认为是初始化的。
把它们标记为隐式解包的可选类型(而不是普通可选类型)的好处是,一旦连接起来,这些属性仍然可以像普通的非可选类型属性一样被引用,而不是使用我们已经看到的额外的可选类型语法:
swift
var label : UILabel!
//...
label.text = "Hello World"
正如你所看到的,这是一个相当特殊的情况,但在隐式解包的可选类型中工作得相当好。
在可失败构造器中使用隐式解包可选类型
在 Swift 中使用隐式解包可选类型的第二个例子是在可失败构造器中使用它们。
在编写 Swift 代码时,定义一个初始化可能失败的类、结构体或枚举有时会很有用。这可能有很多原因。初始化参数不正确或缺失,没有一些外部资源或其他一系列的原因之一。
为了应对这些情况,Swift 允许我们定义一个或多个可失败构造器作为结构体、类或枚举定义的一部分。这些可失败构造器是可失败的,因此它们可以返回一个指定类型的初始化对象,也可以失败并返回 nil
。
为了将一个构造器标记为可失败的,我们在 init
关键字后面但在方法括号之前添加一个?
:
swift
// This WON'T compile!!
class PersonClass {
let name : String
init?(name: String) {
if name.isEmpty { return nil }
self.name = name
}
}
// Returns the error: All stored properties of a class must be
// initialized before returning nil from an initializer.
不过你可以看到,在这个例子中,我们有一个问题。它不会被编译。
在这个例子中,我声明了一个有可失败构造器的类,在这种情况下,如果传入构造器的名字是一个空的字符串,初始化就会失败。
现在,可失败构造器的一般规则是,一旦遇到错误,就返回 nil
。但根据定义,这意味着在构造器返回时,对象中的所有属性可能都已经被初始化。Swift 已经发现了这个事实,因此不会编译我们的代码,因为它违反了 "所有属性必须被初始化的规则"。
正如我们刚刚讨论的那样,隐式解包的可选类型允许我们在有效值尚未分配的情况下,定义一个属性的初始值为 nil
,但仍然允许我们访问这些值,而不需要额外的可选类型语法的负担。我们可以通过可失败构造器来利用这一事实。
在这个例子中,如果我们把 name
属性从普通的String
类型改为隐式解包的optional
类型,我们就可以满足 Swift 的要求,同时还可以用普通的属性语法来访问这些属性:
swift
class PersonClass {
var name : String! // 将存储属性声明为隐式解包可选类型
init?(name: String) {
if name.isEmpty { return nil }
self.name = name
}
}
顺便提一下,为了让这个方法在 Swift 2.0 中发挥作用,我们还必须将 name
属性从常量改为变量。
这是由于早期版本的 Swift 存在一个漏洞,可能导致常量属性在构造器中被多次赋值。苹果公司后来关闭了这个漏洞,但结果是,我们现在必须在这种情况下使用变量而不是常量。从这个论坛主题中可以看出,Chris Lattner 认为这是语言中的一个缺陷,所以它可能会在未来的 Swift 版本中被修复,但现在,请记住这一点。
2021-12-22
译者注:我在 Xcode Version 13.2.1 中测试以上代码时,没有遇到编译器编译不通过的情况。
swift
struct Animal {
let species: String
init?(species: String) {
if species.isEmpty { return nil }
self.species = species
}
}
let someCreature = Animal(species: "Giraffe")
// 这里 someCreature 的类型是 Animal? 而不是 Animal
if let graffe = someCreature {
print("An animal was initialized with a species of \(graffe.species)")
}
// prints "An animal was initialized with a species of Giraffe"
总之,我打算暂时把隐式解包的可选类型留在那里。它们有很多东西,也有很多东西需要你去思考,但是请记住,它们是为 Swift 中一些非常特殊的用例而设计的,在这些情况下,它们非常好,但是除了这些特殊情况,它们(就像强制解包)有很高的运行时错误风险,所以要小心使用它们。
随着隐式解包可选类型的结束,让我们来看看今天的事情,看看空合运算符,这是 Swift 中的一个新运算符,与可选类型一起使用时特别有用。
空合运算符
有时,当一个可选类型没有值时,你想提供一个默认值。显然,我们可以用 if
语句或使用三元操作符来做到这一点:
swift
let optionalValue = myFunc() // May return `nil`
7 + (optionalValue != nil ? optionalValue! : 0)
注意:在这种情况下,问号不是用来表示可选类型的,它是三元运算符的语法的一部分。
如果你不熟悉三元运算符,例子中的第二行代码基本上是说:"如果 optionalValue
有一个值,就使用它,否则使用0"。
不过,有了 nil
空合运算符,我们可以对此进行改进。
nil
空合运算符被写成双问号(??
),是一种缩短上述表达式的方法。它的语法与三元组运算符相似,但允许我们省去对 nil
的检查:
swift
7 + (optionalValue ?? 0)
它所做的只是在可选类型包含值的情况下返回解包的可选类型的值,或者在可选类型为 nil
的情况下返回运算符之后的值。这只是一种便捷语法,但确实可以让我们的代码更容易阅读。
总之,今天就到这里了。像往常一样,如果你有任何问题、意见,或者我有任何错误,请与我联系。