加载中...
如何静态反射C++枚举的名字
第1节:代码改善:一个“坑爹”的文字类冒险游戏
第2节:在禁止多重继承的情况下,如何设计“直立智慧猩猩”类?
第3节:C++多线程代码中的“乱序”执行现象
第4节:C++中函数指针有什么作用呢?
第5节:为什么我用c++写的游戏那么简陋?
第6节:多线程读写socket导致的数据混乱的原因是什么?
第7节:WebSocket 是什么原理?为什么可以实现持久连接?
第8节:怎样在c++中实现instanceof?
第9节:一个函数多处 return 是好风格吗?
第10节:C++中虚函数相比非虚函数的优势
第11节:为什么 C::C::C::C::foo() 能编译成功?
第12节:如何静态反射C++枚举的名字
第13节:看C++大叔如何拥 java 妹子入怀……
第14节:坨——理解递归实现“汉诺塔”代码的关键
第15节:C++编译器如何实现 const(常量)?
第16节:C++如何为断言加上消息
第17节:初学C++到什么水平,算是合格的初级开发工程师?
第18节:C++编程要避免使用单例模式吗?
第19节:学习C++要学boost库吗?
第20节:C++的继承就是复制吗?
第21节:C++构造函数失败,如何中止创建对象?
第22节:C++学完多线程后,学什么呢?
第23节:string_view 适合用做函数的返回值类型吗?
第24节:为指针取别名,为何影响const属性?
第25节:std::enable_shared_from_this 的存在意义?
第26节:C++模板可变参数如何一次性解包?
第27节:Linux下的c++开发,平时是怎么调试代码的呢?
课文封面

假设有个枚举:

enum class Color {red, green, blue}; auto bkgnd = Color::blue; cout << bkgnd; // 将输出数字 2

如何做到在运行期零成本输出bkgnd为blue或Color::blue?这里的零成本包括不在运行期额外申请内存,也不需要额外的 CPU 计算——一切都在编译器准备好。

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++