D言語でコンパイル時に Common Workflow Language パーサーを生成したい!(データ構造編)
この記事は D言語 Advent Calendar 2021 の10日目および Common Workflow Language (CWL) Advent Calendar 2021 の4日目の記事です。
この記事は以下の二部三部構成になっています。
- (前の記事) ワークフロー記述言語 Common Workflow Language (CWL) とその文法定義形式 Semantic Annotations for Linked Avro Data (SALAD)、最近は教育用のプログラミング言語としてもその頭角を表しつつあるD言語の概要を示し、D言語でコンパイル時 SALAD パーサージェネレータを書いたら最強なのでは?という野望について語ります。
- (この記事) D言語でコンパイル時 SALAD パーサージェネレータを書く方針と、SALAD 定義に対応したD言語でのデータ構造を定義する話をします。
- (次の記事) D言語のつよつよメタプログラミングによりパーサーの大部分が自動生成できることを示し、最終的にコンパイラとカリカリチューニングされたコードの CTFEability という壁に阻まれ、コンパイル時 SALAD パーサージェネレーターを書くことに失敗した話をします(つらい)。
前回のあらすじ
- Common Workflow Language (CWL) の文法は Semantic Annotations for Linked Avro Data (SALAD) で記述されている
- D言語はコンパイルが爆速で組み込みからパーサーまで対応できてメタプログラミングが特に強力でD言語で書いた大抵のコードはコンパイル時にも動く
- D言語でコンパイル時 SALAD パーサージェネレーター書いたら最強なのでは!
基本方針
いきなりパーサジェネレーターを書くのは大変なので、まずは自動生成しやすそうなパーサーを手で書き、CWL を読み込むところまで確認します(ここまで実装済み)。
次に、SALAD で書かれた文法定義を読み込み、先程の手書きパーサーと同等のソースコードを出力するコードを書く…予定でした。
この記事では、各 SALAD 定義をどのようにD言語で表現するのかを CWL[1] およびD言語の機能に触れながら解説します。開発したコードは schema-salad-d にあります。
手書きパーサー解説
SALAD 定義の例
以下は CWL の CommandLineTool
の文法を記述した SALAD での定義を一部抜粋したものです^2。この SALAD 定義に対応したD言語のデータ構造を定義し、パーサを作成するのが第一ステップです。
type: record
name: CommandLineTool
documentRoot: true
fields:
- name: id
type: string?
- name: class
jsonldPredicate:
"_id": "@type"
"_type": "@vocab"
type: string
- name: inputs
type:
type: array
items: CommandInputParameter
jsonldPredicate:
mapSubject: id
mapPredicate: type
- name: requirements
type: ProcessRequirement[]?
jsonldPredicate:
mapSubject: class
...
type: record
はレコード型の定義であることを表し、各フィールドの名前や型を定義します。各フィールド定義は主に以下から構成されています。
-
name
: フィールド名を表します。 -
type
: フィールドの型を表します。 -
jsonldPredicate
: 今回の解説の肝です。ここの値によってフィールドのパース方法が変わります[2]。
以下は CWL の CommandLineTool
オブジェクトの例です。
id: my_command # `stning?`: 文字列型 (オプショナル)
class: CommandLineTool # SALAD 定義中の `name` フィールドの値と多分同じ
inputs: # `CommandInputParameter` の配列…配列…?
inp1: File
inp2:
type: string
...
requirements: # `ProcessRequirement[]` (オプショナル)
- class: DockerRequirement # ProcessRequirement を継承したもの1
...
- class: InlineJavascriptRequirement # ProcessRequirement を継承したもの2
...
注意点としては
- 型に
?
がついているものは、対応するフィールドが存在しない場合がある -
inputs
の値がフリーダム (詳細は後述) -
requirements
に色々来る (後述) -
documentRoot: true
が付与されたデータ構造のみが CWL オブジェクトのトップレベルに出現する[3]。
SALAD 定義に対応するD言語のデータ構造の設計
D言語には構造体(値型・継承不可)とクラス(参照型・継承可能)の2種類のユーザー定義型があります。SALAD の文法ではフィールドの型を相互参照する場合があるため、今回は各 SALAD 定義をD言語のクラス定義に変換します。またパーサー実装時を楽にするために、SALAD 定義にあった jsonldPredicate
の情報も可能な限りクラス定義上に反映できるようにします。
というわけで出来上がったクラス定義がこちらになります。
@documentRoot class CommandLineTool
{
SumType!(None, string) id_;
static immutable class_ = "CommandLineTool";
@idMap("id", "type") CommandInputParameter[] inputs_;
@idMap("class")
SumType!(
None,
SumType!(
DockerRequirement,
InlineJavascriptRequirement,
...
)[],
) requirements_;
...
mixin genCtor;
}
順に見ていきましょう。
@documentRoot class CommandLineTool { ... }
クラス定義の前に付与されている @...
は User-Defined Attribute (UDA) と呼ばれるもので、コンパイル時にのみ参照できる情報です[4]。@documentRoot
は CWL 全体のパーサーを生成するときに利用します(利用方法は次回)。
SumType!(None, string) id_;
SumType は最近D言語の標準ライブラリ入りしたデータ構造で、いわゆる代数的データ型を定義できる構造体です[5]。SumType!(None, string)
で「None
(無効値・独自定義) か string
のどちらかの値をとる型」を表現できます。CWL のデータ構造には long
か string
を取るオプショナルなフィールドなど、複数の型を取りうるかつオプショナルなフィールドが現れることがあるため、オプショナルな型を含めた union type[6] を全て SumType
を使って表現することにします[7]。
static immutable class_ = "CommandLineTool";
jsonldPredicate
が { "_id": "@type", "_type": "@vocab" }
付与されているフィールドは固定値になり、name
フィールドの値のみをとるようです[8]。
@idMap("id", "type") CommandInputParameter[] inputs_;
例の中で inputs
フィールドがかなりフリーダムな表現をしていたのは、jsonldPredicate
で記述された Identifier maps が原因です。Identifier maps 指定があるフィールドは、mapSubject
フィールドで指定した CommandInputParameter
のフィールドの値をキーとしたオブジェクトとしても表現が可能になります。また mapPredicate
フィールドが存在する場合には、各キーの値としてオブジェクトの特定フィールドのみ指定する短縮機能が記述できるようになります。
@idMap("class")
SumType!(
None,
SumType!(
DockerRequirement,
InlineJavascriptRequirement,
...
)[],
) requirements_;
UDA で指定できるのは構造体などのデータ構造で、idMap
は以下の定義にしています。
struct idMap { string subject; string predicate = ""; }
デフォルト値を指定できるので、UDA 指定時には predicate
を省略できます。地味に便利ですね。
さて、このフィールドは ProcessRequirement
を継承したデータ構造 (e.g., DockerRequirement
など)が来ますが、どのデータ構造が ProcessRequirement
を継承しているのかは SALAD 定義を読めば全て列挙可能です。そのため、取りうる型をを全て列挙した SumType
として定義すればよいです。
mixin genCtor;
あとは Template mixin を使って、これらのフィールドを読み込むパーサーを生成するテンプレートを作成すれば完成です。簡単ですね!
パーサー生成の話は?
長くなったのでまた次回!
-
パーサ生成を簡単化するため、CWL 処理系やユーザーにより独自拡張を行うための拡張フィールドについては考えないものとします。 ↩︎
-
例えば
CommandInputParameter
の定義にはdocumentRoot: true
が付いていないため、CommandInputParameter
がトップレベルに出現するファイルは CWL として valid ではありません。 ↩︎ -
std.variant とは異なり、
SumType
は取りうる型に制限がある代わりにpure
や@nogc
などの属性やbetterC
にも対応しています。 ↩︎ -
SALAD の仕様は Apache Avro をベースにしています。たまに SALAD の仕様書中には一切記載がなく、ベースの Apache Avro まで調べないとわからない仕様があります。つらい。 ↩︎
-
「ようです」と曖昧な書き方をしたのは、
"_id": "@type"
の挙動が仕様のどこにも書いていないからです…。この手の「正しい挙動が仕様中に記述されておらず、処理系の動作から類推するしかない」問題は、個人的にかなり issue 登録しにくいと感じています… ↩︎
Discussion