この記事の要点
- コンパイラの診断(diagnostic)は、特に不完全だったり不正だったりするコードに対して、何がどこで間違っていて、どう直せばよいのかを的確に伝えられるかが開発生産性を左右します。この記事は Swift 5.2 に向けて進められていた 新しい診断アーキテクチャ を解説するものです。
- 従来の型チェッカは、コードが型チェックに失敗したあとで、エラーの正確な位置を 推測(guess) していました。これは多くの場合うまくいくものの、推測しきれない種類の誤りが数多く残り、
type of expression is ambiguous without more context(式の型を文脈から特定できない)のような具体性に欠ける診断になりがちでした。 - 新しいアプローチでは、エラーを推測する代わりに、型チェッカが問題に出会ったその場で問題を 修正(fix) してみて、適用した修正を覚えておきます。これにより、より多くの種類のコードでエラーの位置を正確に特定でき、しかも最初のエラーで打ち切らずに 複数のエラーをまとめて報告 できるようになります。
- 記録された各修正には、失敗の種類・ソースコード上の位置・関係する型や宣言が含まれます。型チェッカはこれを使って、しばしば Fix-It(修正候補)付きの、実際の修正につながる診断を生成します。
背景: なぜ良い診断は難しいのか
Swift はクラス継承・プロトコル適合・ジェネリクス・オーバーロードといった豊富な機能を持つ表現力の高い言語です。コンパイラはどの Swift コードが妥当でどれが不正かを正確に知っていますが、難しいのは「何が」「どこで」間違っていて「どう直せばよいか」を、いかに分かりやすく伝えるかです。
この取り組みの焦点は 型チェッカ(type checker) の改善にあります。型チェッカは型の使われ方に関する規則を強制し、規則が破られたときにそれを知らせる役割を担います。
たとえば次のコードを考えます。
struct S<T> {
init(_: [T]) {}
}
var i = 42
_ = S<Int>([i!])
従来はこれが次のように診断されていました。
error: type of expression is ambiguous without more context
これは本当にエラーではあるものの、具体性がなく、どう直せばよいかが分かりません。原因は、従来の型チェッカがエラーの正確な位置を 推測 していたことにあります。新しいアプローチではこれが次のように診断されます。
error: cannot force unwrap value of non-optional type 'Int'
_ = S<Int>([i!])
~^
オプショナルでない Int を ! で強制アンラップしている、という実際の問題を正確に指摘できています。
型推論のしくみ
新しい診断は型チェッカと密接に結びついているため、まず型推論を簡単に押さえます。Swift は制約ベースの型チェッカによる双方向の型推論を行い、これは古典的な Hindley-Milner 型推論アルゴリズムに着想を得たものです。
- 型チェッカはソースコードを、コード中の型どうしの関係を表す 制約システム(constraint system) に変換します。
- 個々の関係は 型制約(type constraint) で表されます。型制約は、ある型に要件を課す(例: 整数リテラル型である)か、2 つの型を関係づける(例: 一方が他方へ変換可能である)ものです。
- 制約に現れる型には、タプル・関数・
enum/構造体/クラス・プロトコル・ジェネリックなど任意の Swift の型のほか、$名前で表される 型変数(type variable) を使えます。型変数は他の任意の型の代わりに使えます。
診断にとって重要なのは、制約の生成(Constraint Generation)と求解(Constraint Solving)の 2 段階です。入力の式から、型チェッカは各部分式の抽象的な型を表す型変数の集合と、それらの関係を表す型制約の集合を生成します。よく使われる制約には次のようなものがあります。
$X <bind to> Y: 型変数$Xを確定した型Yに束縛するX <convertible to> Y: 型Xが型Yへ変換可能(部分型や等価を含む)であることを要求するX <conforms to> Y: 型XがプロトコルYに適合することを要求する(Arg1, Arg2, ...) → Result <applicable to> $Function: 両者が同じ入力・出力を持つ関数型であることを要求する
例: str + 1
次の関数を考えます。
func foo(_ str: String) {
str + 1
}
str + 1 という式に対し、型チェッカは次のような型変数を作ります。$Str(str の型)、$One(リテラル 1 の型)、$Result(+ の結果型)、$Plus(演算子 + 自体の型。複数のオーバーロード候補の集合)です。そして次のような制約が付きます。
$Str <bind to> String:strはString型に確定$One <conforms to> ExpressibleByIntegerLiteral:1のような整数リテラルはExpressibleByIntegerLiteralに適合する任意の型(IntやDoubleなど)を取り得る$Plus <bind to> disjunction((String, String) -> String, (Int, Int) -> Int, ...):+は各オーバーロードを要素とする選択肢の集合(disjunction)を作る($Str, $One) -> $Result <applicable to> $Plus:$Plusの各オーバーロードを引数($Str, $One)で試して$Resultを決める
ソルバはまず $Plus の disjunction の選択肢を 1 つずつ試し、各型変数の取り得る型を絞り込んでいきます。たとえば $Plus を最初の候補 (String, String) -> String に束縛すると、$Str <convertible to> String、$One <convertible to> String、$Result = String という制約が導かれます。ここで $One の候補(Int / Double / String)はどれも残りの制約をすべて満たせません(Int と Double は String に変換できず、String は ExpressibleByIntegerLiteral に適合しません)。そのため $One がエラーの位置だと判定でき、ソルバは次の disjunction の候補へバックトラックします。
このようにエラーの位置自体はソルバが特定できます。問題は、こうした行き詰まった状況でソルバを前進させ、完全な解を導く方法でした。
新しいアプローチ: constraint fix
新しい診断基盤は、ソルバが他に試す型がなく行き詰まった状況を解消するために constraint fix(制約の修正) という仕組みを使います。先ほどの例での修正は、「String が ExpressibleByIntegerLiteral に適合しないこと」をいったん無視することです。修正の目的は、エラー位置に関する有用な情報をソルバから残らず取り出し、あとで診断に使えるようにすることにあります。
これが従来との本質的な違いです。従来はエラーの位置を 推測 していたのに対し、新しいアプローチではソルバと協調し、ソルバがエラーの位置をすべて提供してくれます。型変数や制約はどの部分式に由来するかの情報を保持しているため、その関係と型情報を組み合わせれば、問題に応じた診断と Fix-It を素直に組み立てられます。
先の例では $One がエラー位置だと分かっており、$One が + の第 2 引数であること、String が ExpressibleByIntegerLiteral に適合しないことが分かっています。これらから次のような診断を生成できます。
error: binary operator '+' cannot be applied to arguments 'String' and 'Int'
診断の組み立てかた
制約の失敗が検出されると、constraint fix が作られ、失敗の種類・ソースコード上の位置・関係する型や宣言を記録します。ソルバはこれらの修正を蓄積し、解にたどり着いたら、解の一部となった修正を見て、実際の修正につながるエラーや警告を生成します。
func foo(_: inout Int) {}
var x: Int = 0
foo(x)
ここでは x を inout 引数に明示的な & なしで渡せないことが問題です。ソルバは ($X) -> $Result を (inout Int) -> Void に合わせようとし、Int <convertible to> inout Int と $Result <equal to> Void を導きます。Int は inout Int に変換できないため、ソルバはこの失敗を「& が足りない(missing &)」として記録し、その変換制約を無視します。残りの制約は無事に解け、型チェッカは記録した修正を見て、& を挿入する Fix-It 付きのエラーを生成します。
error: passing value of type 'Int' to an inout parameter requires explicit '&'
foo(x)
^
&
このアーキテクチャは、1 つの式の中の 複数の独立したエラー も扱えます。
func foo(_: inout Int, bar: String) {}
var x: Int = 0
foo(x, "bar")
ここでは「& が足りない」失敗に加えて「引数ラベル bar: が足りない」失敗も記録されます。両方を記録したうえで残りの制約を解き、2 つの問題それぞれに Fix-It 付きの診断を出せます。
error: passing value of type 'Int' to an inout parameter requires explicit '&'
error: missing argument label 'bar:' in call
個々の失敗を記録してから残りを解き続けるということは、それらの失敗を直せば型の整合した解が得られる、ということを意味します。だからこそ、しばしば修正候補を伴った、実際にコードを正しくする方向へ導く診断を出せるのです。
改善された診断の例
この仕組みにより、通常のコードでも SwiftUI のコードでも、従来は分かりにくかった多くの診断が具体的になりました。たとえば次のような改善があります。
引数ラベル不足は、従来 argument labels '(_:)' do not match any available overloads(利用可能なオーバーロードに一致しない)と出ていたものが、missing argument label 'answer:' in call のように足りないラベルそのものを指摘し、Fix-It を提示します。
ジェネリックの推論失敗は、従来は generic parameter 'T' could not be inferred(型パラメータ T を推論できない)としか言えませんでしたが、原因がプロトコル適合の欠如なら argument type 'T' does not conform to expected type 'P' のように、本当の原因を直接指摘します。
SwiftUI では効果が顕著です。たとえば次のコードでは、従来は無関係な 'Double' is not convertible to 'CGFloat?' という的外れな診断が出ていました。
import SwiftUI
struct S: View {
var body: some View {
ZStack {
Rectangle().frame(width: 220.0, height: 32.0)
.foregroundColor(.systemRed)
// ...
}
}
}
新しい診断は、本当の原因である「systemRed という色は存在しない」ことを正しく指摘します。
error: type 'Color?' has no member 'systemRed'
.foregroundColor(.systemRed)
~^~~~~~~~~
まとめ
新しい診断基盤は、エラー位置を推測していた従来手法の弱点を克服するよう設計されています。既存の診断の改善・移植が容易で、新機能の実装者が最初から良い診断を提供しやすい構造になっています。すでに移植された診断はいずれも有望な結果を示しており、Swift 5.2 に向けて移植が進められていました。
関連リンク
- Swift 5.1 リリース — この記事で言及されている、新しい診断基盤が最初に導入された Swift 5.1 の公式リリース告知
- Swift 5.2 のリリースプロセス — この診断アーキテクチャの改善が向けられていた Swift 5.2 のリリースプロセス