われがわログ

最適化アルゴリズムとかプログラミングについて書きたい

OmegaConfの便利機能をC++でも使いたいので実装した

成果物

github.com

背景

OmegaConfは、設定ファイルを扱うためのPythonライブラリ。

OmegaConf — OmegaConf 2.1.0 documentation

詳細はこのブログが詳しいが、自分が特に便利だと感じた機能は次の2つ。

  1. Variable interpolation
  2. Custom resolvers

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 説明

GitHub - estshorter/tomlex

実装したtomlexは、toml11をベースとしたライブラリであり、OmegaConfのVariable interpolation、Custom resolversの「ような」機能を実装している。C++17以降のみ対応。

典型的な使い方は以下の通り。tomlex::parseにファイルパスを渡すか、toml::parseで得られたtoml::valuetomlex::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_flt1conv_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);