C++模板元编程实战:一个深度学习框架的初步实现

978-7-115-49170-1
作者: 李伟
译者:
编辑: 傅道坤
分类: C++

图书目录:

详情

本书将以一个深度学习框架的实现为例,讨论如何在一个相对较大的项目中深入应用元编程,为系统性能优化提供了更多的可能。本书分8章,前两章讨论了一些元编程与编译期计算的基本技术,后面六章则讨论了元编程在深度学习框架中的实际应用,涉及到富类型与标签体系、表达式模板、复杂元函数的编写等多个主题,详尽地展示了如何将面向对象与元编程相结合以构造复杂系统。

图书摘要

版权信息

书名:C++模板元编程实战:一个深度学习框架的初步实现

ISBN:978-7-115-49170-1

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

著    李 伟

责任编辑 傅道坤

人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

读者服务热线:(010)81055410

反盗版热线:(010)81055315


本书以一个深度学习框架的初步实现为例,讨论如何在一个相对较大的项目中深入应用元编程,为系统性能优化提供更多的可能。

本书分为8章,前两章讨论了一些元编程与编译期计算的基本技术,后面6章则讨论了元编程在深度学习框架中的实际应用,涉及富类型与标签体系、表达式模板、复杂元函数的编写等多个主题,详尽地展示了如何将面向对象与元编程相结合以构造复杂系统。

本书适合具有一定C++基础的读者阅读。对主流深度学习框架的内核有一定了解的读者,也可以参考本书,对比使用元编程与编译期计算所实现的深度学习框架与主流的(主要基于面向对象所构造的)深度学习框架之间的差异。


半夜突然接到陈冀康先生发来的微信消息,希望我能给《C++模板元编程实战》写一个推荐序。由于我本人对C++模板元编程有非常浓厚的兴趣,外加以前只是略知深度学习的皮毛内容,从来没有系统学习过,所以想借此也开阔一下自己的视野,于是欣然接受了陈先生的邀请,然后就有了下面的文字。

学习编程是一个长期的过程,如果要快速提高自己的话,就需要走出自己的“舒适区”。只有不停地给自己找很多颇具难度,但又不至于难到写不出来的任务,然后利用时间逐个实现这些任务,自己的编程技能才能得到最快速的提高。本书中造的这个“深度学习框架”的轮子,就很适合读者自行尝试开发实现。尽管本书会提供源码下载地址,但是建议读者先别看源码,而是自己跟着书做一遍,把MetaNN实现出来。在成功或者放弃之后再去看作者的代码,相信会有更深刻的领悟。

在阅读本书的过程中,我把大部分时间都花在了前两章。这两章介绍的是C++模板元编程的技巧,作者写得非常出彩。第1章开篇就点出了读者应该如何去了理解模板元编程。想当初我在学习C++ Template Metaprogramming时就走过不少弯路——由于该书的讲解不够通俗易懂,外加当时经验欠缺,最后竟然是通过学习Haskell语言才彻底把C++模板元编程弄明白。如果当初看的是本书的第1章,相信会节省下很多时间。

从第2章开始,作者就已经是布置“大作业”了。而从第4章开始,则开始正式介绍使用C++模板元编程的技巧来实现MetaNN——也就是一个简单的深度学习框架——的过程。如果读者没有很好地理解前两章内容,则从第4章开始应该会觉得非常吃力。当然,这也不是坏事,起码这可以说明两点:自己技术水平确实有不足之处;本书中确实有真材实料,可以让自己学到很多干货。

需要多说一句的是,本书的技术难度相当大,读者最好具备一定的C++模板知识,而且也需了解C++ 11和C++ 14中的一些基本内容,以免在阅读本书时不停地查询相关资料,打断思路。退一步讲,即使各位读者已经学习过模板元编程,在阅读本书时也需要勤加思考,并踏实练习实践书中内容,从而切实提升编程技能。

最后想说的是,本书的代码还是写得相当不错,可能是作者在长期的编码工作中已经把C++的很多最佳实践都潜移默化成自己的本能了,所以没有花费很多笔墨来完整地介绍代码中各个方面的细节。大家在阅读本书的过程中,可以尝试思考一下,为什么作者要这样编写(而不是采用其他方式),以及书中的代码跟其他C++图书介绍的最佳实践有什么异同之处。这也是一种学习的过程。

学而不思则罔,思而不学则殆。预祝各位读者阅读愉快,获益匪浅。

陈梓瀚(vczh) 

2018年9月  


模板元编程(Template Metaprogramming)从来都是C++程序设计中被多数人视为畏途的领域。类型设计本就是具备丰富经验的工程师才能操刀的活计,C++语言之所以强大,一个重要的原因就是它具有强类型(strongly typed)特性。有了这样的特性以后,开发人员就能在编译期实施种类繁多的静态检查,从而把很多潜在的软件缺陷尽早地暴露,避免它们到链接期甚至运行期才兴风作浪。但反过来,类型设计中的任何缺陷都会把原罪带给该类型的所有对象,甚至该类型各级派生类型的所有对象。而且,软件一旦上线,一旦分发到用户手里,再要修改,谈何容易!所以,类型设计,尤其是大型软件中的基础类型设计,对工程师的要求已经很有挑战。而模板元编程,则在此基础之上更进一阶。如果说负责类型设计的工程师,他们的产品是对象和对象的运算;那么,负责模板元编程的工程师,他们的产品就是类型和类型的运算,即前者的上游。对于绝大多数的C++开发人员来说,能够自如地使用对象已非易事。而能够不仅设计类型,还要根据需要自如地产生和剪裁所需要的类型,而且还得把这种能力作为一种服务提供出来,让其他工程师使用,这里面的功夫之深可想而知。所以,虽然关于C++语言的图书汗牛充栋,但是讲解模板元编程的却是寥若晨星。即便是以此为主题的图书,也基本上是就事论事,这不免让读者产生疑问:模板元编程确实厉害,但是这和我的日常工作有何关系?这难道不应该是C++标准委员会中那些头发稀少的专家们的玩具吗?

看到这本《C++模板元编程实战》书名的最后二字,我眼前一亮。模板元编程、深度学习框架,还是实战,这几个关键词,已经有了致命的吸引力。

作为一名C++资深爱好者,我可以清楚地感觉本书的质量和份量。从第一行代码开始,本书就是采用现代C++标准。可以说,作者是直接从现代C++开始学习和掌握C++的新生代工程师,身上绝少来自“C++远古时代”的陈腐气息,至今还在C++社区中纠缠不清的很多语法问题,在本书中根本就不是问题——C++就是现代C++,当然应该使用constexpr,当然应该使用auto,当然应该使用别名声明。

本书以明快、详尽的风格,集中演示了在现代C++中进行模板元编程的必要技巧。现代C++为工程师提供了很多必要的工具,使得模板元编程能够以更直接和清晰的方式来表达生成类型的算法,本书的前几章给出了如何高效利用这些工具的指南。但是本书远未停留在那里,因为全书的重点在于实战二字。

作者通过构造一个功能全面、强大的MetaNN深度学习框架,展示了模板元编程是如何从类型层面进行深度学习涉及的具体数据和操作的设计,这种设计是分层递进的:先引入基本的可定制的数据结构模板和策略,再设计以这些数据模板为基础的算法模板,尔后在数据和算法之上,构建深度学习的业务逻辑。这一部分内容虽然篇幅较大,但是读起来不累,因为讲解的每个知识点都是已经系统讲解过的语言要点的呼应和深化。非常可贵的是,这些内容切切实实地给到读者这样的信息:模板元编程在实战中是确实有用的,而且很多时候是非用不可的。本书每一章后面还跟着若干练习,启发读者的进一步思考。有的习题会让读者从多个侧面考虑如何进一步利用模板元编程的高阶用法,从另外的维度拓展深度学习业务,还有些习题会提示读者某些语言特性可以应用的其他行业领域。

在现代C++和深度学习都炙手可热的今天,有这样一本由真正的一线专家撰写的精良作品,对读者来说是一种幸福。本书对于读者的预备知识要求并不多,因为书中介绍得足够详尽。只要有一颗愿意学习的心,就能够很好地同时掌握现代C++和深度学习这两门能够为你带来巨大技术优势的学问。

谨向广大读者隆重推荐这本《C++模板元编程实战》!

高博,《C++覆辙录》、《Effective Modern C++中文版》译者

2018年10月,新加坡,Tanjong Pagar


C++模板元编程的实质是编译期计算。这种编程范式的发现是一个意外,其编程风格对于普通C++程序员而言非常陌生。习惯了运行期编程思维的程序员很难理解和适应这种编程范式——模板元编程代码就像使用C++之外的某种语言写的天书。

C++模板元编程与模板编程的关系,有点像深度学习与机器学习之间的关系,前者都是后者的一个子领域。不同的是,C++模板元编程要比深度学习偏门多了,尤其在模板元编程实战领域,对于国内C++社群来说,可以说是人迹罕至之地。

迄今为止,有三本图书比较认真地涉及了模板元编程领域,分别是《C++模板元编程》、《C++ Templates中文版》和《产生式编程》,它们都是国外C++技术专家写作的。因此,李伟先生撰写的这本《C++模板元编程实战》是国内第一部以模板元编程为主题的作品,放眼整个C++社群这都是屈指可数的。

本书涉及的两个主题都非常吸引人。对我个人而言,模板元编程是长久以来的兴趣点,深度学习则属于负责的专业课程范围。我很荣幸成为本书最早的读者之一。拜读大作,受益匪浅。

作者首先使用C++ 11之后的新语言特性重新实现了一些基本的元编程技术,然后介绍了以模板元编程技术为主实现的可扩展深度学习框架MetaNN。本书整体结构简单合理,论述深入清晰,这不仅与作者的教育背景和研发经历有关,更能看出他对模板编程和模板元编程技术超乎常人的热情,以及强劲的逻辑思维能力。

本书必然会成为C++狂热爱好者的案头读物。它还可以让其他C++程序员明白,除了熟悉的C++编程,还有C++模板元编程平行世界的存在。那个世界的实战更精彩。

祝阅读愉快!

荣耀博士   

2018年9月,南京大行宫


李伟,2011年毕业于清华大学,曾在百度自然语言处理部负责深度学习机器翻译系统线上预测部分的开发与维护,目前就职于微软亚洲工程院;主要研究方向为C++,拥有10余年相关开发经验,对C++模板元编程与编译期计算有着浓厚的兴趣;喜欢尝试学习与研究新的技术,喜欢编程与阅读。


首先,感谢家人对我的“放纵”与支持。我日常有读书、编程的习惯,但家人并不知道我在写书,对我的研究领域也知之甚少,但他们依然给予了我最大的支持。作为两个孩子的父亲,我并没有很多的时间来陪他们,是家人分担了我应尽的义务,让我有时间可以安心做自己想做的事,在这里,我要衷心地感谢你们,谢谢!

其次,感谢之前在百度自然语言处理部的同事。在我想写一本书但又担心写不好而踟蹰时,他们给了我鼓励与支持。特别感谢张军,他仔细阅读了本书的很多章节,提出了中肯的修改意见,并贡献了本书第3章的一些内容。我曾经希望在本书作者一栏中添上他的名字,但被推辞了,因此只好在这里提出感谢。

再次,感谢陈梓瀚、刘未鹏、高博、荣耀四位老师。作为C++领域的专家,四位老师抽出宝贵的时间审阅本书,提出了中肯的意见与建议并为本书推荐作序,荣耀老师更是将珍藏的书籍相赠予我。这不仅是对我已有工作的肯定,还坚定了我继续从事相关研究的信心。

最后,感谢人民邮电出版社的诸位编辑老师,特别是傅道坤老师。傅老师在整本书的编辑过程中付出了大量的心血,使得整个出版过程可以顺利进行。可以说,没有傅道坤老师与诸位编辑的辛勤劳动,本书是不可能面世的。谢谢你们的大力支持!


虽然本书的名字中包含了“C++”与“深度学习”这两个词,但请注意,如果读者对C++一无所知,想通过本书来学习,那么本书并非合适之选,如果读者想通过本书来学习深度学习的理论,那么本书同样并非合适之选。本书是写给有一定C++编程经验的程序员的,我们将以一个深度学习框架的实现作为示例,来讨论如何在一个相对较大的项目中深入整合元编程技术,通过编译期计算为运行期优化提供更多的可能。

C++是一门被广泛使用的编程语言。在众多的C++开发者中,大多数人用面向对象的方式编写代码:我们日常接触的C++项目基本上都是用这种风格组织的;几乎每一本C++教程都会用绝大部分篇幅来讨论面向对象;每位拥有数年C++开发经验的程序员都会对面向对象有自己的见解。面向对象在C++的开发圈子里成了一种主流,以至于在有些人看来,C++与很多编程语言类似,只是一种面向对象的方言而已。

但事实上,C++所支持的不仅是面向对象这一种编程风格。它还支持另一种编程风格:泛型,并由此衍生出一套编程方法,即编译期计算与元编程。

可能某些读者没有听说过泛型与元编程,但几乎每个开发者都会与它们打交道,只是可能没有意识到而已:我们日常所使用的标准模板库(STL)就是一个典型的泛型风格类库,其中也包含了一些元编程的代码,比如基于迭代器的标签来选择算法等。事实上,我们是那么习惯而自然地使用这个库,以至于对于很多人来说,它已经成了C++中不可缺少的一部分。但另一方面,很多程序员却几乎从不会采用类似STL的风格来开发自己的库:在进行程序设计时,我们首先想到的往往是“引入基类,然后从中派生……”——这是我们所熟悉的方式,也是最容易想到的方式,但事实上,这真的是最合适的方式吗?

是否合适,可谓仁者见仁,智者见智了。作者认为,要回答这个问题,首先要思考一下,为什么选择使用C++开发程序。

一些人选择使用C++来开发程序,是因为C++是一门流行的语言,拥有相对完善的标准支持。是的,这是C++的优势,但它上手不易,难以精通也是事实。TIBOE编程语言排行榜显示,Java比C++更具人气。这并不难理解:相比C++来说,Java更易学习、使用,开发者几乎不用担心野指针等问题,同时还能跨平台……很多C++能解决的问题,使用Java或者其他编程语言同样能搞定。相比之下,C++的学习与使用成本就要高很多,那么我们为什么还要使用C++开发程序呢?

一个主要的原因是:C++比Java等语言编写出的代码更加高效,同时语法又比汇编等低级语言易学、易维护。C++的程序并不支持二进制级的移植,开发者要手工处理指针,确保没有内存泄漏。而这一切的付出所换来的就是媲美于汇编语言的执行速度。作者认为,如果不是为了快速、高效地运行程序,我们完全没有必要选择C++。

既然如此,在使用C++开发程序时,我们就应当一方面最大限度地发挥其速度优势,确保其能够尽量快地运行;另一方面尽可能地确保语法简洁,所开发的代模块能被很容易地使用。C++开发的程序在运行速度上具有天然的优势。但即使如此,要想程序运行得更快,还是有一些工作要做的。比如,在频繁使用std::vector的push_back操作前最好使用其reserve预留出相应的内存;调用函数时,使用常量引用的方式来传递结构较复杂的参数等等。这些技巧是大家耳熟能详的。但除此之外,还有一项并不为人们广泛使用的技巧——编译期计算。

如前文所述,C++程序追求的是高效率与易用性。那么这与编译期计算有什么关系呢?作者认为,与单纯的运行期计算相比,适当地使用编译期计算,可以更好地利用运算本身的信息,提升系统性能。

这样说有些抽象,让我们通过例子来看一下何为“运算本身的信息”,以及如何使用其提升系统的性能。

现在假定我们的程序需要对“矩阵”这个概念进行建模。矩阵可以被视为一个二维数组,每个元素是一个数值。可以指定其行号与列号获取相应元素的值。

在一个相对复杂的系统中,可能涉及不同类型的矩阵。比如,在某些情况下我们可能需要引入一个数据类型来表示“元素全为零”的矩阵;另一种情况是,我们可能需要引入一个额外的数据类型来表示单位矩阵,即除了主对角线上的元素为1,其余元素均为0的矩阵。

如果采用面向对象的方式,我们可以很容易地想到引入一个基类来表示抽象的矩阵类型,在此基础上派生出若干具体的矩阵类来。比如:

1    class AbstractMatrix
2    {
3    public:
4        virtual int Value(int row, int column) = 0;
5    };
6    class Matrix     : public AbstractMatrix;
7    class ZeroMatrix : public AbstractMatrix;
8    class UnitMatrix : public AbstractMatrix;

AbstractMatrix定义了表示矩阵的基类,其中的Value接口在传入行号与列号时,返回对应的元素(这里假定它为int型)。之后,我们引入了若干个派生类,使用Matrix表示一般意义的矩阵;使用ZeroMatrix表示元素全为零的矩阵;而UnitMatrix则表示单位矩阵。

所有派生自AbstractMatrix的具体矩阵必须实现Value接口。比如,对于ZeroMatrix来说,其Value接口的功能就是返回数值0。而对于UnitMatrix来说,如果调用Value接口时传入的行号与列号相同,则返回1;否则返回0。

现在考虑一下,如果我们要实现一个函数,输入两个矩阵并计算二者之和,该怎么写。基于前文所定义的类,矩阵相加函数可以使用如下声明:

1    Matrix Add(const AbstractMatrix * mat1, const AbstractMatrix * mat2);

每个矩阵都实现了AbstractMatrix所定义的接口,因此我们可以在这个函数中分别遍历两个矩阵中的元素,将对应元素求和并保存在结果Matrix矩阵中返回。

显然,这是一种相对通用的实现,能解决大部分问题,但对于一些特殊的情况,则性能较差。比如可能存在如下的性能优化空间:

为了在这类特殊情况时提升计算速度,我们可以在Add中引入动态类型转换,来尝试获取参数所对应的实际数据类型:

1    Matrix Add(const AbstractMatrix * mat1, const AbstractMatrix * mat2)
2    {
3        if (auto ptr = dynamic_cast <const ZeroMatrix *>(mat1))
4            // 引入相应的处理
5        else if (...)
6            // 其他情况
7    }

这种设计有两个问题:首先,大量的if会使得函数变得复杂,难以维护;其次,调用Add时需要对if的结果进行判断——这是一个运行期的行为,涉及运行期的计算,引入过多的判断,甚至可能使得函数的运行速度变慢。

此类问题有一个很经典的解决方案:函数重载。比如,我们可以引入如下若干个函数:

1    Matrix Add(const AbstractMatrix * mat1, const AbstractMatrix * mat2);
2    Matrix Add(const ZeroMatrix * mat1, const AbstractMatrix * mat2);
3    ...
4    ZeroMatrix m1;
5    Matrix m2;
6    Add(&m1, &m2);  // 调用第二个优化算法

其中的第一个版本对应最一般的情况,而其他的版本则针对一些特殊的情形提供相应的优化。

