90stdmove与移动运算符

std::move 到底是什么?#

  • 核心真相std::move 并不移动任何东西。它只是一个简单的类型转换(Casting)。
  • 底层逻辑:它把一个左值(Lvalue)强制转换为右值引用(Rvalue Reference)。它的作用仅仅是告诉编译器:“嘿,我知道这个变量原本是个持久的对象,但现在我不再需要它了,请把它当成一个临时对象,允许别人‘偷’走它的资源。”
  • 代码本质std::move(x) 基本上等同于 static_cast<T&&>(x)

移动赋值运算符 (Move Assignment Operator)#

  • 场景引入:第 89 集讲的是构造新对象,而这一集讲的是给已有对象重新赋值
String dest = "Hello";

//如果你只写了移动构造,没写移动赋值:编译器会“退而求其
//次”,去调用拷贝赋值运算符 operator=(const String& other)。
dest = String("World"); // 这里会涉及移动赋值
  • 手写实现步骤

    1. 清理旧资源:在接管别人的资源前,必须先 delete[] m_Data 释放自己当前的内存(否则会内存泄漏)。
    2. 接管资源:像移动构造函数一样,把对方的指针和大小拷过来。
    3. 置空对方:将源对象的指针设为 nullptr
    4. 返回自身return *this; 以支持链式赋值。

为什么 std::move 是必需的?#

  • 实战演示:Cherno 展示了当你想把一个已命名的变量移动到另一个地方时,必须显式使用 std::move
  • 原因:即使一个变量的类型是 String&&,只要它有名字,它就是一个左值
  • 安全性:这种设计是为了防止你无意中移动了以后还要用到的数据。使用 std::move 是一种显式的“主权放弃”。

常见的错误用法与陷阱#

  • 不要移动 const 对象:如果你尝试 std::move 一个 const 对象,它会默默地退化回拷贝。因为移动语义需要修改原对象(将其置空),而 const 禁止修改。
  • 自赋值检查:在移动赋值运算符中,通常建议检查 if (this != &other),防止自己移动给自己导致资源被意外提前释放。

(例子1)移动构造函数与移动赋值函数#

#ifdef LY_EP90

//c++11才引入了右值引用
#include <iostream>   
#include <utility> // std::move 在这个头文件里

class String
{
public:

	String() = default;

	//构造函数
	String(const char* string)
	{
		printf("Created!\n");
		//不包括\0
		m_Size = strlen(string);
		m_Data = new char[m_Size];
		memcpy(m_Data, string, m_Size);
		std::cout << "String(const char* string)" << std::endl;
	}

	//复制构造函数
	String(const String& other)
	{
		printf("Copied!\n");
		//不包括\0
		m_Size = other.m_Size;
		m_Data = new char[m_Size];

		//在 C++ 中,访问控制(public/private)是基于“类(Class)”层面的,而不是基于“对象(Object)”层面的。
		//简单来说:只要是在 String 类的成员函数内部,你就可以访问任何 String 对象的私有成员。
		memcpy(m_Data, other.m_Data, m_Size);

		std::cout << "String(const String& other)" << std::endl;
	}


	//移动构造函数
	//接收一个右值引用参数,表示可以从一个将要被销毁的临时对象中“窃取”资源,而不是复制资源。
	//如果手动定义了“移动构造函数”,编译器就不再为你自动生成“默认赋值运算符”了。
	String(String&& other) noexcept
	{
		printf("Moved!\n");
		//不包括\0
		m_Size = other.m_Size;
		m_Data = other.m_Data;

		other.m_Size = 0;//将原对象的大小置为0,表示它不再拥有资源

		//把被接管控制权的资源指针置空,防止原对象的析构函数删除已经被移动的资源
		other.m_Data = nullptr;


		std::cout << "String(String&& other)" << std::endl;
	}

	~String()
	{
		delete[] m_Data;
		printf("Destroyed!\n");
		std::cout << "~String()" << std::endl;
	}

	// 移动赋值运算符
	String& operator=(String&& other) noexcept
	{
		printf("Move Assigned!\n");

		// 1. 自赋值检查 (防止自己移动给自己,如 a = std::move(a))
		if (this != &other)
		{
			// 2. 释放旧资源 (dest 已经有内存了,必须先删掉,否则内存泄漏)
			delete[] m_Data;

			// 3. 窃取资源
			m_Size = other.m_Size;
			m_Data = other.m_Data;

			// 4. 将原对象置空 (让它变成空壳)
			other.m_Data = nullptr;
			other.m_Size = 0;
		}

		return *this;
	}

