课程地址:Youtube上TheCherno上传的视频
翻译:神经元猫的高质量翻译

51 Making and Working with Libraries in C++ (Multiple Projects in Visual Studio)

讲如何在vs中建立多个项目,以及如何创建一个库让所有项目都能用。

  1. 确保目标project的配置类型为静态类(.lib)。
  2. 设置“附件包含目录”以便能在include时能正确寻找到目标文件。
  3. 添加引用,这会帮我们自动设置连接器输入,并且改名时不受影响,还会自动创建完整的依赖关系图,先编译依赖后编译此文件。

由于是静态链接,移动后仍然可用,若为动态链接则必须和dll文件在同一目录下。

52 How to Deal with Multiple Return Values in C++

法1 返回struct

法2 传入存结果的指针,没有动态分配

从技术上来说可能是最理想的方法,因为没有复制。

法3 返回数组——只有类型相同时才适用

声明:static std::string* ParseShader(const std::string& filepath);static std::array<std::string, 2> ParseShader(const std::string& filepath); 改用vector也行

返回:return new std::string[]{vs, fs};return std::array<std::string, 2>(vs, fs); new会导致堆分配的发生,同时只适合返回单一类型。

array会在栈上创建,而vector底层存储是在堆上。

法4 返回tuple或pair,容易搞混

#include
#include (make_tuple需要使用)
static std::tuple或者pair<返回类型1,返回类型2> func()
return make_pair(返回值1,返回值2)

从tuple 中获得值
source=返回的元组(tuple or like pair)
std::get<0/1/2/3>(tuple名source);
pair 的类型,可以用 source.first

53 Templates in C++

template与“泛型”不同,也不能说完全不同,比泛型牛逼的多(泛型非常受限于类型系统)。template有点像宏。

template允许定义一个可根据用途进行编译的模板(让编译器基于一套规则,为你写代码)。

模板不是实际存在的函数,只有当调用时才会被实际创建。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
#include <string>

template<typename T> // 用class或typename均可,这里class和类类型不是一个
void Print(T value) {
std::cout << value << std::endl;
}

int main() {
Print(5);
Print<int>(6);
Print(5.5f);
Print("JKA");
}

模板绝不仅限于类型或则任何东西,也不限于函数,可创建一整个类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename T, int N>
class Array {
private:
T m_Array[N];
public:
int GetSize() const {
return N;
}
};

int main() {
Array<std::string, 5> array;
std::cout << array.GetSize() << std::endl;
std::cin.get();
}

54 Stack vs Heap Memory in C++

堆内存和栈内存的 内存分配 方式不同。
栈分配非常快,所做的就是移动栈指针,就像一条CPU指令。栈大小比如2M。
堆内存分配是一堆事情,程序维护空闲列表,如果需要内存过大还会向OS申请内存,这非常耗费资源。
使用上,尽量使用堆,除非需要变量生存期大于作用域,或需要很大的内存

对于 struct Vector3 {float x, y, z;} 堆分配使用Vector3* hvector = new Vector3();Vector3* hvector = new Vector3; 都是可以的

55 Macros in C++

宏是一个很宽泛的概念,这里指C++中使用预处理器来“宏”化某些操作。
预处理器处理宏发生在预编译期,所做的就是查找和替换——所以不要加分号

1
2
3
4
5
#defint LOG(x) std::cout << x << std::endl

int main() {
LOG("Hello");
}

编程习惯:不要向人炫耀你知道所有的C++特性,尤其是高级特性更应当减少使用(新手常犯的误区)。
使用宏会让别人困惑,别人得去找到宏才能看懂。

适合用宏的场景:在现实开发中在开发阶段需要日志系统,在发布阶段可能需要去掉(减少透露信息,提升性能)。

在VS项目属性设置中,在**“DEBUG”配置中预处理器-预处理器定义中设置 PR_DEBUG=1** (注意不要有空格,不过好像有也没事)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
#include <string>

