C++ 八股文查漏补缺

这里只是把我不知道的或记忆模糊的八股文写进来了,其他我已经会的就不写了11 当然,如果写了那肯定是写全了,而且每一个都经过ai的审核,确保没有遗漏的或不说人话的部分。所以不能只把我的这个当成背诵文档。从这个网址背的:C++ 面试题

感觉背八股文本身就是错的,完全没有必要。毕竟这些细枝末节的东西不经常用很快就会忘了,最后就只留有一个印象。用的时候再查就行了嘛。但是如果面试的时候是一问三不知,那在别人看起来不就是啥也不会嘛,所以也就只能背了。

常量指针与指针常量

文章里写的很复杂,其实核心就两个规则:

  1. const 左结合
  2. 如果左边没有类型,则右结合

体现:

比如常量指针:const int * p;int const * p。都是与 int 结合,没有与 * 结合,所以指针本身(*)是可以更改的,但是指向的值(int)无法更改。

而指针常量:int * const p:这个与 * 结合,所以指针不可以更改,而指向的值可以更改。

这个规则忘记是从哪里看到的了,不过如果我没有记错,应该是从 《C++20高级编程》中看到的。

static 的作用

static 可以定义静态变量,静态函数。

改变 全局变量 的作用域

对于全局变量来说,如果没有 static ,则其作用域是全局作用域。其他文件可以通过 extern 关键字来访问到该文件中定义的全局变量。而如果加上了 static,则会将其作用域限制到了文件作用域中。此时,其他的文件无法再访问到该全局变量。在这个情况下,即使两个不同的源文件都定义了相同名字的静态全局变量,它们也是不同的变量。

未初始化的全局静态变量会被编译器将其默认初始化为0。因为全局静态变量存放在内存的 BSS 段,程序启动时系统会自动将该段所有数据清零。

改变 局部变量 的生命周期

正常来说,一个局部变量会随着其所在作用域的消失而消失,当函数执行结束时,变量会被自动销毁。但是对于静态变量来说,它在程序运行期间只会初始化一次,在程序结束运行时才会销毁。当函数多次调用时,它会保留上一次调用结束时的数值。

更改类的成员变量与成员函数的相关性

在没有定义 static 时,类的成员变量与成员函数都描述的是与一个对象的一系列的信息与动作,而加上了 static 以后,该描述的是与类型相关的信息与动作。此时,不需要定义对象,可以直接通过类来访问静态成员与静态函数。

这里有需要注意的:

  1. 类的静态成员函数只能访问静态成员变量与静态成员函数
  2. 不可以将静态成员函数定义成虚函数
  3. 静态成员变量在类内声明,需要在类外定义与初始化。

Union 联合体

Union 由若干个数据类型不同的数据成员组成。使用时联合体只有一个有效的成员。对联合体不同的成员赋值,会覆盖其他成员的值。其内存分配规则为:其内部所有变量的最大值,按照最大类型的倍数进行分配大小。

比如:

typedef union {
char c[10];
double d;
} u33;

由于这里 double 是8个字节,所以按该类型的倍数分配大小。所以其最终的大小为:24 字节(8 * 3)。

volatile

原文说的那些是完全的错误

在 c++中,volatile 正确的使用场景主要有:

  1. 内存映射 I/O:在嵌入式开发或驱动开发中,访问硬件寄存器。由于硬件的状态随时会更改,所以必须要求编译器每次都真切的读地址上的值,不可以优化。
  2. 信号处理:使用 sig_atomic_t 类型的变量在信号处理函数与主程序之间通信时,需要加 volatile。

为什么是错的?

如果两个线程同时访问同一个非原子变量(且其中一个是写操作),发该操作就是 c++标准中定义的“数据竞争”,这是未定义行为(UB)。而编译器的所有的优化性能的操作,都是通过UB来优化的22 只要标准中没有定义,那么编译器就可以做任何的事

怎么做才是对的?

在 C++11标准中。如果要实现上述的功能,可以使用:原子变量或锁。