这种方式很常见,以至于我们可能意识不到这已经是在使用编译期计算了。是的,这是一种典型的编译期计算,编译器需要根据用户的调用选择适当的函数来匹配,而这个选择的过程本身就是一种计算过程。在这个编译期计算的过程中,我们利用了“参与加法计算的矩阵类型是ZeroMatrix”这样的信息,提升了系统性能。

函数重载只是一种很简单的编译期计算——它虽然能够解决一些问题,但使用场景还是相对狭窄的。本书所要讨论的则是更加复杂的编译期计算方法:我们将使用模板来构造若干组件,其中显式包含了需要编译器处理的逻辑。编译器使用这些模板所推导出来的值(或类型)来优化系统。这种用于编译期计算的模板被称为“元函数”,相应的计算方法也被称为“元编程”或“C++模板元编程”。

元编程并非一个新概念。事实上,早在1994年,Erwin Unruh就展示了一个程序,可以利用编译期计算来输出质数。但由于种种原因,对C++模板元编程的研究一直处于不温不火的状态。虽然也涌现了很多元编程的库(如Boost::MPL、Boost::Hana等),但应用这些库来解决实际问题的案例还是相对较少。即使偶尔出现,这些元编程的库与技术也往往处于一种辅助的地位,辅助面向对象的方法来构造程序。

随着C++标准的发展,我们欣喜地发现,其中引入了大量的语法与工具,使得元编程越来越容易。这也使得我们使用元编程构造相对复杂的程序成为了可能。

本书将构造一个相对复杂的系统:深度学习框架。元编程在这个系统中不再是辅助地位,而是整个系统的主角。在前文中,我们提到了元编程与编译期计算的优势之一就是更好地利用运算本身的信息,提升系统性能。这里概述一下如何在大型系统中实现这一点。

一个大型系统中往往要包含若干个概念,每一个概念可能对应多种实现方式,这些实现方式各有优势。基于元编程,我们可以将同一概念所对应的不同实现方式组织成松散的结构。进一步,可以通过标签等方式对概念分类,从而便于维护已有的概念,引入新的概念,或者引入已有概念的新实现。

概念可以进行组合。典型的例子是两个矩阵相加可以构成新的矩阵。我们将讨论元编程中的一项非常有用的技术:模板表达式。它用于组合已有的类型,形成新的类型。新的类型中保留了原有类型中的全部信息,可以在编译期利用这些信息进行优化。

元编程的计算是在编译期进行的。深入使用元编程技术,一个随之而来的问题就是编译期与运行期的交互。通常来说,为了在高效性与可维护性之间取得一个平衡,我们必须考虑哪些计算是可以在编译期完成的,哪些则最好放在运行期,二者如何过渡。在深度学习框架实现的过程中,我们会看到大量编译期与运行期交互的例子。

编译期计算并非目的,而是手段。我们希望通过编译期计算来改善运行期性能。我们会在本书的最后一章看到,如何基于已有的编译期计算结果,来优化深度学习框架的性能。

本书将使用编译期计算与元编程构建一个深度学习框架。深度学习是当前研究的一个热点领域,以人工神经网络为核心,包含了大量的技术与学术成果。我们在这里主要是以之讨论元编程与编译期计算的方法,并不考虑做一个大而全的工具包。但我们所构造的深度学习框架是可扩展的,通过进一步开发,完全可以实现主流深度学习框架所能实现的大部分功能。

即使对讨论的范围进行了上述限定,但本书毕竟同时涉及了元编程与深度学习,如果没有一定的背景知识很难完成讨论。因此,我们假定读者对高等数学、线性代数与C++都有一定的了解,具体来说包括如下内容。

使用元编程可以写出灵活高效的代码,但这并非没有代价。本书将集中讨论元编程,但在此之前,有必要明确一下使用元编程的应用成本,从而对这项技术有更加全面的认识。

元编程的应用成本主要由两个方面构成:研发成本与使用成本。

研发成本

从本质上来说,元编程的研发成本并非来自于这项技术本身,而是来自于程序员编写代码的习惯转换所产生的成本。虽然本书讨论的是C++中的一项编程技术,但它与面向对象的C++开发技术有很大区别。从某种意义上来说,元编程更像一门可以与面向对象的C++代码无缝衔接的新语言。想掌握并用好它,还是要花一些力气的。

对熟悉面向对象的C++开发者来说,学习并掌握这种新的编程方法,主要的难点在于要建立函数式编程的思维模式。编译期涉及的元编程方法是函数式的——构造的中间结果无法改变——由此产生的影响可能会比想像中要大一些。本书会通过大量的实例来帮助读者逐步建立这样的思维模式,相信读完本书,读者就会对其有相对深入的认识了。

使用元编程的另一个问题是调试困难。原因也很简单:大部分C++程序员都在使用面向对象的方式编程,因此大部分编译器都会针对这一点进行优化。相应的,编译器在输出元编程的调试信息方面表现就会差很多。一些情况下,编译器输出的元程序错误信息更像是一篇短文,难以通过其一目了然地定位到问题所在。这个问题没有什么特别好的解决方案,多动手做实验,多看编译器的输出信息,就能慢慢找到感觉。

还有一个问题。相对使用面向对象的C++开发者来说,使用元编程的开发者毕竟还是小众的。而这就造成了在多人协作开发时,使用元编程就比较困难——别人看不懂你的代码,其学习与维护成本会比较高。作者在工作中就经常遇到这样的问题——事实上也正是这个问题间接导致了本书的面世。如果你希望说服你的协作者使用元编程开发C++程序,可以向他推荐本书——这也算是一个小广告。

使用成本

元编程的研发成本更多的是一种主观成本,可以通过提升自身的编程水平来降低;但与之相对的是,元编程的使用成本则更多的是一种客观成本,处理起来也棘手一些:

通常情况下,如果我们希望开发一个程序包并交付他人使用,那么程序包中往往会包含头文件与编译好的静态或动态库,程序的主体逻辑是位于静态库或动态库中的。这样有两个好处:首先,程序包的提供者不必担心位于静态库或动态库中的主体逻辑会遭到泄露——使用者无法看到源码,要想获得程序包中的主体逻辑,就需要通过逆向工程等手段实现,成本相对较高;其次,程序包的使用者可以较快地进行自身程序的编译并链接——因为程序包中的静态、动态库都是已经编译好的了,在使用时只需要链接即可,无需再次编译。

但如果我们开发了一个元编程库并交付其他人使用,那么通常来说将无法获得上述两个好处:元编程的逻辑往往是在模板中实现的,就目前来说,主流的编译器所支持的编译模式要将模板放在头文件之中。这就造成了元程序包的主体逻辑源代码是在头文件中,会随着程序包的发布提供给使用者,使用者了解并仿制相应逻辑的成本会大大降低;其次,调用元程序库的程序在每次编译过程中,都需要编译头文件中的相应逻辑,这就会增大编译的时间[1]

如果我们无法承担由于元编程所引入的使用成本,那么就要考虑一些折衷的解决方案了。一种典型的方式是对程序包的逻辑进行拆分,将编译耗时长、不希望泄漏的逻辑先行编译,形成静态或动态库;将编译时较短,可以展示源代码的部分使用元程序的方式编写,以头文件的形式提供,从而确保依旧可以利用元代码的优势。至于如何划分,则要视项目的具体情况而定了。

本书包含两部分。在第一部分(第1~2章)中将讨论元编程中常见的基础技术;这些技术将被用在第二部分(第3~8章)中,构造深度学习框架。

本书是元编程的实战型书籍,很多理论也是通过示例的方式进行阐述的,不可避免地,书中会涉及大量代码。作者尽量避免将一本书搞成代码的堆砌(这是在浪费读者的时间与金钱),做到只在书中引用需要讨论的核心代码与逻辑,完整的代码则在随书源码中给出。

读者可在异步社区中下载本书的源码。源码包含两个目录:MetaNN与Test。前者包含了深度学习框架中的全部逻辑,而后者则是一个测试程序,用来验证框架逻辑的正确性。本书所讨论的内容可以在MetaNN目录中找到对应的源码。阅读本书时,手边有一份可以参考的源码以便随时查阅,这一点是很重要的。本书用了较多的篇幅来阐述相关的设计思想,只是罗列了一些核心代码。因此,作者强烈建议对照源代码来阅读本书,这样能对书中讨论的内容有更加深入的理解。

对于MetaNN中实现的大部分技术点,Test中都包含了相应的测试用例。因此,读者可以在了解了某个技术点的实现细节之后,通过阅读测试用例,进一步体会相应技术的使用方式。

MetaNN目录中的内容全部是头文件,Test目录则包含了一些cpp文件,可以编译成可执行程序。但本书所讨论的是C++中使用相对较少的元编程,同时使用了C++ 17新标准中的一些技术,因此并非所有的主流编译器都能编译Test目录中的文件。以下罗列出了作者尝试编译并成功的实验环境。

硬件与操作系统

为了编译Test中的程序,你需要一台64位机,建议至少包含4GB的内存。同时,其上运行的是一个64位的操作系统。作者在Windows与Ubuntu上完成了编译。

使用Ubuntu与GCC编译测试程序

GCC是Linux上常见的编译组件。作者的第一个编译环境就是基于Ubuntu 17.10与GCC搭建的,我们使用GCC中的g++编译器来编译测试程序。

使用Ubuntu与Clang编译测试程序

除了GCC,Linux中另一个常见的编译器就是Clang了。基于Ubuntu与Clang也可以编译源码中的测试程序。

很多Linux的发行版都自带Clang编译器。但这些编译器的版本相对较低,可能不支持C++ 17标准。这就需要我们安装一个较高版本的Clang编译器。作者安装的是Clang 6.0。这里不会讨论如何安装编译器,读者可以在网络上搜索相应的安装方法。

使用Windows与MinGW-w64编译测试程序

很多读者都使用Windows操作系统。这里简单介绍一下如何在Windows中编译书中源码。

在Windows中,最常用的编译器就是Microsoft Visual Studio了。但作者尝试使用Microsoft Visual Studio中的VC++编译器来编译测试程序时,系统提示“compiler is out of heap space”——这表示编译测试代码所需要的内存超过了VC++编译器的限制。因此,这里介绍使用MinGW-w64作为编译器,在Windows中编译测试程序。

作者会避免在讨论技术细节时罗列大量非核心的代码。同时,为了便于讨论,通常来说代码段的每一行前面会包含一个行号:在后续对该代码段进行分析时,有时会使用行号来引用具体的行,说明该行所实现的功能。

行号只是为了便于后续分析代码,并不表明该代码段在源代码文件中的位置。一种典型的情况是:当要分析的核心代码段比较长时,代码段的展示与分析是交替进行的。此时,每一段展示的代码段将均从行号1开始计数,即使当前展示的代码段与上一个展示的代码段存在先后关系,也是如此。如果读者希望阅读完整的代码,明确代码段的先后关系,可以阅读随书源码。

除了第3章外,每一章都在最后给出了若干练习,便于读者巩固在本章中学到的知识。这些题目并不简单,有些也没有标准答案。因此,如果读者在练习的过程中遇到了困难,请不要灰心,可以选择继续阅读后续的章节,在熟练掌握了本书讲述的一些技巧后,回顾之前的习题,或许就迎刃而解了。再次声明,一些问题本就是开放性的,没有标准答案,即便做不出来,或者你的答案与别人的不同,也不要灰心。

由于作者深知自身水平有限,而元编程又是一个复杂而颇具挑战的领域,因此本书难免会有不足之处,敬请广大读者指正!作者的E-mail地址为liwei.cpp@gmail.com。

[1] 需要说明的是,还是存在一些方式避免将模板类的实现代码放在头文件中的,但这些方式的局限性都比较大,因此本书不做讨论。

[2] 读者可以在网络上搜索相应的安装方法。


