C++中对象的耽误构造
本文并不讨论“耽误初始化”或者是“懒加载的单例”那样的东西,本文要讨论的是分配某一类型所需的空间后不对类型举行构造(即对象的lifetime没有开始),更通俗点说,就是跳过对象的构造函数执行。使用场景
我们知道,不管是界说某个类型的对象照旧用operator new申请内存,对象的构造函数都是会立刻被执行的。这也是大部门时间我们所盼望的行为。
但另有少数时间我们希望对象的构造不是立刻执行,而是能被延后。
懒加载就是上述场景之一,大概对象的构造开销很大,因此我们希望确实需要它的时间才举行创建。
另一个场景则是在small_vector这样的容器里。
small_vector会事先申请一块栈空间,然后提供类似vector的api来让用户插入/删除/更新元素。栈不像堆那样可以方便地震态申请空间,所以通常需要栈空间的代码会这样写:
template <typename Elem, std::size_t N>
class small_vec
{
std::array<Elem, N> data;
};我知道另有类似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的构造函数。我们来看看代码:
template <typename Elem, std::size_t N>
class small_vec
{
static_assert(SIZE_T_MAX/N > sizeof(Elem)); // 防止size_t回环导致申请的空间小于所需值
alignas(Elem) std::array<std::byte, sizeof(Elem)*N> data; // 除了要计算大小,对齐也需要正确设置,否则会出错
std::size_t size = 0;
};除了解释那条之外,还要当心申请的空间超出系统设定的栈大小。
我说这个办法比力取巧,是因为我们没有直接构造Elem,而是拿std::byte做了替代,虽然现在确实不会默认构造N个Elem对象了,但我们真正需要获取/存储Elem的时间代码就会变得复杂。
首先是push_back,在这个函数里我们需要借助“placement new”来在连续的std::byte上构造对象:
void small_vec::push_back(const Elem &e)
{
// 检查size是否超过data的上限,没超过才能继续添加新元素
new(&this->data) Elem(e);
++this->size;
}可以看到我们直接在对应的位置上构建了一个Elem对象,假如你能用c++20,那么还要个可以简化代码的包装函数std::construct_at可用。
获取的代码看起来比力繁琐,紧张是因为需要类型转换:
Elem& small_vec::at(std::size_t idx)
{
if (idx >= this->size) {
throw Error{};
}
return *reinterpret_cast<Elem*>(&this->data);
}析构函数则需要我们自动去调用Elem的析构函数,因为array里存的是byte,它可不会帮我析构Elem对象:
~small_vec()
{
for (std::size_t idx = 0; idx < size; ++idx) {
Elem *e = reinterpret_cast<Elem*>(&this->data);
e->~Elem();
}
}这个方案是最常见的,因为不止可以在栈上用。当然这个方案也很轻易出错,因为我们需要随时盘算对象所在的真正的索引,还得时候关注对象是否应该被析构,心智负担比力重。
使用union
c++里通常不保举直接用union,要用也得是tagged union。
然而union在跳过构造/析构上是天生的好手:假如union的成员有非平凡默认构造/析构函数,那么union自己的默认构造函数和析构函数会被删除需要用户自己重新界说,而且union保证除了构造函数和析构函数里明白写出的,不会初始化或烧毁任何成员。
这意味union天生就能跳过自己成员的构造函数,而我们只用再写一个什么都不做的union的默认构造函数,就可以保证union的成员的构造函数不会被自动执行了。
看个例子:
class Data
{
public:
Data()
{
std::cout << "constructor\n";
}
~Data()
{
std::cout << "destructor\n";
}
};
union LazyData
{
LazyData() {}
~LazyData() {} // 可以试试删了这两行然后看看报错加深理解
Data data;
};
int main()
{
LazyData d; // 什么也不会输出
}获取元素也相对简朴,因为不需要再强制类型转换了:
template <typename Elem, std::size_t N>
class small_vec
{
union ArrElem
{
ArrElem() {}
~ArrElem() {}
Elem value;
};
std::array<ArrElem, N> data; // 不用再手动计算大小和对齐,不容易出错
std::size_t size = 0;
};析构函数也是一样,需要我们手动析构,这里我就不写了。另外万万别在union的析构函数里析构它的任何成员,别忘了union的成员可以跳过构造函数的调用,这时你去它的调用析构函数是个未界说行为。
方案2比1来的简朴,但依旧有需要手动构造和析构的烦恼,假如你哪个地方忘记了就要出内存错误了。
使用std::optional
前两个方案都依赖size来区分对象是否初始化,且需要手动管理对象的生命周期,这些都是潜在的风险,因为手动的总是不牢靠的。
std::optional恰好能用来解决这个题目,虽然它本来不是为此而生的。
std::optional可以存某个类型的值或者表示没有值的“空”,恰好对于前两个方案的对象是否被构造;而optional的默认构造函数只会构造一个处于“空”状态的optional对象,这意味着Elem不会被构造。最紧张的是对于存储在其中的值,optional会自动管理它的生命周期,在该析构的时间就析构。
现在代码可以改成这样:
void small_vec::push_back(const Elem &e)
{
// 检查size是否超过data的上限,没超过才能继续添加新元素
std::construct_at(std::addressof(this->data.value), e);
}因为不消再手动析构,所以small_vec现在乃至连析构函数都可以不写,交给默认生成的就行。
添加和获取元素也变得很简朴,添加就是对optional赋值,获取则是调用optional的成员函数:
Elem& small_vec::at(std::size_t idx)
{
if (idx >= this->size) {
throw Error{};
}
return this->data.value;
}但用optional不是没有代价的:optional为了区分状态是否为空需要一个额外的标志位来记录自己的状态信息,它需要额外占用内存,但我们现实上可以通过size来判定是否有值存在,索引小于size的optional肯定是有值的,所以这个额外的开销显得有些没须要,而且optional内部的很多方法需要额外判定当前状态,效率也稍差一些。
判定状态带来的额外开销通常是无所谓的除非在性能热点里,但额外的内存耗费就比力棘手了,尤其是在栈这种空间资源有限的地方上。我们来看看具体的开销:
union ArrElem{ ArrElem() {} ~ArrElem() {} long value;};int main(){ ArrElem arr1; std::optional arr2; std::cout
页:
[1]