CPP重学笔记(四)
学习笔记来自https://geek-doc.cn/cpp
- CPP重学笔记(四)
一、pair与tuple
迷你结构体pair
先看一个常见场景:map 里存的是键值对,遍历 map 拿到的每一个元素,类型就是 std::pair<const Key, Value>。再看函数返回值:
// 没有 pair 的日子:要么传指针,要么定义结构体void get_min_max(const std::vector<int>& v, int* min_out, int* max_out);
// 有了 pair:std::pair<int, int> get_min_max(const std::vector<int>& v);传指针那版有”输出参数”的毛病:调用方得先声明两个变量,再把地址传进去,函数写不写都不确定,可读性差。返回 pair 则一目了然——一个函数返回一个”装了两个数的包”。
#include <utility>
std::pair<int, double> p1(42, 3.14); // 直接构造std::pair<int, double> p2{42, 3.14}; // 列表初始化auto p3 = std::make_pair(42, 3.14); // 类型自动推导(C++11 起可用 {} 替代)std::pair<int, double> p4 = {42, 3.14}; // C++11 起可直接赋值
// 访问成员:.first 和 .secondstd::cout << p1.first << " " << p1.second;超出两个值用tuple
胖狗ps:介不是元组吗,几天不见,这么吊了?
tuple 是”定长异构列表”——长度编译期固定,每个位置的类型可以不同:
#include <tuple>
// 三个不同类型std::tuple<int, std::string, double> t(1, "hello", 3.14);
// 访问:std::get<下标>(t)std::cout << std::get<0>(t); // 1std::cout << std::get<1>(t); // hellostd::cout << std::get<2>(t); // 3.14
// 也可以按类型取(类型必须唯一,否则歧义)std::string s = std::get<std::string>(t);注意 get 的模板参数是编译期常量——不能写 std::get<runtime_index>(t),下标必须写死。想按运行时下标取,得用 std::get + if constexpr
pair就是特殊的tuple,可以转换
std::tuple<int, double> t = std::make_tuple(1, 3.14);std::pair<int, double> p = t; // tuple → pair(两个元素时)pair/tuple 适合”一次性打包,用完就拆”的场景。业务含义强的建议还是定义结构体/类——.first、.second、.get<0>() 可读性不如 r.code、r.name。
结构化绑定
打包容易拆包难
// C++11/14 时代的痛苦拆法int code;double value;std::string msg;std::tie(code, value, msg) = get_result(); // 必须预先声明三个变量不过cpp17后一行搞定
// C++17auto [code, value, msg] = get_result(); // 一次性声明并绑定
// map 遍历的经典写法for (const auto& [key, value] : my_map) { std::cout << key << " -> " << value << "\n";}auto [a, b, c] = ... 让编译器帮你”拆包”,等号右边的 pair/tuple/结构体/数组都可以。注意几个细节:
auto [a, b] = p;是拷贝。想拿引用,写auto& [a, b] = p;或const auto& [a, b] = p;- 拆结构体也行——成员按声明顺序绑定(C++17 要求所有成员都是非静态、公开的,C++20 起支持匿名结构体成员等更多情况)
- 循环里用结构化绑定特别顺手,上面 map 遍历就是标准写法
老项目经常用tie,这里认识一下即可
#include <tuple>
int code;std::string msg;std::tie(code, msg) = get_result(); // 先声明,再绑定
// tie 还有个大用途:字典序比较auto [a, b] = std::pair{1, 2};std::tie(a, b) < std::tie(c, d) // 先比 a 和 c,再比 b 和 dpair/tuple 本身就自带 ==、< 这些比较运算符,比较规则是”逐元素比较”,所以两个 pair 直接比大小就行,一般用不上 tie。但 tie 可以让普通变量也获得这种比较能力,这个技巧在排序自定义比较器里偶尔见到。
pair在map里经常刷存在感
map 的 insert、迭代器解引用拿到的都是 pair
#include <map>
std::map<std::string, int> scores;scores.insert({"alice", 90}); // 构造的是 pair<const string, int>
for (const auto& [name, score] : scores) { // 结构化绑定解 pair std::cout << name << ": " << score << "\n";}注意 key 类型是 const std::string——map 不允许修改键(改了会破坏排序),所以 pair 的第一元素带 const。
二、RAll思想
RAII 全称 Resource Acquisition Is Initialization,直译是”资源获取即初始化”。
把资源的生命周期,绑定到对象的生命周期上。
具体来说两句话:
- 资源在对象构造时获取(构造里打开文件、分配内存、加锁)
- 资源在对象析构时释放(析构里关闭文件、释放内存、解锁)
来看一个错误场景
void process() { int* p = new int(42); if (check_failed()) { return; // 忘了 delete p —— 泄漏! } // 更多代码…… delete p;}一旦中间有 return 或抛异常,delete 就可能被跳过。RAII 的版本长这样:
#include <memory>
void process() { std::unique_ptr<int> p = std::make_unique<int>(42); if (check_failed()) { return; // 无所谓:p 析构自动释放 } // 不管怎么退出,p 都会释放}无论函数从哪条路退出(正常 return、中间 return、抛异常),局部对象的析构函数一定会执行——这是栈展开(stack unwinding)保证的,与你怎么写代码无关。这就是 RAII 消灭资源泄漏的底气。
为啥是构造时获取
原因在于:构造函数执行成功,对象才存在;对象存在,就一定持有资源。这保证了一个不变量——“活的 RAII 对象 = 持有资源”。反过来想,如果资源获取失败,构造函数会抛异常,对象根本不会产生,也就没有”半死不活”的状态。这个”要么全有、要么全无”的性质,让资源管理变得可以推理。
对比C
| 环节 | C(手动) | C++(RAII) |
|---|---|---|
| 获取资源 | fopen / malloc | 构造对象 |
| 使用资源 | 用返回值 | 用对象 |
| 释放资源 | fclose / free(靠自觉) | 析构自动执行(编译器保证) |
| 提前 return | 容易漏释放 | 不会漏(栈展开) |
| 抛异常 | 无此概念 | 异常时同样执行析构 |
| 错误处理 | 检查返回值 | 构造抛异常或对象判空 |
RAII 不只管内存
很多人以为 RAII 就是”智能指针管内存”,其实它管的是一切需要成对获取/释放的资源:
- 内存:
new/delete→unique_ptr/shared_ptr - 文件:
open/close→ofstream/ifstream - 锁:
lock/unlock→std::lock_guard - 套接字:
socket/close→ 封装类 - 数据库连接、线程句柄、信号量……凡是”借了要还”的东西
看锁的例子,这是 RAII 在并发里最经典的运用:
#include <mutex>
std::mutex mtx;int counter = 0;
void increment() { std::lock_guard<std::mutex> lock(mtx); // 构造:上锁 ++counter; // 临界区} // 析构:自动解锁lock_guard 的构造里 lock(),析构里 unlock()。就算临界区中间抛了异常,锁也会被解开,不会死锁。手写 lock()/unlock() 的话,异常路径很容易忘记 unlock()。
RAII 的三个心智模型
要真正理解 RAII,可以用三个角度去看它:
1. 生命周期视角:对象的生命周期 = 资源的使用期限。对象活多久,资源就占多久;对象死,资源自动还。
2. 所有权视角:对象”拥有”资源。unique_ptr 拥有裸指针,lock_guard 拥有锁。所有权转移、共享,都有对应的 RAII 类型。
3. 异常安全视角:C 靠 goto cleanup 或者层层检查来保证”出错了也要释放”,RAII 靠析构天然保证。这直接引出了后面 “栈展开与 RAII”的主题——异常发生时,作用域内的 RAII 对象依次析构,资源逐个释放。
这三个视角对应三种问题:什么时候释放?谁负责释放?出错时释放吗?RAII 全部给了确定性答案。
胖狗ps:所以RAll的作用貌似很大啊,自动化管理对象与资源。
总结:RAII 把”资源的获取放进构造、释放放进析构”,让资源生命周期与对象生命周期严格同步,利用栈展开保证无论正常返回还是抛异常都必然释放——这是 C++ 资源管理的地基,智能指针、锁守护、文件流全都是它的应用。
三、unique_ptr
std::unique_ptr。它是智能指针里最常用、也最”省心”的一个——但省心的前提是理解它的脾气:独占所有权,只移动,不拷贝。
#include <memory>
// 裸指针的经典烦恼void demo_raw() { Widget* w = new Widget(); use(w); if (error_occurred()) { return; // 忘记 delete,泄漏 } delete w;}
// unique_ptr 版本void demo_unique() { std::unique_ptr<Widget> w = std::make_unique<Widget>(); use(w.get()); // 需要裸指针时,用 get() if (error_occurred()) { return; // 自动释放,无泄漏 }} // 离开作用域自动 deleteunique_ptr 拥有它指向的对象:它是唯一的持有者。对象析构时,unique_ptr 自动 delete 所指对象。名字里的 “unique” 就是”独占”的意思。
不能拷贝!?
std::unique_ptr<int> p = std::make_unique<int>(42);std::unique_ptr<int> q = p; // 编译错误!拷贝被删除如果允许拷贝,两个 unique_ptr 指向同一个对象,各自析构时都 delete 一次——双重释放(double free),程序直接崩。C++ 的解法是:把拷贝删掉,只允许移动。
“移动”的意思是:把所有权转交给别人,自己变成空。移动之后原指针不再持有任何东西:
std::unique_ptr<int> p = std::make_unique<int>(42);std::unique_ptr<int> q = std::move(p); // 所有权转移到 q
if (!p) { std::cout << "p 已为空\n"; } // p 现在是 nullptrstd::cout << *q; // q 持有 42std::move(p) 把 p 变成”可以移动”的状态,unique_ptr 的移动构造把内部指针交给 q,并把 p 置空。规则很简单:unique_ptr 可以搬走,不能复制。
胖狗ps:所有权?rust里好像经常搞这一套,以后有机会看看,先看看cpp利用move移动所有权。
移动的语义
std::move 这个名字容易让人误解,好像”把东西搬走”是它干的。其实 std::move 只是把左值转换成右值引用,本身不搬任何东西。真正搬东西的是接收方的移动构造函数/移动赋值。移动构造和拷贝构造的区别:拷贝是”再做一个”,移动是”掏空对方的,装进自己的”。
std::unique_ptr<int> p = std::make_unique<int>(1);std::unique_ptr<int> q = std::move(p); // 调用 q 的移动构造:取走 p 的指针,p 置空unique_ptr常用操作
#include <memory>
// 创建(强烈推荐 make_unique,理由见 46 篇)std::unique_ptr<int> p = std::make_unique<int>(42);
// 判空if (p) { /* 非空 */ }if (!p) { /* 空 */ }
// 解引用*p = 10;int v = *p;
// 访问成员(指向类时)auto obj = std::make_unique<Widget>();obj->method(); // 和裸指针一样的 -> 语法
// 拿裸指针(只读借用,不转移所有权)Widget* raw = obj.get();if (raw) { /* ... */ }
// 释放所有权,交还裸指针Widget* taken = obj.release(); // obj 变空,所有权在你手里,记得自己 delete
// 显式重置 / 释放obj.reset(); // delete 旧对象,置空obj.reset(new Widget()); // 换成新对象重点区分三个”放行”操作:
| 操作 | 作用 | 所有权 |
|---|---|---|
get() | 返回裸指针 | 不转移,用完别 delete |
release() | 交还裸指针 | 转移,必须自己 delete |
reset() | 释放旧对象 | 完全释放 |
get() 是最常用的——把 unique_ptr 传给只认裸指针的老接口。注意别把 get() 的结果存起来长期用:unique_ptr 一析构或 reset,那个裸指针就悬垂了。release() 用得少,它相当于”主动放弃 RAII”,通常是你要把指针交给不认智能指针的 C 接口时。
作为函数参数和返回值
这是工程里最容易写错的地方,单独拎出来讲。
参数:函数只读使用,传裸指针或 const Widget&:
void inspect(const Widget* w); // 或 const Widget&inspect(up.get()); // 借用返回值:直接返回 unique_ptr 就行,这是最省心的模式:
std::unique_ptr<Widget> make_widget() { auto w = std::make_unique<Widget>(); return w; // 自动移动(甚至不算移动,是 NRVO 优化)}
auto w = make_widget(); // 调用方拿到独占所有权函数返回 unique_ptr 代表”我把这个对象的所有权交给你”。这是 C++ 里表达”工厂产出、调用方负责销毁”的标准方式。注意别在函数参数里传 unique_ptr 按值——那是”你要把所有权交进来”,多数时候不是本意。
数组与自定义删除器
unique_ptr 也支持数组和自定义删除方式:
// 数组版本(C++11 起有数组特化)std::unique_ptr<int[]> arr = std::make_unique<int[]>(100);arr[0] = 1; // 支持下标访问
// 自定义删除器(比如关文件、释放句柄)auto closer = [](FILE* f) { if (f) std::fclose(f); };std::unique_ptr<FILE, decltype(closer)> fp(std::fopen("a.txt", "r"), closer);数组特化会在析构时 delete[] 而不是 delete,不用自己操心。自定义删除器让它能管”非 new 来的资源”——只要是”借了要还”的东西,都能用 unique_ptr 包装。但日常开发里,make_unique + 默认删除器覆盖了绝大多数场景,自定义删除器属于”遇到了才用”。
总结:unique_ptr 用”拷贝删除 + 移动转移”表达独占所有权,让动态对象的释放由栈生命周期自动保证——创建用 make_unique,借用用 get(),转移用 std::move,从根上消灭 double free 和泄漏。
四、shared_ptr与引用计数
#include <memory>
// 创建(推荐 make_shared,理由见 46 篇)std::shared_ptr<Widget> a = std::make_shared<Widget>();
// 拷贝:共享所有权,引用计数 +1std::shared_ptr<Widget> b = a; // a、b 都指向同一个 Widget
a.reset(); // a 放手,计数 -1(还剩 b 一个持有者,对象还活着)b.reset(); // b 放手,计数归零 → 此刻 Widget 才被销毁shared_ptr 可以拷贝——这正是它和 unique_ptr 的本质区别。每拷贝一次,内部控制块里的引用计数加一;每析构/reset 一次,减一;减到零,销毁对象。
他比unique指针贵,因为每个指针存在一个计数块,也占用着内存。
胖狗ps:这不就python里的引用计数吗?没人计数就销毁
标准库要求 use_count() 是原子的,所以跨线程拷贝、销毁 shared_ptr 是安全的。但这里有个新手最容易误会的点,得讲清楚:
引用计数是线程安全的,指向的对象不是。
std::shared_ptr<Counter> sp = std::make_shared<Counter>();
// 线程 1auto copy1 = sp; // 计数原子 +1,安全// 线程 2auto copy2 = sp; // 计数原子 +1,安全
// 但两个线程同时读写 *sp 指向的 Counter 对象——不安全,要自己加锁引用计数增减是原子的(内部用原子操作),所以”多个线程各自持有 shared_ptr 拷贝”不会踩坏计数。但 *sp 指向的业务对象并没有被魔法地变安全,该加锁还是加锁。shared_ptr 管的是”什么时候销毁”,不是”怎么并发访问”。
shared_Ptr和unique_Ptr转换
#include <memory>
// unique_ptr → shared_ptr:允许(独占转共享,天然兼容)std::unique_ptr<Widget> up = std::make_unique<Widget>();std::shared_ptr<Widget> sp = std::move(up); // up 变空,sp 接管
// shared_ptr → unique_ptr:不允许(无法转)// std::unique_ptr<Widget> up2 = sp; // 编译错误:共享转独占有风险为什么反向不行?因为 shared_ptr 可能有多个持有者,把它”降级”成 unique_ptr 意味着断言”只有我一个持有者”,语言层面无法保证。所以这个方向被禁止。工程里常见模式是:内部用 unique_ptr 持有,跨接口传递时转成 shared_ptr。
别名构造函数与浅拷贝陷阱
shared_ptr 有个容易踩的坑:用 new 从已有对象的指针构造新 shared_ptr,会再开一块控制块,把同一个裸指针交给两套计数:
Widget* raw = new Widget();
std::shared_ptr<Widget> a(raw);std::shared_ptr<Widget> b(raw); // 危险!a、b 各有各的计数
// a 析构 → 计数归零 → delete raw// b 析构 → 计数归零 → 再次 delete raw → 双重释放!正确做法:让所有共享者从同一个 shared_ptr 拷贝,不要各自从裸指针构造。判断标准很简单——一个对象只能有一个控制块。make_shared 从源头规避这个问题,因为它一次把对象和控制块都建好,不会给你碰裸指针的机会。
循环引用
引用计数有个先天缺陷:循环引用时,计数永远减不到零。
#include <memory>
struct Node { std::shared_ptr<Node> next; // ...};
// 构造一个环:a → b → aauto a = std::make_shared<Node>();auto b = std::make_shared<Node>();a->next = b;b->next = a;
// a、b 离开作用域后……// a 的计数:b->next 还指着它,计数 1// b 的计数:a->next 还指着它,计数 1// 计数永远到不了 0 → 两个对象泄漏a 和 b 互相持有,谁都不是”最后一个”,于是谁都不销毁。这就是 shared_ptr 的经典命门。解决办法有两个方向:
- 打破环:其中一边改用
weak_ptr - 重构设计:让环变成单向依赖,或由外部容器统一管理生命周期
weak_ptr 就是为这个问题生的——它”不增加引用计数”,专门用来打破循环。
shared_ptr 不是免费的:
| 开销 | unique_ptr | shared_ptr |
|---|---|---|
| 内存 | 和裸指针一样大 | 多一块控制块(计数+元数据) |
| 拷贝 | 禁止(移动 O(1)) | 原子计数增减,比裸指针拷贝贵 |
| 创建 | make_unique 无额外分配 | make_shared 一次分配;new+构造两次分配 |
| 适用 | 独占 | 真正需要共享时 |
五、weak_ptr与循环引用
weak_ptr
一句话:weak_ptr 是一个”不拥有对象”的 shared_ptr。
它可以指向 shared_ptr 管理的对象,但自己不计数。对象活没活,不由它决定;它只是”看着”。看代码:
#include <memory>
std::shared_ptr<Widget> sp = std::make_shared<Widget>();std::weak_ptr<Widget> wp = sp; // 指向 sp 管理的对象,但不增加计数
std::cout << sp.use_count(); // 还是 1(wp 不计入)创建 weak_ptr 必须从一个 shared_ptr 构造(或者从另一个 weak_ptr 拷贝),它没有 make_weak 这样的工厂——因为它自己不拥有对象,谈不上”创建”。
如何使用:lock与expired
weak_ptr 不能直接解引用——它不拥有对象,对象可能已经死了,*wp 会解引用悬垂指针。标准用法是先用 lock() 尝试”提升”为 shared_ptr:
std::shared_ptr<Widget> sp = std::make_shared<Widget>();std::weak_ptr<Widget> wp = sp;
// ... 其它代码,sp 可能已经 reset ...
if (std::shared_ptr<Widget> tmp = wp.lock()) { // lock() 成功:对象还活着,tmp 是安全的 shared_ptr tmp->do_something();} else { // 对象已被销毁 std::cout << "对象已不在了\n";}lock() 的语义:如果对象还活着,返回一个指向它的 shared_ptr(计数 +1);如果已销毁,返回空 shared_ptr。这个”尝试提升”是原子的——不会出现”检查时活着、用的时候刚被销毁”的竞态。
expired() 是”对象是否已销毁”的检查:
if (wp.expired()) { // 对象已销毁}但注意:expired() 只做检查,拿到结果之后对象可能立刻被销毁(别的线程 reset 了最后的 shared_ptr)。所以”检查后再用”的模式是错的,正确姿势永远是 lock() 一步到位。
如何打破循环引用
#include <memory>
struct Node { std::shared_ptr<Node> next; // 关键:把环的一边换成 weak_ptr};
auto a = std::make_shared<Node>();auto b = std::make_shared<Node>();
// 传统写法会泄漏,但这里我们换成:std::shared_ptr<Node> a_ptr = a;std::weak_ptr<Node> b_weak = b;// 构造环:a 持有 b 的 shared_ptr,b 的 next 指向 a 用 weak// (示意:真正的环构造见下文)更标准的例子是父子关系——孩子持有指向父母的”弱引用”:
struct Parent;struct Child { std::weak_ptr<Parent> parent; // 子→父 用 weak,打破环};
struct Parent { std::shared_ptr<Child> child; // 父→子 用 shared(拥有)};
// 使用时:lock() 提升void Child::greet() { if (auto p = parent.lock()) { p->hello(); // 父还活着,安全调用 }}设计原则:“拥有关系”用 shared_ptr,“借用关系”用 weak_ptr。父拥有子,子不拥有父,只借用——这个方向性一旦定好,环自然就断了。反过来说,如果两边都互称”拥有”,那就不是指针的问题,是设计本身有环,该重构。
weak不能做的三件事
新手容易把 weak_ptr 当万能钥匙,列出边界:
- 不能解引用:
*wp、wp->都不行(编译器报错)。要用必须lock() - 不能构造新对象:没有
make_weak,因为它不拥有 - 不延长生命周期:
use_count()不算它,对象销毁与否与它无关

用武之地
工程里 weak_ptr 的典型用武之地:
- 打破循环引用:树/图结构中子引用父,缓存中条目引用”拥有者”
- 观察者/监听器注册表:管理器持有订阅者的 weak_ptr,订阅者挂了自动失效,避免悬垂
- 缓存:缓存不拥有对象,只记录”这个 key 对应哪个对象”,对象被别处销毁后缓存自动失效
- 线程任务句柄:主线程持有任务的 shared_ptr,任务内部持有主线程的 weak 引用检查主线程是否还活着
大部分业务代码其实用不到 weak_ptr——它是”补丁型”工具,遇到循环引用或者”想引用但不拥有”的场景才出手。平时能用 unique_ptr 就用 unique_ptr,要共享再上 shared_ptr,真要弱引用才轮到 weak_ptr。
总结:weak_ptr 是不增加引用计数的观察者,专治 shared_ptr 的循环引用——lock() 原子提升为 shared_ptr 才能安全使用,expired() 判断存活,核心口诀是”拥有用 shared,借用用 weak”。
六、make_unique/make_shared
为什么不推荐直接用new
#include <memory>
// 写法 A:new + 构造函数(能用,但有隐患)std::unique_ptr<Widget> a(new Widget(1, 2));
// 写法 B:make_unique(推荐)auto a = std::make_unique<Widget>(1, 2);
// shared_ptr 同理auto b = std::make_shared<Widget>(1, 2);工厂函数的参数直接转发给 Widget 的构造函数——make_unique<Widget>(1, 2) 里的 (1, 2) 就是 Widget 构造函数的参数。写法更简洁,auto 自动推导类型,少敲一遍 Widget。
写法A还有一个异常安全的坑
void process(int x) { // 假设 f() 和 g() 都可能抛异常 std::shared_ptr<Widget> sp(new Widget(f(), g()));}C++ 里”实参求值”的顺序:new Widget(f(), g()) 要先把 f()、g() 的结果算出来,再构造 Widget,再把裸指针交给 shared_ptr。如果求值顺序是:
new分配内存f()抛出异常 →new的结果还没交给shared_ptr,裸指针泄漏!
在 C++17 之前,f() 和 g() 的求值顺序甚至没被标准规定(旧标准的未指定行为),有的编译器先算 g() 有的先算 f(),这个泄漏是”编译器相关”的——换个编译器行为就变。C++17 起求值顺序有了一些保证,但 new + 裸指针交接的窗口期问题依然存在。
make_shared 就完全没这个问题:
std::shared_ptr<Widget> sp = std::make_shared<Widget>(f(), g());// f() 或 g() 抛异常 → 尚未创建任何对象,nothing to leak工厂函数把”分配 + 构造 + 交给智能指针”合并成一个原子操作,中间没有裸指针暴露的窗口,异常安全从根上保证。
性能上:对比直接new,make_shared少一次内存分配
shared_ptr 从 new 构造时,需要两次内存分配:一次给 Widget 对象,一次给引用计数控制块。

make_shared 把对象和控制块放进同一次分配(连续内存),省一次 malloc,还顺便提高了缓存局部性——对象和控制块挨着,访问计数时大概率命中缓存。make_unique 没有控制块,所以没有”省分配”一说,它纯粹是异常安全和语法简化的价值。
再补一个 make_shared 的副作用:因为对象和控制块在一起,只有所有 weak_ptr 也都销毁后,这块内存才整体释放。也就是说,存在 weak_ptr 时,make_shared 的对象内存可能”晚释放”一点(控制块在,对象内存就在)。绝大多数场景无感知,但如果你有超长生命周期的 weak_ptr 挂着一个巨大的对象,可以改用 shared_ptr<T>(new T(...)) 分离生命周期——这是”用 new 的合理理由”之一。
对比
| 维度 | make_unique / make_shared | new + 构造函数 |
|---|---|---|
| 异常安全 | 原子,无泄漏窗口 | 参数求值期可能泄漏 |
| 语法 | 简洁,auto 推导 | 类型写两遍 |
| 分配次数 | make_shared 一次 | shared_ptr 两次 |
| 控制块位置 | 与对象连续 | 独立 |
| 自定义删除器 | 不支持 | 支持 |
| 对象存活期独立 | make_shared 弱指针延迟释放 | 可独立 |
什么时候不用工厂函数,用new
工厂函数不是万能的,有几个场景绕不开 new:
1. 自定义删除器
// 自定义删除器只能配 newstd::shared_ptr<FILE> fp(fopen("a.txt", "r"), [](FILE* f){ fclose(f); });// make_shared 没有自定义删除器的版本2. 需要裸指针参与构造(比如已有的 Widget* 要转成 shared_ptr)
3. 需要分离对象与控制块的生命周期(上面说的内存大对象 + 长命 weak_ptr 场景)
4. C++11 之前的编译器(make_unique 是 C++14 加的,make_shared 是 C++11 就有)
标准库在 C++14 才加 std::make_unique(C++11 只有 make_shared)。
总结:make_unique/make_shared 用”分配 + 构造 + 交给智能指针”的原子化消灭了 new 的异常安全窗口,make_shared 还少一次内存分配;默认一律用工厂函数,只有自定义删除器、裸指针转智能指针、分离生命周期这三种情况才回退到 new。
七、智能指针选型
大佬给了个决策树

翻译成一句话就是:先问要不要拥有,再问几个人拥有,最后问有没有环。
- 独占、生命周期清晰 →
unique_ptr - 多持有者共享 →
shared_ptr - 共享但存在环 / 只想观察不想拥有 →
weak_ptr - 不拥有、只是借用 → 引用或裸指针
对比:
| 维度 | unique_ptr | shared_ptr | weak_ptr |
|---|---|---|---|
| 所有权 | 独占 | 共享(引用计数) | 无(观察者) |
| 拷贝 | 禁止 | 允许(计数+1) | 允许(不计入) |
| 移动 | 允许(O(1)) | 允许(O(1)) | 允许 |
| 析构触发销毁 | 是 | 计数归零时 | 从不 |
| 内存开销 | ≈裸指针 | 多一块控制块 | 多一块控制块(弱引用) |
| 线程安全 | 无计数,无需原子 | 计数原子 | 计数原子 |
| 创建 | make_unique | make_shared | 从 shared_ptr 构造 |
| 典型场景 | 独占资源、工厂返回 | 缓存、共享配置 | 破环、观察者 |
开销成本

unique_ptr 是真正的零开销抽象——大小和裸指针一样,速度一样,还白送自动释放。所以它是默认选择。shared_ptr 的拷贝是原子操作,高频路径(比如每次循环拷贝)要注意。weak_ptr 的 lock() 也涉及原子操作,不要在大循环里反复 lock——先提升一次,循环外用。
裸指针还是路边吗?
它在三种情况里依然是正确选择:
1. 不拥有、纯借用的函数参数
void render(const Widget& w); // 引用优先void draw(Widget* w); // 允许为空时用裸指针// 函数不拥有、不释放参数 → 不需要智能指针2. 跨 C 接口边界(调用 C 库、被 C 回调时,只能用裸指针/句柄)
3. 性能极端敏感且生命周期有严格保证(游戏引擎、内核这类场景)
判断原则:裸指针 ≠ 坏,裸指针 + 说不清谁拥有 = 坏。只要”谁拥有、谁释放”在白纸黑字上写清楚了,裸指针完全 OK。智能指针的意义不是消灭裸指针,而是把所有权语义从”注释”升级成”类型”。
工业速查
几条实战经验,直接抄作业:
- 默认用
unique_ptr,升级到shared_ptr必须有明确理由(真有多持有者) - 创建一律
make_unique/make_shared,除非有自定义删除器或分离生命周期需求 - 函数形参:只读用
const Widget&,可空用Widget*,转移所有权用unique_ptr<Widget>(按值/按移动),共享用shared_ptr<Widget>(按值拷贝) - 函数返回值:独占用
unique_ptr<Widget>,共享用shared_ptr<Widget>,都不要返回裸指针 - 容器存多态:
std::vector<std::unique_ptr<Base>>(31 篇切片陷阱的标准解法) - 看到
shared_ptr先想:这个共享真的必要吗?能不能用unique_ptr+ 引用? - 循环引用:双向关系必有一边
weak_ptr,“拥有用 shared、借用用 weak” - 别混用:同一对象不要既用
unique_ptr又用shared_ptr管理(各自计数,双释放)
八、nullptr与NULL
NULL历史问题
C 里 NULL 是宏,标准允许它定义为 0 或 (void*)0。C++ 里为了兼容,通常定义为 0 或 0L——反正就是整数 0。问题就出在这:NULL 是个整数,不是指针。
void f(int);void f(const char*);
f(NULL); // 调用的是 f(int) 还是 f(const char*)?在典型实现里,NULL 就是整数 0,所以重载决议把 f(NULL) 解析成 f(int)——跟调用者的直觉完全相反!写代码的人心里想的是”传空指针”,编译器却调了整数版本。这就是 NULL 的类型缺陷:它没有指针类型,无法参与指针相关的重载决议。
c++11因此引入了nullptr
void f(int);void f(const char*);
f(nullptr); // 明确调用 f(const char*),零歧义nullptr 能隐式转换成任何指针类型(int*、char*、void*、成员指针……),但不能转换成整数。所以上面重载场景下,编译器只能选指针版本。
三种写法都正确下面
int* p1 = nullptr;int* p2 = NULL; // 兼容老代码int* p3 = 0; // 也能编译
if (p1) { /* 非空 */ }if (!p1) { /* 为空 */ }if (p1 == nullptr) { /* 为空 */ }新代码统一用 nullptr:赋值、比较、初始化都写它。if (p) / if (!p) 的隐式 bool 转换依然是标准姿势,不用写成 if (p != nullptr)(后者也没错,只是啰嗦)。
nullptr在模板
nullptr 的另一个用武之地是模板/泛型代码。写模板时经常要表示”空值”,如果写 0,类型推导可能推导成 int;写 nullptr,推导出 std::nullptr_t,能干净地转成需要的指针类型:
九、lambda入门
最简形态
#include <algorithm>#include <vector>
std::vector<int> v{3, 1, 4, 1, 5};
// 按降序排序:lambda 作为比较器std::sort(v.begin(), v.end(), [](int a, int b) { return a > b;});拆开看这个 lambda:[](int a, int b) { return a > b; }
| 片段 | 含义 |
|---|---|
[] | 捕获列表(这里为空:不捕获外部变量) |
(int a, int b) | 参数列表(和普通函数一样) |
{ return a > b; } | 函数体 |
也可以给他赋值当正常函数用
auto add = [](int a, int b) { return a + b; };int sum = add(3, 4); // 7auto 承接 lambda 的类型——注意这里不能用 auto f = ... 之外的写法,因为每个 lambda 的类型都是独一无二的(编译器生成的匿名类),你写不出它的类型名。
捕获列表
普通函数只能用自己的参数,而 lambda 可以捕获定义它的作用域里的变量:
#include <iostream>
int main() { int base = 10; auto add_base = [base](int x) { // 按值捕获 base return x + base; }; std::cout << add_base(3); // 13
int counter = 0; auto inc = [&counter]() { counter++; }; // 按引用捕获 counter inc(); std::cout << counter; // 1}捕获列表有几种写法:
| 写法 | 含义 |
|---|---|
[] | 不捕获任何外部变量 |
[base] | 按值捕获 base(拷贝) |
[&base] | 按引用捕获 base |
[=] | 按值捕获所有用到的外部变量 |
[&] | 按引用捕获所有用到的外部变量 |
[=, &out] | 默认按值,out 例外按引用 |
[this] | 捕获当前对象(成员函数里用 |
两个注意点:
- 按值捕获是拷贝:lambda 内拿到的是副本,改它不影响原变量(除非用
mutable,那属于进阶) - 按引用捕获要小心生命周期:lambda 活多久,引用就必须有效多久。把捕获了引用的 lambda 存到容器里、而引用的变量已经销毁,就是悬垂引用
泛型lambda与尾置返回类型
泛型 lambda(C++14):参数可以用 auto,让编译器推导类型:
auto max_of = [](auto a, auto b) { return a > b ? a : b; };std::cout << max_of(3, 4); // 整数std::cout << max_of(3.14, 2.7); // 浮点尾置返回类型:函数体比较长、或者要强制指定返回类型时用 ->:
auto divide = [](int a, int b) -> double { // 明确返回 double return static_cast<double>(a) / b;};多数场景不写返回类型(编译器从 return 推导),但”函数体没有 return 却想返回 void 之外的”这类边界情况,就需要显式声明
总结:lambda 是 C++11 引入的就地匿名函数对象,语法 [捕获](参数) { 函数体 },捕获列表让它能带状态,类型由编译器生成所以能完美内联——它是现代 C++ 里”把行为当值传递”的标准写法,算法库的回调几乎都被它承包了。
十、constexpr变量
const是不允许改,constexpr是不允许加上编译器就得算出来
先看最简单的形式——constexpr 变量,要求初始化表达式必须在编译期可求值:
constexpr int kMaxSize = 1024; // 字面量,OKconstexpr double kPi = 3.14159; // OKconstexpr int kDouble = kMaxSize * 2; // 由其它 constexpr 算出来,OK
int runtime_value() { return 42; }// constexpr int x = runtime_value(); // 编译错误!运行期才有的值constexpr 变量必须在编译期确定值,所以:
- 初始化必须是编译期常量(字面量、其它 constexpr、constexpr 函数调用)
- 它自动是
const(不能改),还能用作数组大小、模板参数、switch的 case 标签
int arr[kMaxSize]; // 数组大小(编译期必须知道)std::array<int, kMaxSize> a; // 模板参数(必须编译期常量)constexpr函数
C++11 的 constexpr 函数限制很多(函数体只能有一条 return),C++14 放宽到”函数体里能用局部变量、循环、if”,C++17/20 继续扩展。现在写起来已经比较自然:
// C++14 起:函数体可以是普通语句,但参数和返回必须是字面类型constexpr int square(int x) { return x * x;}
constexpr int factorial(int n) { int result = 1; for (int i = 2; i <= n; ++i) result *= i; return result;}
constexpr int kFiveFact = factorial(5); // 编译期就算好 = 120int n = 10;int runtime = factorial(n); // 运行期算(也能调用!)关键特性:constexpr 函数既能在编译期用,也能在运行期用。传入编译期常量,编译期求值;传入运行期变量,退化为普通函数调用。同一个函数,两头通吃。
区别:
| 维度 | const | constexpr |
|---|---|---|
| 核心语义 | 不可修改 | 编译期求值 |
| 修饰对象 | 变量、成员函数、参数 | 变量、函数、构造 |
| 值何时确定 | 可能运行期 | 必须编译期(或作为优化) |
| 能否作数组大小 | 不一定(运行期 const 不行) | 一定可以 |
| 能否作模板参数 | 不一定 | 一定可以 |
判断口诀:const 管”不能改”,constexpr 管”早点算”。二者不冲突——constexpr 变量隐含 const,const 变量不一定是 constexpr。
int get_value() { return 42; }
const int c1 = get_value(); // OK:运行期初始化,之后不能改// constexpr int c2 = get_value(); // 编译错误:get_value 不是 constexpr
constexpr int c3 = 42; // 编译期常量const int c4 = 42; // 也能用于常量表达式(优化场景)什么时候用
该用的场景:
- 常量定义:
kMaxSize、kPi这类语义上”永远不变”的值 - 编译期计算:数组大小、模板参数、查表初始化(比如编译期算好一张三角函数表)
- 性能敏感:热点路径里能提前算的都提前算
用了也白用的场景:
- 简单字面量:
constexpr int x = 1;和const int x = 1;编译产物没差别(都常量折叠) - 运行期才知道的值:别硬标 constexpr,编译器会拒绝
- 过度设计:函数很复杂、又只在运行期用,标 constexpr 增加编译负担却没收益
部分信息可能已经过时









