01 何が問題だったのか
Swiftには、単一の式からなる関数・プロパティ・クロージャで return を省略できる仕組み(SE-0255 などに由来)があり、低儀礼(low-ceremony)な書き味が重視されてきました。しかし if と switch は文(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 どのように解決されるのか
if と switch を、次の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 文として書く必要があります(この「単一の式しか書けない」制約は、将来の緩和候補としても扱われています)。
例外として、ブランチが 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分岐で lazy が Array に巻き戻され、遅延評価のつもりが先行評価になる、といった予想外の挙動が起こり得ます。
なお三項演算子 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 の扱い」と整合しています。
5. result builder 式の一部として使う場合は対象外。
result builder 内の if / switch は従来から buildEither を介して式的に扱われてきました。その挙動は変更されません。ただし、result builder 内でも「変数宣言の初期化式としての if」は新たに許可されます。
パターンマッチとの組み合わせ
switch の case 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 }
}
いずれもデッドコードに関する限定的なケースであり、現実のコードへの影響は小さいと判断されています。
03 今後の見通し
本提案では、関数・プロパティ・クロージャからの値の返却、既存変数への代入、変数宣言時の初期化という3つの限定的な利用に絞って if / switch を式として扱えるようにしています。コミュニティでの利用実績を踏まえ、将来的に次のような拡張が検討されうるとされています。いずれも構想段階の方向性として示されているもので、実現を約束するものではありません。
任意の式の位置での利用(Full Expressions)
1 + if .random() { 3 } else { 4 } のように、任意の部分式として if / switch 式を書けるようにする方向です。便利な例ばかりではなく、for b in [true] where switch b { case true: true case false: false } {} のような奇妙な書き方も可能になります。これらは「奇妙だが無害」な範囲に収まる一方、result builder では新たな曖昧性が生じます。例えば、if 式の直後に .someStaticProperty を書いた場合、それを if 式へのpostfix memberアクセスと見なすか、別の式と見なすかが曖昧になります。パーサーは result builder のためだけに振る舞いを変えられないため、解決すべき課題とされています。
do 式
do ブロックを式として扱えるようにする方向です。例えば次のように、try を含む処理の結果を変数の初期化に使えます。
let foo: String = do {
try bar()
} catch {
"Error \(error)"
}
guard の else で値を返す
guard についても if と同様の機能を求める声があり、else 節で値を返せるようにする案があります。
guard hasNativeStorage else { nil }
これは if / switch 式とは別の話題で、「guard 文での return の省略を許す」提案として独立に扱われるべきものとされています。
複数文のブランチ
各ブランチが単一の式に限定されることで、ブランチの途中にログ出力などの文を挟みたくなった瞬間に、従来の if / switch 文と一時変数の組み合わせへ書き戻さなければならない、という使い勝手の崖があります。これを解消する方向として、Rust のように「スコープ末尾の式をそのスコープの値とする」案や、Java の switch 式における yield のような新しいキーワードを導入する案が挙げられています。前者はSwift全体に波及するかなり大きな変更になり、関数やクロージャの戻り値も含めて検討する必要があります。
result builder 以外での Either
result builder の中では、if の各ブランチで異なる型の値を返し、Either 的な型を構築できます。同じ仕組みを result builder 以外の if 式にも広げる方向です。Swiftにとって強力な新機能になりますが、言語に統合された Either 型の導入を伴うなど影響範囲が大きく、本提案の機能がコミュニティに浸透した後に別提案として検討されるべきとされています。