Skip to content

enum class 与强类型枚举

引言

笔者写这篇文章之前,翻了一下以前写的 C 风格代码——满屏幕的 enum Color { Red, Green, Blue };,然后 if (color == 1) 这种东西随处可见。

如果说是老项目,那没办法,但是到了 2026 年还这么写,基本上就是在给自己挖坑。C 风格 enum 的隐式整数转换、命名污染、无法前向声明,这三板斧砍下来,每一条都够在 code review 里被骂一顿。

enum class(C++11 引入的强类型枚举)就是来解决这些问题的。它不只是一个语法糖——它是一种类型安全层面的承诺。这一章我们从 C 风格 enum 的痛点出发,一步步搞清楚 enum class 到底修掉了什么 bug,以及怎么用它写出更安全的代码。

第一步——C 风格 enum 的三宗罪

在讲 enum class 之前,我们先看看老 enum 到底有哪些让人血压升高的问题。

罪名一:隐式转换为整数

老式 enum 的值可以隐式转换成 int。这听起来像是"方便",实际上是在鼓励你写出这种代码:

cpp
enum Color { Red, Green, Blue };
enum Fruit { Apple, Orange, Banana };

void paint(int c);

paint(Red);       // OK,隐式转成 int
paint(Orange);    // 也 OK!但语义完全错了
paint(42);        // 编译通过,运行时才知道出问题

if (Red == Apple) {
    // 居然编译通过,而且为 true!因为都是 0
}

不同枚举类型的值可以互相比较、可以传给任何接受 int 的函数——编译器完全不管这些值在语义上是否匹配。这类 bug 在代码量大的时候极难追踪,因为编译器不会给你任何警告。

罪名二:命名污染

老式 enum 的所有枚举值都直接暴露在外部作用域中。如果你有两个枚举都定义了 NoneError 这样的常用名字,就会产生冲突:

cpp
enum Status { None, Ok, Error };
enum Permission { None, Read, Write, Execute };  // 编译错误!None 重定义

// 常见的变通方案:加前缀
enum Status { Status_None, Status_Ok, Status_Error };
enum Permission { Perm_None, Perm_Read, Perm_Write, Perm_Execute };

加前缀确实能解决问题,但这是在用手工约定代替语言机制——每个团队都可能有不同的前缀风格,维护成本直接拉满。

罪名三:无法前向声明

C 风格 enum 的底层类型由编译器自行决定,所以编译器在看到 enum 定义之前无法确定它的大小。这导致 enum 不能前向声明(除非你手动指定底层类型,但那就不是"纯 C 风格"了),在头文件依赖管理上非常不方便。

cpp
// status.h
enum Status { Ok, Error };  // 必须看到完整定义

// device.h
// enum Status;  // 编译错误!无法前向声明
class Device {
public:
    Status get_status() const;  // 必须包含 status.h
};

这三条加在一起,基本上就是"类型安全"的反面教材。C++11 的 enum class 针对每一条都给出了明确的解决方案。

第二步——enum class 的三大改进

作用域隔离

enum class 的枚举值不会泄漏到外部作用域。必须通过 EnumName::Value 的方式访问:

cpp
enum class Color { Red, Green, Blue };
enum class Fruit { Apple, Orange, Banana };

Color c = Color::Red;   // 正确
// Color c = Red;        // 编译错误!Red 不在外部作用域
// Fruit f = Color::Red; // 编译错误!类型不匹配

这下 Color::RedFruit::Apple 各管各的,永远不可能撞名或者混用。编译器在编译期就能帮你拦截掉所有跨类型的误用。

禁止隐式转换

enum class 不会隐式转换为任何整数类型,必须使用 static_cast 显式转换:

cpp
enum class Color : uint8_t { Red, Green, Blue };

// int x = Color::Red;                          // 编译错误!
int x = static_cast<int>(Color::Red);           // OK,显式转换

void paint(Color c);
paint(Color::Red);      // OK
// paint(0);             // 编译错误!
// paint(static_cast<Color>(0));  // OK 但不推荐——绕过类型检查

