推广qq群的网站,seo关键词排名软件流量词,微信里的商家链接网站怎么做的,wordpress出不来安装界面一、新基础类型#xff08;C11#xff5e;C20#xff09;
C基础类型回顾一览表 1. 整数类型 long long
我们知道long通常表示一个32位整型#xff0c;而long long则是用来表示一个64位的整型。不得不说#xff0c;这种命名方式简单粗暴。不仅写法冗余#xff0c;而且表…一、新基础类型C11C20
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 int、unsigned long long int等类型实际上它们和long long、unsigned long long具有相同的含义。C标准还为其定义LL和ULL作为这两种类型的字面量后缀所以在初始化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 值为 42949672960x100000000
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 longC标准必须为它配套地加入整型的大小限制。在头文件中增加了以下宏分别代表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 long和unsigned 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_limitslong long::max() std::numeric_limitslong long::max() std::endl;std::cout std::numeric_limitslong long::min() std::numeric_limitslong long::min() std::endl;std::cout std::numeric_limitsunsigned long long::max() std::numeric_limitsunsigned 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_limitslong long::max() 9223372036854775807
std::numeric_limitslong long::min() -9223372036854775808
std::numeric_limitsunsigned 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
在C11标准中添加两种新的字符类型char16_t和char32_t它们分别用来对应Unicode字符集的UTF-16和UTF-32两种编码方法。在正式介绍它们之前需要先弄清楚字符集和编码方法的区别。
字符集和编码方法
通常我们所说的字符集是指系统支持的所有抽象字符的集合通常一个字符集的字符是稳定的。而编码方法是利用数字和字符集建立对应关系的一套方法这个方法可以有很多种比如Unicode字符集就有UTF-8、UTF-16和UTF-32这3种编码方法。除了Unicode字符集我们常见的字符集还包括ASCII字符集、GB2312字符集、BIG5字符集等它们都有各自的编码方法。字符集需要和编码方式对应如果这个对应关系发生了错乱那么我们就会看到计算机世界中令人深恶痛绝的乱码。
不过现在的计算机世界逐渐达成了一致就是尽量以 Unicode 作为字符集标准那么剩下的工作就是处理 UTF-8、UTF-16 和 UTF-32 这 3 种编码方法的问题了。
UTF-8、UTF-16 和 UTF-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 ~ 0x10FFFF 的 Unicode 字符集映射方法为字符编码减去 0x10000 后剩下的 20 比特位分为高位和低位高 10 位的映射范围为 0xD800 ~ 0xDBFF低 10 位的映射范围为 0xDC00 ~ 0xDFFF。例如 0x10437减去 0x10000 后的高低位分别为 0x1 和 0x37分别加上 0xD800 和 0xDC00 的结果是 0xD801 和 0xDC37。
幸运的是一般情况下 0xFFFF 足以覆盖日常字符需求我们也不必为了 UTF-16 的特殊编码方法而烦恼。 UTF-16 编码的优势是可以用固定长度的编码表达常用的字符所以计算字符长度和查找字符也比较方便。另外在内存空间使用上也比 UTF-32 好得多。
最后说一下我们最常用的 UTF-8 编码方法它是一种可变长度的编码方法。由于 UTF-8 编码方法只占用 8 比特位1 字节因此要表达完数量高达 0x10FFFF 的字符集它采用了一种前缀编码的方法。这个方法可以用 1 ~ 4 字节表示字符个数为 0x10FFFF 的 UnicodeISO 10646字符集。为了尽量节约空间常用的字符通常用 1 ~ 2 字节就能表达其他的字符才会用到 3 ~ 4 字节所以在内存空间可以使用 UTF-8但是计算字符串长度和查找字符在 UTF-8 中却是一个令人头痛的问题。表1-1展示了 UTF-8 对应的范围。 使用新字符类型 char16_t 和 char32_t
对于 UTF-8 编码方法而言普通类型似乎是无法满足需求的毕竟普通类型无法表达变长的内存空间。所以一般情况下我们直接使用基本类型 char 进行处理而过去也没有一个针对 UTF-16 和 UTF-32 的字符类型。到了 C11char16_t 和 char32_t 的出现打破了这个尴尬的局面。除此之外 C11 标准还为3种编码提供了新前缀用于声明3种编码字符和字符串的字面量它们分别是 UTF-8 的前缀 u8、 UTF-16 的前缀 u 和 UTF-32 的前缀 U
char utf8c u8a; // C17标准
//char utf8c u8好;
char16_t utf16c u好;
char32_t utf32c U好;
char utf8[] u8你好世界;
char16_t utf16[] u你好世界;
char32_t utf32[] U你好世界;在上面的代码中分别使用UTF-8、UTF-16和UTF-32编码的字符和字符串对变量进行了初始化代码很简单不过还是有两个地方值得一提。 char utf8c u8a在 C11 标准中实际上是无法编译成功的因为在 C11 标准中u8只能作为字符串字面量的前缀而无法作为字符的前缀。这个问题直到 C17 标准才得以解决所以上述代码需要 C17 的环境来执行编译。 char utf8c u8好是无法通过编译的因为存储“好”需要3字节显然utf8c只能存储1字节所以会编译失败。
wchar_t 存在的问题
在 C98 的标准中提供了一个wchar_t字符类型并且还提供了前缀L用它表示一个宽字符。事实上 Windows 系统的 API 使用的就是wchar_t它在 Windows 内核中是一个最基础的字符类型
HANDLE CreateFileW(LPCWSTR lpFileName,…
);
CreateFileW(Lc:\\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_t和char32_t的出现解决了这个问题它们明确规定了其所占内存空间的大小让代码在任何平台上都能够有一致的表现。
新字符串连接
由于字符类型增多因此我们还需要了解一下字符串连接的规则
如果两个字符串字面量具有相同的前缀则生成的连接字符串字面量也具有该前缀如表1-2所示。如果其中一个字符串字面量没有前缀则将其视为与另一个字符串字面量具有相同前缀的字符串字面量其他的连接行为由具体实现者定义。另外这里的连接操作是编译时的行为而不是一个转换。 需要注意的是进行连接的字符依然是保持独立的也就是说不会因为字符串连接将两个字符合并为一个例如连接\xA “B的结果应该是”\nB换行符和字符B而不是一个字符\xAB。
库对新字符类型的支持
随着新字符类型加入C11标准相应的库函数也加入进来。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 编码字符互转。在 C11 中我们可以通过包含 cuchar 来使用这 4 个函数。当然 C11 中也添加了 C 风格的转发方法 std::wstring_convert 以及 std::codecvt。使用类模板 std::wstring_convert 和 std::codecvt 相结合可以对多字节字符串和宽字符串进行转换。不过它们在 C17 标准中已经不被推荐使用了所以应该尽量避免使用它们
除此之外C标准库的字符串也加入了对新字符类型的支持例如
using u16string basic_string;
using u32string basic_string;
using wstring basic_string;3. char8_t 字符类型
使用char类型来处理UTF-8字符虽然可行但是也会带来一些困扰比如当库函数需要同时处理多种字符时必须采用不同的函数名称以区分普通字符和UTF-8字符。C20 标准新引入的类型char8_t可以解决以上问题它可以代替char作为UTF-8的字符类型。char8_t具有和unsigned char相同的符号属性、存储大小、对齐方式以及整数转换等级。引入char8_t类型后在 C17 环境下可以编译的UTF-8字符相关的代码会出现问题例如
char str[] u8text; // C17编译成功C20编译失败需要char8_t
char c u8c;当然反过来也不行
char8_t c8a[] text; // C20编译失败需要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_t、char16_t 和 char32_t 字符类型。虽说这些新的基础类型非常简单但是磨刀不误砍柴工掌握新基础类型尤其是 3 种不同的 Unicode 字符类型会让我们在使用 C 处理字符、字符串以及文本方面更加游刃有余。比如当你正在为处理文本文件中 UTF-32 编码的字符而头痛时采用新标准中 char32_t 和 u32string 也许会让问题迎刃而解。
二、内联和嵌套命名空间C11C20
1. 内联命名空间的定义和使用
开发一个大型工程必然会有很多开发人员的参与也会引入很多第三方库这导致程序中偶尔会碰到同名函数和类型造成编译冲突的问题。为了缓解该问题对开发的影响我们需要合理使用命名空间。程序员可以将函数和类型纳入命名空间中这样在不同命名空间的函数和类型就不会产生冲突当要使用它们的时候只需打开其指定的命名空间即可例如
namespace S1 {void foo() {}
}
namespace S2 {void foo() {}
}
using namespace S1;
int main()
{foo();S2::foo();
}以上是命名空间的一个典型例子例子中命名空间 S1 和 S2 都有相同的函数 foo在调用两个函数时由于命名空间 S1 被 using 关键字打开因此 S1 的 foo 函数可以直接使用而 S2 的 foo 函数需要使用 :: 来指定函数的命名空间。
C11 标准增强了命名空间的特性提出了内联命名空间的概念。内联命名空间能够把空间内函数和类型导出到父命名空间中这样即使不指定子命名空间也可以使用其空间内的函数和类型了比如
#include iostreamnamespace 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 不是一个内联命名空间所以调用 Child1 的 foo 函数需要明确指定所属命名空间。而调用 Child2 的 foo 函数则方便了许多直接指定父命名空间即可。现在问题来了这个新特性的用途是什么呢这里删除内联命名空间将 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 的内联命名空间并且将命名空间 V2 的 inline 关键字删除。请注意示例代码中只能有一个内联命名空间否则编译时会造成二义性问题编译器不知道使用哪个内联命名空间的 foo 函数。
2. 嵌套命名空间的简化语法
有时候打开一个嵌套命名空间可能只是为了向前声明某个类或者函数但是却需要编写冗长的嵌套代码加入一些无谓的缩进这很难让人接受。幸运的是C17标准允许使用一种更简洁的形式描述嵌套命名空间例如
namespace A::B::C {int foo() { return 5; }
}以上代码等同于
namespace A {namespace B {namespace C {int foo() { return 5; }}}
}很显然前者是一种更简洁的定义嵌套命名空间的方法。除简洁之外它也更加符合我们已有的语法习惯比如嵌套类
std::vectorint::iterator it;实际上这份语法规则的提案早在2003年的时候就已经提出只不过到C17才被正式引入标准。另外有些遗憾的是在C17标准中没有办法简洁地定义内联命名空间这个问题直到C20标准才得以解决。在C20中我们可以这样定义内联命名空间
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 占位符C11C17
1. 重新定义的 auto 关键字
严格来说auto并不是一个新的关键字因为它从C98标准开始就已经存在了。当时auto是用来声明自动变量的简单地说就是拥有自动生命期的变量显然这是多余的现在我们几乎不会使用它。于是C11标准赋予了auto新的含义声明变量时根据初始化表达式自动推断该变量的类型、声明函数时函数返回值的占位符。例如
auto i 5; // 推断为 int
auto str hello auto; // 推断为 const char*
auto sum(int a1, int a2)-int // 返回类型后置auto 为返回值占位符
{return a1a2;
}在上面的代码中我们不需要为i和str去声明具体的类型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虽然C11标准已经支持在声明成员变量时初始化但是auto却无法在这种情况下声明非静态成员变量
struct sometype {auto i 5; // 错误无法编译通过
};在C11中静态成员变量是可以用auto声明并且初始化的不过前提是auto必须使用const限定符
struct sometype {static const auto i 5;
};遗憾的是const限定符会导致i常量化显然这不是我们想要的结果。幸运的是在C17标准中对于静态成员变量auto可以在没有const的情况下使用例如
struct sometype {static inline auto i 5; // C17
};4按照C20之前的标准无法在函数形参列表中使用auto声明形参注意在C14中auto可以为lambda表达式声明形参
void echo(auto str) {…} // C20之前编译失败C20编译成功另外auto也可以和new关键字结合。当然我们通常不会这么用例如
auto i new auto(5);
auto* j new auto(5);这种用法比较有趣编译器实际上进行了两次推导第一次是auto(5)auto被推导为int类型于是new int的类型为int *再通过int *推导i和j的类型。我不建议像上面这样使用auto因为它会破坏代码的可读性。在后面的内容中我们将讨论应该在什么时候避免使用auto关键字。
2. 推导规则
1如果auto声明的变量是按值初始化则推导出的类型会忽略cv限定符。进一步解释为在使用auto声明变量时既没有使用引用也没有使用指针那么编译器在推导的时候会忽略const和volatile限定符。当然auto本身也支持添加cv限定符
const int i 5;
auto j i; // auto 推导类型为 int而非 const int
auto m i; // auto 推导类型为 const intm 推导类型为 const int
auto *k i; // auto 推导类型为 const intk 推导类型为 const int*
const auto n j; // auto 推导类型为 intn 的类型为 const int根据规则1在上面的代码中虽然i是const int类型但是因为按值初始化会忽略cv限定符所以j的推导类型是int而不是const int。而m和k分别按引用和指针初始化因此其cv属性保留了下来。另外可以用const结合auto让n的类型推导为const int。
2使用auto声明变量初始化时目标对象如果是引用则引用属性会被忽略
int i 5;
int j i;
auto m j; // auto 推导类型为 int而非 int根据规则2虽然j是i的引用类型为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关键字与列表初始化组合时这里的规则有新老两个版本这里只介绍新规则C17标准。
① 直接使用列表初始化列表中必须为单元素否则无法编译auto类型被推导为单元素的类型。② 用等号加列表初始化列表中可以包含单个或者多个元素auto类型被推导为std::initializer_listT其中T是元素类型。请注意在列表中包含多个元素的时候元素的类型必须相同否则编译器会报错。
auto x5{ 3 }; // x5 类型为 int
auto x3{ 1, 2 }; // 编译失败不是单个元素auto x1 { 1, 2 }; // x1 类型为 std::initializer_listint
auto x2 { 1, 2.0 }; // 编译失败花括号中元素类型不同
auto x4 { 3 }; // x4 类型为 std::initializer_listint在上面的代码中x1根据规则5② 被推导为std::initializer_listT其中的元素都是int类型所以x1被推导为std::initializer_listint。同样x2也应该被推导为std::initializer_listT但是显然两个元素类型不同导致编译器无法确定T的类型所以编译失败。根据规则5①x3包含多个元素直接导致编译失败。x4和x1一样被推导为std::initializer_listTx5被推导为单元素的类型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();以上代码有Derived和Base之间的继承关系并且Derived重写了Base的f函数。代码使用new创建了一个Derived对象并赋值于基类的指针类型变量上。我们知道d-f()一定调用的是Derived的f函数。但是b.f()调用的又是谁的f函数呢实际上由于auto b *d这一句代码是按值赋值的因此auto会直接推导为Base。代码自然会调用Base的复制构造函数也就是说Derived被切割成了Base这里的b.f()最终调用Base的f函数。那么进一步发散如果代码写的是auto b *d结果又会如何呢auto会被推导为BaseBase等价于new Derived()因此会调用Derived::f()
在 CLion 中编辑器会给出推断类型的提示 3. 什么时候使用 auto
合理使用 auto可以让程序员从复杂的类型编码中解放出来不但可以少敲很多代码也会大大提高代码的可读性。但是事情总是有它的两面性如果滥用auto则会让代码失去可读性不仅让后来人难以理解间隔时间长了可能自己写的代码也要研读很久才能弄明白其含义。
所以下面我们来探讨一下如何合理地使用auto。
当一眼就能看出声明变量的初始化类型的时候可以使用auto。对于复杂的类型例如lambda表达式、bind等直接使用auto。
对于第一条规则常见的是在容器的迭代器上使用例如
std::mapstd::string, int str2int;
// … 填充 str2int 的代码
for (std::mapstd::string, int::const_iterator it str2int.cbegin();it ! str2int.cend(); it)
{....
}
// 或者
for (std::pairconst std::string, int it : str2int)
{....
}上面的代码如果不用auto来声明迭代器那么我们需要编写std::map std::string, int::const_iterator和std::pairconst std::string, int来代替auto而多出来的代码并不会增强代码的可读性反而会让代码看起来冗余因为通常我们一眼就能看明白it的具体类型。请注意第二个for的it类型是std::pairconst std::string, int而不是std::pairstd:: string, int如果写成后者是无法通过编译的。直接使用auto可以避免上述问题
std::mapstd::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::_Binderstd::_Unforced,int( cdecl) (int,int),int, const std::_Ph1 绝大多数读者看到这种类型时会默契地选择使用auto来声明变量。
4. 返回类型推导
C14标准支持对返回类型声明为auto的推导例如
auto sum(int a1, int a2) { return a1 a2; }在上面的代码中编译器会帮助我们推导sum的返回值由于a1和a2都是int类型所以其返回类型也是int于是返回类型被推导为int类型。请注意如果有多重返回值那么需要保证返回值类型是相同的。例如
auto sum(long a1, long a2)
{if (a1 0) {return 0; // 返回int类型}else {return a1 a2; // 返回long类型}
}以上代码中有两处返回return 0 返回的是 int 类型而 return a1a2返回的是long类型这种不同的返回类型会导致编译失败。
5. lambda 表达式中使用 auto 类型推导
在C14标准中我们还可以把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. 非类型模板形参占位符
C17标准对auto关键字又一次进行了扩展使它可以作为非类型模板形参的占位符。当然我们必须保证推导出来的类型是可以用作模板形参的否则无法通过编译例如
#include iostream
templateauto N
void f()
{std::cout N std::endl;
}
int main()
{f5(); // N 为 int 类型fc(); // N 为 char 类型f5.0(); // 编译失败模板参数不能为 double
}在上面的代码中函数f5()中5的类型为int所以auto被推导为int类型。同理fc()的auto被推导为char类型。由于f5.0()的5.0被推导为double类型但是模板参数不能为double类型因此导致编译失败。
四、decltype说明符C11C17
1. 回顾 typeof 和 typeid 获取类型
在C11标准发布以前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点也需要注意。
typeid的返回值是一个左值且其生命周期一直被扩展到程序生命周期结束。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*typeid的返回值总是忽略类型的 cv 限定符也就是typeid(const T) typeid(T))。
虽然typeid可以获取类型信息并帮助我们判断类型之间的关系但遗憾的是它并不能像typeof那样在编译期就确定对象类型。
2. 使用 decltype 说明符获取类型
为了用统一方法解决上述问题C11标准引入了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;
}以上代码以 C11 为标准该标准中 auto 作为占位符并不能使编译器对函数返回类型进行推导必须使用返回类型后置的形式指定返回类型。如果接下来想泛化这个函数让其支持各种类型运算应该怎么办由于形参不能声明为 auto因此我们需要用到函数模板
templateclass T
T sum(T a1, T a2)
{return a1 a2;
}
auto x1 sum(5, 10)代码看上去很好但是并不能适应所有情况因为调用者如果传递不同类型的实参则无法编译通过
auto x2 sum(5, 10.5); // 编译失败无法确定 T 的类型既然如此我们只能编写一个更加灵活的函数模板
templateclass R, class T1, class T2
R sum(T1 a1, T2 a2)
{return a1 a2;
}
auto x3 sumdouble(5, 10.5);不错这样好像可以满足我们泛化sum函数的要求了。但美中不足的是我们必须为函数模板指定返回值类型。为了让编译期完成所有的类型推导工作我们决定继续优化函数模板
templateclass T1, class T2
auto sum(T1 a1, T2 a2)-decltype(a1 a2)
{return a1 a2;
}
auto x4 sum(5, 10.5);decltype 终于登场了可以看到它完美地解决了之前需要指定返回类型的问题。解释一下这段代码auto 是返回类型的占位符参数类型分别是 T1 和 T2我们利用 decltype 说明符能推断表达式的类型特性在函数尾部对 auto 的类型进行说明如此一来在实例化 sum 函数的时候编译器就能够知道 sum 的返回类型了。 注意形参也是有作用域的它只能按顺序访问即放在后面的可以访问放在前面的这也是为什么decltype(a1 a2)要放在后面而不是放在前面 上述用法只推荐在C11标准的编译环境中使用因为C14标准已经支持对auto声明的返回类型进行推导了所以以上代码可以简化为
templateclass T1, class T2
auto sum(T1 a1, T2 a2)
{return a1 a2;
}
auto x5 sum(5, 10.5);讲到这里读者肯定有疑问了在C14中decltype的作用又被auto代替了。是否从C14标准以后decltype就没有用武之地了呢
并不是这样的auto作为返回类型的占位符还存在一些问题请看下面的例子
templateclass T
auto return_ref(T t)
{return t;
}
int x1 0;
static_assert(std::is_reference_vdecltype(return_ref(x1))) // 编译错误返回值不为引用类型在上面的代码中我们期望 return_ref 返回的是一个 T 的引用类型但是如果编译此段代码则必然会编译失败因为 auto 被推导为值类型这就是第3章所讲的 auto 推导规则2。如果想正确地返回引用类型则需要用到 decltype 说明符例如
templateclass T
auto return_ref(T t)-decltype(t)
{return t;
}
int x1 0;
static_assert(std::is_reference_vdecltype(return_ref(x1))); // 编译成功以上两段代码几乎相同只是在 return_ref 函数的尾部用 decltype(t) 声明了返回类型但是代码却可以顺利地通过编译。为了弄清楚编译成功的原因我们需要讨论 decltype 的推导规则。
3. 推导规则
decltype(e)其中 e 的类型为 T的推导规则有 5 条
如果 e 是一个未加括号的标识符表达式结构化绑定除外或者未加括号的类成员访问则 decltype(e) 推断出的类型是 e 的类型 T。如果并不存在这样的类型或者 e 是一组重载函数则无法进行推导。如果 e 是一个函数调用或者仿函数调用那么 decltype(e) 推断出的类型是其返回值的类型。如果 e 是一个类型为 T 的左值则 decltype(e) 是 T。如果 e 是一个类型为 T 的将亡值则 decltype(e) 是 T。除去以上情况则 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和规则4foo 函数的返回类型是 const int所以推导结果也为 const intdecltype(i) 和 decltype(a-x) 很简单满足规则1所以其类型为 int 和 double最后一句代码由于 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_castshort(i)); // decltype(static_castshort(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(i0); // decltype(i0) 推导类型为 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_castint(i)); // decltype(static_castint(i)) 推导类型为 int
decltype(i); // decltype(i) 推导类型为 int
decltype(i); // decltype(i) 推导类型为 int
decltype(hello world); // decltype(hello world) 推导类型为 const char()[12]让我们来看一看上面代码中的例子都是怎么推导出来的
可以确认以上例子中的表达式都不是标识符表达式这样就排除了规则1。i0 和 0, i 表达式都返回左值 i所以推导类型为 int。i, 0 表达式返回 0所以推导类型为 int。n[5] 返回的是数组 n 中的第6个元素也是左值所以推导类型为 int。*j 很明显也是一个左值所以推导类型也为 int。static_castint(i) 被转换为一个将亡值类型所以其推导类型为 int。i 和 i 分别返回右值和左值所以推导类型分别为 int 和 int。hello world 是一个常量数组的左值其推导类型为 const char()[12]。
4. cv 限定符的推导
通常情况下decltype(e) 所推导的类型会同步 e 的 cv 限定符比如
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 类型如果想在代码中改变 a 中 x 的值则肯定会编译失败。但是 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)
在 C14 标准中出现了 decltype 和 auto 两个关键字的结合体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_listint
decltype(auto) x4d { 1, 2 }; // 编译失败, {1, 2} 不是表达式
auto *x5a i; // x5a 推导类型为 int*
decltype(auto)*x5d i; // 编译失败, decltype(auto) 必须单独声明观察上面的代码可以发现auto 和 decltype(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)组合我们可以进一步简化代码消除返回类型后置的语法例如
templateclass T
decltype(auto) return_ref(T t)
{return t;
}
int x1 0;
static_assert(std::is_reference_vdecltype(return_ref(x1))); // 编译成功6. decltype(auto) 作为非类型模板形参占位符
与auto一样在C17标准中decltype(auto)也能作为非类型模板形参的占位符其推导规则和上面介绍的保持一致例如
#include iostream
templatedecltype(auto) N
void f()
{std::cout N std::endl;
}
static const int x 11;
static int y 7;
int main()
{fx(); // N 为 const int 类型f(x)(); // N 为 const int 类型fy(); // 编译错误f(y)(); // N 为 int 类型
}在上面的代码中x 的类型为 const int所以 fx() 推导出 N 为 const int 类型这里和 auto 作为占位符的结果是一样的f(x)() 则不同推导出的 N 为 const int 类型符合 decltype(auto) 的推导规则。另外fy() 会导致编译出错因为 y 不是一个常量所以编译器无法对函数模板进行实例化。而 f(y)() 则没有这种问题因为 (y) 被推断为引用类型恰好对于静态对象而言内存地址是固定的所以可以顺利地通过编译最终 N 被推导为 int 类型。
总结
decltype 和 auto 的使用方式有一些相似之处但是推导规则却有所不同理解起来有一定难度。不过幸运的是大部分情况下推导结果能够符合我们的预期。另外从上面的示例代码来看在通常的编程过程中并不会存在太多使用 decltype 的情况。实际上 decltype 说明符对于库作者更加实用。因为它很大程度上加强了C的泛型能力比如利用 decltype 和 SFINAE 特性让编译器自动选择正确的函数模板进行调用等当然这些是比较高级的话题了有兴趣的读者可以提前翻阅第40章的内容。
五、函数返回类型后置C11
1. 使用函数返回类型后置声明函数
前面已经出现了函数返回类型后置的例子接下来我们将详细讨论C11标准中的新语法特性
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. 推导函数模板返回类型
C11标准中函数返回类型后置的作用之一是推导函数模板的返回类型当然前提是需要用到decltype说明符例如
templateclass T1, class T2
auto sum1(T1 t1, T2 t2)-decltype(t1 t2)
{return t1 t2;
}
int main() {auto x1 sum1(4, 2);
}在上面的代码中函数模板 sum1 有两个模板形参 T1 和 T2它们分别是函数形参 t1 和 t2 的类型。为了让 sum1 函数的返回类型由实参自动推导这里需要使用函数返回类型后置来指定 decltype 说明符推导类型作为函数的返回类型。
请注意decltype(t1 t2) 不能写在函数声明前编译器在解析返回类型的时候还没解析到参数部分所以它对 t1 和 t2 一无所知自然会编译失败
decltype(t1 t2) auto sum1(T1 t1, T2 t2) {…} // 编译失败无法识别 t1 和 t2实际上在C11标准中只用decltype关键字也能写出自动推导返回类型的函数模板但是函数可读性却差了很多以下是最容易理解的写法
templateclass 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() 表达式使用了 T1 和 T2 类型的默认构造函数因此编译器要求 T1 和 T2 的默认构造函数必须存在否则会编译失败比如
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() 时会报错。为了解决这个问题需要既可以在表达式中让 T1 和 T2 两个对象求和又不用使用其构造函数方法于是就有了以下两个函数模板
templateclass T1, class T2
decltype(*static_castT1 *(nullptr) *static_castT2 *(nullptr))
sum3(T1 t1, T2 t2)
{return t1 t2;
}templateclass T
T declval();templateclass T1, class T2
decltype(declvalT1() declvalT2()) sum4(T1 t1, T2 t2)
{return t1 t2;
}int main() {sum3(IntWrap(1), IntWrap(2));sum4(IntWrap(1), IntWrap(2));
}在上面的代码中函数模板 sum3 使用指针类型转换和解引用求和的方法推导返回值其中 *static_castT1*(nullptr) *static_castT2*(nullptr) 分别将 nullptr 转换为 T1 和 T2 的指针类型然后解引用求和最后利用 decltype 推导出求和后的对象类型。由于编译器不会真的计算求值因此这里求和操作不会有问题。
函数模板 sum4 利用了另外一个技巧与 sum3 本质上相似。在标准库中提供了一个 std::declval 函数模板声明没有具体实现它将类型 T 转换成引用类型这样在使用 decltype 推导表达式类型时不必经过构造函数检查。由于标准库中 std::declval 的实现比较复杂因此我在这里实现了一个简化版本。declvalT1() declvalT2() 表达式分别通过 declval 将 T1 和 T2 转换为引用类型并且求和最后通过 decltype 推导返回类型。
可以看出虽然这两种方法都能达到函数返回类型后置的效果但是它们在实现上更加复杂同时要理解它们也必须有一定的模板元编程的知识。为了让代码更容易被其他人阅读和理解还是建议使用函数返回类型后置的方法来推导返回类型。
总结
本章介绍了C11标准中的函数返回类型后置语法通过这种方法可以让返回复杂类型的函数声明更加清晰易读。在无法使用C14以及更新标准的情况下通过返回类型后置语法来推导函数模板的返回类型无疑是最便捷的方法。
六、右值引用C11 C17 C20
1. 左值和右值
左值和右值的概念早在C98的时候就已经出现了从最简单的字面理解无非是表达式等号左边的值为左值而表达式右边的值为右值比如
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都是符合语法规则的所以a和b都是左值而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;这一点非常容易被忽略因为经验告诉我们上面的代码中前两行的 1 和 6 都是右值因为不存在 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 11 和 const 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 k i; // 编译报错左值引用无法引用右值 i右值引用的特点之一是可以延长右值的生命周期这个对于字面量 11 可能看不出效果那么请看下面的例子
#include iostreamclass X {
public:X() { std::cout X ctor std::endl; }X(const Xx) { 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 次复制构造函数。
get_pool 函数中返回的 BigMemoryPool 临时对象调用复制构造函数复制了 pool 对象。make_pool 函数中返回的 BigMemoryPool 临时对象调用复制构造函数复制了 get_pool 返回的临时对象。main 函数中 my_pool 调用其复制构造函数复制 make_pool 返回的临时对象。
该代码从正确性上看毫无问题但是从运行性能的角度上看却还有巨大的优化空间。在这里每发生一次复制构造都会复制整整4KB的数据如果数据量更大一些比如4MB或者400MB那么将对程序性能造成很大影响。
5. 移动语义
仔细分析上面代码中 3 次复制构造函数的调用不难发现第二次和第三次的复制构造是影响性能的主要原因。在这个过程中都有临时对象参与进来而临时对象本身只是做数据的复制。如果有办法能将临时对象的内存直接转移到 my_pool 对象中不就能消除内存复制对性能的消耗吗好消息是在C11标准中引入了移动语义它可以帮助我们将临时对象的内存移动到 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::durationdouble 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()调用了移动赋值运算符函数这里的规则和构造函数一样即编译器对于赋值源对象是右值的情况会优先调用移动赋值运算符函数如果该函数不存在则调用复制赋值运算符函数。
最后有两点需要说明一下。
同复制构造函数一样编译器在一些条件下会生成一份移动构造函数这些条件包括没有任何的复制函数包括复制构造函数和复制赋值函数没有任何的移动函数包括移动构造函数和移动赋值函数也没有析构函数。虽然这些条件严苛得让人有些不太愉快但是我们也不必对生成的移动构造函数有太多期待因为编译器生成的移动构造函数和复制构造函数并没有什么区别。虽然使用移动语义在性能上有很大收益但是却也有一些风险这些风险来自异常。试想一下在一个移动构造函数中如果当一个对象的资源移动到另一个对象时发生了异常也就是说对象的一部分发生了转移而另一部分没有这就会造成源对象和目标对象都不完整的情况发生这种情况的后果是无法预测的。所以在编写移动语义的函数时建议确保函数不会抛出异常与此同时如果无法保证移动构造函数不会抛出异常可以使用noexcept说明符限制该函数。这样当函数抛出异常的时候程序不会再继续执行而是调用std::terminate中止执行以免造成其他不良影响。
6. 值类别
到目前为止一切都非常容易理解其中一个原因是我在前面的内容中隐藏了一个概念。但是在进一步探讨右值引用之前我们必须先掌握这个概念——值类别。值类别是C11标准中新引入的概念具体来说它是表达式的一种属性该属性将表达式分为3个类别它们分别是左值lvalue、纯右值prvalue和将亡值xvalue如图6-1所示。从前面的内容中我们知道早在C98的时候已经有了一些关于左值和右值的概念了只不过当时这些概念对于C程序编写并不重要。但是由于C11中右值引用的出现值类别被赋予了全新的含义。可惜的是在C11标准中并没能够清晰地定义它们比如在C11的标准文档中左值的概念只有一句话“指定一个函数或一个对象”这样的描述显然是不清晰的。这种糟糕的情况一直延续到C17标准的推出才得到解决。所以现在是时候让我们重新认识这些概念了。 表达式首先被分为了泛左值glvalue) 和右值rvalue其中泛左值被进一步划分为左值和将亡值右值又被划分为将亡值和纯右值。理解这些概念的关键在于泛左值、纯右值和将亡值。
所谓泛左值是指一个通过评估能够确定对象、位域或函数的标识的表达式。简单来说它确定了对象或者函数的标识具名对象。而纯右值是指一个通过评估能够用于初始化对象和位域或者能够计算运算符操作数的值的表达式。将亡值属于泛左值的一种它表示资源可以被重用的对象和位域通常这是因为它们接近其生命周期的末尾另外也可能是经过右值引用的转换产生的。
剩下的两种类别就很容易理解了其中左值是指非将亡值的泛左值而右值则包含了纯右值和将亡值。再次强调值类别都是表达式的属性所以我们常说的左值和右值实际上指的是表达式不过为了描述方便我们常常会忽略它。
是不是感觉有点晕。相信我当我第一次看到这些概念的时候也是这个反应。不过好在我们对传统左值和右值的概念已经了然于心了现在只需要做道连线题就能弄清楚它们的概念。实际上这里的左值lvalue就是我们上文中描述的C98的左值而这里的纯右值prvalue则对应上文中描述的C98的右值。最后我们惊喜地发现现在只需要弄清楚将亡值xvalue到底是如何产生的就可以了。
从本质上说产生将亡值的途径有两种第一种是使用类型转换将泛左值转换为该类型的右值引用。比如
static_castBigMemoryPool(my_pool)第二种在C17标准中引入我们称它为临时量实质化指的是纯右值转换到临时对象的过程。每当纯右值出现在一个需要泛左值的地方时临时量实质化都会发生也就是说都会创建一个临时对象并且使用纯右值对其进行初始化这也符合纯右值的概念而这里的临时对象就是一个将亡值。
struct X {int a;
};
int main()
{int b X().a;
}在上面的代码中X() 是一个纯右值访问其成员变量 a 却需要一个泛左值所以这里会发生一次临时量实质化将 X() 转换为将亡值最后再访问其成员变量 a。还有一点需要说明在C17标准之前临时变量是纯右值只有转换为右值引用的类型才是将亡值。
在本节之后的内容中依然会以左值和右值这样的术语为主。但是读者应该清楚这里的左值是C17中的左值lvalue右值是C17中的纯右值prvalue和将亡值xvalue。对于将亡值xvalue读者实际上只需要知道它是泛左值和右值交集即可后面的内容也不会重点强调它所以不会影响到读者对后续内容的理解。
7. 将左值转换为右值
在前面提到过右值引用只能绑定一个右值如果尝试绑定左值会导致编译错误
int i 0;
int k i; // 编译失败不过如果想完成将右值引用绑定到左值这个“壮举”还是有办法的。在C11标准中可以在不创建临时值的情况下显式地将左值通过static_cast转换为将亡值通过值类别的内容我们知道将亡值属于右值所以可以被右值引用绑定。值得注意的是由于转换的并不是右值因此它依然有着和转换之前相同的生命周期和内存地址例如
int i 0;
int k static_castint(i); // 编译成功读者在这里应该会有疑问既然这个转换既不改变生命周期也不改变内存地址那它有什么存在的意义呢实际上它的最大作用是让左值使用移动语义还是以BigMemoryPool为例
BigMemoryPool my_pool1;
BigMemoryPool my_pool2 my_pool1;
BigMemoryPool my_pool3 static_castBigMemoryPool (my_pool1);在这段代码中my_pool1 是一个 BigMemoryPool 类型的对象也是一个左值所以用它去构造 my_pool2 的时候调用的是复制构造函数。为了让编译器调用移动构造函数构造 my_pool3这里使用了 static_castBigMemoryPool (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_castBigMemoryPool(pool));
}copy big memory pool.
move big memory pool.
call move_pool
move big memory pool.请注意在这个场景下强制转换为右值就没有任何问题了因为move_pool函数的实参是make_pool返回的临时对象当函数调用结束后临时对象就会被销毁所以转移其内存数据不会存在任何问题。
在C11的标准库中还提供了一个函数模板 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 节提到过常量左值引用既可以引用左值又可以引用右值是一个几乎万能的引用但可惜的是由于其常量性导致它的使用范围受到一些限制。其实在C11中确实存在着一个被称为“万能”的引用它看似是一个右值引用但其实有着很大区别请看下面的代码
void foo(int i) {} // i 为右值引用templateclass 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 则是万能引用。我们知道右值引用只能绑定一个右值但是万能引用既可以绑定左值也可以绑定右值甚至 const 和 volatile 的值都可以绑定例如
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 则是右值引用。在这个推导过程中初始化的源对象如果是一个左值则目标对象会推导出左值引用反之如果源对象是一个右值则会推导出右值引用不过无论如何都会是一个引用类型。
万能引用能如此灵活地引用对象实际上是因为在C11中添加了一套引用叠加推导的规则——引用折叠。在这套规则中规定了在不同的引用类型互相作用的情况下应该如何推导出最终类型如表6-1所示。 上面的表格显示了引用折叠的推导规则可以看出在整个推导过程中只要有左值引用参与进来最后推导的结果就是一个左值引用。只有实际类型是一个非引用类型或者右值引用类型时最后推导出来的才是一个右值引用。
那么这个规则是如何在万能引用中体现的呢让我们以函数模板bar为例看一下具体的推导过程。
在 bar(i); 中 i 是一个左值所以 T 的推导类型结果是 int根据引用折叠规则 T int 的最终推导类型为 int于是 bar 函数的形参是一个左值引用。而在 bar(get_val()); 中 get_val 返回的是一个右值所以 T 的推导类型为非引用类型 int根据引用折叠规则 T int于是最终的推导类型是 intbar 函数的形参成为一个右值引用。
值得一提的是万能引用的形式必须是 T 或者 auto也就是说它们必须在初始化的时候被直接推导出来如果在推导中出现中间过程则不是一个万能引用例如
#include vectortemplateclass T
void foo(std::vectorT t) {}int main()
{std::vectorint v{ 1,2,3 };foo(v); // 编译错误
}一个万能引用而是一个右值引用。因为foo的形参类型是std::vectorT而不是T所以编译器无法将其看作一个万能引用处理。
9. 完美转发
万能引用最典型的用途被称为完美转发。在介绍完美转发之前我们先看一个常规的转发函数模板
#include iostream
#include stringtemplateclass T
void show_type(T t)
{std::cout typeid(t).name() std::endl;
}templateclass 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)来解决这个问题因为常量左值引用是可以引用右值的。
templateclass 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 stringtemplateclass T
void show_type(T t)
{std::cout typeid(t).name() std::endl;
}templateclass T
void perfect_forwarding(T t)
{show_type(static_castT(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_castT(t));中的类型转换之所以这里需要用到类型转换是因为作为形参的t是左值。为了让转发将左右值的属性也带到目标函数中这里需要进行类型转换。当实参是一个左值时T被推导为std::string于是static_castT被推导为static_caststd:: string传递到show_type函数时继续保持着左值引用的属性当实参是一个右值时T被推导为std::string于是static_cast T被推导为static_caststd::string所以传递到show_type函数时保持了右值引用的属性。
和移动语义的情况一样显式使用static_cast类型转换进行转发不是一个便捷的方法。在C11的标准库中提供了一个std::forward函数模板在函数内部也是使用static_cast进行类型转换只不过使用std::forward转发语义会表达得更加清晰std::forward函数模板的使用方法也很简单
templateclass T
void perfect_forwarding(T t)
{show_type(std::forwardT(t));
}请注意std::move和std::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();同样调用的是移动构造函数。
在C20标准中隐式移动操作针对右值引用和throw的情况进行了扩展例如
#include iostream
#include stringstruct X {X() default;X(const X) default;X(X) {std::cout move;}
};X f(X x) {return x;
}int main() {X r f(X{});
}以上代码使用C20之前的标准编译是不会调用任何移动构造函数的。原因前面也解释过因为函数f的形参 x 是一个左值对于左值要调用复制构造函数。要实现移动语义需要将 return x; 修改为 return std::move(x);。显然这里是有优化空间的C20标准规定在这种情况下可以隐式采用移动语义完成赋值。具体规则如下。
可隐式移动的对象必须是一个非易失或一个右值引用的非易失自动存储对象在以下情况下可以使用移动代替复制。
return 或 co_return 语句中的返回对象是函数或 lambda 表达式中的对象或形参。throw 语句中抛出的对象是函数或 try 代码块中的对象。
实际上throw调用移动构造的情况和return差不多我们只需要将上面的代码稍作修改即可
void f() {X x;throw x;
}
int main() {try {f();}catch (…) {}
}可以看到函数f不再有返回值它通过throw抛出xmain函数用try-catch捕获f抛出的x。这个捕获调用的就是移动构造函数。
七、lambda表达式C11C20
1. lambda表达式语法
lambda表达式是现代编程语言的一个基础特性比如LISP、Python、C#等具备该特性。但是遗憾的是直到C11标准之前C都没有在语言特性层面上支持lambda表达式。程序员曾尝试使用库来实现lambda表达式的功能比如Boost.Bind或Boost.Lambda但是它们有着共同的缺点实现代码非常复杂使用的时候也需要十分小心一旦有错误发生就可能会出现一堆错误和警告信息总之其编程体验并不好。
另外虽然C一直以来都没有支持lambda表达式但是它对lambda表达式的需求却非常高。最明显的就是STL在STL中有大量需要传入谓词的算法函数比如std::find_if、std::replace_if等。过去有两种方法实现谓词函数编写纯函数或者仿函数。但是它们的定义都无法直接应用到函数调用的实参中面对复杂工程的代码我们可能需要四处切换源文件来搜索这些函数或者仿函数。
为了解决上面这些问题C11标准为我们提供了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 —— 可选限定符C11中可以用 mutable它允许我们在lambda表达式函数体内改变按值捕获的变量或者调用非const的成员函数。上面的例子中没有使用说明符。exception —— 可选异常说明符我们可以使用 noexcept 来指明lambda是否会抛出异常。对应的例子中没有使用异常说明符。ret —— 可选返回值类型不同于普通函数lambda表达式使用返回类型后置的语法来表示返回类型如果没有返回值void类型可以忽略包括-在内的整个部分。另外我们也可以在有返回值的情况下不指定返回类型这时编译器会为我们推导出一个返回类型。对应到上面的例子是 -int。{ body } —— lambda表达式的函数体这个部分和普通函数的函数体一样。对应例子中的 { return x * y; }。
细心的读者肯定发现了一个有趣的事实由于参数列表限定符以及返回值都是可选的于是我们可以写出的最简单的lambda表达式是[]{}。虽然看上去非常奇怪但它确实是一个合法的lambda表达式。需要特别强调的是上面的语法定义只属于C11标准C14和C17标准对lambda表达式又进行了很有用的扩展我们会在后面介绍。
2. 捕获列表
在lambda表达式的语法中与传统C语法差异最大的部分应该算是捕获列表了。实际上除了语法差异较大之外它也是lambda表达式中最为复杂的一个部分。接下来我们会把捕获列表分解开来逐步讨论其特性。
作用域
我们必须了解捕获列表的作用域通常我们说一个对象在某一个作用域内不过这种说法在捕获列表中发生了变化。捕获列表中的变量存在于两个作用域——lambda表达式定义的函数作用域以及lambda表达式函数体的作用域。前者是为了捕获变量后者是为了使用变量。另外标准还规定能捕获的变量必须是一个自动存储类型。简单来说就是非静态的局部变量。让我们看一看下面的例子
int x 0;
int main()
{int y 0;static int z 0;auto foo [x, y, z] {};
}以上代码可能是无法通过编译的其原因有两点第一变量x和z不是自动存储类型的变量第二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;
}在上面的代码中虽然我们没有捕获变量x和z但是依然可以使用它们。
进一步来说如果我们将一个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; };
}捕获值是将函数作用域的x和y的值复制到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表达式能够顺利地通过编译虽然其函数体内也有改变变量x和y的行为。这是因为捕获的变量默认为常量指的是变量本身当变量按值捕获的时候变量本身就是值所以改变值就会发生错误。相反在捕获引用的情况下捕获变量实际上是一个引用我们在函数体内改变的并不是引用本身而是引用的值所以并没有被编译器拒绝。
另外还记得上文提到的可选说明符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表达式成功地修改了其作用域内的x和y的值。值得注意的是函数bar3相对于函数bar1除了增加说明符mutable还多了一对()这是因为语法规定lambda表达式如果存在说明符那么形参列表不能省略。
编译运行bar2和bar3两个函数会输出相同的结果但这并不代表两个函数是等价的捕获值和捕获引用还是存在着本质区别。当lambda表达式捕获值时表达式内实际获得的是捕获变量的复制我们可以任意地修改内部捕获变量但不会影响外部变量。而捕获引用则不同在lambda表达式内修改捕获引用的变量对应的外部变量也会被修改。
#include iostreamvoid 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 8bar3 in lambda x 6 y 10
bar3 foo() 60
bar3 x 6 y 10从上面代码输出可以看出使用按值捕获mutable 的方式修改lambda内部的x和y的值不会影响外部的x和y的值而使用按引用捕获的方式修改lambda内部的x和y的值会影响外部的x和y的值。
对于捕获值的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之前分别修改了x和y的值但是捕获值的变量x依然延续着lambda定义时的值而在捕获引用的变量y被重新赋值以后lambda表达式捕获的变量y的值也跟着发生了变化。
特殊的捕获方法
lambda表达式的捕获列表除了指定捕获变量之外还有3种特殊的捕获方法。
[this] —— 捕获this指针捕获this指针可以让我们使用this类型的成员变量和函数。[] —— 捕获lambda表达式定义作用域的全部变量的值包括this。[] —— 捕获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表达式内也能访问x和y的值。同理使用[]也会有同样的效果读者不妨自己尝试一下。
3. lambda 表达式的实现原理
如果读者是一个C的老手可能已经发现lambda表达式与函数对象仿函数非常相似所以让我们从函数对象开始深入探讨lambda表达式的实现原理。请看下面的例子
#include iostreamclass 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函数中变量x和y的值并在调用之后返回相同的结果。这两者比较明显的区别如下
使用lambda表达式不需要我们去显式定义一个类这一点在快速实现功能上有较大的优势。使用函数对象可以在初始化的时候有更加丰富的操作例如Bar bar(xy, x * y)而这个操作在C11标准的lambda表达式中是不允许的。另外在Bar初始化对象的时候使用全局或者静态局部变量也是没有问题的。
这样看来在C11标准中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表达式是C11给我们提供的一块语法糖而已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::sort、std::find_if等。
在C11标准以前我们通常需要在函数外部定义一个辅助函数或辅助函数对象类型。对于简单的需求我们也可能使用STL提供的辅助函数例如std::less、std::plus等。另外针对稍微复杂一点的需求还可能会用到std::bind1st、std::bind2nd等函数。总之无论使用以上的哪种方法表达起来都相当晦涩。
幸运的是在有了lambda表达式以后这些问题就迎刃而解了。我们可以直接在STL算法函数的参数列表内实现辅助函数例如
#include iostream
#include vector
#include algorithmint main()
{std::vectorint 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. 广义捕获
C14标准中定义了广义捕获所谓广义捕获实际上是两种捕获方式第一种称为简单捕获这种捕获就是我们在前文中提到的捕获方法即[identifier]、[identifier]以及[this]等。第二种叫作初始化捕获这种捕获方式是在C14标准中引入的它解决了简单捕获的一个重要问题即只能捕获lambda表达式定义上下文的变量而无法捕获表达式结果以及自定义捕获变量名比如
int main()
{int x 5;auto foo [x x 1]{ return x; };
}以上在C14标准之前是无法编译通过的因为C11标准只支持简单捕获。而C14标准对这样的捕获进行了支持在这段代码里捕获列表是一个赋值表达式不过这个赋值表达式有点特殊因为它通过等号跨越了两个作用域。等号左边的变量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 futureclass Work
{
private:int value;
public:Work() : value(42) {}std::futureint spawn(){return std::async([]() - int { return value; });}
};std::futureint foo()
{Work tmp;return tmp.spawn();
}int main()
{std::futureint f foo();f.wait();std::cout f.get() f.get() std::endl;
}输出结果如下
f.get() 32766注意书中这里说输出 f.get() 32766但是我使用 CLion 按C17标准运行输出是f.get() 42不知道为什么。不过可以通过加入一个析构函数中清空value的值同样能说明本节的问题~Work() { value 0; } 这里我们期待f.get()返回的结果是42而实际上返回了32766这就是一个未定义的行为它造成了程序的计算错误甚至有可能让程序崩溃。为了解决这个问题我们引入初始化捕获的特性将对象复制到 lambda表达式内让我们简单修改一下spawn函数
class Work
{
private:int value;
public:Work() : value(42) {}std::futureint spawn(){return std::async([, tmp *this]() - int { return tmp.value; });}
};以上代码使用初始化捕获将*this 复制到 tmp 对象中然后在函数体内返回 tmp 对象的 value。由于整个对象通过复制的方式传递到lambda表达式内因此即使 this 所指的对象析构了也不会影响lambda表达式的计算。编译运行修改后的代码程序正确地输出 f.get() 42。
7. 泛型 lambda 表达式
C14标准让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
C17标准对lambda表达式同样有两处增强一处是常量lambda表达式另一处是对捕获this的增强。其中常量lambda表达式的主要特性体现在constexpr关键字上请阅读constexpr的有关章节来掌握常量lambda表达式的特性这里主要说明一下对于捕获this的增强。
还记得前面初始化捕获*this对象的代码吗我们在捕获列表内复制了一份this指向的对象到tmp然后使用tmp的value。没错这样做确实解决了异步问题但是这个解决方案并不优美。试想一下如果在lambda表达式中用到了大量this指向的对象那我们就不得不将它们全部修改一旦遗漏就会引发问题。为了更方便地复制和使用*this对象C17增加了捕获列表的语法来简化这个操作具体来说就是在捕获列表中直接添加[*this]然后在lambda表达式函数体内直接使用this指向对象的成员还是以前面的Work类为例
class Work
{
private:int value;public:Work() : value(42) {}std::futureint spawn(){return std::async([, *this]() - int { return value; });}
};在上面的代码中没有再使用tmp*this来初始化捕获列表而是直接使用*this。在lambda表达式内也没有再使用tmp.value而是直接返回了value。编译运行这段代码可以得到预期的结果42。从结果可以看出[*this]的语法让程序生成了一个*this对象的副本并存储在lambda表达式内可以在lambda表达式内直接访问这个复制对象的成员消除了之前lambda表达式需要通过tmp访问对象成员的尴尬。
9. 捕获 [, this]
在C20标准中又对lambda表达式进行了小幅修改。这一次修改没有加强lambda表达式的能力而是让this指针的相关语义更加明确。我们知道[]可以捕获this指针相似的[,*this]会捕获this对象的副本。但是在代码中大量出现[]和[,*this]的时候我们可能很容易忘记前者与后者的区别。为了解决这个问题在C20标准中引入了[, this]捕获this指针的语法它实际上表达的意思和[]相同目的是让程序员们区分它与[,*this]的不同。
[, this]{}; // C17 编译报错或者报警告 C20成功编译我使用 CLion 按照 C17 编译上面写法的代码没有报错也没有警告只是最终运行结果不正确。 虽然在C17标准中认为[, this]{}; 是有语法问题的但是实践中GCC和CLang都只是给出了警告而并未报错。另外在C20标准中还特别强调了要用[, 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节中我们讨论了C14标准中lambda表达式通过支持auto来实现泛型。大部分情况下这是一种不错的特性但不幸的是这种语法也会使我们难以与类型进行互动对类型的操作变得异常复杂。用提案文档的举例来说
template typename T struct is_std_vector : std::false_type { };
template typename T struct is_std_vectorstd::vectorT : std::true_type { };auto f [](auto vector) {static_assert(is_std_vectordecltype(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::vectorint 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_tdecltype(x);T copy x;using Iterator typename T::iterator;
};问题虽然解决了但是要时刻注意auto以免给代码带来意想不到的问题况且这都是建立在容器本身设计得比较完善的情况下才能继续下去的。
鉴于以上种种问题C委员会决定在C20中添加模板对lambda的支持语法非常简单
[]typename T(T t) {}于是上面那些让我们为难的例子就可以改写为
auto f []typename T(std::vectorT vector) {// …
};以及
auto f []typename T(T const x) {T copy x;using Iterator typename T::iterator;
};上面的代码是否能让读者眼前一亮这些代码不仅简洁了很多而且也更符合C泛型编程的习惯。
最后再说一个有趣的故事事实上早在2012年让lambda支持模板的提案文档N3418已经提交给了C委员会不过当时这份提案并没有被接受到2013年N3559中提出的基于auto的泛型在C14标准中实现而2017年lambda支持模板的提案又一次被提出来这一次可以说是踩在N3559的肩膀上成功地加入了C20标准。回过头来看整个过程虽说算不上曲折但也颇为耐人寻味C作为一个发展近30年的语言依然在不断地探索和纠错中砺志前行。
11. 可构造和可赋值的无状态 lambda 表达式
在7.4节中我们提到了无状态lambda表达式可以转换为函数指针但遗憾的是在C20标准之前无状态的lambda表达式类型既不能构造也无法赋值这阻碍了许多应用的实现。举例来说我们已经了解了像std::sort和std::find_if这样的函数需要一个函数对象或函数指针来辅助排序和查找这种情况我们可以使用lambda表达式完成任务。但是如果遇到std::map这种容器类型就不好办了因为std::map的比较函数对象是通过模板参数确定的这个时候我们需要的是一个类型
auto greater [](auto x, auto y) { return x y; };
std::mapstd::string, int, decltype(greater) mymap;这段代码的意图很明显它首先定义了一个无状态的lambda表达式greate然后使用decltype(greater)获取其类型作为模板实参传入模板。这个想法非常好但是在C17标准中是不可行的因为lambda表达式类型无法构造。编译器会明确告知lambda表达式的默认构造函数已经被删除了“note: a lambda closure type has a deleted defaultconstructor”。
除了无法构造无状态的lambda表达式也没办法赋值比如
auto greater [](auto x, auto y) { return x y; };
std::mapstd::string, int, decltype(greater) mymap1, mymap2;
mymap1 mymap2;这里mymap1 mymap2;也会被编译器报错原因是复制赋值函数也被删除了“note: a lambda closure type has a deleted copy assignmentoperator”。
为了解决以上问题C20标准允许了无状态lambda表达式类型的构造和赋值所以使用C20标准的编译环境来编译上面的代码是可行的。
总结
在本章我们介绍了lambda表达式的语法、使用方法以及原理。总的来说lambda表达式不但容易使用而且原理也容易理解。它很好地解决了过去C中无法直接编写内嵌函数的尴尬。虽然在GCC中提供了一个叫作nest function的C语言扩展这个扩展允许我们在函数内部编写内嵌函数但这个特性一直没有被纳入标准当中。当然我们也并不用为此遗憾因为现在提供的lambda表达式无论在语法简易程度上还是用途广泛程度上都要优于nest function。合理地使用lambda表达式可以让代码更加短小精悍的同时也具有良好的可读性。
八、非静态数据成员默认初始化C11 C20
1. 使用默认初始化
在C11以前对非静态数据成员初始化需要用到初始化列表当类的数据成员和构造函数较多时编写构造函数会是一个令人头痛的问题
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个构造函数为了在构造的时候初始化非静态数据成员它们的初始化列表有一些冗余代码而造成的后果是维护困难且容易出错。为了解决这种问题C11标准提出了新的初始化方法即在声明非静态数据成员的同时直接对其使用或者{}初始化见第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_。在初始化的优先级上有这样的规则初始化列表对数据成员的初始化总是优先于声明时默认初始化。
最后来看一看非静态数据成员在声明时默认初始化需要注意的两个问题。
不要使用括号()对非静态数据成员进行初始化因为这样会造成解析问题所以会编译错误。不要用auto来声明和初始化非静态数据成员虽然这一点看起来合理但是C并不允许这么做。
struct X {int a(5); // 编译错误不能使用 () 进行默认初始化auto b 8; // 编译错误不能使用 auto 声明和初始化非静态数据成员
};2. 位域的默认初始化
在C11标准提出非静态数据成员默认初始化方法之后C20标准又对该特性做了进一步扩充。在C20中我们可以对数据成员的位域进行默认初始化了例如
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::y和S2::z进行默认初始化了。
九、列表初始化C11 C20
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. 使用列表初始化
C11标准引入了列表初始化它使用大括号{}对变量进行初始化和传统变量初始化的规则一样它也区分为直接初始化和拷贝初始化例如
#include stringstruct 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 }。不过遗憾的是这个特性无法使用到STL的vector、list等容器中。想要初始化容器我们不得不编写一个循环来完成初始化工作。现在列表初始化将程序员从这个问题中解放了出来我们可以使用列表初始化对标准容器进行初始化了例如
#include vector
#include list
#include set
#include map
#include stringint main()
{int x[] { 1,2,3,4,5 };int x1[]{ 1,2,3,4,5 };std::vectorint x2{ 1,2,3,4,5 };std::vectorint x3 { 1,2,3,4,5 };std::listint x4{ 1,2,3,4,5 };std::listint x5 { 1,2,3,4,5 };std::setint x6{ 1,2,3,4,5 };std::setint x7 { 1,2,3,4,5 };std::mapstd::string, int x8{ {bear,4}, {cassowary,2}, {tiger,7} };std::mapstd::string, int x9 { {bear,4}, {cassowary,2}, {tiger,7} };
}以上代码在C11环境下可以成功编译可以看到使用列表初始化标准容器和初始化数组一样简单唯一值得注意的地方是对x8和x9的初始化因为它使用了列表初始化的一个特殊的特性。
3. std::initializer_list 详解
标准容器之所以能够支持列表初始化离不开编译器支持的同时它们自己也必须满足一个条件支持std::initializer_list为形参的构造函数。std::initializer_list简单地说就是一个支持begin、end以及size成员函数的类模板有兴趣的读者可以翻阅STL的源代码然后会发现无论是它的结构还是函数都直截了当。编译器负责将列表里的元素大括号包含的内容构造为一个std::initializer_list的对象然后寻找标准容器中支持std::initializer_list为形参的构造函数并调用它。而标准容器的构造函数的处理就更加简单了它们只需要调用std::initializer_list对象的begin和end函数在循环中对本对象进行初始化。
通过了解原理能够发现支持列表初始化并不是标准容器的专利我们也能写出一个支持列表初始化的类需要做的只是添加一个以std::initializer_list为形参的构造函数罢了比如下面的例子
#include iostream
#include stringstruct C {C(std::initializer_liststd::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_liststd::string a)这是支持列表初始化所必需的值得注意的是std:: initializer_list的 begin 和 end 函数并不是返回的迭代器对象而是一个常量对象指针 const T *。本着刨根问底的精神让我们进一步探究编译器对列表的初始化处理
#include iostream
#include stringstruct C {C(std::initializer_liststd::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_liststd::string(__a, __a3));另外有兴趣的读者不妨用GCC对上面这段代码生成中间代码GIMPLE不出意外会发现类似这样的中间代码
main ()
{struct initializer_list D.40094;const struct basic_string D.36430[3];…std::__cxx11::basic_stringchar::basic_string (D.36430[0], hello, D.36424);…std::__cxx11::basic_stringchar::basic_string (D.36430[1], c, D.36426);…std::__cxx11::basic_stringchar::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条规则。
从浮点类型转换整数类型。从long double转换到double或float或从double转换到float除非转换源是常量表达式以及转换后的实际值在目标可以表示的值范围内。从整数类型或非强枚举类型转换到浮点类型除非转换源是常量表达式转换后的实际值适合目标类型并且能够将生成目标类型的目标值转换回原始类型的原始值。从整数类型或非强枚举类型转换到不能代表所有原始类型值的整数类型除非源是一个常量表达式其值在转换之后能够适合目标类型。
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对应规则22) 列表初始化的优先级问题
通过9.2节和9.3节的介绍我们知道列表初始化既可以支持普通的构造函数也能够支持以std::initializer_list为形参的构造函数。如果这两种构造函数同时出现在同一个类里那么编译器会如何选择构造函数呢比如
std::vectorint x1(5, 5);
std::vectorint x2{ 5, 5 };以上两种方法都可以对std::vectorint进行初始化但是初始化的结果却是不同的。变量x1的初始化结果是包含5个元素且5个元素的值都为5调用了vector(size_type count, const T value, const Allocator alloc Allocator())这个构造函数。而变量x2的初始化结果是包含两个元素且两个元素的值为5也就是调用了构造函数vector(std::initializer_listT init, const Allocator alloc Allocator())。
所以上述问题的结论是如果有一个类同时拥有满足列表初始化的构造函数且其中一个是以std::initializer_list为参数那么编译器将优先以std::initializer_list为参数构造函数。由于这个特性的存在我们在编写或阅读代码的时候就一定需要注意初始化代码的意图是什么应该选择哪种方法对变量初始化。
最后让我们回头看一看9.2节中没有解答的一个问题std::mapstd::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_listvalue_type init, const Allocator)。
5. 指定初始化
为了提高数据成员初始化的可读性和灵活性C20标准中引入了指定初始化的特性。该特性允许指定初始化数据成员的名称从而使代码意图更加明确。让我们看一看示例
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 即可。其中 x 和 y 坐标会调用默认初始化将其值设置为 0。可能这个例子还是不能完全体现出它相对于 Point3D p{ 0, 0, 3 }; 的优势所在不过读者应该能感觉到一旦结构体更加复杂指定初始化就一定能带来不少方便之处。
最后需要注意的是并不是什么对象都能够指定初始化的。
它要求对象必须是一个聚合类型例如下面的结构体就无法使用指定初始化
struct Point3D {Point3D() {}int x;int y;int z;
};
Point3D p{ .z 3 }; // 编译失败Point3D 不是一个聚合类型这里读者可能会有疑问如果不能提供构造函数那么我们希望数据成员 x 和 y 的默认值不为 0 的时候应该怎么做不要忘了从 C11 开始我们有了非静态成员变量直接初始化的方法比如当希望 Point3D 的默认坐标值都是 100 时代码可以修改为
struct Point3D {int x 100;int y 100;int z 100;
};
Point3D p{ .z 3 }; // x 100, y 100, z 3指定的数据成员必须是非静态数据成员。这一点很好理解静态数据成员不属于某个对象。每个非静态数据成员最多只能初始化一次
Point p{ .y 4, .y 2 }; // 编译失败y不能初始化多次非静态数据成员的初始化必须按照声明的顺序进行。请注意这一点和C语言中指定初始化的要求不同在C语言中乱序的指定初始化是合法的但C不行。其实这一点也很好理解因为C中的数据成员会按照声明的顺序构造按照顺序指定初始化会让代码更容易阅读
Point p{ .y 4, .x 2 }; // C编译失败C编译没问题针对联合体中的数据成员只能初始化一次不能同时指定
u f { .a 1 }; // 编译成功
u g { .b asdf }; // 编译成功
u h { .a 1, .b asdf }; // 编译失败同时指定初始化联合体中的多个数据成员不能嵌套指定初始化数据成员。虽然这一点在C语言中也是允许的但是C标准认为这个特性很少有用所以直接禁止了
struct Line {Point a;Point b;
};
Line l{ .a.y 5 }; // 编译失败, .a.y 5 访问了嵌套成员不符合C标准当然如果确实想嵌套指定初始化我们可以换一种形式来达到目的
Line l{ .a {.y 5} };在C20中一旦使用指定初始化就不能混用其他方法对数据成员初始化了而这一点在C语言中是允许的
Point p{ .x 2, 3 }; // 编译失败混用数据成员的初始化最后再来了解一下指定初始化在C语言中处理数组的能力当然在C中这同样是被禁止的
int arr[3] { [1] 5 }; // 编译失败C标准中给出的禁止理由非常简单它的语法和lambda表达式冲突了。
总结
列表初始化是我非常喜欢的一个特性因为它解决了以往标准容器初始化十分不方便的问题使用列表初始化可以让容器如同数组一般被初始化。除此以外实现以std::initializer_list为形参的构造函数也非常容易这使自定义容器支持列表初始化也变得十分简单。C20引入的指定初始化在一定程度上简化了复杂聚合类型初始化工作让初始化复杂聚合类型的代码变得简洁清晰。
十、默认和删除函数C11
1. 类的特殊成员函数
在定义一个类的时候我们可能会省略类的构造函数因为C标准规定在没有自定义构造函数的情况下编译器会为类添加默认的构造函数。像这样有特殊待遇的成员函数一共有 6 个C11以前是4个具体如下。
默认构造函数。析构函数。复制构造函数。复制赋值运算符函数。移动构造函数C11新增。移动赋值运算符函数C11新增。
添加默认特殊成员函数的这条特性非常实用它让程序员可以有更多精力关注类本身的功能而不必为了某些语法特性而分心同时也避免了让程序员编写重复的代码比如
#include string
#include vectorclass City {std::string name;std::vectorstd::string street_name;
};int main()
{City a, b;a b;
}在上面的代码中我们虽然没有为 City 类添加复制赋值运算符函数 City::operator(const City )但是编译器仍然可以成功编译代码并且在运行过程中正确地调用 std::string 和std::vectorstd::string 的复制赋值运算符函数。假如编译器没有提供这条特性我们就不得不在编写类的时候添加以下代码
City City::operator(const City other)
{name other.name;street_name other.street_name;return *this;
}很明显编写这段代码除了满足语法的需求以外没有其他意义很庆幸可以把这件事情交给编译器去处理。不过还不能高兴得太早因为该特性的存在也给我们带来了一些麻烦。
声明任何构造函数都会抑制默认构造函数的添加。一旦用自定义构造函数代替默认构造函数类就将转变为非平凡类型。没有明确的办法彻底禁止特殊成员函数的生成C11之前。
下面来详细地解析这些问题还是以City类为例我们给它添加一个构造函数
#include string
#include vectorclass City {std::string name;std::vectorstd::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::vectorstd::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_vTrivial : std::is_trivial_vTrivial 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 函数另外还想沿用基类 Base 的 foo 函数于是这里使用 using 说明符将 Base 的 foo 成员函数引入子类代码如下
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. 显式默认和显式删除
为了解决以上种种问题C11标准提供了一种方法能够简单有效又精确地控制默认特殊成员函数的添加和删除我们将这种方法叫作显式默认和显式删除。显式默认和显式删除的语法非常简单只需要在声明函数的尾部添加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 iostreamclass 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_vTrivial : std::is_trivial_vTrivial std::endl;std::cout std::is_trivial_vNonTrivial : std::is_trivial_vNonTrivial 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 cstddefstruct 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; // 可以被 newdelete 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) 来构造对象。虽然原因解释得很清楚但是建议还是不要这么使用因为这样除了让人难以理解外没有实际作用。