@SovietPower
2024-02-20T22:48:13.000000Z
字数 76920
阅读 565
笔记
cppcon 社区:https://cppcon.org/about/
Effective Modern C++:https://cntransgroup.github.io/EffectiveModernCppChinese/
C++ 标准提案:https://timsong-cpp.github.io/cppwp/
C++ 17:https://timsong-cpp.github.io/cppwp/n4659
下载:https://www.open-std.org/jtc1/sc22/wg21/docs/standardsBenchmark:https://quick-bench.com
libstd 实现:https://github.com/gcc-mirror/gcc/tree/master/libstdc%2B%2B-v3/include/std
clang 内部接口文档:https://clang.llvm.org/doxygen/glibc 源码:https://elixir.bootlin.com/glibc/glibc-2.39/source/
类型
对象、引用、函数、表达式 都有一种称为类型的性质。
类型分类:
此外还有额外的分类(如果存在,则都包括对应的 cv 限定类型,省略):
char、(un)signed char、char8_t (C++20) 称为 窄字符类型;char16_t、char32_t、wchar_t 称为 宽字符类型。
但是 (un)signed char 不属于字符类型,而是属于 无/有符号整数类型。
POD
POD (plain old data)(简旧数据类型)中 plain 指它是一个普通/平凡的类型,old 指与 C 兼容。
POD 是一个与 C 兼容的类型,它没有虚函数、虚继承等 C++ 的新特性,还可以使用 memset 或 memcpy 进行初始化或拷贝。
所有标量类型 (非 数组/类/结构体/联合) 和 满足 平凡的、标准布局的 两个特性、且没有非 POD 类型的非静态成员的类/结构体 是 POD 类型,它们的构成的数组也是 POD 类型(具体见cppref)。
满足 平凡的、标准布局的 类或结构体,实际上就对应 C 中的 struct(C++ 的 struct 也是为了兼容 C,但在 C++ 中变成了与 class 基本一样)。
内置类型是 POD 的。
只有 POD 类型才可以作为 union 的成员。union 很大部分也是为了兼容 C?
通过is_pod
判断一个类型是否为 POD。
还有一个must_be_pod
让编译器确保一个类型一定是 POD 的。
POD 的优点:
POD 的特点:
满足下面所有条件的 类/结构体 为平凡的/平凡类 (is_trivial):
满足下面所有条件的 类/结构体 为标准布局 (is_standard_layout):
所有非静态成员有相同的访问权限 (只有 public/private/protected 一种)。
没有虚函数和虚基类。
要在同一个类中声明所有非静态数据成员(全在派生类或全在某个基类)。即派生类和(多个)基类之间,只能有一个类有非静态成员。
对于一个派生类,其第一个非静态成员的类型不能是其基类。
如:struct B : A { A a; };
不满足,struct B : A { int t; A a; };
满足。
这个是因为 C++ 要求相同类型的对象必须地址不同而产生的:设一个类 B 中包含了某个空类 A,如果 B 继承自 A,且 B 的第一个成员是一个 A 类型的成员 a,则这个空类 a 仍然需要占用1字节。如果不分配1字节,则两个 A 类型对象 (B 的实例与成员 a) 会拥有相同的地址。
如果 B 不继承自 A,或 B 的第一个成员不是 A 类型,则空类不会占用空间,POD 就要满足这点。
所有非静态数据成员均符合标准布局,其基类也符合标准布局。
C++ 要求相同类型的对象必须地址不同,但不同类型的对象地址可以相同。因为地址也是对象标识的一部分。
像构造函数中经常会有自我赋值的检查if (this != &other)
,如果不同类型的对象可以有相同地址,那这个检查就是无效的了,没办法识别不同对象。
标准布局类型 (Standard Layout Type) 必须应用空基类优化,来保证指向标准布局对象的指针在用 reinterpret_cast 转换后还指向其首成员。这是标准布局要求 3,4 的原因。
静态数据、成员函数是不会影响内存布局的。
标准布局的类不允许编译器在里面加额外的东西,非标准布局的类可以(比如多态类的虚表里可以放 RTTI)。
未定义行为 / 各类 behavior
C++ 标准中一共规定有四类 behavior,分别是 well-defined behavior、implementation-defined behavior、unspecified behavior 以及 undefined behavior。
https://zh.cppreference.com/w/cpp/language/ub
正确的 C++ 程序不存在 UB,因此编译器可以在不存在 UB 的假设下进行优化。为什么会有未定义行为,不都做出规定?
具体见 https://zhuanlan.zhihu.com/p/391088391 ,简单来说:
- abstract machine 只是一个假想的模型,实际上的硬件/软件环境太多,在某个平台上的 well-defined behavior 可能是另一个平台上的 undefined behavior。
比如大部分 CPU 上,有符号整数的溢出是一个 perfectly well-defined behavior,但在某些 CPU 芯片上,有符号整数溢出却会导致 trap,或是被保留到最大值或最小值;绝大部分平台上,解引用空指针会 trap,但某些嵌入式平台上,读写 0 地址是完全合法的;而且空指针是否就是 0 也不一定?
对这些在不同的平台上存在严重分歧甚至 trap 的行为,将其归为未定义行为,因为程序的结果将取决于更底层的操作系统或硬件设计。- 再好的语言设计也无法保证程序在关键数据损坏的前提下,仍然拥有预期的行为。
比如某 bug 导致某对象的虚表指针被修改、两个类型完全不兼容的指针发生了 alias(见 严格别名),都不能指望程序依然拥有预期的行为。因此标准规定在数据受到损坏时,任何与损坏的数据发生交互的行为都是未定义行为。- 消灭未定义行为的代价就是限制语言的能力(如不能直接读写内存、不能操作指针),以及大量的编译期或运行期检查。但 C++ 设计上就不是受太多限制的,且编译/运行期检查并不能完全检测所有 UB,还会影响编译和运行效率(如数组越界、空指针检查),所以不如直接放弃检测。
- 不约束 UB 如何处理,可以允许编译器有更好的优化能力。
编译器通常不会考虑 UB 的影响,甚至假设程序没有任何 UB 并以此进行优化。但这也导致编译器不会对某些错误给出警告。例子见上链接。
well-defined behavior
标准明确规定的所有的 C++ implementation 都需要实现和遵守的行为。
一个抽象机器从初始状态开始,执行一个仅包含 well-defined behavior 的程序,最终一定处于一个确定的、由标准明确规定的最终状态。
程序必须良构、且没有实现定义行为、未指明行为、未定义行为,才能保证其行为是由标准规定的。
implementation-defined behavior
标准没有明确规定、但要求每个 C++ implementation 必须在其文档中明确规定的行为。
一个抽象机器从初始状态开始,执行一个仅包含 well-defined behavior 和 implementation-defined behavior 的程序后,abstract machine 一定处于一个确定的、由 C++ implementation 的文档所明确指明的状态。
Well-defined behavior 和 implementation-defined behavior 都规定了 abstract machine 的确定性行为。
比如:表达式sizeof(int)
。
unspecified behavior
标准没有明确规定、也不要求每个 C++ implementation 必须在其文档中明确规定的行为。但标准会规定一组可能的行为,unspecified behavior 的具体运行时行为只能是这一组可能的行为中的一个或多个。
它规定了抽象机器的非确定性状态转移:抽象机器从一个初始的状态开始,执行一个包含 unspecified behavior 的程序,最终状态可能是标准所限定的若干最终状态中的一个。
比如:求值顺序f(g1(), g2())
、g1() + g2()
。
undefined behavior
标准没有明确规定、不要求每个 C++ implementation 在其文档中明确规定、且标准没有对具体行为施加任何限制的行为。因此任何处理方式都是符合标准要求的,假设它不存在也是合理的。
它规定了抽象机器的非确定性状态转移:抽象机器从一个初始的状态开始,执行一个包含 undefined behavior 的程序,最终状态可能是任何一个状态。标准没有对最终状态施加任何限制。
比如:使用未初始化的标量类型、数组越界、有符号整数溢出、有/无符号数左/右移超过64位、空指针解引用、非 void 函数执行完成但没有返回值(应该声明__attribute__((noreturn))
)、无副作用的死循环。
(注:C++20 规定了有符号数要以补码实现,因此溢出就是高位截断,不是 UB 吗?移位运算不再有 UB)
非良构 (ill-formed)
程序拥有语法错误或可诊断的语义错误。遵从标准的编译器必须为此给出诊断。
执行非良构程序也是 ub。
非良构而不要求诊断:程序拥有通常情况下可能无法诊断的语义错误(例如 ODR 的违规或者其他只能在链接时检测的错误)。
as-if rule
as-if 规则指:编译器可以对程序进行任何修改,只要保证以下几点:
由于编译器通常不能分析外部库代码,来确定它是否执行或影响 IO/volatile,所以无法对其进行这种随意优化。
复制消除和 new 表达式是例外,即使它们有可观测副作用,编译器也可以优化掉。
零开销抽象 (zero overhead)
零开销抽象指:不会为没使用的功能付出代价;而对使用了的功能,无法写出更好的代码(它的开销是必须且已经最小的)。
zero overhead 并不是 zero cost,overhead 指的是额外的、非必要的成本。
初始化
C++ 的初始化和赋值是严格分开的,对于尚不存在的对象,一定是构造而非赋值。
C++ 有多种初始化方式:
不使用初始化器构造变量时执行的初始化。
如果是类,则检查是否有无参构造并调用;如果是数组,对每个元素进行默认初始化;否则不进行初始化。
静态和线程局部变量会进行零初始化,其它变量则为不确定值。const变量则要求必须能进行默认初始化。
注意,对于类会调用基类和成员的默认初始化(如果在初始化列表中定义,则按列表来);POD 类型的默认初始化是不进行初始化。
如果类没有无参构造,则 CE。
new T;
Node a;
以空初始化器列表进行的初始化。小括号花括号都可。
如果是类,如果有默认构造函数,则零初始化,否则默认初始化(调用无参构造)。
如果是数组,对每个元素进行值初始化;否则零初始化。
new T(); // 空括号即为值初始化
new T{};
Node a{}, b();
调用对应构造函数初始化。只能用小括号。
int y{0};
Node a(1, 2);
C风格,不提供错误检查和类型安全性。
int x = 0;
int h = {0};
return {1};
复制初始化选择构造函数时,不会考虑 explicit 构造函数和用户定义转换函数。这就是 explicit 的含义或实现方式。
struct A {
explicit A(int) {}
A(double) {}
};
// 调用 A(int)
A a(1);
A b = A(1);
// 调用 A(double),因为只有该函数可选
A c = 1; // int只能通过直接初始化构造(即显式使用构造函数)
在 C++17 前需要对象可拷贝。所以代码atomic<int> a = 0;
可能会CE (use of deleted func 'atomic(const atomic&)'),需要直接初始化。C++17 后该写法不需要走拷贝构造(复制消除)。
当赋值表达式是同类型的临时量时,复制消除 允许直接将临时量在对象上进行构造,以消除复制或移动构造函数。
只能用花括号。
例外:C++20 起聚合体和数组能用小括号进行聚合初始化,但不能有指派符。
但这又导致小括号初始化引用时,不会延长绑定的临时量的生存期。
比如:struct A{ int&& v; };
,A{1}
会延长右值的生存期,但A(1)
不会,会有悬垂引用。
int arr[] {1, 2}; // 直接列表初始化
int arr[] = {1, 2}; // 复制列表初始化
vector<int> v {1, 2};
vector<int> v = {1, 2};
vector<Node> v {1}; // 注意,如果1无法转换成T,那么将选择其它的构造函数而非vector(initializer_list),比如此处将会选择vector(size)设置大小为1
聚合初始化 (aggregate initialization) 是列表初始化的一种,只适用于聚合体。
聚合体 (aggregate) 包含两种类型:数组,符合下面条件的类:
聚合体可以用{...}
依次初始化类中的各成员(按声明顺序)。如果成员也是聚合体,则用嵌套{...}
初始化,如:Node x = {1, {1, 2}};
(如果是用的等号赋值可以忽略内部大括号)。
当初始化器列表数量少于成员数量时,只显式初始化前面这些成员。
没有显式初始化的成员,如果定义了默认成员初始化器(如int v{3}
)则使用;否则,如果它不是引用,以空初始化器列表初始化它(即值初始化);否则程序非良构。
C++11 中,默认成员初始化器将导致类不再是聚合体,不能进行聚合初始化。
聚合初始化限制比较大,且只能依次初始化,只适用于简单的结构体。
C++20 起聚合初始化支持用指派初始化器初始化聚合体。
指派符的顺序必须与声明顺序一致。没有初始化的元素规则同上。
struct A {
int a;
bool b{true};
string c;
};
A a {1, true, "abc"};
A b {.a = 2, .c = "abc"};
array 是聚合体(包含一个 T 数组),所以能进行聚合初始化,比如:
array<int, 3> arr {{ 1, 2, 3 }};
。
在某些未修正的远古 C++11 中(不用考虑),array 没有接收 init_list 的构造函数,因此arr {1, 2, 3};
会错误。
零初始化 (zero-initialization) 的规则为:
注意,在构造函数函数体内写成员的赋值,并不算第一步初始化;如果成员没有使用初始化列表或指定默认值,则会在进入构造函数前调用默认构造完成初始化,构造函数内进行的赋值只是一次额外的赋值操作。
如下例中,x, y 均被默认初始化一次,但随后 y 被额外赋值一次。所以最好使用初始化列表。
struct Node {
Node(int a, int b): x(a) {
y = b;
}
int x, y;
};
使用 {} 初始化 / 列表初始化
{...} 会被转换为一个 initializer_list(见 STL - initializer_list)。如果对象有 initializer_list 参数的构造函数则完整传入,否则将里面的元素依次传入构造函数。
注意,如果对象有 initializer_list 的构造函数重载(或对象是一个模板类型 T),则
T{...}
将调用该重载,而不是其它构造函数。
因此如果要避免走初始化列表,要用T(...)
而非大括号!想走初始化列表应该用T({...})
。
常见的情况是 vector、string:
cout << string(48, 'a') << '\n'; // 48个a
cout << string{48, 'a'} << '\n'; // 0a
auto f = [](auto vec) {
for (auto v: vec) printf("%d ", v);
puts("");
};
f(vector<int>(3, 1)); // 1 1 1
f(vector<int>{3, 1}); // 3 1
更推荐使用现代的大括号初始化(尤其是基本类型),除非类有 initializer_list 构造函数(所以自定义的类需要谨慎设计)。
优势:
// 可以初始化容器
vector<int> v{1,2,3,4}; // ok
vector<int> v2(1,2,3,4); // error
// 可以为非静态成员指定默认值。没有在成员初始化器列表中赋值时会使用。
// 见 *面向对象 - 成员初始化*
class Node {
int x{0}; // ok
int y = 0; // ok
int z(0); // error
};
double x, y;
int sum1{ x + y }; // 编译错误
int sum2(x + y); // 编译通过,但是x + y精度会丢失
int sum3 = x + y; // 同上
struct A {
A() {puts("create A");}
};
A a(); // 声明了一个返回A的函数,而非创建A对象
A b{}; // 调用无参构造,创建A对象
A c(1); // ok,不能看做函数声明
A d(int(val)); // 不行,可看做函数A func(int val)
缺点:
auto x{1}
的 x 类型是 int,但auto x = {1}
的类型是 std::initializer_list。
class A{
public:
A(int i, bool b) {}
A(int i, double b) {}
A(string s) {}
A(initializer_list<long double> l) {
puts("here");
}
};
// 调用A(int i, bool b)
A a1(1, true);
// 转换实参类型,都调用initializer_list的重载
A a2{10, true};
A a3{10, 5.0};
// 实参无法转为long double,才选择其它重载
A a4{"abc"};
// 显式调用initializer_list的重载
A a5{{}};
A a6({});
A a7({1, 2});
most vexing parse (最烦人的解析)
指一个违反直觉的语法解析规则。在以下情况下,C++语法解析器无法区分 对象的创建 和 函数的声明,会统一按函数声明处理。
double v = 0;
// 以下两个都将被视为函数声明,而非创建对象
int x(int(v));
Node node(int(v));
// 解决方法:
// 1. 使用 {}
int x{int(v)};
Node node{int(v)};
// 2. 使用其它类型转换方式
int x(static_cast<int>(v));
struct Timer {};
struct TimeKeeper {
explicit TimeKeeper(Timer t);
int get_time();
};
int func() {
TimeKeeper keeper(Timer());
return keeper.get_time();
}
// 正确方式:
// 1. 使用 {}
// TimeKeeper keeper{Timer()};
// 2. 使用原始的等号赋值
// TimeKeeper keeper = TimeKeeper(Timer());
// 3. 使用额外括号,避免被当做函数
// TimerKeeper keeper((Timer()));
keeper 声明语句有两种解释:用一个匿名对象 Timer() 创建和构造 TimeKeeper 对象;声明一个函数,它返回 TimeKeeper,参数为一个函数指针,指向一个返回 Timer 的无参函数。
C++ 将按后者处理,所以 keeper 并不是类对象。
严格别名规则 (strict aliasing)
https://zhuanlan.zhihu.com/p/595286568
https://zh.cppreference.com/w/cpp/language/reinterpret_castC++ 中,如果有多个左值指向同一内存地址,那它们之间互称为别名 (alias)。比如
int i=1, *p=&i;
,i 是 *p 的别名,反之同理。
类型双关 (type punning) 指绕过类型系统,将一个对象或一块内存解释为不同的类型。类型双关的实现方式就是别名。比如定义
int x = 1;
和float* p = reinterpret_cast<float*>(&x);
,*p 是 x 的别名,但不是合法别名,因此虽然确实能按 float 的方式访问 int,但在某些情况下,严格别名优化将导致 UB。
如果别名类型 P 不是 T 的兼容类型(合法别名),那么通过 P 类型的泛左值修改或读取动态类型为 T 的对象时(比如强制类型转换),行为未定义。
严格别名规则是为了效率,对程序安全性的部分放弃,需要程序员注意。想要安全地进行类型双关,见 pun_cast(或直接用 bit_cast)。
注意除了类型兼容,还要保证两者对齐一致。
比如char[]
与int
不能直接双关,因为前者的对齐边界为 1。但可以通过alignas
手动对齐以保证正确。
char arr[4] = {1,0,0,0};
int x = *reinterpret_cast<int*>(arr); // UB,对齐不同
// (uintptr_t)(arr) % sizeof(int) != 0 的情况下即违反了对齐要求
alignas(alignof(int)) char arr[4] = {1,0,0,0};
int x = *reinterpret_cast<int*>(arr); // ok
严格别名是一种优化,通过-fstrict-aliasing
开启(O2 包括),通过-fno-strict-aliasing
关闭避免带来错误。
如果编译器始终假设 任何两个指针都可能指向同一地址(正确做法),那么优化空间将会很小、影响效率。
因此,如果 T1, T2 类型并不兼容,或说 T1 类型的指针不是 T2 指针的合法别名,编译器将认为 T1、T2 类型的两个指针绝对不会指向同一地址。
对于动态类型为 T 的对象,如果指针类型 P* 满足以下条件之一:
(动态类型指 new 时使用的类型,仅 malloc 分配的空间并非已初始化合法内存,还需 placement new)
int*
与const int*
)。则称 T 与 P 类型兼容,或说 P* 类型的指针是 T* 指针的合法别名,编译器将假设它们可能指向同一地址,不做激进优化。
不只函数参数,函数内使用的任何指针都遵循该规则进行优化。
int x;
// char* 与 int* 兼容,符合严格别名规则,编译器将认为 p1,p2 可能会指向同一内存区域,不做优化
void foo(char *p1, int *p2);
foo((char*)(&x), &x);
// float* 与 int* 不兼容,不符合严格别名规则,编译器将认为 p1,p2 绝不指向同一内存区域,以此优化
void foo(int *p1, float *p2);
foo(&x, (float*)(&x));
// 产生错误结果的例子
// 编译器认为 i 绝不是 f 的别名,因此直接将返回值优化为 return 1,不关心 f
int foo(int *i, float *f) {
*i = 1;
*f = 0.f;
return *i;
}
int x = 0;
cout << foo(&x, (float*)(&x)); // 输出1
因此,如果 T, P 不兼容,将 T* 类型的指针强转为 P* 类型的指针使用,可能导致 UB。比如int32_t*
与int64_t*
。
编译器将假设程序员遵守严格别名规则,只将指针转为与其类型兼容的指针,并以此优化。
(unsigned) char 与 byte 是例外,即任何类型可以安全地转为 char,再从 char 转回原类型。(如果 uint8 是 unsigned char 的宏定义,那么自然也 ok)
通过 char 做中转,转为不兼容类型是不行的,因为其动态类型并不是 char,还是原类型。
有时候,编译器遵守严格别名也可能过于保守。如果两个指针类型兼容,但它们不可能指向同一地址,那么可以告诉编译器允许其优化,见下面的 restrict。
注意,成员函数中 this 将作为指针参数传递,如果函数用到 char(或相似类型)指针 p,则它们是兼容的,所以编译器会假设 p 与 this 可能指向同一地址,即其修改可能互相影响。如果 p 又是类的成员(即访问需要this->p
),那么每次修改 p 再使用 p,都需要重新获取 p 的地址,导致无意义的访存(因为编译器认为修改 p 可能修改 this,从而导致 p 变化)。
解决方式可以用 restrict,或者将 p 的地址保存到临时变量,避免每次访问this->p
。
代码见 https://godbolt.org/z/sGrsjYP8M。
相似的例子:
struct S { int a, b; };
// int* 和 struct S* 可以别名使用,因为 S 拥有 int 类型的成员
void f2(int *pi, S *ps, S s)
{
// 每次通过 *ps 写入后,必须重新进行 *pi 的读取
for (int i = 0; i < *pi; i++)
*ps++ = s;
}
restrict
restrict是 C 中的关键字,修饰指针(类似 const 要放在右侧才是修饰指针),表明该指针不会发生 pointer aliasing(保证该指针不会和其它某个指针指向同一块内存地址)。
当函数内可能存在 aliasing 时,只要对某个指针指向的区域进行修改,后续访问其它指针也必须进行访存。但如果指针之间不会指向同一区域,那么一个指针的修改不会导致某个指针需要重新进行访存。
例:
add1 中因为可能存在 aliasing,因此不能假设 a、b 指向不同区域,因此修改 b 后需要再对 a 进行访存确定其值:当 a、b 指向不同时,结果为 3;指向相同时,结果为 4。
add2 中明确 a、b 指向不同,因此可使用寄存器中的值计算、甚至直接确定返回值为 3,不需要多余访存。
int add1(int* a, int* b)
{
*a = 1;
*b = 2;
return *a + *b;
}
int add2(int* __restrict a, int* __restrict b)
{
*a = 1;
*b = 2;
return *a + *b;
}
restrict 能允许编译器做更多优化,在使用指针进行运算的函数内应该声明。但注意如果 a、b 指向相同却被声明 restrict,则为 UB。
C++ 标准中没有 restrict,但很多编译器实现了类似功能,如:gcc、clang 的 __restrict(放在成员函数后可以修饰 this)。
名字查找 / 如何决定要使用哪个同名函数/同名变量
https://zh.cppreference.com/w/cpp/language/unqualified_lookup
有限定的标识符
有限定就是在标识表达式前面加上作用域解析运算符::
,以及一个命名空间/类/枚举的名字(或表示类/枚举的 decltype 表达式)。
重载决议
https://zh.cppreference.com/w/cpp/language/overload_resolution
简单来讲就是三步:
在涉及模板时,第一步会进行相应类型的模板实例化、建立候选函数(模板能生成最佳的候选类型;但除非是万能引用,否则不会自动添加 &;也不会添加 cv);第二步淘汰不符合 requires 和无法匹配的实例化(比如 SFINAE。它也可能在第一步)。
因此,当没有完全与参数类型匹配的非模板函数、且模板函数是万能引用时,模板函数将总是被优先选择,不会考虑隐式转换然后调用普通函数(除非有 SFINAE、requires 等限制)。
因此,如果有模板函数f(T&& x)
与f(const A&)
,A、A&、A&& 都将匹配前者。这个问题常在模板构造函数中出现。函数模板部分见 模板 - 函数特化的匹配规则。
简单来说,重载优先级从高到低分为三级:
...
)。同类之间可能有更细致的优先级划分。
转换最多进行3次,优先级取决于其中最低的。
比如:
void f(std::string);
void f(bool);
// 调用 f(bool),因为到 bool 的转换优先于用户定义的到 string 的转换
f("abc");
// 使用模板可以生成最佳匹配,从而保证匹配
// 可以使用简写函数模板,然后用 concept 限制类型
void f(std::convertible_to<std::string> auto &&);
void f(bool);
// 调用 void f(auto:16&&) [with auto:16 = const char (&)[4]]
f("abc");
注意,模板能生成最佳匹配的函数(除了引用和 cv 需要注意)。
C 的重载可通过 _Generic(泛型选择)实现:定义接收不同参数的不同名函数(比如 max_int、max_double),然后定义宏(比如 max),利用 _Generic 根据参数类型决定调用哪个函数。
对象
TODO
类型或对象的对象表示中不属于值表示的位是填充位 (padding bits),用来实现内存对齐和特定大小的位域。
读写填充位是 UB。
位域 (bit field)
位域允许声明具有以位为单位的明确大小的类数据成员,只能是整型或枚举。
位数可以定义很多,但不会超过原类型的值域,多余位为填充位。
如:类中定义unsigned int b: 3;
为3位,值域0~7。
多个相邻位域通常会打包在一起(但这是实现定义行为)。
一个 T 类型的位域所使用的位,不能跨过其对齐边界(包括匿名位域占据的填充位),即位域和匿名位域仍要满足对齐要求。
比如 unsigned char 不能使用从某字节开始的 6 ~ 9 位,因为跨越了对齐边界;uint16_t 可以使用第 2k 字节开始的 6 ~ 9 位,但不能是第 2k+1 字节开始的(会跨越 2k+2)。
匿名位域 (unnamed bit field) 会引入指定数量的填充位,并且不会影响对象的对齐。
当经过匿名位域填充后,该类型剩余的位数不足以存放下一个位域,或在此存放将跨越对齐边界时,就需要在新的分配单元开始下一个位域(零大小匿名位域强制进行该过程)。
struct S {
// 通常会占用3个字节:
unsigned char b1 : 2; // 第1个字节开始,前2位为 b1
unsigned char : 2; // 下2位被跳过,未使用
unsigned char b2 : 6; // 第1个字节只剩4位,unsiged char不能跨过字节,因此要开始第2个字节,前6位给 b2
unsigned char : 0;
unsigned char b3 : 2; // 即使第2个字节足够,但零填充强制开始新的字节
unsigned int : 6; // 刚好使用剩下的6位,且不影响结构体的对齐边界(不需要4字节对齐)
};
struct S2 {
// 通常会占用8个字节:
unsigned char b1 : 4;
unsigned int : 29; // 如果放在前面的4字节,会跨越第4字节,因此需要从第5字节开始放
// 如果是28,则大小为4字节,能正好利用4字节剩下的位不跨越
};
生存期 (lifetime)
每个对象和引用都有生存期。访问生存期外的对象是 UB。
具体见文档、C++17 - launder。
隐式生存期类型 (Implicit Lifetime Type)
https://zh.cppreference.com/w/cpp/language/object
https://zh.cppreference.com/w/cpp/named_req/ImplicitLifetimeType
见 C++17 - launder。
创建对象的过程
创建对象有两种:
静态,如A a
,直接移动栈指针,然后在这片栈空间调用构造函数。
动态,如A *ptr = new A
,会在堆上为a分配空间,然后在栈上创建一个指针,指向堆。
new operator 与 operator new
前者是内置的 new 操作符或叫 new 表达式,不可被重载。对于A *a = new A
,包含两步:调用operator new(sizeof(A))
分配内存,调用 A 的构造函数,最后返回指针。
后者是类中可重载的函数(且可被子类继承),默认有三种重载形式。前两种不会调用构造函数,第三种是 placement new,见下。
// 通过 A* a = new A; 调用
// 通过捕获 bad_alloc 异常,检查是否分配成功
void* operator new (std::size_t size) throw (std::bad_alloc);
// 不会抛出异常,通过 A* a = new(std::nothrow) A; 调用
// 通过判断返回值是否为 nullptr,检查是否分配成功
void* operator new (std::size_t size, const std::nothrow_t& nothrow_constant) throw();
// 通过 new (p)A(); 或 new (p)A(list) 调用,p 是一个指针,list 是 A 类型的初始化列表。会调用构造函数
void* operator new (std::size_t size, void* ptr) throw();
// [] 版本类似,专门用于数组
void* operator new[](size_t);
void* operator delete[](void*);
普通 new 分配失败时会抛出异常,所以要检测错误,最好是用new (std::nothrow) A
然后再判指针是否为空。
但通常不会在意这个失败?
可以重载其它参数或类型(但第一个参数必须是size_t),具体见 operator new。例:
// placement new 的一个重载。如下函数会调用 new (T)A();
// 但注意,要定义相应的 delete
void* operator new (std::size_t size, const T& ptr) throw();
operator new 默认是以 malloc 实现,operator delete 则是 free。
一个简单的实现:
extern void* operator new( size_t size )
{
// if( size == 0 ) ... // 处理 new T[0] 这样的语句
void *last_alloc;
while( !(last_alloc = malloc( size )) )
{
if( _new_handler )
( *_new_handler )(); //调用handler函数
else
return 0;
}
return last_alloc;
}
extern void operator delete( void *ptr )
{
if(ptr) // delete 空指针是安全的
free( (char*)ptr );
}
注意析构函数不应抛出异常,见 面向对象 - 析构函数抛出异常。
如果只想处理未被初始化的内存,可直接调用operator new 获取内存和 operator delete 释放内存,如void *p = operator new(sizeof(A)); operator delete(p);
。
placement new
格式:A *p = new (ptr)type
或A *p = new (ptr)type(initializer-list)
。
placement new(就地构造)不分配内存,而是直接调用构造函数在 ptr 所指的位置构造一个对象,并返回 ptr。
placement new 既可以在栈上生成对象,也可以在堆上,取决于参数 ptr。
例:void *ptr = malloc(sizeof(A)); A *p = new (ptr)A;
或直接A *ptr = (A*)malloc(sizeof(A)); new (ptr)A;
(调用 A 的构造函数)。
在 placement new 调用构造函数时,如果构造函数抛出异常,将会执行相应的 placement delete 来回收空间,避免内存泄露(返回值将不是一个合法的指针,所以在外部无法回收)。所以如果定义了某种形式的 placement new,就要定义相应的 placement delete。
一般与内存池配合使用,用来调用构造函数初始化。
new 与 new() (操作符)
在堆上创建对象分为两步:
operator new
函数,在堆空间中搜索合适的内存并进行分配(对于数组是operator new[]
)。下面的方法不准确但也对。
T x
、new A
是默认初始化,对于类对象将调用无参构造,对于数组对象则依次进行默认初始化,否则不进行初始化。T x{}
、new A{}
是值初始化,带有初始化器(括号)。
对于类和结构体来说:
new A
对于类对象,调用其默认构造(实际效果是除了调用每个成员的无参构造函数外,不会做任何初始化),不会额外的初始化或置 0;new A()
会调用默认构造并进行初始化清 0。new A
与new A()
相同,都执行定义的无参构造,不会做额外的初始化。new A
和new A()
都会编译失败。因为new A(x, y)
实际就是调用构造函数A(x, y)
。基本类型也是这样,但基本类型没有定义无参构造、使用默认构造(实际是什么也不干,不会置 0),所以new int
仅仅分配内存,后面加()
(new int()
)才会进行赋 0 值的初始化。
如:int *ptr = new int[5]
后对应空间内为随机值,但int *ptr = new int[5]()
后对应空间为0。
但是编译器有可能会主动初始化为 0,高优化等级可能会使初始化不发生。
delete 与 delete[]
注:delete 空指针是安全的(不会做任何事)。
delete 后可以把指针置空,避免指针被释放多次。但这不是必须的,有时这完全无意义(比如析构函数内),有时会隐藏原本的问题:出问题说明程序逻辑上可能有问题,你不能控制资源的释放时机。最好肯定是确保指针恰好只被释放一次、释放后就不再使用。new[] 和 new 的区别是要不要额外记录对象数量,便于调用析构函数。但对底层的 allocator 来说,只要满足 alloc 传入 size 就可,free 不用传入 size,只需要一个指针。
delete 释放由 new 创建的单个对象,delete[] 释放由 new[] 创建的数组对象。两者不可混用,否则会导致 UB/RE。
但对于没有定义析构函数的类型(如内置类型、未定义析构函数的结构体),两者没有区别,可混用。原因如下。
delete 包含两步:调用指针所指向的对象的析构函数;调用operator delete()
(默认实现为 free())回收指针所指向的内存。
delete[] 也包含两步:调用指针所指向的数组中的每个对象的析构函数;调用 free() 回收指针所指向的整个数组的内存。
在进行分配内存时,系统会记录分配内存的大小,如果没有析构函数,就不需要知道每个元素的具体大小、每个元素的位置来逐个调用析构,直接释放这块内存就可以了。
但对于有析构函数的类型的数组,要知道每个元素的大小或位置。编译器会在这种类型的数组首地址前,再申请一块空间,记录分配的元素数量,内存块大小/数量就可以得到元素大小。
class TestA
{
public:
int x;
TestA() { }
virtual ~TestA() { cout << "~A" << endl; }
};
int main() {
int* arr = new int[10];
cout << *((long long*)arr - 1) << endl; // 输出随机数
delete[] arr;
TestA* a = new TestA[10];
cout << *((long long*)a - 1) << endl; // 输出10。32位是占4B,64位是8B?
delete[] a; // 输出 10 个 ~A
}
所以对于有析构函数的类型的数组,delete[] 会从数组首地址前的个数开始回收:对于TestA* a = new TestA[10];
,free((long long*)a-1)
是可以运行的,free(a)
会出错。
综上,delete 和 delete[] 的处理逻辑是不同的,进行析构的对象不同,执行 free 的方式也不同,可能判断数组元素个数。
注意,类似栈变量的释放顺序,delete[] 一个数组时也是从后往前销毁元素的,与 new T[] 时的顺序相反。
这很符合逻辑,构造时后面的对象可能会依赖前面的,从后往前释放不会出问题。
栈对象能否用 delete 释放
注意栈对象/临时作用域对象不能用 delete 释放!退出作用域后它会自行释放,手动调用 delete 将会释放两次导致出错。
换句话说,delete 的目标必须是用 new 分配的,不包括 全局的/new[] 的/原地 new 的。
class A{
public:
void test(){
delete this;
}
};
// RE,栈对象不能delete
A a; a.test();
// 会调用delete的方法类似,如shared_ptr
std::shared_ptr<A> p(&a);
p.reset(); // RE
// OK,但之后p就是无效的
A *p = new A();
p->test();
new 与 malloc 的区别
malloc
https://blog.csdn.net/songchuwang1868/article/details/89951543
https://zhuanlan.zhihu.com/p/462819375glibc 的 malloc/free 实现与内存管理:https://zhuanlan.zhihu.com/p/428216764
https://zhuanlan.zhihu.com/p/452686042
TODO
malloc 多次分配小内存时,使用 sbrk。
但如果申请空间大,会使用 mmap,是惰性的,即如果不使用申请的内存,不需要发生实际的内存分配(top 查看不到新的内存占用)。
calloc 是分配并初始化,因此会发生内存分配。
realloc
realloc 可以在已经申请好内存块的基础上,重新分配指定大小的内存。
void* realloc (void* ptr, size_t size);
,ptr 为已经申请过的地址(若未申请则填 NULL)。
如果之前没有申请内存,则直接分配,与 malloc 一致;
如果之前已申请过内存,则有两种情况:
不管怎样,调用 realloc 后都不应该使用传入的那个指针。
注意,malloc、realloc 只会进行分配,不会进行初始化,更别提调用构造函数。
所以如果对 new 出来的空间使用 realloc,新的空间是不会被初始化的,旧的空间也不会调用析构函数。所以 new 不应该与 free 或 alloc 函数混用。
dangling pointer, wild pointer
悬空指针是指向已删除对象的指针。realloc 如果发生 free,则传入的指针可以看做悬空指针(反正访问是 UB)。
野指针是没初始化的指针。
栈和堆的区别?为什么栈比堆高效?
栈是从上往下增长的,用来处理函数调用,不能动态地使用,生命周期被限制在函数内。在某些系统,比如 windows,栈空间可能会有限制。
堆可以动态分配内存,调整已分配的大小。
栈空间的分配快,只需要移动栈指针,不需要分配器参与。
也不会产生内存碎片,不需要 gc 或手动回收,用起来方便。连续性也好。
怎么让对象只在栈上分配
事实上这是完全做不到且无意义的。
对于下面的方法,只要通过有限定名字查找::
指定使用全局的operator new
,而不是优先使用类内部定义的,就可以实现堆上分配:
struct X{
int n{};
X(int v):n{v} {puts("X(int)");}
~X() { puts("~X()"); }
private:
void* operator new(size_t) noexcept {return nullptr;}
void operator delete(void*) {}
};
X* p = ::new X(1);
std::cout<< p->n <<'\n';
::delete p;
实际上只要类本身有一个构造函数,就可以在堆上分配内存,然后用 placement new 初始化它:
char *pc = new char[sizeof(X)]();
X *px = ::new ((void*)pc) X(2);
std::cout<< px->n <<'\n';
::delete px;
想要阻止它,只能禁用或私有所有构造函数。但如果这样做,只能通过一个友元工厂函数来生产对象,而且生产出来的对象只需要通过拷贝/移动构造就可以 placement new 到堆上。除非禁用拷贝/移动构造,但这样工厂类也没法返回对象了。
而且也阻止不了静态或线程局部存储期的对象分配到栈外。
理论上有些价值,但由于做不到,应该由人自己遵守。
如果条件允许,也就是对象不会发生内存逃逸,它的生命周期被限制在某个函数内,就可以分配到这个函数栈上,而不是堆上,以提高效率。
内存逃逸有两种,一是方法逃逸,即对象会在方法外部被使用时,就需要分配到堆上(比如变量的地址被作为指针返回,或被外部的变量保存);二是线程逃逸,是在一个线程中构造的对象,在另一个线程中也会被使用而产生的。
通过避免方法逃逸,可以将对象分配到栈上,提高效率;避免线程逃逸,能允许编译器做更好的优化。
go 和 java 的编译器会进行逃逸分析,使变量尽可能分配到栈上。而 C++ 需要程序员自己决定?
在栈上建立对象,是直接调用类的构造函数;而在堆上建立对象,是执行它的 operator new() 函数,分配空间后,间接调用类的构造函数。
所以,只有使用 operator new,对象才会建立在堆上,只要禁用 operator new 就可以避免它分配在堆上。
可以重载void *operator new(size_t t) = delete;
,也可以重载该函数为 private。
delete 也要做相同的重载。
(当然前提是不使用 alloc 系列的函数)
怎么让对象只在堆上分配
函数返回时,需要调用对象的析构函数。所以如果一个类的析构函数是私有的,它就无法分配到栈上,因为程序没法释放它。
所以将析构函数定义为 private 或 protected,能避免它分配到栈上。
(编译器分配栈空间时, 会检查类的析构函数的可访问性,如果析构函数是私有的,将会编译失败,编程器不能在栈空间上创建该对象)
但是,如果析构函数是私有的,delete p 也会编译失败,我们需要定义一个 public 的销毁方法替换 delete,在里面调用delete this;
。
(此时 new 与 delete 将不再配对,而是与自定义的 destroy 配对。为此可以再封装一个 create 函数,让它与 destroy 配对,不直接使用 new)
C++ 的内存管理
可以通过 C 语言中的内存管理方式,即 malloc, calloc, realloc, free 这些函数;也可通过 C++ 提供的新方式:通过 new 和 delete 操作符、RAII 进行动态内存管理。
C++ 的内存布局
就是 Linux 上的进程格式。
Linux 将虚拟内存组织成一些段(或区域)的集合。一个段就是已分配的虚拟内存的连续片。
Linux 为每个进程维护了一个单独的虚拟地址空间。虚拟内存的地址最底端向上依次是:代码段 .text、已初始化数据段 .data、未初始化数据段 .bss、堆。地址最顶端向下是栈空间。
全局变量和静态变量位于数据段(.data 和 .bss。C++ 不区分数据的初始化和未初始化)。
malloc 等分配的内存块位于堆。
enum
枚举常量代表该枚举类型的变量可能取的值。枚举常量只能以标识符形式表示。
默认情况下,编译器使用 int/uint 作为底层类型,每个枚举值都与一个底层类型的值关联。
如果不为其指定常量值,则默认首个为0,其它的是前一枚举项的值 +1。各枚举常量的值可以重复。
例:
enum ColorSet { // 枚举类型名 ColorSet 可省略,直接定义变量
R, G, B, // 0 1 2
W=10, B, // 10 11
GREY=0, YELLOW, // 0 1
PINK=R+10, // 10
} color1, color2; // 类型名为ColorSet,同时定义两个变量
注意,定义的color1, color2
未初始化,必须指定一个值,否则如果是全局变量则为0,局部则随机,不管它能取哪些值。
初始化变量时,需要赋ColorSet
类型(如=RED, =BLUE),不能直接赋底层类型如 int。
可以为 enum 指定底层类型以减少内存占用:enum E: uint8_t {...};
。类型需要能表示所有枚举项。
使用 属性说明符 - packed 修饰 enum(或编译选项-fshort-enums
),可使用最小、最合适的类型做底层类型(1字节能存 256 个)。
指针与引用
&
(得到**p
),不然只能更改指向的值。引用是否占内存?引用是否就是指针?
标准没有规定引用的实现,因此答案不确定。
但通常来说,编译器会将引用转为指向对象的 const 指针,因此占 8B,类中的引用成员就是如此。
对于非成员引用,编译器可能会将其优化成指向对象的地址(不需要保存指针),甚至是常量(如果指向的对象没被修改)。
top-level const
在一个指针类型中可以遇到多个 const。
如果一个 const 修饰的是对象本身,则称为 top-level const (顶层 const);否则为 low-level const (底层 const)。
指针声明const int* const p
中,左边的 const 为 low-level,不影响 p 本身;右边的 const 才是影响 p 的 top-level,说明 p 是常量。
函数声明const int func() const
中同理,右边的是 top-level。
top-level const 会影响函数类型的确定,见 函数 - 函数类型。
注意 remove_const 去除的也是 top-level 的。
const_cast 可以任意添加和去除指针声明中的 const?不管是 top 还是 low-level const。
但有和无 top const 的指针本来就可互相赋值,只有添加/去除 low const 时需要 cast。
指针转换
cv decomposition (cv分解):https://timsong-cpp.github.io/cppwp/n4659/conv.qual
如果 cv2 的限定符比 cv1 更多,则“指向 cv1 T 的指针的纯右值”可以转换为“指向 cv2 T 的指针的纯右值”。否则不可。
(即可以额外加 cv,但不能去除)
由于"abc"
是 const char 数组可被隐式转换为const char*
,因此char *s = "abc"
会CE,因为去掉了 const。
引用初始化
https://zh.cppreference.com/w/cpp/language/reference_initialization
https://timsong-cpp.github.io/cppwp/n4659/dcl.init.ref#4
设一个cv1 T1
类型的引用,被一个cv2 T2
类型的表达式初始化:
引用初始化规则:
如果初始化表达式是初始化列表,则遵循列表初始化。
如果引用是左值引用:
cv1 T1
与cv2 T2
是引用兼容的,则引用绑定到该左值标识的对象上(或它的基类子对象)。 struct B: A {} b; A &ra = b;
可以绑定到 b 中的 A 类子对象)。cv3 T3
类型的左值(通过到相等或更少的 cv 限定的左值的转换函数),其中cv1 T1
和cv3 T3
是引用兼容的,则引用绑定到转换函数所返回的cv3 T3
类型的左值上(或它的基类子对象)。 operator int&()
,那么int& ir = T2()
是 ok 的,会绑定到 T2::operator int& 的结果。cv3 T3
类型的右值或函数左值,其中cv1 T1
和cv3 T3
是引用兼容的,则引用绑定到转换函数所返回的cv3 T3
类型的左值上(或它的基类子对象)。简单来说,const T&、T&& 可以接受右值(并延长其生命周期),但 T& 不可。
T 类型的引用只能绑定 T 类型对象,其它情况将尝试类型转换、绑定临时对象。
例:
double &d = 1.0; // CE:初始化表达式是非类右值,所以引用类型要么是`const double&`,要么是`double&&`
int i = 1;
double &d = i; // CE:初始化表达式是左值,但不引用兼容,所以同上,引用要么是 const 左值引用要么是右值引用
const double &rd = i; // rd会绑定到一个临时double上,修改i不会影响rd
const string &rs = "abc"; // 同上,字符数组将隐式构造成string,然后引用绑定到该临时string上
临时量的生存期
临时量的生命周期是在整个表达式求值完才结束的。
此外,一旦临时对象或它的子对象被绑定到某个引用,临时对象的生存期就被延续,以匹配引用的生存期。
生存期不能被进一步传递以延长:通过临时量最初绑定的引用 创建的其它引用,不会延长其生存期(如果新引用生存期能比原来的引用长,本来也是错误的)。
临时对象包括:对象类型的纯右值表达式(C++17 前)/临时量实质化生成的亡值(C++17 起)等。其它见文档。
例外:
函数 return 语句中绑定到返回值的临时量不会被延续,它会在返回表达式的末尾立即销毁。因此这种 return 始终返回悬垂引用。
如:int& f(int a) {int b; return a或b;}
,a b 生命周期都在函数内,因此不能返回引用(会给警告)。
在函数调用中绑定到函数形参的临时量不会被延续,存在到含这次函数调用的全表达式结尾为止。如果函数返回一个生命长于全表达式的引用,那么它会成为悬垂引用。
如:std::max
返回 const 引用,所以const string& rs = max(string("1"), string("2"))
不对,string("2")
的生命期是该语句(及函数内),而 max 返回的是对该临时量的引用,在该语句结束后就会销毁。所以之后使用 rs 就是 UB,即使部分编译器能执行。
而非函数调用auto&& rs = string("2")
是正确的,其生存期会被延续到与 rs 一致。
临时量在整个表达式结束时才销毁,所以Node& f(const Node &a) {return a;}
、f( f( f(Node{})))
中,临时量会在最后一个 f 执行完后才销毁。
其它见文档。
第一种情况中,如果函数不返回引用,或返回右值引用并在 return 时 move 走对象,那没问题,因为返回值不是绑定到函数形参的临时量。(注意右值引用也是引用,也一样)
如果返回的引用是非局部的也没问题,比如类方法返回成员函数的引用。
(例外:"abc"
这样的字面量的生存期与程序一致(即使看起来很像局部变量),所以返回"abc"
的const char* f()
是没问题的)
隐式转换 (implicit conversion)
规则见 https://zh.cppreference.com/w/cpp/language/implicit_conversion
此外可以看看 C++ - implicit_cast。
数组和指针
数组不是指针,是两个不同的概念(至少在 C++ 语言层面。具体实现标准不关心)。
但“T 元素的数组”可以隐式转换为“指向 T 的指针”的纯右值,该指针指向数组首地址。(C++17 起,如果数组是纯右值,则发生 C++ - 临时量实质化)
所以,如果数组出现在不期待数组而期待指针的环境中,就会使用该隐式转换。称为数组到指针的退化(但 std::array 不会)。
(比如:int arr[10];
,+arr
就可将其转换为指针;+ (取正数) 是一个一元运算符,会期望一个可以运算的类型)
类似的是函数:“函数类型 T 的左值”,可隐式转换成“指向该函数的指针的纯右值”。
不适用于非静态成员函数,因为不存在指代非静态成员函数的左值。
数组会包含很多信息:起始地址、元素类型、大小。
函数同样,可包含:第一条指令地址、函数签名。
指针加法
对于T *p
,p+k
表示的地址为p+k*sizeof(T)
。
对于int a[5]
,a
是一个int
的指针(下面都指隐式转换后),范围为5。
对于int a[3][5]
,a
是一个int (*a)[5]
的指针,范围为3,所以a+1
指向的是a[1][0]
,a+2
为a[2][0]
。
对于int a[5]
,&a
是一个int a[5]
的指针,范围为1,但大小为5个int。
对于int a[3][5]
,&a
是一个int a[3][5]
的指针,范围为1,但大小为3*5个int。
a+1
指向a+5*4+1
,但&a+1
指向a+3*5*4+1
!
注意取地址符是生成整个整体的指针!
sizeof
sizeof
的单位为字节,int
是4,32位指针也是4,64位指针是8,long (int)
是4或8。
对于字符串char str[20]="123"
,sizeof(str)
返回str
所占空间的大小,为20,包括空字符(结束符);strlen(str)
返回字符串的长度,为3,到结束符为止。
对于字符串char str[]="123"
,会自动指定合适的大小,但会先在后面加\0
!所以sizeof(str)
为4!
对于char *str="123"
,str
是一个指针!sizeof(str)
返回4/8。sizeof(*str)
返回*str
即一个字符的大小,为1。
注意,当字符串用做参数时,会被转为指针传入。不管是char s[]
还是char s[5]
,s
都会被当做指针!
也就是如果char s[]
是参数,sizeof(s)
就是8('sizeof' on array function parameter 'acWelcome' will return size of 'char*'
)。
若char (*p)[5]
,sizeof(p) = 8
;若char *p[5]
,sizeof(p) = 20
。
sizeof 一个类名会返回该类对象的大小,具体见 面向对象 - 类的大小。
任何对象的大小至少为1,即使类型是空类型(没有非静态数据)(只不过在继承时可能优化掉,见 面向对象 - 空基类优化),原因有三点:
- 要保证同一类型的不同对象的地址始终不同,才能区分不同对象(才能知道 a1 是 a1、a2 是 a2)。
- 要保证一个对象有明确的地址,否则指针自增
T *p; ++p;
不能确定如何处理。- C++ 保证 sizeof 的返回值大于0。
如果 sizeof 可能为0,则过去的很多代码中的sizeof(a) / sizeof(a[0])
都会出问题(之前没问题,因为之前就这样保证)。
malloc(sizeof(T)) 可能会申请一个 0 大小的空间。这样会无法确定要返回的地址?
类似 Go?sizeof 表达式是一个编译时就确定的值,容器内元素的个数将不影响该值(也是通过指针指向的),其内的语句也不会真正执行。
数组声明
声明定长数组时,初始值数量不能超过数组大小。
数组大小只要是个常量整型表达式就可,如1+2*3
。
变长数组 (VLA)
非常量长度数组(不是真的变长)。
C++ 标准要求声明数组时,[ ] 内的表达式为 求值大于零的整型常量表达式 (C++14 前) / std::size_t 类型的经转换常量表达式 (C++14 起)。但 gcc 和 clang 都支持了 C 中的 VLA 扩展(MSVC 没有),所以允许数组大小为变量。
当使用 sizeof 计算 VLA 大小时,自然要按照C 的规定:若表达式的类型为 VLA 类型,则在运行时计算其所求值的数组大小,导致该 sizeof 并不是常量表达式。
柔性数组
https://zhuanlan.zhihu.com/p/573081617
https://gcc.gnu.org/onlinedocs/gcc/Zero-Length.html
C99 中,允许在结构体的最后一个元素声明一个长度为0的数组char contents[0]
。该数组的长度任意,可以在运行时指定,分配多大就是多大。而且数组不会计入类的 sizeof,因此可以与指针相比,减少类的对齐长度?
与使用指针相比,少占 8 字节,可以减少一次间接寻址,创建时不需要二次分配空间,但不能使结构体间共享该元素。
0长数组实际是不符合标准的,无大小才是柔性数组,但 gcc, clang 支持这种写法。
所以应将这类数组声明为柔性数组/灵活数组 (flexible array),就是不带长度的数组char contents[]
,与上面一样。
柔性数组只能作为类的最后一个成员,且前面必须有一个成员。
使用:
struct Node {
size_t len;
char contents[];
};
Node *a = (Node*)(new char[sizeof(Node) + len * sizeof(char)]()); // static_cast不行
a->contents[0] = 'a';
delete a;
初始化、 sizeof 及赋值运算符忽略柔性数组成员。拥有柔性数组的结构体,不能作为数组元素或其他结构体的成员(但 gcc 做了扩展,允许这种极易出错的情况)。
注意包含柔性数组的类,赋值时不会考虑柔性数组!应尽量避免赋值(如Node a = b; map[0] = a;
),直接使用指针?
不同进制数的字符表示
默认为十进制,\123
或\o123
为8进制,\x123
为16进制。
RAII (Resource Acquisition is Initialization, 资源获取即初始化)
RAII 是将资源的生命周期绑定到类对象的生命周期上,具体:资源的有效期与持有资源的对象的生命期严格绑定,即由对象的构造函数完成资源的分配(获取),同时由析构函数完成资源的释放。在这种要求下,只要对象能正确地析构,就不会出现资源泄漏问题。
(C++ 标准保证任何情况下,已构造的对象最终会销毁,即它的析构函数最终会被调用)
通过 RAII,还可以减少内存泄露的产生。智能指针与容器(std::array/vector...)就是这样的,能够减少管理内存的工作量。
还可以帮助实现 异常安全。
RAII 更关注资源不会泄露,什么时候创建也无所谓。
内存泄露是一个对象没有被任何对象引用,但内存一直没有释放(直到程序结束);资源泄露则是程序结束后,对象也没有被正确关闭或结束。
内存泄露导致内存无法被重用,使程序占用的内存越来越大。
资源泄露指程序使用系统分配的资源,如套接字、文件描述符、管道等,没有释放,导致系统资源浪费、可用资源减少。
应用场景:
最常见的就是,new
出来的指针(在堆内存)忘记delete
。
一个自动释放指针的类例子见这。注意禁用拷贝,用move
赋值,避免内存被delete
两次。
利用 RAII 或智能指针,可以实现 go 中 defer 的用法,因为函数返回时会析构栈内的对象。
这在函数可能抛出异常时是很有用的。如果不用 RAII,就必须在异常出现前、函数返回时,手动调用析构函数,这几乎是不可能的。正确使用 RAII 可以避免内存泄露与资源泄露。
其它语言通过 GC 避免内存泄露,但基本都没有明确的析构函数?为了避免资源泄露,给出了各式 try with resources 的写法,如 java 的 try,python 的 with,go 的 defer。
智能指针 (smart pointer)
裸指针表示没有资源的所有权,shared_ptr 是共享所有权,unique_ptr 是独占所有权。
裸指针的使用是不安全的,需要程序员保证;不会影响资源的生命周期。
auto_ptr 为什么被废弃
auto_ptr 是非常早的智能指针,设计理念与 unique_ptr 一致,指针独占资源。
由于当时没有移动语义,所以它在拷贝构造函数和拷贝赋值运算符中,通过接收非 const 的指针参数获取资源的所有权,然后将参数置为 null。
所以对于两个 auto_ptr a,b,a = b
的含义不是拷贝,而是移动,这与直觉是非常不符的。
这样的赋值语义非常容易出错,比如参数调用传参,或对于包含 auto_ptr 的容器(如std::vector<std::auto_ptr<int> > vec;
),当操作该容器时,比如遍历和排序,如果不小心用容器中的值进行拷贝,就会导致里面的指针被置为null。
而 unique_ptr 通过 move 实现资源转移,并禁用了拷贝,不容易出错。
unique_ptr
std::unique_ptr<T>
是一个独占资源所有权的指针。
当离开 unique_ptr 的作用域时,即 unique_ptr 释放时,指向的资源会自动释放。
unique_ptr 的创建方式只有三种:
std::unique_ptr<int> ptrInt(new int(5));
std::unique_ptr<FILE> ptrF(fopen("test_file.txt", "w"));
std::unique_ptr<int> uptr = std::make_unique<int>(200);
// 可以指向一个数组,可访问和赋值 uptr[0],...,uptr[9]
std::unique_ptr<int[]> uptr = std::make_unique<int[]>(10);
// 移交所有权。此时再访问uptr将出错
std::unique_ptr<int> uptr2 = std::move(uptr);
std::unique_ptr<int> uptr3(std::move(uptr2)); // 等价
// 注意一些隐式赋值的情况,也要用move
vector<unique_ptr<int>> vec;
vec.push_back(std::move(uptr));
// 如果一个函数返回unique_ptr,由于返回值是右值?所以也可以用来赋值
unique_ptr<int> f(int x);
unique_ptr<int> res = f(3);
unique_ptr 可以自定义回收函数 deleter,方法有3种:
void operator()(type* p) const
。void DeleteFunc(type* p)
。unique_ptr 与 shared_ptr 重载 deleter 的方式不同:
- unique 的 deleter 类型是其类型的一部分,在编译时就确定,需要将该类型传入模板参数。调用时可内联。
shared 可以在运行时任意绑定 deleter。但需要通过指针实现,因此调用时多一次跳转。- 当 unique 被赋值 nullptr 时,析构不会调用 deleter,但 shared 会。
共同点是,当传入自定义 deleter 时,需要析构保存的指针(ptr 不会再调用其它 deleter)。
unique_ptr 只包含一个指针,所以为 8B(以下均为64位)。如果自定义了 deleter,需要保存 deleter 指针对象,所以大小为 16B(可能更大,见下链接)。
但如果方法 1 中的类是空类(无捕获的仿函数,只有 operator ()),则 unique_ptr 可以继承该类,而不是声明一个该类的对象,从而能使用空基类优化,避免 deleter 的额外大小。
具体见:https://zhuanlan.zhihu.com/p/367412477。
make_unique 在 C++14 才提供,不过很好实现:
template<typename T, typename... Ts>
std::unique_ptr<T> make_unique(Ts&& ...params) {
return std::unique_ptr<T>(new T(std::forward<Ts>(params)...));
}
shared_ptr
std::shared_ptr<T>
允许多个指针共享资源的所有权。
为了保证安全回收对象,需要在内部对资源进行引用计数,比 unique_ptr 更复杂些。
函数sptr.use_count()
会返回当前的计数。
shared_ptr 可以通过一个 new 出来的指针进行初始化,也可直接拷贝赋值另一个shared_ptr,也可赋值为 std::make_shared 创建的指针(更推荐,但 C++20 起才支持数组)。
与 unique_ptr 类似,也可指向数组,可自定义 deleter。
但一个 shared_ptr 对象要比无 deleter 的 unique_ptr 略大,除了指向对象的指针外,还有一个指针,指向引用计数等资源信息(这部分在堆中,是共用的)。所以一个 shared_ptr 为 16B。
如果定义了 deleter,会放到资源信息中,所以大小不变。
指向对象的指针,可不可以只放在堆中的共享信息中,而不存在 unique_ptr 中?
不可以,由于继承和多态的存在,一个基类类型的 unique_ptr 可以拷贝自一个派生类类型的 unique_ptr,此时堆中资源的信息指向派生类对象,而该指针应该指向基类对象。shared_ptr 也不能随便用(事实上很少用,unique_ptr 更常用),比如以下情况:
- shared_ptr 有“传染性”,如果某个资源在某一处使用了 shared_ptr,那整个项目内与该资源有关的地方,基本都需要使用 shared_ptr(否则即使某个普通指针指向了资源,资源还是会被释放)。可能需要重构项目。
不过在非异步场景,如果一个函数对传入的指针没有占有性,那么传入原生指针是可以的(但该函数调用的子函数也要保证不占用资源)。- 对同一个 shared_ptr 的非 const 操作不是线程安全的(如:reset、swap、operator =),如果要多线程使用,要每个线程都有自己的 shared_ptr。因此多线程的环境下,函数要使用拷贝而非 const 引用传递 shared_ptr,否则可能不安全(C++20 起可以使用
atomic<shared_ptr>
)。
但是对引用计数的操作是安全的(atomic 更新)。
指针内部管理对象的线程安全,不由 shared_ptr 考虑。这是对象自己的事情。- 如果资源本身比较小,则 shared_ptr 需要的资源信息占比就会很大。
比指针多占用一些内存,如果内存敏感也不适合。- 有些代码会在类中写
detete this
,会导致所有 shared_ptr 的资源失效。此外,如果 shared_ptr 指向一个大对象,在最后一个 shared_ptr 不再指向它时,会导致大对象的析构(如数组、容器)。
这可能导致一句指针赋值,就花费很长时间。
如果是在业务中,可以需要避免这种情况,比如外部再令一个 shared_ptr 指向它,当计数器为 1 时,由后台线程析构。智能指针不应指向 static 对象,因为 static 对象生命周期与程序相同,在程序内 delete 它会出问题。
通过 make_shared 创建 shared_ptr,能允许它将 ptr 结构体与控制信息(引用计数类)放在一起、一起创建,减少一次 new。
更重要的是,它不会暴露裸指针,不易出现使用裸指针产生的错误。
注意,不要对同一个裸指针或对象多次创建 shared_ptr(会重复析构。典型的是 this);不要随意保存 ptr.get()(可能也会导致重复创建 shared_ptr),不要 delete get 的返回值。
可以写shared_ptr<int[]> p(new int[5]{});
,但 C++17 前不能很好的支持数组,因为 17 才有 operator [],这之前需要p.get()[1]
?
shared_ptr 有一个别名构造函数
template<class Y> shared_ptr(const shared_ptr<Y>& r, T* ptr)
,构造后的 sp 会与 r 共享引用计数,并有相同的 deleter 以便释放 r 的资源,但是 sp 内部指向的指针是 ptr 而非 r.get()(通过 get 得到的是 ptr)(引用计数为 0 时执行 r 的 deleter 而非 delete ptr)。
程序员需要保证当 r 的资源有效时,ptr 也一定有效。
一般情况下 ptr 是 r.get() 的成员,比如 r 是 shared_ptr>,ptr 是 r.get().data(),通过该构造函数就可以构造指向 vector 的 data 的 sp,并保证引用计数与 vector 一致,以保证安全。也可通过此方法获取 r.get().data() 的切片保存(类似 go 的切片)。shared_ptr 创建后,deleter 不能改变。
但可以给 deleter 一些状态(比如捕获变量),然后通过get_deleter
获取、修改其状态改变 deleter 的行为。
struct Deleter {
bool d;
void operator()(A* p) {
cout << "deleting: " << (d ? "true" : "false") << '\n';
delete p;
}
};
shared_ptr<A> a {new A{1}, Deleter{}};
auto deleter = get_deleter<Deleter>(a);
deleter->d = true;
除了需要写出 Deleter 类、麻烦点外,一般影响不大。不管是否包含 bool 值都是占 1B。
weak_ptr
由于要维护 weak 的引用计数,所以 shared_ptr 里实际要有两个 atomic,因此效率会比不支持 weak 的略低。
std::weak_ptr<T>
是共享资源的观察者,需要和 shared_ptr 一起使用,它不会影响资源的生命周期。
shared_ptr 与 weak_ptr 共享一个资源控制块(所以也是 16B)。
当 shared_ptr 的资源被释放后,weak_ptr 会自动变成 nullptr,所以使用前要用expired()
检查。如果 weak_ptr 不被释放,则资源控制块也不会被释放。
weak_ptr 可以从一个 shared_ptr 或另一个 weak_ptr 对象构造,获得资源的观测权:可以调用use_count()
获得资源的引用计数,调用expired()
检查资源是否被释放。
但是它只能看到资源的共享信息,没有资源的使用权。
通过lock()
可以创建一个当前正在观察的资源的 shared_ptr(如auto sptr = wptr.lock()
)。
由于 weak_ptr 的引用不会被计数,所以可以用来避免循环引用的问题。
如:类 A 中有一个对 B 的 shared_ptr,类 B 中也有一个对 A 的 shared_ptr。在栈中分别创建指向 A, B 的两个 shared_ptr,并更新 A, B 内部的 shared_ptr 字段指向对方,那么 A, B 对象的引用计数都为 2(一共 4 个 shared_ptr)。当函数返回时,2 个栈对象析构,A, B 的引用计数都变为 1(因为内部还互相指向),导致两个对象都无法析构,产生内存泄露。
可以将一个 shared_ptr 改为 weak_ptr,然后需要使用该资源时,利用 weak_ptr lock 一个出来(并尽快释放)。
改为普通指针也可以,但就需要自己避免泄露问题。
也常用于订阅者模式或观察者模式中。消息发布者根据订阅者是否存在,来决定是否发送,但不能管理订阅者的生命周期(订阅者使用 weak_ptr 数组保存)。
shared_from_this
如果在类内部的某个方法内,用 this(裸指针)创建 shared_ptr,那么每次执行方法,都会创建一个新的引用计数类,它们指向的数据相同,引用计数却独立(通过裸指针创建就是这样,而下面的 enable 会在第一次使用时创建 shared_ptr 供使用)。
这显然是不对的。想要用 this 创建,需要继承public enable_shared_from_this<ClassName>
,它会在对象创建时生成一个指向 this 的 shared_ptr,之后就可以使用 shared_from_this() 返回相同的引用计数类。
具体见:https://zhuanlan.zhihu.com/p/402055010
原理:https://zhuanlan.zhihu.com/p/638029004
例:
struct Foo : public std::enable_shared_from_this<Foo> {
std::shared_ptr<Foo> GetPtr() {
return shared_from_this();
}
};
临时量实质化 (temporary materialization conversion)
C++17 起,任何完整类型 T 的纯右值,可转换成同类型 T 的亡值。转换会用纯右值初始化一个 T 类型的临时对象,并产生一个代表该临时对象的亡值,作为原本纯右值的求值结果。
如果 T 是类或类数组,则必须有可访问的析构函数。
会出现的场景:
Node{}.n
中纯右值Node{}
就会被转换成亡值。函数调用表达式的值类别
具体见草案 expr.call。
值类别 / 表达式的值类别 / 左值和右值
每个表达式可按照两种独立的特性进行分辨:结果的类型和值类别 (value category)(左值引用、右值引用属于一种类型,左值右值是值类别,两者不同。表达式的值类别是不涉及引用的)。
每个表达式只属于三种基本值类别中的一种:左值 (lvalue)、纯右值 (prvalue)、将亡值 (xvalue)。
x
(变量、函数、数据成员的名字。即使变量的类型是右值引用,由它的名字构成的表达式仍是左值表达式),*p
,p->n
,x.n
(x 需要是左值),返回类型是左值引用的函数调用,转换到左值引用的类型转换表达式。&a
,str.substr(1, 2)
,a+b
,Node{}
。(a+b).n
,Node{}.n
,arr[n]
(arr 或 n 至少有一个是右值?),std::move(x)
,static_cast<int&&>(x)
。注意,类的赋值会调用 T& operator =,这并不是内建赋值运算符,而是一个函数调用,因此
A{} = a;
是可以成功的,等价于A{}.operator=(a)
。string 纯右值调用 operator + 也同理。
但要注意其实现(或默认时成员赋值的实现)中不能用内建赋值?
如果要避免这种情况,给A& operator =(const A&)
后面加上引用限定 & 即可。不完整类型:
void 类型;已声明但未定义的类型;在声明后,确定底层类型之前的枚举类型;未知边界数组;上述类型的数组。
如果在数组声明中省略关于大小的表达式,则为未知边界数组。多维数组只能在第一维中有未知边界,如a[][3]
可以,a[3][]
不可以。
左值是表达式结束后依然存在的持久对象(对象在内存中占有确定的位置)
右值是表达式结束后不再存在的临时对象(不在内存中占有确定位置的表达式)
可以对左值取地址,但右值不行。
左值不代表一定可以被赋值(如const T&
),只是可以放在左侧被初始化。
常量字符串是左值!可以&"abc"
,因为字符串是const char*
。
左值为T
,左值引用为T&
,右值引用为T&&
(应该是)。部分见下 万能引用。
非 const 的左值引用不能接收右值!
赋值:
拷贝构造函数为T(const T& x)
,移动构造函数为T(T&& x)
。
类似地,operator =
也分为常量左值(拷贝赋值)和右值版本(移动赋值)。
C++ 在满足以下条件时,会生成默认的移动构造函数(和右值=
?):没有自定义拷贝构造函数、没有自定义operator =
、没有自定义析构函数。
通过= default
也可生成默认的移动构造函数。默认的很简单,就是调用该类所有成员的移动构造函数。
通过= delete
禁用默认实现。
如果声明了拷贝构造函数,那么会自动生成一个拷贝赋值函数;反之亦然。
三法则(The Rule of Three):如果你声明了任何一个拷贝构造函数、拷贝赋值操作或析构函数,那么你应该声明所有的这三个函数。
因为:当需要拷贝构造、拷贝赋值或者析构函数时,往往是类要管理某些资源(通常是内存资源)?当需要在拷贝中对资源进行管理,那么也需要在析构函数中对资源也进行管理(通常是释放内存),反之亦然。
见 面向对象 - 三五零法则。初始化使用构造函数,赋值使用
=
赋值函数。
T t1 = t2
或T t1(t2)
,会调用拷贝构造函数。
T t1 = std::move(t2)
,调用移动构造函数。如果没有实现移动构造,由于右值引用也可匹配const T&
,所以会调用拷贝构造。
T t1 = t2+t3
,会先通过+
生成一个临时的T
(右值,也涉及构造一个T
,取决于+
实现),再通过移动构造赋给t1
。同上,如果没有移动构造,则用拷贝构造。
该语句涉及两次构造,=
和+
。
t1 = t2; t1 = std::move(t2); t1 = t2 + t3;
,与上述情况一致,只是取决于operator =
的实现情况。
前置++ 与 后置++
前置 operator++()
返回一个对操作数本身的引用(一个左值引用)。(因为是左值,在使用该引用赋值时,使用拷贝赋值,即constructor(const Type &x)
)
后置 operator++(int)
返回的是一个 const 临时对象(右值,对操作数的拷贝,是不具名的),只读。
所以++++i
是合法的,i++++
是不合法的;int&& j = i++;
、int& k = ++i;
是合法的,反过来是不合法的。
由于后置会生成一个拷贝作为返回值,所以影响效率(但对于基本类型,会优化掉,结构体要注意)。
复制消除 (copy elision)
copy elision 也包括 move elision(C++11 起有,C++17 起才保证一定应用)。
初始化时,如果初始化表达式和被初始化的对象类型相同,且表达式为临时量(C++17 前,见 C++ - 临时量实质化)/纯右值(C++17 起),则可以省略复制和移动构造函数,直接将表达式产生的对象构造到要初始化的对象位置上。
如:Node a = Node{1, 2};
在优化前需要一次有参构造、一次复制/移动构造、一次析构,但优化后只需要一次有参构造,并且不需要 Node 有复制构造函数。
只有初始化表达式是左值时才需要拷贝构造。
通过-fno-elide-constructors
关闭。
拷贝构造可以去掉引用吗
标准不允许T(T x)
这种构造函数写法。去掉引用会多一次无意义的拷贝,影响效率;但更重要的是传参本身就需要一次拷贝构造,会导致拷贝构造无限递归。
当 x 是纯右值时,由于 C++17 起保证了复制消除,可直接在 T 位置上进行构造,故不会出现无限递归的情况。
返回值优化 RVO
RVO (Return Value Optimization) 是一种编译优化,可以减少函数返回时产生的临时对象,从而消除部分拷贝或移动操作。
当一个未具名且未绑定到任何引用的临时变量,被移动或复制到一个相同类型的对象时,拷贝和移动构造可以被省略,临时变量会直接构造在要拷贝/移动到的对象上。因此,当函数返回值是未命名临时对象时,可以避免拷贝和移动构造。
RVO 其实就是复制消除,因此也可通过-fno-elide-constructors
关闭。
例:
A makeA () {
return A();
}
int main () {
A a = makeA();
return 0;
}
在没有 RVO 的情况下,上述过程(整个程序)应包括一次默认构造函数、两次拷贝构造函数和三次析构函数的调用:
A()
调用默认构造函数,生成临时对象1。return A()
将临时对象1,通过拷贝构造赋值给返回值,即临时对象2。对象1 析构。 a
的拷贝构造,然后对象2 析构。 a
析构。如果实现了移动构造,也还是要这么多次函数,只是把拷贝构造换成了移动构造。
在 RVO/复制消除 优化后,实际只包含a
的一次默认构造函数和a
的一次析构函数。
它相当于将函数优化成直接传入对象进行构造:
void makeA (A& a) {
a.A::A();
}
RVO 优化的条件:
return
里),且类型和返回值类型相同。当使用return std::move(A())
时,会导致 (N)RVO 失效,多一次移动构造和析构。
当函数调用者不是初始化,而是赋值A a; a = makeA();
时,也会多一次默认构造、析构和移动赋值。
当不能确定返回值时,如通过分支决定返回值,也不能优化。
return move 一般是没有意义的,如果返回的对象是隐式可移动的,那么编译器自己就会选择移动构造,不需要显式写 move。
而且可能影响编译器的 RVO 优化。但如果对象不是隐式可移动实体,那么需要确实要加 move,否则重载决议会选择拷贝构造。
主要以下几种情况:
- 返回类的成员变量(类对象还要持有成员,所以一般不会移出来)
- 返回结构化绑定的变量(这种变量与普通变量不太一样)
- 返回局部对象的一部分,比如:
return move(vec[0]);
。具体看 return 规则:https://zh.cppreference.com/w/cpp/language/return
NRVO
NRVO (named RVO) 允许函数中的返回值已预先被定义(具名),而不是只能出现在return
中。
它与复制消除有一点不同:具名栈对象是一个左值,通过它构造返回值时不满足复制消除的右值要求,因此需要一次拷贝/移动构造;NRVO 就是优化了这一次。
RVO 在 C++17 以后才被保证使用。NRVO 则更不确定。
move 原理
move
将一个左值或右值引用 t 转变为右值引用,方便调用移动构造函数(但 move 本身不会对参数做修改)。
实现为:通过 remove_reference 去除类型中的引用,然后通过 static_cast 转为该类型的右值引用类型。
template<typename _Tp>
constexpr typename std::remove_reference<_Tp>::type&& move(_Tp&& __t) noexcept
{
return static_cast<typename std::remove_reference<_Tp>::type&&>(__t);
}
move 的参数类型是T&&
,即通用引用,既可以匹配左值引用、也可匹配右值引用,具体见下。
仅 move 本身不会对参数做修改,所以一个函数调用表达式内,同时使用 p 的函数和 move(p) 还是没问题的(
func(p.get(), std::move(p))
),实际的移动发生在函数内,p.get()
在函数调用前就执行完了。当函数参数的类型非引用时,如果传入左值,则参数发生拷贝构造;如果传入右值,则参数发生移动构造(传入的对象将会被移动)。
因此,如果要向函数func(Node a)
传递不再使用的 Node 对象 a,应该使用func(std::move(a))
以便调用移动构造。
相似问题可以见 规则 - 其它 - pass-by-value。
remove_reference
remove_reference<T>::type
可以移除类型T
中的引用,如:T
是int&
或int&&
都返回int
。
实现就是一个类模板和两个特化的模板,对应非引用、左值引用、右值引用三种模板参数:
// 模板
template<typename _Tp> struct remove_reference { typedef _Tp type; };
// 特化
template<typename _Tp> struct remove_reference<_Tp&> { typedef _Tp type; };
template<typename _Tp> struct remove_reference<_Tp&&> { typedef _Tp type; };
template<typename _Tp>
constexpr typename std::remove_reference<_Tp>::type&& move(_Tp&& _t) noexcept
{
return static_cast<typename std::remove_reference<_Tp>::type&&>(_t);
}
右值引用 / 为什么需要 move
右值引用可为临时对象延长生存期,这点与 const 左值引用一致。但前者可修改,后者只读。
const 右值引用没有特别意义,传参时会被当做 const 左值引用。更重要的是,当函数同时具有右值引用和左值引用的重载时,右值引用重载绑定到右值(包含纯右值和亡值),而左值引用重载绑定到左值。这允许在适当时机自动选择移动构造、移动赋值和其它具有移动能力的函数,使得作用域中不再需要的对象可以被移动出去,避免拷贝和不必要的析构。
左值是表达式结束后依然存在的持久对象(对象在内存中占有确定的位置)
右值是表达式结束时不再存在的临时对象(不在内存中占有确定位置的表达式)很多时候,我们会通过 表达式产生或不再使用的临时对象 去初始化一个对象。如果没有右值引用,就只能通过拷贝临时对象去产生一个新对象,然后临时对象就会析构,多了一次无意义的拷贝和析构。右值引用和移动可以避免这一点。(尤其是某些对象内可能有容器,包含很多数据,拷贝时需要进行深拷贝;而移动则很高效)
区分左值右值,允许程序员更加精细的处理对象拷贝时的内存开销,提高了对临时对象和不需要的对象的利用率,这是 C++ 很有意思的一点。
move 将一个左值引用 T& 转为右值引用 T&&,以便调用移动语义的函数。
被移动所有权的对象不应再被使用,但不代表一定不能使用。要看移动的实现。比如 vector 的移动构造中,保证移动后的 vector 是 empty()。
浅拷贝:拷贝结构体时,会值拷贝里面的数据。但如果结构内有指针,指针值依旧会拷贝,导致拷贝后也指向同一个数据。
深拷贝:对于指针拷贝,需创建新对象,遍历指针指向的旧对象复制其元素。
更常用深拷贝。浅拷贝也很简单。
没有右值引用前,通过 拷贝构造函数、赋值运算符重载 实现结构体深拷贝:
class Array {
public:
int *data_, size_;
Array() {...}
// 拷贝构造
Array(const Array& temp_array) {
size_ = temp_array.size_;
data_ = new int[size_];
for (int i = 0; i < size_; i ++) {
data_[i] = temp_array.data_[i];
}
}
// 拷贝赋值
Array& operator=(const Array& temp_array) {
delete[] data_;
size_ = temp_array.size_;
data_ = new int[size_];
for (int i = 0; i < size_; i ++) {
data_[i] = temp_array.data_[i];
}
}
~Array() {
delete[] data_;
}
};
拷贝构造会进行一次深拷贝。即使参数为左值引用类型,避免了一次参数拷贝(所以要加const
避免修改原值)。
拷贝会保留原值,但有时候,我们不需要保留原值,可以直接将原值的数据给它,原值就不要了。
比如:vec.emplace_back(Node{1})
,Node{1}
会创建一个临时结构体,可以直接将这个结构体内容赋给vec[i]
,然后清空原结构体(也避免多次delete)。
这个Node{1}
就是右值引用。它在表达式结束就会销毁,所以不如直接拿它的值来用。
对于一些左值,如果赋值完后不再需要,也可直接拿它的值过来。这个移动就通过右值引用实现。
右值引用允许通过 移动构造函数、重载赋值运算符(使用右值引用做参数)实现:
class Array {
public:
...
int *data_, size_;
// 移动构造
Array(Array&& temp_array) {
data_ = temp_array.data_;
size_ = temp_array.size_;
// 为防止temp_array析构时delete data,提前置空其data_
temp_array.data_ = nullptr;
}
};
右值引用避免了深拷贝,提高了拷贝性能。如果参数在赋值完后不再需要,就可以移动构造。
(但如果没有实现移动构造或赋值(比如被隐式弃置),也会调用拷贝构造或赋值,即 const & 可以匹配右值,但非 const 的 & 不可)
此外,move
也相当于移交内部对象的所有权。
如:std::unique_ptr
只允许移动构造函数,来保证它拥有对象的所有权,而原指针没有。
std::swap
就会先尝试std::move
,不能再拷贝。(不确定)
万能引用 / 通用引用
万能引用 表示它既可能是个左值引用,也可能是个右值引用。它并不是一种引用类型。
右值引用与万能引用的区别是:右值引用必须是一个明确的类型,如int&&
,而万能引用只用于会发生类型推导的类型,如T&&, auto&&
,可能会是右值引用。注意,只有发生类型推导时才是,像模板类内的非模板成员函数不是,比如:
template <class T> void vector<T>::push(T&& value)
就不是。可以绑定到 const T&,此时不能修改。
template<typename T>
void f(T& param) {
cout << param << endl;
}
template<typename T>
void func(T&& param) {
cout << param << endl;
}
对于第一个函数,只能接收左值或左值引用类型,如int a=3; f(a); f(&a);
,不能接收右值,如f(3)
。
想要支持右值引用,就得再写一个。
为此,C++11中提出 通用/万能引用 (Universal Reference):使用右值引用类型的形参,既能接收右值,又能接收左值。
不只参数,只要是右值引用,都可以接收左值引用类型?
所以上述函数只需要写第二个。
但注意,只有发生类型推导时,T&&
才表示万能引用(如模板函数传参就会经过类型推导的过程,所以如果T
是模板,T&&
就是万能引用;如果T
不是模板,T&&
就是右值引用),否则只表示右值引用。
最常见的例子:是用于模板T&&
,或是用于auto&&
。
注意,当用函数模板实例化,给一个函数指针赋值时,指针的参数类型必须完全匹配函数模板定义中的参数类型。
即使函数模板用的是万能引用,也只能匹配参数类型为右值的函数指针(实例化了就不再是万能引用了)。
template <typename T>
void f(T&& v) {
cout << v << '\n';
}
void(*p)(int&&) = f<int>; // ok
// void(*p)(int&) = f<int>; // error,除非 f 是 f(T& v)
// void(*p)(int) = f<int>; // error,除非 f 是 f(T v)
引用折叠
调用函数时,会发生实参和形参的引用类型不同的情况,两个引用之间会发生引用折叠,结果只保留一个引用:只有两个引用都是右值引用时,结果才是右值引用。如:
int& &&
是int&
,int&& &&
才是int&&
。(可看做引用间的类型转换?)所以,在调用函数时,如果形参是引用类型,可以传递一个引用类型进去,但结果实际的引用类型同时取决于形参和实参的引用类型。
多个引用会发生引用折叠,所以没有多重引用;但是有多重指针。
当形参类型为引用时,传入对应的左/右值会变为左/右值引用类型。
但是 C++ 不允许对引用再加引用,所以当传入引用时,即对引用再进行引用类型转换时,实际的参数类型为:
如果形参或实参的任一类型为左值引用,则实际类型为左值引用。否则,即形参或实参都是右值引用,类型才是右值引用。
// 左值-左值:`T& &`,实际为`T&`
template<typename T>
void func(T& param) {
cout << param << endl;
}
int main(){
int num = 2021;
int& val = num;
func(val); // param 是一个int&
}
// 左值-右值:`T& &&`,实际为`T&`
template<typename T>
void func(T& param) {
cout << param << endl;
}
int main(){
int&& val = 2021;
func(val); // param 是一个int&&
}
// 右值-左值:`T&& &`,实际为`T&`
template<typename T>
void func(T&& param) {
cout << param << endl;
}
int main(){
int num = 2021;
int& val = num;
func(val); // param 是一个int&
}
// 右值-右值:`T&& &&`,实际为`T&&`
template<typename T>
void func(T&& param) {
cout << param << endl;
}
int main(){
int&& val = 4;
func(val); // param 是一个int&&
}
forward 原理 / 完美转发
万能引用函数的实参,可以是左值引用也可以是右值引用。但在函数内,x 作为参数始终是左值。
如果在函数中再使用参数x
进行函数调用,并且要保持它的引用类型,需要用std::forward<T>(x)
。forward 是借助 类型 T 和引用折叠 返回正确的引用类型的:当参数为左值引用时,T 为左值引用,与 forward 中附加的 && 会折叠成左值引用;当参数为右值引用时,T 不带引用,会被 forward 转换成 &&。
一般只有万能引用的函数需要完美转发,因为什么都不加就是左值,用上 move 就是右值。
struct A {
string s;
void setS(string name) noexcept {
s = std::move(name);
}
// or
template <typename String, typename = typename
std::enable_if< !std::is_same<std::decay_t<String>, std::string>::value >::type>
void setS(String &&name) noexcept(std::is_nothrow_assignable<std::string&, String>::value) {
s = std::forward<String>(name);
}
};
当 forward 中的模板参数 T 是一个值类型时(无引用),
forward<T>(t)
等价于move(t)
。
void f(A a) {
// g(a); // 始终不会是右值
g(std::forward<A>(a)); // 始终是右值
}
f(a); // 拷贝一个左值,然后 f 传递右值
f(A{1}); // 右值被直接构造,然后 f 传递右值
在函数相关的模板的转发逻辑中,forward 能根据函数参数的类型(A, A&, const A&)正确转发临时的函数参数。见 Codes - C++ - function_ref。
当给函数传递右值引用T&& x
后,再在函数内将x
作为参数,x
就变成了左值,因为作为参数它有了x
这个名称和地址。
如果传递参数时要保持x
的右值引用,必须用std::forward<T>(x)
传递(如果没有万能引用,或者明确要求右值,可以 move)。
类似的,定义int&& a=1
,调用f(a)
传的也还是左值引用,因为a
是具名的左值。
例子见这里或:
void overloaded (const int& x) {std::cout << "[lvalue]\n";}
void overloaded (int&& x) {std::cout << "[rvalue]\n";}
template <class T>
void fn (T&& x) {
overloaded (x); // always an lvalue
overloaded (std::move(x)); // always an rvalue
overloaded (std::forward<T>(x)); // rvalue if argument is rvalue, else lvalue
}
int main () {
std::cout << "calling fn with lvalue:\n";
int a;
fn (a);
std::cout << "calling fn with rvalue:\n";
// int&& a = 0;
fn (0);
}
forward 的实现:
template<typename _Tp>
constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type& __t) noexcept
{
return static_cast<_Tp&&>(__t);
}
template<typename _Tp>
constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type&& __t) noexcept
{
static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument"
" substituting _Tp is an lvalue reference type");
return static_cast<_Tp&&>(__t);
}
在上面的 fn 中,x 始终是左值,所以总是会调用第一个 forward,第二个是没意义的。
forward 是利用类型推导的 T 进行类型转换的:
int&
(以 int 为例,反正是一个具体类型),cast 转换的类型,即返回的类型会发生引用折叠:int& &&
即int&
。int
,则返回的类型就是int&&
。注意,在进行类型推导时,如果函数的参数是万能引用,即:
template<typename T> void f(T&& x)
,则:
- 调用
f(左值)
,T 会被推导为相应类型的左值引用(const 左值则为 const type&,否则为 type &),如:f<int&>(int&)
。- 调用
f(右值)
,T 会被推导为值或指针(type 或 type*,反正不是引用),如:f<int>(int&&)
。
容器的 emplace 系列函数可接收元素 T 的各参数,将参数 args... 作为std::forward<Args>(args)...
转发给构造函数,完成构造。
而对于 push,必须传递 T 类型,即自己进行构造。
static_assert
static_assert 是编译时的静态检查。通过它,可以用编译器来保证某些约束,在编译期间发现更多的错误,来减少 bug 的产生(尤其是涉及模板时)。
它可以出现在命名空间和块作用域中(作为块声明),也可以在类中(作为成员声明),也可以在函数内。
由于是在编译期间进行断言,不会生成目标代码,所以不会影响程序的性能。
static_assert(bool-constexpr, message) //(since C++11) message 必须是字符串常量或字面量
static_assert(bool-constexpr) //(since C++17)
函数参数不会被认为是立即常量表达式,因此不能出现在 static_assert 里(即使是 constexpr 函数)。
如果想对参数做 assert 校验,并想如果它在编译期能确定时调用 static_assert,需要些别的方法。见这里。
static
static 有多种含义。
静态变量在编译期就可以分配相对内存地址。全局静态变量是在 main 执行之前零初始化(如果是动态链接则是在链接时),局部静态变量是第一次使用时初始化。
volatile
C++ 中,volatile 与原子性(atomic)、内存序(memory_order)、建立线程同步(锁等)无关(应该用括号中的东西)。它不应该被应用于多线程编程。
volatile 意为允许内存映射的 IO 操作(给驱动开发者),仅要求编译器对它和其它 volatile 数据的读写按照程序的先后顺序执行,不能对 volatile 变量内存的读写重排序。因此它并不是内存屏障。
volatile 表示读会产生副作用。副作用可以认为是会影响全局状态的东西。
编译器优化代码时,默认读取没有副作用,如果有,就需要代码告诉编译器,防止它做出错误的代码优化。
可以参考 clang 的解释:特定的访存操作,如 load、store、llvm.memcpy 可被标记成 volatile。优化器不能修改 volatile 操作的数量、不能修改 volatile 操作之间的顺序(相对于其它 volatile 操作)。
但允许优化器修改 volatile 操作相对于非 volatile 操作的顺序,因此与 Java 不同(Java 用它当做屏障),对 volatile 的读写不是屏障,不能用于多线程。
但 MSVC 对标准 C++ 语法做出了扩展,给 volatile 增加了线程间同步的含义,但考虑到可移植性,没有理由新标准库提供了其它解决方案的前提下再使用这种非标准扩展。
对于绝大部分程序员而言,用不到、也不应该使用 volatile。
const
define 只是简单的替换,没有类型信息。define 的定义也不能提供任何封装性,可以被全局访问。此外复杂的内容还容易出错。
所以应尽可能少的使用 define,可以用 const、inline 函数、enum 去代替。const 是具体的对象。const 的变量放在只读区域,有时甚至可以优化为立即数(但没有地址)。
const 可以修饰变量,限定它为常量、不允许改变。
尽可能使用 const 可以减少编程错误;编译器对常量的运算还会尽可能优化,所以效率也高。
const 修饰函数
const 可以修饰成员函数(放在后面)。const 函数无法修改成员变量(其它见 面向对象 - 成员函数的引用限定)。
对于非 const 方法,里面的 this 指针类型是
Type * const this;
;对于 const 方法,里面的 this 指针类型是const Type * const this;
,所以 this 指向 const 对象,无法修改成员。
类似 remove_reference,可以用模板写出一个 remove_const:
template<typename T>
struct remove_const {
typedef T type;
};
template<typename T>
struct remove_const<const T> {
typedef T type;
};
template <class T>
using remove_const_t = typename remove_const<T>::type;
int main() {
int a = 1;
const int b = 2;
remove_const<decltype(a)>::type aa = 3;
remove_const_t<decltype(b)> bb = 4;
cout << std::is_same<decltype(aa), int>::value << endl; // true
cout << std::is_same<decltype(bb), int>::value << endl; // true
}
注意指针的情况,const int *p
的 const 不是 p 的,用 remove_const 后不会有变化。见 top-level const。
constexpr
constexpr 可以:
const 能表示两种含义:
f(const int x)
),虽然这个变量不能直接修改,但它本质上依旧是变量,且可能通过其它方式进行修改(如一个 const 引用)。const int x = 1;
),常量可以初始化数组,如array<int, x> arr
。C++11 中可以将 const 专门用于只读变量声明,将 constexpr 专门用于编译期常量声明。
在 C++11 中,对 constexpr 函数要求非常严格,函数体内只能包含如下内容:空语句;
static_assert
;typedef
;using
;一个返回值语句(必须。不过返回值可以是逗号表达式,允许执行其它简单语句)。
C++14 后允许其它语句出现在 constexpr 函数体内,便于使用 if、for 等语句,不需要通过复杂的模板元编程,就能实现编译期计算(且 constexpr 函数的效率要比模板高很多)。但是,类型本身不能像值一样,在函数体内执行某些语句或运算。类型只能通过模板参数使用,通过模板元编程运算。
C++23 前,程序理论上需要满足:对任意 constexpr 函数,至少存在一组实参取值,使得其能够在编译期调用(满足核心常量表达式要求)。否则非良构。但这并不要求编译器诊断。(有些人希望 constexpr 声明了就该编译期用到)
C++23 起移除了该限制,即使所有调用都在运行时也可以。
constexpr if
constexpr if 语句在编译时求值,并会舍弃条件不满足的语句(直接不编译)。
适用于模板,避免生成某些当前类型无法编译的语句。
C++23 引入了 consteval if 语句(TODO)。
常量表达式 (constant expression)
常量表达式指能在编译时求值的表达式。它是结果满足某些条件的核心常量表达式(见 ref)。
如果常量表达式的值是指针或引用,则它必须指向静态存储期对象(见下 编译期取地址)、函数或空指针。
存储类说明符
存储类说明符是标识符声明中的一部分,除 thread_local 可和 static/extern 一起外,只能出现一个。
与作用域一同决定标识符的存储期与链接。包括:
存储期
所有对象都具有4种存储期之一:
静态局部变量
块作用域内声明的 static 或 thread_local 对象是静态局部变量。当程序首次经过其声明时,才会被初始化(除了零初始化和常量初始化)。在后续执行中,声明会被跳过。
编译器会生成代码来保证初始化仅发生一次(类似 call_once)。
编译期取地址
静态存储期对象可以在编译期取地址,该过程是 constexpr 的(当然编译期取到的地址不是实际地址,在程序运行前这是无法确定的。但编译器可以为其指定相对地址,或运行后建一个映射表,保证运行时能取到实际地址)。
自动存储期对象的地址在编译期是难以确定的,涉及当前栈帧位置。
因此可以用静态存储期对象,初始化 constexpr 引用或指针:
int a;
{
static int b;
constexpr int& ra = a;
constexpr int* pb = &b;
ra = 3; // 与 const 类似,引用的常量性指绑定的对象不变,值可以改变
*pb = 5;
int c;
// constexpr int& rc = c; // error: &c 不是常量表达式
}
或初始化引用类型的模板非类型实参:
template <string& s> // string* 也可
struct D {
void p() {
cout << s << '\n';
}
};
string s = "abc";
{
D<s1> d; // 如果是指针则 D<&s1> d;
d.p();
}
cast
转换表达式的结果是:
- 如果新类型 是左值引用或到函数类型的右值引用,则为左值。
- 如果新类型 是到对象类型的右值引用,则为亡值。
- 否则(即转换到非引用类型)是纯右值。
static_cast:用于良性转换 (no run-time check),一般不会导致意外发生,风险很低。
接近旧式 C 转换(其实很不同),如普通类型转换,最常用。
dynamic_cast:借助 RTTI(运行时检查),用于类型安全的向下转型 (downcasting)。
具体来说,可以将基类的指针或引用,安全地转换为派生类的指针或引用。
因为基类对象的起始位置,不一定就是派生类对象的起始位置,直接类型转换会出错。dynamic 会找出某对象的内存起始位置,并在失败时。
(如果可以,dynamic_cast 最好用 static_cast 替代,避免运行时开销?为了其通用性,dynamic_cast 效率有时可能还好,有时可能会很低)
const_cast:用于 const 与非 const、volatile 与非 volatile 之间的转换。
常用于去除某个引用或指针对象中的 const,以便可以调用非 const 的重载函数(但不是为了修改它。写底层是 const 的对象是 UB)。比如:非 const 成员函数 f 调const_cast<T&>(static_cast<const A&>(*this).f())
就不用 const 与非 const 两个方法写两遍(但这被 deducing this 解决了?)。
简单来说 const_cast 是去除后加的 const,而非把原本 const 的改成非 const。
reinterpret_cast:高度危险的转换,这种转换仅仅是对二进制位的重新解释,不会借助已有的转换规则对数据进行调整,但是可以实现最灵活的 C++ 类型转换。
不具备移植性,常见用途是转化函数指针类型。可用于进行没有任何关联之间的转换,比如一个字符指针转换为一个 int 指针。
注意,char*
指针的输出类型与其它不同,不仅输出当前值,还会一直输出char直到遇到\0
即0x0
。例:https://zhuanlan.zhihu.com/p/33040213。
static_cast 和隐式转换是在语言/语义层面上做转换,比如:在子类向基类 static 转换时,编译器能够理解这一行为并做出相应处理(通过一点偏移,获取基类的起始位置)。
reinterpret_cast 直接假设一个指针拥有其它类型,可以直接转换。因此用它将子类转成基类可能是错的。
例:
struct A { // 4B
int32_t a;
};
struct B {};
struct S: A, B {} s;
// 输出地址
p(&s); // 假设为0
p(static_cast<B*>(&s)); // 4
p(implicit_cast<B*>(&s)); // 4 代表隐式转换
p(reinterpret_cast<B*>(&s)); // 0 显然不对
p((B*)&s); // 4 类似 static_cast
如果代码发生了重构,S 不再继承 B,那么原本的 static_cast 和隐式转换会错误,而 reinterpret 和 C 转换仍然生效:
struct S: A {} s;
// 输出地址
p(&s); // 0
p(static_cast<B*>(&s)); // CE
p(implicit_cast<B*>(&s)); // CE
p(reinterpret_cast<B*>(&s)); // 0
p((B*)&s); // 0
此时 C 转换的行为反而类似 reinterpret_cast。
C 风格转换
不建议使用该转换,不只是因为它不够明确,还有它过于强大(可以代表多种转换),导致非常容易出错:
C 风格转换会按顺序尝试多种 cast,直到发现某一种转换方式合适:
const int *
指针 cp,既可以通过const_cast<int*>(cp)
获取 int*,也可直接通过(int*)cp
去掉 const。例子见上面 cast 的例子。
实现其它的 cast
包括 implicit_cast、pun_cast、public_cast。见下。
implicit_cast
当能使用隐式转换时,应该避免使用 static_cast 强制转换,因为它可以调用 explicit 构造函数和 explicit 转换运算符:
struct A {
explicit A(int a) {}
};
void f(A a) {}
int a = 1;
f(a); // CE
f(static_cast<A>(a)); // ok,调用explicit构造
这可能会导致意外。应该用尽量弱但正合适的方式解决问题,而非过于强大的方式(比如 C 风格转换)(principle of least power)。
但有时隐式转换会不生效(即使完全合适),就是需要显式 static_cast。
比如:在模板函数类型推导中,子类可能需要显式转换为基类:
template<class T>
void f(const T& b, const T& d) {}
f(base, derived); // CE
f(base, static_cast<Base&>(derived)); // ok
因此我们需要写一个隐式 cast,只做隐式转换会做的事情,虽然写出来它就像是一个显式转换。
这个 cast 不需要做任何事,只需要返回对应类型的原值,因为将参数传入本身就会做隐式转换(如果隐式转不了编译器也会给出错误)(是否需要加引用?):
template<class T>
constexpr T implicit_cast(type_identity_t<T> val) {
return val;
}
type_identity可以声明一个参数不参与类型推导,它只是和推导完成后的类型 T 相同(允许传其它类型,隐式转换到 T)。
由于没有其它参数供推导,所以这种写法可以要求 implicit_cast 必须写明 T(如implicit_cast<Base>(d)
)而不能忽略<T>
走类型推导。
(一个经验是,如果参数类型中有双引号xx::type
,则该参数无法进行推导,比如type_identity_t
)
type_identity C++20 起才有,因此可以自己实现:
template<class T>
struct type_identity {
using type = T;
};
template<class T>
using type_identity_t = typename type_identity<T>::type;
实际使用时,还要注意如果 T 可移动构造且 noexcept,则函数可以标记 noexcept。
pun_cast / bit_cast
由于 C++ - 严格别名规则,将一个类型的指针强制转换(如 reinterpret_cast、C cast)到另一个不相关类型的指针是 UB(同一内存地址不能拥有两种类型的视图)。
但有时确实有这种类型双关 (type punning) 的需求。那么如何写一个不是 UB 的强制转换?
C 中能用的一个方法是联合:将两种类型放在一个 union 里(同一内存地址),写入 U 类型值,然后用 V 类型读取该值:
template <class U, class V>
V pun_cast(const U& val) {
union { U u; V v; };
u = val;
return v;
}
但是在 C++ 中,union 只能读取最后一次被写入(已激活)的成员,否则也是 UB(也是因为严格别名)(除非此成员具有标准布局)。
要避免 UB 只能保证 U, V 有不同的地址,因此可通过逐位拷贝:
template <class U, class V>
V pun_cast(const U& val) {
static_assert(sizeof(U) == sizeof(V) &&
std::is_trivially_copyable_v<U> &&
std::is_trivially_copyable_v<V>);
V v;
std::memcpy(std::addressof(v), std::addressof(val), sizeof v);
return v;
}
注意需要 static_assert(或 sfinae (enable_if) 或 concepts)保证类型大小相同,且两个类型可以逐位拷贝(可平凡拷贝)。
但由于 memcpy 不是 constexpr 的,所以该函数不能是 constexpr,因此不能在编译期完成双关转换。
C++20 起,引入了 bit_cast,它类似 memcpy,但是允许在编译时完成转换。
template <class U, class V>
constexpr V pun_cast(const U& val) {
return std::bit_cast<V>(val);
}
所以其实也不需要写 pun_cast,直接用即可。
public_cast
通过它可以在类外部访问私有或保护的数据成员或函数,不会产生 UB。但这会破坏代码的封装性,影响人对程序的判断(类外可以随意更改私有变量),所以不应被使用。
原理是:在进行显式模板实例化时,将不会进行访问限定检查。
代码见下,具体见视频。
class C {
int x{9};
} c;
// auto px = &C::x; // 非法
// int x = c.x; // 非法
// M是想访问的成员指针,Secret的名字是一个key
template <class M, class Secret>
struct public_cast {
static inline M m{};
};
// 在val实例化时,能够将私有的成员指针C::x赋值给m
// 通过一个链式调用,让其在赋值时顺便赋给public_cast,以获取到私有的成员指针
template <auto M, class Secret>
struct access {
static const inline auto m
= public_cast<decltype(M), Secret>::m = M;
};
// 访问c.x需要两条语句
template struct access<&C::x, class CxSecret>;
int x = c.*public_cast<int C::*, CxSecret>::m; // 9
其它方法也能实现,但原理是一样的:因为会有导出显式实例化模板的需求,而当该模板实例化涉及私有成员时,就不得不允许忽略访问权限。
NULL
NULL 是一个宏定义,在 C 中为(void*)0
,在 C++ 中为 0:
#ifdef _cplusplus
#define NULL 0
#else
#define NULL (void *)0
#endif
C 语言中void *
和任何指针类型之间可以互相隐式转换:
void *pv0;
float *pf = pv0;
int *pi = pv0;
pv0 = pf;
pv0 = pi;
但在 C++ 中,任何指针类型可以隐式转换为void *
,反过来则必须使用 static_cast 或 C 风格的T(x)
或(T)x
显式转换:
void *pv0;
float *pf = static_cast<float *>(pv0);
pf = (float*)pv0;
// pf = pv0; error: invalid conversion
int *pi = static_cast<int *>(pv0);
pv0 = pf;
pv0 = pi;
如果 NULL 仍是一个void *
指针的宏定义,在 C++ 中直接写float *p = NULL;
将无法通过编译,所以 C++ 定义 NULL 为 0,而不是指针类型的0。
允许到void *
的转换,是为了兼容 C;不允许隐式反向转换,是因为当函数存在多个重载时(函数1使用 int* 参数,函数2使用 float* 参数),传递 void* 参数时可能导致歧义。
C++ 中,对于一个非 void 的 T* 指针 p,f(p)
将优先匹配对应参数类型f(T* p)
,然后才匹配 void* 参数类型f(void* p)
。
void* 的指针 p 由于不能隐式转换,只能匹配f(void* p)
。如果允许隐式转换,p 将无法确定匹配f(int* p)
还是f(float *p)
。
所以,因为函数重载的歧义问题,C++ 不允许 void* 隐式转换为其它类型,所以将 NULL 从void*
改为了 0。
0 的取值只是随便设了一个非空指针不可能取到的值(地址不可能为0)。
在 C++11 及以前,任何可以编译期求值为 0 的整型表达式,都能转换为空指针。
nullptr
C 语言是没有重载的,T* 和 void* 之间可以随意隐式转换。
但 C++ 规定任何类型的指针可以隐式转换为 void*,但反过来必须显式转换,否则函数重载时会出现歧义。
所以如果 NULL 还是(void*)0
,任何T *p = NULL
的隐式转换写法都将无法过编译。
为了兼容,C++ 将 NULL 改成了 define 0。但这样 NULL 在重载时就会被认为是 int,而非指针(define 没有类型信息)。
所以,C++ 给出了 nullptr,不仅像 NULL 一样表示空指针,还是一个明确的指针类型。
NULL 被定义为数字0,又导致了另一种函数重载时的歧义:f(0)
与f(NULL)
都将匹配f(int x)
,而不是f(void* p)
或f(int* p)
。因为 NULL 就是 0,在类型推导时会被认为是 int 而非指针。
C++11 引入了 nullptr,它是一个安全的空指针,不容易产生歧义,是一个明确的指针类型。f(nullptr)
将匹配f(void* p)
。
nullptr 是一个空类nullptr_t
的唯一实例,可以认为它就是 0,但编译器会将它特别处理,视为一个特殊指针(值为 0)。
栈展开 (stack unwinding)
当函数抛出异常时,函数会将当前栈内的对象析构并返回 (return),然后沿着函数调用栈依次向上,不断析构栈对象、返回,直到遇到第一个能捕获当前异常的函数。
这种沿着调用栈不断向上,寻找异常处理块的过程,叫栈展开。
析构函数抛出异常
C++ 并不阻止,但析构函数不应该抛出异常,否则可能导致异常无法处理,程序退出;或导致内存泄露。
因为在遇到异常时,会发生如上的栈展开过程,这期间会析构当前栈内对象,直到找到一个能处理异常的函数。
如果在析构栈对象的过程中,析构函数又抛出了一个异常,由于 C++ 无法同时处理两个异常,就会导致程序调用std::terminate()
结束(准确来说,如果当前有一个异常,只要新的异常能在函数内被立刻处理、不继续抛出就没事)。
此外,如果栈内有一个容器,当该容器进行析构时,会执行每个元素的析构,如果某个元素在析构时抛出了异常,容器的析构还是要继续(因为函数要退出,必须析构栈内的数据),如果又有一个元素抛出异常,也会导致程序崩溃。
所以如果一个类型的析构函数会抛出异常,该类型的容器的析构就是很危险的,连普通的数组也是。
此外,delete 操作符会先调用对象的析构函数,然后调用 operator delete 释放内存。如果析构函数抛出了异常,后续的释放内存就不会执行,导致内存泄露。
所以,应避免在析构函数中抛出异常,如:
std::abort()
主动退出,避免程序在一个未知的时刻突然崩溃,减少风险。析构函数、资源释放函数(例如 operator delete,以及功能类似物)和兼容标准的自定义 swap 函数,都应该尽可能保证成功。不抛出异常是成功的要求之一。
函数返回局部变量的指针
如果局部变量分配在栈上(如char s[] = "a";
,注意 s 不是指针),在函数返回时会释放,所以如果返回这种临时局部变量的指针,使用时就会出错。
如果局部变量在堆上(如静态局部变量,或char *s = "a";
分配到常量区),指针返回后可以使用。
开洞
有三种情况:
main 函数
程序应当有一个名字是 main 的全局函数,它被指定为程序的启动点。
程序的实际起始和结束点都不是main函数。在链接时,编译器还会自动链接 libc、crt1.o、crti.o、crtn.o,这些是不可缺少的。
链接后,程序的正式入口是<_start>
,会调用__libc_start_main
,这个函数中会进行初始化<_init>
、注册退出处理程序<_fini>
(main退出后执行的),然后调用main函数。
main函数的返回值会被用做exit
的参数,也就是正常情况下,main return后仍会调用exit(0)
。
0除了在惯例上表示无错误外,也是因为在 stdlib.h 中定义了EXIT_SUCCESS
为0(exit 函数成功时要返回的值)。
C99起标准要求int main
(只要返回类型与 int 兼容)在没有返回值时,默认return 0;
。
在 C 中int main()
并不是标准的无参 main 写法,int main(void)
才是。前者表示该函数可以接收任意数量的参数,但是不需要处理,后者只能在无参的情况下调用。
在 C++ 中无参 main 不需要再加 void 了。
头文件
头文件可以分为 header-only file 和 index file(不确定)。
header-only file 的所有声明和定义都包含在一个文件内,#include
后会包含这个源码进行编译。
index file 类似索引文件,#include
后可以去链接对应的静态、动态库。
header-only file/library 是因为编译器不支持模块分离(C++20才支持),不得不将模板的实现写在头文件中。
Boost 提出用hpp做为 header-only library 的文件后缀。
优点:
缺点:
重复定义问题如何解决?
类定义写在头文件,多文件包含那么 ifdef 也解决不了重复定义问题,inline 关键字才能解决。这个关键字有两个作用,一是规避ODR(One Definition Rule)规则,链接器对于ODR Linkage的符号,不会报重复定义错误,视为等价保留一份。二是字面意思提示编译器进行内联优化。TODO:https://maskray.me/blog/2022-11-13-odr-violation-detection
index file 指的就是声明和实现分离,使用时只需要#include
声明,编译后链接相应的编译好的库(lib, .dll, .so)。
动态加载 so/dll:https://lqxhub.github.io/posts/b810e905/
函数内联
内联:将被调用函数的函数体的副本,替换到调用位置处;修改代码以体现参数。
优点:消除函数调用开销:参数传递、寄存器保存;将过程间分析转化为过程内分析,便于优化。
缺点:函数体会变大,对指令缓存 (icache) 不友好;生成的二进制文件会变大(代码段变大),占用内存更多。
多数情况下是正向优化。
在函数声明时加 inline,是建议编译器这个函数可以内联,具体如何做由编译器自己决定。
所以会出现以下情况:inline 声明的函数仍被编译成函数调用 (汇编里用 call);没有 inline 声明的函数,却被内联。
一般不需要程序员做这件事,因为编译器自己可以识别,并且能做的很好。
inline
实际上 inline 的含义已经从“优先内联”变成了“允许函数和变量重复定义 / 规避 ODR”。不同编译/翻译单元可以有多个相同的 inline 函数实现,最后会只保留一个。因此可以在头文件中直接给出 inline 函数的完整定义,而不必像普通函数一样只写声明、将定义放在.cpp中。这对于 header-only 库开发很有用(多个编译单元可能同时使用某个库)。
inline 声明的函数或变量在每个翻译单元中都要拥有相同的地址、定义(包括必须都是 inline)。
首先要明确 include 头文件只是文本替换,因此不同翻译单元都会包含头文件中的定义,当引用同一头文件时就会出现重定义。
inline 声明的对象是外部链接,连接器会将这些对象视为弱符号,从而避免重定义冲突。链接器最后只会保留一个符号,因此不同翻译单元会使用同一个 inline 对象。static 声明与匿名 namespace 等价,也可用来允许重复定义、解决编译错误,但与 inline 的原理和目的不同:static 声明的对象是内部链接,在每个引用该头文件的翻译单元内都会有一个该对象,它们有不同的地址、是多个符号,不同的单元使用的是不同对象。
inline 并不常用,只是用于模板或 header-only lib,解决多个编译单元包含同名对象时的重复编译问题。在 C++20 有模块后就不需要了。
constexpr 声明的对象包含 inline。
在类内定义的成员函数是自动 inline 的,类外定义不会(包括 .cpp 中的)。
inline 函数必须要在 .h 中给出定义,这会增加编译时间,在修改函数时导致更多文件重新编译。(但通常来说影响不大?)
如果想要内联某个函数,需要将函数的定义放在头文件中,以允许编译器在调用时就看到它的实现,这样才能内联(为了避免编译错误还要加 inline)。
开启链接时优化 (LTO) 可以在链接时跨编译单元内联,就不需要这样了。函数被内联后,编译器可能不会生成它的符号。但如果对它取地址了,一定要生成。
C 的 inline 含义与 C++ 不同,代表的就是强制内联,如 gcc 的
__atrribute__((always_inline))
。(但 always_inline 并不保证一定内联,可能被忽略,会给警告。noinline 和 clang 的也是)
__builtin_popcount 原理
__builtin_popcount()
是一个内建函数,可以理解为一个特殊的函数。编译器看到这个函数之后不会按照普通的函数来处理,而是由编译器自己来决定这个函数应该生成什么代码。之所以要使用内建函数,主要是有的函数只用C代码很难实现或者效率不够高,不同平台的实现方式也可能不一样,就让编译器来实现。交给编译器就可以针对特定的硬件指令集优化,比如popcount函数,在x86平台上编译器就能直接用POPCNT
这条指令而不是使用C语言位运算做。
其他很多builtin函数原理都一样,是gcc内建的函数,一般没有移植性,使用时要注意。
C++里类似函数为std::popcount()
。
__builtin_expect
long __builtin_expect (long exp, long c)
。
给编译器提供分支预测信息:exp 是一个 bool 表达式,为实际返回值;c 是表达式的期望取值(0 或 1)。
在 if-else 中,编译器会根据__builtin_expect
的值,决定哪条分支的汇编代码紧跟在 if 后面,可提高 icache 的命中率。
size_t
std::size_t 是无符号数,表示理论上一个对象的最大大小,常用做容量和数组索引。sizeof 的返回值就是它。
size_t 在 32 位机器上是 32 位的 unsigned int,在 64 位机器上则是 64 位的 unsigned long (int),因为理论上一个数组的大小可以超过 2^{32},虽然并没有人这么做。
使用 size_t 可以增加程序的可移植性。但要注意无符号数为 0 时 -1 的问题。
ssize_t 是有符号的 size_t,即 int 或 long (int)。
intptr_t 与 ssize_t 相同,提供了一种可移植且安全的方法定义指针。
数据模型
每个实现关于基础类型的大小所做的选择被统称为数据模型。
因此,基础类型的大小是 implementation-defined 的,标准只规定了一部分,比如 int 至少是 16 位的。
但可以确定 5 类标准有符号整型满足:signed char <= short int <= int <= long int <= long long int。
64 位系统使用的数据模型有三类:LP64, LLP64, ILP64,只是在 int, long 两个整数类型上有差异:
LLP64 指只有 long long 和 指针是64位的,LP64 指 long 和 指针是64位的(自然包括更大的 long long),ILP64 指 int, long, long long 和指针都是64位的。
所有64位的类 Unix 平台均使用 LP64 数据模型,而64位 Windows 使用 LLP64 数据模型,两者在 long 上有区别。
long (int) 是至少 32 位的整数,由上,在 windows 下一般是 32 位,在 unix 下则是 64 位。
long long (int) 是至少 64 位的整数。
extern
见 OS - extern。
字符串字面量
程序中字符串字面量的生命周期伴随整个程序,不需要也不能去释放它。比如const char *s = "abc"; delete[] s;
是错的。const char s[] = "abc";
是栈上对象,也不应该 delete。
只有程序 new 出来的指针才能 delete。
所以如果要将字符串字面量(const char *)传给一个类,类获得指针后要 new 一个空间拷贝过来,不直接用这个指针;如果直接用那不能在析构时 delete。类内保存一个 string 而非 char * 指针更安全,不用考虑析构问题。
注意,通过字符串字面量隐式构造的 string 只是临时量。
成员指针
成员指针包括 数据成员指针 和 函数成员指针/成员函数指针,指向类的某个成员。前者大小为 8B,后者大小为 16B(原因见下)。
使用成员指针时,也必须和该类的实例一起使用。
静态数据和函数成员不与类关联,自然也不需要什么成员指针。
函数成员指针语法:返回值 (类名::* 函数指针名)(参数列表)
,通过(对象名.*函数指针名)(参数列表)
或(对象指针->*函数指针名)(参数列表)
调用。
与普通函数指针相比,就是在 * 前多了类名和作用域限定符::。
例:
struct X {
void f() { cout << "f()\n"; }
void f(int x) { cout << x << '\n'; }
} x;
// #1 p是一个成员函数指针(函数指针比较难看)
void (X::*p)() = &X::f;
(x.*p)();
// #2 参数p是一个成员函数指针。可以动态决定访问类的哪个字段
void g(void (X::*p)(), X& x) {
(x.*p)();
// (x->*p)(); // 如果x是指针
}
g(&X::f, x);
// #3 通过参数类型,可决定选择哪个重载,与普通函数指针一样
void (X::*p2)(int) = &X::f;
(x.*p2)(1);
// #4 传入bind时,通过类型转换决定所选重载
auto func = std::bind(static_cast<void(X::*)(int)>(&X::f), &x, 3);
func();
// 赋值给 function 时,需要有 (const) ClassName& 参数
Node node{1};
Function<int (Node&, int)> fp = &Node::f; // 通过Node&实例调用(也可以是const Node&)
cout << fp(node, 2) << '\n'; // 1
数据成员指针语法:变量类型 类名::* 成员指针名
。
例:
struct X {
int v{1};
} x;
// #1 p是数据成员指针
int X::*p = &X::v;
cout << x.*p << '\n'; // 1
int& t = x.*p;
t = 2;
cout << x.*p << '\n'; // 2
// #2 参数p是数据成员指针
void f(int X::*p, X* x) {
(x->*p) = 3;
}
f(&X::v, &x);
.*
和->*
都是成员访问运算符,分别表示对象/指针的成员指针。后者可重载。
一般搭配某些函数使用,比如invoke:
struct X {
int v{1};
void f() {
cout << "f()\n";
}
} x;
// 绑定数据成员,则返回引用
int& i = std::invoke(&X::v, &x);
i = 2;
cout << x.v << '\n';
// 绑定函数成员,则直接调用,返回值与调用的函数一致?
std::invoke(&X::f, &x);
标准没有规定成员指针如何实现,只规定了其行为,因此其实现(可以看这里)是一个 implementation-defined 行为(可能与编译器、平台都有关)。
对 gcc 和多数平台,数据成员指针 和 虚函数成员指针 实际是一个偏移量,代表该成员在类中的位置,不像普通指针一样指向实际的内存地址。
而 非虚函数成员指针 则是指向函数所在的内存地址(测试方式见这里)。为什么成员函数指针大小为 16B?
在调用成员函数时,需要传入当前对象的地址 this 以能访问成员,但这在多继承时有点不一样:设 C 继承了 A, B,当 C 对象调用 A 方法时,A 使用的 this 实际是 C 对象的地址 + A 类子对象在 C 中的偏移;B 使用的 this 同理,实际是 A 使用的 this + sizeof(A)。因此在调用父类方法时,需要给 this 加一个偏移量才可调用。
此时 C 的成员函数指针既可能指向 A 的方法,也可能指向 B 的方法,但它们使用的 this 不同,因此为了能区分,只能在成员函数指针中保存实际的 this 相对于当前 this 的偏移量。
指向类 C 的非静态数据成员 m 的成员指针,可以用&C::m
进行初始化。但在 C 的成员函数里面使用&C::m
会出现二义性:它既可以代表对 m 取地址&this->m
,也可以代表成员指针。
因此标准规定,&C::m
表示成员指针,&(C::m)
或者&m
表示对成员 m 取地址。
指向类 C 的非静态函数成员 f 的成员指针,可以用&C::f
进行初始化。由于不能给非静态成员函数取地址,所以&(C::f)
和&f
也都代表成员指针。
基类的成员指针,可以隐式转换为派生类的数据成员指针,对函数、数据都有效(前提是不是虚继承,没有虚继承表;但是 MSVC 例外)。
如:如果int Base::* bp = &Base::m
,则可int Derived::* dp = bp;
。
C 中的 tag
C 将 tag (enum, struct, union) 视为二等公民,也就是不那么重要,甚至同名定义与标识符不会冲突。但使用前必须加详细类型说明符,如:使用 Node 结构体类型前要加 structstruct Node node;
。
using
using 有如下功能:
使用 using 定义别名 (alias)
using 和 typedef 都是对原有类型起别名,不会创建新的类型。
但 using 不仅有 typedef 的各功能,还有其它优势(具体见这里和EMCpp):
template<class T>
using remove_reference_t = typename remove_reference<T>::type;
template<typename T>
using MyAllocList = list<T, MyAlloc<T>>;
// 使用:MyAllocList<T> list;
// typedef只能直接指定具体的类型
typedef unique_ptr<unordered_map<string, string>> UPtrMapSS;
// 或在类内部定义依赖类型
template<typename T>
struct MyAllocList {
typedef list<T, MyAlloc<T>> type;
};
// 使用:typename MyAllocList<T>::type list;
typedef void (*func_t)(int, int);
using func_t = void (*)(int, int); // 同样是函数指针类型
异常安全 (exception safety)
异常安全是指程序在发生异常或错误时,是否仍能保持正确工作的状态(这里异常与错误等同)。
通常异常安全可以分为四个等级:
程序中想要保证强异常安全是非常困难的,但如果使用异常,应尽可能做到基本异常安全。RAII 有助于实现这一点。
异常或错误处理 就是在问题发生时,恢复程序的状态、报告问题、可能还要处理泄露的资源(如果 RAII 则不需要)。
正常情况下,对象或系统应该满足某个一致性约束,称为不变式 (invariant)(在 go 为什么不支持可重入锁中也出现过)。比如:vector 对象要保证 data 指向的空间大小为 capacity,元素保存在 data 中。
一个操作或函数可能会暂时违反 invariant,并在正常完成时恢复。但是如果在操作的过程中出现了异常或说错误,对象的 invariant 应该怎么样?异常安全描述的就是错误发生后对象的 invariant,即对象是否可用:
- 强异常安全:invariant 仍成立,且状态与调用之前完全相同,即没有产生任何效果。对象可继续使用。
- 基本异常安全:invariant 仍成立,但不能知道它具体的状态。因此对象不能再使用,只能恢复成初始状态或销毁。
- 无异常安全:invariant 不再成立。如果不做处理,可能出现资源泄露。
异常
C++ 中的异常可以是任意类型(如 throw new int(5)),没有 Exception 等基类限制。
错误检查与处理有两种方式:抛异常;操作返回错误码,使用前检查。
异常不是 zero overhead 的:在 happy path 下(即不会抛出异常/没有问题的情况下)使用异常与错误码几乎没有性能区别;但在 bad path 下(即异常发生时)异常会比错误码慢很多。
因此当错误出现频率足够低、不将异常用于控制流或高频率事件时,可以使用异常,省掉每次对错误码的检查。
一般没必要使用异常,直接用错误码就好。(TODO,看 core guideline)
异常对象和错误码都可以附带一些其它信息,或继承形成层次结构,没有本质区别。
也可以用 C++ - expected(C++23)?
早期有动态异常规范 (dynamic exception specification) 用来限制函数能够抛出异常的种类(通过动态异常说明
throw(...)
),现在已经废除。
因为 C++ 中的异常都是非检查型异常/运行时异常(Unchecked Exception/Runtime Exception),在函数签名中声明异常类型没有意义,catch 时对异常的类型匹配是通过 RTTI 动态解析的。
noexcept
noexcept 有两种含义:
noexcept
或noexcept(表达式)
(前者等价于 noexcept(true))。 noexcept(表达式)
。 函数在不加任何说明符时,可以抛出任何异常。如果加了 noexcept,则不能在运行时抛出任何异常,否则程序会直接终止(调 terminate)(noexcept 不是编译时检查:编译器只会检查当前函数内是否有异常,不会管调用的其它函数是否抛异常)。
noexcept 的意义:
但是:
try...catch
逻辑,导致额外代码或阻止优化。所以其带来的优化很有限。因此一般情况下,只需要给移动构造、移动赋值、swap 添加 noexcept 声明(swap 可能会被用作移动)。
leaf function(不会调用其它函数的函数,比如:获取类成员变量、简单计算)也可以加。
对其它情况,确定的情况可以加,不加也没事,不会带来多少优化,不需要太在意。
与返回类型相似,异常说明是函数类型的一部分,但不是函数签名的一部分,因此只有异常说明不同的函数不能重载。
不求值操作数
以下操作数是不求值操作数,它们不会被求值:
除了不求值操作数表达式和其子表达式外,其它表达式都潜在求值。
作用域
作用域有多种:
extern "C" 的 {} 不会引入作用域。
extern "C" / 语言链接
https://zhuanlan.zhihu.com/p/123269132
https://github.com/huihut/interview/issues/114
所有函数 和 具有外部链接(即能被其它翻译单元使用)的变量和函数,具有语言链接的性质。语言链接是函数或变量类型的一部分。
具有某种语言链接,意思是它满足与某种语言编写的模块进行链接的所有要求(即它可以与这种语言编写的模块进行链接。要求包括调用约定、命名重整等,见 基础 - ABI, name mangling)。这使得不同语言编写的翻译单元可以互相链接。
extern 后可使用字符串字面量声明语言链接。标准包括两种语言链接:"C++"(默认)、"C"。
后面可以加大括号,来声明一系列函数。
因此,extern "C" 表示该函数或变量 满足要与 C 程序进行链接的所有要求,它可以与 C 编写的模块进行链接。
因此 C 单元可以调用 C++ 定义的 extern "C" 函数/变量,或链接这些 C++ 库。
最基本的要求为:不会对函数进行 name mangling(但不同编译器生成的结果仍然可能不同)。因此这样的函数也无法重载(否则 C 也没法用)。
如:
extern "C" void func(int a, int b)
,C 会将函数名编译为_func
,C++ 可能会编译为_Z4funcii
。
通过 extern "C",可以让 C 直接 include C++ 程序所使用的头文件,避免再写一遍。
但要注意 C 中没有 extern 的用法,只在 cpp 里有,因此这种头文件的接口函数要定义为:
#ifdef __cplusplus
extern "C" int f(int, int);
#else
int f(int, int);
#endif
do while (0)
do ... while (false)
有两个好处:类似简单的函数,可通过 break 跳出代码块,代替 goto;在宏定义中包含代码,可以在后面安全地加分号。
[]{...}()
是它的一个替代,并且能像函数一样返回值。只是没法直接 return 跳出当前函数。
伪析构函数
类型 T 可析构 (Destructible),指类型 T 的表达式 u 满足u.~T()
合法,且会回收 u 所拥有的资源、不抛出异常。
为了在模板中使用方便、不用检查类型 T 是否有析构函数,所有标量类型都满足可析构,但数组类型和引用类型不满足。
因此在模板中使用x.~T()
对于 int 等标量类型是合法的,尽管 int 没有析构函数,且直接调用x.~int()
并不合法。
template <typename T>
void f(T* p) {
p->~T();
}
int i = 1;
f(&i); // ok
// i.~int(); // error
template<typename T>
concept is_destructible = requires(T v) { v.~T(); };
struct X {};
cout << is_destructible<X> << '\n'; // true
cout << is_destructible<int> << '\n'; // true
对于E.~T
,当 E 是标量类型、T 是与 E 表达式同类型的类型名或 decltype 时,其只能用做函数调用运算符的左操作数(即func(args...)
的 func),所构成的函数调用表达式称为 伪析构函数调用 (pseudo destructor call)。求值 E 后结束它的结果对象的生存期(对于非类类型,在销毁该对象时(包括通过伪析构函数调用销毁)其生命周期结束)。
这是唯一使 operator. 的左操作数是非类类型的情况。
alignas
alignas可以修饰类、非位域数据成员和变量,指定该类型的实例或该对象有额外的对齐要求。(修饰类时,影响的是类对象,而非类内成员)
一个类的实际对齐,是该类所有成员中对齐要求的最大值:max(max(各成员类型的基本对齐),各成员的 alignas 最大值)。可通过alignof查询。
内存对齐的原因见 基础 - 计组 - 内存对齐。
注意,只有栈对象保证其起始地址位于对齐边界处,直接使用 new/malloc 分配的不保证。
此外,传入函数实参的对象也不会对齐。想要对齐,需要传递指针或引用。
使用 new 分配时,有默认的对齐边界
__STDCPP_DEFAULT_NEW_ALIGNMENT__
,如果分配的对象对齐值不超过该值,自然是对齐的,否则不保证。
要想保证对象对齐,需要用 new 的重载void* operator new(std::size_t, std::align_val_t);
operator delete也有同样的重载。C++17 起,如果对齐超过默认对齐边界,new 会自动调用重载版本,将对象的对齐值作为
align_val_t
的实参。容器的内存申请默认通过
std::allocator
,也不会特意进行对齐。想要保证需要自己指定分配器,比如:Eigen::aligned_allocator。
alignas 也可展开形参包。有多个 alignas 修饰的对象会取最大的对齐值。
template<class... T>
struct A {
alignas(T...) unsigned char buffer[8];
};
decltype
https://zh.cppreference.com/w/cpp/language/decltype
**总结:**decltype 对变量推导其原本的类型(不会 decay。当有括号时视其为表达式);对表达式,亡值推导右值引用,左值推导左值引用,纯右值推导值类型。
decltype(auto) 是对推导式的 decltype 的简写,实际就是 decltype(expr)。
auto 对实际类型为 T 的变量,推导得到 decay(不带引用、cv)。
auto&& 是万能引用,对左值得到左值引用,对右值得到右值引用。
// 推导 x 的类型,为 T
cout << is_same_v<decltype(A{}.x), int> << '\n';
cout << is_same_v<decltype(node.x), int> << '\n';
// 推导该表达式的类型,由于是亡值为 T&&
cout << is_same_v<decltype((A{}.x)), int&&> << '\n';
// 推导该表达式的类型,由于是左值为 T&
cout << is_same_v<decltype((node.x)), int&> << '\n';
// auto&& 对左值得到左值引用
auto&& v1 = node.x;
cout << is_same_v<decltype(v1), int&> << '\n';
// auto&& 对右值(纯右值和亡值)得到右值引用
auto&& v2 = Node{}.x;
cout << is_same_v<decltype(v2), int&&> << '\n';
decltype(表达式)
的返回值为:
所以,decltype((e))
(两个括号)与decltype(e)
的结果可能不同:如果 e 是带有括号的对象的名字,那么它会被当做普通的表达式,去推导其形成的表达式的类型,而非变量本身的类型 T:
Node{}.x
),则无括号时为 T,有括号时为 T&&。在模板中,常与 模板 - declval 一起使用。
decltype(auto)
decltype(auto) 与 auto 都是占位类型说明符,可从变量的初始化表达式/函数的 return 语句推导其类型。
与 auto 的区别为:
比如:auto c = s.at(0);
和decltype(s.at(0)) c = s.at(0);
类似,前者总是不带引用的,后者取决于s.at()
所以带引用。
注意,如果表达式 expr 中有括号,则 decltype(auto) 按照规则可能导致结果不同!
// 类似,decltype(auto) v = t; 与 v = (t) 也可能不同。
decltype(auto) f1(Node& t){
return t.x;
}
decltype(auto) f2(Node& t){
return (t.x); // decltype(auto) 实际为 decltype((t.x))
}
decltype(auto) f3(const Node& t){
return (t.x);
}
// 推导变量类型
cout << is_same_v<decltype(f1(node)), int> << '\n';
// 推导表达式类型,左值返回 T&
cout << is_same_v<decltype(f2(node)), int&> << '\n';
// 即使传右值,函数内 t 也是 const 左值,得到 const 引用
cout << is_same_v<decltype(f3(Node{})), const int&> << '\n';
auto
auto 可用于多种场景。
作为函数参数类型时,对于普通函数将生成简写函数模板(C++20 起)。对于 lambda 将生成泛型 lambda(C++14 起)。
作为占位类型说明符推导类型时,与模板实参推导方式相同。
因此 auto 得到的类型是原实参退化 (decay) 后的类型:对 T 的数组及其引用,auto 会得到 T*;对函数,得到函数指针;否则得到 remove_cvref_t。
比如:const auto& x = ...
得到的类型与template <class T> void f(const T&)
、f(...)
得到的 T 类型相同。auto 和 T 本身不会带引用和 const。
简单来说,推断对象类型时(不带引用)用 auto;推断引用类型时用 auto&&(auto&& 跟 T&& 一样也是万能引用,因为规则相同:左值得到左值引用,右值得到右值引用。如果不修改可以用 const auto&)。
当无法确定带不带引用时(常见于模板),可以用 decltype(auto)。注意可能返回引用,不要出现悬垂引用。auto 会强制代码做初始化,并强制变量类型与初始化的返回值一致,所以也可以多用。
initializer_list
C++11 引入了initializer_list
类型。当程序中出现一段以 {} 包围的字面量时,就会自动构造一个 initializer_list 对象。
它实际是一个只读常量数组,可以作为函数的形参,用 {...} 做实参传递。
初始化列表只是一个模板类template <class _Elem> class initializer_list{ ... };
,包含两个指针const _Elem *_First, *_Last
。通过首尾指针就能访问列表的任意元素。
所以 initializer_list 其实是一个语法糖:
vector<int> nums = {0, 1, 2};
// 上面与下面的方式等价
int nums_[] = {0, 1, 2};
vector<int> nums = initializer_list<int>(nums_, nums_ + _countof(nums_));
// 提供了迭代器方法,所以可以直接使用
int sum(initializer_list<int> nums)
{
int res = 0;
for (const int* it = nums.begin(); it != nums.end(); ++it)
res += *it;
return res;
}
// 可以方便地创建临时数组,用来遍历
for (int g : {1, x, y + z}) {
cout << g << ' ';
}
因为 initializer_list 只是保存指向常量数组的指针,因此将其直接传入参数也不会发生元素的拷贝(只是拷贝结构体,也就是两个指针)。
而通过变量构造它时,会发生变量到数组的拷贝。如:initializer_list<Node> i{node};
需要将 node 拷贝到指定区域。
因为只读,使用它初始 vector 时,里面的元素要被拷贝到 vector 中,因此它不支持 move-only 对象,所以vector<unique_ptr<int>>
就不能用它赋值。
使用它初始化容器的相关问题 及可能更好的实现:https://zhuanlan.zhihu.com/p/545305641
它是用来初始化的,不适合存储某些东西。
它是编译器开洞实现的,以它为参数的函数,在重载时有极高的优先级。
如何用一个类型表示不同类型
比如允许一个类型同时可表示 bool, int, double。
如果不同类型间可能同时存在,那么用结构体或 tuple 可以同时包含上面的几种类型(称为 product type)。
如果同一时刻只会使用一种类型(称为 sum type),上面的方案会浪费空间,可以:
union
C++ 中的 union 使用与 C 不同,有很多问题:
std::variant 是更现代的 union,见 variant。
variant
variant 和 visit 也能实现运行时多态,只是表达能力比继承弱(仅限于声明的几种类型)。因为是直接用值而非指针实现的多态,所以也叫值语义多态 (value polymorphism)。
理论上 visit 是可以内联的,只需要把整个函数体嵌入。如果有 switch 实现,则也不需要查表和访问函数指针。
std::variant 是类型安全的联合体(可称为变化体),能在类型切换时调用构造与析构函数,并在使用时检查类型是否匹配(包含类型信息)。
与联合体的行为类似:如果保存某个类型 T 的值,那么 T 的对象表示会在 variant 自身的对象表示中直接分配。
不能分配额外的动态内存,不能保存引用、数组、void。
当 get 访问不匹配的类型时,会抛出异常 bad_variant_access,所以需要确保类型正确。(访问不存在的类型时,可直接在编译期检查)
也可以使用 holds_alternative 或 get_if 安全地访问,它们在类型不匹配时分别返回 false 或空指针。
if (auto pval = std::get_if<int>(&v)) {
cout << *pval << '\n';
} else if (std::holds_alternative<long>(v)) {
cout << std::get<long>(v) << '\n';
} else {
puts("not int/long");
}
参考实现见 Codes - C++ - variant。
注意,variant 允许隐式转换,如:赋值 int 时,优先匹配 int,如果 variant 没有 int 类型则匹配 long,但无法匹配 float、double、char、short。
visit
variant 的类型是运行时确定的,因此存在其类型无法确定的情况。虽然能通过 if else 依次检查其所有类型(通过 holds_alternative 或 get_if),但这很繁琐。
std::visit 提供了获取其实际类型并执行的简单方式。它接收一个可调用对象 f 和若干个 variant,将 variant 的当前值依次转换为 f 的参数,然后调用 f,返回 f 的返回值。即它能够自动获取当前 variant 内的类型,并传给对应参数类型的 f。
f 称为 visitor(观览者),需要是能接收 variant 所有选项的可调用对象(好像只能是泛型 lambda 和仿函数?),且返回类型都要相同。
使用方式有三种:一是写所有类型都能执行的语句;二是先获取参数(即 variant 当前值)的类型,然后用 constexpr if 和 is_same 根据类型,执行不同语句;三是为可调用对象定义不同参数类型的重载,以便自动匹配(应该最好用)。
variant<int, string> v = 1;
// #1 variant 的所有类型必须都能执行下列语句
std::visit([](auto&& arg) { // 需要使用万能引用 auto&&(会生成有 operator ()(T&&) 的泛型 lambda)
cout << arg << '\n';
}, v);
// #2
std::visit([](auto&& arg) {
using T = decay_t<decltype(arg)>;
if constexpr (is_same_v<T, int>) // constexpr if 在编译期判断,从而能根据条件生成不同代码,避免 CE
cout << "int: " << arg / 2 << '\n';
else if constexpr (is_same_v<T, std::string>)
cout << "string: " << std::quoted(arg) << '\n';
else
static_assert(false, "观览器无法穷尽类型!");
}, v);
// #3
template<class... Ts>
struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;
std::visit(overloaded{
[](int arg) { cout << "int: " << arg / 2 << '\n'; },
[](const std::string& arg) { cout << std::quoted(arg) << '\n'; },
}, v); // 这些重载需要有相同的返回类型
实现方式(见 Codes - C++ - variant):
可调用对象 f 的类型在编译时就要确定,但 visit 能让 f 在运行时接收不同类型的参数、返回不同类型的值。
它的实现与 variant 中根据类型调用对应的 Destroy 函数类似:设 variant 有 n 种类型,则在编译时创建 n 个 f 的不同实例化函数,对应不同的参数类型,然后将它们的指针保存在数组中,visit 时直接使用 variant.index 访问数组调用。
创建不同实例化函数时,同样使用形参包展开:static constexpr VisitorT visitorFuncs[] = { visitImpl<F, Variant, ids>... };
。
这只适用于接收一个 variant 的情况。当有多个 variant 时,就需要定义 size1 * size2 * ... 个函数,并用一个多维表保存它们的指针。
也可以在 visit 实现内部写 switch,根据 index 调用不同函数,避免保存和访问函数指针数组。这个函数指针数组与虚函数类似,与该类绑定。因此调用代价也与虚函数类似?
any
std::any是一个可以接收任意类型的类型安全的容器。
虚函数的实现可见 Codes - C++ - any,与 function 类似。
不过虚函数需要运行时寻址,而且指针+虚表指针就要 16B,并不是很高效。在对象较小时,可以放在栈上,避免动态分配。
optional
https://www.bilibili.com/video/BV1xa4y1z7jJ/
std::optional
expected
TODO
https://zh.cppreference.com/w/cpp/utility/expected
tuple
tuple (元组) 可以把一组类型任意的元素组合到一起,且元素的数量不限。
是一个不包含任何结构、快速简单的容器,可用于函数返回多个返回值。
用std::make_tuple()
、列表初始化({1, "abc", 2.0}
)、构造函数(tuple<int, double> t(1, 1.0)
)都可以构造 tuple 对象。
用std::get<index>()
来根据下标获取 tuple 对象的某个元素。注意 index 必须为 constexpr,在写代码时确定,所以 tuple 无法循环遍历。
通过std::tuple_size<decltype(t)>::value
获取元素数量,std::tuple_element<index, decltype(t)>::type
获取元素类型(可用于声明变量)。
如果两个 tuple 元素数量相同、各元素类型各比较,则可比较。
为什么不能写 tuple[1]?
函数参数不会被当成编译期常量,不能做模板参数。还要保证传入的不是变量。
std::tie 可用于解包 tuple。
std::tie(a, b, c) = tp; // 绑定三个元素
// 如果要忽略绑定某些值,可以用 std::ignore
std::tie(std::ignore, std::ignore, c) = tp;
// std::tuple_cat 可以连接多个 tuple 和 pair
auto tp2 = std::tuple_cat(tp, std::make_pair("Foo", "bar"), tp, std::tie(n));
C++17 结构化绑定可以使用const auto& [a, b, c]
绑定数组、元组、类成员。见 C++ - 结构化绑定。
结构化绑定 (structured binding)
https://zh.cppreference.com/w/cpp/language/structured_binding
C++17 及以后,可用 auto 同时声明多个不同类型的变量,并从一个复杂对象得到赋值。
对象会被解包成多个变量,其类型和顺序与对象中的成员对应。
例:
struct Node {
int x;
std::string y;
};
const auto &[num, s] = Node{1, "s"};