mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
7450 字
20 分钟
cpp重学(四)
2026-10-24

CPP重学笔记(四)#

学习笔记来自https://geek-doc.cn/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 和 .second
std::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); // 1
std::cout << std::get<1>(t); // hello
std::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++17
auto [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 和 d

pair/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,直译是”资源获取即初始化”。

把资源的生命周期,绑定到对象的生命周期上。

具体来说两句话:

  1. 资源在对象构造时获取(构造里打开文件、分配内存、加锁)
  2. 资源在对象析构时释放(析构里关闭文件、释放内存、解锁)

来看一个错误场景

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; // 自动释放,无泄漏
}
} // 离开作用域自动 delete

unique_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 现在是 nullptr
std::cout << *q; // q 持有 42

std::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>();
// 拷贝:共享所有权,引用计数 +1
std::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>();
// 线程 1
auto copy1 = sp; // 计数原子 +1,安全
// 线程 2
auto 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 → a
auto 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 的经典命门。解决办法有两个方向:

  1. 打破环:其中一边改用 weak_ptr
  2. 重构设计:让环变成单向依赖,或由外部容器统一管理生命周期

weak_ptr 就是为这个问题生的——它”不增加引用计数”,专门用来打破循环。

shared_ptr 不是免费的:

开销unique_ptrshared_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 当万能钥匙,列出边界:

  1. 不能解引用:*wp、wp-> 都不行(编译器报错)。要用必须 lock()
  2. 不能构造新对象:没有 make_weak,因为它不拥有
  3. 不延长生命周期:use_count() 不算它,对象销毁与否与它无关

b54625c724b9d1af1d74feca99d6797c3cacbce5

用武之地#

工程里 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。如果求值顺序是:

  1. new 分配内存
  2. 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 对象,一次给引用计数控制块。

9e9dfeda700a9383411e4c6170c49385d2891a18

make_shared 把对象和控制块放进同一次分配(连续内存),省一次 malloc,还顺便提高了缓存局部性——对象和控制块挨着,访问计数时大概率命中缓存。make_unique 没有控制块,所以没有”省分配”一说,它纯粹是异常安全和语法简化的价值。

再补一个 make_shared 的副作用:因为对象和控制块在一起,只有所有 weak_ptr 也都销毁后,这块内存才整体释放。也就是说,存在 weak_ptr 时,make_shared 的对象内存可能”晚释放”一点(控制块在,对象内存就在)。绝大多数场景无感知,但如果你有超长生命周期的 weak_ptr 挂着一个巨大的对象,可以改用 shared_ptr<T>(new T(...)) 分离生命周期——这是”用 new 的合理理由”之一。

对比

维度make_unique / make_sharednew + 构造函数
异常安全原子,无泄漏窗口参数求值期可能泄漏
语法简洁,auto 推导类型写两遍
分配次数make_shared 一次shared_ptr 两次
控制块位置与对象连续独立
自定义删除器不支持支持
对象存活期独立make_shared 弱指针延迟释放可独立

什么时候不用工厂函数,用new#

工厂函数不是万能的,有几个场景绕不开 new:

1. 自定义删除器

// 自定义删除器只能配 new
std::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。

七、智能指针选型#

大佬给了个决策树

5936ad9edc1f719dc4ab45e29f6a4a82094cdbf6

翻译成一句话就是:先问要不要拥有,再问几个人拥有,最后问有没有环。

  • 独占、生命周期清晰 → unique_ptr
  • 多持有者共享 → shared_ptr
  • 共享但存在环 / 只想观察不想拥有 → weak_ptr
  • 不拥有、只是借用 → 引用或裸指针

对比:

维度unique_ptrshared_ptrweak_ptr
所有权独占共享(引用计数)无(观察者)
拷贝禁止允许(计数+1)允许(不计入)
移动允许(O(1))允许(O(1))允许
析构触发销毁是计数归零时从不
内存开销≈裸指针多一块控制块多一块控制块(弱引用)
线程安全无计数,无需原子计数原子计数原子
创建make_uniquemake_shared从 shared_ptr 构造
典型场景独占资源、工厂返回缓存、共享配置破环、观察者

开销成本#

7bf5806546af0db2e92feaabb146f6da6d623409

unique_ptr 是真正的零开销抽象——大小和裸指针一样,速度一样,还白送自动释放。所以它是默认选择。shared_ptr 的拷贝是原子操作,高频路径(比如每次循环拷贝)要注意。weak_ptr 的 lock() 也涉及原子操作,不要在大循环里反复 lock——先提升一次,循环外用。

裸指针还是路边吗?#

它在三种情况里依然是正确选择:

1. 不拥有、纯借用的函数参数

void render(const Widget& w); // 引用优先
void draw(Widget* w); // 允许为空时用裸指针
// 函数不拥有、不释放参数 → 不需要智能指针

2. 跨 C 接口边界(调用 C 库、被 C 回调时,只能用裸指针/句柄)

3. 性能极端敏感且生命周期有严格保证(游戏引擎、内核这类场景)

判断原则:裸指针 ≠ 坏,裸指针 + 说不清谁拥有 = 坏。只要”谁拥有、谁释放”在白纸黑字上写清楚了,裸指针完全 OK。智能指针的意义不是消灭裸指针,而是把所有权语义从”注释”升级成”类型”。

工业速查#

几条实战经验,直接抄作业:

  1. 默认用 unique_ptr,升级到 shared_ptr 必须有明确理由(真有多持有者)
  2. 创建一律 make_unique / make_shared,除非有自定义删除器或分离生命周期需求
  3. 函数形参:只读用 const Widget&,可空用 Widget*,转移所有权用 unique_ptr<Widget>(按值/按移动),共享用 shared_ptr<Widget>(按值拷贝)
  4. 函数返回值:独占用 unique_ptr<Widget>,共享用 shared_ptr<Widget>,都不要返回裸指针
  5. 容器存多态:std::vector<std::unique_ptr<Base>>(31 篇切片陷阱的标准解法)
  6. 看到 shared_ptr 先想:这个共享真的必要吗?能不能用 unique_ptr + 引用?
  7. 循环引用:双向关系必有一边 weak_ptr,“拥有用 shared、借用用 weak”
  8. 别混用:同一对象不要既用 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); // 7

auto 承接 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]捕获当前对象(成员函数里用

两个注意点:

  1. 按值捕获是拷贝:lambda 内拿到的是副本,改它不影响原变量(除非用 mutable,那属于进阶)
  2. 按引用捕获要小心生命周期: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; // 字面量,OK
constexpr double kPi = 3.14159; // OK
constexpr 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); // 编译期就算好 = 120
int n = 10;
int runtime = factorial(n); // 运行期算(也能调用!)

关键特性:constexpr 函数既能在编译期用,也能在运行期用。传入编译期常量,编译期求值;传入运行期变量,退化为普通函数调用。同一个函数,两头通吃。

区别:

维度constconstexpr
核心语义不可修改编译期求值
修饰对象变量、成员函数、参数变量、函数、构造
值何时确定可能运行期必须编译期(或作为优化)
能否作数组大小不一定(运行期 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 增加编译负担却没收益
分享

如果这篇文章对你有帮助,欢迎分享给更多人!

cpp重学(四)
https://fatdog.20060113.xyz/posts/cpp-again4/
作者
神秘大胖狗
发布于
2026-10-24
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

封面
Sample Song
Sample Artist
封面
Sample Song
Sample Artist
0:00 / 0:00