C++ 11 新特性学习

C++11新特性学习

此篇学习笔记为本人学习《深入理解C++11 c++11新特性解析与应用》一书时记录的笔记,供以后使用时参考。
由于本人水平有限,对于很多地方的理解都是借鉴书中的内容,所给例子也多与书中内容类似。

由于新特性分布于C++语法的各个方面,通过浏览该书目录不难发现,作者是按照这些特性的特点来分类讲解的,由于本人按顺序读此本书,因此也按此顺序记录(第一章介绍C++11“新”标准的诞生,之后从第二章开始):

一. 保证稳定性与兼容性
二. 通用文本,专用为末
三. 新手易学,老兵易用
四. 提高安全类型
五. 提高性能及操作硬件的能力
六. 为改变思考方式而改变

特性速查

保证稳定性与兼容性 通用文本,专用为末 新手易学,老兵易用 提高安全类型 提高性能及操作硬件的能力 为改变思考方式而改变
C99兼容 继承构造函数 右尖括号的改进 强枚举类型 常量表达式 空指针nullptr
函数的默认模板参数 委托构造函数 auto 智能指针 变长模板 函数的默认控制
扩展的friend语法 移动语义,完美转发,引用折叠 decltype追踪返回类型语法 垃圾回收机制 原子操作/内存模型 lambda函数
扩展的整型 显示转换操作符 基于范围的for循环 线程局部存储
外部模板 统一的初始化语法,初始化列表 快速退出进程
类成员初始化 非受限联合体
局部类用作模板参数 用户自定义字面量UDL
long long整型 一般化SFINAE规则
__cplusplus 内联名字空间
noexcept PODs模板别名
override/fianl控制
静态断言
类成员的sizeof

一. 保证稳定性与兼容性

单击返回主目录

要点速查:
C99兼容
函数的默认模板参数
扩展的friend语法
扩展的整型
外部模板
类成员初始化
局部类用作模板参数
long long整型
__cplusplus
noexcept
override/fianl控制
静态断言
类成员的sizeof

概述

在设计语言时,往往需要同时兼顾稳定性和兼容性,为了不破坏现有代码,C++11只在非常必要的情况下才引入(改变)关键字。


1. 保持与C99兼容

​ 1.1 预定义宏
​ __STDC_HOSTED__
​ __STDC__
​ __STDC_VERSION__
​ __STDC_IOS__
​ 具体内容暂不详细记录,可以自行查阅。

​ 1.2 __func__预定义标识符
​ 该标识符可以返回调用其函数的名字在轻量级调试有用,其甚至可以放在初始化列表中,但不能作为函数的默认参数,因为根据定义 的标准,__func__ 会隐式的在函数定义之后定义。

​ 1.3 __Pragma操作符
​ C++11定义了与预处理指令#pragma功能相同的操作符__Pragma#pragma once__Pragma("once")效果一致,表示头文件只 包含一次。但是由于__Pragma是一个操作符,因此可以用在一些宏中。例如#define PRAGMA(X) __Pragma(#x)这种宏定义。其中# 代表将其后面的宏参数字符串化。

​ 1.4 变长参数的宏定义以及__VA_ARGS
​ 变长参数的宏定义是指在宏定义中参数列表的最后一个参数为省略号,而预定义的__VA_ARGS则可以在宏定义的实现部分替换省略号所代表的字符串。如#define PR(...) printf(__VA_AGRS__)配合printf函数非常好用,可以用来调试时定位代码位置。

​ 1.5 宽窄字符串的连接
​ C++11之前,将窄字符串(char)转换成宽字符串(wchar_t)是未定义的行为,而在C++11标准中,在将窄字符串与宽字符串进行 连接时,编译器(如果支持C++11)会先将窄字符串转换为宽字符串,再将二者连接。


2. long long整型

​ long long整型有两种:long long 和 unsigned long long。C++11标准中允许long long整型在不同平台可以有不同长度,但至少有64位。在书写常数字字面量时,如果是long long型,后缀可以采用ll,LL表示long long;ULL,ull,Ull,uLL表示unsigned long long。和其他整型一样,如果想要查看平台上long long大小可以查看<climits>(或<limts.h>)中的宏,对于printf函数,输出符号为%lld(long long )和%llu(unsigned long long)。


3. 扩展的整型

C++11标准允许编译器扩展整型,但C++11并没有对扩展类型的名称有任何规定或建议,只是对其的使用规则做了一些限制,目的是统一规范,让广大程序员使用起来更规范(方便)。在长度相同的情况相同的情况下,标准整型的等级会高于扩展类型,强制类型转换会转为等级更高的。


4. 宏__cplusplus

定义的一个宏,在不同编译器会有不同的值,或者没有定义该宏,可通过其值得大小或是否存在来判断编译器是否支持C++11

#ifdef __cplusplus
extern "c" 
{
//一些代码
#ifdef __cplusplus
}
#endif

这里用来处理C与C++混合编写的代码。

这里我没怎么看懂,甚至觉得有问题:第一行的应该是#ifndef,可能吧。

事实上,__cplusplus这个宏并不是只有被定义或者未定义状态,通常其被定义为一个整型值。根据书中的说法改值应被定义为201103L,但在我的VS2022以C++17标准,其值仍是199711L。


5. 静态断言

  1. 断言:运行时与预处理时

    断言就是将一个返回值总是需要为真的判别式放在语句中,用于排除在设计的逻辑上不应该产生的情况。

    assert(expression),当括号内表达式为false时,会直接退出程序,防止程序陷入逻辑混乱。这是运行时断言。

运行时断言有这样的弊端:当断言所在语句不被执行时,则该断言无效

还可以通过#error预处理指令配合#if(n)def来实现预处理时断言。

  1. 静态断言与static_assert(编译时)

    assert宏和#error预处理指令分别在运行时和预处理时起作用,而当我们需要在编译时使用断言是,就需要使用static_assert。其使用起来也非常简单:static_assert(expression,message),第一个参数是断言表达式,当其计算值为false时执行断言,message是一个字符串,是输出的错误信息。


6. noexcept操作符和noexcept修饰符

​ C++11使用noexcept关键字(修饰符)代替throw(),来表示其修饰的函数不会抛出异常。若该函数确实发生了异常,则直接调用std::terminate函数来终止程序,这比之前的throw()效率要高一些,因为throw()往往要执行其他一些操作。当noexcept作为一个操作符时,其可以接受一个常量表达式作为参数,该表达式被转化为一个bool类型的值,true代表函数不会抛出异常,false代表可能会发生异常,其实也可以这么理解,前面所说的noexcept关键字其实本质上就是noexcept(true)的简写。其实noexcept更大的作用是保证应用程序的安全,比如C++11默认将类的析构函数设置为noexcept以提高应用程序安全性。


7. 快速初始化成员变量(就地初始化)

​ 在C++98中,类在声明时,其属性只有静态的常量整型或者枚举类型可以在声明时初始化(普通常量必须声明时初始化),非静态变量可以再构造函数中初始化,其他静态常量或变量在类外初始化。而在C++11,所有静态常量和普通变量在声明时都可以就地初始化,而静态变量仍然必须类内声明,类外初始化(这是为了保证其定义只存在于一个目标文件中)。另外,就地初始化并不与初始化列表冲突,但初始化列表的作用效果往往在就地初始化之后,也就是初始化列表会覆盖就地初始化。


8. 非静态成员的sizeof

C++11支持sizeof(Class::attribute)来获取类成员大小class是类名,attribute是属性名,而在以前需要时用sizeof(((Class*)0)->attribute)的复制格式来获取。


9. 扩展的friend语法

​ 在C++11中,声明友元类时可以省略class关键字--> friend class class_name(c++98),friend class_name(c++11),此外‘class_name’甚至可以是类的别名。这种设计就带来一种应用上的变化---程序员现在可以为模板类声明友元了,如下:

class Demo;
template <typename T> class People
{
    friend T;
};
People<Demo> p1;//类型Demo在这里是People类型的友元
People<int> p2;//对于int类型模板参数,友元声明被忽略

这种特性在测试时有好处。这里简单举个例子,为了测试我们编写的类,如输出我们私有变量的值,但我们并不想大费周章的写上一堆get/set接口,我们可以在源文件开始出进行一种危险的定义:

#ifdef UNIT_TEST
#define private public
#endif

在这里,为了测试我们做了一件非常危险的事:通过宏定义,使代码中的所有private变成了public,这带来的直观好处就是我们所定义的所有私有成员全变成公有了,可以直接使用,但是,这么做往往会出现意想不到的后果:如果该程序中存在含有private的字符串,则其内容会被直接改变,若该字符串恰巧有着重要作用,则在宏替换后,程序无法给定的需求了,甚至发生崩溃;其次,若类中定义私有变量是是使用默认的,即不显示标注访问限制,默认使用private,该操作也无法达到目标效果。

而通过模板类友元,我们可以更安全的操作:

template <typename T> class PeopleT
{
    public:
    friend T;
    ...
}
class Test
{//测试专用类
    Test(PeopleT & p);//设计一个构造函数绑定一个PeopleT对象,参数可自行设计。
    ...
};
using Poeple = PeopleT<int>;//通过using来定义类型的别名,与typedef一致,这里是int类型模板,友元被忽略
using PeopleTest = People<Test>;//这是类Test类型模板,Test成为其友元,可以用来测试。
PeopleTest pt;//这里声明一个People<Test>对象,从此Test类变为PeopleTest的友元类,我们只需要设计Test的方法绑定某个PeopleT对象,就可以直接访问PeopleT对象的元素

以上代码属于抛砖引玉,具体了解还请自行深究


10. final/override控制

  1. final

    C++中有一类函数叫做虚函数,是类函数使用virtual关键字修饰,该函数可以被子类重写,从而实现多态的特性(父类指针指向子类对象,该父类父类指针调用虚函数时执行的是子类重写的函数)。但是有些时候我们并不希望子类重写父类函数,因为这可能会带来其他的麻烦,尤其是当多个开发人员共同开发时尤为明显。为此C++11新增了final关键字,在虚函数声明时将其放在参数列表后面,可以阻止子类对其的重写,如:

    virtual Print(...) final    //final关键字阻止子类重写该函数
    
  2. override

    有时我们希望重写虚函数,但因为种种原因会犯错,包括但不限于函数名拼错,函数原形不匹配,重写了非虚函数。这些行为都无法正确的重写函数,但编译其却不会报错,因为其大概率符合语法规范。为了避免这种情况的发生,C++11新增了虚函数描述符override。如果派生类在虚函数声明时使用了override描述符,那么该函数必须重写了基类中的同名函数,否则将无法通过编译

    void func(args) override;//使用override修饰,则该函数必须是重写了基类的同名函数
    

在学习函数重写时,顺便复习了一下函数重载(overload)与函数重写(override)的区别,在此记录以加强记忆:
1.定义不同---重载是定义相同的方法名,参数不同;重写是子类重写父类的方法。
2.范围不同---重载是在一个类中,重写是子类与父类之间的。
3.多态不同---重载是编译时的多态性,重写是运行时的多态性。
4.返回不同---重载对返回类型没有要求,而重写要求返回类型必须相同。
5.参数不同---重载的参数个数、参数类型、参数顺序可以不同,而重写父子方法参数必须相同。
6.修饰不同---重载对访问修饰没有特殊要求,重写访问修饰符的限制一定要大于被重写方法的访问修饰符。


11. 模板函数的默认模板参数

​ 在C++98中,模板类以及支持默认参数但模板函数不支持,但在C++11中模板函数支持了默认参数。但使用时会发现,默认模板参数往往是配合默认参数使用的。还需要强调的一点:模板函数的默认形参不是模板参数推导的依据,函数模板参数的选择,总是由函数的实参推导而来的。

template<typename T1 = int> void func(T args = 1);//C++11支持模板函数默认模板参数

