在 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 查看原始项目的完整源码。
欢迎关注微信公众号,一起交流