Appearance
C++11/14/17
auto 有什么用?
一句话理解
C++ 的 auto 用来让编译器根据初始化表达式自动推导变量类型。它是编译期推导,不是运行时动态类型,也不是 C# 的 dynamic。
最基本用法
c
auto x = 10; // 编译器根据 10 推导出 x 的类型是 int
auto y = 3.14; // 编译器根据 3.14 推导出 y 的类型是 double
auto name = "Tom"; // 编译器根据字符串字面量推导出 name 的类型是 const char*注意:类型在编译期已经确定了,后面不能随便换类型。
c
auto value = 100; // value 被推导为 int
value = 200; // 可以,因为 200 也是 int
// value = "hello"; // 错误,因为 value 不是字符串类型常见用途 1:简化很长的类型
STL 迭代器类型经常很长。
c
std::vector<int> nums = {1, 2, 3}; // 创建一个 int 类型的 vector
auto it = nums.begin(); // 自动推导 it 是 std::vector<int>::iterator如果不用 auto,可能要写:
c
std::vector<int>::iterator it = nums.begin(); // 手动写完整迭代器类型,代码更长所以 auto 最大的价值之一是减少模板代码噪音。
常见用途 2:范围 for 遍历
c
std::vector<int> nums = {1, 2, 3}; // 创建一个整数数组容器
for (auto n : nums) // 每次循环会把元素拷贝给 n
{ // for 循环体开始
std::cout << n << std::endl; // 输出当前元素
} // for 循环体结束如果元素很大,推荐用引用,避免拷贝:
c
std::vector<std::string> names = {"Tom", "Jerry"}; // 创建字符串容器
for (const auto& name : names) // 使用 const 引用遍历,避免拷贝字符串
{ // for 循环体开始
std::cout << name << std::endl; // 输出当前名字
} // for 循环体结束常见用途 3:接收复杂返回值
c
auto result = MakePlayer(); // 编译器根据 MakePlayer 的返回值推导 result 类型尤其在模板、STL、泛型代码里,返回类型可能很复杂,用 auto 会清爽很多。
常见用途 4:Lambda 泛型参数
C++14 开始,lambda 参数可以用 auto。
c
auto add = [](auto a, auto b) // 创建一个泛型 lambda,可以接收不同类型参数
{ // lambda 函数体开始
return a + b; // 返回两个参数相加的结果
}; // lambda 表达式结束它可以用于 int、float、std::string 等支持 + 的类型。
重要坑点:auto 默认可能拷贝
c
std::string text = "hello"; // 创建一个字符串对象
auto copy = text; // copy 是 text 的一份拷贝
auto& ref = text; // ref 是 text 的引用,不会拷贝
const auto& readOnlyRef = text; // readOnlyRef 是只读引用,不拷贝也不能修改所以面试里一定要说: auto 不等于引用。想保留引用语义,要写 auto& 或 const auto&。
重要坑点:auto 必须初始化
c
auto x = 10; // 正确,编译器可以从 10 推导类型
// auto y; // 错误,没有初始化表达式,编译器无法推导类型面试高分说法
IMPORTANT
auto 是 C++ 的编译期类型推导关键字,可以让编译器根据初始化表达式推导变量类型。它常用于简化复杂类型,比如 STL 迭代器、模板返回值、范围 for 和 lambda。auto 不会让 C++ 变成弱类型,类型仍然在编译期确定。使用时要注意它默认会去掉部分引用和顶层 const,可能产生拷贝;如果想避免拷贝,常用 const auto&。所以工程上我会在类型明显或类型很复杂时使用 auto,但不会为了省字而牺牲可读性。
范围 for 有什么坑?
一句话理解
范围 for 是 C++ 的语法糖,本质还是通过 begin()、end()、迭代器去遍历。它好用,但常见坑主要是:不小心拷贝、没用引用导致改不到原元素、遍历时修改容器导致迭代器失效。
坑 1:auto 默认是拷贝
c
#include <iostream> // 引入输入输出库
#include <vector> // 引入 vector 容器
int main() // 程序入口函数
{ // main 函数体开始
std::vector<int> nums = {1, 2, 3}; // 创建一个整数 vector
for (auto x : nums) // x 是 nums 中元素的副本
{ // for 循环体开始
x += 10; // 修改的是副本,不会影响 nums 里的元素
} // for 循环体结束
std::cout << nums[0] << std::endl; // 输出 1,因为原数组没有被修改
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束如果想修改原元素,要用引用:
c
#include <iostream> // 引入输入输出库
#include <vector> // 引入 vector 容器
int main() // 程序入口函数
{ // main 函数体开始
std::vector<int> nums = {1, 2, 3}; // 创建一个整数 vector
for (auto& x : nums) // x 是 nums 中元素的引用
{ // for 循环体开始
x += 10; // 修改的是容器里的真实元素
} // for 循环体结束
std::cout << nums[0] << std::endl; // 输出 11,因为原数组被修改了
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束坑 2:大对象用 auto 会有额外拷贝
c
#include <iostream> // 引入输入输出库
#include <string> // 引入 string 类型
#include <vector> // 引入 vector 容器
int main() // 程序入口函数
{ // main 函数体开始
std::vector<std::string> names = {"Tom", "Jerry"}; // 创建字符串 vector
for (auto name : names) // 每次循环都会拷贝一个 string
{ // for 循环体开始
std::cout << name << std::endl; // 输出当前字符串副本
} // for 循环体结束
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束更推荐:
c
#include <iostream> // 引入输入输出库
#include <string> // 引入 string 类型
#include <vector> // 引入 vector 容器
int main() // 程序入口函数
{ // main 函数体开始
std::vector<std::string> names = {"Tom", "Jerry"}; // 创建字符串 vector
for (const auto& name : names) // 使用只读引用遍历,避免拷贝
{ // for 循环体开始
std::cout << name << std::endl; // 输出当前字符串
} // for 循环体结束
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束坑 3:遍历中修改容器结构
这个很重要。范围 for 内部依赖迭代器,如果你在遍历 vector 时 push_back、erase,可能导致迭代器失效。
c
#include <vector> // 引入 vector 容器
int main() // 程序入口函数
{ // main 函数体开始
std::vector<int> nums = {1, 2, 3}; // 创建整数 vector
for (auto x : nums) // 范围 for 内部正在使用迭代器遍历 nums
{ // for 循环体开始
nums.push_back(x); // 危险,push_back 可能导致 vector 扩容,让迭代器失效
} // for 循环体结束
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束如果需要边遍历边删除,通常手写迭代器循环:
c
#include <vector> // 引入 vector 容器
int main() // 程序入口函数
{ // main 函数体开始
std::vector<int> nums = {1, 2, 3, 4}; // 创建整数 vector
for (auto it = nums.begin(); it != nums.end(); ) // 手写迭代器循环,方便控制删除后的迭代器
{ // for 循环体开始
if (*it % 2 == 0) // 判断当前元素是否为偶数
{ // if 分支开始
it = nums.erase(it); // 删除当前元素,并接住 erase 返回的新迭代器
} // if 分支结束
else // 当前元素不是偶数
{ // else 分支开始
++it; // 只有不删除时才手动移动到下一个元素
} // else 分支结束
} // for 循环体结束
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束坑 4:map 结构化绑定可能拷贝
c
#include <map> // 引入 map 容器
#include <string> // 引入 string 类型
int main() // 程序入口函数
{ // main 函数体开始
std::map<int, std::string> users; // 创建 map,key 是 int,value 是 string
for (auto [id, name] : users) // id 和 name 都是拷贝出来的
{ // for 循环体开始
name = "Changed"; // 修改的是拷贝,不会修改 map 里的 value
} // for 循环体结束
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束更推荐:
c
#include <map> // 引入 map 容器
#include <string> // 引入 string 类型
int main() // 程序入口函数
{ // main 函数体开始
std::map<int, std::string> users; // 创建 map,key 是 int,value 是 string
for (auto& [id, name] : users) // 使用引用绑定 map 中的真实键值对
{ // for 循环体开始
name = "Changed"; // 修改 map 中真实的 value
} // for 循环体结束
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束面试高分说法
NOTE
范围 for 是语法糖,底层还是 begin/end 加迭代器。使用时最容易踩三个坑:第一,auto 默认拷贝,大对象要用 const auto&;第二,想修改容器元素必须用 auto&;第三,遍历过程中不要随便改变容器结构,否则可能导致迭代器失效。对于 map 的结构化绑定,auto [k, v] 也会拷贝,想修改或避免拷贝要写 auto& [k, v]。总体原则是:只读用 const auto&,要改元素用 auto&,要改容器结构就手写迭代器循环。
nullptr 比 NULL 好在哪里?
一句话理解
nullptr 比 NULL 好在:它是真正的空指针字面量,类型是 std::nullptr_t;而 NULL 很多时候本质上是 0 或 0L,容易被当成整数,导致重载、模板推导时出问题。
NULL 的问题
NULL 通常是一个宏,很多实现里类似这样:
c
#define NULL 0 // 把 NULL 定义成整数 0所以它看起来像空指针,但在某些场景下可能更像一个整数。
比如重载函数:
c
#include <iostream> // 引入输入输出库
void Test(int value) // 定义接收 int 的 Test 函数
{ // int 版本函数体开始
std::cout << "int" << std::endl; // 输出 int
} // int 版本函数体结束
void Test(int* pointer) // 定义接收 int 指针的 Test 函数
{ // 指针版本函数体开始
std::cout << "int pointer" << std::endl; // 输出 int pointer
} // 指针版本函数体结束
int main() // 程序入口函数
{ // main 函数体开始
Test(NULL); // 可能调用 Test(int),因为 NULL 可能就是 0
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束你本来想表达“空指针”,但编译器可能把它当成整数 0,这就是 NULL 的坑。
nullptr 更准确
c
#include <iostream> // 引入输入输出库
void Test(int value) // 定义接收 int 的 Test 函数
{ // int 版本函数体开始
std::cout << "int" << std::endl; // 输出 int
} // int 版本函数体结束
void Test(int* pointer) // 定义接收 int 指针的 Test 函数
{ // 指针版本函数体开始
std::cout << "int pointer" << std::endl; // 输出 int pointer
} // 指针版本函数体结束
int main() // 程序入口函数
{ // main 函数体开始
Test(nullptr); // 明确调用 Test(int*),因为 nullptr 是空指针字面量
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束nullptr 可以转换成任意指针类型,但不能随便当整数用。
c
int* p = nullptr; // 正确,nullptr 可以转换成 int 指针
// int x = nullptr; // 错误,nullptr 不能转换成普通整数nullptr 的类型是什么?
nullptr 的类型是:
c
std::nullptr_t // nullptr 的真实类型它是一个专门表示空指针的类型。
c
#include <cstddef> // 引入 std::nullptr_t 的定义
std::nullptr_t p = nullptr; // p 的类型是 std::nullptr_t模板里也更安全
c
template <typename T> // 声明模板参数 T
void PrintType(T value) // 定义模板函数,参数类型由调用时推导
{ // 函数体开始
} // 函数体结束
int main() // 程序入口函数
{ // main 函数体开始
PrintType(NULL); // T 可能被推导成 int 或 long
PrintType(nullptr); // T 会被推导成 std::nullptr_t
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束所以泛型代码里,nullptr 的语义更明确。
面试高分说法
CAUTION
NULL 是历史遗留写法,本质通常是宏,可能被定义成 0 或 0L,所以在函数重载和模板推导时容易被当成整数。nullptr 是 C++11 引入的空指针字面量,类型是 std::nullptr_t,可以隐式转换为任意指针类型,但不能转换为普通整数,因此类型更安全、语义更明确。现代 C++ 中,表达空指针应该优先使用 nullptr,不要再用 NULL 或 0。
move 本身会移动对象吗?
一句话理解
std::move 本身不会移动对象。它只是把一个左值强制转换成右值引用,告诉编译器:这个对象可以被当成“即将失效的对象”来处理。真正移动资源的是移动构造函数或移动赋值函数。
std::move 到底做了什么?
它大概可以理解成:
c
static_cast<T&&>(obj) // 把 obj 转换成右值引用类型也就是说,它不释放内存、不拷贝数据、不搬资源。它只是改变表达式的值类别。
例子
c
#include <iostream> // 引入输入输出库
#include <string> // 引入 string 类型
#include <utility> // 引入 std::move
int main() // 程序入口函数
{ // main 函数体开始
std::string a = "hello"; // 创建字符串 a,内部可能持有堆内存资源
std::string b = std::move(a); // std::move 把 a 转成右值,b 的移动构造函数接管资源
std::cout << b << std::endl; // 输出 b,此时 b 通常拿到了原来 a 的内容
std::cout << a << std::endl; // a 仍然有效,但内容不要依赖
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束真正发生资源转移的是这一句里的 std::string 移动构造函数:
c
std::string b = std::move(a); // 调用 string 的移动构造函数,而不是 std::move 自己搬资源如果没有移动构造,会怎样?
如果一个类没有移动构造函数,std::move 之后也不一定真的移动,可能退化成拷贝。
c
#include <utility> // 引入 std::move
class Player // 声明 Player 类
{ // Player 类体开始
public: // public 区域开始
Player() = default; // 使用默认构造函数
Player(const Player& other) = default; // 定义拷贝构造函数
}; // Player 类体结束
int main() // 程序入口函数
{ // main 函数体开始
Player a; // 创建 Player 对象 a
Player b = std::move(a); // 因为没有移动构造,可能调用拷贝构造
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束所以面试里一定要说: std::move 不保证一定移动,它只是让对象有资格被移动。
const 对象移动不了吗?
大多数情况下,const 对象很难真正移动。因为移动资源通常要修改源对象,比如把源对象指针置空。
c
#include <string> // 引入 string 类型
#include <utility> // 引入 std::move
int main() // 程序入口函数
{ // main 函数体开始
const std::string a = "hello"; // 创建 const 字符串 a
std::string b = std::move(a); // a 是 const,通常不能调用移动构造,可能变成拷贝
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束原因是 std::move(a) 得到的是类似 const std::string&&,而移动构造通常需要 std::string&&,不能修改 const 源对象。
被 move 之后的对象还能用吗?
能用,但要小心。
被移动后的对象处于有效但值未指定的状态。意思是:
可以析构;
可以重新赋值;
可以调用没有额外前置条件的函数;
不要依赖它原来的内容。
c
#include <string> // 引入 string 类型
#include <utility> // 引入 std::move
int main() // 程序入口函数
{ // main 函数体开始
std::string a = "hello"; // 创建字符串 a
std::string b = std::move(a); // 把 a 的资源移动给 b
a = "world"; // 可以重新给 a 赋值,让 a 回到明确状态
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束面试高分说法
TIP
std::move 本身不会移动对象,它只是一个强制类型转换,把左值转换成右值引用,本质类似 static_cast<T&&>。真正的移动发生在后续调用移动构造函数或移动赋值函数时。如果类型没有移动构造,或者源对象是 const,即使用了 std::move 也可能走拷贝。被移动后的对象仍然是有效对象,可以析构和重新赋值,但原来的值不应该再依赖。用 std::move 的前提是:我明确知道这个对象后面不再需要保留原值。
完美转发是什么?
一句话理解
完美转发就是:在模板包装函数里,把参数继续传给另一个函数时,尽量保留调用者传进来时的左值/右值属性、const 属性和类型信息。
为什么需要完美转发?
假设有两个重载函数:
c
#include <iostream> // 引入输入输出库
#include <utility> // 引入 std::forward
void Handle(int& value) // 接收左值引用版本
{ // 左值版本函数体开始
std::cout << "left value" << std::endl; // 输出这是左值
} // 左值版本函数体结束
void Handle(int&& value) // 接收右值引用版本
{ // 右值版本函数体开始
std::cout << "right value" << std::endl; // 输出这是右值
} // 右值版本函数体结束如果你写一个包装函数:
c
template <typename T> // 声明模板参数 T
void Wrapper(T&& value) // 使用转发引用接收参数
{ // Wrapper 函数体开始
Handle(value); // 错误示范:value 有名字,所以这里永远是左值
} // Wrapper 函数体结束注意重点:即使外面传进来的是右值,value 这个变量在函数内部有名字,所以表达式 value 是左值。
所以会丢失右值语义。
正确写法:std::forward
c
template <typename T> // 声明模板参数 T
void Wrapper(T&& value) // T&& 在这里是转发引用,可以接收左值也可以接收右值
{ // Wrapper 函数体开始
Handle(std::forward<T>(value)); // 按照 value 原来的值类别转发给 Handle
} // Wrapper 函数体结束完整测试:
c
#include <iostream> // 引入输入输出库
#include <utility> // 引入 std::forward
void Handle(int& value) // 定义接收左值引用的 Handle
{ // 左值版本函数体开始
std::cout << "left value" << std::endl; // 输出 left value
} // 左值版本函数体结束
void Handle(int&& value) // 定义接收右值引用的 Handle
{ // 右值版本函数体开始
std::cout << "right value" << std::endl; // 输出 right value
} // 右值版本函数体结束
template <typename T> // 声明模板参数 T
void Wrapper(T&& value) // 使用转发引用接收任意值类别的参数
{ // Wrapper 函数体开始
Handle(std::forward<T>(value)); // 使用完美转发保留原来的左值或右值属性
} // Wrapper 函数体结束
int main() // 程序入口函数
{ // main 函数体开始
int x = 10; // 定义一个 int 左值变量
Wrapper(x); // 传入左值,最终调用 Handle(int&)
Wrapper(20); // 传入右值,最终调用 Handle(int&&)
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束T&& 为什么能接左值又接右值?
在模板类型推导里,T&& 叫转发引用,也经常被叫 universal reference。
它不是普通右值引用。 普通右值引用只能接右值:
c
void Foo(int&& value) // 普通右值引用,只能接右值
{ // Foo 函数体开始
} // Foo 函数体结束而模板里的 T&& 会配合引用折叠:
c
template <typename T> // 声明模板参数 T
void Foo(T&& value) // 转发引用,可以根据传参推导出不同引用形式
{ // Foo 函数体开始
} // Foo 函数体结束如果传左值 x,T 会推导成 int&,最后折叠成 int&。 如果传右值 20,T 会推导成 int,最后是 int&&。
std::forward 和 std::move 区别
std::move 是无条件把对象转成右值。
c
Handle(std::move(value)); // 不管原来是不是左值,都强行按右值传std::forward<T> 是有条件转发。
c
Handle(std::forward<T>(value)); // 原来是左值就传左值,原来是右值就传右值所以完美转发里应该用 std::forward<T>,不是无脑用 std::move。
面试高分说法
NOTE
完美转发是模板编程里的参数转发技术,核心是 T&& 转发引用加 std::forward<T>。因为函数参数有名字后,在函数内部都会表现为左值,如果直接传给下一个函数,就会丢失调用者原来的右值语义。std::forward<T> 可以根据模板推导结果恢复原来的值类别:原来是左值就继续传左值,原来是右值就继续传右值。它常用于工厂函数、emplace、包装函数和回调转发。注意它和 std::move 不同,std::move 是无条件转右值,而 std::forward 是有条件转发。
emplace_back 和 push_back 区别是什么?
一句话理解
push_back 是把一个已经构造好的对象放进容器;emplace_back 是把构造参数交给容器,让容器在尾部位置直接构造对象。
push_back 怎么工作
c
#include <string> // 引入 string 类型
#include <vector> // 引入 vector 容器
class Player // 定义 Player 类
{ // Player 类体开始
public: // public 区域开始
Player(int id, const std::string& name) {} // 定义构造函数,接收 id 和 name
}; // Player 类体结束
int main() // 程序入口函数
{ // main 函数体开始
std::vector<Player> players; // 创建 Player 类型的 vector 容器
Player p(1, "Tom"); // 先构造一个 Player 对象 p
players.push_back(p); // 把 p 拷贝进 vector
players.push_back(std::move(p)); // 把 p 移动进 vector
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束push_back 有两个常见版本:
c
push_back(const T& value) // 接收左值,通常触发拷贝
push_back(T&& value) // 接收右值,通常触发移动所以 push_back(p) 是拷贝,push_back(std::move(p)) 才可能是移动。
emplace_back 怎么工作
c
#include <string> // 引入 string 类型
#include <vector> // 引入 vector 容器
class Player // 定义 Player 类
{ // Player 类体开始
public: // public 区域开始
Player(int id, const std::string& name) {} // 定义构造函数,接收 id 和 name
}; // Player 类体结束
int main() // 程序入口函数
{ // main 函数体开始
std::vector<Player> players; // 创建 Player 类型的 vector 容器
players.emplace_back(1, "Tom"); // 直接在 vector 尾部用参数构造 Player
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束这里没有先写:
c
Player p(1, "Tom"); // 先构造临时对象而是让 vector 在自己的尾部空间里直接调用 Player 的构造函数。
是不是 emplace_back 一定更快?
不一定。
如果你本来就是传构造参数:
c
players.emplace_back(1, "Tom"); // 可能少一次临时对象构造和移动它通常更合适。
但如果你已经有一个对象:
c
Player p(1, "Tom"); // 已经存在一个 Player 对象
players.emplace_back(p); // 这里仍然可能拷贝 p这种情况下,emplace_back 并不会神奇地消除拷贝。想移动已有对象还是要写:
c
players.emplace_back(std::move(p)); // 把 p 当成右值传入,可能调用移动构造vector 扩容时要注意
即使用 emplace_back,如果 vector 容量不够,也会重新分配一块更大的内存,然后把旧元素移动或拷贝过去。
所以:
c
players.emplace_back(1, "Tom"); // 只影响新插入的这个元素如何构造它不能避免 vector 扩容时旧元素移动。
如果知道数量,应该提前:
c
players.reserve(100); // 提前预留容量,减少扩容次数什么时候用哪个?
如果已经有对象,并且表达“把这个对象放进去”:
c
players.push_back(p); // 把已有对象 p 放进容器,语义清楚如果要在容器里直接构造对象:
c
players.emplace_back(1, "Tom"); // 用构造参数直接创建容器元素,语义清楚面试高分说法
IMPORTANT
push_back 接收的是一个已经构造好的 T 对象,然后把它拷贝或移动到容器尾部;emplace_back 接收的是构造 T 所需的参数,通过完美转发在容器内部直接构造元素,所以在避免临时对象方面可能更高效。但 emplace_back 不一定永远比 push_back 快,如果传入的本来就是已有对象,它仍然可能发生拷贝或移动。另外 vector 扩容时,旧元素仍然可能被移动或拷贝,emplace_back 只影响新插入元素的构造方式。现代 C++ 中,构造参数用 emplace_back,已有对象用 push_back,语义最清楚。
lambda 捕获方式有哪些?
一句话理解
C++ lambda 捕获方式就是写在 [] 里的东西,用来决定 lambda 能不能访问外部局部变量,以及是复制一份进去,还是引用外面的变量。
常见捕获方式
[]:不捕获任何外部局部变量。 [x]:按值捕获 x,lambda 内部保存一份副本。 [&x]:按引用捕获 x,lambda 内部使用外面的原变量。 [=]:默认按值捕获用到的外部变量。 [&]:默认按引用捕获用到的外部变量。 [=, &x]:默认按值捕获,但 x 按引用捕获。 [&, x]:默认按引用捕获,但 x 按值捕获。 [this]:捕获当前对象的 this 指针。 [*this]:捕获当前对象的一份副本,C++17 引入。 [p = std::move(ptr)]:初始化捕获,可以改名,也可以把资源移动进 lambda。
值捕获:创建 lambda 时复制一份
c
#include <iostream> // 引入输入输出库
int main() // 程序入口函数
{ // main 函数体开始
int x = 10; // 定义外部局部变量 x
auto func = [x]() // 按值捕获 x,lambda 内部保存 x 的副本
{ // lambda 函数体开始
std::cout << x << std::endl; // 输出 lambda 内部保存的 x 副本
}; // lambda 表达式结束
x = 20; // 修改外部 x,不影响 lambda 内部副本
func(); // 调用 lambda,输出 10
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束重点:[x] 捕获的是创建 lambda 那一刻的值。
引用捕获:使用外部原变量
c
#include <iostream> // 引入输入输出库
int main() // 程序入口函数
{ // main 函数体开始
int x = 10; // 定义外部局部变量 x
auto func = [&x]() // 按引用捕获 x,lambda 内部使用外面的 x
{ // lambda 函数体开始
std::cout << x << std::endl; // 输出外部变量 x 的当前值
}; // lambda 表达式结束
x = 20; // 修改外部 x,lambda 看到的值也会变化
func(); // 调用 lambda,输出 20
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束引用捕获性能好,也能修改外部变量,但最怕外部变量已经销毁,lambda 后面还在用。
按值捕获默认不能改副本
c
#include <iostream> // 引入输入输出库
int main() // 程序入口函数
{ // main 函数体开始
int x = 10; // 定义外部局部变量 x
auto func = [x]() mutable // 按值捕获 x,并允许修改 lambda 内部副本
{ // lambda 函数体开始
x++; // 修改的是 lambda 内部副本,不是外部 x
std::cout << x << std::endl; // 输出内部副本的值
}; // lambda 表达式结束
func(); // 调用 lambda,输出 11
std::cout << x << std::endl; // 输出外部 x,仍然是 10
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束mutable 的意思是允许修改按值捕获进来的副本,不是修改外面的变量。
初始化捕获:移动资源进 lambda
c
#include <iostream> // 引入输入输出库
#include <memory> // 引入智能指针
#include <utility> // 引入 std::move
int main() // 程序入口函数
{ // main 函数体开始
auto ptr = std::make_unique<int>(100); // 创建一个 unique_ptr,独占一个 int 资源
auto func = [p = std::move(ptr)]() // 使用初始化捕获,把 ptr 移动进 lambda,名字改为 p
{ // lambda 函数体开始
std::cout << *p << std::endl; // 输出 lambda 内部持有的资源值
}; // lambda 表达式结束
func(); // 调用 lambda,输出 100
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束这个在异步任务、回调、资源延迟释放里很常见。
最容易踩的坑
不要无脑 [&]。如果 lambda 被保存起来、异步执行、放进线程、注册成回调,引用捕获的局部变量可能已经销毁,后面调用就是悬空引用。
不要无脑 [=]。它可能悄悄拷贝大对象,也可能让你以为改的是外部变量,其实改不到。
类成员函数里要小心 [this]。捕获的是指针,如果对象已经销毁,lambda 还调用成员,就会出野指针问题。
面试高分说法
TIP
lambda 捕获列表决定外部变量如何进入 lambda。[x] 是值捕获,保存创建 lambda 时的一份副本;[&x] 是引用捕获,访问外部原变量。[=] 和 [&] 是默认捕获,方便但容易隐藏拷贝或生命周期风险。[this] 捕获当前对象指针,异步回调里要特别小心对象生命周期;C++17 的 [*this] 可以捕获对象副本。C++14 的初始化捕获可以写 [p = std::move(ptr)],常用于把独占资源移动进 lambda。工程上建议显式捕获需要的变量,异步场景尽量避免引用捕获。
std::function 有什么代价?
一句话理解
std::function 的作用是统一保存各种“可调用对象”,比如普通函数、lambda、函数对象。它好用的代价是:类型擦除、间接调用、可能堆分配、难以内联、拷贝成本更高。
std::function 为什么有代价?
因为它要做到这件事:
c
std::function<void()> func; // func 可以保存任何签名为 void() 的可调用对象它既能保存普通函数:
c
void Hello() // 定义一个普通函数
{ // 函数体开始
} // 函数体结束也能保存 lambda:
c
auto lambda = []() // 定义一个 lambda 表达式
{ // lambda 函数体开始
}; // lambda 表达式结束还可以保存函数对象:
c
struct Task // 定义一个函数对象类型
{ // 结构体开始
void operator()() const // 重载函数调用运算符
{ // 函数体开始
} // 函数体结束
}; // 结构体结束这些类型完全不同,但 std::function<void()> 都能装进去。 这背后靠的是类型擦除。
代价 1:间接调用
普通函数调用可能是直接调用:
c
Hello(); // 直接调用 Hello 函数std::function 调用通常要经过包装层:
c
std::function<void()> func = Hello; // 把普通函数保存进 std::function
func(); // 通过 std::function 的 operator() 间接调用目标函数这会比直接调用多一层间接跳转。 在普通业务代码里通常没事,但在游戏每帧大量调用、AI 更新、粒子更新这种热路径里要注意。
代价 2:难以内联
如果你直接调用 lambda:
c
auto add = [](int a, int b) // 定义一个 lambda
{ // lambda 函数体开始
return a + b; // 返回两个整数的和
}; // lambda 表达式结束
int result = add(1, 2); // 直接调用 lambda,编译器更容易内联但如果放进 std::function:
c
std::function<int(int, int)> add = [](int a, int b) // 把 lambda 包装进 std::function
{ // lambda 函数体开始
return a + b; // 返回两个整数的和
}; // lambda 表达式结束
int result = add(1, 2); // 通过 std::function 调用,编译器更难内联因为具体类型被擦除了,编译器不一定能看清最终调用目标。
代价 3:可能堆分配
小 lambda 可能会被 std::function 放在内部小缓冲区里,这叫小对象优化。
c
std::function<void()> func = []() // 没有捕获的 lambda 通常很小
{ // lambda 函数体开始
}; // lambda 表达式结束但捕获对象很大时,std::function 可能需要在堆上分配内存:
c
#include <array> // 引入 array 容器
#include <functional> // 引入 std::function
int main() // 程序入口函数
{ // main 函数体开始
std::array<int, 1024> data{}; // 创建一个较大的数组对象
std::function<void()> func = [data]() // 按值捕获大对象,std::function 可能产生堆分配
{ // lambda 函数体开始
}; // lambda 表达式结束
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束所以在性能敏感场景里,要避免捕获大对象,或者用引用、指针、模板等方式替代。
代价 4:拷贝 std::function 可能很贵
c
#include <functional> // 引入 std::function
#include <string> // 引入 string 类型
int main() // 程序入口函数
{ // main 函数体开始
std::string name = "Player"; // 创建字符串对象
std::function<void()> a = [name]() // 按值捕获 name,lambda 内部保存一份 string
{ // lambda 函数体开始
}; // lambda 表达式结束
std::function<void()> b = a; // 拷贝 std::function,也可能拷贝内部 lambda 和它捕获的 string
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束如果 lambda 捕获了很多数据,复制 std::function 就可能不是轻量操作。
代价 5:通常要求目标可拷贝
std::function 一般要求内部可调用对象可复制。 如果 lambda 捕获了 unique_ptr,它就是只可移动、不可拷贝的,放进 std::function 会有问题。
c
#include <functional> // 引入 std::function
#include <memory> // 引入智能指针
int main() // 程序入口函数
{ // main 函数体开始
auto ptr = std::make_unique<int>(10); // 创建 unique_ptr,独占 int 资源
auto task = [p = std::move(ptr)]() // 把 unique_ptr 移动进 lambda,lambda 变成只可移动
{ // lambda 函数体开始
}; // lambda 表达式结束
// std::function<void()> func = task; // 错误,task 不可拷贝,std::function 通常装不了
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束只可移动回调可以考虑 C++23 的 std::move_only_function,或者自己设计移动语义回调封装。
什么时候适合用 std::function?
适合做:
回调系统;
事件系统;
任务队列;
延迟执行;
接口层保存任意可调用对象;
不追求极致性能的业务逻辑。
什么时候要谨慎?
每帧高频调用;
大量对象 Update;
AI 热循环;
粒子、物理、寻路热路径;
频繁创建、拷贝、销毁回调对象。
这类地方可以考虑模板参数、函数指针、成员函数指针、轻量 function_ref 思路,或者直接用具体类型。
面试高分说法
IMPORTANT
std::function 是一个通用可调用对象包装器,底层核心是类型擦除。它的好处是接口统一,可以保存函数指针、lambda、函数对象;代价是调用时通常有间接调用开销,具体目标类型被擦除后不容易内联,捕获对象较大时可能产生堆分配,复制 std::function 时还可能复制内部可调用对象。另外 std::function 通常要求目标可拷贝,所以只可移动的 lambda 不适合直接放进去。工程上我会在回调、事件、任务队列这种需要灵活性的地方用它,但在高频热路径上会谨慎使用。
optional、variant、any 分别是什么?
一句话理解
optional 表示可能有值,也可能没有值;variant 表示几个固定类型里选一个;any 表示可以装任意类型,但取出来时要自己知道真实类型。
std::optional
它用来表达“结果可能不存在”。
c
#include <iostream> // 引入输入输出库
#include <optional> // 引入 std::optional
std::optional<int> FindScore(bool found) // 定义函数,返回可能存在的 int
{ // 函数体开始
if (found) // 判断是否找到了分数
{ // if 分支开始
return 100; // 找到了就返回一个有效值
} // if 分支结束
return std::nullopt; // 没找到就返回空值
} // 函数体结束
int main() // 程序入口函数
{ // main 函数体开始
auto score = FindScore(true); // 调用函数,得到一个 optional<int>
if (score.has_value()) // 判断 optional 里面是否有值
{ // if 分支开始
std::cout << score.value() << std::endl; // 取出里面的 int 并输出
} // if 分支结束
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束它比返回 -1、nullptr、特殊错误码更清楚。 适合查找、解析、配置读取、可选字段。
std::variant
它表示“固定几种类型中的一种”,类型集合在编译期就确定。
c
#include <iostream> // 引入输入输出库
#include <string> // 引入 std::string
#include <variant> // 引入 std::variant
int main() // 程序入口函数
{ // main 函数体开始
std::variant<int, std::string> value; // 定义一个 variant,可以保存 int 或 string
value = 10; // 当前保存的是 int
value = std::string("hello"); // 当前保存的是 string
if (std::holds_alternative<std::string>(value)) // 判断当前是否保存 string
{ // if 分支开始
std::cout << std::get<std::string>(value) << std::endl; // 取出 string 并输出
} // if 分支结束
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束适合状态、消息、事件、表达式节点,比如:
c
std::variant<int, float, std::string> configValue; // 一个配置值可能是 int、float 或 string它比 void* 安全,因为类型范围是明确的。
std::any
它是一个“任意类型盒子”。
c
#include <any> // 引入 std::any
#include <iostream> // 引入输入输出库
#include <string> // 引入 std::string
int main() // 程序入口函数
{ // main 函数体开始
std::any value; // 定义一个 any,里面暂时没有值
value = 10; // any 当前保存 int
value = std::string("hello"); // any 当前保存 string
if (value.type() == typeid(std::string)) // 判断 any 里当前保存的是否是 string
{ // if 分支开始
std::cout << std::any_cast<std::string>(value) << std::endl; // 按 string 类型取出值并输出
} // if 分支结束
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束它最灵活,但也最容易滥用。 因为你取值时必须知道真实类型,否则 any_cast 可能失败。
三者怎么选?
如果你想表达“可能没有结果”,用 optional。
如果你知道结果只可能是固定几种类型之一,用 variant。
如果你真的不知道类型是什么,或者在做插件系统、脚本绑定、通用属性表,再考虑 any。
代价区别
optional<T> 通常保存一块 T 的存储空间,再加一个是否有值的标记。
variant<A, B, C> 通常需要能容纳最大候选类型的空间,再加一个 index 记录当前是哪种类型。
any 使用类型擦除,灵活性最高,但可能有堆分配、运行时类型检查和 any_cast 成本,可读性也最差。
面试高分说法
TIP
std::optional、std::variant、std::any 都是 C++17 引入的表达型工具。optional<T> 表示一个值可能存在也可能不存在,适合替代魔法值和部分空指针返回;variant<A, B> 是类型安全的联合体,表示固定类型集合中的一个,适合状态、消息和配置值;any 是类型擦除容器,可以保存任意可复制类型,但取值时要用 any_cast,运行时成本和类型风险更高。工程上优先用语义最具体的类型:能用 optional 不用 any,类型集合明确就用 variant,只有真正需要开放类型时才用 any。
结构化绑定是什么?
一句话理解
结构化绑定是 C++17 的语法,用来把一个复合对象里的多个值,直接拆成多个变量名。
基本写法
c
auto [a, b] = object; // 把 object 里的两个部分拆出来,分别命名为 a 和 b它常用于 pair、tuple、数组、简单结构体、map 遍历。
拆 pair
c
#include <iostream> // 引入输入输出库
#include <string> // 引入 string 类型
#include <utility> // 引入 std::pair
int main() // 程序入口函数
{ // main 函数体开始
std::pair<int, std::string> player = {1, "Tom"}; // 创建一个 pair,保存 id 和名字
auto [id, name] = player; // 结构化绑定,把 first 拆成 id,把 second 拆成 name
std::cout << id << " " << name << std::endl; // 输出拆出来的 id 和 name
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束以前可能写:
c
int id = player.first; // 从 pair 的 first 中取出 id
std::string name = player.second; // 从 pair 的 second 中取出 name现在可以写得更清楚:
c
auto [id, name] = player; // 一次性把 pair 的两个值拆出来拆 tuple
c
#include <iostream> // 引入输入输出库
#include <tuple> // 引入 std::tuple
int main() // 程序入口函数
{ // main 函数体开始
std::tuple<int, float, bool> data = {100, 3.5f, true}; // 创建 tuple,保存三个不同类型的值
auto [hp, speed, alive] = data; // 把 tuple 中三个值分别拆成 hp、speed、alive
std::cout << hp << std::endl; // 输出 hp
std::cout << speed << std::endl; // 输出 speed
std::cout << alive << std::endl; // 输出 alive
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束变量数量必须匹配,三个元素就要拆成三个名字。
拆结构体
c
#include <iostream> // 引入输入输出库
struct PlayerData // 定义一个简单结构体
{ // 结构体开始
int hp; // 生命值字段
float speed; // 速度字段
}; // 结构体结束
int main() // 程序入口函数
{ // main 函数体开始
PlayerData data{100, 5.5f}; // 创建 PlayerData 对象
auto [hp, speed] = data; // 把结构体中的 hp 和 speed 拆出来
std::cout << hp << std::endl; // 输出 hp
std::cout << speed << std::endl; // 输出 speed
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束一般适合拆简单的公开数据成员。私有成员不能随便拆。
最重要的坑:auto 是拷贝
c
#include <iostream> // 引入输入输出库
struct PlayerData // 定义 PlayerData 结构体
{ // 结构体开始
int hp; // 生命值字段
float speed; // 速度字段
}; // 结构体结束
int main() // 程序入口函数
{ // main 函数体开始
PlayerData data{100, 5.5f}; // 创建 PlayerData 对象
auto [hp, speed] = data; // 拷贝 data,再把副本中的成员绑定给 hp 和 speed
hp = 1; // 修改的是拆出来的副本值,不会修改原来的 data.hp
std::cout << data.hp << std::endl; // 输出 100,说明原对象没有变
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束如果要修改原对象,要用引用:
c
#include <iostream> // 引入输入输出库
struct PlayerData // 定义 PlayerData 结构体
{ // 结构体开始
int hp; // 生命值字段
float speed; // 速度字段
}; // 结构体结束
int main() // 程序入口函数
{ // main 函数体开始
PlayerData data{100, 5.5f}; // 创建 PlayerData 对象
auto& [hp, speed] = data; // 使用引用结构化绑定,hp 和 speed 绑定到原对象成员
hp = 1; // 修改原对象的 hp
std::cout << data.hp << std::endl; // 输出 1,说明原对象被修改
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束map 遍历中特别常用
c
#include <iostream> // 引入输入输出库
#include <map> // 引入 std::map
#include <string> // 引入 std::string
int main() // 程序入口函数
{ // main 函数体开始
std::map<int, std::string> players; // 创建 map,key 是玩家 id,value 是玩家名
players[1] = "Tom"; // 插入一个玩家数据
players[2] = "Jerry"; // 插入另一个玩家数据
for (const auto& [id, name] : players) // 用只读引用结构化绑定遍历 map,避免拷贝键值对
{ // for 循环体开始
std::cout << id << " " << name << std::endl; // 输出当前玩家 id 和名字
} // for 循环体结束
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束注意这里推荐写 const auto&,因为:
c
for (auto [id, name] : players) // 会拷贝 map 中的 key 和 value如果 value 很大,会有额外开销。
底层理解
结构化绑定不是把对象“切开”。编译器大概会生成一个隐藏变量,然后让你写的名字绑定到隐藏变量的各个部分。
所以:
c
auto [a, b] = obj; // 通常会先复制 obj,再绑定到副本的成员而:
c
auto& [a, b] = obj; // 绑定到原对象,所以可以修改原对象面试高分说法
NOTE
结构化绑定是 C++17 引入的语法,用来把 pair、tuple、数组和部分结构体中的成员拆成多个变量名。它能减少 first、second、std::get 这类样板代码,让返回多个值和遍历 map 更清楚。使用时要注意 auto [a, b] 通常会产生拷贝,如果只读大对象要用 const auto& [a, b],如果要修改原对象要用 auto& [a, b]。它本质上是编译器帮我们创建隐藏变量,并把名字绑定到对应元素上。