12. 外部模板

  1. 为什么需要外部模板

    实际上,外部模板是C++11关于模板性能上的改进,这意味着即使不使用外部模板也不会出现错误,只是会有更多开销。外部模板可以类比于外部变量(以extern修饰声明的变量),事实上外部模板也是使用extern关键字修饰的。我们之所以使用外部变量,是因为在我们链接两个源文件时,如果两个文件声明了同样的变量,编译器会因为无法决定是否需要合并相同的变量符号而报错;但如果两个源文件同时声明了同样的模板实例函数,编译器会自行删掉其中一个副本,因为这么做不会导致程序出错,只是如果有很多这样重复的模板参数实例,就会极大的增加编译的时间,为此外部模板应运而生。

  2. 显示的实例化与外部模板的声明

    外部模板的使用实际依赖于模板的显示实例化:

    template<typename T> void func(T args);//这里定义一个模板函数
    template void func<int>(int args);//模板显示实例化的语句
    

    而我们声明一个外部模板只需要在模板显示实例化语句前加上extern关键字即可,这里给出一个简单的例子:

    //"head.hpp"文件
    template<typename T> void func(T args);//这里定义一个模板函数
    
    //"Demo1.cpp"文件
    #include “head.hpp"
    template void func<int>(int args);//在Demo1.cpp文件中显示的实例化(只有显示实例化一次后,才可以使用外部模板)
    
    //"Demo2.cpp"文件
    #include “head.hpp"
    extern template void func<int>(int args);//在Demo2.cpp文件中使用外部模板声明,就不会产生重复的模板函数实例副本
    

    注意

    1.如果外部模板声明出现与摸个编译单元中,那么与之对应的显示实例化必须出现于另一个编译单元中或者同一个编译单元的后续代码中

    2.外部模板声明不能用于静态函数(文件域函数),但可以用于静态函数,因为静态函数没有外部链接属性。


13. 局部和匿名类型做模板参数

​ C++11允许使用匿名类型以及局部类型作为模板参数,至于用处我没有仔细思考过,这里给出一个简单的使用例子:

template <typename T> class X {};
template <typename T> class void TempFun (T t) {};
struct {int val;} a;         //a是一个匿名类型变量
typedef struct (int val;)B;  //B是匿名类型

void Fun()
{
    struct C {} c;  //c是一个局部类型
    X<B> x1;		//匿名类做模板参数
    X<C> x2;		//局部类型做模板参数
    TempFun(a);		//匿名类型做模板参数
    //以上在支持C++11的编译器上均能编译通过
}

二. 通用为本,专用为末

单击返回主目录

要点速查:
继承构造函数
委托构造函数
移动语义,完美转发,引用折叠
显示转换操作符
统一的初始化语法和语义,初始化列表,防止收窄
非受限联合体
用户自定义字面量UDL
一般化SFINAE规则
内联名字空间
PODs
模板别名

概述

​ C++11的设计者总是希望从各种方案中抽象出更为通用的方法来构建新的特性,而不是那些精巧却不能广泛使用的方案,这意味着C++11中的新特性往往具有广泛的可用性或提升自身的通用性。我们用两句话总结C++11这一设计特点:更倾向于使用库而不是扩展语言来实现特性,更倾向于通用的而不是特殊手段来实现特性。


1. 继承构造函数

​ 我们在构造派生类对象时往往要同时初始化其基类的部分属性,我们通常的做法是在派生类的初始化列表里初始化基类,对此出现的问题是:如果基类存在多种构造函数,我们在初始化列表初始化基类时需要手动写上多种初始化方式,为此,C++11提出了继承构造函数,即使用using base::base;语句告诉编译器隐式的声明与基类相同的构造函数。

class Base 
{
public:
    int m_val;//一个公有int属性
    Base() {this->m_val = 0;}//无参构造函数
    Base(int val) {this->m_val = val;}//有参构造函数
};

class Deriver : public Base 
{
public:
    using Base::Base;//使用using语句生成继承构造函数
    /*
    *在这里,编译器会隐式的声明与基类相同的构造函数:
    * Deriver() {this->m_val = 0;}
    * Deriver(int val) {this->m_val = val;}
    *注意:这里是编译器隐式声明,即如果该构造函数不被使用或程序员自定义了一个相同的函数,则该函数将不起作用。
    */
};

注意:继承构造函数几点注意事项:
1.如果基类的构造函数参数中存在默认构造函数,则派生类的继承构造函数会产生多个可能与 之对应的版本;
2.如果派生类继承了多个基类且不同基类中存在完全相同的构造函数(即除了名字不一样),我们需要显示定义继承构造函数来解决冲突;
3.基类私有构造函数不会被继承,派生类是从基类虚继承来的,无法声明继承构造函数;
4.如果使用了继承构造函数,编译器不会再为编译器提供默认构造函数。


2. 委派构造函数

​ 委派构造函数设计的目的是减少程序员书写构造函数的时间,总结来说,就是在定义一个类时,我们抽象出最“通用“的构造函数作为目标构造函数,而我们将目标构造函数放初始化列表位置的构造函数叫做委派构造函数。这里给出一个例子:

class Demo 
{
public:
    Demo(int val, typename args):Demo(val) {/*其他初始化操作*/}//委派构造函数,在初始化位置写上目标构造构造函数
private:
    Demo(int val) {this->m_val = val;}//我们抽象出一个最通用的构造函数作为“目标构造函数”
    int m_val;
    //...其他属性
};

注意:
1.委派构造函数的概念是构造函数调用自己类中的另一个构造函数;
2.委派构造与初始化列表只能存在一个;
3.可以出现链式委托构造,但不能出现委托环,即至少有一构造函数不是委托构造函数。

​ 关于异常,如果在委派构造函数中使用try,那么从目标构造函数产生的异常可以在委派构造函数中被捕获。

class Demo 
{
public:
    Demo(int val, typename args) try : Demo(val) //委托构造函数处理目标构造函数中的异常,注意try的位置
    {
        /*其他初始化操作*/
    } catch(...){
        //异常的处理
    }
    
private:
    Demo(int val) {this->m_val = val;}//我们抽象出一个最通用的构造函数作为“目标构造函数”
    int m_val;
    //...其他属性
};
//注意;上述目标构造函数抛出了异常虽然被捕获,但是仍然会退出程序,我的猜测是类的构造函数也被隐式声明为noexcept

从逻辑来看,这样的异常设计是合理的。


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

​ 首先我们需要了解以前所说的C++复制语义,我们有一个变量a,此时我们使用另一个变量b给a赋值,则发生的结果是编译器在内存中复制了一份一模一样的a的内容,再让b拥有这块内存,也就说同样的内容我们出现了两次。

3.1 左值,右值和右值引用

​ 关于左值右值,C++有一个被广泛认同的说法:可以取地址的,有名字的就是左值,反之,不能取地址,没有名字的就是右值。C++11的右值是由两个概念构成的:将亡值和纯右值。纯右值就是C++98中的右值概念,比如非引用函数返回的临时变量,匿名变量等;将亡值是指将要被移动的对象,比如返回右值引用T&&的函数返回值,std::move的返回值等。类比与C++98中的(左值)引用是具名变量的别名,右值引用是匿名变量的变量的别名。但不同的是,右值引用会延长匿名对象的生命周期,它使匿名变量获得与右值引用相同的生命周期。右值引用作为匿名变量的别名其本身就是该匿名变量的具体内容,达到了延长生命周期的作用,而对于普通变量,如果使用匿名变量对其进行赋值操作,编译器会复制匿名变量生成一个新的变量再赋值给普通变量,也就是说,右值引用使我们可以直接操作匿名变量(右值)本身,而不是复制一遍后再使用,从而达到了节省一次申请和释放内存的工作,进而提高性能。后面所说的移动语义就是利用这一点。

说明:
1.非常量左值引用无法引用右值,同理右值引用也无法引用左值,但常量左值引用是一个万能引用,它可以引用左值和右值(这也带来了一些构造函数的精巧设计);
2.上述说明中的匿名变量可扩展至右值

3.2 移动语义

​ 可以这么理解,移动语义就是我们将右值(常见如匿名变量和临时变量)的内容直接转移我们的左值上,从而省去了一次复制。我们常常通过移动构造函数实现移动语义:

class Demo 
{
public:
    //省略其他构造函数
    Demo(Demo && d) : ptr(d.ptr) 
    {//移动构造函数,传入本类的一个匿名对象,直接复制指针
        d.ptr = nullptr;//处理指针变量(在堆区开辟了内存):将匿名对象的指针设为空
        m_val = std::move(d.m_val);//对于非指针变量,使用std::move函数使其强转为右值,这里以int型为例,可推广至自定义类型
    }
private:
    int* ptr;
    int m_val;
};

说明:移动构造函数是参数是本类的一个匿名对象,匿名对象的指针成员直接复制再将其置空,因为匿名对象也是对象,在生命周期结束后也会调用析构函数,若不改变其指针指向,会造成重复释放的情况,对于非指针类型的成员,我们采取使用std::move函数想将其强制类型转换为右值,在进行复制。我们使用语义移动的根本需求是提高性能

3.3 std::move函数

​ C++11标准库中提供了std::move函数,其作用为将一个左值强制转换为右值引用,我们一般使用一个右值引用接收它,看一个例子:

//这里给出std::move函数的源码
template <class _Ty>
_NODISCARD constexpr remove_reference_t<_Ty>&& move(_Ty&& _Arg) noexcept 
{ // forward _Arg as movable
    return static_cast<remove_reference_t<_Ty>&&>(_Arg);
}

int val_1 = 10;
int&& val_2 = std::move(val_1);
val_1 = 20;
cout << "val_1=" << val_1 << "  val_2=" << val_2;//输出结果为:val_1=20 val_2=20

这说明,std::move本质就是个强制类型转换,返回的是左值的右值引用,之前的左值变量类型并未改变,只是他管理的内存又被一个右值引用管理了,所以他们所管理的内容是同步的,改变其中一个另一个会同时改变。前文提到过,move函数常用于移动语义,所以若我们对一个在堆区开辟了内存的指针使用move函数,使用后要根据需求考虑是否将其转换为nullptr,避免出现同一地址重复释放的问题。我们再通过一个例子理解一下std::move函数在移动语义中的使用。

class Person 
{
public:
	Person() : m_name("NULL"), m_age(new int(0)) {}//默认构造
	Person(string name,int* age) : m_name(name), m_age(new int(*age)) {}//有参构造函数
	Person(const Person& p) : m_name(p.m_name), m_age(new int(*p.m_age)) {}//复制构造函数
	Person(Person&& p) : m_name(p.m_name), m_age(p.m_age) { p.m_name = nullptr; }//移动构造函数
private:
	string m_name;
	int* m_age;//年龄,这里有意设计为指针
};

class Room 
{
public:
	Room() : p(), ID(new int(0)) {}//默认构造
	Room(string name, int* age,int* id) :p(name,age),ID(new int(*id)) {}//有参构造函数
	Room(const Room& r) : p(r.p), ID(new int(*r.ID)) {}//复制构造函数
	Room(Room&& r) : p(std::move(r.p)), ID(r.ID) { r.ID = nullptr; }//注意:r虽然是一个右值引用,但其实它本事是一个左值,所以r.p也是一个左值,这里我们希望使用移动语义,所以使用了std::move
private:
	int* ID;//房间名,这里有意设计为指针
	Person p;
};                      

