楚天

惟楚有材,于斯为盛

Welcome to Hexo! This is your very first post. Check documentation for more info. If you get any problems when using Hexo, you can find the answer in troubleshooting or you can ask me on GitHub.

Quick Start

Create a new post

1
$ hexo new "My New Post"

More info: Writing

Run server

1
$ hexo server

More info: Server

Generate static files

1
$ hexo generate

More info: Generating

Deploy to remote sites

1
$ hexo deploy

More info: Deployment

现代 C++ 实践导读

时间:2026/05/08

这组笔记按“对象语义 -> 内存与所有权 -> 并发 -> 设计模式 -> 网络与协程 -> 常用工具 -> 泛型与编译期 -> 工程实践”的顺序整理。
不建议按最早写作顺序读,建议优先按下面的编号顺序走。


1. 推荐阅读顺序

  1. 对象生命周期、特殊成员函数与移动语义
  2. 智能指针与所有权
  3. allocator、自定义内存分配与 pmr
  4. 生产者-消费者模式与阻塞队列
  5. 线程同步消息队列与线程池
  6. 工厂模式、多态与接口设计
  7. 游戏常见设计模式
  8. 对象布局、栈堆与未定义行为
  9. 网络服务基础:TCP 粘包、线程模型与 HTTP(S)
  10. C++20 协程入门与实践
  11. 现代 C++ 常用工具类型
  12. ranges 与 views
  13. 错误处理与 expected、异常设计
  14. 内存泄漏检测与管理
  15. STL 容器、迭代器与算法实践
  16. 编译模型、链接与 CMake 入门
  17. 测试、调试与 Sanitizer 工具链
  18. const 正确性、API 设计与现代属性
  19. constexprconsteval 与编译期计算实践
  20. C++20 concepts 与泛型接口约束
  21. 常用标准库组件:formatchronofilesystemsource_location
  22. 依赖管理与包管理:FetchContent、vcpkg、Conan

2. 这次整理做了什么

这套笔记经历了两轮整理:

  1. 把错名、重复和草稿笔记重构成清晰主题
  2. 把空白或过短内容补成可复习版本
  3. 新增一篇缺失但非常常用的工具类型笔记
  4. 补上了 ranges/views、错误处理设计,以及内存泄漏检测与管理这三块现代 C++ 高频主题
  5. 补上了 STL 容器算法、编译链接/CMake、测试调试/Sanitizer 这三块工程实践基础
  6. 补齐 API 设计、编译期计算、concepts、常用标准库组件、依赖管理这五块后续缺口,并尽量配了可直接复习的代码示例

3. 如果时间有限

优先看这 10 篇:

  1. 生命周期、特殊成员函数与移动语义
  2. 智能指针与所有权
  3. STL 容器、迭代器与算法实践
  4. 现代 C++ 常用工具类型
  5. 错误处理与 expected、异常设计
  6. const 正确性、API 设计与现代属性
  7. constexprconsteval 与编译期计算实践
  8. C++20 concepts 与泛型接口约束
  9. 编译模型、链接与 CMake 入门
  10. 测试、调试与 Sanitizer 工具链

这几篇最直接影响现代 C++ 的工程写法。之后再看线程池、协程、ranges、网络和包管理。


4. 后续还可以补的主题

这次已经补齐了 API 设计、编译期计算、concepts、常用标准库组件和依赖管理。
如果继续扩展,比较值得补的是:

  1. 模块化与 C++20 modules 实践
  2. 完美转发、引用折叠与泛型工厂
  3. 类型擦除:std::function、自定义 type erasure 与多态替代
  4. ABI 稳定性、PImpl 与动态库接口设计
  5. 性能基准测试:Google Benchmark、profiling 与性能回归
  6. 日志、配置、序列化等常见工程基础设施设计

5. 按实例复习的路线

如果想边看边练,可以优先按这些例子复习:

  1. 生命周期实例:手写资源类、Rule of Five、返回值优化
  2. 所有权实例:unique_ptr/shared_ptr/weak_ptr、自定义 deleter
  3. 并发实例:阻塞队列、线程池、停止协议、任务返回值
  4. 设计模式实例:注册表工厂、对象池、状态模式、命令模式
  5. 网络实例:长度前缀拆包器、I/O 线程 + 业务线程池分层
  6. 协程实例:最小 generator,理解 co_yield 和协程帧生命周期
  7. 泛型实例:concepts 约束 range、策略对象接口、静态多态
  8. 工程实例:CMake target、Sanitizer、FetchContent/vcpkg/Conan 依赖接入

高性能 C++ 并行编程导读

时间:2026/05/08

这组笔记现在按“从语言与内存基础,到 CPU 性能工程,再到并行框架、GPU 与实战”的顺序整理。
阅读时不建议完全按时间写作顺序看,而建议按下面这条主线走。


1. 推荐学习顺序

  1. C 指针与内存模型
  2. RAII 与智能指针
  3. 左右值、完美转发与引用折叠
  4. 模板与元编程
  5. 虚函数、多态与动态派发
  6. vector 容器优化
  7. std::pmr 与内存池
  8. 编译器优化与汇编视角
  9. 访存优化
  10. SIMD 与自动向量化
  11. Benchmark 与性能分析方法
  12. C++11 多线程编程
  13. C++20/23 并发工具
  14. 原子操作、内存序与无锁基础
  15. 标准库并行算法与执行策略
  16. TBB 并行编程
  17. NUMA 与多路 CPU 访存
  18. CUDA 开启的 GPU 编程
  19. 流体仿真实战

2. 每篇笔记解决什么问题

编号 主题 核心问题
[01] C 指针与内存模型 地址、生命周期、数组退化、别名与缓存直觉
[02] RAII 与智能指针 资源如何自动释放,所有权如何表达
[03] 左右值、完美转发与引用折叠 std::move / std::forward 到底在做什么
[04] 模板与元编程 泛型、类型推导、if constexpr、traits、concepts
[05] vector 容器优化 连续内存、扩容、失效规则、并发写入模式
[06] 编译器优化与汇编视角 编译器如何重写 C++,哪些写法更利于优化
[07] 访存优化 cache、AoS/SoA、stride、blocking、false sharing
[08] C++11 多线程编程 thread/mutex/condition_variable/future/thread pool
[09] 原子操作、内存序与无锁基础 atomic、CAS、acquire/release、seq_cst
[10] TBB 并行编程之旅 task、并行循环、归约、scan、arena、pipeline
[11] CUDA 开启的 GPU 编程 kernel、内存、shared memory、stream、Thrust
[12] 流体仿真实战 把 CUDA、访存、数值迭代放到一个完整案例里
[13] std::pmr 与内存池 memory_resource、arena、pool、资源生命周期与分配优化
[14] 虚函数、多态与动态派发 虚表直觉、虚析构、对象切片、动态派发成本与替代方案
[15] SIMD 与自动向量化 数据并行、循环依赖、别名、对齐、SoA、编译器向量化报告
[16] Benchmark 与性能分析方法 benchmark、profiling、火焰图、硬件计数器、性能优化闭环
[17] NUMA 与多路 CPU 访存 本地/远端内存、first touch、线程亲和性、跨 socket 扩展性
[18] C++20/23 并发工具 jthread/stop_token/latch/barrier/semaphore/atomic::wait
[19] 标准库并行算法与执行策略 std::executionpar/par_unseqreduce/scan/transform_reduce

