Rokiのチラ裏

学生による学習のログ

P0532R0: On launder() 和訳

std::launder については以前触れたP0532R0: On launder() にとても分かりやすくまとめられていたので、個人的理解のためも含めて和訳してみる。

C++17では、C++14 の NB コメントの結果としてstd::launderを導入している:C++ FCD Comment Status
それは Core Issue 1776 として議論された*1http://www.open-std.org/jtc1/sc22/wg21/docs/cwg_defects.html#1776
最初の具体的な提案:Pointer safety and placement new
最終的に受け入れられた wording:Core Issue 1776: Replacement of class objects containing reference members (et al)
この問題は、C ++17 の次のNBコメントにも影響する:JP20, CA12
このペーパーでは、まずstd::launderが解決しようとしている問題(何十年も存在していた問題)を正確に説明しようとしている。また、私の意見では、何故別の解決策がより理にかなっているのかを動機付けるために、std::launderを用いた解決策から生じた問題についても議論する。要約すると、定数/参照メンバが有効な要素に対して、std::launderを使用して変更することなく、現在配置newが存在する既存のコードを有効にすることを強く推奨する。

Which Problem shall launder() solve?

現在の標準によると、次のコードは未定義の動作になる。
struct X {
 const int n;
 double d;
};
X* p = new X{7, 8.8};
new (p) X{42, 9.9}; // request to place a new value into p
int i = p->n; // undefined behavior (i is probably 7 or 42)
auto d = p->d; // also undefined behavior (d is probably 8.8 or 9.9)
理由は、現在のメモリモデルにある。
3.8 Object lifetime [basic.life]
オブジェクトの存続期間が終了した後、オブジェクトが占有したストレージが再利用または解放される前に、元のオブジェクトが占有した記憶領域に新しいオブジェクトが作成され、元のオブジェクトが占有していた記憶領域、元のオブジェクトを指し示すポインタ、または元のオブジェクトの名前が自動的に新しいオブジェクトを参照し、新しいオブジェクトの存続期間が開始されたら、次の条件を満たす場合に新しいオブジェクトを操作するために使用する事ができる:
  • 新しいオブジェクトの記憶域は、元のオブジェクトが占有していた記憶場所を正確に overlay する
  • 新しいオブジェクトは元のオブジェクトと同じ型である(top-level の cv 修飾は無視される)
  • 元のオブジェクトの型はconst修飾されていない。クラス型の場合は型が const 修飾された非静的データメンバーまたは参照型を含んでいない
  • 元のオブジェクトはT型の最も派生したオブジェクトであり、新しいオブジェクトはT型の最も派生したオブジェクトである(つまり、それらは基本クラスのサブオブジェクトではない)
標準で対応する一つの例を示す。
1.8 The C++ object model [intro.object]
[ Example:
struct X { const int n; };
union U { X x; float f; };
void tong() {
 U u = {{ 1 }};
 u.f = 5.f; // OK, u の新しいサブオブジェクトを作成する
 X *p = new (&u.x) X {2}; // OK, uの新しいサブオブジェクトを作成する
 assert(p->n == 2); // OK
 …
 assert(u.x.n == 2); // 未定義動作、u.xは新しいサブオブジェクトの名前を付けない
} 
—end example ]
この動作は新しいものではなく、C++03 以来存在している。標準の対応する文言の結果として、定数または参照メンバを持つ可能性のあるオブジェクトの配置 new は定数メンバーまたは参照メンバーを持つ可能性のあるオブジェクトに使用され、placement new の戻り値は常にメモリへのアクセスごとに使用される事が保証される:
struct X {
 const int n;
};
X* p = new X{7};
p = new (p) X{42}; // pに新しい値を置くことを要求する
int i = p->n; // OK、p が新しい placement new の戻り値によって再初期化されたため i は 42 である。 

Why using the return value of placement new is a problem

残念ながら、実際には、placement new の戻り値を常に簡単に使用できるわけではない。追加のオブジェクトが必要な場合があり、現在のアロケータインターフェイスはこれをサポートしていない。

