《深入理解C++11:C++11新特性解析与应用》笔记三

第三章 通用为本 专用为末

3.1 继承构造函数

派生类如果要使用基类的构造函数,通常要在构造函数中显式声明:

如果基类中有很多版本的构造函数,派生类里想要拥有和基类那样多的构造函数,就必须一一透传各个接口,相当麻烦。

c++中已经有一个好用的规则,就是如果派生类想要使用基类版本的成员函数,可以通过using声明来完成:

c++11扩展了这个做法。子类可以通过使用using声明来声明继承基类的构造函数:

这样就不需要再透传构造函数了,而且继承构造函数跟派生类中的各种类默认函数(默认构造、析构、拷贝构造等)一样,是隐式声明的。这意味着如果一个继承构造函数不被相关代码使用,编译器不会为其产生真正的函数代码。这比透传方案节省目标代码空间。

继承构造函数只会初始化基类中的成员变量,可以通过类成员初始化表达式,为派生类成员变量设定一个默认值。如果这样无法满足需求,可以实现一个自定义的构造函数。

继承构造函数不会继承基类构造函数的参数默认值。默认值会导致基类产生多个版本的构造函数,这些版本都会被派生类继承。

上面代码中,B可能从A中继承来的候选继承构造函数有如下一些:

多个基类中的部分构造函数可能导致派生类中的继承构造函数函数名、参数都相同,那么继承类中的冲突的继承构造函数将导致不合法的派生类代码。

A和B的构造函数会导致C中重复定义相同类型的继承构造函数。可以通过显式定义继承类的冲突的构造函数,阻止隐式生成相应的继承构造函数来解决冲突:

如果基类的构造函数被声明为私有成员函数,或者派生类是从基类中虚继承的,那么就不能够在派生类中声明继承构造函数。一旦使用了继承构造函数,编译器就不会再为派生类生成默认构造函数了。例子:

B中没有包含一个无参数的版本,因此变量b的定义是不能通过编译的。不过,需要编译器实现继承构造函数这个特性。

3.2 委派构造函数

多个构造函数,可能存在代码冗余的问题,下面三个构造函数,除了初始化列表不同,其他部分基本相似:

通过委派构造函数在构造函数的初始化列表位置进行构造、委派,达到简化代码的目的:

称在初始化列表中调用的基准版本的构造函数为委派构造函数,而被调用的基准版本则为目标构造函数。委派构造就是指委派构造函数将构造的任务委派给了目标构造函数来完成这样一种类构造的方式。

构造函数不能同时委派和使用初始化列表,因此变量赋初值的代码只能放在函数体中。初始化列表的初始化方式总是先于构造函数完成的(编译时就已经决定了),这种写法会导致犯错。可以修改目标函数,使得委派构造函数依然可以在初始化列表中初始化所有成员:

使用委派构造函数的时候,应该使用最通用的构造函数作为目标构造函数。此处书中有一个例子,不过说法有误,这里不再赘述,可以看原书65页。

要避免形成委托环,否则会编译错误。

委派构造的一个应用是使用构造模板函数作为目标构造函数。委托构造使得构造函数的泛型编程成为可能。例子:

如果在委派构造函数中使用try的话,那么从目标构造函数中产生的异常,都可以在委派构造函数中被捕捉到,例子:

目标构造函数中抛出了异常,委派构造函数的函数体部分代码将不会被执行。因为函数体依赖目标构造函数的结果,当其发生异常时,还是不要执行委派构造函数的函数体代码为好。

3.3 右值引用:移动语义和完美转发

3.3.1 指针成员与拷贝构造

在类中包含了一个指针成员的话,要小心拷贝构造函数的实现。如果我们没有实现自己的拷贝构造函数,编译器会隐式生成,其作用类似于memcpy的按位拷贝,这样的构造方式有个问题,就是a.d和b.d都指向了同一块内存。因此在main作用域结束的时候,a和b的析构函数纷纷被调用,当其中之一完成析构后,另一个就成了悬挂指针,在该指针上释放内存会造成严重的错误。解决方案是用户自定义拷贝构造函数来实现深拷贝。

