前言

固然,轻薄短小的书籍乍见之下让所有读者心情轻松,但如果舍弃太多应该深入的地方不谈,也难免令人行止失据,进退两难。

……

作为一个好的学习者,背景不是重点,重要的是,你是否具备正确的学习态度。起步固然可从轻松小品开始,但如果碰上大部头巨著就退避三舍、逃之夭夭,面对任何技术只求快餐速成,学语言却从来不写程序,那就绝对没有成为高手乃至专家的一天。

有些人的学习,自练就一身钢筋铁骨,可以在热带丛林中披荆斩棘,在莽莽草原中追奔逐北。有些人的学习,既未习惯大部头书,也未习惯严谨格调,更未习惯自修勤学,是温室里的一朵花,没有自立自强的本钱

——《Essential C++》前言,侯捷

参考资料

[1] C++ 标准库参考 (STL)—Microsoft

[2] cplusplus.com

[3] stl—wiki

[4] C++语法教程 (bobokick.github.io)

------C++ 基础------

二 变量和基本类型

2.1 基本内置类型

2.1.1 变量类型的大小

下图列出了各类型的最小尺寸。

数据类型最小大小

2.1.2 符号

  • 整型

    • int:正数、负数和0
    • usigned int: 大于0
  • 字符型

    • char :在有些机器上是signed,有些机器是unsigned
    • signed char
    • unsigned char

如何选择?

  • 明知数值不可能为负,用无符号

  • 整数运算用int、long long

  • 浮点用double

2.1.3类型转化

  • :question:有个不明白的地方,不明白怎么算的:(P33)赋给无符号类型一个超出它表示范围的值时,结果是初始值对无符号类型表示数值总数取模后的余数。

  • 当一个算术表达式中既有无符号又有int,int就会转换成无符号,有可能引发错误。 <-混用引发错误

2.2 字面值常量

  • 整型和浮点型

    • 自动选择能匹配的空间最小的一个作为其数据类型
    • 十进制不会是负数。符号不在字面值之内,负号的作用是对字面值取负
    • 科学计数法指数部分用E或e标识
  • 字符和字符串字面值

    1
    2
    3
    4
    5
    6
    'a' // 字符, 'a'
    "a" // 字符串,'a'+'\0'

    // 当两个字符串字面值位置紧邻且仅由空格、缩进和换行符分隔,则实际上是一个整体
    std::cout<< "a really, really long string literal "
    <<"that spans two lines" <<std::endl;
  • 转义
    常用转义字符

  • 布尔和指针

    • bool :true ,false
    • 指针:nullptr

如何指定字面值类型?

  • 通过添加下表的前缀和后缀,改变整型、浮点型和字符型字面值的默认类型
    指定字面值类型

2.3 变量

2.3.1 了解变量

  • 定义
  • 初始化
    • 初始化不是赋值:
      • 初始化是创建变量时赋予其一个初始值;
      • 赋值是把对象的当前值擦除,而以一个新值替代。
  • 四种初始化方式

    1
    2
    3
    4
    5
    6
    7
    8
    // 可能有信息丢失的风险
    long double b = 3.1415929
    int a = b; // a = 3, 信息丢失
    int a(b); // a = 3

    // 使用列表初始化,存在上述风险将报错
    int a = {b};
    int a{b};
  • 默认初始化

    • 内置类型默认初始化的值由定义的位置决定

      • 定义与任何函数体之外 ,初始化为0
      • …内,不被默认初始化,变量值未定义
    • 建议初始化每一个内置类型的变量

  • 声明和定义的关系

    • 声明使得名字为程序所知;定义负责创建与名字关联的实体,并申请存储空间。

    • 只能被定义一次,可以被声明多次。

    • 如果想声明一个变量而非定义,使用extern,并且不要显示地初始化:

      1
      2
      extern int i;     //仅声明
      int i ; //声明并定义
    • 在函数体内部,初始化一个又extern标记的变量,将引发错误。

      1
      extern int i = 3.14; //错误
    • 如果要在多个文件中使用同一个变量,必须将声明和定义分离。变量的定义必须出现且只能出现在一个文件中,而其他用到该变量的文件必须对其进行声明,却绝对不能重复定义。例,

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      //main.cpp
      #include <iostream>
      #include "Class2.h"
      using namespace std;

      int main()
      {
      Class2::print_i(); // 重点输出:Class2: 5
      return 0;
      }

      //Class2.hpp
      #pragma once
      #include <iostream>
      using namespace std;
      extern int i; // <- 注意这里,如果不写就会报:i未声明标识符

      class Class2
      {
      public:
      static void print_i() { cout << "Class2: "<<i << endl; }
      };

      //Class2.cpp
      #include "Class2.h"
      int i = 5;
  • 标识符

    • 用户自定义标识符中不能连续出现两个下划线;不能下划线紧邻大写字母;定义在函数体外的标识符不能以下划线开头。
    • 变量名一般小写字母;类名以大写字母开头

2.3.2 作用域

对于嵌套作用域:

  • 作用域中一旦声明了某个名字,它所嵌套着的所有作用域都能访问这个名字。
  • 同时,允许在内层作用域中重新定义外层作用域中已有的名字。
  • ::访问全局变量。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
using namespace std;

int a = 42;

int main()
{
int b = 0;
cout << a << "," << b << endl; //42,0

int a = 0;
cout << a << "," << b << endl; //0,0

cout << ::a << "," << b << endl; //42,0

return 0;
}

2.4 复合类型

  • 声明语句:一条语句声明由一个基本数据类型和紧随其后的声明符列表组成。
  • 指针和引用,前后的类型都要严格匹配

2.4.1 引用

  • 引用必须被初始化,且只能绑定到对象上,不能与字面值或某个表达式的计算结果绑定;
  • 无法令引用重新绑定到另一个对象;
  • 引用本身不是对象,所以不能定义引用的引用

2.4.2 指针

  • 指针本身是一个对象;

  • 无须在定义时赋初值;

  • 引用不是对象,没有实际地址,不能定义指向引用的指针;但指针是对象,存在对指针的引用,例:

    1
    2
    int *p;
    int *&r = p; // r是对指针p的引用

    注:上面的代码如何阅读?从又向左阅读r的定义,离变量名最近的符号(此处为&)对变量的类型有直接的影响,因此上文中r是一个引用。

void *可用于存放任意对象的地址,我们不清楚其到底指向的是什么类型的对象,也无法访问其指向的内存空间中的对象。

1
2
3
double a = 3.14;
void *ptr = &a;
cout<<*ptr<<endl; //报错

定义多个变量时,类型修饰符(如 *)只修饰一个变量,对该声明语句中的其他变量,不产生任何作用。例,

1
2
3
4
5
// 这样写容易产生误导
int* p1,p2; // p1是指向int的指针,p2是int

// 建议写成
int *p1, p2;

2.5 const

  • const的宗旨:任何试图改变const修饰的变量都将引发错误
  • const对象一旦创建就不能改变,因此必须初始化,初始值可以是任意复杂表达式
  • 默认状态下,const对象仅在文件内生效。如果想要在多个文件中共享,最好的办法是不管是声明还是定义都加上extern关键字。

2.5.1 常量引用

  • 即对const的引用

  • 初始化常量引用允许用任意表达式作为初始值,只要该表达式的结果能转换成引用的类型即可。例:

    1
    2
    3
    4
    double a = 3.14;
    //const int temp = a; // <-编译器内部自己做的操作,生成一个临时变量
    //const int &r = temp; //<-编译器内部自己做的操作,将引用绑定到一个临时变量上
    const int &r = a; //正确
  • const的左值引用可以绑定到右值
    右值引用

    1
    2
    3
    int i = 42;
    int &r = i*42; // 错误,左值引用不可绑定到右值
    const int &r = i*42; // 正确,const的左值引用可以绑定到右值

2.5.2 指针和const

  • 指针常量 — 指向常量的指针

    • 想要存放常量对象的地址,只能使用指向常量的指针;

      1
      2
      const double pi = 3.14;
      const double *cptr = &pi;
- 允许一个指向常量的指针指向非常量对象。
  • 常量指针 — 指针本身是一个常量

    • 把*放在const之后,用以说明指针是一个常量

      1
      2
      int a = 0;
      int *const ptr = &a;

2.5.3 顶层const和底层const

  • 顶层const表示指针本身是一个常量。推广:任意的对象是常量,如算术类型、类、指针…
  • 底层const表示指针所指的对象是常量。推广:指针和引用等复合类型的基本类型部分有关。

当执行拷贝时,

  • 顶层const不受影响;
  • 底层const对象必须具有相同的const资格,或者两个对象的数据类型必须能够转换。

2.5.4 常量表达式和constexpr

  • 常量表达式

    • 常量表达式是指:值不会改变 且 在编译过程就能得到计算结果 的表达式

    • 一个对象是不是常量表达式有其数据类型和初始值共同决定

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      /*********** 例1 **************/
      const int mf = 20;
      const int limit = mf +1;

      /*********** 例2 **************/
      #include <iostream>
      using namespace std;

      int get_size() { return 1; }

      int main()
      {
      const int sz = get_size(); // 通过
      cout << sz << endl; // 输出1
      return 0;
      }
  • constexpr — 由编译器来验证变量是否是一个常量表达式。声明为constexpr的变量:

    • 一定是一个常量

    • 必须用常量表达式初始化

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      /*********** 例1 **************/
      constexpr int mf = 20;
      constexpr int limit = mf +1;
      /*********** 例2 **************/
      #include <iostream>
      using namespace std;

      //int get_size() { return 1; } //C++表达式必须含有常量值,无法调用非constexpr函数
      //const int get_size() { return 1; } //同上
      constexpr int get_size() { return 1; }

      int main()
      {
      constexpr int sz = get_size();
      cout << sz << endl; // 输出1
      return 0;
      }


  • 字面值类型

    • 算术类型、引用和指针都属于,可被定义为constexpr;

      注意:

      • 引用和指针初始值受限:必须是0或者nullptr,或者存储于某个固定地址中的对象。函数提内的对象一般不在固定地址,不能用constexpr;允许函数定义的一类超出函数体本身的变量,其存在于固定地址,constexpr引用(指针)也能(绑定)指向该变量。

      • constexpr定义了一个指针,constexpr仅对指针本身有效,对指针所指的对象无关 <-顶层const。

        1
        2
        3
        4
        5
        6
        7
        8
        9
        constexpr int *q = nullptr;
        // 类似
        int *const q = nullptr;

        //----------------------------------
        //可得
        constexpr const int *p = nullptr;
        // 类似
        const int *const p = nullptr;
    • io、string等不属于字面值类型

  • constexpr函数(笔记6.5.3)

  • constexpr类(笔记7.4.2)

2.6 处理类型

2.6.1 类型别名

  • typedef

    1
    2
    typedef double wages;
    typedef wages base, *p; //base = double, p = double *
  • using

    1
    using wages = double;

需要注意的是,类型别名不能直接往代码中替换,要将类型别名看成一个整体

1
2
3
4
5
typedef char *pstring; //pstring = char *
const pstring cstr = 0; // 指针本身是一个常量,char *const cstr = 0

//直接替换是错误的:
const char *cstr = 0; //指向常量的指针

2.6.2 auto

  • auto定义的变量必须有初始值

  • 编译器以引用对象的类型作为auto的类型(auto会忽略引用)

    1
    2
    int i = 0, & r = i;
    auto a = r; // auto = int
  • auto一般会忽略掉顶层const,保留底层const。想要保留顶层const,则需要手动指出

    1
    2
    3
    4
    5
    6
    7
    8
    int i = 0;
    const int ci = i, & cr = ci;
    auto b = ci; //int (顶层const)
    auto c = cr; //int (顶层const)
    auto d = &i; //int * (顶层const)
    auto e = &ci; //const int * (底层const)

    const auto f = ci; //const int (为保留顶层const而手动指出)
  • auto和引用

    1
    2
    3
    4
    auto &g = ci; //g 的类型为const int &

    //auto &h = 42; //错误,不能为非常量引用绑定字面值
    const auto &h = 42; //const int &
  • 利用auto在一条语句中声明多个变量时,这多个变量的初始值必须是同一类型。

2.6.3 decltype

  • 选择并返回操作数的数据类型 — 编译器分析表达式并得到其类型,却不计算其值

    1
    decltype(func()) sun = x;
  • 如果decltype使用的表达式是一个变量,则decltype返回该变量的类型(包括顶层const和引用)<-区别于auto

    1
    2
    3
    const int ci = 0, &cj = ci;
    decltype(ci) x= 0; // const int
    decltype(cj) y = x; //const int &
  • decltype和引用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    int i = 42, *p = &i, &r = i;

    decltype(r+0) b;

    //decltype(*p) c; //出错,“引用变量c需要初始值设定项”
    decltype(*p) c = i; //c的类型是int &

    decltype(i) d; // int
    decltype((i)) e = i; //int &
    • decltype(r)的结果是引用,如果想让结果类型是r所指的类型,只需把r作为表达式的一部分,如decltype(r+0)
    • decltype表达式的类型是解引用操作,将得到引用类型,因此必须初始化,如decltype(*p) c = i;
    • decltype((variable)) (注意是双层括号)的结果永远是引用类型

三 字符串、向量和数组

3.1 using

  • 每个名字都需要独立的using声明

    1
    using std::cout; using std::endl;
  • 注意:头文件中不应包含using <-否则,每个使用该头文件的代码都会包含该声明,从而引起命名的冲突。

3.2 string

笔记9.6 - string专题

3.2.1 初始化

  • 拷贝初始化。string s = "a";
  • 直接初始化。string s("a");

初始化string的方式

笔记9.6.2 其他初始化string的方法

3.2.2 string对象的操作

string的操作

  • 读写

    • 读取操作时,string对象会自动忽略开头的空白,并从真正的第一个字符开始读起,直到遇到下一个空白。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      //输入"  hello world";

      string s;
      cin>>s;
      cout<<s<<endl; //hello

      string s1,s2;
      cin>>s1>>s2;
      cout << s1<<s2<<endl; //helloworld

      //endl 结束当前行,刷新缓冲区
    • 读取未知数量的string对象

      1
      2
      3
      string s;
      while(cin>>s){ //遇到文件结束标记或非法输入,循环结束
      }
    • 读取一整行

      1
      2
      string line;
      while(getline(cin,line)){}
      • getline从给定的输入流中读取内容,直到遇到换行符为止(换行符也被读取进来了),将内容存入string对象中(不存换行符)。
      • 如果一开始就是换行符,则得到空的string对象。
      • 和cin一样返回流参数,因此可作为循环的判断条件
  • size

    • size()返回一个string::size_type类型的值,是一个无符号类型的值,因此要避免与有符号数混用所带来的问题。
  • 相加
    当把string对象和字符字面值及字符串字面值混在一条语句中使用时,必须确保每个加法运算符两侧的运算对象至少有一个是string.
    14.1最后一点,operator+在string类中为非成员

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    string s = "hello";
    string s1 = s+","; //正确
    string s2 = "hello"+","; // 错误

    string s3 = s+","+"hello"; //正确
    //等价于
    string s3 = (s+",")+"hello";
    //等价于
    string tmp = s1+",";
    string s3 = tmp+"hello";

    string s4 = "hello"+","+s; //错误

3.2.3 处理string对象中的字符

  • 处理函数

cctype头文件中的函数

  • 范围for

    • 注意declaration是引用时,是直接对experssion中原来的元素进行操作
    1
    2
    3
    for(declaration : experssion){
    statement
    }
  • 下标运算符([ ])接收的输入是string::size_type类型的值,返回值是该位置上字符的引用。

    • str[str.size()-1]是最后一个字符
    • 在访问指定字符之前,需要检查字符串是否为空:if(!str.isempty()){ /*访问指定字符*/},并注意下标的合法范围[0 , str.size())

3.3 vector

3.3.1 初始化vector对象

初始化vector对象的方法

  • 使用拷贝初始化(=)只能提供一个初始值;

  • 类内初始值只能使用拷贝初始化或使用花括号形式的初始值;

  • 列表初始化使用{},而不是()

    • 值得注意的是

      1
      2
      vector<string> v1{10};             // 10个默认初始化的元素
      vector<string> v2{10,"hi"}; // 10个值为hi的元素

      以上两者都不是列表初始化,花括号的值必须与元素类型相同,显然不能用int初始化string对象,因此上面两者的花括号中都不能作为元素的初始值。在无法执行列表初始化后,编译器会尝试用默认值初始化vector对象。

3.3.2 vector操作

  • 添加元素 — push_back()

    • 在定义vector对象的时候设定其大小就没什么必要了,事实上如果这么做性能可能更差

  • vector其他操作罗列

    • 注意size()同样返回vector<xxx>::size_type类型

      要使用size_type类型,需要首先指定它是由哪些类型定义的

    vector的其他操作

  • 只有当元素的值可比时,vector对象才能被比较:元素个数相等,对应位置的元素也相等

3.3.3 迭代器

  • 用法

    迭代器运算符

    在for循环中使用!=,原因是c++程序员更愿意使用迭代器而非下标。并非所有的容器的迭代器都定义了<等,但都定义了==!=

  • 迭代器的类型 — iteratorconst_iterator(只读)

    • 如果迭代器对象是一个常量,则只能用const_iterator
    • 不是常量,则都能用
  • begin和end、cbegin和cend

    • 它们返回的具体类型由对象是否是常量决定,如是常量返回const_iterator,否则返回iterator

    • 如果只需读取,而不写入,可使用cbegincend,返回const_iterator

      1
      2
      3
      4
      5
      6
      vector<int> v;
      const vector<int> cv;
      auto it1 = v.begin(); // vector<int>::iterator
      auto it2 = cv.begin(); // vector<int>::const_iterator

      auto it3 = v.cbegin(); // vector<int>::const_iterator
  • 使vector迭代器失效的操作:

    • 在范围for循环中向vector对象添加元素
    • 任何一种可能改变vector对象的操作,如push_back
  • 运算
    vector和string迭代器支持的运算

    • 迭代器相减的结果的类型:difference_type的带符号整型数

3.4 数组

