Skip to content

面向对象

概述

  • 抽象与封装是两个重要的程序设计手段,主要是用来驾驭程序的复杂度,便于大型程序的设计、理解与维护。
  • 抽象是指该程序实体的外部可观察到的行为,不考虑该程序实体的内部是如何实现的。(控制复杂度)
  • 封装是指把该程序实体内部的具体实现细节对使用者隐藏起来,只对外提供一个接口。(信息保护)
    • 包括:过程抽象与封装;数据抽象与封装。
  • 抽象,是指从众多的事务中抽取出具有共同的、本质性的特征作为一个整体。是共同特质的集合形式。
  • 封装,是将通过抽象所得到的数据信息和操作进行结合,使其形成一个有机的整体。对内执行操作,对外隐藏细节和数据信息。
  • 两者的区别,在于抽象是一种思维方式,而封装则是一种基于抽象性的操作方法。我们通过抽象所得到数据信息及其功能,以封装的技术将其重新聚合,形成一个新的聚合体,也就是类。或者说,两者是合作者的关系,如果没有抽象,封装就无从谈起,如果没有封装,抽象也将没有意义。
  • 过程抽象与封装:(基于功能分解与复合的过程式程序设计的基础。)
  • 过程抽象:用一个名字来代表一段完成一定功能的程序代码,代码的使用者只需要知道代码的名字以及相应的功能,而不需要知道对应的程序代码是如何实现的。
  • 过程封装:把命名代码的具体实现隐藏起来(对使用者不可见,或不可直接访问),使用者只能通过代码名字来使用相应的代码。命名代码所需要的数据是通过参数(或全局变量)来获得,计算结果通过返回值机制(或全局变量)返回
  • 过程实现了抽象与封装。数据是公开的,缺乏保护。
  • 数据抽象与封装:与过程抽象与封装相比,数据抽象与封装能够实现更好的数据保护。
  • 数据抽象:只描述对数据能实施哪些操作以及这些操作之间的关系,数据的使用者不需要知道数据的具体表示形式。
  • 数据封装:把数据及其操作作为一个整体(封装体)来进行实现,其中,数据的具体表示被隐藏起来(使用者不可见,或不可直接访问),对数据的访问(使用)只能通过封装体对外接口提供的操作来完成。
  • 数据抽象与封装是面向对象程序设计的基础,其中的对象体现了数据抽象与封装。
  • 面向对象程序设计具有以下几个特征:
  • 程序由若干对象组成,每个对象是由一些数据以及对这些数据所能实施的操作所构成的封装体;
  • 对象的特征(包含那些数据与操作)由相应的类来描述;
  • 对数据的操作是通过向包含数据的对象发送消息(调用对象类的对外接口中的操作)来实现的;
  • 一个类所描述的对象特征可以从其它的类继承(获得)。(注意:如果没有“继承”,则称为:基于对象的程序设计)
  • 对象/类
  • 对象是由数据及能对其实施的操作所构成的封装体。
  • 类描述了对象的特征(包含什么类型的数据和哪些操作),实现抽象。
  • 对象属于值的范畴,是程序运行时刻的实体;类则属于类型的范畴,属于编译时刻的实体。
  • 继承
  • 在定义一个新的类(子类、派生类)时,可以把已有类(父类、基类)的一些特征描述先包含进来,然后再定义新的特征。
  • 多态:某一论域中的一个元素存在多种形式和解释。
  • 一名多用:
    • 函数名重载
    • 操作符重载(语言预定义和用户自定义)
  • 类属(模板):
    • 类属函数:一个函数能对多种类型的数据进行操作。
    • 类属类:一个类可以描述多种类型的对象。
  • 面向对象程序特有的多态(继承机制带来的):
    • 对象类型的多态:子类对象既属于子类,也属于父类。
    • 对象标识的多态:父类的引用或指针可以引用或指向父类对象,也可以引用或指向子类对象。
    • 消息的多态:发给父类对象的消息也可以发给子类对象,父类与子类会给出不同的解释(处理)。
  • 多态带来的好处
    • 使得程序功能扩充变得容易(程序上层代码不变,只要增加底层的具体实现即可)。
    • 增强语言的可扩充性(如操作符重载等)。
  • 影响软件开发效率和软件质量的因素主要包括:
  • 抽象(控制复杂度)
  • 封装(保护信息)
  • 模块化(组织和管理大型程序)
  • 软件复用(缩短开发周期)
  • 可维护性(延长软件寿命)
  • 软件模型的自然度(缩小解题空间与问题空间之间的语义间隙,实现从问题到解决方案的自然过渡)
  • 过程式程序设计的特点
  • 以功能为中心,强调过程(功能)抽象,但数据与操作分离,二者联系松散。
  • 实现了操作的封装,但数据是公开的,数据缺乏保护。
  • 按子程序划分模块,模块边界模糊。
  • 子程序往往针对某个程序而设计,这使得程序难以复用。
  • 功能易变,程序维护困难
  • 基于子程序的解题方式与问题空间缺乏对应。
  • 面向对象程序设计的特点
  • 以数据为中心,强调数据抽象,操作依附于数据,二者联系紧密。
  • 实现了数据的封装,加强了数据的保护。
  • 对象类往往具有通用性,再加上继承机制,使得程序容易复用。
  • 对象类相对稳定,有利于程序维护。
  • 基于对象交互的解题方式与问题空间有很好的对应。
  • 便于找到并分离接⼝与实现,类是⼀个⾃然的模块划分单位,模块边界⽐较 清晰。其中类的定义作为接⼝放在.h ⽂件中,类的实现作为实现放在.cpp ⽂件中。

类与对象

  • 关系;
  • 对象:构成了面向对象程序的基本计算单位,构成动态的面向对象程序,是用来描述客观事物的实体,是包含属性和操作的集合
  • 类:是一种用户自定义类型,由数据成员和成员函数组成,一般存在于静态的程序,类是具有相同操作和数据格式的对象的集合。
  • 关系:对象是相对具体的概念,构成动态面向对象程序,而类是较为抽象的概念, 一般存在于静态的程序。对象是类的实例化,类描述一组具有类似属性和操作的 对象。

  • 在类中说明一个数据成员的类型时,如果未见到相应类型的定义,或相应的类型未定义完,则该数据成员的类型只能是这些类型的指针或引用类型。

class A;    //A是在程序其它地方定义的类,这里是声明。
class B
{   A a; //Error,未见A的定义。
    B b; //Error,B还未定义完,递归了!
    A *p;  //OK
    B *q;  //OK
    A &aa; //OK
    B &bb; //OK
}; 

类做模块

  • 模块是指:从物理上对程序中定义的实体进行分组,是可以单独编写和编译的程序单位。
  • 一个模块包含接口和实现两部分:
  • 接口:是指在模块中定义的、可以被其它模块使用的一些程序实体的描述。(声明)
  • 实现:是指在模块中定义的所有程序实体的具体实现描述。(定义)
  • 划分模块的基本准则
  • 内聚性最大:模块内的各实体之间联系紧密。
  • 耦合度最小:模块间的各实体之间关联较少。
  • 便于程序的设计、理解和维护,能够保证程序的正确性。
  • Demeter法则
  • 一个类的成员函数,除了能访问自身类结构的直接子结构(本类的数据成员)外,不能以任何方式依赖于任何其它类的结构。只应向某个有限集合中的对象发送消息。

this指针

  • 类的每一个成员函数(静态成员函数除外)都有一个隐藏的形参this,其类型为该类对象的指针;在成员函数中对类成员的访问是通过this来进行的。

函数

构造函数

  • 作用:当一个对象创建时,它将获得一块存储空间,该存储空间用于存储对象的数据成员。在使用对象前,需要对对象存储空间中的数据成员进行初始化。C++提供了一种对象初始化的机制:构造函数
class A
{       ......
    public:
        A();
        A(int i);
        A(char *p);
};
......
A a1;    //调用默认构造函数。也可写成:A a1=A(); 
           //但不能写成:A a1();
