前言
enum class(作用域枚举,scoped enumeration)是 C++11 引入的。它常常被当成"把枚举名放进作用域的语法糖",但实际改动远不止于此:它同时解决了传统enum的名字污染、隐式类型转换和底层类型不确定三个问题,还顺带让枚举可以前置声明。
最常见的三个误解:
- 以为
enum class的值完全不能转成整数。可以,只是必须显式写static_cast。这个"多打几个字"的代价换来了类型安全。 - 以为
enum class什么运算都不支持。同类型的相等与大小比较是内建的(Color::Red < Color::Blue合法);被禁掉的是与整数混合运算和位运算,位运算需要自己重载运算符。 - 以为
enum class只是换个写法。它在 ABI 层面也有实质区别:enum class的底层类型永远是固定的(不写就是int),这既带来了可前置声明的能力,也决定了对象的大小。
还有一个必须澄清的边界:什么情况下把整数转成枚举是 UB。这取决于有没有固定底层类型,本文会专门用一节讲清楚。示例以 C++17 为基准,GCC 13 / Clang 17 / MSVC 19.3x 均可编译;C++20/23 的特性会单独标注并给出 C++17 的替代写法。
一、传统 enum 的三个问题
enum Color { Red, Green, Blue }; enum Fruit { Apple, Banana, Red }; // 编译错误:Red 重定义| 问题 | 具体表现 | enum class 如何解决 |
|---|---|---|
| 名字泄漏到外层作用域 | 枚举名进入含有该 enum 的作用域,多个 enum 之间容易撞名 | 必须写Color::Red,名字封装在枚举内部 |
| 隐式转成整数 | int x = Red;合法;枚举值能和不相干的整数比较、参与算术 | 没有隐式转换,必须static_cast |
| 底层类型不确定 | 由实现选择能容纳全部枚举值的整型(实现定义) | enum class默认底层类型是int,也可显式指定 |
| 无法前置声明 | 不指定底层类型时,编译器不知道它多大,无法前置声明 | enum class默认底层类型固定,可以前置声明 |
关于"底层类型不确定",需要说明的是:标准规定了选择规则(选择能表示所有枚举值的最小整型,具体宽度由实现决定),所以它是实现定义行为,不是 UB。但正因为不确定,跨编译器/跨平台的二进制接口就有可能不一致——需要稳定的 ABI 时,永远显式写出底层类型。这是标准要求,与具体 STL 无关。
二、enum class 的规则清单
2.1 作用域与转换
enum class Color { Red, Green, Blue }; Color c = Color::Red; // 必须带 Color:: int n = c; // ❌ 编译错误:没有隐式转换 int m = static_cast<int>(c); // ✅ 显式转换禁止的方向只有"枚举到整数的隐式转换"和"整数到枚举的隐式转换"。反向的显式转换、同类型之间的比较都照常。
2.2 底层类型与内存布局
enum class Tiny : std::uint8_t { A, B }; // 1 字节 enum class Big { X, Y }; // 默认底层类型 int有固定底层类型的枚举,其"枚举值"就是底层类型的值域。因此sizeof(Tiny)等于sizeof(std::uint8_t)(即 1),这是由底层类型直接决定的。
指定较小的底层类型在"枚举会被大量存储"的场景下有意义(例如协议报文里的字段、巨型数组的索引),代价是转换时必须经过static_cast,而且一旦越界就落进 UB 区间(见 2.4)。
2.3 前置声明
enum class Status : int; // 不完整类型,可以声明指针/引用 enum class Status : int { Ok = 0, Error = 1 };enum class可以不带底层类型直接前置声明,因为它的底层类型默认就是int(也就是说enum class的底层类型永远是固定的)。而不限定作用域的enum要前置声明,就必须写出底层类型:enum E : int;。这个差异的根源就是"底层类型是否固定"。
2.4 转换越界时是不是 UB
| 枚举形式 | 值域 | static_cast一个整数进来的结果 |
|---|---|---|
enum class E : int | int的全部值域 | 有定义:先转成int,再作为枚举值使用 |
enum class E(默认int) | 同上一行 | 有定义 |
enum E { A = 0, B = 1 }(无固定底层类型) | 由实现选出的最小类型的值域 | 超出该范围是 UB,标准不保证任何行为 |
标准的表述是(自 C++17 起明确):若枚举类型没有固定底层类型,而转换后的值不在该枚举的值域内,行为未定义。这意味着static_cast<Color>(999)在enum class Color : int下是安全的,但在传统的enum Color下(如果没有固定底层类型且值域装不下 999)就是 UB。写序列化/反序列化代码时,这一点必须小心。
三、常见用法与配套工具
| 需求 | C++11/14/17 的写法 | 更新的标准 |
|---|---|---|
| 取底层类型 | std::underlying_type<E>::type(C++11) | std::underlying_type_t<E>(C++14 别名模板) |
| 转成底层整数 | static_cast<std::underlying_type_t<E>>(e) | std::to_underlying(e)(C++23,在<utility>) |
| 引入枚举名到作用域 | 手动constexpr auto Red = Color::Red; | using enum Color;(C++20) |
| 位标志 | 自己重载 `operator | ` 等 |
| 遍历所有枚举值 | 手写数组 | 无标准设施(std::ranges也不提供) |
注意std::to_underlying是C++23才有的;在 C++17 项目里,请用static_cast加std::underlying_type_t的封装,本文示例就是这么做的。
3.1 完整示例
// enum_class_demo.cpp —— C++17;GCC 13 / Clang 17 / MSVC 19.3x #include <cstdint> #include <iostream> #include <type_traits> // 作用域 + 指定底层类型 enum class Color : std::uint8_t { Red, Green, Blue }; // 前置声明:enum class 的底层类型总是固定的,所以可以不带冒号声明 enum class Status : int; enum class Status : int { Ok = 0, Error = 1 }; // C++17 的 to_underlying 替代写法(C++23 起标准库有 std::to_underlying) template <class E> constexpr auto to_under(E e) noexcept -> std::underlying_type_t<E> { return static_cast<std::underlying_type_t<E>>(e); } // 位标志:enum class 不提供内建的 | & ^ ~,需要自己写 enum class Perm : unsigned { None = 0u, Read = 1u << 0, Write = 1u << 1, Exec = 1u << 2, }; constexpr Perm operator|(Perm a, Perm b) noexcept { return static_cast<Perm>(static_cast<unsigned>(a) | static_cast<unsigned>(b)); } constexpr bool has_flag(Perm set, Perm f) noexcept { return (static_cast<unsigned>(set) & static_cast<unsigned>(f)) != 0u; } // 转成字符串:switch 覆盖全部枚举值,配合 -Wswitch 让编译器帮忙查漏 constexpr const char* name_of(Color c) noexcept { switch (c) { case Color::Red: return "Red"; case Color::Green: return "Green"; case Color::Blue: return "Blue"; } return "unknown"; } int main() { Color c = Color::Green; // 底层类型是 uint8_t 时,直接输出会被当成字符打印,所以要转成整型 std::cout << name_of(c) << " raw=" << static_cast<unsigned>(to_under(c)) << '\n'; // Green raw=1 std::cout << "int(c)=" << static_cast<int>(c) << '\n'; // 1 // 同类型枚举可以内建比较 std::cout << std::boolalpha << (Color::Red < Color::Blue) << '\n'; // true // std::cout << (Color::Red < Status::Ok); // ❌ 编译错误:不同类型的枚举不能比较 Perm p = Perm::Read | Perm::Write; std::cout << has_flag(p, Perm::Read) << '\n'; // true std::cout << has_flag(p, Perm::Exec) << '\n'; // false static_assert(sizeof(Color) == sizeof(std::uint8_t), "底层类型是 uint8_t"); static_assert(sizeof(Perm) == sizeof(unsigned), "底层类型是 unsigned"); static_assert(std::is_enum<Color>::value, "is_enum"); // 固定底层类型下,越界转换是有定义的(值就是 int 的值) Status s = static_cast<Status>(7); std::cout << static_cast<int>(s) << '\n'; // 7 }编译:
g++ -std=c++17 -Wall -Wextra enum_class_demo.cpp -o demo clang++ -std=c++17 -Wall -Wextra enum_class_demo.cpp -o demo常见坑点
坑 1:拿枚举值直接当数组下标。
enum class Color { Red, Green, Blue }; int table[3]; ❌ table[Color::Green] = 1; // 编译错误:没有隐式转换 ✅ table[static_cast<int>(Color::Green)] = 1; ✅ table[to_under(Color::Green)] = 1; // 或封装的辅助函数这正是enum class想要的:索引必须显式,避免把两个不同语义的枚举混着当下标用。
坑 2:用 static_cast 把类型安全又拆掉。
enum class A { X }; enum class B { Y }; A a = A::X; ❌ B b = static_cast<B>(a); // 能编过(先转 int 再转 B),但语义完全错 ✅ // 需要跨界时就写显式的映射函数,把映射关系写出来 B convert(A v) { return v == A::X ? B::Y : B::Y; }坑 3:以为枚举之间可以随便比大小。
❌ Color::Red < Status::Ok; // 不同类型,编译错误 ✅ Color::Red < Color::Blue; // 同类型,内建支持 ✅ static_cast<int>(Color::Red) < static_cast<int>(Status::Ok); // 明确要跨类型比坑 4:位运算不会自动可用。
enum class Perm { Read = 1, Write = 2 }; ❌ Perm p = Perm::Read | Perm::Write; // 编译错误:没有 operator| ✅ constexpr Perm operator|(Perm a, Perm b) { ... } // 自己重载注意:把这个模板化的operator|写得太"泛"(针对所有枚举类型)会污染全局重载集,甚至影响其他库。建议只针对具体的标志枚举类型重载,并且放在该类型的同一命名空间里。
坑 5:默认初始化的枚举成员是"不确定值"。
struct S { Color c; }; // 没有类内初始化器 ❌ S s; if (s.c == Color::Red) { } // s.c 的值不确定,读它是 UB ✅ struct S { Color c = Color::Red; }; ✅ S s{}; // 值初始化,c 变成 0 对应的枚举值"不确定值"是标准里的概念,不是"随机但合法的值"。读一个未初始化的对象是 UB。
坑 6:以为"转换越界"永远是安全的。
enum class Safe : int { A, B }; // 底层类型 int,值域 = int 的值域 enum Legacy { X = 0, Y = 1 }; // 没有固定底层类型 ✅ Safe a = static_cast<Safe>(1000); // 有定义 ❌ Legacy b = static_cast<Legacy>(1000); // 超出枚举值域:UB,标准不保证任何行为反序列化来自网络的整数时,务必先验范围,再转换。
坑 7:指定了窄底层类型之后,输出的是字符而不是数字。
enum class Level : std::uint8_t { Low = 1, High = 2 }; Level lv = Level::High; ❌ std::cout << to_under(lv); // uint8_t 就是 unsigned char,按字符输出 ✅ std::cout << static_cast<unsigned>(to_under(lv)); // 先转成整型再输出 ✅ std::cout << static_cast<int>(lv); // 或者直接从枚举转成 intstd::uint8_t和std::int8_t在operator<<眼里是字符类型,所以会打印出一个不可见的控制字符;std::uint16_t及以上没有这个问题。另外,指定std::uint16_t只固定宽度、不固定字节序,序列化到网络字节流时仍需按协议显式做端序转换。
坑 8:switch 漏掉新加的枚举值。
enum class Color { Red, Green, Blue }; const char* f(Color c) { ❌ switch (c) { case Color::Red: return "R"; case Color::Green: return "G"; } // 加了 Color::Blue 之后忘记补 case,处理逻辑就悄悄走空 ✅ switch (c) { /* 覆盖全部值,且不写 default */ } // 打开 -Wall(GCC/Clang 含 -Wswitch),漏掉枚举值会给出警告 }写default反而会让-Wswitch闭嘴,所以"想被编译器提醒"就不要写default,而是让函数末尾的return兜底。
总结
| 对比项 | enum(不限定作用域) | enum class(作用域枚举) |
|---|---|---|
| 名字访问 | 直接Red | 必须Color::Red |
| 转成整数 | 隐式 | 必须static_cast |
| 与整数混合运算 | 允许(常是 bug 来源) | 编译错误 |
| 底层类型 | 未指定时由实现决定 | 不写就是int,永远固定 |
| 前置声明 | 必须指定底层类型 | 可以不指定 |
| 不同类型间比较 | 经由隐式转换可能编过 | 编译错误 |
| 位运算 | 隐式转整数后可用 | 需要自己重载运算符 |
| 对象大小 | 取决于实现的底层类型 | 取决于固定底层类型 |
三句话收尾:新代码一律用enum class,类型安全带来的收益远大于多写几个static_cast;需要稳定布局或跨平台传输时,永远显式写出底层类型,并注意字节序与越界转换的 UB 边界;位标志需要自己重载运算符,把重载限定在具体的标志类型上,别写成会污染全局的泛型版本。