5.4 名称查找问题

当编译器碰到一个标识符时,它必须能够确定这个标识符所代表的实体的类型和作用域(如果它是一个变量,就是生存期)。模板的引入增加了这个问题的复杂度。当编译器首次看到一个模板定义时它不知道有关这个模板的任何信息,只有当它看到模板的实例化时,它才能判断这个模板是否被正确地使用了。这种状况导致了模板编译需要分两个阶段进行。

5.4.1 模板中的名称

在第1阶段,编译器解析模板定义,寻找明显的语法错误,还要对它所能解析的所有名称进行解析。对于不依赖于模板参数的名称,编译器使用普通名称查找的方法解析它们,如果有必要,编译器也会依赖模板参数进行查找(在后面讨论)。它不能够解析的名称就是所谓的关联名(dependent name),这些名称以某种方式依赖于模板参数。只有等到用实际参数来实例化模板的时候,这些名称才能被解析。因此模板编译的第2个阶段就是模板实例化。在这里,由编译器来决定是否使用模板的一个显式特化来取代基本的模板。

在看下面的例子之前,必须至少理解两个术语。限定名(qualified name)是指具有类名前缀,或者是被一个对象名加上点运算符修饰,或者是被一个指向某一对象的指针加一个箭头运算符所限定的名称修饰。限定名举例如下:

5.4 名称查找问题 - 图1

本教材中多次使用限定名,最近的用法是把它与typename关键字相联系。之所以称为限定名是因为这些目标名(如上面例子中的f)被明确的与一个类或者与一个名字空间相联系,它们会告诉编译器应该去哪儿寻找这些名称的声明。

另一个术语是关联参数查找(argument-dependent lookup[1],ADL),这个机制起初是设计用来简化在名字空间中声明的非成员函数调用(包含运算符)。看下面的代码:

5.4 名称查找问题 - 图2

请注意,在头文件中的典型习惯用法中,没有使用using namespace std指令。没有了这条指令,就必须使用“std:”来限定std名字空间中的每项内容。但是,在这里并没有用它来限定std中的所有内容。你能知道哪一个是不合格的吗?

在程序中没有指定使用哪一个运算符函数。程序员希望下述事情发生,但却不想键入这些代码:

5.4 名称查找问题 - 图3

为了使最初的输出语句能够按照预先的设计执行,ADL规定:当出现了对某个非限定函数的调用,而该非限定函数却没有在一个(标准)作用域内进行声明时,编译器为了匹配这个函数声明,就会寻找它的每一个参数的名字空间来进行匹配。在最初的语句中,第1个函数调用是:

5.4 名称查找问题 - 图4

由于在初始引用的名字空间作用域中没有这个函数声明,编译器注意到这个函数的第1个参数(std:cout)在名字空间std中;因此它就把这个名字空间添加到作用域列表中,以此来寻找一个能完美匹配operator<<(std:ostream&,std:string)的独一无二的函数。它通过<string>头文件发现这个函数是在std名字空间中声明的。

没有ADL,名字空间的使用将会非常的不方便。注意,ADL通常从所有合格的名字空间中引入存有质疑的名称的所有声明—若没有一个最好的匹配,将会产生二义性。

为了避开ADL不用,可以将函数名称置于一对圆括号中:

5.4 名称查找问题 - 图5

现在来看看下面的程序:[2]

5.4 名称查找问题 - 图6

本程序使用的编译器是支持前端兼容用法的Edison Design Group(EDG)[3],上面的代码在这个编译器上不用修改就能正确执行。某些编译器,例如Metrowerks,可以通过配置选项实现正确的查找。输出结果应该是:

5.4 名称查找问题 - 图7

这是因为f是一个非关联的名称,它早在定义模板的过程中就已经解析了,那时只有f(double)在模板的作用域之中。遗憾的是,在实际的应用系统中存在着大量的依赖非标准行为的不规范代码,即将g()中f(1)的调用与其后的f(int)绑定在了一起,因此编译器的编写者也就不情愿的去做改动了。

下面是一个更详细的例子:[4]

5.4 名称查找问题 - 图8

