《现代C++语言核心特性解析》笔记(一)

一、新基础类型(C++11~C++20)

C++基础类型回顾一览表

在这里插入图片描述

1. 整数类型 long long

我们知道long通常表示一个32位整型,而long long则是用来表示一个64位的整型。不得不说,这种命名方式简单粗暴。不仅写法冗余,而且表达的含义也并不清晰。如果按照这个命名规则,那么128位整型就该被命名为long long long了。但是不管怎么样,long long既然已经加入了 C++ 11 的标准,那么我们能做的就是适应它,并且希望不会有long long long这种类型的诞生。

C++标准中定义,long long是一个至少为64的整数类型。请注意这里的用词“至少”,也就说 long long 的实际长度可能大于64

另外,long long 是一个有符号类型,对应的无符号类型为 unsigned long long,当然读者可能看到过诸如 long long intunsigned long long int等类型,实际上它们和long longunsigned long long具有相同的含义。C++标准还为其定义LLULL作为这两种类型的字面量后缀,所以在初始化long long类型变量的时候可以这么写:

long long x = 65536LL;

当然,这里可以忽略LL这个字面量后缀,直接写成下面的形式也可以达到同样的效果:

long long x = 65536;

要强调的是,字面量后缀并不是没有意义的,在某些场合下我们必须用到它才能让代码的逻辑正确,比如下面的代码:

long long x1 = 65536 << 16; // 计算得到的 x1 值为 0
std::cout << "x1 = " << x1 << std::endl;
long long x2 = 65536LL << 16; // 计算得到的 x2 值为 4294967296(0x100000000)
std::cout << "x2 = " << x2 << std::endl;

以上代码的目的是将65536左移16位,以获得一个更大的数值。但是,x1计算出来的值却是0,没有增大反而减小了。原因是在没有字面量后缀的情况下,这里的65536被当作32位整型操作,在左移16位以后,这个32位整型的值变成了0,所以事实是将0赋值给了x1,于是我们看到x1输出的结果为0。而在计算x2的过程中,代码给65536添加了字面量后缀LL,这使编译器将其编译为一个64位整型,左移16位后仍然可以获得正确的结果:42949672960x100000000)。另外,有些编译器可能在编译long long x1 = 65536 << 16;的时候显示一些警告提示,而另一些编译器可能没有,无论如何我们必须在编写代码的时候足够小心,避免上面情况的发生。

和其他整型一样,long long也能运用于枚举类型和位域,例如:

enum longlong_enum : long long {
	x1,
	x2
};
struct longlong_struct {
	long long x1 : 8;
	long long x2 : 24;
	long long x3 : 32;
};
std::cout << sizeof(longlong_enum::x1) << std::endl; // 输出大小为8
std::cout << sizeof(longlong_struct) << std::endl; // 输出大小为8

作为一个新的整型long long,C++标准必须为它配套地加入整型的大小限制。在头文件中增加了以下宏,分别代表long long的最大值和最小值以及unsigned long long的最大值:

#define LLONG_MAX 9223372036854775807LL         // long long的最大值
#define LLONG_MIN (-9223372036854775807LL - 1)  // long long的最小值
#define ULLONG_MAX 0xffffffffffffffffULL        // unsigned long long的最大值

在C++中应该尽量少使用宏,用模板取而代之是明智的选择。

C++标准中对标准库头文件做了扩展,特化了long longunsigned long long版本的numeric_ limits类模板。这使我们能够更便捷地获取这些类型的最大值和最小值,如下面的代码示例:

#include <iostream>
#include <limits>
#include <cstdio>
int main(int argc, char *argv[])
{
	// 使用宏方法
	std::cout << "LLONG_MAX = " << LLONG_MAX << std::endl;
	std::cout << "LLONG_MIN = " << LLONG_MIN << std::endl;
	std::cout << "ULLONG_MAX = " << ULLONG_MAX << std::endl;
	// 使用类模板方法
	std::cout << "std::numeric_limits<long long>::max() = "
	<< std::numeric_limits<long long>::max() << std::endl;
	std::cout << "std::numeric_limits<long long>::min() = "
	<< std::numeric_limits<long long>::min() << std::endl;
	std::cout << "std::numeric_limits<unsigned long long>::max() = "
	<< std::numeric_limits<unsigned long long>::max() <<
	std::endl;
	// 使用printf打印输出
	std::printf("LLONG_MAX = %lld\n", LLONG_MAX);
	std::printf("LLONG_MIN = %lld\n", LLONG_MIN);
	std::printf("ULLONG_MAX = %llu\n", ULLONG_MAX);
}

输出结果如下:

LLONG_MAX = 9223372036854775807
LLONG_MIN = -9223372036854775808
ULLONG_MAX = 18446744073709551615
std::numeric_limits<long long>::max() = 9223372036854775807
std::numeric_limits<long long>::min() = -9223372036854775808
std::numeric_limits<unsigned long long>::max() = 18446744073709551615
LLONG_MAX = 9223372036854775807
LLONG_MIN = -9223372036854775808
ULLONG_MAX = 18446744073709551615

随着整型long long的加入,std::printf也加入了对其格式化打印的能力。新增的长度指示符ll可以用来指明变量是一个long long类型,所以我们分别使用%lld%llu来格式化有符号和无符号的long long整型了。当然,使用C++标准的流输入/输出是一个更好的选择。

2. 新字符类型 char16_t 和 char32_t

在C++11标准中添加两种新的字符类型char16_tchar32_t,它们分别用来对应Unicode字符集的UTF-16UTF-32两种编码方法。在正式介绍它们之前,需要先弄清楚字符集和编码方法的区别。

字符集和编码方法

通常我们所说的字符集是指系统支持的所有抽象字符的集合,通常一个字符集的字符是稳定的。而编码方法是利用数字和字符集建立对应关系的一套方法,这个方法可以有很多种,比如Unicode字符集就有UTF-8UTF-16UTF-323种编码方法。除了Unicode字符集,我们常见的字符集还包括ASCII字符集、GB2312字符集、BIG5字符集等,它们都有各自的编码方法。字符集需要和编码方式对应,如果这个对应关系发生了错乱,那么我们就会看到计算机世界中令人深恶痛绝的乱码。

不过,现在的计算机世界逐渐达成了一致,就是尽量以 Unicode 作为字符集标准,那么剩下的工作就是处理 UTF-8UTF-16UTF-323 种编码方法的问题了。

UTF-8UTF-16UTF-32 简单来说是使用不同大小内存空间的编码方法。

UTF-32 是最简单的编码方法,该方法用一个32位的内存空间(也就是4字节)存储一个字符编码,由于Unicode字符集的最大个数为 0x10FFFFISO 10646),因此4字节的空间完全能够容纳任何一个字符编码。UTF-32 编码方法的优点显而易见,它非常简单,计算字符串长度和查找字符都很方便;缺点也很明显,太占用内存空间。

UTF-16 编码方法所需的内存空间从 32 位缩小到 16 位(占用 2 字节),但是由于存储空间的缩小,因此 UTF-16 最多只能支持 0xFFFF 个字符,这显然不太够用,于是 UTF-16 采用了一种特殊的方法来表达无法表示的字符。简单来说,从 0x0000 ~ 0xD7FF 以及 0xE000 ~ 0xFFFF 直接映射到 Unicode 字符集,而剩下的 0xD800 ~ 0xDFFF 则用于映射 0x10000 ~ 0x10FFFFUnicode 字符集,映射方法为:字符编码减去 0x10000 后剩下的 20 比特位分为高位和低位,高 10 位的映射范围为 0xD800 ~ 0xDBFF,低 10 位的映射范围为 0xDC00 ~ 0xDFFF。例如 0x10437,减去 0x10000 后的高低位分别为 0x10x37,分别加上 0xD8000xDC00 的结果是 0xD8010xDC37

幸运的是,一般情况下 0xFFFF 足以覆盖日常字符需求,我们也不必为了 UTF-16 的特殊编码方法而烦恼。 UTF-16 编码的优势是可以用固定长度的编码表达常用的字符,所以计算字符长度和查找字符也比较方便。另外,在内存空间使用上也比 UTF-32 好得多。

最后说一下我们最常用的 UTF-8 编码方法,它是一种可变长度的编码方法。由于 UTF-8 编码方法只占用 8 比特位(1 字节),因此要表达完数量高达 0x10FFFF 的字符集,它采用了一种前缀编码的方法。这个方法可以用 1 ~ 4 字节表示字符个数为 0x10FFFFUnicodeISO 10646)字符集。为了尽量节约空间,常用的字符通常用 1 ~ 2 字节就能表达,其他的字符才会用到 3 ~ 4 字节,所以在内存空间可以使用 UTF-8,但是计算字符串长度和查找字符在 UTF-8 中却是一个令人头痛的问题。表1-1展示了 UTF-8 对应的范围。

在这里插入图片描述

使用新字符类型 char16_t 和 char32_t

对于 UTF-8 编码方法而言,普通类型似乎是无法满足需求的,毕竟普通类型无法表达变长的内存空间。所以一般情况下我们直接使用基本类型 char 进行处理,而过去也没有一个针对 UTF-16UTF-32 的字符类型。到了 C++11char16_tchar32_t 的出现打破了这个尴尬的局面。除此之外, C++11 标准还为3种编码提供了新前缀用于声明3种编码字符和字符串的字面量,它们分别是 UTF-8 的前缀 u8UTF-16 的前缀 uUTF-32 的前缀 U

char utf8c = u8'a'; // C++17标准
//char utf8c = u8'好';
char16_t utf16c = u'好';
char32_t utf32c = U'好';
char utf8[] = u8"你好世界";
char16_t utf16[] = u"你好世界";
char32_t utf32[] = U"你好世界";

在上面的代码中,分别使用UTF-8UTF-16UTF-32编码的字符和字符串对变量进行了初始化,代码很简单,不过还是有两个地方值得一提。

  • char utf8c = u8'a'在 C++11 标准中实际上是无法编译成功的,因为在 C++11 标准中u8只能作为字符串字面量的前缀,而无法作为字符的前缀。这个问题直到 C++17 标准才得以解决,所以上述代码需要 C++17 的环境来执行编译。

  • char utf8c = u8'好'是无法通过编译的,因为存储“”需要3字节,显然utf8c只能存储1字节,所以会编译失败。

wchar_t 存在的问题

在 C++98 的标准中提供了一个wchar_t字符类型,并且还提供了前缀L,用它表示一个宽字符。事实上 Windows 系统的 API 使用的就是wchar_t,它在 Windows 内核中是一个最基础的字符类型:

HANDLE CreateFileW(
	LPCWSTR lpFileName,);
CreateFileW(L"c:\\tmp.txt",);

上面是一段在Windows系统上创建文件的伪代码,可以看出Windows为创建文件的API提供了宽字符版本,其中LPCWSTR实际上是const wchar_t的指针类型,我们可以通过L前缀来定义一个wchar_t类型的字符串字面量,并且将其作为实参传入API。

讨论到这里读者会产生一个疑问,既然已经有了处理宽字符的字符类型,那么为什么又要加入新的字符类型呢?没错,wchar_t确实在一定程度上能够满足我们对于字符表达的需求,但是起初在定义wchar_t时并没有规定其占用内存的大小。于是就给了实现者充分的自由,以至于在Windows上wchar_t是一个16位长度的类型(2字节),而在 Linux 和 macOS 上wchar_t却是32位的(4字节)。这导致了一个严重的后果,我们写出的代码无法在不同平台上保持相同行为。而char16_tchar32_t的出现解决了这个问题,它们明确规定了其所占内存空间的大小,让代码在任何平台上都能够有一致的表现。

新字符串连接

由于字符类型增多,因此我们还需要了解一下字符串连接的规则:

  • 如果两个字符串字面量具有相同的前缀,则生成的连接字符串字面量也具有该前缀,如表1-2所示。
  • 如果其中一个字符串字面量没有前缀,则将其视为与另一个字符串字面量具有相同前缀的字符串字面量,
  • 其他的连接行为由具体实现者定义。另外,这里的连接操作是编译时的行为,而不是一个转换。

在这里插入图片描述

需要注意的是,进行连接的字符依然是保持独立的,也就是说不会因为字符串连接,将两个字符合并为一个,例如连接"\xA" “B"的结果应该是”\nB"(换行符和字符B),而不是一个字符"\xAB"。

库对新字符类型的支持

随着新字符类型加入C++11标准,相应的库函数也加入进来。C11在中增加了4个字符的转换函数,包括:

size_t mbrtoc16( char16_t* pc16, const char* s, size_t n, mbstate_t* ps );
size_t c16rtomb( char* s, char16_t c16, mbstate_t* ps );
size_t mbrtoc32( char32_t* pc32, const char* s, size_t n, mbstate_t* ps );
size_t c32rtomb( char* s, char32_t c32, mbstate_t* ps );

它们的功能分别是多字节字符和 UTF-16 编码字符互转,以及多字节字符和 UTF-32 编码字符互转。在 C++11 中,我们可以通过包含 <cuchar> 来使用这 4 个函数。当然 C++11 中也添加了 C++ 风格的转发方法 std::wstring_convert 以及 std::codecvt。使用类模板 std::wstring_convertstd::codecvt 相结合,可以对多字节字符串和宽字符串进行转换。(不过它们在 C++17 标准中已经不被推荐使用了,所以应该尽量避免使用它们)

除此之外,C++标准库的字符串也加入了对新字符类型的支持,例如:

using u16string = basic_string;
using u32string = basic_string;
using wstring = basic_string;

3. char8_t 字符类型

使用char类型来处理UTF-8字符虽然可行,但是也会带来一些困扰,比如当库函数需要同时处理多种字符时必须采用不同的函数名称以区分普通字符和UTF-8字符。C++20 标准新引入的类型char8_t可以解决以上问题,它可以代替char作为UTF-8的字符类型。char8_t具有和unsigned char相同的符号属性、存储大小、对齐方式以及整数转换等级。引入char8_t类型后,在 C++17 环境下可以编译的UTF-8字符相关的代码会出现问题,例如:

char str[] = u8"text"; // C++17编译成功;C++20编译失败,需要char8_t
char c = u8'c';

当然反过来也不行:

char8_t c8a[] = "text"; // C++20编译失败,需要char
char8_t c8 = 'c';

另外,为了匹配新的char8_t字符类型,库函数也有相应的增加:

size_t mbrtoc8(char8_t* pc8, const char* s, size_t n, mbstate_t* ps);
size_t c8rtomb(char* s, char8_t c8, mbstate_t* ps);
using u8string = basic_string;

最后需要说明的是,上面这些例子只是C++标准库为新字符类型新增代码的冰山一角,有兴趣的读者可以翻阅标准库代码,包括<atomic>、<filesystem>、<istream>、<limits>、<locale>、<ostream>以及<string_ view>等头文件,这里就不一一介绍了。

总结

本章从 C++ 最基础的新特性入手,介绍了整型 long long 以及 char8_tchar16_tchar32_t 字符类型。虽说这些新的基础类型非常简单,但是磨刀不误砍柴工,掌握新基础类型(尤其是 3 种不同的 Unicode 字符类型)会让我们在使用 C++ 处理字符、字符串以及文本方面更加游刃有余。比如,当你正在为处理文本文件中 UTF-32 编码的字符而头痛时,采用新标准中 char32_tu32string 也许会让问题迎刃而解。

二、内联和嵌套命名空间(C++11~C++20)

1. 内联命名空间的定义和使用

开发一个大型工程必然会有很多开发人员的参与,也会引入很多第三方库,这导致程序中偶尔会碰到同名函数和类型,造成编译冲突的问题。为了缓解该问题对开发的影响,我们需要合理使用命名空间。程序员可以将函数和类型纳入命名空间中,这样在不同命名空间的函数和类型就不会产生冲突,当要使用它们的时候只需打开其指定的命名空间即可,例如:

namespace S1 {
	void foo() {}
}
namespace S2 {
	void foo() {}
}
using namespace S1;
int main()
{
	foo();
	S2::foo();
}

以上是命名空间的一个典型例子,例子中命名空间 S1S2 都有相同的函数 foo,在调用两个函数时,由于命名空间 S1using 关键字打开,因此 S1foo 函数可以直接使用,而 S2foo 函数需要使用 :: 来指定函数的命名空间。

C++11 标准增强了命名空间的特性,提出了内联命名空间的概念。内联命名空间能够把空间内函数和类型导出到父命名空间中,这样即使不指定子命名空间也可以使用其空间内的函数和类型了,比如:

#include <iostream>

namespace Parent {
	namespace Child1
	{
		void foo() { std::cout << "Child1::foo()" << std::endl; }
	}
	
	inline namespace Child2
	{
		void foo() { std::cout << "Child2::foo()" << std::endl; }
	}
}

int main()
{
	Parent::Child1::foo();
	Parent::foo();
}

在上面的代码中,Child1 不是一个内联命名空间,所以调用 Child1foo 函数需要明确指定所属命名空间。而调用 Child2foo 函数则方便了许多,直接指定父命名空间即可。现在问题来了,这个新特性的用途是什么呢?这里删除内联命名空间,将 foo 函数直接纳入 Parent 命名空间也能达到同样的效果。

实际上,该特性可以帮助库作者无缝升级库代码,让客户不用修改任何代码也能够自由选择新老库代码。举个例子:

#include <iostream>
namespace Parent {
	void foo() { std::cout << "foo v1.0" << std::endl; }
}
int main()
{
	Parent::foo();
}

假设现在Parent代码库提供了一个接口foo来完成一些工作,突然某天由于加入了新特性,需要升级接口。有些用户喜欢新的特性但并不愿意为了新接口去修改他们的代码;还有部分用户认为新接口影响了稳定性,所以希望沿用老的接口。这里最直接的办法是提供两个不同的接口函数来对应不同的版本。但是如果库中函数很多,则会出现大量需要修改的地方。另一个方案就是使用内联命名空间,将不同版本的接口归纳到不同的命名空间中,然后给它们一个容易辨识的空间名称,最后将当前最新版本的接口以内联的方式导出到父命名空间中,比如:

namespace Parent {
    namespace V1 {
		void foo() { std::cout << "foo v1.0" << std::endl; }
	}

	inline namespace V2 {
		void foo() { std::cout << "foo v2.0" << std::endl; }
	}
}

int main()
{
	Parent::foo();
}

从上面的代码可以看出,虽然 foo 函数从 V1 升级到了 V2,但是客户的代码并不需要任何修改。如果用户还想使用 V1 版本的函数,则只需要统一添加函数版本的命名空间,比如 Parent::V1::foo()。使用这种方式管理接口版本非常清晰,如果想加入 V3 版本的接口,则只需要创建 V3 的内联命名空间,并且将命名空间 V2inline 关键字删除。请注意,示例代码中只能有一个内联命名空间,否则编译时会造成二义性问题,编译器不知道使用哪个内联命名空间的 foo 函数。

2. 嵌套命名空间的简化语法

有时候打开一个嵌套命名空间可能只是为了向前声明某个类或者函数,但是却需要编写冗长的嵌套代码,加入一些无谓的缩进,这很难让人接受。幸运的是,C++17标准允许使用一种更简洁的形式描述嵌套命名空间,例如:

namespace A::B::C {
	int foo() { return 5; }
}

以上代码等同于:

namespace A {
	namespace B {
		namespace C {
			int foo() { return 5; }
		}
	}
}

很显然前者是一种更简洁的定义嵌套命名空间的方法。除简洁之外,它也更加符合我们已有的语法习惯,比如嵌套类:

std::vector<int>::iterator it;

实际上这份语法规则的提案早在2003年的时候就已经提出,只不过到C++17才被正式引入标准。另外有些遗憾的是,在C++17标准中没有办法简洁地定义内联命名空间,这个问题直到C++20标准才得以解决。在C++20中,我们可以这样定义内联命名空间

namespace A::B::inline C {
    int foo() { return 5; }
}
// 或者
namespace A::inline B::C {
    int foo() { return 5; }
}

它们分别等同于:

namespace A::B { 
    inline namespace C {
        int foo() { return 5; }
    } 
}

namespace A { 
    inline namespace B { 
        namespace C {
            int foo() { return 5; }
        } 
    } 
}

请注意,inline可以出现在除第一个namespace之外的任意namespace之前。

总结

本章主要介绍内联命名空间,正如上文中介绍的,该特性可以帮助库作者无缝切换代码版本而无须库的使用者参与。另外,使用新的嵌套命名空间语法能够有效消除代码冗余,提高代码的可读性。

三、auto 占位符(C++11~C++17)

1. 重新定义的 auto 关键字

严格来说auto并不是一个新的关键字,因为它从C++98标准开始就已经存在了。当时auto是用来声明自动变量的,简单地说,就是拥有自动生命期的变量,显然这是多余的,现在我们几乎不会使用它。于是C++11标准赋予了auto新的含义:声明变量时根据初始化表达式自动推断该变量的类型、声明函数时函数返回值的占位符。例如:

auto i = 5; // 推断为 int
auto str = "hello auto"; // 推断为 const char*
auto sum(int a1, int a2)->int // 返回类型后置,auto 为返回值占位符
{
	return a1+a2;
}

在上面的代码中,我们不需要为istr去声明具体的类型,auto要求编译器自动完成变量类型的推导工作。sum函数中的auto是一个返回值占位符,真正的返回值类型是intsum函数声明采用了函数返回类型后置的方法,该方法主要用于函数模板的返回值推导(见第5章)。注意,auto占位符会让编译器去推导变量类型,如果我们编写的代码让编译器无法进行推导,那么使用auto会导致编译失败,例如:

auto i; // 编译失败
i = 5;

很明显,以上代码在声明变量时没有对变量进行初始化,这使编译器无法确认其具体类型要导致编译错误,所以在使用auto占位符声明变量的时候必须初始化变量。进一步来说,有4点需要引起注意。

(1)当用一个auto关键字声明多个变量的时候,编译器遵从由左往右的推导规则,以最左边的表达式推断auto的具体类型:

int n = 5;
auto *pn = &n, m = 10;

在上面的代码中,因为&n类型为int *,所以pn的类型被推导为int *auto被推导为int,于是m被声明为int类型,可以编译成功。但是如果写成下面的代码,将无法通过编译:

int n = 5;
auto *pn = &n, m = 10.0; // 编译失败,声明类型不统一

上面两段代码唯一的区别在于赋值m的是浮点数,这和auto推导类型不匹配,所以编译器通常会给予一条“in a declarator-list 'auto' must always deduce to the same type”报错信息。细心的读者可能会注意到,如果将赋值代码替换为int m = 10.0;,则编译器会进行缩窄转换,最终结果可能会在给出一条警告信息后编译成功,而在使用auto声明变量的情况下编译器是直接报错的。

(2)当使用条件表达式初始化auto声明的变量时,编译器总是使用表达能力更强的类型:

auto i = true ? 5 : 8.0; // i 的数据类型为 double

在上面的代码中,虽然能够确定表达式返回的是int类型,但是i的类型依旧会被推导为表达能力更强的类型double

(3)虽然C++11标准已经支持在声明成员变量时初始化,但是auto却无法在这种情况下声明非静态成员变量:

struct sometype {
	auto i = 5; // 错误,无法编译通过
};

在C++11中静态成员变量是可以用auto声明并且初始化的,不过前提是auto必须使用const限定符:

struct sometype {
	static const auto i = 5;
};

遗憾的是,const限定符会导致i常量化,显然这不是我们想要的结果。幸运的是,在C++17标准中,对于静态成员变量,auto可以在没有const的情况下使用,例如:

struct sometype {
	static inline auto i = 5; // C++17
};

(4)按照C++20之前的标准,无法在函数形参列表中使用auto声明形参(注意,在C++14中,auto可以为lambda表达式声明形参):

void echo(auto str) {} // C++20之前编译失败,C++20编译成功

另外,auto也可以和new关键字结合。当然,我们通常不会这么用,例如:

auto i = new auto(5);
auto* j = new auto(5);

这种用法比较有趣,编译器实际上进行了两次推导,第一次是auto(5)auto被推导为int类型,于是new int的类型为int *,再通过int *推导ij的类型。我不建议像上面这样使用auto,因为它会破坏代码的可读性。在后面的内容中,我们将讨论应该在什么时候避免使用auto关键字。

2. 推导规则

(1)如果auto声明的变量是按值初始化,则推导出的类型会忽略cv限定符。进一步解释为,在使用auto声明变量时,既没有使用引用,也没有使用指针,那么编译器在推导的时候会忽略constvolatile限定符。当然auto本身也支持添加cv限定符:

const int i = 5;
auto j = i;   // auto 推导类型为 int,而非 const int
auto &m = i;  // auto 推导类型为 const int,m 推导类型为 const int&
auto *k = &i; // auto 推导类型为 const int,k 推导类型为 const int*
const auto n = j; // auto 推导类型为 int,n 的类型为 const int

根据规则1,在上面的代码中,虽然iconst int类型,但是因为按值初始化会忽略cv限定符,所以j的推导类型是int而不是const int。而mk分别按引用指针初始化,因此其cv属性保留了下来。另外,可以用const结合auto,让n的类型推导为const int

(2)使用auto声明变量初始化时,目标对象如果是引用,则引用属性会被忽略:

int i = 5;
int &j = i;
auto m = j; // auto 推导类型为 int,而非 int&

根据规则2,虽然ji引用,类型为int&,但是在推导m的时候会忽略其引用

(3)使用auto和万能引用声明变量时,对于左值会将auto推导为引用类型:

int i = 5;
auto&& m = i; // auto 推导类型为 int& (这里涉及引用折叠的概念)
auto&& j = 5; // auto 推导类型为 int

引用折叠是在自动类型推导(使用auto关键字)时的一个重要规则,它可以帮助确定最终的引用类型。在这段代码中,auto&&是一个通用引用,可以接受左值右值

根据规则3,因为i是一个左值,所以m的类型被推导为int&,这里涉及到了引用折叠的规则,因为 auto&& 是一个通用引用,当通用引用绑定到左值时,最终类型将成为左值引用。所以auto也被推导为int&

5是一个右值,因为它是一个临时值,因此j的类型被推导为int&&,而当通用引用绑定到右值时,最终类型仍然是右值引用,因此auto也被推导为int

(4)使用auto声明变量,如果目标对象是一个数组或者函数,则auto会被推导为对应的指针类型:

int i[5];
auto m = i; // auto 推导类型为 int*
int sum(int a1, int a2)
{
	return a1 + a2;
}
auto j = sum // auto 推导类型为 int (__cdecl *)(int,int)

根据规则4,虽然i是数组类型,但是m会被推导退化为指针类型,同样,j也退化为函数指针类型。

(5)当auto关键字与列表初始化组合时,这里的规则有新老两个版本,这里只介绍新规则(C++17标准)。

  • ① 直接使用列表初始化,列表中必须为单元素,否则无法编译,auto类型被推导为单元素的类型。
  • ② 用等号加列表初始化,列表中可以包含单个或者多个元素,auto类型被推导为std::initializer_list<T>,其中T是元素类型。请注意,在列表中包含多个元素的时候,元素的类型必须相同,否则编译器会报错。
auto x5{ 3 };         // x5 类型为 int
auto x3{ 1, 2 };      // 编译失败,不是单个元素

auto x1 = { 1, 2 };   // x1 类型为 std::initializer_list<int>
auto x2 = { 1, 2.0 }; // 编译失败,花括号中元素类型不同
auto x4 = { 3 };      // x4 类型为 std::initializer_list<int>

在上面的代码中,x1根据规则(5)② 被推导为std::initializer_list<T>,其中的元素都是int类型,所以x1被推导为std::initializer_list<int>。同样,x2也应该被推导为std::initializer_list<T>,但是显然两个元素类型不同,导致编译器无法确定T的类型,所以编译失败。根据规则(5)①,x3包含多个元素,直接导致编译失败。x4x1一样被推导为std::initializer_list<T>x5被推导为单元素的类型int

根据上面这些规则,读者可以思考下面的代码,auto会被推导成什么类型呢?

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

class Derived : public Base {
public:
    virtual void f() override {
        std::cout << "Derived::f()" << std::endl;
    };
};
Base* d = new Derived();
auto b = *d;
b.f();

以上代码有DerivedBase之间的继承关系,并且Derived重写了Basef函数。代码使用new创建了一个Derived对象,并赋值于基类的指针类型变量上。我们知道d->f()一定调用的是Derivedf函数。但是b.f()调用的又是谁的f函数呢?实际上,由于auto b = *d这一句代码是按值赋值的,因此auto会直接推导为Base。代码自然会调用Base的复制构造函数,也就是说Derived被切割成了Base,这里的b.f()最终调用Basef函数。那么进一步发散,如果代码写的是auto &b = *d,结果又会如何呢?(auto会被推导为Base&Base&等价于new Derived(),因此会调用Derived::f()

在 CLion 中编辑器会给出推断类型的提示:

在这里插入图片描述
在这里插入图片描述

3. 什么时候使用 auto

合理使用 auto,可以让程序员从复杂的类型编码中解放出来,不但可以少敲很多代码,也会大大提高代码的可读性。但是事情总是有它的两面性,如果滥用auto,则会让代码失去可读性,不仅让后来人难以理解,间隔时间长了可能自己写的代码也要研读很久才能弄明白其含义。

所以,下面我们来探讨一下,如何合理地使用auto

  1. 当一眼就能看出声明变量的初始化类型的时候可以使用auto
  2. 对于复杂的类型,例如lambda表达式、bind等直接使用auto

对于第一条规则,常见的是在容器的迭代器上使用,例如:

std::map<std::string, int> str2int;
// … 填充 str2int 的代码
for (std::map<std::string, int>::const_iterator it = str2int.cbegin();it != str2int.cend(); ++it) 
{
 ....
}
// 或者
for (std::pair<const std::string, int> &it : str2int) 
{
 ....
}

上面的代码如果不用auto来声明迭代器,那么我们需要编写std::map <std::string, int>::const_iteratorstd::pair<const std::string, int>来代替auto,而多出来的代码并不会增强代码的可读性,反而会让代码看起来冗余,因为通常我们一眼就能看明白it的具体类型。请注意,第二个forit类型是std::pair<const std::string, int>,而不是std::pair<std:: string, int>,如果写成后者是无法通过编译的。直接使用auto,可以避免上述问题:

std::map<std::string, int> str2int;
// … 填充 str2int 的代码
for (auto it = str2int.cbegin(); it != str2int.cend(); ++it) 
{
 ....
}
// 或者
for (auto &it : str2int) 
{
 ....
}

这样是不是简洁了很多!

反过来说,如果使用auto声明变量,则会导致其他程序员阅读代码时需要翻阅初始化变量的具体类型,那么我们需要慎重考虑是否适合使用auto关键字。

对于第二条规则,我们有时候会遇到无法写出类型或者过于复杂的类型,或者即使能正确写出某些复杂类型,但是其他程序员阅读起来也很费劲,这种时候建议使用auto来声明,例如lambda表达式:

auto l = [](int a1, int a2) { return a1 + a2; };

这里l的类型可能是一个这样的名称xxx::<lambda_efdefb7231ea076 22630c86251a36ed4>(不同的编译器命名方法会有所不同),我们根本无法写出其类型,只能用auto来声明。

再例如:

int sum(int a1, int a2) { return a1 + a2; }
auto b = std::bind(sum, 5, std::placeholders::_1);

这里b的类型为std::_Binder<std::_Unforced,int( cdecl&) (int,int),int, const std::_Ph<1> &>,绝大多数读者看到这种类型时会默契地选择使用auto来声明变量。

4. 返回类型推导

C++14标准支持对返回类型声明为auto的推导,例如:

auto sum(int a1, int a2) { return a1 + a2; }

在上面的代码中,编译器会帮助我们推导sum的返回值,由于a1a2都是int类型,所以其返回类型也是int,于是返回类型被推导为int类型。请注意,如果有多重返回值,那么需要保证返回值类型是相同的。例如:

auto sum(long a1, long a2)
{
	if (a1 < 0) {
		return 0; // 返回int类型
	}
	else {
		return a1 + a2; // 返回long类型
	}
}

以上代码中有两处返回,return 0 返回的是 int 类型,而 return a1+a2返回的是long类型,这种不同的返回类型会导致编译失败。

5. lambda 表达式中使用 auto 类型推导

在C++14标准中我们还可以把auto写到lambda表达式的形参中,这样就得到了一个泛型的lambda表达式,例如:

auto l = [](auto a1, auto a2) { return a1 + a2; };
auto retval = l(5, 5.0);

在上面的代码中a1被推导为int类型,a2被推导为double类型,返回值retval被推导为double类型。

让我们看一看lambda表达式返回auto引用的方法:

auto l = [](int &i)->auto& { return i; };
auto x1 = 5;
auto &x2 = l(x1);
assert(&x1 == &x2); // 有相同的内存地址

起初在后置返回类型中使用auto是不允许的,但是后来人们发现,这是唯一让lambda表达式通过推导返回引用类型的方法了。

6. 非类型模板形参占位符

C++17标准对auto关键字又一次进行了扩展,使它可以作为非类型模板形参的占位符。当然,我们必须保证推导出来的类型是可以用作模板形参的,否则无法通过编译,例如:

#include <iostream>
template<auto N>
void f()
{
	std::cout << N << std::endl;
}
int main()
{
	f<5>();   // N 为 int 类型
	f<'c'>(); // N 为 char 类型
	f<5.0>(); // 编译失败,模板参数不能为 double
}

在上面的代码中,函数f<5>()5的类型为int,所以auto被推导为int类型。同理,f<'c'>()auto被推导为char类型。由于f<5.0>()5.0被推导为double类型,但是模板参数不能为double类型,因此导致编译失败。

四、decltype说明符(C++11~C++17)

1. 回顾 typeof 和 typeid 获取类型

在C++11标准发布以前,GCC的扩展提供了一个名为typeof的运算符。通过该运算符可以获取操作数的具体类型。这让使用GCC的程序员在很早之前就具有了对对象类型进行推导的能力,例如:

int a = 0;
typeof(a) b = 5;

由于typeof并非C++标准,因此就不再深入介绍了。关于typeof更多具体的用法可以参考GCC的相关文档。

除使用GCC提供的typeof运算符获取对象类型以外,C++标准还提供了一个typeid运算符来获取与目标操作数类型有关的信息。获取的类型信息会包含在一个类型为std::type_info的对象里。我们可以调用成员函数name获取其类型名,例如:

int x1 = 0;
double x2 = 5.5;
std::cout << typeid(x1).name() << std::endl;      // Clion 中输出 i
std::cout << typeid(x1 + x2).name() << std::endl; // Clion 中输出 d
std::cout << typeid(int).name() << std::endl;     // Clion 中输出 i

值得注意的是,成员函数name返回的类型名在C++标准中并没有明确的规范,所以输出的类型名会因编译器而异。比如,MSVC会输出一个符合程序员阅读习惯的名称,而GCC则会输出一个它自定义的名称。

另外,还有3点也需要注意。

  1. typeid的返回值是一个左值,且其生命周期一直被扩展到程序生命周期结束。
  2. typeid返回的std::type_info删除了复制构造函数,若想保存std::type_info,只能获取其引用或者指针,例如:
auto t1 = typeid(int); // 编译失败,没有复制构造函数无法编译
auto &t2 = typeid(int); // 编译成功,t2 推导为 const std::type_info&
auto t3 = &typeid(int); // 编译成功,t3 推导为 const std::type_info*
  1. typeid的返回值总是忽略类型的 cv 限定符,也就是typeid(const T) == typeid(T))

虽然typeid可以获取类型信息并帮助我们判断类型之间的关系,但遗憾的是,它并不能像typeof那样在编译期就确定对象类型。

2. 使用 decltype 说明符获取类型

为了用统一方法解决上述问题,C++11标准引入了decltype说明符,使用decltype说明符可以获取对象或者表达式的类型,其语法和typeof类似:

int x1 = 0;
decltype(x1) x2 = 0;
std::cout << typeid(x2).name() << std::endl; // x2 的类型为 int
double x3 = 0;
decltype(x1 + x3) x4 = x1 + x3;
std::cout << typeid(x4).name() << std::endl; // x1 + x3 的类型为 double
decltype({1, 2}) x5; // 编译失败,{1, 2} 不是表达式

以上代码展示了 decltype 的一般用法,代码中分别获取变量 x1 和表达式 x1 + x3 的类型并且声明该类型的变量。但是 decltype 的使用场景还远远不止于此。还记得在第3章中讨论过 auto 不能在非静态成员变量中使用吗?decltype 却是可以的:

struct S1 {
	int x1;
	decltype(x1) x2;
	double x3;
	decltype(x2 + x3) x4;
};

比如,在函数的形参列表中使用:

int x1 = 0;
decltype(x1) sum(decltype(x1) a1, decltype(a1) a2)
{
	return a1 + a2;
}
auto x2 = sum(5, 10);

看到这里,读者应该会质疑 decltype 是否有实际用途,因为到目前为止我们看到的无非是一些画蛇添足的用法,直接声明变量类型或者使用 auto 占位符要简单得多。确实如此,上面的代码并没有展示 decltype 的独特之处,只是描述其基本功能。

为了更好地讨论decltype的优势,需要用到函数返回类型后置(见第5章)的例子:

auto sum(int a1, int a2)->int
{
	return a1 + a2;
}

以上代码以 C++11 为标准,该标准中 auto 作为占位符并不能使编译器对函数返回类型进行推导,必须使用返回类型后置的形式指定返回类型。如果接下来想泛化这个函数,让其支持各种类型运算应该怎么办?由于形参不能声明为 auto,因此我们需要用到函数模板:

template<class T>
T sum(T a1, T a2)
{
	return a1 + a2;
}
auto x1 = sum(5, 10)

代码看上去很好,但是并不能适应所有情况,因为调用者如果传递不同类型的实参,则无法编译通过:

auto x2 = sum(5, 10.5); // 编译失败,无法确定 T 的类型

既然如此,我们只能编写一个更加灵活的函数模板:

