C++小记
无限咪咪
2022年12月27日 18:00
收录于文集
共10篇

前一段时间,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 “函数”会根据参数类型,在堆上申请对应大小的空间。

代码块
text/x-c++src
自动换行
复制代码
void f()
{
    int* p = new int(7);
    delete p;
}
复制成功

堆上的“变量”,不会随着声明变量代码块作用域消失而消失,所以要注意 new 和 delete 一定是成对出现的,否则会引起内存泄露。

指针很强大,但是有时候使用起来不方便。于是 C++ 增加了引用类型。左值与右值都有引用类型,起步阶段我们只要了解左值的引用类型就行了。引用的本质就是变量的别名,目的是让代码更简洁。

“&”符号来表示引用

代码块
text/x-c++src
自动换行
复制代码
#include <iostream>
#include <string>

void double_string(std::string& s)
{
	s += s;
}

std::string str = "wuxianmimi";
double_string(str);
std::cout << str << '\n';	// wuxianmimiwuxianmimi
复制成功

上述的例子,如果函数参数不使用引用类型,输出的值不会变化

代码块
text/x-c++src
自动换行
复制代码
//...
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

代码块
text/x-c++src
自动换行
复制代码
struct s { int n; double d[]; };
复制成功

在内存中,Struct 内部的成员按序存放。

static 关键字在 JS 中只能在类中修饰成员和方法,在 C++ 中,还可以在类外部定义此关键字的变量,作用是只有此文件内部可见,类似于 private。而类内部 static 修饰的变量,可以理解为在一个 Namespaces 下定义的公共变量,跟类本身没有关系了。

使用 static 还有个小技巧,函数内定义变量使用 static 关键字,可以使得作用域保持在函数内,但生命周期保持为整个应用,非函数执行完就销毁。

代码块
text/x-c++src
自动换行
复制代码
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();
}
复制成功

如果将函数第一行定义改为

代码块
text/x-c++src
自动换行
复制代码
static int num = 0;
复制成功

那输出会变成 1 2 3 4 5,就跟将变量定义在全局一样,但是好处是可以不像定义在全局的变量会被意外的访问修改。

C++ 中有三种声明数组(静态)的方法

代码块
text/x-c++src
自动换行
复制代码
#include <array>

int main()
{
	// 方法一,最经典的
	int arr[5];

	// 方法二,缺点是无法获取数组长度
	int* arr2 = new int[5];

	// 方法三,C++11新增的内置类型
	std::array<int, 5> arr3;
}
复制成功

一般第一种就够用了。注意,数组变量就是指针。

字符串就是一连串连续的 char,C 中的声明方法:

代码块
text/x-c++src
自动换行
复制代码
const char* name = "wuxianmimi";
复制成功

C++中声明方法”

代码块
text/x-c++src
自动换行
复制代码
std::string name2 = "wuxianmimi";
复制成功

字符串与数组的区别就是,字符串结尾有0来表示终止,而数组没有。还有个小技巧是,使用 string 作为函数参数时,传 const + & 来修饰参数,const 可以防止函数内部意外更改值,& 可以防止拷贝,提高程序运行速度。

const 表示不可变,在 C++ 中有个头疼的特性是,变量值与指针都可以分别表示不可变,有时候一不小心就搞错了。区分的方法是通过观看“const”与“*”的位置,来判断它到底属于哪一种情况。

代码块
text/x-c++src
自动换行
复制代码
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++ 中仿佛都有含义。先来看一下以下代码

代码块
text/x-c++src
自动换行
复制代码
#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+ 来重载 + 号

代码块
text/x-c++src
自动换行
复制代码
Vector2 operator+(const Vector2& v2) const {
	return Add(v2);
}
复制成功

使用时,直接使用 + 号“连接”两个实体

代码块
text/x-c++src
自动换行
复制代码
Vector2 v4 = v + v2;
v4.Print();	// 10, 30
复制成功

有同学会问,我 Print() 方法也不想调用了,直接放在 std::cout 流中输出可以吗?当然没问题,将 << 符号重载即可

代码块
text/x-c++src
自动换行
复制代码
std::ostream& operator<<(std::ostream& stream, const Vector2& v)
{
	stream << v.x << ", " << v.y;
	return stream;
}
复制成功

重载之后就可以直接输出值了,比调用方法看上去简洁了不少

代码块
text/x-c++src
自动换行
复制代码
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)

代码块
text/x-c++src
自动换行
复制代码
struct Vector3 {
	int x, y, z;
}

int offset = (int)&((Vector3*)0)->y;	// y 对应 4
复制成功

引入第三方库,视频中是以 glfw 作为例子的,库下载地址

