C++ 中对象的延迟构造
本文并不讨论“延迟初始化”或者是“懒加载的单例”那样的东西,本文要讨论的是分配某一类型所需的空间后不对类型进行构造(即对象的 lifetime 没有开始),更通俗点说,就是跳过对象的构造函数执行。
使用场景
我们知道,不管是定义某个类型的对象还是用operator new
申请内存,对象的构造函数都是会立刻被执行的。这也是大部分时间我们所期望的行为。
但还有少数时间我们希望对象的构造不是立刻执行,而是能被延后。
懒加载就是上述场景之一,也许对象的构造开销很大,因此我们希望确实需要它的时候才进行创建。
另一个场景则是在 small_vector 这样的容器里。
small_vector 会事先申请一块栈空间,然后提供类似 vector 的 api 来让用户插入/删除/更新元素。栈不像堆那样可以方便地动态申请空间,所以通常需要栈空间的代码会这样写:
我知道还有类似 alloc 这样的函数可以用,然而它性能欠佳而且可移植性差,你能找到的有关它的资料基本都会说不推荐用在生产环境里,VLA 同理,VLA 甚至不是的 c++标准语法。
回到正题,这么写有两个坏处:
类型 Elem 必须能被默认初始化,否则就得在构造函数里把 array 里的每一个元素都初始化
我们申请了 10 个 Elem 的空间,但最后只用了 8 个(对 vector 这样的容器来说这是常见场景),但我们却要构造 Elem 十次,显然是浪费,更坏的是这些默认构造处理的对象是没用的,后面 push_back 的时候就会被覆盖掉,所以这十次构造都是不应该出现的。
c++讲究一个不要为自己用不到的东西付出代价,因此在 small_vec 等基于栈空间的容器上延迟构造是个迫切的需求。
作为一门追求性能和表现力的语言,c++在实现这样的需求上有不少方案可选,我们挑三种常见的介绍。
利用 std::byte 和 placement new
第一种方法比较取巧。c++允许对象的内存数据和std::byte
之间进行互相转换,所以第一种方案是用std::byte
的数组/容器替代原来的对象数组,这样因为构造数组的时候只有std::byte
,不会对 Elem 进行构造,而std::byte
的构造是平凡的,也就是什么都不做(但因为 std::array 的聚合初始化会被初始化为零值)。
这样自然绕过了 Elem 的构造函数。我们来看看代码:
除了注释那条之外,还要当心申请的空间超出系统设定的栈大小。
我说这个办法比较取巧,是因为我们没有直接构造 Elem,而是拿std::byte
做了替代,虽然现在确实不会默认构造 N 个 Elem 对象了,但我们真正需要获取/存储 Elem 的时候代码就会变得复杂。
首先是 push_back,在这个函数里我们需要借助“placement new”来在连续的std::byte
上构造对象:
可以看到我们直接在对应的位置上构建了一个 Elem 对象,如果你能用 c++20,那么还要个可以简化代码的包装函数std::construct_at
可用。
获取的代码看起来比较繁琐,主要是因为需要类型转换:
析构函数则需要我们主动去调用 Elem 的析构函数,因为 array 里存的是 byte,它可不会帮我析构 Elem 对象:
这个方案是最常见的,因为不止可以在栈上用。当然这个方案也很容易出错,因为我们需要随时计算对象所在的真正的索引,还得时刻关注对象是否应该被析构,心智负担比较重。
使用 union
c++里通常不推荐直接用 union,要用也得是 tagged union。
然而 union 在跳过构造/析构上是天生的好手:如果 union 的成员有非平凡默认构造/析构函数,那么 union 自己的默认构造函数和析构函数会被删除需要用户自己重新定义,而且 union 保证除了构造函数和析构函数里明确写出的,不会初始化或销毁任何成员。
这意味 union 天生就能跳过自己成员的构造函数,而我们只用再写一个什么都不做的 union 的默认构造函数,就可以保证 union 的成员的构造函数不会被自动执行了。
看个例子:
输出:
如果是struct LazyData
则会输出“constructor”和“destructor”这两行文字。所以我们能看到构造函数的执行确实被跳过了
union 还有好处是可以自动计算类型需要的大小和对齐,现在我们的数组索引就是对象的索引,代码简单很多:
方案 2 也不会自动构造元素,所以添加元素依旧要依赖 placement new,这里我们使用前文提到的std::construct_at
简化代码:
获取元素也相对简单,因为不需要再强制类型转换了:
析构函数也是一样,需要我们手动析构,这里我就不写了。另外千万别在 union 的析构函数里析构它的任何成员,别忘了 union 的成员可以跳过构造函数的调用,这时你去它的调用析构函数是个未定义行为。
方案 2 比 1 来的简单,但依旧有需要手动构造和析构的烦恼,如果你哪个地方忘记了就要出内存错误了。
使用 std::optional
前两个方案都依赖 size 来区分对象是否初始化,且需要手动管理对象的生命周期,这些都是潜在的风险,因为手动的总是不牢靠的。
std::optional
正好能用来解决这个问题,虽然它本来不是为此而生的。
std::optional
可以存某个类型的值或者表示没有值的“空”,正好对于前两个方案的对象是否被构造;而 optional 的默认构造函数只会构造一个处于“空”状态的 optional 对象,这意味着 Elem 不会被构造。最重要的是对于存储在其中的值,optional 会自动管理它的生命周期,在该析构的时候就析构。
现在代码可以改成这样:
因为不用再手动析构,所以 small_vec 现在甚至连析构函数都可以不写,交给默认生成的就行。
添加和获取元素也变得很简单,添加就是对 optional 赋值,获取则是调用 optional 的成员函数:
但用 optional 不是没有代价的:optional 为了区分状态是否为空需要一个额外的标志位来记录自己的状态信息,它需要额外占用内存,但我们实际上可以通过 size 来判断是否有值存在,索引小于 size 的 optional 肯定是有值的,所以这个额外的开销显得有些没必要,而且 optional 内部的很多方法需要额外判断当前状态,效率也稍差一些。
判断状态带来的额外开销通常是无所谓的除非在性能热点里,但额外的内存花费就比较棘手了,尤其是在栈这种空间资源有限的地方上。我们来看看具体的开销:
MSVC 上 long 是 4 字节的,所以输出如下:
在 Linux x64 的 GCC 下 long 是 8 字节的,输出变成这样:
也就是说用 optional 你就要浪费整整一倍的内存。
所以很多容器库都是选择方案 2 或者 1,比如谷歌;方案 3 很少被用在这样的库中。
总结
为啥我没推荐std::variant
呢,它不是 union 在现代 c++里的首选替代品吗?
原因是除了和 optional 一样浪费内存外,它还强制要求第一个模板参数的类型必须能默认构造,否则必须用std::monostate
做填充,所以在延迟构造的场景里用它你既浪费了内存又让代码变得啰嗦,没啥明显的好处,并不推荐。
方案 1 其实也不推荐,因为像在刀尖上跳舞,武艺高强的自然用着不错,但只要一个疏忽就万劫不复了。
我的建议是如果只想要延迟构造对浪费内存不怎么敏感,那么就选择std::optional
,否则就选方案 2。
文章转载自:apocelipes
评论