动手打造深度学习框架
上QQ阅读APP看书,第一时间看更新

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_具备了一个元函数所需要的全部性质:

  • 输入为某个类型信息T,以模板参数的形式传递到Fun_函数中;
  • 输出为Fun_函数的内部类型type,即Fun_<T>::type;
  • 映射则体现为模板通过特化实现的转换逻辑,若输入类型为int,则输出类型为unsigned int等。

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

  • 并不限制映射的表示形式——像前文所定义的以constexpr开头的函数,以及本小节讨论的提供内嵌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行在获取结果时没有采用依赖型的写法,因此函数名中没有下划线后缀。这种书写形式并非强制性的,本书选择这种形式,仅仅是为了风格上的统一。