74性能基准测试指南

  • 利用 C++ 对象的生命周期(析构函数)来自动化测量任务。
  • 处理一个对性能要求很高的部分时,或者测试刚学到的新技术,此时想比较性能
  • 本章节讲解如何实际测量C++代码的性能

测试循环所需时间#

#ifdef LY_EP74

#include <iostream>   
#include <chrono>

class Timer
{
public:
	Timer()
	{
		std::chrono::high_resolution_clock::now();
	}

	~Timer()
	{
		auto endTimepoint = std::chrono::high_resolution_clock::now();
		//算出起始时间的微秒数 
		auto start = std::chrono::time_point_cast<std::chrono::microseconds>(m_StartTimepoint).time_since_epoch().count();
		//算出结束时间的微秒数
		auto end = std::chrono::time_point_cast<std::chrono::microseconds>(endTimepoint).time_since_epoch().count();
		//算出持续时间
		auto duration = end - start;
		//将持续时间转换为毫秒并输出
		double ms = duration * 0.001;
		std::cout << duration << "us (" << ms << "ms)" << std::endl;
	}

private:
	std::chrono::time_point<std::chrono::high_resolution_clock> m_StartTimepoint;
};

int main()
{
	int value = 0;
	{
		Timer timer; // 创建一个 Timer 对象,开始计时

		for (int i = 0; i < 1000000; i++)
			value += 2;
	}

	std::cout << value << std::endl;

	//__debugbreak() 是 MSVC (Windows) 特有的编译器内置函数,会在这里中断程序,进入调试器,方便我们查看变量的值
	//正式运行程序时也会中断甚至发生异常
	__debugbreak();

}
#endif

先设置断点,然后进入反汇编查看

73动态类型转换(dynamic_cast)

引入:什么是 Casting?#

  • 背景:在 C++ 中,我们经常需要在父类和子类指针之间转换。
  • 痛点:传统的 C 风格转换 (Player*)entitystatic_cast 是“强行”转换。编译器完全信任你,即使你把一个“敌人”对象强行转成“玩家”对象,它也不会报错,但这会导致程序在运行时崩溃。

Dynamic Cast 的特殊性#

  • 一针见血的定义:dynamic_cast 是专门用于继承体系中的安全转换
  • 运行时检查 (RTTI 运行时类型信息 ):它不像其他转换在编译时完成,而是在程序运行时去检查这个对象“到底是不是”你要转的那个类型。
  • 返回值逻辑
    • 如果转换成功:返回有效的指针。
    • 如果转换失败(类型不匹配):返回 NULL (或 nullptr)。

核心前置条件:虚函数表#

  • 关键约束:要使用 dynamic_cast,你的类必须是多态的 (Polymorphic)。
  • 原理:这意味着基类必须至少有一个 虚函数 (Virtual Function)。
  • 底层逻辑:dynamic_cast 依赖于 RTTI (Run-Time Type Information),而 RTTI 信息存储在虚函数表 (vtable) 中。如果类没有虚函数,就没有 RTTI,编译器会直接报错。

代码实战演练#

Cherno 演示了一个经典的场景:

  • 基类:Entity
  • 子类:PlayerEnemy
  • 场景:你有一个 Entity* 指针,你想知道它指向的到底是Player 还是 Enemy
  • 写法:
#ifdef LY_EP73

#include <iostream> 

class Entity
{
public:
	virtual void PrintName() {}
};

class Player : public Entity
{
};

class Enemy : public Entity
{
};


