[C++]沼底のvoidify()

この記事は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++プログラマーには関係ないでしょう。そして、成り行きによっては、この記事は歴史の闇に埋もれることになります。

参考文献

この記事のMarkdownソース