A a2(1);    //调用A(int i)。也可写成:A a2=A(1); 或 A a2=1; 
A a3("abcd");    //调A(char *)。也可写成:A a3=A("abcd");
                 //或 A a3="abcd"; 
A a[4];    //调用对象a[0]、a[1]、a[2]、a[3]的默认构造函数。
A b[5]={A(),A(1),A("abcd"),2,"xyz"};     //调用b[0]的A()、
                    //b[1]的A(int)、b[2]的A(char *)、
                    //b[3]的A(int)和b[4]的A(char *)

A *p1=new A;     //调用默认构造函数
A *p2=new A(2);     //调用A(int i)
A *p3=new A("xyz");    //调用A(char *)
A *p4=new A[20];   //创建动态对象数组时
              //只能调用各对象的默认构造函数
  • 在构造函数的函数头和函数体之间加入一个成员初始化表来对常量和引用数据成员进行初始化。
  • 在成员初始化表中,成员的书写次序并不决定它们的初始化次序,它们的初始化次序由它们在类定义中的描述次序来决定

析构函数

  • 一个对象消亡时,系统在收回它的内存空间之前,将会自动调用对象类中的析构函数。可以在析构函数中完成对象被删除前的一些清理工作(如动态内存)。
  • 析构函数可以显式调用,这时并不是让对象消亡,而是暂时归还对象额外申请的资源。

拷贝构造函数

  • 若一个构造函数的参数类型为本类的引用(值传递会造成递归调用),则称它为拷贝构造函数。

    class A
    {   ......
        public:
            A();  //默认构造函数
            A(const A& a);  //拷贝构造函数
    }; 
    

  • 在三种情况下,会调用类的拷贝构造函数:

  • 创建对象时显式指出
    A a1; 
    A a2(a1);  //创建对象a2,用对象a1初始化对象a2
    
  • 把对象作为值参数传给函数时
  • 把对象作为函数的返回值时

调用顺序

  • 构造函数:
  • 构造顺序:基类->成员对象->对象自身(析构反之)
    • 若包含多个成员对象,这些成员对象的构造函数执行次序则按它们在本对象类中的说明次序进行。
    • 是先调用本身类的构造函数,但在进入函数体之前,会去调用成员对象类的构造函数,然后再执行本身类构造函数的函数体!
    • 也就是说,构造函数的成员初始化表(即使没显式给出)中有对成员对象类的构造函数的调用代码。
    • 注意:如果类中未提供任何构造函数,但它包含成员对象,则编译程序会隐式地为之提供一个默认构造函数,其作用就是调用成员对象类的构造函数!
  • 自定义默认调用基类的默认构造函数,否则应在成员初始化表中指出(成员初始化表中的操作是构造而不是赋值)
  • 未提供构造函数时,系统生成默认构造函数,负责调用基类的默认构造函数

  • 析构函数

  • 先执行本身类的析构函数,再执行成员对象类的析构函数,最后是基类。
  • 如果有多个成员对象,则成员对象析构函数的执行次序则按它们在本对象类中的说明次序的逆序进行。
  • 是先调用本身类的析构函数,本身类析构函数的函数体执行完之后,再去调用成员对象类的析构函数!
  • 也就是说,析构函数的函数体最后有对成员对象类的析构函数的调用代码!
  • 注意:如果类中未提供析构函数,但它包含成员对象,则编译程序会隐式地为之提供一个析构函数,其作用就是调用成员对象类的析构函数。

  • 拷贝构造:

  • 默认拷贝构造函数会调用基类及成员对象的拷贝构造函数
  • 自定义拷贝构造函数会默认调用基类和成员对象的默认构造函数,否则要在成员初始化表中指出
  • 赋值
  • 默认赋值会对派生类成员进行赋值,并调用基类的赋值操作
  • 自定义则不会自动调用基类
  • 手动调用:*(A*)this = b;this->A::operator=(b);
class A {
    int n, m;
public:
    A() :n(0), m(0) { cout << "A()"; }
    A(int n, int m) :n(n), m(m) { cout << "A(int n, int m)"; }
    A(const A& a) :n(a.n), m(a.m) { cout << "A(const A& a)"; }
    ~A() { cout << "~A()"; }
};
class B : public A {
    int x;
public:
    B() :x(0) { cout << "B()"; }
    B(int x) :x(x) { cout << "B(int x)"; }
    B(B& b) :A(b) { cout << "B(B& b)"; }
    ~B() { cout << "~B()"; }
};
class C {
public:
    C() { cout << "C()"; }
    C(C& c) { cout << "C(C& c)"; }
    ~C() { cout << "~C()"; }
};
class D : public C {
    B b;
public:
    D() { cout << "D()"; }
    D(B b) :b(b) { cout << "D(B b)"; }
    D(D& d) : b(d.b), C(d) { cout << "D(D& d)"; }
    ~D() { cout << "~D()"; }
};
case 1
B b;
D d1(b);//A()B()A(const A& a)B(B& b)C()A(const A& a)B(B& b)D(B b)~B()~A()
case 2
D d1;
D d2 = d1; //C()A()B()D()C(C& c)A(const A& a)B(B& b)D(D& d)
case 3
test();//C()A()B()D()~D()~B()~A()~C()

常函数 静态成员

常成员函数

  • 为了防止在一个获取对象状态的成员函数中无意中修改对象的数据成员,可以把它说明成常成员函数。
  • 编译器一旦发现在常成员函数中修改数据成员的值,将会报错!
  • 常成员函数只约束对数据成员值的修改(比如可以修改指针指向的地址的内容,只要不修改指针指向的地址就可以)
class Date
{   public:
        void set(int y, int m, int d); 
      int get_day() const; //常成员函数
     int get_month() const; //常成员函数
     int get_year() const; //常成员函数
  ......
}; 
int Date::get_year() const { return year; }
int Date::get_day() const { return day; }
int Date::get_month() const { return month; }
void Date::set(int y, int m, int d) { year=y; month=m; day=d; }
  • 常量对象只能调用对象类中的常成员函数
    class Date
    { public:
          int set (int y, int m, int d);
          int get_day() const;
          int get_month() const;
          int get_year() const;
       ......
    } 
    void f(const Date &d) //d是个常量对象!
    { ... d.get_day() ... //OK
       ... d.get_month() ... //OK
       ... d.get_year() ... //OK
       d.set (2011,3,23); //Error
    }     
    

静态成员数据

  • 采用全局变量来表示共享数据:
  • 共享的数据与对象之间缺乏显式的联系
  • 数据缺乏保护!
  • 采用静态数据成员可以更好地实现同一个类的不同对象之间的数据共享。
  • 类的静态数据成员对该类的所有对象只有一个拷贝。
  • 类内声明,类外初始化(初始化时要带着数据类型)
  • 静态常量成员是可以在类内声明的static const int age=20;*//正确*
class A
{ int y;
   ......
   static int x; //静态数据成员声明
   void f()   { y = x; x++; ......   }
};
int A::x=0; //**静态数据成员定义及初始化(别忘了数据类型)**
......
A a,b;
a.f();
b.f();
//上述操作对同一个x进行
x++; //Error,不通过A类对象不能访问x!

静态成员函数

  • 静态成员函数只能访问类的静态成员。静态成员函数没有隐藏的this参数!
  class A
  {     int x,y;
        static int shared;
    public:
        A() { x = y = 0; }
        static int get_shared() //静态成员函数
        { 
              return shared; 
        }
        ......
  };
  int A::shared=0;

  ```

## 友元

- 概念在类中 friend 指出**指定与类相关不适合作为成员的程序实体直 接访问类的 public 成员**这些实体就被称为类的友元 
- 特性不对称性b  a 的友元 a 定是 b 的友元无传递性c  b 的友元 b  a 的友元 c 定是 a 的友元
- 利弊:⼀⽅⾯破坏了封装不利于数据保护⼀⽅⾯⼜了数据访问效率 便于使⽤,兼具利弊友元是数据保护和数据访问效率之间的一种折衷方案
- 友元需要在类中用friend显式指出它们可以是全局函数其它的类或其它类的某些成员函数

## 继承

### 概述

- 在定义一个新的类时先把已有的一个或多个类的功能**全部包含进来**然后再在新的类中给出新功能的定义或对已有类的某些功能进行重新定义这个机制称为继承
- 可以继承基类的所有成员部分特殊成员除外
- 不可以继承友元关系**特殊成员基类的构造函数析构函数赋值操作重载函数**
- 基类的友元不是派类的友元基类是某个类的友元时类不是该类的友元
- 定义派生类时一定要见到基类的定义
- C++ protected 类成员访问控制的作是什么 使该成员不能在类外直接访问即该成员不能通过对象访问但是在派类中可以使⽤, 到缓解封装与继承的盾的作⽤。

### 作用域

- 对基类而言派生类成员标识符的作用域是嵌套在基类作用域中的
- 即使派生类中定义了与基类同名但参数不同的成员函数基类的同名函数在派生类的作用域中也是不直接可见的仍然需要用基类名受限方式来使用之

```c++
class A //基类
{       int x,y;
    public:
        void f();
        void g();
};

