第 15 章 面向对象程序设计

时间:2022-10-25 00:11:03

第 15 章 面向对象程序设计

标签: C++Primer 学习记录 继承 派生 虚函数


 

 


15.1 OOP:概述

  1. 面向对象程序设计的核心思想是数据抽象、继承和动态绑定。
    • 数据抽象,可以将类的接口与实现分离。
    • 继承,定义相似的类型并对其相似关系建模
    • 动态绑定,在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象。

15.2 定义基类和派生类

  1. 基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此

  2. 基类的成员函数可以分为两类:

    • 虚函数,希望派生类进行覆盖的函数,任何构造函数之外的非静态函数都可以是虚函数。关键字 virtual只能出现在类内部的声明语句之前而不能用于类外部的函数定义。如果把一个函数声明成虚函数,则该函数在派生类中隐式地也是虚函数。
    • 派生类可以直接继承而不要改变的函数。
  3. 如果派生类没有覆盖其基类中的某个虚函数,则该虚函数的行为类似于其他的普通成员,派生类会直接继承其在基类中的版本

  4. 派生类可以在它覆盖的函数前使用 virtual关键字,但不是非得这么做。C++11新标准允许派生类显式地注明它使用某个成员函数覆盖了它继承的虚函数。具体做法是在形参列表后面、或在 const成员函数的 const关键字后面、或在引用成员函数的引用限定符后面添加一个关键字 override

  5. 一个派生类对象包含派生类自己定义的子对象和与该派生类继承的基类对应的子对象。如下图所示:
    第 15 章 面向对象程序设计

  6. 也正是因为在派生类对象中含有与其基类对应的组成部分,所以能把派生类的对象当成基类对象来使用,也因此能将基类的指针或引用绑定到派生类对象中的基类部分上。这种转换也叫做派生类到基类的类型转换。

    Quote item;            // 基类对象
    Bulk_quote bulk; // 派生类对象
    Quote *p = &item; // p指向 Quote对象
    p = &bulk; // p指向 Bulk_quote对象的 Quote部分
    Quote &r = bulk; // r绑定到 Bulk_quote对象的 Quote部分
  7. 每个类负责定义自己的接口,所以,派生类对象不能直接初始化基类的成员。派生类应该遵循基类的接口,通过调用基类的构造函数来初始化那些从基类中继承而来的成员。派生类的初始化过程大致为:基类初始化——>基类构造函数体——>派生类初始化——>派生类构造函数体。

  8. 对于基类中定义的静态成员,因为它属于基类类型,而不是基类对象,则在整个继承体系中只存在该成员的唯一定义。不论从基类中派生出来多少个派生类,对于每个静态成员来说都只存在唯一的实例。

  9. 作为基类的类必须已经定义而非仅仅声明,这也意味着一个类不能继承它本身。

  10. 使用 final关键字可以防止一个类被其它类继承。

class NoDerived final { /* */ };  // NoDerived不能被继承
class Bad : NoDerived { /* */ }; // 错误
  1. 静态类型:在编译时总是已知的,它是变量声明时的类型或表达式生成的类型。

  2. 动态类型:变量或表达式表示的内存中的对象的类型,直到运行时才可知。如果表达式既不是引用也不是指针,则它的动态类型永远与静态类型一致。

  3. 一个基类的对象既可以以独立的形式存在,也可以作为派生类对象的一部分存在。因此基类不一定是派生类对象的一部分,但派生类中一定含有基类部分。所以,不存在从基类向派生类的隐式类型转换,但“存在”派生类向基类的转换(只对指针和引用有效、对象类型的话派生类部分会被切断)。

  4. 即使一个基类指针或引用已经绑定在一个派生类对象上,也不能执行从基类向派生类的转换。可以使用 dynamic_cast执行运行时安全检查或 static_cast来强制覆盖掉编译器的检查工作。

Quote base;
Bulk_quote *bulkP = &base; // 错误
Bulk_quote bulk;
Quote *itemP = &bulk; // 正确,动态类型是 Bulk_quote
Bulk_quote *bulkP = itemP; // 错误,不能将基类转换成派生类

