跳过正文
  1. Articles/

C++ 中如何优雅进行 enum 到 string 的转换 ?

·2425 字·5 分钟· ·
ykiko
作者
ykiko
目录

拒绝硬编码
#

定义一个enum

enum Color {
    RED,
    GREEN,
    BLUE
};

尝试打印

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

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

考虑使用一个数组当map,将枚举值作为key,将字符串作为value,这样就可以通过枚举值直接查找到对应的字符串了

std::string_view color_map[] = {
    "RED",
    "GREEN",
    "BLUE"
};

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

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

模板打表
#

这一小节介绍的内容已经有人提前封装好了,可以直接使用 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,可能会增加几十秒甚至更长的编译时间。所以一般只适用于小型枚举。优点是轻量级,开箱即用,其它的什么也不用做。

外部代码生成
#

既然手写字符串转枚举很麻烦,那么写个脚本生成代码不就行了?的确如此,我们可以使用 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} value) {{
    switch(value) {{
{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 value) {
    switch(value) {
case 0: return "RED";
case 1: return "BLUE";
case 2: return "GREEN";
}}

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

#

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

// 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的增加和修改会十分困难。

总结
#

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

年年月月盼反射,还是不知道什么时候才能进入标准呢。想要提前了解 C++ 静态反射的小伙伴,可以看

或者还不知道反射是什么的小伙伴,可以参考这篇文章的内容