template<class R, class T1, class T2>
R sum(T1 a1, T2 a2)
{
	return a1 + a2;
}
auto x3 = sum<double>(5, 10.5);

不错,这样好像可以满足我们泛化sum函数的要求了。但美中不足的是我们必须为函数模板指定返回值类型。为了让编译期完成所有的类型推导工作,我们决定继续优化函数模板:

template<class T1, class T2>
auto sum(T1 a1, T2 a2)->decltype(a1 + a2)
{
	return a1 + a2;
}
auto x4 = sum(5, 10.5);

decltype 终于登场了,可以看到它完美地解决了之前需要指定返回类型的问题。解释一下这段代码,auto 是返回类型的占位符,参数类型分别是 T1T2,我们利用 decltype 说明符能推断表达式的类型特性,在函数尾部对 auto 的类型进行说明,如此一来,在实例化 sum 函数的时候,编译器就能够知道 sum 的返回类型了。

注意,形参也是有作用域的,它只能按顺序访问,即放在后面的可以访问放在前面的,这也是为什么decltype(a1 + a2)要放在后面,而不是放在前面

上述用法只推荐在C++11标准的编译环境中使用,因为C++14标准已经支持对auto声明的返回类型进行推导了,所以以上代码可以简化为:

template<class T1, class T2>
auto sum(T1 a1, T2 a2)
{
	return a1 + a2;
}
auto x5 = sum(5, 10.5);

讲到这里,读者肯定有疑问了,在C++14中decltype的作用又被auto代替了。是否从C++14标准以后decltype就没有用武之地了呢?

并不是这样的,auto作为返回类型的占位符还存在一些问题,请看下面的例子:

template<class T>
auto return_ref(T& t)
{
	return t;
}
int x1 = 0;
static_assert(std::is_reference_v<decltype(return_ref(x1))>) // 编译错误,返回值不为引用类型

在上面的代码中,我们期望 return_ref 返回的是一个 T 的引用类型,但是如果编译此段代码,则必然会编译失败,因为 auto 被推导为值类型,这就是第3章所讲的 auto 推导规则2。如果想正确地返回引用类型,则需要用到 decltype 说明符,例如:

template<class T>
auto return_ref(T& t)->decltype(t)
{
	return t;
}
int x1 = 0;
static_assert(std::is_reference_v<decltype(return_ref(x1))>); // 编译成功

以上两段代码几乎相同,只是在 return_ref 函数的尾部用 decltype(t) 声明了返回类型,但是代码却可以顺利地通过编译。为了弄清楚编译成功的原因,我们需要讨论 decltype 的推导规则。

3. 推导规则

decltype(e)(其中 e 的类型为 T)的推导规则有 5 条:

  1. 如果 e 是一个未加括号的标识符表达式(结构化绑定除外)或者未加括号的类成员访问,则 decltype(e) 推断出的类型是 e 的类型 T。如果并不存在这样的类型,或者 e 是一组重载函数,则无法进行推导。
  2. 如果 e 是一个函数调用或者仿函数调用,那么 decltype(e) 推断出的类型是其返回值的类型。
  3. 如果 e 是一个类型为 T 的左值,则 decltype(e)T&
  4. 如果 e 是一个类型为 T 的将亡值,则 decltype(e)T&&
  5. 除去以上情况,则 decltype(e)T

根据这5条规则,我们来看一看C++标准文档给的几个例子:

const int&& foo();
int i;
struct A {
	double x;
};
const A* a = new A();
decltype(foo()); // decltype(foo()) 推导类型为 const int&&
decltype(i);     // decltype(i) 推导类型为int
decltype(a->x);  // decltype(a->x) 推导类型为 double
decltype((a->x)); // decltype((a->x)) 推导类型为 const double&

在上面的代码中,decltype(foo()) 满足规则2和规则4,foo 函数的返回类型是 const int&&,所以推导结果也为 const int&&decltype(i)decltype(a->x) 很简单,满足规则1,所以其类型为 intdouble;最后一句代码,由于 decltype((a->x)) 推导的是一个带括号的表达式 (a->x),因此规则1不再适用,但很明显 a->x 是一个左值,又因为 a 带有 const 限定符,所以其类型被推导为 const double&

如果读者已经理解了decltype的推导规则,不妨尝试推导下列代码中decltype的推导结果:

int i;
int *j;
int n[10];
const int&& foo();
decltype(static_cast<short>(i)); // decltype(static_cast<short>(i)) 推导类型为 short
decltype(j); // decltype(j) 推导类型为 int*
decltype(n); // decltype(n) 推导类型为 int[10]
decltype(foo); // decltype(foo) 推导类型为 int const &&(void)
struct A {
	int operator() () { return 0; }
};
A a;
decltype(a()); // decltype(a()) 推导类型为 int

最后让我们看几个更为复杂的例子:

int i;
int *j;
int n[10];
decltype(i=0); // decltype(i=0) 推导类型为 int&
decltype(0,i); // decltype(0,i) 推导类型为 int&
decltype(i,0); // decltype(i,0) 推导类型为 int
decltype(n[5]); // decltype(n[5]) 推导类型为 int&
decltype(*j); // decltype(*j) 推导类型为 int&
decltype(static_cast<int&&>(i)); // decltype(static_cast<int&&>(i)) 推导类型为 int&&
decltype(i++); // decltype(i++) 推导类型为 int
decltype(++i); // decltype(++i) 推导类型为 int&
decltype("hello world"); // decltype("hello world") 推导类型为 const char(&)[12]

让我们来看一看上面代码中的例子都是怎么推导出来的:

  1. 可以确认以上例子中的表达式都不是标识符表达式,这样就排除了规则1。
  2. i=00, i 表达式都返回左值 i,所以推导类型为 int&
  3. i, 0 表达式返回 0,所以推导类型为 int
  4. n[5] 返回的是数组 n 中的第6个元素,也是左值,所以推导类型为 int&
  5. *j 很明显也是一个左值,所以推导类型也为 int&
  6. static_cast<int&&>(i) 被转换为一个将亡值类型,所以其推导类型为 int&&
  7. i++++i 分别返回右值和左值,所以推导类型分别为 intint&
  8. hello world 是一个常量数组的左值,其推导类型为 const char(&)[12]

4. cv 限定符的推导

通常情况下,decltype(e) 所推导的类型会同步 ecv 限定符,比如:

const int i = 0;
decltype(i); // decltype(i) 推导类型为 const int

但是还有其他情况,当e是未加括号的成员变量时,父对象表达式的cv限定符会被忽略,不能同步到推导结果:

struct A {
    double x;
};
const A* a = new A();
decltype(a->x) i; // decltype(a->x) 推导类型为 double, const 属性被忽略
i = 6.0; // i 可以被正常赋值

在上面的代码中,a 被声明为 const 类型,如果想在代码中改变 ax 的值,则肯定会编译失败。但是 decltype(a->x) 却得到了一个没有 const 属性的 double 类型。当然,如果我们给 a->x 加上括号,则情况会有所不同:

struct A {
    double x;
};
const A* a = new A();
decltype((a->x)) i; // decltype((a->x)) 推导类型为 const double&
i = 6.0; // 编译失败,i 不可以被赋值

总的来说,e是加括号的数据成员时,父对象表达式的cv限定符会同步到推断结果

5. decltype(auto)

C++14 标准中出现了 decltypeauto 两个关键字的结合体:decltype(auto)。它的作用简单来说,就是告诉编译器用 decltype 的推导表达式规则来推导 auto。另外需要注意的是,decltype(auto) 必须单独声明,也就是它不能结合指针、引用以及 cv 限定符。看完下面的例子,读者就会有所体会:

int i;
int&& f();
auto x1a = i;              // x1a 推导类型为 int
decltype(auto) x1d = i;    // x1d 推导类型为 int
auto x2a = (i);            // x2a 推导类型为 int
decltype(auto) x2d = (i);  // x2d 推导类型为 int&
auto x3a = f();            // x3a 推导类型为 int
decltype(auto) x3d = f();  // x3d 推导类型为 int&&
auto x4a = { 1, 2 };       // x4a 推导类型为 std::initializer_list<int>
decltype(auto) x4d = { 1, 2 }; // 编译失败, {1, 2} 不是表达式
auto *x5a = &i;           // x5a 推导类型为 int*
decltype(auto)*x5d = &i;  // 编译失败, decltype(auto) 必须单独声明

观察上面的代码可以发现,autodecltype(auto) 的用法几乎相同,只是在推导规则上遵循 decltype 而已。比如(i)auto 规则的作用下,x2a 的类型被推导为 int,而 x2d 的类型被推导为 int&。另外,由于 decltype(auto) 必须单独声明,因此 x5d 无法通过编译。

这里特别解释一下 int&& f();auto x3a = f(); 这里使用了引用折叠(Reference Collapsing):

auto用于推导函数的返回值类型时,它会忽略引用限定符(&&&),并将其结果类型视为一个普通的对象类型。这意味着无论函数f返回的是左值引用还是右值引用,auto都会推导它为对象类型。

在上面代码中,f() 返回一个右值引用 int&&,但由于auto会忽略引用限定符,所以auto x3a 的类型被推导为 int 而不是 int&&

而对于 decltype(auto) x3d = f();这一行使用了 decltype(auto),它不是一个类型推导,而是根据初始化表达式的类型来推导变量的类型。在这里,x3d 的类型将根据表达式 f() 的类型来推导。因为 f() 返回一个右值引用,所以 x3d 的类型也会成为右值引用,即 int&&。这就是为什么 x3d 的类型被推导为 int&&

总结:auto 在类型推导时会忽略引用,但decltype(auto) 会保留引用类型。因此,x3a 的类型是 int,而 x3d 的类型是 int&&,这符合函数 f() 返回值的类型。

接下来让我们看一看 decltype(auto) 是如何发挥作用的。还记得 decltype 不可被 auto 代替的例子吗?return_ref 想返回一个引用类型,但是如果直接使用 auto,则一定会返回一个值类型。这让我们不得不采用返回类型后置的方式声明返回类型。

现在有了decltype(auto)组合,我们可以进一步简化代码,消除返回类型后置的语法,例如:

template<class T>
decltype(auto) return_ref(T& t)
{
    return t;
}
int x1 = 0;
static_assert(std::is_reference_v<decltype(return_ref(x1))>); // 编译成功

6. decltype(auto) 作为非类型模板形参占位符

auto一样,在C++17标准中decltype(auto)也能作为非类型模板形参的占位符,其推导规则和上面介绍的保持一致,例如:

#include <iostream>
template<decltype(auto) N>
void f()
{
	std::cout << N << std::endl;
}
static const int x = 11;
static int y = 7;
int main()
{
	f<x>();   // N 为 const int 类型
	f<(x)>(); // N 为 const int& 类型
	f<y>();   // 编译错误
	f<(y)>(); // N 为 int& 类型
}

在上面的代码中,x 的类型为 const int,所以 f<x>() 推导出 Nconst int 类型,这里和 auto 作为占位符的结果是一样的;f<(x)>() 则不同,推导出的 Nconst int& 类型,符合 decltype(auto) 的推导规则。另外,f<y>() 会导致编译出错,因为 y 不是一个常量,所以编译器无法对函数模板进行实例化。而 f<(y)>() 则没有这种问题,因为 (y) 被推断为引用类型,恰好对于静态对象而言内存地址是固定的,所以可以顺利地通过编译,最终 N 被推导为 int& 类型。

总结

decltypeauto 的使用方式有一些相似之处,但是推导规则却有所不同,理解起来有一定难度。不过幸运的是,大部分情况下推导结果能够符合我们的预期。另外从上面的示例代码来看,在通常的编程过程中并不会存在太多使用 decltype 的情况。实际上, decltype 说明符对于库作者更加实用。因为它很大程度上加强了C++的泛型能力,比如利用 decltypeSFINAE 特性让编译器自动选择正确的函数模板进行调用等,当然这些是比较高级的话题了,有兴趣的读者可以提前翻阅第40章的内容。

五、函数返回类型后置(C++11)

1. 使用函数返回类型后置声明函数

前面已经出现了函数返回类型后置的例子,接下来我们将详细讨论C++11标准中的新语法特性:

auto foo()->int
{
	return 42;
}

以上代码中的函数声明等同于 int foo(),只不过采用了函数返回类型后置的方法,其中 auto 是一个占位符,函数名后 -> 紧跟的 int 才是真正的返回类型。当然,在这个例子中传统的函数声明方式更加简洁。而在返回类型比较复杂的时候,比如返回一个函数指针类型,返回类型后置可能会是一个不错的选择,例如:

int bar_impl(int x)
{
	return x;
}

typedef int(*bar)(int);
bar foo1()
{
	return bar_impl;
}

auto foo2()->int(*)(int)
{
	return bar_impl;
}

int main() {
	auto func = foo2();
	func(58);
}

在上面的代码中,函数 foo2 的返回类型不再是简单的 int 而是函数指针类型。使用传统函数声明语法的 foo1 无法将函数指针类型作为返回类型直接使用,所以需要使用 typedef 给函数指针类型创建别名 bar,再使用别名作为函数 foo1 的返回类型。而使用函数返回类型后置语法的 foo2 则没有这个问题。同样,auto 作为返回类型占位符,在 -> 后声明返回的函数指针类型 int(*)(int) 即可。

2. 推导函数模板返回类型

C++11标准中函数返回类型后置的作用之一是推导函数模板的返回类型,当然前提是需要用到decltype说明符,例如:

template<class T1, class T2>
auto sum1(T1 t1, T2 t2)->decltype(t1 + t2)
{
	return t1 + t2;
}
int main() {
	auto x1 = sum1(4, 2);
}

在上面的代码中,函数模板 sum1 有两个模板形参 T1T2,它们分别是函数形参 t1t2 的类型。为了让 sum1 函数的返回类型由实参自动推导,这里需要使用函数返回类型后置来指定 decltype 说明符推导类型作为函数的返回类型。

请注意,decltype(t1 + t2) 不能写在函数声明前,编译器在解析返回类型的时候还没解析到参数部分,所以它对 t1t2 一无所知,自然会编译失败:

decltype(t1 + t2) auto sum1(T1 t1, T2 t2) {} // 编译失败,无法识别 t1 和 t2

实际上,在C++11标准中只用decltype关键字也能写出自动推导返回类型的函数模板,但是函数可读性却差了很多,以下是最容易理解的写法:

template<class T1, class T2>
decltype(T1() + T2()) sum2(T1 t1, T2 t2)
{
	return t1 + t2;
}

int main() {
	sum2(4, 2);
}

以上代码使用 decltype(T1()+T2()) 让编译器为我们推导函数的返回类型,其中 T1()+T2() 表达式告诉编译器应该推导 T1 类型对象与 T2 类型对象之和的对象类型。但是这种写法并不通用,它存在一个潜在问
题,由于 T1() + T2() 表达式使用了 T1T2 类型的默认构造函数,因此编译器要求 T1T2默认构造函数必须存在,否则会编译失败,比如:

class IntWrap {
public:
    IntWrap(int n) : n_(n) {}
    IntWrap operator+ (const IntWrap& other)
    {
        return IntWrap(n_ + other.n_);
    }
private:
    int n_;
};

int main() {
    sum2(IntWrap(1), IntWrap(2)); // 编译失败,IntWrap 没有默认构造函数
}

虽然编译器在推导表达式类型的时候并没有真正计算表达式,但是会检查表达式是否正确,所以在推导 IntWrap() + IntWrap() 时会报错。为了解决这个问题,需要既可以在表达式中让 T1T2 两个对象求和,又不用使用其构造函数方法,于是就有了以下两个函数模板:

template<class T1, class T2>
decltype(*static_cast<T1 *>(nullptr) + *static_cast<T2 *>(nullptr))
sum3(T1 t1, T2 t2)
{
	return t1 + t2;
}

template<class T>
T&& declval();

template<class T1, class T2>
decltype(declval<T1>() + declval<T2>()) sum4(T1 t1, T2 t2)
{
	return t1 + t2;
}

int main() {
	sum3(IntWrap(1), IntWrap(2));
	sum4(IntWrap(1), IntWrap(2));
}

在上面的代码中,函数模板 sum3 使用指针类型转换和解引用求和的方法推导返回值,其中 *static_cast<T1*>(nullptr) + *static_cast<T2*>(nullptr) 分别将 nullptr 转换为 T1T2 的指针类型,然后解引用求和,最后利用 decltype 推导出求和后的对象类型。由于编译器不会真的计算求值,因此这里求和操作不会有问题。

函数模板 sum4 利用了另外一个技巧,与 sum3 本质上相似。在标准库中提供了一个 std::declval 函数模板声明(没有具体实现),它将类型 T 转换成引用类型,这样在使用 decltype 推导表达式类型时不必经过构造函数检查。由于标准库中 std::declval 的实现比较复杂,因此我在这里实现了一个简化版本。declval<T1>() + declval<T2>() 表达式分别通过 declvalT1T2 转换为引用类型并且求和,最后通过 decltype 推导返回类型。

可以看出,虽然这两种方法都能达到函数返回类型后置的效果,但是它们在实现上更加复杂,同时要理解它们也必须有一定的模板元编程的知识。为了让代码更容易被其他人阅读和理解,还是建议使用函数返回类型后置的方法来推导返回类型。

总结

本章介绍了C++11标准中的函数返回类型后置语法,通过这种方法可以让返回复杂类型的函数声明更加清晰易读。在无法使用C++14以及更新标准的情况下,通过返回类型后置语法来推导函数模板的返回类型无疑是最便捷的方法。

六、右值引用(C++11 C++17 C++20)

1. 左值和右值

左值和右值的概念早在C++98的时候就已经出现了,从最简单的字面理解,无非是表达式等号左边的值为左值,而表达式右边的值为右值,比如:

int x = 1;
int y = 3;
int z = x + y;

以上面的代码为例,x 是左值,1 是右值;y 是左值,3 是右值;z 是左值,x + y 的结果是右值。用表达式等号左右的标准区分左值和右值虽然在一些场景下确实能得到正确结果,但是还是过于简单,有些情况下是无法准确区分左值和右值的,比如:

int a = 1;
int b = a;

按照表达式等号左右的区分方式,在第一行代码中a 是左值,1 是右值;在第二行代码中 b 是左值,而 a 是右值。这里出现了矛盾,在第一行代码中我们判断 a 是一个左值,它却在第二行变成了右值,很明显这不是我们想要的结果,要准确地区分左值和右值还是应该理解其内在含义。

在C++中所谓的左值一般是指一个指向特定内存的具有名称的值(具名对象)它有一个相对稳定的内存地址,并且有一段较长的生命周期。而右值则是不指向稳定内存地址的匿名值(不具名对象),它的生命周期很短,通常是暂时性的。基于这一特征,我们可以用取地址符&来判断左值和右值,能取到内存地址的值为左值,否则为右值。还是以上面的代码为例,因为&a&b都是符合语法规则的,所以ab都是左值,而&1在GCC中会给出“lvalue required as unary '&' operand”错误信息以提示程序员&运算符需要的是一个左值。

上面的代码在左右值的判断上比较简单,但是并非所有的情况都是如此,下面这些情况左值和右值的判断可能是违反直觉的,例如:

int x = 1;
int get_val()
{
	return x;
}
void set_val(int val)
{
	x = val;
}
int main()
{
	x++;
	++x;
	int y = get_val();
	set_val(6);
}

在上面的代码中,x++++x虽然都是自增操作,但是却分为不同的左右值。其中 x++ 是右值,因为在后置++操作中编译器首先会生成一份x值的临时副本,然后才对x递增,最后返回临时副本内容。而++x则不同,它是直接对x递增后马上返回其自身,所以 ++x 是一个左值。如果对它们实施取地址操作,就会发现++x的取地址操作可以编译成功,而对x++取地址则会报错。但是从直觉上来说,&x++看起来更像是会编译成功的一方:

int *p = &x++; // 编译失败
int *q = &++x; // 编译成功

接着来看上一份代码中的get_val函数,该函数返回了一个全局变量x,虽然很明显变量x是一个左值,但是它经过函数返回以后变成了一个右值。原因和x++类似,在函数返回的时候编译器并不会返回x本身,而是返回x的临时复制,所以int * p = &get_val();也会编译失败。

int *p = &get_val(); // 编译失败,不能取到地址

对于set_val函数,该函数接受一个参数并且将参数的值赋值到x中。在main函数中set_val(6);实参6是一个右值,但是进入函数之后形参val却变成了一个左值,我们可以对val使用取地址符,并且不会引起任何问题:

void set_val(int val)
{
    int *p = &val;
	x = val;
}

最后需要强调的是,通常字面量都是一个右值,但除了字符串字面量以外

int x = 1;
set_val(6);
auto p = &"hello world";

这一点非常容易被忽略,因为经验告诉我们上面的代码中前两行的 16 都是右值,因为不存在 &1&6 的语法,这会让我们想当然地认为 "hello world" 也是一个右值,毕竟 &"hello world" 的语法也很少看到。但是这段代码是可以编译成功的,其实原因仔细想来也很简单,编译器会将字符串字面量存储到程序的数据段中,程序加载的时候也会为其开辟内存空间,所以我们可以使用取地址符 & 来获取字符串字面量的内存地址。

2. 左值引用

左值引用是编程过程中的常用特性之一,它的出现让C++编程在一定程度上脱离了危险的指针。当我们需要将一个对象作为参数传递给子函数的时候,往往会使用左值引用,因为这样可以免去创建临时对象的
操作。非常量左值的引用对象很单纯,它们必须是一个左值。

例如常见的一个例子是下面代码中使用左值引用来交换两个变量的值:

void swap(int &a, int &b) {
    int temp = a;
    a = b;
    b = temp;
}

对于这一点,常量左值引用的特性显得更加有趣,它除了能引用左值,还能够引用右值,比如:

int a = 2;
int &x1 = a;       
int &x1 = 7;       // 编译错误
const int &x = 11; // 编译成功
const int &x = a;   

在上面的代码中,int &x1 = 7;代码会编译报错,因为 int& 无法绑定一个 int 类型的右值,但是const int &x = 11却可以编译成功。请注意,虽然在结果上 const int &x = 11const int x = 11 是一样的,但是从语法上来说,前者是被引用了,所以语句结束后 11 的生命周期被延长,而后者当语句结束后右值 11 应该被销毁。

虽然常量左值引用可以引用右值的这个特性在赋值表达式中看不出什么实用价值,但是在函数形参列表中却有着巨大的作用。一个典型的例子就是复制构造函数复制赋值运算符函数,通常情况下我们实现的这两个函数的形参都是一个常量左值引用,例如:

class X {
public:
	X() {}
	X(const X&) {}
	X& operator = (const X&) { return *this; }
};

X make_x()
{
	return X();
}

int main() 
{
	X x1;
	X x2(x1);
	X x3(make_x());
	x3 = make_x();
}

以上代码可以通过编译,但是如果这里将类 X 的复制构造函数和复制赋值函数形参类型的常量修饰 const 删除,则 X x3(make_x());x3 = make_x(); 这两句代码会编译报错,因为非常量左值引用无法绑定到 make_x() 产生的右值。常量左值引用可以绑定右值是一条非常棒的特性,但是它也存在一个很大的缺点——常量性。一旦使用了常量左值引用,就表示我们无法在函数内修改该对象的内容(强制类型转换除外)。所以需要另外一个特性来帮助我们完成这项工作,它就是右值引用。

注意,上面代码中去掉 const 常量修饰符以后, X x3(make_x()); 以及 make_x() 函数中在 C++ 17 以前编译会报错,但是从 C++ 17 开始对拷贝构造函数做了优化,所以如果是使用C++ 17及以上版本不会报错。但是 x3 = make_x();这句不管是现在还是以前的版本都会报错。

3. 右值引用

顾名思义,右值引用是一种引用右值且只能引用右值的方法。在语法方面右值引用可以对比左值引用,在左值引用声明中,需要在类型后添加&,而右值引用则是在类型后添加&&,例如:

int i = 0;
int &j = i;   // 左值引用
int &&k = 11; // 右值引用

在上面的代码中,k 是一个右值引用,如果试图用 k 引用变量 i,则会引起编译错误。

int i = 0;
int &&k = i; // 编译报错,右值引用无法引用左值 i

右值引用的特点之一是可以延长右值的生命周期,这个对于字面量 11 可能看不出效果,那么请看下面的例子:

#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; }
	void show() { std::cout << "show X" << std::endl; }
};

X make_x()
{
	X x1;
	return x1;
}

int main()
{
	X &&x2 = make_x();
	// X x2 = make_x()
	x2.show();
}

在理解这段代码之前,让我们想一下如果将 X &&x2 = make_x() 这句代码替换为 X x2 = make_x() 会发生几次构造。在没有进行任何优化的情况下应该是 3 次构造,首先 make_x 函数中 x1 会默认构造一次,然后 return x1 会使用复制构造产生临时对象,接着 X x2 = make_x() 会使用复制构造将临时对象复制到 x2,最后临时对象被销毁。所以使用 X x2 = make_x() 应该期望的输出是下面这样:

X ctor
X copy ctor
X dtor
X copy ctor
X dtor
show X
X dtor

在使用了右值引用以后,以上流程发生了微妙的变化,让我们编译运行这段代码。输出结果:

X ctor
X copy ctor
X dtor
show X
X dtor

请注意,用GCC编译以上代码需要加上命令行参数 -fno-elide-constructors 用于关闭函数返回值优化(RVO)。因为GCC的 RVO 优化会减少复制构造函数的调用,不利于语言特性实验。

从运行结果可以看出上面的代码只发生了两次构造。第一次是 make_x 函数中 x1 的默认构造,第二次是 return x1 引发的复制构造。不同的是,由于 x2 是一个右值引用,引用的对象是函数 make_x 返回的临时对象,因此该临时对象的生命周期得到延长,所以我们可以在 X &&x2 = make_x() 语句结束后继续调用 show 函数而不会发生任何问题。对性能敏感的读者应该注意到了,延长临时对象生命周期并不是这里右值引用的最终目标,其真实目标应该是减少对象复制,提升程序性能

PS:如果使用 CLion 可以在 CMakeLists.txt 中添加如下代码禁用编译器优化:

# 禁用函数返回值优化(RVO)
set(CMAKE_CXX_FLAGS "-fno-elide-constructors")

# 如何添加每个带main函数的cpp文件为可执行文件
file(GLOB files "${CMAKE_CURRENT_SOURCE_DIR}/*.cpp")

foreach (file ${files})
    get_filename_component(name ${file} NAME_WE) # 使用NAME_WE以去掉文件扩展名
    add_executable(${name} ${file})
endforeach ()

但是我实际使用的时候,发现添加了也不起作用,仍然会被优化,也就是说 X x2 = make_x()X &&x2 = make_x() 的执行结果是一样的,不知道为什么。

如果不加上面的禁用参数,输出如下:

X ctor
show X
X dtor

可见现代的C++编译器都很聪明,都会直接优化去掉拷贝构造函数的调用。

4. 右值的性能优化空间

通过前面的介绍我们知道了很多情况下右值都存储在临时对象中,当右值被使用之后程序会马上销毁对象并释放内存。这个过程可能会引发一个性能问题,例如:

#include <iostream>
class BigMemoryPool {
public:
	static const int PoolSize = 4096;
	BigMemoryPool() : pool_(new char[PoolSize]) {}
	~BigMemoryPool()
	{
		if (pool_ != nullptr) {
			delete[] pool_;
		}
	}

	BigMemoryPool(const BigMemoryPool& other) : pool_(new char[PoolSize])
	{
		std::cout << "copy big memory pool." << std::endl;
		memcpy(pool_, other.pool_, PoolSize);
	}

private:
	char *pool_;
};

BigMemoryPool get_pool(const BigMemoryPool& pool)
{
	return pool;
}

BigMemoryPool make_pool()
{
	BigMemoryPool pool;
	return get_pool(pool);
}

int main()
{
	BigMemoryPool my_pool = make_pool();
}

以上代码同样需要加上编译参数-fno-elide-constructors,编译运行程序会在屏幕上输出字符串:

copy big memory pool.
copy big memory pool.
copy big memory pool.

可以看到 BigMemoryPool my_pool = make_pool(); 调用了 3 次复制构造函数。

  1. get_pool 函数中返回的 BigMemoryPool 临时对象调用复制构造函数复制了 pool 对象。
  2. make_pool 函数中返回的 BigMemoryPool 临时对象调用复制构造函数复制了 get_pool 返回的临时对象。
  3. main 函数中 my_pool 调用其复制构造函数复制 make_pool 返回的临时对象。

该代码从正确性上看毫无问题,但是从运行性能的角度上看却还有巨大的优化空间。在这里每发生一次复制构造都会复制整整4KB的数据,如果数据量更大一些,比如4MB或者400MB,那么将对程序性能造成很大影响。

5. 移动语义

仔细分析上面代码中 3 次复制构造函数的调用,不难发现第二次和第三次的复制构造是影响性能的主要原因。在这个过程中都有临时对象参与进来,而临时对象本身只是做数据的复制。如果有办法能将临时对象的内存直接转移到 my_pool 对象中,不就能消除内存复制对性能的消耗吗?好消息是在C++11标准中引入了移动语义,它可以帮助我们将临时对象的内存移动到 my_pool 对象中,以避免内存数据的复制。让我们简单修改一下 BigMemoryPool 类代码:

class BigMemoryPool {
public:
	static const int PoolSize = 4096;
	BigMemoryPool() : pool_(new char[PoolSize]) {}
	~BigMemoryPool()
	{
		if (pool_ != nullptr) {
			delete[] pool_;
		}
	}

	BigMemoryPool(BigMemoryPool&& other)
	{
		std::cout << "move big memory pool." << std::endl;
		pool_ = other.pool_;
		other.pool_ = nullptr;
	}

	BigMemoryPool(const BigMemoryPool& other) : pool_(new char[PoolSize])
	{
		std::cout << "copy big memory pool." << std::endl;
		memcpy(pool_, other.pool_, PoolSize);
	}

private:
	char *pool_;
};

在上面的代码中增加了一个类 BigMemoryPool 的构造函数 BigMemoryPool(BigMemoryPool&& other),它的形参是一个右值引用类型,称为移动构造函数。这个名称很容易让人联想到复制构造函数,那么就让我们先了解一下它们的区别。

从构造函数的名称和它们的参数可以很明显地发现其中的区别,对于复制构造函数而言形参是一个左值引用,也就是说函数的实参必须是一个具名的左值,在复制构造函数中往往进行的是深复制,即在不能破坏实参对象的前提下复制目标对象。而移动构造函数恰恰相反,它接受的是一个右值引用,其核心思想是通过转移实参对象的数据以达成构造目标对象的目的,也就是说实参对象是会被修改的

进一步来说类BigMemoryPool的移动构造函数,在函数中没有了复制构造中的内存复制,取而代之的是简单的指针替换操作。它将实参对象的pool_赋值到当前对象,然后置空实参对象以保证实参对象析构的时候不会影响这片内存的生命周期。

编译运行这段代码,其输出结果如下:

copy big memory pool.
move big memory pool.
move big memory pool.

可以看到后面两次的构造函数变成了移动构造函数,因为这两次操作中源对象都是右值(临时对象),对于右值编译器会优先选择使用移动构造函数去构造目标对象。当移动构造函数不存在的时候才会退而求其次地使用复制构造函数。在移动构造函数中使用了指针转移的方式构造目标对象,所以整个程序的运行效率得到大幅提升。

为了验证效率的提升,我们可以将上面的代码重复运行 100 万次,然后输出运行时间。请注意,在做实验前需要将构造函数中的打印输出语句删除,否则会影响实验数据:

#include <chrono>
#include <iostream>
class BigMemoryPool {
public:
	static const int PoolSize = 4096;
	BigMemoryPool() : pool_(new char[PoolSize]) {}
	~BigMemoryPool()
	{
		if (pool_ != nullptr) {
			delete[] pool_;
		}
	}

	BigMemoryPool(BigMemoryPool&& other)
	{
//		std::cout << "move big memory pool." << std::endl;
		pool_ = other.pool_;
		other.pool_ = nullptr;
	}

	BigMemoryPool(const BigMemoryPool& other) : pool_(new char[PoolSize])
	{
//		std::cout << "copy big memory pool." << std::endl;
		memcpy(pool_, other.pool_, PoolSize);
	}

private:
	char *pool_;
};

BigMemoryPool get_pool(const BigMemoryPool& pool)
{
    return pool;
}

BigMemoryPool make_pool()
{
    BigMemoryPool pool;
    return get_pool(pool);
}

int main()
{
	auto start = std::chrono::high_resolution_clock::now();
	for (int i = 0; i < 1000000; i++) {
		BigMemoryPool my_pool = make_pool();
	}
	auto end = std::chrono::high_resolution_clock::now();
	std::chrono::duration<double> diff = end - start;
	std::cout << "Time to call make_pool :" << diff.count() << " s" << std::endl;
}

以上代码在我的机器上运行结果是0.206474s,如果将移动构造函数删除,运行结果是0.47077s,可见使用移动构造函数将性能提升了 1 倍多。

除移动构造函数能实现移动语义以外,移动赋值运算符函数也能完成移动操作,继续以BigMemoryPool为例,在这个类中添加移动赋值运算符函数:

class BigMemoryPool {
public:
	…
	BigMemoryPool& operator=(BigMemoryPool&& other)
	{
		std::cout << "move(operator=) big memory pool." << std::endl;
		if (pool_ != nullptr) {
			delete[] pool_;
		}
		pool_ = other.pool_;
		other.pool_ = nullptr;
		return *this;
}
private:
	char *pool_;
};
...
int main()
{
	BigMemoryPool my_pool;
	my_pool = make_pool();
}

这段代码编译运行的结果是:

copy big memory pool.
move big memory pool.
move(operator=) big memory pool.

可以看到赋值操作my_pool = make_pool()调用了移动赋值运算符函数,这里的规则和构造函数一样,即编译器对于赋值源对象是右值的情况会优先调用移动赋值运算符函数,如果该函数不存在,则调用复制赋值运算符函数。

最后有两点需要说明一下。

  1. 同复制构造函数一样,编译器在一些条件下会生成一份移动构造函数,这些条件包括:没有任何的复制函数,包括复制构造函数和复制赋值函数;没有任何的移动函数,包括移动构造函数和移动赋值函数;也没有析构函数。虽然这些条件严苛得让人有些不太愉快,但是我们也不必对生成的移动构造函数有太多期待,因为编译器生成的移动构造函数和复制构造函数并没有什么区别。
  2. 虽然使用移动语义在性能上有很大收益,但是却也有一些风险,这些风险来自异常。试想一下,在一个移动构造函数中,如果当一个对象的资源移动到另一个对象时发生了异常,也就是说对象的一部分发生了转移而另一部分没有,这就会造成源对象和目标对象都不完整的情况发生,这种情况的后果是无法预测的。所以在编写移动语义的函数时建议确保函数不会抛出异常,与此同时,如果无法保证移动构造函数不会抛出异常,可以使用noexcept说明符限制该函数。这样当函数抛出异常的时候,程序不会再继续执行而是调用std::terminate中止执行以免造成其他不良影响。

6. 值类别

到目前为止一切都非常容易理解,其中一个原因是我在前面的内容中隐藏了一个概念。但是在进一步探讨右值引用之前,我们必须先掌握这个概念——值类别。值类别是C++11标准中新引入的概念,具体来说它是表达式的一种属性,该属性将表达式分为3个类别,它们分别是左值lvalue)、纯右值prvalue)和将亡值xvalue),如图6-1所示。从前面的内容中我们知道早在C++98的时候,已经有了一些关于左值和右值的概念了,只不过当时这些概念对于C++程序编写并不重要。但是由于C++11中右值引用的出现,值类别被赋予了全新的含义。可惜的是,在C++11标准中并没能够清晰地定义它们,比如在C++11的标准文档中,左值的概念只有一句话:“指定一个函数或一个对象”,这样的描述显然是不清晰的。这种糟糕的情况一直延续到C++17标准的推出才得到解决。所以现在是时候让我们重新认识这些概念了。

在这里插入图片描述

表达式首先被分为了泛左值(glvalue) 和右值(rvalue),其中泛左值被进一步划分为左值和将亡值,右值又被划分为将亡值和纯右值。理解这些概念的关键在于泛左值、纯右值和将亡值。

  1. 所谓泛左值是指一个通过评估能够确定对象、位域或函数的标识的表达式。简单来说,它确定了对象或者函数的标识(具名对象)
  2. 纯右值是指一个通过评估能够用于初始化对象和位域,或者能够计算运算符操作数的值表达式
  3. 将亡值属于泛左值的一种,它表示资源可以被重用的对象和位域,通常这是因为它们接近其生命周期的末尾,另外也可能是经过右值引用的转换产生的。

剩下的两种类别就很容易理解了,其中左值是指非将亡值的泛左值,而右值则包含了纯右值和将亡值。再次强调,值类别都是表达式的属性,所以我们常说的左值和右值实际上指的是表达式,不过为了描述方便我们常常会忽略它。

是不是感觉有点晕。相信我,当我第一次看到这些概念的时候也是这个反应。不过好在我们对传统左值和右值的概念已经了然于心了,现在只需要做道连线题就能弄清楚它们的概念。实际上,这里的左值(lvalue)就是我们上文中描述的C++98的左值,而这里的纯右值(prvalue)则对应上文中描述的C++98的右值。最后我们惊喜地发现,现在只需要弄清楚将亡值(xvalue)到底是如何产生的就可以了。

从本质上说产生将亡值的途径有两种,第一种是使用类型转换将泛左值转换为该类型的右值引用。比如:

static_cast<BigMemoryPool&&>(my_pool)

第二种在C++17标准中引入,我们称它为临时量实质化,指的是纯右值转换到临时对象的过程。每当纯右值出现在一个需要泛左值的地方时,临时量实质化都会发生,也就是说都会创建一个临时对象并且使用纯右值对其进行初始化,这也符合纯右值的概念,而这里的临时对象就是一个将亡值