15.3 虚函数

  1. 通常情况下,如果不使用某个函数,则无需为该函数提供定义。但是由于虚函数是在运行时才被解析,所以必须为每个虚函数都提供定义,而不管它是否被用到了。

  2. 引用或指针的静态类型与动态类型不同这一事实是 C++语言支持多态性的根本所在。而对于非虚函数的调用是在编译时进行绑定。类似的,通过对象进行的函数(虚函数或非虚函数)调用也在编译时绑定。

  3. 一旦某个函数被声明为虚函数,则在所有派生类中它都是虚函数。而对于派生类中覆盖的虚函数,其形参必须相同,返回类型也要与基类匹配。而当虚函数的返回类型是类本身的指针或引用且可进行类型转换时,也是允许的。
    第 15 章 面向对象程序设计

  4. 在派生类中覆盖基类的虚函数,使用 override标识符。而如果被其标识的函数被编译器认为并不能成功覆盖掉基类的虚函数时,则会报错。

    struct B {
    virtual void f1(int) const;
    virtual void f2();
    void f3();
    };
    struct D1 : B {
    void f1(int) const override; // 正确
    void f2(int) override; // 错误,B没有形如 f2(int)的函数
    virtual void f2(int); // 正确,在 D1中声明 f2(int)的虚函数
    void f3() override; // 错误,f3不是虚函数
    void f4() override; // 错误,B中没有名为 f4的函数
    };
  5. 将某个函数指定为 final,则之后任何尝试覆盖该函数的操作都将引发错误。另外,finaloverride说明符要出现在形参列表(包括任何 const或引用修饰符)以及尾后返回类型之后。

    struct D2 : B {
    // 从 B继承 f2()和 f3(),覆盖 f1(int)
    void f1(int) const final; // 不允许后续的其他类覆盖 f1(int)
    };
    struct D3 : D2 {
    void f2(); // 正确
    void f1(int) const; // 错误
    };
  6. 虚函数可以拥有默认实参,其实参值由本次调用的静态类型决定。如果我们通过基类的引用或指针调用函数,即使实际运行的是派生类中的函数版本,其使用的也是基类中定义的默认实参。

  7. 有时候我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本。使用作用域运算符可以实现这一目的。如果一个派生类虚函数需要调用它的基类版本,但是没有使用作用域运算符,则在运行时该调用将被解析为对派生类版本自身的调用,从而导致无限递归。

    // 强行调用基类中定义的函数版本而不管 baseP的动态类型到底是什么
    double undiscounted = baseP->Quote::net_price(42);

15.4 抽象基类

  1. 纯虚函数,在声明语句的分号之前书写=0,将一个虚函数说明为纯虚函数。其中,=0只能出现在类内部的虚函数声明语句处。

    class A {
    public:
    A() = default;
    int get_size() const = 0;
    };
  2. 值得注意的是,也可以为纯虚函数提供定义,不过函数体必须定义在类的外部。

  3. 含有纯虚函数的类是抽象基类。抽象积累负责定义接口,而后续的其他类可以覆盖该接口。不能(直接)创建一个抽象基类的对象,但派生类构造函数可以使用抽象基类的构造函数来构建各个派生类对象的基类部分。