3.3.2 移动语义

拷贝构造函数中为指针成员分配新的内存在进行内容拷贝是常见做法。但是有时候确实不需要这样的拷贝构造语义。假设有如下代码:

这里构造函数被调用了一次,这是在GetTemp函数中HasPtrMem()表达式显式地调用了构造函数而打印出来的。拷贝构造函数则被调用了两次。一次是从 HasPtrMem()生成的变量上拷贝构造出一个临时值,作为GetTemp的返回值,另一次由临时值构造出main中变量a调用的。相应地,析构函数也调用了3次。过程如下:

这里的问题是如果d指向的是一个大内存的数据的话,拷贝过程会非常昂贵,而临时变量的产生和销毁并不影响程序正确性。不使用拷贝构造语义,从临时变量中拷贝构造变量a时,在拷贝时分配新的堆内存,并从临时对象的堆内存中拷贝内容到a.d。构造完成后,临时对象将析构。 这样的偷走临时变量中资源的构造函数,称为移动构造函数。实现这种移动语义的代码如下:

这样就调用了两次移动构造函数,拷贝构造函数没有再被调用。在拷贝构造函数中,将本对象d指向临时对象成员d所指的内存,并将该临时对象成员d置为指针空值。这样临时对象析构时就不会析构掉这块内存。

3.3.3 左值、右值与右值引用

一个典型的判别方法是,在赋值表达式中,出现在等号左侧的就是左值,而在等号右侧的则为右值。另一个说法是,可以取地址的、有名字的是左值,反之,不能取地址的、没有名字的就是右值。在c++11中,右值由两个概念构成,一个是将亡值xvalue,另一个是纯右值 prvalue。

纯右值是c++98标准中右值的概念,讲的是用于辨识临时变量和一些不跟对象关联的值。

将亡值则是c++新增的跟右值引用相关的表达式,例如将要被移动的对象,返回右值引用T&&的函数返回值、std::move的返回值,或者转换为T&&的类型转换函数的返回值。右值引用就是对一个右值进行引用的类型,因为右值通常不具有名字,只能用引用的方式找到它的存在,例如

T&& a = ReturnRValue();

通过右值引用的声明,ReturnRValue函数返回的右值生命期得到了延续。相比于T b = ReturnRvalue();右值引用变量声明,就会少一次对象的析构及一次对象的构造。因为a是右值引用,直接绑定了ReturnRValue()返回的临时值,而b只是由临时值构造而成的,就会有额外的构造和析构开销。

能够声明右值引用a的前提是ReturnRValue返回的是一个右值。通常右值引用不能绑定到任何左值。普通左值引用只能绑定左值,无法绑定到右值,除非是常量左值引用。相比于右值引用所引用的右值,常量左值引用所引用的右值在它的余生中只能是只读的。c++98中也有使用常量左值引用绑定右值的例子,例如:const bool & judgement = true;c++98中,我们也常使用常量左值引用来减少临时对象的开销。在c++11中,同样可以用右值引用作为参数来减少临时变量拷贝的开销。进一步而言,还能修改该临时值。不过修改临时值的意义通常不大,除非使用移动语义。

std::move的作用是强制一个左值成为右值,这样就可以使用以右值引用为参数的移动构造函数。右值引用的来由从来就跟移动语义紧紧相关,这是右值存在的一个最大的价值,另一个价值是用于转发。

为了语义完整,c++11还存在着常量右值引用,一来右值引用主要是为了移动语义,移动语义需要右值是可以修改的,那么常量右值引用在移动语义中就没有用武之地。二来常量左值引用也能实现右值不可以更改的效果,因此目前常量右值引用没有什么用处。

标准库在 <type_traits> 头文件中提供了 3 个模板类:is_rvalue_reference、is_lvalue_reference、is_reference,可供我们进行判断一个类型是否是引用类型、右值引用、左值引用类型。

 3.3.4 std::move:强制转化为右值

c++11中标准库在<utility>中提供了一个函数std::move,可以将一个左值强制转为右值引用,继而我们可通过右值引用使用该值,以用于移动语义。从实现上讲,std::move基本等同于一个类型转换:static_cast<T&&>(lvalue);