struct X {
	int a;
};
int main()
{
	int b = X().a;
}

在上面的代码中,X() 是一个纯右值,访问其成员变量 a 却需要一个泛左值,所以这里会发生一次临时量实质化,将 X() 转换为将亡值,最后再访问其成员变量 a。还有一点需要说明,在C++17标准之前临时变量是纯右值,只有转换为右值引用的类型才是将亡值。

在本节之后的内容中,依然会以左值和右值这样的术语为主。但是读者应该清楚,这里的左值是C++17中的左值(lvalue),右值是C++17中的纯右值(prvalue)和将亡值(xvalue)。对于将亡值(xvalue),读者实际上只需要知道它是泛左值和右值交集即可,后面的内容也不会重点强调它,所以不会影响到读者对后续内容的理解。

7. 将左值转换为右值

在前面提到过右值引用只能绑定一个右值,如果尝试绑定左值会导致编译错误:

int i = 0;
int &&k = i; // 编译失败

不过,如果想完成将右值引用绑定到左值这个“壮举”还是有办法的。在C++11标准中可以在不创建临时值的情况下显式地将左值通过static_cast转换为将亡值,通过值类别的内容我们知道将亡值属于右值,所以可以被右值引用绑定。值得注意的是,由于转换的并不是右值,因此它依然有着和转换之前相同的生命周期和内存地址,例如:

int i = 0;
int &&k = static_cast<int&&>(i); // 编译成功

读者在这里应该会有疑问,既然这个转换既不改变生命周期也不改变内存地址,那它有什么存在的意义呢?实际上它的最大作用是让左值使用移动语义,还是以BigMemoryPool为例:

BigMemoryPool my_pool1;
BigMemoryPool my_pool2 = my_pool1;
BigMemoryPool my_pool3 = static_cast<BigMemoryPool &&>(my_pool1);

在这段代码中,my_pool1 是一个 BigMemoryPool 类型的对象,也是一个左值,所以用它去构造 my_pool2 的时候调用的是复制构造函数。为了让编译器调用移动构造函数构造 my_pool3,这里使用了 static_cast<BigMemoryPool &&>(my_pool1)my_pool1 强制转换为右值(也是将亡值,为了叙述思路的连贯性后面不再强调)。由于调用了移动构造函数,my_pool1 失去了自己的内存数据,后面的代码也不能对 my_pool1 进行操作了。

现在问题又来了,这样单纯地将一个左值数据转换到另外一个左值似乎并没有什么意义。在这个例子中的确如此,这样的转换不仅没有意义,而且如果有程序员在移动构造之后的代码中再次使用my_pool1还会引发未定义的行为。正确的使用场景是在一个右值被转换为左值后需要再次转换为右值,最典型的例子是一个右值作为实参传递到函数中。我们在讨论左值和右值的时候曾经提到过,无论一个函数的实参是左值还是右值,其形参都是一个左值,即使这个形参看上去是一个右值引用,例如:

BigMemoryPool get_pool(const BigMemoryPool& pool)
{
    return pool;
}

BigMemoryPool make_pool()
{
    BigMemoryPool pool;
    return get_pool(pool);
}

void move_pool(BigMemoryPool &&pool)
{
	std::cout << "call move_pool" << std::endl;
	BigMemoryPool my_pool(pool);
}

int main()
{
	move_pool(make_pool());
}

编译运行以上代码输出结果如下:

copy big memory pool.
move big memory pool.
call move_pool
copy big memory pool.

在上面的代码中,move_pool 函数的实参是 make_pool 函数返回的临时对象,也是一个右值,move_pool 的形参是一个右值引用,但是在使用形参 pool 构造 my_pool 的时候还是会调用复制构造函数而非移动构造函数。为了让 my_pool 调用移动构造函数进行构造,需要将形参 pool 强制转换为右值:

void move_pool(BigMemoryPool &&pool)
{
	std::cout << "call move_pool" << std::endl;
	BigMemoryPool my_pool(static_cast<BigMemoryPool&&>(pool));
}
copy big memory pool.
move big memory pool.
call move_pool
move big memory pool.

请注意,在这个场景下强制转换为右值就没有任何问题了,因为move_pool函数的实参是make_pool返回的临时对象,当函数调用结束后临时对象就会被销毁,所以转移其内存数据不会存在任何问题。

在C++11的标准库中还提供了一个函数模板 std::move 帮助我们将左值转换为右值,这个函数内部也是用 static_cast 做类型转换。只不过由于它是使用模板实现的函数,因此会根据传参类型自动推导返回类型,省去了指定转换类型的代码。另一方面从移动语义上来说,使用 std::move 函数的描述更加准确。所以建议读者使用 std::move 将左值转换为右值而非自己使用 static_cast 转换,例如:

void move_pool(BigMemoryPool &&pool)
{
	std::cout << "call move_pool" << std::endl;
	BigMemoryPool my_pool(std::move(pool));
}

8. 万能引用和引用折叠

第 2 节提到过常量左值引用既可以引用左值又可以引用右值,是一个几乎万能的引用,但可惜的是由于其常量性,导致它的使用范围受到一些限制。其实在C++11中确实存在着一个被称为“万能”的引用,它看似是一个右值引用,但其实有着很大区别,请看下面的代码:

void foo(int &&i) {}     // i 为右值引用

template<class T>
void bar(T &&t) {}       // t 为万能引用

int get_val() { return 5; }
int &&x = get_val();     // x 为右值引用
auto &&y = get_val();    // y 为万能引用

在上面的代码中,函数 foo 的形参 i 和变量 x 是右值引用,而函数模板的形参 t 和变量 y 则是万能引用。我们知道右值引用只能绑定一个右值,但是万能引用既可以绑定左值也可以绑定右值,甚至 constvolatile 的值都可以绑定,例如:

int i = 42;
const int j = 11;
bar(i);
bar(j);
bar(get_val());
auto &&x = i;
auto &&y = j;
auto &&z = get_val();

看到这里读者应该已经发现了其中的奥秘。所谓的万能引用是因为发生了类型推导,在 T&&auto&& 的初始化过程中都会发生类型的推导,如果已经有一个确定的类型,比如 int &&,则是右值引用。在这个推导过程中,初始化的源对象如果是一个左值,则目标对象会推导出左值引用;反之如果源对象是一个右值,则会推导出右值引用,不过无论如何都会是一个引用类型。

万能引用能如此灵活地引用对象,实际上是因为在C++11中添加了一套引用叠加推导的规则——引用折叠。在这套规则中规定了在不同的引用类型互相作用的情况下应该如何推导出最终类型,如表6-1所示。

在这里插入图片描述

上面的表格显示了引用折叠的推导规则,可以看出在整个推导过程中,只要有左值引用参与进来,最后推导的结果就是一个左值引用。只有实际类型是一个非引用类型或者右值引用类型时,最后推导出来的才是一个右值引用。

那么这个规则是如何在万能引用中体现的呢?让我们以函数模板bar为例看一下具体的推导过程。

bar(i);i 是一个左值,所以 T 的推导类型结果是 int&,根据引用折叠规则 T&& int& 的最终推导类型为 int&,于是 bar 函数的形参是一个左值引用。而在 bar(get_val());get_val 返回的是一个右值,所以 T 的推导类型为非引用类型 int,根据引用折叠规则 T&& int,于是最终的推导类型是 int&&bar 函数的形参成为一个右值引用。

值得一提的是,万能引用的形式必须是 T&& 或者 auto&&,也就是说它们必须在初始化的时候被直接推导出来,如果在推导中出现中间过程,则不是一个万能引用,例如:

#include <vector>

template<class T>
void foo(std::vector<T> &&t) {}

int main()
{
	std::vector<int> v{ 1,2,3 };
	foo(v); // 编译错误
}

一个万能引用,而是一个右值引用。因为foo的形参类型是std::vector<T>&&而不是T&&,所以编译器无法将其看作一个万能引用处理。

9. 完美转发

万能引用最典型的用途被称为完美转发。在介绍完美转发之前,我们先看一个常规的转发函数模板:

#include <iostream>
#include <string>

template<class T>
void show_type(T t)
{
	std::cout << typeid(t).name() << std::endl;
}

template<class T>
void normal_forwarding(T t)
{
	show_type(t);
}

int main()
{
	std::string s = "hello world";
	normal_forwarding(s);
}

在上面的代码中,函数normal_forwarding是一个常规的转发函数模板,它可以完成字符串的转发任务。但是它的效率却令人堪忧。因为normal_forwarding按值转发,也就是说std::string在转发过程中会额外发生一次临时对象的复制。其中一个解决办法是将void normal_forwarding(T t)替换为void normal_ forwarding(T &t),这样就能避免临时对象的复制。不过这样会带来另外一个问题,如果传递过来的是一个右值,则该代码无法通过编译,例如:

std::string get_string()
{
	return "hi world";
}
normal_forwarding(get_string()); // 编译失败

当然,我们还可以将void normal_forwarding(T &t)替换为void normal_forwarding (const T &t)来解决这个问题,因为常量左值引用是可以引用右值的。

template<class T>
void normal_forwarding(const T &t)
{
	show_type(t);
}

std::string get_string()
{
    return "hi world";
}

int main()
{
	std::string s = "hello world";
	normal_forwarding(s);
    normal_forwarding(get_string())
}

但是我们也知道,虽然常量左值引用在这个场景下可以“完美”地转发字符串,但是如果在后续的函数中需要修改该字符串,则会编译错误。所以这些方法都不能称得上是完美转发。

万能引用的出现改变了这个尴尬的局面。上文提到过,对于万能引用的形参来说,如果实参是给左值,则形参被推导为左值引用;反之如果实参是一个右值,则形参被推导为右值引用,所以下面的代码无论传递的是左值还是右值都可以被转发,而且不会发生多余的临时复制:

#include <iostream>
#include <string>

template<class T>
void show_type(T t)
{
	std::cout << typeid(t).name() << std::endl;
}

template<class T>
void perfect_forwarding(T &&t)
{
	show_type(static_cast<T&&>(t));
}

std::string get_string()
{
	return "hi world";
}

int main()
{
	std::string s = "hello world";
	perfect_forwarding(s);
	perfect_forwarding(get_string());
}

如果已经理解了引用折叠规则,那么上面的代码就很容易理解了。唯一可能需要注意的是show_type(static_cast<T&&>(t));中的类型转换,之所以这里需要用到类型转换,是因为作为形参的t是左值。为了让转发将左右值的属性也带到目标函数中,这里需要进行类型转换。当实参是一个左值时,T被推导为std::string&,于是static_cast<T&&>被推导为static_cast<std:: string&>,传递到show_type函数时继续保持着左值引用的属性;当实参是一个右值时,T被推导为std::string,于是static_cast <T&&>被推导为static_cast<std::string&&>,所以传递到show_type函数时保持了右值引用的属性。

和移动语义的情况一样,显式使用static_cast类型转换进行转发不是一个便捷的方法。在C++11的标准库中提供了一个std::forward函数模板,在函数内部也是使用static_cast进行类型转换,只不过使用std::forward转发语义会表达得更加清晰,std::forward函数模板的使用方法也很简单:

template<class T>
void perfect_forwarding(T &&t)
{
	show_type(std::forward<T>(t));
}

请注意std::movestd::forward的区别,其中std::move一定会将实参转换为一个右值引用,并且使用std::move不需要指定模板实参,模板实参是由函数调用推导出来的。而std::forward会根据左值和右值的实际情况进行转发,在使用的时候需要指定模板实参。

10. 针对局部变量和右值引用的隐式移动操作

在对旧程序代码升级新编译环境之后,我们可能会发现程序运行的效率提高了,这里的原因一定少不了新标准的编译器在某些情况下将隐式复制修改为隐式移动。虽然这些是编译器“偷偷”完成的,但是我们不能因为运行效率提高就忽略其中的缘由,所以接下来我们要弄清楚这些隐式移动是怎么发生的:

#include <iostream>
struct X {
	X() = default;
	X(const X&) = default;
	X(X&&) {
		std::cout << "move ctor";
	}
};
X f(X x) {
	return x;
}
int main() {
	X r = f(X{});
}

这段代码很容易理解,函数f直接返回调用者传进来的实参 x,在 main 函数中使用 r 接收 f 函数的返回值。关键问题是,这个赋值操作究竟是如何进行的。从代码上看,将 r 赋值为 x 应该是一个复制,对于旧时的标准这是没错的。但是对于支持移动语义的新标准,这个地方会隐式地采用移动构造函数来完成数据的交换。编译运行以上代码最终会显示 “move ctor” 字符串。

除此之外,对于局部变量也有相似的规则,只不过大多数时候编译器会采用更加高效的返回值优化代替移动操作,这里我们稍微修改一点f函数:

X f() {
	X x;
	return x;
}
int main() {
	X r = f();
}

请注意,编译以上代码的时候需要使用-fno-elide-constructors选项用于关闭返回值优化。然后运行编译好的程序,会发现X r = f();同样调用的是移动构造函数。

在C++20标准中,隐式移动操作针对右值引用和throw的情况进行了扩展,例如:

#include <iostream>
#include <string>

struct X {
	X() = default;
	X(const X&) = default;
	X(X&&) {
		std::cout << "move";
	}
};

X f(X &&x) {
	return x;
}

int main() {
	X r = f(X{});
}

以上代码使用C++20之前的标准编译是不会调用任何移动构造函数的。原因前面也解释过,因为函数f的形参 x 是一个左值,对于左值要调用复制构造函数。要实现移动语义,需要将 return x; 修改为 return std::move(x);。显然这里是有优化空间的,C++20标准规定在这种情况下可以隐式采用移动语义完成赋值。具体规则如下。

可隐式移动的对象必须是一个非易失或一个右值引用的非易失自动存储对象,在以下情况下可以使用移动代替复制。

  1. returnco_return 语句中的返回对象是函数或 lambda 表达式中的对象或形参。
  2. throw 语句中抛出的对象是函数或 try 代码块中的对象。

实际上throw调用移动构造的情况和return差不多,我们只需要将上面的代码稍作修改即可:

void f() {
	X x;
	throw x;
}
int main() {
	try {
		f();
	}
	catch () {
	}
}

可以看到函数f不再有返回值,它通过throw抛出xmain函数用try-catch捕获f抛出的x。这个捕获调用的就是移动构造函数。

七、lambda表达式(C++11~C++20)

1. lambda表达式语法

lambda表达式是现代编程语言的一个基础特性,比如LISP、Python、C#等具备该特性。但是遗憾的是,直到C++11标准之前,C++都没有在语言特性层面上支持lambda表达式。程序员曾尝试使用库来实现lambda表达式的功能,比如Boost.BindBoost.Lambda,但是它们有着共同的缺点,实现代码非常复杂,使用的时候也需要十分小心,一旦有错误发生,就可能会出现一堆错误和警告信息,总之其编程体验并不好。

另外,虽然C++一直以来都没有支持lambda表达式,但是它对lambda表达式的需求却非常高。最明显的就是STL,在STL中有大量需要传入谓词的算法函数,比如std::find_ifstd::replace_if等。过去有两种方法实现谓词函数:编写纯函数或者仿函数。但是它们的定义都无法直接应用到函数调用的实参中,面对复杂工程的代码,我们可能需要四处切换源文件来搜索这些函数或者仿函数。

为了解决上面这些问题,C++11标准为我们提供了lambda表达式的支持,而且语法非常简单明了。这种简单可能会让我们觉得它与传统的C++语法有点格格不入。不过在习惯新的语法之后,就会发觉lambda表达式的方便之处。

lambda表达式的语法非常简单,具体定义如下:

[ captures ] ( params ) specifiers exception -> ret { body }

先不用急于解读这个定义,我们可以结合lambda表达式的例子来读懂它的语法:

#include <iostream>
int main()
{
	int x = 5;
	auto foo = [x](int y)->int { return x * y; };
	std::cout << foo(8) << std::endl;
}

在这个例子中,[x](int y)->int { return x * y; } 是一个标准的lambda表达式,对应到lambda表达式的语法。

  • [ captures ] —— 捕获列表,它可以捕获当前函数作用域的零个或多个变量,变量之间用逗号分隔。在对应的例子中,[x] 是一个捕获列表,不过它只捕获了当前函数作用域的一个变量 x,在捕获了变量之后,我们可以在 lambda 表达式函数体内使用这个变量,比如 return x * y。另外,捕获列表的捕获方式有两种:按值捕获引用捕获,下文会详细介绍。
  • ( params ) —— 可选参数列表,语法和普通函数的参数列表一样,在不需要参数的时候可以忽略参数列表。对应例子中的 (int y)
  • specifiers —— 可选限定符,C++11中可以用 mutable,它允许我们在lambda表达式函数体内改变按值捕获的变量,或者调用非const的成员函数。上面的例子中没有使用说明符。
  • exception —— 可选异常说明符,我们可以使用 noexcept 来指明lambda是否会抛出异常。对应的例子中没有使用异常说明符。
  • ret —— 可选返回值类型,不同于普通函数,lambda表达式使用返回类型后置的语法来表示返回类型,如果没有返回值(void类型),可以忽略包括->在内的整个部分。另外,我们也可以在有返回值的情况下不指定返回类型,这时编译器会为我们推导出一个返回类型。对应到上面的例子是 ->int
  • { body } —— lambda表达式的函数体,这个部分和普通函数的函数体一样。对应例子中的 { return x * y; }

细心的读者肯定发现了一个有趣的事实,由于参数列表,限定符以及返回值都是可选的,于是我们可以写出的最简单的lambda表达式是[]{}。虽然看上去非常奇怪,但它确实是一个合法的lambda表达式。需要特别强调的是,上面的语法定义只属于C++11标准,C++14和C++17标准对lambda表达式又进行了很有用的扩展,我们会在后面介绍。

2. 捕获列表

在lambda表达式的语法中,与传统C++语法差异最大的部分应该算是捕获列表了。实际上,除了语法差异较大之外,它也是lambda表达式中最为复杂的一个部分。接下来我们会把捕获列表分解开来逐步讨论其特性。

作用域

我们必须了解捕获列表的作用域,通常我们说一个对象在某一个作用域内,不过这种说法在捕获列表中发生了变化。捕获列表中的变量存在于两个作用域——lambda表达式定义的函数作用域以及lambda表达式函数体的作用域。前者是为了捕获变量,后者是为了使用变量。另外,标准还规定能捕获的变量必须是一个自动存储类型。简单来说就是非静态的局部变量。让我们看一看下面的例子:

int x = 0;
int main()
{
	int y = 0;
	static int z = 0;
	auto foo = [x, y, z] {};
}

以上代码可能是无法通过编译的,其原因有两点:第一,变量xz不是自动存储类型的变量;第二,x不存在于lambda表达式定义的作用域。这里可能无法编译,因为不同编译器对于这段代码的处理会有所不同,比如GCC就不会报错,而是给出警告。那么如果想在lambda表达式中使用全局变量或者静态局部变量该怎么办呢?马上能想到的办法是用参数列表传递全局变量或者静态局部变量,其实不必这么麻烦,直接用就行了,来看一看下面的代码:

#include <iostream>
int x = 1;
int main()
{
	int y = 2;
	static int z = 3;
	auto foo = [y] { return x + y + z; };
	std::cout << foo() << std::endl;
}

在上面的代码中,虽然我们没有捕获变量xz,但是依然可以使用它们。

进一步来说,如果我们将一个lambda表达式定义在全局作用域,那么lambda表达式的捕获列表必须为空。因为根据上面提到的规则,捕获列表的变量必须是一个自动存储类型,但是全局作用域并没有这样的类型,比如:

int x = 1;
auto foo = [] { return x; };
int main()
{
	foo();
}

捕获值和捕获引用

捕获列表的捕获方式分为捕获值捕获引用,其中捕获值的语法我们已经在前面的例子中看到了,在[]中直接写入变量名,如果有多个变量,则用逗号分隔,例如:

int main()
{
	int x = 5, y = 8;
	auto foo = [x, y] { return x * y; };
}

捕获值是将函数作用域的xy的值复制到lambda表达式对象的内部,就如同lambda表达式的成员变量一样。

捕获引用的语法与捕获值只有一个&的区别,要表达捕获引用我们只需要在捕获变量之前加上&,类似于取变量指针。只不过这里捕获的是引用而不是指针,在lambda表达式内可以直接使用变量名访问变量而
不需解引用,比如:

int main()
{
	int x = 5, y = 8;
	auto foo = [&x, &y] { return x * y; };
}

上面的两个例子只是读取变量的值,从结果上看两种捕获没有区别,但是如果加入变量的赋值操作,情况就不同了,请看下面的例子:

void bar1()
{
	int x = 5, y = 8;
	auto foo = [x, y] {
		x += 1; // 编译失败,无法改变捕获变量的值
		y += 2; // 编译失败,无法改变捕获变量的值
		return x * y;
	};
	std::cout << foo() << std::endl;
}

void bar2()
{
	int x = 5, y = 8;
	auto foo = [&x, &y] {
		x += 1;
		y += 2;
		return x * y;
	};
	std::cout << foo() << std::endl;
}

在上面的代码中函数bar1无法通过编译,原因是我们无法改变捕获变量的值。这就引出了lambda表达式的一个特性:捕获的变量默认为常量,或者说 lambda是一个常量函数(类似于常量成员函数)。bar2函数里的lambda表达式能够顺利地通过编译,虽然其函数体内也有改变变量xy的行为。这是因为捕获的变量默认为常量指的是变量本身,当变量按值捕获的时候,变量本身就是值,所以改变值就会发生错误。相反,在捕获引用的情况下,捕获变量实际上是一个引用,我们在函数体内改变的并不是引用本身,而是引用的值,所以并没有被编译器拒绝。

另外,还记得上文提到的可选说明符mutable吗?使用mutable说明符可以移除lambda表达式的常量性,也就是说我们可以在lambda表达式的函数体中修改捕获值的变量了,例如:

void bar3()
{
	int x = 5, y = 8;
	auto foo = [x, y] () mutable {
		x += 1;
		y += 2;
		return x * y;
	};
	std::cout << foo() << std::endl;
}

