71现代CPP的安全性和教学理念

  • 重点围绕 原始指针(Raw Pointers)与智能指针(Smart Pointers) 的长期争论,以及为什么坚持从底层原理开始教学

视频看过一遍了,全程没有讲到代码例子,所以这里用了ai总结这个视频

The Cherno 在本集中讨论了“安全编程”在 C++ 中的定义,重点围绕 原始指针(Raw Pointers)与智能指针(Smart Pointers) 的长期争论,以及他为什么坚持从底层原理开始教学。

时间线总结

话题引入#

  • 讨论 C++ 社区中关于“现代 C++”与“安全性”的激烈争论。
  • 两种极端观点:
    • 现代派: 只应使用现代 C++,永远不碰原始指针。
    • 传统派: 智能指针有性能开销,真男人就该手动管理内存。

什么是 C++ 中的“安全性”?#

  • 安全编程的核心目标是:减少崩溃、内存泄漏和非法访问(Access Violations)
  • 从 C++11 开始,社区转向智能指针,主要是为了解决堆内存分配中的人为错误

内存管理的两大痛点#

  • 忘记释放内存: 导致内存泄漏,可能导致程序最终因内存不足而崩溃。
  • 所有权问题(Ownership): 当一个指针在多个函数或类之间传递时,不清楚由谁负责销毁它。

智能指针的本质#

  • 智能指针本质上只是对一两行代码的自动化:自动调用 delete 或释放内存
  • 它们通过自动化来减少人为疏忽,提高代码的健壮性。

他的立场:你应该使用智能指针#

  • 在实际的生产代码或大型框架中,100% 应该使用智能指针
  • 不使用它们会极大地增加代码维护的难度和出错的风险。

为什么他还在视频里用原始指针?#

  • 简洁性: 在简单的 Sandbox(沙盒)测试或只有 100 行左右的小样板代码中,原始指针写起来更快、读起来更直观。
  • 环境区别: 学习阶段的小程序不需要像生产环境那样严谨地考虑所有权转移。

教学责任与影响#

  • 反思作为拥有大量订阅者的博主,使用原始指针是否会给初学者带来不良示范(类似于红灯穿马路)
  • 但他认为,如果不教原始指针,学生就无法真正理解 C++ 的工作原理。

核心教学哲学:理解底层(非常重要)#

  • 智能指针只是原始指针的封装(Wrapper)。
  • 如果你不理解原始指针和内存是如何运作的,你就无法成为一名优秀的 C++ 程序员,尤其是处理高性能或实时系统的开发。
  • ==学习“旧东西”==是为了更好地掌握“新工具”。

核心结论#

  • 在项目开发中: 推荐使用智能指针,为了安全和效率。
  • 学习过程中: 必须掌握原始指针,为了理解底层和内存真相。

Cherno 的观点: 避开底层原理而只学“安全”的做法,无法培养出真正顶尖的开发者。

70条件断点与操作断点设置

  • 本节介绍visual studio 快速小技巧,也是一个适用于开发和调试的通用技巧,围绕断点展开
  • 围绕条件展开,以及一些可应用于断点的操作
    • 条件断点 断点在特定条件下触发
    • 动作断点 断点触发时进行一些操作在控制台打印内容并继续/或暂停执行程序

断点在代码里也可以实现

  • 写if语句并(手动)设置断点 也可以直接debug break;之类的编译器内部函数

调试中发现问题的情况下,如果使用ide的调试断点功能,就能省去重编译、编写代码再调试运行

添加Action和Condition#

#ifdef LY_EP70
#include <iostream>

int main()
{ 
	int a = 2, b = 3;
	for (int i = 0; i < 5; i++) {
		std::cout << "i:" << i << std::endl;
	}
	std::cout << a << std::endl;
	std::cout << b << std::endl;
	std::cin.get();
	return 0;
}
#endif
  • 先添加断点,然后再点击Action,最后添加打印内容i is {i} ,values of a,b is : {(float)a}, {(float)b}
  • 然后设置Condition,设置为i == 3,即只有在i == 3时才触发断点

这个contine code execution如果勾上了,则调试时不会停止,会直接跳过该断点

69类型转换

  • 类型转换(Type Casting)是在 C++ 强类型系统中进行类型间转换的过程。
  • cpp作为一种强类型语言,意味着有一个类型系统,且类型是强制规定的
    • 如果一个东西是整型,不能突然把它拿来当双精度数,除非有简单的==隐式转换 意味着cpp知道如何在这两种类型之间转换且不丢失数据 ==
    • 可以使用显示转换
  • 类型转换包括C风格转换C++风格转换