// 使用#if比使用#ifdef要好
#if PR_DEBUG == 1
#define LOG(x) std::cout << x << std::endl;
#else
#define LOG(x)
#endif

int main() {
LOG("Hello");
std::cin.get();
}

可以使用反斜杠对换行符进行转义,注意后面不要有空格,否则就是对空格转义而非对换行符转义。

1
2
3
#define MAIN int main() {\
std::cin.get();\
}

56 The “auto” keyword in C++

两面性:一方面API改变修改返回值类型后客户端不需要修改,另一方面可能会破坏依赖于特定类型的代码、还会降低可读性(别人不能很快明白类型)。

推荐:如果类型非常长就推荐用,此时当进入非常复杂的代码集,包含了模板等,就不得不用auto,因为不知道类型是什么(不要写这么复杂的代码,这很难向别人解释代码如何工作,很难维护)。

auto不会加引用,存储返回结果得用 const auto& res =

57 Static Arrays in C++ (std::array)

std::array也是放在栈上,和普通数组的工作方式基本一样,不需要手动维护数组大小,还能使用sort等标准函数,还能可选边界检查(Release模式下无,边界检查会影响程序性能)。而std::vector是在堆上。
std::array 的size直接返回模板参数,不是返回size变量等——不额外占用空间
std::array 的实现相当高效,应该用其替代C风格的数组。

头文件如果是一个模板类,头文件就是所需的全部,不需要实现文件。

58 Function Pointers in C++

本节课讲原始C风格的函数指针。

函数指针是将一个函数赋值给一个变量的方法,这与我们之前通过()调用函数的使用方法不同,扩展:还能将函数作为参数传给其他函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void HelloWorld(int a) {
std::cout << "Hello World! Value: " << a << std::endl;
}

int main() {
//auto func = HelloWorld; // 法1,推荐

// void(*func)(int) = HelloWorld; // 法2, C风格函数指针真的很奇怪
// void指返回值类型

typedef void(*HelloWorldFunc)(int); // 法3
HelloWorldFunc func = HelloWorld;

func(8);

std::cin.get();
}

lambda 本质上就是一个普通函数,只是它不像普通函数这样声明,它是在我们的代码在过程中生成的,用完即弃的函数。

[ ] 叫做 捕获方式,即如何传入传出参数。

注意下面代码中 函数指针 的写法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#include <vector>

void ForEach(const std::vector<int>& values, void(*func)(int)) {
for (int value : values) {
func(value);
}
}

int main() {
std::vector<int> values = { 1, 5, 4, 2, 3 }; // 初始化列表
ForEach(values, [](int value) { // lambda表达式作为参数传递
std::cout << "Values: " << value << std::endl;
});
std::cin.get();
}

59 Lambdas in C++

lambda本质上是定义一种叫作匿名函数的方式,不需要实际创建一个函数,就像一个快速的一次性函数,展示下需要运行的代码。它更像一个变量,而非一个正式的函数,在实际编译的代码中作为一个符号存在

用法:在我们会设置函数指针指向函数的任何地方,都可以将它设置成lambda。我们所做的就是构造一个稍后会调用的函数。

[ ] 捕获方式:打算如何传递变量。

  • [=] 全部按值传递。
  • [&] 全部按引用传递。
  • [a, &b] a通过值传递,b通过引用传递。

非捕获lambda可以隐式转换为函数指针,而有捕获lambda不可以。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <vector>
#include <algorithm>
#include <functional>

//无法将参数 2 从“main::<lambda_2>”转换为“void(__cdecl*)(int)”
//void ForEach(const std::vector<int>& values, void(*func)(int)) {
void ForEach(const std::vector<int>& values, const std::function<void(int)>& func) {
for (int value : values)
func(value);
}

int main() {
std::vector<int> values = { 1, 5, 4, 2, 3 };
auto it = std::find_if(values.begin(), values.end(), [](int value) {return value > 0; });
std::cout << *it << std::endl;

int a = 5;
auto lambda = [=](int value) {std::cout << "Values: " << value << std::endl; };
ForEach(values, lambda);
std::cin.get();
}

