84跟踪内存的分配与释放

核心原理:重载全局 new#

  • 原理:在 C++ 中,当你写 new Object() 时,它实际上调用了一个名为 operator new 的全局函数。
  • 自定义拦截:你可以重写这个函数。编译器会优先使用你的自定义版本,而不是标准库提供的版本。
  • 代码演示
void* operator new(size_t size) {
    std::cout << "Allocating " << size << " bytes\n";
    return malloc(size); // 最终还是要调用底层的内存分配
}

对应的 delete 重载#

  • 对称性:重载了 new 就必须重载 delete,否则可能会导致内存管理逻辑不一致。
  • 代码实现
    void operator delete(void* memory, size_t size) {
        std::cout << "Freeing " << size << " bytes\n";
        free(memory);
    }
  • 注意:Cherno 提到现代 C++ 建议使用带 size 参数的 delete 版本,以便更精准地追踪。

实战:追踪总分配量 (The Tracker)#

  • 引入单例/全局状态:为了统计数据,Cherno 定义了一个简单的结构体来存储 TotalAllocatedTotalFreed
  • 逻辑:在 new 里面让计数器增加,在 delete 里面减少。
  • 演示:通过创建一个 std::string 或自己的 Entity 类,观察控制台实时打印出的分配字节数。

完整代码#

#ifdef LY_EP84

#include <iostream>  
#include <memory>

struct AllocationMetrics
{
	uint32_t TotalAllocated = 0;
	uint32_t TotalFreed = 0;
	
	uint32_t CurrentUsage()
	{
		return TotalAllocated - TotalFreed;
	}
};

static AllocationMetrics s_AllocationMetrics;

//重写operator new 的全局函数,
//链接器会转而链接这个函数
void* operator new(size_t size)
{
	s_AllocationMetrics.TotalAllocated += size;
	std::cout << "Allocating " << size << " bytes\n";
	return malloc(size);
}

//重写特定函数签名来获取特定的释放大小的信息
void operator delete(void* memory, size_t size)
{

	s_AllocationMetrics.TotalFreed += size;
	std::cout << "Freeing " << size << " bytes\n";
	free(memory);
}

struct Object
{
	int x, y, z;
};

/*
内存使用情况
*/
static void PrintMemoryUsage()
{
	std::cout << "Memory Usage: " << s_AllocationMetrics.CurrentUsage() << " bytes.\n";
}


