2.3 即用即推导

优质
小牛编辑
121浏览
2023-12-01

2.3.1 视若无睹的语法错误

这一节我们将讲述模板一个非常重要的行为特点:那就是什么时候编译器会对模板进行推导,推导到什么程度。

这一知识,对于理解模板的编译期行为、以及修正模板编译错误都非常重要。

我们先来看一个例子:

template <typename T> struct X {};
	
template <typename T> struct Y
{
  typedef X<T> ReboundType;				    // 类型定义1
  typedef typename X<T>::MemberType MemberType;	// 类型定义2
  typedef UnknownType MemberType3;			  // 类型定义3

  void foo()
  {
    X<T> instance0;
    typename X<T>::MemberType instance1;
    WTF instance2
    大王叫我来巡山 - + &
  }
};

把这段代码编译一下,类型定义3出错,其它的都没问题。不过到这里你应该会有几个问题:

  1. 不是struct X<T>的定义是空的吗?为什么在struct Y内的类型定义2使用了 X<T>::MemberType 编译器没有报错?
  2. 类型定义2中的typename是什么鬼?为什么类型定义1就不需要?
  3. 为什么类型定义3会导致编译错误?
  4. 为什么void foo()在MSVC下什么错误都没报?

这时我们就需要请出C++11标准 —— 中的某些概念了。这是我们到目前为止第一次参阅标准。我希望能尽量减少直接参阅标准的次数,因此即便是极为复杂的模板匹配决议我都暂时没有引入标准中的描述。 然而,Template引入的“双阶段名称查找(Two phase name lookup)”堪称是C++中最黑暗的角落 —— 这是LLVM的团队自己在博客上说的 —— 因此在这里,我们还是有必要去了解标准中是如何规定的。

2.3.2 名称查找:I am who I am

在C++标准中对于“名称查找(name lookup)”这个高大上的名词的诠释,主要集中出现在三处。第一处是3.4节,标题名就叫“Name Lookup”;第二处在10.2节,继承关系中的名称查找;第三处在14.6节,名称解析(name resolution)。

名称查找/名称解析,是编译器的基石。对编译原理稍有了解的人,都知道“符号表”的存在及重要意义。考虑一段最基本的C代码:

int a = 0;
int b;
b = (a + 1) * 2;
printf("Result: %d", b);

在这段代码中,所有出现的符号可以分为以下几类:

  • int:类型标识符,代表整型;
  • a, b, printf:变量名或函数名;
  • =, +, *:运算符;
  • ,, ;, (, ):分隔符;

那么,编译器怎么知道int就是整数类型,b=(a+1)*2中的ab就是整型变量呢?这就是名称查找/名称解析的作用:它告诉编译器,这个标识符(identifer)是在哪里被声明或定义的,它究竟是什么意思。

也正因为这个机制非常基础,所以它才会面临各种可能的情况,编译器也要想尽办法让它在大部分场合都表现的合理。比如我们常见的作用域规则,就是为了对付名称在不同代码块中传播、并且遇到重名要如何处理的问题。下面是一个最简单的、大家在语言入门过程中都会碰到的一个例子:

int a = 0;
void f() {
  int a = 0;
  a += 2;
  printf("Inside <a>: %d\n", a);
}
void g() {
  printf("Outside <a>: %d\n", a);
}
int main() {
  f();
  g();
}

/* ------------ Console Output -----------------
Inside <a>: 2
Outside <a>: 0
--------------- Console Output -------------- */

我想大家尽管不能处理所有名称查找中所遇到的问题,但是对一些常见的名称查找规则也有了充分的经验,可以解决一些常见的问题。 但是模板的引入,使得名称查找这一本来就不简单的基本问题变得更加复杂了。 考虑下面这个例子:

struct A  { int a; };
struct AB { int a, b; };
struct C  { int c; };

template <typename T> foo(T& v0, C& v1){
  v0.a = 1;
  v1.a = 2;
  v1.c = 3;
}

简单分析上述代码很容易得到以下结论:

  1. 函数foo中的变量v1已经确定是struct C的实例,所以,v1.a = 2;会导致编译错误,v1.c = 3;是正确的代码;
  2. 对于变量v0来说,这个问题就变得很微妙。如果v0struct A或者struct AB的实例,那么foo中的语句v0.a = 1;就是正确的。如果是struct C,那么这段代码就是错误的。

