动手打造深度学习框架

978-7-115-57012-3
作者: 李伟
译者:
编辑: 赵祥妮

图书目录:

详情

本书基于 C++编写,旨在带领读者动手打造出一个深度学习框架。本书首先介绍 C++模板元编程的基础技术,然后在此基础上剖析深度学习框架的内部结构,逐一实现深度学习框架中的各个组件和功能,包括基本数据结构、运算与表达模板、基本层、复合层、循环层、求值与优化等,最终打造出一个深度学习框架。本书将深度学习框架与 C++模板元编程有机结合,更利于读者学习和掌握使用 C++开发大型项目的方法。 本书适合对 C++有一定了解,希望深入了解深度学习框架内部实现细节,以及提升 C++程序设计水平的读者阅读。

图书摘要

版权信息

书名:动手打造深度学习框架

ISBN:978-7-115-57012-3

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

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

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

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


    李 伟

责任编辑 赵祥妮

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

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

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

读者服务热线:(010)81055410

反盗版热线:(010)81055315


本书基于C++编写,旨在带领读者动手打造出一个深度学习框架。本书首先介绍C++模板元编程的基础技术,然后在此基础上剖析深度学习框架的内部结构,逐一实现深度学习框架中的各个组件和功能,包括基本数据结构、运算与表达模板、基本层、复合层、循环层、求值与优化等,最终打造出一个深度学习框架。本书将深度学习框架与C++模板元编程有机结合,更利于读者学习和掌握使用C++开发大型项目的方法。

本书适合对C++有一定了解,希望深入了解深度学习框架内部实现细节,以及提升C++程序设计水平的读者阅读。


本书讨论了如何将C++模板元编程(简称元编程)深入应用到一个相对较大的项目(深度学习框架)的开发过程中,通过元编程与编译期计算为运行期优化提供更多的可能。

本书内容将围绕两个主题展开:C++模板元编程与深度学习框架。在笔者看来,这两个主题都算是时下比较前沿的技术。深度学习框架不必多说,它几乎已经成为人工智能的代名词,无论是在自然语言处理、语音识别,还是图像识别等与人工智能相关的技术中,都可以看到深度学习的身影。本书的另一个主题——C++模板元编程,或者说与之相关的C++泛型编程,也是C++领域越来越热门的一种技术。从C++11到C++20,我们看到标准中引入了越来越多的与 C++模板元编程相关的内容。C++20 中非常振奋人心的特性可能要数Concepts与std::ranges了,前者是直接对元编程语法的增强,后者则深入应用了元编程技术。可以说,正是元编程技术的发展,使得C++这门已经被使用了40余年的语言焕发出新的活力。

C++并不容易上手。与Java等语言相比,它过于关注底层的机制,开发者需要人为地处理诸如内存的分配与释放、对象的生命周期管理等“零零碎碎”的问题;与C语言、汇编语言相比,它又包含了过多的语法细节,学习成本要高很多。但C++也有它自身的优势:它可以写出性能堪比C语言程序的代码,同时包含了足以用于构建大型程序的语法框架。这也让它在众多编程语言中脱颖而出,为很多开发者所钟爱。

严格来说,几乎所有的程序设计语言都是“图灵完备”的,这也就意味着大家能做的事情差不多。之所以要发明出这么多程序设计语言,一个主要的原因就是要在易用性与高性能之间取得一种平衡。关于这种平衡,不同的程序设计语言选择了不同的取舍方式:像Python、Java等语言更倾向于易用性,比如Python是弱类型的,我们可以使用一个变量名称指代不同类型的数据;Java则通过虚拟机隐藏了不同计算机之间的硬件与操作系统的差异,实现了“一次编译,到处运行”。相比之下,C++则将语言的天平更多地向性能倾斜,可以说,C++泛型编程与元编程正是这一点的体现。

举例来说,同样是构造容器保存数据,Python可以直接将数据放置到数组中,不需要考虑每个数据的具体类型。之所以可以这样做,是因为Python中的每个类型都派生自一个相同的类型,所以数组中所保存的元素本质上都是这个类型的指针——这是一种典型的面向对象编程方式。C++也支持这种方式,但除此之外,C++还可以通过模板引入专门的容器,来保存特定类型的数据。事实证明,后一种方式由于对存储的类型引入了更多的限制,因此有更多优化的空间,其性能也就更好。这种方式也被C++标准库所采用。

通过模板,我们可以编写一套相似的代码,并以不同的类型进行实例化,从而实现对不同的类型进行相似的处理。这种可以应用于不同类型的代码也被称为“泛型”代码。

在引入泛型机制的基础上,又产生了一些新的问题。比如,我们可能需要根据某个类型来推导出相应的指针类型,以间接引用该类型的某个变量;又如,虽然大部分情况下,我们可以使用一套代码来处理不同类型,但对于某些“特定的”类型来说,一些处理逻辑上的调整可能会极大地提升性能。因此,我们需要一种机制进行类型推导或逻辑调整。这种机制以程序作为输入与输出,是处理程序的程序,因此被称为元程序,而相应的代码编写方法则被称为元编程。

