ADAPTについての説明はこちらへ。
ADAPTのGitHubリポジトリはこちらへ。
文字列からラムダ関数へ変換する機能の実装
Rttiのラムダ関数に限定されるが、文字列をパースしてラムダ関数に変換するParse関数を用意した。Rttiは静的型情報が削除されているので、こんな芸当も可能だ。というより、こんな芸当を最終的に実現することが、わざわざC++でありながら動的型のラムダ関数なんてものを用意していた理由の一つである。ADAPTの機能を何らかのアプリケーションに組み込む際に、ラムダ関数を外部から文字列パラメータで与えられるようにすることが目的だ。
DTree t; //...tへのデータ構造定義やデータ格納処理 using Lambda = eval::RttiFuncNode<DTree>; Lambda lambda1 = Parse(t, "a + mean_if(hypot(b, c), d % 2 == 0)"); Lambda lambda2 = Parse(t, "a - a.at(pos2() - 1i64)"); //上記は以下と同等。 //ADAPT_GET_PLACEHOLDERS(t, a, b, c, d); //Lambda lambda1 = a + mean_if(hypot(b, c), d % 2 == 0); //Lambda lambda2 = a - a.at(t.pos2() - (int64_t)1); Bpos bpos{ 1, 4, 3 }; double res = lambda(t, bpos).f64();
文字列式ラムダ関数の文法は通常の書き方とほとんど同じだが、少し異なるところがある。
- フィールドへのアクセスは通常ならプレースホルダを用いるが、文字列式ラムダ関数の場合はコンテナクラスの構造定義時に与えた名前をそのまま指定する。
- 連結コンテナ用は未実装。技術的に可能だが色々と調整が必要なので後回しにした。
- ユーザー定義関数は現時点で非対応。
- コンテナのメンバ関数として実装される関数(pos、size)は、メンバ関数としてではなく単なる一般関数のように呼び出す。
- 整数、浮動小数点リテラルはi16、f32のような接尾辞を付与することで型を調整できる。デフォルトではi32、f64になる。
なお、本機能はラムダ関数の保つ機能のほとんどすべてが組み込まれている関係で、ビルドが非常に重く、吐き出されるバイナリが巨大である。そのためこれの使い方は下記の選択肢を用意している。
使い方1. ヘッダオンリーライブラリとして使う
<OpenADAPT/Parser_impl.h>をincludeするだけでよい。ただしビルドは極めて遅いし重い。Ryzen 7 7700X + メモリ32GBのPC上でMSVCを用いてビルドしたときは何とかぎりぎり通る程度の状態だった。GCCだと失敗するかもしれない。
使い方2. 事前にビルドする
OpenADAPTのビルド時、cmakeコマンドに-DENABLE_PREBUILT_PARSER=ONというオプションを与えることで、DTree/DTable/DHistのためのParse関数を事前にビルドすることができる。-DPREBUILT_PARSER_TARGET=DTree;DHistのように、必要なコンテナの種類を指定してもよい。
この方法を取った場合、多数のソースコードにコンパイル単位を分割した上でビルドするので、標準的なPCでも時間はかかるがビルド可能な程度になっているはずだ。
出力ファイルが中間生成物を合わせて数GBに達してしまうので、サイズを削減したい場合は-DBUILD_SHARED_LIBS=ONとして共有ライブラリ化することも検討されたい。
これを使用する場合は、<OpenADAPT/Parser.h>をincludeしつつ、各ビルド成果物へ静的/動的にリンクすればよい。もしMSVCを使用しており、かつ-DBUILD_SHARED_LIBS=ONにより共有ライブラリとしてビルドした場合、includeよりも前に#define ADAPT_DLL_IMPORTと定義しておくことを推奨する1。
CMakeから使用する場合は以下のようにしても良い。
find_package(OpenADAPT REQUIRED) target_link_libraries(myproj PRIVATE OpenADAPT::Parser)
関数名整理
ラムダ関数内で使用できるsizeとcountという階層関数であるが、名前と実際の挙動との対応を整理するためにそれぞれcountall、count_ifに変更した。従来の名前はしばらくdeprecatedとして使用可能にしておくが、文字列式ラムダ関数では新しい名前しか使えない。
本当はcountallではなくcountにしたかったが、元々のcount関数と被ってしまうのは非常にまずいのでこのようにしている。とはいえ、既に後述する新しいsize関数があるので出番は多くないだろう。
ラムダ関数内で使用するsize、pos関数の追加
こちらのsize関数はコンテナクラスのメンバとして呼び出すsizeである。指定された要素に属している子要素数を返す。
これは殆どの場合、階層関数のcountall(上述のとおりsizeから名前を変更した)に同じ階層のフィールドを与えた場合と同等の結果を返す。
DTree t; auto lambda1 = t.size(2_layer);//現在指し示している1層要素に所属する2層要素の数を返す。 ADAPT_GET_PLACEHOLDERS(t, fld_layer2); assert(fld_layer2.GetLayer() == 2_layer); auto lambda2 = countall(fld_layer2);//指定された1層要素に属す全2層要素のfld_layer2に対してアクセスを試み、アクセスに成功した数を返す。 //lambda1とlambda2は計算過程が異なるが、殆どの場合同じ結果を返す。
違いは速度と、アクセス不能な何かがある場合の挙動、そして連結コンテナで未実装である点だ。
* 階層関数のsize関数は律儀に一つ一つの要素で引数部分の計算を試みながら数えるので動作が遅い。こちらはいちいち数えず単にstd::vector::sizeのように範囲を計算するだけなので高速である。
* 階層関数のsize関数は、引数として与えられた部分の計算を試み、何らかの理由でアクセスに失敗したり計算結果が不正となったりした場合にはその要素をカウントしないというちょっと特殊な振る舞いがある2。新size関数は単にコンテナの要素数を参照して返すだけなのでアクセスという概念がなく、そのような計算を実行できない。
* 新size関数は連結コンテナでは使用できない。というのも、挙動が直感とは異なるものになりかねないため3。
pos関数は現在位置を返す関数群である。
Bpos b{ 5, 10, 15 };
auto pos_layer0 = t.pos0();
auto pos_layer1 = t.pos1();
auto pos_layer2 = t.pos2();
//t.pos(N_layer)はt.posN()に等しい。
int64_t p0 = pos_layer0(t, b).i64(); // 5
int64_t p1 = pos_layer1(t, b).i64(); // 10
int64_t p2 = pos_layer2(t, b).i64(); // 15
ToVector、Extractなどのrange conversionによって一括操作する場合に現在位置を参照する手段が必要になることが往々にしてあったため、今更ながら設けた。
基本インデックス型をuint32_tからint64_tへ変更
もともとインデックス型はメモリ使用量削減のために歴史的に32bitとなっており、少しでも上限値を増やすためにunsignedにしていたのだが、ADAPTのフィールドにはunsigned型が存在しないという大きな矛盾点がありどうするか悩んでいた。最近は我々が扱うデータサイズも32bitの範囲を超えつつあることも踏まえ、今回、思い切って64bitに拡大することにした。
BinJointの追加
SHist/DHistの特定のビンに連結する方法。原理的にはKeyJointでも代用可能だが、Histの0層要素に連結する場合はこちらのほうが呼び出しがシンプルである。
//生徒の後期期末試験(exam == 3)の数学の点数をヒストグラム化 ADAPT_GET_PLACEHOLDERS(*m_tree, class_, number, name, exam, math, english); auto tree = *m_tree | Filter(exam == 3) | ADAPT_EXTRACT(class_, number, name, math); auto hist = *m_tree | Filter(exam == 3) | ADAPT_HIST(cast_f64(math).named("math"), 5., class_, number, name); //treeの2層(試験成績層)とhistの0層(ヒストグラムのビン)を連結 auto jt = Join(tree, 2_layer, 0_layer, hist); auto [class_r0, number_r0, name_r0, math_r0] = jt.GetPlaceholders<0>("class_", "number", "name", "math"); auto [class_r1, number_r1, name_r1, math_r1] = jt.GetPlaceholders<1>("class_", "number", "name", "math"); //連結対象にはtree側の数学成績が収まるであろうビンを指定 jt.SetBinJoint<1_rank>(cast_i32(math_r0 / 5.)); //数学の点数が一致する生徒の一覧を表示 jt | Filter(math_r0 == math_r1, !(name_r0 == name_r1 && number_r0 == number_r1)) | Show(class_r0, number_r0, name_r0, math_r0, class_r1, number_r1, name_r1, cast_i32(math_r1));
試験的なモジュールのサポート
遊び半分で、本ライブラリ(Parser部分を除く)をモジュールとして使用できるようにした。ただし、ADAPTをCMakeでインストールする際に-ENABLE_MODULE=ONとオプションを与える必要がある。さらに現時点でビルド可能なのはClang>=20とGCC>=15のみで、MSVCでは現状ビルド不能である。"sorry: not yet implemented"て……。
find_package(OpenADAPT REQUIRED) target_link_libraries(myproj PRIVATE OpenADAPT::Module)
//このあたりのヘッダは事前にincludeする必要がある。 #include <format> #include <iostream> #include <vector> #include <string> #include <cassert> #include <cmath> #include <complex> #include <ranges> #include <random> #include <filesystem> #include <thread> //モジュールからはマクロをimportできないので、独立にincludeする。 #include <OpenADAPT/Macros.h> import adapt;
ものは試しと色々頑張って使えるようにしてみたものの、私にはほぼ何の恩恵もなかったので今後サポートし続けるかは不明である。
与太話
文字列からラムダ関数を作る機能って誰が使うの、と甚だ疑問に思うような更新内容だが、私が使うのである。ADAPT v1以前にはそもそもこちらの文字列式から生成するラムダ関数機能しかなかった。というのもADAPTは本来、パラメータファイルなどから文字列情報として計算式を受け取ることを念頭に開発されていたのだ。これはADAPTが昔私が開発していたいくつかの研究用GUIアプリケーションのベースライブラリとなっており、JSONないしYAMLファイルで挙動を記述する必要があったので、そのような設計にならざるを得なかったためである。
OpenADAPTの開発に当たっては、それらの研究用アプリケーションは概ね安定動作し喫緊の開発課題でなくなっていたことで、一旦文字列からのラムダ関数機能は捨て去ることにして、パーサーを端折った実装にした。データ分析ライブラリとしてはこちらのほうが使い勝手が良かったのはまあ、うん。
ただ、諸般の事情でそろそろ上記研究用アプリケーションをアップデートしたいと思い始め、そのためにはOpenADAPTにも文字列式パーサーを実装しなくてはならず、二年越しに重い腰を上げたのである。
この研究用アプリケーションのうち一つは3Dデータビューアだ。主として我々が使用する検出器の情報を3Dで可視化するために作成したGUIアプリケーションであるが、一応どのようなデータでも扱える汎用設計になっており、その肝となっていたのがこの文字列式パーサーだった。これにより、生データをどのような三次元情報に変換するかをパラメータとして記述させていたのだ。
何もかもが思惑通りに進んだ場合、いずれこのツールもオープンソースで公開するかもしれない。それこそ誰が使うの、という話にはなってしまうけれども。
ちなみに今回、パーサーのコードの大半はGitHub CopilotのAgentモードを使用して生成させた。私もAIによるコーディングは色々と試してきたのだが今の今まで上手く行った試しがなく、単純な反復コードを予測させる以外に活用することはほとんどなかった。しかしAgentモードでは自身でテストプログラムを作成、ビルド、実行、エラー修正を反復してくれるようになり大幅に精度が向上したので、これなら実用的だと思い取り入れてみることにした。
尤も、ある程度コードが複雑化すると精度は頭打ちになった。こちらが作れといった機能を作らず保留したり、一度修正させた箇所をわざわざ元の愚劣なコードに戻したり、ライブラリの構造を理解しきれず意味不明な呼び出しを試みたり、過去の記憶をすっかり忘却して同じミスを何度も何度も繰り返したり、速度以外は人間の足元にも及ばない状態ではあった。よって、精度が頭打ちになったあたりでAIに修正させるのを止め、この時点での出力を叩き台として私が大幅な修正を施した。
現時点ではそれほど大規模でないコードの叩き台を作らせるくらいの役割が精一杯であろう。一方で自分では思いつかなかった効果的な実装を提案してくれる場合もあるなど、時間短縮以上のメリットも感じられた。凄まじく仕事が早く思考がフラットである代わりに理解力と記憶力が極めて悪い、アンバランスな部下ができたと思えば、使いようによっては大幅な作業効率化が果たせそうではあった。
……Gitの履歴やGitHubのContributorsにCopilotが載ってしまうのは、ちょっと気持ち悪いなぁと思わなくもないけれども、現状解決方法がなさそうだったので諦めることにした。まあ実際に貢献してくれているのだから、人ではないという理由で排除するのはよくないかもしれない。いや別にAIに人格を認めたりする気はないのだが。