说明:这里设计了两个类:PersonRoom,我们为两个类设计移动构造函数,Room类中有一个Person类的对象作为成员,所以在设计Room类的移动构造函数时,我们对参数列表中的'r.p'使用了std::move函数,目的是调用Person类的移动构造函数,以提高程序的性能。可以这样理解,当我们使用一个右值引用来表示一个右值时,此右值引用已经是一个不折不扣的左值了,因此如果我们想继续在函数中传递此右值就需要对右值引用使用std::move函数。事实上,对于std::move函数的使用,给出的建议是:在编写移动构造函数时,应该总是记得使用std::move转换拥有形如堆内存,文件句柄等资源作为其成员的变量,比如列子中的Person类,其内部有一个在堆区开辟内存的指针,所以我们对Person对象使用std::move函数。另一方面,对于使用std::move,如果成员有移动构造函数,则实现其移动语义,对于没有移动构造函数的,其接受常量左值版本的构造函数也可以实现复制构造,并不会引起大问题。所以对于形如int的基本变量,我的理解是其可能没有移动构造函数,所以没有使用std::move函数的必要性,当然也可以使用。

移动语义和右值引用较难理解,需要反复斟酌加深理解。

3.4 完美转发

总结来说,就是利用新引入的右值引用以及全新定义的引用折叠规则,实现了减少重载函数但同时完美实现语义的功能。完美转发定义:在函数模板中,完全依照模板的参数类型将参数传递给函数模板中调用的另外一个函数。我们在传递参数时,往往不希望产生额外的拷贝工作,这是我们就可以考虑使用引用。但是关于引用的使用,我们又有几个问题:

1.const T& 虽然万能,但是使用起来却限制很大,比如不能修改;
2.左值引用不可以使用右值来初始化
3.在使用模板参数时,如果使用一个右值rightVal来初始化T,如T = rightVal,编译器会推导出T为rightVal对应类型的普通变量而非右值引用,从而无法实现移动语义。

因此,当我们需要在传递左值引用或右值引用均能实现完美语义的话,就必须为每种参数情况重载一个函数,如果一个函数有较多参数,这种做法对程序员无疑的非常不友好的,因此,我们通过C++11提出的右值引用配合全新规定的引用折叠规则实现了完美转发的功能。

C++11中引用折叠规则(仅适用于模板类型推导,对于确定的类型不使用):

typedef typename T;//给一种类型取别名T
typedef T& TR;//给T&类型取别名TR
TR& v = val;//定义一个TR& 类型的变量v,这种书写方式在c++98会导致编译出错
TR的类型定义 声明v的类型 v的实际类型
T& TR A&
T& TR& A&
T& TR&& A&
T&& TR A&&
T&& TR& A&
T&& TR&& A&&

通过观察不难发现规律:一旦定义中出现了左值引用,引用折叠总是优先将其折叠为左值引用,而对于定义为右值引用,在其基础上再加上一个'&'时对应类型会变成左值引用。利用这一特点,我们就可以实现完美转移了:

void call(T && t) 
{
    realFunc(static_cast<T &&>(t));
}

t是一个右值引用,但其本身是一个左值,如果我们想继续将该引用的右值传递下去,则需对该右值引用继续使用static_cast<T &&>强制类型转换。如果我们调用call函数时,给出的参数是X类型的左值引用和右值引用,会出现不同的情况:

void call(X& && t) 
{//参数为左值引用
    realFunc(static_cast<X& &&>(t));
}
void call(X&& && t) 
{//参数为右值引用
    realFunc(static_cast<X&& &&>(t));
}

这里X& &&转换为X&X&& && 转换为X&&。此时我们会发现static_cast<X && &>(t),并不起作用但是static_cast<X&& &&>(t)就起到了作用,因此这里的static_cast<T &&>(t)是一个万能的方法,其对左值引用和右值引用都能进行很好的处理。另外我们会发现可以使用std::move函数来代替static_cast<T &&>,但是C++11要求我们使用std::forward函数(这三者实现的功能一致):

void call(T && t) 
{
    realFunc(std::forward(t));//这里使用std::forward函数代替static_cast
}

这里引用书中的解释:虽然两个函数使用在实际实现上差别不大,但是标准库这样设计,也许是为了让每个名字对应于不同的用途,以应对未来可能的扩展。这可以说是对我的思维进行了扩展。

几个使用案例:

void fun(int& v)
{
	cout << "int& v" << endl;
}
void fun(const int& v)
{
	cout << "const int& v" << endl;
}
void fun(int&& v)
{
	cout << "int&& v" << endl;
}
template<class T>
void fac(T&& v)
{
	fun(std::forward<T>(v));
}
int main(){
	int a = 10;
	const int b = 20;
	const int& x = b;
	const int& y = 20;
	fac(a);//int&  这里a是int类型,但(T&& v)中T被推导为int&,我们认为此时a是一个左值引用类型,这是模板T&&推导中特殊的地方
	fac(b);//const int&
	fac(10);//int&&
	fac(x);//const int&
	return 0;
}

特别说明(这也是我后续回看是发现的问题):对于T&& v我们所说的传入左值引用类型,其实就是传入一个正常的左值变量,具体解释课参看上述代码案例中的描述


4. 显式类型转换

​ c++98中提供了explicit关键字,作用是防止构造函数被隐式调用:

class C 
{
public:
    C(int a) { _val = a;}//只有一个参数的构造函数,其提供了隐式构造的功能
private:
    int _val;
};
C c1(1);//调用有参构造
C c2;
c2 = 2;//使用隐式构造,即隐式调用了构造函数C(2);
/****************************/
class CC 
{
public:
    explicit CC(int a) { _val = a;}//只有一个参数的构造函数被explicit关键字修饰,屏蔽了其隐式构造的功能
private:
    int _val;
};
C c1(1);//调用有参构造
CC c2;
c2 = 2;//禁止使用隐式构造,编译失败!!!

​ C++11将explicit关键字的使用扩展到了自定义的类型转换操作符上,意味着只有在直接构造目标类型或则和显式类型转换时才使用该类型。

class ConvertTo {};
class Convertavle 
{
public:
	explicit operator ConvertTo() const { return ConvertTo(); }//explicit修饰的类型转换运算符
};
void Func(Convertto ct) {}
void test() {
	Convertable c;
 	ConvertTo ct(c);//直接构造目标,c可以类型转换,可以编译,注意这里的区别
    ConvertTo ct2 = c;//试图隐式转换,编译失败
    ConvertTo ct3 = static_cast<ConvertTo>(c);//强制(显式)类型转换,编译通过
    Func(c);//试图隐式转换,编译失败
    //若类型转换运算符不被explicit修饰,则以上均可通过编译
}

1.类中定义一个函数 :operator typeName(),即可自定义类型转换操作符。
2.显示类型转换并没有完全禁止从源类型到目标类型的转换,只是拷贝构造和非显式类型转换不被允许。
3.通常情况下,我们应当禁止使用隐式类型转换,避免发生不必要的错误。


5. 初始化列表

  1. 认识初始化列表

​ c++98标准允许数组使用‘{}’对数组元素进行统一的集合初始值设定,对于STL中的容器却不允许,所以我们总是需要进过声明对象--循环 动作来初始化容器,但C++11中允许各种类型使用初始化列表进行初始化操作:

int val {10};
int arr[] {1,2,3,4}; //使用初始化列表,省略了=
int* ptr = new int{20};
vector<int> v{5,6,7};//使用初始化列表直接赋值
map<int,float> m = {{1,1.0f},{2,2.0f}};//这里去掉=依然正确
  1. 自定义使用

​ 事实上,标准模板库对初始化列表的支持源自<initializer_list>头文件,因此,程序员只需要#include<initializer_list>, 并且使用以initializer_list<T>为模板类为参数的构造函数就可以使自定义类使用列表初始化,看一个例子:

#include <vector>
#include <string>
//#include <initializer_list> 该头文件默认包含
using namespace std;

enum Gender { boy, girl };
class People 
{
public:
	People(initializer_list<pair<string, Gender>> list) 
    {
		auto i = list.begin();
		for (; i != list.end(); ++i)
			data.push_back(*i);
	}
private:
	vector<pair<string, Gender>> data;
};
People ship2022 = { {"Garfiedl",boy},{"HelloKitty",girl} };

引用书中的例子

​ 这里我们自定义People类,并定义了一个以initializer_list<T>为模板类为参数的构造函数,对于initializer_list像正常的 迭代对象list一样操作,最后我们使用初始化列表初始化People类型对象,注意我们在内部的pair也是使用初始化列表初始化的。

同理我们发现初始化列表是可以放在函数参数中的;

  1. 独特的防止类型收窄机制

    初始化列表方式的初始化不允许发生类型收窄,否则无法通过编译(这也是C++11发布时唯一一种可以防止类型收窄的初始化方式)

    int a {1.23f};//编译失败,因为1.23f是浮点数,赋值给int变量会造成类型收窄
    

    类型收窄可以简单理解为新类型无法表示原有类型数据的完整值得情况;

初始化列表将标准语言与程序库拉的更近,统一了内置类型和自定义类型的行为,体现了其通用为本,专用为末的思想。


6. POD类型

​ POD是英文Plain Old Data的缩写,其用来说明一个类型的属性,尤其是用户自定义类的属性。C++11将POD划分为两个基本概念的集合:平凡的和标准布局的。

  1. 平凡的(trivial)

    平凡类/结构体的定义:

    拥有平凡的默认构造函数和析构函数(即我们不定义他们,让编译器自己生成)
    拥有平凡的拷贝构造函数和移动构造函数
    拥有平凡的拷贝复制运算符和移动赋值运算符
    不能包含虚函数以及虚基类。
    注:这里对平凡的概念说明比较粗糙,建议查资料进一步了解

  2. 标准布局(standard layout)

    标准布局的类/结构体定义:

    1. 所有非静态成员拥有相同访问权限;

    2. 在类或结构体继承时,满足其中一种情况:

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

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

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

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

  3. 使用POD的好处

    1. 字节赋值,代码中我们可以安全的使用memsetmemcpy对POD类型进行初始化和拷贝操作。
    2. 提供对C内存布局兼容。
    3. 保证了静态初始化的安全有效。

    之所以要求类中的第一个非静态成员的类型与基类不同,是因为C++标准中,如果基类没有成员,标准允许派生类的第一个成员与基类共享地址,但同时C++标准有要求类型相同的对象必须地址不同,所以说如果类中第一个非静态成员的类型与基类相同,则不能满足地址共享原则,这就不满足了POD之标准布局原则。

1.我们可以使用std::is_pod<T>::value来判定一个类型是否是POD。
2.POD类型是C++中非常重要的概念,后面很多特性都会用到POD类型,但在这里只是简单的介绍。


7. 非受限联合体

​ 在C++98中,联合体中成员的类型不能是非POD或者静态或引用类型的成员,C++11取消了一些限制:任何费引用类型都可以成为联合体的数据成员,这样的联合体称为非受限联合体。C++11标准默认删除了非受限联合体的默认构造函数(考虑到非POD类型也在联合体中),所以我们最好配合placement new自定义非受限联合体的构造函数,同时考虑到是联合体,我们定义析构函数也需要十分小心。匿名非受限联合体可以放在类声明中以增加类的长度。

我使用较少,记录比较粗糙。


8. 用户自定义字面量

​ C++11允许用户自定义字面量。意思就是对于自定义类,我们可以通过解析一个整型,浮点型,字符或字符串来表示我们自定义类,具体方法是我们在自定义类外定义一个typename operator "" _C(args)函数,函数体内部实现的是解析参数构造自定义类,给出一个列子:

#include <cstdlib>
#include <iostream>
using namespace std;
typedef unsigned char uint8;
struct RGBA 
{ //RGBA 表示颜色
	uint8 r;
	uint8 g;
	uint8 b;
	uint8 a;
	RGBA(uint8 R,uint8 G,uint8 B,uint8 A=0) : r(R),g(G),b(B),a(A) {}//构造函数
};