早期的元编程应用范围相对有限,一方面是因为这种代码的编写方式难以掌握;另一方面则是因为C++语法对其支持程度不高。随着人们对C++这门语言认识程度的加深,以及C++标准中引入了更多的相关工具,元编程的使用门槛也逐渐降低,以至于可以应用在很多复杂程序的开发之中。本书将元编程深入应用到深度学习框架的开发过程中,就是一次有益的尝试。

深度学习框架中有一个核心概念——张量。张量可以被视为多维数组,典型的张量包括一维向量、二维矩阵等。矩阵可被视为一个二维数组,其中的每个元素是一个数值,可以通过指定行数与列数获取该位置元素的值。

在一个相对复杂的系统中,可能涉及各种不同的矩阵。比如,在某些情况下我们可能需要引入某种数据类型来表示“元素全为0”的矩阵;或者一些情况可能需要基于某个矩阵表示出一个新的矩阵,新矩阵中的每个元素都是原有矩阵中相同位置元素乘 −1 后的结果。

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

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

AbstractMatrix定义了表示矩阵的基类,其中的Value接口在传入行号与列号时,返回对应元素的值(这里假定它为int类型)。之后,我们引入了若干个派生类,使用Matrix来表示一般意义的矩阵;使用ZeroMatrix来表示元素全为0的矩阵;NegMatrix的内部则包含一个AbstractMatrix类型的对象指针,它表示的矩阵的每个元素为其中包含的Matrix对应元素乘−1的结果。

所有派生自AbstractMatrix的具体矩阵必须实现Value接口。比如,对ZeroMatrix来说,其Value接口的功能就是返回数值0;对NegMatrix来说,它会首先调用其内部对象的Value接口,之后将获取的值乘−1并返回。

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

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    
5    ZeroMatrix m1;
6    Matrix m2;
7    Add(&m1, &m2); // 调用第二个优化算法

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

这种方案很常见,以至于我们可能意识不到这已经是在使用元编程了。我们相当于构造了一个元程序,其输入是具体的矩阵类型,输出是相应的求和算法。编译器会根据不同的输入选择不同的算法处理——整个计算过程在编译期完成。相应地,元编程也被称为编译期计算。

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

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

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

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

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

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

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

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

本书将使用编译期计算与元编程打造一个深度学习框架。深度学习是当前研究的一个热点领域,以人工神经网络作为核心,包含了大量的技术与学术成果。本书主要讨论元编程与编译期计算的方法,因此并不考虑做一个大而全的工具包。但我们所打造的深度学习框架是可扩展的,能够用于人工神经网络的训练与预测。

尽管对讨论的范围进行了上述限定,但本书毕竟同时涉及元编程与深度学习,读者如果没有一定的背景知识很难完成学习。因此,我们假定读者对相关数学知识与C++都有一定的了解,具体有以下几点。

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

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

1.研发成本

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

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

使用元编程的另一个问题是调试困难。原因也很简单:大部分C++开发者都在使用面向对象的方式编程,因此大部分编译器都会针对这一点进行优化。相应地,编译器在输出元编程的调试信息方面效果就会差很多。很多情况下,编译器输出的元程序错误信息更像是一篇短文。这个问题在C++20标准引入了Concepts后有所缓解,但目前主流的编译器还是支持C++17标准,对该问题没有什么特别好的解决方案。通常来说,我们要多动手做实验,多看编译器的输出信息,慢慢找到感觉。

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

2.使用成本

元编程的研发成本是一种主观成本,可以通过开发者提升自身的编程水平来降低。相对地,元编程的使用成本则是一种客观成本,处理起来更棘手。

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

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

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

本书包含两部分。第1部分(第1~3章)将讨论元编程的基础技术,这些技术将被用在第2部分(第4~10章)中,用于打造深度学习框架。

第1章 元编程基本方法。本章讨论元函数的基本概念,讨论将模板作为容器的可能性,在此基础上给出顺序、分支、循环代码的编写方法——这些方法构成整个编程体系的核心。在此之后,我们会进一步讨论一些典型的惯用法,包括奇特的递归模板式(Curiously Recurring Template Pattern,CRTP)等内容,它们都会在后文中被用到。

第2章 元数据结构与算法。本章在第1章的基础上进行了引伸,引入基本的数据结构与算法的概念。我们将讨论在编译期表示集合、映射(map)等数据结构的方法,同时给出编译期高效的数据索引与变换算法。这些算法都是泛型的,但与传统的C++泛型算法不同。传统的C++泛型算法的目的是处理运行期不同的数据,而这里的算法是为了处理编译期不同的数据(甚至是类型)。我们将这种高效处理的算法抽象出来,保存在一个元算法库中,供后续编写深度学习框架使用。

