Flatten nested optionals resulting from ‘try?’
01 何が問題だったのか
try? は「失敗してもいいから値を取れたらそれで十分」というときに便利な構文ですが、Swift 4 までは try? を使うと思いがけず多重の Optional(いわゆる nested optional)が生まれてしまう、という問題がありました。
Swift には nested optional を作らないようにする仕組みがいくつか備わっています。たとえば as? は対象が Optional かどうかに関わらず結果を T? にそろえますし、optional chaining も foo?.bar() の結果が Bar? になるよう平坦化します。
let x = nonOptionalValue() as? MyType // MyType?
let y = optionalValue() as? MyType // MyType?
let a = optThing?.pizza() // Pizza?
let b = optThing?.optionalPizza() // Pizza?
ところが try? だけはこの平坦化を行わず、元の式が Optional を返す場合にそのうえからさらに Optional をかぶせていました。
let q = try? harbor.boat() // Boat?
let r = try? harbor.optionalBoat() // Boat??
この挙動は、try? と optional chaining や as? を組み合わせたときに実際のコードで頻繁に顔を出します。
// foo?.makeBar() は Bar? を返すため、try? を被せると Bar?? になる
let x = try? foo?.makeBar()
// JSONSerialization.jsonObject(with:) は Any を返すので as? で [String: Any]? に
// したいが、try? が上乗せされて [String: Any]?? になってしまう
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
こうして生まれた nested optional はほとんどの場合意図したものではなく、回避するために次のような余分なパターンを書く必要がありました。
// パターン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? を使うコードは基本的に「エラーだったのか、値が nil だったのか」を区別せず、値が取れたときだけ処理したいケースです。エラーをきちんと扱いたいなら do / try / catch を使うべきで、try? にそれを求めるのは自然ではありません。にもかかわらず、try? を使っただけで nested optional が現れてしまうのは、シンタックスシュガーのはずの機能がかえって複雑さを持ち込んでいる状態でした。
02 どのように解決されるのか
Swift 5 では try? に optional chaining と同じ平坦化の振る舞いを与えます。foo?.someExpr() が foo の有無に関わらず結果を単一の Optional にそろえるのと同じように、try? someExpr() も次のように動作します。
someExpr()が非Optionalの値を返すなら、Optionalでくるむ。someExpr()がOptionalを返すなら、それ以上Optionalを重ねない。
この変更により、従来 nested optional になっていた式の型が 1 段減ります。
// 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]
もともと非 Optional を返す式に対する try? の型は変わりません。
// Swift 4: String?
// Swift 5: String?
let fileContents = try? String(contentsOf: someURL)
また、元々多重の Optional を返す関数に対しては平坦化は 1 段だけです。try? が足す 1 段分だけが吸収されるイメージです。
func doubleOptionalInt() throws -> Int?? {
return 3
}
// Swift 4: Int???
// Swift 5: Int??
let x = try? doubleOptionalInt()
仕組み
Swift 4 では try? 式の型は「サブ式の型 T に対して Optional<T>」と定義されていました。Swift 5 では「ある Optional<_> 型 U で、サブ式の型 T が U に coercible であるもの」と定義し直されます。型制約系はこの条件を満たす最小のネスト段数を自動で選ぶため、結果的に「サブ式がすでに Optional ならそのまま、そうでなければ 1 段だけ包む」という挙動になります。
ジェネリックな文脈での振る舞い
ジェネリックなコードの中での try? は、実行時に T が Optional になるかどうかによって挙動が変わったりはしません。コンパイル時点では result の型はあくまで T で、T が Optional かどうかは分からないので、常に 1 段だけ Optional が足されます。
func test<T>(fn: () throws -> 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? })
既存のジェネリックコードは従来どおり try? を使えます。
as? との違い
見かけの似ている as? による平坦化とは挙動が異なります。as? は明示的に型を指定するため、foo as? T は foo の Optional の段数に関係なく常に Optional<T> になります。つまり as? は「段数を増やすことも減らすこともできる」より汎用の仕組みです。今回の try? の変更は、あくまで「サブ式がすでに Optional のときに 1 段だけ増やさない」というものにとどまります。
移行
この変更は、サブ式が Optional を返す try? 式のうち「自分で明示的に平坦化していないもの」に対してだけソース互換性を壊します。実際のコードでは、もともと二重 if let / guard let や case let x??: パターンで回避していたものが多く、それらは新しい挙動と互換です。コンパイラは Swift 4 モードでは従来の挙動を保ち、これらのパターンに対しては自動マイグレーションが用意されています。