现代C++语言(C++11/14/17)特性总结和使用建议(二)

override和final成员函数

以前C++中虚函数没有一个强制的机制来标识虚函数会在派生类里被改写。vitual关键字是可选的,这使得阅读代码变得很费劲。因为可能需要追溯到继承体系的源头才能确定某个方法是否是虚函数。为了增加可读性,可以在派生类里也写上virtual关键字。但即使这样,仍然会产生一些微妙的错误。看下面这个例子:

 

1.png

D::f按理应当重写B::f。然而二者的声明是不同的,一个参数是short,另一个是int。因此D::f只是拥有同样名字的另一个函数(重载)而不是重写。当你通过B类型的指针调用f()可能会期望打印出D::f,但实际上则会打出 B::f 。

另一个很微妙的错误情况:参数相同,但是基类的函数是const的,派生类的函数却不是。

 

2.png

同样,这两个函数是重载而不是重写,所以你通过B类型指针调用f()将打印B::f,而不是D::f。

幸运的是,现在有一种方式能描述你的意图。新标准加入了两个新的标识符(不是关键字):
override,表示函数应当重写基类中的虚函数。
final,表示派生类不应当重写这个虚函数。
第一个的例子如下:

 

3.png

现在这将触发一个编译错误(后面那个例子,如果也写上override标识,会得到相同的错误提示):
'D::f' : method with override specifier 'override' did not override any base class methods

另一方面,如果你希望函数不要再被派生类进一步重写,你可以把它标识为final。可以在基类或任何派生类中使用final。在派生类中,可以同时使用override和final标识。

 

4.png

被标记成final的函数将不能再被F::f重写。

这两个关键字可以让代码的可读性更好,而且可以有效地防止错误,应当作为强制要求。

相关链接:https://zh.cppreference.com/w/cpp/language/override
https://zh.cppreference.com/w/cpp/language/final

追踪返回类型

C++11引入了一种新语法——追踪返回类型,来处理函数模板的返回类型依赖于实际的入口参数类型的场景,例如:

 

5.png

如上面的写法所示,我们把函数的返回值移至参数声明之后,复合符号->decltype(t1 + t2)被称为追踪返回类型。而原本函数返回值的位置由auto关键字占据。这样我们就可以让编译器来推导Sum函数模板的返回类型了。而auto占位符和->return_type也就是构成追踪返回类型函数的2个基本元素,追踪返回类型的函数和普通函数的声明最大的区别在于返回类型的后置。

追踪返回类型的另一个优势是简化函数的定义,提高代码的可读性,这种情况常见于函数指针中。

 

6.png

除此之外,追踪返回类型也被广泛的应用在转发函数中,例如:

 

8.png

如果孤立的看这个特性,其用处貌似不大,一般来说代码中很少遇到返回值类型非常复杂的函数定义,即使有,通常也用typedef简化过了。因此追踪返回类型这种新语法往往和其他特性联合使用才有价值,比如lamda表达式。

相关链接:https://zh.cppreference.com/w/cpp/language/function

lamda表达式

lambda表达式可以用于创建并定义匿名的函数对象,以简化编程工作。Lambda的语法如下:

[函数对象参数](操作符重载函数参数)->返回值类型{函数体}

 

8.png

[]内的参数指的是Lambda表达式可以取得的变量。(1)函数中的b就是指函数可以得到在Lambda表达式外的变量,如果在[]中传入=的话,即是可以取得所有的外部变量,如(2)和(3)Lambda表达式。
()内的参数是每次调用函数时传入的参数。
->后加上的是Lambda表达式返回值的类型,如(3)中返回了一个int类型的变量。

lamda表达式通常只有和能接收函数对象的算法接口(如STL中的algorithm)配合使用才有价值。而就我司的历史代码来看,开发人员普遍未形成使用函数对象的习惯,因此lamda表达式的推广也会受到已有编程习惯的限制。

lamda表达式经常需要和其他特性配合使用。比如auto,decltype,追踪返回类型等等。有的人可能会误解lamda表达式的类型是函数指针,其实不是,它是一个匿名的函数对象。当写一个新的lamda表达式时,即使其参数和返回值与其它函数一样,也会声明一个新的类型。因此,一些接口应当为接收lamda表达式做好设计(因为lamda表达式没有统一类型,因此要定义接口参数类型为模板)。

[]捕获列表对于未接触过函数对象的人来说需要认真理解。如果是按对象值捕获会产生隐式的拷贝构造过程,对于大对象有堆栈溢出风险;如果是按引用捕获必须要保证原对象的生存周期长于lamda表达式的生命周期,否则有访问野指针的风险。

