Chienomi

コンパイラとインタープリターの話

技術::programming

私のTwitter TL上でコンパイラとインタープリターに関するいい加減な説明についてちょっと話題だったので、そういえばこの知識はあまり広く浸透していないのかもしれないと思い、ここに記すことにした。

重要な前提として、「コンパイラ」と「インタープリター」という両者は概念の層が違うため、比較して語れるようなものではない。

にも関わらずコンパイラとインタープリターをプログラミング言語の種別として並べて語られることが多い理由は、大昔に遡る。 それはBASICが流行したとき、従来のプログラミング言語(FORTRANやPascal)の処理系がコンパイラであったことから、それと比較してインタープリターであるBASICの違いを表現するために、FORTRANやPascalを「コンパイラ型」、BASICを「インタープリター型」と表現して対比したのである。

そして、その意味について特に考えることなく、その言い方だけが継承されてきてしまったわけだ。

コンパイル

コンパイルというのは変換作業である。

コンパイルはどちらかというとライブラリのリンクなどに主眼を置いた言葉だ。 C言語の場合、コンパイルは「リンク」と「ビルド」という2つの作業に分けられるのだが、「リンクしてビルドする」ということをコンパイルと呼んでいるわけだ。

ただし、「コンパイル」という用語が必ずしもその工程を指すわけではなく、多くの場合何らかの結果に変換することを指す。

CやC++はバイナリ(CPUコード)に変換する。 JavaやC#の場合、バイトコードに変換する。

バイトコードは結局のところ、それが意味するものとしては「バイナリ」と変わらない言葉なのだが、言葉にすると同じものだが別の概念なので「バイトコード」という言葉を使って呼び分けを行っているわけだ。

バイトコードは基本的にVMと呼ばれるプログラムによって解釈される。 VMは”Virtual Machine”で日本語にすると「仮想マシン」である。 これは、そのバイトコードを実行するための専用の設計を持つコンピュータに見えるものである。

後述するが、CPUコードというのはCPUで実行されるための専用形式である。 一方、バイトコードはCPUコードとは異なるものだ。もしも「バイトコード実行マシン」があればバイトコードはそれ以上の加工は必要なく、そのまま実行することができる。

実際はそのようなことはなく、何らかの方法でCPUで動作するようにしなければならないのだが、VMはあたかも「バイトコード実行マシン」であるかのように振る舞う。そこからCPUを動作させる段階はさらにVMがなんとかすることになる。

わざわざバイトコードに変換して、そこからCPU実行するくらいであれば最初からCPUコードに変換すれば良いのではないかと思うかもしれない。 実際そういう面もあるのだが、一方でバイトコードにするメリットもある。一長一短なのだ。 最近はCPUコードに変換してしまったほうがメリットが大きい状況では部分的にCPUコードに変換してしまうJIT(Just In Time)コンパイルが行われることもある。

Ruby(1.9以降)やPerlの公式処理系も(最終的には)バイトコードへとコンパイルする。 これはJavaやC#と全く同じ動作である。

ちなみに、Perlは「バイトコードとVMの両方を含むCソースコードを出力する」ということもできる。 これをやるとC言語としてCPUコードにコンパイルできる。

Ruby 1.8は構文木にコンパイルする。

CPUとCPUコード(バイナリ)

一応言っておくず、「バイナリ」という用語は必ずしもCPUコード(機械語実行形式)を指すわけではなく、もっと幅広く使われる用語だ。 一方、ELFバイナリみたいに言ってしまうと逆にCPUコードよりも狭い用語になる。

機械語というのはデータ形式ではなくCPUネイティブの命令表現そのものを指す可能性があるため、それを区別しここではCPUネイティブな実行形式のデータを指してCPUコードと表現する。

CPUはどのような言語を理解するのか、というのは当然ながら厳密に決まっており、その表現方法も決まっている。 そして、CPUは命令を(基本的には)来た順に処理するし、CPUに与えられるコードはCPUがどのように動作すべきかを規定したものである必要がある。

アセンブリはおおよそこのCPU命令に対して1:1で書かれたものである。これをCPUコードにコンパイルするのがアセンブラだ。 アセンブリはCPUコードそのものをテキスト表現したものだとも言える。 だが、多くのプログラミング言語でアセンブリを書くことができるので、そのあたりの話はややこしくなる。

インタープリター

インタープリターは基本的に「解釈器」だ。

プラグラミング言語処理系の場合、1ステートずつコンパイルするもののことを指す。 「1行ずつ」と表現されることが多いが、これは間違っている。1ステートずつだ。

次のBashスクリプトに注目しよう。

declare v=j
for i in {a..d}
do
  echo "Hey $v"
  v=${v}j${i}
done