int main()
{ 
	Player* player = new Player();
	Entity* e = player;//隐式转换,Player* 转换为 Entity*

	Entity* actuallyEnemy = new Enemy();
	Entity* actuallyPlayer = new Player();

	//Player* p = e;//错误,Entity* 转换为 Player*,需要显示转换

	Player* p = (Player*)actuallyEnemy;//不安全的转换,Enemy* 转换为 Player*,编译器允许,但运行时会出问题;如果访问Player和Entity共有的成员,可能不会出问题,但如果访问Player特有的成员,就会出问题,因为actuallyEnemy实际上是一个Enemy对象,而不是Player对象。

	Player* p1 = static_cast<Player*>(actuallyEnemy);//和上面的显示转换一样,属于不安全的转换 

	//actuallyEnemy指向的类必须是多态的,在 C++ 的设计哲学里,非多态类确实没有存储运行时的类型信息。
	Player* p2 = dynamic_cast<Player*>(actuallyEnemy);//安全的转换,运行时会检查actuallyEnemy是否实际上是一个Player对象,如果是,则返回指向Player对象的指针;如果不是,则返回nullptr。由于actuallyEnemy实际上是一个Enemy对象,所以p2将被赋值为nullptr。


	Player* p3 = dynamic_cast<Player*>(actuallyPlayer);

	if (p2)
	{
		std::cout << "actuallyEnemy is a Player." << std::endl;
	}
	else
	{
		std::cout << "actuallyEnemy is NOT a Player." << std::endl;
	}

	if(p3)
	{
		std::cout << "actuallyPlayer is a Player." << std::endl;
	}
	else
	{
		std::cout << "actuallyPlayer is NOT a Player." << std::endl;
	}

	std::cin.get();
}

/*
actuallyEnemy is NOT a Player.
actuallyPlayer is a Player.
*/
#endif

性能代价 (The Cost)#

Cherno 给出了一针见血的职业警告:

72预编译头文件详解

预编译头文件的作用是将大量稳定的头文件提前编译成二进制缓存。这样编译器在处理每个源文件时,直接读取该缓存,而无需重新解析成千上万行重复的代码,从而极大地缩短编译时间。

比如常用的标准模版库:比如向量、字符串、标准输出之类,假设我们每次(在每个.cpp文件)都要包含vector,需要读取vector.h头文件并编译,而且vector本身也包含其他一系列头文件,所有其他头文件被复制到vector.h中,再把vector.h复制到.cpp中(大约十万行代码),还要进行解析和标记化,并以某种形式进行编译

  • 如果项目中有多个cpp文件,很多地方都包括vector.h,他们会被逐个包含在每个文件中 每个翻译单元(.cpp)都会被单独编译,然后链接器将它们链接起来
  • 且每次对那个cpp文件 包含了vector.h的 重新更改时,整个 这里是说要把vector.h复制进来然后再编译 都得重新编译

建议

  • 改用预编译头文件,它会获取一堆头文件(要包含的那些),然后它编译一次,并将起转换为二进制格式。这对编译器来说处理起来比纯文件快得多 因为它以处于解析状态,而且是二进制文件,随时可以,不用再重新编译
    • 举个例子,可能本来要1分钟,使用预编译头文件后,编译可能只需8秒钟
  • 预编译头文件其实就是一个头文件,且包含一系列其他头文件
    • 不可以把所有头文件都放进去,因为如果一个头文件经常变化,那么每次改动都需要重新编译整个预编译头文件
    • 不需要在项目的每个cpp都包含日志文件,只需要包含含有日志的预编译头文件
  • C库、标准模板库、WindowsAPI调用库,建议把这些都放进预编译头文件 每次编译可能他的代码量比实际代码还多 。这样就能从每个C文件中获取所有内容,如果要用只能指针,就不用实际直接包含memory头文件,因为他在预编译头文件中,它会包含在实际编写的每个C++文件中
  • 因为把所有文件都放进了pch.h 也可以叫其他名字 ,所以不能一眼看出实际include了什么,且pch.h中包含了所有的,所以也不清楚实际它只依赖哪些头文件
  • 如果编写游戏引擎,可能只有一个cpp文件需要包含GLFW 编译一起就搞定 ,那么可以把GLFW放入pch.h中,因为其他内容都被抽象放在自己的api中;而其他的标准库、vector、标准输入输出之类的,很多都会用的,也可以让它编译一次就搞定

例子#

//Main.cpp
#include "pch.h"

int main()
{
	std::cout << "Hello World!" << std::endl;
}
//pch.h
#pragma once

#include <iostream>
#include <algorithm>
#include <functional>
#include <memory>
#include <thread>
#include <utility>

// Data structures
#include <string>
#include <stack>
#include <deque>
#include <array>
#include <vector>
#include <set>
#include <map>
#include <unordered_set>
#include <unordered_map>

//Windows API
#include <windows.h>

做个测试

然后ctrl+f7编译Main.cpp,查看生成的Main.i文件

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() 这是最基础的情况:

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]的内存地址。