制限解除union

しばらくブログを書いていなかったが、実際のところペーパーワークに駆られて(現在進行形で駆られている)コードをほぼ書けていない。そのストレスで日曜日に何の関係もないコードをゴリゴリ書いていた。そこでタイトルにあるものを使って、ボンミスで時間を飛ばしてしまった。

(2017年5月18日)以下の記事中に登場する私のコードに誤りがあると思われるので、それに関する記事を書いた。

erratum: 前回の記事に関して - in neuro

C++11でunionの制限がかなり解除された。以下の記事に詳しい。

www.kmc.gr.jp

cpprefjp.github.io

で、これを使って以下のようなクラスを書くことにしたのだ。目的は、動的型付けなフォーマットのファイルをパースすることである。以前にTOMLのパーサを作ったが、少し不満な点があったので、今C++98でも使えるという制限を解除して書いたらどうなるかと思って試してみた。あのコードは過不足なく動くのだが、使っていくうちに少し遅いと感じたり、また少し不便だと思ったところがあるのと、パーサそのものの実装をもう少し綺麗にできないかと感じているので、記事にしあぐねている間に別の実装をやり始めてしかもそこでの経験を記事にしてしまった。

C++11が使えるようになったのなら、そして複数の型を持てるようなvalueを定義するなら、unionの出番であろう。初期化にはplacement newが、破棄するときには明示的デストラクタ呼び出しが必要だが、毎回の読み出しにポインタをたぐらなくてよい分高速化が見込めるのではないだろうか、と考えてのことだ(計測していないのでどうか知らない)。

というわけで簡略化したコード。

enum class value_t
{
    integer,
    string,
    array,
    empty,
};

まず、型を示すenumを定義する。これは別にunionを持つクラス内でやってもいいのだが、今回は外で定義する。キャストのtemplate parameterとして使おうと思ったからだ。

そして、ある型が何に対応するかを取得できる関数を作る。

template<typename T>
constexpr value_t check_type()
{
    return std::is_integral<typename std::decay<T>::type>::value ? value_t::integer :
           std::is_same<typename std::decay<T>::type, std::string>::value ? value_t::string:
           std::is_same<typename std::decay<T>::type, std::vector<value>>::value ? value_t::array:
           value_t::empty;
}

template<typename T>
struct value_traits
{
    constexpr static value_t type_index = check_type<T>();
    constexpr static bool is_value_type = type_index != value_t::empty;
};

これ別に関数じゃなくてもトレイト内に直接作れる気が今してきた

で、本体だが、無名unionと型のenumを持つことにする。インターフェースとしては、デフォルトctor/dtorと、コピー・ムーブctor、コピー・ムーブ演算子、加えて値を直接渡せるコンストラクタ、v.cast<value_t::Integer>()のようにして参照を取り出せるアクセサ、別の型でも再代入可能な代入演算子あたりがほしい。

value::cast()はテンプレート引数によって返す型が異なるので、その変換のためのヘルパーが必要だ。これは単に構造体を特殊化していくと作れる。type generator的なものだ。

template<value_t> struct default_value_type{};
template<> struct default_value_type<value_t::integer>
{
    typedef int type;
}
template<> struct default_value_type<value_t::string>
{
    typedef std::string type;
}
template<> struct default_value_type<value_t::array>
{
    typedef std::vector<value> type;
}

で、本体の宣言。

class value
{
  public:
    value();
    ~value();
    value(value const&);
    value(value&&);
    value& operator=(value const&);
    value& operator=(value&&);

    template<typename T, std::enable_if<
        value_traits<T>::is_value_type, std::nullptr_t>::type = nullptr>
    value(T&& v);

    template<typename T, std::enable_if<
        value_traits<T>::is_value_type, std::nullptr_t>::type = nullptr>
    value& operator=(T&& v);

    value_t type() const {return type_;}

    template<value_t type_index>
    typename default_value_type<type_index>::type&       cast();
    template<value_t type_index>
    typename default_value_type<type_index>::type const& cast() const;

  private:

    void switch_clean();

    template<value_t type_index, typename T>
    void switch_assign(T&& val);

    template<value_t>
    struct switch_cast;

  private:
    value_t type_;
    union {
        int integer_;
        std::string string_;
        std::vector<value> array_;
    };
};

さっき指定していない関数が3つある。その中ではswitch_castを作った理由が一番簡単だ。これはcast関数が返す型をswitchできるようにしている。実装は、

template<> struct value::switch_cast<value_t::Integer>
{
    static int &      invoke(value& v)       {return v.integer_;}
    static int const& invoke(value const& v) {return v.integer_;}
};
template<> struct value::switch_cast<value_t::String>
{
    static std::string&       invoke(value& v)       {return v.string_;}
    static std::string const& invoke(value const& v) {return v.string_;}
};
template<> struct value::switch_cast<value_t::Array>
{
    static std::vector<value>&       invoke(value& v)       {return v.array_;}
    static std::vector<value> cosnt& invoke(value const& v) {return v.array_;}
};

実装だが、unionを使うときには注意点がある。switch_cleanswitch_assignを作った理由はそれだ。その注意点とは、自明でない特殊メンバ関数(ctor/dtorなど)を持つ(非静的)メンバがunionにあると、特殊メンバ関数が暗黙定義されなくなるのだ。なので、これは定義してやる必要がある。さらに、オブジェクトはplacement newで構築する必要があり、またunionを破棄する前に明示的にdtorを呼んでやらねばならない。というのも、unionからしてみれば何が入っているかも知らないのに自動で正しいデストラクタを呼べというのは無茶ぶりであるので、こちらが管理してやらねばならないというのは頷ける。

