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<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 查看原始项目的完整源码。

欢迎关注微信公众号,一起交流
兆华杂记
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容