🐍

D言語でコンパイル時に Common Workflow Language パーサーを生成したい!(データ構造編)

2021/12/31に公開

この記事は 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言語のデータ構造を定義し、パーサを作成するのが第一ステップです。

CommandLineTool.yml
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 オブジェクトの例です。

cmd.cwl
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 の情報も可能な限りクラス定義上に反映できるようにします。

というわけで出来上がったクラス定義がこちらになります。

cwl.d
@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 のデータ構造には longstring を取るオプショナルなフィールドなど、複数の型を取りうるかつオプショナルなフィールドが現れることがあるため、オプショナルな型を含めた 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 を使って、これらのフィールドを読み込むパーサーを生成するテンプレートを作成すれば完成です。簡単ですね!

パーサー生成の話は?

長くなったのでまた次回

脚注
  1. パーサ生成を簡単化するため、CWL 処理系やユーザーにより独自拡張を行うための拡張フィールドについては考えないものとします。 ↩︎

  2. jsonldPredicate はパース方法以外に各種名前解決などにも影響を与えますが、今回は解説を省略します。 ↩︎

  3. 例えば CommandInputParameter の定義には documentRoot: true付いていないため、CommandInputParameter がトップレベルに出現するファイルは CWL として valid ではありません。 ↩︎

  4. 先頭に @ が付いていても、一部は UDA ではないので注意が必要です。 ↩︎

  5. std.variant とは異なり、SumType は取りうる型に制限がある代わりに pure@nogc などの属性や betterC にも対応しています。 ↩︎

  6. SALAD の仕様は Apache Avro をベースにしています。たまに SALAD の仕様書中には一切記載がなく、ベースの Apache Avro まで調べないとわからない仕様があります。つらい。 ↩︎

  7. オプショナルな値を取り扱うことのみが目的の場合は Nullable の方が適切です。 ↩︎

  8. 「ようです」と曖昧な書き方をしたのは、"_id": "@type" の挙動が仕様のどこにも書いていないからです…。この手の「正しい挙動が仕様中に記述されておらず、処理系の動作から類推するしかない」問題は、個人的にかなり issue 登録しにくいと感じています… ↩︎

Discussion