15.5 访问控制与继承

  1. 派生类的成员和友元只能访问派生类对象中的基类部分的受保护成员,对于普通的基类对象中的成员不具有特殊的访问权限,即在派生类中也不能通过基类对象来访问基类的 protected成员

    class Base {
    protected:
    int prot_men;
    };
    class Sneaky : public Base {
    friend void clobber(Sneaky &s);
    friend void clobber(Base &b);
    int j; // 默认是 private
    };
    // 正确,clobber能访问 Sneaky对象的 private和 protected成员
    void clobber(Sneaky &s) { s.j = s.prot_men = 0; }
    // 错误,clobber不能访问 Base对象的 protected成员
    void clobber(Base &b) { b.prot_men = 0; }
  2. 派生访问说明符对于派生类成员(及友元)能否访问其直接基类的成员没有什么影响,对基类成员的访问权限只与基类中的访问说明符有关。派生访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限。

  3. 派生类向基类的转换是否可访问由使用该转换的代码决定,同时派生类的派生访问说明符也会有影响。总而言之,是在某个给定节点上,如果基类的共有成员是可访问的,则派生类向基类的类型转换也是可访问的。假定 D继承自 B:

    • 只有当 D公有地继承 B时,用户代码才能使用派生类向基类的类型转换;如果 D继承 B的方式是受保护的或私有的,则用户代码不能使用该转换。
    • 不论 D以什么方式继承 B,D的成员函数和友元都能使用派生类向基类的类型转换;派生类向其直接基类的类型转换对于派生类的成员和友元来说永远是可访问的。
    • 如果 D继承 B的方式是公有或受保护的,则 D的派生类的成员和友元可使用 D向 B的类型转换;反之,如果 D继承 B的方式是私有的,则不能使用。
  4. 就像友元关系不能传递一样,友元关系同样也不能继承。基类的友元在访问派生类成员时不具特殊性,类似的,派生类的友元也不能随意访问基类的成员。但是基类的友元是可以访问内嵌在派生类对象中的基类成员。

    class Base {
    // 添加 friend声明,其他成员与之前的一致
    friend class Pal;
    };
    class Pal {
    public:
    int f(Base b) { return b.prot_men; } // 正确
    int f2(Sneaky s) { return s.j; } // 错误
    // 对基类的访问权限由基类本身控制,即使对于派生类的基类部分也是如此
    int f3(Sneaky s) { return s.prot_men; }
    };
  5. 通过在类的内部使用 using声明语句,我们可以将该类的直接或间接基类中的任何可访问成员(非私有成员)标记出来。using声明语句中名字的访问权限由该 using声明语句之前的访问说明符来决定。

    class Base {
    public:
    std::size_t size() const { return n; }
    protected:
    std::size_t n;
    };
    // private继承,继承成员的访问权限发生了改变
    class Derived : private Base {
    public:
    using Base::size;
    protected:
    using Base::n;
    };
  6. struct和 class都可以用来定义类,只是二者的默认成员访问说明符和默认派生访问说明符不同而已。

    • struct,两个说明符都默认为 public
    • class, 两个说明符都默认为 private

