DAY1-Modern C++
0 先验配置
查看自己的c++版本
# GCC
g++ -dM -E -x c++ /dev/null | grep __cplusplus
# Clang
clang++ -dM -E -x c++ /dev/null | grep __cplusplus
# MSVC (Windows)
cl /nologo /EP /dM /TP nul 2>nul | findstr __cplusplus
| 输出值 | 对应标准 |
| --------- | --------- |
| `199711L` | C++98/03 |
| `201103L` | **C++11** |
| `201402L` | **C++14** |
| `201703L` | **C++17** |
| `202002L` | C++20 |
| `202302L` | C++23 |
| 维度 | C++98 | Modern C++ (11/14/17) |
| ---------- | ----------------------- | ---------------------------------------------- |
| **内存管理** | 手动 `new/delete`,极易泄漏 | `std::unique_ptr`/`std::shared_ptr`,RAII 自动化 |
| **拷贝成本** | 深拷贝是唯一选择( costly ) | **移动语义**(`std::move`),转移资源而非复制 |
| **初始化** | 赋值初始化、括号初始化混乱 | **统一初始化列表 `{}`**、`std::initializer_list` |
| **构造函数** | 重复代码只能抽函数 | **委托构造**(一个构造函数调用另一个) |
| **默认函数控制** | 编译器隐式生成,无法控制 | `= default`(显式默认)、`= delete`(显式禁用) |
| **类型推导** | 必须写冗长类型名 | `auto`、`decltype`、尾置返回类型 |
| **并发** | 依赖平台 API(pthread/Win32) | 标准库自带 `std::thread`、`std::mutex`、`std::future` |
| **Lambda** | 无,必须写仿函数类 | 匿名函数,算法配合利器 |
| **常量表达式** | `#define` 或运行期计算 | `constexpr` 编译期计算 |
| **范围 for** | 手写迭代器循环 | `for (auto& x : container)` |
指定编译标准
g++ -std=c++11 your.cpp # C++11
1 初始化列表
class Packet
{
private:
/* data */
std::vector<uint8_t> data_;
public:
Packet(/* args */);
~Packet();
Packet(size_t len) : data_(len) {}; // 直接构造vector
};
// 使用
int main(int argc, char const *argv[])
{
Packet p(1024); // 创建长度为 1024 字节的包,data_ 里有 1024 个 0
return 0;
}
析构函数
~Packet()的作用:在对象被销毁前,做收尾清理。对
Packet来说,成员是std::vector<uint8_t> data_:
你几乎不用在
~Packet()里写东西;
vector自己会释放它在堆上申请的内存;这叫 RAII:资源随对象生命周期自动管理。
什么时候必须关心析构?
当类自己管理资源时,例如:
class Bad {
uint8_t* raw_; // 自己 new 出来的
public:
Bad(size_t n) : raw_(new uint8_t[n]) {}
~Bad() { delete[] raw_; } // 必须自己 delete,否则内存泄漏
};
Packet用vector,没有裸指针,所以析构通常交给编译器默认实现就够了。
1. Clang 是什么?
Clang 是一个 C / C++ / Objective-C 编译器前端,属于 LLVM 项目的一部分。
可以把它理解成和 GCC、MSVC 同类的工具:把源码编译成可执行文件或库。
特点
说明
错误信息
通常更清晰,会指出问题位置和原因
编译速度
往往比 GCC 更快(尤其大项目)
标准支持
对新标准(C++17/20/23)跟进较快
常见用途
macOS/iOS 默认工具链(Xcode)、Linux 开发、静态分析(clang-tidy)、IDE 补全/跳转的后端
各版本核心变化(简表)
标准
发布时间
代表性特性
定位
C++11
2011
auto、lambda、智能指针、move、范围 for、nullptr、vector初始化列表现代 C++ 分水岭,变化最大
C++14
2014
泛型 lambda、
make_unique、返回类型auto、数字分隔符'1'000在 11 上小步完善
C++17
2017
std::optional/variant/string_view、结构化绑定、if constexpr、filesystem实用特性多,工程上很成熟
C++20
2020
Concepts、Coroutines、Ranges、
std::format、Modules(初步)又一次大升级(你没问,但选型常提到)
常见表示成员变量的风格
写法
示例
常见来源
后缀下划线
data_Google C++ 风格、很多开源 C++ 项目
前缀
m_
m_data老 Windows/MFC、部分嵌入式代码
_前缀
_data少数团队习惯
无后缀,用
this->
this->data = data也能区分,但写起来啰嗦
Rule of Zero(现代 C++ 推荐)
一个成员都不用自己管,全用
vector、string、unique_ptr等 RAII 类型:class Packet {
std::vector<uint8_t> data_; // 就这一行管内存
// 不写析构、拷贝、移动 — 编译器生成的会正确委托给 vector
};
这叫 Rule of Zero:
编译器生成的拷贝会拷贝vector(深拷贝);移动会移动vector(快);析构会释放vector的内存。
你不需要手写那五个函数。
Packet(1024)→data_(1024)→ 堆上约 1024 字节,元素初值为 0;拷贝
Packet→ 两个vector各有一块堆内存;移动
Packet→ 两个vector内部指针交换/转移,几乎 O(1)。这也是现代 C++ 里用
vector而不是uint8_t*+new/delete的原因:拷贝/移动/析构语义由标准库实现好了。
六、为什么要
private(封装)class Packet {
private:
std::vector<uint8_t> data_; // 外部不能直接碰
public:
Packet(size_t len);
// 将来可以加: size(), data(), append(...) 等
};
1. 隐藏实现细节
外部只应知道「这是一个定长/可变的数据包」,不应依赖「底层是 vector 还是别的」。
以后若改成
std::span、内存池、共享缓冲区,只要改类内部,调用方代码不用改。2. 防止误用
若
data_是 public:Packet p(100);
p.data_.clear(); // 外面随便清掉,类 invariant 被破坏
p.data_.resize(999999); // 绕过你的业务规则
private后,只能通过你提供的接口操作,你可以在接口里做校验、日志、线程安全等。3. 保持「不变量」(invariant)
例如约定:「Packet 长度一旦创建就不能为 0」——
在构造函数和 public 方法里保证;若data_公开,外部无法保证。4. 和拷贝/移动的关系
资源在
private的data_里;对外只暴露行为。
拷贝/移动由编译器 +vector在幕后完成,调用方写:Packet a(100);
Packet b = a; // 拷贝
Packet c = std::move(a); // 移动
不需要知道内部是深拷贝还是指针转移。
一、为什么 C 里
b = a也是拷贝,C++ 却要单独强调?C 里多数拷贝是「按位复制」(trivial)
int a = 10;
int b = a; // 4 字节从 a 的栈槽复制到 b 的栈槽,完事
int没有「内部堆内存、文件句柄、锁」这类资源,编译器知道怎么复制,没有歧义。C++ 里对象可以「拥有资源」,拷贝语义不再显然
Packet a(1024); // a 内部 vector 在堆上有 1024 字节
Packet b = a; // 应该复制 1024 字节?还是两个对象共用同一块?
若默认只做浅拷贝(只复制 vector 对象里的几个指针),两个
Packet会指向同一块堆内存,析构时 double free。
所以 C++ 必须回答:谁负责拷贝?拷贝多深?这就是 C++ 把「拷贝」单独拎出来说的原因——不是「有没有拷贝」,而是 对象类型的拷贝语义要由语言/程序员明确定义。
C++ 和其他语言的对比(直觉)
语言/模型
常见做法
C
基本类型按值;结构体按成员复制;指针只复制地址,资源你自己管
Java/C# 对象
变量多是「引用」,
b = a常是两个引用指向同一对象(不是深拷贝对象内容)C++
值语义为主:
b = a默认是新对象拥有一份独立数据(对 vector 等是深拷贝)C++ 强调拷贝,是因为它默认 值语义 + 可自定义,既要安全又要性能(于是又有移动、零拷贝式优化等)。
二、C++ 里和「拷贝/移动/销毁」相关的特殊成员函数
对一个类(如
Packet),编译器可能生成或使用这些函数:
函数
典型触发写法
作用
析构
~Packet()对象生命周期结束
释放资源
拷贝构造
Packet(const Packet& other)
Packet b = a;或Packet b(a);用
other构造新对象拷贝赋值
operator=(const Packet& other)
b = a;(b、a都已存在)把
other的内容赋给已存在的b移动构造
Packet(Packet&& other)
Packet b = std::move(a);从
other偷资源 构造b移动赋值
operator=(Packet&& other)
b = std::move(a);把
other的资源转移给b前 3 个是经典 Rule of Three;加上后 2 个是 Rule of Five。
构造:新对象还没建完,在「出生」时用谁的数据。
赋值:对象已经存在,可能要先释放
b旧数据,再复制a的数据。
三、
operator=就是运算符重载吗?只对Packet有效吗?是运算符重载,而且是 成员函数,第一个参数是隐式的
this:class Packet {
public:
Packet& operator=(const Packet& other) {
if (this != &other) {
data_ = other.data_; // vector 的 operator= 会做深拷贝
seq_ = other.seq_;
}
return *this; // 支持链式:p1 = p2 = p3
}
};
含义:
operator=只在 左边是Packet、右边也是Packet(或能转成 const Packet&) 时,才会走这个函数。
int x = 10;用的是int的内建赋值,和Packet::operator=无关。对
Packet用vector时,你往往 不写operator=,编译器生成的版本会逐个成员赋值;data_ = other.data_会调用std::vector自己的operator=(深拷贝)。所以:是重载,且针对「Packet 对 Packet 的赋值」;成员内部的
vector、uint32_t各有自己的赋值规则。
1. 浅拷贝 vs 深拷贝(C++ 对象层)
浅拷贝(危险,对裸指针):
a.raw_ ──┐
├──→ [同一块堆内存]
b.raw_ ──┘
深拷贝(Packet + vector 的期望):
a.data_ ──→ [堆块 A,1024 字节]
b.data_ ──→ [堆块 B,1024 字节,内容相同]
拷贝时 CPU 在干什么(和「上下文切换」的区别)
普通内存拷贝(例如 vector 深拷贝 1MB):
CPU 从源地址 load 到寄存器/缓存
再 store 到目标地址
可能触发 cache line 填充、内存带宽占用
不会 因为「拷贝 1MB」就做一次 上下文切换
上下文切换(context switch) 是另一回事:
操作系统把 CPU 从 线程 A 换到 线程 B
要保存/恢复寄存器、切换页表、可能 cache 失效
常见原因:时间片用完、阻塞 I/O、
sleep、锁竞争
2 委托构造 delegating constructor
委托构造是 C++11 特性:一个构造函数在初始化列表里调用本类另一个构造函数,避免重复写 data_(len), seq_(seq)。
概念示例(讲解用,不是你文件里的现码):
class Packet {
public:
// 主构造:真正干活的
Packet(size_t len, uint32_t seq) : data_(len), seq_(seq) {}
// 委托:seq 默认为 0,转交给上面那个
Packet(size_t len) : Packet(len, 0) {}
// 委托:默认长度 0
Packet() : Packet(0, 0) {}
};
要点:
委托必须写在 成员初始化列表里:Packet(len) : Packet(len, 0) {}
被委托的构造负责初始化所有成员;委托方 不能再 初始化已委托的成员(C++ 规则)
用途:减少重复、统一 invariant(所有路径都经过同一个「主构造」)
你文件里的 Packet(/* args */) 若是想表达「默认包」,更现代的写法通常是委托到 Packet(0, 0) 或 = default 默认构造,而不是空函数体(空函数体对 vector、seq_ 的初始化语义和委托不同,这是后话)。
3 = default / = delete
= default:我要编译器生成默认实现
class Packet {
public:
Packet() = default;
~Packet() = default;
Packet(const Packet&) = default; // 默认拷贝构造
Packet& operator=(const Packet&) = default; // 默认拷贝赋值
Packet(Packet&&) = default; // 默认移动构造
Packet& operator=(Packet&&) = default; // 默认移动赋值
};
用途:
- 语义就是「成员该怎么拷贝/移动/析构,全交给成员(如
vector)」- 在类内 声明 时写
= default,有时比完全省略更清晰(尤其混合自定义构造时)- Rule of Zero 的显式写法:五个特殊函数都
default,你只写业务构造函数
= delete:禁止某种操作class Packet {
public:
Packet(const Packet&) = delete; // 禁止拷贝构造
Packet& operator=(const Packet&) = delete; // 禁止拷贝赋值
};
效果:
Packet a(1024);
Packet b = a; // 编译错误:拷贝构造已 delete
b = a; // 编译错误:拷贝赋值已 delete
用途:单例、独占资源、
unique_ptr成员、「只能移动不能拷贝」的类型。
unique_ptr:不可拷贝、只可移动(Rule of Five 的典型)
std::unique_ptr<T>独占 堆上对象:拷贝被 delete,移动 allowed。#include <memory>
std::unique_ptr<int> p1 = std::make_unique<int>(42);
// std::unique_ptr<int> p2 = p1; // 错误:拷贝构造 = delete
std::unique_ptr<int> p2 = std::move(p1); // OK:移动
// 此时 p1 == nullptr,p2 拥有那块 int
若类里有一个
unique_ptr成员:class Connection {
std::unique_ptr<Socket> sock_;
public:
// 编译器生成的拷贝构造会尝试拷贝 sock_ → unique_ptr 拷贝被 delete → 类整体不可拷贝
Connection(Connection&&) = default; // 移动 OK
Connection& operator=(Connection&&) = default;
Connection(const Connection&) = delete;
Connection& operator=(const Connection&) = delete;
};
这就是 Rule of Five 在实践中的样子:
成员类型 拷贝 移动 类通常策略
vector深拷贝
转移
Rule of Zero,
= default即可
unique_ptr禁止
转移
类也 不可拷贝、可移动,常显式
delete拷贝对比你的
Packet:std::vector<uint8_t> data_; // 可拷贝、可移动 → Packet 默认可拷贝可移动
// 若改成:
// std::unique_ptr<uint8_t[]> data_; // Packet 会天然「不可拷贝」
Rule of Three / Five / Zero 是什么?
都是关于:类管理资源时,要不要自己写「析构 / 拷贝 / 移动」 的经验法则。
Rule of Three(三条规则,C++98 时代)
如果你必须自定义下面任意一个:
- 析构函数
~T()- 拷贝构造函数
T(const T&)- 拷贝赋值
operator=(const T&)那通常 三个都要自己写(或明确
= default/= delete)。原因:默认拷贝可能是浅拷贝,和自定义析构(如
delete[])配在一起会 double free。Rule of Five(五条规则,C++11 起)
在 Three 基础上,加上:
- 移动构造函数
T(T&&)- 移动赋值
operator=(T&&)否则移动可能仍走拷贝,性能差;或语义不对。
Rule of Zero(零规则,现代 C++ 推荐)
一个资源都不用手管,成员全是
vector、string、unique_ptr等 RAII 类型:
- 五个函数都不手写;
- 编译器生成的默认版本会逐个成员正确析构/拷贝/移动。
你的
Packet若只有vector+uint32_t→ 适合 Rule of Zero。
若成员有unique_ptr→ 类往往不可拷贝、只可移动(见unique.cpp)。Rule of Three: 自己管资源 → 常要写 析构 + 拷贝×2
Rule of Five: 再加 移动×2
Rule of Zero: 成员都是 RAII → 五个都不写
4 public/private/protected/explicit
1.
public/private/protected
关键字 谁能访问
private仅本类(+ 友元)
protected本类 + 派生类
public任何人
封装要回答的问题不是「能不能访问」,而是:
- 不变量(invariant) 谁保证?例如:
Packet长度、checksum 与 payload 一致。- 实现细节 能否以后换?例如:
vector换成内存池,调用方不应感知。
3.
explicit——防止「不小心构造出来」explicit Packet(size_t len); // 禁止 Packet p = 1024;
Packet p(1024); // OK
网关里常见:
Packet(size_t len)、Port(int)若允许隐式转换,容易在重载、容器插入时 静默出错。
学习动作:对你课表里的每个单参数构造,问一句:「隐式转换会不会在生产代码里埋雷?」
封装层次(从外到内): public 接口 ←── 外部用户唯一可见的"契约" │ protected ←── 子类可扩展,外部不可见 │ private ←── 内部实现细节,连子类都不信任 │ friend ←── "白名单":特定类/函数可穿透 private(不是 public!)
1. 友元(
friend)是什么?友元是 C++ 里一种 「打破封装,但只允许特定对象访问」 的机制。
默认:
private只有 本类自己的成员函数 能访问。加上友元后:你点名的一个函数或类 也可以访问
private/protected成员。class Packet {
private:
uint32_t magic_;
uint32_t length_;
std::vector<uint8_t> payload_;
friend class ProtocolParser; // 只有 ProtocolParser 能碰 private
};
class ProtocolParser {
public:
Packet parse(const uint8_t* buf, size_t n) {
Packet p;
p.magic_ = read_u32(buf); // OK:友元允许
p.length_ = read_u32(buf + 4);
// ...
return p;
}
};
void handler() {
Packet p;
// p.magic_ = 1; // 错误:Handler 不是友元,不能访问
}
可以把它理解成:
对比 含义
public谁都能访问
private+ getter/setter谁都能通过你提供的接口访问
friend只有你信任的那一个类/函数 能直接访问内部字段
为什么要用?
在网关里,解析器需要一次性写入很多 header 字段;若每个字段都写 setter,代码又长又慢。友元表示:解析是 Packet 的内部事务,只信任 Parser。代价:
Packet和ProtocolParser绑紧了——Parser 知道 Packet 内部长什么样,改字段名两边都要改。友元也可以是 函数:
class Packet {
friend Packet parsePacket(const uint8_t* buf, size_t n);
};
数据流:网络字节流 → Parser → Packet → Handler
你还不熟悉这条链,用 收一个 TCP 包 的例子从头到尾走一遍。
现实中发生了什么?
网卡收到电信号,内核 TCP 栈拼成一段 字节(byte stream)。
你的网关程序
read()/recv()读到内存里,例如:[0xDE 0xAD 0x00 0x00 0x00 0x10 ... payload ...]
\____ magic ____/ \_ len _/ \___ 16 字节数据 ___/
这段 raw bytes 还不是 C++ 对象,只是
uint8_t buf[256]。各角色干什么?
┌─────────────────┐
│ 网络 / 内核 │ 字节流:0xDE 0xAD 0x00 0x10 ...
└────────┬────────┘
│ recv() 读到用户态 buffer
▼
┌─────────────────┐
│ ProtocolParser │ 按协议格式「解读」字节:magic 对不对?length 多少?
│ (解析器) │ 校验 checksum,切出 payload
└────────┬────────┘
│ 填好字段
▼
┌─────────────────┐
│ Packet │ 有类型的 C++ 对象:magic_, length_, payload_
│ (数据包对象) │ 带 invariant:length == payload.size()
└────────┬────────┘
│ 交给业务
▼
┌─────────────────┐
│ 业务 Handler │ 根据 seq/cmd 做路由、鉴权、转发
│ (业务处理) │ 只关心「这是什么命令、内容是什么」
└─────────────────┘
为什么要分 Parser 和 Handler? --- 解耦合
- Parser:懂 二进制格式(大端/小端、字段偏移、版本号)。
- Handler:懂 业务逻辑(登录、心跳、转发到哪个服务)。
分开后:协议改版只动 Parser;业务改版只动 Handler。
Getter 和 Setter 是什么?
对 private 成员,对外提供 读 / 写 的小函数,而不是直接暴露字段。
Getter(读)
class Packet {
private:
uint32_t magic_;
std::vector<uint8_t> payload_;
public:
uint32_t magic() const { return magic_; } // 读 magic
const std::vector<uint8_t>& payload() const { return payload_; } // 读 payload(不拷贝)
size_t payloadSize() const { return payload_.size(); }
};
- 名字常见:
magic()、getMagic()(团队风格不同)- 后面加
const表示:这个函数 不会修改对象(只读)业务 Handler 用法:
void onPacket(const Packet& p) {
if (p.magic() != 0xDEAD) return;
auto& data = p.payload(); // 只读访问
}
Setter(写)
class Packet {
public:
void setMagic(uint32_t m) { magic_ = m; }
void setPayload(std::vector<uint8_t> pl) {
payload_ = std::move(pl);
length_ = static_cast<uint32_t>(payload_.size()); // 保持 invariant
}
};
- Setter 可以带校验:长度超限、magic 非法就拒绝
- 这就是封装的好处:改内部状态必须经过你的规则
Getter / Setter / 友元 怎么选?
方式 谁用 特点 public 字段
所有人直接改
简单,易出错,不推荐生产
getter + setter
外部按需读写
接口清晰,可在 setter 里校验
只有 getter,无 setter
Handler 只读
解析后不允许随便改 header
友元 Parser
只有 Parser 能写 private
解析路径快、集中,Handler 仍用 getter 读
5 移动语义
#include <memory>
#include <cstring>
#include <algorithm>
class Packet {
std::unique_ptr<uint8_t[]> data_;
size_t len_;
uint32_t seqNum_; // 序列号
uint16_t flags_; // 标志位
public:
// 1. 默认构造
Packet() : data_(nullptr), len_(0), seqNum_(0), flags_(0) {}
// 2. 普通构造
explicit Packet(size_t len, uint32_t seq = 0)
: data_(std::make_unique<uint8_t[]>(len)),
len_(len),
seqNum_(seq),
flags_(0) {
std::memset(data_.get(), 0, len);
}
// 3. 拷贝构造(深拷贝)— 左值用
Packet(const Packet& other)
: data_(std::make_unique<uint8_t[]>(other.len_)),
len_(other.len_),
seqNum_(other.seqNum_),
flags_(other.flags_) {
std::memcpy(data_.get(), other.data_.get(), len_);
}
// 4. 拷贝赋值
Packet& operator=(const Packet& other) {
if (this != &other) {
// 拷贝并交换惯用法(Copy-and-Swap)
Packet tmp(other); // 拷贝
swap(tmp); // 无异常交换
}
return *this;
}
// 5. 移动构造(重点!)— 右值用
Packet(Packet&& other) noexcept // ✅ noexcept 必须!
: data_(std::move(other.data_)), // ✅ 偷走指针
len_(other.len_), // ✅ 复制标量
seqNum_(other.seqNum_),
flags_(other.flags_) {
// ✅ 关键:置空源对象!
other.len_ = 0;
other.seqNum_ = 0;
other.flags_ = 0;
// other.data_ 已经被 unique_ptr 的移动构造置空了,不需要手动处理
}
// 6. 移动赋值
Packet& operator=(Packet&& other) noexcept {
if (this != &other) {
data_ = std::move(other.data_); // 释放原有资源,偷走新的
len_ = other.len_;
seqNum_ = other.seqNum_;
flags_ = other.flags_;
other.len_ = 0; // 置空源对象
other.seqNum_ = 0;
other.flags_ = 0;
}
return *this;
}
// 辅助:交换(用于拷贝赋值)
void swap(Packet& other) noexcept {
using std::swap;
swap(data_, other.data_);
swap(len_, other.len_);
swap(seqNum_, other.seqNum_);
swap(flags_, other.flags_);
}
// 7. 析构 — unique_ptr 自动处理,无需手写
// 访问器
size_t size() const { return len_; }
uint8_t* data() { return data_.get(); }
const uint8_t* data() const { return data_.get(); }
bool empty() const { return len_ == 0; }
};
五、
std::move是什么?不是「搬内存」,而是
static_cast<T&&>:把表达式变成「可以移动」的右值引用。Packet a(1024);
Packet b = std::move(a); // 告诉编译器:可以动 a 了,用移动构造
注意:
std::move本身不移动任何东西,只改 值类别;- 真正移动发生在 移动构造/移动赋值 被选中时;
- 对
std::vector这类已有移动语义的类型,通常 Rule of Zero 就够(编译器生成的移动会移动data_)。
一、右值引用
&&到底是什么?1. 先从「表达式」分两类:左值 vs 右值
C++ 里,不是「变量在左、常量在右」,而是看 能不能取地址、有没有名字、是不是临时量。
类型 直觉 例子 左值 (lvalue)
有名字、能反复用、
&x合法
Packet a;、a、*ptr右值 (rvalue)
临时、快消失、一般不能
&
42、Packet(1024)、std::move(a)的结果Packet a(1024);
Packet b = a; // a 是左值 → 拷贝
Packet c = Packet(4096); // Packet(4096) 是临时右值 → 可移动
Packet d = std::move(a); // move 后表达式是右值 → 移动
右值 ≈ 「这个对象我马上就要扔,你可以把它的资源偷走」。
2. 左值引用
&vs 右值引用&&void foo(Packet& p); // 只接受左值:必须是有名字的变量
void bar(Packet&& p); // 只接受右值:临时对象、move 后的结果
引用 绑定对象 典型用途
T&左值
读/改已有对象,不拷贝
const T&左值 或 右值
万能 const 引用(右值也能绑,但只能读)
T&&仅 右值
移动构造、移动赋值、「我要偷资源」
Packet a(1024);
foo(a); // OK
foo(Packet(1024)); // 错误:临时量是右值,不能绑到 Packet&
bar(Packet(1024)); // OK:右值绑到 Packet&&
bar(std::move(a)); // OK:显式把 a 当右值
// bar(a); // 错误:a 是左值,不能绑到 Packet&&
3.
&&在声明里的两种含义(容易混)含义 A:右值引用类型(参数、变量)
void consume(Packet&& p); // p 是右值引用
Packet&& ref = Packet(1024); // 少见;ref 实际是左值(有名字了!)
含义 B:移动构造函数里的「引用折叠」
Packet(Packet&& other) noexcept // 移动构造:other 是右值引用
: data_(std::move(other.data_)) {}
这里
Packet&&表示:这个构造函数专门接管「别人不要了」的那个 Packet。
4. 为什么需要
&&?—— 区分拷贝和移动// 拷贝:other 还要活着,必须深拷贝
Packet(const Packet& other) : data_(other.data_) {}
// 移动:other 快死了,可以偷 data_ 内部的堆指针
Packet(Packet&& other) noexcept : data_(std::move(other.data_)) {}
若没有
&&overload,右值也会走const Packet&拷贝构造 → 大报文仍然深拷贝。
5.
std::move做了什么?(不 move,只 cast)std::move(a) // 等价于 static_cast<Packet&&>(a)
- 不移动任何东西,只把
a从「左值表达式」变成「右值表达式」;- 真正移动发生在:移动构造 / 移动赋值 被调用时。
Packet b = std::move(a); // 1. move → 右值 2. 移动构造偷资源 3. a 进入 moved-from
6. moved-from 状态
移动之后,源对象 仍析构,必须处于 合法但未指定 状态:
Packet a(1'000'000);
Packet b = std::move(a);
// b:拥有原来那 1MB
// a:data_ 通常是空 vector,但标准不保证;安全做法:只析构/赋值为空,不再当「正常包」用
1. 「裸缓冲区」是什么意思?
不用
vector/string帮你管内存,自己持有一块堆内存的 原始指针 + 长度:class PacketRaw {
uint8_t* data_; // 裸指针:指向堆上 byte 数组
size_t size_;
size_t capacity_;
public:
PacketRaw(size_t n) : data_(new uint8_t[n]), size_(n), capacity_(n) {}
~PacketRaw() { delete[] data_; } // 你必须自己释放
// 若不写移动,默认拷贝只复制指针 → 浅拷贝 → double free
// 所以必须手写移动构造/移动赋值/析构(Rule of Five)
};
对比 vector<uint8_t> data_裸缓冲区 uint8_t* data_谁分配/释放
vector
你自己
new[]/delete[]默认拷贝
深拷贝(安全)
浅拷贝(危险)
Rule
Rule of Zero 够用
Rule of Five,必须手写移动
「裸」= 没有智能包装,直接操作指针。
2. 缓冲区「在哪里」?
PacketRaw 对象(通常在栈上)
┌──────────────────┐
│ data_ ──────────┼──→ 堆 (heap): [ byte0 | byte1 | ... ] ← 真正的大报文在这里
│ size_ │
│ capacity_ │
└──────────────────┘
- 对象本身:栈或别的对象成员里;
- 大报文字节:
data_指向的 堆 上;- 移动:把
data_指针「转给」新对象,源对象data_ = nullptr(典型写法)。
RVO / NRVO 是什么?怎么知道有没有发生?
1. 名字
缩写 全称 场景 RVO
Return Value Optimization
return临时对象NRVO
Named RVO
return有名字的局部变量// RVO:返回无名临时量
Packet makeA() {
return Packet(1024); // 直接在调用方栈槽构造,无拷贝无移动
}
// NRVO:返回同名局部对象
Packet makeB() {
Packet p(1024);
// ... 可能修改 p ...
return p; // 常见优化:直接在 caller 的 p 位置构造
}
优化含义:省略拷贝/移动,在 调用方返回目的地 直接构造对象。
2. 和移动的关系
Packet make() {
Packet p(1'000'000);
return p;
}
Packet q = make();
情况 发生什么 有 NRVO
p直接在q的内存构造 → 无深拷贝、无移动无 NRVO(C++17 起仍常见兜底)
return p→ 移动p到q→ 仍无 1MB memcpy很老编译器 + 无移动
可能 深拷贝 1MB(现在少见)
移动是 NRVO 失败时的优秀后备;RVO/NRVO 成功时更好。
3. 你怎么「知道」是 RVO/NRVO 还是移动?
方法 1:看汇编 / 编译器报告(最可靠)
g++ -std=c++17 -O2 -fno-elide-constructors move_demo.cpp -S -o move_demo.s
# -fno-elide-constructors 故意关掉 RVO,对比有无移动调用
g++ -std=c++17 -O2 move_demo.cpp -S -o move_demo_opt.s
# 默认 -O2 常能看 NRVO:没有 memcpy 1MB,没有调用移动构造
在
-O2且 未 使用-fno-elide-constructors时,简单return local常 看不到 移动构造函数调用。方法 2:加日志(学习用)
Packet(const Packet&) { std::cout << "copy\n"; }
Packet(Packet&&) { std::cout << "move\n"; }
Packet make() {
Packet p(1024);
return p;
}
// Debug -O0:可能打印 move 或 copy
// Release -O2:可能什么都不打印(NRVO)
Debug / -O0 常禁用 elision → 看到
move不等于 Release 也会 move。
Release -O2 什么都不打印 → 很可能是 RVO/NRVO。方法 3:C++17 标准保证(记这个就够)
对 纯右值 prvalue 返回:
return Packet(1024); // C++17 起:即使没 RVO,也禁止多余拷贝(保证一次构造)
对 局部左值
return p;:NRVO 允许但非必须;失败则 必须移动(有移动构造时),不能拷贝 unless 真的需要。
4. 一张决策图
return 大 Packet
│
▼
编译器 -O2?
│
┌────┴────┐
是 否 (-O0 / -fno-elide-constructors)
│ │
▼ ▼
NRVO/RVO? 常看到 move 或 copy(便于观察)
│
┌─┴─┐
成功 失败
│ │
▼ ▼
零额外 移动构造(vector 偷指针,仍无 1MB 拷贝)
构造
一、RAII 有哪些?哪些明显不是?
RAII 是什么(一句话)
Resource Acquisition Is Initialization:在对象构造时取得资源,在析构时自动释放;资源生命周期 = 对象生命周期。
{
Packet p(1024); // 构造:vector 在堆上拿内存
} // 析构:vector 自动 release
常见 是 RAII 的类型(Modern C++ 里应优先用)
类型 管理的资源 析构时
std::vector<T>动态数组(堆)
释放元素 + 内存
std::string字符缓冲区
释放
std::unique_ptr<T>独占堆对象
delete
std::shared_ptr<T>共享堆对象
引用计数为 0 时释放
std::fstream/std::ifstream文件句柄
关闭文件
std::lock_guard/std::unique_lock互斥锁
解锁
std::FILE*的包装(或用fstream)C 文件
关闭
std::thread(join 在析构)线程
join/detach 策略
socket 的 RAII 包装类(自定义)
fd
close()
std::optional、std::variant不直接管 OS 资源,但管对象状态
按规则析构内部
你的
Packet里:std::vector<uint8_t> data_; // RAII ✓
std::unique_ptr<int> ptr1_; // RAII ✓
→ 符合 Rule of Zero:不用手写析构/移动/拷贝。
明显 不是 RAII 的(或本身不管释放)
东西 为什么不是 RAII 裸指针
T*只存地址,析构时 不会
delete
malloc返回的void*必须
free,和对象生命周期无关整数 fd
int sock_fd_只是一个数,析构时 不会
closeC 数组
uint8_t buf[1024](栈上)随栈帧消失,但不叫「取得/释放资源」那种 RAII
new出来的指针存成员但不 delete反模式
引用
T&不拥有资源
std::span/ 指针+长度视图不拥有 内存,只「借用」
迭代器
不拥有容器
普通 int / bool / enum
无资源
反例(典型非 RAII,网关里要避免):
class BadPacket {
uint8_t* data_; // 裸指针:谁 delete?→ 必须手写 Rule of Five
int fd_; // socket fd:谁 close?→ 必须手写析构
};
灰色地带(要会辨认)
类型 说明
std::shared_ptr是 RAII,但 共享所有权;网络连接通常用
unique_ptr更合适
std::weak_ptr不延长生命周期,辅助 RAII,不单独管资源
内存池里的一块
池本身可能是 RAII;
uint8_t*从池取出后,归还责任在你,不算完整 RAII
std::move_only_function等管 callable 资源,算 RAII
记法:问自己——这个对象析构时,资源会不会自动、确定地释放? 会 → RAII;不会 → 你还得写析构或别用。
6 C++20新重载
Modern 运算符重载要点清单
运算符 Modern 写法 注意
==
friend bool operator==(const T&, const T&) = default;C++20 可与
<=>联动
<=>
friend auto operator<=>(const T&, const T&) = default;成员逐个可比较时最省事
<<非成员
friend,返回ostream&调试用,别泄露敏感 payload
拷贝
= delete(网络类)替代 Java「禁止克隆」的显式表达
移动
= default;+noexcept容器友好,标准库会优化
不要 学老式写法:
bool operator==(const Packet& other) const { ... } // 可以,但 C++20 更常 symmetry 用 friend + = default
不要 对 moved-from 对象做业务假设;比较仅用于调试或未移动的对象。
6 网关协议栈继承图
[ 字节流 / Socket ]
↓
TransportHandler → TcpHandler / UdpHandler
↓
Packet 对象
↓
ApplicationHandler → HttpHandler / WebSocketHandler
↓
业务路由 / 鉴权
↓
GatewayCore::dispatch() ← final,不允许被继承篡改
7 虚函数与接口设计
2. 抽象基类 vs 接口类
类型 典型内容 网关例子 抽象基类 ABC
纯虚 + 部分已实现(
process()模板方法)
ProtocolHandler带validate()默认实现接口类 Interface
仅 虚析构 + 纯虚函数,无数据成员/无实现
IProtocolHandler只有handle()/name()
// 接口类(Modern 网关常见)
class IProtocolHandler {
public:
virtual ~IProtocolHandler() = default;
virtual bool handle(Packet& pkt) = 0;
virtual std::string_view name() const noexcept = 0;
IProtocolHandler(const IProtocolHandler&) = delete;
IProtocolHandler& operator=(const IProtocolHandler&) = delete;
protected:
IProtocolHandler() = default; // 禁止外部直接构造接口
};
接口类要点:多态边界清晰;实现类 TcpHandler 承担全部状态(socket、缓冲区等)。
override — 强制编译器检查
class TcpHandler : public IProtocolHandler {
public:
bool handle(Packet& pkt) override; // ✓ 确实重写基类虚函数
std::string_view name() const noexcept override;
// bool handle(Packet pkt) override; // ✗ 签名错了,编译失败(没有 override 可能静默隐藏)
};
没有 override 有 override
写错参数/const 可能变成 新函数,多态不生效
签名不匹配 → 编译错误
虚析构 — 为什么必须 virtual?
规则:只要类会通过 基类指针/引用 删除或销毁派生对象,基类析构必须是 虚函数。
IProtocolHandler* p = new TcpHandler(...);
delete p; // 若 ~IProtocolHandler 非 virtual → 只调基类析构 → 派生类资源泄漏
unique_ptr<Base> 同理:
std::unique_ptr<IProtocolHandler> p = std::make_unique<TcpHandler>(...);
// p 析构时调用 delete,内部类型是 TcpHandler,但 delete 表达式是 Base*
// → 需要 virtual ~Base() 才能 ~TcpHandler() → ~TcpHandler 成员
C++ 标准对 unique_ptr<Derived> 转 unique_ptr<Base> 有特殊 deleter,但 接口设计仍应虚析构;手写 delete base_ptr 和旧代码路径都会踩坑。
7 静态多态 CRTP
二、虚函数多态 vs CRTP 静态多态
虚函数(动态多态) CRTP(静态多态)
绑定时机
运行期 查 vtable
编译期 知道真实类型 Derived
调用
handler->handle(pkt) → 间接跳转
handler.handle(pkt) → 常可 内联 到 TcpHandler::handle
开销
vptr + 虚表查找 + 难内联
无 vtable;可能 零抽象开销
类型
运行时多种 Handler 混在一个 vector<unique_ptr<Base>>
每种 Handler 独立类型;混用要 std::variant / 模板 / 宏展开
典型场景
插件、运行时注册、类型运行时才知道
网关热路径、模板已知、追求极致吞吐
网关直觉:
控制面 / 插件化 → 虚函数 + 接口类。
数据面 / 每包都 dispatch → CRTP 或 if (proto==TCP) + 特化,避免每包 vtable。
三、CRTP 长什么样?
核心模式
template <typename Derived>
class ProtocolHandlerCrtp {
public:
bool process(Packet& pkt) {
// 编译期已知 Derived → 可内联 Derived::handle
return static_cast<Derived*>(this)->handle(pkt);
}
std::string_view name() const {
return static_cast<const Derived*>(this)->name();
}
};
class TcpHandlerCrtp : public ProtocolHandlerCrtp<TcpHandlerCrtp> {
public:
bool handle(Packet& pkt) { /* TCP 逻辑 */ return true; }
std::string_view name() const { return "TCP"; }
};
为什么是 TcpHandler : public Base<TcpHandler>?
基类模板参数是 派生类自己 → 基类方法里能 static_cast<Derived*>(this) 调派生实现;
名字 Curiously Recurring:派生类声明时基类已经「知道」派生类型(编译期)。
static_cast 在这里:
不是绕过类型系统,而是告诉编译器:「这个 Base<TcpHandler>* 实际就是 TcpHandler」。
若 Derived 写错(class Bad : public Base<Other>),属于 未定义行为。
四、虚函数版网关处理器(完整示例)
#include <iostream>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
struct Packet {
std::size_t len{0};
};
class IProtocolHandler {
public:
virtual ~IProtocolHandler() = default;
virtual bool handle(Packet& pkt) = 0;
virtual std::string_view name() const = 0;
};
class TcpHandlerV : public IProtocolHandler {
public:
bool handle(Packet& pkt) override {
pkt.len += 4; // 模拟解析头
return true;
}
std::string_view name() const override { return "TCP"; }
};
class UdpHandlerV : public IProtocolHandler {
public:
bool handle(Packet& pkt) override {
pkt.len += 2;
return true;
}
std::string_view name() const override { return "UDP"; }
};
class GatewayVirtual {
public:
void registerHandler(std::unique_ptr<IProtocolHandler> h) {
handlers_.push_back(std::move(h));
}
// 热路径:对每个 handler 虚调用
__attribute__((noinline))
std::size_t dispatchAll(Packet& pkt) {
std::size_t sum = 0;
for (auto& h : handlers_)
sum += h->handle(pkt) ? pkt.len : 0;
return sum;
}
private:
std::vector<std::unique_ptr<IProtocolHandler>> handlers_;
};
特点:运行时往 vector 里塞不同 Handler;h->handle(pkt) 一定走虚函数(除非编译器去虚化并证明类型唯一,很难跨 vector)。
五、CRTP 版网关处理器(完整示例)
#include <iostream>
#include <string>
#include <string_view>
struct Packet {
std::size_t len{0};
};
template <typename Derived>
class ProtocolHandlerCrtp {
public:
__attribute__((noinline))
bool process(Packet& pkt) {
return static_cast<Derived*>(this)->handle(pkt);
}
std::string_view name() const {
return static_cast<const Derived*>(this)->name();
}
};
class TcpHandlerC : public ProtocolHandlerCrtp<TcpHandlerC> {
public:
bool handle(Packet& pkt) {
pkt.len += 4;
return true;
}
std::string_view name() const { return "TCP"; }
};
class UdpHandlerC : public ProtocolHandlerCrtp<UdpHandlerC> {
public:
bool handle(Packet& pkt) {
pkt.len += 2;
return true;
}
std::string_view name() const { return "UDP"; }
};
// 编译期固定两种 Handler(静态多态常见写法)
class GatewayCrtp {
public:
__attribute__((noinline))
std::size_t dispatchAll(Packet& pkt) {
std::size_t sum = 0;
sum += tcp_.process(pkt) ? pkt.len : 0;
sum += udp_.process(pkt) ? pkt.len : 0;
return sum;
}
private:
TcpHandlerC tcp_;
UdpHandlerC udp_;
};
// 若要在编译期遍历多种类型,可用 std::tuple + std::apply(C++17)
特点:tcp_.process(pkt) 编译期类型是 TcpHandlerC → handle 可内联进 dispatchAll 循环体。
六、汇编层面对比(你要看什么)
用 -O2 -std=c++17 编译,对比 dispatchAll 循环(可用 Compiler Explorer 或本地 g++ -S)。
虚函数版(典型)
; 伪汇编示意
mov rax, [handler] ; 取对象地址
mov rax, [rax] ; 取 vptr
call [rax + OFFSET] ; 间接调用 handle — 无法内联跨编译单元
标志:
两次内存 load(对象 → vtable)
call *reg 间接跳转
handle 体 不在 dispatchAll 里展开
CRTP 版(典型,-O2)
; 伪汇编示意 — handle 逻辑可能直接出现在循环里
add DWORD PTR [pkt+len], 4 ; TcpHandler::handle 内联
add DWORD PTR [pkt+len], 2 ; UdpHandler::handle 内联
标志:
无 vptr、无间接 call
热路径变成 直线代码 + 寄存器,对 CPU 分支预测、I-cache 更友好
量化直觉(数量级,非绝对)
场景 相对成本
虚调用 + 难内联
每次 dispatch 多 数 ns~数十 ns + 阻止周围优化
CRTP 全内联
接近手写 if (tcp) ... else if (udp) ...
百万 PPS 网关
每包省一次间接调用 → CPU 占用明显下降
注意:若虚函数版只有 一种 派生类且整个 dispatchAll 在同一 TU 且 LTO,编译器偶尔能 去虚化;但 vector<unique_ptr<IHandler>> 多态混用通常 去不掉。
七、如何自己测性能差异(概念 + 命令)
两段 相同逻辑 的 micro-benchmark(学习用):
// 公共:循环 N 次 dispatch
constexpr int N = 10'000'000;
Packet pkt{0};
// Virtual
GatewayVirtual gw;
gw.registerHandler(std::make_unique<TcpHandlerV>());
gw.registerHandler(std::make_unique<UdpHandlerV>());
for (int i = 0; i < N; ++i) gw.dispatchAll(pkt);
// CRTP
GatewayCrtp gw2;
for (int i = 0; i < N; ++i) gw2.dispatchAll(pkt);
g++ -std=c++17 -O2 -DNDEBUG bench_virtual.cpp -o bench_v
g++ -std=c++17 -O2 -DNDEBUG bench_crtp.cpp -o bench_c
time ./bench_v
time ./bench_c
# 或 perf stat -r 5 ./bench_v
预期(O2、小 handle、多态 vector):CRTP 常快 1.2x~3x 甚至更多;
若 handle 很重(解析 HTTP),差距会 缩小(瓶颈在业务而非 vcall)。
工具 看什么
time
总墙钟时间
perf stat
branch-misses、instructions(CRTP 往往指令更少)
g++ -S
有没有 call *
八、CRTP 在网关/交换机里的典型用法
1. 静态 Handler 链(数据面)
template <typename... Handlers>
class Pipeline {
std::tuple<Handlers...> chain_;
public:
bool run(Packet& p) {
bool ok = true;
std::apply([&](auto&... h) {
((ok = ok && h.process(p)), ...); // C++17 fold
}, chain_);
return ok;
}
};
using GatewayPipe = Pipeline<TcpHandlerC, HttpParserC, AuthC>;
编译期展开 → 无 vtable,类似手写流水线。
2. 混入(Mixin)能力
template <typename Derived>
class StatsMixin {
public:
void bump() { static_cast<Derived*>(this)->stats_.packets++; }
};
class TcpHandlerC : public ProtocolHandlerCrtp<TcpHandlerC>,
public StatsMixin<TcpHandlerC> { ... };
3. final + CRTP 防继承链被改
class TcpHandlerC final : public ProtocolHandlerCrtp<TcpHandlerC> { ... };
九、何时用虚函数、何时用 CRTP(决策表)
需求 选
运行时加载 .so 插件
虚函数
Handler 类型编译期固定、追求 PPS
CRTP
多种 Handler 存在同一容器且运行时增减
虚函数(或 std::variant + visit 静态分发)
交换机每包固定 2~3 级处理
CRTP / 手写 / variant
需要虚析构 + unique_ptr<Base>
虚函数接口
接口类 + 零开销
CRTP 或 concept + 模板
std::variant 折中:
using HandlerVariant = std::variant<TcpHandlerC, UdpHandlerC>;
void dispatch(HandlerVariant& h, Packet& p) {
std::visit([&](auto& concrete) { concrete.process(p); }, h);
}
运行时选分支,但 visit 常编译成跳转表,仍无 vptr,比虚函数更接近 CRTP。
更多推荐


所有评论(0)