	//拷贝赋值运算符
	String& operator=(const String& other)
	{
		printf("Copy Assigned!\n");
		if (this != &other)
		{
			delete[] m_Data;
			m_Size = other.m_Size;
			m_Data = new char[m_Size]; // 必须申请新内存
			memcpy(m_Data, other.m_Data, m_Size);
		}
		return *this;
	}

	void Print()
	{
		for (uint32_t i = 0; i < m_Size; i++)
			printf("%c", m_Data[i]);
		printf("\n");
	}
private:
	char* m_Data;

	//这是一个通过 typedef 或 using 定义的类型,表示
	//无符号整型32位数。
	// int 在某些古老的 16 位系统上可能是 2 字节,在现代系统上通常是 4 字节,uint32_t 强制规定在任何符合标准的编译器上,它永远是 32 位(4 字节)
	uint32_t m_Size;
};

class Entity
{
public:


	Entity(const String& name)
		:m_Name(name)
	{
		std::cout << "Entity(const String& name)" << std::endl;
	}



	//Entity(String&& name)
	//	//一旦右值引用有了名字,它在后续表达式中就变成了左值。
	//	//编译器会想:“这个 name 虽然是引用进来的,但它现在是有名有姓的变量,为了安全起见,我必须调用 复制构造函数 来保护它。
	//	:m_Name(name)
	//{
	//	std::cout << "Entity( String&& name)" << std::endl;
	//}



	//接收一个临时对象作为参数,使用移动语义来构造 Entity 对象,避免不必要的复制,提高性能。
	Entity(String&& name)
		//强转为右值引用,告诉编译器:我知道 name 是一个左值,但我想把它当作一个将要被销毁的临时对象来处理,所以请调用 移动构造函数 来构造 m_Name。
		//:m_Name((String&&)name) //这种写法也行
		:m_Name(std::move(name))  // 将左值 name 强行转回右值
	{
		std::cout << "Entity( String&& name)" << std::endl;
	}

	void PrintName()
	{
		m_Name.Print();
	}

private:
	String m_Name;
};

int main()
{
	//这里将"Cherno"隐式调用构造函数构造了一个String
	//之后move给了Entity(Entity内部的m_Name接管了这个临时String指针指向的堆内存)
	Entity entity("Cherno");
	entity.PrintName();
	std::cout << "===0==" << std::endl;
	String string = "Hello";
	std::cout << "===1==" << std::endl;
	String dest = string;//这是复制
	std::cout << "===2==" << std::endl;
	String dest1 = std::move(string);//调用移动构造函数
	dest1.Print();
	std::cout << "===3==" << std::endl;
	//如果没写移动赋值,编译器会“退而求其次”,去调用拷贝赋值运算符 operator=(const String & other)
	dest = "abc";
	std::cout << "===4==" << std::endl;
	 
	dest = std::move(string);
	dest.Print();//string已经被移动了,变成了空壳,所以dest打印出来是空的

	std::cin.get();
	return 0;
}
/*
Created!
String(const char* string)
Moved!
String(String&& other)
Entity( String&& name)
Destroyed!
~String()
Cherno
===0==
Created!
String(const char* string)
===1==
Copied!
String(const String& other)
===2==
Moved!
String(String&& other)
Hello
===3==
Created!
String(const char* string)
Move Assigned!
Destroyed!
~String()
===4==
Move Assigned!

*/
#endif

(例子2)详细解释#

#ifdef LY_EP90

//c++11才引入了右值引用
#include <iostream>   
#include <utility> // std::move 在这个头文件里

class String
{
public:

	String() = default;

	//构造函数
	String(const char* string)
	{
		printf("Created!\n");
		//不包括\0
		m_Size = strlen(string);
		m_Data = new char[m_Size];
		memcpy(m_Data, string, m_Size);
		std::cout << "String(const char* string)" << std::endl;
	}

	//复制构造函数
	String(const String& other)
	{
		printf("Copied!\n");
		//不包括\0
		m_Size = other.m_Size;
		m_Data = new char[m_Size];

		//在 C++ 中,访问控制(public/private)是基于“类(Class)”层面的,而不是基于“对象(Object)”层面的。
		//简单来说:只要是在 String 类的成员函数内部,你就可以访问任何 String 对象的私有成员。
		memcpy(m_Data, other.m_Data, m_Size);

		std::cout << "String(const String& other)" << std::endl;
	}


