专注、坚持

[译] 摊平由「try?」造成的嵌套可选

2019.07.08 by kingcos

介绍

Swift 中的 try? 语句目前很容易引入嵌套可选(译者注:嵌套可选即类似 var foo: String?? 可选的可选类型)。而用户难以推断嵌套可选的产生原因,所以 Swift 尝试避免在一些常见情况下产生嵌套可选。

该文档提议给予 try? 与其它常见的 Swift 功能中相同的可选摊平行为(译者注:可选摊平即将可选类型的值去掉可选的一层),来避免常见的嵌套可选。

Swift-evolution 帖子: 使 try? 与可选链摊平协同工作

动机

目前,使用 try? 非常容易产生嵌套的 Optional 类型。虽然构建嵌套可选是被允许的,但这通常并非是开发者所希望的。

Swift 拥有许多机制来避免意外创建嵌套可选。比如:

// 注意 as? 是如何可以无视被转换的值是否是可选类型的,其总是产生了相同的类型
let x = nonOptionalValue() as? MyType    // x 是 'MyType?' 类型
let y = optionalValue() as? MyType       // y 是 'MyType?' 类型

// 注意可选链可以无视调用者是否产生可选值,其总是产生了相同的类型
let a = optThing?.pizza()             // a 是 'Pizza?' 类型
let b = optThing?.optionalPizza()     // b 是 'Pizza?' 类型

但是 try? 的行为不同:

let q = try? harbor.boat()           // q 是 'Boat?' 类型
let r = try? harbor.optionalBoat()   // r 是 'Boat??' 类型

上述例子是特意举例的,但其实在生产环境的代码中,也很容易造成嵌套可选。举个例子:

// 由于 'foo' 的可选链,'foo?.makeBar()' 的结果是 'Bar?'。
// 'try?' 增加了额外一层可选。所以 'x' 类型是 'Bar??'
let x = try? foo?.makeBar()

// JSONSerialization.jsonObject(with:) 返回 'Any'。
// 我们使用 'as?' 来确认结果是否是期望的类型,但 'try?' 增加了额外一层可选,导致结果 'dict' 现在是 '[String: Any]??' 类型。
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]

虽然使用 try? 很容易造成嵌套可选,但对现存代码的一个调查显示,这并非是理想的结果。使用 try? 和嵌套可选的代码几乎总是伴随着以下几种模式之一:

// 模式 1:双层 if-let 或 guard-let
if  let optionalX = try? self.optionalThing(),
    let x = optionalX {
    // 在这里使用 'x'
}

// 模式 2:引入括号并使用 'as?' 摊平
if let x = (try? somethingAsAny()) as? JournalEntry {
    // 在这里使用 'x'
}

// 模式 3:模式匹配
if case let x?? = try? optionalThing() {
    // 在这里使用 'x'
}

这些变通方法使得语言更加难以学习和使用,且没有任何好处或者回报。

使用 try? 的代码通常不关心错误和空结果的区别,这就是为什么所有这些模式只关注取出值而忽略错误。如果开发者关心错误,那么应该使用 do/try/catch 取而代之。

提议解决方案

在 Swift 5 中,try? someExpr() 将和 foo?.someExpr() 行为一致:

  • 如果 someExpr() 返回非可选值,try? someExpr() 将会被包在可选中。
  • 如果 someExpr() 返回 Optionaltry? someExpr() 将不会再额外附加可选。

这将导致以下的 try? 表达式类型改变:

// Swift 4: 'Int??'
// Swift 5: 'Int?'
let result = try? database?.countOfRows(matching: predicate)


// Swift 4: 'String??'
// Swift 5: 'String?'
let myString = try? String(data: someData, encoding: .utf8)

// Swift 4: '[String: Any]??'
// Swift 5: '[String: Any]?'
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]

当子表达式返回非可选类型时,整体类型将不会发生改变。

// Swift 4: 'String?'
// Swift 5: 'String?'
let fileContents = try? String(contentsOf: someURL)

如果子表达式已经是嵌套可选,结果将和此嵌套层级一致:

func doubleOptionalInt() throws -> Int?? {
    return 3
}

// Swift 4: 'Int???'
// Swift 5: 'Int??'
let x = try? doubleOptionalInt()

关于 try?as? 的额外说明

虽然 as? 常有摊平可选类型的作用(如上在「动机」部分的举例),但这与提议的 try? 并非是完全相同的行为。由于 as? 采用明确的类型,所以它实际上可以摊平多个层级的嵌套可选。无论 foo 上有多少层可选,foo as? T 将总是返回 Optional<T>。取决于指定的类型,as? 可以潜在地增加或减少可选的层级。(as? 同样可以在子类型与父类型之间转换,这与正在审议的行为无关。)

在实践中,as? 与嵌套可选最常使用在将 T?? 降级到 T?,这与使用可选链以及提议的 try? 类似。但 as? 是比提议的 try? 功能更加丰富有力的结构。

细节设计

在 Swift 4 中,try? 表达式的类型被定义为 Optional<T>T 表示位于 try? 之后的表达式类型。

在 Swift 5 中,try? 表达式的类型将变为某种 UUOptional<_> 类型且子表达式类型 T 能够强制转换为 U。类型约束系统将自动选择满足约束的最小嵌套可选层级,这将导致本提案中描述的行为。

泛型

关于与泛型互通性的一些问题被提出,如下例:

func test<T>(fn: () throws -> T) -> T? {

    // 如果 T 是可选类型,该行是否会改变行为?
    if let result = try? fn() {
        print("We got a result!")
        return result
    }
    else {
        print("There was an error")
        return nil
    }
}

// T 在这里被推断为 'Int'
let value  = test({ return 15 })

// T 在这里被推断为 'Int?'
let value2 = test({ return 15 as Int? })

答案是这与 T 在运行时是否可选无关。在编译时刻,result 拥有清晰的类型:T。这在 Swift 4 和 Swift 5 的模式中都是正确的,因为 T 在编译时刻无法得知是否是 Optional 类型,try? 表达式额外增加了一层 Optional,之后又通过 if let 解包。

使用 try? 的泛型代码仍可继续使用,而无需关心泛型是否可能在运行时变成可选。这种情况的行为并没有发生改变。

源代码兼容

如果表达式自身没有明确摊平可选,这对于操作可选子表达式的 try? 表达式来说是一个打破源的改变。尽管目前出现的情况很少,但详见下面的分析。当编译器运行在 Swift 4 模式下,我们可以提供向后兼容的行为,且为大多数常见情况提供迁移的可能。

关于 Swift 5 中包含打破源变化的讨论很激烈,但我相信最终能通过。以下是 swift-evolution README 中列出的部分标准:

1. 当前的语法或 API 必须主动展示可能为用户带来的问题

嵌套可选是一个复杂的概念。其在语言中存在是有价值的,但对于其使用应当是目的明确的。由于目前 try? 和可选链或 as? 转换之间的交叉使用,初学者很容易不明所以地造成嵌套可选。

实际检测错误 try? 要比使用 try更加困难。对比:

// 使用 'try?'
if let result = try? foo?.bar() {
    // 额外对 'result' 做些什么,尽管在 if let 内其仍可能为 nil
}
else {
    //出现错误,但不清楚是什么
}
// 使用 'try/catch'
do {
    let result = try foo?.bar()
    // 额外对 'result' 做些什么,由于可选链其仍可能为 nil
}
catch {
    // 处理错误
}

使用 try? 的变体将变得非常凌乱(比如关于 result 是什么类型?),而且没有显而易见的优点。在不关心 else 语句且只希望处理存在的值时,try? 是更好的选择,但提议的改变将更好的作用于这种情况。

由于与 as? 转换的交叉使用,因此当前的语法仍比较痛苦,如下:

if let x = try? foo() as? String {
    // `String` 是这里想要得到的类型,
    // 但意外的是 `x` 是 `Optional<String>` 类型
}

2. 新语法 / API 必须更加清晰且不与现存的 Swift 语法冲突

提出的变更更好地解决了以上所有的问题。此次变更同样明确了 try? 的角色,即在可能的情况下访问值,而非 try/catch 类似的错误处理机制。

3. 已存在的代码必须支持合理地自动迁移

如下分析,大多数源代码将无需迁移;已经遇到产生嵌套可选的开发者,可能已经使用了兼容模式。该提案简单地提供了一种简化的方式。

自动迁移已为双 if/guard letcase let value??: 这些上述已提到的模式实现。

Swift 源兼容性套件分析

Swift 源兼容性套件建议不要为大多数用户制造巨大的改变。我手动检查了 try? 在兼容性套件中的使用案例。以下是结果:

  • 兼容性套件中总共有 613try? 的用例。其中绝大多数使用在非可选子表达式中,不受该提议的影响。

  • try? ... as? 的用例有 4 个。它们都将 try? 包裹在括号中以使用 as? 的摊平行为,且这将是兼容的。它们看来像这样:

    (try? JSONSerialization.jsonObject(with: $0)) as? NSDictionary
    
  • 3 个项目中共有 12try? foo?.bar() 的用例。其中 10 个为 _ = try? foo?.bar(),所以结果的类型不重要。其中 2 个拥有 Void 类型的子表达式,且没有赋值结果到任何变量。

  • 6try? somethingReturningOptional() 的用例。它们全都使用 flatMap { $0 } 手动摊平,且因此兼容,因为该表达式的返回类型在任一行为下都是相同的。

    (try? get(key)).flatMap { $0 }
    
  • 据我所知,整个套件中没有双可选被实际用来区分错误和 nil 转值的情况。

  • 据我所知,兼容套件中没有发现任何源代码不兼容的情况。

ABI 稳定性的影响

对 ABI 无影响。

API 适应性的影响

try? 表达式永远不会暴露在函数边界,因此 API 适应性理应不受影响。

考虑过的替代方案

改变 try? 的绑定优先权

对于类似 let x = try? getObject() as? Foo 的表达式,可以通过转换表达式为 (try? getObject) as? Foo,来消除嵌套可选。显式添加括号已经是双可选问题的常用变通方法。

但此举不能解决 try? 与可选链(例如:try? foo?.bar?.baz())的情况,以及不能解决从函数直接返回的一个可选结果(例如:try? cachedValue(for: key))。

改变 try? 的绑定优先权可能造成比该提案更加深远的源变化。

什么也不做

在当前的模式下写出正确的代码是可以的。我们并非提出完全从语言中移除嵌套可选,我们只是希望用户弄清楚它们。

这是一个可行的解决方案,但作为语法糖的一部分,为简化日常使用却拥有如此复杂的结构是很奇怪的。