class B: public A
{       int z;
  public:
        void f();
        void h()
        {   f();  //B类中的f
            A::f();  //A类中的f
        }
}
class B: public A
{       int z;
    public:
        void f(int); //不是重载A的f! 
        void h() 
        {   f(1);  //OK
            f();  //Error
            A::f();  //OK
        }
};

继承方式

  • class <派生类名>:[<继承方式>] <基类名>
  • B的继承方式只影响“B的派生类”以及“B类的对象”对“A类成员”的访问

QQ截图20221202235624

*多继承

  • 多继承使得一个类可以同时继承多个父类,其包含多个父类的所有成 员,可以在程序的一些部分用来替代不同父类来使用,从而实现完全子类型。多继承增强了语言的表达能力,它使得语言能够自然、方便地描述问题领域中的存在于对象类之间的多继承关系。

  • 问题:多继承使得语言特征复杂化,加大了编译程序的难度以及使得消息绑定复杂化等,给正确使用多继承带来困难。

  • 名冲突问题:(不同基类中含有同名成员)

    • 使用基类名受限解决名冲突问题。
      class A
      {       ......
          public:
              void f();
              void g();
      };
      class B
      {       ......
          public:
              void f();
              void h();
      };
      class C: public A, public B
      {       ......
          public:
              void func()
              {   A::f(); //OK,调用A的f。
                  B::f(); //OK,调用B的f。
              }
      };
      
  • 重复 继承问题(菱形继承):

    • 使用虚基类解决重复继承问题。
      class A { int x; ......};//A是虚基类
      class B: virtual public A {......};
      class C: virtual public A {......};
      class D: public B, public C {......};
      D d;
      
  • class <派生类名>: [<继承方式>] <基类名1>,[<继承方式>] <基类名2>, …

  • 基类的声明次序决定:

    • 对基类数据成员的存储安排。
    • 对基类构造函数/析构函数的调用次序(先基类后自身,基类按声明顺序依次构造)
  • 虚继承

  • 虚基类的构造函数由最新派生出的类的构造函数直接调用。
  • 虚基类的构造函数优先非虚基类的构造函数执行。
    class A
    {  int x;
      public:
       A(int i) { x = i; }
    };
    class B: virtual public A //包含虚基类A
    {  int y;
    public:
       B(int i): A(1) { y = i; }
    };
    class C: virtual public A //包含虚基类A
    {  int z;
      public:
       C(int i): A(2) { z = i; }
    };
    class D: public B, public C //包含虚基类A
    {  int m;
    public:
       D(int i, int j, int k): B(i), C(j), A(3) { m = k; }
    };
    class E: public D //包含虚基类A
    {  int n;
      public:
       E(int i, int j, int k, int l): D(i,j,k), A(4) { n = l; }
    };
    ......
    D d(1,2,3);  //这里,A的构造函数由D调用,d.x初始化为3。
    //调用的构造函数及它们的执行次序是:
    A(3)B(1)C(2)D(1,2,3)
    E e(1,2,3,4);  //这里, A的构造函数由E调用,e.x初始化为4。
    //调用的构造函数及它们的执行次序是:A(4)、B(1)、C(2)、D(1,2,3)、E(1,2,3,4) 
    

聚合与组合

概述

  • 类之间除了继承关系外,还存在一种整体与部分的关系,即一个类的对象包含了另一个类的对象,即:聚合与组合。
  • 聚合/组合相比继承的代码复用有哪些优点?能否仅仅通过前两者实现代码复用?为什么?
  • 优点:可以用于表示不含父子继承的其他整体与部分关系,可以用于表示更多的普通包含关系。并且不存在与封装的矛盾,对外只需要 public 这一种接口。
  • 能否只使用聚合和组合:不能。虽然继承的代码复用功能常常可以使用组合来实 现,但是继承更容易实现子类型,可以使用动态绑定,在需要基类对象的地方可 以用派生类对象去代替。而聚合组合关系不具有子类型关系。

聚合

  • 在聚合关系中,被包含的对象与包含它的对象独立创建和消亡,被包含的对象可以脱离包含它的对象独立存在。例如,一个公司与它的员工之间是聚合关系。
  • 聚合类的成员对象一般是采用对象指针表示,用于指向被包含的成员对象,被包含的成员对象是在外部创建,然后加入进来的。
class A { ...... };
class B //B与A是聚合关系
{ A *pm; //指向成员对象
public:
   B(A *p) { pm = p; } //成员对象在聚合类对象外部创建,然后传入
   ~B() { pm = NULL; } //传进来的成员对象不再是聚合类对象的成员
   ......
};
......
A *pa=new A; //创建一个A类对象
B *pb=new B(pa); //创建一个聚合类对象,其成员对象是pa指向的对象
......
delete pb; //聚合类对象消亡了,其成员对象并没有消亡
...... // pa指向的对象还可以用在其它地方
delete pa; //聚合类对象原来的成员对象消亡

组合

  • 在组合关系中,被包含的对象随包含它的对象创建和消亡,被包含的对象不能脱离包含它的对象独立存在。例如,一个人与他的头、手和脚之间则是组合关系。
  • 组合类的成员对象一般直接是对象,有时也可以采用对象指针表示,但不管是什么表示形式,成员对象一定是在组合类对象内部创建并随着组合类对象消亡。
  • 实际上,private继承已经退化成组合了!
    class A//case 1
    { ......
    };
    class C //C与A是组合关系
    { A a;
    public:
      ......
    };
    ......
    C *pc=new C; //创建一个组合类对象,其成员对象在组合类对象内部创建
    .....
    delete pc; //组合类对象与其成员对象都消亡了
    
    class A// case 2
    { ......
    };
    class C //C与A是组合关系
    { A *pm; //指向成员对象
    public:
      C() { pm = new A; } //成员对象随组合类对象在内部创建
      ~C() { delete pm; } //成员对象随组合类对象消亡
      ......
    };
    ......
    C *pc=new C; //创建一个组合类对象,其成员对象在组合类对象内部创建
    .....
    delete pc; //组合类对象与其成员对象都消亡了
    

虚函数与动态绑定

动态绑定原理

class A //基类
{     int x,y;
    public:
      void f() { x++; y++; }
      ......
};
class B: public A //派生类
{     int z;
    public:
      void g() { z++; } 
     ......
};
//合法:基类指针指向派生类
A a;
B b;
b.f(); //OK,基类的操作可以实施到派生类对象
a = b;  //OK,派生类对象可以赋值给基类对象,          
          //属于派生类但不属于基类的数据成员将被忽略
A *p = &b;  //OK,基类指针变量可以指向派生类对象
......
void func1(A *p); 
void func2(A &x);
void func3(A x);
func1(&b); func2(b); func3(b); //OK
//不合法:派生类指针指向基类
A a;
B b;
a.g(); //Error,a没有g这个成员函数。
b = a;  //Error,它将导致b有不一致的成员数据
              //(a中没有这些数据)。
B *q = &a;  //Error,
                 //操作“q->g();”将修改不属于a的数据!
......
void func1(B *p); 
void func2(B &x);
void func3(B x);
func1(&a); func2(a); func3(a); //Error