	//移动构造函数
	//接收一个右值引用参数,表示可以从一个将要被销毁的临时对象中“窃取”资源,而不是复制资源。
	//如果手动定义了“移动构造函数”,编译器就不再为你自动生成“默认赋值运算符”了。
	String(String&& other) noexcept
	{
		printf("Moved!\n");
		//不包括\0
		m_Size = other.m_Size;
		m_Data = other.m_Data;

		other.m_Size = 0;//将原对象的大小置为0,表示它不再拥有资源

		//把被接管控制权的资源指针置空,防止原对象的析构函数删除已经被移动的资源
		other.m_Data = nullptr;


		std::cout << "String(String&& other)" << std::endl;
	}

	~String()
	{
		delete[] m_Data;
		printf("Destroyed!\n");
		std::cout << "~String()" << std::endl;
	}

	// 移动赋值运算符:将另一个对象,移入当前这个对象自身
	//语义契约(Semantic Contract)。	C++ 的设计哲学是:让自定义类型的行为表现得像内置类型(如 int)一样。标准做法始终是返回非 const 的 *this 引用
	String& operator=(String&& other) noexcept
	{
		printf("Move Assigned!\n");

		// 1. 自赋值检查 (防止自己移动给自己,如 a = std::move(a),
		// 因为如下是会释放旧资源的,所以移动给自己就什么都没有了)
		if (this != &other)
		{
			// 2. 释放旧资源 (dest[当前对象] 已经有内存了,必须先删掉,否则内存泄漏)
			delete[] m_Data;

			// 3. 窃取资源
			m_Size = other.m_Size;
			m_Data = other.m_Data;

			// 4. 将原对象置空 (让它变成空壳)
			other.m_Data = nullptr;
			other.m_Size = 0;
		}

		//找到 this 指向的对象,返回它的引用(别名),
		// 而不是创建一个新的副本
		return *this;
	}

	//拷贝赋值运算符
	String& operator=(const String& other)
	{
		printf("Copy Assigned!\n");
		if (this != &other)
		{
			delete[] m_Data;
			m_Size = other.m_Size;
			m_Data = new char[m_Size]; // 必须申请新内存
			memcpy(m_Data, other.m_Data, m_Size);
		}
		return *this;
	}

	void Print()
	{
		for (uint32_t i = 0; i < m_Size; i++)
			printf("%c", m_Data[i]);
		printf("\n");
	}
private:
	char* m_Data;

	//这是一个通过 typedef 或 using 定义的类型,表示
	//无符号整型32位数。
	// int 在某些古老的 16 位系统上可能是 2 字节,在现代系统上通常是 4 字节,uint32_t 强制规定在任何符合标准的编译器上,它永远是 32 位(4 字节)
	uint32_t m_Size;
};

class Entity
{
public:


	Entity(const String& name)
		:m_Name(name)
	{
		std::cout << "Entity(const String& name)" << std::endl;
	}



	//Entity(String&& name)
	//	//一旦右值引用有了名字,它在后续表达式中就变成了左值。
	//	//编译器会想:“这个 name 虽然是引用进来的,但它现在是有名有姓的变量,为了安全起见,我必须调用 复制构造函数 来保护它。
	//	:m_Name(name)
	//{
	//	std::cout << "Entity( String&& name)" << std::endl;
	//}



	//接收一个临时对象作为参数,使用移动语义来构造 Entity 对象,避免不必要的复制,提高性能。
	Entity(String&& name)
		//强转为右值引用,告诉编译器:我知道 name 是一个左值,但我想把它当作一个将要被销毁的临时对象来处理,所以请调用 移动构造函数 来构造 m_Name。
		//:m_Name((String&&)name) //这种写法也行
		:m_Name(std::move(name))  // 将左值 name 强行转回右值
	{
		std::cout << "Entity( String&& name)" << std::endl;
	}

	void PrintName()
	{
		m_Name.Print();
	}

private:
	String m_Name;
};