以上代码可以通过编译,也就是说lambda表达式成功地修改了其作用域内的xy的值。值得注意的是,函数bar3相对于函数bar1除了增加说明符mutable,还多了一对(),这是因为语法规定lambda`表达式如果存在说明符,那么形参列表不能省略。

编译运行bar2bar3两个函数会输出相同的结果,但这并不代表两个函数是等价的,捕获值和捕获引用还是存在着本质区别。当lambda表达式捕获值时,表达式内实际获得的是捕获变量的复制,我们可以任意地修改内部捕获变量,但不会影响外部变量。而捕获引用则不同,在lambda表达式内修改捕获引用的变量,对应的外部变量也会被修改

#include <iostream>

void bar2()
{
    int x = 5, y = 8;
    auto foo = [x, y] () mutable {
        x += 1;
        y += 2;
        std::cout <<"bar2 in lambda x = "<< x << " y = " << y << std::endl;
        return x * y;
    };
    int a = foo();
    std::cout <<"bar2 foo() = "<< a << std::endl;
    std::cout <<"bar2 x = " << x << " y = " << y << std::endl;
}
void bar3()
{
    int x = 5, y = 8;
    auto foo = [&x, &y] () {
        x += 1;
        y += 2;
        std::cout <<"bar3 in lambda x = "<< x << " y = " << y << std::endl;
        return x * y;
    };
    int a = foo();
    std::cout <<"bar3 foo() = "<< a << std::endl;
    std::cout <<"bar3 x = " << x << " y = " << y << std::endl;
}
int main()
{
    bar2();
    bar3();
}

输出:

bar2 in lambda x = 6 y = 10
bar2 foo() = 60
bar2 x = 5 y = 8

bar3 in lambda x = 6 y = 10
bar3 foo() = 60
bar3 x = 6 y = 10

从上面代码输出可以看出,使用按值捕获+mutable 的方式修改lambda内部的xy的值不会影响外部的xy的值,而使用按引用捕获的方式修改lambda内部的xy的值会影响外部的xy的值。

对于捕获值lambda表达式还有一点需要注意,捕获值的变量在lambda表达式定义的时候已经固定下来了,无论函数在lambda表达式定义后如何修改外部变量的值,lambda表达式捕获的值都不会变化,例如:

#include <iostream>
int main()
{
    int x = 5, y = 8;
    auto foo = [x, &y]() mutable {
        x += 1;
        y += 2;
        std::cout << "lambda x = " << x << ", y = " << y << std::endl;
        return x * y;
    };
    x = 9;
    y = 20;
    foo();
}

运行结果如下:

lambda x = 6, y = 22

在上面的代码中,虽然在调用foo之前分别修改了xy的值,但是捕获值的变量x依然延续着lambda定义时的值,而在捕获引用的变量y被重新赋值以后,lambda表达式捕获的变量y的值也跟着发生了变化。

特殊的捕获方法

lambda表达式的捕获列表除了指定捕获变量之外还有3种特殊的捕获方法。

  1. [this] —— 捕获this指针,捕获this指针可以让我们使用this类型的成员变量和函数。
  2. [=] —— 捕获lambda表达式定义作用域的全部变量的,包括this
  3. [&] —— 捕获lambda表达式定义作用域的全部变量的引用,包括this

首先来看看捕获this的情况:

#include <iostream>
class A
{
public:
	void print()
	{
		std::cout << "class A" << std::endl;
	}
	
	void test()
	{
		auto foo = [this] {
			print();
			x = 5;
		};
		foo();
	}
private:
	int x;
};
int main()
{
	A a;
	a.test();
}

在上面的代码中,因为lambda表达式捕获了this指针,所以可以在lambda表达式内调用该类型的成员函数print或者使用其成员变量x

捕获全部变量的值或引用则更容易理解:

#include <iostream>
int main()
{
	int x = 5, y = 8;
	auto foo = [=] { return x * y; };
	std::cout << foo() << std::endl;
}

以上代码并没有指定需要捕获的变量,而是使用[=]捕获所有变量的值,这样在lambda表达式内也能访问xy的值。同理,使用[&]也会有同样的效果,读者不妨自己尝试一下。

3. lambda 表达式的实现原理

如果读者是一个C++的老手,可能已经发现lambda表达式与函数对象仿函数)非常相似,所以让我们从函数对象开始深入探讨lambda表达式的实现原理。请看下面的例子:

#include <iostream>

class Bar
{
public:
	Bar(int x, int y) : x_(x), y_(y) {}
	int operator () ()
	{
		return x_ * y_;
	}
private:
	int x_;
	int y_;
};

int main()
{
	int x = 5, y = 8;
	auto foo = [x, y] { return x * y; };
	Bar bar(x, y);
	std::cout << "foo() = " << foo() << std::endl;
	std::cout << "bar() = " << bar() << std::endl;
}

在上面的代码中,foo是一个lambda表达式,而bar是一个函数对象。它们都能在初始化的时候获取main函数中变量xy的值,并在调用之后返回相同的结果。这两者比较明显的区别如下:

  1. 使用lambda表达式不需要我们去显式定义一个类,这一点在快速实现功能上有较大的优势。
  2. 使用函数对象可以在初始化的时候有更加丰富的操作,例如Bar bar(x+y, x * y),而这个操作在C++11标准的lambda表达式中是不允许的。另外,在Bar初始化对象的时候使用全局或者静态局部变量也是没有问题的。

这样看来在C++11标准中,lambda表达式的优势在于书写简单方便且易于维护,而函数对象的优势在于使用更加灵活不受限制,但总的来说它们非常相似。而实际上这也正是lambda表达式的实现原理。

lambda表达式在编译期会由编译器自动生成一个闭包类,在运行时由这个闭包类产生一个对象,我们称它为闭包。在C++中,所谓的闭包可以简单地理解为一个匿名且可以包含定义时作用域上下文的函数对象。现在让我们抛开这些概念,观察lambda表达式究竟是什么样子的。

首先,定义一个简单的lambda表达式:

#include <iostream>
int main()
{
	int x = 5, y = 8;
	auto foo = [=] { return x * y; };
	int z = foo();
}

接着,我们用GCC输出其GIMPLE的中间代码:

main ()
{
    int D.39253;
    {
        int x;
        int y;
        struct __lambda0 foo;
        typedef struct __lambda0 __lambda0;
        int z;
        try
        {
            x = 5;
            y = 8;
            foo.__x = x;
            foo.__y = y;
            z = main()::<lambda()>::operator() (&foo);
        }
        finally
        {
            foo = {CLOBBER};
        }
    }
    D.39253 = 0;
    return D.39253;
}
main()::<lambda()>::operator() (const struct __lambda0 * const __closure)
{
    int D.39255;
    const int x [value-expr: __closure->__x];
    const int y [value-expr: __closure->__y];
    _1 = __closure->__x;
    _2 = __closure->__y;
    D.39255 = _1 * _2;
    return D.39255;
}

从上面的中间代码可以看出lambda表达式的类型名为__lambda0,通过这个类型实例化了对象foo,然后在函数内对foo对象的成员__x__y进行赋值,最后通过自定义的()运算符对表达式执行计算并将结果赋值给变量z。在这个过程中,__lambda0是一个拥有operator()自定义运算符的结构体,这也正是函数对象类型的特性。所以,在某种程度上来说,lambda表达式是C++11给我们提供的一块语法糖而已,lambda表达式的功能完全能够手动实现,而且如果实现合理,代码在运行效率上也不会有差距,只不过实用lambda表达式让代码编写更加轻松了。

我们也可以复制上面的代码到https://cppinsights.io/这个网站上运行来探查其内部的实现原理。

4. 无状态 lambda 表达式

C++标准对于无状态的lambda表达式有着特殊的照顾,即它可以隐式转换为函数指针,例如:

void f(void(*)()) {}
void g() { f([] {}); }  // 编译成功

在上面的代码中,lambda表达式[] {}隐式转换为void(*)()类型的函数指针。同样,看下面的代码:

void f(void(&)()) {}
void g() { f(*[] {}); }

这段代码也可以顺利地通过编译。我们经常会在STL的代码中遇到lambda表达式的这种应用。

5. 在 STL 中使用 lambda 表达式

要探讨lambda表达式的常用场合,就必须讨论C++的标准库STL。在STL中我们常常会见到这样一些算法函数,它们的形参需要传入一个函数指针或函数对象从而完成整个算法,例如std::sortstd::find_if等。

在C++11标准以前,我们通常需要在函数外部定义一个辅助函数或辅助函数对象类型。对于简单的需求,我们也可能使用STL提供的辅助函数,例如std::lessstd::plus等。另外,针对稍微复杂一点的需求还可能会用到std::bind1ststd::bind2nd等函数。总之,无论使用以上的哪种方法,表达起来都相当晦涩。

幸运的是,在有了lambda表达式以后,这些问题就迎刃而解了。我们可以直接在STL算法函数的参数列表内实现辅助函数,例如:

#include <iostream>
#include <vector>
#include <algorithm>

int main()
{
    std::vector<int> x = {1, 2, 3, 4, 5};
    std::cout << *std::find_if(x.cbegin(),x.cend(),
                             [](int i) { return (i % 3) == 0; }) << std::endl;
}

函数std::find_if需要一个辅助函数帮助确定需要找出的值,而这里我们使用lambda表达式直接在传参时定义了辅助函数。无论是编写还是阅读代码,直接定义lambda表达式都比定义辅助函数更加简洁且容易理解。

6. 广义捕获

C++14标准中定义了广义捕获,所谓广义捕获实际上是两种捕获方式,第一种称为简单捕获,这种捕获就是我们在前文中提到的捕获方法,即[identifier][&identifier]以及[this]等。第二种叫作初始化捕获,这种捕获方式是在C++14标准中引入的,它解决了简单捕获的一个重要问题,即只能捕获lambda表达式定义上下文的变量,而无法捕获表达式结果以及自定义捕获变量名,比如:

int main()
{
	int x = 5;
	auto foo = [x = x + 1]{ return x; };
}

以上在C++14标准之前是无法编译通过的,因为C++11标准只支持简单捕获。而C++14标准对这样的捕获进行了支持,在这段代码里捕获列表是一个赋值表达式,不过这个赋值表达式有点特殊,因为它通过等号跨越了两个作用域。等号左边的变量x存在于lambda表达式的作用域,而等号右边x存在于main函数的作用域。如果读者觉得两个x的写法有些绕,我们还可以采用更清晰的写法:

int main()
{
	int x = 5;
	auto foo = [r = x + 1]{ return r; };
}

很明显这里的变量r只存在于lambda表达式,如果此时在lambda表达式函数体里使用变量x,则会出现编译错误。初始化捕获在某些场景下是非常实用的,这里举两个例子,第一个场景是使用移动操作减少代码运行的开销,例如:

#include <string>
int main()
{
	std::string x = "hello c++ ";
	auto foo = [x = std::move(x)]{ return x + "world"; };
}

上面这段代码使用std::move对捕获列表变量x进行初始化,这样避免了简单捕获的复制对象操作,代码运行效率得到了提升。

第二个场景是在异步调用时复制this对象,防止lambda表达式被调用时因原始this对象被析构造成未定义的行为,比如:

#include <iostream>
#include <future>

class Work
{
private:
    int value;
public:
    Work() : value(42) {}
    std::future<int> spawn()
    {
        return std::async([=]() -> int { return value; });
    }
};

std::future<int> foo()
{
    Work tmp;
    return tmp.spawn();
}

int main()
{
    std::future<int> f = foo();
    f.wait();
    std::cout << "f.get() = " << f.get() << std::endl;
}

输出结果如下:

f.get() = 32766

注意,书中这里说输出 f.get() = 32766,但是我使用 CLion 按C++17标准运行输出是f.get() = 42,不知道为什么。不过可以通过加入一个析构函数中清空value的值同样能说明本节的问题:~Work() { value = 0; }

这里我们期待f.get()返回的结果是42,而实际上返回了32766,这就是一个未定义的行为,它造成了程序的计算错误,甚至有可能让程序崩溃。为了解决这个问题,我们引入初始化捕获的特性,将对象复制到
lambda表达式内,让我们简单修改一下spawn函数:

class Work
{
private:
    int value;
public:
    Work() : value(42) {}
    std::future<int> spawn()
    {
        return std::async([=, tmp = *this]() -> int { return tmp.value; });
    }
};

以上代码使用初始化捕获,将*this 复制到 tmp 对象中,然后在函数体内返回 tmp 对象的 value。由于整个对象通过复制的方式传递到lambda表达式内,因此即使 this 所指的对象析构了也不会影响lambda表达式的计算。编译运行修改后的代码,程序正确地输出 f.get() = 42

7. 泛型 lambda 表达式

C++14标准让lambda表达式具备了模版函数的能力,我们称它为泛型lambda表达式。虽然具备模版函数的能力,但是它的定义方式却用不到template关键字。实际上泛型lambda表达式语法要简单很多,我们只需要使用auto占位符即可,例如:

int main()
{
	auto foo = [](auto a) { return a; };
	int three = foo(3);
	char const* hello = foo("hello");
}

由于泛型lambda表达式更多地利用了auto占位符的特性,而lambda表达式本身并没有什么变化,因此想更多地理解泛型lambda表达式,可以阅读第3章,这里就不再赘述了。

8. 常量 lambda 表达式和捕获 *this

C++17标准对lambda表达式同样有两处增强,一处是常量lambda表达式,另一处是对捕获this的增强。其中常量lambda表达式的主要特性体现在constexpr关键字上,请阅读constexpr的有关章节来掌握常量lambda表达式的特性,这里主要说明一下对于捕获this的增强。

还记得前面初始化捕获*this对象的代码吗?我们在捕获列表内复制了一份this指向的对象到tmp,然后使用tmpvalue。没错,这样做确实解决了异步问题,但是这个解决方案并不优美。试想一下,如果在lambda表达式中用到了大量this指向的对象,那我们就不得不将它们全部修改,一旦遗漏就会引发问题。为了更方便地复制和使用*this对象,C++17增加了捕获列表的语法来简化这个操作,具体来说就是在捕获列表中直接添加[*this],然后在lambda表达式函数体内直接使用this指向对象的成员,还是以前面的Work类为例:

class Work
{
private:
    int value;
    
public:
    Work() : value(42) {}
    std::future<int> spawn()
    {
        return std::async([=, *this]() -> int { return value; });
    }
};

在上面的代码中没有再使用tmp=*this来初始化捕获列表,而是直接使用*this。在lambda表达式内也没有再使用tmp.value而是直接返回了value。编译运行这段代码可以得到预期的结果42。从结果可以看出,[*this]的语法让程序生成了一个*this对象的副本并存储在lambda表达式内,可以在lambda表达式内直接访问这个复制对象的成员,消除了之前lambda表达式需要通过tmp访问对象成员的尴尬。

9. 捕获 [=, this]

在C++20标准中,又对lambda表达式进行了小幅修改。这一次修改没有加强lambda表达式的能力,而是让this指针的相关语义更加明确。我们知道[=]可以捕获this指针,相似的,[=,*this]会捕获this对象的副本。但是在代码中大量出现[=][=,*this]的时候我们可能很容易忘记前者与后者的区别。为了解决这个问题,在C++20标准中引入了[=, this]捕获this指针的语法,它实际上表达的意思和[=]相同,目的是让程序员们区分它与[=,*this]的不同。

[=, this]{}; // C++17 编译报错或者报警告, C++20成功编译

我使用 CLion 按照 C++17 编译上面写法的代码没有报错,也没有警告,只是最终运行结果不正确。

虽然在C++17标准中认为[=, this]{}; 是有语法问题的,但是实践中GCC和CLang都只是给出了警告而并未报错。另外,在C++20标准中还特别强调了要用[=, this]代替[=],如果用GCC编译下面这段代码:

template <class T>
void g(T) {}

struct Foo {
	int n = 0;
	void f(int a) {
		g([=](int k) { return n + a * k; });
	}
};

编译器会输出警告信息,表示标准已经不再支持使用 [=] 隐式捕获 this 指针了,提示用户显式添加 this*this。最后值得注意的是,同时用两种语法捕获 this 指针是不允许的,比如:

[this, *this]{};

这种写法在CLang中一定会给出编译错误,而GCC则稍显温柔地给出警告,在我看来这种写法没有意义,是应该避免的。

10. 模板语法的泛型 lambda 表达式

在7.7节中我们讨论了C++14标准中lambda表达式通过支持auto来实现泛型。大部分情况下,这是一种不错的特性,但不幸的是,这种语法也会使我们难以与类型进行互动,对类型的操作变得异常复杂。用提案文档的举例来说:

template <typename T> struct is_std_vector : std::false_type { };
template <typename T> struct is_std_vector<std::vector<T>> : std::true_type { };

auto f = [](auto vector) {
	static_assert(is_std_vector<decltype(vector)>::value, "");
};

普通的函数模板可以轻松地通过形参模式匹配一个实参为 vector 的容器对象,但是对于 lambda 表达式,auto 不具备这种表达能力,所以不得不实现 is_std_vector,并且通过 static_assert 来辅助判断实参的真实类型是否为 vector。在 C++ 委员会的专家看来,把一个本可以通过模板推导完成的任务交给 static_assert 来完成是不合适的。除此之外,这样的语法让获取 vector 存储对象的类型也变得十分复杂,比如:

auto f = [](auto vector) {
	using T = typename decltype(vector)::value_type;
	// …
};

当然,能这样实现已经是很侥幸了。我们知道 vector 容器类型通常使用内嵌类型 value_type 表示存储对象的类型。但我们不能保证面对的所有容器都会遵循这一规则,因此依赖内嵌类型是不可靠的。

进一步来说,decltype(obj) 有时不能直接获取我们所需的类型。不记得decltype推导规则的读者可以复习一下前面的章节,这里就直接说明示例代码:

auto f = [](const auto& x) {
	using T = decltype(x);
	T copy = x; // 可以编译,但是语义错误
	using Iterator = typename T::iterator; // 编译错误
};

std::vector<int> v;
f(v)

请注意,在上面的代码中,decltype(x) 推导出的类型并不是 std::vector,而是 const std::vector&,所以 T copy = x; 不是一个复制操作而是引用。对于一个引用类型,T::iterator 也是不符合语法的,所以会导致编译错误。在提案文档中,作者使用了 STL 的 decay,这样可以删除类型的常量性(cv)以及引用属性,于是就有了下面的代码:

auto f = [](const auto& x) {
	using T = std::decay_t<decltype(x)>;
	T copy = x;
	using Iterator = typename T::iterator;
};

问题虽然解决了,但是要时刻注意auto,以免给代码带来意想不到的问题,况且这都是建立在容器本身设计得比较完善的情况下才能继续下去的。

鉴于以上种种问题,C++委员会决定在C++20中添加模板对lambda的支持,语法非常简单:

[]<typename T>(T t) {}

于是,上面那些让我们为难的例子就可以改写为:

auto f = []<typename T>(std::vector<T> vector) {
	// …
};

以及

auto f = []<typename T>(T const& x) {
	T copy = x;
	using Iterator = typename T::iterator;
};

上面的代码是否能让读者眼前一亮?这些代码不仅简洁了很多,而且也更符合C++泛型编程的习惯。

最后再说一个有趣的故事,事实上早在2012年,让lambda支持模板的提案文档N3418已经提交给了C++委员会,不过当时这份提案并没有被接受,到2013年N3559中提出的基于auto的泛型在C++14标准中实现,而2017年lambda支持模板的提案又一次被提出来,这一次可以说是踩在N3559的肩膀上成功地加入了C++20标准。回过头来看整个过程,虽说算不上曲折,但也颇为耐人寻味,C++作为一个发展近30年的语言,依然在不断地探索和纠错中砺志前行。

11. 可构造和可赋值的无状态 lambda 表达式

在7.4节中我们提到了无状态lambda表达式可以转换为函数指针,但遗憾的是,在C++20标准之前无状态的lambda表达式类型既不能构造也无法赋值,这阻碍了许多应用的实现。举例来说,我们已经了解了像std::sortstd::find_if这样的函数需要一个函数对象或函数指针来辅助排序和查找,这种情况我们可以使用lambda表达式完成任务。但是如果遇到std::map这种容器类型就不好办了,因为std::map的比较函数对象是通过模板参数确定的,这个时候我们需要的是一个类型:

auto greater = [](auto x, auto y) { return x > y; };
std::map<std::string, int, decltype(greater)> mymap;

这段代码的意图很明显,它首先定义了一个无状态的lambda表达式greate,然后使用decltype(greater)获取其类型作为模板实参传入模板。这个想法非常好,但是在C++17标准中是不可行的,因为lambda表达式类型无法构造。编译器会明确告知,lambda表达式的默认构造函数已经被删除了(“note: a lambda closure type has a deleted defaultconstructor”)。

除了无法构造,无状态的lambda表达式也没办法赋值,比如:

auto greater = [](auto x, auto y) { return x > y; };
std::map<std::string, int, decltype(greater)> mymap1, mymap2;
mymap1 = mymap2;

这里mymap1 = mymap2;也会被编译器报错,原因是复制赋值函数也被删除了(“note: a lambda closure type has a deleted copy assignmentoperator”)。

为了解决以上问题,C++20标准允许了无状态lambda表达式类型的构造和赋值,所以使用C++20标准的编译环境来编译上面的代码是可行的。

总结

在本章我们介绍了lambda表达式的语法、使用方法以及原理。总的来说lambda表达式不但容易使用,而且原理也容易理解。它很好地解决了过去C++中无法直接编写内嵌函数的尴尬。虽然在GCC中提供了一个叫作nest function的C语言扩展,这个扩展允许我们在函数内部编写内嵌函数,但这个特性一直没有被纳入标准当中。当然我们也并不用为此遗憾,因为现在提供的lambda表达式无论在语法简易程度上,还是用途广泛程度上都要优于nest function。合理地使用lambda表达式,可以让代码更加短小精悍的同时也具有良好的可读性。

八、非静态数据成员默认初始化(C++11 C++20)

1. 使用默认初始化

在C++11以前,对非静态数据成员初始化需要用到初始化列表,当类的数据成员和构造函数较多时,编写构造函数会是一个令人头痛的问题:

class X {
public:
	X() : a_(0), b_(0.), c_("hello world") {}
	X(int a) : a_(a), b_(0.), c_("hello world") {}
	X(double b) : a_(0), b_(b), c_("hello world") {}
	X(const std::string &c) : a_(0), b_(0.), c_(c) {}
private:
	int a_;
	double b_;
	std::string c_;
};

在上面的代码中,类X有4个构造函数,为了在构造的时候初始化非静态数据成员,它们的初始化列表有一些冗余代码,而造成的后果是维护困难且容易出错。为了解决这种问题,C++11标准提出了新的初始化方法,即在声明非静态数据成员的同时直接对其使用=或者{}初始化(见第9章)。在此之前只有类型为整型或者枚举类型的常量静态数据成员才有这种声明默认初始化的待遇:

class X {
public:
	X() {}
	X(int a) : a_(a) {}
	X(double b) : b_(b) {}
	X(const std::string &c) : c_(c) {}
private:
	int a_ = 0;
	double b_{ 0. };
	std::string c_{ "hello world" };
};

以上代码使用了非静态数据成员默认初始化的方法,可以看到这种初始化的方式更加清晰合理,每个构造函数只需要专注于特殊成员的初始化,而其他的数据成员则默认使用声明时初始化的值。比如X(conststd::string c)这个构造函数,它只需要关心数据成员c_的初始化而不必初始化a_b_。在初始化的优先级上有这样的规则,初始化列表对数据成员的初始化总是优先于声明时默认初始化。

最后来看一看非静态数据成员在声明时默认初始化需要注意的两个问题。

  1. 不要使用括号()对非静态数据成员进行初始化,因为这样会造成解析问题,所以会编译错误。
  2. 不要用auto来声明和初始化非静态数据成员,虽然这一点看起来合理,但是C++并不允许这么做。
struct X {
	int a(5);   // 编译错误,不能使用 () 进行默认初始化
	auto b = 8; // 编译错误,不能使用 auto 声明和初始化非静态数据成员
};

2. 位域的默认初始化

在C++11标准提出非静态数据成员默认初始化方法之后,C++20标准又对该特性做了进一步扩充。在C++20中我们可以对数据成员的位域进行默认初始化了,例如:

struct S {
	int y : 8 = 11;
	int z : 4 {7};
};

在上面的代码中,int数据的低8位被初始化为11,紧跟它的高4位被初始化为7

位域的默认初始化语法很简单,但是也有一个需要注意的地方。当表示位域的常量表达式是一个条件表达式时我们就需要警惕了,例如:

int a;
struct S2 {
	int y : true ? 8 : a = 42;
	int z : 1 || new int { 0 };
};

请注意,这段代码中并不存在默认初始化,因为最大化识别标识符的解析规则让=42{0}不可能存在于解析的顶层。于是以上代码会被认为是:

int a;
struct S2 {
	int y : (true ? 8 : a = 42);
	int z : (1 || new int { 0 });
};

所以我们可以通过使用括号明确代码被解析的优先级来解决这个问题:

int a;
struct S2 {
	int y : (true ? 8 : a) = 42;
	int z : (1 || new int){ 0 };
};

通过以上方法就可以对S2::yS2::z进行默认初始化了。

九、列表初始化(C++11 C++20)

1. 回顾变量初始化

在介绍列表初始化之前,让我们先回顾一下初始化变量的传统方法。其中常见的是使用括号和等号在变量声明时对其初始化,例如:

struct C {
	C(int a) {}
};

int main()
{
	int x = 5;
	int x1(8);
	C x2 = 4;
	C x3(4);
}

一般来说,我们称使用括号初始化的方式叫作直接初始化,而使用等号初始化的方式叫作拷贝初始化(复制初始化)。请注意,这里使用等号对变量初始化并不是调用等号运算符的赋值操作。实际情况是,等号是拷贝初始化,调用的依然是直接初始化对应的构造函数,只不过这里是隐式调用而已。如果我们将C(int a)声明为explicit,那么C x2= 4就会编译失败。

使用括号和等号只是直接初始化和拷贝初始化的代表,还有一些经常用到的初始化方式也属于它们。比如 new运算符和类构造函数的初始化列表就属于直接初始化,而函数传参和return返回则是拷贝初始化。前者比较好理解,后者可以通过具体的例子来理解:

#include <map>
struct C {
	C(int a) {}
};

void foo(C c) {}

C bar()
{
	return 5;
}

int main()
{
	foo(8); 	 // 拷贝初始化
	C c = bar(); // 拷贝初始化
}

这段代码中foo函数的传参和bar函数的返回都调用了隐式构造函数,是一个拷贝初始化。

2. 使用列表初始化

C++11标准引入了列表初始化,它使用大括号{}对变量进行初始化,和传统变量初始化的规则一样,它也区分为直接初始化拷贝初始化,例如:

#include <string>

struct C {
	C(std::string a, int b) {}
	C(int a) {}
};

void foo(C) {}

C bar()
{
	return {"world", 5};
}

int main()
{
	int x = {5};             // 拷贝初始化
	int x1{8};               // 直接初始化
	C x2 = {4};              // 拷贝初始化
	C x3{2};                 // 直接初始化
	foo({8});                // 拷贝初始化
	foo({"hello", 8});       // 拷贝初始化
	C x4 = bar();            // 拷贝初始化
	C *x5 = new C{ "hi", 42 };  // 直接初始化
}

仔细观察以上代码会发现,列表初始化和传统的变量初始化几乎相同,除了foo({"hello", 8})return {"world", 5}这两处不同。读者应该发现了列表初始化在这里的奥妙所在,它支持隐式调用多参数的构造函数,于是{"hello", 8}{"world", 5}通过隐式调用构造函数C::C(std::string a, int b)成功构造了类C的对象。当然了,有时候我们并不希望编译器进行隐式构造,这时候只需要在特定构造函数上声明explicit即可。

讨论使用大括号初始化变量就不得不提用大括号初始化数组,例如int x[] = { 1, 2, 3, 4, 5 }。不过遗憾的是,这个特性无法使用到STLvectorlist等容器中。想要初始化容器,我们不得不编写一个循环来完成初始化工作。现在,列表初始化将程序员从这个问题中解放了出来,我们可以使用列表初始化对标准容器进行初始化了,例如:

#include <vector>
#include <list>
#include <set>
#include <map>
#include <string>

int main()
{
	int x[] = { 1,2,3,4,5 };
	int x1[]{ 1,2,3,4,5 };
	std::vector<int> x2{ 1,2,3,4,5 };
	std::vector<int> x3 = { 1,2,3,4,5 };
	std::list<int> x4{ 1,2,3,4,5 };
	std::list<int> x5 = { 1,2,3,4,5 };
	std::set<int> x6{ 1,2,3,4,5 };
	std::set<int> x7 = { 1,2,3,4,5 };
	std::map<std::string, int> x8{ {"bear",4}, {"cassowary",2}, {"tiger",7} };
	std::map<std::string, int> x9 = { {"bear",4}, {"cassowary",2}, {"tiger",7} };
}

以上代码在C++11环境下可以成功编译,可以看到使用列表初始化标准容器和初始化数组一样简单,唯一值得注意的地方是对x8x9的初始化,因为它使用了列表初始化的一个特殊的特性。

3. std::initializer_list 详解

标准容器之所以能够支持列表初始化,离不开编译器支持的同时,它们自己也必须满足一个条件:支持std::initializer_list为形参的构造函数。std::initializer_list简单地说就是一个支持beginend以及size成员函数的类模板,有兴趣的读者可以翻阅STL的源代码,然后会发现无论是它的结构还是函数都直截了当。编译器负责将列表里的元素(大括号包含的内容)构造为一个std::initializer_list的对象,然后寻找标准容器中支持std::initializer_list为形参的构造函数并调用它。而标准容器的构造函数的处理就更加简单了,它们只需要调用std::initializer_list对象的beginend函数,在循环中对本对象进行初始化。

通过了解原理能够发现,支持列表初始化并不是标准容器的专利,我们也能写出一个支持列表初始化的类,需要做的只是添加一个以std::initializer_list为形参的构造函数罢了,比如下面的例子:

#include <iostream>
#include <string>

struct C {
	C(std::initializer_list<std::string> a)
	{
		for (const std::string* item = a.begin(); item != a.end(); ++item) {
			std::cout << *item << " ";
		}
		std::cout << std::endl;
	}
};

int main()
{
	C c{ "hello", "c++", "world" };
}

上面这段代码实现了一个支持列表初始化的类 C,类 C 的构造函数为 C(std:: initializer_list<std::string> a),这是支持列表初始化所必需的,值得注意的是,std:: initializer_listbeginend 函数并不是返回的迭代器对象,而是一个常量对象指针 const T *。本着刨根问底的精神,让我们进一步探究编译器对列表的初始化处理:

#include <iostream>
#include <string>

struct C {
	C(std::initializer_list<std::string> a)
	{
		for (const std::string* item = a.begin(); item != a.end(); ++item)
		{
			std::cout << item << " ";
		}
		std::cout << std::endl;
	}
};

int main()
{
	C c{ "hello", "c++", "world" };
	std::cout << "sizeof(std::string) = " <<
	std::hex << sizeof(std::string) << std::endl;
}

运行输出结果如下:

0x77fdd0 0x77fdf0 0x77fe10
sizeof(std::string) = 20

以上代码输出了std::string对象的内存地址以及单个对象的大小(不同编译环境的std::string实现方式会有所区别,其对象大小也会不同,这里的例子是使用GCC编译的,std::string对象的大小为0x20)。仔细观察3个内存地址会发现,它们的差别正好是std::string所占的内存大小。于是我们能推断出,编译器所进行的工作大概是这样的:

const std::string __a[3] = {std::string{"hello"}, std::string{"c++"}, std::string{"world"}};
C c(std::initializer_list<std::string>(__a, __a+3));

另外,有兴趣的读者不妨用GCC对上面这段代码生成中间代码GIMPLE,不出意外会发现类似这样的中间代码:

main ()
{
	struct initializer_list D.40094;
	const struct basic_string D.36430[3];
	…
	std::__cxx11::basic_string<char>::basic_string (&D.36430[0], "hello", &D.36424);
	…
	std::__cxx11::basic_string<char>::basic_string (&D.36430[1], "c++", &D.36426);
	…
	std::__cxx11::basic_string<char>::basic_string (&D.36430[2], "world", &D.36428);
	…
	D.40094._M_array = &D.36430;
	D.40094._M_len = 3;
	C::C (&c, D.40094);}

4. 使用列表初始化的注意事项

使用列表初始化是如此的方便,让人不禁想马上运用到自己的代码中去。但是请别着急,这里还有两个地方需要读者注意。

1) 隐式缩窄转换问题

隐式缩窄转换是在编写代码中稍不留意就会出现的,而且它的出现并不一定会引发错误,甚至有可能连警告都没有,所以有时候容易被人们忽略,比如:

int x = 12345;
char y = x;
// char y{x}; // 这样编译器报错

这段代码中变量y的初始化明显是一个隐式缩窄转换,这在传统变量初始化中是没有问题的,代码能顺利通过编译。但是如果采用列表初始化,比如char z{ x },根据标准编译器通常会给出一个错误,MSVC和CLang就是这么做的,而GCC有些不同,它只是给出了警告。

现在问题来了,在C++中哪些属于隐式缩窄转换呢?在C++标准里列出了这么4条规则。

  1. 从浮点类型转换整数类型。
  2. long double转换到doublefloat,或从double转换到float,除非转换源是常量表达式以及转换后的实际值在目标可以表示的值范围内。
  3. 从整数类型或非强枚举类型转换到浮点类型,除非转换源是常量表达式,转换后的实际值适合目标类型并且能够将生成目标类型的目标值转换回原始类型的原始值。
  4. 从整数类型或非强枚举类型转换到不能代表所有原始类型值的整数类型,除非源是一个常量表达式,其值在转换之后能够适合目标类型。

4条规则虽然描述得比较复杂,但是要表达的意思还是很简单的,结合标准的例子就很容易理解了:

int x = 999;
const int y = 999;
const int z = 99;
const double cdb = 99.9;
double db = 99.9;
char c1 = x;  // 编译成功,传统变量初始化支持隐式缩窄转换
char c2{ x }; // 编译失败,可能是隐式缩窄转换,对应规则4
char c3{ y }; // 编译失败,确定是隐式缩窄转换,999 超出 char 能够适应的范围,对应规则4
char c4{ z }; // 编译成功,99 在 char 能够适应的范围内,对应规则4
unsigned char uc1 = { 5 };  // 编译成功,5 在 unsigned char 能够适应的范围内,对应规则4
unsigned char uc2 = { -1 }; // 编译失败,unsigned char 不能够适应负数,对应规则4
unsigned int ui1 = { -1 };  // 编译失败,unsigned int 不能够适应负数,对应规则4
signed int si1 = { (unsigned int)-1 }; // 编译失败,signed int 不能够适应 -1 所对应的 unsigned int,通常是 4294967295,对应规则4
int ii = { 2.0 }; // 编译失败,int 不能适应浮点范围,对应规则1
float f1{ x };    // 编译失败,float 可能无法适应整数或者互相转换,对应规则3
float f2{ 7 };    // 编译成功,7 能够适应 float,且 float 也能转换回整数 7,对应规则3
float f3{ cdb };  // 编译成功,99.9 能适应 float,对应规则2
float f4{ db };   // 编译失败,可能是隐式缩窄转无法表达 double,对应规则2

2) 列表初始化的优先级问题

通过9.2节和9.3节的介绍我们知道,列表初始化既可以支持普通的构造函数,也能够支持以std::initializer_list为形参的构造函数。如果这两种构造函数同时出现在同一个类里,那么编译器会如何选择构造函数呢?比如:

std::vector<int> x1(5, 5);
std::vector<int> x2{ 5, 5 };

以上两种方法都可以对std::vector<int>进行初始化,但是初始化的结果却是不同的。变量x1的初始化结果是包含5个元素,且5个元素的值都为5,调用了vector(size_type count, const T& value, const Allocator& alloc = Allocator())这个构造函数。而变量x2的初始化结果是包含两个元素,且两个元素的值为5,也就是调用了构造函数vector(std::initializer_list<T> init, const Allocator& alloc = Allocator())

所以,上述问题的结论是,如果有一个类同时拥有满足列表初始化的构造函数,且其中一个是以std::initializer_list为参数,那么编译器将优先以std::initializer_list为参数构造函数。由于这个特性的存在,我们在编写或阅读代码的时候就一定需要注意初始化代码的意图是什么,应该选择哪种方法对变量初始化。

最后让我们回头看一看9.2节中没有解答的一个问题,std::map<std::string, int> x8{ {"bear",4}, {"cassowary",2}, {"tiger",7} }中两个层级的列表初始化分别使用了什么构造函数。其实答案已经非常明显了,内层 {"bear",4}, {"cassowary",2}{"tiger",7} 都隐式调用了 std::pair 的构造函数 pair(const T1& x, const T2& y),而外层的 {…} 隐式调用的则是 std::map 的构造函数 map(std::initializer_list<value_type> init, const Allocator&)

5. 指定初始化

为了提高数据成员初始化的可读性和灵活性,C++20标准中引入了指定初始化的特性。该特性允许指定初始化数据成员的名称,从而使代码意图更加明确。让我们看一看示例:

struct Point {
	int x;
	int y;
};
Point p{ .x = 4, .y = 2 };

虽然在这段代码中Point的初始化并不如 Point p{ 4, 2 }; 方便,但是这个例子却很好地展现了指定初始化语法。实际上,当初始化的结构体的数据成员比较多且真正需要赋值的只有少数成员的时候,这样的指定初始化就非常好用了:

struct Point3D {
	int x;
	int y;
	int z;
};
Point3D p{ .z = 3 }; // x = 0, y = 0

在上面的代码中 Point3D 需要 3 个坐标,不过我们只需要设置 z 的值,指定 .z = 3 即可。其中 xy 坐标会调用默认初始化将其值设置为 0。可能这个例子还是不能完全体现出它相对于 Point3D p{ 0, 0, 3 }; 的优势所在,不过读者应该能感觉到,一旦结构体更加复杂,指定初始化就一定能带来不少方便之处。

最后需要注意的是,并不是什么对象都能够指定初始化的。

  1. 它要求对象必须是一个聚合类型,例如下面的结构体就无法使用指定初始化:
struct Point3D {
	Point3D() {}
	int x;
	int y;
	int z;
};
Point3D p{ .z = 3 }; // 编译失败,Point3D 不是一个聚合类型

这里读者可能会有疑问,如果不能提供构造函数,那么我们希望数据成员 xy 的默认值不为 0 的时候应该怎么做?不要忘了,从 C++11 开始我们有了非静态成员变量直接初始化的方法,比如当希望 Point3D 的默认坐标值都是 100 时,代码可以修改为:

struct Point3D {
	int x = 100;
	int y = 100;
	int z = 100;
};
Point3D p{ .z = 3 }; // x = 100, y = 100, z = 3
  1. 定的数据成员必须是非静态数据成员。这一点很好理解,静态数据成员不属于某个对象。
  2. 每个非静态数据成员最多只能初始化一次
Point p{ .y = 4, .y = 2 }; // 编译失败,y不能初始化多次
  1. 非静态数据成员的初始化必须按照声明的顺序进行。请注意,这一点和C语言中指定初始化的要求不同,在C语言中,乱序的指定初始化是合法的,但C++不行。其实这一点也很好理解,因为C++中的数据成员会按照声明的顺序构造,按照顺序指定初始化会让代码更容易阅读:
Point p{ .y = 4, .x = 2 }; // C++编译失败,C编译没问题
  1. 针对联合体中的数据成员只能初始化一次,不能同时指定
u f = { .a = 1 }; // 编译成功
u g = { .b = "asdf" }; // 编译成功
u h = { .a = 1, .b = "asdf" }; // 编译失败,同时指定初始化联合体中的多个数据成员
  1. 不能嵌套指定初始化数据成员。虽然这一点在C语言中也是允许的,但是C++标准认为这个特性很少有用,所以直接禁止了:
struct Line {
	Point a;
	Point b;
};
Line l{ .a.y = 5 }; // 编译失败, .a.y = 5 访问了嵌套成员,不符合C++标准

当然,如果确实想嵌套指定初始化,我们可以换一种形式来达到目的:

Line l{ .a {.y = 5} };
  1. 在C++20中,一旦使用指定初始化,就不能混用其他方法对数据成员初始化了,而这一点在C语言中是允许的:
Point p{ .x = 2, 3 }; // 编译失败,混用数据成员的初始化
  1. 最后再来了解一下指定初始化在C语言中处理数组的能力,当然在C++中这同样是被禁止的:
int arr[3] = { [1] = 5 }; // 编译失败

C++标准中给出的禁止理由非常简单,它的语法和lambda表达式冲突了。

总结

列表初始化是我非常喜欢的一个特性,因为它解决了以往标准容器初始化十分不方便的问题,使用列表初始化可以让容器如同数组一般被初始化。除此以外,实现以std::initializer_list为形参的构造函数也非常容易,这使自定义容器支持列表初始化也变得十分简单。C++20引入的指定初始化在一定程度上简化了复杂聚合类型初始化工作,让初始化复杂聚合类型的代码变得简洁清晰。

十、默认和删除函数(C++11)

1. 类的特殊成员函数

在定义一个类的时候,我们可能会省略类的构造函数,因为C++标准规定,在没有自定义构造函数的情况下,编译器会为类添加默认的构造函数。像这样有特殊待遇的成员函数一共有 6 个(C++11以前是4个),具体如下。

  1. 默认构造函数。
  2. 析构函数。
  3. 复制构造函数。
  4. 复制赋值运算符函数。
  5. 移动构造函数(C++11新增)。
  6. 移动赋值运算符函数(C++11新增)。

添加默认特殊成员函数的这条特性非常实用,它让程序员可以有更多精力关注类本身的功能而不必为了某些语法特性而分心,同时也避免了让程序员编写重复的代码,比如:

#include <string>
#include <vector>

class City {
	std::string name;
	std::vector<std::string> street_name;
};

int main()
{
	City a, b;
	a = b;
}

在上面的代码中,我们虽然没有为 City 类添加复制赋值运算符函数 City::operator=(const City &),但是编译器仍然可以成功编译代码,并且在运行过程中正确地调用 std::stringstd::vector<std::string> 的复制赋值运算符函数。假如编译器没有提供这条特性,我们就不得不在编写类的时候添加以下代码:

City& City::operator=(const City & other)
{
	name = other.name;
	street_name = other.street_name;
	return *this;
}

很明显,编写这段代码除了满足语法的需求以外没有其他意义,很庆幸可以把这件事情交给编译器去处理。不过还不能高兴得太早,因为该特性的存在也给我们带来了一些麻烦。

  1. 声明任何构造函数都会抑制默认构造函数的添加。
  2. 一旦用自定义构造函数代替默认构造函数,类就将转变为非平凡类型。
  3. 没有明确的办法彻底禁止特殊成员函数的生成(C++11之前)。

下面来详细地解析这些问题,还是以City类为例,我们给它添加一个构造函数:

#include <string>
#include <vector>

class City {
	std::string name;
	std::vector<std::string> street_name;
public:
	City(const char *n) : name(n) {}
};

int main()
{
	City a("wuhan");
	City b; // 编译失败,自定义构造函数抑制了默认构造函数
	b = a;
}

以上代码由于添加了构造函数City(const char *n),导致编译器不再为类提供默认构造函数,因此在声明对象b的时候出现编译错误,为了解决这个问题我们不得不添加一个无参数的构造函数:

class City {
	std::string name;
	std::vector<std::string> street_name;
public:
	City(const char *n) : name(n) {}
	City() {} // 新添加的构造函数
};

可以看到这段代码新添加的构造函数什么也没做,但却必须定义。乍看虽然做了一些多此一举的工作,但是毕竟也能让程序重新编译和运行,问题得到了解决。真的是这样吗?事实上,我们又不知不觉地陷入另一个麻烦中,请看下面的代码:

class Trivial
{
	int i;
public:
	Trivial(int n) : i(n), j(n) {}
	Trivial() {}
	int j;
};

int main()
{
	Trivial a(5);
	Trivial b;
	b = a;
	std::cout << "std::is_trivial_v<Trivial> : " << std::is_trivial_v<Trivial> << std::endl;
}

上面的代码中有两个动作会将Trivial类的类型从一个平凡类型转变为非平凡类型。第一是定义了一个构造函数Trivial(int n),它导致编译器抑制添加默认构造函数,于是Trivial类转变为非平凡类型。第二是定义了一个无参数的构造函数,同样可以让Trivial类转变为非平凡类型。

最后一个问题大家肯定也都遇到过,举例来说,有时候我们需要编写一个禁止复制操作的类,但是过去C++标准并没有提供这样的能力。聪明的程序员通过将复制构造函数和复制赋值运算符函数声明为private并且不提供函数实现的方式,间接地达成目的。为了使用方便,boost库也提供了noncopyable类辅助我们完成禁止复制的需求。

不过就如前面的问题一样,虽然能间接地完成禁止复制的需求,但是这样的实现方法并不完美。比如,友元就能够在编译阶段破坏类对复制的禁止。这里可能会有读者反驳,虽然友元能够访问私有的复制构造函数,但是别忘了,我们并没有实现这个函数,也就是说程序最后仍然无法运行。没错,程序最后会在链接阶段报错,原因是找不到复制构造函数的实现。但是这个报错显然来得有些晚,试想一下,如果面临的是一个巨大的项目,有不计其数的源文件需要编译,那么编译过程将非常耗时。如果某个错误需要等到编译结束以后的链接阶段才能确定,那么修改错误的时间代价将会非常高,所以我们还是更希望能在编译阶段就找到错误。

还有一个典型的例子,禁止重载函数的某些版本,考虑下面的例子:

class Base {
	void foo(long &);
public:
	void foo(int) {}
};

int main()
{
	Base b;
	long x = 5;
    b.foo(8);
    b.foo(x); // 编译错误
}

由于将成员函数 foo(long &) 声明为私有访问并且没有提供代码实现,因此在调用 b.foo(x) 的时候会编译出错。这样看来它跟我们之前讨论的例子没有什么实际区别,再进一步讨论,假设现在我们需要继承 Base 类,并且实现子类的 foo 函数;另外,还想沿用基类 Basefoo 函数,于是这里使用 using 说明符将 Basefoo 成员函数引入子类,代码如下:

class Base {
	void foo(long &);
public:
	void foo(int) {}
};

class Derived : public Base {
public:
	using Base::foo;
	void foo(const char *) {}
};

int main()
{
	Derived d;
	d.foo("hello");
	d.foo(5);
}

上面这段代码看上去合情合理,而实际上却无法通过编译。因为using说明符无法将基类的私有成员函数引入子类当中,即使这里我们将代码d.foo(5)删除,即不再调用基类的函数,编译器也是不会让这段代码编译成功的。

2. 显式默认和显式删除

为了解决以上种种问题,C++11标准提供了一种方法能够简单有效又精确地控制默认特殊成员函数的添加和删除,我们将这种方法叫作显式默认显式删除。显式默认和显式删除的语法非常简单,只需要在声明函数的尾部添加=default=delete,它们分别指示编译器添加特殊函数的默认版本以及删除指定的函数:

struct type
{
	type() = default;
	virtual ~type() = delete;
	type(const type &);
};
type::type(const type &) = default;

以上代码显式地添加了默认构造和复制构造函数,同时也删除了析构函数。请注意,=default 可以添加到类内部函数声明,也可以添加到类外部。这里默认构造函数的 =default 就是添加在类内部,而复制构造函数的 =default 则是添加在类外部。提供这种能力的意义在于,它可以让我们在不修改头文件里函数声明的情况下,改变函数内部的行为,例如:

// type.h
struct type {
	type();
	int x;
};

// type1.cpp
type::type() = default;

// type2.cpp
type::type() { x = 3; }

=delete=default不同,它必须添加在类内部的函数声明中,如果将其添加到类外部,那么会引发编译错误。

通过使用=default,我们可以很容易地解决之前提到的前两个问题,请观察以下代码:

#include <iostream>

class NonTrivial
{
	int i;
public:
	NonTrivial(int n) : i(n), j(n) {}
	NonTrivial() {}
	int j;
};

class Trivial
{
	int i;
public:
	Trivial(int n) : i(n), j(n) {}
	Trivial() = default;
	int j;
};

int main()
{
	Trivial a(5);
	Trivial b;
	b = a;
	std::cout << "std::is_trivial_v<Trivial>   : " << std::is_trivial_v<Trivial> << std::endl;
	std::cout << "std::is_trivial_v<NonTrivial> : " << std::is_trivial_v<NonTrivial> << std::endl;
}

注意,我们只是将构造函数Trivial() {}替换为显式默认构造函数Trivial() = default,类就从非平凡类型恢复到平凡类型了。这样一来,既让编译器为类提供了默认构造函数,又保持了类本身的性质,可以说完美解决了之前的问题。

另外,针对禁止调用某些函数的问题,我们可以使用 = delete 来删除特定函数,相对于使用 private 限制函数访问,使用 = delete 更加彻底,它从编译层面上抑制了函的生成,所以无论调用者是什么身份(包括类的成员函数),都无法调用被删除的函数。进一步来说,由于必须在函数声明中使用 = delete 来删除函数,因此编译器可以在第一时间发现有代码错误地调用被删除的函数并且显示错误报告,这种快速报告错误的能力也是我们需要的,来看下面的代码:

class NonCopyable
{
public:
	NonCopyable() = default;                       // 显式添加默认构造函数
	NonCopyable(const NonCopyable&) = delete;      // 显式删除拷贝构造函数
	NonCopyable& operator=(const NonCopyable&) = delete;	// 显式删除拷贝赋值运算符函数
};

int main()
{
	NonCopyable a, b;
	a = b;			// 编译失败,拷贝赋值运算符已被删除
}

以上代码删除了类NonCopyable的复制构造函数和复制赋值运算符函数,这样就禁止了该类对象相互之间的复制操作。请注意,由于显式地删除了复制构造函数,导致默认情况下编译器也不再自动添加默认构造函数,因此我们必须显式地让编译器添加默认构造函数,否则会导致编译失败。

最后,让我们用= delete来解决禁止重载函数的继承问题,这里只需要对基类Base稍作修改即可:

class Base {
//	void foo(long &); 
public:
	void foo(long &) = delete;	// 删除 foo(long &) 函数
	void foo(int) {}
};

class Derived : public Base {
public:
	using Base::foo;
	void foo(const char *) {}
};

int main()
{
	Derived d;
	d.foo("hello");
	d.foo(5);
}

请注意,上面对代码做了两处修改。第一是将foo(long &)函数从private移动到public,第二是使用= delete来显式删除该函数。如果只是显式删除了函数,却没有将函数移动到public,那么编译还是会出错的。

PS:这个地方我没有理解书中作者想表达的含义,因为如果只是将foo(long &)函数从private移动到public的话,也可以使上面代码通过编译,可以正常运行,那添加= delete来显式删除该函数显得没有必要了。即如下代码可以正常编译运行:

class Base {
public:
	void foo(long &); 
	void foo(int) {}
};

class Derived : public Base {
public:
	using Base::foo;
	void foo(const char *) {}
};

int main()
{
	Derived d;
	d.foo("hello");
	d.foo(5);
}

3. 显式删除的其他用法

显式删除不仅适用于类的成员函数,对于普通函数同样有效。只不过相对于应用于成员函数,应用于普通函数的意义就不大了:

void foo() = delete;
static void bar() = delete;

int main()
{
	bar(); // 编译失败,函数已经被显式删除
	foo(); // 编译失败,函数已经被显式删除
}

另外,显式删除还可以用于类的new运算符和类析构函数。显式删除特定类的new运算符可以阻止该类在堆上动态创建对象,换句话说它可以限制类的使用者只能通过自动变量、静态变量或者全局变量的方式创建对象,例如:

#include <cstddef>

struct type
{
	void * operator new(std::size_t) = delete;
};

type global_var;

int main()
{
	static type static_var;
	type auto_var;
	type *var_ptr = new type;	// 编译失败,该类的 new 已被删除
}

显式删除类的析构函数在某种程度上和删除 new 运算符的目的正好相反,它阻止类通过自动变量、静态变量或者全局变量的方式创建对象,但是却可以通过 new 运算符创建对象。原因是删除析构函数后,类无法进行析构。所以像自动变量、静态变量或者全局变量这种会隐式调用析构函数的对象就无法创建了,当然了,通过 new 运算符创建的对象也无法通过 delete 销毁,例如:

struct type 
{
	~type() = delete;
};
type global_var;			// 编译失败,析构函数被删除无法隐式调用

int main()
{
	static type static_var;	// 编译失败,析构函数被删除无法隐式调用
	type auto_var;			// 编译失败,析构函数被删除无法隐式调用
	type *var_ptr = new type; // 可以被 new
	delete var_ptr;			// 编译失败,析构函数被删除无法显式调用
}

通过上面的代码可以看出,只有new创建对象会成功,其他创建和销毁操作都会失败,所以这样的用法并不多见,大部分情况可能在单例模式中出现。

4. explicit 和 =delete

在类的构造函数上同时使用explicit=delete是一个不明智的做法,它常常会造成代码行为混乱难以理解,应尽量避免这样做。下面这个例子就是反面教材:

struct type
{
	type(long long) {}
	explicit type(long) = delete;
};
void foo(type) {}

int main()
{
	foo(type(58)); // 编译报错
	foo(58);
}

foo(type(58)) 会造成编译失败,原因是 type(58) 显式调用了构造函数,但是 explicit type(long) 却被删除了。foo(58) 可以通过编译,因为编译器会选择 type(long long) 来构造对象。虽然原因解释得很清楚,但是建议还是不要这么使用,因为这样除了让人难以理解外,没有实际作用。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

川峰

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值