第3章 异类词典与policy模板。本章将会利用前两章的知识构造出两个组件。第1个组件是一个容器,用于保存不同类型的数据对象;第2个组件则是一个使用具名参数的policy 系统。这两个组件均将用于后续深度学习框架的打造。虽然本章偏重于基础技术的应用,但笔者还是将其归纳为泛型编程的基础技术,因为这两个组件都比较基础,可以作为基础组件应用于其他项目之中。这两个组件本身不涉及深度学习的相关知识,但我们会在后续打造深度学习框架时使用它们来辅助设计。

第4章 深度学习概述。从本章开始,我们将着手打造深度学习框架。本章将介绍深度学习框架的背景知识。如果读者之前没有接触过深度学习,那么通过阅读本章,可以对这一领域有一个大致的了解,从而明晰我们要打造的框架所要包含的主要功能。

第5章 类型体系与基本数据类型。本章讨论深度学习框架所涉及的数据。为了最大限度地发挥编译期计算的优势,我们将深度学习框架设计为富类型的,即它能够支持很多具体的数据类型。随着所支持数据类型的增多,如何有效地组织这些数据类型就成了一个重要的问题。本章讨论基于标签的数据类型组织形式,它是元编程中一种常见的分类方法。

第6章 运算与表达式模板。本章讨论深度学习框架中运算的设计。人工神经网络会涉及很多运算,包括矩阵相乘、矩阵相加、取元素对数值,以及更复杂的运算。为了能够在后续对运算进行优化,这里采用了表达式模板以及缓式求值的技术。

第7章 基本层。在运算的基础上,我们引入了层的概念。层将深度学习框架中相关的操作关联到一起,提供了正向、反向传播的接口,便于用户调用。本章将讨论基本层,描述如何使用第3章所构造的异类词典和policy模板来简化层的接口与设计。

第8章 复合层。基于第7章的知识,我们就可以构造各式各样的层,并使用这些层来搭建人工神经网络。但这种做法有一个问题:人工神经网络中的层是千变万化的,如果每一个之前没有出现过的层都手工编写代码实现,那么工作量还是比较大的,也不是我们希望看到的。在本章我们将构造一个特殊的层——复合层,用于组合其他的层来产生新的层。复合层中比较复杂的一块逻辑是自动梯度计算——这是人工神经网络在训练过程中的一个重要概念。可以说,如果无法实现自动梯度计算,那么复合层存在的意义将大打折扣。本章将会讨论自动梯度计算的一种实现方式,它也是本书的重点之一。

第9章 循环层。循环层的特殊之处在于需要对输入数据进行拆分,对拆分后的数据依次执行正向、反向传播逻辑,并将执行后的结果进行合并。我们将循环层的通用逻辑与具体的正向、反向传播算法分离出来,从而实现一个相对灵活的循环层组件。

第10章 求值与优化。人工神经网络是一种计算密集型的系统,无论对训练还是预测来说都是如此。一方面,我们可以采用多种方式来提升计算速度,典型地,可以使用批量计算同时处理多组数据,最大限度地利用计算机的处理能力;另一方面,我们可以对求值过程进行优化,从数学意义上简化与合并多个计算过程,从而提升计算速度。本章将讨论与此相关的主题。

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

读者可以在https://github.com/liwei-cpp/MetaNN/tree/book_v2中下载本书的源码。源码中包含几个子目录。其中,MetaNN 子目录包含了深度学习框架中的全部逻辑,而其他子目录中则是一些测试逻辑,用来验证框架逻辑的正确性。本书所讨论的内容可以在MetaNN目录中找到对应的源码。阅读本书时,有一份可以参考的源码以便随时查阅是非常重要的。本书用了较多的篇幅阐述设计思想,但只是罗列了一些核心代码。因此,笔者强烈建议读者对照源代码来阅读本书,这样能对本书讨论的内容有更加深入的理解。

对于MetaNN中实现的每个技术要点,我们都引入了相应的测试用例。因此,读者可以在了解了某个技术要点的实现细节之后,通过阅读测试用例,进一步体会相应技术要点的使用方法。

MetaNN中的内容全部是头文件,测试用例则包含了一些CPP文件,可以编译成可执行程序。MetaNN中的代码主要基于C++17编写。因此,测试代码的编译器需要支持C++17标准。同时需要注意的是,由于代码中使用了大量的元编程技术,因此会给编译过程带来不小的负担。特别地,编译所需要的内存相对较多。因此,这里不建议采用32位编译器进行编译,否则可能会出现因编译器内存溢出而编译失败的情况。

笔者采用Linux系统,以GCC与Clang作为测试编译器,在GCC 、Clang 8.0.0等环境中完成编译测试。代码使用CodeLite工程进行组织,读者可以在Linux系统中安装CodeLite,导入代码中的MetaNN.workspace工程文件进行编译,也可以尝试使用自己熟悉的工具编译代码[1]

笔者尽量避免在讨论技术细节时罗列很多非核心的代码。同时,为了便于讨论,通常来说代码的每一行前面会包含一个行号:在对该代码进行分析时,一些情况下会使用行号来表示具体行的代码,说明该行所实现的功能。

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

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

由于笔者水平有限,而元编程又是一个比较有挑战性的领域,因此本书难免出现疏漏。对于本书描述隐晦不清之处以及其他可以改进的地方,欢迎发邮件到liwei.cpp@gmail.com与笔者交流。

