可选值

问题及解决方法

哨岗值 - 可选值产生的背景

在编程世界中有一种非常通用的模式,那就是某个操作是否要返回一个有效值。很多情况下,一些操作由于各种原因没有正常的返回期望的值,而是返回了一个“魔法”数来表示没有返回真实的值。这样的值被称为“哨岗值”。而由于我们忘记检查哨岗值而导致程序出错,此外,这些哨岗值的检查非常麻烦具有很多不确定性,可能我们需要查看文档,可能文档也是错误的。

通过枚举解决魔法数的问题

大多数语言支持某种类型的枚举,Swift更进一步,他的枚举中包含‘关联值’的概念。也就是说枚举可以在它们的值中包含另外的关联的值,如下:

enum Optional<T> {
  case None
  case Some(T)
}

获取关联值的唯一方法是使用 switch 或者 if case 语句。和哨岗值不同,除非你显式地检查并解包,你是不可能意外地使用到一个 Optional 中的值的。

因此,Swift 中与 find 等效的方法 indexOf 所返回的不是一个索引值,而是一个 Optional<Index>。它是通过协议扩展实现的:

extension CollectionType where Generator.Element: Equatable {
  func indexOf(element: Generator.Element) -> Optional<Index> {
    for idx in self.indices where self[idx] == element {
      return .Some(idx)
    }
    // 没有找到,返回 .None
    return .None
  }
}

可选值遵守 NilLiteralConvertible 协议,因此你可以用 nil 来替代.None;像上面 idx 这样的非可选值将在需要的时候自动“升级”为可选值,这样你就可以直接写 return idx,而不用 return .Some(idx)。

现在,用户就不会错误地使用一个无效的值了:

  var array = ["one", "two", "three"]
  let idx = array.indexOf("four")
  // 编译错误:removeIndex takes an Int, not an Optional<Int>
  array.removeAtIndex(idx)”

如果你得到的可选值不是 .None,现在想要取出可选值中的实际的索引的话,你必须对其进行“解包”:

switch array.indexOf("four") {
  case .Some(let idx):
    array.removeAtIndex(idx)
  case .None:
    break // 什么都不做
}

Swift 2.0 中引入了使用 ? 作为在 switch 中对 Some 进行匹配的模式后缀的语法,另外,你还可以使用 nil 字面量来匹配 None:

switch array.indexOf("four") {
  case let idx?:
    array.removeAtIndex(idx)
  case nil:
    break // 什么都不做
}

可选值概览

if let

使用 if let 来进行可选绑定 (optional binding) 要比上面使用 switch 语句要稍好一些:

if let idx = array.indexOf("four") {
  array.removeAtIndex(idx)
}

if let idx = array.indexOf("four") where idx != array.startIndex {
  array.removeAtIndex(idx)
}

你也可以在同一个 if 语句中绑定多个值。更赞的是,后面的绑定值可以基于之前的成功解包的值来进行操作。这在你想要多次调用一些返回可选值的函数时会特别有用。

let urlString = "http://www.objc.io/logo.png" where url.pathExtension == "png"
if let url = NSURL(string: urlString),
data = NSData(contentsOfURL: url),
image = UIImage(data: data)
{
    let view = UIImageView(image: image)
    XCPlaygroundPage.currentPage.liveView = view
}

如果你需要在指定 if let 绑定之前执行某个检查的话,可以为 if 提供一个前置的条件。如下:使用 NSScanner 来进行扫描,它将返回一个代表是否扫描到某个值的布尔值,在之后,你可以解包得到的结果。

let stringScanner = NSScanner(string: "myUserName123")
var username: NSString?
let alphas = NSCharacterSet.alphanumericCharacterSet()

if stringScanner.scanCharactersFromSet(alphas, intoString: &username),
let name = username
{
  print(name)
}

while let

while let 语句和 if let 非常相似,它代表一个当遇到 nil 时终止的循环。