RGBA operator "" _C(const char* col, size_t n) 
{ //一个长度n的字符串,解析这个字符串从而构造对象
	const char* p = col;
	const char* end = col + n;
	const char* r, *g, *b, *a;
	r = g = b = a = nullptr;
	for (; p != end; ++p) {
		if (*p == 'r') r = p;
		else if (*p == 'g') g = p;
		else if (*p == 'b') b = p;
		else if (*p == 'a') a = p;
	}
	if ((r == nullptr) || (g == nullptr) || (b == nullptr) || (a == nullptr)) throw;
	else if (a == nullptr)
		return RGBA(atoi(r + 1), atoi(g + 1), atoi(b + 1));//atoi的作用是将一个字符串转换为整型,遇到空格会停止
	else return RGBA(atoi(r + 1), atoi(g + 1), atoi(b + 1), atoi(a + 1));
}
RGBA rgba = ("r0 g0 b0 a0"_C);//这里我们使用“”_C当做字面量初始化

上述例子中参数是const char*size_t,也可使用整数型(不超过unsigned long long),浮点型(不超过long double)或字符(char)。

注意:

1.字面量操作符函数声明中,operator"" 与用户自定义后缀之间必须有空格;

2.自定义后缀建议以下划线开始,从而避免被编译器警告因为可能与内置浮点数等后缀重复造成混乱;

3.双引号是字面量操作符函数形式,如果参数不是字符,则使用时字面量不需要加引号如123456_L


9. 内联名称空间

​ 一句话说,就是允许程序员在父名称空间直接定义子名称空间的变量或者特化子名称空间的模板:

namespace base
{
    inline namespace derive
    {
		struct Test {};
    }
    Test test;//derive::Test ,在C++11中,我们不需要表明子类作用域
}

目前内联名称空间大量的被用于程序库中,其配合宏实现了对不同版本的编译器的不同支持:

namespace total
{
#if __cplusplus == 201103L
inline
#endif
namespace cppll 
{/*定义的内容*/} //如果编译器支持C++11,我们将“导入”cpp11名称空间

#if __cplusplus < 201103L
inline
#endif
namespace oldcpp {/*定义的内容*/}//如果编译器不支持C++11,我们将“导入”oldcpp名称空间
}

这里我们配合__cplusplus宏实现了对不同版本编译器选择不同的名称空间,对于需要长期维护的库来说,这是个很实用的功能。

C++还有一个ADL特性:”参数关联名称查找“,该特性允许编译器在名称空间找不到函数名称时从参数的名称空间内查找函数名字。虽然ADL带来了一定的便利性,但其也一定程度上破坏了namespace的封装性,因此我们建议依然使用传统的::方式列出完整名字。


10. 模板的别名

​ C++11支持了using定义类型别名,实现了与typedef同样的功能,不仅如此,using还支持了为模板取别名:

using uint8 = unsigned char;//using 代替 typedef
template <typename T> using MapStirng = std::map<T,char*>;//为模板类定义别名
MapString<int> numberedString;//通过别名实例化模板

11. 一般化的SFINAE规则

​ 这条规则表示的是对重载模板的函数进行展开时导致了类型不匹配,编译器并不会立即报错,会继续查找匹配的模板,这里给出一个例子:

struct Test 
{
	typedef int foo;
};

template <typename T>
void func(typename T::foo) {}//定义第一个模版函数#1 这里typename关键字的作用是告诉编译器其后面是一个类型的名称
template <typename T>
void func(T) {}//定义第二个模板函数#2

int main()
{
	func<Test>(10);//调用#1
	func<int>(10);//调用#2,由于FSINAE规则,虽然不存在类型int::foo,但也不会编译错误,而是继续查找匹配的模板
}

不过C++11之前对于SFINAE的支持并不完全,例如:

template <int I> struct A {};//这里将I限制为int
char fun(int);
char fun(float);
template <typename T>
A<sizeof(fun((T)0))> f(T) {}
int main() 
{
	f(1);
}

程序员可以根据参数长度而定义出返回不同值得模板函数,但大多数C++98编译器会报出一个SFINEA错误。

这部分目前没看懂,以后看看能不能理解。

--> 2023.12.10 正在学习MyTinySTL项目,用到了相关的技术,了解了一些


三. 新手易学,老兵易用

单击返回主目录

要点速查:
右尖括号的改进
auto
decltype
追踪返回类型语法
基于范围的for循环


概述

C++在变得不断强大的同时也在不断努力使其语法变得简单,这一章我们将看到几个为了简便语法而加入的新特性。


1. 尖括号的改进

​ 在c++98中模板嵌套时遇到连续的右尖括号是需要手动,否则编译器会认为其是右移运算符,但C++11会优先将其推导为模板:

Y<X<1> > z1;//c++98,C++11成功
Y<X<1>> z2;//c++98失败,C++11成功
/*如果确实需要使用右移运算符,可以通过添加括号的方式解决*/
Y<x<(1 >> 5)>> z3;

2. auto类型推导

  1. 静态类型,动态类型

    静态类型和动态类型的主要区别点在于类型检查的时间点:静态类型主要在编译阶段进行检查,动态类型主要在运行时检查,C++普遍被认为是一种静态类型的语言,因为我们在使用每个变量前必须将其定义。而动态类型实现主要归功于一个技术--类型推导。在c++98中就增加了RTTI机制(运行时识别类别),但很多情况下其无法满足需求,因此C++11将类型推导标准化了为autodecltype

  2. auto

    C++11标准赋予了auto关键字全新的含义:新的类型指示符,auto声明的变量的类型必须由编译器在编译时期推导得出。我们可以这样定义变量:auto val = 1;这样编译器会推导出val是int类型,但我们需要注意使用auto声明变量时一定要立刻初始化,否者会因为无法推导而报错。其实,auto并非一种类型声明,而是一个类型声明时的占位符,编译器会在编译时期将auto替代为变量的实际类型。

  3. auto的优势

    1. 简化了书写形式,如声明容器迭代器时可以直接使用auto减少代码量并且更易阅读;

    2. 免去程序员一些声明类型时的麻烦,使用auto自动获得最佳匹配类型;

    3. auto的自适应性能够在一定程度上支持泛型编程,如不同类型平台的类型长度不一样,这时我们使用auto就可以达到跨平台的效果,因为auto会在编译时确定类型。

    4. 我们甚至可以在定义宏的时候使用auto以提升程序的性能:

      #difine Max1(a,b) ((a) > (b)) ? (a) : (b)
      #define Max2(a, b) ({\
      	auto _a = (a);\
      	auto _b = (b);\
      	(_a > _b) ? _a : _b;\
      	}) //这里'\'的意思是代表他们在同一行
      

      我们使用auto来推导_a_b变量类型,因为将其定义为变量后,可以直接使用,而不是每次使用a,b时都读取一次,从而提升性能。

  4. auto使用细则

    1. auto可以与* 和 & 联合使用,也可不联合;

    2. auto可以和const,volatile限制符一起使用;

    3. auto来声明多个变量类型时,只有第一个变量用于auto的类型推导,推导出的数据类型被用作其他变量;

    4. 有些情况受限于语法的二义性,auto不能使用:

      1.函数参数不能使用auto,即使参数有默认值
      2.结构体和类的非静态成员变量类型不能是auto
      3.不能使用auto声明数组:auto arr[3] = {1,2,3}
      4.实例化模板参数时不能使用auto


3. decltype类型推导

  1. decltype使用

    decltype也可以进行类型推导,但使用方式与auto略有不同:

    int a = 1;
    decltype(a) b = 0;//这里decltype推导b为int类型变量
    

    可以看出,decltype总是以一个普通的表达式为参数,返回该表达式的类型,与auto相同的是,作为一个类型指示符它也可以将获得的类型来定义另外一个变量,而且decltype类型推导也是在编译时期进行的。

  2. decltype的应用

    1. 配合using可以为类型取别名如:using size_t = decltype(sizeof(0));,这里我们直接使用字面量定义了类型的别名,这在以前是做不到的。

    2. 使用decltypec重用匿名类型:

      struct 
      {
          char* name;
      }temp;//匿名的结构体对象temp
      int main{
          decltype(temp) temp1;//配合decltype重用匿名类
      }
      
  3. decltype推导四规则decltype(expression):下面以e简称expression

    1. 如果e是一个没有带括号的标记符表达式或者类成员访问表达式,那么decltype(e)就是e所命名实体的类型;
    2. 否则,假设e的类型是T,如果e是一个将亡值,那么decltype(e)为T&&;
    3. 否则,假设e的类型是T,如果e是一个左值,那么decltype(e)为T&;
    4. 否则,假设e的类型是T,则decltype(e)为T。

    1.标记符表达式:程序员自定义的标记符,如一个变量名就是一个标记符,单个标记符对于的表达式就是标记符表达式;

    2.对于第4条解释:如int a = 1;decltype((a)) b = a;这里b被推导为int类型的引用。

  4. cv限制符的继承与冗余的符号

    auto不同,decltype可以继承cv限制符,即如果被推断类型被cv限制符修饰,则推断出的类型也会带上cv限制符;对于冗余&符号,decltype推导式时会自动忽略,而*符号不会忽略。

    int i = 1;
    int& j = i;
    int *p = &i;
    const int k = 1;
    decltype(j)& val1 = i;//这里有冗余的&,编译器自动忽略
    decltype(j)& val1 = 0;//编译错误
    decltype(p)* ptr = &p;//*不被忽略,ptr为int**类型
    decltype(p)* ptr = &i;//编译错误
    decltype(k) val2 = 2;//val2为const int类型
    const decltype(k) val3 = 3;//正常编译,应为const被忽略,val3为const int类型
    

4. 追踪返回类型

​ C++11引入了追踪返回类型来解决函数返回值类型是由参数类型决定的情况:

template <typename T1, typename T2>
auto sum(T1& t1, T2& t2) -> decltype(t1 + t2)
{
    return t1+t2;
}

在这里,我们使用auto关键字占位,并将真正的类型使用->decltype(...)描述。

  1. 使用追踪返回类型可以使代码更加通俗易懂:
int (*(*pf())())() {return nullptr;}//这是一个返回一个函数指针的函数,看到这里你可能会去复习函数指针的相关知识
/*但是我们也可以这样写*/
auto pf1() -> auto (*)() ->int (*)() {return nullptr;}//这里我们使用了类似嵌套的手法,阅读时从左向右
  1. 追踪返回类型常用于转发函数:
double fun1(int a) {return double(a)+0.01;}
template <typename T>
auto fun2(T t) -> decltype(fun1(t)) //转发函数
{
    return fun1(t);
}
  1. 追踪类型用于函数指针和函数引用,用法和函数一致。

函数引用:如,auto (&fr) () -> int;


5. 基于范围的for循环

int arr[5] {1,2,3,4,5};
for(int& i : arr)//使用基于范围的for循环,由于这里使用了引用,所以可以直接改变arr的内容
    i*=2;

是否满足使用基于范围的for循环需要一定条件:for循环的范围是可确定的,对于类需要有begin和end函数,对于数组需要能确定长度


四. 提高类型安全

单击返回主目录

要点速查:
强类型枚举
智能指针
垃圾回收机制


概述

​ C++是一门非常强调类型的语言,虽然C++98对于系统类型的构建已经近乎完美,但依然存在着枚举这样的漏网之鱼,所以C++11对其的用法进行了增强。同时,C++11在指针的安全使用方面也做了改进。


1. 强类型枚举

  1. 有缺陷的枚举类型

    C/C++中的枚举(enum)有一个很奇怪的设定:具有名字的enum类型的名字以及enum的成员的名字都是全局可见的,因此会导致一些问题:

    enum School { Name, Address, Kind };
    enmu Student { Name, Age, Gender };//编译出错
    

    这里编译出错的原因是因为General是全局可见的,导致在type2中General会重定义,全局可见的特性会污染其他枚举空间。

    同时,由于枚举被设计为常量数值的别名所以枚举的成员总是可以被隐式的转换为整型,因此if(Address>=Age)这样的语句是成立的,但是从实际来看,这种关系没有意义。

  2. 强类型枚举

    enum class Type {...};

    强枚举类型声明非常简单,只需要在enmu后面加上class关键字即可。强枚举类型具有几点优势:

    强作用域,强类型枚举成员的名称不会被输出到其父作用域空间;
    转换限制,强类型枚举成员的值不可以与整型隐式的相互转换;
    可以指定底层类型,强类型枚举默认底层类型是int,但也可以显示的指定类型,如enun class Type: char {...};指定底层类型为char
    注意:C++11扩展了原来的枚举,即使是不加class关键字的普通枚举也可使用:type形式指定底层类型。


