对象连续性:对就地构造的影响
问题概览
在C++中,理论上只有某些类型的对象被保证是“连续的”,如果我的分析正确,这在尝试在一个存储区内对非连续对象进行就地构造(placement new)时会引发问题。
为了让问题更清楚,我觉得有必要提供大量背景信息。
技术背景
在重新阅读 访问对象表示(这个议题现在计划在C++29发布!)时,我意外发现了另一个被我忽视的问题:让更多对象变得连续(该议题似乎停滞不前)。到目前为止,只有可平凡拷贝(trivially copyable)或标准布局(standard layout)类型被保证是连续的:https://eel.is/c++draft/intro.object#8。
于是我遇到了一个问题:在标准中找不到对“连续性”的正式定义。在P1945R0中,假设它是对象最后一个字节的地址与第一个字节的地址之差再加1,这应该等同于存放在数组中的两个相邻对象之间的地址差。
一个这样的对象的内存布局的图形表示如下:
object representation: BBBxxB
byte index: 012345
其中 B 是对象表示所占的字节(包括填充位),x 是对象表示之外的字节。
这个“对象footprint”(占用空间)可以这样计算:
template <typename T>
concept is_contiguous =
std::is_trivially_copyable_v<T> || std::is_standard_layout_v<T>;
template <typename T>
std::size_t footprint() {
if constexpr (is_contiguous<T>) {
return sizeof(T);
} else {
static_assert(std::is_default_constructible_v<T>);
T arr[2];
return reinterpret_cast<std::byte*>(arr + 1) -
reinterpret_cast<std::byte*>(arr);
}
}
如果对象是连续的,那么footprint等于 sizeof,否则,可能会更大,我必须从一个数组中推导。但我很确定这一步的计算在P1838R7下是UB,因为 reinterpret_cast 的结果不是一个字节数组,因此指针运算是未定义的。此外,我还将很难将其扩展到非默认构造的类型。
另一个问题是,对于非连续类型,sizeof 的返回值对我来说是模糊的(参见 https://eel.is/c++draft/expr.sizeof)。
以上面的 BBBxxB 例子为例,sizeof 预计应为 4,footprint 预计应为 6(因为它本来就应该是对齐的倍数,因此是2 的幂)。
注:到目前为止,在实践中我还无法创建一个不是连续的类型。
就地构造的问题
现在的实际问题是在字节存储区内创建(非连续类型的)对象(以及它们的数组)。这种手动内存管理很常见,但如果 sizeof 现在小于footprint,我们就会遇到问题:
static constexpr std::size_t M = 10;
static constexpr std::size_t N = M * sizeof(test_t);
alignas(test_t) std::byte storage[N];
for (std::size_t i = 0; i < N; ++i) {
//
new (storage + i * sizeof(test_t)) test_t{};
}
问题在于如果 sizeof(test_t) 小于对象内存footprint,第二个创建的对象将部分覆盖第一个对象。
我的想法是把 sizeof(test_t) 替换成 footprint<test_t>(),但我需要一个非UB的版本,且可能是 consteval。
问题摘要
- 是否存在对连续性的正式定义,并且它是否与上述footprint的计算一致?
- 是否有一个明确的方法来计算类型footprint?
- 是否有在就地构造非连续类型对象的解决方案?
解决方案
我并不认为就地构造本身有问题。在 BBBxxB 的示例中,如果在同一类型的每个对象中都需要 xx 字节,那么它们就是填充字节,由 sizeof 计入。如果并非必须,那么在需要时可以把 BBBxxB 压缩为 BBBB。
不要把“可能非连续”与“必须非连续”混淆。
sizeof 的含义
另一个问题是,对于非连续类型,
sizeof的返回值对我来说不太清楚(参见 https://eel.is/c++draft/expr.sizeof)。
到底有哪些不清楚的地方?
当应用于一个类时,结果是该类对象占用的字节数,其中包括为了将对象放入数组而需要的任何填充。
给定一个类型 T,将该类型的对象放入一个数组,设为 T arr[2]。由于 数组必然是连续的,&arr[0] 与 &arr[1] 之间的字节数就是在数组中放置 T 对象所需的字节数。(任何需要的“对象表示之外的字节”但仍在对象边界内的字节都变成填充。)换句话说,sizeof(T) 等于你为 footprint<T>() 计算出的值。
由于 sizeof(T) 也被定义为表示类型 T 的非 可能重叠的对象所占的字节数,可以得出:类型 T 的非可能重叠对象所占用的字节数等于在数组中放置 T 对象所需的字节数。作为推论,放入数组时所需的任何填充字节都被对象占据。
一个不是连续的对象
注:到目前为止,实际操作中我还无法创建一个在实践中不是连续的类型。
如果你指的是对象从不连续的类型,那你前面的任务相当艰巨。数组是连续分配的,因此能够为给定类型构造一个数组,意味着该类型可以拥有连续的对象(例如数组的元素)。你也许可以在一个抽象类上取得一些进展,但这留给其他人处理。
如果你只是指一个对象可能是非连续的类型,倒也并不难。虚拟继承使这一点相当自然。
struct Base { char z; };
struct Derived1 : virtual Base { int x; };
struct Derived2 : virtual Base { float y; };
struct Composite : Derived1, Derived2 {};
Composite object;
Derived1 &sub1 = object;
在我的尝试中,我输出了 sub1 和 sub1.z 的地址,发现差值为28,而 sizeof(sub1) 只有16。根据我对寻址方案的理解,我推断 sub1 实际占用了16个非连续字节。内存布局大概如下所示:
x y z
------------
| Base |
------------ |----------|
| Derived1 | | Derived1 | <--- Omitted bytes in the middle
-----------+---------------------|
| Derived2 Derived2 |
-----------------------
(你的实际情况可能不同。在其他环境中,取决于虚基基所在的位置,可能是 Derived2 子对象不是连续的,或者,理论上,你可能需要一个 Derived3 类来得到一个非连续对象。)
问题摘要
- 是否存在对连续性的正式定义,并且它是否与上述footprint的计算一致?
并非严格定义,但 ([intro.memory]/1) 指出“供C++程序使用的内存由一个或多个连续字节序列组成。”这为实现对什么是“连续”在具体实现中的含义留出空间。另一方面,可以假设在每种实现中,字节在某种意义上是可以被认为是连续的。
将“连续”定义为除了字节以外的对象时,定义通常取决于引入术语的人。(我不清楚标准是否提及“连续对象”或“连续类型”)。一个“连续对象”可以是占用连续存储字节的对象,或者更强地说,其数据成员彼此之间没有填充且完全相邻。一个“连续类型”可以是所有对象都是连续的类型,,也可以是某些对象可能是连续的类型。截至我所知,还没有关于这些概念的正式定义。
我不认为什么计算“footprint”会与对“除了footprint以外的定义”的一致性产生冲突。你对“footprint”的定义建立在字节连续的概念上,所以两者在本质上对我来说是一致的。(我想我误解了这里所要提出的问题。抱歉。)
- 是否有一个明确的方法来计算类型footprint
是的。使用 sizeof。
- 是否有在就地构造非连续类型对象的解决方案
无需解决方案,因为不存在问题。如果你有 sizeof(T) 个连续字节,那么就可以在这些字节中构造类型为 T 的对象。你并不需要保证 T 的所有对象都是连续的,就能确保通过 new 表达式直接构造的对象占用连续字节。
来自 [expr.new] 的要点如下
- 对于
new T和new (storage) T,除了空间分配的方式/是否分配之外,要求是一致的。 - 请求的空间量是
sizeof(T)(即使不会分配空间)。 - 存储块被假定为所请求的大小(即
sizeof(T))。
因此,对于一个 new 表达式,编译器有义务使新构造的类型为 T 的对象适合放在 sizeof(T) 字节的块中。
更详细地讲:
17:由 new 表达式请求的空间不得小于被创建对象的大小(数组的情况可以更大)。
20:分配函数的第一个参数是请求的空间大小(非数组对象的大小即所创建对象的大小)。
21:一个示例表明对于 new T,空间请求由 sizeof(T) 给出。
22:重载解析可能选择一个不分配的 operator new,它只是按原样返回指针。(这是就地构造与通常动态分配之间要求的唯一差异。尤其是,就地构造和动态分配都要求提供相同数量的空间。)
23:指向的存储被假定为所请求的大小。因此,对于就地构造,前提是假设程序员已确保有 sizeof(T) 字节可用。编译器必须遵守这一假设。