标准库中的 readLine 函数从标准输入中读取一个可选字符串。当到达输入末尾时,这个方法将返回 nil。

while let line = readLine() {
  print(line)
}

和 if let 一样,你可以在可选绑定后面添加一个 where 语句。如果你想在遇到 EOF 或者空行的时候终止循环的话,只需要加一个判断空字符串的语句就行了。要注意,一旦条件为 false,循环就会停止 (也许你错误地认为 where 条件会像 filter 那样工作,其实不然)。

while let line = readLine() where !line.isEmpty {
  print(line)
}

let array = [1, 2, 3]
var generator = array.generate()
while let i = generator.next() {
  print(i)
}

双重可选值

一个可选值本身也可以被使用另一个可选值包装起来,这会导致可选值嵌套在可选值中。假设你有一个字符串数组,其中的字符串是数字,你现在想将它们转换为整数。最直观的方式是用一个 map 来进行转换:

let stringNumbers = ["1", "2", "3", "foo"]
let maybeInts = stringNumbers.map { Int($0) }

你现在得到了一个元素类型为 Optional<Int> 的数组,也就是说,maybeInts 是 [Int?] 类型。
当使用 for 循环遍历这个结果数组时,显然每个元素都会是可选整数值,因为 maybeInts 含有的就是这样的值:

for maybeInt in maybeInts {
  // maybeInt 是一个 Int? 值
  // 得到三个整数值和一个 `nil`
}

for...in 是 while 循环加上一个生成器的简写方式,生成器的 next 函数返回的其实是一个 Optional<Optional<Int>> 值,或者说是一个 Int??。

当循环到达最后一个值,也就是从 “foo” 转换而来的 nil 时,从 next 返回的其实是一个非 nil 的值,这个值是 .Some(nil)。while let 将这个值解包,并将解包结果 (也就是 nil) 绑定到 maybeInt 上。解决方法:使用for case语法:

for case let i? in maybeInts {
    // i 将是 Int 值,而不是 Int?
    // 1, 2, 和 3
}
// 或者只对 nil 值进行循环
for case nil in maybeInts {
    // 将对每个 nil 执行一次
}

if var and while var

除了 let 以外,你还可以使用 var 来搭配 if 和 while:

if var i = Int(s) {
  i += 1
  print(i) // 打印 2
}

可选值是值类型,解包一个可选值做的事情是将它里面的值提取出来。所以使用 if var 这个变形和在函数参数上使用 var 类似,它只是获取一个能在作用域内使用的副本的简写,而并不会改变原来的值。

解包后可选值的作用域

有时候只能在 if 块的内部访问被解包的变量确实让人有点不爽,但是这其实和其他一些做法并无不同。
不过如果你从函数中提早退出的话,情况就完全不同了。有时候你可能会这么写:

func doStuff(withArray a: [Int]) {
  if a.isEmpty { return }
  // 现在可以安全地使用 a[0]
}

提早退出有助于避免恼人的 if 嵌套,你也不再需要在函数后面的部分再次重复地进行判断。此外还可以利用swift的延时初始化来实现:

func doStuffWithFileExtension(fileName: String) {
  let period: String.Index
  if let idx = fileName.characters.indexOf(".") {
    period = idx
  } else {
    return
  }

  let extensionRange = period.successor()..<fileName.endIndex
  let fileExtension = fileName[extensionRange]
  print(fileExtension)
}

虽然避免了if嵌套,但是这段代码看起来很丑。我们在这里真正需要的其实是一个 if not let 语句,其实这正是 guard let 所做的事情。

func doStuffWithFileExtension(fileName: String) {
    guard let period = fileName.characters.indexOf(".") else { return }

    let extensionRange = period.successor()..<fileName.endIndex
    let fileExtension = fileName[extensionRange]
    print(fileExtension)
}

