1 问题
C++ 有枚举,编译后其值就被转成整数了,有时程序会有输出枚举名字的需求,朴素的做法就是手工一个个写字符串(名字),并实现匹配,比如:
enum class Shape {rectangle, circular};
std::string ToString (Shape s)
{
switch(s)
{
case Shape::rectangle : return "rectangle";
case Shape::circular : return "circular";
default: assert(false && "不依规矩,不成方圆");
}
}
这当然很烦人,三个烦人处:一是每种类型都得写一个函数;二是手工打字符串容易打字出错;三是default那个地方很讨厌。
2 破题 __PRETTY_FUNCTION__
好在,g++、clang 都扩展支持一个有趣的宏:__PRETTY_FUNCTION__ , MSVC 则有 __FUNCSIG__ 。在编译时,这个宏会被替换成一个字符串,内容是当前所在函数的“漂亮的/pretty”的名字,比如:
void foo(const char* )
{
std::cout << __PRETTY_FUNCTION__ << std::endl;
}
调用 foo(), 它应该输出:
void foo(const char*)
注意到了吗?入参的类型 const char* 是 __PRETTY_FUNCTION__ 所得的函数名字的组成之一 —— 这并不意外,要区分同名的多个不同函数,必须依靠它的入参组成(入参类型、入参个数),这是C++函数支持重载的重要机制。
嗯,现在可以陷入沉思了……要是有个函数,它入参就是“类型”(不是类型具化后数据),那么传入int类型, 通过 __PRETTY_FUNCTION__ 能得到一个带 “int” 子串的字符串,传入 const char (或 char const),就能得到一个带 “const char” 的字符串,把子串扣出来,我们就得到某个类型的名字了嘛!而如果我们有一个 枚举(enum)类型叫 “Color”,不也一样能扣出它的名字?这距离我们扣枚举的值的名字,似乎很近了。
继续深思,入参是类型,而不是类型具化后的数据,不就是模板函数嘛!
让我们来写一个函数模板,看看这个 __PRETTY_FUNCTION__ 得到的函数名,会是什么呢?
我们给出能完整执行的代码:
#include <iostream>
template<typename T>
void foo(T t)
{
std::cout << __PRETTY_FUNCTION__ << std::endl;
}
int main()
{
foo(10);
foo('c');
}
它的输出是:
void foo(T) [with T = int]
void foo(T) [with T = char]
注意到了吧!在一对方括号中"with"后面的内容——漂亮地包含了参数的实际类型。
3 试试用户自定义类型
不仅支持C++内置的简单类型,以上代码当然也支持 用户自定义 的struct/class类型的。让我们马上再来试试,这次我们干脆让foo函数返回字符串:
#include <iostream>
template<typename T>
char const* foo(T t)
{
return __PRETTY_FUNCTION__;
}
struct d2school {};
int main()
{
std::cout << foo( d2school{} ) << std::endl;
}
刻意解释: d2school {} 用来临时构造一个 d2school 类型的对象,自然,也可以使用C++老传统的 d2chool () 来构造。
输出是:const char* foo(T) [with T = d2school]。
说到用户自定义类型,枚举也是用户自定义类型呀,来,马上试试:
#include <iostream>
template<typename T>
char const* foo(T t)
{
return __PRETTY_FUNCTION__;
}
struct d2school {};
enum class Color {red, green};
int main()
{
std::cout << foo(d2school {}) << std::endl;
std::cout << foo(Color::red) << std::endl;
}
猜到了吧,输出肯定是:
const char* foo(T) [with T = d2school]
const char* foo(T) [with T = Color]
前面刻意解释 的“ d2school {} ”暴露了一个小问题:为了得到一个类型的名字,我们却不得不临时提供一个数据,正好有个数据时倒好办,手上没数据强行搞一个变量这就烦人了。
小问题倒是好解决,C++模板允许我们精确地指定一个模板的类型的入参。下面我们去掉foo的入参,再对应修改调用方法:
#include <iostream>
template<typename T>
char const* foo() // 入参去掉了……
{
return __PRETTY_FUNCTION__;
}
struct d2school {};
enum class Color {red, green};
int main()
{
// 调用起来很酷:
std::cout << foo<unsigned int>() << std::endl;
std::cout << foo<bool>() << std::endl;
std::cout << foo<d2school>() << std::endl;
std::cout << foo<Color>() << std::endl;
}
嗯,现在的代码用起来和看起来都非常的有C++的味道(毕竟我们都用过 C++自带的那些xxx_cast 不是?)
能跨各主流编译器(g++\clang\MSVC),并且是在编译期间得到指定类型的名字字符串,在很多时候也是蛮有用的。但是,我们不仅想要枚举类型的名字,我们还想要枚举值的名字。
这世上还有什么语言,能像C++那样对模板(泛型)支持到令人发指的程度呢?C++模板的又一个简单而基础的知识点来了:模板支持非类型的入参呢!大白话点讲,就是模板入参不仅可以传类型,也可以假装“退回”普通函数,传一个具体的数据,而枚举值,就是一个数据。
如果你还不明白,就再看一眼前面代码中我们定义的Color枚举, Color 是一个枚举类型,而其下定义的 red、green,那是值。
虽说支持传递非类型参数 是C++模板一项“令人发指”的特性,但又有细分:在当前主流的 C++1x 标准下,它是翘兰花指,而在C++20,这项特性是疯狂到赤裸裸“竖中指”的地步——C++1x (11、14、17)传的数据只能是简单类型,而C++20以后,竟然可以传用户自定久的struct/class类型的数据了……
我们使用C++1x 标准就够了,因为enum 值 底层实现是整数系类型,属于简单类型。
4 两个模板参数:类型和该类型的数据
现在我们要为 foo 模板 添加第二个模板入参,并且它是第一个入参(T,一个类型)的数据,让我们为它取名为“V”:
template <typename T, T V> // “T”是类型, “V” 是 T 类型的一个数据
char const* foo()
{
return __PRETTY_FUNCTION__;
}
注意:T 前面 有个 typename(“type name”),明确指明T是一个类型(的名字),而V,它的前面是T,所以它是一个T类型的数据。
既然模板入参多了一个,调用时,自然也得多传一个,比如这样:foo<bool, false>() ; 其中,bool 是一个类型,而 false 是一个bool类型的数据。完整代码如下:
#include <iostream>
template<typename T, T V>
char const* foo()
{
return __PRETTY_FUNCTION__;
}
enum class Color {red, green};
int main()
{
std::cout << foo<unsigned int, 999u>() << std::endl;
std::cout << foo<bool, false>() << std::endl;
std::cout << foo<Color, Color::red>() << std::endl;
}
注意:我们去掉了 d2shool 结构的相关代码,原因见前。
我们迫切关心的是:__PRETTY_FUNCTION__ 这家伙,它将如何展现第二个模板参数的内容,请看以上代码的输出:
const char* foo() [with T = unsigned int; T V = 999]
const char* foo() [with T = bool; T V = false]
const char* foo() [with T = Color; T V = Color::red]
__PRETTY_FUNCTION__,你太棒了!竟然把值都给包含进去了……
看到输出内容中的“Color::red”了吧?现在,想办法把它从上面的字符串中扣出来,文章就结束了。
5 编译期“扣”字符串
扣的方法很多——但关键是得在编译期间扣——“现代”的C++的又一知识点来了:带有constexpr 修饰的函数,编译器将会在编译代码时就执行它,得到结果后,把结果塞入代码以替换这个函数的执行——听起来像是加强版的“inline”修饰的函数;但后者只是尝试将函数内的实现提出来变成每一处调用位置的代码,constexpr 却是尝试先编译一下这个函数,然后再将编译后的这个函数执行一下,得到结果后再替换代码。
举个例子吧,假设,你有一个函数:
constexpr int accumulate (int beg, int end)
{
int r = 0;
for (int i=beg; i<=end; ++i)
r += i;
return r;
}
逻辑很简单:从 beg 一直加到 end,返回累加和。然后你这么调用:
int sum = accumulate (1, 100);
由于 accumulate 带有 constexpr 修饰,所以编译器会在编译时——此时你的可执行程序还不存在——就直接执行 accumulate(1, 100),然后在内心骂你一句“小傻瓜,这不就是 1加到100嘛!”,于是(可以先简单地认为)它帮你改写了调用处的源代码,变成:
int sum = 5050;
然后再编译。
——这已经不是兰花指或中指的问题了,依我看这是举或不举的问题。同样,这里也有版本高低之分。C++11标准能支持constexpr函数有很大限制,比如无法支持如上带有循环的代码;但C++14或更高标准则能支持(当然也有不少限制)。
牛皮吹完……现在必须强调一下,constexpr 并不强制,或者说,并不保证经它修饰的函数,一定能在编译器执行求值,你必须依据C++标准的规定很小心地写函数,否则,标示了constexpr的函数,依然可能是在运行时调用。
也可以将上述现象视为constexpr的一种灵活性;你可以拿它和C++20的consteval 作对比。
回归主题,如何从母串:
“const char* foo() [with T = Color; T V = Color::red]”
当中扣出“Color”、“Color::red”、“red” 这三个子串呢?表面上看,这太简单了,基本和现代C++的新鲜知识点无关了;但正如前面所强调的,如何写这一实现,事实上必须非常小心,甚至往往得借助一些工具查看编译后的汇编代码,才能确实是否真的实现了编译期求值。
我的扣法是:找到母串中的 ‘=’(两个) 、’;’ 、 ‘:’ (第二个)以及最后结束的 ‘]’ 等字符在母串中的位置(下标索引),然后:
- 首个 ‘=’ 和 ‘;’ 之间的,是枚举的类型名字,本例是 Color;
- 第二个’=’ 和 ‘]’ 之间的,是枚举值的全称,本例是 Color::red;
- 冒号和 ‘]’ 之间的,是枚举值的短名字,本例是 red。
实际处理时,还需要跳过等号后面的一个空格。另外,我们也得支持传统C++的枚举(即不带class),此时,全称和短名字是相同的。判断方法:没冒号就是传统的枚举,有冒号就是新标的枚举——专业术语叫 “scoped enum”。
连实现带用例的完整代码见后。
一点说明:为了省除手工写代码,我用了C++17的string_view。因此下面的代码必须使用支持C++17标准的编译器;事实上,于g++而言,编译器自身版本也得足够新,建议11.x以上。
如果不懂如何查看自己用的C++编译器支持的C++版本,可以通过类似以下代码查看相关版本:
cout << __cplusplus << " , " << __VERSION__ << endl;
我的输出是:201703,11.2.0
6 完整代码
#include <cassert>
#include <cstring>
#include <iostream>
#include <string_view>
// 用来存储枚举反射信息的结构体
// 注意名字都使用 string_view 存储,以避免动态内存分配
struct ReflectionEnumInfo
{
bool scoped; // 是否 scoped enum
std::string_view name, valueFullName, valueName; // 类型名、值名、值全名
// 构造时,从母串中按指定位置 得到各子串
// info : 母串,即 __PRETTY_FUNCTION__ 得到的函数名
// e1:等号1位置; s:分号位置; e2:等号2位置; colon:分号位置; end:]位置
constexpr
ReflectionEnumInfo(char const* info
, std::size_t e1, std::size_t s, std::size_t e2
, std::size_t colon, std::size_t end)
: scoped(colon != 0), name (info + e1 + 2, s - e1 -2)
, valueFullName (info + e2 + 2, end - e2 - 2)
, valueName((scoped)? std::string_view(info+colon+1, end-colon-1)
: valueFullName)
{}
};
// 说了半天的 模板函数,带 constexpr
template <typename E, E V>
constexpr ReflectionEnumInfo Renum()
{
char const* info = __PRETTY_FUNCTION__;
// 找各个符号位置
std::size_t l = strlen(info);
std::size_t e1 = 0, s = 0, e2 = 0, colon = 0, end = 0;
for (std::size_t i=0; i<l && !end; ++i)
{
switch(info[i])
{
case '=' : (!e1) ? e1 = i : e2 = i; break;
case ';' : s = i; break;
case ':' : colon = i; break;
case ']' : end = i; break;
}
}
return {info, e1, s, e2, colon, end};
}
////// 下面是用例代码 ///////
// 为方便输出 ReflectionEnumInfo ,重载一下输出流操作
std::ostream& operator << (std::ostream& os, ReflectionEnumInfo const& ri)
{
os << "scoped = " << std::boolalpha << ri.scoped << std::noboolalpha
<< "\nname = " << ri.name
<< "\nvalueName = " << ri.valueName
<< "\nvalueFullName = " << ri.valueFullName
<< "\n------------------------------\n";
return os;
}
enum class Shape {rectangle, circular};
enum Color {cRed = 1, cGreen };
int main()
{
auto ri1 = Renum<Shape, Shape::rectangle>();
std::cout << ri1;
auto ri2 = Renum<Shape, Shape::circular>();
std::cout << ri2;
auto ri3 = Renum<Color, cRed>();
std::cout << ri3;
auto ri4 = Renum<Color, static_cast<Color>(2)>();
std::cout << ri4;
auto ri5 = Renum<Color, static_cast<Color>(12)>();
std::cout << ri5;
std::cout << std::endl;
}
请特别注意最后两个测试案例,它们都是从整数值强制转换到Color枚举,但一个是合法范围,另一个是非法范围。
另外,请注意,这里使用了 gcc 的一处扩展:gcc 提供了静态版的 strlen()库函数 ,即:给出一个编译期的C风格(以零结束)的字符串,就能在编译期直接“数”出来这个字符串的长度。如果不利用这个扩展函数的话,得自己写一个编译期的 strlen()。
输出:
scoped = true
name = Shape
valueName = rectangle
valueFullName = Shape::rectangle
------------------------------
scoped = true
name = Shape
valueName = circular
valueFullName = Shape::circular
------------------------------
scoped = false
name = Color
valueName = cRed
valueFullName = cRed
------------------------------
scoped = false
name = Color
valueName = cGreen
valueFullName = cGreen
------------------------------
scoped = false
name = Color
valueName = (Color)12
valueFullName = (Color)12
-------------------------------
在线编译、运行及查看结果 :Coliru Viewer 。
从程序自身的运行输出结果看,似乎没有错,但是,“Renum()”函数到底是不是只在编译器执行呢?可借助工具站点(详见文末)查看代码的汇编结果。其中的重点在于main()函数内的第一行代码:
auto ri = Renum<Shape, Shape::rectangle>();
它的汇编是:
粗略的看,.LC5 的位置存储了一个字符串,正是本次函数“调用”时 __PRETTY_FUNCTION__ 所代表的字符串;而标注为147、150、153等行号代码中各自出现的“神奇”数字如 47、60、67 应是编译期计算出来几个数值,代表在母串的偏移,家可以手工数数。汇编代码详见 Compiler Explorer - C++。