语言:C C++
上次更新时间:11.19
C/C++
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/n4659Benchmark:https://quick-bench.com
未定义行为 / 各类 behavior
C++ 标准中一共规定有四类 behavior,分别是 well-defined behavior、implementation-defined behavior、unspecified behavior 以及 undefined behavior。
为什么会有未定义行为,不都做出规定?
具体见 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 的程序,最终状态可能是任何一个状态。标准没有对最终状态施加任何限制。
比如:数组越界、有符号整数溢出、空指针解引用、非 void 函数执行完成但没有返回值(应该声明__attribute__((noreturn)))。
严格别名规则 (strict alias)
对于两个指针类型
T1 *与T2 *,如果T1与T2在去除 cv-qualification 后是不兼容的类型并且T1与T2都不是char、signed char或unsigned char类型,那么这两个指针类型的实例禁止发生 alias,否则将产生 UB。在这里,编译器看到lhs的类型是int32_t *,rhs的类型是int64_t *,而int32_t与int64_t这两个类型并不兼容,因此判定lhs与rhs不可能 alias,进而完成优化。
TODO
名字查找 / 如何决定要使用哪个同名函数/同名变量
https://zh.cppreference.com/w/cpp/language/unqualified_lookup
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[])。 - 调用构造函数构造对象,初始化这片内存空间。
对于类和结构体来说:
- 如果类没有实现无参构造,则
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 释放由 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();
malloc
glibc 的 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 一致;
如果之前已申请过内存,则有两种情况:
- 新分配的内存块比原来更大:则可能在原内存块后继续分配,也可能分配一个新的内存块,将数据拷贝过去,然后用 free 释放原内存块。
- 新分配的内存块比原来更小:直接在原内存块的基础上缩小。
不管怎样,调用 realloc 后都不应该使用传入的那个指针。
注意,malloc、realloc 只会进行分配,不会进行初始化,更别提调用构造函数。
所以如果对 new 出来的空间使用 realloc,新的空间是不会被初始化的,旧的空间也不会调用析构函数。所以 new 不应该与 free 或 alloc 函数混用。
dangling pointer, wild pointer
悬空指针是指向已删除对象的指针。realloc 如果发生 free,则传入的指针可以看做悬空指针(反正访问是 UB)。
野指针是没初始化的指针。
怎么让对象只在栈上分配
事实上这是完全做不到且无意义的。
对于下面的方法,只要通过有限定名字查找::指定使用全局的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 到堆上。除非禁用拷贝/移动构造,但这样工厂类也没法返回对象了。
而且也阻止不了静态或线程局部存储期的对象分配到栈外。
理论上有些价值,但由于做不到,应该由人自己遵守。
在栈上建立对象,是直接调用类的构造函数;而在堆上建立对象,是执行它的 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
枚举常量代表该枚举类型的变量可能取的值。枚举常量只能以标识符形式表示。
编译器为每个枚举常量指定一个整数值,默认从0开始,依次+1。
可以在定义枚举类型时为部分或全部枚举常量指定整数值。在指定值之前的枚举常量仍按默认方式取值,而指定值之后的枚举常量按依次加1的原则取值。各枚举常量的值可以重复。
例:
enum ColorSet {
RED, BLUE, GREEN, // 0 1 2
WHITE=10, BLACK, // 10 11
GREY=0, YELLOW // 0 1
} color1, color2; // 类型名为ColorSet,同时定义两个变量
枚举类型名 ColorSet 可省略,直接定义变量。
注意,定义的color1, color2未初始化,必须指定一个值,否则如果是全局变量则为0,局部则随机,不管它能取哪些值。
初始化变量时,需要赋ColorSet类型(如=RED, =BLUE),不能直接赋int。
指针与引用
- 当作为参数传递时,指针和引用传递的都是地址,不过指针实际上仍是值传递。
注意非引用传递都是拷贝值!会创建一个新的临时变量,对这个变量取地址修改,也不会影响函数外的变量。
这包括指针,所以更改指针也要对它&(得到**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类型的表达式初始化:
- 定义:
T1 与 T2 引用相关 (reference-related):iff T1 和 T2 是同一类型,或 T1 是 T2 的基类(如果 T1 是 T2 的不可达基类或有歧义,则程序非良构)。
T1 与 T2 引用兼容 (reference-compatible):iff- T1 与 T2 引用相关,或 T1 是一个函数,T2 是一个相同类型的 noexcept 函数;
- 且 cv1 的修饰符不少于 cv2(cv2 是 cv1 的子集)。
引用初始化规则:
-
如果初始化表达式是初始化列表,则遵循列表初始化。
-
如果引用是左值引用:
- 且初始化表达式是非位域左值,且
cv1 T1与cv2 T2是引用兼容的,则引用绑定到该左值标识的对象上(或它的基类子对象)。
比如:struct B: A {} b; A &ra = b;可以绑定到 b 中的 A 类子对象)。 - 且 T2 是类,且 T1 与 T2 并非引用相关,但初始化表达式可以转换为
cv3 T3类型的左值(通过到相等或更少的 cv 限定的左值的转换函数),其中cv1 T1和cv3 T3是引用兼容的,则引用绑定到转换函数所返回的cv3 T3类型的左值上(或它的基类子对象)。
比如:如果 T2 实现了operator int&(),那么int& ir = T2()是 ok 的,会绑定到 T2::operator int& 的结果。
- 且初始化表达式是非位域左值,且
-
否则,引用必须要么是一个指向非 volatile 的 const 类型的左值引用,要么是一个右值引用。
- 如果初始化表达式是非位域右值或函数调用表达式左值,且两者引用兼容,则绑定到表达式表示的对象上(或它的基类子对象)。
- 如果 T2 是类,且 T1 与 T2 并非引用相关,但初始化表达式可以转换为
cv3 T3类型的右值或函数左值,其中cv1 T1和cv3 T3是引用兼容的,则引用绑定到转换函数所返回的cv3 T3类型的左值上(或它的基类子对象)。 - 以上两种情况中,如果初始化表达式是纯右值,则 C++17 中会先进行临时量实质化将其转换为亡值(见 C++ - 临时量实质化)。
- 否则,将初始化表达式隐式转换为 T,将引用绑定到转换后的结构上。
如果 T2 是 T1 或从 T1 派生(相关),那么必须拥有等于或少于 T1 的 cv 限定;如果引用是右值引用,那么初始化表达式不能是左值。
简单来说,const 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 返回的是对该临时量的引用,在该语句结束后就会销毁。所以这应该是 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++ - 临时量实质化)
所以,如果数组出现在不期待数组而期待指针的环境中,就会使用该隐式转换。称为数组到指针的退化。
(比如: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 及赋值运算符忽略柔性数组成员。拥有柔性数组的结构体,不能作为数组元素或其他结构体的成员。
注意包含柔性数组的类,赋值时不会考虑柔性数组!应尽量避免赋值(如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 是独占所有权。
裸指针的使用是不安全的,需要程序员保证;不会影响资源的生命周期。
智能指针 - unique_ptr
std::unique_ptr<T>是一个独占资源所有权的指针。
当离开 unique_ptr 的作用域时,即 unique_ptr 释放时,指向的资源会自动释放。
unique_ptr 的创建方式只有三种:
- 调用构造函数,参数必须是一个通过 new 创建的指针(指针会自行析构对象,所以栈内创建的对象将会被析构两次)。
- 赋值为 std::make_unique 并创建一个新对象。
- 通过 std::move 移交另一个 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)。 - 使用 lambda 函数,既要在类模板参数中写明该函数类型,也要在第二个参数中写明。
unique_ptr 只包含一个指针,所以为 8B(以下均为64位)。如果自定义了 deleter,需要保存 deleter 指针对象,所以大小为 16B(可能更大,见下链接)。
但如果方法 1 中的类是空类(只有函数),则 unique_ptr 可以继承该类,而不是声明一个该类的对象,从而能使用空基类优化,避免 deleter 的额外大小。
具体见:https://zhuanlan.zhihu.com/p/367412477。
智能指针 - 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 的操作不是线程安全的,如果要多线程使用,要每个线程都有自己的 shared_ptr。
多线程的环境下,函数要使用拷贝而非 const 引用传递 shared_ptr,否则是不安全的。
但是对引用计数的操作是安全的(atomic 更新)。- 如果资源本身比较小,则 shared_ptr 需要的资源信息占比就会很大。
比指针多占用一些内存,如果内存敏感也不适合。- 有些代码会在类中写
detete this,会导致所有 shared_ptr 的资源失效。此外,如果 shared_ptr 指向一个大对象,在最后一个 shared_ptr 不再指向它时,会导致大对象的析构(如数组、容器)。
这可能导致一句指针赋值,就花费很长时间。
如果是在业务中,可以需要避免这种情况,比如外部再令一个 shared_ptr 指向它,当计数器为 1 时,由后台线程析构。智能指针不应指向 static 对象,因为 static 对象生命周期与程序相同,在程序内 delete 它会出问题。
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)...));
}
智能指针 - weak_ptr
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 数组保存)。
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 实现资源转移,并禁用了拷贝,不容易出错。
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)。
- 左值 (lvalue):左值可以在赋值表达式的左边出现,是表达式结束后依然存在的持久对象,在内存中占有确定的位置。
如:x,*p,p->n,x.n(x 需要是左值),返回类型是左值引用的函数调用。 - 纯右值 (pure rvalue, prvalue):字面量、所有内建的算术/逻辑/比较/取地址表达式、返回类型是非引用的函数调用或重载运算符表达式等都是纯右值。
纯右值不具有多态:它所标识的对象的动态类型始终是该表达式的类型。不能具有不完整类型(但 void 和 throw 表达式可以)。
如:&a,str.substr(1, 2),a+b,Node{}。 - 亡值 (expiring value, xvalue):右值对象的成员表达式或成员指针表达式,返回类型为右值引用的函数调用表达式,转换到右值引用类型的类型转换表达式。
与纯右值非常像,因为往往用于移动、很快就会消失;但可以是多态的,可以具有不完整类型,也算具名,所以也属于泛左值。
如:(a+b).n,Node{}.n,arr[n](arr 或 n 至少有一个是右值?),std::move(x),static_cast<int&&>(x)。 - 泛左值 (generalized lvalue, glvalue):左值和将亡值。
可以转化为纯右值,可以是多态的(可以有和静态类型不同的动态类型),可以具有不完整类型。 - 右值 (right value, rvalue):纯右值和将亡值。表达式结束后不再存在的临时对象,不在内存中占有确定位置。
右值可以用来初始化 const 左值引用和右值引用,但该右值所标识的对象的生命周期,将被延长到该引用的作用域结尾。- 右值不能取地址。
- 右值不能用作内建赋值运算符及内建复合赋值运算符的左操作数。
- 右值可以用来初始化 const 左值引用和右值引用,并且其生命周期会被延长。
注意,类的赋值会调用 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)
也包括 move elision(C++11 起)。
初始化时,如果初始化表达式和被初始化的对象类型相同,且表达式为临时量(C++17 前,见 C++ - 临时量实质化)/纯右值(C++17 起),则可以省略复制和移动构造函数,直接将表达式产生的对象构造到要初始化的对象位置上。
如:Node a = Node{1, 2};在优化前需要一次有参构造、一次复制/移动构造、一次析构,但优化后只需要一次有参构造。
只有初始化表达式是左值时才需要拷贝构造。
通过-fno-elide-constructors关闭。
返回值优化 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 的拷贝/移动构造) - 对象
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 则更不确定。
std::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&&,即通用引用,既可以匹配左值引用、也可匹配右值引用,具体见下。
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/右值引用
左值是表达式结束后依然存在的持久对象(对象在内存中占有确定的位置)
右值是表达式结束时不再存在的临时对象(不在内存中占有确定位置的表达式)很多时候,我们会通过 表达式产生或不再使用的临时对象 去初始化一个对象。如果没有右值引用,就只能通过拷贝临时对象去产生一个新对象,然后临时对象就会析构,多了一次无意义的拷贝和析构。右值引用可以避免这一点。(尤其是某些对象内可能有容器,包含很多数据,拷贝时需要进行深拷贝;而移动则很高效)
区分左值右值,允许程序员更加精细的处理对象拷贝时的内存开销,提高了对临时对象和不需要的对象的利用率,这是 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<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&&。
引用折叠
调用函数时,会发生实参和形参的引用类型不同的情况,两个引用之间会发生引用折叠,结果只保留一个引用:只有两个引用都是右值引用时,结果才是右值引用。如:
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&&
}
std::forward 原理 / 完美转发
万能引用函数的实参,可以是左值引用也可以是右值引用。但在函数内,x 作为参数始终是左值。
如果在函数中再使用参数x进行函数调用,并且要保持它的引用类型,需要用std::forward<T>(x)。一般只有万能引用的函数需要完美转发,因为什么都不加就是左值,用上 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); } // 为什么是!is_same? };forward 是借助 类型 T 和引用折叠 返回正确的引用类型的:当参数为左值引用时,T 为左值引用,与 forward 中的 && 会折叠成左值引用;当参数为右值引用时,T 为一个值,会被 forward 转换成 &&。
当给函数传递右值引用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 进行类型转换的:
- 当 fn 的实参为左值引用时,_Tp 被推导为
int&(以 int 为例,反正是一个具体类型),cast 转换的类型,即返回的类型会发生引用折叠:int& &&即int&。 - 当 fn 的实参为右值引用时,_Tp 被推导为
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)
const
define 只是简单的替换,没有类型信息。define 的定义也不能提供任何封装性,可以被全局访问。此外复杂的内容还容易出错。
所以应尽可能少的使用 define,可以用 const、inline 函数、enum 去代替。const 是具体的对象。const 的变量放在只读区域,有时甚至可以优化为立即数(但没有地址)。
const 可以修饰变量,限定它为常量、不允许改变。
尽可能使用 const 可以减少编程错误;编译器对常量的运算还会尽可能优化,所以效率也高。
- 类中的 const 常量,只能在初始化列表中进行赋值。
- const 可以修饰指针,但具体修饰的对象要从右往左确定。
- C++ 禁止将 const 对象的地址赋值给非 const 指针。如果一定要,通过 const_cast 转换。
- const 引用不会对引用对象进行修改。所以函数参数可以使用 const 引用传递来减少拷贝。
- 如果函数返回值的类型为 const,如果返回普通数值则没有什么影响,但如果返回指针,则只能赋给相同类型的 const 指针。
const 修饰函数
const 可以修饰成员函数(放在后面)。const 函数无法修改成员变量(其它见 面向对象 - 成员函数的引用限定)。
- const 对象只能访问类的 const 方法,而非 const 对象任意。const 引用也是。
- const 方法可以访问所有成员变量,但也只能调用它们的 const 方法,来保证不会发生修改。
- const 方法不能修改任何成员变量,除非变量用 mutable 修饰。
- 如果 const 和非 const 的两个函数构成重载(函数名、参数、返回值相同),则非 const 对象默认调用非 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 专门用于常量声明。
通过 constexpr 修饰的函数,其返回值也是常量?(修饰函数时,不要求该函数的所有调用语句都是常量表达式,f(constInt)、f(variable)都是可以的,但只有实参为常量/编译期已知的才会在编译期被计算?)
在 C++11 中,对 constexpr 返回值的函数要求非常严格,函数体内只能包含如下内容:空语句;static_assert;typedef;using;一个返回值语句(必须。不过返回值可以是逗号表达式,允许执行其它语句)。
C++14 后允许其它语句出现在函数体内。
volatile
volatile 与原子性(atomic)、内存序(memory_order)、建立线程同步(锁等)无关(应该用括号中的东西)。它不应该被应用于多线程编程。
但 MSVC 对标准 C++ 语法做出了扩展,给 volatile 增加了线程间同步的含义,但考虑到可移植性,没有理由新标准库提供了其它解决方案的前提下再使用这种非标准扩展。
标准 C++ 的 volatile 几乎只在一种情况下有用:驱动开发者需要操作映射到外设 I/O 的内存。对于绝大部分程序员而言,用不到、也不应该使用 volatile。
cast
https://blog.csdn.net/weixin_39640298/article/details/85418743
static_cast:用于良性转换 (no run-time check),一般不会导致意外发生,风险很低。
接近旧式 C 转换(其实很不同),如普通类型转换,最常用。
dynamic_cast:借助 RTTI(运行时检查),用于类型安全的向下转型 (downcasting)。
具体来说,可以将基类的指针或引用,安全地转换为派生类的指针或引用。
因为基类对象的起始位置,不一定就是派生类对象的起始位置,直接类型转换会出错。dynamic 会找出某对象的内存起始位置,并在失败时。
const_cast:用于 const 与非 const、volatile 与非 volatile 之间的转换。
常用于去除某个引用或指针对象中的 const,以便可以调用非 const 的重载函数(但不是为了修改它。写底层是 const 的对象是 UB)。
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_cast:能够去掉或增加 const。
对于一个const int *指针 cp,既可以通过const_cast<int*>(cp)获取 int*,也可直接通过(int*)cp去掉 const。 - static_cast:与其类似。
但是,C 转换不会在意一些访问限定符,比如允许从子类转换一个私有基类(但是实测不可)。 - static_cast 然后 const_cast。
- reinterpret_cast:与其相同。因此 C 转换也非常危险。
- reinterpret_cast 然后 const_cast。
例子见上面 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 相同。
由于没有其它参数,所以这种写法可以要求 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
public_cast
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 函数
C程序的起始和结束都不是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在没有返回值时,自动补充return 0;。
头文件
头文件可以分为 header-only file 和 index file(不确定)。
header-only file 的所有声明和定义都包含在一个文件内,#include后会包含这个源码进行编译。
index file 类似索引文件,#include后可以去链接对应的静态、动态库。
header-only file/library 是因为编译器不支持模块分离(C++20才支持),不得不将模板的实现写在头文件中。
Boost 提出用hpp做为 header-only library 的文件后缀。
优点:
- 使用方便,只需要指明路径、包含头文件。
不需要考虑平台的特殊性,不需要为不同环境做多个版本的库文件,因为是用户自己编译。 - 给出所有代码的情况下,编译器在编译时可能做出更好的优化(而不是只能在链接时优化)。
缺点:
- 编译慢,每次编译都要编译整个头文件的所有实现。对大项目影响更大。
划分编译单元,将代码拆分,可以缓解该问题。 - 如果修改了库的实现,所有使用该库的编译单元都要重新编译。
所以 header-only library 应尽量为相对稳定的功能库,而不是接口库。适合变动需求不大,对灵活性要求高。
重复定义问题如何解决?
类定义写在头文件,多文件包含那么 ifdef 也解决不了重复定义问题,inline 关键字才能解决。这个关键字有两个作用,一是规避ODR(One Definition Rule)规则,链接器对于ODR Linkage的符号,不会报重复定义错误,视为等价保留一份。二是字面意思提示编译器进行内联优化。
index file 指的就是声明和实现分离,使用时只需要#include声明,编译后链接相应的编译好的库(lib, .dll, .so)。
函数内联
内联:将被调用函数的函数体的副本,替换到调用位置处;修改代码以体现参数。
优点:消除函数调用开销:参数传递、寄存器保存;将过程间分析转化为过程内分析,便于优化。
缺点:函数体会变大,对指令缓存 (icache) 不友好;生成的二进制文件会变大(代码段变大),占用内存更多。
多数情况下是正向优化。
在函数声明时加 inline,是建议编译器这个函数可以内联,具体如何做由编译器自己决定。
所以会出现以下情况:inline 声明的函数仍被编译成函数调用 (汇编里用 call);没有 inline 声明的函数,却被内联。
一般不需要程序员做这件事,因为编译器自己可以识别,并且能做的很好。
inline
实际上 inline 的含义已经从“优先内联”变成了“允许函数和变量重复定义 / 规避 ODR”。不同编译单元可以有多个相同的 inline 函数实现,最后会只保留一个。因此可以在头文件中直接给出 inline 函数的完整定义,而不必像普通函数一样只写声明、将定义放在.cpp中。这对于 header-only 库开发很有用。
inline 声明的函数或变量在每个翻译单元中都拥有相同的地址,但因为符号只有一个,所以可能是不同的实现。
inline 函数必须要在 .h 中给出定义,这会增加编译时间,在修改函数时导致更多文件重新编译。
constexpr 声明的对象包含 inline。
static 声明与匿名 namespace 等价,也可用来允许重复定义,但与 inline 的原理和目的不同:static 声明的对象会有不同的地址、包括多个符号。
static 限制只在该翻译单元可见,但不能在头文件中添加,因为头文件会在每个翻译单元内都有一份。C 的 inline 含义与 C++ 不同,代表的就是强制内联
__atrribute__((always_inline))。
虚函数可以是内联函数吗
可以定义成 inline,但是否 inline 还要看编译器。
内联是在编译期发生的,如果虚函数的执行涉及多态,也就是通过基类对象的指针或引用调用虚函数,那么实际调用的函数只有在运行期才能判断,不能内联。
但如果不涉及多态(Base b; b->F();),或确定一个类没有派生类或它的派生类不会重写该虚函数(在类或函数后声明final),或是显式指明要调用基类的虚函数(d->Base::F()),或是能调用的目标虚函数只有一个,那么可以内联,从而优化效率。取决于编译器。
由于虚函数需要一次指针访问(间接寻址),也会影响分支预测导致缓存失效(但通常影响不大),因此编译器也有 speculative devirtualization 优化:类似分支预测,调用虚函数前检查它是否是某个大概率调用的虚函数,如果是就继续执行(通过将该预测的虚函数的实现 inline 直接放到后面,可以实现虚函数 inline),不是就像以前一样访问虚函数表指向的函数。
比如:
// tmp = this->bar(); 去虚化实现
bar_ptr = this->_vptr[BAR_VTABLE_INDEX]; // load function entry point from vtable
if (bar_ptr == Base::bar) {
// inlined Base::bar()
// 函数实现...
tmp = value;
} else {
tmp = bar_ptr(); // normal indirect virtual call
}
但是预测失败的代价也很高,所以编译器对该策略的使用非常保守。
__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 的命中率。
std::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。
如何用一个类型表示不同类型
比如允许一个类型同时可表示 bool, int, double。
如果不同类型间可能同时存在,那么用结构体或 tuple 可以同时包含上面的几种类型(称为 product type)。
如果同一时刻只会使用一种类型(称为 sum type),上面的方案会浪费空间,可以:
- std::any:any 会 new 一个对应类型的指针保存,然后记录对象的一些类型信息,以便正常调用对象的函数。
- union:C 中的方案,多个类型共用一块内存,大小取决于里面最大的类型。与 any 不同的是它可以把数据存在栈里(只要是栈对象),减少 new 的开销。
但是 C++ 中 union 很难用,如果联合体的某个成员是拥有用户定义的构造函数和析构函数的类,那么切换其活跃成员通常需要显式析构函数和 placement new。很容易 UB。 - std::variant:类似 union,但 variant 可以在切换活跃成员时,自动管理对象的生命周期(主要是调用构造函数和析构函数?),而 union 需要自己管理。
但使用起来也不太方便,具体见:https://zhuanlan.zhihu.com/p/645810896
union
C++ 中的 union 与 C 不同。
比如:
- 只允许读取最后写入的那个联合成员,直接访问其它的联合成员是 UB。
原因就是 严格别名规则:不允许不同类型的指针指向同一片内存。
字符串字面量
程序中字符串字面量的生命周期伴随整个程序,不需要也不能去释放它。比如const char *s = "abc"; delete[] s;是错的。const char s[] = "abc";是栈上对象,也不应该 delete。
只有程序 new 出来的指针才能 delete。
所以如果要将字符串字面量(const char *)传给一个类,类获得指针后要 new 一个空间拷贝过来,不直接用这个指针;如果直接用那不能在析构时 delete。类内保存一个 string 而非 char * 指针更安全,不用考虑析构问题。
std::bind 原理
TODO
C 中的 tag
C 将 tag (enum, struct, union) 视为二等公民,也就是不那么重要,甚至同名定义与标识符不会冲突。但使用前必须加详细类型说明符,如:使用 Node 结构体类型前要加 structstruct Node node;。
使用 using 定义别名 (alias)
using 和 typedef 都是对原有类型起别名,不会创建新的类型。
但 using 不仅有 typedef 的各功能,还有其它优势(具体见这里和EMCpp):
- 别名声明可以被模板化,称为别名模板 (alias templates),不像 typedef 需要被嵌套进一个模板类。
C++14 的各种 ..._t 就是用 using 定义,这在 C++11 就能实现。
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;
- 不需要使用 typename 来标识 dependent type。
通过 typedef 定义的、包含模板参数的类型名属于依赖类型?因为它依赖于 T,在使用该类型前需要加 typename。但用 using 定义的类型不需要。 - 由于后面立即跟随新标识符(类似赋值),所以有时会更清晰易读。
typedef void (*func_t)(int, int);
using func_t = void (*)(int, int); // 同样是函数指针类型
基本类型 (fundamental types)
- integral types (整型):包括 bool、各类 char、各类 int。
- floating-point types (浮点型):包括 float、double、long double。
- arithmetic types (算术类型):包括整型与浮点型。
- scalar types (标量类型/数值类型):包括算术类型、枚举类型、指针、成员指针、nullptr_t,和它们的 cv 限定版本。
- POD types:包括标量类型、POD 类,和它们的数组类型、cv 限定版本。
POD
https://blog.csdn.net/weixin_42482896/article/details/118271161
POD (plain old data) 中 plain 指它是一个普通/平凡的类型,old 指与 C 兼容。
POD 是一个与 C 兼容的类型,它没有虚函数、虚继承等 C++ 的新特性,还可以使用 memset 或 memcpy 进行初始化或拷贝。
所有标量类型 (非 数组/类/结构体/联合) 和 满足 平凡的、标准布局的 两个特性(或之一?)的类/结构体 是 POD 类型,它们的构成的数组也是 POD 类型。
满足 平凡的、标准布局的 类或结构体,实际上就对应 C 中的 struct(C++ 的 struct 也是为了兼容 C,但在 C++ 中有些改变,变成了与 class 基本一样?)。
内置类型是 POD 的。
只有 POD 类型才可以作为 union 的成员。union 很大部分也是为了兼容 C?
C++11 定义了一个辅助类模板:template <typename T> struct std::is_pod;,可以用它判断一个类型是否为 POD:bool isPOD = is_pod<ClassName>::value;。
还提供了一个must_be_pod让编译器确保一个类型一定是 POD 的。
POD 的优点:
- 可以安全地使用 memset 和 memcpy 对 POD 类型进行初始化和拷贝。非 POD 不保证可以。
通过 memcpy 将对象的数据拷贝到一个 char 数组,然后再拷贝回来,还是原来的对象。 - 数据与 C 的内存布局相同,所以用 POD 数据在 C 与 C++ 间进行交互是安全的。
- 可以进行静态初始化。静态初始化一般比较高效且简单(比如放入目标文件的.bss段,在初始化中直接被赋0)。
POD 的特点:
- 通过 goto 语句从某个变量还不存在的作用域内,跳到它已经存在的作用域内,是非法的(编译器会报错),但对于 POD 类型没有该限制。
- C++ 标准保证 POD 类型的开头不会填充任何内容(虚指针,空基类)。也就是,如果一个 POD 类型 A 的第一个成员是 T 类型的,可以安全地从 A* 到 T* 进行 reinterpret_cast,获得一个 T 的指针,反之亦然。
满足下面所有条件的 类/结构体 为平凡的:
- 拥有平凡的默认构造函数 (trivial constructor) 和析构函数 (trivial destructor)。
平凡的构造函数指 编译器给出的默认的(什么都不干的)构造函数,一旦我们定义了任意构造函数,即使这个函数也是空的、什么都不干,因为它不是默认的,所以也不是平凡的构造函数。通过 = default 可以显式定义默认的构造函数。
析构函数类似。
所以对于无参构造和析构,平凡意味着除了调用成员的默认构造函数外,什么都不用干。 - 拥有平凡的拷贝构造函数 (trivial copy constructor) 和移动构造函数 (trivial move constructor)。
同上,平凡的拷贝/移动构造函数是编译器默认给出的实现。
平凡的拷贝构造函数可以直接用 memcpy 或 memmove 一次完成拷贝,不需要对成员变量依次赋值。
所以对于拷贝/移动构造函数,平凡意味着可以通过简单的内存拷贝/移动完成构造。 - 拥有平凡的拷贝赋值运算符 (trivial assignment operator) 和移动赋值运算符 (trivial move operator)。
同拷贝构造函数和移动构造函数。 - 不能包含虚函数和虚基类。
C++11 定义了一个辅助类模板:template <typename T> struct std::is_trivial;,可以用它判断一个类型是否为平凡的:bool isT = is_trivial<ClassName>::value;。
满足下面所有条件的 类/结构体 为标准布局:
-
所有非静态成员有相同的访问权限 (只有 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 的原因。
静态数据、成员函数是不会影响内存布局的。
C++11 定义了一个辅助类模板:template <typename T> struct std::is_standard_layout;,可以用它判断一个类型是否为标准布局:bool isSL = is_standard_layout<ClassName>::value;。
存储类说明符
存储类说明符是标识符声明中的一部分,除 thread_local 可和 static/extern 一起外,只能出现一个。
与作用域一同决定标识符的存储期与链接。包括:
- 无说明符:自动存储期。如局部非静态变量。
register 也是自动存储期,提示编译器将此对象放在寄存器,但已弃用。 - static:静态或线程存储期;内部链接。
- extern:静态或线程存储期;外部链接。
- thread_local:线程存储期。
- mutable:不影响存储期或链接,只是控制类成员的访问方式。
存储期
所有对象都具有4种存储期之一:
- 自动(automatic)存储期:生命周期为所在代码块。包括非 thread_local、static、extern 的所有局部对象。
- 静态(static)存储期:生命周期与整个程序一致,只存在一个实例。包括所有在命名空间(包含全局命名空间)作用域声明的对象(即全局对象),和带有 static/extern 的对象。
如果未初始化,则被零初始化。 - 线程(thread)存储期:生命周期与线程一致,每个线程有一个该实例。包括声明了 thread_local 的对象。
- 动态(dynamic)存储期:生命周期由动态内存分配函数来控制。
聚合初始化 (aggregate initialization)
聚合体 (aggregate) 包含两种类型:数组,符合下面条件的类:
- 没有自定义的构造函数。
- 没有 private 或 protected 的非静态数据成员。
- 没有基类,没有虚函数。
聚合体可以用聚合初始化(列表初始化的一种),即使用{...}依次初始化类中的各成员(按声明顺序)。如果成员也是聚合体,则用嵌套{...}初始化,如:Node x = {1, {1, 2}};(如果是用的等号赋值可以忽略内部大括号)。
初始化
C++ 有多种初始化方式:
// default initialization
// 默认初始化:不使用初始化器构造变量时执行的初始化。
// 如果是类,则检查是否有无参构造;如果是数组,对每个元素进行默认初始化;否则不进行初始化。
// 静态和线程局部变量会进行零初始化,其它变量则为不确定值。const变量则要求必须能进行默认初始化。
new T;
Node a;
// value initialization
// 值初始化:如果是类,如果有平凡的默认构造函数,则零初始化,否则默认初始化。
// 如果是数组,对每个元素进行值初始化;否则零初始化。
new T(); // 空括号即为值初始化
new T{};
Node a{}, b();
// direct initialization
// 直接初始化(构造函数初始化)
int y{0};
Node a(1, 2);
// copy initialization
// 复制初始化,C风格,不提供错误检查和类型安全性。其它见下。
int x = 0;
int h = {0};
return {1};
// list initialization,包含 aggregate initialization
// 列表初始化,包含聚合初始化
int arr[] {1, 2}; // 直接列表初始化
int arr[] = {1, 2}; // 复制列表初始化
vector<int> v {1, 2};
vector<int> v = {1, 2};
零初始化 (zero-initialization) 的规则为:
- 如果 T 是标量类型,则将 0 转换为 T 进行赋值。
标量类型见 C++ - 基本类型,简单来说就是整数、浮点、枚举、指针。 - 如果 T 是非联合的类,则类的每个非静态数据成员、每个非虚基类子对象被零初始化,且对齐边界为 0。(如果对象不是基类子对象,那么还包括每个虚基类子对象。如果 B 继承 A,那么 A 是 B 的一个基类子对象)
- 如果 T 是联合类型,则其第一个非静态具名数据成员被零初始化,且对齐边界为 0。
- 如果 T 是数组,对其每个元素进行零初始化。
- 如果 T 是引用,不做任何初始化。
注意,在构造函数函数体内写成员的赋值,并不算第一步初始化;如果成员没有使用初始化列表或指定默认值,则会在进入构造函数前调用默认构造完成初始化,构造函数内进行的赋值只是一次额外的赋值操作。
如下例中,x, y 均被默认初始化一次,但随后 y 被额外赋值一次。所以最好使用初始化列表。
struct Node {
Node(int a, int b): x(a) {
y = b;
}
int x, y;
};
复制初始化 (copy initialization)
复制初始化选择构造函数时,不会考虑 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
使用 {} 初始化 / 列表初始化
{...} 会被转换为一个 initializer_list
更推荐使用现代的大括号初始化,因为:
- 列表初始化可用的场景更多,能用做任何类型的初始化?所以也叫统一初始化 (uniform initialization)。
// 可以初始化容器
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
};
- 可避免隐式的窄化转换 (narrowing conversion),如 double 值传给 int,{} 不允许。
double x, y;
int sum1{ x + y }; // 编译错误
int sum2(x + y); // 编译通过,但是x + y精度会丢失
int sum3 = x + y; // 同上
- 可避免一个歧义的语法解析规则 (most vexing parse,见下)。
该解析规则是:当一条语句可以被编译器解析成函数声明 (function declaration) 时,会优先将该语句视为函数申明,即使该语句的意图不是函数申明。
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 推导类型时,会推导成 std::initializer_list。
auto x{1}的 x 类型是 int,但auto x = {1}的类型是 std::initializer_list。 - 如果不存在以 initializer_list 为形参的构造函数,大小括号就区别不大。
但只要存在,编译器就会优先选择该构造函数(且尽可能将实参隐式转换为 initializer_list 中的类型)。只有实在无法转换时才会选择其它重载(如 string 到 int)。
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++语法解析器无法区分 对象的创建 和 函数的声明,会统一按函数声明处理。
- 包含强制类型转换的构造函数初始化。
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 并不是类对象。
位域 (bit field)
位域允许声明具有以位为单位的明确大小的类数据成员,只能是整型或枚举。
位数可以定义很多,但不会超过原类型的值域,多余位为填充位。
如:类中定义unsigned int b: 3;为3位,值域0~7。
异常
异常安全是指程序在发生异常时仍能保持正确工作的状态。通常异常安全可以分为四个等级。
- 保证不抛出异常,此时自然不存在异常带来的错误。
- 强异常安全,指如果一个操作发生异常,则它不会产生任何副作用,系统保持这个操作前的一切状态。典型的,std::vector等容器提供强异常安全保证。
- 基本异常安全,指发生异常不会使对象处于不合法的状态(但原值可能改变)、也不会发生资源泄露。
- 无异常安全。例如,发生异常前动态申请了内存、异常处理后未释放,就产生了资源泄露;修改某个值,改了一半抛出了异常,就产生了值的错误等等。
程序中想要保证强异常安全是非常困难的,但如果使用异常,应尽可能做到基本异常安全。RAII有助于保证这一点。
noexcept
函数在不加任何声明时,可以抛出任何异常。如果加了 noexcept,则不能在运行时抛出任何异常,否则程序会直接终止。
没有加 noexcept 声明且文件内没有定义的函数,需要假定它会抛出异常,因此会生成析构栈对象的汇编供使用。声明为 noexcept 可以不必生成这部分代码。例子见这里。
C++ 中的异常可以是任意类型(如 throw new int(5)),没有 Exception 等基类限制。
早期有动态异常规范 (dynamic exception specification) 用来限制函数能够抛出异常的种类,现在已经废除。
因为 C++ 中的异常都是非检查型异常/运行时异常(Unchecked Exception/Runtime Exception),在函数签名中声明异常类型没有意义,catch 时对异常的类型匹配是通过RTTI动态解析的。
C++ 一般不建议使用异常,容易导致程序崩溃,直接用异常码就好。函数签名包含一个函数的信息,包括:函数名、参数类型、参数个数、所在类和命名空间等。编译器将源代码编译成目标文件时,用函数签名的信息对函数名进行改编,形成修饰名(符号修饰)。也是决定两个函数是否能重载的东西。
一般情况下,移动构造与移动赋值均应添加 noexcept 声明,因为 vector 等有强异常安全保证的 STL 容器,在发现移动构造函数不是 noexcept 的情况下,会优先考虑使用复制构造,所以不加 noexcept,很多情况下移动构造等于白写。
强异常安全指如果一个操作发生异常,它也不会产生任何副作用,系统保持这个操作前的一切状态。容器提供强异常安全保证。
extern "C"
https://zhuanlan.zhihu.com/p/123269132
https://github.com/huihut/interview/issues/114TODO
extern "C" 表示按 C 的规则翻译函数名,而不是 C++ 的规则(涉及重载)?(不确定,见链接)用于 C 与 C++ 的混合编程。
如:extern "C" void func(int a, int b),C 会将函数名编译为_func,C++ 会编译为_func_int_int。
使用 extern "C" 定义的函数,可以与使用 C 编写的函数进行链接,使得 C++ 可以调用 C 函数,或允许某个 C++ 函数被 C 代码调用。
后面可以加大括号,来声明一系列函数。注意 C 中没有 "C" 的用法,只在 cpp 里有。
结构化绑定 (structured binding)
https://zh.cppreference.com/w/cpp/language/structured_binding
C++17 及以后,可用 auto 同时声明多个不同类型的变量,并从一个复杂对象得到赋值。
对象会被解包成多个变量,其类型和顺序与对象中的成员对应。
- 可以绑定三种类型:数组、tuple-like 类型、类(会绑定到该类的非静态成员,它们必须都是 public 的,数量也要与标识符数量一致;静态成员不会被绑定)。
绑定数组时,如果不加引用,会产生额外的数组拷贝? - 在 auto 前加 const,则定义出来的变量类型会有 const;在 auto 后加 &,则会拿到引用。
- C++20 前,lambda 表达式不能捕获结构化绑定定义的变量。
结构化绑定的变量也不会被视作隐式可移动实体,return 时默认不会用 NRVO。它和普通变量有些区别?
例:
struct Node {
int x;
std::string y;
};
const auto &[num, s] = Node{1, "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。
alignas
alignas可以修饰类、非位域数据成员和变量,指定该类型的实例或该对象有额外的对齐要求。(修饰类时,影响的是类对象,而非类内成员)
一个类的实际对齐,是该类所有成员中对齐要求的最大值:max(max(各成员类型的基本对齐),各成员的 alignas 最大值)。可通过alignof查询。
内存对齐的原因见 基础 - 计组 - 内存对齐。
- 对齐值是一个 size_t 整数,且为 2 的幂。如果 alignas 后的值非 2 的幂,则程序非良构(可能 CE)。
- alignas(0) 合法,会被忽略(但还是可能 CE)。
- 如果某个 alignas 声明的对齐值,比没有该声明时的对齐值还小,则程序非良构。即 alignas 要求的值要大于等于类型原有的对齐。
注意,只有栈对象保证其起始地址位于对齐边界处,直接使用 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。
函数
lambda 表达式
lambda 表达式用于定义匿名函数。
相比常规函数,lambda 表达式可以直接看到上下文,更灵活和简洁,可读性也更强。
适用于函数比较简单,逻辑不需要复用的情况。
语法为:[captures](params) -> return_type { body };
或:[捕获列表] (函数参数) mutable 或 exception 声明 -> 返回值类型 {函数体}
捕获列表用来说明外部变量的使用方式,表示函数体中用到的、定义在外面的变量在函数体中是否允许被改变。说明符可以是 = 或 &,= 表示值传递,不允许改变;& 表示引用传递,允许改变。
包括 5 种格式:
- []:不捕获任何变量。
- [=]:按值传递的方法捕获父作用域的所有变量。
- [&]:按引用传递的方法捕获父作用域的所有变量。
- [=, &a, &b]:按值传递的方法捕获父作用域的所有变量,但按引用传递的方法捕获变量a, b([=, a] 将被视为 a 重复定义)。
- [&, a]:按引用传递的方法捕获父作用域的所有变量,但按值传递的方法捕获变量a。
除了捕获列表和函数体外,其它都可以忽略(但在有 mutable 的情况下,参数()不可忽略,即使为空)(返回值类型是自动推导)。
在块作用域中的 lambda 函数仅能捕捉父作用域中的局部变量。捕捉任何非此作用域或非局部变量都会编译失败。
块作用域以外的 lambda 函数的捕捉列表必须为空。
默认情况下,lambda 函数是一个 const 函数。mutable 可以去除它的 const 属性。
例:
auto plus = [] (int x, int y) -> int { return x + y; }
int sum = plus(1, 2);
lambda 对象之间不能相互赋值,即使看起来类型相同。因为每个 lambda 都会被转换成一个仿函数类型,仿函数类的名称为 lambda+ uuid。
每个 lambda 表达式都会生成一个唯一的、不具名的非联合类,叫做闭包类型 (closure type)。
如果内部有 static 变量,则每次调用该 lambda 都会使用同一个 static 变量。
std::function
仿函数?TODO
lambda就是生成一个重载了operator()的struct的对象
函数指针
表达式可分为两种:数值表达式、类型表达式。类型表达式可分为两种:数值类型表达式、函数类型表达式。
int(int)就是一个函数类型表达式,可用于声明函数,如:
int f(int x) // int(int) 类型的函数
{ return x; }
int g(int x) // int(int) 类型的函数
{ return x + 1; }
int h(int func(int), int x) // 形参是 int(int) 类型的函数
{ return func(x); }
int main() {
h(f, 1); // 1
h(g, 1); // 2
}
int *a(int)是一个函数签名,该函数接收一个 int 参数,返回 int*。
(函数签名:一个函数的信息,如函数名、输入输出、可能的异常)
int (*a)(int)中 a 是一个函数指针(即int(*)(int)类型的变量),指向一个接收一个 int、返回 int 的函数(即int(int)类型的函数)。
// 4种定义函数指针的方法。&f前的&可不写,会隐式转换为函数的指针。
int(*p)(int) = &f; // 1
typedef int(*)(int) Fn; // 2
using Fn = int(*)(int); // 3
Fn p = &f;
auto p = &f; // 4
为什么用函数指针变量,不直接用int(int)的函数类型变量?
因为在C++中,函数类型不是一等公民 ,大概就是不能直接复制一个函数,因此在传递参数的时候只能用指向这个函数的指针来代替它。
函数类型
函数的类型由以下几点决定,如果它们都相同则函数类型相同:
- 返回值类型是否相同。
- 参数类型是否相同:确定每个参数的类型,然后将“T 类型数组”和“T 类型函数”的参数类型替换成“T 类型指针”,然后将 top-level 的 cv 修饰符去除,得到参数类型列表。(可见 C++ - top-level const)
因此,int(*)(const int p, decltype(p)*)和int(*)(int, const int*)是相同的类型, void(int)和void(const int)相同,但is_same_v<void(int*), void(const int*)>是 false。
显式弃置
通过定义特殊的重载函数,并将其声明为= delete,可以避免不希望的隐式转换。
如:void f(int);可以通过f(1.0)调用,但加上void f(double) = delete;重载,f(1.0)将匹配该弃置的函数,避免隐式转换。
函数的默认参数
- 默认参数不需要是常量,它在每次函数调用时,都会进行一次求值。
int x{0};
int g() {
return x++;
}
void f(int v = g()) { // int v = x++ 效果一样
printf("%d\n", v);
}
f(); // 0
f(-1); // -1
f(); // 1
- 函数的其它参数 不能出现在默认参数的求值表达式中。
- 如果函数是成员函数,则类的非静态变量 不能出现在默认参数的求值表达式中。
因为在求值时,类对象 (*this) 还不存在。
struct A {
int a;
static int b;
void f(int v = a++) {} // error
void f(int v = b++) {} // ok
};
int A::b = 0;
函数签名 (function signature)
函数签名包含一个函数的信息,包括:函数名、参数类型、参数个数、所在类和命名空间等。
编译器将源代码编译成目标文件时,用函数签名的信息对函数名进行改编,形成修饰名(符号修饰)。
如果两个同名函数签名不同,则可重载。
(对 C++)签名中不包含返回值类型,所以只有返回类型不同的函数不能形成重载。
通过模板特化可以实现多个只有返回值不同的函数,但这不是重载。
如:template<class T> T func() {return T{};}、template<class T> auto func() {return T{};}。
模板
泛型
泛型编程指:以独立于某种特定类型的方式编写代码。使用时,需要提供具体的类型。
模板是泛型编程的基础,是创建类或者函数的蓝图或公式。通过给定蓝图或公式足够的信息,能将它真正的转变成具体的类或函数。这种转变通常发生在编译时。
模板的使用:
TODO
https://www.jianshu.com/p/5a543b56c78c
模板的定义
模板类:在 class 声明前添加template <typename/class...>告诉编译器这是一个模板类,并指定模板的参数。
定义模板类时,需要指定模板参数,其余与普通类相同,如:vector<int> v。
模板参数也可以是非类型参数,如:template<typename T, int size = 5>,非类型参数可看做类中的一个常量,也用于标识一个类。
模板函数:在函数声明前添加template <typename/class...>。
模板函数的使用与普通函数相同,因为编译器会从函数参数中解析模板参数。
模板参数可以给出默认值,与函数类似,需要从右往左给出。
不同模板参数的类(函数),就是不同的类(函数),它们有自己独立的 static 成员变量与函数(因为是从一个类模板生成的新的类)。
类模板的 static 成员变量与函数只有在使用时才会实例化。
模板的特化
模板类型会对所有类型生效,但有时我们想对某种类型进行特殊处理,就可以用特化。
特化可以分为特化和偏特化。
对于要特化的模板参数,不在template中给出。对于类,在类的声明后添加要特化的类型;对于函数,直接写明返回值和参数类型即可。
特化指将类模板中的所有参数进行具体化。
此时没有模板参数,但还是要用template <>告诉编译器这是模板的特化。
例:
template <class T>
class stack {};
template <> // 对于类,在声明后指定
class stack<bool> {};
template <class T>
T max(const T t1, const T t2) {
return t1 < t2 ? t2 : t1;
}
template <> // 对于函数,在返回值和参数中不用模板即可
const char* max(const char* t1,const char* t2) {
return (strcmp(t1,t2) < 0) ? t2 : t1;
}
偏特化指将类模板中的部分参数进行具体化。
没有具体化的模板参数,既要写在template <...>中,也要写在类声明后;具体化的参数同上,写在类声明后。
函数只有特化,没有偏特化,好像有点复杂,TODO 可参考这里。
例:
template <class T, class Allocator>
class vector {};
template <class Allocator>
class vector<bool, Allocator> {};
为什么使用特化/偏特化,不用虚函数?
一点是特化的语义更明确,这是一个新的类,数据定义方式可以与原模板类不同;另一点是虚函数是运行时多态,在运行期间要访问虚函数表、找到要调用的函数,而特化是在编译时就能确定调用的函数地址,效率更高。
函数特化的匹配规则
最特化的优先匹配,即类型最精确的优先使用。
对于函数,可能还存在重载的非模板函数。选择要调用哪个函数的规则为:确定参数类型,先找出能够完全匹配的函数(模板可以匹配任意类型),如果有多个,优先选择非模板函数,其次是最特化的模板函数,最后是无特化的。
例:
template <class T> void f(T);
template <> void f(int);
void f(int);
f(1.0); // 参数为double,只有f(T) 完全匹配,因此选择f(T)(在编译时会生成f(double) 函数)
f(1); // 参数为int,都可匹配,所以选择非模板重载
f<>(1); // <> 是空的模板参数列表,所以只会选择模板函数;而特化f(int) 比更f(T) 更合适
显式实例化
在不同的编译单元中,一个模板的同一种特化可能被实例化多次。
一般问题不大,想避免的话要另写一个文件专门显式实例化:.h 里写模版声明,然后 .cpp 里给出定义和显示实例化?
如:一些文件都要实例化f<int>,就可以在头文件里写class Node<int>避免隐式实例化。另一个文件使用时先extern class Node<int>。
// def.cpp
template <typename T>
void f() { puts("f"); }
template void f<int>();
// main.cpp
template <typename T>
void f();
extern template void f<int>();
int main() {
f<int>();
}
decltype
decltype(表达式)的返回值为:
- 如果实参是没有括号的标识符表达式或类成员访问表达式,那结果为以表达式命名的实体的类型。如果该表达式命名的实体不存在,或指向一组重载函数,则程序非良构。
- 设表达式的类型为 T,
- 如果表达式值类别(见 C++ - 表达式的值类别)是亡值,则为 T&&。
- 如果表达式值类别是左值,则为 T&。
- 如果表达式值类别是纯右值,则为 T。
所以,decltype((e))(两个括号)与decltype(e)的结果可能不同:
- 如果 e 是标识符表达式(左值),则无括号时为 T,右括号时为 T&。
- 如果 e 是右值对象的成员表达式(亡值,如
Node{}.x),则无括号时为 T,有括号时为 T&&。
typename
typename 用来定义模板,或告诉编译器 后面的标识符是一个类型,而不是变量或函数,用来消除歧义。
在模板定义中的使用为:template<typename T>,这与template<class T>是一样的,保留后者是为了兼容。
最初只有 class,引入 typename 是因为 class 也是类定义,可能导致混淆,尤其是声明一个标识符为类型时。
当只使用模板定义时,会产生如下的歧义:
template<typename/class T>
void f(T x) {
T::iterator it;
}
只有知道了 T 的具体类型后,编译器才能知道 T::iterator 是不是一个类型,才能允许这样一个变量声明。
为了减少代码的歧义,需要在 T::iterator 前加上 typename 来声明这是一个类型:typename T::iterator it;。
可变模板参数
带省略号的参数称为参数包,能接收任意个参数(包括0个)。
在模板参数前加省略号,它就是一个可变模板参数,能够接收任意个、任意类型的参数。
通过sizeof...获取变长参数包中的参数数量。
template<class... T>
int func(T... t){
size_t nSize = sizeof...(t);
}
但是,不能直接获取参数包中的每个参数(如 args[i] 是不行的),只能通过递归或逗号表达式展开参数包的方式来获取。
递归展开参数包:
多加一个模板参数,每次处理一个参数,将剩下的递归处理。
template<class T, class ...Args>
void F(T value, Args... args)
{
cout << value << " ";
F(args...);
}
// 为了让其递归终止,还需要一个无参的函数
void F()
{
// end
}
// 为了让其递归终止,只含一个模板参数的函数也可,但 F 不能接收0个参数
template<class T>
void F()
{
// end
}
这样的缺点是,如果调用 F 时没有传入参数,会匹配无参的、结束递归的 F,而不是模板函数。如果想要不管什么情况都要匹配模板函数,需要再加一个外层函数。
使用sizeof...判断个数来结束递归是不可行的,原因没看懂。
逗号表达式展开:
下面的写法与递归类似,但更简洁。
template<class... T>
int func(T... t){
(f(t), ...);
}
如果参数类型都相同,可通过初始化列表来获取参数:
template<class ...Args>
void ShowList(Args... args)
{
int arr[] = { args... }; // 注意只能传入int,且至少1个
for (auto e : arr)
cout << e << " ";
}
普通的可变参数,如 printf,需要用 va_start 之类的宏实现?
va_list(C 中的可变参数实现)
va_list, va_start, va_arg, va_end是一组宏定义。
va_list用来定义一组可变参数。它其实就是char*或void*指针。
通过f(int n, va_list ap)定义变量后,需要用va_start(ap, n)去初始化 ap,让它指向 n 后面的第一个参数的位置(具体可见上面链接中的宏定义)。
va_arg(ap, type)会返回 ap 指针当前指向的 type 类型的值,并增加 sizeof(type) 以指向下一个参数。
va_end(ap)清空 va_list,其实就是ap = (va_list)0;。
由于这些 va 操作只是宏定义和访问指针,所以编译器其实并不知道参数的类型、不能做类型检查,需要程序自己决定类型,使用时也容易出错。
判断一个类中是否有某个成员
TODO:这个太麻烦改一个更简单的
struct __sfinae_types
{
typedef char __one;
typedef struct { char __arr[2]; } __two;
};
/**
* Actual implementation of _Has_result_type, which uses SFINAE to
* determine if the type _Tp has a publicly-accessible member type
* result_type.
*/
template<typename _Tp>
class _Has_result_type_helper : __sfinae_types
{
// 这就是一个可以接收任何参数的模板类
template<typename _Up>
struct _Wrap_type{ };
// 只要_Up有result_type,就可以生成这个函数声明,也就可以得到一个返回值为__one的函数
template<typename _Up>
static __one __test(_Wrap_type<typename _Up::result_type>*);
// 由于使用...的函数的优先级比上面一个更低,所以只要_Up有result_type被定义,就会生成返回__one
// 的test函数;如果没有,返回生成这个返回__two的函数声明
template<typename _Up>
static __two __test(...);
public:
// 1. 无论哪个__test函数都只需要声明,不需要定义。编译器知道其返回值就好了;
// 2. 通过测试返回值的大小来判断是返回的__one还是__two,进而进一步判断是否有result_type在
// 类型内被定义
static const bool value = sizeof(__test<_Tp>(0)) == 1;
};
template<typename _Tp>
struct _Has_result_type
: integral_constant<bool,_Has_result_type_helper<typename remove_cv<_Tp>::type>::value>
{ };
模板元编程
奇异递归模板模式 (CRTP, Curiously Recurring Template Pattern)
CRTP 指一个类A有一个基类,这个基类是类A本身的模板特化。可以实现编译期多态,减少虚函数调用?与虚函数的区别?(TODO)
例:
template <class Derived>
struct Base { void name() { (static_cast<Derived*>(this))->impl(); } };
struct D1 : public Base<D1> { void impl() { puts("D1"); } };
struct D2 : public Base<D2> { void impl() { puts("D2"); } };
template<typename T>
void f(Base<T>* b) {
b->name();
}
int main() {
// Base<D1> b1; b1.name(); // 未定义行为
// Base<D2> b2; b2.name(); // 未定义行为
D1 d1; d1.name();
D2 d2; d2.name();
f(&d1);
f(&d2);
}
类型擦除
https://www.cnblogs.com/qicosmos/p/3256022.html
将原有类型消除或者隐藏,换言之,在封装接口中,很多情况下我不关心具体类型是什么或者根本不需要这个类型,它可以使接口有更好的通用性、延展性,消除耦合,减少重复代码。
TODO
类型萃取
TODO
面向对象
概念:各种函数
成员函数就是某一类内部的函数,也叫方法,不能独立存在。
方法重载(overload):
重载函数的调用根据参数的个数、序列来确定(返回值任意),而虚函数依据对象确定。
如果是类中的成员函数,则只能重载自己类的。
方法重写(overwrite):
发生在父类和子类之间,子类会屏蔽父类的同名函数。
当函数参数不同时,virtual 有无任意;当函数参数与父类相同时,基类函数必须没有 virtual,否则就是覆盖。
方法覆盖(override):
针对虚函数,发生在父类和子类之间,参数要相同,且返回值类型、抛出异常类型要是父类同名函数的子集,该方法权限要大于等于父类同名函数的权限,该方法是否为 const 要与父类一致。
虚函数(virtual):
虚函数是c++实现多态的手段,具体指的就是如果派生类中的方法为虚时,基类指针可以根据其指向的具体对象的类型而绝对调用哪个函数。如果方法不为虚,则用指针调用方法和直接用对象调用是没有区别的,基类指针必然调用基类方法,而派生类指针必然调用派生类方法。
一个基类类型的指针,可以通过引用指向一个派生类,如class B: public A ; B b; A *a = &b;,A调用虚函数时,会调用B的;否则调用的就是A的。
静态方法不能是虚函数,因为虚函数需要通过一个实例的虚函数表访问,但静态方法属于类不属于实例本身。
友元函数(friend):
定义在类外部的普通函数或类,但它需要在类体内进行说明。
友元不是成员函数,但是它可以访问类中的私有成员。
在类之间,友元关系是单向的,且不能传递。
析构函数:
格式:~类名() {} 没有参数列表、返回值类型及返回值。
析构函数的调用时间
对于局部变量,在函数返回时析构。
对于静态变量和全局变量,谁先构造的,谁就后析构。全局变量的初始化在主函数运行前,静态变量的初始化在第一次遇到该静态变量的声明时。
对于new创建的变量,在程序结束后系统也不会回收,需要手动delete析构。
继承
子类构造时,也会调用父类的无参构造函数。
父类析构时,不会调用子类的析构函数。如:B 继承 A,A = new B(),~A()不会调用~B()。
- 子类中所有的构造函数都会默认访问父类中的空参数的构造函数,因为每一个子类构造内第一行都有默认的语句super();
- 若父类中没有空参数的构造函数,那么子类的构造函数内,必须通过super语句指定要访问的父类中的构造函数;
- 若子类构造函数中用this来指定调用子类自己的构造函数,那么被调用的构造函数也一样会访问父类中的构造函数。
如果 B 继承 A,某函数形参类型为 A,则传入 B 会隐式转换为 A 类型。
调用基类的构造函数
struct B {
B(): x{0} {}
B(int v): x(v) {}
int x;
};
struct D: B {
using B::B;
};
虚函数
虚函数用来实现多态,基类指针和引用可以指向一个子类对象,并调用该子类实现的虚函数,而不是基类实现的函数。
父类中定义的virtual函数,子类可以继承使用(不声明或不提供定义),也可进行覆盖 (override)。
使用基类指针指向一个子类对象,调用的非虚函数为指针类的函数,调用的虚函数为实际指向对象类的函数(如果该类覆盖了该虚函数)。
非虚函数取决于指针类型,虚函数取决于对象类型。所以使用的虚函数是在运行时确定的。
如果一个类有虚函数,就会有相应的虚函数表。继承的子类会有一个新的虚函数表,其中重写的虚函数指向新的地址,没重写的虚函数指向父类中函数的地址。
当一个类有虚函数时,该类的对象中就会多存一个指针 vptr,指向它的虚函数表 vtbl,虚函数表中保存了每个虚函数所在的地址。
比如有基类 A 和子类 B,A 定义了虚函数 f1, f2,B 覆盖了 f1,则 B 的虚函数表中,f1 会指向 B 实现的 f1,f2 会指向 A 实现的 f2。
所以对于有虚函数的类的对象,其起始位置会多一个指针,多占用 4 或 8 字节。
此外调用的虚函数需要在运行时 检查虚函数表确定,而不能在编译时就确定,效率要低一些。
所以如果没有必要 或不作为基类,不要声明虚函数。
用于多态的基类,其析构函数应定义为虚函数(并在子类中实现对应的析构函数)。
因为使用基类指针指向子类时,如果析构函数不是虚函数,就会调用基类的析构函数,导致子类的对象可能无法正确释放,产生内存泄露。
设 A 是 B 的基类,
A *x = new B(),则称 x 的静态类型 (static type) 为其直接指向的基类 A,动态类型 (dynamic type) 为其实际类型 B。
注意,如果虚函数的参数有默认值,则默认值取决于 x 的静态类型 A 中的定义,即使虚函数的实现确实取决于 B。
如:父类 A 有虚函数virtual void f(int a = 1),子类 B 覆盖虚函数virtual void f(int a = 2),则x->f()会调用B::f(),但参数的默认值来自A::f(),是 1。
因此,虚函数应该避免默认参数值(或者时刻记住默认值取决于静态类型)。
虚函数表存放的位置 / 虚函数的原理
每个有虚函数的类会有一个虚函数表。因为类决定了虚函数表,所以同一个类的多个对象可以共享虚函数表,只需要用 vptr 指向(没有指针,就不好找该类的虚函数表位置,除非在全局维护)。
虚函数表位于内存中的只读数据段 (.rodata),也就是常量所在的位置。条目指向代码段。
每个有虚函数的对象会保存一个虚表指针,指向该位置。
在派生类中,对于没有重写的虚函数,在虚函数表中的值与基类一致,指向基类的虚函数;重写的虚函数,在虚函数表中的值指向派生类的虚函数。
如果派生类定义了新的虚函数,也在虚函数表的后面加入它的地址。
对于基类的任意一个虚函数,它在虚函数表中的位置,和在派生类的虚函数表中的位置是相同的。所以运行时,只需要读取 vptr 指向的虚函数表中的对应位置,然后调用那个函数,就能实现动态绑定。
多重继承中,对每个(有虚函数的)基类,都会有一个虚指针。虚函数表的意义一样。
假设类 C 依次继承了 A,B,则 C 对象的内存布局依次为:A 的虚函数表指针,A 的数据成员,B 的虚函数表指针,B 的数据成员,C 的数据成员。
当创建一个 B 类型指针指向 C 时,指针直接指向的位置,正是 B 的虚指针所在的位置。
对于派生类定义的新虚函数,放在第一个虚指针指向的虚函数表后面。(第一个继承的类也称为主基类?)
我们说的虚函数表,其实只是虚表的一部分。虚指针是指向虚表的。
虚表依次包含:offset to top (到对象起始地址的偏移值),RTTI information (指向 RTTI 信息),virtual function pointers (虚函数表,保存虚函数指针)。如果有虚继承,则前面还要加上:virtual call offsets, virtual base offsets。offset to top 一般为0,只有在多继承时会不为0。比如:C 继承了 A,B,则在 D 的第二个的虚表中(对应B),offset 为 -sizeof(A)。
RTTI (Run-Time Type Identification) 是运行时类型识别,能够在运行时获得对象的类型信息。
使得基类的指针或引用,能够知道它指向的对象的实际派生类型,从而能实现 typeid 和 dynamic_cast 两个操作。
typeid(x).name()会返回 x 的类型 (string),但该类型信息非常简单。
一个类型的指针,也可以强制转换成另一种没有继承关系的类型,并调用新类型的虚函数。因为它们的 vptr 位置相同,运行时只是去指向 vptr 指向的表的某个位置指向的代码,只是可能不正确。
虚函数的覆盖与重写
PS:好像并不区分这两个概念。
覆盖发生在父类和子类之间,参数要相同,且返回值类型、抛出异常类型要是被重写父类函数的子集,该方法权限要大于等于被重写父类函数的权限,该方法是否为 const 要与父类一致。
重写也发生在父类和子类之间,但子类会屏蔽父类的同名函数。
当同名函数不满足覆盖条件时,就发生重写,会创建一个新的函数,而不是覆盖原函数。
例:
class Base {
public:
virtual void Show(int x); // 虚函数
};
class Derived : public Base {
public:
virtual void Show(double x); // 参数列表不一样,新的虚函数
virtual void Show(int x) const; // const 属性不一样,新的虚函数
};
有时我们想覆盖,但意外地进行了重写。为避免该错误,C++11 引入了两个关键字:
- override:声明覆盖函数。要求在派生类中声明的覆盖函数,与基类的虚函数有相同的签名(各属性相同)。否则编译器会报错。
子类重写的虚函数也是虚函数,写不写 virtual 都行,但一定要注明 override。 - final:阻止类的进一步派生 和 虚函数的进一步重写。子类对 final 函数进行重写会报错。
应该尽可能使用这两个关键字来减少错误。
子类不应该去覆盖父类的普通成员函数。如果需要,将父类的普通成员函数修改为虚函数。
纯虚函数
虚函数可以提供实现,也可以不实现。(但一般是要实现的?调用未实现的函数会报错:no matching function)
纯虚函数明确表明该函数没有实现,必须由继承该类的子类实现,是一个接口或规范。
纯虚函数代表,基类的对象不会使用该函数,且子类对象必须自己实现该函数。
析构函数可以是虚函数或纯虚函数(并且应该是虚函数),但构造函数不能加 virtual,因为虚函数要通过对象中的 vtable 找到函数起始地址,但在构造函数执行前对象都不存在,也就没有 vtable,没法调用虚的构造函数。
在虚函数声明的后面加 =0 即可定义纯虚函数:virtual void func() = 0。
抽象类
定义了纯虚函数的类为抽象类,不能创建抽象类的实例,只能创建它的派生类的实例(如果也不是抽象类)。
抽象类定义了子类的各个接口,可以为子类创建规范。
继承与组合
(公共)继承表示 is-a 关系,组合(与非公共继承)表示 has-a 关系。继承能实现多态、代码复用,但这些功能都能通过 组合+接口+委托实现。
如果不是 is-a 的关系(不想让 A 对象能作为 B 对象使用),或继承层次过深、过复杂时,会影响到代码的可读性和可维护性,就应使用组合而非继承。
一般来说,总可以将继承替换为组合:如果继承关系设计的不好,有些子类会继承并不应该实现的某些方法,就需要重写这些方法,让它们抛出异常(但很可能误调用);可能会需要拆分功能,继承多个抽象类,导致逻辑很复杂。
例子见这里。
继承没有组合灵活,组合可以使对象更自由的组合多个模块,而继承中(如果不想很麻烦)每个对象要么都有某个模块,要么都没有。
不过如果继承关系不复杂,用继承也没关系;继承还有两个特点:
- 能够重写父类的方法。
- (在 C++ 中)能使用空基类优化。
继承方式
B 在继承 A 时,可以在 A 前面加修饰符表示继承方式。默认为 private。
- private/私有继承:
class Derive: (private) Base。
基类的 protected 和 public 成员会在派生类中变为 private,可以在派生类内部使用,但不能在类外部或派生类的子类中使用。 - public/公共继承:
class Derive: public Base。
基类的成员的访问权限不变。
只有 public 继承表示 is-a 关系,表示子类是父类的子集,子类对象一定是父类对象,能够在父类对象上做的行为也能在子类上做。而其它继承方式只是为了继承属性或方法。 - protected/保护继承:
class Derive: protected Base。
基类的 protected 和 public 成员会在派生类中变为 protected,可以在派生类和子类中使用,但不能在类外部使用。
基类的 private 成员,不管怎样派生类都无法访问。
(但注意,一个类的 private 成员,在该类内部就可以调用,即使不是该对象!C++ 的访问修饰符以类为单位,而不是以对象为单位。比如拷贝构造函数中,可以访问参数的私有变量)
公共继承最常用,但对于 C++,不是所有继承都是 is-a 的关系,继承可以只是继承属性或方法。
这其实与面向对象的设计原则不符(子类与父类就是 is-a 关系),Java, C# 中都只有公共继承,但 C++ 并不是严格的面向对象,允许其它范式、更自由或更看重效率?
只有公共继承的派生类,才可以使用基类指针指向该派生类(否则会A is an accessible base of B)。
私有/保护继承实现的功能(继承),与在类中定义私有/保护对象类似(组合)(私有继承类 A,与定义私有类 A 对象类似)。
但是,继承能够使用空基类优化,私有继承空的类不会占大小,而组合使用私有变量至少还要 1 字节。
非公共继承不允许派生类被当做基类使用,能够隐藏基类的实现细节。
非公共继承在业务中很不常用,但在模板库这类通用、安全的代码中有用(可能是因为空基类在业务中也不常用)。
using
using 有如下功能:
- 在命名空间和块作用域中:将另一命名空间的成员引入到当前命名空间或块作用域中。
- 在类定义中:可以将基类成员引入到派生类的定义中(可以改变 私有或保护变量(或私有或保护继承的变量)的访问级别。在 public 下 using 就会将它作为派生类中的公开成员)。
- 其它见文档,以及 C++ - 使用 using 定义别名。注意 using 不能引入基类的析构函数、不能引入模板的特化。
赋值兼容原则/类型兼容原则
(仅限公共继承)
派生类的对象或指针可以赋值给基类对象或指针;派生类可以初始化基类的引用。反之不可。
即派生类对象可以作为基类对象使用,但此时只能访问基类中的成员(但可以用自己的虚函数)(切片现象)。
所以任何基类出现的地方,都可以用派生类去代替,反之不可。
但有些场景下,将子类当作父类用会不符合预期。
例:见 面向对象 - 构造/析构函数,即base = std::move(derived)。
使用 protected/private 继承使基类成为不可访问的基类(派生类不能再被作为基类使用),就可能在不当使用时,产生编译错误,更早发现问题。
多重继承
一个类可以继承多个基类。这种类可能有多个虚函数表(即多个虚指针)。
设 D 继承的类为 B1, B2, ..., Bn,则 D 的对象的内存布局,依次为:B1 的虚函数指针与对象、B2 的虚函数指针与对象、...、Bn 的虚函数指针与对象、D 的对象。
多继承中,不同基类的起始地址,并不都是派生类的起始地址,所以对于一个派生类 D,要想安全的进行基类指针 *B1, *B2 之间的转换,必须通过 dynamic_cast。
例:
class BaseA
{
public:
int x;
virtual void funcA() { cout << "BaseA::funcA()" << endl;
};
class BaseB
{
public:
virtual void funcB() { cout << "BaseB::funcB()" << endl; }
};
class Derived : public BaseA, public BaseB {};
int main()
{
Derived d;
BaseA* pa = &d;
BaseB* pb = &d;
BaseB* pbe = (BaseB*)pa; // error!只是简单进行了类型转换,但pa的地址不应是pb的地址
BaseB* pbc = dynamic_cast<BaseB*>(pa);
cout << "sizeof(d) = " << sizeof(d) << endl;
cout << "Using pa to call funcA()..." << endl;
pa->funcA(); // correct
cout << "Using pb to call funcB()..." << endl;
pb->funcB(); // correct
cout << "Using pbe to call funcB()..." << endl;
pbe->funcB(); // error! 输出 BaseA::funcA()
cout << "Using pbc to call funcB()..." << endl;
pbc->funcB(); // correct
cout << "pa = " << pa << endl; // BaseA 的地址:n
cout << "pb = " << pb << endl; // BaseB 的地址:n+16 (虚函数+int)
cout << "pbe = " << pbe << endl; // error!仍为 BaseA 的地址:n
cout << "pbc = " << pbc << endl; // BaseB 的地址:n+16
}
继承和多继承的构造函数,都可以在派生类调用构造函数时,在初始化列表中调用:D(形参列表): A(实参列表), B(实参列表), C(实参列表)。派生类的将在最后调用。
注意,与初始化列表中的变量一致,多继承中构造函数被调用的顺序,取决于继承时的顺序,与列表中的顺序无关。
析构函数会自动调用,与构造顺序相反。
多重继承中的二义性
继承的多个基类中的同名变量可能产生二义性。
对此,需要用作用域运算符::来限定所访问的成员是属于哪一个基类的:ClassName::VariableName。也因此任一基类只能被直接继承一次,否则无法处理冲突。
作用域运算符不能嵌套(连续使用),如:不能A::B::x,如果需要只能在 A 中加一个函数getBx()。
派生类中的成员可以与基类中的成员同名。如果没有指定作用域,则默认使用当前类中的。
虚基类
在多继承中,如果一个类 D 继承的两个(多个同理)基类 B 和 C 继承了相同的基类 A,则默认情况下 D 会继承 A 两次,拥有 A 的两份数据,使用时要通过 B 和 C + 作用域运算符进行区分。
如果想让公共的基类只被继承一次(菱形继承),则在继承它时,需要使用 virtual 指定它为虚基类(B 和 C 必须都声明虚基类)。
/*
默认情况:
A<-B<-D->C->A
D 继承 A 两次
*/
/*
虚继承的情况:
D->(B,C)->A
D 只继承 A 一次
*/
class A {};
class B: virtual public A {};
class C: virtual public A {};
class D: public B, public C {};
指定虚基类的格式:class Derived: virtual (public/private/protected) Base。
虚基类的构造函数,会优先于非虚基类的构造函数执行。析构函数执行会更晚。
虚继承
当派生类继承了一个或多个虚基类时,或说虚继承了一个或多个类时,会创建一个虚基类指针 vbptr,虚基类指针指向虚基类表,虚基类表中保存了 虚基类的数据对象,距离 vbptr 地址的偏移量。
虚基表指针可以视为一个数据成员,会在继承时也继承下来。
例(图见这里):
class A {int a;};
class B {int b;};
class C: virtual public A, virtual public B {int c;};
则 C 的内存布局为:
00: vbptr (C本身)
08: c
16: a (虚基类A)
24: b (虚基类B)
虚基类表为:
0: 0
1: 16 (= A的起始地址 - vbptr)
2: 24 (= B的起始地址 - vbptr)
class A {int a;};
class B: virtual public A {int b;};
class C: virtual public A {int c;};
class D: public B, public C {int d;};
则 D 的内存布局为:
00: vbptr1 (D正常的继承B,拥有B的数据成员)
08: b
16: vbptr2 (D正常的继承C,拥有C的数据成员)
24: c
32: d (D本身)
40: a (虚基类A)
虚基类表1为:
0: 0
1: 40 (= A的起始地址 - vbptr1)
虚基类表2为:
0: 0
1: 24 (= A的起始地址 - vbptr2)
如果有虚继承,虚基类指针会在该类的起始地址位置,然后才是虚函数表指针。但是前者会被当做普通对象继承,而后者不是?
非静态成员初始化
两种方法,具体见这里。
- 在构造函数的成员初始化器列表中初始化。
- 通过默认成员初始化器:在成员声明包含花括号或等号初始化器。如果在成员初始化器列表中初始化该成员,该初始化会被忽略。
// 1.
struct S {
int n;
std::string s;
S() : n(7) {} // 直接初始化 n,默认初始化 s
};
// 2.
struct S {
int n = 7;
std::string s{'a', 'b', 'c'};
S() {} // 默认成员初始化器将复制初始化 n,列表初始化 s
};
静态成员初始化
static 只用于静态成员的声明,不用于它的定义。可以声明为不完整类型(只要不是 constexpr 或 inline)。
变量的定义(即初始化)需要在类外部进行。
例外:const、inline 可以在类内定义;constexpr 必须在类内定义(用常量表达式初始化)。
具体见这里。
为什么需要在类外定义?
- 非 const 的 static 成员变量,如果在当前文件初始化,编译器会在编译期将它翻译成一个强符号。当这个类作为头文件之一引入到多个文件后,就会产生多个名字相同的强符号,导致链接时出现重复定义的错误。(这与 static 变量是一致的)
- 加上 const 后,如果文件里没有对它进行取地址等行为,编译器可以根本不给它分配内存,而是在汇编中直接用立即数代替这个 static const 变量,所以不会有错误。
对其进行取地址时,它一样会变成强符号。如果多个文件中对其进行取地址,也会发生重复定义。非 static 成员变量不会有问题,因为它们根本不会在编译期产生符号。
类似的还有普通函数,普通函数是强符号,如果在头文件中定义也会重复定义。
但类成员函数是弱符号,头文件中定义不会出现重复定义。
空基类优化 (ebo)
允许空的基类子对象大小为0(见这里)。
但是,如果首个非静态数据成员的类型,与继承的一个空基类的类型相同,或是继承的一个空基类的派生类,则无法使用空基类优化,因为两个同类型的基类子对象必须有不同的地址。
C++ 20 引入了[[no_unique_address]],可能违反地址不同的约束。
当多重继承时,空基类的优化取决于编译器,对于 GCC,所有空基类均应用空基类优化,不分配任何空间。
默认构造函数
创建类时,如果不自行实现以下函数,类会提供默认实现:构造函数、析构函数、拷贝构造函数、移动构造函数、拷贝赋值函数(operator =)、移动赋值函数。
Node a = b会调用拷贝构造,a = b会调用拷贝赋值 =。
子类的默认构造函数中,会调用基类的构造函数和各个成员的构造函数。
默认实现的构造函数是无参的,如果我们定义了任意构造函数,编译器就不会实现默认无参构造函数,需要我们自己实现(但其它的还在,比如拷贝构造)。
但是一旦我们给出了无参构造函数,该类或结构体就不再是平凡的 (trivial),就不是一个 POD 类型,会失去一些优点,比如 POD 可以直接使用 memset 完成初始化。
如果我们既想定义有参构造函数,又想使用默认无参构造函数,来满足 POD 条件,可以使用 = default 标注构造函数,让编译器给出默认实现。
如果我们不允许类使用某个函数,即不想编译器默认实现某个函数,可以用 = delete 标注函数。
如:A& operator=(const A&) = delete;禁用拷贝构造函数;A(double) {} = delete;禁用 double 类型的构造函数。
默认的无参/拷贝/移动构造函数就是调用每个成员的无参/拷贝/移动构造函数。
部分内容见 C++ - 左值和右值。
TODO
默认构造函数就是无参数的构造函数T(),规则见:https://blog.csdn.net/hankai1024/article/details/7947989。
一个不符合直觉的规则是:函数被显式和隐式弃置是有区别的:显式弃置的函数仍可参与重载决议,且优先级不受影响,可能会优于其它函数被选择(此时程序非良构)。(其实隐式弃置是指编译器不会再为你生成该函数,而非有了再弃;而显式弃置则是声明了再弃)
显式弃置就是使用= delete,隐式弃置包括很多,如:用户定义了析构函数导致移动构造与赋值弃置(见 面向对象 - 三五零法则),父类的移动构造被弃置导致子类的移动构造弃置。如下面的代码中移动构造被显式弃置,但因为比复制构造更合适,所以会被选择,导致使用 deleted function。
(这里与 RVO 无关,因为程序首先得能正常运行,才能做优化)struct A { A() = default; A(const A&) = default; A(A&&) = delete; }; A f() { return A(); } f(); // A a = f(); // CE struct B: A {}; B g() { return B(); } g(); // OK由于 A 的子类 B 中的移动构造是隐式弃置,所以不会被选择,而是执行赋值构造。
构造/析构函数
构造函数和析构函数不会被继承,但是会在子类进行构造/析构时自动调用(自定义的构造函数也是)。
所以基类的构造与析构函数,需要声明为 public 或 protected 供子类调用。
用于多态的基类,其析构函数应定义为虚函数(并在子类中实现对应的析构函数)。
因为使用基类指针指向子类时,如果析构函数不是虚函数,就会调用基类的析构函数,导致子类的对象可能无法正确释放,产生内存泄露。
因为子类可以当做基类使用,在涉及析构、移动构造(即资源释放)时非常危险。析构函数可以定义为虚函数,但移动构造定义为虚函数也没用,比如:
B 继承自 A,两者有各自的移动构造函数。定义A a; B b;,执行a = std::move(b);,将总会调用 A 的移动构造函数(将 b 看做 A),可能导致 b 的成员无法正常或全部移动。
虽然 move 后的 b 不会(不应)再使用,但如果 B 的析构函数没考虑到这个问题,可能会出问题。
构造函数调用顺序 / 构造顺序
首先调用基类各成员的构造函数,然后调用基类的构造函数,然后是派生类中各成员的构造函数,最后才是派生类自己的构造函数(可以认为类的构造函数中,会先执行自己成员的构造函数)。
类成员的构造函数的调用顺序,与它们的声明顺序一致。
默认调用的构造函数都是无参构造函数。如果在初始化列表中写明,则不会默认调用无参构造,而是选择初始化列表中的构造方式。
初始化列表会先于构造函数体执行。
class A {
public:
A(int a) { std::cout << '1'; }
virtual ~A() { std::cout << '4'; }
};
class B : public A {
public:
B(): A(3) { std::cout << '2'; } // 如果不在初始化列表中初始化A,则会调用A()初始化父类,但A()已被删除,会CE
virtual ~B() { std::cout << '3'; }
};
析构函数的调用顺序总是相反:先是派生类,再是类成员,再是基类(从右到左)。
对于多继承的多个基类函数,按照从左到右的顺序调用构造。
如果多继承中有虚基类,则先依次执行虚基类的构造函数,再依次执行非虚基类的构造函数。
最好不要在析构函数中执行虚函数。
析构时,派生类的析构会先执行,然后才是基类的析构。
如果基类的析构函数中使用了虚函数,由于此时对象的派生类已释放(使用派生类的数据成员就是未定义的),虚函数表会调整指向,执行基类的虚函数。这其实与虚函数的语义不符(为了避免 UB 选择这样)。
析构函数为什么不默认是虚的
因为虚函数需要虚表,导致类对象多保存一个虚表指针,可能会浪费空间。比如空类只有 1B,但如果有虚析构函数则为 4/8B。
explicit
explicit 用于修饰构造函数,表示该构造函数必须被显式调用,不能通过隐式类型转换调用。
构造函数默认都是 implicit 的,可能导致问题或不想要的情况。
如:class A有一个构造函数A(double a)以及拷贝构造函数A(const A&),如果不加 explicit,则A a = 1;将通过,1 会被隐式转为 double 然后构造成 A,从而能调用A(const A&)。隐式转换代表一个 double(或是能隐式转换成 double 的值)都可被看做一个 A 类型对象、出现在 A 类型的位置上。
比如:函数f(const A &x)能够通过f(1.0)调用。
如果用explicit A(double a),则A a = xx;右式必须是一个 A 类型值,不能通过一个 double 隐式构造出 A,一个 double 也不能代表 A、出现在它的位置上,除非经过显式类型转换或使用构造函数。
原理是:复制初始化选择构造函数时,不会考虑 explicit 函数。即它不能用于隐式转换和复制初始化。
具体见:https://zh.cppreference.com/w/cpp/language/explicit
例:
class A {
public;
A(int a) { x = a; }
int x;
}
class B {
public;
explict B(int a) { x = a; }
int x;
}
int main() {
A a1(1); // ok
A a2 = 1; // ok,将通过复制初始化调用A::A(int)
A a3 = A(1); // ok,显式的类型转换
B b1(1); // ok
B b2 = 1; // error: 复制初始化不会考虑B::B(int)
B b3 = B(1); // ok,显式的类型转换
}
初始化列表
构造函数后:的部分即为初始化列表,可用于:初始化基类、初始化成员变量、初始化 const 的成员变量。
const 的成员变量无法在构造函数内部初始化,只能在定义时或使用初始化列表赋值。
没有在初始化列表中定义的成员,会在调用构造函数前调用无参构造,否则会按照初始化列表中的方式调用构造。
变量初始化的顺序是其声明的顺序,跟初始化列表中的顺序无关!初始化列表中的参数会根据声明顺序依次赋值,然后执行函数体。
静态成员
静态成员不与类的对象关联,而只与类有关联。
静态成员的初始化必须在类外部进行(定义该类的文件内),除非静态成员是 static const 或 static constexpr(即静态常量,只读)。也不能在构造函数中初始化。
类外部初始化的例子:int ClassName::a = 1;、Singleton* Singleton::instance = nullptr;。
注意只能初始化一次,否则会重复定义。
类对象不存在的情况 / 一个类对象包括什么?
可以对某类声明一个空指针:A a = nullptr,a不包含实际对象,但是依旧可以调用A类中的非虚函数和非涉及具体对象的变量。
事实上,一个实际对象只包含虚函数表和它的成员变量,空对象只是不包含这些。其它函数和静态变量与类绑定,而不是必须与对象绑定。
如果一个对象什么都没有,则占用1字节作为占位。
类中一旦有 virtual 修饰的成员函数(不论数量),即虚函数,编译器会构建虚函数表,在该类的对象中会存放一个指向虚函数表的指针,占用4字节(或8字节,即指针大小)。
如果有,虚函数表指针会放在类内存结构的最开始。static 静态变量是对类而言的,为所有对象共有,不计算对象的占用空间。
什么都没有的类,或仅有非虚成员函数的类,或仅有类型声明的类,都为空类,编译器会为其构造占用1字节空间的变量(通常为char型);若该类为基类,则继承自空基类的子类不计算这1字节的空间,仅计算子类所占空间(编译器的空基类优化:允许空的基类子对象大小为0)。
但是,设一个类 B 中包含了某个空类 A,如果 B 继承自 A,且 B 的第一个非静态成员是一个 A 类型的成员 a,则这个空类 a 仍然会占用1字节,因为 C++ 要求相同类型的对象必须地址不同,如果不分配1字节,则两个 A 类型对象 (B 的实例与成员 a) 会拥有相同的地址。
如果 B 不继承自 A,或 B 的第一个成员不是 A 类型,则空类不会占用空间。
例:如下代码中,若按4字节对齐,则sizeof为12B。若为1字节对齐,则为9B。若为64位机器、按8字节对齐,则为16B。
class CTest
{
public:
virtual void mem_fun(){} // 4B或8B
private:
char m_chData; // 1B
int m_nData; // 4B
static char s_chData;
};
char CTest::s_chData=’\0’;
类的大小
类只是一种类型定义,本身没有实际大小,我们说的类的大小都是指类的对象的大小。sizeof 一个类名会返回该类对象的大小。
类(对象)的大小只与非静态数据成员有关,与函数和静态数据无关。静态数据保存在共同区域,由所有对象共有,所以不会计入某个对象的大小。
虚函数对类的大小有影响,因为会引入虚指针。
面向对象的设计思想
TODO
设计模式
见 规则 - 设计模式。
三五零法则 / 350
- 三之法则:如果某个类需要自定义的析构函数、自定义的复制构造函数或自定义的复制赋值运算符,那么它几乎肯定需要全部三者。
所以如果实现了三者之一,则要注意是否需要实现另外两者,使用默认的隐式定义很可能出问题(比如析构时需要释放资源,那么在拷贝赋值时肯定需要类似的资源释放,不能用默认行为)。 - 五之法则:任何想要使用移动语义的类,必须声明全部五个特殊成员函数(析构函数、拷贝构造、移动构造、拷贝赋值、移动赋值)。由于声明拷贝、移动或析构函数之一,就会导致移动构造和移动赋值被隐式弃置,因此即使不实现也应该显式声明 default。
class base_of_five_defaults
{
public:
base_of_five_defaults(const base_of_five_defaults&) = default;
base_of_five_defaults(base_of_five_defaults&&) = default;
base_of_five_defaults& operator=(const base_of_five_defaults&) = default;
base_of_five_defaults& operator=(base_of_five_defaults&&) = default;
virtual ~base_of_five_defaults() = default;
};
使用默认移动构造和移动赋值、不给出实现通常不是错误,但会失去优化机会。
- 零之法则:有自定义析构函数、复制/移动构造函数或复制/移动赋值运算符的类,应该专门处理所有权。其他类都不应该拥有自定义的析构函数、复制/移动构造函数或复制/移动赋值运算符。
当某个基类要用于多态时,可能需要将它的析构函数声明为 public 虚函数。由于这会阻止隐式移动的生成,因此必须将各特殊成员函数显式声明为 default(即上面的代码)。但是,由于 规则 - C.67 多态类应该抑制复制操作,基类的拷贝构造和赋值可能应设为 delete(如果有需要就 protected)。
三五零与 规则 - C.21 如果自定义或显式弃置了拷贝、移动或析构函数,那么应该自定义或显式弃置它们所有 类似。
mutable 说明符
mutable 允许 const 类对象修改相应类成员。可以修饰非引用、非常量类型的非静态数据成员。
mutable 常与 mutex 一起使用(M&M 规则),允许 const 函数给类内的 mutex 加锁,以保证线程安全。
https://timsong-cpp.github.io/cppwp/n4659/dcl.stc#9
设 A 类对象 a 拥有 mutable 的 B 类成员 b,则不管 a(或 *this)有没有 const 修饰,a.b 都不会有 const 修饰符,因此可以被修改。
因此将 a.b 传入函数时,会优先匹配B&而非const B&。
例:struct X { X() { std::cout << "1"; } X(X&) { std::cout << "2"; } X(const X&) { std::cout << "3"; } X(X&&) { std::cout << "4"; } ~X() { std::cout << "5"; } }; struct Y { mutable X x; Y() = default; Y(const Y&) = default; }; Y y1; Y y2 = std::move(y1);由于 Y 的移动构造被弃置,因此 y2 初始化也会匹配 Y 的默认拷贝构造
Y(const Y&),会调用各成员的拷贝构造。
虽然 y1 有 const 修饰,但 mutable 成员 x 会去除 const,因此在拷贝 x 时会优先匹配X(X&)而非X(const X&)。void g(const X& x) { puts("gx const"); } void g(X& x) { puts("gx non-const"); } void f(const Y& y) { g(y.x); // 如果x是mutable,匹配 g(X& x) } f(y1); // 输出 gx non-const
成员函数的限定符
成员函数可以添加多种限定符,来修饰或区分成员函数是被什么样的实例(即 *this)调用。(这与普通函数不同)
- cv 限定符:const、volatile
限定只有 const/volatile 的类对象(*this 有同样的 cv 限定)才可调用该成员函数。
cv 修饰符不同的函数具有不同类型,可以重载以实现最佳匹配。
- 引用限定符:&/&&
限定只有左值/右值类对象才可调用该成员函数。
这允许我们在不同的情况下,返回不同的类型。左值对象可以返回左值引用,右值对象可以返回移动后的成员函数,来避免拷贝构造:
class Widget {
public:
using DataType = std::vector<double>;
DataType& data() & //对于左值Widgets,返回左值
{ return values; }
DataType data() && //对于右值Widgets,返回右值
{ return std::move(values); }
private:
DataType values;
};
auto vals1 = w.data(); //调用左值重载版本的Widget::data,拷贝构造vals1
auto vals2 = makeWidget().data(); //调用右值重载版本的Widget::data,移动构造vals2
Widget{}.data(); //调用右值版本
这允许我们区别对待左值对象和右值对象,避免不必要的拷贝。在工厂函数中很有用。
与 cv 限定符不同,引用限定符不改变 this 指针的性质:即使在右值引用限定的函数中,*this 仍是左值表达式。
相关内容可见 C++ - RVO 和EMCpp。
- 说明符 override:声明该函数覆盖了基类的某个虚函数。如果不是,编译器会报错。
- 说明符 final:阻止类的进一步派生 和 虚函数的进一步重写。
this 指针有什么用
成员函数代码只有一份,但类对象可以有很多个。(非静态)成员函数内通过 this 来判断自己操作的是哪一个对象。
代码中,通过 this 可以指定某个成员变量,可以返回指向自身的指针或引用(比如实现链式调用)。
this 实际上是成员函数的一个形参,在调用时将对象的地址作为实参传入。
为什么 this 不是引用?
历史原因,C++ 引入指针时还没有引入引用。
delete this
成员函数中可以释放 this,就像在成员函数外一样。
注意释放后,
- 不能访问该对象的任何成员变量及虚函数(虚指针也会被删除)。
但仍可调用不使用成员变量的函数。 - 不能再访问和使用 this 指针(已经是悬挂指针了)。
注意栈上的/临时作用域内的类对象不能 delete 它(包括 delete this),否则将导致被 delete 两次,因为它会在作用域结束后会自行释放。
换句话说,delete 的目标必须是用 new 分配的,不包括 全局的/new[] 的/原地 new 的。
STL
使用 STL 时要注意的问题
vector 提供了.at(pos),可以代替[]获取元素。at 会检查下标是否合法,非法的话将抛出异常std::out_of_range。
为了提高性能,对于 [] 程序并不会去检查边界,可能会导致 UB。
.size()的类型是无符号的,如果 size 为0,使用 size() -1,则结果是无符号最大值。
所以在循环中,不要写i <= size-1。
此外,int 在与无符号数比较时,也会被转为无符号。所以负数在与无符号相比时,会被转换为一个大值!所以最好使用无符号数(如 size_t)作为下标。
遍历容器时,尽量使用const auto& v: container,而不是自己说明类型(如:const int& v: vec,这个例子还比较简单)。
因为自己写的类型可能写错,比如 map 的类型是typedef pair<const Key, T> value_type;,如pair<const std::string, int>,如果写成了pair<std::string, int>,对每个元素都会发生一次拷贝。
使用 map 时,如果不确定一个元素没有,就只能先 find,如果没有再 insert。这需要两次查询。
map 的 emplace 能够在元素不存在时进行插入,并返回pair<iterator, bool>,只需要一次。例:auto [iter, insert_ok] = m.emplace("key", 0);。
判断为空时,使用empty()而非size() == 0。虽然 C++11 保证了 size 也是 O(1) 的,但应使用语义更明确、简洁的函数。
对容器进行 swap 是 O(1) 的(只交换指针)。但例外是 std::array,swap 将交换每个元素的值,所以为 O(n)。
所以,当 swap array 后,原来 array 上的迭代器还依然指向原来的元素,但是值变了;swap 非 array 的容器后,原来容器上的迭代器将指向对方容器上的元素,但指向的元素的值不变(array 和 vector 的区别好像就是可以直接交换)。
可移动赋值 (MoveAssignable)
如果 T 满足可移动赋值,则对于 T 类型的可修改左值表达式(T&) t,或 T 类型的右值表达式(T&&) rv,语句t = rv返回 t 且可实现赋值。
很多 STL 模板的类型 T,都需满足可移动赋值。
该类型至少要满足两个条件之一:
- 实现了接收值或
const Type&的复制赋值运算符,该参数也可绑定右值的实参。 - 实现了移动赋值函数。
可移动构造类似,对应构造函数。
clear
容器的clear()都是O(n)的,因为要调用每个元素的析构函数。
不过,如果容器是连续存储的(如 vector),且元素的析构函数是平凡的(如 POD 类型),可以在常量时间内完成 clear。
(trivially-destructible types:析构函数是默认的,即什么都不需要干,使用该空间前初始化即可)
clear()不会释放容器的内存,以便复用供之后使用。
对于 vector,clear 后它的 capacity 是不变的。但可以使用shrink_to_fit(),或std::vector<int>().swap(v)。后者是定义了个临时 vector,在退出作用域(这里由于是右值所以会立刻释放)时析构、自动释放内部元素。
对于其它容器,只能在 clear 前,遍历所有元素自己去 delete 它。std::set/map/...<int>().swap(v)之类的写法可能不能用。
容器内元素的生命周期
容器在析构,或执行 erase, clear 等函数时,会执行内部元素析构函数,释放对象。
注意,如果容器内部存储的是指针,容器不会释放指针指向的对象。如果需要,在 erase 或 clear 前必须自己 delete 掉,否则一样会内存泄露。
vector 迭代器
TODO
- 当执行 erase 时,指向删除节点及之后的全部迭代器将失效。
erase 可以删除节点,也可以删除区间,都是用迭代器指定。erase 将返回删除前的下一个位置。
所以在迭代中有 erase 时,应使用it = erase(it),而不是++it。 - 在 push_back 后,end 操作返回的迭代器肯定失效。
- 在 push_back 后,若容量发生改变,则需要重新加载整个容器,所有的迭代器都会失效。
- 在 push_back 后,如果空间未重新分配,指向插入位置之前的元素的迭代器仍然有效,但指向插入位置之后的元素的迭代器全部失效。
- shrink_to_fit 后都会失效。
容器迭代器的有效性
对于节点式容器(map, list, set)元素的删除,插入操作会导致指向该元素的迭代器失效,其他元素迭代器不受影响。
常量迭代器
cbegin()与cend()返回一个const_iterator(const_cast<const C&>(a).begin()),而普通的begin()与end()返回的是iterator。crbegin()、crend()同理。
const_iterator 不能更改对应的元素。
对于 const 修饰的 vector,只能调用 cbegin()(只能使用 const 迭代器),不能调用 begin()。
线程安全
大部分 STL 都是不保证线程安全的。
即使一写多读,也容易出问题,比如:vector 的 push_back 和 unordered_map 的 resize 会导致元素迁移,旧的指针失效。
对于 vector,可以预先 resize n 个元素,当数组使用,以避免扩容导致的问题。
vector 的 reserve 只是保证 vector 有容纳 n 个对象的空间,而 resize 会构造 n 个对象,并且将容量重新调整到 n。
reserve 只在容量小于 n 时进行扩容;而 resize 在容量小于 n 时 append n-cap 个元素,在容量大于 n 时 erase 末尾的 cap-n 个元素。
resize 后面 append 的元素只会调用无参构造初始化。可以传入一个 value 表示 append value 的副本。vector 中会维护三个迭代器:first(容器的起始位置)、last(最后一个元素的结束位置)、end(容器的结束位置)。
vector 的扩容
https://zh.cppreference.com/w/cpp/container/vector/resize
面试说的时候多说点,比如:
- 如果 T 满足可移动插入(可从一个该类型的右值构造到未初始化的空间),则 resize 可以选择移动构造避免拷贝。
- 如果 T 的移动构造没有加 noexcept,则会优先选择复制构造来保证强异常安全,导致移动白写。
- 如果抛出异常,则该函数不产生任何效果。
- 如果 T 满足可移动插入、但没有 noexcept 的移动构造,且不满足可复制插入,则会选择可能抛出异常的移动构造,并且属于未指定行为。
push_back 同理。
扩容策略(扩容因子)与编译器相关,mingw 中的 gcc 每次扩容2倍,visual studio 中的编译器每次扩容 1.5 倍。是一个扩容次数(即效率)和可能浪费的空间之间的折中。
理论上来说,1.5 倍扩容更好,因为能够复用之前释放的空间(能够提高页表缓存的命中率?或不需要分配新的页)。而 2 倍扩容中每次申请空间,都恰好不能复用之前释放的空间。
但实际中不只有 vector 需要申请内存,很难保证之前分配的是连续的、且没有其它对象使用,所以不一定有用(但内存管理可能会合并已释放的空间)。
因为需要先分配再释放,所以扩容倍数在 (1, 1.618) 之间才能够实现复用(就是斐波那契数列)。
Linux 用 伙伴系统 (buddy system) 管理内存。就是将内存按 2 的幂大小划分成不同规格的内存块,同一规格的用链表管理。
所以在申请 2 的幂大小的内存块时,是刚好可以分配的,可能效率也不低。
vector 的扩容拷贝,对于 POD 类型,会调用 std::copy(能够直接使用 memmove?);对于非 POD 类型,会遍历每个对象,调用构造函数进行拷贝或移动。
扩容可以使用移动构造,但必须满足:类拥有不会抛出异常的移动构造函数(使得源码中的__move_if_noexcept_cond为 false,才能使用move_iterator)。
这种容器内部的复制行为 是不能出现异常的,所以只有标记了
noexcept,才能使用移动构造函数。
大多数情况下,移动构造函数都应该用noexcept声明。move_iterator 在通过迭代器构造时,会使用 move 移动另一个迭代器的所有权,从而移动指向的资源。
vector 扩容为什么不像 realloc 一样,先检查后面有没有足够空间能直接使用?
https://www.zhihu.com/question/384869006
C++ 标准库容器的动态内存分配是由分配器(Allocator)类处理的。所以分配器提供什么接口,标准库容器的内存操作才能用什么。
从 C++98 至今标准库的分配器要求都缺少原位扩张/收缩的接口,所以 vector/basic_string 也用不了。实际上有 N3495 、P0401 、 P0894 等零星提案建议增加分配器的接口,以支持这些功能,但是这些提案都没有通过。为什么不能直接用 realloc?
- realloc 只能处理 m/c/realloc 分配的内存,虽然默认分配器确实使用 malloc 分配内存但并不能做这种假设,所以不能直接使用 realloc。
- 因为 realloc 在无法分配时会调用 free 和拷贝,所以对象必须是可平凡拷贝的,但 vector 没有这种保证。
vector 的扩容为什么不在后面申请空间
TODO
https://www.zhihu.com/question/384869006/answer/1130101522
https://www.zhihu.com/question/413934131
std::copy 与 memcpy
TODO
https://blog.csdn.net/Johnsonjjj/article/details/107743872
https://zh.cppreference.com/w/cpp/algorithm/copy
关联容器
关联容器:对所有元素的检索都是通过元素的 key 进行的,而非元素的内存地址。
如:map, set。
std::vector
https://zh.cppreference.com/w/cpp/container/vector_bool
std::vector(对于 bool 以外的 T)满足容器、知分配器容器、序列容器、连续容器(C++17 起)及可逆容器的要求。vector
的读写效率可能略低?但节省了空间、提高了 cache 命中率。
它没有容器的特性,无法使用某些 STL 算法和迭代器,所以在用vector<T>时一定要考虑 T 是否可能为 bool(可以 enable_if 拒绝 bool)。
比如:T *p = &c[0];将编译失败,因为 reference 将返回 proxy 类,而非 T&。
vector<bool>虽然是 vector 的 bool 特化,但并不是标准的 STL 容器,不支持 STL 的许多功能。
而且里面的元素不是一个个 bool(1字节),而是一个个 bit 或 proxy object,在用auto v = vec[i]时很容易出错。
一个 bool 本身是占1字节的。为了减少空间占用,vector<bool>将其中的每1个字节看做8个bool,与 bitset 类似。
对于正常的 STL 容器,调用operator[]会返回对应元素的引用 T&。但字节是寻址的基本单位,我们没法获取一个位的地址,所以vector<bool>调用operator[]会返回一个 proxy reference(代理引用)而非 bool&(所以无法赋给一个 bool* )。这不是一个真正的引用,而是一个std::vector<bool>::reference类型的返回值。它是一个临时的右值,我们无法对它取地址。
使用 auto 将operator[]的结果赋值给变量 x 时,x 也将是代理引用,修改 x 将修改容器内的值。这与其它 vector 是不同的(x 将是一个拷贝值)。
如果容器被销毁,x 将变成悬挂指针,再使用是不安全的。
vector<bool> vb = {false, false, false};
bool c = vb[0]; // 包含proxy reference到bool的隐式转换
c = true; // 不影响容器
auto d = vb[1]; // 对于非bool类型的vector<T>,auto将返回T而不是引用
d = true; // 影响容器元素
bool *p = &vb[0]; // error:[]返回值并非bool&
之所以定义它,是为了减少内存消耗,而 bitset 无法在运行时改变容器大小。
但由于它的特殊性,在模板编程中可能比较危险(vector
push_back 与 emplace_back
emplace_back 功能上可以替代 push_back,但它们的语义有点不同:push_back 侧重对已有对象的复制或临时对象的移动,emplace_back 侧重直接在容器对应位置构造新对象。
- 即使实现了移动构造,push_back 也要在容器外先创建一个对象,然后再 move 过去、释放旧对象(如
strVec.push_back("123"));而 emplace_back 直接在对应位置进行构造,不需要 move,可能会有一点点的提升。 - emplace_back 可读性相对差(不能明确表达放入一个对象),也不太安全。使用时需要明确操作的 vector 的类型,否则可能写错。所以通常还是选择 push_back。
如:对于vector<vector<int>> vec;,vec.push_back(10)与vec.emplace_back(10)是不同的。如果想在某个数组后添加一个10,那么必须指定下标,如果不指定,用 push_back 会CE,但 emplace_back 会正常运行且不对(实际是在后面建了个大小为10的 vector),所以 push_back 更好些(写错了也能更快发现错误)。
具体见:https://abseil.io/tips/112 - push_back 是传已构造好的对象,IDE 可以直接帮你判断传的对象对不对,但 emplace_back 如果传错了,只有编译时才能知道。具体见:https://www.zhihu.com/question/387522517
- emplace_back 的构造可以忽略 explicit 限制。
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;
}
因为 initializer_list 只是保存指向常量数组的指针,因此将其直接传入参数也不会发生元素的拷贝(只是拷贝结构体,也就是两个指针)。
而通过变量构造它时,会发生变量到数组的拷贝。如:initializer_list<Node> i{node};需要将 node 拷贝到指定区域。
因为只读,使用它初始 vector 时,里面的元素要被拷贝到 vector 中,因此它不支持 move-only 对象,所以vector<unique_ptr<int>>就不能用它赋值。
使用它初始化容器的相关问题 及可能更好的实现:https://zhuanlan.zhihu.com/p/545305641
forward_list
单向链表。与 list 相比,不能双向迭代,但更省空间。
没有 rbegin(), rend()。insert() 改为了 insert_after(),插入时需要指定迭代器,emplace, erase 同理。
没有维护 size,所以无法获得内部元素数量。
max_size() 会返回容器大小的理论极限,即std::distance(begin(), end())。通常很大,没什么意义?
deque
deque 使用多个固定尺寸数组来保存元素,所以内部元素不是连续的,但仍可以保证 O(1) 的随机访问(但需要进行两次指针解引用,比 vector 多一次)。
deque 会按需自动扩展或收缩。扩展 deque 比扩展 vector 要高效,因为不需要移动元素,只需分配一个新的小数组。所以如果要大量在两端插入/移除元素,deque 会比 vector 高效很多。
但是,deque 有较大的最小内存开销,因为一次要分配一整个小数组。小数组的大小可能为元素大小的 8 或 16 倍。
deque 的遍历好像也不太高效?
类似 vector,可进行 shrink_to_fit()。
queue
queue 是一个专为 FIFO 设计的容器适配器。
容器适配器指它本身只是一个封装层,必须依赖指定的底层容器才能实现具体功能(但要求底层容器实现了特定接口)。
默认容器为 deque,可通过模板指定:template <class T, class Container = deque<T> > class queue;。
queue 可以接收任何一个支持下列接口的容器作为底层容器:empty(); size(); front(); back(); push_back(); pop_front()。
priority_queue 也只是一个容器适配器,需要指定底层容器才能实例化。默认为 vector
底层容器必须支持随机访问和以下接口:empty(); size(); front(); push_back(); pop_back()。
stack 也是,默认为 deque。
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++ - 结构化绑定。
proxy class
代理类,指“它的所有对象都是为了其它对象而存在的,就像对象的代理人”。
见:
https://www.cnblogs.com/reasno/p/4858490.html
https://blog.csdn.net/weixin_28712713/article/details/82316047
string 的其它实现方式
COW:https://www.cnblogs.com/promise6522/archive/2012/03/22/2412686.html
标准库的 string 的实现是非常简单的,基本就是一个 char 的容器或数组(其实也不恰当,因为不能像容器一样取出单个元素修改,只能创建新的),内部也没有统一编码(没有编码的概念,但可能很难,因为编码和带来的问题也复杂),不是变长的。
常见的 string 拷贝方式即为全部拷贝,称为 eager copy。
另一种方式为 copy on write (COW) 或 lazy copy,在两个 string 对象进行拷贝时不分配新内存,只在发生修改时进行拷贝。
COW 减少了不必要的资源分配,减少了分配和资源带来的延迟(但如果要写,只是把这部分工作延后了,可能反而影响性能)。
通过下面的代码可以判断 std::string 是否使用了 COW:
std::string a = "A medium-sized string to avoid SSO";
std::string b = a;
// a.data() == b.data() true // C++11 中,data() 与 c_str() 是相同的
b.append('A');
// a.data() == b.data() false
COW 只发生在两个 string 对象进行拷贝构造和赋值上,如果使用 (const) char*(如 s.data())赋值还是会发生拷贝,因为 string 无法确定和控制 char* 所指对象的生命周期。
所以通过std::string b = a.data();可以避免 COW。
但是 COW 由于以下缺点,并没有多少库会用到:
-
std::string 本身不是线程安全的,但在两个线程内使用不同的 string 必须是线程安全的(C++ 标准要求,如果库的组件要瞒着用户在多个对象间共享内存,则必须自己保证线程安全)。
COW 的 string 共享了数据,所以要保证线程安全。COW 是通过用一个
atomic<size_t>做引用计数来保证的。
拷贝或创建一个字符串时,将计数器原子加一;如果要修改一个字符串,首先将计数器原子减一,然后分配和拷贝一个新的字符串。如果发现计数器为0,还会释放原字符串。
但频繁的原子操作可能会影响性能。 -
当使用 string 的非 const 函数返回引用时(如 operator[], at()),由于无法确定之后是否修改该引用,string 必须进行拷贝。
所以访问str[0]就会发生字符串拷贝,即使不做出任何修改;甚至访问迭代器begin(), end()也要进行拷贝,因为迭代器是可以修改字符串的。
除非根据只读还是非只读,对字符串的访问方式进行严格区分(比如cbegin(),或像vector<bool>中的[]返回一个代理引用 proxy),否则无意义的拷贝还是非常容易发生。
在使用 lazy copy 对象的 operator[] 时,类无法确定使用 operator[] 的语句是否会做出修改。为此可以返回一个 proxy class,代理类保存了父对象的引用,在使用 operator = 时再进行拷贝。
上面的方式叫做 lazy evaluation?
lazy evaluation:惰性计算,不仅包含 lazy copy,还要将一个表达式的值的计算尽可能拖延,直到这个表达式真正被使用的时候。
典型的例子是 python 中的 generator,只在每次使用时 yield 生成新的元素。
另一个概念是 lazy fetch,如果一个对象 a 有很多大对象,并且它们需要从硬盘或数据库中读取,那么我们在创建 a 时不需要将 a 的所有对象进行初始化,而是只初始化需要的子对象。可以将不用的对象值替换为空指针,在使用时获取。
SSO (smart string optimization) 一般用于短字符串,就是将字符串直接存在 string 对象里,而不是存一个 data 指针然后 new 字符串。对于局部创建的字符串,SSO 会把字符串直接放到栈里。
由于栈空间大小限制,以及 string 数组的需求(大对象的数组太慢),只适合很小的字符串?
FBString
是 folly 库实现的 string。
会根据字符串的长度决定是否使用 COW:将字符串按 24、256 分成三类:长度 <24 (大小 <24B)的为 small string,长度在 [24, 255] 的为 medium string,长度大于等于 256 的为 large string。
字符串的主要存储结构是一个 union:
union {
uint8_t bytes_[sizeof(MediumLarge)]; // For accessing the last byte.
Char small_[sizeof(MediumLarge) / sizeof(Char)];
MediumLarge ml_; // 大小为24B
};
struct MediumLarge {
Char* data_;
size_t size_;
size_t capacity_;
};
small string 使用 SSO,即直接使用 small_ 字段(对象本身)存储字符串。如果是局部对象会用对象本身的栈空间。
medium 和 large string 使用 MediumLarge 结构存储字符串的指针,字符串被分配到堆上。
不同的是,medium 使用 eager copy,即拷贝时直接复制;large string 使用 COW,还有一个 RefCounted 对象进行引用计数。
FB 对 COW 也有改进,会将 operator[] 和迭代器 begin() 分成 const 和 non-const 版本,使用const char &c = str[0]会调用 const 修饰的函数。
非 const 的访问会调用非 const 的 begin(),会调用mutableData(),将计数器减一、拷贝一个新的字符串;而 const 的 begin() 直接获取data(),不会拷贝。
iterator begin() { return store_.mutableData(); }
const_iterator begin() const { return store_.data(); }
const_iterator cbegin() const { return begin(); }
// end, rbegin, rend 同理。rend 是一个 reverse_iterator(begin())
const_reference operator[](size_type pos) const { return *(begin() + pos); }
reference operator[](size_type pos) { return *(begin() + pos); }
const_reference at(size_type n) const {
enforce<std::out_of_range>(n < size(), "");
return (*this)[n];
}
reference at(size_type n) {
enforce<std::out_of_range>(n < size(), "");
return (*this)[n];
}
使用 string 要注意的问题
- string 用 substr 取一个子串,都会拷贝一个新串,即使并不会发生修改。
- 不要使用 std::string 定义常量,还会导致分配堆内存(常量应该可以避免这点)。
可以使用constexpr char s[] = "const string";,但在使用时要写成const char*?取长度是 O(n) 的,需要传递长度参数。
也可使用 string_view:constexpr std::string_view s = "const";或constexpr auto s = "const"sv;。与 char 数组相比获取长度是 O(1) 的,也有很多常用方法。 - 析构函数非平凡。因为全局对象销毁顺序难以预测,所以存在生命周期结束后被访问的风险(如 std::string 被其他全局对象引用)。
- 通过
.c_str()或.data()获得的指针,不应被传给任何非 const 指针;不能通过其进行写入;不应在其上调用非 const 成员函数。(但实际上修改是 ok 的,只要原 string 不是 const 对象?文档要求不能改,是因为早期没有规定 string 的实现,如果是 COW 修改就是不合法的)
(C++17 后可以,因为提供了非 const 版本的 .data() 重载)
string_view
std::string_view 在 C++ 17 引入。是一个字符串的视图,不能修改字符串,不会控制字符串的生命周期。所以构造 string_view 并不会发生字符串的拷贝,但程序员要保证它指向的对象始终合法(只要它还存活)。
string_view 只包含一个指向数据的指针和长度,是非常轻量的。
提供了.data(),返回指针字段;但没提供.c_str(),因为它与 C 风格字符串的语义不同(不依赖结束符、是一个子串):
- string 和 char 数组,都使用空字符 (NUL) 作为字符串的结束标志;而 string_view 使用长度,对字符串内的空字符并不关心(还会输出)。
所以 string_view 没有要求必须以空字符结束(可能有也可能没有),在使用.data()时要小心,比如:直接输出 string_view 是正确的,但获取sv.data()再进行输出,可能是错的(结果不同或程序直接 RE)。 - string_view 可能通过截取一个字符串的子串产生,用长度字段指定结束位置。
所以输出sv.data()的结果,可能比输出 sv 的结果更长,且不符合预期。
C++17 的命名空间std::literals中重载了""sv 运算符,用于定义 string_view 字面量。
也允许通过 ""s 定义 string 字面量("abc" 在转换前是一个const char*,不能直接 operator+,但 "abc"s 就是一个 string)
using namespace std::literals;
std::string_view s("abc"sv);
std::string add = "abc" + "123"; // error: invalid operands of types 'const char [4]' and 'const char [4]' to binary 'operator+'
std::string add = "abc" + std::string("123"); // 可能ok,会进行类型转换
std::string add = "abc"s + "123"s; // ok
格式化输出容器
通用的格式化输出函数是流,输出到 sstream 就是字符串,输出到 ostream 就是打印。
现在可以用 format 库,见:https://www.zhihu.com/question/586364098
std::allocator / 内存管理
TODO
一些旧的 STL 里的 std::allocator 实现了二级内存池,但随着 malloc 的改进,这基本不需要了,std::allocator 都只是 operator new 或者 malloc 的一层封装。
在使用基于节点的容器(如 map)插入、删除大量节点后,节点的内存可能不会立刻释放。
具体见:https://www.zhihu.com/question/483406906这是 malloc 的实现导致的,对于小内存块,它会缓存一个 small bin 链表,也就是用户态的一个内存池。但是即使这个链表特别长,也很难触发合并将它们合并成大块内存 或将其释放,导致程序依旧持有大量内存。
而 vector 这类持有连续大块内存的容器,析构后内存往往会被真正释放(但如果它比较短,也可能被缓存)。
range-based for loop
range for 是比传统 for 可读性更好的版本,实际是个语法糖?
语法为for (范围变量声明: 范围表达式 ) 循环语句。C++20 支持初始化语句;C++17 允许结构化绑定。
range for 某个 A 类型的容器 a,实际就是迭代器 for:
for (auto v: a) ...
// 等价于
for (auto it = a.begin(); it != a.end(); ++it)
auto v = *it; ...
所以只要类实现 begin, end,且它们返回的类型可自增、可比较、可解引用,就能用 range for:
struct Array {
Array(std::initializer_list<int> l) {
size = l.size();
data = new int[size]();
std::copy(l.begin(), l.end(), data);
}
~Array() {
delete[] data;
}
// 自定义拷贝构造与赋值
int* begin() { // 迭代器类型为int*,可自增、可解引用。也可自定义类
return data;
}
int* end() {
return data + size;
}
int *data;
size_t size;
};
Array a = {1, 2, 3};
for (auto v: a)
printf("%d ", v);
range for 也可遍历数组,但不能是指向数组的指针。
注意如果不加引用,则每次迭代都需要一次拷贝构造。
写代码
写 STL 注意事项
- 扩容等元素拷贝时,用 move 修饰!
- 如果成员函数不做任何修改,加 const 修饰。
- 析构函数加 noexcept 修饰。
- 构造函数一般可加 explicit(看情况)。
- 移动构造最好要加 noexcept!由于容器的强异常安全保证,在移动构造可能抛出异常的情况下,会选择复制构造。
- 有些类应禁用拷贝构造与赋值,如 lock。
- TODO:
https://www.zhihu.com/question/53085291
手写 vector
见 Codes - 面试 - vector。
扩容时用 move 移动元素!
手写 string
见 Codes - 面试 - string,和STL - string 的其它实现方式。
手写 move 相关函数
见 Codes - 面试 - move。
手写 shared_ptr
见 Codes - 面试 - shared_ptr。
手写 shared_ptr 与 weak_ptr
https://github.com/tacgomes/smart-pointers/blob/master/weak_ptr.h
手写 tuple
https://blog.csdn.net/liuyuan185442111/article/details/123781874
https://zhuanlan.zhihu.com/p/356954012
注意利用私有继承,继承空基类可以优化。
一个合适的 tuple,对下面的代码应该编译错误:
void use_tuple(const ::tuple<float, char> & tuple) {}
int main()
{
::tuple<int, float, char> t(0, 1.0, 'a');
use_tuple(t);
}
单例模式
单例模式实质上就是创建全局对象。如同普通的使用全局变量,在绝大多数场景下这并不是一个好做法,应当先检查一下设计是否合理(可见 规则 - 尽量避免使用单例)。
(C++11及以后)使用单例模式时,最佳的实现只有一个:magic static。双重检查锁定是过时、复杂且错误的??原因参见 Scott Meyers 与 Andrei Alexandrescu 的 "C++ and the Perils of Double-Checked Locking"
单例模式分为懒汉式和饥饿式。
懒汉式:延后初始化,只有在第一次访问单例时,才创建单例对象。在对象不存在时,可能会被多个线程同时访问,需要考虑线程安全。
饥饿式:程序开始运行时,就创建并初始化单例。访问单例不需要考虑线程安全(只会读)。
懒汉式只在必要时创建,可能节省内存空间。
单例类的特点:
- 无参构造和析构函数为 private,构造、赋值(共4个)均为 private 或 delete,避免外部操作。
(其实只需要禁用拷贝构造,禁用拷贝也会禁用移动?) - 类中有一个获取静态实例的静态方法:包含成员
static Singleton* instance;,包含方法static Singleton* getInstance()。
class Singleton
{
private:
static Singleton* instance;
Singleton() = default;
~Singleton() = default;
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
public:
static Singleton* getInstance();
};
单例模式 - 生产可用
二次检查+锁+写屏障。(但最简单的还是函数内静态变量)
由于指定重排和缓存的不一致性,创建单例后需要通过写屏障保证:1. 单例的创建一定在后面的其他语句(准确来说是写)执行之前完成。2. 写屏障之前的写入,即单例的创建,一定会提交到共享缓存或内存中,对其它 CPU 立即可见。
inline void MemoryWriteBarrier() {
__asm__ __volatile__("sfence":::"memory");
}
template<typename T>
class Singleton {
public:
static T& GetInstance()
{
if (destroyed)
{
puts("Singleton has been destroyed.");
abort();
}
if (instance == nullptr)
{
std::lock_guard<std::mutex> lock(mu);
if (instance == nullptr)
{
T* p = new T();
MemoryWriteBarrier();
instance = p;
atexit(Destroy); // in stdlib.h
}
}
return *instance;
}
// 可用于带参初始化
static T& GetInstance(const T& t)
{
if (destroyed)
{
puts("Singleton has been destroyed.");
abort();
}
if (instance == nullptr)
{
std::lock_guard<std::mutex> lock(mu);
if (instance == nullptr)
{
T* p = new T(t);
MemoryWriteBarrier();
instance = p;
atexit(Destroy); // in stdlib.h
}
}
return *instance;
}
static bool IsAlive()
{
return instance != nullptr;
}
private:
Singleton() {};
~Singleton() {};
// 禁用拷贝
Singleton(const Singleton&);
Singleton& operator =(const Singleton&);
static void Destroy()
{
destroyed = true;
if (instance)
{
delete instance;
instance = nullptr;
}
}
static std::mutex mu;
static T* volatile instance; // 注意 volatile 要写在后面!
static bool volatile destroyed;
};
template<typename T>
std::mutex Singleton<T>::mu;
template<typename T>
T* volatile Singleton<T>::instance = nullptr;
template<typename T>
bool volatile Singleton<T>::destroyed = false;
C++11 中,能保证函数的局部静态对象,只会在第一次访问函数时被初始化,并且是线程安全的。
所以直接将 instance 改为函数的静态对象即可(注意此时返回的是引用):
static Singleton& getInstance()
{
static Singleton instance;
return instance;
}
static 对象初始化的时间:
对于非局部静态对象 (non-local static),初始化发生在 main 函数执行前,可以认为是程序执行之前,所以肯定是安全的(此时只有1个主线程)。但不同非局部静态对象之间的初始化顺序,是随机的。
对于局部静态对象 (local static),初始化发生在第一次进入该函数、且第一次遇到对象定义语句时。C++11 保证局部静态对象的创建是线程安全的,其它线程在初始化位置可能等待。
初始化语句只执行一次。
单例在 dll 中使用可能出现问题
单例使用 static 定义,static 对象会被分配于当前的编译单元中(动态库内)。如果可执行文件和动态库使用同一个单例类,将会在两者中各创建一个 static 单例,互相独立。
多线程
mutex
mutex 就是封装的 pthread_mutex,但要注意调它加锁解锁接口时需要检查返回值,返回值不为0时表示操作失败。
lock 就是封装 mutex,构造时加锁、析构时解锁。
注意禁用拷贝,构造函数加 explicit。
mutex 可通过泛型传入,允许使用不同类型的锁。
shared_mutex
mutex 是互斥锁,shared_mutex 是读写锁,允许 lock, unlock (排他性锁定)与 lock_shared, unlock_shared(共享锁定)。在 C++17 引入。
通过 try_lock, try_lock_shared 尝试以排他/共享模式加锁,如果成功加锁返回 true,不成功 false。
recursive_mutex 是可重入锁。
lock_guard
lock_guard 与智能指针类似,可以在一个作用域内方便地管理互斥锁({ }就会定义一个作用域)。
创建 lock_guard 时,它需要获取一个锁的所有权并加锁;在离开 lock_guard 的作用域时,它会被析构并自动释放锁。
使用例子:std::mutex m_mutex; std::lock_guard<std::mutex> lock(m_mutex);。
如果函数可能抛出异常,不使用 lock_guard 管理锁是非常危险的,很可能死锁。
实现:
template <class _Mutex>
class lock_guard { // class with destructor that unlocks a mutex
public:
// 无adopt_lock参数,构造时就加锁
explicit lock_guard(_Mutex& _Mtx) : _MyMutex(_Mtx) {
_MyMutex.lock(); // construct and lock
}
// 传入adopt_lock参数时,构造时不加锁
lock_guard(_Mutex& _Mtx, adopt_lock_t) : _MyMutex(_Mtx) {} // construct but don't lock
// 析构时解锁
~lock_guard() noexcept {
_MyMutex.unlock();
}
// 屏蔽拷贝构造
lock_guard(const lock_guard&) = delete;
lock_guard& operator =(const lock_guard&) = delete;
private:
_Mutex& _MyMutex;
};
lock_guard 并不管理锁的生命周期。如果锁在其它地方被回收,lock_guard 执行析构释放时会发生错误(访问空指针)。
unique_lock
与 lock_guard 类似,但是一个更完整、自由的锁管理器:
- unique_lock 可以手动使用
.unlock()解锁(析构时判断是否上锁才解锁)。 - 在定义时传入 std::defer_lock 可以不在构造时加锁。可通过
.lock()手动加锁。
如:std::unique_lock<mutex> lock(mu, std::defer_lock); lock.lock();。
但注意,重复 lock 会死锁、程序结束。
但占用的资源,相对 lock_guard 更大。
C++ 中,mutex 是普通的锁,lock 是对 mutex 的封装。
unique 是写锁,shared 是读锁,通过对同一个 std::shared_mutex 加锁实现安全读写。
shared_lock
shared_lock 是类似 unique_lock 的包装器,用做读锁。在 C++14 引入。
多个 shared_lock 可以同时加锁,会调用 mutex 的 lock_shared。
call_once
call_once 与 once_flag 共同使用,保证同一 once_flag 对应的函数调用,只会被执行一次,是线程安全的。
当函数执行成功时,反转 flag 避免多次执行;函数抛出异常时,传给 call_once 的调用方,不设置 flag。
例:
// x 只会被输出 1 次
std::once_flag flag;
std::call_once(flag, f, 1); // void f(int x)
std::call_once(flag, [&](int x) {
printf("lambda(%d)\n", x);
}, 1);
atomic
atomic 是原子变量。
提供了 load, store, fetch_add/sub/and/or/xor(和相关的 +=, -=...),前置/后置 自增/自减。
原子变量对于任何可用类型都支持,即使这个类型非常大(甚至几万字节)。但指令集肯定不能在这么多字节上做 CAS,所以编译器可能会生成其它指令来保证原子性,比如加锁,然后执行一系列非原子操作。
在这种情况下,compare_exchange发生伪失败的概率会大大提高,具体见下。
compare_exchange
https://stackoverflow.com/questions/25199838/understanding-stdatomiccompare-exchange-weak-in-c11
a.compare_exchange_weak/strong(exp, tar) 在 a 等于期望值 exp 时,将 a 赋为 tar 并返回 true;否则将 exp 设为 a 当前的值,返回 false。
weak 版本通常需要跟循环一起使用:
// 在当前线程读取 value 进行一系列操作的过程中,不能有其他线程修改 value
expected = value.load();
do desired = function(expected);
while (!value.compare_exchange_weak(expected, desired));
// (但这显然可能有 ABA 问题,需要注意)
// 如果 desired 表达式很简单,可以简化,如:
while (!value.compare_exchange_weak(expected, expected->next));
// 如果不在意是谁修改了 value,只要是预期值就行,可以提前结束
expected = false;
while (!value.compare_exchange_weak(expected, true)
&& !expected);
因为 weak 版本可能发生伪失败,也就是原子变量可能已经等于期望值,但由于上下文切换等原因,compare 返回了 false(不过概率并不高)。
strong 版本会进行额外检查,避免伪失败的发生,其内部通常也包括一个小循环,用于重试。
如果代码中不需要循环,一般没必要因为伪失败自己写一个循环,直接用 strong 版本即可。
如果本身就需要循环,那顺便使用 weak 版本允许伪失败的发生,可以提高效率。
如果伪失败发生概率很低,在某些平台上,使用 weak + 循环 可能也会提高效率。
实现一个无锁栈:
template<typename T>
class Stack
{
private:
struct Node
{
Node* next;
std::shared_ptr<T> data;
Node(const T& _data):
data(std::make_shared<T>(_data)) {}
};
std::atomic<Node*> head;
public:
void push(const T& data)
{
Node* new_node = new Node(data);
new_node->next = head.load(std::memory_order_relaxed);
while(!head.compare_exchange_weak(new_node->next, new_node,
std::memory_order_release,
std::memory_order_relaxed));
}
std::shared_ptr<T> pop()
{
Node* old_head = head.load();
while (old_head &&
!head.compare_exchange_weak(old_head, old_head->next));
return old_head ? old_head->data : std::shared_ptr<T>();
}
};
condition_variable
condition_variable 能够阻塞一个或多个线程,并允许其它线程通过通知唤醒它们。
wait 时需要传入一个绑定 mutex 的 unique_lock,cv 会解锁它,直到线程被唤醒、重新获得锁后,结束 wait(访问资源肯定是要加锁的,wait 唤醒时自动获取锁,不需要我们再去加锁)。
想要修改共享变量的线程需要:
- 获得一个 mutex(通常通过 std::unique_lock)。
- 在持有锁时进行修改。
- 在 cv 上执行 notify_one 或 notify_all。该步可以不持有锁。
等待 cv 的线程需要:
- 获得同样的 mutex(通常通过 std::unique_lock)。
- 检查状态是否已更新,如果没有,调用 wait 等待。
该步骤需通过循环进行,因为 cv 可能发生虚假唤醒(多个线程被唤醒,但只有一个能成功消费,其它继续等待),即线程从 wait 中被唤醒时,状态没有更新完毕。
例:
std::condition_variable cv;
bool flag = false;
void thread1()
{
std::unique_lock<std::mutex> lock(mu);
// do something...
flag = true; // 更新完成
cv.notify_one(); // 通知线程 2
}
void thread2()
{
std::unique_lock<std::mutex> lock(mu);
// !flag 时才等待。flag 为 true 时说明已经通知过了(数据已更新)
while (!flag)
cv.wait(lock); // 等待通知
// do something
}
生产者消费者:
std::queue<int> q;
std::mutex mu;
std::condition_variable cv;
void producer(int n)
{
for (int i=0; i<n; i++)
{
// 加锁,操作队列
std::unique_lock<std::mutex> lock(mu);
q.push(i);
// 通知
cv.notify_one();
// 如果没有通知,只能通过延时控制同步
// std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
}
void consumer()
{
while (true)
{
// 加锁,操作队列
std::unique_lock<std::mutex> lock(mu);
// 如果没有数据,等待
// 唤醒后,确保真的有数据,再进行下一步消费
while (q.empty())
cv.wait(lock);
assert(!q.empty());
int data = q.front(); q.pop();
printf("consumer get %d\n", data);
// 如果没有通知,只能通过延时控制同步
// std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
}
thread_local
thread_local 定义的全局变量,是线程私有的(会在创建线程时拷贝?)。
memory order (内存序)
见 基础 - 多处理器编程 - memory order。
其它
大数组的读写会更慢吗
好像数组的大小一般不太重要,除了关注连续性外(单个数组通常比多个数组更好),更应该关注代码的可读性和易维护性,所以分配一个大数组是可以的(前提是真的必须,并没有多少这样的需求)。
大数组通常是一个结构体的数组。
如果需要遍历结构体数组中的某个元素,为了提高连续性,可能需要把 AoS (结构体数组,Arrray of Structs) 替换为 SoA (包含数组的结构体, Struct of Array)。
比如:对于struct A{Other o; int x;} a[N],如果要经常遍历a[i].x,即for(i=0; i<N; i++) f(a[i].x); ,可能将所有 x 定义成一个连续数组更好,如:struct SoAofA{Other o[N]; int x[N];} a;,遍历a.x[i]。
结合 SoA,另一种可能的优化是:将冷热字段分成单独两个结构体,分开存储。
例:
struct ind_vec_cold {
// Cold fields not accessed frequently.
float ind_1[4096];
float ind_2[4096];
};
struct ind_vec_hot {
// Hot fields accessed frequently.
int n;
};
struct ind_vec {
ind_vec(int n): hot(n), cold(n) {}
vector<ind_vec_hot> hot;
vector<ind_vec_hot> cold;
};
ind_vec data(NT);
for(int i = 0; i<100; i++){ // Not slow anymore!
data.hot[i].n = 1;
}