この動作は

  1. 変数vjを代入することをCPUコードにし、代入する
  2. for行を解釈する (ブレースの展開、complex commandであることの認識)
  3. complex commandのためその構文の終端であるdoneまで読み込む
  4. list (ループの中身)を抽出する
  5. イテレータによって変数iaを代入する
  6. echoの行を解釈してCPUコードにし、実行する
  7. 変数vに代入することをCPUコードにし、代入する

あまり綺麗な説明になっていないが、要は「1ステートずつコンパイルしている」のだ。 これが

echo "Hey $v"; v=${v}j${i}

のようになっていてもコンパイルされる回数は変わらない。

そしてステートごとにコンパイルされるので、ループが増えればそれだけコンパイルする回数も増える。例えば

for i in {1..10}
do
  echo "Hey"
done

というのは、変数iの値は変わっていくが、実行されるlistは変わらない。だが、この場合、listは10回コンパイルされる。

この違いはsyntax errorで明らかになる。例えば、Bashの変数代入はvar=valであり、スペースを入れることはできない。スペースを入れるとsyntax errorになるのだが、

echo Start

v = 100

echo I got $v dollers

みたいなコードではechoが実行され、syntax errorになり、echoが実行される。

$ bash 1.bash 
Start
1.bash: 行 3: v: コマンドが見つかりません
I got dollers

では、Rubyでsyntax errorにしてみよう。演算子を間違って変なものにしてしまった

puts "start"

v ]= 100

puts "I got #{v} dollers"

結果は次のとおりだ。

$ ruby 1.rb 
1.rb:3: syntax error, unexpected ']', expecting end-of-input
v ]= 100

コンパイルするということは、何らかの解釈をした上で変換を行わなくてはならない。 syntax errorはどのように解釈すべきかわからないものであるから、変換することができない。 Rubyはバイトコードに変換した上でそれをVMで実行するのだが、実行まで至ることができず、何も実行されないままエラーになる。

競技プログラミングなどではこれを「ランタイムエラー」と呼ぶのでややこしいが、syntax errorは「コンパイルエラー」である。これはBashでも同じだ。

違いとしては、Rubyの場合はバイトコードへのコンパイル時が1度だけあり、その後はVMによるラン時になる。 一方、インタープリター型言語処理系であるBashの場合はコンパイル時とラン時が交互にくるわけだ。

一方、インタープリターは解釈器であるから、このような言語処理系に限らない。 というのも、構文木を処理するものは「構文木インタープリター」と呼ばれている。 構文木インタープリターは構文木を解析し、解析して得られた結果を都度CPUコードにして実行する。

一方、「構文木コンパイラ」というのもある。こっちは構文木を解析した結果を他の形式(CPUコードやバイトコード)に変換するのだ。 言語処理系は基本的には構文木を経てコンパイルされるため、コンパイラは構文木へのコンパイラと構文木のパーサー&コンパイラを持っているという話になる(まぁ、これを普通は構文木コンパイラなどと言ったりはせずに、コンパイラと言うのだが)。

Ruby 1.8は構文木インタープリターによって構文木を解釈して実行していた。Ruby 1.9になって構文木からバイトコードを生成するようになった。

また、CPUもCPU命令をひとつずつ解釈して実行するわけだから、CPU自体がインタープリターであると見ることができる。

ただし、実際のCPUはスーパースカラーといって、複数の命令を読み込んだりするし、さらにアウトオブオーダーによって読み込んだ複数の命令を並び替えたりもする。まぁ、言語処理系のインタープリターにしても制御構文は持っていたりするから、それもインタープリターだよ、と言えなくもないが。

つまり

コンパイラは、変換処理をするもの。

インタープリターは解釈・実行をするもの。

だから全くの別物。

ソースコードを起点としてプログラムの実行にあたっては必ず両方が必要になる。 (CPUをインタープリターであるとみなすのであれば)

これは「言語」じゃなく「言語処理系」の話

そもそも、プログラミング言語そのものに「コンパイラ型」だの「インタープリター型」だのの特性があるわけではなく、そのプログラミング言語を動作させるための言語処理系の設計がどうなっているかの話だ。

例えば、昔のブラウザに搭載されるJavaScript処理系はインタープリターであった。 だが、Google Chromeに搭載されているv8は、常にネイティブコードに変換する。 一方、Firefoxに搭載されているSpiderMonkeyはまずバイトコードに変換し、その上でそれが望ましいのならば部分的にネイティブコードにコンパイルする。

Common Lispの処理系は、人気のSBCLは全体をネイティブコードにコンパイルして実行する。 一方、ポピュラーなClispはバイトコードにコンパイルして実行する。

Ruby処理系のJRubyはRubyで書かれたソースコードをJavaクラスファイルに変換して出力することができる。

だから〇〇系(型)言語、という言い方自体がおかしい。 〇〇系言語処理系、ならまだわかる。

現在、人気のある言語で、インタープリターによって実行される言語処理系というのは相当に少ない。 パフォーマンス的に絶望的に遅いからだ。せいぜいシェル系の言語くらいだろうか。

