第 10 章:指针、STL 与纹理管理
第 10 章:指针、STL 与纹理管理:保留第三版项目代码讲解,并以源码—状态—输出切片、确定性轨迹和故障重放完成验收。
学习目标
- 能解释“第 10 章:指针、STL 与纹理管理”如何区分拥有与观察指针,用 std::vector 管理可变实体集合,并把纹理生命周期提升到所有 Sprite 之上
- 能逐项定位 指针(pointers)、标准模板库(standard template library)、容器(container)、纹理管理(texture management),说明它们位于源码、运行状态还是可见输出边界
- 能按 创建所有者 → 加入 vector → 记录容量 → 更新实体 → 按序销毁 重放“生成一波僵尸”,持续检查“每个动态对象只有一个明确所有者,vector 变更后不继续使用可能失效的元素地址,纹理活得比 Sprite 久”
- 能注入“保存 vector 元素指针后触发扩容,再通过旧地址更新僵尸”,从所有权图、vector size/capacity、扩容前后地址、析构日志及纹理/精灵寿命找到第一个不一致并用同输入恢复
第三版来源、工具链与版本边界
“第 10 章:指针、STL 与纹理管理”对齐 Packt 2024 年第三版的对应章节范围,并以官方公开代码仓库和 SFML 官方文档核对本页可公开验证的工程事实。本页是独立中文重写,不复现原书正文,也不把商品页、目录或代码仓库冒充完整原版。
在“第 10 章:指针、STL 与纹理管理”中,第三版示例使用 SFML 2.6.1 时代 API。本页保留与本章有关的 2.6 系列合同;SFML 3 的事件、角度、时长和构造接口差异不会被静默回填。升级工具链时必须单独记录本章迁移补丁,不能把版本不匹配误判为“指针(pointers)”概念错误。
- Packt:Beginning C++ Game Programming, Third Edition:在“第 10 章:指针、STL 与纹理管理”中,核对 2024 年第三版、C++20、SFML、四个项目、21 个正式教学章节及章节次序;不把商品页当作正文全文。
- PacktPublishing:第三版官方代码仓库:在“第 10 章:指针、STL 与纹理管理”中,核对 Timber、Pong、ZombieShooter、Run 四个项目的公开代码与资源组织;代码许可证不等于原书正文授权。
- SFML 2.6 官方教程:在“第 10 章:指针、STL 与纹理管理”中,核对本书使用的 SFML 2.6 系列 Window、Graphics、View、VertexArray、Shader 与 Audio API 语义。
正式概念与运行状态合同
指针(pointers)
这个正式目录节点落在 所有权 边界:对象创建、指针职责与析构。在本页中,它参与“区分拥有与观察指针,用 std::vector 管理可变实体集合,并把纹理生命周期提升到所有 Sprite 之上”。验收时保存所有权图、vector size/capacity、扩容前后地址、析构日志及纹理/精灵寿命,不能只凭最终画面判断。
标准模板库(standard template library)
这个正式目录节点落在 集合变化 边界:vector 容量、插入、擦除和迭代。在本页中,它参与“区分拥有与观察指针,用 std::vector 管理可变实体集合,并把纹理生命周期提升到所有 Sprite 之上”。验收时保存每个动态对象只有一个明确所有者,vector 变更后不继续使用可能失效的元素地址,纹理活得比 Sprite 久,不能只凭最终画面判断。
容器(container)
这个正式目录节点落在 资源绑定 边界:Texture 所有者与 Sprite 观察关系。在本页中,它参与“区分拥有与观察指针,用 std::vector 管理可变实体集合,并把纹理生命周期提升到所有 Sprite 之上”。验收时保存所有权图、vector size/capacity、扩容前后地址、析构日志及纹理/精灵寿命,不能只凭最终画面判断。
纹理管理(texture management)
这个正式目录节点落在 所有权 边界:对象创建、指针职责与析构。在本页中,它参与“区分拥有与观察指针,用 std::vector 管理可变实体集合,并把纹理生命周期提升到所有 Sprite 之上”。验收时保存每个动态对象只有一个明确所有者,vector 变更后不继续使用可能失效的元素地址,纹理活得比 Sprite 久,不能只凭最终画面判断。
| 验收项 | 本页合同 |
|---|---|
| 最小正常场景 | 预留足够容量后创建并加入固定数量实体 |
| 边界或恢复场景 | 保存元素地址后插入直到 vector 扩容 |
| 必须保持 | 每个动态对象只有一个明确所有者,vector 变更后不继续使用可能失效的元素地址,纹理活得比 Sprite 久 |
| 单一故障 | 保存 vector 元素指针后触发扩容,再通过旧地址更新僵尸 |
| 可观察证据 | 所有权图、vector size/capacity、扩容前后地址、析构日志及纹理/精灵寿命 |
先预测,再操作三个本页实验
实验一:从源码到可见结果
先预测“生成一波僵尸”会怎样穿过 所有权 → 集合变化 → 资源绑定,再切换场景和正式概念。每次操作都必须能回到同一初始状态。
Source · state · visible result
第 10 章:指针、STL 与纹理管理:可运行切片
区分拥有与观察指针,用 std::vector 管理可变实体集合,并把纹理生命周期提升到所有 Sprite 之上
选择输入或构建场景
定位正式概念
bcgp3-10 · 当前切片
指针(pointers):预留足够容量后创建并加入固定数量实体
对象创建、指针职责与析构
↓
vector 容量、插入、擦除和迭代
↓
Texture 所有者与 Sprite 观察关系
可验收结果
集合大小正确,所有对象被更新且退出时只析构一次
实验二:逐步执行状态轨迹
依次执行 创建所有者 → 加入 vector → 记录容量 → 更新实体 → 按序销毁。每一步只选中一个阶段,并持续检查“每个动态对象只有一个明确所有者,vector 变更后不继续使用可能失效的元素地址,纹理活得比 Sprite 久”。
Deterministic state trace
第 10 章:指针、STL 与纹理管理:状态执行轨迹
每个动态对象只有一个明确所有者,vector 变更后不继续使用可能失效的元素地址,纹理活得比 Sprite 久
所有权图、vector size/capacity、扩容前后地址、析构日志及纹理/精灵寿命
实验三:单一故障与同输入恢复
注入“保存 vector 元素指针后触发扩容,再通过旧地址更新僵尸”,定位第一项不一致;撤销后用完全相同的“触发重新分配”重放。只有中间状态和最终输出一起恢复才算修复。
Fault injection · clean replay
第 10 章:指针、STL 与纹理管理:故障注入与恢复
单一故障:保存 vector 元素指针后触发扩容,再通过旧地址更新僵尸
第 1 次重放使用同一源码、资源、初始状态和输入序列
每个动态对象只有一个明确所有者,vector 变更后不继续使用可能失效的元素地址,纹理活得比 Sprite 久
所有权图、vector size/capacity、扩容前后地址、析构日志及纹理/精灵寿命
同输入重放后,所有权、状态更新、可见输出和诊断证据重新一致
易错边界与工程取舍
从“一千只僵尸是否要复制一千张纹理”开始
Zombie Arena 要在运行期生成数量变化的敌人。第 10 章用
↡保存对象或函数地址的值类型,可为空且可改指向;它本身不说明所有权,也不延长目标生命周期。连接对象,用
↡C++ 标准库中容器、算法、迭代器和其他通用组件的集合。管理可增长集合,并建立多只精灵共享一份 Texture 的资源协议。
地址、空指针与解引用
↡对象在其存储期内占据的内存位置,可用取地址运算符获得并保存到兼容指针。只有在对象存活且没有因移动、删除或容器重分配改变位置时才可使用。Zombie* p = &zombie 不复制 Zombie,也不让它活得更久。
Zombie zombie{spawnPosition};
Zombie* target{&zombie};
if (target != nullptr)
target->update(dt, player.position());适合表达“当前没有目标”。非空仍不足以证明有效:目标可能已销毁,留下数值非零的悬空指针。
↡通过指针访问其目标对象或成员的操作;前置条件是指针指向有效且类型兼容的存活对象。失败不是可靠异常,而通常是未定义行为。函数参数若必定需要对象,引用比可空指针更直接;若允许没有目标,指针配合明确检查合适。
所有权:原始指针不等于 delete 责任
↡决定谁负责让对象存活、何时销毁,以及失败和移动时如何转移责任的协议。
最好由类型表达。std::vector<Zombie> 直接拥有值对象;std::unique_ptr<Zombie> 表达独占动态所有权;std::shared_ptr 表达共享所有权但带来引用环和开销,不能因“不想思考生命周期”就默认使用。
std::vector<std::unique_ptr<Zombie>> polymorphicHorde;
polymorphicHorde.push_back(std::make_unique<FastZombie>(spawn));当前 Zombie 若是同一具体类型,优先 vector<Zombie>:连续存储、所有权简单、少一次间接访问。只有需要运行时多态且对象身份必须稳定时,才考虑 unique_ptr 容器。不要手写 new[]/delete[] 管群体。
解决释放责任,不自动解决指向对象的所有观察者失效。
vector:大小、容量与重分配
↡标准库动态连续序列容器,拥有元素并分别维护当前元素数 size 与已分配槽位 capacity。
适合一批同类型 Zombie。reserve(n) 只预留容量,不创建元素;访问 horde[0] 前仍需 size()>0。
std::vector<Zombie> horde;
horde.reserve(maximumZombies);
for (std::size_t index{0}; index < initialCount; ++index)
horde.emplace_back(randomSpawn(randomEngine));
for (Zombie& zombie : horde)
zombie.update(dt, player.position());是最常见生命周期陷阱。reserve 足够容量可推迟重分配,但后续超过容量仍会发生,不能把元素地址当永久 id。
迭代器与删除阶段
↡抽象容器位置并支持遍历的对象;其有效性受容器具体修改操作的失效规则约束。删除 vector 元素会移动后续元素,使被删位置及之后的迭代器、引用和指针失效。不要在范围 for 内直接 erase 当前元素。
#include <algorithm>
std::erase_if(horde, [](const Zombie& zombie) {
return !zombie.alive();
});C++20 erase_if 清楚表达过滤。更新阶段可先标记死亡,所有碰撞和事件处理完成后统一删除;删除后不再使用本帧保存的元素地址。若其它系统需要长期身份,给 Zombie 分配稳定 id 并通过索引表查询,而不是暴露 vector 地址。
算法中的 lambda 按引用捕获外部状态时也受生命周期约束。只在同步调用期使用通常安全;存进异步任务前必须明确捕获副本或所有权。
指针、引用和迭代器失效表
push_back 未重分配时现有元素地址保持,发生重分配时全部失效;erase 使删除点及之后失效;clear 使所有元素引用失效;vector 销毁后全部失效。reserve 若增大 capacity 本身就可能重分配。
这些规则属于容器契约,不应靠一次调试地址“看起来没变”判断。启用标准库调试模式和 AddressSanitizer 可发现部分错误,但设计上应缩短观察指针生命周期:在一次循环内取得、使用,容器修改前丢弃。
↡对象已销毁或地址失效后仍保留的指针或引用,任何解引用都会产生未定义行为。即使内存尚未被覆盖也不合法。
纹理管理:加载一次,稳定借用
↡集中加载、复用和释放 Texture,使多个 Sprite 共享资源并保持引用地址稳定的生命周期协议。先解决原则,第 11 章再封装 TextureHolder。生成一千只僵尸时,不应一千次从磁盘解码并上传相同纹理;所有 Zombie Sprite 可绑定同一个 Texture。
sf::Texture zombieTexture;
if (!zombieTexture.loadFromFile("assets/graphics/zombie.png"))
throw std::runtime_error{"failed to load zombie texture"};
std::vector<Zombie> horde;
horde.reserve(maximumZombies);
for (std::size_t index{0}; index < count; ++index)
horde.emplace_back(zombieTexture, randomSpawn(randomEngine));要求 Texture 比所有 Sprite 活得更久。声明顺序可让 texture 先构造、horde 后构造,从而销毁时 horde 先、texture 后;更大项目由资源库显式控制。
资源容器的地址稳定性
不能随便用 vector<Texture> 再保存元素引用:扩容会移动 Texture 对象,Sprite 仍指旧地址。节点式 std::map<std::string, sf::Texture> 的元素引用在插入其他项时保持稳定,删除对应项仍会失效。unordered_map 重哈希使迭代器失效,但标准保证元素引用和指针不因 rehash 失效;擦除目标仍失效。
std::map<std::string, sf::Texture> textures;
auto [it, inserted] = textures.try_emplace("zombie");
if (inserted && !it->second.loadFromFile("assets/graphics/zombie.png")) {
textures.erase(it);
throw std::runtime_error{"zombie texture load failed"};
}
const sf::Texture& zombieTexture{it->second};加载失败必须回滚空条目,否则下一次查询误以为资源已缓存。返回 const 引用只读借用,资源库禁止在精灵仍存活时 erase。第 11 章会把这些不变量封装成 TextureHolder。
热重载与关闭顺序
开发期热重载不能先销毁旧 Texture 再构造新对象。若 SFML 支持在同一 Texture 对象上重新 loadFromFile,地址保持但内容更新仍要处理失败:先加载候选,成功后 move-assign 是否保持 Sprite 关联要按库语义验证;更稳妥是资源句柄层通知 Sprite 重新绑定。
关闭顺序从借用者到拥有者:先停止游戏更新,销毁 Zombie/Sprite,再销毁纹理库和窗口上下文。音频、字体也遵循类似图。进程退出看似会回收内存,但明确顺序能避免析构期间访问已销毁资源。
纹理键要规范化路径和颜色空间等变体,同一路径不同相对写法不应重复加载。缓存没有上限也会增长;关卡切换要按资源组释放,但只有确认无借用后才能删。
构建僵尸群的完整流程
关卡开始先确定 count 与上限,reserve 后 emplace Zombie;每个对象借用已加载纹理并拥有位置、速度和血量。每帧用引用遍历更新和绘制,碰撞阶段只标记死亡,帧尾统一 erase。任何长期目标使用稳定 id 查询。
生成位置必须在竞技场内、与 Player 保持最小距离并有最大尝试次数,避免随机条件不可满足时无限循环。构造中途抛异常时 vector 自动销毁已完成 Zombie;纹理由更外层资源库继续拥有。
性能测试分别记录加载次数、draw calls、update 时间与内存。共享 Texture 解决重复资源,不自动解决一千次 draw;后续纹理一致的实体可进一步批处理,但不要牺牲正确生命周期换表面 FPS。
先预测:保存
Zombie* target = &horde[0]后持续 push_back,reserve 容量被超过会怎样?vector 重分配移动全部元素,target 悬空;应使用稳定 id 重新查询或选择满足身份需求的存储模型。
小结
- 指针可空可改指向但不表达所有权,解引用必须证明目标仍存活且地址未失效
- 同类型 Zombie 优先由 vector 按值拥有,需要多态身份时再用 unique_ptr 等明确所有权
- vector 重分配使全部元素观察地址失效,erase 使删除点及之后失效,删除集中在安全阶段
- 纹理只加载一次并由稳定资源层拥有,多个 Sprite 共享借用而不复制图像
- 资源容器要审计地址稳定和 erase 规则,加载失败回滚,使用期禁止删除
- 关闭时先销毁 Sprite/实体借用者,再销毁 Texture 所有者;热重载也必须保持或重建绑定
练习
问题 1 指针非空为什么仍可能不能解引用?列出 vector 的三个失效场景。
问题 2 一千只同类型僵尸应选择 vector<Zombie>、vector<unique_ptr<Zombie>> 还是裸指针数组?说明条件。
问题 3 纹理缓存为何必须地址稳定?加载失败、热重载和关闭分别怎样处理?
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 指针
保存对象或函数地址的值,可为空可改指向,不自动表示所有权。
- 标准模板库
C++ 标准库中的通用容器、算法、迭代器和其他组件集合。
- 对象地址
- 对象在有效存储期内占据的位置。
- 空指针
不指向任何对象的 nullptr 值,只能检查不能解引用。
- 解引用
- 经指针访问有效目标对象的操作。
- 所有权
规定谁维持对象生命、何时销毁和怎样转移责任的协议。
- std::unique_ptr
独占拥有动态对象并在作用域结束自动释放的可移动智能指针。
- std::vector
拥有元素的动态连续序列容器,区分 size 与 capacity。
- 重分配
vector 更换连续存储并移动元素的过程,会使旧元素地址失效。
- 迭代器
抽象容器位置并支持遍历、受容器修改失效规则约束的对象。
- 悬空指针
目标已销毁或地址失效后仍保留的不可解引用指针。
- 纹理管理
集中加载、复用和释放 Texture 并保证 Sprite 借用期的协议。
- 纹理共享
多个 Sprite 关联同一个长生命周期 Texture 并各自保存变换状态。