int main()
{
	//临时对象经历了:Created! -> Moved! -> Destroyed!
	Entity entity("Cherno");
	//打印字符串
	entity.PrintName();
	std::cout << "=====0=====" << std::endl;

	//隐式调用构造函数String(const char* string),Created! 
	String string = "Hello";
	std::cout << "=====1=====" << std::endl;

	//隐式调用(复制)构造函数, 本质上就是“通过复制一个已有的对象来创建一个新对象”,这就是所谓的“复制构造”。
	String dest = string;
	std::cout << "=====2.1=====" << std::endl;
	//使用接受字符串右值引用的构造函数,来构造一个String
	String dest1 = (String&&)string;
	std::cout << "=====2.2=====" << std::endl;
	String dest2((String&&)string);//这个string内部的指针已经被dest1接管了,所以dest2构造的时候,string已经是空壳了,所以dest2也是空的
	std::cout << "=====2.3=====" << std::endl;

	//这样写无需知道string是什么类型的
	//remove_reference_t<_Ty>&& move(_Ty&& _Arg)  ,查看源码可知返回的是右值引用
	//这里是移动构造,而非移动赋值,因为dest3是第一次定义并使用
	String dest3 = std::move(string);
	std::cout << "=====3=====" << std::endl;

	//当将一个变量(数值、表达式)赋给一个已有变量时,使用的是赋值
	//就像这里是移动赋值
	dest = std::move(string);


	std::cin.get();
	return 0;
}
/*
Created!
String(const char* string)
Moved!
String(String&& other)
Entity( String&& name)
Destroyed!
~String()
Cherno
=====0=====
Created!
String(const char* string)
=====1=====
Copied!
String(const String& other)
=====2.1=====
Moved!
String(String&& other)
=====2.2=====
Moved!
String(String&& other)
=====2.3=====
Moved!
String(String&& other)
=====3=====
Move Assigned!
*/
#endif

(例子3)视频举的#

#ifdef LY_EP90

//c++11才引入了右值引用
#include <iostream>   
#include <utility> // std::move 在这个头文件里

class String
{
public:

	String() = default;

	//构造函数
	String(const char* string)
	{
		printf("Created!\n");
		//不包括\0
		m_Size = strlen(string);
		m_Data = new char[m_Size];
		memcpy(m_Data, string, m_Size);
		std::cout << "String(const char* string)" << std::endl;
	}

	//复制构造函数
	String(const String& other)
	{
		printf("Copied!\n");
		//不包括\0
		m_Size = other.m_Size;
		m_Data = new char[m_Size];

		//在 C++ 中,访问控制(public/private)是基于“类(Class)”层面的,而不是基于“对象(Object)”层面的。
		//简单来说:只要是在 String 类的成员函数内部,你就可以访问任何 String 对象的私有成员。
		memcpy(m_Data, other.m_Data, m_Size);

		std::cout << "String(const String& other)" << std::endl;
	}


	//移动构造函数
	//接收一个右值引用参数,表示可以从一个将要被销毁的临时对象中“窃取”资源,而不是复制资源。
	//如果手动定义了“移动构造函数”,编译器就不再为你自动生成“默认赋值运算符”了。
	String(String&& other) noexcept
	{
		printf("Moved!\n");
		//不包括\0
		m_Size = other.m_Size;
		m_Data = other.m_Data;

		other.m_Size = 0;//将原对象的大小置为0,表示它不再拥有资源

		//把被接管控制权的资源指针置空,防止原对象的析构函数删除已经被移动的资源
		other.m_Data = nullptr;


		std::cout << "String(String&& other)" << std::endl;
	}

	~String()
	{
		delete[] m_Data;
		printf("Destroyed!\n");
		std::cout << "~String()" << std::endl;
	}

	// 移动赋值运算符:将另一个对象,移入当前这个对象自身
	//语义契约(Semantic Contract)。	C++ 的设计哲学是:让自定义类型的行为表现得像内置类型(如 int)一样。标准做法始终是返回非 const 的 *this 引用
	String& operator=(String&& other) noexcept
	{
		printf("Move Assigned!\n");

		// 1. 自赋值检查 (防止自己移动给自己,如 a = std::move(a),
		// 因为如下是会释放旧资源的,所以移动给自己就什么都没有了)
		if (this != &other)
		{
			// 2. 释放旧资源 (dest[当前对象] 已经有内存了,必须先删掉,否则内存泄漏)
			delete[] m_Data;

			// 3. 窃取资源
			m_Size = other.m_Size;
			m_Data = other.m_Data;

			// 4. 将原对象置空 (让它变成空壳)
			other.m_Data = nullptr;
			other.m_Size = 0;
		}

		//找到 this 指向的对象,返回它的引用(别名),
		// 而不是创建一个新的副本
		return *this;
	}

