C++中对象的延迟构造

news/2024/11/16 5:29:17/文章来源:https://www.cnblogs.com/apocelipes/p/18415570

本文并不讨论“延迟初始化”或者是“懒加载的单例”那样的东西,本文要讨论的是分配某一类型所需的空间后不对类型进行构造(即对象的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++标准语法。

回到正题,这么写有两个坏处:

  1. 类型Elem必须能被默认初始化,否则就得在构造函数里把array里的每一个元素都初始化
  2. 我们申请了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[this->size*sizeof(Elem)]) 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[idx*sizeof(Elem)]);
}

析构函数则需要我们主动去调用Elem的析构函数,因为array里存的是byte,它可不会帮我析构Elem对象:

~small_vec()
{for (std::size_t idx = 0; idx < size; ++idx) {Elem *e = reinterpret_cast<Elem*>(&this->data[idx*sizeof(Elem)]);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; // 什么也不会输出
}

输出:

如果是struct LazyData则会输出“constructor”和“destructor”这两行文字。所以我们能看到构造函数的执行确实被跳过了

union还有好处是可以自动计算类型需要的大小和对齐,现在我们的数组索引就是对象的索引,代码简单很多:

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;
};

方案2也不会自动构造元素,所以添加元素依旧要依赖placement new,这里我们使用前文提到的std::construct_at简化代码:

void small_vec::push_back(const Elem &e)
{// 检查size是否超过data的上限,没超过才能继续添加新元素std::construct_at(std::addressof(this->data[this->size++].value), e);
}

获取元素也相对简单,因为不需要再强制类型转换了:

Elem& small_vec::at(std::size_t idx)
{if (idx >= this->size) {throw Error{};}return this->data[idx].value;
}

析构函数也是一样,需要我们手动析构,这里我就不写了。另外千万别在union的析构函数里析构它的任何成员,别忘了union的成员可以跳过构造函数的调用,这时你去它的调用析构函数是个未定义行为。

方案2比1来的简单,但依旧有需要手动构造和析构的烦恼,如果你哪个地方忘记了就要出内存错误了。

使用std::optional

前两个方案都依赖size来区分对象是否初始化,且需要手动管理对象的生命周期,这些都是潜在的风险,因为手动的总是不牢靠的。

std::optional正好能用来解决这个问题,虽然它本来不是为此而生的。

std::optional可以存某个类型的值或者表示没有值的“空”,正好对于前两个方案的对象是否被构造;而optional的默认构造函数只会构造一个处于“空”状态的optional对象,这意味着Elem不会被构造。最重要的是对于存储在其中的值,optional会自动管理它的生命周期,在该析构的时候就析构。

现在代码可以改成这样:

template <typename Elem, std::size_t N>
class small_vec
{std::array<std::optional<Elem>, N> data; // 自动管理生命周期std::size_t size = 0;
};

因为不用再手动析构,所以small_vec现在甚至连析构函数都可以不写,交给默认生成的就行。

添加和获取元素也变得很简单,添加就是对optional赋值,获取则是调用optional的成员函数:

void small_vec::push_back(const Elem &e)
{// 检查size是否超过data的上限,没超过才能继续添加新元素this->data[size] = e;
}Elem& small_vec::at(std::size_t idx)
{if (idx >= this->size) {throw Error{};}return *this->data[idx]; // 也可以用value(),但optional里是空的这里会抛出std::bad_optional_access异常
}

但用optional不是没有代价的:optional为了区分状态是否为空需要一个额外的标志位来记录自己的状态信息,它需要额外占用内存,但我们实际上可以通过size来判断是否有值存在,索引小于size的optional肯定是有值的,所以这个额外的开销显得有些没必要,而且optional内部的很多方法需要额外判断当前状态,效率也稍差一些。

判断状态带来的额外开销通常是无所谓的除非在性能热点里,但额外的内存花费就比较棘手了,尤其是在栈这种空间资源有限的地方上。我们来看看具体的开销:

union ArrElem
{ArrElem() {}~ArrElem() {}long value;
};int main()
{ArrElem arr1[10];std::optional<long> arr2[10];std::cout << "sizeof long: " << sizeof(long) << '\n';std::cout << "sizeof ArrElem arr1[10]: " << sizeof(arr1) << '\n';std::cout << "sizeof std::optional<long> arr2[10]: " << sizeof(arr2) << '\n';
}

MSVC上long是4字节的,所以输出如下:

在Linux x64的GCC下long是8字节的,输出变成这样:

也就是说用optional你就要浪费整整一倍的内存。

所以很多容器库都是选择方案2或者1,比如谷歌;方案3很少被用在这样的库中。