3. 这次整理做了什么

这套笔记经历了两轮整理:

  1. 把空白或草稿笔记补成正式版:
    C 指针RAII编译器优化访存优化流体仿真
  2. 新增了几篇缺失但关键的笔记:
    原子操作、内存序与无锁基础std::pmr 与内存池虚函数、多态与动态派发
  3. 补齐 CPU 性能工程和现代并行主题:
    SIMD 与自动向量化Benchmark 与性能分析方法NUMA 与多路 CPU 访存C++20/23 并发工具标准库并行算法与执行策略
  4. 统一为按内容排序的 [NN]xxx.md 命名

4. 如果只想抓主干

时间有限时,可以先看这 10 篇:

  1. C 指针与内存模型
  2. RAII 与智能指针
  3. 左右值、完美转发与引用折叠
  4. 模板与元编程
  5. vector 容器优化
  6. 编译器优化与汇编视角
  7. 访存优化
  8. SIMD 与自动向量化
  9. Benchmark 与性能分析方法
  10. C++11 多线程编程

这几篇能先把 C++ 高性能和并行里最常见的基础坑位补齐。之后再看 原子操作标准库并行算法TBBCUDA流体仿真实战


5. 接下来的自然扩展

这次已经补齐了 SIMD、benchmark/profiling、NUMA、现代并发工具和标准库并行算法。
如果后面继续扩展,比较值得补的是:

  1. 严格别名规则、restrict 与 noalias 设计
  2. 无锁数据结构进阶:ABA、hazard pointers、epoch reclamation
  3. OpenMP / MPI 与跨节点并行
  4. CUDA 深入:occupancy、warp divergence、coalesced access、Nsight
  5. LTO / PGO / sanitizer / 性能回归测试的工程化
  6. 协程、异步 I/O 与 executors / sender-receiver 后续演进

6. 按实例复习的路线

如果想按代码例子复习,可以优先这样走:

  1. 资源管理实例:RAII、智能指针、自定义 deleter
  2. 容器实例:vector reserve/resize、迭代器失效、并行分区写入
  3. CPU 性能实例:顺序访问、AoS/SoA、blocking、false sharing、SIMD 自动向量化
  4. 测量实例:steady_clock、Google Benchmark、profiling 指标解读
  5. 并发实例:线程池、阻塞队列、原子发布/获取、jthread/barrier/semaphore
  6. 并行算法实例:parallel_forparallel_reducestd::execution::par
  7. GPU 实例:CUDA kernel、shared memory tile、stream 异步拷贝
  8. 综合实例:流体仿真里的 ping-pong buffer、压力迭代和 CUDA 数据流

allocator、自定义内存分配与 pmr 入门

时间:2026/04/09

关键词:std::allocatorallocator_traits、分配与构造分离、状态型分配器、std::pmr
核心目标:理解 STL 容器如何管理内存,以及什么时候值得定制分配器。


1. 为什么 allocator 存在

容器不仅要“放元素”,还要处理:

  • 申请原始内存
  • 在内存上构造对象
  • 销毁对象
  • 释放内存

标准库把这层职责抽象成 allocator。


2. std::allocator 的核心职责

可以粗略理解成四步:

  1. allocate(n):分配能容纳 n 个对象的原始内存
  2. construct(...):在指定位置构造对象
  3. destroy(...):调用析构
  4. deallocate(...):释放内存

现代实现中更常通过:

  • std::allocator_traits

来统一调度这些接口。


3. 为什么“分配”和“构造”是两件事

因为拿到一块内存,不等于对象已经存在。

1
2
T* p = alloc.allocate(4); // 只有内存
std::construct_at(p, value); // 这里对象才真正构造

这也是容器能高效管理未初始化存储区的基础。


4. 一个最小自定义分配器骨架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <memory>

template <class T>
struct MyAllocator {
using value_type = T;

MyAllocator() = default;

template <class U>
MyAllocator(const MyAllocator<U>&) {}

T* allocate(std::size_t n) {
return static_cast<T*>(::operator new(n * sizeof(T)));
}

void deallocate(T* p, std::size_t) {
::operator delete(p);
}
};

配合容器使用:

1
std::vector<int, MyAllocator<int>> v;

5. 什么时候值得自定义 allocator

不是所有项目都需要。

更常见的适用场景:

  • 频繁小对象分配
  • 想用内存池减少碎片
  • 想把对象放到特定区域
  • 需要统计分配行为
  • 游戏/服务端有帧级或 arena 分配需求

如果只是普通业务代码,标准分配器通常已经够用。


6. 状态型分配器

有些分配器不仅有类型,还有内部状态,例如:

  • 指向某个内存池
  • 指向某个 arena

这类分配器要特别注意:

  • 拷贝行为
  • 容器复制/移动时状态如何传播

这也是 allocator_traits 很重要的原因之一。


7. std::pmr:现代 C++ 更实用的内存资源抽象

C++17 提供了 std::pmr

  • polymorphic memory resource

它把“分配策略”从模板参数层搬到了运行时资源对象层。

最常见组件:

  • std::pmr::memory_resource
  • std::pmr::polymorphic_allocator
  • std::pmr::vector
  • std::pmr::monotonic_buffer_resource

这通常比手写 allocator 模板更实用。


8. monotonic_buffer_resource 的直觉

这种资源很适合:

  • 批量分配
  • 很少单独释放
  • 整体回收

例如一次请求、一次帧更新、一次解析过程。

好处:

  • 分配快
  • 局部性好

代价:

  • 单个对象通常不能灵活归还给池

9. 和容器性能的关系

allocator 影响的通常不是接口语义,而是:

  • 分配次数
  • 分配成本
  • 碎片
  • 局部性

但要注意:

  • allocator 优化通常排在算法和数据布局之后
  • 不要在没有证据前,把 allocator 当成首要瓶颈

10. 常见误区

10.1 把 allocator 当成“所有性能问题的解药”

很多性能问题其实更可能出在:

  • 数据布局
  • 扩容策略
  • 锁争用
  • 随机访问

10.2 只会写 allocate/deallocate,却不理解对象构造时机

allocator 真正重要的是:

  • 内存和对象是分开的

10.3 在没有统一资源模型时滥用自定义 allocator

结果容易让代码复杂度大幅上升。


11. 参考实例:用 pmr 做一次请求内存池

假设一次请求里要临时创建很多字符串和数组,请求结束后统一释放。
这时 monotonic_buffer_resource 很适合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <array>
#include <cstddef>
#include <iostream>
#include <memory_resource>
#include <string>
#include <string_view>
#include <utility>
#include <vector>

struct RequestData {
std::pmr::vector<std::pmr::string> headers;

explicit RequestData(std::pmr::memory_resource* mr)
: headers(mr) {}

void add_header(std::string_view key, std::string_view value) {
std::pmr::string line{headers.get_allocator().resource()};
line.append(key);
line.append(": ");
line.append(value);
headers.push_back(std::move(line));
}
};

