C++的缺陷和思考(六)

本文继续来介绍C++的缺陷和笔者的一些思考。先序文章请看
C++的缺陷和思考(五)
C++的缺陷和思考(四)
C++的缺陷和思考(三)
C++的缺陷和思考(二)
C++的缺陷和思考(一)

模板的全特化

先跑个小题~,模板的「模」正确发音应该是「mú」,原本是工程上的术语,生产一种工件可能需要一种样本,但它和实际生产出的工件可能并不相同。所以说,「模板」本身并不是实际的工件,但可以用于生产出工件。更通俗来说,可以理解成一个浇注用的壳,比如说是圆柱形状,如果你往里灌铁水,那出来的就是铁柱;如果你灌铝水出来的就是铝柱;如果你灌水泥,那出来的就是水泥柱……

所以C++中用“模板”这个词特别贴切,它本身并不是实际代码,而在实例化的时候才会生成对应的代码。

而模板又存在“特化”的问题,分为“偏特化”和“全特化”。偏特化也就是部分特化,也就是半成品,本质上来说仍然属于“模板”。但全特化就很特殊了,全特化的模板就已经不是模板了,而是真正的代码了,因此这里的行为也会和模板有所不同,而更加接近普通代码。

最简单的例子就是,模板的声明和实现一般都会写在头文件中(除非仅在某个源文件中使用)。这是由于模板是编译期代码,在编译期会生成实际代码,而“编译”过程是单文件行为,因此你必须保证每个独立的源文件都能找到这段模板定义。(include头文件本质就是文件内容的复制,所以还是相当于每个使用的源文件都获取了一份模板定义)。而如果拆开则会在编译期间找不到而报错:

demo.h

template <typename T>
void f(T t);

demo.cpp

template <typename T>
void f(T t) {
// ...
}

main.cpp

#include "demo.h" // 这里只获得了声明

int main() {
  f<int>(5); // ERR,链接报错,因为只有声明而没有实现
  return 0;
}

上例中,main.cpp包含了demo.h,因此获得的是f函数的声明。当main.cpp在编译期间,是不会去关联demo.cpp的,在主函数中调用了f,因此会标记f函数已经声明。

而编译demo.cpp的时候,由于f并没有任何实例化,因此不会产生任何代码。

此后链接main.cpp和demo.cpp,发现main.cpp中的f没有实现,因此链接阶段报错。

所以,我们才要求模板的实现也要写在头文件中,也就是变成:

demo.h

// 声明
template <typename T>
void f(T t);

// ...其他内容

// 定义
template <typename T>
void f(T t) {
}

main.cpp

#include "demo.h"

int main() {
  f<int>(5); // OK
  return 0;
}

由于实现也写在了demo.h中,因此当主函数中调用了f时,既会用模板f的声明生成出f的声明,也会用模板f的实现生成出f的实现。

但是对于全特化的模板,情况将完全不同。因为全特化的模板已经不是模板了,而是一个确定的函数,编译期不会再用它来生成代码,因此,这时如果你把实现也写在头文件里,就会出现重定义错误:

demo.h

template <typename T>
void f(T t) {}

// f全特化
template <>
void f<int>(int t) {}

src1.cpp

#include "demo.h" // 这里有一份f的实现

main.cpp

#include "demo.h" // 这里也有一份f的实现

int main() {
  f<int>(a); // ERR, redefine f
  return 0;
}

这时会报重定义错误,因为f的实现写在了demo.h中,那么src.cpp包含了一次,相当于实现了一次,然后main.cpp也包含了一次,相当于又实现了一次,所以报重定义错误。

因此,正确的做法是把全特化模板当做普通函数来对待,只能在源文件中定义一次:

demo.h

template <typename T>
void f(T t) {}

// 特化f的声明
template <>
void f<int>(int t);

demo.cpp

#include "demo.h"
// 特化f的定义
template <>
void f<int>(int t) {}

src1.cpp

#include "demo.h" // 只得到了声明,没有重复实现

main.cpp

#include "demo.h" // 只得到了声明,没有重复实现

int main() {
  f<int>(5); // OK,全局只有一份实现
  return 0;
}

所以在使用模板特化的时候,一定要小心,如果是全特化的话,就要按照普通函数/类来对待,声明和实现需要分开。