相关链接:https://zh.cppreference.com/w/cpp/language/lambda

成员函数控制:=delete和=default

在C++中声明自定义的类,编译器会默认帮助程序员生成一些他们未自定义的成员函数。这样的函数版本被称为“默认函数”,这包括了以下一些自定义类型的成员函数:
构造函数
拷贝构造函数
拷贝赋值函数(operator=)
移动构造函数
移动拷贝函数
析构函数

此外C++编译器还会为以下这些自定义类型提供全局默认操作符函数:
operator ,
operator &
operator &&
operator *
operator ->
operator ->*
operator new
operator delete

在C++语言规则中,一旦程序员实现了这些函数的自定义版本,则编译器不会再为该类自动生成默认版本。不过一旦声明了自定义版本的构造函数,则有可能导致我们定义的类型不再是POD的。变为非POD类型带来一系列负面影响有时是程序员所不希望的,很多时候,这意味着编译器失去了优化这些简单的数据类型的可能,因此客观上我们需要一些方式来使得这一的简单类型“恢复”POD的特质。

在C++11中,标准通过提供新的机制来控制默认版本函数的生成。这个新机制重用了default关键字。程序员可以在默认函数定义或者声明时加上“=default”,从而显式的指示编译器生成该函数的默认版本。

 

9.png

另一方面,程序员在一些情况下希望能限制一些默认函数的生成。最典型的,类的编写者有时候需要禁止使用者使用拷贝构造函数,在C++98标准中,我们的做法是将拷贝构造函数声明为private的成员,并且不提供函数实现。这样一来,一旦有人试图(或者无意识)使用拷贝构造函数,编译器就会报错。

在C++11中,标准中给出了更简单的方法,即在函数的定义或者声明加上“=delete”。“=delete”会指示编译器不生成函数的缺省版本,例如:

 

10.png

“=delete”修饰符在很多类中都能派上用场,因为现有代码中不少类并没有为拷贝构造和赋值操作做好设计,显式的防止这种操作可以避免出乎意料的运行式错误,对于清除静态检查工具(如PC-Lint)的告警也有帮助。“=default”相对来说用处就没那么大了,通常一个类如果有需要初始化的成员,也不允许使用默认构造函数。

相关链接:https://zh.cppreference.com/w/cpp/language/function

列表初始化语法

C++11中,集合(列表)的初始化已经成为C++语言的一个基本功能,这种初始化的方法被称为“初始化列表”(initializer list),例如:

 

11.png

这样一来,自动变量和全局变量的初始化在C++11中被丰富了,程序员可以使用以下几种形式完成初始化的工作:

等号“=”加上赋值表达式(assignment-expression),比如 int a = 3 + 4;
等号“=”加上花括号式的初始化列表,比如 int a = {3 + 4};
圆括号式的表达式列表(expression-list),比如 int a (3 + 4);
花括号式的初始化列表,比如 int a {3 + 4};

后面2种形式也可以用于获取堆内存new操作符中,比如:

 

12.png

自定义的类如果要使用初始化列表初始化,需要#include <initializer_list>头文件,并且声明一个以initializer_list<T>模板类为参数的构造函数:

 

13.png

同样的,函数的参数列表也可以使用初始化列表:

 

14.png

列表初始化语法对于STL容器和自定义类作用很大(尤其是一些测试代码),但对于数组等类型就没必要强制使用了。总的来说,这是一个能在部分场景下大大提高效率的值得推广的特性。

相关链接:https://zh.cppreference.com/w/cpp/language/list_initialization

Strongly-typed enums(强类型枚举)

传统的C++枚举类型存在一些缺陷:它们会将枚举常量暴露在外层作用域中(这可能导致名字冲突,如果同一个作用域中存在两个不同的枚举类型,但是具有相同的枚举常量就会冲突),而且它们会被隐式转换为整形,无法拥有特定的用户定义类型。

在C++11中通过引入了一个称为强类型枚举的新类型,修正了这种情况。强类型枚举由关键字enum class标识。它不会将枚举常量暴露到外层作用域中,也不会隐式转换为整形,并且拥有用户指定的特定类型(传统枚举也增加了这个性质)。

 

15.png

强类型枚举进一步强化了编译器类型检查,值得推广。不过,以前有很多程序员喜欢利用枚举和整型之间的转换来完成一些编码技巧(比如用枚举值作为数组下标),这些做法现在行不通了。把枚举强制转换成整型不是一个好习惯,因此以前一些代码设计的思路可能需要转变。

相关链接:https://zh.cppreference.com/w/cpp/language/enum

右值引用、移动构造、移动赋值和完美转发

