Dhallという面白くて強い設定記述言語とRustに存在する最強なプロシージャルマクロのお話です。
Dhallについて
Dhallとは設定記述言語の一つです。要するにINIやJSONやYAMLといったものの一種になります。
Dhallはそうした設定記述言語の中でも、変数が使えたり関数が使えたりと突出してマンフレンドリーな機能を多く備えています。
その一方でチューリング不完全であり、ざっくりというと式の評価が必ず停止します。設定ファイルを読み込んだら記述をミスって無限ループになって初期化フェーズで死んだなんてことになると大変ですので、必ずとある形で評価が停止してくれるというのは設定ファイルとして採用する際に安心できるポイントです。
Haskellよろしくインデントベースな構文であることと、書き方が多様にわたるためプログラムから生成する設定ファイルのフォーマットとして扱うには少し厳しいところがありますが、Kubernetesやdocker-composeやGitHub Actionsといった人間が手動でしか書かない設定ファイルの中間記述言語としては大変優秀です12。
詳しい説明は本家にゆずるとして、Rustでもserde_dhallを使うことでDhallファイルの読み書きができます。これはクレート名からも察せるとおり、Serdeのインターフェイスを用いてSerialize/Deserializeを行うことができます。jsonやyamlと何一つ変わらない形で移行できるというのはなんとも嬉しいお話ですね。
#[derive(Deserialize, Debug)]
pub struct Config {
pub x: u64,
pub y: u64
}
fn main() {
let cfg: Config = serde_dhall::from_str("{ x = 1, y = 2 + 3 }").parse().expect("Failed to parse configure");
println!("loaded config: {:?}", cfg); // => loaded config: Config { x: 1, y: 5 }
}
Serdeのインターフェイスで使えるならこれでいいですね。手書き設定ファイルを使用しているアプリの開発者はぜひ積極的に移行していきましょう。めでたしめでたし
型定義をいちいち合わせるの、面倒じゃないですか?
さて、Dhallには本筋の設定を記述する以外にも設定の 型 を定義することも可能です。例えば上の設定データに対する型は以下のように書くことができます。
{ x : Natural, y : Natural }
静的型付け言語をご利用の方はもう型がもつパワーは十分にご存知であると思いますが、Dhallも静的型付け言語の例にもれず型を付けることが安全かつエラーを引き起こさない設定ファイルを書く上で非常に重要になります。実際先に用例として挙げたKubernetes, docker-compose, GitHub Actionsでの利用例では型とデフォルト値のみを記述したファイル(スキーマ定義)がいくつか公開されており、基本的にはこれらをインポートして設定ファイルを記述する形を取ることがほとんどです。
- Kubernetes: https://github.com/dhall-lang/dhall-kubernetes
- docker-compose: https://github.com/sbdchd/dhall-docker-compose
- GitHub Actions(手前味噌ですが): https://github.com/Pctg-x8/gha-schemas
これらはサービスの設定ファイルで、これからするお話の題材にするとちょっと実感が湧きにくいかと思いますので、もう少しアプリレイヤーに近い例を挙げます。
これはとあるゲームエンジンの起動時設定ファイルのスキーマ定義3です。そこそこ複雑ですね。
let ScreenStyle = < Windowed | Unbordered | Fullscreen >
let Screen = {
, Type = { width : Natural, height : Natural, style : ScreenStyle }
, default = { style = ScreenStyle.Windowed }
}
let App = {
, Type = { screen : Screen.Type }
, default = { screen = Screen.default }
}
in { App, Screen, ScreenStyle }
この定義はまだ草案の段階ですので、今後機能追加に伴って定義が増えていくことが予想されます。そのたびにアプリコード側の型定義も修正するのはなんとも面倒です。できればこの定義ファイルからアプリ側の型定義を自動で生成したいです。
というわけでタイトルに戻ります。今回はアプリコード側はRustを使用しています。Rustで任意のファイルをパースしてコードを生成するとなれば使用できる選択肢は一つ、 Procedural Macros4 です。
というわけで、早速Procedural Macrosを使用してDhallの型定義からRustの型定義を生成するコードを作ります。
作ったものがこちらになります: https://github.com/Pctg-x8/dhall-rust-typegen
このクレートはserde_dhallの内部で使用しているクレートを引っ張り出してきて使っているので、crates.ioにはあげていません。
そう、serde_dhallは予め定義した構造の他に「値のみ」「型のみ」を解析する機能までは存在するんですが、値と型が複合しているスキーマのような形式を解析する機能は惜しくも存在していません。
ただし、Dhallの機能的には上記が解析できないということはありえないので、解析フェーズの内部クレートを直接引っ張ってくることで今回の機能を実現しました。
内部の作りをざっくり解説すると、dhallクレートの各種構造を使って、 Parsed -> Resolved -> TypeChecked -> Normalized の順に引数で指定されたファイルを解析して、そこからASTのような構造( ElementTree
/ InlineType
/ ItemType
)を作ってコード( TokenStream
)を生成しています。
Dhallでは、URLを指定してネット経由でファイルをインポートしてくる機能もあり、そのあたりの解決をParsed -> Resolvedのところでやっています。
また、型があるので、そこの解決をResolved -> TypeCheckedのところでやっています。
TypeCheckedでは一部の型がプレースホルダーとして残っているため、それを具体的な型に変換するなどしてNormalized構造を得ます。
Normalizedから得られるNIRという構造は、いわゆるWHNF(Weak Head Normal Form)の形を表しています。型の世界であればこれはほぼ脱糖しきった形でありもっとも具体的な形となっているとみなせるため、ここまでくれば型生成に利用できます。
ちなみに、もしこのクレートを使用する際は、cargoはgitリポジトリのサブディレクトリ以下のクレートも参照できるので以下のように書くことで使うことはできます。
dhall_schema_import = { git = "https://github.com/Pctg-x8/dhall-rust-typegen" }
このクレートで以下のように書くことで、スキーマ定義からある程度の型を自動で作ってくれるようになっています。
// この場合のベースディレクトリは、srcのひとつ上(プロジェクトルート)です。
dhall_schema_import::import!("./config.dhall");
// 上記コードは、以下のように展開されます(cargo expand参照)
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
pub struct App { pub screen: Screen }
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
pub struct Screen { pub width: u64, pub height: u64, pub style: ScreenStyle }
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
pub enum ScreenStyle { Windowed, Unbordered, Fullscreen }
このマクロで生成される型はSerialize
とDeserialize
を両方実装していますので、そのままserde_dhall
で使うことが可能です。
dhall_schema_import::import!("./config.dhall");
fn main() {
let cfg: App = serde_dhall::from_file("init.dhall").parse().expect("Failed to parse initial config");
...
}
作ってはみたけど
今回は突貫で作ったため、まだいくつか対応できていない点やマクロということでIDE上での扱いに難があります。
1. ルートツリーが特定の形のレコードじゃない場合に使えない
例えば次のようなスキーマ定義はこのマクロでは処理できません。
let Point = { x : Natural, y : Natural } in Point
Dhallの言語仕様上はこの書き方はOKなので、スキーマの記述としてこの形が現れる可能性は十分にあります。
この場合はstruct Point
が定義されて欲しいわけですが、現状のマクロの処理ではこの形での型名が取れないため、仮にルートの扱いを変えて出力できるようにしたとしてもstruct AnonType1
とでてくるはずです。
今回使っているserde_dhallの内部クレートでは、タイプチェックの段階でどうやら型名が消去されてルートの式に埋め込まれてしまうようで、元の型名を正確に復元するにはResolvedのタイミングで得られるHIRという構造を舐める必要があります。Resolvedまでで少なくともimportのあたりは解決されているはずなので、この段階でのHIRを舐めるのもそこまで難しくはないと思いますが、持っている情報がかなり多いのでNIRを舐めるように単純にはいかないかもしれません。あと多分式がWHNFの形をしてないはずなのでそれもまともに対応しようとすると難しそうです。
2. 同じ構造の型の名前を正確に復元できない
これは前項に関連するのですが、今回のマクロでは「同じ構造の型には一つの型名を与え、それを使う」という処理を入れています。
例えば、次の例ではstruct Point
とstruct Offset
で別々の構造体ができて欲しいところですが、おそらくstruct Point
かstruct Offset
のどちらかしか出力されません。
let Point = { x : Natural, y : Natural }
let Offset = { x : Natural, y : Natural }
in { Point, Offset }
なぜこのようなことになるかというと、前項の「タイプチェックの段階で型名が消去され、ルートの式に埋め込まれてしまう」ためです。例えば先述のconfig.dhallを処理する場合、NIRを舐めて復元できる情報はおおよそ次のものと同じです。
{ App = {
, Type = {
, screen : {
, width : Natural
, height : Natural
, style : < Windowed | Unbordered | Fullscreen >
}
}
, default = { screen = { style = < Windowed | Unbordered | Fullscreen >.Windowed } }
}
, Screen = {
, Type = {
, width : Natural
, height : Natural
, style : < Windowed | Unbordered | Fullscreen >
}
, default = { style = < Windowed | Unbordered | Fullscreen >.Windowed }
}
, ScreenStyle = < Windowed | Unbordered | Fullscreen >
}
このように、全てのアイテムがインラインに展開されます。Rustにはインラインで無名型を定義する構文はないので、このインライン展開された型をなんとかして型名に変換する必要があります。
HIRにはこの辺りの情報がちゃんと残っているので、まともにやるのであればHIRを舐めて型名とメンバの対応表を作っておくとかになるのかなと思います。ただ同じ構造をした別名の型が2つ以上存在するというのはなかなか稀だと思うので、今回は同じ形をした型をまとめる5方向でお茶を濁しています。
3. Rust Analyzerではマクロ展開が(デフォルトで)できない
Rust Analyzerではマクロ(Procedural Macros)の展開がオプショナル(デフォルトOFF)となっています。Visual Studio Code上でRust Analyzerを使用している場合、rust-analyzer.procMacro.enable
とrust-analyzer.cargo.loadOutDirsFromCheck
の2つをtrue
に設定すればマクロの展開が行われます。
ただしその場合でも(この文章を書いている段階では)全然関係ない範囲がエラーとして報告されることもあり、なかなか不安定です。
-
実際、そこに注目してかいろいろやっている人がいます。たとえば https://ryota-ka.hatenablog.com/entry/2018/08/27/110000 ↩
-
あとは、spago(PureScriptのパッケージマネージャ)でもデフォルトとして使われていますね。パッケージ依存の記述なんかは確かにほぼ手書きなので非常に用途として合っています。 ↩
-
https://doc.rust-lang.org/reference/procedural-macros.html ↩
-
ASTをHashできるようにして、ルートレコードにあるASTを生成する中でHashMapに名前を記録しています。 ↩