3.4.1 定义和初始化数组

  • 定义

    • 编译的时候维度必须是已知的,维度必须是一个常量表达式(constexpr
    • 和内置类型的变量一样,如果在函数内部定义了某种内置类型的数组,那么默认初始化会令数组含有未定义的值
    • 不许用auto指定数组;
    • 不存在引用的数组
  • 初始化

    • 显式地初始化数组

      • 列表初始化时,可不写维度,编译器会自动推测

      • 指明维度后,初始值数量不应超过维度大小

    • 字符数组的特殊性 — 注意字符串结尾的空字符也会被拷贝到数组中

      1
      2
      3
      char a3[] = "c++";  // 维度为4
      // 相当于
      char a3[] = {'c','+','+','\0'};
  • 数组不允许拷贝和赋值

    1
    2
    3
    int a[] = {0,1,2};
    int a2[] = a; // 错误
    a2 = a; // 错误
  • 复杂的数组声明

    1
    int (*parray)[10] = &arr;

    从数组名开始,由内向外,由右向左 —>parray是一个指针,指向大小为10的数组,数组中包含int对象

  • 在使用数组下标时,通常将其定义为size_t类型,它是一种无符号整型,定义于cstddef
  • 两个特殊性质
    • 不允许拷贝数组
    • 使用数组时通常会将其转化成指针

3.4.2 指针和数组

  • 数组名是指向数组首元素的指针

    • auto推断得到的类型是指针

      1
      2
      int ia[] = {0,1,2};
      auto ia2(ia); // auto == int *
  • 使用decltype时,返回的类型是数组 <— 与auto区分

    1
    2
    decltype(ia) ia3 = {0,1,2,3};
    ia3[1] = 5;
  • “迭代器”

    • 获取数组的“尾后迭代器” <—不能对尾后指针进行解引用或者递增

      1
      int *e = &arr[/*元素个数*/];
    • 然而这种方法极易出错,c++11在iterator头文件中定义了两个函数beginend,它们分别返回头指针和尾指针,用法如下

      1
      2
      3
      4
      #include <iterator>
      int ia[] = {0,1,2,3};
      int *begin = begin(ia); // 正确的使用形式是将数组作为它们的参数
      int *end = end(ia);
  • 两个指向同一数组不同元素的指针相减得到它们之间的距离,类型为ptrdiff_t,定义于cstddef,为带符号类型。(对比3.3.3节-运算-两个迭代器相减的结果类型)

  • 与vector与string等标准库下标运算仅支持无符号数不同,数组的下标运算(内置下标运算)支持负数

    1
    2
    3
    4
    int a[] = {0,1,2,3};
    int *p = &a[2];
    int j = *(p+1); // j = a[3]
    int k = p[-2]; //k = a[0]

3.4.3 多维数组

严格来说,c++没有多维数组,所说多维数组其实是数组的数组。谨记这一点,对今后理解和使用多维数组大有裨益。

  • int arr[3][4]: 大小为3的数组,每个元素是含有4个整数的数组

  • 初始化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    int ia[3][4] = {{0,1,2,3},
    {4,5,6,7},
    {8,9,10,11}};
    //等价
    int ia[3][4] = {0,1,2,3,4,5,6,7,8,9,10,11};

    //----------------------------------------
    //初始化每行首元素
    int ia[3][4] = {{0},{4},{8}};
    //不等于
    int ia[3][4] = {0,4,8};
  • 下标和多维数组 略

  • 范围for处理多维数组

    1
    2
    3
    4
    5
    for(const auto & row :ia){  <--注意此处一定为 引用,原因如下所述
    for(auto col :row){
    cout<<col<<endl;
    }
    }

    auto会将数组自动转成指针 (3.4.2节-auto推断得到…),row的类型就成了int *,怎么可能再进行内层循环呢?

  • 指针和多维数组

3.4.4 与旧代码接口

  • C风格字符串

    • c++程序中最好不要使用
      c风格字符串操作函数列举
    • 传入上述函数的指针必须指向以空字符串作为结束的数组
  • string和c风格字符串

    • 任何出现字符串字面值的地方都可以用 以空字符结束的字符数组 替代
      • 允许用…来初始化string对象或为string对象赋值
      • 允许作为加法运算中的一员(P111)
    • 但是,不能反过来用string对象直接初始化指向字符数组的指针,
      • string.c_str()返回一个指向以空字符结束的字符数组的指针(char *),数组存放的数据恰好与string对象一样;
      • 但如果后续操作改变string,之前返回的字符数组将会失效
  • 使用数组初始化vector

    • 允许使用数组来初始化vector对象,只需指明首元素地址和尾后地址

      1
      2
      3
      int arr[] = {0,1,2,3,4,5};
      vector<int> ivec1(begin(arr),end(arr)); //{0,1,2,3,4,5}
      vector<int> ivec2(arr+1,arr+3); //{1,2} <--不包含rr[3]

四 表达式

4.1 基础

  • 小整数类型(bool、char、short等)通常会被提升成较大的整数类型,主要是int

  • 运算符重载时,运算对象的个数、运算符的优先级和结合律都是无法改变的

  • 左值和右值 <— 有些迷惑(P121,2024/1/13):question:

    • 左值表示一个占据内存中可识别位置的一个对象,更进一步地,可以对左值取地址
    • 判断右值的一个简单方法就是能不能对变量或者表达式取地址,如果不能,他就是右值

    • 参考文献

  • 求值顺序

    • 对于那些没有指定执行顺序的运算符来说,如果表达式指向并修改了同一个对象,将会引发错误并产生未定义的行为。

    • 求值顺序与优先级和结合律无关(P123底部) <—拿不准的时候用括号来强制符合要求

      1
      2
      如,int i = f()+g()*h()+j()
      这些函数的调用顺序没有明确的规定,如果它们互不相关,则无妨。如果其中某几个函数影响同一个对象,则将会产生未定义行为
    • 这4种运算符明确规定了运算对象的求值顺序:&&, ||, ?:, ,

    • 例外情况:当 改变运算对象的子表达式本身 就是 另一个子表达式的运算对象 ,则没有什么影响:如*++iter,递增运算先发生(改变运算对象的子表达式),解引用后发生。

4.2 运算符

本节运算符表都是按照优先级顺序将其分组,同优先级按照从左到右的顺序。

4.2.1 算术运算符

算术运算符

  • 算术对象和求值结果都是右值
  • 小整数的对象被提升成较大整数
  • 一元正号、加法、减法运算符都能作用于指针
  • 一元正(负)号,(负数将运算对象值取负后)返回对象值的一个(提升后的)副本
  • c++11规定商一律向0取整
  • %返回两个整数相除所得的余数
  • 如果m%n!=0,结果符号与m相同

4.2.2 逻辑和关系运算符

逻辑和关系运算符表

  • 关系运算符作用于算术类型或指针类型,逻辑运算符作用于任意能转换成布尔值的类型。
  • 返回值都是布尔类型,运算结果和求值对象都是右值
  • 因为关系运算法的求值结果是布尔类型,所以将几个关系运算符连写在一起会产生意想不到的效果

4.2.3 赋值运算符

  • 赋值运算符的左侧运算对象必须是一个可修改左值
  • 右侧运算对象将转换成左侧运算对象的类型

4.2.4 递增、递减运算符

  • ++--运算符必须作用于左值运算对象,前置版本将对象本身作为左值返回,后置版本将对象原始值的副本作为右值返回

  • 除非必须,否则不用递增递减运算符的后置版本 <— 额外增加开销(P132)

  • 如果一个子表达式改变了某个运算对象的值,另一条子表达式又要使用该值的话,运算对象的求值顺序就很关键了

    1
    2
    //P133
    *beg = toupper(*beg++); // 未定义行为

4.2.5 成员访问运算符

1
2
ptr->mem;
(*ptr).men;
  • 解引用运算符的优先级低于点运算符,所以执行解引用运算的子表达式两端必须加上()(*ptr).men

  • 箭头运算符作用于一个指针类型的对象,结果是一个左值。点运算符分成两种情况:如果成员所属的对象是左值,那么结果是左值;反之,如果成员所属的对象是右值,那么结果是右值。

4.2.6 条件运算符

1
condition ? expr1 : expr2;
  • 当条件运算符的两个表达式都是左值或者能转换成同一类左值类型时,运算结果是左值,否者,运算结果是右值。

  • 可嵌套,最好别超过2到3层,如

    1
    cond1?expr1:cond2?expr2:expr3;
  • 满足右结合律,右边的条件运算构成了靠左边条件运算的分支。上述代码实际为例。

    1
    cond1 ? expr1  :   (cond2?expr2:expr3)  // <--从右边开始结合,括号中的是分支
  • 条件运算符的优先级非常低,当一条长表达式中嵌套了条件运算子表达式时,通常需要在其两端加上括号

4.2.7 位运算符

  • 位运算符整数类型的运算对象,并且把运算对象看成二进制位的集和

  • 如果运算对象是“小整型”,则它的值将会被自动提升为较大的整数类型。 —— 先提升,再对提升后的整体进行位运算

  • 不同机器对于符号位的处理各不相同,因此建议位运算符仅用于处理无符号类型

位运算符(左结合律)

  • 移位运算符

    • <<>>的右侧的运算符一定不能为负,并且值严格小于结果的位数

    • 满足左结合律

      1
      2
      3
      cout << "hi"<<"three"<<endl;
      //等价于
      ( (cout<<"hi") << "three" ) <<endl;
  • 示例
    位移运算符
  • 位求反运算符 略

  • 位与、位或、位异或

    • 异或 :不同出1,相同处0

4.2.8 sizeof运算符

  • sizeof返回一条表达式或一个类型名字所占的字节数,满足右结合律,得到size_t类型的常量表达式

    1
    2
    sizeof (type);  // sizeof (类型名)
    sizeof expr; // sizeof 表达式 <-- 不实际计算表达式的值,意味着即使是无效指针依然安全
  • c++11新标准允许使用作用域运算符来获取类成员大小

    1
    sizeof Sale_data::revenue;
  • 常见的sizeof运算结果

    • char或类型为char的表达式,结果为1

    • 引用类型,结果为被引用对象所占空间的大小

    • 指针,指针本身所占空间的大小

    • 解引用指针,指针指向的对象所占空间的大小,指针不需要有效

    • 数组,整个数组所占的大小;等价于对数组中所有元素各执行一次sizeof并求和

      1
      2
      3
      int ia[] = {...} // 一个很多元素的数组
      constexpr size_t sz = sizeof(ia) / sizeof(*ia); // 返回ia数组的元素的数量
      int arr2[sz]; // sizeof返回一个常量表达式,所有可以用于声明数组的维度
    • string和vector,只返回该类型固定部分的大小,不会计算对象中的元素占用了多少空间。<— 什么意思?没看懂

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      #include <iostream>
      #include <vector>
      using namespace std;

      int main()
      {
      cout<<sizeof(int)<<endl; // 4

      vector<int> a(10, 5); // 10个5,10个int大小按理来说应该是40个字节
      for (auto& ia : a) {
      cout << ia ;
      }
      cout << endl;

      cout << sizeof(a) << endl; // 却是32? <-- 不懂

      return 0;
      }

4.2.9 逗号运算符

  • 首先对左侧的表达式求值,然后将结果丢弃。真正的运算结果是右侧表达式的值,如果右侧运算对象是左值,那么最终的求值结果也是左值

  • 一般用于for循环,例

    1
    2
    3
    4
    vector<int>::size_type cnt = ivec.size();
    for(vector<int>::size_type ix = 0; ix != ivec.size(); ++ix, --cnt){
    ivec[ix] = cnt;
    }

4.3 类型转换

如果两种类型可相互转换,则它们是关联的。

4.3.1 隐式转换

  • 算术转换
    运算符的运算对象将转换至最宽的类型

    • 整型提升

      • 小整数类型提升成较大的整数类型:bool、char、signed char、unsigned char、short、unsigned short 所有可能的值都能存在int里,则提升为int,否则为unsigned int
      • 较大的char(wchar_t, char16_t, char32_t)提升成int、unsigned int、long、unsigned long、long long和 unsigned long long 中最小的一种类型
    • 无符号类型的运算对象

      • 注意有符号和无符号的混用带来的意外后果

  • 数组转换成指针

    • 在大多数情况下,数组名自动转化成数组首元素的指针
    • 当数组名被用作decltype关键字、取地址符、sizeof、typeid、用一个引用来初始化数组时,上述转化不会发生
  • 指针的转换

    • 0、nullptr能转化成任意指针类型
    • 任意非常量指针能转换成void *
    • 任意对象指针能转化成const void *
    • 有继承关系的类型间
  • 转换成bool

    • 0:false 否则true
  • 转换成常量

    • 允许将指向非常量类型的指针(引用)转化成指向相应的常量类型的指针(引用)

    • 不能反过来,因为这样试图删掉底层const

      1
      2
      3
      int i;
      const int *p = &i;
      const int &r = i;
  • 类类型定义的转换 (P144)

    • 类类型能定义由编译器自动执行的转换
    • 每次只能执行一种类类型的转换

4.3.2 显式转换

1
cast-name<type>(expression)

type:要转换的类型,expression:要转换的值;如果type是引用类型,则结果是左值

cast-name包括:static_cast、dynamic_cast、const_cast和reinterpret_cast

  • static_cast <— 最常用

    • 任何具有明确定义的类型转换,只要不包含底层const,都可以使用static_cast

    • 当需要把一个较大的算术类型赋给较小的类型时,利用static_cast可关闭“精度损失”的警告信息

    • 利用static_cast找回void *指针。

      1
      2
      void *p = &d;
      double * dp = static_cast<double *>(p); //确保等号两边类型一样,否则产生未定义行为
  • const_cast

    • 只能改变对象的底层const仅用于进行去除 const 属性,它也是四个强制类型转换运算符中唯一能够去除 const 属性的运算符

    • 要注意可能发生的未定义后果

    • 常用于有重载函数的上下文 <— 比如?

    • 用法举例

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      /*例1*/       //(P145,改)
      const int a = 0;
      const int* pc = &a; // 底层const
      cout << *pc << endl; //0
      int* p = const_cast<int*>(pc);
      *p = 5;
      cout << *p << endl; //5


      /*例2*/ // 参考文献[1]
      //const_cast只针对指针、引用、this指针 (只能改变对象的底层const)
      int main() {
      const int n = 5;

      int* k = const_cast<int*>(&n);//指针
      *k = 123;
      cout << *k << endl; //123

      int& kRef = const_cast<int&>(n);//引用
      kRef = 456;
      cout << kRef << endl; //456
      }


      /*例3*/ // 参考文献[1]
      class CTest
      {
      public:
      CTest() : m_nTest(2) {}
      //常成员函数,不能修改成员变量的值
      void foo(int nTest) const {
      //void* p = this;
      //m_nTest = nTest;

      //m_nTest = nTest; 错误

      //<CTest* const>指针地址不可改变,(this)代表常成员函数的this指针
      //const_cast去除CTest*前面的const
      const_cast<CTest* const>(this)->m_nTest = nTest;
      }

      public:
      int m_nTest;
      };

      int main() {
      CTest t;
      t.foo(1);
      }


  • reinterpret_cast

    • 通常为运算对象的位模式提供较低层次上的重新解释

    • 非常有风险

      本质上依赖于机器,想要安全地使用必须对涉及的类型和编译器实现转换的过程都非常了解

  • dynamic_cast (19章介绍)

4.4 运算符优先级表

运算符优先级表

五 语句

5.1 简单语句

  • 空语句 — 没有什么用,但是需要注意对循环的影响

    1
    2
    ;  //<--空语句,真么用也没有
    int i = 0;; // <-- 不会报错,就是多了一条空语句 :)

5.2 作用域

5.3 条件语句

5.3.1 if…else

  • 注意花括号
  • 悬垂else
    • 我们怎么知道给定的else和那个if相匹配? 这个问题被称为悬垂else
    • c++规定,与离它最近的尚未匹配的if匹配,从而消除程序的二义性

5.3.2 switch…case

  • case标签必须是整型常量表达式

  • switch的默认动作是从某个匹配成功的case开始,顺序执行其下的所有case,直到遇到break。最好在每个case中都添加break,以避免不必要的问题。虽然在某些情况下,我们确实希望多个case共享同一组操作而不写break,此种情况最好加一段注释以说明。

  • 最好添加default,声明我们已经考虑了默认情况,即使现在什么都没有做。

    • 标签不应该孤零零地出现,它后面必须跟上一条语句或者另外一个 case标签。如果switch 结构以一个空的 default 标签作为结束则该default 标签后面必须跟上一条空语句或一个空块。 <— (P163,没怎么懂在说什么)
  • switch内部的变量定义

    • 在C++11的标准下,【变量定义】操作在编译阶段就会执行分配内存,而涉及【变量初始化】操作的语句则必须等到程序运行时才会调用执行。

    • 因此对于switch语句的使用,如果确实有需要在内部定义变量的场景,最好的方法就是在编程的时候,将整个switch语句中都用到的变量在switch外定义好,到了switch内部,则可以针对某个case需要单独使用某些变量的情况,用{}作用域符号来明确此case语句的作用域

    • 参考文献

5.4 迭代语句

5.4.1 while

5.4.2 for

1
for(init-statemen; condition; expression){ statement; }
  • 只要condition为真,就执行一次statement,如果为false,一次也不执行
  • init-statemen可以定义多个变量,但只能有一条声明语句,因此意味着所有变量的基础类型必须相同
  • init-statemen 、condition、 expression都可以省略(P167)

5.4.3 do…while

1
2
3
do{
statement;
}while(condition); //<--最后还有个分号
  • condition不能为空
  • condition使用的变量不能定义在循环体之外
  • 不允许在condition部分定义变量

5.5 跳转语句

  • break

    • 终止最近的whiledo...whileforswitch,并从这些语句之后的第一条语句开始执行;
  • continue

    • whiledo...whilefor可用

    • 终止最近的循环中的当前迭代 并立即开始下一次迭代

  • goto

5.6 异常处理

image-20240115104749450

5.6.1 异常类

c++标准库定义了一组类,分别在4个头文件中:

  • exception
    • 定义exception,只报告异常的发生,不提供任何额外信息
  • stdexcept
    • 定义了几种常用的异常,下图列出
  • new
    • bad_alloc(12章)
  • type_info
    • bad_cast(19章)

其中,exceptionbad_allocbad_cast只能默认初始化,不允许提供初值。反之,其余的异常类必须提供string或c风格字符串以初始化。

异常类只有一个名为what()的成员函数,没有任何参数,返回初始化异常类时用到string(c风格)字符串。对于默认初始化的异常类,返回内容由编译器决定。

image-20240115142614063

5.6.2 抛出异常

throw关键字抛出一个异常后,会直接跳转到对应的catch块,节选5.6.4中示例如下:

1
2
3
4
if (this->m_isbn != item.m_isbn){
throw runtime_error("data must refer to same isbn.");
}
return this->m_sales_volume + item.m_sales_volume;

5.6.3 处理异常

try...catch接住throw抛出的异常并处理,语法如下

1
2
3
4
5
6
7
8
9
10
try {
//正常逻辑
//抛出异常
}
catch (/*(可能未命名的)异常声明1*/) {
//异常处理1
}
catch (/*(可能未命名的)异常声明2*/) {
//异常处理2
}

catch一旦完成,程序跳转到try语句块最后一个catch子句之后的那条语句继续执行。

  • 函数在寻找 异常处理代码 的过程中 退出(P175)

    在复杂系统中,程序在遇到抛出异常的代码前,其执行路径可能已经经过了多个 try语句块。例如,一个try语句块可能调用了包含另一个try语句块的函数,新的try语句块可能调用了包含又一个 try 语句块的新函数,以此类推。

    寻找处理代码的过程与函数调用链刚好相反。当异常被抛出时,首先搜索抛出该异常的函数。如果没找到匹配的 catch 子句,终止该函数,并在调用该函数的函数中继续寻找。如果还是没有找到匹配的 catch 子句,这个新的函数也被终止,继续搜索调用它的函数。以此类推,沿着程序的执行路逐层回退,直到找到适当类型的 catch 子句为止。

    如果最终还是没能找到任何匹配的 catch 子句,程序转到名为terminate 的标准库函数。该函数的行为与系统有关,一般情况下,执行该函数将导致程序非正常退出。对于那些没有任何 try语句块定义的异常,也按照类似的方式处理:毕竟,没有 try语句块也就意味着没有匹配的catch 子句。如果一段程序没有 try 语句块且发生了异常系统会调用terminate函数并终止当前程序的执行。

5.6.4 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <iostream>
#include <string>
#include <stdexcept>
using namespace std;

class Sales_item {
public:
Sales_item(string isbn, int sales_volume)
:m_isbn{ isbn }, m_sales_volume{ sales_volume } {}

string isbn() const { return m_isbn; }
int saleVolume() const { return m_sales_volume; }

int operator+(Sales_item item) {
// 使用异常处理将相加的代码和与用户交互的代码分离
try {
if (this->m_isbn != item.m_isbn)
throw runtime_error("data must refer to same isbn."); //跳转到catch (runtime_error err) {行
return this->m_sales_volume + item.m_sales_volume;
}
catch (runtime_error err) {
cout << err.what() << endl;
}
return -1;
}
private:
string m_isbn;
int m_sales_volume; //销售额
};

int main() {
Sales_item item1("1-2-3", 10);
Sales_item item2("1-2-3", 11);
Sales_item item3("4-5-6", 12);

int res1 = item1 + item2;
cout << res1 << endl;//21

int res2 = item1 + item3;
cout << res2 << endl;//失败并输出-1
}

六 函数

6.1 基础

  • 函数最外层的作用于中的局部变量也不能使用和函数形参一样的名字。(P184顶部,不理解)
  • 函数的返回类型不能是数组类型或函数类型,但可以是指向数组或函数的指针
  • 函数的三要素(返回类型、函数名、形参类型)描述了函数的接口,函数声明也被称为函数原型
  • 局部静态对象:在局部变量前加static,第一次经过该对象定义语句的时候初始化,并且直到程序终止才销毁,在此期间即使对象所在的函数结束也不会对它有影响。

6.2 参数传递

6.2.1 参数传递的方式

  • 值传递
  • 指针传递
    • 其实是一种形式的值传递
    • 在c++中,建议用引用类型的形参代替指针
  • 引用传递
    • 当函数无须修改引用形参的值时最好使用常量引用
    • 使用引用形参返回额外的信息

6.2.2 const形参和实参

  • 顶层const被忽略

    • 当形参有顶层const时,形参的顶层const被忽略,传给他常量对象或非常量对象都是可以的

    • 因为顶层const被忽略掉了,所以下述的两个func是一样的,不能重载

      1
      2
      int func(const int i) { return i; }
      int func(int i) { return i; } // 函数“int func(const int)”已有主体
  • 指针或引用参与const

    • 遵循“任何可能引发修改const值的操作都是非法的”

    • P191,略

  • 尽量使用常量引用

    • 把函数不会改变的形参定义成普通的引用是一种比较常见的错误,

      • 会给函数调用者“函数可以修改它们实参值的误导”

      • 极大限制函数所能接收的实参类型(我们不能把const对象、字面值或者需要类型转换的对象传递给普通的引用形参

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        /*例1*/
        string func(const string&i){ return i; }
        int main() {
        cout << func("a") << endl; // a
        return 0;
        }

        /*例2*/
        string func( string&i){ return i; }
        int main() {
        //无法用 "const char [2]" 类型的值初始化 "std::string &" 类型的引用(非常量限定)
        cout << func("a") << endl;
        return 0;
        }

参考文献

6.2.3 数组形参

  • 因为不能拷贝数组,我们无法以值传递的方式使用数组参数;又因数组会被转换成指针,所以当我们为函数传递一个数组时,实际上传递的是指向数组首元素的指针。

  • 三种等价的数组传参方式,数组大小对函数调用无影响

    1
    2
    3
    4
    //等价
    void func(const int *);
    void func(const int[]);
    void func(const int[10]); // 10表示我们期望,实际上不一定
  • 数组以指针传参,函数不知道大小,有三种常用管理方式管理指针形参:

    • 使用 结束标记 指定数组长度

      • 使用 类似c风格字符串数组的结束标记 标记数组结束的位置
    • 使用标准库规范

      • 使用begin和end函数,传递首元素和尾后元素的指针

        1
        2
        3
        4
        5
        6
        7
        8
        void func(const int*beg, const int*end){
        while(beg != end)
        cout<<*beg++<<endl;
        }

        /////调用
        int j[] = {0,1,2};
        func(begin(j),end(j));
  • 显式传递一个表示数组大小的形参 — 旧式风格

    1
    2
    3
    4
    5
    6
    7
    void func(const int *ia, size_t size){

    }

    /////调用
    int j[] = {0,1,2};
    func(j, end(j) - begin(j) );
  • 数组引用形参

    • 形参是数组的引用,维度是类型的一部分

    • 下例中,(&arr)括号必不可少,否则 int &arr[10]是将arr声明成了引用的数组

    • 下例中,[10]不可少,因为数组的大小是构成数组的一部分,只能将函数作用于大小为10的数组

      1
      void func(int (&arr)[10]){}
  • 传递多维数组

    • 多维数组是数组的数组,又因为将数组传递进函数的时候,传入的是指向第一个元素的指针。所以将多维数组传入函数,传入的是指向第一个数组(即多维数组的第一个元素)的指针。函数声明可以写为

      1
      2
      3
      4
      5
      //形参matrix看起来是一个二维数组,实际上是指向含有10个整数的数组的指针
      void func(int matrix[][10],int rowSize){}

      //matrix是一个指针,指向10个整数的数组
      void func(int (*matrix)[10], int rowSize){} //int (*matrix)[10] 小括号不可少,否则是10个指针构成的数组
  • main处理命令行

    1
    2
    int main(int argc, char **argv){}
    int main(int argc, char *argv[]){}

6.2.4 可变形参

可变形参用于编写可处理不同数量实参的函数,主要有三种方法:

  • initializer_list

    • 要求所有实参类型相同

    • 其中的对象永远是常量,无法改变其中的元素值

    • 如果向其中传递的是一个序列,则必须放在花括号中

    • 示例

      1
      2
      3
      4
      5
      6
      7
      8
      9
      /*声明*/
      void error(ErrorCode e, initializer_list<string> il){
      cout<<e.msg()<<endl;
      for(auto &msg : il)
      cout<<msg<<endl;
      }

      /*调用*/
      error_msg(ErrorCode(0), {"functionX","okay"} );
<img src="https://hezexian.oss-cn-guangzhou.aliyuncs.com/picture/202401171100692.png" alt="image-20240117110037435" style="zoom:67%;" />
  • 可变参数模板 (16章)

    • 实参类型不同
  • 省略符

    • varargsc标准库功能

    • 省略符只能出现在形参列表的最后一个位置

    • 仅用于c和c++通用的类型,大多数类类型的对象在传递给省略符形参时都无法正确拷贝

    • 示例

      1
      2
      void foo(parm_list, ...);
      void foo(...);

6.3 返回类型和return

6.3.1 有返回值的函数

  • 在含有return语句的循环后面也有一条return语句

  • 不要返回 对局部对象的引用 或 指向局部变量的指针,局部变量在函数完成后已经被释放

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    /*
    * 在vs2022上测试,该程序能正常编译和运行,
    * 但是运行结果不对,
    * 显然意味着这种错误不容易被发现
    */
    const string& test() {
    string a;
    a = "a";
    if (!a.empty())
    return a;
    else
    return "empty";
    }

    int main() {
    cout << test() << endl; // 期望输出a,实际上输出空
    return 0;
    }
  • 引用返回左值

    • 调用一个返回引用的函数得到左值,其他返回类型得到右值

    • 我们能为返回类型是非常量引用的函数的结果赋值

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      char& get_val(string &str, string::size_type ix) {
      return str[ix]; // 返回对 str[ix]的引用
      }

      int main() {
      string s = "a test";
      get_val(s, 0) = 'A';
      cout << s << endl;
      return 0;
      }
  • 可返回列表初始化,return {"funcX","okay"}

  • main函数的返回值

    • cstdlib中定义了两个预处理变量,表示成功或者失败

      1
      2
      return EXIT_FALLURE;
      return EXIT_SUCCESS;
  • 递归

    • 在递归函数中,一定有某条路径是不包含递归调用的,否则将一直递归循环,直至内存耗尽

6.3.2 数组指针

数组不能被拷贝,所以函数不能返回数组,不过函数可以返回数组的指针或引用。定义一个返回数组的指针或引用的函数有如下几种方法:

  • 使用类型别名

    1
    2
    3
    4
    5
    6
    7
    /*两个等价的定义类型别名的方法*/
    //arrT是一个类型别名,它表示的类型是含有10个整数的数组
    typedef int arrT[10];
    using arrT = int[10];

    /*使用*/
    arrT *func(int i);
  • 普通方法

    1
    int (*func(int i))[10];

    202401171607017

  • 尾置返回类型

    • c++11新标准可使用 ,将返回类型放在->后,并在原来写返回值类型的地方放个auto

      1
      auto func(int i) -> int(*)[10];  // 返回一个指针,指向放10个int数据的数组
  • decltype

    1
    2
    3
    4
    5
    6
    int odd[] = {1,3,5,7,9};
    int even[] = {2,4,6,8};

    decltype(odd) *arrPtr(int i){
    return (i%2) ? &odd : &even;
    }

    decltype 并不负责把数组类型转换成对应的指针,所以decltype 的结果(即int[])是个数组,要想表示 arrPtr 返回指针还必须在函数声明时加一个*符号。

6.4 重载

  • 如果同一作用域内的几个函数名字相同但形参列表不同,称之为函数重载(overloaded)
  • 重载和const形参
  • 重载和const_cast
    • const_cast在重载函数的情境中最有用 — 保障了安全性
      image-20240118100745867
  • 重载与作用域
    • 不要把函数声明置于局部作用域内

6.5 特殊用途语言特性

6.5.1 默认实参

  • 一旦某个形参被赋予了默认值,其后所有形参都必须有默认值

  • 默认实参负责填补函数调用缺少的尾部实参

  • 合理设置形参顺序,将经常使用默认值的形参放在后面

  • 函数后续声明只能为之前那些没有默认值的形参添加默认实参,而且该形参右侧的所有形参必须都有默认值

    1
    2
    3
    string screen(sz,sz,char=' ');
    string screen(sz,sz,char= '*'); //错误,重复声明
    string screen(sz = 24, sz = 80, char);// 正确,添加默认形参
  • 默认实参初始值

    • 局部变量不能作为默认实参,用作默认实参初始值的 表达式的值 必须声明在函数之外

    • 只要表达式的类型能转换成形参所需的类型,该表达式就能作为默认实参

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      sz wd = 80;
      char def = ' ';
      sz ht();

      string screen(sz = ht(), sz = wd, char = def)

      void f2()
      {
      string window = screen(); //screen(ht(), 80, ' ' );

      def = '*'; // 将传递这个更新过的 全局变量的 值
      sz wd = 100; //局部变量与默认实参没有任何关系
      window = screen(); //screen(ht(), 80, '*');
      }

6.5.2 内联函数

  • inline

  • 对编译器的建议,

  • 加速程序

6.5.3 constexpr函数

  • 能用于常量表达式的函数

  • 函数的返回类型及所有形参的类型都得是字面值类型,函数体中必须有且只有一条return语句

    1
    constexpr int new_sz() {return 42;}
  • 初始化任务时,编译器会把constexpr函数的调用替换成其结果值,函数被隐式地指定为内联函数

    1
    int arr[new_sz()];
  • constexpr函数中也可以包含其他语句,只要这些语句在运行时不执行任何操作(空语句、typedef、using) <— 唯一可执行的语句就是return

  • 允许返回非常量:当实参是是一个常量表达式时,返回常量表达式,反之则不然: <— constexpr函数不一定返回常量表达式

    1
    2
    3
    4
    5
    6
    constexpr int scale(size_t cnt) {return new_sz() * cnt ;}

    int arr[scale(2)]; // 正确

    int i = 2;
    int arr[scale(i)]; //错误,返回的不是常量表达式,无法初始化数组

6.5.4 调试帮助

  • assert(expr)#include <cassert>

    • expr为假,输出信息并终止,为真,什么也不做。即expr为不可能情况
  • NDEBUG预处理变量

    • 针对assert():定义NDEBUG能避免检查各种条件所需的运行时开销,当然此时根本就不会执行运行时检查。因此,assert 应该仅用于验证那些确实不可能发生的事情。我们可以把assert当成调试程序的一种辅助手段,但是不能用它替代真正的运行时逻辑检查,也不能替代程序本身应该包含的错误检查

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      /*例1*/
      #include <cassert>
      int main() {
      int i = 5;
      assert(i > 6); // Assertion failed: i > 6, file ... line 4
      return 0;
      }

      /*例2 NDEBUG要写在整个程序的开头,否则没有用*/
      #define NDEBUG
      #include <cassert>
      int main() {
      int i = 5;
      assert(i > 6); // 失效
      return 0;
      }

      /*例3 NDEBUG要写在整个程序的开头,否则没有用*/
      #include <cassert>
      #define NDEBUG
      int main() {
      int i = 5;
      assert(i > 6); // Assertion failed: i > 6,file ...
      return 0;
      }
    • 针对#ifndef ...#endif

      • 如果定义了NDEBUG,#ifndef ...#endif之间的代码将被忽略
      1
      2
      3
      4
      5
      //#define NDEBUG

      #ifndef NDEBUG
      ...
      #endif
      • 补充,预处理器定义的几个 用于调试程序的 变量

        | 变量名 | 功能 |
        | ————— | ——————————————————————- |
        | __func__ | const char 的一个静态数组,存放函数的名字 |
        | __FILE__ | 存放文件名的字符串字面值 |
        | __LINE__ | 存放当前行号的整型字面值 |
        | __TIME__ | 存放文件编译时间的字符串字面值 |
        | __DATE__ | 存放文件编译日期的字符串字面值 |

        • 示例
  
1
2
3
4
//示例
std::cout<<__func__
<<"in file: "<<__FILE__
<<"line "<<__LINE__;

6.6 函数匹配

  • 候选函数

    • 与被调用的函数同名
    • 其声明在调用的可见
  • 可行函数 — 从候选函数中选出能被这组实参调用的函数

    • 实参数量相等,类型相同
  • 最佳匹配

    • 单个参数:实参类型与形参类型越接近,它们匹配得越好

    • 多个参数:如果有且只有一个函数满足下列条件,则匹配成功。如果在检查了所有实参之后没有任何一个函数脱颖而出,则该调用是错误的。编译器将报告二义性调用的信息。

      • 该函数每个实参的匹配都不劣于其他可行函数需要的匹配。
      • 至少有一个实参的匹配优于其他可行函数提供的匹配。
    • 实参类型的转换 (P219)

      • 编译器将实参类型到形参类型的转换划分成几个等级:
        image-20240118161700062

      • 在设计良好的系统中函数很少会含有与下列例子相似的形参:(P219底部-220)

        • 假设有两个函数,一个接受 int、另一个接受short,则只有当调用提供的是 short 类型的值时才会选择 short 版本的函数。有时候,即使实参是一个很小的整数值,也会直接将它提升成int 类型。
        • 所有算术类型转换的级别都一样。例如,从int 向unsigned int 的转换并不比从int向 double的转换级别高。当存在两种可能的算数类型转换时,调用具有二义性。
      • 重载忽略顶层const,因此顶层const不能用于重载;而底层const可重载:如果重载函数的区别在于它们的引用类型的形参是否引用了 const(或者指针类型的形参是否指向const),则当调用发生时编译器通过实参是否是常量来决定选择哪个函数:

        1
        2
        3
        4
        5
        6
        7
        int lookup(string&);
        int lookup(const string &);
        int a;
        const int b;

        lookup(a); // 调用 lookup( string &);
        lookup(b); // 调用 lookup(const string &);

6.7 函数指针

6.7.1 函数指针是什么?

  • 函数指针指向的是函数而非对象

  • 函数指针指向某种特定类型。函数的类型由它的返回类型和形参列表共同决定,与函数名无关

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 函数
    bool lengthCompare(const string &, const string &);

    // 类型
    bool(const string &, const string &);

    //指向函数的指针
    bool (*ptr)(const string &, const string &)
    /*ptr是一个指针,指向参数是(const string &, const string &)的函数,返回bool类型*/
    /*(*ptr)的括号不可少,否则ptr变成了一个返回 bool* 类型的函数*/

6.7.2 如何使用

  • 当我们把函数名作为一个值使用时,该函数自动转换成指针
  • 还能指向函数的指针调用该函数,而无须提前解引用指针
  • 不同类型的函数指针间不存在转换
  • 可用nullptr或0初始化指针
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* 函数 */
bool lengthCompare(const string &, const string &);
/* 指向函数的指针 */
bool (*ptr)(const string &, const string &);
/*声明的同时定义*/
bool (*ptr)(const string &, const string &) = lengthCompare;


/* 初始化 */
ptr = nullptr;
/* 赋值 */
ptr = lengthCompare;
// 等价于
ptr = &lengthCompare;



/* 调用 */
bool b = ptr("hello", "goodbye");
//等价
bool b = (*ptr)("hello", "goodbye");
//等价
bool b = lengthCompare("hello", "goodbye")

6.7.3 重载函数的指针

  • 编译器通过指针类型决定选用哪个函数
  • 指针类型必须与重载函数中的某一个精确匹配
1
2
3
4
5
6
7
8
//重载的func函数
void func(int *);
void func(unsigned int);

//指向func函数的指针
void (*ptr)(unsigned int) = func; // 正确
void (*ptr)(int) = func; // 错误,没有一个重载的func与该形参列表匹配
double (*ptr)(unsigned int) = func; // 错误,没有一个重载的func与该返回类型匹配

6.7.4 函数指针作形参

  • 和数组类型(6.2.3节),形参可以是指向函数的指针。

    1
    2
    3
    4
    5
    6
    //形参看起来是函数类型,实际上确实当成指针使用
    void useBigger(const string &s1, const string &s2,
    bool pf(const string &, const string &));
    //等价
    void useBigger(const string &s1, const string &s2,
    bool (*pf)(const string &, const string &));
  • 可以直接把函数作为实参使用,他会被自动转换为指针

    1
    useBigger(s1,s2,lengthCompare); // 函数名即指向函数的指针
  • 如上,直接使用函数指针类型显得冗长,使用类型别名和decltype简化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    /*函数类型*/
    typedef bool Func(const string &, const string &);
    //等价
    using Func = bool(const string &, const string &);
    //等价
    typedef decltype(lengthCompare) Func;



    /*指向函数的指针*/
    typedef bool (*FuncPtr)(const string &, const string &);
    //等价
    using FuncPtr = bool(*)(const string &, const string &);
    //等价
    typedef decltype(lengthCompare) *FuncPtr;



    /*用简化的函数类型声明useBigger*/
    void useBigger(const string &s1, const string &s2, Func); // 编译器自动地将Func表示的函数类型转换成指针
    //等价
    void useBigger(const string &s1, const string &s2, FuncPtr);

6.7.5 返回指向函数的指针

  • 与形参不同,编译器不会自动地将函数返回类型当成对应的指针类型处理,因此我们必须显式地将返回类型指定为指针

    1
    2
    3
    4
    5
    6
    //四个等价
    FuncPtr useBigger(const string &s1, const string &s2, FuncPtr);
    Func *useBigger(const string &s1, const string &s2, FuncPtr);
    auto useBigger(const string &s1, const string &s2, FuncPtr) -> bool(*)(const string &, const string &);//尾置返回类型
    bool (* useBigger(const string &s1, const string &s2, FuncPtr) ) (const string &, const string &);
    /*解释:先看括号里的,useBigger有形参列表,是一个函数;其前面有*,是一个指针;指向一个bool(const string &, const string &)的函数类型*/
  • 将auto和decltype用于函数指针类型

    • 牢记将decltype作用于某个函数,它返回函数类型而非指针类型

      1
      2
      3
      4
      string::size_type sumlength(const string&, const string &);
      string::size_type largerlength(const string &, const string &);

      decltype(sumlength) *getFunc(const string &);

七 类

7.1 定义抽象数据类型

7.1.1 关于类的基础知识

(P228~P235)主要讲述了“类”的基础知识。

一、

首先说明了类是什么:

  • 类的基本思想是数据抽象 (data abstraction)和封装(encapsulation)。数据抽象是一种依赖于接口(interface)和实现 (implementation)分离的编程(以及设计)技术。
  • 类的接口包括用户所能执行的操作;类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。
  • 封装实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节,也就是说,类的用户只能使用接口而无法访问实现部分。

二、

通过设计Sales_data类,讲述了数据成员及成员函数。并提出了如下建议:

在一些简单的应用程序中,类的用户和类的设计者常常是同一个人。尽管如此,还是最好把角色区分开来。当我们设计类的接口时,应该考虑如何才能使得类易于使用;而当我们使用类时,不应该顾及类的实现机理。

三、

接着通过成员函数引入this指针,该指针是类的成员函数额外的隐式参数,指向调用它的那个对象。有如下代码,

1
2
3
//Sale_data定义于P230中间
Sales_data total;
total.isbn(); // total调用了成员函数isbn()

实际上,编译器将该调用重写成如下形式

1
Sales_data::isbn(&total);
  • 任何对类成员的直接访问都被看做this的隐式引用;
  • 任何自定义名为this的参数或变量的行为都是非法的;
  • this是一个常量指针,不允许修改this中保存的地址。

成员函数在紧随参数列表之后,可以有const,用以修改隐式this指针的类型。示例如下:

1
string isbn() const {return bookNo; }
  • 默认的情况下,this是指向非常量版本的常量指针,所以不能把this绑定到常量对象上,这使得我们不能在一个常量对象上调用普通的成员函数。C++允许在成员函数紧随参数列表后,添加const关键字,使得this变成一个指向常量的常量指针。如此,该成员函数被称为常量成员函数,常量成员函数不能改变调用它的对象的内容

  • 常量对象,及常量对象的引用或指针都只能调用常量成员函数。

四、

关于成员变量声明于成员函数之后,成员函数却能读取到成员变量,书中作出如下解释:

编译器分两步处理类:

  • 首先编译成员的声明
  • 然后才轮到成员函数体(如果有的话)。

因此,成员函数可以随意使用类中的其他成员而无须在意这些成员出现的次序。

五、

之后,

  • P232提及如何在类外部定义成员函数;

  • P233介绍了如何定义一个返回this对象的函数,通过*this以获得执行该函数的对象。

  • P234在“定义类相关的非成员函数”一节中,提到了如下几个关键点:

    • 一些辅助函数,尽管定义的操作从概念上来说属于类的接口的组成部分,但它们实际上并不属于类本身。这些函数也应与类声明(而非定义)在同一个头文件内。这样,用户使用接口的任何部分都只需要引入一个文件。

    • istreamostreamio类属于不能被拷贝的类型,因此,我们只能通过引用来传递它们。又因为读写操作会改变流的内容,所以两个函数接受的都是普通引用,而非对常量的引用。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      istream &read(istream &is, Sales_data &item){
      ...;
      is>>a>>b>>c;
      return is;
      }
      ostream &write(ostream &os, Sales_data &item){
      ...;
      os<<item.isbn(); //注意没有endl()等控制格式
      return os;
      }
    • 执行输出任务的函数应该尽量减少对格式的控制,将格式控制交给用户

    • 默认情况下,拷贝类的对象其实拷贝的是对象的数据成员(没有拷贝成员函数,不同的类对象共用成员函数,并用this控制(侯捷高级面向对象课程))

7.1.2 构造函数

一、构造函数不能被声明成const

当我们创建类的一个const对象时,直到构造函数完成初始化过程,对象才真正取得“常量”属性—>因此,构造函数在const对象的构造过程中可以向其写值

二、(编译器)合成的默认构造函数将按如下规则初始化类的数据成员:

  • 如果存在类内初始值。则用它来初始化成员
  • 否则,默认初始化

(P262 默认构造函数的作用)

  • 类必须包含一个默认构造函数以便在下述情况下使用。
  • 在实际中,如果定义了其他构造函数,那么最好也提供一个默认构造函数。
  • 当对象被默认初始化或值初始化时自动执行默认构造函数。默认初始化在以下情况下发生:

    • 当我们在块作用域内不使用任何初始值定义一个非静态变量(参见2.2.1节,第39页)或者数组时(参见3.5.1节,第101页)。
    • 当一个类本身含有类类型的成员且使用合成的默认构造函数时(参见7.1.4 节,第235页)。
    • 当类类型的成员没有在构造函数初始值列表中显式地初始化时(参见7.1.4 节,第237页)。
  • 值初始化在以下情况下发生:

    • 在数组初始化的过程中如果我们提供的初始值数量少于数组的大小时 (参见3.5.1节,第 101页)。
    • 当我们不使用初始值定义一个局部静态变量时(参见6.1.1节,第185页)。
    • 当我们通过书写形如 T()的表达式显式地请求值初始化时,其中T是类型名(vector 的一个构造函数只接受一个实参用于说明 vector 大小(参见3.3.1节第88页),它就是使用一个这种形式的实参来对它的元素初始化器进行值初始化).

三、某些类不能依赖于默认构造函数:

  • 类内已经显式声明了构造函数
  • 类中包含内置类型(int等)或复合类型(如数组、指针),如执行默认构造,则他们的值将是未定义的。只有当这些值被赋予了初始值(7.3.4),才可使用默认构造
  • 类中包含一个其他类型的成员,其这个成员的类型没有默认构造,则编译器也无法对当前类执行默认构造

四、构造函数的几种方式

  • =default

    在c++11新标准中,可以在参数列表后面写=default来要求编译器生成默认构造函数。注意要为内置类型或复合类型数据成员提供初始值。

    1
    Sales_data() = default;
  • 构造函数列表初始化
    当某个数据成员被构造函数初始值化列表忽略时,它将以与合成的默认构造函数相同的方式隐式初始化(此时要求有类内初始值),

    1
    Sales_data(const string &s,double p) : bookNo(s),revenue(p) {}
  • 在类外部定义构造函数

五、拷贝、赋值和析构

尽管编译器能替我们合成拷贝、赋值和销毁的操作,但是必须要清楚的一点是,对于某些类来说合成的版本无法正常工作。特别是,当类需要分配类对象之外的资源时,合成的版本常常会失效。管理动态内存的类通常不能依赖于上述操作的合成版本

7.1.3 构造函数再探

本节应是 书本P257页开始7.5 的内容,为了笔记结构的简洁,放到 笔记7.1.2构造函数 之后。

一、关于列表初始

  • 使用列表初始化在构造函数体内通过拷贝赋值的方式初始化,看似一样,但有时必须使用列表初始化且必不可少:如果成员是const、引用,或者属于某种未提供默认构造函数的类类型,我们必须通过列表初始化为这些成员变量提供初始值。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    class ConstRef{
    public:
    /*正确*/
    ConstRef(int ii):i(ii),ci(ii),ri(i){}
    /*引发错误*/
    ConstRef(int ii){
    i = ii;
    ci = ii; //错误,不能给const赋值
    ri = i; //错误,引用未被初始化
    }


    private:
    int i;
    const int ci;
    int &ri;
    };
  • 列表初始化的初始化顺序问题 — 成员列表初始化的顺序与它们在类定义中的出现顺序一致:

    构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序成员列表初始化的顺序与它们在类定义中的出现顺序一致:第一个成员先被初始化,然后第二个,以此类推。构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序。一般来说,初始化的顺序没什么特别要求。不过一个成员用另一个成员来初始化,那么这两个成员的初始化顺序就很关键了。最好令构造函数初始值的顺序与成员声明的顺序保存一致,如果可能的话,尽量避免使用某些成员初始化其他成员。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    /*例1*/
    class ConstRef {
    public:
    /*正确*/
    ConstRef(int ii) :j(ii),i(j) {
    cout <<"i = "<< i << ", j = " << j << endl;
    // 终端输出:i = -858993460, j = 1
    // 出错,因为根据声明的顺序,先初始化i,而此时j还是未定义状态
    }
    private:
    int i;
    int j;
    };

    /*例2*/
    class ConstRef {
    public:
    /*正确*/
    ConstRef(int ii) :j(ii),i(j) {
    cout <<"i = "<< i << ", j = " << j << endl;
    // 终端输出:i = 1, j = 1
    }
    private:
    // 声明顺序与列表初始化顺序匹配
    int j;
    int i;
    };
  • 如果为一个构造函数的所有参数都提供了默认实参,则其实际上也成为了默认构造函数。

二、委托构造

  • 概念:一个委托构造函数 使用它所属类的其他构造函数 执行自身初始化过程。(将自身的(一些或全部)职责委托给了其他构造函数)
  • 当一个构造函数委托给另一个构造函数时,受委托的构造函数的列表初始化和函数体被依次执行,然后才轮到委托者的函数体。
  • 示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Sales_data {
public:
using unint = unsigned int;
//非委托构造使用对应的实参初始化成员
Sales_data(string s, unint cnt, double price)
:bookNo(s), sold(cnt), rev(cnt* price) {}

// 委托构造
Sales_data() :Sales_data("", 0, 0) {}
Sales_data(string s) :Sales_data(s, 0, 0) {}
Sales_data(istream& is) :Sales_data() { read(is, *this); }

void read(istream& is, Sales_data) {/*...*/ }

private:
string bookNo;
unint sold;
double rev;
};

三、隐式类型转换

  • 如果一个类的构造函数只接受一个参数,则有从 该参数类型 到 该类类型 的隐式转换

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    #include <cassert>
    #include <string>
    #include <iostream>
    using namespace std;

    class Sales_data {
    public:
    using uint = unsigned int;
    Sales_data() = default;
    Sales_data(string s, uint cnt, double p)
    :m_isbn(s), m_cnt(cnt), m_price(p) {}
    Sales_data(string s) :m_isbn(s) { } // 1.string 可隐式转换为 Sales_data

    Sales_data& combine(Sales_data sd) { // 3. <-- string转换为Sales_data后带入
    if (sd.m_isbn != this->m_isbn)
    cerr << "isbn is not same." << endl;
    this->m_cnt += sd.m_cnt;
    return *this;
    }
    private:
    string m_isbn = "";
    uint m_cnt = 0;
    double m_price = 0;
    };


    int main() {
    Sales_data item("978-7-121-15535-2"); // 直接初始化
    //也可拷贝初始化
    //Sales_data item = string("978-7-121-15535-2");

    string isbn = "978-7-121-15535-2";
    item.combine(isbn); // 2.正确,string 隐式转换为 Sales_data类型

    return 0;
    }
  • 但是,这种类型转换只允许一步完成,下面这种分开是不允许的:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    int main() {
    Sales_data item("978-7-121-15535-2", 2, 3);
    /*错误*/
    item.combine("978-7-121-15535-2");
    //编译器先向字符串常量转化为string;再将该string临时变量转换成Sales_data
    //两步转换导致隐式转换失败
    //编译器报错: 不存在从 "const char [18]" 转换到 "Sales_data" 的适当构造函数


    /*正确*/
    item.combine(string("978-7-121-15535-2"));
    //或
    item.combine(Sales_data("978-7-121-15535-2"));
    return 0;
    }
  • 利用explicit关键字抑制单参数构造函数的隐式类型转换

    • 关键字 explicit只对一个实参的构造函数有效。需要多个实参的构造函数不能用于执行隐式转换,所以无须将这些构造函数指定为explicit 的。

    • 只能在类内声明构造函数时使用explicit 关键字,在类外部定义时不应重复

    • 使用了explicit关键字的构造函数只能以直接初始化的形式使用,不再支持拷贝形式的初始化

      1
      2
      3
      4
      5
      6
      7
      8
      9
      class Sales_data{
      public:
      explicit Sales_data(string s) :m_isbn(s) { } // 禁止隐式转换
      };
      string isbn = "978-7-121-15535-2";
      Sales_data item1(isbn); // 正确
      Sales_data item2 = isbn; // 错误

      item1.combine(isbn); // 错误,隐式转化为explicit禁止了
    • 可是我们非要类型转换怎么办?可以显式类型转换

      1
      2
      3
      item1.combine(Sales_data(isbn)); // 错误,隐式转化为explicit禁止了
      //或
      item1.combine(static_cast<Sales_data>(isbn));
  • 标准库中含有显式构造(explicit)的类

    • 接受一个单参数的const char*的string构造函数(参见3.21节,第76页)不是explicit的。
    • 接受一个容量参数的 vector 构造函数(参见3.3.1节,第87页)是explicit 的。

7.2 控制访问和封装

7.2.1 访问说明符

  • public
  • private
  • protected

7.2.2 class与struct

  • class默认private
  • struct默认public

7.3.3 友元

一、友元函数

  • 当类的数据成员被设为private,非成员函数(所谓辅助函数)就无法访问到它们了。为解决这个问题,可将这些非成员函数设为友元friend;
  • 友元生命只能定义在类的内部,但是在类内出现的具体位置不限。友元不是类的成员,也不受它所在区域访问控制级别的约束。不过,一般来说,最好在类开始或结束的位置集中声明友元。
  • 友元的声明仅仅指定了访问权限,而非通常意义上的函数声明,所以必须在友元声明之外再专门对函数进行一次声明。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Sales data{
//为Salesdata的非成员函数所做的友元声明
friend Sales_data add(const Sales_data&,const Sales_data&);

//其他成员及访问说明符与之前一致
public:
Sales_data() = default;
......
private:
std::string bookNo;
......
}

//Salesdata接口的非成员组成部分的声明
Sales_data add(const Sales data&,const Sales datas); //类的非成员函数声明

二、友元类

书本P250~P252对友元进行了补充,介绍了类与类之间的友元关系。

1.类作友元

  • 友元类的成员函数可以访问此类的所有成员

    1
    2
    3
    class Screen{
    friend class Window_mgr; // Window_mgr的成员函数可以访问Screen的所有成员
    };
  • 友元关系不具有传递性。Window_mgr的友元与Screen没有关系。

2.类的成员函数作友元

  • 当把一个成员函数声明成友元时,我们必须明确指出该成员函数属于哪个类

    1
    2
    3
    class Screen{
    friend void Window_mgr::clear(ScreenIdx); // Window_mgr的成员函数可以访问Screen的所有成员
    };
  • 想要某个成员函数作为友元,必须仔细组织程序结构,以满足声明和定义的彼此依赖关系:

    image-20240122104212361

  • 尽管重载函数名字相同,但它们是不同的函数,友元声明要分别声明。

3.友元声明和作用域

  • 要理解:友元声明的作用是设定访问权限,其本身并不是普通意义上的声明。(必须在别处书写真正的声明。)

    1
    2
    3
    4
    5
    6
    7
    void f(); // 声明
    struct X{
    friend void f() { cout << "hello" << endl; } // 即使在此处定义,也要在类的外部提供声明
    X() { f(); }
    };

    // void f(); //在此处定义,本例类的构造函数会报“f找不到标识符”

7.3 类的其他特性

7.3.1 在类中定义类型成员

  • 类还可以自定义某种类型在类中的别名,该别名同样存在访问权限。
  • 与不同成员不用关注定义的顺序不同,定义类型的成员必须先定义后使用。因此类型成员通常出现在类开始的地方;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Screen{
public:
/* 在类中定义类型成员 */
typedef string::size_type pos;
//等价
//using pos = string::size_type;

private:
pos cursor = 0;
pos height = 0, width = 0; // 默认初始值

}

/* 如何在类外使用?*/
Screen::pos myPos;

7.3.2 令成员做inline函数

  • 最好在类外部定义的地方说明inline,以使类更容易理解

    1
    2
    3
    4
    5
    6
    7
    8
    class Screen{
    public:
    Screen &move(pos r, pos c);
    }

    inline
    Screen &Screen::move(pos r, pos c){
    }
  • inline成员函数也应与相应的类定义在同一个头文件中

7.3.3 可变数据成员 — mutable

  • 可以通过向类的某个变量声明中加入mutable关键字,达到即使是在一个const成员函数内也能修改该成员变量的目的。
  • 可变数据成员永远不会是const,任何时候都能被修改
1
2
3
4
5
6
class Screen{
public:
void some_member() const { ++access_ctr; } //access_ctr用于记录成员函数被调用了多少次
private:
mutable size_t access_ctr;
}

7.3.4 类数据成员的初始值

  • 希望自己设计的类一开始就被一个默认初始化,最好的方式就是将默认值声明成类内初始值
  • 当我们提供一个类内初始值时,必须以等号或者花括号表示
1
2
3
4
class Window_mgr{    
private:
vector<Screen> screens{Screen(24,80,' ')};
}

7.3.5 :star:成员函数利用引用返回*this

一、可将一系列操作连接成一条表达式

1
2
3
4
5
6
7
8
9
typedef string::size_type pos;
inline Screen &Screen::move(pos r, pos c){ // <-- 返回*this的引用
...;
return *this;
}
inline Screen &Screen::set(char ch){
...;
return *this;
}

上述代码中,返回引用的函数是左值的,意味着上述函数返回的是对象本身而非副本。将this对象作为左值返回,可以把一系列操作连接成一条表达式:

1
2
3
4
5
6
Screen myScreen;
myScreen.move(4,0).set('#'); // 一系列操作连接成一条表达式

//等价
myScreen.move(4,0);
myScreen.set('#');

反之,如果返回的非引用(Screen &)而是值传递(Screen),则调用set()只是改变副本,而不能改变myScreen的值,连续调用将会失败。

1
2
Scream tmp = myScreen.move(4,0);
tmp.set('#');

对比实验如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include <cassert>
#include <string>
#include <iostream>
using namespace std;

class Screen {
public:
typedef string::size_type pos;
Screen() = default;

// 返回值为引用的版本 <-- 正确的版本
Screen& move(pos r, pos c);
Screen& set(char ch);

// 重载返回值非引用的版本,noRefer是用于重载的参数,无实际意义
Screen move(pos r, pos c, string noRefer);
Screen set(char ch, string noRefer);

void print() { cout << m_r << ", " << m_c << ", " << m_ch << endl; }

private:
pos m_r = 0, m_c = 0;
char m_ch = ' ';

};
// 返回值为引用的版本
inline Screen& Screen::move(pos r, pos c) {
m_r = r;
m_c = c;
return *this;
}
inline Screen& Screen::set(char ch) {
m_ch = ch;
return *this;
}
// 重载返回值非引用的版本,noRefer是用于重载的参数,无实际意义
inline Screen Screen::move(pos r, pos c, string noRef) {
m_r = r;
m_c = c;
return *this;
}
inline Screen Screen::set(char ch, string noRef) {
m_ch = ch;
return *this;
}
//main
int main() {
Screen myScreen1,myScreen2;
myScreen1.print(); //0,0,

myScreen1.move(4, 0).set('#');
myScreen1.print(); //4,0,#

myScreen2.move(4, 0, "noRef").set('#', "noRef"); // set失败,作用于了myScreen的副本
myScreen2.print(); //4,0,

return 0;
}

二、const成员函数的重载和*this指针的返回

  • 重载多个const成员函数如何选择?下图1
  • 从const成员函数返回*this:一个const成员函数如果以引用形式返回this,返回类型将是常量引用,下图2。
  • this指针的隐式传递,下图3

image-20240121210058172

7.3.6 类类型

  • 只声明而未定义的类被称作前向声明;

  • 在类定义之后,声明之前被称为不完全类型;

  • 不完全类型用于有限的场景:

    • 可以定义指向这种类型的指针或引用

    • 可以声明(但不可定义)以不完全类型作为参数或者返回类型的函数

  • 我们创建类的对象之前,该类必须被定义过

  • 一种例外情况:(此处没有读懂,但是知道该用法)
    image-20240121222858971

7.4 类的其他形式

7.4.1 聚合类

  • 用户可以直接访问其成员,且具有特殊初始化语法

  • 满足如下条件:

    • 所有成员都是 public的。
    • 没有定义任何构造函数。
    • 没有类内初始值
    • 没有基类,也没有 virtual函数
  • 示例

    1
    2
    3
    4
    struct data{
    int ival;
    string s;
    };
  • 初始化 — 顺序必须与生命顺序一致

    1
    data val{0,"anna"};

7.4.2 constexpr类

  • 对于聚合类,如果数据成员都是字面值类型,则为字面值常量类
  • 对于普通的类,满足:
    • 数据成员都必须是字面值类型
    • 类必须至少含有一个 constexpr 构造函数。
    • 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式(笔记2.5.4);或者如果成员属于某种类类型,则初始值必须使用成员自己的constexpr构造函数。
    • 类必须使用析构函数的默认定义,该成员负责销毁类的对象(书7.1.5节,第239页)。

关于constexpr构造函数

  • 尽管构造函数不能是 const 的,但是字面值常量类的构造函数可以是 constexpr函数。
  • 事实上,一个字面值常量类必须至少提供一个constexpr 构造函数
  • constexpr构造函数的形式:
    • 法一:=default
    • 法二:既符合构造函数的要求(无返回语句),又符合constexpr函数的要求。 <— constexpr构造函数体一般是空的。
  • constexpr构造函数必须初始化所有数据成员。初始值 或者使用constexpr 构造函数 ,或者是一条常量表达式。
  • constexpr 构造函数用于生成constexpr 对象以及 constexpr 函数的参数或返回类型。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <cassert>
#include <string>
#include <iostream>
using namespace std;

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() const { return hw || io || other; }
//在vs2022上须指定为const成员函数,否则:
//“bool Debug::any(void)”: 不能将“this”指针从“const Debug”转换为“Debug &”

void set_io(bool b) { io = b; }
void set_hw(bool b) { hw = b; }
void set_other(bool b) { hw = b; }
private:
bool hw;
bool io;
bool other;
};




int main() {
/*调用*/
constexpr Debug io_sub(false, true, false);
if (io_sub.any()) // if(true)
cerr << "print appropriate error messages" << endl;
constexpr Debug prod(false);
if (prod.any()) // if(false)
cerr << "print anerror message" << endl;

return 0;
}

7.5 类的作用域

7.5.1 类名::的作用范围

本节从书本P253开始,首先简述了如何通过类访问其中的成员变量、成员函数和typedef的类型别名。

接着,讲述了类名::的作用范围,即其后的所有东西,包括函数名、参数列表和函数体。而其之前的返回值类型不包含在其中,如果返回值类名在此类中定义,也要用类名::额外声明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Screen{
public:
/* 在类中定义类型成员 */
typedef string::size_type pos;
//等价
//using pos = string::size_type;

pos clear(int i);

private:
pos cursor = 0;
pos height = 0, width = 0; // 默认初始值

}

/*调用*/
// Screen::clear中的 `Screen::`不作用于pos,pos需要额外声明其所属类
Screen::pos Screen::clear(int i){}

7.5.2 名字查找

  • 类成员声明的名字查找,考虑下述代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    typedef double Money;
    string bal;
    class Account{
    public:
    Money balance() {return bal;} //1.

    private:
    //typedef double Money; //2.错误,Money不可重复定义
    Money bal;
    };
    • 编译器处理完类中的全部声明后,才会处理成员函数的定义。

    • 在注释1处,编译器没有找到 在Account中 使用Money前 出现的声明,接着到Account外层作用域寻找,找到了Money。

    • 另一方面,成员函数balance()的函数体在整个类全部可见(声明)后才被处理(函数定义),因此返回成员变量bal,而非外层的string的bal。

    • 在注释2处,

      一般来说,内层作用域可以重新定义外层作用域中的名字,即使该名字已经在内层作用域中使用过。然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字。

      建议将类型名的定义放在类开始处,这样保证所有使用该类的成员都出现在类名定义之后。

  • 成员函数中使用的名字的查找方式:
    image-20240123224641037

    • 成员变量名和成员函数参数名重名,降低了代码的阅读性。

      建议不要将成员名字作为参数或其他局部变量使用,如下述代码例3所示。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      int height;
      class Screen{
      public:
      typedef string::size_type pos;
      /*********** 例1 *************/
      void func(pos height){
      cursor = width * height; // height是参数声明
      }
      /*****************************/
      private:
      pos cursor = 0;
      pos height = 0,width = 0;
      };


      /*将例1替换成例2*/
      /*********** 例2 *************/
      void func(pos height){
      cursor = width * this->height; //类成员height
      //等价
      cursor = width * Screen::height; //类成员height
      }
      /*****************************/


      /*将例1替换成例3*/
      /*********** 例3 建议的写法*************/
      void func(pos ht){
      cursor = width * height; // 类成员height
      }
      /*****************************/


      /*将例1替换成例4*/
      /*********** 例4 *************/
      void func(pos height){
      cursor = width * ::height; // 全局的那个int height;
      }
      /*****************************/
  • 在文件中名字的出现处进行解析

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    int height;
    class Screen{
    public:
    typedef string::size_type pos;
    void func(pos);
    private:
    pos cursor = 0;
    pos height = 0,width = 0;
    };

    Screen::pos verify(Screen::pos);

    void func(pos var){
    height = verify(var);
    }

    虽然函数verify()在类Screen定义之后,但出现在了成员函数func()定义之前的全局作用域,所以可被正常使用。(参见:成员函数中使用的名字的查找方式第3点:如果类内也没找到该名字的声明,在成员函数定义之前的作用域内继续查找。)

7.6 类的静态成员

7.6.1 基础

  • 在成员声明前加上static关键字声明静态成员。静态成员直接与类关联,而不是与类的对象关联。
  • 静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据。只会存在一个静态数据,被所有对象共享。
  • 静态成员函数不与任何对象绑定,不包含this指针,不能声明为const成员函数。

7.6.2 定义静态成员

  • 因为静态成员不属于类的任何一个对象,因此不能用类的构造函数初始化。
  • 一般来说,不能再类内初始化静态成员,必须在类外部定义和初始化每个静态成员。除了笔记7.6.3的情况。
  • 一旦定义,将存在于整个程序的生命周期。
1
2
3
4
5
6
7
8
9
10
class Account
{
public:
Account();
~Account() = default;
private:
static int i; /*= 0;//错误,带有类内初始值设定项的成员必须为常量*/
};

int Account::i = 0; // 类外定义和初始化

7.6.3 静态成员的类内初始化

笔记7.6.2说:“不能再类内初始化静态成员,必须在类外部定义和初始化每个静态成员。”但是,有一种例外。

我们可以为静态成员提供const 整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的constexpr(参见 7.5.6 节,第267 页)。初始值必须是常量表达式,因为这些成员本身就是常量表达式,所以它们能用在所有适合于常量表达式的地方。例如,我们可以用一个初始化了的静态数据成员指定数组成员的维度:

1
2
3
4
5
6
7
8
class Account{
private:
static constexpr int period = 30;
double daily_tbl[period];
};

// 不带初始值的定义
constexpr int Account::period;

书中提到了两种情况,说明static constexpr是否需要重复定义:

  • 仅用静态常量表达式替换它的值,如定义数组维度,则不用重复定义(也可以多此一举地定义)
  • 当需要将其传递为一个接收该类型的函数时,则需要重复定义。

为省去麻烦,干脆不论上述何种情况,都在类外重新不带初始值地定义一下该成员。如上述代码最后一行所示。

7.6.4 能使用静态成员,而不能使用普通成员变量的场景

一、静态数据成员可以是不完全类型

有关不完全类型见书P250—类的声明笔记7.3.6 类类型

  • 特别的,静态数据成员的类型可以就是它所属的类型,而非静态成员变量只能声明它所属的类的指针或引用
1
2
3
4
5
6
class Bar{
private:
static Bar mem1; //正确,静态成员可以是不完全类型
Bar *men2; //正确,指针成员可以是不完全类型
Bar mem3; // 错误,数据成员必须是完整类型
};

二、可以使用静态成员作默认实参,而普通成员不行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Screen {
public:
//静态成员作默认实参
Screen& clear(char = bg) ;

private:
static char bg;
};
char Screen::bg = 'a'; // 类外定义static数据成员

Screen& Screen::clear(char s) {
cout << s << endl;
return *this;
}

int main() {
Screen a;
a.clear(); // 使用默认实参bg,终端输出 a

return 0;
}

------标准库------

八 标准库的IO操作

8.1 IO类

8.1.1 IO库类型和头文件

  • 标准库给出的IO类型如下图所示;其中,以“w”开头的版本是为了支持宽字符语言,标准库定义了一组类型和对象来操纵wchar_t类型的数据。

image-20240124215309669

8.1.2 IO对象无拷贝或赋值

  • IO对象不能拷贝或者赋值
    • 不能拷贝io对象,因此我们不能将形参或返回值类型设置为流类型,而常以引用方式传递
    • 读写一个io对象会改变其状态,因此传递和返回的引用不能是const
1
2
3
4
ofstream out1,out2;
out1 = out2; //错误,不能对流对象赋值
ofstream print(ofstream); //错误,不能初始化ofstream参数
out1 = print(out2); //错误,不能拷贝流对象(不能将形参设置为流对象)

8.1.3 IO状态

  • 条件状态表

202401242233996

  • 判断一个流是否处于良好状态的最简单的方法

    1
    2
    3
    while(cin>>word){
    //读取成功的操作
    }
  • IO 库定义了一个与机器无关的 iostate类型,该类型应作为一个位集合来使用。有4个iostate类型的constexpr值(badbit,failbit,eofbit,goodbit),表示特定的位模式,这些值可以与位运算符一起使用来一次性检测或设置多个标志位:

    • badbit 表示系统级错误,如不可恢复的读写错误。通常情况下,一旦 badbit被置位,流就无法再使用了。
    • failbit 被置位于发生可恢复错误后,如期望读取数值却读取一个字符等错误。这种问题通常是可以修正的,流还可以继续使用。
    • 如果到达文件结束位置,eofbitfailbit 都会被置位。
    • goodbit 的值为0,表示流未发生错误。如果badbitfailbiteofbit任一个被置位,则检测流状态的条件会失败。
  • 为检测流的状态,IO库提供了一组函数。其中,good()fail()是确定流的总体状态的方法。下面列出两种使用方法:

    • 状态管理:保存流的状态并恢复

      1
      2
      3
      4
      auto old_state = cin.rdstate();
      cin.clear();
      proess_func(cin);
      cin.setstate(old_state);
    • 将failbit和badbit复位,但保持eofbit不变:

      1
      cin.clear(cin.rdstate() & ~cin.failbit & ~cin.badbit);

      过程如下图所示
      image-20240125104415847

8.1.4 缓冲区

  • 导致缓冲区刷新的原因

    • 程序正常结束,作为main函数return操作的一部分,缓冲刷新被执行。

    • 缓冲区满时,需要刷新缓冲,而后新的数据才能继续写入缓冲区。

    • 我们可以使用操纵符如endl(参见1.2节,第6页)来显式刷新缓冲区。
    • 在每个输出操作之后,我们可以用操纵符unitbuf设置流的内部状态,来清空缓冲区。默认情况下,对cerr 是设置unitbuf的,因此写到cerr的内容都是立即刷新的。
    • 一个输出流可能被关联到另一个流。在这种情况下,当读写被关联的流时,关联到的流的缓冲区会被刷新。例如,默认情况下,cincerr 都关联到 cout。因此,读cin或写cerr都会导致cout的缓冲区被刷新。
  • 刷新缓冲区的几种方式

    1
    2
    3
    4
    5
    6
    7
    8
    cout<<"hi"<<endl;    //附加一个换行符,然后刷新缓冲区
    cout<<"hi"<<flush; //不附加任何字符,刷新缓冲区
    cout<<"hi"<<end; //附加一个空字符,然后刷新缓冲区

    cout<<unitbuf;
    //任何输出都立即刷新,无缓冲
    ......;// 一些操作
    cout<<nounitbuf; // 回到正常的缓冲模式
  • 书中提到的一个注意事项:

    警告:如果程序崩溃,输出缓冲区不会被刷新

    如果程序异常终止,输出缓冲区是不会被刷新的。当一个程序崩溃后,它所输出的数据很可能停留在输出缓冲区中等待打印。

    当调试一个已经崩溃的程序时,需要确认那些你认为已经输出的数据确实已经刷新了。否则,可能将大量时间浪费在追踪代码为什么没有执行上,而实际上代码已经执行了,只是程序崩溃后缓冲区没有被刷新,输出数据被挂起没有打印而已。

    程序员常常在调试添加打印语句。这类语句应该保证一直刷新流。否则,如果程序崩溃,输出可能还留在缓冲区中,从而导致关于程序崩溃位置的错误推断。

8.1.5 关联输入流和输出流

  • 当一个输入流被关联到一个输出流时,任何试图从输入流读取数据的操作都会先刷新关联的输出流。标准库将cin和cout关联,因此cin >>ival将会导致cout的缓冲区被刷新。

  • 利用iostream::tie()函数,既可以将一个istream对象关联到另一个ostream上,也可以将一个ostream关联到另一个ostream上

    1
    2
    3
    4
    5
    6
    7
    8
    cin.tie(&cout); // 标准库中,cin与cout关联

    //old_tie指向旧的关联
    ostream *old_tie = cin.tie(nullptr);//cin不再与任何流关联(即解除关联)

    cin.tie(&cerr); // 将cin与cerr关联,读取cin会导致cerr的刷新

    cin.tie(old_tie); // 恢复之前的关联

8.2 iostream

书本第5页(略)

8.3 fstream(文件流)

8.3.1 fstream的特有操作

  • fstream继承与iostream,除了可以使用iostream的操作外,还有其特有的操作,如下所示:
    image-20240124223730897
    • 在要求使用基类类型对象的地方,我们可以用继承类型的对象来替代。因为fstream(和sstream)继承于iostream,所以接受iostream的引用(或指针)参数的函数,可以用对用的fstream(或sstream)类型来调用。
    • 通过构造函数打开文件的,会自动调用open()close() (自动构造和析构)。通过open()打开文件,则必须在结束是手动书写close()
    • 对一个已经打开的文件流调用open()会失败,并导致failbit被置位。必须先关闭(close())已经关联的文件,再打开新文件。

8.3.2 文件模式

image-20240124223522104

  • 默认模式
    • ifstream:in模式
    • ofstream:out模式
    • fstream:in+out模式
  • 一些注意事项
    • out模式打开文件会将文件清空,除非同时显式指定appin
    • 只有当out也被设定时才可设定trunc
    • 每次调用open()都要(显式或隐式地)重新设置文件模式。
    • 只可以对ofstreamfstream设置out
    • 只可以对ifstreamfstream设置in
    • 只要trunc没被设定,就可以设定app 模式。在 app模式下,即使没有显式指定out 模式,文件也总是以输出方式被打开。
    • atebinary模式可用于任何类型的文件流对象,且可以与其他任何文件模式组合使用。

8.4 sstream(string流)

  • 和fstream同样继承与ostream,既可以使用iostream的操作,也有其特有操作:
    image-20240125163147587

  • strm.str(s)会清空strm中原有的数据,示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    #include <string>
    #include <iostream>
    #include <sstream>
    #include <vector>
    using namespace std;

    int main() {
    ostringstream nums;
    vector<string> nums_vec{"123","456","789","101112"};
    for ( auto num : nums_vec) {
    nums << num << " ";
    }
    cout << nums.str() << endl; //123 456 789 101112

    nums.str(string("888")); // 清空了string流中原有的数据
    cout << nums.str() << endl; //888

    return 0;
    }

8.4.1 istringstream

  • 何时使用?
    • 当我们对整行文本进行处理,并同时需要处理行内的单个单词时。
  • 示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <string>
#include <iostream>
#include <sstream>
#include <vector>
using namespace std;


struct PersonInfo {
string name;
vector<string> phones;
};

int main() {
string line, phone;
vector<PersonInfo> people;

// 将文件中的所有数据存入people:vector<PersonInfo>中
while (getline(cin, line)) {
PersonInfo info;
istringstream record(line);
record >> info.name;
while (record >> phone)
{
info.phones.push_back(phone);
}
people.push_back(info);
}

// 逐人验证号码是否有效
for(const auto &entry : people)
{
ostringstream formated, badNums;

for (const auto &phone :entry.phones)
{
if (!valid(phone)) { // 省略判断电话是否有效的代码
badNums << phone << " ";
}
else{
formated << format(phone) << " "; //省略格式化电话号码的代码
}
}

if (badNums.str().empty())
cout << entry.name << " " << formated.str() << endl;
else
cerr << "input error: " << entry.name
<< " invalid numbers(s) "<< badNums.str() << endl;

}



return 0;
}

8.4.2 ostringstream

  • 何时使用?我们想逐步构造输出的内容,希望放在最后一起打印。(此构造非构造函数的构造,不要过分解读))

  • 接着8.4.1节的代码,示例如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    // 逐人验证号码是否有效
    for(const auto &entry : people)
    {
    ostringstream formated, badNums;

    for (const auto &phone :entry.phones)
    {
    if (!valid(phone)) { // 省略判断电话是否有效的代码
    badNums << phone << " ";
    }
    else{
    formated << format(phone) << " "; //省略格式化电话号码的代码
    /*注意此处 ↑。
    使用标准的输出运算符`<<`向这些对象写入数据,
    但这些“写入”操作实际上转换为 `string`操作,
    分别向` formatted`和`badNums`中的`string `对象添加字符。*/

    }
    }

    if (badNums.str().empty())
    cout << entry.name << " " << formated.str() << endl;
    else
    cerr << "input error: " << entry.name
    << " invalid numbers(s) "<< badNums.str() << endl;

    程序最有趣的部分是对字符串流 formattedbadNums的使用。我们使用标准的输出运算符<<向这些对象写入数据,但这些“写入”操作实际上转换为 string操作,分别向formattedbadNums中的string对象添加字符。

8.4.3 本节完整的示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/* 文件中的数据 */
//morgan 2015552368 8625550123
//drew 9735550130l
//ee 6095550132 2015550175 8005550000

#include <string>
#include <iostream>
#include <sstream>
#include <vector>
using namespace std;


struct PersonInfo {
string name;
vector<string> phones;
};

int main() {
string line, phone;
vector<PersonInfo> people;

// 将文件中的所有数据存入people:vector<PersonInfo>中
while (getline(cin, line)) {
PersonInfo info;
istringstream record(line);
record >> info.name;
while (record >> phone)
{
info.phones.push_back(phone);
}
people.push_back(info);
}

// 逐人验证号码是否有效
for(const auto &entry : people)
{
ostringstream formated, badNums;

for (const auto &phone :entry.phones)
{
if (!valid(phone)) { // 省略判断电话是否有效的代码
badNums << phone << " ";
}
else{
formated << format(phone) << " "; //省略格式化电话号码的代码
}
}

if (badNums.str().empty())
cout << entry.name << " " << formated.str() << endl;
else
cerr << "input error: " << entry.name
<< " invalid numbers(s) "<< badNums.str() << endl;
}

return 0;
}

九 顺序容器

9.1 概述

image-20240126092125836

  • forward_list没有size操作,因为保存或计算其大小就会比手写链表多出额外的开销。对其他容器而言,size保证的是一个快速的常量时间的操作。

9.1.1 选用顺序容器的原则

  • 首选vector
  • 很多小元素,且空间开销重要 —> 不要使用listforward_list
  • 要求随机访问 —> vectordeque
  • 中间插入和删除 —> listforward_list
  • 头尾插入和删除,但不在中间插入和删除 —> deque
  • 如果程序只有在读取输入时才需要在容器中间位置插入元素,随后需要随机访问元素,则
    • 首先,确定是否真的需要在容器中间位置添加元素。当处理输入数据时,通常可以很容易地向 vector 追加数据,然后再调用标准库的sort函数,来重排容器中的元素,从而避免在中间位置添加元素。
    • 如果必须在中间位置插入元素,考虑在输入阶段使用list,一旦输入完成,将list中的内容拷贝到一个 vector 中。
  • 如果程序既需要随机访问元素,又需要在容器中间位置插入元素,则取决于在listforward_list 中访问元素与 vectordeque中插入/删除元素的相对性能,一般来说,应用中占主导地位的操作(执行的访问操作更多还是插入/删除更多)决定了容器类型的选择。在此情况下,对两种容器分别测试应用的性能可能就是必要的了)。

