1 ポイント 投稿者 GN⁺ 2024-05-10 | 1件のコメント | WhatsAppで共有
  • Datatype99は、純粋なC99で代数的データ型、網羅的なパターンマッチング、コンパイル時イントロスペクションを提供するマクロベースのライブラリ
  • 型指定を誤ったvariant、漏れのあるパターンマッチング、不正なフィールドアクセスをコンパイル時に検出することを目指しており、標準準拠のC99コンパイラだけを必要とする
  • 内部的には、datatypeはtagged unionと値コンストラクタ(value constructor)に、matchswitch文に展開され、生成されるデータレイアウトは形式化されたコード生成セマンティクスに従う
  • インストールは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への依存関係で構成される
  • プロジェクトで使うには、datatype99metalang99/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にアクセスしても、コンパイラが警告しない場合がある
  • matchofを使うと、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が提供するバインディングはxlhsrhsのような変数で、ポインタ型を持つため値を変更できる
  • variantの生成には、内部的に生成される値コンストラクタを使う
BinaryTree leaf5 = Leaf(5);
BinaryTree leaf7 = Leaf(7);
BinaryTree node = Node(&leaf5, 123, &leaf7);

構文と生成セマンティクス

  • Datatype99は、datatyperecordmatchofotherwiseMATCHESifLetなどのマクロ構文を提供する
  • 短縮マクロ名とpostfixed版が併存する
    • 例: match99of99derive99
    • 名前衝突を避けるには、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と順に比較し、成功した分岐の文を実行した後、次の命令へ移動する
  • 完全なmatchifLet構文は、それぞれ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を発生

注意すべき使用パターン

  • ofifLetに渡す文の中で、最上位の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件のコメント

 
GN⁺ 2024-05-10
Hacker Newsのコメント
  • 命令型言語を使うたびに、代数的データ型がほぼいつも恋しくなる
    仕事では Java を使わなければならないが、Java は昔けなしていたほど悪くはないと思うようになった。それでも「Java に F# の discriminated union があれば」と思う瞬間が何十回もあった
    いろいろな手法でそれっぽく真似はできるし、enum だけで十分な場合も多いが、たいていは本物の代数的データ型が持つ柔軟さと簡潔さに欠ける。何より、関数型言語で得られるすばらしい パターンマッチング がない
    この C 拡張は、求めていたパターンマッチングを備えているように見えて、かなり良さそうだし、Arduino プロジェクトで使えるか試してみたい
    • 代数的データ型とパターンマッチングを使ったことがない人は何がそんなにすごいのか理解できず、慣れている人でも、それがない言語で働くまでは何がすごいのかわからない
      知ったばかりの人は、パン以来最高の発明だと言って止まらなくなる :)
    • Java 21 の sealed interface でパターンマッチングが可能
    • Kotlin は JVM と互換性があり、代数的データ型もある
      Java には https://github.com/functionaljava/functionaljava があり、サポートは終了しているが安定している
    • 代数的データ型とパターンマッチングは、実は 命令型言語でも非常によく機能する。たとえば Rust
  • 製品をもう一度ゼロから実装するなら、コンパイラが強制する網羅的なパターンマッチングを備えた discriminated union は必須条件にするだろう
    強力すぎて、これなしではやっていけない
    • Dart でいちばん欲しいのは union type だが、残念ながら当面は追加されそうにない
      最近 sealed class 上でコンパイラが強制するパターンマッチングのサポートが入ったので、半分くらいは来たと言えそう
    • 今そういう説明に当てはまる言語って何があるんだろう? 正確に何を意味しているのかよくわからないが、サポートしている言語の例を見ればもっと理解しやすそう
  • たしかにより良さそうだし、おそらく以前自分が作った試みよりもうまく動きそうだが [1]、コード量は 8 倍で、すばらしいが少し怖い Metalang9 マクロツールキット に依存している
    代数的データ型が内部でどう動作するかを見たいなら、libsum は良い入門資料だと思う
    [1] https://github.com/naasking/libsum
    • そのリポジトリにスターを付けていたのを見ると、Datatype99 を設計するときに調べていた気がする :)
  • これは魔法使いの仕事だ
    C をほぼ 20 年触ってきたが、マクロシステムがこんな黒魔術を可能にするほど強力だとは思ってもみなかった
    本当にすごい
    • 著者はまだ 19歳。急に自分がバカみたいに感じる
    • 同じ著者が作った metalang99 も面白いかもしれない
    • x-macro、つまりこういうマクロの使い方はかなり派手だ
      伝統的には、型安全なジェネリクスを作ってアクセスしたり、ハードウェアレジスタや割り込み定義の重複コードを減らしたりするのに使われてきた
      少し呪われた感じはあるが、核心は実際とても単純で、C ベースのプロジェクトで認知的複雑さと重複コードを減らす信頼できる道具だ
    • 代数的データ型は大体、ジェネリックな struct と union の上にユニオンタグを付けた文字列置換のようなものだ
      マクロの使い方としては、そこまで複雑ではない
  • Wikipedia にこれに関連する興味深い内容がある。union を「オブジェクト指向プログラミングのクラス階層」として実装する方法だ
    https://en.wikipedia.org/wiki/Tagged_union#Class_hierarchies...
    同じ内容を扱った長いブログ記事もあるが、書き手はまだその Wikipedia セクションを見ていないように見える
    https://nandakumar.org/blog/2023/12/paradigms-in-disguise.ht...
    datatype99 の開発者が、こうしたその場しのぎの問題を README でいきなり示しているのは評価に値する
  • https://melt.cs.umn.edu/ もある。C に テンプレートと代数的データ型を追加する拡張がある
    https://github.com/melt-umn/ableC-template-algebraic-data-ty...
  • 「of と ifLet に渡す文の中ではトップレベルの break/continue を使わず、代わりに goto label を使え」というのは、かなり大きな 足を撃つ系の罠 に見える
    それでも、それ以外はとてもすばらしい
    • goto を使うこと自体は問題ではないが、そういうブロック内では break/continue を使ってはいけない ことを知っておく必要がある
      昔マクロで immediate mode UI を作ったことがあるが、それを思い出した。自分の場合は問題にならなかったが、一部のブロックでは "break" が使える
      たとえば win_form ブロックでは "break" で抜けられるし、"goto" も動く。一方で win_command ブロックは "break" を捕捉しないので、win_command の中で break や goto を使うと、その win_command を包んでいる外側のブロック、おそらく win_form ブロックを抜けることになる。普通は "Cancel" ボタンのような場合に使う
    • Rust のマクロまわりの良さは、こうした条件を検査するコードを書けるだけでなく、それが実際に現実的で想像しやすく、簡単に導入できる オープンソースライブラリ の助けも借りられることだ
      単にいくつかの点で少し優れているという話ではなく、エコシステム全体で時間とともに積み重なった数多くの小さな不便を滑らかに解消できる点にある。CPython 系に独自の語彙型が多すぎるとか、高性能コンピューティングライブラリ群にもそういう問題があるとか、そうしたものと同じで累積効果が大きい
      たとえば、このマクロでこうした問題を起こしている部分は、よく書かれた Rust マクロならそもそも問題にならなかったはずだ。賢い人たちが C の限界を回避しようとして生まれた副産物だ