基本的显示/隐式转换#

#ifdef LY_EP69
#include <iostream>

int main()
{
	int a = 5;
	double value = a;//隐式转换

	double value1 = 5.25;
	int a1 = value1;//隐式转换

	int a2 = (int)value1;//显示转换

	std::cout << "a:" << a << std::endl;//5
	std::cout << "value:" << value << std::endl;//5
	std::cout << "value1:" << value1 << std::endl;//5.25
	std::cout << "a1:" << a1 << std::endl;//5
	std::cout << "a2:" << a2 << std::endl;//5


	std::cin.get();
	//成功的情况只有一种(0),而失败的原因
	//可以有成千上万种(1, 2, 3...)。
	return 0;
}
#endif

类型转换例子#

#ifdef LY_EP69
#include <iostream>

class Base
{
public:
	Base() {}

	//这里应该把析构函数设为vitual,我只是为了演示
	//只要有虚函数就能用动态转换转换成功,而这个虚函数
	//不一定非得是析构函数
	~Base() {} 

	virtual void test() {}
};

//public Base中 <public> 决定:基类(Base)中的成员在
//派生类(Derived)中保持什么样的访问权限。(public则成员
//的最高权限是public)
class Derived : public Base
{
public:
	Derived() {}
	~Derived() {}
};

class AnotherClass : public Base
{
public:
	AnotherClass() {}
	~AnotherClass() {}
};

int main()
{
	//C风格转换
	double value = 5.25;
	double a1 = value + 5.3;//10.55
	double a2 = (int)value + 5.3;//强制截断,10.3 
	double a3 = (int)(value + 5.3);//强制截断,10 
	std::cout << a1 << std::endl;
	std::cout << a2 << std::endl;
	std::cout << a3 << std::endl;

	// C++风格转换(以下C风格转换都能做到,但是C++风格还会做点
	// 额外的事情)
	double s = static_cast<int>(value) + 5.3;//1 静态转换,会有编译时检查

	//double s1 = static_cast<AnotherClass>(value) + 5.3;//编译失败,没有哪个构造函数支持,即没有 AnotherClass(int)这样的构造函数先隐式转换成AnotherClass

	//AnotherClass s11 = static_cast<AnotherClass*>(value) ;//编译失败,无效的类型转换
	//AnotherClass s12 = static_cast<AnotherClass*>(&value) ;;//编译失败,无效的类型转换


	//2 重新解释转换 //类似类型双关
	AnotherClass* a = reinterpret_cast<AnotherClass*>(&value);//“重新解释转换”,编译通过,即指向该值的指针,解释成:指向另一个类实例的指针


	//3 动态类型转换 //多态时用到
	Derived* derived = new Derived();
	Base* base = derived;
	//检查基类指针是否是(某个)派生类实例 
	//静态转换,但很明显这会出问题(base是指向Derived实例而非AnotherClass)
	AnotherClass* ac1 = static_cast<AnotherClass*>(base);
	AnotherClass* ac1_1 = (AnotherClass*)(base);

	Derived* ac3 = dynamic_cast<Derived*>(base);//编译通过

	AnotherClass* ac2 = dynamic_cast<AnotherClass*>(base);//编译通过,但是返回null
	if (ac3)
	{
		std::cout << "ac3转换成功" << std::endl;//ac3转换成功
	}
	if (ac2)
	{
		std::cout << "ac2转换成功" << std::endl;//没成功
	}

	//4 常量解释转换:移除或添加常量
	//视频没说到

	std::cin.get();
	//成功的情况只有一种(0),而失败的原因
	//可以有成千上万种(1, 2, 3...)。
	return 0;
}
#endif
  • dynamic_cast 是在==运行时(Runtime)==通过查询虚函数表(vtable)来确认对象的实际类型的。
    • 规则:只有当类中至少包含一个==虚函数(virtual function)==时,编译器才会为该类生成类型信息(RTTI)。
    • 所以如果被转换的实例类中完全没有虚函数,那么使用dynamic_cast时在编译阶段就报错了
  • RTTI (运行时类型识别):当类包含虚函数时,编译器会在对象内存布局中增加一个指向“虚函数表”的指针。表中包含了该类的 type_info
  • 安全性检查:执行 dynamic_cast 时,程序会检查内存中对象的实际 type_info。如果 Base* base 实际上指向的是 Derived,转换成功;如果指向的是其他不相关的类,则返回 NULL