如果你不确定应该使用哪种容器,那么可以在程序中只使用 vectorlist公共的操作:迭代器,而不是使用下标,以避免随机访问。这样,在必要时选择使用vectorlist都很方便。

9.2 容器库概览

对于容器类型的操作,有些适用于所有容器;有些仅针对顺序或关联或无序;有些适用于个别容器。

本节介绍适用于所用容器的操作。

本章剩余部分则聚焦顺序容器的操作。

202401260943509

虽然我们可以在容器中保存几乎任何类型,但某些容器操作对元素类型有其自己的特殊要求。我们可以为不支持特定操作需求的类型定义容器,但这种情况下就只能使用那些没有特殊要求的容器操作了。有如下示例。

顺序容器的 接受容器大小的 构造函数版本,要求其中元素的类型必须能够被默认初始化。

1
2
3
//NoDefault:a Type With No Default Constructor
vector<NoDefault> v1(10,init); // 正确,提供了元素初始化器
vector<NoDefault> v1(10); // 错误,需要元素初始化

9.2.1 迭代器

在书P296~P299,介绍了容器的迭代器。书中首先说,与容器一样,迭代器有着公共的接口,不同容器的迭代器都执行着类似的操作。 并特别指出,forward_list的迭代器不支持--

接着介绍了迭代器的左闭右开区间:[ begin , end ),以及利用该特性对容器中元素进行访问的操作。特别提到需要保证在合法的范围内解引用begin

然后提到了类型成员,特别提到了反向迭代器,与正向迭代器相比,各种操作的含义都发生了颠倒。比如,++会得到上一个元素;rbeginrend会获得尾元素和首元素之前位置的迭代器。笔记10.4.3介绍

提到容器相关的类型别名在书16章介绍。

P298,书9.2.3节begin和end成员 中提到:

  • 迭代器中(begin,cbegin,rbegin,crbegin,end,cend,rend,crend),不以c开头的版本都是重载过的。

  • 可以将一个普通版本的iterator转化为对应的const_iterator,反之则不然。

  • autobeginend结合使用时,获得的选代器类型依赖于容器类型,与我们想要如何使用迭代器毫不相干。但以c开头的版本还是可以获得 const_iterator 的而不管容器的类型是什么。示例如下

    1
    2
    auto it7 = a.begin();    // 仅当a是const时,it7是const_iterator
    auto it8 = a.cbegin(); // it8是const_iterator

    当不需要写访问时,应使用 cbegincend

笔记10.4 再探迭代器将对迭代器的内容进行拓展。

9.2.2 容器定义和初始化

image-20240126122404847

一、拷贝初始化

将一个新容器创建为另一个容器的拷贝的方法有两种:

  • 直接拷贝整个容器

    • 要求两个容器的类型及其元素类型必须匹配

    • 示例

      1
      2
      3
      4
      5
      6
      7
      list<string> authors = {"Milton","Shakespeare","Austen"}; // 列表初始化
      list<string> list2(authors);
      //等价
      list<string> list2 = authors;

      vector<string> list3(authors); //错误,容器的类型不匹配
      list<char *> list4(authors); //错误,元素的类型不匹配
  • 拷贝由 迭代器对 指定的元素范围

    • 不要求容器的类型相同,也不要求元素的类型相同。只要能将要拷贝的元素转换为要初始化的容器的元素类型即可。

      1
      2
      list<const char *> authors = {"Milton","Shakespeare","Austen"};
      forward_list<string> words(authors.begin(), authors.end());
    • array不适用

二、列表初始化

三、顺序容器独有:指定容器大小来初始化

  • 只有顺序容器的构造函数才接受大小参数,关联容器并不支持。

  • 如果元素类型是内置类型或者是具有默认构造函数的类类型,可以只为构造函数提供一个容器大小参数。如果元素类型没有默认构造函数,除了大小参数外,还必须指定一个显式的元素初始值。举个例子,创建一个Test类,并将其默认构造函数删除,编译器报错如下。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    class Test {
    public:
    Test() = delete;
    /*删除了默认构造函数,成员变量a无法执行默认初始化。
    * 当创建10个Test类型的vector的时候,找不到默认构造函数,编译器报错如下:
    * 错误 C2280 “Test::Test(void)”: 尝试引用已删除的函数
    */

    private:
    int a;
    };

    int main() {
    vector<Test> t(10);
    return 0;
    }
  • 不指定大小的容器中,元素可以没有构造函数。如上面提到的拷贝初始化、列表初始化等。

四、array的固定大小

  • 大小也是类型的一部分,必须同时指定元素类型和大小。

  • 和其他容器不同,默认构造的array是非空的。其包含了指定数量的被默认初始化后的元素(因此元素类型一定要有默认初始化)。

  • 值得注意的是,虽然不能对内置数组类型进行拷贝或者对象赋值,但array没有该限制array<>在拷贝赋值的时候,注意元素类型和数量要一样。内置数组拷贝赋值和array容器拷贝赋值对比如下:
    202401261613618

9.2.3 赋值和swap

image-20240126122444107

  • 上表中列出的,与赋值有关的运算符可用于所有容器:

    1
    2
    c1=c2;
    c1={a,b};
  • 由于右边运算对象的大小可能与左边运算对象的大小不同,因此array类型不支持assign,也不允许用花括号值列表进行赋值

    1
    2
    3
    4
    5
    array<int,10> a1 = {0,1,2,3,4,5,6,7,8,9};
    array<int,10> a2 = {0}; // 10个0

    a2 = a1; //正确
    a2 = {0}; //错误,不能用花括号值列表给array赋值
  • assign()

    • 仅顺序容器

    • 允许我们从一个不同但相容的类型赋值,或者从容器的一个子序列赋值。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      #include <iostream>
      #include <list>
      #include <vector>
      using namespace std;

      int main() {
      list<string> names;
      vector<const char*> old_style;

      names = old_style; // 错误,容器类型不匹配

      names.assign(old_style.begin(), old_style.end()); // 自动进行了类型转换

      return 0;
      }
    • 由于其旧元素被替代,因此传递给assign的迭代器不能指向调用assign的容器。以下是ChatGPT给出的例子,人为制造一个错误。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      #include <iostream>
      #include <list>
      using namespace std;

      int main() {
      std::list<int> originalList = {1, 2, 3, 4, 5};

      // 试图在循环中使用迭代器来调用 assign
      for (auto it = originalList.begin(); it != originalList.end(); ++it) {
      // 尝试在循环中调用 assign,传递迭代器指向原始列表
      originalList.assign(it, it);

      // 这里迭代器已经失效,因为 assign 改变了容器的大小
      // 这可能导致未定义的行为或程序崩溃
      std::cout << *it << std::endl; // 试图访问失效的迭代器
      }

      return 0;
      }
  • swap()

    • 交换两个相同类型的容器内容。
    • array外,swap不对任何元素进行拷贝、删除或插入操作,只交换两个容器的内部数据结构,因此速度非常快。而对array则会真正交换它们的元素。
    • string外,指向容器的迭代器、引用和指针,在swap操作后都不会失效,仍指向swap操作前所指向的那些元素。但是这些元素已经属于不同的容器了。
    • 统一使用非成员版本的swap()是个好习惯。

9.2.4 容器大小

  • size():返回元素数目。forward_list不支持size()
  • empty():容器是否为空
  • max_size():返回一个大于或等于该类型容器所能容纳的最大元素数的值。

9.2.5 关系运算

  • 每个容器类型都支持相等运算符(==和!=)
  • 除无序关联容器外都支持关系运算符(> 、>=、 < 、<=)
  • 比较的对象必须有相同的容器类型和相同的元素类型。
  • 用于比较的元素类型必须重载了(定义了)关系运算符
  • 比较规则类似string,如下:
    image-20240126175704307

9.3 顺序容器的特有操作

9.3.1 插入元素

202401261226532

一、push_back

  • arrayforward_list外,每个顺序容器(包括string)都支持push_back

  • push_back是将对象拷贝

    关键概念:容器元素是拷贝
    当我们用一个对象来初始化容器时,或将一个对象插入到容器中时,实际上放入到容器中的是对象值的一个拷贝,而不是对象本身。就像我们将一个对象传递给非引用参数一样,容器中的元素与提供值的对象之间没有任何关联。随后对容器中元素的任何改变都不会影响到原始对象,反之亦然。

二、push_front

  • listforward_listdeque还支持push_front

三、insert

  • vectordequeliststring都支持insert。(注:forward_list为特殊版本,于forward_list专题介绍)

  • 每个insert都接受一个迭代器作为其第一参数,表示将某个(些)额外的元素添加到这个迭代器所指向的元素之。 <—注意,是之前插入

  • 虽然某些容器(如vector)不支持push_front 操作,但它们对于 insert 操作并无类似的限制(插入开始位置)。因此我们可以将元素插入到容器的开始位置,而不必担心容器是否支持push_front :

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    #include <iostream>
    using namespace std;
    #include <vector>
    #include <string>
    int main() {
    vector<string> vec_str = {"a","b","c"};

    // vector不支持push_front,但是可以通过insert插入新的首元素,但是可能很耗时
    vec_str.insert(vec_str.begin(), "hello");

    for(const auto &word : vec_str)
    cout<<word<<" "; // hello a b c
    return 0;
    }

一、插入特定元素

1
c.insert(p,t);    //对容器c,向p位置之前插入元素t

二、插入范围内元素

  • c.insert(p,n,t) :对容器c,向p位置之前插入n个元素t

  • c.insert(p,b,e):对容器c,向p位置之前插入一对迭代器,特别说明,这对迭代器不能指向 调用insert的容器对象(此处为c) 的元素

  • c.insert(p,il):对容器c,向p位置之前插入初始化列表il

本节的三种插入方式,返回指向第一个新加入元素的迭代器。如果插入为空,则将insert的第一个参数返回。

通过使用该返回值,可以在容器中一个特定的位置反复插入元素。示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
using namespace std;
#include <string>
#include <list>
int main() {
list<string> lst;
string word;

auto iter = lst.begin();
while(cin>>word)
//等价于调用push_front
iter = lst.insert(iter,word);
return 0;
}

三、

emplace_frontemplaceemplace_backpush_frontinsertpush_back对应。

push_xxx和insert,将元素类型的对象拷贝到容器中;

emplace_xxx则是将参数传递给元素类型的构造函数,以在容器管理的内存空间中直接构造元素。传递给emplace函数的参数必须元素类型的构造函数相匹配。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <iostream>
using namespace std;
#include <utility>
#include <vector>

class Sales_data{
friend ostream & operator<<(ostream& os, Sales_data sd);
public:
using uint = unsigned ;
Sales_data() = default;
Sales_data(string isbn, uint cnt, double price)
:m_isbn(isbn),m_cnt(cnt),m_price(price){}

private:
string m_isbn;
uint m_cnt = 0;
double m_price = 0;
};

ostream & operator<<(ostream& os, Sales_data sd){
os<<sd.m_isbn<<" "<<sd.m_cnt<<" "<<sd.m_price;
return os;
}

int main() {
vector<Sales_data> vec;
vec.emplace_back("123",25,15.99);//直接在容器的内存空间中创建对象
vec.push_back(Sales_data("456",15,36.2)); // 创建元素的临时对象,并将其拷贝到容器
for(const auto & sales_data : vec)
cout<<sales_data<<endl;
/* 终端输出:
123 25 15.99
456 15 36.2
*/
}

9.3.2 访问元素

image-20240127150051109

  • at和下标运算符只使用于string、vector、deque、array
  • back不适用forward_list
  • back()front()at下标运算符返回的都是引用
  • at相较于下标运算符较安全,越界抛出out_of_range的异常。

因为返回的是引用,可通过访问元素的函数修改容器内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <vector>
#include <iostream>
using namespace std;
int main(){
vector<int> vec{0,1,2,3,4,5,6,7,8,9};

vec.front() = 42;

auto &v1 = vec.back();
v1 = 1024;

//理解auto的规则(笔记2.6.2):
// auto以引用对象的类型作为auto的类型
auto v2 = vec.back(); //不是引用,是一个拷贝
v2 = 0; //未能改变vec中的元素

for(const auto & num : vec)
cout<<num<<" ";
cout<<endl;
return 0;
}

9.3.3 删除元素

image-20240127152516619

  • pop_front()pop_back()返回void,如果还需要弹出的元素值,要在弹出前保存。

  • erase(p)返回p的下一个元素的迭代器

  • erase(b,e)e指向要删除的最后一个元素的下一个位置,结束后b==e

    1
    2
    3
    4
    vector<int> vec{0,1,2,3};
    vec.clear();
    //等价
    vec.erase(vec.begin(),vec.end());

补充:forward_list没有pop_back(),如何删除尾元素?

在C++中,std::forward_list是单向链表,由于单向链表的特性,删除最后一个元素需要一些额外的考虑,因为它没有直接提供像pop_back这样的成员函数,而是需要从头开始遍历链表找到倒数第二个元素,然后将其next指针置为空。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* forward_list删除尾元素
*/
#include <iostream>
#include <forward_list>

int main() {
std::forward_list<int> myList = {1, 2, 3, 4, 5};

// 如果列表为空,无需进行任何操作
if (!myList.empty()) {
auto current = myList.before_begin(); // 迭代器指向头之前的位置
auto last = myList.begin(); // 迭代器指向头

// 遍历找到倒数第二个元素
while (std::next(last) != myList.end()) {
++current;
++last;
}

// 删除最后一个元素
myList.erase_after(current);
}

// 输出删除后的列表
for (const auto& value : myList) {
std::cout << value << " ";
}
std::cout << std::endl;

return 0;
}

std::next是C++标准库中的一个函数模板,用于返回给定迭代器的下一个迭代器。该函数的定义位于头文件 <iterator> 中。下面是它的基本语法:

1
2
template< class InputIt >
InputIt next( InputIt it, typename std::iterator_traits<InputIt>::difference_type n = 1 );
  • it:要获取下一个迭代器的起始迭代器。
  • n:要移动的步数,默认为1。
    std::next在迭代器上执行递增操作,允许你在容器中向前移动指定数量的位置。这对于在算法中进行迭代器操作时非常有用。
    以下是一个简单的示例,演示如何使用std::next
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
#include <iterator>
#include <vector>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5};
auto it = numbers.begin();

// 使用 std::next 将迭代器向前移动两个位置
auto advancedIt = std::next(it, 2);
// 输出结果
std::cout << "Original iterator value: " << *it << std::endl;
std::cout << "Advanced iterator value: " << *advancedIt << std::endl;
return 0;
}

在这个示例中,std::next被用来将迭代器 it 向前移动两个位置,产生一个新的迭代器 advancedIt。这样,你可以在容器中灵活地移动迭代器,而不必手动进行逐步增加。
如果有其他问题或需要更详细的解释,请随时提出。

9.3.4 改变容器大小

image-20240127155838890

  • 如果当前大小大于所要求的大小,容器后部的元素会被删除:如果当前大小小于新大小,会将新元素添加到容器后部:

    1
    2
    3
    4
    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个元素
  • resize 操作接受一个可选的元素值参数,用来初始化添加到容器中的元素。如果调用者未提供此参数,新元素进行值初始化。

  • 如果容器保存的是类类型元素,且 resize 向容器添加新元素,则我们必须提供初始值,或者元素类型必须提供一个默认构造函数

9.3.5 容器操作可能使迭代器失效

一、添加元素

image-20240127161124456

二、删除元素

image-20240127161526765

三、因此,我们需要管理迭代器

当你使用迭代器(或指向容器元素的引用或指针)时,最小化要求迭代器必须保持有效的程序片段是一个好的方法。

由于向迭代器添加元素和从迭代器删除元素的代码可能会使迭代器失效,因此必须保证每次改变容器的操作之后都正确地重新定位迭代器。这个建议对 vector、string和 deque尤为重要。

