Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

基礎: 型システム

注: このセクションは作業中です。将来、型規則のような、より形式的な側面を扱うために拡張または改訂される可能性があります。

Rust の最大の強みは、その型システムです。 したがって、型について議論する必要があります。 これは奥深いトピックであり、その専門分野である型理論は、コンピュータプログラミングよりも前から存在します。 数ページでそれを十分に扱うことはできません。

静的型システムは、おそらく現存する静的解析の中で最も広く普及し、強力な形式です。 型には 2 つの役割があると考えてみましょう。

1. 抽象データを物理マシンに対応付ける

型は、データがどのように読み書きされるかについての仕様です。 ハードウェアという機械的なレベルにおいてです。 メモリ内では、あらゆる構成要素は単なるビットパターン、つまり 01 の列にすぎません。 型は、人間の推論に適した言語レベルのオーバーレイを提供します。

たとえば、整数型は、ビット列を、現代の「64 ビット」マシンでは一度に 64 ビットずつ、整数として解釈します。 それらは、「レジスタ」(小さく、すぐにアクセスできる、CPU 固有の塊だと考えてください)に格納されているとき、数学的に操作できます(加算、減算、乗算など)。

第 2 章の incr 関数を振り返ってみましょう。 整数へのポインタを引数として受け取る関数がありました。 C では、以下のコードはポインタ ab がエイリアスしないことを保証できませんでした。 また、どちらのポインタも有効なメモリ位置を指していることを保証できませんでした。

void incr(int* a, int* b) {
    *a += *b;
}

Rust への移植では、その両方の問題が解消されました。

#![allow(unused)]
fn main() {
fn incr(a: &mut isize, b: &isize) {
    *a += *b;
}
}

どちらの言語にも、意味的な重ね合わせを行う型システムがあります。 それらはソースコード上の操作を物理ハードウェア上の操作に対応付けます。

  1. RAM アドレスからビットパターン(整数型 ab の値)をレジスタへ読み込む(デリファレンス読み取り)。

  2. 2 つの CPU レジスタの値を、整数であるかのように、CPU 命令を使って加算する(数学的操作)。

  3. 結果をメモリへ書き戻す(デリファレンス書き込み)。

2. 排除によってプログラムの振る舞いを検証する

型には、ハードウェアというソーセージがどのように作られるのかを解明することに加えて、あるいはそれと一体となって、もう 1 つの役割があります。 型は、排除によって、プログラムが何をするかを検証します。 この主題に関する画期的な教科書1は、次のように述べています。

型システムとは、フレーズをそれらが計算する値の種類に従って分類することにより、特定のプログラムの振る舞いが存在しないことを証明するための扱いやすい構文的手法である。

これは本質的に、型は起こり得る振る舞いを制約できるため、特定のことが起こらないと確信できる、という意味です。 Rust の incr 関数の場合、それは 2 つの問題状態(エイリアシングと無効なポインタ)を完全に排除することを意味します。

特定の振る舞いが存在しないことを、どのように証明するのでしょうか。 大まかには、望ましい振る舞いに基づいて値をグループ化することによってです。 たとえば次のとおりです。

  • グループ化:012、…、255 は、型 u8(8 ビット符号なし整数)にグループ化できます。

  • 振る舞いの不在の証明: u8 オペランドに適用された + 演算子は、連結ではなく加算を行います。したがって、プログラムが 2 つの符号なしバイトを連結することは決してないと保証できます。その操作はこの言語では意味を持ちません。

静的型システムと動的型システムという 2 つの大きな型システムのクラスの違いは、その不在の証明をどのように行うかにあります。

  • 静的型付けはコンパイル時に証明を行います。プログラムが実行時にその振る舞いを決して示さないことを保証します。

    • 変数には型があります。その結果として、値にも型があります。そして、あらゆる可能な実行について、型はコンパイル時に既知です。
  • 動的型付けは実行時に値に型をタグ付けします。操作の合法性はプログラムの実行中に検査されます。検査が失敗した場合、プログラムは終了するか、例外を投げる可能性があります。

    • 値には型がありますが、変数には型がありません。そして、値の型は実行時にのみ判明します。

ケーススタディ: 動的型付け

ある考え方を自分のものにする最善の方法は、反例を見ることである場合があります。 対比は理解を深める助けになります。 少しの間、Rust の静的型から離れてみましょう。

Python は、初心者に優しい構文と大規模なプロフェッショナルユーザーベースを持つスクリプト言語です。 Rust とは異なり、Python は動的型付けです。 多くのプロジェクトでは、これにより開発時の摩擦が減り、プロトタイピングの速度が向上します。 インタープリタの抽象化により、開発者は、それを実行するマシンではなく、出荷するプロダクトに集中できます。

しかし、低消費電力の組み込みセンシングから高性能な分散ワークロードまで、Python がまったく適さないユースケースの世界があります。 高信頼性アプリケーションはその一部です。 比較的遅い性能も要因ですが、信頼性の低さのほうが大きな欠点です。 その理由を見てみましょう。

次のコマンドで Python の Read Execute Print Loop(REPL)を起動するところから始めます。

python3

REPL により、入力したプログラムをその場で実行できます。これは動的型付けによって可能になる便利なワークフローです。 2 つの変数 wordx を宣言し、それらの型を調べます。

>>> word = "Hello"
>>> print(type(word))
<class 'str'>
>>> x = 3
>>> print(type(x))
<class 'int'>

word(文字列)に x(整数)を掛けることは合法です。 結果は次のとおりです。

>>> print(word*x)
HelloHelloHello

しかし、文字列に文字列(word 自身)を掛ける場合を考えてみてください。 これは一般には意味をなさないため、エラーになります。 Rust はこのエラーを、コードを出荷するずっと前のコンパイル時に捕捉します。 しかし Python はそれを実行時に、しかもその特定の行が実行された場合にのみ投げます。