概述

  • 消息的多态性体现为:相同的一条消息可以发送到不同类的对象,从而会得到不同的解释(处理)。
  • 静态绑定:在编译时刻根据对象的类型决定采用哪一个消息处理函数
  • 动态绑定:在程序运行时决定采用哪一个消息处理函数。 在基类中用虚函数指出动态绑定的成员函数,在派生类中对成员函数进行重写, 并且通过指针或引用来获取对象
  • 构造函数不能是虚函数,析构函数可以是并且通常都是虚函数
    • 派生类指针指向派生类释放时依次调用派生类析构函数和基类析构函数
    • 基类指针指向派生类释放时只调用基类析构函数
    • 基类指针指向派生类且析构函数为虚函数则依次调用派生类析构函数和基类析构函数
  • 只有类的成员函数才可以是虚函数,但静态成员函数不能是虚函数。
  • 虚函数:
  • 指定消息采用动态绑定。
  • 指出基类中可以被派生类重定义的成员函数。
  • 对于基类中的一个虚函数,在派生类中定义的、与之具有相同型构的成员函数是对基类该成员函数的重定义
    • 派生类中定义的成员函数的名字、参数个数和类型与基类相应成员函数相同;
    • 其返回值类型与基类成员函数返回值类型或者相同,或者是基类成员函数返回值类型的public派生类
  • 基类中哪些成员函数需要设计成虚函数?
  • 在设计基类时,有时虽然给出了某些成员函数的实现,但实现的方法可能不是最好,今后可能还会有更好的实现方法。
  • 在基类中根本无法给出某些成员函数的实现,它们必须由不同的派生类根据实际情况给出具体的实现。(纯虚函数)
  • 虚函数的空间占用(虚函数表指针):类A的size = size_without_vtable + vptr_count * sizeof(vptr)。其中,vptr_count = 此类直接继承的类的 vptr_count 之和。如果类A定义了新的虚函数,还要再vptr_count=max(vptr_count,1)(vptr复用)。

实现

class A
{       int x,y;
    public:
        virtual void f(); //虚函数
};
class B: public A
{       int z;
    public:
        void f(); 
        void g();
};
void func1(A& x)
{   ......
    x.f(); 
    ......
}
void func2(A *p)
{   ......
    p->f(); 
    ......
}
......
A a;
func1(a); //在func1中调用A::f
func2(&a); //在func2中调用A::f
B b;
func1(b); //在func1中调用B::f
func2(&b); //在func2中调用B::f

class A
{  ......
  public:
    A() { f(); }
    ~A() { f(); }
    virtual void f();
    void g();
    void h() { f(); g(); }//此处实现了动态绑定,成员函数中调用成员函数是通过this指针实现的,因此实际上传入一个指针来尽心函数调用,因此可以自动实现动态绑定                                           
};
class B: public A
{  .......
 public:
   B() { ...... }
    ~B();
    void f(); 
    void g(); 
};
......
A a;  //调用A::A()和A::f
a.f();  //调用A::f
a.g();  //调用A::g
a.h();  //调用A::h、A::f和A::g
//a消亡时会调用A::~A()和A::f

B b;  //调用B::B()、A::A()和A::f//基类的构造函数和析构函数中对虚函数的调用不进行动态绑定。
b.f();  //调用B::f
b.g();  //调用B::g
b.h();  //调用A::h、B::f和A::g
//b消亡时会调用B::~B()、A::~A()和A::f
- 构造函数和析构函数中对虚函数的调用不进行动态绑定

纯虚函数

  • 纯虚函数是没给出实现的虚函数,函数体用“=0”表示,如virtual int f()=0;

抽象类

  • 包含纯虚函数的类称为抽象类。抽象类不能用于创建对象。抽象类的作用是为派生类提供一个基本框架和 一个公共的对外接口。派生类中一定要给出纯虚函数的定义。

虚函数与虚继承的实现原理

虚函数

1. 概述

简单地说,每一个含有虚函数(无论是其本身的,还是继承而来的)的类都至少有一个与之对应的虚函数表,其中存放着该类所有的虚函数对应的函数指针。例: img

其中: - B的虚函数表中存放着B::foo和B::bar两个函数指针。 - D的虚函数表中存放的既有继承自B的虚函数B::foo,又有重写(override)了基类虚函数B::bar的D::bar,还有新增的虚函数D::quz。

2. 虚函数表构造过程

从编译器的角度来说,B的虚函数表很好构造,D的虚函数表构造过程相对复杂。下面给出了构造D的虚函数表的一种方式(仅供参考): img

提示:该过程是由编译器完成的,因此也可以说:虚函数替换过程发生在编译时。

3. 虚函数调用过程

以下面的程序为例: img

编译器只知道 pb 是 B*类型的指针,并不知道它指向的具体对象类型 :pb 可能指向的是 B 的对象,也可能指向的是 D 的对象。

但对于“pb->bar()”,编译时能够确定的是:此处 operator->的另一个参数是 B::bar(因为 pb 是 B*类型的,编译器认为 bar 是 B::bar),而 B:: bar 和 D:: bar 在各自虚函数表中的偏移位置是相等的。

无论pb指向哪种类型的对象,只要能够确定被调函数在虚函数中的偏移值,待运行时,能够确定具体类型,并能找到相应vptr了,就能找出真正应该调用的函数。

B::bar是一个虚函数指针, 它的ptr部分内容为9,它在B的虚函数表中的偏移值为8(8+1=9)。

当程序执行到“pb->bar()”时,已经能够判断pb指向的具体类型了: - 如果pb指向B的对象,可以获取到B对象的vptr,加上偏移值8((char)vptr + 8),可以找到B::bar。 - 如果pb指向D的对象,可以获取到D对象的vptr,加上偏移值8((char)vptr + 8) ,可以找到D::bar。 - 如果pb指向其它类型对象...同理...

4. 多重继承

当一个类继承多个类,且多个基类都有虚函数时,子类对象中将包含多个虚函数表的指针(即多个vptr),例: img

其中:D自身的虚函数与B基类共用了同一个虚函数表,因此也称B为D的主基类(primary base class)。

虚函数替换过程与前面描述类似,只是多了一个虚函数表,多了一次拷贝和替换的过程。

虚函数的调用过程,与前面描述基本类似,区别在于基类指针指向的位置可能不是派生类对象的起始位置,以如下面的程序为例: img

虚继承

虚继承的目的是让某个类做出声明,承诺愿意共享它的基类。其中,这个被共享的基类就称为虚基类。在这种机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含一份虚基类的成员。 实际上为了实现虚继承引入了类似虚函数表指针的vbptr,vbptr 指的是虚基类表指针,该指针指向了一个虚基类表,虚表中记录了虚基类与本类的偏移地址;通过偏移地址,这样就找到了虚基类成员,而虚继承也不用像普通多继承那样维持着公共基类(虚基类)的两份同样的拷贝,节省了存储空间。

QQ截图20221203110322|275

x移到最后,在原来x的位置存储一个x的偏移量指针(指向虚基类表)

操作符重载

概述

  • 操作符重载是指对已有的操作符进⾏重载,使得他们能对⾃定义 类型的对象进⾏操作,是实现多态性的⼀种语⾔机制
  • 基本原则:
  • 只能重载 c++中已经有的操作符,不能臆造新的操作符。部份特殊操作符除外(如".",".*","?: "等)
  • 要遵循已有操作符的语法,不能改变操作数的个数。不能改变原操 作符的优先级和结合性。
  • 尽量遵循已有操作符原来的语义。
  • 操作符重载的两种实现途径:
  • 作为一个类的非静态的成员函数(操作符new、delete除外)。
    • new操作符和delete操作符必须作为静态的成员函数来 重载:
    • 静态函数对象可以通过类名而不是对象名来调用。由于调用 new 时对象还没 有构建,所以不能通过对象来访问成员函数,所以必须作为静态成员函数来重载。
  • 作为一个全局(友元)函数。(<<、>>等)
  • 拷贝构造函数与赋值操作符“=”重载函数的区别:
  • 作⽤不同:拷⻉构造函数在初始化时调⽤,赋值操作符在赋值时 调⽤(A a=b 调⽤的是拷⻉构造函数,A a; a=b 是调⽤赋值操作符)。
  • 语法不同:拷⻉构造函数形如(A (A&a)),赋值操作符重载形如(A& operator=() )并且拷⻉构造函数必须是成员函数。