有两个要求:

  • 添加/删除vector、string 或deque 元素的循环程序必须考虑迭代器、引用和指针可能失效的问题:

    每次循环都更新迭代器、引用或指针

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    /**
    * 添加/删除vector、string 或deque 元素的循环程序必须考虑迭代器、引用和指针可能失效的问题。
    * 程序必须保证每个循环步中都更新迭代器、引用或指针。
    * 如果循环中调用的是insert()或erase(),那么更新迭代器很容易,因为这些操作都返回迭代器,我们可以用来更新:
    */
    #include <vector>
    #include <iostream>
    using namespace std;
    int main(){
    vector<int> vec{0,1,2,3,4,5,6,7,8,9};
    auto it = vec.begin();
    while(it!=vec.end()){
    //注意等号左侧,每步循环都更新了迭代器
    if(*it % 2){
    //奇数
    it = vec.insert(it, *it);
    it += 2;
    // 向前移动迭代器,跳过当前元素及插入到它之前的元素
    // insert()在it的前面插入新元素,并返回指向新插入元素的迭代器,所以+2
    }
    else{
    //偶数
    it = vec.erase(it);
    // 不必向前移动迭代器,erase()使it指向删除元素的下一个位置
    }
    }

    for(const auto &num:vec)
    cout<<num<<" "; // 1 1 3 3 5 5 7 7 9 9
    cout<<endl;
    return 0;
    }
  • 不要保存end返回的迭代器

    当添加/删除vector、string的元素,或在deque中首元素之外的任何位置添加/删除元素,原来的end返回的迭代器总是失效。

    因此,如果在一个循环中插入/删除deque、string、vector中的元素,不要缓存end返回的迭代器。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    /**
    * 想要往每两个数中间插入42
    */
    #include <vector>
    #include <iostream>
    using namespace std;

    int main(){
    vector<int> vec{0,1,2,3,4,5,6,7,8,9};
    auto be = vec.begin();
    // auto en = vec.end(); /*insert后,end迭代器失效会引发死循环*/
    // while(be != en){ /*循环中,不要缓存尾后迭代器*/
    while(be != vec.end()){ /*应该在每次插入操作后重新调用end()*/
    ++be;
    be = vec.insert(be,42);
    ++be;
    }
    for(const auto &num:vec)
    cout<<num<<" "; // 0 42 1 42 2 42 3 42 4 42 5 42 6 42 7 42 8 42 9 42
    cout<<endl;
    return 0;
    }

9.4 vector对象是如何增长的?

image-20240128091159836

  • resize和reserve
    • resize改变容器中元素的数目,而不是容器的容量,如不能减少预留的内存空间。
    • reserve仅影响vector/string预先分配多大的内存,并不改变容器中元素的数目
  • capacity和size
    • size已经保存的元素数目
    • capacity表示在不分配新的内存的前提下,容器最多保存多少元素。

示例

image-20240128092852165

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <vector>
#include <iostream>
using namespace std;
int main(){
vector<int> ivec;
cout<<"size = "<<ivec.size()<<" | "<<"capacity = "<<ivec.capacity()<<endl;
// size = 0 | capacity = 0

for(int i = 0; i < 24; ++i)
ivec.push_back(i);
cout<<"size = "<<ivec.size()<<" | "<<"capacity = "<<ivec.capacity()<<endl;
// size = 24 | capacity = 32
// 存了24个元素,分配了可保存32个元素内存

ivec.reserve(50);
cout<<"size = "<<ivec.size()<<" | "<<"capacity = "<<ivec.capacity()<<endl;
// size = 24 | capacity = 50、
// reserve()将内存括展到了50,ivec内元素个数没变

while(ivec.size()!=ivec.capacity())
ivec.push_back(0); //写满预分配的内存
ivec.push_back(1); // 在添加1位
cout<<"size = "<<ivec.size()<<" | "<<"capacity = "<<ivec.capacity()<<endl;
// size = 51 | capacity = 100
// 超出预分配的内存,ivec的内存两倍括展

ivec.shrink_to_fit();
cout<<"size = "<<ivec.size()<<" | "<<"capacity = "<<ivec.capacity()<<endl;
// size = 51 | capacity = 51
// 请求退还没有用过的内存(不一定采纳)

return 0;
}

9.5 forward_list专题

原文《特殊的forward_list》操作

当添加或删除一个元素时,删除或添加的元素之前的那个元素的后继会发生改变。为了添加或删除一个元素,我们需要访问其前驱,以便改变前驱的链接。但是,forward_list 是单向链表。在一个单向链表中,没有简单的方法来获取一个元素的前驱。

出于这个原因,在一个 forward list 中添加或删除元素的操作是通过改变给定元素之后的元素来完成的。这样,我们总是可以访问到被添加或删除操作所影响的元素。由于这些操作与其他容器上的操作的实现方式不同,forward_list 并未定义insert、emplace和erase,而是定义了名为insert_after、emplace_after和erase_after 的操作(参见表 9.8)。

例如,在我们的例子中,为了删除 elem3,应该用指向elem2的迭代器调用 erase_after。为了支持这些操作,forward_list还定义了before_begin,它返回一个首前迭代器。这个选代器允许我们在链表首元素之前并不存在的元素“之后”添加或删除元素(亦即在链表首元素之前添加删除元素)。

image-20240127220317328

示例:

当向forward_list中添加或删除元素时,我们必须关注两个选代器:一个指向我们要处理的元素,另一个指向其前驱。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* 知识点:
* 当向forward_list中添加或删除元素时,我们必须关注两个选代器:
* 一个指向我们要处理的元素,另一个指向其前驱。
*
* 示例:删除forward_list中的奇数
*/
#include <forward_list>
#include <iostream>
using namespace std;

int main(){
forward_list<int> flist = {0,1,2,3,4,5,6,7,8,9};
auto prev = flist.before_begin();//首前迭代器
auto curr = flist.begin();
while(curr != flist.end()){
if(*curr%2){
//奇数
curr = flist.erase_after(prev);//返回删除的元素的下一个位置的迭代器,并用其更新curr
}
else{
prev = curr;
++curr;
}
}

for(const auto &num:flist)
cout<<num<<" "; //0 2 4 6 8
cout<<endl;
return 0;
}

9.6 string专题

书P320《9.5 额外的string操作》

除了顺序容器的共同操作外,string还提供了一些额外的操作,如所述。

9.6.1 string操作函数汇总

202401281520448

9.6.2 构造string的子序列

除了在笔记3.2.1已经介绍过的构造函数方法,以及与其他顺序容器相同的构造函数外,string还支持另外3个构造函数。

image-20240128155347371

当我们从一个const char*创建string时

  • 通常,指针指向的数组必须以空字符结尾,拷贝操作遇到空字符时停止
  • 如果我们还传递给构造函数一个计数值,数组就不必以空字符结尾。
  • 如果我们未传递计数值且数组也未以空字符结尾,或者给定计数值大于数组大小,则构造函数的行为是未定义的。

因此,const char *数组最好以空字符结尾。

当从string拷贝:

  • 开始位置要小于或等于size
  • 计数值再大,最多拷贝到string结束的位置

子字符串操作:

  • str.substr(pos = 0, n = str.size() -pos);

9.6.2 改变string的其他方法

联想:3.4.4 与旧代码接口—c风格字符串

string类型支持顺序容器的赋值运算符以及assign、insert 和erase操作(表9.4;表9.7)外,还定义了额外的insert和erase版本。

202401281619598

  • insert、erase、assign示例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    #include <string>
    #include <iostream>
    using namespace std;

    void printStr(string &str){
    cout<<str<<" | size = "<<str.size()<<endl;
    }

    int main(){
    string s = "abcdefghij";
    printStr(s); // abcdefghij | size = 10

    string s1 = s;
    s1.insert(s1.size(), 5, '!'); //末尾插入5个感叹号
    printStr(s1); // abcdefghij!!!!! | size = 15

    string s2 = s;
    s2.erase(s.size()-5/*, 5*/); // 从s的倒数5个位置开始,删除最后5个字符
    printStr(s2); // abcde | size = 5

    string s3 = s;
    const char *cp = "stately, plump buck";
    s3.assign(cp,2); // 用cp的前2个字符覆盖整个s
    printStr(s3); // st | size = 2

    string s4 = s;
    s4.insert(s.size(), cp+7);
    // 从cp向后移动7位所指向的元素开始,到cp结束之间的所有字符,插入到s的末尾(s.size())
    printStr(s4); // abcdefghij, plump buck | size = 22

    string s5 = s;
    string s5_ = ",xyz";
    s5.insert(0,s5_); //在s5的位置0处插入s5_
    printStr(s5); // ,xyzabcdefghij | size = 14

    string s6 = s;
    s6.insert(0,s5_,0,s5_.size());
    // 在s6[0]之前插入s5_中s5_[0]开始的s5_.size()个字符
    printStr(s6); //,xyzabcdefghij | size = 14
    }
  • append、replace示例

    • append(str):末尾追加str
    • replace(开始位置pos,删除几个元素n,在当前位置添加字符串str) = erase+insert;删除的字符数n可以不等于添加的字符数量str
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    #include <string>
    #include <iostream>
    using namespace std;

    void printStr(string &str){
    cout<<str<<" | size = "<<str.size()<<endl;
    }

    int main(){
    string s{"c++ primer"};
    s.append(" 4th Ed.");
    printStr(s); // c++ primer 4th Ed. | size = 18

    s.replace(11,3,"5th");
    printStr(s); //c++ primer 5th Ed. | size = 18

    //replace时,删除的字符数和添加的字符数可以不相等
    s.replace(11,3,"Fifth");// 删除3个字符,但增加5个字符
    printStr(s); // c++ primer Fifth Ed. | size = 20
    }

9.6.3 string搜索操作

202401282149718

  • string类提供了6个搜索函数,每个函数都有4个重载版本。

  • 搜索操作返回string::size_type值,表示匹配发生位置的下标。若搜索失败,返回string::npos的static成员。标准库将npos定义为const string::size_type类型,并初始化为-1。又由于npos是unsigned类型,此初始值意味着npos等于任何string最大的可能大小。

  • 搜索操作大小写敏感

  • str.find_first_of(args):返回str中第一个出现在args中的元素的下标

    1
    2
    3
    4
    5
    string str{"pi=3.14"};
    string nums{"+-.0123456789"};
    auto pos = str.find_first_of(nums);
    //str中第一个出现在nums的元素的下标(即“pi=3.14”的‘3’的下标)
    cout<<pos<<endl; // 3

    find_last_of、find_first_not_of、find_last_not_of

  • 逆向搜索:有从左向右搜索,也有从右向左搜索的函数

  • 一个常见的设计模式:
    通过指定 从哪里搜索的可选参数 在字符串中循环地搜索 子字符串出现的所有位置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    string::size_type pos=0;
    string name{"bananabananabanana"};
    string nums{"hb"};
    while((pos=name.find_first_of(nums,pos))!=string::npos){
    cout<<"found number at idx: "<<pos
    <<"element is "<< name[pos]<<endl;
    ++pos; //<-- 必须,否则死循环
    }
    /*
    found number at idx: 0element is b
    found number at idx: 6element is b
    found number at idx: 12element is b
    */

9.6.4 compare

见笔记9.6.1汇总 表9.15

9.6.5 数值转换

书P327~P328

202401281801235

  • 要转换为数值的string中,第一个空白字符必须是数值可能出现的字符:

    1
    2
    3
    string str{"pi = 3.14"};
    double val = stod(str.substr(str.find_first_of("+-.0123456789")));
    cout<<val<<endl; // 3.14
    • 如果string不能转换为数值,表9.6中函数抛出invalid_argument异常
    • 如果转换得到的数值无法用任何类型表示,抛出out_of_range
  • 查找原则

    string 参数中第一个非空白符必须是符号(+ 或 -)或数字。它可以以0x 或0X开头来表示十六进制数。

    对那些将字符串转换为浮点值的函数,string 参数也可以以小数点 (.)开头,并可以包含 e 或 E 来表示指数部分。

    对于那些将字符串转换为整型值的函数,根据基数不同,string 参数可以包含字母字符,对应大于数字9的数。

9.7 适配器

(adaptor)

image-20240128103539423

9.7.1 什么是适配器

9.7.2 定义一个适配器

一、每个适配器都定义两个构造函数

  • A a;默认构造函数创建一个空对象

  • A a(c)接受一个容器c的构造函数 ,拷贝容器c的元素来初始化适配器

    1
    2
    3
    4
    5
    6
    7
    8
    int main(){
    deque<int> deq;
    stack<int> stk(deq); // 拷贝deq的元素到stk,以初始化stk
    // 其实隐藏了默认容器类型,等价于
    stack<int,deque<int>> stk(deq);

    return 0;
    }

二、重载默认容器类型

默认容器类型:

  • stack和queue基于deque实现
  • priority_queue基于vector实现

我们可以在创建一个适配器时,将一个命名的顺序容器作为第二个 类型 参数,来重载默认容器类型。

1
2
3
4
5
6
7
8
9
10
11
12
using namespace std;
int main(){
vector<int> vec;

// 在vector的基础上实现空栈
stack<int,vector<int>> stk1;

// 在vector的基础上实现,初始化时保存vec的拷贝
stack<int,vector<int>> stk2(vec);

return 0;
}

三、重载默认容器类型的限制

stack:可用于除了array和forward_list之外的任何容器类型(deque、list、vector)

queue:只能用于list和deque之上,不能用于vector

priority_queue只能用于vector和deque,不能用于list

9.7.3 栈适配器

参考资料

stack定义在stack头文件中

image-20240128110200371

用法示例(书P330):

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stack>
using namespace std;
int main(){
stack<int> stk;
for(size_t i = 0; i < 10; ++i)
stk.push(static_cast<int>(i));
while (!stk.empty())
{
int val = stk.top();
stk.pop();
}
return 0;
}

虽然每个适配器都是基于底层容器的操作定义自己的操作,但我们只可以使用适配器的操作,而不可使用底层容器的操作。如,虽然stack基于deque实现,stack.push()基于deque.push_back(),但我们不能在一个stack上调用push_back()

9.7.4 队列适配器

参考资料

queue和priority_queue定义在queue头文件中

202401281122221

queue(FIFO)

priority_queue:

priority_queue 允许我们为队列中的元素建立优先级。新加入的元素会排在所有优先级比它低的已有元素之前。

饭店按照客人预定时间而不是到来时间的早晚来为他们安排座位,就是一个优先队列的例子。

默认情况下,标准库在元素类型上使用<运算符来确定相对优先级。


对于表9.19有个疑问:表中第二行说,queue也可以用list或vector实现,是不是有有误?
在《笔记9.6.2三、重载默认容器类型的限制(书P329最下面的一大段话)》中提到:queue:只能用于list和deque之上,不能用于vector。
两者是不是冲突了?

不知道两句换描述的事物是不是不一样,但照我理解,两句话在说一个东西。

实际测试:

1.正确执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <queue>
#include <deque>
#include <iostream>
using namespace std;
int main(){
deque<int> deq{1,2,3,4};
queue<int,deque<int>> que1(deq);

while(!que1.empty())
{
cout<<que1.front()<<endl;
que1.pop();
}
}

2.正常编译:

1
2
3
4
5
6
7
#include <queue>
#include <vector>
using namespace std;
int main(){
vector<int> vec{1,2,3,4};
queue<int,vector<int>> que1(vec);
}

3.编译器报错:

In template: no member named ‘pop_front’ in ‘std::vector

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <vector>
#include <queue>
#include <iostream>
using namespace std;
int main(){
vector<int> vec{1,2,3,4};
queue<int,vector<int>> que1(vec);

while(!que1.empty())
{
cout<<que1.front()<<endl;
que1.pop(); // <--在此处报错
}
}

我认为,确实queue能用vector构造,但是当碰到处理(增删)首元素的情况,就会出错。所以不要用vector构造queue。


9.8 特例汇总

9.8.1 forward_list<>

  • 没有size操作
  • 不支持反向迭代器
  • 不支持--
  • 不支持适配器(因为所有适配器都要求容器有添加、删除和访问尾元素的能力)

单向链表对尾元素的处理很耗时,所以一般不支持xxx_back()操作,而是xxx_after()

  • 不支持push_backemplace_back,有自己专有的insertemplace
  • 不支持back()获取尾元素的引用。
  • 不支持pop_back()
  • forward_list 并未定义insert、emplace和erase,而是定义了名为insert_afteremplace_aftererase_after的操作(见forward_list专题)

9.8.2 string

  • 不支持C c(n)构造
  • swap会导致string的迭代器、指针和引用失效。而其他容器不会。

9.8.3 array<>

一、构造

  • C c默认构造的时候,c中的元素也会通过默认构造初始化。如果c是其他容器时,则为空。
  • 不支持通过C c(n,t)C c(n)构造容器。(n个值为t的元素)

二、拷贝和赋值

  • 不支持c = {a,b,c,...}
  • 不支持C c(b,e):b和e为迭代器的范围拷贝构造(见笔记9.2.2 一、拷贝初始化)
  • C c1=c2两者必须是相同大小