Placement new into members

戻り値を使用する際にオーバーヘッドとなる原因の1つの例は、ストレージが既存のメンバーである場合である。std::optionalstd::variantの場合があてはまる。
template <typename T>
class coreoptional
{
private:
    T payload;
public:
    coreoptional(const T& t)
        : payload(t) {}

    template<typename... Args>
    void emplace(Args&&... args) {
        payload.~T(); 
        ::new (&payload) T(std::forward<Args>(args)...); // *
     }
     const T& operator*() const & {
         return payload; // **
     }
}; 
ここでTが定数または参照メンバを持つ構造体である場合
struct X {
    const int _i;
    X(int i) : _i(i) {}
    friend std::ostream& operator<< (std::ostream& os, const X& x) {
        return os << x._i;
    }
};
次のコードは、未定義の動作となる。
coreoptional<X> optStr{42};
optStr.emplace(77);
std::cout << *optStr; // 未定義の動作(恐らく 42 または 77 が出力される)
その理由は、出力操作では演算子*(上記の**を参照)が呼び出され、戻り値を使用しない新しい値が配置されたメモリが使用される(*上記参照)。このようなクラスでは、placement new の戻り値を保持するポインタメンバーを追加する必要があり、値が必要な時に利用する。
template <typename T>
class coreoptional
{
private:
    T payload;
    T* p; // placement new の戻り値を使用できるようにする。
public:
    coreoptional(const T& t)
        : payload(t) {
        p = &payload;
    }
    template<typename... Args>
    void emplace(Args&&... args) {
        payload.~T();
        p = ::new (&payload) T(std::forward<Args>(args)...);
    }

    const T& operator*() const & {
        return *p; // ここで payload を使ってはならない
    }
};
このオーバーヘッドを避けるためにstd::launderが導入された(下記参照)。

Placement new into allocated memory

データへのアクセスが必要なときに使用されるメモリへのポインタを内部的に保持しているため、ポインタメンバーを追加するこのオーバーヘッドはヒープ上にメモリを割り当てる(コンテナ)クラスにとって問題とはならないと言える。しかし、それに限らずいくつかの異なる問題を抱えている。まず、アロケータが使用されているとき、ここでは new の返り値を使用することはできない。なぜなら、現在アロケータインタフェースは new の返り値を扱う手段を提供していないからである。アロケータ要件(17.5.3.5 Allocator requirements [allocator.requirements])に従うと、a.construct(c, args)は戻り値の型がvoidであるため、placement new の戻り値を使用する事はできないのである。従って、現在(C++14まで)、定数/参照メンバを持つ要素を使用するstd::vectorなどのコンテナは、ほとんどの場合すぐに未定義の動作になってしまう。例えば
struct X {
    const int i;
    X(int _i) : i(_i) {}
    friend std::ostream& operator<< (std::ostream& os, const X& x) {
        return os << x.i;
    }
};
std::vector<X> v;
v.push_back(X(42));
v.clear();
v.push_back(X(77));
std::cout << v[0]; // 未定義の動作
その理由はvector::push_back(他の挿入関数と同じ)は、配置 new の戻り値を無視するアロケータのconstruct関数を介して配置 new を呼び出しているからである。3.8 Object lifetime [basic.life]§8 に従って、値を保持する内部的に使用されるオブジェクトが新しい値を参照するという保証はない。具体的には、std::vectorは次のようになる(ムーブセマンティクスを使用しないなど、いくつかの単純化を行なっている)。
template <typename T, typename A = std::allocator<T>>
class vector
{ 
public:
    typedef typename std::allocator_traits<A> ATR;
    typedef typename ATR::pointer pointer;
private:
    A _alloc; // 現在のアロケータ
    pointer _elems; // 要素の配列
    size_t _size; // 要素のサイズ
    size_t _capa; // 容量
public:
    void push_back(const T& t) {
        if (_capa == _size) {
            reserve((_capa+1)*2);
        }
        ATR::construct(_alloc, _elems+_size, t); // 配置 new を呼び出す
        ++_size;
    }
    T& operator[] (size_t i){
        return _elems[i]; // 置き換えられた要素のメンバーが定数であるため未定義の動作
    }
 …
}; 
再度、ATR::constructは、呼び出された配置 new の戻り値を返さないことに留意。従って、_elemsの代わりにこの戻り値を使用することはできない。再びこの問題を解決するためにstd::launderが提案されたが、以下で説明するように、この問題はstd::launderでは解決できない。
C++11 以前では定数メンバを持つ要素を使用することはできなかった(vector)か、正式にはサポートされなかった。何故なら、要素はCopyConstructible と Assignable でなければならなかったからである(リストなどのノードベースのコンテナはconstメンバー)。しかし、C++11 では、ムーブセマンティクスを導入することで、上のクラスXとして定数メンバを持つ要素がサポートされたため、この未定義の動作が発生する。

