プログラミング言語についての雑談

31 min

この記事では、プログラミング言語の設計方法を通じて、プログラミング言語理論の基本的な概念、実装の考え方、現状について普及させます。

私がプログラミング言語を設計する……の?

では、私たち自身で新しいプログラミング言語を設計しましょう:C—。

そう、自分たちで設計するのです。

まずはコンパイラ理論やコンパイラ、インタプリタの実装など複雑なことは脇に置き、機能の具体的な実装については議論しません(タイトルを見てください:雑談です!)。

私たちはボトムアップで、自分たちのためのプログラミング言語を構築していきます。

最も低い層では、私たちの言語の最終的なコンパイル成果物は RISC-VI 形式であり、それに対応する RISC-VI 命令セットがあると仮定します。このアセンブリ言語は非常に基本的で、メモリとレジスタに対して簡単な操作しかできません。

コードはどこで動くの?

この問題は実は RISC-VI 命令セットやアーキテクチャの問題であり、私たちが設計する高級言語の機能とは一見関係がないように思えます。しかし、これはあなたのコードがコンピュータシステムのどの層に位置するかに関わる問題であり、プログラミング言語設計者がまず考慮すべきことです:

コンピュータシステムの本質は、階層化された仮想マシンである

この階層的な仮想マシンモデルでは、上位層が下位層の操作インターフェースをラップし隠蔽し、自身の特徴を追加してさらに上位層に提供します。

オペレーティングシステムはハードウェア(ベアメタル)に対する一層の仮想化です。例えばあなたのコンピュータが x86 命令セットアーキテクチャである場合、書いた C 言語の小さなプログラムをバイナリにコンパイルして実行するとき、OS はまずそのバイナリファイルのフォーマットを解析します。Linux の場合、その実行ファイルの形式は elf フォーマットです。elf フォーマットの解析が終わると、OS はファイル内の各セクションをメモリにロードし、コードセクションの最初の命令にジャンプして実行を開始します。もちろん実際はもっと複雑で、OS はメモリ資源を精密に管理し、プロセスなどを通じて異なるタスクの実行を隔離します。

偶然(?)にも、C 言語でコンパイルされたバイナリファイルは、OS の層だけでなくベアメタル上でも動作可能です。典型的な例として、Linux カーネルの大部分は C 言語で書かれています(最近は主流に Rust コードも取り込まれ、将来が期待されています)。システムプログラミングのレベルでは、C プログラムは主に OS が提供するシステムコールを使用します。例えば x86 の Linux 環境で、プログラムがファイルのデータをメモリに読み込む場合、通常は sys_read というシステムコールを呼び出します。このシステムコールを受け取ると、Linux はファイルからデータを読み込みます。もちろん事前の権限チェックなども行われます。しかし、もし C で OS を作るなら、システムコールは存在しません。ファイルという概念すら OS が抽象化したものです。このとき、直接ハードディスクドライバとやりとりし、ハードディスクコントローラのレジスタを操作して特定の位置のデータを読み取る必要があります。

この説明から、OS は上述の仮想マシンの定義に完全に合致していることがわかります。むしろ Linux は elf ファイルを実行するための仮想マシンと言っても過言ではありません(もちろんそれ以上のこともしています)。OS が C 言語に提供する標準ライブラリを除けば、C 言語はベアメタル層で動作する言語です(正確には C 言語のコンパイル成果物がこの層で動作しますが、ここではそう言っておきます)。

別の例を見てみましょう:Python。Python は典型的なインタプリタ型言語です。公式のインタプリタは CPython で、これは C 言語で書かれた Python インタプリタです。上記の階層モデルを当てはめると、CPython は OS 上の仮想マシンであり、OS の提供するインターフェースをラップして上位の Python プログラムに提供しています。

極端に言えば、C 言語のようなコンパイル型言語も無理やりインタプリタ型言語と見なせます。CPU は確かに命令を一つずつ読み取り、解析して実行していますが、それらの命令はバイナリです。一方 Python はインタプリタ型言語であり、その命令は人間が読める文字列です。

