C++11特性

本篇总结 C++11 特性,参考书目为《深入理解 C++11:C++11 新特性解析与应用》

其一:关键字

constexpr

  C++ 中 const 类型只在初始化后才意味着它的值应该是常量表达式,从而在运行时不能被改变。不过由于初始化依旧是动态的,这对 ROM 设备来说并不适用。这就要求在动态初始化前就将常量计算出来。为此标准增加了 constexpr,它让函数和变量可以被编译时的常量取代。

使用范围:

  • 修饰的函数只能包括 return 语句。
  • 修饰的函数只能引用全局不变常量。
  • 修饰的函数只能调用其他 constexpr 修饰的函数。
  • 函数不能为 void 类型和,并且 prefix operation(v++)不允许出现。

  对于自定义类型的数据,需要自定义常量构造函数来使其成为常量表达式值。

  常量表达式的构造函数使用要求;

  • 函数体必须为空
  • 初始化列表只能由常量表达式来赋值

  注意:不允许常量表达式作用于virtual的成员函数。“运行时”与“可以在编译时进行值计算”的constexpr的意义时冲突的。

  常量表达式构造函数也可用于非常量表达式中的类型构造,不必为该类型再重写一个非常量表达式版本。

  ‍

与 const 区别:

  修饰函数的时候两者之间最基本的区别是:

  const 修饰一个对象表示它是常量。这暗示对象一经初始化就不会再变动了,并且允许编译器使用这个特点优化程序。这也防止程序员修改了本不应该修改的对象。

  constexpr 是修饰一个常量表达式。但请注意 constexpr 不是修饰常量表达式的唯一途径。

  • const 只能用于非静态成员的函数而不是所有函数。它保证成员函数不修改任何非静态数据。

  • constexpr 可以用于含参和无参函数。

  • constexpr 函数适用于常量表达式,只有在下面的情况下编译器才会接受 constexpr 函数:

      1. 函数体必须足够简单,除了 typedef 和静态元素,只允许有 return 语句。如构造函数只能有初始化列表,typedef 和静态元素 (实际上在 C++14 标准中已经允许定义语句存在于 constexpr 函数体内了
      1. 参数和返回值必须是字面值类型

static_assert

  断言(assertion)是一种编程中常用的手段。在通常情况下,断言就是将一个返回值总是需要为真的判别式放在语句中,用于排除在设计的逻辑上不应该产生的情况。在 C++ 中,程序员也可以定义宏 NDEBUG 来禁用 assert 宏。

  通过预处理指令 if 和 error 的配合,也可以让程序员在预处理阶段进行断言。

  断言 assert 宏只有在程序运行时才能起作用。而#error 只在编译器预处理时才能起作用,有的时候,我们希望在编译时能做一些断言.

  在 C++11 标准中,引入了 static_assert 断言来解决这个问题。

使用方式

  接收两个参数,一个是断言表达式,这个表达式通常需要返回一个 bool 值;一个则是警告信息,它通常也就是一段字符串。

  • static_assert 是编译时期的断言
  • static_assert 可以用于任何名字空间
  • static_assert 的断言表达式的结果必须是在编译时期可以计算的表达式,即必须是常量表达式。如果读者使用了变量,则会导致错误

noexcept

  noexcept 形如其名地,表示其修饰的函数不会抛出异常

  在 C++11 中如果 noexcept 修饰的函数抛出了异常,编译器可以选择直接调用 std::terminate()函数来终止程序的运行,这比基于异常机制的 throw()在效率上会高一些

  虽然 noexcept 修饰的函数通过 std::terminate 的调用来结束程序的执行的方式可能会带来很多问题,比如无法保证对象的析构函数的正常调用,无法保证栈的自动释放等,但很多时候,“暴力”地终止整个程序确实是很简单有效的做法

  C++11 默认将 delete 函数设置成 noexcept

  C++11 标准中让类的析构函数默认也是 noexcept(true)的。

final

  final 关键字的作用是使派生类不可覆盖它所修饰的虚函数

  final 通常只在继承关系的“中途”终止派生类的重载中有意义

  final/override 也可以定义为正常变量名,只有在其出现在函数后时才是能够控制继承/派生的关键字

  ‍

explicit

  在C++11中,标准将explicit的使用范围扩展到了自定义的类型转换操作符上,以支持所谓的“显式类型转换”

  显式类型转换并没完全禁止从源类型到目标类型的转换,不过由于此时拷贝构造和非显式类型转换不被允许,那么我们通常就不能通过赋值表达式或者函数参数的方式来产生这样一个目标类型

  ‍

auto类型推导

静态类型与动态类型

  静态类型和动态类型的主要区别在于对变量进行类型检查的时间点。

  对于所谓的静态类型,类型检查主要发生在编译阶段;而对于动态类型,类型检查主要发生在运行阶段

  auto声明变量的类型必须由编译器在编译时期推导而得

  auto声明的变量必须被初始化,以使编译器能够从其初始化表达式中推导出其类型。从这个意义上来讲,auto并非一种“类型”声明,而是一个类型声明时的“占位符”,编译器在编译时期会将auto替代为变量实际的类型。

使用方式

  结合volatile和const

volatile和const代表了变量的两种不同的属性:易失的和常量的。在C++标准中,它们常常被一起叫作cv限制符(cv-qualifier)

C++11标准规定auto可以与cv限制符一起使用,不过声明为auto的变量并不能从其初始化表达式中“带走”cv限制符

例外是引用,声明为引用的变量e、g都保持了其引用的对象相同的属性

  ‍

  用auto来声明多个变量类型时,只有第一个变量用于auto的类型推导,然后推导出来的数据类型被作用于其他的变量,所以不允许这些变量的类型不相同

  ‍

不能推导的情况

  • auto是不能做形参的类型的。如果程序员需要泛型的参数,还是需要求助于模板。
  • 对于结构体来说,非静态成员变量的类型不能是auto的
  • 声明auto z[3]这样的数组同样会被编译器禁止
  • 实例化模板的时候使用auto作为模板参数

  ‍

  ‍

  ‍

  ‍

  ‍

  ‍

  ‍

decltype

RTTI——运行时类型识别

  • RTTI的机制是为每个类型产生一个type_info类型的数据,程序员可以在程序中使用typeid随时查询一个变量的类型,typeid就会返回变量相应的type_info数据
  • 除了typeid外,RTTI还包括了C++中的dynamic_cast等特性。

很多时候,运行时才确定出类型对于程序员来说为时过晚,程序员更多需要的是在编译时期确定出类型(标准库中非常常见)。而通常程序员是要使用这样的类型而不是识别该类型,因此RTTI无法满足需求

decltype的类型推导并不是像auto一样是从变量声明的初始化表达式获得变量的类型,decltype总是以一个普通的表达式为参数,返回该表达式的类型。

使用规则

  当程序员用decltype(e)来获取类型时,编译器将依序判断以下四规则:

  1. 如果e是一个没有带括号的标记符表达式(id-expression)或者类成员访问表达式,那么decltype(e)就是e所命名的实体的类型。此外,如果e是一个被重载的函数,则会导致编译时错误。
  2. 否则,假设e的类型是T,如果e是一个将亡值(xvalue),那么decltype(e)为T&&。
  3. 否则,假设e的类型是T,如果e是一个左值,则decltype(e)为T&。
  4. 否则,假设e的类型是T,则decltype(e)为T。

  与auto类型推导时不能“带走”cv限制符不同,decltype是能够“带走”表达式的cv限制符的。不过,如果对象的定义中有const或volatile限制符,使用decltype进行推导时,其成员不会继承const或volatile限制符

  decltype从表达式推导出类型后,进行类型定义时,也会允许一些冗余的符号。比如cv限制符以及引用符号&,通常情况下,如果推导出的类型已经有了这些属性,冗余的符号则会被忽略

  深入理解C++11:C++11新特性解析与应用-Michael Wong IBM XL编译器中国开发团队-微信读书

  image

  综合auto decltype 和追踪返回类型

  追踪返回类型的函数和普通函数的声明最大的区别在于返回类型的后置。

  ‍

四种类型转换

  RTTI——运行时类型识别

  为什么会有RTTI?多态需要运行时类型识别

  缺点:破坏了抽象;由于类型不确定,程序变脆弱;程序缺乏扩展性

  typeid操作符返回结果是名为type_info的标准库类型的对象的引用。在头文件typeinfo中定义,有两种形式:1、typeid(type) 2、typeid(expression)

  表达式的类型是类类型且至少含有一个虚函数,typeid操作符返回表达式的动态类型,需要在运行时计算,否则返回表达式的静态类型。

  ​type_info​类提供了public虚析构函数,以使用户能够用其作为基类。它的默认构造函数和复制构造函数及赋值操作符都定义为private,所以不能定义或复制type_info 类型的对象。 程序中创建type_info对象的唯一方法是使用typeid操作符。 由此可见,如果把typeid看作函数的话,其应该是type_info的友元。 这具体由编译器的实现所决定,标准只要求实现为每个类型返回唯一的字符串。

  问题:C++里面的typeid运箕符返回值是什么?

  答: 常量对象的引用。

  如果p是基类指针,并且指向一个派生类型的对象,并且基类中有虚函数,那么typeid(*p)​返回p所指向的派生类类型,typeid(p)​返回基类类型。

  RTTI的实现原理: 通过在虚表中放一个额外的指针,每个新类只产生一个typeinfo实例,额外指针指向typeinfo, typeid返回对它的一个引用。

  ‍

static_cast

  static_cast(expression) :把expression转换为type类型

  使用场景:

  1. 进行void*类型指针和有类型指针之间的转换
  2. 执行语言支持的基本类型转换
  3. 在继承层次中执行向下的强制转换(下行转换,不安全,没有类型检查)
  4. 子类指针或引用向上转成父类的指针或引用 (上行转换,安全)
  5. 枚举间,枚举与int float间转换

  注意:

  • static_cast不能转换掉expression的const、volatile和_unaligned属性
  • 编译器隐式执行任何类型转换都可以由static_cast显示完成
  • static_cast用于有直接或间接关系的指针或引用之间转换,没有继承关系的指针不能用此转换
  • static_cast效率比dynamic_cast高。大部分情况下static_cast编译时就可以确定指针移动多少偏移量,但是对于虚继承要用到虚指针确定一个到虚基类的偏移量。

  ‍

  ‍

dynamic_cast

  动态映射可以映射到中间层级,将派生类映射到任何一个基类,然后在基类之间可以互相映射

  dynamic_cast安全性:包含类型检查,转换成功会返回目标类型指针,失败会返回NULL,相对于static_cast安全

  工作原理:

  type_info是C++ Standard所定义的类型描述器,放置着每个类的类型信息,而虚表中的第一个slot变指向type_info的地址。

  derived*的类型会由编译器产生出来,b的类型则在运行时由dynamic_cast从虚表中取出。

  dynamic_cast对比两种类型,再决定能否转化。

  由上可知:运行时的对象信息储存在虚表中。所以要使用dynamic_cast,类中必须要有一个虚方法,否则会导致编译错误。

  ‍

dynamic_cast 运算符可以去掉 cv 属性。如果源类型和目标类型的 cv 属性不同,dynamic_cast 运算符会将 cv 属性从源类型中去掉,然后进行转换。¹²

如果源类型和目标类型的 cv 属性相同,dynamic_cast 运算符会执行与 static_cast 运算符相同的转换。³

源: 与必应的对话, 2023/6/21
(1) dynamic_cast 运算符 | Microsoft Learn. https://learn.microsoft.com/zh-cn/cpp/cpp/dynamic-cast-operator?view=msvc-170.
(2) 这下可以安心使用 dynamic_cast 了:dynamic_cast 的实现 …. https://zhuanlan.zhihu.com/p/580330672.
(3) C++类型转换之dynamic_cast - 知乎. https://zhuanlan.zhihu.com/p/459523323.

static_cast与dynamic_cast

  • dynamic_cast可以将一个多态虚基类转换成子类或邻近兄弟类。但是static_cast不能,因为它不会对它的操作对象进行检查类型。
  • 编译器无法预知被void指针指向的内存的信息。因此,对于需要查看对象类型的dynamic_cast而言,它无法对void进行转换。而static_cast在这种情况下可以做到。

const_cast

  static_cast是不能去掉const,const_cast是专门用来去掉const

  添加const,static_cast也可以添加上const,只是不能去掉const

  ‍

reinterpret_cast

  为操作数的位模式提供较低层的重新解释。

  最不安全,可以把指针转换一个整数,也可以把整数转换成指针

  尽量避免使用

  ‍

  ‍

“=default”与”=deleted”

  C++11标准称“= default”修饰的函数为显式缺省(explicit defaulted)函数,而称“= delete”修饰的函数为删除(deleted)函数

  C++11引入显式缺省和显式删除是为了增强对类默认函数的控制,让程序员能够更加精细地控制默认版本的函数。不过这并不是它们的唯一功能,而且使用上,也不仅仅局限在类的定义内。事实上,显式缺省不仅可以用于在类的定义中修饰成员函数,也可以在类定义之外修饰成员函数

  ‍

  程序员在使用显式删除时候,应该总是避免explicit关键字修饰的函数,反之亦然。

  ‍

  在一些情况下,我们需要对象在指定内存位置进行内存分配,并且不需要析构函数来完成一些对象级别的清理。这个时候,我们可以通过显式删除析构函数来限制自定义类型在栈上或者静态的构造

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <cstddef>
#include <new>
extern void* p;
class NoStackAlloc{
public:
NoStackAlloc() = delete;
};
int main(){
NoStackAlloc nsa; // 无法通过编译
new (p) NoStackAlloc(); // placement new, 假设p无需调用析构函数
return 1;
}
// 编译选项:g++ 7-2-10.cpp -std=c++11-c

  由于placement new构造的对象,编译器不会为其调用析构函数,因此析构函数被删除的类能够正常地构造。

  placement new机制 - 知乎 (zhihu.com)

其二:预定义宏

func

  预定义标识符功能,其基本功能就是返回所在函数的名字

  按照标准定义,编译器会隐式地在函数的定义之后定义__func__标识符。不过将__fun__标识符作为函数参数的默认值是不允许的,这是由于在参数声明时,__func__还未被定义

_Pragma

  类似#pragma,是用来向编译器传达语言标准以外的一些信息。

使用格式:

  _Pragma(字符串字面量)

  例如 _Pragma(“once”)

与预处理指令#pragma 区别:

  相比预处理指令#pragma,由于_Pragma 是一个操作符,因此可以用在一些宏中。

  #pragma 则不能在宏中展开,因此从灵活性上来讲,C++11 的_Pragma 具有更大的灵活性。

  _VA_ARGS

  变长参数的宏定义是指在宏定义中参数列表的最后一个参数为省略号,而预定义宏__VA_ARGS__则可以在宏定义的实现部分替换省略号所代表的字符串。

   #define PR(…) printf(VA_ARGS)

__cplusplus

  ‍

1
2
3
4
5
6
7
#ifdef __cplusplus
extern "C" {
#endif
// 一些代码
#ifdef __cplusplus
}
#endif

  _cplusplus 这个宏通常被定义为一个整型值。

  由于 extern “C”可以抑制 C++ 对函数名、变量名等符号(symbol)进行名称重整(name mangling),因此编译出的 C 目标文件和 C++ 目标文件中的变量、函数名称等符号都是相同的(否则不相同),链接器可以可靠地对两种类型的目标文件进行链接。这样该做法成为了 C 与 C++ 混用头文件的典型做法。

其三:基础类型

  在 C++11 标准中,在将窄字符串和宽字符串进行连接时,支持 C++11 标准的编译器会将窄字符串转换成宽字符串,然后再与宽字符串进行连接。

  在 C++11 中,标准要求 long long 整型可以在不同平台上有不同的长度,但至少有 64 位。

  long long int lli = -9000000000000000000LL;

  unsigned long long int ulli = -9000000000000000000ULL;

  同其他的整型一样,要了解平台上 long long 大小的方法就是查看 (或 <limits. h> 中的宏)

  对于 printf 函数来说,输出有符号的 long long 类型变量可以用符号 %lld,而无符号的 unsigned long long 则可以采用 %llu。

  C++11 中一共只定义了以下 5 种标准的有符号整型:

  • signed char
  • short int
  • int
  • long int
  • long long int

  每一种有符号整型都有一种对应的无符号整数版本,且有符号整型与其对应的无符号整型具有相同的存储空间大小。

  而在进行隐式的整型转换的时候,一般是按照低等级整型转换为高等级整型,有符号的转换为无符号。

POD类型

概念

  概念解释

  两个基本概念:平凡的(trivial)和标准布局的(standard layout)

平凡定义:

1、拥有默认构造函数和析构函数,也就是什么都不干

2、平凡的拷贝构造函数,基本上等同于使用memcpy进行类型构造

3、平凡的拷贝赋值运算符和移动赋值运算符

4、不包含虚函数和虚基类

  ‍

  标准布局定义:

  • 所有非静态成员有相同的访问权限

  • 在类或者结构体继承时,满足以下两种情况之一:

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

    • 基类有非静态成员,而派生类没有非静态成员。

      也就是非静态成员只要同时出现在派生类和基类间,其即不属于标准布局的

      一旦非静态成员出现在多个基类中,派生类也不属于标准布局的。

  • 类中第一个非静态成员的类型和其基类不同 深入理解C++11:C++11新特性解析与应用-Michael Wong IBM XL编译器中国开发团队-微信读书

  • 没有虚函数和基类

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

使用好处

  • 字节赋值,代码中我们可以安全地使用memset和memcpy对POD类型进行初始化和拷贝等操作
  • 提供对C内存布局兼容。C++程序可以与C函数进行相互操作,因为POD类型的数据在C与C++间的操作总是安全的
  • 保证了静态初始化的安全有效。静态初始化在很多时候能够提高程序的性能,而POD类型的对象初始化往往更加简单(比如放入目标文件的.bss段,在初始化中直接被赋0)

  ‍

  ‍

指针空值nullptr

  在C++11标准中,nullptr是一个所谓“指针空值类型”的常量。指针空值类型被命名为nullptr_t

  • 所有定义为nullptr_t类型的数据都是等价的,行为也是完全一致
  • nullptr_t类型数据可以隐式转换成任意一个指针类型
  • nullptr_t类型数据不能转换为非指针类型,即使使用reinterpret_cast()的方式也是不可以的。
  • nullptr_t类型数据不适用于算术运算表达式。
  • nullptr_t类型数据可以用于关系运算表达式,但仅能与nullptr_t类型数据或者指针类型数据进行比较,当且仅当关系运算符为==、<=、>=等时返回true。

  ‍

  在把nullptr_t应用于模板中时候,我们会发现模板却只能把它作为一个普通的类型来进行推导(并不会将其视为T*指针

  要让编译器成功推导出nullptr的类型,必须做显式的类型转换

  ‍

  在C++11标准中,nullptr类型数据所占用的内存空间大小跟void*相同的

  与(void*)0区别:

  nullptr是一个编译时期的常量,它的名字是一个编译时期的关键字,能够为编译器所识别。

  而(void*)0只是一个强制转换表达式,其返回的也是一个void指针类型。而且最为重要的是,在C++语言中,nullptr到任何指针的转换是隐式的,而(void)0则必须经过类型转换后才能使用

  ‍

  nullptr被定义为一个右值常量,取其地址并没有意义,不过C++11标准并没有禁止声明一个nullptr的右值引用,并打印其地址

其四:其他特性

快速初始化成员变量

  在 C++98 中,支持了在类声明中使用等号“=”加初始值的方式,来初始化类中静态成员常量。这种声明方式我们也称之为“就地”声明。

  不过 C++98 对类中就地声明的要求却非常高。如果静态成员不满足常量性,则不可以就地声明,而且即使常量的静态成员也只能是整型或者枚举型才能就地初始化。而非静态成员变量的初始化则必须在构造函数中进行。

  ​

  • 在 C++11 中,标准允许非静态成员变量的初始化有多种形式。具体而言,除了初始化列表外,在 C++11 中,标准还允许使用等号=或者花括号{}进行就地的非静态成员变量初始化。
  • 初始化列表的效果总是优先于就地初始化
  • 对于非常量的静态成员变量,C++11 则与 C++98 保持了一致。程序员还是需要到头文件以外去定义它,这会保证编译时,类静态成员的定义最后只存在于一个目标文件中。

  使用列表初始化的一个最大优势是防止类型收窄 深入理解C++11:C++11新特性解析与应用-Michael Wong IBM XL编译器中国开发团队-微信读书

扩展的 friend 语法

  在 C++11 中,声明一个类为另外一个类的友元时,不再需要使用 class 关键字

1
2
3
4
5
6
7
8
class P;
template class People {
friend T;
};
People PP; // 类型P在这里是People类型的友元
People Pi; // 对于int类型模板参数,友元声明被忽略
// 编译选项:g++ -std=c++11 2-9-2.cpp

  内置类型 int 作为模板参数的时候,People 会被实例化为一个普通的没有友元定义的类型

  这样一来,我们就可以在模板实例化时才确定一个模板类是否有友元,以及谁是这个模板类的友元

模板函数的默认模板参数

  与类模板有些不同的是,在为多个默认模板参数声明指定默认值的时候,程序员必须遵照“从右往左”的规则进行指定。而这个条件对函数模板来说并不是必须的

  模板函数的默认形参不是模板参数推导的依据。函数模板参数的选择,总是由函数的实参推导而来的

外部模板

  部模板的使用实际依赖于 C++98 中一个已有的特性,即显式实例化(Explicit Instantiation)。显式实例化的语法很简单,比如对于以下模板:

  template void fun(T) {}

   我们只需要声明

  template void fun(int);

  这就可以使编译器在本编译单元中实例化出一个 fun(int)版本的函数(这种做法也被称为强制实例化

  可以通过: extern template void fun(int);这样的语法完成一个外部模板的声明。

  外部模板声明不能用于一个静态函数(即文件域函数),但可以用于类静态成员函数(这一点是显而易见的,因为静态函数没有外部链接属性,不可能在本编译单元之外出现)。

  ‍

模板的别名

  在C++11中,定义别名已经不再是typedef的专属能力,使用using同样也可以定义类型的别名,而且从语言能力上看,using丝毫不比typedef逊色

  ‍

  ‍

继承构造函数

  假设基类有很多构造函数,派生类只是修改了一点代码,但是如果想要拥有同样多的构造函数的话需要一一”透传“各个接口。

  如果派生类要使用基类的成员函数的话,可以通过 using 声明(using-declaration)来完成。

  子类可以通过使用 using 声明来声明继承基类的构造函数。

  ​

  • C++11 标准继承构造函数被设计为跟派生类中的各种类默认函数(默认构造、析构、拷贝构造等)一样,是隐式声明的。这意味着如果一个继承构造函数不被相关代码使用,编译器不会为其产生真正的函数代码。这无疑比“透传”方案总是生成派生类的各种构造函数更加节省目标代码空间。
  • 对于继承构造函数来讲,参数的默认值是不会被继承的
  • 如果基类的构造函数被声明为私有成员函数,或者派生类是从基类中虚继承的,那么就不能够在派生类中声明继承构造函数
  • 如果一旦使用了继承构造函数,编译器就不会再为派生类生成默认构造函数了

委托构造函数

  • 在 C++11 中,所谓委派构造,就是指委派函数将构造的任务委派给了目标构造函数来完成这样一种类构造的方式,C++11 中的委派构造函数是在构造函数的初始化列表位置进行构造的、委派的。
  • 在 C++ 中,构造函数不能同时“委派”和使用初始化列表,所以如果委派构造函数要给变量赋初值,初始化代码必须放在函数体中。
  • 在使用委派构造函数的时候,我们也建议程序员抽象出最为“通用”的行为做目标构造函数
  • 在委托构造的链状关系中,有一点程序员必须注意,就是不能形成委托环
  • 委派构造的一个很实际的应用就是使用构造模板函数产生目标构造函数,委托构造使得构造函数的泛型编程也成为了一种可能。
  • 此外,在异常处理方面,如果在委派构造函数中使用 try 的话,那么从目标构造函数中产生的异常,都可以在委派构造函数中被捕捉到。

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

  编写 C++ 程序有一条必须注意的规则,就是在类中包含了一个指针成员的话,那么就要特别小心拷贝构造函数的编写,因为一不小心,就会出现内存泄露。

  传递临时对象,临时对象将在语句结束后被析构,会释放堆内存资源,而在拷贝构造的时候又会被分配堆内存。这样一去一来没有意义,所以要在临时对象传递来后构造新的变量的时候不重新分配内存,也就是偷走临时变量中的资源。

  在 C++11 中,这样的“偷走”临时变量中资源的构造函数,就被称为“移动构造函数”。而这样的“偷”的行为,则称之为“移动语义”(move semantics)。

右值概念

  可以取地址的、有名字的就是左值,反之,不能取地址的、没有名字的就是右值。

  更为细致地,在 C++11 中,右值是由两个概念构成的,一个是将亡值(xvalue,eXpiring Value),另一个则是纯右值(prvalue,Pure Rvalue)。

  纯右值就是 C++98 标准中右值的概念,讲的是用于辨识临时变量和一些不跟对象关联的值。比如非引用返回的函数返回的临时变量值(我们在前面多次提到了)就是一个纯右值。一些运算表达式,比如 1 + 3 产生的临时变量值,也是纯右值。而不跟对象关联的字面量值,比如:2、‘c’、true,也是纯右值。此外,类型转换函数的返回值、lambda 表达式(见 7.3 节)等,也都是右值。

  将亡值则是 C++11 新增的跟右值引用相关的表达式,这样表达式通常是将要被移动的对象(移为他用),比如返回右值引用 T&&的函数返回值、std::move 的返回值(稍后解释),或者转换为 T&&的类型转换函数的返回值(稍后解释

  无论是声明一个左值引用还是右值引用,都必须立即进行初始化。

  在常量左值引用在 C++98 标准中开始就是个“万能”的引用类型。它可以接受非常量左值、常量左值、右值对其进行初始化。而且在使用右值对其初始化的时候,常量左值引用还可以像右值引用一样将右值的生命期延长。不过相比于右值引用所引用的右值,常量左值所引用的右值在它的“余生”中只能是只读的。相对地,非常量左值只能接受非常量左值对其进行初始化。

C++11 引用类型及其可以引用的值类型

  ​

  标准库在 头文件中提供了 3 个模板类:is_rvalue_reference、is_lvalue_reference、is_reference,可供我们进行判断。

std::move

  • std::move 并不能移动任何东西,它唯一的功能是将一个左值强制转化为右值引用
  • 被转化的左值,其生命期并没有随着左右值的转化而改变
  • 因此需要自己确定该不该用,我们需要转换成为右值引用的是一个确实生命期即将结束的对象
  • 为了保证移动语义的传递,程序员在编写移动构造函数的时候,应该总是记得使用 std::move 转换拥有形如堆内存、文件句柄等资源的成员为右值,这样一来,如果成员支持移动构造的话,就可以实现其移动语义。

  移动语义,还有一个比较典型的应用是可以实现高性能的置换(swap)函数

  ​

  如果 T 是可以移动的,那么移动构造和移动赋值将会被用于这个置换。代码中,a 先将自己的资源交给 tmp,随后 b 再将资源交给 a,tmp 随后又将从 a 中得到的资源交给 b,从而完成了一个置换动作。整个过程,代码都只会按照移动语义进行指针交换,不会有资源的释放与申请。而如果 T 不可移动却是可拷贝的,那么拷贝语义会被用来进行置换。这就跟普通的置换语句是相同的了

  为了避免移动语义还没完成就抛出异常,导致指针成为悬空指针可以用一个 std::move_if_noexcept 的模板函数替代 move 函数。该函数在类的移动构造函数没有 noexcept 关键字修饰时返回一个左值引用从而使变量可以使用拷贝语义,而在类的移动构造函数有 noexcept 关键字时,返回一个右值引用,从而使变量可以使用移动语义

完美转发

  完美转发(perfect forwarding),是指在函数模板中,完全依照模板的参数的类型,将参数传递给函数模板中调用的另外一个函数

  C++11是通过引入一条所谓“引用折叠”(reference collapsing)的新语言规则,并结合新的模板推导规则来完成完美转发

  image

  完美转发的一个作用就是做包装函数,这是一个很方便的功能

  ‍

非受限联合体(Union)

  标准规定,任何非引用类型都可以成为联合体的数据成员,这样的联合体即所谓的非受限联合体(Unrestricted Union)

  匿名非受限联合体可以运用于类的声明中,这样的类也被称为“枚举式的类”(union-like class)

  深入理解C++11:C++11新特性解析与应用-Michael Wong IBM XL编译器中国开发团队-微信读书

  ‍

基于范围的for循环

  for循环后的括号由冒号“:”分为两部分,第一部分是范围内用于迭代的变量,第二部分则表示将被迭代的范围

  基于范围的循环使用在标准库的容器中时,如果使用auto来声明迭代的对象的话,那么这个对象不会是迭代器对象

  image

  ‍

强类型枚举

  非强类型枚举:

  C/C++的enum有个很“奇怪”的设定,就是具名(有名字)的enum类型的名字,以及enum的成员的名字都是全局可见的。这与C++中具名的namespace、class/struct及union必须通过“名字::成员名”的方式访问相比是格格不入的。

  另外,由于C中枚举被设计为常量数值的“别名”的本性,所以枚举的成员总是可以被隐式地转换为整型,很多时候,这也是不安全的。

  非强类型作用域,允许隐式转换为整型,占用存储空间及符号性不确定,都是枚举类的缺点

  针对这些缺点,新标准C++11引入了一种新的枚举类型,即“枚举类”,又称“强类型枚举” (strong-typed enum)。

  声明强类型枚举非常简单,只需要在enum后加上关键字class。

  强类型枚举具有以下几点优势:

  强作用域,强类型枚举成员的名称不会被输出到其父作用域空间

  转换限制,强类型枚举成员的值不可以与整型隐式地相互转换

  可以指定底层类型。强类型枚举默认的底层类型为int,但也可以显式地指定底层类型,具体方法为在枚举名称后面加上“:type”,其中type可以是除wchar_t以外的任何整型

  如

1
enum class Type: char { General, Light, Medium, Heavy };

  使用enum class的时候,应该总是为enum class提供一个名字,匿名的什么都做不了

  ‍

对原来枚举的改进

  也可以指定底层数据类型

1
enum Type: char { General, Light, Medium, Heavy };

  枚举成员的名字除了会自动输出到父作用域,也可以在枚举类型定义的作用域内有效

1
2
3
enum Type { General, Light, Medium, Heavy };
Type t1 = General;
Type t2 = Type::General;

  ‍

变长模板

  语法:

1
template <typename... Elements> class tuple;

  可以看到,我们在标示符Elements之前的使用了省略号(三个“点”)来表示该参数是变长的。

  在C++11中,Elements被称作是一个“模板参数包”(template parameter pack)。这是一种新的模板参数类型。有了这样的参数包,类模板tuple就可以接受任意多个参数作为模板参数。

  ‍

  通过定义递归的模板偏特化定义,我们可以使得模板参数包在实例化时能够层层展开,直到参数包中的参数逐渐耗尽或到达某个数量的边界为止

1
2
3
4
5
6
7
template <typename... Elements> class tuple;     // 变长模板的声明
template<typename Head, typename... Tail> // 递归的偏特化定义
class tuple<Head, Tail...> : private tuple<Tail...> {
Head head;
};
template<> class tuple<> {}; // 边界条件
// 编译选项:g++ -std=c++11 6-2-2.cpp

  在C++11中,标准要求函数参数包必须唯一,且是函数的最后一个参数(模板参数包没有这样的要求)。

  ‍

退出函数

  1. terminate
  2. abort
  3. exit

  直观地讲,只要C++程序中出现了非程序员预期的行为,都有可能导致terminate的调用。而terminate函数在默认情况下,是去调用abort函数的。不过用户可以通过set_terminate函数来改变默认的行为。因此,可以认为在C++程序的层面,termiante就是“终止”。

  C中(头文件)的abort则更加低层。abort函数不会调用任何的析构函数,默认情况下,它会向合乎POSIX标准的系统抛出一个信号(signal):SIGABRT。如果程序员为信号设定一个信号处理程序的话(signal handler),那么操作系统将默认地释放进程所有的资源,从而终止程序

  exit函数会正常调用自动变量的析构函数,并且还会调用atexit注册的函数。这跟main函数结束时的清理工作是一样的。在程序退出时(调用ANSI C定义的exit函数的时候),所有注册的函数都被调用,值得注意的是,注册的函数被调用的次序与其注册顺序相反,这多少跟析构函数的执行与其声明的顺序相反是一致的

  ‍

  但是,一一调用析构太慢,操作系统统一回收更快

  多线程下,exit可能会阻塞,无法退出

  C++11引入了quick_exit

  该函数并不执行析构函数而只是使程序终止。与abort不同的是,abort的结果通常是异常退出(可能系统还会进行coredump等以辅助程序员进行问题分析),而quick_exit与exit同属于正常退出。此外,使用at_quick_exit注册的函数也可以在quick_exit的时候被调用。这样一来,我们同样可以像exit一样做一些清理的工作(这与很多平台上使用_exit函数直接正常退出还是有不同的)。在C++11标准中,at_quick_exit和at_exit一样,标准要求编译器至少支持32个注册函数的调用。

  ‍

其五:智能指针

  C++ 智能指针最佳实践&源码分析 - 知乎 (zhihu.com)

  ​std::weak_ptr​的内部实现主要涉及以下几个关键点:

  1. 弱引用计数(weak reference count):std::weak_ptr​内部维护了一个弱引用计数,用于记录有多少个std::weak_ptr​对象共享同一个底层对象。
  2. 引用计数(reference count):底层对象使用std::shared_ptr​来管理引用计数,记录有多少个std::shared_ptr​对象共享该底层对象。
  3. 控制块(control block):std::shared_ptr​和std::weak_ptr​共享一个控制块,其中包含了指向底层对象的指针以及引用计数信息。
  4. 弱引用指针(weak reference pointer):std::weak_ptr​内部包含了一个指向控制块的指针,通过该指针可以访问底层对象。

  当一个std::shared_ptr​对象创建时,它会分配一个控制块并将指向底层对象的指针保存在该控制块中。同时,引用计数和弱引用计数都初始化为1。

  当另一个std::shared_ptr​对象通过拷贝构造或拷贝赋值创建时,它会与原有的std::shared_ptr​对象共享同一个控制块,并将引用计数加1。

  当一个std::shared_ptr​对象被销毁或重置时,它会将引用计数减1。如果减1后引用计数为0,表示没有任何std::shared_ptr​对象引用底层对象,此时会释放底层对象并销毁控制块。

  当一个std::weak_ptr​对象通过拷贝构造或拷贝赋值创建时,它会与原有的std::weak_ptr​对象共享同一个控制块,并将弱引用计数加1。

  ​std::weak_ptr​对象可以通过lock()​方法获取一个有效的std::shared_ptr​对象。该方法会检查控制块是否存在(即弱引用计数是否大于0),如果存在则返回一个指向底层对象的std::shared_ptr​对象,同时将引用计数加1。如果控制块不存在,则返回一个空的std::shared_ptr​对象。

  当一个std::shared_ptr​对象被销毁或重置时,它会将引用计数减1。如果减1后引用计数为0,表示没有任何std::shared_ptr​对象引用底层对象,此时会释放底层对象并销毁控制块。同时,会将弱引用计数减1。当弱引用计数也减为0时,控制块会被销毁,此时无法通过lock()​方法获取有效的std::shared_ptr​对象。

  通过上述机制,std::weak_ptr​可以安全地观测被std::shared_ptr​管理的对象,而不会增加其引用计数。这在解决循环引用问题、避免内存泄漏等场景中非常有用。

  ​std::weak_ptr​提供了两个主要的成员函数:lock()​和expired()​。

  1. lock()​函数:

    • lock()​函数用于获取std::weak_ptr​指向的对象的强引用(std::shared_ptr​),如果对象存在的话。
    • 当你调用lock()​函数时,它会检查std::weak_ptr​是否指向一个有效的对象。如果指向的对象仍然存在,则lock()​函数会返回一个有效的std::shared_ptr​,它可以被用于访问和操作该对象。
    • 如果std::weak_ptr​已经过期(指向的对象已经被释放),则lock()​函数会返回一个空的std::shared_ptr​。
    • 使用lock()​函数之前,建议先使用expired()​函数进行检查,以避免抛出异常。
  2. expired()​函数:

    • expired()​函数用于检查std::weak_ptr​指向的对象是否已经被释放(过期)。
    • 当你调用expired()​函数时,它会返回一个布尔值。如果返回true​,表示std::weak_ptr​已经过期,指向的对象已经被释放;如果返回false​,表示std::weak_ptr​仍然有效,指向的对象仍然存在。

  综上所述,lock()​函数用于获取指向对象的强引用(std::shared_ptr​),而expired()​函数用于检查std::weak_ptr​是否已经过期。通常,你可以先使用expired()​函数进行检查,然后再决定是否调用lock()​函数获取对象的强引用。这样可以确保在访问对象之前,先判断对象是否仍然存在,避免出现悬空指针的情况。

  ‍

其六:C++并行编程

  常见的并行编程有多种模型,如共享内存、多线程、消息传递等。

  多线程模型允许同一时间有多个处理器单元执行统一进程中的代码部分,而通过分离的栈空间和共享的数据区及堆栈空间,线程可以拥有独立的执行状态以及进行快速的数据共享。

  C/C++对线程的支持,一个最为重要的部分,就是在原子操作中引入了原子类型的概念。

  通过#include头文件来使用对应于内置类型的原子类型定义。定义表如下:

  ​image

  ‍

  不过更普遍地,可以使用atomic类模板。

  在C++11中,原子类型只能从其模板参数类型中进行构造,标准不允许原子类型进行拷贝构造、移动构造,以及使用operator=等,以防止发生意外

  ‍

内存模型,顺序一致性

  默认情况下,在C++11中的原子类型的变量在线程中总是保持着顺序执行的特性

  称这样的特性为“顺序一致”的,即代码在线程中运行的顺序与程序员看到的代码顺序一致,a的赋值语句永远发生于b的赋值语句之前

  为了解除性能约束,使得编译器可以优化指令执行,采用memory_order:让程序员指定内存顺序

  C++11中,标准一共定义了7种memory_order的枚举值:

  ​image

  由于memory_order_release和memory_order_acquire常常结合使用,我们也称这种内存顺序为release-acquire内存顺序。

  顺序一致、松散、release-acquire和release-consume通常是最为典型的4种内存顺序

  ‍

线程局部存储

  线程局部存储(TLS, thread local storage)是一个已有的概念。简单地说,所谓线程局部存储变量,就是拥有线程生命期及线程可见性的变量。

  全局、静态变量在多线程模型下总是在线程间共享的,但是有时候不希望这样。

  例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <pthread.h>
#include <iostream>
using namespace std;
int errorCode = 0;
void* MaySetErr(void * input) {
if (*(int*)input == 1)
errorCode = 1;
else if (*(int*)input == 2)
errorCode = -1;
else
errorCode = 0;
}
int main() {
int input_a = 1;
int input_b = 2;
pthread_t thread1, thread2;
pthread_create(&thread1, NULL, &MaySetErr, &input_a);
pthread_create(&thread2, NULL, &MaySetErr, &input_b);
pthread_join(thread2, NULL);
pthread_join(thread1, NULL);
cout << errorCode << endl;
}
// 编译选项:g++ 6-4-1.cpp -lpthread

  当两个线程运行函数的时候,最终的errorCode的值是不确定的。一旦errno在线程间共享,一些程序中的错误将会隐藏。

  解决方法:线程局部存储

  语法:通过thread_local修饰符声明变量

1
int thread_local errCode;

  一旦声明一个变量为thread_local,其值将在线程开始时被初始化,而在线程结束时,该值也将不再有效。对于thread_local变量地址取值(&),也只可以获得当前线程中的TLS变量的地址值

  ‍

  ‍

其七:lambda函数

语法定义:

  通常情况下,lambda函数的语法定义如下:

1
[capture](parameters) mutable->return-type {statement}

  其中,

  ❑ [capture]:捕捉列表。捕捉列表总是出现在lambda函数的开始处。事实上,[]是lambda引出符。编译器根据该引出符判断接下来的代码是否是lambda函数。捕捉列表能够捕捉上下文中的变量以供lambda函数使用

  ❑ (parameters):参数列表。与普通函数的参数列表一致。如果不需要参数传递,则可以连同括号()一起省略

  ❑ mutable:mutable修饰符。默认情况下,lambda函数总是一个const函数,mutable可以取消其常量性。在使用该修饰符时,参数列表不可省略(即使参数为空)。

  ❑ ->return-type:返回类型。用追踪返回类型形式声明函数的返回类型。出于方便,不需要返回值的时候也可以连同符号->一起省略。此外,在返回类型明确的情况下,也可以省略该部分,让编译器对返回类型进行推导。

  ❑ {statement}:函数体。内容与普通函数一样,不过除了可以使用参数之外,还可以使用所有捕获的变量。

  最简单的lambda函数 []{}

  ‍

  语法上,捕捉列表由多个捕捉项组成,并以逗号分割。捕捉列表有如下几种形式:

  ❑ [var]表示值传递方式捕捉变量var。

  ❑ [=]表示值传递方式捕捉所有父作用域的变量(包括this)。

  ❑ [&var]表示引用传递捕捉变量var。

  ❑ [&]表示引用传递捕捉所有父作用域的变量(包括this)。

  ❑ [this]表示值传递方式捕捉当前的this指针。

  注意 父作用域:enclosing scope,这里指的是包含lambda函数的语句块

  捕捉列表可以任意组合,但是要避免重复

  ‍

  在块作用域中的lambda函数仅能捕捉父作用域中的自动变量,捕捉任何非此作用域或者是非自动变量(如静态变量等)都会导致编译器报错

  ‍


C++11特性
https://shanhainanhua.github.io/2023/08/11/C++11特性/
作者
wantong
发布于
2023年8月11日
许可协议