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[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。
C++中对象的延迟构造的更多相关文章
- .net中对象序列化技术浅谈
.net中对象序列化技术浅谈 2009-03-11 阅读2756评论2 序列化是将对象状态转换为可保持或传输的格式的过程.与序列化相对的是反序列化,它将流转换为对象.这两个过程结合起来,可以轻松地存储 ...
- C++中对象初始化
在C++中对象要在使用前初始化,永远在使用对象之前先将它初始化. 1.对于无任何成员的内置类型,必须手工完成此事. 例如: int x=0; double d; std::cin>>d; ...
- 【你不知道的javaScript 上卷 笔记7】javaScript中对象的[[Prototype]]机制
[[Prototype]]机制 [[Prototype]]是对象内部的隐试属性,指向一个内部的链接,这个链接的作用是:如果在对象上没有找到需要的属性或者方法引用,引擎就 会继续在 [[Prototyp ...
- JVM 堆中对象分配、布局和访问
本文摘自深入理解 Java 虚拟机第三版 对象的创建 Java 是一门面向对象的语言,Java 程序运行过程中无时无刻都有对象被创建出来.从语言层面看,创建对象只是一个 new 关键字而已,而在虚拟机 ...
- Java中对象的生与灭- 核心篇
前言 大家好啊,我是汤圆,今天给大家带来的是<Java中对象的生与灭- 核心篇>,希望对大家有帮助,谢谢 文章纯属原创,个人总结难免有差错,如果有,麻烦在评论区回复或后台私信,谢啦 简介 ...
- 2020-BUAA-OO-面向对象设计与构造-第四单元总结&课程总结
咱的OO结束辣! Part1: Unit4 Summary 本单元作业,我主要使用了适配器模式和访问者模式.总体上看,代码量和文件数量有所上升,但配合分包等措施后,文件结构清晰,各部分耦合度均较低.缺 ...
- 你真的了解JAVA中对象和类、this、super和static关键字吗
作者:小牛呼噜噜 | https://xiaoniuhululu.com 计算机内功.JAVA底层.面试相关资料等更多精彩文章在公众号「小牛呼噜噜 」 目录 Java对象究竟是什么? 创建对象的过程 ...
- C#中对象,字符串,dataTable、DataReader、DataSet,对象集合转换成Json字符串方法。
C#中对象,字符串,dataTable.DataReader.DataSet,对象集合转换成Json字符串方法. public class ConvertJson { #region 私有方法 /// ...
- JVM中对象的创建过程
JVM中对象的创建过程如以下流程图中所示: 对其主要步骤进行详细阐述: 为新生对象分配内存: 内存的分配方式: 指针碰撞:假设Java堆中内存是绝对规整的,所有用过的内存放在一边,空闲的内存在另一边, ...
- js中对象使用
简单记录javascript中对象的使用 一.创建对象 //创建一个空对象 var o={}; //创建一个含有两个属性的对象,x.y var o2={x:12,y:'12',name:'JS'}; ...
随机推荐
- MiniAuth 一个轻量 ASP.NET Core Identity Web 后台管理中间插件
MiniAuth 一个轻量 ASP.NET Core Identity Web 后台管理中间插件 「一行代码」为「新.旧项目」 添加 Identity 系统跟用户.权限管理网页后台系统 开箱即用,避免 ...
- 微软CrowdStrike驱动蓝屏以及内核签名
原因 当Windows操作系统遇到严重错误导致系统崩溃时,屏幕显示为蓝色,通常伴有错误代码和信息,这被称为"蓝屏死机"(Blue Screen of Death,简称BSOD) h ...
- Docker PHP容器安装composer
1.进入php容器docker exec -it cb6c1fe83bff(php容器ID) bash2.安装composerphp -r "copy('https://install.ph ...
- ORACLE PL/SQL 对象、表数据对比功能存储过程简单实现
最近帮忙跟进个oracle11g upgrade 升级到19c 的项目,由于业主方不太熟悉oracle upgrade相关升级流程,以及升级影响范围相关的事项,担心应用停机升级以后会导致数据库保存的业 ...
- 【Uni-App】API笔记 P1
1.调试打印: console.log() 向控制台打印 log 日志 console.debug() 向控制台打印 debug 日志 注:App 端自定义组件模式下,debug 方法等同于 log ...
- 元学习的经典文献:S. Thrun - 1998 - LEARNING TO LEARN: INTRODUCTION AND OVERVIEW
地址: https://link.springer.com/chapter/10.1007/978-1-4615-5529-2_1
- 中国的AI领域发展的重大不足 —— 数据缺少,尤其是自然语言领域(NLP)、大模型领域
全世界公开可用的语言文本中绝大部分是英文文本,其中中文文本只有1.5% 相关: China's Betting Big On Artificial Intelligence. Could The US ...
- CUDA11.3编译pytorch2.0.1报错:error: ‘nvmlProcessInfo_v1_t’ was not declared in this scope
问题如题: CUDA11.3编译pytorch2.0.1报错:error: 'nvmlProcessInfo_v1_t' was not declared in this scope 解决方法参考: ...
- PowerBI_一分钟学会计算门店开业前3天销售金额_计算列及度量值方法
在某些特殊场景,我们往往需要去计算一些特定的组别的聚合数据 今天,就以计算门店开业前3天的销售情况,来学习一下,利用计算列和DAX度量值,两种快捷计算此类问题的方案. 一:XMIND 二:示例数据 2 ...
- 性能测试面试题大曝光,让你如何迅速拿下 offer!
性能测试面试题精选 1. 以前做过性能测试么?请结合例子具体说明性能测试的流程 面试考察点:性能测试的流程 首选做性能测试的需求分析,明确性能测试的目标.范围.场景和性能指标(如响应时间.吞吐量.并发 ...