三、其他

  • 不支持添加/插入/删除元素的函数

    • 不支持笔记9.3.1中表9.5(如push_back
    • 不支持笔记9.3.3的所有操作
  • swap会真正交换array的元素值,而其他容器不会。

  • 不支持resize()

  • 不支持适配器(因为所有适配器都要求容器有添加和删除元素的能力)

十 泛型算法

10.1 概述

关键概念:算法永远不会执行容器的操作(算法只所用于迭代器)

  • 泛型算法本身不会执行容器的操作,它们只会运行于迭代器之上,执行迭代器的操作。泛型算法运行于迭代器之上而不会执行容器操作的特性带来了一个令人惊讶但非常必要的编程假定:算法永远不会改变底层容器的大小。算法可能改变容器中保存的元素的值,也可能在容器内移动元素,但永远不会直接添加或删除元素。

  • 如我们将在书本10.4.1节(第358页)所看到的,标准库定义了一类特殊的迭代器,称为插入器(inserter)。与普通迭代器只能遍历所绑定的容器相比,插入器能做更多的事情。当给这类迭代器赋值时,它们会在底层的容器上执行插入操作。因此,当一个算法操作一个这样的迭代器时,迭代器可以完成向容器添加元素的效果,但算法自身永远不会做这样的操作。

  • 大多数算法定义在algorithm中,numeric也定义了一组数值泛型算法。

  • 一般情况下,这些算法并不直接操作容器,而是遍历由两个迭代器指定的一个元素范围来进行操作。

  • 迭代器令算法不依赖于容器,但是算法依赖于容器的元素类型。(因为算法要对元素进行比较等操作)

10.2 初识泛型算法

10.2.1 只读算法

  • find_<algorithm>
    fund(开始迭代器(指针),结束迭代器(指针),val)

    • 作用:在指定范围内[开始迭代器,结束迭代器)查找val值,找到了就返回 第一个 等于val的 元素的 迭代器,否则返回结束迭代器。
  • count_<algorithm>

    • 作用:返回给定值在次序中出现的次数
  • accumulate_<numeric>
    sum = accumulate(vec.cbegin(),vec.cend(),0)

    • 作用:将范围内所有元素加到第三个参数上,返回最终的加法之和。

    • 要求第三个参数重载过+

    • 第三个参数决定了函数中使用哪个类型重载的加法运算符,以及返回值类型。

      1
      2
      string sum = accumulate(str.cbegin(),str.cend(),string("")); //正确
      string sum = accumulate(str.cbegin(),str.cend(),""); //错误,const char*没有重载过‘+’运算符
  • equal_<algorithm>
    equal(roster1.cbegin(),roster1.cend(),roster2.cbegin());

    • 作用:用于确定两个序列是否保存相同的值。第三个参数是第二个序列的首元素的迭代器
    • 基于假设:它假定第二序列至少与第一序列一样长
      image-20240129112533625
      image-20240129115208359
    • 由于equal利用迭代器完成操作,因此,我们可以通过调用equal来比较两个不同类型的容器中的元素,而且,元素类型也不必一样,只要我们能用==来比较两个元素类型即可

image-20240129112605375

10.2.2 写容器元素算法

  • fill
    fill(vec.begin(),vec.end(),val);

    • 作用:将范围内的每个元素重置为第三个参数
  • fill_n
    fill_n(起始位置迭代器,n,val)

    • 作用:从其实位置的迭代器开始,将n和元素替换为val

    • 注意:不应在一个空容器上调用fill_n,或类似写元素的算法。示例如下:

      1
      2
      3
      4
      //灾难错误示例
      vector<int> vec; //空vector
      //修改了10个不存在的向量,引发未定义的结果
      fill_n(vec.begin(), 10, 0);
  • back_inserter_<iterator>:“插入” 迭代器

    • 作用:接受一个指向容器的引用,返回一个与该容器绑定插入迭代器。

    • 我们用此迭代器赋值时,赋值运算符会调用push_back

    • 常常使用back_inserter创建一个插入迭代器,作为算法的目的位置使用

    • 示例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      #include <string>
      #include <iostream>
      #include <vector>
      #include <iterator>
      using namespace std;

      /** 有bug
      * 重载<<,流式输出容器中的元素
      * @tparam T
      * @param os
      * @param ctor
      * @return
      */
      template<typename T>
      ostream& operator<<(ostream &os,T &ctor){
      for(const auto &ele:ctor){
      os<<ele;
      }
      return os;
      }

      int main(){
      /*例1*/
      vector<int> vec; //空容器
      auto iter = back_inserter(vec); // 插入迭代器
      *iter = 24; //赋值运算符调用push_back
      cout<<vec<<endl;

      /*例2*/
      vec.clear(); //清空容器
      //常常使用back_inserter创建一个插入迭代器,作为算法的目的位置使用
      fill_n(back_inserter(vec),10,1);
      cout<<vec<<endl;
      return 0;
      }
  • copy
    copy(源起始迭代器,源终止迭代器,目的序列的起始位置)

    • 传递给copy的目的序列至少要包含与输入序列一样多的元素

    • 返回目的位置迭代器增值后的值。下例中为a2尾后位置:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      #include <string>
      #include <iostream>
      #include <vector>
      #include <iterator>
      using namespace std;

      /** 有bug
      * 流式输出容器
      * @tparam T
      * @param os
      * @param ctor
      * @return
      */
      template<typename T>
      ostream& operator<<(ostream &os,T &ctor){
      for(const auto &ele:ctor){
      os<<ele;
      }
      return os;
      }

      int main(){
      int a1[] = {1,2,3,4,5,6,7,8,9};
      int a2[sizeof(a1)/sizeof(*a1)];
      auto re = copy(begin(a1), end(a1),a2); // re指向尾后
      cout<<a2<<endl; // 123456789
      cout<<*(re-1)<<endl; // 9
      return 0;
      }
  • replace_copy
    replace_copy(ilst.cbegin(), ilst.end(), back_inserter(ivec), 0, 42);
    将ilst(值不会改变)中的所有元素拷贝到ivec(可以是空列表)之后,并将ivec中的0替换为24

    • 对比replace是将原来的序列范围内的值替换

10.2.3 重排容器元素算法

  • sort:sort(序列开始位置迭代器,结束位置迭代器)
  • unique:返回不重复区域之后一个位置的迭代器 = unique(序列开始位置迭代器,结束位置迭代器)

书中的例子:排序一个由单词组成的vector,并删除重复的单词

202401291602660

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <string>
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

template<typename T>
void printCtor(T &ctor){
for(const auto &ele:ctor){
cout<<ele<<" ";
}
}

int main(){
vector<string> vec{"the","quick","red","fox","jumps","over","the","slow","red","turtle"};
printCtor(vec);cout<<endl;
// 输出:the quick red fox jumps over the slow red turtle

/*sort*/
sort(vec.begin(), vec.end());
printCtor(vec);cout<<endl;
// 输出:fox jumps over quick red red slow the the turtle


/*unique 将重复项移动到末尾*/
// iter指向不重复区域的下一个位置
auto iter = unique(vec.begin(), vec.end());
vec.erase(iter,vec.end());
printCtor(vec);cout<<endl;
// 输出:fox jumps over quick red slow the turtle

return 0;
}

10.3 定制操作

10.3.1 谓词

一、通过“谓词”改变算法的默认行为

“谓词”是一种可调用的表达式,其返回结果是一个能用作条件的值。

  • 一元谓词,只接受一个单一参数的谓词
  • 二元谓词,接受两个参数的谓词

接受谓词参数的算法对输入序列中的元素调用谓词,元素类型必须能转化为谓词的参数类型。

根据算法接受一元谓词还是二元谓词,我们传递给算法的谓词必须严格接受一个或两个参数。

二、举例

接受一个二元谓词参数的sort(),用谓词替换<来比较元素

1
2
3
4
5
6
7
bool isShorter(const string s1, const string s2){
return s1.size()<s2.size();
}

sort(words.begin(), words.end(), isShorter);

stable_sort(words.begin(), words.end(), isShorter);//可保持等长元素间的字典顺序

三、补充

find_if()算法接受一对迭代器,表示一个范围。与find()不同的是,find_if()的第三个参数是一个谓词,find_if算法对输入序列中的每个元素调用给定的这个谓词,返回第一个使谓词返回非0值的元素,如果不存在这样的元素,则返回尾迭代器。

1
2
size_t sz = 5;
auto wc = find_if(word.begin(), word.end(), [sz](const string &s){return s.size() >= sz ;});

for_each()接受一个可调用的对象,并对输入序列中的每个元素调用此对象。

1
for_each(word.begin(), word.end(), [](const string &s){cout<<s<<" ";});

10.3.2 lambda

(很重要,放在二级标题)

一、书写格式

1
[capture list](parameter list) -> return type { function body }

我们可以忽略参数列表和返回类型,但必须永远包含捕获列表和函数体

1
2
auto f = []{ return 42; }; // 相当于空参数列表;自动推导返回类型。
cout<<f()<<endl;

二、参数

lambda不能有默认参数 —-> 一个lambda调用的实参数目永远与形参数目相等。

三、捕获列表

一、谁需要被捕获?

捕获列表只适用于局部非static变量,可以直接使用局部static变量和在它所在函数之外声明的名字。

1
2
3
4
5
6
7
8
9
10
int a = 1;
int main() {
int b = 2; // 只有局部非static变量需要捕获
static int c = 3;
auto f = [b]() {cout << a << " " << b << " " << c; };
// f的类型 main::__l2::<lambda_1>
f(); // 1 2 3

return 0;
}
二、几种捕获方式

1.值捕获

  • 采用值捕获的前提是变量可以拷贝

  • 被捕获的变量的值是在lambda创建时拷贝,而不是调用时拷贝。(与函数参数不同)

  • 又由于 “ 被捕获的变量的值是在lambda创建时拷贝”,因此,随后对其修改不会影响到lambda内对应的值

    1
    2
    3
    4
    5
    6
    void func1(){
    size_t v1 = 42;
    auto f = [v1]{return v1;};// <--值捕获
    v1=0;
    auto j = f();// j = 42;
    }

2.引用捕获

下例为引用捕获示例与上述值捕获示例的对比

1
2
3
4
5
6
void func1(){
size_t v1 = 42;
auto f = [&v1]{return v1;}; // <--引用捕获
v1=0;
auto j = f();// j = 0;
}

lambda捕获的都是函数的局部变量,函数结束后,捕获的引用指向的局部变量已经消失。

  • 必须确保被引用的对象在lambda执行的时候是存在的。

  • 函数不能返回一个包含引用捕获的lambda(因为局部变量已消失,和函数不能返回局部变量的引用/指针是一个道理)

image-20240202113652567

3.隐式捕获

  • 当我们混合使用隐式捕获和显式捕获时,
    • 必须以隐式捕获开头(原文:捕获列表的第一个元素必须是一个&=,以指定默认捕获方式)。
    • 显式捕获的变量必须使用与隐式捕获不同的方式。
三、捕获列表的书写方式汇总

image-20240202105221721

四、可变lambda(mutable)
  • 默认情况下,对于一个值被拷贝的变量,lambda 不会改变其值。但是,如果我们希望能改变个被捕获的变量的值,就必须在参数列表首加上关键字 mutable。
  • 一个引用捕获的变量是否可以修改,依赖于此引用指向的是一个const还是一个非const

  • 对于局部static变量和在它所在函数之外声明,不用mutable也可以在lambda中修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*如果我们希望能改变个被捕获的变量的值,就必须在参数列表首加上关键字 mutable。*/
int v1 = 42;
auto f = [v1]() mutable {return ++v1; };
// auto f = [v1]() { return ++v1; }; //v1报错:表达式必须是可修改的左值(v1只读)
cout << v1 << " " << f() << endl; // 42 43


/*一个引用捕获的变量是否可以修改,依赖于此引用指向的是一个const还是一个非const*/
auto f1 = [&v1]() {return ++v1; };
cout << v1 << " " << f1() << endl; //43 43
const int v2 = 1;
//auto f2 = [&v2] {return ++v2; }; //v2报错:表达式必须是可修改的左值


/*对于局部static变量和在它所在函数之外声明,不用mutable也可以在lambda中修改*/
static int v3 = 1;
auto f3 = [] {return ++v3; };
cout << v3 << " " << f3(); // 2 2

四、指定lambda的返回类型

  • 默认情况下,如果一个lambda体 除了单一return外 还包含了其他语句,则编译器假定lambda返回void。如果与本意不符,需要显式指明返回类型。

  • 示例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    //lambda体内只有一条return语句,编译器自动推导返回类型
    transform(vi.begin(), vi.end(), vi,begin(),
    [](int i){ return i < 0 ? -i : i; });

    //编译错误:
    //除了单一return外 还包含了其他语句,编译器推断为void,实际却返回int
    transform(vi.begin(), vi.end(), vi,begin(),[](int i){
    if(i < 0)
    return -i;
    else
    return i; });

    //正确写法:显式指定返回类型
    transform(vi.begin(), vi.end(), vi,begin(),[](int i)->int{ // 尾置返回类型
    if(i < 0)
    return -i;
    else
    return i; });
  • 补充:transform()
    使用标准库 transform 算法和一个 lambda来将一个序列中的每个负数替换为其绝对值:

    1
    2
    transform(vi.begin(), vi.end(), vi.begin(),
    [](int i) { return i < 0 ?-i :i; });

    函数transform接受三个迭代器和一个可调用对象。前两个迭代器表示输入序列,第三个迭代器表示目的位置。算法对输入序列中每个元素调用可调用对象,并将结果写到目的位置。

五、参数绑定

一、引入

之前我们写过的代码

1
2
size_t sz = 5;
auto wc = find_if(word.begin(), word.end(), [sz](const string &s){return s.size() >= sz ;});

如果好多地方使用同样的操作,或者操作需要很多语句才能完成,使用lambda不方便,需要使用函数。但是如何用接受两个参数的函数替换一元谓词?如

1
2
3
4
5
bool check_sz(const string &s, string::size_type sz){ 
return s.size() > sz ;
}

auto wc = find_if(word.begin(), word.end(), /*check_sz*/); // 如何用接受两个参数的函数替换一元谓词?

二、bind

使用在库functional中的bind()。可以将其看做函数适配器(类比容器适配器),接受一个可调用对象,生成一个新的可调用对象来“适应”原本对象的参数列表。一般形式为:

1
auto newCallable = bind(callable, arg_list);

调用newCallable时,newCallable会调用函数callable,并向callable传递arg_list中的参数。

其中,arg_list中可能会有std::placeholders::_n,为占位符。表示调用newCallable时,传入的参数应该填入callable形参列表的第n位。

举例,对于一中的例子,利用bind改写:

1
2
3
4
5
6
7
bool check_sz(const string &s, string::size_type sz){ 
return s.size() > sz ;
}

auto check6 = bind(check_sz, std::placeholders::_1, 6);

auto wc = find_if(word.begin(), word.end(), check6);

举例2:

1
2
3
4
5
6
7
8
9
10
using namesapce std::placeholders;

bool check_sz(const string &s, string::size_type sz){
return s.size() > sz ;
}

auto check6 = bind(check_sz, _1, 6);

string s = "hello";
bool b1 = check6(s); // 相当于 check_sz(s,6);

三、bind的参数

bind对参数的作用:

  • 绑定给定可调用对象中的参数(上文所述)

  • 重新安排参数的顺序。示例如下:

    1
    2
    3
    auto g = bind(func, a, b, _2, c, _1);

    g(X,Y);//func(a,b,Y,c,X);

四、绑定引用参数

我们希望传递一个引用给bind,而不是拷贝,用ref()cref()

1
2
#include <functional>
for_each(words.begin(), words.end(), bind(print, ref(os), _1, ' '));

10.4 再探迭代器

笔记9.2.1迭代器基础

补充额外的迭代器:

  • 插入迭代器(insert iterator):迭代器被绑定到一个容器上,可以用来向容器插入元素
  • 流迭代器(stream iterator):迭代器被绑定到输入/输出流上,用来遍历所关联的io
  • 反向迭代器(reverse iterator):迭代器向反方向移动(++/—方向相反)。除了forward_list外标准库容器都有反向迭代器
  • 移动迭代器(move iterator):这些迭代器不是拷贝其中的元素,而是移动它们。

10.4.1 插入迭代器

image-20240203103605570

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
vector<int> nums{ 0,1,2,3,4,5,6,7,8,9 };
auto it = inserter(nums,++nums.begin()); // inserter(容器,迭代器);插入迭代器之前的位置
it = 12; //等价*it或++it或it++ = 12,因为这三个存在但什么都不做,只返回it

for (const auto& num : nums) {
cout << num << " ";//0 12 1 2 3 4 5 6 7 8 9
}

//等价
vector<int> nums{ 0,1,2,3,4,5,6,7,8,9 };
int ins_num = 12;
auto iter = nums.insert(++nums.begin(), ins_num);
++iter; // <--特别注意这里,iter又指回了原来的地方

for (const auto& num : nums) {
cout << num << " ";//0 12 1 2 3 4 5 6 7 8 9
}

一个值得注意的地方:

1
2
3
4
5
list<int> lst{1,2,3,4};
list<int> lst2, lst3;

copy(lst.begin(), lst.end(), front_inserter(lst2));//lst2=4 3 2 1
copy(lst.begin(), lst.end(), inserter(lst3,lst3.begin()));//lst2=1 2 3 4

原理如图所示:

image-20240203114013429

10.4.2 流迭代器

原文:iostream迭代器

虽然iostream类型不是容器,但标准库定义了可以用于这些IO类型对象的选代器(参见8.1 节,第278页)。

istream_iterator (参见表10.3)读取输入流,ostream_iterator(参见表10.4节,第361页)向一个输出流写数据。

这些选代器将它们对应的流当作一个特定类型的元素序列来处理。通过使用流迭代器,我们可以用泛型算法从流对象读取数据以及向其写入数据。

一、istream_iterator

image-20240203152334297

用法示例:用一个istream_iterator从标准输入读取数据,存入一个vector的例子

1
2
3
4
5
6
7
8
9
10
11
/* 写法1 */
vector<int> vec;
istream_iterator<int> in_iter(cin) /* 从cin读取int类型的数据 */, eof/*istream尾后迭代器*/;
while (in_iter != eof)
{
vec.push_back(*in_iter++);
}

/*写法2,等价于写法1*/
istream_iterator<int> in_iter(cin), eof;
vector<int> vec(in_iter, eof); // 从迭代器范围构造vec(用一对表示元素范围的迭代器构造vec)

要求:

  • 必须指定迭代器将要读写的对象类型
  • 该对象类型定义了>>来读取流
  • 默认初始化迭代器,即创建了istream流的尾后迭代器

特点:允许使用懒惰求值。 <— 没怎么看懂

当我们将一个istream_iterator 绑定到一个流时,标准库并不保证迭代器立即从流读取数据。具体实现可以推迟从流中读取数据,直到我们使用迭代器时才真正读取

标准库中的实现所保证的是,在我们第一次解引用迭代器之前,从流中读取数据的操作已经完成了。对于大多数程序来说,立即读取还是推迟读取没什么差别。但是,如果我们创建了一个istream_iterator,没有使用就销毁了,或者我们正在从两个不同的对象同步读取同一个流,那么何时读取可能就很重要了。

应用:用一对istream_iterator来调用accumulate

1
2
istream_iterator<int> in(cin), eof;
cout<<accumulate(in, eof, 0)<<endl;

二、ostream_iterator

image-20240203152310391

用法示例:用ostream_iterator来输出 值的序列

1
2
3
4
5
6
7
8
9
10
/* 写法1 */
vector<int> vec{0,1,2,3,4,5,6};
ostream_iterator<int> out_iter(cout, " ");
for (const auto& n : vec)
*out_iter++ = n; // 0 1 2 3 4 5 6
// 等价
// out_iter = n; // 不推荐,因为上面的写法更易阅读

/* 写法2 */
copy(vec.begin(), vec.end(), out_iter);

要求:

  • 要输出的类型 定义了<<
  • 第二个可选参数表示:在输出的每个元素后都会打印该字符(必须是c风格字符串——字符串字面常量或以空字符结尾的字符数组的指针)
  • ostream_iterator必须绑定到一个指定的流,不允许空的或表示尾后位置的ostream_iterator

应用:(书P362)

1

10.4.3 反向迭代器

返回笔记9.2.1

  • rbegin(),rend(), crbegin(),crend()
  • forward_list和流迭代器不能创建反向迭代器
  • reverse_iterator的base()将反向迭代器转换为普通迭代器(在容器中正向移动)

image-20240203211300327

应用:

1
2
3
4
5
6
7
8
9
10
11
12
/*例1*/
sort(vec.begin(), vec.end()); // 顺序
sort(vec.rbegin(), vec.rend()); // 逆序

/*例2*/
//first,middle,last
auto comma = find(line.cbegin(), line.cend(), ',');
cout<<string(line.cbegin(), comma);//first

auto rcomma = find(line.crbegin(), line.crend(), ',');
cout<<string(line.crbegin(), rcomma);//tsal
cout<<string(rcomma.base(),line.cend());//last

10.5 泛型算法结构

10.5.1 五类迭代器

任何算法最基本的特性是 它要求其迭代器提供哪些操作。

类似容器,迭代器也定义了一组公共操作。迭代器按其提供的操作分类,这些分类形成了一种层次,除了输出迭代器外,一个高层类别的迭代器支持低层类别迭代器的所有操作。

C++标准指明了泛型和数值算法的每个迭代器参数的最小类别(至少应该达到的类别)。例如,find 算法在个序列上进行一遍扫描,对元素进行只读操作,因此至少需要输入迭代器。对每个迭代器参数来说,其能力必须与规定的最小类别至少相当。向算法传递一个能力更差的迭代器会产生错误。

迭代器提供的操作可以划分为5类。每个算法都会对 它的每个迭代器参数 指明 需要提供哪类迭代器。

image-20240203215001891

迭代器类别简述:

202402042051427

10.5.2 算法形参模式

image-20240204210849325

  • dest
    • 表示算法可以写入的目的位置迭代器
    • 使用dest时,算法假定:按其需要写入的数据,不管写入多少元素都是安全的
    • 如果dest是一个直接指向容器的迭代器,算法将输出数据写到容器中已存在的元素内
  • beg2
    • 接受单独beg2的算法 假定从beg2开始的序列 至少 与beg和end所表示的范围 一样大

10.5.3 算法命名规范

  • 一些算法使用重载形式传递一个谓词

  • _if版本的算法

    • 接受一个元素值的算法通常有一个不同名的版本,接受一个谓词以替代元素值,这类接受谓词参数的算法都附加_if。

    • 示例

      1
      2
      find(beg,end,val);//查找范围内val第一次出现的位置
      find_if(beg,end,pred);//查找第一个令pred为真的元素的位置
  • _copy版本的算法

    • 默认情况下,重排元素的算法将重排后的元素写回给定的输入序列中。这些算法还提供另一个版本,将元素写到一个指定的输出目的位置。如我们所见,写到额外目的空间的算法都在名字后面附加一个_copy

    • 示例

      1
      2
      reverse(begin,end); // 反向输入范围中的序列
      reverse_copy(begin,end,dest); // 将元素按逆序拷贝到dest
  • 一些算法同时提供_if和_copy,如remove_ifremove_copy_if

10.6 特定容器算法

  • 对于listforward_list,应该 优先使用成员函数版本的算法 而不是通用算法
    202402042147204
  • 链表类型还定义了splice成员(链表特有,splicesplice_after
    image-20240204214806386
  • 链表特有的操作会改变容器

    多数链表特有的算法都与其通用版本很相似,但不完全相同。链表特有版本与通用版本间的一个至关重要的区别是链表版本 会 改变底层的容器。例如,remove 的链表版本会删除指定的元素。unique 的链表版本会删除第二个和后继的重复元素。

    类似的,merge和splice 会销毁其参数。例如,通用版本的merge 将合并的序列写到一个给定的目的迭代器:两个输入序列是不变的。而链表版本的 merge 函数会销毁给定的链表——元素从参数指定的链表中删除,被合并到调用 merge 的链表对象中。在merge 之后,来自两个链表中的元素仍然存在,但它们都已在同一个链表中。

十一 关联容器

image-20240205071603483

  • map & set

    • map中的元素是键值对
    • set每个元素只包含一个关键字
  • 8个容器见的不同体现在3个维度上

    • map or set

    • 要求不重复关键字 or 允许重复关键字 multi

    • 顺序存储 or 无序存储 unordered

      • 有序存储会自动排序

        1
        2
        3
        4
        multimap<string,string> authors{{"Alain","a"},
        {"Stanley","c++Primer"},
        {"Alain","b"},
        {"Blain","c"}};

        image-20240205212831668

  • 也可以对一个关联容器进行列表初始化

11.1 简单使用关联容器

  • map

    • map是键值对的集和

    • map通常被称为关联数组,可使用key而不是位置作为下标来查找val

    • map<key的类型,val的类型> —> 每个元素是一个pair类型.first 表示key,.second表示val

  • set

    • set就是关键字的简单集和。
    • 值就是关键字

简单应用:单词计数

1
2
3
4
5
6
7
8
9
10
11
12
map<string,size_t> word_cnt;
set<string> exclude{"the","a","an","but"}; //忽略计数的单词
string word;

while(cin>>word)
if(exclude.find(word) == exclude.end()) // 没找到,返回尾后指针
++word_cnt[word]; // ++是将val加1

for(const auto &w:word_cnt){
cout<<w.first<<" occurs "<<w.second
<<((w.second>1)?" times ":" time ")<<endl;
}

11.2 关联容器概述

11.2.1 如何定义关联式容器

  • 除了不支持顺序容器的push_back等位置操作,以及构造函数和插入操作这些接受一个元素值和一个数量值的操作外,都支持笔记9.2中表9.2的普通容器操作。
  • 关联容器的迭代器都是双向的
  • 初始化
    image-20240205095623403
  • 初始化时,没有muti的关联容器会自动删除key重复的元素
    image-20240205095744626

11.2.2 关键字类型的要求

  • 对于有序容器,关键字类型必须定义元素比较的方法。默认情况下标准库使用key类型的<运算符来比较两个关键字。
  • key类型可以提供自己的操作来替代默认的<,遵循严格弱序(小于等于)
    image-20240205103238274
  • key如果没有自定义的<运算符,也可使用关键字类型的比较函数,下例所示

    1
    2
    3
    4
    5
    6
    7
    // 比较函数
    bool compareIsbn(const Sales_data &sd1, const Sales_data &sd2){
    return sd1.isbn() < sd2.isbn();
    }

    // 定义mutiset
    mutiset<Sales_data, decltype(compareIsbn) *> bookstore(); // decltype作用于函数时,返回函数类型而非指针类型,所以要另加*号
    • 元素的操作类型(比较函数)也是容器类型的一部分
    • 操作类型也仅仅只是类型,当创建容器时,才会以构造函数参数的形式提供真正的比较操作。

11.2.3 pair

  • #include <utility>

  • pair上的操作
    image-20240205095922457

  • 如何创建一个pair对象

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    /*写法1*/
    {key,val};

    /*写法2*/
    pair<key_type,val_type>(key,val);

    /*写法3*/
    make_pair(key,val);

    /*隐式构造一个空的pair*/
    pair<key_type,val_type>();

11.3 关联容器操作

11.3.1 获得关联容器中元素的类型 & 容器迭代器

image-20240205145535666

  • 关键字是const的,不能随便改变一个元素的关键字

    • 1.类型别名
      image-20240205145826046

    • 2.不能改变map的key的值,因为是const的
      image-20240205150018200

    • 3.set的val就是key,同样是const的

      虽然set 类型同时定义了iterator和const_iterator类型,但两种类型都只允许只读访问 set 中的元素

      与不能改变一个map 元素的关键字一样,一个 set 中的关键字也是 const 的

      image-20240205150123954

  • 同样可由++迭代器遍历关联容器

  • 关联容器和算法

    • 通常不对关联容器使用泛型算法——关键字是const的
    • 关联容器可用于只读容器的算法——不过建议使用成员函数
    • 在实际编程中,如果我们真要对一个关联容器使用算法,要么是将它当作一个源序列,要么当作一个目的序列。

11.3.2 添加元素

image-20240205151520039

  • 非muti在构造时会自动忽略重复的项

  • 对map进行insert,切记所需元素类型的pair

  • insert/emplace的返回值

    • 对于非muti,返回一个pair。first是一个指向插入的元素的迭代器。second是一个bool,指出 插入成功(true) 还是 已在容器中(false)

    • 对于first,通常用auto代替,详细的结构如下:

      1
      2
      3
      map<string,size_t> word_cnt;
      pair<map<string,size_t>::iterator, bool> ret =
      word_cnt.insert({word,1});
  • 对muti,返回值为指向新元素的迭代器,没有bool

    • 应用:添加具有相同关键字的多个元素

11.3.3 删除元素

image-20240205153100464

11.3.4 map的下标操作

image-20240205155953031

  • 仅map和unordered_map支持下标操作
  • 如果关键字不在map/unordered_map中,会创建该关键字。初始化顺序如下:
    image-20240205160026146
  • 由于下标运算符可能插入一个新元素,所以只能对非const使用
  • 解引用迭代器和下标操作所得到的值不同,前者为(value_type),后者为(mapped_type)

11.3.5 访问元素

202402052102580

  • 只是为了判断特定元素在不在容器中,find是最佳选择,下标运算会有副作用

  • muti容器中,具有相同关键字的多个元素相邻存储

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    multimap<string,string> authors{{"Alain","a"},
    {"Stanley","c++Primer"},
    {"Alain","b"},
    {"Blain","c"}};
    string author{"Alain"};
    auto entries = authors.count(author); // 元素个数
    auto iter = authors.find(author); // 作者的第一本书
    while (entries--){
    cout<<iter->second<<" "; // a b
    ++iter;
    }
  • 针对muti,lower_bound和upper_bound

    • lower_bound返回的迭代器指向第一个具有给定关键字的元素
    • upper_bound返回的迭代器指向最后一个具有给定关键字的元素之后的位置
    • 如果给定关键字不存在,则两个函数指向相同的位置——第一个安全的插入点(即能够保持容器顺序的插入位置)
1
2
3
4
5
6
7
8
9
10
11
12
13
//等价上例
multimap<string,string> authors{{"Alain","a"},
{"Stanley","c++Primer"},
{"Alain","b"},
{"Blain","c"}};

string author{"Alain"};
for(auto beg = authors.lower_bound(author),
end = authors.upper_bound(author);
beg != end; ++beg)
{
cout<<beg->second<<" "; // a b
}

image-20240205214737877

image-20240205215417909

  • equal_bound

    • 此函数接受一个关键字,返回一个选代器对pair。若关键字存在,则第一个迭代器指向第一个与关键字匹配的元素(相当于lower_bound),第二个迭代器指向最后一个匹配元素之后的位置(相当于upper_bound)。若未找到匹配元素,则两个迭代器都指向关键字可以插入的位置(同样类似lower_bound和upper_bound)。

    • 示例

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      //等价上例
      multimap<string,string> authors{{"Alain","a"},
      {"Stanley","c++Primer"},
      {"Alain","b"},
      {"Blain","c"}};

      string author{"Alain"};
      for(auto pos = authors.equal_range(author);
      pos.first != pos.second; ++pos.first)
      {
      cout<<pos.first->second<<" "; // a b
      }

11.4 无序容器

  • 通常可以用无序容器替换对应的有序容器,反之亦然。但是,由于元素未按顺序存储,一个使用无序容器的输出(通常)会与使用有序容器的版本不同。

  • 管理桶

    • 无序容器的形式如下图所示
      image-20240206092547916

    • 无序容器在存储上组织为一组桶,每个桶保存零个或多个元素。无序容器使用一个哈希函数将元素映射到桶。为了访问一个元素,容器首先计算元素的哈希值,它指出应该搜索哪个桶。容器将具有一个特定哈希值的所有元素都保存在相同的桶中。如果容器允许重复关键字,所有具有相同关键字的元素也都会在同一个桶中。因此,无序容器的性能依赖于哈希函数的质量和桶的数量和大小。

      对于相同的参数,哈希函数必须总是产生相同的结果。理想情况下,哈希函数还能将每个特定的值映射到唯一的桶。但是,将不同关键字的元素映射到相同的桶也是允许的。当一个桶保存多个元素时,需要顺序搜索这些元素来查找我们想要的那个。计算一个元素的哈希值和在桶中搜索通常都是很快的操作。但是,如果一个桶中保存了很多元素,那么查找一个特定元素就需要大量比较操作。

    • 无序容器的管理操作
      image-20240205221101059

  • 无序容器对key类型的要求

    • 无序容器需要==来比较元素和hash<key_type>来生成每个元素的hash值

    • 可以通过重载关键字类型的默认操作,类定义无序容器:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      class Sales_data{
      public:
      explicit Sales_data(string isbn):m_isbn(std::move(isbn)){}
      string isbn() const { return m_isbn;}
      private:
      string m_isbn;
      };

      size_t hasher(const Sales_data &sd){
      return hash<string>()(sd.isbn());
      }

      bool eqop(const Sales_data &lhs, const Sales_data &rhs){
      return lhs.isbn() == rhs.isbn();
      }


      int main(){
      using sd_mutiset = unordered_set<Sales_data, decltype(hasher)*, decltype(eqop)*>;
      sd_mutiset bookstore(42, hasher, eqop);//桶数目、哈希函数指针、相等性判断运算符指针

      //如果Sales_data中重载了==,可省略等号
      unordered_set<Foo,decltype(FooHash)*> fooSet(10,FooFash);
      return 0;
      }

十二 动态内存

12.1 动态内存与智能指针

使用动态内存的原因:

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

image-20240206105347502

12.1.1 shared_ptr

  • make_shared()

    • 最安全的分配和使用动态内存的方法.

    • 返回一个shared_ptr,指向函数在动态内存中分配的对象

    • #include <memery>

    • 类似emplacemake_shared()用其参数构造给定类型的对象

      1
      shared_ptr<string> ptr = make_shared<string>(10,'9');// 9999999999
  • shared_ptr

    • 自动销毁所管理的对象,自动释放关联的内存(析构函数)
    • 如果是容器中的元素,不用时记得用erase删除
    • 如果多个对象共享底层数据(使用动态内存的原因),当某个对象被销毁时,我们不能单方面地销毁底层数据

12.1.2 new & delete

(原文:12.1.2 直接管理内存)

  • 直接管理内存的类不能依赖类对象的拷贝、赋值和销毁操作的任何默认定义。(相对于智能指针容易出错)
  • 初始化方式

    1
    2
    3
    4
    5
    6
    int *p = new int; //默认构造
    int *p = new int(); // 值初始化
    int *p = new int(5); // 传统构造方式
    int *p = new int{5};//列表初始化

    string *p = new string(10,'9');//*p为"9999999999",注意'9'是单引号的字符
  • 值初始化

    • 对于自定义构造函数会执行默认构造函数,默认/值初始化没有差别;

    • 而对于内置类型, 建议使用值初始化,而不是默认初始化 。

      1
      2
      3
      4
      int *p0 = new int;//默认初始化,*p0的值未定义
      int *p1 = new int();//值初始化,*p1=0

      string *p2 = new string;//string有默认构造函数,*p=""
    • 同样,对于自定义类中的那些依赖于编译器合成的默认构造函数的内置类型成员,如果未在类内被初始化,那么它们的值也是未定义的。

    • 如果提供了括号包围的初始化器,则可以用auto

      1
      auto p = new auto(obj);
  • 动态分配const对象

    1
    2
    3
    //const int *pci = new const int; //错误
    const int *pci = new const int(1024);
    const string *pcs = new const string; // string有默认构造函数,隐式初始化
    • 类似其他任何 const 对象,一个动态分配的 const 对象必须进行初始化。
    • 对于一个定义了默认构造函数的类类型,其const 动态对象可以隐式初始化,而其他类型的对象就必须显式初始化。
    • 由于分配的对象是 const 的,new 返回的指针是一个指向const的指针。
  • 内存耗尽,会抛出bab_alloc异常,可以通过定位new的方式阻止其抛出异常

    1
    2
    #include <new>
    int *p = new (nothrow) int(); //如果分配失败,返回一个空指针,而不是抛出异常
  • delete
    (书本P409~P411)都是在讲delete的用法,并没有特别的知识。需要注意的地方有:

    • delete销毁给定的指针指向的对象,并释放其内存

    • delete之后,指针变为空悬指针,最好将指针置为nullptr

    • delete指向数组的指针

      1
      2
      3
      4
      5
      6
      auto *p = new string(10, '9');
      delete p;

      auto *p1 = new int[5];
      //delete p1;//只会删除数组中的一个元素
      delete[] p1; //告诉编译器,将要删除的是数组

12.1.3 shared_ptr & new

202402061545630

  • 可以使用new通过值初始化的方式构造智能指针

    1
    shared_ptr<int> p(new int(42));
  • 接受指针参数的智能指针构造函数时explicit的,不能使用隐式转换构造指针指针,有如下几种错误情况

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    /*例1*/
    shared_ptr<int> p = new int(42);// 错误

    /*例2*/
    shared_ptr<int> clone(int p){
    //return new int(p); // 错误
    return shared_ptr<int>(new int(p)); // 正确,显式转换
    }

    /*例3*/ // <-- 不要混用普通指针和智能指针,推荐使用make_shared
    void process(shared_ptr<int> ptr){}

    //--------------------
    // 错误
    int *x = new int(42);
    //process(x); //错误,不可隐式转换
    process(shared_ptr<int>(x)); // 错误。虽然合法,但x在process结束时会被释放
    int j = *x; //未定义行为,x已经被释放


    //--------------------
    //正确的写法
    shared_ptr<int> p(new int(42));
    process(p); // 引用计数+1
    int i = *p; //仍然存在
  • 不要混用普通指针和智能指针,推荐使用make_shared。

    • 当将一个 shared_ptr 绑定到一个普通指针时,我们就将内存的管理责任交给了这个shared_ptr。一旦这样做了,我们就不应该再使用内置指针来访问 shared_ptr所指向的内存了。如上例3错误写法所示。
    • 不要使用get初始化另一个智能指针或为智能指针赋值——get()返回内置指针,如果delete了,指针指针就失效了。
      image-20240206212657982
  • reset()用一个新的指针赋予一个shared_ptr,常与unique()(注意:是shared_ptr的成员函数,不是unique_ptr<>)一起用,来控制多个shared_ptr共享的对象。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    shared_ptr<string> a,b;
    shared_ptr<string> p(new string("x"));
    a = p, b = p;
    if(!p.unique())
    p.reset(new string(*p));
    *p+=string("y");
    cout<<*p<<endl; // xy
    cout<<*a<<endl; //x
    cout<<*b<<endl; //x

    image-20240206162851438

12.1.4 智能指针 & 异常

  • 如果在new和delete之间发生了异常,且异常没有在函数内部被捕获,new的内存就永远无法释放了——使用智能指针就不会有这样的问题。

  • 智能指针陷阱
    image-20240206220131426

  • shared_ptr额外的用法 ——释放哑类(没有析构函数的类)
    (书P416)
    利用shared_ptr并指定删除器(deleter),当func()退出时(即使由于异常而退出),哑类对象也会被正常关闭

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    struct destination{};//目的ip
    struct connection{};
    connection connect(destination *);
    void disconnect(connection); // 断开connect连接(析构)

    //删除器(deleter)
    void end_disconnect(connection *p /*指向 shared_ptr尖括号中类型 的指针*/){
    disconnect(*p);
    }

    void func(destination &d){
    connection c = connect(&d);
    // 如果我们在f()退出前没有调用disconnect,就无法关闭c了
    // 利用shared_ptr并指定删除器,当f()退出时(即使由于异常而退出),connection也会被正常关闭,如下:
    shared_ptr<connection> p(&c, end_disconnect); // 第二个参数指定自定义的删除器(指向函数的指针)

    }

12.1.5 unique_ptr

image-20240206222750628

  • 独占、“拥有”

  • 初始化unique_ptr必须采用直接初始化形式。

  • 不支持普通拷贝和赋值。但是有个例外:可以拷贝或赋值一个将要被销毁的unique_ptr,如

    1
    2
    3
    4
    5
    6
    7
    8
    unique_ptr<int> clone(int p){
    return unique_ptr<int> (new int(p));
    }

    unique_ptr<int> clone(int p){
    unique_ptr<int> ret(new int(p));
    return ret;
    }
  • 虽然不能拷贝和赋值unique_ptr,但是可以通过调用release或reset将指针的所有权转移(非const)

    • reset(重置)就是将std::unique_ptr指向新的资源。由于std::unique_ptr就是最后一个指向当前资源的智能指针,因此,在重置前需要销毁回收当前的资源。

      1
      2
      3
      4
      5
      unique_ptr<string> p2();
      unique_ptr<string> p3(new string("p3"));
      p2.reset(p3.release());
      // reset释放p2原来的指向的内存,并令p2指向内存
      // release返回p3当前指向的内存地址后,令p3 == nullptr
  • release函数可以释放所有权,并返回指向std::unique_ptr所管理的资源的指针。

    • 注意:release仅仅释放了所有权,并没有销毁回收所管理的资源。而回收内存资源的责任交还给了使用者。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    /*例1*/
    p2.release(); //错误,p2不会释放内存,并且我们丢失了指针
    auto p = p2.release(); // 正确,但要记得手动 delete p

    /*例2*/
    unique_ptr<string> p3(new string("p3"));
    string *pstr = p3.release();
    cout<<*pstr; //终端输出: p3
    delete pstr;
  • 书中示例

    1
    2
    3
    4
    unique_ptr<string> p1(new string("p1"));
    unique_ptr<string> p2(p1.release()); //**release只是将p1置空(p1==nullptr),并没有销毁原来指向的内存**
    unique_ptr<string> p3(new string("p3"));
    p2.reset(p3.release()); // reset释放p2原来的指向的内存,并令p2指向内存
  • 向unique_ptr传递删除器

    • 与shared_ptr不同,需要在<>指定删除器函数类型

      1
      unique_ptr<objType,decltype(deleteFunc)*> n(new ObjType, deleteFunc)
    • 用unique_ptr重写shared_ptr网络连接的例子

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      struct destination{};//目的ip
      struct connection{};
      connection connect(destination *);
      void disconnect(connection); // 断开connect连接(析构)

      //删除器(deleter)
      void end_disconnect(connection *p /*指向 shared_ptr尖括号中类型 的指针*/){
      disconnect(*p);
      }

      void func(destination &d){
      connection c = connect(&d);
      // 如果我们在f()退出前没有调用disconnect,就无法关闭c了
      // 利用unique_ptr并指定删除器,当f()退出时(即使由于异常而退出),connection也会被正常关闭,如下:
      unique_ptr<connection,decltype(end_disconnect)*> p(&c, end_disconnect); // 第二个参数指定自定义的删除器(指向函数的指针)

      }

12.1.6 weak_ptr

image-20240206222828563

  • weak_ptr的主要特点

    weak_ptr(见表 12.5)是一种不控制所指向对象生存期的智能指针,它指向由一个shared_ptr管理的对象。将一个 weak_ptr 绑定到一个 shared_ptr 不会改变shared_ptr的引用计数。一旦最后一个指向对象的 shared_ptr 被销毁,对象就会被释放。即使有 weak_ptr 指向对象,对象也还是会被释放,因此,weak_ptr 的名字抓住了这种智能指针“弱”共享对象的特点。

  • weak_ptr需要用shared_ptr初始化

    1
    2
    auto p = make_shared<int> (42);
    weak_ptr<int> wp(p);
  • 调用lock()以使用weak_ptr

    • 由于对象可能不存在,我们不能使用weak_ptr直接访问对象,而必须调用lock()

      1
      2
      3
      4
      5
      if(shared_ptr<int> np = wp.lock()){
      // lock检查weak_ptr指向的对象是否存在;
      // 如果存在,返回指向共享对象的shared_ptr
      // 否则返回空
      }

12.2 动态数组

建议使用容器,而不是动态分配的数组

12.2.1 new & 数组

一、两种声明方法

1
2
3
4
5
6
7
// 声明
// 法1
int *pia = new int[42]; // 必须指定大小,必须是整形,但不一定是常量表达式

// 法2
typedef int arrT[42];
int *p = new arrT ;
  • 分配一个数组会得到一个元素类型的指针,分配的内存不是数组类型。

    • 不能调用begin和end

    • 不能使用范围for

    • 原文

      分配一个数组会得到一个元素类型的指针

      虽然我们通常称 new T[]分配的内存为“动态数组”,但这种叫法某种程度上有些误导。当用 new 分配一个数组时,我们并未得到一个数组类型的对象,而是得到一个数组元素类型的指针。即使我们使用类型别名定义了一个数组类型,new 也不会分配一个数组类型的对象。在上例中,我们正在分配一个数组的事实甚至都是不可见的一一连[num]都没有。new 返回的是一个元素类型的指针。

      由于分配的内存并不是一个数组类型,因此不能对动态数组调用 begin 或end(参见3.5.3 节,第106 页)。这些函数使用数组维度(回忆一下,维度是数组类型的一部分)来返回指向首元素和尾后元素的指针。出于相同的原因,也不能用范围 for 语句来处理所谓的)动态数组中的元素。

二、初始化

1
2
3
4
5
6
7
int *pia = new int[42];  // 10个未初始化的int。内置类型未初始化,其中的值是未定义的
int *pia2 = new int[42]();
int *pia3 = new int[10]{0,1,2,3,4,5,6,7,8,9};

string *psa = new string[10]; // 10个值初始化的空stirng,string中有默认构造函数
string *psa2 = new string[10]();
string *psa3 = new string[10]{"a","an","the",string(3,'x')};
  • 如果,初始化器中数目大于指定的元素数,new失败,不会分配任何内存,抛出bad_array_new_length异常(#include <new>

  • 虽然我们可用空括号对数组中的元素进行值初始化,但不能在括号中给出初始化器。意味着不能用auto分配数组——笔记12.1.2 new & delete节—>值初始化—>第4小点

  • 动态分配一个空数组是合法的

    1
    2
    3
    4
    5
    6
    7
    8
    9
    char arr[0]; // 错误,不能定义长度为0的数组
    char *cp = new char[0]; //正确,但cp不能解引用。可用于循环的比较操作,如下

    /*动态分配一个空数组,用于循环的比较操作*/
    size_t n = get_size();
    int *p = new int[n]; // n为0,算法依然成立
    for(int *q = p; q != p+n; ++q){
    /*处理数组*/
    }

三、释放动态数组

必须带有方括号,不论是那种初始化形式。例:

1
2
int *pia2 = new int[42]();
delete[] pia2;

四、智能指针和动态数组

  • unique_ptr

    • 标准库提供了一个 管理new分配的数组的 unique_ptr版本,销毁时将自动调用delete[]

      1
      2
      3
      4
      //    unique_ptr<int[]> up = new int[10]; //No viable conversion from 'int *' to 'unique_ptr<int[]>'
      unique_ptr<int[]> up(new int[10]{1,2});
      auto p = up.release(); // 书P425说的是销毁其*指针*,而不是说销毁指针指向的元素
      cout<<*p; //终端输出: 1
    • 也可以使用下标运算符,但不支持 点和箭头 运算符

      1
      2
      3
      for(size_t i = 0; i != 10; ++i){
      up[i] = i;
      }
    • unique_ptr管理数组的方式汇总
      image-20240207175919576

  • shared_ptr

    shared_ptr不直接支持动态管理数组,需要我们:1)提供删除器;2)使用get()获取数组首元素指针,以访问数组中的元素。

    • 提供删除器(否则,shared_ptr将用delete销毁其所指向的对象)

      1
      2
      shared_ptr<int> sp(new int[10], [](int *p){ delete[] p; });
      sp.reset();// 将调用我们提供的删除器

      202402071812706

    • 使用get()获取数组首元素指针,以访问数组中的元素

      1
      2
      3
      for(size_t i = 0; i != 10; ++i){
      *(sp.get() + i) = i; // 使用get()获得内置指针
      }

12.2.2 allocator类

  • #include <memory>
  • 目的:先分配内存,在需要的时候再在该内存创建对象。(区别于new等同时分配内存和创建对象)

image-20240207214733502

  • 基础用法示例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    #include <iostream>
    #include <memory>
    using namespace std;

    int main(){
    int n = 5;
    allocator<string> alloc; // 可以分配string内存的allocator对象
    auto const p = alloc.allocate(n); // 分配5个未初始化的string的内存

    auto q = p; // q指向构造元素之后的位置
    // construct(指向当前要填充位置的指针,...构造函数所需的参数...)
    alloc.construct(q++); // *q为空字符串
    alloc.construct(q++,10,'c'); //*q为"cccccccccc"
    alloc.construct(q++,"hi"); // *q为"hi"
    // cout<<*q<<endl;//灾难:q指向未构造的内存,不能在未构造的情况下使用原始内存
    cout<<*(q-1)<<endl; //hi

    // 当我们用完对象后,必须对每个构造的元素调用destroy()销毁
    while (q != p)
    alloc.destroy(--q); // 只能对真正构造了的元素调用destory

    // 一旦元素销毁,就可以用该内存保存其他的string元素

    // 程序结束,释放alloc申请的内存(要先对所有元素destory)
    alloc.deallocate(p,n); // p必须指向由allocate分配的内存,n必须等于allocate分配的大小

    }
  • 拷贝和填充未初始化的内存
    image-20240207220937592

    • 书中例子:作为一个例子,假定有一个 int 的 vector,希望将其内容拷贝到动态内存中。我们将分配一块比 vector 中元素所占用空间大一倍的动态内存,然后将原 vector 中的元素拷贝到前一半空间,对后一半空间用一个给定值进行填充:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      #include <iostream>
      #include <memory>
      using namespace std;
      #include <vector>

      int main(){
      vector<int> v{0,1,2,3,4};

      allocator<int> alloc;
      auto const p = alloc.allocate(v.size() * 2);

      auto q = uninitialized_copy(v.begin(),v.end(), p);
      // p:第三个参数**必须指向未构造的内存**
      // q:指向最后一个构造的元素之后的位置

      q = uninitialized_fill_n(q,v.size(), 42);
      // 在目的指针(q)指向的内存中创建给定数目(v.size())个对象,用给定的值(42)对他们进行初始化

      while(p!=q){
      cout<<*(--q)<<" "; //输出: 42 42 42 42 42 4 3 2 1 0
      alloc.destroy(q);
      }

      alloc.deallocate(q,v.size()*2);

      return 0;
      }

跳过的书中例子的记录

P391 一个单词转换的map

P404 ~ P406 StrBlob类
P420底部 ~ P422 核查指针类 —— 为StrBlob定义一个伴随指针类

P432 ~ P435 文本查询类的定义

------类设计者工具------

十三 拷贝控制

拷贝构造、拷贝赋值和析构 :big three —— 侯捷《面向对象高级编程》

13.1,13.2:拷贝

13.4:移动

13.1 拷贝、赋值和析构

13.1.1 拷贝构造

1
2
3
4
5
class Foo{
public:
Foo(); //默认构造
Foo(const Foo&); // 拷贝构造
};
  • 第一个参数必须是自身类型的引用,且几乎总是一个const,且额外参数都有默认值
  • 不应该是explicit
  • static成员不被拷贝(拷贝每个非static成员)

一:拷贝初始化

拷贝初始化依靠拷贝构造函数或移动构造函数来完成。——左值拷贝,右值移动

拷贝初始化的发生条件:

  • =

  • 作为实参 传递给一个非引用的形参

  • 返回类型为非引用的函数 返回一个对象
  • 列表初始化一个数组的元素或一个聚合类中的成员
  • 标准库容器调用insert或push等

二:拷贝初始化的限制

总结:主要是针对explicit的限制,拷贝构造不可隐式转化,可直接构造为临时对象再使用 —> 当传递一个实参或从函数返回一个值时,不可隐式使用explicit,需要像示例2最后一行一样使用。

参考:

[1] Copy-initialization

[2] C++复制初始化的限制 —— 对[1]的翻译

相比于直接初始化,复制初始化有更加严格的限制。

1:在复制初始化时,不能使用声明为explicit的构造函数进行的隐式转换。而直接初始化则是允许的:

1
2
3
4
5
6
7
struct Exp { explicit Exp(const char*) {} }; // not convertible from const char*
Exp e1("abc"); // OK
Exp e2 = "abc"; // Error, copy-initialization does not consider explicit constructor

struct Imp { Imp(const char*) {} }; // convertible from const char*
Imp i1("abc"); // OK
Imp i2 = "abc"; // OK
  • Exp类中的构造函数声明为了explicit,因此,复制初始化Exp e2 = “abc”将会发生编译错误:error: conversion from ‘const char [4]’ to non-scalar type ‘Exp’ requested.

  • Imp类中的构造函数没有声明为explicit,因此,可以用字符串”abc”进行直接初始化或复制初始化Imp的对象。

2:在复制初始化中,使用隐式转换时,必须是从初始化器(=右边的表达式)可以直接转换为被初始化对象,而不是间接的。在直接初始化中,可以使用从初始化器到构造函数参数的隐式转换。

1
2
3
4
5
struct S { S(std::string) {} }; // implicitly convertible from std::string

S s1("abc"); // OK: conversion from const char[4] to std::string
S s2 = "abc"; // Error: no conversion from const char[4] to S
S s3 = std::string("abc"); // OK: conversion from std::string to S
  • 类S有一个接受std::string参数的构造函数,因此,可以使用”const char*”直接初始化S的对象s1。这里的转换序列是:const char* à std::string à struct S;

  • 复制初始化中,则不允许这种转换,因为它不是直接转换,而是间接转换,因此,s2的初始化就会发生编译错误;

  • 复制初始化中,可以使用直接隐式转换,因此,可以使用std::string的对象,初始化s3。

三:编译器可以绕过拷贝构造函数

参考文献

C++ primer P442 P447:在拷贝初始化过程中,编译器可以跳过拷贝构造函数,直接创建对象。即,编译器允许将下面的代码 

1
string null_book = "999";  //1

改写为

1
string null_book("999");  //2

由于string的构造函数不是一个explicit的,所以说string类型允许从const char* 到string的隐式转换。

在行1中,首先将”999”隐式转化为一个string的临时对象,然后应该调用string的拷贝构造函数对null_book初始化。即

1
2
string temp_str("999");
string null_book = temp_str; //或者 string null_book(temp_str);

在这里编译器会进行优化,跳过拷贝构造函数直接创建对象,使临时变量直接成为所要创建的对象

类似的下面代码也会跳过拷贝构造函数

1
string str = string();

但是在这种情况下,拷贝构造函数必须是public的,否则编译会不通过。可能是因为如果拷贝构造函数是private的话,编译器会理解为不能够使用拷贝构造函数,进而不会进行这种优化。

13.1.2 拷贝赋值

  • 在类中重载=运算符,必须是成员函数
  • 通常应该返回指向其左侧运算对象的引用
  • 标准库通常要求保存在容器中的类型要具有赋值运算符
  • 将右侧的非static成员赋予左侧(xxx除外)
1
2
3
4
5
6
7
class Foo{
public:
Foo& operator=(const Foo &f){
//this->xxx = f.xxx;
return *this;
}
};

注意:将于笔记13.2详细说明

  • 拷贝赋值运算符与往常一样执行类似拷贝构造函数和析构函数的工作。即,它必须递增右侧运算对象的引用计数(即,拷贝构造函数的工作),并递减左侧运算对象的引用计数,在必要时释放使用的内存(即,析构函数的工作)。
  • 必须处理自赋值(自己赋值给自己)

13.1.3 析构函数

13.1.3.1 什么时候需要析构函数

什么时候需要析构函数?一般是成员通过new动态申请了内存的时候。(管理类外资源的类)

因为:隐式销毁一个内置指针类型的成员不会delete它所指向的对象。

13.1.3.2 什么时候调用析构函数?

变量在离开其作用域时被销毁。

  • 当一个对象被销毁时,其成员被销毁
  • 容器(包括数组)被销毁时,其元素被销毁
  • 对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁(参见12.1.2节,第409页)。
  • 对于临时对象,当创建它的完整表达式结束时被销毁

析构函数调用过程(P446)

如同构造函数有一个初始化部分和一个函数体,析构函数也有一个函数体和一个析构部分。

  • 在一个构造函数中,成员的初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化。
  • 在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。

要认识到析构函数体自身并不直接销毁成员是非常重要的:成员是在析构函数体之后隐含的析构阶段中被销毁的。在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的。

13.1.3.3 三/五法则

需要析构函数的类也需要拷贝构造和拷贝赋值

需要拷贝构造的类也需要拷贝赋值,反之亦然。但不必然要求析构函数。

13.1.4 控制是否使用默认(合成)函数

13.1.4.1 =default & =delete

  • 显式地要求编译器使用默认版本:=default

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class Sales_data{
    public:
    Sales_data() = default; // 类内 = default将被声明为内联
    Sales_data(const Sales_data &) = default;
    Sales_data& operator=(const Sales_data &);
    ~Sales_data() = default;
    };

    Sales_data& Sales_data::operator=(const Sales_data &) = default; // 这样写就不是内联
  • 阻止拷贝:=delete

    1
    2
    3
    4
    5
    6
    7
    class noCopy{
    noCopy() = default;
    noCopy(const noCopy &) = delete;
    noCopy & operator=(const noCopy &) = delete;

    ~noCopy() = default; //析构函数不能是=delete,否则会导致一系列问题
    };
  • 何时阻止拷贝?例如:iostream类阻止了拷贝,以避免多个对象读取或写入相同的io缓冲
  • =delete必须出现在函数第一次声明的时候
  • 可以对任何函数=delete:用于引导函数匹配过程。(析构函数除外)
  • 对上一点的补充:析构函数不能是=delete,否则
    • 1)无法定义该类型的变量或临时对象;且如果它被包含进另一个类,则导致外层的类也不能定义变量或临时对象
    • 2)无法释放指向该类型的动态分配到指针(可以new但不能delete)

13.1.4.2 何时合成的拷贝控制成员被定义为删除

(P450):question:有点乱

  • 如果类的某个成员的析构函数是删除的或不可访问的(例如,是 private 的),则类的合成析构函数被定义为删除的。
  • 如果类的某个成员的拷贝构造函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的。如果类的某个成员的析构函数是删除的或不可访问的,则类合成的拷贝构造函数也被定义为删除的。
  • 如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是类有一个const的或引用成员,则类的合成拷贝赋值运算符被定义为删除的。
  • 如果类的某个成员的析构函数是删除的或不可访问的,或是类有一个引用成员,它没有类内初始化器(参见2.6.1节,第65页),或是类有一个 const 成员,它没有类内初始化器且其类型未显式定义默认构造函数,则该类的默认构造函数被定义为删除的。

总结:本质上,如果一个类中,存在某个数据成员不能默认构造、拷贝、赋值或销毁,则该类对应的成员函数将被定义为删除(=delete)。

  • 类中存在引用成员或无法默认构造的const成员类,编译器不会合成默认构造函数
  • 类中存在引用成员或无法默认构造的const成员类,编译器不会合成拷贝赋值运算符

13.1.4.3 private拷贝控制

没有=delete前(c++11前),通过声明(但不定义)private的拷贝构造和拷贝赋值,可以阻止任何拷贝该类对象的企图。

  • private:试图拷贝对象的用户代码将在编译阶段被标记为错误
  • 不定义:成员函数或友元函数中的拷贝操作将会导致链接错误

13.2 拷贝控制和资源管理

管理类外资源的类有两种拷贝方式:(a)拷贝指针指向的对象;(b)拷贝指针本身。

13.2.1 行为像值的类——拷贝指针指向的对象

  • 定义一个拷贝构造函数,完成string 的拷贝,而不是拷贝指针
  • 定义一个析构函数来释放string
  • 定义一个拷贝赋值运算符来释放对象当前的 string,并从右侧运算对象拷贝string

其中,尤其需要注意拷贝赋值的写法

  • 拷贝赋值运算符组合了类似拷贝构造函数和析构函数的工作:

    • 赋值运算符左侧:销毁左侧运算对象的资源——(析构函数的构造)

    • 赋值运算符右侧:从右侧运算对象拷贝数据到左侧运算对象——(拷贝构造的工作)

  • 必须处理自赋值

  • 需要异常安全——当异常发生时,能将左侧运算对象置于一个有意义的状态

202402201501741