2. 智能指针

​ C++11废弃了C++98中的auto_ptr,改用unique_ptr,shared_ptr,weak_ptr等智能指针来自动回收堆分配的对象。智能指针重载了*运算符,因此可以通过*ptr的方式访问内存。使用智能指针需要包含头文件<memory>

  1. unique_ptr

    unique_ptr,顾名思义,其不能与其他unique_ptr类型指针共享内存,如果确实需要,可以使用std::move函数来实现内存所有权的转移。从实现来说,unique_ptr是一个禁止了拷贝构造函数,保留了移动构造函数的指针封装类型。这里我们看一些实例:

    #include <memory>//包含头文件
    unique_ptr<int> p1(new int(10));
    unique_ptr<int> p2 = p1;//无法通过编译
    unique_ptr<int> p3 = std::move(p1);//通过std::move函数将内存所属权交给p3,此后p1将不再用于原来的内存
    cout<<*p1;//运行时出错,没有内存访问权限
    cout<<*p3;//智能指针重载*运算符
    p3.reset();//显示释放内存
    p1.reset();//不会导致运行时出错
    
  2. shared_ptr和weak_ptr

    shared_ptr依然形如其名,其同类型的指针可以共享类型,实现上采用了引用计数,其调用成员函数reset会失去该内存控制权并使引用计数减少:

    #include <memory>//包含头文件
    shared_ptr<int> p1(new int(2));
    shared_ptr<int> p2 = p1;//使p1初始化p2
    cout<<*p1<<"  "<<*p2;//输出:2  2
    p1.reset();
    cout<<*p1;//运行时出错
    cout<<*p2;//输出:2
    

    weak_ptr往往配合shared_ptr使用,它可以指向shared_ptr指针指向的内存但并不拥有该内存,但我们可以使用其成员函数lock获取一个指向该内存的shared_ptr对象,若其指向内存已被释放会lock返回空指针nullptr,这可以用来验证shared_ptr指针的有效性。

    #include <memory>
    shared_ptr<int> p1(new int(1));
    weak_ptr<int> wp = p1;//指向shared_ptr对象所指内存
    share_ptr<int> p2 = wp.lock();//weak_ptr成员函数lock返回一个share_ptr对象
    p1.reset();
    p2.reset();//p1,p2所指内存被释放
    shared_ptr<int> p3 = wp.lock();//p3 = nullptr
    

1.智能指针可以帮助用户进行有效的内存管理(如不用手动delete,在抛出异常或者栈解退时自动完成堆内存释放以防止内存泄漏问题),但也不建议滥用智能指针,因为其会降低性能。
2.c++98中有智能指针模板auto_ptr,但其在C++11中已被弃用,大多数情况下我们可以直接使用unique_ptr代替过时的auto_ptr


4. 垃圾回收机制

​ 由于C++给与了指针非常强大灵活的功能导致垃圾回收机制难以建立,而C++11所建立的垃圾回收机制(最小垃圾回收机制)并不完善,且目前对老版本代码的兼容性很差,考虑到C++更高版本可能会有更好的垃圾回收机制,所以在此略去垃圾回收机制的介绍部分。

关于最小垃圾回收机制,是定义了什么样的指针对于垃圾回收是安全的,什么样的动作对垃圾回收是造成影响的,一但有存在问题的垃圾回收,程序员可以通过调用相关API来通知垃圾回收器不要回收这些“垃圾”。


五. 提高性能及操作硬件的能力

单击返回主目录

要点速查:
常量表达式
变长模板
原子操作/内存模型
线程局部存储
复制和再抛出异常
并行动态初始化和析构
快速退出进程


概述

性能是C++永恒的主题,同时对于硬件相关编程也是其热门话题。在C++11中,我们可以看到其在性能与硬件操作上的许多创新之处。


1. 常量表达式(constexpt)

  1. 运行时常量和编译时常量

    我们通常说const修饰的变量叫常量,不过大多数情况下其真实的含义是“运行时”常量,即数据运行时不可更改。但我们有时候却需要编译时期的常量,这是const关键字无法保证的,如

    const int get() {return 1;}
    int arr[get()];//编译错误,get()不是编译时常量
    

    同理,get()也不能用在case语句中,因为其不是编译时常量。为此,C++11新增了constexpr关键字,使用constexpr代替const修饰的函数成为了常量表达式:

    constexpr int get() {return 1;}
    int arr[get()];//编译成功
    

    当然constexpr关键字不仅只适用于普通函数,还可用作与数据声明,类的构造函数等。

  2. 常量表达式函数

    我们可以在函数返回类型前加上constexpr关键字使其成为常量表达式,但是这对函数也有一些限制:

    1.函数体只有单一的return返回语句;
    2.函数必须返回值,不能是void函数;
    3.在使用前必须已有定义(不能只有声明);
    4.return返回语句表达式中不能使用非常量表达式的函数、全局数据,且必须是一个常量表达式,且返回语句不要出现 return x=1这样的危险操作。

    实际上,get()函数虽然是一个常量表达式,但也可以作为普通的函数调用(认为constexpress关键字不存在),因此无需为他重载一个无consexper版本的函数,而且这样做往往会报错。

    consexpr int get() {return 1;}
    //int get() {return 1;};//不要这样做,往往会编译出错
    constexpr int a = get();//get函数在编译时计算
    int b = get();			//get函数在运行时才计算
    
  3. 常量表达式值

    与常量表达式函数一样,一般我们将使用constpxer修饰的变量称之为常量表达式值。常量表达式值必须被一个常量表达式赋值,而且定义时必须同时初始化。

    const int i = 1;	 //运行时常量
    constexper int j = 1;//编译时常量
    

    这两个常量在大部分情况使用是没有区别的,但有一点可以肯定的是,如果变量i在全局名称空间中,,编译器一定会为其产生对应的数据,而对于j如果不是有代码显示的使用了它的地址,编译器可以选择不为其产生数据而仅将其当做编译时期的值,这与枚举值和右值字面量是一致的,它们都是编译时期的常量。对于浮点类型,其常量表达式的精度要大于等于其在运行时的浮点数常量的精度。

    自定义类型的常量表达式值,可以发现,我们之前所有提到的常量值都是内置类型的,而如果我们想使用constexpr关键字修饰自定义类型,则需要定义自定义常量构造函数:

    struct MyType
    {
        constexpr MyType(int x):i(x) {}//自定义常量构造函数
        //constexpr int getI() { return i; } 我们定义了一个常量表达式函数返回i的值
        int i;
    };
    constexpr MyType m = {0};
    // m.getI();//不知;出于何种原因,这里无法编译
    /* VS2022(C++14标准)报出的错误为:对象含有与成员函数“MyTypegetl”不兼容的类型限定符,对象含有与成员函数“MyTypegetl”不兼容的类型限定符
    对象类型是: const MyType  如果你知道原因,请告诉我 */
    m.i;//这样却不会出问题
    

    自定义常量构造函数即在构造函数前加上constexpr关键字并且遵循一下规则:

    1.函数体必须为空;
    2.使用初始化表达式来赋值,且只能由常量表达式来赋值。
    3.无需为自定义常量构造函数重载相同的无constexpr版本,其可以当正常构造函数使用。

    需要注意的是,我们声明的是常量表达式构造函数,但其编译时期的“常量性''是体现在类型上的,如果不为自定义类型提供自定义常量构造函数,其无法定义为一个常量值。

  4. 常量表达式的其他运用

    1. 常量表达式与模板函数

      常量表达式是可以运用与模板函数中的,但由于模板函数中类型的不确定性(即自定义类型是否拥有自定义常量构造函数),所以模板函数是否会被会被实例化满足编译时常量性的版本是未知的,因此C++11规定,当声明为常量表达式的模板后,而某个该模板函数的实例化结果不满足常量表达式需求的话,constexpr关键字会被自动忽略,如:

      struct Test 
      {
      	 Test() {}
      };
      template <typename T> 
      constexpr T func(T t) { return t;}
      
      Test t;
      Test t1 = func<Test>(t);//正常编译,Test结构体没有常量构造函数,所以这里函数忽略constexpr
      constexpr Test t2 = func<Test>(t);//编译出错,无法定义出constexpr Test类型
      
    2. 常量表达式与递归函数

      我们直接看一个例子:

      #include <iostream>
      using namespace std;
      constexpr int Fibonacci(int n) 
      {
      	return (n == 1) ? 1 : ((n == 2) ? 1 : Fibonacci(n - 1) + Fibonacci(n - 2));
      }
      int main()
      {
      	constexpr int f[] = { 
      		Fibonacci(10),Fibonacci(11),
      		Fibonacci(12), Fibonacci(13) //这一系列函数的值在编译时期就计算好了
      	};
      	return 0;
      }
      

      我们定义一个函数用于求斐波那契数列对应位置的值,可以看到我们在返回语句中调用了函数自身使他成为一个递归函数。同时由于其被constexpr修饰,所以我们调用时一般来说其值在编译时期就计算好了。事实上,这种基于编译时期计算的方式早在模板元编程提出时就出现了,这也是一个有意思的话题,我们再来看一个模板元编程的例子,同样是计算斐波那契数列:

      #include <iostream>
      #include <string>
      using namespace std;
      
      template <long num>
      struct Fibonacci 
      {
      	static const long val = Fibonacci<num - 1>::val + Fibonacci<num - 2>::val;
      };
      //模板特化,作为边界条件
      template <> struct Fibonacci<2> { static const long val = 1; };
      template <> struct Fibonacci<1> { static const long val = 1; };
      template <> struct Fibonacci<0> { static const long val = 0; };
      int main()
      {
      	long f[] = {
      		Fibonacci<10>::val,Fibonacci<11>::val,
      		Fibonacci<12>::val,Fibonacci<13>::val,
      	};
      	return 0;
      }
      

      我们定义了一个非类型参数的模板Fibonacci,并且通过递归的方式定义了一个静态变量val,利用模板在编译时期推导的特性,就可以实现在编译时期的计算,而为了提供推导的终止条件,我们模板特化了三个实例对象作为推导边界。

需要注意的是,我们理想的常量表达式函数计算应该在编译时期进行,但事实时由于编译器的优化特性,其真实执行时可能会绕过编译时计算而是在运行时再计算,这可能与对编译器的优化级别有关。


2. 变长模板