	//拷贝赋值运算符
	String& operator=(const String& other)
	{
		printf("Copy Assigned!\n");
		if (this != &other)
		{
			delete[] m_Data;
			m_Size = other.m_Size;
			m_Data = new char[m_Size]; // 必须申请新内存
			memcpy(m_Data, other.m_Data, m_Size);
		}
		return *this;
	}

	void Print()
	{
		for (uint32_t i = 0; i < m_Size; i++)
			printf("%c", m_Data[i]);
		printf("\n");
	}
private:
	char* m_Data;

	//这是一个通过 typedef 或 using 定义的类型,表示
	//无符号整型32位数。
	// int 在某些古老的 16 位系统上可能是 2 字节,在现代系统上通常是 4 字节,uint32_t 强制规定在任何符合标准的编译器上,它永远是 32 位(4 字节)
	uint32_t m_Size;
};

class Entity
{
public:


	Entity(const String& name)
		:m_Name(name)
	{
		std::cout << "Entity(const String& name)" << std::endl;
	}



	//Entity(String&& name)
	//	//一旦右值引用有了名字,它在后续表达式中就变成了左值。
	//	//编译器会想:“这个 name 虽然是引用进来的,但它现在是有名有姓的变量,为了安全起见,我必须调用 复制构造函数 来保护它。
	//	:m_Name(name)
	//{
	//	std::cout << "Entity( String&& name)" << std::endl;
	//}



	//接收一个临时对象作为参数,使用移动语义来构造 Entity 对象,避免不必要的复制,提高性能。
	Entity(String&& name)
		//强转为右值引用,告诉编译器:我知道 name 是一个左值,但我想把它当作一个将要被销毁的临时对象来处理,所以请调用 移动构造函数 来构造 m_Name。
		//:m_Name((String&&)name) //这种写法也行
		:m_Name(std::move(name))  // 将左值 name 强行转回右值
	{
		std::cout << "Entity( String&& name)" << std::endl;
	}

	void PrintName()
	{
		m_Name.Print();
	}

private:
	String m_Name;
};

int main()
{ 
	{
		std::cout << "=====0=====" << std::endl;


		//使用String(const char* string)构造String对象,Created!
		String apple = "Apple";
		//使用默认构造函数构造String对象
		String dest;

		std::cout << "=====1=====" << std::endl;
		std::cout << "Apple: ";
		apple.Print();
		std::cout << "Dest: ";
		dest.Print();
		std::cout << "=====1=====" << std::endl;

		//使用移动赋值运算符将apple的资源移入dest,Move Assigned!
		//相当于dest.operator=(std::move(apple));
		dest = std::move(apple);

		std::cout << "=====2=====" << std::endl;
		std::cout << "Apple: ";
		apple.Print();
		std::cout << "Dest: ";
		dest.Print();
		std::cout << "=====2=====" << std::endl;

		//综上,没有任何复制就移动了整个字符数组的所有权,交换了两个
		//变量
	}
	

	std::cin.get();
	return 0;
}
/* 
=====0=====
Created!
String(const char* string)
=====1=====
Apple: Apple
Dest:
=====1=====
Move Assigned!
=====2=====
Apple:
Dest: Apple
=====2=====
Destroyed!
~String()
Destroyed!
~String()
*/
#endif

结尾 | 总结:拷贝 vs 移动#

  • 拷贝 (Copy):两份独立的资源,安全但慢。
  • 移动 (Move):一份资源的所有权转移,极快。
  • 金句:如果你希望你的 C++ 程序拥有极致性能,学会何时及如何使用 std::move 是必修课。
  • 当包含移动构造函数时,应该也包含移动赋值函数

89移动语义

