简介

  • 内存管理的基本概念,大致的学习路径是:
    • 先讲堆和栈,
    • 然后讨论C++的特色功能RAII

1.1 基本概念

  • 堆,英文是heap,在内存管理的语境下,指的是动态分配内存的区域。这个堆跟数据结构里的堆不是一回事。这里的内存,被分配之后需要手动释放,否则就会造成内存泄漏。

  • C++标准里有一个相关概念是自由存储区,英文是 free store,特指使用 new 和 delete 来分配和释放内存的区域。一般而言,这是堆的一个子集:
    • new 和 delete 操作的区域是 free store
    • malloc 和 free 操作的区域是 heap
  • 但 new 和 delete通常底层使用malloc和free来实现,所以free store也是heap。

  • 栈,英文是stack,在内存管理的语境下,指的是函数调用过程中产生的本地变量和调用数据的区域。这个栈和数据结构里的栈高度相似,都满足“后进先出”(last-in-first-out 或 LIFO)

  • RAII, 完整的英文是 Resource Acquisition Is Initialization,是C++所特有的资源管理方式。由少量其他语言,例如D,Ada和Rust也采纳了RAII,但主流的编程语言中,C++是唯一一个依赖RAII来做资源管理的
  • RAII依托栈和析构函数,来对所有的资源–包括堆内存在内–进行管理。对RAII的使用,使得C++不需要类似于Java那样的垃圾收集方法,也能有效地对内存进行管理。RAII的存在,也是垃圾收集虽然理论上可以在C++使用,但从来没有真正流行过的主要原因。

1.2 深入探讨 – 堆

  • 从现代编程的角度来看,使用堆,或者说使用动态内存分配,是一件再自然不过的事情。
  • 从历史的角度,动态内存分配实际上是较晚出现的。由于动态内存带来的不确定性 – 内存分配耗时需要多久?失败了怎么办?等等。至今仍有很多场合会禁用动态内存,尤其是在实时性要求比较高的场合,例如飞行控制器和电信设备。

  • 在堆上分配内存,有些语言可能使用new这样的关键字,有些语言则是在对象的构造时隐式分配,不需要特殊关键字。
  • 不管哪种情况,程序通常需要牵涉到三个可能的内存管理器的操作
    • 让内存管理器分配一个某个大小的内存块
    • 让内存管理器释放一个之前分配的内存款
    • 让内存管理器进行垃圾收集操作,寻找不再使用的内存块并予以释放
  • C++通常会做上面的操作1和2
  • Java会做上面的操作1和3
  • Python会做上面的操作1,2,3
  • 这是语言的特性和实现方式决定的。

  • 需要略加说明的是,上面的三个操作都不简单,并且彼此之间是相关的。
  • 幸运的是,内存分配和释放的管理,是内存管理器的任务。一般情况下我们不需要介入。我们只需要正确地使用new和delete。每个new出来的对象都应该用delete来释放。

1.3 深入探讨 – 栈

  • 函数调用,本地变量使用栈。这一过程取决于计算机的实际架构,具体细节可能有所不同,但原理上都是相通的,都会使用一个后进先出的结构。

  • 栈是向上增长的。在包括x86在内的大部分计算机体系架构中,栈的增长方向是低地址,因而上方意味着低地址。任何一个函数,根据架构的约定,只能使用进入函数时栈指针向上部分的栈空间。
  • 当函数调用另外一个函数时,会把参数也压入栈里(我们此处忽略使用寄存器传递参数的情况),然后把下一行汇编指令的地址压入栈,并跳转到新的函数。新的函数进入后,首先做一些必须的保存功能,然后会调用栈指针,分配出本地变量所需要的空间,随后执行函数中的代码,并在执行完毕之后,根据调用者压入栈的地址,返回到调用者未执行的代码中继续执行。

  • 注意到了吗:本地变量所需要的内存就在栈上,跟函数执行所需要的其他数据在一起。当函数执行完成之后,这些内存也就自然而然释放掉了。
  • 我们可以看到:
    • 栈上的分配极为简单,移动一下栈指针而已
    • 栈上的释放也极为简单,函数执行结束时移动一下栈指针即可
    • 由于后进先出的执行过程,不可能出现内存碎片。
  • 顺便说一句,(图2)每种颜色都表示某个函数占用的栈空间。这部分空间有个特定的术语,叫做栈帧(stack frame)。
  • GCC 和 Clang 的命令行参数中提到 frame 的,例如-fomit-frame-pointer,一般就是指栈帧。

  • 前面例子的本地变量是简单类型,C++里称之为POD类型(Plain Old Data)。
  • 对于有构造和析构函数的非POD类型,栈上的内存分配也同样有效,只不过C++编译器会在生成代码的合适位置,插入对构造和析构函数的调用。
  • 这里尤其重要的是:编译器会自动调用析构函数,包括在函数执行发生异常的情况。
  • 在发生异常时对析构函数的调用,还有一个专门的术语,叫做栈展开(stack unwinding)。

  • 在C++里,所有的变量缺省都是值语义 – 如果不使用 * 和 & 的话,变量不会像Java或Python一样引用一个堆上的对象。对于像智能指针这样的类型,写ptr->call()ptr.get(),语法上都是对的,并且->.有着不同的语法作用。而在大部分其他语言中,访问成员只有.,但在作用上实际等价于C++的->
  • 这种值语义和引用语义的区别,是C++的特点,也是它的复杂性的一个来源。要用好C++,就需要理解它的值语义的特点。

1.4 深入探讨 – RAII

  • C++支持将对象存储在栈上面。但是,在很多情况下,对象不能,或者不应该,存储在栈上。例如:
    • 对象很大
    • 对象的大小在编译时不能确定
    • 对象是函数的返回值,但由于特殊的原因,不应该使用对象的值返回。
  • 常见情况之一是:在工厂方法或其他面向对象编程的情况下,返回值类型是基类(的指针或引用)。
  • 举例:—。 这个create_shape方法会返回一个shape对象,对象的实际类型是某个shape的子类。这种情况下,函数的返回值只能是指针或其变体形式。如果返回类型是shape,实际却返回一个circle,编译器不会报错,但结果多半是错的。这种现象叫做对象切片(object slicing),是C++特有的一种编码错误。这种错误不是语法错误,而是一个对象复制相关的语义错误,也算是C++的一个陷阱

  • 那么,我们怎样才能确保,在使用create_shape的返回值时不会发生内存泄漏呢?
  • 答案就是:在析构函数和它的栈展开行为上。我们只需要把这个返回值放到一个本地变量里,并确保其析构函数会删除该对象即可。
  • 如果好奇delete空指针会发生什么的话,那答案是,这是一个合法的空操作。在new一个对象和delete一个指针时编译器需要干不少活的
    • 也就是说,new的时候先分配内存(失败时整个操作失败并向外抛出异常,通常使bad_alloc),然后在这个结果指针上构造对象;
    • 构造成功则new操作整体完成,否则释放刚分配的内存并继续向外抛构造函数产生的异常。
    • delete时则判断指针是否为空,在指针不为空时调用析构函数并释放之前分配的内存
  • 在析构函数里做必要的清理工作,这就是RAII的基本用法。
  • 这种清理并不限于释放内存,也可以是:
    • 关闭文件(fstream的析构就会这么做)
    • 释放同步锁
    • 释放其他重要的系统资源

1.5 小结

  • 讨论了C++里内存管理的一些基本概念,强调栈是C++里最自然的内存使用方式,并且,使用基于栈和析构函数的RAII,可以有效地对包括内存在内的系统资源进行统一管理。

C++的智能指针

  • 使用智能指针,可以简化资源的管理,从根本上消除资源(包括内存)泄漏的可能性。
  • 智能指针本质上并不神秘,其实就是RAII资源管理功能的自然展现而已。

1.1 模板化和易用性

  • 要让某一个类能够包装任意类型的指针,我们需要把它变成一个类模板。
  • 在示例中,和shape_wrapper比较一下,我们就是在开头增加模板声明template <typename T>,然后把代码中的shape替换成模板参数T而已。

1.2 拷贝构造和赋值

  • 拷贝构造和赋值,我们暂且简称为拷贝,这是个比较复杂的问题了。关键它还不是实现问题,而是我们该如何定义其行为。

  • 使用智能指针的目的就是要减少对象的拷贝。
  • 一般而言,并没有通用的方法可以通过基类的指针来构造出一个子类的对象来。
  • 方法一:
    • 在拷贝时转移指针的所有权。在赋值函数中,通过拷贝构造产生一个临时对象并调用swap来交换指针的所有权。这种惯用法保证了强异常安全性:赋值分为拷贝构造和交换两步,异常只可能在第一步发生;二第一步如果发生异常的话,this对象完全不受任何影响。无论拷贝构造成功与否,结果只有赋值成功和赋值没有效果两种状态,而不会发生因为赋值破坏了当前对象这种场景。
    • 这个语义本质上就是C++98的auto_ptr的定义。但是auto_ptr在C++17时已经被正式从C++标准中删除了