语法

  • +

    class Complex//成员函数
    {   public:
            Complex operator + (const Complex& x) const//第一个参数为this隐式传递,因此比全局函数少一个参数,这种隐式传递导致有时只能用全局函数重载
            {   Complex temp;
                temp.real = real+x.real;
                temp.imag = imag+x.imag;
                return temp;//返回右值,对临时变量进行操作,不改变原先的值
            }
        ......
    };
    class Complex//全局函数
    {   ......
        friend Complex operator + (const Complex& c1, 
                                   const Complex& c2);
    };
    Complex operator + (const Complex& c1, 
                            const Complex& c2)
    {   Complex temp;
        temp.real = c1.real + c2.real;
        temp.imag = c1.imag + c2.imag;
        return temp;
    }
    

  • ++ --

    class Counter
    {       int value;
        public:
            Counter() { value = 0; }
            Counter& operator ++()  //前置的++重载函数
            {   
                value++;
                return *this;
            }
            const Counter operator ++(int)  //后置的++重载函数,在括号内增加一个int
            {   
                Counter temp=*this; //保存原来的对象
                value++; //写成:++(*this);更好!调用前置的++重载函数
                return temp; //返回原来的对象,返回一个常量,使得返回值只能作为右值使用
            }
    };
    

  • = 要注意深浅拷贝问题

    class String
    {  ......
    String& operator = (const String& s)
    {   if (&s == this) return *this;  //防止自身赋值:a=a
        delete []str;//防止内存泄露
        str = new char[s.len+1];//深拷贝
        strcpy(str,s.str);
       len = s.len; 
        return *this;
    }
    };
    

  • []

char& operator [](int i) {  return str[i]; }//返回左值
char operator [](int i) const { return str[i]; } //用于常量对象,返回右值
  • new delete(必须作为静态的成员函数来重载,不过static可以不写,系统会默认)
  • 系统提供的new和delete操作所涉及的空间分配和释放是通过系统的堆区管理系统来进行的,效率常常不高。
  • 可以对操作符new和delete进行重载,使得程序能以自己的方式来实现动态对象空间的分配和释放功能。
  • 操作符new有两个功能:
    • 为动态对象分配空间
    • 调用对象类的构造函数
  • 操作符delete也有两个功能:
    • 调用对象类的析构函数
    • 释放动态对象的空间
  • 重载操作符new和delete时,重载的是它们的分配空间和释放空间的功能,不影响对构造函数和析构函数的调用。
  • 通常需要使用c风格内存控制实现自定义操作
    • malloc(size),分配一块size个字节大的内存空间,返回值为void*
    • ptd = (double * ) malloc (30 * sizeof(double));
    • memset(void *s, int c, size_t n),从 s 开始对 n 个字节进行初始化,c 实际范围应该在0~~255,因为该函数只能取 c 的后八位进行初始化
  • 针对某个类的动态对象,程序可以自己管理该类对象的空间分配和释放,以提高效率。
    • 第一次创建该类的动态对象时,先从系统管理的堆区中申请一块大的空间,然后把上述大空间分成若干小块,每个小块的大小为该类一个对象的大小,用链表来管理这些小块;在上述链表中为该类对象分配空间。
    • 该类的一个对象消亡时,该对象的空间归还到new操作中申请到的大空间(链表)中,而不是归还到系统的堆区中。
void* operator new(size_t size)//系统自动计算A的大小,把它作为参数(size)去调用new的重载函数。
{   void* p=malloc(size); //调用系统堆空间分配操作。
    memset(p,0,size); //把申请到的堆空间初始化为全“0”。
    return p;
}
  • 重载new时,除了对象空间大小参数以外,也可以带有其它参数void *operator new(size_t size,…);
  • p = new (==...==) A(...);
  • ==...== 表示提供给 new 重载函数的其它参数
  • ... 表示提供给A类构造函数的参数
void operator delete(void *p)//第二个参数可有可无,如果有,则必须是size_t类型
  {
   free(p);
  }
#include <cstring>
class A
{       ...... //类A的已有成员说明。
    public:
        static void *operator new(size_t size);
        static void operator delete(void *p);
    private:
        A *next; //用于组织A类对象自由空间结点的链表。
        static A *p_free; //用于指向A类对象的自由空间链表头。
};
const int NUM=32; 
void *A::operator new(size_t size)
{   if (p_free == NULL)
    {   //申请NUM个A类对象的大空间。
      p_free = (A *)malloc(size*NUM);  //一个动态数组
        //在大空间上建立自由结点链表。
        for (int i=0;i<NUM-1; i++)  
           p_free[i].next = &p_free[i+1];
        p_free[NUM-1].next = NULL;
    }
   //从链表中给当前对象分配空间
    A *p=p_free;
    p_free = p_free->next;
    memset(p,0,size);
    return p;
}
void A::operator delete(void *p)
{   ((A *)p)->next = p_free;
    p_free = (A *)p;
}
A *q1=new A;
A *q2=new A;
delete q1;

A *A::p_free=NULL;
  • ()
  • 函数对象:把函数调用也作为一种操作符来看待

    int operator () (int x) //函数调用操作符()的重载函数
        { 
        return x+value; 
        }
    

  • 自定义类型转化:

  • 当不同自定义类型转换操作符同时使用时,可能会无法确定转换的类型,造 成歧义问题。可以通过使用显示转换类型,或在自定义转换操作时用 explicit 禁 止隐式转化。
  • 类中带一个参数的构造函数可以用作从其它类型到该类的转换。
  • 可自定义类型转换重载,从一个类转换成其它类型。
    class A
    {     int x,y;
      public:
          A() { x = 0;  y = 0; }
          A(int i) { x = i; y = 0; }
          A(int i,int j) { x = i;  y = j; }
          explicit operator int() { return x+y; }
      friend A operator +(const A &a1, const A &a2);
    };
    

io库

  • std::cin与std::cout相比scanf和printf的优势:
  • scanf、printf 在编译时不进行参数类型检查,会导致与类型相关的运行错误。 而 cin、cout 不需要单独指定数据的类型和个数,编译时刻根据数据本身来决定 操作的类型和个数,这样可以避免与类型和个数相关的错误。
  • C++的I/O类库中基本的类及应用场景:
  • 面向控制台的I/O iostream:从标准输入设备(键盘)中获得数据,把程序结果从标准(显示器)输出设备输出。
  • 面向文件的I/O fstream:从外存文件获得数据,把程序结果存到外存文件中。
  • 面向字符串变量的I/O Stringstream:从程序中的字符串变量中获得数据,把程序 结果保存到字符串变量中。

重载

  • <<>>重载只能是全局函数,不能用成员函数
  • 因为cout<<c会被解释成operator<<(cout, c),由于cout在左边(而实际上c的this指针会在左边),所以不能用成员函数重载
class A
{       int x,y;
    public:
        ......  
        virtual void display(ostream& out) const
        {   out << x << ',' << y ; }
};
ostream& operator << (ostream& out, const A& a)//因为一般不把操作符重载函数做虚函数,因此使用这种方法进行"间接"动态绑定
{   a.display(out); //动态绑定到A类或B类对象的display。
    return out;//方便连续输出cout<<a<<s
}
class B: public A
{       double z;
    public:
        ......
        void display(ostream& out) const
        {   A::display(out); out << ',' << z ;      }
};
A a; B b; A *p=&b;
cout << a << endl << b << endl << *p << endl; //OK