你可能会觉得"每次都写 static_cast 好麻烦"。笔者的看法是:麻烦正是安全的代价。如果某个地方需要把枚举值当整数用,那你就必须显式写出来——这意味着你在那个位置做出了一个有意识的决定,而不是无意中被编译器放过了。

指定底层类型与前向声明

enum class 可以指定底层类型,并且默认为 int。指定底层类型后,编译器在声明时就知道枚举的大小,所以前向声明变得可行:

cpp
// status.h —— 前向声明
enum class Status : uint8_t;

// device.h —— 只需要前向声明
class Device {
public:
    Status get_status() const;
    void set_status(Status s);
};

// status.cpp —— 完整定义
enum class Status : uint8_t { kOk = 0, kError = 1, kBusy = 2 };

在头文件中只需要前向声明,完整定义放在 .cpp 文件中,这就打破了头文件之间的循环依赖。而且在嵌入式中,你可以把底层类型指定为 uint8_t,确保枚举变量只占一个字节:

cpp
enum class SensorState : uint8_t {
    kOff = 0,
    kInit = 1,
    kReady = 2,
    kError = 3
};

static_assert(sizeof(SensorState) == 1, "SensorState should be 1 byte");

第三步——位运算与 enum class

在 C 风格代码中,用枚举值做位标志(bitmask)是非常常见的操作:

cpp
// C 风格:天然支持位运算(因为隐式转换成 int)
enum Permission { Read = 1, Write = 2, Execute = 4 };
int perms = Read | Write;  // OK

enum class 禁止了隐式转换,所以 Color::Red | Color::Green 这种写法直接编译错误。要支持位运算,我们需要手动重载运算符:

cpp
#include <type_traits>

enum class Permission : uint32_t {
    kNone    = 0,
    kRead    = 1 << 0,
    kWrite   = 1 << 1,
    kExecute = 1 << 2
};

// 辅助函数:枚举值到底层类型的转换
template <typename E>
constexpr auto to_underlying(E e) noexcept
{
    return static_cast<std::underlying_type_t<E>>(e);
}

constexpr Permission operator|(Permission a, Permission b) noexcept
{
    return static_cast<Permission>(to_underlying(a) | to_underlying(b));
}

constexpr Permission operator&(Permission a, Permission b) noexcept
{
    return static_cast<Permission>(to_underlying(a) & to_underlying(b));
}

constexpr Permission operator^(Permission a, Permission b) noexcept
{
    return static_cast<Permission>(to_underlying(a) ^ to_underlying(b));
}

constexpr Permission operator~(Permission a) noexcept
{
    return static_cast<Permission>(~to_underlying(a));
}

constexpr Permission& operator|=(Permission& a, Permission b) noexcept
{
    a = a | b;
    return a;
}

constexpr Permission& operator&=(Permission& a, Permission b) noexcept
{
    a = a & b;
    return a;
}

// 辅助判断:是否有任何标志位被设置
constexpr bool has_any_flag(Permission flags) noexcept
{
    return to_underlying(flags) != 0;
}

// 辅助判断:是否包含特定标志位
constexpr bool has_flag(Permission flags, Permission flag) noexcept
{
    return to_underlying(flags & flag) != 0;
}

使用起来非常自然:

cpp
Permission user_perms = Permission::kRead | Permission::kWrite;

if (has_flag(user_perms, Permission::kWrite)) {
    // 用户有写权限
}

user_perms |= Permission::kExecute;  // 添加执行权限
user_perms &= ~Permission::kWrite;   // 移除写权限

这段代码虽然看起来有点长(毕竟要手写六个运算符),但它保证了类型安全:你不可能把 PermissionColor 的值混在一起做位运算。在实际项目中,这些运算符通常会被提取到一个通用的头文件里,配合模板或宏来复用。

说到这里,值得提一下 C++23 的进展。std::to_underlying 已经在 C++23 中被正式纳入标准库,上面的 to_underlying 辅助函数可以直接换成 <utility> 里的 std::to_underlying。至于 std::flags 这种专门为位掩码设计的类型包装器,目前还在提案阶段(P1872),尚未进入标准。在那之前,手动重载运算符仍然是最主流的做法。