15.6 继承中的类作用域

  1. 派生类的作用域嵌套在其基类的作用域之内,所使用的对象、引用或指针的静态类型决定了哪些成员能被使用。

  2. 派生类能够重用定义在其直接基类或间接基类中的名字,并且定义在内层作用域(派生类)的名字将隐藏定义在外层作用域(基类)的名字。可以通过作用域运算符来使用被隐藏的基类成员。

    struct Base {
    Base() : mem(0) { }
    protected:
    int mem;
    };
    struct Derived : Base {
    // 用 i初始化 Derived::mem,Base::mem进行默认初始化
    Derived(int i) : mem(i) { }
    int get_mem() { return mem; } // 返回 Derived::mem
    protected:
    int mem; // 隐藏基类中的 mem
    };
    // 使用作用域运算符来使用被隐藏的基类成员
    struct Derived : Base {
    int get_base_mem() { return Base::mem; }
    }
  3. 名字查找先于类型检查,对于派生类和基类中的某个同名成员,即使派生类和基类成员的形参列表不一致,派生类成员也还是会隐藏基类成员。

    struct Base {
    int memfcn();
    };
    struct Derived : Base {
    int memfcn(int); // 隐藏基类的 memfcn
    };
    Derived d; Base b;
    b.memfcn(); // 调用 Base::memfcn()
    d.memfcn(10); // 调用 Derived::memfcn(int)
    d.memfcn(); // 错误,参数列表为空的 memfcn被隐藏了
    d.Base::memfcn(); // 正确,调用 Base::memfcn()
  4. 以 p—>mem()解析函数调用的过程,依次执行以下 4个步骤:

    • 首先确定 p(或obj)的静态类型。因为我们调用的是一个成员,所以该类型必然是类类型。
    • 在 p(或obj)的静态类型对应的类中查找 mem。如果找不到,则依次在直接基类中不断查找直至到达继承链的顶端。如果找遍了该类及其基类仍然找不到,则编译器将报错。
    • 一旦找到了 mem,就进行常规的类型检查以确认对于当前找到的 mem,本次调用是否合法。
    • 假设调用合法,则编译器将根据调用的是否是虚函数而产生不同的代码:
      • 如果 mem是虚函数且我们是通过引用或指针进行的调用,则编译器产生的代码将在运行时确定到底运行该虚函数的哪个版本,依据是对象的动态类型。
      • 反之,如果 mem不是虚函数或者我们是通过对象(而非引用或指针)进行的调用,则编译器将产生一个常规函数调用。
    class Base {
    public:
    virtual void fcn() { cout << "Base::fcn()" << endl; }
    };

    class D1 : public Base {
    public:
    // 隐藏基类的 fcn,这个 fcn不是虚函数
    // D1继承了 Base::fcn()的定义
    void fcn(int); // 形参列表与 Base中的 fcn不一致
    virtual void f2(); // 一个新的虚函数,在 Base中不存在
    };

    class D2 : public D1 {
    public:
    void fcn(int); // 非虚函数,隐藏了 D1::fcn(int)
    void fcn(); // 覆盖了 Base中的虚函数 fcn
    void f2(); // 覆盖了 D1中的虚函数 f2
    };

    Base bobj; D1 d1obj; D2 d2obj;
    // 指向不同对象的基类指针都调用 fcn()
    Base *bp1 = &bobj; Base *bp2 = &d1obj; Base *bp3 = &d2obj;
    bp1->fcn(); // 虚调用,将在运行时调用 Base::fcn()
    bp2->fcn(); // 虚调用,将在运行时调用 Base::fcn()
    bp3->fcn(); // 虚调用,将在运行时调用 D2::fcn()

    // 静态指针类型与其动态类型相同,都调用 f2()
    D1 *d1p = &d1obj; D2 *d2p = &d2obj;
    bp2->f2(); // 错误,Base中没有名为 f2的函数
    d1p->f2(); // 虚调用,将在运行时调用 D1::f2()
    d2p->f2(); // 虚调用,将在运行时调用 D2::f2()

    // 不同指针类型都指向 D2对象,都调用 fcn(int)
    Base *p1 = &d2obj; D1 *p2 = &d2obj; D2 *p3 = &d2obj;
    p1->fcn(42); // 错误,Base中没有接受一个 int的 fcn
    p2->fcn(42); // 静态绑定,调用 D1::fcn(int)
    p3->fcn(42); // 静态绑定,调用 D1::fcn(int)
  5. 派生类可以覆盖重载函数的 0个或多个实例。而因为函数调用过程中,在查找到目标名字后就会停止查找,而不关心参数类型,所以如果派生类希望所有的重载版本对于它来说都是可见的,那么它就需要覆盖所有的版本,或者一个也不覆盖

  6. using声明语句指定一个名字而不指定形参列表,所以一条基类成员函数的 using声明语句就可以把该函数的所有重载实例添加到派生类作用域中了。注意,此时基类函数的每个实例在派生类中都必须是可访问的。


