型の安全性とnull安全について
最終更新日: 2019/12/24
たまに #?? タグが埋め込まれていますが、もし良かったらこのツイートにリプを飛ばしていただけると幸いです。 はじめに
最近は動的型付けのプログラミング言語が流行っていた時代が落ち着き、静的型付けの言語の波が来ていますね。それに伴ってプログラマは型と楽しく付き合っていく必要性が高まってきています。型を付けるありがたみはいくつか考えられますが、今回はその中でも安全なプログラムを書くための補助になるものである「null安全」について紹介します。
null安全の話に入る前にもう少し広い範囲の型の安全性についてみてみます。
なぜ型を用いるのか
プログラミング言語をコンパイルするときには、大きく分けて以下のようなフェーズを辿ります。
字句解析→構文解析→意味解析→コード生成
字句解析フェーズで、コードを変数や値などの小さなトークンに分割します。構文解析フェーズではそのトークンから構文規則に則り抽象構文木を作成し、ここで文法チェックも同時に行います。意味解析フェーズで意味のチェックを行います。ここで、文法と意味を仕様に沿ってチェックしてることから分かるように、プログラミング言語は、「どのように記述するか」の文法と、「どのように動作するか」の意味によって定義されます。
文法のチェックと意味のチェックの違いを見てましょう。例えば以下のようなシンプルなプログラミング言語の構文規則を見てみます。
code:ast
program ::= expr
expr ::= binExpr | num | bool
binExpr ::= expr binOp expr
binOp ::= '+' | '*'
num ::= 0|1|2|3|4|5|6|7|8|9
bool ::= true | false
この規則によると、1 + trueのような文法は正しいものと判断されてしまうことがわかります。しかし、生成されたコードの実行環境で1 + trueのようなプログラムの動作が未定義の場合、実行時エラーに繋がります。このように、プログラムには「文法的には正しいが、意味的には間違っているもの」が存在し、まさにこの部分を型などを用いて検査します。
型安全性というのは、「型検査を通過したなら、絶対に意味論的に未定義な状態にならない」という性質のことを指します。こういう性質のことを論理学の言葉で「健全性」とも言います。この記事では、「型安全性」と「健全性」は同義として扱います。 健全性とは
つまり型安全性とは、ということですが、これは、「型エラーがなければ、未定義な状態にならない」という性質です。健全な型検査を通過すれば、絶対にある種のバグがないんです。絶対に!
ただしこれは、「型エラーがある」時の話は一切していないことに注意が必要です。
以下の画像のように、任意のプログラムは以下の3つの状態に分類されます。
型エラーもあるし、バグもある状態(図の黒色の部分)
型エラーはあるけど、バグはない状態(灰色の部分)
正しく型付けされた状態。つまり型エラーがない状態(白色の部分)
https://gyazo.com/f063806c45c1576924f6286eba7ddb4d
「健全性」が言っているのは、この図の白い部分のことです。型検査に通過したプログラムは、正常に動くプログラムに内包されるので、これがつまり「型検査を通過したなら、絶対に不正な動作はしない」ということになります。
図の中で、わかりにくいのは灰色の箇所でしょうか。これは、型エラーはあるが、正しく動くプログラムです。例えば以下のような例が考えられます。
code:ts
// この関数の戻り地はstring
const returnString = (): string => {
if (true) {
return 'string'; // 条件がtrueなので100%こちらが評価される
} else {
return 42; // error: Type '42' is not assignable to type 'string'.
}
};
上の例はTypeScriptですが、他の静的型付けの言語でも同じです。
code:hs
returnString :: String
returnString = if True then "string" else 42
-- ^^ No instance for (Num String) arising from the literal ‘42’
この関数returnString()はその名の通り、string型を返すと定義された関数です。しかし、else節ではnumber型を返しています。この時点で型と実装がズレていますが、if文の条件節がtrueなので、これは必ず型付け通りにstring型を返します。このプログラムは、バグを起こさないものであるはずですが、型エラーがあるので型検査には引っかかります。これは簡単な例でしたが、こんなふうに、バグは起こらないけど型検査が通らないことが起こるので安全性の検査は保守的だと言えます。
一方で、型だけでは検査しきれないことを実行時の検査に任せる場合もあります。つまり、正しく型付けされてコンパイルは通っているのに、実行時エラーが起こることもあるということです。例えば、Haskellではこんな感じです。
code:hs
main = do
print $ 10 * (a !! 3) -- *** Exception: Prelude.!!: index too large
Haskellでは配列の添字アクセスは!!を用いてできます。上のプログラムは型検査は通り、コンパイルされますが、見ての通りa !! 3は要素数を超えているので、計算することができず実行時エラーが起きています。これは、型だけでは検査しきれなかったところを実行時のチェックに任せるので未定義な状態になるわけではありません。
どこまでが型の責務で、どこからが実行環境の責務かは言語や実装に依って異なります。例えば、上の添字アクセスの例は、依存型を持つATS言語や、wikipediaによるとJS++という言語では配列の境界チェックができます。 完全性とは
健全性が保証されていれば、とにかく型検査を通すプログラムを書けばひとまず安心ですが、上の例のように型検査を通す為に必要以上にガンバらないといけないこともあり、理想を言えばこの境界を一致させてほしいですね。これが完全性です。言い方を替えれば「型エラーがあれば、絶対にバグがある」ということです。 図で言えば以下のように、灰色の部分を消し、allとwell typedの境界を一致させることに当たります。
https://gyazo.com/28efbe4052ce232948c3dba146c45448
型安全、つまり健全性が満たされている言語はいくつかありますが、完全性が満たされている言語ってあるのでしょうか。 #?? null安全とは
ここまで、型安全について見てきましたが、わざわざ明示的に型を書くことで得られる効用の一つは、「プログラムの実行時に未定義な状態にさせたくない」というものがありました。未定義な状態というのは、言語や型検査器の仕様によって異なりますが、とにかくあまり嬉しい状況ではないことは確かです。。例えばC言語ではコードの書き方によっては正常にコンパイルされても、実行時にダングリングポインタを踏み、クラッシュすることがありますが、Rustならunsafeの世界でなければ、コンパイルが通れば、そのような起こらないように設計されています。 この問題の原因は、無効なメモリ領域を指すポインタにアクセスすることですが、これに関連して、値が未定義な変数を参照することによる例外に「null pointer exception」があります。いわゆる「ぬるぽ」です。これを型で防ぐ機構がnull安全です。コンパイル時の型検査でnull値になりうるものへのアクセスを制御します。
null安全であるためには、以下の2つの条件を満たす必要があります。
nullable
non-null
詳しくは後述しますが、要は「nullになりうる型T?」と「絶対にnullにならない型T」を区別しようという戦略です。
これら両方を満たすものをnull安全と呼ぶのであって、片方だけではnull安全ではありません。
nullableはnullになりうる型
nullableというのは、簡単に言うと「nullになりうること型」のことです。
TypeScriptではこんな感じだったり、
code:ts
type A = string | null
Haskellならこんな感じだったり、
code:hs
Maybe a = Just a | Nothing
Rustならこんな感じだったりします。
code:rs
enum Option<T> {
None,
Some(T),
}
SwiftやKotlinなら?を用いて表す型のことです。
これらの型は、ある値が「nullもしくは何かの型である」ことを表現します。type A = string | nullという型は、stringかもしれないし、nullかもしれません。これで何が嬉しいのかと言うと、nullableな型はそのままいつもどおりには扱うことができず、値にアクセスしたりメソッドを呼びたい時には必ずnullチェックをしないとコンパイラに怒られます。
code:ts
const s: string | null = null;
if (s != null) { // nullじゃないなら、
s.length; // OK
}
こうすることで、nullになり得る型は絶対にnullチェックをして、nullじゃないことを確認してから使うことを強制されます。強制されることで気付かずにnull値にアクセスすることを防ぐことができます。逆に、nullableな値を使うためには、毎回こういう処理をしないといけません。これは一見面倒そうに見えますが、null安全な言語にはこのチェックを便利に扱う言語機構が用意されていることが多いです。
ここでは、その便利機能の例としてOptional ChainingとMonadを簡単に紹介します。
Optional Chaining
code:ts
const x = foo?.bar?.baz() ?? 'hoge' // fooやbarがnull/undefinedなら'hoge'を返す
?.の部分がOptional Chainingで、??の部分がNullish Coalescingです。nullableなfooやbarに正しく値が入っている場合はbaz()を呼び、そうでない場合はdefault値である`
"hoge"を代入します。
Optional ChainingはTypeScriptに限ったものではなく、C#やSwiftなどにも似た機能があります。この辺の話はuhyo氏の記事が詳しいです。
Maybeモナド
HaskellにはMaybeという型コンストラクタがあり、Maybe a = Just a | Nothingのような型を定義します。このaの部分には具体的な型を入れてMaybe Intのようにして、「Intかもしれないし、Nothingかもしれないような型」を使います。正しく値がある場合は、Just a、そうでない場合はNothingを返します。 例えばMaybe Int同士の演算をしたときに、どちらか一方でもNothingだった場合は、結果もNothingになります。つまり、複数のMaybe Intの計算が続く場合、どれか一つでもNothingなら、結果であるNothingが伝搬し、最終的な結果もNothingになります。逆に全てJust Intなら、結果もJust Intになります。
ここまではモナドは関係なくnullableな型の話です。
このような失敗したかどうかという文脈が伝搬するものを、Haskellではモナドを用いて簡潔に書くことができます。以下は成功する例です。
code:hs
add = do
n1 <- Just 42 -- 42を取り出す
n2 <- Just 100 -- 100を取り出す
return $ n1 + n2 -- 足してJustにくるんで返す
main = print add -- Just 142 ←Justにくるまれている
TypeScriptのときとは異なり、正しい値のときもJustという型コンストラクタにくるまれているので、演算するときは値を取り出す必要があります。上の例では、n1もn2も正しい値のあるMaybe Int型、つまりJust Int型なので、思ったとおりに演算されます。演算の結果は「失敗するかも知れない」という文脈を保持し続けるので、ただの値142ではなく、Just 142になります。
一方で、演算のどれか一つでもがNothingなら、結果もNothingになります。
code:hs
add = do
n1 <- Just 42
n2 <- Nothing -- 片方がNothingだった
return $ n1 + n2
main = print add -- Nothing
こうして見ると、Opational ChainingとMaybeモナドの、失敗したときの文脈の伝搬の類似性が見えてきますね。
non-nullとは
ここまで、null安全の1つ目の条件である「nullable」について見てきましたが、次は2つ目の「non-null」についてです。non-nullというのは、nullableでない型にnullを代入できないという性質です。
nullableな型の例で挙げたような「string | nullとかMaybe IntとかString?のような、nullが入りうる型」ではない型には、決してnullを代入できない、ということです。
例えばTypeScriptではstring型やnumber型の値にnullを代入できません。代入しようとすると怒られます。
code:ts
let hoge: string = "こんちわ"
hoge = null // Type 'null' is not assignable to type 'string'.
その言語の中にたとえnullableな型があってもnon-nullがなければ、結局どの型にもnullが潜むことになり、静的検査でnull値の判断ができなくなってしまいます。
例えば、Javaはnull安全ではない言語の一つですが、Javaでは参照型のみにNullを代入することができます。他のprimitiveな型はNullにならないので良さそうに見えますが、non-nullは一つの例外も許しません。「nullableでない型には決してnullを代入できない」を守らないといけません。じゃあ、「この参照型はnullを入れられるからnullableと見なせば良いじゃん」って思うかも知れませんが、これは上述したようなnullableの成約を得られていません。nullableな型では、利用する時に必ずnullチェックを強制されますが、この参照型はそうはならないからです。
null安全でない言語ではどう頑張るか
null安全でない言語には例えばCやC++、Java、Go、動的型付けの言語などがあります。
これらの言語の場合、その値がnullかどうかをプログラマが常に気にする必要があります。APIからデータを取得するときはもちろんのこと、その後もずっと「これはnullになりうるか?」を気にしないといけません。うっかり、nullになることを忘れてアクセスしようものならすぐさま実行時エラーになります。
この問題への対策からさらにnull安全な言語の嬉しさが見えてくるので少し紹介します。
例えば、nullを使う代わりに、「nullを表す-1」を使えば良いじゃんという案が考えられます。こうすると誤ってnull値にアクセスすることは避けられます。しかし、これは根本的な問題の解決になっておらず、型を確認してもintとあるだけなので、利用者はこれがnullになるのかどうかの判断ができないので、結局全ての値に対しnullになるかどうかを気にしないといけません。
ではコレに加えて、命名規則を設けることでこの値がnullになるかどうかを判別しようという案も考えられます。名前にNというsuffixが付いていたらnullableっぽいものだ、とします。一見良さそうですが、これが上手くいくかどうかは完全にプログラマの裁量に委ねられます。チーム内で命名規則に関する知識の共有ができておらず間違った名前にすることもあり得ますし、たとえ正しく命名されていても利用時にnullチェックをしなくてもコンパイラは怒ってくれません。
こうして見ると、「型を見ただけでnullになりうるかどうかがわかる」ことや「危ない書き方をしてるときにコンパイラに怒ってもらえる」ことがnull安全な言語での嬉しさだとわかります。
動的型付け言語ではどうか
PythonやRubyなどの動的型付けの言語では、静的な型検査が行われないのでそもそもnull安全かどうかの議論の対象になりません。静的な世界では一種類の型を扱っているようなものです。ref しかし、実際には内部に型情報を持ち、実行時にチェックしているので、未定義な動作はせず型安全だと呼ぶことができます。不整合な値同士の演算があった場合は、実行時に検知して例外を投げることができます。
まとめると、動的型付けの言語はnull安全ではないが、型安全、だと言えます。
まとめ
ここまで、型安全とnull安全について見てきました。
型安全性というのは、「型エラーがなければ、未定義な状態にならない」という性質で、型検査を通過すればバグのないプログラムを書けるというものでした。未定義な状態の定義や、型システムと実行時チェックの役割分担は言語や実装によって異なります。
null安全というのは、nullableな型とnon-nullな型を両方持ち合わせている型システムのことでした。nullかもしれない型は利用時にnullチェックを強制されるので、nullを安全に扱えます。
型システムは、このように楽に安全なプログラムを書ける補助するだけでなく、生きるドキュメントの代わりになったり、最適化の補助をしてくれたり、なかなか魅力に満ち溢れたものです。mrsekut.iconもまだまだ勉強を始めたばかりで、この記事や他のページに気になる点などございましたらご指摘いただけると嬉しいです。
それでは、よいクリスマスを。
参考
TaPL