读者对extern关键字应该不会陌生,它可以在声明变量和函数的时候使用,用于指定目标为外部链接,但其本身并不参与目标的定义,所以对目标的属性没有影响。extern最常被使用的场景是当一个变量需要在多份源代码中使用的时候,如果每份源代码都定义一个变量,那么在代码链接时会出错,正确的方法是在其中一个源代码中定义该变量,在其他的源代码中使用extern声明该变量为外部变量。
\\ src1.cpp
int x = 0;
\\ src2.cpp
extern int x;
x = 11;由于在多份源代码中定义同一个变量会让链接报错,因此我们不得不使用extern来声明外部变量。但是外部模板又是怎么一回事呢?我们都知道,在多份源代码中对同一模板进行相同的实例化是不会有任何链接问题的,例如:
// header.h
template<class T> bool foo(T t) { return true; }
// src1.cpp
#include <header.h>
bool b = foo(7);
// src2.cpp
#include <header.h>
bool b = foo(11);在上面的代码中,src1.cpp和src2.cpp都会实例化一份相同的函数代码bool foo<int>(int)。不过它们并没有在链接的时候产生冲突,这是因为链接器对于模板有特殊待遇。编译器在编译每份源代码的时候会按照单个源代码的需要生成模板的实例,而链接器对于这些实例会进行一次去重操作,它将完全相同的实例删除,最后只留下一份实例。不过读者有没有发现,在整个过程中编译器生成各种模板实例,连接器却删除重复实例,中间的编译和连接时间完全被浪费了。如果只是一两份源代码中出现这种情况应该不会有太大影响,但是如果源代码数量达到上万的级别,那么编译和连接的过程将付出大量额外的时间成本。
为了优化编译和连接的性能,C++11标准提出了外部模板的特性,这个特性保留了extern关键字的语义并扩展了关键字的功能,让它能够声明一个外部模板实例。在进一步说明外部模板之前,我们先回顾一下如何显式实例化一个模板:
// header.h
template<class T> bool foo(T t) { return true; }
// src1.cpp
#include <header.h>
template bool foo<double>(double);
// src2.cpp
#include <header.h>
template bool foo<double>(double);在上面的代码中,src1.cpp和src2.cpp编译时分别显式实例化了同一份函数bool foo<double>(double),而在连接时其中的一个副本被删除,这个过程和之前隐式实例化的代码是一样的。如果想在这里声明一个外部模板,只需要在其中一个显式实例化前加上extern template,比如:
// header.h
template<class T> bool foo(T t) { return true; }
// src1.cpp
#include <header.h>
extern template bool foo<double>(double);
// src2.cpp
#include <header.h>
template bool foo<double>(double);这样编译器将不会对src1.cpp生成foo函数模板的实例,而是在链接的时候使用src2.cpp生成的bool foo<double>(double)函数。如此一来就省去了之前冗余的副本实例的生成和删除的过程,改善了软件构建的性能。另外,外部模板除了可以针对函数模板进行优化,对于类模板也同样适用,例如:
// header.h
template<class T> class bar {
public:
void foo(T t) {};
};
// src1.cpp
#include <header.h>
extern template class bar<int>;
extern template void bar<int>::foo(int);
// src2.cpp
#include <header.h>
template class bar<int>;从上面的代码可以看出,extern template不仅可以声明外部类模板实例extern template class bar<int>,还可以明确具体的外部实例函数extern template void bar<int>::foo(int)。
最后需要说明一下,我并没有在大型的工程中使用外部模板提升工程的构建性能,所以无法给出一个明确的数据证明。但是从原理上来说,这种优化应该是非常有效的,因为对一个复杂的模板实例化确实需要不少的时间。如果有读者正在苦于项目工程的构建效率过低,并且有足够的精力对大量的源代码进行修改,不妨试一试外部模板这个特性。
从C++引入右尖括号开始直到C++11标准发布,C++一直存在这样一个问题,两个连续的右尖括号>>一定会被编译器解析为右移,这是因为编译器解析采用的是贪婪原则。但是在很多情况下,连续两个右尖括号并不是要表示右移,可能实例化模板时模板参数恰好也是一个类模板,又或者类型转换的目标类型是一个类模板。在这种情况下,过去我们被要求在两个尖括号之间用空格分隔,比如:
#include <vector>
typedef std::vector<std::vector<int> > Table; // 编译成功
typedef std::vector<std::vector<bool>> Flags; // 编译失败,>>被解析为右移如果上面的代码使用GCC 4.1编译,会发现代码无法通过编译,同时编译器会给出具体的提示,要求将代码中的'>>'修改为'> >'。当然,类型转换static_cast、const_cast、dynamic_cast和reinterpret_cast也存在同样的问题。这个问题虽然不大,但是确实也挺让人厌烦的,所以在C++11中将连续右尖括号的解析进行了优化。
在C++11标准中,编译器不再一味地使用贪婪原则将连续的两个右尖括号解析为右移,它会识别左尖括号激活状态并且将右尖括号优先匹配给激活的左尖括号。这样一来,我们就无须在两个右尖括号中插入空格了。
还是编译上面的代码,只不过这一次我们采用新一点的编译器,比如GCC 8.1,代码就能够顺利地编译。
这样就结束了吗?并不是,由于解析规则的修改会造成在老规则下编写的代码出现问题,比如:
template<int N>
class X {};
X <1 >> 3> x;上面的代码用GCC 4.1可以顺利编译,因为代码里的1 >> 3被优先处理,相当于X <(1 >> 3)> x。但是在新的编译器中,这段代码无法成功编译,因为连续两个右尖括号的第一个括号总是会跟开始的左尖括号匹配,相当于(X <1 >)> 3> x。无法兼容老代码的问题虽然看似严重,但其实要解决这个问题非常简单,只要将需要优先解析的内容用小括号包括起来即可,比如X <(1 >> 3)> x。
故事到这里还没有结束,由于涉及模板编程,因此情况比我们想象得还要复杂一点,因此来看一看下面的例子:
#include <iostream>
template<int I> struct X {
static int const c = 2;
};
template<> struct X<0> {
typedef int c;
};
template<typename T> struct Y {
static int const c = 3;
};
static int const c = 4;
int main() {
std::cout << (Y<X<1> >::c > ::c > ::c) << std::endl;
std::cout << (Y<X< 1>>::c > ::c > ::c) << std::endl;
}上面的代码在新老编译器上都可以成功编译,但是输出结果却不相同,用GCC 4.1编译这份代码,运行后输出为0和3。但是在GCC 8.1或者以上版本的编译器上编译运行,得到的结果却是0和0。现在让我们看一看这是怎么发生的。
对于GCC 8.1而言,std::cout << (Y<X<1> >::c > ::c > ::c) << std::endl;和std::cout << (Y<X< 1>>::c > ::c > ::c) << std::endl;的解析方式相同,都是先解析X<1>,接着解析Y<X<1>>::c,最后的代码相当于std::cout << (3 > 4 > 4) << std::endl,所以输出都为0。
而对于GCC 4.1,两个语句有着截然不同的解析顺序。其中std::cout << (Y<X<1> >::c > ::c > ::c) << std::endl;和GCC 8.1的解析顺序相同,所以输出为0。但是std::cout << (Y<X< 1>>::c > ::c > ::c) << std::endl;的解析顺序则不同,先解析1>>::c得到结果0,接着解析X<0>::c得到结果为类型int,最后解析Y<int> ::c的结果为3,所以输出结果为3。
对于同一份代码的运行结果不同,这是我们处理兼容问题时最不想看到的情况。值得庆幸的是,像上面这份“奇怪”的代码不太会出现在真实的开发环境中。不过在将老代码迁移到新编译环境中时还是应该小心谨慎,避免出现难以预测的问题。
友元在C++中一直是一个备受争议的特性,争议的焦点是一个类的友元可以忽略该类的访问属性(public、protected、private),对类成员进行直接访问,破坏了代码的封装性。不过,我却很喜欢这个特性,在我看来友元语法简单且使用方便,合理使用不会造成代码混乱、难以阅读甚至可以简化代码,它提供了一种语法上的可能性,让程序员更灵活地控制对类的访问。至于说破坏封装性的问题,我们大可以谨慎使用友元,保证编写的类不会被滥用即可。
也许C++委员会也是出于我这样的想法,在C++标准中不但没有反对和删除这个特性,反而扩展了它在模板里的能力。介绍该能力之前,需要先介绍一个语法上的改进,在C++11标准中,将一个类声明为另外一个类的友元,可以忽略前者的class关键字。当然,忽略class关键字还有一个大前提,必须提前声明该类,例如:
class C;
class X1 {
friend class C; // C++11前后都能编译成功
};
class X2 {
friend C; // C++11以前会编译错误,C++11以后编译成功
};在上面的代码中,X1可以在C++11以及之前标准的编译器中编译成功,而X2在C++11之前则可能会编译失败,因为friend C缺少class关键字。这里说可能,是因为在某些新版本的编译器中,例如GCC,即使指定了-std=c++03,X2也能够编译通过,而在另外一些新编译器中可能会给出警告,例如CLang,但也会编译成功。请注意,这里为了保证X2编译通过,class C的提前声明是必不可少的。
引入忽略class关键字这个能力后,我们会发现friend多了一些事情可以做,比如用friend声明基本类型、用friend声明别名、用friend声明模板参数:
class C;
typedef C Ct;
class X1 {
friend C;
friend Ct;
friend void;
friend int;
};
template <typename T> class R {
friend T;
};
R<C> rc;
R<Ct> rct;
R<int> ri;
R<void> rv;以上代码中的friend C和friend Ct具有相同的含义,都是指定类C为类X1的友元。对于基本类型,friend void和friend int虽然也能编译成功,但是实际上编译器不会做任何事情,也就是说它们会被忽略。这个特性可以延伸到模板参数上,当模板参数为C或者Ct时,C为类R<C>的友元,当模板参数为int等内置类型时,friend T被忽略,类R<int>不存在友元。
通过上面的示例可以发现,用模板参数结合友元可以让我们在使用友元的代码上进行切换而不需要多余的代码修改,例如:
class InnerVisitor { /*访问SomeDatabase内部数据*/ };
template <typename T> class SomeDatabase {
friend T;
// … 内部数据
public:
// … 外部接口
};
typedef SomeDatabase<InnerVisitor> DiagDatabase;
typedef SomeDatabase<void> StandardDatabase;这里DiagDatabase是一个对内的诊断数据库类,它设置InnerVisitor为友元,通过InnerVisitor对数据库数据进行诊断。而对外则使用类StandardDatabase,因为它的友元声明为void,所以不存在友元,外部需要通过标准方法访问数据库的数据。
请读者回答一个问题,如果想根据不同的类型去定义一个变量有哪些做法,根据以往的C++知识,读者应该能想到两种方法。
在类模板定义静态数据成员:
#include <iostream>
template<class T>
struct PI {
static constexpr T value = static_cast<T>(3.1415926535897932385);
};
int main()
{
std::cout << PI<float>::value << std::endl;
}使用函数模板返回所需的值:
#include <iostream>
template<class T>
constexpr T PI()
{
return static_cast<T>(3.1415926535897932385);
}
int main()
{
std::cout << PI<int>() << std::endl;
}很明显,根据类型定义变量并不是一件有难度的事情,通过类模板和函数模板可以轻松达到这个目的。
不过C++委员会似乎并不满足于此,在C++14的标准中引入了变量模板的特性,有了变量模板,我们不再需要冗余地定义类模板和函数模板,只需要专注要定义的变量即可,还是以变量PI为例:
#include <iostream>
template<class T>
constexpr T PI = static_cast<T>(3.1415926535897932385L);
int main()
{
std::cout << PI<float> << std::endl;
}在上面的代码中,constexpr T PI = static_cast<T>(3.141592653589 7932385L);是变量的声明和初始化,template<class T>是变量的模板形参。请注意,虽然这里的变量声明为常量,但是对于变量模板而言这不是必需的,同其他模板一样,变量模板的模板形参也可以是非类型的:
#include <iostream>
template<class T, int N>
T PI = static_cast<T>(3.1415926535897932385L) * N;
int main()
{
PI<float, 2> *= 5;
std::cout << PI<float, 2> << std::endl;
}在上面的代码中,变量模板PI不再是一个常量,我们可以在任意时候改变它的值。实际上,在C++14标准中变量模板给我们带来的最大便利是关于模板元编程的。举例来说,当比较两个类型是否相同时会用到:
bool b = std::is_same<int, std::size_t>::value;可以看到,类模板std::is_same使用常量静态成员变量的方法定义了value的值,显而易见,直接使用变量模板编写代码要简单得多,比如:
template<class T1, class T2>
constexpr bool is_same_v = std::is_same<T1, T2>::value;
bool b = is_same_v<int, std::size_t>;有些令人尴尬的是,虽然C++14标准已经支持变量模板的特性并且也证明了可以简化代码的编写,但是在C++14的标准库中却没有对它的支持。我们不得不继续使用std::is_same<int, std::size_t>::value的方法来判断两个类型是否相同。这个尴尬的问题一直延续到C++17标准的发布才得到解决,在C++17标准库的type_traits中对类型特征采用了变量模板,比如对于some_trait<T>:: value,会增加与它等效的变量模板some_trait_v<T>,这里_v后缀表示该类型是一个变量模板。因此在C++17的环境下,判断两种类型是否相同就只需要编写一行代码即可:
bool b = std::is_same_v<int, std::size_t>;C++20标准扩展了explicit说明符的功能,在新标准中它可以接受一个求值类型为bool的常量表达式,用于指定explicit的功能是否生效。为了解释这项新功能的目的,让我们先看一看提案文档中的示例代码:
std::pair<std::string, std::string> safe() {
return {"meow", "purr"}; // 编译成功
}
std::pair<std::vector<int>, std::vector<int>> unsafe() {
return {11, 22}; // 编译失败
}在上面的代码中safe()函数可以通过编译,unsafe()则会编译报错。这个结果符合预期,整型转换为std::vector<int>看上去都不可能是合理的。不过,让我们想一想这个差异是怎么发生的。因为"meow"和"purr"都可以构造std::string,所以safe()能编译成功,这没有问题。问题是整型也可以通过构造函数构造std::vector<int>,为何unsafe()函数编译失败了,有读者可能会想到std::vector<int>的构造函数使用了explicit说明符,所以整型需要显式构造std::vector<int>。知识点的确没错,但是这里std::vector<int>的构造函数使用explicit说明符无法阻止std::pair的构造,因为std::pair的实现类似于以下代码:
template<class T1, class T2>
struct MyPair {
template <class U1, class U2>
MyPair(const U1& u1, const U2& u2) : first_(u1), second_(u2) {}
T1 first_;
T2 second_;
};
MyPair<std::vector<int>, std::vector<int>> unsafe() {
return { 11, 22 }; // 编译成功
}上面这段代码是可以通过编译的,这说明std::vector<int>的构造函数使用explicit说明符没有限制作用。仔细观察代码会发现,实际上{11, 22}并没有直接构造std::vector<int>,而是通过first_(u1)和second_(u2)间接构造std::vector<int>,这个过程显然是一个显式构造。要解决这个问题,我们需要对MyPair的构造函数使用explicit说明符。
template<class T1, class T2>
struct MyPair {
template <class U1, class U2>
explicit MyPair(const U1& u1, const U2& u2) : first_(u1), second_(u2) {}
T1 first_;
T2 second_;
};
MyPair<std::vector<int>, std::vector<int>> unsafe() {
return { 11, 22 }; // 编译失败
}
MyPair<std::string, std::string> safe() {
return { "meow", "purr" }; // 编译失败
}但是这样一来又会导致safe()编译失败。为了解决这一系列的问题,标准库采用SFINAE和概念的方法实现了std::pair的构造函数,其代码类似于:
// SFINAE版本
template <typename T1, typename T2>
struct pair {
template <typename U1=T1, typename U2=T2,
std::enable_if_t<
std::is_constructible_v<T1, U1> &&
std::is_constructible_v<T2, U2> &&
std::is_convertible_v<U1, T1> &&
std::is_convertible_v<U2, T2>
, int> = 0>
constexpr pair(U1&&, U2&& );
template <typename U1=T1, typename U2=T2,
std::enable_if_t<
std::is_constructible_v<T1, U1> &&
std::is_constructible_v<T2, U2> &&
!(std::is_convertible_v<U1, T1> &&
std::is_convertible_v<U2, T2>)
, int> = 0>
explicit constexpr pair(U1&&, U2&& );
};
// 概念版本
template <typename T1, typename T2>
struct pair {
template <typename U1=T1, typename U2=T2>
requires std::is_constructible_v<T1, U1> &&
std::is_constructible_v<T2, U2> &&
std::is_convertible_v<U1, T1> &&
std::is_convertible_v<U2, T2>
constexpr pair(U1&&, U2&& );
template <typename U1=T1, typename U2=T2>
requires std::is_constructible_v<T1, U1> &&
std::is_constructible_v<T2, U2>
explicit constexpr pair(U1&&, U2&& );
};从上面的代码可以看出,标准库利用SFINAE和概念实现了两套构造函数,对于类型可以转换地(使用std::is_convertible_v判定)采用无explicit说明符的构造函数,而对于其他情况使用有explicit说明符的构造函数。
尽管使用以上方法很好地解决了上述一系列问题,但是不得不说它的实现非常复杂。幸好explicit(bool)的引入有效地缩减了解决上述问题的编码:
// SFINAE版本
template <typename T1, typename T2>
struct pair {
template <typename U1=T1, typename U2=T2,
std::enable_if_t<
std::is_constructible_v<T1, U1> &&
std::is_constructible_v<T2, U2>
, int> = 0>
explicit(!std::is_convertible_v<U1, T1> ||
!std::is_convertible_v<U2, T2>)
constexpr pair(U1&&, U2&& );
};
// 概念版本
template <typename T1, typename T2>
struct pair {
template <typename U1=T1, typename U2=T2>
requires std::is_constructible_v<T1, U1> &&
std::is_constructible_v<T2, U2>
explicit(!std::is_convertible_v<U1, T1> ||
!std::is_convertible_v<U2, T2>)
constexpr pair(U1&&, U2&& );
};观察上述代码可以发现,std::pair不再需要实现两套构造函数了。取而代之的是:
explicit(!std::is_convertible_v<U1, T1> || !std::is_convertible_v<U2, T2>)当U1、U2不能转换到T1和T2的时候,!std::is_convertible_v<U1, T1> || !std::is_ convertible_v<U2, T2>的求值为true,explicit(true)表示该构造函数为显式的。反之,当U1、U2可以转换到T1和T2时,最终结果为explicit(false),explicit说明符被忽略,构造函数可以隐式执行。
本章介绍了5个和模板密切相关的特性,其中连续右尖括号的解析优化,虽然看似改动很小,但却实打实地让我们在编写模板的时候舒心了不少。相对于前者,外部模板和friend声明模板形参在实用性上确实少了一些,但不可否认的是它们完善了模板机制。接着介绍变量模板,我认为是比较实用的新特性,很明显,相较于C++14标准库,在C++17标准库引入了变量模板特性之后,type_traits中的模板元函数使用起来更加简明了。最后,explicit(bool)虽然比较复杂但非常实用,它让explicit说明符可以根据指定类型来发挥作用,对于代码库的设计者来说,这无疑增加了编码的灵活性。