文件读写

  • 每个打开的文件都有一个内部(隐藏)的位置指针,它指出文件的当前读写位置。
  • 进行读/写操作时,每读入/写出一个字节,文件位置指针会自动往后移动一个字节的位置。

  • 判断是否正确读入了数据,可以调用ios类的成员函数fail来实现:bool ios::fail() const;

  • 该函数返回true表示文件操作失败;返回false表示操作成功。
  • 以二进制方式存取文件不利于程序的兼容性和可移植性。例如,
  • 在不同计算机平台上,整型数的各字节在内存中的存储次序可能不一样。
  • 在不同的编译环境下,同样的结构类型数据的尺寸(字节数)可能不一样。
  • 具有性能优势
  • 随机读写:

  • 下面的操作用来指定文件内部读指针的位置:

    • istream& istream::seekg(<位置>);//指定绝对位置
    • istream& istream::seekg(<偏移量>,<参照位置>); //指定相对位置
    • streampos istream::tellg(); //获得指针位置
  • 下面的操作来指定文件内部写指针的位置:
    • ostream& ostream::seekp(<位置>);//指定绝对位置
    • ostream& ostream::seekp(<偏移量>,<参照位置>); //指定相对位置
    • streampos ostream::tellp(); //获得指针位置
  • <参照位置>可以是:ios::beg(文件头),ios::cur(当前位置)和ios::end(文件尾)。

异常处理

概念

错误类型

  • 语法错误:违反语法规则,可由编译程序发现。
  • 逻辑错误:设计不当造成没有完成预期功能,通过对程序静态分析和动态测试发现。
  • 运行异常:程序设计对运行环境考虑不周造成程序运行错误,如内存访问错误等。
  • 可以预见但难以避免,通过对异常进行预见性处理提高程序鲁棒性。

异常处理

  • 处理策略
  • 就地处理
    • exit:终止程序前,会关闭打开的文件,调用全局对象和static存储类型的局部对象的析构函数。
    • abort:直接终止程序不进行任何处理。
    • 往往在发生地不能很好的处理异常
  • 异地处理
    • 通过返回值返回异常导致正常返回值与异常交叉在一起,有时难以区分
    • 通过指针或引用返回需要引入额外参数
    • 通过全局变量返回可能使用者不知道全局变量的存在
    • 以上三种方法正常代码与异常处理代码混在一起,可读性差

结构化异常处理

  • 把有可能出现异常的操作放入try语句块。
  • try{<操作>}如try{f();}
  • try中操作出现异常,则通过throw产生一个异常对象,并中断操作的执行。
  • throw <任意类型(类似返回值)>如throw x;
  • 异常由catch捕获并处理
  • catch (<类型>[<变量>]){<操作>}
  • 变量名可以缺省,此时只关心类型,不关心值
  • 紧跟在try之后
  • 异常的嵌套处理
  • 内层try产生了异常则首先在内层try之后的catch处理,不存在相应的catch则逐步向外查找,如果都找不到系统会进行处理(执行abort)

9异常处理-24

  • 因此函数对外接口出了参数和返回值外增加了可能抛掷的异常

断言

  • 确认程序运行到某一阶段时的状态是否正,决定是否中断执行
  • assert(<表达式>)
  • 表达式为1不进行任何操作
  • 表达式为0输出异常位置,然后执行abort
  • 定义了NDEBUG断言就不会再使用
  • 再头文件之前添加#define NDEBUG

GUI(仅概念)

事件(消息)驱动的程序设计

含义

  • 每个应用程序都有一个消息队列
  • 系统把属于不同应用程序的消息放入各自的消息队列
  • 应用程序从自己的消息队列中获取消息并处理获得的消息
  • 直到取到某个特定消息(结束消息)后结束消息循环
    • 取消息处理消息的过程称为消息循环
  • 应用程序的每个窗口都有一个消息处理函数
  • 消息大部分关联到某一个窗口
  • 应用程序取到消息后会调用相应窗口的消息处理函数

10-1-9

文档-视结构的应用框架

  • 应用框架是一种通用的可以复用的程序结构,规定了程序应该包含的组件及其关系,开发者通过给组件添加业务代码实现不同的应用。复用应用框架使得开发更快质量更高成本更低。
  • 文档-视
  • 文档:用于存储和管理应用程序中的数据
  • 视:显示文档数据,实现对文档数据操作时与用户交互的功能
  • 实现了数据内部表示形式和外部展现形式相互独立
  • 一个文档可以对应多个视对象,即可以用不同方式显示和操作

GUI

概念

  • 含义:即图形用户接口,是人与计算机进行交互的一种方式,由窗口,下拉菜单,对话框的机制构成。用户通过鼠标键盘等发出指令,计算机用图形输出来反馈操作的结果。
  • 优点:gui 比控制 台接口更为直观,便于用户进行操作,使得程序更为易用。

泛型(类属)程序设计

模板

概念

  • 一个程序能对多种类型的数据进行操作或描述的特性称为类属或泛型
  • 类属函数:能对不同数据类型完成相同操作的函数
  • 类属类:成员类型可变,但操作类型不变
  • 对具有类属性的程序实体进行程序设计的技术为:泛型程序设计
  • 传统通用指针(void*)实现的问题:
  • 麻烦,需要大量指针操作
  • 容易出错,编译程序无法进行类型检查
  • 对自定类型操作时可能需要自定义拷贝构造函数和重载操作符
  • 模板的复用:模板也属于一种多态,称为参数化多态。使用一个模板之前首先要对其实例化(用一个具体的类型去替代模板的类型参数),而实例化是在编译时刻进行的,它一定要见到相应的源代码,否则无法实例化!(所以要尽量要把模板的声明和实现都放在同一个头文件中)因此,模板属于源代码复用。

函数模板

  • 只需要在函数定义(声明)前面添加:

    template<class T1,class T2,...[,int x]>//T1,T2均为类型名,可在函数参数等处使用,x为非类型参数
        //比如
    template <class T> 
    T max(T a, T b)
    { return a>b?a:b;
    }
    

  • 实例化(模板实参推导)

  • 使用函数模板所定义的函数,首先必须要对函数模板进行实例化
  • 函数模板的实例化通常是隐式的(自动识别类型)
  • 有时,编译程序无法根据调用时的实参类型来确定所调用的模板实例函数如max(int x,double y)
    • 显式实例化如max<double>(x,m);
  • 如果使用了非类型参数,那么必须显示实例化如f<int,10>(1);

类模板

  • 与函数模板类似
  template <class T1,class T2,...[,int x]> 
  class <类名>
  { 
      <类成员说明>//可以使用类型T1、T2
   public:
      <返回值> <函数名>(...); 
  }
  template <class T1,class T2,...[,int x]> 
  <返回值> <类名><T1,T2...[,int x]>::<函数名>(...);//类名后要实例化
  //类属类的操作依赖于类属函数实现

  template <class T, int size> //例
  class Stack
  {     T buffer[size];
        int top;
    public:
        Stack() { top = -1; }
        void push(const T &x);
        void pop(T &x);
  };
  template <class T,int size> 
  void Stack <T,size>::push(const T &x) { ...... }
  template <class T, int size> 
  void Stack <T,size>::pop(T &x) { ...... }

  ```

- 类属类的实例化必须显示指出比如vector使用时就要指定元素类型
- 不同类模板实例之间**不共享**类模板中的静态成员在同类型之间共享)。

- **友元**
  - 普通函数做友元
  ```c++
  template <class T> //类模板A的定义
  class A
  { T x,y;
    ......
    friend void f(A<T>& a); //f是多个重载的函数,
                 //它们与A的实例是一对一友元!
  };
  void f(A<int>& a) { ...... } //该f仅是A<int>的友元,
  void f(A<double>& a) { ...... } //该f仅是A<double>的友元
  ......
  A<int> a1; //实例化A<int>
  A<double> a2; //实例化A<double>
  A<char *> a3; //实例化A<char *>
  f(a1); //调用f(A<int>&)
  f(a2); //调用f(A<double>&)
  f(a3); //调用f(A<char *>&),但连接时出错,该函数不存在!
  ```

  - 函数模板做友元
    - 声明类属类还要带上模板
![](https://thdlrt.oss-cn-beijing.aliyuncs.com/16760294475967.jpg)


  ```c++
  template <class T> class A; //类模板A的声明(f的定义中要用到)
  template <class T> void f(A<T>& a) { ...... } //f是函数模板

  template <class T> //类模板A的定义
  class A
  { T x,y;
    ......
    template <class T1> friend void f(A<T1>& a); //整个模板f是友元,
                    //f的实例与A的实例是多对多友元
  };
  ......
  A<int> a1; //实例化A<int>
  A<double> a2; //实例化A<double>
  A<char *> a3; //实例化A<char *>
  f(a1); //实例化f<int>并调用之,它是A所有实例的友元
  f(a2); //实例化f<double>并调用之,它是A所有实例的友元
  f(a3); //实例化f<char *>并调用之,它是A所有实例的友元
  ```

 **判断两个类是否相同**

```c++
template<class T>
class check
{
public:
    T a;
    static int k;
};
template<class T>//模板类静态成员变量的初始化
int check<T>::k = 0;
template<class T1,class T2>
bool is_same_type(T1 a,T2 b)
{
    check<T1>::k = 1;//利用同类型模板类共享静态成员变量
    check<T2>::k = 2;
    return check<T1>::k == check<T2>::k;
}

