0.1 C++11
0.1.1 自动类型推导
让编译器在编译期帮你推断类型,而不是你手写类型。真正难点在于:推导规则。
核心工具:
- auto
- decltype 精确类型
- decltype(auto) 完美转发
0.1.1.1 auto
基本规则
c++
auto x = 10; // int
auto y = 3.14; // double
根据初始化表达式推导类型 。
关键规则:
- 忽略引用
c++
int a = 10;
int& ref = a;
auto x = ref; // int 而不是 int&
- 忽略顶层 const
c++
const int a = 10;
auto x = a; // int(const 丢了)
- 想保留需要手动加
c++
const int a = 10;
const auto x = a; // const int
auto& y = a; // const int&
需要注意:
c++
int& func();
auto x = func(); // int(引用丢了)
auto& y = func(); // int&
// 给出以下示例
#include <iostream>
using namespace std;
int a = 10;
int& func() {
return a;
}
int main()
{
auto x = func();
auto& y = func();
int *p = &a;
cout << p << endl;
cout << &x << endl;
cout << &y << endl;
return 0;
}
// 程序执行结果如下:
// 0x1027d0000
// 0x16d636ab8
// 0x1027d0000
0.1.1.2 decltype 获取精确类型
c++
int a = 10;
decltype(a) x = 20; // int
auto 推导来源是初始化值,decltype 推导来源为表达式,与 auto 不同,decltype 能够保留引用和 const。
关键规则:
c++
int a = 10;
decltype(a) x = a; // int
decltype((a)) y = a; // int&
括号() 在 C++ 中是运算符,作用是产生一个表达式 。因此编译器不再看声明,而是分析表达式的值类别 。每个表达式都有两个属性:类型 + 值类别,值类别分为左值和右值:
- lvalue 有名字、有地址、可以放在赋值表达式左边的
- rvalue 临时的、没有名字、只能放在赋值表达式右边的
(a) 这个表达式: a 是一个有名字、有地址的变量,(a) 求值之后仍然指向那块内存,所以是 lvalue。
decltype 对表达式的规则:
- 表达式是 lvalue → 结果加 & → T&
- 表达式是 rvalue → 结果不变 → T
类型推导在使用 STL 容器时很好用,通过推导类型避免写一长串 iterator 类类型。
0.1.1.3 decltype(auto)
c++
int& func();
auto a = func(); // int
decltype(auto) b = func(); // int&
decltype(auto) = 不会丢失引用,这在模板中非常重要,比如:
c++
template<typename T>
decltype(auto) wrapper(T&& t) {
return t;
}
这样可以保证返回值类型完全一致。
0.1.1.4 总结
- auto 会丢失引用和 const
- auto& 强制引用
- decltype 完全精确
- decltype(auto) 自动+完全精确
推荐使用:
c++
auto it = vec.begin(); // 简化类型
for (auto& x : vec) // 避免拷贝
需要警惕:
c++
auto x = func(); // 可能偷偷拷贝(性能问题)
模板/泛型才用:
c++
decltype(auto)
0.1.2 智能指针
先说一下智能指针的核心目的:让堆内存的生命周期自动管理,不需要手动 delete。
0.1.2.1 为什么需要智能指针?
裸指针存在的问题:异常、提前返回、多个出口,任何一个地方漏掉 delete 就会导致泄露。
智能指针通过 RAII 解决这个问题:对象离开作用域时,析构函数自动释放内存。
0.1.2.2 unique_ptr 独占所有权
一块内存只有一个主人,主人销毁时,内存跟着销毁。
c++
#include <memory>
std::unique_ptr<int> p = std::make_unique<int>(42);
// 离开作用域自动 delete,不需要手动释放
不能拷贝,只能移动
c++
auto p1 = std::make_unique<int>(42);
auto p2 = p1; // ❌ 编译错误,不允许拷贝
auto p2 = std::move(p1); // ✅ 转移所有权,p1 变成 nullptr
移动之后 p1 就失效了,整个体系中,始终只有一个指针拥有这块内存。
常用操作:
c++
auto p = std::make_unique<int>(42);
*p // 解引用,得到 42
p.get() // 获取裸指针,但不转移所有权
p.release() // 放弃所有权,返回裸指针,p 变 nullptr(需要自己 delete)
p.reset() // 释放当前内存,p 变 nullptr
p.reset(new int(99)) // 释放旧的,接管新的
作为函数参数:
c++
// 转移所有权进去,调用方失去控制权
void take_ownership(std::unique_ptr<int> p) { ... }
// 只是借用,不转移
void borrow(int* p) { ... }
void borrow(const std::unique_ptr<int>& p) { ... }
管理数组:
c++
auto arr = std::make_unique<int[]>(10);
arr[0] = 42; // 支持下标访问
// 离开作用域自动调用 delete[]
0.1.2.3 shared_ptr 共享所有权
基本概念 :多个指针可以共同拥有一块内存,内部用引用计数追踪有多少人拥有它,计数归零时才释放。
c++
auto p1 = std::make_shared<int>(42); // 引用计数 = 1
auto p2 = p1; // 引用计数 = 2
auto p3 = p1; // 引用计数 = 3
p1.reset(); // 引用计数 = 2,内存还在
p2.reset(); // 引用计数 = 1,内存还在
p3.reset(); // 引用计数 = 0,内存释放
内部结构
shared_ptr 内部有两块东西:
c++
shared_ptr 对象
├── 指向堆对象的指针 → [ int: 42 ]
└── 指向控制块的指针 → [ use_count: 3 | weak_count: 0 | deleter ]
控制块是单独分配的一块内存,所有指向同一对象的 shared_ptr 共享同一个控制块。
make_shared 和 new 的区别
c++
// 方式一:两次内存分配(对象一次,控制块一次)
std::shared_ptr<int> p1(new int(42));
// 方式二:一次内存分配(对象和控制块在一起)
auto p2 = std::make_shared<int>(42); // 推荐
make_shared 更高效,且异常安全,优先使用。
引用计数的代价
引用计数的增减是原子操作(线程安全),但这不意味着 shared_ptr 本身是线程安全的:
c++
// 引用计数的修改是安全的
// 但同一个 shared_ptr 对象被多线程同时读写仍然不安全
auto p = std::make_shared<int>(42);
// 多线程同时拷贝 p → 安全(原子操作保护计数)
// 多线程同时对 p 赋值 → 不安全(p 本身不是原子的)
0.1.2.4 weak_ptr 弱引用,不参与计数
为什么需要它
shared_ptr 的循环引用会导致内存永远无法释放:
c++
struct Node {
std::shared_ptr<Node> next;
std::shared_ptr<Node> prev; // 双向链表
};
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b; // b 的引用计数 = 2
b->prev = a; // a 的引用计数 = 2
// a、b 离开作用域,引用计数从 2 降到 1,永远不会到 0
// 内存泄漏!
weak_ptr 打破循环
c++
struct Node {
std::shared_ptr<Node> next;
std::weak_ptr<Node> prev; // 弱引用,不增加计数
};
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b; // b 的引用计数 = 2
b->prev = a; // a 的引用计数仍然 = 1(weak_ptr 不计数)
// 离开作用域,a 引用计数降为 0,a 释放
// a 释放导致 a->next 释放,b 引用计数降为 0,b 释放
使用 weak_ptr
weak_ptr 不能直接解引用,必须先提升成 shared_ptr,提升时检查对象是否还活着:
c++
auto sp = std::make_shared<int>(42);
std::weak_ptr<int> wp = sp;
// 方式一:lock(),对象已销毁则返回空 shared_ptr
if (auto locked = wp.lock()) {
std::cout << *locked; // 安全访问
} else {
// 对象已经销毁
}
// 方式二:expired(),检查是否已销毁
if (!wp.expired()) {
// 注意:这里和 lock() 之间可能有竞争,生产中用 lock()
}
三种智能指针对比
| unique_ptr | shared_ptr | weak_ptr | |
|---|---|---|---|
| 所有权 | 独占 | 共享 | 无 |
| 引用计数 | 无 | 有 | 不增加 |
| 可拷贝 | ❌ | ✅ | ✅ |
| 可移动 | ✅ | ✅ | ✅ |
| 开销 | 几乎零 | 原子操作+控制块 | 同shared_ptr |
| 直接解引用 | ✅ | ✅ | ❌需要lock() |
| 适用场景 | 单一所有者 | 共享所有者 | 打破循环引用/观察者 |
0.1.2.5 常见陷阱
- 陷阱1:裸指针构造多个
shared_ptr
c++
int* raw = new int(42);
auto p1 = std::shared_ptr<int>(raw);
auto p2 = std::shared_ptr<int>(raw); // ❌ 两个独立控制块!
// p1 和 p2 各自认为自己是唯一拥有者
// 任何一个析构时就会 delete raw
// 另一个析构时再次 delete → double free,崩溃
解决:永远用 make_shared,或者只从一个 shared_ptr 拷贝。
- 陷阱2:在类内部获取自身的
shared_ptr
c++
struct Foo {
void bad() {
// this 是裸指针,这里又创建了一个独立的 shared_ptr
auto p = std::shared_ptr<Foo>(this); // ❌ 危险
}
};
正确的做法是继承 enable_shared_from_this
c++
struct Foo : std::enable_shared_from_this<Foo> {
void good() {
auto p = shared_from_this(); // ✅ 从已有控制块获取
}
};
auto foo = std::make_shared<Foo>();
foo->good(); // 安全
- 陷阱3:
unique_ptrmove 后继续使用
c++
auto p1 = std::make_unique<int>(42);
auto p2 = std::move(p1);
*p1; // ❌ p1 已经是 nullptr,未定义行为
- 陷阱4:
shared_ptr管理this导致提前释放
c++
void Foo::register_self() {
// 如果外部没有 shared_ptr 持有这个对象
// shared_from_this() 会抛异常
auto p = shared_from_this();
}
Foo foo; // 栈上对象
foo.register_self(); // ❌ 崩溃,栈对象没有控制块
0.1.3 lambda 表达式
0.1.3.1 lambda 是什么
lambda 就是一个 可以就地定义的匿名函数 ,本质上编译器会把它翻译成一个类(重载了 operator() 的函数对象)。
c++
// 普通函数
int add(int a, int b) { return a + b; }
// 等价的 lambda
auto add = [](int a, int b) { return a + b; };
add(1, 2); // 调用方式完全一样,结果 3
0.1.3.2 完整语法结构
c++
[捕获列表] (参数列表) 说明符 -> 返回类型 { 函数体 }
① ② ③ ④ ⑤
每个部分都可以省略,最简形式:
c++
auto f = [] { return 42; }; // 没有参数,没有捕获,返回类型自动推导
0.1.3.3 最核心概念------捕获列表
捕获列表决定 lambda 能访问外部哪些变量,以及怎么访问。
[]什么都不捕获
c++
int x = 10;
auto f = [] { return x; }; // ❌ 编译错误,x 没有被捕获
[x]值捕获------拷贝一份进来
c++
int x = 10;
auto f = [x] { return x; };
x = 99; // 修改外部 x
f(); // 返回 10,lambda 内部是捕获时的副本,不受影响
捕获的副本默认是 const 的,不能在 lambda 内部修改:
c++
int x = 10;
auto f = [x] {
x = 99; // ❌ 编译错误,值捕获默认是 const
};
[&x]引用捕获------绑定原变量
c++
int x = 10;
auto f = [&x] { return x; };
x = 99;
f(); // 返回 99,引用捕获,x 怎么变 lambda 就看到什么
引用捕获可以在 lambda 内部修改原变量:
c++
int x = 10;
auto f = [&x] { x = 99; };
f();
// x 现在是 99
[=]隐式值捕获所有变量
c++
int x = 10, y = 20;
auto f = [=] { return x + y; }; // x、y 都被值捕获
[&]隐式引用捕获所有变量
c++
int x = 10, y = 20;
auto f = [&] { x = 99; y = 88; }; // x、y 都被引用捕获
f();
// x == 99, y == 88
- 混合捕获
c++
int x = 10, y = 20, z = 30;
// 默认值捕获,但 y 用引用
auto f = [=, &y] { y = 99; return x + y + z; };
// 默认引用捕获,但 x 用值
auto g = [&, x] { y = 99; return x; };
如果需要捕获多个,依次用逗号隔开即可。
- this 相关
c++
[this] // 捕获当前对象的指针
[*this] // 捕获当前对象的副本(C++ 17)
0.1.3.4 mutable------让值捕获可以修改
值捕获的副本默认是 const,加 mutable 去掉这个限制:
c++
int x = 10;
auto f = [x]() mutable {
x = 99; // ✅ 修改的是副本,不影响外部 x
return x;
};
f(); // 返回 99
// 外部 x 仍然是 10
注意:修改的是 lambda 内部自己的副本,外部变量不受影响。
0.1.3.5 返回类型
- 自动推导,大多情况都够用
c++
auto f = [](int a, int b) { return a + b; }; // 推导为 int
- 多个 return 语句时需要明确指定
c++
// ❌ 两个 return 类型不同,推导失败
auto f = [](bool flag) {
if (flag) return 1;
return 1.0;
};
// ✅ 明确指定返回类型
auto f = [](bool flag) -> double {
if (flag) return 1;
return 1.0;
};
0.1.3.6 lambda 的类型与存储
lambda 的类型是唯一的匿名类型
每个 lambda 都有自己独一无二的类型,不能直接写出来,只能使用 auto:
c++
auto f = [] { return 42; };
// f 的类型类似于编译器生成的:
// struct __lambda_xyz {
// int operator()() const { return 42; }
// };
用 std::function 存储(有开销)
需要统一存储不同 lambda 时用 std::function:
c++
#include <functional>
std::function<int(int, int)> op;
op = [](int a, int b) { return a + b; };
op(1, 2); // 3
op = [](int a, int b) { return a * b; };
op(3, 4); // 12
无捕获的 lambda 可以转化为函数指针
c++
auto f = [](int a, int b) { return a + b; };
// 没有捕获,可以退化为函数指针
int (*fp)(int, int) = f;
fp(1, 2); // 3
// 有捕获的 lambda 不能转函数指针
int x = 10;
auto g = [x](int a) { return a + x; };
int (*gp)(int) = g; // ❌ 编译错误
0.1.3.7 实际使用场景
场景一:配合 STL 算法
c++
std::vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6};
// 排序
std::sort(v.begin(), v.end(), [](int a, int b) {
return a > b; // 降序
});
// 查找
auto it = std::find_if(v.begin(), v.end(), [](int x) {
return x > 4;
});
// 遍历
std::for_each(v.begin(), v.end(), [](int x) {
std::cout << x << " ";
});
场景二:回调函数
c++
void do_something(int x, std::function<void(int)> callback) {
// 做一些事
callback(x * 2);
}
int result = 0;
do_something(21, [&result](int val) {
result = val; // 引用捕获,修改外部变量
});
// result == 42
场景三:延迟执行/任务封装
c++
std::vector<std::function<void()>> tasks;
for (int i = 0; i < 5; i++) {
tasks.push_back([i] { // 值捕获 i
std::cout << "task " << i << "\n";
});
}
for (auto& task : tasks) task();
// 输出 task 0 ~ task 4
场景四:就地执行(IIFE)
c++
// 定义完立刻调用,常用于复杂初始化
const int result = [&] {
if (condition_a) return 1;
if (condition_b) return 2;
return 0;
}();
0.1.3.8 C++14/17/20的扩展
- C++14:泛型 lambda (参数用 auto)
c++
auto print = [](auto x) {
std::cout << x << "\n";
};
print(42); // int
print(3.14); // double
print("hello"); // const char*
- C++14:捕获时初始化(广义捕获)
c++
// 捕获一个移动语义的对象(unique_ptr 不能拷贝)
auto p = std::make_unique<int>(42);
auto f = [ptr = std::move(p)] {
return *ptr;
};
// p 已经是 nullptr,所有权转移到 lambda 内部
// 捕获时计算新值
int x = 10;
auto g = [y = x * 2] { return y; }; // y == 20
- C++17:
[*this]捕获对象副本
c++
struct Foo {
int value = 42;
auto get_lambda() {
return [*this] { return value; }; // 捕获整个对象的副本
// 即使 Foo 对象销毁,lambda 仍然安全
}
};
- C++20:模板 lambda
c++
// C++14 泛型 lambda 无法约束类型
// C++20 可以用模板参数
auto f = []<typename T>(T a, T b) {
return a + b;
};
f(1, 2); // ✅ 同类型 int
f(1, 2.0); // ❌ 类型不同,编译错误(而不是隐式转换)
0.1.3.9 常见陷阱
- 陷阱1:引用捕获的生命周期问题
c++
std::function<int()> make_lambda() {
int x = 42;
return [&x] { return x; }; // ❌ x 是局部变量,函数返回后销毁
}
auto f = make_lambda();
f(); // 未定义行为,x 已经不存在了
解决:返回值要用值捕获或者确保引用对象的生命周期够长
- 陷阱2:for 循环中引用捕获循环变量
c++
std::vector<std::function<void()>> tasks;
for (int i = 0; i < 3; i++) {
tasks.push_back([&i] { // ❌ 引用捕获,i 最终是 3
std::cout << i << "\n";
});
}
for (auto& t : tasks) t();
// 在栈内存还没有被覆盖的情况下,输出三行 3,不是 0 1 2
// 由于循环变量 i 的生命周期在 for 循环结束就结束了,后续这块栈内存,没有被覆盖,这里的内容就还是 3,如果被覆盖了,就是其他值了
解决:使用值捕获。
- 陷阱3:在类成员中捕获 this
c++
struct Foo {
int value = 42;
auto get_lambda() {
return [this] { return value; };
// 实际上捕获的是 this 指针
// 如果 Foo 对象销毁,lambda 仍然访问 value 就是悬空指针
}
};
解决:生命周期不确定时,使用[*this](c++17)复制整个对象
- 陷阱4:值捕获是创建时拷贝
c++
int a = 10;
auto f = [a]() {
return a;
};
a = 20;
f(); // 还是 10
0.1.4 右值引用 + move
0.1.4.1 左值和右值
左值(lvalue):有名字、有地址、能放在赋值表达式左边的
c++
int a = 10;
a = 20; // a 是左值,可以放左边
int* p = &a; // 可以取地址
右值(rvalue):临时的、没有名字的、用完就消失的
c++
10; // 字面量,右值
a + 1; // 表达式结果,临时值,右值
func(); // 函数返回的临时对象(返回非引用时),右值
判断口诀:能不能取地址。能取地址的就是左值,不能就是右值。
c++
&a; // ✅ 能取地址,左值
&10; // ❌ 不能取地址,右值
&(a + 1); // ❌ 不能取地址,右值
0.1.4.2 没有右值引用之前的问题
用一个 String 类演示:
c++
class String {
public:
char* data;
size_t size;
// 构造函数
String(const char* str) {
size = strlen(str);
data = new char[size + 1];
memcpy(data, str, size + 1);
std::cout << "构造: " << data << "\n";
}
// 拷贝构造函数
String(const String& other) {
size = other.size;
data = new char[size + 1];
memcpy(data, other.data, size + 1); // 深拷贝,重新分配内存
std::cout << "拷贝构造: " << data << "\n";
}
~String() { delete[] data; }
};
现在执行这段代码:
c++
String make_string() {
String s("hello");
return s; // 返回一个临时对象
}
String a = make_string(); // 接收临时对象
这个过程会发生什么:
c++
构造: "hello" ← make_string 内部构造 s
拷贝构造: "hello" ← s 拷贝到返回值(临时对象)
拷贝构造: "hello" ← 临时对象拷贝到 a
析构 ← 临时对象销毁
析构 ← s 销毁
核心矛盾 :临时对象马上就要销毁了,我们却还要把它的数据完整拷贝 一份给 a,拷贝完临时对象立刻析构。这是纯粹的浪费------能不能直接把临时对象的内存"转交"给 a,而不是拷贝?
0.1.4.3 右值引用&&
C++11引入右值引用,专门用来绑定右值:
c++
int a = 10;
int& lref = a; // 左值引用,绑定左值 ✅
int& lref = 10; // 左值引用,绑定右值 ❌ 编译错误
int&& rref = 10; // 右值引用,绑定右值 ✅
int&& rref = a; // 右值引用,绑定左值 ❌ 编译错误
const int& clref = 10; // ❗️const 左值引用,可以绑定右值 ✅(特殊规则)
右值引用的作用:能够让我们识别出这是一个临时对象,可以安全地偷走它的资源。
0.1.4.4 核心------移动构造函数
有了右值引用,就可以给 String 加一个移动构造函数:
c++
class String {
public:
char* data;
size_t size;
// 拷贝构造:老老实实深拷贝
String(const String& other) {
size = other.size;
data = new char[size + 1];
memcpy(data, other.data, size + 1);
std::cout << "拷贝构造\n";
}
// 移动构造:直接偷走资源
String(String&& other) {
size = other.size;
data = other.data; // 直接拿走指针,不分配新内存
other.data = nullptr; // 把原对象置空,防止析构时 double free
other.size = 0;
std::cout << "移动构造\n";
}
~String() {
delete[] data; // nullptr 时 delete 是安全的
}
};
现在再执行 make_string() 内部流程就变成了如下:
c++
String a = make_string();
构造 ← 构造 s
移动构造 ← 把 s 的资源"偷"给返回值(不分配新内存)
移动构造 ← 把返回值的资源"偷"给 a
析构 ← 返回值析构(data 是 nullptr,什么都不做)
析构 ← s 析构(data 是 nullptr,什么都不做)
从两次拷贝(两次 new + memcpy)变成两次指针交换,性能差距在数据量大的时候极其显著。
0.1.4.5 std::move ------把左值变成右值
问题来了:右值引用只能绑定右值,但有时候我们手里有个左值,想触发移动怎么办?
c++
String a("hello");
String b = a; // a 是左值,调用拷贝构造
String b = std::move(a); // 强制把 a 转成右值,调用移动构造
std::move 并不移动任何东西,它只是做了一个类型转换,把左值转换成右值引用,从而让编译器选择移动构造而不是拷贝构造。
std::move 的实现非常简单:
c++
template<typename T>
typename std::remove_reference<T>::type&& move(T&& t) {
return static_cast<typename std::remove_reference<T>::type&&>(t);
}
// 本质就是 static_cast<T&&>
关键点:std::move 之后,原变量进入"有效但未指定状态"
c++
String a("hello");
String b = std::move(a);
// a.data 现在是 nullptr
// a 还是合法对象,但内容已经被掏空
// 此后不应该再使用 a 的值,只能赋值或析构
std::cout << a.data; // ❌ 逻辑错误,a 已经被掏空
a = String("world"); // ✅ 重新赋值是安全的
0.1.4.6 移动赋值运算符
除了移动构造,还需要移动赋值:
c++
class String {
public:
// 移动赋值运算符
String& operator=(String&& other) {
if (this == &other) return *this; // 自我赋值检查
delete[] data; // 释放自己原来的资源
data = other.data; // 偷走资源
size = other.size;
other.data = nullptr; // 把原对象置空
other.size = 0;
std::cout << "移动赋值\n";
return *this;
}
};
c++
String a("hello");
String b("world");
b = std::move(a); // 触发移动赋值
// b 拿到 "hello",a 被置空
0.1.4.7 五法则------这几个函数要一起考虑
C++ 有个原则:如果你需要自定义以下任何一个,通常五个都需要自定义:
c++
class String {
String(const String&); // 1. 拷贝构造
String(String&&); // 2. 移动构造
String& operator=(const String&); // 3. 拷贝赋值
String& operator=(String&&); // 4. 移动赋值
~String(); // 5. 析构函数
};
0.1.4.8 编译器的自动优化:RVO/NRVO
实际上现代编译器有返回值优化(RVO),很多场景下根本不会发生拷贝或移动:
c++
String make_string() {
return String("hello"); // RVO:直接在调用方的内存里构造,没有任何拷贝/移动
}
String a = make_string();
// 实际只有一次构造,编译器把 make_string 内部的对象直接构造在 a 的地址上
但 RVO 不是万能的,以下情况 RVO 无法生效,需要移动语义兜底:
c++
String make_string(bool flag) {
String s1("hello");
String s2("world");
return flag ? s1 : s2; // 多个返回路径,NRVO 无法确定,退回到移动
}
0.1.4.9 转发引用(万能引用)------ && 的另一种身份
&& 出现在模板参数推导 的场景时,它不是右值引用,而是转发引用,既能绑定左值也能绑定右值:
c++
template<typename T>
void func(T&& x) { // 这里的 && 是转发引用,不是右值引用
// x 可以绑定左值或右值
}
int a = 10;
func(a); // T 推导为 int&,x 的类型是 int& &&,折叠成 int&
func(10); // T 推导为 int,x 的类型是 int&&
引用折叠规则:
c++
& + & → &
& + && → &
&& + & → &
&& + && → &&
0.1.4.10 完美转发std::forward
有了转发引用,就能做完美转发------把参数原封不动地传给下一层函数,保留左值/右值属性:
c++
// 不用完美转发的问题
template<typename T>
void wrapper(T&& x) {
func(x); // ❌ x 在这里是左值(它有名字!)
// 即使传入的是右值,这里也会调用 func 的左值版本
}
// 用完美转发
template<typename T>
void wrapper(T&& x) {
func(std::forward<T>(x)); // ✅ 保留原始的左值/右值属性
}
int a = 10;
wrapper(a); // x 是左值引用,forward 后仍然是左值
wrapper(10); // x 是右值引用,forward 后仍然是右值
std::forward 的实现:
c++
template<typename T>
T&& forward(std::remove_reference_t<T>& x) {
return static_cast<T&&>(x);
}
// T 是左值引用时 → 返回左值引用
// T 是非引用时 → 返回右值引用
std::remove_reference_t<T>& x 的含义:不管 T 是什么类型,先去掉引用,之后再变成左值引用。这里是为了确保参数统一是"左值表达式"。
c++
// 例如这里 T 是 int&
std::remove_reference_t<int&>& = int&
std::remove_reference_t<int>& = int&
关键在于 return static_cast<T&&>(x); 如果 T 本身是左值引用,如 int&,则实际最要转换的类型就是 int& &&,结合前边的引用这些,这里会这些成 &。
实际应用场景,构造函数转发:
c++
template<typename T, typename... Args>
std::unique_ptr<T> make_unique(Args&&... args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
// 无论 args 是左值还是右值,都原封不动传给 T 的构造函数
}
0.1.4.11 std::move vs std::forward 对比
c++
std::move(x) → 无条件转成右值引用
用于:明确知道 x 不再需要,想触发移动
std::forward<T>(x) → 根据 T 的类型决定转成左值还是右值引用
用于:模板中转发参数,保留原始值类别
0.1.4.12 实际收益对比
c++
std::vector<std::string> v;
std::string s = "一段很长很长的字符串...";
v.push_back(s); // 拷贝:分配新内存 + 复制所有字符
v.push_back(std::move(s)); // 移动:只交换指针,O(1)
v.emplace_back("直接构造"); // 更好:直接在 vector 内部构造,连移动都省了
容器扩容时也会用到移动:
c++
// vector 扩容时,如果元素有 noexcept 移动构造,就用移动而不是拷贝
// 所以移动构造应该声明 noexcept
String(String&& other) noexcept { // ← 加上 noexcept
data = other.data;
other.data = nullptr;
}
0.1.4.13 总结
c++
值类别
├── 左值(lvalue):有名字、有地址
│ 绑定到:T&(左值引用)
│
└── 右值(rvalue):临时的、即将消亡的
绑定到:T&&(右值引用)
│
▼
如何"偷走"右值的资源?
移动构造函数 String(String&&)
移动赋值运算符 operator=(String&&)
│
▼
左值也想触发移动?
std::move(x) → 无条件转右值引用
│
▼
模板中如何保留值类别传递?
T&&(转发引用)+ std::forward<T>(x)
核心记住两句话:
std::move不移动,只是告诉编译器"这个东西你可以偷走"- 真正的移动发生在移动构造/移动赋值里,是你自己写的"偷资源"逻辑
0.2 总结
0.2.1 自动类型推导
| 工具 | 保留引用 | 保留 const | 典型用途 |
|---|---|---|---|
auto |
❌ 丢失 | ❌ 丢失 | 简化局部变量、迭代器 |
auto& |
✅ 强制引用 | ✅ 保留 | range-for、避免拷贝 |
decltype(expr) |
✅ | ✅ | 精确推导表达式类型 |
decltype(auto) |
✅ | ✅ | 模板返回值完美转发 |
核心记忆点
auto推导"初始化值的类型",会剥除引用和顶层 const。decltype(x)看"声明类型",decltype((x))看"表达式值类别"------多一层括号触发 lvalue →T&规则。decltype(auto)= "用decltype的规则自动推",专为模板中的完美返回类型设计。
0.2.2 智能指针
| 指针类型 | 所有权语义 | 开销 | 最常见用途 |
|---|---|---|---|
unique_ptr |
独占,不可拷贝 | 几乎零 | 默认首选,单一所有者 |
shared_ptr |
共享,引用计数 | 原子操作 + 控制块 | 多处共享同一资源 |
weak_ptr |
无所有权 | 同 shared_ptr |
打破循环引用、观察者 |
选型口诀
单一所有者 → unique_ptr(零开销,优先选)
需要共享 → shared_ptr(make_shared,一次分配)
需要观察/打破循环 → weak_ptr(lock() 后才能访问)
只是借用,不管理 → 裸指针或引用
四个高频陷阱
- 不要用同一裸指针构造两个
shared_ptr(double free)。 - 类内获取自身
shared_ptr需继承enable_shared_from_this。 std::move后原unique_ptr变nullptr,不可再使用。shared_from_this必须在shared_ptr管理的对象上调用,栈对象直接崩溃。
0.2.3 lambda 表达式
语法结构
c++
[捕获列表] (参数列表) mutable -> 返回类型 { 函数体 }
捕获方式速查
| 写法 | 含义 | 注意 |
|---|---|---|
[] |
不捕获 | 无法访问外部变量 |
[x] |
值捕获(副本,const) | mutable 可解除 const |
[&x] |
引用捕获 | 注意对象生命周期 |
[=] |
隐式值捕获所有 | 避免滥用 |
[&] |
隐式引用捕获所有 | 避免滥用,生命周期风险 |
[this] |
捕获 this 指针 | 对象销毁则悬空 |
[*this] |
捕获对象副本(C++17) | 生命周期安全 |
核心原则
- 值捕获是创建时的快照,之后外部怎么变都不影响副本。
- 引用捕获是别名,生命周期必须比 lambda 长,否则是悬空引用(UB)。
for循环里异步存储 lambda → 用值捕获,不要引用捕获循环变量。- 无捕获的 lambda 可以退化为函数指针;有捕获的不行。
std::function可以统一存储不同 lambda,但有类型擦除开销。
0.2.4 右值引用 + move
值类别
左值(lvalue):有名字、有地址 → 绑定到 T&
右值(rvalue):临时、即将消亡 → 绑定到 T&&
判断口诀:能不能取地址,能取就是左值。
移动语义的本质
拷贝 = 深拷贝一份新资源(new + memcpy)→ 开销大
移动 = 偷走指针,把原对象置空 → O(1)
移动语义的实现需要你手动写 移动构造 + 移动赋值 ,并遵守五法则(拷贝构造 / 拷贝赋值 / 移动构造 / 移动赋值 / 析构,只要自定义一个,通常五个都要写)。
std::move vs std::forward
| 作用 | 使用场景 | |
|---|---|---|
std::move(x) |
无条件转成右值引用 | 明确放弃 x,触发移动 |
std::forward<T>(x) |
按 T 的类型决定左/右值 | 模板中转发参数,保留原始值类别 |
最重要的两句话
std::move不移动任何东西,它只是类型转换,告诉编译器"你可以偷走这个"。- 真正的移动发生在你自己写的移动构造/移动赋值里,是你写的"偷指针、置 nullptr"逻辑。
完美转发模式
c++
template<typename T>
void wrapper(T&& x) {
func(std::forward<T>(x)); // 原封不动传递左值/右值
}
T&& 在模板中是转发引用 (万能引用),配合 std::forward 实现完美转发,是 make_unique / emplace_back 等标准库设施的底层机制。
0.2.5 四大特性关联图
auto / decltype
└─→ 推导出正确类型,决定是值还是引用
│
▼
右值引用 T&&
│
┌─────────┴──────────┐
│ │
std::move std::forward
(左值→右值,触发移动) (完美转发,保留值类别)
│
▼
移动构造 / 移动赋值
(真正的"偷资源"逻辑)
│
▼
智能指针的移动语义
unique_ptr 只能 move,不能 copy
shared_ptr 内部控制块用引用计数管理生命周期
lambda 的捕获列表本质是生成一个含成员的匿名类
└─→ [ptr = std::move(p)] 把 unique_ptr 移动进 lambda
这四个特性在工程中高度协作:auto 推导类型,&& 识别右值,move/forward 触发或保留移动语义,智能指针和 lambda 则是移动语义最直接的受益者。