void handle_request() {
std::array<std::byte, 4096> buffer{};
std::pmr::monotonic_buffer_resource arena{
buffer.data(),
buffer.size()
};

RequestData req{&arena};
req.add_header("Host", "example.com");
req.add_header("User-Agent", "demo-client");

for (const auto& h : req.headers) {
std::cout << h << "\n";
}

// handle_request 返回时,arena 整体释放本次请求的临时内存。
}

这种模式适合:

  • 请求级临时对象
  • 一帧游戏逻辑里的临时容器
  • 解析器中短生命周期 token

不适合:

  • 单个对象需要频繁独立释放
  • 容器或字符串要活得比 arena 更久

12. 一页总结

allocator 这篇最值得记住的是:

  1. 容器管理的是“原始内存 + 对象构造/销毁”
  2. allocator 负责内存来源
  3. allocator_traits 是现代实现的核心适配层
  4. 真正工程里,std::pmr 往往比手写 allocator 更实用

如果只记一句:

allocator 优化通常是进阶优化,前提是你已经把算法、数据布局和容器选择做对了。

工厂模式、多态与接口设计

时间:2026/04/09

关键词:抽象接口、虚函数、工厂函数、依赖倒置、override、虚析构、unique_ptr
核心目标:理解什么时候该把“创建对象”和“使用对象”分开。


1. 为什么需要工厂模式

很多代码的问题不是“不会 new”,而是:

  • 调用方知道太多具体类型
  • 构造逻辑散落各处
  • 后续替换实现很痛苦

工厂模式的核心价值是:

  • 把对象创建逻辑集中起来
  • 让调用方依赖抽象接口,而不是具体实现

2. 多态接口的基础

1
2
3
4
5
6
7
8
9
10
11
12
struct Pet {
virtual ~Pet() = default;
virtual void speak() = 0;
};

struct Cat : Pet {
void speak() override { std::puts("meow"); }
};

struct Dog : Pet {
void speak() override { std::puts("woof"); }
};

这里要点有两个:

  • 基类析构函数要么虚,要么不允许多态删除
  • 派生类重写时用 override

3. 一个最简单的工厂函数

1
2
3
4
5
6
7
8
#include <memory>
#include <string>

std::unique_ptr<Pet> make_pet(const std::string& kind) {
if (kind == "cat") return std::make_unique<Cat>();
if (kind == "dog") return std::make_unique<Dog>();
return nullptr;
}

调用方只关心:

  • 我要一个 Pet

而不关心:

  • 具体怎么构造 Cat / Dog

4. 为什么返回 unique_ptr

返回裸指针会引入一个问题:

  • 谁负责 delete

std::unique_ptr 更清晰:

  • 工厂负责创建
  • 调用方接管独占所有权

这是现代 C++ 工厂接口最常见的实践。


5. 简单工厂 vs 工厂方法

5.1 简单工厂

一个集中函数,根据参数分支创建对象。

优点:

  • 简单直接

缺点:

  • 新增类型时可能要改原工厂

5.2 工厂方法

把“创建哪种对象”交给子类。

适合:

  • 框架式扩展
  • 产品族较复杂

但对小项目来说,简单工厂已经够用。


6. 接口设计比模式名更重要

真正工程里,更该关注这些问题:

  • 基类是不是表达了稳定抽象
  • 调用方是否真的不需要知道具体类型
  • 返回所有权是否清晰
  • 是否需要注册表或插件化

很多时候模式本身不复杂,难的是接口边界。


7. 一个更贴近工程的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Reducer {
virtual ~Reducer() = default;
virtual int init() const = 0;
virtual int combine(int a, int b) const = 0;
};

struct SumReducer : Reducer {
int init() const override { return 0; }
int combine(int a, int b) const override { return a + b; }
};

struct MulReducer : Reducer {
int init() const override { return 1; }
int combine(int a, int b) const override { return a * b; }
};

这里“算法骨架”不变,“聚合策略”可替换。
这类设计常和工厂、策略模式一起出现。


8. 工厂模式的常见扩展

8.1 注册表工厂

把字符串或类型 id 映射到创建函数:

  • 更适合插件化
  • 更适合可扩展系统

8.2 抽象工厂

如果你需要创建一整组关联对象,就可能进入抽象工厂场景。

例如:

  • UI 皮肤
  • 跨平台组件族

9. 常见坑

9.1 基类没有虚析构

多态删除会出问题。

9.2 工厂返回裸指针

所有权不清晰。

9.3 为了“用模式而用模式”

小项目里过度抽象只会增加复杂度。

9.4 基类接口设计得太宽

会让派生类被迫实现很多并不需要的东西。


10. 参考实例:注册表工厂

当对象类型会扩展时,可以用“名字 -> 创建函数”的注册表。
调用方只依赖抽象接口,不需要知道具体类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <functional>
#include <memory>
#include <stdexcept>
#include <string>
#include <string_view>
#include <unordered_map>
#include <utility>

struct Shape {
virtual ~Shape() = default;
virtual double area() const = 0;
};

struct Circle : Shape {
explicit Circle(double r) : r(r) {}
double area() const override { return 3.14159 * r * r; }
double r = 1.0;
};

struct Square : Shape {
explicit Square(double side) : side(side) {}
double area() const override { return side * side; }
double side = 1.0;
};

class ShapeFactory {
public:
using Creator = std::function<std::unique_ptr<Shape>(double)>;

void register_type(std::string name, Creator creator) {
creators_[std::move(name)] = std::move(creator);
}

std::unique_ptr<Shape> create(std::string_view name, double arg) const {
auto it = creators_.find(std::string(name));
if (it == creators_.end()) {
throw std::runtime_error("unknown shape type");
}
return it->second(arg);
}

private:
std::unordered_map<std::string, Creator> creators_;
};

int main() {
ShapeFactory factory;

factory.register_type("circle", [](double r) {
return std::make_unique<Circle>(r);
});

factory.register_type("square", [](double side) {
return std::make_unique<Square>(side);
});

auto shape = factory.create("circle", 2.0);
double a = shape->area();
}

这个例子里:

  • 工厂集中管理创建逻辑
  • 返回 unique_ptr 表达所有权转移
  • 新类型只需要注册,不需要改调用方
  • 运行期根据字符串选择具体类型

11. 一页总结

工厂模式最核心的收益不是“设计模式名词”,而是:

  1. 创建逻辑集中
  2. 调用方依赖抽象
  3. 所有权表达清晰

如果只记一句:

当“对象怎么创建”开始影响“对象怎么使用”时,就该考虑把创建逻辑抽出来。

网络服务基础:TCP 粘包、线程模型与 HTTP(S)

时间:2026/04/09

关键词:TCP stream、粘包拆包、长度前缀、分隔符、线程池、epoll、TLS、wrk
核心目标:建立服务端编程的基本工程直觉,先把“消息边界、并发模型、协议分层”分清楚。


1. TCP 为什么会有“粘包”问题

因为 TCP 是字节流,不是消息流。

这意味着:

  • 发送端发了两次 send
  • 接收端不一定就对应收到两次 recv

接收端看到的只是连续字节流,所以必须自己定义消息边界。


2. 两种最常见的拆包方案

2.1 长度前缀

格式示例:

1
[4字节长度][payload]

接收端流程:

  1. 先读够头部
  2. 解析长度
  3. 再继续读够 payload

这通常是最通用、最可靠的做法。