为什么一般将析构函数设置为虚函数?

这里原文介绍的比较的浅,但是我也记不清了,所以正好去查一查资料来回忆一下。

为了在使用“基类指针指向派生类对象”并进行 delete 操作时,可以正确触发动态绑定,从而先调用派生类的析构函数,再调用基类的析构函数,防止内存泄漏或资源未释放。

以下是具体的例子:

class Base {
public:
// 假设这里没加 virtual
~Base() { cout << "Base dest" << endl; }
};

class Derived : public Base {
public:
int* data;
Derived() { data = new int[100]; }
~Derived() {
delete[] data;
cout << "Derived dest" << endl;
}
};

int main() {
Base* ptr = new Derived(); // 基类指针指向派生类对象
delete ptr; // 关键点在这里!
}

如果没有加virtual,那么编译器就会认为这个ptr是一个Base类型的对象。此时就会直接调用 Base::~Base() 函数,即:只调用基类的析构函数。如果 Derived 中也定义了一些对象,需要依靠析构函数来清除,那么这就会使得内存泄漏。

如果添加了virtual,那么编译器会生成 vtable 虚函数表 与 vptr 虚表指针。BaseDerived 都会有一个虚函数表。表中存放着各自虚函数的地址(包括析构函数)。同时,每个对象补全在内存的最开始位置会多出一个隐藏指针 vptr,指向该对象所属类的 vtable。

在这个情况下,当编译器看到 delete ptr且析构函数是虚函数时。它不会像之前一样,直接调用指令,而是生成一段间接调用的逻辑:

  1. 先读取ptr指向对象的内存首地址,得到vptr。
  2. 通过 vptr 找到 vtable
  3. 通过 vtable 找到析构函数实在存在的地址(由于该对象是 Derived,Derived 的 vptr 指向的是 Derived 的 vtable ,所以这里找到的就是 Derived::~Derived() 的地址)
  4. 调用该析构函数 Derived::~Derived()
  5. 释放内存空间

诶,这就结束了吗,基类不需要调用吗?是的,因为派生类的析构函数Derived::~Derived()的末尾会被编译器自动插入调用其父类析构函数Base::~Base()的指令。这里也可以解释:析构顺序是 Derived -> Base,先拆子,后拆父。

为什么一般不把构造函数设置为虚函数

这个问题是上面问题的一个镜像问题。

在语法上,C++ 禁止将构造函数设置为 virtual。

从虚函数的调用过程中可以看到,如果要调用 ptr->func() 的时候,程序会通过 ptr 指向的对象内存中找到 vptr,再借此找到 vtable,进而找到具体的地址。但是 vptr 是在对象构造期间被初始化的。如果构造是虚函数,那么调用构造函数就需要通过 vptr 来进行虚函数的动态分发。但是这个时候对象还没有实例化,内存中根本没有 vptr,或者是 vptr还没有赋值。这是一个矛盾的点。

而且虚函数的核心意义是多态。即:程序知道这是一个基类的指针,但是希望根据它实际指向的对象类型来执行动作。这里有一个前提:对象已经存在,只是不知道它的具体的类型。

C++ 的4种类型转换方式

static_cast 静态转换

它主要有3种用途:

  1. 相关类型之间的转换33 比如,基本类型的转换,枚举与整型,还有用户自定义的类型转换(通过构造函数,转换运算符)等等
  2. void* 指针与具体类型指针(比如 int*)之间的转换
  3. 类层级结构中的上行转换,比如子类指针转父类
  4. 类层级结构中的下行转换,将父类转成子类,但是它不会在运行时检查转换的对象是不是正确的。如果强行转换,则会产生未定义行为。

它是在编译时进行类型检查的,所以速度很快

但是它不能在两个完全不相关的类指针之间转换,也不可以去掉const 属性。

dynamic_cast 动态转换

它是用于处理类层级结构中的安全性检查。

它可以进行下行转换,即:父类指针转子类指针。