1.3 移动指针

  • 把拷贝构造函数中的参数类型smart_ptr*改成了smart_ptr&&,现在它成了移动构造函数
  • 把赋值函数中的参数类型smart_ptr&改成了smart_ptr,在构造参数时直接生成新的智能指针,从而不再需要在函数体中构造临时对象。现在赋值函数的行为时移动还是拷贝,完全依赖于构造函数时走的是移动构造还是拷贝构造。

  • 根据C++的规则,如果我们提供了移动构造函数,而没有手动提供拷贝构造函数,那么后者自动被禁用
  • 记住,C++里哪些复杂的规则也是为方便编程而设立的。

  • 这也是C++11的unique_ptr的基本行为。

1.4 子类指针向基类指针的转换

1.5 引用计数

  • unique_ptr 算是一种较为安全的智能指针了。但是,一个对象只能被单个 unique_ptr 所拥有,这显然不能满足所有使用场合的需求。
  • 一种常见的情况是:多个智能指针同时拥有一个对象;当他们全部都失效时,这个对象也同时会被删除。这也就是shared_ptr

  • 多个不同的shared_ptr不仅可以共享一个对象,在共享同一对象时也需要同时共享一个计数。
  • 当最后一个指向对象(和共享计数)的shared_ptr析构时,它需要删除对象和共享计数。

1.6 指针类型转换

  • 对应于C++里的不同的类型强制转换:
    • static_cast
    • reinterpret_cast
    • const_cast
    • dynamic_cast
  • 只能指针需要实现类似的函数模板。实现本身并不复杂,但为了实现这些转换,我们需要添加构造函数,允许在对智能指针内部的指针对象赋值时,使用一个现有的智能指针的共享计数。

右值和移动 究竟解决了什么问题?

  • 移动语义是C++11里引入的一个重要概念;理解这个概念,是理解很多现代C++里的优化的基础。

1.1 值分左右

  • 我们常常会说,C++里有左值和右值。标准里的定义实际更加复杂,规定了下面这些值类别(value categories)
    • 一个 lvalue 是通常可以放在等号左边的表达式,左值
    • 一个 rvalue 是通常只能放在等号右边的表达式,右值
    • 一个 glvalue 是 generalized lvalue, 广义左值
    • 一个 xvalue 是 expiring value, 将亡值
    • 一个 prvalue 是 pure rvalue, 纯右值
  • 我们暂且抛开这些概念,只看其中两个:lvalue 和 prvalue

  • 左值 lvalue 是有标识符,可以取地址的表达式,最常见的情况有:
    • 变量,函数或数据成员的名字
    • 返回左值引用的表达式,例如++x, x = 1, cout << ' '
    • 字符串字面量,例如"hello world"
  • 在函数调用时,左值可以绑定到左值引用的参数,例如T&。一个常量只能绑定到常左值引用,例如const T&

  • 反之,纯右值 prvalue 是没有标识符,不可以取地址的表达式,一般也称之为 临时对象。最常见的情况有:
    • 返回非引用类型的表达式,例如x++, x+1, make_shared<int>(42)
    • 除字符串字面量之外的字面量,例如42, true

  • 在C++11之前,右值可以绑定到常左值引用(const lvalue reference)的参数,例如const T&,但不可以绑定到非常左值引用(non-const lvalue reference),例如T&
  • 从C++11开始,C++语言里多了一种引用类型 – 右值引用。右值引用的形式是T&&,比左值引用多了一个&符号。跟左值引用一样,我们可以使用 const 和 volatile 来修饰,但最常见的情况是,我们不会用 const 和 volatile 来修饰右值。

  • 引入一种额外的引用类型当然增加了语言的复杂性,但也带来了很多优化的可能。
  • 由于C++有重载,我们就可以根据不同的引用类型,来选择不同的重载函数,来完成不同的行为。

  • 对于指针,我们通常使用值传递,并不关心它是左值还是右值
  • std::move(ptr),它的作用是把一个左值引用强制转换成一个右值引用,而并不改变其内容。从实用的角度,在这里std::move(ptr1)等价于static_cast<smart_ptr<shape>&&>(ptr1)。因此,std::move(ptr1)的结果是指向ptr1的一个右值引用,这样构造ptr2时就会选择上面第二个重载。

  • 我们可以把std::move(ptr1)看作是一个有名字的右值。为了跟无名的纯右值prvalue相区别,C++里目前就把这种表达式叫做xvalue。跟左值lvalue不同,xvalue仍然是不能取地址的–这点上,xvalue和prvalue相同。所以,xvalue和prvalue都被归为右值rvalue。

  • 另外,需要注意的是:值类别(value category) 和 值类型(value type), 是两个看似相似,却毫不相干的术语。
    • 前者指的是上面这些左值,右值相关的概念
    • 后者则是与引用类型(reference type)相对而言,表明一个变量是代表实际数值,还是引用另外一个数值。
  • 在C++里,所有的原生类型,枚举,结构,联合,类都代表值类型,只有引用&和指针*才是引用类型。(在Java里,数字等原生类型是值类型,类则属于引用类型。在Python里,一切类型都是引用类型。)

1.2 生命周期和表达式类型

  • 一个变量的生命周期在超出作用域时结束。如果一个变量代表一个对象,当然这个对象的生命周期也在那时结束。
  • 那么临时对象(prvalue)呢?
  • 在这儿,C++的规则是:
    • 一个临时对象会在包含这个临时对象的完整表达式估值完成后,按生成顺序的逆序被销毁,除非有生命周期延长发生。
  • 临时对象最后生成,最先析构
  • 为了方便对临时对象的使用,C++对临时对象有特殊的生命周期延长规则,这条规则是:
    • 如果一个prvalue被绑定到一个引用上,它的生命周期则会延长到跟这个引用变量一样长

1.3 移动的意义

  • 上面谈了一些语法知识。就跟学外语的语法一样,这些内容是比较枯燥的。虽然这些知识有时有用,但往往要回过头来看的时候才觉得。初学之时,更重要的是理解为什么,和熟练掌握基本的用法。

  • 对于smart_ptr,我们使用右值引用的目的是实现移动,而实现移动的意义是减少运行的开销:在引用计数指针的场景下,这个开销并不大。
  • 移动构造和拷贝构造的差异仅在于:
    • 少了一次other.shared_count_->add_count()的调用
    • 被移动的指针被清空,因而析构时也少了一次shared_count_->reduce_count()的调用
  • 在使用容器类的情况下,移动更有意义。
  • string result = string("Hello, ") + name + ".";
    • 在C++11之前的年代里,这种写法是绝对不推荐的。因为它会引入很多额外的开销
    • 既然C++是一门追求性能的语言,一个合格的C++程序员会写:
      1
      2
      3
      
        string result = "Hello, ";
        result += name;
        result += ".";
      
    • 这样的话,只会调用构造函数一次和string::operator+=两次,没有任何临时对象需要生成和析构,所有的字符串都只复制了一次。
    • 但是,从C++11开始,这就不再是必须的。同样上面那个单行的语句,性能上,所有的字符串只复制了一次;虽然比啰嗦的写法仍然要增加临时对象的构造和析构,但由于这些操作不牵涉到额外的内存分配和释放,是相当廉价的。

  • 此外,很关键的额一点是,C++里的对象缺省都是值语义。在下面这样的代码里:
    1
    2
    3
    4
    
      class A {
        B b_;
        C c_;
      };
    
  • 从实际内存布局的角度,很多语言:例如Java和Python,会在A对象里放B和C的指针(虽然这些语言里本身没有指针的概念)。而C++则会直接把B和C对象放在A的内存空间里。
  • 这种行为既是优点也是缺点:
    • 优点:是因为它保证了内存访问的局域性,而局域型在现在处理器架构上是聚堆具有性能优势的
    • 缺点:是因为复制对象的开销大大增加,在Java类语言里复制的是指针,在C++里是完整的对象。这就是为什么C++需要移动语义这一优化,而Java类语言里则根本不需要这个概念。
  • 一句话总结:
    • 移动语义使得在C++里返回大对象(如容器)的函数和运算符称为现实,因而可以提高代码的简洁性和可读性,提高程序员的生产率。
    • 所有的现代C++的标准容器都针对移动进行了优化。

