Swift Digest
SE-0380 | Swift Evolution

if and switch expressions

Proposal
SE-0380
Authors
Ben Cohen, Hamish Knight
Review Manager
Holly Borla
Status
Implemented (Swift 5.9)

01 何が問題だったのか

Swiftには、単一の式からなる関数・プロパティ・クロージャで return を省略できる仕組み(SE-0255 などに由来)があり、低儀礼(low-ceremony)な書き味が重視されてきました。しかし ifswitch は文(statement)でしかなく、値を生成する用途ではその恩恵を受けられませんでした。

例えば、各ブランチで値を返すだけの switch でも、毎回 return を書く必要があります。

public static func width(_ x: Unicode.Scalar) -> Int {
  switch x.value {
    case 0..<0x80: return 1
    case 0x80..<0x0800: return 2
    case 0x0800..<0x1_0000: return 3
    default: return 4
  }
}

変数にまとめて代入したい場合も、既存の書き方にはそれぞれ弱点があります。

  • 三項演算子をネストして書くと、一応式として書けますが、条件が複雑になると非常に読みにくくなります。
  • 定義時初期化(definite initialization)を使い、let x: T を宣言してから各ブランチで代入する方法は、ブランチごとに型と代入を繰り返す手間があり、opaque type や複雑なジェネリック型では型を明示できず現実的ではありません。
  • デフォルト値付きの var x = ... を使うと、意図しない既定値が残るバグの原因になりやすくなります。
  • 即時実行のクロージャ { ... }() で包む手もありますが、各ブランチに return が必要な上、その return がクロージャからの脱出か外側の関数からの脱出かを読み取るのに余計な認知負荷がかかります。

結果として、「本来は値を決めるための分岐」に対して、言語側が十分にシンプルな書き方を提供できていない状況でした。多くのモダン言語(Rust、Kotlin、Scala、Java の新しい switch 式など)が if / switch を式として扱えるのに対し、Swiftはこの点で後れを取っていました。

02 どのように解決されるのか

ifswitch を、次の3つの限定的な文脈で式として扱えるようにします。

  • 関数・プロパティ・クロージャからの値の返却(暗黙・明示いずれの return でも可)
  • 既存の変数への値の代入
  • 変数宣言時の初期化

これ以外の任意の式の位置(関数引数や部分式など)での利用は、今回のスコープ外です。

基本の書き方

先ほどの switch は、各 case に単一の式を書くだけの形に書き換えられます。

public static func width(_ x: Unicode.Scalar) -> Int {
  switch x.value {
    case 0..<0x80: 1
    case 0x80..<0x0800: 2
    case 0x0800..<0x1_0000: 3
    default: 4
  }
}

if / else if / else の連鎖も、そのまま代入式として使えます。

let bullet =
    if isRoot && (count == 0 || !willExpand) { "" }
    else if count == 0 { "- " }
    else if maxDepth <= 0 { "▹ " }
    else { "▿ " }

式として扱われるための条件

if / switch を式として使うには、次のルールを満たす必要があります。

1. 各ブランチ(if の各節、switch の各 case)は単一の式であること。

そのブランチが選ばれたとき、その式の値が全体の式の値になります。途中にログ出力などの文を挟みたい場合は、従来どおり if / switch 文として書く必要があります(この「単一の式しか書けない」崖は Future Directions でも触れられています)。

例外として、ブランチが throw するか、fatalError() のように処理を終える場合は、そのブランチが値を生成する必要はなく、そこに至るまで複数の文を書けます。throw するブランチがあっても、全体の式を try で修飾する必要はありません(必要な箇所の try / throw は各ブランチ内で書きます)。

ブランチ内にさらに if / switch 式をネストすることもできます。

2. 各ブランチの式を個別に型推論したとき、同じ型になること。

Swiftは各ブランチを独立に型検査します。そのため、次のコードはコンパイルエラーになります。

let x = if p { 0 } else { 1.0 } // エラー: Int と Double で型が一致しない

どちらかを 0.0 にするか、0 as Double のようにして揃えるか、左辺に型注釈を書いて文脈を与える必要があります。