How launder() tries to solve the problem

現在の標準によると、次のコードは未定義の動作となる。CWGはstd::launderを次のように導入することで問題を解決すること決めた: プログラムがこの問題が発生する可能性のあるデータに影響を与えるたびに、std::launderを使用してデータにアクセスする必要がある:
struct X {
 const int n;
};
X* p = new X{7};
new (p) X{42}; // pに新しい値を置くことを要求する
int b = p->n; // 未定義の動作
int c = std::launder(p)->n; // OK, c は 42 (launder(p) は double-check を強制する)
int d = p->n; // まだ未定義の動作
std::launderはその利用のためにポインタを「白く洗い流す」ことに留意。最後の行が示しているように、配置 new が呼び出されたデータにアクセスするときは常にstd::launderを使用する必要がある。つまり、現在(技術的に)壊れているコードを修正し、定数/参照要素を持つ replacement メモリを使用する場合は、std::launderを使用してこのコードを修正する必要がある。しかし、若干異なる解決策があるかもしれない。例えば Richard Smith 氏が指摘したように
もう1つの選択肢は、要素が挿入または削除されたときにのみlaunderを行うことである。
(_M_begin = std::launder(_M_begin)) // あなたのコンパイラが十分にスマートであるという前提の下で
コード生成時にこれらのストアを削除する。
しかし、いずれにしても、これらすべてのラッパー/コンテナクラスの既存のコードで何かを修正する必要がある。

Why launder() doesn’t work for allocator‐based containers

std::vectorなどの全てのアロケータベースのコンテナでは、未定義の動作を避けるためのstd::launderは問題となる。例えば、上記の単純化されたベクトルの例でstd::launderを使用して、置き換え可能なオブジェクトのデータに定数要素を使用してアクセスすることを考える:
template <typename T, typename A = std::allocator<T>>
class vector
{
public:
    typedef typename std::allocator_traits<A> ATR;
    typedef typename ATR::pointer pointer;
private:
    A _alloc; // 現在のアロケータ
    pointer _elems; // 要素の配列
    size_t _size; // 要素のサイズ
    size_t _capa; // 容量
 …
};
演算子`[]`の未定義の動作を修正するためには、置き換えられたメモリを参照するポインタにstd::launderを適用する必要がある。:
  • 明白な解決策は次のコードかもしれない:
    T& operator[] (size_t i){
        return std::launder(_elems)[i]; // まだUBかもしれない
    } 
    
    残念ながら_elemsの型は生のポインタ型ではない可能性があるため、これは未定義の振る舞いにつながる可能性がある。 Jonathan Wakely が指摘しているように、std::allocator_traits::pointerがクラス型である可能性がある:
    std::launderは引数として生ポインタを取るが、アロケータの "ポインタ"型が生ポインタでない場合、それをlaunderに渡すことはできない。
  • もう一つの生のポインタがある。しかし:
    T& operator[] (size_t i){
        return std::launder(this)->_elems[i]; // launder() は効果がない
    }
    
    Richard Smith 氏が指摘したように、std::launder(this)のタイプはちょうどこれと同等である:
    寿命が終わったオブジェクトを指し、新しいオブジェクトが同じストレージに作成されたオブジェクトをpが指していない限り、launder(p)は効果がない事に留意されたし。