当然了,硬要把实现写在头文件里也是可以的,只不过要用inline修饰,防止重定义。

demo.h

template <typename T>
void f(T t) {}

// 特化f声明
template <>
void f<int>(int t);

// 特化f内联定义
template <>
inline void f<int>(int t) {}

构造/析构函数调用虚函数

我们知道C++用来实现“多态”的语法主要是虚函数。当调用一个对象的虚函数时,会根据对象的实际类型来调用,而不是根据引用/指针的类型。

class Base {
 public:
  virtual void f() {std::cout << "Base::f" << std::endl;}
};

class Child1 : public Base {
 public:
  void f() override {std::cout << "Child1::f" << std::endl;}
};

class Child2 : public Base {
 public:
  void f() override {std::cout << "Child2::f" << std::endl;}
};

void Demo() {
  Base *obj1 = new Child1;
  Child2 ch;
  Base &obj2 = ch;
  Base obj3;

  obj1->f(); // Child1::f
  obj2.f(); // Child2::f
  obj3.f(); // Base::f
}

但有一种特殊情况,会让多态性失效,请看下面例程:

class Base {
 public:
  Base() {f();} // 构造函数调用虚函数
  virtual void f() {std::cout << "Base::f" << std::endl;}
};

class Child : public Base {
 public:
  Child() {}
  void f() override {std::cout << "Child::f" << std::endl;}
};

void Demo() {
  Child ch; // Base::f
}

我们知道子类构造时需要先调用父类构造函数。这里由于Child中没有指定Base的构造函数,因此会调用无参的构造。在Base的无参构造函数中调用了虚函数f。照理说,我们是在构造Child的过程中调用了f,那么应该调用的是Childf,但实际调的是Basef,也就是多态性失效了。

究其原因,我们就要知道C++构造的模式了。由于ChildBase的子类,因此会含有Base类的成员,并且构造时也要先构造。在构造ChildBase部分时,先初始化了虚函数表,由于此时还属于Base的构造函数,因此虚函数表中指向的是Base::f。虚函数表初始化后开始构造Base的成员,示例中由于是空的所以跳过。再执行Base构造函数的函数体,函数体里调用了f以上都属于Base的构造,完成后才会继续Child独有部分的构造。首先会构造虚函数表,把f指向Child::f。然后是初始化成员,示例中为空所以跳过。最后执行Child构造函数函数体,示例中是空的。

所以,我们看到,这里调用f的时机,是在Base构造的过程中。f由于是虚函数,因此会通过虚函数表来访问,但又因为此时虚函数表里指向的就是Base::f,所以会调用到Base类的f

同理,如果在析构函数中调用虚函数的话,同样会失去多态性。原则就是哪个类里调用的,实际就会调用哪个类的实现

经典二义性问题

C++中存在3个非常经典的二义性问题,并且他们的默认含义都是反直觉的。

临时对象传参时的二义性

请看下面的代码:

struct Test {};

struct Data {
 explicit Data(const Test &test);
};

void Demo() {
  Data data(Test()); // 这句是什么含义?
}

上面这种类型的代码确实有时会一不留神就写出来。我们愿意是想创建一个Data类型的对象叫做data,构造参数是一个Test类型,这里我们直接创建了一个临时对象作为构造参数。

但如果你真的这样写的话,会得到一个warning,并且data这个对象并没有创建成功。为什么呢?因为编译期把它误以为是函数声明了。这里首先需要了解一个语法糖:

void f(void d(int));
// 等价于
void f(void (*d)(int));

C++中允许参数为“函数类型”,又因为函数并不是一种存储类型,因此这种语法会当做“函数指针类型”来处理。所以说当函数参数是一个函数的时候,本质上是让传一个函数指针进去。

与此同时,C++也支持了“函数取地址”和“解函数指针”的操作。函数取地址后仍然是函数指针,解函数指针后仍然是函数指针:

void f() {}

void Demo() {
  void (*p1)() = f; // 函数类型转化为函数指针(C语言只支持这种写法)
  void (*p2)() = &f; // 函数类型取地址还是函数指针类型
  p2(); // 函数指针直接调用相当于函数调用
  (*p2)(); // 函数指针解指针后仍然是函数指针
  auto p3 = *p2; // 同上,p3仍然是void (*)()类型
  (*************p2)(); // 逐渐离谱,但确实是合法的
}

