設定ファイル言語 cumin を自作している

公開:2020/12/07
更新:2020/12/22
11 min読了の目安(約10200字IDEAアイデア記事

これは 言語実装 Advent Calendar 2020 の 8 日目です.
昨日は iigura さんによる 並列処理対応 Forth 系言語 Paraphrase の 2020 年の開発状況 でした.

これは ドワンゴ Advent Calendar 2020 の 8 日目です.
昨日は yoshikyoto さんによる 10年戦士のレガシーPHPを改善するためにやってきたこと でした.

はじめに

先月くらいに思い立って衝動的に言語を自作し始めました.
自作言語といえば当然プログラミング言語である中で恐縮なのですが, 設定ファイル言語です.
JSONとかYAMLとかのあれです.
名前を cumin としました.

いきなり反省点なのですが, あまりにも衝動的に作りすぎて, 作れば作るほど細部の甘さに気付かされて何も考えてないことが露呈しながら実装を薄めています. およそこれが自分の欲しい言語の方向性だとは思っているんですが, 五行コード書くたびに一回, いや本当にこれが欲しかった言語なのか? と自問して手が止まる, そんな日々を過ごしています.

さて, およその文法もそれに乗せてる意味論も素朴で自分としては至極普通だと思っているので, 自作言語勢の方たちには物足りない内容かもしれません. ご了承ください. 技術的な話よりは設計思想の話をメインに書くつもりです.

見た目

いきなりですが cumin は次のような見た目をしています.

struct Host {
    host: String,
    port: Nat = 3306,
}

let main = Host("1.2.3.4", 9033);

let replica = [
    Host { host = "1.2.3.5" },
    Host { host = "1.2.3.6" },
];

{{
    main = main,
    replica = replica,
}}

見て分かるようにほとんどRust風の文法を持たせています.

そしてこれは次のJSONに対応しています.

{
  "main": {
    "host": "1.2.3.4",
    "port": 9033
  },
  "replica": [
    {
      "host": "1.2.3.5",
      "port": 3306
    },
    {
      "host": "1.2.3.6",
      "port": 3306
    }
  ]
}

設定ファイル言語とはなにか

もちろんそれは, 静的なデータを表現する言語です. 書かれたテキストはデータそのものです. 純粋なJSONは完全に静的なデータそのものを全て展開した状態で記述したに過ぎません.
YAMLもほぼそうですが, アンカーという機能があるために状況は少しだけ複雑です.

prod: &prod
  host: 1.2.3.4
  port: 3306

stg: *prod

これは {"host": "1.2.3.4", "port": 3306} というデータに prod という名前を与えてそれを再利用する機能です. 簡易的なマクロ機能だと思うことも出来ます(或いは変数代入?). この機能が可読性を上げるとか下げるとか, そういう議論をするつもりはありませんが, ともかく人はこういうものを欲しがるものです. 私もたまに使います.

素朴な JSON はコメントを書けないだとか trailing-comma を許さないだとか, そういった細かな不便さもありますが(それらを解決した JSON5 という言語もありますね), もっと機能を乗せた Jsonnet なんてものがいつの間にかあって, 徐々に使われ始めているようです.
私から見ると Jsonnet は設定ファイル言語というには強力過ぎるくらい強力な言語で 「最後に JSON データを1つ吐きだす JavaScript プログラム」と言ってしまってもはや良いと思います. そもそも jsonnet.org は "data templating language" だと言っています. つまり純粋に設定ファイル言語だとは誰も言ってないので, ここで挙げるのに適切でなかったかもしれません.
さて, なんと言っても, 関数が書けます. 再帰も出来るのでループが表現できます. もちろん条件分岐も用意されています.

local host(hostname, port = 3306) = {
  host: hostname,
  port: port,
};
{
  main: host("1.2.3.4"),
}

私は仕事で ansible をよく書くのですが, この設定ファイルは YAML で, しかもそこに Jinja2 というテンプレート言語が乗っています. 書きやすさ読みやすさの違いはあれど, 機能としてはほぼ Jsonnet とほぼ同じくらいだと思っています. 例えば関数の代わりに macro 機能があったりします. しかし圧倒的に使いづらいとは思っていますけど.

{% macro host(hostname, port = 3306) %}
host: 1.2.3.4
port: 3306
{% endmacro %}

main:
  {{ host("1.2.3.4") | indent(2) }}

YAML に載せるために indent() で辻褄を合わせないといけないのとかがダルいですね. あと {%{%- の違いとか :anger:. 結局 YAML のためだけではないテンプレート言語を載せているのが根源だと思います.

現状への不満点

主観的な感想ですが, JSON, YAML は弱すぎるし, Jsonnet は強すぎます. また既存のものにテンプレート言語を載せるのも, 読みづらいし書きづらいだけです. Jsonnet は単に設定ファイル言語としては強力すぎて, また強力過ぎる道具を使いこなせるほど人間は賢くないとも思っています. もちろん単純に良い悪いを語るつもりはなくて, 使い所次第だとも思います. 十分に小さくて手でYAMLを書いて10行程度なら書けば書けばいいし, Jsonnet でようやくキレイに片付けられる仕事ならそれを選べばいいです.

cumin が目指しているもの

Rust 風の文法

これは単に好みです.
サブセットそのものにはしてありませんが, Rust を書いてる人間が自然に書きやすいようにしたいです. また Rust を知らない人でも雰囲気で読めると嬉しいですね.

構造的であること

例えばYAMLで

servers:
  - host: 1.2.3.4
    port: 3306
  - host: 1.2.3.5
    port: 3306
  - host: 1.2.3.6
  - host: 1.2.3.7
    port: 3306

とあるときに, 一個 port が抜けています. YAML としてはこれは間違いなく valid です. ですが, 恐らくこれは, この YAML を書いた人間のミスでしょう. その場合, これを読んで使うプログラム側で実行時エラーになります. こういうのは hostport という2つのデータを持つと宣言した構造体を使えば解決します. 当たり前ですがタイポしても同様です. 読み込んで使おうとして初めてエラーになります. フィールドに名前がついた構造体を使うことで名前のチェックも行われます.

構造体の他に enum による列挙体も用意してあります.
ここに Rust 同様に代数的データ構造を載せるかどうかは迷っていて, 今はまだ載せていません.

型付けされていること

現状は漸進的な型付けをしています(というか設定ファイルにおける静的とか動的とは?). 配列を含む素朴な原始型が用意されています. 構造体や列挙体を宣言することでユーザーは自由に型を拡張できます. 構造体は内部のデータに型の制約を持ち, 誤ったデータを与えると型エラーになります.

JSON に変換可能であること

設定ファイルの言語をただ作っただけでは使えなくて, 普段使っているプログラミング言語のためのローダーが必要です. とりあえずの対応として JSON に変換させるコマンドを用意しています. JSON を読めない言語は無いと思うので.

今, 仮に cumin 言語で書かれたデータ表現(単に cumin データと呼ぶことにします)を JSON に変換するプログラムのことをコンパイラと呼んでいます. 実態としては, cumin 言語のパース, 型を付けながらの評価実行を行います. なのでコンパイラというよりはインタプリタか何かな気がするのですが, JSON 自体を中間言語だと思えば, トランスコンパイラだと言えなくもないかなと主張することにします.

cumin 言語への意味づけですが, JSON にどう対応するかで定めます. cumin データは常に JSON へ変換可能です. 逆に JSON に変換できないデータは持てません. 例えばタプルだとか日付データといったものを第一級には持ちません.

代数的データ構造を用意してないといったのもこれが原因です.
タグを用意するみたいなことをすれば出来なくはないのですが, 生成された JSON が人間に読めないようなものを作りたくもないなというのが本心です.
これだと思える自然な表現方法が思いついたら作りたいとは思ってます.

プログラムとして強力すぎないこと

ここがかなりあやふやだし, じゃあどうすれば私自身が満足できるのか, 未だに分かっていません.
考えてるのは

  • 関数は要らない
    • 読みたいのはロジックではなくてデータだから
  • マクロはあってもいいかも
    • 適度に可読性に貢献するなら
    • 構造体があるから要らないかも?
  • 簡単な計算機能はあっていい
    • 数の四則演算とかリストの結合とか

「関数」と「簡単な計算機能」の境目は? とか自分でも分かってないです.

ここからは言語の中身について書いていきます.

言語仕様

文と式があって, ゼロ個以上の文に続いて最後にちょうど一個の式があるとき, これは妥当な cumin データです.

雰囲気 BNF です:

<CUMIN> ::= <SS> <EXPR>
<SS> ::= <EMPTY> | <STATEMENT> <SS>

気持ちとしては一番最後に書く式が最終的に export するデータで, それより前に書く文はその準備です.

文としては今は3つだけ,

  • 構造体の宣言
  • 列挙体の宣言
  • let 束縛

です. 他のファイルを参照する import 機能くらいは欲しいので追加したらここに文として追加する予定です (Future Work).

構造体

およそ Rust の構造体と同じですが, フィールド名が必ず必要なのと, デフォルト値を与えることが出来るという2点が違います.

<STRUCT> ::= "struct" <IDENTIFER> "{" <INNER> "}"
<INNER> ::= <EMPTY> | <FIELD> | <FIELD> "," <INNER>
<INNER> ::= <IDENTIFER> ":" <TYPE> <DEFAULT_VALUE>
<DEFAULT_VALUE> ::= <EMPTY> | "=" <EXPR>

こんな感じ:

struct User {
    id: Nat,
    name: String = "UNKNOWN",
}

cumin は極力 trailing-comma を許していて, 最後のカンマはあってもなくてもよいです. Rust もそうですし最近の言語はみんなそうですよね.

以上が構造体の宣言で, 次のようにインスタンスを作ることが出来ます.

User(1, "John")

丸括弧で適用するとき, 引数は宣言の順序通りである必要があります.
またデフォルト値がある場合でも省略できません.

それ以外の書き方として

User { name = "John", id = 1, }

とも書けます. この場合はフィールド名と紐付いているおかげで順序を自由に入れ替えてよいです.
また省略した場合にどれを省略したかも分かるので, この書き方の時に限り省略可能です.

User { id = 1 }  // name には "UNKNOWN" が使われる

ところで // 以降はコメントとして扱われます.

さて意味づけですが, 先述したように, どういう JSON に対応するかで記述します.

User { name = "John", id = 1, }

{
    "name": "John",
    "id": 1
}

に対応します. 構造体の宣言でフィールド名を必ず要求したのは, ここの都合上です. また User という構造体名自体は情報として失われます. ここは, 良いのか悪いのか.

列挙体

cumin の列挙体は C/C++ の列挙体と同等の機能です.

<ENUM> ::= <IDENTIFER> "{" <INNER> "}"
<INNER> ::= <EMPTY> | <IDENTIFER> | <IDENTIFER> "," <INNER>
enum Direction {
    East,
    West,
    South,
    North,
}

この宣言はちょうど4種類の値のいずれかを取るという型 Direction を表しています.
次のように使います.

Direction::South

Direction:: はどの列挙体の値を使うかを表していて, 省略できません.
また, JSON では

"South"

という文字列に対応します.
従って Direction という名前は失われますし, もっと言えばただの文字列データと区別は付きません.

このように JSON への変換は保証しますが, 逆変換は何の保証もしてません.

let 束縛

<LET> ::= "let" <IDENTIFER> "=" <EXPR> ";"
let x = 1 + 2;

はい.

無名辞書

JSON の素の辞書というのは Map<String, Any> みたいな何でもありの構造をしています.
これそのものに対応する構造を一応用意しています.
無名関数ならぬ無名辞書です.

{{
    x = 1,
    y = 2,
}}

これは次と同じです.

struct Data {
    x: Int,
    y: Int,
}
Data(1, 2)

ちょうど一回しか利用せず, 定義するまでもない構造体を書くのに使います. 特に, 一番最後に export するデータが辞書のときにはこれが便利だと思っています.

今の無名辞書は型アノテーションをする文法を用意してないので何の型チェックもしませんが, 次のような書き方を許してもいいかもしれません. もしかしたら書けるようにします.

// Future Work
{{
    x: Int = 1,
    y: Int = 2,
}}

自然数, 整数, 浮動小数, 文字列, 真偽値, 配列, 辞書, 変数とそれらの簡単な演算などが式です.

// 自然数
123

// 自由に _ を入れて桁区切りできる
1_000_000_000

// 整数
-123

// 四則演算
(1 + 2) / 3

// 浮動小数
3.141_592

// 文字列
"hoge"
"hoge" + "fuga"

// 真偽値
not true or false
1 == 2

// 配列
[1, 2, 3]

普通だと思います.

ブロック式

Rust を書いてる方にはお馴染みのブロックという機能があります. 個人的にも大好きな文法なので取り入れました. C/C++ でも使えるのですが(使えましたよね?)何も無いところに突然 {} でブロックを作ることがことが出来ます. これは変数スコープを与えるので効果を限定的にするのに便利です. Rust のブロック式は式とあるように値を返します. {} の中に書いた最後の評価値がブロック全体の評価値になります.

let x = {
  let y = 2;
  let z = 3;
  y + z
};

これはブロックの評価値 5x に束縛しているコードです.
{} の中身は cumin データそのものだと見なすことが出来ます.

<BLOCK> ::= "{" <CUMIN> "}"

{} の中で構造体や列挙体を宣言して使うことも出来ますが, それらはブロックの外からは見えないので使えません.

環境変数

$ で始まる識別子は環境変数であるとして参照します. 環境変数は常に文字列です.

{{
    shell = $SHELL,
}}
{"shell":"zsh"}

データを外挿する機構が欲しかった.
当たり前ですが参照する環境変数はコンパイルの実行時やローダーの環境から読むことを想定しています.

プリミティブな型として次があります.

Any
Nat  // 自然数
Int  // 整数
Float
String
Bool
Array<_>
Option<_>

Any はいわゆる Any です. 今のところは漸進的に型を見ているだけなので, その初期値として Any があると便利だから用意しているだけです.
<_> とついているのは型パラメータを受け取る型です. 例えば自然数 (Nat) の配列は Array<Nat> という型で表現されます.

Null-able な値

ある変数が文字列 (String) であるかまたは null であるとき, Option<String> という型で表現します.
そしてその値は次のように表現します.

{{
    x = Some("Hello"), // null ではないことを表す
    y = None,  // null であることを表す
}}

対応する JSON は次です

{
    "x": "Hello",
    "y": null
}

実装

処理系もとい, JSON へのトランスコンパイラは Rust で実装しています. 最初にも貼りましたがこれです:

パーザーライブラリとして combine を使っています. これは基本的には LL(1) のパーザーコンビネータを提供してくれて, 部分的に LL(k) にすることが出来ます. Rust のパーザーライブラリで一番有名なのは恐らく nom だと思うんですが, ドキュメントを眺めてこちらのほうが使い勝手が良さそうに見えたので選びました. 若干の後悔がなくもないので, 乗り換えるかもしれません. というのも, 文法を複雑化するにつれて指数的にコンパイル時間が増えるので辛いです. 今はなんとかしてコンパイル時間を3分以内に抑えるようにしています. 型推論がどうも爆発しているらしくて(たぶん), 型アノテーションを付けまくって対応しています. このライブラリ使ってる人じゃないと分からない細かい話なんですが, .with() とか .skip() 使わずにタプルで受け取って, 無名関数の引数は全部型アノテーションつけるようにすると少しマシになりました.

最新版では nom に乗り換えました.
コンパイル爆速だし最高です.

Future Work

無限にあります.
やっぱり代数的データ構造は欲しい.

// 案
enum X {
    A,
    B { data: Int },
    C { data: Int },
}
X::A        // => "A"
X::B(data)  // => {"B": {"data": data}}
X::C(data)  // => {"C": {"data": data}}

うーん. まだちょっと悩んでる.

最新版では合併型を代わりに入れました. これは直和ではなくてただの和集合です.
ここについては型による安全性よりも意味の簡潔性を優先した形になります.

一ヶ月言語を自作して思ったこと

現状への不満があって作り始めたけど, 何が正解なのか自分自身で分からずに模索しています. 私は最初に, こういうふうに書けたらいいよな, という Example を5つくらい書き出してみて, そこから文法を逆算するようなことをして実装を始めました. 構想を練るのにせいぜい2日くらいしか掛けてませんでしたが, 完璧な言語を設計するのにはたぶん全然足りないです. そんなん当たり前で世界で広く使われてるプログラミング言語だって, バージョンが上がるたびに文法が増えていってるんだから, そりゃあそうです. 実際に実装を始めて, 出来ることが増えるにつれて, 欲しくなる機能も増えたためにアドホックに追加するはめになりました. パーザー部分が本当に地獄になってます.

二日前の coward さんのアドベントカレンダー記事 最近見つけたおもしろ自作言語の紹介 の総括がかなりぐさっと来ていて, 全くそのとおりだなあと実感しています.
初めの方はとりあえず動くものを書いて, 動いて, それだけで楽しいというだけでモチベーションが上がるんですが, 段々とね.