它是在运行时检查的,同时要求基类必须至少有一个虚函数(因为该函数底层是通过 vtable 来确定的,如果没有虚函数,则没有 vtable)。同时,由于它是在运行时检查的,它会有一定的开销。

如果它在转换失败时,如果是指针,则会返回 nullptr,如果是引用,则会抛出 std::bad_cast 异常。

这里有一个要注意的点:

#include <iostream>

class Animal {
public:
virtual void eat() {
std::cout << "animal 吃" << std::endl;
};
};
class Dog : public Animal {
public:
void eat() override {
std::cout << "dog 吃" << std::endl;
}
};

int main()
{

Animal* a = new Dog();
// 成功:因为 Dog 是 Animal 的子类,它们“相关”
Dog* d = dynamic_cast<Dog*>(a);

std::cout << "Hello World!\n";
}

上述的代码中,dynamic_cast 与 static_cast都可以将 animal 转为 dog,那么这两个有什么区别呢?static_cast在编译期检查,而dynamic_cast在运行时检查,所以static_cast的速度很快。由于static_cast不在运行时检查对象的真实类型,所以它是不安全的,而dynamic_cast是安全的,可以看以下的例子:

Animal* a = new Cat(); // 实际上是一只猫
// 危险!static_cast 会强行把“猫”当成“狗”
Dog* d = static_cast<Dog*>(a);
// 这里可能导致崩溃,因为 d 指向的对象内存里根本没有 Dog 的特有数据
d->bark();

const_cast 常量转换

这是4种转换中唯一可以改变 const 属性的转换

它可以移除变量的 const 或 volatile 资格,或者是添加 const 属性44 添加 const 属性往往通过隐藏转换直接完成

这里需要注意的是:如果原始的对象被定义为了 const ,然后使用 const_cast 去掉常量性并修改其值,该执行是未定义行为。在很多编译器中,这样的更改在使用了o2或o3优化后往往是无效的。这里需要再次强调的是:如果一个变量原本不是 const,只是被通过 const int* 引用了,此时用 const_cast 去掉 const 并修改它是安全的。只有当原始变量被声明为 const int x = 10; 时,修改它才是未定义行为。

那么这个有什么用呢?它通常用于调用某些参数类型不匹配,但是可以保证不修改的旧式库函数。也就是用于兼容旧版的库,正常开发时,不需要使用这个转换。

reinterpret_cast 重解释转换

这个是最底层的转换。C++的类型本质上是对一个地址空间的解读。而这个转换就是告诉编译器:这一块地址空间应该更改成另一种解读方式。因此,该转换完全依赖程序员的正确性,无条件相信程序员。

它可以将一种类型的指针转换为另一种完全不相关的类型指针,比如将 int* 转成 char*。

它不可移植,同时很可能违反严格别名规则55 该规则规定编译器可假设不同类型的指针(除 char* 等)绝不指向同一地址。这允许编译器将变量值缓存在寄存器中以加速运行。若通过 reinterpret_cast 让 float* b 指向 int* a 的地址,当你先写 *a = 10 后写 *b = 2.0f 时,编译器会认为修改 b 不可能影响 a,因此在后续读取 *a 时可能直接从寄存器返回旧值 10,而非从内存读取被修改后的位模式。这种逻辑错乱即为未定义行为。。但是,也不是所有的类型都是会违反的。为了方便底层开发,标准规定了几种特殊类型的,它们是可以指向任何其他类型成不违反规则的:

  1. char*unsigned char*std::byte*,这些被视为原始字节类型,。可以使用 char* 指向任何对象,并读取其字节内容。
  2. 兼容类型,比如int*const int*,有符号与无符号的版本。

如果需要操作位,那么最好的方式不是使用 reinterpret_cast,而是使用 memcpy 或std::bit_cast

bit_cast

这个是C++20引入的新特性,专门用于安全的重新解释位,也就是处理上述的问题。

float f = 1.23f;
auto i = std::bit_cast<int>(f); // 优雅且安全