示例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <string>
using namespace std;
class HasPtr {
public:
HasPtr(const string& s = string()) :
ps(new string(s)), i(0) {} // 构造

HasPtr(const HasPtr& p) :
ps(new string(*p.ps)), i(p.i) {} // 拷贝构造
HasPtr& operator=(const HasPtr& p); // <-特别需要注意拷贝赋值

~HasPtr() { delete ps; } //析构

private:
string* ps;
int i;
};

HasPtr& HasPtr::operator=(const HasPtr& p)
{
auto newp = new string(*p.ps); // 现将数据保存在额外的空间中,避免因自赋值而将自身销毁
delete ps; // 先释放当前对象指针指向的空间
ps = newp; // 后从右侧对象拷贝数据
i = p.i;
return *this;
}


/*错误案例*/
HasPtr& HasPtr::operator=(const HasPtr& p)
{
/*如果有如下情况,ps将会指向无效内存:
* HasPtr a;
* a = a;
* 调用operator=时,先将a.ps销毁了,又让a.ps指向了已经被销毁的空间,而产生未定义行为
*/
delete ps;
ps = new string(*(p.ps));
i = p.i;
return *this;
}

13.2.2 行为像指针的类——拷贝指针本身

同样要注意拷贝赋值的写法:

  • 拷贝赋值运算符组合了类似拷贝构造函数和析构函数的工作:

    • 需要递增右侧运算对象的引用计数(拷贝构造)

    • 递减左侧运算对象的引用计数,并在必要时释放资源(析构)

  • 处理自赋值

  • 异常安全

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <string>
using namespace std;
class HasPtr {
public:
HasPtr(const string& s = string()) :
ps(new string(s)), i(0) ,use(new size_t(i)){} //构造

HasPtr(const HasPtr& p) :
ps(p.ps), i(p.i), use(p.use) { ++*use; } // 拷贝构造
HasPtr& operator=(const HasPtr& p); // 拷贝赋值

~HasPtr() {
if (--*use == 0) { // 如果引用计数变为0,则释放内存
delete ps;
delete use;
}
}

private:
string* ps;
int i;

size_t* use; // 引用计数器,保存在动态内存中
};

HasPtr& HasPtr::operator=(const HasPtr& p)
{
++*p.use; // 递增右侧运算对象的引用计数,可以避免自赋值被销毁:当=两边相同时,在递减之前,计数器已经被递增过了
if (--*use == 0) { // 递减左对象的引用计数
delete ps;
delete use;
}
this->ps = p.ps;
this->i = p.i;
this->use = p.use;

return *this;
}

13.2.3 交换操作

与拷贝控制成员不同,swap并不是必要的。但是,对于分配了资源的类,定义swap可能是一种很重要的优化手段。

一:编写自定义swap

两个类型交换的传统做法:

1
2
3
HasPtr tmp = v1;
v1 = v2;
v2 = tmp;

可通过重载swap的默认行为,实现两个自定义类的交换:

1
2
3
4
5
6
7
8
9
10
class HasPtr{
friend void swap(HasPtr &, HasPtr &); // 定义为友元以访问类的private成员
};

inline
void swap(HasPtr &l, HasPtr &r){
using std::swap;
swap(l.ps, r.ps); // 交换指针
swap(l.i,r.i); // 交换int成员
}

调用HasPtr定义的swap:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Foo{
friend void swap(Foo &, Foo &);

private:
HasPtr h;
};

inline
void swap(Foo &r, Foo &l){
using std::swap;
swap(r.h, l.h); // 自定义版本的swap优先级高于std::swap, 此处会调用类中重载的版本

//交换Foo的其他成员
}

建议使用using std::swap的写法:

image-20240220162159834

二:在赋值构造中使用swap

拷贝交换技术:将左侧运算对象与右侧运算对象的一个副本进行交换。

优势:自动处理了自赋值情况 且 天然异常安全。

1
2
3
4
HasPtr & HasPtr::operator=(const HasPtr rhs){ // <-注意:参数以值方式传递
swap(*this,rhs);
return *this; // rhs被销毁
}

13.3 两个例子

13.3.1 拷贝控制示例

Message.hpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
#pragma once
#include "Folder.hpp"
#include <string>
#include <set>
using namespace std;

class Message
{
friend class Folder;
friend void swap(Message& l, Message& r);
public:
explicit Message(const string str = "")
:contents(str) {}

Message(const Message&);//拷贝构造
Message& operator=(const Message&);
~Message() { remove_from_folders(); }

//从给定folder中添加/删除本message
void save(Folder& f) { this->folders.insert(&f); f.addMsg(this); }
void remove(Folder& f) { this->folders.erase(&f); f.remMsg(this); }

private:
string contents;
set<Folder*> folders; // 包含本message的folder

void add_to_folders(const Message&m) {
// 将本message添加到指向m的folder中
for (auto f : m.folders) {
f->addMsg(this);
}
}
void remove_from_folders() {
for (auto f : this->folders) {
f->remMsg(this);
}
}

};

Message::Message(const Message&m)
:contents(m.contents), folders(m.folders)
{
add_to_folders(m);
}

inline Message& Message::operator=(const Message&r)
{
remove_from_folders();
contents = r.contents;
folders = r.folders;
add_to_folders(r);

return *this;
}

void swap(Message& l, Message& r) {
using std::swap;

//将每个消息的指针从它(原来)所在的Folder中删除
for (auto f: l.folders)
f->remMsg(&l);
for (auto f : r.folders)
f->remMsg(&r);

swap(l.folders, r.folders); // 交换set
swap(l.contents, r.contents); // 交换string

//将每个Message的指针添加到它的(新)Folder中
}

13.3.2 动态内存管理类

StrVec类

13.4 :star:对象移动

(书P470)

为何使用移动?

  • 提升性能。在某些情况下,对象拷贝后机立即被销毁了,在这种情况下,移动而非拷贝对象会大幅度提升性能。

  • 不能被共享的资源(io类或unique_ptr),不可被拷贝但可以移动。

    在旧 C++标准中,没有直接的方法移动对象。因此,即使不必拷贝对象的情况下,我们也不得不拷贝。如果对象较大,或者是对象本身要求分配内存空间(如 string),进行不必要的拷贝代价非常高。类似的,在旧版本的标准库中,容器中所保存的类必须是可拷贝的。但在新标准中,我们可以用容器保存不可拷贝的类型,只要它们能被移动即可。

13.4.1 什么是右值引用

一、左值和右值

左值:表示一个对象的身份,通常可以被取地址

  • 左值引用的作用对象:不能将左值引用绑定到要求转换的表达式、字面值常量、返回右值的表达式。

右值:表示一个对象的值,通常无法取地址

  • 右值引用的作用对象:不能将右值引用直接绑定到左值,如必须要绑定,则要先用std::move将左值转换成右值,再右值引用。

左值和右值的对比:

20240221103743320

二、右值引用

右值引用就是必须绑定到右值的引用,通过&&表示。

右值引用的一个重要性质:只能绑定到一个将要销毁的对象上,从而将一个右值引用的资源“移动”到另一个对象上。

示例

image-20240221105229193

1
2
3
4
5
6
7
8
9
10
/*使用*/
int main() {
int&& rr1 = 42;
int&& rr2 = std::move(rr1);
cout << rr2 << endl; // 42

int&& rr3 = 42;
cout << rr3 << endl; // 42
return 0;
}

13.4.2 std::move()

  • 通过std::move将左值转化为右值(原文:使用std::move获得绑定到左值上的右值引用)
    • move的作用是偷取(窃取)其他变量里的资源变为自己的(是资源所有权的变更,而不是拷贝资源),如内存、线程等等,而不必自己再从0获取。很显然这样可以节约一些程序开销。
1
2
3
4
5
6
int &&rr1 = 42; // 正确,字面值常量是右值

int &&rr2 = rr1; // 错误,rr1是左值,右值引用只能绑定右值。(变量(rr1)可以看做没有运算符的表达式,而表达式都是左值)

int &&rr3 = std::move(rr1); // 正确,使用std::move获得绑定到左值上的右值引用(我的理解:将左值转化为右值)
//之后,可以销毁rr1的源对象,也可以赋予它新值,但是不可再使用rr1源对象的值
  • #include <utility>

  • 我们可以销毁一个移后源对象(此处指rr1),也可以赋予它新值,但不能使用一个移后源对象的值。

    move 调用告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它。我们必须认识到,调用 move 就意味着承诺:除了对 rr1 赋值或销它外,我们将不再使用它。在调用move之后,我们不能对移后源对象的值做任何假设。

    移动操作还必须保证源对象可以安全地为其赋予新值或者可以安全地使用而不依赖其当前值。

    另一方面,移动操作对移后源对象中留下的值没有任何要求。因此,我们不可对其值进行假设,我们的程序也不应依赖于移后源对象中的数据。

  • 使用move 的代码应该使用 std::move而不是move(不提供using声明)。这样做可以避免潜在的名字冲突。

image-20240222131649580


13.4.3 自定义类中的移动构造和移动赋值

对于移动构造和移动赋值:

  • 从给定对象窃取资源而不是拷贝资源,不新分配任何内存

  • 必须在声明和定义中都标记为noexcept,通知标准库不抛出任何异常(为什么需要noexcpt,书P474,:question:)

  • 源对象的所有指针置为nullptr,确保移后源对象处于一个可析构的状态(必须确保移后源对象被销毁是无害的)
  • 后续程序不应依赖移后源对象中的数据

13.4.3.1 移动构造

模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <string>
class StrVec {
public:
/*移动构造函数*/
StrVec(StrVec&& s) noexcept // 移动操作不应抛出任何异常
//成员初始化器接管s中的资源
:elements(s.elements),first_free(s.first_free),cap(s.cap)
{
// 源对象的指针全都要置空,确保移后源对象处于一个可析构的状态
s.elements = s.first_free = s.cap = nullptr;
}

private:
string* elements;
string* first_free;
string* cap;
};
  • 第一个参数须是该类型的右值引用,额外参数必须有默认值。(类似拷贝构造)

13.4.3.2 移动赋值

类似拷贝赋值:

  • 析构函数+移动构造
  • 必须正确处理自赋值

模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <string>
class StrVec {
public:
/*拷贝赋值*/
StrVec& operator=(StrVec&& s) noexcept {
//if语句检测自赋值:对于“=”两端相同的资源,避免在使用右侧运算对象之前就释放了左侧对象(避免将自身释放)
if (this != &s) { // s是左值,取地址
/*this->*/free(); // 释放左侧运算对象所使用的内存
elements = s.elements;
first_free = s.first_free;
cap = s.cap;

// 将s置于可析构状态
s.elements = s.first_free = s.cap = nullptr; // 源对象的指针全都要置空
}

return *this;
}

private:
string* elements;
string* first_free;
string* cap;
};

13.4.3.3 合成的移动操作

何时会生成合成移动操作?

  • 只有当一个类
    • 没有定义任何自己版本的拷贝控制成员
    • 且它的所有数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。

何时移动操作被定义为删除?(P476 )

  • 与拷贝构造函数不同,移动构造函数被定义为删除的函数的条件是:
    • 有类成员定义了自己的拷贝构造函数且未定义移动构造函数,
    • 或者是有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数。移动赋值运算符的情况类似。
  • 如果有类成员的移动构造函数或移动赋值运算符被定义为删除的或是不可访问的,则类的移动构造函数或移动赋值运算符被定义为删除的。
  • 类似拷贝构造函数,如果类的析构函数被定义为删除的或不可访问的,则类的移动构造函数被定义为删除的。
  • 类似拷贝赋值运算符,如果有类成员是 const 的或是引用,则类的移动赋值运算符被定义为删除的。

移动操作和合成拷贝之间的关系:

  • 如果类定义了一个移动构造函数和(或)一个移动赋值运算符,则该类的合成拷贝构造函数和拷贝赋值运算符会被定义为删除

    • 因此,定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作。否则,这些成员默认地被定义为删除的。

移动右值,拷贝左值,但如果没有移动构造函数,右值也将被拷贝。 —>拷贝也是一种形式的“移动”,并且几乎是安全的。

  • 移动右值,拷贝左值:一个类既有拷贝构造,又有移动构造,编译器会实现最优匹配

  • 如果没有移动构造函数,右值也将被拷贝。此种情况,即使通过std::move也会调用拷贝构造

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class Foo{
    public:
    Foo() = default;
    Foo(const Foo &); // 拷贝构造
    //未定义移动构造
    };

    Foo x;
    Foo y(x); // 拷贝构造
    Foo z(std::move(x)); // 拷贝构造,因为没有定义移动构造;此处会将 Foo&& 隐式转化为 const Foo&

13.4.3.4 三/五法则

所有五个拷贝控制成员应该看作一个整体:

一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。如前所述,某些类必须定义拷贝构造函数、拷贝赋值运算符和析构函数才能正确工作(参见13.1.4 节,第447页)。这些类通常拥有一个资源,而拷贝成员必须拷贝此资源。一般来说,拷贝一个资源会导致一些额外开销在这种拷贝并非必要的情况下,定义了移动构造函数和移动赋值运算符的类就可以避免此问题。

13.4.5 (基于)拷贝并交换(技术的)赋值运算符

即,添加了移动构造函数之后,类中使用拷贝并交换技术的重载赋值运算符既是拷贝赋值,也是移动赋值。具体是那种赋值方式依赖于operator=()的实参类型:拷贝初始化要么使用拷贝构造函数,要么使用移动构造函数——移动右值,拷贝左值。如此,一个operator=函数就实现了拷贝赋值和移动赋值两种功能。

1
2
3
4
5
6
7
8
9
class HasPtr{
public:
// 添加移动构造
HasPtr(HasPtr &&p)noexcept:ps(p.ps),i(p.i){p.ps=0;}
// 赋值运算符既是拷贝赋值,也是移动赋值
HasPtr &operator=(HasPtr rhs){
swap(*this,rhs); return *this;
}
};

参考书中的论述:

值得注意的是,标准库不保证哪些算法适用移动迭代器,哪些不适用。由于移动一个对象可能销毁掉原对象,因此你只有在确信算法在为一个元素赋值或将其传递给一个用户定义的函数后不再访问它时,才能将移动迭代器传递给算法。

联想:operator=的作用包括构造+析构——对左对象析构并将右对象拷贝(移动)到左对象。


13.4.4 移动迭代器

  • 解引用移动迭代器生成右值引用

  • 调用make_move_iterator()将一个迭代器转为移动迭代器:make_move_iterator(begin())

  • 移动迭代器支持正常的迭代器操作,我们可以将一对移动迭代器传递给算法。

  • 值得注意的是:

    值得注意的是,标准库不保证哪些算法适用移动迭代器,哪些不适用。由于移动一个对象可能销毁掉原对象,因此你只有在确信算法在为一个元素赋值或将其传递给一个用户定义的函数后不再访问它时,才能将移动迭代器传递给算法

13.5 右值引用和成员函数

13.5.1 重载拷贝版本和移动版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class StrVec{
public:
void push_back(const string&s){ // 拷贝:绑定到int
//...
alloc.construct(first_free++, s);
}
void push_back(string &&s){ // 移动:只能绑定到类型为int的可修改的右值
//...
alloc.construct(first_free++, std::move(s));
}
};

//-----------使用StrVec
StrVec vec;
string s = "a";
vec.push_back(s); // 调用拷贝的版本
vec.push_back("done"); //调用移动的版本:临时对象/常量调用右值的版本
  • 区分拷贝和移动的重载函数通常由一个版本接受一个const T&,而另一个版本接受一个T&&

13.5.2 引用限定符

对一个右值进行赋值居然是成立的:

1
2
string s1 = "a", s2 = "b";
s1 + s2 = "c"; // 对s1和s2的连接结果赋值

在新标准下可以阻止这一情况的发生:在参数列表后放置一个引用限定符

1
2
3
4
Class Foo{
public:
Foo &operator=(const Foo &) &;//只能向可修改的左值赋值
};
  • 引用限定符可以是&&&,指出this可以指向一个左值或一个右值;

  • 只能用于非static成员函数(类似const)

  • 必须同时出现在函数的声明和定义

  • 可以与const连用,引用限定符必须在const之后

    1
    2
    3
    4
    class Foo{
    public:
    Foo someM() const &; // 必须注意const和&的顺序
    };
  • 如果一个成员函数有引用限定符,则具有相同参数列表的所有参数版本都必须加引用限定符。(相同参数列表的重载版本要么都加,要么都不加引用限定符)

    就像一个成员函数可以根据是否有 const 来区分其重载版本一样(书:参见7.3.2节第247页),引用限定符也可以区分重载版本。而且,我们可以综合引用限定符和 const 来区分一个成员函数的重载版本。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class Foo{
    puiblic:
    Foo sorted() &&;
    //Foo sorted() const; // 错误
    Foo sorted() const &;

    Foo sorted(int *);
    Foo sorted(int *) const;
    };

    //----------------编译器会根据调用sorted的对象的左值/右值属性来确定使用哪个sorted版本
    retVal().sorted(); // retVal()是右值,调用&&版本
    retFoo().sorted(); // retFoo()是左值,调用const &版本

十四 重载运算与类型转换

14.1 基本概念

  • 除了重载的函数调用运算符operator()外,其他重载运算符不能含有默认实参

  • 对于一个运算符函数来说,它或是类的成员,或至少含有一个类类型的参数。—>当运算符作用于内置类型(如int)的运算对象,我们无法改变该运算符的含义

    1
    2
    //错误:不能为int重定义内置的运算符
    int operator+(int, int);
  • 可以直接调用重载的运算符函数

    1
    2
    3
    4
    5
    6
    7
    //1,等价
    data1+data2;
    operator+(data1, data2);

    //2,等价
    data1+=data2;
    data1.operator+=(data2);
  • 通常情况下,可以重载却不应重载:逗号、取地址、逻辑与/或(原因:重载版本无法保留求值顺序和/或短路求值属性,导致运算符不符合用户习惯)
    image-20240313162557121

  • 何时使用运算符重载?

    • 只有当操作的含义对于用户来说清晰明了时才可以重载运算符。如果用户对运算符可能有多种理解,重载运算符将产生二义性。
  • 使用与内置类型一致的含义:

    • operator==,也应有operator!=;有operator<,也应有其他关系运算符。
    • 有算符运算符或为运算符,最好也提供复合运算符。如有operator+,也应有operator+=,并用+=(复合)来实现+(算数)。
    • 重载运算符的返回类型通常情况下应该与其内置版本的返回类型兼容:
      • 逻辑运算符和关系运算符应该返回 bool,
      • 算术运算符应该返回一个类类型的值,
      • 赋值运算符和复合赋值运算符则应该返回左侧运算对象的一个引用
  • 作为成员还是非成员?
    image-20240314153519440

    1
    2
    3
    4
    // 例如
    string s = "w";
    string t = s+"hi"; // 等价s.operator+("hi");
    string u = "hi"+s; // 如果+是string的成员,则产生错误

    关于对称性的解释:如+,a+b==b+a —>对称性

    因为标准库中,string将+定义成了普通的非成员函数,所以”hi”+s等价于operator+(“hi”,s)。

    和任何其他函数调用一样,每个实参都能被转换成形参类型。唯一的要求是至少有一个运算对象是类类型,并且两个运算对象都能准确无误地转换成string。

14.2 输入和输出运算符

非成员

14.2.1 operator<<

  • 例子

    1
    2
    3
    4
    5
    6
    ostream &operator<<(ostream&os, const Sales_data &item){
    os<<item.isbn() << " "<< item.units_sold << " "
    << item.revenue << " " << item.avg_price();

    return os;
    }
  • operator<<应尽量减少格式化操作,更不应打印换行符。

  • operator<<必须是非成员函数,又因需要读写非公成员,一般设置为友元。

14.2.2 operator>>

  • 例子

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    istream &operator>>(istream &is, Sales_data &item){
    double price;
    is>>item.bookNo>>item.units_sold>>price;
    if(is) // 检查输入是否成功
    item.revenue = item.units_sold * price;
    else
    // 输入失败,对象被赋予默认状态
    // 当读取操作发生错误时,
    item = Sales_data();

    return is;
    }
  • operator>>必须处理输入可能失败的情况。利用if(is)检查是否输入成功。

    • 当流含有错误类型的数据时,读取操作可能失败:如输入的是int,读取的对象要求string
    • 当读取到达文件尾 或 遇到输入流的其他错误
  • operator>>也应该设置流的条件状态以标识出失败信息:
    • 通常情况下,只需设置failbit
    • 除此之外,设置eofbit表示文件耗尽,badbit表示流被破坏等

14.3 算数和关系运算符

  • 通常情况下,我们把算数和关系运算符定义成非成员函数以允许左侧或右侧的运算对象进行转换
  • 形参一般为常量的引用,const Sales_data &lhs

14.3.1 算数运算符

  • 最有效的方式是使用复合赋值来定义算数运算符,+= —> +

14.3.2 相等运算符

  • 相等运算符和不相等运算符中的一个应该把工作委托给另一个

14.3.3 关系运算符

  • 因为关联容器和一些算法要用到小于运算符,所以定义operator<会比较有用
  • 如果存在唯一一种逻辑可靠的<定义,则应该考虑为这个类定义<运算符;顺序关系应与关联容器中对关键字的要求一致。
  • 如果该类同时包含==,则当且仅当<的定义和==产生的结果一致时才定义<运算符;特别是,如果两个对象时!=的,那么一个对象应该<另外一个。(解释:P498-14.3.2-(第5段)尽管……)

14.4 赋值运算符

  • 赋值运算符必须定义成类的成员,复合赋值运算符通常情况下也应该这样做。这两类运算符都应该返回左侧运算对象的引用。
  • 赋值,除了移动赋值和拷贝赋值,还可接受元素列表

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    vector<string> v;
    v = {"a", "b", "c"};

    class StrVec{
    public:
    StrVec &operator=(std::initializer_list<std::string> il){
    auto data = alloc_n_copy(il.begin(), il.end()); // 分配空间并拷贝元素
    /*this->*/free(); // 释放当前对象的内存
    elements = data.first; // 更新数据成员使其指向新的空间
    first_free = cap = data.second;

    return *this;
    }
    }
    • 赋值运算符必须释放当前内存,在创建新内存。
    • 此处不同的是,不必检查自我赋值
  • 复合赋值运算符

    1
    2
    3
    4
    Sales_data &Sales_data::operator+=(const Sales_data &rhs){
    //...
    return *this;
    }

14.5下标运算符

  • operator[]必须是成员函数

  • 通常返回访问元素的引用

  • 通常同时定义常量版本和非常量版本:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    #include <iostream>

    class StrVec {
    public:
    std::string& operator[](std::size_t n) {
    return elements[0];
    }
    const std::string& operator[](std::size_t n) const {
    return elements[0];
    }

    std::size_t size() const { elements->size(); }

    private:
    std::string elements[];
    };


    int main()
    {
    StrVec svec;
    const StrVec& cvec = svec;
    if (svec.size() && svec[0].empty()) {
    svec[0] = "zero";
    //cvec[0] = "zero";//没有与这些操作数匹配的"="运算符.操作数类型为: const std::string = const char [5]
    }
    }

14.6 递增递减运算符

  • 建议是类的成员

  • 示例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    class StrBlobPtr {
    public:
    /* 前置 */
    // 如果显示调用则:p.operator++();
    StrBlobPtr& operator++(); // 返回递增或递减后对象的**引用**
    StrBlobPtr& operator--();

    /* 后置 */
    // 后置版本接受一个额外的(不被使用的)int类型的形参,该形参仅用于重载(用于区分前置和后置)
    // 如果显示调用则:p.operator++(0);
    StrBlobPtr operator++(int); // 应该返回对象的原值,返回的形式是一个**值**而非引用
    StrBlobPtr operator--(int);

    };

    StrBlobPtr& StrBlobPtr::operator++()
    {
    check(curr, "increment past end of StrBlobPtr.");
    // check的作用:
    // 1-检查StrBlobPtr是否有效
    // 2-索引值是否有效 -->curr已经到达vector末尾,则抛出异常

    ++curr;
    return *this;
    }

    StrBlobPtr& StrBlobPtr::operator--()
    {
    --curr;
    check(curr, "decrement past begin of StrBlobPtr.");
    // curr如果已经是0,`--`后将是一个表示无效下标的非常大的正数值
    return *this;
    }

    StrBlobPtr StrBlobPtr::operator++(int)
    {
    StrBlobPtr ret = *this;
    ++*this;
    return ret;
    }

    StrBlobPtr StrBlobPtr::operator--(int)
    {
    StrBlobPtr ret = *this;
    --*this;
    return ret;
    }

14.7 成员访问运算符

  • 常用语迭代器类及智能指针类

  • 箭头运算符(->)必须是类的成员,解引用运算符(*)通常是类的成员。

  • 这两个函数都是const函数,返回非const的引用或指针

  • 示例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class StrBlobPtr {
    public:
    /* 成员访问运算符 */
    std::string& operator*() const {
    auto p = check(curr, "dereference past end."); // 检查curr是否在合法范围内
    return (*p)[curr]; // *p为对象所指的vector
    }

    std::string* operator->() const {
    // 将实际工作交于operator*(),返回解引用结果的地址
    return &this->operator*();
    }

    };
  • 对箭头运算符返回值有限定,只能用于获取成员。

    重载的箭头运算符必须返回类的指针 或 自定义了箭头运算符的某个类的对象

    对于point->mem,只能有两种含义,具体取决于point的类型:

    • 如果point是一个指针类型,那么point->mem等价于(*point).mem,表示访问指针point指向的对象的mem成员。

    • 如果point是一个类类型的对象,并且该类重载了operator->,那么point->mem会调用point.operator->()来获取一个指针,然后再访问该指针的mem成员。

这样的机制允许类型设计者为其类对象提供类似于指针的接口,使得使用这些对象时能够有类似直接使用指针一样的语法和方便性。

14.8 函数调用运算符

14.8.1 基础

  • 必须是成员函数

  • 函数对象:对象的行为像函数一样。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    #include <iostream>

    struct absInt {
    int operator()(int val)const {
    return val < 0 ? -val : val;
    }
    };

    int main()
    {
    int i = -42;
    absInt absObj;
    int res = absObj(i);
    std::cout << res << std::endl; // 42
    }
  • 函数对象常常作为泛型算法的实参

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    #include <iostream>
    #include <vector>
    #include <string>
    #include <algorithm>
    using namespace std;

    class PrintString
    {
    public:
    PrintString(ostream& o = cout, char c = ' ')
    :os(o), step(c) {}
    void operator()(const string& s)const { os << s << step; }

    private:
    ostream& os;
    char step;
    };


    int main() {
    // 基本用法
    string s = "s";
    PrintString priterner;
    priterner(s); // s

    // 函数对象常常作为泛型算法的实参
    vector<string> vs{"a","b","c"};
    for_each(vs.begin(), vs.end(), PrintString(cerr,"\n")); // a b c
    // 首次时,PrintString()创建临时对象;
    // 之后for_each()的内部代码会调用这个对象(此时运行operator())以排序
    }


14.8.2 lambda表达式与operator()

一、无捕获行为的lambda

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[](const string &a, const string &b){
return a.size() < b.size();}
// 等价
class ShorterString{
public:
bool operator()(const string &s1, const string &s2) const
//默认情况下lambda不能改变它捕获的变量。
//因此在默认情况下,由lambda产生的类当中的函数调用运算符是一个const成员函数。
//如果lambda被声明为可变的,则调用运算符就不是const的了。
{
return s1.size() < s2.size();
}
};

/*于是有*/

stable_short(words.begin(), words.end(),
[](const string &a, const string &b)
{return a.size() < b.size();});
// 等价
stable_short(words.begin(), words.end(),ShorterString());
//第三个实参是新构建的Shorterstring对象,当stable_sort内部的代码每次比较两个string时就会“调用”这一对象,
//此时该对象将调用运算符的函数体,判断第一个string的大小小于第二个时返回 true。

二、有捕获行为的lambda

如我们所知,当一个lambda 表达式通过引用捕获变量时,将由程序负责确保lambda执行时引用所引的对象确实存在(参见10.3.3节,第350页)。因此,编译器可以直接使用该引用而无须在lambda产生的类中将其存储为数据成员。

相反,通过值捕获的变量被拷贝到lambda中(参见10.3.3节,第350页)。因此,这种lambda产生的类必须为每个值捕获的变量建立对应的数据成员,同时创建构造函数,令其使用捕获的变量的值来初始化数据成员。

如,

1
2
auto wc = find_if(words.begin(), words.end(),
[sz](const string& a) {return a.size() >= sz; });

lambda将产生如下类,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class SizeComp {
public:
SizeComp(std::size_t n) :sz(n) {}
bool operator()(const string& s) const
{
return s.size() >= sz;
}

private:
std::size_t sz;
};

//lambda表达式产生的类不含默认构造函数、赋值运算符及默认析构函数;
//它是否含有默认的拷贝/移动构造函数则通常要视捕获的数据成员类型而定

则find_if等价于,

1
auto wc = find_if(words.begin(), words.end(),SizeComp(sz));

14.8.3 标准库定义的函数对象

