std::variantを使ってみた

C++はC++03までしか知らなくて、最近になってC++11以降を触っています。Ubuntu 20.04のgccは9.3なのでデフォルトだとC++14になりますが、今日はC++17で追加された std::variant を使ってみました。

std::variantとは

複数のクラスのうちどれかのインスタンスが入っているけど、どれかが分からない変数を表しているのが std::variant です。

例えば…

例えばこんなことが出来ます。

#include <variant>
#include <iostream>

int main() {
    std::variant<int, const char*> v[] = {1, 2, "3", 4};
    std::cout << std::get<int>(v[0]) << std::endl;
    std::cout << std::get<int>(v[1]) << std::endl;
    std::cout << std::get<const char*>(v[2]) << std::endl;
    std::cout << std::get<int>(v[3]) << std::endl;
    return 0;
}

この例では intconst char* が入っている std::variant 型の配列を表示しています。ただこういうのはループで処理したいものですね。

ループにすると…

#include <variant>
#include <iostream>

int main() {
    std::variant<int, const char*> v[] = {1, 2, "3", 4};
    for (const auto& e: v) {
        std::visit([](const auto& x){
            std::cout << x << std::endl;
        }, e);
    }
    return 0;
}

出力

1
2
3
4

こんな感じになります。キャストがなくなってすっきりしていますが、これは std::visit のおかげです。中に入っているラムダ式の x の型が自動で実際の値の型になります。std::cout<< 演算子が int 引数にも const char* にも同じように対応しているためにこの書き方が出来ます。

余談: std::visit のラムダ式の型確認

#include <variant>
#include <iostream>

int main() {
    std::variant<int, const char*> v[] = {1, 2, "3", 4};
    for (const auto& e: v) {
        std::visit([](const auto& x){
            std::cout << typeid(x).name() << std::endl;
        }, e);
    }
    return 0;
}

出力

i
i
PKc
i

サブタイプ多相との比較

先の std::visit を使用した方法はアドホック多相と分類される形ですが、継承/派生を利用したサブタイプ多相でも同じことが出来ます。

これ継承(サブタイプ多相)でいいのでは?

結局 std::cout<< 演算子が同じ形のオーバーロードを持っているから共通化できているわけで、共通化できているなら virtual で抽象化してあげればいいのでは?と考えられます。

#include <variant>
#include <iostream>
#include <functional>

template<class C, class T = std::char_traits<C>>
class Writable {
public:
    virtual void write_to(std::basic_ostream<C,T>& out) = 0;
};

template<class V>
class TypeBox {
public:
    V value;
    TypeBox(const V& v): value(v) {}
};

template<class V, class C, class T = std::char_traits<C>>
class WritableTypeBox : public TypeBox<V>, public Writable<C,T> {
public:
    WritableTypeBox(const V& v): TypeBox<V>(v) {}
    void write_to(std::basic_ostream<C,T>& out) {
        out << this->value << std::endl;
    }
};

int main() {
    WritableTypeBox<int, char> v1 = 1;
    WritableTypeBox<int, char> v2 = 2;
    WritableTypeBox<const char*, char> v3 = "3";
    WritableTypeBox<int, char> v4 = 4;
    std::reference_wrapper<Writable<char>> v[] = {v1, v2, v3, v4};
    for (const auto& e: v) {
        e.get().write_to(std::cout);
    }
    return 0;
}

少しゴチャゴチャして操作対象のコンテナとインスタンスを保管する変数が分離しました。見た目の構造は少しゴテゴテしてるものの、実際には単純そうで、少しコードの目方が増えてしまっている印象ですね。実際どちらの方が扱いやすいのかよく分かりません。

迷ったら時間計測

#include <variant>
#include <iostream>
#include <vector>
#include <functional>
#include <chrono>
#include <string>

template<class C, class T = std::char_traits<C>>
class Writable {
public:
    virtual void write_to(std::basic_ostream<C,T>& out) = 0;
};

template<class V>
class TypeBox {
public:
    V value;
    TypeBox(const V& v): value(v) {}
};

template<class V, class C, class T = std::char_traits<C>>
class WritableTypeBox : public TypeBox<V>, public Writable<C,T> {
public:
    WritableTypeBox(const V& v): TypeBox<V>(v) {}
    void write_to(std::basic_ostream<C,T>& out) {
        out << this->value << "\n";
    }
};

template<class Unit=std::chrono::milliseconds>
long long measure(std::function<void()> fn) {
    const auto base = std::chrono::high_resolution_clock::now();
    fn();
    return std::chrono::duration_cast<Unit>(std::chrono::high_resolution_clock::now() - base).count();
}

int main() {
    const int COUNT = 1000000;
    std::vector<std::string> vec_org(COUNT, "1");

    std::cerr << measure([&](){
        std::vector<std::variant<int, std::string>> v(vec_org.begin(), vec_org.end());
        for (const auto& e: v) {
            std::visit([&](const auto& x){
                std::cout << x << "\n";
            }, e);
        }
    }) << std::endl;

    std::cerr << measure([&](){
        std::vector<WritableTypeBox<std::string, char>> superbox(vec_org.begin(), vec_org.end());
        std::vector<std::reference_wrapper<Writable<char>>> v(superbox.begin(), superbox.end());
        for (const auto& e: v) {
            e.get().write_to(std::cout);
        }
    }) << std::endl;
    return 0;
}

時間測定をするので、重たい出力処理は避けたかったのですが、それがメインなので今回はそのまま。ただし、std::endlflush を伴うので使用せず、"\n" に置き換えています。