它有两个基本的要求:

  1. sizeof(To) == sizeof(From)(大小严格相等)。
  2. 两个类型都必须是 Trivially Copyable(平凡可拷贝)。 它实际上相当于编译器层面实现的 memcpy,但更优雅且支持 constexpr(可在编译期执行)。

重载,重写,隐藏

重载:指在同一个可访问的区域内,声明多个具有不同参数万的同名函数。编译器会根据参数列表来确定使用哪一个函数,重载并不关心函数返回类型。

隐藏:在派生类的函数屏蔽了与其同名的基类函数。只需要函数名相同就行,不管参数列表是不是相同,基类函数都会被隐藏。

重写:在派生类中,写一个与基类完全一样的函数66 函数名,参数列表,返回值类型都完全相同。同时基类中被重写的函数必须有 virtual 关键字。

C++ 的多态是什么,怎么通过虚函数实现呢?

C++ 的多态性是指:同一个操作作用于不同的对象时,可以产生不同的行为。在C++中多态通常分为两种类型:

  1. 编译时多态,这也可以称为静态多态:通过函数重载与运算符重载实现。可以使编译器在编译时确定调用哪个函数。
  2. 运行时多态,这也称为动态多态:这通过虚函数实现,可以在运行时根据对象的实际类型决定调用的函数77 这里具体的原理可以看我上文的描述

什么是函数对象?与普通函数的区别?

函数对象就是指一个重载了 operator() 的类或结构体实例。函数对象可以像普通函数一样被调用,但它们实现上是对象,具有状态与行为。

最简单的一个:

class Add {
public:
int operator()(int a, int b) {
return a + b;
}
};
Add add;
int result = add(2, 3); // 这里就是调用了函数对象

可以看到,对于调用的函数本身是一个对象,所以它可以有自己的状态。而普通的函数则没有这个状态。但是函数对象需要先实例化,之后才可以调用,而普通函数可以直接调用。

C++ 空类的大小是多少?

C++ 空类的大小是1字节。

C++ 规定,任何对象都必须有一个唯一的内存地址。如果空类的大小为0,那么当创建这个类的多个对象时,它们会共享同一个地址,这违反了“每个对象地址唯一”的规则。所以编译器会给空类隐藏分配1字节的空间,目的是为了让这个类的每个实例可以有独一无二的内存地址。

静态成员不占用类的大小,因为静态成员保存在静态存储区。

拓展:如果只有虚函数的类,大小是多少?

大小是一个指针的大小,因为虚函数的类对象中都有一个虚函数表指针。

左值与右值

最简单的判断标准是:能不能对其使用取地址运算符,如果可以取,那么就是左值,如果不可以,那么就是右值。

引用传递主要用于避免大对象的拷贝。普通的左值引用用于在函数中修改传入的参数,而常量左值引用则保证不修改,只读取。右值引用主要用于实现移动语义与完美转发。

在C++ 中,可以使用 std::move() 将左值强制转成右值。但是这里需要注意的是,std::move() 本质并没有移动任何东西,它的本质是一个静态类型的转换。它只是告诉编译器,这里应该把左值当成右值来用。因此,使用它来传参时,会优化匹配右值引用的参数,从而触发移动构造或移动赋值,从而实现资源的转移。

以下是 std::move() 的底层原理:

_EXPORT_STD template <class _Ty>
_NODISCARD _MSVC_INTRINSIC constexpr remove_reference_t<_Ty>&& move(_Ty&& _Arg) noexcept {
// 1. typename std::remove_reference<T>::type 提取出 T 去掉引用后的基本类型
// 2. static_cast<...&&> 强制转换为该基本类型的右值引用
return static_cast<remove_reference_t<_Ty>&&>(_Arg);
}

步骤如下:

  1. 先使用 T&& arg 88 这里也叫万能引用来接收左值与右值参数。
  2. 然后使用 std::remove_refrence<T>::type 去除掉 T 上的引用属性(即:将int&int&& 都变成单纯的 int)。
  3. 最后使用 static_cast<int&&>(arg) 完成强制转换,加上 &&

