可変長テンプレートでもstd::source_locationを使いたい!

この記事はC++アドベントカレンダー2021の記事です。

qiita.com

小ネタですが今日の分が埋まっていなかったので。

std::source_locationとは

std::source_locationは、その名の通りソースコード中の位置を表す情報が入った構造体です。C++20以降で使うことができ、<source_location>で定義されています。

https://cpprefjp.github.io/reference/source_location/source_location.html

__FILE____LINE__モダンないいかんじのバージョンだ、という認識でだいたいOKです。

この構造体は普通、std::source_location::current()で作ります。この関数は呼ばれた位置を示すstd::source_locationを返します。デフォルトコンストラクタもありますが、コンテナに入れるときに気を使わなくていいくらいの用途しかないと思います。

明示的にstd::source_location::current()を呼ぶのは書くのも意識するのも面倒なので、ほとんどの場合において、これは関数のデフォルト引数として使います。この機能のいいところは、それでも意図した通りに動くところです。

void f(std::source_location loc = std::source_location::current()) {
    std::cout << "in function " << loc.function_name()
        << " at " << loc.file_name() << ":"
        << loc.line() << ":" << loc.column() << std::endl;
}

f(/*この中でcurrent()が呼ばれるので、この位置が出力される*/);

ログ取りに便利ですね。

この構造体はコピー構築・コピー代入ができるので、持ちまわることもできます。たとえば

class X {
  public:
    X(std::source_location loc = std::source_location::current())
        : loc_(loc)
    {}
  private:
    std::source_location loc_;
};

のようにしておくと、Xを構築した箇所の情報を追跡することができます。

問題

上記のようにsource_locationはデフォルト引数として使われることが多い、というか、あまり手動で構築する機会はありません。ですが、デフォルト引数が使えない場所というのはあります。可変引数テンプレートです。

まず、デフォルト引数は省略される可能性があるため、前にも後ろにもあるとどの引数がどれに対応するのかあいまいになります。なので省略不可能な引数は全て、省略可能なものの前に定義しなければならないと決まっています。

int f(int a = 10, int b, int c = 30); // error!
f(10, 20); // a = 10 ? b = 10 ?

可変引数テンプレートは、任意個の引数にマッチすることができるため、始まって以降全ての引数を吸い尽くします。つまり、可変引数テンプレートの後に何かを明示的に渡すことはできません。1

template<typename ... T>
int f(T&& ... args, double x); // error!

よって、デフォルト引数はそれ以外の引数よりも後ろに置かなければならないものの、可変引数テンプレートはそれ以外の引数よりも後ろに置かなければならない、というわけでこの二つは競合します。

template<typename ... T>
int f(T&& ... args, // error!
      std::source_location loc = std::source_location::current());

ですが、特にログなどの用途では可変引数テンプレートは便利です。なんとかこれらを同時に使えないものでしょうか。

解決方法

今回の問題は、可変引数テンプレートとデフォルト引数が同じレイヤーに登場していることに起因しています。方針としては、この二つが登場するレイヤーを分ければよいということになります。

そこで、「関数のように見えるが実は違うもの」として自然にクラスを思い浮かべます。ですが、クラスのoperator()だと、クラスの初期化が終わっていなければなりません。クラスの初期化が終わっているということは、クラステンプレートは実体化されているということです。これだとコンストラクタ呼び出しのために余計な括弧が必要ですし、可変引数テンプレートもコンストラクタ呼び出し時に明示的に与えなければなりません。

なら、コンストラクタ呼び出しを使うのはどうでしょう。これならとりあえず第一の問題、コンストラクタのための余計な括弧が必要になることは解決し、見た目上は普通の関数にしか見えなくなります。

さらに、クラスのテンプレート引数に可変引数テンプレートを押しやっておけば、コンストラクタ内では引数のリストが既知になります。するとコンストラクタでデフォルト引数を使えるようになり、可変引数テンプレートとデフォルト引数が競合することはなくなります。

template<typename ... Args>
class f {
    // ここではすでにArgs...は既知(f<Args...>のコンストラクタだから)
    f(Args ... args, std::source_location loc = std::source_location::current()); // OK!
};

これを関数であるかのように書けるよう、(自明な)推論補助を書きます。コンストラクタがクラスのテンプレートパラメータ型の値を直接受け取っているのでなくてもいけるやろと思ったら無理でした。