常量转换#

#ifdef LY_EP69
#include <iostream>

void IncreaseValue(const int* val_ptr) {
	// 编译失败:不能修改 const 指针指向的内容
	//*val_ptr = 10; 

	// 使用 const_cast 去掉 const 属性
	int* modifiable_ptr = const_cast<int*>(val_ptr);
	*modifiable_ptr += 5;
}

int main() {

	//number 是一个普通的非 const 变量 
	int number = 10;

	std::cout << "修改前: " << number << std::endl;

	// 传入 number 的地址
	IncreaseValue(&number);

	std::cout << "修改后: " << number << std::endl; // 输出 15


	//=====演示有问题情况=====
	const int constant_var = 100; // 原始变量就是常量
	const int* ptr = &constant_var;

	int* naughty_ptr = const_cast<int*>(ptr);
	*naughty_ptr = 200; // 危险!这是“未定义行为”
	std::cout << "constant_var修改后: " << constant_var << std::endl;

	// 此时打印 constant_var,结果可能还是 100,也可能是 200,取决于编译器优化

	std::cin.get();
	return 0;
}
#endif

为什么number修改成功,而constant_var修改失败

68虚析构函数

  • 虚析构函数对处理多态性而言非常重要
  • 如果类B从类A派生而来,A a=new B(),现在如果决定detele a ,此时希望运行的不仅是A的析构函数,还有B的析构函数。这本质上就是虚析构函数的作用

虚函数的核心作用与机制#

在 C++ 中,虚函数(Virtual Function) 是实现多态性(Polymorphism)的核心机制。它允许程序在运行期间(Runtime)根据对象的实际类型来调用相应的函数,而不是在编译期间(Compile time)就固定死。

1. 实现动态绑定 (Dynamic Binding)#

这是虚函数最直接的作用。在基类指针或引用指向派生类对象时,调用虚函数会执行“子类版本”而非“基类版本”。

  • 非虚函数: 编译器根据指针的类型决定调用哪个函数(静态绑定)。
  • 虚函数: 编译器根据指针指向的实际对象决定调用哪个函数(动态绑定)。

2. 支持接口复用与扩展 (Extensibility)#

通过虚函数,你可以编写通用的代码来处理不同类型的对象,而无需知道它们的具体类别。

特性说明
一致性基类定义接口规范,所有子类必须遵循。
可扩展性增加新的子类时,不需要修改原有的基类或调用方的逻辑。
重写 (Override)子类可以根据自身需求,重新定义基类的行为。

3. 虚析构函数的重要性 (Virtual Destructor)#

在多态场景下,基类的析构函数必须声明为 virtual

  • 原因: 如果基类析构函数不是虚函数,当删除一个指向派生类对象的基类指针时,只会调用基类的析构函数,导致派生类特有的成员变量无法被正确释放,从而产生内存泄漏 (Memory Leak)

4. 底层实现:虚函数表 (Vtable)#

虚函数是通过 虚函数表 (Virtual Table, vtbl)虚函数表指针 (vptr) 实现的:

  1. vptr: 每个含有虚函数的对象内部都有一个隐藏指针。
  2. vtbl: 存储了该类所有虚函数的地址。
  3. 调用过程: 程序运行阶段,通过对象的 vptr 找到 vtbl,再从中找到对应的函数地址。这虽然比普通函数调用稍微多了一点点开销,但换取了极大的灵活性。

简短代码示例#

class Animal {
public:
    virtual void speak() { cout << "Animal speaks"; } // 虚函数
    virtual ~Animal() {} // 虚析构函数
};

class Dog : public Animal {
public:
    void speak() override { cout << "Woof!"; } // 重写
};

void makeItSpeak(Animal* a) {
    a->speak(); // 运行阶段根据 a 指向的具体对象决定调用哪个 speak()
}

类继承时构造与析构的顺序#

#ifdef LY_EP68
#include <iostream>

class Base
{
public:
	Base() { std::cout << "Base constructor\n"; }
	~Base() { std::cout << "Base destructor\n"; }
};

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();
}
#endif
/*输出
Base constructor
Base destructor
------------------
Base constructor
Base destructor
------------------
Base constructor
Derived constructor
Derived destructor
Base destructor
------------------
Base constructor
Derived constructor
====================> 输出没有这行:Derived destructor,没有调用子类析构函数,即内存泄露,没有释放m_array
Base destructor
*/