代码块
text/x-c++src
自动换行
复制代码
https://www.glfw.org/download.html
复制成功

Windows 用户直接下载 32位的 Windows pre-compiled binaries,注意这里的32位、64位与本机无关,与目标机器有关。

下载完毕后解压文件,将咪咪光标选中的两个文件夹拷贝到”项目根目录 \Dependencies\GLFW\“ 下

解压后的文件夹目录

链接第三方库一般分静态链接和动态链接两种,这里只提一下动态链接的做法,分3个步骤

  1. 添加头文件路径:project properties  > C/C++ >General>Addtional Include Directories 中增加 $(SolutionDir)Dependencies\GLFW\include。

  2. 增加依赖文件夹:project properties  >Linker >General>Addtional Library Directories 中增加 $(SolutionDir)Dependencies\GLFW\lib-vc2015。

  3. 增加动态库依赖:project properties  >Linker >Input>Addtional Dependcies 增加一个 glfw3dll.lib。

  4. 拷贝 glfw3.dll 至项目根目录\Debug\ 目录下。

Main.cpp 代码如下

代码块
text/x-c++src
自动换行
复制代码
#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 实现的。它可能有点像泛型,但是模板更强,除了类型相关的事情,它还能做其他事,有点像宏。

代码块
text/x-c++src
自动换行
复制代码
#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();
}
复制成功

栈分配与堆分配,两种初始化类实例的方法如下

代码块
text/x-c++src
自动换行
复制代码
// 栈声明
Entity e;

// 堆声明
Entity* e = new Entity();
复制成功

一般情况下,能”栈”则“栈”,栈分配的效率高。栈分配内存时做2件事——移动指针、返回指针地址。而堆分配与删除都比栈分配慢很多,堆分配需要调用一整套指令,而栈分配只要一条指令。通常只有2种情况下使用堆分配:

  • 需要变量生命周期更长,在作用域外使用

  • 数据很大

宏(macro)就是让程序在预处理阶段做的事,它比 template 执行更早,在预处理阶段,而template 是在编译时才处理。咪咪的理解,宏的使用场景就是可以和发布类型结合的情况,比如下列例子,只有在 DEBUG 模式下才进行 log(需要在 Preprocessor Definitions 增加 PM_DEBUG)

代码块
text/x-c++src
自动换行
复制代码
#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 的意思是尽少使用,如果类型特别长难以阅读时,才使用。

函数指针,在上面宏的例子中已经使用了

代码块
text/x-c++src
自动换行
复制代码
// 第二个参数就是函数指针类型
void ForEach(const std::vector<int>& v, void(*func)(int))
{
	for (int num : v)
	{
		func(num);
	}
}
复制成功

函数指针的作用就是将函数作为参数传递,对于只接触过面向对象语言的同学可能一时会有点难以理解,但是对于JSer,函数作为参数传递在 js 开发中已经稀疏平常的事情了。

Lambda 表达式,宏的那个例子中也有了,作为 ForEach 第二个实参传递的就是

代码块
text/x-c++src
自动换行
复制代码
ForEach(values, [](int value) {Log(value); });
复制成功

命名空间(Namespaces),Cherno 的意思也是尽量不要用,特别是在头文件中,这会导致作用域变复杂,可能出现意想不到的 bug。

二维数组在 C++ 中,可以理解为指针的数组,每个指针又指向一个数组,注意释放内存的时候,需要从最里层的内容开始遍历 delete。另外,二维数组的访问比一维数组慢很多,原因是二层的数组指针分配的数组内存是随机的,不一定是连续的,可能导致缓存未命中,所以没有一维数组遍历快。Cherno 建议使用一维数组存放,访问时同样可以使用双层循环来访问,模拟二维数组。

Unions,挺神奇的,对同一块内存的不同解释,咪咪觉得可以配合 type punning 来理解类型在cpp中的原理。

代码块
text/x-c++src
自动换行
复制代码
#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 中的解构赋值

代码块
text/x-c++src
自动换行
复制代码
int a[2] = { 1, 2 };

auto[x, y] = a;

std::cout << x << ", " << y << std::endl;	// 1, 2
复制成功

std::optional,可以用来表示一个可能存在也可能不存在的值,视频中使用的是 IO 作为例子

代码块
text/x-c++src
自动换行
复制代码
#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,可以减少创建、复制字符串的开销

代码块
text/x-c++src
自动换行
复制代码
#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个视频所涉及的知识点,差不多就是这些了,但是其中也跳过了一些部分,比如虚函数、纯虚函数等等概念。

最近疫情肆虐,咪咪身边不少人中招了,各位也要保重好身体。

最后,依然感谢你看到这里~与君共勉~~