拓展:什么是完美转发?什么情况下需要使用?底层原理?

完美转发:在模板函数中,保持参数的原有值类别99 什么是原有值类别?即左值与右值属性不变,然后将其传递给其他的函数。

为什么需要这个呢?因为:“具名的右值引用”本身是一个左值。那么为什么因为这个,就需要完美转发了呢?因为引用折叠规则。在C++中,是不允许直接写出引用的引用的。但是如果在模板推导时,出现了这个情况,那么编译器会自动折叠它们。折叠的方式如下,但是使用一句话就可以总结:只要有左值引用 & 参与,那么折叠的结果就是左值引用 &:

  1. & + & = &
  2. & + && = &
  3. && + & = &
  4. && + && = &&

从以下的例子中看:

void process(int& x)  { cout << "左值处理" << endl; }
void process(int&& x) { cout << "右值处理" << endl; }

template <typename T>
void wrapper(T&& arg) {
// arg 作为一个形参,它有名字,所以在 wrapper 内部,arg 永远被看作是左值!
process(arg); // 这里永远会调用 process(int& x)
}

int main() {
int a = 10;
wrapper(a); // 传入左值,输出:"左值处理"
wrapper(20); // 传入右值,输出:"左值处理" —— 糟糕!右值属性丢失了!
}

为了解决该问题,可以使用完美转发来完成:

template <typename T>
void wrapper(T&& arg) {
// 使用 std::forward 恢复 arg 原本的值类别
process(std::forward<T>(arg));
}
// 此时 wrapper(20) 将正确输出 "右值处理"

以下是 std::forward() 的底层原理:

std::forward() 是有条件的类型转换。它依赖于 C++11 的模板参数推导与引用折叠规则。其底层的源码可以看成:

// 接收左值的版本
_EXPORT_STD template <class _Ty>
_NODISCARD _MSVC_INTRINSIC constexpr _Ty&& forward(remove_reference_t<_Ty>& _Arg) noexcept {
return static_cast<_Ty&&>(_Arg);
}

_EXPORT_STD template <class _Ty>
_NODISCARD _MSVC_INTRINSIC constexpr _Ty&& forward(remove_reference_t<_Ty>&& _Arg) noexcept {
static_assert(!is_lvalue_reference_v<_Ty>, "bad forward call");
return static_cast<_Ty&&>(_Arg);
}

假设有这样一个模板函数

template <typename T>
void wrapper(T&& arg) {
process(std::forward<T>(arg));
}

如果传入的是左值(例如 wrapper(a)):

  1. 根据 C++11 的特殊规则,编译器会将模板参数 T 推导为 int&。那么形参 arg 的类型就是 int& &&,触发引用折叠为 int&
  2. 随后调用 std::forward<T>(arg),即调用 std::forward<int&>(arg)
  3. 在 forward 内部,返回类型 T&& 变成了 int& &&,再次折叠为 int&。这是一个左值引用。
  4. 结论:传入给 arg 的是左值,传给 process 的也是一个左值引用,完美保持了左值属性。

为什么传入右值时,它不是推导成 int &&,然后将int && && 折叠成 int &&呢?
在C++中,表达式本身没有“引用”类型,表达式只有“基础类型”(比如是 int,还是double,还是MyClass等)和“值类别”(是左值,还是右值,还是亡值)。同时,在进行类型推导前,表达式身上的引用属性会被剥离。因此,当传入字面量,或计算表达式甚至是显示调用了std::move(x)时,它们的类型都将是int。同时值类别也都是右值(std::move()的值类别是亡值,这也是右值)。

那么为什么传入左值时,却推导出了int&呢?
因为 C++ 11 为了实现完美转发,为左值设定了一个特殊规则:如果形参是万能引用(T&&),并且传入的参数是一个左值,那么T会被特殊推导为该类型的左值引用(int&)。如果没有该规则,那么将左值T传给T&&时,推导是会报错的。

