- Datatype99は、純粋なC99で代数的データ型、網羅的なパターンマッチング、コンパイル時イントロスペクションを提供するマクロベースのライブラリ
- 型指定を誤ったvariant、漏れのあるパターンマッチング、不正なフィールドアクセスをコンパイル時に検出することを目指しており、標準準拠のC99コンパイラだけを必要とする
- 内部的には、
datatypeはtagged unionと値コンストラクタ(value constructor)に、matchはswitch文に展開され、生成されるデータレイアウトは形式化されたコード生成セマンティクスに従う
- インストールは
datatype99.hと依存関係であるMetalang99をincludeパスに追加する方式で、GCC/Clangではマクロ展開エラー出力を減らすコンパイルオプションの使用が推奨される
- Cコードベースには
#include <datatype99.h>で統合でき、GCC・Clang・MSVC・TCCで動作することが知られており、C++11以降もサポートする
Datatype99が提供するもの
- Datatype99は、安全で直感的な代数的データ型をC99で提供する
- 機能範囲には、網羅的なパターンマッチングとコンパイル時イントロスペクションも含まれる
- 外部のコード生成ツールなしで動作し、実装は純粋なC99ベース
- 主な特徴
- 型安全性: 型指定を誤ったvariant、網羅的でないパターンマッチング、不正なフィールドアクセスをコンパイル時に検出
- 移植性: 標準準拠のC99コンパイラが必要で、標準ライブラリ・コンパイラ/プラットフォーム固有機能・VLAは不要
- 予測可能性: コード生成セマンティクスが定義されており、生成されるデータレイアウトが常に同じになることを保証
- 理解しやすいエラー: 誤ったコードに対して、ライブラリ自体が一部の構文エラーを検出
- 実利用事例: OpenIPCでIPカメラ向けリアルタイムストリーミングソフトウェア開発に使われており、RTSP 1.0実装と約5万行の非公開コードが含まれる
インストールとビルド設定
- Datatype99は、
datatype99.hという1つのヘッダファイルとMetalang99への依存関係で構成される
- プロジェクトで使うには、
datatype99とmetalang99/includeをincludeディレクトリに追加する必要がある
- GCCでは
-ftrack-macro-expansion=0、Clangでは-fmacro-backtrace-limit=1を指定して、不要なマクロ展開エラー出力を減らす方法が推奨される
- CMake使用時は
FetchContentが推奨される
- デフォルトの
datatype99/CMakeLists.txtは、GitHub ReleasesからMetalang99 v1.13.5をダウンロードする
- この動作は、事前に
FetchContent_Declareを呼び出すことで上書きできる
- Datatype99に依存するヘッダはprecompiled headerにでき、includeされるたびに再コンパイルされないためコンパイル時間を短縮できる
使い方: tagged unionをより安全に使う
- Datatype99は基本的にtagged unionに対するシンタックスシュガーであり、より安全で簡潔な形式を提供する
- 通常のCで二分木を表現するには、
tag enumとunionを自分で書く必要がある
- Datatype99では同じ構造を次のように宣言できる
datatype(
BinaryTree,
(Leaf, int),
(Node, BinaryTree *, int, BinaryTree *)
);
- 通常の
switch方式では、case Leaf:の後で誤ってtree->data.nodeにアクセスしても、コンパイラが警告しない場合がある
matchとofを使うと、variantごとのバインディングはその分岐でだけ見え、不適切にアクセスするとコンパイルに失敗する
int sum(const BinaryTree *tree) {
match(*tree) {
of(Leaf, x) return *x;
of(Node, lhs, x, rhs) return sum(*lhs) + * x + sum(*rhs);
}
return -1;
}
ofが提供するバインディングはx、lhs、rhsのような変数で、ポインタ型を持つため値を変更できる
- variantの生成には、内部的に生成される値コンストラクタを使う
BinaryTree leaf5 = Leaf(5);
BinaryTree leaf7 = Leaf(7);
BinaryTree node = Node(&leaf5, 123, &leaf7);
構文と生成セマンティクス
- Datatype99は、
datatype、record、match、of、otherwise、MATCHES、ifLetなどのマクロ構文を提供する
- 短縮マクロ名とpostfixed版が併存する
- 例:
match99、of99、derive99
- 名前衝突を避けるには、
datatype99.hをincludeする前にDATATYPE99_NO_ALIASESを定義できる
- ライブラリヘッダではpostfixedマクロの使用が推奨される
datatypeは次の要素を生成する
- forward
typedef
- non-empty variantごとの構造体
- variantフィールド型に対する
typedef
- sum typeに対する
typedef
tag enumとunionを含むtagged union
- variantごとの
inline static値コンストラクタ
derive(...)で指定されたderiver呼び出し
- すべてのvariantが空でも、C標準上unionには最低1つの項目が必要なため、
char dummy;が入る
recordはderivation processが定義されたstructであり、フィールドがない場合もchar dummy;を生成する
matchはsum typeインスタンスをvariantと順に比較し、成功した分岐の文を実行した後、次の命令へ移動する
- 完全な
matchとifLet構文は、それぞれ1つのC文に展開される
MATCHESはsum typeインスタンスが特定のvariantに該当するかを真/偽で検査する
matchesはdeprecatedであり、MATCHESの使用が推奨される
deriveと補助属性
derive(...)はsum typeやrecordに対してグローバルコードを生成する用途で使われる
- sum typeのderiverはMetalang99互換マクロの形で呼び出され、variantリストはtuple listとして渡される
- recordのderiverもMetalang99互換マクロとして呼び出され、フィールドリストは
(<type>, <field-name>)形式のtuple listとして渡される
- derive helper attributeはderiverに渡す名前付き引数
- helper attributeはobject-like macro形式を使う
#define <variant-name>_<namespace>_<attribute-name> attr(/* attribute value */)
- 提供される属性操作マクロ
DATATYPE99_attrIsPresent / DATATYPE99_ATTR_IS_PRESENT: 属性の存在有無を確認
DATATYPE99_attrValue / DATATYPE99_ATTR_VALUE: 存在する属性の値を抽出
DATATYPE99_assertAttrIsPresent: 必須属性がなければfatal errorを発生
注意すべき使用パターン
ofとifLetに渡す文の中で、最上位のbreak/continueを使ってはならない
- 内側の
for/whileループ内のcontinueは問題ない
- 最上位の制御フローは
goto labelで置き換える必要がある
- variantパラメータとして配列を指定するには、別の
structに入れる必要がある
ofが導入するバインディングは常にmutableなので、matchに渡した値がconstなら変更しないよう注意する必要がある
datatype定義を見やすく保つには、Clang-Formatで// clang-format offと// clang-format onを使える
- derive helper attributeは、該当する
datatype定義の後で必ず#undefし、namespace汚染を避けるべき
- variantパラメータの意味が文脈だけでは明確でない場合は、型エイリアスや別の構造体で、より説明的な名前を付けられる
エラー、IDE、互換性
- 一部の構文エラーはライブラリ自体が検出する
Bar(int)のようなtupleではない形式
- commaの欠落
- 禁止されたtrailing comma
- それ以外のエラーも通常のコンパイラ診断として現れる
- 存在しない型名
- 網羅的でない
match
ofの過剰なバインダー
- 型指定を誤ったvariant引数
- ポインタバインディングを逆参照していないreturn
- 経験上、ほぼ95%のエラーが意味のある形で見えるとされている
- エラーが理解できない場合は
-Eで生成コードを確認でき、コード生成セマンティクスが形式的に定義されているため、通常は予想外のコードは出てこない
- VS Codeは生成された型に対する候補を自動で有効化するが、マクロ構文のハイライトはサポートしない
- Datatype99はGCC、Clang、MSVC、TCCで動作することが知られている
- C++11以降もサポートする
Cを対象とする理由と限界
- 純粋なCで書かれた既存ソフトウェアがDatatype99の利点を得られる
- 既存のCコードベースには
#include <datatype99.h>だけで統合できる
- 一部の環境では、組み込み機器、Linuxおよび他のOSのように、歴史的理由から純粋なCを維持している
- Cの安定したABIは、MetaCallのようなプラグインシステムプロジェクトにとって重要
- Cは完全な仕様と多くのライブラリを持つ成熟した言語として提示されている
- より現代的または高水準の言語を使えるなら、古いCの代わりにそうした言語の使用が推奨されるが、多くの人にとってはその選択肢がないか、コストが大きい
- Datatype99とMetalang99の違いは役割にある
- Metalang99はメタプログラミング用の関数型言語
- Datatype99はMetalang99で書かれた代数的データ型の実装
1件のコメント
Hacker Newsのコメント
仕事では Java を使わなければならないが、Java は昔けなしていたほど悪くはないと思うようになった。それでも「Java に F# の discriminated union があれば」と思う瞬間が何十回もあった
いろいろな手法でそれっぽく真似はできるし、enum だけで十分な場合も多いが、たいていは本物の代数的データ型が持つ柔軟さと簡潔さに欠ける。何より、関数型言語で得られるすばらしい パターンマッチング がない
この C 拡張は、求めていたパターンマッチングを備えているように見えて、かなり良さそうだし、Arduino プロジェクトで使えるか試してみたい
知ったばかりの人は、パン以来最高の発明だと言って止まらなくなる :)
Java には https://github.com/functionaljava/functionaljava があり、サポートは終了しているが安定している
強力すぎて、これなしではやっていけない
最近 sealed class 上でコンパイラが強制するパターンマッチングのサポートが入ったので、半分くらいは来たと言えそう
代数的データ型が内部でどう動作するかを見たいなら、libsum は良い入門資料だと思う
[1] https://github.com/naasking/libsum
C をほぼ 20 年触ってきたが、マクロシステムがこんな黒魔術を可能にするほど強力だとは思ってもみなかった
本当にすごい
伝統的には、型安全なジェネリクスを作ってアクセスしたり、ハードウェアレジスタや割り込み定義の重複コードを減らしたりするのに使われてきた
少し呪われた感じはあるが、核心は実際とても単純で、C ベースのプロジェクトで認知的複雑さと重複コードを減らす信頼できる道具だ
マクロの使い方としては、そこまで複雑ではない
https://en.wikipedia.org/wiki/Tagged_union#Class_hierarchies...
同じ内容を扱った長いブログ記事もあるが、書き手はまだその Wikipedia セクションを見ていないように見える
https://nandakumar.org/blog/2023/12/paradigms-in-disguise.ht...
datatype99 の開発者が、こうしたその場しのぎの問題を README でいきなり示しているのは評価に値する
https://github.com/melt-umn/ableC-template-algebraic-data-ty...
それでも、それ以外はとてもすばらしい
昔マクロで immediate mode UI を作ったことがあるが、それを思い出した。自分の場合は問題にならなかったが、一部のブロックでは "break" が使える
たとえば win_form ブロックでは "break" で抜けられるし、"goto" も動く。一方で win_command ブロックは "break" を捕捉しないので、win_command の中で break や goto を使うと、その win_command を包んでいる外側のブロック、おそらく win_form ブロックを抜けることになる。普通は "Cancel" ボタンのような場合に使う
単にいくつかの点で少し優れているという話ではなく、エコシステム全体で時間とともに積み重なった数多くの小さな不便を滑らかに解消できる点にある。CPython 系に独自の語彙型が多すぎるとか、高性能コンピューティングライブラリ群にもそういう問題があるとか、そうしたものと同じで累積効果が大きい
たとえば、このマクロでこうした問題を起こしている部分は、よく書かれた Rust マクロならそもそも問題にならなかったはずだ。賢い人たちが C の限界を回避しようとして生まれた副産物だ
ただし、このマクロ自体も元々Rustのネイティブ機能を移植したものなので、Rustではそもそも書く必要がなかったはずで、そのためコミュニティ製ソフトウェアでも自然に活用されている。
gotoは関数から関数へ移動するために使うときにだけ自爆要素になるのであって、実際のところ "goto considered harmful" もその慣行を標的にしたものだった。そうした慣行は消え、今では関数内で使う
gotoはかなり無害で、実際break/continueとほぼ同じ。そしてRustがあることもすでに知っているが、Cプログラムを書く人たちが誰もが知っている理由でRustを使わないとしよう。
それでも少なくともZigは検討に値する。2日前にZigでこういうコードを書いた。
comptimeのinline elseを使ってタグ付きunion上のswitch文の全分岐を生成し、"l"フィールドを持つunionメンバーにオフセットを加える。コンパイル時に分かる型情報であれば、分岐の性質をさまざまに変えられるし、その情報はかなり多い。すべての条件がコンパイル時に決定されるので、switchの各分岐はそのバリアントを処理するのに必要なロジックだけを持つ。「でも自分のプログラムはすでにCで、必要なのも1ファイルだけだ」というなら、それでもZigを試してみるといい。気に入るかもしれない。
https://gist.github.com/unclechu/eb37cc81e80afbbb5e74990b62e...
struct + union + enumで 代数的データ型の利点 の大半は得られるのではないか? 複数の型のunionを置き、どれを選ぶかを区別するenumを付けるパターンを使ったことがある。std::variantも合計型のようにある程度は動作するように思える。唯一の問題は、フィールドの特定の値に合わせてきれいな
switch文を作れないことだが、入れ子のswitch文もそこまで汚くはない。このアプローチの問題は、あらゆる細部を漏らさず面倒を見る 精神的負荷 が大きいことにある。疲れてくると、新しいクラスを作る代わりに既存のクラスへ機能を無理やり押し込み始める。
結局、抽象化を高くつくものだと感じるようになり、その結果、もっと優雅に問題を解けるのに抽象化をあまり使わなくなる。
要するに、複雑な考えを参照するたびに長い説明を繰り返す代わりに、"Darmok and Jalad at Tanagra" と言える能力は変革的だ。
パターンマッチング が大きな使い勝手上の利点だ。
マクロでやるのは難しいが、不可能ではない。基本的にはマッチ文脈を開始するマクロが必要で、検査したすべての選択肢を追跡する変数を導入し、マッチ文脈が終わるときに全選択肢を確認したか検査するよう構成する必要がある。
std::variantはあまり好きではない。std::visitですべてのケースを漏れなくマッチさせるやり方はハッキーに感じる。これが本当に 第一級の言語機能 になれば大きな利点があるはずだ。コルーチンのようにこれまで取り組まれてきた他の機能よりも、日常的なコードに与える影響は大きいかもしれない。