CPP重学(六)
一、RAII 与智能指针
RAll思想:构造获取,析构释放
class File { FILE *fp_;public: File(const char *path) : fp_(fopen(path, "r")) { // 构造 = 获取资源 if (!fp_) throw std::runtime_error("open failed"); } ~File() { if (fp_) fclose(fp_); } // 析构 = 释放资源 // ... 读写接口};三种智能指针选型
- unique_ptr:默认首选,独占所有权,不可拷贝,可以移动
- shared_ptr:共享所有权(引用计数),可以拷贝,计数+1.
- weak_ptr:不拷贝,不拥有,只观察
unique_Ptr:auto p = std::make_unique<int> 67;// std::unique_ptr<int> q = p; // 编译错误:不可拷贝auto q = std::move(p); // 移动转移所有权,p 变空零开销:与裸指针同尺寸同性能,perf 下无任何额外成本。语义即文档:unique_ptr 传给函数 = "我借给你,用完还我"。
shared_ptrauto s1 = std::make_shared<int>(42);auto s2 = s1; // 引用计数 2s1.reset(); // 计数 1// s2 离开作用域时计数归 0,自动 delete引用计数线程安全,但控制块额外分配一次内存(性能略低于 unique_ptr)。循环引用会让计数永不归零 → 泄漏,见下文。
weak_ptr:打破循环引用struct Node { std::shared_ptr<Node> next; std::weak_ptr<Node> parent; // 用 weak 避免环};
auto a = std::make_shared<Node>();auto b = std::make_shared<Node>();a->next = b; // b 被 a 持有b->parent = a; // weak:不增加 a 的计数循环引用问题:如果 parent 也用 shared_ptr,a 和 b 互相持有、计数永不归零,内存泄漏。用 weak_ptr 观察但不拥有,打破环。访问weak指向的对象前要先lock if (auto sp = parent.lock()) { // lock 成功说明对象还活着 sp->doSomething();}常见坑
| 坑 | 示例 | 后果 |
|---|---|---|
用 new 不用 make_* | unique_ptr<T>(new T) | 额外一次分配/异常不安全 |
| 混用裸指针与智能指针 | p.get() 后 delete | 双重释放 |
| 循环引用 | shared_ptr 成环 | 泄漏 |
shared_ptr 存数组 | make_shared<int[]> 需 C++17 语法 | 析构不完整 |
把 this 交给 shared_ptr | 从裸 this 构造 | 双重管理 |
this陷阱
当你需要在类的成员函数里把”自己”交给 shared_ptr(比如把 this 注册进回调、定时器),绝不能 shared_ptr<T>(this)——这会凭空造出一个新的所有者,和原有的控制块不是一回事,析构时双重 delete。正确做法是从 std::enable_shared_from_this 继承,用 shared_from_this():
struct Worker : std::enable_shared_from_this<Worker> { void start() { // 错误:shared_ptr<Worker>(this) 新建独立控制块 → 双重释放 // register_callback([self = shared_from_this()] { self->on_event(); }); auto self = shared_from_this(); // 正确:复用同一控制块 register_callback([self] { self->on_event(); }); } void on_event() { /* ... */ }};shared_from_this() 要求对象本身已经由一个 shared_ptr 管理,它从那个控制块里取出”第二个强引用”,而不是新建。否则抛 bad_weak_ptr——这是一个能提前暴露用法的保护机制。
另一个高频坑是 lambda 捕获:按值捕获 shared_ptr 会增加引用计数(延长生命周期),按引用捕获 [&] 则要小心回调执行时对象是否已析构。长期存活的异步回调,几乎都该按值捕获 shared_ptr 或 weak_ptr 后 lock(),避免悬空引用。
二、移动语义与右值引用
拷贝 vs 移动:同一资源,两种代价

值类别
int x = 42; // x 是左值:有名字、可取地址int y = x; // x 作为右值使用(被读取)int &&r = 42; // 42 是右值字面量:临时、不可取地址| 值类别 | 定义 | 例子 | 能否 & |
|---|---|---|---|
| 左值 lvalue | 有名字、持久 | 变量、*p、a[0] | 能 |
| 纯右值 prvalue | 临时值 | 42、f() 返回的临时 | 不能 |
| 将亡值 xvalue | 即将销毁的资源持有者 | std::move(x) | 能(临时身份) |
核心直觉:右值 = 临时对象 = 反正要销毁 → 与其深拷贝,不如偷走它的资源(把指针搬过来,让它变空)。
移动构造与移动赋值
class Buffer { char *data_; size_t size_;public: // 拷贝构造:深拷贝(昂贵) Buffer(const Buffer &o) : data_(new char[o.size_]), size_(o.size_) { std::copy(o.data_, o.data_ + size_, data_); } // 移动构造:偷资源(O(1)) Buffer(Buffer &&o) noexcept : data_(o.data_), size_(o.size_) { o.data_ = nullptr; // 让源对象失效,析构安全 o.size_ = 0; } ~Buffer() { delete[] data_; }};
Buffer a(1024);Buffer b = a; // 拷贝:分配+复制 1024 字节Buffer c = std::move(a); // 移动:仅搬指针,O(1)什么时候移动
| 场景 | 行为 |
|---|---|
T c = std::move(a) | 移动构造 |
T c = f()(函数返回临时) | 移动(或拷贝省略,见下) |
v.push_back(std::move(obj)) | 移动进容器 |
| 把左值传给期望右值的参数 | 需显式 std::move |
普通赋值 b = a(左值到左值) | 拷贝 |
规则:右值 → 移动,左值 → 拷贝。左值不会自动变成右值,必须显式 std::move——这是 C++ 的安全设计(防止意外移动破坏对象)。
移动构造/赋值声明 noexcept 很关键:vector 扩容时,只有移动是 noexcept 才敢用移动(否则为保证强异常安全退回拷贝)。移动必须 noexcept 是工程铁律。
std::move 与 std::forward
std::move:只是类型转换,将左值转为没用的右值。
move之后,原来的左值就空了。
template <typename T>constexpr std::remove_reference_t<T> &&move(T &&t) noexcept { return static_cast<std::remove_reference_t<T> &&>(t);}std::move(x) 不移动任何东西——它只是把 x 转换为右值引用,让重载决议选择移动版本。
两个坑
move之后源对象处于”有效但未指定”状态,别再用它(虽然能析构、能重新赋值)。- 对
const对象move是无效的——const T转出来是const T&&,匹配不到T(T&&),会退化成拷贝:
std::forward:完美转发,保留原本的左值/右值属性
template <typename T>void wrapper(T &&arg) { // 万能引用(T&& 在模板里) inner(std::forward<T>(arg)); // 保持左值/右值属性转发}T&& 在模板中不是右值引用而是万能引用:传左值 → T = A&,传右值 → T = A。std::forward<T>(arg) 按 T 恢复原始值类别:左值转发成左值、右值转发成右值——"完美转发"。拷贝省略
std::vector<int> make() { return {1, 2, 3}; }auto v = make(); // C++17 保证:直接构造,零拷贝零移动C++17 起纯右值直接初始化保证省略拷贝(如 make() 返回的临时直接构造 v)。这是”值语义 + 零开销”的重要支柱——写返回大对象的函数可以放心传值。
| 机制 | 开销 | 何时生效 |
|---|---|---|
| 拷贝 | O(n) 深拷贝 | 左值到左值 |
| 移动 | O(1) 偷指针 | 右值目标 |
| 拷贝省略 | 0 | 纯右值初始化(C++17 保证) |
std::vector<int> make() { std::vector<int> v(1000000); return v; // NRVO/C++11 起:直接搬家,不拷百万元素}std::vector<int> a = make(); // 接管 make 里的 vector,零拷贝移动语义让”资源所有权转移”代替”整体复制”。std::move(x) 不移动任何东西,只是把 x 变成右值,触发移动构造/赋值——把指针和长度从源”偷”到目标,源置空。百万元素 vector 从”拷 8MB”变成”拷 3 个字段”,快几个数量级。
陷阱:被 move 后的对象处于”有效但未指定”状态
std::string s = "hello"; std::string t = std::move(s);// s 现在为空,不能再假设它有 "hello",但能安全析构/赋值总结:移动语义 = 右值(临时)对象偷走资源而非深拷贝,std::move 只是类型转换、std::forward 保持值类别转发,C++17 拷贝省略进一步把纯右值开销降到 0;写返回大对象的函数就放心传值,交给移动与省略。
三、完美转发与引用折叠
引用的引用:编译器帮忙折叠
int x = 1;int &ref = x;int & &ref_ref = ref; // "引用的引用"?C++ 里不允许你直接声明”引用的引用”,但当它经过模板参数推导产生时,编译器会执行引用折叠(reference collapsing),把它变成普通引用。上面这句等价于 int &ref_ref = ref;。
引用折叠的规则只有四条,规律非常好记——只要出现一个左值引用 &,结果就是左值引用;只有两个都是右值引用 &&,结果才是右值引用:
| 组合 | 折叠结果 | 记忆法 |
|---|---|---|
T& & | T& | 有左值就是左值 |
T& && | T& | 有左值就是左值 |
T&& & | T& | 有左值就是左值 |
T&& && | T&& | 全是右值才是右值 |
using LRef = int&;using RRef = int&&;using A = LRef&; // int& & → int&using B = LRef&&; // int& && → int&using C = RRef&; // int&& & → int&using D = RRef&&; // int&& && → int&&一句话记忆:左值引用有”一票否决权”——四种组合里但凡有一个 &,结果都是 &。
万能引用:T&&
&& 出现在两个地方,含义完全不同:
void f(int &&r); // 右值引用:只能绑右值template <typename T>void g(T &&arg); // 万能引用:既能绑左值又能绑右值T&& 只有当 T 是模板推导的类型参数(或 auto&&)时才是万能引用(也叫转发引用)。它的行为:
| 实参 | 推导出的 T | 参数类型 | 说明 |
|---|---|---|---|
左值 int | int& | int& && → int& | 万能引用变左值引用 |
右值 42 | int | int&& | 万能引用变右值引用 |
const 左值 | const int& | const int& | const 保留 |
这里的关键:万能引用通过引用折叠”同时接收左值和右值”,而且不丢失实参的 const 属性。这正是”完美转发”的地基——信息在传递过程中不失真。
std::forward 的标准实现(简化版)就一句话:
template <typename T>constexpr T&& forward(std::remove_reference_t<T>& t) noexcept { return static_cast<T&&>(t); // 把左值 t 转成 T&&}配合万能引用,它做的事是:
template <typename T>void wrapper(T &&arg) { inner(std::forward<T>(arg));}推演一下两条路径:
- 传左值:
T = int&→std::forward<int&>(arg)的返回类型是int& &&→ 折叠为int&。转发成左值,inner收到左值引用。 - 传右值:
T = int→std::forward<int>(arg)返回int&&。转发成右值,inner收到右值引用,可以触发移动。
所以 forward 的名字很贴切:把实参原有的值类别(左值/右值)原封不动”转发”给下一层。参数在 wrapper 内部是左值(有名字),经过 forward<T> 恢复了它进函数前的身份。
两者底层都是 static_cast,区别只在”转成什么”:
| 对比项 | std::move | std::forward |
|---|---|---|
| 本质 | 无条件转右值引用 | 按模板参数 T 恢复原值类别 |
| 适用场景 | 明确要转移资源 | 泛型代码里转发参数 |
| 典型用法 | v.push_back(std::move(obj)) | inner(std::forward<T>(arg)) |
| 对左值 | 变成右值(危险) | 保持左值 |
| 对右值 | 保持右值 | 保持右值 |
用错的反面教材:转发函数里用 move 代替 forward,会把左值实参也变成右值传下去——如果 inner 恰好有移动构造重载,左值对象就被悄悄”掏空”了。这是隐蔽 bug 的高发点。
template <typename T>void bad_wrapper(T &&arg) { inner(std::move(arg)); // 错!左值也被转成右值}完美转发案例
#include <utility>#include <iostream>
void take(int &x) { std::cout << "lvalue: " << x << "\n"; }void take(int &&x) { std::cout << "rvalue: " << x << "\n"; }
template <typename T>void log_and_take(T &&arg) { std::cout << "log...\n"; take(std::forward<T>(arg)); // 值类别原样转发}
int main() { int a = 10; log_and_take(a); // 输出 lvalue: 10(a 仍是左值) log_and_take(20); // 输出 rvalue: 20(临时对象保持右值)}总结:完美转发 = 万能引用(T&&)+ 引用折叠(有 & 就是 &)+ std::forward(按 T 恢复值类别)三层配合,让泛型包装器把参数的左值/右值身份原封不动传给下一层;move 是无条件右值化,forward 是条件保持,转发场景用后者。
四、模板与泛型编程
模板 = 把”类型”或”值”变成参数,让同一份代码适用于多种类型,编译期由编译器自动生成具体代码。
编译期展开:一次模板,N 份代码

函数模板与类模板
函数模板
template <typename T>T max_of(T a, T b) { return a > b ? a : b;}
int m1 = max_of(3, 7); // 实例化 int 版本double m2 = max_of(3.14, 2.71); // 实例化 double 版本编译期生成两份代码(int 和 double 各一份)——每用一次类型就展开一次,这就是”模板 = 编译器代写代码”。
类模板
template <typename T>class Stack { std::vector<T> data_;public: void push(const T &v) { data_.push_back(v); } T pop() { auto v = data_.back(); data_.pop_back(); return v; }};
Stack<int> si; // Stack<int>Stack<std::string> ss; // Stack<std::string>:互不相同的类型三个大类
- 类型参数(最常见)
template <typename T> // T 是一个类型void f(T x);- 非类型参数(编译期常量)
template <typename T, int N> // N 是一个编译期整数常量class Array { T data[N];};
Array<int, 10> arr; // 固定大小 10 的数组,N 在编译期确定N 必须是编译期常量(字面量、constexpr、sizeof 结果等),不能是运行时变量。这让数组大小、循环展开等能在编译期优化。- 模板模板参数(嵌套)
template <template <typename> class Container, typename T>class Wrapper { Container<T> c;};
Wrapper<std::vector, int> w; // Container = std::vector, T = int- 可变参
template <typename... Args> // Args 是一个"类型包",可含 0~N 个类型void print(Args... args) { // args 是"参数包" (std::cout << ... << args); // C++17 折叠表达式}
print(1, "hello", 3.14, 'c'); // Args = {int, const char*, double, char}模板特化
全特化:为指定类设计的
template <typename T>T max_of(T a, T b) { return a > b ? a : b; }
// 全特化:const char* 比较内容而非指针template <>const char *max_of<const char *>(const char *a, const char *b) { return std::strcmp(a, b) > 0 ? a : b;}偏特化:只针对部分参数
// 指针版本的 Stacktemplate <typename T>class Stack<T *> { // 专门处理指针:自动 delete 等};| 特化类型 | 语法 | 适用 |
|---|---|---|
| 全特化 | template <> ... max_of<const char*> | 函数/类模板 |
| 偏特化 | template <typename T> class Stack<T*> | 仅类模板 |
| 偏特化(参数) | template <typename T> class Stack<std::vector<T>> | 仅类模板 |
ifconstexpr:编译器分支
template <typename T>void process(T v) { if constexpr (std::is_integral_v<T>) { // 仅整型时编译此分支 } else { // 其他类型时编译此分支 }}if constexpr 在编译期丢弃未选中分支——不做运行期判断,还能编译”本不该实例化”的代码(如对整型调用字符串专属 API)
SFINAE 与 concepts
C++20 之前,模板匹配失败的错误信息是几百行晦涩编译错误。SFINAE(替换失败不是错误)是 C++11~17 时代的约束手段,但语法晦涩:
template <typename T>auto length(const T &c) -> decltype(c.size()) { // 只有有 .size() 才匹配 return c.size();}C++20 concepts:把约束变成语言特性
template <typename T>concept HasSize = requires(T t) { t.size(); };
template <HasSize T> // 约束模板参数size_t length(const T &c) { return c.size(); }
length(std::vector<int>{1,2,3}); // OK:有 size()// length(42); // 编译错误:明确提示 int 不满足 HasSize| 约束方式 | 时代 | 可读性 | 运行时开销 |
|---|---|---|---|
| 无约束模板 | C++98 | 报错难懂 | 0 |
| SFINAE | C++11~17 | 晦涩 | 0 |
| concepts | C++20 | 报错清晰 | 0 |
总结:模板 = 编译期按类型展开的零成本抽象(函数/类模板 + 全特化/偏特化 + 变参折叠 + concepts 约束);与虚函数相比,模板把多态决策从运行期提前到编译期,换来性能、换来可读的错误提示。
五、模板特化与偏特化
为什么要特化
template <typename T>T max_of(T a, T b) { return a > b ? a : b;}大部分类型都能用。但遇到 const char*(C 字符串),这个版本比较的是指针地址而不是字符串内容,行为就错了:
max_of("apple", "banana"); // 比较的是指针,结果随机!遇到这种类型,我们希望走”按字符串比较”的专用逻辑。这就是特化的价值——通用逻辑覆盖 95%,特殊类型单独处理 5%。
函数模板特化
显示特化(全特化)
#include <cstring>
// 通用模板template <typename T>T max_of(T a, T b) { return a > b ? a : b;}
// 对 const char* 的显式特化template <>const char* max_of<const char*>(const char* a, const char* b) { return std::strcmp(a, b) > 0 ? a : b;}注意语法要点:
| 要点 | 说明 |
|---|---|
template <> | 空模板参数列表,标志”这是特化” |
max_of<const char*> | 显式指定特化对象 |
| 参数类型 | 必须与目标完全匹配(这里是 const char*) |
特化vs重载:优先选重载
C++ 有一个反直觉的事实:函数模板的特化不参与重载决议,它只是”当实例化匹配时优先用的实现”。而重载参与决议。
// 重载:参数类型不同,参与重载决议const char* max_of(const char* a, const char* b) { return std::strcmp(a, b) > 0 ? a : b;}对函数模板来说,重载通常比特化更好——重载是”新函数”,特化是”原函数的补丁”。工程实践:能用重载解决的问题,不要用特化。
| 对比项 | 函数模板重载 | 函数模板显式特化 |
|---|---|---|
| 本质 | 独立函数 | 原模板的专用实现 |
| 参与重载决议 | 是 | 否 |
| 匹配规则 | 更灵活的候选集 | 与原模板绑定 |
| 推荐度 | 优先 | 谨慎 |
特化与偏特化
全特化:指定所有类型
// 通用类模板template <typename T, typename U>struct Pair { void describe() const { /* 通用逻辑 */ }};
// 全特化:两个类型都固定template <>struct Pair<int, int> { void describe() const { /* 针对两个 int 的专用逻辑 */ }};偏特化:只指定部分
// 偏特化:第二个类型固定为 int,第一个保留template <typename T>struct Pair<T, int> { void describe() const { /* 通用 T + int 的专用逻辑 */ }};
// 偏特化:两个参数都是指针template <typename T, typename U>struct Pair<T*, U*> { void describe() const { /* 针对指针的专用逻辑 */ }};| 特化类型 | 写法 | 示例 |
|---|---|---|
| 全特化 | template <> struct X<具体类型> | struct Pair<int, int> |
| 偏特化 | template <部分参数> struct X<具体模式> | struct Pair<T, int>、struct Pair<T*, U*> |
| 函数模板 | 只能全特化 | template <> max_of<const char*> |
为什么函数不能偏特化? 因为函数可以重载——max_of(const char*, const char*) 本身就是”偏特化效果”,不需要再引入一套偏特化语法。类没有重载机制,只能靠偏特化补上这部分能力。
匹配优先级
编译器选择模板的顺序是:全特化 > 偏特化(越具体越优先)> 通用模板。
Pair<int, int> a; // 全特化Pair<double, int> b; // 偏特化 Pair<T, int>Pair<int*, int*> c; // 偏特化 Pair<T*, U*>Pair<double, char> d; // 通用模板
std::vector<bool>:特化的经典案例
std::vector<bool> 是 C++ 里”最出名”的特化:
std::vector<bool> flags(8);flags[3] = true; // 这里发生了什么?普通 vector 每个元素是独立对象,operator[] 返回引用。但 vector<bool> 特化后每个 bool 只占 1 bit,8 个 bool 挤在一个字节里——这样就没有”指向单个 bool 的引用”可用了,operator[] 只能返回一个代理对象(proxy)。
这带来两个经典坑:
auto b = flags[3]; // b 是代理对象,不是 bool&bool x = flags[3]; // 能隐式转换,OK
auto &ref = flags[3]; // 编译错误:不能把代理绑到 bool&| 对比项 | std::vector<bool> | 普通 std::vector<T> |
|---|---|---|
| 存储 | 每元素 1 bit(省内存) | 每元素 sizeof(T) |
operator[] 返回 | 代理对象 | T& 引用 |
| 能否取元素地址 | 不能 | 能 |
| 性能 | 节省 8 倍内存,但位操作有额外开销 | 直存直取 |
这个案例的价值在于:特化可以彻底改变一个类模板的行为——节省了 8 倍内存,代价是引号语义和迭代器行为的改变。遇到 auto& ref = v[i] 编译不过,先想想是不是 vector<bool>。
特化注意事项
- 特化必须和原模板在同一个命名空间,且要在使用之前声明。
- 特化不能改变成员的签名(不能加成员变量改构造),只能改实现——它的接口必须与原模板兼容。
- 部分特化不能用于函数模板(如前述)。
- 特化常用于:类型特征(
std::is_xxx)、分配器、哈希函数(std::hash<T>)、std::vector<bool>。
namespace std {template <>struct hash<MyType> { // 为自定义类型提供哈希 size_t operator()(const MyType &t) const { return /* 计算哈希 */; }};}给 std::hash 特化后,MyType 就能直接放进 unordered_map / unordered_set——这是特化在标准库中最常见的用途。
总结:特化是给模板”开小灶”:全特化固定所有类型、偏特化(仅类模板)固定部分类型,编译器按”全特化 > 偏特化 > 通用模板”选择,vector<bool> 的特化则是”省内存换语义”的经典代价案例;函数模板优先用重载而非特化。
部分信息可能已经过时