stl标准库

概念

  • 容器:容器用于存储序列化的数据
  • 算法:算法用于对容器中的数据元素进行一些常用操作
  • 迭代器:迭代器实现了抽象的指针功能,它们指向容器中的数据元素,用于对容器中的数据元素进行遍历和访问。
  • 迭代器是容器和算法之间的桥梁:传给算法的不是容器,而是指向容器中元素的迭代器,算法通过迭代器实现对容器中数据元素的访问。这样使得算法与容器保持独立,从而提高算法的通用性。

  • lambda表达式在编程中提供了什么便利:

  • 对于一些临时用一下的简单函数,如果也要先定义函数并对其命名再使用是 很麻烦的,lambda 函数是一种匿名函数机制,可以把函数的定义和使用合二为 一。可以方便一些不常用的临时函数的使用。

容器

  • 种类
  • vector:用于需要随机访问,并且主要在尾部增减元素的场景
  • list:用于经常在任意位置插入删除元素的场景
  • deque:用于需要随机访问并且需要在两端增减元素的场景
  • stack:用于仅在尾部增减访问的场景
  • queue:用于仅在尾部增加头部删除的场景
  • priority_queue:用于需要按照优先级排序出队的场景
  • map multimap unordered_map:用于需要根据关键字访问键值的场景,multiplemap可以有重复关键字,unordered_map是map的无序版本。
  • set multiset unordered_set:用于需要查找关键是否存在,multipleset可以有重复关键字,unordered_set是set的无序版本
  • basic_string:用于元素为字符类型,有string和wstring实例
  • 如果容器的元素类型是一个类,则针对该类可能需要:自定义拷贝构造函数和赋值操作符重载函数以及重载小于操作符(<)

算法

  • 一个算法能接收的迭代器的类型是通过算法模板参数的名字来体现的。
template <class InIt, class OutIt>
OutIt copy(InIt src_first, InIt src_last, 
                OutIt dst_first)
//src_first和src_last是输入迭代器,算法中只能读取它们指向的元素。
//dst_first是输出迭代器,算法中可以修改它指向的元素。
void sort(RanIt first, RanIt last);
  • 有些算法可以让使用者提供一个函数或函数对象作为自定义操作,其参数和返回值类型由相应的算法决定。

  • Op或Fun:一元操作,需要一个参数

  • BinOp或BinFun:二元操作,需要两个参数

    OutIt transform(InIt src_first, InIt src_last, 
                               OutIt dst_first, Op f);
    OutIt transform(InIt1 src_first1, InIt1 src_last1, 
                           InIt2 src_first2, OutIt dst_first, BinOp f);
    //可以为一维也可以为二维
    ```

## 迭代器

- 种类
  - 输出迭代器可以修改指向的元素支持* ++输入输出是只得对于算法而言的InIt
  - 输入迭代器只能读取支持* -> ++ == != OutIt
  - 前向迭代器可以读取修改元素支持* -> ++ == !=
  - 双向迭代器可以读取修改元素支持* -> ++ -- == !=
  - 随机访问迭代器可以读取修改元素支持* -> [] ++ -- + - += -= == != < <= > >=RanIt
![](https://thdlrt.oss-cn-beijing.aliyuncs.com/QQ%E6%88%AA%E5%9B%BE20221201223206.png)

  - 反向迭代器用于对容器元素从尾到头进行反向遍历++操作是往容器首部移动--操作是往容器尾部移动
    - 可以通过容器类的成员函数rbegin和rend可以获得容器的尾和首元素的反向迭代器
  - 插入迭代器用于在容器中指定位置插入元素其中包括
    - back_insert_iterator用于在尾部插入元素
    - front_insert_iterator用于在首部插入元素
    - insert_iterator用于在任意指定位置插入元素
    - 它们可以分别通过全局函数back_inserterfront_inserter和inserter来获得函数的参数为容器

- 与容器对应关系
  - vector deque basic_string 使用随机迭代器
  - list map set使用双向迭代器
  - queue stack priority_queue 不支持迭代器

# 程序设计范式

## 命令式程序设计

- 需要对如何做进行详细描述包括操作步骤和状态变化
- 代表
  - 过程式程序设计
    - 程序由一个或多个过程函数或子程序组成
    - 每个过程执行一个特定的任务可以被其他过程调用
    - 数据和过程是分开的数据通常通过参数传递到过程
  - 面向对象程序设计
    - 面向对象程序设计是一种基于对象的编程范式对象是类的实例类定义了对象的属性和行为
    - 强调通过类和对象进行模块化和复用

## 声明式程序设计

- 只需要对做什么进行描述不需要给出操作步骤和状态变化

- 有良好的数学理论支持易于保证程序的正确性并且设计出的程序比较精炼和具有潜在的并行性
- 代表
  - 函数式程序设计
  - 逻辑式程序设计

### 函数式程序设计

- 是指把程序组织成一组数学函数计算过程体现为基于一系列函数应用把函数作用于数据的表达式求值
- 特征
  - 函数
    - 以相同的参数调用一个函数总得到相同的值。(引用透明
    - 除了产生计算结果不会改变其他任何东西。(无副作用
  - 没有状态计算体现为数据之间的映射它不改变已有数据而是产生新的数据。(无赋值操作
  - 函数也是值函数的参数和返回值都可以是函数可由已有函数生成新的函数。(高阶函数
  - 递归是主要的控制结构重复操作采用函数的递归调用来实现而不采用迭代循环)。
  - 表达式的惰性延迟求值Lazy evaluation):需要用到表达式的值的时候才会去计算它
  - 潜在的并行性由于程序没有状态以及函数的引用透明和无副作用等特点因此一些操作可以并行执行

- 基本手段

  - **递归和尾递归**
    - 由于函数递归调用效率低递归调用层次有限制因此常采用尾递归即递归调用是函数的最后一步操作
    - 尾递归调用便于编译程序优化由于递归调用后不再做其它事从而不会再使用当前栈空间的内容因此递归调用时可重用当前的栈空间可以自动转成迭代
  - 过滤/映射/规约操作Filter/Map/Reduce
  - 部分函数应用Partial Function Application
  - **柯里化Currying**
    - 把一个多参数的函数变换成一系列单参数的函数它们分别接收原函数的第一个参数第二个个参数......
      - 数学上对单参数函数的研究模型可以用到多参数函数上
      - 对程序设计不必把一个多参数的函数所需要的参数同时提供给它可以逐步提供
    - 偏函数应用和柯里化的区别
      - 偏函数应用是通过固定原函数的一些参数值来得到一个参数个数较少的函数对该函数的调用将得到一个具体的值
      - 柯里化则是把原函数转换成由一系列单参数的函数构成的函数链对柯里化后的函数调用将得到函数链上的下一个函数对函数链上最后一个函数的调用才会得到具体的值
      - 偏函数应用可以按任意次序绑定原函数的参数值而柯里化只能依次绑定原函数的参数值

  ```c++
  //例如,对于下面带两个参数的函数f(x,y):
  #include <functional>
  using namespace std;
  int f(int x,int y) { return x+y; }
  //可把它变成一个单参数函数f_cd(参数为f的参数x),该函数返回另一个单参数函数(参数为f的参数y)
  function<int (int)>  f_cd(int x) //返回值是个单参数函数
  { return bind(f,x,_1);
     //或
     return [x](int y)->int { return f(x,y); };
  }
  cout << f(1,2);
  cout << f_cd(1)(2);
  ```

```c++
#include<iostream>
#include<functional>
using namespace std;
struct S {
    int m1;
    int m2;
};
int func(int p1, int p2, int p3, S p4) {
    return p1 + p2 + p3 + p4.m1 + p4.m2;
}
auto curry_func(int p1)
{
    return [=](int p2)
    {
        return [=](int p3)
        {
            return[=](S p4)
            {
                return func(p1, p2, p3, p4);
            };
        };
    };
}
int main() {
    int p1 = 2, p2 = 4, p3 = 5;
    S p4{ 8,7 };
    cout << func(p1, p2, p3, p4)<<endl;
    cout << curry_func(p1)(p2)(p3)(p4) << endl;
    return 0;
}