つまり、一般的にはstd::launderによって希望の効果が得られるような生のポインタがないのである。従って、std::launderは、定数/参照メンバを持つ要素を持つときに、アロケータベースのコンテナで未定義の動作を回避するために問題を解決しないのである。

Why not fixing the Basic Lifetime Guarantees Instead?

直接的な疑問は、何故単純にメモリモデルを修正して配置newが行われるときに暗黙的に launder が行われるようにしないのかである。再び注意:この問題は新しいものではない。関連する wording は C++98 の一部ではないが、C++03(その時点では3.8§7)の一部である。よって、長い間、この問題は少なくとも正式に存在していると主張する事ができる。
もう一つの疑問は、既存の習慣に関する事である。
つまり、コンパイラがこの種の最適化を使用するかどうかによって異なる:
定数/参照の最適化に関して、現在のコンパイラは通常:
  • 私が知っている限り、gcc は現在、定数/参照の場合最適化していないが、 vptr 部分の最適化は行っている
  • 私が知っている限り、clang は現在いくつかのconst関連の最適化に取り組んでおり、これらの問題が有効になる可能性がある。
  • XLコンパイラに関しては、Hubert Tongが以下のように示している:
    XLコンパイラは、定数/参照の場合にも最適化する。しかし、これは、静的記憶領域の場合に限定されることを意味し、例えば
    struct A {
        const int x;
        int y;
    };
    
    A a = { 42, 0 }; 
    
    void foo(); // 定義は見えない
     
    int main(void) {
        foo();
        return a.x;
    } 
    
    a.x の読み込みは 42 に置き換えられる。これは2004年以前のケースである。
このようにコアルールによって定められたことによる結果を見ることができる。(実際には、多くのコードが壊れてしまうことが予想されるので、実際には、遅かれ早かれ後戻りするかもしれないが、この不必要な努力と混乱を避けるべきである。)

Is there more than the constant/reference member issue?

別の質問は、定数/参照の問題のほかに同様の問題があるかどうかである。実際、Richard Smith 氏はプライベート・Eメールで述べている。
しかし、"dynamic type" の規定は極めて価値があることが知られている。特に、
X *p; // pは dynamic type が Y であるオブジェクトを指す事が知られている
p->virtual_f(); // devirtualize する事ができる
p->virtual_g(); // これも devirtualize する事ができる
二回目の呼び出しを devirtualize する事ができるためパフォーマンスが大幅に向上し、それは最初の関数が配置newを使用してオブジェクトの dynamic type を変更しなかったと仮定することが許可されているためにのみ可能である。
別の例:
struct B { virtual void f(); };
struct D1 : B { virtual void f(); };
struct D2 : B { virtual void f(); };
variant<D1, D2> v;
ここでvariantは、アクティブな要素をD1D2の間で変更できるようにするために、内部的にlaunder のようなものを使用し、正しい関数を確実に呼び出すための仮想呼び出しを必要とする(これは本質的に上記p->virtual_f() / p -> virtual_g()上記の場合、場合と同じである。)。
私が知っている限りでは、これらのケースはコンテナやラッパータイプを使用しているときに起こることはないか、ごく稀である。よって、std::launderが効果を発揮しない const / reference のケースを修正しながら、これらのケースに対してstd::launderを提供することそのものには価値がある。

Hiding the problem could make things worse