int main()
{
	PrintMemoryUsage();
	Object* obj = new Object;

	//正好12字节,在 C++ 中,类/结构体本身不包含任何运行时开销。
	std::cout << "Size of Object: " << sizeof(Object) << std::endl;

	PrintMemoryUsage();
	std::string s = "Cherno";

	PrintMemoryUsage();
	std::cout << "=========" << std::endl;

	//这行代码仍会分配12字节
	std::unique_ptr<Object> obj1 = std::make_unique<Object>();
	PrintMemoryUsage();
	std::cout << "**********" << std::endl;

	{
		std::unique_ptr<Object> obj =
			std::make_unique<Object>();
		PrintMemoryUsage();
	}

	PrintMemoryUsage();

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

/*
Memory Usage: 0 bytes.
Allocating 12 bytes
Size of Object: 12
Memory Usage: 12 bytes.
Allocating 8 bytes
Memory Usage: 20 bytes.
=========
Allocating 12 bytes
Memory Usage: 32 bytes.
**********
Allocating 12 bytes
Memory Usage: 44 bytes.
Freeing 12 bytes
Memory Usage: 32 bytes.
*/
#endif

new#

  • 设置断点在operator new函数里面的return malloc(size);
  • (Debug模式下)第二次进入断点,即std::string s = "Cherno";后进去,调用栈如下(从下往上)

82单例模式详解

单例模式 (Singleton)。单例的核心思想是:在整个应用程序的生命周期中,某个类只存在一个实例。

什么是单例? (The Concept)#

  • 核心定义:单例是只有单一实例的类。
  • 应用场景:当你需要一个全局访问点来管理某些资源时(如随机数生成器、渲染器、数据库连接等)非常有用。
  • 争议性:单例本质上是“披着类外壳的全局变量”,在某些开发者眼中这被视为一种“反模式”,但 Cherno 认为在特定场景下它非常高效。 把类当做命名空间来调用某些函数
  • 如果你只有一个实例,你那实际有的就只是一组数据,以及一些配套功能
  • 替代单例:属于某个命名空间的函数、全局变量、某个与源文件关联的静态变量、特定的C++源文件
  • 单例的生命周期和应用程序的一样

基础实现逻辑 (Basic Implementation)#

单例模式主要依靠以下三个步骤实现:

  1. 私有化构造函数:通过 private 构造函数防止外部通过 new 或直接声明来创建新实例。
  2. 禁用拷贝构造:删除拷贝构造函数(ClassName(const ClassName&) = delete;),确保无法通过复制来产生第二个实例。
  3. 静态访问点:提供一个静态方法(通常命名为 Get()),返回那个唯一的静态实例。
#ifdef LY_EP82

#include <iostream>  


class Singleton
{
public:
	Singleton(const Singleton&) = delete;//禁止拷贝
	static Singleton& Get()
	{
		return s_Instance;
	}
	void Function() {
		std::cout << "Function" << std::endl;
	}
private:
	float m_Member = 3.4f;
	Singleton() {}

	static Singleton s_Instance;
};


//定义 
//1. 在全局/静态内存区分配 sizeof(Singleton) 字节的空间。
//2. 在程序启动时,自动调用 Singleton 的构造函数来初始化这块空间。
//由于你使用了作用域解析符 Singleton::,编译器认为这行代码依然属于 Singleton 类的范畴
Singleton Singleton::s_Instance;

int main()
{
	//这种写法,调用了Singleton类的复制构造函数,
	//拷贝了所有成员,并创建了一个新的实例(违背单例本意)
	//类定义中Singleton(const Singleton&) = delete;//禁止拷贝
	//Singleton instance = Singleton::Get();

	auto& instantce = Singleton::Get();
	Singleton::Get().Function();

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

现代 C++ 的最佳实践 (The Best Way)#

Cherno 演示了最简洁且线程安全的单例写法(C++11 及更高版本):

80加速字符串操作

附:常量字符串#

	std::string name = "Yan Chernikov";

关于空字符#

  • 右侧的字面量 “Yan Chernikov”(13个看得见的字符) 在内存(常量区)中以 \0 结尾。
  • 左侧的 std::string 对象 会在堆上申请空间,并把这 13 个字符拷贝过去。
  • 关键点:std::string 内部实现总是会额外多分配一个字节来存储这个 \0。这是为了确保当你调用 name.c_str() 时,能返回一个符合 C 语言标准的、以空字符结尾的字符串。

关于赋值#

  • 字面量字符串是一个const char[N]类型的数组
  1. 数组退化 (Array-to-pointer decay): 字符串字面量首先从 const char[14] 退化为指向首元素的指针 const char* 。

  2. 调用构造函数(Implicit Constructor Call): std::string 类有一个接收 const char* 参数的构造函数。由于该构造函数没有被标记为 explicit,编译器会自动调用它来创建一个临时的 std::string 对象。

  3. 内存分配(The “Cost”): 正如 Cherno 在第 80 集中强调的,这个隐式转换并不只是“换个名字”,它会在 堆(Heap)上分配内存,并将字符串内容拷贝进去。

  • name.size():返回 13(不计入 \0)。
  • name[13]:在 C++11 及以后的标准中,访问这个位置是合法的,它会返回 \0。

调试注意#

本篇测试均在Release模式下测试,因为Debug模式下由于调试需要会有其他的内存分配

#ifdef LY_EP80

#include <iostream>  
#include <string>

static uint32_t s_AllocaCount = 0;

//重载new运算符
void* operator new(std::size_t size)
{
	s_AllocaCount++;
	printf("Allocating %zu bytes of memory.\n", size);
	return malloc(size);
} 

int main()
{  
	
	//std::string 在栈上预留了 15 字节 的缓冲区,无需堆分配。
	std::string name = "Yan Chernikov";//13个字符 
	
	//Debug模式下输出Allocating 8 bytes of memory.(调试代理、迭代器调试需要的8字节)
	//Release模式下没有输出
	std::string name1 = "012345678901234567891";//21个字符 

	/*现代 CPU 处理内存时,如果地址是 16 或 32 的倍数,效率最高。以下是输出
	Allocating 32 bytes of memory.
	*/
	 
	std::cin.get();
	return 0;
} 
#endif

1. 问题所在:不必要的内存分配#

传统的 std::string 在进行截取、传递或操作时,往往会触发动态内存分配(Heap Allocation)

79通过多线程提升性能

如何利用 std::async 让 C++ 运行更快

这一集的核心在于展示如何通过多线程异步处理来优化原本串行执行的耗时操作,主要讲解了 std::async 的用法及其与 std::future 的配合。

1. 问题背景与性能现状#

  • 串行执行的局限性:Cherno 首先展示了一个简单的示例程序(通常是加载资源或执行大量计算)。在单线程环境下,这些任务必须一个接一个地完成,导致总耗时是所有任务时间的总和。
  • 性能瓶颈:如果其中一个任务(如 LoadFunction)非常耗时,整个程序就会被阻塞,无法利用现代 CPU 的多核优势。

如今的游戏加载非常长,原因是需要加载的资源太多,加载过程不仅仅是从磁盘读取文件,涉及解压文件、将其发送到图形处理器、根据特定情况特定转换,但每一项资源、纹理、模型通常相互独立,即它们是多线程的理想选择

2. 引入 std::async#

  • 什么是 std::async:它是 C++11 引入的一个高级 API,用于启动异步任务。它比手动创建 std::thread 更简单,因为它能自动处理线程的创建和管理。

  • std::launch 策略

    • std::launch::async:强制在不同线程中异步运行。
    • std::launch::deferred:延迟执行,直到调用 get()wait() 时才在当前线程运行。
  • 基本语法:展示如何将普通函数调用包装进 std::async 中。

#ifdef LY_EP79

#include <iostream> 
#include <thread>
#include <chrono>
#include <future> 

static std::mutex mtx;
static std::vector<int> meshes;

static void LoadMesh(int i)
{
	std::cout << "Loading mesh...[" << i << "]\n";

	//加锁->互斥锁
	//该锁在当前作用域结束时自动释放 
	std::lock_guard<std::mutex> lock(mtx);

	//模拟:将加载的网格ID添加到共享资源中
	meshes.push_back(i);
	std::cout << "Mesh Added: " << i << "\n";


}

void LoadMeshes()
{
	//因为这里的异步任务,即LoadMesh函数返回void,所以我们使用std::future<void>来存储这些异步任务的结果。
	std::vector<std::future<void>> futures;

	for (int i = 10; i < 15; i++)
	{
		// std::async :立即在后台开启一个新的线程,去执行 LoadMesh(i) 这个函数,不要阻塞我当前的进度。
		
		//1. 但是,因为它返回的 std::future 没有赋值给任何变量,它会在这一行结束时立刻析构。
		//2. std::future 的析构函数逻辑是:“等一下!任务还没跑完,我不能死,我要阻塞主线程直到子线程结束。”
		//3. 所以,最终导致主线程在每次调用 std::async 后都被阻塞,等待子线程完成,才继续下一次循环。
		//std::async(std::launch::async, LoadMesh, i);

		// 将 future 存入 vector,防止它立即析构
		//在单独线程中加载网格
		futures.push_back(std::async(std::launch::async, LoadMesh, i));
	}


}


void LoadMeshesSync()
{
	for (int i = 10; i < 15; i++)
	{
		LoadMesh(i);
	}


}

int main()
{
#define ASYNC 1
#if ASYNC 
	LoadMeshes();
#else
	LoadMeshesSync();
#endif
	std::cin.get();
	return 0;
}
/* Mesh Added:顺序是随机的,因为可能某线程执行std::lock_guard<std::mutex> lock(mtx);这行代码前,被其他线程抢先
Loading mesh...[10Loading mesh...[11]
]
Loading mesh...[12]
Loading mesh...[13]
Loading mesh...[14]
Mesh Added: 11
Mesh Added: 10
Mesh Added: 12
Mesh Added: 13
Mesh Added: 14
*/
#endif

通过parallel Stacks查看不同线程栈信息

78存储任意类型数据

介绍了 C++17 引入的 std::any。它是一个可以存储绝对任何类型变量的容器(本质上是一个类型安全的 void*),但与 void* 不同的是,它会处理对象的构造和析构。

1. 基本用法#

  • 包含头文件#include <any>
  • 赋值:你可以随意给它赋值,不需要预先定义类型。

2. 如何提取数据:std::any_cast#

由于 any 可以是任何东西,你必须使用 std::any_cast 并指定类型来取回数据:

  • 按值取回std::any_cast<int>(data)。如果类型不匹配,会抛出 std::bad_any_cast 异常。
  • 按引用取回:为了性能,通常建议取引用或指针,避免拷贝。

例子#

#ifdef LY_EP78

#include <iostream> 
#include <any>
#include <string>
#include <variant>

int main()
{
	std::any data;
	{
		char name[] = "Cherno";
		data = (const char*)name;// 将 name 数组的地址存储在 data 中

	} // name 数组在这里被销毁了

	// 此时 data 内部存储的指针指向了一个已经失效的内存地址(野指针)

	data = 3.45;

	//1. 构造临时对象:std::string 首先根据 "Cherno" 构造自己(这次会有一次内存分配和拷贝)。
	//2. 调用 operator=:这个临时对象被传给 std::any::operator=。
	//3. 内部转换:std::any 内部会检测到这是一个右值(临时对象),它会调用 std::string 的移动构造函数,在自己的内部空间(或它在堆上新开辟的空间)重建这个 string。
	//4. 释放临时对象:原本的临时对象变成“空壳”,随后被销毁。
	data = std::string("Cherno");// 现在 data 内部存储的是一个 std::string 对象,之前的指针已经被覆盖了

	// 这种方式直接在 data 的内部空间构造 string,效率最高
	data = std::make_any<std::string>("Cherno");

	//获取值
	std::string string = std::any_cast<std::string>(data);
	std::cout << string << std::endl;

	data = "haha";

	//运行时报错,data此时是一个const char*,不是std::string
	//std::string string2 = std::any_cast<std::string>(data);
	//std::cout << string2 << std::endl;

	std::variant<int, std::string> var;
	var = 3;
	var = "Hello";

	std::string string3 = std::get<std::string>(var);
	std::cout << string3 << std::endl;//运行正常,输出"Hello"

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

3. 工作原理与性能开销#

这是本集的重点,Cherno 解释了为什么不能滥用 std::any

77在单变量中存储多种类型数据

核心概念#

本集介绍了 C++17 引入的 std::variant。它是一个类型安全的联合体 (Type-safe Union),允许一个变量在不同时间存储预定义的一组类型中的任意一种 比如我接收一个参数可能是字符串或者整数–因为也许我接受的参数来自命令行参数

1. 为什么需要 std::variant?#

在 C++17 之前,如果你想让一个变量既能存 string 也能存 int

  • 使用 union:传统的 union 不够安全,它不会追踪当前存储的是哪种类型,且处理复杂类型(如 std::string)时非常麻烦。
  • 使用 std::variant:它解决了这些问题。它知道自己当前存的是什么,并且会自动处理复杂类型的构造和析构。

2. 基本用法#

  • 包含头文件#include <variant>
  • 声明与赋值
	//列出可能的数据类型
    std::variant<std::string, int> data; 
    data = "Cherno"; // 存储 string
    data = 24;       // 存储 int
  • 注意variant 的大小等于其最大候选类型的大小加上一些存储索引的开销(类似于 struct),这与 union 共享内存的特性类似。

3. 如何读取数据#

由于 variant 是类型安全的,你不能直接访问,有几种主要方式:

  • std::get<T>(var):指定类型获取。如果类型不匹配,会抛出 std::bad_variant_access 异常。
  • std::get_if<T>(&var)(推荐方式) 传入地址。如果类型匹配则返回指针,否则返回 nullptr。这种方式不会抛出异常,适合配合 if 使用。
  • std::variant::index():返回当前存储类型的索引(从 0 开始)。

例子#

#ifdef LY_EP77

#include <iostream>
#include <variant>

int main()
{
	std::variant<std::string, int> data;
	data = "Cherno";
	std::cout << std::get<std::string>(data) << std::endl;
	data = 2;
	//data = 3.45;//编译报错,不通过
	std::cout << std::get<int>(data) << std::endl;
	//std::cout << std::get<std::string>(data) << std::endl;//运行时抛出异常
	data = true;//隐式转换,true转为1
	std::cout << std::get<int>(data) << std::endl;//1

	//运行时抛出异常
	//Unhandled exception at 0x763A2CA4 in HelloWorld62.exe: Microsoft C++ exception: std::bad_variant_access at memory location 0x012FFA2C.
	//std::cout << std::get<std::string>(data) << std::endl;

	//如上定义该变量时,std::string对应的索引映射为0,int对应的索引映射为1
	std::cout << data.index() << std::endl;

	//get_if函数接受一个地址,返回一个指针
	//如果当前 variant 存的数据类型匹配,它返回指向该数据的指针。
	//如果类型不匹配,它返回 nullptr(空指针),而不会让程序崩溃。
	if (auto  value = std::get_if<std::string>(&data))
	{
		std::string& v = *value;
		std::cout << "it's a string:" << v << std::endl;
	}
	else if (auto  value = std::get_if<int>(&data))
	{
		int& v = *value;
		std::cout << "it's an int:" << v << std::endl;

	}

	//union 的特点是所有成员共享同一块内存。如果你给 age 赋值,就会覆盖掉 name 的内存。如果此时 name 原本存有字符串,它的析构函数将无法正确释放内存,导致内存泄漏或崩溃。
	//注意:在现代 C++ 中,如果你要在 union 里放 std::string,你必须手动定义 union 的构造和析构函数。
	union TestS
	{
		std::string name;//28字节
		int age;//4字节
	};
	std::cout << "union======size===" << std::endl;
	std::cout << sizeof(std::string) << std::endl;//28字节
	std::cout << sizeof(int) << std::endl;//4字节
	std::cout << sizeof(TestS) << std::endl;//28字节
	std::cout << "variant======size===" << std::endl;

	//32=28+4,所以基本上创建了struct,或者说用于存储两种数据类型的空间;所以union更高效,而variant安全性更高
	std::cout << sizeof(data) << std::endl;//32


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

data = false;分析#

当执行 data = false; 时,发生了隐式类型转换(Implicit Conversion)

76可选数据处理

核心概念#

本集主要讨论 C++17 引入的 std::optional。它提供了一种优雅的方式来处理==“值可能存在,也可能不存在”==的情况,避免了使用魔术数字(如 -1)、空字符串或布尔标志位等不直观的方法。

1. 传统处理方式的弊端#

在没有 std::optional 之前,如果一个函数可能无法返回有效数据,程序员通常会:

  • 返回特殊值:例如找不到文件时返回空字符串 ""。但问题是,有时空字符串本身可能也是合法的返回结果,导致二义性。
  • 使用布尔标记:通过引用的方式传出数据,函数返回 bool 表示是否成功。这种方式虽然可行,但代码写起来比较琐碎。
    • 函数返回一个布尔值(true 或 false)来表示操作是否成功,而实际的数据通过“输出参数”(通常是引用或指针)来传递。

2. std::optional 的基本用法#

std::optional 像是一个容器,它要么包含一个指定类型的值,要么为空(std::nullopt)。

  • 包含头文件#include <optional>
  • 读取文件示例
    std::optional<std::string> ReadFileAsString(const std::string& filepath) {
        std::ifstream stream(filepath);
        if (stream) {
            std::string result;
            // 读取文件内容...
            return result;
        }
        //return {};// ai提示return std::nullopt; // 显式表示没有值 [00:05:00]
    }

完整代码#

//同文件夹下:data.txt
Data!Hello!
#ifdef LY_EP76

#include <iostream>   
#include <fstream>
//C++17引入了std::optional类型,可以用来表示一个值可能存在也可能不存在的情况。它提供了一种更安全和更清晰的方式来处理函数返回值,避免了使用引用参数来传递成功与否的信息。
#include <optional>
#include <sstream> //读取文件内容需要使用std::stringstream


//方法1:通过引用参数返回成功与否
//std::string ReadFileAsString(const std::string& filePath,
//bool& outSuccess)
std::optional<std::string> ReadFileAsString(const std::string& filePath)
{
	std::ifstream stream(filePath);
	if (stream)
	{
		std::stringstream result;
		result << stream.rdbuf();
		//read file
		stream.close();
		return result.str();
	} 
	return {};
}

int main()
{

	//在内存中,std::optional<T> 通常包含两个主要部分:
	//1. 一块足够大的内存空间:用来存放类型为 T 的值(在你的例子中是 std::string)。
	//2. 一个布尔标记(bool flag):记录这个盒子现在是“满的”还是“空的”
	std::optional<std::string> data = ReadFileAsString("data.txt");
	std::optional<std::string> data1 = ReadFileAsString("data1.txt");

	//如果是读取参数,那就是指定默认值
	//std::string value = data.value_or("default value");

	//或者if(data)
	if(data.has_value())
	{
		//数据读取
		std::string& string = *data;
		std::cout << "File content: " << data.value() << std::endl;
		std::cout << "File content: " << string << std::endl;
	}
	else
	{
		std::cout << "Failed to read file." << std::endl;
	}


	if (data1.has_value())
	{
		//数据读取
		std::string& string = *data1;
		std::cout << "File content: " << data1.value() << std::endl;
		std::cout << "File content: " << string << std::endl;
	}
	else
	{
		std::cout << "Failed to read file(data1.txt)." << std::endl;
	}
	std::cin.get();

} 
/*
File content: Data!Hello!
File content: Data!Hello!
Failed to read file(data1.txt).
*/
#endif

3. 如何检查与获取值#

调用返回 std::optional 的函数后,有几种处理方式:

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文件