Cherno笔记 1-30
💡 “Best C++ course for game development。”
同样适合作为C++初学者的入门课,讲到都是C++在工作中最重要的内容。
课程地址:Youtube上TheCherno上传的视频
翻译:神经元猫的高质量翻译
1 Welcome to C++
如果用C++写垃圾代码,甚至有可能比虚拟机语言(C#, Java)更慢,因为后者会再运行时优化很多东西而C++不会。
2 How to Setup C++ on Windows
不要使用VS默认位置:地址长且包含一个空格,英伟达的某些VS安卓插件的某些部分无法正常工作。
solution基本就是一个工作台,是一个包含多个相关project的集合,这些project可以是不同类型的,比如dll、exe、lib等,而每个project是文件的集合,然后被编译成某种目标二进制文件(比如library或executable)。
3 How to Setup C++ on Mac
后面的教程都是基于Visual Studio的,由于没钱买Mac,这里就跳过了。
4 How to Setup C++ on Linux
这节教Linux上Code::Blocks的使用,本人在Linux上习惯用vscode,这节没认真看。
5 How C++ Works
任何以#开头的都是预处理指令(processor statement
,会将对应文件进行拷贝和粘贴,它们发生在真正的编译过程之前。
如果不返回任何值,它会返回0,这只适用于main()。
<<
:被重载的符号,虽然看起来像运算符,可以把它们想象成一个函数。
std::cout << “Hello World!” << std::endl;
可以想成伪代码 std::cout.print(”Hello World!”).print(std::endl);
(后者跑不起来)。
防止关闭窗口:std::cin.get()
或 system(”pause”);
。
VS中Output Window和Error List都可以看到错误,但是Error List几乎是垃圾,它本身就是尝试在Output中寻找Error单词并把信息加入Error List,它提供的是overview,如果想要更详细的信息,还是得去Output Windows中看。
双击Output中的行数,会自动将光标定位到对应位置。
- 声明(declarations):说该符号或者函数存在。
- 定义(definitions):说明这个函数到底是啥。
若声明却没有定义函数,编译器依旧完全相信你,但链接器试图寻找定义的函数时却找不到该函数就会报链接错误。如果都正确了,编译器会将每个文件但单独编译为 .obj 文件,而链接器会将他们合并为一个可执行文件,例如 .exe 文件。
6 How the C++ Compiler Works
cpp文件可称为 translation unit
,c++和Java不同:Java文件必须与class对应,而文件夹必须与package对应;而C++中文件只是向编译器喂源码的方式,一个cpp文件对应一个obj文件。
#include
预处理指令只是会将对应文件的全部内容原原本本的复制粘贴到源码中,形成.i后缀文件(VS中需要在项目属性中打开预处理到文件,注意VS提示我们这将不会生成obj文件)。
可以使用如下语句控制.i文件内容:
1 |
|
constant folding:编译优化时完成常数运算,而非运行时,比如 return 2 * 5 对应 mov eax 10。
Debug模式默认开启Od,即不优化,便于分析.asm汇编文件。
7 How the C++ Linker Works
VS按 ctrl+F7
或点编译,则只会进行编译
VS按 ctrl+F5
或点生成(build),则会对整个项目进行compile+link,需要entry point(可以是main()也可以是其他——在VS项目属性中定义)
error C2143:以C开头为编译错误,LNK2143:以LNK开头为链接时错误
Unresolved external symbol
1 | // ---Log.cpp--- |
- 由于linker找不到Log(),build会报 LNK1120:1 unresolved external symbol。
- 若注释14行
Log("Multiply");
则不会报错,因为linker不需要去寻找Log()地址。 - 若注释19行
std::cout << Multiply(5, 8) << std::endl;
则仍然会报错,因为虽然本文件没用到Multiply(),但是其他文件生成时可能会link到Multiply()。 - 若注释20行,同时在Multiply()前加上static声明(不能被其他obj文件函数引用),则不会报错。
重复定义
- 一个文件中定义两个名称、参数、返回类型完全相同的函数会报 C2084。
- 两个文件中定义两个名称、参数、返回类型完全相同的函数会报 LNK1169。下面例子展示这种错误有多容易犯:
1 | // ---Log.h--- |
问题: Log.cpp和Math.cpp中都有 Log() 的定义,这导致linker无法选择。
3钟解决方式如下:
- 推荐:.h头文件保留声明,定义放到第三个translation unit中,这里放到 Log.cpp 中很合适。
模板类是个例外,声明和定义都放在头文件中。 - 在.h中Log()前加上static:2个cpp中的Log都不能被其他obj文件链接
- 在.h中Log()前加上inline:作用是将函数调用直接替换成函数体
Log("Initialized Log");
变成std::cout << "Initialized Log" << std::endl;
8 Variables in C++
c++中不同变量类型之间的唯一区别是大小——创建变量时通过数据类型决定分配的内存大小。数据类型取决于程序员,并没有很多规则去束缚,但是cout等时会根据类型决定打印的结果。
( Cherno这样说其实有点问题,不只是内存大小不同的问题,类型还将决定可执行的操作。)
float val = 5.5;
可能会以为浮点数常量的是float类型,但在VS把光标放到**5.5
上可以看到其实是(double)(5.5),稍后被强制转化为float。**
要定义浮点数要使用 float val = 5.5f
或 float val = 5.5F
。
bool类型变量只有0是false,非0都是true,打印bool变量会得到0或1。
虽然存储bool类型按理来说1bit就可以,但没办法寻址1个bit的内容,内存寻址的最小单元是byte。可通过技巧解决:一个byte放8个bool。
9 Functions
- 函数:降低重复代码的代码块。
- 方法:class中的代码块。
声明应当放在头文件中,定义放在cpp文件中。
10 Header files
有两种不同文件类型的概念,一种是像cpp一样编译文件,这种就有头文件概念;而另一种像C#和Java中没有头文件概念。
VS中文件夹是假的,文件具体放在哪个文件夹其实都可以。
#pragma once
: #开头的都是预处理指令,发生在编译之前。
组织单个头文件被多次包含,并转换为单个翻译单元——可以被放在程序的多个位置,但一个翻译单元(一个cpp文件)只能放一次。
#pragma once
很简洁很好用,工业中广泛应用。
另一种传统方式是添加 header guides
如下:
1 |
|
如果被包含两次,则结果如下
1 |
|
<>与“”:
- 尖括号:告诉编译器目标路径中包含所需要包含的文件夹。
- 引号: 既包含相对于当前路径的文件,比如
#include “../Log.h”
,又可用来指定编译器包含目录的相对路径里的文件,比如#include “iostream”
。 - 尖括号只用于编译器包含路径,引号可以做一切(通常用在包含相对路径)。
iostream其实也是文件,只不过没有扩展名,c++设计者有意通过这点将C++标准库与C标准库区分。
11 How to DUBUG in VS
调试的两大部分:断点 和 读取内存。
VS的调试模式中会给所有未初始化的局部变量赋值为0xcc
, 在调试/窗口/内存中可以打开内存视图,输入&a
,可以查看变量地址和值。
str初始化后,可以通过其值(是一个字符串地址)去寻找到对应字符串,如下:
1 | 0x00609B30 68 65 6c 6c 6f 00 00 00 48 65 6c 6c 6f 20 57 6f 72 6c 64 21 00 00 00 00 00 00 b0 40 08 9c hello...Hello World!......?@.? |
12 CONDITIONS and BRANCHES in C++
可以通过避免分支对程序进行优化。
可以在VS编辑区右键进入反汇编代码,静态分析反汇编代码在没有源码时非常有用,但在有源码时这是nightmare的,不过分析汇编代码可以学习一些知识:
1 | bool compare_res = x == 5; |
不要写单行写 if (x == 5) cout << “y” << endl;
:调试时不能进入后一句且不便于增添!
else if
并非什么C++关键词,其实就是 else + if (条件)
。
13 BEST Visual Studio Setup for C++ Projects!
VS中展示的并不是文件夹(folder),而是过滤器(filter)。在项目下新建的也是filter。
点击“显示所有文件的图标”后展示的就是磁盘目录视图,在这里可以新建folder。推荐新建一个src
文件夹,然后将所有cpp文件和头文件都放到里面。
-
输出目录:
$(SolutionDir)bin\$(Platform)\$(Configuration)\
-
中间件目录:
$(SolutionDir)bin\intermediates\$(Platform)\$(Configuration)\
14 Loops in C++ (for loops, while loops)
个人觉得循环没啥好讲的。
15 Control Flow in C++ (continue, break, return)
- continue:只在loop中出现。
- break:多在loop中出现,也在switch中出现。
- return:完全退出function,在哪都可以。
16 POINTERS in C++
目前只讨论raw pointers,不讨论smart pointers。
指针对manage和manipulate内存极度重要,pointer is a integer which stores a memory address。类型实际上没有意义,只是帮助推断地址所指的数据类型。
void* ptr = 0;
表述此无类型(类型实际上无意义)指针为NULL,内存地址不能到0。
实际上0表示NULL,或者说NULL和nullptr被define为0。
1 | int x = 6; |
编译器会根据类型设置位模式,也会根据类型解释位模式。
逆向引用:指针的*运算符通常被称为dereference运算符
指针也是存在于内存中的变量,可以使用 double pointer。
指针指向堆上内存的例子:
1 | char* buffer = new char[8]; |
注意:手动删除指针数组需要使用delete []
。
17 REFERENCES in C++
引用只是指针的扩展,是指针的语法糖,使它更容易容易阅读、更容易理解。
没有什么引用能做而指针不能做的事情,指针更有用更强大,但是很多情况下使用引用更简单。
1 | // --- 指针 --- |
引用是引用现有变量的方式,引用本身只是别名,不是变量,不占内存,不像指针可以传创建一个新指针变量然后设置它等于空指针或者类似的东西。
定义引用后无法修改,声明时必须立即赋值。
int& alias = a;
&是类型的一部分,这与指针中使用到的取地址符不同,这里alias只是别名,通过汇编代码去看的话,只有一个变量
💡 引用和指针的区别:
本质上指针是存储地址的对象(变量),而引用是对象的别名,因此:
- 指针占内存空间;而引用不占。
- 指针不必初始化(这时使用默认初始化);而引用必须初始化。
- 指针可以重新赋值;而引用不能。
- 可以存在指针的指针等;而不存在引用的引用。
个人理解:引用是指针的语法糖,它自带顶层const属性。PS:
顶层const(top-level const):本身是个常量。
底层const(low-level const):所指对象是一个常量。
18 CLASSES in C++
类只是方便组合数据和函数的一种简单方式。任何使用class做的事情,不用class也能做,class并没有提供新的functionality。
类是一种变量类型,是自定义数据类型的基础。
19 CLASSES vs STRUCTS in C++
类和结构体唯一区别是:类默认是private的,结构体默认是public的。
struct在c++中唯一存在的原因是,C++希望与C保持向后兼容性。
如果想要所有成员都是public的,然而又不想写上public,应该使用struct吗?——是的,它就算这样的微不足道。 (这个问题不绝对,取决于编程风格)
从技术上讲,除了visibility,二者可能没有太大区别,然而实际的使用情况会有所不同。——继承,不要在struct中使用继承,若要有一个完整的类层次,使用类。
20 How to Write a C++ Class
这节讲类讲的非常简单,本节给出了一个Log类的例子,第23节[”ENUMS in C++“](./# ENUMS in C++)中给出了此Log类例子的升级版。
21 Static in C++
static在C++中根据上下文有两种意思,一种是在类或结构体外使用static关键字,一种是在类或结构体内部使用static。
- 类外的static,声明为static的符号链接将只在内部,这意味着它只对定义所在的翻译单元可见。
- 类或结构体内的static,意味着该变量实际上将与类所有实例共享内存,静态变量只有1个实例。
不同文件中不能有同名的全局变量,除非使用static修饰,或者在某个文件中使用 extern
声明。
全局变量不好,一定要尽可能使用static,除非真的需要跨翻译单元链接。
22 Static for Classes and Structs in C++
通过类实例引用静态变量无意义,因为类静态变量就像类的全局实例一样。
静态方法也是一样,无法访问类的实例,静态方法不需要通过类的实例被调用,而在静态方法内部,不能写引用到类实例的代码,静态方法也不能访问非静态变量。
💡 在类中写的每个非静态方法总会获得当前类的一个实例作为参数,而静态方法不会获得那个隐藏参数,所以不能访问非静态变量。
1 | struct Entity { |
23 ENUMS in C++
enumeration 是一组值的集合,是一种命名值的方式(枚举数其实就是一个整数)。
当想使用整数来表示某些状态或某些数值时,它非常有用。
- 第一个元素默认是0,第二个往后默认增1
- enum元素类型默认是int,可以通过如下方式修改为
unsigned char
- 第6行后value的类型可以写
unsigned char
,也可以写Example**,写后者的话就限制在A、B、C三个值中(当然也可以绕过)**。
1 | enum Example : unsigned char { |
下面给出一个更偏实际的例子:
1 |
|
注意:这里Level只是普通的枚举数,不是枚举类,Level不是真正的命名空间。
24 Constructors in C++
构造函数最通用用法:创建类的实例时初始化该类,确保初始化了所有的内存。
Java等语言对于数据基本类型,比如 int 和 float 会自动初始化为0,但是C++不会,必须手动初始化所有基本类型。
可以写多个构造函数,前提是它们有不同的参数(函数重载:同域下的同名函数的不同版本)。
若不实例化对象,将不会运行构造方法,比如只使用一个类的静态方法。
当使用new创建一个对象时,也会调用构造方法。
💡 删除构造函数的方法,二者任选其一即可:
- 设置private隐藏构造函数
Log() = delete;
25 Destructors in C++
任何时候,一个对象要被销毁时,析构函数将被调用。
析构函数同样适用于堆和栈分配的对象,堆:调用delete时析构函数将被调用;栈:当作用域结束时,栈对象将被删除——不要主动调用,否则会造成double destruct。
1 |
|
26 Inheritance
继承:避免代码重复,扩展现有类并为基类提供新功能的一种方式。
父类是子类的子集,子类是父类的超集。如果开始重写函数和Player类,就需要维护一个虚函数表(V表),需要额外占用内存。
1 |
|
注意上述代码中 sizeof Player
的结果在x86和x64下不同,地址位数不同。
多态:使用一种类型表示多种类型,比如Player不只是Player也是Entity,可以在任何需要使用Entity的地方使用Player。
在C++中,多态性(Polymorphism)主要是指通过基类指针或引用来调用派生类对象的成员函数。
27 Virtual Functions
虚函数允许在子类中重写方法:若B是A的子类,可以在A类中创建一个方法,标记为virtual,可选择在B类中重写那个方法,让它做其他事情。
1 |
|
上面代码中, Player类型的p被赋值给声明为Entity的变量,调用getName()的是Entity的getName()。
原因:通常在我们声明函数时,我们的方法通常在类内部起作用,然后当要调用方法时,会调用属于该类的方法。可以通过虚函数解决此问题。
虚函数引入了一种称为Dynamic Dispatch(动态联编)的东西,它通常通过v表(虚函数表)来实现编译,v表就是一个表,它包含基类中所有虚函数的映射,这样就可在运行时将它们映射到正确的覆写(override)函数。
如果想覆写一个函数,必须将基类中的基函数标记为虚函数:
- 父类加上
virtual
关键字。
父类成员函数加上virtual
后,子类成员函数隐式的也是virtual
。 - 子类加上
override
关键字(C++11)。
好处:1.易读;2.减少错误:如果函数名称写错了——和父类函数名不同,就报错提醒。
1 | class Entity { |
虚函数有两种运行时成本:
- 存储v表内存:基类中要有一个成员指针,指向v表。
- 每次调用虚函数时,需要遍历这个表,来确定映射到哪个函数。
28 Interfaces in C++ (Pure Virtual Functions)
C++中纯虚函数允许我们在基类中定义一个没有实现的函数,然后强制子类去实现该函数。确保每个类都有一个特定的方法,可以将该抽象类作为参数(类型)放入一个通用函数中。C++11中无interface关键字,其实还是一个类,只不过有一个未实现的虚函数。
类中的接口只包含未实现的方法,作为模版。
由于此接口类实际不包含方法实现,实际上不可能实例化那个类,在子类中实现后可以实例化。
纯虚函数应该理解为虚函数 + 类不可实例化标记。
1 | class Printable { |
29 Visibility in C++
可见性指类的某些成员或者方法实际上有多可见,是对程序实际运行方式完全没有影响的东西,对程序的性能和类似的东西也没有影响(可见性不是cpu需要理解的东西)。
C++中有三个基础的可见性修饰符:private
、protected
和 public
,class中如果不写则默认为private
,struct中默认为public
。
扩展:Java中还可以使用default(不写)可见修饰符,C#中还有internal。
private
意味着只有友元和目标类可以访问private变量,子类也不能访问。
在C++中有个叫friend
的东西,可让其他类或者函数成为目标类的友元,实际上可以从类中访问私有成员。protected
意味着目标类和层次结构中的所有子类可以访问这些符号。
1 |
|
可见性的好处:确保人们不会调用不该调用的代码该破坏。
一个关于UI的例子:使用按钮改变位置时如果只是修改 X=5
,显示器会使用内存中旧的X值,一个做法是声明X为private
(提醒自己和他人不要通过赋值修改X),而给出一个public
的SetX()其中实现修改X并刷新显示器的功能。
30 Arrays in C++
arr[-1] = 0
Memory access violation。
在Debug模式下会得到一个程序崩溃的错误信息。
但在Release模式下可能不会得到报错信息,已经写入了不属于你的内存。
<=
运算时,在做小于以及等于的比较——影响性能
1 | // 错误 |
int arr[5];
是在栈上创建数组,作用域结束时内存释放。
int* arr = new int[5];
在堆上创建数组,直到程序把它销毁之前都存在,销毁:delete[] arr;
。
在堆上创建数组的原因:最大的原因是生存期:**如果有一个函数需要返回一个数组,这个数组在函数中创建,则必须使用new
,**另一个原因是数据量太大,堆上放不下。
sizeof 栈数组 得到数组大小,sizeof 堆数组 只能得到数组地址大小。
在堆上创建内存会导致 内存间接寻址(Memory Indirection):一个指针,指向另一个内存块(保存实际的数组)。内存中跳跃影响性能,还可能会导致内存碎片(Memory fragmentation),缓存丢失(cache miss)。
标准数组:std::array 内置在C++库。优点:边界检查,记录数组大小,这会带来较小的性能损耗。
1 |
|