つまり、大抵の言語処理系はコンパイラを持っている。

その上でプログラミング言語を「インタープリター型」「コンパイル型」などと言いたがるのは、要はユーザー視点で実行がどのようになされるかという点を分けて語りたいからだ。

「コンパイル時」「ラン時」というのはフェーズの呼び別けだが、事前コンパイラ(AOTコンパイラ)によってコンパイル結果を出力するものと、実行時コンパイラ(JITコンパイラ)によってコンパイルした結果をそのまま実行するものを呼び分けたいわけだ。 なぜならば、ユーザーから見たときに実行する対象(デプロイする対象)がソースコードなのか出力されたコードなのかが変わるからだ。

しかしこれはよく考えれば割とどうでもいいことだというのがわかる。 だって単純に「その状態で保存するかどうか」の話なのだから。

実際、Ruby(CRuby。公式処理系の話)もRubyVM::InstructionSequence#to_binaryというメソッドでコードをコンパイルしたバイトコードをバイナリ文字列として得ることができ、これをファイルに保存すればAOTコンパイルしたバイトコードを保存することができる。 ちなみに、これをロードするときはRubyVM::InstructionSequence.load_from_binaryを使用し、これによってRubyVM::InstructionSequenceオブジェクトが得られるのでRubyVM::InstructionSequence#evalによって実行することができる。

そんなどうでもいい話だし、概念が曖昧なので、特にそれを呼び分けることができない。 のだが、それをどうしても呼び分けたい人がいる。そこで、そのプログラムの実体がインタープリターであるかどうかに関わらず、JITコンパイルされるものを「インタープリター型」、AOTコンパイルされるものを「コンパイル型」などと呼んでいるわけだ。

もっとも、インタープリターというのは動作であり機能であるから、さすがにおかしいと感じる人が少なくない。 そこで、JITコンパイルされる(インタープリターによって動作するわけではない)ものを「スクリプト言語」と呼ぶようになった。

だが、前述の通りこれも微妙である。例えばPerlはスクリプト言語なのだろうか? Perlの公式処理系で

print "Hello, world!\n";

というhello.plを実行するとする。 JITコンパイラを使うのであれば

perl hello.pl

になるだろう。だが、B::Bytecodeモジュールを利用して

perl -MO=Bytecode,-H,-ohello hello.pl

とかやるとPerlバイトコードが出力できる。そしてJavaみたいに

perl hello

とバイトコードを実行できるのだ。

そして先程も述べた通り、それは言語に備わっている特性ではなく言語処理系に備わっている特性である。 ここではRubyもPerlも、処理系としてはコンパイラであり、バイトコードにコンパイルすることを述べ、またどちらもバイトコードを出力して保存する手段が存在するためにソースコードを実行することも(Javaのように)バイトコードを実行することもできることを明らかにした。 であれば「Perl(Ruby)はスクリプト言語である」というのは何を根拠にしているのだろうか?

処理系が本当にインタープリタであるならばコンパイルした結果が存在するタイミングがないため、コンパイルした結果を出力することはできないが、JITコンパイラなのであればコンパイルした結果は出力できる。

JITバイトコードコンパイラから得られるのはバイトコードであって結局のところ機械語にするのは実行時ではないか(バイトコードへのコンパイル時点では直接実行するのに必要な情報が欠落しているため、バイトコードコンパイラから機械語を得ることはできない)という主張を元にスクリプト言語であるとするのであれば、バイトコードを出力し、バイトコードの実行がランタイムになるJavaやC#もスクリプト言語であるということになる。

もちろん気持ちはわかる。 PerlやRubyでバイトコードを保存することなんて、現実的にはないし、メリットもない。速くなるわけでもない。 長いRubyコードをしょっちゅう起動しているのであればバイトコードにしておけばいくらかメリットはあるかもしれないが、それは明らかにサーバーとしてプロセスを永続化したほうが絶大なメリットがある。 だから、PerlやRubyの処理系に与えるのは普通はソースコードであり、バイトコードを与えることはまずない。

一方、Javaは普通は実行のために処理系に与えるのはバイトコードであり、AOTコンパイルするのが普通だ。 Java 11からは条件付きでJITコンパイル実行できるようになったが、常用するようなものではない(そもそも、Javaのコンパイルタイムは長いので実用的でない)。

「普通の使い方でコンパイルフェーズをユーザーが意識するかどうか」において「スクリプト言語」と呼び別けたい気持ちはわかる。 が、それは合理的ではない。定義が曖昧で、「普通」だの「意識する」だのという単語が入ってしまっているし、本質的にはどうとでもなるからだ。 便利な概念を示す言葉としては、「スクリプト言語」などというのも、まぁ許容すべきものではあろう。 しかし、厳密な技術用語としては受け入れがたい。

繰り返すが、言語処理系の話である。 言語はこれを決めることはできない。