g++9.3で-std=c++17 -O2オプション付けてコンパイルし、標準出力を/dev/nullに捨てた結果、どちらも74~82ms程度の値になり、有為な差は見つかりませんでした。

結論

好きなのを使いましょう。ただしネット上には std::variant 遅いという声もチラホラ。今回は重たいストリーム処理で比較しちゃったし、もっと実験した方がいいかもですね。

注意事項

今回私が std::variant を使用して、一点だけハマった点があるので紹介しておきます。以下のリンクは以降の記述で参考にさせてもらった資料です。

c++ – Can’t stream std::endl with overloaded operator<<() for std::variant – Stack Overflow
https://stackoverflow.com/questions/52845621/cant-stream-stdendl-with-overloaded-operator-for-stdvariant

c++ – Unmangling the result of std::type_info::name – Stack Overflow
https://stackoverflow.com/questions/281818/unmangling-the-result-of-stdtype-infoname

可変長テンプレート引数の照合について

#include <iostream>
#include <typeinfo>
#include <cxxabi.h>

const std::string demangle(const char *name) {
    int status = -4;
    char *result = abi::__cxa_demangle(name, NULL, NULL, &status);
    const char *const demangled_name = (status == 0) ? result : name;
    std::string return_value(demangled_name);
    std::free(result);
    return return_value;
}

template <class... Args>
class Dummy {};

template <class... Args>
void print(const Dummy<Args...>& t) {
    std::cout << __PRETTY_FUNCTION__ << std::endl;
    std::cout << demangle(typeid(t).name()) << std::endl;
}

int main() {
    print({});
    return 0;
}

出力

void print(const Dummy<Args ...>&) [with Args = {}]
Dummy<>

ちょっとややこしいのですが、ここでは print という関数に空の初期化リストを渡して呼び出しています。

print 関数ではまず __PRETTY_FUNCTION__ というgccの固有マクロを使って関数の形を文字列で表示しています。このときテンプレートパラメータも明示されるのですが、ここでは可変長引数が空で指定されてる事がわかります。

次に引数で渡された t の型を文字列に変換して出力しています。これは Dummy<> なので、実際に可変長引数が空で指定されてることがわかります。

C++では一定のルールで可変長テンプレート引数を決められないと判断された場合、テンプレート引数が空の状態で適用されるというルールがあり、今回空の初期化リストでは 可変長テンプレート引数を決められないと判断され、Dummy<> になっています。Dummy のコンストラクタはデフォルトなので、空の初期化リストでそのまま構築され、構築された Dummy<> インスタンスの参照が t に入ることになります。

ここで重要なのは、可変長テンプレート引数を決められないと判断された場合、(他の適合するオーバーロードされた関数があっても)使用されないのではなく、Dummy<> で使用されるという点です。

実際に起きた現象

#include <iostream>
#include <variant>

template<class C, class T = std::char_traits<C>, class... Args>
std::basic_ostream<C,T>& operator<<(std::basic_ostream<C,T>& out, const std::variant<Args...>& v) {
    std::visit([&](const auto& x){out << x;}, v);
    return out;
}

int main() {
    std::cout << std::variant<int, const char*>{"hoge"} << std::endl;
    return 0;
}

上記をコンパイルすると下記のようなエラーが起きます。

...
/usr/include/c++/9/variant: In instantiation of ‘class std::variant<>’:
/home/user/cpp/variant/sample13.cpp:11:65:   required from here
/usr/include/c++/9/variant:1239:39: error: static assertion failed: variant must have at least one alternative
 1239 |       static_assert(sizeof...(_Types) > 0,
      |                     ~~~~~~~~~~~~~~~~~~^~~
...

2番目のエラー部分だけ出していますが、std::variant のテンプレート引数が空になっていることが分かります。そしてこのエラーを引き起こしたのはsample.cpp:11:65 ということで、これは std::endl の部分の出力した時にエラーになっていることを示しています。

つまり本来 std::endl などのマニピュレータ関数を処理する << 演算子のオーバーロード関数ではなく、なぜかここで実装した std::variant 用の << 演算子のオーバーロード関数が呼ばれてエラーになっているということです。

これは前節で説明した、可変長テンプレート引数を決められないと判断された場合に std::endl が該当したからで、そのために std::variant<> がインスタンス化され、テンプレート引数がない std::variant という何の役にも立たないクラスであることを検出されて static_assert が失敗してエラーになっているということです。

他にもエラーは出ていますが、そもそも可変長テンプレート引数以前の部分でstd::variant用のオーバーロードが弾かれるように実装する必要があるので、そこを念頭に対処します。

対策

可変長テンプレート引数を先頭とそれ以外に分けてしまいます。

#include <iostream>
#include <variant>

template<class C, class T = std::char_traits<C>, class Arg0, class... ArgsRest>
std::basic_ostream<C,T>& operator<<(std::basic_ostream<C,T>& out, const std::variant<Arg0, ArgsRest...>& v) {
    std::visit([&](const auto& x){out << x;}, v);
    return out;
}

int main() {
    std::cout << std::variant<int, const char*>{"hoge"} << std::endl;
    return 0;
}

こうすることで、そもそも Arg0 が決められないために可変長引数以外の部分で弾かれてこのオーバーロードは std::endl で使用されず、エラーもなくなります。

出力

hoge

結論: std::variantの可変長テンプレート引数は先頭とそれ以外に分けよ!

まとめ

  • std::variantを使うと、安全に複数の型をアドホック多相で使用できる
  • 今回の実験では最適化時、サブタイプ多相とも有為な速度差はなかった
  • std::variantで可変長テンプレート引数を使う場合は先頭とそれ以外に分ける

未分類C++,linux

Posted by first_user