这一节的知识点较多篇幅可能会比较长,但是有较强的规律性,需要仔细品味。

  1. 变长函数和变长的模板参数

    在1.1节中,我们知道了C++11已经兼容了C99标准的变长宏,变长宏可以轻松配合printf函数打印相关信息。同时我们需要提到C语言的变长参数特性:在声明函数时,其最后的参数使用...表示,代表为变长参数。而我们真正调用函数时传入的参数(变长参数表示的那一部分),对于函数本身来说,是根本不知道它们的类型和数量的,因此在使用变长参数时往往会受到很大的限制。例如对于没有定义转义字的非POD的数据来说,使用变长函数就会出现未定义的程序行为(printf函数是变长函数):

    const char* a = "hello%s";
    printf(a, "world");//打印helloworld
    printf(a, string("world"));//打印hello然后乱码 因为string是非POD类型的数据
    

    C++11需要引入一种更为先进的变长参数的实现方式,即类型和变量能同时传递给变长参数函数。如果想要获得变量类型,使用模板就是一个很好的解决方案,但是我们同时需要不限制参数的数量,因此,变长模板(variadic template)应运而生。C++11中的tuple类模板就是一个很好的例子,它类似于pair类模板,不过不是只能存放两个而是可以存放任意多的数据。

  2. 变长模板:模板参数包和函数参数包

    首先给出变长模板的语法,这里以tuple为例:

     template <typename... Elements> class tuple;
    

    我们在标识符Elements前面使用了三个点来表示该模板参数是变长的,在C++11中,这里的Elements被称为模板参数包,可以看做一种新的模板参数类型。对于tuple<int, char, float>编译器会将这三个类型打包为一个模板参数包。

    对于非类型模板,也是可以使用变长模板的:

    template <int... A> class Test {};
    Test<1,2,3> t;//模板参数可以为任意多个
    

    模板参数包在推导是会被认为是单个模板参数,为了使用这个模板参数包,我们需要将其解包:

    template <typename... A> class Template: private B<A...> {};/*这里`<A...>`就是对模板参数的解包即对其还原。*/
    

    在将模板参数包解包了之后,我们如何使用这个变长模板参数呢?实际上,C++11实现tuple模板的方式给出了一种使用模板参数包的方案---递归。我们通过定义递归模板偏特化定义,使得模板参数包参实例化时可以层层展开,直到参数包中的参数逐渐耗尽或者达到边界为止。这里给出一个简化的tuple实现代码,可以帮助理解:

    template <typename... Elements> class tuple;//声明一个模板类tuple
    template <typename Head, typename... Tail>
    class tuple<Head, Tail...> : private tuple<Tail...>
    { /*这里使用的递归的推导方式,模板实例化时会层层展开*/
        Head head;/*每次提取一个类型并创建一个成员变量*/
    };
    template<> class tuple<> {};/*模板的偏特化定义,作为模板推导的边界*/
    

    这种变长模板的定义方式稍显复杂,却有效地解决了模板参数数量的问题。这里在给出一个非类型模板的例子:

    #include <iostream>
    using namespace std;
    
    template <long... nums> struct Multiply;//声明一个非类型模板类
    template <long first, long... last>
    struct Multiply<first, last...> 
    {
        static const long val = first * Multiply<last...>::val;/*这里使用递归的方法构造Multiply的静态成员变量*/
    };
    template<> struct Multiply<> { static const long val = 1; }; /*模板推导边界条件*/
    int main()
    {
        cout << Multiply<1,2,3,4>::val << endl; /* 输出为24 同时注意这里也发生了编译时期的计算,因此也可以属于模板元编程的范畴*/
        return 0;
    }
    

    有了模板参数包和函数参数包两个概念,我们就可以实现C语言中变长函数的功能了,这里看一个C++11提案中实现的新printf函数的例子:

    #include <iostream>
    using namespace std;
    
    //函数推导的边界,实例函数比模板函数有更高的优先级
    void Printf(const char* s)
    {
        while(*s){
            if(*s =='%' && *++s !='%') throw runtime_error("invalid format string: missing arguements");
            cout << *s++;
        }
    }
    template<typename T, typename... Args>
    void Printf(const char* s, T val, Args... args) 
    { /*这里args就是函数参数包*/
        while(*s){
            if(*s =='%' && *++s !='%'){
                cout<<val;
                return Printf(++s,args...);/*注意这里使用的是 args... 这里也是函数变长参数的使用方式*/
            }
            cout<<*s++;
        }
        throw runtime_error("extra arguments provided to Printf");
    }
    
    int main()
    {
       Printf("hello %s\n", string("world"));/* 这里我们成功输出了helloworld,即使第二个参数是非POD类型*/
        return 0;
    }
    
  3. 变长模板:进阶

    1. 参数包可以展开的位置

      • 表达式
      • 初始化列表
      • 基类描述符
      • 类成员初始化列表
      • 模板参数列表
      • 通用属性列表(第七章)
      • lambda函数的捕捉列表(第六章)
    2. 参数展开时的特殊操作

      1. 配合&&等,如声明了Args这个参数包,如果展开时这样书写:Args&&...,则展开为Args1&&,Args2&&,...,Argsn&&

      2. ...的位置,可以看下面的代码:

        template<typename... A> class T: private B<A>... {};
        template<typename... A> class T: private B<A...> {};
        /* 这两行只有...位置的区别 而我们实例化为T<X,Y>,则会出现两种完全不同的情况 */
        class T<X,Y>:private B<X>, B<Y> {};
        class T<X,Y>:private B<X,Y> {};
        //显然,这两种解包结果差别很大,这两种解包方式可以迁移至函数参数解包中
        
      3. sizeof...,C++11中引入了新操作符sizeof...用来计算参数包中参数的个数,用法请自行想象。

      4. 使用模板作为变长模板参数包

        先看一个例子吧:

        template<typename I, template<typename> class... B> struct Container{};//声明一个模板结构体
        template<typename I, template<typename> class A, template<typename> class... B>
        struct Container<I,A,B...>
        {
        	A<I> a;
            Container<I,B...> b;
        };
        template<typename I> struct Container<I>{};//推导的边界条件
        

        我们首先声明了一个模板类,同时我们看到了这样的写法:<typename I, template<typename> class A, template<typename> class... B>,这里template<typename> class A的意思是我们有一个模板参数A,但这个参数A本身也是一个模板类型,同理,template<typename> class... B表示B是一个模板参数包,不过该参数包中对应的类型都是模板类型。这里依旧采用了“声明--递归定义--递推边界”的定义方式实现模板参数包的展开。

        一般不要在一个模板类中使用两个模板参数包,这往往会报错

      5. 模板参数与完美转发的应用,引用书中的例子:

        #include <iostream>
        using namespace std;
        struct A 
        {
            A() {}/*默认构造*/
            A(const A& a) {cout<<"复制构造函数"<<__func__<<endl;}
            A(A&& a) {cout<<"移动构造函数"<<__func__<<endl;}
        };
        struct B 
        {
            B() {}/*默认构造*/
            B(const B& b) {cout<<"复制构造函数"<<__func__<<endl;}
            B(B&& b) {cout<<"移动构造函数"<<__func__<<endl;}
        };
        //变长模板的定义
        template <typename... T> struct MultiTypes;//声明
        template <typename T1, typename... T>
        struct MultiTypes<T1, T...>: public MultiTypes<T...>
        {
            T1 t1;
            MultiTypes<T1, T...>(T1 a, T... b):t1(a), MultiTypes<T...>(b...){/*构造函数 通过递归展开模板参数*/
                cout<<"MuliTypes<T1, T...>(T1 a,T... b)"<<endl;
            }
        };
        template <> struct MultiTypes<> 
        {
            MultiTypes<> () {cout<<"MultiTypes<>()" << endl;}/*模板的推导终止边界*/
        };
        //完美转发的变长模板
        template <template <typename...> class VariadicType, typename... Args>//模板作为模板参数,且该模板的模板参数是变长参数
        VariadicType<Args...> Build(Args&&... args)
        {
            return VariadicType<Args...>(std::forward<Args>(args)...);
        }
        int main()
        {
            A a;
            B b;
            Build<MultiTypes>(a,b);
            /* 输出
            MultiTypes<>()
        	MuliTypes<T1, T...>(T1 a,T... b)
        	MuliTypes<T1, T...>(T1 a,T... b)
        	*/
        }
        

        同样的套路,我们使用“声明--递归定义--递推边界”的定义方式实现模板参数包的展开,在配合右值引用实现完美转发,为了成功构造出我们的拥有变长模板参数的模板类,我们转发函数也需要适当改造(配合模板类使用)。

3. 原子类型与原子操作

建议找其他教程学习,这里只做简单介绍

  1. 并行编程,多线程与C++11

    在C++11之前,C++是没有多线程特性的,一直是一种顺序编程的语言。伴随着硬件的不断发展,并行编程的优势不断体现出来,同时作为最具有实用性的编程模型--多线程模型被其他语言广泛支持,C++对多线程的支持也十分有必要,因此,在C++11标准中,我们引入的对多线程的支持,其中最重要的布就是在原子操作中引入原子类型的概念。

  2. 原子操作与C++11原子类型

    1. 原子操作:就是多线程程序中‘最下的且不可并行化的’的操作。可以这么理解:如果我们对于一个资源是原子操作的化,则在多个线程访问该资源时,有且仅有唯一一个线程在对这个资源进行操作,从而避免了资源不同步和混乱的问题。通常情况下,原子操作都是通过“互斥”操作来保证访问的,例如使用POSIX标准的ptherad库中的互斥锁mutex

    2. C++原子类型

      例如使用上文提到的mutex互斥锁,我们就需要在原子操作的临界区进行加锁与解锁的操作,这无疑会增加代码量同时增加程序员的负担。因此C++11对并行编程进行了良好的抽象,要实现同样的功能会简单很多。先看一个使用原子类型实现原子操作的案例:

      #include <iostream>
      #include <thread>
      #include <atomic>
      using namespace std;
      atomic_llong total {0};/*原子数据类型*/
      void func(int) 
      {
          for(long long i = 0; i < 10000000;i++)
          {
              total+=i;
          }
      }
      int main()
      {
          thread t1(func,0);
          thread t2(func,0);
          t1.join();
          t2.join();
          cout<<total<<endl; //99999990000000
          return 0;
      }
      

      可以看到,由于使用了原子类型,我们程序运行时保证了total变量在正确计算的情况下最后才输出结果,如果不是线程安全的,其输出结果将是不确定的值:我们可以将atomic_llong类型替换为long long类型,在多次运行程序,将会发现其输出值在不断变化。同时原子类型的使用会使程序非常的简洁。

      这里我们给出原子类型的对应关系表,包含头文件

      原子类型名称 对应的内置类型名称
      atomic_bool bool
      atomic_char char
      atomic_schar signed char
      atomic_uchar unsigned char
      atomic_int int
      atomic_uint unsigned int
      atomic_short short
      atomic_ushort unsigned short
      atomic_long long
      atomic_ulong unsigned long
      atomic_llong long long
      atomic_ullong unsigned long long
      atomic_char16_t char16_t
      atomic_char_32_t char32_t
      atomic_wchar_t wchar_t
    3. 使用atomic类模板定义原子类型

      我们可以使用这样的语句定义一个原子类型的变量std::atomic<T> t;,声明一个类型为T的原子变量类型t。使用类模板的另一个好处就是可以定义出任意类型的原子类型变量了。

      注意:C++11标准规定原子类型只能从其模板参数类型中进行构造,而不允许原子类型进行拷贝构造,移动构造,以及使用operato=等,以免发生意外。如:

      atomic<int> a1 {1};//编译通过
      int val = 2;
      atomic<int> a3 {val};//编译通过
      atomic<int> a2 {a1};//无法通过编译,因为原子类型不允许拷贝构造
      
    4. 其他

      原子类型能够在线程之间保持原子性的原因主要还是因为编译器能够保证对原子类型的操作都是原子操作,同时原子操作都是平台相关的,所以有必要定义出统一的接口并根据编译选项或环境产生其平台相关的实现,C++11将许多原子操作定义为类模板的成员函数,至于这些接口,可以自习查资料学习。

      同时,我们上述给出的代码使用的是顺序一致性的内存模型,实际上像C++这样十分追求性能的语言来看,这是远远不能满足的,因此原子操作是可以根据需要自己设置内存模型的。

总之,多线程是一个十分复杂的问题,非常值得研究,受水平影响,这里仅进行有限的介绍。在学习多线程时,不仅要学会如何书写多线程,更重要的是学会设计多线程,合理的设计才能完全发挥多线程的全部性能。


4. 线程局部存储(TLS)

简单来说,就是变量拥有线程的生命周期以及线程内的可见性。这里主要讨论的是全局变量和静态变量,因为其在不同线程间来回使用时,我们常常难以获得实时准确的消息。例如我们设置一个共有的报错变量,该变量所有线程可见,当一个线程出现错误时就将该变量设置为一个特殊值,这样我们通过变量值就可以判定那个线程出了问题,这个想法是好的,但在多线程里实现却不简单,由于调度的随机性,我们的错误很有可能会被另一个错误覆盖从而导致我们无法准确的定位错误。而这种问题的一种解决方案就是TLS。