对于有捕获的lambda,如果使用void ForEach(const std::vector<int>& values, void(*func)(int)) {

1
2
3
4
5
6
7
8
9
10
11
12
a.cpp: In function 'int main()':
a.cpp:20:19: error: cannot convert 'main()::<lambda(int)>' to 'void (*)(int)'
20 | ForEach(values, lambda);
| ^~~~~~
| |
| main()::<lambda(int)>
a.cpp:7:52: note: initializing argument 2 of 'void ForEach(const std::vector<int>&, void (*)(int))'
7 | void ForEach(const std::vector<int>& values, void(*func)(int)) {
| ~~~~~~^~~~~~~~~~
a.cpp:18:7: warning: unused variable 'a' [-Wunused-variable]
18 | int a = 5;
| ^

60 Why I don’t “using namespace std”

使用using namespace std后就不知道所知用的函数来自哪个namespace。公司可能会有自己的stl版本,比如EASTL,std::vector和eastl::vector好区分,而使用using namespace 语句后就没那么容易区分了。

绝对不要在头文件中使用using namespace
cherno也会用using namespace,但只会using自己的库,而不会使用std或者eastl。
如果确实要使用using namespace,就在一个足够小的作用域下使用。

61 Namespaces in C++

C中无命名空间,不能将一个函数命名为Init,得叫glfwInit,glBegin等。

C++中有命名空间,主要目的是避免命名冲突,希望能够在不同的上下文中调用相同的符号。

:: 是命名空间操作符。

类本身也是一种命名空间;
枚举不是命名空间,而枚举类是。

一般在头文件.h中禁止使用using namespace,因为这样会导致别人引用你的头文件时出现冲突。在cpp里你可以用,但为了养成良好习惯一般用什么写什么,比如using std::vector;

1
2
3
4
5
6
using orange::print();  // 只为print指定命名空间,print_again需再指定
print("hello");
apple::print_again();

namespace o = orange; // 为namespace起别名
o::print();

严肃项目中,所有函数都应放在namespace xxx {} 里面。

62 Threads in C++

如果不使用线程,无法让程序同时做两件事(比如同时等待用户输入,和向控制台打印消息)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>
#include <thread>

static bool s_Finished = false;

void DoWork() {
using namespace std::literals::chrono_literals; // 1s需要

std::cout << "Thread id = " << std::this_thread::get_id() << std::endl;
while (!s_Finished) {
std::cout << "Working..." << std::endl;
std::this_thread::sleep_for(1s);
}
}

int main() {
std::thread worker(DoWork); // 参数为 函数指针 和 函数参数
// eg: std::thread second (bar,0);

std::cin.get();
s_Finished = true;

worker.join(); // main线程阻塞,等待worker线程退出

std::cout << "Thread id = " << std::this_thread::get_id() << std::endl;
std::cin.get();
}

63 Timing in C++

标准写法可参考 74

chrono是C++库的一部分(C++11),不需要使用操作系统。在chrono之前,如果想要非常精确的计时器,需要使用操作系统库(Windows中,有QueryPerformanceCounter)。

chrono与平台无关,推荐使用(99%情况下使用chrono就好),除非正在做一些特定的底层的事情希望进一步减少开销,或者想要使用特定于平台的库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <chrono>
#include <thread>

int main() {
using namespace std::literals::chrono_literals;

std::chrono::time_point<std::chrono::steady_clock> start = std::chrono::high_resolution_clock::now();
std::this_thread::sleep_for(1s);
auto end = std::chrono::high_resolution_clock::now();

std::chrono::duration<float> duration;
duration = end - start;
std::cout << duration.count() << "s" << std::endl;

std::cin.get();
}

instrumentation(插码),可以使用插码来实际修改源代码,以包含某种分析工具。

std::endl 很慢,推荐换成 \n。

记时类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>
#include <chrono>
#include <thread>

struct Timer {
std::chrono::time_point<std::chrono::steady_clock> start, end;
std::chrono::duration<float> duration;

Timer() {
start = std::chrono::high_resolution_clock::now();
}

~Timer() {
end = std::chrono::high_resolution_clock::now();
duration = end - start;
float ms = duration.count() * 1000.0f;
std::cout << "Timer took " << ms << "ms\n";
}
};

void Function() {
Timer timer; // 插码
for (int i = 0; i < 100; ++i)
std::cout << "Hello\n";
}

int main() {
Function();
std::cin.get();
}

64 Multidimensional Arrays in C++ (2D arrays)

在处理任何类型的数组时,指针都是重要的,因为数组就是内存块,处理内存简单的方式就是使用指针。

二维数组就是数组的数组,是数组的集合,int**就是指向int*数组的指针。

int** a2d = new int*[5] 所做的就是分配20byte的空间(32位),这个空间后续可以存放其他类型数据比如float。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int main() {
// 二维数组
int** a2d = new int* [5]; // 为5个指针分配空间
for (int i = 0; i < 5; ++i)
a2d[i] = new int[5]; // 为5*5个数据分配空间

for (int i = 0; i < 5; ++i)
for (int j = 0; j < 5; ++j)
a2d[i][j] = 2;

for (int i = 0; i < 5; ++i)
delete[] a2d[i];
delete[] a2d;

// 一维等效写法
int* a1d = new int[25];
for (int i = 0; i < 5; ++i)
for (int j = 0; j < 5; ++j)
a1d[i * 5 + j] = 2;
delete[] a1d;
std::cin.get();
}

堆分配方式,会被分配到内存中完全随机的位置,可能很远——要访问全部数据,可能在访问一行数据后,要跳到其他维度,这会导致 cache miss(缓存不命中) ,这意味着我们在从ram中获取数据时浪费了时间。
内存分配越分散性能越差。优化时尽量让访问的内存存储在一块,这样定位数据时会有更多的cache hits以及更少的cache miss。

65 Sorting in C++

1
2
3
4
5
6
7
8
9
std::vector<int> values = { 3, 5, 1, 4, 2 };
std::sort(values.begin(), values.end(), [](int a, int b) {
if (a == 1) // 确保1放在最后
return false;
if (b == 1) // 应当这样设置
return true;
return a < b;
// 函数返回值含义:a是否应当在b前面
});

66 Type Punning in C++

类型双关只是一个花哨的术语,用来在C++中绕过类型系统。
C++是强类型语言,有类型系统。C++中虽然类型是由编译器强制执行的,但可以直接访问内存,这是一种原始的、底层的访问。

1
2
3
4
5
6
int a = 50;
double value = a; // 隐式转换,等价于double value = (double)a;
std::cout << value << std::endl; // 50

// a的内存为0x32
// b的内存为0x404900 00000000

类型双关:double value = *(double*)&a; 取a的地址转换为double*指针,然后再解引用。这样处理会把a后面4字节的内存也复制过来。
PS:使用引用则为double& value = *(double*)&a;

理解:把目标内存当作不同类型的内存来对待。我们所需要做的只是将该类型作为指针,然后将其转换为另一个指针,如果由必要还可以进行解引用。

结构体(类)本身不包含任何数据的填充。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>

struct Entity {
int x, y;
int* GetPositions() { // 让x,y作为数组返回,重新建数组太慢了
return &x;
}
};

int main() {
Entity e = { 5, 8 };
int* position = (int*)&e;
std::cout << position[0] << ", " << position[1] << std::endl;

int y = *(int*)((char*)&e + 4); // 实际中千万不要这样写
std::cout << y << std::endl;

int* a = e.GetPositions();
a[1] = -1;
std::cout << e.x << ", " << e.y << std::endl;

std::cin.get();
}

67 Unions in C++

联合体有点像类类型或结构体类型,只不过它一次只能占用一个成员的内存。类中各个成员共享内存,将a的值修改为5则d的值也会修改为5。

可以像使用结构体和类一样使用联合体,可以给它添加静态函数或普通函数、方法等。然而,不能使用虚函数,还有其他一些限制。

通常人们用联合体来做的事是和类型双关紧密相关的。
当想给同一个变量取两个不同的名字时,或想用多种方法来处理相同的数据时,联合体很有用。

通常union是匿名使用的,但是匿名union不能含有成员函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
struct Vector2 {
float x, y;
};

struct Vector4 {
union {
struct {
float x, y, z, w;
};

struct {
Vector2 a, b;
};
};
};

void PrintVector2(Vector2 v) {
std::cout << v.x << ", " << v.y << std::endl;
}

int main() {
Vector4 vector = { 1.0f, 2.0f, 3.0f, 4.0f };
PrintVector2(vector.a);
vector.z = 100.0f;
PrintVector2(vector.b);
std::cin.get();
}

68 Virtual Destructors in C++

虚析构函数就是虚函数析构函数的组合。虚析构函数对于处理多态非常重要。

在普通方法前标记为 virtual,那么它就可以被覆写,这意味着虚函数表要做这样的设置。

虚析构函数有点不同,虚析构函数不是要覆写析构函数,而是加上一个析构函数

💡 只要允许一个类拥有子类,百分百需要声明析构函数是虚函数,否则没人能安全地扩展此类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <iostream>

class Base {
public:
Base() { std::cout << "Base Constructor\n"; }
~Base() { std::cout << "Base Destructor\n"; } // 加virtual后第3种情况与2同
};

class Derived : public Base {
public:
Derived() { std::cout << "Derived Constructor\n"; }
~Derived() { std::cout << "Derived Destructor\n"; }
};

int main() {
Base* base = new Base();
delete base;
std::cout << "----------------\n";
Derived* derived = new Derived();
delete derived;
std::cout << "----------------\n";
Base* poly = new Derived();
delete poly;
std::cin.get();
}

// Out
Base Constructor
Base Destructor
----------------
Base Constructor
Derived Constructor
Derived Destructor
Base Destructor
----------------
Base Constructor
Derived Constructor
Base Destructor

注意:正常情况下,创建子类对象时,会先调用父类构造函数,再调用子类构造函数。
删除子类对象时,会先调用子类析构函数,再调用父类析构函数。

69 Casting in C++

本节需要练习并通过经验来学习,而不是告诉它如何运作。这个话题上若只使用理论而没有实践,则不会有多大帮助。

(int)value 是 C语言风格类型转换。圆括号中指定了要强制转换的类型,然后是我们要强制转换的变量,变量可用括号括起来。

C++风格类型转换共有4中主要的cast,它们不能做任何C风格类型转换所不能处理的事情。这不是添加新功能,只是添加一些语法糖(dynamic_cast会做运行时检查,其他的会做编译时检查)。

  • static_cast 用于进行比较“自然”和低风险的转换,如整型和浮点型、字符型之间的互相转换**,不能用于指针类型的强制转换。**
  • reinterpret_cast 用于进行各种不同类型的指针之间强制转换,把目标内存重新解释成别的东西
    double s = reinterpret_cast<AnotherClass*>(&value) + 5.3;
  • const_cast 用于进行去除 const 属性的转换。
  • dynamic_cast 不检查转换安全性,仅运行时检查(与运行时状态信息RTTI紧密相关),如果不能转换,返回null。

搞这么多cast的好处:除了可能收到检查外,还可以在代码库中搜索它们。

70 Conditional and Action Breakpoints in C++

普通断点的缺点:需要停止应用程序,然后重新编译;有时还很难复现bug状态

条件断点:可以告诉调试器,我想在此放置一个断点,但我只希望断点在特定条件下触发。

操作断点:允许我们采取某种动作,一般是在碰到断点时打印一些东西到控制台。

右键断点,选择选择”条件“或者”操作“,”操作”中还能取消勾选“继续执行”。