被转化的左值的生命周期并没有随着左右值转化而改变,也就是不会立即析构。一般地,我们需要转换为右值引用的更多是生命周期即将结束的对象。看一个例子:

需要注意这里的move(m.h)确保了移动语义传递到了类的成员。

为了保证移动语义的传递,在编写移动构造函数时,应总是使用std::move将形如堆内存、文件句柄等资源的成员转换为右值。如果成员支持移动构造的话,就能实现其移动语义。即使成员没有移动构造函数,那么接受常量左值的构造函数也会实现拷贝构造,因此也不会引起大问题。

3.3.5 移动语义的一些其它问题

移动语义一定是要修改临时变量的值,因此一定要排除不必要的const关键字。

默认情况下,编译器会隐式生成一个移动构造函数,但是如果声明了自定义的拷贝构造函数、拷贝赋值函数、移动赋值函数、析构函数中的一个或多个,编译器将不会生成默认版本。默认生成的移动构造函数跟默认拷贝构造函数是一样的,只能做些按位拷贝的工作。

同样地,如果声明了自定义的移动构造函数、拷贝赋值函数、移动赋值函数、析构函数中的一个或多个,编译器也不会生成默认的拷贝构造函数。

所以在c++11中,拷贝构造/赋值和移动构造/赋值必须同时提供,或者同时不提供,才能保证类同时具有拷贝和移动语义。

在标准库的头文件 <type traits> 里,我们还可以通过一些辅助的模板类来判断一个类型是否是可以移动的。比如 is_move_constructible、is_trivially_move_constructible、is_nothrow_move_constructible,使用方法仍然是使用其成员 value。比如:
cout << is_move_constructible<UnknownType>::value:

移动语义的一个典型应用是可以实现高性能的置换函数:

另一个关于移动构造的话题是异常。对于移动构造函数来说,抛出异常是件危险的事。可能移动语义还未完成,一个异常抛出来了,会导致一些指针成为悬挂指针。应尽量编写不抛出异常的移动构造函数,通过添加一个noexcept关键字,确保在抛出异常的时候终止程序。在标准库中可以使用一个std::move_if_noexcept的模板函数替代move函数。该函数在类的移动构造函数没有noexcept关键字修饰时返回一个左值引用,从而使变量可以使用拷贝语义,而在类的移动构造函数有noexcept关键字时,返回一个右值引用,从而使变量可以使用移动语义。move_if_noexcept是以牺牲性能保证安全的一种做法。

编译器中有一个RVO/NRVO的优化,也即是返回值优化。除非关闭这个优化,否则很多构造和移动都会被省略。对于下面的代码,一旦打开 g++/clang++ 的 RVO/NRVO,从 ReturnValue 函数中a变量拷贝/移动构造临时变量,以及从临时变量拷贝 /移动构造 b 的二重奏就通通没有了。‘

b变量实际就使用了ReturnValue函数中a的地址,任何拷贝和移动都没有了。有些情况下,一些构造无法省略。还有些情况,即使RVO/NRVO完成了,也不能达到最好的效果。移动语义可以解决编译器无法解决的优化问题,因而总是有用的。

3.3.6 完美转发

完美转发指的是函数模板中,完全依照模板的参数的类型,将参数传递给函数模板中调用的另外的一个函数。比如:

对于目标函数IrunCodeActually而言,它总是希望转发函数将参数按照传入Iamforwarding时的类型传递,而不产生额外的开销,就好像转发者不存在一样。

这点实际上不简单,上面的例子中,在Iamforwarding的参数中使用最基本类型进行转发,该方法会导致参数在传给IrunCodeActually之前就产生了一次额外的临时对象拷贝。

所以通常需要一个引用类型。其次,需要考虑转发函数对类型的接受能力。因为目标函数可能需要能够既接受左值引用,又接受右值引用。

c++11通过引用折叠(将复杂的未知表达式折叠为已知的简单表达式)的新语言规则,结合新的模板推导规则来完成完美转发。

c++11之前,形如以下代码会编译错误:

c++11中会发生引用折叠,对于不同类型的TR和v,具体如下:

模板对类型的推导规则如下:当转发函数的实参是类型X的一个左值引用,那么模板参数被推导为X&类型,而转发函数的实参是类型X的一个右值引用的话,那么模板的参数被推导为X&&类型。

进一步,我们可以把转发函数写成如下形式:

当我们调用转发函数时传入一个X类型的左值引用,转发函数被实例化为:

这时候左值传递就没有问题了。调用前的static_cast没有什么作用,是留给传递右值用的。

当我们调用转发函数时传入一个X类型的右值引用的话,转发函数被实例化为:

对于一个右值而言,当它使用右值引用表达式引用的时候,该右值引用是个不折不扣的左值。想在函数调用中继续传递右值,就需要使用std::move进行左右值的转换,std::move通常就是一个static_case。c++11中用于完美转发的函数叫forward。所以可以把转发函数写成这样:

完美转发的一个作用是做包装函数。

3.4 显式转换操作符

Rational1的构造函数没有explicit关键字修饰,意味着该构造函数可以被隐式调用,字面量11就会成功地构造出Rational1(11,1)这样的变量,而Rational2的构造函数不能从字面量21中构造。

c++11中,标准将explicit的适用范围扩展到自定义的类型转换操作符上,以支持所谓的显式类型转换。explicit关键字作用于类型转换操作符上,意味着只有在直接构造目标类型或显式类型转换的时候可以使用该类型,例如拷贝构造则不行。

3.5 列表初始化

3.5.1 初始化列表

c++11中集合(列表)的初始化已经成为c++语言的一个基本功能,称为初始化列表。一些例子:

自动变量和全局变量的初始化在c++11中被丰富了,一些初始化形式:

1.等号加上赋值表达式。2.等号加上花括号的初始化列表,例如int a = {3+4}; 3.圆括号式的表达式列表,例如int a(3+4);4.花括号式的初始化列表,例如int a{3+4};后两种也能用于获取堆内存new操作符中,例如:

int *i = new int(1); double *d = new double{1.2f};

标准模板库中容器对初始化列表的支持源自<initializer_list>这个头文件中initializer_list类模板的支持。只要引入该头文件,并且声明一个以initializer_list<T>模板类为参数的构造函数,同样可以使得自定义的类使用列表初始化。例子:

函数的参数列表也可以使用初始化列表。例子:

类和结构体的成员函数也可以使用初始化列表,包括一些操作符的重载函数。

初始化列表还可以用于函数返回的情况。返回一个初始化列表,通常会导致构造一个临时变量,类型是依据返回类型的。跟普通字面量相同,如果返回值是一个引用类型的话,会返回一个临时变量的引用。

const vector<int>& Func1() ( return {3,5};}
这里注意,必须要加 const 限制符。该规则与返回一个字面常量是一样的。

3.5.2 防止类型收窄

使用列表初始化可以防止类型收窄,类型收窄时无法通过编译。类型收窄指的是一些可以使得数据变化或者精度丢失的隐式类型转换。类型收窄的几种典型情况:

  • 从浮点数隐式地转化为整型数
  • 从高精度的浮点数转为低精度的浮点数
  • 从整型转为浮点数,如果整型数达到浮点数无法精确地表示
  • 从整型转为较低长度的整型,如果无法被容纳

一些例子:

3.6 POD类型

POD是Plain Old Data的缩写。POD通常用于说明一个类型的属性,尤其是用户自定义类型的属性。Plain表示POD是个普通的类型,c++中常见的类型都有这样的性质,不像一些存在着虚函数虚继承的类型那么特别。Old表示与c的兼容性,比如可以用最老的memcpy()函数进行复制,使用memset()进行初始化等。c++11将POD划分为两个基本概念的集合:平凡的trivial和标准布局的standard layout。

一个平凡的类或结构体应该符合以下定义:

1)拥有平凡的默认构造函数和析构函数。

不定义类的构造函数,编译器会生成一个平凡的默认构造函数。一旦定义了构造函数,该构造函数便不再是平凡的。可以使用=default来显式地声明缺省版本的构造函数,从而使类型恢复平凡化。