image-20240317120201949

  • 标准库(#include <functional>)定义了一组表示算数运算、关系运算符和逻辑运算符的类,每个类分别定义了执行命名操作的调用运算符。

  • 示例

    1
    2
    3
    4
    5
    plus<int> intAdd;
    int sum = intAdd(10,20); //sum=30

    negate<int> intNeg; // 取反
    int neg = intNeg(-10); // neg = 10
  • 在算法中使用标准库函数

    • 表示运算符的函数对象类常用来替换算法中的默认运算符

      1
      2
      // 默认使用operator<将序列升序,可做如下更改使其降序排列
      sort(svec.begin(), svec.end(), greater<string>); // 大于比较运算
    • 标准库规定其函数对于指针同样使用

      • 首先对于顺序容器,我们之前曾经介绍过比较两个无关指针将产生未定义的行为(书:参见3.5.3节,第107页),然而我们可能会希望通过比较指针的内存地址来排序指针的vector。直接这么做将产生未定义的行为,因此我们可以使用一个标准库函数对象来实现该目的:

        1
        2
        3
        4
        5
        6
        vector<string*> nameTab;
        // 错误,nameTab中的指针彼此没有关联,<将产生未定义行为
        sort(nameTab.begin(), nameTab.end(),
        [](string* a, string* b) {return a < b; }); // 妄图通过比较内存地址,类排序vector,将不会成功
        // 正确,标准库规定指针的less是定义良好的
        sort(nameTab.begin(), nameTab.end(), less<string*>());
      • 其次,关联容器可以直接排序指针,而无需显式地声明。

        关联容器使用 less<key_type>对元素排序,因此我们可以定义一个指针的set或者在map中 使用指针作为关键值而无须直接声明less。

14.8.4 可调用对象与function

  • C++语言中有几种可调用的对象:函数、函数指针、lambda表达式(书:参见10.3.2节,第 346页)、bind 创建的对象(书:参见10.3.4节,第354页)以及重载了函数调用运算符的类。

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // 如下可调用对象
    // 1
    int add(int i, int j){return i+j;}
    // 2
    auto mod = [](int i, int j){return i%j; };
    // 3
    struct divide{
    int operator()(int i, int j){
    return i/j;
    }
    };

    // 具有相同的调用形式
    int(int, int)
  • 使用map创建函数表用于存储可调用这些对象的“指针”

    1
    2
    3
    4
    5
    6
    map<string, int(*)(int, int)> binops;
    binops.insert({"+",add}); // {"+",add}:pair

    // 但是不能将mod和divide存入binops
    // 问题在于mod是个lambda表达式,而每个lambda有其自己的类型,该类型与存储在binops中的值的类型不匹配
    // 如何才能将mod和divide存入binops呢?使用function<T>
  • 使用function<T>

    • #include <functional>
      image-20240317135208426

    • 示例

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      // 1.函数
      int add(int i, int j) { return i + j; }
      // 2.lambda
      auto mod = [](int i, int j) {return i % j; };
      // 3.可调用对象
      struct divide {
      int operator()(int i, int j) {
      return i / j;
      }
      };

      int main(){
      function<int(int, int)> f1 = add; // 函数指针
      function<int(int, int)> f2 = divide(); // 函数对象类的对象
      function<int(int, int)> f3 = mod; // lambda

      cout << f1(4, 2) << endl; // 6
      cout << f2(4, 2) << endl; // 2
      cout << f3(4, 2) << endl; // 0

      // 构建map函数表
      map<string, function<int(int, int)>> binops = {
      {"+",add}, // 函数指针
      {"-",std::minus<int>()}, // 标准库中的函数对象
      {"/",divide()}, // 用户定义的函数对象
      {"*",[](int i, int j) {return i * j; }}, // 未命名的lambda
      {"%",mod} // 命名的lambda
      };
      cout << binops["+"](10, 5) << endl; // 15
      cout << binops["-"](10, 5) << endl; // 5
      cout << binops["/"](10, 5) << endl; // 2
      cout << binops["*"](10, 5) << endl; // 50
      cout << binops["%"](10, 5) << endl; // 0

      }
  • 但是我们不能不能直接将重载的函数名置于function<T>

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 1
    int add(int i, int j){return i+j;}
    // 2.重载add
    Sales_data add(const Sales_data &, const Sales_data &);

    map<string, function<int(int, int)>> binops;
    binops.insert({"+",add}); // 错误:哪一个add?

    // 两种方法解决该二义性问题
    • 将函数指针传入function,而不是直接传入重载的函数的名字

      1
      2
      int (*fp)(int, int) = add;
      binops.insert({"+",fp});
    • 用lambda消除二义性

      1
      binops.insert({"+",[](int i, int j){ return add(a,b);}});

14.9 重载、类型转换与运算符

14.9.1 类型转换运算符

1
operator type() const {};
  • type可以是void之外的任意类型;
  • type不允许转换成数组或者函数类型,但允许转换成指针(包括数组指针及函数指针)或引用类型。
  • type也是该函数的返回类型
  • 没有显式返回类型,type()中也不能写形参
  • 必须定义成类的成员函数
  • 一般定义成const(类型转换通常不改变转换对象的内容)

举例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class SmallInt{
public:
SmallInt(int i = 0):val(i){
if(i <0 || i > 255)
throw std::out_of_range("Bad SmallInt value.");
}
operator int() const { return val;}
private:
std::size_t val;
};

int main(){
SmallInt si;
si = 4; // 4被隐式转换为SmallInt,然后调用SmallInt::operator=
si + 3; // si被隐式转化为int,后执行整数加法
}

然而,隐式转换有时会带来一些问题(书P515底~516顶):

1
2
3
//operator bool()引发的问题
int i = 42;
cin << i;

上述代码的解释:cin没有定义<<,这段代码本应报错。然而,cin中使用了operator bool(),将cin隐式转化成了bool,bool又被提升为了int,并被作为内置左移运算符的左侧运算对象。最终,提升后的bool(0或1)被左移了42个位置。

因此在必要的时候,需要用explicit禁用隐式转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class SmallInt{
public:
SmallInt(int i = 0):val(i){
if(i <0 || i > 255)
throw std::out_of_range("Bad SmallInt value.");
}
explicit operator int() const { return val;}
private:
std::size_t val;
};

int main(){
SmallInt si;
si = 4; // 4被隐式转换为SmallInt,然后调用SmallInt::operator=
static_cast<int>(si) + 3; // si被显式转换
}

值得注意,在下列情况(表达式被用作条件时),显式类型转换将被隐式地执行:

  • if、while、do的条件部分
  • for语句头的条件表达式
  • ? :的条件表达式
  • !,||,&&

对于上文因为operator bool()引发的问题,将其定义为explicit后,只有在条件中,才会隐式转换:

1
while(std::cin >> value){}

对于此过程的解释:while 语句的条件执行输入运算符,它负责将数据读入到 value 并返回cin。为了对条件求值,cin被istream operator bool类型转换函数隐式地执行了转换。如果cin的条件状态是 good(参见8.1.2 节,第280 页),则该函数返回为真;否则该函数返回为假。

bool 的类型转换通常用在条件部分,因此operator bool一般定义成explicit

14.9.2 避免有二义性的类型转换

(:question:有些难以理解)

  • 不要在两个类之间构建相同的类型转换,反例如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    struct A{
    A() = default;
    A(const B&); // 以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());
    A a2 = f(A(b));
    //注意,不可用强制类型转换,因为其本身也会碰到二义性问题
  • 二义性与转换目标为内置类型的多重类型转换
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    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) ?

    short s = 42;
    A a3(s); // 使用A::A(int),把short提升为int的优先级 大于 short提升为double
  • 总结

image-20240318162313244

  • 重载函数与转换构造函数

    • 调用重载函数时,如果多个类型转换都提供了同一种可行的匹配,则这些类型转换优先级一样(书:这些类型转换一样好)
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      // 两个转换构造函数
      struct C {
      C(int);
      };

      struct D {
      D(int);
      };

      // 重载的manip()
      void manip(const C&);
      void manip(const D&);

      manip(10); // 二义性错误,manip(C(10))还是manip(D(10));
      manip(C(10)); // 正确,需要显式声明
  • 重载函数与用户定义的类型转换
    同时,这种情况也不会考虑任何可能出现的优先级

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    struct C {
    C(int);
    };

    struct E{
    E(double);
    };

    // 重载的manip()
    void manip2(const C&);
    void manip2(const E&);

    manip2(10); // 二义性错误,manip(C(10))还是manip(E(double(10))) ?
  • 只有当重载函数能通过同一个类型转换函数得到匹配时,我们才会考虑其中的类型转换(:question:同一类型转换指?原文书P520中间)

如果在调用重载函数时我们需要使用构造函数或者强制类型转换来改变实参的类型,则这通常意味着程序的设计存在不足。

14.9.3 函数匹配与重载的运算符

  • 当在表达式中使用重载的运算符时,无法判断在使用的是成员函数还是非成员函数,他们都在候选函数集中。

    1
    2
    3
    // a + b 可能是
    a.operator+(b);
    operator+(a,b);

    对比:调用一个命名函数时,具有该名字的成员函数和非成员函数不会彼此重载,因为他们的调用语法是不同的。

  • 如果我们对同一个类既提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会遇到重载运算符与内置运算符的二义性问题。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    class SmallInt {
    friend SmallInt operator+(const SmallInt&, const SmallInt&);

    public:
    SmallInt(int = 0) {}; // int -> SmallInt
    operator int() const { return val; } // SmallInt -> int

    private:
    std::size_t val;
    };



    SmallInt s1, s2;

    SmallInt s3 = s1 + s2; // 正确,使用重载的operator+

    int i = s3 + 0; // 二义性错误
    // 我们可以把0转换成SmallInt,然后使用SmallInt的+;
    // 或者把s3转换成int,然后对于两个int 执行内置的加法运算。

十五 面向对象程序设计

15.1 OOP:概述

oop核心思想:数据抽象、继承、动态绑定。(或者说:封装、继承、多态)

  • 数据抽象:类的接口与实现分离

  • 继承

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class Quote{
    public:
    std::string isbn() const;
    virtual double net_price(std::size_t n) const; // 虚函数
    };

    class Bulk_quote : public Quote{ // public继承,我们完全可以把Bulk_quote的对象当成Quote的对象来用
    public:
    double net_price(std::size_t n) const override; // 重写
    };
  • 动态绑定(或者说运行时绑定)

    • 函数的运行版本由实参决定
    • 当我们使用基类的引用、指针调用一个虚函数时,将引发动态绑定
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    double print_total(const Quote& item, size_t n){

    double ret = item.net_price(n);
    // 当item是Quote类型时调用Quote::net_price()
    // 当item是Bulk_quote类型时调用Bulk_quote::net_price()

    cout<<"isbn: "<<item.isbn()
    <<" sold: "<<n<<" total due: "<<ret<<endl;
    return ret;
    }

15.2 定义基类和派生类

15.2.1 定义基类

  • 基类通常都会定义一个虚析构函数,即使该函数不执行任何实际操作也是如此。

  • 任何构造函数之外的非静态函数,都可以是虚构函数

  • virtul只能出现在类内的声明语句之前,而不能用于类外部的函数定义

15.2.2 定义派生类

  • 继承列表中,访问说明符的作用是控制派生类从基类继承而来的成员是否对派生类的用户可见。

    如果一个派生是公有的,则基类的公有成员也是派生类接口的组成部分。此外,我们能将公有派生类型的对象绑定到基类的引用或指针上。因为我们在派生列表中使用了public,所以 Bulk_quote的接口隐式地包含 isbn 函数,同时在任何需要 Quote的引用或指针的地方我们都能使用Bulkquote的对象。

派生类中的虚函数

  • 覆盖虚函数:如果派生类没有覆盖其基类中的某个虚函数,则该虚函数的行为类似于其他的普通成员,派生类会直接继承其在基类中的版本。

  • override的位置:写在声明语句最后。

    在形参列表后面、或者在const成员函数的 const 关键字后面、或者在引用成员函数的引用限定符后面添加一个关键字override。

派生类对象及派生类向基类的类型转换

  • 因为在派生类对象中含有与其基类对应的组成部分,所以我们能把派生类的对象当成基类对象来使用,而且我们也能将基类的指针或引用绑定到派生类对象中的基类部分上
    image-20240319201317821

    1
    2
    3
    4
    5
    6
    7
    Quote item; // 基类对象
    Bulk_quote bulk; // 子类对象
    Quote *p = &item; // p指向Quote对象

    p = &bulk; // p指向bulk的Quote部分
    Quote &r = bulk; // p绑定到bulk的Quote部分
    // 以上两个转换被称为 派生类到基类的转换 --> 继承的关键所在

    这种隐式特性意味着我们可以把派生类对象或者派生类对象的引用用在需要基类引用的地方;同样的,我们也可以把派生类对象的指针用在需要基类指针的地方。

    • 基类指针指向派生类(的基类部分) —> 继承的关键所在(多态、动态绑定)

在派生类中构造基类

  • 每个类控制它自己的成员初始化过程

    尽管在派生类对象中含有从基类继承而来的成员,但是派生类并不能直接初始化这些成员。和其他创建了基类对象的代码一样,派生类也必须使用基类的构造函数来初始化它的基类部分。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class Bulk_quote : public Quote{
    public:
    Bulk_quote(const std::string& book, double p, std::size_t qty, double disc)
    :Quote(book, p) /* 通过初始化列表将实参传递给基类构造函数,否则基类部分会执行默认初始化 */
    , min_qty(qty), discount(disc){}
    private:
    std::size_t min_qty;
    double discount;
    };

在派生类中使用基类成员

  • 对于派生类,访问自己的成员和访问基类成员的方式一样。

    派生类的作用域嵌套在基类的作用域之内。因此,对于派生类的一个成员来说,它使用派生类成员的方式与使用基类成员的方式没什么不同。

  • 然而,即使访问方式一样,也不能直接初始化基类的成员。

    必须明确一点:每个类负责定义各自的接口。要想与类的对象交互必须使用该类的接口,即使这个对象是派生类的基类部分也是如此。 因此,派生类对象不能直接初始化基类的成员。尽管从语法上来说我们可以在派生类构造函数体内给它的公有或受保护的基类成员赋值,但是最好不要这么做。和使用基类的其他场合一样,派生类应该遵循基类的接口,并且通过调用基类的构造函数来初始化那些从基类中继承而来的成员。

继承与静态成员

:question:

派生类的声明

  • 派生类的声明中包含类名但不得包含其派生列表。

  • 派生列表及与定义有关的其他细节必须与类的主题一起出现。

  • 示例

    1
    2
    class Bulk_quote : public Quote; // 错误,派生列表不能出现在这里
    class Bulk_quote; // 正确,声明派生类的正确方式

被用作基类的类

  • 如果我们想将某个类用作基类,则该类必须已经定义而非仅仅声明。 — > 一个类不能派生其本身

  • 示例

    1
    2
    class Quote; // 声明但未定义
    class Bulk_quote : public Quote{}; // 错误,必须被定义Quote

防止继承的发生

  • 在类名后面加final,以阻止继承

  • 在函数后面加final,任何尝试覆盖该函数的操作都将引发错误。

    1
    2
    3
    4
    5
    6
    7
    struct D2:B{
    void f1(int) const final;
    };
    struct D3 :D2{
    void f2();
    void f1(int) const; //错误,D2已经声明为final
    };

15.2.3 类型转换与继承

派生类 —> 基类的隐式转换

  • 我们可以将基类的指针或引用绑定到派生类对象上。

  • 当使用基类的引用(或指针)时,实际上我们并不清楚该引用(或指针)所绑定对象的真实类型。该对象可能是基类的对象,也可能是派生类的对象(动态类型)。

  • 区分静态类型和动态类型

    • 静态类型:字面上要求的类型

    • 动态类型:多态、动态绑定的类型

  • 如果表达式既不是引用也不是指针,则其动态类型 == 静态类型。

但是不存在 基类 —> 派生类的隐式转换

  • 因为 这样有可能会访问 基类中本不存在的对象

    1
    2
    Quote base;
    Bulk_quote * bulkP = &base; // 错误,不能将基类转化为派生类
  • 即使一个基类指针或引用绑定在一个派生类对象上,也不能执行基类向派生类的转换。

    1
    2
    3
    Bulk_quote bulk;
    Quote *itemP = &bulk; // 正确,动态类型是Bulk_quote
    Bulk_qoute *bulk = itemP; // 错误,不能将基类转换成派生类(除非使用强制转换static_cast)

对象之间不存在类型转换(能转换,但是会丢失派生类中,基类没有的数据)

  • 具体表述:派生类向基类的自动类型转换只对指针或引用有效,在派生类类型和基类类型之间不存在这样的转换。

  • 原因:当我们用一个派生类对象为一个基类对象初始化或赋值时,只有该派生类对象中的基类部分会被拷贝、移动或赋值,它的派生部分被忽略掉(切掉)了。

    1
    2
    3
    Bulk_quote bulk;
    Quote item(bulk); // 使用Quote::Quote(const Quote&),只能构造派生类中的基类部分
    item = bulk; // 使用Quote::operator=(const Quote&),只能处理基类中自己的成员

15.3 虚函数

  • 我们必须为每一个虚函数提供定义,而不管他是否被用到
  • 对虚函数的调用可能在运行时才会被解析
    • 动态绑定只有当我们通过指针或引用调用虚函数时才会发生
    • 当通过普通类型(非指针非引用)的表达式调用虚函数,不会发生动态绑定,只会运行静态类型的函数
    • note
      image-20240323143031094
  • 派生类中的虚函数
    • 也是虚函数
    • 函数类型与基类一致(返回值类型、形参类型)。但是有一个例外情况:
      image-20240323144408065
  • final和override说明符

    • 使用override标记了某个函数,但该函数并没有覆盖已经存在的虚函数,编译器将报错
    • 在类名后面加final,以阻止继承
    • 在函数后面加final,任何尝试覆盖该函数的操作都将引发错误
  • 虚函数和默认实参

    • 如果虚函数使用了默认实参,则基类和派生类中定义的默认实参最好一致。

      和其他函数一样,虚函数也可以拥有默认实参(参见6.5.1节,第211页)。如果某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定。

      换句话说,如果我们通过基类的引用或指针调用函数,则使用基类中定义的默认实参,即使实际运行的是派生类中的函数版本也是如此。此时,传入派生类函数的将是基类函数定义的默认实参。如果派生类函数依赖不同的实参,则程序结果将与我们的预期不符。

  • 回避虚函数机制

    • 在某些情况下,希望虚函数的调用不要动态绑定:

      1
      double undiscounted = baseP->Quote::net_price(42); // 'Quote::'指明调用Quote的net_price(),而不管baseP的动态类型到底是什么
    • 什么情况下使用呢?通常是 当一个派生类的虚函数 调用 它覆盖的基类的虚函数版本 时。如果没有作用域运算符,将会被解析为派生类自身的调用,将导致无限递归。

15.4 抽象基类

  • 一个纯虚函数无须定义,在结尾处加上=0

  • 含有纯虚函数的类是抽象基类

    • 不能创建抽象基类的对象
    • 不重写纯虚函数的派生类也是抽象基类
  • 派生类构造函数只能初始化它的直接基类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    class Disc : public Quote{
    public:
    Disc() = default;
    Disc(const string &book, double price, size_t qty, double disc)
    :Quote(book, price), quantity(qty), discount(disc){}
    double net_price(size_t) const = 0;

    protected:
    size_t quantity = 0;
    double discount = 0.0;
    };

    class Bulk_quote : public Disc{
    Bulk_quote() = default;
    Bulk_quote(const string &book, double price, size_t qty, double disc)
    :Disc(book, price, qty, disc){} // <-- 初始化它的直接基类,而不是初始化Quote

    double net_price(size_t) const override;
    };

如前所述,每个类各自控制其对象的初始化过程。因此,即使Bulk_quote没有自己的数据成员,它也仍然需要像原来一样提供一个接受四个参数的构造函数。该构造函数将它的实参传递给Disc_quote的构造函数,随后Disc_quote的构造函数继续调用Quote的构造函数。Quote的构造函数首先初始化bulk的bookNo和price 成员,当Quote的构造函数结束后,开始运行Disc_quote的构造函数并初始化 quantity 和discount成员,最后运行Bulk_quote的构造函数,该函数无须执行实际的初始化或其他工作。

15.5 访问控制与继承

  • 参考

  • C++中的public、protect、private三个关键字既可以用于访问控制,也可以用于控制继承方式。

    • 参考

    • 访问控制
      image-20240325165736001

      大致解释就是:

      1. 首先类本身对于自己的所有成员肯定是有访问权限的,无论是public、protect还是private
      2. 类的实例化对象只对类的public成员有访问权限
      3. protect相比private成员的特别之处在于,protect对于派生类是可访问(或可见)的,而private成员在派生类中不可见。

      对于第三点,换句话说就是,只有父类的public、protect成员可以被子类继承,private成员子类压根是看不到的。至于父类的public、protect成员继承到子类后,是什么样子,这还要看下面的继承方式了。

  • 控制继承方式(继承方式的作用不影响派生类对基类的访问,而是影响派生类的用户——见派生说明符)
    image-20240325165814776
  • 派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问权限。

    • 参考

    • 理解下面代码

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      class Base {
      Base() {
      a = 10;
      }
      protected:
      int a;
      };

      class Derived : Base {
      Base baseObject;

      void foo() {
      a = 11; // target object is self (kind of Derived)
      baseObject.a = 12; // target object is kind of Base
      }
      };

      注意在Derived 的 foo() 的方法里,第一句是可以编译通过的,第二句不行,会报错

      这正是因为,第一句

      1
      a = 11

      操作的对象是Derived类型,因此他可以看到从Base继承下来的protected a成员

      相对,第二句

      1
      baseObject.a = 12

      是不行的,因为他的操作对象明确地是一个与自身self无关的另一个Base类型对象,其a成员是不可见的。

    • 例2

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      class Base{
      protected:
      int prot_mem;
      };

      clas Sneaky : public Base{
      //正确,派生类的成员和友元 访问 派生类对象中的基类部分 的受保护成员
      friend void clobber(Sneaky &s){
      s.j =
      s.prot_mem = // 派生类对象中的基类部分
      0;};
      //错误,操作对象明确地是一个与Sneaky自身无关的另一个Base类型对象
      friend void clobber(Base &b){b.prot_mem = 0;};
      int j;
      };
  • 综之,派生类的成员和友元只能访问派生类对象中的基类部分的受保护成员;对于普通的基类对象中的成员不具有特殊的访问权限。 <—其实很好理解,感觉书上写复杂了。
  • 派生说明符

    • 不影响派生类成员(及友元)访问其直接基类
    • 作用是控制派生类用户(包括派生类的派生类)对于基类成员的访问权限
  • 派生类向基类转换的可访问性
    派生类向基类的转换 —> 基类的指针(引用)指向派生类的对象

    • 参考

    • 书中原文

      派生类向基类到转换是否可访问由使用该转换到代码决定,同时派生类到派生访问说明符也会有影响。假定D继承自B:

      • 只有当D公有继承B时,用户代码才能使用派生类向基类到转换;如果D继承B的方式时受保护的或者私有的,则用户代码不能使用该转换。

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        #include <iostream>
        #define _COUT(str) std::cout<<str<<" ";
        #define _COUTL(str) std::cout<<str<<std::endl;

        class A{
        public:
        virtual void print()
        {
        _COUTL("我是A");
        }
        };

        class B :public A
        {
        public:
        void print()
        {
        _COUTL("我是B 继承A");
        }
        };

        class C : private A
        {
        public:
        void print()
        {
        _COUTL("我是C 继承A");
        }

        };

        int main()
        {
        A *p;
        B b;
        C c;

        p = &b;
        p = &c;// error:Cannot cast 'C' to its private base class 'A'
        p->print();

        }
      • 不论D以什么方式继承B,D的成员函数和友员函数都能使用派生类向基类的转换;派生类向其直接基类的类型转换对于派生类的成员和友员而言永远是可访问的。

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        class B{
        };
        class D:public B{
        void function(D &d){
        B b = d; // 派生类向基类的转换
        }
        friend void friendFunction(D &d)
        {
        B b = d;
        }
        };
        class E:protected B{
        void function(E &e){
        B b = e;
        }
        friend void friendFunction(E &e)
        {
        B b = e;
        }
        };
        class F:private B{
        void function(F &f){
        B b = f;
        }
        friend void friendFunction(F &f)
        {
        B b = f;
        }
        };
      • 如果D继承B 的方式是公有的或者受保护的,则D的派生类的成员和友员可以使用D向B 的类型转换;反之,如果D继承B 的方式时私有的,则不能使用

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        45
        46
        47
        48
        class D:public B{
        void function(D &d){
        B b = d; // 使用D向B 的类型转换
        }
        friend void friendFunction(D &d)
        {
        B b = d;
        }
        };
        class E:protected B{
        void function(E &e){
        B b = e;
        }
        friend void friendFunction(E &e)
        {
        B b = e;
        }
        };
        class F:private B{
        void function(F &f){
        B b = f;
        }
        friend void friendFunction(F &f)
        {
        B b = f;
        }
        };
        class G : D{
        void function(D &d){
        B b = d; //D的派生类的成员和友员可以使用D向B 的类型转换
        }
        };

        class H : E{
        void function(E &e){
        B b = e;
        }
        };

        class I : F{
        void function(F &d){
        B b = f; //error:'B' is a private member of 'B'
        }
        friend void friendFunction2(F &f)
        {
        B b = f; //error:'B' is a private member of 'B'
        }
        };
tips:对于代码中的某个给定节点来说,如果基类的公有成员是可访问的,则派生类向基类的类型转换也是可访问的;反之则不行。

不考虑继承的话,我们可以认为一个类有两种不同的用户:普通用户和类的实现者.

其中,普通用户编写的代码使用类的对象,这部分代码只能访问类的公有(接口)成员;实现者则负责编写类的成员和友元的代码,成员和友元既能访问类的公有部分,也能访问类的私有(实现)部分。
如果进一步考虑继承的话就会出现第三种用户,即派生类。基类把它希望派生类能够使用的部分声明成受保护的。普通用户不能访问受保护的成员,而派生类及其友元仍旧不能访问私有成员。
和其他类一样,基类应该将其接口成员声明为公有的;同时将属于其实现的部分分成两组:一组可供派生类访问,另一组只能由基类及基类的友元访问。对于前者应该声明为受保护的,这样派生类就能在实现自己的功能时使用基类的这些操作和数据;对于后者应该声明为私有的。

  • 友元和继承
    友元不能传递,也不可继承

    • 但是,基类中的友元,可以访问派生类中的基类部分

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      class Base
      {
      friend class Pal;

      protected:
      int prot_mem;
      };

      class Sneaky : public Base {
      int j;
      };

      class Pal {
      public:
      int f(Base b) { return b.prot_mem; }
      int f2(Sneaky s) { return s.j; } // 错误,Pal不是Sneaky的友元
      int f3(Sneaky s) { return s.prot_mem; } // 却可以访问派生类中的基类部分
      };
    • 友元关系不可继承

      1
      2
      3
      4
      5
      6
      class D2 : public Pal {
      public:
      int mem(Base b) {
      return b.prot_mem; // 错误,成员prot_mem不可访问
      }
      };
  • 利用using改变个别成员的了访问性

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    class Base
    {
    public:
    int size() const { return n; }

    protected:
    int n;
    };

    class Der : private Base { // 注意private继承
    public:
    using Base::size;
    protected:
    using Base::n;
    };
  • 默认的继承保护

    • struct:公有继承,公有访问
    • class:私有继承,私有访问

15.6 继承中的类作用域

15.6.1 本节主要讲了成员的隐藏和覆盖

  • 每个类定义自己的作用域,在定义域内定义自己的成员。派生类的作用域嵌套在基类的作用域之内。如果一个名字在派生类的作用域内无法完成解析,则编译器将继续在外层的基类作用域中寻找该名字。

  • 一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的

    • 我的理解是:即,基类指针指向派生类的基类部分,只能访问到基类成员
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class D : public Q{
    public:
    int discount() const{}
    };

    D d;

    D *dp = &d; // 静态类型和动态类型一致
    dp->discount(); // 正确

    Q *qp = &d; // 静态类型和动态类型不一致
    qp->discount(); // 错误
    // 即 基类指针指向派生类的基类部分,只能访问到基类成员 的原理。
    // 虚函数除外,基类指针调用虚函数,实际上会调用派生类的实现

对于普通的成员而言:

  • 派生类也能 重用定义在 直接基类或间接基类 中的名字,此时 定义在外层作用域(即基类)的名字 被 定义在内层作用域(即派生类)的名字 隐藏
    • 派生类的成员将隐藏同名的基类成员,即使形参列表不一致(而不是重载 — > 内层作用域函数不会重载外层作用域,而是隐藏)
      • 如果非要用被隐藏的成员,则加上作用域运算符::,指定是那个类的成员
      • 名字查找先于类型检查
    • 除了覆盖继承而来的虚函数之外,派生类最好不要重用定义在其他基类中的名字。

image-20240329153733093

对于虚函数:

  • 基类与派生类的相应虚函数接受的实参必须相同

15.6.2 派生类覆盖基类的重载的函数

参考:派生类覆盖基类的重载的函数——mybright_

  • 和其他函数一样,类的成员函数不论是否是虚函数都可以被重载(重载的发生需要是在同一作用域)。
    然而,派生类一旦声明了一个和基类重载函数同名的函数,派生类将会覆盖基类的所有重载函数,也就是说派生类可以覆盖基类重载函数的0个或全部个实例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    class Base
    {
    public:
    void func() { printf("Base func()\n"); };
    void func(int a) { printf("Base func(int a)\n");}
    };

    class D1 : public Base
    {
    public:
    void func(string& str) { printf("D1 func(string& str)\n"); }
    };

    class D2 : public Base
    {
    public:
    // 派生类将会覆盖基类的所有重载函数
    void func(int str) { printf("D2 func(int str)\n"); } // 覆盖了func()和func(int a)
    };

    int main(void)
    {
    D1 d1;
    d1.func(); // 报错,基类的func()函数已被隐藏
    d1.func(2); // 报错,基类的func(int)函数已被隐藏

    D2 d2;
    d2.func(); // “D1::func”: 函数不接受 0 个参数 --> 覆盖了基类func()和func(int a),只剩下重写的D2::func(int str)
    d2.func(2); // 正确

    return 0;
    }

    显然,派生类D1的func(string& str)函数把基类Base中所有名为”func”的函数都隐藏了,要想通过D1调用func()和func(int)函数,要么在D1类中重新定义函数,要么使用using关键字将Base类中所有”func”成员包含到派生类中

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    class D1 : public Base
    {
    public:
    using Base::func; //将基类的func所有重载实例到包含到D1中
    void func(string& str) { printf("D1 func(string& str)\n"); }
    };

    int main(void)
    {
    D1 d1;

    d1.func();
    d1.func(2);

    return 0;
    }

    using声明语句指定一个名字而不指定形参列表,所以一条基类成员函数的using声明语句就可以将函数的所有重载实例添加到派生类作用域中。此时,派生类只需要定义自身特有的函数,不需要为继承而来的其他函数重新定义。这样,外界对派生类没有重新定义的重载函数的访问实际上是对using声明点的访问。

需要注意的是,using关键字还能改变基类的该成员函数在派生类中的访问级别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Base
{
protected:
void func() { printf("Base func()\n"); }
void func(int a) { printf("Base func(int a)\n");}
};

class D1 : public Base
{
public:
using Base::func; //将原本是protected属性改为public
void func(string& str) { printf("D1 func(string& str)\n"); }
};

int main(void)
{
D1 d1;

d1.func();
d1.func(2);

return 0;
}

15.7 构造函数和拷贝控制

15.7.1 虚析构函数

delete一个 指向子类对象的基类指针,编译器必须清楚它应执行的子类的析构函数。如何让编译器知道?

  • 将基类的析构函数定义为虚函数(虚析构函数)

    如果我们删除的是一个指向派生类对象的基类指针,则需要虚析构函数

  • 如果基类中的析构函数不是虚函数,则delete指向子类对象的基类指针,将产生未定义行为。

  • 显式(虚)析构函数阻止合成移动操作

15.7.2 合成的拷贝控制与继承

构造和析构的传递顺序

  • 构造、拷贝构造从基类向派生类传递
  • 析构函数从派生类开始,传递到基类

拷贝构造与继承

  • 基类构造、拷贝、移动、析构不可访问/删除 —导致—> 派生类也不可访问/删除对应的函数
  • 基类析构不可访问/删除 ——> 派生类合成的默认和拷贝构造是删除的。
  • 基类析构不可访问/删除 ——> 派生类的移动构造也是被删除的。

在实际编程过程中,如果在基类中没有默认、拷贝或移动构造函数,则一般情况下派生类也不会定义相应的操作。

补充:

  • 类没有定义移动操作,实际使用拷贝操作来移动。
  • 定义了拷贝构造,将不会有合成的移动构造

移动构造与继承

  • 虚析构将阻止合成移动 ——> 导致派生类也没有移动
  • 派生类想要移动,基类必须显式地定义移动和拷贝
1
2
3
4
5
6
7
8
9
class Quote{
public:
Quote() = default;
Quote(const Quote&) = default;
Quote(Quote &&) = default;
Quote& operator=(const Quote&) = default;
Quote& operator=(Quote&&) = default;
virtual ~Quote() = default;
};

15.7.3 派生类的拷贝控制成员

派生类的拷贝或移动构造函数

在默认情况下,基类默认构造函数初始化派生类对象的基类部分。

如果我们想拷贝(或移动)基类部分,则必须在派生类的构造函数初始值列表中显式地使用基类的拷贝(或移动)构造函数

否则,将出现不合常理的现象:基类部分被默认初始化,而派生类部分被拷贝。

1
2
3
4
5
6
7
8
class Base{};
class D :public Base{
public:
D(const D &d):Base(d) // 拷贝基类成员
/*D的成员初始化*/{}
D(D&& d):Base(std::move(d)) // 移动基类成员
/*D的成员初始化*/{}
};

派生类的拷贝或移动赋值 同理

1
2
3
4
5
6
D &D::operator=(const D &rhs){
Base::operator=(rhs); // 为基类部分赋值
//为派生类成员赋值
//酌情处理自赋值及释放已有资源
return *this;
}

派生类析构函数 隐式 调用基类析构函数,不必显式声明

  • 派生类的析构函数仅需 析构派生类自己分配的资源
1
2
3
4
5
class D : public Base{
public:
// Base::~Base()被自动调用
~D(){ /* 析构派生类自己分配的资源 */ }
};

在构造函数和析构函数中调用虚函数

  • 如果构造函数/析构函数内调用了某个虚函数,则我们应该执行与构造函数/析构函数所属类型相对应(能否理解为“相同”?)的虚函数版本。

  • 原因:

    如果 基类的构造函数 调用了 派生类的虚函数,又因为派生类的虚函数必定访问派生类独有的成员。

    想象一种情况:当创建一个派生类对象时,首先 会构建 自身的基类部分 ,然而基类构造函数通过派生类虚函数访问了派生类独有成员,但是派生类独有成员尚未开始构建,引起程序崩溃。

15.7.4 继承的构造函数

  • 一个类只能初始化它的直接基类。出于同样的原因,一个类也只能继承其直接基类的构造函数。
  • 类不能继承默认、拷贝和移动构造函数。如果派生类未定义这些函数,编译器将为其合成。
  • 派生类如何继承基类的构造函数?
    利用一条注明(直接)基类名的using语句,显式说明派生类继承了直接基类的构造函数。此情况下的using,会生成一系列代码。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class Base{
    public:
    Base(){}
    };

    class D : public Base{
    public:
    using Base::Base; // 显式说明派生类继承了直接基类的构造函数
    };

    这样,对于基类的每个构造函数,编译器都会生成一个与之对应的派生类构造函数。对应构造函数之间,形参列表完全相同。

    生成的派生类构造函数形如

    1
    derived(params) : base(args) {}

    其中,derived是派生类的名字,base是基类的名字,parms是构造函数的形参列表,args将派生类构造函数的形参传递给基类的构造函数。

  • 继承的构造函数的特点

    • using不会改变派生类继承而来的构造函数的“属性”,包括:

      • 访问权限
      • explicit
      • constexpr
    • 不会继承默认实参

      当一个基类构造函数含有默认实参(参见6.5.1节,第211页)时,这些实参并不会被继承。相反,派生类将获得多个继承的构造函数,其中每个构造函数分别省略掉一个含有默认实参的形参。

      例如,如果基类有一个接受两个形参的构造函数,其中第二个形参含有默认实参,则派生类将获得两个构造函数:一个构造函数接受两个形参(没有默认实参),另一个构造函数只接受一个形参,它对应于基类中最左侧的没有默认值的那个形参。

    • 如果基类含有几个构造函数,则除了两个例外情况,大多数时候派生类会继承所有这些构造函数

      • 第一个例外是派生类可以继承一部分构造函数,而为其他构造函数定义自己的版本。如果派生类定义的构造函数与基类的构造函数具有相同的参数列表,则该构造函数将不会被继承。定义在派生类中的构造函数将替换继承而来的构造函数。

      • 第二个例外是默认、拷贝和移动构造函数不会被继承。这些构造函数按照正常规则被合成。继承的构造函数不会被作为用户定义的构造函数来使用,因此,如果一个类只含有继承的构造函数,则它也将拥有一个合成的默认构造函数(理解如下)。

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        #include <string>
        #include <iostream>
        using namespace std;
        class B {
        public:
        B() { cout << "B default" << endl; } // 默认构造,不会被继承

        /*>>>将被继承*/
        B(const int&) { cout << "B int" << endl; }
        B(const string&) { cout << "B string" << endl; }
        /*<<<*/
        };

        class D : public B {
        public:
        using B::B; // 只用于继承的构造函数,则其默认构造是自身合成的

        //D(const int &i):B(i){}
        //D(const string &i):B(i){}
        int i = 0;
        };

        void main() {
        D d1(1); //输出 B int
        D d2(); //输出 B default -->d2是一个函数声明
        D d3; // 什么都没有 --> 证明D自身合成了一个默认构造

        cout << d1.i << endl;
        //cout << d2.i << endl; // 错误
        cout << d3.i << endl;

        d
        }

        关于上述代码,d2和d3的区别,AI的解释如下:

        D d2();D d3; 这两行代码在 C++ 中有着非常不同的含义,它们之间的区别主要体现在对象的声明与构造方式上。

        1. D d2();:这行代码看起来像是在声明并调用一个名为 d2D 类型的对象的默认构造函数,但实际上这是一个函数声明,而不是对象的创建。它声明了一个返回类型为 D,名字为 d2,且不带任何参数的函数。这是一个常见的误区,称为“最令人困惑的解析”(Most Vexing Parse)。因此,D d2(); 并不会创建一个类型为 D 的对象。
        2. D d3;:这行代码实际上声明了一个名为 d3D 类型的对象,并通过调用默认构造函数来初始化它(假设 D 有默认构造函数或能够通过继承等方式获得一个)。这是对象声明和初始化的标准方式之一,如果 D 类型可默认构造(即存在无参的构造函数或所有参数都有默认值的构造函数),那么这个声明会创建一个 D 类型的实例。
          总结来说,D d2(); 实际上是一个函数声明,而 D d3; 是一个对象的声明并调用了默认构造函数来创建这个对象。如果你的意图是创建一个类型为 D 的对象,应该使用 D d3; 的形式。

        在ide中,对于d2和d3显色的差别也可证明AI的解释,d1、d3是对象,d2是函数
        image-20240331192752208

15.8 容器和继承

  • 使用容器存放继承体系中的对象时,应在容器中放置(智能)指针而非对象

    • 原因:容器中不能报错不同类型的元素,如果传递派生类,只会保存其基类部分。通过放置基类指针以使用继承关系。

    • 示例

      1
      2
      3
      4
      5
      6
      7
      8
      vector<share_ptr<Quote>> basket;

      basket.push_back(make_shared<Quote>("0123",50));

      basket.push_back(make_shared<Bulk_quote>("0123",50,10,.25));
      // push_back时,shared_ptr<Bulk_quote> -转换-> shared_ptr<Quote>

      cout<<basket.back()->net_price(15);
  • 由上,我们无法直接使用对象,而必须使用指针/引用,势必增加程序的复杂性,因此需要编写一些辅助函数。(书P559~562,编写Basket类)略

15.9 文本查询程序再探

书P562~574,略

十六 模板与泛型编程

16.1 定义模板

16.1.1 函数模板

>>>按照模板参数分类,两种类别>>>

  • 类型模板参数,即模板参数是一种类型

    1
    2
    3
    4
    5
    6
    template <typename T, typename U/*多个模板参数的写法*/>  /*<--模板定义中,模板参数列表不能为空*/
    int compare(const T &v1, const T &v2){
    if(v1 < v2) return -1;
    if(v2 < v1) return 1;
    return 0;
    }
    • <typename T> 模板参数列表
    • typename T 模板参数
    • 在使用模板时,指定模板实参
    • 编译器用推断出的模板参数来实例化一个特定版本的函数;这些生成的函数版本被称为模板的实例
  • 非类型模板参数。模板参数表示一个值而非一个类型

    • 通过特定的类型名来指定非类型参数(而非class、typename)

    • 当模板被实例化时,函数的参数必须是常量表达式。以使编译器在编译阶段推断类型和实例化模板。

    • 示例

      1
      2
      3
      4
      5
      6
      7
      8
      9
      template <unsigned N, unsigned M>
      int compare(const char (&p1)[N], const char (&p2)[M]){
      return strcmp(p1,p2);
      }

      // 调用
      compare("hi", "mom");
      // 推断结果如下-->
      int compare(const char (&p1)[3], const char (&p2)[4]); // 包含'\0'
    • 一个非类型参数可以是一个整型,或者一个指向对象或函数类型的指针或(左值)引用。

      • 整型参数的实参必须是一个常量表达式

        在需要常量表达式的地方,可以使用非类型参数。如指定数组大小。

      • 绑定到指针或引用非类型参数的实参必须具有静态的生存周期

>>>

  • inline和constexpr的函数模板:放在模板参数列表之后,返回类型之前
    1
    2
    template <typename T> 
    inline T min(const T&, const T&);
  • 编写类型无关的代码

    • 编写泛型代码的两个重要原则
      • 模板中的函数参数是const的引用。保证函数可以用于不能拷贝的类型,如unique_ptr、io等
      • 函数体中的条件判断仅使用<。降低了函数对要处理的类型的要求,这些类型必须支持<,但不必同时支持>
  • 模板编译

    • 模板的头文件通常既包含声明也包含定义
    • 大多数编译错误在实例化期间报告

16.1.2 类模板

  • 通常写法
    1
    2
    3
    4
    template <typename T>
    class Blob{
    //...
    };
  • 实例化类模板

    1
    Blob<int> ia;

    编译器会实例化出特定的类

    1
    2
    3
    4
    template <> class Blob<int>{
    // T --> int
    //...
    };

    类模板的名字不是类型名,实例化出的才是一个类类型。

  • 定义在类模板之外的成员函数必须以template开始,后接模板参数列表:

    1
    2
    template<typename T>
    ret-type Blob<T>::member-name(parm-list){}
  • 定义在类模板之外的构造函数

    1
    2
    3
    4
    5
    6
    7
    template <typename T>
    Blob<T>::Blob() : data(std::make_shared<std::vector<T>>){}

    template <typename T>
    Blob<T>::Blob(std::initializer<T> li) : data(std::make_shared<std::vector<T>>(li)){}
    //则
    Blob<string> articles = {"a","b","c"}; // 用列表初始化std::initializer
  • 类模板 成员函数的 实例化

类模板的构造函数

16.1.3 模板参数

16.4.4 成员模板

16.2 模板实参推断

16.3 重载与模板

16.4 可变参数模板

16.5 模板特例化

------高级主题------

十七 标准库特殊设施

17.1 tuple

image-20240408105517815

17.1.1 三种初始化方式

  • 默认初始化
    1
    tuple<size_t, size_t, size_t> threeD; // 三个成员都默认初始化为0
  • 直接初始化

    1
    tuple<size_t, size_t, size_t> threeD{1,2,3};

    注意,tuple的构造函数时explicit的,必须使用上述直接初始化方法,而不是用下面的方式初始化

    1
    tuple<size_t, size_t, size_t> threeD = {1,2,3}; // 错误
  • make_tuple

    1
    auto threeD = make_tuple(1,2,3);

17.1.2 访问tuple

  • 获取tuple的元素
    使用get<n>(tuple-name)访问tuple_name(tuple对象名)的第n(整型常量表达式)个元素,返回指定成员的引用。

    1
    auto element = get<0>(threeD); // 访问threeD的第0个元素
  • 查询tuple的成员数量
    tuple_size<tuple-name>::value

    1
    size_t sz = tuple_size<threeD>::value; // 返回3
  • 查询tuple的成员类型
    tuple_element<n,tuple-name> tuple-name中第n个元素的类型

    1
    tuple_element<0,threeD> cnt; // cnt的类型为size_t

17.1.3 关系和相等运算符

  • 只有当两个tuple的成员数量(先看)和类型(后看)相同,才能比较
    image-20240408113102995

17.1.4 tuple的作用

tuple的常见用途之一是从一个函数返回多个值

1
2
3
4
5
6
typedef tuple<vector<Sales_data>::size_type,
vector<Sales_data>::const_iterator,
vector<Sales_data>::const_iterator> matches;

vector<matches>
findbooks(const vector<vector<Sales_data>> &files, const string &book){}

如何使用从上述函数返回的tuple

1
2
3
4
5
6
//...
for(const auto &store : trans){
os<<get<0>(store)
<<get<1>(store)
<<get<2>(store);
}

17.2 bitset

在头文件bitset

需要指定大小(类似array<>),以声明包含多少个二进制位

image-20240428104750717

示例:

1
2
3
4
5
6
7
8
9
10
bitset<32> bitvec(1U); // 32位;低位为1,其他位为0
bitset<13> bitvec(0xbeef); // 1111011101111,高位被丢弃
bitset<20> bitset(0xbeef); // 00001011111011101111,高位补0

bitset<32> bitset("1100"); // 从string或者字符数组指针来初始化
// 要记住string的下标编号习惯于bitset相反

string str("1111111000000011001101");
bitset<32> bitset(str,5,4); // 取str[5]开始的4个二进制位,即1100作为低四位,高位补0
bitset<32> bitset(str,str.size()-4); // 使用str最后四个字符作为低四位,高位补0

image-20240428110605972

  • 函数size是一个constexpr函数,因此可以用在要求常量表达式的地方
  • 当使用to_ulongto_ullong时,只有当bitset大小小于等于目标大小才行,否则报overflow_error

17.3 正则表达式

暂时跳过

17.4 随机数

c++中不建议用rand,而应该使用default_random_engine

17.4.1 随机数引擎和随机数分布的基本用法

#include <random>

  • 引擎 类型,生成随机unsigned整数序列
  • 分布 类型,使用引擎返回服从特定概率分布的随机数

随机数引擎和随机数分布

  • 随机数引擎是函数对象类;定义了operator(),不接受参数并返回一个随机unsigned整数。

  • 随机数分布也是一个函数类;定义了operator(),接受一个随机数引擎作为参数

  • 用法示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    /*输出随机数*/
    #include <iostream>
    #include <random>
    using namespace std;

    void main() {
    default_random_engine e;
    unsigned n = 10;
    while (n--)
    cout << e() << " ";
    }
    // 输出
    // 3499211612 581869302 3890346734 3586334585 545404204 4161255391 3922919429 949333985 2715962298 1323567403



    /* 如果想要指定随机数范围,则需要使用随机数分布 */
    void main() {
    default_random_engine e;
    uniform_int_distribution<unsigned> u(0, 9); // 指定[0,9]的随机数范围
    unsigned n = 10;
    while (n--)
    cout << u(e) << " "; // <--必须是u(e)
    }
    //输出:8 1 9 8 1 9 9 2 6 3

  • 特别注意的,

    1.对于大多数场合,随机数引擎的输出是不能直接使用的,这也是为什么早先我们称之为原始随机数。问题出在生成的随机数的值范围通常与我们需要的不符,而正确转换随机数的范围是极其困难的。

    2.注意,我们传递给分布对象的是引警对象本身,即u(e)。如果我们将调用写成u(e()),含义就变为将e生成的下一个值传递给u,会导致一个编译错误。我们传递的是引擎本身,而不是它生成的下一个值,原因是某些分布可能需要调用引擎多次才能得到一个值。

  • 随机数引擎操作
    image-20240425181412655
    其中Engine可替换为如下随机数引擎,不同的随机数引擎区别在于性能与随机性质量不同:
    image-20240425181728467

  • 随机数分布

image-20240426113005086

202404252032541

17.4.2 随机数固定的数组序列

一个给定的随机数发生器一直会生成相同的随机数序列。有两种方法控制生成不同的随机数:

1.一个函数如果定义了局部的随机数发生器,应该将其(包括引警和分布对象)定义为static的。否则,每次调用函数都会生成相同的序列。

2.设置随机数种子(seed)

将引擎和分布对象定义为static

观察如下示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <iostream>
#include <random>
using namespace std;

vector<unsigned> bad_randVec() {
default_random_engine e;
uniform_int_distribution<unsigned> u(0, 9);
vector<unsigned> ret;
unsigned n = 100;
while (n--)
{
ret.push_back(u(e));
}
return ret;
}

vector<unsigned> good_randVec() {
static default_random_engine e; // 引擎设置为static
static uniform_int_distribution<unsigned> u(0, 9); // 分布也设置为static
vector<unsigned> ret;
unsigned n = 100;
while (n--)
{
ret.push_back(u(e));
}
return ret;
}


void main() {
// v1和v2是相同的序列
vector<unsigned> v1(bad_randVec());
vector<unsigned> v2(bad_randVec());
cout << ((v1 == v2) ? "equal" : "not equal") << endl; // equal

// v3和v4是不同的序列
vector<unsigned> v3(good_randVec());
vector<unsigned> v4(good_randVec());
cout << ((v3 == v4) ? "equal" : "not equal") << endl; // not equal

}

原理:由于e和u是static的,因此它们在函数调用之间会保持住状态。第一次调用会使用u(e)生成的序列中的前100个随机数,第二次调用会获得接下来100个,依此类推。

随机数种子

种子就是一个数值,引擎可以利用它从序列中的一个新位置重新开始生成随机数。

1
2
3
4
5
6
default_random_engine e1; // 默认种子

default_random_engine e1(32767); // 重随机数序列的第32767位开始
//等价
default_random_engine e2;
e2.seed(32767);

通常情况下,用ctime中的time()作为种子:

选择种子极为困难,最常用的方法是调用系统函数time。这个函数定义在头文件ctime 中,它返回从一个特定时刻到当前经过了多少秒。函数time 接受单个指针参数,它指向用于写入时间的数据结构。如果此指针为空,则函数简单地返回时间:

1
default_random_engine e1(time(0));

如果程序作为一个自动过程的一部分反复运行,将time的返回值作为种子的方式就无效了;它可能多次使用的都是相同的种子。

17.4.3 其他随机数分布

生成随机浮点数uniform_real_distributino<double>

1
2
3
4
default_random_endine e;
uniform_real_distribution<double> u(0,1);
for(size_t i =0; i < 10; ++i)
cout<<u(e)<<endl;

每个分布模板都有一个默认模板实参(除了bernoulli_distribution

生成浮点值的分布类型默认生成double值,而生成整型值的分布默认生成int值。

使用空<>表示我们希望使用默认结果类型

1
uniform_real_distribution<> u(0,1);

生成非均匀分布的随机数

1
normal_distribution<> n(4,1.5); // 以均值4为中心,标准差为1.5的正态分布

特别的,bernoulli_distribution是一个普通类而非模板,总是返回一个bool,默认t:f=0.5;

1
2
3
4
5
6
7
8
// 引擎和分布应该在循环之外,
default_random_engine e;
bernoulli_distribution b;

do{
bool first = b(e);
//...
}

可调整t:f的比率:

1
bernoulli_distribution b(.55);

此时t:f = 55/45

由于引擎返回相同的随机数序列(参见17.4.1节,第661页),所以我们必须在循环外声明引擎对象。否则,每步循环都会创建一个新引警,从而每步循环都会生成相同的值。类似的,分布对象也要保持状态,因此也应该在循环外定义。

17.5 IO再探

17.5.1-1 格式化输入

操纵符汇总:

202404081604598

202404081603526

关于操纵符

  • 除了条件状态外,每个iostream还维护格式状态来控制IO格式化的细节。
  • 操纵符:一个函数或一个对象,影响流的状态,返回其所处理的流对象。

    • 作用:输出控制,大多设置和复原成对出现。
      • 控制 数值输出形式
      • 控制 补白的位置和数量
  • 格式状态的改变是持久的:改变后对后续的IO都生效。不再需要特殊格式时,须尽快将格式控制恢复至默认状态

布尔值的格式

  • boolalpha &noboolalpha:输出0/1还是true/false
1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
using namespace std;

void main() {
cout << "default bool:" << true << " " << false << endl;

cout << "boolalpha bool:"
<< boolalpha
<<true << " " << false
<< noboolalpha /*及时关闭格式控制,恢复默认状态*/
<<endl;
}

image-20240408161713828

整型的进制

  • showbase:在输出结果中显示前导(0x——十六进制,0——八进制)
  • 八进制oct & 十进制dec & 十六进制hex
  • 十六进制大写:uppercase & nouppercase
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
using namespace std;

void main() {
cout << showbase; /*在输出结果中显示前导:0x——十六进制,0——八进制*/
cout << "default: " << 20 << " " << 1024 << endl;
cout << "octal: " << oct << 20 << " " << 1024 << dec << endl; /*及时利用dec恢复默认进制*/
cout << "hex: " << hex << 20 << " " << 1024 << dec << endl;
cout << "decimal: " << dec << 20 << " " << 1024 << endl;

cout << "hex_upper: " << uppercase /*十六进制大写*/
<< hex << 20 << " " << 1024 << dec
<< nouppercase << endl;

cout << noshowbase;
}

image-20240408163133390

浮点数格式(分3点)

1.精度控制
  • precision(io对象成员函数)
    • 重载版本1:接收一个int以设置精度,返回旧精度
    • 重载版本2:不接受参数,返回当前精度
  • setprecision(操纵符)
    • 接收参数的操纵符都在头文件iomanip中定义
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
#include <iomanip>
using namespace std;

void main() {
// cout.precision返回当前精度
cout << "当前精度(默认精度): " << cout.precision()
<< " ,例子: " << sqrt(2.0) << endl;


// cout.precision(12)将打印精度设置为12
cout.precision(12);
cout << "当前精度: " << cout.precision()
<< " ,例子: " << sqrt(2.0) << endl;


// 使用setprecision操纵符设置精度
cout << setprecision(24);
cout << "当前精度: " << cout.precision()
<< " ,例子: " << sqrt(2.0) << endl;


// 恢复默认
cout.precision(6);
}

image-20240408164643660

2.浮点数计数法
  • 改变浮点数计数法 也会改变 流的精度的 默认含义

    • 默认含义:精度指数字的总位数,包括整数部分和小数部分
    • 使用操纵符后:精度特指小数部分的位数
  • 操纵符

    • scientific:科学计数法

    • fixed:使用定点十进制

    • hexfloat:强制浮点数使用十六进制格式

    • defaultfloat:将流恢复至默认状态——自动选择值的计数法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
#include <iomanip>
using namespace std;

void main() {
cout << "精度: " << cout.precision() << endl;

cout << "默认格式: " << 100 * sqrt(2.0) << endl; //默认精度为总位数

cout << "科学技术: " << scientific << 100 * sqrt(2.0) << endl;

cout << "定点十进制: " << fixed << 100 * sqrt(2.0) << endl; // 操纵符或精度为小数位数

cout << "浮点数十六进制: " << hexfloat << 100 * sqrt(2.0) << endl;

cout << "恢复默认: " << defaultfloat << 100 * sqrt(2.0) << endl;

}

image-20240408172411155

  • 补充:打印小数点

    • showpoint & noshowpoint
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    #include <iostream>
    #include <iomanip>
    using namespace std;

    void main() {
    // 浮点数小数部分为0时不显示小数部分
    cout << 10.0 << endl;

    // showpoint强制打印小数部分
    cout << showpoint
    << 10.0
    << noshowpoint /*恢复默认*/
    << endl;

    }

    image-20240408173040901

3.补白

即,精细地控制数据格式的操纵符:

  • setw(int n)

    • C++ setw() 函数 | 菜鸟教程 (runoob.com)

    • setw() 函数只对紧接着的输出产生作用。

      当后面紧跟着的输出字段长度小于 n 的时候,在该字段前面用空格补齐,当输出字段长度大于 n 时,全部整体输出。
      cpp-setw-20200922-RUNOOB

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      #include <iostream>
      #include <iomanip>

      using namespace std;

      int main()
      {
          // 开头设置宽度为 4,后面的 runoob 字符长度大于 4,所以不起作用
          cout << setw(4) << "runoob" << endl;

          // 中间位置设置宽度为 4,后面的 runoob 字符长度大于 4,所以不起作用
          cout << "runoob" << setw(4) << "runoob" << endl;

          // 开头设置间距为 14,后面 runoob 字符数为6,前面补充 8 个空格
          cout << setw(14) << "runoob" << endl;

          // 中间位置设置间距为 14 ,后面 runoob 字符数为6,前面补充 8 个空格
          cout << "runoob" << setw(14) << "runoob" << endl;

          return 0;
      }

      image-20240408200203230

  • left &right

    • 左对齐 & 右对齐(默认右对齐)
  • internal

    • 在符号和值之间添加填充字符
  • setfill(char)

    • setw() 默认填充的内容为空格,配合setfill()使用设置其他字符以填充。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      #include <iostream>
      #include <iomanip>

      using namespace std;

      int main()
      {
          cout << setfill('*')  << setw(14) << "runoob" << endl;
      // 终端打印:********runoob
          return 0;
      }

补白示例,书P671~672

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>
#include <iomanip>
using namespace std;

void main() {
int i = -16;
double d = 3.14159;

// 补白第一列,使用输出中最小的12个位置
cout << "i:" << setw(12) << i << "next col" << "\n"
<< "d:" << setw(12) << d << "next col" << "\n\n";


// 补白第一列,左对齐所有列
cout << left;
cout << "i:" << setw(12) << i << "next col" << "\n"
<< "d:" << setw(12) << d << "next col" << "\n\n";
cout << right; // 及时恢复默认


// 补白第一列,但补在域内部
cout << internal;
cout << "i:" << setw(12) << i << "next col" << "\n"
<< "d:" << setw(12) << d << "nexy col" << "\n\n" ;
cout << right; // right也可以用于恢复internal


// 补白第一列,#作补白符
cout << setfill('#');
cout << "i:" << setw(12) << i << "next col" << "\n"
<< "d:" << setw(12) << d << "next col" << "\n\n";
cout << setfill(' '); // 及时恢复默认

}

image-20240408202548573

17.5.1-2 格式化输入

  • 默认情况下,输入运算符忽略空白符(空格、制表、换行、换纸和回车符)。
    image-20240409151044920

  • 操纵符noskipws命令输入读取空白符

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    #include <iostream>
    #include <iomanip>
    using namespace std;

    void main() {
    char ch;

    cin >> noskipws;
    while (cin>>ch)
    {
    cout << ch;
    }
    cin >> skipws; // 恢复默认
    }

    输出:image-20240409151657415

17.5.2 未格式化的输入、输出

感觉实际上就是c风格的io操作?

除了上文的格式化io,标准库还提供一组底层操作,支持未格式化io,允许我们将一个流当作一个无解释的字符序列来处理。

单字节操作

image-20240409134303717

  • get(),保留空白符,输出与输入完全相同,同noskipws

    • 有参版本用法

      1
      2
      3
      char ch;
      while(cin.get(ch))
      cout.put(ch);
    • 无参版本,返回int(原因P674中部)

      1
      2
      3
      int ch; // 一定注意是 int !!!
      while((ch = cin.get()) != EOF)
      cout.put(ch);
  • 将 字符 放回 输入流

    • peek
    • unget
    • putback

多字节操作

WPS拼图0

流随机访问

image-20240428161213839

十八 用于大程序的工具

针对于较为复杂,小组和个人难以管理的系统

18.1 异常处理

  • 异常使得我们能够将问题的检测与解决过程分开。
  • 想要有效地使用异常处理,必须首先了解
    • 抛出异常时发生了什么;
    • 捕获异常时发生了什么
    • 用来传递错误的对象的意义

18.1.1 throw:抛出异常

  • 抛出一条表达式来引发一个异常,被抛出的表达式的类型当前的调用链共同决定了哪段处理代码将被用来处理该异常。
  • throw类似return
    image-20240506145444080
  • 栈展开

    书中关于栈展开的描述:

    如果对抛出异常的函数的调用语句位于一个try语句块内,则检查与该try 块关联的catch子句。如果找到了匹配的catch,就使用该catch 处理异常。否则,如果该try语句嵌套在其他try块中,则继续检査与外层try匹配的catch 子句。如果仍然没有找到匹配的catch,则退出当前这个主调函数,继续在调用了刚刚退出的这个函数的其他函数中寻找,以此类推。

image-20240506145712403

  • 栈展开过程中,对象会被自动销毁
    • 如果栈展开过程中退出了某个块,编译器将确保这个块中创建的对象能够被正确销毁。—>析构函数将被自动调用(析构函数在栈展开过程中执行)
    • 如果异常发生在构造函数(数组或标准容器)中,则当前的对象可能构造了一部分了。—>也要确保已构造的成员被正确销毁
  • 析构函数与异常
    • 析构函数不应该抛出不能被它自身处理的异常。(即,如果析构函数需要执行某个可能抛出异常的操作,则该操作应该被放置在一个try语句块中,并且在析构函数内部得到处理
  • 异常对象

    • 异常对象是throw抛出的东西。有如下需要注意的地方:
      bae30205aeb9f3deb941c40858a4b3e

    • 异常对象举例
      摘自C++异常处理 — 异常对象(Exception Object)-CSDN博客
      在C++异常处理中,throw可以抛出任何对象,可以是int类型,也可以是class类型,对于catch块而言,有两种选择:
      1)catch这个类型,例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      int main()
      {
      int a=1;
      try
      {
      throw a;
      }
      catch(int) // 捕获int型异常,无法获知a的内容
      {
      cout<<"Something wrong happened!"<<endl;
      }

      return 0;
      }

      2)catch这个类型的对象,例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      int main()
      {
      int a=1;
      try
      {
      throw a;
      }
      catch(int b) // 捕获int型异常,可以获知a的值
      {
      cout<<"Something wrong happened: "<<b<<endl;
      }

      return 0;
      }

      这两者的区别很明显,后者可以通过访问异常对象(本例中是b)从而获取异常信息。
      3)如果catch的是引用,那么修改该引用,原对象是否会被同步修改?答案是“否”,

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      int a = 1;
      void main() {
      try
      {
      throw a;
      }
      catch (int &b)
      {
      cout << b << endl;//1
      b = 2;
      cout << b << endl;//2
      }
      cout << a << endl; //1

      }