インタプリタ型言語の大きな利点は移植性の高さです。インタプリタが異なる OS の低レイヤーの差異を隠蔽し、高級言語層に同じ API を提供するため、書いたコードは「一度書けばどこでも動く」ことが可能になります。ただし、苦労の法則により、楽になるのはプログラマであり、インタプリタを書く人は苦労します。しかし、コンパイル型言語でもコンパイラは異なる命令セットアーキテクチャごとに別々に実装しなければならず、苦労は変わりません。

「どこでも動く」と言えば、最も有名な「一度コンパイルすればどこでも動く」言語、Java を語らずにはいられません。Java はコンパイルとインタプリタを組み合わせています。JVM は Python の CPython に相当し、Java 言語のランタイムです。java ファイルはまず class ファイルにコンパイルされ、JVM が読み込むファイル形式は class ファイル形式です。このファイルもバイナリですが、異なる命令セットアーキテクチャの OS 上でも同じ形式です。だから x86 でコンパイルした class ファイルを RISC-V の JVM で実行できるのです。JVM は class ファイルを読み込んだ後、インタプリタのように一命令ずつ読み込み、解析して実行します。したがって Java がコンパイル型かインタプリタ型かの区別は難しいです。

JVM は非常に成功した仮想マシン(コンピュータシステム層面)であり、Java 言語だけでなく Scala や Groovy など多くの他言語もサポートしています。これは重要な事実に基づいています:これらの言語はすべて class 形式にコンパイル可能だからです。

一般的に、インタプリタ型言語は実行効率が低く、コンパイル型言語は効率が高いと考えられています。しかしプログラミング言語の発展に伴い、多くのインタプリタ型言語は実行効率を上げるための機能を追加しています。Java を例にとると、JVM が class ファイルをインタプリタ実行している段階で、JVM はホットスポット(頻繁に実行されるコード)を動的に解析し、そのコードを直接機械語にコンパイルします。次にホットスポットに到達したときは再度解釈せず、すでにコンパイルされた機械語を直接実行します。この技術は JIT(Just-in-time compilation、即時コンパイル)と呼ばれます。同様に Python にも numba という JIT 技術を用いた実行高速化ライブラリがあります。