如果传入的是右值(例如 wrapper(20)):

  1. 编译器遵循常规规则,将模板参数 T 推导为基础类型 int。那么形参 arg 的类型代入后直接就是 int&&(未发生折叠)。
  2. 随后调用 std::forward<T>(arg),即调用 std::forward<int>(arg)
  3. 在 forward 内部,返回类型 T&& 就是 int&&。这是一个右值引用。
  4. 结论:传入给 arg 的是右值,传给 process 的也是一个右值引用,完美保持了右值属性。

内存对齐

内存对齐就是指将数据存储在内存中特定倍数的地址上。这是一种“使用空间换时间”的优化策略,旨在配合CPU的内存访问机制,提升数据读取效率。

在C++中,主要有这样的几个概念:

  1. 对齐要求:每种数据类型都有其特定的对齐要求。比如在 64 位系统中,int 的对齐要求通常是4字节,所以它的起始地址必须可以被 4 整除。如果一个类型的邽是其自身大小的整数倍时,则称为自然对齐1010 比如 8 字节的 double 存放在 8 的倍数地址上
  2. 填充字节:为了满足对齐要求,编译器在数据成员之间或结构体末尾会自动插入无意义的字节。

    1. 内部填充:在结构体的成员之间,确保后续成员满足对齐要求
    2. 尾部填充:位于结构体末尾,确保在创建该结构体数组时,每一个数组元素的起始地址都符合对齐要求。
  3. 对齐值:通常指一个结构体中最大成员的对齐要求。整个结构体的起始地址和总大小必须是该对齐值的整数倍。

为什么要内存对齐?

  1. 有的硬件无法访问任意地址上的任意数据
  2. 性能原因,CPU从内存中读取数据是按存储块读取的,比如4,6,16字节这样。在对齐情况下,只需要1次总结周期就可以完成4字节的读取。如果是非对齐,那么可能需要两次内存访问,同时还执行了额外的位移与拼接操作,这会降低吞吐量。

如果一个结构体的大小太大,可以通过“按字节大小降序排列成员”的方式来减少填充字节。

动态链接库怎么装载到内存中?

通过 mmap 把该库直接映射到各个进程的地址空间中,尽管每个进程都认为自己的地址空间中加载了该库,但是实际上该库在内存中只存在一份。

函数调用的时候压栈是怎么样的?

函数在调用时,会进行以下的压栈操作:

  1. 传递参数:调用者(Caller)先将参数压栈。
  2. 保存返回地址:执行 CALL 指令时,CPU 自动将下一条指令的地址压栈。
  3. 保存调用者的栈帧指针:进入函数后,函数内部(Callee)将旧的栈帧基址(如 EBP/RBP)压栈。
  4. 分配局部变量空间:通过移动栈指针(如 ESP/RSP)为局部变量预留空间。

如果 new 内存失败了会怎么样?

会抛出 std::bad_alloc 异常。如果加上了 std::nothrow 关键字,即:A* p = new (std::nothrow) A();。这时, new 就不会抛出异常,而是返回空指针。

malloc 与 new 的区别是什么

  1. 分配内存的位置:malloc 是在堆上动态分配内存,而 new 是从自由存储区为对象动态分配内存。同时,自由存储区的位置取决于 operator new 的实现。因此,自由存储区不仅可以是堆,也可以是静态存储区。
  2. 返回类型安全性:malloc 内存分配成功后返回 void *,然后再强制类型转换为需要的类型;而new操作符分配内存成功后直接返回与对象类型相匹配的指针类型;所以 new 是符合类型安全的操作符。
  3. 内存分配失败返回值:malloc 内存分配失败后返回 NULL。new分配内存失败则抛出异常(std::bad_alloc)。
  4. 分配内存的大小的计算:使用new操作符时,不需要指定内存块的大小,编译器会根据类型信息自行计算,而malloc 需要显式的指出所需要的内存的尺寸。
  5. 重载:newdelete可以被重载,而 mallocfree 无法被重载。
  6. 初始化:new 在成功分配到内存后,会直接调用构造函数。而free则需要自己手动完成内存的初始化。

malloc 的原理与底层实现