李伟

2021年12月

[1] 需要说明的是,虽然很多编译器都支持C++17,但在支持的细节上有所差异。笔者尝试使用Visual Studio 2019编译测试代码,但有部分代码无法通过编译,系统提示编译器内部错误。



我们的深度学习框架将大量利用C++模板元编程(metaprogramming)技术。因此在讨论深度学习框架的打造之前,我们有必要先对这一技术进行系统而深入的学习。本章将讨论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++ 中用得更多的是类型(type)元函数——以类型作为输入和(或)输出的元函数。

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

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

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

可以使用如下的代码来实现上述元函数[2]

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行相当于定义了一个unsigned int类型的变量h并赋值3。

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

在C++11发布之前,已经有一些对C++元函数进行讨论的著作了。在C++ Template Metaprogramming一书中,作者将上述代码中的第1~8行所声明的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++ Template Metaprogramming一书中的定义,它不是一个元函数,因为它没有内嵌类型type。但根据本章开头的讨论,它具有输入(T)、输出(Fun<T>),同时明确定义了映射规则,所以在本书中,它会被视为一个元函数。

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

在前文中,我们展示了几种元函数的书写方法。与一般的函数不同,元函数本身并非在C++ 语言设计之初就被引入,因此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    template <int a>
2    constexpr int fun = a + 1;

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

前文所讨论的元函数均只有一个返回值。实际上,元函数可以具有多个返回值。考虑下面的代码:

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

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

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

提到元函数,就不能不提及元程序库: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中的元函数,其关系类似于我们在小节中讨论的Fun_与Fun。

通常来说,编写元程序往往需要使用这个库以进行类型转换。我们的深度学习框架也不例外:本书会使用其中的一些元函数,并在首次使用某个元函数时说明其功能。读者可以通过The C++Standard Library: A Tutorial and Reference一书来系统性地了解该函数库。

按前文中对函数的定义,理论上宏也可以被视为一类元函数。但一般来说,我们在讨论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行在获取结果时没有采用依赖型的写法,因此函数名中没有下划线后缀。这种书写形式并非强制性的,本书选择这种形式,仅仅是为了风格上的统一。

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

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

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

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是一个典型的高阶函数,即以另一个函数为输入参数的函数。可以将其总结为如下的数学表达式(为了更明确地说明函数与数值的关系,下式中的函数以大写字母开头,而数值则以小写字母开头):

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

考虑下面的代码:

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    using Res_ = typename Fun_<false>::template type<T>;
17
18    Res_<int&>::type h = 3;

其中第1~13行定义了元函数Fun_:

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

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

其中的addOrRemove是一个bool值,而T则是Fun_的输出,在代码中是一个元函数。

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

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

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

从本质上来说,我们需要的并非一种数组的表示方式,而是一个容器:用来保存数组中的每个元素。元素可以是数值、类型或模板。可以将这3种元素视为不同类别(category)的操作数:就像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个容器同样以模板作为其元素,但每个模板可以放置多个类型信息。

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

大部分技术的发展都经历了由简单到复杂的过程,程序设计语言也不例外。早期的程序设计语言只包含语句,后期为了逻辑的可复用性,人们引入了函数的概念;进一步,为了更好地复用逻辑,人们又引入了对象的概念。某些时候,新概念的引入并不需要在语言层面上添加额外的支持:我们需要做的,仅仅是从不同的视角来看待相同的问题。但往往这种新概念、新视角的引入,能让我们对事物的本质有更深刻的认识。

对C++模板元编程的理解也是如此。现阶段,大部分讨论该技术的资料都是从元函数的角度来考虑的。但事实上,笔者认为,我们完全可以更进一步,从对象的角度来重新审视元编程这项技术。相信本书的很多读者都比较熟悉面向对象的编程方法。相比之下,了解函数式编程的读者可能相对较少。从面向对象的角度来讨论元编程技术,我们无须引入任何新的语法,只是换一个角度来看相同的问题,就足以使我们对其有更深刻的认识。

来看如下示例:

1    template <typename T, size_t N>
2    struct Fun_
3    {
4        constexpr static size_t val = N > 10 ? N / 2 : N;
5        using ArrType = T[val];
6    };
7
8    using ResType = Fun_<int, 5>::ArrType;
9    constexpr size_t Number = Fun_<int, 5>::val;

从元函数的角度来看,我们可以将上述代码视为具有多个返回值的元函数。代码第8~9行相当于调用这个元函数,并获取相应的返回值。

正如前文所述,一些学者认为元函数最好只有一个返回值,他们对上述这种包含多个返回值的元函数持反对的态度。但换个角度来看,我们可以将Fun_视为一个对象,而将其中的定义视为对象的元数据域。第8~9行则被视为获取一个对象的不同的元数据域。从这个角度来说,在一个对象中包含多个数据域就是一件非常自然的事情了。

我们可以使用C++ 的一些限定符来限制这些元数据域[9]