因此在模板定义的地方进行语义分析,并不能完全得出代码是正确或者错误的结论,只有到了实例化阶段,确定了模板参数的类型后,才知道这段代码正确与否。令人高兴的是,在这一问题上,我们和C++标准委员会的见地一致,说明我们的C++水平已经和Herb Sutter不分伯仲了。既然我们和Herb Sutter水平差不多,那凭什么人家就吃香喝辣?下面我们来选几条标准看看服不服:

14.6 名称解析(Name resolution)

1) 模板定义中能够出现以下三类名称:

  • 模板名称、或模板实现中所定义的名称;
  • 和模板参数有关的名称;
  • 模板定义所在的定义域内能看到的名称。

9) … 如果名字查找和模板参数有关,那么查找会延期到模板参数全都确定的时候。 …

10) 如果(模板定义内出现的)名字和模板参数无关,那么在模板定义处,就应该找得到这个名字的声明。…

14.6.2 依赖性名称(Dependent names)

1) …(模板定义中的)表达式和类型可能会依赖于模板参数,并且模板参数会影响到名称查找的作用域 … 如果表达式中有操作数依赖于模板参数,那么整个表达式都依赖于模板参数,名称查找延期到模板实例化时进行。并且定义时和实例化时的上下文都会参与名称查找。(依赖性)表达式可以分为类型依赖(类型指模板参数的类型)或值依赖。

14.6.2.2 类型依赖的表达式

2) 如果成员函数所属的类型是和模板参数有关的,那么这个成员函数中的this就认为是类型依赖的。

14.6.3 非依赖性名称(Non-dependent names)

1) 非依赖性名称在模板定义时使用通常的名称查找规则进行名称查找。

Working Draft: Standard of Programming Language C++, N3337

知道差距在哪了吗:人家会说黑话。什么时候咱们也会说黑话了,就是标准委员会成员了,反正懂得也不比他们少。不过黑话确实不太好懂 —— 怪我翻译不好的人,自己看原文,再说好懂了人家还靠什么吃饭 —— 我们来举一个例子:

int a;
struct B { int v; }
template <typename T> struct X {
  B b;          // B 是第三类名字,b 是第一类
  T t;          // T 是第二类
  X* anthor;      // X 这里代指 X<T>,第一类
  typedef int Y;    // int 是第三类
  Y y;          // Y 是第一类
  C c;          // C 什么都不是,编译错误。
  void foo() {
     b.v += y;      // b 是第一类,非依赖性名称
     b.v *= T::s_mem;   // T::s_mem 是第二类
              // s_mem的作用域由T决定
              // 依赖性名称,类型依赖
  }
};

所以,按照标准的意思,名称查找会在模板定义和实例化时各做一次,分别处理非依赖性名称和依赖性名称的查找。这就是“两阶段名称查找”这一名词的由来。只不过这个术语我也不知道是谁发明的,它并没有出现的标准上,但是频繁出现在StackOverflow和Blog上。

接下来,我们就来解决2.3.1节中留下的几个问题。

先看第四个问题。为什么MSVC中,模板函数的定义内不管填什么编译器都不报错?因为MSVC在分析模板中成员函数定义时没有做任何事情。至于为啥连“大王叫我来巡山”都能过得去,这是C++语法/语义分析的特殊性导致的。 C++是个非常复杂的语言,以至于它的编译器,不可能通过词法-语法-语义多趟分析清晰分割,因为它的语义将会直接干扰到语法:

void foo(){
  A<T> b;
}

在这段简短的代码中,就包含了两个歧义的可能,一是A是模板,于是A<T>是一个实例化的类型,b是变量,另外一种是比较表达式(Comparison Expression)的组合,((A < T) > b)

甚至词法分析也会受到语义的干扰,C++11中才明确被修正的vector<vector<int>>,就因为>>被误解为右移或流操作符,而导致某些编译器上的错误。因此,在语义没有确定之前,连语法都没有分析的价值。

大约是基于如此考量,为了偷懒,MSVC将包括所有模板成员函数的语法/语义分析工作都挪到了第二个Phase,于是乎连带着语法分析都送进了第二个阶段。符合标准么?显然不符合。

但是这里值得一提的是,MSVC的做法和标准相比,虽然投机取巧,但并非有弊无利。我们来先说一说坏处。考虑以下例子:

// ----------- X.h ------------

template <typename T> struct X {
  // 实现代码
};

// ---------- X.cpp -----------

// ... 一些代码 ...
X<int> xi; 
// ... 一些代码 ...
X<float> xf;
// ... 一些代码 ...

此时如果X中有一些与模板参数无关的错误,如果名称查找/语义分析在两个阶段完成,那么这些错误会很早、且唯一的被提示出来;但是如果一切都在实例化时处理,那么可能会导致不同的实例化过程提示同样的错误。而模板在运用过程中,往往会产生很多实例,此时便会大量报告同样的错误。

当然,MSVC并不会真的这么做。根据推测,最终他们是合并了相同的错误。因为即便对于模板参数相关的编译错误,也只能看到最后一次实例化的错误信息:

template <typename T> struct X {};
	
template <typename T> struct Y
{
  typedef X<T> ReboundType; // 类型定义1
  void foo()
  {
    X<T> instance0;
    X<T>::MemberType instance1;
    WTF instance2
  }
};

void poo(){
  Y<int>::foo();
  Y<float>::foo();
}

MSVC下和模板相关的错误只有一个:

error C2039: 'MemberType': is not a member of 'X<T>'
      with
      [
        T=float
      ]

然后是一些语法错误,比如MemberType不是一个合法的标识符之类的。这样甚至你会误以为int情况下模板的实例化是正确的。虽然在有了经验之后会发现这个问题挺荒唐的,但是仍然会让新手有困惑。

相比之下,更加遵守标准的Clang在错误提示上就要清晰许多:

error: unknown type name 'WTF'
  WTF instance2
  ^
error: expected ';' at end of declaration
  WTF instance2
         ^
         ;
error: no type named 'MemberType' in 'X<int>'
  typename X<T>::MemberType instance1;
  ~~~~~~~~~~~~~~~^~~~~~~~~~
  note: in instantiation of member function 'Y<int>::foo' requested here
    Y<int>::foo();
        ^
error: no type named 'MemberType' in 'X<float>'
  typename X<T>::MemberType instance1;
  ~~~~~~~~~~~~~~~^~~~~~~~~~
  note: in instantiation of member function 'Y<float>::foo' requested here
    Y<float>::foo();
          ^
4 errors generated.

可以看到,Clang的提示和标准更加契合。它很好地区分了模板在定义和实例化时分别产生的错误。

另一个缺点也与之类似。因为没有足够的检查,如果你写的模板没有被实例化,那么很可能缺陷会一直存在于代码之中。特别是模板代码多在头文件。虽然不如接口那么重要,但也是属于被公开的部分,别人很可能会踩到坑上。缺陷一旦传播开修复起来就没那么容易了。

但是正如我前面所述,这个违背了标准的特性,并不是一无是处。首先,它可以完美的兼容标准。符合标准的、能够被正确编译的代码,一定能够被MSVC的方案所兼容。其次,它带来了一个非常有趣的特性,看下面这个例子:

struct A;
template <typename T> struct X {
  int v;
  void convertTo(A& a) {
     a.v = v; // 这里需要A的实现
  }
};

struct A { int v; };

void main() {
  X<int> x;
  x.foo(5);
}

这个例子在Clang中是错误的,因为:

error: variable has incomplete type 'A'
            A a;
              ^
  note: forward declaration of 'A'
   struct A;
      ^
1 error generated.

符合标准的写法需要将模板类的定义,和模板函数的定义分离开:

TODO 此处例子不够恰当,并且描述有歧义。需要在未来版本中修订。

struct A;
template <typename T> struct X {
  int v;
  void convertTo(A& a);
};

struct A { int v; };

template <typename T> void X<T>::convertTo(A& a) {
   a.v = v;
}
  
void main() {
  X<int> x;
  x.foo(5);
}

但是其实我们知道,foo要到实例化之后,才需要真正的做语义分析。在MSVC上,因为函数实现就是到模板实例化时才处理的,所以这个例子是完全正常工作的。因此在上面这个例子中,MSVC的实现要比标准更加易于写和维护,是不是有点写Java/C#那种声明实现都在同一处的清爽感觉了呢!

扩展阅读: The Dreaded Two-Phase Name Lookup

2.3.3 “多余的” typename 关键字

到了这里,2.3.1 中提到的四个问题,还有三个没有解决:

template <typename T> struct X {};
	