C++11对TLS标准做了统一的规定:要声明一个线程局部存储的变量,只需要在全局变量声明时加上thread_local errCode关键字修饰即可如

thread_local int errorFlag;/*全局变量使用thread_local关键字修饰,使用了线程局部存储,静态变量声明方法相同*/

而一个变量一但使用了线程局部存储,就意味着每个线程都会有一个独立的errorFlag拷贝,在一个线程中改变errorFlag不会改变其他线程的errorFlag

TLS变量的声明与使用十分简单,但是其实现往往比较复杂。一般来说,TLS的实现往往需要配合编译器,链接器,加载器甚至操作系统的相互配合。C++11对TLS只进行了语法上的统一,对实现没有做性能上的规定,所以不同平台上TLS的性能往往不同,如果想要获得最好性能,需要进一步了解所用平台与环境的相关说明文档。


5. 快速退出:quick_exit 与 at_quick_exit

  1. C++的终止函数

    1. terminate函数实际上是C++语言中异常处理的一部分,一般没有被捕捉的异常就会导致terminate函数的调用,而在默认情况下,terminate函数会调用abort函数(也可以通过set_terminate函数来改变默认行为)。abort函数不会调用任何析构函数,因此默认的terminate函数也不会调用任何析构函数。abort函数默认会向合乎POSIX标准的系统抛出一个信号:SIGABRT。如果程序员不为信号设定一个信号处理程序的话,那么操作系统默认会s释放进程所有资源,从而终止程序。在一些不健壮的程序中,这种意外的退出可能会导致出现其他意外的问题。

    2. exit函数相比于terminate函数则要安全许多,可以说是“正常退出”。exit函数会正常调用自动变量的析构函数,并且还会调用atexit注册的函数。我们看一个例子理解一下:

      #include <iostream>
      using namespace std;
      struct A {~A() {cout<<"析构函数调用\n";} }
      void test() {cout<<"exit!"<<endl;}
      int main()
      {
          A a;
          atexit(test);/*这里使用atexit注册了test函数,exit函数执行时会调用test函数输出语句*/
          exit(0);
      }
      

      关于atext注册函数的调用顺序,是与注册顺序相反的,这与析构函数有所相似。

    3. quick_exit 与 at_quick_exit

      1. 需求

        terminate函数往往会带来异常风险,exit结束的方式往往并不令人满意,因为当我们的代码中出现了很多的类,在堆空间分配了大量零散的内存,这时exit最后自动析构所带来的开销是不小的,从另一方面来说,这些资源的回收往往可以让操作系统以更加高效的方式解决(直接将内存标记为未分配即可),因此,很多析构有时无意义的。另外,在多线程情况下,如果使用exit函数退出程序,通常需要想线程发送一个信号,并等待x线程结束后执行析构函数与atexit注册的函数,这种设计在语法上无可挑剔,但现实往往会出现意外:例如一个线程在等待I/O或者进入了死循环,在一些复杂情况下就会出现一些“死锁”状况,会表现出程序卡死而无法退出的现象。在这种情况下,我们就需要设计出功能更为细致的退出函数。

      2. 快速退出

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

        #include <iostream>
        using namespace std;
        struct A {~A() {cout<<"析构函数调用\n";} };
        void test() {cout<<"exit!"<<endl;}
        int main()
        {
            A a;
            at_quict_exit(test);/*使用at_quick_exit注册了test函数,quick_exit函数执行时会调用test函数输出语句,但a的析构函数不会执行*/
            quick_exit(0);
        }
        

总结

​ 第五章讨论了五个个C++比较深入的话题。

  1. 常量表达式constexpr。常量表达式函数可以在编译时计算,这使得一些在运行时消耗的计算时间可以放到编译时期,同时以往const修饰的运行时常量的使用限制也被较好的解决。由于constexpr演化出的constexpr元编程有超越模板元编程的各种优势,可以看出它的开发上限是很高的。

  2. 变长模板是C++引入新的“变长”参数工具,但却远胜于以往的变长宏和变长函数,因为其可以同时传递参数类型和数量,通过递归配合模板偏特化(声明--递归定义--递推边界)可以逐个解析出变长模板的每个参数。变长模板的出现可以说给库的作者提供了很大的升级空间。

  3. 原子操作的引入彻底宣告C++来到了并行编程和多线程时代。相比于偏于底层的pthread库,C++11通过定义原子类型的方式,轻松化解了互斥访问同步变量的难题,不过也延续了C++语言易于学习,难于精通的特点。不同平台上具有差异化的内存模型也让C++11在设计多线程时更加的细致复杂。事实上,要想学好多线程编程,知道相关操作的使用只是九牛一毛,最重要的是会设计变量在多线程中的操作逻辑以及控制逻辑,这是最终要的但也是此本书没有涉及的,因此想要真正学会多线程,非常有必要找其他教材深入学习。

  4. 线程局部存储(LTS)是为了适应并行编程而对线程局部存储语法上进行了统一。

  5. 快速退出也是性能上一个显著的改进,更适用于多线程,可用于解出死锁导致的卡死状态,同时也可以省去许多不必要的析构函数的调用。


六. 为改变思考方式而改变

单击返回主目录

要点速查
空指针nullptr
函数的默认控制
lambda函数


概述

C++是一门成熟的语言,语言核心部分的改变通常都遵循着一贯的设计思想,但这不意味着C++就一定要墨守成规,C++11也创新性的添加了一些新元素,对这些新特性的使用也许会给使用者带来思考方式的改变。


1. 指针空值--nullptr

  1. 指针空值:从0到NULL再到nullptr

    在C++11以前,我们一般会使用NULL来初始化指针,而NULL是一个宏定义:#define NULL 0,也就是说我们通过NULL来表示0来初始化指针。事实上,大多数计算机是不允许访问地址为0的内存空间的,如果访问了程序可能直接回异常终止(退出)。因为0可以代表16进制的地址,也可代表一个普通的整型,这时使用宏定义NULL就会出现二义性的问题:

    void func(int) { cout<< "int param\n"; }
    void func(int*) { cout<<"int* param\n"; }
    void test()
    {
        func(0);//"int param"
        func(NULL);//"int param"
        func(nullptr);//"int* param"
    }
    

    可以看到,我们重载了函数func,但在使用0和NULL分别作为参数时输出全是“int param",这意味着NULL无法调用参数为int*版本的重载函数,其原因就是二义性:编译器优先将NULL(也就是0)解释为整型,所以调用的是整型作为参数的版本。

    C++11新标准中,处于兼容性的考虑,字面量0的二义性没有被取消,但是同时引入了一个“空指针值类型”的常量--nullptr来代替NULL宏。如果我们在上述代码中参数使用nullptr,则会输出“int* param”。

    一般来说,nullptr翻译为指针空值,而void*翻译为无类型指针,不可混为一谈

  2. nullptr和nullptr_t

    C++11不仅定义了空指针常量nullptr,也定义了其指针空值类型nullptr_t,这意味着可以通过nullptr_t来声明一个指针空值类型的常量(看起来用处不大)。关于指针空值类型的特殊性,C++11标准对其行为也做了较多的限制,我们可以来看一下:

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

    1. nullptr(void*)0并不是等价关系。两者的相似之处在于都可以转换为任何类型的指针,但nullptr是一个编译时期的常量,它的名字是一个编译时期的关键字,能够为编译器所识别,而(void*)0只是一个强制类型转换表达式,其返回的也是一个void*类型的指针。

    2. C++11标准规定用户不能直接获得nullptr的地址,但是却允许用户获得nullptr_t类型数据的地址,其原因是因为nullptr被定义为一个右值常量,取地址没有意义。可以有这样一种情况:定义一个nullptr_t类型数据的常量右值引用,并使用nullptr初始化,再打印其地址。

      int main()
      {
      	nullptr_t n1 = nullptr;
      	const nullptr_t&& n2 = nullptr;
      	printf("%x\n", &n1);//7511fa78
      	printf("%x\n", &n2);//7511fab8
      	//printf("%x", &nullptr);//无法编译,因为nullptr是右值常量
      	return 0;
      }
      

一般来说,我们只需要记得将原来使用NULL地方替换成nullptr,就可以提升程序的健壮性。


2. 默认函数的控制

  1. 类与默认函数

    C++中自定义的类,编译器默认会生成一些程序员未定义的成员函数,这样的函数版本被称为”默认函数“。

    默认函数

    • (无参)构造函数
    • 拷贝构造函数
    • 拷贝赋值函数(operator=)
    • 移动构造函数
    • 移动拷贝构造函数
    • 析构函数

    此外,C++编译器还会为自定义类提供以下默认操作符函数

    • operator ,
    • operator &
    • operator &&
    • operator *
    • operator ->
    • operator ->*
    • operator new
    • operator delete

    对于这些默认版本的函数,程序员如果提供了自定义的版本,编译器就不再提供默认版本。有些时候我们是需要编译器提供的默认版本的,比如我们希望自定义类是POD类型的,则其构造函数必须是由编译器提供的。同样的时候,我们又不想让编译器提供默认版本的函数,例如我们不想让自定义类使用某个运算符。因此C++11提供了=default=delete两个函数修饰符用来控制默认函数的产生。

  2. =default=delete

    一般的,我们将=default修饰的函数称为显示缺省函数,将=delete修饰的函数称为(显示)删除函数。如果我们需要将一个默认函数变为显示缺省函数,只需在定义是在函数后面加上 =default;即可,不需要提供函数体,显示缺省函数将自动使用编译器提供的版本。同理,在默认函数定义加上=delete;即可将函数设置为显示删除函数,编译器不会为其提供默认版本。

    关于=default的,我们一般建议在类外函数定义时才使用。因为我们可以选择不同的文件编译,这使我们可以更灵活的设计,如果在头文件类内声明函数是直接定义为显示缺省函数,则无法在源文件类外继续重载该函数了。

    关于=delete则有更多可以讨论的地方。

    1. 我们可以通过显示删除某个函数的一个重载版本来避免编译器做不必要的类型转换;

      class A
      {
          A(int) {}
          A(char) =delete;/*删除通过char类型构造的方式*/
          void* operator new(std::size_t) =delete;/*删除new操作符从而限制对象在堆上的构造*/
      };
      
    2. 尽量不要将explicit=delete一起使用,explicit是禁止类隐式构造,而再使用=delete相当于删除了显示构造功能而只能隐式构造对象,这显然是极不合理的。

    3. 可以通过显示删除析构函数来限制自定义类在栈上或者静态的构造,而我们可以通过placement new来构造,因为placement new构造的对象编译器不会为其调用析构函数。