在阅读代码时,guard 是一个明确的信号,它暗示我们“只在条件成立的情况下继续”。最后 Swift 编译器还会检查你是否确实在 guard 块中退出了当前作用域,如果没有的话,你会得到一个编译错误。因为可以得到编译器帮助,所以我们建议尽量选择使用 guard,即便 if 也可以正常工作。

可选链

在 Objective-C 中,对 nil 发消息什么都不会发生。Swift 里,我们可以通过“可选链 (optional chaining)”来达到同样的效果。

self.delegate?.callback()

如果你的可选值值中确实有值,那么编译器能够保证方法肯定会被实际调用。如果没有值的话,这里的问号对代码的读者来说是一个清晰地信号,表示方法可能会不被调用。

还有如下情况:

let dictOfArrays = ["nine": [0, 1, 2, 3, 4, 5, 6, 7]]
let sevenOfNine = dictOfArrays["nine"]?[7] ”

let dictOfFuncs: [String: (Int, Int) -> Int] = [
  "add": (+),
  "subtract": (-)
]
dictOfFuncs["add"]?(1, 1)

也可以使用可选值链来进行赋值,如果它不是 nil 的话,赋值操作将会成功:

splitViewController?.delegate = myDelegate”

nil 合并运算符

很多时候,你会想要解包一个可选值,如果可选值是 nil 时,就用一个默认值来替代它。你可以使用 nil 合并运算符来完成这件事:

let stringteger = "1"
let i = Int(stringteger) ?? 0

当你发现你在检查某个语句来确保取值满足条件的时候,往往意味着使用可选值会是一个更好的选择。假设你要做的不是对空数组判定,而是要检查一个索引值是否在数组边界内:

let i = array.count > 5 ? a[5] : 0

不像 first 和 last,通过索引值从数组中获取元素不会返回Optional,不过我们可以对 Array 进行扩展来包含这个功能:

extension Array {
  subscript(safe idx: Int) -> Element? {
    return idx < endIndex ? self[idx] : nil
  }
}

现在你就可以这样写:

let i = array[safe: 5] ?? 0

合并操作也能够进行链接 — 如果你有多个可能的可选值,并且想要选择第一个非 nil 的值,你可以将它们按顺序合并:

let i: Int? = nil
let j: Int? = nil
let k: Int? = 42
let n = i ?? j ?? k ?? 0

可选值 map

在之前你看到过这个例子:

func doStuffWithFileExtension(fileName: String) {
    guard let period = fileName.characters.indexOf(".") else { return }
    let extensionRange = period.successor()..<fileName.endIndex
    let fileExtension = fileName[extensionRange]
    print(fileExtension)
}

我们可以稍作改变,现在不在 else 块中从函数返回,而是将 fileExtension 声明为可选值,并且在 else 中将它设置为 nil:

func doStuffWithFileExtension(fileName: String) {
  let fileExtension: String?
  if let idx = fileName.characters.indexOf(".") {
  let extensionRange = idx.successor()..<fileName.endIndex
  fileExtension = fileName[extensionRange]
  } else {
  fileExtension = nil
  }
  print(fileExtension ?? "No extension")
}

Swift 中的可选值里专门有一个方法来处理这种情况,它叫做 map。这个方法接受一个闭包,如果可选值有内容,则调用这个闭包对其进行转换。上面的函数用 map 可以重写成:

func doStuffWithFileExtension(fileName: String) {
  let fileExtension: String? = fileName.characters.indexOf(".").map { idx in
    let extensionRange = idx.successor()..<fileName.endIndex
    return fileName[extensionRange]
  }
  print(fileExtension ?? "No extension")
}

显然,这个 map 和数组以及其他序列里的 map 方法非常类似。但是与序列中操作一系列值所不同的是,可选值的 map 方法只会操作一个值,那就是该可选值中的那个可能的值。你可以把可选值当作一个包含零个或者一个值的集合,这样 map 要么在零值的情况下不做处理,要么在有值的时候会对其进行转换。

