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

在 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("BLUE").value(), Color::BLUE);

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

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

枚举值转换为字符串

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

template 
concept Enum = std::is_enum_v;

template 
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(void) noexcept 去掉末尾17个再过滤开头
  constexpr auto name = pretty_name({ __FUNCSIG__, sizeof(__FUNCSIG__) - 17 });
#  endif
  return static_string{name};
}
template 
inline constexpr auto enum_name_v = n();

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

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

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

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

#define __FUNCSIG__ \
  "auto __cdecl magic_enum::detail::n(void) noexcept"

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

"auto __cdecl magic_enum::detail::n

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;
CHECK_EQ(s, "RED");
//这相当于编译器最后生成的代码
CHECK_EQ("RED"sv, "RED");

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

字符串转换为枚举

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

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

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

//V是否为指定枚举的合法值
template 
constexpr bool is_valid() noexcept { return n(V)>().size() != 0; }

//返回以O为基准、指定序号的枚举值
template >
constexpr E value(std::size_t i) noexcept { return static_cast(static_cast(i) + O); }

template 
constexpr auto values(std::index_sequence) noexcept {
  //遍历指定取值检查是否合法枚举值
  constexpr bool valid[sizeof...(I)] = { is_valid(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(i);
    return std::to_array(values); //再转换成array后返回
  } else {
    return std::array{}; //无合法枚举值,返回空array
  }
}

//返回取值范围中的所有合法值,是一个基于最小值的索引序列
template >
constexpr auto values() noexcept {
  constexpr auto min = reflected_min_v; //枚举范围最小值
  constexpr auto max = reflected_max_v; //枚举范围最大值
  constexpr auto range_size = max - min + 1;
  return values>(std::make_index_sequence{});
}

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

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

template 
inline constexpr auto count_v = values_v.size(); //size_t类型

template 
constexpr auto names(std::index_sequence) noexcept {
  return std::array{ { enum_name_v[I]>... }};
}

template 
inline constexpr auto names_v = names(std::make_index_sequence>{});

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

template 
constexpr auto enum_cast(std::string_view value) noexcept -> std::optional
{
  for (std::size_t i = 0; i < count_v; ++i) //逐个比较,相等则返回对应枚举值
    if (value == names_v[i])
      return enum_value(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("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++ 中枚举与字符串转换技巧)