no hard code

定义一个 enum

enum Color { RED, GREEN, BLUE };

尝试打印

Color color = RED;
std::cout << color << std::endl;
// output => 0

如果需要枚举作为日志输出,我们不希望在查看日志的时候,还要人工去根据枚举值去查找对应的字符串,麻烦并且不直观。我们希望直接输出枚举值对应的字符串,比如 REDGREENBLUE

手动编写 switch 完成枚举转字符串

std::string enum_to_string(Color color) {
    switch(color) {
        case Color::RED: return "RED";
        case Color::GREEN: return "GREEN";
        case Color::BLUE: return "BLUE";
    }
    return "Unknown";
}

但是当枚举数量很多的时候,手写并不方便,非常繁琐。具体表现为,如果我们想增加若干枚举定义,那字符串映射表相应的内容也需要修改,当数量达到上百个的时候,很可能会有疏漏。或者接手一个别人的项目,发现他有一大堆枚举,内容太多,手写非常耗时间。

需要寻找解决办法,能自动的进行相关的修改。在别的语言中,如 Java,C#,Python,可以轻松的通过反射实现这个功能。但是 C++ 目前并没有反射,故此路不通。目前这个问题主要有三种解决方案。

template

这一小节介绍的内容已经有人提前封装好了,可以直接使用 magic enum 这个库。下面主要是对这个库的原理进行解析,为了方便展示,将用 C++20 实现,实际上 C++17 就可以。

在三大主流编译器中,有一些特殊宏变量。GCC 和 Clang 中的 __PRETTY_FUNCTION__,MSVC 中的 __FUNCSIG__。这几个宏变量会在编译期间被替换成函数的签名,如果该函数是模板函数则会将模板实例化的信息也输出(也可以使用 C++20 加入标准的 source_location,它具有和这些宏类似的效果)

template <typename T>
void print_fn() {
#if __GNUC__ || __clang__
    std::cout << __PRETTY_FUNCTION__ << std::endl;
#elif _MSC_VER
    std::cout << __FUNCSIG__ << std::endl;
#endif
}

print_fn<int>();
// gcc and clang => void print_fn() [with T = int]
// msvc => void __cdecl print_fn<int>(void)

特别的,当模板参数是枚举常量的时候,会输出枚举常量的名称

template <auto T>
void print_fn() {
#if __GNUC__ || __clang__
    std::cout << __PRETTY_FUNCTION__ << std::endl;
#elif _MSC_VER
    std::cout << __FUNCSIG__ << std::endl;
#endif
}

enum Color { RED, GREEN, BLUE };

print_fn<RED>();
// gcc and clang => void print_fn() [with auto T = RED]
// msvc => void __cdecl print_fn<RED>(void)

可以发现,在特定的位置出现了枚举名。通过简单的字符串裁剪,便能得到我们想要的内容了

template <auto value>
constexpr auto enum_name() {
    std::string_view name;
#if __GNUC__ || __clang__
    name = __PRETTY_FUNCTION__;
    std::size_t start = name.find('=') + 2;
    std::size_t end = name.size() - 1;
    name = std::string_view{name.data() + start, end - start};
    start = name.rfind("::");
#elif _MSC_VER
    name = __FUNCSIG__;
    std::size_t start = name.find('<') + 1;
    std::size_t end = name.rfind(">(");
    name = std::string_view{name.data() + start, end - start};
    start = name.rfind("::");
#endif
    return start == std::string_view::npos
               ? name
               : std::string_view{name.data() + start + 2, name.size() - start - 2};
}

进行测试

enum Color { RED, GREEN, BLUE };

int main() {
    std::cout << enum_name<RED>() << std::endl;
    // output => RED
}

成功满足我们的需求。但是事情并没有结束,这种形式要求枚举是模板参数,那就只支持编译期常量。但是其实绝大部分时候,我们用的枚举都是运行期变量,怎么办呢?静态转动态,只要打个表就行了,考虑通过模板元编程生成一个 array,其中每个元素就是 index 对应枚举的字符串表示。一个问题是,这个数组应该多大,这就需要我们来获取枚举项的数量了。一种比较直接的办法是,直接在枚举中定义一对用来标记的首尾项,这样直接相减就能获取到枚举的最大数量了。但是很多时候,我们并不能修改枚举定义,还好这里有一个小 trick 能解决这个问题

constexpr Color color = static_cast<Color>(-1);
std::cout << enum_name<color>() << std::endl;
// output => (Color)2

可以发现,如果这个整数没有对应的枚举项,那么最后就不会输出对应的枚举名,而是带有括号的强制转换表达式。这样只需要判断下得到的字符串中有没有 ) 就知道对应的枚举项是否存在了。递归判断就可以找出最大的枚举值了(这样查找适用范围有限,如分散枚举值,可能相对困难一点)