5.4 名称查找问题 - 图9

这个程序的输出应该是:

5.4 名称查找问题 - 图10

看看X:f()中的声明:

·E,是X:f()的返回类型,它不是一个关联名称,因此在解析模板的时候它就被找到了。编译器找到了E后,用typedef将E命名成一个double类型。这种情况可能看起来有点奇怪,因为在非模板类中,E在基类中的声明应该先被找到,但那是非模板类的规则。(基类Y,是一个关联基类(dependent base class),因此在模板定义期间不能够找到它)。·g()的调用也是不依赖参数类型的,因为它没有用到T。若g带有某些定义在另一个名字空间中的类类型的参数,则ADL就会将这个名字空间接管过来,因为在它的作用域内没有带有这种参数的g定义。因此,这个调用匹配了g()的全局声明。

·this->h()调用是一个限定名称调用,限定它的对象(this)指的是当前对象,即该当前对象是X类型的,X通过继承机制又依赖于名称Y<T>。X中没有函数h(),因此查找将去X的基类Y<T>作用域内寻找。由于这是一个关联名称,它在实例化期间进行查找,Y<T>此时已经完全准确地知道(包括可能在X的定义之后编写的任何可能的特化)。因此它调用的是Y<int>:h()。

·t1和t2的声明是关联的。

·对operator<<(cout, t1)的调用也是关联的,因为t1的类型为T。它是在T已经确定为int后才进行查找,并且在std中找到后添加了int。

·swap()的非限定调用也是关联的,因为它的参数是类型T。这从根本上引起了全局swap(int&,int&)的实例化。

·std:swap()的限定调用不是关联的,因为std是一个固定的名字空间。编译器知道去std中寻找合适的声明。(“:”左边的限定词必须为关联的限定名称提及一个模板参数)。之后std:swap()函数模板在实例化期间生成std:swap(int&,int&)。X<T>:f()中再也没有关联名称了。

综上所述:若名称是关联的,则它的查找是在实例化时进行,非限定的关联名称除外,它是一个普通名称查找,它的查找进行的比较早是在定义时进行。所有模板中的非关联名称被较早地查找,这种查找是在模板定义被解析的时候进行。(若有必要,这种名称还有另一种实例化期间的查找,此时实际参数类型是已知的。)

如果读者已经理解了我们讨论过的例子,请准备好学习下一节有关friend声明的内容,它也许会带给读者另一个惊奇。

5.4.2 模板和友元

在类中声明一个友元函数,就允许一个类的非成员函数访问这个类的非公有成员。若友元函数的名称是被限定的,则将会在限定它的名字空间或类中找到它。但是,如果它是非限定的,编译器必须假定在某处能找到这个友元函数的定义,因为所有的标识符必须有一个惟一的作用域。编程人员希望把这个函数定义在最近的封装名字空间(而非类)作用域内,这个作用域内也包括与那个函数有友元关系的类。通常这个作用域就是全局作用域。下面的非模板例子清晰地阐明了这一点:

5.4 名称查找问题 - 图11

Friendly类中f()的声明是非限定的,因此编译器希望能最终将这个声明链接到位于文件作用域(在本例中就是包含Friendly的名字空间作用域)中的它的定义上。它的定义出现在函数h()的定义之后。然而,h()中对链接到同一函数的f()的调用却是一件单独的事情。这个过程由ADL进行解析。由于h()中的f()的参数是一个Friendly对象,为了匹配f()的声明,编译器将会去寻找Friendly类,并将成功找到。如果调用的是f(1)(这是很有意义的,因为1能被隐含地转变为Friendly(1)),这个调用将会失败,因为没有任何提示来通知编译器应该去哪儿寻找这个f()的声明。在这种情况下,EDG编译器会正确地解释f没有定义。

现在假定Friendly和f都是模板,程序如下所示:

5.4 名称查找问题 - 图12

首先要注意到Friendly中f的声明里的尖括号。这是必要的,它告诉编译器f是一个模板。否则,编译器就会去寻找一个名为f的普通函数而不会找到它。读者可能会在尖括号里加上模板参数(<T>),但它其实可以很容易地从声明中推断出来。