第一部分:new Base() 这是最基础的情况:

67联合体

  • 联合体有点像结构体,不过一次只能占用一个成员的内存,即每个成员都共享同一块内存
    • 如果我们有一个结构体,我们在里面声明四个浮点数,那就意味着我们有4个4字节,即那个结构体共有16个字节
    • 联合体只能有一个成员,如果声明4个浮点数(float),如A、B、C、D,联合体的大小仍为4个字节。当尝试访问A或B或C或D时,他们实际上是同一块内存,即把A改成5,马上访问D时值也是5
  • Struct 会为每个成员分配独立空间,而 Union 里的所有成员起始地址相同。
  • 使用联合体的方式和使用结构体或类完全一样,也能添加静态函数、普通函数和方法 不能有虚方法;静态成员不占 Union 实例空间
  • 类型双关(Type Punning):用不同的名称或类型查看同一块原始内存。
    • 当要给同一个变量取两个不同名字时,可以用联合体
  • 通常联合体匿名使用、也不添加方法

联合体不能有虚方法的原因#

虚函数的工作原理依赖于虚函数表指针(vptr)。

  • Class/Struct 的做法:编译器会在对象内存的开头插入一个隐藏的指针(vptr),指向该类的虚函数表(vtable)。
  • Union 的冲突:Union 的所有成员都必须从偏移量 0 开始共享内存。
    • 如果 Union 有虚函数,那么它必须有一个 vptr。
    • 这个 vptr 会占用内存。由于 Union 成员共享空间,你的数据成员(比如 int 或 float)会和这个 vptr 重叠。
    • 一旦你给成员赋值,就会覆盖掉 vptr,导致程序在调用虚函数时崩溃;反之,虚函数机制也会破坏你的数据。

例子:类型双关#

#ifdef LY_EP67_
#include <iostream>
 

int main()
{
	struct Union
	{
		union
		{
			float a;
			int b;
		};
	};

	Union u;

	//在 C++ 中,如果你在一个 struct 或 class 内部定义了一个
	//_没有名字_的 union,编译器会把这个联合体的成员直接注
	//入(Injected)到外层结构体的作用域中。
	u.a = 2.0f;//0x00F6FE20  00 00 00 40
	//40 00 00 00 为 0100 0000 0000 0000 0000 0000 0000 0000
	//u.b:0表示正数,所以这个值为2^30次方,即 10,7374,1824 
	//也就是我们拿到了构成那个浮点数的内存,然后把它当做int来解释(
	//类型双关)
	std::cout << u.a << "," << u.b << std::endl;

	std::cin.get();
}
#endif

相对拙劣的做法#

#ifdef LY_EP67
#include <iostream>

struct Vector2
{
	float x, y;
};

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

	Vector2& GetA()
	{
		//把&x当成一个Vector2的地址来解释
		//*表示取值的时候连续取两个float的值来构成一个Vector2
		return *(Vector2*)&x;
	}

	//尝试把Vector4看成是两个Vector2
	//Vector2 GetA()
	//{
	//	//..
	//	//添加一些成员并返回,这里省略了
	//	return Vector2();
	//}
};

void PrintVector2(const Vector2& v)
{
	std::cout << "Vector2: (" << v.x << ", " << v.y << ")\n";
}

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

利用union原理并巧妙使用它#

#ifdef LY_EP67
#include <iostream>

struct Vector2
{
	float x, y;
};

//最外层 struct Vector4:定义了这块内
// 存的总大小(4 x 4 = 16 字节)。
struct Vector4
{

	//这是一个“平行空间”。它(union)告诉编译器:“我内部定义的所有东西,都从同一个起始地址开始存放”。
	union
	{

		//union的大小,是它内部最大的成员的大小。在这个例子中,union的大小是16字节(4个float,每个4字节)。

		//它把这 16 字节切分成了四个 float
		//,并分别取名为 x, y, z, w。(这个struct
		//是联合体的第1个成员)
		struct
		{
			float x, y, z, w;
		};

		//(这个struct是联合体的第2个成员)
		struct
		{
			Vector2 a, b;
		};

	};
};

void PrintVector2(const Vector2& v)
{
	std::cout << "Vector2: (" << v.x << ", " << v.y << ")\n";
}