18.1.2 catch:捕获异常

  • catch的一些概念

    • catch 子句(catch clause)中的异常声明(exception declaration)看起来像是只包含一个形参的函数形参列表。像在形参列表中一样,如果catch 无须访问抛出的表达式的话,则我们可以忽略捕获形参的名字。

    • 参数类型必须是完全类型。可以是左值引用,但不能是右值引用。

    • 当进入一个catch语句后,通过异常对象初始化异常声明中的参数

      • 和函数的参数类似,如果catch的参数类型是非引用类型,则该参数是异常对象的一个副本,在catch语句内改变该参数实际上改变的是局部副本而非异常对象本身;
      • 相反,如果参数是引用类型,则和其他引用参数一样,该参数是异常对象的一个别名,此时改变参数也就是改变异常对象
    • catch的参数还有一个特性也与函数的参数非常类似:

      • 如果catch的参数是基类类型,则我们可以使用其派生类类型的异常对象对其进行初始化。此时,如果catch的参数是非引用类型,则异常对象将被切掉一部分(参见15.23节,第535页),这与将派生类对象以值传递的方式传给一个普通函数差不多。
      • 另一方面,如果catch的参数是基类的引用,则该参数将以常规方式绑定到异常对象上。
    • 异常声明的静态类型将决定catch语句所能执行的操作。如果catch的参数是基类类型,则catch无法使用派生类特有的任何成员。

      通常情况下,如果catch接受的异常与某个继承体系有关,则最好将该 catch的参数定义成引用类型。

  • 查找匹配的处理代码

    • 越是专门(最佳)的catch越应该置于整个catch列表的前端
    • 派生类异常处理代码应放在基类异常处理代码之前
    • 绝大多数类型转换都不被允许,除了
      • 允许从非常量向常量的类型转换,也就是说,一条非常量对象的throw语句可以匹配一个接受常量引用的catch语句。
      • 允许从派生类向基类的类型转换。
      • 数组被转换成指向数组(元素)类型的指针,函数被转换成指向该函数类型的指针。
  • 重新抛出

    • 有时,一个单独的catch语句不能完整地处理某个异常。在执行了某些校正操作之后,通过重新抛出(rethrowing)的操作将异常传递给调用链更上一层的另外一个catch语句接着处理异常。

    • 重新抛出仍然是一条throw语句,只不过不包含任何表达式:

      1
      throw;
      • 空的throw语句只能出现在 catch语句或 catch语句直接或间接调用的函数之内。如果在处理代码之外的区域遇到了空throw语句,编译器将调用terminate。
      • 重新抛出语句并不指定新的表达式,而是将当前的异常对象沿着调用链向上传递
    • 如果在catch中改变了参数的内容后重新抛出,则只有当catch异常声明是引用类型时我们对参数所做的改变才会被保留并继续传播

      1
      2
      3
      4
      5
      6
      7
      8
      catch(my_err &eobj){  // 引用类型
      eobj.status = errCodes::severeErr; //修改了异常对象
      throw; // 异常对象改变
      }
      catch(my_err eobj){ // 非引用类型
      eobj.status = errCodes::badErr; //修改了异常对象
      throw; // 异常对象没有改变
      }
  • 与任意类型异常匹配:catch(...)

    • 通常与重新抛出一起使用,其中catch执行当前局部能完成的工作,随后重新抛出异常
    1
    2
    3
    4
    5
    6
    7
    void manip(){
    try{
    }
    catch(...){
    throw;
    }
    }

18.1.3 try语句块与构造函数

因为在初始值列表抛出异常时,构造函数体内的try语句块还未生效。所以构造函数体内的catch语句无法处理构造函数初始值列表抛出的异常。

为解决上述问题,必须将构造函数写成函数try语句块(也称为函数测试块,function tryblock)的形式。函数try语句块使得一组 catch 语句

  • 既能处理构造函数(或析构函数体),
  • 也能处理构造函数的初始化过程(或析构函数的析构过程)。
1
2
3
4
template <typename T>
Blob<T>::Blob(std::initializer_list<T> il) try:data(std::make_share<std::vector<T>>(il)){
/*构造函数体*/
}catch(const std::bad_alloc &e) { handle_out_of_memory(e); }

另外,需要注意的是,在参数初始化的过程中发生了异常,则该异常属于调用表达式的一部分,并将在调用者所在的上下文中处理。

18.1.4 noexcept

  • 异常说明符、异常说明表达式

说明符

  • 指定某个函数不会发生异常,形如

    1
    2
    3
    4
    5
    6
    void recoup(int) noexcept; // 一定不抛出异常
    void recoup(int) throw(); // 旧式c++语法,等价第一行

    void recoup(int) noexcept(true); // 等价第一行
    void recoup(int) noexcept(false); // 可能抛出异常,等价下式
    void recoup(int);
  • 写法及注意事项

    • 紧跟在函数的参数列表后面
    • 在尾置返回类型之前
    • 要么出现在该函数的所有声明语句和定义语句中,要么一次也不出现
    • 可以在函数指针的声明和定义中使用
    • 在typedef和类型别名中不能出现
    • 在成员函数中,noexcept说明符需要跟在,const及引用限定符之后,而在final、override或虚函数=0之前
  • 违反异常说明

    • 在noexcept中写throw,仍然能够顺利编译通过。

    • 尽管函数声明了(noexcept)它不会抛出异常,但实际上还是抛出了 —>违反了异常说明。
      一旦一个noexcept函数抛出了异常,程序就会调用terminate以确保遵守不在运行时抛出异常的情况。

运算符

1
noexcept(e);

当e调用的所有函数都做了不抛出说明,且e本身不含有throw语句时,上述表达式返回true;否则返回false。

  • 补充:

    1
    void f() noexcept(noexcept(g()));

    f和g的异常说明一致

补充:noexcept与指针、虚函数、拷贝控制的关系

  • 指针
    • 如果指针做了不抛出异常说明,则该指针只能指向不抛出异常的函数。否则,可以指向任何函数。
  • 虚函数
    • 如果虚函数做了不抛出异常的说明,则派生出来的虚函数也同样需要做此承诺。否则,派生类对应的函数既可以抛出也可以不抛出异常。
  • 拷贝控制
    • 当编译器合成拷贝控制成员时,也会同时生成一个异常说明。如果对所有成员和基类的所有操作都承诺了noexcept,也合成的成员也是noexcept的。
    • 析构函数没有声明noexcept,也将由编译器合成。合成的异常说明也将与假设由编译器为类合成析构函数时所得的异常说明一致。(:question:)

18.1.5 异常类层次

image-20240508204809958

18.2 命名空间

18.2.1 定义一个命名空间

  • 命名空间作用域后无须分号

  • 命名空间既可以定义在全局作用域内,也可以定义在其他命名空间中,但是不能定义在函数和类的内部

  • 写法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    //---sales_data.h---
    #include <string>
    /*不能将`#include <string>`写在`using cpp_primer`内,
    否则为试图将命名空间std嵌套进命名空间cpp_primer中*/

    namespace cpp_primer{
    class Sales_data{/*...*/};
    Sales_data operator+(const Sales_data&,
    const Sales_data&);
    }

    //---sales_data.cpp---
    #include "sales_data.h"

    namespace cpp_primer{
    Sales_data operator+(const Sales_data&,
    const Sales_data&)
    {/*...*/}
    }
  • 全局命名空间

    1
    ::member_name;//全局命名空间中的一个成员
  • 模版特例化
    模板特例话必须定义在原始模板所属的命名空间中,之后就能在命名空间外部定义它了。书本例子如下:

    1
    2
    3
    4
    5
    6
    7
    8
    //对于特例化的hash
    //我们必须将模板特例化声明成std的成员
    namespace std{
    template <> struct hash<Sales_data>;
    }

    //在std中添加了模板特例化的声明之后,就可以在命名空间std的外部定义它了
    template <> struct std::hash<>
  • 嵌套的命名空间与内联命名空间、匿名命名空间

    • 嵌套的命名空间

      1
      2
      3
      4
      5
      6
      7
      namespace cpp_primer{
      namespace QueryLib{
      class Query{};
      }
      }

      cpp_primer::QueryLib::Query
    • 匿名命名空间

      1
      2
      3
      4
      5
      6
      7
      namespace cpp_primer{
      namespace {
      class Query{};
      }
      }

      cpp_primer::Query
      • 未命名的命名空间中定义的变量拥有静态的生命周期,在第一次使用前创建,并且直到程序结束才销毁(static)

      • 可以在一个文件内不连续,但不能跨越文件;两个文件都有匿名命名空间,两空间相互独立;

      • 如果一个头文件定义了匿名空间,则该命名空间中定义的名字将在每个包含了该头文件的文件中对应不同的实体。

      • 可以直接使用;不能对其中成员使用作用域运算符。

      • 二义性问题

        1
        2
        3
        4
        5
        6
        int i;
        namespace {
        int i;
        }

        i = 10; // 二义性
  • 内联命名空间

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    //---fifthED.h---
    inline namespace FifthED{/*声明*/
    class Query{};
    }
    namespace FifthED{/*定义*/}

    //---fourthED.h---
    namespace fourthED{
    class Query{};
    }

    //---main.cpp---
    namespace cpp_primer{
    #include "fifthED.h"
    #include "fourthED.h"
    }

    cpp_primer::Query // FifthED是inline的,cpp_primer::的代码可以直接获得FifthED的成员
    cpp_primer::fourthED::Query
    • inline必须出现在命名空间第一次定义的地方,后续inline可以写,也可以省略
    • 何时用内联命名空间?当应用程序的代码在一次发布和另一次发布之间发生了改变,常常会用到内联命名空间。

18.2.2 使用命名空间

  • 命名空间的别名

    1
    2
    namespace cplusplus_primer{/*...*/};
    namespace primer = cplusplus_primer; // 别名
  • using声明和using指示(书P702~P704)

    • using声明:只引入命名空间的一个成员
    • using指示:将一个命名空间打开

18.2.3 命名空间内部名字的查找

原文:类、命名空间与作用域

  • 查找命名空间中成员

    • 由内向外依次查找每个外层作用域

    • 总是向上查找作用域,名字必须先声明后使用(与class区别)。

  • 查找实参的所属命名空间

    • 考虑

      1
      2
      3
      4
      std::string s;
      std::cin >> s;
      //等价
      operator>>(std::cin, s); // 也可以显式声明为std::operator>>(std::cin, s);
    • 法线operator>>不是std::operator>>,为什么可以省略?

      当我们给函数传递一个类类型的对象时,除了常规的作用域查找外,还会查找实参(及该实参的基类)所属的命名空间

      即,std::cin的作用域传递给了operator>>

  • 查找与std::movestd::forword
    • move和forward的名字容易被冲突,所以最好写全:std::move()
  • 友元声明与实参相关的查找

    • 回顾:“当类声明了一个友元,该友元声明并没有使得友元本身可见。”

    • 然而,一个另外的未声明的类或函数如果第一次出现在友元声明中,则我们认为他是最近的外层命名空间的成员。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      namespace A{
      class C{
      // f2和f未在其他其他地方声明
      // 则他们被隐式地设置为了命名空间A的成员
      friend void f2();
      friend void f(const C&);
      };
      }


      int main(){
      A::C obj;
      f(obj); // 命名空间查找从实参传递到f函数

      A::f2();
      }

18.2.4 重载与命名空间

  • 与实参相关的查找与重载

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    namespace NS
    {
    class Quote{};
    void display (const Quote& ){}
    };

    class Bulk_item : public NS::Quote{};

    int main()
    {
    Bulk_item book;
    display(book);
    return 0;
    }

    解释上述代码:我们传递给 display()的实参属于类类型Bulk_item,因此该调用语句的候选函数不仅应该在调用语句所在的作用域中查找,而且也应该在Bulk_item及其基类Quote所属的命名空间中查找。命名空间NS中声明的函数display(constQuote&)也将被添加到候选函数集当中。

  • 重载与using声明

    • using语句声明的是一个名字,而非一个特定的函数

      1
      2
      using NS::print(int); // 错误
      using NS::print;
    • 一个using声明引入的函数将重载该声明语句所属作用域中已有的其他同名函数。

      • 如果using声明出现在局部作用域中,则引入的名字将隐藏外层作用域的相关声明。
      • 如果using声明所在的作用域中已经有一个函数与新引入的函数同名且形参列表相同,则该using声明将引发错误。
      • 除此之外,using声明将为引入的名字添加额外的重载实例,并最终扩充候选函数集的规模。
  • 重载与using指示
    与using 声明不同的是,对于using指示来说,引入一个与已有函数形参列表完全相同的函数并不会产生错误。此时,只要我们指明调用的是命名空间中的函数版本还是当前作用域的版本即可。
  • 跨越多个using指示的重载(简单的知识,略)

18.3 多重继承与虚继承

18.3.1 多重继承

  • 多重继承是指从多个直接基类中产生派生类的能力。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class Bear : public ZooAnimal {/*...*/};
    class Panda : public Bear, public Endangered {/*...*/};

    // 显式地初始化所有基类
    Panda::Panda(std::string name, bool onExhibit)
    : Bear(name, onExhibit, "Panda"),
    Endangered(Endangered::critical){}
    // 隐式使用Bear的默认构造函数初始化Bear子对象
    Panda::Panda()
    :Endangered(Endangered::critical){}
    • 基类的构造顺序与派生列表中基类的出现顺序保持一致。
  • 继承的构造函数与多重继承

    • 只构造直接基类

    • c++11中,允许派生类从它的一个或几个基类中继承构造函数。但如果重多个基类中继承了相同的构造函数(即形参列表完全相同),则将出错:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      class A {
      public:
      A(int tv) {}
      };
      class B {
      public:
      B(int tv) {}
      };

      // 错误
      class C : public A,public B {
      public:
      using A::A;//继承A类的构造函数 ==> C(int tv):A(tv){}
      using B::B;//错误!继承B类的构造函数是 ==> C(int tv):B(tv){}该构造函数和继承自A类的一模一样
      };


      // 正确
      class C : public A,public B {
      public:
      using A::A;//继承A类的构造函数 ==> C(int tv):A(tv){}
      using B::B;//继承B类的构造函数是 ==> C(int tv):B(tv){}该构造函数和继承自A类的一模一样

      // 此时,C必须为该构造函数(参数为int的构造函数)定义自己的版本
      C(int tv):A(tv),B(tv){/*...*/}
      C() = default; // 一旦C定义了自己的构造函数,则必须出现
      };
  • 析构顺序与构造顺序相反

  • 多重继承的派生类的拷贝和移动操作 参考只有一个基类的继承 的情况

    • 不仅需要拷贝自己,还需有拷贝基类

18.3.2 类型转换与多个基类

  • 同 只有一个基类的情况
    • 派生类的指针或引用能自动转换成(任何)一个可访问的基类指针或引用
    • 基类指针指向子类对象

可能导致二义性错误

1
2
3
4
5
6
7
//重载的print
void print(const Bear&);
void print(const Endangered& );

//通过Panda对象 对不带前缀限定符的print 函数进行调用将产生编译错误
Panda ying_yang("ying_yang");
print(ying_yang); // 错误,将会导致二义性问题
  • 基于指针类型或引用类型的查找

    • 对象、指针和引用的静态类型决定了我们能够使用(动态类型)的哪些成员。

      下图中Endangered为静态类型,Panda为动态类型。

![image-20240528111835185](https://hezexian.oss-cn-guangzhou.aliyuncs.com/picture/202405281118467.png)



> gpt的解释:
>
> 
> 
1
Bear *pb = new Panda("ying_yang");
> 这里,`Bear *pb` 是指针变量 `pb` 的静态类型,也就是 `Bear` 类型。这是因为 `pb` 被声明为 `Bear` 类型的指针。静态类型是在编译时确定的,不会在运行时改变。 > 而 `Panda("ying_yang")` 是 `Panda` 类型的对象实例化,这是实际创建的对象的动态类型。动态类型是在运行时由对象的实际类型决定的。 > 当你通过 `new` 关键字分配内存并创建一个对象时,你实际上是在创建一个 `Panda` 类型的对象,尽管你声明的指针静态类型是 `Bear`。这里有一个类型转换的过程,将 `Panda` 对象强制转换为 `Bear` 指针。这种转换在运行时进行,如果不正确使用,可能会导致运行时错误,尤其是在尝试调用 `Bear` 类中不存在的成员函数或属性时。 > 在这个例子中,虽然 `pb` 的静态类型是 `Bear`,但是它指向的是一个 `Panda` 对象。这种类型的转换在不违反多态性规则的情况下是允许的,因为 `Panda` 是 `Bear` 的子类型。然而,如果你试图通过 `pb` 调用只有 `Bear` 类中定义的成员函数,这将导致编译错误,因为这些函数在 `Panda` 类中可能没有被定义,或者可能有不同的实现。

18.3.3 多重继承下,使用基类函数导致的二义性问题

原文标题:多重继承下的类作用域

如何导致的二义性问题?

首先,Panda继承于ZooAnimal& Endangered

假设,ZooAnimalEndangered都定义了 max_weight(),并且Panda没有定义该成员,则下面的调用是错误的(二义性的):

1
double d = ying_yang.max_weight();

错误原因:

  • 程序会并行地在ZooAnimalEndangered中找到了max_weight,却不知道使用哪一个。
  • 对于max_weight()
    • 形参列表不同,也可能引发错误;
    • 在一个类中私有,在另一个类中公有或受保护,也同样可能引发错误。

解决办法:

  • 最好是在派生类中为该函数定义一个新版本。
    1
    2
    3
    4
    5
    double Panda::max_weight() const
    {
    return std::max(ZooAnimal::max_weight(),
    Endangered::max_weight()); // 也要用前缀指明是哪一个基类的max_weight()
    }

18.3.4 虚继承

  • 虚继承示意图
    image-20240528155652479
  • 上图中,如果不是虚继承,则Panda包含了两次ZooAnimal。而在虚基类体系下,不论ZooAnimal被间接继承了多少次,在Panda中都只包含唯一一个共享的ZooAnimal。

  • 写法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class Raccoon : public virtual ZooAnimal {/*...*/};
    class Bear : virtual public ZooAnimal {/*...*/};

    // Raccoon和Bear的派生类的写法仍按正常方式
    class Panda
    : public Bear, public Raccoon, public Endangered{/**/};

    // 常规方式的类型转换
    void dance(const Bear &);

    Panda ying_yang;
    dance(ying_yang);
  • 虚基类成员的可见性(关于二义性的问题)
    对于如下情况:

    image-20240528190602426

    • 如果在D1和D2中都没有x的定义,则x将被解析为B的成员,此时不存在二义性,一个D的对象只含有x的一个实例。
    • 如果x是B的成员,同时是D1和D2中某一个的成员,则同样没有二义性,派生类的x比共享虚基类B的x优先级更高。
    • 如果在D1和D2中都有x的定义,则直接访问x将产生二义性问题

18.3.5 构造函数与虚继承

1.虚基类率先构造

对于下图的继承关系,解释Panda的构造顺序。

image-20240528155652479

  1. 首先使用 Panda的构造函数初始值列表中提供的初始值构造虚基类 ZooAnimal 部分。
  2. 接下来构造 Bear 部分。
  3. 然后构造 Raccoon 部分。
  4. 然后构造第三个直接基类Endangered。
  5. 最后构造 Panda 部分。

与一般继承稍有不同的是,ZooAnimal即使不是Panda的直接基类,Panda的构造函数也可以直接(显式或隐式地)初始化ZooAnimal。

虚基类总是先于非虚基类构造,与他们在继承体系中的次序和位置无关。

2.构造函数与析构函数的次序

编译器按照直接基类的声明顺序对其依次进行检查,以确定其中是否含有虚基类。如果有,则先构造虚基类,然后按照声明的顺序逐一构造其他非虚基类。

合成的拷贝和移动构造函数、合成的赋值运算符中的成员 按照构造函数的顺序;

析构函数按照与构造函数相反的顺序。

对如下继承关系:

image-20240528193910082

当TeddyBear的构造函数的初始化列表如下时,

1
2
class TeddyBear 
: public BookCharacter, public Bear, public virtual ToyAnimal{/**/};

调用的构造函数顺序如下,

1
2
3
4
5
6
ZooAnimal();
ToyAnimal();
Character();
BookCharacter();
Bear();
TeddyBear();

十九 特殊工具与技术

19.1 控制内存分配

19.1.1 重载newdelete

  • new的工作过程
    • 使用new表达式时的三个步骤:
      2358049-20240130211938449-1413014829
    • 重载new实际上是重载operator new,以改变内存分配的方式。
      image-20240531112655464
    • 重载的operator new可以在全局,也可作为类的成员。类成员覆盖全局。我们可以使用作用域运算符来忽略定义在类中的operator new,如::new/::delete
  • operator new/operator delete的接口

    • 标准库定义了8个重载版本,用户可以在全局或类中重载任意一个。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      // 可能抛出异常
      void *operator new(size_t);
      void *operator new[](size_t);
      void *operator delete(void *) noexcept; // delete不允许抛出异常
      void *operator delete[](void *) noexcept;

      // 承诺不抛出异常
      void *operator new(size_t, nothrow_t& ) noexcept;
      void *operator new[](size_t, nothrow_t& ) noexcept;
      void *operator delete(void*, nothrow_t& ) noexcept;
      void *operator delete[](void*, nothrow_t& ) noexcept;
    • 为类成员时,

      因为operator new用在对象构造之前,operator delete用在对象销毁之后,所以

      • 隐式静态,无需显示声明static;
      • 不能操作类的任何数据成员
    • 对于标准库的operator new/operator new[]

      • 必须返回void *
      • 第一个形参必须是size_t,且该形参不能含有默认实参;size_t指所有元素所需的空间
    • 自定义operator new/operator new[]

      • 必须使用new的定位形式

      • 可以为其提供额外的形参,除了以下形式

        1
        void *operator new(size_t, void *); // 只供标准库使用,不被用户重新定义
  • operator deleteoperator delete[]
    • 定义为类成员时候,可以包含另一个类型为size_t的形参。此时,形参的初始值是第一个形参所指对象的字节数。
    • 该size_t形参可用于删除继承体系中的对象,size_t代表的字节数由动态类型决定
  • 在operator new(operator delete)中,只用malloc(free)分配(释放内存)

    • <cstdlib>

    • 示例

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      void *operator new(size_t sz)
      {
      if(void *mem = malloc(sz))
      {
      return mem;
      }
      else
      throw bad_alloc();
      }

      void *operator delete(void *mem) noexcept
      {
      free(mem);
      }

19.1.2 定位new表达式