7.1 template

这一节的焦点放在 template 的语意上, 讨论 templates 在编译系统中"何时","为什么"以及"如何"发挥其功能.下面是有关 template 的三个主要讨论方向:

1.template 的声明。就是当声明一个 template class,template class member function等等,会发生什么事情;

2.如何"实例化(instantiates)" class object 、inline nonmember,以及member template functions,这些是"每一个编译单位都会拥有一份实例"的东西。

3.如何"实例化(instantiates)"nonmember、member template functions,以及 static template members,这些都是"每一个可执行文件中只需要一份实例"的东西。

使用"实例化(instantiation)"这个字眼来表示"进程 process 将真正的类型和表达式绑定到template 相关形式参数(formal parameters)上"的操作.例如,下面是一个 template function:

template <class Type>
Type min(const Type &t1, const Type &t2){ ... }

用法如下:

min(1.0, 2.0);

于是进程就把 Type 绑定为 double 并产生min()的一个程序文本实体(并适当施以mangling手术,给它一个独一无二的名称),其中t1和t2的类型都是 double。

Template 的"实例化"行为 (Template Instantiation)

考虑下面的 template Point class:
template <class Type>
class Point 
{
public:
    enum Status{ unallocated, normalized };
    Point(Type x = 0.0, Type y = 0.0, Type z = 0.0};
    ~Point();
    void *operator new(size_t);
    void operator delete(void *, size_t);
    // ...
private:
    static Point<Type> *freeList;
    static int chunkSize;
    Type _x, _y, _z;
};

首先当编译器看到 template class 声明时,它会做出什么反应?
在实际程序中,什么反应也没有!也就是说,上述的 static data members并不可用,nested enum 或其他 enumerators也一样.
虽然 enum Status 的真正类型在所有的Point instantiations中都一样,其enumerators也是,但它们每一个都只能够通过 template Point class 的某个实体来存取或操作,因此可以这样写:(枚举类只能通过实例来存取操作)

Point<float>::Status s;

但不能这样写:

Point::Status s;	// error

希望这个enum只有一个实例被生产出来,避免多份拷贝。或者把 enum 抽取到一个 nontemplate base class 中。

同理,freeList 和 chunkSize 对程序而言也还不可用,不能够写:

Point::freeList;	// // error

必须明确地指定类型,才能使用freeList:

Point<float>::freeList;	// // ok

像上面这样使用 static member,会使其一份实体与 Point class 的 float instantiation在程序中产生关联.如果写:

Point<double>::freeList;	// // ok

就会出现第二个freeList实体,与Point class 的 double instantiation产生关联。

如果定义一个指针,指向特定的实体,像这样:
Point<float> *ptr = 0;

程序中什么也没有发生,为什么呢? 因为一个指向 class object 的指针,本身并不是一个 class object,编译器不需要知道与该 class 有关的任何members的数据或object布局数据.所以将"Point的一个float实体"实例化也就没有必要。

如果不是 pointer,而是 reference,又如何?

const Point<float> &ref = 0;

是的, 它真的会具现出一个"Point的float实体"来,这个定义的真正语意会被扩展为:

// 内部扩展
Point<float> temp(float(0));
const Point<float> &ref = temp;

因为reference并不是无物(no object)的代名词。0 被视为整数,必须被转换为以下类型的一个对象:

Point< float >

所以,一个 class object的定义,不论是由编译器暗中做,或由程序员像下面这样明确地做:

const Point<float> origin;

都会导致 template class 的 "实例化",也就是说,float instantiation 的真正对象布局会被产生处理。
然而 member functions不应该被"实例化,只有在member functions被使用的时候,C++ Standard才要求它们被"具现"出来.当前的编译器并不精确遵循这项要求。之所以由使用者来主导"具现"规则, 有两个主要原因:
1.空间和时间效率的考虑。如果 class 中有100个member functions,但程序只针对某个类型使用其中两个,针对另一个类型使用其中五个,那么将其他93个函数都"具现"将花费大量的时间和空间.
2.尚未实现的机能。并不是一个 template 具现出来的所有类型就一定能够完整支持一组member functions所需要的所有运算符。如果只"具现"那些真正用到的member functions,template 就能够支持那些原本可能会造成编译时期错误的类型。
例如,origin的定义需要调用Point的default constructor和destructor,那么只有这两个函数需要被"具现"。类似的道理,当程序员写:

Point<float> *p = new Point<float>;

时,只有(1)Point template 的 float 实例;(2)new 运算符;(3)default constructor需要被"实例"化。静态成员函数也依赖正真的 template 类型。C++模板类中的静态成员函数需要在头文件里定义,否则会出现LNK2019,找不到所定义的函数。也就是说对于静态函数,C++的编译器默认是不会查找相应的源文件的。

这些函数在什么时候"具现"出来呢?当前流行两种策略:
Ⅰ、在编译时候,那么函数将"实例化"于 origin 和 p 存在的那个文件中.
Ⅱ、在链接时候,那么编译器会被一些辅助工具重新激活。template 函数实体可能被放在这个文件中,别的文件中,或一个分离的储存位置上。

Template的错误报告 (Error Reporting within a Template)

template <class T>
class Mumble
{
public$:	// 4
	Mumble(T t = 1024): _t(t)	// 5
	{
		if (tt != t){ throw ex ex; }	// 7
	}
private:
	T tt;
}	// 11

L4:$字符错误。一,$不是合法的标识符;二,class 声明中只允许public, protected, private 三个标签,$使public$不能正确显示为public。
L5:t 的初始化整型常量 1024,要视T真是类型而定,只有实例化之后才能确定是否可行。
L5:_t不存在。会在 “类型检验” 阶段找出。每一个名称必须绑定到一个定义上。
L7:!=运算符可能已定义好,可能没有。视T真正类型定。
L7:意外的键入ex两次。在编译期解析阶段发现。
L11:没有以分号作为 class 声明的结束。编译器语句分析阶段被发现。
在一个nontemplate class 声明中,这个6个错误会被编译器挑出来。但 template class 却不同。所有与类型有关的检验,如果涉及到 template 参数,

Template 中的名称决议法

区分:
scope of the template definition [模板定义域],定义出template的程序端
scope of the template instantiation[模板实例化域],实例化template的程序端

// scope of the template definition【模板定义域】
extern double foo(double);

template<class type>
class ScopeRules
{
public:
	void invariant() {  _member = foo( _val ); }
	type type_dependent() { return foo( _member ); }
private:
	int _val;
	type _member;
}

然后第二种情况举例说明:

// scope of the template instantiation【模板具现域】
extern int foo(int);

ScopeRules<int> sr;

在ScopeRules template中有两个foo调用操作。
在“scope of the template definition”中,只有一个foo函数声明位于scope之内。
在“scope of the instantiation”中,两个foo函数版本都位于scope之内。
有一个这样的调用操作:

// scope of the template instantiation
sr.invariant();		// 调用哪一个foo()函数实例?

在template之中,对于一个非成员名字的决议结果,是根据这个名字是否和“用来实例化该模板的实际参数类型”有关来决定的。
Ⅰ、如果非成员名的使用和实例化模板用的实际参数无关,就以“scope of the template declaration【模板声明域】”来决定名字归属;
Ⅱ、如果有关联,那么就以“scope of the template instantiation【模板具现域】”来决定名字归属。

invariant中的foo与我们用来具现模板的参数int无关,所以使用“scope of the template declaration”中的foo函数,下面是更详细的解释:

// foo函数的决议结果并不依赖于模板参数
_member = foo( _val );

_val在模板声明中,已经被定义成一个int类型,无论具现这个ScopeRules的实际参数是什么类型,都不会影响到_val本身。还有,函数的决议结果只和函数的signature【原型】有关,和函数的返回值类型无关。所以,模板具现后的_member类型并不会影响到哪一个foo函数体被选中。所以调用操作根据“scope of the template declaration”来决议,而在“scope of the template declaration”域中,只有一个double版本foo函数,所以自然只有一个候选者。