int main()
{
	Vector4 vector = { 1.0f, 2.0f, 3.0f, 4.0f };

	//可以用vector.x,vector.y,vector.z,vector.w来访问这四个float
	//或者用vector.a和vector.b来访问这两个Vector2
	//且vector.a.x和vector.a.y分别对应vector.x和vector.y,vector.b.x和vector.b.y分别对应vector.z和vector.w(
	//因为它们共享同一块内存,所以修改其中一个成员会影响到其他成员的值)
	vector.x = 2.0f;
	std::cout << "Vector4: (" << vector.x << ", " << vector.y << ", " << vector.z << ", " << vector.w << ")\n";// Vector4: (2, 2, 3, 4)

	//验证上述结论
	//这里修改了z却间接修改了vector.b
	vector.z = 500.0f;
	std::cout << "-----" << std::endl;

	PrintVector2(vector.a); // Vector2: (2, 2)
	PrintVector2(vector.b); // Vector2: (500, 4)
	std::cin.get();
}
#endif

66类型双关

  • 类型双关指的是,C绕过类型系统的一种方式,C是强类型语言,不会把所有东西都设置为auto(可以,但不推荐)
  • C++中类型在一定程度上由编译器强制限定,但你可以直接访问内存
  • 本章节实例会访问某个int数值的内存,并把它当做双精度数来处理(轻易绕过类型系统)
    • 假设有一个类(基本类型结构体),我们想把它写成字节流,它没有其他指针指向它,那么我们可以直接解释整个结构体、类、或者其他东西,这里会视为字节数组:只要我们知道任何大小,就可以直接访问数据

转换#

#ifdef LY_EP66
#include <iostream>

int main()
{ 
	//小端法,低字节的数据存储在内存的低地址处,
	// 高字节的数据存储在内存的高地址处
	//Ox00000032
	int a = 50;// 32 00 00 00 
	 
	//隐式转换,a隐式转换成了double类型
	//相当于 double value =(double)a
	//Ox404900000000000000,double数值50
	//的16进制表示为0x4049000000000000
	double value = a;// 00 00 00 00 00 00 49 40 
	std::cout << value << std::endl;

	std::cin.get();
}
#endif

转换后内存中的数值变了

类型双关-不同方式读取同一内存#

#ifdef LY_EP66
#include <iostream>

int main()
{
	int a = 50;
	//把a(内存处)的内存以double类型的方式进行访问
	//将整型进行类型双关变成双精度型
	//* 操作符会尝试从该地址开始,一口气往后读 8 个字节,这里是
	//32 00 00 00 cc cc cc cc (小端法)
	double  value = *(double*)&a;
	std::cout << a << std::endl;
	std::cout << value << std::endl;

	//不想创建全新变量,只是想把这个整数当成双精度数来访问,
	//那么可以在这个双精度数后面加&,这样就能引用他
	//强行让 value1 成为 &a 处那块 8 字节内存的别名。通过
	// value1 修改数据会直接破坏 a 及其相邻内存。
	double&  value1 = *(double*)&a;
	//这里会把8个字节(a和a后面的4个字节)都写入0
	value1 = 0;
	std::cin.get();
}
#endif

65排序

  • 数据结构决定了数据的存储方式
  • 本节主要讲解如何对现有数据进行排序
  • 当使用C及其内置集合类型,如std::vector时,优先用C内置的函数排序:能根据你提供的任何迭代器进行实际排序

cppreference中的解释#

//std::sort
//Defined in header <algorithm>
template< class RandomIt >
void sort( RandomIt first, RandomIt last ); (1)(constexpr since C++20)

template< class ExecutionPolicy, class RandomIt >
void sort( ExecutionPolicy&& policy,
           RandomIt first, RandomIt last ); (2)(since C++17)
           
template< class RandomIt, class Compare >
void sort( RandomIt first, RandomIt last, Compare comp ); (3)(constexpr since C++20)

template< class ExecutionPolicy, class RandomIt, class Compare >
void sort( ExecutionPolicy&& policy, RandomIt first, RandomIt last, Compare comp );  (4)(since C++17)

可以传入迭代器、谓词(非必须)

迭代器:存储了原数据的地址。可以利用迭代器迭代原数据,存储了地址所以可以方便的交换数据

没有返回值,时间复杂度为O(N·log(N))

Given N as last - first:

1,2) O(N·log(N)) comparisons using operator<(until C++20)std::less{}(since C++20).
3,4) O(N·log(N)) applications of the comparator comp.

