ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++游戏开发中enum class实战:从传统enum坑到类型安全迁移

C++游戏开发中enum class实战:从传统enum坑到类型安全迁移

做游戏客户端这几年,C++枚举是几乎每个系统都会用到的基础设施:角色状态、怪物AI、技能类型、消息通道、资源分类,随手一数就是一大串。以前项目里到处是C风格的enum,写起来确实爽——不用敲那么多字,还自动兼容整数。可一旦代码量堆起来,这种“省事”就开始一个接一个地还债。我记得最典型的一次,两个不同模块的枚举里都有Idle,一个是动画状态的Idle,一个是逻辑状态机的Idle,传统enum在同一个编译单元里直接重定义;还有一次,同事把攻击类型枚举当伤害数值传进了减伤函数,编译器一声不吭,运行时数值完全不对。这类问题几乎都是同一根源:传统enum的类型能力过于宽松,编译器根本不设防。所以现在新项目、新文件,我基本只用enum class,老代码也慢慢在迁。这篇文章就把这几年的使用经验整理一遍,适合正在用C++写游戏、或者刚进项目准备接手状态机这类系统的同学,看完能少走不少弯路。

1. 传统enum在游戏项目里留下的坑

1.1 隐式整型转换:类型安全形同虚设

C风格enum本质上就是个带名字的整数常量,规则允许它无痛变成int。游戏里的接口向来又多又杂,参数类型手一抖传错特别常见。比如有一个技能系统接口:

void ApplyDamage(Actor* target, int damageType, int amount);

调用方写:

ApplyDamage(player, AttackType::Fire, 100);

编译不会报任何错误,因为AttackType::Fire自动变成了int。可一旦某个模块的接口改了实现,或者另一个函数重载了int版本,枚举的语义就丢了。更隐蔽的是跨枚举比较:

ActorState state = ActorState::Run; if (state == AnimState::Walk)

传统enum下这行也能编译,二者都隐式转成int。如果两个枚举的值恰好相同,这个条件就莫名其妙成立了。游戏里AI状态和动画状态本来就不是一回事,这种代码往往要等玩家触发某个交互流程之后才会暴露出问题,排查成本非常高。

在我经手的老项目里,碰撞分组、输入动作、道具属性这些参数经常跨模块传递。调用方不看文档直接传枚举,C风格enum完全拦不住。类型安全的enum class出现之后,这些比较、传参的错误基本在编译期就被打回去了。

1.2 成员名泄到外层作用域:同名冲突隐患

传统enum的每个成员名都被注入到枚举所在的作用域,这导致游戏项目中极其常见的“枚举成员重名”问题。两个毫无业务关系的枚举,只要放在同一个命名空间里,成员名撞车就直接编译失败:

namespace game { enum ActorState { Idle, Run, Attack, Dead }; enum AnimState { Idle, Walk, Attack, Hit }; // 和上面的 Idle、Attack 冲突 }

实际项目中解决办法通常是给成员名加前缀,写成ActorStateIdle、AnimStateIdle。名字越来越长,可读性越来越差,而且只要逻辑上改了枚举值,所有引用点都得跟着改。

更麻烦的是,有些老代码为了图省事写了using namespace,甚至把枚举定义直接放进全局作用域。名称一旦泄露出去,后续任何新枚举定义都可能撞上历史成员名。我之前就因为协议消息枚举里有Login这个名字,和另一个系统的枚举撞车,最后不得不花了半天把所有引用都改名,还要同步更新协议文档。改用enum class之后,成员被限定在枚举自己的作用域里,同名之间互不干扰,这是彻底解决了问题,而不是靠“小心命名”来回避。

1.3 底层类型由编译器决定:跨平台与存档容易翻车

C++标准只要求枚举的底层类型是“能容纳所有枚举值的整型”,具体是char、short、int、unsigned int还是别的,全由编译器自行选择。同一个枚举,在这套工具链下sizeof可能是4,换另外一个平台工具链可能就是2。

游戏开发最怕这种不确定。客户端通常要发到多个平台,存档文件、网络报文、数据库ID都可能牵扯到枚举。一个用于网络消息号的枚举,客户端用4字节写入,服务端用2字节解析,轻则消息错位,重则整个连接崩掉。

更隐蔽的是,枚举值本身也会改变底层类型的选择。比如你给枚举加了一个0xFFFFFFFF成员,编译器很可能把它升成unsigned int,于是size或者带符号性都和之前不同了。这类问题平时不暴露,发布版本时才出现,还特别难复现。所以协议、存档这类和外部存储打交道的枚举,必须显式指定固定的底层类型,并用static_assert把尺寸锁死。

2. enum class的规则和使用姿势

2.1 三个核心特性:隔离、禁止隐式转换、可指定底层

enum class是C++11引入的“作用域枚举”。相比传统enum,它有三个关键变化:成员必须通过枚举名访问;不隐式转换为整数;可以显式指定底层类型。

#include <cstdint> enum class ActorState : std::uint8_t { Idle, Run, Attack, Dead }; int main() { ActorState state = ActorState::Run; // state == 1; // 编译错误,不能和整数比较 // int raw = state; // 编译错误,不能隐式转换 return 0; }

“必须通过枚举名访问”看起来只是多打几个字,但它解决的是1.2节说的名称冲突。现在同一个编译单元里可以放心地同时存在ActorState::Idle和AnimState::Idle,编译器知道这是两个不同类型。

“不隐式转换为整数”看着只是删掉了一个便利语法,实际是把整个类型系统重新连接起来了。你没法再把ActorState传给一个接收int参数的函数,也没法拿它和别的枚举比较。代码里的意图被编译器强制保护,传参错误在编译期暴露,而不是等到运行时。

底层类型可指定这点,等于把跨平台尺寸的主动权交回开发者手里。这也是游戏项目里使用enum class最重要的原因之一。

2.2 需要和整数打交道时:只在边界做显式转换

禁止隐式转换之后,序列化、配置读取、网络封包这些场景怎么办?答案是显式转换,并且只允许出现在IO边界。

C++23提供了std::to_underlying,在 头文件里。此前需要自己写一个极简工具函数:

#include <type_traits> template <typename E> constexpr auto toUnderlying(E value) noexcept -> std::underlying_type_t<E> { return static_cast<std::underlying_type_t<E>>(value); }

写入存档时用toUnderlying(state)拿到整数,读取时用static_cast (raw)转回去。但我不建议把static_cast (raw)散落在各处。更合理的做法是把“读取校验”收拢成一个带检查的函数,或者至少在业务层确认raw一定落在合法范围内再转,避免外部配置传入负数或超范围值。

真正要警惕的是“顺手强转”的习惯。有些同学为了省事,在一个八竿子打不着的逻辑里直接static_cast ,结果又回到了用魔法数字比较的老路,类型安全形同虚设。我的经验是:业务逻辑里一律直接用枚举比较、switch;只有进入IO、配置、存档等外部边界时才允许转换。

2.3 代价是真的存在:不能自增,不能直接当下标

enum class不是没有缺点。它不像传统int枚举那样可以state++,也不能直接用作数组下标:

// 编译错误,ActorState 没有 operator++ // for (ActorState s = ActorState::Idle; s <= ActorState::Dead; ++s) // 编译错误,不能直接把枚举当数组下标 // std::string names[static_cast<int>(ActorState::Dead) + 1];

处理方式很直接:如果需要遍历,就用Count哨兵值配合底层整数循环;如果枚举值连续且从0开始,可以用数组,但索引处需要一次显式转换。这些代码看着是多了一点点,但把转换点集中在了“纯粹为遍历服务”的位置上,业务判断仍然保持着强类型语义。

switch分支里写枚举成员名也比以前长,例如case ActorState::Idle:。不过这在我看来是优点,因为在阅读代码时不用去猜这个分支到底是哪个枚举的值,尤其是多个枚举都出现在同一个函数里时,可读性反而是上升的。

3. 游戏开发里最常用的枚举场景与设计选型

3.1 状态机与AI调度:让编译器提醒你补状态处理

角色状态机是游戏开发里枚举最经典的使用场景。攻击、待机、移动、死亡,每种行为对应一个ActorState。切状态时用switch逐个处理,每个分支做对应的进入动作、播放动画、触发音效:

switch (state) { case ActorState::Idle: PlayIdleAnimation(); break; case ActorState::Run: PlayRunAnimation(); break; case ActorState::Attack: PlayAttackAnimation(); break; case ActorState::Dead: PlayDeathAnimation(); break; }

这个写法的关键点是:不要随便加default分支。编译器在开启-Wswitch之后,会检查switch是否覆盖了枚举的所有成员;一旦有default分支存在,这种检查就会被抑制,新增一个枚举值时编译器不再提醒你在哪些地方漏了处理。这是我在实战里踩过的最大坑。想让“新增状态”这个操作被团队强制执行到位,就把default拿掉,开发期开-Werror=switch,这样任何遗漏都会直接编译失败。

如果状态迁移逻辑复杂,用std::unordered_map<ActorState, StateHandler>之类的表驱动也是一种方案。枚举作为key非常合适,比用字符串强,也比纯int可读性高。

3.2 对象类型与碰撞分组:别再用字符串和魔法数字

游戏场景里有玩家、敌人、NPC、道具、陷阱,不同对象类型往往决定交互规则。如果把这些分类用字符串存,运行时比较开销大,而且拼写错误编译器不会提醒;用普通int常量,跨模块传值时很难看出语义。枚举就是为此设计的。

enum class EntityType : std::uint8_t { Player, Enemy, Npc, Item, Trap };

碰撞分组本质上也是一种类型分类。多个物体的碰撞过滤规则经常用位标志表达,比如玩家所在的层只与特定几个层产生物理响应。这个过程需要把数值组合起来,后面的4.2会专门讲位掩码枚举的处理办法。

3.3 事件消息与回调注册:防止事件号被随意篡改

事件系统在游戏里无处不在:OnDamage、OnDeath、OnPickup、OnQuestUpdate。事件类型用枚举定义后,事件总线会根据类型找到对应的订阅回调。传统enum存在两个问题:一是外部代码可以把任意int强塞进事件类型字段;二是两个系统如果都定义了重名成员,会引发命名冲突。

用enum class定义事件类型后,注册接口的类型签名会强制要求EventType参数,调用方必须用正确的枚举值。再配合一个转换表或范围校验,能有效杜绝“事件号错乱”这类低级别错误。这在多人协作的项目里帮助非常大,因为事件类型往往是公共头文件,改动会影响所有模块。

3.4 资源加载与资产分类:新增资源类型时获得编译期提醒

资源管理器的核心工作是根据资源类型选择加载器:贴图、模型、音频、动画、着色器,各类资源的处理流程完全不同。用枚举表达资产类型时,典型的代码结构就是switch:

switch (assetType) { case AssetType::Texture: LoadTexture(path); break; case AssetType::Mesh: LoadMesh(path); break; case AssetType::Audio: LoadAudio(path); break; }

只要是这类“每个枚举值都要有对应处理”的分派逻辑,enum class的价值最明显。新增资源类型后,编译器会警告所有没有覆盖新成员的分支;而传统enum在同样写法下会默默走过default分支,等到运行时加载失败、资源无法显示,你才会发现某个switch漏改了。

3.5 网络消息、存档和配置读写的稳定枚举:绑定编号和尺寸

网络协议和存档里的枚举,核心要求是“稳定”,而不是“方便理解”。协议消息号、道具ID、Buff ID、任务ID这一类数据,不能依赖枚举定义顺序。原因很简单:代码更新不是所有人同步的,新旧客户端可能同时存在,老版本存档也经常要兼容。

因此这类枚举不仅要显式指定底层类型,还要给每个成员赋固定的编号,并且约定新值一律追加到末尾,不允许在中间插入,也不允许复用已废弃的编号。

enum class MessageType : std::uint16_t { Unknown = 0, Login = 1, EnterScene = 2, Move = 3, // 旧版本已经发布,编号不能改 Respawn = 100, // 新值继续往后加 UseItem = 200 };

在编解码函数里加上static_assert(sizeof(MessageType) == 2),从编译期锁死尺寸。线上版本一旦出现协议错位,排查第一件事就是看枚举的编号和底层类型定义是否一致。

4. 底层类型、位标志与前置声明的细节

4.1 底层类型不是越小越好,按“存储与用途”来选

默认情况下enum class底层类型是int,4字节。对大部分状态类枚举来说没问题。但游戏项目里一套存档可能包含成千上万个枚举值,状态、Buff标记、道具类型都是枚举,每个省2~3字节,批量序列化的体积差距就出来了。

如果是游戏角色状态这类值域很小的枚举,指定std::uint8_t可以显著压缩内存和存档体积。但要记住:底层类型选小了,枚举值超过可表示范围会直接编译错,所以数量可能增长到300以上的枚举,至少用std::uint16_t。如果枚举里保存了负数,就要选择有符号底层类型,不能用uint8_t。

另一个容易被忽略的问题是结构体内存对齐。在角色配置、存档结构、批量buffer里,int枚举可能让结构体padding突然变大。底层类型改成uint8_t或uint16_t,配合#pragma pack等对齐策略,整体内存布局往往紧凑很多。对要频繁遍历实体组件的系统,这种差异不能忽略。

4.2 位掩码枚举:运算符重载要写完整

伤害类型、Buff叠加、碰撞层、效果开关这类数据,本质上不是“多选一”,而是“多个同时存在”。比如一个技能同时带Fire和Ice属性,用单个枚举值表达就会出现问题,正确姿势是把它当作位标志集合。

最稳妥的实现是专门写一组运算符重载,封装底层整数操作:

enum class DamageFlag : std::uint32_t { None = 0x0, Fire = 1 << 0, Ice = 1 << 1, Poison = 1 << 2 }; inline DamageFlag operator|(DamageFlag lhs, DamageFlag rhs) { using U = std::underlying_type_t<DamageFlag>; return static_cast<DamageFlag>( static_cast<U>(lhs) | static_cast<U>(rhs)); } inline bool HasFlag(DamageFlag value, DamageFlag query) { using U = std::underlying_type_t<DamageFlag>; return (static_cast<U>(value) & static_cast<U>(query)) != 0; }

这里的static_cast集中在运算符内部,外部使用方仍保持类型安全。注意不要图省事在业务代码里把枚举转成int然后用裸位运算,那会让所有调用点都失去类型保护。如果需要完整能力,还得把&=、|=、~也补上,交给工具类统一管理。

如果标志位数量较多、语义复杂,用std::bitset是比位掩码枚举更好的选择。我的判断标准是:需要和外部数据紧凑交互、需要控制字节长度时,用位掩码枚举;纯粹在内存里做状态累积时,用bitset更省心。

4.3 前置声明:给底层类型加上固定值就行

大项目里公共类型定义很容易成为编译瓶颈。传统enum无法前置声明,因为编译器不知道这个枚举占用多大空间;而enum class只要指定了底层类型,就可以前置声明。

// 公共头文件,不需要包含完整定义 enum class EventType : std::uint8_t;

这样事件总线的接口层可以只依赖前置声明,事件类型的具体枚举值定义放到少数实现文件里。大型代码库中,这种改动能明显减少编译时间。需要注意的是:函数参数如果只是指针或引用,前置声明足够;但如果对象按值存储、需要sizeof或者要访问成员,仍然必须包含完整定义。我自己的习惯是,把“需要前置声明的枚举”集中放到一个最小的公共头,把“完整定义”放到各自模块内部头,尽量避免把全量枚举定义炸给所有编译单元。

4.4 枚举编号稳定性:从源头防住ID漂移

游戏版本迭代过程中,最怕有人在枚举列表中间插入新值,导致后面所有枚举编号顺移。上一版存档里ID=5是Consumable,更新后ID=5变成了Potion,加载老存档就直接出逻辑错误。

对策其实很简单:新项目从第一天就给关键枚举显式编号,新值只能追加在尾部。已经废弃的编号保留一个Reserved占位,不要复用。这一条对协议、存档、道具、任务、技能这些会被外部系统引用的枚举尤其重要。配合代码评审,让每个“新增枚举”的改动都能让团队成员看到编号变化,很多线上事故根本不会发生。

5. 实用周边:字符串转换、遍历、映射表与轻量反射

5.1 枚举转字符串:用X宏避免写两遍

日志、调试面板、存档导出,这些功能都需要把枚举打印成可读名称。最简单的写法是switch手写映射:

const char* ToString(ActorState state) { switch (state) { case ActorState::Idle: return "Idle"; case ActorState::Run: return "Run"; case ActorState::Attack: return "Attack"; case ActorState::Dead: return "Dead"; } return "Unknown"; }

问题是每新增一个枚举,就要同时改定义和这个转换函数,很容易漏。更推荐用X宏把枚举定义和转换函数放在同一处维护:

#define ACTOR_STATE_LIST(X) \ X(Idle) \ X(Run) \ X(Attack) \ X(Dead) enum class ActorState : std::uint8_t { #define GEN_ENUM(Name) Name, ACTOR_STATE_LIST(GEN_ENUM) #undef GEN_ENUM }; const char* ToString(ActorState state) { switch (state) { #define GEN_CASE(Name) case ActorState::Name: return #Name; ACTOR_STATE_LIST(GEN_CASE) #undef GEN_CASE } return "Unknown"; }

这个做法本质是“一份列表,多处生成”,后续加新的状态,只需要改ACTOR_STATE_LIST里的列表,枚举定义和ToString会同时更新。这已经足够应对游戏项目里绝大多数枚举需求,不需要引入重型反射。

5.2 遍历枚举:Count哨兵还是AllOf数组

遍历枚举值在游戏开发中很常见:初始化UI列表、遍历调试菜单、做存档校验。如果枚举值连续且从0开始,最直接的办法是加一个Count作为结束标记:

enum class Direction : std::uint8_t { North = 0, East, South, West, Count }; for (std::uint8_t i = 0; i < static_cast<std::uint8_t>(Direction::Count); ++i) { Direction d = static_cast<Direction>(i); // 处理 d }

但Count这个成员有个陷阱:它不应该参与业务逻辑,也不应该被序列化。如果枚举里存在跳号,连续性被破坏,这个方案就不适用了。此时可以在X宏的基础上生成一个Array:

constexpr std::array<Direction, 4> AllDirections() { return { Direction::North, Direction::East, Direction::South, Direction::West }; }

虽然写法上还是需要维护一次列表,但至少遍历逻辑不会因为枚举跳号而出错。

5.3 枚举到配置/数据的映射:用表驱动替代if-else

状态机、资源表、UI配置,最常见的模式是根据枚举查一份映射。例如状态到动画、状态到音效、道具类型到图标资源。实现方式有三种:switch、std::unordered_map、std::array。

数量少或逻辑本身就分散在不同模块时,switch最直观,还能附带处理逻辑。数据驱动比较重的模块用std::unordered_map更清晰:

const std::unordered_map<ActorState, const char*>& StateToAnim() { static const std::unordered_map<ActorState, const char*> table = { { ActorState::Idle, "idle_loop" }, { ActorState::Run, "run_loop" }, { ActorState::Attack, "attack_once" }, { ActorState::Dead, "dead_idle" } }; return table; }

如果枚举数量固定且连续,std::array加上底层整数索引会比unordered_map更快,但要注意确保索引不越界。一个经验法则是:每帧调用、批量调用的查询路径上,用switch或array;初始化、低频配置读取,用unordered_map完全没问题。

5.4 不要轻易上重型反射

有些开发者从其他语言转过来,会想“要是C++枚举能自动转字符串、自动遍历就好了”。C++原生做不到,社区里有一些魔法方案能通过编译器内建函数提取枚举名,达到接近反射的效果。这类方案在某些工具链下确实好用,但一旦涉及跨平台编译器差异、特殊枚举值、非连续范围,维护成本会快速上升,而且出了问题非常难调试。

在游戏项目里,我的建议一直很明确:X宏就能解决九成需求。它行为透明、只依赖预处理器、不挑编译器版本、团队成员一看就懂。如果你后续真的遇到了大量枚举需要统一转换、序列化、校验的场景,再考虑上代码生成工具,用法上要克制,别让一个“枚举增强”变成团队的技术黑盒。

6. 迁移与排查:从传统enum到enum class的实操实录

6.1 老项目迁移:别做全局替换

把老代码从传统enum切到enum class,最忌讳的是“全局搜索替换成enum class,然后被编译错误淹没”。我的建议是分模块、分阶段,每次只迁一个系统。

第一步,把enum改成enum class并指定底层类型,然后编译。接下来会涌现大量编译错误,基本都是隐式转换、直接访问成员、switch分支漏写枚举名这几类。逐个处理时,要先判断每个使用点到底是“正常的数据交换”还是“隐式类型误用”。序列化、存档、网络封包这类边界位置,补上toUnderlying/static_cast;业务逻辑里的隐式比较,绝不机械补static_cast,而是要改成直接用同类型枚举比较,或者把逻辑修正过来。这时候最容易暴露一个潜在问题:以前某个地方其实不小心传错类型却编译通过了,迁移时正好有机会修掉。

迁移后还要集中检查几件事:外部调用方是否还有直接访问裸成员名的代码(传统enum的成员名可以在枚举外直接写Idle,迁移后需要改成ActorState::Idle);switch分支是否全部更新为加作用域的case;旧代码里的默认default分支是否掩盖了漏处理。全部检查完后,再做一轮全量编译,配合-Werror=switch把遗漏全部暴露出来。

6.2 -Wswitch和-Werror=switch:让编译器守住最后一道门

GCC、Clang在编译switch枚举类型时,如果switch没有覆盖所有枚举值,会给出警告。把警告升级成错误,就能让“新增枚举忘改switch”的隐患直接阻断编译。

需要注意的坑是:一旦switch里存在default分支,这个警告就可能被抑制。所以团队约定应该明确——枚举类型的分派switch,尽量不写default;如果业务上确实需要处理未知值,要把default写成显式的错误处理日志,而不是静默返回。分支合并时也直接写多个case标签,比如:

case ActorState::Idle: case ActorState::Run: // 合并处理 break;

这样新增枚举后,编译器依然能检查出哪些switch没有覆盖新成员。

6.3 跨平台协议枚举尺寸不一致:static_assert救场

多平台项目里,枚举尺寸不一致是常见的线上问题。症状很典型:一部分客户端发消息正常,另一部分发消息后服务端解析全部错位,日志里出现大量乱码或非法消息号。排查起来往往要同时看客户端、服务端两边的枚举定义。

根治方式就是统一协议层枚举的底层类型,并且加上尺寸断言:

enum class MessageType : std::uint16_t { ... }; static_assert(sizeof(MessageType) == 2, "MessageType must be exactly 2 bytes!");

如果项目里还有历史遗留的传统enum,至少在编解码入口处强制转换成固定宽度的整数类型,不要直接拿着原始枚举去做字节写入。对比编译产物也能帮助确认问题,但要先让定义统一,排查才有意义。

6.4 枚举ID漂移:版本升级后老配置全乱

道具ID、Buff ID、任务ID这类被外部配置引用的枚举,最怕有人“看着不爽”调整顺序,或者为了逻辑分组把一个新值插到列表中间。出现这种问题后,老版本的存档、数据库、日志全都对不上号。

防止ID漂移最有效的手段是显式编号+追加策略。首次定稿时就把每个枚举值写上固定数字,后续新值只能加到列表末尾,逻辑删除的值在列表中保留Reserved占位。代码评审时把“枚举值是否保持稳定”作为一项常规检查。如果团队体量大,可以考虑用脚本生成常量头文件,与策划配置表做一致性校验,一旦两边对不上直接爆出错误。

6.5 绕回int:用底层类型把类型安全又丢掉了

迁移到enum class之后,团队里很容易出现一种“绕路”行为:为了少写case ActorState::Idle这样的代码,有人先toUnderlying(state)转成int,然后对int做switch;或者把int结果传给接收int参数的旧函数。这等于亲手拆掉刚装上的安全门。

我在代码评审中遇到这种写法会特别提醒:底层整数转换只允许出现在IO边界、序列化、外部配置接入点。内部逻辑接口应该直接接收枚举类型。一旦数量多了,整个系统的类型提示就形同虚设,之前传统enum踩过的坑会原样回来。

6.6 常见问题与对策速查表

问题现象典型原因处理思路
switch新增枚举没任何提示switch里有default分支去掉default,开-Werror=switch
序列化后字节长度不稳定未指定底层类型,跨平台尺寸不同指定固定宽度底层类型,加static_assert
两个枚举成员名冲突传统enum成员注入外层作用域改用enum class
调用方传入了任意整数枚举隐式转成了int用enum class,边界处显式转换
版本更新后配置、存档错乱枚举中途插入新值导致编号漂移显式编号,新值追加尾部
日志里面枚举全是数字没有字符串转换函数用X宏生成ToString
遍历枚举不方便enum class不支持++用Count哨兵或AllOf数组
团队成员把枚举转int后再switch习惯问题或图省事代码评审强约束,转换只限IO边界

最后再分享一点个人体会。从传统enum全面切到enum class,最大的阻力不是技术,是习惯。刚改那阵子我也觉得每个地方都要写ActorState::很啰嗦,代码看着比原来密。但跑过一个版本之后慢慢发现,代码评审时能更清楚地看出每个类型参数的真实语义,新增枚举时编译器的提示帮我挡掉了好多次运行时的低级问题。我的做法是:把所有容易扩散的枚举,比如状态、事件、消息类型、道具分类,全部集中在一个枚举定义头里,配合X宏做转换;业务代码只认枚举类型本身,底层整数只在存档、网络、配置这些进出口出现。

如果新项目还没开始写多少个枚举,建议从第一天就把enum class定成规矩。手上如果有老代码,按迁移步骤一个模块一个模块来,不用着急一次性改完。这个改动不会让游戏跑得更快,但真的可以让你少熬好几个排查bug的夜。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进