本书由异步社区出品,社区(https://www.epubit.com/)为您提供相关资源和后续服务。

本书提供如下资源:

本书源代码。

要获得以上配套资源,请在异步社区本书页面中点击 ,跳转到下载界面,按提示进行操作即可。注意:为保证购书读者的权益,该操作会给出相关提示,要求输入提取码进行验证。

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。

当您发现错误时,请登录异步社区,按书名搜索,进入本书页面,点击“提交勘误”,输入勘误信息,点击“提交”按钮即可。本书的作者和编辑会对您提交的勘误进行审核,确认并接受后,您将获赠异步社区的100积分。积分可用于在异步社区兑换优惠券、样书或奖品。

我们的联系邮箱是contact@epubit.com.cn。

如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果您有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们;有意出版图书的作者也可以到异步社区在线提交投稿(直接访问www.epubit.com/selfpublish/submission即可)。

如果您是学校、培训机构或企业,想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。

如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接发邮件给我们。您的这一举动是对作者权益的保护,也是我们持续为您提供有价值的内容的动力之源。

“异步社区”是人民邮电出版社旗下IT专业图书社区,致力于出版精品IT技术图书和相关学习产品,为作译者提供优质出版服务。异步社区创办于2015年8月,提供大量精品IT技术图书和电子书,以及高品质技术文章和视频课程。更多详情请访问异步社区官网https://www.epubit.com。

“异步图书”是由异步社区编辑团队策划出版的精品IT专业图书的品牌,依托于人民邮电出版社近30年的计算机图书出版积累和专业编辑团队,相关图书在封面上印有异步图书的LOGO。异步图书的出版领域包括软件开发、大数据、AI、测试、前端、网络技术等。

异步社区

微信服务号



本章将讨论元编程与编译期计算所涉及的基本方法。我们首先介绍元函数,通过简单的示例介绍编译期与运行期所使用“函数”的异同。其次,在此基础上进一步讨论基本的顺序、分支、循环代码的书写方式。最后介绍一种经典的技巧——奇特的递归模板式。

上述内容可以视为基本的元编程技术。而本书后续章节也可以视为这些技术的应用。掌握好本章所讨论的技术,是熟练使用C++模板元编程与编译期计算的前提。

C++元编程是一种典型的函数式编程,函数在整个编程体系中处于核心的地位。这里的函数与一般C++程序中定义与使用的函数有所区别,更接近数学意义上的函数——是无副作用的映射或变换:在输入相同的前提下,多次调用同一个函数,得到的结果也是相同的。

如果函数存在副作用,那么通常是由于存在某些维护了系统状态的变量而导致的。每次函数调用时,即使输入相同,但系统状态的差异会导致函数输出结果不同:这样的函数被称为具有副作用的函数。元函数会在编译期被调用与执行。在编译阶段,编译器只能构造常量作为其中间结果,无法构造并维护可以记录系统状态并随之改变的量,因此编译期可以使用的函数(即元函数)只能是无副作用的函数。

以下代码定义了一个函数,满足无副作用的限制,可以作为元函数使用。

1    constexpr int fun(int a) { return a + 1; }

其中的constexpr为C++ 11中的关键字,表明这个函数可以在编译期被调用,是一个元函数。如果去掉了这个关键字,那么函数fun将只能用于运行期,虽然它具有无副作用的性质,但也无法在编译期被调用。

作为一个反例,考虑如下的程序:

1    static int call_count = 3;
2    constexpr int fun2(int a)
3    {
4        return a + (call_count++);
5    }

这个程序片断无法通过编译——它是错误的。原因是函数内部的逻辑丧失了“无副作用”的性质——相同输入会产生不同的输出;而关键字constexpr则试图保持函数的“无副作用”特性,这就导致了冲突。将其进行编译会产生相应的编译错误。如果将函数中声明的constexpr关键字去掉,那么程序是可以通过编译的,但fun2无法在编译期被调用,因为它不再是一个元函数了。

希望上面的例子能让读者对元函数有一个基本的印象。在C++中,我们使用关键字constexpr来表示数值元函数,这是C++中涉及的一种元函数,但远非全部。事实上,C++中用得更多的是类型元函数——即以类型作为输入和(或)输出的元函数。

从数学角度来看,函数通常可以被写为如下的形式:

其中的3个符号分别表示了输入(x)、输出(y)与映射(f[1]。通常来说,函数的输入与输出均是数值。但我们大可不必局限于此:比如在概率论中就存在从事件到概率值的函数映射,相应的输入是某个事件描述,并不一定要表示为数值。

回到元编程的讨论中,元编程的核心是元函数,元函数输入、输出的形式也可以有很多种,数值是其中的一种,由此衍生出来的就是上一节所提到的数值元函数;也可以将C++中的数据类型作为函数的输入与输出。考虑如下情形:我们希望将某个整数类型映射为相应的无符号类型。比如,输入类型int时,映射结果为unsigned int;而输入为unsigned long时,我们希望映射的结果与输入相同。这种映射也可以被视作函数,只不过函数的输入是int、unsigned long等类型,输出是另外的一些类型而已。

可以使用如下代码来实现上述元函数:

1    template <typename T>
2    struct Fun_ { using type = T; };
3    
4    template <>
5    struct Fun_<int> { using type = unsigned int; };
6    
7    template <>
8    struct Fun_<long> { using type = unsigned long; };
9    
10    Fun_<int>::type h = 3;

读者可能会问:函数定义在哪儿?最初接触元函数的读者往往会有这样的疑问。事实上,上述片断的1~8行已经定义了一个函数Fun_第10行则使用了这个Fun_<int>::type函数返回unsigned int,所以第10行相当于定义了一个无符号整型的变量h并赋予值3。

Fun_与C++一般意义上的函数看起来完全不同,但根据前文对函数的定义,不难发现,Fun_具备了一个元函数所需要的全部性质:

在C++ 11发布之前,已经有一些讨论C++元函数的著作了。在《C++模板元编程》一书[2]中,将上述程序段中的1~6行所声明的Fun_视为元函数:认为函数输入是X时,输出为Fun_<X>::type。同时,该书规定了所讨论的元函数的输入与输出均是类型。将一个包含了type声明的类模板称为元函数,这一点并无不妥之处:它完全满足元函数无副作用的要求。但作者认为,这种定义还是过于狭隘了。当然像这样引入限制,相当于在某种程度上统一了接口,这将带来一些程序设计上的便利性。但作者认为这种便利性是以牺牲代码编写的灵活性为代价的,成本过高。因此,本书对元函数的定义并不局限于上述形式。具体来说:

在放松了对元函数定义的限制的前提下,我们可以在Fun_的基础上再引入一个定义,从而构造出另一个元函数Fun[3]

1    template <typename T>
2      using Fun = typename Fun_<T>::type;
3    
4    Fun<int> h = 3;

Fun是一个元函数吗?如果按照《C++模板元编程》中的定义,它至少不是一个标准的元函数,因为它没有内嵌类型type。但根据本章开头的讨论,它是一个元函数,因为它具有输入(T),输出(Fun<T>),同时明确定义了映射规则。那么在本书中,就将它视为一个元函数。

事实上,上文所展示的同时也是C++标准库中定义元函数的一种常用的方式。比如,C++ 11中定义了元函数std::enable_if,而在C++ 14中引入了定义std::enable_if_t[4],前者就像Fun_那样,是内嵌了type类型的元函数,后者则就像Fun那样,是基于前者给出的一个定义,用于简化使用。

在前文中,我们展示了几种元函数的书写方法,与一般的函数不同,元函数本身并非是C++语言设计之初有意引入的,因此语言本身也没有对这种构造的具体形式给出相应的规定。总的来说,只要确保所构造出的映射是“无副作用”的,可以在编译期被调用,用于对编译期乃至运行期的程序行为产生影响,那么相应的映射都可以被称为元函数,映射具体的表现形式则可以千变万化,并无一定之规。

事实上,一个模板就是一个元函数。下面的代码片断定义了一个元函数,接收参数T作为输入,输出为Fun<T>:

1    template <typename T>
2    struct Fun {};

函数的输入可以为空,相应地,我们也可以建立无参元函数:

1    struct Fun
2    {
3        using type = int;
4    };
5    
6    constexpr int fun()
7    {
8        return 10;
9    }

这里定义了两个无参元函数。前者返回类型int,后者返回数值10。

基于C++ 14中对constexpr的扩展,我们可以按照如下的形式来重新定义1.1.1节中引入的元函数:

1    template <int a>
2    constexpr int fun = a + 1;

这看上去越来越不像函数了,连函数应有的大括号都没有了。但这确实是一个元函数。唯一需要说明的是:现在调用该函数的方法与调用1.1.1节中元函数的方法不同了。对于1.1.1节的函数,我们的调用方法是fun(3),而对于这个函数,相应的调用方式则变成了fun<3>。除此之外,从编译期计算的角度来看,这两个函数并没有很大的差异。

前文所讨论的元函数均只有一个返回值。元函数的一个好处是可以具有多个返回值。考虑下面的程序片断:

1    template <>
2    struct Fun_<int>
3    {
4        using reference_type = int&;
5        using const_reference_type = const int&;
6        using value_type = int;
7    };

这是个元函数吗?希望你回答“是”。从函数的角度上来看,它有输入(int),包含多个输出:Fun_<int>::reference_type、Fun_<int>::const_reference_type与Fun_<int>::value_type。

一些学者反对上述形式的元函数,认为这种形式增加了逻辑间的耦合,从而会对程序设计产生不良的影响(见《C++模板元编程》)。从某种意义上来说,这种观点是正确的。但作者并不认为完全不能使用这种类型的函数,我们大可不必因噎废食,只需要在合适的地方选择合适的函数形式即可。

提到元函数,就不能不提及一个元函数库:type_traits。type_traits是由boost引入的,C++ 11将被纳入其中,通过头文件type_traits来引入相应的功能。这个库实现了类型变换、类型比较与判断等功能。

考虑如下代码:

1    std::remove_reference<int&>::type h1 = 3;
2    std::remove_reference_t<int&> h2 = 3;

第1行调用std::remove_reference这个元函数,将int&变换为int并以之声明了一个变量;第2行则使用std::remove_reference_t实现了相同的功能。std::remove_reference与std::remove_reference_t都是定义于type_traits中的元函数,其关系类似于1.1.2节中讨论的Fun_与Fun。

通常来说,编写泛型代码往往需要使用这个库以进行类型变换。我们的深度学习框架也不例外:本书会使用其中的一些元函数,并在首次使用某个函数时说明其功能。读者可以参考《C++标准模板库》[5]等书籍来系统性地了解该函数库。

按前文对函数的定义,理论上宏也可以被视为一类元函数。但一般来说,人们在讨论C++元函数时,会把讨论范围的重点限制在constexpr函数以及使用模板构造的函数上,并不包括宏[6]。这是因为宏是由预处理器而非编译器所解析的,这就导致了很多编译期间可以利用到的特性,宏无法利用。

典型事例是,我们可以使用名字空间将constexpr函数与函数模板包裹起来,从而确保它们不会与其他代码产生名字冲突。但如果使用宏作为元函数的载体,那么我们将丧失这种优势。也正是这个原因,作者认为在代码中尽量避免使用宏。

但在特定情况下,宏还是有其自身优势的。事实上,在构造深度学习框架时,本书就会使用宏作为模板元函数的一个补充。但使用宏还是要非常小心的。最基本的,作者认为应尽量避免让深度学习框架的最终用户接触到框架内部所定义的宏,同时确保在宏不再被使用时解除其定义。

元函数的形式多种多样,使用起来也非常灵活。在本书(以及所构造的深度学习框架)中,我们会用到各种类型的元函数。这里限定了函数的命名方式,以使得程序的风格达到某种程度上的统一。

在本书中,根据元函数返回值形式的不同,元函数的命名方式也会有所区别:如果元函数的返回值要用某种依赖型的名称表示,那么函数将被命名为xxx_的形式(以下划线为其后缀);反之,如果元函数的返回值可以直接用某种非依赖型的名称表示,那么元函数的名称中将不包含下划线形式的后缀。以下是一个典型的例子:

1    template <int a, int b>
2    struct Add_ {
3        constexpr static int value = a + b;
4    };
5    
6    template <int a, int b>
7    constexpr int Add = a + b;
8    
9    constexpr int x1 = Add_<2, 3>::value;
10    constexpr int x2 = Add<2, 3>;

其中的1~4行定义了元函数Add_;6~7行定义了元函数Add。它们具有相同的功能,只是调用方式不同:第9与10行分别调用了两个元函数,获取到返回结果后赋予x1与x2。第9行所获取的是一个依赖型的结果(value依赖于Add_存在),相应地,被依赖的名称使用下划线作为后缀:Add_;而第10行在获取结果时没有采用依赖型的写法,因此函数名中没有下划线后缀。这种书写形式并非强制性的,本书选择这种形式,仅仅是为了风格上的统一。

相信在阅读了上节之后,读者已经建立起了以下的认识:元函数可以操作类型与数值;对于元函数来说,类型与数值并没有本质上的区别,它们都可视为一种“数据”,可以作为元函数的输入与输出。

事实上,C++元函数可以操作的数据包含3类:数值、类型与模板,它们统一被称为“元数据”,以示与运行期所操作的“数据”有所区别。在上一节中,我们看到了其中的前两类,本节首先简单讨论一下模板类型的元数据。

模板可以作为元函数的输入参数,考虑下面的代码:

1    template <template <typename> class T1, typename T2>
2    struct Fun_ {
3        using type = typename T1<T2>::type;
4    };
5    
6    template <template <typename> class T1, typename T2>
7    using Fun = typename Fun_<T1, T2>::type;
8    
9    Fun<std::remove_reference, int&> h = 3;

1~7行定义了元函数Fun,它接收两个输入参数:一个模板与一个类型。将类型应用于模板之上,获取到的结果类型作为返回值。在第9行,使用这个元函数并以std::remove_reference与int&作为参数传入。根据调用规则,这个函数将返回int,即我们在第9行声明了一个int类型的变量h并赋予值3。

从函数式程序设计的角度上来说,上述代码所定义的Fun是一个典型的高阶函数,即以另一个函数为输入参数的函数。可以将其总结为如下的数学表达式(为了更明确地说明函数与数值的关系,下式中的函数以大写字母开头,而纯粹的数值则是以小写字母开头):

Fun(T1, t2)=T1(t2)

与数值、类型相似,模板除了可以作为元函数的输入,还可以作为元函数的输出,但编写起来会相对复杂一些。

考虑下面的代码:

1    template <bool AddOrRemoveRef> struct Fun_;
2    
3    template <>
4    struct Fun_<true> {
5        template <typename T>
6        using type = std::add_lvalue_reference<T>;
7    };
8    
9    template <>
10    struct Fun_<false> {
11        template <typename T>
12        using type = std::remove_reference<T>;
13    };
14    
15    template <typename T>
16    template <bool AddOrRemove>
17    using Fun = typename Fun_<AddOrRemove>::template type<T>;
18    
19    template <typename T>
20    using Res_ = Fun<false>;
21    
22    Res_<int&>::type h = 3;

代码的1~13行定义了元函数Fun_:

代码的15~17行定义了元函数Fun,与之前的示例类似,Fun<bool>是Fun_<bool>::type的简写[7]。注意这里的using用法:为了实现Fun,我们必须引入两层template声明:内层(第16行)的template定义了元函数Fun的模板参数;而外层(第15行)的template则表示了Fun的返回值是一个接收一个模板参数的模板——这两层的顺序不能搞错。

代码段的19~20行是应用元函数Fun计算的结果:输入为false,输出结果保存在Res_中。注意此时的Res_还是一个函数模板,它实际上对应了std::remove_reference——这个元函数用于去除类型中的引用。而第22行则是进一步使用这个函数模板(元函数的调用)来声明int型的对象h。

如果读者对这种写法感到困惑,难以掌握,没有太大的关系。因为将模板作为元函数输出的实际应用相对较少。但如果读者在后续的学习与工作中遇到了类似的问题,可以将这一小节的内容作为参考。

与上一小节类似,这里也将整个的处理过程表示为数学的形式,如下:

Fun(addOrRemove)=T

其中的addOrRemove是一个bool值,而T则是Fun的输出,是一个元函数。

学习任何一门程序设计语言之初,我们通常会首先了解该语言所支持的基本数据类型,比如C++中使用int表示带符号的整数。在此基础上,我们会对基本数据类型进行一次很自然地扩展:讨论如何使用数组。与之类似,如果将数值、类型、模板看成元函数的操作数,那么前文所讨论的就是以单个元素为输入的元函数。在本节中,我们将讨论元数据的“数组”表示:数组中的“元素”可以是数值、类型或模板。

可以有很多种方法来表示数组甚至更复杂的结构。《C++模板元编程》一书讨论了C++模板元编程库MPL(Boost C++ template Meta-Programming library)。它实现了类似STL的功能,使用它可以很好地在编译期表示数组、集合、映射等复杂的数据结构。

但本书并不打算使用MPL,主要原因是MPL封装了一些底层的细节,这些细节对于元编程的学习来说,又是非常重要的。如果简单地使用MPL,将在一定程度上丧失学习元编程技术的机会。而另一方面,掌握了基本的元编程方法之后再来看MPL,就会对其有更深入的理解,同时使用起来也会更得心应手。这就好像学习C++语言时,我们通常会首先讨论int a[10]这样的数组,并以此引申出指针等重要的概念,在此基础上再讨论vector<int>时,就会有更深入的理解。本书会讨论元编程的核心技术,而非一些元编程库的使用方式。我们只会使用一些自定义的简单结构来表示数组,就像int*这样,简单易用。

从本质上来说,我们需要的并非一种数组的表示方式,而是一个容器:用来保存数组中的每个元素。元素可以是数值、类型或模板。可以将这3种数据视为不同类别的操作数,就像C++中的int与float属于不同的类型。在元函数中,我们也可以简单地认为“数值”与“类型”属于不同的类别。典型的C++数组(无论是int*还是vector<int>)都仅能保存一种类型的数据。这样设计的原因首先是实现比较简单,其次是它能满足大部分的需求。与之类似,我们的容器也仅能保存一种类别的操作数,比如一个仅能保存数值的容器,或者仅能保存类型的容器,或者仅能保存模板的容器。这种容器已经能满足绝大多数的使用需求了。

C++ 11中引入了变长参数模板(variadic template),使用它可以很容易地实现我们需要的容器[8]

1    template <int... Vals> struct IntContainer;
2    template <bool... Vals> struct BoolContainer;
3    
4    template <typename...Types> struct TypeContainer;
5    
6    template <template <typename> class...T> struct TemplateCont;
7    template <template <typename...> class...T> struct TemplateCont2;

上面的代码段声明了5个容器(相当于定义了5个数组)。其中前两个容器分别可以存放int与bool类型的变量;第3个容器可以存放类型;第4个容器可以存放模板作为其元素,每个模板元素可以接收一个类型作为参数;第5个容器同样以模板作为其元素,但每个模板可以放置多个类型信息[9]

细心的读者可能发现,上面的5条语句实际上是声明而非定义(每个声明的后面都没有跟着大括号,因此仅仅是声明)。这也是C++元编程的一个特点:事实上,我们可以将每条语句最后加上大括号,形成定义。但思考一下,我们需要定义吗?不需要。声明中已经包含了编译器需要使用的全部信息,既然如此,为什么还要引入定义呢?事实上,这几乎可以称为元编程中的一个惯用法了——仅在必要时才引入定义,其他的时候直接使用声明即可。在后文中,我们会看到很多类似的声明,并通过具体的示例来了解这些声明的使用方式。

事实上,到目前为止,我们已经基本完成了数据结构的讨论——深度学习框架只需要使用上述数据结构就可以完成构造了。如果你对这些结构还不熟悉,没关系,在后面构造深度学习框架的过程中,我们会不断地使用上述数据结构,你也就会不断地熟悉它们。

数据结构仅仅是故事的一半,一个完整的程序除了数据结构还要包含算法。而算法则是由最基本的顺序、分支与循环操作构成的。在下一节,我们将讨论涉及元函数时,该如何编写相应的顺序、分支或循环逻辑。

相信本书的读者可以熟练地写出在运行期顺序、分支与循环执行的代码。但本书还是需要单独开辟出一节来讨论这个问题,是因为一旦涉及元函数,相应的代码编写方法也会随之改变。

顺序执行的代码书写起来是比较直观的,考虑如下代码:

1    template <typename T>
2    struct RemoveReferenceConst_ {
3    private:
4        using inter_type = typename std::remove_reference<T>::type;
5    public:
6        using type = typename std::remove_const<inter_type>::type;
7    };
8    
9    template <typename T>
10    using RemoveReferenceConst
11        = typename RemoveReferenceConst_<T>::type;
12    
13    RemoveReferenceConst<const int&> h = 3;

这一段代码的重点是2~7行,它封装了元函数RemoveReferenceConst_,这个函数内部则包含了两条语句,顺序执行:

(1)第4行根据T计算出inter_type;

(2)第6行根据inter_type计算出type。

同时,代码中的inter_type被声明为private类型,以确保函数的使用者不会误用inter_type这个中间结果作为函数的返回值。

这种顺序执行的代码很好理解,唯一需要提醒的是,现在结构体中的所有声明都要看成执行的语句,不能随意调换其顺序。考虑下面的代码:

1    struct RunTimeExample {
2        static void fun1() { fun2(); }
3        static void fun2() { cerr << "hello" << endl; }
4    };

这段代码是正确的,可以将fun1与fun2的定义顺序发生调换,不会改变它们的行为。但如果我们将元编程示例中的代码调整顺序:

1    template <typename T>
2    struct RemoveReferenceConst_ {
3        using type = typename std::remove_const<inter_type>::type;
4        using inter_type = typename std::remove_reference<T>::type;
5    };

程序将无法编译,这并不难理解:在编译期,编译器会扫描两遍结构体中的代码,第一遍处理声明,第二遍才会深入到函数的定义之中。正因为如此,RunTimeExample是正确的,第一遍扫描时,编译器只是了解到RunTimeExample包含了两个成员函数fun1与fun2;在后续的扫描中,编译器才会关注fun1中调用了fun2。虽然fun2的调用语句出现在其声明之前,但正是因为这样的两遍扫描,编译器并不会报告找不到fun2这样的错误。

但修改后的RemoveReferenceConst_中,编译器在首次从前到后扫描程序时,就会发现type依赖于一个没有定义的inter_type,它不继续扫描后续的代码,而是会直接给出错误信息。在很多情况下,我们会将元函数的语句置于结构体或类中,此时就要确保其中的语句顺序正确。

我们也可以在编译期引入分支的逻辑。与编译期顺序执行的代码不同的是,编译期的分支逻辑既可以表现为纯粹的元函数,也可以与运行期的执行逻辑相结合。对于后者,编译期的分支往往用于运行期逻辑的选择。我们将在这一小节看到这两种情形各自的例子。

事实上,在前面的讨论中,我们已经实现过分支执行的代码了。比如在1.2.2节中,实现了一个Fun_元函数,并使用一个bool参数来决定函数的行为(返回值):这就是一种典型的分支行为。事实上,像该例那样,使用模板的特化或部分特化来实现分支,是一种非常常见的分支实现方式。当然,除此之外,还存在一些其他的分支实现方式,每种方式都有自己的优缺点——本小节会讨论其中的几种。

使用std::conditional与std::conditional_t实现分支

conditional与conditional_t是type_traits中提供的两个元函数,其定义如下[10]

1    namespace std
2    {
3    template <bool B, typename T, typename F>
4        struct conditional {
5            using type = T;
6    };
7    
8    template <typename T, typename F>
9        struct conditional<false, T, F> {
10            using type = F;
11    };
12    
13    template <bool B, typename T, typename F>
14    using conditional_t = typename conditional<B, T, F>::type;
15    }

其逻辑行为是:如果B为真,则函数返回T,否则返回F。其典型的使用方式为:

1    std::conditional<true, int, float>::type x = 3;
2    std::conditional_t<false, int, float> y = 1.0f;

分别定义了int型的变量x与float型的变量y。

conditional与conditional_t的优势在于使用比较简单,但缺点是表达能力不强:它只能实现二元分支(真假分支),其行为更像运行期的问号表达式:x = B ? T : F;。对于多元分支(类似于switch的功能)则支持起来就比较困难了。相应地,conditional与conditional_t的使用场景是相对较少的。除非是特别简单的分支情况,否则并不建议使用这两个元函数。

使用(部分)特化实现分支

在前文的讨论中,我们就是使用特化来实现的分支。(部分)特化天生就是用来引入差异的,因此,使用它来实现分支也是十分自然的。考虑下面的代码:

1    struct A; struct B;
2    
3    template <typename T>
4    struct Fun_ {
5        constexpr static size_t value = 0;
6    };
7    
8    template <>
9    struct Fun_<A> {
10        constexpr static size_t value = 1;
11    };
12    
13    template <>
14    struct Fun_<B> {
15        constexpr static size_t value = 2;
16    };
17    
18    constexpr size_t h = Fun_<B>::value;

代码的第18行根据元函数Fun_的输入参数不同,为h赋予了不同的值——这是一种典型的分支行为。Fun_元函数实际上引入了3个分支,分别对应输入参数为A、B与默认的情况。使用特化引入分支代码书写起来比较自然,容易理解,但代码一般比较长。

在C++ 14中,除了可以使用上述方法进行特化,还可以有其他的特化方式,考虑下面的代码:

1    struct A; struct B;
2    
3    template <typename T>
4    constexpr size_t Fun = 0;
5    
6    template <>
7    constexpr size_t Fun<A> = 1;
8    
9    template <>
10    constexpr size_t Fun<B> = 2;
11    
12    constexpr size_t h = Fun<B>;

这段代码与上一段实现了相同的功能(唯一的区别是元函数调用时,前者需要给出依赖型名称::value,而后者则无须如此),但实现简单一些。如果希望分支返回的结果是单一的数值,则可以考虑这种方式。

使用特化来实现分支时,有一点需要注意:在非完全特化的类模板中引入完全特化的分支代码是非法的。考虑如下代码:

1    template <typename TW>
2    struct Wrapper {
3        template <typename T>
4        struct Fun_ {
5            constexpr static size_t value = 0;
6        };
7    
8        template <>
9        struct Fun_<int> {  
10            constexpr static size_t value = 1;
11        };
12    };

这个程序是非法的。原因是Wrapper是一个未完全特化的类模板,但在其内部包含了一个模板的完全特化Fun_<int>,这是C++标准所不允许的,会产生编译错误。

为了解决这个问题,我们可以使用部分特化来代替完全特化,将上面的代码修改如下:

1    template <typename TW>
2    struct Wrapper {
3        template <typename T, typename TDummy = void>
4        struct Fun_ {
5            constexpr static size_t value = 0;
6        };
7    
8        template <typename TDummy>
9        struct Fun_<int, TDummy> {
10            constexpr static size_t value = 1;
11        };
12    };

这里引入了一个伪参数 TDummy,用于将原有的完全特化修改为部分特化。这个参数有一个默认值void,这样就可直接以Fun_<int>的形式调用这个元函数,无需为伪参数赋值了。

使用std::enable_if与std::enable_if_t实现分支

enable_if与enable_if_t的定义如下:

1    namespace std
2    {
3        template<bool B, typename T = void>
4        struct enable_if {};
5        
6        template<class T>
7        struct enable_if<true, T> { using type = T; };
8        
9        template< bool B, class T = void >
10        using enable_if_t = typename enable_if<B, T>::type;
11    }

对于分支的实现来说,这里面的T并不特别重要,重要的是当B为true时,enable_if元函数可以返回结果type。可以基于这个构造实现分支,考虑下面的代码:

1    template <bool IsFeedbackOut, typename T,
2              std::enable_if_t<IsFeedbackOut>* = nullptr>
3    auto FeedbackOut_(T&&) { /* ... */ }
4    
5    template <bool IsFeedbackOut, typename T,
6              std::enable_if_t<!IsFeedbackOut>* = nullptr>
7    auto FeedbackOut_(T&&) { /* ... */ }

这里引入了一个分支。当IsFeedbackOut为真时,std::enable_if_t<IsFeedbackOut>::type是有意义的,这就使得第一个函数匹配成功;与之相应的,第二个函数匹配是失败的。反之,当IsFeedbackOut为假时,std::enable_if_t<!IsFeedbackOut>::type是有意义的,这就使得第二个函数匹配成功,第一个函数匹配失败。

C++中有一个特性SFINAE(Substitution Failure Is Not An Error),中文译为“匹配失败并非错误”。对于上面的程序来说,一个函数匹配失败,另一个函数匹配成功,则编译器会选择匹配成功的函数而不会报告错误。这里的分支实现也正是利用了这个特性。

通常来说,enable_if与enable_if_t会被用于函数之中,用做重载的有益补充——重载通过不同类型的参数来区别重名的函数。但在一些情况下,我们希望引入重名函数,但无法通过参数类型加以区分[11]。此时通过enable_if与enable_if_t就能在一定程度上解决相应的重载问题。

需要说明的是,enable_if与enable_if_t的使用形式是多种多样的,并不局限于前文中作为模板参数的方式。事实上,只要C++中支持SFINAE的地方,都可以引入enable_if或enable_if_t。有兴趣的读者可以参考C++ Reference中的说明。

enable_if或enable_if_t也是有缺点的:它并不像模板特化那样直观,以之书写的代码阅读起来也相对困难一些(相信了解模板特化机制的程序员比了解SFINAE的还是多一些的)。

还要说明的一点是,这里给出的基于enable_if的例子就是一个典型的编译期与运行期结合的使用方式。FeedbackOut_中包含了运行期的逻辑,而选择哪个FeedbackOut_则是通过编译期的分支来实现的。通过引入编译期的分支方法,我们可以创造出更加灵活的函数。

编译期分支与多种返回类型

编译期分支代码看上去比运行期分支复杂一些,但与运行期相比,它也更加灵活。考虑如下代码:

1    auto wrap1(bool Check)
2    {
3        if (Check) return (int)0;
4        else return (double)0;
5    }

这是一个运行期的代码。首先要对第1行的代码简单说明一下:在C++ 14中,函数声明中可以不用显式指明其返回类型,编译器可以根据函数体中的return语句来自动推导其返回类型,但要求函数体中的所有return语句所返回的类型均相同。对于上述代码来说,其第3行与第4行返回的类型并不相同,这会导致编译出错。事实上,对于运行期的函数来说,其返回类型在编译期就已经确定了,无论采用何种写法,都无法改变。

但在编译期,我们可以在某种程度上打破这样的限制:

1    template <bool Check, std::enable_if_t<Check>* = nullptr>
2    auto fun() {
3        return (int)0;
4    }
5    
6    template <bool Check, std::enable_if_t<!Check>* = nullptr>
7    auto fun() {
8        return (double)0;
9    }
10    
11    template <bool Check>
12    auto wrap2() {
13        return fun<Check>();
14    }
15    
16    int main() {
17        std::cerr << wrap2<true>() << std::endl;
18    }

wrap2的返回值是什么呢?事实上,这要根据模板参数Check的值来决定。通过C++中的这个新特性以及编译期的计算能力,我们实现了一种编译期能够返回不同类型的数据结果的函数。当然,为了执行这个函数,我们还是需要在编译期指定模板参数值,从而将这个编译期的返回多种类型的函数蜕化为运行期的返回单一类型的函数。但无论如何,通过上述技术,编译期的函数将具有更强大的功能,这种功能对元编程来说是很有用的。

这也是一个编译期分支与运行期函数相结合的例子。事实上,通过元函数在编译期选择正确的运行期函数是一种相对常见的编程方法,因此C++ 17专门引入了一种新的语法if constexpr来简化代码的编写。

使用if constexpr简化代码

对于上面的代码段来说,在C++ 17中可以简化为:

1    template <bool Check>
2    auto fun()
3    {
4        if constexpr (Check)
5        {
6            return (int)0;
7        }
8        else
9        {
10            return (double)0;
11        }
12    }
13    
14    int main() {
15        std::cerr << fun<true>() << std::endl;
16    }

其中的if constexpr必须接收一个常量表达式,即编译期常量。编译器在解析到相关的函数调用时,会自动选择if constexpr表达式为真的语句体,而忽略其他的语句体。比如,在编译器解析到第15行的函数调用时,会自动构造类似如下的函数:

1    // template <bool Check>
2    auto fun()
3    {
4    //    if constexpr (Check)
5    //    {
6            return (int)0;
7    //    }
8    //    else
9    //    {
10    //        return (double)0;
11    //    }
12    }

使用if constexpr写出的代码与运行期的分支代码更像。同时,它有一个额外的好处,就是可以减少编译实例的产生。使用上一节中编写的代码,编译器在进行一次实例化时,需要构造wrap2与fun两个实例;但使用本节的代码,编译器在实例化时只会产生一个fun函数的实例。虽然优秀的编译器可以通过内联等方式对构造的实例进行合并,但我们并不能保证编译器一定会这样处理。反过来,使用if constexpr则可以确保减少编译器所构造的实例数,这也就意味着在一定程度上减少编译所需要的资源以及编译产出的文件大小。

但if constexpr也有缺点。首先,如果我们在编程时忘记书写constexpr,那么某些函数也能通过编译,但分支的选择则从编译期转换到了运行期——此时,我们还是会在运行期引入相应的分支选择,无法在编译期将其优化掉。其次,if constexpr的使用场景相对较窄:它只能放在一般意义上的函数内部,用于在编译期选择所执行的代码。如果我们希望构造元函数,通过分支来返回不同的类型作为结果,那么if constexpr就无能为力了。该在什么情况下使用if constexpr,还需要针对特定的问题具体分析。

一般来说,我们不会用while、for这样的语句组织元函数中的循环代码——因为这些代码操作的是变量。但在编译期,我们操作的更多的则是常量、类型与模板[12]。为了能够有效地操纵元数据,我们往往会使用递归的形式来实现循环。

还是让我们参考一个例子:给定一个无符号整数,求该整数所对应的二进制表示中1的个数。在运行期,我们可以使用一个简单的循环来实现。在编译期,我们就需要使用递归来实现了:

1    template <size_t Input>
2    constexpr size_t OnesCount = (Input % 2) + OnesCount<(Input / 2)>;
3    
4    template <> constexpr size_t OnesCount<0> = 0;
5    
6    constexpr size_t res = OnesCount<45>;

1~4行定义了元函数OnesCount,第6行则使用了这个元函数计算45对应的二进制包含的1的个数。

你可能需要一段时间才能适应这种编程风格。整个程序在逻辑上并不复杂,它使用了C++ 14中的特性,代码量也与编写一个while循环相差无几。程序第2行OnesCount<(Input / 2)>是其核心,它本质上是一个递归调用。读者可以思考一下,当Input为45或者任意其他的数值时,代码段第2行的行为。

一般来说,在采用递归实现循环的元程序中,需要引入一个分支来结束循环。上述程序的第4行实现了这一分支:当将输入减小到0时,程序进入这一分支,结束循环。

循环使用更多的一类情况则是处理数组元素。我们在前文中讨论了数组的表示方法,在这里,给出一个处理数组的示例:

1    template <size_t...Inputs>
2    constexpr size_t Accumulate = 0;
3    
4    template <size_t CurInput, size_t...Inputs>
5    constexpr size_t Accumulate<CurInput, Inputs...>
6        = CurInput + Accumulate<Inputs...>;
7    
8    constexpr size_t res = Accumulate<1, 2, 3, 4, 5>;

1~6行定义了一个元函数:Accumulate,它接收一个size_t类型的数组,对数组中的元素求和并将结果作为该元函数的输出。第8行展示了该元函数的用法:计算res的值15。

正如前文所述,在元函数中引入循环,非常重要的一点是引入一个分支来终止循环。程序的第2行是用于终止循环的分支:当输入数组为空时,会匹配这个函数的模板参数<size_t...Inputs>,此时Accumulate返回0。而4~6行则组成了另一个分支:如果数组中包含一个或多于一个的元素,那么调用Accumulate将匹配第二个模板特化,取出首个元素,将剩余元素求和后加到首个元素之上。

事实上,仅就本例而言,在C++ 17中可以有更简单的代码编写方法,即使用其所提供的fold expression技术:

1    template <size_t... values>
2    constexpr size_t fun()
3    {
4        return (0 + ... + values);
5    }
6    
7    constexpr size_t res = fun<1, 2, 3, 4, 5>();

fold expression本质上也是一种简化的循环写法,它的使用具有一定的限制。本书不对其进行重点讨论。

编译期的循环,本质上是通过分支对递归代码进行控制的。因此,上一节所讨论的很多分支编写方法也可以衍生并编写相应的循环代码。典型的,可以使用if constexpr来编写分支,这项工作就留给读者进行练习了。

回顾一下之前的代码:

1    template <size_t Input>
2    constexpr size_t OnesCount = (Input % 2) + OnesCount<(Input / 2)>;
3    
4    template <> constexpr size_t OnesCount<0> = 0;
5    
6    constexpr size_t x1 = OnesCount<7>;
7    constexpr size_t x1 = OnesCount<15>;

考虑一下,编译器在编译这一段时,会产生多少个实例。

在第6行以7为模板参数传入时,编译器将使用7、3、1、0来实例化OnesCount,构造出4个实例。接下来第7行以15为参数传入这个模板,那么编译器需要用15、7、3、1、0来实例化代码。通常,编译器会将第一次使用7、3、1、0实例化出的代码保留起来,这样一来,如果后面的编译过程中需要使用同样的实例,那么之前保存的实例就可以复用了。对于一般的C++程序来说,这样做能极大地提升编译速度,但对于元编程来说,这可能会造成灾难。考虑以下的代码:

1    template <size_t A>
2    struct Wrap_ {
3        template <size_t ID, typename TDummy = void>
4        struct imp {
5            constexpr static size_t value = ID + imp<ID - 1>::value;
6        };
7    
8        template <typename TDummy>
9        struct imp<0, TDummy> {
10            constexpr static size_t value = 0;
11        };
12    
13        template <size_t ID>
14        constexpr static size_t value = imp<A + ID>::value;
15    };
16    
17    int main() {
18        std::cerr << Wrap_<3>::value<2> << std::endl;
19        std::cerr << Wrap_<10>::value<2> << std::endl;
20    }

这段代码结合了前文所讨论的分支与循环技术,构造出了Wrap_类模板。它是一个元函数,接收参数A返回另一个元函数。后者接收参数ID,并计算

在编译第18行代码时,编译器会因为这条语句产生Wrap_<3>::imp的一系列实例。不幸的是,在编译第19行代码时,编译器无法复用这些实例,因为它所需要的是Wrap_<10>::imp的一系列实例,这与Wrap_<3>::imp系列并不同名。因此,我们无法使用编译器已经编译好的实例来提升编译速度。

实际情况可能会更糟,编译器很可能会保留Wrap_<3>::imp的一系列实例,因为它会假定后续可能还会出现再次需要该实例的情形。上例中Wrap_中包含了一个循环,循环所产生的全部实例都会在编译器中保存。如果我们的元函数中包含了循环嵌套,那么由此产生的实例将随循环层数的增加呈指数的速度增长——这些内容都会被保存在编译器中。

不幸的是,编译器的设计往往是为了满足一般性的编译任务,对于元编程这种目前来说使用情形并不多的技术来说,优化相对较少。因此编译器的开发者可能不会考虑编译过程中保存在内存中的实例数过多的问题(对于非元编程的情况,这可能并不是一个大问题)。但另一方面,如果编译过程中保存了大量的实例,那么可能会导致编译器的内存超限,从而出现编译失败甚至崩溃的情况。

这并非危言耸听。事实上,在作者编写深度学习框架时,就出现过对这个问题没有引起足够重视,而导致编译内存占用过多,最终编译失败的情况。在小心修改了代码之后,编译所需的内存比之前减少了50%以上,编译也不再崩溃了。

那么如何解决这个问题呢?其实很简单:将循环拆分出来。对于上述代码,我们可以修改为如下内容:

1    template <size_t ID>
2    struct imp {
3        constexpr static size_t value = ID + imp<ID - 1>::value;
4    };
5    
6    template <>
7    struct imp<0> {
8        constexpr static size_t value = 0;
9    };
10    
11    template <size_t A>
12    struct Wrap_ {
13        template <size_t ID>
14        constexpr static size_t value = imp<A + ID>::value;
15    };

在实例化Wrap_<3>::value<2>时,编译器会以5、4、3、2、1、0为参数构造imp。在随后实例化Wrap_<10>::value<2>时,之前构造的东西还可以被使用,新的实例化次数也会随之变少。

但这种修改还是有不足之处的:在之前的代码中,imp被置于Wrap_中,这表明了二者的紧密联系;从名称污染的角度上来说,这样做不会让imp污染Wrap_外围的名字空间。但在后一种实现中,imp将对名字空间造成污染:在相同的名字空间中,我们无法再引入另一个名为imp的构造,供其他元函数调用。

如何解决这种问题呢?这实际上是一种权衡。如果元函数的逻辑比较简单,同时并不会产生大量实例,那么保留前一种(对编译器来说比较糟糕的)形式,可能并不会对编译器产生太多负面的影响,同时使得代码具有更好的内聚性。反之,如果元函数逻辑比较复杂(典型情况是多重循环嵌套),又可能会产生很多实例,那么就选择后一种方式以节省编译资源。

即使选择后一种方式,我们也应当尽力避免名字污染。为了解决这个问题,在后续编写深度学习框架时,我们会引入专用的名字空间,来存放像imp这样的辅助代码。

减少编译期实例化的另一种重要的技术就是引入短路逻辑。考虑如下代码:

1    template <size_t N>
2    constexpr bool is_odd = ((N % 2) == 1);
3    
4    template <size_t N>
5    struct AllOdd_ {
6        constexpr static bool is_cur_odd = is_odd<N>;
7        constexpr static bool is_pre_odd = AllOdd_<N - 1>::value;
8        constexpr static bool value = is_cur_odd && is_pre_odd;
9    };
10    
11    template <>
12    struct AllOdd_<0> {
13        constexpr static bool value = is_odd<0>;
14    };

这段代码的逻辑并不复杂。1~2行引入了一个元函数is_odd,用来判断一个数是否为奇数。在此基础上,AllOdd_用于给定数N,判断0~N的数列中是否每个数均为奇数。

虽然这段代码的逻辑非常简单,但足以用于讨论本节中的问题了。考虑一下在上述代码中,为了进行判断,编译器进行了多少次实例化。在代码段的第7行,系统进行了递归的实例化。给定N作为AllOdd_的输入时,系统会实例化出N+1个对象。

上述代码判断的核心是第8行:一个逻辑“与”操作。对于“与”来说,只要有一个操作数不为真,那么就该返回假。但这种逻辑短路的行为在上述元程序中并没有得到很好地利用——无论is_cur_odd的值是什么,AllOdd_都会对is_pre_odd进行求值,这会间接产生若干实例化的结果,虽然这些实例化可能对系统最终的求值没什么作用。

以下是这个程序的改进版本(这里只列出了修改的部分):

1    template <bool cur, typename TNext>
2    constexpr static bool AndValue = false;
3    
4    template <typename TNext>
5    constexpr static bool AndValue<true, TNext> = TNext::value;
6    
7    template <size_t N>
8    struct AllOdd_ {
9        constexpr static bool is_cur_odd = is_odd<N>;
10        constexpr static bool value = AndValue<is_cur_odd,
11                                               AllOdd_<N - 1>>;
12    };

这里引入了一个辅助元函数AndValue:只有当该元函数的第一个操作数为true时,它才会实例化第二个操作数[13];否则将直接返回false。代码段的10~11行使用了AndValue以减少实例化的次数,同时也减少了代码的编译成本。

本章所讨论的很多内容都并非C++中新引入的技术,而是基于已有技术所衍生出来的一些使用方法。编写运行期程序时,这些方法可能并不常见,但在元编程中,我们会经常使用这些方法,而这些方法也可以视为元编程中的惯用法。

如果对元函数划分等级,那么进行基本变换(如为输入一个类型,返回相应的指针类型)的元函数是最低级的;在此之上则是包含了顺序、分支与循环逻辑的元函数。在掌握了这些工具后,我们就可以进而学习一些更高级的元编程方式——奇特的递归模板式就是其中之一。

奇特的递归模板式(Cruiously Recurring Template Pattern,CRTP)是一种派生类的声明方式,其“奇特”之处就在于:派生类会将本身作为模板参数传递给其基类。考虑如下代码:

1    template <typename D> class Base { /*...*/ };
2    
3    class Derived : public Base<Derived> { /*...*/ };

其中第3行定义了类Derived,派生自Base<Derived>——基类以派生类的名字作为模板参数。这乍看起来似乎有循环定义的嫌疑,但它确实是合法的。只不过看起来比较“奇特”而已。

CRTP有很多应用场景,模拟虚函数是其典型应用之一。习惯了面向对象编程的读者对虚函数并不陌生:我们可以在基类中声明一个虚函数(这实际上声明了一个接口),并在每个派生类中采用不同的方式实现该接口,从而产生不同的功能——这是面向对象中以继承实现多态的一种经典实现方式。选择正确的虚函数执行需要运行期的相应机制来支持。在一些情况下,我们所使用的函数无法声明为虚函数,比如下面的例子:

1    template <typename D>
2    struct Base
3    {
4        template <typename TI>
5        void Fun(const TI& input) {
6            D* ptr = static_cast<D*>(this);
7            ptr->Imp(input);
8        }
9    };
10    
11    struct Derive : public Base<Derive>
12    {
13        template <typename TI>
14        void Imp(const TI& input) {
15            cout << input << endl;
16        }
17    };
18    
19    int main() {
20        Derive d;
21        d.Fun("Implementation from derive class");
22    }

在这段代码中,基类Base<D>会假定派生类实现了一个接口Imp,会在其函数Fun中调用这个接口。如果使用面向对象的编程方法,我们就需要引入虚函数Imp。但是,Imp是一个函数模板,无法被声明为虚函数,因此这里借用了CRTP技术来实现类似虚函数的功能。除了函数模板,类的静态函数也无法被声明为虚函数,此时借用CRTP,同样能达到类似虚函数的效果:

1    template <typename D>
2    struct Base
3    {
4        static void Fun() {
5            D::Imp();
6        }
7    };
8    
9    struct Derive : public Base<Derive>
10    {
11        static void Imp() {
12            cout << "Implementation from derive class" << endl;
13        }
14    };
15    
16    int main() {
17        Derive::Fun();
18    }

元编程涉及的函数大部分与模板相关,或者往往是类中的静态函数。在这种情况下,如果要实现类似运行期的多态特性,就可以考虑使用CRTP。

本章讨论了元编程中可能会用到的一些基本技术,从元函数的定义方式到顺序、分支与循环程序的编写,再到奇特的递归模板式。这些技术有的专门用于编写元函数,有的则需要与元编程结合在一起以发挥更大的作用。一些技术初看起来与常见的运行期编程方法有很大的不同,初学者难免会感到不习惯。但如果能够反复地练习,在适应了这些技术之后,读者就可以得心应手地编写元程序,实现大部分编译期计算的功能了。

本章只是选择相对基础且具有代表性的技术进行介绍,省略了很多高级的元编程技巧,比如:可以使用模板的默认参数来实现分支;基于包展开与fold expression实现循环等。相对于面向对象的编程技术来说,C++模板元编程是一个较新的领域,新的技术层出不穷,仅仅通过一章的篇幅很难一一列举。有兴趣的读者可以搜索相关的资源进行学习。

即使只是使用本章所讨论的这些技术,也可以进行很复杂的编译期计算了。本书的后续章节会使用这些技术构造深度学习框架。事实上,在后续的章节中,大部分的讨论都可以被视为利用本章所讨论的技术来解决实际问题的演练。因此,读者完全可以将本书后续的内容看成是本章讨论技术的一个练习。这个练习的过程也正是带领读者熟悉本章知识点的过程。相信在读完本书之后,读者会对元编程有一个更加成熟的认识。

除了直接使用本章所讨论的技术,我们也可以通过一些元编程的库来实现特定的操作。比如:使用Boost::MPL或Boost::Hana库来实现数组、集合的操作——这些库所提供的接口与本章中讨论的数组处理方式看上去有很大不同,但实现的功能则是类似的。使用本书中描述的数组处理方式,就像在运行期使用C++的基本数组,而使用诸如MPL这样的元编程库,则更像在运行期使用vector。本书不会讨论这些元编程库,因为作者认为,如果基本数组没有用好,就很难用好vector。本书所传达给读者的是元编程的基础技法,相信读者在打好了相应的基础后,再使用其他高级的元编程类库就不是什么难事了。

1.对于元函数来说,数值与类型其实并没有特别明显的差异:元函数的输入可以是数值或类型,对应的变换可以在数值与类型之间进行。比如可以构造一个元函数,输入是一个类型,输出是该类型变量所占空间的大小——这就是一个典型的从类型变换为数值的元函数。试构造该函数,并测试之。

2.作为进一步的扩展,元函数的输入参数甚至可以是类型与数值混合的。尝试构造一个元函数,其输入参数为一个类型以及一个整数。如果该类型所对应对象的大小等于该整数,那么返回true,否则返回false。

3.本章介绍了若干元函数的表示形式,你是否还能想到其他的形式?

4.本章讨论了以类模板作为元函数的输出,尝试构造一个元函数,它接收输入后会返回一个元函数,后者接收输入后会再返回一个元函数——这仅仅是一个练习,不必过于在意其应用场景。

5.使用SFINAE构造一个元函数:输入一个类型T,当T存在子类型type时该元函数返回true,否则返回false。

6.使用在本章中学到的循环代码书写方式,编写一个元函数,输入一个类型数组,输出一个无符号整型数组,输出数组中的每个元素表示了输入数组中相应类型变量的大小。

7.使用分支短路逻辑实现一个元函数,给定一个整数序列,判断其中是否存在值为1的元素。如果存在,就返回true,否则返回false。

[1] C++中的函数可以视为对上述定义的扩展,允许输入或输出为空。

[2] David Abrahams、Aleksey Gurtovoy著,荣耀译,机械工业出版社,2010年出版。

[3] 注意第2行的typename表明Fun_<T>::type是一个类型,而非静态数据,这是C++规范中的书写要求。

[4] 1.3.2节会讨论这两个元函数。

[5] Nicolai M. Josuttis著,侯捷译,电子工业出版社,2015年出版。

[6] Advanced Metaprogramming in Classic C++这本书对使用宏与模板协同构造元函数有较深入的讨论。

[7] 第17行的template关键字表明后续的<是模板的开头,而非小于号。

[8] 注意代码段的6~7行:在C++ 17之前,像template <typename> class这样的声明中,class不能换成typename。这一点在C++ 17中有所放松,本书还是沿用了C++ 17之前的书写惯例,使用class而非typename来表示模板。

[9] C++ 17支持类似template <auto... Vals> struct Cont这样的容器,用于存储不同类型的数值。本书不会使用到类似的容器,因此不做讨论。

[10] 这里只是给出了一种可能的实现,不同的编译器可能会采用不同的实现方式,但其逻辑是等价的。

[11] 我们将在深度学习的框架中看到这样的例子。

[12] C++ 14标准允许在constexpr函数中使用变量,但支持程度有限。

[13] 通常来说,在C++中,只有访问了模板内部的具体元素时,相应的元素才会被实例化。因此像本例中的1~2行并不会导致第二个模板参数TNext内部元素的实例化。


自C++问世以来,对它的批评就一直没有间断过。批评的原因有很多种。C与汇编语言的拥护者认为其中包含了太多“华而不实”的东西[1];而习惯了Java、Python的程序员又会因C++缺少了某些“理所当然”的特性而感到失望[2]。空穴来风,非是无因。作为C++开发者,在享受这门语言所带来的便利的同时,我们也应当承认它确实存在不尽如人意的地方。世上没有完美的东西,编程语言也如此。作为一名程序员,我们能做的就是用技术来改善不如意的方面,让自己的编程生活变得惬意一些。

正是从这一点出发,本章设计并实现了两个数据结构:异类词典与policy模板。它们都可以被视为一种容器,可以通过键来查询相应的值。但与我们经常使用的运行期容器(如std::map)不同,这两个数据结构中的键是编译期元数据。

与运行期容器相比,这两个数据结构有各自的优势与劣势。数据结构根据其特性的不同,应用场景也不一样。接下来,让我们从“具名参数”这个议题出发,来讨论它们的应用场景与各自的特点。

很多编程语言都支持在函数调用时使用类似具名参数的概念。具名参数最大的优势就在于能为函数调用提供更多信息。考虑如下的C++函数(它实现了一个插值计算):

1    float fun(float a, float b, float weight)
2    {
3        return a * weight + b * (1 - weight);
4    }

在调用这个函数时,如果将3个参数的顺序搞错,那么将得到完全错误的结果,但函数的3个参数的类型相同,因此编译器并不能发现这样的错误。

使用具名参数进行函数调用,可以在一定程度上缓解上述错误的发生。考虑如下代码:

1    fun(1.3f, 2.4f, 0.1f);
2    fun(weight = 0.1f, a = 1.3f, b = 2.4f);

其中的第2行就是一种具名函数调用。显然它比第1行更具可读性,同时更不容易出错。

但不幸的是,到目前为止,C++语言本身并不直接支持函数的具名调用,因此上述代码也是无法编译的。一种在C++中使用具名函数的方式是通过类似std::map这样的映射结构[3]

1    float fun(const std::map<std::string, float>& params) {
2        auto a_it = params.find("a");
3        auto b_it = params.find("b");
4        auto weight_it = params.find("weight");
5    
6        return (a_it->second) * (weight_it->second) +
7               (b_it->second) * (1 - (weight_it->second));
8    }
9    
10    int main() {
11        std::map<std::string, float> params;
12        params["a"] = 1.3f; params["b"] = 2.4f;
13        params["weight"] = 0.1f;
14    
15        std::cerr << fun(params); // 调用
16    }

这段代码并不复杂:在调用函数之前,我们使用params构建了一个参数映射,这个结构的构建过程等价于为参数指定名称的过程。在函数体内部,我们则通过访问params中的键(字符串类型)来获取相应的参数值。每个参数的访问都涉及参数名称的显式调用,因此与本章一开始所给出的版本相比,这样的代码相对来说出错的可能性会小很多。

使用诸如std::map这样的容器,可以减少函数参数传递过程中出现错误的可能性。但这种方式也有相应的缺陷:参数的存储与获取涉及键的查询,而这个查询的过程是在运行期完成的,这需要付出相应的运行期的成本。以上述代码为例:在程序的2~4行获取参数,以及6~7行对迭代器解引用都需要运行期计算来完成。虽然相应的计算时间可能并不算长,但与整个函数的主体逻辑(浮点运算)相比,参数获取还是占了很大的比例的。如果fun函数被多次调用,那么键与值的关联所付出的成本就会成为不可忽视的一块了。

仔细分析具名参数的使用过程,可以发现这个过程中的一部分可以在编译期完成。具名参数的本质是建立一个键到数值的映射。对于确定的函数,所需要的键(参数)也就确定了。因此,键的相关操作完全可以放到编译期来处理,而参数值的相关操作则可以留待运行期完成。

std::map的另一个问题是值的类型必须一致。对于之前的例子,传入的参数都是浮点数,此时可以用std::map进行键值映射。但如果函数所接收的参数类型不同,使用std::map就会困难许多——通常需要通过派生的手段为不同的值类型引入基类,之后在std::map中保存基类的指针。这样会进一步增加运行期的成本,同时不便于维护。

参数解析是高级语言中一项很基本的功能。这一章将描述两种结构,以改进上面这种纯运行期的解决方案——在引入具名操作的同时尽量减少由此而引入的运行期成本,同时能更好地配合元编程使用。这两种结构均将作为辅助模块用于后续的深度学习框架中。

我们要引入的第一个模块是异类词典VarTypeDict。它一个容器,按照键-值对来保存与索引数据。这里的“异类”是指容器中存储的值的类型可以是不同的。比如,可以在容器中保存一个double型的对象与一个std::string型的字符串。同时,容器中用于索引对象的键是在编译期指定的,基于键的索引工作也主要在编译期完成。

在着手构造任何一个模块前,需要对这个模块的使用方式有所预期:用户该如何调用这个模块来实现相应的功能?这是在编写代码前需要首先考虑的。因此,在讨论具体实现之前,我们首先给出这个模块的调用接口。

该模块的调用示例如下:

1    // 声明一个异类词典 FParams
2    struct FParams : public VarTypeDict<A, B, Weight> {};
3    
4    template <typename TIn>
5    float fun(const TIn& in) {
6        auto a = in.template Get<A>();
7        auto b = in.template Get<B>();
8        auto weight = in.template Get<Weight>();
9    
10        return a * weight + b * (1 - weight);
11    }
12    
13    int main() {
14        std::cerr << fun(FParams::Create()
15                         .Set<A>(1.3f)
16                         .Set<B>(2.4f)
17                         .Set<Weight>(0.1f));
18    }

这并非完整的示例代码,但也相差无几了。代码的第2行定义了结构体FParams,它继承自VarTypeDict,用来表示fun函数所需要的参数集。VarTypeDict是本节要实现的模块。而这一行的定义表示:FParams中包含3个参数,分别命名为A、B与Weight(A、B与Weight是元数据,后文会给出其具体的定义)。

fun函数接收的参数是异类词典容器的实例,类似于前文中的std::map,可以从其中获取相应的参数值。在此基础上,第10行调用函数的核心逻辑计算并返回。需要说明的是:函数所接收的参数类型TIn并非FParams,而是一个与FParams相关的类型。异类词典的使用者不需要关心fun函数的具体输入类型,只需要知道可以通过这个输入类型调用Get获取相应的参数值即可。

在14~17行的main函数中,我们调用了fun函数并打印出相应的函数返回值。这里使用了一种称为“闭包”的语法[4],这看上去与一般的程序写法有些不同,但并不难懂。14~17行中的含义依次为:构造一个容器来保存数据(Create),放入A所对应的值1.3f,放入B所对应的值2.4f,放入Weight所对应的值0.1f。

具名参数的一个好处就是可以交换参数的顺序而不影响程序的执行结果。考虑如下代码:

1    std::cerr << fun(FParams::Create()
2                             .Set<B>(2.4f)
3                             .Set<A>(1.3f)
4                             .Set<Weight>(0.1f));

这将得到与上文调用完全相同的结果。

在前文中,我们使用了派生的方法定义了FParams。事实上,这一行的代码还可以改得更简单:

1    using FParams = VarTypeDict<A, B, Weight>;

此时,FParams只是VarTypeDict<A, B, Weight>的别名而已。无论是采用派生的方法,还是这种通过using引入类型别名,所引入的FParams都能完成前文所述的异类词典的功能。

从原理上来看,上述代码段与使用std::map类似,都是将参数打包传递给函数,由函数解包使用。但二者是有本质上的差别的。首先,VarTypeDict使用了元编程,相应的,程序的6~8行获取参数值的操作主要是在编译期完成的——它只会引入很少的运行期成本。

其次,如果我们忘记为一个键赋予相应的值:

1    std::cerr << fun(FParams::Create()
2                             .Set<B>(2.4f)
3                             .Set<A>(1.3f);

那么程序将出现编译错误。

考虑一下,使用std::map用做具名参数的载体时,少提供一个参数会出现什么后果?此时,不会出现编译错误,但会出现运行错误。与运行期出错相比,编译期出错总是要好一些的。错误发现得越早,解决起来相对就越容易。

最后,我们可以在容器中放置不同类型的数据,比如可以对程序进行简单的改写以实现另一个功能:

1    struct FParams : public VarTypeDict<A, B, C> {};
2    
3    template <typename TIn>
4    float fun(const TIn& in) {
5        auto a = in.template Get<A>();
6        auto b = in.template Get<B>();
7        auto c = in.template Get<C>();
8    
9        return a ? b : c;
10    }
11    
12    fun(FParams::Create().Set<A>(true).Set<B>(2.4f).Set<C>(0.1f));

在这段程序中,fun根据传入参数A的布尔值来决定返回B或C中的一个。fun所接收的参数类型不再相同。如果使用std::map这样的构造,那么就必须引入一个基类作为容器所存储的值的基本类型。但使用VarTypeDict,我们就无需考虑这个问题——VarTypeDict天生就是支持异类类型的。

上述代码的核心就是VarTypeDict,我们希望实现这样一个类,来提供上述的全部功能。VarTypeDict的本质是一个容器,包含了若干键-值对映射,这一点与运行期的std::map很类似,只不过它的键是编译期的常量。因此,在讨论VarTypeDict的具体实现前,有必要思考一下该用什么来表示键的信息。

VarTypeDict中的键是一个编译期常量。对于定义:

1    struct FParams : public VarTypeDict<A, B, Weight> {};

我们需要引入编译期常量来表示A,B与Weight。那么,该用什么作为其载体呢?我们有很多选择,最典型的就是整型常量(比如int)。比如,可以定义:

1    constexpr int A = 0;

B与Weight的定义类似。但这里有个数值冲突的问题。比如我们在某处定义了A=0,在另外一处定义了B=0。现在想在某个函数中同时使用A与B。那么在调用Set(或Get)时,如果传入A(即数值0),编译器无法知道是对二者中的哪一个进行设置(或读取)。为了防止这种情况的产生,我们需要一种机制来避免定义值相同的键。这种机制本身就增加了代码的维护成本。比如,在多人同时开发时,可能需要分配每个人能够使用的键值区域,避免出现冲突;在代码的维护过程中,也需要记录哪些键值已经被使用过了,哪些还可以使用;而在开发了一段时间后,可能需要对已经使用的键值进行调整,比如让含义相近的键所对应的键值相邻,这可能会导致很多键的调整。随着代码量的增加,模块逐渐复杂化,这种映射所需要的维护成本也将越来越大。

整数的问题在于它的描述性欠佳:无法从其字面值中了解到对应的键的含义。要解决这个问题,我们很自然地就会想到使用字符串。字符串具有很好的描述性——其含义从字面信息上看一目了然。确保字符串不出现冲突也比确保整数不出现冲突也要容易些。对于不同的参数,我们总可以通过引入限定词,将其含义尽量准确地描述出来,将其与其他参数的差异表述出来。而这种限定信息可以很方便地加到字符串中,以避免冲突。那么,使用字符串作为键怎么样?

不幸的是,目前来说,C++在编译期对字符串支持得并不好。使用字符串作为VarTypeDict的模板参数并非一个很好的选择。

事实上,C++并非不支持使用字符串作为模板参数,但能够作为模板参数的字符串的类型是有限的,诸如std::string这样的数据类型是不能作为模板参数的。通常来说,如果我们希望以字符串作为模板参数,那么所使用的是诸如“Hello”这样的字符串字面值。

字符串字面值可以作为非类型模板参数。非类型模板参数有两种声明方式,采用引用的方式,或者采用值的方式。如果采用引用的方式声明字符串,字符串的长度信息会被视为其类型信息的一部分。此时,“Hello”与“C++”会被视为不同类型,因为二者长度不同。

如果采用值的方式声明,字符串字面值的类型会被蜕化为相应的指针类型,比如“Hello”与“C++”都会被视为const char。我们可以引入接收const char为参数的模板,并为其传入字符串:

1    template <const char* info>
2    struct Temp;
3    
4    constexpr char a[] = "Hello";
5    using Res = Temp<a>;

此时,Temp实例所对应的模板参数并非字符串,而是指向该字符串的指针。这就会造成一个问题,我们可能构造两个内容相同的字符串,指向不同的地址,且由此构造出的Temp实例可能是不同的:

1    template <const char* info>
2    struct Temp;
3    
4    constexpr char a[] = "Hello";
5    constexpr char a2[] = "Hello";
6    using Res = Temp<a>;
7    using Res2 = Temp<a2>;

在上面的代码中Res与Res2的类型相同吗?不一定。这要看编译器是否让a与a2指向相同的地址了。如果编译器发现a与a2的内容相同,那么它可能引入优化,让二者指向相同的地址,这就会使得Res与Res2的类型相同;反之,如果编译结果中a与a2指向不同的地址,那么Res与Res2的类型就不同了。

如果要在VarTypeDict中使用字符串作为键,那么我们就需要在某个地方统一定义这些字符串字面值(比如在一个CPP文件中引入前文中的a与a2这样的常量),之后在实例化模板时,使用这样的常量作为模板参数。与使用整数的方式类似,这同样会增大模板定义的复杂性,不利于扩展。

字符串的这些问题使得它不适合用作VarTypeDict中的键。正如前文所述,编译期不同字符串之间比较起来相对困难,类型存在差异,这些特性导致了它在处理起来是相对麻烦的,因此通常来说,我们会尽量避免在编译期使用字符串以及字符串的相关操作。

那么要用什么作为键呢?事实上,为了能基于其索引到相应的值,这里的键只需要支持“等于”判断即可。有一个很天然的东西可以支持等于判断:那就是类(或者结构体)的名字。这里正是使用了结构体的名字作为键。在上例中,对A、B与Weight的声明如下:

1    struct A; struct B; struct Weight;
2    
3    struct FParams : public VarTypeDict<A, B, Weight> {};

这里的A、B与Weight只是用作键来使用,程序并不需要其定义,因此也就没有必要引入定义——只是给出声明即可。

A、B与Weight在上述程序的第1行与第3行出现了两次,我们可以进一步简化,将二者合并起来:

1    // struct A; struct B; struct Weight; 去掉这一行
2    struct FParams : public VarTypeDict<struct A,
3                                        struct B,
4                                        struct Weight> {};

这样整个程序就能看上去更加清晰。

VarTypeDict包含了异类词典的核心逻辑,这一节将分析它的实现。

外围框架

VarTypeDict的外围框架代码如下所示:

1    template <typename...TParameters>
2    struct VarTypeDict
3    {
4        template <typename...TTypes>
5        struct Values {
6        public:
7            template <typename TTag, typename TVal>
8            auto Set(TVal&& val) && ;
9    
10            template <typename TTag>
11            const auto& Get() const;
12        };
13    
14    public:
15        static auto Create() {
16            using namespace NSVarTypeDict;
17            using type = typename Create_<sizeof...(TParameters),
18                                          Values>::type;
19            return type{};
20        }
21    };

VarTypeDict是个类模板,包含静态函数Create,该函数会根据VarTypeDict传入的模板参数构造一个类型type,之后返回这个类型所对应的对象。

Create返回的对象实际上是Values<TTypes...>的实例。Values是位于VarTypeDict内部的一个模板,它提供了Set与Get函数。因此,对于之前的代码:

1    std::cerr << fun(FParams::Create()
2                             .Set<B>(2.4f)
3                             .Set<A>(1.3f)
4                             .Set<Weight>(0.1f));

第1行中的Create相当于构造了Values<TTypes...>类型的变量,后面几个Set则相当于向Values<TTypes...>中传入数据。

Values定义于VarTypeDict内部,有自己的模板参数TTypes。TTypes与TParameters一样,都是变长模板。事实上,它们内部均保存了类型信息。TParameters中保存的是键,而TTypes中保存的是相应的数值类型。比如,对于以下代码:

1    VarTypeDict<A, B, C>::Create()
2                         .Set<A>(true).Set<B>(2.4f).Set<C>(0.1f);

执行后所构造的对象为:VarTypeDict<A, B, C>::Values<bool, float, float>。

让我们先看一下Create函数的具体实现。

Create函数的实现

Create函数是整个模块中首个对外的接口,但这个接口在实现时有一个问题。考虑如下代码:

1    VarTypeDict<A, B>::Create()
2                      .Set<A>(true).Set<B>(2.4f);

Create返回的是Value<TTypes...>的实例。这个实例最终需要包含容器中每个值所对应的具体数据类型。对于这段代码,理想的情况是,Create函数执行完成时,返回的是Value<bool,float>类型的实例。后续Set会依据这个信息来设置数据。但程序是从前向后执行的。在执行Create时,系统无法知道要设置的数值类型(bool,float类型),它该怎么为TTypes赋值呢?

有几种方式可以解决这个问题。比如,我们可以改变接口设计,将这一部分信息提前。在调用Create之前就提供了这个信息。但如果采用这样的设计,模块的调用者就需要显式提供这一部分信息,比如,按照如下的方式来调用这个接口:

1    VarTypeDict<A, B, bool, float>::Create()
2                                   .Set<A>(true).Set<B>(2.4f);

这增加了调用者的负担,同时也增加了程序出错的可能性(考虑如果在VarTypeDict中写错了值类型,会出现什么问题):它并不是一个好的解决方案。

一个较好的处理方式是引入一个“占位类型”:

1    struct NullParameter;

在Create函数调用之初,用这个占位符类型填充TTypes,在之后的Set中,再来修改这个类型为实际的类型。还是以之前的代码为例(其中每一步调用后都给出了相应的返回类型):

1    VarTypeDict<A, B>
2        ::Create()      // Values<NullParameter, NullParameter>
3         .Set<A>(true)  // Values<bool, NullParameter>
4         .Set<B>(2.4f); // Values<bool, float>

基于这样的思想,实现Create函数如下:

1    namespace NSVarTypeDict
2    {
3    template <size_t N, template<typename...> class TCont, typename...T>
4    struct Create_ {
5        using type = typename Create_<N - 1, TCont,
6                                     NullParameter, T...>::type;
7    };
8    
9    template <template<typename...> class TCont, typename...T>
10    struct Create_<0, TCont, T...> {
11        using type = TCont<T...>;
12    };
13    }
14    
15    template <typename...TParameters>
16    struct VarTypeDict {
17        // ...
18    
19        static auto Create() {
20            using namespace NSVarTypeDict;
21            using type = typename Create_<sizeof...(TParameters),
22                                         Values>::type;
23            return type{};
24        }
25    };

函数的主体逻辑实际上位于名字空间NSVarTypeDict里面的Create_中[5]。而Create内部调用了元函数Create_,传入参数,获取它的返回结果(类型),使用该类型构造一个对象并返回。

Create_本身实现了一个循环逻辑。它包含了两部分,前者(3~7行)是原始模板(Primiary Template),它接收3个参数:

在其内部,它会构造一个NullParameter的类型并放到类型数组中,将N减1,之后进行下一次迭代。

Create_的另一个特化(9~12行)表示N=0时的情形,也就是循环终止的情形。此时,系统直接返回TCont<T...>这个类型数组。

Create函数内部调用了Create_函数,传入TParameter的大小[6],同时传入数组容器Values,以保存类型计算的结果。这里有两点需要注意:首先,Values与Create均定义于VarTypeDict内部,因此在Create中使用Values时,无需指定其外围类VarTypeDict;其次,Create调用Create_函数时,只提供了两个模板参数,此时Create_中的T...将对应一个空的类型序列,这是C++标准所允许的。

Create用于构造初始的类型数组,以之前的代码为例:

1    VarTypeDict<A, B, C>::Create();

将构造出Values<NullParameter, NullParameter, NullParameter>,这个新构造出的类型将提供Set与Get接口。

Values的主体框架

Values的主体逻辑如下[7]

1    template <typename...TParameters>
2    struct VarTypeDict
3    {
4      template <typename...TTypes>
5      struct Values
6      {
7          Values() = default;
8  
9          Values(std::shared_ptr<void>(&&input)[sizeof...(TTypes)])
10          {
11              for (size_t i = 0; i < sizeof...(TTypes); ++i)
12              {
13                  m_tuple[i] = std::move(input[i]);
14              }
15          }
16  
17      public:
18          template <typename TTag, typename TVal>
19          auto Set(TVal&& val) &&
20          {
21              using namespace NSMultiTypeDict;
22              constexpr static size_t TagPos = Tag2ID<TTag, TParameters...>;
23  
24              using rawVal = std::decay_t<TVal>;
25              rawVal* tmp = new rawVal(std::forward<TVal>(val));
26              m_tuple[TagPos] = std::shared_ptr<void>(tmp,
27                  [](void* ptr) {
28                  rawVal* nptr = static_cast<rawVal*>(ptr);
29                  delete nptr;
30              });
31
32              using new_type = NewTupleType<rawVal, TagPos, Values<>, TTypes...>;
33              return new_type(std::move(m_tuple));
34          }
35
36          template <typename TTag>
37          auto& Get() const;
38
39      private:
40          std::shared_ptr<void> m_tuple[sizeof...(TTypes)];
41      };
42  };

这里同时列出了Set的逻辑,并将对其进行分析。除Set之外,Values还提供了Get接口。但该接口比较简单,因此分析的工作就留给读者了。

Values是定义在VarTypeDict内部的类,因此,VarTypeDict的模板参数对Values也是可见的。换句话说,在Values内部,一共可以使用两套参数:TParameters与TTypes。这两套参数是两个等长的数组,前者表示键,后者表示值的类型。

Values内部核心的数据存储区域是一个智能指针数组m_tuple(第40行)。其中的每个元素都是一个void的智能指针。void类型的指针可以与任意类型的指针相互转换,因此在这里使用其存储参数地址。

Values的默认构造函数无需进行任何操作。另一个构造函数接收另一个智能指针数组作为输入,将其复制给m_tuple,这主要是供Set调用。

Values::Set是函数模板,它接收两个模板参数,分别表示了键(TTag)与值的类型(TVal)。根据C++中函数模板的自动推导规则,将TVal作为第二个模板参数,这样在调用该函数时,只需提供TTag的模板实参(编译器可以推导出第二个实参的类型信息)。也即,假定x是一个Values类型的对象,那么:

1    x.Set<A>(true);

调用时,TTag将为A,TVal将自动推导为类型bool。

Values::Set同样调用了几个位于NSVarTypeDict中的元函数来实现内部逻辑。对于传入的参数。它的处理流程如下。

1.调用NSVarTypeDict::Tag2ID获取TTag在TParameters中的位置,保存于TagPos中(第22行)。

2.调用std::decay对TVal进行处理,用于去除TVal中包含的const、引用等修饰符。之后使用这个新的类型在堆中构造一个输入参数的复本,并将该复本的地址放置到m_tuple相应的位置上(24~30行)。

3.因为传入了新的参数,所以要相应地修改Values中的TType类型,调用NSVar TypeDict::NewTupleType获取新的类型,并使用这个新的类型构造新的对象并返回(第32~33行)。

NewTupleType逻辑分析

限于篇幅,这里仅对NewTupleType进行分析,而Tag2ID的逻辑则留给读者自行分析。

假定对Values<X1, X2, X3>调用Set时,更新的是数组中的第二个值。而新传入的数据类型为Y,那么为了记录这个信息,我们需要构造一个新的类型Values<X1, Y, X3>。这本质上是一个扫描替换的过程:扫描原有的类型数组,找到要替换的位置,将新的类型替换掉。NewTupleType实现了这个功能。

NewTupleType调用了NewTupleType_来实现其逻辑。而NewTupleType_的声明如下(与Create_类似,这个函数也是依次对数组中的每个元素进行处理):

1    template <typename TVal, size_t N, size_t M,
2              typename TProcessedTypes,
3              typename... TRemainTypes>
4    struct NewTupleType_;

其中TVal为替换的目标数据类型;N表示目标类型在类型数组中的位置;TProcessedTypes为一个数组容器,其中包含了已经完成扫描的部分;而TRemainTypes中包含了还需要进行扫描替换的部分;M是一个辅助变量,表示已经扫描的类型个数。

除了上述声明,NewTupleType_一共提供了两个特化版本,它们共同组成了一个循环处理的逻辑。第一个特化版本扫描数组的前半部分,如下:

1    template <typename TVal, size_t N, size_t M,
2              template <typename...> class TCont,
3              typename...TModifiedTypes,
4              typename TCurType,
5              typename... TRemainTypes>
6    struct NewTupleType_<TVal, N, M, TCont<TModifiedTypes...>,
7                         TCurType, TRemainTypes...>
8    {
9        using type =
10            typename NewTupleType_<TVal, N, M + 1,
11                                   TCont<TModifiedTypes..., TCurType>,
12                                   TRemainTypes...>::type;
13    };

它描述的是N!=M的情况。该特化使用TCont<TModifiedTypes...>来表示已经完成替换扫描的类型;使用TCurType与TRemainTypes一起表示未完成替换扫描的类型(其中TCurType表示当前处理的类型)。因为N!=M,所以只需要简单地将TCurType放入TCont容器中,继续处理下一个类型。

如果N==M,那么编译器将采用下一个特化:

1    template <typename TVal, size_t N,
2              template <typename...> class TCont,
3              typename...TModifiedTypes,
4              typename TCurType,
5              typename... TRemainTypes>
6    struct NewTupleType_<TVal, N, N, TCont<TModifiedTypes...>,
7                         TCurType, TRemainTypes...>
8    {
9        using type = TCont<TModifiedTypes..., TVal, TRemainTypes...>;
10    };

此时已经找到了要替换的元素,系统要做的就是用TVal替换TCurType放到已完成替换扫描的容器中,同时将其后续的类型也放到该容器中,返回包含了新类型数组的容器。

NewTupleType调用NewTupleType_来实现其逻辑,它本质上只是NewTupleType_的一个外壳:

1    template <typename TVal, size_t TagPos,
2              typename TCont, typename... TRemainTypes>
3    using NewTupleType
4        = typename NewTupleType_<TVal, TagPos, 0, TCont,
5                                 TRemainTypes...>::type;

以上就是对VarTypeDict核心代码的分析。整个模块虽然看起来比较复杂,但本质上并没有脱出第1章所讨论的顺序、分支、循环的路子,只要仔细分析,其中蕴含的逻辑并不难以理解。

限于篇幅,这里并没有列出并分析VarTypeDict所涉及的全部元函数与逻辑,而是将一些类似的分析留在练习中,供读者自行体会。建议读者认真完成其余部分的程序分析,只有通过不断地练习,才能更好地掌握元函数的编写方法。

通过前文的分析,不难看出:从本质上来说,VarTypeDict维护了一个映射,即将编译期的键映射为运行期的数值。仅从这一点上来看,它与本章最初提到的std::map在功能上并没有太大的区别。但std::map以及类似的运行期构造在使用过程中会不可避免地产生过多的运行期成本。比如:在向其中插入一个元素时,std::map需要通过键的比较来确定插入位置,这个比较过程需要占用运行期的计算量。VarTypeDict类所实现的Set函数也需要这种类似的查找工作,但相应的代码为:

1    constexpr static size_t TagPos = Tag2ID<TTag, TParameters...>;

这是在编译期就完成了的,并不占用运行期的时间。而如果编译系统足够智能,那么中间量TagPos也会被优化掉——不会占用任何内存。这些都是运行期的等价物无法比拟的。

类似地,Get函数也能从编译期计算中获益。

当手头有一把锤子时,我们往往会把一切事物看成钉子。很多人都是如此,程序员也不能免俗。对于元编程的初学者来说,当我们体会到元编程的好处后,可能会希望将代码的每个部分都用元编程相关的技术来实现,美其名曰利用编译期计算来减少运行成本。

占用运行期成本的一种典型构造就是指针:它需要一个额外的空间来保存地址,在使用时需要解引用来获取实际的数值。在VarTypeDict中,我们使用了void型的指针数组来保存传入的数值。一个很直接的想法就是把它去掉,利用编译期计算进一步减少成本。

实际上,VarTypeDict中的指针数组在编译期也是有其替换物的,典型的就是std::tuple。对于原始程序中的声明:

1    std::shared_ptr<void> m_tuple[sizeof...(TTypes)];

可以修改为:

1    std::tuple<TType...> m_tuple;

这样似乎能减少指针实现中的内存分配与回收操作,提升程序的速度。但事实上,这并不是什么好主意。

避免使用std::tuple的主要原因是Set中的更新逻辑。按照之前的分析,TType...维护了当前的值类型,每一次调用Set,相应的TType...也会发生改变。如果使用std::tuple<TType...>作为m_tuple的类型,那么每次更新时,m_tuple的类型也会发生改变。

仅仅是类型上的改变并不会造成多大的影响,但问题是:我们需要将更新前的数值数组赋予更新后的数值数组。两个数组的类型不同,因此赋值操作就需要对数组中的每个元素逐一拷贝或移动。如果VarTypeDict中包含了N个元素,那么每次调用Set,就需要对N个元素拷贝或移动一次。为了设置全部的参数值,整个系统就需要调用N(N−1)次拷贝或移动。这些工作是在运行期完成的,所引入的成本可能会大于使用指针所产生的运行期成本。同时,为了进行这样的移动,我们还需要引入一些编译期的逻辑。因此,无论从哪个角度上来说,使用std::tuple作为值的存储空间都是不合算的。

异类词典可以被视为一种容器,使用键来索引相关的值。其中的键是编译期的常量,而值则可以是运行期的对象。异类词典的这种特性使得其可以在很多场景下得到应用。典型地,可以构造异类词典的对象作为函数参数。

另外,正是因为异类词典的值是运行期对象,这也会使得它无法应用于某些特定的场景之中。相比函数来说,模板也会接收参数,但模板所接收的参数值都是编译期的常量。同时,除了数值对象,模板参数还可以是类型或者模板——这些是异类词典本身无法处理的。在本节中,我们考虑另一种构造:policy模板,来简化模板参数的输入。

考虑如下情形,我们希望实现一个类,对“累积(accumulate)”这个概念进行封装:典型的累积策略包括连加与连乘。而这些策略除了具体的计算方法有所区别,调用方式相差无几。为了最大限度地代码复用,我们考虑引入一个类模板,来封装不同的行为:

1    template <typename TAccuType> struct Accumulator { /* ... */ };

在这个定义中,TAccuType表示采用的“累积”策略。在Accumulator内部,可以根据这个参数的值选择适当的处理逻辑。

事实上,仅就累积类而言,还可能有其他的选项。比如,我们可能希望这个类除了能进行累积,还能对累积的结果求平均值。进一步,我们希望能够控制该类是否进行平均操作。还有,我们还希望控制计算过程中使用的数值的类型,等等。基于上述考虑,将之前定义的Accumulator模板扩展如下:

1    template <typename TAccuType, bool DoAve, typename ValueType>
2    struct Accumulator { /* ... */ };

该模板包含了3个参数,这种作用于模板、控制其行为的参数称为policy(策略)[8]。每个policy都表现为键值对,其中的键与值都是编译期常量。每个policy都有其取值集合。比如,对于上例来说,TAccuType的取值集合为“连加”“连乘”等;DoAve的取值集合为true与false;而ValueType可以取float、double等。

通常来说,为了便于模板的使用,我们会为其中每个的policy赋予默认值,对应了常见的用法,比如:

1    template <typename TAccuType = Add, bool DoAve = false,
2              typename ValueType = float>
3    struct Accumulator { /* ... */ };

这表示Accumulator在默认情况下使用连加进行累积,不计算平均值,使用float作为其返回类型。类的使用者可以按照如下方式使用Accumulator的默认行为:

1    Accumulator<> ...

这种调用形式能满足一般意义上的需求,但在某些情况下,我们需要改变默认的行为。比如,如果要将计算类型改为double,那么就需要按照如下方式声明:

1    Accumulator<Add, false, double> ...

这表示将值类型由默认的float变为double,其他policy不变。

这种设置方式有两个问题。首先,位于double前的Add与false是不能少的,即使它们等于默认值,也是如此——否则编译器会将TAccuType与double匹配,产生无法预料的结果(通常来说是编译错误)。其次,只看上述声明的话,对Accumulator不熟悉的人可能很难搞清楚这些参数的含义。

如果能在设置模板参数时显式为每个参数值命名,那么情况就会好很多,比如,假设我们能这么写:

1    Accumulator<ResType = double> ...

那么设置的含义就会一目了然。但事实上,C++不直接支持具名的模板参数。上述语句不符合C++标准,会导致编译错误。

虽然不能像上面那样书写成“键=值”的形式,但我们可以换一种C++标准接受的形式。本书将这种形式称为“policy对象”。

policy对象

每个policy对象都属于某个policy,它们之间的关系就像C++中的对象与类那样。policy对象是编译期常量,其中包含了键与值的全部信息,同时便于阅读。典型的policy对象形式为:

1    PMulAccu // 采用连乘的方式进行累积
2    PAve     // 求平均

本书中定义的policy对象以大写字母P开头。使用者可以根据其名称,一目了然地明确该对象所描述的policy的含义。对于支持policy对象的模板,可以非常容易地改变其默认行为。比如,假定前文中讨论的Accumulator类模板支持policy对象,那么我们可以按照如下的方式来编写代码:

1    Accumulator<PDoubleValueType>
2    Accumulator<PDoubleValueType, PAve>
3    Accumulator<PAve, PDoubleValueType>

其中的第1行表示:修改默认行为,采用double作为值的类型。第2行与第3行表示:采用double作为值的类型,同时进行求平均的操作。从声明中不难看出,将policy对象赋予模板时,其顺序是任意的。使用policy对象,我们就可以获得具名参数的全部好处。

policy对象模板

policy对象的构造与使用是分离的。我们需要首先构造出某个policy对象(比如PAve),并在随后声明Accumulator的实例时使用该对象(比如Accumulator<PAve>)。

这会引入一个问题:为了能够让policy的用户有效地使用policy对象,policy的设计者需要提前声明出所有可能的policy取值。比如,可以构造PAve与PNoAve来分别表示求平均与不求平均——这相当于对是否求平均的选项进行了枚举。对于一些情况来说,这种枚举是相对简单的——比如对于是否求平均的问题,只需要枚举两种情况即可;但另一些情况下,枚举所有可能的取值以构造policy对象的集合则是不现实的。比如,我们在前文中定义了PDoubleValueType来表示累积的返回值为double类型。但如果要支持其他的返回值类型,就需要引入更多的policy对象,比如PFloatValueType、PIntValueType等,且枚举出所有的情况往往是不现实的。为了解决这个问题,我们引入了policy对象模板,它是一个元函数,可以传入模板参数以构造policy对象。比如,我们可以构造policy对象模板来表示保存计算结果的类型:

1    PValueTypeIs<typename T>

用户可以按照如下方式来使用该模板:

1    // 等价于 Accumulator<PDoubleValueType>
2    Accumulator<PValueTypeIs<double>>
3    
4    // 等价于 Accumulator<PDoubleValueType, PAve>
5    Accumulator<PValueTypeIs<double>, PAve>

使用policy对象模板,我们就将构造policy对象的时机移到了policy的使用之处。这样就无需为使用policy而提前准备大量的policy对象了。

有了policy对象之后,使用该对象的函数模板与类模板被称为policy模板[9]。《C++ Templates中文版》一书[10]的第16章给出了一种policy对象与policy模板的构造方式,有兴趣的读者可以阅读一下。但该书中的policy模板对其模板参数的个数有严格的限制——如果希望改变其能接收的最大policy对象的个数,那么整个构造就要从底层进行相应的调整。本节在其基础上提出了一种更加灵活的结构,我们将在后面分析其实现原理。但在深入其细节之前,还是让我们先看一下如何使用本书所提供的框架来引入PValueTypeIs、PAve这样的policy对象(模板)吧。

policy分组

一个实际的系统可能包含很多像Accumulator这样的模板,每个模板都要使用policy对象,一些模板还会共享policy对象。因此,只是简单地声明并使用policy对象,对于复杂系统来说还稍显不足。比较好的思路是将这些对象按照功能划分成不同的组。每个模板可以使用一个或几个组中的policy对象。

属于同一个policy,但取值不同的policy对象之间存在互斥性。比如,可以定义PAddAccu与PMulAccu分别表示采用连加的方式与连乘的方式进行累积。实例化累积对象时,我们只能从二者当中选择其一,而不能同时引入这两个policy对象——换句话说,如下代码是无意义的:

1    Accumulator<PAddAccu, PMulAccu>

为了描述policy对象所属的组以及互斥性,我们为其引入了两个属性:major class(主要类别)表示其所属的组;而minor class(次要类别)描述了互斥信息。如果两个policy对象的major class与minor class均相同,那么二者是互斥的,不能被同时使用。

在C++中,组的刻画方式有很多种,比如,可以将每个组放到单独的名字空间中,也可以将不同组的policy对象放置到不同的数组中——但这两者并不是很好的选择。因为policy对象将会参与到元函数的计算过程中,而C++中操作名字空间的元编程方法并不成熟;使用数组也不好,与异类词典中讨论的类似,使用数组,就可能要花较大的力气来维护数组索引与键之间的关系。

我们的方式是采用类(或者说,结构体)作为组的载体,组的内部定义了其中包含的policy信息。每个policy都是一个键值对,键与值都是编译期常量。与异类词典类似,我们同样采用类型声明来表示键,但对值则没有什么过多的要求:它可以是类型、数值甚至模板。唯一要注意的是:如上文讨论的那样,每个policy都有一个默认值。以下是一个简单的policy组的示例:

1    struct AccPolicy
2    {
3        struct AccuTypeCate
4        {
5            struct Add;
6            struct Mul;
7        };
8        using Accu = AccuTypeCate::Add;
9    
10        struct IsAveValueCate;
11        static constexpr bool IsAve = false;
12    
13        struct ValueTypeCate;
14        using Value = float;
15    };

这个policy组被命名为AccPolicy:顾名思义,其中包含了Accumulator所需要的policy。组里面包含了3个policy,它们刚好对应了3种常见的policy类别。

事实上,除了上述policy,我们还可以定义其他类型的policy,比如取值为模板的policy。但相对来说,上述3种policy最为常用。

在上述代码中,我们虽然引入了一些定义,但并没有从代码逻辑上将policy键与其对象的minor class关联起来。同时,读者可能发现了:policy的键与对象的minor class之间在名称上存在相关性。实际上,这种相关性是有意为之的。在本书中,我们约定:

宏与policy对象(模板)的声明

在定义了policy的基础上,我们可以进一步引入policy对象(模板)。本书提供了若干宏定义,使用它们可以很容易地定义policy对象(模板)[11]

1    TypePolicyObj(PAddAccu, AccPolicy, Accu, Add);
2    TypePolicyObj(PMulAccu, AccPolicy, Accu, Mul);
3    ValuePolicyObj(PAve, AccPolicy, IsAve, true);
4    ValuePolicyObj(PNoAve, AccPolicy, IsAve, false);
5    TypePolicyTemplate(PValueTypeIs, AccPolicy, Value);
6    ValuePolicyTemplate(PAvePolicyIs, AccPolicy, IsAve);

本书引入了4个宏:

在上面的代码段中,我们通过这4个宏定义了4个policy对象与2个policy对象模板:以第2行为例,它定义了一个编译期常量PMulAccu(其中的P表示policy,本书将采用这种命名方式定义policy对象)对应的major class与minor class分别为AccPolicy与AccuTypeCate,取值为AccuTypeCate::Mul[12]。而在第5行则定义了一个类型policy对象模板PValueTypeIs,其major class与minor class分别为AccPolicy与ValueTypeCate。读者可以按照相同的方式理解其余的定义。

这里有几点需要说明。

首先,采用宏的方式来定义policy对象(模板)并非必须的。这只是一种简写而已。我们会在后文中给出宏的实现细节。完全可以不使用宏,但宏可以大大简化对象的定义。

其次,上述6个对象中,有几个定义实际上再次描述了policy的默认值。引入这几个对象只是为了使用方便:用户在使用带policy的模板时,可以不引入policy对象而采用默认值,也可以引入某个对象显式地指定policy的取值——即使显式指定的值与默认值相同,这样做也是合法的。

最后,policy对象与policy对象模板并不冲突——我们完全可以为同一个policy既定义policy对象又定义policy对象模板。比如上面的代码段中,我们就同时定义了PAve与PAvePolicyIs,它们是兼容的。

在定义了policy之后,就可以使用它了。考虑下面的例子[13]

1    template <typename...TPolicies>
2    struct Accumulator
3    {
4        using TPoliCont = PolicyContainer<TPolicies...>;
5        using TPolicyRes = PolicySelect<AccPolicy, TPoliCont>;
6    
7        using ValueType = typename TPolicyRes::Value;
8        static constexpr bool is_ave = TPolicyRes::IsAve;
9        using AccuType = typename TPolicyRes::Accu;
10    
11    public:
12        template <typename TIn>
13        static auto Eval(const TIn& in)
14        {
15            if constexpr(std::is_same<AccuType,
16                                      AccPolicy::AccuTypeCate::Add>::value)
17            {
18                ValueType count = 0;
19                ValueType res = 0;
20                for (const auto& x : in)
21                {
22                    res += x;
23                    count += 1;
24                }
25    
26                if constexpr (is_ave)
27                    return res / count;
28                else
29                    return res;
30            }
31            else if constexpr (std::is_same<AccuType,
32                                            AccPolicy::AccuTypeCate::Mul>::value)
33            {
34                ValueType res = 1;
35                ValueType count = 0;
36                for (const auto& x : in)
37                {
38                    res *= x;
39                    count += 1;
40                }
41                if constexpr (is_ave)
42                    return pow(res, 1.0 / count);
43                else
44                    return res;
45            }
46            else
47            {
48                static_assert(DependencyFalse<AccuType>);
49            }
50        }
51    };
52    
53    int main() {
54        int a[] = { 1, 2, 3, 4, 5 };
55        cerr << Accumulator<>::Eval(a) << endl;
56        cerr << Accumulator<PMulAccu>::Eval(a) << endl;
57        cerr << Accumulator<PMulAccu, PAve>::Eval(a) << endl;
58        cerr << Accumulator<PAve, PMulAccu>::Eval(a) << endl;
59        //  cerr << Accumulator<PMulAccu, PAddAccu>::Eval(a) << endl;
60        cerr << Accumulator<PAve, PMulAccu,
61                            PValueTypeIs<double>>::Eval(a) << endl;
62        cerr << Accumulator<PAve, PMulAccu, PDoubleValue>::Eval(a) << endl;
63    }

Accumulator是一个接收policy的类模板,它提供了静态函数Eval来计算累积的结果。在程序的55~62行分别给出了若干调用示例。其中,第55行采用了默认的policy:累加、不平均、返回float——因为在Accumulator的声明中没有指定具体的policy对象,所以Accumulator会在其内部获取policy相关参数时使用policy在定义时指定的默认值。而第56与第57行则引入了非默认的policy对象进行计算——采用连乘的方式,分别进行不求平均与求平均的累积。第58行的输出与第57行完全一致:policy的设置顺序是可调换的。如果将第59行的注释去掉,那么编译将失败,系统将提示Minor class set conflict!——表示不能同时设置两个互相冲突的policy对象。

在上述代码段的60~61行,使用了之前定义的PValueTypeIs模板,传入double作为参数——这表示了使用double作为保存结果的类型。它的行为与第62行是一致的。

需要注意的是,虽然第57与第58行的输出完全一样,但进行计算的具体类型是不同的。也即Accumulator<PMulAccu, PAve>与Accumulator<PAve, PMulAccu>类型不同。这与上一节讨论的异类词典不同。对于上一节讨论的异类词典,我们可以改变Set的顺序,但最终得到的词典容器的类型不会发生改变。但如果改变了policy对象的顺序,则模板的实例化出的类型也会有所差别。

policy对象是如何改变模板的默认行为的呢?这就要深入到实现的细节当中才能了解。在此之前,让我们首先讨论一些背景知识——只有先理解了它们,才能进行实现细节的讨论。

在讨论policy模板的具体实现前,让我们首先了解一些背景知识,从而明晰其工作原理。考虑如下代码:

1    struct A { void fun(); };
2    struct B : A { void fun(); };
3    
4    struct C : B {
5        void wrapper() {
6            fun();
7        }
8    };

那么,当调用C类的wrapper函数时,该函数会调用A与B类中的哪个fun函数呢?

这并不是个很难回答的问题。根据C++中继承的规则,如果C中没有找到fun的定义,那么编译器会沿着C的派生关系寻找其基类、基类的基类等。直到找到名为fun的函数为止。在这个例子中,B::fun将被调用。

上述3个类的继承关系如图2.1所示。

图2.1 简单的单继承关系

这里使用实线箭头表示继承关系,箭头指向的方向为基类方向。在图中,B继承自A,二者定义了同名函数。此时,我们称B::fun支配(dominate)了A::fun。在搜索时,编译器会选择具有支配地位的函数。

另一种典型的支配关系发生在多继承的情况中,如图2.2所示。

图2.2 多继承中的支配关系

这里使用虚线箭头表示虚继承的关系,即

1    struct B : virtual A;
2    struct C : virtual A;

假定D::wrapper会调用fun函数。在这个图中,C继承了A中的fun函数,而B则重新定义了fun。相应地,B中新定义的函数更具有支配地位。因此D在调用时会选择B::fun。

注意对于多重继承的情况,只有采用虚继承时,上述讨论才是有效的。否则,编译器会报告函数解析有歧义。另外,即使使用了虚继承,如果对于图2.2来说,当C中也定义了函数fun时,编译器还是会报告函数解析有歧义——因为有两个处于支配地位的函数,它们之间并不存在支配关系,这也会使得编译器无从选择。

前文讨论了函数间的支配关系。实际上,支配关系不仅存在于函数之间。在类型与常量定义等方面,同样存在着类似的支配关系。

在了解了支配与继承的关系后,我们可以考虑一下policy对象的构造了。policy对象之所以能“改变”默认的policy值,实际上是因为它继承了定义的policy类,并在其自身定义中改变了原始的policy的值,即形成了支配关系。

比如,在给定AccPolicy的基础上,可以这样定义PMulAccu:

1    struct AccPolicy {
2        struct AccuTypeCate { struct Add; struct Mul; };
3        using Accu = AccuTypeCate::Add;
4        // ...
5    };
6    
7    struct PMulAccu : virtual public AccPolicy {
8        using MajorClass = AccPolicy;
9        using MinorClass = AccPolicy::AccuTypeCate;
10        using Accu = AccuTypeCate::Mul;
11    }

这里给出了PMulAccu的完整定义。其中,代码的8~9行定义了PMulAccu的major class与minor class,后文会讨论对这二者操作的元函数,目前可以不用太关心。我们只需要关注代码的第10行即可,这一行重新引入了Accu的值。根据支配关系,如果存在某个类X继承自PMulAccu,那么当在类X中搜索Accu的信息时,编译器将返回AccuEnum::Mul而非定义于AccPolicy中的默认值AccuEnum::Add。

一个policy模板可以接收多个policy对象,而policy模板的行为则是由这些policy对象共同决定的。基于policy模板所接收到的全部policy对象,可以通过元编程的手段构造一个policy的支配层次结构,如图2.3所示。

图2.3 policy支配结构

其中的PO_0~PO_n表示可以作为模板参数的,属于同一组的policy对象。它们均虚继承自相同的policy类:PolicyGroup。在此基础上,我们引入了PSR_0~PSR_n这几个外围类[14]。除了PSR_n,每个外围类都有两个基类;PSR_n则只有一个基类。如果PO_0~PO_n是相容的,即任意两个policy对象的minor class均不相同,那么从PSR_0出发,进行搜索时,对于属于该组的任意policy,一定能找到一个在支配性上没有歧义的定义。这个定义要么来自PolicyGroup类——这将对应policy的默认值;要么来自某个policy对象——这将对应某个非默认值。

在明确了这个结构之后,接下来的主要工作就是引入元函数,基于模板参数构造出该结构。

主体框架

整个policy模板的对外接口就是policy选择元函数:PolicySelect。回顾一下这个元函数的使用方式:

1    template <typename...TPolicies>
2    struct Accumulator {
3        using TPoliCont = PolicyContainer<TPolicies...>;
4        using TPolicyRes = PolicySelect<AccPolicy, TPoliCont>;
5    
6        using ValueType = typename TPolicyRes::Value;
7        static constexpr bool is_ave = TPolicyRes::IsAve;
8        using AccuType = typename TPolicyRes::Accu;
9    
10        // ...
11    }

程序的第4行调用了PolicySelect,即policy选择元函数。传入我们所关注的policy组信息AccPolicy,以及由模板接收到的policy对象所构成的policy数组TPoliCont。元函数返回的TPolicyRes就是图2.3所示的policy支配结构。在此基础上,6~8行使用了这个结构,获取了相应的policy参数值。

PolicyContainer是policy的数组容器,其声明与我们之前所见的编译期数组声明并没有什么不同:

1    template <typename...TPolicies>
2    struct PolicyContainer;

完全可以使用std::tuple或者其他的容器作为它的代替品。但使用这个容器声明,可以从名称上很容易地分辨出该数组的功能。

PolicySelect仅仅是元函数NSPolicySelect::Selector_的封装:

1    template <typename TMajorClass, typename TPolicyContainer>
2    using PolicySelect
3        = typename NSPolicySelect::Selector_<TMajorClass,
4                                             TPolicyContainer>::type;

它将参数传递给NSPolicySelect::Selector_,由其实现核心的计算逻辑。NSPolicySelect:: Selector_的定义如下:

1    template <typename TMajorClass, typename TPolicyContainer>
2    struct Selector_;
3    
4    template <typename TMajorClass, typename... TPolicies>
5    struct Selector_<TMajorClass, PolicyContainer<TPolicies...>> {
6        using TMF = typename MajorFilter_<PolicyContainer<>,
7                                          TMajorClass,
8                                          TPolicies...>::type;
9    
10        static_assert(MinorCheck_<TMF>::value,
11                      "Minor class set conflict!");
12    
13        using type = std::conditional_t<IsArrayEmpty<TMF>,
14                                        TMajorClass,
15                                        PolicySelRes<TMF>>;
16    };

通过引入模板特化,Select_限定其第2个参数只能是PolicyContainer类型的容器。在其内部,它做了3件事情。

在这3步中,第一步的逻辑本质上就是线性搜索,相对比较简单。相应的分析工作就留给读者完成了。我们直接看一下第二步的逻辑。

MinorCheck_元函数

前文已经强调过:作为同一模板参数的policy对象的minor class不能相同。否则,这是不合逻辑的,编译器也会因此遇到解析出现歧义的情形,从而给出编译错误。但既然编译器已经能给出错误提示了,为什么还要在这里进行检测呢?事实上,编译器给出的错误提示是“解析出现歧义”,并没有明确地表示出这种歧义产生的原因——policy对象出现了冲突。因此,在这里有必要引入一次额外的检测,给出更明确的信息。

上述代码的10~11行完成了这个检测。它调用MinorCheck_函数,传入policy对象数组,获得该函数的返回值(布尔类型编译期常量)。并使用了C++ 11中引入的static_assert进行检测。static_assert是一个静态断言,接收两个参数:当第一个参数为false时,将产生一个编译错误,输出第二个参数提供的错误信息。

MinorCheck_元函数的功能就是检测输入的policy对象数组(这个数组中的每个元素都属于相同的policy组),判断其中的任意两个元素是否具有相同的minor class,如果不存在,则返回true,否则返回false。

考虑一下,如果在运行期该如何解决这个问题。相应的算法并不复杂,用一个二重循环就可以了:

1    for (i = 0; i < VecSize; ++i) {
2        for (j = i + 1; j < VecSize; ++j) {
3            if (Vec[i] and Vec[j] have same minor class)
4            {
5                return false;
6            }
7        }
8    }
9    return true;

上述代码已经很说明问题了:在外层循环中,我们依次处理数组中的每个元素,通过内层循环将其与位于它后面的元素进行比较,只要发现存在相同minor class的情况,就返回false。如果整个比较完成后,还是没有发现这样的情况,那么就返回true。

元函数的实现逻辑也没有什么本质上的不同:引入一个类似的二重循环就行了。只不过编译期与运行期的循环写起来有一些差异,导致它看上去有些复杂罢了。

1    template <typename TPolicyCont>
2    struct MinorCheck_ {
3        static constexpr bool value = true;
4    };
5    
6    template <typename TCurPolicy, typename... TP>
7    struct MinorCheck_<PolicyContainer<TCurPolicy, TP...>> {
8        static constexpr bool cur_check
9            = MinorDedup_<typename TCurPolicy::MinorClass,
10                          TP...>::value;
11    
12        static constexpr bool value
13            = AndValue<cur_check,
14                       MinorCheck_<PolicyContainer<TP...>>>;
15    };

MinorCheck_接收一个名为TPolicyCont的policy对象数组,通过特化构成外层循环。

首先看一下特化版本。该版本的输入参数为PolicyContainer<TCurPolicy, TP...>,这表明所接收的是一个以PolicyContainer为容器的数组,数组中包含了一个或一个以上的元素,其首元素为TCurPolicy,其余的元素表示为TP...。

在此基础上,程序首先获取了该policy对象所对应的minor class(第9行),之后调用MinorDedup_传入这个获取到的值以及后续的全部元素进行比较(也即内层循环),将比较的值返回到cur_check这个编译期常量中。

如果这个返回值为true,表示后续的每个元素的minor class都不是TCurPolicy,此时就可以进行下一步的检测了,下一步的检测是通过递归调用MinorCheck_来完成的。其逻辑在第14行。在这里,我们使用了AndValue这个自定义的元函数,实现了判断的短路逻辑——如果当前这一步的检测结果cur_check为false,那么程序将直接返回false,不再进行后续的检测。只有本次检测结果为true,后续的检测结果(即第14行对应的结果)也为true时,元函数才返回true。

外层循环的终止逻辑是位于MinorCheck_的原始模板定义中的。当输入的policy对象数组中的全部元素处理完成,再次递归调用时,传入该元函数中的参数将是PolicyContainer<>。此时就无法匹配该模板的特化版本了(特化版本要求数组中至少存在一个元素)。那么编译器将匹配该模板的原始版本(程序的1~4行)。这个定义只需要简单地返回true就实现了循环的终止。

内层循环逻辑则定义于MinorDedup_元函数中:

1    template <typename TMinorClass, typename... TP>
2    struct MinorDedup_ {
3        static constexpr bool value = true;
4    };
5    
6    template <typename TMinorClass, typename TCurPolicy, typename... TP>
7    struct MinorDedup_<TMinorClass, TCurPolicy, TP...> {
8        using TCurMirror = typename TCurPolicy::MinorClass;
9    
10        constexpr static bool cur_check
11            = !(std::is_same<TMinorClass, TCurMirror>::value);
12    
13        constexpr static bool value
14            = AndValue<cur_check,
15                       MinorDedup_<TMinorClass, TP...>>;
16    };

这个元函数同样存在一个原始模板与特化版本,分别实现了循环的终止与迭代两部分。它接收一个参数序列,其中的第一个参数为待比较的minor class类型;后面的参数则为进行比较的policy对象。该模板的特化版本是循环的主体:获取当前处理的policy对象(TCurPolicy)的MinorClass(第8行),调用std::is_same将其与TMinorClass比较(10~11行)。std::is_same是C++标准库中的一个元函数,接收两个类型参数,当二者相同时返回true,否则返回false。

程序的13~15行实现了与MinorCheck_类似的循环逻辑:如果当前检测结果为true,那么就继续循环,进行下一次检测,否则直接返回false。

与MinorCheck_类似,MinorDedup_也是在其原始模板中实现了循环的终止逻辑:返回true。

构造最终的返回类型

在经过了“基于组名的policy对象过滤”“同组policy对象的minor class检测”之后,policy选择元函数的最后一步就是构造最终的返回类型,即policy支配结构。

这里还有个小分支需要处理:在某些情况下,这一步的输入是空数组PolicyContainer<>。产生空数组的原因有两种:一是用户在使用时没有引入policy对象对模板的默认行为进行调整;另一种是虽然用户进行了调整,但调整的policy对象属于其他的组,这样在policy选择的第一步,就会因过滤而产生空的policy数组。

图2.3中给出的policy支配结构要求输入数组中至少存在一个policy对象。因此,如果数组中不存在对象,就需要单独处理。处理的方式也很简单:如果数组中不包含对象,那么就直接返回默认的policy定义即可。

1    template <typename TMajorClass, typename... TPolicies>
2    struct Select_<TMajorClass, PolicyContainer<TPolicies...>> {
3        ...
4        using type = std::conditional_t<IsArrayEmpty<TMF>,
5                                        TMajorClass,
6                                        PolicySelRes<TMF>>;
7    };

TMF是第一步生成的policy对象数组,IsArrayEmpty用于判断该数组是否为空。如果数组确实为空,那么就直接返回TMajorClass。TMajorClass中定义了属于该组中的每个policy的默认值。

如果TMF不为空,那么就可以使用其构造policy的支配结构了。这里用PolicySelRes <TMF>来表示这个支配结构:

1    template <typename TPolicyCont>
2    struct PolicySelRes;
3    
4    template <typename TPolicy>
5    struct PolicySelRes<PolicyContainer<TPolicy>> : public TPolicy {};
6    
7    template <typename TCurPolicy, typename... TOtherPolicies>
8    struct PolicySelRes<PolicyContainer<TCurPolicy, TOtherPolicies...>>
9        : public TCurPolicy,
10          public PolicySelRes<PolicyContainer<TOtherPolicies...>> {};

在图2.3的基础上,这一段代码并不难以理解。PolicySelRes通过两个特化实现了整个逻辑。这个元函数的输入为PolicyContainer的数组。如果数组中包含了两个或两个以上的元素,那么编译器将选择第二个特化(7~10行),这个特化将派生自两个类。

如果数组中只包含一个元素,那么编译器将选择第一个特化(4~5行),这个特化只派生自一个类,对应图2.3中从PSR_n到PO_n的垂直连线。

至此,我们已经基本完成了policy模板的主体逻辑。为了使用Policy模板,我们需要:

第2步需要为每个policy对象或policy对象模板引入一个类。这里引入了4个宏来简化这项操作:

1    #define TypePolicyObj(PolicyName, Ma, Mi, Val) \
2    struct PolicyName : virtual public Ma\
3    { \
4        using MajorClass = Ma; \
5        using MinorClass = Ma::Mi##TypeCate; \
6        using Mi = Ma::Mi##TypeCate::Val; \
7    }
8    
9    #define ValuePolicyObj(PolicyName, Ma, Mi, Val) ...
10    #define TypePolicyTemplate(PolicyName, Ma, Mi) ...
11    #define ValuePolicyTemplate(PolicyName, Ma, Mi) ...

限于篇幅,这里仅列出TypePolicyObj的定义。从中不难看出,它的本质就是构造一个类,虚继承自policy组,同时设置major class,minor class与policy的值。

这里有一个小技巧来简化代码:为了声明policy对象所属的组,我们需要为每个policy对象引入“using MajorClass = Ma;”这样的语句。所有派生自Ma的policy对象(模板)都需要加上这一句。我们可以对其进行简化:在policy的定义中引入这个声明。比如,对于之前定义的AccPolicy来说,可以这么写:

1    struct AccPolicy
2    {
3        using MajorClass = AccPolicy;
4        // ...
5    }

这样就可以简化上述宏的定义,去掉其中MajorClass的声明了:

1    #define TypePolicyObj(PolicyName, Ma, Mi, Val) \
2    struct PolicyName : virtual public Ma\
3    { \
4        using MinorClass = Ma::Mi##TypeCate; \
5        using Mi = Ma::Mi##TypeCate::Val; \
6    }

读者可以自行分析一下另外3个宏的实现。

需要说明的是,宏的引入只是为了简化policy对象的声明。也可以不用宏来声明这种对象。宏的处理能力是有限的。对于某些无法使用这些宏声明的policy对象,可以考虑使用一般的(模板)类进行声明。

在本章中,我们讨论了异类词典与policy模板的实现。

这两个模块本质上都是容器,通过键来索引容器中的值。只不过对于异类词典来说,它的键是编译期常量,而值则是运行期的对象;对于policy模板来说,它的键与值都是编译期常量。因为编译期与运行期性质的不同,其实现的细节也存在较大的差异。

虽然本章的讨论是从具名参数出发的,但像异类词典这样的构造,也可以应用在参数传递以外的场景中。比如像std::map那样作为单纯的容器使用。此时,异类词典索引较快,可存储不同数据类型的优势也能够得到体现。

每种数据结构都有其优势与劣势,虽然异类词典与std::map相比具有上述优势,但它也有其自身的不足:正是为了支持存储不同的数据类型,以及可以在编译期处理键的索引,异类词典所包含的元素个数是固定的,它不能像std::map那样在运行期增加与删除元素。

但反过来,虽然无法在运行期为异类词典添加新的元素,但我们可以在编译期通过元函数为异类词典添加或删除元素。关于这一部分的内容,就留给读者自行练习了。

与异类词典相比,policy模板的值也是编译期常量,相应地,可以在其中保存数值以外的信息,比如类型与模板等。本章所讨论的只是policy模板的一个初步实现。在第7章,我们会对本章所讨论的policy模板进行进一步的扩展,为其引入层次关系,使得它能够处理更加复杂的情形。

本章其实并没有引入什么新的元编程知识(关于支配的讨论并不是元编程的知识,而是与C++继承相关的基本知识,属于面向对象的范畴)。我们只是在确定了最终的接口形式后,用元编程来实现而已,所用的基本技巧也都是在第1章所讨论的顺序、循环、分支程序设计方法。但与第1章相比,不难看出,本章所编写的顺序、循环、分支程序更加复杂,也更加灵活。要想真正掌握这种程序设计技术,还需要读者不断地体会、练习。

本章所构造出的模块将被用于深度学习框架之中,作为基本的组件来使用。从第3章开始,我们将讨论深度学习框架的实现。

1.NSVarTypeDict::Create_使用的是线性方式来构造元素。即要构造N个元素,那么就每次构造一个,再次循环。这样在编译期循环的执行次数以及实例化的数目为O(N)。能否修改这个逻辑,使得在编译期循环执行次数与实例化的数目为O(log(N))?

2.阅读并分析NSVarTypeDict::Tag2ID的执行逻辑。

3.本章分析了NSVarTypeDict::NewTupleType_的实现逻辑。这个元函数包含了一个声明与两个特化。实际上,它的定义是可以简化的:特化1(N!=M时)与特化2(N==M时)实际上是一种分支,可以使用std::conditional_t将这两个特化合并成一个。改写这个元函数的实现,按照上述思路进行化简。思考化简后的代码与之前的代码的优劣。

4.阅读并分析VarTypeDict::Values::Get的执行逻辑。

5.VarTypeDict::Values::Set在定义时函数声明的结尾处加了&&,这表明该函数只能用于右值。定义一个能用于左值的Set,思考这个新的函数与旧的函数相比,有什么优势,什么劣势。

6.接收数组的Values构造函数是供Values::Set函数调用的。而Set也是定义于Values中的函数。那么,能否将该构造函数的访问权限从public修改为private或protected?给出你的理由,之后尝试修相应的访问权限改并编译,看看是否符合你的预期。

7.使用std::tuple替换VarTypeDict::Values中的指针数组,实现不需要显式内存分配与释放的VarTypeDict版本。分析新版本的复杂度。

8.在2.3节,我们给出了一个用于进行累积计算的类,并使用它展示了policy与policy对象的概念。事实上,除了类模板,我们也可以在函数模板中使用它们。将该节提供的例子进行改写,使用函数模板实现与示例所提供的累积算法相同的功能。

9.分析NSPolicySelect::MajorFilter_的实现逻辑。

10.分析IsArrayEmpty的实现逻辑。

11.尝试构造模板policy对象,即policy对象的“值”是一个模板。尝试引入宏来简化相应policy对象的定义。

12.本章开发的异类词典包含了Get方法,可以根据键值获取不同类型的数据对象。但目前Get方法在返回对象时是先对词典中的对象进行复制,之后将复制的结果返回。对于一些数据结构来说,复制的成本相对较高。我们可以考虑使用移动语义来减少复制所引入的额外的成本。在已有的代码框架基础上,为NamedParameters::Values引入一个新的Get函数,当NamedParameters::Values对象本身是右值时调用:调用新的Get函数时,将通过移动的方式返回底层数据对象。

13.尝试编写两个元函数,在编译期为异类词典添加或删除元素。比如编写AddItem与DelItem两个元函数,使得如下的代码:

using MyDict = VarTypeDict<struct A, struct B>;

using DictWithMoreItems = AddItem<MyDict, struct C>;
using DictWithLessItems = DelItem<MyDict, A>;

DictWithMoreItems的类型为VarTypeDict<A, B, C>,而DictWithLessItems的类型为VarTypeDict<B>。

注意AddItem与DelItem应当能处理一些边界情况,比如调用AddItem<MyDict, A>或调用DelItem<MyDict, C>应当报错:前者添加了重复的键,而后者要删除一个并不存在的键。

[1] 典型的论述包括:“你们这些C++程序员总是一上来就用语言的那些‘漂亮的’库特性(比如STL、Boost)和其他彻头彻尾的垃圾……”

[2] 典型的论述包括:“写C或者C++就像是在用一把卸掉所有安全防护装置的链锯。”

[3] 出于简洁考虑,本段代码省略了迭代器合法性的检查。

[4] 关于闭包的详细解释,可以参考Domain-Specific Languages一书,Martin Fowler著,Addison-Wesley Professional,2010年。

[5] NS是名字空间(namespace)的简写。NSVarTypeDict表示里面放置的是专门供VarTypeDict使用的核心逻辑。如第1章所述,在这里引入名字空间,将一些通用的逻辑提取出来,可以减少编译过程中的实例化数目,提升编译效率。这也是整个代码库的风格之一。

[6] 这是用sizeof...关键字来获得的,它是C++ 11中的一个关键字。

[7] 其中的Set函数在定义时声明的结尾处加了&&,这表明该函数只能用于右值。在程序中使用了std::move与std::forward,用于右值转换与完美转发。这些都是C++ 11中的特性。读者可以参考C++ 11的相关书籍,或者在网络上搜索“右值限定符”“右值引用”“完美转发”来了解。这里不再赘述。

[8] 事实上,这里存在一个与之类似的概念:trait。通常trait用于描述特性,而policy用于描述行为。但trait与policy并不总是能分得很清楚,有兴趣的读者可以阅读《C++ Templates中文版》一书。本书将统一采用policy这个名称。

[9] 注意policy模板与policy对象模板的区别:前者表示使用policy对象的模板,而后者表示构造policy对象的模板。

[10] David Vandevoorde、Nicolai M. Josuttis著,陈伟柱译,人民邮电出版社,2008年出版。

[11] 事实上,这也是本书中唯一用到宏的地方。作者对宏的使用是非常小心的,关于这一点的相关论述,可以参考本书的第1章。

[12] 宏在其内部对Accu与Mul自动扩展,构造出AccuTypeCate与AccuTypeCate::Mul。

[13] 这里使用了if constexpr来实现编译期选择的逻辑。而代码段中的DependencyFalse<AccuType>则是一个元函数,其值为false,表示不应被触发的逻辑。根据C++的规定,我们不能直接使用static_assert(false),但可以使用代码中的方式来标记不应被触发的逻辑。

[14] PSR是Policy Selection Result(policy选择结果)的缩写。



相关图书

代码审计——C/C++实践
代码审计——C/C++实践
CMake构建实战:项目开发卷
CMake构建实战:项目开发卷
C++ Templates(第2版)中文版
C++ Templates(第2版)中文版
C/C++代码调试的艺术(第2版)
C/C++代码调试的艺术(第2版)
计算机图形学编程(使用OpenGL和C++)(第2版)
计算机图形学编程(使用OpenGL和C++)(第2版)
Qt 6 C++开发指南
Qt 6 C++开发指南

相关文章

相关课程