第四步——switch 匹配与编译器警告

enum classswitch 语句是天生一对。由于 enum class 的值必须通过限定名访问,编译器知道所有可能的取值,可以在你遗漏分支时发出警告:

cpp
enum class NetworkState : uint8_t {
    kDisconnected,
    kConnecting,
    kConnected,
    kError
};

std::string_view to_string(NetworkState state)
{
    switch (state) {
    case NetworkState::kDisconnected: return "disconnected";
    case NetworkState::kConnecting:   return "connecting";
    case NetworkState::kConnected:    return "connected";
    // 如果缺少 kError 分支,-Wswitch 会发出警告
    }
    return "unknown";
}

笔者强烈建议:在使用 enum classswitch 时,不要写 default 分支。原因在于,如果你写了 default,编译器就会认为你已经处理了所有"其他"情况,-Wswitch 警告就失效了。而如果你不写 default,以后新增枚举值时,编译器会在所有遗漏的 switch 处给出警告,帮你把 bug 扼杀在编译期。

对应的编译器选项是 GCC/Clang 的 -Wswitch(默认开启)或 -Wswitch-enum(更严格,即使有 default 也会警告)。在项目的 CMakeLists.txt 中加上这些选项,是一个不错的工程实践。

第五步——C++20 using enum

enum class 的作用域隔离虽然是好事,但有时候在一个频繁使用某个枚举的函数里,反复写 EnumName:: 确实有些啰嗦。C++20 引入了 using enum 声明,可以一次性把某个枚举的所有值引入当前作用域:

cpp
enum class TokenType {
    kNumber, kString, kIdentifier,
    kPlus, kMinus, kStar, kSlash,
    kLeftParen, kRightParen, kEof
};

std::string_view token_to_string(TokenType type)
{
    // 把所有枚举值引入函数作用域
    using enum TokenType;

    switch (type) {
    case kNumber:     return "number";
    case kString:     return "string";
    case kIdentifier: return "identifier";
    case kPlus:       return "+";
    case kMinus:      return "-";
    case kStar:       return "*";
    case kSlash:      return "/";
    case kLeftParen:  return "(";
    case kRightParen: return ")";
    case kEof:        return "eof";
    }
    return "unknown";
}

using enum 的作用域仅限于当前块(花括号内),所以不会污染外部作用域。它也可以用在类定义中:

cpp
class Lexer {
public:
    using enum TokenType;  // 所有枚举值成为类的成员

    TokenType next_token();
    bool is_operator(TokenType t);
};

⚠️ 这里有一个踩坑点:using enum 会把所有枚举值都引入当前作用域。如果两个枚举有同名的值,同时 using enum 会产生冲突。所以使用时要确保你清楚该枚举的所有值,以及它们不会和当前作用域中的名字冲突。

实战应用——状态机与错误码

状态机

状态机是嵌入式和协议解析中最常见的模式之一。用 enum class 来表示状态,配合 switch 实现状态转移,既清晰又安全:

cpp
#include <cstdio>

enum class DeviceState : uint8_t {
    kIdle,
    kInitializing,
    kRunning,
    kSuspending,
    kError
};

class DeviceController {
public:
    void on_event(const char* event)
    {
        switch (state_) {
        case DeviceState::kIdle:
            if (is_start(event)) {
                state_ = DeviceState::kInitializing;
                std::printf("State: Idle -> Initializing\n");
                do_init();
            }
            break;
        case DeviceState::kInitializing:
            if (is_init_done(event)) {
                state_ = DeviceState::kRunning;
                std::printf("State: Initializing -> Running\n");
            } else if (is_error(event)) {
                state_ = DeviceState::kError;
                std::printf("State: Initializing -> Error\n");
            }
            break;
        case DeviceState::kRunning:
            if (is_stop(event)) {
                state_ = DeviceState::kSuspending;
                std::printf("State: Running -> Suspending\n");
            } else if (is_error(event)) {
                state_ = DeviceState::kError;
                std::printf("State: Running -> Error\n");
            }
            break;
        case DeviceState::kSuspending:
            if (is_suspend_done(event)) {
                state_ = DeviceState::kIdle;
                std::printf("State: Suspending -> Idle\n");
            }
            break;
        case DeviceState::kError:
            if (is_reset(event)) {
                state_ = DeviceState::kIdle;
                std::printf("State: Error -> Idle\n");
            }
            break;
        }
    }

