C++ 单独编译(separate compilation)与 模板的编译
前言:
C++ template是泛型编程的基础,一提起模板可能很多人都了解,但是真正用起来可能出现一堆问题。在很多项目中,都大量用到了template编程,像STL、tensorflow源码等。很多人都知道C++模板的定义和实现不能分开,否则会出现Link error: undefined reference to,但是当你阅读优秀的源码,比如tensorflow,会发现人家的代码模板的定义和实现就是分开写的(所以以后可以抛弃模板的定义和实现必须不能分开的想法),那他们是怎么做到的?其实这并不难,主要依赖于C++编译代码的方式。
1.C++ separate compilation
C+ +语言支持separate compilation,即单独编译。也就是说.cc文件里的东西都是相对独立的,在编译一个.cc文件时是不知道任何其他的.cc文件的存在,只需要在编译成目标文件(.obj文件)后再与其他的目标文件做一次链接(link)就行了。
举例来说,在文件a.cc中定义并实现了一个函数void a();在b.cc中调用这个函数。代码如下:
//file a.cc #include<iostream> void a() { std::cout <<"a()"<< std::endl; }
//file b.cc #include<iostream> void a(); int main() { a(); return 0; }
然后执行:g++ a.cc b.cc编译。在编译的过程中a.cc与b.cc并不知道对方的存在,而是分别编译成目标文件,然后再由编译器进行链接,整个程序就生成好了。这是怎么实现的呢?在b.cc中调用void a()函数之前,先进行声明,这样编译b.cc就会生成一个符号表,像void a()这样的只有声明没有实现的函数就放在这个表中,链接的时候再去其他目标文件中寻找这个符号的实现,一旦找到了就顺利生成可执行程序,否则便会报错:Link error: undefined reference to ‘a()’.
这种编译机制给c++程序带来的好处是:当一个函数被很多的.cc文件调用时,只需要在那些.cc文件中声明这个函数就可以了。
但是设想假如一个文件中实现了100个函数,而在许多其他文件中都需要这100个函数,那么按照这种机制,你就必须将100个函数的声明都复制一遍,粘贴到需要使用这些函数的.cc文件中(庆幸你还能使用复制+粘贴的功能吧)。这显然是很麻烦的,因此头文件(.h文件)也就诞生了(这就是为什么一般头文件只放声明的原因)。
.h文件的内容和.cc一样,都是c++的源代码,但是.h是不被编译的,一般头文件只放各种函数的声明,在需要这些函数的地方使用宏#include包含这个头文件(本质就是复制粘贴,相信写c++的程序员都知道),这样就解决了上面的问题。
把上面提到的单独编译的特性理解了就不难理解模板编译的问题了。
2.为什么模板的定义和实现必须放在一起?
首先说一下模板的编译,看一段简单的代码:
#include<iostream> template <typename T> void Print(T a) { std::cout << "Print():"<< a <<std::endl; } int main() { Print(100); return 0; }
执行g++ template.cc编译程序,那么这段代码是怎么编译的呢?简单来说就是模板的编译是需要“类型推导”的,当编译器看到Print(100)的时候,就会将T换成int做类型推导(因为100是int类型),生成一份void Print(int)的代码。同理假如你再写一个Print(6.f),那么T就被换成了float,再生成一份void Print(float)的代码,所以当后面你去调用的时候,才能找到对应类型的函数。但是编译器绝对不会为你生成所有类型的代码,因为这样代价太高了,毕竟类型那么多。这也是正常的,毕竟你写了什么我就给你什么才是对的。
也就是说函数模板(或者struct)的编译,会根据你的具体调用生成相应的代码。但是假如你没有任何调用(或者特化,特化这个概念后面再提),那么编译器也不会生成任何与此模板相关的代码。
然后再回到模板声明和实现分开的问题。将a.cc与b.cc改写成函数模板,如下所示:
#include<iostream> template <typename T> void a(T a) { std::cout <<"a()"<< std::endl; }
template <typename T> void a(T a); int main() { a(100); return 0; }
然后用同样的方式编译:g++ a.cc b.cc,结果你会发现这次居然报错:undefined reference to `void a<int>(int)',也就是说编译器根本没有进行函数模板的类型推到,并生成相关的代码。有的人会说我明明写了a(100),为什么模板没有进行类型的推到。结合“单独编译”与“模板的编译”,我想应该很容易明白这是为什么(可以自己尝试想一想)。
原因如下:
①根据前面提到的单独编译特性:a.cc编译的时候根本不知道b.cc的存在,因此编译器编译a.cc的时候是不知道b.cc中调用a(100)这个事情
②再结合模板编译的特性:因为编译a.cc的时候,并不知道b.cc中的调用,这就等价于编译器看不到任何关于模板函数的调用(或者特化),因此a.cc中的代码编译之后不会生成任何代码。
这下明白了吧!一切都是编译器单独编译+模板编译导致的结果。
所以当你在.h中定义模板,并在.cc中实现模板的时候,就注定你会错误。这样的写法在编译程序的时候,.cc文件的编译得不到任何结果,等价于白写。
但是前面我也提到了,很多优秀的源码,模板定义和实现是分开的,那人家是怎么实现的呢?这就又涉及到了模板的特化。
3.怎么能将模板的定义和实现分开
将a.cc再稍加改动,程序就可以通过编译了,如下所示:
#include<iostream> template <typename T> void a(T a) { std::cout <<"a()"<< std::endl; } template <> void a<int>(int a) { std::cout <<"a<int>()"<< std::endl; }
最下面4行就是对函数模板void a(T a)的全特化,将其全特化为int类型,这样编译器编译的时候就会生成一份T为int的代码,就可以编译通过了(上面的仅仅是示例代码,真实项目中肯定不会这样用,因为这样特化还不如直接启用函数重载来的快,上面的例子只是为了说明用法。在一般情况下,结构体的全特化与偏特化用的较多一点,后面我会提到)。
模板特化的好处是可以根据函数参数的不同类型,使函数运行不同的逻辑。以上面的例子为例,当T为int时,打印a<int>(),你可以仿照上面的例子写一个float的全特化,然后打印出a<float>().
另外注意目前模板函数只支持全特化,不支持偏特化(所以函数模板的全特化就有点像函数重载了,因此几乎不会有人全特化函数模板),函数模板的使用更多的还是用在函数逻辑与类型无关的情况下,比如数学上常用的max函数,这就解决了函数重载代码重复的问题,也是模板存在的意义。
4.仿函数与偏特化
前面提到了,函数模板的全特化就类似函数重载了,因此一般不会对函数模板进行全特化。应用更多的则是偏特化。
设想现在有这样一个问题:一个函数a有两个模板参数,即void a(T a, R b)。你希望当T为int是打印的是:“int:” + b,当T为float时打印的是“float:” + b,而R的类型可以任取,你会怎么解决?
如果能够使用函数模板的偏特化(偏特化就是部分特化,只将模板参数的一部分指定为固定类型,英文为Partial specialization),那么你可能想这样解决:
#include<iostream> template <typename T, typename R> void a(T a, R b); template <typename R> void a<int, R>(int a, R b) { std::cout <<"int:" << b << std::endl; } template <typename R> void a<float, R>(float a, R b) { std::cout <<"float:" << b << std::endl; } int main() { a(10,100); return 0; }
但是遗憾的是,但你编译的时候得到错误:error: non-type partial specialization ‘a<int, R>’ is not allowed,即我前面说的,函数模板是不支持偏特化的。那么到底该怎么办?如果用函数重载的话,你得写多少函数啊!显然函数重载也不是好的办法。
答案就是:结构体是支持偏特化的。可以将函数放在结构体中,对结构体进行偏特化。对上面的代码稍加改动,如下所示:
#include<iostream> template <typename T, typename R> struct P { void a(T a, R b); }; template <typename R> struct P<int, R> { void a(int a, R b) { std::cout <<"int:" << b << std::endl; } }; template <typename R> struct P<float,R> { void a(float a, R b) { std::cout <<"float:" << b << std::endl; } }; int main() { P<int,int> p1; p1.a(0,0); P<float,int> p2; p2.a(0.f,0); return 0; }
这样就可以解决上面提到的问题。但是还有一个小问题:我们定义这个结构体只是为了其中的一个函数,那么我每次调用还要创建一个结构体对象。怎么才能这个问题呢?其中之一就是static函数,可以将函数声明为static,这样通过作用域符号::就可以调用函数,例如:P<int, int>::a(0, 0) 或者 P<float, int>::a(0.f, 0)读者可以自己试一下。这种方法利用了结构体或类内静态函数的特性。
还有一种应用更广泛的用法,就是仿函数(Functor),仿函数简单来说就是让结构体对象有函数的特性,实现上要重载operator()函数(建议自己先看一下什么是仿函数)。将上面的代码再进行简单的修改,如下所示:
#include<iostream> template <typename T, typename R> struct P { void operator()(T a, R b); }; template <typename R> struct P<int, R> { void operator()(int a, R b) { std::cout <<"int:" << b << std::endl; } }; template <typename R> struct P<float,R> { void operator()(float a, R b) { std::cout <<"float:" << b << std::endl; } }; int main() { P<int,int>()(0,0); P<float,int>()(0.f,0); return 0; }
这样这个调用看起来就十分像函数了。
最后再考虑一个问题:怎么将上面的代码拆开成不同的文件。毕竟这样代码结构更清晰。
前面提到了模板编译是需要类型推导的,必须指明模板参数的全部类型,才能生成对应的代码。偏特化也一样,如果仅仅将上面的代码简单的分开,结果还是会报:error:undefined reference to,错误原因与之前完全相同。那么怎么才能将声明与实现分开呢?
答案还是全特化,但是是在偏特化之后再进行全特化,这样仅仅需要一行代码就能实现,如下所示:
//file a.h #include<iostream> template <typename T, typename R> struct P { void operator()(T a, R b); };
#include <iostream> #include "a.h" template <typename R> struct P<int, R> { void operator()(int a, R b) { std::cout <<"int:" << b << std::endl; } }; template <typename R> struct P<float,R> { void operator()(float a, R b) { std::cout <<"float:" << b << std::endl; } }; template struct P<int,int>; template struct P<float,int>;
#include <iostream> #include "a.h" int main() { P<int,int>()(0,0); P<float,int>()(0.f,0); return 0; }
将三个文件a.h a.cc main.cc放在同一个文件夹下,执行:g++ a.cc main.cc即可通过编译。
可以看到在a.cc最后两行就是对模板偏特化之后的全特化,这样就能做到一行代码全特化一个函数模板,生成一份对应的代码。
5.其他
上面提到的技巧,在许多开源项目中应用广泛,以tensorflow源码为例。tensorflow分CPU版本和GPU版本,在其源码实现中就大量利用了仿函数的概念,首先偏特化cpu和gpu两个版本(因为不同设备计算的方法肯定不相同),然后再进行不同类型的全特化。这和我上面提到的例子完全相同,其源码实现也类似我上面的实现方式。STL中基本都是基于模板实现的,其设计思想是c++泛型编程的精粹,c++很多设计思想都是源自STL源码,感兴趣的可以阅读《泛型编程与STL》)。