在类定义之前,提前声明函数模板f是很有必要的,虽然在前面的例子中并没有这个声明,因为当时f不是模板;这句话的意思清楚表明:友元函数模板必须提前声明。为了恰当地声明f, Friendly也必须在它之前进行声明,因为f具有一个Friendly参数,因此Friendly的声明在程序开始的最前面。也可以将f的完全定义放在Friendly的初始声明之后,这样就避免了将它的定义和声明分离开,但选择这样做是为了更接近上一个例子的格式。

为了在模板中使用友元,最后还可以选择这样做:在主类模板定义中完全地定义友元函数。下面来看看上例在这种情况下是如何修改的:

5.4 名称查找问题 - 图13

5.4 名称查找问题 - 图14

本例和前面的例子有一个重要的区别:在这里f不再是一个模板,而是一个普通函数。(请记住,要表明f()是一个模板,尖括号是必不可少的。)Friendly类模板每次实例化的时候,就会生成一个新的带有当前Friendly特化参数的普通重载函数。这就是为什么Dan Saks称之为“产生新友元”[5]的原因。这是为模板定义友元函数的最方便的方式。

为了说明得更清楚一些,假设现在要想向一个类模板中加入非成员友元运算符。下面只是个仅持有一个普通值的类模板:

5.4 名称查找问题 - 图15

有些初学者没有理解本节前面的例子,他们可能会灰心丧气。因为程序中没有一个简单的流输出插入符来验证程序所做的工作。如果不在Box的定义中定义自己的运算符,就必须提供早些时候讨论过的前置声明:

5.4 名称查找问题 - 图16

5.4 名称查找问题 - 图17

程序在这里定义了一个加运算符和一个输出流操作符。主程序揭示了这个方法的一个缺陷:无法使用隐式转换(如表达式b1+2),因为模板没有提供这些转换。使用内部类,非模板方法将会使程序变得短小、更强健:

5.4 名称查找问题 - 图18

由于运算符成员函数是普通函数(为Box的每一个特化进行的重载—本例中就是int),像平常一样,隐式转换也可以使用;因此表达式b1+2是合法的。

注意,有一个特殊的类型不能成为Box或其他任意类模板的友元,这个类型是T—或由T参数化的类模板类型。无论如何,找不到一个很合理的原因解释为什么不能这样用,但的确如此,friend class T这个声明是不合法的,也不能被编译。

友元模板

在程序中可以更精确地说明一个模板的哪些特化是类的友元。在上节的例子中,只有函数模板f是一个友元,它与特化Friendly的类型相同。举例来说,只有特化f<int>(const Friendly<int>&)才是类Friendly<int>的一个友元。这个例子是通过Friendly的模板参数来特化在其友元声明中的f的方式来实现的。若愿意,可以产生一个特别的、固定的f特化作为所有Friendly实例的一个友元,如下所示:

5.4 名称查找问题 - 图19

通过用double代替T, f的double特化可以访问任意Friendly特化的非公有成员。而f<double>()特化直到被明确调用时才会被实例化。

同样,若声明了一个参数不依赖于T的非模板函数,这个函数就是所有Friendly实例的一个友元:

5.4 名称查找问题 - 图20

同前面一样,由于g(int)是非限定的,他必须在文件作用域(包含Friendly的名字空间作用域)内定义。

也可以让f的所有特化成为所有Friendly特化的友元。只需要通过一个所谓的友元模板(friend template)来实现,如下所示:

5.4 名称查找问题 - 图21

由于友元声明的模板参数独立于T,因此任意的T和U的组合都允许使用,形成友元关系。像成员模板一样,友元模板也可以出现在非模板类中。

[1]也称为Koenig查找,因为Andrew Koenig首先向C++标准委员会建议了这种查找技术。ADL用一般概念阐述了模板是否应该包括于其中。

[2]这是Herb Sutter奉献的一个程序。

[3]很多编译器使用这种前端兼容用法,包括Comeau C++。

[4]这也是基于Herb Sutter的一个例子。

[5]来源于2001年9月份在波特兰的一次“C++研讨会”。