本篇笔记将记录 Cherno 的 C++ 系列的所有令初学 C++ 者眼前一亮的知识点,而简单的语法知识和基本操作在此不做笔记,强烈建议新手完整地观看全系列教程。
注意,每 P 的知识点不是孤立的,可能会在后面更加深入地、全面地拓展讲解,有些简单的知识点可能会更多地讲底层和优化,因此都是值得认真学习和细细琢磨的。
P.72 预编译头文件
当我们每次 #include <vector>
时,编译器都需要读取整个 vector
头文件并编译他,不仅如此, vector
还会包含其他的文件,这些文件也要拷贝到 vector
中,一个文件可能就要十几万行代码,解析这些文件并以某种形式标记,在你想要编译的 main 文件之前把这些都编译,因为 vector
的内容也要都拷贝到你的 main 文件中。所有的代码每次都要被解析和编译,当你有很多的文件都包含了 vector
,每个文件都会单独地包含并拷贝后编译,最后再链接在一起。重点是,每次你对 C++ 文件修改,整个文件都需要重新编译,那些包含地诸如 vector
这样的头文件要从头拷贝到你的文件中,重新解析再编译。
使用预编译头文件可以抓取一堆头文件,并转换为编译器可以使用的格式,而不用一遍遍地读取这些头文件。一堆代码只要编译一次,并以二进制格式存储,这对编译器是极为友好的,能大大加快编译时间。但要记住,不要把频繁更改的文件放入预编译头文件中,否则该文件每次修改都要重新编译到预编译头文件中,整个所有的预编译头文件都要从头开始构建。因此预编译头文件常用于外部依赖,诸如 std 库、Windows API 等等,因为它们是如此的庞大但你又不会修改它们。
被编译到 PCH 的文件它会被包含在你写的 cpp 文件中,你就无需频繁地写包含语句了。 PCH 所做地就是把所有的东西都塞进 PCH 中,他可能会隐藏实际正在使用地东西,这会导致在模块化和代码重用方面变得困难,因为你看不出一个 cpp 文件他包含了什么依赖。因此预编译文件通常会将常用的 C++ 库、 std 库和 Windows API 等等包含。
P.73 dynamic_cast
C++ 的类型系统并不是一个强制的系统,只是 C++ 提供给我们的一种保护代码的方法。dynamic_cast
是专门用于沿继承层次结构进行的强制类型转化。其常用于继承结构下对象的类型转换验证,当无法转换时会返回一个空指针。
如下代码所示,当一个子类对象被隐式转换赋值给了父类变量,再想赋值给一个子类变量就不被允许了。你可以试图使用 C 风格的显示转换,虽然编译器不会报错,但当你转换的是一个持有其他子类对象 Enemy
的父类变量呢?那么如果你试图调用 Enemy
独有的变量或方法时程序就会崩溃。而 dynamic_cast
就可以检查这一情形,为你返回一个空指针。
class Entity{};
class Player:public Entity{};
class Enemy:public Entity{};
int main(){
Player* player = new Player();
Entity* e = player;
Player* p = (Player*)e;
Player* p = static_cast<Player*>(e);
Player* p = dynamic_cast<Player*>(e);
}
dynamic_cast
要求传入的参数类型必须是一个多态的类型,这要求我们的父类必须拥有一个虚函数表,即必须要有一个虚函数存在,因为这意味着子类有需要重写的东西,类型绝对是一个多态的类型。dynamic_cast
之所以能做到这一点,能分辨出变量的类型,是因为他存储了运行时类型信息 RTTI(Runtime Type Information),显然这会增加一些开销。而 dynamic_cast
也需要时间来检查类型信息是否匹配。
P.75 结构化绑定
C++17 带来的新特性结构化绑定,使得使用元组处理多个返回值的情况时变得好用了。
#include <tuple>
std::tuple<std::string, int> CreatePerson{return{"Cherno",24};}
int main(){
auto[name, age] = CreatePerson();
}
P.77 单一变量存放多类型的数据
C++17 提供的新特性 variant
和 union
很像。但和 union
不同的是,variant
实际上是为你创建了一个结构体或者类,并将你所给定的数据类型存储为那个结构体或类中的成员,也就是说,union
是将数据存储在同一块内存,并给出不同的类型解释,variant
则是将数据按照给定类型每种都拷贝一份在内存中并按给定类型解释。从技术上将,union
更有效率、更好的,但 variant
更加地类型安全,不会造成未定义行为。除非是在做底层优化,试图将内存大小保持一个很低的水平,我们应该使用 variant
而不是 union
。
#include <variant>
int main(){
std::variant<std::string, int>data;
data = "Cherno";
std::cout << std::get<std::string>(data) << std::endl;
data = 2;
std::cout << std::get<int>(data) << std::endl;
if(auto value = std::get_if<std::string>(&data)){
std::string& v = *value;
}
}
variant
也和 optional
很像,但 optional
只能返回一个默认的空值,variant
则可以自定义类型,比如一个枚举类型,用来应对更多情形。
enum class ErrorCode{
None = 0, NotFound = 1, NoAccess = 2
}
std::variant<std::string, ErrorCode> ReadFileAsString(){
return {};
}
P78. 存储任意类型的数据
variant
像是一个类型安全的 union
,any
在小类型时将类型存储为一个 union
,大类型时会分配一个 void*
指针,以动态分配内存。也就是说在小类型诸如 int
和 float
等等,使用 any
还是 variant
都可以。
如果需要更多空间,any
会动态分配,但 variant
不会。换句话说,variant
除了更加地类型安全和一些好的限制性,其在处理较大的数据时也会执行的更快,因为不会发生动态的内存分配。
any
在获取数据时,如果为了提高性能,避免优化,可以使用引用的形式,但是当你使用诸如 std::any_cast<std::string>
的形式获取时会发现不能运行,你需要将方法的模板类型也改为引用的形式。在需要分配的字节不超过 32 时(不同平台的实现会不同),any
就不会分配任何内存,如果超过了,就调用new
。
P80. 如何让 C++ 字符串更快
重写 new
操作符,对分配内存次数 s_AllocCount
进行统计,当我们调用函数,传入 std::string
类型对象引用时,打印结果显示,分配了 8 字节的内存,并在堆上分配了一次。当我们创建字符串时,会导致一次堆分配,这来自于 std::string
的底层 basic_string
,可以看到这个函数调用了 _Allocate
函数在堆上分配内存。如果我们不传入 std::string
对象,而是直接在函数中写上 cosnt char
类型的字符串字面量,也会发生一次分配,即使函数接受的是一个 const
引用,但仍然要为我们构造一个 std::string
,构造的过程仍需要分配内存。
static unit32_t s_AllocCount = 0;
void* operator new(size_t size){
s_AllocCount++;
std::cout << "Allocating " << size << " bytes\n";
return malloc(size);
}
void PrintName(const std::string& name){
std::cout << name << std::endl;
}
int main(){
std::string name = "Yan Chernikov";
PrintName(name);
PrintName("Yan Chernikov");
std::string firstName = name.substr(0,3);
std::string lastName = name.substr(4,9);
std::cout<< s_AllocCount << "allocations" << std::endl;
std::cin.get();
}
当我们调用下几次 std::string
的函数,如下所示会在堆上分配三次,如果在一个实时运行的程序,诸如游戏等,每帧都在做这种事情,它就会堆积起来损害你的帧速率。实际上,我们并不需要新建一个字符串去接收处理结果,因为结果就来自于 std::string
变量本身,我们只需要一个窗口能以特定视角观察这个字符串的视图。而这个视图在 C++ 17 中被实现,std::string_view
就是 C++ 17 中的一个新类,其本质上只是一个指向现有内存的指针,换句话说就是一个 const char
指针,指向其他人拥有的字符串,再加上一个大小 size
。例如 name.substr(0,3)
完全可以表述为一个指向第一个字符、大小为 3 的指针,再次运行,我们会发现只分配了一次堆内存。
std::string name = "Yan Chernikov";
std::string firstName = name.substr(0,3);
std::string lastName = name.substr(4,9);
std::string_view firstName(name.c_str, 3);
std::string_view lastName(name.c_str + 4, 9);
实际上还能做到一次都不分配。使用 const char*
来声明字符串字面量传入,这种形式声明的字符串前面有的视频中有提到会在常量区域存储字符串。此时再去使用 std::string_view
来构造视图,就不会在堆上进行分配了。
const char* name = "Yan Chernikov";
std::string_view firstName(name.c_str, 3);
std::string_view lastName(name.c_str + 4, 9);
函数的参数类型也是,即使你使用 const char*
来声明字符串字面量来传入 std::string
类型的参数,依旧会构造一次、分配一次堆,但如果把函数参数类型也改为 std::string_view
就不会一次都不分配了。
void PrintName(const std::string& name){
std::cout << name << std::endl;
}
int main(){
PrintName("Cherno");
}
P83. 小字符串优化
当字符串在不超过 15 个字符时(根据平台不同可能不同),会将字符串分配在栈缓冲区,不会在堆上分配。std::string
的背后是 basic_string
,其会调用 assign
函数,经过层层重定向,我们可以看到当数量小于指定数值 _Myres
时,就会将字符串的指针指向内存缓冲区分配的内存,而不必在堆上进行分配了。如果没通过这个大小测试,那么就会调用 Reallocate_for
函数,其最终调用了一个 allocate
函数在堆上分配内存。
P85. 左值和右值
左值拥有自己内存地址,可以访问该地址。右值只是一个临时变量,这意味着右值只能读不能写。可以将左值理解为一个装着球的盒子,右值就只是一个球。右值和左值均可以赋值给左值,而左值不能赋值给右值。因此我们以可以对一个有地址的左值创建他的引用,而不能对没有地址的右值创建引用。
//右值和左值均可以赋值给左值
int i = 10; //√
int a = i; //√
10 = i; //×
int& ref = i; //√
int& ref = 10; //×
除了字面量是右值,诸如函数的返回值也属于右值。但是如果函数返回的是左值引用则不一样了。只需要保证返回的变量不会在当前函数栈结束后销毁即可,例如静态的局部变量,在数学计算上如向量归一化,常常对 *this
直接操作,然后返回 *this
左值引用。
int& normalize{
*this = (*this)/length();
return *this;
}
对于函数,其参数接受左值和右值,将参数改为左值引用后则只接受左值。但是如果是 const
修饰的左值引用函数参数,则可以接受右值,这里编译器会发生一次转换。因此对于函数可以为参数加 const
修饰以兼容左值和右值。除了左值引用还存在右值引用,使用右值引用作为函数参数,则该参数只接受右值传递。
经过测试,如果多种参数类型的函数同时存在,右值会优先调用右值引用传递的函数,左值以及左值引用会优先调用左值引用传递的函数。如果只存在 const 修饰的左值引用传递函数,则都会调用该函数
void Test(std::string& str){std::cout<<"std::string&"<<std::endl;}
void Test(const std::string& str){std::cout<<"const std::string&"<<std::endl;}
void Test(std::string&& str){std::cout<<"std::string&&"<<std::endl;}
int main(){
const std::string constStr = "const";
std::string str = "hello";
std::string& ref = str;
Test(constStr);//const std::string&
Test(str);//std::string&
Test(ref);//std::string&
Test("world");//std::string&&
}
P88. 参数计算顺序
类似如下的情形,函数接收的实参调用了操作符,这种属于未定义地行为,会由于编译器地不同而变化,这完全依赖于编译器如何处理这种情况。
对于启用 C++14 或更老的标准的 MSVC 编译器,在 Debug 模式下,后缀自增函数右边的 i
先传入为 0
再自增,于是左边的 i
传入为 1
。但在 Release 模式下,但后会由于编译器会进行常量展开,先将函数中的参数尽可能地替换为常量,在例子中即两个 i
都被替换为 0
,然后再进行。对于前增函数,无论是 Debug 还是 Release 式,都是先进行了两次自增后传入 2
。
C++17 标准规定后缀表达式必须在其他表达式之前进行计算,因此启用 C++17 标准后,以上情形都将是先计算函数参数,但他们的顺序仍然是不确定的,在MSVC和gcc中,先传入右侧参数为 ,在 clang 中,则是先传入左边参数即 ,并且 gcc 和 clang 都会对这种写法提出警告,这是未定义的行为。
int i = 0;
Print(i++,i++);
int i = 0;
Print(++i,++i);
P89. 移动语义
移动语义是以右值引用为基础的,这是 C++11 的新语法。将一个对象传给函数时,你需要在传参时构造一个临时的对象然后复制到正在调用的函数中。而我们实际上只需要把构造的临时对象直接拿来用不久可以了吗,因此移动语义就像是拿一个盒子去扣住一个球,而不是复制一个球再放进盒子里。
对于一个拷贝构造函数,需要传入左值引用,并将涉及到堆的成员变量进行深拷贝。而移动构造函数,只需要将传入的右值引用的各成员变量逐个赋值,无需深拷贝。并且需要将右值引用的值设为 nullptr
或 0
,等待销毁即可。
String(const String& other){
size = other.size;
data = new char[size];
memcpy(data, other, size);
}
String(String&& other){
size = other.size;
data = other.data;
other.data = nullptr;
other.size = 0;
}
P90. std::move
与移动赋值操作符
在实际使用中,右值引用的 _name
会退化为左值,因此依旧会发生深拷贝,因此需要在使用 _name
时强转为右值引用,为了优雅且灵活的实现这一目的,提供了 std::move
以方便转换引用类型。
Entity(String&& _name):name(_name){}
Entity(String&& _name):name((String&&)_name){}
Entity(String&& _name):name(std::move(_name)){}
除了构造时需要,在赋值时也可能需要将一个已经存在的对象移动给另外一个,因此需要移动赋值运算符重载。但是还需要再移动前判断传入的参数是否为本身。
String& operator=(String&& other) {
printf("Moved!\n");
if (this != &other) {
delete[] m_Data;
m_Size = other.m_Size;
m_Data = other.m_Data;
other.m_Data = nullptr;
other.m_Size = 0;
}
return *this;
}
在 C++ 类型设计中,如果需要实现析构函数,就一定要正确地实现拷贝构造器和赋值运算符重载,这被称为三法则,如果再加上移动构造函数和移动赋值运算符重载,称为五法则。