この記事はC++ Advent Calendar 2022の14日目の記事です。
規格書中の特定領域に突如出現する謎の説明専用関数voidify()
、その謎を解明するため、我々調査隊はアマゾンの奥地へと向かった――。
謎の関数voidify
voidify()
とは規格書中に登場する謎の関数で、次のように定義されています。
template<class T> constexpr void* voidify(T& ptr) noexcept { return const_cast<void*>(static_cast<const volatile void*>(addressof(ptr))); }
つまりは、オブジェクトの配置されているストレージのポインタをvoid*
で取得する関数です。まずこの時点で、定義内で謎の2段階キャストをかけている意味が分かりません。
このvoidify()
は、標準ライブラリの関数のうち、未初期化領域にオブジェクトを構築する関数において使用されます。例えば、std::construct_at()
ではその効果の指定において次のように使用されています。
namespace std { template <class T, class... Args> constexpr T* construct_at(T* location, Args&&... args) { // こう実行したときと同じ効果となる、という指定 return ::new (voidify(*location)) T(std::forward<Args>(args)...); } }
voidify()
は配置new
の対象ポインタを取得するのに使用されています。しかし、配置new
は::new(location)
で十分なはずで、なぜvoidify()
を通す必要があるのでしょうか、謎です・・・
voidfy()
の入力
まず、使用側のコード(上記のconstruct_at()
の効果)を見て凄く気になる点を解消しておきます。それは、未初期化領域のポインタlocation
に対してvoidify(*location)
としているところです。
location
は未初期化領域のはずなので*location
は未定義動作になるように思えて、とても奇妙です。しかし、これは規格中でequivalent toという形で指定されていて、これは未定義動作とかそういう都合の悪いことは除いて同じ効果になってね(ざっくり)、みたいな意味であり実際に未定義動作を起こせという意味ではありません。voidify(*location)
はその式全体として、location
のポインタ型をvoid*
に変換するという意味でしかありません。
それと関連して、なぜvoidify()
の入力はT&
であってT*
でないのか?という疑問がわきます。voidify()
の入力がT*
ならば、そもそも未初期化領域のポインタをデリファレンスするみたいなコードが表面化することもないはずです。結局、voidify(*location)
はなぜこうなっていてこれは何をしているのでしょうか?
この理由はおそらく、voidify()
がポインタ型だけではなくより広いイテレータ型に対しても使用されるためです。例えば、std::uninitialized_copy
をはじめとする未初期化領域を初期化するアルゴリズムでも使用されており、そのような場所でもコードの見た目としては同様に::new(voidify(*it)) ...
のように使用されます。
voidify()
の引数型をT*
にしてしまうとイテレータ型を受け取れなくなり、引数型を単にT& t
等にして内部で*t
する場合は追加の制約が必要になります。おそらくはこれらの事情から、引数はポインタ/イテレータをデリファレンスした結果の参照を受け取るようにしたうえでデリファレンスは呼び出し側で行う、という使用法になっているのだと思われます。
voidify()の役割
voidify()
がやっていることは、ポインタ型をvoid*
に変換することです。ではなぜこれが必要なのか?というと、オーバーロードされていないグローバルなoperator new()
を確実に呼び出すためです。
voidify()
が使われているところでは、::new (voidify(...)) T(...)
のようにしてvoidify()
の結果のポインタをnew
式に渡してその領域にT
のオブジェクトを構築(配置new
)しています。まず、::new
としていることでグローバルな(クラススコープでオーバーロードされたものではない)operator new
を呼び出そうとしています。
ここでさらに問題なのは、グローバルなoperator new
もオーバーロードされている可能性があることで、特に特定の型のポインタ型に特殊化されている可能性があります。
#include <new> #include <iostream> #include <memory> struct S { int n; }; // 特殊化された配置new演算子 [[nodiscard]] void* operator new(std::size_t size, S* ptr) noexcept { std::cout << "call S specific operator new()\n"; return ptr; } int main() { // Sの場合 S s{ .n = 1 }; std::destroy_at(&s); // S*に特殊化されたoperator newが使用される S* sp = ::new(&s) S{10}; std::cout << sp->n << '\n'; // 非Sの場合 int n = 1; std::destroy_at(&n); // デフォルトのoperator newが使用される int* ip = ::new(&n) int{}; std::cout << *ip << '\n'; }
出力例
call S specific operator new() 10 0
この時に、配置new
の入力ポインタをvoid*
にするとこのような変なnew
演算子オーバーロードを弾くことができます。
int main() { S s{ .n = 1 }; std::destroy_at(&s); S* sp = ::new((void*)&s) S{10}; std::cout << sp->n << '\n'; }
出力例
10
本来配置new
を行うoperator new()
のオーバーロードは許可されておらず、void*
でのオーバーロードは多重定義エラーとなります。しかし、特定の型のポインタT*
に特殊化したものはコンパイルエラーにはならず、この例のように実行できてしまいます(当然未定義動作ですが)。
voidify()
が使用されるところでは、std::construct_at()
がそうであるように、ポインタの指す領域に対して新しいオブジェクトを構築することを意図していて、voidify()
はその効果の説明のために使用されます。voidify()
の使用は、それらの関数の呼び出しがこのような変なオーバーロードを呼び出さないこと(それによって未定義動作とならないこと)を表明し、なおかつ、実装が同様に変なオーバーロードを呼び出さないことを要求しています。
voidify()の実装
voidify()
の内部でやっていることは、その役割のために必要な任意のポインタからvoid*
へのキャストの実装です。先程の例のように、(void*)
ないしstatic_cast<void*>()
ではダメなのでしょうか?
template<class T> constexpr void* voidify(T& ptr) noexcept { // このキャスト2段重ねは何? return const_cast<void*>(static_cast<const volatile void*>(addressof(ptr))); }
まず最初に、ptr
に対してaddressof()
を使用しているのは、その意図通りに&
演算子のオーバーロードを回避して確実にポインタを取得するためです。
そのように取得されたポインタ型にはトップレベルのCV修飾が着くことはなく、残るのはT
に指定されるCV修飾のみで、それが存在しうるのはptr
の型がconst (volatile) U
のような型の場合です。
T
にCV修飾がある場合、const T*
のようなポインタ型に対してstatic_cast<void*>()
のキャストはできません。なぜなら、static_cast
はCV修飾を取り除けないからです。一方で、static_cast
はCV修飾を追加することは問題なく(すでに存在してる場合でも)できるため、static_cast<const volatile void*>()
はaddressof(ptr)
の結果のポインタ型がなんであれconst volatile void*
にキャストすることができます。
そして、次のconst_cast<void*>()
はそこから追加したばかりのconst volatile
を外してvoid*
にキャストしています。
すなわち、このキャスト2段重ねは(voidify()
の呼び出し側の大本の)入力のポインタ型を、そのCV修飾に関わらずvoid*
にキャストするためのものです。
実はここ(void*)
ならできてしまうのですが、それは多分標準ライブラリでは好まれないのでしょう。また、このキャスト2段重ねは特別扱いなしで定数式で実行可能であり、reinterpret_cast
を使用しないのはそのためです。
そのようなポインタがここに渡ってくる場合はいつか?というと、外側の関数(std::construct_at
など)の入力にconst (volatile) T*
みたいなのが渡された場合です。std::construct_at
をはじめとする未初期化領域にオブジェクト構築を行う関数は、この場合でも正しく動作します。
#include <new> #include <iostream> #include <memory> int main() { const int* p1 = new int{0}; // const int*を渡しても動作する std::destroy_at(p1); const int* p2 = std::construct_at(p1, 10); std::cout << *p2 << '\n'; }
出力例
10
ところで、このようにconst T*
のようなポインタの指す領域に新規にオブジェクトを構築することは正しい振る舞いなのでしょうか?標準ライブラリがそう指定しているのだから正しいのでしょうか?
納得できる理由を提供するのであれば、次のようになるでしょうか。
const (volatile) T*
のようにポインタの参照先に対するCV修飾は、参照先のオブジェクトに対する修飾です(領域ではなく)。そして、std::construct_at
をはじめとする関数群は未初期化領域にオブジェクトを構築することを役割としており、期待される事前条件として(これは明文化されていませんが)入力の領域にはオブジェクトがまだ構築されていないことがあります。つまり、これらの関数がその役割を遂行する際には、CV修飾を適用すべきオブジェクトはまだ無いためCV修飾は意味をなさず、これらの関数がその領域にオブジェクトを構築して初めてそのCV修飾は意味を持ちます。したがって、これらの関数がconst (volatile) T*
なポインタが指す領域に新しいオブジェクトを構築することはそのCV修飾の意味を犯してはいないのです(この辺筆者の感想です)。
反voidify()運動
その点についてそれが正しくないと感じている人が少なからずいるようで、voidify()
には密かに反対派が居ます。
例えば、C++23 CD(committee draft)に対するNBコメントとしてvoidify()
を使わないように推奨するNBコメントが提出されており、これはC++20に対しても別の国からのNBコメントとして提出されていました。
C++20の時は否決されていましたが、C++23では少なくともLEWGのコンセンサスを得られています。
C++20の時のNBコメントによれば、voidify()
はC++20に<ranges>
を導入したP0896 One Range Proposalの一部として追加されたようで、これはconst
性の正しさを損ねるからstatic_cast<void*>
に変更しよう、のようにコメントされていました。これだけだと主張として弱かったのか否決されています。
C++23でも同様にconst
性の正しさを損ねるというのが主たる理由ですが、もし本当にconst T*
な領域にオブジェクトを構築したい場合は呼び出し側が明示的にconst_cast
をすべきであり、暗黙にconst
を外すのは安全ではない、とより説得力のある主張がなされています。
一方でP0896の著者の方は、voidify()
のこの振る舞いはstd::construct_at
などがconst (volatile)
なオブジェクトを生成できるようにすることが目的で、他の方法ではできず、小さいながらも利点がある、と述べています。ただしこれに対しては、CWG Issue 2514の解決によってそのような利点は無くなる、とも指摘されています(この辺何言ってるかよくわかりません)。
どうなるのかは知りませんが、どうなったとしてもほとんどのC++プログラマーには関係ないでしょう。そして、成り行きによっては、この記事は歴史の闇に埋もれることになります。
参考文献
- Why do we need voidify function template in uninitialized_copy - stackoverflow
- [specialized.algorithms]/4 - N4861
std::construct_at
- cpprefjp- operator new/delete | Programming Place Plus C++編【言語解説】 第36章
- How could I sensibly overload placement operator new? - stackoverflow
- US215 20.10.11 [specialized. algorithms] p6 Remove "vodify" - cplusplus/nbballot
- GB-121 27.11.1 [specialized.algorithms.general] Remove voidify - cplusplus/nbballot