Swift Digest
SE-0230 | Swift Evolution

Flatten nested optionals resulting from ‘try?’

Proposal
SE-0230
Authors
BJ Homer
Review Manager
John McCall
Status
Implemented (Swift 5.0)

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 で、サブ式の型 TU に coercible であるもの」と定義し直されます。型制約系はこの条件を満たす最小のネスト段数を自動で選ぶため、結果的に「サブ式がすでに Optional ならそのまま、そうでなければ 1 段だけ包む」という挙動になります。

ジェネリックな文脈での振る舞い

ジェネリックなコードの中での try? は、実行時に TOptional になるかどうかによって挙動が変わったりはしません。コンパイル時点では result の型はあくまで T で、TOptional かどうかは分からないので、常に 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? TfooOptional の段数に関係なく常に Optional<T> になります。つまり as? は「段数を増やすことも減らすこともできる」より汎用の仕組みです。今回の try? の変更は、あくまで「サブ式がすでに Optional のときに 1 段だけ増やさない」というものにとどまります。

移行

この変更は、サブ式が Optional を返す try? 式のうち「自分で明示的に平坦化していないもの」に対してだけソース互換性を壊します。実際のコードでは、もともと二重 if let / guard letcase let x??: パターンで回避していたものが多く、それらは新しい挙動と互換です。コンパイラは Swift 4 モードでは従来の挙動を保ち、これらのパターンに対しては自動マイグレーションが用意されています。