再回到一开始的例子,假如我们要声明一个函数名为data,返回值是Data类型,参数是一个函数类型,一个返回值为Test,空参类型的函数。那么就是:

Data data(Test());
// 或者是
Data data(Test (*)());

第一种写法正好和我们刚才想表示“定义Data类型的对象名为data,参数是一个Test类型的临时对象”给撞脸了。引发了二义性。

解决方法也很简单,我们知道表示“值”的时候,套一层或者多层括号是不影响“值”的意义的:

// 下面都等价
a;
(a);
((a));

那么表示“函数调用”时,传值也是可以套多层括号的:

f(a);
f((a));
f(((a)));

但是当你表示函数声明的时候,你就不能套多层括号了:

void f(int); // 函数声明
void f((int)); // ERR,错误语法

所以,第一种解决方法就是,套一层括号,那么就只能解释为“函数调用”而不是“函数声明”了:

Data data((Test())); // 定义对象data,不会出现二义性

第二种方法就是不要用小括号表示构造参数,而是换成大括号:

Data data{Test{}}; // 大括号表示构造参数列表,不能表示函数类型

在要不就不要用临时对象,改用普通变量:

Test t;
Data data{t};

模板参数嵌套时的二义性

当两个模板参数套在一起的时候,两个>会碰在一起:

std::vector<std::vector<int>> ve; // 这里出现了一个>>

而这和参数中的右移运算给撞脸了:

std::array<int, 1 >> 5> arr; // 这里也出现了一个>>

在C++11以前,>>会优先识别为右移符号,因此对于模板嵌套,就必须加空格:

std::vector<std::vector<int> > ve; // 加空格避免歧义

但可能是因为模板参数右移的情况远远少过模板嵌套的情况,因此在C++11开始,把这种默认情况改了过来,遇见>>会识别为模板嵌套:

std::vector<std::vector<int>> ve; // OK

但相对的,如果要进行右移运算的话,就会识别错误,解决方法是加括号

std::array<int, 1 >> 5> arr; // ERR
std::array<int, (1 >> 5)> arr; // OK,要通过加小括号避免歧义

模板中类型定义和静态变量二义性

直接上代码:

template <typename T>
struct Test {
  void f() {
  	T::abc *p;
  }
};

struct T1 {
  static int abc;
};

struct T2 {
  using abc = int;
};

void Demo() {
  Test<T1> t1;
  Test<T2> t2;
}

Test是一个模板类,里面取了参数T的成员abc。对于T1的实例化来说,T1::abc是一个整型变量,所以T::abc *p相当于两个变量相乘,*会理解为“乘法”。

而对于T2来说,T2::abc是一个类型重命名,那么T::abc *p相当于定义一个int类型的指针,*会理解为指针类型。

所以,对于模板Test来说,由于T还没有实例化,所以不能确定T::abc到底是静态变量还是类型重命名。因此会出现二义性。

解决方式是用typename关键字,强制表名这里T::abc是一个类型:

template <typename T>
struct Test {
  void f() {
    typename T::abc *p; // 一定表示指针定义
  }
};

typename关键字大家应该并不陌生,但一般都是在模板参数中见到的。其实在C++11以前,模板参数中表示“类型”参数的关键字是class,但用这个关键字会对人产生误导,其实这里不一定非要传类类型,传基本类型也是OK的,因此C++11的时候让typename可以承担这个责任,因为它更能表示“类型名称”这种含义。但其实在此之前typename仅仅是为了解决上面二义性问题的。

另外值得说明的一点是,C++17以前,模板参数是模板的情况时仍然只能用class

// 要求参数要传一个模板类型,其含有两个类型参数
// C++14及以前版本这里必须用class
template <template <typename, typename> class Temp>
struct Test {}

template <typename T, typename R>
struct T1 {}

void Demo() {
  Test<T1>; // 模板参数是模板的情况实例化
}

C++17开始才允许这个class替换为typename

// C++17后可以用typename
template <template <typename, typename> typename Temp>
struct Test {}

你可能感兴趣的:(C++代码,编程技巧和心得,c++,算法,开发语言)