C++ 杂谈
本文是我在知乎上的小回答的一个整理,内容比较杂而且语言随意,可能不够严谨。
编译器生成的函数的行为
C++ 从早期开始就会在满足条件的情况下隐式生成一些函数以及成员函数,例如默认构造函数,复制构造函数,赋值操作符的重载以及析构函数;C++ 11 后有多了两个函数:移动构造函数和赋值操作符对右值的重载;并且可以使用 default 关键字来主动的生成这些函数,用于在这些函数被抑制的时候重新指示编译器生成这些函数,或者单纯是为了清晰性显式声明。
这些函数有可能是空函数也可能不是空函数,实际上依赖于数据成员(以及基类,基类可以看作是特殊的成员)是否存在有副作用的实现确定的。
例如:
struct A {
int i;
A() = default;
};
struct B {
std::string i;
B() = default;
};
由于 A
的作为唯一非静态数据成员的 int
类型的 i
是基本类型,不存在默认构造,因此编译器生成的 A
的默认构造的函数是空函数。
相比之下,B
的成员 i
是 std::string
这种自定义类型,因此编译器生成的默认构造函数内存在 i
的默认构造的调用。
虽然编译器会生成空函数,但并不代表这些无副作用函数会被使用(产生调用),任何合格的编译器都不会真的去调用一个空函数。
同理,如果所有成员的析构都是无副作用的,则生成的析构是无副作用的。如果有成员的析构有副作用,则调用该成员的析构。
此外,编译器生成的复制构造,对基本类型成员采用逐成员复制,非基本类型则采用对应的复制构造。
编译器生成的复制赋值,对基本类型成员采用逐成员复制,非基本类型则采用对应的复制赋值。
编译器生成的移动构造,对基本类型成员采用逐成员复制(因为基本类型的移动就是复制),非基本类型则采用对应的移动构造(如果有复制没有移动则再回退到复制)。
编译器生成的移动赋值,对基本类型成员采用逐成员复制,非基本类型则采用对应的移动赋值(如果有复制没有移动则再回退到复制)。
复制消除以及返回值优化
函数的返回值显然要对 caller 可见,因此必定保存在 caller 的栈或者寄存器内,基本类型、引用以及指针都可以直接复制,没有额外开销,不在此文范围内;对于自定义类型,当储存在寄存器时则和基本类型一致,当储存在栈时则可能需要考虑是否存在复制/移动。
在 C++ 中,return
语句后面接的对象并不一定等价于那个在栈上或者寄存器中储存的返回值,因此可以进行以下分析:
考虑最简单的情况 1,返回一个匿名的临时对象(纯右值),并且返回值类型等于 return
的操作数的类型,显然这个对象可以直接构造在返回值上,没有任何理由需要复制一份(自然,也不需要移动),C++ 17 开始强制执行该优化,因此 C++17 开始一定能保证不存在纯右值临时量的构造。
考虑情况 2:假设 return X().s
,x
和 s
显然是是纯右值临时对象,但是 s
是 X
的一部分,由于不能将 s
和 X
割裂储存,因此不能进行复制消除。
考虑情况 3:假设 return s
,s
是一个左值,这时候需要考虑 s
是否能直接储存在返回值上,假设 s
来源于函数参数或者 s
具有静态储存周期、线程局部储存周期、动态储存周期、或者 s
是某个对象的成员(类似情况 2),显然 s
不能在返回值上储存。但假设 s
不是上述情况(生命周期完全在该函数作用域内),则s
能被储存于返回值中。这时进行的就是 NRVO,即具名返回值优化,也是一种复制消除,由于该条件较苛刻,很难考虑到所有情况,因此 C++ 没有强制要求实现 NRVO。
当上述任何复制消除都不能被满足时,则编译器可以采取第二种策略:使用移动代替赋值。
考虑情况 4:在情况 2 的时候,由于 return
的操作数和返回值不能共用储存,但是 return
的操作数确实是右值,此时显然可以使用移动来替代赋值(假设允许移动)。
考虑情况 5:假设存在满足情况 3 的左值 x
,但 return
的操作数是 x
的成员,则此时也可使用移动代替复制,因为在 return
之前 x
以及 x
的成员都是亡值。
C++ 标准保证情况 1,允许情况 3,4,5,同时情况 4,5 如果不信任编译器则可以手动进行移动。上述列出来的情况并不覆盖所有情况,只覆盖常见情况并提供分析思路。
此外,如果 caller 使用的是声明及初始化语法,或者在声明后第一次使用时即为赋值,则直接重用 caller 中该变量的储存空间,如果不是,则要考虑作为右值的返回值是的复制或者移动。
LIBC 是 ABI 的决定者
此段内容的问题为为什么使用 x86-64-mingw-w64-gcc 编译 64 位程序时,long
的宽度是 32 位而不是 64 位(64 位 Linux 的 long 为 64 位)。
因为 64 位的 MSVCRT 和 UCRT 的 long
是 32 位,所以 mingw-w64 的 long
也必须是 32 位。知乎上那些把 ABI 兼容放嘴边的人根本就没理解一件事,就是 C 和 C++ 的 ABI 是 LIBC 决定的。为了实现系统调用,所有 C 和 C++ 程序都要链接 LIBC,而不同 LIBC 里面的符号以及对类型的解释是不一样的。
stdio 里面的 fseek
的第二个参数是 long
,而在 Windows 的 UCRT 和 MSVCRT 里 long
的长度是 32 位,因此 mingw-w64 为了正确使用以及类型安全,不能自己把 long
改成 64 位,不然链接之后传参传 64 位的,运行的时候会被 fseek
截断,就炸了。64 位 Linux 使用的 long 为 64 位是因为 glibc 或者 musl 选择了 64 位。
因为这个原因,C 和 C++ 的 ABI 从来没有统一一说,因为有的平台上 long
是 64 位,有的平台上 int
是 64 位,怎么统一?符号名统一是个 *,有什么用呢?一切都是 LIBC 决定的,因此注定一个 LIBC 一个 ABI。这也就是 mingw-w64 分 MSVCRT 和 UCRT 的原因(因为 MSVCRT 和 UCRT 的符号以及结构体声明不一样),也是 x86_64-linux-gcc 区分 glibc 和 musl 的原因。目标 LIBC 不同的两个编译中间产物,是不能链接在一起的。
Rust 要进行系统调用也得按照规矩来,不是你说 64 位就 64 位,你说 32 位就 32 位,LIBC 才起决定性作用。C/C++ 之所以跨平台就是因为编译器保证了基本数据类型和 LIBC 一致,因此只要符号名一致就可以保证安全,不会出现数据被截断,因此可以根据符号名直接链接到 LIBC 上,而不用中间做其他转换。
另外 C 这个 * 玩意函数传参不检查类型,传的参数类型不一致也不报错。当年我给 sqrt
传了个 int
,C 是不负责把 int
转成 double
的,而 gcc 即使知道这件事也没报错,于是我获得了一个 INF。C++ 里面就会帮你转换,这就是 C++ 隐式转换的来源。
此外,Windows 的 UCRT 虽然是 “C” 库,但是实际上是 C++ 实现的(LIBC 用于实现系统调用接口,提供 C/C++ 运行环境),并且微软实际上通过 UCRT 这次重构解决了以往纯 C 实现的 MSVCRT 的 ABI 不稳定的问题,可以参考这个博文 伟大的C运行时(CRT)重构;同时,LLVM 也在使用 C++ 实现 LIBC,所谓的 C++ ABI 不稳定实际上是个伪命题。
对象移动后可以继续使用
C++ 的移动和 Rust 的所有权是相似但不相同的两个东西,都承担了一些设计导致的代价(Rust 是需要程序员写所有权,还有使用一些包装类)。
C++ 的移动转移的不是所有权而是状态(资源随状态进行移动),并且由于历史问题导致在使用存在一些问题,下面会讲。
前提是移动是在对象上发生的,这很明显,因为移动构造和移动赋值都是对象到对象,这两个是移动的根本(这好像是句废话,但不是)。
第一个问题在于以对象本身(值)为参数和以左值引用为参数的两个重载是有歧义的,以值为参数的重载能接收任何实参:值(右值)和左值引用。这将导致如果我们把参数设计为值(使得其可以转移对象)时,将不能存在左值引用的重载。想一想对于容器,我们显然需要同时需要左值和右值的版本,但是目前来说做不到。
那么一个非常无奈而且迷惑的设计就被发明了:右值引用。
由于右值引用为参数时,实参只能为右值,因此对于容器的插入函数我们就可以设计两个重载,一个是左值引用,一个是右值引用,以满足我们的需求,并且不使用值为参数的重载。
第二个问题在于右值引用本身,实际上看了之前那一段,我们可以有一个想法:既然左值引用和值存在冲突,那么我们能不能直接发明一种表示右值为参数的想法呢?是可以的,但是很遗憾,C++ 标准委员会没有选择这种做法。
因此,第二个问题产生了:右值引用不是对象。右值引用不是对象,导致绑定右值引用是不触发移动构造和移动赋值的,因此 std::move
原本设计的意思是转移资源,但现在多了绑定到右值引用的情况,它就不是转移资源了。可以考虑到标准制定人可能觉得对象的移动构造仍然有开销,选用了没开销的方式。
第三个问题在于,C++ 对象的生命周期由其储存周期决定,而不是任何可见的代码,析构函数调用的位置显然是由储存周期决定的(实际上构造也是)(除了动态内存分配的情况,需要手动析构,但此时编译器也不负责插入析构),因此,无论你写了什么样的代码,编译器插入的析构函数调用就在那里。
因此,一个对象被移动了,其也必须可以被析构。
经典的移动赋值是逐个成员交换,移动构造是先构造一个空对象再交换。
现在就可以得出结论了:
- C++ 里面析构函数的插入是不受任何用户写的代码的影响的,因此为了防止析构野指针,移动后对象必须处于合法状态,即保证其可以进行安全的析构。
- 单线程情况下,由于需要保证1,我可以非常肯定的说,移动后的对象是可以继续用的。以容器
std::vector
举例,若该对象和空对象进行移动了,那么其内部肯定size
和capacity
全0
,指针也为0
,若和非空对象进行移动,则可能导致其内部有残留的数据,但是这些数据是合法的,由于对象必须可析构,所以实际上可用先手动析构(和容器的clear
效果一样)再用布置new
去原位构造一个新对象。 - 在多线程环境下,如果以右值引用的方式传给新线程,那么则有可能(极有可能)存在竞争,并且这种代码是需要严格禁止的,因为有可能造成悬垂引用。
一般来讲是没必要重用被移动后的对象的,因为你还需要手动的析构或者clear,新开个对象就变成了编译器帮你,实际上就只增加了点栈空间。极少数的例外是std::swap
,它必须这么实现,因此 C++ 必须使得移动后的对象可以继续使用。
零五法则
C++ 中持有资源的函数一般需要遵循如下规则:要么一个都不该实现,要么都实现。
这些函数包括复制构造函数,复制赋值函数,移动构造函数,移动赋值函数以及析构函数,因为这些函数都涉及到资源管理。不包括其他的构造函数。
假设一个类不持有资源,那么他就不需要析构函数,并且复制构造函数也不需要申请资源,复制赋值函数就不需要释放旧的资源并申请新的资源,移动构造和移动赋值函数就不需要释放旧资源移动新资源,只需要机械的复制每一个成员即可,这正是编译器生成的函数所做的事。
此外,假设一个类属于上述描述,那么其默认构造应该是将所有成员清零,而不是去申请资源,也不是什么也不干,这基于两点原因:1. 有些资源无法默认构造,例如网络连接;2. 假设该类可作为容器的成员,例如 std::vector
,那么在使用 resize
进行扩容时就需要默认构造元素,并且这些元素会马上被新对象覆盖掉,因此不能使得 resize
在扩容时做无用的资源申请,并且扩容之后应当允许对 std::vector
进行析构,如果默认构造 什么也不做,将导致容器储存野值随后析构野指针。
函数抛异常情况
C++ 中函数只有两种可能,一种是潜在抛出异常,一种是不抛出异常。不抛出异常有两种情况,第一种是有些函数按标准默认不抛出异常(析构函数),第二种是手动添加noexcept
说明符的函数不抛出异常。
由于无法保证函数不抛出异常,所以对于所有不抛出异常的函数,在函数内部如果抛出了异常,则调用 std::terminate
。
根据 C++ Reference 的说法,不抛出异常的函数如果内部抛出了异常,则栈展开停止于不抛出异常的函数内的抛出点,并且调用 std::terminate
,这和我的理解和经验一致。
上面说过,因为无法保证函数不抛出异常,所以 noexcept
运算按照上述所说的规则进行判断,和函数体内写的任何东西都没关系。
最后,不抛出异常的函数就一个有用的效果:不抛出异常的函数可以不用在该函数上,以及该函数的调用点添加栈展开的代码,使得编译器更容易进行优化,并减小二进制体积。
当下由于移动构造有可能抛异常,因此有一些容器(例如 std::vector
)在 resize
需要重新进行分配时,会根据移动构造是否抛出异常来决定采取复制还是移动的策略。