C++11加入了右值引用(rvalue reference)的概念(用&&标识),用来区分对左值和右值的引用。左值就是一个有名字的对象,而右值则是一个无名对象(临时对象)。move语义允许修改右值(以前右值被看作是不可修改的,等同于const T&类型)。

C++的class或者struct以前都有一些隐含的成员函数:默认构造函数(仅当没有显式定义任何其他构造函数时才存在),拷贝构造函数,析构函数还有拷贝赋值操作符。拷贝构造函数和拷贝赋值操作符提供bit-wise的拷贝(浅拷贝),也就是逐个bit拷贝对象。也就是说,如果你有一个类包含指向其他对象的指针,拷贝时只会拷贝指针的值而不会管指向的对象。在某些情况下这种做法是没问题的,但在很多情况下,实际上你需要的是深拷贝,也就是说你希望拷贝指针所指向的对象。而不是拷贝指针的值。这种情况下,你需要显式地提供拷贝构造函数与拷贝赋值操作符来进行深拷贝。

如果你用来初始化或拷贝的源对象是个右值(临时对象)会怎么样呢?你仍然需要拷贝它的值,但随后很快右值就会被释放。这意味着产生了额外的操作开销,包括原本并不需要的空间分配以及内存拷贝。

现在说说move constructor和move assignment operator。这两个函数接收T&&类型的参数,也就是一个右值。在这种情况下,它们可以修改右值对象,例如“偷走”它们内部指针所指向的对象。举个例子,一个容器的实现(例如vector或者queue)可能包含一个指向元素数组的指针。当用一个临时对象初始化一个对象时,我们不需要分配另一个数组,从临时对象中把值复制过来,然后在临时对象析构时释放它的内存。我们只需要将指向数组内存的指针值复制过来,由此节约了一次内存分配,一次元数组的复制以及后来的内存释放。

以下代码实现了一个简易的buffer。这个buffer有一个成员记录buffer名称(为了便于以下的说明),一个指针(封装在unique_ptr中)指向元素为T类型的数组,还有一个记录数组长度的变量。

 

16.png

默认的copy constructor以及copy assignment operator大家应该很熟悉了。C++11中新增的是move constructor以及move assignment operator,这两个函数根据上文所描述的move语义实现。如果你运行这段代码,你就会发现b4构造时,move constructor会被调用。同样,对b1赋值时,move assignment operator会被调用。原因就在于getBuffer()的返回值是一个临时对象——也就是右值。

你也许注意到了,move constuctor中当我们初始化变量name和指向buffer的指针时,我们使用了std::move。name实际上是一个string,std::string实现了move语义。std::unique_ptr也一样。但是如果我们写_name(temp._name),那么copy constructor将会被调用。不过对于_buffer来说不能这么写,因为std::unique_ptr没有copy constructor。但为什么std::string的move constructor此时没有被调到呢?这是因为虽然我们使用一个右值调用了Buffer的move constructor,但在这个构造函数内,它实际上是个左值。为什么?因为它是有名字的——“temp”。一个有名字的对象就是左值。为了再把它变为右值(以便调用move constructor)必须使用std::move。这个函数仅仅是把一个左值引用变为一个右值引用。

完美转发(perfect forwarding),指的是在函数模板中,完全依照模板的参数的类型,将参数传递给函数模板中调用的另外一个函数,来看一个例子(C++11中,用于完美转发的函数名为forward):

 

17.png

右值引用对于简化代码并无好处(事实上它让代码更复杂了),其主要优势在于性能。但是,对于不太熟悉C++规则的开发人员来说,自己的代码是否能正确调用到移动构造函数不是件容易检查的事,因为不管是否使用右值引用,都不会出现编译和运行时错误,只有通过性能实测才能检验结果。右值引用也带来了一些新的陷阱,比如通过std::move来强行把左值转成右值会导致左值失效,如果后续继续使用这个左值将带来难以预测的后果。另一方面,自定义移动构造函数中如果忘了把传入的右值置为无效(如空指针),将导致析构函数出现运行时错误。结合标准库使用时,右值引用有更多隐含的注意事项,比如std::vector会检查移动构造函数是否带有noexcept修饰符,如果没有则在迁移容器元素时不会使用移动构造,使得性能无法提升。

鉴于上面这些难以觉察的陷阱,建议在开发时,对于性能要求并非特别高的场景,慎用右值引用。另一方面,必须给开发人员做好培训让他们了解清楚右值引用的各种注意事项。对于移动构造和移动赋值函数,应当声明为noexcept。

完美转发需要利用右值引用特性,其语法原理相当的晦涩和不直观,而且需要用到的场景也很有限。

相关链接:https://zh.cppreference.com/w/cpp/language/reference

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值