静态反射C++枚举名字的超简方案——C++闲得慌系列之(一)

静态反射C++枚举名字的超简方案——C++闲得慌系列之(一)_第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那个地方很讨厌。

好在,g++、clang,(如果是微软的 MSVC,则应是__FUNCSIG__)都扩展支持一个有趣的宏:__PRETTY_FUNCTION__ 。在编译时,它会被替换成一个字符串,内容是当前所在函数的“漂亮的/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 

template
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"后面的内容——漂亮地包含了参数的实际类型。

不仅支持C++内置的简单类型,以上代码当然也支持 用户自定义 的struct/class类型的。让我们马上再来试试,这次我们干脆让foo函数返回字符串:

#include 

template
char const* foo(T t)
{
    return __PRETTY_FUNCTION__;
}

struct d2school {}; //注释一下:d2school 是个小网站,谁用谁知道

int main()
{    
    std::cout << foo( d2school{} ) << std::endl;
}
刻意解释: d2school {} 用来临时构造一个 d2school 类型的对象,自然,也可以使用C++老传统的 d2chool () 来构造。

输出是:const char* foo(T) [with T = d2school]。

说到用户自定义类型,枚举也是用户自定义类型呀,来试试:

#include 

template
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 

template
char const* foo()  // 入参去掉了……
{
    return __PRETTY_FUNCTION__;
}

struct d2school {};
enum class Color {red, green};

int main()
{
   // 调用起来很酷:
    std::cout << foo() << std::endl;
    std::cout << foo() << std::endl;
    std::cout << foo() << std::endl;    
    std::cout << foo() << 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 值 底层实现是整数系类型,属于简单类型。

现在我们要为 foo 模板 添加第二个模板入参,并且它是第一个入参(T,一个类型)的数据,让我们为它取名为“V”:

template  // “V” 在这里
char const* foo()
{
     return __PRETTY_FUNCTION__;
}

注意: T 前面 有个 typename(“type name”),明确指明T是一个类型(的名字),而V,它的前面是T,所以它是一个T类型的数据。

既然模板入参多了一个,调用时,自然也得多传一个,比如这样:foo() ; 其中,bool 是一个类型,而 false 是一个bool类型的数据。完整代码如下:

#include 

template
char const* foo()
{
    return __PRETTY_FUNCTION__;
}

enum class Color {red, green};

int main()
{
    std::cout << foo() << std::endl;
    std::cout << foo() << std::endl;    
    std::cout << foo() << 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”了吧?现在,想办法把它从上面的字符串中扣出来,文章就结束了。

扣的方法很多——但关键是得在编译期间扣——“现代”的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以上。

可以通过类似以下代码查看相关版本:
cout << __cplusplus << " , " << __VERSION__ << endl;
我的输出是:201703,11.2.0
#include 
#include 
#include 
#include 

// 用来存储枚举反射信息的结构体
// 注意名字都使用 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  
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();    
    std::cout << ri1;
    
    auto ri2 = Renum();
    std::cout << ri2;

    
    auto ri3 = Renum();
    std::cout << ri3;
    
    auto ri4 = Renum(2)>();
    std::cout << ri4;    

    auto ri5 = Renum(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(); 

它的汇编是:

静态反射C++枚举名字的超简方案——C++闲得慌系列之(一)_第2张图片

粗略的看,.LC5 的位置存储了一个字符串,正是本次函数“调用”时 __PRETTY_FUNCTION__ 所代表的字符串;而标注为147、150、153等行号代码中各自出现的“神奇”数字如 47、60、67 应是编译期计算出来几个数值,代表在母串的偏移,大家可以手工数数。详见:Compiler Explorer - C++ (x86-64 gcc 11.2) 。

你可能感兴趣的:(白话C++,c++,开发语言)