ChernoCPP-2
Back to Top
Video Links
Notebook Links
- Cherno (thecherno.com)
- 📘 The Cherno’s C++ Course Notes 📘
- 神经元猫的个人空间-神经元猫个人主页-哔哩哔哩视频 (bilibili.com)
- cppreference.com
- 如何在两周内学会C++并构建优质的项目 | Atomlab
- Bilibili | C++ 工程师|面经汇总|2020.12| - 力扣(LeetCode)
- 如何学习C++ (zhangjiee.com)
- CppGuide社区
47. Dynamic Arrays in C++
所以C++提供给我们一个叫做
Vector
的类,这个Vector在std namespace(std命名空间中)。为什么叫Vector?可以在链接中了解到背后的故事: 它被称为向量是因为标准模板库的设计者 Alex Stepanov 在寻找一个名称以区分它与内置数组时采用了这个名字。他现在承认这是一个错误,因为数学已经使用术语 “向量” 来表示一组固定长度的数字序列。而 C++11 则进一步加重了这个错误,引入了一个名为 ‘array’ 的类,它的行为类似于数学上的向量。 Alex 给我们的教训是:在给事物命名时要非常小心谨慎。
所以它其实不应该被叫做Vector(向量),而是应该被称为类似ArrayList,这样更有意义,因为它本质上是一个动态数组。它有点像一个集合,一个不强制其实际元素具有唯一性的集合。 换句话说,它基本上就是一个array(数组),不过与C++普通数组类型(原始数组或标准数组类[31 Arrays in C++](https://nagi.fun/Cherno-CPP-Notes/1-50/31 Arrays in C%2B%2B/))不同的是,它可以调整数组大小,这意味着当你创建这个vector的时候,这个动态数组的时候,它并没有固定大小。你可以给它一个固定大小,如果你想用一个特定的大小初始化它。但一般情况下我们不给它设置一个size。 你只需要创建这个Vector然后把元素放进去,每次你往里面放一个元素,数组大小会增长。
原理:当你超过分配的内存大小时,它会在内存中创建一个比第一个大的新数组,把所有东西都复制到这里,然后删除旧的那个,这样你就拥有了更多存储空间的新数组。(所以可以猜测Alex当时是觉得动态数组可以像向量一样无限延长……)
[47 C++的动态数组(std::vector) - cherno-cpp-notes (nagi.fun)](https://nagi.fun/Cherno-CPP-Notes/1-50/47 Dynamic Arrays in C%2B%2B (std - -vector)/)
1 |
|
48. Optimizing the usage of std::vector in C++
std::vector class
基本上是这样工作的: 你创建一个 vector,然后你开始push_back
元素,也就是向数组中添加元素。如果 vector 的容量不够大,不能容纳你想要的新元素。vector 需要分配新的内存,至少足够容纳这些想要加入的新元素和当前已有的内容,从内存中的旧位置复制到内存中的新位置,然后删除旧位置的内存。 所以当我们尝试push_back
一个元素的时候,如果容量用完,它就会调整大小,重新进行分配——这就是将代码拖慢的原因之一。事实上,我们需要不断地重新分配,which is a 缓慢的操作,我们需要重新分配当我们要复制所有的现有元素的时候,这是我们要避免的。 事实上,这就是我们现在对于复制的优化策略:我们如何避免复制对象,如果我们处理的是 vector,特别是基于 vector 的对象(我们没有存储 vector 指针,我们存储的是 vector 对象)。
1 |
|
- 如果我们了解自己的“环境”,就是如果我们知道本身计划要放进 3 个 vertex 对象,为什么不让 vector 一开始就留下足够 3 个元素的内存,这样就不用调整两次大小了。 ->
vertices.reserve(3);
这就是第二种优化策略。 - 我们所做的就是将 vertex 从 main 函数复制到 vector 类中,如果我们可以再适当的位置构造那个 vertex,在 vector 实际分配的内存中,这就是优化策略一号。在这种情况下,不是传递我们已经构建的 vertex 对象,而是只是传递了构造函数的参数列表,它告诉我们的 vector:在我们是的 vector 内存中,使用以下参数来构造一个 vertex 对象。
1 |
|
49. Using Libraries in C++(Static Linking)
[49 C++中使用库(静态链接) - cherno-cpp-notes (nagi.fun)](https://nagi.fun/Cherno-CPP-Notes/1-50/49 Using Libraries in C%2B%2B (Static Linking)/)
静态链接与动态链接
静态链接
- 静态链接意味着这个库会被放到你的可执行文件中(它在你的 exe 文件内部,或者其它操作系统下的可执行文件内)。
动态链接
- 动态链接库是在运行时被链接的额,所以你仍有一些链接,你可以选择在程序运行时装载动态链接库,有一个叫loadLibrary的函数,你可以在 WindowsAPI 中使用它作为例子。它会载入你的动态库,可以从中拉出函数然后开始调用。你也可以在应用程序启动时加载你的 dll 文件,这就是你的Dynamic Link Library(动态链接库)。
- 所以主要的区别就是:库文件是否被编译到 exe 文件中,或链接到 exe 文件中,还是一个在运行时单独的文件,你需要把它放在你的 exe 文件旁边或某个地方,然后你的 exe 文件可以加载它。因为这种依赖性,你需要把 exe 文件和 dll 文件弄在一起。
- 所以通常喜欢用静态的。静态链接在技术上更快,因为编译器或链接器实际上可以执行链接时优化之类的。静态链接在技术上可以产生更快的应用程序,因为有几种优化方法可以应用,因为我们知道在链接时要链接的函数。而对于动态库,我们不知道会发生什么而必须保持它的完整,当动态链接库被运行时的程序装载时,程序的部分将被补充完整。
所以通常情况下,静态链接是更好的选择。
静态链接实例
在Visual Studio中,需要对Solution Property进行修改。参考:[49 C++中使用库(静态链接) - cherno-cpp-notes (nagi.fun)](https://nagi.fun/Cherno-CPP-Notes/1-50/49 Using Libraries in C%2B%2B (Static Linking)/)
50. Using Dynamic Libraries in C++
动态链接发生在runtime(运行时),而静态链接是在编译时发生的。 当你编译一个静态库的时候,将其链接到可执行文件,也就是应用程序,或者链接到一个动态库。就像你取出了那个静态库的内容,然后你把那些内容放入到其它的二进制数据中,实际在你的动态库中或者在你的可执行文件中。
有很多地方可以优化,因为编译器和链接器现在完全知道静态链接时实际进入应用程序的代码(静态链接允许更多的优化发生)。 而动态链接发生在运行时,所以只有你真正启动你的可执行文件时,你的动态链接库才会被加在,所以它实际上不是可执行文件的一部分(运行时将一个额外的文件加载到内存中)。
现在可执行文件在实际运行前就需要具备某些库、某些动态库、某些外部文件,这就是为什么你在Windows上启动一个应用程序时,可能看到一个错误消息弹出:需要dll、没有找到dll……. 这是动态链接的一种形式,可执行文件知道动态链接库的存在,把动态库作为一项需要,虽然动态库仍然是一个单独的文件,一个单独的模块,并在运行时加载。你也可以完全动态地加载动态库,这样可执行文件就与动态库完全没有任何关系了,但是在你的可执行文件中,你可以查找并在运行时加载某些动态库,然后获得某些函数指针或者动态库里你想要的东西,然后使用那个动态库。
对于动态库,请记住两个版本。 第一个是“静态的”动态库的版本,我的应用程序现场需要这个动态链接库,我已经知道里面有什么函数,我可以用什么。 第二个版本是我想任意加载这个动态库,我甚至不需要知道里面有什么,但我想取出一些东西或者做很多事。 这两种动态库都有很好的用途,先专注看第一种:我知道我的应用程序需要这个库,但我要动态地链接它。
如果你要对比静态和动态链接的话,对于函数之类的声明,动态链接时实际有些不同。但GLFW像大多数库一样,同时支持静态和动态链接,使用相同的头文件。
见上节课,.dll
和dll.lib
同时编译是非常重要的,因为如果你尝试使用不同的静态库,在运行时链接到dll,你可能会得到不匹配的函数和错误类型的内存地址,函数指针不会正常工作。
51. Making and Working with Libraries in C++(Multiple Projects in Visual Studio)
[51 C++中创建与使用库 - cherno-cpp-notes (nagi.fun)](https://nagi.fun/Cherno-CPP-Notes/51-100/51 Making and Working with Libraries in C%2B%2B (Multiple Projects in VS)/)
52. How to Deal with Multiple Return Values in C++
[52 C++中如何处理多返回值 - cherno-cpp-notes (nagi.fun)](https://nagi.fun/Cherno-CPP-Notes/51-100/52 How to Deal with Multiple Return Values in C%2B%2B/)
1. 指针和引用
1 |
|
2. array和vector
Array和vector的区别:array会在栈上创建,而vector会把它的底层存储在堆上,所以从技术上讲返回
std::array
会更快。注意:多返回值需要是同类型
1 |
|
3. tuple和pair
tuple基本上是一个类,它可以包含x个变量,但不关心类型,
1 |
|
4. Struct⭐ (Cherno推荐使用的方法)
1 |
|
53. Templates in C++
1. 函数重载
1 |
|
2. 模板
1 |
|
模板只有在它被调用时才会创建,否则它根本就不存在。你甚至在里面有语法错误,只要不调用都不会影响编译。(视频中的VS2017是这样的,但目前VS2022中的错误就算不调用也会影响编译。)
3. 用在类上的模板
1 |
|
4. 哪里用模板?哪里不用
本部分完全是主观的,仅供参考。 很多游戏工作室或软件公司实际上禁止使用模板,但模板非常有用,比如在日志系统或者其它的使用场景下,你想记录每一种可能的类型,难道你真的要为每个函数都进行重载吗?你可以用模板自动完成,也就是你可以让编译器基于一些规则为你写代码。 这是说,你可以深入使用模板来做一些很好的事,但如果你的模板变得非常复杂,你开始让它为你生成一个完整的元语言就有点过头了。因为这里有一个平衡点,当模板变得越来越复杂时,没人能搞懂它是做什么的了,你得花大量时间弄清楚哪些代码已经被编译了以及你的模板到底发生了什么而不能工作。这种情况说明你做的过头了。
Cherno 的观点是:手动做一些事,自己写代码实际上会让你和你的团队受益更多,而不是试图创建庞大的模板魔法般地为你做所有事。所以模板不应该被完全禁止,但也不应该滥用。比如游戏引擎的日志系统和材质系统肯定会用到模板,当你有一个可以包含各种不同类型的统一缓冲区时,模板在一定程序上是非常有用的。
54. Stack vs Heap Memory in C++
1. 什么是栈和堆?
应用程序启动后,操作系统要做的就是将整个程序加载到内存,并分配一大堆物理 RAM(随机存取存储器:Random Access Memory)以便我们的实际应用程序可以运行。 栈和堆是 RAM 中实际存在的两个区域: 栈stack通常是一个预定义大小的内存区域,通常约为 2 兆字节(2MB)左右; 堆heap也是一个预定义了默认值的区域,但是它可以增长,并随着应用程序的进行而改变。 重要的是要知道这两个内存区域的实际物理位置都是在 RAM 中,很多人倾向于认为栈存储在 CPU 缓存中或类似的位置,它确实因为我们不停访问而在缓存中活跃,但不是所有的栈内存都会存储在这里,这并不是它的工作方式。只要记住这两个内存区域的实际位置都在我们的内存中,这就是为什么内存中有两个不同区域的原因。
2. 栈与堆的内存分配
我们的程序中,内存是用来存储运行程序所需的数据的,不管是从局部变量还是从文件中读取的东西。而栈和堆就是我们可以存储数据的地方。 它们的工作原理非常不同,但本质上做的事情是一样的:我们可以要求 C++从栈或者堆中给我们一些内存,顺利的话它会给我们一个要求大小的内存块。而不同之处在于,它会如何分配内存。
1 |
|
栈分配
因为 debug 模式下在变量周围添加了safety guards,以确保我们不会溢出所有变量,在错误的内存中访问它们等问题。所以在内存中这些变量的存储位置都很接近,因为实际发生的是:当我们在栈中分配变量时,栈顶部的指针就移动这个变量大小的字节。分配一个 4 个字节的整数,栈顶指针就移动 4 个字节。内存实际上是像栈一样相互叠加存储的,现在在大多数栈的实现中,栈是倒着来的。这就是为什么你看图中会发现:第一个 int value 存储在更高的内存地址,后续的 array 和 vector 在旁边存储在较低的内存地址,因为它是反向生长的。
栈的做法就是把东西叠在一起,这就是为什么stack allocation(栈分配)非常快,它就像一条 CPU 指令,我们所做的就是移动栈指针,然后返回栈指针的地址。我如果要分配一个整数,我要反向移动栈指针 4 个字节,然后返回那个内存地址,因为这是 4 个字节块的开始。
栈中分配内存时,一旦这个作用域结束,你在栈中分配的所有内存都会被弹出,内存被释放。
堆分配
堆分配的内存不会紧挨着,在堆中分配new
后要调用delete
关键字来释放内存,用[智能指针](https://nagi.fun/Cherno-CPP-Notes/51-100/54 Stack vs Heap Memory in C%2B%2B/44 SMART POINTERS in C++.md)的make
也一样会帮你调用关键字,所以我们需要手动去释放内存。
3. new关键字实际上做了什么?
new
关键字实际上调用了一个叫做malloc
的函数(memory allocate)的缩写,这样做通常会调用底层操作系统或平台的特定函数,这将在堆上为你分配内存。当你启动应用时,你会被分配到一定数量的物理 RAM,而你的程序会维护一个叫free list(空闲列表)的东西,它的作用是跟踪哪些内存块是空闲的并储存它们的位置。当你使用malloc
请求堆内存时,它可以浏览空闲列表,找到一块符合大小要求的内存块,然后返回你一个它的指针,并记录分配的大小和它现在是否被分配的情况(这样你就不能使用这块内存了)。
这里想说的重点是,在堆上分配内存是一大坨事情,而在栈上分配内存就像一条 CPU 指令。这两种主要内存的区别就是分配方式的区别,可以从汇编指令中看到,声明变量时栈分配的指令就一两行,而堆分配就是一大段指令了,之后还要调用delete
,这又是大段指令。
所以事实上,如果可能的话你应该尽量在栈上分配内存。在堆上分配的唯一原因是如果你不能在栈上分配,比如你需要让它的声明周期比你在处理的作用域更长,或者你特别需要更多的数据,比如我想加载一个 50MB 的纹理材质,这就不适合在栈上分配,因此你不得不在堆上分配。
性能的不同是因为分配方式,所以理论上如果你在运行你的程序前在堆上预先分配一个内存块,然后从这个预先分配的内存块中进行堆分配,那栈、堆分配就基本一样了,你唯一可能要处理的就是cpu cache miss的问题(缓存不命中),但 miss 的数量可能不够造成麻烦。所以当你调用new
时,你需要检查 free list,请求内存再记录所有内容,这就是堆相比于栈慢的地方,而实际的访问(CPU、缓存)通常可以忽略不计
55. Macros in C++
- 预处理
带有#
的为preprocessor statement,即预处理指令。 该类指令发生在真正的编译之前,当编译器收到一个源文件时,做的第一件事情就是预处理所有预处理指令。
预处理阶段基本上是一个文本编辑阶段,在这个阶段我们可以控制给编译器什么代码,这就是macro(宏)的用武之地了。 我们能做的就是写一些宏,它将代码中的文本替换为其它东西,这基本就像遍历我们的代码然后执行查找和替换。 (所以模板会比宏评估得更晚一些)
你使用宏的方式取决于你的个人爱好,如果你用了很多宏,代码可能会比较难理解。不要用太多的 C++特性,尤其是当我们进入更高级的特性时,你不需要向所有人炫耀你知道所有的 C++特性,用更多的特性也不是写好代码的方式。
- “宏”举例
1 |
|
1 |
|
1. 使用宏区分Debug和Release
2. 多行宏定义
1 |
|
56. The AUTO keyword in C++
有一种方法可以让 C++自动推导出数据的类型,不管是在创建、初始化变量数据时,还是在将一个变量对另一个变量进行赋值时。
1 |
|
这样如果api发生改变时,比如 GetName 的返回类型改为了char*
,客户端不需要任何改动。但是坏处是我也不知道 api 已经改变了,它可能会破坏依赖于特定类型的代码。
什么时候适合用 auto?
- 迭代器
1 |
|
- 类型相当大
1 |
|
57. Static Array in C++(std::array)
1. 静态数组
1 |
|
2. 静态数组和普通数组异同
std::array
和普通数组在内存上形式是一样的,都在栈上分配,不像std::vector
类是在堆上分配的。
但是std::array
有边界检查(仅在 Debug 模式下),在最优化的情况下和普通数组性能是一样的。
std::array
实际上不存储自己的 size,size 是你给它的一个模板参数,这意味着调用 size function 直接返回 5 而不是返回一个存储在内存中的 size 变量
可以看到边界检查是在一个宏中的,这意味着只有在那个调试级别才会发生,如果等级为 0 则返回跟 C 语言数组工作方式一样的。
你应该开始选择使用std::array
而不是 C 语言风格数组,因为它增加了一层调试(在你期望对代码保护时),而且也没有性能成本,还可以让你记录数组的大小。
58. Function Pointers in C++
把函数传给变量;将函数作为参数传递给其他函数
- 函数指针举例1
1 |
|
- 函数指针举例2
1 |
|
1 |
|
这里的[]
叫做capture method(捕获方式),也就是如何传入传出参数,后面会介绍更多。
59. lambda in C++
lambda本质上是我们定义一种叫做匿名函数的方式,用这种方法不需要实际创建一个函数,就像是一个快速的一次性函数,我们更想将它视作一个变量而不是像一个正式的函数那样,在我们编译的代码中作为一个符号存在。
只要你有一个函数指针,你都可以在C++中使用lambda,这就是它的工作原理,所以lambda是我们不需要通过函数定义就可以定义一个函数的方法。 lambda的用法是,在我们会设置函数指针指向函数的任何地方,我们都可以将它设置为lambda。
lambda是一个指定一个函数未来想要运行的代码的很好的方法。
1. capture
如果我们想把外部变量放到lambda函数内部的指令中呢? 和我们创建自己的函数其实一样,都是有两个方法:值传递和引用传递,这也就是捕获这一块的东西,[]
就是我们打算如何传递变量。([=]
,传递所有变量,通过值传递;[&]
传递所有变量,通过引用传递) 还可以只传入单独的变量,[a]
通过值传递传入a,[&a]
通过引用传递。
1 |
|
60. Why I don’t use “using namespace std”
1. 什么是 using namespace?
就像上文中的代码用了很多标准库的内容,如果在代码前面加上
1 |
|
就可以直接写 vector,find_if 了,看上去代码更干净一点。 还可以把它限制在作用域中,比如写到 main 函数的第一行,这样 main 函数中调用标准库就不用写”std::“了。
所以using namespace
可以非常有用,如果你在处理很长的命名空间,或是你有自己的命名空间,自己的项目文件中的符号都在这个命名空间中,你可以使用这个。
但是我个人不喜欢using namespace std
2. 为什么不喜欢
第一眼看上去代码是干净了,但是如果看原始代码,可以发现你很容易就能指出代码中使用的是 C++标准模板库(带有 std 前缀的)。如果用了using namespace std
,就相对而言有点难分辨了。如果你也用标准库喜欢用的snake case(蛇形命名法,如 find_if),就很难区分到底是不是 std 中的。
1 |
|
这并不是 orange 在 apple 后导致的,而是因为其它原因。“Hello”其实是一个 const char[]数组,而不是一个 string,如果只有 apple 命名空间,会在 apple::print()中做一个[隐式转换](https://nagi.fun/Cherno-CPP-Notes/51-100/60 Why I don’t using namespace std/40 Implicit Conversion and the Explicit Keyword in C++.md#^cde452),将 const char 数组转换为 string 对象。但是引入 orange 命名空间后,orange::print()匹配度更高,因为它的参数本来就是一个 const char*,不需要隐式转换。
如果我们不用using namespace
,而是简单地引入另一个库apple::print()
就不会有这样的运行时错误。
另一个要百分百避免的就是在头文件中使用 using namespace,永远不要这样做,把这些命名空间用在了你原本没有打算用的地方,谁知道它会 include 什么呢?任何大型项目中追踪起来都是很困难的,所以绝对不要在头文件中使用 using namespace!
61. Namespaces in C++
[61 C++的名称空间 - cherno-cpp-notes (nagi.fun)](https://nagi.fun/Cherno-CPP-Notes/51-100/61 Namespaces in C%2B%2B/)
62. Threads in C++
本节课讲的是threads(线程),也就是讲我们如何进行parallelization(并行化)。现在大多数计算机或处理器等设备都有不止一个逻辑处理线程,当我们进入更复杂的项目时,将某些工作移动到两个不同的执行线程会对我们非常有益。不仅仅是为了提高性能,也是我们还能用它做些什么事。
1 |
|
这就是一个简单的 C++多线程例子。代码的主要工作流程如下:
- 全局的
s_Finish
标记声明为static
,以限制其在当前源文件中的作用范围。 DoWork
函数是一个线程例程。它进入一个无限循环,每秒打印一次”Working…”,直到s_Finish
标志被设置为true
。- 在
main
函数中,创建了一个名为worker
的新线程。DoWork
函数被作为参数传递给线程的构造函数,表示应在新创建的线程中运行此函数。 std::cin.get();
语句是一个阻塞调用,它等待用户按回车键。- 一旦按下回车键,
s_Finish
标志被设置为true
,这导致DoWork
函数跳出其循环并返回。 worker.join();
语句用于等待线程完成其执行,然后程序才能继续。确保线程在主线程(在这种情况下,是程序)结束之前完成执行是至关重要的。如果程序在worker
线程仍在运行时结束,那么它将被突然终止,这可能导致各种问题,如资源未被正确释放。
线程很重要,它们对于加速程序非常有用,主要目的是优化,还可以做例如上面例子中这些事情。
63. Timing in C++
我们如何计算完成某个操作或者执行某个代码所需要的时间呢?
计时对很多事情都很有用,不论你是希望某些事情在特定时间发生,还是只是评估性能或做基准测试,看你的代码运行得有多快,你需要知道应用程序实际运行的时间。
有几种方法可以实现这一点,C++11 之后我们有了“chrono”,它是 C++库的一部分,不需要去使用操作系统库。但在有 chrono 之前,如果你想要高分辨率的时间,你想要一个非常精确的计时器,那你就需要用到操作系统库了。例如在 Windows 中有一个叫做“QueryPerformanceCounter”的东西,我们仍然可以使用那些东西。事实上如果你想要更多地控制即使,控制 CPU 的计时能力,那么你可能会使用平台特定的库。不过本节只会看一看和平台无关的 C++标准库方法(chrono 库的一部分),它可以计算出执行代码时,代码之间经过了多长时间。
1. 计时1s
1 |
|
chrono 库非常好,可以高精度计时,几乎适用于所有平台,所以非常建议使用这个方法来满足你所有的计时需求,除非你在做一些特定的底层的事情。
2. 计算时间
1 |
|
64. Multidimensional Arrays in C++(2D arrays)
从二维数组开始作为一个例子,实际上它只是数组的数组(三维数组就是数组的数组的数组…..),就是数组的集合。 我们考虑处理数组的一种策略就是使用指针,我们有一个指针,指向数组在内存中的开头位置。可以想象一下有一个指针的数组,最终你会得到一个内存块,里面包含的是连续的指针,每个指针都指向内存中的某个数组,所以得到的是指向数组的指针的集合,也就是数组的数组。
1 |
|
这里只是分配了一个可以存储200字节指针的内存块,并没有初始化。 然后我们可以遍历并设置每个指针指向一个数组,这样就能得到一个包含50个数组的内存位置的数组
65. Sorting in C++
1 |
|
66. Type Punning in C++
Type punning(类型双关)只是一个花哨的术语,用来在 C++中绕过类型系统。C++是强类型语言,也就是说它有一个类型系统,不像 JavaScript 那样创建变量不需要声明变量类型,但 C++中你创建变量时必须声明整数、双精度数、结构体等等类型。然而这种类型系统并不像 Java 中那么“强制”,C++中虽然类型是由编译器强制执行的,但你可以直接访问内存,所以可以很容易地绕过类型系统,你是否要这么做取决于你的实际需求。在某些情况下,你绝对不应该规避类型系统,因为类型系统存在是有原因的,除非你有充分的理由,否则你不会想过多地使用它。
- 隐式类型转换
1 |
|
- 显式类型转换(与上面的隐式类型转换其实是一样的)
1 |
|
- 取a的地址,转换为double类型的指针再解引用
1 |
|
- struct
1 |
|
67. Unions in C++
Union (联合体)有点像 class 类型或者 struct 类型,只不过它一次只能占用一个成员的内存。 通常如果我们有一个结构体,我们在里面声明 4 个浮点数,就可以有 4x4 个字节在这个结构体中,总共是 16 个字节。 但一个联合体只能有一个成员,所以如果我要声明 4 个浮点数,比如 abcd,联合体的大小仍然是 4 个字节,当我尝试去处理它们,比如将 a 设为 5,它们的内存是一样的,d 的值也会是 5,这就是联合体的工作方式。
你可以像使用结构体或类一样使用它们,也可以给它添加静态函数或者普通函数、方法等。然而你不能使用虚方法,还有一些其它限制,但通常人们用联合体来做的事,是和[类型双关](https://nagi.fun/Cherno-CPP-Notes/51-100/66 Type Punning in C%2B%2B/)紧密相关的。当你想给同一个变量取两个不同的名字时,它真的很好用。
通常union
是匿名使用的,但匿名 union 不能含有成员函数。
1 |
|
1 |
|
68. Virtual Destructors in C++
虚析构函数可以想象为虚函数和析构函数的组合。
虚析构函数对于处理多态非常重要,换句话说,如果我有一系列的子类和所有的继承:有一个类 A,然后一个类 B 派生于 A,你想把类 B 引用为类 A,但它实际上是类 B,然后你决定删除 A 或者它以某种方式删除了,然后你还是希望运行 B 的析构函数,而不是运行 A 的析构函数,这就是所谓的虚析构函数以及它的作用。
1 |
|
在第三种情况下,Derived只调用了构造函数,没有调用析构函数,这是有可能会造成内存泄露了!!
这里只有基类的析构函数被调用了,而派生类的析构函数没有被调用。 这点很重要,因为这会造成内存泄漏。 delete
poly 时,它不知道这个调用的析构函数可能有另一个析构函数,因为它(~Base)没有被标记为虚函数。
1 |
|
标记为virtual,意味着 C++知道在层次结构下可能有某种重写的方法,这个方法就可以被覆写。 而virtual destructor(虚析构函数)的意思不是覆写析构函数,而是加上一个析构函数。换句话说如果我把积累的析构函数改为虚函数,它实际会先调用派生类析构函数,然后在层次结构中向上,调用基类析构函数。
69. Casting in C++
1. 什么是 casting
这里的casting(转换)是指类型转换,或者说是必须在 C++可用类型系统中进行的类型转换。
C++是一门强类型语言,意味着存在一个类型系统,而且类型是强制的。(见[66 课:类型双关](https://nagi.fun/Cherno-CPP-Notes/51-100/66 Type Punning in C%2B%2B/#^4d9dfe)) 如果我把某物设为 int,那就不能突然把它当做 double 或者 float,反过来也一样。我必须坚持原有的类型,除非有一个简单的隐式转换([见 40 课:隐式和显式](https://nagi.fun/Cherno-CPP-Notes/51-100/69 Casting in C%2B%2B/40 Implicit Conversion and the Explicit Keyword in C++.md#^cde452)),这意味着 C++知道如何在这两种类型之间转换,并且没有数据损失,这就是隐式转换;或者是有一个显示转换([见 66 课:类型双关](https://nagi.fun/Cherno-CPP-Notes/51-100/66 Type Punning in C%2B%2B/#^f3904d)),告诉 C++你需要把这个类型转换成目标类型,本章将正是介绍强制转换的含义,并了解如何使用它。
2. casting
C 风格
1 |
|
C++ 风格
C++风格的转换有多种,一个是static_cast
,还有reinterpret_cast
、dynamic_cast
、const_cast
,共这四种主要的 cast。它们并不能做任何 C 风格类型转换做不到的事情,这并不是添加新功能,只是添加了一些syntax sugar。
dynamic_cast
,它会实际执行一个检查,如果转换不成功返回 NULL,所以这做了额外的事情,会降低运行速度。但在大多数情况下,C++风格类型转换并不做额外的事情,它们只是一些代码中的英文单词。static_cast
,意思是静态类型转换,在静态类型转换的情况下,还会做一些其它的编译时检查,检查这种转换是否可能。reinterpret_cast
也是一样,就像是把我们说过的类型双关用英语表达出来一样,意思就是我要把这段内存重新解释成其它东西.const_cast
,移除或者添加变量的 const 限定。
所以为什么要搞这么多 CAST ?因为除了可能收到上面说的那些编译时的检查外,还可以方便我们从代码库中搜索它们。如果我想看到我的类型转换都在哪儿,也许我有性能问题而不想用dynamic_cast
,我可以直接搜索这个词,如果用的是 C 语言风格的 cast,就很难去搜索它,所以它对程序员的阅读和编写代码都有帮助。 而且它也能帮助我们减少在尝试强制转换时,可能意外犯下的错误,比如类型不兼容。
70. Conditional and Action Breakpoints in C++
[70 条件与操作断点 - cherno-cpp-notes (nagi.fun)](https://nagi.fun/Cherno-CPP-Notes/51-100/70 Conditional and Action Breakpoints in C%2B%2B/)
本讲内容是一个简单的 VS 开发和调试的技巧,不过不仅仅是断点,而是关于条件与操作应用在断点上。
1. 条件断点 Condition
通过条件或条件断点,我们可以告诉调试器想在这里放置一个断点,但我希望断点在特定条件下触发,比如内存中的某些东西满足了条件就触发这个断点。
2. 操作断点 Action
操作断点是允许我们采取某种动作,一般是在碰到断点时打印一些东西到控制台。
这里有两种类型的操作断点:
- 一是让你在打印你想要的东西时继续执行,比如你想记录鼠标位置,每次鼠标移动,移动事件(打印鼠标位置)就会发生,可以让那个断点打印一些东西到控制台但保持程序运行;
- 二是打印一些东西,但仍然中断程序,暂停程序的执行,这样我们就可以检查内存中的其它东西。
71. Safety in modern C++ and how to teach it
本节将讨论C++中“安全”意味着什么。
安全编程,就是在编程中,我们希望降低崩溃、内存泄漏、非法访问等问题。 随着C++11的到来,Cherno想说的是应该转向智能指针这样的概念,而不是原始指针。这主要是因为存在内存泄漏以及不知道实际分配或者释放了哪些内存的问题。本节也重点围绕指针和内存,而不是异常或者是其它与安全编程有关的比如错误检查之类的东西。
当我们开始倾向于[智能指针](https://nagi.fun/Cherno-CPP-Notes/51-100/71 Safety in modern C%2B%2B and how to teach it/44 SMART POINTERS in C++.md)之类的东西时,这一切都可以归结为我想要分配堆内存,智能指针和自动内存管理系统的存在使程序员的生活更容易,且更有力,这意味着你不再需要处理某些事情,就算忘记处理了它也会自动为你处理。
分配内存这件事很简单,你想在堆上分配一块内存,如果你分配成功会得到一个指向那块内存开始部分的有效的指针,它将一直存在,直到你明确地删除它,这就是整个基本概念了。
那问题就来自几方面了:
- 如果我***忘记释放***我的内存会发生什么问题,可能是无害的甚至注意不到,也有内存耗尽灾难性地导致程序崩溃。而“细心一点、做一个好程序员”显然不是一个真正的解决方案,你还是需要考虑更复杂的结构来删除由你自己明确分配的内存。
- 还有ownership(所有权问题),即谁会拥有分配的内存呢?如果我有一个原始指针,指向那块内存,我把它从一个函数传递给另一个函数,从一个类传递给另一个类,谁会负责管理和清理这些内存就是***所有权问题***。你不确定A、B这两个管理那个原始指针的函数哪个最后结束,但是要保证两个函数都能访问那个指针,除非你指明这两个函数运行完后再执行一个清理步骤,但这显然会极大复杂化整个程序,也是我们绝对想避免的。我想要重新分配数据,但我不想要显式地建立一些东西,比如管理所有权或者转义所有权,which会使事情变得非常复杂,你将不得不手动跟踪它。这是另一种所有权问题。
这两大问题就是我们需要自动删除内存的原因,当我们讨论C++的安全问题时,特别是智能指针时,我们只需要自动化一行简单的代码就搞定了内存删除与释放问题,所以你百分之百不应该拒绝使用智能指针,自己构建、修改智能指针也是正常的。
当然如果只是做一个一百来行的小型sandbox应用,可能用原始指针可读性更好,因为你不关心是否释放了内存,也不关心所有权,你只用写一个*
就能让代码会更干净。
Cherno认为大家应该停止关于“Smart or Raw”的争论,在一个真正的框架环境、真正的应用中,生产代码应该使用智能指针,不这么做是非常愚蠢的举动,大部分典型的问题都可以通过这样解决(可能线程方面有点问题,因为shared_ptr
不是线程安全的,使用智能指针还有很多其它约束,所以智能指针不是通用的内存解决方案)。更严肃的代码中完全应该使用智能指针,只是初学C++是需要了解原始指针和内存是如何工作的,因为[智能指针只是原始指针上的包装](https://nagi.fun/Cherno-CPP-Notes/51-100/71 Safety in modern C%2B%2B and how to teach it/44 SMART POINTERS in C++.md#^a6997e),它们围绕原始指针做了额外的辅助代码,以便自动化所有事情,但本质上只是删除和释放了内存。你必须得知道这一切是如何工作的,这也是为什么Cherno有几课是讲编译器和链接是如何工作的([06 How the C++ Compiler Works](https://nagi.fun/Cherno-CPP-Notes/51-100/71 Safety in modern C%2B%2B and how to teach it/06 How the C++ Compiler Works.md)、[07 How the C++ Linker Works](https://nagi.fun/Cherno-CPP-Notes/51-100/71 Safety in modern C%2B%2B and how to teach it/07 How the C++ Linker Works.md))
72. Precompiled Headers in C++
1. 什么是预编译头文件
预编译的头文件实际上是让你抓取一堆头文件,并将它们转换成编译器可以使用的格式,而不必一遍又一遍地读取这些头文件。 举个例子,每次在 C++文件中#include <vector>
的时候,它需要读取整个 Vector 头文件并编译它,而且 Vector 还包含一堆其它的包含文件,这些文件也一样需要读取,预处理器必须把这些复制到这个 Vector 文件,这就有 10w+行代码了,它们需要被解析并以某种形式标记并编译,在你想要编译 main 文件之前,因为你的 main 文件包含 Vector 文件的话,Vector 必须复制并粘贴到 main 文件中,然后所有代码每次都需要被解析和编译。重点是每次你对 C++文件进行修改,哪怕只是加了个空格,整个文件都要重新编译,所以 Vector 文件必须被复制并粘贴到你的 C++文件中,从头开始重新解析并编译。不仅如此,你的项目中有多个文件它们又都包含了 Vector,你不得不持续一遍遍地解析同样的代码,这需要大量时间。
所以你可以用一个叫做预编译头文件的东西来代替,它的作用是接受一堆你告诉它要接收的头文件(基本上是一堆代码)它只编译一次,以二进制格式存储,这对编译器来说比单纯的文本处理要快得多。这样就不需要解析整个 Vector 文件,每次它只需要看预编译的头文件,which 此时已经是非常快速且容易使用的、对编译器来说很容易使用的二进制格式。这意味着它会大幅加快编译时间,特别是你的项目越来越大,你会有越来越多的 C++文件。越来越多的头文件,诸如此类,你可以在预编译头文件中添加更多内容,你也有更多使用了共同头文件的源文件需要编译,它会指数级地加速,好的多得多。
所以如果你关心编译时间,你一定要使用预编译头文件。
不过,还有些你不应该用预编译头文件做的事: 到目前为止提到的预编译头文件,其本质还是头文件,which 包含了一堆其它头文件。因此你可能会想把项目中所有的东西都放在预编译头文件中,如果这样做的话是不是构建速度飞快。
是这样,但是如果你把东西放到预编译头文件中,而这些东西会发生变化,在实际的项目中我们在处理项目所以它很有可能会变化,显然必须重新构建预编译的头文件,而这要花费时间,这也可能会导致编译速度变慢。所以不要把会频繁更改的文件放入预编译头文件中。
尽管预编译头文件很有用,而且把你自己的项目文件当进去也没问题,比如把一个不会需要修改的 Log.h 文件放进去就很好,因为这个文件很常用,也方便使用,你不需要再手动地将 Log 包含到项目中的每个 C++文件中。但只要这个 Log 会修改,就不适合放入预编译头文件中,否则每次都要重新编译。
预编译头文件真正有用的是外部依赖,本质上它主要用于不是你写的那些代码,比如 STL、Windows api 等,如果你要#include <windows.h>
,which is a 巨大的的头文件,包含了非常多的其它头文件,你不回去修改 windows.h 或者 STL,所以它没有理由不被你放在预编译头文件中,因为它们的代码可能比你的实际项目代码多很多倍,每个 C++文件每次都要编译它们可想是一件多么恐怖的事情,你可能永远也不会去修改它们。因此直接把它们放入到预编译头文件中就不用管了。
2. 依赖关系
PCH(就是预编译头文件)实际上做的事是把所有东西都塞进来,它可能会隐藏现在实际正在使用的东西,会影响可读性。比如只有个别文件需要使用一个窗口库 GLFW,那就没必要把所有的依赖项都放在 PCH 中,如果你只看一个单独的 cpp 文件你并不知道它需要什么依赖,再把它导入其它文件时就不好理解它依赖的东西了。但如果你通过实际的include
包含它们就很清晰了,可以看到每个文件需要什么文件。但是如果你只包含 PCH,然后 PCH 中放很多包含文件,就会比较麻烦了。
所以不要把所有依赖都放在 PCH 中,因为包含实际的依赖会更容易阅读。应该放进 PCH 的东西是像 STL 这样的,因为 string、vectors、std::cout 是许多地方都要用到的,你不希望每次都编译它们,而 GLFW 可能就只需要编译一次。
73. Dynamic Casting in C++
dynamic_cast
是专门用于沿继承层次结构进行的强制类型转换,比如我的一个游戏里有一个实体类,它派生出了玩家类和敌人类,如果我想将玩家转换为实体是很简单的,因为玩家本身就是实体对象,可以隐式转换。但如果我想将一个实体类型转换为玩家,编译器会相信我们,如果它并不是一个玩家的话我们就相当于在尝试玩家独有的数据,程序可能会崩溃。因为这个原因,dynamic_cast
常被用来做验证,如果我们尝试使用它将一个敌人转化为玩家,这个转化会失败,dynamic_cast
会返回一个 NULL 指针,也就是 0。
74. BENCHMARKING in C++ (how to measure performance)
1 |
|
智能指针的性能对比
1 |
|
切换到 Release 模式,可以发现make_shared
明显比new
快,所以一定要确保你所分析的代码,是在 Release 时真正有意义的,因为你不会在 Debug 时发布代码。
75. Structed bindings in C++(C++17)
- 结构化绑定(只针对 C++17)
Structured binding(结构化绑定)是一个新特性,让我们更好地处理多返回值(多返回值可参考[52 C++处理多返回值](https://nagi.fun/Cherno-CPP-Notes/51-100/52 How to Deal with Multiple Return Values in C%2B%2B/)),这是在 52 课方法基础上拓展的一种处理多返回值的新方法,特别是如何处理 tuple(元组)和 pairs (对组)以及返回诸如此类的东西。因为结构化绑定简化了我们的代码,让它比以前的做法更简洁。
- 没有结构化绑定这个新特性时,最好使用sturct来处理多返回值
1 |
|
- 结构化绑定
需要确保项目属性设置为C++17才行,C++11和C++14不支持此属性,编译通过不了。
1 |
|
76. How to deal with OPTIONAL Data in C++(C++17)
很多时候,我们有一个返回数据的函数,比方说我们正在读取一个文件,但是如果这个文件不能被读取会发生什么?它可能不存在,或者是数据不是我们期望的格式,我们仍然需要从函数中返回一些东西。在这个特定的情况下,可能只会返回一个空字符串,但这没有多大意义。意思是,如果读取文件是空的, 我们应该有办法看到数据是否存在。
这就是要用到std::optional
的地方了,这是 C++17 标准的新东西。
- 不使用optional
1 |
|
- 使用optional的情况
1 |
|
77. Multiple TYPES of Data in a SINGLE VARIABLE in C++
1 |
|
这个和std::optional
很像,它的作用是让我们不用担心处理的确切数据类型, 只有一个变量放在那儿,我们之后再去考虑它的具体类型。它允许你列出所有可能的类型,然后你可以决定它将是什么,如果你想的话可以把它重新赋值给任意类型,这也是你创建可能有多个类型的变量的一种方式。
78. How to store ANY data in C++(C++17)
[78 如何存储任意类型的数据 - cherno-cpp-notes (nagi.fun)](https://nagi.fun/Cherno-CPP-Notes/51-100/78 How to store ANY data in C%2B%2B/)
79. How to make C++ run FASTER (with std::async)
通过多线程来提高性能!