1.4 如何实现移动

  • 要让你设计的对象支持移动的话,通常需要下面几步:
    • 你的对象应该有分开的拷贝构造和移动构造函数(除非你只打算支持移动,不支持拷贝,例如unique_ptr)
    • 你的对象应该有swap成员函数,支持和另外一个对象快速交换成员
    • 在你的对象的名空间下,应当有一个全局的swap函数,调用成员函数swap来实现交换。支持这种用法会方便别人(包括你自己在将来)在其他对象里包含你的对象,并快速实现它们的swap函数
    • 实现通用的operator=
    • 上面各个函数如果不抛异常的化,应当标为noexcept。这对移动构造函数尤为重要。

1.5 不要返回本地变量的引用

  • 有一种常见的C++编程错误,是在函数里返回一个本地对象的引用。由于在函数结束时本地对象即被销毁,返回一个指向本地对象的引用属于未定义行为。理论上来说,程序出现任何奇怪的行为都是正常的。

  • 在C++11之前,返回一个本地对象意味着这个对象会被拷贝,除非编译器发现可以做返回值优化(named return value optimization, 或NRVO)能把对象直接构造到调用者的栈上。
  • 从C++11开始,返回值优化仍可以发生,但在没有返回值优化的情况下,编译器将试图把本地对象移动出去,而不是拷贝出去。这一行为不需要程序员手工用std::move进行干预:使用std::move对于移动行为没有帮助,反而会影响返回值优化

1.6 引用坍缩和完美转发

  • 引用坍缩(又称 引用折叠)。这个概念在泛型编程中是一定会碰到的
  • 对于一个实际的类型T,它的左值引用是T&,右值引用是T&&,那么
    • 是不是看到T&,就一定是一个左值引用?
    • 是不是看到T&&,就一定是一个右值引用?
  • 对于前者的回答为:是;对于后者的回答为:否

  • 关键在于,在有模板的代码里,对于类型参数的推到结果可能是引用。我们可以略过一些繁复的语法规则,要点是:
    • 对于template <typename T> foo(T&&)这样的代码,如果传递过去的参数是左值,T的推到结果是左值引用;如果传递过去的参数是右值,T的推到结果是参数的类型本身
    • 如果T是左值引用,那T&&的结果仍然是左值引用:即type& &&坍缩成了type&
    • 如果T是一个实际类型,那T&&的结果自然就是一个右值引用
  • 我们之前提到过,右值引用变量仍然会匹配到左值引用上去

  • 事实上,很多标准库里的函数,连目标的参数类型都不知道,但我们仍然需要能够保持参数的值类别:左值的仍然是左值,右值的仍然是右值。
  • 这个功能在C++标准库中已经提供了,叫std::forward。它和std::move一样都是利用引用坍缩机制来实现。
  • 此处,我们不介绍其实现细节,而是重点展示其用法。我们可以把我们的两个bar函数简化成:
    1
    2
    3
    4
    5
    
      template <typename T>
      void var(T&& s)
      {
        foo(std::forward<T>(s));
      }
    
  • 对于下面这样的代码:
    1
    2
    3
    
      circle temp;
      bar(temp);
      bar(circle());
    
  • 现在的输出是:
    • foo(const shape&)
    • foo(shape&&)
  • 因为在T是模板参数时,T&&的作用主要是保持值类别进行转换,它有个名字就叫做 转发引用(forwarding reference)。因为既可以是左值引用,也可以是右值引用,它也曾经被称为 万能引用(universal reference)

1.7 小结

  • 介绍了C++里的值类别,重点介绍了临时变量,右值引用,移动语义和实际的编程用法
  • 由于这是C++11里的重点功能,对于其基本用法需要牢牢掌握

容器汇编一:比较简单的若干容器

  • 对于容器,学习上的一个麻烦点是你无法直接输出容器的内容
    • 如果定义了一个vector<int> v,是没有办法简单输出v的内容的
  • 我们需要一个更好用的工具。在此,我向你大力推荐 xeus-cling。它的便利性无与伦比——你可以直接在浏览器里以交互的方式运行代码,不需要本机安装任何编译器

1.1 string

  • string 一般并不被认为是一个 C++ 的容器。但鉴于其和容器有很多共同点,我们先拿 string 类来开说
  • string 是模板 basic_string 对于 char 类型的特化,可以认为是一个只存放字符 char 类型数据的容器。“真正”的容器类与 string 的最大不同点是里面可以存放任意类型的对象。
  • 跟其他大部分容器一样, string 具有下列成员函数:
    • begin 可以得到对象起始点
    • end 可以得到对象的结束点
    • empty 可以得到容器是否为空
    • size 可以得到容器的大小
    • swap 可以和另外一个容器交换其内容
  • 对于不那么熟悉容器的人,需要知道 C++ 的 begin 和 end 是半开半闭区间:
    • 在容器非空时,begin 指向第一个元素,而 end 指向最后一个元素后面的位置
    • 在容器为空时,begin 等于 end。
  • 在 string 的情况下,由于考虑到和 C 字符串的兼容,end 指向代表字符串结尾的 \0 字符。

  • 上面就几乎是所有容器的共同点了。也就是说:
    • 容器都有开始和结束点
    • 容器会记录其状态是否非空
    • 容器有大小
    • 容器支持交换
  • 当然,这只是容器的“共同点”而已。每个容器都有其特殊的用途。
  • 下面你会看到,不管是内存布局,还是成员函数,string 和 vector 是非常相似的。
  • string 当然是为了存放字符串。和简单的 C 字符串不同:
    • string 负责自动维护字符串的生命周期
    • string 支持字符串的拼接操作(如之前说过的 + 和 +=)
    • string 支持字符串的查找操作(如 find 和 rfind)
    • string 支持从 istream 安全地读入字符串(使用 getline)
    • string 支持给期待 const char* 的接口传递字符串内容(使用 c_str)
    • string 支持到数字的互转(stoi 系列函数和 to_string)
    • 等等
  • 推荐你在代码中尽量使用 string 来管理字符串

  • 不过,对于对外暴露的接口,情况有一点复杂。我一般不建议在接口中使用 const string&,除非确知调用者已经持有 string:
    • 如果函数里不对字符串做复杂处理的话,使用 const char* 可以避免在调用者只有 C 字符串时编译器自动构造 string,这种额外的构造和析构代价并不低
  • 反过来,如果实现较为复杂、希望使用 string 的成员函数的话,那就应该考虑下面的策略:
    • 如果不修改字符串的内容,使用 const string& 或 C++17 的 string_view 作为参数类型。后者是最理想的情况,因为即使在只有 C 字符串的情况,也不会引发不必要的内存复制。
    • 如果需要在函数内修改字符串内容、但不影响调用者的该字符串,使用 string 作为参数类型(自动拷贝)。
    • 如果需要改变调用者的字符串内容,使用 string& 作为参数类型(通常不推荐)

1.2 vector

  • vector 应该是最常用的容器了。它的名字“向量”来源于数学术语,但在实际应用中,我们把它当成动态数组更为合适。它基本相当于 Java 的 ArrayList 和 Python 的 list。
  • 和 string 相似,vector 的成员在内存里连续存放,同时 begin、end、front、back 成员函数指向的位置也和 string 一样

  • 除了容器类的共同点,vector 允许下面的操作(不完全列表):
    • 可以使用中括号的下标来访问其成员(同 string)
    • 可以使用 data 来获得指向其内容的裸指针(同 string)
    • 可以使用 capacity 来获得当前分配的存储空间的大小,以元素数量计(同 string)
    • 可以使用 reserve 来改变所需的存储空间的大小,成功后 capacity() 会改变(同 string)
    • 可以使用 resize 来改变其大小,成功后 size() 会改变(同 string)
    • 可以使用 pop_back 来删除最后一个元素(同 string)
    • 可以使用 push_back 在尾部插入一个元素(同 string)
    • 可以使用 insert 在指定位置前插入一个元素(同 string)
    • 可以使用 erase 在指定位置删除一个元素(同 string)
    • 可以使用 emplace 在指定位置构造一个元素
    • 可以使用 emplace_back 在尾部新构造一个元素
  • 大家可以留意一下 push_… 和 pop_… 成员函数。它们存在时,说明容器对指定位置的删除和插入性能较高。
  • vector 适合在尾部操作,这是它的内存布局决定的。只有在尾部插入和删除时,其他元素才会不需要移动,除非内存空间不足导致需要重新分配内存空间。
  • 当 push_back、insert、reserve、resize 等函数导致内存重分配时,或当 insert、erase 导致元素位置移动时,vector 会试图把元素“移动”到新的内存区域。vector 通常保证强异常安全性,如果元素类型没有提供一个保证不抛异常的移动构造函数,vector 通常会使用拷贝构造函数。因此,对于拷贝代价较高的自定义元素类型,我们应当定义移动构造函数,并标其为 noexcept,或只在容器中放置对象的智能指针。这就是为什么我之前需要在 smart_ptr 的实现中标上 noexcept 的原因。

  • C++11 开始提供的 emplace… 系列函数是为了提升容器的性能而设计的。你可以试试把 v1.emplace_back() 改成 v1.push_back(Obj1())。对于 vector 里的内容,结果是一样的;但使用 push_back 会额外生成临时对象,多一次(移动或拷贝)构造和析构。如果是移动的情况,那会有小幅性能损失;如果对象没有实现移动的话,那性能差异就可能比较大了。
  • 现代处理器的体系架构使得对连续内存访问的速度比不连续的内存要快得多。因而,vector 的连续内存使用是它的一大优势所在。当你不知道该用什么容器时,缺省就使用 vector 吧。
  • vector 的一个主要缺陷是大小增长时导致的元素移动。如果可能,尽早使用 reserve 函数为 vector 保留所需的内存,这在 vector 预期会增长很大时能带来很大的性能提升。

