注:该教程建立在学习过 C 语言的基础上,因此很多提过的细节会忽略,主要学习一些 C 语言没有或不同的特性,建议先学习C 语言基础教程

本文中没有特殊重申的,大多语句和特性都与 C 语言相同,C++是 C 的超集,兼容了 C 的大多数特性


开始


章节概要:编写一个简单的 C++程序;初识输入输出;使用 C++ 版本的 C 标准库头文件;类简介

编写一个简单的 C++程序

  • 简单示例

    // 相比C代码,可以省略main(void)的void
    int main()
    {
        return 0;
    }

初识输入输出

  • 程序示例

    #include <iostream>
    int main()
    {
        std::cout << "Enter two numbers!" << std::endl;
        int v1, v2;
        std::cin >> v1 >> v2;
        std::cout << "The sum of them is " << v1 + v2;
        return 0;
    }
  • C++的 IO 机制

    1、C++包含了一个全面的标准库来提供IO 机制。其中有从 C 语言延续而来cstdio库和新的iostream
    2、iostream库包含两个基础类型istreamostream,分别表示输入流输出流
    3、一个(stream)就是一个字符序列,是从 IO 设备读出写入 IO 设备

  • 标准输入输出对象

    1、标准库定义了 4 个IO 对象
    2、为了处理输入,我们使用名为cinistream类型对象,这个对象也被称为标准输入
    3、对于处理输出,我们使用名为coutostream类型对象,这个对象也被称为标准输出
    4、此外还有其他两个ostream类型对象,名为cerrclog。其中cerr通常用来输出警告和错误信息clog用来输出程序运行时的一般性信息

  • 向流写入写出

    1、如果需要使用iostream库中的对象进行输入输出,则需要用到流插入符将内容传输给
    2、<<输出运算符。其接受两个运算对象左侧必须是一个ostream对象右侧的运算对象是要打印的值。此运算符将给定的值写入给定的ostream对象
    3、>>输入运算符。其与>>类型,它左侧接受一个istream对象右侧接受一个运算对象。它从给定的istream读入数据,并存入给定的对象

  • endl 操纵符

    1、endl是一个被称为操纵符特殊值
    2、endl的效果是结束当前行(有换行效果),并将与设备关联的缓冲区的内容刷到设备中
    3、缓冲刷新操作可以保证到目前为止程序所产生的所有输出真正写入流中,而不是仅停留在内存中等待写入到流中

  • 命名空间

    1、示例程序中使用了std::coutstd::endl,而不是直接的coutendl。其前缀std::指出名字coutendl是定义在名为std命名空间中的
    2、命名空间可以帮助我们避免不经意的名字定义冲突以及使用库中相同名字导致的冲突标准库定义的所有名字都在命名空间std
    3、通过命名空间使用标准库有一个副作用:当使用标准库中的一个名字时,必须通过作用域运算符::显式声明我们想使用来自std中的名字,如示例std::cout那样(后续将给出一个更简单的访问标准库名字的方法)

使用 C++版本的 C 标准库头文件

  • 建议使用 C++版本标准库

    1、C++标准库中兼容了 C 语言的标准库,其按照如下命名规则命名
    2、C 语言的头文件形如name.h,C++将其命名为cname。即去掉.h后缀,文件名前添加字母c
    3、因此stdio.hcstdio内容是一样的,而且stdio.h也能在 C++调用,但从命名规范上来讲,cstdio更符合 C++的要求
    4、特别的,在名为cname头文件定义的名字从属于命名空间std,而原本.h的则不然
    5、一般来说,C++程序应使用名为cname的头文件,因为这样标准库的名字总能在std找到。如果使用.h形式,那么程序员不得不时刻牢记哪些是从 C 语言继承过来的,哪些又是 C++独有的

类简介

  • 我们将在之后详细学习相关的知识,在此只简单介绍

  • 什么是类

    1、在 C++中,我们通过定义一个类定义自己的数据结构
    2、一个定义了一个类型,以及与之相关的一组操作
    3、类机制是 C++最重要的特性之一。实际上,C++最初的设计焦点就是能定义使用上像内置类型一样自然的类类型

  • 如果要使用一个类,我们需要了解三件事情

    1、类名是什么?
    2、它在哪里定义的?
    3、它支持什么操作?


变量和基本类型


章节概要:基本内置类型;指定字面量类型;变量;对象;列表初始化;C++关键字;复合类型;引用;指针;void* 指针;const限定符;const的引用;constexpr和常量表达式;处理类型;类型别名;auto类型说明符;decltype类型指示符;自定义数据结构;定义类;使用类

基本内置类型

  • 基本数据类型

    1、C++定义了一套包括算术类型空类型在内的基本数据类型
    2、其中算术类型包含了整型浮点型字符型布尔型
    3、空类型 void不对应具体的值,仅用于一些特殊的场合

  • C++的基本数据类型与 C 语言规则相同,但 C++中直接支持bool 类型,且iostream直接支持拓展类型(如int32_t等)

  • 指定字面量类型

    • 通过添加前缀或后缀,可以改变不同类型字面量默认类型

    • 指定字符和字符串字面量

      前缀 类型 含义
      u char16_t Unicode 16 字符
      U char32_t Unicode 32 字符
      L wchar_t 宽字符
      u8 char UTF-8(仅用于字符串字面常量)
    • 指定整型字面量

      后缀 最小匹配类型
      u 或 U unsigned
      l 或 L long
      ll 或 LL long long
    • 指定浮点型字面量

      后缀 类型
      f 或 F float
      l 或 L long double

变量

  • 变量

    1、变量提供一个具名的可供程序操作的存储空间
    2、C++中的每个变量都有其数据类型数据类型决定着变量所占内存空间大小及布局方式、该空间能存储值的范围,以及变量能参与的运算
    3、对于 C++程序员来说,变量对象一般可以互换使用

  • 对象

    1、C++程序员在很多场合都会使用对象这个名词。通常情况下,对象指一块能存储数据具有某种类型内存空间
    2、一部分人对对象的定义并不相同,比如:一些人仅在与类有关的场景才使用对象这个词;另一些人把命名了的对象叫做变量;还有一些人把对象区分开来,对象能被程序修改的数据,而只读的数据

  • 列表初始化

    • C++定义了初始化好几种不同形式,如下:

      int units_sold = 0;
      int units_sold(0);
      int units_sold = {0}; // 列表初始化
      int units_sold{0};    // 列表初始化
    • 列表初始化

      1、作为C++11 新标准的一部分,用花括号初始化变量得到了全面应用。这种初始化形式被称为列表初始化
      2、现在,无论是初始化对象还是某些时候为对象赋新值,都可以使用这样一组由花括号括起来的初始值
      3、当用于内置类型变量时,这种初始化形式有一个重要特点:如果我们使用列表初始化初始值存在丢失信息的风险编译器将报错(如下例)
      4、这样的介绍看似无关紧要,因为我们不会故意用 long double 值初始化 int 变量,然而这种初始化可能在不经意间发生,因此这种赋值更加保守安全

      long double ld = 3.1415926536;
      int a{ld}, b = {ld};    // 报错:转换未执行,因为存在丢失信息的风险(丢失浮点精度)
      int c(ld), d = ld;      // 正确:转换执行,且确实丢失了部分值(丢失了小数点后的浮点部分)
  • C++关键字

    关键字 关键字 关键字 关键字 关键字
    alignas continue friend register true
    alignof decltype goto reinterpret_cast try
    asm default if return typedef
    auto delete inline short typeid
    bool do int signed typename
    break double long sizeof union
    case dynamic_cast mutable static unsigned
    catch else namespace static_assert using
    char enum new static_cast virtual
    char16_t explicit noexcept struct void
    char32_t export nullptr switch volatile
    class extern operator template wchar_t
    const false private this while
    constexpr float protected thread_local
    const_cast for public throw

复合类型

  • 复合类型是指基于其他类型定义的类型。C++有几种复合类型,在此主要了解其中两种:引用指针

  • 引用

    • 示例

      int ival = 1024;
      int &refVal = ival;     // 定义引用,refVal 指向 ival
      int &refVal2;           // 报错:引用必须被初始化
      /*------------------------------------------------------------------*/
      refVal = 2;             // 实际赋值给 refVal 指向的 ival
      int &refVal3 = refVal;  // refVal3 指向 refVal 指向的 ival
      /*------------------------------------------------------------------*/
      double dval = 10;
      int &refVal4 = dval;    // 报错:refVal4 为int类型,其指向对象必须为int类型
      int &refVal5 = 10;      // 报错:引用只能绑定在对象上
    • 引用介绍

      1、引用为对象起了另外一个名字,通过将声明符写成&变量名定义引用类型
      2、定义引用时,程序把引用和它的初始值绑定在一起。一旦初始化完成,引用将和它的初始值对象一直绑定在一起。因为无法令引用重新绑定另外一个对象,所以引用必须初始化
      3、引用并非对象,它只是为一个已经存在的对象所起的另外一个名字。定义一个引用之后,对其进行的所有操作都是在与之绑定的对象上进行的
      4、所有引用的类型都要和与之绑定的对象严格匹配,且引用只能绑定在对象上

  • 指针

    • C++的指针整体与 C 语言的指针相同,在此写出一些差异

    • 空指针

      1、空指针不指向任何对象,下为三种生成空指针的方法
      2、第一种方法是使用字面值nullptr来初始化指针。nullptr是一种特殊类型的字面值,它可以被转换成任意其他的指针类型
      3、第二种方法是使用字面值 0来生成空指针(注意不是 int 值)
      4、第三种方法是使用NULL 预处理器变量,这个变量在头文件cstdlib中定义,预处理变量不属于命名空间std。在新标准下,现在的 C++程序最好使用nullptr,同时尽量避免使用NULL

      int *p1 = nullptr;
      int *pt = 0;
      // 使用 NULL 需要调用 cstdlib 头文件
      int *p3 = NULL;
  • void* 指针

    1、void* 是一种特殊的指针类型,可用于存放任意对象的地址。不同的是,我们对该地址指向对象的类型并不了解
    2、利用 void* 指针能做的事有限不能直接操作其所指的对象,因为并不知道对象是什么类型
    3、概括来说,以 void* 视角来看,内存空间也就仅仅是内存空间没办法访问内存空间中的对象

const 限定符

  • 简述

    1、有时我们希望定义一种值不能被改变变量(或直接称为常量),可以用const关键字加以修饰
    2、因为const对象一旦创建值就不能改变,所以const对象必须初始化
    3、const的用法整体与 C 语言中类似

  • const的引用

    1、可以把引用绑定到const对象上,我们称之为对常量的引用(或常量引用)
    2、常量引用仅对引用本身作出限定,对于引用的对象并未限定。如果对象本身不是常量,那么允许通过其他途径修改它的值

    const int i = 1024;
    const int &r1 = i;      // 正确:引用及其对应对象都是常量
    r1 = 42;                // 错误:r1是对常量的引用
    int &r2 = i;            // 错误:r2是对非常量的引用,不能指向常量对象
    /*------------------------------------------------------------*/
    int n = 30;
    const int &r1 = n;
    r1 = 40;                // 错误:r1是对常量的引用
    n = 40;                 // 正确:n本身不是常量
  • constexpr和常量表达式

    • 常量表达式

      1、常量表达式值不会改变在编译过程中就能确定值的表达式
      2、一个对象是不是常量表达式,由它的数据类型初始值共同决定

      const int max_file = 20;          // 是常量表达式
      const int limit = mix_file + 1;   // 是常量表达式
      int staff_size = 27;              // 不是常量表达式:数据类型只是普通 int
      const int sz = get_size();        // 不是常量表达式:不能在编译过程中确定值
    • constexpr变量

      1、在一个复杂系统中,很难(甚至几乎肯定不能)分辨一个初始值是不是常量表达式
      2、C++11规定,允许将变量声明为constexpr类型,由编译器来验证变量的值是否为常量表达式
      3、声明为constexpr的变量一定是一个常量,而且必须用常量表达式初始化
      4、不能使用普通函数作为constexpr初始值,允许定义一种特殊的constexpr函数,后续介绍

      constexpr int mf = 20;            // 编译通过
      constexpr int limit = mf + 1;     // 编译通过
      constexpr int sz = size();        // 编译错误:除非 size() 是 constexpr 函数
    • 字面值类型

      1、常量表达式的值需要在编译时就得到计算,这些类型一般比较简单,值也显而易见,就把它们称为字面值类型
      2、目前为止,算术类型引用指针字面值类型。自定义类IO 库string 类型不是字面值类型,也就不能被定义为constexpr
      3、尽管指针引用都能被定义成constexpr,但它们的初始值受到严格限制。如constexpr指针的初始值只能是nullptr0,或存储于某个固定地址中的对象

处理类型

  • 类型别名

    1、有两种方法可用于定义类型别名,第一种是使用typedef关键字,与 C 语言用法相同
    2、新标准规定了一种新方法:使用别名声明。这种方法使用using关键字作为别名声明的开始,其后紧跟别名和等号,作用是把等号左侧的名字规定成等号右侧的类型的别名

    typedef double wages;      // wages 是 double 的别名
    typedef wages base, *p;    // base 是 wages(即double)的别名,p 是 double* 的别名
    using SI = Sales_item;     // SI 是 Sales_item 的别名
  • auto类型说明符

    1、编程时常常需要把表达式的值赋给变量,这就要求在声明变量时清楚地知道表达式的类型,然而做到这一点并不容易,甚至有时根本做不到
    2、C++11引入了auto类型说明符,能让编译器替我们去分析表达式的类型
    3、和原来只对应一种特定类型的说明符(如 double)不同,auto能让编译器通过初始值推算变量的类型。显然,auto定义的变量必须有初始值
    4、使用auto也能在一条语句声明多个变量,但由于一条声明语句只能有一个基本数据类型,所以该语句中所有变量初始基本数据类型必须都一样

    auto item = val1 + val2;    // auto 自动判断表达式类型
    auto i = 0, *p = &i;        // 正确:i 是整数,p 是整型指针
    auto sz = 0, pi = 3.14;     // 错误:sz 和 pi 类型不一致
  • decltype类型指示符

    1、有时我们希望从表达式的类型推断出要定义的变量的类型,但是不想用该表达式的值初始化变量。为此,C++11引入了decltype说明符
    2、delctype可以返回操作数的数据类型,在此过程中,编译器分析表达式并得到它的类型,但并不实际计算表达式的值
    3、注意delctype表达式的结果如果是加上括号的变量,则结果将是引用。即decltype(())(注意是双层括号)的结果永远是引用

    int ci = 1;
    decltype(ci) x = 0;         // x 的类型就是 ci 的类型,即 int,值为 0
    decltype(def()) sum = x;    // sum的类型就是函数 def() 的返回类型,值为 x 的值
    /*--------------------------------------------------------------------*/
    int i = 1;
    decltype((i)) d;            // 错误:d 是 int& 引用类型,必须初始化
    decltype(i) e;              // 正确:e 是一个未初始化的 int 类型

自定义数据结构

  • 什么是数据结构

    1、从最基本的层面理解,数据结构是把一组相关的数据元素组织起来然后使用它们的策略和方法
    2、举一个例子,我们想要创建一个Sales_items把书本的ISBN 编号售出量销售收入等数据组织在一起,并提供诸如isbn()等函数<<>>++=等运算在内的一系列操作Sales_item就是一个数据结构
    3、C++允许用户以类的形式自定义数据类型,而库类型stringistreamostream等也是以类的形式定义的

  • 定义类

    • 简单的不含运算功能的类的定义实际就是C 的结构体定义,使用struct定义

    • 此外,C++提供另外一个关键字class定义自己的数据结构,后续介绍

  • 使用类:简单的的使用与C 的结构体使用规则相似,但定义类对象(结构变量)时不需要再使用struct再次声明


字符串、向量和数组


章节概要:命名空间的using声明;标准库类型string;定义和初始化string对象;string对象的操作;处理string对象中的字符;范围 for 语句;标准库类型vector;模板;定义和初始化vector对象;向vector对象添加元素;其他vector操作;vector的索引;迭代器介绍;使用迭代器;泛型编程;迭代器运算;数组;auto与数组;指针也是迭代器;与旧代码的接口

命名空间的 using 声明

  • 引入

    1、目前为止,我们用到的库函数基本上都属于命名空间std,而程序也显式地将这一点标示了出来,如std::cin
    2、用这种方法显得比较繁琐,然而幸运的是,通过更简单的途径也能使用到命名空间的成员
    3、本节将介绍其中一种最安全的方法,也就是使用using声明,后续会介绍另一种方法

  • using声明

    1、有了using声明就无须专门的前缀,也能使用所需的名字了
    2、using声明具有如下形式using 命名空间名::名字,如后示例
    3、按照规定,每个using声明只能引入命名空间中的一个成员,因此每个名字都需要独立声明
    4、位于头文件的代码不应该使用using声明,因为引用该头文件的源代码也会引入这个声明,对于某些程序,可能会产生名字冲突

    #include <iostream>
    using std::cin;
    int main()
    {
        int i;
        cin >> i;
        std::cout << i;
        return 0;
    }

标准库类型 string

  • 介绍

    1、标准库类型string表示可变长的字符序列(字符串),使用string类型必须先包含string头文件
    2、作为标准库的一部分string定义在命名空间std
    3、接下来的示例都假定已包含了下述代码

    #include <string>
    using std::string
  • 定义和初始化 string 对象

    1、如何初始化类的对象是由类本身决定的,一个类可以定义很多种初始化对象的方式,但这些方式之间必须有所区别:或者是初始值数量不同,或者是初始值类型不同
    2、下为初始化string对象方式和示例

    方式 说明
    string s1 默认初始化,s1 是一个空串
    string s2(s1) 直接初始化,s2 是 s1 的副本
    string s2 = s1 拷贝初始化,s2 是 s1 的副本
    string s3(“value”) 直接初始化,s3 是字符串字面值”value”的副本,空字符除外
    string s3 = “value” 拷贝初始化,s3 是字符串字面值”value”的副本,空字符除外
    string s4(n, ‘c’) 直接初始化,把 s4 初始化为连续 n 个字符 c 组成的字符串
    string s1;                    // 默认初始化,s1是空字符串
    string s2 = s1;               // s2是s1的副本
    string s3("value");           // s3是字面值"value"的副本
    string s3 = "value";          // s3是字面值"value"的副本
    string s4(10, 'c');           // 直接初始化,s4内容是 cccccccccc
    string s4 = string(10, 'c');  // 拷贝初始化,s4内容是 cccccccccc
  • string 对象的操作

    • 总览

      1、一个除了要规定初始化其对象的方式外,还要定义对象上能执行的操作。其中,类既能定义通过函数名调用的操作,也能定义各种运算符该类对象上的新含义
      2、下表列举了大部分string类的操作

      操作 含义
      os<<s 将 s 写入到输出流 os 中,返回 os
      is>>s 从 is 中读取字符串赋给 s,字符串以空格分隔,返回 is
      getline(is, s) 从 is 中读取一行赋给 s,返回 is
      s.empty() s 为空返回 true,否则返回 false
      s.size() 返回 s 中字符个数
      s[n] 返回 s 中第 n 个字符的引用,位置 n 从 0 计起
      s1+s2 返回 s1 和 s2 连接后的结果
      s1=s2 用 s2 的副本代替 s1 中原来的字符
      s1==s2 如果 s1 和 s2 中的字符完全一样,则它们相等,对字母的判断依据是 ASCII 码
      s1!=s2 如果 s1 和 s2 中有一个字符不一样,则它们不相等,对字母的判断依据是 ASCII 码
      <,<=,>,>= 对字符串从前向后依次比较字符的 ASCII 码,成立与否的依据是首个不相等字符的 ASCII 大小关系
    • 读写操作

      // 基本读写
      int main()
      {
          string s;
          cin >> s;   // 遇到空格停止
          cout << s;
          return 0;
      }
      // getline整行读取
      int main()
      {
          string line;
          getline(cin, line);   // 遇到换行符停止
          cout << line << endl;
          return 0;
      }
      // C语言风格EOF读写
      int main()
      {
          string word;
          // while(getline(cin, line))
          while(cin >> word)
              cout << word << endl;
          return 0;
      }
    • emptysize操作

      1、empty函数根据string对象是否为空返回一个布尔值size函数返回string对象的长度
      2、这两个函数都是string成员函数调用该类函数的方法是,使用点操作符指明是哪个对象执行成员函数即可(对象.成员函数())

      int main()
      {
          string line;
          while(getline(cin, line))
              // 如果字符串不为空 且 字符串长度大于10
              if(!line.empty() && line.size() > 10)
                  cout << line << endl;
          return 0;
      }
    • string::size_type类型

      1、实际上,size函数的返回值是一个string::size_type类型的值
      2、尽管我们不太清楚string::size_type类型的具体细节,但有一点是肯定的,它是一个无符号类型值,且足够存放任何string对象的大小
      3、所有用于存放stringsize函数返回值变量,都应该是该类型(auto len = line.size();line的类型也自动设为该类型)
      4、由于该类型无符号整型,因此切记,如果在表达式中混用无符号数和有符号数会产生意想不到的错误。如变量 n是一个具有负值的 int,则表达式s.size() < n几乎肯定是true,因为负值 n自动转换成一个较大的无符号值

    • 字面值和string对象相加

      1、即使一种类型并非所需,我们也可以使用它,不过前提是这种类型能自动转换成所需类型
      2、因为标准库允许把字符字面值字符串字面值转换成string对象,所以在需要string对象的地方就可以用这两种字面值代替
      3、注意:当把string对象这两种字面值混在一条语句中使用时,必须确保每个加法运算符的两侧的运算对象至少有一个string

      string s1 = "hello", s2 = "world";
      string s3 = s1 + "," + s2 + '\n';
      /*------------------------------*/
      // 注意按顺序结合后结果的类型
      string s4 = s1 + ",";               // 正确
      string s5 = "hello" + ",";          // 错误
      string s6 = s1 + "," + "world";     // 正确
      string s7 = "hello" + "," + s2;     // 错误
  • 处理 string 对象中的字符

    • 引入

      1、我们经常需要单独处理string对象中的字符,这类处理的关键问题是如何获取字符本身,另一个关键问题是要知道能改变某个字符的特性
      2、对于后者,我们可以使用C 语言继承而来cctype(即ctype.h),对于后者,我们可以通过范围 for 语句实现

    • 范围 for 语句

      1、如果想对string对象中的每个字符做点什么,目前最好的操作是使用C++11 标准提供的范围 for 语句(语法形式如后附)
      2、其中变量将被用于访问序列中的基础元素。每次迭代变量都会被初始化对象序列下一个元素值

      for (变量 : 对象序列)
      {
          循环体;
      }
    • 示例:统计标点数并输出标点

      #include <cctype>
      #include <iostream>
      #include <string>
      
      using std::cin;
      using std::cout;
      using std::endl;
      using std::getline;
      using std::string;
      
      int main()
      {
          int ct = 0;
          string s;
          getline(cin, s);
          for (auto c : s)
          {
              if (ispunct(c))
              {
                  ct++;
                  cout << c << ' ';
              }
          }
          cout << endl << "ct: " << ct << endl;
          return 0;
      }
    • 使用范围 for 改变字符

      1、如果想要改变string对象字符的值,只需要把循环变量定义成引用类型即可
      2、记住,所谓引用只是给定对象的一个别名,因此当引用作为循环控制变量时,这个变量实际被依次绑定到序列的每个元素
      3、下示例将整个字符串中的小写字母转换成大写字母

      #include <cctype>
      #include <iostream>
      #include <string>
      
      using std::cin;
      using std::cout;
      using std::endl;
      using std::getline;
      using std::string;
      
      int main()
      {
          string s;
          getline(cin, s);
          for (auto &c : s)
          {
              c = toupper(c);
              cout << c << ' ';
          }
          cout << endl << s << endl;
      }
    • 只处理一部分字符

      1、如果只想处理一部分字符,那么范围 for 语句不能很好的胜任了。通常访问string对象中的单个字符有两种方式:一种是使用下标,另一种是使用迭代器(后续介绍)
      2、string对象的下标使用与C 语言字符数组规则类似,使用for 循环遍历处理。对象的下标必须大于等于 0小于s.size()

标准库类型 vector

  • 介绍

    1、标准库类型vector表示对象的集合,其中所有对象的类型都相同
    2、集合中的每个对象都有一个与之对应的索引,索引用于访问对象
    3、因为vector容纳着其他对象,所以它也常常被称作容器
    4、要想使用vector,必须包含适当的头文件。后续的例子中,都假设包含以下声明

    #include <vector>
    using std::vector;
  • 模板

    • 模板简介

      1、C++既有类模板,也有函数模板,其中vector是一个类模板。只要对 C++有深入了解后才能写出模板,我们将在 16 章介绍。但即使不会创建模板,我们也可以试着使用模板
      2、模板本身不是类或函数,相反可以将模板看做编译器生成类或函数编写的一份说明。编译器根据模板创建类或函数的过程称为实例化,当使用模板时,需要指出编译器应把类或函数实例化成何种类型
      3、对于类模板来说,我们通过提供一些额外信息来指定模板应实例化成什么样的类,需要提供哪些信息由模板决定。提供信息的方式总是这样:在模板名字后面跟一对尖括号,在括号内放上信息,模板名<信息>;

    • vector 模板

      1、例如vector模板,需要提供的信息vector内存放的对象类型(示例如后附代码)
      2、vector模板而非类型,由vector生成的类型必须包含vector所存放元素的类型,例如vector<int>
      3、vector能容纳绝大多数类型对象作为其元素,但是由于引用不是对象,所以不存在包含引用的vector
      4、早期版本 C++中,如果vector的元素还是vector,必须在外层右尖括号其元素类型之间添加一个空格。比如应该写成vector<vector<int> >

      vector<int> ivec;               // ivec保存int类型的对象
      vector<Sales_item> Sales_vec;   // 保存Sales_item类型的对象
      vector<vector<string>> file;    // 该向量的元素是vector对象
  • 定义和初始化 vector 对象

    • 下表列出了初始化vector对象方法

      方法 说明
      vector<T> v1 v1 是一个空 vector,潜在元素为 T 类型,执行默认初始化
      vector<T> v2(v1) v2 中包含有 v1 所有元素的副本
      vector<T> v2 = v1 同上等价
      vector<T> v3(n,val) v3 包含 n 个重复的元素,每个元素值都是 val
      vector<T> v4(n) v4 包含 n 个重复执行了值初始化的对象
      vector<T> v5{a,b,c,…} v5 包含了初始值个数的元素,每个元素被赋予了相应的初始值
      vector<T> v5 = {a,b,c,…} 同上等价
    • 区分()值初始化{}列表初始化的含义

      1、vector<int> ivec(3,-1)含义是ivec存放了3 个值为 -1 的元素;而vector<int> ivec{3,-1}含义是ivec存放了两个元素分别为3 和 -1
      2、vector<int> ivec(10)是指ivec创建了10 个执行了值初始化的元素(如 int 类型会被初始化为 0)
      3、简单地说,想要初始化多个相同值就使用()值初始化,想要初始化多个不同值就使用{}列表初始化

  • 向 vector 对象添加元素

    1、经常我们会遇到,创建一个vector时并不清楚实际所需元素个数,元素的无法确定;还有些时候元素初值已知,但这些值总量较大各不相同
    2、例如我们需要vector对象存储 1-100 的数字,对此更好的办法是先创建一个vector,然后在运行时利用vector成员函数push_back添加数据
    3、push_back负责把一个值当成vector对象尾元素,添加到对象的尾端,示例如下
    4、注意:如果循环体内部含有vector添加元素的语句,则不能使用范围 for 语句

    vector<int> ivec;
    for (int i = 1; i <= 100; i++)
        ivec.push_back(i);
  • 其他 vector 操作

    • vector提供了一些其他操作,下表列出其中比较重要的一些

      操作 含义
      v.empty() 如果 v 不含有任何元素,返回 true,否则返回 false
      v.size() 返回 v 中元素的个数,返回值类型是 vector\<T>::size_type 类型
      v.push_back(t) 将值为 t 的元素添加到 v 的尾端
      v[n] 返回 v 中第 n 个位置上元素的引用
      v1 = v2 用 v2 中的元素拷贝替换 v1 中的元素
      v1 = {a,b,c,…} 用列表中的元素拷贝替换 v1 中的元素
      ==,!=,<,<=,>,>= 与 string 相同,比较字典序
  • vector 的索引

    1、和string类似,如果需要逐个访问全部vector元素,也可以使用范围 for 语句+引用
    2、对于需要非顺序访问,可以使用下标访问,下标同样从 0 开始计算
    3、注意:只能对确知已存在的元素使用下标操作,因此不能使用下标添加元素,添加元素只能使用push.back

迭代器介绍

  • 介绍

    1、我们已经知道可以使用下标访问string对象的字符vector对象的元素,还有另一种更通用的机制也能实现,就是迭代器
    2、除了vector外,标准库还定义了其他几种容器所有标准库容器都能使用迭代器,但只有少数几种才同时支持下标操作
    3、类似于指针类型,迭代器也提供对对象间接访问,同时迭代器也有有效无效之分。有效的迭代器指向某个元素或指向容器尾元素的下一位置,其余都属于无效迭代器

  • 使用迭代器

    • 引入

      1、和指针不同的是,获取迭代器不是使用取地址符有迭代器的类型同时拥有返回迭代器的成员
      2、比如这些类型都拥有名为beginend成员,其中begin 成员负责返回指向第一个元素的迭代器end 成员负责返回指向容器尾元素下一位置的迭代器
      3、end 成员返回的迭代器常被称作尾后迭代器,或简称为尾迭代器。这样的迭代器没什么实际含义,只是个标记而已,表示我们已经处理完了容器中所有元素。特殊情况下,如果容器为空,则beginend返回的是同一个迭代器
      4、一般来说,我们不清楚(也不需要在意)迭代器的准确类型是什么,通常使用auto关键字定义变量来自动确定类型

      // b 表示 v 的第一个元素,e 表示 v 尾元素的下一位置
      auto b = v.begin(), e = v.end();
    • 迭代器运算符

      1、下表列举了部分迭代器支持的运算
      2、和指针类似,也能通过解引用迭代器来获取它所指示的元素,执行解引用的迭代器必须合法确实指示着某个元素
      3、举个例子,使用迭代器访问string对象,将字符串的首字母大写(如下例)

      运算 含义
      *iter 返回迭代器 iter 所指元素的引用
      iter->mem 解引用 iter 并获取该元素的名为 mem 的成员,等价于(*iter).mem
      ++iter 或 iter++ 令 iter 指示容器中的下一个元素
      –iter 或 iter– 令 iter 指示容器中的上一个元素
      iter1 == iter2 判断两个迭代器是否相等,如果两个迭代器指示同一个元素或它们是同一个容器的尾迭代器,则相等,否则不相等
      iter1 != iter2 判断两个迭代器是否相等,如果两个迭代器指示同一个元素或它们是同一个容器的尾迭代器,则相等,否则不相等
      string s("some thing");
      if (s.begin() != s.end())   // 确保 s 为非空字符串
      {
          auto it = s.begin();    // it 表示 s 的第一个字符
          *it = toupper(*it);     // 改为大写
      }
    • 将迭代器移动到另外一个元素

      1、迭代器使用递增运算符++来移动到下一个元素,使用递减运算符--来移动到上一个元素
      2、因为end 返回的迭代器并不实际指示某个元素,所以不能对其进行递增解引用
      3、下例为使用迭代器依次访问string对象字符,将整个字符串的字母大写

      string s("hello world");
      for (auto it = s.begin(); it != s.end(); it++)
          *it = toupper(*it);
    • 迭代器类型

      1、就像不知道stringvectorsize_type成员到底是什么类型一样,一般来说我们也不知道(也无需知道)迭代器的精确类型
      2、实际上,那些拥有迭代器标准库类型使用iteratorconst_iterator来表示迭代器的类型,其中const_iterator常量指针差不多

      vector<int>::iterator it1;          // it1 能读写 vector<int> 的元素
      string::iterator it2;               // it2 能读写 string 对象中的字符
      
      vector<int>::const_iterator it3;    // it3 只能读元素,不能写元素
      string::const_iterator it4;         // it4 只能读字符,不能写字符
    • cbegincend函数

      1、beginend返回的具体类型对象是否是常量决定。如果对象是常量则返回const_iterator,如果不是常量在返回iterator
      2、有时候这种默认的行为并非我们所要,如果对象只需读无须写最好使用常量类型
      3、为了便于我们得到const_iterator类型的返回值,C++11 引入了两个新函数cbegincend,其返回值一定是const_iterator类型

    • 组合解引用和成员访问操作

      1、解引用迭代器可获得迭代器所指的对象,如果该对象的类型恰好是,就有可能希望进一步访问它的成员
      2、例如,对于一个由字符串组成的vector对象,要想检查其元素是否为空,令it是该vector迭代器,只要检查it 所指字符串是否为空即可:(*it).empty()(注意前面的括号不可省略,因为关系到运算符优先级问题)
      3、为了简化上述表达式,C++定义了箭头运算符->。箭头运算符把解引用成员访问两个操作结合在一起,也就是说,it->mem等效于(*it).mem

    • 某些对vector对象的操作会使迭代器失效

      1、虽然vector对象可以动态地增长,但是也会有一些副作用
      2、已知的一个限制是不能在范围 for 语句内向vector添加元素。另外一个限制是任何一种可能改变vector对象容量的操作,比如push_back,都会使其迭代器失效(后续解释)
      3、谨记,但凡是使用了迭代器循环体都不要向迭代器所属容器添加元素

  • 泛型编程

    1、原来使用 C 或 Java 的程序员转而使用 C++ 后,会对for 循环大量使用!=而不是<进行判断感到奇怪
    2、C++程序员习惯性使用!=,其原因和他们更愿意用迭代器而非下标的原因一样:这种编程风格在标准库提供的所有容器上都有效
    3、与之类似,所有标准库容器的迭代器都定义了!===,但它们中的大多数没有定义<运算符
    4、因此,只要我们养成使用迭代器!=的习惯,就不用太在意用的到底是哪种类型

  • 迭代器运算

    1、stringvector迭代器提供了更多额外的运算符,一方面可使得迭代器每次移动过多个元素,另外也支持迭代器进行关系运算
    2、所有这些运算都被称为迭代器运算,如下表

    运算 含义
    iter + n 迭代器加上一个整数值结果仍为迭代器,迭代器指示的新位置与原来相比向前移动了若干个元素。结果迭代器或指示容器内的一个元素,或指示 end 位置
    iter - n 迭代器减去一个整数值结果仍为迭代器,迭代器指示的新位置与原来相比向后移动了若干个元素。结果迭代器或指示容器内的一个元素,或指示 end 位置
    iter += n 迭代器加法的复合赋值语句,将 iter 加 n 的结果赋给 iter
    iter -= n 迭代器减法的复合赋值语句,将 iter 减 n 的结果赋给 iter
    iter1 - iter2 两个迭代器相减的结果是它们之间的距离,参与运算的两个迭代器必须指向同一个容器中的元素或 end 位置
    >,>=,<,<= 迭代器关系运算符,如果某迭代器指向的容器位置在另一个迭代器所指位置之前,则前者小于后者,参与运算的两个迭代器必须指向同一个容器中的元素或 end 位置,类型是difference_type的带符号整型
  • 使用迭代器运算

    • 使用迭代器运算的一个经典算法二分搜索,其从有序序列中寻找某个给定值

      // text 必须是有序的,sought 是要搜索的给定值
      auto beg = text.begin(), end = text.end();    // beg 和 end 表示搜索的范围
      auto mid = text.begin() + (end - bag) / 2;    // 初始中间点
      while (mid != end && *mid != sought)
      {
          if (sought < *mid)                        // 如果在前半部分
              end = mid;                            // 忽略后半部分
          else                                      // 如果在后半部分
              beg = mid + 1;                        // 在mid之后寻找
          mid = beg + (end - beg) / 2;              // 新的中间点
      }

数组

  • 数组的大部分使用规则与 C 语言相同

  • auto 与数组

    1、由于数组名是数组的首元素地址,所以使用auto关键字时,auto ia2(ia)等同于auto ia2(&ia[0])
    2、但如果使用decltype关键字,上述转换不会发生decltype(ia)返回类型是由10 个整数构成的数组

    int ia[] = {0,1,2,3,4,5,6,7,8,9};
    // ia2是一个整型指针,指向ia的首元素
    auto ia2(ia);
    // ia3是一个含有10个整数的数组
    decltype(ia) ia3 = {0,1,2,3,4,5,6,7,8,9}
  • 指针也是迭代器

    1、就像使用迭代器遍历vector对象那样,使用指针也能遍历数组中的元素。不过前提是先获取到指向数组首元素的指针指向数组尾元素下一位的指针
    2、获取首元素地址可以使用数组名,而尾后指针可以获取尾元素后那个并不存在的元素的地址。如假设定义int arr[10];,则可以int *end = &arr[10]
    3、尽管通过计算可以得到尾后指针,但这种方法极易出错。为了让指针的使用更简单,C++11 新增了两个函数begin函数end函数
    4、这两个函数容器中两个同名成员功能类似,不过数组不是类类型,所以这两个函数不是成员函数。正确使用的方法是将数组作为它们的参数,如下

    int ia[] = {0,1,2,3,4,5,6,7,8,9};
    int *begin = begin(ia);
    int *end = end(ia);
  • 与旧代码的接口

    • 引入

      1、很多 C++程序在标准库出现之前就已经写成了,它们肯定没用到stringvector类型;而且有一些 C++程序实际上是与 C 语言或其他语言接口程序,当然也无法使用C++标准库
      2、但是,现代的 C++ 程序不得不与那些充满了数组C 风格字符串的代码衔接。为了使这一工作简单易行,C++专门提供了一组功能

    • 混用string对象和 C 风格字符串

      1、允许使用以\0结束的字符数组(即 C 风格字符串)来对string对象进行初始化赋值
      2、在string对象加法运算中允许使用C 风格字符串作为其中一个运算对象(不能两个都是);在string对象复合赋值运算中允许使用C 风格字符串作为右侧运算对象
      3、上述性质反过来不成立了:如果程序某处需要一个C 风格字符串,不能用string对象代替它,例如不能用string对象初始化一个指向 char 的指针
      4、为了完成该功能,string专门提供了一个名为c_str成员函数:其返回一个指针,指向一个C 风格字符串,该字符串内容string对象一样;指针的类型是const *char,确保不会改变字符数组的内容

      char *str = s;                // 错误,不能用string对象初始化指向char的指针
      const char *str = s.c_str();  // 正确
    • 用数组初始化vector对象

      • 允许使用数组初始化vector对象。要实现这一目的,只需指明要拷贝区域首元素地址尾后地址即可(最简单的方式是用beginend函数)

        int int_arr[] = {0,1,2,3,4,5};
        vector<int> ivec(begin(int_arr), end(int_arr));

表达式


章节概要:特性补充;sizeof运算符;强制类型转换

特性补充

  • C++的表达式运算符与 C 大部分相同,在此补充强调一些特性

  • sizeof 运算符

    1、对stringvector对象执行sizeof运算符只返回该类型固定部分大小不会计算对象中的元素占用了多少空间
    2、因为sizeof返回值是一个常量表达式constexpr size_t,因此可以用sizeof的结果声明数组的大小

  • 强制类型转换

    • 一个命名的强制类型转换具有如下形式转换模式<转换类型>(表达式)

    • 其中,转换模式static_castdynamic_castconst_castreinterpret_cast中的一种

    • static_cast模式

      1、任何具有明确定义类型转换,只要不包含底层const,都可以使用static_cast,例如:double slope = static_cast<double>(i);
      2、static_cast对于编译器无法自动执行类型转换也非常有用,例如可以找回存在于void*的指针,转换回原始的指针类型,示例如后
      3、但是对于存在于void*的指针,应确保指针的值保持不变,且转换后所得类型就是指针所指类型

      // 任何非常量对象的地址都能存入void*
      void* p = &d;
      //将void*转换回原始的指针类型(指向double的指针)
      double *dp = static_cast<double*>(p);
    • const_cast模式

      1、const_cast只能改变运算对象的底层const,对于将常量对象转换成非常量对象的行为,称其为去掉const性质
      2、一旦去掉了某个对象的const性质,编译器将不再阻止我们对该对象进行写入操作
      3、如果对象本身不是一个常量,那么强制类型转换获得写权限合法的行为;但如果对象本身是一个常量,则再使用const_cast执行写操作将产生未定义的后果

      const char *pc;
      char *p = const_cast<char*>(pc);    // 正确:但是写操作是未定义的行为
      /*-----------------------------*/
      const char *cp;
      char *q = static_cast<char*>(cp);   // 错误:static_cast不能转换掉const性质
      const_cast<string>(cp);             // 错误,const_cast只能改变常量属性
      static_cast<string>(cp);            // 正确
    • reinterpret_cast模式

      1、reinterpret_cast通常为运算对象位模式提供较低层次上的重新解释
      2、如下例,我们必须牢记pc所指的真实对象是 int 而非字符,如果把pc当成普通的字符指针使用就可以在运行时发生错误
      3、使用reinterpret_cast非常危险的,其中关键问题是类型改变了,但编译器没有给出任何警告或错误提示。下面使用 pc 时就会认定它的值是char*类型编译器没法知道它实际存放的是指向 int 的指针
      4、reinterpret_cast本质上依赖于机器。想要安全地使用,必须对涉及的类型编译器实现转换的过程都相当了解

      int *ip;
      char *pc = reinterpret_cast<char*>(ip);
      string str(pc);     // 错误:可能导致异常的运行时行为
    • dynamic_cast模式:支持运行时类型识别,后续介绍

    • 此外,C 语言的旧式强制类型转换仍然支持,但与 C++ 的相比,表现形式上不那么清晰明了,出错追踪比较困难,建议使用 C++ 的转换方式


语句


章节概要try语句块和异常处理;C++的异常处理;throw表达式;try语句块;编写处理代码;标准异常

try 语句块和异常处理

  • 引入

    1、异常是指存在于运行时反常行为,这些行为超出了函数正常功能的范畴,典型的异常包括失去数据库连接以及遇到意外输入等。处理反常行为可能是设计所有系统最难的一部分
    2、当程序的某部分检测到一个让它无法处理的问题时,就需要用到异常处理。如果程序中含有可能引发异常的代码,那么通常也会有专门的代码处理问题
    3、异常处理机制为程序中异常检测异常处理这两部分的协作提供支持。C++中,异常处理包括以下部分

  • C++的异常处理

    1、throw表达式异常检测部分使用throw表达式来表示它遇到了无法处理的问题,我们说throw引发了异常
    2、try语句块异常处理部分使用try语句块处理异常。try中包含一个或多个catch子句,抛出的异常通常会被某个catch子句处理。因为catch子句处理异常,所以它们也被称为异常处理代码
    3、一套类异常:用于在throw表达式和相关的catch子句之间传递异常的具体信息

  • throw 表达式

    1、程序的异常检测部分使用throw表达式引发一个异常,形式如throw 表达式;,其中的表达式类型就是抛出的异常类型
    2、如下示例,如果书籍的 ISBN 不一样就抛出异常,该异常类型是runtime_error对象。抛出异常将终止当前的函数,并把控制权转移给能处理该异常的代码
    3、类型runtime_error标准库异常类型的一种,定义在stdexcept头文件中,后面将介绍其他的异常类型
    4、我们必须初始化runtime_error对象,方式是给它提供一个string对象或一个C 风格字符串,这个字符串中有一些关于异常的辅助信息

    if(item1.isbn() != item2.isbn())
        throw runtime_error("数据必须具有相同的ISBN");
    cout << item1 + item2 << endl;
  • try 语句块

    1、try语句块的通用语法形式如下
    2、跟在try之后的是一个或多个catch子句catch子句包括三部分:关键字catch、括号内一个(可能未命名的)对象的声明(称作异常声明)、以及一个块
    3、当选中了某个catch子句异常处理之后,执行与之对应的块catch一旦执行完成,会跳转到try最后一个catch子句之后的语句继续执行
    4、try中的程序语句组成程序的正常逻辑,像其他任何块一样,可以有包括声明在内的任意 C++ 语句

    try
    {
        程序语句
    }
    catch(异常声明)
    {
        处理语句
    }
    catch(异常声明)
    {
        处理语句
    }
    // ...
  • 编写处理代码

    1、编写异常处理代码,上面的程序可以按如下示例处理
    2、将程序本来要执行的任务放在try语句块中,因为这段代码可能抛出异常
    3、try对应一个catch子句,该子句负责处理runtime_error类型的异常。如果try中抛出了此类异常,则会执行该catch内的语句
    4、catch中输出给用户的信息中,输出了err.what()的返回值。从catch小括号的定义能得到err是一个runtime_error类的对象what则是该的一个成员函数
    5、what成员函数没有参数返回值C 风格字符串(即const char*)。其中,runtime_errorwhat成员返回的是初始化一个具体对象时所用的string对象的副本(即try中初始化的字符串)

    while (cin >> item1 >> item2)
    {
        try
        {
            if(item1.isbn() != item2.isbn())
                throw runtime_error("数据必须具有相同的ISBN");
            cout << item1 + item2;
        }
        catch(runtime_error err)
        {
            cout << err.what() << "\n 再次尝试?输入y或n"<< endl;
            char c;
            cin >> c;
            if(!cin || c == 'n')
            {
                  cout << "程序退出" << endl;
                  break;          // 跳出 while 循环
            }
        }
    }
  • 标准异常

    • 标准异常类

      1、C++标准库中定义了一组,用于报告标准库函数遇到的问题。这些异常类也可以在用户编写的程序中使用,分别定义在4 个头文件
      2、exception:定义了最通用的异常类exception,它只报告异常的发生不提供任何额外信息
      3、stdexcept:定义了几种常用的异常类,详细信息见下表
      4、new:定义了bad_alloc异常类型,后续介绍
      5、type_info:定义了bad_cast异常类型,后续介绍

      stdexcept 定义的异常类 含义
      exception 最常见的错误
      runtime_error 只有在运行时才能检测出的任务
      range_error 运行时错误:生成的结果超出了有意义的值域范围
      overflow_error 运行时错误:计算上溢
      underflow_error 运行时错误:计算下溢
      logic_error 程序逻辑错误
      domain_error 逻辑错误:参数对应的结果值不存在
      invalid_argument 逻辑错误:无效参数
      length_error 逻辑错误:试图创建一个超出该类型最大长度的对象
      out_of_range 逻辑错误:使用一个超出有效范围的值
    • 运算与成员

      1、标准库异常类只定义了几种运算,包括创建或拷贝异常类型的对象,以及为异常类型的对象赋值
      2、我们只能以默认初始化的方式初始化exceptionbad_allocbad_cast对象,不允许为这些对象提供初始值
      3、其他异常类型的行为恰好相反,应该使用string对象或者C 风格字符串初始化这些类型的对象,不允许使用默认初始化的方式。创建此类对象时,必须提供初始值,该值含有错误相关信息
      4、异常类型之定义了一个名为what成员函数,返回值是C 风格字符串,字符串的目的是提供关于异常的一些文本信息。对于其他无初始值的异常类型what返回的内容由编译器决定


函数


章节概要:含有可变形参的函数;initializer_list形参;函数的返回值;不要返回局部对象的引用或指针;返回数组指针的函数;函数重载;定义重载函数;重载和const形参;const_cast和重载;特殊用途语言特性;默认实参;内联函数;constexpr函数

含有可变形参的函数

  • 简介

    1、有时我们无法提前预知应该向函数传递几个实参。为了编写能处理不同数量实参的函数,C++11 提供了以下几种方法
    2、如果所有的实参类型相同,可以传递一个名为initializer_list标准库类型
    3、如果实参类型不同,可以编写一种特殊的函数——可变参数模板,将在后续介绍
    4、此外还有一种从C 标准库stdarg.h继承而来的省略符形参...,但有了一些限制

  • initializer_list 形参

    • 简介

      1、initializer_list是一种标准库类型,用于表示某种特定类型的值的数组,其定义在initializer_list头文件
      2、和vector一样,该类型也是一种模板类型,这意味着定义对象时,也必须说明列表中所含元素的类型initializer_list<int> li;
      3、和vector不同的是,该类型对象中的元素永远是常量值,我们无法改变该类型对象中元素的值

    • 使用

      1、我们可以使用该类型作为函数的形参,使用迭代器访问列表中的元素
      2、使用函数时,如果想向initializer_list形参传递一个值的序列,则必须把序列放在一对花括号
      3、含有initializer_list形参的函数也可以同时拥有其他形参

      // 定义函数
      void error_msg(int error_code, initializer_list<string> i1)
      {
          cout << "error code:" << error_code;
          for(auto beg = i1.begin(); beg != i1.end(); beg++)
              cout << *beg << ' ';
          cout << endl;
      }
      // 传参使用
      int error_number = 15;
      string s1("error1"), s2("error2");
      error_msg(error_number, {"function", s1, s2});
  • 省略号形参...

    1、省略号形参...沿用自C 语言的stdarg.h,为了便于C++程序访问某些特殊的C 代码
    2、但省略符形参应该仅仅用于 C 和 C++ 通用的类型。特别应该注意,C++的大多数类类型的对象传递时都无法正确拷贝

函数的返回值

  • 不要返回局部对象的引用或指针

    1、函数完成后,所占用的内存空间也会随之释放。由此,局部变量的引用指向局部变量的指针将指向不再有效的内存区域
    2、如下示例,对于两条return语句,都将返回未定义的值

    // 函数返回值类型是const string &,即一个引用
    const string &manip()
    {
        string ret;
        if (!ret.empty())
            return ret;       // 错误:返回了局部对象的引用,返回的引用将指向无效区域
        return "Empty";       // 错误:Empty也是一个局部临时量,返回的引用将指向无效区域
    }
  • 返回数组指针的函数

    • 直接声明

      1、如果我们想定义一个返回数组指针的函数,则数组的下标数必须跟在函数名后。而函数的形参列表也跟在函数名后,且应该先于数组的下标数
      2、因此,返回数组指针的函数形式如:类型 (* 函数名(形参列表))[数组下标数],具体的例子和解读如下(可以参考类型声明黄金法则)

      int (*func(int i))[10];
      /*---------------------*/
      /*
      解读:
      func(int i)             :表示函数需要一个int类型形参
      (*func(int i))          :返回类型是一个指针
      (*func(int i))[10]      :指针指向一个大小是10的数组
      int (*func(int i))[10]  :指向的是一个含有10个int值的数组
      */
    • 使用尾置返回类型

      1、在 C++11 新标准中还有一种可以简化上述声明的方法,就是使用尾置返回类型
      2、任何函数的定义都能使用尾置返回,但这种形式对于返回类型比较复杂的函数最有效,比如返回类型是数组的指针或者数组的引用
      3、尾置返回类型跟在形参列表后面并以一个->符号开头。为了表示函数真正的返回类型跟在形参列表后,原先标识返回值的地方改为auto

      // 函数返回一个指针,指向含有10个整数的数组
      auto func(int i) -> int(*)[10];
    • 使用decltype获取类型

      1、还有一种情况,如果知道函数返回的指针将指向哪个数组,可以使用decltype声明返回类型,如下例
      2、decltype(odd)标识了返回类型是一个含有 5 个 int 值的数组*标识了返回类型是一个指针
      3、有一个地方要注意,decltype不负责把数组类型转换成相应的指针,所以如果需要返回指向数组的指针的函数,需要像下例中声明时加一个*

      int odd[] = {1, 3, 5, 7, 9};
      int even[] = {0, 2, 4, 6, 8};
      // 使用 decltype(odd) 获取类型
      decltype(odd) *arrPtr(int i)
      {
          return (i % 2) ? &odd : &even;  // 返回一个指向数组的指针
      }

函数重载

  • 简介

    1、如果同一作用域内的几个函数名字相同形参列表不同,我们称之为重载函数。如下例
    2、这些函数接受的形参类型不同,但是执行的操作非常相似,使用函数重载可以在一定程度上减轻起名记名的负担
    3、当调用这些函数时,编译器会根据传递的实参判断具体想要执行的是哪个函数

    // 函数原型
    void print(char *cp);               // 1
    void print(int *beg, int *end);     // 2
    void print(int ia[], size_t size);  // 3
    /*--------------------------------------*/
    // 函数调用
    int j[2] = {0, 1};
    print("Hello World");         // 调用 1
    print(j, end(j) - begin(j));  // 调用 3
    print(begin(j), end(j));      // 调用 2
  • 定义重载函数

    1、对于定义的重载函数来说,它们至少应该在形参数量形参类型有所不同
    2、假设有两个函数,他们的返回类型不同形参列表相同,这种声明不是重载函数,是错误的
    3、有时候两个形参列表看起来不一样,但实际是相同的。比如省略了形参名,或使用类型别名
    4、不能重载main()函数

    // 不允许两个函数除了返回类型不同其余都相同
    void lookup(Account&);
    bool lookup(Account&);    // 错误
    /*--------------------------------*/
    // 形参列表相同
    void lookup(Account &acct);
    void lookup(Account&);    // 相同,省略了形参名
    typedef Phone Telno;
    void lookup(Phone&);
    void lookup(Telno&);      // 相同,使用了类型别名
  • 重载和 const 形参

    1、顶层const不影响传入函数的对象,一个拥有顶层const的形参无法和另一个没有顶层const的形参区分开来
    2、如果形参是某种类型的指针或引用,则通过区分指向的是常量对象还是非常量对象可以实现函数重载,注意此时的const是底层的

    void lookup(Phone);
    void lookup(const Phone);       // 重复声明了void lookup(Phone)
    void lookup(Phone*);
    void lookup(Phone* const);      // 重复声明了void lookup(Phone*),顶层const
    /*------------------------------------------------------*/
    void lookup(Account&);          // 作用于Account的引用
    void lookup(const Account&);    // 新函数,作用于常量引用
    void lookup(Account*);          // 作用于指向Account的指针
    void lookup(const Account*);    // 新函数,作用于指向常量的指针,底层const
  • const_cast 和重载

    1、const_cast重载函数的情境中最有用,如下例
    2、原函数参数返回类型都是const string的引用。虽然我们可以对两个非常量的string调用这个函数,但返回结果仍是const string,我们并不希望返回的是常量
    3、此时我们需要一种新的函数,当它的实参不是常量时,得到的结果是一个非常量引用,使用const_cast可以很方便做到这一点
    4、新函数内通过const_cast把参数先转为常量,调用原函数,再将原函数结果转为非常量

    // 原函数,返回 const string 的引用
    const string &shorterString(const string &s1, const string &s2)
    {
        return s1.size() <= s2.size() ? s1 : s2;
    }
    // 重载函数,返回非 const 的引用
    string &shorterString(string &s1, string &s2)
    {
        auto &r = shorterString(const_cast<const string&>(s1), const_cast<const string&>(s2));
        return const_cast<string&>(r);
    }

特殊用途语言特性

  • 默认实参

    1、可以将函数形参列表中的值赋予初始值,这便是默认实参。当函数调用时,如果没有给定这个值的实参,便会使用默认实参
    2、我们可以为一个或多个形参赋予默认值。但要注意,一旦某个形参被赋予了默认值,它后面的所有形参必须有默认值
    3、函数调用时,实参按照位置顺序解析,默认实参负责填补函数调用缺少的尾部实参(即靠右侧位置的形参)。即不能省略前面的参数值只能省略尾部的参数值

    // 默认实参函数定义
    typedef string::size_type sz;
    string screen(sz ht = 24, sz wid = 24, char background = ' ');
    // 函数调用
    string window;
    window = screen();            // screen(24, 80, ' ')
    window = screen(66);          // screen(66, 80, ' ')
    window = screen(66,256,'#')   // screen(66, 256, '#')
    // 错误调用
    window = screen(, , '?');     // 错误
    window = screen('?');         // 错误,screen('?', 80, ' ')
  • 内联函数

    1、使用inline将函数指定为内联函数,通常就是将它在每个调用点内联地展开,可以避免函数调用的开销。说明如下示例
    2、内联只是向编译器发出的一个请求编译器可以选择忽略这个请求
    3、一般来说,内联机制用于优化规模较小流程直接调用频繁的函数。很多编译器不支持内联递归函数,而且一个大于 75 行的函数也不大可能在调用点内联展开

    // 原函数即程序调用
    inline string shorterString(string &s1, string &s2)
    {
        return s1.size() <= s2.size() ? s1 : s2;
    }
    string s1 = "abcde", s2 = "abcdef";
    cout << shorterString(s1, s2);
    
    // 在编译过程中,函数调用将直接展开成下面形式,从而不经过shorterString,减少了函数调用的开销
    cout << (s1.size() <= s2.size() ? s1 : s2);
  • constexpr 函数

    1、constexpr函数是指能用于常量表达式的函数。定义的方法与其他函数类似,但有如下规定:函数的返回类型所有形参都必须是字面值类型,而且函数体中必须有且仅有一条return语句
    2、执行初始化任务时,编译器constexpr函数的调用都替换成其结果值。为了能在编译过程中随时展开constexpr函数隐式指定为内联函数
    3、constexpr函数不一定返回常量表达式

    constexpr size_t scale(size_t ct)
    {
        return 3 * ct;
    }
    
    int arr[scale(2)];      // 正确,编译时 scale(2) 将被内联展开为 6
    int i = 2;
    int a2[scale(i)];       // 错误,i不是常量表达式


章节概要:定义抽象数据类型;设计Sales_data类;定义Sales_data类;分析与设计;this参数;const成员函数;返回this对象的函数;该类相关的非成员函数;构造函数;合成的默认构造函数;合成的默认构造函数的局限性;定义构造函数;= default的含义;构造函数初始值列表;类外定义构造函数;拷贝、赋值和析构;访问控制与封装;publicprivateclassstruct;友元;类的其他特性;类成员再探;mutable可变数据成员;返回*this的成员函数;从const成员函数返回*this;基于const的重载;友元再探;类之间的友元关系;令函数成员作为友元;友元函数重载和作用域;类的作用域;名字查找与类作用域;构造函数再探;委托构造函数;隐式类类型转换;explicit抑制构造函数隐式转换;聚合类;字面值常量类;constexpr构造函数;类的静态成员;声明、使用、定义静态成员;静态成员的类内初始化;静态成员与普通成员的区别

定义抽象数据类型

  • 抽象数据类型

    1、如果一个类,我们可以通过它的接口(例如描述的操作)来使用该类的对象,但不能访问该类的数据成员(甚至不知道该类有哪些数据成员),我们称这样的类抽象数据类型
    2、反之,如果一个类允许用户直接访问它的数据成员,并要求用户编写操作这样的类不是一个抽象数据类型
    3、对于一个普通的数据类型(非抽象数据类型),如果想把它变成抽象数据类型,我们需要定义一些操作供类的用户使用。一旦定义了自己的操作,我们就可以封装它的数据成员

  • 设计 Sales_data 类

    • 引入

      1、为了方便图书管理,我们需要设计一个类便于信息存储操作,需要先分析满足下列操作
      2、类中需要有能读写的数据对象,需要对这些对象支持一些操作
      3、操作包括一个名为isbn成员函数,并且支持+=+=<<>>运算符
      4、我们将在后续学习重载运算符,现在对于运算符运算,我们先定义为普通函数的形式
      5、由于特殊的原因(后续重载运算符介绍),执行加法IO 的函数不作为类的成员,我们将其定义成普通函数

    • 该类应包含的操作

      1、一个isbn成员函数,返回对象的 ISBN 编号
      2、一个combine成员函数,用于将一个Sales_data对象加到另一个对象上
      3、一个add函数,执行两个Sales_data对象的加法
      4、一个read函数,将数据从istream读入到Sales_data对象中
      5、一个print函数,将Sales_data对象的值输出到ostream

    • 使用类的接口(先不考虑如何实现该类,首先看看应该如何使用这些接口函数)

      Sales_data total;             // 保存当前求和结果的变量
      if(read(cin, total))          // 读入第一笔交易
      {
          Sales_data trans;         // 保存下一条交易数据的变量
          while(read(cin, trans))   // 读入剩余的交易
          {
              if(total.isbn() == trans.isbn())    // 检查 isbn
                total.combine(trans);             // 更新变量 total 当前的值
              else
              {
                  print(cout, total) << endl;     // 输出结果
                  total = trans;                  // 处理下一本书
              }
          }
          print(cout, total) << endl; // 输出最后一条交易
      }
      else                            // 如果没有输入
      {
          cerr << "No data?" << endl; // 通知用户
      }
  • 定义 Sales_data 类

    • 分析与设计

      1、该类的数据成员有:string类型的bookNo,表示 ISBN 编号;unsigned类型的units_sold,表示某书的销量;double类型的revenue,表示这本书的总销售收入
      2、类中将包含combineisbn两个成员函数,此外,我们还需要另一个成员函数avg_price返回售出书籍的平均价格。由于avg_price目的并非通用,所以它应该属于类的实现的一部分,而不是接口的一部分
      3、定义声明一个成员函数的方法与普通函数差不多。成员函数的声明必须在类的内部,而它的定义可以在外部。作为接口组成部分非成员函数addreadprint等,它们的定义和声明都在类的外部
      4、定义在类内部的函数隐式的内联函数

      struct Sales_data
      {
          // 数据成员
          std::string bookNo;
          unsigned units_sold = 0;
          double revenue = 0.0;
          // 成员函数
          std::string isbn() const
          {
              return bookNo;
          }
          Sales_data &combine(const Sales_data &);    // 将在类外定义
          double avg_price() const;                   // 将在类外定义
      };
      // 非成员接口函数
      Sales_data add(const Sales_data &, const Sales_data &);
      std::ostream &print(std::ostream &, const Sales_data &);
      std::istream &read(std::istream &, Sales_data &);
    • this参数

      1、isbn成员函数是如何获得bookNo 成员所依赖的对象的呢?
      2、观察对isbn调用total.isbn()。我们使用了点运算符来访问total 对象中的isbn成员函数并调用它
      3、还有一种例外的形式,当我们调用成员函数时,实际上是在替某个对象调用它。如果isbn指向Sales_data成员(如 bookNo),则它隐式地指向调用该函数的对象的成员。在上例中,当isbn返回bookNo时,实际上它隐式地返回total.bookNo
      4、成员函数通过名为this额外的隐式参数来访问和调用它的那个对象,当我们调用成员函数时,会用请求该函数的对象地址初始化this。例如调用total.isbn()时,编译器负责把total 的地址传递给isbn隐式形参this
      5、在成员函数体内部,也可以直接使用调用该函数的对象的成员,而无须通过成员访问运算符,因为this所指的正是这个对象。任何对类成员直接访问都被看作this隐式引用
      6、对于我们来说,this形参隐式定义的,我们可以在成员函数体内部使用this。尽管没有必要,但我们还是可以把isbn函数体写为return this->bookNo;
      7、因为this总是指向这个对象,所以this是一个常量指针不允许修改this中保存的地址

    • const成员函数

      1、isbn函数的另一个关键之处参数列表后的const,这里,const的作用是修改隐式this指针的类型
      2、默认情况下,this的类型是指向类类型非常量版本常量指针,例如Sales_data成员函数中,this的类型是Sales_data *const。这意味着,我们不能把this绑定到一个常量对象上,这使得不能在一个常量对象上调用普通的成员函数
      3、如果isbn是一个普通函数this是一个普通指针形参,则我们应该把this声明成const Sales_data *const类型,毕竟isbn函数体内不会改变this所指的对象
      4、然而,this是隐式的而且不会出现在形参列表中,在哪将this声明成指向常量的指针?C++的做法是把const放在成员函数的参数列表后
      5、紧跟在参数列表后const表示this是一个指向常量的指针,而像这样使用const成员函数称作常量成员函数

    • 在类的外部定义成员函数

      1、前面提过成员函数的定义可以在类外,我们的avg_pricecombine成员函数就打算通过这种方式定义
      2、要注意,类外部定义的成员的名字必须包含所属的类名,通过作用域运算符来告知编译器定义的这个函数是类内的成员函数

      double Sales_data::avg_price() const
      {
          if (units_sold)
              return revenue / units_sold;
          else
              return 0;
      }
    • 返回this对象的函数

      1、函数combine的设计初衷类似于复合赋值运算符+=,其定义如下
      2、当程序调用total.combine(trans);时,this指针绑定了total 的地址rhs 引用绑定了实参 trans
      3、该函数值得关注的是它的返回类型返回语句。模仿标准的赋值运算符把它的左侧运算对象当做左值返回,因此combine必须返回引用,而左侧运算对象又是Sales_data对象,因此返回类型是Sales_data&
      4、如前所述,我们需要把该对象当做左值返回。我们无须使用隐式的this指针访问函数调用者某个具体成员,而是要把调用函数的对象当做一个整体来访问,即return *this;,该调用返回 total 的引用

      Sales_data &Sales_data::combine(const Sales_data &rhs)
      {
          // 把 rhs 的成员加到 this 的成员上
          units_sold += rhs.units_sold;
          revenue += rhs.revenue;
          // 返回调用该函数的对象
          return *this;
      }
    • 该类相关的非成员函数

      • readprint函数

        1、readprint函数分别接受一个各自 IO 类型的引用作为其参数,是因为IO 类属于不能被拷贝的类型,因此只能通过引用传递。而且,读取和写入的操作会改变流的内容,所以使用普通引用而不是const引用
        2、print函数不负责换行。一般来说,执行输出任务的函数应该尽量减少对格式的控制,这样可以确保由用户代码决定是否换行

        // 输入的交易信息包括 ISBN、售出总数、售出单价
        std::istream &read(std::istream &is, Sales_data &item)
        {
            double price = 0;
            is >> item.bookNo >> item.units_sold >> price;
            item.revenue = price * item.units_sold;
            return is;
        }
        
        std::ostream &print(std::ostream &os, const Sales_data &item)
        {
            os << item.isbn() << ' ' << item.units_sold << ' ' << item.revenue << ' ' << item.avg_price();
            return os;
        }
      • add函数

        Sales_data add(const Sales_data &lhs, const Sales_data &rsh)
        {
            Sales_data sum = lhs; // 把 lhs 的数据成员拷贝给 sum
            sum.combine(rsh);     // 把 rsh 的数据成员添加到 sum
            return sum;           // 返回新的 Sales_data 对象
        }
  • 构造函数

    • 介绍

      1、每个都分别定义了它的对象被初始化的方式通过一个或几个特殊的成员函数控制其对象的初始化过程,这些函数被称为构造函数
      2、构造函数的任务是初始化类对象数据成员,无论何时只要有类的对象被创建就会执行构造函数
      3、构造函数的名字和类名相同。和其他函数不同的是,构造函数没有返回类型,且不能被声明成const。类可以包含多个构造函数,和其他重载函数类似
      4、当我们创建类的一个const对象时,直到构造函数完成初始化过程,对象才真正取得了常量属性。因此构造函数const对象的构造过程中可以向其写值

    • 合成的默认构造函数

      1、我们的Sales_data类并没有定义任何构造函数,可是之前使用了Sales_data对象的程序却没有异常,它们是如何初始化的?
      2、我们没有为这些对象提供初始值,它们执行了默认初始化通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数,其无须任何实参
      3、如我们所见,默认构造函数在很多方面都有其特殊性。其中之一是,如果我们的类没有显式定义构造函数,编译器会为我们隐式定义一个默认构造函数,这样的函数有称为合成的默认构造函数
      4、对于大多数类来说,这个合成的默认构造函数将按照如下规则初始化数据:使用类内的初始值(如果有的话)初始化成员,否则默认初始化该成员

    • 合成的默认构造函数的局限性

      1、合成的默认构造函数只适合非常简单的类,对于一个普通的类来说,必须定义它自己的默认构造函数,原因有三:
      2、首先,编译器只有在发现类不包含任何构造函数时才会替我们生成默认构造函数,一旦我们定义了一些其他构造函数,除非我们再定义一个默认生成函数,否则类将没有默认构造函数。如果一个类需要在某种情况下控制初始化,那么该类很可能在所有情况下都需要控制
      3、其次,对于某些类来说,合成的默认构造函数可能执行错误的操作。如果定义在块中的内置类型或数组指针这类的复合类型的对象默认初始化,它们的值是未定义的。因此这样的类应该在类内初始化这些成员,或定义一个自己的默认构造函数
      4、最后,有时候编译器不能为某些类合成默认构造函数。对这样的类来说,必须自定义默认构造函数,否则该类将没有可用的默认构造函数

    • 定义构造函数

      • 示例

        1、对于我们的Sales_data类,我们将使用下面的参数定义 4 个不同的构造函数
        2、一个istream&,从中读取一条交易信息
        3、一个const string&,表示 ISBN 编号;一个unsigned,表示图书数量;一个double,表示售出单价
        4、一个const string&,表示 ISBN 编号,编译器将赋予其他成员默认值
        5、一个空参数列表(即默认构造函数),如刚刚介绍,既然已定义其他构造函数,那么也必须定义一个默认构造函数

        struct Sales_data
        {
            // 新增的构造函数
            Sales_data() = default;
            Sales_data(const std::string &s) : bookNo(s)
            {
            }
            Sales_data(const std::string &s, unsigned n, double p) : bookNo(s), units_sold(n), revenue(p * n)
            {
            }
            Sales_data(std::istream &);   // 将在类外定义
        
            // 数据成员
            std::string bookNo;
            unsigned units_sold = 0;
            double revenue = 0.0;
            // 成员函数
            std::string isbn() const
            {
                return bookNo;
            }
            Sales_data &combine(const Sales_data &);
            double avg_price() const;
        };
      • = default的含义

        1、我们首先解释默认构造函数Sales_data() = default;
        2、首先请明确:因为该构造函数不接受任何实参,所以它是默认构造函数。我们定义它只是因为我们既需要其他形式的构造函数,又需要默认的构造函数,我们希望它的作用完全等同于合成的默认构造函数
        3、在 C++11 中,如果我们需要默认的行为,可以通过在参数列表后写上= default要求编译器生成构造函数。其中= default既可以和声明一起出现在类内,也可以作为定义出现在类外
        4、和其他函数一样,如果= default类内,则默认构造函数是内联的;反之则不是内联的

      • 构造函数初始值列表

        1、之后的两个构造函数中,出现了新的部分,即冒号冒号和花括号之间的代码。其中花括号定义了空的函数体,我们把新出现的部分称为构造函数初始值列表
        2、这部分负责为新创建的对象的一个或几个数据成员赋初值。构造函数初始值成员名字的一个列表,每个名字后紧跟括号括起来的或者在花括号内的成员初始值。不同成员初始化通过逗号分隔
        3、例如,上例含有三个参数的构造函数分别用前两个参数初始化了成员bookNounits_sold,而revenue的初始值则通过revenue(p * n)使用计算后的值作为初始值
        4、需要注意,这两个构造函数函数体是空的是因为这些构造函数的唯一目的就是为成员赋值,没有其他任务需要执行

      • 类外定义构造函数

        1、我们将在类外定义istream为参数的构造函数。该函数需要执行一些实际操作,所以在函数体内,调用了之前定义的read函数数据成员赋值
        2、Sales_data::Sales_data的含义是,我们定义的是Sales_data类的成员,其成员名Sales_data
        3、这个构造函数初始值列表为空,但是由于执行了构造函数体,所以对象的成员依然能被初始化

        Sales_data::Sales_data(std::istream &is)
        {
            read(is, *this);
        }
  • 拷贝、赋值和析构

    1、除了定义类的对象如何初始化外,类还需要控制拷贝赋值销毁对象时发生的行为
    2、如果我们不主动定义这些操作,编译器将替我们合成它们。一般来说,编译器生成的版本将对对象的每个成员执行拷贝赋值销毁操作
    3、我们将在 13 章介绍如何自定义上述操作

  • 完整的类与使用

    #include <iostream>
    #include <string>
    
    struct Sales_data
    {
        // 新增的构造函数
        Sales_data() = default;
        Sales_data(const std::string &s) : bookNo(s)
        {
        }
        Sales_data(const std::string &s, unsigned n, double p) : bookNo(s), units_sold(n), revenue(p * n)
        {
        }
        Sales_data(std::istream &);
    
        // 数据成员
        std::string bookNo;
        unsigned units_sold = 0;
        double revenue = 0.0;
        // 成员函数
        std::string isbn() const
        {
            return bookNo;
        }
        Sales_data &combine(const Sales_data &);
        double avg_price() const;
    };
    
    
    // 非成员接口函数
    Sales_data add(const Sales_data &, const Sales_data &);
    std::ostream &print(std::ostream &, const Sales_data &);
    std::istream &read(std::istream &, Sales_data &);
    
    double Sales_data::avg_price() const
    {
        if (units_sold)
            return revenue / units_sold;
        else
            return 0;
    }
    
    Sales_data &Sales_data::combine(const Sales_data &rhs)
    {
        // 把 rhs 的成员加到 this 的成员上
        units_sold += rhs.units_sold;
        revenue += rhs.revenue;
        // 返回调用该函数的对象
        return *this;
    }
    
    // 输入的交易信息包括 ISBN、售出总数、售出单价
    std::istream &read(std::istream &is, Sales_data &item)
    {
        double price = 0;
        is >> item.bookNo >> item.units_sold >> price;
        item.revenue = price * item.units_sold;
        return is;
    }
    
    std::ostream &print(std::ostream &os, const Sales_data &item)
    {
        os << item.isbn() << ' ' << item.units_sold << ' ' << item.revenue << ' ' << item.avg_price();
        return os;
    }
    
    Sales_data add(const Sales_data &lhs, const Sales_data &rsh)
    {
        Sales_data sum = lhs; // 把 lhs 的数据成员拷贝给 sum
        sum.combine(rsh);     // 把 rsh 的数据成员添加到 sum
        return sum;           // 返回新的 Sales_data 对象
    }
    
    // 构造函数
    Sales_data::Sales_data(std::istream &is)
    {
        read(is, *this);
    }
    
    int main()
    {
        Sales_data total;          // 保存当前求和结果的变量
        if (read(std::cin, total)) // 读入第一笔交易
        {
            Sales_data trans;             // 保存下一条交易数据的变量
            while (read(std::cin, trans)) // 读入剩余的交易
            {
                if (total.isbn() == trans.isbn()) // 检查 isbn
                    total.combine(trans);         // 更新变量 total 当前的值
                else
                {
                    print(std::cout, total) << std::endl; // 输出结果
                    total = trans;                        // 处理下一本书
                }
            }
            print(std::cout, total) << std::endl; // 输出最后一条交易
        }
        else // 如果没有输入
        {
            std::cerr << "No data?" << std::endl; // 通知用户
        }
        return 0;
    }

访问控制与封装

  • public 和 private

    1、对目前为止,我们已经为类定义了接口,但没有任何机制强制用户使用这些接口。我们的类还没有封装,也就是说,用户可以直达Sales_data对象内部控制它的具体细节
    2、C++中,我们使用访问说明符来加强类的封装性,如下说明。我们可以使用这些说明符再次定义Sales_data类,如后示例程序
    3、public说明符:定义在publib后的成员可以在整个程序内被访问public成员定义类的接口
    4、private说明符:定义在private后的成员只可以被类的成员函数访问,不能被使用该类的代码访问,private部分封装了类的实现细节
    5、通常构造函数部分成员函数跟在public后,而数据成员作为实现部分的函数跟在private
    6、一个类可以包含任意数量访问说明符,每个访问说明符指定了接下来的成员访问级别,其有效范围直到出现下一个访问说明符类的结尾

    class Sales_data
    {
        public:
            Sales_data() = default;
            Sales_data(const std::string &s, unsigned n, double p) : bookNo(s), units_sold(n), revenue(p * n)
            {
            }
            Sales_data(const std::string &s) : bookNo(s)
            {
            }
            Sales_data(std::istream &);
    
            std::string isbn() const
            {
                return bookNo;
            }
            Sales_data &combine(const Sales_data &);
    
        private:
            double avg_price() const
            {
                return units_sold ? revenue / units_sold : 0;
            }
            unsigned units_sold = 0;
            std::string bookNo;
            double revenue = 0.0;
    };
  • class 和 struct

    1、上例我们使用了class而非struct定义类,这种变化只是形式上有所不同,可以任意选择唯一区别是它们的默认访问权限不一样
    2、类可以在它第一个访问说明符前定义成员,这种成员的访问权限依赖于类定义的方式
    3、使用struct则这种成员默认是public的,而class这种成员默认是private

  • 友元

    1、既然Sales_data数据成员private的,那么我们的addprintadd函数就无法正常编译了。这是因为这几个函数虽然是类接口的一部分,但不是类的成员
    2、类可以允许其他类或函数访问它的非公有成员,方法是令它们成为该类的友元。对于函数,只需要增加一条friend关键字开头的函数声明即可
    3、友元声明只能出现在类定义内部,但友元不是类的成员不受访问控制的约束
    4、友元声明仅仅指定了访问权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,就必须在友元声明外再次专门进行一次函数声明

    friend Sales_data add(const Sales_data &, const Sales_data &);
    friend std::istream &read(std::istream &, Sales_data &);
    friend std::ostream &print(std::ostream &, const Sales_data &);

类的其他特性

  • 类成员再探

    • 为了展示这些新特性,我们需要定义一对相互关联的类ScreenWindow_mgr

    • 定义类型成员

      1、假设Screen表示显示器中的一个窗口,该类中包含一个用于保存内容string成员和分别用于表示光标位置屏幕的高和宽string::size_type成员
      2、除了定义数据和函数成员外,类还可以自定义某种类型在类中的别名。这种类型别名与其他成员一样存在访问限制,可以是publicprivate的一种
      3、如下我们在public部分定义了pos别名,这样用户就可以使用这个名字。Screen的用户不应该知道该类使用string对象存放数据,因此通过把pos定义成public可以隐藏Screen实现细节

      class Screen
      {
          public:
              typedef std::string::size_type pos;
      
          private:
              pos cursor = 0;
              pos height = 0, width = 0;
              std::string contents;
      };
    • Screen类的成员函数

      class Screen
      {
          public:
              typedef std::string::size_type pos;
              Screen() = default; // 默认构造函数
              Screen(pos ht, pos wd, char c)
                  : height(ht), width(wd), contents(ht * wd, c) // 构造函数,cursor 会被类内初始值初始化为 0
              {
              }
              char get() const // 读取光标处的字符
              {
                  return contents[cursor]; // 隐式内联
              }
              // 重载成员函数 get
              inline char get(pos ht, pos wd) const; // 显式内联
              Screen &move(pos r, pos c);            // 能在之后被设置为内联
      
          private:
              pos cursor = 0;
              pos height = 0, width = 0;
              std::string contents;
      };
      
      // 在类内声明为 inline
      char Screen::get(pos r, pos c) const
      {
          pos row = r * width;      // 计算行的位置
          return contents[row + c]; // 返回给定列的字符
      }
      // 在定义处指定 inline
      inline Screen &Screen::move(pos r, pos c)
      {
          pos row = r * width; // 计算行的位置
          cursor = row + c;    // 在行内将光标移动到指定列
          return *this;        // 以左值形式返回对象
      }
    • mutable可变数据成员

      1、有时我们希望能修改类的某个数据成员,即使是在一个const成员函数里,对此可以通过在变量的声明中加入mutable关键字声明一个可变数据成员
      2、一个可变数据成员永远不会是const,即使它是const对象的成员。因此,一个const成员函数可以改变一个可变成员的值
      3、例如,我们将给Screen添加一个access_ctr 可变成员,以跟纵成员函数被调用了多少次,如下例。尽管some_member是一个const成员函数,但依然可以改变access_ctr的值

      public:
          void some_member() const
          {
              ++access_ctr; // 保存一个计数值,用于记录成员函数被调用的次数
          }
      
      private:
          mutable size_t access_ctr; // 即使在一个 const 对象内也能被修改
    • 类数据成员的初始值

      1、定义好Screen后,我们将继续定义一个窗口管理类并用它来表示显示器上的一组Screen。这个类将包含一个Screen类型的vector每个元素表示一个特定的Screen
      2、默认情况下,我们希望Window_mgr类开始时总是拥有一个默认初始化的Screen。C++11 中,最好的办法是把这个默认值声明成一个类内初始值,由Screen构造函数初始化

      class Window_mgr
      {
          private:
              // 默认情况下,一个 Window_mgr 包含一个标准尺寸的空白 Screen,通过类内初始值和 Screen 的构造函数实现
              std::vector<Screen> screens{Screen(24, 80, ' ')};
      };
  • 返回 *this 的成员函数

    • 继续改进

      class Screen
      {
          public:
              // 添加两个新的成员函数
              Screen &set(char);
              Screen &set(pos, pos, char);
      };
      
      inline Screen &Screen::set(char c)
      {
          contents[cursor] = c; // 设置当前光标所在位置的新值
          return *this;         // 将 this 作为左值返回
      }
      inline Screen &Screen::set(pos r, pos col, char ch)
      {
          contents[r * width + col] = ch; // 设置给定位置的新值
          return *this;                   // 将 this 作为左值返回
      }
    • 返回*this的意义

      1、和move操作一样,set返回值也是调用set对象的引用,意味着这些函数返回的是对象本身而不是对象的副本
      2、如果我们把一系列操作连在一条表达式myScreen.move(4,0).set('#');,这些操作将会在同一个对象上执行
      3、更直观的,我们将这条语句的含义拆解,如下:

      // 如果返回 Screen &,其等价于:
      myScreen.move(4,0);
      myScreen.set('#');
      
      // 如果返回 Screen,其等价于:
      Screen temp = myScreen.move(4,0);     // 对返回值进行拷贝
      temp.set('#');                        // 不会改变 myScreen 的 contents
    • const成员函数返回*this

      1、下面我们要添加一个display操作,负责打印Screen的内容。我们希望这个函数能和moveset出现在同一序列,因此该函数也应该返回它的对象的引用
      2、从逻辑上说,显示并不需要改变对象内容,因此我们令display是一个const成员。此时this将是一个指向const的指针,而*this就是const对象
      3、由此推断,display返回类型const Screen&。然而,如果真的返回一个const的引用,那我们就不能将其嵌入到一组动作的序列里(如后示例)
      4、即使myScreen是个非常量对象,对set的调用也不能通过编译。问题在于displayconst版本返回了常量引用,我们无权set一个常量对象

      Screen myScreen;
      // 如果 display 返回常量引用,set 将无权更改常量对象的数据,因而报错
      myScreen.display(cout).set('*');
    • 基于const的重载

      1、通过区分成员函数是否是const的,我们可以对其进行重载,其原因主要如下
      2、首先,非常量版本的函数对于常量对象是不可用的,所以我们只能在常量对象上调用const成员函数。其次,虽然可以在非常量对象上使用常量版本的函数,但显然此时非常量版本是一个更好的匹配
      3、如下例,我们将声明一个do_display私有成员,用于负责打印。所有的display操作都将调用这个函数,然后返回自己的类型的对象
      4、当do_display执行完成后,这非常量版本display函数返回普通引用,而常量版本display函数返回常量引用

      class Screen
      {
          public:
              // 普通版本 display
              Screen &display(std::ostream &os)
              {
                  do_display(os);
                  return *this;
              }
              // const 版本 display
              const Screen &display(std::ostream &os) const
              {
                  do_display(os);
                  return *this;
              }
      
          private:
              void do_display(std::ostream &os) const
              {
                  os << contents;
              }
      };
  • 完整的 Screen 类

    #include <iostream>
    #include <string>
    #include <vector>
    
    class Screen
    {
        public:
            typedef std::string::size_type pos;
    
            Screen() = default; // 默认构造函数
            Screen(pos ht, pos wd, char c)
                : height(ht), width(wd), contents(ht * wd, c) // 构造函数,cursor 会被类内初始值初始化为 0
            {
            }
    
            char get() const // 读取光标处的字符
            {
                return contents[cursor]; // 隐式内联
            }
            inline char get(pos ht, pos wd) const; // 显式内联
            Screen &move(pos r, pos c);            // 能在之后被设置为内联
    
            void some_member() const
            {
                ++access_ctr; // 保存一个计数值,用于记录成员函数被调用的次数
            }
    
            Screen &set(char);
            Screen &set(pos, pos, char);
    
            // 普通版本 display
            Screen &display(std::ostream &os)
            {
                do_display(os);
                return *this;
            }
            // const 版本 display
            const Screen &display(std::ostream &os) const
            {
                do_display(os);
                return *this;
            }
    
        private:
            mutable size_t access_ctr; // 即使在一个 const 对象内也能被修改
            pos cursor = 0;
            pos height = 0, width = 0;
            std::string contents;
    
            void do_display(std::ostream &os) const
            {
                os << contents;
            }
    };
    
    // 在类内声明为 inline
    char Screen::get(pos r, pos c) const
    {
        pos row = r * width;      // 计算行的位置
        return contents[row + c]; // 返回给定列的字符
    }
    // 在定义处指定 inline
    inline Screen &Screen::move(pos r, pos c)
    {
        pos row = r * width; // 计算行的位置
        cursor = row + c;    // 在行内将光标移动到指定列
        return *this;        // 以左值形式返回对象
    }
    
    inline Screen &Screen::set(char c)
    {
        contents[cursor] = c; // 设置当前光标所在位置的新值
        return *this;         // 将 this 作为左值返回
    }
    inline Screen &Screen::set(pos r, pos col, char ch)
    {
        contents[r * width + col] = ch; // 设置给定位置的新值
        return *this;                   // 将 this 作为左值返回
    }
  • 友元再探

    • 先前我们的Sales_data类把三个普通的非成员函数定义成了友元,类还可以把其他的类定义成友元,还可以把其他类的成员函数定义成友元

    • 类之间的友元关系

      1、假设我们的Window_mgr类的某些成员可能需要访问它管理的Screen类的内部数据。例如我们需要为Window_mgr添加一个clear 成员,用于把指定的Screen内容设为空白
      2、此时clear需要访问Screen私有成员,而要使这种访问合法Screen需要把Window_mgr指定成它的友元
      3、如果一个类指定了友元类,则友元类成员函数可以访问此类包括私有成员在内所有成员
      4、需要注意的是,友元关系不存在传递性。即如果Window_mgr有它自己的友元,这些友元不能理所当然地访问Screen

      class Screen
      {
          friend class Window_mgr;
          // 此处省略先前的类对象
      };
      
      class Window_mgr
      {
          public:
              // 窗口中每个屏幕的编号
              using ScreenIndex = std::vector<Screen>::size_type;
              // 按照编号将指定的 Screen 重置为空白
              void clear(ScreenIndex i)
              {
                  // s 是一个 Screen 的引用,指向我们想清空的屏幕
                  Screen &s = screens[i];
                  s.contents = std::string(s.height * s.width, ' ');
              }
      
          private:
              std::vector<Screen> screens{Screen(24, 80, ' ')};
      };
    • 令函数成员作为友元

      1、除了令整个类作为友元外,还可以只为 clear 提供访问权限。当把一个成员函数声明称友元时,必须明确指出成员函数属于哪个类
      2、但要想令某个成员函数作为友元,必须要按照如下方式设计程序:
      3、首先定义Window_mgr类,其中声明clear函数,但不能定义它。在clear使用Screen成员之前必须先声明Screen;接下来定义Screen,包含对于clear的友元声明;最后定义clear,此时它才可以使用Screen成员

      class Screen
      {
          friend void Window_mgr::clear(ScreenIndex);
      }
    • 友元函数重载和作用域

      1、尽管重载函数的名字相同,但它们仍是不同的函数。因此如果一个类想把一组重载函数声明友元,则需要对每一个函数分别声明
      2、非成员函数声明不是必须在它们的友元声明之前,当一个名字第一个出现在一个友元声明中,我们隐式假定该名字在当前作用域中可见,然而友元本身不一定真的声明在当前作用域。甚至就算在类内定义该函数,我们也必须在类外提供相应的声明,从而使得函数可见
      3、重点在于理解友元声明本身的作用影响访问权限,而不是普通意义上的声明

      struct X
      {
          // 友元函数可以定义在类内,但不是真正的定义
          friend void f();
          X()
          {
            f();      // 调用 f() 函数,错误:f 还没有被声明
          }
          void g();
          void h();
      };
      
      void X::g()
      {
          return f(); // 调用 f() 函数,错误:f 还没有被声明
      }
      void f();       // 声明定义在 X 中的函数 f,从此时 f 才被真正声明可用
      void X::h()
      {
          return f(); // 正确:现在 f 的声明在作用域中了
      }

类的作用域

  • 类的作用域

    • 引入

      1、每个类都会定义它自己的作用域。在类的作用域外普通数据函数成员只能由对象、引用、指针使用成员访问运算符来访问;对于类类型成员则使用作用域运算符来访问
      2、不论哪种情况,跟在运算符后的名字都必须是对应类的成员

      Screen::pos ht = 24, wd = 80;   // 使用 Screen 定义的 pos 类型
      Screen scr(ht, wd, ' ');
      Screen *p = &scr;
      char c = scr.get();             // 访问 scr 对象的 get 成员
      c = p -> get();                 // 访问 p 所指向的 scr 的 get 成员
    • 作用域和定义在类外的成员

      1、一个就是一个作用域很好的说明了为什么我们在类外定义成员函数时需要提供类名::函数名。在类外,成员的名字被隐藏了
      2、一旦遇到了类名,定义的剩余部分就在类的作用域内了,这里的剩余部分包括参数列表函数体,因而我们可以直接使用类的其他成员而无须再次授权
      3、Window_mgr类的clear 成员用到了该类中定义的ScreenIndex类型void Window_mgr::clear(ScreenIndex i);。这里使用ScreenIndex时已经在该类的作用域下(因为在Window_mgr::后),所以不需要额外说明
      4、有时由于返回类型出现在类名前,此时想要使用ScreenIndex作为返回类型,必须明确指定哪个类定义了它,如下

      // 作用域::返回类型 类名::函数名(形参列表)
      window_mgr::ScreenIndex Window_mgr::addScreen(const Screen &s)
  • 名字查找与类作用域

    • 用于类成员声明的名字查找

      typedef double Money;
      std::string bal;
      
      class Account
      {
          public:
              Money balance()
              {
                  return bal;
              }
      
          private:
              Money bal;
      };

      1、编译器看到balance函数的声明语句时,将先在Account类内查找 Money 的声明,没找到就到类外的作用域去查找,找到Moneytypedef语句
      2、balance函数体由于在整个类可见后才被处理,因此其return语句返回名为 bal 的成员而不是外层的string对象

    • 类型名要特殊处理

      typedef double Money;
      
      class Account
      {
          public:
              Money balance()
              {
                  return bal;             // 已经使用了外层作用域的 Money
              }
      
          private:
              typedef double Money;       // 错误:不能重新定义 Money
              Money bal;
      };

      1、一般来说,内层作用域可以重新定义外层作用域的名字,即使该名字已经在内层作用域中使用过
      2、然而在类中,如果成员使用了外层作用域中的名字,而该名字代表一种类型,则类不能在之后重新定义该名字
      3、尽管重新定义类型名字是一种错误的行为,但是编译器并不为此负责,一些编译器将顺利通过这样的代码

    • 成员定义中的普通块作用域的名字查找

      int height; // 定义了一个名字,稍后将在 Screen 中使用
      
      class Screen
      {
          public:
              typedef std::string::size_type pos;
              void dummy_fcn(pos height)
              {
                  cursor = width * height; // 使用的是函数的形参 height
              }
      
          private:
              pos cursor = 0;
              pos height = 0, width = 0;
      };

      1、该例中,编译器处理函数中的乘法表达式时,它首先在函数作用域内查找名字,即先查找形参列表同名形参。因此该例使用的是形参 height而非成员 height全局 height
      2、如果要在这种情况下单独使用其他作用域的名字,可以使用::height调用全局 height,使用this -> heightScreen::height调用成员 height

构造函数再探

  • 委托构造函数

    1、C++11 新标准拓展了构造函数初始值的功能,使得我们可以定义所谓的委托构造函数。一个委托构造函数,使用它所属类的其他构造函数执行它自己的初始化过程
    2、和其他构造函数一样,一个委托构造函数也有一个成员初始值列表和一个函数体。与其他成员初始值一样,类名后面紧跟圆括号括起来的参数列表列表必须与类中另一个构造函数匹配

    class Sales_data
    {
        public:
            // 非委托构造函数使用对应的实参初始化成员
            Sales_data(std::string s, unsigned ct, double price) : bookNo(s), units_sold(ct), revenue(ct * price)
            {
            }
            // 其余构造函数全部委托给另一个构造函数
            Sales_data() : Sales_data("", 0, 0)
            {
            }
            Sales_data(std::string s) : Sales_data(s, 0, 0)
            {
            }
            Sales_data(std::istream &is) : Sales_data()
            {
                read(is, *this); // 之前上文类中定义过的函数
            }
    
        private:
            std::string bookNo;
            unsigned units_sold = 0;
            double revenue = 0.0;
    };
  • 隐式类类型转换

    • 介绍

      1、在类中,如果构造函数只接受一个实参,则它实际上定义了转换为此类类型隐式转换机制,有时我们把这种构造函数称为转换构造函数
      2、在先前的Sales_data类中,接受string的和接受istream构造函数分别定义了这两种类型向Sales_data隐式转换的规则。也就是说,在需要使用Sales_data的地方,可以使用stringistream代替
      3、如下例,我们用一个string实参调用了combine成员函数。该调用合法,编译器用给定的string自动创建了一个临时Sales_data对象,这个新生成的临时对象被传递给了combine

      std::string null_book = "9-999-99999-9";
      // 构造一个临时的 Sales_data 对象,该对象的 units_sold 和 revenue 都为 0,bookNo 等于 null_book
      item.combine(null_book);
    • 只允许一步类类型转换

      1、编译器只会自动地执行一步类型转换,因此下面代码隐式使用了两种转换规则是错误的
      2、如果我们想完成上述调用,可以主动进行一步显式转换,如后例

      // 错误:需要用户定义的两种转换,但编译器只转换一次
      // 1 把 字符串字面量 转换成 string
      // 2 把 string 转换成 Sales_data
      item.combine("9-999-99999-9");
      // 正确:显式转换成 string,隐式转换成 Sales_data
      item.combine(string("9-999-99999-9"));
      // 正确:隐式转换成 string,显式转换成 Sales_data
      item.combine(Sales_data("9-999-99999-9"));
    • explicit抑制构造函数隐式转换

      1、我们可以通过将只有一个实参的构造函数声明为explicit阻止隐式转换(多个实参的构造函数不能用于执行隐式转换,所以无须指定),此时将不能通过指定了explicit构造函数隐式创建类对象
      2、只能在类内声明构造函数时使用explicit,在类外定义时不应重复
      3、explicit构造函数只能用于直接初始化,当我们执行拷贝初始化(=)不能使用explicit构造函数

      class Sales_data
      {
          public:
              explicit Sales_data(std::istream&): bookNo(s)
              {
              }
      };
      // 错误:类外定义时不要重复使用 explicit
      explicit Sales_data::Sales_data(std::istream &is)
      {
          read(is, *this);
      }
      // 错误:不能使用拷贝初始化
      Sales_data item = null_book;
  • 聚合类

    1、聚合类使得用户可以直接访问其成员,当一个类满足如下条件时,我们说它是聚合的:
    2、所有成员都是public的;没有定义任何构造函数;没有类内初始值;没有基类,也没有virtual函数(这部分后续介绍)
    3、我们可以提供一个花括号括起来成员初始值列表来初始化聚合类的数据成员,顺序必须与声明的顺序一致

  • 字面值常量类

    • 介绍

      1、之前我们提到过constexpr函数参数返回值都必须是字面值类型。除了算术类型、引用、指针外,某些类也是字面值类型
      2、和其他类不同,这样的类可能含有constexpr构造函数,这样的成员必须符合constexpr函数的所有要求,它们是隐式const
      3、数据成员都是字面值类型聚合类都是字面值常量类,此外,如果一个类满足以下要求,也是一个字面值常量类
      4、数据成员都必须是字面值类型;类必须包含至少一个constexpr函数;数据成员的类内初始值必须是常量表达式,如果成员属于某种类类型,初始值必须使用成员自己的constexpr构造函数;类必须使用析构函数默认定义,该成员负责销毁类的对象

    • constexpr构造函数

      1、constexpr构造函数可以声明成=default。否则,该函数就必须既满足构造函数的要求(不包含返回语句),又满足constexpr函数的要求(唯一可执行的语句就是返回语句)
      2、综合这两点,constexpr构造函数体一般来说是空的
      3、constexpr构造函数必须初始化所有数据成员初始值要么使用constexpr构造函数,要么是一条常量表达式

      class Debug
      {
          public:
              constexpr Debug(bool b = true) : hw(b), io(b), other(b)
              {
              }
              constexpr Debug(bool h, bool i, bool o) : hw(h), io(i), other(o)
              {
              }
              constexpr bool any()
              {
                  return hw || io || other;
              }
      
          private:
              bool hw;
              bool io;
              bool other;
      };

类的静态成员

  • 引入

    1、有的时候类需要它的一些成员类本身直接相关,而不是与类的各个对象保持关联
    2、例如,一个银行账户类可能需要一个数据成员来表示当前的基准利率,我们希望利率与类关联,没必要每个对象存储利率信息
    3、一旦利率浮动,我们希望所有的对象都能使用新值

  • 声明静态成员

    1、我们通过在成员的声明前加上关键字static使其与类关联在一起。下例为我们模拟实现的银行账户类
    2、静态成员存在于任何对象之外,对象中不包含任何与数据成员有关的数据
    3、静态成员函数不与任何对象绑定在一起,它们不包含this指针,且静态成员函数不能声明成const

    class Account
    {
        public:
            void calculate()
            {
                amount += amount * interesRate;
            }
            static double rate()
            {
                return interesRate;
            }
            static void rate(double);
    
        private:
            std::string owner;
            double amount;
            static double interesRate;
            static double initRate();
    };
  • 使用静态成员

    // 使用作用域运算符直接访问静态成员
    double r;
    r = Account::rate();
    
    // 使用类的对象、引用或指针来访问静态成员
    Account ac1;
    Account *ac2 = &ac1;
    r = ac1.rate();
    r = ac2 -> rate();
    
    // 成员函数不通过作用域运算符就能直接使用静态成员
    class Account
    {
        public:
            void calculate()
            {
                ammout += amount * interestRate;    // 直接使用
            }
        private:
            static double interestRate;
    };
  • 定义静态成员

    1、和其他成员函数一样,我们在类内和类外都可以定义静态成员函数。当在类外定义时,不要重复static关键字
    2、因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的时候被定义的,这意味着它们不是由构造函数初始化的
    3、我们不能在类内初始化静态成员,相反,我们必须在类外定义和初始化每个静态成员

    // 类外定义静态成员函数
    void Account::rate(double newRate)
    {
        interestRate = newRate;
    }
    
    // 定义并初始化一个静态成员
    double Account::interestRate = initRate();
  • 静态成员的类内初始化

    1、通常情况下,类的静态成员不应该在类内初始化
    2、然而,我们可以为静态成员提供const整型类内初始值,不过要求静态成员必须是字面值常量类型constexpr
    3、如果在类内提供了一个初始值,则静态成员的定义不能再指定一个初始值

    class Account
    {
        public:
            static double rate()
            {
                return interestRate;
            }
            static void rate(double);
    
        private:
            static constexpr int period = 30; // period 是常量表达式
            double daily_tbl[period];
    };
  • 静态成员与普通成员的区别

    1、静态成员能用于某些场景,而普通成员不能。静态成员独立于任何对象,因此,在某些普通成员可能非法的场合,静态成员可以正常使用。比如静态数据成员可以是不完全类型(后续介绍)
    2、特别的,静态数据成员的类型可以是它所属的类类型,而普通成员只能声明成它所属类的指针或引用
    3、此外,我们可以使用静态成员作为默认实参,而普通成员不能,这是因为普通成员的值本身属于对象的一部分


IO 库


章节概要IO类;IO库类型;IO类型间的关系;IO对象;无拷贝或赋值;条件状态;查询流的状态;管理条件状态;管理输出缓冲;缓冲刷新的原因;刷新输出缓冲区;unitbuf操作符;tie关联输入和输出流;文件输入输出;fstream操作;fstream对象;openclose;文件模式;指定文件模式的限制;阻止丢弃已有数据;string流;stringstream操作;使用istringstream;使用ostringstream

IO 类

  • IO 库类型

    1、目前为止,我们已使用过的IO类型和对象都是操纵char数据的。但现实场景下,我们不能限制实际应用程序仅从控制台窗口进行IO 操作
    2、应用程序常常需要读写命名文件,而且使用IO操作处理string中的字符很方便。此外,还可能读写需要宽字符支持的语言
    3、为了支持这些不同种类的IO 操作,在istreamostream之外,标准库还定义了其他一些IO库类型,下表列出其中部分
    4、iostream定义了用于读写流的基本类型fstream定义了读写命名文件的类型sstream定义了读写内存string对象的类型
    5、为了支持使用宽字符的语言,标准库定义了一组类型和对象来操纵wchat_t类型数据。宽字符版本的类型和函数名w开头,如wcinwcoutwcerr

    头文件 类型 描述
    iostream istream,wistream 从流中读取数据
    iostream ostream,wostream 向流中写入数据
    iostream iostream,wiostream 读写流
    fstream ifstream,wifstream 从文件读取数据
    fstream ofstream,wofstream 向文件写入数据
    fstream fstream,wfstream 读写文件
    sstream istringstream,wistringstream string读取数据
    sstream ostringstream,wostringstream string写入数据
    sstream stringstream,wstringstream 读写string
  • IO 类型间的关系

    1、概念上,设备类型字符大小都不会影响我们要执行的IO 操作。比如我们可以使用>>读取数据,却不必管是从控制台,磁盘文件,或是string中读取;也不必管读取的字符能存入一个char 对象内,还是需要一个wchar_t对象
    2、标准库使我们能忽略这些不同类型流之间的差异,是通过继承机制实现的。我们可以使用具有继承关系的类,而不必了解继承机制如何工作的细节
    3、简单地说,继承机制使我们可以声明一个特定的类继承自另一个类。我们通常可以将一个派生类对象(继承类对象)当做其基类对象(被继承的类对象)来使用
    4、比如,ifstreamistringstream继承自istream。因此,我们可以像使用istream对象那样,使用ifstreamistringstream对象。这意味着,我们如何使用cin的,就可以同样使用这些类型的对象

  • IO 对象

    • 无拷贝或赋值

      1、我们不能拷贝IO对象赋值
      2、由于不能拷贝,因此我们不能将形参返回类型设置为流类型。进行IO 操作函数通常以引用的方式传递和返回流
      3、由于读写一个 IO 对象改变其状态,所以传递和返回的引用也不能是const

      #include <fstream>
      
      std::ofstream out1, out2;
      out1 = out2;                          // 错误:不能对流对象赋值
      std::ofstream print(std::ofstream);   // 错误:不能初始化 ofstream 参数
      out2 = print(out2);                   // 错误:不能拷贝流对象
    • 条件状态

      1、IO 操作一个与生俱来的问题是可能发生错误。一些错误是可恢复的,而其他错误可能发生在系统深处,已经超出了应用程序可修正的范围
      2、下表列出了IO所定义的一些函数和标志,可以帮助我们访问和操纵流的条件状态

      函数和标志 描述
      strm::iostate 是一种机器相关的类型,提供了表达条件状态的完整功能
      strm::badbit 用来指出流已崩溃
      strm::failbit 用来指出一个 IO 操作失败了
      strm::eofbit 用来指出流到达了文件结束
      strm::goodbit 用来指出流未处于错误状态,此值保证为 0
      s.eof() 若流 s 的 eofbit 置位,则返回 true
      s.fail() 若流 s 的 failbit 或 badbit 置位,则返回 true
      s.bad() 如流 s 的 badbit 置位,则返回 true
      s.good() 若流 s 处于有效状态,则返回 true
      s.clear() 将流 s 中所有条件状态位复位,将流的状态设置为有效,返回 void
      s.clear(flag) 根据给定的 flag 标志位,将流 s 中对应条件状态位复位,其中 flag 的类型为strm::iostate,返回 void
      s.setstate(flag) 根据给定的 flag 标志位,将流 s 中对应条件状态位置位,其中 flag 的类型为strm::iostate,返回 void
      s.rdstate() 返回流 s 的当前条件状态,返回值类型为strm::iostate
    • 查询流的状态

      1、IO定义了与机器无关的iostate类型,它提供了表达流状态的完整功能,该类型应作为一个位集合来使用
      2、该类型包含如上表中的四个constexpr,这些值用来表示特定类型的 IO 条件,可以与位运算符一起使用来一次性检测或设置多个标志位
      3、badbit表示系统级错误,如不可恢复的读写操作,通常如果badbit被置位,流就无法再使用了;failbit在发生可恢复错误被置位,如读取类型不对应等错误,通常这种问题是可修正的,流还可以继续使用;如果到达文件结束位置eofbitfailbit都会被置位goodbit值为 0表示流未发生错误
      4、同样,如上表所列,标准库还定义了一组函数来查询标志位的状态

    • 管理条件状态

      1、如上表,标准库中还有一些用来管理条件状态的函数
      2、流对象的成员rdstate返回一个iostate,对应当前流的状态setstate操作将给定的条件位置位,表示发生了对应错误;clear是一个重载成员函数,它分别有一个不接受参数的版本和接受一个iostate参数的版本,用于将条件位复位

      auto old_state = cin.rdstate(); // 记住 cin 的当前状态
      cin.clear();                    // 复位,使 cin 有效
      process_input(cin);             // 使用 cin
      cin.setstate(old_state);        // 将 cin 置为原有状态
  • 管理输出缓冲

    • 每个输出流都管理一个缓冲区,用来保存程序读写的数据。如果需要打印一串文本文本串可能会立即打印出来,也可能被操作系统保存在缓冲区中,之后再打印

    • 缓冲刷新的原因

      1、程序正常结束,作为main函数的return操作的一部分缓冲刷新被执行
      2、缓冲区满时,需要刷新缓冲,而后新的数据才能继续写入缓冲区
      3、我们可以使用操纵符(如endl)来显式刷新缓冲区
      4、在每个输出操作之后,我们可以用操纵符unitbuf设置流的内部状态,来清空缓冲区。默认情况下,对cerr是设置unitbuf的,因此写到cerr的内容都是立即刷新
      5、一个输出流可能被关联到另一个流,这种情况下,当读写被关联的流时,关联到的流缓冲区会被刷新。例如默认情况下,cincerr都关联到cout,因此,读cin或写cerr都会导致cout的缓冲区被刷新

    • 刷新输出缓冲区

      1、我们已经使用过操纵符endl,它完成换行并刷新缓冲区的工作
      2、IO中还有两个类似的操纵符flushends。其中flush只刷新缓冲区,不输出任何额外字符;ends向缓冲区插入一个空字符,然后刷新缓冲区

      cout << "hi!" << endl;
      cout << "hi!" << flush;
      cout << "hi!" << ends;
    • unitbuf操作符

      1、如果想在接下来每次输出操作后刷新缓冲区,我们可以使用unitbuf操纵符
      2、它告诉在接下来的每次写操作之后执行一次flush操作
      3、而使用nounitbuf可以重置流,使其恢复使用正常的系统管理缓冲区刷新机制

      cout << unitbuf;        // 此后所有输出操作后都会立即刷新缓冲区
      cout << nounitbuf;      // 此后回到正常的缓冲机制
    • tie关联输入和输出流

      1、当一个输入流关联到一个输出流时,任何试图从输入流读取数据的操作将会导致关联的输出流被刷新
      2、tie两个重载的版本不带参数tie返回指向输出流的指针。如果本对象当前关联到一个输出流,则返回的就是这个流的指针;如果对象未关联到其他流,则返回空指针
      3、接受一个指向ostream的指针作为参数tie将自己关联到此ostream。即x.tie(&o)返回指向原输出流的指针,并将x关联到新输出流o
      4、我们既可以将istream关联到ostream,也可以将ostream关联到ostream每个流同时最多关联到一个流,但多个流可以同时关联到同一个ostream

      cin.tie(&cout);                       // 将 cin 和 cout 关联在一起
      // old_tie 指向当前关联到 cin 的流(如果有的话)
      ostream *old_tie = cin.tie(nullptr);  // cin 不再与其他流关联
      // 将 cin 和 cerr 关联,这不是一个好主意,因为 cin 应该关联到 cout
      cin.tie(&cerr);                       // 读取 cin 会刷新 cerr 而不是 cout
      cin.tie(old_tie);                     // 重建 cin 和 cout 间的正常关联

文件输入输出

  • fstream操作

    1、头文件fstream定义了三个类型来支持文件 IOifstreamofstreamfstream,这些类型提供的操作与我们之前是用过的对象cincout操作一样
    2、特别是,我们可以用IO运算符(流插入符<<>>)来读写文件,可以用getline从一个ifstream读取数据
    3、除了继承自iostream类型的行为外,fstream还增加了一些新成员来管理与流关联的文件,如下表

    fstream 操作 描述
    fstream fstrm 创建一个未绑定的文件流,此处的 fstream 是头文件 fstream 中定义的一个类型
    fstream fstrm(s) 创建一个 fstream,并打开名为 s 的文件。s 可以是 string 类型或指向字符数组形式字符串的指针。这些构造函数都是 explicit 的,默认文件模式 mode 依赖于 fstream 的类型
    fstream fstrm(s, mode) 与前一个构造函数类似,但按照指定模式 mode 打开文件
    fstrm.open(s) 打开名为 s 的文件,并将文件与 fstrm 绑定,返回 void。默认文件模式 mode 依赖于 fstream 的类型
    fstrm.close() 关闭与 fstrm 绑定的文件,返回 void
    fstrm.is_open() 返回一个 bool 值,指出与 fstrm 关联的文件是否成功打开且尚未关闭
  • fstream对象

    • 创建对象

      1、当我们想要读写一个文件时,可以定义一个文件流对象,并将对象与文件关联起来
      2、每个文件流都定义了一个名为open成员函数,它完成一些系统相关的操作,来定位给定的文件,并视情况打开为读或写模式
      3、创建文件流对象时,可以直接提供文件名(可选),如果直接提供了文件名,则open会被自动调用

      ifstream in(ifile);         // 构造一个 ifstream 并打开 ifile 文件
      ofstream out;               // 输出文件流未关联到任何文件
    • 可以用fstream替代iostream&

      1、之前提到过,在要求使用基类类型对象的地方,我们可以使用继承类型对象替代
      2、这意味着,接受一个iostream类型引用或指针的参数的函数,也可以用一个对应的fstreamsstream类型调用

    • openclose

      1、如果我们定义了一个空文件流对象,可以随后调用open将它与文件关联起来,如下示例
      2、如果调用open失败failbit将被置位。由于调用可能失败,所以使用类似if(out)的方式进行检测是好习惯
      3、一旦一个文件流已经打开,它就会一直保持关联。如果对一个已打开的文件流调用open会失败,并会导致failbit被置位,随后试图使用该文件流的操作都会失败
      4、为了将文件流关联到另一个文件,必须先close关闭已关联的文件,成功关闭后才可以打开新的文件。如果open成功,则open设置流的状态,使得good()true

      ifstream in(ifile);         // 构造一个 ifstream 并打开 ifile 文件
      ofstream out;               // 输出文件流未关联到任何文件
      out.open(ifile + ".copy");  // 打开指定文件
      
      in.close();                 // 关闭文件
      in.open(ifile + "2");       // 打开另一个文件
  • 文件模式

    • 文件模式

      1、每个流都有一个关联的文件模式,用来指出如何使用文件,如下表
      2、无论用哪种方法打开文件(调用open或文件名初始化流等),都可以指定文件模式
      3、默认情况下,ifstreamin打开,ofstreamout打开,fstreaminout打开

      文件模式 含义
      in 只读(输入)模式
      out 只写(输出)模式
      app 只写,每次写操作前均定位到文件末尾
      ate 打开文件后立刻定位到文件末尾
      trunc 阶段文件
      binary 二进制模式
    • 指定文件模式的限制

      1、只可以对ofstreamfstream设定out模式
      2、只可以对ifstreamfstream设定in模式
      3、只有当out被设定时才能设定trunc模式
      4、只要trunc没被设定,就可以设定app模式
      5、默认情况下,即使我们没有设定trunc,以out打开的文件也会被阶段。为了保留以out打开的文件的内容,我们必须同时指定app模式,这样只会将数据追加到文件末尾;或者同时指定in模式,即打开文件同时进行读写操作
      6、atebinary可用于任何类型的文件流对象,且可以与其他任何类型模式组合使用

    • 阻止丢弃已有数据

      1、默认情况下,当我们打开一个ofstream时,文件的内容会被丢弃
      2、阻止清空ostream给定文件的方式是同时指定app模式

      // 下列语句中,file 都会被截断
      ofstream out1("file");
      ofstream out2("file", ofstream::out);
      ofstream out3("file", ofstream::out | ofstream::trunc);
      // 下列语句,通过 app 保留了文件内容
      ofstream app1("file", ofstream::app);
      ofstream app2("file", ofstream::out | ofstream::app);

string 流

  • stringstream操作

    1、头文件sstream定义了三个类型来支持内存 IOistringstreamostringstreamstringstream
    2、与fstream类似,sstream定义的类型也都继承自iostream
    3、除了继承得来的操作,sstream还定义了一些成员来管理与流相关联的string,如下表

    stringstream 操作 描述
    sstream strm 创建一个未绑定的 stringstream 对象,此处的 sstream 是头文件 sstream 中的一个类型
    sstream strm(s) 创建一个 sstream,保存字符串 s 的一个拷贝。此构造函数是 explicit 的
    strm.str() 返回 strm 保存的 string 的拷贝
    strm.str(s) 将字符串 s 拷贝到 strm 中,返回 void
  • 使用istringstream

    • 当我们的某些工作是对整行文本进行处理,而其他一些工作是处理行内的单个单词,通常可以使用istringstream(在理解上,可以认为该类型对象类似于一个单独的string输入缓冲区)

    • 示例

      1、假定有一个文件,列出了一些人和他们的电话号码,某些人只有一个号码,另一些人有多个号码,文件中每条记录都以人名开始,后面跟随一个或多个电话号码,不同记录之间换行。通过程序实现这些内容的存储
      2、我们在程序中使用一个istringstream与读入的文本行绑定,这样就可以在该类型对象上使用输入运算符>>读取每个元素

      #include <iostream>
      #include <sstream>
      #include <string>
      #include <vector>
      
      struct PersonInfo
      {
          public:
              std::string name;
              std::vector<std::string> phones;
      };
      
      int main()
      {
          std::string line, word;         // 分别保存来自输入的一行和单词
          std::vector<PersonInfo> people; // 保存来自输入的所有记录
          // 逐行从输入读取数据
          while (std::getline(std::cin, line))
          {
              PersonInfo info;                 // 创建一个保存此记录数据的对象
              std::istringstream record(line); // 将记录绑定到刚读入的行
              record >> info.name;             // 读取名字(到空格停止)
              while (record >> word)           // 读取电话号码(到空格停止并再次循环)
                  info.phones.push_back(word); // 保持它们
              people.push_back(info);          // 将此记录追加到 people 末尾
          }
          return 0;
      }
  • 使用ostringstream

    • 当我们逐步构造输出,希望最后一起打印时,ostringstream是很有用的(在理解上,可以认为该类型对象类似于一个单独的string输出缓冲区)

    • 示例

      1、假定对于istringstream的上例,我们想逐个验证电话改变其格式输出,对于有无效号码的人,我们不会将其输出,而是打印一条包含人名和无效号码错误信息。通过程序实现这些内容的格式化与转存
      2、由于我们不希望输出有无效号码的人,因此对每个人,直到验证完所有号码才能进行输出。这种情况下,我们可以将输出内容先写入到一个ostringstream中更加方便

      #include <iostream>
      #include <sstream>
      #include <string>
      #include <vector>
      
      struct PersonInfo
      {
          public:
              std::string name;
              std::vector<std::string> phones;
      };
      
      int main()
      {
          std::string line, word;         // 分别保存来自输入的一行和单词
          std::vector<PersonInfo> people; // 保存来自输入的所有记录
          // 逐行从输入读取数据
          while (std::getline(std::cin, line))
          {
              PersonInfo info;                 // 创建一个保存此记录数据的对象
              std::istringstream record(line); // 将记录绑定到刚读入的行
              record >> info.name;             // 读取名字
              while (record >> word)           // 读取电话号码
                  info.phones.push_back(word); // 保持它们
              people.push_back(info);          // 将此记录追加到 people 末尾
          }
      
          // 假定已有两个函数:
          // valid() 完成电话号码验证;format() 完成格式化
          for (const auto &entry : people) // 对于 people 的每一项
          {
              std::ostringstream formatted, badNums; // 每个循环创建的对象
              for (const auto &nums : entry.phones)  // 对每个数
              {
                  if (!valid(nums))                     // 如果无效
                      badNums << " " << nums;           // 将数的字符存入 badNums
                  else                                  // 如果有效
                      formatted << " " << format(nums); // 进行格式化
              }
              if (badNums.str().empty()) // 如果没有错误的数,打印名字和格式化的数
                  std::cout << entry.name << " " << formatted.str() << std::endl;
              else // 如果有错误,打印错误信息
                  std::cerr << "input error: " << entry.name << " invalid number(s): " << badNums.str() << std::endl;
          }
          return 0;
      }

顺序容器


章节概要:顺序容器概述;顺序容器类型;选择容器;容器库概览;容器操作;迭代器范围;容器类型成员;beginend成员;容器定义与初始化;assignswap;顺序容器操作;添加元素;添加元素的操作;使用insert添加元素;使用insert的返回值;使用emplace;访问元素;删除元素;特殊的forward_list操作;改变容器大小;有关迭代器失效;vector对象如何增长;操作原理描述;管理容量的成员函数;额外的string操作;构造string的其他方法;其他构造函数;substr操作;改变string的其他方法;特殊版本函数;appendreplace函数;string搜索操作;compare函数;数值转换;容器适配器;适配器类型;定义适配器;栈适配器;队列适配器

顺序容器概述

  • 顺序容器类型

    1、下表中列出了标准库中的顺序容器,其中大部分类型都提供高效灵活的内存管理,我们可以添加删除元素扩张收缩容器大小
    2、容器保存元素的策略容器操作有着固有且重大的影响。某些情况下,存储策略还会影响特定容器是否支持特定操作
    3、forward_listarrayC++新标准增加的类型。与内置数组相比,array是一种更安全易用的数组类型
    4、新标准库的容器比旧版本快得多,其性能几乎与最精心优化过同类数据结构一样好,甚至过之
    5、现代C++程序应该尽可能多使用标准库容器,而不是像内置数组这样的原始数据结构

    顺序容器类型 描述
    vector 可变大小数组,支持快速随机访问,在尾部之外的位置插入或删除元素可能很慢
    deque 双端队列,支持快速随机访问,在头尾插入删除速度很快
    list 双向链表,只支持双向顺序访问,在任何位置插入删除速度都很快
    forward_list 单向链表,只支持单向顺序访问,在任何位置插入删除速度都很快
    array 固定大小数组,支持快速随机访问,不能添加删除元素
    string 与 vector 类似,专门用来保存字符,随机访问快,尾部插入删除速度快
  • 选择容器

    1、通常,使用vector最好的选择,除非你有很好的理由选择其他容器
    2、如果你的程序有很多小的元素,且空间的额外开销很重要,则不要使用listforward_list
    3、如果程序要求随机访问元素,应使用vectordeque
    4、如果程序要求在容器的中间插入或删除元素,应使用listforward_list
    5、如果程序需要在头尾插入或删除元素,但不会在中间插入或删除,则使用deque
    6、如果程序只在输入时在容器中间位置插入元素,随后需要随机访问元素,则最好在输入阶段使用list,完成后拷贝到vector(但最好确定是否真的一定要在中间位置插入元素,大多情况可以输入到vector再通过sort函数排序)

容器库概览

  • 一般来说,每个容器都定义在一个头文件中,头文件名与类型名相同

  • 容器操作

    • 类型别名

      类型别名 描述
      iterator 此容器类型的迭代器类型
      const_iterator 可以读取元素但不能修改元素的迭代器类型
      size_type 无符号整数类型,足够保存此种容器类型最大可能容器的大小
      difference_type 带符号整数类型,足够保存两个迭代器之间的距离
      value_type 元素类型
      reference 元素的左值类型,与 value_type&含义相同
      const_reference 元素的 const 左值类型,即 cosnt value_type&
    • 构造函数

      构造函数 描述
      C c 默认构造函数,构造空容器
      C c1(c2) 构造 c2 的拷贝 c1
      C c(b,e) 构造 c,将迭代器 b 和 e 指定的范围内的元素拷贝到 c,但不适用于 array
      C c{a,b,c,…} 列表初始化 c
    • 赋值和 swap(assign 不适用于关联容器和 array)

      赋值和 swap 描述
      c1 = c2 将 c1 的元素替换为 c2 的元素
      c1 = {a,b,c,…} 将 c1 的元素替换为列表中的元素,但不适用于 array
      a.swap(b) 交换 a 和 b 的元素
      swap(a,b) 交换 a 和 b 的元素
      seq.assign(b,e) 将 seq 中的元素替换为迭代器 b 和 e 所表示范围中的元素,b 和 e 不能指向 seq 中的元素
      seq.assign(il) 将 seq 中的元素替换为初始化列表 il 中的元素
      seq.assign(n,t) 将 seq 中的元素替换为 n 个值为 t 的元素
    • 大小

      大小 描述
      c.size() c 中元素的数目,但不支持 forward_list
      c.max_size() c 可保存的最大元素数目
      c.empty() 若 c 为空,返回 true,否则返回 false
    • 增删元素(不适用于 array,且在不同容器中这些操作的接口都不同)

      增删元素 描述
      c.insert(args) 将 args 中的元素拷贝进 c
      c.emplace(inits) 使用 inits 构造 c 中的一个元素
      c.erase(args) 删除 args 指定的元素
      c.clear() 删除 c 中所有元素,返回 void
    • 关系运算符

      关系运算符 描述
      ==,!= 所有容器都支持相等和不等运算符
      <,<=,>,>= 关系运算符,无序关联容器不支持
    • 获取迭代器

      获取迭代器 描述
      c.begin(),c.end() 返回指向 c 的首元素与尾元素之后位置的迭代器
      c.cbegin(),c.cend() 返回 const_interator
    • 反向容器的额外成员(不支持 forward_list)

      反向容器的额外成员 描述
      reverse_iterator 按逆序寻址元素的迭代器
      const_reverse_interator 不能修改元素的逆序迭代器
      c.rbegin(),c.rend() 返回指向 c 的尾元素与首元素之前位置的迭代器
      c.crbegin(),c.crend() 返回 const_reverse_interator
  • 迭代器范围

    1、一个迭代器范围由一对迭代器表示,两个迭代器分别指向同一个容器中的元素位置尾元素之后位置,这两个迭代器通常被称为beginend
    2、这种元素范围被称为左闭合区间,数学描述为[begin, end),表示自 begin 开始于 end 前结束
    3、标准库使用左闭合范围是因为这种范围有三种方便的特性,假定beginend构成合法的迭代器范围,则有:
    4、如果beginend相等,则范围为空;如果beginend不等,则范围至少包含一个元素,且begin指向首元素;我们可以对begin递增若干次,使begin==end,过程中进行迭代

  • 容器类型成员

    1、每个容器都定义了多个类型,我们已经使用过其中三种size_typeiteratorconst_iterator
    2、除了已经使用过的迭代器类型,大多容器还提供反向迭代器,简单说,反向迭代器就是一种反向遍历容器的迭代器
    3、剩下还有一些类型别名,通过别名我们可以在不了解容器中元素类型的情况下使用它。比如元素类型value_type元素类型引用referenceconst_reference

  • begin 和 end 成员

    1、beginend操作生成指向容器首元素位置尾后位置的迭代器,这两个迭代器最常见的用途是形成一个包含容器所有元素迭代器范围
    2、beginend多个版本,带r的版本(rbegin)返回反向迭代器,带c的版本(cbegin)返回const迭代器,二者可叠加(crbegin)

  • 容器定义与初始化

    • 将一个容器初始化为另一个容器的拷贝

      1、将一个新容器创建为另一个容器的拷贝有两种方法:可以直接拷贝整个容器,或者可以拷贝由一个迭代器对指定的元素范围
      2、为了创建一个容器另一个容器的拷贝(即第一种方法),两个容器的类型及其元素的类型必须匹配
      3、而使用迭代器对(即第二种方法)本质上是拷贝迭代器指向的数据,就不要求容器类型相同了,但元素类型需要能转换成相应的类型

    • array具有固定大小

      1、与内置数组一样,array的大小也是类型的一部分,即定义一个array时,除了指定元素类型,还要指定容器大小array<int, 42>array<int, 42>::size_type
      2、由于大小array类型的一部分,所以array不支持普通的容器构造函数array大小固定的特性也影响了它所定义的构造函数的行为
      3、一个默认构造array非空的,它包含与其大小一样多的元素,这些元素都被默认初始化
      4、虽然我们不能对内置数组进行拷贝对象赋值操作,但对于array来说并无此限制

  • assign 和 swap

    • 使用assgin(仅顺序容器)

      1、使用赋值运算符赋值要求左边和右边的运算对象类型相同,而array外的顺序容器还定义了一个assign成员函数,允许我们从不同但相容的类型赋值,或从容器的一个子序列赋值
      2、assign操作用迭代器参数指定的元素替换左边容器的所有元素,比如我们可以用assign实现将vector中的一段char*赋值给一个list中的string,如下例

      list<string> names;
      vector<const char*> oldstyle;
      names = oldstyle;                                   // 错误:容器类型不匹配
      names.assign(oldstyle.cbegin(), oldstyle.cend());   // 正确:可以将 const char* 转换成 string
    • 使用swap

      1、swap操作交换两个相同类型容器内存,调用swap后,两个容器中的元素将会交换
      2、除array外,swap不对任何元素进行拷贝删除插入操作,不会真正移动元素,因此可以保证在O(1)常数时间内完成;而array真正交换它们的元素,因此所需时间是O(n)级别
      3、元素不会被移动意味着除string外,指向容器的迭代器引用指针swap之后不会失效,它们仍指向swap之前所指向的那些元素,只是swap之后这些元素已经属于不同的容器了(即swap前指向vec1[3]swap后指向vec2[3],而vec2实际就是交换前的vec1)。不同的是,对一个string调用swap会导致迭代器引用指针失效

顺序容器操作

  • 添加元素

    • 添加元素的操作

      1、除array外,所有标准库容器都提供灵活的内存管理,在运行时可以动态添加或删除元素改变容器大小,如下表
      2、这些操作会改变容器大小,因此array不支持这些操作
      3、forward_list有自己专有版本的insertemplace,且不支持push_backemplace_back
      4、vectorstring不支持push_frontemplace_front

      添加元素的操作 描述
      c.push_back(t),c.emplace_back(args) 在 c 的尾部创建一个值为 t 或由 args 创建的元素,返回 void
      c.push_front(t),c.emplace_front(args) 在 c 的头部创建一个值为 t 或由 args 创建的元素,返回 void
      c.insert(p,t),c.emplace(p,args) 在迭代器 p 指向的元素之前创建一个值为 t 或由 args 创建的元素,返回指向新添加元素的迭代器
      c.insert(p,n,t) 在迭代器 p 指向的元素之前插入 n 个值为 t 的元素,返回指向新添加的第一个元素的迭代器
      c.insert(p,b,e) 将迭代器 b 和 e 指定的范围内的元素插入到迭代器 p 指向的元素之前,b 和 e 不能指向 c 中的元素,返回指向新添加的第一个元素的迭代器,若范围为空,则返回 p
      c.insert(p,il) il 是一个花括号包裹的元素值列表列表,将这些给定值插入到迭代器 p 指向的元素之前,返回指向新添加的第一个元素的迭代器,若列表为空,则返回 p
    • 使用insert添加元素

      1、push_backpush_front操作能够快捷添加元素容器头尾,而insert允许我们在容器任意位置插入元素
      2、每个insert函数都接受一个迭代器作为第一个参数,指出在容器什么位置放置新元素,它可以指向容器任何位置,包括尾后位置insert会将元素插入到这个位置之前
      3、虽然某些容器不支持push_front,但对insert操作插入开始位置并无限制,因此我们可以将元素插入到容器开始位置,而不必关心容器是否支持push_front,但这样操作需要注意运行效率
      4、此外insert还可以接受更多参数添加多个相同值添加一个迭代器范围内的值,使用方法见上表格

      vector<string> svec;
      list<string> slist;
      
      // 等价于调用 slist.push_front("Hello!");
      slist.insert(slist.begin(), "Hello!");
      
      // vector 不支持 push_front,但可以 insert 到 begin() 之前
      // 警告:插入到 vector 末尾之外的任何位置都可能很慢
      svec.insert(svec.begin(), "Hello!");
    • 使用insert的返回值

      1、insert返回指向新添加的第一个元素迭代器
      2、通过使用insert的返回值,可以在容器中一个特定位置反复插入元素,如下例

      string word;
      list<string> lst;
      auto iter = lst.begin();
      while(cin >> word)
          iter = list.insert(iter, word);   // 每次执行后 iter 都赋值为返回值,即每次 iter 都指向开头位置
    • 使用emplace

      1、新标准引入了三个新成员emplace_frontemplaceemplace_back,这些操作将会构造元素(而不是拷贝元素),分别对应push_frontinsertpush_back
      2、当调用pushinsert成员函数时,我们将元素类型的对象传递给它们,这些对象拷贝到容器中。而当我们调用emplace时,则是将参数传递给元素类型的构造函数emplace使用这些参数在容器管理的内存空间中直接构造元素
      3、emplace函数在容器中直接构造元素,所以传递给emplace函数的参数必须与元素类型的构造函数相匹配

  • 访问元素

    1、下表列出了顺序容器访问元素操作,如果容器没有元素,则访问操作是未定义的
    2、at下标操作只适用于stringvectordequearray,此外back不适用于forward_list
    3、此外,使用时有一些注意事项,如下示例

    访问元素的操作 描述
    c.back() 返回 c 中尾元素的引用
    c.front() 返回 c 中首元素的引用
    c[n] 返回 c 中下标为 n 的元素的引用,n 是一个无符号整数。如 n>=c.size(),则函数行为未定义
    c.at(n) 返回 c 中下标为 n 的元素的引用。如果下标越界,则抛出一个out_of_range异常
    // 在解引用一个迭代器或调用 front 或 back 之前检查是否有元素
    if(!c.empty())
    {
        // val1 和 val2 是 c 中第一个元素值的拷贝
        auto val1 = *c.begin();   // 解引用迭代器
        auto val2 = c.front();    // front 获取首元素的引用
        // val3 和 val4 是 c 中最后一个元素值的拷贝(但此处操作对 forward_list 不适用)
        auto last = c.end();      // 注意迭代器指向尾后位置
        auto val3 = *(--last);    // 注意不能递减 forward_list
        auto val4 = c.back();     // forward_list 不支持
    }
  • 删除元素

    1、与添加元素的操作类似,非array的容器也有多种删除元素的方式,如下表。需要注意,删除元素的函数不检查其参数,程序员必须确保待删除的元素存在
    2、这些操作会改变容器大小,所以不适用于array
    3、forward_list特殊版本的erase,且不支持pop_back
    4、vectorstring不支持pop_front

    删除元素的操作 描述
    c.pop_back() 删除 c 中尾元素,函数返回 void
    c.pop_front() 删除 c 中首元素,函数返回 void
    c.erase(p) 删除迭代器 p 所指定的元素,返回一个指向被删除元素之后元素的迭代器。若 p 是尾后迭代器,则函数行为未定义
    c.erase(b,e) 删除迭代器 b 和 e 所指范围内的元素,返回一个指向最后一个被删元素之后元素的迭代器。若 e 本身是尾后迭代器,返回尾后迭代器
    c.clear() 删除 c 中所有元素,返回 void
  • 特殊的 forward_list 操作

    • 单向链表的操作

      1、为了理解forward_list为什么有特殊版本添加和删除操作,考虑当我们从一个单向链表删除一个元素会发生什么。当删除一个元素时,会改变序列中的链接,如删除elem3改变elem2指向elem4
      2、当添加或删除一个元素时,该元素之前的元素后继发生改变。为了执行这一操作,我们需要访问其前驱,以改变前驱的链接。但是forward_list单向链表,没有简单的办法获取一个元素的前驱
      3、由于这个原因,在一个forward_list中的增删操作是通过改变给定元素后的元素来完成的,这样,我们总是可以访问到被增删操作所影响的元素

    • forward_list增删操作

      1、由于forward_list操作其他容器上的操作实现方式不同,所以定义了一套特殊的函数,如下表
      2、为了支持这些操作forward_list也定义了before_begin,它是一个首前迭代器,它将允许我们在链表首元素之前的不存在的元素之后执行增删操作

      forward_list 增删操作 描述
      lst.before_begin(),lst.cbefore_begin() 返回指向链表首前元素的迭代器,此迭代器不能解引用。cbefore_begin 返回一个 const_iterator
      lst.insert_after(p,t),lst.insert_after(p,n,t),lst.insert_after(p,b,e),lst.insert_after(p,il) 在迭代器 p 之后的位置插入元素。t 是一个对象,n 是数量,b 和 e 是表示范围的一对迭代器(但不能指向 lst 内),il 是一个花括号列表。返回一个指向最后一个插入元素的迭代器。如果 b 和 e 的范围为空,则返回 p。若 p 为尾后迭代器,则函数行为未定义
      emplace_after(p,args) 使用 args 在 p 指定的位置之后创建一个元素,返回一个指向这个新元素的迭代器。若 p 为尾后迭代器,则函数行为未定义
      lst.erase_after(p),lst.erase_after(b,e) 删除 p 指向的位置之后的元素,或删除[b,e)之间的元素。返回一个指向被删元素之后元素的迭代器或尾后迭代器。如果 p 指向 lst 的尾元素或是一个尾后迭代器,则函数行为未定义
    • 示例程序

      forward_list<int> flst = {0,1,2,3,4,5,6,7,8,9};
      auto prev = flst.before_begin();        // 表示 flst 的首前元素
      auto curr = flst.begin();               // 表示 flst 的第一个元素
      while(curr != flst.end())               // 表示仍有元素要处理
      {
          if(*curr % 2)                       // 若元素为奇数
              curr = flst.erase_after(prev);  // 删除它并移动 curr
          else
          {
              prev = curr;                    // prev 指向 curr,即移动 prev 指向了下一个元素
              ++curr;                         // 移动迭代器 curr,指向 curr 下一个元素
          }
      }
  • 改变容器大小

    1、下标列出了改变容器大小的操作,但不适用于array
    2、注意:如果resize缩小容器,则指向被删除元素迭代器引用指针都会失效;对vectorstringdeque进行resize可能导致迭代器指针引用失效

    改变容器大小的操作 描述
    c.resize(n) 调整 c 的大小为 n 个元素,如 n < c.size(),则多出的元素被丢弃;若必须添加新元素,则对新元素进行值初始化
    c.resize(n,t) 调整 c 的大小为 n 个元素,任何新添加的元素都初始化值为 t
    list<int> ilist(10, 42);      // 10 个 int,每个的值都是 42
    ilist.resize(15);             // 再将 5 个值为 0 的元素添加到 ilist 的末尾
    ilist.resize(25, -1);         // 再将 10 个值为 -1 的元素添加到 ilist 的末尾
    ilist.resize(5);              // 再从 ilist 末尾删除 20 个元素
  • 有关迭代器失效

    • 迭代器失效

      1、向容器添加元素删除元素的操作可能使指向容器元素指针引用迭代器失效
      2、一个失效的指针引用迭代器不再表示任何元素,使用它们是一种严重的程序设计错误,可能引起与使用未初始化指针一样的问题
      3、有关迭代器失效的具体说明如下

    • 向容器添加元素时

      1、如果容器vectorstring,且存储空间被重新分配,则上述三者都会失效;如果存储空间未重新分配,则指向插入位置之前三者仍然有效,但指向插入位置之后三者将失效
      2、对于deque,插入到除首尾位置之外的任何位置都会导致三者失效;如果在首尾插入元素迭代器会失效,但指针引用不会失效
      3、对于listforward_list,指向容器的三者仍然有效

    • 从容器删除元素时

      1、对于vectorstring,指向被删元素之前元素的三者仍然有效之后部分三者将失效
      2、对于deque,如果在首尾之外的任何位置删除元素,那么指向被删除元素外的其他元素的三者也会失效;如果删除尾元素,则尾后迭代器也会失效其他位置三者不受影响;如果删除首元素三者也不会受影响
      3、对于listforward_list指向容器其他位置三者仍然有效
      4、注意,当我们删除元素时被删除元素三者一定失效尾后迭代器一定失效

    • 编写改变容器的循环程序

      1、添加删除vectorstringdeque元素的循环程序必须考虑上述三者可能失效的问题。程序必须保证每个循环步中更新三者
      2、如果循环中调用的是inserterase,那么更新迭代器很容易,我们可以利用这些操作返回值来更新,如下示例

      vector<int> vi = {0,1,2,3,4,5,6,7,8,9};
      auto iter = vi.begin();
      while(iter != vi.end())
      {
          if(*iter % 2)
          {
              iter = vi.insert(iter, *iter);    // 复制当前元素
              iter += 2;                        // 向前移动迭代器,跳过当前元素以及插入到它之前的元素
          }
          else
              iter = vi.erase(iter);            // 删除偶数元素
              // 不应向前移动迭代器,iter 指向我们删除的元素之后的元素
      }
    • 不要保存end返回的迭代器

      1、当我们在vectorstring或在deque首元素之外的任何位置增删元素时,原来end返回的迭代器总是会失效的
      2、因此,增删元素的循环程序必须反复调用end,而不能在循环之前保存end返回的迭代器一直当做容器末尾使用
      3、通常C++标准库的实现中end()都很快,部分原因就是因为需要经常反复调用

vector 对象如何增长

  • 操作原理描述

    1、为了支持快速随机访问vector将元素连续存储(每个元素紧挨着前一个元素存储)。通常情况下,我们不关心一个标准库类型如何实现只需要关心如何使用。然而对于vectorstring,其部分实现渗透到了接口上
    2、假定容器中元素是连续存储的,且容器大小可变,考虑向vectorstring添加元素会发生什么:如果没有空间容纳新元素,容器不可能简单将它添加到内存中其他位置,因为元素必须连续存储
    3、假如如此,容器必须分配新的内存空间保存已有元素和新元素,将已有元素移动到新空间,然后添加新元素,再释放旧空间。如果我们每添加一个新元素就执行一次这样的操作,性能会慢到不可接受
    4、为了避免这种代价标准库实现者采用了可以减少容器空间重新分配次数的策略。当不得不重新获取新的空间时,vectorstring通常会分配比新的空间需求更大的空间。容器预留这些空间作为备用,可用来保存更多新元素,并减少重新分配次数

  • 管理容量的成员函数

    1、vectorstring类型提供了一些成员函数,它们允许我们与实现中内存分配的部分互动,如下表
    2、shrink_to_fit只适用于vectorstringdeque。新标准中可以使用shrink_to_fit要求容器退回不需要的内存空间,但具体的实现可以选择忽略此请求
    3、capacityreserve只适用于vectorstring。注意reserve不改变元素的数量,它仅影响预先分配多大的内存

    容器大小管理操作 描述
    c.shrink_to_fit() 将 capacity() 减少为与 size() 相同大小
    c.capacity() 返回不重新分配内存空间的情况下,c 还能保存多少元素
    c.reserve(n) 分配至少能容纳 n 个元素的内存空间

额外的 string 操作

  • 构造 string 的其他方法

    • 其他构造函数

      1、除了之前介绍过的构造函数,以及与其他顺序容器相同构造函数string还支持另外三个构造函数,如下表
      2、nlen2pos2都是无符号值

      string 构造函数 描述
      string s(cp,n) s 是 cp 指向数组中前 n 个字符的拷贝。此数组应至少包含 n 个字符
      string s(s2,pos2) s 是 string s2 从下标 pos2 开始的字符的拷贝。若 pos2>s2.size(),则行为未定义
      string s(s2,pos2,len2) s 是 string s2 从下标 pos2 开始 len2 个字符的拷贝。若 pos2>s2.size(),则行为未定义。不管 len2 值是多少,函数至多拷贝 s2.size() - pos2 个字符
    • substr操作

      1、s.substr(pos,n):返回一个string,包含s中从pos开始的n个字符的拷贝。pos默认为0n默认为s.size() - pos,即所有字符
      2、substr返回一个string,它是原始string一部分或全部拷贝,可以传递给substr一个可选的开始位置计数值作为参数
      3、如果开始位置超过了string大小,则会抛出一个out_of_range异常;如果开始位置+计数值大于string大小,则函数会自动调整计数值,只拷贝到string末尾

      string s("hello world");
      string s2 = s.substr(0, 5);   // hello
      string s3 = s.substr(6);      // world
      string s4 = s.substr(6,11);   // 自动调整 11,拷贝到末尾,world
      string s5 = s.substr(12);     // 抛出 out_of_range 异常
  • 改变 string 的其他方法

    • 特殊版本函数

      1、string类型支持顺序容器的赋值运算符assigninserterase操作。此外它还定义了这些函数的一些特殊版本
      2、除了接受迭代器inserterase外,string还提供了接受下标的版本,如后例
      3、标准库string还提供了接受 C 风格字符数组insertassign,如后例

      修改 string 的操作 描述
      s.insert(pos,args) 在 pos 之前插入 args 指定的字符。pos 可以是一个下标或迭代器。接受下标的版本返回指向 s 的引用;接受迭代器的版本返回指向第一个插入字符的迭代器
      s.erase(pos,len) 删除从 pos 开始的 len 个字符。如果 len 被省略,则删除 pos 开始直至末尾的所有字符。返回指向 s 的引用
      s.assign(args) 将 s 中的字符替换为 args 指定的字符。返回指向 s 的引用
      s.insert(s.size(), 5, '!');   // 在 s 末尾插入 5 个 !
      s.erase(s.size() - 5, 5);     // 从 s 删除最后的 5 个字符
      
      const char *cp = "Stately, plump Buck";
      s.assign(cp, 7);              // 调用 assign 替换 s 的内容为 "Stately"
      s.insert(s.size(), cp + 7);   // s = "Stately, plump duck";
    • appendreplace函数

      1、string定义了两个额外的成员函数appendreplace,这两个函数可以改变string内容,如下表
      2、append是在string末尾进行插入操作的一种简写;replace调用eraseinsert的一种简写

      修改 string 的操作 描述
      s.append(args) 将 args 追加到 s。返回指向 s 的引用
      s.replace(range,args) 删除 s 中范围 range 内的字符,替换为 args 指定的字符。range 或者是一个下标和一个长度,或者是一对指向 s 的迭代器。返回指向 s 的引用
  • string 搜索操作

    1、string提供了一些6 种搜索函数,每种都有4 种重载函数,下表中描述了这些成员函数及其参数
    2、每个搜索操作都返回一个string::size_type,表示匹配发生位置的下标。如果搜索失败,则返回一个名为string::nposstatic成员
    3、标准库string::npos定义为const string::size_type类型,并初始化为-1。由于nposunsigned类型初始值-1意味着npos等于任何string最大的可能大小

    string 搜索操作 描述
    s.find(args) 查找 s 中 args 第一次出现的位置
    s.rfind(args) 查找 s 中 args 最后一次出现的位置
    s.find_first_of(args) 在 s 中查找 args 中任何一个字符第一次出现的位置
    s.find_last_of(args) 在 s 中查找 args 中任何一个字符最后一次出现的位置
    s.find_first_not_of(args) 在 s 中查找第一个不在 args 中的字符
    s.find_first_not_of(args) 在 s 中查找最后一个不在 args 中的字符
    args 的重载形式 描述
    c,pos 从 s 中位置 pos 开始查找字符 c 。pos 默认为 0
    s2,pos 从 s 中位置 pos 开始查找字符串 s2 。pos 默认为 0
    cp,pos 从 s 中位置 pos 开始查找指针 cp 指向的 C 风格字符串。pos 默认为 0
    cp,pos,n 从 s 中位置 pos 开始查找指针 cp 指向的数组的前 n 个字符。pos 和 n 无默认值
  • compare函数

    1、除了关系运算符外,标准库string还提供了一组compare函数,其与C 标准库strcmp很相似,如下表
    2、类似strcmp,根据s等于大于小于参数指定的字符串,s.compare分别返回 0正数负数

    compare 函数 描述
    s.compare(s2) 比较 s 和 s2
    s.compare(pos1,n1,s2) 将 s 中从 pos1 开始的 n1 个字符与 s2 进行比较
    s.compare(pos1,n1,s2,pos2,n2) 将 s 中从 pos1 开始的 n1 个字符与 s2 中从 pos2 开始的 n2 个字符进行比较
    s.compare(cp) 比较 s 与 cp 指向的 C 风格字符串
    s.compare(pos1,n1,cp) 将 s 中从 pos1 开始的 n1 个字符与 cp 指向的 C 风格字符串进行比较
    s.compare(pos1,n1,cp,pos2) 将 s 中从 pos1 开始的 n1 个字符与 cp 指向的地址开始的 n2 个字符进行比较
  • 数值转换

    1、字符串中常常包含表示数值的字符,有时我们需要将其转换为数值新标准引入了多个函数,可以实现数值数据与string之间的转换,如下表
    2、string参数中第一个非空白符必须是正负符号或数字,它可以以 0x 或 0X 开头表示十六进制数;对那些将字符串转换成浮点值的函数,参数还可以以小数点开头,并可以包含 e 或 E表示指数部分;对那些将字符串转换为整型值的函数,根据基数不同string参数还可以包含字母字符对于大于数字 9 的数
    3、如果string不能转换一个数值,函数会抛出invalid_argument异常;如果转换得到的数值不能用任何类型表示,函数会抛出out_of_range异常

    数值转换函数 描述
    to_string(val) 一组重载函数,返回数值 val 的 string 表示
    stoi(s,p,b),stol(s,p,b),stoul(s,p,b),stoll(s,p,b),stoull(s,p,b) 返回 s 的起始子串(表示整数内容)的数值,返回值类型对应函数名 sto 后的类型名缩写,如 i 为 int,ul 为 unsigned long。b 表示转换所用的基数(进制),默认为 10。p 是 size_t 指针,用来保存 s 中第一个非数值字符的下标,默认为 0,即不保存下标
    stof(s,p),stod(s,p),stold(s,p) 返回 s 的起始子串(表示浮点数内容)的数值,返回值类型对应函数名 sto 后的类型名缩写。p 是 size_t 指针,用来保存 s 中第一个非数值字符的下标,默认为 0,即不保存下标
    string s2 = "pi = 3.14";
    // 转换 s 中以数字开始的第一个子串,结果 d = 3.14
    // 先使用 find_first_of 找到数值部分,再用 substr 截取子串,再用 stod 转换
    double d = stod(s2.substr(s2.find_first_of("+-.0123456789")));

容器适配器

  • 适配器类型

    1、除了顺序容器外,标准库还定义了三个顺序容器适配器stackqueuepriority_queue,此外下表列出了它们支持的操作
    2、适配器标准库中的一个通用概念容器迭代器函数都有适配器。本质上,一个适配器是一种机制,能使某种事物的行为看起来像另外一种事物一样
    3、一个容器适配器接受一种已有的容器类型,使其行为看起来像一种不同的类型。例如stack接受一个顺序容器,使其操作看起来像一个stack

    适配器操作 描述
    size_type 一种类型,足以保存当前类型的最大对象的大小
    value_type 元素类型
    container_type 实现适配器的底层容器类型
    A a; 创建一个名为 a 的空适配器
    A a(c) 创建一个名为 a 的适配器,带有容器 c 的一个拷贝
    关系运算符 每个适配器都支持所有关系运算符
    a.empty() 若 a 为空,则返回 true,否则返回 false
    a.size() 返回 a 中的元素数目
    a.swap(b),swap(a,b) 交换 a 和 b 的内容,a 和 b
  • 定义适配器

    1、每个适配器都定义了两个构造函数默认构造函数创建一个空对象接受一个容器的构造函数拷贝该容器初始化适配器
    2、默认情况下,stackqueue基于deque实现的priority_queue基于vector实现的。我们可以在创建适配器时将一个命名的顺序容器作为第二个类型参数,来重载默认容器类型
    3、对于一个给定的适配器,可以使用哪些容器是有限制的。所有适配器都要求容器具有增删元素访问尾元素的能力,因此不能构造在arrayforward_list上;此外queue不能构造在vector上,priority_queue不能构造在list

    stack<int> stk(deq);                            // 从 deq 拷贝元素到 stk
    
    stack<string, vector<string>> str_stk;          // 在 vector 上实现的空栈
    stack<string, vector<string>> str_stk2(svec);   // 在 vector 上实现,初始化时保存 svec 的拷贝
  • 栈适配器

    1、stack类型定义在stack头文件中,下表列出了栈特有的操作,并举出了一个示例
    2、默认基于deque实现,也可以在listvector上实现
    3、每个容器适配器都基于底层容器类型的操作定义了自己的特殊操作,但我们只能使用适配器操作,而不能使用底层容器类型的操作

    栈特有的操作 描述
    s.pop() 删除栈顶元素,但不返回元素值
    s.push(item),s.emplace(args) 创建一个新元素压入栈顶,该元素通过拷贝或移动 item 而来,或者由 args 构造
    s.top() 返回栈顶元素,但不将元素弹出栈
    stack<int> intStack;
    
    for(size_t ix = 0; ix != 10; ix++)
      intStack.push(ix);
    
    while(!intStack.empty())
    {
        int value = intStack.top();
        intStack.pop();
    }
  • 队列适配器

    1、queuepriority_queue类型定义在queue头文件中,下标列出了队列特有的操作
    2、queue默认基于deque实现,priority_queue默认基于vector实现。queue也可以用listvector实现,priority_queue也可以用deque实现
    3、priority_queue允许我们为队列中的元素建立优先级新加入的元素会排在所有优先级比他低的已有元素之前。饭店按照客人预定时间而不是到店时间来安排座位就是一个例子

    队列特有的操作 描述
    q.pop() 返回 queue 的首元素或 priority_queue 的最高优先级的元素,但不返回元素值
    q.front(),q.back() 返回首元素或尾元素,但不删除此元素,只适用于 queue
    q.top() 返回最高优先级的元素,但不删除此元素,只适用于 priority_queue
    q.push(item),q.emplace(args) 在 queue 末尾或 priority_queue 中恰当位置创建一个元素,其值为 item,或由 args 构造

泛型算法


章节概要:概述;算法如何工作;算法不执行容器操作;初识泛型算法;只读算法;accumulate;算法和元素类型;操作两个序列的equal;写容器元素的算法;fill;算法不检查写操作;介绍back_insertercopyreplace;重排容器元素的算法;sortunique;定制操作;谓词与向算法传参;lambda表达式;向lambda传参;使用捕获列表;lambda捕获和返回;lambda捕获列表;可变lambda;指定lambda返回类型;参数绑定;bind;使用placeholders名字;bind重排参数顺序;绑定引用参数;再探迭代器;标准库迭代器;插入迭代器;iostream迭代器;istream_iterator操作;使用算法操作流迭代器;ostream_iterator操作;反向迭代器;泛型算法结构;五类迭代器;算法参数规范;算法命名规范;特定容器算法;链表类型的成员算法;splice成员

概述

  • 引入

    1、大多数算法都定义在头文件algorithm中。标准库还在头文件numeric中定义了一组数值泛型算法
    2、一般情况下,这些算法并不直接操作容器,而是遍历由两个迭代器指定的一个元素范围来进行操作。通常情况下,算法遍历范围,对其中每个元素进行操作
    3、例如,假设我们有一个intvector,希望知道其中是否包含某个特定值,可以使用find算法,如下

    int val = 42;     // 要查找的值
    // 如果在 vec 中找到想要的元素,则返回结果指向它,否则返回结果为 vec.cend()
    auto result = find(vec.cbegin(), vec.cend(), val);
    // 报告结果
    cout << "The value" << val << (result == vec.cend() ? "is not present" : "is present") << endl;
  • 算法如何工作

    1、为了弄清这些算法如何用于不同类型的容器,让我们更近距离观察一下find如何工作。概念上,find应执行如下步骤
    2、访问序列中的首元素;比较此元素与我们要查找的值匹配;如果此元素与我们要查找的值匹配,就返回标识此元素的值;否则find前进到下一元素重复步骤 2 和 3;如果到达序列尾find应停止;如果到达序列末尾,返回一个指出元素未找到的值
    3、迭代器算法不依赖于容器,但算法依赖于元素类型的操作

  • 算法不执行容器操作

    1、泛型算法本身不会执行容器操作,它们只会运行于迭代器之上,执行迭代器的操作,该特性说明算法永远不会改变底层容器的大小
    2、算法可能改变容器中保存的元素的值,也可能在容器内移动元素,但永远不会直接增删元素
    3、标准库定义了一类特殊的迭代器,称为插入器。与普通迭代器只能遍历所绑定的容器相比,插入器能做更多的事情,后续介绍

初识泛型算法

  • 介绍

    1、标准库提供了超过 100 个算法,幸运的是,与容器类似,这些算法有一致的结构附录按操作方式列出了所有算法
    2、除了少数例外标准库算法都对一个范围内的元素进行操作。我们将此元素范围称为输入范围,接受输入范围的算法总是使用前两个参数来表示此范围(范围[a, b))
    3、虽然大多算法遍历输入范围的方式相似,但它们使用范围内的元素的方式不同理解算法最基本的方法就是了解它们是否读取元素改变元素重排元素顺序

  • 只读算法

    • accumulate

      1、一些算法只会读取输入范围内的元素,而不改变元素,称为只读算法find就是这样一种算法
      2、另一个只读算法accumulate,它定义在头文件numeric中。该函数接受三个参数,前两个表示范围,第三个参数是和的初值
      3、假定vec是一个整数序列,则有下例对 vec 中元素求和
      4、accumulate第三个参数的类型决定了函数中使用哪个加法运算符以及返回值的类型

      // 对 vec 中元素求和,和初始值为 0
      int sum = accumulate(vec.cbegin(), vec.cend(), 0);
    • 算法和元素类型

      1、accumulate第三个参数作为求和起点,这蕴含着一个编程假定:将元素类型加到和的类型上的操作必须是可行的
      2、即,序列中元素的类型必须与第三个参数匹配,或者能转换为第三个参数的类型
      3、下例中,由于string定义了+运算符,所以我们可以通过调用accumulatevector中所有string元素连接起来。但注意,必须显式创建一个string不可以空串当成第三个参数传给函数,原因在于空串表示保存和的类型将是const char*,而此类型没有定义+运算符

      string sum = accumulate(v.cbegin(), v.cend(), string(""));
      
      // 错误:const char* 没有定义 + 运算符
      string sum = accumulate(v.cbegin(), v.cend(), "");
    • 操作两个序列的equal

      1、另一个只读算法equal,用于确定两个序列是否保存相同的值。它将第一个序列中的每个元素第二个序列中的对应元素进行比较,如果对应元素都相等,则返回true,否则返回false
      2、此算法接受三个迭代器,前两个表示第一个序列中的元素范围,第三个表示第二个序列的首元素。但要注意,equal假定第二个序列至少与第一个序列一样长,且默认只接受一个单一迭代器来表示第二个序列其他算法,也都有此假定
      3、由于equal利用迭代器完成操作,因此可以调用equal比较两个不同类型容器中的元素,而且元素类型也不必一样,只要能用==比较两个元素类型即可
      4、如下例,roster1可以是vector<string>,而roster2可以是list<const char*>

      // roster2 中元素数目应至少与 roster1 中一样多
      equal(roster1.cbegin(), roster1.cend(), roster2.cbegin());
  • 写容器元素的算法

    • fill

      1、一些算法新值赋予序列中的元素。当我们使用这类算法时,必须注意确保序列原大小大于等于我们要写入的元素数目,这是因为算法不会执行容器操作,因此它们自己不能改变容器大小
      2、一些算法会自己向输入范围写入元素,这些算法本质上并不危险,它们最多写入与给定序列一样多的元素
      3、例如算法fill接受一对迭代器表示一个范围,还接受一个值作为第三个参数fill将给定的这个值赋予序列中的每个元素。由于fill向给定输入序列写入数据,所以只要传递一个有效的输入序列写入操作就是安全

      fill(vec.begin(), vec.end(), 0);                    // 将每个元素重置为 0
      fill(vec.begin(), vec.begin() + vec.size()/2, 10);  // 将容器的一个子序列设置为 10
    • 算法不检查写操作

      1、一些算法接受一个迭代器来指出一个单独的目的位置,这些算法将新值赋予一个序列中的元素,该序列目的位置迭代器指向的元素开始
      2、例如,函数fill_n接受一个单迭代器、一个计数值、一个。它将给定值赋予迭代器指向元素开始指定个元素。我们可以用fill_n将一个新值赋予vector中的元素,如下例
      3、函数fill_n假定写入指定个元素是安全的,即假定从指定元素起有指定个元素,如果越界,会出现严重错误,行为未定义

      vector<int> vec;                      // 空 vector
      fill_n(dest.begin(), vec.size(), 0);  // 将所有元素重置为 0
      fill_n(dest.begin(), 10, 50);         // 错误:vec 为空,没有 10 个元素,行为未定义
    • 介绍back_inserter

      1、一种保证算法足够元素空间来容纳输出数据的方法是使用插入迭代器,这是一种向容器中添加元素迭代器
      2、通常情况,我们通过一个普通迭代器向容器元素赋值时被赋予迭代器指向的元素;而通过插入迭代器赋值时,一个与=右侧值相等的元素添加到容器
      3、后续还会再详细介绍插入迭代器,现在我们简要介绍插入迭代器中的back_inserter,它定义在iterator头文件
      4、back_inserter接受一个指向容器的引用,返回一个与该容器绑定插入迭代器。我们常常使用back_inserter创建迭代器作为算法的目的位置来使用,如下例

      vector<int> vec;                    // 空 vector
      auto it = back_inserter(vec);       // 创建插入迭代器,通过 it 赋值会将元素添加到 vec 中
      *it = 42;                           // vec 中现在有一个元素,值为 42
      fill_n(back_inserter(vec), 10, 0);  // 添加 10 个 0 到 vec 中
    • copy

      1、copy算法是另一个向目的位置迭代器指定的输出序列中的元素写入数据的算法。它接受三个迭代器前两个表示一个输入范围第三个表示目的序列的起始位置
      2、此算法将输入范围中的元素拷贝到目的序列中,注意传递的目的序列至少要包含与输入序列一样多的元素。我们可以用copy实现内置数组的拷贝,如下例
      3、copy返回的是其目的位置迭代器(递增后)的,即ret指向拷贝到a2尾后位置
      4、多个算法都提供所谓的拷贝版本,这些算法计算新元素的值,但不会将它们放在输入序列的末尾,而是创建一个新序列保存这些结果
      5、例如replace读取一个序列(前两个参数迭代器表示),将其中等于给定值(第三个参数)的值更改为另一个值(第四个参数)。如果我们希望保留原序列不变,可以使用replace_copy,它接受额外的第三个参数表示调整后序列的保存位置,如下例

      int a1[] = {0,1,2,3,4,5,6,7,8,9};
      int a2[sizeof(a1) / sizeof(*a1)];         // a2 与 a1 大小一样
      // ret 指向拷贝到 a2 的尾元素之后的位置
      auto ret = copy(begin(a1), end(a1), a2);  // 把 a1 的内容拷贝给 a2
      // 将所有值为 0 的元素改为 42
      replace(ilst.begin(), ilst.end(), 0, 42);
      
      // 使用 back_inserter 按需要增长目标序列,序列保存至 back_inserter(ivec)
      replace_copy(ilst.begin(), ilst.cend(), back_inserter(ivec), 0, 42)
  • 重排容器元素的算法

    • 引入

      1、某些算法会重排容器元素的顺序,比如sort。调用sort重排输入序列中的元素使之有序,它是利用元素类型<运算符来实现排序的
      2、现在假定我们要分析一段文章所用的词汇。假定已有一个vector保存了文本,我们希望简化vector使每个单词只出现一次

    • sortunique

      1、为了消除重复单词,首先使用sortvector排序,使重复单词相邻出现
      2、排序完成后,可以使用标准库算法unique重排vector,使不重复的元素出现在vector的开始部分
      3、由于算法不能执行容器的操作,我们将使用vectorerase来真正完成删除操作

      void elimDups(vector<string> &words)
      {
          // 按字典序排序 words,以便查找重复单词
          sort(words.begin(), words.end());
          // unique 重排输入范围,使每个单词只出现一次,重复的单词置后
          // 返回指向不重复区域之后一个位置的迭代器
          auto end_unique = unique(words.begin(), words.end());
          // 使用向量操作 erase 删除重复单词
          words.erase(end_unique, words.end());
      }

定制操作

  • 引入

    1、很多算法都会比较输入序列中的元素,默认情况下,这类算法使用元素类型<==运算符来完成比较
    2、标准库还为这些算法定义了额外的版本,允许我们提供自定义的操作来代替默认运算符
    3、例如sort默认使用元素类型<来比较,但可能我们希望的顺序<定义的顺序不同,或者序列保存的是未定义<运算符元素类型,这些情况都需要重载sort默认行为

  • 谓词与向算法传参

    1、谓词是一个可调用的表达式,其返回结果是一个能用作条件的值标准库所使用的谓词分两类:一元谓词二元谓词
    2、接受谓词参数的算法对输入序列中的元素调用谓词,因此,元素类型必须能转换为谓词的参数类型
    3、例如,接受一个二元谓词参数sort版本,用这个谓词代替<比较元素。我们可以按长度排序单词,如下例
    4、在我们将words按长短排序后,还希望相同长度的元素字典序排序,可以使用stable_sort,如下

    vector<string> words{"abc", "yy", "bbbb", "zzz", "aaaaa", "za"};
    
    // 比较函数
    bool isShorter(const string &s1, const string &s2)
    {
        return s1.size() < s2.size();
    }
    
    // 按长度由短至长排序
    sort(words.begin(), words.end(), isShorter);
    for (auto it : words)
        cout << it << ' ';
    
    // 按长度排序,且相同长度按字典序排序
    stable_sort(words.begin(), words.end(), isShorter);
    for (auto it : words)
        cout << it << ' ';
  • lambda 表达式

    • 引入

      1、根据算法接受一元谓词还是二元谓词,我们传递给算法的谓词也必须严格接受一个或两个参数,但有时我们希望进行的操作需要更多参数,会超出算法对谓词的限制
      2、例如我们要计算大于等于一个给定长度的单词有多少,且只打印这些单词,我们能设计出一个大体框架,如下
      3、我们可以使用标准库算法find_if来查找。其接受一对迭代器,表示范围第三个参数接受一个一元谓词。返回第一个使谓词返回非 0 值元素,如果不存在则返回尾后迭代器
      4、但是,find_if只接受一元谓词,我们会使用一个参数表示当前元素没有任何办法能传递第二个参数表示长度。为此,需要使用另外一些语言特性

      void biggie(vector<string> &words, vector<string>::size_type sz)
      {
          // 之前自定义的函数,将 words 按字典序排序,删除重复单词
          elimDups(words);
          // 先前定义的 isShorter
          stable_sort(words.begin(), words.end(), isShorter);
          // 获取一个迭代器,指向第一个满足 size() >= sz 的元素,即此后的都符合条件
          // 计算数目
          // 输出
      }
    • lambda介绍

      1、我们可以向一个算法传递任何类别可调用对象。对于一个对象表达式,如果可以对其使用调用运算法(),则称它为可调用的
      2、到目前为止,我们使用过的两种可调用对象函数函数指针,此外还有两种可调用对象重载了函数调用运算符的类(后续介绍)和lambda表达式
      3、一个lambda表达式表示一个可调用的代码单元,我们可以将其理解为一个未命名的内联函数与任何函数类似,一个lambda具有一个返回类型形参列表函数体;但与函数不同的是,lambda可能定义在函数内部
      4、一个lambda形式如:[捕获列表](参数列表) -> 返回类型{函数体}。其中,捕获列表是一个lambda所在函数中定义的局部变量的列表(通常为空),其余三者与函数一样,但是lambda必须使用尾置返回指定返回类型
      5、我们可以忽略参数列表返回类型,但必须永远包含捕获列表函数体,如auto f = []{ return 42; };。当忽略返回类型时,如果函数体只有return语句,则自动推断返回类型,否则返回void

    • lambda传参

      1、可以像给普通函数传参那样lambda传参,但注意不同的是lambda不能有默认形参
      2、如下例,我们可以编写一个isShorter()函数功能相同的lambda

      vector<string> words{"abc", "yy", "bbbb", "zzz", "aaaaa", "za"};
      
      auto lam = [](const string &a, const string &b) { return a.size() < b.size(); };
      
      // 此处 lam 也可以直接写上述 lambda 表达式
      stable_sort(words.begin(), words.end(), lam);
      for (auto it : words)
          cout << it << ' ';
    • 使用捕获列表

      1、虽然一个lambda可以出现在一个函数中使用其局部变量,但只能使用那些明确指明的变量
      2、一个lambda通过将局部变量包含在其捕获列表中来指出将使用这些变量捕获列表指引lambda在其内部访问局部变量所需的信息
      3、如下例,该lambda捕获 sz,并只有单一的string参数

      [sz](const string &a) { return a.size() >= sz; };
    • 完整的biggies()

      void biggies(vector<string> &words, vector<string>::size_type sz)
      {
          // 自定义的 elimDups,按字典序排序并删除重复单词
          elimDups(words);
      
          // 按长度排序
          auto sort_length = [](const string &a, const string &b) { return a.size() < b.size(); };
          stable_sort(words.begin(), words.end(), sort_length);
      
          // 获取一个迭代器,指向第一个满足 size() >= sz 的元素
          auto find_first = [sz](const string &a) { return a.size() >= sz; };
          auto wc = find_if(words.begin(), words.end(), find_first);
      
          // 计算数目
          auto count = words.end() - wc;
          cout << count << endl;
      
          // 打印单词
          auto print = [](const string &s) { cout << s << ' '; };
          for_each(wc, words.end(), print);   // 标准库算法,接受一个可调用对象,并对输入序列中每个元素调用此对象
      }
  • lambda 捕获和返回

    • lambda对象

      1、当定义一个lambda时,编译器生成一个lambda对应的新的类类型,后续会介绍这种类如何生成
      2、目前,可以认为当向一个函数传递lambda时,同时定义了一个新类型该类型的一个对象传递的参数就是编译器生成的类类型的未命名对象
      3、类似的,当使用auto定义一个lambda初始化的变量时,定义了一个lambda生成的类型的对象

    • lambda捕获列表

      捕获列表 描述
      [ ] 空捕获列表,lambda 不能使用所在函数中的变量
      [names] names 是一个逗号分隔的名字列表,这些名字都是 lambda 所在函数的局部变量。默认情况下,捕获列表中的变量都被拷贝,名字前如果使用了&,则采用引用捕获方式
      [&] 隐式捕获列表,采用引用捕获方式。lambda 中所使用的来自所在函数的实体都采用引用方式使用
      [=] 隐式捕获列表,采用值捕获方式。lambda 将拷贝所使用的来自所在函数的实体的值
      [&, identifier_list] identifier_list 是一个逗号分隔的列表,包含 0 个或多个来自所在函数的变量。这些变量采用值捕获方式,而任何隐式捕获的变量都采用引用方式捕获。identifier_list 名字前不能使用&
      [=, identifier_list] identifier_list 是一个逗号分隔的列表,包含 0 个或多个来自所在函数的变量。这些变量采用引用方式捕获,而任何隐式捕获的变量都采用值方式捕获。identifier_list 名字不能包括 this,而名字前必须使用&
    • 可变lambda

      1、默认情况下,对于一个值被拷贝的变量lambda不会改变其值
      2、如果我们希望能改变一个被捕获的变量的值,就必须在参数列表首加上关键字mutable。因此,可变lambda可以省略参数列表

      void func()
      {
          size_t v1 = 42;
          // f 可以改变它所捕获的变量的值
          auto f = [v1] () mutable { return ++v1; };
          v1 = 0;
          auto j = f();   // j 为 43
      }
    • 指定lambda返回类型

      1、默认情况下,如果一个lambda包含return之外的任何语句,则编译器将假定其返回void
      2、如下例,我们可以用标准库算法transform和一个lambda表达式来实现转换数字为其绝对值transform第三个参数表示目的位置
      3、使用三目运算符的版本由于是只有一条return,被解析为返回int;而使用if的版本则被解析为void因此编译错误,必须显式指定返回类型
      4、当我们需要为一个lambda定义返回类型时,必须使用尾置返回类型

      // 三目运算符版本,正确
      transform(vi.begin(), vi.end(), vi.begin(), [](int i) { return i < 0 ? -i : i; });
      
      // if 版本,错误
      transform(vi.begin(), vi.end(), vi.begin(), [](int i) { if (i < 0) return -i; else return i; });
      // if 版本,正确
      transform(vi.begin(), vi.end(), vi.begin(), [](int i) -> int { if (i < 0) return -i; else return i; });
  • 参数绑定

    • 引入

      1、对于那种只在一两个地方使用的简单操作lambda表达式是最有用的。如果我们需要在很多地方使用相同的操作,通常应该定义一个函数,而不是多次编写相同的lambda。类似的,如果一个操作需要很多语句才能完成,通常使用函数更好
      2、如果lambda捕获列表为空,通常可以用函数来代替,既可以用lambda也可以用函数来实现
      3、但是对于捕获局部变量lambda用函数代替没那么容易了。例如如果调用的一个函数接受一个一元谓词lambda可以通过捕获列表从形参获取额外信息,而函数必须解决如何作为一元谓词的问题

    • bind

      1、为了解决向作为一元谓词的函数传递多个参数的问题,方法是使用一个名为bind标准库函数,它定义在头文件functional
      2、可以将bind看作一个通用的函数适配器,它接受一个可调用对象,生成一个新的可调用对象适应原对象的形参列表
      3、调用bind的一般形式如:auto newCallable = bind(callable, arg_list)。其中,newCallable本身是一个可调用对象arg_list形参列表。当调用newCallable时,会调用callable,并使用arg_list作为callable的形参
      4、arg_list中可能出现形如_n的名字,其中n是一个整数,这些参数是占位符,它们占据了传递给newCallable的参数的位置_1newCallable第一个参数_2第二个参数,以此类推

    • 使用bind绑定参数

      1、一个简单的例子,我们将使用bind生成一个调用check_size的对象,如下
      2、此bind调用只有一个占位符,表示check6接受一个参数占位符出现在arg_list第一个位置上,表示check6的此参数对应check_size的第一个参数

      // 原函数
      bool check_size(const string &s, string::size_type sz)
      {
          return s.size() >= sz;
      }
      
      // 绑定参数
      auto check6 = bind(check_size, _1, 6);
      
      // 调用
      string s = "hello";
      bool b1 = check6(s);    // 即调用 check_size(s, 6)
      
      // 作为一元谓词
      auto wc = find_if(words.begin(), words.end(), check6);
      auto wc = find_if(words.begin(), words.end(), bind(check_size, _1, sz));
    • 使用placeholders名字

      1、名字_n都定义在命名空间placeholders中(定义在头文件functional中),而这个命名空间定义在std命名空间中,因此为了使用这些名字,两个命名空间都要写上(如std::placeholders::_1)
      2、为了更方便的使用,我们通常使用using声明:using std::placeholders::_1。但这样对于每个占位符名字都要单独声明,不仅繁琐还易出错
      3、我们可以使用另一种using声明形式using namespace namespace_name,表示希望所有来自namespace_name的名字都可以在我们的程序中直接使用,如此处可写作using namespace std::placeholders

    • bind重排参数顺序

      1、如前文所述,我们可以用bind修正参数的值,更一般的,可以用bind绑定给定可调用对象中的参数或是重新安排其顺序
      2、例如,func是一个可调用对象,它有5 个参数,则允许下面的调用

      auto g = bind(func, a, b, _2, c, _1);
      
      g(d, e);    // 等同于 func(a, b, e, c, d)
    • 绑定引用参数

      1、默认情况下,bind的那些不是占位符的参数拷贝返回的可调用对象中。但与lambda类似,有时对于有些绑定的参数我们希望以引用方式传递,或是希望绑定参数的类型无法拷贝
      2、如下例,为了替换一个引用方式捕获ostreamlambda,可以很容易编写一个函数完成相同工作,但不能直接用bind代替对os的捕获。原因在于bind拷贝其参数,而我们不能拷贝一个ostream
      3、如果我们希望传给bind一个对象不拷贝它,必须使用标准库函数ref,它定义在头文件functional中。ref返回一个对象,包含给定的引用,此对象是可以拷贝的标准库还有一个cref,生成一个保存const引用的类

      // os 是一个局部变量,引用一个输出流
      // c 是一个局部变量,类型为 char
      for_each(words.begin(), words.end(), [&os, c](const string &s) { os << s << c; });
      
      // 同功能函数
      ostream &print(ostream &os, char c, const string &s)
      {
          return os << s << c;
      }
      // 错误:不能拷贝 os
      for_each(words.begin(), words.end(), bind(print, os, ' ', _1));
      
      // 使用 ref
      for_each(words.begin(), words.end(), bind(print, ref(os), ' ', _1));

再探迭代器

  • 标准库迭代器(头文件iterator)

    迭代器类型 描述
    插入迭代器 这些迭代器被绑定到一个容器上,可用来向容器插入元素
    流迭代器 这些迭代器被绑定到输入或输出流上,可用来遍历所有关联的 IO 流
    反向迭代器 这些迭代器向后而不是向前移动。除了 forward_list 外标准库容器都有反向迭代器
    移动迭代器 这些专用的迭代器不是拷贝其中的元素,而是移动它们(将在后续介绍)
  • 插入迭代器

    • 插入迭代器操作

      1、插入器是一种迭代器适配器,它接受一个容器生成一个迭代器
      2、当我们通过一个插入迭代器进行赋值时,该迭代器调用容器操作来向给定容器的指定位置插入一个元素,下表列出了插入迭代器支持的操作

      操作 描述
      it = t 在 it 指定的当前位置插入值 t。假定 c 是 it 绑定的容器,依赖于插入迭代器不同种类,赋值分别调用 c.push_back(t)、c.push_front(t)、c.insert(t,p)
      *it,++it,it++ 虽然这些操作存在,但不会对 it 做任何事情,每个操作都返回 it
    • 插入迭代器类型

      1、插入迭代器三种类型,差异在于元素插入的位置
      2、只有在容器支持push_front操作时才能使用front_inserter;只有在容器支持push_back操作时才能使用back_inserter

      类型 描述
      back_inserter 创建一个使用 push_back 的迭代器,元素插入到容器末尾
      front_inserter 创建一个使用 push_front 的迭代器,元素插入到容器首端
      inserter 创建一个使用 insert 的迭代器,此函数接受第二个参数,其必须是指向给定容器的一个迭代器,表示插入位置,元素将插入到给定迭代器表示的元素之前
  • iostream 迭代器

    • 引入

      1、虽然iostream类型不是容器,但标准库定义了可以用于这些IO 类型对象迭代器
      2、istream_iterator读取输入流ostream_iterator向一个输出流写数据
      3、这些迭代器将它们对应的流当做一个特定类型的元素序列来处理,通过使用流迭代器,我们可以用泛型算法流对象读写数据

    • istream_iterator操作

      1、当创建一个流迭代器时,必须指定迭代器将要读写的对象类型。一个istream_iterator使用>>读取流,所以要读取的类型必须定义了>>运算符。下表列出了istream_iterator的操作
      2、当创建一个istream_iterator时,我们可以将它绑定到一个流。此外还可以默认初始化迭代器,这样就创建了一个可以当做尾后值使用的迭代器,如下
      3、如下例,使用istream_iterator从标准输入读取数据存入一个vector。此外我们还可以重写这部分代码,以体现流迭代器更有用的地方,即构造容器

      istream_iterator 操作 描述
      istream_iterator<T> in(is) in 从输入流 is 读取类型为 T 的值
      istream_iterator<T> end 读取类型为 T 的值的 istream_iterator 迭代器,表示尾后位置
      in1 == in2,in1 != in2 in1 和 in2 必须读取相同类型。如果它们都是尾后迭代器,或绑定到相同输入,则两者相等
      *in 返回从流中读取的值
      in->mem 与(*in).mem 含义相同
      ++in,in++ 使用元素类型所定义的>>运算符从输入流中读取下一个值。前置版本返回一个指向递增后迭代器的引用,后置版本返回旧值
      // 使用示例
      istream_iterator<int> int_it(cin);        // 从 cin 读取 int
      istream_iterator<int> int_eof;            // 尾后迭代器
      ifstream in("afile");                     // 创建一个文件输入流与 afile 绑定
      istream_iterator<string> str_it(in);      // 从 afile 读取字符串
      
      // 从 istream_iterator 读入数据存入 vector
      istream_iterator<int> in_iter<cin>;       // 从 cin 读取 int
      istream_iterator<int> eof;                // istream 尾后迭代器
      while(in_iter != eof)                     // 当有数据可供读取时
          // 后值递增运算读取流,返回迭代器的旧值
          // 解引用迭代器,获得从流读取的前一个值
          vec.push_back(*in_iter++);
      
      // 重写优化
      istream_iterator<int> in_iter(cin), eof;  // 从 cin 读取 int
      vector<int> vec(in_iter, eof);            // 从迭代器范围构造 vec
    • 使用算法操作流迭代器

      1、由于算法使用迭代器处理数据,而流迭代器又至少支持某些迭代器操作,因此我们可以用某些算法操作流迭代器
      2、如下例,我们可以用一对istream_iterator来调用accumulate,该调用会计算从标准输入读取的值的和

      istream_iterator<int> in(cin), eof;
      cout << accumulate(in, eof, 0) << endl;
    • ostream_iterator操作

      1、我们可以对任何具有<<运算符类型定义ostream_iterator,下表列出了ostream_iterator的操作
      2、当创建一个ostream_iterator时,可以提供可选的第二参数,它是一个C 风格字符串,在输出每个元素后都会打印此字符串
      3、必须将ostream_iterator绑定到一个指定流,不允许空的表示尾后位置的ostream_iterator
      4、如下例,我们可以用ostream_iterator输出值的序列。由于解引用递增运算没有实意,可以省略。我们还可以调用copy算法更方便地打印元素

      ostream_iterator 操作 描述
      ostream_iterator<T> out(os) out 将 类型为 T 的值写到输出流 os 中
      ostream_iterator<T> out(os, d) out 将 类型为 T 的值写到输出流 os 中,每个值后都输出一个 d,d 必须是 C 风格字符串
      out = val 用 << 运算符将 val 写入到 out 所绑定的 ostream 中,val 的类型必须与 out 可写的类型兼容
      *out,++out,out++ 虽然存在这些运算符,但不对 out 做任何事情,每个运算符都返回 out
      ostream_iterator<int> out_iter(cout, " ");
      for(auto e : vec)
          // 也可以直接写为:
          // out_iter = e;
          *out_iter++ = e;
      
      // 使用算法操作
      copy(vec.begin(), vec.end(), out_iter);
  • 反向迭代器

    1、反向迭代器就是在容器中从尾元素向首元素反向移动的迭代器。对于反向迭代器递增(或递减)操作的含义会颠倒过来(即++移动到前一个元素)
    2、除了forward_list外,其他容器都支持反向迭代器,我们可以通过调用rbeginrendcrbegincrend成员函数来获得反向迭代器,它们返回指向容器尾元素首前元素位置的迭代器

泛型算法结构

  • 引入

    1、任何算法最基本的特性是它要求其迭代器提供哪些操作算法要求迭代器操作可以分为五个迭代器类型每个算法都会对它的每个迭代器参数指明须提供哪类迭代器
    2、第二种算法分类的方式是按照是否读写重排序列元素
    3、算法还共享一组参数传递规范和一组命名规范,我们将在后续介绍

  • 五类迭代器

    • 介绍

      1、类似容器迭代器也定义了一组公共操作。一些操作所有迭代器都支持,另外一些只有特定迭代器才支持
      2、迭代器是按照它们所提供的操作分类的,分为五类迭代器,如下表。这种分类形成了一种层次,除了输出迭代器外,一个高层类型的迭代器能完全支持低层类别的迭代器的所有操作
      3、C++标准指明了泛型和数值算法的每个迭代器参数最小类别,即对每个迭代器参数来说,其能力必须与规定的最小类别至少相当。向算法传递一个能力更差的迭代器产生错误,对于这类错误,很多编译器不会给出任何警告

      迭代器类别 描述
      输入迭代器 只读不写,单遍扫描,只能递增
      输出迭代器 只写不读,单遍扫描,只能递增
      前向迭代器 可读写,多遍扫描,只能递增
      双向迭代器 可读写,多遍扫描,可递增递减
      随机访问迭代器 可读写,多遍扫描,支持全部迭代器运算
    • 输入迭代器

      • 介绍

        1、输入迭代器可以读取序列的元素,且输入迭代器只用于顺序访问
        2、对于一个输入迭代器*it++保证是有效的,但递增它可能导致所有其他指向流的迭代器失效,其结果就是不能保证输入迭代器的状态可以保存用来访问元素。因此,输入迭代器只能用于单遍扫描算法
        3、istream_iterator是一种输入迭代器

      • 输入迭代器必须支持的操作

        1、用于比较两个迭代器==!=运算符
        2、用于推进迭代器的前置和后置递增运算符++
        3、用于读取元素解引用运算符*解引用只会出现在赋值运算符右侧
        4、箭头运算符->,等价于(*it).mem

    • 输出迭代器

      • 介绍

        1、输出迭代器的特点是只写不读元素,且我们只能向输出迭代器赋值一次
        2、类似输入迭代器输出迭代器也只能用于单遍扫描算法用作目的位置迭代器通常都是输出迭代器,如copy第三个表示目的位置的迭代器参数就是输出迭代器
        3、ostream_iterator是一种输出迭代器

      • 输出迭代器必须支持的操作

        1、用于推进迭代器的前置和后置递增运算符++
        2、解引用运算符*解引用只会出现在赋值运算符左侧(向一个已经解引用的输出迭代器赋值,就是将值写入它所指的元素)

    • 前向迭代器

      1、前向迭代器可以读写元素,这类迭代器只能在序列中沿一个方向移动
      2、前向迭代器支持所有输入输出迭代器的操作,而且可以多次读写同一个元素,因此我们可以保存前向迭代器的状态算法可以对序列多次扫描
      3、算法replace要求前向迭代器forward_list上的迭代器前向迭代器

    • 双向迭代器

      1、双向迭代器可以正向反向读写元素
      2、除了支持所有前向迭代器的操作外,还支持前置和后置递减运算符--
      3、算法reverse要求双向迭代器,除了forward_list外,其他标准库都提供符合双向迭代器要求迭代器

    • 随机访问迭代器

      • 介绍

        1、随机访问迭代器提供在常量时间O(1)访问任意元素的能力
        2、此类迭代器除了支持双向迭代器的操作,还支持一些其他操作
        3、算法sort要求随机访问迭代器arraydequestringvector迭代器都是随机访问迭代器,用于访问内置数组元素指针也是

      • 随机访问迭代器额外支持的操作

        1、用于比较两个迭代器相对位置关系运算符<<=>>=
        2、迭代器和一个整数值加减运算,计算结果是在序列中前进(或后退)给定整数个元素的位置
        3、用于两个迭代器上的减法运算,得到两个迭代器的距离
        4、下标运算符iter[n],与*(iter[n])等价

  • 算法参数规范

    1、在任何其他算法分类之上,还有一组参数规范。理解参数规范可以更方便得知算法需要的参数及操作大多算法都具有如下四种形式之一
    2、alg算法的名字begend表示算法所操作的输入范围几乎所有算法都接受一个输入范围是否有其他参数依赖于要执行的操作destbeg2end2都是可以顾名思义的迭代器参数
    3、除了这些迭代器参数,一些算法还接受额外的非迭代器特定参数

    alg(beg, end, other_args);
    alg(beg, end, dest, other_args);
    alg(beg, end, beg2, other_args);
    alg(beg, end, beg2, end2, other_args);
  • 算法命名规范

    1、除了参数规范,算法还遵循一套命名和重载规范
    2、一些算法使用重载形式传递一个谓词。接受谓词参数来代替<==运算符的算法,以及不接受额外参数的算法,通常都是重载的函数,如unique
    3、一些算法有_if版本接受一个元素值的算法通常有另一个不同名的版本(非重载版本),其接受一个谓词代替元素值,这些算法都有_if后缀,如find
    4、一些算法有区分拷贝和不拷贝的版本。默认情况下,重排元素的算法将重排后的元素写回给定输入序列中,这些算法还提供另一个版本将元素写到指定输出目的位置,其名字后附加_copy后缀,如reverse

    unique(beg, end);             // 使用 == 运算符比较元素
    unique(beg, end, comp);       // 使用 comp 比较元素
    
    find(beg, end, val);          // 查找输入范围中 val 第一次出现的位置
    find_if(beg, end, pred);      // 查找第一个令 pred 为真的元素
    
    reverse(beg, end);            // 反转输入范围中元素的顺序
    reverse_copy(beg, end, dest); // 将元素按逆序拷贝到 dest

特定容器算法

  • 链表类型的成员算法

    1、与其他容器不同,链表类型listforward_list定义了几个成员函数形式的算法,这些算法都返回void,如下表
    2、链表类型定义的其他算法的通用版本也可以用于链表,但代价太高。这些通用版本算法需要交换元素,而链表可以通过改变元素间的链接快速交换元素。因此,链表版本的算法比通用版本的算法性能好得多,应当优先使用成员函数版本的算法

    list 和 forward_list 的成员算法 描述
    lst.merge(lst2),lst.merge(lst2, comp) 将来自 lst2 的元素合并入 lst。lst 和 lst2 都必须是有序的,元素将从 lst2 中删除,合并后 lst2 变为空。第一个版本使用<运算符,第二个版本使用 comp 给定的比较操作
    lst.remove(val),lst.remove_if(pred) 调用 erase 删除与给定值相等或令一元谓词 pred 为真的每个元素
    lst.reverse() 反转 lst 中元素的顺序
    lst.sort(),lst.sort(comp) 使用<或 comp 给定的比较操作排序元素
    lst.unique(),lst.unique(pred) 调用 erase 删除同一个值的连续拷贝。第一个版本使用==,第二个版本使用 pred 给定的二元谓词
  • splice 成员

    1、链表类型还定义了splice算法,其形式如lst.splice(args)flst.splice_after(args),参数描述如下表
    2、此算法是链表数据结构特有的,因此不需要通用版本

    splice 的 args 参数 描述
    (p, lst2) p 是一个指向 lst 中元素的迭代器,或一个指向 flst 首前位置的迭代器。函数将 lst2 的所有元素移动到 lst 中 p 之前的位置或是 flst 中 p 之后的位置,将元素从 lst2 中删除。lst2 不能是与 lst 或 flst 相同的链表
    (p, lst2, p2) p2 是一个指向 lst2 中位置的有效迭代器。函数将 p2 指向的元素移动到 lst 中,或将 p2 之后的元素移动到 flst 中。lst2 可以是与 lst 或 flst 相同的链表
    (p, lst2, b, e) b 和 e 表示 lst2 中的合法范围。函数将给定范围中的元素从 lst2 移动到 lst 或 flst。lst2 可以是与 lst 或 flst 相同的链表,但 p 不能指向给定范围中的元素
  • 链表特有的操作会改变容器

    1、多数链表特有的算法与其通用版本很相似,但不完全相同,二者至关重要的区别是链表版本会改变底层的容器
    2、例如,通用版本merge合并的序列写入到一个给定的目的迭代器两个输入序列不变;而链表版本merge销毁给定的链表元素从指定链表中删除,被合并到调用merge的链表中


关联容器


章节概要:使用关联容器;mapset;关联容器概述;通用操作;重复关联容器;关键字类型要求;关键字类型的比较函数;pair类型;创建pair的函数;关联容器操作;关联容器迭代器;迭代器解引用;关联容器和算法;添加元素;insert操作;insert返回值;删除元素;erase操作;map下标操作;下标操作的返回值;访问元素;在multimapmultiset中查找元素;示例:单词转换程序;无序容器;使用无序容器;管理桶;关键字类型要求

使用关联容器

  • 关联容器类型

    1、关联容器支持高效的关键字查找和访问。两个主要的关联容器mapset
    2、map中的元素是一些键值对,其中关键字起到索引作用则表示与索引相关联的数据set每个元素只包含一个关键字,可以快速检查一个给定关键字是否在 set 中
    3、类型mapmultimap定义在头文件map中,类型setmultiset定义在头文件set中,相对应的无序容器定义在头文件unordered_mapunordered_set

    关联容器类型 描述
    map 关联数组,保存键值对
    set 只保存关键字的容器
    multimap 关键字可重复出现的 map
    multiset 关键字可重复出现的 set
    unordered_map 用哈希函数组织的无序 map
    unordered_set 用哈希函数组织的无序 set
    unordered_multimap 用哈希函数组织的无序 map,关键字可重复出现
    unordered_multiset 用哈希函数组织的无序 set,关键字可重复出现
  • 使用 map

    1、map键值对的集合。例如,可以将人名作为关键字,将电话号码作为,我们称这样的数据结构为将名字映射到电话号码
    2、map常被称为关联数组关联数组普通数组类似,不同之处在于其下标不必是整数,我们通过关键字而不是位置来查找值
    3、类似顺序容器关联容器也是模板。因此定义一个map,必须指定关键字和值的类型
    4、从map提取元素时,会得到一个pair类型对象(一个模板类型,保存名为 first 和 second 的公有数据成员),map所使用的pairfirst保存关键字second保存

    // 统计每个单词出现的次数
    map<string, int> word_count;   // 创建 string 到 int 的空 map
    string word;
    while(cin >> word)
        word_count[word]++;           // 提取关键字 word 的计数器并累加
    // 打印结果
    for(const auto &w : word_count)
        cout << w.first << ":  " << w.second << endl;
  • 使用 set

    1、set关键字的集合。当只是想知道一个值是否存在时,set是最有用的
    2、set也是一个模板定义一个set必须指定其元素类型
    3、find成员返回一个迭代器,如果给定关键字在set,则迭代器指向该关键字,否则指向尾后迭代器
    4、对于上面使用map的例子,我们可以额外使用set实现忽略统计某些单词,如下

    // 统计每个单词出现的次数
    map<string, int> word_count;  // 创建 string 到 int 的空 map
    set<string> exclude = {"the", "but", "and", "or", "an", "a"};
    string word;
    
    while(cin >> word)
        // 只统计不在 exclude 中的单词
        if(exclude.find(word) == exclude.end())
            word_count[word]++;   // 提取关键字 word 的计数器并累加

关联容器概述

  • 通用操作

    1、关联容器支持所有的普通容器操作(见 9.2 节)
    2、但不支持顺序容器的位置相关的操作,例如push_frontpush_back,原因是关联容器中的元素根据关键字存储的,这些操作对关联容器没有意义
    3、此外,关联容器不支持构造函数或插入操作这些接受一个元素值和一个数量值的操作

  • 重复关联容器

    1、一个mapset关键字必须唯一,即对于一个给定关键字只能有一个元素与之对应。而multimapmultiset没有此限制,即多个元素都可以具有相同的关键字
    2、下例展示了具有唯一关键字的容器允许重复关键字的容器的区别

    // 定义有 20 个元素的 vector,保存 0-9 每个整数的两个拷贝
    vector<int> ivec;
    for(vector<int>::size_type i = 0; i != 10; i++)
    {
        ivec.push_back(i);
        ivec.push_back(i);            // 重复保存一次
    }
    // iset 包含来自 ivec 的不重复的元素,miset 包含所有 20 个元素
    set<int> iset(ivec.begin(), ivec.end());
    multiset<int> miset(ivec.begin(), ivec.end());
    cout << ivec.size() << endl;      // 打印出 20
    cout << iset.size() << endl;      // 打印出 10
    cout << miset.size() << endl;     // 打印出 20
  • 关键字类型要求

    • 有序容器的关键字类型

      1、对于有序容器关键字类型必须定义元素比较的方法。默认情况下,标准库使用关键字类型<运算符来比较两个关键字
      2、在集合类型(set)中,元素类型就是关键字类型;在映射类型(map)中,元素的第一部分的类型就是关键字类型
      3、先前可以向一个算法提供我们自定义的比较操作,与之类似,也可以提供自定义的操作来代替关键字上的<运算符
      4、但所定义的操作必须在关键字类型上定义一个严格弱序(可以看做<=),无论我们怎样定义比较函数,它必须具备下列性质
      5、实际编程中,如果一个类型定义了行为正常的<运算符,则它可以用作关键字类型

    • 比较函数所需的性质

      1、两个关键字不能同时<=对方
      2、如果k1<=k2,且k2<=k3,那么必须也有k1<=k3
      3、如果存在两个关键字,任何一个都不<=对方,那么称这两个关键字等价(容器将它们视作相等)。如果k1 等价于 k2,且k2 等价于 k3,那么必须也有k1 等价于 k3

    • 关键字类型的比较函数

      1、用来组织一个容器中元素操作的类型也是容器类型的一部分。为了指定使用自定义的操作,必须在定义关联容器类型时提供操作的类型,在尖括号中紧跟元素类型给出
      2、如下例,我们定义了一个比较函数,我们希望创建一个multiset使用该函数为元素排序。定义multiset时必须提供两种类型:一个关键字类型和一个函数指针类型
      3、bookstore(compareIsbn)使用传入函数地址初始化对象,表示当我们bookstore添加元素时,通过调用compareIsbn为这些元素排序

      // 比较函数
      bool compareIsbn(Sales_data &lhs, Sales_data &rhs)
      {
          return lhs.isbn() < rhs.isbn();
      }
      
      // bookstore 中的多条记录可以有相同的 ISBN
      // bookstore 中的元素以 ISBN 的顺序进行排列
      // 尖括号中 Sales_data 为关键字类型,decltype(compareIsbn)* 为指向 compareIsbn() 的函数指针
      multiset<Sales_data, decltype(compareIsbn)*> bookstore(compareIsbn);
  • pair 类型

    • pair类型与操作

      1、在介绍关联容器操作之前,我们需要了解名为pair标准库类型,它定义在头文件utility
      2、一个pair保存两个数据成员。类似容器pair是一个用来生成特定类型模板。因此当创建一个pair时,必须提供两个类型名,其两个数据成员将分别具有对应的类型,两个类型不要求一致pair<string, vector<int>> line
      3、pair默认构造函数数据成员进行值初始化,我们也可以为每个成员提供初始化器pair<string, string> author{"James", "Joyce"}
      4、与其他标准库类型不同,pair数据成员public的,我们可以通过成员访问符直接访问
      5、标准库定义了如下几个pair操作

      pair 操作 描述
      pair<T1,T2> p; p 是一个 pair,两个类型分别为 T1 和 T2 的成员都进行了值初始化
      pair<T1,T2> p(v1,v2); p 是一个成员类型为 T1 和 T2 的 pair,first 和 second 成员分别用 v1 和 v2 进行初始化
      pair<T1,T2> p = {v1,v2}; 等价于 p(v1,v2)
      make_pair(v1,v2) 返回一个用 v1 和 v2 初始化的 pair,pair 类型由 v1 和 v2 的类型推断
      p.first 返回 p 的名为 first 的公有数据成员
      p.second 返回 p 的名为 second 的公有数据成员
      p1 < p2 关系运算符按字典序定义,利用元素的 < 运算符来实现
      p1 == p2,p1 != p2 当 first 和 second 分别相等时,则 pair 相等
    • 创建 pair 的函数

      1、如果一个函数需要返回一个pair。在新标准下,我们可以对返回值进行列表初始化
      2、在早期版本中,不允许使用花括号包裹的初始化器返回pair类型对象,必须显式构造返回值

      // 返回 pair 的函数
      pair<string, int> process(vector<string> &v)
      {
          // 处理 v
          if(!v.empty())
              return {v.back(), v.back().size()};                   // 列表初始化
              return make_pair(v.back(), v.back().size());          // 使用 make_pair
              return pair<string, int>(v.back(), v.back().size());  // 早期版本
          else
              return pair<string, int>();                           // 隐式构造返回值
      }

关联容器操作

  • 关联容器迭代器

    • 额外的类型别名

      类型别名 描述
      key_type 此容器类型的关键字类型
      mapped_type 每个关键字关联的类型,只适用于 map
      value_type 对于 map,为 pair<const key_type, mapped_type>,对于 set,与 key_type 相同
    • 迭代器解引用

      1、当解引用一个关联容器迭代器时,会得到一个类型为容器value_type值的引用
      2、如下例,对map而言,value_type是一个pair类型,但要注意mapvalue_type中的key_typeconst的(即表示索引名的first不可改变)
      3、set迭代器类型同时定义了iteratorconst_interator,但两种类型都只允许只读访问(即索引名不可改变)
      4、遍历关联容器的方法与先前遍历顺序容器的方法类似,使用beginend成员完成

      // 获得指向 word_count 中一个元素的迭代器
      map<string, int> word_count;
      auto map_it = word_count.begin();
      // *map_it 是一个指向 pair<const string, int> 对象的引用
      cout << map_it->first << ' ' << map_it->second;
      map_it->first = "new key";        // 错误:关键字是 const 的,不能改变索引名
      map_it->second++;                 // 正确:可以通过迭代器改变元素的值
    • 关联容器和算法

      1、我们通常不对关联容器使用泛型算法,因为关键字const意味着不能将关联容器传递给修改或重排容器元素的算法
      2、虽然关联容器可用于只读元素的算法,但是很多这类算法都要搜索序列。由于关联容器的元素不能通过它们的关键字快速查找,因此使用搜索算法几乎总是个坏主意
      3、关联容器定义了一个名为find成员函数,通过一个给定的关键字直接获取元素。泛型算法库中也有一个find算法,但此算法会进行顺序搜索,因此使用关联容器定义的find成员函数调用泛型find好得多
      4、实际编程中,如果我们真要对一个关联容器使用算法,要么是将它当做一个源序列,要么是当做一个目的位置

  • 添加元素

    • insert操作

      1、关联容器insert成员向容器中添加一个元素一个元素范围
      2、由于mapset包含不重复的关键字,因此插入已存在的元素对容器没有任何影响
      3、对一个map进行insert操作时,必须记住元素类型pair,我们可以在insert参数列表中创建一个pair,此外还另有如下三种方法

      // 重复元素对容器没有影响
      vector<int> ivec = {2,4,6,8,2,4,6,8};     // ivec 有 8 个元素
      set<int> set2;                            // 空集合
      set2.insert(ivec.cbegin(), ivec.cend());  // set2 有 4 个元素
      set2.insert({1,3,5,7,1,3,5,7});           // set2 现在有 8 个元素
      
      // 向 map 添加元素的 4 种方法
      map<string, size_t> word_count;
      word_count.insert({word, 1});
      word_count.insert(make_pair(word, 1));
      word_count.insert(pair<string, size_t>(word, 1));
      word_count.insert(map<string, size_t>::value_type(word, 1));
      insert 操作 描述
      c.insert(v),c.emplace(args) v 是 value_type 类型的对象,args 用来构造一个元素。对于 map 和 set,只有当元素的关键字不在 c 中时才插入(或构造)元素,函数返回一个 pair,包含一个迭代器,指向具有指定关键字的元素,以及一个指示插入是否成功的 bool 值。对于 multimap 和 multiset,总会插入(或构造)给定元素,并返回一个指向新元素的迭代器
      c.insert(b,e),c.insert(il) b 和 e 是迭代器,表示一个 c::value_type 类型值的范围,il 是这种值的花括号列表。函数返回 void
      c.insert(p,v),c.insert(p,args) 类似 insert(v) 或 emplace(v),但将迭代器 p 作为一个指示,指出从哪里开始搜索新元素应该存储的位置。返回一个迭代器,指向具有给定关键字的元素
    • insert返回值

      1、insertemplace返回值依赖于容器类型和参数。对于不包含重复关键字的容器,它们返回一个pair,其first指向具有给定关键字的元素second为一个bool值,如果关键字已在容器中则为false
      2、作为一个例子,我们用insert重写单词计数程序,如下例

      map<string, size_t> word_count;
      string word;
      while(cin >> word)
      {
          // 插入一个元素,关键字等于 word,值为 1
          // ret 记录返回值
          auto ret = word_count.insert({word, 1});
          // 如果已经存在,则 insert 什么也没做,手动 +1
          if(!ret.second)
              ret.first->second++;    // 递增计数器,等价于 word_count[word]++
              // ret.first 是指向具有该关键字的元素的迭代器
              // ret.first->second 该元素的计数器部分(second)
      }
  • 删除元素

    • erase操作

      erase 操作 描述
      c.erase(k) 从 c 中删除每个关键字为 k 的元素。返回一个 size_type 值,指出删除的元素的数量
      c.erase(p) 从 c 中删除迭代器 p 指定的元素。p 必须指向 c 中一个真实元素,不能等于 c.end()。返回指向 p 之后元素的迭代器,若 p 指向 c 中的尾元素,则返回 c.end()
      c.erase(b,e) 删除迭代器对 b 和 e 所表示的范围中的元素。返回 e
  • map 下标操作

    • 下标操作

      1、mapunordered_map提供了下标运算符和一个对应的at函数
      2、set类型不支持下标,因为set中没有与关键字相关联
      3、我们不能对一个multimap或一个unordered_multimap进行下标操作,因为这些容器中可能有多个值与一个关键字相关联
      4、下标和at操作只适用于非constmapunordered_map

      下标操作 描述
      c[k] 返回关键字为 k 的元素;如果 k 不在 c 中,添加一个关键字为 k 的元素,对其进行值初始化
      c.at(k) 访问关键字为 k 的元素,带参数检查;若 k 不在 c 中,抛出一个 out_of_range 异常
    • 下标操作的返回值

      1、map下标运算符与我们用过的其他下标运算符的一个不同之处返回类型。通常,解引用一个迭代器返回的类型和下标运算符返回的类型是一样的,但对map则不然
      2、当对一个map进行下标操作时,会获得一个mapped_type对象;当解引用一个map时,会得到一个value_type对象
      3、相同的是,map下标运算符返回一个左值,这意味着我们既可以读也可以写元素
      4、如果关键字还未在map下标运算符添加一个新元素,这一特性允许我们编写出十分简洁的程序

      cout << word_count["Anna"];
      ++word_count["Anna"];
      cout << word_count["Anna"];
  • 访问元素

    • 访问操作

      1、关联容器提供多种查找一个指定元素的方法,如下表。具体应该使用哪个操作依赖于我们要解决什么问题
      2、对于不允许重复关键字的容器,使用findcount没什么区别;对于允许重复关键字的容器,count额外还会统计有多少个元素有相同的关键字
      3、lower_boundupper_bound不适用于无序容器

      set<int> iset = {0,1,2,3,4,5,6,7,8,9};
      iset.find(1);       // 返回一个迭代器,指向 key == 1 的元素
      iset.find(11);      // 返回一个迭代器,其值等于 iset.end()
      iset.count(1);      // 返回 1
      iset.count(11);     // 返回 0
      
      // 使用 find 检查一个元素是否存在
      if(word_count.find("foobar") == word_count.end())
      访问操作 描述
      c.find(k) 返回一个迭代器,指向第一个关键字为 k 的元素,若 k 不在容器中,则返回尾后迭代器
      c.count(k) 返回关键字等于 k 的元素的数量
      c.lower_bound(k) 返回一个迭代器,指向第一个关键字不小于 k 的元素(首个具有给定关键字的元素的位置)
      c.upper_bound(k) 返回一个迭代器,指向第一个关键字大于 k 的元素(最后一个匹配给定关键字的元素之后的位置)
      c.equal_range(k) 返回一个迭代器 pair,表示关键字等于 k 的元素的范围。若 k 不存在,pair 的两个成员均等于 c.end()
    • multimapmultiset中查找元素

      1、在一个不允许重复关键字的关联容器中查找一个元素是一件很简单的事情——元素要么在容器中,要么不在;但对于允许重复关键字的容器过程更为复杂:在容器中可能有很多元素具有给定的关键字
      2、如果一个multimapmultiset中有多个元素具有给定关键字,则这些元素在容器中会相邻储存
      3、例如,给定一个从作者到书籍的映射,我们可能想打印一个特定作者的所有著作,共有三种方法解决这个问题

      // 最直观的方法:使用 find 和 count
      
      string search_item("Alain de Botton");        // 要查找的作者
      auto entries = authors.count(search_item);    // 元素的数量
      auto iter = authors.find(search_item);        // 作者的第一本书
      // 用一个循环来查找此作者的所有著作
      while(entries)
      {
          cout << iter->second << endl;             // 打印书籍名
          ++iter;                                   // 前进到下一本书
          --entries;                                // 记录已经打印了多少本书
      }
      // 面向迭代器的解决方法:使用 lower_bound 和 upper_bound
      
      string search_item("Alain de Botton");        // 要查找的作者
      // beg 和 end 表示对应此作者的元素的范围
      for(auto beg = authors.lower_bound(search_item), end = authors.upper_bound(search_item); beg != end; beg++)
          cout << beg->second << endl;              // 打印书籍名
      // 最直接的方法:使用 euqal_range
      
      string search_item("Alain de Botton");        // 要查找的作者
      // pos 保存迭代器时,表示与关键字匹配的元素范围,是一个 pair
      for(auto pos = authors.equal_range(search_item); pos.first != pos.second; pos.first++)
          cout << pos.first->second << endl;        // 打印书籍名
  • 示例:单词转换程序

    • 需求描述:向程序输入一段文字,将如下单词转换成对应单词

      where r u
      y dont u send me a pic
      k thk 18r
      where are you
      why dont you send me a picture
      okay? thanks! later
    • 单词转换程序

      1、我们的程序将使用三个函数来实现这些功能
      2、函数word_transform管理整个过程,它接受两个ifstream参数:第一个参数绑定到单词转换规则文件,第二个参数绑定到要转换的文本文件
      3、函数buildMap读取转换规则文件,并创建一个map,用于保存每个单词到其转换内容映射
      4、函数transform接受一个string,如果存在转换规则,返回转换后的内容

      void word_transform(ifstream &map_file, ifstream &input)
      {
          auto trans_map = buildMap(map_file);        // 调用 buildMap,保存转换规则
          string text;                                // 保存输入中的每一行
          while(getline(input, text))                 // 读取一行输入
          {
              istringstream stream(text);             // 字符输入流,用于读取每个单词
              string word;
              bool firstword = true;                  // 控制是否打印空格
              while(stream >> word)                   // 读取一个单词
              {
                  if(firstword)
                      firstword = false;
                  else
                      cout << ' ';                    // 单词之间打印一个空格
                  cout << transform(word, trans_map); // 调用 transform,翻译并输出
              }
              cout << endl;                           // 完成一行的转换,打印换行
          }
      }
      map<string, string> buildMap(ifstream &map_file)
      {
          map<string, string> trans_map;              // 保存转换规则
          string key;                                 // 要转换的单词
          string value;                               // 替换后的内容
          // 读取第一个单词存入 key 中,行中剩余内容存入 value
          while(map_file >> key && getline(map_file, value))
              if(value.size() > 1)                    // 检查是否有转换规则
                  trans_map[key] = value.substr(1);   // 跳过前导空格
              else
                  throw runtime_error("no rule for" + key);
          return trans_map;
      }
      const string &transform(const string &s, const map<string, string> &m)
      {
          // 实际的转换工作;此部分是程序的核心
          auto map_it = m.find(s);
          // 如果单词在转换规则 map 中
          if(map_it != m.cend())
              return map_it->second;                  // 使用替换短语
          else
              return s;                               // 否则返回原 string
      }

无序容器

  • 介绍

    1、新标准定义了4 个无序关联容器,这些容器不使用比较运算符组织元素,而是使用一个哈希函数关键字类型的==运算符
    2、在关键字类型的元素没有明显的序关系的情况下,无序容器是非常有用的。在某些应用中,维护元素的序代价非常高时,无序容器也很有用
    3、理论上哈希技术能获得更好的平均性能,但实际中还需要进行性能测试和调优。不过因此,使用无序容器通常更为简单,也有更好的性能

  • 使用无序容器

    1、除了哈希管理操作之外,无序容器还提供了与有序容器相同的操作(如findinsert等)
    2、这意味着我们曾用于mapset的操作也能用于unordered_mapunordered_set。类似的,无序容器也有允许重复关键字的版本
    3、因此,通常可以用一个无序容器替换对应的有序容器,反之亦然。但是由于元素未按顺序存储,其输出与有序容器的版本不同

  • 管理桶

    1、无序容器在存储上组织为一组桶每个桶保存零个或多个元素无序容器使用一个哈希函数元素映射到,为了访问一个元素,容器首先计算元素的哈希值,它指出应该搜索哪个桶
    2、容器将具有特定哈希值所有元素保存在相同桶中。如果容器允许重复关键字,所有具有相同关键字的元素也都会保存在相同桶中。因此,无序容器的性能依赖于哈希函数的质量桶的数量与大小
    3、对于相同的参数哈希函数必须总是产生相同结果。理想情况下,哈希函数还能将每个特定的值映射到唯一的桶,但将不同关键字的元素映射到相同的桶也是允许的。当一个桶保存多个元素时,需要顺序搜索这些元素来查找目标元素
    4、无序容器提供了一组管理桶的函数,如下表。它们将允许我们查询容器状态及必要时强制容器进行重组

    无序容器管理操作 描述
    c.bucket_count() 正在使用的桶的数量
    c.max_bucket_count() 容器能容纳的最多的桶的数量
    c.bucket_size(n) 第 n 个桶有多少个元素
    c.bucket(k) 关键字为 k 的元素在哪个桶里
    local_iterator 可以用来访问桶中元素的迭代器类型
    const_local_iterator 桶迭代器的 const 版本
    c.begin(n),c.end(n) 桶 n 的首元素迭代器和尾后迭代器
    c.cbegin(n),c.cend(n) 与前两个函数类型,返回 const_local_iterator
    c.load_factor() 每个桶的平均元素数量,返回 float 值
    c.max_load_factor() c 试图维护的平均桶大小,返回 float 值。c 会在需要时添加新的桶,以使得 load_factor <= max_load_factor
    c.rehash(n) 重组存储,使得 bucket_count >= n 且 bucket_count > size/max_load_factor
    c.reserve(n) 重组存储,使得 c 可以保存 n 个元素且不必 rehash
  • 关键字类型要求

    • 描述

      1、默认情况下,无序容器使用关键字类型的==运算符比较元素,他们还使用一个hash<key_type>类型对象来生成每个元素的哈希值
      2、标准库内置类型(包括指针)提供了hash模板,还为一些标准库类型(如string、智能指针等)定义了hash。因此,我们可以直接定义关键字类型内置类型标准库类型无序容器
      3、但是,我们不能直接定义关键字类型自定义类类型无序容器。与容器不同,不能直接使用哈希模板,而是必须提供我们自己的hash模板版本,我们将在后续介绍如何做到这点

    • 定义重载函数

      1、我们不使用默认的hash,而是用另一种办法,类似于为有序容器重载关键字类型的默认比较操作
      2、为了能将Sales_data用作关键字,我们需要提供函数来代替==运算符哈希值计算函数。我们从定义这些重载函数开始,如下例
      3、我们的hasher使用一个标准库hash类型对象来计算ISBN成员哈希值,该hash类型建立在string类型之上。类似的,eq0p通过比较ISBN号来比较两个Sales_data
      4、接着如下,我们使用这些函数定义一个unordered_multiset

      size_t hasher(const Sales_data &sd)
      {
          return hash<string>()(sd.isbn());
      }
      bool eq0p(const Sales_data &lhs, const Sales_data &rhs)
      {
          return lhs.isbn() == rhs.isbn();
      }
      
      using SD_multiset = unordered_multiset<Sales_data, decltype(hasher)*, decltype(eq0p)*>;
      // 参数是桶大小、哈希函数指针 和 相等性判断运算符指针
      SD_multiset bookstore(42, hasher, eq0p);

动态内存


章节概要:动态内存与智能指针;shared_ptr类(智能指针);定义与使用智能指针;智能指针操作;make_shared函数;shared_ptr的拷贝与赋值;shared_ptr的销毁与释放;设计使用动态生存期资源的类;直接管理内存(动态内存指针);new动态分配和初始化对象;delete释放内存;结合使用与异常;shared_ptrnew结合使用;智能指针和异常;自定义的释放;其他智能指针;unique_ptr类;weak_ptr类;设计核查指针类;动态数组;new和动态数组;智能指针管理动态数组;allocator类;使用标准库:文本查询程序

动态内存与智能指针

  • 引入

    1、我们的程序到目前为止只使用过静态内存栈内存静态内存用来保存局部static对象static数据成员以及定义在任何函数外的变量栈内存用来保存定义在函数内static对象
    2、分配在静态内存栈内存中的对象由编译器自动创建和销毁。对于栈对象,仅在其定义的程序块运行时才存在;而static对象使用之前分配程序结束时销毁
    3、除了静态内存栈内存,每个程序还拥有一个内存池,这部分内存被称作自由空间。程序用来存储动态分配的对象(即程序运行时分配的对象)
    4、动态对象生存期由程序控制,也就是说,当动态对象不使用时,代码必须显式地销毁它们。虽然使用动态内存有时是必要的,但正确地管理动态内存是非常棘手的

  • 动态内存

    1、动态内存的管理是通过一对运算符来完成的:new动态内存中为对象分配空间返回指向该对象的指针,我们可以选择对对象进行初始化delete接受一个动态对象指针销毁该对象释放关联的内存
    2、动态内存的使用很容易出问题,因为确保在正确的时机释放内存是十分困难的。有时我们会忘记释放内存,就会产生内存泄漏;有时在尚有指针引用内存时释放,就会产生引用非法内存的指针

  • 智能指针

    1、为了更安全便捷地使用动态内存,新的标准库提供了两种智能指针类型管理动态对象,它们都定义在memory头文件
    2、智能指针的行为类似常规指针,重要的区别是它负责自动释放所指向的对象
    3、新标准库提供的两种智能指针区别在于管理底层指针的方式shared_ptr允许多个指针指向同个对象unique_ptr独占所指向的对象
    4、标准库还定义了一个名为weak_ptr的伴随类,它是一种弱引用指向shared_ptr所管理的对象

shared_ptr 类(智能指针)

  • 定义与使用智能指针

    1、类似于vector智能指针也是模板。因此,当我们创建智能指针时,必须在尖括号内提供额外的信息,即提供指针指向的类型
    2、默认初始化智能指针中保存着一个空指针,我们将在后续介绍初始化智能指针的其他方法
    3、智能指针使用方式与普通指针类似解引用一个智能指针返回它指向的对象。如果在一个条件判断中使用智能指针,效果为检测它是否为空

    shared_ptr<string> p1;        // shared_ptr,可以指向 string
    shared_ptr<list<int>> p2;     // shared_ptr,可以指向 int 的 list
    
    if(p1 && p1 -> empty())       // 如果智能指针 p1 不为空,其指向的 string 对象也不为空
      *p1 = "hi";                 // 解引用 p1,将一个新值赋予 string 对象
  • 智能指针操作

    智能指针通用的操作 描述
    shared_ptr<T> sp,unique_ptr<T> up 空智能指针,可以指向类型为 T 的对象
    p 将 p 用作一个条件判断,若 p 指向一个对象(即 p 不为空),则为 true
    *p 解引用 p,得到它指向的对象
    p->mem 等价于(*p).mem
    p.get() 返回 p 中保存的指针(内置指针类型)。要小心使用,若智能指针释放了其对象,返回的指针所指向的对象也就消失了
    swap(p, q),p.swap(q) 交换 p 和 q 中的指针
    shared_ptr 独有的操作 描述
    make_shared<T>(args) 返回一个 shared_ptr,指向一个动态分配的类型为 T 的对象。使用 args 初始化此对象
    shared_ptr<T> p(q) p 是 shared_ptr q 的拷贝;此操作会递增 q 中的引用计数器。q 中的指针必须能转换为 T*
    p = q p 和 q 都是 shared_ptr,所保存的指针必须能相互转换。此操作会递减 p 的引用计数,递增 q 的引用计数;若 p 的引用计数变为 0,则将其管理的原内存释放
    p.use_count() 返回与 p 共享对象的智能指针的数量;可能很慢,主要用于调试
    p.unique() 若 p.use_count() 为 1,返回 true,否则返回 false
  • make_shared 函数

    1、最安全分配和使用动态内存的方法是调用memory头文件中定义的make_shared函数:make_shared\<T>(args)
    2、该函数在动态内存分配一个对象初始化,返回指向此对象的shared_ptr
    3、当要用make_shared时,必须指定要创建的对象的类型,定义方式与模板类似
    4、类似于顺序容器emplace成员函数make_shared也可以用其参数来构造给定类型的对象,在其后面的小括号给出参数调用该类的构造函数。如果不传递任何参数,则会进行值初始化
    5、我们通常用auto定义一个对象保存make_shared的结果,这种定义方法比较简单

    // 指向一个值为 42 的 int 的 shared_ptr
    shared_ptr<int> p1 = make_shared<int>(42);
    // 指向一个值为 "9999999999" 的 string
    shared_ptr<string> p2 = make_shared<string>(10, '9');
    // 指向一个值初始化的 int,即值为 0
    shared_ptr<int> p3 = make_shared<int>();
    
    // 使用 auto 定义对象更方便地保存结果
    // p4 指向一个动态分配的空 vector<string>
    auto p4 = make_shared<vector<string>>();
  • shared_ptr 的拷贝与赋值

    1、当进行拷贝或赋值操作时,每个shared_ptr都会记录有多少个其他shared_ptr指向相同的对象
    2、我们可以认为,每个shared_ptr都有一个关联的计数器,通常称其为引用计数。无论何时我们拷贝一个shared_ptr计数器就会递增
    3、例如,当用一个shared_ptr初始化另一个shared_ptr时,或将它作为参数传递给一个函数时,或作为函数返回值时,它所关联的计数器就会递增。反之,当我们给shared_ptr赋予新值,或者某个shared_ptr被销毁计数器就会递减
    4、一旦一个shared_ptr计数器变为 0,它就会自动释放自己所管理的对象

    auto p = make_shared<int>(42);    // p 指向的对象只有 1 个引用者
    auto q(p);                        // q 和 p 指向相同对象,此对象有 2 个引用者
    
    auto r = make_shared<int>(30);    // r 指向的对象只有 1 个引用者
    r = q;                            // 给 r 赋值,令它指向另一个地址
    // 此时,将递增 q 指向对象的引用计数,递减 r 原先指向对象的引用计数
    // 而 r 原先指向的对象的引用计数变为 0,没有引用者,将自动释放内存
  • shared_ptr 的销毁与释放

    • 析构函数与销毁对象

      1、当指向一个对象最后一个shared_ptr被销毁时,shared_ptr自动销毁此对象。它是通过另一个特殊的成员函数——析构函数完成销毁工作
      2、类似于构造函数每个类都有一个析构函数。就像构造函数控制对象初始化一样,析构函数控制对象销毁时做的操作
      3、析构函数一般用来释放对象所分配的资源。例如string构造函数分配内存来保存字符,而string析构函数就负责释放这些内存
      4、shared_ptr析构函数递减它指向的对象的引用计数,当引用计数变为 0析构函数就会销毁对象,并释放内存

    • shared_ptr能自动释放相关联的内存

      1、当动态内存不再使用时shared_ptr自动释放动态对象,这一特性使得动态内存的使用变得十分容易,如下例
      2、由于最后一个shared_ptr销毁前内存都不会释放,所以保证shared_ptr无用后销毁十分重要。如果忘记销毁无用的shared_ptr,程序仍能正确执行,但会浪费内存
      3、shared_ptr无用后仍保留的一种可能情况是:将shared_ptr存放在一个容器中,随后重排了容器,从而不再需要某些元素。这种情况下,应确保及时手动erase删除无用的shared_ptr

      // 该函数返回一个 shared_ptr,指向一个 Foo 类型的动态分配对象,对象通过一个类型为 T 的参数 arg 初始化
      shared_ptr<Foo> factory(T arg)
      {
          return make_shared<Foo>(arg);
      }
      
      void use_factory(T arg)
      {
          shared_ptr<Foo> p = factory(arg);
          // ...此处省略使用 p 的代码
      }   // p 离开了作用域,它指向的内存会被自动释放
      
      shared_ptr<Foo> use_factory2(T arg)
      {
          shared_ptr<Foo> p = factory(arg);
          // ...此处省略使用 p 的代码
          return p;     // 返回 p 时,引用计数递增
      }   // p 离开了作用域,但引用递增不为 0,不会被销毁
  • 设计使用动态生存期资源的类

    • 使用动态内存的场景

      1、程序不知道自己需要使用多少对象
      2、程序不知道所需对象的准确类型
      3、程序需要在多个对象间共享数据

    • 因共享数据而使用动态内存的情景

      1、容器类是出于上述第一种原因而使用动态内存的典型例子;我们还将在 15 章见到出于第二种原因而使用动态内存的例子;而在此,我们将举出因第三种原因而使用动态内存的例子
      2、到目前为止,我们使用过的类中,分配的资源具有与原对象一致的生存期。例如每个vector拥有自己的元素,当我们拷贝vector时,vector副本vector中的元素是相互分离的。此外,一个vector的元素只有当这个vector还存在时才存在,当vector被销毁,则对应元素也销毁
      3、但某些类中,分配的资源具有与原对象相独立的生存期。例如,假设我们希望定义一个名为Blob的类,保存一组元素。与容器不同,我们希望Blob对象不同拷贝之间共享相同的元素。即,当我们拷贝一个Blob元素时,Blob副本Blob应该引用相同的底层元素
      4、一般而言,如果两个对象共享底层的数据,当某个对象被销毁时,不能单方面销毁底层数据。此外,允许多个对象共享相同的状态使用动态内存的一个很常见的原因

      Blob<string> b1;                              // 空 blob
      // 任意一个新作用域
      {
          Blob<string> b2 = {"a", "an", "the"};
          b1 = b2;                                  // b1 和 b2 共享相同的元素
      }
      // 此时 b2 由于离开作用域而被销毁,但其元素不能被销毁,因为 b1 仍指向这些元素
    • 定义StrBlob

      1、由于后续才会学习模板有关内容,因此现在我们先定义一个管理string的类,命名为StrBlob,如下例
      2、实现一个新的集合类型的最简单方法是使用某个标准库容器来管理元素。采用这种方法,我们可以借助标准库类型管理元素所使用的内存。本例中,我们将使用vector保存元素。但是,我们不能在一个Blob对象直接保存vector,因为一个对象的成员对象销毁时也会被销毁。为了保证元素继续存在,我们将vector保存在动态内存
      3、为了实现我们希望的数据共享,我们为每个StrBlob设置了一个shared_ptr管理动态分配的vector,使用动态指针能自动完成对vector管理与释放。此外,我们还需要为定义一些操作构造函数

      #include <initializer_list>
      #include <iostream>
      #include <memory>
      #include <string>
      #include <vector>
      
      using std::initializer_list;
      using std::make_shared;
      using std::shared_ptr;
      using std::string;
      using std::vector;
      
      class StrBlob
      {
          public:
              // 定义类型
              using size_type = vector<string>::size_type;
      
              // 构造函数(使用初始化列表构造)
              StrBlob() : data(make_shared<vector<string>>())
              {
              }
              StrBlob(initializer_list<string> il) : data(make_shared<vector<string>>(il))
              {
              }
      
              // 成员函数,其中 data-> 实际上就是使用 vector 的成员函数
              size_type size() const
              {
                  return data->size();
              }
              bool empty() const
              {
                  return data->empty();
              }
              // 添加或删除元素
              void push_back(const string &t)
              {
                  data->push_back(t);
              }
              void pop_back();
              // 元素访问
              string &front();
              string &back();
      
          private:
              // 动态指针操控的动态内存数据成员
              shared_ptr<vector<string>> data;
              // 检查操作,如果 data[i] 不合法,抛出一个异常
              void check(size_type i, const string &msg) const;
      };
    • 元素访问成员函数

      1、由于要使用pop_backfrontback操作访问vector中的元素,且它们在访问元素前都必须检查元素是否存在,因此我们定义了一个名为checkprivate工具函数,用于检查给定索引是否在合法范围内。该函数还接受一个string,会将此参数传递给异常处理程序string用于描述错误内容
      2、pop_back元素访问成员函数首先调用check,如果check成功,再继续利用底层vector的操作完成自己的工作

      void StrBlob::check(size_type i, const string &msg) const
      {
          if (i >= data->size())
              throw std::out_of_range(msg);
      }
      
      string &StrBlob::front()
      {
          // 如果 vector 为空,check 会抛出一个异常
          check(0, "front on empty StrBlob");
          return data->front();
      }
      
      string &StrBlob::back()
      {
          // 如果 vector 为空,check 会抛出一个异常
          check(0, "back on empty StrBlob");
          return data->back();
      }
      
      void StrBlob::pop_back()
      {
          // 如果 vector 为空,check 会抛出一个异常
          check(0, "pop_back on empty StrBlob");
          data->pop_back();
      }
    • StrBlob的拷贝、赋值和销毁

      1、类似先前设计的Sales_dataStrBlob也使用默认版本的拷贝、赋值和销毁成员函数来对此类对象进行这些操作
      2、我们的StrBlob只有一个数据成员,它是shared_ptr类型。因此当我们对StrBlob对象执行这些操作时,它的shared_ptr成员就会被执行这些操作
      3、如前所述,使用了智能指针,其会自动统计引用计数,且会在引用计数为 0自动销毁

直接管理内存(动态内存指针)

  • new 动态分配和初始化对象

    • new的使用

      1、C++定义了两个运算符来分配和释放动态内存new分配内存delete释放内存。但相对于智能指针,这两个运算符非常容易出错;而且,自己直接管理内存的类使用智能指针的类不同,它们不能依赖类对象进行拷贝、赋值和销毁操作的任何默认定义
      2、在自由空间分配的内存是无名的,因此new无法为其分配的对象命名,而是返回一个指向该对象的指针(动态内存指针/内置指针)。默认情况下,动态分配的对象是默认初始化的,此外我们也可以用其他一些通用的初始化方式
      3、对于定义了自己的构造函数类类型,要求值初始化没有意义的,不管采用什么形式,对象都会通过默认构造函数初始化;但对于内置类型,两种形式就差别较大了,值初始化内置类型对象有着已定义良好的值,而默认初始化的对象的值是未定义的;类似的,对于类中依赖于编译器合成的默认构造函数内置类型成员,如果它们未在类内初始化,那么它们的值也是未定义的
      4、出于与变量初始化相同的原因,对动态分配的对象进行初始化通常总是个好主意

      int *pi = new int;                    // pi 指向一个动态分配的、未初始化的无名 int 对象
      string *ps = new string;              // 初始化为空 string
      
      int *pi2 = new int(1024);             // pi2 指向的对象的值为 1024
      string *ps2 = new string(10, '9');    // ps2 指向的对象的值为 "9999999999"
      vector<int> *pv2 = new vector<int>{0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
      
      string *ps3 = new string;             // 默认初始化为空的 string
      string *ps4 = new string();           // 值初始化为空的 string
      int *pi3 = new int;                   // 默认初始化,但 *pi3 的值未定义
      int *pi4 = new int();                 // 值初始化为 0,即 *pi4 的值为 0
    • 有关autoconst

      1、如果我们提供了一个小括号包括初始化器,就可以使用auto从该初始化器来推断我们想要分配的对象的类型
      2、由于编译器要用初始化器的类型来推断分配的类型,只有当括号中仅有单一初始化器时才可以使用auto
      3、用new分配const对象合法的。类似于其他任何const对象,一个动态分配const对象必须进行初始化,且由于分配的对象const的,其返回的指针也是指向const的指针
      4、对于一个定义了默认构造函数类类型,其const动态对象可以隐式初始化,而其他类型的对象必须显式初始化

      // p 指向一个与 obj 类型相同的对象,该对象用 obj 进行值初始化
      auto p1 = new auto(obj);
      // 错误:括号中只能有单个初始化器
      auto p2 = new auto(a, b, c);
      
      // 分配并初始化一个 const int
      const int *pci = new const int(1024);
      // 分配并默认初始化一个 const 的空 string
      const string *pcs = new const string;
    • 内存耗尽

      1、虽然现代计算机通常都配备了大容量内存,但自由空间被耗尽的情况还是有可能发生。一旦一个程序用光了所有可用内存new就会失败,并且默认情况下抛出类型为bad_alloc的异常
      2、我们可以改变使用new的方式来阻止它抛出异常(如下),我们称这种形式new定位 new定位 new允许我们new传递额外的参数
      3、在此例中,我们传递给它一个标准库定义的nothrow对象,意图是告诉它不抛出异常。这种形式的new如果不能分配内存,就会返回一个空指针
      4、bad_allocnothrow都定义在头文件new

      int *p1 = new int;                // 如果分配失败,抛出 std::bad_alloc 异常
      int *p2 = new (nothrow) int;      // 如果分配失败,不抛出异常,返回空指针
  • delete 释放内存

    • 释放动态内存

      1、为了防止内存耗尽,在动态内存使用完毕后,必须将其归还给系统。我们通过delete来将动态内存归还
      2、delete接受一个指针,指向我们想要释放的对象delete p;(p 必须指向动态分配对象或空指针)
      3、与new类似,delete也执行两个动作销毁对象释放内存

    • 指针值与delete

      1、我们传递给delete的指针必须指向动态分配的内存或是空指针。释放一块并非new分配的内存,或者将相同指针值释放多次,其行为未定义
      2、如下例,对于delete i;的请求,编译器生成错误信息,因为编译器知道 i 不是一个指针。但对于pi1pi2所产生的错误却更具有潜在危害
      3、通常情况下,编译器不能分辨一个指针指向的是静态分配还是动态分配的对象,类似的,它也不能分辨一个指针所指向的内存是否已经释放了。对于这些delete,大多数编译器编译通过,但它们本身是错误的
      4、虽然一个const对象的值不能被改变,但它本身可以被销毁

      // i 是整型,pi1 是整型静态指针,pi2 是空指针
      int i, *pi1 = &i, *pi2 = nullptr;
      // pd 指向动态分配的 double 对象,pd2 与 pd 指向相同动态对象
      double *pd = new double(33), *pd2 = pd;
      
      delete i;               // 错误:i 不是一个指针
      delete pi1;             // 未定义:pi1 指向一个局部变量(不是动态对象)
      delete pi2;             // 正确,可以释放空指针
      delete pd;              // 正确
      delete pd2;             // 未定义:pd2 指向的内存已经被释放了
      
      const int *pci = new const int(1024);
      delete pci;             // 正确,可以释放 const 动态对象
    • 生存期直到被释放

      1、如前所述,由shared_ptr管理的内存在最后一个shared_ptr销毁时会自动释放,但对于动态内存指针来说就不是这样了,其在被显式释放之前始终存在
      2、返回指向动态内存的指针函数给其调用者增加了一个额外负担——调用者必须记得释放内存
      3、如下例,我们重写之前已定义的factoryuse_factory函数,必须做如下修改

      // factory 返回一个指针,指向一个动态分配的对象
      Foo *factory(T arg)
      {
          // ...此处省略处理 arg 的代码
          return new Foo(arg);                // 调用者必须负责释放此内存
      }
      
      void use_factory(T arg)
      {
          Foo *p = factory(arg);              // 调用了 factory,就必须记得释放 p 的内存
          // ... 此处省略使用 p 的代码
          delete p;                           // 如果已经不再需要 p,必须释放掉 p 的内存
      }   // 如果忘记 delete 的话,即使离开了作用域,p 的内存仍然没有被释放
    • delete后重置指针值

      1、当我们delete一个指针后,指针值就变得无效了。虽然指针已经无效,但在很多机器上指针仍然保存着动态内存的地址,这被称为空悬指针
      2、未初始化指针的所有缺点空悬指针都有(例如由于数据被释放,地址指向的数据未定义,但指针仍可能被不知情地调用)。有一种办法可以避免空悬指针的问题:在指针即将离开作用域之前释放内存,这样在关联内存被释放后就没有机会继续使用指针了
      3、如果我们需要保留指针,可以在delete后将nullptr赋予指针,这样就清楚地指出指针不指向任何对象
      4、动态内存的一个基本问题是可能多个指针指向相同内存。在delete重置指针的方法只对被直接操作重置的指针有效,对于其他指向相同内存的指针无效。在实际系统中,查找指向相同内存所有指针异常困难的,因此查找所有指针再依次重置指针值也是很难实现的

      int *p(new int(42));        // p 指向动态内存
      auto q = p;                 // q 和 p 指向相同内存
      delete p;                   // p 和 q 都变得无效
      p = nullptr;                // 重置 p 指出其不再绑定到任何对象
      // q 并没有被重置,q 仍然是一个空悬指针

结合使用与异常

  • shared_ptr 和 new 结合使用

    • 有关结合使用

      1、如前所述,如果我们不初始化一个智能指针,它就会被初始化为一个空指针,借此我们还可以用new返回的指针初始化智能指针
      2、接受指针参数智能指针构造函数explicit的,因此我们不能将一个内置指针通过隐式转换变成智能指针,必须使用直接初始化初始化智能指针
      3、出于相同的原因,也不能在一个返回shared_ptr的函数的返回语句中隐式转换普通指针,必须将shared_ptr显式绑定到一个想要返回的指针

      shared_ptr<double> p1;                      // p1 可以指向一个 double,目前指向空指针
      shared_ptr<int> p2(new int(42));            // 正确:使用直接初始化,p2 指向一个值为 42 的 int
      shared_ptr<int> p3 = new int(1024);         // 错误:必须使用直接初始化形式
      
      shared_ptr<int> clone(int p)
      {
          return new int(p);                      // 错误:不能隐式转换为 shared_ptr<int>
          return shared_ptr<int>(new int(p));     // 正确:显式地用 int* 创建 shared_ptr<int>
      }
      定义和改变 shared_ptr 的方法 描述
      shared_ptr<T> p(q) p 管理内置指针 q 所指向的对象。q 必须要指向 new 分配的内存,且能够转换为 T* 类型
      shared_ptr<T> p(u) p 从 unique_ptr u 那里接管了对象的所有权,将 u 置空
      shared_ptr<T> p(q, d) p 接管了内置指针 q 所指向对象的所有权。q 必须能转换为 T* 类型,p 将使用可调用对象 d(lambda表达式或对象)来代替 delete
      shared_ptr<T> p(p2, d) 与 p(p2) 类似,p 是 p2 的拷贝,但 p 将用可调用对象 d 代替 delete
      p.reset(),p.reset(q),p.reset(q, d) 若 p 是唯一指向其对象的 shared_ptr,reset 会释放此对象。若传递了可选的参数内置指针 q,会令 p 指向 q,否则将 p 置空。若还传递了参数 d,将用可调用对象 d 代替 delete
    • 不要混合使用普通指针和智能指针

      1、shared_ptr可以协调对象的析构,但仅限于其自身的拷贝,这也是推荐使用make_shared而不是new的原因。这样,我们就能在分配对象同时将shared_ptr与之绑定,避免无意中将同一块内存绑定到多个独立创建的shared_ptr
      2、考虑下例的函数。虽然不能传递给process内置指针,但可以传递一个临时的shared_ptr,它是用一个内置指针显式构造的,但是这样做可能会导致错误。当调用结束临时对象被销毁,但原先的指针仍指向已释放的内存地址,成为空悬指针

      void process(shared_ptr<int> ptr)
      {
          // 使用 ptr
      }   // ptr 离开作用域,被销毁
      
      shared_ptr<int> p(make_shared<int>(42));  // 创建 p,此时引用计数为 1
      process(p);                               // 拷贝 p 会递增它的引用计数,在 process 中引用计数为 2
      int i = *p;                               // 正确:离开函数时引用计数为 1,没有被销毁
      
      // 显式构造临时的 shared_ptr
      int *x(new int(1024));                    // 危险:x 是一个普通指针,不是一个智能指针
      process(x);                               // 错误:不能将 int* 转换成一个 shared_ptr<int>
      process(shared_ptr<int>(x));              // 合法,但内存会被释放
      int j = *x;                               // 未定义:x 是一个空悬指针
    • 不要用get初始化另一个智能指针或为其赋值

      1、智能指针类型定义了一个get函数(前表中提过),它返回一个内置指针,指向智能指针管理的对象
      2、此函数是为了向不能使用智能指针的代码中传递一个内置指针的情况而设计的,使用get返回的指针的代码不能delete此指针
      3、虽然编译器不会给出错误,但将另一个智能指针绑定到get返回的指针上是错误

      shared_ptr<int> p(new int(42));           // 此时引用计数为 1
      int *q = p.get();                         // 正确:q 是内置指针,但使用 q 时要注意,不要让它被管理的指针被释放
      // 新程序块
      {
          shared_ptr<int>(q);                   // 未定义:两个独立的 shared_ptr 指向相同的内存
      }   // 程序块结束,q 被销毁,它指向的内存被释放
      int foo = *p;                             // 未定义:p 指向的内存已经被释放了
    • 其他shared_ptr操作

      1、shared_ptr还定义了其他一些操作(具体看前表),比如我们可以reset将一个新指针赋予一个shared_ptr
      2、与赋值类型,reset更新引用计数,需要时会释放 p 指向的对象。因此,reset常常和unique一起使用,unique检查智能指针引用计数是否为 1,这样便可以控制多个shared_ptr共享的对象
      3、在改变底层对象之前,我们检查自己是否是当前对象仅有的用户(使用unique);如果不是,在改变之前要制作一份新的拷贝保证其他用户正常指向当前内存

      shared_ptr<int> p;
      p = new int(1024);                        // 错误:不能将一个指针赋予 shared_ptr
      p.reset(new int(1024));                   // 正确:p 指向一个新对象
      
      if(!p.unique())
          p.reset(new int(*p));                 // 我们不是唯一用户,分配新的拷贝(通过 new 拷贝 p 的值到新的地址)
      *p += newVal;                             // 现在我们知道自己是唯一用户,可以改变对象的值
  • 智能指针和异常

    • 发生异常时的内存释放

      1、先前介绍过异常处理程序能在异常发生后程序流程继续,我们注意到这种程序需要确保异常发生后资源能够被正确释放
      2、一个简单的确保资源被释放的方式是使用智能指针,这样即使程序块过早结束智能指针类也能确保内存不再需要时被释放。如下函数,无论函数正常处理退出,还是发生异常退出局部对象都会被销毁
      3、与之相对,发生异常时我们直接管理的内存不会自动释放的。如果newdelete之间发生异常,且异常未被捕获,则内存就永远不会被释放了。在函数f2之外没有指针指向这块内存,因此就无法释放

      void f1()
      {
          shared_ptr<int> sp(new int(42));      // 分配一个新对象
          // 这段代码抛出异常,且在 f1 中未被捕获
      }   // 函数结束后,shared_ptr自动释放内存
      
      void f2()
      {
          int *ip = new int(42);                // 动态分配一个新对象
          // 这段代码抛出异常,且在 f2 中未被捕获
          delete ip;                            // 在退出之前释放内存
      }
    • 智能指针和哑类

      1、包括所有标准库类在内的很多C++类都定义了析构函数,负责清理对象使用的资源,但并不是所有类都有这样的良好定义。特别是那些为CC++两种语言设计的类,通常都要求用户显式地释放所使用的任何资源
      2、那些分配了资源,却没有定义析构函数释放资源的类,可能遇到与使用动态内存相同的错误——忘记释放资源。类似的,如果资源分配和释放之间发生异常,也会发生资源泄露
      3、与管理动态内存类似,我们可以使用类似的技术管理这些类。例如下面一段使用网络库的代码,如果connection析构函数,就可以在f结束时自动关闭链接,但它没有
      4、这个问题与上一个程序中使用shared_ptr避免内存泄漏等价,使用shared_ptr来保证connection被正确关闭,已被证明是一种有效的方法

      struct destination;                       // 表示正在连接什么
      struct connection;                        // 使用连接所需的信息
      connection connect(destination*);         // 打开链接
      void disconnect(connection);              // 关闭给定链接
      
      void f(destination &d /* 其他参数 */)
      {
          connection c = connect(&d);           // 获得链接,但要使用后关闭
          // ...使用连接
          // 如果退出 f 前忘记调用 disconnect,就无法关闭 c 了
      }
    • 使用自定义的释放操作

      1、默认情况下,shared_ptr假定它们指向的是动态内存,因此当其被销毁时默认对它管理的内存执行delete操作
      2、为了用shared_ptr管理一个connection,必须自定义一个函数来代替delete。这个删除器函数必须能完成shared_ptr中保存的指针进行释放的操作
      3、在本例中,我们的删除器必须接受单个类型为connection的参数。改写f函数,当创建一个shared_ptr时,可以传递一个可选的指向删除器函数参数

      void end_connection(connection *p)
      {
          disconnect(*p);                                 // 调用上面写好的关闭函数
      }
      
      void f(destination &d /* 其他参数 */)
      {
          connection c = connect(&d);
          shared_ptr<connection> p(&c, end_connection);   // 传递第二个指向删除器函数的参数
          // ...使用连接
          // 当 f 退出时(即使因为异常),connection 会被正确关闭
      }

其他智能指针

  • unique_ptr 类

    • unique_ptr操作

      1、一个unique_ptr拥有它所指向的对象,与shared_ptr不同,某个时刻只能有一个unique_ptr指向一个给定对象。当unique_ptr被销毁时,其指向的对象也被销毁。下表列出了unique_ptr特有的操作(通用操作同前表)
      2、与shared_ptr不同,没有类似make_shared标准库函数,因此当我们定义unique_ptr时,需要将其绑定到new返回的指针
      3、初始化unique_ptr必须采用直接初始化,此外其不支持普通拷贝或赋值。虽然我们不能拷贝或赋值unique_ptr,但可以通过releasereset指针所有权转移给另一个unique_ptr。但注意如果我们不用另一个智能指针保存release返回的指针,程序就要负责释放资源

      unique_ptr 独有的操作 描述
      unique_ptr<T> u1,unique_ptr<T, D> u2 空 unique_ptr,可以指向类型为 T 的对象。u1 会使用 delete 释放它的指针;u2 会使用类型为 D 的可调用对象释放指针
      unique_ptr<T, D> u(d) 空 unique_ptr,指向类型为 T 的对象,用类型为 D 的对象 d 代替 delete
      u = nullptr 释放 u 指向的对象,将 u 置空
      u.release() u 放弃对指针的控制权,返回指针,并将 u 置空
      u.reset(),u.reset(q),u.reset(nullptr) 释放 u 指向的对象。如果提供了内置指针 q,则令 u 指向这个对象,否则将 u 置空
      unique_ptr<double> p1;                              // 定义可以指向 double 的空 unique_ptr
      unique_ptr<int> p2(new int(42));                    // p2 指向一个值为 42 的 int
      
      unique_ptr<string> p3(new string("hello world"));
      unique_ptr<string> p4(p3);                          // 错误:不支持拷贝
      unique_ptr<string> p5;
      p5 = p3;                                            // 错误:不支持赋值
      
      // 将所有权从 ps1 转移给 ps2
      unique_ptr<string> ps1(new string("hello"));
      unique_ptr<string> ps2(ps1.release());              // release 将 ps1 置空,并返回指针
      // 将所有权从 ps3 转移给 ps2
      unique_ptr<string> ps3(new string("world"));
      ps2.reset(ps3.release());                           // reset 释放了 ps2 原来指向的内存
      
      // 易错:不使用智能指针接收 release 返回的指针
      ps2.release();                                      // 错误:ps2 不会释放内存,而且我们丢失了指针
      auto ps = ps2.release();                            // 正确:但也要记得 delete(ps)
    • 局部变量和返回值可以拷贝

      1、不能拷贝unique_ptr的规则有一个例外:我们可以拷贝或赋值一个将要被销毁的unique_ptr
      2、最常见的例子是从函数返回一个unique_ptr,此外还可以返回一个局部变量的拷贝
      3、对于这两种情况,编译器知道要返回的对象即将被销毁,这种情况下,编译器会执行一种特殊的拷贝,后续介绍

      unique_ptr<int> clone(int p)
      {
          // 正确:从 int* 创建一个 unique_ptr<int>,并作为返回值拷贝
          return unique_ptr<int>(new int(p));
      
          // 正确:也可以将局部变量作为返回值拷贝
          unique_ptr<int> ret(new int(p));
          return ret;
      }
    • unique_ptr传递删除器

      1、类似shared_ptrunique_ptr默认情况下用delete释放它指向的对象。与shared_ptr相同,我们可以重载一个unique_ptr默认的删除器,但其管理删除器的方式shared_ptr不同,其原因后续介绍
      2、重载一个unique_ptr删除器会影响到其类型以及如何构造(或reset)该类型对象。与重载关联容器比较操作类似,必须在尖括号中提供删除器类型。在创建reset一个对象时,必须提供一个指定类型的可调用对象(删除器)
      3、作为更具体的例子,我们将重写先前用shared_ptr写的连接程序,用unique_ptr代替shared_ptr

      // p 指向一个类型为 objT 的对象,并使用一个类型为 delT 的对象释放 objT 对象
      // 它会调用一个名为 fcn 的 delT 类型对象
      unique_ptr<objT, delT> p(new objT, fcn);
      
      void f(destination &d /* 其他需要的参数 */)
      {
          connection c = connect(&d);   // 打开链接
          // 当 p 被销毁,链接将自动关闭
          unique_ptr<connection, decltype(end_connection)*> p(&c, end_connection);
          // ...使用连接
          // 当 f 退出时(即使由于异常),connection 会被正确关闭
      }
  • weak_ptr 类

    • weak_ptr操作

      1、weak_ptr是一种不控制所指向对象生存期智能指针,他指向由shared_ptr管理的对象。将一个weak_ptr绑定到shared_ptr不会增加其引用计数,当shared_ptr被释放,即使还有weak_ptr指向对象对象也会被释放。因此weak_ptr具有弱共享对象的特点
      2、下表列出了weak_ptr的操作。当我们创建一个weak_ptr时,要shared_ptr初始化
      3、由于对象可能不存在,我们不能使用weak_ptr直接访问对象必须调用lock检查指向的对象是否存在。如果存在,lock返回一个指向共享对象的shared_ptr。与其他任何shared_ptr类似,只要此shared_ptr存在,其指向的底层对象就一直存在

      weak_ptr 独有的操作 描述
      weak_ptr<T> w 空 weak_ptr 可以指向类型为 T 的对象
      weak_ptr<T> w(sp) 与 shared_ptr sp 指向相同对象的 weak_ptr。T 必须能转换为 sp 指向的类型
      w = p p 可以是一个 shared_ptr 或一个 weak_ptr。赋值后 w 与 p 共享对象
      w.reset() 将 w 置空
      w.use_count() 与 w 共享对象的 shared_ptr 的数量
      w.expired() 若 w.use_count() 为 0,返回 true,否则返回 false
      w.lock() 如果 expired 为 true,返回一个空 shared_ptr,否则返回一个指向 w 的对象的 shared_ptr
      auto p = make_shared<int>(42);
      weak_ptr<int> wp(p);                    // wp 弱共享 p,p 的引用计数未改变
      
      if(shared_ptr<int> np = wp.lock())      // 如果 np 不为空则条件成立
      {
          // 在 if 中,np 与 p 共享对象
          // ...使用 np
      }
  • 设计核查指针类

    • 核查指针类

      1、作为weak_ptr用途的一个展示,我们将为先前定义过的StrBlob定义一个伴随指针类。该类命名为StrBlobPtr,保存一个weak_ptr指向StrBlobdata成员,且是初始化时提供给它
      2、通过使用weak_ptr不会影响一个给定的StrBlob所指向的vector的生存期,但可以阻止用户访问一个不再存在的vector

      // 对于访问一个不存在元素的尝试,StrBlobPtr 抛出一个异常
      class StrBlobPtr
      {
          public:
              // 构造函数
              StrBlobPtr() : curr(0)
              {
              }
              StrBlobPtr(StrBlob &a, size_t sz = 0) : wptr(a.data), curr(sz)
              {
              }
      
              // 成员函数
              string &deref() const; // 解引用
              StrBlobPtr &incr();    // 前缀递增
      
          private:
              // 若检查成功,check 返回一个指向 vector 的 shared_ptr
              shared_ptr<vector<string>> check(size_t i, const string &msg) const;
              // 保存一个 weak_ptr,意味着底层 vector 可能被销毁
              weak_ptr<vector<string>> wptr;
              // 在数组中的当前位置
              size_t curr;
      };
      
      shared_ptr<vector<string>> StrBlobPtr::check(size_t i, const string &msg) const
      {
          auto ret = wptr.lock(); // 判断 vector 是否存在
      
          // 抛出错误
          if (!ret)
              throw std::runtime_error("unbound StrBlobPtr");
          if (i >= ret->size())
              throw std::out_of_range(msg);
      
          return ret; // 返回指向 vector 的 shared_ptr
      }
    • 指针操作

      1、我们将在 14 章学习如何定义自己的运算符。现在我们将定义名为derefincr函数,分别用来解引用递增StrBlobPtr,且这两个函数都将调用check函数
      2、当然,为了访问StrBlobdata成员,我们的StrBlobStr必须声明为StrBlob友元。我们还要为StrBlob定义beginend操作,返回指向它自身StrBlobPtr

      string &StrBlobPtr::deref() const
      {
          auto p = check(curr, "dereference past end");
          return (*p)[curr]; // (*p) 是对象所指向的 vector
      }
      
      // 前缀递增:返回递增后对象的引用
      StrBlobPtr &StrBlobPtr::incr()
      {
          // 如果 curr 已经指向容器尾后位置,就不能递增它
          check(curr, "increment past end of StrBlobPtr");
          ++curr; // 推进当前位置
          return *this;
      }
      
      class StrBlobPtr;
      class StrBlob
      {
          public:
              // 友元
              friend class StrBlobPtr;
              // 返回指向首元素和尾后元素的 StrBlobPtr
              StrBlobPtr begin()
              {
                  return StrBlobPtr(*this);
              }
              StrBlobPtr end()
              {
                  auto ret = StrBlobPtr(*this, data->size());
                  return ret;
              }
      
          // ...相同部分省略
      }

动态数组

  • 动态数组

    1、newdelete运算符一次分配/释放一个对象,但有些应用需要一次为很多对象分配内存的功能(例如vectorstring需要在连续内存中保存它们的元素)。因此,当容器需要重新分配内存时,必须一次性为很多元素分配内存
    2、对此,C++语言标准库提供了两种方式C++语言定义了另一种new表达式语法,可以分配并初始化一个对象数组;而标准库中提供了allocator,允许我们将分配和初始化分离,并且使用allocator通常会提供更好的性能更灵活的内存管理能力
    3、事实上,大多数应用都没有直接访问动态数组的需求。当一个应用需要可变数量的对象时,我们先前在StrBlob中使用标准库vector的方法会更安全快速可靠大多数应用都应当首先使用标准库容器而不是动态分配的数组

  • new 和动态数组

    • new分配动态数组

      1、为了让new分配一个对象数组,我们要在类型名后跟上一对方括号指明分配对象的数目new将返回指向第一个对象的指针方括号中的大小必须是整型,但不必是常量
      2、也可以用一个表示数组类型类型别名来分配一个数组,这样new表达式中就不必使用方括号
      3、分配一个数组会得到一个元素类型指针:虽然我们称new T[]分配的内存动态数组,但它不是一个数组类型的对象,而是一个数组元素类型指针
      4、由于分配的内存不是数组类型,因此不能动态数组调用beginend(它们使用数组维度返回指针)。出于相同原因,也不能用范围for来处理动态数组的元素

      int *pia = new int[get_size()];           // pia 指向一个 int,调用 get_size() 确定分配多少个 int
      
      using arrT = int[42];                     // arrT 表示 42 个 int 的数组类型
      int *p = new arrT;                        // 分配一个 42 个 int 的数组
    • 初始化动态数组

      1、默认情况下new分配的对象都是默认初始化的,也可以对数组元素进行值初始化,方法是在数组大小后跟一对空括号。在新标准中,我们还可以提供一个元素初始化器花括号列表
      2、如果初始化器数目小于元素数目剩余元素将进行值初始化;如果大于元素数目,则new失败,不会分配任何内存,抛出bad_array_new_length异常
      3、虽然我们用空括号对数组中元素进行值初始化,但不能在括号中给出初始化器,这意味着不能用auto分配数组

      int *pia = new int[10];                   // 10 个未初始化的 int
      int *pia2 = new int[10]();                // 10 个值初始化为 0 的 int
      string *psa = new string[10];             // 10 个空 string
      string *psa2 = new string[10]();          // 10 个空 string
      
      // 10 个 int 分别用列表中对应的初始化器初始化
      int *pia3 = new int[10]{0,1,2,3,4,5,6,7,8,9};
      // 10 个 string,前 4 个用给定的初始化器初始化,剩余的进行值初始化
      string *psa3 = new string[10]{"a", "an", "the", string(3, 'x')};
    • 可以动态分配空数组

      1、如下例,可以用任意表达式来确定要分配的对象的数目。但该例中,如果get_size返回 0,代码仍能正常运行。虽然我们不能创建大小为 0静态数组,但当n = 0时调用new[n]是合法的
      2、当我们用new分配一个大小为 0 的数组时,new返回一个合法的非空指针,此指针保证与new返回的其他指针不相同。对于零长度数组来说,此指针就像尾后指针一样
      3、我们可以像使用尾后迭代器一样使用这个指针。可以用其进行比较操作(如样例中循环),也可以向其加/减 0,也可以从此指针减去自身得到 0。但此指针不能解引用——它不指向任何元素

      size_t n = get_size();                    // get_size 返回需要的元素数目
      int *p = new int[n];                      // 分配数组保存元素
      for(int *q = p; q != p + n; q++)
      {
          // ...处理数值
      }
      
      char arr[0];                              // 错误:不能定义长度为 0 的静态数组
      char *cp = new char[0];                   // 正确:但 cp 不能解引用
    • 释放动态数组

      1、为了释放动态数组,我们是用一种特殊形式的delete,其在指针前加上一个空方括号
      2、这种delete销毁参数指向的数组的元素,并释放内存。数组中的元素将按逆序销毁
      3、当我们释放一个指向数组的指针时,空方括号是必须的:它指示编译器此指针指向一个对象数组的首元素。如果忽略了方括号,其行为未定义

      delete p;                                 // p 必须指向一个动态分配的对象或为空
      delete [] pa;                             // pa 必须指向一个动态分配的数组或为空
  • 智能指针管理动态数组

    • unique_ptr管理

      1、标准库提供了一个可以管理new动态数组unique_ptr版本。为了使用unique_ptr管理动态数组,必须在对象类型后跟一对空方括号
      2、类型说明符中的方括号<T []>指出其指向一个T 类型数组而不是单个 T 类型
      3、下表列出了指向动态数组unique_ptr操作。与先前版本有些不同,我们不能使用.->运算符,但我们可以使用下标运算符访问数组元素

      unique_ptr<int []> up(new int[10]);       // up 指向一个包含 10 个未初始化 int 的数组
      up.release();                             // 自动用 delete[] 销毁其指针
      
      for(size_t i = 0; i != 10; i++)
      {
          up[i] = i;                            // 可以使用下标运算符为其赋值
      }
      指向数组的 unique_ptr 操作 描述
      unique_ptr<T []> u u 可以指向一个动态分配的数组吗,数组元素类型为 T
      unique_ptr<T []> u(p) u 指向内置指针 p 所指向的动态分配的数组。p 必须能转换为类型 T*
      u[i] 返回 u 拥有的数组中位置 i 处的对象
      除去.->外的unique_ptr操作 仍然支持
    • shared_ptr管理

      1、与unique_ptr不同,shared_ptr不直接支持管理动态数组,如果希望使用,必须提供自定义的删除器
      2、如下例,我们传递给shared_ptr一个lambda作为删除器,它使用delete[]释放数组。如果未提供删除器,这段代码是未定义
      3、有关delete[]:默认情况下,shared_ptr只将使用delete释放对象,如果对动态数组使用delete,其后果与直接释放动态数组指针时忘记[]一样
      4、shared_ptr不直接支持动态数组的特性使得访问元素比较麻烦,其未定义下标运算符,且智能指针类不支持指针算术运算。因此,为了访问元素,必须用get获取内置指针,再用它访问数组元素

      // 为了使用 shared_ptr,必须提供删除器
      shared_ptr<int> sp(new int[10], [](int *p) {delete[] p;});
      sp.reset(); // 使用提供的 lambda 释放数组,它使用 delete[]
      
      // 访问元素
      for(size_t i = 0; i != 10; i++)
      {
          *(sp.get() + i) = i;  // 使用 get 获取内置指针,再进行指针算术运算来选择元素,解引用后赋值
      }
  • allocator 类

    • 分配与构造组合的缺点

      1、new有一些灵活性上的局限,其中一方面表现在它将内存分配对象构造组合在了一起;类似的,delete也将对象析构内存释放组合在了一起
      2、当分配一大块内存时,我们通常计划在这块内存上按需构造对象,这种情况下,我们希望内存分配对象构造能够分离。意味着,我们可以提前分配大块内存,但只在真正需要时才执行创建对象
      3、一般情况下,内存分配对象构造组合可能会导致不必要的浪费(如下例)。我们可能创建了一些永远用不到的对象,而对于能用到的对象,也都被赋值了两次(初始化+赋值)。更重要的是,那些没有默认构造函数就不能动态分配数组

      string *p = new string[n];                  // 构造 n 个空 string
      string s;
      string *q = p;                              // q 指向第一个 string
      while(cin >> s && q != p + n)
          *q++ = s;                               // 赋予 *q 一个新值(可能读取数远小于分配数,造成浪费)
      size_t size = q - p;                        // 记住我们读取了多少个 string
      // ...使用数组
      delete[] p;                                 // 释放内存
    • allocator操作

      1、标准库allocator定义在头文件memory中,用于将内存分配对象构造分离
      2、allocator提供一种类型感知内存分配方法,其分配的内存是原始的未构造的,下表列出了该类的操作

      allocator 操作 描述
      allocator<T> a 定义一个名为 a 的 allocator 对象,可以为类型为 T 的对象分配内存
      a.allocate(n) 分配一段原始的、未构造的内存,保存 n 个类型为 T 的对象
      a.deallocate(p, n) 释放从 T* 指针 p 中地址开始的内存,这块内存保存了 n 个类型为 T 的对象。p 必须是先前由 allocate 返回的指针,且 n 必须是 p 创建时所要求的大小。在调用 deallocate 之前,用户必须对每个在这块内存中创建的对象调用 destroy
      a.construct(p, args) p 必须是一个类型为 T* 的指针,指向一块原始内存。args 被传递给类型为 T 的构造函数,用来在 p 指向的内存中构造一个对象
      a.destroy(p) p 为 T* 类型的指针,此算法对 p 指向的对象执行析构函数
    • 分配未构造的内存

      1、allocator是一个模板,要定义allocator对象,必须指明其可以分配的对象类型。当它分配内存时,会根据给定对象类型确定恰当的内存大小对其位置
      2、创建allocator对象后,要先使用allocate分配一段原始内存,其分配的内存未构造的,我们在这些内存中按需构造对象新标准库中,construct成员函数接受一个指针,用于在给定位置构造一个元素,还接受可选的额外参数,用于初始化构造的对象
      3、为了使用allocate返回的内存,必须先用construct构造对象,使用未构造的内存的行为是未定义的。当用完对象后,必须对每个元素调用destroy销毁,且只能对真正构造了的元素进行destroy
      4、一旦元素destroy销毁后,就可以重新使用这部分内存来保存其他string,也可以将其归还系统释放内存通过调用deallocate完成

      // 分配一段原始内存
      allocator<string> alloc;                    // 可以分配 string 的 allocator 对象
      auto p = alloc.allocate(n);                 // 分配 n 个未初始化的 string
      
      // 在未分配的内存中构造对象
      auto q = p;                                 // q 指向最后构造的元素之后的位置
      alloc.construct(q++);                       // *q 为空字符串
      alloc.construct(q++, 10, 'c');              // *q 为 cccccccccc
      alloc.construct(q++, "hi");                 // *q 为 hi
      
      // 使用构造的对象
      cout << *p << endl;                         // 正确:使用 string 的输出运算符
      cout << *q << endl;                         // 灾难:当前 q 还未构造对象,其指向未构造的内存
      
      // 销毁元素
      while(q != p)
          alloc.destroy(--q);                     // 销毁元素,释放真正构造的 string
      
      // 归还内存给系统
      alloc.deallocate(p, n);                     // 归还内存
    • 拷贝和填充未初始化内存的算法

      1、标准库还为allocator定义了两个伴随算法,可以在未初始化内存创建对象,它们都定义在头文件memory中。这些函数在给定目的位置创建元素,而不是由系统分配内存给它们。其返回值目标范围尾后迭代器
      2、作为一个例子,假定有一个intvector,希望将其内容拷贝到动态内存中。我们将分配一块两倍于vector空间动态内存,将vector拷贝到前一半空间,再用一个给定值填充后一半空间

      allocator 算法 描述
      uninitialized_copy(b, e, b2) 从迭代器 b 和 e 指出的输入范围中拷贝元素到迭代器 b2 指定的未构造的原始内存中。b2 指向的内存必须足够大,能容纳输入序列中元素的拷贝
      uninitialized_copy_n(b, n, b2) 从迭代器 b 指向的元素开始,拷贝 n 个元素到 b2 开始的内存中
      uninitialized_fill(b, e, t) 在迭代器 b 和 e 指定的原始内存范围中创建对象,对象的值均为 t 的拷贝
      uninitialized_fill_n(b, n, t) 从迭代器 b 指向的内存地址开始创建 n 个对象。b 必须指向足够大的未构造的原始内存,能够容纳给定数量的对象
      // 分配两倍于 vector 空间的动态内存
      auto p = alloc.allocate(vi.size() * 2);
      // 通过拷贝 vi 中的元素构造从 p 开始的前一半元素
      auto q = uninitialized_copy(vi.begin(), vi.end(), p);
      // 将剩余一半元素初始化为 42
      uninitialized_fill_n(q, vi.size(), 42);

使用标准库:文本查询程序

  • 描述

    1、我们将实现一个简单的文本查询程序,作为标准库相关内容学习的总结
    2、程序允许用户在一个给定文件查询单词,查询结果是当前单词在文件中出现的次数,以及其所在行的列表。如果一个单词在一行中出现多次此行只列出一次会按照升序输出
    3、例如,我们可能读入一个文件,在其中寻找单词 element,该程序的前几行输出是这样的:

    element occurs 112 times
        (line 36) A set element contians only a key;
        (line 158) operator creates a new element
        (line 160) Regardless of whether the element
        (line 168) When we fetch an element from a map, we
        (line 214) If the element is not found, find returns
        ...
  • 文本查询程序设计

    • 从需求入手,我们的程序需要完成以下任务

      1、当程序需要读取输入文件时,它必须记住单词出现的每一行。因此程序需要逐行读取文件,并将每一行分解为独立的单词
      2、当程序生成输出时:
      —-它必须能提取每个单词所关联的行号
      —-行号必须按升序出现无重复
      —-它必须能打印给定行号中的文本

    • 利用标准库,我们可以实现这些要求

      1、我们将使用一个vector<string>保存整个输入文件的一份拷贝。输入文件中的每行保存为vector一个元素。当需要打印一行时,可以用行号作为下标提取文本行
      2、我们使用一个istringstream将每行分解为单词
      3、我们使用一个set保存每个单词在输入文本中出现的行号。这保证了每行只出现一次行号升序排列
      4、我们使用一个map单词与它出现的行号set关联起来。这样就可以方便地提取任意单词的set
      5、我们的解决方案还使用shared_ptr,原因稍后解释

    • 数据结构

      1、虽然我们可以用vectorsetmap直接编写程序,但如果定义一个更抽象的解决方案,会更为有效
      2、我们将从定义一个保存输入文件的类——TextQuery开始,这会令文件查询更为容易。它包含一个vector和一个mapvector保存输入文件的文本map关联每个单词和它出现的行号set。这个类将有一个用来读取给定输入文件构造函数和一个执行查询操作
      3、返回所有内容的最简单的方式是再定义另一个类——QueryResult保存查询结果。这个类会有一个print函数,完成结果打印工作

    • 类之间共享数据

      1、我们的QueryResult要表达查询的结果,其中包括行号的set行对应的文本,而这些数据都保存在TextQuery类型对象中,因此我们必须确定如何访问它们
      2、可能我们可以拷贝set,但这样非常耗时,而且我们不希望拷贝vector,这可能引起整个文件的拷贝。而通过返回指向TextQuery对象内部迭代器(或指针),虽然可以避免拷贝,但会开启新的陷阱:如果TextQuery对象在对应的QueryResult对象之前被销毁QueryResult将引用一个不存在的对象中的数据
      3、因此,对于这两个类生存期应该同步,考虑到两个类概念上共享了数据,就可以使用shared_ptr反映数据结构中的这种共享关系

    • 使用TextQuery

      // 使用文本查询程序
      void runQueries(ifstream &infile)
      {
          // infile 是一个 ifstream,指向我们要处理的文件
          TextQuery tq(infile); // 保存文件并建立查询 map
          // 与用户交互:提示用户输入要查询的单词,完成查询并打印结果
          while (true)
          {
              cout << "enter word to look for, or q to quit: ";
              string s;
              // 若遇到文件末尾或用户输入了 q,终止循环
              if (!(cin >> s) || s == "q")
                  break;
              // 指向查询并打印结果
              print(cout, tq.query(s)) << endl;
          }
      }
  • 程序详细的类定义

    • TextQuery类定义

      1、我们先从TextQuery类定义开始。创建类对象构造函数接受一个istream,用来读取输入文件。该类还提供query操作,接受一个string,返回QueryResult表示string出现的那些行
      2、设计类的数据成员时,要考虑QueryResult共享数据的需求,其共享保存输入文件vector保存单词关联行号set

      using line_no = vector<string>::size_type; // 行号
      
      class QueryResult; // 为了定义 TextQuery::query() 的返回类型,这个定义是必须的
      
      // TextQuery 类定义
      class TextQuery
      {
          public:
              TextQuery(ifstream &);                         // 构造函数
              QueryResult query(const string &) const;       // 查询操作
      
          private:
              shared_ptr<vector<string>> file;          // 输入文件
              map<string, shared_ptr<set<line_no>>> wm; // 每个单词到它所在行号的集合的映射
      };
    • TextQuery构造函数

      // TextQuery 类构造函数:读取输入文件并建立单词到行号的映射
      TextQuery::TextQuery(ifstream &is) : file(new vector<string>)
      {
          string text;
          while (std::getline(is, text)) // 对文件中的每一行
          {
              file->push_back(text);    // 保存此行文本
              int n = file->size() - 1; // 当前行号
      
              istringstream line(text); // 用于将行文本分解为单词
              string word;              // 用于接收并处理行中每个单词
      
              while (line >> word) // 对行中每个单词
              {
                  // 如果单词不在 wm 中,以之为下标在 wm 中添加一项
                  auto &lines = wm[word];            // lines 是一个 shared_ptr
                  if (!lines)                        // 第一次遇到这个单词时,此指针为空
                      lines.reset(new set<line_no>); // 分配一个新的 set
                  lines->insert(n);                  // 将此行号插入 set 中
              }
          }
      }
    • QueryResult

      1、QueryResult有三个数据成员:一个string保存查询单词;一个shared_ptr,指向保存输入文件vector;一个shared_ptr,指向保存单词出现行号set
      2、它唯一的一个成员函数是一个构造函数,其工作是将参数保存对应的数据成员

      // QueryResult 类
      class QueryResult
      {
          public:
              // 打印函数的友元
              friend ostream &print(ostream &os, const QueryResult &qr);
      
              // 构造函数
              QueryResult(string s, shared_ptr<set<line_no>> p, shared_ptr<vector<string>> f) : sought(s), lines(p), file(f)
              {
              }
      
          private:
              string sought;                   // 查询单词
              shared_ptr<set<line_no>> lines;  // 出现的行号
              shared_ptr<vector<string>> file; // 输入文件
      };
    • TextQuery::query函数

      1、query函数接受一个string参数,即要查询的单词query用它在map定位对应行号set。如果找到了这个string,该函数构造并返回QueryResult
      2、唯一的问题是:如果给定string未找到,应该返回什么?此时没有可返回的set,为此我们定义了一个局部static对象,它是一个指向空行号setshared_ptr。当未找到给定单词时,返回此对象的一个拷贝

      // TextQuery::query() 函数
      QueryResult TextQuery::query(const string &sought) const
      {
          // 如果找到 sought,我们将返回一个指向此 set 的指针
          static shared_ptr<set<line_no>> nodata(new set<line_no>);
      
          // 使用 find 而不是下标运算符来查找单词,避免将单词添加到 wm 中
          auto loc = wm.find(sought);
          if (loc == wm.end())
              return QueryResult(sought, nodata, file); // 未找到
          else
              return QueryResult(sought, loc->second, file);
      }
    • 打印结果

      // 打印结果:print() 函数
      ostream &print(ostream &os, const QueryResult &qr)
      {
          // 如果找到了单词,打印出现次数和所有出现的位置
          os << qr.sought << " occurs " << qr.lines->size() << " time" << (qr.lines->size() > 1 ? "s" : "") << endl;
      
          // 打印单词出现的每一行
          for (auto num : *qr.lines) // 对 set 中每个单词
              // 避免行号从 0 开始给用户带来困惑
              os << "\t(line " << num + 1 << ") " << *(qr.file->begin() + num) << endl;
      
          return os;
      }
  • 完整的类

    #include <fstream>
    #include <iostream>
    #include <map>
    #include <memory>
    #include <set>
    #include <sstream>
    #include <string>
    #include <vector>
    
    using std::cin;
    using std::cout;
    using std::endl;
    using std::ifstream;
    using std::istringstream;
    using std::map;
    using std::ostream;
    using std::set;
    using std::shared_ptr;
    using std::string;
    using std::vector;
    
    using line_no = vector<string>::size_type; // 行号
    
    class QueryResult; // 为了定义 TextQuery::query() 的返回类型,这个定义是必须的
    
    // TextQuery 类定义
    class TextQuery
    {
        public:
            TextQuery(ifstream &);                         // 构造函数
            QueryResult query(const string &) const;       // 查询操作
    
        private:
            shared_ptr<vector<string>> file;          // 输入文件
            map<string, shared_ptr<set<line_no>>> wm; // 每个单词到它所在行号的集合的映射
    };
    
    // TextQuery 类构造函数:读取输入文件并建立单词到行号的映射
    TextQuery::TextQuery(ifstream &is) : file(new vector<string>)
    {
        string text;
        while (std::getline(is, text)) // 对文件中的每一行
        {
            file->push_back(text);    // 保存此行文本
            int n = file->size() - 1; // 当前行号
    
            istringstream line(text); // 用于将行文本分解为单词
            string word;              // 用于接收并处理行中每个单词
    
            while (line >> word) // 对行中每个单词
            {
                // 如果单词不在 wm 中,以之为下标在 wm 中添加一项
                auto &lines = wm[word];            // lines 是一个 shared_ptr
                if (!lines)                        // 第一次遇到这个单词时,此指针为空
                    lines.reset(new set<line_no>); // 分配一个新的 set
                lines->insert(n);                  // 将此行号插入 set 中
            }
        }
    }
    
    // QueryResult 类
    class QueryResult
    {
        public:
            // 打印函数的友元
            friend ostream &print(ostream &os, const QueryResult &qr);
    
            // 构造函数
            QueryResult(string s, shared_ptr<set<line_no>> p, shared_ptr<vector<string>> f) : sought(s), lines(p), file(f)
            {
            }
    
        private:
            string sought;                   // 查询单词
            shared_ptr<set<line_no>> lines;  // 出现的行号
            shared_ptr<vector<string>> file; // 输入文件
    };
    
    // TextQuery::query() 函数
    QueryResult TextQuery::query(const string &sought) const
    {
        // 如果找到 sought,我们将返回一个指向此 set 的指针
        static shared_ptr<set<line_no>> nodata(new set<line_no>);
    
        // 使用 find 而不是下标运算符来查找单词,避免将单词添加到 wm 中
        auto loc = wm.find(sought);
        if (loc == wm.end())
            return QueryResult(sought, nodata, file); // 未找到
        else
            return QueryResult(sought, loc->second, file);
    }
    
    // 打印结果:print() 函数
    ostream &print(ostream &os, const QueryResult &qr)
    {
        // 如果找到了单词,打印出现次数和所有出现的位置
        os << qr.sought << " occurs " << qr.lines->size() << " time" << (qr.lines->size() > 1 ? "s" : "") << endl;
    
        // 打印单词出现的每一行
        for (auto num : *qr.lines) // 对 set 中每个单词
            // 避免行号从 0 开始给用户带来困惑
            os << "\t(line " << num + 1 << ") " << *(qr.file->begin() + num) << endl;
    
        return os;
    }
    
    // 使用文本查询程序
    void runQueries(ifstream &infile)
    {
        // infile 是一个 ifstream,指向我们要处理的文件
        TextQuery tq(infile); // 保存文件并建立查询 map
        // 与用户交互:提示用户输入要查询的单词,完成查询并打印结果
        while (true)
        {
            cout << "enter word to look for, or q to quit: ";
            string s;
            // 若遇到文件末尾或用户输入了 q,终止循环
            if (!(cin >> s) || s == "q")
                break;
            // 指向查询并打印结果
            print(cout, tq.query(s)) << endl;
        }
    }
    
    int main()
    {
        string filename = "passage.txt";
        ifstream infile(filename);
        runQueries(infile);
        return 0;
    }

拷贝控制


章节概要:拷贝、赋值与销毁;拷贝控制;拷贝构造函数;拷贝赋值运算符;重载赋值运算符;析构函数;三/五法则;阻止拷贝;定义删除的函数;拷贝控制和资源管理;行为像值的类;行为像指针的类;交换操作;类的swap;拷贝控制示例;动态内存管理类;对象移动;右值引用;std::move;移动构造函数和移动赋值运算符;移动迭代器;右值引用和成员函数;引用限定符

拷贝、赋值与销毁

  • 拷贝控制

    1、当定义一个类时,我们显式地隐式地指定在此类型的对象拷贝移动赋值销毁时要做什么
    2、一个类通过定义五种特殊的成员函数控制这些操作拷贝构造函数拷贝赋值运算符移动构造函数移动赋值运算符析构函数
    3、拷贝/移动构造函数定义当用同类型对象初始化本对象时的操作;拷贝/移动赋值运算符定义将一个对象赋予同类型另一个对象时的操作;析构函数定义当此类型对象销毁时的操作。我们称这些操作称为拷贝控制操作

  • 拷贝构造函数

    • 定义拷贝构造函数

      1、如果一个构造函数第一个参数自身类类型的引用,且额外参数都有默认值,则此构造函数是拷贝构造函数
      2、虽然我们可以定义一个接受非const引用拷贝构造函数,但此参数几乎总是一个const的引用
      3、拷贝构造函数在几种情况下都会被隐式使用。因此,拷贝构造函数通常不应该是explicit

      class Foo
      {
          public:
              Foo();            // 默认构造函数
              Foo(const Foo&);  // 拷贝构造函数
      };
    • 合成拷贝构造函数

      1、如果我们没有定义一个类的拷贝构造函数编译器会为我们定义一个合成的拷贝构造函数
      2、对于某些类来说,合成拷贝构造函数用于阻止拷贝该类对象;而一般情况下,合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中,编译器给定对象中依次将每个非static成员拷贝到当前创建的对象
      3、每个成员的类类型决定了它如何拷贝:对类类型的成员,会使用其拷贝构造函数来拷贝;对于内置类型的成员,则直接拷贝。虽然我们不能直接拷贝一个数组,但合成拷贝构造函数逐个拷贝数组元素
      4、作为例子,下面的代码等价于先前定义的Sales_data合成拷贝构造函数

      Sales_data::Sales_data(const Sales_data &orig) : bookNo(orig.bookNo), units_sold(orig.units_sold), revenue(orig.revenue)
      {
      }
    • 拷贝初始化

      1、现在,我们可以完全理解直接初始化拷贝初始化之间的差异了,如下例
      2、当使用直接初始化时,我们实际上是要求编译器使用普通的函数匹配来选择与提供参数最匹配构造函数;而使用拷贝初始化时,我们要求编译器右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换
      3、拷贝初始化在以下情况时会发生:使用=定义变量;将对象作为实参传递给一个非引用类型形参;从返回类型为非引用类型的函数返回对象;用花括号列表初始化数组元素聚合类成员
      4、此外,某些类类型会对它们分配的对象使用拷贝初始化:当我们初始化标准库容器,或调用其insertpush成员,容器会对其元素进行拷贝初始化。与之相对,用emplace创建的元素都进行直接初始化

      string dots(10, '.')                      // 直接初始化
      string s(dots);                           // 直接初始化
      string s2 = dots;                         // 拷贝初始化
      string null_book = "9-999-99999-9";       // 拷贝初始化
      string nines = string(100, '9');          // 拷贝初始化
    • 参数、返回值、引用与限制

      1、在参数调用过程中,具有非引用类型参数要进行拷贝初始化;类似的,当一个函数具有非引用的返回类型时,返回值会被用来初始化调用者的结果
      2、拷贝构造函数被用来初始化非引用类类型参数,这也是拷贝构造函数参数必须是引用的原因:为了调用拷贝构造函数,我们必须拷贝它的实参,而为了拷贝它的实参,就必须调用拷贝构造函数
      3、因此,如果我们使用的初始化值要求通过一个explicit的构造函数来进行类型转换,那么使用拷贝初始化还是直接初始化不是无关紧要的了,如下

      vector<int> v1(10);                       // 正确:直接初始化
      vector<int> v2 = 10;                      // 错误:(已知前提)接受大小参数的构造函数是 explicit 的
      
      void f(vector<int>);                      // 函数 f 的参数进行拷贝初始化
      f(vector<int>(10));                       // 正确:从一个 int 直接构造一个临时 vector 作为参数
      f(10);                                    // 错误:不能用一个 explicit 的构造函数拷贝一个实参
  • 拷贝赋值运算符

    • 描述

      1、与控制其对象如何初始化一样,也可以控制其对象如何赋值
      2、与拷贝构造函数一样,如果类未定义自己的拷贝赋值运算符编译器会为它合成一个

      Sales_data trans, accum;
      trans = accum;  // 使用 Sales_data 的拷贝赋值运算符
    • 重载赋值运算符

      1、介绍合成赋值运算符之前,我们需要了解一些有关重载运算符的知识,详细内容将在 14 章介绍
      2、重载运算符本质上是函数,其名字由operator后接表示要定义的运算符的符号组成。因此,赋值运算符就是一个名为operator=函数。类似于其他函数,运算符函数也有一个返回类型和一个参数列表
      3、重载运算符参数表示运算符的运算对象某些运算符(包括赋值运算符)必须定义为成员函数。如果是一个运算符是一个成员函数,其左侧运算对象就会绑定到隐式的this参数。对于一个二元运算符(例如赋值运算符),其右侧运算对象作为显式参数传递
      4、为了与内置类型赋值保持一致,赋值运算符通常返回一个其左侧运算对象的引用;此外标准库通常要求保存在容器中的类型具有赋值运算符,且其返回值左侧运算对象的引用

      class Foo
      {
          public:
              Foo& operator=(const Foo&); // 赋值运算符
      };
    • 合成拷贝赋值运算符

      1、与拷贝构造函数一样,如果类未定义自己的拷贝赋值运算符编译器会为它生成一个合成拷贝赋值运算符
      2、类似拷贝构造函数,对于某些类合成拷贝赋值运算符用来禁止类型对象赋值。如果并非出于此目的,它会将右侧运算对象的每个static成员赋予左侧运算对象的对应成员,这一工作通过拷贝赋值运算符来完成
      3、对于数组类型的成员,逐个赋值数组元素,合成拷贝赋值运算符返回一个左侧运算对象的引用
      4、作为例子,下面的代码等价于先前定义的Sales_data合成拷贝赋值运算符

      Sales_data& Sales_data::operator=(const Sales_data &rhs)
      {
          bookNo = rhs.bookNo;                        // 调用 string::operator=
          units_sold = rhs.units_sold;                // 使用内置的 int 赋值
          revenue = rhs.revenue;                      // 使用内置的 double 赋值
          return *this;                               // 返回一个此对象的引用
      }
  • 析构函数

    • 定义析构函数

      1、析构函数执行与构造函数相反的操作,其释放对象使用的资源,并销毁对象static数据成员
      2、析构函数的一个成员函数,名字由波浪号~后接类名构成。它没有返回值,也不接受参数
      3、由于析构函数不接收参数,因此它不能被重载。对一个给定类,只有唯一一个析构函数

      class Foo
      {
          public:
              ~Foo(); // 析构函数
      };
    • 析构函数的工作

      1、如同构造函数有一个初始化部分和一个函数体析构函数也有一个函数体和一个析构部分
      2、在一个构造函数中,成员初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序初始化。而在一个析构函数中,首先执行函数体,然后销毁成员,成员按初始化顺序逆序销毁
      3、在一个析构函数中,不存在类似构造函数初始化列表的东西来控制成员如何销毁析构部分隐式的,成员销毁时发生什么完全依赖于成员的类型:销毁类类型成员需要执行成员自己的析构函数;而内置类型没有析构函数,因此销毁内置类型成员什么也不需要做
      4、隐式销毁内置指针不会delete指向的对象。而与普通指针不同,智能指针类类型,所以有析构函数,因此其在析构阶段会被自动销毁

    • 什么时候会调用析构函数

      1、无论何时一个对象被销毁,就会自动调用其析构函数
      2、例如:变量离开作用域对象被销毁时,其成员被销毁容器被销毁时,其元素被销毁;对指向动态分配对象的指针应用delete时;创建临时对象完整表达式结束
      3、由于析构函数自动运行,我们的程序可以按需分配资源通常无需担心何时释放这些资源
      4、注意:当指向一个对象引用或指针离开作用域时,析构函数不会执行

    • 合成析构函数

      1、当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数。在析构函数体(无论是否为空)执行完毕后,成员才会被自动销毁
      2、要认识到析构函数体自身并不直接销毁成员,成员是在析构函数体之后析构阶段被销毁的,析构函数体是作为成员销毁步骤之外的另一部分而进行的
      3、下面的代码等价于先前定义的Sales_data析构函数

      class Sales_data
      {
          public:
              // 成员会被自动销毁,除此之外不需要做其他事情
              ~Sales_data()
              {
              }
      }
  • 三/五法则

    • 描述

      1、如前所述,有三个基本操作可以控制类的拷贝拷贝构造函数拷贝赋值运算符析构函数,称为三法则
      2、新标准下,还可以定义两个操作移动构造函数和一个移动赋值运算符,在三法则基础上加上这两个操作称为五法则
      3、C++虽然不要求我们定义所有操作,但三/五法则的操作通常被看作整体。通常,只需要其中一个操作不需要定义所有操作的情况是很少见的,其需要满足两项基本原则

    • 基本原则

      1、需要析构函数的类也需要拷贝和赋值操作:通常,对析构函数的需求比对拷贝构造函数赋值运算符的需求更为明显,如果一个类需要析构函数,几乎可以肯定它也需要拷贝构造函数拷贝赋值运算符
      2、需要拷贝操作的类也需要赋值操作反之亦然:如果一个类需要拷贝构造函数,几乎可以肯定它也需要拷贝赋值运算符

  • 阻止拷贝

    • 引入

      1、虽然大多数类应该定义拷贝构造函数拷贝赋值运算符,但对某些类来说,这些操作没有合理的意义
      2、此情况下,定义类时必须采用某种机制阻止拷贝或赋值。例如,iostream阻止了拷贝,以避免多个对象写入或读取相同IO缓冲
      3、但是如何阻止拷贝?即使不定义拷贝控制成员编译器也会为其生成合成版本

    • 定义删除的函数

      1、新标准下,我们可以通过将拷贝构造函数拷贝赋值运算符定义为删除的函数阻止拷贝
      2、删除的函数是这样一种函数:我们虽然声明了它,但不能以任何方式使用它。在函数的参数列表后加上=delete来定义删除的函数
      3、与=default不同=delete必须出现在函数首次声明时,此外我们可以对任何函数指定=delete

      struct NoCopy
      {
          NoCopy() = default;                         // 使用合成的默认构造函数
          NoCopy(const NoCopy&) = delete;             // 将拷贝构造函数定义为删除,阻止拷贝
          NoCopy &operator=(const NoCopy&) = delete;  // 将拷贝赋值运算符定义为删除,阻止赋值
          ~NoCopy() = default;                        // 使用合成的析构函数
      };
    • 析构函数不能删除

      1、注意:我们不能删除析构函数。如果析构函数被删除,就无法销毁此类对象
      2、对于一个删除了析构函数的类编译器不允许定义该类变量或创建该类临时对象,而且我们也不能定义该类变量或临时对象
      3、虽然我们不能定义这种类型的变量或成员,但可以动态分配这种类型的对象。但注意,不能释放这些对象

      struct NoDtor
      {
          NoDtor() = default;               // 使用合成的默认构造函数
          ~NoDtor() = delete;               // 删除了析构函数,我们将不能销毁 NoDtor 对象
      };
      
      NoDtor nd;                            // 错误:NoDtor 的析构函数被删除,不能定义该类对象
      NoDtor *p = new NoDtor();             // 正确:可以动态分配该类对象,但不能销毁
      delete p;                             // 错误:NoDtor 的析构函数是删除的
    • private拷贝控制

      1、在新标准发布前通过将其拷贝构造函数拷贝赋值运算符声明为private阻止拷贝(现在也可以这样做)
      2、如下例:由于析构函数public的,用户可以定义该类型对象;由于拷贝构造函数拷贝赋值运算符private的,用户代码不能拷贝该类型对象,但注意友元成员函数可以拷贝对象

      class PrivateCopy
      {
          private:
              PrivateCopy(const PrivateCopy&);
              PrivateCopy &operator=(const PrivateCopy&);
      
          public:
              PrivateCopy() = default;
              ~PrivateCopy();
      };

拷贝控制和资源管理

  • 引入

    1、通常,管理类外资源的类必须定义拷贝控制成员(三/五法则的成员)。为了定义这些成员,我们首先必须定义此类对象拷贝语义:通过定义拷贝操作,使类的行为看起来像一个值一个指针
    2、类的行为像一个值:类有自己的状态,当拷贝像值的对象时,副本与原对象完全独立,改变副本不会对原对象有影响,反之亦然
    3、类的行为像一个指针:类共享状态,当拷贝像指针的对象时,副本和原对象使用相同底层数据,改变副本也会改变原对象,反之亦然
    4、为了说明这两种方式,我们将为HasPtr定义拷贝控制成员,该类有两个成员:一个int和一个string指针。这些成员本身就是值,因此通常应该让它们的行为像值。而我们如何拷贝指针成员决定了像HasPtr这样的类是具有类值行为还是类指针行为

  • 行为像值的类

    • 定义行为像值的类

      1、为了提供类值的行为,对于类管理的资源,每个对象都应该有一份自己的拷贝。如下例HasPtr,对于ps指向的string,每个HasPtr对象都要有自己的拷贝
      2、为了实现类值类型HasPtr需要:定义拷贝构造函数,完成string拷贝,而不是拷贝指针;定义析构函数释放string;定义拷贝赋值运算符释放对象当前string,并从右侧运算对象拷贝string

      class HasPtr
      {
          public:
              // 默认构造函数
              HasPtr(const string &s = string()) : ps(new string(s)), i(0)
              {
              }
              // 拷贝构造函数:对 ps 指向的 string,每个 HasPtr 对象都有自己的拷贝
              HasPtr(const HasPtr &p) : ps(new string(*p.ps)), i(p.i)
              {
              }
              // 拷贝赋值运算符(还未定义)
              HasPtr& operator=(const HasPtr &rhs);
              // 析构函数
              ~HasPtr()
              {
                  delete ps;  // 记得释放内置的动态指针,其不会自动释放
              }
      
          private:
              string *ps;
              int i;
      };
    • 类值拷贝赋值运算符

      1、赋值运算符通常组合了析构函数构造函数的操作。其销毁左侧运算对象从右侧运算对象拷贝数据,但要特别注意这些操作要按正确顺序执行,有关的大多错误编译器不会提示
      2、如下例,定义了HasPtr赋值运算符。此外为了说明防范自赋值操作的重要性,思考下面列出的第二个反例

      HasPtr& HasPtr::operator=(const HasPtr &rhs)
      {
          auto newp = new string(*rhs.ps);    // 拷贝底层 string
          delete ps;                          // 释放旧内存
          ps = newp;                          // 从右侧运算对象拷贝数据到本对象
          i = rhs.i;
          return *this;                       // 返回本对象
      }
      
      // 反例,这样编写是错误的!
      HasPtr& HasPtr::operator=(const HasPtr &rhs)
      {
          delete ps;                          // 释放对象指向的 string
          // 注意:如果 rhs 和 *this 是同一个对象,我们将从已释放的内存中拷贝数据
          ps = new string(*rhs.ps);
          i = rhs.i;
          return *this;
      }
  • 行为像指针的类

    • 定义行为像指针的类

      1、对于行为类似指针的类,我们需要为其定义拷贝构造函数拷贝赋值运算符,用来拷贝指针成员本身而不是它指向的string
      2、我们的类虽然仍然需要析构函数释放构造函数分配的内存,但不能单方面释放,只有当最后一个指向stringHasPtr被销毁才能释放
      3、令一个类展现类指针行为最好办法是用shared_ptr管理资源拷贝shared_ptr便会拷贝指针,且shared_ptr自带引用计数,能在恰当时间释放内存
      4、但有时我们希望直接管理资源,这时使用引用计数就很有用了。我们将重新定义HasPtr使其具有类指针行为,并不使用shared_ptr

    • 引用计数的工作方式

      1、除了初始化对象外,每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用于记录有多少对象与创建的对象共享状态。当我们创建一个对象时,只有一个对象共享状态因此将计数器初始化为 1
      2、拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,也包括计数器。其递增共享的计数器,指出给定对象的状态又被一个新用户所共享
      3、析构函数递减计数器,指出共享状态用户少了一个。如果计数器变为 0,则析构函数就必须销毁状态
      4、拷贝赋值运算符递增右侧对象计数器递减左侧对象计数器。如果左侧对象计数器为 0,则它没有共享用户拷贝赋值运算符就必须销毁状态

    • 定义使用引用计数的类

      1、通过使用引用计数,我们就可以编写类指针行为HasPtr
      2、在此,我们添加了名为use数据成员,用于记录有多少对象共享相同string

      class HasPtr
      {
          public:
              // 构造函数分配新的 string 和新的计数器,将计数器置为 1
              HasPtr(const string &s = string()) : ps(new string(s)), i(0), use(new size_t(1))
              {
              }
              // 拷贝构造函数拷贝所有三个数据成员,并递增计数器
              HasPtr(const HasPtr &p) : ps(p.ps), i(p.i), use(p.use)
              {
                  ++*use;
              }
              HasPtr& operator=(const HasPtr &rhs);
              ~HasPtr();
      
          private:
              string *ps;
              int i;
              size_t *use;
      };
    • 类指针析构函数和拷贝赋值运算符

      1、析构函数不能无条件地delete,因为可能还有其他对象指向这块内存。其应该先递减引用计数,然后判断如果计数器变为 0,再释放动态指针的内存
      2、拷贝赋值运算符与往常一样执行类似拷贝构造函数析构函数的工作。它递增右侧对象计数递减左侧对象计数,必要时释放左侧对象内存

      HasPtr::~HasPtr()
      {
          if(--*use == 0)           // 如果引用计数变为 0
          {
              delete ps;            // 释放 string 内存
              delete use;           // 释放计数器内存
          }
      }
      
      HasPtr& HasPtr::operator=(const HasPtr &rhs)
      {
          ++*rhs.use;               // 递增右侧对象计数
          if(--*use == 0)           // 递减本对象计数
          {
              delete ps;            // 释放 string 内存
              delete use;           // 释放计数器内存
          }
      
          ps = rhs.ps;              // 将数据从 rhs 拷贝到本对象
          i = rhs.i;
          use = rhs.use;
          return *this;             // 返回本对象
      }

交换操作

  • 交换操作

    1、除了定义拷贝控制成员管理资源的类通常还要定义一个swap函数。对于要与重排元素顺序的算法一起使用的,定义swap尤为重要这类算法交换两个元素时会调用swap
    2、如果一个类定义了自己的swap,那么算法将使用类自定义版本的swap;否则,算法将使用标准库定义的swap
    3、我们虽然不知道swap如何实现的,但理论上很容易理解:其进行一次拷贝两次赋值。例如交换两个HasPtr的代码可能如下
    4、但理论上,这些内存分配都是不必要的,我们更希望swap交换指针,而不是分配string的副本。如下

    // 分配副本的方式
    HasPtr temp = v1;               // 创建 v1 的值的一个临时副本
    v1 = v2;                        // 将 v2 的值赋给 v1
    v2 = temp;                      // 将保存的 v1 的值赋予 v2
    
    // 交换指针的方式
    string *temp = v1.ps;           // 为 v1.ps 中的指针创建一个副本
    v1.ps = v2.ps;                  // 将 v2.ps 的指针赋给 v1.ps
    v2.ps = temp;                   // 将保存的 v1.ps 的指针赋予 v2.ps
  • 类的 swap

    1、可以在我们的上定义一个自定义的swap重载swap默认行为,其类型实现如下
    2、我们首先将swap定义为friend,以便访问数据成员。由于swap的存在就是为了优化代码,因此我们将其声明为inline内联函数swap函数体对给定对象的每个数据成员调用了swap
    3、swap函数应调用对应类swap,而不是std::swap。具体原因在下一小节介绍

    class HasPtr
    {
        friend void swap(HasPtr &lhs, HasPtr &rhs);
        // ...省略成员定义
    };
    
    inline void swap(HasPtr &lhs, HasPtr &rhs)
    {
        using std::swap;
        swap(lhs.ps, rhs.ps);       // 变换指针,而不是 string 数据
        swap(lhs.i, rhs.i);         // 变换 int 成员
    }
  • 应调用对应类 swap,而不是 std::swap

    1、上例中,数据成员内置类型的,而内置类型没有特定版本swap。因此,swap的调用会调用标准库std::swap
    2、但如果一个类有特定的swap,那么调用std::swap就是错误的了。如果有一个名为Foo,他有一个类型为HasPtr成员 h,如果未定义Fooswap,就会使用标准库版本swap,但其会对HasPtr管理的string进行不必要的拷贝
    3、我们可以Foo编写swap,来避免这些拷贝。但如下第一段代码此版本与简单使用默认版本swap没有任何性能差异,问题在于我们显式调用了标准库swap。我们不希望使用std::swap,而是希望使用HasPtrswap,因此正确的定义应如第二段代码
    4、特别注意:每个swap调用都应该是未加限定的,即每个调用都应该是swap而不是显式的std::swap。这种情况下,只要存在类型特定的swap,其匹配程度优先于std版本(即使使用using)。如果没有特定匹配,且作用域内有using std::swap;,才会使用std版本

    // 第一段(缺陷)
    void swap(Foo &lhs, Foo &rhs)
    {
        // 错误:该函数使用了 std::swap,而不是 HasPtr 版本的 swap
        std::swap(lhs.h, rhs.h);
        // ...交换类型 Foo 的其他成员
    }
    
    // 第二段(正确)
    void swap(Foo &lhs, Foo &rhs)
    {
        using std::swap;
        // 正确:即使使用 using 声明,只要不显式指定使用 std::swap,仍优先使用 HasPtr 版本的 swap
        swap(lhs.h, rhs.h);
        // ...交换类型 Foo 的其他成员
    }
  • 赋值运算符中使用 swap

    1、定义swap的类通常用swap定义赋值运算符这些运算符使用了一种名为拷贝并交换的技术,将左侧对象右侧对象的副本进行交换
    2、这个技术的有趣之处是他自动处理了自赋值情况且天然就能保证异常安全。它在改变左侧对象之前拷贝右侧对象,保证了自赋值的正确;能保证异常安全的原因是代码唯一可能抛出异常的是拷贝构造函数中的new,而它就算发生异常会也在改变左侧对象之前发生

    // 注意 rhs 是按值传递的,意味着 HasPtr 的拷贝构造函数将右侧对象的 string 拷贝到 rhs 中
    HasPtr &HasPtr::operator=(HasPtr rhs)   // rhs 是右侧对象的一个副本
    {
        // 交换左侧对象和局部变量 rhs 的内容
        swap(*this, rhs);                   // rhs 现在指向本对象曾经使用的内存
        return *this;                       // rhs 被销毁,从而 delete 了 rhs 中的指针
    }

拷贝控制示例

  • 描述

    1、虽然通常来说分配资源的类更需要拷贝控制,但资源管理不是一个类需要拷贝控制唯一原因,一些类也需要拷贝控制来进行簿记工作其他操作
    2、作为类需要拷贝控制来进行簿记工作的例子,我们将定义MessageFolder,分别表示电子邮件消息消息目录。每个Message的内容只有一个副本,以确保从任何Folder查看此Message都会看到修改后的内容。为了记录Message位于哪些Folder中,每个Message都保存一个它所在Folderset;同样,每个Folder都保存一个它包含Message的指针的set
    3、篇幅原因,我们在此只设计Message类,但我们假定Folder类有addMsgremMsg两个成员函数,分别用于在给定Folder对象添加或删除Message

  • Message 类的设计

    1、Message将提供saveremove操作,向一个给定Folder添加或删除Message。创建Message只需指明消息内容不会指出Folder,而将Message放到特定Folder必须调用save
    2、当拷贝Message时,副本原对象不同的Message对象,但两个Message出现在相同的Folder中。因此,拷贝操作包括消息内容Folder指针set的拷贝,而且我们必须在所有其所在的Folder添加指向新创建Message的指针
    3、销毁一个Message,它将不复存在。因此,我们必须从所有其所在的Folder删除指向该Message的指针
    4、当将一个Message赋予另一个Message对象时,左侧对象内容会被右侧对象内容所替代。我们还必须更新Folderset,从原来左侧对象Folder将它删除,并将它添加到右侧对象Folder

  • 定义 Message 类

    class Message
    {
        public:
            friend class Folder;                          // 友元 Folder 类
            friend void swap(Message &, Message &);       // 友元 swap 函数
    
            // 默认构造函数:数据成员 folders 被隐式初始化为空 set
            explicit Message(const string &str = "") : contents(str)
            {
            }
            // 拷贝控制成员:管理指向本 Message 的指针
            Message(const Message &);                     // 拷贝构造函数
            Message& operator=(const Message &);          // 拷贝赋值运算符
            ~Message();                                   // 析构函数
            // 从给定 Folder 的 set 中增加/删除本 Message
            void save(Folder &);
            void remove(Folder &);
    
        private:
            string contents;                              // 实际消息文本
            set<Folder*> folders;                         // 包含本 Message 的 Folder
            // 拷贝控制对象所使用的工具函数
            void add_to_Folders(const Message &);         // 将 Message 添加到指向参数的 Folder 中
            void remove_from_Folders();                   // 从 folders 中的每个 Folder 中移除本 Message
    };
  • save 和 remove 成员函数

    // 将 Message 保存到 Folder
    void Message::save(Folder &f)
    {
        folders.insert(&f);         // 将给定 Folder 添加到 folders 中
        f.addMsg(this);             // 将本 Message 添加到 f 的 set 中
    }
    
    // 将 Message 从 Folder 移除
    void Message::remove(Folder &f)
    {
        folders.erase(&f);          // 将给定 Folder 从 folders 中移除
        f.remMsg(this);             // 将本 Message 从 f 的 set 中移除
    }
  • 拷贝构造函数

    // 公共操作:将 Message 添加到指向参数的 Folder 中
    void Message::add_to_Folders(const Message &m)
    {
        for (auto f : m.folders) // 对每个包含 m 的 Folder
            f->addMsg(this);     // 向该 Folder 添加指向本 Message 的指针
    }
    
    // 拷贝构造函数:拷贝 contents 和 folders 数据成员
    Message::Message(const Message &m) : contents(m.contents), folders(m.folders)
    {
        add_to_Folders(m); // 将本 Message 添加到指向 m 的 Folder 中
    }
  • 析构函数

    // 公共操作:从 folders 中的每个 Folder 中移除本 Message
    void Message::remove_from_Folders()
    {
        for (auto f : folders) // 对 folders 中每个指针
            f->remMsg(this);   // 从该 Folder 中移除本 Message
    }
    
    // 析构函数
    Message::~Message()
    {
        remove_from_Folders();
    }
  • 拷贝复制运算符

    // 拷贝赋值运算符
    Message &Message::operator=(const Message &rhs)
    {
        // 通过先删除指针再插入对象,来处理自赋值情况
        remove_from_Folders();   // 更新 folders,删除左侧对象所在的 Folder 的记录
        contents = rhs.contents; // 从 rhs 拷贝消息内容
        folders = rhs.folders;   // 从 rhs 拷贝 folders
        add_to_Folders(rhs);     // 将该 Message 添加到指向 rhs 的 Folder 中
        return *this;
    }
  • swap 函数

    1、标准库定义了stringsetswap版本。因此如果我们的Message自定义它的swap,那么它将从中受益避免contentsfolders进行不必要的拷贝
    2、但同时,我们的自定义的swap还要管理被交换的指针,在Message被交换后原本指向m1Folder就要指向m2,反之亦然。这需要我们在swap定义相关操作

    // swap 函数
    void swap(Message &lhs, Message &rhs)
    {
        using std::swap; // 本例中严格来说并不需要,但这是一个好习惯
    
        // 将每个 Message 的指针从它原来所在 Folder 中删除
        for (auto f : lhs.folders)
            f->remMsg(&lhs);
        for (auto f : rhs.folders)
            f->remMsg(&rhs);
    
        // 交换 contents 和 folders
        swap(lhs.contents, rhs.contents); // 使用 string 类的 swap(string&, string&)
        swap(lhs.folders, rhs.folders);   // 使用 set 类的 swap(set&, set&)
    
        // 将每个 Message 的指针添加到新的 Folder 中
        for (auto f : lhs.folders)
            f->addMsg(&lhs);
        for (auto f : rhs.folders)
            f->addMsg(&rhs);
    }

动态内存管理类

  • 描述

    1、某些类需要在运行时分配可变大小的内存。这种类通常可以使用标准库容器保存它们的数据,例如之前定义的StrBlob使用vector管理其元素底层内存
    2、但是,这一策略并不适用于全部类,某些类需要自己分配内存,这些类通常必须自定义拷贝控制成员管理分配的内存
    3、我们将实现标准库vector简化版本,我们只实现其针对string的版本,命名为StrVec(只实现部分功能)

  • StrVec 类的设计

    1、回忆std::vector的行为:其将元素保存在连续内存中,预先分配足够内存来保存可能需要的更多元素。添加元素成员函数将检查是否有空间容纳更多元素:如果有,将在下个可用位置构造一个对象;如果没有,则重新分配空间,将已有元素移入新获得的空间释放旧内存添加新元素
    2、StrVec使用类似的策略,我们将使用一个allocator获得原始内存。由于allocator分配的内存未构造的,我们将在需要添加元素时用其construct成员创建对象;类似的,我们将使用destroy成员销毁元素
    3、每个StrVec三个指针指向其元素所使用的内存elements指向已分配的内存首元素first_free指向最后一个实际元素之后的位置(已分配内存的尾后,未分配内存的首);cap指向分配的整个内存的尾后位置。除了这些指针,StrVec还有一个类型为allocator<string>alloc静态成员,用于分配StrVec使用的内存
    4、该类还有四个工具函数alloc_n_copy分配内存,并拷贝给定范围的元素free销毁构造的元素释放内存chk_n_alloc保证StrVec至少有容纳一个新元素的空间,如果不够则添加新元素,调用reallocate分配内存reallocate内存用完时StrVec分配新内存

  • 定义 StrVec 类

    // 类 vector 的内存分配策略的简化实现
    class StrVec
    {
        public:
            // allocator 成员进行默认初始化
            StrVec() : elements(nullptr), first_free(nullptr), cap(nullptr)
            {
            }
            StrVec(const StrVec &);            // 拷贝构造函数
            StrVec &operator=(const StrVec &); // 拷贝赋值运算符
            ~StrVec();                         // 析构函数
            void push_back(const string &);    // 拷贝元素
    
            size_t size() const
            {
                return first_free - elements;
            }
            size_t capacity() const
            {
                return cap - elements;
            }
            string *begin() const
            {
                return elements;
            }
            string *end() const
            {
                return first_free;
            }
            // ...
    
        private:
            static allocator<string> alloc; // 分配元素
    
            // 被添加元素的函数所使用
            void chk_n_alloc()
            {
                if (size() == capacity())
                    reallocate();
            }
    
            // 工具函数,被拷贝构造函数、赋值运算符和析构函数所使用
            pair<string *, string *> alloc_n_copy(const string *, const string *);
            void free();        // 销毁元素并释放内存
            void reallocate();  // 获得更多内存并拷贝已有元素
            string *elements;   // 指向数组首元素的指针
            string *first_free; // 指向数组第一个空闲元素的指针(未分配内存的首)
            string *cap;        // 指向数组尾后位置的指针
    };
  • 使用 construct

    1、函数push_back调用chk_n_alloc确保有空间容纳新元素,如果需要,chk_n_alloc会调用reallocate。当chk_n_alloc返回时,push_back知道必定有空间容纳新元素,便可以要求allocator类型成员construct新的尾元素
    2、construct第一个参数是一个指针,指向调用allocate所分配的未构造内存空间剩余参数用于确定用哪个构造函数构造对象
    3、值得注意的是,对construct的调用也会递增first_free,表示已经构造了新元素,其使用前置递增

    // push_back 函数
    void StrVec::push_back(const string &s)
    {
        chk_n_alloc();                    // 确保有空间分配元素
        alloc.construct(first_free++, s); // 在 first_free 指向的元素中构造 s 的副本
    }
  • alloc_n_copy 成员

    1、我们在拷贝或赋值StrVec时,可能会调用alloc_n_copy成员函数。类似vector,我们的StrVec也要有类值的行为。当我们拷贝或赋值StrVec时必须分配独立的内存,并从原StrVec拷贝元素至新对象
    2、alloc_n_copy分配足够内存来保存给定范围的元素,并将这些元素拷贝到新分配的内存中。其返回一个指针的pair,分别指向新空间开始位置尾后位置

    // alloc_n_copy 函数
    pair<string *, string *> StrVec::alloc_n_copy(const string *b, const string *e)
    {
        // 分配空间保存给定范围中的元素
        auto data = alloc.allocate(e - b);
        // 初始化并返回一个 pair,该 pair 由 data 和 uninitialized_copy 的返回值(都是指针)构成
        return {data, uninitialized_copy(b, e, data)};
    }
  • free 成员

    1、free两个责任:首先destroy元素,然后释放StrVec自己分配的内存空间
    2、for调用allocatordestroy成员(其会运行string的析构函数),从构造的尾元素开始,到首元素为止逆序销毁所有元素。一旦元素被销毁,我们就调用deallocate释放本StrVec分配的内存

    // free 函数
    void StrVec::free()
    {
        // 不能传递给 deallocate 空指针,如果 elements 为 0,函数什么也不做
        if (elements)
        {
            // 逆序销毁旧元素
            for (auto p = first_free; p != elements; /* 空 */)
                alloc.destroy(--p);
            alloc.deallocate(elements, cap - elements);
        }
    }
  • 拷贝控制成员

    1、实现了alloc_n_copyfree成员后,想要实现拷贝控制成员就很简单了
    2、拷贝构造函数调用alloc_n_copy,将返回结果赋予数据成员alloc_n_copy返回值是一个指针的pair,其first指向新构造空间的首元素second指向尾后位置
    3、析构函数调用free
    4、拷贝赋值运算符释放已有内存前调用alloc_n_copy,以正确处理自赋值问题

    // 拷贝构造函数
    StrVec::StrVec(const StrVec &s)
    {
        // 调用 alloc_n_copy 分配空间
        auto newdata = alloc_n_copy(s.begin(), s.end());
        elements = newdata.first;
        first_free = cap = newdata.second;
    }
    
    // 析构函数
    StrVec::~StrVec()
    {
        free();
    }
    
    // 拷贝赋值运算符
    StrVec &StrVec::operator=(const StrVec &rhs)
    {
        // 调用 alloc_n_copy 分配空间
        auto data = alloc_n_copy(rhs.begin(), rhs.end());
        free();
        elements = data.first;
        first_free = cap = data.second;
        return *this;
    }
  • reallocate 成员

    • 在重新分配内存过程中移动而不是拷贝元素

      1、在编写用于重新分配内存reallocate之前,我们先思考此函数应该做什么:为一个新的更大的string分配内存;在内存空间前一部分构造对象保存现有元素销毁原内存元素释放这块内存
      2、我们可以看出,为一个StrVec重新分配内存会引起从旧内存到新内存逐个拷贝string。虽然我们不知道string具体实现细节,但知道string具有类值行为,因此拷贝string必须真的拷贝数据
      3、但对于reallocate要拷贝StrVecstring时,一旦将旧空间元素拷贝到新空间后,这些旧空间元素不再需要了,而又被释放。因此拷贝这些string的数据多余的,如果我们能避免分配和释放string额外开销StrVec性能会好得多

    • 移动构造函数和 std::move

      1、通过使用新标准库引入的两种机制,我们就可以避免string拷贝的花销
      2、首先,有一些标准类(包括string),都定义了移动构造函数。虽然关于其工作的细节具体实现都未公开,但我们知道移动构造函数用于将资源移动(而不是拷贝)到正在创建的对象,且移后源string保持有效可析构的状态
      3、第二个机制是名为move标准库函数,它定义在utility头文件中。目前关于move我们需要了解两点:首先,当reallocate在新内存构造string时,必须调用move表示希望使用移动构造函数,否则将使用拷贝构造函数;其次,我们通常不为move提供using声明,原因后续解释,当我们使用move时一般直接调用std::move

    • 定义 reallocate 成员

      // reallocate 成员
      void StrVec::reallocate()
      {
          // 我们将分配当前两倍大小的内存
          auto newcapacity = size() ? 2 * size() : 1;
          // 分配新内存
          auto newdata = alloc.allocate(newcapacity);
      
          // 将数据从旧内存移动到新内存
          auto dest = newdata;  // 指向新数组中下一个空闲位置
          auto elem = elements; // 指向旧数组中下一个元素
          for (size_t i = 0; i != size(); ++i)
              // 使用 move 表明使用移动构造函数
              alloc.construct(dest++, std::move(*elem++));
          free(); // 释放旧内存
      
          // 更新数据结构,执行新元素
          elements = newdata;
          first_free = dest;
          cap = elements + newcapacity;
      }

对象移动

  • 引入

    1、新标准一个最主要的特性是可以移动(而非拷贝)对象。很多情况下都会发生对象拷贝,但某些情况下对象拷贝后就立刻被销毁,这时移动对象(相比拷贝对象)会大幅提升性能
    2、使用移动而不是拷贝另一个原因是:对于像IOunique_ptr这样的类,都包含不能被共享的资源(指针或 IO 缓冲)。因此,这类对象不能拷贝但可以移动

  • 右值引用

    • 右值引用

      1、为了支持移动操作新标准引入了一种新的引用类型——右值引用。所谓右值引用就是必须绑定到右值的引用,我们通过&&(而不是&)来获得右值引用
      2、右值引用有一个重要性质:只能绑定到一个将要销毁的对象。因此我们可以自由地将一个右值引用的资源移动到另一个对象
      3、右值引用也只是某个对象的另一个名字。如我们所知,对于常规引用(为了区分,我们可称其为左值引用),我们不能将其绑定到要求转换的表达式字面常量返回右值的表达式;而右值引用与之相反,我们可以将它绑定到这类表达式上,但不能直接将它绑定到一个左值上
      4、返回左值引用函数,连同赋值下标解引用前置递增/递减运算符,都是返回左值表达式的例子,我们可以将左值引用绑定到这类表达式;返回非引用类型函数,连同算术关系以及后置递增/递减运算符,都生成右值,我们可以将右值引用const的左值引用绑定到这类表达式
      5、左值持久右值短暂左值持久的状态,而右值要么是字面常量,要么是表达式求值过程中创建的临时变量。由于右值引用只能绑定到临时对象,我们得知所引用的对象将要被销毁且其没有其他用户,这意味着使用右值引用的代码可以自由地接管所引用对象的资源

      int i = 42;
      int &r = i;                   // 正确:r 引用 i
      int &&rr = i;                 // 错误:不能把一个右值引用绑定到左值
      int &r2 = i * 42;             // 错误:i * 42 是一个右值
      const int &r3 = i * 42;       // 正确:我们可以将一个 const 的引用绑定到右值上
    • 变量是左值

      1、变量可以看作只有一个运算对象没有运算符表达式,类似其他任何表达式,变量表达式也有左值/右值属性
      2、变量表达式都是左值。带来的结果是,我们不能将一个右值引用绑定到右值引用类型变量
      3、在了解到右值表示临时对象这一结果后,变量是左值这一结论就并不令人惊讶了,毕竟变量是持久的,直到离开作用域被销毁

      int &&rr1 = 42;               // 正确:字面常量是右值
      int &&rr2 = rr1;              // 错误:表达式 rr1 是左值
    • std::move

      1、虽然不能将右值引用直接绑定到左值,但可以显式地左值转换为对应右值引用类型。我们还可以通过调用move标准库函数来获得绑定到左值上的右值引用move定义在头文件utility中,其使用将在 16 章描述的机制来返回给定对象的右值引用
      2、如下例,move调用告诉编译器,我们有一个左值,但我们希望像右值一样处理它。调用move就意味着承诺除了对rr1赋值或销毁外,我们将不再使用它
      3、在调用move后,我们不能对移后源对象的值做任何假设。我们可以销毁一个移后源对象,或赋予它新值,但不能使用一个移后源对象的值
      4、如前所述,与大多数标准库名字不同,我们通常不对move使用using声明,而直接调用std::move

      int &&rr3 = std::move(rr1);   // 正确:但只可以对 rr1 进行赋值或销毁,不能使用 rr1 的值
  • 移动构造函数和移动赋值运算符

    • 移动构造函数

      1、类似string或其他标准库类,如果我们自定义的类同时支持移动和拷贝,那么也能从中受益。为此需要定义移动构造函数移动赋值运算符,这两个成员类似对应的拷贝操作,但它们从给定对象“窃取资源而不是拷贝资源
      2、移动构造函数第一个参数也是该类类型的引用,但它是一个右值引用,且函数的任何额外参数必须有默认实参
      3、除了资源移动移动构造函数还必须确保移后源对象销毁后无害的。特别是,一旦资源完成移动,源对象必须不再指向被移动的资源——这些资源的所属权已经归属了新的对象
      4、如下例,我们为StrVec定义了移动构造函数。其中noexcept用于通知标准库我们的构造函数不会抛出任何异常,具体将在后续介绍
      5、移动构造函数不分配任何内存,它接管给定StrVec的内存。接管内存后,它将给定对象中的指针置为nullptr,这样就完成了从给定对象的移动操作,此对象将继续存在。最终,源对象被销毁并运行其析构函数

      StrVec::StrVec(StrVec &&s) noexcept   // 表示移动操作不会抛出任何异常
          : elements(s.elements), first_free(s.first_free), cap(s.cap)  // 初始化成员接管 s 中的资源
      {
          // 令 s 进入“对其运行析构函数是安全的”的状态
          s.elements = s.first_free = s.cap = nullptr;
      }
    • 移动操作、标准库容器和异常

      1、由于移动操作不分配任何资源,因此移动操作通常不会抛出任何异常,我们应当将此事通知标准库,否则标准库会认为类对象随时可能抛出异常,并为了处理这种可能额外做一些准备
      2、一种通知标准库的方法是在构造函数中指明noexceptnoexcept新标准引入的,我们将在 18 章讨论更多细节
      3、目前重要的是,知道noexcept是我们承诺函数不抛出异常的一种方法,且我们在一个函数的参数列表后指定noexcept。对于构造函数,其出现在参数列表初始化列表的冒号之间,且如果要分开声明和定义构造函数,必须在类的声明定义都指定noexcept

    • 移动赋值运算符

      1、移动赋值运算符执行与析构函数移动构造函数相同的工作。与移动构造函数一样,如果其不抛出任何异常,应把它标记为noexcept;类似拷贝赋值运算符移动赋值运算符也必须正确处理自赋值
      2、如下例,我们直接检查this指针rhs的地址是否相同。如果相同,则左右两侧的对象指向相同的对象,不需要做任何事情;否则,我们释放左侧对象的内存,并接管右侧对象的内存,再将右侧对象的指针置空

      StrVec &StrVec::operator=(StrVec &&rhs) noexcept
      {
          // 直接检测自赋值
          if(this != &rhs)
          {
              free();                   // 释放已有元素
              elements = rhs.elements;  // 从 rhs 接管资源
              first_free = rhs.first_free;
              cap = rhs.cap;
              // 将 rhs 置于可析构状态
              rhs.elements = rhs.first_free = rhs.cap = nullptr;
          }
          return *this;
      }
    • 移后源对象必须可析构

      1、从一个对象移动数据并不会销毁对象,但有时在移动操作完成后源对象会被销毁。因此,当我们编写一个移动操作时,必须确保移后源对象进入可析构状态。如上例中,我们将右侧对象的指针全部置空
      2、除了将移后源对象置为析构安全的状态外,移动操作还必须保证对象仍然有效。一般来说,对象有效就是指可以安全地为其赋值或可以安全地使用不依赖其当前值。另一方面,移动操作移后源对象留下的值没有任何要求,因此程序不应该依赖于移后源对象的数据
      3、例如,当我们从string对象移动数据时,我们知道移后源对象仍然有效,因此我们可以执行emptysize这些操作。但是我们不知道会得到什么结果,我们可能期望一个移后源对象是空的,但这并没有保证。因此移后源对象虽然保持有效可析构的状态,但用户不能对其值进行任何假设

    • 合成的移动操作

      1、与处理拷贝构造函数拷贝赋值运算符一样,编译器也会合成移动构造函数移动赋值运算符。但是合成移动操作的条件与前者大不相同
      2、不同于拷贝操作,当我们不声明移动操作时,编译器不会为某些类合成移动操作,特别如果一个类定义了自己的拷贝操作,编译器更不会为其合成移动操作。因此,某些类没有移动操作,而这些类会用对应的拷贝操作代替移动操作
      3、只有当一个类没有定义任何自己的拷贝控制成员,且每个static数据成员可以移动时,编译器才会合成移动构造函数移动赋值运算符。编译器可以移动内置类型成员,如果一个成员是类类型且有对应的移动操作,那么也能移动这个类成员
      4、移动操作永远不会隐式定义为删除的函数。但是如果我们显式要求编译器生成=default的移动操作,且编译器不能移动所有成员,则会将其定义为删除的函数。定义一个移动操作的类也必须定义了拷贝操作,否则这些成员默认地被定义为删除

      // 编译器会为 X 和 hasX 合成移动操作
      struct X
      {
          int i;                        // 内置类型可以移动
          std::string s;                // string 定义了自己的移动操作
      };
      struct hasX
      {
          X mem;                        // X 有合成的移动操作
      };
      
      X x1, x2 = std::move(x1);         // 使用合成的移动构造函数
      hasX hx1, hx2 = std::move(hx1);   // 使用合成的移动构造函数
    • 移动与拷贝操作的选择

      1、移动右值拷贝左值:如果一个类既有拷贝操作又有移动操作编译器将使用普通的函数匹配规则来确定使用哪个函数。赋值操作的情况类似
      2、没有移动操作右值也被拷贝:没有移动操作时,编译器不会合成移动操作函数匹配规则保证该类型对象会被拷贝,即使试图调用move也是如此

      // 移动右值,拷贝左值
      StrVec v1, v2;
      v1 = v2;                          // v2 是左值,所以匹配拷贝赋值操作
      StrVec getVec(istream &);         // 假定 getVec 返回一个右值
      v2 = getVec(cin);                 // getVec(cin) 是一个右值,匹配移动赋值操作
      
      // 右值也被拷贝
      class Foo
      {
          public:
              Foo() = default;
              Foo(const Foo&);          // 拷贝构造函数
              // 其他成员定义,但 Foo 未定义移动构造函数
      };
      Foo x;
      Foo y(x);                         // 拷贝构造函数,x 是一个左值
      Foo z(std::move(x));              // 拷贝构造函数,因为未定义移动构造函数
    • 定义Message类的移动操作

      1、回想先前定义的MessageFolder就应该定义移动操作,这样Message就可以使用stringset的移动操作避免拷贝contentsfolders成员的额外开销
      2、但是,除了移动folder成员,我们还必须更新每个指向原MessageFolder。我们必须删除旧Message指针,并添加新Message指针。由于两个移动操作都需要更新指针,我们先定义这一操作,如下
      3、Message移动构造函数调用move移动contents,并默认初始化自己的folders成员;而移动赋值运算符直接检查自赋值情况

      // 从本 Message 移动 Folder 指针
      void Message::move_Folders(Message *m)
      {
          folders = std::move(m->folders);    // 使用 set 的移动赋值运算符
          for (auto f : folders)              // 对于每个 Folder
          {
              f->remMsg(m);                   // 从 Folder 中删除旧 Message
              f->addMsg(this);                // 将本 Message 添加到 Folder 中
          }
          m->folders.clear();                 // 确保销毁 m 是无害的
      }
      
      // 移动构造函数
      Message::Message(Message &&m) : contents(std::move(m.contents))
      {
          move_Folders(&m); // 移动 folders 并更新 Folder 指针
      }
      
      // 移动赋值运算符
      Message& Message::operator=(Message &&rhs)
      {
          if(this != &rhs)  // 直接检查自赋值情况
          {
              remove_from_Folders();
              contents = std::move(rhs.contents); // 移动赋值运算符
              move_Folders(&rhs); // 重置 Folders 指向本 Message
          }
          return *this;
      }
    • 移动迭代器

      1、先前StrVecreallocate成员使用了一个for来调用construct旧内存将元素拷贝到新内存。作为一种替换方法,我们可以调用uninitialized_copy构造新分配的内存,且更为简单。但是,uninitialized_copy对元素进行拷贝操作(而不是移动),标准库中并没有类似的函数将对象移动到未构造内存
      2、新标准库定义了一种移动迭代器适配器,其通过改变给定迭代器的解引用运算符的行为来适配此迭代器,移动迭代器解引用运算符生成一个右值引用
      3、我们通过调用标准库make_move_iterator函数将一个普通迭代器转换为一个移动迭代器。其接受一个迭代器参数,返回一个移动迭代器
      4、原迭代器所有操作移动迭代器都能正常工作,因此我们可以将其传递给算法,比如uninitialized_copy

      void StrVec::reallocate()
      {
          // 分配大小两倍于当前规模的内存空间
          auto newcapacity = size() ? 2 * size() : 1;
          auto first = alloc.allocate(newcapacity);
      
          // 移动元素
          auto last = uninitialized_copy(make_move_iterator(begin()), make_move_iterator(end()), first);
          free();                     // 释放旧空间
          elements = first;           // 更新指针
          first_free = last;
          cap = elements + newcapacity;
      }
  • 右值引用和成员函数

    • 移动版本的成员函数

      1、除了构造函数赋值运算符,如果一个成员函数同时提供拷贝和移动版本,它也能从中受益。这种允许移动的成员函数需要遵循一种参数模式:一个版本接受一个指向const的左值引用,第二个版本接受一个指向非const的右值引用
      2、一般来说,我们不需要定义接受const X&&X&参数的函数版本。当我们希望从实参窃取数据时,通常传递一个右值引用,因此实参不能是const的;类似的,从一个对象拷贝数据不应该改变对象,因此不需要接受X&的版本
      3、如下例,我们为先前StrVecpush_back函数定义拷贝和移动版本。当我们调用push_back时,实参类型决定新元素是拷贝还是移动到容器中

      // 简单示例
      void push_back(const X&);   // 拷贝:绑定到任意类型的 X
      void push_back(X&&);        // 移动:只能绑定到类型 X 的可修改的右值
      
      // StrVec 的 push_back
      class StrVec
      {
          public:
              void push_back(const string&);      // 拷贝元素
              void push_back(string&&);           // 移动元素
              // ...其他成员定义,如前
      };
      // 拷贝版本的 push_back
      void StrVec::push_back(const string &s)
      {
          chk_n_alloc();                          // 确保有足够空间容纳元素
          alloc.construct(first_free++, s);       // 在 first_free 指向的元素中构造 s 的一个副本
      }
      // 移动版本的 push_back
      void StrVec::push_back(string &&s)
      {
          chk_n_alloc();                          // 确保有足够空间容纳元素
          alloc.construct(first_free++, std::move(s));
      }
      
      StrVec vec;                                 // 空 vec
      string s = "some string or another";
      vec.push_back(s);                           // 调用拷贝版本
      vec.push_back("done");                      // 调用移动版本
    • 左/右值引用成员函数与函数重载

      1、通常,我们在一个对象调用成员函数,而不管该对象是左值还是右值,如下例。在旧标准中,我们没法阻止这种使用方式,为了维持向后兼容性新标准库仍然允许向右值赋值。但是,我们可能希望在自定义的类阻止这种用法,希望强制左侧运算对象(即this指向的对象)是左值
      2、我们指出this左/右值属性的方式与定义const成员函数类似,即在参数列表后放置一个引用限定符引用限定符可以是&&&,分别指出this可以指向一个左值一个右值
      3、引用限定符只能用于static成员函数,且必须同时出现函数声明和定义中。一个函数可以同时用const引用限定,但引用限定符必须跟在const后面
      4、就像一个成员函数可以根据是否const区分重载版本引用限定符也可以区分重载版本。而且我们可以综合引用限定符const区分一个成员函数的重载版本

      // 令人惊讶的调用方式
      string s1 = "a value", s2 = "another";
      auto n = (s1 + s2).find('a');     // 在一个右值 (s1 + s2),即两个字符串组合的结果上调用了 find 成员
      s1 + s2 = "wow!";                 // 对一个右值 (s1 + s2),即两个字符串组合的结果上进行了赋值
      
      // 引用限定符
      class Foo
      {
          public:
              Foo &operator=(const Foo&) &;   // 只能向可修改的左值赋值
              Foo func() &&;                  // 只能操作不可修改的右值
              Foo someMem() & const;          // 错误:const 限定符必须在前
              Foo anotherMem() const &;       // 正确:const 限定符在前
      };

重载运算与类型转换


章节概要:重载运算符;使用重载运算符;重载运算符的设计;输入输出运算符;算术和关系运算符;赋值运算符;下标运算符;递增递减运算符;成员访问运算符;lambda是函数对象;标准库函数对象;可调用对象与function;标准库function类型;重载、类型转换与运算符;类型转换运算符;避免有二义性的类型转换;函数匹配与重载运算符

重载运算符

  • 使用重载运算符

    • 重载运算符

      1、重载运算符是具有特殊名字的函数:它们的名字由关键字operator后接要定义的运算符共同组成。重载运算符也包含返回类型参数列表函数体
      2、重载运算符参数数量与其作用的运算对象数量一样,一元运算符一个参数二元运算符两个参数。且对于二元运算符左侧对象传递给第一个参数右侧对象传递给第二个参数。除了重载的函数调用运算符operator()之外,其他重载运算符不能含有默认参数。如果一个运算符函数成员函数,那它的左侧对象绑定到隐式的this指针
      3、对于一个运算符函数,它要么是类的成员,要么至少含有一个类类型参数,这意味着当运算符作用于内置类型运算对象时,我们无法改变其含义。我们只能重载运算符,不能发明运算符。对于一个重载运算符来说,其优先级结合律与对应的内置运算符一致
      4、对于+-*&这四个既是一元也是二元的运算符来说,也可以被重载,其会从参数数量判断我们定义的是哪种运算符。我们不能重载::.*.?:运算符,其余可被重载的运算符如下表

      可重载 可重载 可重载 可重载 可重载 可重载
      + - * / % ^
      & | ~ ! , =
      < > <= >= ++
      << >> == != && ||
      += -= /= %= ^= &=
      |= *= <<= >>= [] ()
      -> ->* new new[] delete delete[]
    • 直接调用重载运算符函数

      1、通常情况下,我们将运算符作用于类型正确的实参,用这种间接方式调用重载运算符函数
      2、然而,我们也可以像调用普通函数那样直接调用运算符函数,先指定函数名,然后传入数量正确类型适当实参
      3、我们也可以像调用其他成员函数一样显式调用成员运算符函数

      data1 + data2;                  // 普通的表达式
      operator+(data1, data2);        // 等价的函数调用
      
      data1 += data2;                 // 基于调用的表达式
      data1.operator+=(data2);        // 对成员运算符函数的等价调用
    • 某些运算符不应被重载

      1、由于某些运算符指定了运算对象求值的顺序,又因为重载运算符本质上是函数调用,所以这些关于求值顺序的规则无法应用到重载运算符上。比如&&||,运算符
      2、此外,&&||重载版本无法保留内置运算符的短路求值属性,两个运算对象总是会被求值,因此不建议重载它们
      3、还有一个原因使得我们一般不重载,&运算符C++语言已经定义了这两种运算符用于类类型对象特殊含义,所以它们不应该被重载,否则它们的异常行为将导致类的用户无法适应

  • 重载运算符的设计

    • 使用与内置类型一致的含义

      1、当你开始设计一个类时,首先应该考虑的是这个类将提供哪些操作,然后再思考应该把每个操作定义为普通函数还是重载运算符。如果某些操作在逻辑上与运算符相关,才适合定义成重载运算符
      2、具体如下:如果类执行IO操作,则定义移位运算符使其与内置类型IO保持一致;如果某个操作检查相等性,则定义operator==,并且通常它也应该有operator!=;如果类包含一个内在的单序比较操作,则定义operator<,并且通常它也应该有其他关系操作
      3、除此之外,重载运算符返回类型通常应该与内置版本返回类型兼容:逻辑运算符关系运算符应返回bool算术运算符应返回类类型的值赋值运算符复合赋值运算符应该返回左侧对象引用

    • 选择作为成员或非成员

      1、当我们定义重载运算符时,首先要决定将其声明为类的成员函数还是一个普通的非成员函数。某些情况下我们别无选择,因为有的运算符必须作为成员;另一些情况下,运算符作为普通函数比作为成员函数更好
      2、这些准则有助于我们做出抉择:=[]()->运算符必须是成员复合赋值运算符=略有不同,其一般是成员(并非必须);改变对象状态的运算符或者与给定类型密切相关的运算符,例如++--*运算符,通常是成员具有对称性的运算符可能转换任意一端的运算对象,例如算术相等性关系位运算等,通常是普通非成员
      3、程序员希望能在含有混合类型表达式中使用对称性运算符。例如,我们能求一个int和一个double的和,因为它们中任意一个都可以是左侧对象右侧对象,所以加法是对称的。如果我们想提供含有类对象混合类型表达式,则运算符必须定义成非成员函数,原因如下
      4、如果我们把运算符定义为成员函数,他的左侧对象必须是运算符所属类对象,如下例。现实中,因为stringoperator+定义成普通的非成员函数,所以"hi" + s才能等价于operator+("hi", s)正常执行操作

      // 假设 operator+ 只是 string 类的成员
      string s = "world";
      string t = s + "!";     // 正确:左侧对象是 string,可以调用 operator+ 将一个 const char* 添加到 string 中
      string u = "hi" + s;    // 错误:左侧对象是 const char*,其本身作为内置类型根本没有成员函数,也无法调用到 string 重载的 operator+ 运算符

输入输出运算符

  • 概述

    1、如我们所知,IO分别使用>><<来执行输入输出操作
    2、对于这两个操作,IO定义了其读写内置类型的版本,而对于则需要自定义新版本支持IO操作

  • 输出运算符 <<

    1、通常情况下,输出运算符第一个形参非常量ostream的引用(表示左侧对象);第二个形参是一个类类型常量引用(表示右侧对象);为了与内置类型一致,一般返回它的ostream形参。如下例,是重载Sales_data输出运算符
    2、用于内置类型输出运算符不太考虑格式化操作,尤其不会打印换行符,用户也希望重载的输出运算符也能如此行事。令输出运算符尽量减少格式化操作,可以使用户有权控制输出的细节
    3、输入输出运算符必须是非成员函数,否则它们的左侧对象将是我们的类对象(成员重载运算符的左侧对象绑定到this),即调用会变成data << cout,违背平常的使用习惯

    // 必须定义为非成员函数,参数第一个形参是左侧对象,第二个形参是右侧对象
    ostream &operator<<(ostream &os, const Sales_data &item)
    {
        os << item.isbn() << ' ' << item.units_sold << ' ' << item.revenue << ' ' << item.avg_price();
        return os;
    }
  • 输入运算符 >>

    1、通常情况下,输入运算符第一个形参将读取的流的引用第二个形参类类型非常量引用;通常返回给定流的引用。如下例,是重载Sales_data输入运算符
    2、注意,输入运算符必须处理输入可能失败的情况,在执行输入运算符时可能出现以下错误:当含有错误类型数据时,读取操作可能失败;当读取操作到达文件末尾或者遇到输入流其他错误时也会失败
    3、在该程序中,我们没有逐个检查读取操作,而是等读取结束后赶在使用数据前直接一次性检查。当读取操作发生错误时,输入运算符应当负责从错误中恢复,如该例中将对象置为合法状态(尽管数据错误,但技术上正确),我们能略微保护使用者免受错误影响
    4、但是,一些输入运算符需要做更多数据验证工作标示错误,例如该例中发生错误丢弃数据时,应当设置流的条件状态标示失败信息。通常情况下,输入运算符只设置failbit表示出现错误,但此外还可以设置eofbit表示文件耗尽,设置badbit表示流被破坏。最好的方式是IO库自己标示这些错误

    istream &operator>>(istream &is, Sales_data &item)
    {
        double price;                                   // 先把数据读入 price 再使用
        is >> item.bookNo >> item.units_sold >> price;  // 读入所有数据
        if (is)                                         // 检查输入是否成功
            item.revenue = item.units_sold * price;
        else
            item = Sales_data();                        // 输入失败:对象被赋予默认状态
        return is;
    }

算术和关系运算符

  • 概述

    1、通常情况下,我们把算术运算符关系运算符定义为非成员函数以允许左右侧对象进行转换
    2、这些运算符一般不需要改变运算符对象的状态,所以形参都是常量引用

  • 算术运算符 +

    1、算术运算符通常会计算两个运算对象得到一个新值,这个值常常位于一个局部变量内,操作完成后返回该局部变量的副本作为结果
    2、如果类定义使用算术运算符,则它一般也会定义对应的复合赋值运算符,此时使用复合赋值运算符来定义算术运算符是最有效的方法,如下

    Sales_data operator+(const Sales_data &lhs, const Sales_data &rhs)
    {
        Sales_data sum = lhs;         // 把 lhs 的数据成员拷贝给 sum
        sum += rhs;                   // 使用复合赋值运算符(前提是已定义)
        return sum;
    }
  • 相等运算符 ==

    1、通常情况下,类通过定义相等运算符来检验两个对象是否相等。也就是说,它们会比较对象的每一个数据成员,只有全部相等时才认为两个类相等
    2、据此,我们的Sales_data相等运算符不但应该比较bookNo,还应该比较具体销售数据,如下
    3、这体现了相等运算符设计准则:如果一个类含有判断两个对象是否相等的操作,一定要重载运算符而不是定义为普通函数,以方便用户使用;如果类定义了operator==,则其应该能判断一组对象中是否含有重复数据;通常情况下,相等运算符应该具有传递性;如果类定义了operator==也应该定义operator!=相等不相等中的其中一个应该把工作委托给另一个

    bool operator==(const Sales_data &lhs, const Sales_data &rhs)
    {
        return lhs.isbn() == rhs.isbn() &&
            lhs.units_sold == rhs.units_sold &&
            lhs.revenue == rhs.revenue;
    }
    bool operator!=(const Sales_data &lhs, const Sales_data &rhs)
    {
        return !(lhs == rhs);
    }
  • 关系运算符 <

    1、定义了相等运算符的类也常常(但不总是)包含关系运算符。特别是,因为关联容器一些算法要用到<,因此定义operator<比较有用
    2、通常情况下关系运算符应该:定义顺序关系,令其与关联容器对关键字的要求一致,并且如果类同时含有==,则定义一种关系令其==保持一致。特别是,如果两个对象!=的,那么一个对象应该<另一个对象
    3、尽管我们可能认为Sales_data也应该支持关系运算符,但其并不需要,因为其不存在一种可靠的<定义(按照什么排序,需要用到什么的排序),因此这个类不定义<也许更好
    4、如果存在唯一一种逻辑可靠的<定义,则应该考虑为这个类定义<;如果这个类同时还包含==,则当且仅当<的定义和==产生一致结果时才定义<

赋值运算符

  • 赋值运算符 =

    1、之前在 13 章已经介绍过拷贝复制运算符移动赋值运算符,它们可以把类对象赋值给该类另一个对象。此外,类还可以定义其他赋值运算符以使用别的类型作为右侧对象
    2、例如,标准库vector还允许接受花括号列表作为参数(如下),我们也可以把这个运算符加到我们自定义的StrVec中,其返回左侧对象引用
    3、通常情况下,我们更倾向于将赋值运算符定义在类的内部

    // vector 接受花括号列表作为参数
    vector<string> v;
    v = {"a", "an", "the"};
    
    // 在 StrVec 中定义
    class StrVec
    {
        public:
            StrVec& operator=(initializer_list<string>);
            // ...其他成员不变
    };
    StrVec& StrVec::operator=(initializer_list<string> il)
    {
        // alloc_n_copy 分配内存空间并从给定范围拷贝元素
        auto data = alloc_n_copy(il.begin(), il.end());
        free();                   // 销毁对象中的元素并释放内存空间
        elements = data.first;    // 更新数据成员使其指向新空间
        first_free = cap = data.sencond;
        return *this;
    }
  • 复合赋值运算符 +=

    1、复合赋值运算符不非得是类成员,但我们也还是更倾向于将其定义在类内。其返回左侧对象引用
    2、下面是Sales_data复合赋值运算符的定义

    // 作为成员的二元运算符,左侧对象绑定到隐式的 this
    // 假定两个对象表示的是同一本书
    Sales_data& Sales_data::operator+=(const Sales_data &rhs)
    {
        units_sold += rhs.units_sold;
        revenue += rhs.revenue;
        return *this;
    }

下标运算符

  • 下标运算符 []

    1、下标运算符operator[]必须是成员函数。为了与原始定义兼容,下标运算符通常以所访问元素的引用作为返回值,这样的好处是下标可以出现在赋值运算符任意一端
    2、进一步,我们最好同时定义下标运算符的常量版本非常量版本,以返回对应类型的数值。由于下标运算符返回元素的引用,因此当元素是常量,我们便不能为其赋值了。如下,我们定义StrVec下标运算符

    class StrVec
    {
        public:
            // 必须是成员函数,通常有 const 和非 const 版本
            string& operator[](size_t n)
            {
                return elements[n];
            }
            const string& operator[](size_t n) const
            {
                return elements[n];
            }
            // ...其他成员
    
        private:
            string *elements; // 指向数组首元素的指针
    };
    
    // 假设 svec 是一个 StrVec 对象
    const StrVec cvec = svec;
    // 如果 svec 含有元素,对第一个元素运行 string 的 empty 函数
    if(svec.size() && svec[0].empty())
    {
        svec[0] = "zero";   // 正确:下标运算符返回 string 的引用
        cvec[0] = "zip";    // 错误:对 cvec 取下标返回的是常量引用
    }

递增递减运算符

  • 前置递增递减运算符

    1、为了说明递增递减运算符,我们直接在StrBlobPtr定义它们,其应返回递增递/减后的对象引用,如下例
    2、递增运算符中,我们把curr的值传递给check,如果其小于vector的大小,则check正常返回;否则说明已到达末尾抛出异常
    3、递减运算符先递减curr,再调用check。此时,如果递减前curr(无符号数)已经是 0 了,则传给check的值将是一个表示无效下标非常大的正数,也一定超过vector大小抛出异常

    class StrBlobPtr
    {
        public:
            // 前置递增递减运算符
            StrBlobPtr& operator++();
            StrBlobPtr& operator--();
            // ...其他成员
    };
    
    // 前置递增递减运算符
    StrBlobPtr& StrBlobPtr::operator++()
    {
        // 如果 curr 已经指向尾后位置,则无法递增
        check(curr, "increment past end of StrBlobPtr");    // 检查 curr
        ++curr;                                             // 递增 curr
        return *this;
    }
    StrBlobPtr& StrBlobPtr::operator--()
    {
        // 如果 curr 是 0,则继续递减会产生一个无效下标
        --curr;                                             // 递减 curr
        check(curr, "decrement past begin of StrBlobPtr");  // 检查 curr
        return *this;
    }
  • 区分前置和后置运算符

    1、想要同时定义前置和后置运算符,必须首先解决区分问题,因为普通的重载形式无法区分这两种情况
    2、为此,后置版本接受一个额外的(不被使用的)int形参。当我们使用后置运算符时,编译器为这个形参提供一个值为 0 的实参
    3、这个形参的唯一作用就是区分前置和后置版本而不是真的要在实现后置版本参与运算

  • 后置递增递减运算符

    1、对于后置版本来说,在递增/递减之前需要先记录对象的状态,以供返回值使用
    2、后置版本不需要直接检查范围,我们将调用前置版本来实现操作,在前置版本中才会检查范围递增/递减
    3、操作执行后,对象被递增/递减,但我们将返回提前记录下的对象的状态,因此返回一个对应对象(而无须对象引用)

    class StrBlobPtr
    {
        public:
            // 后置递增递减运算符
            StrBlobPtr operator++(int);                     // 后置运算符的 int 形参
            StrBlobPtr operator--(int);
            // ...其他成员
    };
    
    // 后置递增递减运算符
    StrBlobPtr StrBlobPtr::operator++(int)                  // 后置运算符的 int 形参
    {
        // 此处无须检查有效性,调用前置递增运算时才需要
        StrBlobPtr ret = *this;                             // 记录当前值
        ++*this;                                            // 调用前置运算符前移一个元素(需要保证前置运算符已定义)
        return ret;                                         // 返回当前记录的值,且源对象的值已递增
    }
    StrBlobPtr StrBlobPtr::operator--(int)
    {
        // 此处无须检查有效性,调用前置递减运算时才需要
        StrBlobPtr ret = *this;                             // 记录当前值
        --*this;                                            // 调用前置运算符后移一个元素(需要保证前置运算符已定义)
        return ret;                                         // 返回当前记录的值,且源对象的值已递减
    }

成员访问运算符

  • 成员访问运算符 *和->

    1、在迭代器智能指针中常常用到解引用运算符*箭头运算符->,我们以如下形式StrBlobPtr添加这两种运算符
    2、->必须是类的成员*通常也是类的成员,尽管并非必须如此,但几乎也总是这样
    3、值得注意的是,这两个运算符定义成了const成员,因为获取元素并不会改变对象状态;此外它们的返回值分别是非常量对象引用对象指针,因为一个StrBlobPtr只能绑定到非常量StrBlobPtr对象

    class StrBlobPtr
    {
        public:
            string& operator*() const
            {
                auto p = check(curr, "dereference past end");
                return (*p)[curr];  // (*p) 是对象所指的 vector
            }
            string* operator->() const
            {
                // 将实际工作委托给解引用运算符
                return & this->operator*();
            }
            // ...其他成员
    };

函数调用运算符

  • 函数调用运算符

    • 描述

      1、如果类重载了函数调用运算符,则我们可以像使用函数一样使用该类对象。因为这样的类同时也能存储状态,所以与普通函数相比它们更灵活
      2、函数调用运算符必须是成员函数。如下例,我们设计absInt类含有一个调用运算符,其返回参数的绝对值。使用时,令一个absInt对象absObj作用于一个实参列表,这一过程看起来非常像函数调用的过程。即使absObj只是一个对象而非函数,但我们也能调用该对象
      3、如果类定义了调用运算符,则该类对象称作函数对象,因为可以调用这些对象,所以说这些对象行为像函数一样

      struct absInt
      {
          // 函数调用运算符 operator()
          int operator()(int val) const
          {
              return val < 0 ? -val : val;
          }
      };
      
      int i = -42;
      absInt absObj;        // 含有函数调用运算符的对象
      int ui = absObj(i);   // 将 i 传递给 absObj.operator()
    • 含有状态的函数对象类

      1、和其他类一样,函数对象类除了operator()之外也可以包含其他成员。函数对象类通常含有一些数据成员,这些成员被用于定制调用运算符中的操作
      2、如下例,我们定义一个打印string实参内容的类。该类的构造函数接受一个输出流引用和一个用于分隔的字符,且都设置了默认值,之后的函数调用运算符中使用数据成员来协助打印给定的string
      3、函数对象常常作为泛型算法实参,例如可以使用标准库for_each算法和我们的PrintString打印容器内容for_each(vs.begin(), vs.end(), PrintString(cerr, '\n'))。其中第三个实参PrintString的一个临时对象,用cerr'\n'初始化该对象,我们将把容器vs中的元素依次打印到cout用换行符分隔

      class PrintString
      {
          public:
              // 默认构造函数
              PrintString(ostream &o = cout, char c = ' ') : os(o), sep(c)
              {
              }
              // 函数调用运算符
              void operator()(const string &s) const
              {
                  os << s << sep;
              }
      
          private:
              ostream &os;  // 用于写入的目的流
              char sep;     // 用于将不同输出隔开的字符
      };
      
      PrintString printer;              // 使用默认值,打印到 cout
      printer(s);                       // 在 cout 中打印 s,后面跟一个空格
      PrintString errors(cerr, '\n');   // 构造新的 PrintString 函数对象并初始化
      errors(s);                        // 在 cerr 中打印 s,后面跟一个换行符
  • lambda 是函数对象

    • 描述

      1、上一小节,我们使用一个PrintString对象作为调用for_each的实参,这个用法和我们先前编写的使用lambda表达式的程序类似。实际上,当我们编写了一个lambda后,编译器将该表达式翻译成一个未命名类未命名对象
      2、在lambda表达式产生的类中含有一个重载的函数调用运算符。如下例,对于我们传递给stable_sort作为最后一个实参lambda表达式来说,其行为类似于下面的的一个未命名对象,我们也可以用这个对象替换实参中的lambda

      // 根据单词长度对其排序,对于长度相同的单词按照字母表顺序排序
      stable_sort(words.begin(), words.end(), [](const string &a, const string &b) { return s1.size() < s2.size(); });
      
      // lambda 等同于下面的类的未命名对象
      class ShorterString
      {
          public:
              bool operator()(const string &s1, const string &s2) const
              {
                  return s1.size() < s2.size();
              }
      };
      
      // 可以用这个对象替换 lambda
      stable_sort(words.begin(), words.end(), ShorterString());
    • 表示lambda及相应捕获行为的类

      1、当一个lambda通过引用捕获变量时,将由程序负责确保lambda执行时引用的对象确实存在,因此编译器可以直接使用该引用无须在lambda产生的类中将其存储为数据成员。相反,通过值捕获变量时,变量被拷贝到lambda中,则必须为每个值建立对应的数据成员,同时创建构造函数初始化数据成员
      2、如下例,有一个lambda,作用是找到第一个长度不小于给定值的string,其产生的类类似于下面的类。与上面的ShorterString不同,该类含有数据成员构造函数,合成的类不含有默认构造函数,因此要使用这个类必须提供一个实参

      // 获得第一个指向满足条件元素的迭代器,该元素满足 size() >= sz
      auto wc = find_if(words.begin(), words.end(), [sz](const string &a) { return a.size() >= sz; });
      
      // 该 lambda 产生的类类似于下面的类
      class SizeComp
      {
          public:
              // 构造函数,形参对应捕获的变量,没有默认值
              SizeComp(size_t n) : sz(n)
              {
              }
              // 函数调用运算符
              bool operator()(const string &s) const
              {
                  return s.size() >= sz;
              }
      
          private:
              size_t sz;  // 对应通过值捕获的变量
      };
      
      // 使用该类必须提供实参
      auto wc = find_if(words.begin(), words.end(), SizeComp(sz));
  • 标准库函数对象

    • 介绍

      1、标准库定义了一组表示算术运算符关系运算符逻辑运算符,每个分别定义了一个执行命名操作调用运算符。例如plus类定义函数调用运算符用于对一些对象执行+操作modulus执行二元的%操作equal_to执行==操作
      2、这些都被定义成模板,我们可以为其指定具体应用类型,即调用运算符形参类型,类似于使用vector
      3、下标列出了这些标准库函数对象,它们定义在头文件functional

      算术运算 关系运算 逻辑运算
      plus equal_to logical_and
      minus not_equal_to logical_or
      multiplies greater logical_not
      divides greater_equal
      modulus less
      negate less_equal
      plus<int> intAdd;                   // 可执行 int 加法的函数对象
      negate<int> intNegate;              // 可对 int 值取相反数的函数对象
      // 使用 intAdd::operator(int, int) 求和
      int sum = intAdd(10, 20);           // sum = 30
      sum = intNegate(intAdd(10, 20));    // sum = -30
      // 使用 intNegate::operator(int) 生成 -10,再将 -10 作为 intAdd 的第二个参数
      sum = intAdd(10, intNegate(10));    // sum = 0
    • 在算法中使用标准库函数对象

      1、表示运算符函数对象类常用来替换算法中的默认运算符。如我们所知,默认情况下标准库排序算法使用operator<将序列升序排列。现在,如果要降序排列,我们可以传入一个greater类型对象,该类将产生一个调用运算符并负责执行待排序类型>运算,如下
      2、需要注意的是,标准库规定其函数对于指针同样适用。我们之前介绍过比较两个无关指针将产生未定义行为,然而我们可能会希望通过比较指针内存地址来排序指针的vector直接这么做将产生未定义行为,因此我们可以使用标准库函数对象来实现此目的,如下

      // 使用 greater 降序排列
      sort(svec.begin(), svec.end(), greater<string>());
      
      // 有关指针的排序
      vector<string *> nameTable;       // 指针的 vector
      // 错误:直接比较将产生未定义行为
      sort(nameTable.begin(), nameTable.end(), [](string *a, string *b) { return a < b; });
      // 正确:标准库规定指针的 less 是定义良好的
      sort(nameTable.begin(), nameTable.end(), less<string *>());
  • 可调用对象与 function

    • 引入

      1、C++中有几种可调用对象函数函数指针lambda表达式bind对象重载了函数调用运算符
      2、和其他对象一样,可调用对象也有类型。例如,每个lambda都有他自己唯一的类类型函数函数指针的类型则由其返回值类型实参类型决定等等
      3、然而,两个不同类型可调用对象有可能共享同一种调用形式调用形式指明了调用返回值类型以及实参类型。例如int(int, int)是一个函数类型,接受两个int返回一个int

    • 不同类型可能具有相同调用形式

      1、对于多个可调用对象共享同种调用形式的情况,我们会希望把它们看成具有相同的类型。如下,这些调用对象分别对其参数执行了不同的算术运算,尽管类型各不相同,但仍共享同一种调用形式int(int, int)
      2、我们可能希望使用这些可调用对象构建一个简单的桌面计算器。为此需要定义一个函数表,用于存储指向这些可调用对象指针,当程序需要特定操作时,从该表中查找调用的函数。C++中,函数表很容易通过map实现
      3、对于本例,我们使用表示运算符符号string作为关键字,使用实现运算符函数作为,假定所有函数相互独立,且只处理关于int的二元运算,则可以定义成下例中的形式。但是,我们不能将moddivice存入binops,因为它们都是类类型(lambda也有自己的类类型),与binops值类型不匹配

      // 普通函数
      int add(int i, int j)
      {
          return i + j;
      }
      // lambda,产生一个未命名的函数对象类
      auto mod = [](int i, int j) { return i % j; };
      // 函数对象类
      struct divide
      {
          int operator()(int denominator, int divisor)
          {
              return denominator / divisor;
          }
      };
      
      // 构建从运算符到函数指针的映射,函数接受两个 int,返回一个 int
      map<string, int(*)(int, int)> binops;
      // 正确:add 是一个指向正确类型的指针
      binops.insert({"+", add});    // {"+", add} 是一个 pair
      // 错误:mod 不是一个函数指针
      binops.insert({"%", mod});
    • 标准库function类型

      1、我们可以使用function标准库类型来解决上述问题,它定义在头文件functional中,function是一个模板,下表列出了它的操作
      2、如下例,我们声明了一个function类型,它可以表示接受两个int返回一个int可调用对象,使用这个function可以重新定义上一小节的map,并将所有相同调用形式可调用对象都添加到这个map
      3、一如往常,当我们索引map时将得到关联值的引用,如果我们索引binops,将得到function对象的引用function重载了调用运算符,其接受自己的实参然后将传递给存好的可调用对象

      function 操作 描述
      function<T> f; f 是一个用来存储可调用对象的空 function,这些可调用对象的调用形式应该与 T 相同,即 T 应该为 retType(args)
      function<T> f(nullptr); 显式构造一个空 function
      function<T> f(obj); 在 f 中存储可调用对象 obj 的副本
      f 将 f 作为条件:当 f 含有一个可调用对象时为真,否则为假
      f(args) 调用 f 中的对象,参数是 args
      function<T>的成员类型 描述
      result_type 该 function 类型的可调用对象的返回类型
      argument_type 当 T 只有一个实参时定义的类型,如果 T 只有一个实参,arguement_type 便是该实参类型的同义词
      first_argument_type,second_argument_type 当 T 只有两个实参时定义的类型,如果 T 有两个实参,则 first_argument_type 和 second_argument_type 分别代表两个实参的类型
      // function 对象的使用
      function<int(int, int)> f1 = add;                                 // 函数指针
      function<int(int, int)> f2 = divide();                            // 函数对象类的对象
      function<int(int, int)> f3 = [](int i, int j) { return i * j; };  // lambda
      cout << f1(4, 2) << endl;   // 打印 6
      cout << f2(4, 2) << endl;   // 打印 2
      cout << f3(4, 2) << endl;   // 打印 8
      
      // 重新定义上一小节的 map,值变成了 function 对象
      map<string, function<int(int, int)>> binops = {{"+", add},                              // 函数指针
                                                  {"-", minus<int>()},                        // 标准库函数对象
                                                  {"*", [](int i, int j) { return i * j; }},  // 未命名的 lambda
                                                  {"/", divide()},                            // 用户定义的函数对象
                                                  {"%", mod}};                                // 命名了的 lambda
      // 使用 binops
      binops["+"](10, 5);     // 调用 add(10, 5)
      binops["-"](10, 5);     // 使用 minus<int> 对象的调用运算符
      binops["*"](10, 5);     // 使用 lambda 函数对象
      binops["/"](10, 5);     // 使用 divide 对象的调用运算符
      binops["%"](10, 5);     // 使用 lambda 函数对象
    • 重载函数的二义性问题

      1、我们不能直接将重载函数名存入function对象中,原因如下例
      2、解决这种二义性问题的一种途径是存储函数指针而非函数名,另一种方式是使用lambda指定版本

      // 二义性问题
      int add(int i, int j)                         // 普通函数
      {
          return i + j;
      }
      Sales_data add(Sales_data&, Sales_data&);     // 重载的另一个函数
      map<string, function<int(int, int)>> binops;
      binops.insert( {"+", tp} );                   // 错误:哪个 add
      
      // 方法 1:函数指针
      int (*fp)(int, int) = add;                    // 指针所指的 add 是接受两个 int 的版本
      binops.insert( {"+", fp} );                   // 正确:指向一个正确的 add 版本
      
      // 方法 2:使用 lambda 指定版本
      binops.insert( {"+", [](int a, int b) { return add(a, b);} } );

重载、类型转换与运算符

  • 类型转换运算符

    • 描述

      1、类型转换运算符是类的一种特殊成员函数,负责将一个类类型的值转换成其他类型类型转换函数的一般形式如:operator type() const;,其中type表示某种类型
      2、类型转换运算符可以面向任意类型(除了void)进行定义,只要该类型能作为函数的返回类型。因此,不允许转换成数组函数类型,但允许转换成指针引用
      3、类型转换运算符没有显式的返回类型形参,而且必须定义成类的成员函数。因其不应改变转换对象的内容,所以一般被定义成const成员

    • 定义含有类型转换运算符的类

      1、我们首先定义一个简单的类,令其表示0 到 255之间的一个整数,如下。SmallInt既定义了向类类型的转换(构造函数),也定义了类类型向其他类型的转换(类型转换运算符)
      2、尽管编译器一次只能执行一个自定义类型转换,但是隐式的自定义类型转换可以置于一个内置类型转换之前或之后,并与其一起使用,如下例
      3、由于类型转换运算符隐式执行的,所以无法给这些函数传参,定义中也不能使用任何形参。同时,尽管类型转换函数不负责指定返回类型,但实际上每个类型转换函数都应该返回一个对应类型值

      class SmallInt
      {
          public:
              // 构造函数:从 int 转换成类类型对象
              SmallInt(int i = 0) : val(i)
              {
                  if(i < 0 || i > 255)
                      throw out_of_range("Bad SmallInt value");
              }
              // 类型转换运算符:从类类型对象转换成 int
              operator int() const
              {
                  return val;
              }
      
          private:
              size_t val;   // size_t 类型对象
      };
      
      SmallInt si;
      si = 4;       // 首先将 4 隐式转换成 SmallInt,然后调用 SmallInt::operator=
      si + 3;       // 首先将 si 隐式转换成 int,然后执行整数加法
      si = 3.14;    // 内置类型转换先将 double 转换成 int,然后调用 SmallInt(int) 构造函数
      si + 3.14;    // SmallInt 的类型转换运算符先将 si 转换成 int,然后内置类型转换将 int 转换成 double
    • 可能产生意外结果

      1、在实践中,类很少提供类型转换运算符。大多情况下,如果类型发生自动转换,用户会感到比较意外,而不是受到了帮助。然而存在一种例外:对于类,定义bool的类型转换仍然比较普遍
      2、早期版本中,如果想定义一个bool的类型转换,则常常遇到一个问题:bool是一种算术类型类类型转换成bool后可以被用在任何需要算术类型的上下文中。这样的类型转换可能引发意想不到的结果,特别是当istream含有向bool的类型转换时,如下例
      3、该例中,程序试图<<作用于输入流。因为istream本身没有定义<<,本身应该产生错误。但是,该代码能使用istreambool类型转换运算符,将cin转换成bool,然后bool又会接着被提升成int,并被用作内置的左移运算符(位运算)的左侧运算对象,这一行为与我们的预期大相径庭

      int i = 42;
      cin << i;     // 如果 istream 向 bool 的类型转换不是显式的,则该代码在编译器看来是合法的
    • 显式的类型转换运算符

      1、为了防止上面的异常情况发生,新标准引入了显式的类型转换运算符,如下例
      2、和显式的构造函数一样,编译器(通常)也不会将显式的类型转换运算符用于隐式类型转换。当类型转换运算符显式时,必须通过显式的强制类型转换才可以使用
      3、但有一个例外:如果表达式被用作条件,则编译器会将显式类型转换自动应用于它

      class SmallInt
      {
          public:
              // 编译器不会自动执行这一类型转换
              explicit operator int() const
              {
                  return val;
              }
              // ...其他成员一致
      };
      
      SmallInt si = 3;            // 正确:SmallInt 的构造函数不是显式的
      si + 3;                     // 错误:此处需要隐式的类型转换,但类的运算符是显式的
      static_cast<int>(si) + 3;   // 正确:显式地请求类型转换
  • 避免有二义性的类型转换

    • 引入

      1、如果类中包含一个或多个类型转换,就必须确保在类类型目标类型之间只存在唯一一种转换方式。否则,我们的代码很可能具有二义性。有两种情况可能产生多重转换路径
      2、第一种情况是两个类提供相同类型转换。例如A 类定义了接受B 类对象转换构造函数,同时B 类定义了转换目标是A 类类型类型转换运算符时,我们就说它们提供了相同的类型转换
      3、第二种情况是类定义了多个转换规则,而这些转换涉及的类型本身就可以通过其他类型转换联系在一起。典型例子是算术运算符,对于某个给定类来说,最好只定义最多一个算术类型有关转换规则

    • 实参匹配和相同类型转换

      1、在下面例子中,我们定义了两种将 B 转换成 A 的方法:一种使用B类型转换运算符,一种使用A以 B 为参数的构造函数
      2、该例中对 f 的调用存在二义性。如果确实想执行该调用,就必须显式地调用指定函数
      3、值得注意的是,我们无法使用强制类型转换来解决二义性问题,因为强制类型转换本身也面临二义性

      // 最好不要在两个类之间构建相同的类型转换
      struct B;
      struct A
      {
          A() = default;
          A(const B&);            // 把 B 转换成 A 的构造函数
          // ...其他数据成员
      };
      struct B
      {
          operator A() const;     // 把 B 转换成 A 的类型转换运算符
          // ... 其他数据成员
      };
      
      A f(const A&);
      B b;
      A a = f(b);                 // 二义性错误:含义是 f(B::operator A()) 还是 f(A::A(const B&))
      
      A a1 = f(b.operator A());   // 正确:使用 B 的类型转换运算符
      A a2 = f(A(b));             // 正确:使用 A 的构造函数
    • 转换目标为内置类型的多重类型转换

      1、如果类定义了一组类型转换,它们的转换源(转换目标)类型本身就可以通过其他类型转换联系在一起,则同样会产生二义性问题。最简单的也最常见的例子就是中定义了多个参数都是算术类型构造函数,或者多个转换目标都是算数类型类型转换运算符
      2、如下例,如对f2的调用中,无论哪个类型转换无法精确匹配long double,然而两个类型转换都能使用,因此哪个都不比另一个更好,调用将产生二义性

      struct A
      {
          // 最好不要创建两个转换源都是算数类型的类型转换
          A(int = 0);
          A(double);
          // 最好不要创建两个转换目标都是算数类型的类型转换
          operator int() const;
          operator double() const;
          // ...其他成员
      };
      
      void f2(long double);
      A a;
      f2(a);          // 二义性错误:含义是 f(A::operator int()) 还是 f(A::operator double())
      
      long lg;
      A a2(lg);       // 二义性错误:含义是 A::A(int) 还是 A::A(double)
    • 重载函数与转换构造函数

      1、当我们调用重载函数时,从多个类型转换中进行选择将变得更加复杂。如果两个或多个类型转换都提供了同一种可行匹配,则这些类型转换一样好
      2、举个例子,当几个重载函数参数分属不同类类型时,如果这些类恰好定义了同样的转换构造函数,则二义性问题将进一步提升,如下例。我们可以显式地构造正确的类型以消除二义性

      struct C
      {
          C(int);
          // ...其他成员
      };
      struct D
      {
          D(int);
          // ...其他成员
      };
      void manip(const C&);
      void manip(const D&);   // 重载版本的 manip
      manip(10);              // 二义性错误:含义是 manip(C(10)) 还是 manip(D(10))
      manip(C(10));           // 正确:调用 manip(const C&)
  • 函数匹配与重载运算符

    1、重载运算符也是重载的函数,因此,通用的函数匹配规则通常适用于在表达式中使用内置运算符还是重载运算符。不过当运算符函数出现在表达式中时,候选函数集的规模比我们使用调用运算符调用函数时更大
    2、如果a是一种类类型,则表达式a sym b(sym代表一种运算符)的含义可能是a.operatorsym(b);operatorsym(a, b);,且后者可能是一个非成员函数。和普通函数不同,我们不能通过调用形式来区分调用的是成员函数还是非成员函数
    3、当我们使用重载运算符作用于类类型对象时,候选函数中包含该运算符普通非成员版本内置版本。此外,如果左侧对象类类型,则定义在该类中重载运算符也包含在候选函数
    4、当我们调用一个命名的函数时,具有该名字的成员函数非成员函数之间不会彼此重载。当我们通过类类型对象进行函数调用时,只考虑该类的成员函数;当我们在表达式中使用重载运算符时,无法判断正在使用的是哪个版本,因此二者都应该在考虑范围内
    5、举个例子,我们为先前的SmallInt定义一个加法运算符,如下。我们可以使用这个类将两个SmallInt对象相加,但如果我们试图执行混合模式的算术运算,就会遇到二义性问题

    class SmallInt
    {
        public:
            // 重载运算符,定义在类外(定义已省略),返回 SmallInt
            friend SmallInt operator+(SmallInt&, SmallInt&);
    
            SmallInt(int = 0);      // 转换源为 int 的类型转换
            operator int() const    // 转换目标为 int 的类型转换
            {
                return val;
            }
    
        private:
            size_t val;
    };
    
    SmallInt s1, s2;
    SmallInt s3 = s1 + s2;          // 使用重载的 operator+
    int i = s3 + 0;                 // 二义性错误:是把 0 转换成 SmallInt 然后使用 SmallInt 重载的加法,还是把 s3 转换成 int 然后使用内置加法

面向对象程序设计


章节概要OOP概述;

OOP 概述


页底评论