template <typename T, std::size_t N = 0>
constexpr auto enum_max() {
    constexpr auto value = static_cast<T>(N);
    if constexpr(enum_name<value>().find(")") == std::string_view::npos)
        return enum_max<T, N + 1>();
    else
        return N;
}

然后通过 make_index_sequence 生成一个对应的长度数组就行了

template <typename T>
    requires std::is_enum_v<T>
constexpr auto enum_name(T value) {
    constexpr auto num = enum_max<T>();
    constexpr auto names = []<std::size_t... Is>(std::index_sequence<Is...>) {
        return std::array<std::string_view, num>{enum_name<static_cast<T>(Is)>()...};
    }(std::make_index_sequence<num>{});
    return names[static_cast<std::size_t>(value)];
}

测试一下

enum Color { RED, GREEN, BLUE };

int main() {
    Color color = RED;
    std::cout << enum_name(color) << std::endl;
    // output => RED
}

更进一步可以考虑支持 bitwidth enum,也就是 RED | BLUE 这种形式的枚举,这里就不继续展开了。

这种方法的缺点很明显,通过模板实例化来打表,其实会很大的拖慢编译速度。如果 enum 中的数量较多,在一些对常量求值效率较低的编译器上,如 MSVC,可能会增加几十秒甚至更长的编译时间。所以一般只适用于小型枚举。优点是轻量级,开箱即用,其他的什么也不用做。

code generation

既然手写字符串转枚举很麻烦,那么写个脚本生成代码不就行了?的确如此,我们可以使用 libclang 的 Python bind 轻松的完成这项工作。具体如何使用这个工具,可以参考 使用 Clang 工具自由的支配 C++ 代码吧,下面只展示实现效果的代码

import clang.cindex as CX

def generate_enum_to_string(enum: CX.Cursor):
    branchs = ""
    for child in enum.get_children():
        branchs += f'case {child.enum_value}: return "{child.spelling}";\n'
    code = f"""
std::string_view {enum.spelling}_to_string({enum.spelling} color) {{
    switch(color) {{
{branchs}
    }}
}}"""
    return code

def traverse(node: CX.Cursor):
    if node.kind == CX.CursorKind.ENUM_DECL:
        print(generate_enum_to_string(node))
        return

    for child in node.get_children():
        traverse(child)

index = CX.Index.create()
tu = index.parse('main.cpp')
traverse(tu.cursor)

测试代码

// main.cpp
enum Color { RED, GREEN, BLUE };

这是最后生成的代码,可以直接生成 .cpp 文件,放在固定目录下面,然后构建之前运行一下这个脚本就行了

std::string_view enum_to_string(Color color) {
    switch(color) {
        case 0: return "RED";
        case 1: return "BLUE";
        case 2: return "GREEN";
    }
}

优点,非侵入式,可以用于大数量的枚举。缺点,有外部依赖,需要将代码生成加入到编译流程里面。可能会使编译流程变得很复杂。

xmacro

上面的两种方式都是非侵入式的。也就是说,可能你拿到了一个别人的库,不能修改它的代码,只好这么做了。如果是完全由自己定义枚举呢?其实可以在定义阶段就特殊处理,以方便后续的使用。比如(代码开头的注释表示当前文件名):

// Color.def
#ifndef COLOR_ENUM
#define COLOR_ENUM(...)
#endif

COLOR_ENUM(RED)
COLOR_ENUM(GREEN)
COLOR_ENUM(BLUE)

#undef COLOR_ENUM

然后在要使用的地方,通过修改宏定义来生成代码就行了

// Color.h
enum Color {

#define COLOR_ENUM(x) x,
#include "Color.def"
};

std::string_view color_to_string(Color value) {
    switch(value) {
#define COLOR_ENUM(x)                                                                                        \
    case x: return #x;
#include "Color.def"
    }
}

这样的话,只要在 def 文件里面进行相关的增加和修改就行了。之后如果要遍历 enum 什么的,也可以直接定义一个宏来生成代码就行了,非常方便。事实上,对于大数量的枚举,有很多开源项目都采取这种方案。例如 Clang 在定义 TokenKind 的时候,就是这么做的,相关的代码请参考 Token.def。由于 Clang 要适配多种语言前端,最后总计的 TokenKind 有几百个之多。如果不这样做,进行 Token 的增加和修改会十分困难。

conclusion

  • 非侵入式且枚举数量较少,编译速度不是很重要,那就使用模板打表(至少要求 C++17)
  • 非侵入式且枚举数量较多,编译速度很重要,那就使用外部代码生成
  • 侵入式,可以直接使用宏

年年月月盼反射,还是不知道什么时候才能进入标准呢。想要提前了解 C++ 静态反射的小伙伴,可以看 C++26 静态反射提案解析。或者还不知道反射是什么的小伙伴,可以参考这篇文章的内容:写给 C++ 程序员的反射教程