というわけで以下のような実装になる。

inline void value::switch_clean()
{
    switch(this->type_)
    {
        case type::integer : return;
        case type::string  : {this->string_.~basic_string<char>(); return;}
        case type::array   : {this->array_.~vector<value>(); return;}
        case type::empty   : return;
        default : assert(false);
    }
}

template<value_t type_index, typename T>
void value::switch_assign(T&& val)
{
    switch(type_index)
    {
        case type::integer : integer_ = static_cast<int>(val);
        case type::string  : {new(&(this->string_)) std::string(val); return;}
        case type::array   : {new(&(this->array_)) std::vector<value>(val); return;}
        case type::empty   : return;
        default : assert(false);
    }
}

なんのことはない、placement newdtor呼び出しをくくりだしているだけだ。ここで、switch_assigntype_indexはテンプレート引数なのだからstructの部分特殊化による分岐でコンパイル時に解決できるが、面倒なのでここではしない。また、まともなC++コンパイラ

template<std::size_t N>
std::size_t f(){
    std::size_t retval=0;
    for(std::size_t i=0; i<N; ++i)
        retval+=i*i;
    return retval;
}

のようなコードをコンパイル時にループアンロールできるという話があるのでこの程度のswitchコンパイル時に解決してくれないものだろうか(未確認)。これを見てみるのも面白いかもしれない。

さて、特殊メンバ関数の実装は、上記を使って

value::value(): type_(value_t::Empty){}
value::~value(){switch_clean();}

ここまでは良いのだが、テンプレート引数を動的に決められない以上value.cast<value.type()>などとは出来ず、(コピー・ムーブ)(コンストラクタ・代入演算子)は以下のようにせざるを得ない。

value::value(const value& rhs): type_(rhs.type())
{
    switch(rhs.type())
    {
        case value_t::integer:
        {
            switch_assign<value_t::integer>(rhs.cast<value_t::integer>());
            break;
        }
        case value_t::string:
        {
            switch_assign<value_t::string>(rhs.cast<value_t::string>());
            break;
        }
        case value_t::array:
        {
            switch_assign<value_t::array>(rhs.cast<value_t::array>());
            break;
        }
        case value_t::empty: break;
        default : assert(false);
    }
}
/*
残りの、ムーブコンストラクタ、コピー代入演算子、ムーブ代入演算子が続く……
*/

面倒だ。

ところで、switch_assignを作った理由は別にある。value_traitsを覚えているだろうか。これによって、

template<typename T, std::enable_if<
    value_traits<T>::is_value_type, std::nullptr_t>::type>
value::value(T&& v)
{
    switch_assign<value_traits<T>::type_index>(std::forward<T>(v));
}

/*
代入演算子も同様、ただし既に値が入っていた場合、必要に応じてデストラクタを呼ぶ
*/

という風に書けるのである。この目的のためにswitch_assignを作ったのであって、コピーコンストラクタなどはベタ書きに比べて特に書きやすくなるわけではない。 ので、最初コピーコンストラクタなどはベタ書きしており、そこでplacement newを使わず直接代入して、SIGSEGVを受け取っていた。それで半日潰した。

unionではコピー・ムーブコンストラクタや代入演算子でもplacement newを使わなければならない。

当然すぎるのだが、何故か引っかかってしまった。

あと、コンストラクタでは気にならないのだが、代入演算子の場合、相手のvalue_tタグとこちらのそれが違っていた場合、switch_cleanを呼ぶ必要がある。これを忘れるとかなり厳しいことになる。

また、ムーブ時に相手のvalue::type_をムーブすると、type_で分岐するデストラクタの挙動がUBになる……と思ったが、実際どうだろう。標準ライブラリのオブジェクトはムーブ後、「有効だが未規定な値」、つまり残骸に代入すればまた使えるが、残骸の中を見た時には何が起きるかわからない、という状態になっていたはずだが、そういえばenum、もっと言えばその下にあるcharintのようなfundamental typeの場合どうなるんだったか。未規定になるなら、デストラクタは困るだろう。なので、ムーブコンストラクタ・ムーブ代入演算子type_をムーブしない方がよいように思える。少し調べるべきではあるが。実際、moveが威力を発揮するのはどちらかというと基本型よりもstd::vectorのようなリソースへのポインタなどを持っているオブジェクトや、リソースが唯一のものであることを保証すべき(std::unique_ptrstd::thread)場面なので、value::type_はコピーでもさほどの問題はないと思われる。

で、最後がcast()だ。これは、switch_castをそのまま使って、

template<value_t T>
typename detail::default_value_type<T>::type const&
value::cast() const
{
    if(T != type_) throw type_error("different type!");
    return switch_cast<T>::invoke(*this)
}
/*
非const版も同様
*/

のようにすればよい。

動的に型を取り出せない(呼び出し側がswitchとかで正しく分岐する必要がある)のは少し不便なので、他にmatch文もどきが書けるような仕組みを作ろうか。オペレータチェイン(だったっけか)を上手く使えばできるような気がするので、後で試してみよう。