C++的杂七杂八:如何获得完整的类型名称

/ 16评 / 0

Published by orzz.org(). (https://orzz.org/cxx-get-the-name-of-the-given-type/)

地球人都知道C++里有一个typeid操作符可以用来获取一个类型/表达式的名称:

但是这个name()的返回值是取决于编译器的,在vc和gcc中打印出来的结果如下:

一个稍微长一点的类型名称,比如:

打出来是这个效果:

(话说gcc您的返回结果真是。。)
当然了,想在gcc里得到和微软差不多显示效果的方法也是有的,那就是使用__cxa_demangle

显示效果:

先不说不同编译器下的适配问题,来看看下面这个会打印出啥:

显示效果:

可爱的cv限定符和引用都被丢掉了=.=
如果直接在typeid的结果上加上被丢弃的信息,对于一些类型而言(如函数指针引用)得到的将不是一个正确的类型名称。
想要获得一个类型的完整名称,并且获得的名称必须要是一个正确的类型名称,应该怎样做呢?

一、如何检查C++中的类型

我们需要一个泛型类,用特化/偏特化机制静态检查出C++中的各种类型,并且不能忽略掉类型限定符(type-specifiers)和各种声明符(declarators)。
先来考虑一个最简单的类模板:

假如在它的基础上特化,需要写多少个版本呢?我们可以稍微实现下试试:

这还远远没有完。有同学可能会说了,我们不是有伟大的宏嘛,这些东西都像是一个模子刻出来的,弄一个宏批量生成下不就完了。

实际上当我们真的信心满满的动手去写这些宏的时候,才发现适配上的细微差别会让宏写得非常痛苦(比如&和*的差别,[]和[N]的差别,还有函数类型、函数指针、函数指针引用、函数指针数组、类成员指针、……)。当我们一一罗列出需要特化的细节时,不由得感叹C++类型系统的复杂和纠结。

但是上面的理由并不是这个思路的致命伤。
不可行的地方在于:我们可以写一个多维指针,或多维数组,类型是可以嵌套的。总不可能为每一个维度都特化一个模板吧。

不过正由于类型其实是嵌套的,我们可以用模板元编程的基本思路来搞定这个问题:

一个简单的继承,就让特化变得simple很多。因为当我们萃取出一个类型,比如T *,之后的T其实是携带上了除*之外所有其他类型信息的一个类型。那么把这个T再重复投入check中,就会继续萃取它的下一个类型特征。

可以先用指针、引用的萃取来看看效果:

输出结果(vc):

很漂亮,是不是?当然,在gcc里这样输出,void会变成v,所以gcc下面要这样写check模板:

二、保存和输出字符串

我们可以简单的这样修改check让它同时支持vc和gcc:

但是到目前为止,check的输出结果都是无法保存的。比较好的方式是可以像typeid(T).name()一样返回一个字符串。这就要求check能够把结果保存在一个std::string对象里。
当然了,我们可以直接给check一个“std::string& out”类型的构造函数,但是这样会把输出的状态管理、字符的打印逻辑等等都揉在一起。因此,比较好的设计方法是实现一个output类,负责输出和维护状态。我们到后面就会慢慢感觉到这样做的好处在哪里。
output类的实现可以是这样:

这个小巧的output类负责自动管理输出状态(是否增加空格)和输出的类型转换(使用std::ostringstream)。
上面的实现里有两个比较有意思的地方。
一是operator()的做法,采用了变参模板。这种做法让我们可以这样用output:

这种写法比cout的流操作符舒服多了。
二是operator()和compact的返回值。当然,这里可以直接使用void,但是这会造成一些限制。
比如说,我们想在使用operator()之后马上compact呢?若让函数返回自身对象的引用,就可以让output用起来非常顺手:

check的定义和CHECK_TYPE__宏只需要略作修改就可以使用output类:

为了让外部的使用依旧简洁,实现一个外敷函数模板是很自然的事情:

如果我们想实现表达式的类型输出,使用decltype包裹一下就行了。

不知道看到这里的朋友有没有注意到,check在gcc下的输出可能会出现问题。原因是abi::__cxa_demangle并不能保证永远返回一个有效的字符串。
我们来看看这个函数的返回值说明

“Returns: A pointer to the start of the NUL-terminated demangled name, or NULL if the demangling fails. The caller is responsible for deallocating this memory using free.”

所以说比较好的做法应该是在abi::__cxa_demangle返回空的时候,直接使用typeid(T).name()的结果。
一种健壮的写法可以像这样:

上面我们通过使用std::unique_ptr配合lambda的自定义deleter,实现了一个简单的Scope Guard机制,来保证当abi::__cxa_demangle返回的非NULL指针一定会被free掉。

三、输出有效的类型定义

3.1 一些准备工作

上面的特化解决了cv限定符、引用和指针,甚至对于未特化的数组、类成员指针等都有还不错的显示效果,不过却无法保证输出的类型名称一定是一个有效的类型定义。比如说:

原因是因为这个类型是一个指针,指向一个int[],所以会先匹配到指针的特化,因此*就被写到了最后面。
对于数组、函数等类型来说,若它们处在一个复合类型(compound types)中“子类型”的位置上,它们就需要用括号把它们的“父类型”给括起来。
因此我们还需要预先完成下面这些工作:

  • 1. 如何判断数组、函数等类型的特化处于check继承链中“被继承”(也就是某个类的基类)的位置上
  • 2. 圆括号()、方括号[],以及函数参数列表的输出逻辑
  • 上面的第1点,可以利用模板偏特化这种静态的判断来解决。比如说,给check添加一个默认的bool模板参数:

    这个小小的修改就可以让check在继承的时候把父-子信息传递下去。

    接下来先考虑圆括号的输出逻辑。我们可以构建一个bracket类,在编译期帮我们自动处理圆括号:

    在bracket里,不仅实现了圆括号的输出,其实还实现了一个编译期if的小功能。当不输出圆括号时,我们可以给bracket指定一个其它的输出内容。
    当然,不实现bracket,直接在check的类型特化里处理括号逻辑也可以,但是这样的话逻辑就被某个check特化绑死了。我们可以看到bracket的逻辑被剥离出来以后,后面所有需要输出圆括号的部分都可以直接复用这个功能。

    然后是[]的输出逻辑。考虑到对于[N]类型的数组,还需要把N的具体数值输出来,因此输出逻辑可以这样写:

    输出逻辑需要写在bound类的析构,而不是构造里。原因是对于一个数组类型,[N]总是写在最后面的。
    这里在输出的时候直接使用了运行时的if-else,而没有再用特化来处理。是因为当N是一个编译期数值时,对于现代的编译器来说“if (N == 0) ; else ;”语句会被优化掉,只生成确定逻辑的汇编码。

    最后,是函数参数的输出逻辑。函数参数列表需要使用变参模板适配,用编译期递归的元编程手法输出参数,最后在两头加上括号。
    我们可以先写出递归的结束条件:

    输出逻辑写在析构里的理由,和bound一致。结束条件是显然的:当参数包为空时,parameter将只输出一对括号。
    注意到模板的bool类型参数,让我们在使用的时候需要这样写:

    这是因为bool模板参数混在变参里,指定默认值也是没办法省略true的。
    稍微有点复杂的是参数列表的输出。一个简单的写法是这样:

    parameter在析构的时候,析构函数的scope就是bracket的影响范围,后面的其它显示内容,都应该被包括在bracket之内,因此bracket需要显式定义临时变量bk;
    check的调用理由很简单,因为我们需要显示出每个参数的具体类型;
    最下面是parameter的递归调用。在把out_丢进去之前,我们需要思考下具体的显示效果。是希望打印出(P1, P2, P3)呢,还是(P1 , P2 , P3)?
    在这里我们选择了逗号之前没有空格的第一个版本,因此给parameter传递的是out_.compact()。

    对parameter的代码来说,看起来不明显的就是bracket的作用域了,check和parameter的调用其实是被bracket包围住的。为了强调bracket的作用范围,同时规避掉莫名其妙的“(void)bk;”手法,我们可以使用lambda表达式来凸显逻辑:

    这样bracket的作用域一目了然,并且和check、parameter的定义方式保持一致,同时也更容易看出来out_.compact()的意图。

    3.2 数组(Arrays)的处理

    好了,有了上面的这些准备工作,写一个check的T[]特化是很简单的:

    这时对于不指定数组长度的[]类型,输出结果如下:

    当我们开始兴致勃勃的接着追加[N]的模板特化之前,需要先检查下cv的检查机制是否运作良好:

    尝试编译时,gcc会给我们吐出一堆类似这样的compile error:

    检查了出错信息后,我们会惊讶的发现对于const int[]类型,竟然可以同时匹配T const和T[]。
    这是因为按照C++标准ISO/IEC-14882:2011,3.9.3 CV-qualifiers,第5款:

    “Cv-qualifiers applied to an array type attach to the underlying element type, so the notation “cv T,” where T is an array type, refers to an array whose elements are so-qualified. Such array types can be said to be more (or less) cv-qualified than other types based on the cv-qualification of the underlying element types.”

    可能描述有点晦涩,不过没关系,在8.3.4 Arrays的第1款最下面还有一行批注如下:

    “[ Note: An “array of N cv-qualifier-seq T” has cv-qualified type; see 3.9.3. —end note ]”

    意思就是对于const int[]来说,const不仅属于数组里面的int元素所有,同时还会作用到数组本身上。
    所以说,我们不得不多做点工作,把cv限定符也特化进来:

    这样对于加了cv属性的数组而言,编译和显示才是正常的。
    接下来,考虑[N],我们需要稍微修改一下上面的CHECK_TYPE_ARRAY__宏,让它可以同时处理[]和[N]:

    这段代码里稍微用了点“preprocessor”式的技巧。gcc的__VA_ARGS__处理其实不那么人性化。虽然我们可以通过“,##__VA_ARGS__”,在变参为空时消除掉前面的逗号,但这个机制却只对第一层宏有效。当我们把__VA_ARGS__继续向下传递时,变参为空逗号也不会消失。
    因此,我们只有用上面这种略显抽搐的写法来干掉第二层宏里的逗号。这个处理技巧也同样适用于vc。

    然后,实现各种特化模板的时候到了:

    这里有个有意思的地方是:gcc里可以定义0长数组[0],也叫“柔性数组”。这玩意在gcc里不会适配到T[N]或T[]上,所以要单独考虑。

    现在,我们适配上了所有的引用、数组,以及普通指针:

    这里看起来有点不一样的是多维数组的输出结果,每个维度都被括号限定了结合范围。这种用括号明确标明数组每个维度的结合优先级的写法,虽然看起来不那么干脆,不过在C++中也是合法的。
    当然,如果觉得这样不好看,想搞定这个也很简单,稍微改一下CHECK_TYPE_ARRAY__就可以了:

    这里使用了std::is_array来判断下一层类型是否仍旧是数组,如果是的话,则不输出括号。

    3.3 函数(Functions)的处理

    有了前面准备好的parameter,实现一个函数的特化处理非常轻松:

    这里有一个小注意点:函数和数组一样,处于被继承的位置时需要加括号;parameter的构造时机应该在bracket的前面,这样可以保证它在bracket之后被析构,否则参数列表将被添加到错误位置上。
    我们可以打印一个变态一点的类型来验证下正确性:

    我们可以看到,函数指针已经被正确的处理掉了。这是因为一个函数指针会适配到指针上,之后去掉指针的类型将是一个正常的函数类型。
    这里我们没有考虑stdcall、fastcall等调用约定的处理,如有需要的话,读者可自行添加。

    3.4 类成员指针(Pointers to members)的处理

    类成员指针的处理非常简单:

    显示效果:

    3.5 类成员函数指针(Pointers to member functions)的处理

    其实我们不用做什么特别的处理,通过T C::*已经可以适配无cv限定符的普通类成员函数指针了。只是在vc下,提取出来的T却无法适配上T(P...)的特化。
    这是因为vc中通过T C::*提取出来的函数类型带上了一个隐藏的thiscall调用约定。在vc里,我们无法声明或定义一个thiscall的普通函数类型,于是T C::*的特化适配无法完美的达到我们想要的效果。
    所以,我们还是需要处理无cv限定的类成员函数指针。通过一个和上面T C::*的特化很像的特化模板,就可以处理掉一般的类成员函数指针:

    下面考虑带cv限定符的类成员函数指针。在开始书写后面的代码之前,我们需要先思考一下,cv限定符在类成员函数指针上的显示位置是哪里?答案当然是在函数的参数表后面。所以我们必须把cv限定符的输出时机放在T(P...)显示完毕之后。
    因此想要正确的输出cv限定符,我们必须调整T(P...)特化的调用时机:

    上面这段代码先定义了一个at_destruct,用来在析构时执行“输出cv限定符”的动作;同时把原本处在基类位置上的T(P...)特化放在了第二成员的位置上,这样就保证了它将会在cv_之后才被析构。
    这里要注意的是,at_destruct的构造在base_和out_之前,所以如果直接给cv_传递out_时不行的,这个时候out_还没有初始化呢。但是在这个时候,虽然base_同样尚未初始化,但base_.out_的引用却是有效的,因此我们可以给cv_传递一个base_.out_。
    另外,at_destruct虽然定义了带str参数的构造函数,CHECK_TYPE_MEM_FUNC__宏中却没有使用它。原因是若在宏中使用#__VA_ARGS__作为参数,那么当变参为空时,#__VA_ARGS__前面的逗号在vc中不会被自动忽略掉(gcc会忽略)。

    最后,来一起看看输出效果吧:

    尾声

    折腾C++的类型系统是一个很有意思的事情。当钻进去之后就会发现,一些原先比较晦涩的基本概念,在研究的过程中都清晰了不少。
    check_type的实用价值在于,可以利用它清晰的看见C++中一些隐藏的类型变化。比如完美转发时的引用折叠:

    在上面实现check_type的过程中,用到了不少泛型,甚至元编程的小技巧,充分运用了C++在预处理期、编译期和运行期(RAII)的处理能力。虽然这些代码仅是学习研究时的兴趣之作,实际项目中往往typeid的返回结果就足够了,但上面的不少技巧对一些现实中的项目开发也有一定的参考和学习价值。

    顺便说一下:上面的代码里使用了大量C++11的特征。若想在老C++中实现check_type,大部分的新特征也都可以找到替代的手法。只是适配函数类型时使用的变参模板,在C++98/03下实现起来实在抽搐。论代码的表现力和舒适度,C++11强过C++98/03太多了。


    完整代码及测试下载请点击:check_type


    评论中GreyMagic给了一种非常简单的方法, 使用一个外敷的类模板, 将待检测的类型放进去, 然后再使用typeid即可简单的得到完整的类型名称:

    这种做法是很简单的, 而且也可以得到正确的结果. 不过使用这种做法, 如上, 在vc下的输出是struct Type<int>, 而在gcc下则是Type<int>.
    实际上由于标准并没有严格规定typeid的name返回内容, 因此不同的编译器上很可能会得到不同的外敷名称. 为了在各种编译器下得到统一的输出, 我们不得不check各种需要测试的编译器, 并通过预编译宏来区分应该如何去掉外敷的struct Type<>, 或是Type<>.

    当然了, 为了避免这么一个编译器相关的"不优雅"的判断而使用各种技巧来得到我们上文所述的check_type, 自然也是很划不来的.
    一般的实际生产环境中, 简简单单的解决问题才是最好的, 往往连GreyMagic的简单方法都显得没必要, 直接typeid即可.
    而本文的实际目的, 是为了针对这个小问题来讨论如何精确的在编译期检查及定位C++的特定类型, 以及学习和研(zhe)究(teng)C++的类型系统.

    Published by orzz.org(). (https://orzz.org/cxx-get-the-name-of-the-given-type/)

    1. 我傻逼我自豪说道:

      这种写法:std::string func() { std::string retValue(“test”); return std::move(retValue);}有问题,有可能会crash。

    2. GreyMagic说道:

      你把一个可以很简单处理的问题无限复杂化了,其实只需要这样做即可:定义一个 templateT{}; 然后求 typeid(T<你需要求的类型>).name() 即可,最后再把结果客串的前面的 struct T< 和尾部的 > 删掉即可。比如你要求 int& , 用 typeid(T).name() 返回 “struct T” 你再把固定的头和尾去掉,中间剩下的就是 int&利用一个简单的模板将上面的方法稍微封装一下即可实现你那个效果。

      • GreyMagic说道:

        @GreyMagic 这鬼网页把我上面话里的一些字自动删除了,我再补充下看能不能显示出来:你把一个可以很简单处理的问题无限复杂化了,其实只需要这样做即可:定义一个 templateT《typename…》{}; 然后求 typeid(T<你需要求的类型>).name() 即可,最后再把结果客串的前面的 struct T< 和尾部的 > 删掉即可。比如你要求 int& , 用 typeid(T).name() 返回 “struct T《int&》” 你再把固定的头和尾去掉,中间剩下的就是 int&利用一个简单的模板将上面的方法稍微封装一下即可实现你那个效果。

    3. GreyMagic说道:

      尖括号显示不出来,第二次还是有被自动删除掉的字,继续替换成《》——你把一个可以很简单处理的问题无限复杂化了,其实只需要这样做即可:定义一个 templateT《typename…》{}; 然后求 typeid(T《你需要求的类型》).name() 即可,最后再把结果客串的前面的 struct T《和尾部的 》 删掉即可。比如你要求 int& , 用 typeid(T《int&》).name() 返回 “struct T《int&》” 你再把固定的头和尾去掉,中间剩下的就是 int&利用一个简单的模板将上面的方法稍微封装一下即可实现你那个效果。

    4. GreyMagic说道:

      楼主的人品实在太差了?我前几天发了个回复,居然被删除了。我前几天回复的意思是说要获得完整类型,不忽略顶级CV和引用的方法根本就不需要使用楼主这篇文章所讲的这一大堆复杂而麻烦的模板和宏,直接把要求的类型用 template《typename…》T 包装一下,求时用 typeid(T《const int》).name() 就行了,只不过返回的字符串会多出 struct T《》,直接用模板包装删除掉即可。一个本来10行代码都不要的简单至极的问题,被楼主硬是装作好有学问一样的卖弄,写出一大堆的模板和宏来。楼主写出一篇错误的文章,我回复来指正并且给出正确的解决方案,楼主确直接删除?这是何目的?高傲得即使是错了也不容许任何正确的意见吗?还是拉不下面子想继续把一个简单问题无限复杂化来显得自己好有学问?做人连这点气度都没有?当然,我这个回复极大可能又被楼主删除。请你反思下你的做人问题!

      • 木头云说道:

        @GreyMagic 这个。。。我其实没有删除你的回复,只是我一直没上自己的网站,所以你的回复一直没有人去批准,自然不会显示出来=.=
        看来我以后就算不发文章也得经常上来看一下,否则最终竟然会有这种误解【笑。这么晚才回复也不知道你会不会看到,没看到只能表示遗憾了。

        你评论里说的完全正确。对于一般实际应用,就如你所说,简简单单的解决就够了。
        本文的目的其实是为了针对这个小问题来讨论如何精确的在编译期检查及定位C++的特定类型,如果你只是为了解决问题,大可用简单的方法啦。文中并没有明确的说明目的是研究学习,并不是项目运用,对你可能造成了一些困扰,非常抱歉。

    发表回复

    您的电子邮箱地址不会被公开。 必填项已用*标注

    此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据