第十五章 面向对象程序设计

OOP的核心思想是多态性(polymorphism)。

  • 多态即具有继承关系的多个类型
  • 引用或指针的静态类型与动态类型不同是C++支持多态的根本

本章内容:

  • 基类与派生类语法及其类型转换
  • 虚函数、纯虚函数、抽象基类
  • 访问控制:成员访问控制、派生访问控制、using声明
  • 继承过程中的函数解析和作用域
  • 继承过程中的构造函数和(合成)拷贝控制成员

15.1 OOP:概述

  • 基类,派生类、类派生列表
  • [[ch15-面向对象程序设计#15.3 虚函数|虚函数]]:基类将函数声明为虚函数,派生类定义适合自己的版本
  • 动态绑定(dynamic binding,又称运行时绑定):
    • 使用基类的引用或指针调用一个虚函数时将发生动态绑定(即在运行时,根据传入参数的类型选择函数版本)

15.2 定义基类和派生类

定义基类

  • 如果函数希望被派生类覆盖,则基类将其定义为虚函数;否则基类中的函数希望派生类直接继承而且不要改变
  • 基类中的虚函数 ^ae3c55
    • 基类通常都应该定义一个【虚析构函数】,即使该函数不执行任何实际操作。
    • 除构造函数之外的任何非静态函数都可以定义为虚函数
    • 如果基类把一个函数声明为虚函数,则该函数在派生类中隐式的也是虚函数
  • [[ch15-面向对象程序设计#^8deb68|访问控制]]

定义派生类

  • 【类派生列表中的访问说明符】用于控制【派生类从基类继承而来的成员】是否【对派生类的对象】可见
    • 派生类必须将继承而来的成员函数中需要覆盖的那些重新声明
    • 如果一个派生是公有的,则基类的公有成员也是派生类接口的组成部分,因此可以将派生类类型的对象绑定到基类的指针或引用上
  • 派生类中的虚函数 ^91e48b
    • 如果派生类没有覆盖基类中的某个虚函数,则派生类会直接继承其在基类中的版本
    • 派生类必须在其内部对所有重新定义的虚函数进行声明,virtual关键字可加可不加
    • 派生类中覆盖虚函数时,形参类型和返回值类型必须相同
      • 返回值不相同只有一个例外:虚函数返回类型是类本身的指针或引用,比如类Base派生出类Derived,则基类Base的虚函数返回值可以返回*Base,而派生类Derived中覆盖的虚函数可以返回*Derived
    • C++11使用override显式指明重新定义虚函数(override放在引用限定符之后)
  • 派生类到基类的类型转换
    • 基类与派生类之间的[[#类型转换与继承]]
    • [[ch15-面向对象程序设计#^ec492b|派生类向基类转换的可访问性]]
  • 派生类构造函数:每个类控制自己的成员初始化过程
    • 派生类必须使用【基类的构造函数】来初始化它的基类部分,【派生类的构造函数】通过【[[ch07-类#^742596|构造函数初始值列表]]】来将实参传递给【基类构造函数】,同时在【构造函数初始值列表】初始化自己的数据成员
    • 遵循基类的接口,尽管从语法上可以在派生类中给基类的公有成员直接进行赋值
    • 除非特别指出,否则派生类对象的基类部分会像数据成员一样执行默认初始化
    • 顺序:先初始化基类部分,在按声明顺序依次初始化派生类的成员
  • 静态成员
    • 如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一实例。
    • 静态成员遵循通用的访问控制规则
  • 派生类的声明中不包含它的派生列表,派生列表必须于派生类的定义一起出现
  • 如果想用某个类作为基类,该类必须已经定义而非仅仅声明
    • 一个类不能派生它本身
    • 直接基类与间接基类:派生类构造函数只初始化它的直接基类
  • 防止继承:在类名后面跟一个关键字final
    • final关键字除了防止继承,还可以防止函数被覆盖

类型转换与继承

  • 可以将【指向基类的指针/引用】绑定到派生类对象上,因此【指向基类的指针/引用】的静态类型与动态类型可能不一致
    • 静态类型:变量或表达式类型在编译时已知
    • 动态类型:变量或表达式类型在运行时才可知,是指针指向的内存中对象的类型
  • 编译器自动将【指向派生类的指针/引用】转换为【指向基类的指针/引用】
  • 不存在【指向基类的指针/引用】隐式转换到【指向派生类的指针/引用】
    • 除了使用强制类型转换:使用dynamic_cast,将【指向基类的指针/引用】安全的转换成【指向派生类的指针/引用】,将在运行期进行安全检查
    • 如果已知某个基类到派生类的转换是安全的,可以使用static_cast强制覆盖掉编译器的检查工作
  • 派生类向基类的自动类型转换只对指针或引用类型有效,对象之间不存在类型转换。
    • 如果表达式不是引用/指针,则它的静态类型与动态类型永远一致
    • 有时确实希望将派生类对象转换成基类类型,派生类的部分被切掉(sliced down)了

15.3 虚函数

  • [[ch15-面向对象程序设计#^ae3c55|基类中的虚函数]]
  • [[ch15-面向对象程序设计#^91e48b|派生类中的虚函数]]
  • virtual关键字只能出现在类内部的声明语句,而不能用于类外部的函数定义
  • 必须为每一个虚函数提供定义,不管是否被用到(因为编译器也无法确定哪个虚函数会被使用)
  • 默认实参
    • 如果虚函数中有默认实参,则默认实参的值由本次调用的指针/引用的静态类型决定
    • 因此可能使用的是基类中的默认实参,但是实际运行的是派生类的虚函数版本
    • 最好基类和派生类中的默认实参一致
  • 回避虚函数:对虚函数的调用不要进行动态绑定,而强迫执行虚函数的某个版本
    • 使用作用域运算符::)来回避虚函数
    • 通常,只有成员函数(或友元)中才需要使用使用回避虚函数的机制,比如一个派生类的虚函数调用它覆盖的基类的虚函数版本(如果不使用回避机制,在运行时该调用将被解析为派生类版本自身的调用,导致无限循环递归)
    • 例子:Derived* p = Derived(); p->Base::func();

15.4 抽象基类

  • 纯虚函数:一个没有意义的虚函数
    • 纯虚函数无需定义,或者也可以提供定义,但是函数体必须定义在类的外部
    • 声明时末尾加上=0将函数声明为纯虚函数,且只能出现在类内部的函数声明中
  • 抽象基类
    • 含有(或未经覆盖直接继承)纯虚函数的类
      • 如果派生类不覆盖抽象基类中的纯虚函数,则该派生类仍然是抽象基类
    • 抽象基类负责定义接口,后续的其他类可以覆盖该接口
    • 不能创建抽象基类的对象。

15.5 访问控制与继承

  • 派生类中继承而来的成员的访问权限受到两个因素影响:基类中成员的访问控制(成员访问说明符)、类派生列表中的访问控制(派生访问说明符
    • 基类中成员的访问控制 ^8deb68
      • public:基类本身、派生类、友元、类对象都可以访问
      • protected : 基类本身、派生类、友元可以访问,类对象无法访问
        • 派生类和友元可以通过派生类对象访问基类的protected成员,但是不能直接通过基类对象来访问。例子
      • private : 基类本身、友元可以访问,其他都无法访问
    • 类派生列表中的访问控制:基类中public/protected的成员,在派生类中的访问说明符
      • 如果继承是public的,则成员遵循原来的访问说明符
      • 如果继承是private的,则派生类中【从基类中继承而来的成员】是private的
      • 如果继承是protected的,则派生类中【从基类中继承而来的成员】是protected的
  • 派生类向基类转换的可访问性 ^ec492b
    • 总体原则:对于继承树中的某个节点,如果基类的共有成员是可以访问的,则派生类可以向基类进行类型转换;反之则不行。示例说明
    • 只有当继承是public的时,派生类才能转换到基类(基类指针指向派生类对象)
    • 不管D以什么方式继承B,【D的成员函数和友元函数】中【派生类D可以转换到直接基类B】
    • 如果D继承B的方式是public的或protected的,则【D派生类的成员和友元】可以使用【D向B的类型转换】;反之如果是私有的,则不能使用
  • 友元关系是单向的,不具有传递性,且不能继承
    • 如果Pal是基类Base的友元,则Pal可以访问Base的对象的成员和派生类Derived中属于Base部分的成员
     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{
        friend class Pal;
    protected:
        int protected_val; // 每个类负责控制自己成员的访问权限,protected_val访问权限由Base控制(即使Base是内嵌在派生类对象中)
    };
    class Derived: public Base{
        friend class PPal;
    protected:
        int de_protected_val;
    };
    class Pal{ // 基类的友元
    public:
        int f(Derived d) {return d.protected_val;} // protected_val的访问控制权限由Baes控制,这种可访问性包括了Base对象内嵌在其派生类对象中的情况
        int g(Derived d) {return d.de_protected_val;} // error: 基类的友元不能随便访问派生类的成员
    };
    class DPal: public Pal{ // 【基类友元】的派生类
    public:
        int k(Derived d) {return d.protected_val;} // error:友元关系不能继承
    };
    class PPal{ // 派生类的友元
    public:
        int h(Derived d) {return d.protected_val;}
    };
    
  • 改变派生类个别成员的可访问性:使用using。 ^f2ba1c
    • 将基类的public/protected成员使用using进行标记,放在派生类public/protected/private的位置,就获得了相应的访问级别
    • 派生类只能针对基类的public/protected成员使用using声明改变可访问性(因为派生类无法访问基类的private成员)
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    
    class Base{
        public:
            int pub_func();
            int pub_func(int i);
        protected:
            int pro;
        private:
            int pri;
    }
    class Derived: private Base{ // Derived中从基类Base中继承而来的成员默认都是private的
        public:
            using Base::pub_func; // 使用using声明,两个重载的pub_func现在都被添加,都是public的
        protected:
            using Base::pro; // 使用using声明,pro现在是protected的
        // 派生类无法访问到基类的private成员    
    }
    
  • classstruct
    • 默认使用clas定义的类成员是private的,使用struct定义的类成员是public的
    • 默认使用class定义的派生类是私有继承的,使用struct定义的派生类是公有继承的。
    • 除此之外再无不同

15.6 继承中的类作用域

  • 派生类的作用域嵌套在其基类的作用域之内
  • 函数调用的解析过程:p->mem()或者obj.mem()
    1. 确定p或obj的静态类型
    2. 名字查找:在该静态类型对应的类中查找mem,如果找不到,则依次在直接继承中不断查找,直到继承链的顶端
    3. 类型检查:假如找到mem,进行常规的类型检查,以确认本次调用是否合法
    4. 假如合法,编译器根据调用的是否为虚函数产生不同的代码:
      • mem是虚函数且通过指针或引用来调用:编译器产生的代码将在运行时确定到底是运行该虚函数的哪个版本,依据是对象的动态类型
      • mem不是虚函数或者通过对象进行调用:产生一个常规的函数调用
  • 函数调用的解析过程导致的现象:
    • 一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的
      • 比如将基类指针绑定到派生类,基类指针静态类型是指向基类(因此无法调用派生类特有的成员),但是动态类型是指向派生类
    • 派生类的成员将隐藏同名的基类成员,即使成员函数形参列表不同
      • 可以使用域运算符::使用被隐藏的基类成员
      • 除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字
      • 派生类中继承来的虚函数要保持相同的参数列表,否则派生类定义的是一个新函数,该新函数不是虚函数
    • 虚函数解析过程:
      • 在编译期,基类指针在静态类型中进行名字查找和类型检查
      • 在运行期,根据动态类型决定运行虚函数的哪个版本
  • 覆盖重载的函数:
    • 派生类可以覆盖重载函数的0个或多个版本
    • 如果派生类希望所有的重载版本对它来说都是可见的,那么就需要覆盖所有的版本,或者一个也不覆盖(因此到基类中寻找名字)
    • 如果像重写一部分而非全部,可以使用using声明将同名的重载版本都添加到派生类作用域中,然后再重写
    • 根本原因还是相应静态类型中查找到名字后但是类型不匹配,如果只覆盖一部分相当于重载版本变少了
     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 <cstdio>
    #include <iostream>
    using namespace std;
    
    class Base{
    public:
        void f() { cout<<"Base 1"<<endl; }
        void f(int a) { cout<<"Base 2"<<endl; }
        void f(int a, int b) { cout<<"Base 3"<<endl; }
    };
    
    class D1: public Base{
    public:
        void f() { cout<<"D1 "<<endl; } // 只覆盖一个
    };
    
    class D2: public Base{
    public:
        // 一个都不覆盖
    };
    
    class D3: public Base{
    public:
        using Base::f;
        void f(int a) { cout<<"D3 2"<<endl; }
    };
    
    int main(){
        D1 d1; D2 d2; D3 d3; 
    //  d1.f(1); // 报错:因为d1静态类型为D1,D1中有函数f,但是类型检查失败
        d2.f(0); // Base 2
        d3.f(0); // D3 2
    
    //  D1* p = &d1; // 这样定义就错,原因同上
        Base *p = &d1; // p的静态类型为Base,Base中有函数f(int)
        p->f(0); // Base 2
    }
    
  • 重载、重写(覆盖)与隐藏,参考
    • 重载:同一作用域内的几个函数同名但是形参列表不同
    • 隐藏:派生类中的函数屏蔽了与其同名的基类函数,不管参数列表是否相同
    • 重写(覆盖):虚函数重写

15.7 构造函数与拷贝控制

虚析构函数

  • 基类通常应该定义一个虚析构函数,这样最终执行动态类型版本的析构函数
  • 一般来说,如果一个类需要析构函数,那么它也需要拷贝和赋值操作,但是基类的析构函数不遵循该规则
  • 虚析构函数将阻止使用合成的移动操作,即使使用=default显式声明

合成的拷贝控制与继承

  • 派生类的合成拷贝控制成员,通过调用基类的合成拷贝控制成员,来对基类部分进行相应的拷贝、移动、销毁等操作
  • 某些定义基类的方式可能导致部分派生类成员成为被删除的函数:
    • 如果基类中的默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的或者不可访问的函数,则派生类中对应的成员也会是被删除的。
    • 如果基类的析构函数是被删除的或者不可访问的,则派生类中合成的默认和拷贝构造函数也会是被删除的。
    • 如果基类的移动操作是删除的,则派生类中对应的函数也是删除的。
    • 在实际编程中,如果基类没有默认、拷贝或移动构造函数,则一般情况下派生类也不会定义相应的操作。
  • 移动操作与继承
    • 大多数基类都会定义一个虚析构函数,因此基类通常没有合成的移动操作
    • 如果需要移动操作,首先在基类中定义,之后派生类会自动合成移动操作

派生类的拷贝控制成员

  • 当派生类定义了拷贝或移动操作时,该操作通过调用基类的对应成员,来拷贝或移动包括基类在内的整个对象。
  • 与拷贝和移动操作不同,派生类的析构函数只负责销毁由派生类自己分配的资源,对象销毁的顺序与创建顺序相反
  • 在构造函数和析构函数中尽量不要调用虚函数:例子
    • 比如在进行基类的初始化时,调用了派生类版本的虚函数,但是此时派生类还未进行初始化
     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
    
    class Base{
    public:
        Base() { func(); } // 构造函数中调用虚函数
        Base(const Base&) =default;    
        Base(Base&&) =default;
        Base& operator= (const Base&) =default;
        Base& operator= (Base&&) =default;
        virtual ~Base() =default; 
        virtual void func() {cout<<"Base"<<endl;}
    };
    class Derived: public Base{
    public:
        Derived(): Base() { func(); } 
        Derived(const Derived& d): Base(d) {} // 使用基类的构造函数初始化对象的基类部分
        Derived(Derived&& d): Base(d) {}
        Derived& operator= (const Derived& d){
          Base::operator=(d); // 使用基类的拷贝赋值运算符赋值对象的基类部分
          /* do something*/
          return *this;
        }
        ~Derived() { /*销毁派生类自己分配的资源*/ }
        virtual void func() { cout<<"Derived"<<endl; }
    };
    
    Derived d; 
    // 先构造基类部分,此时派生类部分还未创建,基类构造函数中使用的是Base::func()
    

继承的构造函数

  • 背景
    • 如果基类有多个不同的构造函数,那么派生类也需要相应实现多个构造函数,参考
    • 派生类不能继承默认、拷贝、移动构造函数,派生类如果没有直接定义这些构造函数,编译器会为派生类合成
  • 派生类可以使用using声明重用基类的构造函数,编译器在派生类中生成一个形参列表完全相同的构造函数,派生类自己的数据成员被默认初始化
    • 和普通的using声明不一样(可以[[ch15-面向对象程序设计#^f2ba1c|改变派生类个别成员的可访问性]]),构造函数的using声明不会改变构造函数的访问声明符
    • 如果基类构造函数是explict的或constexpr的,则重用的构造函数也拥有相同的属性
    • 如果一个基类构造函数含有默认实参,这些实参并不会被直接继承,派生类会获得多个继承的构造函数,每个构造函数分别省略掉一个含有默认实参的形参。
    • 基类有几个构造函数,派生类会重用所有的这些构造函数,除了两个例外:
      • 派生类可以继承一部分构造函数,而为其他构造函数定义自己的版本:如果派生类定义的构造函数与重用基类的构造函数具有相同的参数列表,则派生类中的构造函数将替换重用的基类构造函数
      • 默认、拷贝和移动构造函数不会被继承,这些构造函数按照正常规则被合成

15.8 容器与继承

  • 当我们使用容器存放继承体系中的对象时,通常必须采用间接存储的方式,即在容器中放置(智能)指针而非对象,否则派生类对象的部分会被切掉

15.9 文本查询程序再探

  • 使系统支持:单词查询、逻辑非查询、逻辑或查询、逻辑与查询。

面向对象的解决方案

  • 将几种不同的查询建模成相互独立的类,这些类共享一个公共基类:
    • WordQuery
    • NotQuery
    • OrQuery
    • AndQuery
  • 这些类包含两个操作:
    • eval:接受一个TextQuery对象并返回一个QueryResult
    • rep:返回基础查询的string表示形式。
  • 继承和组合:
    • 当我们令一个类公有地继承另一个类时,派生类应当反映与基类的“是一种(Is A)”的关系。
    • 类型之间另一种常见的关系是“有一个(Has A)”的关系。
  • 对于面向对象编程的新手来说,想要理解一个程序,最困难的部分往往是理解程序的设计思路。一旦掌握了设计思路,接下来的实现也就水到渠成了。 Query程序设计:
操作解释
Query程序接口类和操作
TextQuery该类读入给定的文件并构建一个查找图。包含一个query操作,它接受一个string实参,返回一个QueryResult对象;该QueryResult对象表示string出现的行。
QueryResult该类保存一个query操作的结果。
Query是一个接口类,指向Query_base派生类的对象。
Query q(s)Query对象q绑定到一个存放着string s的新WordQuery对象上。
q1 & q2返回一个Query对象,该Query绑定到一个存放q1q2的新AndQuery对象上。
`q1q2`
~q返回一个Query对象,该Query绑定到一个存放q的新NotQuery对象上。
Query程序实现类
Query_base查询类的抽象基类
WordQueryQuery_base的派生类,用于查找一个给定的单词
NotQueryQuery_base的派生类,用于查找一个给定的单词
BinaryQueryQuery_base的派生类,查询结果是Query运算对象没有出现的行的集合
OrQueryQuery_base的派生类,返回它的两个运算对象分别出现的行的并集
AndQueryQuery_base的派生类,返回它的两个运算对象分别出现的行的交集