C++是支持自定义类型转换运算符的,通过自定义转换运算符可以对原本没有关系的两个类型进行转换,可以说为类型转换提供了不少方便。不过一直以来C++专家对自定义类型转换都保持谨慎的态度,其原因是自定义类型转换可能让程序员更容易写出与实际期待不符的代码,而编译器无法给出有效的提示,请观察以下代码:
#include <iostream>
#include <vector>
template<class T>
class SomeStorage {
public:
SomeStorage() = default;
SomeStorage(std::initializer_list<T> l) : data_(l) {};
operator bool() const { return !data_.empty(); }
private:
std::vector<T> data_;
};
int main()
{
SomeStorage<float> s1{ 1., 2., 3. };
SomeStorage<int> s2{ 1, 2, 3 };
std::cout << std::boolalpha;
std::cout << "s1 == s2 : " << (s1 == s2) << std::endl;
std::cout << "s1 + s2 : " << (s1 + s2) << std::endl;
}以上代码的编译运行结果如下:
s1 == s2 : true
s1 + s2 : 2SomeStorage是一个用于存储某类型数据的类模板。比如SomeStorage <int>用于存放整型数据,SomeStorage<float>用于存放浮点数据。正常逻辑下这两个类的实例s1和s2是不能相等的,但是编译运行代码后发现s1 == s2的输出为true。另外,这两个不相关的类居然还可以做加法运算,返回结果为2,乍看起来完全没有道理。事实上,这里忽略了自定义类型转换运算符operator bool()的影响。在s1和s2比较和相加的过程中,编译器会对它们做隐式的自定义类型转换以符合比较和相加的条件。由于这两个对象都不为空,因此它们的返回值都为true,s1 == s2的运算结果自然也为true,而求和运算会将bool转换为int,于是输出运算结果为2。可见,自定义类型转换运算符有时候就是这么不尽如人意。
另外,类型转换问题不止存在于自定义类型转换运算符中,构造函数中也同样有问题,例如:
#include <iostream>
#include <string.h>
class SomeString {
public:
SomeString(const char * p) : str_(strdup(p)) {}
SomeString(int alloc_size) : str_((char *)malloc(alloc_size)) {}
~SomeString() { free(str_); }
private:
char *str_;
friend void PrintStr(const SomeString& str);
};
void PrintStr(const SomeString& str)
{
std::cout << str.str_ << std::endl;
}
int main()
{
PrintStr("hello world");
PrintStr(58); // 代码写错,却编译成功
}SomeString类重载了两个构造函数,其中SomeString(const char * p)接受一个字符串作为参数并且将字符串复制到对象中,SomeString(int alloc_size)接受一个整数用于分配字符串内存。函数PrintStr的意图是打印SomeString的字符串。PrintStr("hello world")编译成功是符合预期的,因为字符串会隐式构造SomeString对象。奇怪的是PrintStr(58)这个函数的调用,很明显这不是程序员写PrintStr函数的意图,真正的意图可能是PrintStr("58"),但由于粗心漏掉了引号。问题来了,编译器面对这样的代码不会给出任何错误或者警告提示。因为编译器会将58作为参数通过SomeString(int alloc_size)构造函数构造成SomeString对象。
当然了,C++已经考虑到了构造函数面临的这种问题,我们可以使用explicit说明符将构造函数声明为显式,这样隐式的构造无法通过编译:
class SomeString {
public:
SomeString(const char * p) : str_(_strdup(p)) {}
explicit SomeString(int alloc_size) : str_((char *)malloc(alloc_size)) {}
~SomeString() { free(str_); }
private:
char *str_;
friend void PrintStr(const SomeString& str);
};
int main()
{
PrintStr("hello world");
PrintStr(58); // 编译失败
PrintStr(SomeString(58));
}以上代码用explicit说明符声明了SomeString(int alloc_size),这样一来通过整数构造对象必须用显式的方式,所以PrintStr(58)会编译失败。
借鉴显式构造函数的成功经验,C++11标准将explicit引入自定义类型转换中,称为显式自定义类型转换。语法上和显式构造函数如出一辙,只需在自定义类型转换运算符的函数前添加explicit说明符,例如:
#include <iostream>
#include <vector>
template<class T>
class SomeStorage {
public:
SomeStorage() = default;
SomeStorage(std::initializer_list<T> l) : data_(l) {};
explicit operator bool() const { return !data_.empty(); }
private:
std::vector<T> data_;
};
int main()
{
SomeStorage<float> s1{ 1., 2., 3. };
SomeStorage<int> s2{ 1, 2, 3 };
std::cout << std::boolalpha;
std::cout << "s1 == s2 : " << (s1 == s2) << std::endl; // 编译失败
std::cout << "s1 + s2 : " << (s1 + s2) << std::endl; // 编译失败
std::cout << "s1 : " << static_cast<bool>(s1) << std::endl;
std::cout << "s2 : " << static_cast<bool>(s2) << std::endl;
if (s1) {
std::cout << "s1 is not empty" << std::endl;
}
}以上代码给operator bool()添加了explicit说明符,将自定义类型转换运算符声明为显式的。于是,在编译s1 == s2和s1 + s2的时候我们收到了两条错误信息,因为现在已经无法隐式地调用自定义类型转换运算符函数了,而显式地转换static_cast <bool>(s1)和static_cast<bool>(s2)则可以编译成功。在这份代码中我们还发现了另外一个有趣的地方,if语句内的s1可以成功地调用显式自定义转换函数将其转换为bool类型而不会引发编译错误,这似乎和显式自定义类型转换运算符有些矛盾。实际上,这个“矛盾”恰好是C++11标准所允许的。
为了做进一步解释,这里需要引入布尔转换,顾名思义就是将其他类型转换为bool。对于布尔转换,C++11标准为其准备了一些特殊规则以减少代码冗余:在某些期待上下文为bool类型的语境中,可以隐式执行布尔转换,即使这个转换被声明为显式。这些语境包括以下几种。
if、while、for的控制表达式。
内建逻辑运算符!、&&和||的操作数。
条件运算符?:的首个操作数。
static_assert声明中的bool常量表达式。
noexcept说明符中的表达式。
以上语境对类型进行布尔转换是非常自然的,并不会产生其他不良的影响,而且会让代码更加简练,容易理解。
最后需要说明的是,新标准库也充分利用了显式自定义类型转换特性,比如std::unique_ptr定义了显式bool类型转换运算符来指示智能指针的内部指针是否为空,std::ifstream定义了显式bool类型转换运算符来指示是否成功打开了目标文件等。
std::launder()是C++17标准库中新引入的函数,虽然本书并不打算介绍标准库中新增的内容,但是对于std::launder()还是有必要说明一下的,因为它想要解决的是C++语言的一个核心问题。让我们通过标准文档中的例子看一看这个问题到底是什么?
struct X { const int n; };
union U { X x; float f; };请注意上面的代码片段中,结构体X的数据成员n是一个const int类型。接下来聚合初始化联合类型U:
U u = {{ 1 }};现在const int类型数据成员n被初始化为1,由于n的常量性,编译器可以总是认为u.x.n为1。接下来我们使用replace new的方法重写初始化这块内存区域:
X *p = new (&u.x) X {2};新创建的p->n的值为2。现在问题来了,请读者想一想u.x.n的值应该是多少?如果只是从内存的角度来看,毫无疑问这里的结果是2,但是事情往往没那么简单,由于u.x.n是一个常量且初始化为1,因此编译器有理由认为u.x.n是无法被修改的,通过一些优化后u.x.n的结果有可能为1。实际上在标准看来,这个结果是未定义的。在经过replace new的操作后,我们不能直接使用u.x.n,只能通过p来访问n。
具体来说,C++标准规定:如果新的对象在已被某个对象占用的内存上进行构建,那么原始对象的指针、引用以及对象名都会自动转向新的对象,除非对象是一个常量类型或对象中有常量数据成员或者引用类型。简单来说就是,如果数据结构X的数据成员n不是一个常量类型,那么u.x.n的结果一定是2。但是由于常量性的存在,从语法规则来说x已经不具备将原始对象的指针、引用以及对象名自动转向新对象的条件,因此结果是未定义的,要访问n就必须通过新对象的指针p。实际上,这并不是一个新的语法规则,不过好像大多数人对此不太了解。
标准库引入std::launder()就是为了解决上述问题,标准文档的例子中:
assert(*std::launder(&u.x.n) == 2);它是一个有定义的行为,而且获取n的值也保证为2。怎么理解std::launder()呢?在我看来不妨从字面意思理解,launder在英文中有清洗和刷洗的意思。而在这里不妨理解为洗内存,它的目的是防止编译器追踪到数据的来源以阻止编译器对数据的优化。最后要说一句,如果读者阅读std::launder的代码可能会感到惊讶,因为这个函数什么也没做,类似于:
template<typename T>
constexpr T*
launder(T* p) noexcept
{
return p;
}没错,到目前为止这个函数确实什么也没做。Botond Ballo曾在2016年芬兰奥卢的C++标准委员会会议报告中写到过关于std::launder()的体会。
返回值优化是C++中的一种编译优化技术,它允许编译器将函数返回的对象直接构造到它们本来要存储的变量空间中而不产生临时对象。严格来说返回值优化分为RVO(Return Value Optimization)和NRVO(Named Return Value Optimization),不过在优化方法上的区别并不大,一般来说当返回语句的操作数为临时对象时,我们称之为RVO;而当返回语句的操作数为具名对象时,我们称之为NRVO。在C ++ 11标准中,这种优化技术被称为复制消除(copy elision)。如果使用GCC作为编译器,则这项优化技术是默认开启的,取消优化需要额外的编译参数“-fno-elide- constructors”。
让我们从下面的例子开始对返回值优化技术进行探索:
#include <iostream>
class X {
public:
X() { std::cout << "X ctor" << std::endl; }
X(const X&x) { std::cout << "X copy ctor" << std::endl; }
~X() { std::cout << "X dtor" << std::endl; }
};
X make_x()
{
X x1;
return x1;
}
int main()
{
X x2 = make_x();
}可以看到函数make_x()返回了对象x1并赋值到x2上,理论上说这其中必定需要经过两次复制构造函数,第一次是x1复制到临时对象,第二次是临时对象复制到x2。现在让我们用GCC编译并且运行这份代码,会输出结果:
X ctor
X dtor令人吃惊的是,整个过程一次复制构造都没有调用,这就是NRVO的效果。如果这里将make_x函数改为:
X make_x()
{
return X();
}也会收到同样的效果,只不过优化技术名称从NRVO变成了RVO。
接下来在编译命令行中添加开关“-fno-elide-constructors”,然后再次编译运行该代码,这时的输出结果如下:
X ctor
X copy ctor
X dtor
X copy ctor
X dtor
X dtor这才是我们刚刚预想的结果,一个默认构造函数和两个复制构造函数的调用。从结果可以看出返回值优化的效果特别理想,整整减少了两次复制构造和析构,这对于比较复杂或者占用内存很大的对象来说将是很重要的优化。
但是请别高兴得太早,实际上返回值优化是很容易失效的,例如:
#include <iostream>
#include <ctime>
class X {
public:
X() { std::cout << "X ctor" << std::endl; }
X(const X&x) { std::cout << "X copy ctor" << std::endl; }
~X() { std::cout << "X dtor" << std::endl; }
};
X make_x()
{
X x1, x2;
if (std::time(nullptr) % 50 == 0) {
return x1;
}
else {
return x2;
}
}
int main()
{
X x3 = make_x();
}现在make_x()函数不确定会返回哪个对象了,如果继续在GCC中添加“-fno-elide-constructors”开关进行编译,则运行时依然会出现两次复制构造函数:
X ctor
X ctor
X copy ctor
X dtor
X dtor
X copy ctor
X dtor
X dtor若删除“-fno-elide-constructors”开关是否会消除复制构造函数呢?答案是否定的,这时只能消除一次复制构造:
X ctor
X ctor
X copy ctor
X dtor
X dtor
X dtor原因其实很容易想到,由于以上代码中究竟由x1还是x2复制到x3是无法在编译期决定的,因此编译器无法在默认构造阶段就对x3进行构造,它需要分别将x1和x2构造后,根据运行时的结果将x1或者x2复制构造到x3,在这个过程中返回值优化技术也尽其所能地将中间的临时对象优化掉了,所以这里只会看到一次复制构造函数的调用。
为了让读者更清晰地了解这部分的过程,我们让GCC生成中间代码GIMPLE,然后对比其中的区别。
不带“-fno-elide-constructors”的中间代码:
make_x ()
{
struct X x1 [value-expr: *<retval>];
X::X (<retval>);
try
{
return <retval>;
}
catch
{
X::~X (<retval>);
}
}
…
main ()
{
int D.39995;
{
struct X x2;
try
{
x2 = make_x (); [return slot optimization]
try
{
}
finally
{
X::~X (&x2);
}
}
finally
{
x2 = {CLOBBER};
}
}
D.39995 = 0;
return D.39995;
}带“-fno-elide-constructors”的中间代码:
make_x ()
{
struct X x1;
try
{
X::X (&x1);
try
{
X::X (<retval>, &x1);
return <retval>;
}
finally
{
X::~X (&x1);
}
}
finally
{
x1 = {CLOBBER};
}
}
…
main ()
{
struct X D.36509;
int D.40184;
{
struct X x2;
try
{
D.36509 = make_x (); [return slot optimization]
try
{
try
{
X::X (&x2, &D.36509);
}
finally
{
try
{
X::~X (&D.36509);
}
catch
{
X::~X (&x2);
}
}
}
finally
{
D.36509 = {CLOBBER};
}
try
{
}
finally
{
X::~X (&x2);
}
}
finally
{
x2 = {CLOBBER};
}
}
D.40184 = 0;
return D.40184;
}读者看出其中的区别了吗?
1.make_x函数中前者直接使用调用者的返回值构造X::X (<retval>);,而后者使用x1构造X::X (&x1);,在构造结束之后再复制到返回值X::X (<retval>, &x1);。
2.在main函数中前者在make_x后没有任何复制动作,因为这时x2已经构建完成,而后者先调用D.36509 = make_x ();将返回值复制到临时对象,然后再通过X::X (&x2, &D.36509);复制到x2。
另外值得注意的是,虽然返回值优化技术可以省略创建临时对象和复制构造的过程,但是C++11标准规定复制构造函数必须是存在且可访问的,否则程序是不符合语法规则的,例如:
#include <iostream>
class X {
public:
X() { std::cout << "X ctor" << std::endl; }
~X() { std::cout << "X dtor" << std::endl; }
private:
X(const X&x) { std::cout << "X copy ctor" << std::endl; }
};
X make_x()
{
return X();
}
int main()
{
X x2 = make_x();
}在上面的代码中,我们将类X的复制构造函数设置为私有。根据返回值优化的要求,复制构造函数必须是可访问的,所以上面的代码在C++11的编译环境下将会导致编译错误。
C++14标准对返回值优化做了进一步的规定,规定中明确了对于常量表达式和常量初始化而言,编译器应该保证RVO,但是禁止NRVO。
在C++17标准中提到了确保复制消除的新特性,它从另一个角度出发对C++进行了性能优化,而且也能达到RVO的效果。该特性指出,在传递临时对象或者从函数返回临时对象的情况下,编译器应该省略对象的复制和移动构造函数,即使这些复制和移动构造还有一些额外的作用,最终还是直接将对象构造到目标的存储变量上,从而避免临时对象的产生。标准还强调,这里的复制和移动构造函数甚至可以是不存在或者不可访问的。
以上描述可以分为两个部分理解,首先对于临时对象强制省略对象的复制和移动构造函数,这一点实际上和RVO一样,只是对编译器提出了硬性要求。其次,也是最引人注意的一点,它允许复制和移动构造函数是不存在和不可访问的。在上面的例子中我们已经看到,返回值优化对于这一点是不允许的,现在我们不妨将上面代码的编译环境切换到C++17,读者一定会惊喜地发现代码编译成功了。另外,我们甚至可以更激进一些,显式删除复制构造函数:
X(const X&x) = delete;同样会发现,这份代码依然能正确地编译运行。这一点带来的最大好处是,所有类型都能使用工厂函数,即使该类型没有复制或者移动构造函数,例如:
#include <atomic>
template<class T, class Arg>
T create(Arg&& arg)
{
return T(std::forward<Arg>(arg));
}
int main()
{
std::atomic<int> x = create<std::atomic<int>>(11);
}请注意上面的代码,由于std::atomic的复制构造函数被显式删除了,同时编译器也不会提供默认的移动构造函数,因此在C++17之前是无法编译成功的。而在C++17的标准下则不存在这个问题,代码能够顺利地编译运行。
最后提醒读者一点,返回值优化虽然能够帮助我们减少返回对象的复制,但是作为程序员还是应该尽量减少对这些优化的依赖,因为不同的编译器对其的支持可能是不同的。面对传递对象的需求,我们可以尽量通过传递引用参数的方式完成,不要忘了C++11中支持的移动语义,它也能在一定程度上代替返回值优化的工作。
以下代码在C++20标准之前是无法编译成功的:
struct C {
int i;
friend bool operator==(C, C) = default;
};因为在C++20之前的标准中,类的默认比较规则要求类C可以有一个参数为const C&的非静态成员函数,或者有两个参数为const C&的友元函数。而C++20标准对这一条规则做了适度的放宽,它规定类的默认比较运算符函数可以是一个参数为const C&的非静态成员函数,或是两个参数为const C&或C的友元函数。这里的区别在于允许按值进行默认比较,于是上面的代码可以顺利地通过编译。但是请注意,下面这两种情况依旧是标准不允许的:
struct A {
friend bool operator==(A, const A&) = default;
};
struct B {
bool operator==(B) const = default;
};在上面的代码中,A因为混用const A&和A而不符合标准要求,所以编译失败。另外,标准并没有放宽默认比较中对于非静态成员函数的要求,B依然无法通过编译。
一直以来,C++在声明数组的时候都支持通过初始化时的元素个数推导数组长度,比如:
int x[]{ 1, 2, 3 };
char s[]{ "hello world" };这种声明数组的方式非常方便,特别是对于字符串数组而言,将计算数组所需长度的任务交给编译器,省去了我们挨个数字符检查的烦恼。但遗憾的是,这个特性并不完整,因为在用new表达式声明数组的时候无法把推导数组长度的任务交给编译器,所以下面的代码就无法成功编译了:
int *x = new int[]{ 1, 2, 3 };
char *s = new char[]{ "hello world" };好在C++20标准解决了以上问题。提案文档中强调在数组声明时根据初始化元素个数推导数组长度的特性应该是一致的,所以用以上方式声明数组理应是一个合法的语法规则。需要注意的是,到目前为止支持这一特性的编译器只有CLang,GCC和MSVC都是无法编译上面的代码的。
在C++20标准中允许数组转换为未知范围的数组,例如:
void f(int(&)[]) {}
int arr[1];
int main()
{
f(arr);
int(&r)[] = arr;
}以上代码在C++20标准下可以正常编译通过。对于重载函数的情况,编译器依旧会选择更为精准匹配的函数:
void f(int(&)[])
{
std::cout << "call f(int(&)[])";
}
void f(int(&)[1])
{
std::cout << "call f(int(&)[1])";
}
int arr[1];
int main()
{
f(arr);
}在上面的代码中,void f(int(&)[1])明显更匹配int arr[1];,所以输出结果为call f(int(&)[1])。需要注意的是,到目前为止只有GCC能够支持该特性。
我们知道,通常情况下delete一个对象,编译器会先调用该对象的析构函数,之后才会调用delete运算符删除内存,例如:
#include <new>
struct X {
X() {}
~X()
{
std::cout << "call dtor" << std::endl;
}
void* operator new(size_t s)
{
return ::operator new(s);
}
void operator delete(void* ptr)
{
std::cout << "call delete" << std::endl;
::operator delete(ptr);
}
};
X* x = new X;
delete x;以上代码的输出结果必然是:
call dtor
call delete在C++20标准以前,这个析构和释放内存的操作完全由编译器控制,我们无法将其分解开来。但是从C++20标准开始,这个过程可以由我们控制了,而且实现方法也非常简单:
struct X {
X() {}
~X()
{
std::cout << "call dtor" << std::endl;
}
void* operator new(size_t s)
{
return ::operator new(s);
}
void operator delete(X* ptr, std::destroying_delete_t)
{
std::cout << "call delete" << std::endl;
::operator delete(ptr);
}
};请注意在上面的代码中,delete运算符发生的两个变化:第一个参数类型由void *修改为X *;增加了一个类型为std::destroying_delete_t的形参,且我们并不会用到它。编译器会识别到delete运算符形参的变化,然后由我们去控制对象的析构。比如在上面的代码中,我们没有调用析构函数,于是输出的结果如下:
call delete在这种情况下,我们需要自己调用析构函数:
void operator delete(X* ptr, std::destroying_delete_t)
{
ptr->~X();
std::cout << "call delete" << std::endl;
::operator delete(ptr);
}C++20标准完善了调用伪析构函数结束对象生命周期的规则。在过去,调用伪析构函数会根据对象的不同执行不同的行为,例如:
template<typename T>
void destroy(T* p) {
p->~T();
}在上面的代码中,当T是非平凡类型时,p->~T();会结束对象生命周期;相反当T为平凡类型时,比如int类型,p->~T();会被当成无效语句。C++20标准修补了这种行为不一致的规则,它规定伪析构函数的调用总是会结束对象的生命周期,即使对象是一个平凡类型。
考虑这样一个类或者结构体,它编写复制构造函数的时候没有使用const:
struct MyType {
MyType() = default;
MyType(MyType&) {};
};template <typename T>
struct Wrapper {
Wrapper() = default;
Wrapper(const Wrapper&) = default;
T t;
};
Wrapper<MyType> var;Wrapper的复制构造函数的形参是const版本而其成员MyType不是,这种不匹配在C++17和以前的标准中是不被允许的。但仔细想想,这样的规定并不合理,因为代码并没有试图去调用复制构造函数。在C++20标准中修正了这一点,如果不发生复制动作,这样的写法是可以通过编译的。需要注意的是,就目前的编译器情况来看MSVC和GCC都对C++17标准做了优化,也就是说以上代码无论在C++17还是C++20标准上都可以编译通过,只有CLang严格遵照标准,在C++17的环境下会报错。当然:
Wrapper<MyType> var1;
Wrapper<MyType> var2(var1);这样的写法是无论如何都会编译失败的。
volatile是一个非常著名的关键字,用于表达易失性。它能够让编译器不要对代码做过多的优化,保证数据的加载和存储操作被多次执行,即使编译器知道这种操作是无用的,也无法对其进行优化。事实上,在现代的计算机环境中,volatile限定符的意义已经不大了。首先我们必须知道,该限定符并不能保证数据的同步,无法保证内存操作不被中断,它的存在不能代替原子操作。其次,虽然volatile操作的顺序不能相对于其他volatile操作改变,但是可以相对于非volatile操作改变。更进一步来说,即使从C++编译代码的层面上保证了操作执行的顺序,但是对于现代CPU而言这种操作执行顺序也是无法保证的。
因为volatile限定符现实意义的减少以及部分程序员对此理解的偏差,C++20标准在部分情况中不推荐volatile的使用,这些情况包括以下几种。
1.不推荐算术类型的后缀++和--表达式以及前缀++和--表达式使用volatile限定符:
volatile int d = 5;
d++;
--d;2.不推荐非类类型左操作数的赋值使用volatile限定符:
// E1 op= E2
volatile int d = 5;
d += 2;
d *= 3;3.不推荐函数形参和返回类型使用volatile限定符:
volatile int f() { return 1; }
int g(volatile int v) { return v; }4.不推荐结构化绑定使用volatile限定符:
struct X {
int a;
short b;
};
X x{ 11, 7 };
volatile auto [a, b] = x;以上4种情况在C++20标准的编译环境中编译都会给出'volatile'- qualified type is deprecated的警告信息。
对于逗号运算符我们再熟悉不过了,它可以让多个表达式按照从左往右的顺序进行计算,整体的结果为系列中最后一个表达式的值,例如:
int a[]{ 1,2,3 };
int x = 1, y = 2;
std::cout << a[x, y];在上面的代码中,std::cout << a[x, y];等同于std::cout << a[y];,最后输出结果是3。不过从C++20标准开始,std::cout << a[x, y];这句代码会被编译器提出警告,因为标准已经不推荐在下标表达式中使用逗号运算符了。该规则的提案文档明确地表示,希望array[x,y]这种表达方式能用在矩阵、视图、几何实体、图形API中。而对于老代码的维护者或者依旧想在下标表达式中使用逗号运算符的读者,可以在下标表达式外加上小括号来消除警告:
std::cout << a[(x, y)];模块(module)是C++20标准引入的一个新特性,它的主要用途是将大型工程中的代码拆分成独立的逻辑单元,以方便大型工程的代码管理。模块能够大大减少使用头文件带来的问题,例如在使用头文件时经常会遇到宏和函数的重定义,而模块则会好很多,因为宏和未导出名称对于导入模块是不可见的。使用模块也能大幅提升编译效率,因为编译后的模块信息会存储在一个二进制文件中,编译器对于它的处理速度要远快于单纯使用文本替换的头文件方法。可惜到目前为止并没有主流编译器完全支持该特性,所以这里只做简单介绍:
// helloworld.ixx
export module helloworld;
import std.core;
export void hello() {
std::cout << "Hello world!\n";
}
// modules_test.cpp
import helloworld;
int main()
{
hello();
}上面的代码很容易理解,helloworld.ixx是接口文件,它将编译成一个名为helloworld的导出模块。在模块中使用import引入了std.core,std.core是一个STL模块,包含了STL中最主要的容器和算法。除此之外,模块还使用export导出了一个hello函数。编译器编译helloworld.ixx会生成一个helloworld.ifc,该文件包含了模块的元数据。modules_test.cpp可以通过import helloworld;导入helloworld模块,并且调用它的导出函数hello。
在使用VS 2019进行编译时有两点需要注意。
1.在安装VS 2019的C++环境时勾选模块(默认不勾选)。如果不做这一步,会导致import std.core;无法正确编译。
2.编译选项开启/experimental:module。
本章介绍了新标准中多个基础特性的优化,这些特性大部分是对现有C++功能的完善,虽然它们非常简单,但是有些却非常实用,比如支持显式自定义类型转换运算符、支持new表达式推导数组长度等。在这些特性当中影响最大的应该是返回值优化,虽然该特性对于程序员本身来说并无感知,但是编译器却做了相当多的工作,也正因为该特性的存在使代码在升级标准环境后可以有性能上的提升。