
前一段时间,b站首页推荐给我一个合集,叫“最好的C++教程”,当时我想我靠,如今教学视频那么多,敢说最好,必然有什么过人之处,于是点进合集草草浏览了一下各个视频标题目录,根据咪咪多年的经验,这套教学确实不错,果断地添加到稍后再看。最近有时间,将这套教程刷完了,记录点心得做一个小结,希望也能给有需要的朋友带来帮助。

视频的作者是油管的 up 主 The Cherno,是前 EA 的员工,目前除了发布视频外,在研发自己的游戏引擎 Hazel (仓库地址:https://github.com/TheCherno/Hazel)。这套 C++ 教程大部分都是17年、18年的时候发布的,当时 Cherno 只有 22 岁,但是他对 C++ 的理解已经非常透彻了,此外他讲课的思路也很清晰,全程没有废话。
这套视频虽然说很好,但是作为我们学习来说,可能还存在两点小问题。第一是课程可能不适合完全零基础的同学,Cherno 自己也在开头的视频中说,如果零基础的话,可能还需要查询很多资料才能跟上课程的节奏;第二个小问题就是语言问题,课程全程是英语,虽然前面几个视频转载的 up 主自行进行了翻译,翻译质量也很好,但是后面大部分视频目前只有英文机翻字幕,学习效率肯定是没有输入中文信息高的,但是光看代码演示的话,跟上课程的节奏应该没有问题。
环境方面,课程前半部分是基于 C++14 标准进行教学,后面有些对 C++17 新特性的补充,使用的 IDE 是 Visual Studio 2017。
本套课程并没有明显的大纲,每个视频都自成一派,可单独食用。如果实在要进行分类,根据咪咪的理解可以分为以下5个模块
基础(P01 ~ P33),这部分讲解了C++最最基础的元素,从下载 VS 开始,变量、语法、类、数组、字符串等等,这部分学完后,应该就可以自己写一个完整的 Demo 了。
进阶基础(P34 ~ P48),掌握基础后,可以通过这几个视频进一步理解、提升自己的编程水平,智能指针、操作符重载、箭头函数......,你不用这些特性也能写程序,但是知道这些后可以让你写的更好。
结合第三方库(P49 ~ P51),这三个视频,对如何静态、动态引入第三方库,站在巨人的肩膀上,才能走得更远。
工程初探(P52 ~ P74),这部分内容也可以称之为进阶基础2,相比之前的特性,这些视频介绍的特性难度有所提升,并且对性能、预编译等特性有所涉及。这些特性在学习时最大的问题可能是听懂了,但是不知道在什么情况下用!可能需要一两个项目的实战经验后才能有所顿悟。
一些新特性(P75 ~ P81),从这开始开始介绍一些 C++17 实用的新特性,此外还有异步编程与可视化 Benchmark内容。
以上 81 个视频是这套课程的精华,之后的视频都是 2020 年以后发布的,这时候 Cherno 对这个系列的更新频率没有之前高了,一般是有赞助了再更新视频。而且这些视频涉及到点比较杂、比较细、比较难,特别是 Move semantics (移动语义)这个视频 (P89),如果是有实战经验的或者前面 81 个视频内容掌握的还可以的同学,可以尝试学习,否则可以先放一放。但 P95 例外,这个视频介绍了作者自己如何学习 C++ 的方法,其实就一句话——多多阅读真实项目源码~

基础部分,声明变量、循环语句、控制语句这些最基础的,咪咪就不在这里赘述了。
首先关注一下指针,指针的本质就是内存地址,正整数,可以通过关键词 new 来返回一个指针,new “函数”会根据参数类型,在堆上申请对应大小的空间。
void f()
{
int* p = new int(7);
delete p;
} 堆上的“变量”,不会随着声明变量代码块作用域消失而消失,所以要注意 new 和 delete 一定是成对出现的,否则会引起内存泄露。
指针很强大,但是有时候使用起来不方便。于是 C++ 增加了引用类型。左值与右值都有引用类型,起步阶段我们只要了解左值的引用类型就行了。引用的本质就是变量的别名,目的是让代码更简洁。
“&”符号来表示引用
#include <iostream>
#include <string>
void double_string(std::string& s)
{
s += s;
}
std::string str = "wuxianmimi";
double_string(str);
std::cout << str << '\n'; // wuxianmimiwuxianmimi 上述的例子,如果函数参数不使用引用类型,输出的值不会变化
//...
void double_string(std::string s)
{
s += s;
}
std::string str = "wuxianmimi";
double_string(str);
std::cout << str << '\n'; // wuxianmimi
关于引用有两点要注意。第一,引用能做的,指针都能做,反过来却不行。第二,引用类型只存在源码之中,不存在内存中。
讲到内存,Visual Studio 2017 中可以通过下图的方法,在调试模式下打开内存监控窗口。

打开内存监控窗口
然后在 Address 输入框中,输入“&[变量名]”,比如”&str“,就可以跳转到改变量所在内存区域

str变量的内存区域
⚠这里有个问题,”wuxianmimi“一共是10个字符,对应着内存的 77 75 ... 6d 69 共10个字节,最后的 00 字节表示终止,但前面 60 05 50 01 这四个字节又代表什么?这四个字节的值每次运行都会改变,且无论字符串是 ”mimi“ 或者是空字符串,这四个字节都会存在。咪咪也查了一些资料,但是找不到相关的解释,不知道有没有童鞋可以给咪咪解惑一下。
我相信不少人在学习 C++ 时,会被 Struct 与 Class 搞混,因为这两个特性实在是很像。而 Cherno 的解释很简单——类成员默认私有,结构成员默认公共,因为C语言中有 Struct,C++ 考虑到兼容性,保留了下来。并且他也说了下他使用这两个特性的方法,保存结构体用Struct,保存复杂的对象用 Class。
声明 Struct
struct s { int n; double d[]; }; 在内存中,Struct 内部的成员按序存放。
static 关键字在 JS 中只能在类中修饰成员和方法,在 C++ 中,还可以在类外部定义此关键字的变量,作用是只有此文件内部可见,类似于 private。而类内部 static 修饰的变量,可以理解为在一个 Namespaces 下定义的公共变量,跟类本身没有关系了。
使用 static 还有个小技巧,函数内定义变量使用 static 关键字,可以使得作用域保持在函数内,但生命周期保持为整个应用,非函数执行完就销毁。
void Function() {
int num = 0;
num++;
std::cout << num << std::endl;
}
int main()
{
Function(); // 1
Function(); // 1
Function(); // 1
Function(); // 1
Function(); // 1
std::cin.get();
} 如果将函数第一行定义改为
static int num = 0; 那输出会变成 1 2 3 4 5,就跟将变量定义在全局一样,但是好处是可以不像定义在全局的变量会被意外的访问修改。
C++ 中有三种声明数组(静态)的方法
#include <array>
int main()
{
// 方法一,最经典的
int arr[5];
// 方法二,缺点是无法获取数组长度
int* arr2 = new int[5];
// 方法三,C++11新增的内置类型
std::array<int, 5> arr3;
} 一般第一种就够用了。注意,数组变量就是指针。
字符串就是一连串连续的 char,C 中的声明方法:
const char* name = "wuxianmimi"; C++中声明方法”
std::string name2 = "wuxianmimi"; 字符串与数组的区别就是,字符串结尾有0来表示终止,而数组没有。还有个小技巧是,使用 string 作为函数参数时,传 const + & 来修饰参数,const 可以防止函数内部意外更改值,& 可以防止拷贝,提高程序运行速度。

const 表示不可变,在 C++ 中有个头疼的特性是,变量值与指针都可以分别表示不可变,有时候一不小心就搞错了。区分的方法是通过观看“const”与“*”的位置,来判断它到底属于哪一种情况。
int main()
{
int number = 6;
// 值不变,const左,*右
int const *num1 = new int;
num1 = &number;
*num1 = 6; // error
// 指针不变,const右,*左
int* const num2 = new int;
num2 = &number; // error
*num2 = 6;
std::cin.get();
} const 还可以修饰类方法,修饰后方法内不可以更改成员。
new 是操作符,可以被重载。功能就是分配内存,返回指针。new Entity 时会调用constructor,delete 时也会调用类的 destructor 方法。new 对象时还可以指定分配的内存地址:new(指针) Entity();
操作符重载是 C++ 重要的特性之一,咪咪第一次看到的时候也觉得像“魔法”一样,感觉键盘上什么按键到 C++ 中仿佛都有含义。先来看一下以下代码
#include <iostream>
struct Vector2 {
float x, y;
Vector2(float x, float y) : x(x), y(y) {};
Vector2 Add(const Vector2& v2) const {
return Vector2(x + v2.x, y + v2.y);
}
void Print() {
std::cout << x << ", " << y << std::endl;
}
};
int main()
{
Vector2 v(1.0f, 20.0f);
v.Print(); // 1, 20
Vector2 v2(9.0f, 10.0f);
Vector2 v3 = v.Add(v2);
v3.Print(); // 10, 30
std::cin.get();
}
.Add() 方法感觉还不够直观,我们想直接使用两个实体相加,看上去更简洁,那么可以使用操作符重载,将 + 重载
Vector2 中增加下列方法, 通过 operator+ 来重载 + 号
Vector2 operator+(const Vector2& v2) const {
return Add(v2);
} 使用时,直接使用 + 号“连接”两个实体
Vector2 v4 = v + v2;
v4.Print(); // 10, 30 有同学会问,我 Print() 方法也不想调用了,直接放在 std::cout 流中输出可以吗?当然没问题,将 << 符号重载即可
std::ostream& operator<<(std::ostream& stream, const Vector2& v)
{
stream << v.x << ", " << v.y;
return stream;
} 重载之后就可以直接输出值了,比调用方法看上去简洁了不少
std::cout << v4 << std::endl; 只要记住,操作符就是函数。
智能指针,名字很酷炫,作用就是自动 delete 堆上 new 的变量。视频中介绍了3种类型,std::unique_ptr<Entity>、std::shared_ptr<Entity>、std::weak_ptr<Entity>,区别就是 delete 的时机不同。
copy constructor,当构建方法的参数类型和本身一样,将会调用这个方法。这个视频也说了一个重要的概念,除了引用类型,其余变量赋值语句,都是在复制变量。所以,尽量使用 const + & 来修饰参数。
箭头操作符,就是解构指针,然后访问成员。视频中还提到一个小技巧,关于获取类成员的偏移量(offset)
struct Vector3 {
int x, y, z;
}
int offset = (int)&((Vector3*)0)->y; // y 对应 4

引入第三方库,视频中是以 glfw 作为例子的,库下载地址
https://www.glfw.org/download.html Windows 用户直接下载 32位的 Windows pre-compiled binaries,注意这里的32位、64位与本机无关,与目标机器有关。
下载完毕后解压文件,将咪咪光标选中的两个文件夹拷贝到”项目根目录 \Dependencies\GLFW\“ 下

解压后的文件夹目录
链接第三方库一般分静态链接和动态链接两种,这里只提一下动态链接的做法,分3个步骤
添加头文件路径:project properties > C/C++ >General>Addtional Include Directories 中增加 $(SolutionDir)Dependencies\GLFW\include。
增加依赖文件夹:project properties >Linker >General>Addtional Library Directories 中增加 $(SolutionDir)Dependencies\GLFW\lib-vc2015。
增加动态库依赖:project properties >Linker >Input>Addtional Dependcies 增加一个 glfw3dll.lib。
拷贝 glfw3.dll 至项目根目录\Debug\ 目录下。
Main.cpp 代码如下
#include <iostream>
#include <GLFW/glfw3.h>
int main()
{
int a = glfwInit();
std::cout << a << std::endl;
std::cin.get();
} 编译运行,如果配置成功的话,控制台应该输出1。还有如何配置的时候,VS 项目属性配置中没有C/C++选项,只要在项目中新建一个 cpp 文件,配置项就会显示出来了。

模板 Templates 是非常强大的功能,标准库中很多功能就是通过 Templates 实现的。它可能有点像泛型,但是模板更强,除了类型相关的事情,它还能做其他事,有点像宏。
#include <iostream>
template <typename T>
void Print(T value)
{
std::cout << value << std::endl;
}
template<int size>
class MyArray
{
private:
int m_Array[size];
public:
int GetSize() const { return size; }
};
int main()
{
Print(5); // 5
Print("mimi"); // mimi
MyArray<6> a;
Print(a.GetSize()); // 6
std::cin.get();
} 栈分配与堆分配,两种初始化类实例的方法如下
// 栈声明
Entity e;
// 堆声明
Entity* e = new Entity(); 一般情况下,能”栈”则“栈”,栈分配的效率高。栈分配内存时做2件事——移动指针、返回指针地址。而堆分配与删除都比栈分配慢很多,堆分配需要调用一整套指令,而栈分配只要一条指令。通常只有2种情况下使用堆分配:
需要变量生命周期更长,在作用域外使用
数据很大
宏(macro)就是让程序在预处理阶段做的事,它比 template 执行更早,在预处理阶段,而template 是在编译时才处理。咪咪的理解,宏的使用场景就是可以和发布类型结合的情况,比如下列例子,只有在 DEBUG 模式下才进行 log(需要在 Preprocessor Definitions 增加 PM_DEBUG)
#include <iostream>
#include <vector>
#if PM_DEBUG
#define Log(x) std::cout << x << std::endl
#else
#define Log(x)
#endif
void ForEach(const std::vector<int>& v, void(*func)(int))
{
for (int num : v)
{
func(num);
}
}
int main()
{
std::vector<int> values = { 1, 2, 3, 4, 5 };
ForEach(values, [](int value) {Log(value); });
std::cin.get();
} auto 类型,Cherno 的意思是尽少使用,如果类型特别长难以阅读时,才使用。
函数指针,在上面宏的例子中已经使用了
// 第二个参数就是函数指针类型
void ForEach(const std::vector<int>& v, void(*func)(int))
{
for (int num : v)
{
func(num);
}
} 函数指针的作用就是将函数作为参数传递,对于只接触过面向对象语言的同学可能一时会有点难以理解,但是对于JSer,函数作为参数传递在 js 开发中已经稀疏平常的事情了。
Lambda 表达式,宏的那个例子中也有了,作为 ForEach 第二个实参传递的就是
ForEach(values, [](int value) {Log(value); }); 命名空间(Namespaces),Cherno 的意思也是尽量不要用,特别是在头文件中,这会导致作用域变复杂,可能出现意想不到的 bug。
二维数组在 C++ 中,可以理解为指针的数组,每个指针又指向一个数组,注意释放内存的时候,需要从最里层的内容开始遍历 delete。另外,二维数组的访问比一维数组慢很多,原因是二层的数组指针分配的数组内存是随机的,不一定是连续的,可能导致缓存未命中,所以没有一维数组遍历快。Cherno 建议使用一维数组存放,访问时同样可以使用双层循环来访问,模拟二维数组。
Unions,挺神奇的,对同一块内存的不同解释,咪咪觉得可以配合 type punning 来理解类型在cpp中的原理。
#include <iostream>
struct Vector2
{
float x, y;
};
struct Vector4
{
union
{
struct
{
float x, y, z, w;
};
struct
{
Vector2 a, b;
};
};
};
void PrintVector2(const Vector2& v2)
{
std::cout << v2.x << ", " << v2.y << std::endl;
}
int main()
{
Vector4 v4 = { 1.0f, 2.0f, 3.0f, 4.0f };
PrintVector2(v4.a); // 1, 2
PrintVector2(v4.b); // 3, 4
v4.z = 600.0f;
std::cout << "++++++++++++++++++" << std::endl;
PrintVector2(v4.a); // 1, 2
PrintVector2(v4.b); // 600, 4
std::cin.get();
} 接下来几个章节都比较深,什么 virtual destructor、casting,如果没有一定的基础会听的云里雾里的。有些概念听完了,但是却不知道实际编程时使用的场景。作为初学者,咪咪认为还是不要深入到这些概念中,把一些基础概念吃透后,有了一定的实战经验,再回头看这些知识,可能更有帮助。

P75 ~ P81 这几个视频介绍一些 C++17 实用的新特性,注意 VS 首先要配置一下语言标准,配置项如下 Properties-C/C++-Language-C++ Language Standard 选择 c++ 17

选择c++语言标准
结构绑定(struct binding),有点类似 ES6 中的解构赋值
int a[2] = { 1, 2 };
auto[x, y] = a;
std::cout << x << ", " << y << std::endl; // 1, 2 std::optional,可以用来表示一个可能存在也可能不存在的值,视频中使用的是 IO 作为例子
#include <iostream>
#include <optional>
#include <fstream>
#define Log(x) std::cout << x << std::endl
std::ostream& operator<<(std::ostream& stream, const std::string& v)
{
stream << v;
return stream;
}
std::optional<std::string> readFileAsString(const std::string& filePath)
{
std::ifstream stream(filePath);
if (stream)
{
std::string result;
stream.close();
return result;
}
return {};
}
int main()
{
std::optional<std::string> data = readFileAsString("data.txt");
std::string count = data.value_or("不存在");
if (data.has_value())
{
Log("成功读取文件内容");
}
else
{
Log("无法读取文件");
}
} std::variant,又是多类型的值,与 Union 的区别是 std::variant 每种类型,都分配了一块内存,而 Union 还记得吗,是对同一块内存不同的解释。
any 类型,Cherno 小哥也说不实用。。。
std::string_view,可以减少创建、复制字符串的开销
#include <iostream>
#include <string>
#include <string_view>
static uint32_t s_AllocCount = 0;
void* operator new(size_t size)
{
s_AllocCount++;
std::cout << "分配内存:" << size << "字节;\n";
return malloc(size);
}
#define STRING_VIEW 1
#if STRING_VIEW
void PrintName(std::string_view name)
{
std::cout << name << std::endl;
}
#else
void PrintName(const std::string& name)
{
std::cout << name << std::endl;
}
#endif
int main()
{
std::string name = "wuxianmimi";
#if STRING_VIEW
std::string_view firstname(name.c_str(), 6);
std::string_view lastname(name.c_str() + 6, 4);
PrintName(firstname);
PrintName(lastname);
std::cout << "分配内存" << s_AllocCount << "次" << std::endl; // 1次
#else
std::string firstname = name.substr(0, 6);
std::string lastname = name.substr(6, 4);
PrintName(firstname);
PrintName(lastname);
std::cout << "分配内存" << s_AllocCount << "次" << std::endl; // 3次
#endif
std::cin.get();
}

81个视频所涉及的知识点,差不多就是这些了,但是其中也跳过了一些部分,比如虚函数、纯虚函数等等概念。
最近疫情肆虐,咪咪身边不少人中招了,各位也要保重好身体。
最后,依然感谢你看到这里~与君共勉~~