1.3 deque

  • deque 的意思是 double-ended queue,双端队列。它主要是用来满足下面这个需求:
    • 容器不仅可以从尾部自由地添加和删除元素,也可以从头部自由地添加和删除。
  • deque 的接口和 vector 相比,有如下的区别:
    • deque 提供 push_front、emplace_front 和 pop_front 成员函数。
    • deque 不提供 data、capacity 和 reserve 成员函数。
  • deque 的内存布局,可以看到:
    • 如果只从头、尾两个位置对 deque 进行增删操作的话,容器里的对象永远不需要移动。
    • 容器里的元素只是部分连续的(因而没法提供 data 成员函数)。
    • 由于元素的存储大部分仍然连续,它的遍历性能是比较高的。
    • 由于每一段存储大小相等,deque 支持使用下标访问容器元素,大致相当于 index[i / chunk_size][i % chunk_size],也保持高效。
  • 如果你需要一个经常在头尾增删元素的容器,那 deque 会是个合适的选择。

1.4 list

  • list 在 C++ 里代表双向链表。和 vector 相比,它优化了在容器中间的插入和删除:
    • list 提供高效的、O(1) 复杂度的任意位置的插入和删除操作。
    • list 不提供使用下标访问其元素。
    • list 提供 push_front、emplace_front 和 pop_front 成员函数(和 deque 相同)。
    • list 不提供 data、capacity 和 reserve 成员函数(和 deque 相同)。
  • 需要指出的是,虽然 list 提供了任意位置插入新元素的灵活性,但由于每个元素的内存空间都是单独分配、不连续,它的遍历性能比 vector 和 deque 都要低
  • 这在很大程度上抵消了它在插入和删除操作时不需要移动元素的理论性能优势。如果你不太需要遍历容器、又需要在中间频繁插入或删除元素,可以考虑使用 list。
  • 另外一个需要注意的地方是,因为某些标准算法在 list 上会导致问题,list 提供了成员函数作为替代,包括下面几个:
    • merge
    • remove
    • remove_if
    • reverse
    • sort
    • unique

1.5 forward_list

  • 既然 list 是双向链表,那么 C++ 里有没有单向链表呢?答案是肯定的。从 C++11 开始,前向列表 forward_list 成了标准的一部分。
  • 大部分 C++ 容器都支持 insert 成员函数,语义是从指定的位置之前插入一个元素。对于 forward_list,这不是一件容易做到的事情(想一想,为什么?)。标准库提供了一个 insert_after 作为替代。此外,它跟 list 相比还缺了下面这些成员函数:
    • back
    • size
    • push_back
    • emplace_back
    • pop_back
  • 为什么会需要这么一个阉割版的 list 呢?
  • 原因是,在元素大小较小的情况下,forward_list 能节约的内存是非常可观的;在列表不长的情况下,不能反向查找也不是个大问题。提高内存利用率,往往就能提高程序性能,更不用说在内存可能不足时的情况了。
  • 目前你只需要知道这个东西的存在就可以了。如果你觉得不需要用到它的话,也许你真的不需要它。

1.6 queue

  • 在结束本讲之前,我们再快速讲两个类容器。它们的特别点在于它们都不是完整的实现,而是依赖于某个现有的容器,因而被称为容器适配器(container adaptor)。

  • 我们先看一下队列 queue,先进先出(FIFO)的数据结构。
  • queue 缺省用 deque 来实现。它的接口跟 deque 比,有如下改变:
    • 不能按下标访问元素
    • 没有 begin、end 成员函数
    • 用 emplace 替代了 emplace_back,用 push 替代了 push_back,用 pop 替代了 pop_front;没有其他的 push_…、pop_…、emplace…、insert、erase 函数
  • 它的实际内存布局当然是随底层的容器而定的。

1.7 stack

  • 类似地,栈 stack 是后进先出(LIFO)的数据结构。
  • stack 缺省也是用 deque 来实现,但它的概念和 vector 更相似。它的接口跟 vector 比,有如下改变:
    • 不能按下标访问元素
    • 没有 begin、end 成员函数
    • back 成了 top,没有 front
    • 用 emplace 替代了 emplace_back,用 push 替代了 push_back,用 pop 替代了 pop_back;没有其他的 push_…、pop_…、emplace…、insert、erase 函数
  • 一般图形表示法会把 stack 表示成一个竖起的 vector:

  • 这里有一个小细节需要注意。stack 跟我们前面讨论内存管理时的栈有一个区别:
    • 在这里下面是低地址,向上则地址增大;而我们讨论内存管理时,高地址在下面,向上则地址减小,方向正好相反
  • 提这一点,是希望你在有需要检查栈结构时不会因此而发生混淆;在使用 stack 时,这个区别通常无关紧要。

容器汇编二:需要函数对象的容器

1.1 函数对象及其特化

  • 在讲容器之前,我们需要首先来讨论一下两个重要的函数对象,less 和 hash。

  • 我们先看一下 less,小于关系。在标准库里,通用的 less 大致是这样定义的:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
      template <class T>
      struct less
        : binary_function<T, T, bool> {
        bool operator()(const T& x,
                        const T& y) const
        {
          return x < y;
        }
      };
    
  • 也就是说,less 是一个函数对象,并且是个二元函数,执行对任意类型的值的比较,返回布尔类型。
  • 作为函数对象,它定义了函数调用运算符(operator()),并且缺省行为是对指定类型的对象进行 < 的比较操作。
  • 有点平淡无奇,是吧?原因是因为这个缺省实现在大部分情况下已经够用,我们不太需要去碰它。在需要大小比较的场合,C++ 通常默认会使用 less,包括我们今天会讲到的若干容器和排序算法 sort。如果我们需要产生相反的顺序的话,则可以使用 greater,大于关系。

  • 计算哈希值的函数对象 hash 就不一样了。它的目的是把一个某种类型的值转换成一个无符号整数哈希值,类型为 size_t。它没有一个可用的默认实现。对于常用的类型,系统提供了需要的特化
  • 这当然是一个极其简单的例子。更复杂的类型,如指针或者 string 的特化,都会更复杂。要点是,对于每个类,类的作者都可以提供 hash 的特化,使得对于不同的对象值,函数调用运算符都能得到尽可能均匀分布的不同数值。
  • 对于容器也是如此,函数对象的类型确定了容器的行为。

1.2 priority_queue

  • priority_queue 也是一个容器适配器。上一讲没有和其他容器适配器一起讲的原因就在于它用到了比较函数对象(默认是 less)。
  • 和 stack 相似,支持 push、pop、top 等有限的操作,但容器内的顺序既不是后进先出,也不是先进先出,而是(部分)排序的结果。
  • 在使用缺省的 less 作为其 Compare 模板参数时,最大的数值会出现在容器的“顶部”。如果需要最小的数值出现在容器顶部,则可以传递 greater 作为其 Compare 模板参数。

