美文网首页IT技术篇
Modern C++ 中枚举与字符串转换技巧

Modern C++ 中枚举与字符串转换技巧

作者: 魏兆华 | 来源:发表于2021-09-08 17:16 被阅读0次

    在 Java、C# 这样的语言中,从枚举转换成字符串,或者从字符串转换成枚举,都是很常见的操作,也很方便。比如下面是 C# 的例子:

    public enum Color { red, green, blue }
    
    static void Main(string[] args) {
      Console.WriteLine("This color is {0}.", Color.red);
    }
    

    之所以可以这么用,是因为在 IL 中以元数据方式保存了整个枚举类型的各类信息,包括其内部实际值和类型名称字符串。

    C++ 中就没有那么容易了,因为 C++ 直接将源代码编译成目标机的机器语言,也就是最终执行的指令序列,枚举类型的名称字符串在指令序列中是不存在的。但是,现实应用中确实可能存在这样的场合,即需要从枚举名称字符串找到它对应的枚举值,有没有办法实现呢?

    有人说这还不简单,手工建立一个查询字典不就可以了么?确实是可以,但是不得不说,这个方法它确实是既低效又丑陋,对于讲究代码美学的高等级码农来说,肯定是不能忍受啊,我们要的就是不管看起来还是用起来,都无比简洁自然的那种实现。

    如果只从 C++ 标准来看是没有直接办法的,但事实上每一种 C++ 编译器都在 C++ 标准之外有所拓展,充分利用好这些拓展,就能轻松实现上述需求。本文就是笔者在 Github 上冲浪时,无意中发现的一个名叫 magic_enum 的 C++ 项目,相当完美地解决了这个问题。随后笔者重新 C++20 的 concept,并使用 doctest 重新写了一个相对简单的示例程序。下面进行简要介绍和技术解析。

    使用示例

    先看最常用的使用场景:

    enum class Color : int { RED = -10, BLUE = 0, GREEN = 10 };
    //场景一:枚举值转换成字符串
    CHECK_EQ(enum_name(Color::RED), "RED");
    //场景二:字符串转换成枚举值
    CHECK_EQ(enum_cast<Color>("BLUE").value(), Color::BLUE);
    

    场景一,从枚举值转换为字符串,这个相对简单,只要找到办法能将枚举值的表示字符串,转化为实际的字符串类型就可以。

    场景二,从字符串转换成枚举值,这个来说要复杂得多。首先,得知道要转换成哪一个枚举类型,因为一个字符串可能与多个枚举类型相对应,所以必须要指定转换类型,可以用模板参数来表示,就像上面例子中那样;其次,一个字符串未必一定能够成功转换成指定枚举类型中的值,比如上面例子中如果使用 "CYAN" 来作为参数,那么是没办法转换成 RED、BLUE、GREEN 三者之一的,换句话说,从字符串转换到枚举值是有可能没有结果的。

    枚举值转换为字符串

    闲话少说,直接上代码(简化版):

    template <typename E>
    concept Enum = std::is_enum_v<E>;
    
    template <Enum E, E V>
    constexpr auto n() noexcept {
    #  if defined(__clang__) || defined(__GNUC__)
      constexpr auto name = pretty_name({ __PRETTY_FUNCTION__, sizeof(__PRETTY_FUNCTION__) - 2 });
    #  elif defined(_MSC_VER)
      //auto __cdecl magic_enum::detail::n<enum Color, Color::RED>(void) noexcept 去掉末尾17个再过滤开头
      constexpr auto name = pretty_name({ __FUNCSIG__, sizeof(__FUNCSIG__) - 17 });
    #  endif
      return static_string<name.size()>{name};
    }
    template <Enum E, E V>
    inline constexpr auto enum_name_v = n<E, V>();
    

    理解这段代码的关键,就是各种编译器的自定义宏。以 Visual C++ 为例,它的内部对每个函数都有一个自定义宏 FUNCSIG,意思差不多就是函数签名。在 clang 或者 g++ 里就是 PRETTY_FUNCTION 宏。上面代码中的 n() 函数里,使用条件编译判断当前使用的是哪个编译器,再根据不同的编译器选择不同的自定义宏,获取编译器内部的函数签名,再通过 pretty_name 函数截取到对应的值名称。

    比如我们可以使用以下用法,获取到 Color::RED 值所对应的名称字符串 “RED”:

    constexpr std::string_view s = enum_name_v<Color, Color::RED>;
    CHECK_EQ(s, "RED");
    

    enum_name_v 直接获取 n 函数的返回值,那么将模板参数代入 n 函数后,在 Visual C++ 编译器里,其函数签名就变成了

    #define __FUNCSIG__ \
      "auto __cdecl magic_enum::detail::n<enum Color, Color::RED>(void) noexcept"
    

    pretty_name 函数的调用参数只有一个,就是 string_view,花括号内是它的构造参数,将长度减去 17 之后(包含末尾的 \0),实际调用的参数值就成了:

    "auto __cdecl magic_enum::detail::n<enum Color, Color::RED"
    

    pretty_name 函数的作用,就是由后向前扫描整个字符串,一旦发现非标识符字符就停止,然后截断已经扫描过的字符串并返回:

    constexpr std::string_view pretty_name(std::string_view name) noexcept {
      for (std::size_t i = name.size(); i > 0; --i) {
        if (!((name[i - 1] >= '0' && name[i - 1] <= '9') || (name[i - 1] == '_') ||
          (name[i - 1] >= 'a' && name[i - 1] <= 'z') || (name[i - 1] >= 'A' && name[i - 1] <= 'Z'))) {
          name.remove_prefix(i); //由后向前,发现非标识符字符即启动截断,保留后半截
          break;
        }
      }
      if (name.size() > 0 && ((name.front() >= 'a' && name.front() <= 'z') ||
        (name.front() >= 'A' && name.front() <= 'Z') || (name.front() == '_'))) {
        return name; //首字母不是数字
      }
      return {}; //否则就是非法名称
    }
    

    因此,pretty_name 最后返回的就是 "RED" 这个枚举值名称,它向外传递到 enum_name_v 再赋值给 s,中间经过了自定义类型 static_string 和 string_view 两个类型的自动转换。所以,最后我们的测试断言 CHECK_EQ 是顺利通过的。

    还要注意的一点就是,从 enum_name_v 到 pretty_name 这层层调用的一系列函数,全部都是标记了 constexpr 的,这就意味着它们都可以在编译期就完成求值。换句话说,上面的调用在经过编译器处理后,最后实际变成的是以下代码:

    //这是我们原来书写的代码
    constexpr std::string_view s = enum_name_v<Color, Color::RED>;
    CHECK_EQ(s, "RED");
    //这相当于编译器最后生成的代码
    CHECK_EQ("RED"sv, "RED");
    

    这就是现代 C++ 编译器,编译期计算的能力已经相当强大,由它生成的代码,毫无疑问其执行效率要远高于 Java、C# 以及 Python 等语言。当然,前提是首先得能熟练地掌握它。

    字符串转换为枚举

    如前所述,将字符串转换为枚举要麻烦许多。针对所转换的枚举类型,必须得要有一个完备的字符串列表,并与枚举值一一对应,这样才可以根据字符串去进行查找。那么,需要准备哪些数据呢?来作一下具体分析:

    第一步,要有一个合法枚举值列表,并且编译器要能根据普通的枚举声明自动列举出来。这里需要注意的是,枚举值是可以从负数开始的,也可以是稀疏的,就像前面的例子,Color 类型的三个枚举值,对应的内部值分别是 -10、0、10。

    为进一步简化示例代码,先不考虑标志位枚举的情况,假定都是如 Color 这样的简单枚举,取枚举值列表可以这样完成:

    //V是否为指定枚举的合法值
    template <Enum E, auto V>
    constexpr bool is_valid() noexcept { return n<E, static_cast<E>(V)>().size() != 0; }
    
    //返回以O为基准、指定序号的枚举值
    template <Enum E, int O, typename U = std::underlying_type_t<E>>
    constexpr E value(std::size_t i) noexcept { return static_cast<E>(static_cast<int>(i) + O); }
    
    template <Enum E, int Min, std::size_t... I>
    constexpr auto values(std::index_sequence<I...>) noexcept {
      //遍历指定取值检查是否合法枚举值
      constexpr bool valid[sizeof...(I)] = { is_valid<E, value<E, Min, IsFlags>(I)>()... };
      constexpr std::size_t count = values_count(valid); //共有多少个合法枚举值
      if constexpr (count > 0) {
        E values[count] = {};
        for (std::size_t i = 0, v = 0; v < count; ++i) //将所有合法枚举值填充入数组
          if (valid[i])
            values[v++] = value<E, Min, IsFlags>(i);
        return std::to_array(values); //再转换成array后返回
      } else {
        return std::array<E, 0>{}; //无合法枚举值,返回空array
      }
    }
    
    //返回取值范围中的所有合法值,是一个基于最小值的索引序列
    template <Enum E, typename U = std::underlying_type_t<E>>
    constexpr auto values() noexcept {
      constexpr auto min = reflected_min_v<E>; //枚举范围最小值
      constexpr auto max = reflected_max_v<E>; //枚举范围最大值
      constexpr auto range_size = max - min + 1;
      return values<E, IsFlags, reflected_min_v<E>>(std::make_index_sequence<range_size>{});
    }
    

    上面例子中,reflected_min_v 和 reflected_max_v 两个模板函数,是根据枚举内部类型值以及用户自定义设定,来确定枚举值的取值范围。在遍历整个取值范围后,将所有合法的枚举值存入一个 std::array。注意这里所有函数仍然都是带 constexpr 标记的。

    第二步,要有一个枚举值字符串列表,与上面的合法枚举值一一对应。这个相对好办,解决了第一步之后,可以依次遍历每个枚举值生成字符串,组成列表就可以了:

    template <Enum E>
    inline constexpr auto count_v = values_v<E>.size(); //size_t类型
    
    template <Enum E, std::size_t... I>
    constexpr auto names(std::index_sequence<I...>) noexcept {
      return std::array<std::string_view, sizeof...(I)>{ { enum_name_v<E, values_v<E>[I]>... }};
    }
    
    template <Enum E>
    inline constexpr auto names_v = names<E>(std::make_index_sequence<count_v<E>>{});
    

    下面可以基本完成 enum_cast 主功能了:

    template <Enum E>
    constexpr auto enum_cast(std::string_view value) noexcept -> std::optional<E>
    {
      for (std::size_t i = 0; i < count_v<E>; ++i) //逐个比较,相等则返回对应枚举值
        if (value == names_v<E>[i])
          return enum_value<E>(i);
      return {};
    }
    

    注意返回的是 std::optional 模板类型,如果对应的枚举值没有找到,则返回空值。

    更进一步的设计

    上文中我们完全没有考虑标志位枚举的情况,这种情况要复杂得多,看以下的使用示例:

    enum class AnimalFlags : std::uint64_t {
      HasClaws = 1 << 10,
      CanFly = 1 << 20,
      EatsFish = 1 << 30,
      Endangered = std::uint64_t{ 1 } << 40
    };
    
    constexpr AnimalFlags f1 = AnimalFlags::HasClaws | AnimalFlags::EatsFish;
    CHECK_EQ(enum_name(f1), "HasClaws|EatsFish");
    
    constexpr auto f2 = magic_enum::flags::enum_cast<AnimalFlags>("EatsFish|CanFly");
    CHECK_EQ(f2.value(), AnimalFlags::EatsFish | AnimalFlags::CanFly);
    

    还有最常用的流操作符:

    std::ostringstream str;
    str << Color::RED;
    CHECK_EQ(str.str(), "RED");
    

    此外,还应当允许用户自定义枚举值的字符串名称、自定义字符串比较算法等等,作为一个相对完整的功能,这些都是必要的。具体的实现本文就不再详述了,感兴趣的可以点击 这里 查看笔者改写的源码,也可以点击 magic_enum 查看原始项目的完整源码。

    欢迎关注微信公众号,一起交流 兆华杂记

    相关文章

      网友评论

        本文标题:Modern C++ 中枚举与字符串转换技巧

        本文链接:https://www.haomeiwen.com/subject/bpwvwltx.html