template <typename T> struct Y
{
  typedef X<T> ReboundType;						// 这里为什么是正确的?
  typedef typename X<T>::MemberType MemberType2;	// 这里的typename是做什么的?
  typedef UnknownType MemberType3;				// 这里为什么会出错?
};

我们运用我们2.3.2节中学习到的标准,来对Y内部做一下分析:

template <typename T> struct Y
{
  // X可以查找到原型;
  // X<T>是一个依赖性名称,模板定义阶段并不管X<T>是不是正确的。
  typedef X<T> ReboundType;
	
  // X可以查找到原型;
  // X<T>是一个依赖性名称,X<T>::MemberType也是一个依赖性名称;
  // 所以模板声明时也不会管X模板里面有没有MemberType这回事。
  typedef typename X<T>::MemberType MemberType2;
	
  // UnknownType 不是一个依赖性名称
  // 而且这个名字在当前作用域中不存在,所以直接报错。
  typedef UnknownType MemberType3;				
};

下面,唯一的问题就是第二个:typename是做什么的?

对于用户来说,这其实是一个语法噪音。也就是说,其实就算没有它,语法上也说得过去。事实上,某些情况下MSVC的确会在标准需要的时候,不用写typename。但是标准中还是规定了形如 T::MemberType 这样的qualified id 在默认情况下不是一个类型,而是解释为T的一个成员变量MemberType,只有当typename修饰之后才能作为类型出现。

事实上,标准对typename的使用规定极为复杂,也算是整个模板中的难点之一。如果想了解所有的标准,需要阅读标准14.6节下2-7条,以及14.6.2.1第一条中对于current instantiation的解释。

简单来说,如果编译器能在出现的时候知道它是一个类型,那么就不需要typename,如果必须要到实例化的时候才能知道它是不是合法,那么定义的时候就把这个名称作为变量而不是类型。

我们用一行代码来说明这个问题:

a * b;

在没有模板的情况下,这个语句有两种可能的意思:如果a是一个类型,这就是定义了一个指针b,它拥有类型a*;如果a是一个对象或引用,这就是计算一个表达式a*b,虽然结果并没有保存下来。可是如果上面的a是模板参数的成员,会发生什么呢?

template <typename T> void meow()
{
  T::a * b; // 这是指针定义还是表达式语句?
}

编译器对模板进行语法检查的时候,必须要知道上面那一行到底是个什么——这当然可以推迟到实例化的时候进行(比如VC,这也是上面说过VC可以不加typename的原因),不过那是另一个故事了——显然在模板定义的时候,编译器并不能妄断。因此,C++标准规定,在没有typename约束的情况下认为这里T::a不是类型,因此T::a * b; 会被当作表达式语句(例如乘法);而为了告诉编译器这是一个指针的定义,我们必须在T::a之前加上typename关键字,告诉编译器T::a是一个类型,这样整个语句才能符合指针定义的语法。

在这里,我举几个例子帮助大家理解typename的用法,这几个例子已经足以涵盖日常使用(预览)

struct A;
template <typename T> struct B;
template <typename T> struct X {
  typedef X<T> _A; // 编译器当然知道 X<T> 是一个类型。
  typedef X  _B; // X 等价于 X<T> 的缩写
  typedef T  _C; // T 不是一个类型还玩毛
  
  // !!!注意我要变形了!!!
  class Y {
    typedef X<T>   _D;      // X 的内部,既然外部高枕无忧,内部更不用说了
    typedef X<T>::Y  _E;      // 嗯,这里也没问题,编译器知道Y就是当前的类型,
                    // 这里在VS2015上会有错,需要添加 typename,
                    // Clang 上顺利通过。
    typedef typename X<T*>::Y _F; // 这个居然要加 typename!
                    // 因为,X<T*>和X<T>不一样哦,
                    // 它可能会在实例化的时候被别的偏特化给抢过去实现了。
  };
  
  typedef A _G;           // 嗯,没问题,A在外面声明啦
  typedef B<T> _H;        // B<T>也是一个类型
  typedef typename B<T>::type _I; // 嗯,因为不知道B<T>::type的信息,
                  // 所以需要typename
  typedef B<int>::type _J;    // B<int> 不依赖模板参数,
                  // 所以编译器直接就实例化(instantiate)了
                  // 但是这个时候,B并没有被实现,所以就出错了
};