1    template <typename T, size_t N>
2    struct Fun_
3    {
4    private:
5        constexpr static size_t val = N > 10 ? N / 2 : N;
6    public:
7        using ArrType = T[val];
8    };

上述代码展示了可以为不同的元数据域引入不同的限定符,从而确保了用户无法直接访问Fun_<T>::val。这与我们掌握的C++ 知识是吻合的。

如果元数据域看上去“平平无奇”,那么元方法可能会让读者耳目一新。在面向对象中,我们可以为一个对象提供相应的调用方法。来看如下代码:

1    res = Fun(f).method(m);

这是一种典型的面向对象调用方法:函数Fun以f为参数,返回一个对象。返回对象的method方法被调用,以m为参数,进行某种处理后返回res。

这并没有什么大不了的,不是吗?那么让我们换一种写法:

1    using res = Fun<int*>::method<5>;

除去语句中的一些符号的差异外,我并不认为这两种语句有什么本质的不同:相比之下,第二种无非就是将“.”换成了“::”,将调用的圆括号换成了角括号而已。

现在让我们给出Fun与method的一种实现:

1     template <typename T>
2     struct Wrapper
3     {
4         template <size_t N>
5         using method = T[N];
6     };
7
8     template <typename T>
9     struct Fun_
10    {
11        using type = Wrapper<std::remove_pointer_t<T>>;
12    };
13
14    template <typename T>
15    using Fun = typename Fun_<T>::type;

如果按照前文所讨论的方式,从函数式编程的角度来理解这段代码,那么显然,Fun与Wrapper都是高阶函数:前者返回一个元对象,而后者在传入参数T后会产生元方法method。对于不习惯函数式编程的读者来说,掌握并运用高阶函数其实是比较困难的。但我们完全可以换一个角度来理解上面的代码:Fun返回了一个元对象,该元对象包含了一个元方法。对于调用语句来说,则是Fun以int*为输入,之后调用了返回元对象的元方法method,仅此而已。

方法可以返回对象,对象可以继续调用相应的方法,以此类推就可以形成如下的调用链:

1    X().method1().method2()...

相应地,元方法就可以返回元对象,我们可以构造如下的调用链:

1    X<>::template method1<>::template method2<>...

如果从函数式编程的角度理解,我们需要考虑二阶函数、三阶函数等概念,但从面向对象的角度理解,这仅仅是对象方法的调用而已。

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

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

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

顺序执行的代码还是比较直观的,来看如下代码:

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    };

代码将无法编译。

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

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

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

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

1.使用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为true,则函数返回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)的过程中有一点需要注意,来看以下代码:

1    using Res = std::conditional_t<false,
2                                   remove_reference_t<int&>,
3                                   remove_reference_t<float&>>;

其中虽然传入的第1个参数是false,但作为其分支的第2个与第3个模板参数均会被求值。即使只有一个求值结果会被使用,也是如此。

在这个示例中,我们使用了remove_reference_t作为分支的计算逻辑,remove_reference_t是复杂度较低的元函数。如果作为分支的元函数复杂度较高,那么对两个分支均进行计算会对编译器造成不小的负担,同时也没有必要。

为了解决这个问题,通常来说我们会采用如下惯用法:

1    using Res =
2        std::conditional_t<false, remove_reference<int&>,
3                           remove_reference<float&>>::type;

这段代码与上一段是逻辑等价的。但conditional_t会选择两个remove_reference元函数的调用之一来返回,而随后的type则会实际调用conditional_t返回的元函数进行计算。这样,我们只需要对返回的分支进行计算即可:与上一段代码相比,这就降低了编译期的计算成本。

但这种形式会引入一个新的问题。在一些情况下,我们希望使用conditional_t来实现如下逻辑:传入一个类型,如果第一个模板参数为true(或为false),那么直接返回该类型,否则就引入元函数对该类型进行变换。来看以下代码:

1    using Res1 = std::conditional_t<false,
2                                    remove_reference_t<int&>,
3                                    int&>;
4
5    using Res2 = std::conditional_t<false,
6                                    remove_reference<int&>,
7                                    int&>::type;

其中Res1的调用是合法的,虽然这段代码进行了一个无用的操作(计算remove_reference_t<int&>,但没有使用它)。Res2虽然降低了元函数的计算成本,但它是非法的,因为int&并不存在type定义。

为了解决这个问题,我们需要引入一个额外的元函数层:

1    template <typename T>
2    struct Identity_
3    {
4        using type = T;
5    };

在此基础上,可以按照如下方式来修改上述定义:

1    using Res2 = std::conditional_t<false,
2                                    remove_reference<int&>,
3                                    Identity_<int&>>::type;

其中Identity_的计算成本非常低,这种定义既确保了调用的合法性,也降低了编译期的计算成本。

conditional与conditional_t的优点在于其定义比较直观,但缺点是表达能力不强。

鉴于上述原因,conditional与conditional_t的使用场景是相对较少的。除非是特别简单的分支情况,否则并不建议使用这两个元函数。

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

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

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_的输入,其返回值也不同——这是一种典型的分支行为。元函数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>的形式调用这个元函数,无须为伪参数赋值了。

