关于std::launder可达性规则与未定义行为的技术疑问
std::launder的示例中有如下代码块:
int x2[2][10]; auto p2 = std::launder(reinterpret_cast<int(*)[10]>(&x2[0][0])); // 未定义行为:通过指向x2[0]的结果指针可访问x2[1] // 但源指针无法访问x2[1]
这让我感到困惑,因为std::aligned_storage的示例似乎并非如此:
std::aligned_storage_t<sizeof(T), alignof(T)> data[N]; // 访问对齐存储中的对象 const T& operator[](std::size_t pos) const { // 注意:P0137R1的对象模型变更后需要使用std::launder return *std::launder(reinterpret_cast<const T*>(&data[pos])); }
困惑点在于:&data[pos]等价于&data[pos][0],因为对于数组p,&p == &p[0]。
既然std::aligned_storage几乎只能以alignas(T) std::byte[sizeof (T)];这类方式实现,为何这种用法是合法的?数组被包含在结构体中会产生特殊效果吗?原因是什么?
假设我编写了如下代码:
template <typename T> using uninitialized = alignas(T) std::byte[sizeof (T)]; constexpr auto N = 5; uninitialized<int> array[N]; for (std::size_t i = 0; i < N; i++) { new (&array[i]) int(i); }
那么类似这样的类型转换:
auto laundered_value_ptr = std::launder(reinterpret_cast<int*>(&array[i]));
是否和第一个示例一样属于未定义行为?那这段代码呢:
auto laundered_array_ptr = std::launder(reinterpret_cast<int*>(array)); laundered_array_ptr[0] = 9; laundered_array_ptr[2] = 76;
按照第一个示例的逻辑,似乎无法正确使用这块内存,因为std::byte(*)[sizeof (int)]类型意味着周围的所有内存都可被访问,那么上述所有代码都可能属于未定义行为?
我使用g++ -g -O0 -Wextra -Wall -Wpedantic -std=c++20 -fsanitize=undefined -fsanitize=address编译这些示例,奇怪的是没有收到任何警告,这让我完全困惑。
编译器版本如下:
g++ (GCC) 12.1.1 20220730
核心区别在于对象模型的「可访问范围」规则
第一个示例的未定义行为,根源在于指针的可访问范围不匹配:
&x2[0][0]是指向x2[0]数组中第一个int的指针,它的可访问范围仅限于x2[0]这个数组对象的内存(10个int)。- 当你把它强转为
int(*)[10]类型,得到的指针逻辑上指向x2[0]数组本身。此时调用std::launder后,这个指针允许通过算术运算访问x2[1](因为数组指针的算术会跳过整个数组大小),但x2[1]是独立于x2[0]的对象,超出了原指针的可访问范围——这违反了std::launder的约束:清洗后的指针不能访问原指针无法触及的对象。
而std::aligned_storage的示例是合法的,原因如下:
std::aligned_storage_t<sizeof(T), alignof(T)>是一个独立的对象类型,data[N]是由N个这样的独立对象组成的数组。- 每个
data[pos]的内存恰好能容纳一个T,当你在data[pos]中构造T后,&data[pos]指向的是这个独立的aligned_storage对象,它的可访问范围就是自身的内存(刚好是T的大小)。 - 强转为
T*并调用std::launder后,得到的指针仅能访问该位置构造的T,不会触及其他data[pos]对象的内存——完全符合原指针的可访问范围,因此合法。
对你编写代码的分析
- 单个元素的访问:
auto laundered_value_ptr = std::launder(reinterpret_cast<int*>(&array[i]));
这是合法的。&array[i]指向的是uninitialized<int>类型的对象(即std::byte[sizeof(int)]数组),它的可访问范围就是自身的内存。你在其中构造的int完全包含在这个范围内,清洗后的指针仅访问该int,没有越界到其他对象,因此不存在未定义行为。
- 整个数组的指针转换与访问:
auto laundered_array_ptr = std::launder(reinterpret_cast<int*>(array)); laundered_array_ptr[0] = 9; laundered_array_ptr[2] = 76;
这里laundered_array_ptr[2]是未定义行为。原因是:
arraydecay后是指向第一个uninitialized<int>对象的指针,它的可访问范围仅限于第一个std::byte[sizeof(int)]数组的内存。laundered_array_ptr[2]等价于*(laundered_array_ptr + 2),会访问到第三个uninitialized<int>对象的内存(因为每个int占sizeof(int)字节),而这超出了原指针的可访问范围——和第一个示例的问题本质相同。
为什么编译器没有警告?
C++的对象模型规则比较抽象,很多未定义行为属于「语义层面」的问题,而非语法或内存地址越界(ASAN能检测内存地址越界,但这里的问题是对象访问权限)。即使开启了UBSAN,编译器也无法覆盖所有对象模型违规场景,尤其是这种通过指针转换绕过对象边界的情况。GCC 12.1.1的UBSAN目前还没有针对这类场景的检测逻辑,因此不会给出警告。
内容的提问来源于stack exchange,提问作者yotsugi