この問題は、対応するメンバ関数を呼び出すことによって間接的に発生する可能性があることにも留意されたし:
Obj x;
x.use(); // ok
mutate(x); // 配置 new の呼び出し?
x.use(); // 未定義の動作かもしれない
std::launder(&x)->use(); // 必要かもしれない
x.use(); // まだ未定義の動作かもしれない
mutate()が 配置new を呼び出さないことを知っていない限り、オブジェクトxを使用するたびに必ずstd::launder()を使用する必要があるが、ここでもstd::launderを適用するための未処理のポインタはない(ここでもstd::launder(&x)はxが生きているので&xと同じである)。
Nathan Myers 氏は以下のように薦める。
私たちが本当に望むのは、名前の特定の使用(例えば argument)がそれを証明することを宣言する事である。これは実際に厄介な作業を行うメンバ関数を含む関数の特定の引数に付随する事ができる。例えば:
template <T> void default_construct(T taint* p) { new(p) T(); } 
恐らく、コンパイラはそのように宣言されていない引数がそれに渡された場合、警告する必要がある。std::vector::push_backの場合、1つの vector メンバーのみが汚染*2される。そのメンバーは、宣言されたメンバー機能による汚損の対象として最もよく識別される。つまり、汚れたメンバ関数を呼び出すだけでは、このすべてが汚されることはないが、汚れたメンバーvec::ptrだけはやはり汚れてしまう。いずれにしても、rvalue 自体は、必要な各場所でコンパイラによって目に見えないほど laundered される。コンパイラプログラマよりもはるかに優れている。vector<>::data()vector<>::push_back()の場合は、前者の結果と後者の呼び出しの間に同様の関係が必要なようである。
そのような機能を追加する期間は短いの AFNOR の変更によって実装に拡張された緯度を98→03に制限することは、恐らくC++14(及びC++ 11)の corrigendum の中で必要と思われる。
別の中間的な手段は、上記の例の3番目の "o.use()"がOKになるように、launderation を固着させる(別名 whitewashing)ようにする事である。
Obj o;
o.mod(); // o tainted
launder(&o);
o.use(); // ok
しかし、これを corrigendum に入れるのは難しいだろう。何にせよ、これは C++17 のために、必要だと思われる。

Summary and Recommendation

ここで説明する問題は、20年以上にわたって利用可能な2つのプログラミング手法を使用している。
  • 常に配置newを返却値として使わない
    • 便利のため
    • 追加のオブジェクトを導入することを避けるため(variantまたはoptionのように)
    • アロケータを使用するすべてのクラスに他のオプションがないため
  • 定数メンバーを持つクラス
  • アロケータベースのコンテナ
全ての組み合わせが常に可能ではないが(例えば、vectorC++11 まで定数メンバを持つ要素を使用できなかった)、一定のメンバーを持つオブジェクトとの組み合わせで配置 new を使用するコードが多くある。(C++11より前は、例えばノードベースのコンテナで簡単に起こる事があった。)。コンパイラが現在対応する最適化をオンにしていれば、既存のコードの多くが暗黙的に壊れてしまうと思われる。従って、現在の実装では、このような変更がキャンセルされる可能性がある。しかし私はこの既存のコードを有効にするための wording を明確にすべきであると強く提案する。標準は常に既存の慣行を標準化すべきである。しかし、これらの最適化の1つがもはや利用できない場合、どのくらいのコードが壊れているのかは分からない。定数/参照メンバの部分については、このような最適化がまだ広く普及していないように思われるため、壊れたコードは然程ないのではないかと思われる。実際に、既存のすべての汎用ラッパークラスとコンテナクラスがそれに応じて実装されることは期待できない(問題は標準ライブラリクラスにのみ適用されるものではないのである)。また、指摘したように、std::launderを使用した現在のソリューションでは、const / reference メンバーを持つ要素を持つアロケータベースのコンテナを使用した未定義の動作の問題は解決されない。
よって私が見た唯一の妥当なオプションは、[basic.life]の C++98 バージョンに戻って、const / referenceメンバのないクラスに対してのみ、既に許可している最適化を許可する事である。

*1:訳注: n3690 3.8 [basic.life] paragraph 7std::optional のような再配置を必要とするモデルの実装を困難にするとしてこの議論が取り上げられた:https://groups.google.com/a/isocpp.org/forum/#!msg/std-proposals/93ebFsxCjvQ/myxPG6o_9pkJ

*2:本文では "tainted" とある。恐らく launder の対義語として述べているのだと思われる