template<typename ... Args>
f(Args...) -> f<Args...>

ここではデフォルト引数は書きません。2

このようにすると、以下のコードが通ります。

f(42, 3.14, "foobar"s);

これは、あたかも関数呼び出しに見えますが、実際にはf<int, double, std::string>のコンストラクタを呼んで、できたfインスタンスを即捨てるコードです。この時点で、コンストラクタに副作用を持たせることによって副作用のあるvoidを返す関数は実現できます。

では値を返せるようにしましょう。受け取ったものから必要なものを計算して、メンバとして持ちます。どうせfがクラスであることを知っているのは我々だけなので、暗黙の型変換ができるようにしましょう。

template<typename ... Args>
class f {
    f(Args ... args, std::source_location loc = std::source_location::current())
      : result(do_something(args...))
    {
        std::cout << "at " << loc.line() << ":" << loc.column() << std::endl;
    }
    operator int() const noexcept {return this->result;}
  private:
    int result;
};
template<typename ... Args>
f(Args...) -> f<Args...>

const int a = f(42, 3.14, "foobar"s);

というわけで、可変引数テンプレート関数でstd::source_locationを使っているように見せかけるコードでした。もし返り値をautoで受けられるとこのfがそのまま受け取られるのでバレないように祈る必要が出てきます。あとコピーが重いものは適切にmoveしたりするなんかうまくやる必要があります。

また、代入時の変換は継承でも実現可能です。もしテンプレート周りでややこしくなっていたら試してみるのも手でしょう。

書く前に10秒だけ調べたら新規性がなかったようですが( https://stackoverflow.com/questions/57547273/how-to-use-source-location-in-a-variadic-template-function )、深く考えないことにしました。

ほかにいくつか試しましたが、これはというものは考え付きませんでした3。なんかもっといいアイデアはないもんでしょうか。

限定付きのよりよい解決策

例えば、関数の第一引数が確定している場合は、その第一引数を適当にラップすることで実装可能です。例えば、ログ用の関数でlog_levelを取ることにしましょう。

enum class log_level {debug, info, warn, error, fatal};

// invalid
template<typename ... Args>
void log(log_level, Args... args, std::source_location loc = std::source_location::current())

log_levelsource_locationを持つ適当な構造体を作って、そのコンストラクタでcurrent()を取ることにします。

struct log_level_with_location {
    log_level_with_location(log_level l,
        std::source_location loc = std::source_location::current())
        : lv(l), loc(loc)
    {}
    log_level lv;
    std::source_location loc;
};
template<typename ... Args>
void log(log_level_with_location lv, Args... args);

こうすれば、log(log_level::info, args...)の呼び出しの際にlog_levelからlog_level_with_locationへの変換が発生して、log_level_with_locationのコンストラクタでsource_location::current()が呼ばれます。このコンストラクタはlog(lv, args...)呼び出し時に呼ばれるので、log(lv, args...)時点での位置が取得できます。

もし第一引数が決まっているのなら、これがいいですね。


  1. 一応、メタプログラミングによって最後(からN番目)の引数だけを取り出して何かをすることは可能です(参考→ https://in-neuro.hatenablog.com/entry/2020/05/24/171047 )。このようにすれば、「最後の要素に特定のものを渡しているかどうかチェック」して「最後の要素を特別扱いする」ことが可能ですが、今回はデフォルト引数を渡したいので、これではうまくいきません。関数のdeclarationの時点でデフォルト引数を書く方法が必要です。

  2. 昔@onihusube9さんに教えていただきました → https://twitter.com/onihusube9/status/1447973137916710917?s=20

  3. 思いつくままに検討した方法ですが、lambdaの初期化captureだとlambdaの宣言箇所に、非型template引数ではtemplateの箇所になってしまうので、うまくいきません(templateは実体化された箇所になってくれないかと期待していましたが……)。すべての可変長引数Tに対してstd::source_locationTを持つ構造体value_with_location<T>を考えて、それをTから作るコンストラクタでstd::source_location::current()を呼ぶというごり押しは通りそうですが、value_with_location<T>Tの推論がうまくいきません。だからといって型消去をすると型を戻せなくなり、詰みます。