指针、内存管理、现代 C++ 特性
- 内存与指针基础:先建立生命周期模型
-
- [1. 程序内存的工程化理解](#1. 程序内存的工程化理解)
- [2. 生命周期:构造、使用、销毁](#2. 生命周期:构造、使用、销毁)
- [3. 指针是什么,不是什么](#3. 指针是什么,不是什么)
- [4. 指针、引用与值传递](#4. 指针、引用与值传递)
- [5. 数组与边界:最常见的 P0 内存问题](#5. 数组与边界:最常见的 P0 内存问题)
- [6. 动态分配:理解即可,业务层尽量不手写](#6. 动态分配:理解即可,业务层尽量不手写)
- [7. const 与指针声明](#7. const 与指针声明)
- [8. 容器失效:现代 C++ 仍会制造悬垂访问](#8. 容器失效:现代 C++ 仍会制造悬垂访问)
- [9. 阶段验收](#9. 阶段验收)
- [RAII 与智能指针:让资源跟随对象生命周期](#RAII 与智能指针:让资源跟随对象生命周期)
-
- [1. RAII 是资源管理的默认架构](#1. RAII 是资源管理的默认架构)
- [2. 所有权术语](#2. 所有权术语)
- [3. unique_ptr:独占所有权的默认选择](#3. unique_ptr:独占所有权的默认选择)
- [4. shared_ptr:共享所有权,不是"安全裸指针"](#4. shared_ptr:共享所有权,不是“安全裸指针”)
- [5. weak_ptr:打断环并做可选观察](#5. weak_ptr:打断环并做可选观察)
- [现代 C++(C++11--C++17):提升表达力,不牺牲边界](#现代 C++(C++11–C++17):提升表达力,不牺牲边界)
-
- [1. C++11:现代基线](#1. C++11:现代基线)
-
- [auto、范围 for 与初始化](#auto、范围 for 与初始化)
- move:资源转移,不是优化咒语
- [Rule of Zero / Five](#Rule of Zero / Five)
- lambda:短小、局部、捕获明确
- [enum class、override、nullptr](#enum class、override、nullptr)
- [2. C++14:补齐泛型与资源创建体验](#2. C++14:补齐泛型与资源创建体验)
- [3. C++17:高价值生产力特性](#3. C++17:高价值生产力特性)
-
- [structured bindings](#structured bindings)
- optional:显式表达"可能没有结果"
- variant:封闭的类型集合
- string_view:不拥有字符串
- [filesystem 与并行策略](#filesystem 与并行策略)
- [4. 异常安全等级](#4. 异常安全等级)
- [5. 禁用清单](#5. 禁用清单)
- [C++20 与工程实战:可用性评估先于使用](#C++20 与工程实战:可用性评估先于使用)
-
- [1. C++20 签发条件](#1. C++20 签发条件)
- [2. concepts:让模板约束可读](#2. concepts:让模板约束可读)
- [3. ranges:管道式数据处理](#3. ranges:管道式数据处理)
- [4. span:连续内存视图](#4. span:连续内存视图)
- [5. 协程:控制流工具,不是自动多线程](#5. 协程:控制流工具,不是自动多线程)
- [6. 并发与内存模型:必须保守](#6. 并发与内存模型:必须保守)
- [7. 性能与内存预算](#7. 性能与内存预算)
- [8. 结业项目:资源缓存核心](#8. 结业项目:资源缓存核心)
- 练习、答案要点与代码审查清单
-
- [1. 基础练习](#1. 基础练习)
-
- [练习 1:修复悬垂引用](#练习 1:修复悬垂引用)
- [练习 2:所有权建模](#练习 2:所有权建模)
- [练习 3:修复循环引用](#练习 3:修复循环引用)
- [练习 4:异步 lambda 捕获](#练习 4:异步 lambda 捕获)
- [练习 5:容器失效](#练习 5:容器失效)
- [2. 结业项目验收表](#2. 结业项目验收表)
- [3. Pull Request 审查清单](#3. Pull Request 审查清单)
-
- 生命周期与资源
- 指针、引用与智能指针
- [现代 C++、性能与线程](#现代 C++、性能与线程)
- [4. 推荐资料](#4. 推荐资料)
内存与指针基础:先建立生命周期模型
1. 程序内存的工程化理解
常见进程布局可粗略理解为代码区、全局/静态存储区、堆、栈。真实布局受 OS、ABI、编译器和优化影响;不要把图当成精确地址规则。
| 区域 | 常见对象 | 生命周期 | 关键风险 |
|---|---|---|---|
| 栈(stack) | 局部变量、函数参数、调用帧 | 作用域结束自动销毁 | 栈对象地址逃逸、栈溢出 |
| 堆(free store) | 动态创建对象 | 显式/RAII 管理 | 泄漏、重复释放、碎片 |
| 静态存储区 | 全局变量、static 变量 |
进程期间 | 初始化顺序问题、全局可变状态 |
| 代码/只读区 | 指令、常量数据 | 进程期间 | 不可写数据被错误修改 |
"堆"与"栈"不是性能优劣标签:栈分配通常成本低且局部性好,但容量有限;堆适合运行期大小与跨作用域生命周期,但需要分配器管理。优先让对象按值活在最短必要作用域,而不是先决定它必须在堆上。
2. 生命周期:构造、使用、销毁
cpp
#include <iostream>
struct Probe {
explicit Probe(const char* name) : name_(name) {
std::cout << "construct: " << name_ << '\n';
}
~Probe() { std::cout << "destroy: " << name_ << '\n'; }
private:
const char* name_;
};
void Play() {
Probe scene("scene");
{
Probe effect("effect");
} // effect 在这里销毁
} // scene 在这里销毁
结论:局部对象按逆序销毁。后续 RAII、锁、文件、GPU 句柄管理都建立在这个保证上。
3. 指针是什么,不是什么
指针保存一个地址;它不携带数组长度、不自动管理对象生命周期、也不保证地址有效。
cpp
int score = 100;
int* score_ptr = &score; // 指向已有对象,非拥有
*score_ptr += 10; // 解引用:访问所指对象
指针的两层状态必须分开思考:
- 指针变量本身是否有效;
- 它指向的对象是否仍然存活、是否允许访问。
nullptr 表示"不指向对象";它不是"已释放但可继续判断"的通用状态。释放后若保留裸指针,它仍可能非空,却已悬垂。
4. 指针、引用与值传递
| 方式 | 能否为空 | 能否改绑 | 常用语义 |
|---|---|---|---|
T value |
不适用 | 不适用 | 拷贝/移动一份独立值 |
T& |
否(必须绑定对象) | 否 | 必需的非拥有别名 |
const T& |
否 | 否 | 只读借用,避免拷贝 |
T* |
是 | 是 | 可选的非拥有访问;或底层 API 互操作 |
cpp
void AddExperience(Player& player, int delta); // player 必须存在
void LogName(const std::string& name); // 只读借用
bool TryGetBoss(Scene* scene, Boss*& out_boss); // 老接口示例,需严控边界
现代接口中,若"可选但不拥有",C++17 优先考虑 std::optional<std::reference_wrapper<T>>,或清晰约定 T* 可为空。不要用引用表达可能不存在的对象。
5. 数组与边界:最常见的 P0 内存问题
cpp
int values[3] = {10, 20, 30};
// values[3] = 40; // 越界:未定义行为(UB)
未定义行为不是"必然崩溃",而是编译器不再需要按你的直觉工作。它可能看似正常、偶发错误或在优化版本中彻底改变表现。
优先使用:
- 固定长度:
std::array<T, N>; - 运行期长度:
std::vector<T>; - 只读/可写视图(C++20):
std::span<T>; - 遍历:范围
for,少手写下标。
cpp
std::vector<int> damages{12, 18, 25};
for (int damage : damages) {
// 这里不会直接暴露下标计算
}
6. 动态分配:理解即可,业务层尽量不手写
cpp
Monster* monster = new Monster();
// ... 中途 return / throw 时容易遗漏 delete
delete monster;
monster = nullptr;
new 成功后必须恰好对应一次 delete;new[] 必须对应 delete[]。但这套人工配对无法覆盖所有分支、异常与多人维护,故业务代码应采用 RAII 容器和智能指针(下一篇)。
三类必须立即识别的问题
cpp
int* BadPointer() {
int local = 42;
return &local; // 悬垂指针:local 已离开生命周期
}
void DoubleFree() {
int* p = new int(1);
delete p;
delete p; // 重复释放:UB
}
void Leak() {
int* p = new int(1);
(void)p; // 所有权丢失:泄漏
}
7. const 与指针声明
cpp
const int* read_only = nullptr; // 不能通过 read_only 修改 int
int* const fixed_address = nullptr; // 声明时必须初始化;不能改指向
const int* const fixed_read_only = nullptr;
读法:const 修饰谁,谁不可通过该表达式修改。团队代码中宁可把声明拆开写清楚,也不要写难读的多重指针声明。
8. 容器失效:现代 C++ 仍会制造悬垂访问
cpp
std::vector<int> items;
items.push_back(1);
int* first = &items[0];
items.push_back(2); // 可能扩容;first 可能失效
规则:容器发生扩容、插入、删除时,旧指针/引用/迭代器是否失效取决于容器和操作。查标准库文档;不确定时,操作后重新获取。
9. 阶段验收
- 手绘
Play()示例的构造与析构顺序。 - 说明为什么
int* p = &local;在local离开作用域后不能使用。 - 将
new[]/delete[]的动态数组改为std::vector。 - 写一个函数:输入
std::vector<int>,返回最大值;空数组必须有明确行为(异常、optional或返回状态)。 - 用 AddressSanitizer 定位一个越界访问,并写出根因。
RAII 与智能指针:让资源跟随对象生命周期
1. RAII 是资源管理的默认架构
RAII(Resource Acquisition Is Initialization):在构造函数获取资源,在析构函数释放资源。对象离开作用域时,语言保证析构执行,因此异常、早返回和多个分支都能统一回收。
资源不限于内存:文件、socket、互斥锁、数据库事务、GPU buffer、引擎对象句柄都应有明确的 RAII 包装。
cpp
class FileHandle {
public:
explicit FileHandle(const char* path) : file_(std::fopen(path, "rb")) {
if (file_ == nullptr) throw std::runtime_error("open failed");
}
~FileHandle() {
if (file_ != nullptr) std::fclose(file_);
}
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
private:
std::FILE* file_ = nullptr;
};
真实项目优先使用已有封装;不要重复发明资源管理器。这个例子只展示责任边界。
2. 所有权术语
- 拥有(owning):负责保证资源最终释放,且通常可转移责任。
- 借用/观察(borrowing/observing):可访问资源,但不负责释放;必须不超过资源生命周期。
- 共享拥有(shared ownership):多个对象共同延长资源生命周期;成本和拓扑复杂度更高。
设计接口时先回答:谁创建?谁销毁?是否允许为空?是否允许跨线程?答案决定类型,不是反过来。
3. unique_ptr:独占所有权的默认选择
cpp
#include <memory>
std::unique_ptr<Monster> CreateMonster(int config_id) {
return std::make_unique<Monster>(config_id);
}
void Spawn() {
auto monster = CreateMonster(); // Spawn 独占所有权
// 自动释放;无需 delete
}
特征:不可拷贝、可移动、几乎等价于裸指针大小。它是"一个明确所有者"的最强表达。
cpp
void Transfer(std::unique_ptr<Monster> monster) {
// 接收所有权
}
std::unique_ptr<Monster> monster = CreateMonster();
Transfer(std::move(monster));
// monster 仍是合法对象,但不再拥有资源;不要解引用
优先 make_unique:异常安全、代码更短;只有需要自定义 deleter 或对接特殊 API 时才直接构造。
4. shared_ptr:共享所有权,不是"安全裸指针"
cpp
auto texture = std::make_shared<Texture>("ui_atlas");
std::shared_ptr<Texture> ui_texture = texture;
引用计数归零时资源释放。代价包括控制块、原子计数(常见实现)、生命周期变得不直观,以及循环引用风险。
适用条件:资源确实被多个独立生命周期的模块共同拥有,且无法定义唯一上层所有者。例如异步任务与 UI 展示同时需要延长某个不可复制资源的生命期。
不适用:只是为了"方便传参"、避免理解所有权、管理父子树结构、跨线程共享可变数据。
5. weak_ptr:打断环并做可选观察
cpp
struct Player;
struct Team {
std::vector<std::shared_ptr<Player>> members;
};
struct Player {
std::weak_ptr<Team> team; // 观察,不延长 Team 生命周期
};
if (auto team = player->team.lock()) {
// 本作用域内 team 保持有效
}
weak_ptr 只能由 `share
现代 C++(C++11--C++17):提升表达力,不牺牲边界
1. C++11:现代基线
auto、范围 for 与初始化
cpp
std::vector<int> ids{1, 2, 3};
for (const auto id : ids) { /* 值拷贝 */ }
for (const auto& id : ids) { /* 只读借用 */ }
auto 用于消除冗长类型,不应用于隐藏关键所有权类型。范围 for 能减少下标越界风险,但容器修改规则仍然存在。
move:资源转移,不是优化咒语
cpp
std::vector<std::string> names;
std::string name = LoadName();
names.push_back(std::move(name));
移动后对象仍合法但状态未指定;只能赋新值、销毁或调用明确允许的操作。对 const 对象 std::move 通常无法触发真正移动。
Rule of Zero / Five
- Rule of Zero:成员由标准 RAII 类型管理时,不手写析构、拷贝、移动函数。
- Rule of Five:若手写析构、拷贝构造、拷贝赋值、移动构造、移动赋值之一,通常要审视全部五个。
cpp
class TextureOwner {
public:
TextureOwner(const TextureOwner&) = delete;
TextureOwner& operator=(const TextureOwner&) = delete;
TextureOwner(TextureOwner&&) noexcept = default;
TextureOwner& operator=(TextureOwner&&) noexcept = default;
private:
std::unique_ptr<Texture> texture_;
};
lambda:短小、局部、捕获明确
cpp
int bonus = 10;
auto calc = [bonus](int base) { return base + bonus; };
异步任务中避免 [&] 捕获局部变量:任务运行时引用可能悬垂。优先按值捕获必要数据;涉及对象生命周期时用受控句柄或 weak_ptr。
enum class、override、nullptr
cpp
enum class DamageType { Physical, Fire };
class FireEffect : public IEffect {
public:
void Tick(float dt) override;
};
它们能避免枚举污染、虚函数签名写错、0/NULL 歧义,是低成本高收益的防错特性。
2. C++14:补齐泛型与资源创建体验
std::make_unique:独占对象的首选创建方式。- 泛型 lambda:
[](const auto& value) { ... }。 - 返回类型推导、变量模板等按需使用。
泛型代码错误信息可能复杂;仅当有明确复用收益时再抽象。
3. C++17:高价值生产力特性
structured bindings
cpp
for (const auto& [id, player] : players) {
// id / player
}
确认绑定是否是引用,避免无意复制大对象。
optional:显式表达"可能没有结果"
cpp
std::optional<int> FindDamage(const Skill& skill) {
if (!skill.IsValid()) return std::nullopt;
return skill.GetDamage();
}
if (const auto damage = FindDamage(skill)) {
ApplyDamage(*damage);
}
它适合业务上正常缺失的结果。不可恢复系统错误仍按项目约定处理(错误码、异常、日志、断言)。
variant:封闭的类型集合
cpp
using Event = std::variant<DamageEvent, HealEvent, SpawnEvent>;
std::visit([](const auto& event) { Handle(event); }, event);
适合类型集合在编译期可知的事件;插件化或跨动态库边界需要评估其他设计。
string_view:不拥有字符串
cpp
void LogTag(std::string_view tag);
string_view 通常不分配也不拥有字符。禁止长期保存指向临时 std::string 的 view。
filesystem 与并行策略
std::filesystem 可减少路径拼接错误,但仍须处理 IO 失败。并行执行策略并不自动保证安全:共享数据仍需同步,游戏主线程对象通常不能跨线程访问。
4. 异常安全等级
| 等级 | 说明 | 例子 |
|---|---|---|
| 无抛出保证 | 不抛异常 | noexcept 移动、析构 |
| 强保证 | 失败后状态不变 | 临时构造成功后交换提交 |
| 基本保证 | 无泄漏、对象仍有效 | 部分工作完成但可继续使用 |
| 无保证 | 失败后状态未知 | 不应暴露给业务 |
资源释放依赖 RAII,而不是在每个可能失败的位置手动 delete。
5. 禁用清单
- 不用
std::move返回局部变量(通常妨碍 NRVO)。 - 不为小对象或简单逻辑滥用模板、lambda、
variant。 - 不跨帧缓存没有所有权保证的
string_view、迭代器或引用。 - 不为"少一次拷贝"牺牲 API 清晰度;先测量。
- 不假设标准版本可用;构建系统必须明确
/std:c++17或-std=c++17。
C++20 与工程实战:可用性评估先于使用
1. C++20 签发条件
进入项目主干前确认:
- 目标平台的编译器、标准库、CI 与调试工具都支持。
- 三方 SDK、引擎编译参数和 ABI 兼容,不能只在本机编过。
- 老平台有明确降级或条件编译策略。
- 解决的是已知问题,而不是"代码更现代"。
- 协程、ranges 等复杂特性有可维护性或性能证据。
2. concepts:让模板约束可读
cpp
#include <concepts>
template <std::integral T>
T ClampNonNegative(T value) {
return value < 0 ? 0 : value;
}
概念把约束放在接口上,改善诊断。普通业务代码仍优先普通函数;不要为使用 concepts 而强行泛型化。
3. ranges:管道式数据处理
cpp
#include <ranges>
const auto alive_ids = actors
| std::views::filter([](const Actor& a) { return a.IsAlive(); })
| std::views::transform([](const Actor& a) { return a.Id(); });
视图通常惰性且非拥有。底层容器销毁、扩容或变化后,视图/迭代器可能失效;不要保存依赖临时容器的 view。
4. span:连续内存视图
cpp
#include <span>
int SumDamage(std::span<const int> damages) {
int total = 0;
for (int damage : damages) total += damage;
return total;
}
span 替代"裸指针 + 长度",但不拥有数据。调用方仍负责底层内存的有效生命周期。
5. 协程:控制流工具,不是自动多线程
co_await 不等于创建线程。采用协程前必须明确:
- 恢复在哪个线程?UI/场景对象是否只能在主线程访问?
- 取消加载时 awaiter 如何释放资源?
- owner 销毁后 continuation 如何避免悬垂
this? - 网络超时、断线、重复回包、场景切换如何收敛?
- 如何埋点 await 延迟、失败率和取消原因?
无法回答时,优先复用项目现有异步框架。
6. 并发与内存模型:必须保守
shared_ptr 的计数操作可线程安全,不代表所管理对象可并发读写。共享可变状态必须用互斥锁、原子操作或消息队列,并明确数据所有权。
推荐客户端边界:Worker 线程做 IO、解压、纯数据构建;主线程做对象生命周期、渲染提交、UI 和 gameplay 状态变更;线程间传递不可变值或明确所有权的任务结果。不要用 volatile 实现同步。
7. 性能与内存预算
| 症状 | 先验证 | 常用方向 |
|---|---|---|
| 每帧卡顿 | profiler 中分配/锁/脚本热点 | 对象池、批处理、减少临时分配 |
| 峰值内存高 | 生命周期与缓存占用 | LRU、分包、场景卸载 |
| 加载慢 | IO、反序列化、解压耗时 | 异步流水线、预加载、二进制格式 |
| DrawCall 超标 | RenderDoc/引擎统计 | 合批、图集、剔除、LOD |
对象池不是默认优化。仅当 profile 证明高频分配是热点时使用;对象重置必须完整,禁止状态污染。
8. 结业项目:资源缓存核心
实现单线程版资源缓存:
Texture不可复制、可移动;析构记录释放日志。AssetCache用路径为 Key 管理资源;调用方只获得非拥有访问或受控句柄。- 淘汰后外部访问必须可检测失效,禁止悬垂指针。
- 业务层禁止裸
new/delete;失败路径可观测。 - 至少验证命中、未命中、重复加载、淘汰、释放五类场景。
设计评审覆盖:Key 规范化、重复加载策略、失败日志、低内存回收、异步取消与回调线程、热更、销毁线程。
练习、答案要点与代码审查清单
1. 基础练习
练习 1:修复悬垂引用
cpp
const std::string& MakeName() {
std::string name = "Hero";
return name;
}
要求:修复并说明最佳方案。
答案要点 :直接按值返回 std::string。NRVO/移动语义通常使其高效;返回局部引用非法,堆分配只会转移释放责任。
练习 2:所有权建模
场景:World 创建 Entity;System 每帧访问;UI 显示选中 Entity 名字;场景卸载后访问必须安全失效。
答案要点 :World 独占拥有 Entity(常见为 unique_ptr 或 ECS 容器);System 借用;UI 优先保存稳定 ID/句柄,每次查询有效性,不长期持有裸指针。是否使用 weak_ptr 取决于 Entity 是否真的共享所有权。
练习 3:修复循环引用
cpp
struct Parent { std::shared_ptr<Child> child; };
struct Child { std::shared_ptr<Parent> parent; };
答案要点 :通常 Parent::child 保持 shared_ptr,Child::parent 改为 weak_ptr。两条强引用形成环时,计数不会归零。
练习 4:异步 lambda 捕获
cpp
void BeginLoad() {
std::string path = GetPath();
Submit([&] { Load(path); });
}
答案要点 :函数返回后 path 已销毁。按值捕获 [path],或移动捕获 [path = std::move(path)];捕获 this 时还需处理 owner 销毁。
练习 5:容器失效
cpp
std::vector<Monster> monsters;
monsters.push_back(Monster{});
Monster* first = &monsters[0];
for (int i = 0; i < 100; ++i) monsters.push_back(Monster{});
first->Attack();
答案要点 :push_back 可能扩容并迁移元素。预知容量可 reserve;否则操作后重新定位,或改存稳定句柄/独立对象。不可把"碰巧没有扩容"当正确性。
2. 结业项目验收表
| 项目 | 通过标准 |
|---|---|
| 所有权 | 每个资源有唯一或明确共享的 owner |
| 生命周期 | 卸载、失败、提前 return、异常均无泄漏 |
| 接口 | 参数与返回值表达可空性、借用/拥有语义 |
| 内存安全 | ASan/UBSan(可用时)无报错 |
| 性能 | 未引入每帧无谓分配;关键结论有 profile 依据 |
| 并发 | Worker 不触及主线程对象;回调线程明确 |
| 可观测性 | 加载失败、淘汰、超时有日志/埋点策略 |
3. Pull Request 审查清单
生命周期与资源
- 每个资源是否有清晰 owner?
- 是否存在可替换为容器、值对象或 RAII 的散落
new/delete? - 提前 return、throw、取消时是否泄漏?
- 析构顺序是否符合依赖?
指针、引用与智能指针
- 裸指针是否仅是非拥有访问或 C API 互操作?
- 返回的引用、指针、
string_view、span是否不指向临时/局部对象? - 容器修改后是否仍使用可能失效的迭代器/引用/指针?
- 是否默认选择
unique_ptr,而非用shared_ptr回避设计? -
shared_ptr是否存在循环引用;反向关系是否是weak_ptr?
现代 C++、性能与线程
-
std::move后是否错误读取对象状态? - 异步 lambda 是否避免
[&]和无保护this? - 项目实际标准是否支持所用特性?
- 性能判断是否有 profiler/统计证据?
- 是否误把
shared_ptr当线程安全? - 确定性数值是否避开浮点累积与直接比较?
4. 推荐资料
- C++ Primer:系统补语言基础,配合编译练习。
- Effective Modern C++:理解现代特性陷阱,逐条验证。
- cppreference:查标准库语义、迭代器失效规则与版本支持。
- 编译器 warning、ASan/UBSan、静态分析:日常开发工具,不是上线前补救。
最终标准不是记住语法,而是能回答:对象在哪里创建、谁拥有、何时销毁、失败怎么办、性能证据是什么。