总结

为啥我没推荐std::variant呢,它不是union在现代c++里的首选替代品吗?

原因是除了和optional一样浪费内存外,它还强制要求第一个模板参数的类型必须能默认构造,否则必须用std::monostate做填充,所以在延迟构造的场景里用它你既浪费了内存又让代码变得啰嗦,没啥明显的好处,并不推荐。

方案1其实也不推荐,因为像在刀尖上跳舞,武艺高强的自然用着不错,但只要一个疏忽就万劫不复了。

我的建议是如果只想要延迟构造对浪费内存不怎么敏感,那么就选择std::optional,否则就选方案2。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/797747.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

黑客失误?76.2万车主,家庭住址信息泄露

​据Cybernews研究团队发现,一个包含76.2W名车主及其车辆详细信息的敏感数据库已经在网上泄露。这些数据托管在一个美国的IP地址上,首次发现是在8月4日,至少暴露了48小时。据该团队称,泄露的数据揭示了车主的敏感信息。泄露的细节几乎揭示了拥有车辆的个人的所有信息,包括…

C++ 在 Visual Studio 如何将指针星号设置成靠近变量而不是类型

“工具”->“选项”->“文本编辑器”->“C/C++”->“代码样式”->“格式设置”->“间距”->“指针/引用对齐方式”->“右对齐”。

Git冲突解决技巧

在多人协作的软件开发项目中,Git 冲突是不可避免的现象。当两个或更多的开发者同时修改了同一段代码,并且尝试将这些修改合并到一起时,冲突就发生了。解决这些冲突是确保代码库健康和项目顺利进行的关键。在多人协作的软件开发项目中,Git 冲突是不可避免的现象。当两个或更…

AI写作助手哪些好用?6款强大的AI写作助手值得收藏!

在内容创作日益重要的今天,AI写作助手已经成为许多创作者的得力工具。它们不仅能够提高写作效率,还能在一定程度上保证文章质量。面对市场上琳琅满目的AI写作助手,如何选择一款好用且适合自己的工具呢?以下推荐6款强大的AI写作助手,它们各具优势,助力创作者轻松应对各种写…

1928.规定时间内到达终点的最小话费,题解

1928. 规定时间内到达终点的最小花费 - 力扣(LeetCode) 有点难,参考官方题解代码: 利用了动态规划思想,逐步计算从起点到各个城市在不同时间下的最小费用。 1.代码解释,涉及,static关键字,constexpr关键字,INT_MAX除以2赋值的含义static constexpr int INFTY = INT_MA…

软件技术基础的第一次作业

这个作业属于哪个课程 https://edu.cnblogs.com/campus/zjlg/rjjc这个作业的目标 向教师和助教介绍自己,并熟悉博客园 对课程的期望姓名 颜宇航学号 20223293011251.个人介绍 大家好,我是22自动化2班的颜宇航,来自浙江台州。很高兴能够在这里与大家分享自己的生活!我是一个…

帝国cms密码忘记,帝国cms网站忘记登陆账号密码

如果你忘记了帝国CMS的管理员账号和密码,可以通过以下几种方法来找回或重置密码: 1. 通过数据库直接修改密码 如果你对数据库操作比较熟悉,可以采用此方法:登录数据库管理工具:使用phpMyAdmin或其他数据库管理工具登录到你的数据库。 找到管理员表:在数据库中找到存储管理…

Ubuntu Server 24.04 LTS 安装记事

Ubuntu Server 24.04 LTS,代号 Noble Numbat,于 2024 年 4 月 25 日正式发布,该发布版可以支持长达12年。 曾经接触Ubuntu的时候,还是它的第一个版本,但是那个时候网络太局限了,关于这个系统的书籍也很少,所以就一直没有使用起来。 有时候好像什么事情都得有个名头,就像…

帝国cms密码忘记,帝国cms忘记登陆账号密码

如果你忘记了帝国CMS的管理员账号和密码,可以通过以下几种方法来找回或重置密码: 1. 通过数据库直接修改密码 如果你对数据库操作比较熟悉,可以采用此方法:登录数据库管理工具:使用phpMyAdmin或其他数据库管理工具登录到你的数据库。 找到管理员表:在数据库中找到存储管理…

1张超级“支付清算架构”图

在支付行业的快速发展中,理解和掌握支付清算架构对于从业人员来说至关重要。本文将通过一张精心绘制的“超级支付清算架构图”,带领读者深入探索支付生态的全貌。这张图不仅包含了丰富的支付组织、系统建设和账户基础等信息,而且通过高维度抽象,展示了它们之间复杂的交互关…