例子#

algorithm:英[ˈælɡərɪðəm]

64多维数组

  • 多维数组,其实就是由数组作为元素,构成的数组

二维数组与三维数组#

#include <iostream>

int main()
{
	//存储50个整数, 指针存储这50个整数的第一个数的地址
	//50*4=200字节
	int* array = new int[50];

	//A2D, 2D数组,50个[整数指针]
	//分配50个[整数指针]; 32位系统,一个指针4字节
	//50*4=200字节
	int** a2d = new int*[50];

	//都是分配了200个字节,4个字节一组,总共50组

	//类型,其实就是在设置处理这些属性的语法,比如 int* array,
	//int** a2d等等
	array[0] = 0;//处理的是整数
	a2d[0] = nullptr;//处理的是指针

	//有空间存储50个指针,这些指针共占200字节,之后我们可以遍历
	//它们,并将每个指针都设置为指向一个数组. 其实最终得到的就是50个数组
	//二维数组: 有一个数组,包含着(50个)[数组的内存地址] 
	for (int i = 0; i < 50; i++)
	//为50个数组元素(指针)处理操作
	{
		//让每一个数组元素(指针)的值,等于new(50个元素)出来的数组的首地址
		a2d[i] = new int[50];
	}


	//分配50个(二维数组)
	int*** a3d = new int**[50];
	//遍历每一个二维数组
	for (int i = 0; i < 50; i++)
	{
		//初始化二维数组
		a3d[i] = new int* [50];
		//遍历a3d[i]这个数组的50个元素
		for (int j = 0; j < 50; j++)
		{
			int** ptr = a3d[i];
			//让每个元素指向new出的50个元素的数组
			ptr[j] = new int[50];
		}
	}
	//a3d[0]:得到一个二级指针数组
	//a3d[0][0]:得到一个指针数组
	//a3d[0][0][0]:得到整数
	a3d[0][0][0] = 0;

	std::cin.get();
}

详解二维数组#

#ifdef LY_EP64
#include <iostream>

int main()
{
	//创建一个数组,每个数组元素是指针
	int** a2d = new int* [50];

	//对50个元素赋值
	for (int i = 0; i < 50; i++)
	{
		//对第i个指针元素赋值,每个指针元素
		//指向新创建的数组
		a2d[i] = new int[50];

	}
	//第1个数组的第一个元素赋值为0
	//左边的0是指针的索引
	//右边的0,1,2是整数的索引
	a2d[0][0] = 0;
	a2d[0][1] = 0;
	a2d[0][2] = 0;

	//没有这种语法,编译报错
	//delete[][] a2d;


	for (int i = 0; i < 50; i++)
	{
		//删除所有内部的数组
		delete[] a2d[i];
	}
	//这个删除的是,保存指针元素的那个数组(最外层)
	delete[] a2d;

	std::cin.get();
}
#endif

内存碎片化#

#ifdef LY_EP64
#include <iostream>

int main()
{ 
	//包含5个指针元素的数组
	int** a2d = new int* [5];
	 
	for (int i = 0; i < 5; i++)
	{ 
		//每个元素都包含五个数组
		a2d[i] = new int[5];
	} 

	//设置元素的值
	for (int y = 0; y < 5; y++)
	{
		for (int x = 0; x < 5; x++)
		{
			a2d[y][x] = 2;
		}
	}

	//这25个元素并不是一个连续的能
	//容纳25个整数的缓冲区,而是5个独立
	//的缓冲区,每个缓冲区能容纳5个整数
	//这5个缓冲区,被分配到内存中的随机位置

	for (int i = 0; i < 5; i++)
	{
		//删除所有内部的数组
		delete[] a2d[i];
	}

	//这个删除的是,保存指针元素的那个数组(最外层)
	delete[] a2d;

	std::cin.get();
}

#endif;
  • 如果我们必须遍历,每次遍历5个整数后(访问25个整数中的每一组),然后转到数组的下一行(下一个整数数组),到内存中的另一个位置读取/写入数据,这可能会导致缓存未命中,这意味着从内存中获取数据时会浪费时间(如果他们分配得很近可能不会缓存未命中)
    • 如上所示,这样遍历这25个整数,比在内存中连续分配一个25个整数的一维数组慢很多,因为那段内存是连续的
    • 当你编程优化时,当你处理内存时,优化时最重要的一件事就是优化内存访问
    • 如果能够在内存中紧凑存储要访问的内存,并且通过某种方式摆放,以获得更多的缓存命中,使程序运行更快
  • 缓存学习包括:CPU缓存如何工作