为什么要引入移动语义?#

  • 核心痛点:在 C++11 之前,将对象从一处转移到另一处通常只能通过“拷贝”。

    • 向一个函数传递一个对象
    • 从函数返回一个对象
      • 如果有返回值优化RVO (Return Value Optimization)编译器直接在调用者(Caller)的栈帧中构造该对象,而不是在函数内部构造再拷贝出来。结果:既没有拷贝构造,也没有移动构造,性能损耗为零。
      • 当编译器因为逻辑复杂(例如根据条件返回不同的局部变量)而无法进行 RVO 时,移动语义就成了“二号救兵”。
        • 旧时代 (C++11 之前):如果 RVO 失效,必须调用拷贝构造函数,这涉及昂贵的深拷贝。
        • 现代 C++ (C++11 之后):编译器会尝试调用移动构造函数。1. 因为返回的对象是一个即将销毁的右值,它可以被“掠夺”。2. 你不需要手动写 return std::move(obj);,编译器会自动处理局部变量的返回
  • 性能代价:如果对象持有堆内存(如字符串或数组),拷贝意味着:申请新内存 -> 复制所有数据 -> 销毁旧对象。这在处理临时对象(扔掉的对象)时是非常巨大的浪费。

  • 解决方案:如果我们可以直接“移动”对象——即把旧对象的内存指针直接交给新对象,就能省去内存分配和数据复制的开销。

基础:构建实验用的 String#

  • 模拟场景:Cherno 编写了一个简易的 String 类,包含一个 char* 指针和 size
  • 可视化调试:在构造函数中加入 printf("Created"),在拷贝构造函数中加入 printf("Copied"),以便观察内存分配发生的时机。

拷贝的代价演示#

  • 实验代码:创建一个 Entity 类,它拥有一个 String 成员。当通过构造函数传入一个字符串字面量时,会发现控制台打印了 “Created” 然后是 “Copied”
  • 问题分析:即便传入的是一个临时字符串(右值),Entity 仍然会调用拷贝构造函数进行深拷贝。明明临时对象马上就要销毁了,我们却还要复制它,这很不科学。
#ifdef LY_EP89

//c++11才引入了右值引用
#include <iostream>   

class String
{
public:
	String() = default;

	//构造函数
	String(const char* string)
	{
		printf("Created!\n");
		//不包括\0
		m_Size = strlen(string);
		m_Data = new char[m_Size];
		memcpy(m_Data, string, m_Size);
		std::cout << "String(const char* string)" << std::endl;
	}
	
	//复制构造函数
	String(const String& other)
	{
		printf("Copied!\n");
		//不包括\0
		m_Size = other.m_Size;
		m_Data = new char[m_Size];

		//在 C++ 中,访问控制(public/private)是基于“类(Class)”层面的,而不是基于“对象(Object)”层面的。
		//简单来说:只要是在 String 类的成员函数内部,你就可以访问任何 String 对象的私有成员。
		memcpy(m_Data, other.m_Data, m_Size);

		std::cout << "String(const String& other)" << std::endl;
	}
	 

	~String()
	{
		delete[] m_Data;
		printf("Destroyed!\n");
		std::cout << "~String()" << std::endl;
	}

	void Print()
	{
		for (uint32_t i = 0; i < m_Size; i++)
			printf("%c", m_Data[i]);
		printf("\n");
	}
private:
	char* m_Data;

	//这是一个通过 typedef 或 using 定义的类型,表示
	//无符号整型32位数。
	// int 在某些古老的 16 位系统上可能是 2 字节,在现代系统上通常是 4 字节,uint32_t 强制规定在任何符合标准的编译器上,它永远是 32 位(4 字节)
	uint32_t m_Size;
};

class Entity
{
public:
	Entity(const String& name)
		:m_Name(name)
	{
		std::cout << "Entity(const String& name)" << std::endl;
	}  

	void PrintName()
	{
		m_Name.Print();
	}

private:
	String m_Name;
};

int main()
{
	//隐式构造函数构造String对象,调用String(const char* string)构造函数
	//Entity entity("Cherno");

	//临时对象String("Cherno")的生命周期直到支持它的那个“完整表达式”计算完成为止,即该行代码分号结束时
	//!!现在main函数创建这个(临时)对象后,传(复制)给Entity构造函数的当参数使用,又马上销毁了这个临时对象(只留下复制的)
	Entity entity(String("Cherno"));

	entity.PrintName();

	std::cin.get();
	return 0;
}
/*
Created!
String(const char* string)
Copied!
String(const String& other)
Entity(const String& name)
Destroyed!
~String()
Cherno
*/
#endif

编写移动构造函数 (Move Constructor)#

  • 核心语法:使用右值引用 String(String&& other)。注意要加上 noexcept 关键字。
  • 实现逻辑(偷走资源)
    1. 直接将 this->m_Data 指向 other.m_Data(浅拷贝指针)。
    2. this->m_Size 设为 other.m_Size
  • 关键的一步(置空原对象):必须将 other.m_Data 设为 nullptr,并将 other.m_Size 设为 0
  • 原理:这把旧对象变成了一个“空壳”。当旧对象析构时,delete nullptr 不会做任何事,从而保证了内存所有权的完美转移。
