OmegaConfの便利機能をC++でも使いたいので実装した
成果物
背景
OmegaConfは、設定ファイルを扱うためのPythonライブラリ。
OmegaConf — OmegaConf 2.1.0 documentation
詳細はこのブログが詳しいが、自分が特に便利だと感じた機能は次の2つ。
Variable interpolationは他のデータを参照できる機能で、数値計算だと例えば以下のように、出力ファイル名を他のパラメータに応じて変更できる。
job_id: "TEST" param: 10 output_filepath: "output_${job_id}_${param}.bin" # "output_TEST_10.bin"
出力ファイル名にパラメータやIDを表すprefix/suffixをつけることはよくあることだが、OmegaConfではそれをyamlファイル中に記載できる。
Custom resolversは、プログラム中で定義した関数の実行結果を値とするものだ。例えば、配列として渡された値を"_"で連結する関数をOmegaConfへ登録したい場合は下記のようになる。
OmegaConf.register_new_resolver("join", lambda x: "_".join(x))
すると、yamlで以下のような構文が使える。
output_filepath: "output_${join: 0,10,20}.bin" # "output_0_10_20.bin"
業務がPythonで完結するならOmegaConfを使っていればよいのだが、残念なことにC++を使った業務があり、OmegaConfの上記機能が使いたくなったので実装した、というのが背景である。
tomlex 説明
実装したtomlexは、toml11をベースとしたライブラリであり、OmegaConfのVariable interpolation、Custom resolversの「ような」機能を実装している。C++17以降のみ対応。
典型的な使い方は以下の通り。tomlex::parse
にファイルパスを渡すか、toml::parse
で得られたtoml::value
をtomlex::resolve
に渡せばよい。そうすると、"${}"で囲った部分が全て展開される。
#include <iostream> #include <string> #include <toml.hpp> #include <tomlex/tomlex.hpp> #include <tomlex/resolvers.hpp> toml::value no_op(toml::value && args) { return std::move(args); }; toml::value join(toml::value && args, std::string const& sep = "_") { switch (args.type()) { case toml::value_t::array: { auto& array_ = args.as_array(); std::ostringstream oss; for (int i = 0; i < array_.size() - 1; i++) { oss << tomlex::detail::to_string(array_[i]) << sep; } oss << tomlex::detail::to_string(array_[array_.size() - 1]); return oss.str(); } default: return std::move(args); } } int main(void) { std::string filename = "example.toml"; tomlex::register_resolver("join", [](toml::value && args) { return join(std::move(args)); }); tomlex::register_resolver("no_op", no_op); toml::value cfg = tomlex::parse(filename); std::cout << cfg << std::endl; //こっちでもOK //toml::value cfg = toml::parse(filename); //cfg = tomlex::resolve(std::move(cfg)); tomlex::clear_resolvers(); }
tomlexのVariable interpolationはOmegaConfとほぼ同じ。 右辺にある文字列において、参照先を${}で囲って参照する。参照のフォーマットはdotted key。クォーテーションでの囲みやドットの間のスペースには対応しない。 また、相対パスにも対応していない。常に絶対パスを用いること。
job_id = "TEST" param = 10 output_filepath = "output_${job_id}_${param}.bin" # "output_TEST_10.bin" test = "${a.b.c.d}" # 10: int #test = "${ a.b.c.d }" # 前後のスペースはOK #test = "${a. b.c.d}" # NG #test = "${'a.b.c.d'}" # NG [a.b.c] d = 10 # e = "${.d}" # 相対パスはNG
文字列が"${EXPR}"のように"${"で始まって"}"で終わっているなら、展開後の型は参照先の型と同じとなる。それ以外は文字列になる。
test2 = "${param}0" # "100": string
tomlexのCustom resolversは、関数の引数を表すフォーマットがtoml記法である(tomlのvalueとして正しいフォーマットである)必要がある。 というのも、この引数の文字列がtomlのパーサで読み込まれ、適切な型に変換されるからだ。
output_filepath = "output_${join: [10,20,30]}.bin" # "output_0_10_20.bin" #output_filepath = "output_${ join : [10,20,30] }.bin" # 前後のスペースはOK #output_filepath = "output_${join: 10,20}.bin" # NG
3つ目のoutput_filepath
では引数が10,20
であり、これはtoml valueとしては不正であるため、例外が投げられる。引数にtomlフォーマットを強制している点はOmegaConfとは異なるので注意。
また、本ライブラリは、"${EXPR}"を、展開後の文字列で置換していく(置換時に型情報が消える)という実装になっているため、Custom resolversを使う場合には引数の型に注意すること。以下に幾つか例を示す。
flt1 = 7.0 str1 = "7.0" # be careful of argument type conv_flt1 = "${no_op: ${flt1}}" # ${no_op: 7.0} -> 7.0: double conv_str1 = "${no_op: ${str1}}" # ${no_op: 7.0} -> 7.0: double
conv_flt1
、conv_str1
は、no_op
の引数を展開した後にどちらも${no_op: 7.0}
となってしまう。tomlのパーサは7.0をdouble型とみなすため、最終結果は等しくdouble型の7.0になる。
引数を文字列として扱いたい場合は、引数を明示的にクォーテーションで囲むこと。
conv_str2 = '${no_op: "${str1}"}' # ${no_op: "7.0"} -> "7.0": str
なお、interpolationと同じく、"${EXPR}"のように"\${"で始まって"$}"で終わっているなら、展開後の型は関数の戻り値と同じ型となる。それ以外は文字列。
ref_flt1 = "${no_op: ${flt1}}0" # "7.00": string
正直、ここら辺の仕様は自分でも微妙だと思っている。しかし、これを直そうとすると、おそらく自分でパーサーを書かねばならず、面倒そうなので仕様としまっている。なお、OmegaConfではこのようなことは起こらない。
ちなみに、参照先が文字列の場合はクォーテーションをつければよいのでは?と思うかもしれないが、それを単純に実装すると、以下の場合におかしな結果になる
param_str = "10" test = "${param_str}0" # '"10"0': string
本当は"100"が欲しいのだろうが、'"10"0'のような文字列になってしまう。なので、"${}"の前後の文字からクォーテーションの挿入可否を判定する必要があり、そのためのパーサーが必要なのだ。
追記
OmegaConfのfrom_cli
相当の機能も実装した。
auto cfg = tomlex::from_cli(argc, argv);