当你想要的就是一个可选值结果时,可选值 map 就非常有用。设想你想要为数组实现一个变种的 reduce 方法,这个方法不接受初始值,而是直接使用数组中的首个元素作为初始值.

[1, 2, 3, 4].reduce(+)

因为数组可能会是空的,这种情况下没有初始值,结果只能是 nil,所以这个结果应当是一个可选值。你可能会这样来实现它:

extension Array {
  func reduce(combine: (Element, Element) -> Element) -> Element? {
  // 如果数组为空,self.first 将是 nil
  guard let fst = first else { return nil }
  return self.dropFirst().reduce(fst, combine: combine)
  }
}

因为可选值为 nil 时,可选值的 map 也会返回 nil,所以我们可以使用不包含 guard 的单 return 形式来重写 reduce:

extension Array {
  func reduce(combine: (Element, Element) -> Element) -> Element? {
    return first.map {
      self.dropFirst().reduce($0, combine: combine)
    }
  }
}

鉴于可选值 map 与集合的 map 的相似性,可选值 map 的实现和集合 map 也很类似:

extension Optional {
  func map<U>(transform: Wrapped -> U) -> U? {
    if let value = self {
      return transform(value)
    }
    return nil
  }
}

可选值 flatMap

如果你的序列中包含可选值,可能你会只对那些非 nil 值感兴趣。实际上,你可以忽略掉那些 nil 值。

设想你需要处理一个字符串数组中的数字。在有可选值模式匹配时,用 for 循环可以很简单地就实现:

let numbers = ["1", "2", "3", "foo"]
var sum = 0
for case let i? in numbers.map({ Int($0) }) {
  sum += i
}

你可能也会想用 ?? 来把 nil 替换成 0:

numbers.map { Int($0) }.reduce(0) { $0 + ($1 ?? 0) }

实际上,你想要的版本应该是一个可以将那些 nil 过滤出去并将非 nil 值进行解包的 map。标准库中序列的 flatMap 正是你想要的:

numbers.flatMap { Int($0) }.reduce(0, combine: +)

可选值判等和比较

在判等时你不需要关心一个值是不是 nil,你只需要检查它是否包含某个 (非 nil 的) 特定值即可:

if regex.characters.first == "^" {
// 只匹配字符串开头
}

上面的代码之所以能工作主要基于两点。首先,== 有一个接受两个可选值的版本,它的实现类似这样:

func ==<T: Equatable>(lhs: T?, rhs: T?) -> Bool {
  switch (lhs, rhs) {
    case (nil, nil): return true
    case let (x?, y?): return x == y
    case (_?, nil), (nil, _?): return false
  }
}

强制解包的时机

上面提到的例子都用了很利索的方式来解包可选值,什么时候你应该用感叹号 (!) 这个强制解包运算符呢?

当你能确定你的某个值不可能是 nil 时可以使用叹号,你应当会希望如果它不巧意外地是 nil 的话,这句程序直接挂掉。

  func flatten<S: SequenceType, T where S.Generator.Element == T?>(source: S) -> [T]{
    return Array(source.lazy.filter { $0 != nil }.map { $0! })
  }

这里,因为在 filter 的时候已经把所有 nil 元素过滤出去了,所以 map 的时候没有任何可能会出现 $0! 碰到 nil 值的情况。
不过使用强制解包还是很罕见的。

第二个例子,下面这段代码会根据特定的条件来从字典中找到值满足这个条件的对应的所有的键:

let ages = [
  "Tim": 53, "Angela": 54, "Craig": 44,
  "Jony": 47, "Chris": 37, "Michael": 34,
]

let people = ages
.keys
.filter { name in ages[name]! < 50 }
.sort()

这里使用 ! 非常安全 — 因为所有的键都是来源于字典的,所以在字典中找不到这个键是不可能的。

改进强制解包的错误信息

