型システム
2021-09
型システム(かたシステム、type system)は、コンピュータプログラミングの数々の構成要素および値に対して、型(type)と呼ばれる特性を付与するための数々の規則群から成立している形式体系である。型の付与は、型付け(typing)と言われる。例えば、変数・式・関数・モジュール・オブジェクトなどが型の付与対象になり、それらの型付け要素を規則的な関係でまとめたデータ構造にも型は付与されてカテゴライズされる。
型システムの目的は、プログラムエラーとバグの発生を抑止することである。そのための型安全性とは、各計算および各オペレーションでの型エラー(不正計算、ロジックエラー、バッファオーバーフロー、不正ポインタなど)の発生を防止することと同義になる。
型システムは、一般的に型理論をベースにしている。
プログラミング言語はさまざまな値を扱う。代表的かつ最も原始的なものは数値や文字列だが、一般的に有限の資源制約があるコンピュータにとって都合のよい内部表現が使われ、例えば数値には32ビットや64ビットといった固定サイズの整数型や浮動小数点数型が、文字列には特定の文字コード集合によって符号化された整数値の羅列(文字配列)が使われることが多い。文字列の表現には最後の文字(番兵)に0を使用するゼロ終端文字列(ヌル終端文字列)が使われることもあれば、長さ情報を別途整数値で保持する複合データ構造が使われることもある。三角関数は浮動小数点数を引数にとり浮動小数点数を返す。先頭の文字を大文字にする関数は文字列を引数にとり文字列を返す。ユーザーからの入力を数値として扱うためには、文字列を解釈して数値を返す関数が必要である。ここで、3.14 や "hoge" といった値について「浮動小数点数」や「文字列」といった種類に分類して扱っているが、同じ種類の値であれば同じ操作(演算)が可能である。この「値の種類」が型(データ型)である。
プログラムにおけるエラーはさまざまだが、型に基づく一連のエラーがある。単純な例としては、浮動小数点数を表現しているワードを(一般的なコンピュータのハードウェアでは、メモリ上のワードとしては区別がつかないため)整数型として扱ってしまう、といったようなものである。この例では 0 と +0.0 のような特別な場合を除いてたいていの場合は得られる結果は無意味であり、より複雑な構造を持った値の場合は構造を壊して不正にしてしまうかもしれない。このような異常をプログラムが起こさないことを検査するのが型検査(type checking)である。
型にまつわるものに限らず、プログラムの安全性(safety)とは、プログラミング言語や文脈によって定義が異なる場合があり、一概に述べることはできない。ひとつの指標として、プログラムが言語仕様で定義されていない「未定義」の状態に陥らない、という性質のことを指すことがある。たとえばC言語やC++の標準では、NULLポインタのデリファレンスや、配列の範囲外アクセスによるバッファオーバーランなど、そういった「未定義」の動作を引き起こすケースが決められている。大抵の実行環境では、NULLポインタのデリファレンスによってセグメンテーション違反(アクセス違反)が引き起こされ、オペレーティングシステムによってプログラムが異常終了させられることになるが、必ずしもそうなるとは限らず、実際には何が起こるか分からない。「安全な」プログラムを記述するためには、言語未定義の動作を避けるように注意深くコーディングしなければならない。この指標の観点では、プログラムのエラーを、言語未定義や処理系依存の異常動作によってではなく、ランタイムやインタプリタが検出して仕様通りに異常終了するような場合は「安全」の側に含まれることになる。一般的に仮想マシン上で動作するJavaやC#のような言語は、ランタイムによって検証され、信頼されたコードのみを実行する仕組みが用意されているため、C/C++よりも安全である。未定義動作はコンピュータセキュリティと密接な関係があり、例えばバッファオーバーランが引き起こされると悪意のある不正なコードを実行できてしまったりするセキュリティホールにつながることがある。ただし安全なチェック機構のある言語ほどオーバーヘッドが大きくなるため、安全性は実行速度とのトレードオフの関係にある。
例えば、JavaではNullオブジェクトを参照した場合、実行時にNullPointerException例外がスローされると言語仕様で規定されており、この点でC言語やC++よりも安全であると言える。しかし、JavaではNullオブジェクトを参照するコードを記述しても言語構文上は合法とみなされるため、コンパイラによるチェック機構は働かず、実行時にNullPointerExceptionがスローされるまでプログラミングミスに気づかない可能性がある。さらに進んだKotlinのように「Null安全」な言語では、Nullの状態を許容しない型を定義することができ、このNull非許容型を使う限りはNullオブジェクトを参照することはないことが保証される。また、Kotlinでは主にJavaとの相互運用のためにNullの状態を許容する型も定義できるが、このNull許容型を使う場合は、事前にNullチェックコードを記述することを言語仕様によって強制されるため、安全性が増す。
そして型にまつわる安全性のことを型安全性(type safety)と呼ぶが、一般的に型安全性とは、データ(オブジェクト)の本来の型に従ってプログラムを正しく実行できる性質のことを指す。前述のように型安全性が具体的にどのようなものであるかはプログラミング言語や文脈に依存する。
一般的にコンピュータでは整数と浮動小数点数のように異なる型の値同士の演算はできず、いったん同じ型に揃えて演算する必要がある。プログラマがコード上で型変換(キャスト)を明示する必要のある厳しい言語もあれば、コンパイラによってある程度の暗黙の型変換や型昇格がなされるようなゆるい言語もある。さらにCにおいては、キャスト(型変換)構文を使うと、互換性のない型同士でも強制変換できてしまう。例えば整数とポインタとの間で相互変換することや、整数へのポインタを浮動小数点数へのポインタに無理やり変換することもできてしまい、簡単に型安全性が破壊される。誤った型へのポインタ経由で領域にアクセスした場合の動作は未定義である。型検査によって型安全性を確保するために、互換性のない型同士の変換は明示的・暗黙的問わず一切できないようになっている言語や、間違った型変換をすると実行時に例外をスローする言語もある。
型安全でない例としては、C/C++において、可変長引数の関数を利用する場面が挙げられる。代表的なものがprintfやscanfで、これらの関数は任意の数のあらゆる型のデータ(オブジェクト)を可変長引数として統一的に渡せるように、いったん引数オブジェクトの型情報を消去し、別途入力として与えられた書式文字列をもとに型情報を関数内部で復元する仕組みになっている。しかし書式文字列の指定にミスがあると、本来のオブジェクトの型とは異なる誤った型として取り出すことになってしまうが、型が消去されてしまっているためにコンパイラはミスを検出することができない。結果として、想定されたものとは異なるでたらめな値が出力されてしまったり、間違った型へのポインタを経由することで誤った領域にデータを書き込んでしまいクラッシュしたり、といった未定義の異常動作を引き起こす。一方、JavaやC#の可変長引数では、実引数として渡される個々のオブジェクト自身が型情報を保持しており、書式指定ミスによる型の不一致があった場合は例外をスローして安全にプログラムを中断・異常終了させることができるため、C/C++よりも型安全性が高い。
別の例として、ジェネリックプログラミング(ジェネリクス)をサポートしていなかった初期のJavaやC#では、可変長の動的配列や双方向リストなどのコレクションに型Tのオブジェクトを格納する場合はいったん基底型であるjava.lang.ObjectやSystem.Objectにアップキャストし、またコレクションからオブジェクトを取り出す場合は型Tにダウンキャストしなければならず、正しい型のオブジェクトとして取り出すためには注意深くコーディングする必要があった。ジェネリクスをサポートすることで、コレクションに格納する型Tを静的に決めることができるようになり、誤ったキャストにより実行時エラーを引き起こす可能性を排除できるようになった。このような改善も型安全性の向上とみなされる。
静的型付き言語の型検査では、すべての式の型がその式が現れた文脈で期待される型と一貫しているか、検証しなければならない。たとえば、x := eという代入文では式eの推論される型は変数xの宣言型または推論型と一貫していなければならない。この一貫性の概念を互換性といい、プログラミング言語ごとに固有のものである。
明らかなように、eとxの型が同一でかつその型への代入操作が許可されているなら、これは正当な式である。したがって最も単純な型システムでは、2つの型が互換であるかどうかは2つの型が同一である(または等価である)かどうか(等価性)という単純な問題に置き換えることができる。別の言語では2つの式が同じ型を持つと理解されるのに別の基準を採用している。これら、型の「同一性理論」は非常に多岐にわたっており、2つの対極の例は構造的型システムと名前的型システムとである。構造的型システムとは外部に見せている構造(インタフェース、特に暗黙的なもの)が同じものを持つ値を同じ型であるとするもので、名前的型システムとは型宣言の構文からのみ型の同一性を判定する(型が同じ「名前」を持たなければならない)ものである。
オブジェクト指向言語のように基底型と派生型の関係がある言語では、互換性の関係はより複雑なものとなる。型Derivedが型Baseの派生型であるとすると、Derived型の値はBase型の値が必要とされる文脈で使用することができる(リスコフの置換原則)。しかし逆は真ではない。等価性と同様に派生型の関係はプログラミング言語によって異なった方法で定義され、多くのバリエーションが存在しうる。
静的な型システムの言語では型宣言を必要とし、基本的にプログラマはすべての変数に特定の型を明示的に関連付けなければならない。しかし、変数の初期化時の右辺値や変数の使われ方など、プログラマが型を明示せずともコードの文脈から型を自動的に決定する型推論(type inference)の機能を持つ言語もある。例として、Haskellにおいて変数 x と y を加算する関数 f を定義してみる。
ここで、x と y の型を特に明示していないが、+による加算は数値のみに定義されているので、コンパイラは x と y は共に数値型であると推論できる。ゆえにプログラム中で f の引数として数値でない型(文字列やリストなど)の値を渡して呼び出すとエラーを報告する。
型推論の目的は、単にコードの記述効率を向上したり、型のミスマッチに起因するエラーを軽減したりすることだけではない。ラムダ式や匿名型など、コンパイラが内部で型を自動生成し、プログラマが具体的な型の名前をコード上で書き下すことができない場合もあり、そういった機能に対応しなければならないという目的もあって型推論を導入した言語もある(C++やC#など)。
静的型付き言語はいわゆるコンパイラ言語に、動的型付き言語はいわゆる動的プログラミング言語によくみられる。
型検査がどのように働くのかを見るために、次の擬似コードを考える。
var x;
x := 5;
x := "hi";
この例では、1行目でxという名の変数を宣言し、2行目でxに整数5を代入し、3行目でxに文字列"hi"を代入している。ほとんどの静的型付けの処理系ではこのようなコードは不正(型エラー)となる。なぜなら2行目と3行目でxに一貫性のない型の値を代入しているからである。
対照的に動的型付けの処理系では、型は変数ではなく値に付けられるので、上のようなコードが実行できる。動的型付けの処理系は間違った文や式を実行したときに、値の誤用に関するエラーを型エラーとして捕捉する。つまり、動的型付けはエラーをプログラムの実行中に捕捉する。動的型付けの典型的な実装ではプログラム中のすべての値が型情報を持ち、演算に値を使う前に型情報を確かめる。例を挙げる。
var x := 5;
var y := "hi";
var z := x + y;
このコードでは、1行目でxを値5で束縛し、2行目でyを値"hi"で束縛し、3行目でxとyを足そうとしている。動的型付き言語ではxを束縛した値は<tt>(整数, 5)</tt>というペアとして表すことができ、yを束縛した値は<tt>(文字列, "hi")</tt>というペアで表すことができる。プログラムが3行目を実行しようとしたとき、処理系は<tt>整数</tt>と<tt>文字列</tt>という型情報を検査し、もし演算+(加算)がその2つの型について定義されていなかったら、エラーを出す。
プログラミング言語の中には、静的に型検査されないコードをプログラマが書けてしまう「バックドア」を持つものもある。例として、JavaやC風の言語には「キャスト」がある。
プログラミング言語が静的型付けをもつことは必ずしも動的型付けをもたないことを意味するわけではない。例えばJavaは静的型付けを採用しているが、処理によっては動的な型情報の取得を必要とするものもあり、それらは動的型付けの一形態とみなせる。静的型付けと動的型付けの違いについては様々な議論がある。
静的および強い型付けの支持者と動的および自由な型付けの支持者の間では衝突が度々おきる。前者のグループは厳密な型付けの使用によって、処理系がより多くのエラーを問題が大きくなる前に発見できるようになると主張している。後者のグループはより気軽な型付けによってコードはよりシンプルなものになり、そのようなコードは解析しやすいとされるので、エラーは減少すると主張している。型推論がある言語では型を手で宣言する必要はほとんどないので、強い型付けに伴う開発のオーバーヘッドは低減される。
個人がどのグループに分かれるかは、開発しているソフトウェアの種類やチームのメンバーの能力、他のシステムとの対話性の度合い、開発チームの規模などに依ることが多い。少人数で小回りのきくプロジェクトには気軽な型付けがより合い、フォーマルで大人数で仕事が分断されている(プログラマ、アナリスト、テスト部隊、など)プロジェクトは厳密な型付けのほうがうまくいくことが多い、と結論づける者もいる。
TypeScriptのように、JavaScriptの持つ動的型付けにまつわる問題点を回避して大規模アプリケーション開発にも耐えうるようにするため、静的型付けをサポートするよう改良された言語もある。
静的な型検査によってコンパイラは最適化に有用な情報を得ることがある。例えばデータ構造アライメントの指定によって、ある型の値が16の倍数のアドレスに配置されることが保証されていれば、コンパイラはより効率の良いマシン命令を選択できる。
より表現力の高い型システムでは、型はプログラマの意図を説明することができるので、型自身がドキュメントの役割を果たし、可読性の向上に寄与することもある。例として、何らかの時刻情報(タイムスタンプ)を返す関数を定義するとき、単なる64ビットの組み込み整数型ではなく専用のタイムスタンプ型(構造体型など)を返す関数として宣言すると、その型情報が関数の意味を記述していることになる。
型によってプログラマは低レベルでの実装に煩わされずにより高レベルで考えることができるようになる。これはプログラム設計に適切な抽象化をもたらす。例えば文字列型によってプログラマは文字列を文字列として、単なるバイトの列ではないものとして考えることができる。
また型によって複数のデータ構造の間の依存関係を明確にすることができ、さらに上位レベルでは複数のサブシステム間のインタフェースを表現することができる。これはサブシステムの相互運用性に必要な定義を局所化し、それらのサブシステムが通信する際に起きる矛盾を防止するなど、モジュール性の向上に貢献する。
静的型付けか動的型付けかの選択はいくつかのトレードオフを必要とする。
静的型付けは型エラーをコンパイル時にある程度確実に発見する。よって最終的なプログラムの信頼性を上げるはずである。しかしながら、型エラーがどれほど犯しやすい間違いなのか、その内の何割が静的型付けで検出できるのか、という点についてプログラマの意見は割れている。静的型付けの支持者は型検査されたプログラムの方が信頼性が高いと信じており、それに対して動的型付けの支持者は実際に流通しているソフトウェアの信頼性では大差ない点を指摘している。
静的型付けは大抵、より高速に実行可能なコンパイル済みコードを生成する。コンパイラが正確なデータ型を知っていれば、最適化されたコードを生成できる。さらに、静的型付き言語のコンパイラではショートカットをみつけるのもより簡単になる。この理由からCommon Lispなどのいくつかの動的型付き言語では随意で型宣言ができるようになっている。最適化のための型付けは静的型付けの影響で普及した。
対照的に、動的型付けのほうがコンパイラやインタプリタの動作が高速になることがある。動的型付けの言語ではソースコードが変更されてもやり直すべき解析が少ないためである。これは「編集-コンパイル-テスト-デバッグ」というサイクルの時間を減らす。
型推論のない静的型付き言語ではプログラマがメソッドや関数の型を宣言しなければならない。これはプログラムの追加的なドキュメントとして機能することがあり、コンパイラによってコードと同期させることが強制される。しかし型宣言のない静的型付き言語もあるので、これは静的型付けのというよりは型宣言の報酬である。言語が型推論の機能を持っていたとしても、型推論による暗黙的型付けを多用しすぎると可読性やメンテナンス性がかえって低下することがある。型推論を使用することが適切ではないケースについて一定のガイドラインが示されていることもある。
動的型付けはいくつかの静的型付けでは不正となり実現できない仕組みを可能にする。例えばデータをコードとして実行するeval関数である。さらに動的型付けでは、具体的なデータ構造の代わりに文字列を暫定的に用いることなどがやりやすく、プロトタイピングとの相性も良い。
動的型付き言語のメタプログラミング機能はより強力で使いやすいことが多い。例を挙げると、C++のテンプレートはRubyやPythonでの等価なコードより、書くのが煩わしい。またイントロスペクションのような、より高度な実行時の仕組みを静的型付き言語で使うのは、さらに困難になることが多い。
典型的には、プログラム中ではすべての値には1つの特定の型が付けられる(1つの型が複数の派生型を持つ場合でも)。オブジェクトやモジュール、通信路、依存関係、及び型自身にさえ型が付けられることもある。
また、型システムの理念であるコンパイル時に重点を置いたフォールトアヴォイダンスを更に推し進めている拡張的な形式体系にエフェクトシステムというものがある。こちらではプログラム副作用をも体系化して一種の型として扱っている。
ポリモーフィズム(多相)という語は、コード(具体的には関数やクラス)が複数の型の値に基づいて動作できること、または同じデータ構造の異なるインスタンスが異なった型の要素を持てることを指す。型システムによってはコードの再利用性を改善するためにポリモーフィズムを持つものもある。ポリモーフィズムのある言語ではプログラマはリストや連想配列のようなデータ構造を使用される要素の型ごとにではなく、単に一度だけ実装すればよい。この理由から計算機科学ではこの種のポリモーフィズムの利用はジェネリックプログラミングと呼ばれることがある。ポリモーフィズムの型理論における基礎は抽象化やモジュール、また(場合によっては)派生型についての研究と密接な関連がある。
http://pico.vub.ac.be/~wdmeuter/RDL04/papers/Meijer.pdf