#ifdef LY_EP89

//c++11才引入了右值引用
#include <iostream>   
#include <utility> // std::move 在这个头文件里

class String
{
public:
	String() = default;

	//构造函数
	String(const char* string)
	{
		printf("Created!\n");
		//不包括\0
		m_Size = strlen(string);
		m_Data = new char[m_Size];
		memcpy(m_Data, string, m_Size);
		std::cout << "String(const char* string)" << std::endl;
	}

	//复制构造函数
	String(const String& other)
	{
		printf("Copied!\n");
		//不包括\0
		m_Size = other.m_Size;
		m_Data = new char[m_Size];

		//在 C++ 中,访问控制(public/private)是基于“类(Class)”层面的,而不是基于“对象(Object)”层面的。
		//简单来说:只要是在 String 类的成员函数内部,你就可以访问任何 String 对象的私有成员。
		memcpy(m_Data, other.m_Data, m_Size);

		std::cout << "String(const String& other)" << std::endl;
	}


	//移动构造函数
	//接收一个右值引用参数,表示可以从一个将要被销毁的临时对象中“窃取”资源,而不是复制资源。
	String(String&& other) noexcept
	{
		printf("Moved!\n");
		//不包括\0
		m_Size = other.m_Size;
		m_Data = other.m_Data;

		other.m_Size = 0;//将原对象的大小置为0,表示它不再拥有资源

		//把被接管控制权的资源指针置空,防止原对象的析构函数删除已经被移动的资源
		other.m_Data = nullptr;


		std::cout << "String(String&& other)" << std::endl;
	}

	~String()
	{
		delete[] m_Data;
		printf("Destroyed!\n");
		std::cout << "~String()" << std::endl;
	}

	void Print()
	{
		for (uint32_t i = 0; i < m_Size; i++)
			printf("%c", m_Data[i]);
		printf("\n");
	}
private:
	char* m_Data;

	//这是一个通过 typedef 或 using 定义的类型,表示
	//无符号整型32位数。
	// int 在某些古老的 16 位系统上可能是 2 字节,在现代系统上通常是 4 字节,uint32_t 强制规定在任何符合标准的编译器上,它永远是 32 位(4 字节)
	uint32_t m_Size;
};

class Entity
{
public:


	Entity(const String& name) 
		:m_Name(name)
	{
		std::cout << "Entity(const String& name)" << std::endl;
	}



	//Entity(String&& name)
	//	//一旦右值引用有了名字,它在后续表达式中就变成了左值。
	//	//编译器会想:“这个 name 虽然是引用进来的,但它现在是有名有姓的变量,为了安全起见,我必须调用 复制构造函数 来保护它。
	//	:m_Name(name)
	//{
	//	std::cout << "Entity( String&& name)" << std::endl;
	//}
	


	//接收一个临时对象作为参数,使用移动语义来构造 Entity 对象,避免不必要的复制,提高性能。
	Entity(String&& name)
		//强转为右值引用,告诉编译器:我知道 name 是一个左值,但我想把它当作一个将要被销毁的临时对象来处理,所以请调用 移动构造函数 来构造 m_Name。
		//:m_Name((String&&)name) //这种写法也行
		:m_Name(std::move(name))  // 将左值 name 强行转回右值
	{
		std::cout << "Entity( String&& name)" << std::endl;
	}

	void PrintName()
	{
		m_Name.Print();
	}

private:
	String m_Name;
};

int main()
{
	//隐式构造函数构造String对象,调用String(const char* string)构造函数
	//Entity entity("Cherno");

	//临时对象String("Cherno")的生命周期直到支持它的那个“完整表达式”计算完成为止,即该行代码分号结束时
	//!!现在main函数创建这个(临时)对象后,传(复制)给Entity构造函数的当参数使用,又马上销毁了这个临时对象(只留下复制的)
	Entity entity(String("Cherno"));

	entity.PrintName();

	std::cin.get();
	return 0;
}
/*
Created!
String(const char* string)
Moved!
String(String&& other)
Entity( String&& name)
Destroyed!
~String()
Cherno
*/
#endif