>>> print(word*word)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: can't multiply sequence by non-int of type 'str'

高信頼性ソフトウェアにとって、それでは遅すぎます。 テストスイートでカバーされていないコードパスで 1 つの型エラーに遭遇するだけで、サービスの劣化や停止につながります。 静的型システムがない場合、プログラム内のあまり一般的でないパスやテストされていないパスを実行することは、「勘に頼って危険なことをする」のに似ています。 そして、不完全なテストスイートを相手に動的型付けのコードをリファクタリングすると、実際にはそうしたまれなパスを追加している可能性があります。

単に 100% のテストカバレッジを達成すればよいのでは?

大規模プロジェクトで 100% のカバレッジを達成するのは現実的でない場合があります。 たとえ達成できたとしても、プログラムの状態空間(取り得るすべての状態の集合)は、そのカバレッジ(実行された文の集合)と必ずしも相関しません。 つまり、完全なカバレッジでテストに合格しても、本番環境の実行時に型エラーに遭遇する可能性があるということです。

ゴルディロックスな保証の対比

1970 年代、産業用アプリケーション向けのコンパイラは、ほとんど未踏の領域でした。 今日では当然視しているコンパイル時検証の多くは、まだ数十年先の研究成果でした。 コンパイラが十分に高度で、どこで実行時チェックが不要かを把握できなければ、実行時チェックは法外に高コストでした。 したがって、C の 弱い静的型付け は、安全性の強制が控えめであることを意味します。 C の型は暗黙的に変換、すなわちキャストでき、そのため誤って行われることも少なくありません。

弱い型付けは、他の設計上の選択と相まって、壊滅的な量の 未定義動作(UB)2 を許す言語を生み出しました。C プログラムは実行時に予測不能な振る舞いを示すことがあります。 これは、特定のコンパイラ実装の問題ではなく、言語仕様にある「隙間」に起因します。 そして、これらの隙間を後から修正することはできません。私たちが依存している既存の C プログラムを壊してしまう可能性があるからです。

子どもの寓話3にちなんで名付けられた「ゴルディロックスの原理」4は、分野横断的な理解を反映しています。つまり、ある性質について「ちょうどよい量」を最適化したい、ということです。 私たちにとって、その性質は性能制約下での保証です。 3 匹の熊は C、Python、Rust です。 大まかな高レベルの比較を示します。

CPythonRust
型安全か?いいえ(弱い、静的いいえ(強い、動的)はい強い静的
メモリ安全か?いいえはいはい
高速か?はいいいえはい

Python はオプションの静的型付けをサポートしているのでは?

実世界の Python プロジェクトを対象にした査読済みの大規模分析5では、型アノテーションを使用しているのは少数派(3.8%)であり、使用している場合でも型チェックに合格するほど正しく使われていることはまれ(その 3.8% のうち 15%)であり、一般的な型チェッカー(MyPyPyType)は偽陽性を生じる(44〜49% の確率)ことがわかりました。

さらに悪いことに、Python の型チェッカーは互いに意見が食い違うことがよくあります。 オプションの型付けは、コンパイラによって強制される静的型システムの実用的な代替にはなりません。特に高保証の文脈ではなおさらです。

この表では捉えきれないニュアンスが数多くあります。 しかし、型と信頼性に関するこの余談を締めくくるために、この表を使います。

Go はどうですか?

Go は、人気のあるモダンな静的型付けのネイティブコンパイル型プログラミング言語です。 すばらしい並行処理サポートを備えています。 しかし、ガベージコレクションにより、幅広いシステムプログラミングのタスクには適しません。 Go は予測不能な間隔でプログラム全体を「停止」し、メモリをクリーンアップするアルゴリズムを実行しなければなりません。 これは、リアルタイムシステムや低レイテンシシステムでは受け入れられないことがよくあります。

Rust は、変数スコープに基づいて割り当て/解放ロジックを挿入することで、コンパイル時にメモリをうまく扱えるようにします。 その結果、予測可能な性能が得られます。 人気のチャットアプリケーションを開発する Discord は、Go のガベージコレクションがあるサービスの性能目標と両立しないことを発見しました。 同社はレイテンシの急上昇をなくすため、そのサービスを Rust で書き直しました6

要点

機械的なレベルでは、型システムはデータの読み書きを行う仕組みを支えています。 より抽象的なレベルでは、型システムは特定の望ましくない結果が発生しないことを保証します。ただし、プログラマーが型キャストのバグを持ち込まない限りにおいてです。

実行時に型チェックを行う動的型付け言語は、信頼性リスクをもたらします。 以前に探索済みのパスや状態でも、クラッシュや例外が発生する可能性があります。

制限の少ない型キャストが許容される弱い静的型付けも、同様にリスクがあります。 それは UB を引き起こす可能性があります。 その結果には、クラッシュ、不正な結果、セキュリティ脆弱性が含まれます。


  1. 型とプログラミング言語. Benjamin C. Pierce (2002).

  2. 未定義動作:私のコードに何が起きたのか?. Xi Wang, Haogang Chen, Alvin Cheung, Zhihao Jia, Nickolai Zeldovich, M. Frans Kaashoek (2012).

  3. ゴルディロックスと 3 匹の熊. Robert Southey (1837).

  4. ゴルディロックスの原理. Wikipedia (2021)

  5. 実世界における Python 3 の型:2 つの型システムの物語. Ingkarat Rak-amnouykit, Daniel McCrevan, Ana Milanova, Martin Hirzel, Julian Dolby (2020).

  6. Discord が Go から Rust へ移行する理由. Jesse Howarth (2020).