最近のプログラミング言語は何らかの型推論を搭載したものが多いです。しかし、型推論も万能ではなく、ところどころで型注釈を書かないとコンパイルが通らない、あるいは意図せず any
型になってしまう、という状況があります。
この記事では、どういう場合に型注釈を書くべきかについてのガイドとなることを目指します。
主に、「HaskellやMLほどではないがある程度の型推論ができる」言語、具体的にはTypeScript, Python(mypy等)、C#、Swiftなどを想定しています。特定の言語についてのガイドではないので、全体的にふわふわした記述になっているかもしれません。
関数の型を書くべきか
トップレベル関数の引数は、注釈が必要なことが多いです。一部の言語、HaskellやMLなどは引数の使われ方から型を推論できたりしますが、他の多くの言語はそこまでの能力を持っていません。
戻り値の型については、関数の本体から推論できる場合もあります。しかし、再帰関数の場合は推論できない場合があるので、その場合は型注釈が必要です。
// TypeScriptの例
// 注釈がないと戻り値の型が any になる
function fib(n: number) {
if (n <= 1) {
return n;
} else {
return fib(n - 1) + fib(n - 2);
}
}
技術的に「必要か」とは無関係に、言語によってはトップレベル関数の型を書かせるような仕様になっているケースもあると思います。また、Haskellのような強力な型推論を搭載した言語でも、可読性などの観点からトップレベル関数の型は書いておくと良いでしょう。
一方、コールバック関数の型は文脈からわかる場合があるので、書かなくて良いケースが多いです。
// TypeScriptの例
// この x の型は関数を書いた文脈から number と判断される
[1, 2, 3].map(x => x + 1);
// この場合は f の型注釈が与えられていないので x の型はコンパイラーにはわからない。any になる
const f = x => x + 1;
[1, 2, 3].map(f);
というわけで、関数の型については「トップレベル関数の型は書け、コールバック関数の型は不要なケースがある」というのがある程度の言語に当てはまると思います。
変数の型を書くべきか
初期化を伴う変数の定義であれば、右辺の式を見れば型が分かります。なので、型を書かなくても良いかもしれません。
// TypeScriptの例
const a = 42; // 右辺を見れば型がわかるので型注釈は不要
ただし、可変な変数であるとか、空配列で初期化する場合は型注釈が必要になるケースがあります。
// TypeScriptの例
// 右辺の型は 42 だが、可変な変数なのでそれをそのまま使うわけにはいかない。
// TypeScriptの場合は widening により number 型になる。
let b = 42;
b++;
// TypeScriptの例
const c = []; // 要素の型についてのヒントがないのでコンパイラーは要素の型を決定できない
const d: number[] = []; // OK
// Swiftの例
var arr = [] // 型注釈がないのでコンパイラーは要素の型を決定できない
var arr2: [Int] = [] // OK
もちろん、初期化に使う式に「期待する型」を伝播させたい場合(後述)は型注釈をつけます。
// Javaの例
jshell> List<Integer> a = new ArrayList<>(); // 「期待する型」が伝播して要素の型が Integer になる
a ==> []
| 次を作成しました: 変数 a : List<Integer>
jshell> var b = new ArrayList<>(); // 「期待する型」がわからないので要素の型が Object になる
b ==> []
| 次を作成しました: 変数 b : ArrayList<Object>
// C#の例
int[] a = [1, 2, 3]; // 「期待する型」が伝播してコレクション式が使える
var b = [1, 2, 3]; // 「期待する型」がわからないのでコレクション式が使えない
double c = default; // 「期待する型」が伝播してデフォルト式が使える
var d = default; // 「期待する型」がわからないのでデフォルト式が使えない
// Swiftの例
let a: Date = .now // 「期待する型」が伝播して Date.now を呼び出せる
let b = .now // 「期待する型」がわからないので呼び出せない
というわけで、変数の型については「可変な変数や空配列には型注釈を書く、右辺から型がわかる定数なら不要」としておくのが良いのではないかと思います。
型情報が伝播する方向を意識する
いろいろ書きましたが、型情報が伝播する方向を意識すればどこに型注釈が必要でどこなら不要かがわかるでしょう。
普通のプログラミング言語では、実行時の値はプログラムの「上流」から「下流」へ伝播します。つまり、f(g(x))
という式があったら x
の値が最初にわかっていて、次に g(x)
の値が判明し、最後に f(g(x))
の値が判明します。
これに対し、現代的な言語では型情報が下流から上流へ伝播するケースがあります。つまり、f(g(x))
という式があったら、f
や g
の型を元に x
の型を推論することが可能だろう、ということです。x
が変数名だったら上流から下流の場合と変わらないかもしれませんが、x
がコールバック関数だったり、C#の default
式だったり、Swiftの .now
みたいなやつだったりすると型注釈が不要になるかもしれません。
変数の定義に型注釈を与える
let x: T = ...;
ということは、変数を使う箇所に T
という型を伝播させるだけではなく、変数定義の右辺(...
の部分)に「期待される型」を伝播させる意味もあるでしょう。
注意点として、文を跨いだときは「下流から上流への伝播」は止まる言語が多いと思います。HaskellやMLのような強力な型推論を持つ言語ならともかくとして。
言語設計者はどうするべきか
ここに書いた指針が当てはまるのは「ある種の言語」であって、すべての言語に当てはまる、あるいはすべての言語設計者がそうするべきというわけではありません。HaskellやMLのようなある程度制約された(型推論と相性の良い)型システムを持つ言語であれば型注釈はほとんど書かなくても(高度な機能を使わなければ)やっていけます。
逆に、表現力のある型システムを持ちながら型注釈が極力不要な言語を作りたい場合はどうするか。そういう場合は、トップレベル関数の引数に関する型検査を呼び出し側が判明するまで遅延させたり(C++のテンプレート)、複雑なフロー解析を行ったりすることになるでしょう。デメリットは、エラーメッセージの可読性やコンパイル時間が犠牲になることでしょう。
ここに書いたような水準の(型の表現力と型注釈の量でバランスを取った)言語を設計する際に型注釈の量を少しでも減らしたければ、「空配列を作って要素を追加させる」ような設計はやめてmap/filterを推奨する、可変な変数は型注釈が必要になるケースがあるので定数を推奨する、などを行うと良いかもしれません。