下面看看“与具现模板类型”【type-dependent】有关的用法:

sr.type_dependent();

这个函数的内容如下:
return foo(_member);
这个例子与上一个例子不同,因为_member肯定与具现ScopeRules的参数有关:该参数将决定_member的实际类型。所以这一次foo必须在“scope of the template instantiation”域中被决议。这个域中就有了两个foo版本函数,但由于_member的被具现后的类型是int,因此这次就是int版本的foo函数出线。但是,如果ScopeRules是被unsigned int或long具现出来,那么这里的foo选择就会模糊不清。最后,如果ScopeRules是被某一个用户自定义类类型具现出来,而该类没有针对int和double实现conversion运算符【转换运算符】,那么foo的调用操作会被标识错误!但不管情况如何,都是以“scope of the template instantiation”来决定,而不是“scope of the template declaration”来决定。

这意味着编译器必须保持两个scope contexts【上下文范围】:

1):“scope of the template declaration”,用来专注于一般的template class;
2):“scope of the template instantiation”,用来专注于特定的实体;

编译器的resolution【决议】算法必须决定哪一个才是适当的scope,然后在其中搜索适当的name。

// bzf:两个域;函数原型;和函数返回类型无关。

Member Function 的实例化行为

对于template 的支持,最困难的就是template function的instatiation【具现】。目前编译器提供了两种策略:一个是编译时期策略——程序代码必须在program text file【程序文本文件】中备好可用;另一个是链接时期策略,有一些meta-compilation【元编译】工具可以导引编译器的实例化行为。

编译器的设计者必须回答的三个问题:

Ⅰ、编译器如何找出函数的定义?

一是包含template program text file【模板程序文本文件】,就好像它是个头文件一样。另一个方法是要求一种文件命名规则,例如可以要求:在Point.h中发现的函数声明,其 template program text file一定要放在Point.C或Point.cpp文件中,以此类推。

Ⅱ、编译器如何能够只实例化程序需要用到的函数?

解决办法之一就是忽略这项要求,把一个已经实例化的class的所有成员函数都实例化出来。另一种方法——模拟链接操作,检测看一下哪个member function是程序真正需要的,然后只为它或它们产生实体。

Ⅲ、编译器如何阻止member definitions在多个.o文件中都被具现出来呢?

一是产生多个实体,然后从链接器中提供支持,只留下其中一个实体,其余的都忽略。另一个办法是由用户自己来导引“模拟链接阶段”的实例化策略,决定哪些instances才是所需的。

目前是编译时期还是链接时期的instantiation策略,都存在弱点:当template实例被产生出来时,有时候会大量增加程序的编译时间。显然,这是模板函数第一次实例化时的必要条件。然而当这些模板函数被非必要的再次实例化时,或者当“决定那些模板函数是否需要被再次实例化”代价太大!

c++支持模板的原始意图是一个由用户导引的use-directed automatic instantiation mechanism【自动实例化机制】:既不需要用户的介入,也不需要相同文件有多次的具现行为。



Edison Design Group开发出的第二代directed-instantiation【直接具现】机制,非常接近于template facility【模板工具】的原始含义。它的主要过程如下:

1):一个程序的代码被编译时,最初并不会产生任何模板具现体,相关信息被产生于object files之中。

2):当object files被链接到一块时,会有一个prelinker程序被执行,该程序会检查object files,寻找模板实体的相互参考以及对应的定义。

3):对于每一个参考到的模板实体而该实体却没有定义的情况,prelinker会将该文件看作于另一个文件【在这个文件中,实体已经定义】的同类。用这种方法,就可以把必要的程序具现操作指定给特定的文件。这些都会注册在prelinker所产生的.ii文件【放在磁盘目录ii_file】中。

4):prelinker重新执行编译器,重新编译每一个“.ii文件曾被改变过”的文件。这个过程不断重复,直到所有必要的具现操作都已经被完成。

5):所有的object files都被链接成一个可执行问价。