1.3 关联容器

  • 关联容器有 set(集合)、map(映射)、multiset(多重集)和 multimap(多重映射)。
  • 跳出 C++ 的语境,map(映射)的更常见的名字是关联数组和字典,而在 JSON 里直接被称为对象(object)。
  • 在 C++ 外这些容器常常是无序的;在 C++ 里关联容器则被认为是有序的。

  • 关联容器是一种有序的容器。名字带“multi”的允许键重复,不带的不允许键重复。set 和 multiset 只能用来存放键,而 map 和 multimap 则存放一个个键值对
  • 与序列容器相比,关联容器没有前、后的概念及相关的成员函数,但同样提供 insert、emplace 等成员函数。此外,关联容器都有 find、lower_bound、upper_bound 等查找函数,结果是一个迭代器:
    • find(k) 可以找到任何一个等价于查找键 k 的元素(!(x < k   k < x))
    • lower_bound(k) 找到第一个不小于查找键 k 的元素(!(x < k))
    • upper_bound(k) 找到第一个大于查找键 k 的元素(k < x)
  • 如果你需要在 multimap 里精确查找满足某个键的区间的话,建议使用 equal_range,可以一次性取得上下界(半开半闭)

  • 如果在声明关联容器时没有提供比较类型的参数,缺省使用 less 来进行排序。如果键的类型提供了比较算符 < 的重载,我们不需要做任何额外的工作。否则,我们就需要对键类型进行 less 的特化,或者提供一个其他的函数对象类型。

  • 对于自定义类型,我推荐尽量使用标准的 less 实现,通过重载 <(及其他标准比较运算符)对该类型的对象进行排序。存储在关联容器中的键一般应满足严格弱序关系(strict weak ordering;)即:
    • 对于任何该类型的对象 x:!(x < x)(非自反)
    • 对于任何该类型的对象 x 和 y:如果 x < y,则 !(y < x)(非对称)
    • 对于任何该类型的对象 x、y 和 z:如果 x < y 并且 y < z,则 x < z(传递性)
    • 对于任何该类型的对象 x、y 和 z:如果 x 和 y 不可比(!(x < y) 并且 !(y < x))并且 y 和 z 不可比,则 x 和 z 不可比(不可比的传递性)
  • 大部分情况下,类型是可以满足这些条件的,不过:
    • 如果类型没有一般意义上的大小关系(如复数),我们一定要别扭地定义一个大小关系吗?
    • 通过比较来进行查找、插入和删除,复杂度为对数 O(log(n)),有没有达到更好的性能的方法?

1.4 无序关联容器

  • 从 C++11 开始,每一个关联容器都有一个对应的无序关联容器,它们是:
    • unordered_set
    • unordered_map
    • unordered_multiset
    • unordered_multimap
  • 这些容器和关联容器非常相似,主要的区别就在于它们是“无序”的。这些容器不要求提供一个排序的函数对象,而要求一个可以计算哈希值的函数对象。
  • 你当然可以在声明容器对象时手动提供这样一个函数对象类型,但更常见的情况是,我们使用标准的 hash 函数对象及其特化。
  • 正常情况下,向 std 名空间添加声明或定义是禁止的,属于未定义行为。
  • 从实际的工程角度,无序关联容器的主要优点在于其性能。
    • 关联容器和 priority_queue 的插入和删除操作,以及关联容器的查找操作,其复杂度都是 O(log(n)),而无序关联容器的实现使用哈希表 ,可以达到平均 O(1)!
    • 但这取决于我们是否使用了一个好的哈希函数:在哈希函数选择不当的情况下,无序关联容器的插入、删除、查找性能可能成为最差情况的 O(n),那就比关联容器糟糕得多了。

1.5 array

  • 我们讲的最后一个容器是 C 数组的替代品。C 数组在 C++ 里继续存在,主要是为了保留和 C 的向后兼容性。C 数组本身和 C++ 的容器相差是非常大的:
    • C 数组没有 begin 和 end 成员函数(虽然可以使用全局的 begin 和 end 函数)
    • C 数组没有 size 成员函数(得用一些模板技巧来获取其长度)
    • C 数组作为参数有退化行为,传递给另外一个函数后那个函数不再能获得 C 数组的长度和结束位置
  • 在 C 的年代,大家有时候会定义这样一个宏来获得数组的长度:
    1
    2
    
      #define ARRAY_LEN(a) \
        (sizeof(a) / sizeof((a)[0]))
    
  • 如果在一个函数内部对数组参数使用这个宏,结果肯定是错的。

  • C++17 直接提供了一个 size 方法,可以用于提供数组长度,并且在数组退化成指针的情况下会直接失败:
  • 此外,C 数组也没有良好的复制行为。你无法用 C 数组作为 map 或 unordered_map 的键类型

  • 如果不用 C 数组的话,我们该用什么来替代呢?我们有三个可以考虑的选项:
    • 如果数组较大的话,应该考虑 vector。vector 有最大的灵活性和不错的性能。
    • 对于字符串数组,当然应该考虑 string。
    • 如果数组大小固定(C 的数组在 C++ 里本来就是大小固定的)并且较小的话,应该考虑 array。array 保留了 C 数组在栈上分配的特点,同时,提供了 begin、end、size 等通用成员函数。
  • array 可以避免 C 数组的种种怪异行径

异常:用还是不用,这是个问题

  • 首先,开宗明义,如果你不知道到底该不该用异常的话,那答案就是该用。如果你需要避免使用异常,原因必须是你有明确的需要避免使用异常的理由。

1.1 没有异常的世界

  • 我们先来看看没有异常的世界是什么样子的。最典型的情况就是 C 了。
  • 我们有大量需要判断错误的代码,零散分布在代码各处。可这是 C 啊。我们用 C++、不用异常可以吗?
  • 当然可以,但你会发现结果好不了多少。毕竟,C++ 的构造函数是不能返回错误码的,所以你根本不能用构造函数来做可能出错的事情。你不得不定义一个只能清零的构造函数,再使用一个 init 函数来做真正的构造操作。C++ 虽然支持运算符重载,可你也不能使用,因为你没法返回一个新矩阵……
  • 我上面还只展示了单层的函数调用。事实上,如果出错位置离处理错误的位置相差很远的话,每一层的函数调用里都得有判断错误码的代码,这就既对写代码的人提出了严格要求,也对读代码的人造成了视觉上的干扰……

1.2 使用异常

  • 如果使用异常的话,我们就可以在构造函数里做真正的初始化工作了。
  • 假设我们的矩阵类有下列的数据成员:
    1
    2
    3
    4
    5
    6
    7
    
      class matrix {
        …
      private:
        float* data_;
        size_t nrows_;
        size_t ncols_;
      };
    
  • 构造函数我们可以这样写:
    1
    2
    3
    4
    5
    6
    7
    
      matrix::matrix(size_t nrows,
                     size_t ncols)
      {
        data_  = new float[nrows * ncols];
        nrows_ = nrows;
        ncols_ = ncols;
      }
    
  • 析构非常简单:
    1
    2
    3
    4
    
      matrix::~matrix()
      {
        delete[] data_;
      }
    
  • 乘法函数可以这样写:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    
      class matrix {
        …
        friend matrix
        operator*(const matrix&,
                  const matrix&);
      };
    
      matrix operator*(const matrix& lhs,
                       const matrix& rhs)
      {
        if (lhs.ncols != rhs.nrows) {
          throw std::runtime_error(
            "matrix sizes mismatch");
        }
        matrix result(lhs.nrows, rhs.ncols);
        // 进行矩阵乘法运算
        return result;
      }
    
  • 使用乘法的代码则更是简单:
    • matrix c = a * b;
  • 你可能已经非常疑惑了:错误处理在哪儿呢?只有一个 throw,跟前面的 C 代码能等价吗?
  • 异常处理并不意味着需要写显式的 try 和 catch。异常安全的代码,可以没有任何 try 和 catch。
  • 如果你不确定什么是“异常安全”,我们先来温习一下概念:
    • 异常安全是指当异常发生时,既不会发生资源泄漏,系统也不会处于一个不一致的状态。
  • 我们看看可能会出现错误 / 异常的地方:
    • 首先是内存分配。如果 new 出错,按照 C++ 的规则,一般会得到异常 bad_alloc,对象的构造也就失败了。这种情况下,在 catch 捕捉到这个异常之前,所有的栈上对象会全部被析构,资源全部被自动清理。
    • 如果是矩阵的长宽不合适不能做乘法呢?我们同样会得到一个异常,这样,在使用乘法的地方,对象 c 根本不会被构造出来。
    • 如果在乘法函数里内存分配失败呢?一样,result 对象根本没有构造出来,也就没有 c 对象了。还是一切正常。
    • 如果 a、b 是本地变量,然后乘法失败了呢?析构函数会自动释放其空间,我们同样不会有任何资源泄漏。
  • 总而言之,只要我们适当地组织好代码、利用好 RAII,实现矩阵的代码和使用矩阵的代码都可以更短、更清晰。我们可以统一在外层某个地方处理异常——通常会记日志、或在界面上向用户报告错误了。

1.3 避免异常的风格指南?

  • 一些游戏项目为了追求高性能,也禁用异常。这个实际上也有一定的历史原因,因为今天的主流 C++ 编译器,在异常关闭和开启时应该已经能够产生性能差不多的代码(在异常未抛出时)。
  • 代价是产生的二进制文件大小的增加,因为异常产生的位置决定了需要如何做栈展开,这些数据需要存储在表里。典型情况,使用异常和不使用异常比,二进制文件大小会有约百分之十到二十的上升。LLVM 项目的编码规范里就明确指出这是不使用 RTTI 和异常的原因