代替二维数组#

#ifdef LY_EP64
#include <iostream>

int main()
{
	//这是一个栈上的原生二维数组,且内存中连续(对比int** a2d = new int* [5];方式)
	//物理形态:[ 1, 2, 3, 4, 5, 6, 7, ... 25 ] 
	//特点:编译器通过公式 Address + (row x 5 + col) x 4 直接定位。没有额外的寻址操作。(字节定位)
	int a[5][5] = { 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 };
	for (int i = 0; i < 5; i++)
		for (int j = 0; j < 5; j++)
			std::cout << a[i][j] << std::endl;


	//使用一维数组(连续的一块内存)完成二维数组5 * 5 的数组功能
	int* array = new int[5 * 5];
	for (int y = 0; y < 5; y++)
	{
		for (int x = 0; x < 5; x++)
		{
			array[x + y * 5] = 2;
		}
	}

	std::cin.get();
}

#endif;

建议#

  • 避免使用二维数组,如果存储一个位图,有一幅图像的所有元素,会把图像(正方形或长方形)看成二维的,但不代表要把它存为二维数组,建议存为一维数组

为什么推荐“平铺的一维数组”而不是int a[5][5]#

  • 动态需求:实际开发中(如加载一张图片),你往往在运行时才知道宽度和高度。这时你无法使用 int a[W][H] (W和H如果是动态则编译失败),只能用 new
  • 统一性:如果你习惯了 array[y * width + x],那么无论数组是在栈上(int array[25];)还是堆上(int* array = new int[width * height];),你的逻辑 底层逻辑 都是统一的。
    • 对比:如果你用原生语法,栈上是 a[y][x],堆上如果你用 int** 也是 a[y][x] 如果用int* array = new int[width * height];就没办法用这种形式. 和前者甚至不🙆‍♀️ ,但它们的底层指令完全不同(一个计算偏移,一个跳转指针),且不能互相传递。
  • 传递方便:将 int a[5][5] 作为参数传递给函数很麻烦(必须指定列数,如 void func(int arr[][5])),而传递一个 int* 则非常简单通用
    • 编译器必须知道“每一行有多少个元素”,否则它无法计算出a[i][j]的内存地址。

63计时

  • 计时:完成某项操作或执行特定代码所需的时间
  • c++17加入了 chrono

计时简单例子#

#ifdef LY_EP63

#include <iostream> 
//引入了本节所需要的大部分内容
//跨平台计时
#include <chrono>
//线程相关
#include <thread>

int main()
{
	//为了使用1s表示1秒的用法
	using namespace std::literals::chrono_literals;

	//获取当前时间
	//auto--> std::chrono::steady_clock::time_point
	auto start = std::chrono::high_resolution_clock::now();

	//模拟耗时
	//指定大概睡眠1秒,会有其他开销
	std::this_thread::sleep_for(1s);
	auto end = std::chrono::high_resolution_clock::now();
	
	//算出实际时长
	//也可以用auto
	std::chrono::duration<float> duration = end - start;
	std::cout << duration.count() << "s" << std::endl;
	
	std::cin.get();
}
#endif 

解释字面量1s#

1s 被称为 用户定义字面量 (User-defined literals),代表一个 std::chrono::seconds 对象,其值为 1。字面量本质上是调用了一个特殊的运算符函数

// 编译器实际执行的操作(示意)
operator""s(1);

在c++的标准库的头文件中,它的定义大致如下:

// 简化后的底层实现
constexpr chrono::seconds operator""s(unsigned long long _Val) noexcept {
    return chrono::seconds(_Val);
}
  • operator"":这是定义字面量的关键字
  • s:这是后缀名称。
  • constexpr:意味着这个转换在编译期就能完成,没有任何运行时开销(Runtime overhead)。

给函数计时#

#ifdef LY_EP63

#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;//可以省略不存储结束值

		//1000.0f不是重载,而是编译器硬编码的,1000f会编译报错
		float ms = duration.count() * 1000.0f;
		//24ms
		//std::cout << "Timer took " << ms << "ms" << std:: endl;
		//21ms
		std::cout << "Timer took " << ms << "ms\n";// << std::endl;
	}
};

