
五、C++ 新特性、关键字与编译原理(第 14~25 项补全)
14~25 知识点关系
现代 C++ 工程能力
├─ 标准演进
│ └─ 14. C++17/20/23 高频特性
│
├─ 数据与接口
│ ├─ 15. STL 容器选择
│ ├─ 16. string_view / span / optional
│ └─ 24. 移动语义与完美转发
│
├─ 资源与异常
│ ├─ 17. RAII
│ └─ 20. 异常处理
│
├─ 构建、链接与交付
│ ├─ 18. extern "C"
│ ├─ 19. CMake / ABI / 依赖管理
│ └─ 22. 头文件保护
│
├─ 基础关键字与编译机制
│ ├─ 21. static 使用时机
│ └─ 23. inline 类型检查
│
└─ 框架设计
└─ 25. 模板 / CRTP / 类型擦除
14. C++17/20/23 中哪些特性在工程中高频使用?
核心:C++17 解决"接口更安全、类型表达更清楚";C++20 解决"模板约束、范围处理、异步协程和连续内存视图";C++23 进一步补齐错误处理、多维数组视图、有序扁平容器和日志输出。在推理服务、数据管线、SDK 开发中,这些特性主要用于减少拷贝、提前暴露类型错误、简化资源与错误处理。
C++17 高频特性
#include <filesystem>
#include <optional>
#include <string_view>
#include <variant>
#include <iostream>
#include <map>
// 1. string_view:非拥有字符串视图,避免 const string& 触发临时对象
void print_name(std::string_view name) {
std::cout << name << '\n';
}
// 2. optional:明确表达"可能没有值"
std::optional<int> find_index(const std::map<int, int>& m, int key) {
auto it = m.find(key);
if (it == m.end()) return std::nullopt;
return std::distance(m.begin(), it);
}
// 3. variant:类型安全的 tagged union
using Device = std::variant<CpuDevice, GpuDevice, NpuDevice>;
void run(const Device& device) {
std::visit([](const auto& dev) {
dev.execute();
}, device);
}
// 4. 结构化绑定
std::map<std::string, int> counter;
for (const auto& [name, count] : counter) {
std::cout << name << count << '\n';
}
// 5. if constexpr:编译期分支
template <class T>
void describe(T value) {
if constexpr (std::is_integral_v<T>) {
std::cout << "integer\n";
} else {
std::cout << "other\n";
}
}
高频点还包括:inline 变量、折叠表达式、类模板参数推导 CTAD、std::filesystem、并行算法 std::sort(std::execution::par, ...)。
C++20 高频特性
#include <concepts>
#include <ranges>
#include <span>
#include <vector>
#include <iostream>
// concepts:约束模板参数,错误信息更清楚
template <std::integral T>
T add(T a, T b) {
return a + b;
}
void process(std::vector<int>& data) {
namespace rv = std::views;
// ranges:惰性管道,不额外构造中间容器
auto result = data
| rv::filter([](int x) { return x > 0; })
| rv::transform([](int x) { return x * 2; })
| rv::take(10);
for (int x : result) {
std::cout << x << ' ';
}
}
// span:连续内存非拥有视图
float sum(std::span<const float> values) {
float total = 0;
for (float x : values) total += x;
return total;
}
C++20 中工程上常见的还有协程 coroutine、模块 module、std::jthread 自动 join、std::format、latch/barrier/semaphore、atomic_ref、constexpr 能力增强。协程适合高并发连接、异步任务调度;span 非常适合张量缓冲区、批量输入和零拷贝接口。
C++23 高频特性
#include <expected>
#include <print>
#include <mdspan>
#include <vector>
std::expected<int, ErrorCode> parse_port(const std::string& text) {
if (text.empty()) {
return std::unexpected(ErrorCode::EmptyInput);
}
return 8080;
}
void demo() {
std::print("server port = {}\n", 8080);
}
std::expected<T, E>:函数成功返回T,失败返回错误对象E,比异常更适合可预期业务错误。std::mdspan:把一段连续内存解释成多维数组,非常适合张量形状、矩阵和批量数据视图。std::print:类型安全、性能更好的格式化输出。flat_map/flat_set:底层用有序连续数组存储,小数据量和查找密集场景缓存友好。std::move_only_function:可以持有只移动对象,例如unique_ptr捕获的回调。std::generator:简化协程生成器。
版本选择建议:
C++17:生产环境最稳,优先掌握 optional/variant/string_view/结构化绑定
C++20:现代工程主力,重点掌握 concepts/ranges/span/协程
C++23:看编译器和部署环境,逐步引入 expected/mdspan/print/flat_map
工程中不要为了追新而追新。真正高频的是那些能减少拷贝、明确所有权、表达"可能失败/可能为空"、让模板接口更可读的特性。
15. STL 容器在数据处理和推理服务中如何选择?
核心:热路径默认使用连续内存容器 vector/span,因为张量、batch、特征列都要求高缓存命中和可直接传递给底层计算库;配置和元数据使用 unordered_map/map;任务调度使用 queue/priority_queue;链表在高性能数据管线中通常应避免。
15.1 数据处理场景
张量和批量样本通常是连续内存:
#include <vector>
#include <span>
struct Tensor {
std::vector<float> data;
std::vector<int64_t> shape;
std::span<float> view() {
return data;
}
};
void infer(std::span<const float> input);
vector 的连续内存可以直接传给 C 接口、推理运行时或 GPU 拷贝函数:
std::vector<float> batch(1 * 3 * 224 * 224);
runtime.set_input(0, batch.data(), batch.size() * sizeof(float));
如果只需要读取一段已有缓冲区,不要复制成 vector,用 span:
void preprocess(std::span<const float> raw,
std::span<float> output);
15.2 元数据和配置
精确按键查找使用 unordered_map:
#include <unordered_map>
#include <string>
std::unordered_map<std::string, std::string> config = {
{"model", "resnet50"},
{"device", "cuda:0"}
};
需要范围查询或有序输出时使用 map:
#include <map>
#include <chrono>
std::map<long long, Metric> time_series;
auto begin = time_series.lower_bound(start_ms);
auto end = time_series.upper_bound(end_ms);
小而固定、频繁遍历的配置,C++23 可考虑 flat_map,它比节点式红黑树缓存更友好。
15.3 任务队列
普通 FIFO:
#include <queue>
#include <mutex>
#include <condition_variable>
std::queue<Request> pending;
std::mutex mtx;
std::condition_variable cv;
优先级调度:
struct Request {
int priority;
};
struct Compare {
bool operator()(const Request& a, const Request& b) const {
return a.priority < b.priority;
}
};
std::priority_queue<Request, std::vector<Request>, Compare> scheduler;
15.4 容器选择表
| 场景 | 推荐容器 | 原因 |
|---|---|---|
| 张量数据、特征数组 | vector |
连续内存、缓存友好、可传裸指针 |
| 不拥有的数据窗口 | span |
零拷贝表达连续区间 |
| 固定大小数组 | array |
无堆分配,大小编译期确定 |
| 请求 FIFO | queue |
接口语义明确,默认底层 deque |
| 优先级调度 | priority_queue |
堆结构,快速取最高优先级 |
| 头部尾部都增删 | deque |
双端 O(1) |
| 精确 key 查询 | unordered_map |
平均 O(1) |
| 有序 key/范围查询 | map |
O(log n),有序 |
| 去重标签 | unordered_set/set |
按是否需要顺序选择 |
| 固定类型集合 | variant |
类型安全,不使用虚继承 |
| 高频流水线 | 自定义环形缓冲/无锁队列 | STL 容器本身不提供线程安全保证 |
15.5 为什么 list 很少用于热路径
list 理论上已知位置插入删除 O(1),但每个节点独立分配,地址不连续,遍历时 cache miss 很高。数据处理中,即使 vector 中间删除是 O(n),由于内存连续和向量化,实际也经常更快。常见优化是"标记删除 + 最后统一 compact":
std::vector<Item> items;
size_t write = 0;
for (size_t read = 0; read < items.size(); ++read) {
if (items[read].valid) {
items[write++] = std::move(items[read]);
}
}
items.resize(write);
15.6 并发注意事项
STL 容器不是线程安全容器。多线程中:
- 多线程只读可以共享。
- 一个线程写、其他线程读或多个线程同时写,都需要外部同步。
- 不要在遍历线程中让另一个线程修改容器。
- 高吞吐场景可使用分片锁、MPSC 队列、环形缓冲区或对象池。
总结:数据面追求连续内存和零拷贝,控制面追求清晰映射和稳定查找;不要只根据理论复杂度选型,要结合缓存、分配次数、对象大小和并发模型。
16. std::string_view、std::span、std::optional 在高性能接口中有什么价值?
核心:string_view 和 span 是非拥有视图,用"指针 + 长度"描述已有内存,避免为了传参复制字符串或数组;optional 用类型系统表达"可能没有值",避免魔法值和空指针歧义。它们共同提高接口表达力并减少不必要拷贝。
16.1 string_view:非拥有字符串视图
传统接口:
void process(const std::string& s);
它能接收 std::string,但传入 C 字符串时可能构造临时 string,产生堆分配。string_view 只保存起始指针和长度:
#include <string_view>
void process(std::string_view s) {
if (!s.empty()) {
char first = s.front();
}
}
int main() {
process("hello"); // 不拥有字面量内存
process(std::string("world")); // 不复制原字符串
}
截取子串是 O(1),因为它只调整指针和长度:
std::string_view sv = "model/resnet50/layer1";
auto name = sv.substr(6); // 视图,不复制底层字符
注意:string_view 不保证以 \0 结尾,所以不能无脑传给要求 C 字符串的接口:
void c_api(const char* s);
// c_api(sv.data()); // 如果 sv 是子串,可能没有 \0
16.2 span:连续对象序列视图
span<T> 类似连续数组的 string_view,但可用于任意类型:
#include <span>
#include <vector>
#include <array>
#include <numeric>
float mean(std::span<const float> values) {
if (values.empty()) return 0;
float sum = std::accumulate(values.begin(), values.end(), 0.0f);
return sum / static_cast<float>(values.size());
}
int main() {
float raw[3] = {1.0f, 2.0f, 3.0f};
std::vector<float> vec = {4.0f, 5.0f};
std::array<float, 2> arr = {6.0f, 7.0f};
mean(raw);
mean(vec);
mean(arr);
}
span<const T> 表示只读视图,span<T> 表示可写视图:
void normalize(std::span<float> data) {
for (float& x : data) x = x / 255.0f;
}
它统一了数组、vector、array 和一段连续缓冲区,不需要写多个重载,也不需要传 pointer + size 两个参数。
16.3 optional:明确可能缺失
#include <optional>
#include <string>
#include <unordered_map>
std::optional<std::string> get_header(
const std::unordered_map<std::string, std::string>& headers,
const std::string& key
) {
auto it = headers.find(key);
if (it == headers.end()) {
return std::nullopt;
}
return it->second;
}
调用方必须处理"没有值"的情况:
if (auto token = get_header(headers, "token")) {
use(token.value());
} else {
reject();
}
int timeout = get_header(headers, "timeout")
.and_then(parse_int)
.value_or(30);
它比返回 -1、nullptr 更清晰,因为不存在的值不会和正常业务值混淆。
16.4 生命周期是最大风险
视图不拥有内存:
std::string_view bad() {
std::string s = "temporary";
return s; // 错误:s 销毁后 view 悬空
}
std::span<const float> bad2() {
std::vector<float> data(1024);
return data; // 错误:vector 销毁后 span 悬空
}
关系图:
拥有者:string / vector / array / 原始 buffer
│
├─ string_view:借用字符区间
├─ span:借用对象区间
└─ 所有者销毁 → 所有视图立即失效
因此,视图适合作为函数参数和短期局部处理对象,不适合作为长期保存成员,除非能明确保证所有者生命周期更长。
16.5 optional 与 expected 的分工
optional<T>:值可能不存在,这是正常情况
expected<T,E>:可能成功,也可能失败,且失败原因重要
异常:异常是意外错误,不用于普通控制流
总结:高性能接口中,string_view/span 解决"借用而不复制",optional 解决"存在或不存在"。但视图类型必须严格遵守生命周期,optional 不应替代完整错误码。
17. 为什么 RAII 是 C++ 资源管理的核心思想?
核心:RAII,Resource Acquisition Is Initialization,即"资源获取即初始化"。它把资源生命周期绑定到对象生命周期:构造函数获取资源,析构函数释放资源;对象离开作用域时自动释放,即使发生异常也能通过栈展开完成清理。
这里的资源不仅是内存,还包括文件句柄、锁、socket、线程、GPU context、模型会话、共享内存等。
17.1 手动资源管理的问题
void bad() {
FILE* fp = std::fopen("a.txt", "r");
if (!fp) return;
void* buffer = std::malloc(1024);
if (!buffer) {
std::fclose(fp); // 每一个返回路径都要记得释放
return;
}
if (process_failed()) {
std::free(buffer);
std::fclose(fp);
return;
}
std::free(buffer);
std::fclose(fp);
}
一旦分支增多或中途抛异常,就很容易泄漏。RAII 让清理动作自动发生:
#include <fstream>
#include <stdexcept>
void good() {
std::ifstream file("a.txt");
std::vector<char> buffer(1024);
if (process_failed()) {
throw std::runtime_error("failed");
}
// 离开作用域时,vector 和 ifstream 自动释放
}
17.2 自定义 RAII 资源类
#include <stdexcept>
#include <utility>
class FileHandle {
private:
FILE* fp = nullptr;
public:
explicit FileHandle(const char* path, const char* mode) {
fp = std::fopen(path, mode);
if (!fp) {
throw std::runtime_error("open file failed");
}
}
~FileHandle() {
if (fp) {
std::fclose(fp);
}
}
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
FileHandle(FileHandle&& other) noexcept : fp(other.fp) {
other.fp = nullptr;
}
FileHandle& operator=(FileHandle&& other) noexcept {
if (this != &other) {
if (fp) std::fclose(fp);
fp = other.fp;
other.fp = nullptr;
}
return *this;
}
FILE* get() const { return fp; }
};
17.3 锁是最常见 RAII
#include <mutex>
#include <vector>
std::mutex mtx;
std::vector<int> data;
void push_safe(int x) {
std::lock_guard<std::mutex> lock(mtx);
data.push_back(x);
// 无论正常退出还是异常,都会自动 unlock
}
执行过程:
进入作用域
├─ lock_guard 构造:lock
├─ 执行临界区
└─ 离开作用域:析构自动 unlock
17.4 智能指针是内存 RAII
#include <memory>
void f() {
auto p = std::make_unique<Buffer>(1024);
process(*p);
} // 自动 delete,不需要手写释放
unique_ptr:独占资源,零额外引用计数开销。shared_ptr:共享所有权,引用计数归零释放。weak_ptr:弱引用,避免循环引用。
17.5 异常安全
RAII 是 C++ 异常安全的基础。异常抛出后,编译器进行栈展开,局部对象按构造逆序析构:
构造顺序:A → B → C
异常退出:C → B → A
如果资源不是 RAII 对象,异常可能直接跳过释放语句。
17.6 Rule of Zero / Rule of Five
-
Rule of Zero:业务类尽量只使用 RAII 成员,不手写析构函数。
-
Rule of Five:如果类自己管理资源,就应同时考虑析构、拷贝构造、拷贝赋值、移动构造、移动赋值。
class GoodSession {
private:
std::unique_ptrcontext;
std::lock_guardstd::mutex lock; // 实际中注意成员生命周期
};
在推理服务中,模型会话、设备内存、流句柄、临时 buffer 都可以封装成 RAII 对象:构造时申请,析构时归还。这样可以避免请求异常中断后显存或句柄泄漏。总结:RAII 把"记得释放"变成"不可能忘记释放",是 C++ 区别于很多语言的核心资源管理机制。
18. C++ 程序调用被 C 编译器编译后的函数,为什么要加 extern "C"?
核心:C++ 支持函数重载,编译时会对函数名进行名字修饰;C 编译器不会。若 C++ 直接按 C++ 规则寻找 C 函数符号,链接器会找不到符号。extern "C" 告诉 C++ 编译器按 C 链接规则生成和查找符号。
18.1 名字修饰差异
C 函数:
int add(int a, int b);
C 编译后符号通常就是:
add
C++ 支持重载:
int add(int a, int b);
double add(double a, double b);
编译后符号可能类似:
_Z3addii
_Z3adddd
也就是把参数类型编码进符号名。链接器正是靠这些符号解析函数地址。
18.2 不加 extern "C" 的错误
假设有 C 库:
// math_c.c
int c_add(int a, int b) {
return a + b;
}
C++ 直接声明:
int c_add(int, int);
int main() {
return c_add(1, 2);
}
C++ 编译器会寻找 C++ 修饰后的符号,而 C 目标文件里只有 C 风格符号,于是链接报错:
undefined reference to `c_add(int, int)'
正确写法:
extern "C" int c_add(int a, int b);
int main() {
return c_add(1, 2);
}
18.3 标准兼容头文件
如果头文件同时被 C 和 C++ 包含:
#ifndef MATH_C_H
#define MATH_C_H
#ifdef __cplusplus
extern "C" {
#endif
int c_add(int a, int b);
void c_log(const char* message);
#ifdef __cplusplus
}
#endif
#endif
__cplusplus 只在 C++ 编译时定义,因此 C 编译器不会看到 extern "C" 语法。
18.4 extern "C" 的含义
它指定的是链接约定,不是说函数内部必须用 C 写。函数实现仍然可以是 C++:
extern "C" int create_engine() {
// 内部可以使用 std::string、vector、类
return 0;
}
但导出后,外部按 C 符号名调用它。
18.5 注意限制
extern "C" 函数不能靠参数重载:
extern "C" void f(int);
// extern "C" void f(double); // 冲突:C 链接无法区分重载
更重要的是,C 边界不应随意传递 C++ 对象:
extern "C" void process(const std::string& s); // 不推荐跨编译器边界
因为不同编译器、不同标准库、不同编译选项下,std::string 的内存布局和 ABI 可能不同。稳定 SDK 边界通常只传递:
- 基本类型;
- 不透明指针;
- C 结构体;
- 函数指针;
- 错误码。
异常也不能穿越 C 调用边界:
extern "C" int safe_entry() noexcept {
try {
run_cpp_logic();
return 0;
} catch (...) {
return -1;
}
}
18.6 链接流程图
C 源码 ──C编译器──> C 目标文件:符号 add
│
C++ 源码 ─C++编译器─> C++ 目标文件:寻找 _Z3addii
│
不加 extern "C" → 符号不匹配,链接失败
加 extern "C" → 寻找 add,链接成功
总结:extern "C" 的本质是关闭 C++ 的名字修饰,让 C++ 和 C 在链接层使用同一套符号约定。它是 C/C++ 混合编程、动态库导出和稳定 ABI 边界的基础。
19. CMake、ABI 兼容和依赖管理在 SDK 交付中为什么重要?
核心:CMake 决定 SDK 能否在不同平台和编译环境中被正确构建与接入;ABI 决定编译后的库能否被另一个编译器或选项下的程序安全链接;依赖管理决定交付是否可复现、可升级、可审计。三者共同决定 SDK 是"能在作者机器运行"还是"客户能稳定集成"。
19.1 CMake:描述构建而不是手写编译命令
一个现代 CMake 目标通常这样写:
cmake_minimum_required(VERSION 3.16)
project(inference_sdk LANGUAGES CXX)
add_library(inference_sdk
src/session.cpp
src/tensor.cpp
)
target_include_directories(inference_sdk
PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>
)
target_compile_features(inference_sdk PUBLIC cxx_std_17)
target_link_libraries(inference_sdk
PRIVATE
Threads::Threads
)
install(TARGETS inference_sdk
EXPORT inference_sdkTargets
LIBRARY DESTINATION lib
ARCHIVE DESTINATION lib
RUNTIME DESTINATION bin
INCLUDES DESTINATION include
)
PRIVATE/PUBLIC/INTERFACE 表达依赖传播:
PRIVATE:自己实现需要,调用者不需要
PUBLIC :自己需要,调用者也需要
INTERFACE:自己不需要,调用者需要
SDK 还要安装 xxxConfig.cmake,让客户可以:
find_package(inference_sdk REQUIRED)
target_link_libraries(app PRIVATE inference_sdk::inference_sdk)
19.2 ABI 兼容为什么关键
API 是源码层面的接口,ABI 是二进制层面的接口:
API 兼容:重新编译后能通过
ABI 兼容:不重新编译,旧程序直接链接新库也能运行
ABI 涉及:
- 类对象内存布局;
- 虚函数表顺序;
- 函数符号修饰;
- 参数传递和调用约定;
- 标准库类型布局;
- 结构体对齐;
- 模板实例化;
- 异常对象和 RTTI;
- 动态库符号可见性。
例如,在类中间增加一个成员变量,可能改变对象布局:
// 旧版本
class Session {
void* handle;
};
// 新版本
class Session {
int version;
void* handle; // 偏移变了
};
旧客户程序按旧布局访问,就可能崩溃。虚函数顺序变化也会破坏虚表:
class IPlugin {
public:
virtual void init();
virtual void run();
virtual ~IPlugin();
};
已发布接口中不要在中间插入虚函数,只能追加,并明确版本规则。
19.3 稳定 SDK 边界
跨编译器交付时,优先暴露 C ABI:
// sdk_c_api.h
#ifdef __cplusplus
extern "C" {
#endif
typedef struct EngineHandle EngineHandle;
EngineHandle* engine_create();
int engine_init(EngineHandle* handle, const char* path);
int engine_run(EngineHandle* handle, const float* input, int size);
void engine_destroy(EngineHandle* handle);
#ifdef __cplusplus
}
#endif
内部继续使用 C++:
EngineHandle* engine_create() {
return reinterpret_cast<EngineHandle*>(new Engine());
}
void engine_destroy(EngineHandle* handle) {
delete reinterpret_cast<Engine*>(handle);
}
也可以使用 PImpl 隐藏类布局:
class Session {
private:
class Impl;
std::unique_ptr<Impl> impl;
public:
Session();
~Session();
};
19.4 依赖管理
依赖来源常见有:
find_package:使用系统或已安装依赖
FetchContent:配置阶段下载并纳入构建
add_subdirectory:源码内置第三方库
ExternalProject:构建阶段单独构建外部项目
包管理器:vcpkg / Conan
SDK 交付时要回答:
- 依赖版本是否固定?
- 静态链接还是动态链接?
- 第三方许可证是否允许分发?
- 是否重复传递依赖导致符号冲突?
- 交叉编译时能否找到目标平台依赖?
- 客户项目已经使用不同版本依赖时怎么办?
- 运行时动态库搜索路径如何处理?
19.5 交付清单
include/:公共头文件
lib/:静态库/动态库
cmake/:Config 文件和目标导出
bin/:Windows 运行时 dll 和工具
LICENSES/:第三方许可证
docs/:版本、ABI、升级说明
总结:CMake 解决"怎么正确构建和接入",ABI 解决"编译后的二进制能否安全替换",依赖管理解决"构建是否可复现、许可证和版本是否可控"。 对 SDK 来说,代码功能正确只是第一步,可构建、可链接、可升级同样重要。
20. C++ 中异常处理机制
核心:C++ 使用 throw 抛出异常,用 try/catch 捕获异常。异常沿调用栈向上传播,期间局部对象通过栈展开自动析构。异常适合处理意外错误,正常业务分支更适合返回值、optional 或 expected。
20.1 基本语法
#include <stdexcept>
#include <iostream>
double divide(double a, double b) {
if (b == 0) {
throw std::invalid_argument("divide by zero");
}
return a / b;
}
int main() {
try {
std::cout << divide(10, 0) << '\n';
} catch (const std::invalid_argument& e) {
std::cout << "error: " << e.what() << '\n';
} catch (const std::exception& e) {
std::cout << "standard exception: " << e.what() << '\n';
} catch (...) {
std::cout << "unknown exception\n";
}
}
捕获顺序应从派生类型到基类类型,通常使用 const T&,避免对象切片和额外拷贝。
20.2 栈展开过程
f() throw
↑
e() 局部对象析构
↑
d() 局部对象析构
↑
c() 局部对象析构
↑
main() 中 try/catch 捕获
示例:
struct Resource {
~Resource() {
std::cout << "release\n";
}
};
void inner() {
Resource r;
throw std::runtime_error("fail");
}
void outer() {
inner();
}
int main() {
try {
outer();
} catch (const std::exception& e) {
// r 的析构函数仍然会执行
}
}
这就是 RAII 与异常机制的配合:局部对象在栈展开过程中自动释放资源。
20.3 标准异常层次
std::exception
├─ std::logic_error
│ ├─ invalid_argument
│ ├─ out_of_range
│ └─ length_error
├─ std::runtime_error
│ ├─ overflow_error
│ ├─ underflow_error
│ └─ system_error
├─ std::bad_alloc
├─ std::bad_cast
└─ std::bad_weak_ptr
自定义异常推荐继承 std::exception:
class SessionError : public std::runtime_error {
public:
explicit SessionError(const std::string& msg)
: std::runtime_error(msg) {}
};
20.4 noexcept
void safe_cleanup() noexcept {
// 承诺不抛异常
}
如果 noexcept 函数内部真的抛出异常且没有捕获,程序会调用 std::terminate。移动构造函数通常应标记 noexcept,因为 vector 扩容时只有知道移动不会抛异常,才会安全使用移动而不是拷贝。
20.5 异常性能
主流编译器常使用"零成本异常"模型:
- 不抛异常时,正常路径开销通常很小。
- 一旦抛出异常,需要查找处理表、展开栈、析构对象,成本较高。
因此:
高频正常路径:不要用异常表达普通分支
真正意外错误:可以使用异常
跨 C ABI:禁止异常穿越
线程入口:最外层兜底捕获
线程函数示例:
#include <thread>
void worker() noexcept {
try {
run_task();
} catch (const std::exception& e) {
log_error(e.what());
} catch (...) {
log_error("unknown error");
}
}
20.6 异常与错误码选择
| 方式 | 适合场景 | 特点 |
|---|---|---|
| 异常 | 构造失败、不可忽略的意外错误 | 正常路径简洁,抛出成本高 |
optional |
值可能不存在 | 不表达失败原因 |
expected |
可预期错误且需要原因 | 显式返回,调用方必须处理 |
| 错误码 | C 边界、实时系统、禁用异常环境 | 无异常开销,但容易忽略 |
20.7 禁用异常的工程
部分嵌入式、游戏、实时系统会关闭异常,此时应统一错误码或 expected 风格,并确保 RAII 仍可用于资源释放。跨动态库边界尤其不要让异常逃出,因为不同编译器和运行时的异常 ABI 未必兼容。
总结:异常机制不是"返回错误的另一种简单写法",而是一套配合栈展开和 RAII 的错误传播机制。设计接口时要明确哪些错误是正常可预期结果,哪些才是真正异常。
21. 什么时候用 static?
核心:当你需要让局部变量只初始化一次并保持状态、让全局符号只在当前翻译单元可见、让成员变量被所有对象共享、让成员函数不依赖具体对象时,使用 static。
21.1 函数内持久状态
需要跨多次函数调用保留值,但又不希望暴露为全局变量:
#include <iostream>
int next_id() {
static int id = 0;
return ++id;
}
int main() {
std::cout << next_id(); // 1
std::cout << next_id(); // 2
}
适合局部缓存、一次性初始化、Meyers 单例:
class Config {
public:
static Config& instance() {
static Config config;
return config;
}
private:
Config() = default;
};
C++11 保证局部 static 初始化线程安全。
21.2 当前源文件私有辅助函数或变量
// parser.cpp
static int internal_state = 0;
static void helper() {
// 只能在 parser.cpp 中访问
}
这可以避免与其他 .cpp 中的同名符号冲突。现代 C++ 更推荐匿名命名空间:
namespace {
int internal_state = 0;
void helper() {}
}
21.3 类对象之间共享数据
#include <iostream>
class Request {
private:
inline static int total = 0;
public:
Request() { ++total; }
~Request() { --total; }
static int active_count() {
return total;
}
};
所有对象共享同一份 total,适合对象计数、全局注册表、类级配置。
21.4 不依赖对象的工具函数
class MathUtil {
public:
static int square(int x) {
return x * x;
}
};
int main() {
int x = MathUtil::square(5);
}
静态成员函数没有 this,不能访问普通非静态成员,不能是虚函数,也不能加 const。
21.5 使用决策表
| 需求 | 是否使用 static |
|---|---|
| 函数调用后保留局部值 | 用 static 局部变量 |
| 只初始化一次的单例 | 用函数内 static 对象 |
隐藏 .cpp 内部辅助函数 |
用 static 或匿名命名空间 |
| 所有对象共享计数器 | 用 static 成员变量 |
| 不访问对象状态的工具函数 | 用 static 成员函数或命名空间函数 |
| 每个对象独立的数据 | 不要用 static |
| 多线程共享可变状态 | 用 static 但必须加锁或 atomic |
21.6 不适合使用的情况
static 本质上会引入隐藏共享状态,滥用会降低可重入性和可测试性:
int bad_process(int x) {
static int cache = 0; // 多线程同时访问可能竞争
cache = x;
return cache;
}
多线程下:
#include <atomic>
class Counter {
private:
inline static std::atomic<int> total{0};
public:
static void add() {
total.fetch_add(1);
}
};
还要注意静态对象初始化顺序。不同编译单元中的全局静态对象不应互相依赖,否则可能遇到"静态初始化顺序灾难"。函数内 static 可以规避一部分问题。
21.7 static 五种作用回顾
1. static 局部变量:生命周期延长,只初始化一次
2. static 全局变量:内部链接,当前文件可见
3. static 函数:内部链接,当前文件可见
4. static 成员变量:类所有,对象共享
5. static 成员函数:属于类,无 this
总结:static 同时管理"生命周期、链接属性、类级共享"三件事。使用前先问:这个状态到底属于当前对象、当前函数调用、当前源文件,还是整个类?
22. 头文件中的 #ifndef/#define/#endif 有什么作用?
核心:它们构成头文件保护宏,防止同一个头文件在同一个翻译单元中被重复包含,从而避免类、结构体、函数声明、模板定义重复出现而导致编译错误。
22.1 为什么会重复包含
// a.h
class A {};
// b.h
#include "a.h"
class B {};
// main.cpp
#include "a.h"
#include "b.h"
预处理后,main.cpp 中实际变成:
class A {};
class A {}; // 重复定义
class B {};
于是编译器报错:A 重定义。
22.2 头文件保护写法
#ifndef MODULE_STUDENT_H
#define MODULE_STUDENT_H
#include <string>
class Student {
private:
std::string name;
public:
explicit Student(std::string name);
};
#endif // MODULE_STUDENT_H
第一次包含时:
MODULE_STUDENT_H 未定义
→ 定义 MODULE_STUDENT_H
→ 保留头文件内容
第二次包含时:
MODULE_STUDENT_H 已定义
→ #ifndef 条件为假
→ 跳过头文件内容
22.3 预处理展开示意
第一次 #include "student.h"
┌────────────────────────┐
│ #ifndef STUDENT_H │ 未定义,进入
│ #define STUDENT_H │ 定义保护宏
│ class Student { ... }; │ 保留
│ #endif │ 结束
└────────────────────────┘
第二次 #include "student.h"
┌────────────────────────┐
│ #ifndef STUDENT_H │ 已定义,整段跳过
└────────────────────────┘
22.4 与 #pragma once 的区别
#pragma once
class Student {};
#pragma once 由编译器根据文件物理身份保证只包含一次,写法简洁,不会出现宏名冲突。大多数主流编译器都支持。
| 对比 | include guard | #pragma once |
|---|---|---|
| 标准程度 | C/C++ 标准支持 | 编译器扩展,但支持极广 |
| 拷贝文件/硬链接场景 | 更可控 | 依赖编译器识别同一文件 |
| 可移植性 | 最好 | 主流平台基本无问题 |
很多工程会同时使用:
#pragma once
#ifndef PROJECT_STUDENT_H
#define PROJECT_STUDENT_H
class Student {};
#endif
22.5 它不能解决什么
头文件保护只防止同一个翻译单元内重复包含 ,不能阻止不同 .cpp 分别包含同一个头文件,这本来就是正常行为:
a.cpp ──包含──> student.h
b.cpp ──包含──> student.h
每个 .cpp 是独立翻译单元,都会看到类定义,这是允许的。它也不能自动解决循环依赖:
// a.h
#include "b.h"
class A { B* b; };
// b.h
#include "a.h"
class B { A* a; };
如果只是指针或引用,可以用前置声明减少包含:
class B;
class A {
B* b;
};
22.6 宏命名建议
保护宏应尽量唯一,通常使用:
项目名_目录_文件名_H
例如:
#ifndef INFERENCE_SDK_CORE_SESSION_H
#define INFERENCE_SDK_CORE_SESSION_H
总结:#ifndef/#define/#endif 是预处理阶段的重复包含防护,它保证一个头文件在同一个 .cpp 展开结果中只出现一次,是 C/C++ 传统工程中避免重定义错误的基础手段。
23. 内联函数在编译时是做参数类型检查吗?
核心:是的,内联函数是真正的 C++ 函数,会在编译阶段进行参数类型检查、返回值检查和作用域检查;这正是它比函数式宏安全的根本原因。但 inline 只是内联建议,不保证函数体一定被展开。
23.1 宏没有类型检查
#include <iostream>
#define SQUARE(x) ((x) * (x))
int main() {
int n = 3;
std::cout << SQUARE(n++) << '\n';
}
宏在预处理阶段直接文本替换:
((n++) * (n++))
它不知道 x 的类型,也不会检查参数是否合理,还会造成多次求值。
23.2 inline 函数有完整类型检查
inline int square(int x) {
return x * x;
}
int main() {
int n = 3;
int y = square(n++); // 参数只求值一次,n 按值传入
}
编译器会检查:
- 实参能否转换为形参类型;
- 返回值类型是否匹配;
- 函数体中的操作是否合法;
- 名称查找和访问权限是否正确。
模板内联函数同样会在实例化时进行类型检查:
template <class T>
inline T square(T x) {
return x * x;
}
如果 T 不支持乘法,编译期直接报错。
23.3 inline 的真正含义
很多人误以为 inline 就是"把函数代码复制到调用处"。实际上它有两层含义:
- 建议编译器内联展开,编译器可以忽略。
- 允许函数在多个翻译单元中出现相同定义,即突破单一定义规则中对函数的普通限制。
第二点在头文件中尤其重要:
// math.h
#pragma once
inline int add(int a, int b) {
return a + b;
}
多个 .cpp 都包含这个头文件时,不会产生重复定义链接错误。编译器或链接器会合并这些相同定义。
23.4 类内定义函数隐式 inline
class Calculator {
public:
int add(int a, int b) const {
return a + b; // 类内定义,隐式 inline
}
int sub(int a, int b) const;
};
inline int Calculator::sub(int a, int b) const {
return a - b;
}
23.5 编译器是否内联由它决定
以下情况编译器可能拒绝内联:
-
函数太大;
-
函数包含循环或复杂分支;
-
函数是虚函数且真实类型运行时才知道;
-
函数地址被取用;
-
调试构建关闭优化;
-
跨动态库边界。
inline virtual void f() {}
// 通过指针动态调用时仍可能无法内联
现代 C++ 更推荐让编译器根据优化级别自动判断,不必给小函数机械添加 inline。但如果函数定义放在头文件中,inline 仍然承担 ODR 作用。
23.6 对比宏、inline、模板
| 方式 | 处理阶段 | 类型检查 | 多次求值 | 适合场景 |
|---|---|---|---|---|
| 函数式宏 | 预处理 | 无 | 可能 | 极少,需利用文本特性时 |
| inline 函数 | 编译阶段 | 有 | 不会 | 头文件中的小函数 |
| 模板函数 | 编译实例化 | 有,且更强 | 不会 | 类型无关通用逻辑 |
| 普通函数 | 编译/链接 | 有 | 不会 | 一般函数 |
23.7 编译流程位置
源代码
↓
预处理:宏展开、#include,不做 C++ 类型检查
↓
编译:词法/语法/语义分析,inline 函数在这里做类型检查
↓
优化:决定是否内联展开
↓
汇编/链接:合并 inline 定义
所以准确回答是:内联函数当然会做参数类型检查,因为它不是宏;预处理只负责文本,类型检查发生在编译阶段。inline 是否展开是编译器优化决策,而它在头文件中允许多处定义的链接语义同样重要。
24. C++ 移动语义和完美转发如何减少数据拷贝?
核心:拷贝是复制一份资源,移动是转移资源所有权;std::move 把左值强制转换成右值引用,让移动构造或移动赋值接管资源;完美转发通过转发引用和 std::forward 保持参数原本的左值/右值属性,避免中间层产生多余拷贝。
24.1 拷贝与移动的区别
#include <cstring>
#include <utility>
class Tensor {
private:
float* data = nullptr;
std::size_t size = 0;
public:
explicit Tensor(std::size_t n) : size(n) {
data = new float[n];
}
~Tensor() {
delete[] data;
}
// 拷贝构造:深拷贝
Tensor(const Tensor& other) : size(other.size) {
data = new float[size];
std::memcpy(data, other.data, size * sizeof(float));
}
// 移动构造:直接接管资源
Tensor(Tensor&& other) noexcept
: data(other.data), size(other.size) {
other.data = nullptr;
other.size = 0;
}
};
对比:
拷贝:
源对象 ──复制一块新内存──> 新对象
源对象仍然完整保留
移动:
源对象 ──指针所有权转移──> 新对象
源对象进入有效但状态未指定的 moved-from 状态
24.2 std::move 本身不移动任何东西
Tensor a(1024 * 1024);
Tensor b = a; // 拷贝
Tensor c = std::move(a); // 选择移动构造
std::move(a) 只是一个类型转换,近似:
static_cast<Tensor&&>(a);
真正执行资源转移的是移动构造函数或移动赋值运算符。移动后的 a 仍然是合法对象,可以重新赋值或析构,但不要假设其值具体是什么。
24.3 vector 中的移动收益
#include <vector>
#include <string>
std::vector<std::string> pipeline;
std::string large_text = load_big_text();
pipeline.push_back(large_text); // 拷贝
pipeline.push_back(std::move(large_text)); // 移动,避免复制大字符串
pipeline.emplace_back(1024, 'x'); // 原地构造
vector 扩容时,如果元素的移动构造是 noexcept,它会直接移动;否则为了异常安全,可能退化为拷贝。因此移动构造和移动赋值通常应标记 noexcept。
24.4 右值引用成员接口
class Request {
private:
std::vector<float> payload;
public:
void set_payload(const std::vector<float>& data) {
payload = data; // 左值:拷贝
}
void set_payload(std::vector<float>&& data) {
payload = std::move(data); // 右值:移动
}
};
更简洁的方式是按值传参再移动:
void set_payload(std::vector<float> data) {
payload = std::move(data);
}
调用方传左值时发生一次拷贝,传右值时发生移动。
24.5 完美转发
普通包装函数可能丢失右值属性:
template <class T>
void bad_wrapper(T x) {
target(x); // x 是左值,即使调用者传右值,也按左值处理
}
转发引用配合 std::forward:
#include <utility>
template <class... Args>
void wrapper(Args&&... args) {
target(std::forward<Args>(args)...);
}
Args&& 在模板类型推导下是转发引用,也称万能引用:
void use(std::string s);
template <class T>
void relay(T&& value) {
use(std::forward<T>(value));
}
int main() {
std::string s = "hello";
relay(s); // 左值仍按左值传
relay(std::string("tmp")); // 临时对象继续按右值移动
}
24.6 工厂和 emplace
#include <memory>
#include <string>
struct Model {
Model(std::string name, int device);
};
template <class... Args>
std::unique_ptr<Model> make_model(Args&&... args) {
return std::make_unique<Model>(std::forward<Args>(args)...);
}
参数被原样转发给构造函数,避免中间临时对象。
24.7 在数据管线中的价值
未优化:
读取数据 → 复制成 Request → 复制到队列 → 复制给 Worker → 复制到输出
移动/转发后:
读取数据 → 构造 Request → 所有权移动到队列 → 所有权移动给 Worker → 移动输出
大块 tensor、字符串、vector、文件句柄、模型上下文都属于"移动远便宜于拷贝"的对象。
注意事项:
- 不要移动后继续使用原对象的业务数据。
const T&&基本没有实用价值,因为不能修改源对象。- 基本类型移动和拷贝成本相同。
- 移动函数应保证源对象仍可安全析构和重新赋值。
- 跨接口移动时明确所有权转移方向。
总结:移动语义把"复制资源"变成"转移句柄",完美转发保证泛型中间层不破坏值类别;二者组合可以显著减少大缓冲区、容器和请求对象在管线中的复制次数。
25. C++ 模板、CRTP 和类型擦除如何用于高性能框架?
核心:模板在编译期生成类型专用代码,实现零运行时开销的静态多态;CRTP 通过派生类作为基类模板参数,在编译期实现多态和能力混入;类型擦除在边界隐藏具体类型,对外提供统一运行时接口。高性能框架通常内部用模板和 CRTP 追求性能,边界用类型擦除保持扩展性。
25.1 模板:编译期泛型
#include <vector>
#include <numeric>
template <class T>
T sum(const std::vector<T>& values) {
T total{};
for (const auto& x : values) {
total += x;
}
return total;
}
编译器为 sum<int>、sum<float> 分别生成专用代码,没有虚函数调用,也没有运行时类型判断。
C++20 可用 concepts 约束类型:
#include <concepts>
template <std::arithmetic T>
T scale(T value, T factor) {
return value * factor;
}
25.2 CRTP:静态多态
普通虚函数多态:
class LayerBase {
public:
virtual void forward() = 0;
};
每次调用可能经过虚表。CRTP 写法:
template <class Derived>
class LayerBase {
public:
void forward() {
static_cast<Derived*>(this)->forward_impl();
}
};
class ConvLayer : public LayerBase<ConvLayer> {
public:
void forward_impl() {
// 卷积实现
}
};
class ReluLayer : public LayerBase<ReluLayer> {
public:
void forward_impl() {
// ReLU 实现
}
};
调用关系:
LayerBase<Derived>::forward()
→ 编译期已知 Derived
→ static_cast<Derived*>
→ 直接调用 Derived::forward_impl()
→ 可内联,无 vptr/vtable
CRTP 也适合实现通用能力混入:
template <class Derived>
class EqualityComparable {
public:
friend bool operator!=(const Derived& a, const Derived& b) {
return !(a == b);
}
};
class Point : public EqualityComparable<Point> {
public:
int x, y;
bool operator==(const Point& other) const {
return x == other.x && y == other.y;
}
};
25.3 类型擦除:隐藏具体类型
模板性能高,但类型必须编译期已知,容器中不能自然存放不同模板类型。类型擦除把具体实现藏在统一接口后面。
手写简化版:
#include <memory>
#include <utility>
class AnyTask {
private:
struct Concept {
virtual ~Concept() = default;
virtual void run() = 0;
};
template <class T>
struct Model : Concept {
T object;
explicit Model(T obj) : object(std::move(obj)) {}
void run() override {
object.run();
}
};
std::unique_ptr<Concept> ptr;
public:
template <class T>
AnyTask(T task) : ptr(std::make_unique<Model<T>>(std::move(task))) {}
void run() {
ptr->run();
}
};
于是不同类型可以放进同一容器:
#include <vector>
class DecodeTask {
public:
void run();
};
class EncodeTask {
public:
void run();
};
int main() {
std::vector<AnyTask> tasks;
tasks.emplace_back(DecodeTask{});
tasks.emplace_back(EncodeTask{});
for (auto& task : tasks) {
task.run();
}
}
标准库中的 std::function、std::any、std::shared_ptr 的删除器都使用了类型擦除思想。
25.4 三者分工
模板
├─ 优点:编译期展开、可内联、零虚调用
├─ 缺点:不同类型代码膨胀,类型必须编译期知道
└─ 场景:热路径、算法、数值计算
CRTP
├─ 优点:静态多态、无虚表、可复用基类能力
├─ 缺点:继承写法不直观,不能运行时替换
└─ 场景:算子层、Mixin、统一接口但类型固定
类型擦除
├─ 优点:统一运行时接口,可存放异构对象
├─ 缺点:通常有虚调用或间接访问
└─ 场景:框架边界、任务队列、插件、回调
25.5 框架中的组合方式
编译期热路径:
TensorOp<T> / CRTP Layer
→ 内联、优化、向量化、零虚调用
运行时边界:
统一 Scheduler / Task / Plugin
→ 类型擦除,接收不同算子和任务类型
示例:
template <class Derived>
class OperatorBase {
public:
void execute() {
static_cast<Derived*>(this)->compute();
}
};
class AddOp : public OperatorBase<AddOp> {
public:
void compute();
};
class MulOp : public OperatorBase<MulOp> {
public:
void compute();
};
内部算子使用 CRTP,外部调度器使用类型擦除:
std::vector<AnyTask> runtime_tasks;
25.6 与虚函数比较
| 技术 | 分派时间 | 是否需要共同基类 | 适用范围 |
|---|---|---|---|
| 虚函数 | 运行时 | 需要 | 经典多态、插件 |
| 模板 | 编译期 | 不需要 | 算法泛型 |
| CRTP | 编译期 | 需要模板基类 | 静态多态、能力复用 |
| 类型擦除 | 运行时 | 对外不需要暴露具体基类 | 异构对象统一封装 |
总结:模板和 CRTP 用编译期信息换运行时性能,类型擦除用少量运行时间接换接口统一。高性能框架不是完全排斥虚函数,而是在热路径静态化,在扩展边界动态化。
第 14~25 项总结
| 题号 | 关键词 | 核心结论 |
|---|---|---|
| 14 | C++17/20/23 | 重点掌握零拷贝视图、概念约束、范围处理、协程、expected 与 mdspan |
| 15 | STL 选择 | 数据面优先连续内存,控制面按查找和顺序需求选 map/unordered_map |
| 16 | view/optional | 视图借用内存避免拷贝,optional 表达可能缺失 |
| 17 | RAII | 资源绑定对象生命周期,构造获取、析构释放 |
| 18 | extern "C" | 关闭 C++ 名字修饰,实现 C/C++ 链接兼容 |
| 19 | SDK 交付 | CMake 管构建,ABI 管二进制兼容,依赖管理管可复现 |
| 20 | 异常 | throw/try/catch 配合栈展开和 RAII,异常不能跨 C 边界 |
| 21 | static | 管理持久局部状态、内部链接、类共享成员和静态函数 |
| 22 | include guard | 防止同一翻译单元重复包含头文件 |
| 23 | inline | 是真正函数,有类型检查;是否展开由编译器决定 |
| 24 | move/forward | 转移所有权、保留值类别,减少大对象复制 |
| 25 | 模板/CRTP/擦除 | 热路径静态多态,框架边界类型擦除 |
整体记忆主线:
现代接口:string_view / span / optional / expected
现代资源:RAII / unique_ptr / 移动语义
现代泛型:template / concepts / CRTP / 类型擦除
现代交付:CMake / ABI / extern "C" / 依赖管理
现代健壮性:异常、static、头文件保护、inline 规则