1.4 异常的问题

  • 异常当然不是一个完美的特性,否则也不会招来这些批评和禁用了。对它的批评主要有两条:
    • 异常违反了“你不用就不需要付出代价”的 C++ 原则。只要开启了异常,即使不使用异常你编译出的二进制代码通常也会膨胀。
    • 异常比较隐蔽,不容易看出来哪些地方会发生异常和发生什么异常。
  • 对于第一条,开发者没有什么可做的。事实上,这也算是 C++ 实现的一个折中了。目前的主流异常实现中,都倾向于牺牲可执行文件大小、提高主流程(happy path)的性能。只要程序不抛异常,C++ 代码的性能比起完全不做错误检查的代码,都只有几个百分点的性能损失。除了非常有限的一些场景,可执行文件大小通常不会是个问题。
  • 第二条可以算作是一个真正有效的批评。和 Java 不同,C++ 里不会对异常规约进行编译时的检查。从 C++17 开始,C++ 甚至完全禁止了以往的动态异常规约,你不再能在函数声明里写你可能会抛出某某异常。你唯一能声明的,就是某函数不会抛出异常——noexcept、noexcept(true) 或 throw()。这也是 C++ 的运行时唯一会检查的东西了。如果一个函数声明了不会抛出异常、结果却抛出了异常,C++ 运行时会调用 std::terminate 来终止应用程序。不管是程序员的声明,还是编译器的检查,都不会告诉你哪些函数会抛出哪些异常。

  • 当然,不声明异常是有理由的。特别是在泛型编程的代码里,几乎不可能预知会发生些什么异常。我个人对避免异常带来的问题有几点建议:
    • 写异常安全的代码,尤其在模板里。可能的话,提供强异常安全保证,在任何第三方代码发生异常的情况下,不改变对象的内容,也不产生任何资源泄漏。
    • 如果你的代码可能抛出异常的话,在文档里明确声明可能发生的异常类型和发生条件。确保使用你的代码的人,能在不检查你的实现的情况下,了解需要准备处理哪些异常。
    • 对于肯定不会抛出异常的代码,将其标为 noexcept。尤其是,移动构造函数、移动赋值运算符和 swap 函数一般需要保证不抛异常并标为 noexcept(析构函数通常不抛异常且自动默认为 noexcept,不需要标)。

1.5 使用异常的理由

  • 虽然后面我们会描述到一些不使用异常、也不使用错误返回码的错误处理方式,但异常是渗透在 C++ 中的标准错误处理方式。标准库的错误处理方式就是异常。其中不仅包括运行时错误,甚至包括一些逻辑错误
  • 比如,在说容器的时候,有一个我没提的地方是,在能使用 [] 运算符的地方,C++ 的标准容器也提供了 at 成员函数,能够在下标不存在的时候抛出异常,作为一种额外的帮助调试的手段。

  • C++ 的标准容器在大部分情况下提供了强异常保证,即,一旦异常发生,现场会恢复到调用函数之前的状态,容器的内容不会发生改变,也没有任何资源泄漏。前面提到过,vector 会在元素类型没有提供保证不抛异常的移动构造函数的情况下,在移动元素时会使用拷贝构造函数。这是因为一旦某个操作发生了异常,被移动的元素已经被破坏,处于只能析构的状态,异常安全性就不能得到保证了。
  • 只要你使用了标准容器,不管你自己用不用异常,你都得处理标准容器可能引发的异常——至少有 bad_alloc,除非你明确知道你的目标运行环境不会产生这个异常。这对普通配置的 Linux 环境而言,倒确实是对的……这也算是 Google 这么规定的一个底气吧。
  • 虽然对于运行时错误,开发者并没有什么选择余地;但对于代码中的逻辑错误,开发者则是可以选择不同的处理方式的:你可以使用异常,也可以使用 assert,在调试环境中报告错误并中断程序运行。由于测试通常不能覆盖所有的代码和分支,assert 在发布模式下一般被禁用,两者并不是完全的替代关系。在允许异常的情况下,使用异常可以获得在调试和发布模式下都良好、一致的效果。

迭代器和好用的新for循环

1.1 什么是迭代器?

  • 迭代器是一个很通用的概念,并不是一个特定的类型。它实际上是一组对类型的要求
  • 它的最基本要求就是从一个端点出发,下一步、下一步地到达另一个端点。按照一般的中文习惯,也许“遍历”是比“迭代”更好的用词。我们可以遍历一个字符串的字符,遍历一个文件的内容,遍历目录里的所有文件,等等。这些都可以用迭代器来表达

  • 输入迭代器不要求对同一迭代器可以多次使用 * 运算符,也不要求可以保存迭代器来重新遍历对象,换句话说,只要求可以单次访问。如果取消这些限制、允许多次访问的话,那迭代器同时满足了前向迭代器(forward iterator)。
  • 一个前向迭代器的类型,如果同时支持 –(前置及后置),回到前一个对象,那它就是个双向迭代器(bidirectional iterator)。也就是说,可以正向遍历,也可以反向遍历。
  • 一个双向迭代器,如果额外支持在整数类型上的 +、-、+=、-=,跳跃式地移动迭代器;支持 [],数组式的下标访问;支持迭代器的大小比较(之前只要求相等比较);那它就是个随机访问迭代器(random-access iterator)。
  • 一个随机访问迭代器 i 和一个整数 n,在 *i 可解引用且 i + n 是合法迭代器的前提下,如果额外还满足 *(addressdof(*i) + n) 等价于 *(i + n),即保证迭代器指向的对象在内存里是连续存放的,那它(在 C++20 里)就是个连续迭代器(contiguous iterator)。
  • 以上这些迭代器只考虑了读取。如果一个类型像输入迭代器,但 *i 只能作为左值来写而不能读,那它就是个输出迭代器(output iterator)。

  • 而比输入迭代器和输出迭代器更底层的概念,就是迭代器了。基本要求是:
    • 对象可以被拷贝构造、拷贝赋值和析构。
    • 对象支持 * 运算符。
    • 对象支持前置 ++ 运算符。
  • 迭代器通常是对象。但需要注意的是,指针可以满足上面所有的迭代器要求,因而也是迭代器。这应该并不让人惊讶,因为本来迭代器就是根据指针的特性,对其进行抽象的结果。事实上,vector 的迭代器,在很多实现里就直接是使用指针的。

1.2 常用迭代器

  • 最常用的迭代器就是容器的 iterator 类型了
  • 以我们学过的顺序容器为例,它们都定义了嵌套的 iterator 类型和 const_iterator 类型。
  • 一般而言,iterator 可写入,const_iterator 类型不可写入,但这些迭代器都被定义为输入迭代器或其派生类型:
    • vector::iterator 和 array::iterator 可以满足到连续迭代器。
    • deque::iterator 可以满足到随机访问迭代器(记得它的内存只有部分连续)
    • list::iterator 可以满足到双向迭代器(链表不能快速跳转)。
    • forward_list::iterator 可以满足到前向迭代器(单向链表不能反向遍历)。
  • 很常见的一个输出迭代器是 back_inserter 返回的类型 back_inserter_iterator 了;用它我们可以很方便地在容器的尾部进行插入操作
  • 另外一个常见的输出迭代器是 ostream_iterator,方便我们把容器内容“拷贝”到一个输出流

1.3 使用输入行迭代器

  • 在此之前,我先解说一下基于范围的 for 循环这个语法。虽然这可以说是个语法糖,但它对提高代码的可读性真的非常重要。如果不用这个语法糖的话,简洁性上的优势就小多了。我们直接把这个循环改写成等价的普通 for 循环的样子。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
      {
        auto&& r = istream_line_reader(is);
        auto it = r.begin();
        auto end = r.end();
        for (; it != end; ++it) {
          const string& line = *it;
          cout << line << endl;
        }
      }
    
  • 可以看到,它做的事情也不复杂,就是:
    • 获取冒号后边的范围表达式的结果,并隐式产生一个引用,在整个循环期间都有效。注意根据生命期延长规则,表达式结果如果是临时对象的话,这个对象要在循环结束后才被销毁。
    • 自动生成遍历这个范围的迭代器。
    • 循环内自动生成根据冒号左边的声明和 *it 来进行初始化的语句。
    • 下面就是完全正常的循环体。
  • 生成迭代器这一步有可能是——但不一定是——调用 r 的 begin 和 end 成员函数。具体规则是:
    • 对于 C 数组(必须是没有退化为指针的情况),编译器会自动生成指向数组头尾的指针(相当于自动应用可用于数组的 std::begin 和 std::end 函数)。
    • 对于有 begin 和 end 成员的对象,编译器会调用其 begin 和 end 成员函数(我们目前的情况)。
    • 否则,编译器会尝试在 r 对象所在的名空间寻找可以用于 r 的 begin 和 end 函数,并调用 begin(r) 和 end(r);找不到的话则失败报错。