原理:

  1. 当开辟的空间小于 128K 时,调用 brk()函数,通过移动 program break 来实现;
  2. 当开辟的空间大于 128K 时,调用 mmap() 函数,䏍在虚拟地址空间中开辟一块内存空间来实现。

在现代的操作系统中,这个128K的阈值是动态调整的。如果开启了动态阈值,它会根据分配习惯在128K到32MB之间变动。

brk() 原理:会将堆顶指针向高地址移动,从而获得新的内存空间。 mmap() 原理:通过匿名映射,从文件映射区中分配一块内存。

malloc并不是每次都会将 free 的内存还给内核,而是先放到Bins里。当再次 malloc 时,会优先从 Bins 中找一个大小合适的内存返回,这样可以减少系统调用,提高性能。

malloc 返回的都是虚拟地址。如果是第一次读写这块地址时,会触发缺页中断,此时内核才会真正的去分配物理内存并建立映射关系。

std::future

std::future 是一个占位符对象,代表了一个在未来某个时间点才会变得可用的值。

在多线程编程中,通常会使用一个后台的任务来计算结果或获得数据。那么如果主线程要拿到这个后台任务的返回值,就可以使用 std::future。std::future 提供了一种机制来访问异步操作的结果。

它是提供者与获取者的工作模型。它可以被以下的3种提供者提供:

  1. std::async:直接启动一个异步函数,返回一个 std::future
  2. std::promise:手动设置值。可以在一个线程里使用 promise 写数据,在另一个线程里通过关联的 future 读数据。
  3. std::packaged_task:将一个函数包装起来,使其返回值可以被 future 获取,常用于线程池

以下是这3种的使用示例:

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

// 一个简单的耗时计算任务:计算平方
int calculate_square(int x) {
std::this_thread::sleep_for(std::chrono::milliseconds(500));
return x * x;
}

int main() {
std::cout << "--- 开始 std::future 提供者演示 ---\n" << std::endl;

// ====================================================
// 1. std::async —— 最简单的自动化方案
// 作用:直接启动异步任务,返回 future。
// ====================================================
{
std::cout << "[std::async] 正在启动..." << std::endl;

// std::launch::async 确保在新线程中运行
std::future<int> f1 = std::async(std::launch::async, calculate_square, 10);

// 主线程可以做别的事...
std::cout << "[std::async] 结果: " << f1.get() << std::endl;
}

// ====================================================
// 2. std::promise —— 最灵活的手动方案
// 作用:在线程间手动传递“单次”信号或值。
// ====================================================
{
std::cout << "\n[std::promise] 正在启动..." << std::endl;

std::promise<int> prom;
std::future<int> f2 = prom.get_future();

// 启动一个线程,手动在某个时刻填充结果
std::thread t([&prom]() {
std::this_thread::sleep_for(std::chrono::milliseconds(500));
int result = 20 * 20;
prom.set_value(result); // 手动设置值,此时 f2.get() 才会解除阻塞
});

std::cout << "[std::promise] 结果: " << f2.get() << std::endl;
t.join();
}

// ====================================================
// 3. std::packaged_task —— 适用于任务队列/线程池
// 作用:包装一个函数对象,解耦“任务定义”与“任务执行”。
// ====================================================
{
std::cout << "\n[std::packaged_task] 正在启动..." << std::endl;

// 包装函数
std::packaged_task<int(int)> task(calculate_square);

// 获取关联的 future
std::future<int> f3 = task.get_future();

// task 本身是可调用的(类似于 std::function),但它会将结果存入 future
// 我们可以把这个 task 扔进一个线程或者任务队列里
std::thread t(std::move(task), 30);

std::cout << "[std::packaged_task] 结果: " << f3.get() << std::endl;
t.join();
}

std::cout << "\n--- 演示结束 ---" << std::endl;
return 0;
}

这里需要注意的是:std::future::get() 只能被调用一次。调用后,该对象的状态就无效了。如果需要使用多个线程等待同一个结果,应该使用 std::shared_future