2)拥有平凡的拷贝构造函数和移动构造函数。平凡构造函数基本等同于使用memcpy进行类型的构造。

3)拥有平凡的拷贝赋值运算符和移动赋值运算符。

4)不能包含虚函数及虚基类。

template <typename T> struct std::is trivial;

类模板is_trivial的成员value可以用于判断T的类型是否是一个平凡的类型。除了类和结构体外,is_trivial还可以对内置的标量类型数据及数组类型进行判断。

标准布局的类或结构体应该符合:

1)所有非静态成员有相同的访问权限(public,private,protected)

2)在类或者结构体继承是,满足一下两种情况之一:

  • 派生类中有非静态成员,且只有一个仅包含静态成员的基类。
  • 基类有非静态成员,而派生类没有非静态成员。

换句话说,只要非静态成员同时出现在派生类和基类间,其即不属于标准布局。非静态成员出现在多个基类中,派生类也不属于标准布局。

3)类中第一个非静态成员的类型与其基类不同。

在 C++ 标准中,如果基类没有成员,标准允许派生类的第一个成员与基类共享地址。因为派生类的地址总是“堆叠”在基类之上的,所以这样的地址共享,表明了基类并没有占据任何的实际空间(可以节省一点数据)。但是如果基类的第一个成员仍然是基类,在我们的例子中可以看到,编译器仍然会为基类分配1 字节的空间。分配为 1字节空间是由于 C++ 标准要求类型相同的对象必须地址不同(基类地址及派生类中成员d 的地址必须不同),而导致的结果是,对于 D1和 D2 两种类型而言,其“布局”也就是不同的了。

4)没有虚函数和虚基类。

5)所有非静态数据成员均符合标准布局类型,其基类也符合标准布局。

c++11可以使用模板类来帮助判断类型是否是一个标准布局的类型。

template <typename T> struct std::is_standard_layout;

通过is_standard_layout模板类的成员value来判断类型是否是标准布局。

要判断某一类型是否是PoD,标准库中的<type_traits>头文件提供了模板类:

template <typename T> struct std::is_pod;

通过is_pod模板类的成员value判定一个类型是否是POD。

POD的好处:

1)字节赋值,可以安全地使用memset和memcpy对POD类型进行初始化和拷贝等操作。

2)提供对c内存布局兼容。

3)保证了静态初始化的安全有效。

3.7 非受限联合体

c++98中不允许联合体拥有静态或引用类型的成员。c++11取消了对数据成员类型的限制,规定任何非引用类型都可以成为联合体的数据成员,这样的联合体即所谓的非受限联合体。

3.8 用户自定义字面量

c++11可以通过定一个后缀标识的操作符,将声明了该后缀标识的字面量转化为需要的类型。后缀声明也能用于数值。

c++11要求声明字面量操作符有一定规则:

3.9 内联名字空间

通过名字空间的细分,一个子名字空间不能直接引用另一个子名字空间中的名字。库的使用者在使用名字空间的时候,需要知道太多的子名字空间的名字。库的使用者不希望要使用很长的类型声明,库的提供者也没必要让使用者看到子名字空间。

c++98不允许在不同的名字空间对模板进行特化。

c++11通过关键字inline来声明一个内联的名字空间,内联名字空间允许在父名字空间定义或特化自名字空间的模板。

3.10 模板的别名

c++中使用typedef为类型定义别名。比如:

typedef int myInt;

c++11中可以使用using定义类型别名:

c++11标准库中的is_same模板类可以判断两个类型是否是一致。

在使用模板编程的时候,using语法比typedef更灵活。下面这个例子,typedef无法实现这样的效果:

3.11 一般化的SFINEA规则

SFINEA-substitution failure is not an error,即匹配失败不是错误,是c++模板中的一条规则。表示的是对重载的模板参数进行展开的时候,如果展开导致了一些类型不匹配,编译器并不会报错。这使得模板匹配更加精确,一些模板函数、模板类在实例化时使用特殊模板版本,另一些则使用通用的版本,大大增加了模板设计使用上的灵活性。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值