激活移动语义:std::move#

  • 常见误区:即便写了移动构造函数,编译器在==某些情况下(如成员变量赋值)==可能仍会选择拷贝。
  • 解决方法:在 Entity 的构造函数中使用 m_Name(std::move(name))
  • 效果验证:再次运行程序,你会发现 “Copied” 变成了 “Moved”。这意味着没有发生新的内存分配,程序运行得更快了。
#ifdef LY_EP89_

//#include <utility> //不加的话,std::move编译报错

int main()
{
	//int a=std::move(3);
	return 0;
}

#endif

结尾 | 总结与展望#

  • 核心价值:移动语义让你在不损失代码可读性的前提下,极大地提升了处理大型对象和临时对象的性能。

85左值与右值

什么是左值和右值?#

  • 核心定义:Cherno 用最简单的方式解释了两者。
    • 左值 (lvalue) 定位值 :有存储地址、有名字的变量。你可以给它赋值,它通常存在于表达式的左侧。
    • 右值 (rvalue):临时值,没有名字,通常在表达式结束后就销毁了(如字面量 10 或临时函数返回值)。
  • 直观判定法:如果你能取这个东西的地址(使用 & 运算符),它通常就是左值。

左值引用 (lvalue references)#

  • 回顾:这就是我们常用的 int& a = b;
  • 规则限制:左值引用不能绑定到右值上。
    • int& a = 10; 会报错,因为 10 是右值。
    • 例外情况const int& a = 10; 是合法的。编译器会产生一个临时变量来存放 10,然后让引用绑定它。这在函数参数传递中非常常见。

例子#

#ifdef LY_EP85

#include <iostream>   



void SetValueConst(const int& value)
{

}

void SetValue1(int& value)
{

}
void SetValue(int value)
{

}

int GetValue()
{
	return 10;//10是一个右值
}

int& GetRefValue()
{
	static int i=10;
	std::cout << "GetRefValue():" <<  i << std::endl; 
	return i;
}

int main()
{
	int i = 10;//i为左值,10为右值
	//10 = 3;//表达式必须为左值,10是右值,不能作为表达式的左边
	int a = i;//讲一个左值(a)设置为另一个左值(i)
	int i1 = GetValue();//GetValue()是一个右值,i是一个左值
	//GetValue() = 5;//GetValue()是一个右值,不能作为表达式的左边,而表达式必须是一个可修改的左值

	for (int i = 0; i < 3; i++)
	{
		GetRefValue()++;//GetRefValue()是一个左值,可以作为表达式的左边 

	}
	/*
GetRefValue():10
GetRefValue():11
GetRefValue():12
	*/

	SetValue(i);//i是一个左值,可以作为表达式的右边,用左值创建一个临时对象,并将其作为实参传递给函数
	SetValue(10);//10是一个右值,可以作为表达式的右边,即用右值创建一个临时对象,并将其作为实参传递给函数

	SetValue1(i);//从左值创建一个左值引用,所以可以将左值i作为实参传递给函数SetValue1(int& value),编译器会将i作为实参传递给函数

	//SetValue1(10); //编译出错:不能从右值创建一个左值引用,所以不能将右值10作为实参传递给函数SetValue1(int& value),编译器会报错 //非const的引用,必须通过左值来初始化

	SetValueConst(i);//从左值创建一个const左值引用 

	SetValueConst(10);//从右值创建一个const左值引用 
	//int& a1 = 10;//非const的引用,必须通过左值来初始化

	//编译器可能创建了一个带实际存储的临时变量(比如temp=10),然后
	//const int& b1=temp;
	const int& b1 = 11;//const的引用,还可以通过右值来初始化

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

右值引用 (rvalue references) —— &&#

  • 新语法:C++11 引入了 int&&
  • 作用:它专门用来绑定右值。
    • int&& a = 10; 是合法的。
    • 它允许我们“拦截”那些即将销毁的临时对象。
  • 意义何在?:这是为了性能优化。既然右值是临时的,我们与其拷贝它的数据,不如直接“偷”走它的资源(这就是移动语义的基础)。

实际应用:函数重载#

  • 场景演示:Cherno 展示了如何通过重载同一函数名,分别处理左值和右值:
void PrintName(const std::string& name); // 接收左值和 const 右值
void PrintName(std::string&& name);      // 专门接收右值(临时对象)
  • 解释

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 的函数后,有几种处理方式: