れやかボックス

拡張をいれまくって自作言語を作った話

何をしたの?

『Go言語で作るインタプリタ』という本を実践してみたログです。 この本では Monkey という言語を作るのが目標となっているのですが、それに飽き足らず拡張をたくさん加えてみました。

https://github.com/reyalka/revop

この本の概要

この『Go言語で作るインタプリタ』を1行でまとめると

Go言語で書かれたインタプリタを写経して手元で自作言語を作ろう!

です。

写経本なので誰でも言語を作れる上、実際に動くものが手に入るので取り掛かりやすい本でした。 特に本文の解説が非常に分かりやすく、胸を張って『名著』といえる本でした。

私も一通り実装してみました。(最後のマクロの章はスキップしましたが)

flowchart LR
    l["Lexer(文字列解析)"] --> p["Parser(構文解析)"] --> e["Evaluator(評価)"]

しかし、それだとあっさり終わってしまって満足できなかったので、

  • 自分が刺さる文法
  • 他の言語にあるのに不足している文法
  • 少し気に入らなかったものを直した文法

を詰め込みました。 最終的に追加したのは、

自分が刺さる文法

  • Block式
  • |> (パイプ演算子)
  • add(3, _) (プレースホルダー)

他の言語にあるのに不足している文法

  • &&と||
  • <=と>=
  • // (コメント)
  • ^ (累乗演算子)
  • mutキーワード
  • 変数への再代入
  • map, reduce, filter関数
  • for, while 関数 ← 文ではなく

少し気に入らなかったものを直した文法

  • 辞書のキーの指定方法
  • ifのtruthy

並べてみると、色々追加しましたね。

最終的にこのような文法になりました。

https://github.com/reyalka/revop/blob/main/examples/main.rv

実装していく中で思ったこと・気づいたことなどをまとめていきます。

この記事の対象

  • 自分でプログラミング言語を作ってみたい方
  • 既存の言語の構文に少しでも不満がある方
  • 言語処理系に興味がある方

この記事を読んで「自作言語って意外と簡単」と思っていただければ幸いです。


その1. 自分が刺さる機能を実装

Major Versionを1つ上げて、盛大にリリースパーティーを開いてもいい機能

パイプ演算子 |> と プレースホルダー _

let double = fn(x) { x * 2 }

1 + 2 
    |> double
    |> push([1, 2, 3, 4, 5], _)
    |> ec؜ho
    // -> [1, 2, 3, 4, 5, 6]

完全なシンタックスシュガー(糖衣構文)として|>と_を実装しました。

これらは糖衣構文なので、LexerとParserを変更するだけでEvaluatorは一切変更していません。

行っていることは

  • x |> f という構文をみつけたら f(x) に変更
    • ただし、x |> f(y)という構文をみつけたら、x を先頭に挿入して f(x, y) に変更
  • f(x, _) という構文をみつけたら fn($arg0) { f(x, $arg0) } に変更
    • ただし、f(x, _, _)と2つ以上の _ があると、 fn($arg0, $arg1) { f(x, $arg0, $arg1) } に変更

です。

パイプ演算子 |> は先頭に挿入してしまうので、2番目以降に挿入したいときはプレースホルダー _ を使う、という設計です。

ちなみに、パイプ演算子 |> の構文は Elixir を、プレースホルダー _ の文法は Gleam を、パクり参考にしました。

1つ悩んだ点がパイプ演算子 |> の優先順位の問題です。

様々なケースを考慮してたくさんテストを追加していますが、「この解釈はおかしいだろ」と言われそうな所もたくさんあります。 実装上仕方がなかった…

https://github.com/reyalka/revop/blob/3db66f6cb89a7f65f28850cc69d07d848d7e82a5/parser/parser_test.go#L1081-L1137

:::details 補足 例えば、

  • a + b |> c は c(a + b) となり、
  • a |> b + c は b(a) + c となってほしい

ところですが、

右結合の強さと左結合の強さを区別することができないので下のような解釈がされてしまいます。

  • a + b |> c は c(a + b) となり、
  • a |> b + c は (b + c)(a) となります。(関数として(b + c)を扱っている) :::

ブロック式

let computed = {
    let mut temp0 = 10;
    let mut temp1 = 20;
    // ... 複雑な計算 ...
    return temp5;
}

ブロックを式として扱うことができます。これは好きという人も多いのでしょうか(私感)

ちなみにこの構文を追加したせいで、辞書リテラルの構文を{ ...}から#{ ... }に変更せざるをえなくなってしまいました。(後述)

その2. 少し気に入らなかったものを直した文法を変更

辞書のキーについて

書籍で実装する言語では、

{ 
    "key": "value1",
    1 + 2 + 3: "value2"
}

というようにキーの部分にどんな式でも置くことができます。

ただ、この構文は個人的にあまり気に入らなかったです。 そこでJavaScriptの構文を参考にして、以下のような構文に変更しようとしました。

{
    key: "value1",
    [1 + 2 + 3]: "value2" // 式を置くときは[ ]で囲む
}

しかし、ここで問題が発生します。このままだと構文が曖昧になって実装することができません。

なぜならブロック文を追加してしまったせいで、

{ [1, 2, 3]; }       // ブロック式
{ [1 + 2]: "value" } // 辞書リテラル

の2つをすぐに区別することができません。 {を読んで、次のトークンが[でも、それがブロックがなのか辞書なのかをすぐに判別することができないのです。

対処するには、

{ [1, 2, 3]; }       // ブロック式
//        ^
{ [1 + 2]: "value" } // 辞書リテラル
//      ^

上のように一旦[の終わりである]まで先読みしてから、その次が:かそうでないかを見る、という方法で2つを判断することができます。

しかし、配列の入れ子も考慮する必要があるので、

{ [1, 2, [3, 4]]; }

[が出現した回数を数えながら、一番外側の]を探すという面倒な処理が生じます。

一方、今回作成したParserは

  • 左から右へ順番に読み込んで行きながら
  • 決して一度読んだトークンをもう一度読まずに
  • 次の1トークンだけを先読みしながら進んでいく

という LL(1) と呼ばれる種類のパーサーです。

そんなわけで、この方針で実装するのは諦めて、

{ [1, 2, 3]; }       // ブロック式
#{ [1 + 2]: "value" } // 辞書リテラル

と先頭に#をつけるようにしました。 あまり美しくはない文法ですが、仕方なく妥協です。1

ifの条件について

その3. 絶対にあったほうがいい機能を実装

論理演算子 &&と||

if ((cond1 && cond2) || cond3) {
    echo("passed!")
}

あわせて短絡評価も実装しました。

https://github.com/reyalka/revop/blob/2f229ab7de0e4949ea71fbc1e76f27b231eb38e6/evaluator/evaluator.go#L211-L220

単純な関数1つで事足りてしまいますね。

コメント

// comment

むしろ書籍で実装した言語になかったことが驚き

実装はLexerだけを変更するだけで終わりました。

累乗演算子 ^

echo(3 ^ 3) // -> 27

一見+や*といった演算子と同じように見えて、実は右結合です

3 + 3 + 3 == ((3 + 3) + 3)
3 ^ 3 ^ 3 == (3 ^ (3 ^ 3))

可変変数

let mut n = 5;
n = 3;

変数に可変性を持たせられるようにmut キーワードを追加しました。

可変ではない変数に再代入しようとしたり、型が違う値を再代入しようとすると、エラーが起きます。

let n = 5;
n = 3; // cannot assign to immutable variable: n

let mut s = "hello";
s = 10; // type mismatch: cannot assign INTEGER to STRING

この実装はスコープ自体を操作するので、結構実装が難しかったです。

Assignするだけでもこれだけのコードになります↓

map, reduce, filter 関数

map([1, 2, 3], fn(x) { x * 2 }); // -> [2, 4, 6]

書籍中では実装している言語でmapを定義していましたが、標準組み込み関数の中に入れました。

先ほど述べたパイプ演算子 |>と組み合わせると、スタイリッシュに書くことができます。

let double = fn(x) { x * 2 }

[1, 2, 3, 4, 5]
    |> map(double)             // [2, 4, 6, 8, 10]
    |> filter(fn(x) { x > 5 }) // [6, 8, 10]
    |> len                     // 3
    |> ec؜ho

for, while 関数

for(["fizz", "buzz", "fizzbuzz"], fn(e) { echo(e); })
// fizz
// buzz
// fizzbuzz
let mut i = 0;
while(
    fn() { i < 5 },
    fn() { echo(i); i = i + 1; }
)
// 0
// 1
// 2
// 3
// 4

あえてfor文やwhile文にせず関数にしたのはパイプ演算子 |>と組み合わせて面白いことができないかな、と思ったからです。

決して新しい構文を実装するのが面倒だったからではありません。

まとめ

おおよそ2, 3週間を費やして自作言語をここまで実装してみましたが、とても学びは多かったです。

  • 言語処理系の仕組みが大雑把に語れるようになった
  • 文法を機械で処理しやすいかという視点で見られるようになった
  • ASTと仲良くなれた

総じてやってよかったと強く思います。 ぜひ皆さんもやろう、自作言語。

Footnotes

  1. 他の解決策としては、ブロック式の前にdoを強制するというのがあります。(そっちの方が良かったかも…) ↩