3.使用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为true时,std::enable_if_t<IsFeedbackOut>是有意义的,这就使得第1个函数匹配成功,与之相应,第2个函数匹配失败,反之,当IsFeedbackOut为false时,std::enable_if_t<!IsFeedbackOut>是有意义的,这就使得第2个函数匹配成功,第1个函数匹配失败。

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

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

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

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

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

这里讨论的分支实现方式远非全部,还有很多分支实现方式。比如,在C++17中新引入的void_t模板也可以用来实现分支。你还能想到其他的分支实现方式吗?

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

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

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来简化代码的编写。

5.使用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为true的语句体,而忽略其他的语句体。比如,在编译器解析到第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这样的语句组织元函数中的循环代码——因为这些代码操作的是变量。但在编译期,我们操作的更多的则是常量、类型与模板[13]。为了能够有效地操作元数据,我们往往会使用递归的形式来实现循环。

还是让我们看一个例子:给定一个无符号整数,求该整数所对应的二进制表示中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将匹配这个模板特化,取出首个元素,将剩余元素求和后加到首个元素之上。

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

使用递归来编写循环是一种通用的解决方案。除此之外,对于一些特殊的循环需求,C++ 也提供了更简单的解决方案:折叠表达式与包展开。

1.基于折叠表达式实现循环

一种常见的循环场景是,输入一个序列(通常是数值序列),对序列中的元素逐个操作,并返回操作结果——通常来说,操作结果往往是一个数值。比如,前文中对数组求和的示例就是此类操作的典型。由于此类操作通常会以一串数据作为输入,由此构造出一个数值,因此在数学上其有一个很形象的称呼:折叠。常见的折叠应用包含求和、求最大最小值等。

C++17引入了折叠表达式来简化此类循环的编写。以数组求和为例,前文中的示例在C++17中可以有更简单的代码编写方法:

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>();

在这里,我们使用了折叠表达式来计算输入数组的和,并将计算结果保存在编译期常量中。事实上,折叠表达式不仅可以用于编译期计算,也可以用于运行期计算:

1     size_t helper(size_t in)
2     {
3         static size_t value = 0;
4         return in + (value++);
5     }
6
7     template <size_t... values>
8     size_t fun()
9     {
10        return (helper(0) + ... + helper(values));
11    }
12
13    std::cout << fun<1, 2, 3, 4, 5>() << std::endl;

执行后系统会输出30。

在这个例子中,我们使用了helper函数辅助计算。显然,helper函数是一个运行期函数(因为其中包含了静态成员,会在每次调用时改变相应的数值),但这并不妨碍我们在fun函数中使用折叠表达式——当然,由于fun函数调用了运行期逻辑,因此它也就不再是一个元函数了,因为不能在其声明中引入constexpr限定符。

通常来说,折叠表达式的输入是数值序列,返回是一个数值。但也不一定非要如此。一个经典的示例是,可以使用折叠表达式依次输出数值序列中的元素,即使元素的类别不同也没有关系:

1    template <typename... T>
2    void Fun(T... t)
3    {
4        (std::cout << ... << t);
5    }
6    Fun("abc", 1, 1.3);

这种输出方式会将输出的内容连在一起,不利于阅读。一种改进的方案是引入一个辅助函数:

1    template <typename T>
2    void Helper(T t)
3    {
4         std::cout << t << std::endl;
5     }
6
7     template <typename... T>
8     void Fun(T... t)
9     {
10        (Helper(t), ...);
11    }
12    Fun("abc", 1, 1.3);

这会使输入的每个元素在一行中输出。

这里对折叠表达式的语法细节不做过多讨论,读者可以参考C++ Primer等书籍了解。在这里要着重指出的是,折叠表达式虽然使用起来很简单,但它是有其自身的局限性的。

从某种角度来说,折叠表达式只是一种语法糖,用于简化特定情形下的循环书写而已。

2.基于包展开实现循环

另一种经常要使用循环的场景是,给定输入序列,产生输出序列,输出序列中的每个元素是输入序列中相应元素的处理结果。每个元素的处理逻辑均是相同的。在这种场景下,可以使用C++ 提供的包展开来简化循环。

一个典型的例子是,给定一个数值序列,将序列中的每个元素加1,构造新的序列并输出:

1    template <size_t... I> struct Cont;
2
3    template <size_t... I>
4    using Fun = Cont<(I+1)...>;
5
6    using Res = Fun<1, 2, 3, 4, 5>; 

上述代码使用包展开的方式构造了元函数Fun,并使用该元函数以<1, 2, 3, 4, 5>作为输出,将输出结果保存在Res中。Res其实就是Cont<2, 3, 4, 5, 6>。

从这个示例中不难看出包展开与折叠表达式的一些区别:

事实上,由于包展开所处理的是类型,因此它的输入并不一定是数值数组:

1    template <size_t... I> struct Cont;
2
3    template <typename... T>
4    using Fun = Cont<sizeof(T)...>;
5
6    using Res = Fun<int, char, double>;

