Boost.Variant の assign の処理

かなり面白かったので書いてみます。

代入する型が一致する場合

まずは小手調べ。簡単に代入できる場合までの処理。
Boost 1.40.0 では以下の実装になっています。

void variant_assign(const variant& rhs)
{
    if (which_ == rhs.which_)
    {
        detail::variant::assign_storage visitor(rhs.storage_.address());
        this->internal_apply_visitor(visitor);
    }
    else
    {
        assigner visitor(*this, rhs.which());
        rhs.internal_apply_visitor(visitor); 
    }
}

template <typename T>
void assign(const T& rhs)
{
    detail::variant::direct_assigner<T> direct_assign(rhs);
    if (this->apply_visitor(direct_assign) == false)
    {
        variant temp(rhs);
        variant_assign( detail::variant::move(temp) );
    }
}
template <typename T>
variant& operator=(const T& rhs)
{
    assign(rhs);
    return *this;
}

現在設定されている型と代入しようとしている型が一致している場合は、単純に direct_assign によって(Visitor 経由で)直接 = 演算子で新しい値が代入されます。


現在設定されている型と代入しようとしている型が一致していない場合は、一度 rhs で variant を構築してみて、その後 variant_assign 関数内で which_ が一致するかどうかを見ています(多分暗黙に変換されて同じになる場合があるのだと思います)。
which_ が一致する場合は assign_storage によって値が設定されます。これもほとんど = 演算子で代入するのと同じです。


で、which_ が一致しない場合、つまり現在設定されている型と代入しようとしている型が完全に異なる場合、assigner というクラスによって値が設定されることになります。

代入する型が異なる場合

型が異なる場合の代入は、基本的に以下のような処理になると考えられます。

// 現在設定されている型が T、代入しようとしている型が U として...
lhs_.~T(); // デストラクタで現在の値を破棄して
new(lhs_.address()) U(rhs); // lhs_ の共有領域を使って rhs からコピーコンストラクトする

単純に考えるとこれだけでいいのですが、コピーコンストラクタで例外が発生すると、以前に設定されていた T 型の値は既に破棄されているため、以前の状態に戻すことができません。このままだと Boost.Variant は不定な状態になってしまいます。
これは例外の基本的な保証すらできていないので例外安全ではないです。なので別の方法を考える必要があります。

Boost.Variant での代入の例外保証

Boost.Variant はこの問題を解決するために、とりあえず4つのケースに分けてそれぞれの代入操作を用意しています。

  1. コピーで例外が発生しない場合
  2. コピーで例外が発生するけどムーブコンストラクタがある場合
  3. ムーブコンストラクタを持っていないけど fallback_type を持っている場合
  4. fallback_type を持っていない場合

これらは has_nothrow_copy といったメタ関数を使って静的に分岐します。

コピーで例外が発生しない場合

例外が発生しないので簡単です。以下のような操作になっています。

lhs_.destroy_content(); // nothrow
new(lhs_.storage_.address()) RhsT( rhs_content ); // nothrow
lhs_.indicate_which(rhs_which_); // nothrow

indicate_which() は、代入されている型の種類を設定します。
この値が which() で返す値になります。当然 nothrow です。

コピーで例外が発生するけどムーブコンストラクタがある場合

ムーブであれば例外が発生しないので、ムーブします。

RhsT temp(rhs_content);
lhs_.destroy_content(); // nothrow
new(lhs_.storage_.address()) RhsT( detail::variant::move(temp) ); // nothrow
lhs_.indicate_which(rhs_which_); // nothrow

この detail::variant::move() が実際どんなものなのかちゃんと調べてないですが、とりあえず C++0x の move とあまり変わらないと考えておきます。
テンポラリにコピーしているのは、ムーブした後に rhs_content が無効な値になったら困るからですね。

ムーブコンストラクタを持っていないけど fallback_type を持っている場合

この辺からが結構やばい感じです。
fallback_type というのは要するに、デフォルトコンストラクタで例外を投げないクラスのことです。
variant<> に渡されたテンプレート引数の中からデフォルトコンストラクタで例外を投げないクラスをメタ関数で探して、もしあれば、このコードに来ることになります。
以下のようなコードになっています。

lhs_.destroy_content(); // nothrow
try
{
    new(lhs_.storage_.address()) RhsT(rhs_content);
}
catch (...)
{
    new (lhs_.storage_.address()) fallback_type_; // nothrow
    lhs_.indicate_which(fallback_type_index_::value); // nothrow
    throw;
}
lhs_.indicate_which(rhs_which_); // nothrow

コピーに失敗した場合、lhs_ にはコンストラクタで例外を投げない fallback_type を設定します。
こうすると、呼び出す前の状態に戻すことはできません(強い保証ではない)が、どれかの状態になっていることは保証できます(基本的な保証)。

fallback_type を持っていない場合

最後に、fallback_type すら持っていない場合です。
もうどうしようも無い気がするのですが、Boost.Variant はやってくれました

// lhs content をバックアップする
LhsT* backup_lhs_ptr = new LhsT(lhs_content);

// lhs content を破棄する
lhs_content.~LhsT(); // nothrow

try
{
    // rhs_content_ を lhs_ のストレージにコピーするのを試みる
    new(lhs_.storage_.address()) RhsT(rhs_content_);
}
catch (...)
{
    // 失敗した場合、lhs_ のバックアップポインタをコピーする
    new(lhs_.storage_.address())
        backup_holder<LhsT>( backup_lhs_ptr ); // nothrow

    // バックアップを使っていることを知らせる
    lhs_.indicate_backup_which( lhs_.which() ); // nothrow

    throw;
}

// 成功した場合
lhs_.indicate_which(rhs_which_); // nothrow

// バックアップの削除
delete backup_lhs_ptr; // nothrow

まず現在の値をバックアップします。この時点で例外が発生する可能性がありますが、ここで発生する分には何も問題はありません。
で、実際にコピーを試みます。例外が発生しない場合は普通に処理しますが、例外が発生した場合、バックアップ用のポインタを持った backup_holder をコピーして、その後 which_ にバックアップ使用中というのを明記します。


問題なのは、「backup_holder を設定すると不定な(外から見えない)値に変更されたんだから例外安全じゃないじゃん!」ということです。
しかも、indicate_backup_which() の実装を見てみると、

void indicate_backup_which(int which)
{
    which_ = static_cast<which_t>( -(which + 1) );
}

which は必ずプラスの値になるはずなので、これはマイナスの値を設定していることになります。意味がわかりません。


結局これらは何をしているのかというと、例外が発生して失敗した場合は、ヒープを使ってバックアップした値を使うモードに切り替えているのです。
実際 which() なんかは、

private:
    bool using_backup() const
    {
        return which_ < 0;
    }

public:
    int which() const
    {
        if (using_backup())
            return -(which_ + 1);

        return which_;
    }

こうなっていて、バックアップした値を使うモード(which_ < 0 の場合)でも正しい値が返されるようになっています。
また、値を取得したりといった操作を行う際には Visit する際にこの値を見てマイナスの値だったら backup_holder<> から値を取り出すような処理が入っているのです。


こうすることで、Boost.Variant は代入に失敗した場合でも外から見て以前の状態と同じように見せかけているのです。
以前の状態と同じなので、これは強い保証です。例外安全です。


操作を行う前にバックアップを取るというのはよくあることですが、ロールバックする際にそのまま設定すると例外が発生するからポインタのまま保持して以前の状態をシミュレーションするとか、がんばりすぎです。


Boost こわい。