何でもかんでも自分で一から作っていると人生が何回あっても足りない。なので、ありものをいかに活用していくかが重要となる。
この記事では、プログラミング言語を作る上で既存の資産を活用することについて考えてみたい。
言語を作る目的
すでに世の中にはたくさんのプログラミング言語がある。自分で新言語を作るのは手間がかかる上に、普及に成功する確率は低い。
成功のためには、
- 言語を作る手間を削減しつつ
- 新規性を持たせ、それをアピールする
べきだろう。このうち、「手間の削減」がこの記事の主題となる。
ライブラリー・埋め込みDSLで済ませられないか考える
まず、そもそも新言語を作る必要があるのか、という点だ。場合によっては、既存の言語のライブラリーとして新言語っぽいものを作れたりする。埋め込みDSLとかだ。
例としては
- Arduino言語 (C++)
- Halide (C++)
- 配列プログラミング言語
などがある。
埋め込み先の言語としては
- C++
- Python
- Ruby
- Lua
- Haskell
- F#
- Scala
- Rust (macro)
- Lisp (macro)
などが考えられる。埋め込み先の言語にモナドとか(限定)継続とかコルーチンがあるとマクロを使わずに制御構造をいじることができて便利そうだ。
既存の言語のlinter, 型チェッカーとして実装する
既存の言語に型がない、あるいは型が貧弱なことが不満であれば、型チェッカーだけを実装するという手がある。型がなかったところに型をつければもはや別言語と言って差し支えないだろう(?)。
例としては
- Flow (JavaScript)
- mypy (Python)
- Sorbet (Ruby)
- Liquid Haskell (Haskell)
などがある。
なお、元々型がない言語に対して作られたライブラリーは型がないことを前提にしがちなので、型を後付けしようとすると型システムが複雑になりがちである。
既存の言語を拡張する:フロントエンドの流用
既存の言語とある程度互換性のある派生言語を作る、という方式。
メリットとしては
- ユーザーが馴染みやすい
- 既存の言語に対して書かれたライブラリーを流用できる(互換性が十分高ければ)
- シンタックスハイライト等を流用できる
- コンパイラーのコードを流用できる
などがある。
例としては
- C言語→
- C++
- Objective-C
- GLSL等、GPUで動く連中
- C++→
- C++/CLI
- CUDA C++等、GPUで動く連中
- Scheme→Racket
- OCaml→F#
- Standard ML→Alice, SML#
- PHP→Hack
がある。
HaskellはGHC自身がドシドシ言語拡張を取り込んでいくスタイルだからちょっと特殊かもしれない。Haskellを拡張した言語はHaskellというか。でもLiquid HaskellやClashは派生と言えるか?
既存の基盤を利用する:バックエンドの流用
既存の基盤としては
- LLVM
- JVM
- CLI (.NET)
などがある。これらをターゲットとすることでネイティブコードの生成を省けたり、GCの実装を省けたりする。JVM, CLIならある程度のライブラリーもついてくるだろう。
既存の言語も基盤と思える。いわゆるトランスパイルだ。例:
- C
- ←GHC (C backend), MLton (C backend), Vala
- C++
- ←PureScript (purescript-native)
- JavaScript
- ←TypeScript, CoffeeScript, PureScriptなど多数
- Go
- ←PureScript (purescript-native)
- Scheme
- ←Idris2
- Lua
- ←MoonScript, LunarML
さまざまな言語にトランスパイルできることが売りのHaxeみたいなやつもある。
さて、既存の基盤を利用するということは、既存の基盤の制約を受け入れなければならないことでもある。
例えば、ネイティブコードを直接生成する場合は容易だったことがLLVMを経由することでやりづらくなる場合がある。独自の呼び出し規約を使うとか、浮動小数点数のsignaling NaNをきちんと取り扱いたいとか。
別の例として、JVMやJavaScriptには末尾呼び出し最適化がないので関数型言語を実装するときに困る。一応トランポリンという解決策があるが、あまりそういうことをしていると手間が増えて何のために既存の基盤を利用したのかわからなくなる。
あと、JVMやCLIは中途半端に型システムがあるので、高度な型システムを持った言語をコンパイルするのは面倒だったりする(らしい?)。
特定の基盤を前提にした新言語を作る場合はこういう制約をはみ出さないように設計するのが普通だろう。末尾呼び出し最適化を保証しないとか、型システムの表現力を調整するとか。
その先は地獄だぞ
「手間の削減」のための方法をいろいろ考えてみたけど、こだわり出すと無限に労力がかかる羽目になる。
LLVMで独自の呼出規約を実現したいからLLVMを改造する、JVM/JavaScriptでTCOがしたいからトランポリンをする、JavaScript等で限定継続を実装したいからCPS変換をする……。
あるいは、JVM向けに言語を作ったけどやっぱりネイティブやJavaScriptでも動かしたいから新たなバックエンドを用意する…….。
結局のところ、実用性を志向するなら「ありもの」を使ったところで膨大な労力がかかることに変わりはないのだろう。既存の資産の活用は、「とりあえず動かす」ための段差を小さくするための手段と思った方がよいのかもしれない。最終的に登る標高は変わらないけど、崖を登るのと階段を登るのとどっちがいいですか、という話だ。
ちなみに、私が作っているLunarMLは
- 既存の言語の再実装である
- 既存の言語(Lua / JavaScript)にコンパイルする
ので、ここに書いた「手間の削減」という意味では割と優等生なのではないかと思う。……と言いたいところだが、
- 既存の言語の再実装である→ただし既存のコンパイラーの流用はしていない
- 既存の言語(Lua / JavaScript)にコンパイルする→ただしLuaやJavaScriptの制限を掻い潜るためにいろいろやっている。また、これから独自のVMを実装しようとしている
なのであまりトータルの手間は削減できていない気もする。「とりあえず動かす」までにかかった時間が結構長かったし。