2.2 分隔符协议

例如按 \n 分隔:

1
hello\nworld\n

优点:

  • 简单直观

缺点:

  • payload 中若可能出现分隔符,需要转义或编码

3. 长度前缀接收缓冲的核心思路

接收端通常需要一个累积缓冲区:

1
std::vector<std::uint8_t> inbuf;

每次 recv 到数据后:

  • 先追加到 inbuf
  • 再循环解析完整包

关键不是“每次只解析一个包”,而是:

  • 一次 recv 可能带来 0.5 个包、1 个包、或者多个包

4. 为什么不能假设一次 recv 就是一条完整消息

因为可能出现:

  • 半包
  • 多包合并
  • 多次拆散

所以网络编程第一条纪律就是:

任何协议都必须先定义并实现消息 framing。


5. 线程模型的几种常见选择

5.1 每连接一个线程

优点:

  • 思维简单

缺点:

  • 连接数一大就撑不住

5.2 单线程事件循环

优点:

  • 资源利用率高

缺点:

  • 业务阻塞会拖住整个 loop

5.3 Reactor + 线程池

常见工程做法:

  • I/O 线程负责收发和事件分发
  • 工作线程池处理业务

这样可以同时兼顾:

  • 网络扩展性
  • 业务并发

6. epoll / kqueue / IOCP` 在解决什么问题

这些机制本质上都在解决:

  • 如何高效等待大量连接上的 I/O 事件

Linux 上最常见的是:

  • epoll

它不是协议,也不是线程池,而是:

  • 一个事件通知机制

7. HTTP 和 HTTPS 不要混成一层

7.1 HTTP

应用层协议,定义:

  • 请求行
  • 头部
  • body

7.2 HTTPS

可以粗略理解成:

  • HTTP over TLS

也就是:

  • 先做 TLS 加密通道
  • 再在其上跑 HTTP

所以 HTTPS 的复杂度不仅来自 HTTP,还来自:

  • 握手
  • 证书
  • 加解密

8. 一个服务端最基础的工程分层

可以先按这几层理解:

  1. socket 层
  2. 缓冲区与 framing 层
  3. 协议解析层
  4. 业务处理层
  5. 线程模型 / 调度层

很多初学者的问题是把所有逻辑都揉进一次 recv 回调里,后期几乎无法维护。


9. 线程池在网络服务里的角色

网络服务中,线程池通常不应该负责:

  • 直接阻塞式读写所有 socket

更常见的是负责:

  • 处理业务任务
  • 数据库访问
  • CPU 密集计算
  • 日志/异步处理

也就是说:

  • I/O 线程负责“把事情收进来”
  • 线程池负责“把事情做完”

10. TLS / HTTPS 的最小认知

只要记住这几点就够做入门框架理解:

  • TLS 负责加密通道
  • 证书用于身份认证
  • HTTPS 不是“新的 HTTP 消息格式”,而是多了一层安全传输

工程上常见做法是:

  • 直接用成熟库处理 TLS
  • 不自己从零实现加密协议

11. 压测为什么重要

服务端“能跑”不等于“能扛”。
至少要关注:

  • QPS
  • 延迟
  • P99
  • 错误率
  • CPU / 内存占用

wrk 是一个常用 HTTP 压测工具,适合快速做吞吐和延迟观察。


12. 常见坑

12.1 把 TCP 当消息协议

这会直接导致粘包拆包错误。

12.2 收到数据就立刻假设包完整

半包是常态,不是例外。

12.3 网络线程直接做重业务

会拖慢整个事件循环。

12.4 自己手写 TLS 协议栈

不现实,也没必要。


13. 参考实例:长度前缀拆包器

TCP 是字节流,所以接收缓冲里可能一次来半包、多包或任意切分。
下面是一个最小长度前缀协议解析器:前 4 字节是大端 payload 长度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <cstdint>
#include <cstddef>
#include <optional>
#include <span>
#include <stdexcept>
#include <vector>

class FrameDecoder {
public:
void append(std::span<const std::byte> bytes) {
buffer_.insert(buffer_.end(), bytes.begin(), bytes.end());
}

std::optional<std::vector<std::byte>> next_frame() {
if (buffer_.size() < header_size) {
return std::nullopt;
}

std::uint32_t len = read_u32_be(buffer_.data());
if (len > max_frame_size) {
throw std::runtime_error("frame too large");
}

const std::size_t total = header_size + len;
if (buffer_.size() < total) {
return std::nullopt;
}

std::vector<std::byte> payload(
buffer_.begin() + header_size,
buffer_.begin() + static_cast<std::ptrdiff_t>(total)
);

buffer_.erase(
buffer_.begin(),
buffer_.begin() + static_cast<std::ptrdiff_t>(total)
);

return payload;
}

private:
static constexpr std::size_t header_size = 4;
static constexpr std::uint32_t max_frame_size = 1024 * 1024;

static std::uint32_t read_u32_be(const std::byte* p) {
return (std::uint32_t(std::to_integer<unsigned char>(p[0])) << 24) |
(std::uint32_t(std::to_integer<unsigned char>(p[1])) << 16) |
(std::uint32_t(std::to_integer<unsigned char>(p[2])) << 8) |
std::uint32_t(std::to_integer<unsigned char>(p[3]));
}

std::vector<std::byte> buffer_;
};

使用方式通常是:

1
2
3
4
5
6
7
8
FrameDecoder decoder;

// 每次 recv 得到一段 bytes 后:
decoder.append(bytes);

while (auto frame = decoder.next_frame()) {
handle_message(*frame);
}

关键点:

  • append() 不假设一次收到完整消息
  • next_frame() 可能返回空,表示还需要更多字节
  • 解出一帧后要从缓冲区移除已经消费的数据
  • 必须限制最大包大小,避免恶意长度导致内存暴涨

14. 一页总结

服务端编程最重要的理解链是:

  1. TCP 只有字节流,没有消息边界
  2. 所以必须先做 framing
  3. 高并发服务通常要把 I/O 与业务处理分层
  4. HTTPS = HTTP + TLS,不是单纯“更安全的 socket”

如果只记一句:

网络服务首先是“协议边界和并发模型”问题,其次才是 API 调用问题。

游戏常见设计模式

时间:2026/04/09

关键词:单例、状态模式、命令模式、观察者、组件化、对象池
核心目标:从游戏开发常见场景出发,理解哪些模式真的有用,以及哪些模式容易被滥用。


1. 游戏代码为什么特别容易模式化

游戏逻辑常见特点:

  • 实体多
  • 状态多
  • 事件多
  • 生命周期复杂
  • 性能敏感

因此很多经典模式在游戏里非常常见,但也特别容易被滥用。


2. 单例模式:能用,但要克制

单例通常用于:

  • 配置中心
  • 日志系统
  • 全局资源管理器

最简单安全的写法通常是局部静态:

1
2
3
4
5
6
7
8
9
10
class GameConfig {
public:
static GameConfig& instance() {
static GameConfig cfg;
return cfg;
}

private:
GameConfig() = default;
};

优点:

  • 简单
  • 线程安全初始化

缺点:

  • 全局依赖隐蔽
  • 测试困难
  • 生命周期难拆

所以经验上:

  • 单例适合少量基础设施,不适合把一切都做成全局对象

3. 状态模式:替代大 switch

当一个角色会在多种状态之间切换,例如:

  • Idle
  • Chase
  • Attack
  • Dead

如果全写在一个大 switch 里,代码会越来越乱。
状态模式的思路是:

  • 每个状态自己负责更新逻辑和转移条件
1
2
3
4
5
6
struct Monster;

struct State {
virtual ~State() = default;
virtual void update(Monster& m) = 0;
};

这样“状态行为”会比“状态枚举 + 大分支”更容易扩展。


4. 命令模式:把输入和行为解耦

适用场景:

  • 输入映射
  • 回放系统
  • AI 行为排队
  • 网络同步操作记录

基本思路:

  • 把“做什么”封装成命令对象
1
2
3
4
struct Command {
virtual ~Command() = default;
virtual void execute() = 0;
};

这样可以做到:

  • 排队执行
  • 延迟执行
  • 撤销/重放

5. 观察者 / 事件模式

适合:

  • UI 更新
  • 成就系统
  • 音效触发
  • 状态广播

思路是:

  • 某个系统发事件
  • 多个订阅者响应

优点:

  • 降低模块直接耦合

风险:

  • 调用链变隐蔽
  • 调试困难

所以事件系统要控制好:

  • 事件粒度
  • 生命周期
  • 订阅关系

6. 组件化 / ECS 思路

传统继承层级:

  • Monster -> BossMonster -> FlyingBossMonster -> ...

很容易爆炸。
组件化更偏向:

  • Position
  • Render
  • Physics
  • Health

通过组合形成实体能力。

这样更灵活,也更适合:

  • 数据驱动
  • 批量更新
  • SoA / cache 友好设计

7. 对象池:减少频繁分配

游戏里这些对象往往高频创建销毁:

  • 子弹
  • 粒子
  • 临时特效

如果每次都 new/delete,可能带来:

  • 分配开销
  • 碎片
  • 抖动

对象池的思路是:

  • 提前分配一批对象
  • 使用时取出
  • 用完后归还

但要注意:

  • 池化会增加状态管理复杂度
  • 不是所有对象都值得池化

8. 模板方法模式

适用于:

  • 主流程固定
  • 个别步骤由派生类决定

例如角色更新:

1
2
3
4
5
6
7
8
9
10
11
12
struct Character {
virtual ~Character() = default;
virtual void think() = 0;
virtual void move() = 0;
virtual void draw() = 0;

void update() {
think();
move();
draw();
}
};

这种模式的优点是流程稳定,但要避免基类职责过重。


9. 游戏开发里最常见的误区

9.1 什么都做成单例

最后会形成巨型全局依赖网。

9.2 继承层级过深

很多时候组合比继承更稳。

9.3 事件系统滥用

会让控制流难以追踪。

9.4 为了模式而模式

很多小项目只需要清晰结构,不需要把所有经典模式全搬进来。


10. 参考实例:对象池管理子弹

游戏里子弹、粒子、临时特效经常大量创建和销毁。
对象池可以把频繁分配变成复用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <optional>
#include <cstddef>
#include <vector>

struct Bullet {
bool active = false;
float x = 0.0f;
float y = 0.0f;
float vx = 0.0f;
float vy = 0.0f;
float life = 0.0f;
};

class BulletPool {
public:
explicit BulletPool(std::size_t capacity)
: bullets_(capacity) {}

Bullet* spawn(float x, float y, float vx, float vy) {
for (auto& b : bullets_) {
if (!b.active) {
b = Bullet{
.active = true,
.x = x,
.y = y,
.vx = vx,
.vy = vy,
.life = 2.0f,
};
return &b;
}
}

return nullptr; // 池满,可以选择丢弃或扩容
}

void update(float dt) {
for (auto& b : bullets_) {
if (!b.active) {
continue;
}

b.x += b.vx * dt;
b.y += b.vy * dt;
b.life -= dt;

if (b.life <= 0.0f) {
b.active = false;
}
}
}

const std::vector<Bullet>& bullets() const {
return bullets_;
}

private:
std::vector<Bullet> bullets_;
};

这个例子体现了对象池的几个取舍:

  • 分配次数少,运行时更稳定
  • 对象地址相对稳定
  • 需要显式维护 active 状态
  • 池满策略必须提前设计

11. 一页总结

游戏里真正常用、且值得优先掌握的几个模式是:

  1. 状态模式
  2. 命令模式
  3. 观察者 / 事件模式
  4. 组件化 / ECS
  5. 对象池

单例不是不能用,但一定要克制。

如果只记一句:

游戏模式的价值不在“名词多高级”,而在于它能不能降低复杂状态和高频对象管理的混乱度。

错误处理与 expected、异常设计

时间:2026/04/09

关键词:异常、noexceptstd::expectedoptional、错误传播、恢复性错误、编程错误
核心目标:建立一套工程上可执行的判断标准,知道什么时候该抛异常,什么时候该返回错误值或 expected


1. 错误处理不是“选一个 API”那么简单

错误处理真正要先回答的是:

  • 这是不是预期内会发生的失败
  • 调用方是否应该恢复
  • 失败信息需要多详细
  • 代码库是否接受异常

所以讨论异常和 expected 时,重点不是站队,而是:

  • 哪种语义更适合这一层接口

2. 先把失败分类型

最有用的分类通常是:

2.1 编程错误 / 违反前置条件

例如:

  • 越界
  • 非法状态
  • 不满足接口约束

这类错误通常不属于“正常业务失败”。

2.2 可恢复的业务失败

例如:

  • 解析失败
  • 文件不存在
  • 权限不足
  • 网络超时

调用方通常有机会决定下一步怎么做。

2.3 致命错误

例如:

  • 系统资源耗尽
  • 程序已进入不一致状态

3. 异常适合什么场景

异常最适合:

  • 错误很少发生
  • 一旦发生,需要沿调用栈自动展开
  • 局部函数不适合层层手动返回错误码

典型例子:

  • 构造函数失败
  • 资源获取失败
  • 深层调用链中的异常退出

4. expected 适合什么场景

std::expected<T, E> 更适合:

  • 失败是正常、可预期分支
  • 调用方需要显式处理失败
  • 你希望错误成为接口类型的一部分

例如:

  • 配置解析
  • 用户输入校验
  • 业务规则检查
  • 网络协议解析

5. optionalexpected 的区别

optional<T> 表示:

  • 可能有值,也可能没值

但它不告诉你:

  • 为什么没值

expected<T, E> 表示:

  • 要么有 T
  • 要么有错误 E

所以经验上:

  • 只有“有没有结果”时,用 optional
  • 需要表达“为什么失败”时,用 expected

6. 一个最直接的例子

1
2
std::optional<User> find_user(int id);
std::expected<User, ParseError> parse_user(std::string_view text);

前者表达“可能没有”,后者表达“失败且要知道原因”。


7. 什么时候不适合抛异常

7.1 失败是高频正常分支

7.2 热路径里非常在意开销和可预测性

7.3 跨 ABI / 跨模块边界不方便统一异常策略

7.4 团队整体约束就是禁用异常

这时更适合:

  • expected
  • error code
  • status object

8. 什么时候异常特别合理

8.1 构造函数失败

8.2 无法在每层都写样板检查

8.3 资源清理由 RAII 承担

异常和 RAII 配合得最好:

  • 抛出异常
  • 栈展开
  • 局部资源自动释放

9. 一个 expected 风格的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <expected>
#include <string_view>

enum class ParseError {
Empty,
InvalidNumber
};

std::expected<int, ParseError> parse_int(std::string_view s) {
if (s.empty()) {
return std::unexpected(ParseError::Empty);
}
int value = 0;
for (char ch : s) {
if (ch < '0' || ch > '9') {
return std::unexpected(ParseError::InvalidNumber);
}
value = value * 10 + (ch - '0');
}
return value;
}

10. 异常风格的例子

1
2
3
4
5
6
7
8
9
10
11
int parse_int_or_throw(std::string_view s) {
if (s.empty()) throw std::invalid_argument("empty");
int value = 0;
for (char ch : s) {
if (ch < '0' || ch > '9') {
throw std::invalid_argument("invalid number");
}
value = value * 10 + (ch - '0');
}
return value;
}

11. 一层系统里最好统一风格

最容易出问题的是混乱:

  • 一半函数抛异常
  • 一半函数返回错误码
  • 一半函数返回空值

更稳妥的做法是:

  • 每一层接口尽量有统一错误处理约定

12. noexcept 的意义

noexcept 表示:

  • 这个函数承诺不抛异常

它既是语义约束,也会影响某些容器对移动操作的选择。


13. 析构函数为什么通常不能抛

析构阶段如果异常继续外逃,尤其在栈展开过程中,会很危险。
工程上通常遵循:

  • 析构函数不要让异常逃出

14. 一个很实用的决策表

14.1 用异常

当失败:

  • 不常发生
  • 不适合做常规分支
  • 需要栈展开自动清理

14.2 用 expected

当失败:

  • 很常见
  • 需要显式处理
  • 希望错误成为接口类型的一部分

14.3 用 optional

当失败:

  • 只是“没有结果”
  • 不需要详细错误原因

15. 常见坑

15.1 用异常做普通循环分支

15.2 用 optional 隐藏真实错误信息

15.3 一个模块里混用三四种错误风格

15.4 给所有函数乱加 noexcept


16. 参考实例:expected 风格解析配置

当失败是正常分支,并且调用方需要知道原因时,可以把错误写进返回类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <expected>
#include <charconv>
#include <stdexcept>
#include <string>
#include <string_view>
#include <system_error>

enum class ParseError {
empty,
invalid_number,
out_of_range,
};

std::expected<int, ParseError> parse_port(std::string_view text) {
if (text.empty()) {
return std::unexpected(ParseError::empty);
}

int value = 0;
const char* first = text.data();
const char* last = text.data() + text.size();

auto [ptr, ec] = std::from_chars(first, last, value);
if (ec != std::errc{} || ptr != last) {
return std::unexpected(ParseError::invalid_number);
}

if (value <= 0 || value > 65535) {
return std::unexpected(ParseError::out_of_range);
}

return value;
}

void load_endpoint(std::string_view port_text) {
auto port = parse_port(port_text);
if (!port) {
report_parse_error(port.error());
return;
}

connect_to_port(*port);
}

同样的场景如果用异常,适合“失败少见、希望跨多层传播”的边界:

1
2
3
4
5
6
7
int parse_port_or_throw(std::string_view text) {
auto port = parse_port(text);
if (!port) {
throw std::runtime_error("invalid port");
}
return *port;
}

选择标准仍然是:

  • 高频可恢复失败:expected
  • 不常发生、跨层传播失败:异常
  • 只是没有值且不关心原因:optional

17. 一页总结

错误处理最重要的不是“异常 vs expected 谁更先进”,而是:

  1. 先判断失败是不是正常可恢复分支
  2. 再决定错误是否应该进入类型系统
  3. 再决定是否需要异常自动展开调用栈

可以直接记这条经验:

  • 不常发生、跨层传播、依赖 RAII 清理:优先考虑异常
  • 常见失败、需要显式处理、想把错误写进接口:优先考虑 expected

如果只记一句:

错误处理风格最怕的不是选错,而是同一层接口没有一致性。

C++20 协程入门与实践

时间:2026/04/09

关键词:co_awaitco_returnco_yieldpromise_type、挂起点、异步流程、generator
核心目标:理解协程为什么不是“更轻的线程”,而是一种把异步控制流写成顺序代码的语言机制。


1. 协程到底解决什么问题

传统异步代码常见两个问题:

  • 回调层层嵌套
  • 状态机代码难写难读

协程的核心价值是:

  • 把“会暂停、稍后恢复”的流程写成看起来接近顺序代码的形式

所以它更像:

  • 语言级状态机生成器

而不是线程替代品。


2. 协程不是线程

这点最重要。

协程:

  • 默认不并行
  • 默认不自动切线程
  • 只是能挂起和恢复

线程:

  • 是操作系统调度实体
  • 真正涉及并行执行

所以:

  • 协程解决的是“控制流组织”
  • 线程解决的是“执行资源”

3. 三个关键字

3.1 co_await

等待某个可等待对象,并可能挂起当前协程。

3.2 co_return

从协程返回结果。

3.3 co_yield

常用于生成器场景,逐个产出值。


4. 编译器视角下协程发生了什么

当函数里出现协程关键字后,它会被编译器改写成:

  • 一个协程状态对象
  • 一个 promise_type
  • 若干挂起点
  • 一个恢复入口

也就是说,协程本质上是:

  • 编译器自动帮你拆出来的状态机

5. promise_type 是什么

如果一个返回类型想作为协程返回对象,就需要配套定义:

  • promise_type

它负责描述:

  • 协程如何创建
  • 如何返回值
  • 初始/最终是否挂起
  • 异常怎么处理

这是 C++ 协程里最“底层”的部分。


6. 一个最小协程返回类型骨架

1
2
3
4
5
6
7
8
9
10
11
#include <coroutine>

struct Task {
struct promise_type {
Task get_return_object() { return {}; }
std::suspend_never initial_suspend() noexcept { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
};

这样一个返回 Task 的函数就可以写成协程。


7. co_await 的直觉

写:

1
co_await something;

编译器会尝试把 something 变成一个 awaitable,并调用:

  • await_ready()
  • await_suspend()
  • await_resume()

可以粗略理解为:

  1. 先问要不要挂起
  2. 如果挂起,如何安排恢复
  3. 恢复后返回什么结果

8. generator 场景为什么适合协程

例如想逐个生成值:

  • 传统写法要手工保存状态
  • 协程可以自然写成“产出一个,暂停,再继续”

这正是 co_yield 最直观的用法。

所以协程特别适合:

  • lazy sequence
  • parser
  • pipeline
  • 异步流

9. 协程最常见的工程用途

9.1 异步 I/O

把:

  • 发请求
  • 等待回包
  • 继续处理

写成线性流程。

9.2 生成器

按需逐个产出数据。

9.3 Actor / 任务系统

用协程表达暂停与恢复点。


10. 协程为什么很容易“看起来简单,实际上不简单”

因为源码很线性,但真实问题仍然存在:

  • 生命周期谁管
  • 在哪条线程恢复
  • 恢复时机谁触发
  • 异常怎么传播
  • 取消怎么处理

也就是说:

  • 协程简化的是控制流表达
  • 没有消灭异步系统的本质复杂度

11. 一个最重要的工程问题:恢复在哪发生

协程本身不决定线程。
真正决定“恢复在哪个执行器/事件循环/线程池”的,是 awaitable 或运行时框架。

所以写协程框架时一定要搞清楚:

  • 谁调 resume
  • 什么时候调
  • 在哪调

12. 常见坑

12.1 把协程当轻量线程

这是最常见误解。

12.2 忽略返回对象和 promise 生命周期

协程帧如果没人管理,容易泄漏或悬空。

12.3 只学语法,不理解恢复机制

这样很快就会在真实异步项目里迷路。

12.4 在协程里捕获悬空引用

因为协程可能挂起很久,引用生命周期尤其要小心。


13. 参考实例:最小 generator

下面这个例子展示 co_yield 如何把“逐个产出值”的逻辑写直。
它不是完整工业级 generator,但很适合理解协程帧、promise 和恢复过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
#include <coroutine>
#include <exception>
#include <iostream>
#include <utility>

class IntGenerator {
public:
struct promise_type {
int current = 0;

IntGenerator get_return_object() {
return IntGenerator{
std::coroutine_handle<promise_type>::from_promise(*this)
};
}

std::suspend_always initial_suspend() noexcept {
return {};
}

std::suspend_always final_suspend() noexcept {
return {};
}

std::suspend_always yield_value(int value) noexcept {
current = value;
return {};
}

void return_void() noexcept {}

void unhandled_exception() {
std::terminate();
}
};

explicit IntGenerator(std::coroutine_handle<promise_type> h)
: handle_(h) {}

IntGenerator(const IntGenerator&) = delete;
IntGenerator& operator=(const IntGenerator&) = delete;

IntGenerator(IntGenerator&& other) noexcept
: handle_(std::exchange(other.handle_, {})) {}

~IntGenerator() {
if (handle_) {
handle_.destroy();
}
}

bool next() {
if (!handle_ || handle_.done()) {
return false;
}

handle_.resume();
return !handle_.done();
}

int value() const {
return handle_.promise().current;
}

private:
std::coroutine_handle<promise_type> handle_;
};

IntGenerator range(int begin, int end) {
for (int i = begin; i < end; ++i) {
co_yield i;
}
}

int main() {
auto gen = range(3, 7);

while (gen.next()) {
std::cout << gen.value() << "\n";
}
}

这个例子里:

  • range() 调用后不会立刻跑完整循环
  • 每次 next() 都会 resume() 到下一个 co_yield
  • promise_type::current 保存本次产出的值
  • IntGenerator 析构时必须 destroy() 协程帧

14. 一页总结

协程最重要的理解链是:

  1. 协程不是线程,而是可挂起函数
  2. 编译器会把协程改写成状态机
  3. co_await 的核心是等待、挂起和恢复
  4. 真正工程难点在生命周期、调度器和恢复时机

如果只记一句:

协程的价值是把异步流程写直,而不是把并发问题自动解决掉。

const 正确性、API 设计与现代属性

时间:2026/05/08

关键词:const、引用、值传递、explicit[[nodiscard]]noexceptenum class、API 边界
核心目标:把“函数签名”写成清楚的契约,让调用者一眼知道所有权、可变性、失败方式和使用方式。


1. API 设计先看函数签名

函数签名不是只给编译器看的,它也是给人看的。

一个好的签名应该回答:

  1. 参数会不会被修改
  2. 参数会不会被保存
  3. 返回值是否必须检查
  4. 函数是否可能抛异常
  5. 构造是否允许隐式转换
  6. 调用者是否转移所有权

对比两个接口:

1
void parse(char* data, int len);

和:

1
[[nodiscard]] Config parse_config(std::string_view text);

第二个签名更清楚:

  • 输入只是观察,不拥有
  • 输入不会被修改
  • 返回值值得检查
  • 解析结果是一个明确对象

2. const 参数:表达“我不会改它”

读大对象时,优先用 const T&

1
2
3
4
5
6
7
8
struct User {
std::string name;
std::string email;
};

void print_user(const User& user) {
std::cout << user.name << " <" << user.email << ">\n";
}

如果写成值传递:

1
void print_user(User user);

会拷贝整个对象,通常没有必要。

如果参数很小,值传递更简单:

1
2
void set_retry_count(int n);
void set_timeout(std::chrono::milliseconds timeout);

经验:

  • 小标量:值传递
  • 大对象只读:const T&
  • 可空观察:const T*
  • 连续数组观察:std::span<const T>
  • 字符串观察:std::string_view

3. const 成员函数:承诺不改对象状态

成员函数后面的 const 表示不会修改这个对象的可观察状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Cache {
public:
std::size_t size() const {
return items_.size();
}

bool empty() const {
return items_.empty();
}

private:
std::vector<int> items_;
};

如果一个查询函数没写 const

1
std::size_t size();

那么 const Cache& 就不能调用它。
这会让 API 很难组合。


4. 什么时候用 mutable

mutable 用于“逻辑上不改变对象,但需要更新内部缓存”的场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Text {
public:
explicit Text(std::string s) : data_(std::move(s)) {}

std::size_t word_count() const {
if (!cached_) {
cached_words_ = count_words(data_);
cached_ = true;
}
return cached_words_;
}

private:
static std::size_t count_words(std::string_view text);

std::string data_;
mutable bool cached_ = false;
mutable std::size_t cached_words_ = 0;
};

注意:

  • mutable 不是绕过 const 的万能钥匙
  • 多线程读同一个对象时,mutable 缓存也需要同步
  • 如果缓存逻辑复杂,优先考虑显式构建缓存对象

5. 字符串参数优先考虑 std::string_view

只读、不保存字符串时:

1
2
3
void log_message(std::string_view msg) {
std::cout << msg << "\n";
}

它可以接收:

1
2
3
4
5
6
7
log_message("hello");

std::string s = "world";
log_message(s);

std::string_view v = "cpp";
log_message(v);

但不要保存 string_view 指向临时对象:

1
2
3
4
5
6
7
8
9
class Bad {
public:
void set_name(std::string_view name) {
name_ = name; // 危险:name 可能指向临时字符串
}

private:
std::string_view name_;
};

如果对象要保存字符串,应该拥有它:

1
2
3
4
5
6
7
8
9
class User {
public:
void set_name(std::string name) {
name_ = std::move(name);
}

private:
std::string name_;
};

6. 连续数组参数优先考虑 std::span

传统接口:

1
double average(const double* data, std::size_t n);

现代写法:

1
2
3
4
5
6
7
8
9
10
11
#include <span>
#include <numeric>

double average(std::span<const double> xs) {
if (xs.empty()) {
return 0.0;
}

double sum = std::accumulate(xs.begin(), xs.end(), 0.0);
return sum / static_cast<double>(xs.size());
}

可以接收:

1
2
3
4
5
6
7
std::vector<double> v{1.0, 2.0, 3.0};
std::array<double, 3> a{1.0, 2.0, 3.0};
double raw[] = {1.0, 2.0, 3.0};

average(v);
average(a);
average(raw);

span 只观察,不拥有。
不要让 span 活得比底层数组更久。


7. 值传递 + move:接收要保存的对象

如果函数要把参数保存到成员里,经常可以用值传递:

1
2
3
4
5
6
7
8
9
10
11
12
class Person {
public:
explicit Person(std::string name)
: name_(std::move(name)) {}

void rename(std::string name) {
name_ = std::move(name);
}

private:
std::string name_;
};

调用者传左值时会拷贝一次:

1
2
std::string n = "Alice";
Person p(n); // 拷贝进参数,再 move 到成员

调用者传右值时通常很高效:

1
2
Person p("Alice");
Person q(std::string("Bob"));

这比同时写 const std::string&std::string&& 两套重载更简单。


8. explicit:阻止意外隐式转换

单参数构造函数默认可能触发隐式转换:

1
2
3
4
5
6
7
8
9
10
11
class Port {
public:
Port(int value) : value_(value) {}

private:
int value_;
};

void connect(Port port);

connect(80); // 可以隐式把 int 转成 Port

很多时候这不是你想要的。
更推荐:

1
2
3
4
5
6
7
8
9
class Port {
public:
explicit Port(int value) : value_(value) {}

private:
int value_;
};

connect(Port{80}); // 调用者明确表达意图

经验:

除非你明确希望它能隐式转换,否则单参数构造函数优先写 explicit


9. [[nodiscard]]:返回值不能随手丢

错误处理或资源构造结果经常不能忽略:

1
2
3
4
5
6
7
enum class Error {
none,
file_not_found,
permission_denied,
};

[[nodiscard]] Error save_config(std::string_view path);

调用者如果丢掉返回值,编译器会提醒:

1
save_config("app.toml"); // 可能警告

更清晰的写法:

1
2
3
if (auto err = save_config("app.toml"); err != Error::none) {
report(err);
}

也可以标记类型:

1
2
3
4
5
6
struct [[nodiscard]] Result {
bool ok;
std::string message;
};

Result load_user(int id);

适合 [[nodiscard]] 的返回值:

  • 错误码
  • std::optional
  • std::expected
  • 资源句柄
  • 需要调用者继续使用的 builder 结果

10. noexcept:承诺不抛异常

noexcept 不只是优化提示,也是契约。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <cstddef>
#include <utility>

class Buffer {
public:
Buffer(Buffer&& other) noexcept
: data_(std::exchange(other.data_, nullptr)),
size_(std::exchange(other.size_, 0)) {}

private:
int* data_ = nullptr;
std::size_t size_ = 0;
};

移动构造如果是 noexcept,容器扩容时更愿意移动元素而不是拷贝元素。

不要乱写:

1
2
3
void f() noexcept {
may_throw(); // 如果真的抛出,会 std::terminate
}

经验:

  • 析构函数默认应不抛
  • 移动构造/移动赋值能保证不抛时写 noexcept
  • 低层工具函数如果不抛,可以写 noexcept
  • 不能保证时不要硬写

11. enum class:避免枚举污染和隐式转换

老式 enum:

1
2
3
4
5
6
enum Color {
red,
green,
};

int x = red; // 可以隐式转 int

更推荐:

1
2
3
4
5
6
7
8
enum class Color {
red,
green,
};

void paint(Color c);

paint(Color::red);

如果要指定底层类型:

1
2
3
4
enum class HttpStatus : int {
ok = 200,
not_found = 404,
};

enum class 的好处:

  • 名称不污染外层作用域
  • 不会随便隐式转整数
  • API 可读性更强

12. 现代属性的几个实用场景

12.1 [[maybe_unused]]

用于故意未使用的变量或参数:

1
2
3
4
5
void on_debug_event([[maybe_unused]] int code) {
#ifdef DEBUG
std::cout << code << "\n";
#endif
}

12.2 [[deprecated]]

给旧接口留迁移提示:

1
2
[[deprecated("use parse_config_v2 instead")]]
Config parse_config(std::string_view text);

12.3 [[fallthrough]]

明确 switch 穿透是有意的:

1
2
3
4
5
6
7
8
9
10
switch (level) {
case 3:
enable_verbose();
[[fallthrough]];
case 2:
enable_info();
break;
default:
break;
}

这些属性不是装饰,它们让代码意图对编译器和读者都更清楚。


13. 返回值设计:值、引用、指针怎么选

13.1 返回值

最常见、最安全:

1
std::vector<int> make_ids();

现代 C++ 有移动语义和返回值优化,不要过早改成输出参数。

13.2 返回引用

表示返回对象内部已有内容:

1
2
3
4
5
6
7
8
9
class User {
public:
const std::string& name() const noexcept {
return name_;
}

private:
std::string name_;
};

注意调用者不能让引用超过对象生命周期。

13.3 返回指针

适合表达“可能没有”且不转移所有权:

1
const User* find_user(int id) const;

如果没有值,也可以用:

1
std::optional<User> find_user(int id) const;

如果对象很大、不想复制,并且不拥有,就用指针或引用包装语义说清楚。


14. 一个完整的小 API 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <optional>
#include <span>
#include <string>
#include <string_view>
#include <vector>

enum class Role {
admin,
guest,
};

struct User {
int id = 0;
std::string name;
Role role = Role::guest;
};

class UserStore {
public:
explicit UserStore(std::vector<User> users)
: users_(std::move(users)) {}

[[nodiscard]] const User* find(int id) const noexcept {
for (const auto& user : users_) {
if (user.id == id) {
return &user;
}
}
return nullptr;
}

[[nodiscard]] std::vector<User> find_by_name(std::string_view name) const {
std::vector<User> out;
for (const auto& user : users_) {
if (user.name == name) {
out.push_back(user);
}
}
return out;
}

void append(User user) {
users_.push_back(std::move(user));
}

void append_all(std::span<const User> users) {
users_.insert(users_.end(), users.begin(), users.end());
}

private:
std::vector<User> users_;
};

这个例子里:

  • 构造函数 explicit
  • 只读查询是 const
  • 可能没有结果时返回指针
  • 必须关注的查询结果加 [[nodiscard]]
  • 保存参数时用值传递 + move
  • 批量只读输入用 span<const User>

15. 常见误区

15.1 所有参数都写 const T&

小整数、枚举、轻量句柄直接值传递更好。

15.2 到处返回 const T&

如果返回的是临时对象或局部变量引用,会立刻悬空。
现代 C++ 返回值通常很便宜,别怕返回对象。

15.3 string_view 当成字符串成员保存

除非你非常清楚底层字符串生命周期,否则成员里保存 std::string

15.4 所有函数都加 noexcept

noexcept 是承诺,不是祝福。
承诺错了会直接终止程序。

15.5 不写 explicit

隐式转换引起的问题很隐蔽。
构造函数默认倾向 explicit 是很好的工程习惯。


16. 一页总结

现代 C++ API 设计最常用的几条规则:

  1. 只读大对象用 const T&
  2. 字符串观察用 std::string_view
  3. 连续数组观察用 std::span
  4. 要保存的对象可以值传递再 move
  5. 查询成员函数尽量写 const
  6. 单参数构造函数优先 explicit
  7. 重要返回值加 [[nodiscard]]
  8. 能保证不抛时才写 noexcept

一句话:

好 API 的核心是把所有权、可变性和失败语义写在签名里,而不是藏在文档里。