void Function()
{
	//作用域(函数)内创建Timer对象,整个(函数)作用域(结束后)都会被计时
	Timer timer;
	for (int i = 0; i < 100; i++)
		std::cout << "Hello" << std::endl;
}

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

其他#

  • visual studio也自带分析工具
  • 或者其他任何集成开发环境也许自带分析工具
  • 可以在源代码中修改使其具有类似的计时(分析)工具
  • 还可以用来展示每个函数运行时长的图表,以及那个函数调用了哪个函数

62多线程

  • std::cin.get() 等待用户输入的那段时间,什么也做不了。此时就可以在后台开另一个线程做其他事

从这篇开始,代码都会写在同一个项目中,使用Mainxx_yy.cpp的类似名字。由于同一个项目有多个main函数(同一篇都好几个),所以文件最顶端添加宏进行判断,visual studio中对项目配置如下

函数声明与类创建[区别]#

#ifdef LY_EP62

#include <iostream>
#include <thread>

class Test
{
public:
	Test()
	{
		std::cout << "Test()" << std::endl;
	}

	Test(int k)
	{
		std::cout << "Test(int)" << std::endl;
	}
};
void DoWork()
{

}

int main()
{
	//声明一个类Test的对象
	Test t{};

	// 这是一个函数声明,声明一个函数名
	// 为 t1,没有参数,返回类型
	// 是 Test
	Test t1();

	// 这是一个函数声明,声明一个函数名
	// 为 worker,没有参数,返回类型
	// 是 int
	std::thread worker1();
	
	//声明一个线程对象
	std::thread worker2{};

	//创建线程并立即启动DoWork内的操作
	std::thread worker(DoWork);
	worker.join();

	std::cin.get();
}
#endif

线程简单例子#

#ifdef LY_EP62

#include <iostream>
#include <thread>
 
void DoWork()
{

}

int main()
{
	// 这是一个函数声明,声明一个函数名
	// 为 worker,没有参数,返回类型
	// 是 int
	std::thread worker1();

	//创建的是一个“空”线程对象,内部没有关联
	// 任何实际的执行函数,也不启动线程
	std::thread worker2{};

	//创建线程并立即启动DoWork内的操作
	std::thread worker(DoWork);

	//...启动->到join这期间的代码并
	//不会阻塞

	//主线程会等待
	//主线程会停在 join() 这一行,像在车站等
	// 朋友一样,直到子线程这辆车“到站”并合
	// 二为一,主线程才会继续往下走。
	worker.join();

	//子线程完成前不会执行该行代码
	std::cin.get();
}
#endif
  • join的作用:让当前线程(通常是主线程)停下来,进入阻塞状态,直到子线程执行完毕为止。
  • 主线程启动一个工作线程,工作线程执行任务,然后在主线程上等待工作线程完成所有任务

子线程无限循环#

#ifdef LY_EP62

#include <iostream>
#include <thread>

void DoWork()
{
	while (true)
	{
		std::cout << "working..." << std::endl;
	}
}

int main()
{ 
	std::thread worker(DoWork); 

	worker.join();

	//子线程完成前不会执行该行代码
	std::cin.get();
}
#endif

阻止无限循环#

#ifdef LY_EP62

#include <iostream>
#include <thread>

// 显式包含时间库
//cherno视频里没有却能运行,可能是有些编译器中,
// <thread> 头文件为了实现 sleep_for 等功能,
// 其内部已经写了 #include <chrono>。(ai回答)
#include <chrono> 

static bool s_Finished = false;

void DoWork()
{ 
	using namespace std::literals::chrono_literals;
	while (!s_Finished)
	{
		std::cout << "working..." << std::endl;
		std::this_thread::sleep_for(1s);
	}
}

int main()
{
	std::thread worker(DoWork);

	//主线程自己阻塞自己了
	std::cin.get();
	//用户按下回车后
	s_Finished=true;

	//按下回车后,子线程或许刚进入
	//while语句块{ ,且还没到达打印语句
	//所以可能子线程要再打印一行,之后下
	//一次循环才不会进入


	worker.join();
	std::cout << "===child thread finished~~==" << std::endl;

	//子线程完成前不会执行该行代码
	std::cin.get();
}
#endif
/* 输出
working...
working...
working...
working...
working...
		 //这就是用户按下的回车行
working...
===child thread over~==

*/

子线程配合join可以用来做清理工作,主线程等待子线程清理完毕后再退出或执行某些操作