逻辑式程序设计

  • 基于形式逻辑的编程范式
  • 程序由逻辑声明(事实和规则)组成
  • 推理引擎根据声明和查询进行推理并生成结果

宏定义

简单宏定义

  • #define <宏名/标识符> <字符串>
  • 末尾不添加分号
  • 为了便于书写,多行宏定义可以在末尾添加 \
  • 嵌套使用宏
    #define M 5                 // 宏定义
    #define MM M * M            // 宏的嵌套
    printf("MM = %d\n", MM);    // MM 被替换为: MM = M * M, 然后又变成 MM = 5 * 5
    
  • # 将宏参数转化为字符串字面量 #define TO_STRING(x) #x
  • ## 对字符串进行拼接
  • 可变参数,将两个语言符号组合成一个语言符号
    • #define NAME(n) num ## n
    • 对于 NAME(0) 就会被替换为 num0
  • 可变参数宏 … 和 __VA_ARGS__
    • ... 表示参数,__VA_ARGS__ 用在替换文本中
    • 直接使用 __VA_ARGS__ 会按照传入宏的实参列表原样展开,各参数之间保持原有的逗号分隔格式
    • 对可变参数的每一项进行操作
      // 辅助宏:用于获取第一个参数
      #define GET_FIRST_ARG(arg1, ...) arg1
      
      // 辅助宏:用于“剥离”第一个参数,并递归处理剩余的参数
      #define PROCESS_REMAINING_ARGS(...) PRINT_ARGS(__VA_ARGS__)
      
      // 打印单个参数的宏
      #define PRINT_SINGLE_ARG(arg) printf("%d\n", arg);
      
      // 处理和打印所有参数的宏
      // 这里使用了GNU C的逗号省略扩展
      #define PRINT_ARGS(first_arg, ...) \
          do { \
              PRINT_SINGLE_ARG(first_arg); \
              if(sizeof((int[]){__VA_ARGS__}) > 0) { \
                  PROCESS_REMAINING_ARGS(__VA_ARGS__); \
              } \
          } while(0)
      

带参数的宏定义

  • #define <宏名>(<参数表>) <字符串
    • #define S(a,b) a*b
  • 由于只简单的进行字符串替换,因此如果传入的实参是表达式要注意运算优先级的问题
    • 对于 #define S(r) r*r 使用 area=S(a+b); 会被替换为 area=a+b*a+b,因此应该改进为 #define S(r)((r)*(r))
    • 或者对于 #define N 2+2 使用 int a=N*N; 同样也会出现问题,因此要注意括号的使用
  • “” 内的不会被当做实参,如 #define FUN(a) "a"

宏定义的返回值

  • 对于有多个语句的宏定义可以使用 GUN C 扩展语句表达式实现
    • 最后一个语句会作为返回值
      #define MAX(a, b) \
        ({ typeof(a) _a = (a); \
           typeof(b) _b = (b); \
           _a > _b ? _a : _b; })
      

内联汇编

基本语法(gcc)

  • gcc 默认使用 AT&T 风格
  • 将要执行的汇编指令作为字符嵌套 asm("assembly code");

使用 c 语言中的变量

asm ( assembler template 
    : output operands                  //指定输出结果的变量
    : input operands                   //指定输入变量
    : list of clobbered registers      //告诉编译器哪些寄存器会被汇编代码修改的列表
    );
- 汇编模板:-使用%后跟数字来引用操作数(例如%0%1等)。 - 输出操作数:格式为 "约束"(变量),使用=指示这是一个输出操作数,+表示既是输入也是输出。 - 输入操作数:- 输入操作数指定汇编代码读取的C变量。 - 寄存器破坏列表:- 告诉编译器该汇编代码会修改哪些寄存器,防止编译器做出错误的假设。
int sum, a = 10, b = 20;
asm ("add %2, %1\n\t"
     "mov %1, %0"
     : "=r"(sum)    /* 输出:sum,'='表示写操作,'r'表示任何通用寄存器 *///对应%0
     : "r"(a), "r"(b) /* 输入:a和b,'r'表示放入任意通用寄存器 *///对应%1,%2
     :               /* 此例中未使用寄存器破坏列表 */
     );
- 约束条件: - r:表示任意通用寄存器。 - m:表示内存地址。 - i、n:表示立即数。 - g:表示可以是寄存器、内存或立即数,具体取决于编译器优化。

补充

判断系统类型

#if __x86_64__
...
#else
...
#endif

上下文切换 setjmp.h

  • setjmp 保存当前的环境
    • setjump 保存环境(寄存器等状态),之后立即返回 0
  • longjmp 恢复之前保存的环境
    void co_yield() {
        int val = setjmp(current->context);
        if (val == 0) {
            // 第一次返回
        } else {
            // 由longjmp触发的第二次返回
        }
    }
    
  • setjmp 第一次返回:当 co_yield 函数被调用时,setjmp(current->context) 首先保存当前的协程(也就是 current 协程)的环境(包括寄存器等状态)到 current->context 中。在这之后,setjmp 立即返回0。之后冲虚选择一个待运行的协程运行
  • setjmplongjmp触发的第二次返回:在某个时刻,当其他某个协程再次通过longjmp(current->context, val)跳回到这个协程时,setjmp会返回一个非零值(这个值由longjmp第二个参数指定)。
    int main() {
        int n = 0;
        jmp_buf buf;
        setjmp(buf);
        printf("Hello %d\n", n);
        longjmp(buf, n++);
    }
    
  • 这里 n 会递增,这是应为 n 存储在栈上,buf 只存储栈指针等信息,对栈的修改不会被恢复
    • 使用 register 就不会有这个问题了

栈的切换 stack_switch_call

static inline void
stack_switch_call(void *sp, void *entry, uintptr_t arg) {
    asm volatile (
#if __x86_64__
        "movq %0, %%rsp; movq %2, %%rdi; jmp *%1"
          :
          : "b"((uintptr_t)sp),
            "d"(entry),
            "a"(arg)
          : "memory"
#else
        "movl %0, %%esp; movl %2, 4(%0); jmp *%1"
          :
          : "b"((uintptr_t)sp - 8),
            "d"(entry),
            "a"(arg)
          : "memory"
#endif
    );
}
- void *sp:这是新栈的顶部地址。函数将会把当前的栈指针(rspesp,取决于是64位还是32位架构)设置为这个值,从而切换到新的栈上执行。 - void *entry:这是要调用的函数的地址。一旦栈切换完成,程序将通过无条件跳转(jmp)指令跳转到这个地址执行。 - uintptr_t arg:这是传递给 entry 指定的函数的参数。对于64位架构,这个参数直接通过 rdi 寄存器传递(遵循 x86-64的调用约定);对于32位架构,这个参数被放置在新栈的某个特定位置,以便被调用的函数能够按照 C 的调用约定通过栈访问它。

  • 如果希望在程序运行前完成一系列的初始化工作 (例如分配一些内存),可以定义 __attribute__((constructor)) 属性的函数,它们会在 main 执行前被运行
    __attribute__((constructor)) void init() {
        ...
    }