4. lambda函数

  1. lambda函数的定义与使用

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

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

    直观来看,lambda函数跟普通函数相比不需要定义函数名,取而代之的多了一对方括号([])。同时,lambda函数还采用了追踪返回类型的方式声明其返回值。我们具体解释一下lambda函数每个部分的具体作用:

    • [capture]:捕捉列表。捕捉列表总是出现在lambda函数的开始处。事实上,[]是lambda的引出符,编译器会根据引出符判断接下来的代码是lambda函数。仅能捕捉自动变量。
    • (parameters):参数列表。与普通函数的参数列表一致,如果不需要传递参数,大部分情况下可以连同括号一起省略。
    • mutable:mutlable修饰符。默认情况下,lambda函数总是一个const函数,mutable可以取消其常量性,同时在使用此修饰符时,参数列表不可省略(即使参数为空)。
    • ->return-type:返回类型 用追踪返回类型形式声明函数的返回类型。出于方便,不需要返回值的时候可以连同符号->一起省略,同时,在返回类型明确的情况下,也可以省略该部分让编译器自行推导返回类型。
    • {statement}:函数体。内容与普通函数一样,除了可以使用参数之外,还可以使用捕获的变量。

    根据lambda函数的定义,我们可以写出的最简单的lambda函数:[]{};,相应的,这个lambda无法实现任何功能。

    关于捕获列表,其实有较多可以讨论的地方,首先看一下语法上的规则:

    • [val]表示值传递方式捕捉变量val;
    • [=]表示值传递方式捕捉所有父作用域的变量,包括this;
    • [&val]表示引用传递捕捉所有父作用域的变量;
    • [&]表示引用传递方式捕捉所有父作用域的变量,包括this;
    • [this]表示值传递方式捕捉this指针。

    同时这些捕获列表可以组合使用,如

    • [=,&a]表示值传递方式捕捉父作用域出a之外的所有变量,以引用传递方式捕捉a变量

    但是同一个变量不能捕捉两次:

    • [=,a],[&,&a],[a,&a]这些捕获列表显然是无法编译的。

    需要注意的是,捕获列表只能捕获最近一个父作用域的变量,在父作用域的父作用域及以外的变量是无法捕获的,这里可以说是C++对作用域进行了更细致的划分。

    我们可以将lambda函数看做一个局部匿名函数,可以用来封装一些局部代码,且lambda函数的可读性很高。

    局部函数就是在函数作用域中定义的函数,也称为内嵌函数。局部函数的作用域通常仅属于其父作用域,能够访问父作用域的变量,且在其父作用域使用。C/C++语言标准不允许局部函数存在,而C++11让局部函数以lambda函数的形式表现了出来。

  2. lambda与仿函数

    我们在学习C++标准模板库(STL)时,会遇到类特殊的对象,称之为函数对象或者仿函数。其本质就是自定义类,且重载了该类的括号()运算符,使其对象可以想函数一样使用,因此称之为仿函数。看一个例子:

    #include <iostream>
    using namespace std;
    template<typename T>
    class Add
    {
    public:
    	Add(T val = 0) { this->value = val; }
    	T operator ()(T x, T y)
    	{
    		return x + y + this->value;
    	}
    private:
    	T value;
    };
    int main()
    {
    	Add<int> add1;
    	Add<int> add2(1);
    	cout << add1(1, 1) << endl;//2
    	cout << add2(1, 1) << endl;//3
    	return 0;
    }
    

    这里,我们定义一个Add类,并且实例了其两个对象add1和add2,我们可以想使用函数一样使用add1和add2对象,但可以注意到,出了计算所传的参数,我们在Add类内部定义了一个私有变量value,并且可以在实例化对象时给value赋值,这样,我们就可以将value看做为一个Add对象的初始转态,而仿函数对象可以有不同初始状态的实例,因此可以借助仿函数实现功能类似却又不同的仿函数实例。事实上,这个初始状态就是对应于lambda函数的捕获列表。lambda函数捕获列表捕获的变量,其实就是lambda函数的初始转态,这与lambda函数参数列表中的参数是有所区别的。综上,可以看到lambda函数与仿函数有很大的相似性,实际上,编译器实现lambda函数的原理和仿函数也是十分相似的。

  3. lambda函数一些有趣的实验

    1. 捕获变量的时机

      #include <iostream>
      using namespace std;
      int main()
      {
      	int val = 1;
      	const int a = [=]() {return 1; }();
      	auto b = [&val]() {return val;};
      	auto c = [val]() {return val; };
      	cout << b() << endl;//1
      	cout << c() << endl;//1
      	++val;
      	cout << b() << endl;//2
      	cout << c() << endl;//1
      	return 0;
      }
      

      首先对于lambda函数,我们可以直接在其后面加上(param)就可以直接调用,不过以后就没法调用了。同时,我们可以定义一个auto类型的变量获得lambda函数的名字,从而重复使用lambda函数。另外关于捕获列表中以引用传递捕获的变量其值是由lambda函数调用时决定的,而通过值传递捕获的变量,其值在lambda函数定义时就已经决定。

    2. lambda函数的类型

      在C++11标准的定义上可以发现,lambda被定义为“闭包”(closure),而每个lambda表达式则会产生一个闭包类型的临时变量(右值),因此严格的说,lambda函数并非函数指针。但是C++11允许lambda表达式向函数指针的转换,不过前提是其没有捕捉任何变量。我们可以利用decltype关键字来获得lambda函数的类型。

    3. lambda函数的常量性以及mutable修饰符

      前面我们提到了lambda函数的常量性以及可以取消其常量性的mutable修饰符,这里我们仔细研究一下:

      int val = 0;
      //auto const_val_lambda = [=] () {val = 3;};//无法编译
      auto mutalbe_val_lambde = [=]() mutable {val = 3;};//正常编译
      auto const_ref_lambda = [&]() {val = 3; };//正常编译
      auto const_param_lambda = [&](int v) {v = 3; };//正常编译
      

      lambda函数的常量性是对其捕获列表中变量的常量性,而参数是可以修改的。这点其实和类的常量成员函数很相似(成员函数是常量的话,它就不可以修改成员变量的值),不过前面也说到过,lambda和仿函数的实现方式非常想,而仿函数本质就是类。而在这里,我们捕获列表和参数列表的差异化也体现了出来,看到这里,你应该更加理解了捕获列表中变量作为lambda函数“初始状态”的含义了。但是与我们设想不同的是,常量lambda中以引用传递方式捕获的变量依旧可以修改值,目前普遍给出的说法是:这里只会改变引用的值,却不会改变引用本身。关于mutable修饰符的使用,目前来看用处不是很大,一般使用默认的常量控制即可。

      关于按值传递的捕获变量无法改变,可以理解为其名称“闭包”的一种生动描述吧。

    4. lambda与STL

      lambda函数出现对于C++11最大的贡献就在STL库中。前面我们提到了lambda函数与仿函数的相似性,而仿函数就是STL库的一大重要成分。许多STL的函数类似于for_each,其使用时就需要一个仿函数。不难发现,设计一个lambda函数比设计一个仿函数要轻松许多,而且lambda形式更简洁,甚至实现的功能更加丰富。但是也不能完全说仿函数就一无是处,由于lambda可见变量只有其父作用域,因此在某些情况下,我们必须使用仿函数才能访问到我们需要的变量。一般情况下,我们认为满足一下两种条件时可以使用lambda函数代替仿函数:

      1. 是局限于一个局部作用域中使用的代码逻辑。
      2. 这些代码逻辑需要被作为参数传递。

      书上如此描述,我觉得比较抽象。


总结

​ 这一章节重点介绍了3个C++11新特性:nullptr,=default/=delete和lambda函数。

  1. nullptr这个新特性主要用于指针的初始化,C++11标准通过引入新的关键字nullptr排除了字面量0的二义性,使其成为指针初始化的标准方式。一般来说,程序员只需要将以前使用NULL的地方替换为nullptr就可以在支持C++11标准的编译器下获得更加健壮的指针初始化代码。
  2. default/delete函数则试图在C++缺省函数是否生成上给与程序员更多的控制力。通过显示缺省函数,我们可以保证自己设计的类依旧符合POD的定义,而通过显示删除函数,我们可以禁止类使用不该使用的一些功能。
  3. lambda可以说是C++11改变最大的一个新特性,其特殊的形式表达到特殊但强大的使用方式无疑会显著的影响编写代码者的思维方式。lambda的出现让STL的使用更加简化却又更加丰富,并且使C++作用域的概念再次被细分,可以说lambda让人们看到了C++新的光芒。

事实上,C++11提供的新的特性远不止这些,这里也只是记录了我学习到的相关知识,书后面还有一章:“融入实际应用”,大致介绍了C++对于内存对齐,Unicode字符集,原生字符串(R"(...)"),通用属性( [[noreturn]][[carries_dependency]] )相关内容的介绍。如果你也要系统深入的学习C++11,建议还是找一本书仔细研究。

写在最后

C++是我学习的第一门编程语言,算是学校安排的吧,目前为止也是我用的最多的一门编程语言。上课时感觉老师将的比较慢,就跑到了B站里自学,那时候有很高的学习热情在加上受益于宿舍晚上不断电,我有时也会学习C++到十二点。我印象里我网课一直从一开始的数据类型到最后的STL库的使用,这一路的学习让我对编程有了初步的了解,也渐渐让我掌握了编程语言的一些学习方法。大一寒假,我抱着一本《C++ Primer Plus》回了家 (事实上,在我 编写 这段文字时,这本书就摆在我前方的书柜上),因为平时无事就抱着书来啃。看完了几张纸,我就咬定这是一本好书,我要好好读下去!为什么这么说,因为不同于在学校里的教材直来直往的罗列知识点 ,读《C++ Primer Plus》更像是和作者面对面的对话,而且书上的内容也很有深度,一些 在自学时遇到的无法解决的问题这里似乎都给出了答案。另外,我常常有这样的感觉:你读书是往往会思考出一些问题,而在看这本书时,我刚想出的问题,它在这一页或者下一页就会给出答案,可以说是读书体验极佳了,虽然用了一整个寒假,但我觉得这本书绝对没有白啃!在学习C++11时,遇到 忘记的知识点 我也会把它拿出来翻一下,好几次我都在想:为什么我当时能一口气把这本书看完?即使当时很多知识点并未完全了解。现在想想,可能真的是有一股学习的热情吧。

进入大学前我没有任何编程基础,也多次在浏览器里搜索过学哪一门编程语言入门比较好,那里的回答各种各样。但是如果你现在问我学什么编程语言入门比较好,那我一定毫不犹豫的说:C++。为啥?这本书里说的一句话我非常的赞同:C++是一门易于学习 而难于精通的语言。我认为C++上手并不难,其实很多时候,我们简单的将C++理解为C语言+面向对象,虽然C++的能力不限于此。大一下和大二上,学校又开设了Java和Python两门语言课,这两门课在我看来还是较为简单的,因为当你熟悉了一门语言,你就会发现很多语言相同的特性,这让你在学习新语言时如鱼得水。而C++语言较为严格的语法也让我更能轻松的理解了其他更加“人性化 ”语言的原理,比如内存模型和设计思想。

大一下学期阴差阳错的(bushi)加入了学校的RM实验室,并成为了一名视觉组成员。当时第一件事就是配置环境和熟悉已有代码。看着前人已经写好的代码,似乎和我学的有些许不一样,因为使用了许多现代C++的特性,于是我也觉得有必要学习一下较为先进的C++了。起初我看的是这本书(《深入理解C++11》)的PDF版,但是看电子书时我常常无法集中注意力,所以最后还是买了本纸质书来看(至少在我看来,读纸质书的感觉和读电子书是完全不一样的)。但由于各种事情,学习C++11的进度总是被推迟,最后,直到今年(2022)十月份我才正式的开始了学习。又由于刚好学习了MarkDown语法,我便想着做一篇笔记,于是便有了这篇笔记。我现在理解了之前网上看到的话,记笔记写博客不是给别人看的,而是给自己看的。确实如此,记笔记的过程中加深的我的思考,让我对新特性有更加清晰的认识,也许能记得更加牢固。不过由于大二课程压力陡然提升,学习的进度也常有拖拉,我大概是花了将近两个月才读完这本书 并且做完笔记,这比我预期要晚了不少。

再说说学习感受吧。C++11的新特性确实让人眼前一亮,学习时我都会在自己电脑上测试一下(我使用的是Visual Stdio2022 和VScode)。不过也仅仅是测试了,目前还没有实际写项目用上,因此从理论认识到实际灵活使用我还是有很大一段距离的。而且笔记其实记得并不全,最后有些内容我是实在没看懂 ,不敢随便记录,还是看以后有机会理解吧!截至笔记完成时间C++23已经在测试了,十多年前的C++11似乎也老了,不过编程学习就是一个终身学习的过程,以后要学的新东西还很多 。最后引用《离骚》里的一句话:路漫漫其修远兮,吾将上下而求索。

posted @ 2024-07-21 11:10  daniulma_13381  阅读(43)  评论(0)    收藏  举报