let y: Float = switch x.value {
    case 0..<0x80: 1
    case 0x80..<0x0800: 2.0
    case 0x0800..<0x1_0000: 3.0
    default: 4.5
}

nil リテラルを含む場合、左辺の型注釈で Optional の型を与える必要があります。

// 無効
let x = if p { nil } else { 2.0 }

// 有効(型文脈を与える)
let x: Double? = if p { nil } else { 2.0 }

関数からの戻り値や既存変数への代入では、型文脈が常にあるためこの問題は生じません。

双方向の型推論(bidirectional inference)を使わない理由としては、型チェッカのパフォーマンス(分岐が多いと顕著に悪化する)と、各ブランチを個別に追えなくなる可読性の問題が挙げられています。例えば双方向推論が効くと、[1][1].lazy.map(...) の2分岐で lazyArray に巻き戻され、遅延評価のつもりが先行評価になる、といった予想外の挙動が起こり得ます。

なお三項演算子 p ? 0 : 1.0 は従来どおり双方向推論が働き、Double になります(挙動の違いがあります)。

3. Never 型のブランチは例外として扱う。

fatalError() などで Never を返すブランチが混ざっていても、残りのブランチが同じ型で揃っていれば全体の型はその型になります。

// x の型は Int(else の fatalError() は型判定から除外される)
let x = if .random() {
  1
} else {
  fatalError()
}

4. if には else が必須。

if 式では、全てのパスで値が決まっている必要があるため、else を省略できません。これは既存の「戻り値や definite initialization における if の扱い」と整合しています。網羅性解析を if 式にも拡張する案はFuture Directionsとして残されています。

5. result builder 式の一部として使う場合は対象外。

result builder 内の if / switch は従来から buildEither を介して式的に扱われてきました。その挙動は変更されません。ただし、result builder 内でも「変数宣言の初期化式としての if」は新たに許可されます。

パターンマッチとの組み合わせ

switchcase let ...if let といったパターンマッチも、そのままブランチの式として利用できます。

private func balance() -> Tree {
    switch self {
    case let .node(.B, .node(.R, .node(.R, a, x, b), y, c), z, d):
        .node(.R, .node(.B,a,x,b),y,.node(.B,c,z,d))
    case let .node(.B, .node(.R, a, x, .node(.R, b, y, c)), z, d):
        .node(.R, .node(.B,a,x,b),y,.node(.B,c,z,d))
    case let .node(.B, a, x, .node(.R, .node(.R, b, y, c), z, d)):
        .node(.R, .node(.B,a,x,b),y,.node(.B,c,z,d))
    case let .node(.B, a, x, .node(.R, b, y, .node(.R, c, z, d))):
        .node(.R, .node(.B,a,x,b),y,.node(.B,c,z,d))
    default:
        self
    }
}

if let によるアンラップも自然に書けます。

// let x = foo.map(process) ?? someDefaultValue と等価
let x = if let foo { process(foo) } else { someDefaultValue }

ソース互換性に関する注意

デッドコードを含むごく一部のケースで、挙動が変わります。例えば次のコードはこれまで「到達不能な if 文」として警告付きでコンパイルできていましたが、本提案以降は if 式として解釈されるため、return が非 Void の値を返すことになりコンパイルエラーになります。

func foo() {
  return
  if .random() { 0 } else { 0 }
}

いずれもデッドコードに関する限定的なケースであり、現実のコードへの影響は小さいと判断されています。

Future Directions(speculative)

今回は上記3つの限定的な利用に絞っています。コミュニティでの利用実績を踏まえて、将来的に次のような拡張が検討されうる、とされています。

  • 任意の式の位置での利用(1 + if .random() { 3 } else { 4 } のような形)。result builder で postfix member との曖昧性が生じるなど、解決すべき課題もあります。
  • do 式(let foo: String = do { try bar() } catch { "Error \(error)" } のような形)。
  • guardelse で値を返せるようにする拡張。
  • 複数文のブランチを許すための仕組み(スコープ末尾の式を値とする方式、あるいは yield のような新キーワード)。
  • result builder 以外でも ifEither 型を構築できるようにする拡張。

いずれもspeculativeな見通しで、本提案で約束されるものではありません。