这段代码中定义的元函数接收一个类型序列,构造一个数值序列,输出序列中的每个值对应了输入序列中类型的尺寸。在笔者的测试环境中,Res实际上是Cont<4, 1, 8>。不同的编译环境可能会产生不同的结果。

我们甚至能使用包展开做更复杂的操作:

1     template <typename T, size_t V>
2     struct Pair
3     {
4         using type = T;
5         constexpr static size_t value = V;
6     };
7
8     template <bool... I> struct Cont;
9
10    template <typename... Pairs>
11    using Fun = Cont<(sizeof(typename Pairs::type) == Pairs::value)...>;
12
13    using Res = Fun<Pair<int, 2>, Pair<char, 1>, Pair<double, 8>>;

这段代码的核心是第11行。本质上,它从输入序列的每个元素中获取类型与数值信息,判断类型的尺寸是否等于相应的数值,并将结果保存在返回数组中。在笔者的测试环境中,函数的调用结果Res值为Cont<false, true, true>。不同的编译环境可能会产生不同的结果。

通过上面的一些例子,我们也不难发现包展开的一些局限性。它只能满足某些特殊的循环需求:要求输入与输出均是数组,输入数组中的每个元素与输出数组中的元素存在一一映射的关系。映射可以是类型间的,也可以是类型与数值间的,由元函数来表示。

虽然折叠表达式与包展开都只能处理特定的循环问题,不具有通用性,但在一定情况下,它们还是能够简化代码编写的。同时,我们将会在第2章看到,适当地使用折叠表达式与包展开可以降低程序编译的复杂度。

3.实现编译期switch逻辑

我们在前文提到过,C++ 提供了conditional_t以支持编译期的二分支选择,但使用它来进行编译期的多分支选择则比较困难。这里,我们将利用之前学到的循环、分支代码的编写方法来实现一个编译期多分支选择的元函数CompileTimeSwitch。

让我们先来看一下这个元函数的调用方式:

1    using ChooseResult
2        = CompileTimeSwitch<
3              std::integer_sequence<bool, Cond1, ..., CondN>,
4              Cont<Res1, ..., ResN(, Def)>
5              >;

其中CompileTimeSwitch接收两个序列:integer_sequence<bool,Cond1, ..., CondN>是一个布尔序列,Cont<Res1, ..., ResN(,Def)>是一个选项序列。第一个序列中的Cond1 ... CondN是N个bool值。CompileTimeSwitch会依次判断这些值。如果Condi为true,同时其前面的bool值均为false,那么返回Resi。如果所有的bool值均为false,那么返回Def。

Def是可选的,如果调用CompileTimeSwitch没有提供Def[15],那么必须要求第一个数组中的bool值至少有一个为true,否则系统会出现编译错误。

最后需要说明的是,Cont可以是任意能保存类型数组的模板。比如,它可以是std::tuple,也可以是一个自定义的数组容器。

现在让我们看一下CompileTimeSwitch的实现:

1     template <typename TBooleanCont, typename TFunCont>
2     struct CompileTimeSwitch_;
3
4     template <bool curBool, bool... TBools,
5               template<typename...> class TFunCont,
6               typename curFunc, typename... TFuncs>
7     struct CompileTimeSwitch_<std::integer_sequence<bool, curBool, TBools...>,
8                               TFunCont<curFunc, TFuncs...>>
9     {
10    static_assert((sizeof...(TBools) == sizeof...(TFuncs)) ||
11                  (sizeof...(TBools) + 1 == sizeof...(TFuncs)));
12    using type
13      = typename conditional_t<
14                   curBool,
15                   Identity_<curFunc>,
16                   CompileTimeSwitch_<std::integer_sequence<bool, TBools...>,
17                                      TFunCont<TFuncs...>>>::type;
18    };
19
20    template <template<typename...> class TFunCont, typename curFunc>
21    struct CompileTimeSwitch_<std::integer_sequence<bool>, TFunCont<curFunc>>
22    {
23      using type = curFunc;
24    };
25
26    template <typename TBooleanCont, typename TFunCont>
27    using CompileTimeSwitch
28        = typename CompileTimeSwitch_<TBooleanCont, TFunCont>::type;

这是我们看到的首个相对复杂的元函数。让我们分析一下它的工作原理。

第1~2行是CompileTimeSwitch_的声明,它表明这个模板接收两个类型作为模板参数。这两个类型实际上对应了bool类型与结果数组。

第4~18行与第20~24行是CompileTimeSwitch_的两个特化,它们形成了一个循环。第4~18行包含了循环的主体逻辑:它通过一个conditional_t判断当前的bool值(curBool)是否为true,并根据判断的结果来确定是调用第15行返回当前分支curFunc,还是通过调用第16~17行进行下一步的循环。

第20~24行则包含了循环的结束逻辑:如果在之前的判断中,没有任何一个bool值为true,同时存在默认分支,那么直接返回默认分支。

在构造了CompileTimeSwitch_元函数的基础上,通过如下代码构造CompileTimeSwitch:

1    template <typename TBooleanCont, typename TFunCont>
2    using CompileTimeSwitch
3        = typename CompileTimeSwitch_<TBooleanCont, TFunCont>::type;

CompileTimeSwitch会作为一个辅助元函数用于深度学习框架的打造。

我们回顾一下之前的代码:

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 x2 = OnesCount<15>;

可以思考一下,编译器在编译这一段代码时,会产生多少个实例。

在第6行以7为模板参数调用元函数时,编译器将使用7310来实例化OnesCount,构造出4个实例。接下来第7行以15为参数传入这个元函数,编译器需要用157310来实例化代码。通常,编译器会将使用7310实例化出的代码保存起来。这样一来,如果后面的编译过程需要使用同样的实例,那么就可以复用之前保存的实例了。对于一般的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> 时,编译器会以543210为参数构造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行:一个逻辑“与”操作。对于“与”来说,只要有一个操作数不为true,那么系统将返回falce。但这种逻辑短路的行为在上述元程序中并没有被很好地利用到——无论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时,它才会实例化第二个操作数[16],否则它将直接返回false。在代码的第10~11行使用了AndValue以减少实例化的次数,同时也降低了代码的编译成本。

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

如果对惯用法划分等级,那么只包含一条语句的元函数是最低级的,在此之上则是顺序、分支与循环程序的编写方法。在掌握了这些工具后,我们就可以学习一些更高级的元编程方式——奇特的递归模板式(CRTP)就是其中之一。

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 Derived : public Base<Derived>
12    {
13        template <typename TI>
14        void Imp(const TI& input) {
15            cout << input << endl;
16        }
17    };
18
19    int main() {
20        Derived d;
21        d.Fun("Implementation from derived 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 Derived : public Base<Derived>
10    {
11        static void Imp() {
12            cout << "Implementation from derived class" << endl;
13        }
14    };
15
16    int main() {
17        Derived::Fun();
18    }

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

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

本书的后文将会使用这些方法打造深度学习框架。事实上,后文中大部分的讨论都可以被视为利用本章所讨论的方法来解决实际问题的演练。因此,读者完全可以将本书后文的内容看成本章所讨论方法的一个练习。这个练习的过程也正是带领读者熟悉本章知识点的过程。相信在读完本书之后,读者会对元编程有更加成熟的认识。

需要说明的是,我们可以使用本章的方法进行元编程,也可以选择一些其他的元编程方法。比如使用MPL这样的元程序库来实现数组、集合等数据结构及相关操作——某些元程序库所提供的接口与本章中讨论的数组处理技术看上去有很大不同,但实现的功能是类似的。使用本书描述的数组处理方式,就像在运行期使用C++的基本数组,而使用MPL这样的元程序库,则更像在运行期使用std::vector。本书不会讨论像MPL这样的元程序库的使用方法,因为笔者认为:如果基本数组没有用好,就很难用好std::vector。本书所传达给读者的是元编程的基础方法,相信读者在打好了相应的基础后,再使用其他高级的元程序库就会更加得心应手。

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

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

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

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

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

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

7.每种技术都有其适用的范围,比如,if constexpr在一些情况下会简化代码的编写,但在另一些情况下,则可能会造成代码编写上的麻烦。尝试使用if constexpr重新实现小节所讨论的Accumulate方法,分析本书所给出的实现与新实现的优劣。

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

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

[2] 代码使用了模板的部分特化以及C++11中的using特性。using特性可以实现typedef的功能,同时比typedef的用途更加广泛,读者可以参考C++ Primer等书籍学习。

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

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

[5] 事实上,这两个函数还是存在一些差异的:使用函数签名constexpr int fun(int)所引入的函数既可以在编译期调用,也可以在运行期调用;使用上述模板所引入的元函数则只能在编译期调用。

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

[7] 当然,作为最基本的元程序库,type_traits除外。

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

[9] 元数据域即编译期可访问的数据域。在这里,我们从元数据域的角度来审视该代码。在后文中,我们将会看到,还可以从顺序执行逻辑的角度来审视该代码。正如前文所述,对于同一段代码,我们可以从不同的角度来观察它,从而更深入地理解它。

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

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

[12] http://en.cppreference.com/w/cpp/types/enable_if。

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

[14] 如果一定要这么做也不是不可以,但可能需要借助数值,并使用decltype获取相应的类型来实现,这是比较麻烦的。

[15] 即第2个数组中包含的元素个数与第1个数组中包含的bool值个数相同。

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


相关图书

ChatGPT原理与应用开发
ChatGPT原理与应用开发
深度学习的数学——使用Python语言
深度学习的数学——使用Python语言
深度学习:从基础到实践(上、下册)
深度学习:从基础到实践(上、下册)
动手学深度学习(PyTorch版)
动手学深度学习(PyTorch版)
深度学习与医学图像处理
深度学习与医学图像处理
深度强化学习实战:用OpenAI Gym构建智能体
深度强化学习实战:用OpenAI Gym构建智能体

相关文章

相关课程