其实,你可能会留一个注释来提醒为什么这里要使用强制解包。那为什么不把这个注释直接作为错误信息呢?这里我们加了一个 !! 操作符,它将强制解包和一个更具有描述性质的错误信息结合在一起,当程序意外退出时,这个信息也会被打印出来:

infix operator !! { }
func !! <T>(wrapped: T?, @autoclosure failureText: ()->String) -> T {
  if let x = wrapped { return x }
  fatalError(failureText())
}

let s = "foo"
let i = Int(s) !! "Expecting integer, got \"\(s)\"”

@autoclosure 注解确保了我们只在需要的时候会执行操作符右侧的语句。

在调试版本中进行断言

通常,你可能会选择在调试版本或者测试版本中进行断言,让程序崩溃,但是在最终产品中,你可能会把它替换成像是零或者空数组这样的默认值。

我们可以实现一个疑问感叹号 !? 操作符来代表这个行为。我们将这个操作符定义为对失败的解包进行断言,并且在断言不触发的发布版本中将值替换为默认值:

infix operator !? { }
func !?<T: IntegerLiteralConvertible>(wrapped: T?, @autoclosure failureText: ()->String) -> T{
    assert(wrapped != nil, failureText())
    return wrapped ?? 0
}

现在,下面的代码将在调试时触发断言,但是在发布版本中打印 0:

let i = Int(s) !? "Expecting integer, got \"\(s)\""

对于返回 Void 的函数,使用可选链进行调用时将返回 Void?。利用这一点,你可以写一个非泛型的版本来检测一个可选链调用碰到 nil,且并没有进行完操作的情况:

func !?(wrapped: ()?, @autoclosure failureText: ()->String) {
  assert(wrapped != nil, failureText)
}
var output: String? = nil
output?.write("something") !? "Wasn't expecting chained nil here”

想要挂起一个操作我们有三种方式。首先,fatalError 将接受一条信息,并且无条件地停止操作。第二种选择,使用 assert 来检查条件,当条件结果为 false 时,停止执行并输出信息。在发布版本中,assert 会被移除掉,条件不会被检测,操作也永远不会挂起。第三种方式是使用 precondition,它和 assert 比较类型,但是在发布版本中它不会被移除,也就是说,只要条件被判定为 false,执行就会被停止。

多灾多难的隐式可选值

隐式可选值是那些不论何时你使用它们的时候就自动强制解包的可选值。别搞错了,它们依然是可选值,现在你已经知道了当可选值是 nil 的时候强制解包会造成应用崩溃,那你到底为什么会要用到隐式可选值呢?实际上有两个原因:

  • 暂时来说,你可能还需要到 Objective-C 里去调用那些没有检查返回是否存在的代码。
  • 因为一个值只是很短暂地为 nil,在一段时间后,它就再也不会是 nil。

隐式可选值行为

因为隐式可选值会尽可能地隐藏它们的可选值特性,所以它们在行为上也有一些不一样。

func increment(inout x: Int) {
  x += 1
}

// 普通的 Int
var i = 1
// 将 i 增加为 2
increment(&i)
// 隐式解包的 Int
var j: Int! = 1
// 错误:cannot invoke 'increment' with an argument list of type '(inout Int!)'
increment(&j)

总结

在处理有可能是 nil 的值的时候,可选值会非常有用。相比于使用像是 NSNotFound 这样的魔法数,我们可以用 nil 来代表一个值为空。Swift 中有很多内置的特性可以处理可选值,所以你能够避免进行强制解包。隐式解包可选值在与遗留代码协同工作时会有用,但是在有可能的情况下还是应该尽可能使用普通的可选值。最后,如果你需要比单个可选值更多的信息 (比如,在结果不存在时你可能需要一个错误信息提示),你可以使用抛出错误的方法。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,761评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,953评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,998评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,248评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,130评论 4 356
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,145评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,550评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,236评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,510评论 1 291
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,601评论 2 310
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,376评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,247评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,613评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,911评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,191评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,532评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,739评论 2 335

推荐阅读更多精彩内容