易用性改进一:自动类型推断和初始化

  • 我们主要是介绍 C++ 里好用的特性,而非让你死记规则
  • 因此,这里讲到的内容,有时是一种简化的说法。对于日常使用,本讲介绍的应该能满足大部分的需求。对于复杂用法和边角情况,你可能还是需要查阅参考资料里的明细规则

1.1 自动类型推断

  • 如果要挑选 C++11 带来的最重大改变的话,自动类型推断肯定排名前三

  • auto, 自动类型推断,顾名思义,就是编译器能够根据表达式的类型,自动决定变量的类型(从 C++14 开始,还有函数的返回类型),不再需要程序员手工声明
  • 但需要说明的是,auto 并没有改变 C++ 是静态类型语言这一事实——使用 auto 的变量(或函数返回值)的类型仍然是编译时就确定了,只不过编译器能自动帮你填充而已

  • auto 实际使用的规则类似于函数模板参数的推导规则。当你写了一个含 auto 的表达式时,相当于把 auto 替换为模板参数的结果。举具体的例子:
    • auto a = expr; 意味着用 expr 去匹配一个假想的 template f(T) 函数模板,结果为值类型。
    • const auto& a = expr; 意味着用 expr 去匹配一个假想的 template f(const T&) 函数模板,结果为常左值引用类型。
    • auto&& a = expr; 意味着用 expr 去匹配一个假想的 template f(T&&) 函数模板,根据[第 3 讲] 中我们讨论过的转发引用和引用坍缩规则,结果是一个跟 expr 值类别相同的引用类型。
  • decltype 的用途是获得一个表达式的类型,结果可以跟类型一样使用。它有两个基本用法:
    • decltype(变量名) 可以获得变量的精确类型。
    • decltype(表达式) (表达式不是变量名,但包括 decltype((变量名)) 的情况)可以获得表达式的引用类型;除非表达式的结果是个纯右值(prvalue),此时结果仍然是值类型。
    • 如果我们有 int a;,那么:
      • decltype(a) 会获得 int(因为 a 是 int)。
      • decltype((a)) 会获得 int&(因为 a 是 lvalue)。
      • decltype(a + a) 会获得 int(因为 a + a 是 prvalue)。
  • 通常情况下,能写 auto 来声明变量肯定是件比较轻松的事。但这儿有个限制,你需要在写下 auto 时就决定你写下的是个引用类型还是值类型
  • 根据类型推导规则,auto 是值类型,auto& 是左值引用类型,auto&& 是转发引用(可以是左值引用,也可以是右值引用)。使用 auto 不能通用地根据表达式类型来决定返回值的类型。不过,decltype(expr) 既可以是值类型,也可以是引用类型。因此,我们可以这么写:
    • decltype(expr) a = expr;
  • 这种写法明显不能让人满意,特别是表达式很长的情况(而且,任何代码重复都是潜在的问题)。为此,C++14 引入了 decltype(auto) 语法。对于上面的情况,我们只需要像下面这样写就行了。
    • decltype(auto) a = expr;
  • 这种代码主要用在通用的转发函数模板中:你可能根本不知道你调用的函数是不是会返回一个引用。这时使用这种语法就会方便很多。

1.2 函数返回值类型推断

  • 从 C++14 开始,函数的返回值也可以用 auto 或 decltype(auto) 来声明了。同样的,用 auto 可以得到值类型,用 auto& 或 auto&& 可以得到引用类型;
  • 而用 decltype(auto) 可以根据返回表达式通用地决定返回的是值类型还是引用类型
  • 和这个形式相关的有另外一个语法,后置返回值类型声明。严格来说,这不算“类型推断”,不过我们也放在一起讲吧。它的形式是这个样子:
    1
    2
    3
    4
    
      auto foo(参数) -> 返回值类型声明
      {
        // 函数体
      }
    
  • 通常,在返回类型比较复杂、特别是返回类型跟参数类型有某种推导关系时会使用这种语法

1.3 类模板的模板参数推导

  • 如果你用过 pair 的话,一般都不会使用下面这种形式:pair<int, int> pr{1, 42};
  • 使用 make_pair 显然更容易一些:auto pr = make_pair(1, 42);
  • 这是因为函数模板有模板参数推导,使得调用者不必手工指定参数类型;但 C++17 之前的类模板却没有这个功能,也因而催生了像 make_pair 这样的工具函数。
  • 在进入了 C++17 的世界后,这类函数变得不必要了。现在我们可以直接写:pair pr{1, 42};

  • 在初次见到 array 时,我觉得它的主要缺点就是不能像 C 数组一样自动从初始化列表来推断数组的大小了:
    1
    2
    3
    
      int a1[] = {1, 2, 3};
      array<int, 3> a2{1, 2, 3}; // 啰嗦
      // array<int> a3{1, 2, 3}; 不行
    
  • 这个问题在 C++17 里也是基本不存在的。虽然不能只提供一个模板参数,但你可以两个参数全都不写
    • array a{1, 2, 3}; // 得到 array<int, 3>
  • 这种自动推导机制,可以是编译器根据构造函数来自动生成:
  • 也可以是手工提供一个推导向导,达到自己需要的效果:

1.4 结构化绑定

  • 在讲关联容器的时候我们有过这样一个例子:
    1
    2
    3
    4
    
      multimap<string, int>::iterator
        lower, upper;
      std::tie(lower, upper) =
        mmp.equal_range("four");
    
  • 这个例子里,返回值是个 pair,我们希望用两个变量来接收数值,就不得不声明了两个变量,然后使用 tie 来接收结果。
  • 在 C++11/14 里,这里是没法使用 auto 的。好在 C++17 引入了一个新语法,解决了这个问题。目前,我们可以把上面的代码简化为:auto [lower, upper] = mmp.equal_range("four");
  • 这个语法使得我们可以用 auto 声明变量来分别获取 pair 或 tuple 返回值里各个子项,可以让代码的可读性更好。

1.5 列表初始化

  • 在 C++98 里,标准容器比起 C 风格数组至少有一个明显的劣势:不能在代码里方便地初始化容器的内容。比如,对于数组你可以写:
    • int a[] = {1, 2, 3, 4, 5};
  • 而对于 vector 你却得写:
    1
    2
    3
    4
    5
    6
    
      vector<int> v;
      v.push(1);
      v.push(2);
      v.push(3);
      v.push(4);
      v.push(5);
    
  • 这样真是又啰嗦,性能又差,显然无法让人满意。于是,C++ 标准委员会引入了列表初始化,允许以更简单的方式来初始化对象。现在我们初始化容器也可以和初始化数组一样简单了:
    • vector v{1, 2, 3, 4, 5};
  • 同样重要的是,这不是对标准库容器的特殊魔法,而是一个通用的、可以用于各种类的方法。
  • 从技术角度,编译器的魔法只是对 {1, 2, 3} 这样的表达式自动生成一个初始化列表,在这个例子里其类型是 initializer_list。程序员只需要声明一个接受 initializer_list 的构造函数即可使用。从效率的角度,至少在动态对象的情况下,容器和数组也并无二致,都是通过拷贝(构造)进行初始化

1.6 统一初始化

  • 你可能已经注意到了,我在代码里使用了大括号 {} 来进行对象的初始化。这当然也是 C++11 引入的新语法,能够代替很多小括号 () 在变量初始化时使用。这被称为统一初始化(uniform initialization)。
  • 大括号对于构造一个对象而言,最大的好处是避免了 C++ 里“最令人恼火的语法分析”(the most vexing parse)
  • 你几乎可以在所有初始化对象的地方使用大括号而不是小括号。它还有一个附带的特点:当一个构造函数没有标成 explicit 时,你可以使用大括号不写类名来进行构造,如果调用上下文要求那类对象的话

  • 这个语法主要的限制是,如果一个类既有使用初始化列表的构造函数,又有不使用初始化列表的构造函数,那编译器会千方百计地试图调用使用初始化列表的构造函数,导致各种意外。所以,如果给一个推荐的话,那就是:
    • 如果一个类没有使用初始化列表的构造函数时,初始化该类对象可全部使用统一初始化语法。
    • 如果一个类有使用初始化列表的构造函数时,则只应用在初始化列表构造的情况。