上面这个directed-instantiation【直接具现】机制的主要成本在于,程序被第一次编译时的.ii文件设定时间;次要成本则是必须针对每一个compile afterwords【后续编译】执行prelinker,以确保所有被参考到的模板们都存在定义。在最初的设定以及成功的第一次链接后,重新编译操作包含以下程序:

1):对于每一个将被重新编译的program text file,编译器会检查其对应的.ii文件。

2):如果这些对应的.ii文件能列出一组要被具现的模板们,那么这些模板将在此次编译时被具现。

3):prelinker必须执行起来,确保所有参考到的模板们都已经被定义好。

不幸的是,所有的机制都存在一定bugs。Edison Design Group的编译器使用了一个由cfront2.0引入的算法,在大部分情况下,针对程序的每一个class自动产生虚函数表的单一实体,例如下面的class声明:

class PrimitiveObject: Geometry
{
public:
  virtual ~PrimitiveObject();
  virtual draw();
   ...
}
如果它被包含在几十个源码文件中,编译器如何确保只有一个虚函数表被产生出来呢?倒是产生几十个虚函数表比较容易。
Andy Koenig【应该是一个c++编译器设计值】用下面的方法解决上面的问题:每一个虚函数的地址都被放置在active classes的虚函数表中,如果取得了函数地址,则意味着虚函数的定义肯定出现在程序的某个地点,否则程序就无法链接成功。此外,该函数只能有一个实体,否则也是链接不成功。那么,就把虚函数表放在定义了该class的第一个non-inline、nonpure virtual function的文件中吧。以我们上面的例子而言,编译器会把虚函数表放在存储着虚析构器的文件之中。

然而在template之中,这种单一定义并不一定为真,在template所支持的将模块中的每一样东西都编译的模型下,不只是多个定义可能被产生,而且链接器也放任让多个定义同时出现,它只要选择其中一个忽略掉其它的就可以了。

Edison Design Group机制会做什么事呢?考虑下面这个library函数:

void foo(const Point<float>* ptr)
{
  ptr->virtual_func();
}
其中的虚函数调用会被转换成类似这样的东西:
//c++伪码
//ptr->virtual_func();的转换后形式
(*ptr->_vtbl_Point<float>[2])(ptr);
这会具现出Point类的一个float实体以及它的虚函数func()。由于每一个虚函数的地址被放在虚函数表中,如果虚函数表被产生出来,那么里面的每一个虚函数也必须被具现出来,就像C++ standard所说:如果一个虚拟函数被具现出来,那么它的具现点应紧跟在它所属的class具现点之后。
      然而,如果编译器遵循cfront的虚函数表实现机制,那么在“Point的float实体有一个virtual destructor定义被具现出来”之前,这个虚函数表不会被产生,除非在这一点上,并没有明确使用virtual destructor以担保其具现行为。

      Edison Design Group的automatic template【自动模板】机制并不明确它自己的编译器对于一个non-inline、nonpure virtual function的隐晦使用,所以并没有把它标示于.ii文件中,所以,链接器反而回头抱怨下面这个符号并没有出现:

_vtbl_Point<float>
并拒绝产生一个可执行文件,automatic instantiation在此失效!程序员必须明确的强制将destructor具现出来,目前的编译系统是以(#pragmatic)指令来支持此要求。然而C++ standard也已经扩充了对template的支持,允许程序员明确地要求在一个文件中将整个模板类具现出来:
template class Point3d<float>;
或者针对一个模板类的个别成员函数:
template class Point3d<float>::X() const;
再或者是针对个别模板函数:
template class Point3d<float> operator+(const Point3d<float>&, const Point3d<float>&);
      在实现层面上,template instantiation【模板具现】似乎拒绝全面自动化。甚至虽然每一个工作都做对了,产生出来的object files的重新编译成本还是太高。所以用手动方式先在个别的object module【对象模块】中完成pre-instantiation【预先具现】,虽然沉闷,但也是唯一有效率的办法。
posted @ 2021-04-26 15:09  点|滴  阅读(16)  评论(0)    收藏  举报