本文继续来介绍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<int>
,因此会标记f<int>
函数已经声明。
而编译demo.cpp的时候,由于f
并没有任何实例化,因此不会产生任何代码。
此后链接main.cpp和demo.cpp,发现main.cpp中的f<int>
没有实现,因此链接阶段报错。
所以,我们才要求模板的实现也要写在头文件中,也就是变成:
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<int>
时,既会用模板f
的声明生成出f<int>
的声明,也会用模板f
的实现生成出f<int>
的实现。
但是对于全特化的模板,情况将完全不同。因为全特化的模板已经不是模板了,而是一个确定的函数,编译期不会再用它来生成代码,因此,这时如果你把实现也写在头文件里,就会出现重定义错误:
demo.h
template <typename T>
void f(T t) {}
// f<int>全特化
template <>
void f<int>(int t) {}
src1.cpp
#include "demo.h" // 这里有一份f<int>的实现
main.cpp
#include "demo.h" // 这里也有一份f<int>的实现
int main() {
f<int>(a); // ERR, redefine f<int>
return 0;
}
这时会报重定义错误,因为f<int>
的实现写在了demo.h中,那么src.cpp包含了一次,相当于实现了一次,然后main.cpp也包含了一次,相当于又实现了一次,所以报重定义错误。
因此,正确的做法是把全特化模板当做普通函数来对待,只能在源文件中定义一次:
demo.h
template <typename T>
void f(T t) {}
// 特化f<int>的声明
template <>
void f<int>(int t);
demo.cpp
#include "demo.h"
// 特化f<int>的定义
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<int>声明
template <>
void f<int>(int t);
// 特化f<int>内联定义
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
,那么应该调用的是Child
的f
,但实际调的是Base
的f
,也就是多态性失效了。
究其原因,我们就要知道C++构造的模式了。由于Child
是Base
的子类,因此会含有Base
类的成员,并且构造时也要先构造。在构造Child
的Base
部分时,先初始化了虚函数表,由于此时还属于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++的缺陷和思考(七)