1.7 类数据成员的默认初始化

  • 按照 C++98 的语法,数据成员可以在构造函数里进行初始化。
  • 这本身不是问题,但实践中,如果数据成员比较多、构造函数又有多个的话,逐个去初始化是个累赘,并且很容易在增加数据成员时漏掉在某个构造函数中进行初始化
  • 为此,C++11 增加了一个语法,允许在声明数据成员时直接给予一个初始化表达式。这样,当且仅当构造函数的初始化列表中不包含该数据成员时,这个数据成员就会自动使用初始化表达式进行初始化。
  • 示例:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    
      class Complex {
      public:
        Complex()
          : re_(0) , im_(0) {}
        Complex(float re)
          : re_(re), im_(0) {}
        Complex(float re, float im)
          : re_(re) , im_(im) {}
        …
    
      private:
        float re_;
        float im_;
      };
    
  • 假设由于某种原因,我们不能使用缺省参数来简化构造函数,我们可以用什么方式来优化上面这个代码呢?
  • 使用数据成员的默认初始化的话,我们就可以这么写:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
      class Complex {
      public:
        Complex() {}
        Complex(float re) : re_(re) {}
        Complex(float re, float im)
          : re_(re) , im_(im) {}
    
      private:
        float re_{0};
        float im_{0};
      };
    
  • 第一个构造函数没有任何初始化列表,所以类数据成员的初始化全部由默认初始化完成,re_ 和 im_ 都是 0。
  • 第二个构造函数提供了 re_ 的初始化,im_ 仍由默认初始化完成。
  • 第三个构造函数则完全不使用默认初始化。

易用性改进二:字面量,静态断言和成员函数说明符

1.1 自定义字面量

  • 字面量(literal)是指在源代码中写出的固定常量,它们在 C++98 里只能是原生类型,如:
    • “hello”,字符串字面量,类型是 const char[6]
    • 1,整数字面量,类型是 int
    • 0.0,浮点数字面量,类型是 double
    • 3.14f,浮点数字面量,类型是 float
    • 123456789ul,无符号长整数字面量,类型是 unsigned long
  • C++11 引入了自定义字面量,可以使用 operator”” 后缀 来将用户提供的字面量转换成实际的类型。C++14 则在标准库中加入了不少标准字面量。下面这个程序展示了它们的用法:

1.2 二进制字面量

  • 你一定知道 C++ 里有 0x 前缀,可以让开发人员直接写出像 0xFF 这样的十六进制字面量
  • 另外一个目前使用得稍少的前缀就是 0 后面直接跟 0–7 的数字,表示八进制的字面量,
  • 在跟文件系统打交道的时候还会经常用到:有经验的 Unix 程序员可能会觉得chmod(path, S_IRUSR|S_IWUSR|S_IRGRP|S_IROTH) 并不比 chmod(path,0644) 更为直观
  • 从 C++14 开始,我们对于二进制也有了直接的字面量:
    • unsigned mask = 0b111000000;
  • 这在需要比特级操作等场合还是非常有用的。
  • 不过,遗憾的是, I/O streams 里只有 dec、hex、oct 三个操纵器(manipulator),而没有 bin,因而输出一个二进制数不能像十进制、十六进制、八进制那么直接。一个间接方式是使用 bitset,但调用者需要手工指定二进制位数:
    • #include <bitset> cout << bitset<9>(mask) << endl;

1.3 数字分隔符

  • 数字长了之后,看清位数就变得麻烦了。有了二进制字面量,这个问题变得分外明显。
  • C++14 开始,允许在数字型字面量中任意添加 ' 来使其更可读。
  • 具体怎么添加,完全由程序员根据实际情况进行约定。某些常见的情况可能会是:
    • 十进制数字使用三位的分隔,对应英文习惯的 thousand、million 等单位。
    • 十进制数字使用四位的分隔,对应中文习惯的万、亿等单位。
    • 十六进制数字使用两位或四位的分隔,对应字节或双字节。
    • 二进制数字使用三位的分隔,对应文件系统的权限分组。
  • 一些实际例子如下:
    1
    2
    3
    4
    
      unsigned mask = 0b111'000'000;
      long r_earth_equatorial = 6'378'137;
      double pi = 3.14159'26535'89793;
      const unsigned magic = 0x44'42'47'4E;
    

1.4 静态断言

  • C++98 的 assert 允许在运行时检查一个函数的前置条件是否成立。
  • 没有一种方法允许开发人员在编译的时候检查假设是否成立。比如,如果模板有个参数 alignment,表示对齐,那我们最好在编译时就检查 alignment 是不是二的整数次幂。之前人们用了一些模板技巧来达到这个目的,但输出的信息并不那么友善。

  • C++11 直接从语言层面提供了静态断言机制,不仅能输出更好的信息,而且适用性也更好,可以直接放在类的定义中,而不像之前用的特殊技巧只能放在函数体里
  • 静态断言语法上非常简单,就是:
    • static_assert(编译期条件表达式, 可选输出信息);
  • 产生上面的示例错误信息的代码是:
    • static_assert((alignment & (alignment - 1)) == 0,"Alignment must be power of two");

1.5 default 和 delete 成员函数

  • 在类的定义时,C++ 有一些规则决定是否生成默认的特殊成员函数。这些特殊成员函数可能包括:
    • 默认构造函数
    • 析构函数
    • 拷贝构造函数
    • 拷贝赋值函数
    • 移动构造函数
    • 移动赋值函数
  • 生成这些特殊成员函数(或不生成)的规则比较复杂,每个特殊成员函数有几种不同的状态:
    • 隐式声明还是用户声明
    • 默认提供还是用户提供
    • 正常状态还是删除状态
  • 这三个状态是可组合的,虽然不是所有的组合都有效。隐式声明的必然是默认提供的;默认提供的才可能被删除;用户提供的也必然是用户声明的。

  • 如果成员和父类没有特殊原因导致对象不可拷贝或移动,在用户不声明这些成员函数的情况下,编译器会自动产生这些成员函数,即隐式声明、默认提供、正常状态。有特殊成员、用户声明的话,情况就非常复杂了:
    • 没有初始化的非静态 const 数据成员和引用类型数据成员会导致默认提供的默认构造函数被删除。
    • 非静态的 const 数据成员和引用类型数据成员会导致默认提供的拷贝构造函数、拷贝赋值函数、移动构造函数和移动赋值函数被删除。
    • 用户如果没有自己提供一个拷贝构造函数(必须形如 Obj(Obj&) 或 Obj(constObj&);不是模板),编译器会隐式声明一个
    • 用户如果没有自己提供一个拷贝赋值函数(必须形如 Obj& operator=(Obj&) 或Obj& operator=(const Obj&);不是模板),编译器会隐式声明一个。
    • 用户如果自己声明了一个移动构造函数或移动赋值函数,则默认提供的拷贝构造函数和拷贝赋值函数被删除。
    • 用户如果没有自己声明拷贝构造函数、拷贝赋值函数、移动赋值函数和析构函数,编译器会隐式声明一个移动构造函数
    • 用户如果没有自己声明拷贝构造函数、拷贝赋值函数、移动构造函数和析构函数,编译器会隐式声明一个移动赋值函数。
  • 我不鼓励你去死记硬背这些规则,而是希望你在项目和测试中体会其缘由。我认为这些规则还相当合理,虽然有略偏保守之嫌。尤其是关于移动构造和赋值:只要用户声明了另外的特殊成员函数中的任何一个,编译器就不默认提供了。不过嘛,缺省慢点总比缺省不安全要好……

  • 我们这儿主要要说的是:
    • 我们可以改变缺省行为,在编译器能默认提供特殊成员函数时将其删除,或在编译器不默认提供特殊成员函数时明确声明其需要默认提供
    • (不过,要注意,即使用户要求默认提供,编译器也可能根据其他规则将特殊成员函数标为删除)。

1.6 override 和 final 说明符

  • override 和 final 是两个 C++11 引入的新说明符。
  • 它们不是关键词,仅在出现在函数声明尾部时起作用,不影响我们使用这两个词作变量名等其他用途。这两个说明符可以单个或组合使用,都是加在类成员函数声明的尾部。

  • override 显式声明了成员函数是一个虚函数且覆盖了基类中的该函数。如果有override 声明的函数不是虚函数,或基类中不存在这个虚函数,编译器会报告错误。
  • 这个说明符的主要作用有两个:
    • 给开发人员更明确的提示,这个函数覆写了基类的成员函数;
    • 让编译器进行额外的检查,防止程序员由于拼写错误或代码改动没有让基类和派生类中的成员函数名称完全一致。
  • final 则声明了成员函数是一个虚函数,且该虚函数不可在派生类中被覆盖。如果有一点没有得到满足的话,编译器就会报错。
  • final 还有一个作用是标志某个类或结构不可被派生。同样,这时应将其放在被定义的类或结构名后面。