ただし、このマクロ自体も元々Rustのネイティブ機能を移植したものなので、Rustではそもそも書く必要がなかったはずで、そのためコミュニティ製ソフトウェアでも自然に活用されている。

  • goto は関数から関数へ移動するために使うときにだけ自爆要素になるのであって、実際のところ "goto considered harmful" もその慣行を標的にしたものだった。
    そうした慣行は消え、今では関数内で使う goto はかなり無害で、実際 break / continue とほぼ同じ。
  • Cプログラムを書かなければならず、タグ付きunionに対して 完全なパターンマッチング が本当に必要だとしよう。Datatype99が提供するものも「簡単に言えばタグ付きunionの上のシンタックスシュガー」だ。
    そしてRustがあることもすでに知っているが、Cプログラムを書く人たちが誰もが知っている理由でRustを使わないとしよう。
    それでも少なくともZigは検討に値する。2日前にZigでこういうコードを書いた。
    comptimeinline else を使ってタグ付きunion上の switch 文の全分岐を生成し、"l" フィールドを持つunionメンバーにオフセットを加える。コンパイル時に分かる型情報であれば、分岐の性質をさまざまに変えられるし、その情報はかなり多い。すべての条件がコンパイル時に決定されるので、switch の各分岐はそのバリアントを処理するのに必要なロジックだけを持つ。
    「でも自分のプログラムはすでにCで、必要なのも1ファイルだけだ」というなら、それでもZigを試してみるといい。気に入るかもしれない。
  • struct + union + enum代数的データ型の利点 の大半は得られるのではないか? 複数の型のunionを置き、どれを選ぶかを区別するenumを付けるパターンを使ったことがある。
    std::variant も合計型のようにある程度は動作するように思える。
    唯一の問題は、フィールドの特定の値に合わせてきれいな switch 文を作れないことだが、入れ子の switch 文もそこまで汚くはない。
    • その通り。慣習と規律だけでもオブジェクト指向の多くの利点は得られるが、そうするにはたとえばvtableを手で扱わなければならず、しばしば実装の細部まで降りていく必要がある。
      このアプローチの問題は、あらゆる細部を漏らさず面倒を見る 精神的負荷 が大きいことにある。疲れてくると、新しいクラスを作る代わりに既存のクラスへ機能を無理やり押し込み始める。
      結局、抽象化を高くつくものだと感じるようになり、その結果、もっと優雅に問題を解けるのに抽象化をあまり使わなくなる。
      要するに、複雑な考えを参照するたびに長い説明を繰り返す代わりに、"Darmok and Jalad at Tanagra" と言える能力は変革的だ。
    • モデリングの側面は真似できる。だが、それは代数的データ型の利点の半分にもならない。
      パターンマッチング が大きな使い勝手上の利点だ。
    • 合計型の値を扱うときは、すべての選択肢を 検査したか確認する ことが絶対的に重要だ。
      マクロでやるのは難しいが、不可能ではない。基本的にはマッチ文脈を開始するマクロが必要で、検査したすべての選択肢を追跡する変数を導入し、マッチ文脈が終わるときに全選択肢を確認したか検査するよう構成する必要がある。
    • 本当に必要なときに大半のコードでC++を使うのは概ね問題ないが、std::variant はあまり好きではない。
      std::visit ですべてのケースを漏れなくマッチさせるやり方はハッキーに感じる。これが本当に 第一級の言語機能 になれば大きな利点があるはずだ。
      コルーチンのようにこれまで取り組まれてきた他の機能よりも、日常的なコードに与える影響は大きいかもしれない。
  • これを実装したのは本当にすごい。敬意を表する。