この記事では、プログラミング言語をどう設計するかを話しながら、言語理論の基本概念、実装の考え方、現状を紹介します。
自分で言語を設計……するんですか?
これから新しいプログラミング言語、C--を設計します。
そう、私たち自身でです。
コンパイラ理論やコンパイラ・インタープリターの実装といった複雑な話は、ひとまず置いておきます。個々の機能の具体的な実装にも踏み込みません。タイトルを見てください、雑談です!
自分たちの言語を、下から積み上げていきましょう。
最下層は仮定してしまいます。最終的なコンパイル結果はRISC-VI形式で、対応するRISC-VI命令セットがあるものとします。このアセンブリ言語はとても基本的で、メモリとレジスターの簡単な操作しかできません。
コードはどこで動く?
これはRISC-VIの命令セットやアーキテクチャの問題で、設計したい高級言語の機能とは無関係に見えます。しかし、コードがシステムのどの階層にいるかという、言語設計者が最初に考えるべき問題です。
コンピューターシステムの本質は、階層化された仮想マシンです。
このモデルでは、上位層が下位層の操作インターフェースを包んで隠し、独自の機能を加えて、さらに上の層へ提供します。
OSはハードウェア、つまりベアメタルを仮想化する層です。たとえばx86のPCで小さなCプログラムをコンパイルして実行すると、OSはまずバイナリ形式を解析します。Linuxなら実行ファイルはELFです。解析後、各セグメントをメモリへロードし、コードセグメントの最初の命令へ飛んで実行します。もちろん実際はもっと複雑で、OSはメモリを細かく管理し、プロセスなどで実行中のタスクを分離します。
偶然にも(?)、Cのコンパイル結果はOS上だけでなく、ベアメタルでも動きます。典型例はLinuxカーネルで、大半がCです。最近メインラインにRustも入り、今後が楽しみです。システムプログラミングでは、Cプログラムは主にOSのシステムコールを使います。x86 Linuxでファイルをメモリへ読むなら、通常sys_readを呼びます。Linuxは権限確認などを行い、代わりに読み込んでくれます。しかしCでOSを書く場合、使えるシステムコールはありません。ファイル自体がOSの抽象概念です。ある位置のデータを読むだけでも、ディスクのハードウェアと直接やり取りし、コントローラーのレジスターを変更する必要があります。
こう見るとOSは先ほどの仮想マシンの定義にぴったり当てはまります。LinuxはELFを実行するVMとも言えます。もちろんそれ以上のこともしますが。OSが提供するC標準ライブラリを除けば、Cはベアメタルの層で動く言語です。正確にはコンパイル結果がそこで動くのですが、ひとまずそう呼びましょう。
別の例🌰としてPythonを見ます。典型的なインタープリター型言語で、公式実装のCPythonはCで書かれています。階層モデルで言えば、CPythonはOS上のVMです。OSのインターフェースを包み、上のPythonプログラムから呼べるようにします。
極端に言えば、Cのようなコンパイル型言語も無理やりインタープリター型と見なせます。CPUは実際に命令を1つずつ読んで解析・実行していて、それがバイナリなだけです。一方Pythonの命令は読める文字列で、CPythonの入力は人間が読めるテキストです。
インタープリター型の大きな利点は移植性です。OSごとの違いを隠して共通APIを提供するので、一度書けばどこでも動くわけです。もっとも苦労の総量は保存されるので、利用者が楽をした分、インタープリターの作者が苦労します。ただコンパイル型でも命令セットごとの実装は必要なので、それほど違わないのかもしれません。
どこでも動くといえば、有名な一度コンパイルすればどこでも動くJavaです。Javaはコンパイルと解釈を組み合わせます。JVMはPythonのCPythonに相当するランタイムです。javaファイルをまずclassへコンパイルし、JVMはそのclass形式を読みます。バイナリですが、異なる命令セットのOSでも同じ形式です。だからx86で作ったclassがRISC-VのJVMでも動きます。読み込んだ後は、インタープリターのように命令を1つずつ解析・実行します。Javaがコンパイル型かインタープリター型か、単純には決められません。
JVMはシステムの観点でも成功したVMで、JavaだけでなくScalaやGroovyなども動かせます。重要なのは、これらがすべてclass形式にコンパイルできることです。
一般にはインタープリター型は遅く、コンパイル型は速いと考えます。しかし発展とともに、前者にも実行効率を上げる機能が加わりました。JavaではJVMがclassを解釈する間にホットなコードを分析し、直接機械語へコンパイルします。次回からは再解釈せず、その機械語を実行します。これがJIT(Just-in-time compilation、実行時コンパイル)です。PythonのNumbaもJITで実行を高速化します。
もちろんCのインタープリターを書けば、Cはインタープリター型だと言ってもいいわけですが……。
型システム
C--が動く場所は決まりましたが、まだ非常に貧弱で、存在していないも同然です。
- コンパイル型で進むなら、RISC-VIは命令セットアーキテクチャで、通常は高級言語とは独立しています。
- インタープリター型で進むなら、RISC-VIと対応するインタープリターを自分たちで設計・実装するかもしれません。
RISC-VIが操作できるのはメモリとレジスターだけです。その目には、どちらも意味のないバイト列で、下位VMの許す範囲でどのバイトも操作できます。
型システムがなく、すべての操作がバイト列を直接扱うと仮定します。Cで言えば、型を定義せずvoid *ですべてのバイトを操作するようなものです。アドレス取得、参照先の操作、バイトの読み書きしかできず、アセンブリとほぼ同じです。ヒープに4バイト整数を作り1を代入するなら、次のようになります。C構文ですが、型がなければ本当にアセンブリ同然です。
void *intBytes = malloc(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行目では、1が3番目ではなく0番目のバイトに入る心配をせず、そのまま代入できます。コンパイラはintへのポインターだと分かれば、操作をint向けに調整します。最終的な機械語はやはり3番目のバイトを1にしますが、その処理はコンパイラ任せで、私たちは型を操作すればよいのです。
面白いのは、Cがポインターのオフセット計算をコンパイル時に処理することです。ここでintBytes+1は実際には元のアドレス+4です。intが4バイトだからです。これはCの配列実装の重要な基盤でもあります。
Cでは、型システムはコンパイル時の機能です。
構造体の定義も、本質的にはコンパイラへメモリの操作方法を伝えるものです。たとえば次です。
typedef exampleStruct struct {
int a;
int b;
}
intが2つなので8バイトを占めます。exampleStructへのポインターespを定義すると、そのアドレスから8バイトをexampleStructの構造で解釈するという意味になります。esp.bやesp->bでint bを操作するのは、espからオフセット4〜7のバイトをintとして操作することです。
つまり構造体のフィールド名は、実行時には構造体先頭からのアドレスのずれを示すだけ、と言えます。
ポインターを使わず関数内でexampleStruct esp;と宣言する場合は、スタック上の変数確保に対するコンパイラの特別扱いです。構文糖衣と見てもよいでしょう。というのも、次のことができるからです。
- 手動で初期化せず、宣言すれば使えます。実際にはコンパイラがスタックフレーム内の位置を先に決めています。
- 寿命を管理する必要がなく、関数終了時に自動解放されます。下向きに伸びるスタックならポインターを上へ動かすだけで、メモリ自体は上書きしません。
欠点は言うまでもなく、2つ目の利点の裏返しです。関数が戻ると解放されるので、関数の外では使えません。
Cに比べるとJavaの型システムはかなり制約があります。型情報がオブジェクトのメモリに直接入り、キャストは型ツリーの親子間でしかできません。
値渡しか参照渡しか?
関数の引数が値渡しか参照渡しかは、よく話題になり、間違えやすいところでもあります。
根本的にはすべて値渡しと考えられ、いわゆる参照渡しも値渡しに基づく最適化です。
Cは分かりやすく、レジスターでもスタックでも、元の内容をコピーまたは退避し、呼び出し先から戻った後に変わらないようにします。ポインターもその性質を外して見れば、アーキテクチャに応じた32ビットや64ビットの数値にすぎません。そのアドレスをlongへ代入して渡すのと同じです。
参照渡しという話は主にJavaで登場します。参照の本質はオブジェクトのハンドルで、それを通じて一部の情報にアクセスします。ハンドル自体はメモリアドレスを表しませんが、実装には実際のアドレスが含まれるはずです。参照を関数やメソッドへ渡すのは、そのアドレスを含む構造体を渡すと見なせます。Cのポインター渡しに似ているので、動作も似ています。
ただJavaには8つの基本型と、それぞれのラッパークラスもあり、実装の統一感に欠けます。初期にC++プログラマーを呼び込むためだったとも聞きますが、優雅さは失われています。
配列とは何か?
基本的な型システムができたら、特殊ですがよく使う複合型、配列を考えます。しかし配列とは何で、本当に存在するのでしょうか。
Cには実行時の配列はなく、コンパイル時の構文糖衣としてポインターで実装されます。配列名は0番目の要素へのアドレスです。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には本物の配列があります。各配列はオブジェクトで、要素型や長さなどがヘッダーに保存されます。だから実行時に範囲外アクセスを検出し、IndexOutOfBoundsExceptionを投げられます。
手続き型かオブジェクト指向か?
実は、それほど問題ではありません。広い意味では、両者は言語の違いというより考え方やパラダイムです。Cでも構造体を使えばオブジェクト指向で書けます。
狭く定義するなら、カプセル化、継承、多態性という3大特徴を標準で完全に実装する言語だけを、オブジェクト指向言語と呼ぶことにします。
単純なカプセル化ならCでも構造体へまとめるだけです。しかし重要な目的は、内部実装を隠し、外からは提供されたインターフェースでのみデータへアクセス・操作できるようにすることです。Cの構造体にはアクセス制御がなく、フィールドを自由に変更できるので、その意味でのカプセル化には実質的な意味がありません。JavaやC++とCの大きな構文上の違いに、ドット演算子でメンバーメソッドを呼べることがありますが、実装は特別ではありません。メソッドは第1引数がオブジェクトへのポインターである関数で、コンパイラが自動で追加してthisと呼ぶだけです。ほかは普通の関数と同じです。
Java、C++、Goは、直接または間接的に合成で継承を実装する点が似ています。合成を使うので、独自コンストラクターでは先に親のコンストラクターを呼びます。Goはとくに明確で、構造体の中へ名前のない親構造体を埋め込みます。親フィールドへのアクセスは、実際にはその親オブジェクトのフィールドへの直接アクセスで、構文糖衣に見えます。
type Parent struct {
a int64
}
type Child struct {
Parent
b int64
}
c := &Child{Parent{}, 0}
a := c.a
//或者
a := c.Parent.a;
JavaやC++に比べると、Goの継承はおままごとのようです。子から親の変数へのアクセスを特別に制御せず、先頭文字の大文字・小文字によるパッケージ公開規則をそのまま使います。Javaのprotectedのような、親子関係専用の制御がありません。
多態性の典型例は、親ポインターが異なる子オブジェクトを指し、共通メソッドを呼ぶと子ごとに違う動作をすることです。JavaとC++は2つの代表的な実装です。JavaにはJVMがあるので簡単です。親型のハンドル経由でも、そこから実オブジェクトの型やメソッド情報を調べられ、具体的な実装を呼べます。つまり実行時多態性です。一方C++はコンパイル時多態性です。各クラスに仮想関数テーブルがあり、インスタンスにはそのテーブルへのポインターが入ります。テーブルは継承で上書きされ得るメソッドの一覧です。コンパイラは親子が同じメソッドを実装した場合、両テーブルの同じ位置に置きます。その呼び出しは「仮想関数テーブルのN番目を呼ぶ」という命令になります。親ポインターから呼んでも、実際には子のテーブルから関数ポインターを得るため、子の実装が動き、多態性が実現します。
ジェネリクスの実装
IDEAの補完のおかげで、ジェネリクスは最もよく使われる高度な言語機能の一つでしょう。しかしJavaに入ったのはJDK 5、C++でその実装方法の一つであるテンプレートが導入されたのはC++11で、比較的新しい機能です。面白いことに、この2つはまったく異なる実装方式を代表します。
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より、明らかに安全です。
C++の欠点は、生成されるクラスが完全でなければならず、型引数を使わない関数も、同じコードなのに繰り返し生成されることです。大きな容量の無駄になり得ます。C#はここを最適化しています。コンパイル時に最終コードを直接生成せず、その型の異なる実装が何種類必要かを集計します。.NETランタイムのCLR、JVMのようなものですが、そのJITが共有機械語を作り、型ごとの特化情報を別テーブルとして各ジェネリック型が持ちます。これで大半のコードを共有できます。詳細は論文を参照してください。ただ、C++を責めても仕方ありません。C#にはランタイムがあります。C++も専用ランタイムを持つか、出力へ組み込まなければ、この動的な共有は難しいでしょう。
おわりに
ここまで決めれば、構文さえまだ決めていなくても、言語の大まかな姿は決まっています。残るのはかなり共通した、順を追った作業です。実装しましょう!
もちろんGCや特殊な寿命管理など、高度な機能はいくらでも加えられます。VMがあると実装は楽ですが、必須ではありません。GoはGCのコードを最終成果物に直接コンパイルし、実行ファイルごとに小さなVMを付けるような形を取ります。
大半の人は自分で言語を実装する必要はありませんが、共通点や特徴を知ることは大切です。世界最高の言語はなく、ある状況に最も適した言語があるだけです。だから「最強の言語」論争は滑稽です。どの言語も何かの問題を解決するために生まれます。既存言語と構文以外の特徴が同じ新言語は、長続きしにくいでしょう。新しい問題を解決しないなら、わざわざ学ぶ理由がありません。
こうした特徴を知ると、思わぬ利点もあります。プログラマー同士の飲み会で大きなことを言いたいときや、技術グループでまた根拠を交えた最強言語論争が始まったとき、思う存分語れます。