もちろん、もし C 言語のインタプリタを作れば、C 言語もインタプリタ型言語と言えます(

型システム

C—の実行場所は決まりましたが、C—はまだ非常に簡素で、ほとんど存在しないに等しいです:

  • コンパイル型言語の道を進むなら、RISC-VI は命令セットアーキテクチャであり、高級言語とは一般に無関係です
  • インタプリタ型言語の道を進むなら、RISC-VI と対応するインタプリタは私たちが設計・実装することになります

RISC-VI はメモリとレジスタだけを操作できるアセンブリ言語で、メモリ領域やレジスタは意味のないバイト配列として扱われます。任意のバイトを操作できます(下位仮想マシンの許す範囲内で)。

今のところ型システムがないと仮定し、すべての操作は直接バイト配列を扱います。C 言語で言えば、型を定義せず void *ポインタで全バイトを操作するようなものです。使える操作はアドレス取得とデリファレンス、バイトの代入と取得だけで、ほとんどアセンブリ言語を書くのと変わりません!ヒープ上に 4 バイトの整数を作成して 1 を代入する方法は以下のようになります(もちろん C の文法で、実際に型システムがなければアセンブリと同じです):

void *intBytes = malloc(4);    // ヒープ上に 4 バイト確保
*(intBytes+3) = 0x01;          // ビッグエンディアンと仮定し、オフセット 3 に 1 をセット

私たちがよく知る int や float などはどこに行ったのか?それは型システムの仕事です。

型の本質は、メモリ領域の解釈方法である

型システムは無秩序なヒープやスタック空間を意味のある塊に分割し、型に応じて異なる解釈方法を与えます。プログラマにとっては、異なる型は宣言時の文法の違いとして最も直感的に現れます。例えば C 言語では int は通常 4 バイトの整数を表し、double は IEEE 754 準拠の倍精度浮動小数点型を表します。型はコンパイラが生成する実行時のバイト操作方法に影響します。

型システムがあれば、上記の 4 バイト整数の作成は以下のように書けます:

int *intBytes = (int *)malloc(4);
*intBytes = 1;

intBytes が int 型ポインタであると宣言したため、そのメモリ領域は整数として解釈されます。したがって 2 行目で intBytes に 1 を代入しても、1 が 0 番目のバイトに入るのではなく、3 番目のバイトに入ることを心配する必要はありません。コンパイラは intBytes が int 型ポインタであることを知っているので、すべての操作を int 型に合わせて最適化します。最終的な機械語はやはり 3 番目のバイトに 1 をセットしますが、それはコンパイラがやってくれるので、私たちは型を操作するだけで済みます。

面白い点として、C 言語はコンパイル時にポインタ変数のオフセット計算を最適化しています。例えば intBytes+1 は実際には intBytes の指すアドレスに 4 を足したものです。これは C 言語が配列を実現する重要な根拠にもなっています。

型システムはコンパイル時の特徴である(C 言語の場合)

同様に構造体も本質的にはコンパイラにメモリ操作を指示するためのものです。例えば以下の構造体:

typedef struct exampleStruct {
	int a;
	int b;
} exampleStruct;

この構造体は 2 つの int 変数を持ち、メモリ 8 バイトを占めます。exampleStruct 型のポインタ esp を定義し、あるメモリ領域を指すとき、そのアドレスとその後の 8 バイトを exampleStructの構造に従って解釈します。esp.besp->b で int b を操作するときは、実際には int 型として esp の指すアドレスの 4〜7 バイト目を操作しています。

つまり構造体内の変数は、実行時には構造体の先頭からのオフセットを示すだけです。

ポインタを使わず関数内でexampleStruct esp;のように構造体を宣言する場合は、コンパイラがスタック上の変数割り当てを特別に扱っているか、あるいは構文糖衣構文と考えられます。なぜなら:

  1. 手動で初期化しなくても宣言だけで使える。実際にはコンパイラがコンパイル時にスタックフレーム内の位置を決めている。
  2. ライフサイクル管理が不要で、関数終了時に自動的に解放される。解放はスタックポインタの移動(スタックは下方向に伸びる)で行われ、メモリは上書きされない。

もちろんスタック割り当ての欠点も言うまでもなく、2 番目の利点は欠点でもあり、関数外で使えなくなります。

C と比べて Java の型システムは非常に制限されています。型情報はオブジェクトのメモリ内に直接書き込まれ、強制型変換は型ツリーの親子ノード間でしかできません。

値渡しか参照渡しか?

よく議論され、間違えやすい点に関数の引数渡しがあります。値渡しか参照渡しか。

本質的には、すべての関数引数は値渡しであり、参照渡しは値渡しの最適化と考えられます。

C 言語は言うまでもなく、レジスタ渡しでもスタック渡しでも、元の内容をコピーまたはバックアップし、関数終了後に内容が変わらないようにします。ポインタを渡す場合は、そのポインタの属性を除けば、単に数値列(32 ビットまたは 64 ビット)を渡しているだけで、アドレスを long 型変数に代入して渡すのと同じです。

参照渡しという言葉は主に Java で使われます。参照はオブジェクトハンドルであり、プログラムはハンドルを通じてオブジェクトの一部情報にアクセスします。ハンドルはオブジェクトのメモリ上のアドレスを直接表すわけではありませんが、実装上はオブジェクトの実際のメモリアドレスを含みます。参照を関数やメソッドに渡すときは、オブジェクトの実アドレスを含む構造体を渡すと見なせ、C 言語のポインタ渡しに似ています。

しかし Java には 8 種類の基本データ型があり、それぞれ対応するラッパークラスもあります。実装上は統一されておらず、これは初期に C++ プログラマを誘うための措置だったと言われ、優雅さに欠けます。

配列とは何か?

基本的な型システムができたら、次は特殊だが非常に一般的な複合データ型、配列を考えます。しかし配列とは何か、本当に存在するのか?

C 言語は実行時に配列は存在せず、配列はコンパイル時の構文糖衣です。C はポインタを基に配列を実現しています。配列名は実際には配列の最初の要素のアドレスを指し、例えばint a[10]を宣言すると、a は &a[0]と同じです。添字演算(角括弧)も型ポインタのオフセット計算に基づきます。a[1]*(a+1) と同じです。具体的には:

int b = a[0];

// 等価
void *p = (void *)a;
p += 4;
int b = *((int *)p);

配列は特定の型ポインタを基に実装され、対応する型ポインタと自由に変換できるため、言語レベルでの配列境界チェックはありません。例えばスタック上に長さ 10 の配列を定義し、11 番目の要素を読み書きしても問題がないことがあります。

「違う、配列境界を越えるとセグメンテーションフォルトが起きる」と言う人もいますが、それは C 言語がチェックしているのではなく、アクセスしたメモリが読み書き不可で OS がエラーを返しているだけです。これは言語レベルのエラーではありません。

C 言語は配列を引数に渡す方法が 3 つあります:ポインタ渡し、サイズが定義された配列渡し、サイズ未定義の配列渡しです。

void func(int *array);
void func(int array[10]);
void func(int array[]);

興味深いのは、1 番目と 3 番目の方法では func 内で len を使って元の配列長を取得できず、2 番目は常に 10 と見なされます。実引数が 10 要素の配列でなくてもです。これは配列の長さ情報が型定義に書かれているためで、引数渡しなどで型が変わると長さ情報が失われることを示しています。配列はポインタで実現されているという本質に合致します:実際のメモリには連続した要素以外の情報はありません。

対して Java には本物の配列があります。Java の配列はすべてオブジェクトであり、基本型や長さなどの情報はオブジェクトヘッダに書かれています。したがって Java は実行時に配列の境界チェックを行い、IndexOutOfBoundsException を投げます。

手続き型かオブジェクト指向か?

実はこれは問題ではありません。広義には、オブジェクト指向と手続き型はプログラミング思想やパラダイムの違いであり、言語の違いではありません。C 言語でも構造体を定義してオブジェクト指向的なプログラミングは可能です。

狭義には、オブジェクト指向言語とは、オブジェクト指向の 3 大特性(カプセル化、継承、多態性)をネイティブに完全実装した言語を指します。

カプセル化は言うまでもなく、単純なカプセル化は C 言語でも構造体で可能です。しかしカプセル化の重要な目的は「オブジェクトの内部実装詳細を隠し、外部はオブジェクトが提供するインターフェースを通じてのみデータを操作する」ことですが、C 言語の構造体にはアクセス制御がなく、内部フィールドは自由に変更可能で、実質的にカプセル化は意味を成しません。Java や C++ と C の大きな文法上の違いは、オブジェクト(構造体)のメンバメソッドをドット演算子で直接呼べることですが、実装上は特に特別なことはなく、メンバメソッドは第一引数にオブジェクトポインタを取る関数であり、コンパイラが自動的に this ポインタとして追加しているだけで、他の関数と変わりません。

実装面では、Java、C++、Golang は共通点があります:いずれも直接または間接的にコンポジション(合成)を通じて継承を実現しています。継承を使う際は、カスタムコンストラクタで親クラスのコンストラクタをまず呼ぶ必要があります。Golang の継承は特にコンポジションの意味が強く、構造体内に匿名の親構造体を定義します。これにより親の変数へのアクセスは親オブジェクトの変数を直接操作することになり、構文糖衣のように見えます:

type Parent struct {
	a int64
}

type Child struct {
	Parent
	b int64
}

c := &Child{Parent{}, 0}
a := c.a
// または
a := c.Parent.a;

Java や C++ と比べて、Golang の継承はままごとのようで、子クラスが親クラスの変数へのアクセス制御を規定せず、パッケージ外公開を決める大文字小文字のルールをそのまま使い、Java の protected のような細かいアクセス制御はありません。

多態性の典型的な表れは、親ポインタが異なる子クラスオブジェクトを指し、共通の関数を呼ぶと子クラスごとに異なる振る舞いをすることです。Java と C++ はそれぞれ典型的な方法で多態性を実現しています。Java はランタイム(JVM)を持つため、多態性の実装が非常に容易です。親型のオブジェクトハンドルでメソッドを呼びますが、ハンドルからオブジェクトの具体的な型やメソッド情報を特定できるため、具体的な実装メソッドの呼び出しが容易です。したがって Java の多態性はランタイム多態性です。一方 C++ はコンパイル時多態性です。C++ の各クラスは仮想関数テーブルを持ち、オブジェクト生成時に自分の仮想関数テーブルへのポインタを持ちます。仮想関数は継承でオーバーライドされる可能性のあるメソッドのリストです。コンパイラは親クラスと子クラスが同じメソッドを実装している場合、そのメソッドが仮想関数テーブルの同じ位置にあることを保証します。したがって、仮想関数呼び出しは「仮想関数テーブルの N 番目の関数を呼ぶ」という命令に変換されます。親クラスポインタでメソッドを呼ぶと、実際には子クラスの仮想関数テーブルの関数ポインタが呼ばれ、多態性が実現されます。

ジェネリクスの実装

ジェネリクスは IDE のスマート補完のおかげで、最も広く使われる高級言語機能の一つです。しかし Java は JDK5 でようやくジェネリクスを実装し、C++ は C++11 標準でテンプレートプログラミング(ジェネリクスの一種)を導入しました。これは新しい機能です。興味深いのは、この 2 つの実装が全く異なるジェネリクス実装方式を代表していることです。

Java の場合、ジェネリクスはコンパイル時に実装され、実行時には存在しません。いわゆる型消去です。したがって Java のジェネリクスはコンパイル時の型チェックにのみ使われます。もしコンパイル時のジェネリクスチェックを回避したら(例えば手動で class ファイルを作成したり、リフレクションで操作したり)、JVM はそれを検知できません。例えば List<String> を宣言すると、通常は String 型しか入れられませんが、型消去により実行時には単なる List、あるいはList<Object> であり、実行時に Integer を入れても JVM はエラーを出しません。

C++ のジェネリクスはコード生成で実現され、テンプレートと呼ばれます。コンパイラはテンプレートクラスの各インスタンス化で使われるテンプレートパラメータを調べ、それぞれに対応するメソッドを生成します。例えばClassName<typename T>というテンプレートクラスにTest(T t)メソッドがあり、ClassName<int> testObjでインスタンスを作ると、コンパイル成果物にはTest(int t)メソッドが実際に存在します。Java と同様、実行時にジェネリクスは感知されませんが、テンプレート生成のクラスやメソッドは手動定義と変わりません。しかしこの方式は Java より安全です。型消去ではなくコード生成なので、コンパイルを回避すればチェックなしになる Java とは異なります。

C++のジェネリクスの欠点は、テンプレート生成の最終実装クラスは完全でなければならず、テンプレートパラメータを含まない関数も重複生成されることです。コードが同じでも複数生成され、メモリ浪費になります。C#はこれを改善し、コンパイル時にテンプレートコードを直接生成せず、テンプレートクラスの異なる実装数を統計し、.NETランタイム(CLR、JVMに類似)のJITコンパイル時に共有機械語を生成します。型特化情報は別テーブルに格納され、各インスタンス化ジェネリック型が保持します。これによりコードの大部分を共有できます。詳細は論文を参照してください。ただしC++にランタイムがないため、C#のような動的共有は難しいです。

終わりに

上記の内容を決めれば、あなたのプログラミング言語のおおよその姿はほぼ確定します。文法すらまだ決めていなくても。あとは非常に一般的で段階的な内容、つまり実装するだけです!

もちろん、言語に GC や特殊なライフサイクル管理などの高級機能を自由に追加できます。VM を持つと高級機能の実装は楽になりますが必須ではありません。例えば golang は GC 関連コードを最終成果物に直接組み込み、コンパイル成果物ごとに小さな VM を持つような形です。

大多数の人は自分でプログラミング言語を実装する必要はありませんが、言語間の共通点や特徴を理解することは依然として重要です。世界に最良のプログラミング言語はなく、ある場面で最適な言語があるだけです。したがって最高の言語論争は滑稽です。各言語は特定の問題を解決するために生まれました。もし新しい言語が既存の言語と機能的に何も変わらなければ、なぜ新言語を学ぶ必要があるのでしょう?

もちろん、これらの特徴を知っていると、プログラマ同士の飲み会や技術グループでの言語論争(もちろん理論的な議論)で大いに語ることができます。