15.7 构造函数与拷贝控制

  1. 当需要 delete一个的基类指针时,该指既可以指向基类对象,也可以指向派生类对象,此时编译器必须明确执行基类或派生类的指针。析构函数的虚属性会被继承,无论派生类中使用合成的析构函数还是自定义的析构函数,都将是虚函数。这样,就能保证 delete基类指针时总能运行正确的析构函数版本。假如基类析构函数不是虚函数,且指针的静态类型与动态类型不一致,则此时只能调用基类的析构函数,那派生类对象的部分则无法完成析构,从而产生未定义行为

  2. 如前所述,当一个类中存在拷贝控制成员时,编译器不会为这个类合成移动操作。对于需要定义虚析构函数的基类,也是如此。

  3. 派生类可能会将合成的拷贝控制成员定义为删除的,这与基类有关:

    • 如果基类中的默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的函数或不可访问,则派生类中对应的成员将是被删除的。原因是编译器不能使用基类成员来执行派生类对象基类部分的构造、赋值或销毁操作。
    • 如果在基类中有一个不可访问和删除掉的析构函数,则派生类中合成的默认拷贝构造函数将是删除的,因为编译器无法销毁派生类对象的基类部分。
    • 和过去一样,编译器将不会隐式合成一个删除掉的移动操作。当我们使用=default请求一个移动操作时,如果基类中的对应操作是删除的或不可访问的,那么派生类中该函数将是被删除的,原因是派生类对象的基类部分不可移动。同样,如果基类的析构函数是删除的或不可访问的,则派生类的移动构造函数也将是被删除的。
    class B {
    public:
    B();
    B(const B&) = default;
    // 其他成员,不含有移动构造函数
    };
    class D {
    // 没有声明任何构造函数
    };
    D d; // 正确,D的合成默认构造函数使用 B的默认构造函数
    D d2(d); // 错误,D的合成拷贝构造函数是被删除的
    D d3(std::move(d)); // 错误,隐式地使用 D的被删除的拷贝构造函数
  4. 因为大多数基类都会定义一个虚析构函数,所以默认情况下,基类通常不含有合成的移动操作,导致派生类中也不会有合成的移动操作。而当确实需要执行移动操作时,应该首先在基类中显式定义相应的移动操作。注意,因为在定义了自己的移动操作后,编译器会将合成的拷贝操作定义为删除的。所以大多数情况下,定义了移动操作后,还需要显式的定义拷贝操作。

  5. 在派生类中定义除析构函数之外的其他拷贝控制成员时,都需要显式的进行基类的相应操作。而析构函数则只用负责销毁派生类自己分配的资源,派生类对象的基类部分时自动销毁的。

  6. 在默认情况下,基类默认构造函数初始化派生类对象的基类部分。如果我们想拷贝(或移动)基类部分,则必须在派生类的构造函数初始值列表中显式的使用基类的拷贝(或移动)构造函数

    class Base { /*...*/ };
    class D : public Base {
    public:
    D(const D &d) : Base(d) // 拷贝基类成员
    /* D的成员的初始值 */ { /*...*/ }
    D(D &&d) : Base(std::move(d)) // 移动基类成员
    /* D的成员的初始值 */ { /*...*/ }
    ~D(); // Base::~Base()被自动调用
    };
  7. 如果构造函数或析构函数调用了某个虚函数,则我们应该执行与构造函数或析构函数所属类型相对应的虚函数版本。

  8. 通过一条注明了(直接)基类名的 using声明语句,派生类可以从基类继承构造函数。通常情况下,using声明语句只是令某个名字在当前作用域内可见,而当作用于构造函数时,using声明语句将令编译器产生代码。只对基类部分进行初始化,派生类自己的数据成员将会默认初始化。

    class Bulk_quote : public Disc_quote {
    public:
    using Disc_quote::Disc_quote; // 继承 Disc_quote的构造函数
    double net_price(std::size_t) const;
    };
  9. 继承的构造函数的特点:

    • 构造函数的 using声明不会改变该构造函数的访问级别。
    • using声明语句不能指定 explicit或 constexpr,继承的构造函数将与基类中的相应函数具有相同属性。
    • 当基类构造函数具有默认实参时,实参不会被继承,而是派生类会获得多个继承的构造函数,每个构造函数分别省略掉一个含有默认实参的形参。
    • 因为 using声明只指定名字而不指定形参列表,所以一般情况下派生类会继承基类的所有构造函数。不过也有两个例外。
      • 派生类定义的构造函数与基类的构造函数具有相同的形参列表时,则该构造函数不会被继承,派生类中使用的是自己定义的相应函数。
      • 默认、拷贝和移动构造函数不会被继承,这些构造函数按照正常规则被合成。因此,如果派生类中只含有继承的构造函数,则它将拥有一个合成的默认构造函数。

15.8 容器与继承

  1. 不允许在容器中保存不同类型的元素,基类对象不能转换成派生类对象,派生类对象转换成基类对象,派生类部分又会被“切掉”。因此,容器和存在继承关系的类型无法兼容。此时,更好的做法是在容器中存放基类的(智能)指针。

    vector<shared_ptr<Quote>> basket;
  2. 对于 C++面向对象的编程来说,一个很有意思的悖论是,我们无法直接使用对象进行面向对象编程,相反,我们必须使用指针和引用


15.9 文本查询程序再探

  1. 在使用面向对象编程构建一个较为复杂的应用程序时,要注意使用接口类,来隐藏核心类。相当于在用户和核心类之间又多了一个缓冲区域,对于用户而言,只需要使用接口类就可以了,而不用关系核心类如何实现。这样,在进行核心功能的更改时,过去使用的用户代码仍然可以正常运行。用户——>接口类——>核心类