    DeviceState get_state() const noexcept { return state_; }

private:
    DeviceState state_ = DeviceState::kIdle;

    void do_init() { /* ... */ }

    static bool is_start(const char* e)      { return e[0] == 'S'; }
    static bool is_init_done(const char* e)  { return e[0] == 'D'; }
    static bool is_stop(const char* e)       { return e[0] == 'T'; }
    static bool is_suspend_done(const char* e) { return e[0] == 's'; }
    static bool is_error(const char* e)      { return e[0] == 'E'; }
    static bool is_reset(const char* e)      { return e[0] == 'R'; }
};

这段代码的好处是:如果你以后给 DeviceState 新增了一个状态(比如 kPaused),编译器会在所有缺少这个分支的 switch 处发出警告(前提是你没写 default),这样你就不会遗漏任何状态转移逻辑。

错误码

enum class 做错误码,比用 #define 或裸 int 安全得多:

cpp
#include <string_view>

enum class ErrorCode : int {
    kOk = 0,
    kInvalidArgument = 1,
    kNotFound = 2,
    kPermissionDenied = 3,
    kTimeout = 4,
    kInternalError = 5
};

struct Result {
    ErrorCode code;
    std::string_view message;

    bool is_ok() const noexcept { return code == ErrorCode::kOk; }
};

Result open_file(const char* path)
{
    if (!path || path[0] == '\0') {
        return {ErrorCode::kInvalidArgument, "path is empty"};
    }
    // ... 实际的文件打开逻辑
    return {ErrorCode::kOk, "success"};
}

这样做的好处是:调用方不能随便传一个 42 进去当错误码——它必须使用 ErrorCode 类型的值。这种编译期检查虽然简单,但在大型项目中能帮你省下大量调试时间。

C 与 C++ 接口互操作

在实际项目中,enum class 有时会碰到与 C 接口交互的场景。底层 C 库可能要求传 intuint32_t,而你的 C++ 代码用的是 enum class。这时候需要显式转换:

cpp
extern "C" void hal_set_mode(uint8_t mode);

enum class HalMode : uint8_t {
    kSleep = 0,
    kNormal = 1,
    kBoost = 2
};

void set_device_mode(HalMode mode)
{
    // enum class -> 底层类型 -> C 接口
    hal_set_mode(static_cast<uint8_t>(mode));
}

如果你需要频繁做这种转换,to_underlying 辅助函数(或者 C++23 的 std::to_underlying)能帮你少写几行 static_cast。不过从笔者的经验来看,这种转换通常集中在接口层(adapter 层),不会散布在业务逻辑中,所以代码量并不算大。

小结

enum class 从 C++11 开始就存在了,到今天已经是现代 C++ 中不可或缺的基础工具。它通过三个核心改进——作用域隔离、禁止隐式转换、可指定底层类型——彻底修复了 C 风格 enum 的类型安全问题。

位运算需要手写运算符重载,但这恰恰是类型安全的体现:编译器不会在你不知情的情况下把两个不同枚举的值混在一起做位运算。switchenum class 的配合让编译器帮你检查穷尽性,配合 -Wswitch 选项,新增枚举值时不会遗漏任何分支。C++20 的 using enum 则在保持类型安全的前提下,为频繁使用枚举的场景提供了便利的简写方式。

下一篇我们要探讨的"强类型 typedef",和 enum class 解决的是同一类问题——只不过它面向的不是"有限的枚举值",而是"相同底层类型但语义不同的值"。

参考资源

基于 VitePress 构建