0%

C++ Primer - 第 15 章 面向对象程序设计

15.1 OOP:概述

  1. 面向对象程序设计(object-oriented programming)的核心思想是数据抽象(封装)、继承和动态绑定(多态)。

  2. 在 C++ 语言中,基类将类型相关的函数与派生类不做改变直接继承的函数区分对待。对于某些函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明成虚函数(virtual function)。

  3. 派生类必须通过使用类派生列表(class derivation list)明确指出它是从哪个(哪些)基类继承而来的。类派生列表的形式是:首先是一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有访问说明符

    1
    2
    3
    4
    5
    class Bulk_quote : public Quote // Bulk_quote 继承了 Quote
    {
    public:
    double net_price(std::size t) const override;
    };
  4. 派生类必须在其内部对所有重新定义的虚函数进行声明。 派生类可以在这样的函数之前加上 virtua1 关键字,但是并不是非得这么做。C++11 新标准允许派生类显式地注明它将使用哪个成员函数改写基类的虚函数,具体措施是在该函数的形参列表之后增加一个 override 关键字。

  5. 通过使用动态绑定(dynamic binding),我们能用同一段代码分别处理 QuoteBulk_quote 的对象。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // 计算并打印销售给定数量的某种书籍所得的费用
    double print_total(ostream &os,
    const Quote &item, size_t n)
    {
    ostream &os, const Quote &item, size_t n)
    // 根据传入 item 形参的对象类型调用 Quote::net_price
    // 或者 Bulk_quote::net_price
    double ret = item.net price(n);
    os << "ISBN: " << item.isbn() // 调用 Quote:isbn
    << " # sold: " << n << "total due: " << ret << endl;
    return ret;
    }

    函数 print_totalitem 形参是基类 Quote 的一个引用,实际传入 print_total 的对象类型将决定到底执行 net_price 的哪个版本:

    1
    2
    3
    // basic 的类型是 Quote;bulk 的类型是 Bulk_quote
    print_total(cout, basic, 20); // 调用 Quote 的 net_price
    print_total(cout, bulk, 20); // 调用 Bulk_quote 的 net_price

    函数的运行版本由实参决定,即在运行时选择函数的版本,所以动态绑定有时又被称为运行时绑定(run-time binding)。

    Note:在 C++ 语言中,当我们使用基类的引用(或指针)调用一个虚函数时将发生动态绑定。

15.2 定义基类和派生类

15.2.1 定义基类

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

  2. 派生类可以继承其基类的成员,然而当遇到如 net_price 这样与类型相关的操作时,派生类必须对其重新定义。换句话说,派生类需要对这些操作提供自己的新定义以覆盖override)从基类继承而来的旧定义。

  3. 在 C++ 语言中,基类必须将它的两种成员函数区分开来:

    • 基类希望其派生类进行覆盖的函数
    • 基类希望派生类直接继承而不要改变的函数

    对于前者,基类通常将其定义为虚函数virtual)。当我们使用指针或引用调用虚函数时,该调用将被动态绑定。根据引用或指针所绑定的对象类型不同,该调用可能执行基类的版本,也可能执行某个派生类的版本。

  4. 基类通过在其成员函数的声明语句之前加上关键字 virtua1 使得该函数执行动态级定。任何构造函数之外的非静态函数都可以是虚函数。关键字 virtual 只能出现在类内部的声明语句之前而不能用于类外部的函数定义。如果基类把一个函数声明成虚函数,则该函数在派生类中隐式地也是虚函数。

  5. 成员函数如果没被声明为虚函数,则其解析过程发生在编译时而非运行时。

  6. 派生类可以继承定义在基类中的成员,但是派生类的成员函数不一定有权访问从基类继承而来的成员。和其他使用基类的代码一样,派生类能访问公有成员,而不能访问私有成员。不过在某些时候基类中还有这样一种成员,基类希望它的派生类有权访问该成员,同时禁止其他用户访问。我们用受保护的(protected)访问运算符说明这样的成员。

15.2.2 定义派生类

  1. 派生类必须通过使用类派生列表(class derivation list)明确指出它是从哪个(哪些)基类继承而来的。类派生列表的形式是:首先是一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有以下三种访问说明符中的一个:publicprotected 或者 private

  2. 派生类必须将其继承而来的成员函数中需要覆盖的那些重新声明。

  3. 如果一个派生是公有的,则基类的公有成员也是派生类接口的组成部分。此外,我们能将公有派生类型的对象绑定到基类的引用或指针上

    之我见:为什么强调公有派生?因为如果是保护派生或私有派生,那么即使将派生对象绑定到基类引用或指针上,派生对象内部的所有内容对外部都将不可见。

  4. 大多数类都只继承自一个类,这种形式的继承被称作“单继承”。

  5. 派生类经常(但不总是)覆盖它继承的虚函数。如果派生类没有覆盖其基类中的某个虚函数,则该虚函数的行为类似于其他的普通成员,派生类会直接继承其在基类中的版本。

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

  7. 一个派生类对象包含多个组成部分:

    • 一个含有派生类自己定义的(非静态)成员的子对象
    • 一个与该派生类继承的基类对应的子对象,如果有多个基类,那么这样的子对象也有多个

    C++ 标准并没有明确规定派生类的对象在内存中如何分布,因此,在一个对象中,继承自基类的部分和派生类自定义的部分不一定是连续存储的。

  8. 因为在派生类对象中含有与其基类对应的组成部分,所以我们能把派生类的对象当成基类对象来使用,而且我们也能将基类的指针或引用绑定到派生类对象中的基类部分上

    1
    2
    3
    4
    5
    Quote item;       // 基类对象
    Bulk_quote bulk; // 派生类对象
    Quote *p = &item; // p 指向 Quote 对象
    p = &bulk; // p 指向 bulk 的 Quote 部分
    Quote &r = bulk; // r 绑定到 bulk 的 Quote 部分

    这种转换通常称为派生类到基类的(derived-to-base)类型转换。和其他类型转换一样,编译器会隐式地执行派生类到基类的转换。这种隐式特性意味着我们可以把派生类对象或者派生类对象的引用用在需要基类引用的地方:同样的,我们也可以把派生类对象的指针用在需要基类指针的地方。

    Note:在派生类对象中含有与其基类对应的组成部分,这一事实是继承的关键所在。

  9. 尽管在派生类对象中含有从基类继承而来的成员,但是派生类并不能直接初始化这些成员。和其他创建了基类对象的代码一样,派生类也必须使用基类的构造函数来初始化它的基类部分。

  10. 派生类对象的基类部分与派生类对象自己的数据成员都是在构造函数的初始化阶段执行初始化操作的。类似于我们初始化成员的过程,派生类构造函数同样是通过构造函数初始化列表来将实参传递给基类构造函数的

  11. 除非我们特别指出,否则派生类对象的基类部分会像数据成员一样执行默认初始化。 如果想使用其他的基类构造函数,我们需要以类名加圆括号内的实参列表的形式为构造函数提供初始值。这些实参将帮助编译器决定到底应该选用哪个构造函数来初始化派生类对象的基类部分。

    Note:首先初始化基类的部分,然后按照声明的顺序依次初始化派生类的成员。

  12. 派生类可以访问基类的公有成员和受保护成员。

  13. 派生类的作用域嵌套在基类的作用域之内。因此,对于派生类的一个成员来说,它使用派生类成员的方式与使用基类成员的方式没什么不同。

  14. 关键概念:遵循基类的接口。必须明确一点:每个类负责定义各自的接口。要想与类的对象交互必须使用该类的接口,即使这个对象是派生类的基类部分也是如此。

    因此,派生类对象不能直接初始化基类的成员。尽管从语法上来说我们可以在派生类构造函数体内给它的公有或受保护的基类成员赋值,但是最好不要这么做。和使用基类的其他场合一样,派生类应该遵循基类的接口,并且通过调用基类的构造函数来初始化那些从基类中继承而来的成员。

  15. 如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义。不论从基类中派生出来多少个派生类,对于每个静态成员来说都只存在唯一的实例。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class Base
    {
    public:
    static void statmem();
    };
    class Derived : public Base
    {
    void f(const Derived &);
    };

    静态成员遵循通用的访问控制规则,如果基类中的成员是 private 的,则派生类无权访问它。假设某静态成员是可访问的,则我们既能通过基类使用它也能通过派生类使用它:

    1
    2
    3
    4
    5
    6
    7
    8
    void Derived::f(const Derived &derived_obj)
    {
    Base::statmem; // 正确:Base 定义了statmem
    Derived::statmem(); // 正确:Derived 继承了 statmem
    // 正确:派生类的对象能访问基类的静态成员
    derived_obj.statmem(); // 通过 Derived 对象访问
    statmem(); // 通过 this 对象访问
    }
  16. 派生类的声明与其他类差别不大,声明中包含类名但是不包含它的派生列表。一条声明语句的目的是令程序知晓某个名字的存在以及该名字表示一个什么样的实体,如一个类、一个函数或一个变量等。派生列表以及与定义有关的其他细节必须与类的主体一起出现。如果我们想将某个类用作基类,则该类必须已经定义而非仅仅声明。

  17. 一个类不能派生它本身。 一个类是基类,同时它也可以是一个派生类。

  18. 每个类都会继承直接基类(direct base)的所有成员。对于一个最终的派生类来说,它会继承其直接基类的成员;该直接基类的成员又含有其基类的成员;依此类推直至继承链的顶端。因此,最终的派生类将包含它的直接基类的子对象以及每个间接基类(indirect base)的子对象

  19. C++11 新标准提供了一种防止继承发生的方法,即在类名后跟一个关键字 final

    之我见:当于给类做了“绝育手术”,被 final 修饰的类“后继无类”。

15.2.3 类型转换与继承

  1. WARNING:理解基类和派生类之间的类型转换是理解 C++ 语言面向对象编程的关键所在。

  2. 通常情况下,如果我们想把引用或指针绑定到一个对象上,则引用或指针的类型应与对象的类型一致,或者对象的类型含有一个可接受的 const 类型转换规则。存在继承关系的类是一个重要的例外:我们可以将基类的指针或引用绑定到派生类对象上。例如,我们可以用 Quote& 指向一个 Bulk_quote 对象,也可以把一个 Bulk_quote 对象的地址赋给一个 Quote*

  3. 可以将基类的指针或引用绑定到派生类对象上有一层极为重要的含义:当使用基类的引用(或指针)时,实际上我们并不清楚该引用(或指针)所绑定对象的真实类型。该对象可能是基类的对象,也可能是派生类的对象。

  4. Note:和内置指针一样,智能指针类也支持派生类向基类的类型转换,这意味着我们可以将一个派生类对象的指针存储在一个基类的智能指针内。

  5. 当我们使用存在继承关系的类型时,必须将一个变量或其他表达式的静态类型(static type)与该表达式表示对象的动态类型(dynamic type)区分开来。表达式的静态类型在编译时总是已知的,它是变量声明时的类型或表达式生成的类型;动态类型则是变量或表达式表示的内存中的对象的类型。动态类型直到运行时才可知。

  6. 如果表达式既不是引用也不是指针,则它的动态类型永远与静态类型一致。

    Note:基类的指针或引用的静态类型可能与其动态类型不一致。

  7. 不存在从基类向派生类的隐式类型转换。 之所以存在派生类向基类的类型转换是因为每个派生类对象都包含一个基类部分,而基类的引用或指针可以绑定到该基类部分上。一个基类的对象既可以以独立的形式存在,也可以作为派生类对象的一部分存在。如果基类对象不是派生类对象的一部分,则它只含有基类定义的成员,而不含有派生类定义的成员。

    因为一个基类的对象可能是派生类对象的一部分,也可能不是,所以不存在从基类向派生类的自动类型转换:

    1
    2
    3
    Quote base;
    Bulk_quote *bulkP = &base; // 错误:不能将基类转换成派生类
    Bulk_quote &bulkRef = base; // 错误:不能将基类转换成派生类

    如果上述赋值是合法的,则我们有可能会使用 bulkPbulkRef 访问 base 中本不存在的成员。

    除此之外还有一种情况显得有点特别,即使一个基类指针或引用绑定在一个派生类对象上,我们也不能执行从基类向派生类的转换:

    1
    2
    3
    Bulk quote bulk;
    Quote *itemP = &bulk; // 正确:动态类型是 Bulk_quote
    Bulk_quote *bulkP = itemP; // 错误:不能将基类转换成派生类

    编译器在编译时无法确定某个特定的转换在运行时是否安全,这是因为编译器只能通过检查指针或引用的静态类型来推断该转换是否合法。如果在基类中含有一个或多个虚函数,我们可以使用 dynamic_cast 请求一个类型转换,该转换的安全检查将在运行时执行。同样,如果我们已知某个基类向派生类的转换是安全的,则我们可以使用 static_cast来强制覆盖掉编译器的检查工作。

  8. 派生类向基类的自动类型转换只对指针或引用类型有效,在派生类类型和基类类型之间不存在这样的转换。

  9. 当我们初始化或赋值一个类类型的对象时,实际上是在调用某个函数。当执行初始化时,我们调用构造函数;而当执行赋值操作时,我们调用赋值运算符。这些成员通常都包含一个参数,该参数的类型是类类型的 const 版本的引用。

  10. 当我们给基类的构造函数传递一个派生类对象时,实际运行的构造函数是基类中定义的那个,显然该构造函数只能处理基类自己的成员。类似的,如果我们将一个派生类对象赋值给一个基类对象,则实际运行的赋值运算符也是基类中定义的那个,该运算符同样只能处理基类自己的成员。

    1
    2
    3
    Bulk_quote bulk;  // 派生类对象
    Quote item(bulk); // 使用 Quote::Quote(const Quote&) 构造函数
    item = bulk; // 调用 Quote::operator=(const Quotes)

    当构造 item 时,运行 Quote 的拷贝构造函数。该函数只能处理 bookNoprice 两个成员,它负责拷贝 bulkQuote 部分的成员,同时忽略掉 bulkBulk_quote 部分的成员。类似的,对于将 bulk 赋值给 item 的操作来说,只有 bulkQuote 部分的成员被赋值给 item,其余部分被切掉(sliced down)了。

    WARNING:当我们用一个派生类对象为一个基类对象初始化或赋值时,只有该派生类对象中的基类部分会被拷贝、移动或赋值,它的派生类部分将被忽略掉。

  11. 要想理解在具有继承关系的类之间发生的类型转换,有三点非常重要:

    • 从派生类向基类的类型转换只对指针或引用类型有效
    • 基类向派生类不存在隐式类型转换
    • 和任何其他成员一样,派生类向基类的类型转换也可能会由于访问受限而变得不可行

    尽管自动类型转换只对指针或引用类型有效,但是继承体系中的大多数类仍然(显式或隐式地)定义了拷贝控制成员。因此,我们通常能够将一个派生类对象拷贝、移动或赋值给一个基类对象。不过需要注意的是,这种操作只处理派生类对象的基类部分。

15.3 虚函数

  1. 当我们使用基类的引用或指针调用一个虚成员函数时会执行动态绑定。因为我们直到运行时才能知道到底调用了哪个版本的虚函数,所以所有虚函数都必须有定义。通常情况下,如果我们不使用某个函数,则无须为该函数提供定义。但是我们必须为每一个虚函数都提供定义,而不管它是否被用到了,这是因为连编译器也无法确定到底会使用哪个虚函数。(纯虚函数是个例外)

  2. 当某个虚函数通过指针或引用调用时,编译器产生的代码直到运行时才能确定应该调用哪个版本的函数。被调用的函数是与绑定到指针或引用上的对象的动态类型相匹配的那一个。

  3. 动态绑定只有当我们通过指针或引用调用虚函数时才会发生。 当我们通过一个具有普通类型(非引用非指针)的表达式调用虚函数时,在编译时就会将调用的版本确定下来。

  4. 关键概念:C++ 的多态性。OOP 的核心思想是多态性(polymorphism)。我们把具有继承关系的多个类型称为多态类型,因为我们能使用这些类型的“多种形式”而无须在意它们的差异。引用或指针的静态类型与动态类型不同这一事实正是 C++ 语言支持多态性的根本所在。

    当我们使用基类的引用或指针调用基类中定义的一个函数时,我们并不知道该函数真正作用的对象是什么类型,因为它可能是一个基类的对象也可能是一个派生类的对象。如果该函数是虚函数,则直到运行时才会决定到底执行哪个版本,判断的依据是引用或指针所绑定的对象的真实类型。

    另一方面,对非虚函数的调用在编译时进行绑定。类似的,通过对象进行的函数(虚函数或非虚函数)调用也在编译时绑定。对象的类型是确定不变的,我们无论如何都不可能令对象的动态类型与静态类型不一致。因此,通过对象进行的函数调用将在编译时绑定到该对象所属类中的函数版本上。

    当且仅当对通过指针或引用调用虚函数时,才会在运行时解析该调用,也只有在这种情况下对象的动态类型才有可能与静态类型不同。

  5. 当我们在派生类中覆盖了某个虚函数时,可以再一次使用 virtual 关键字指出该函数的性质。然而这么做并非必须,因为一旦某个函数被声明成虚函数,则在所有派生类中它都是虚函数。一个派生类的函数如果覆盖了某个继承而来的虚函数,则它的形参类型必须与被它覆盖的基类函数完全一致。

  6. 派生类中虚函数的返回类型也必须与基类函数匹配。该规则存在一个例外,当类的虚函数返回类型是类本身的指针或引用时,上述规则无效。也就是说,如果 DB 派生得到,则基类的虚函数可以返回 B* 而派生类的对应函数可以返回 D*,只不过这样的返回类型要求从 DB 的类型转换是可访问的。(可参考 CSDN 博客——博主注)

    Note:基类中的虚函数在派生类中隐含地也是一个虚函数。当派生类覆盖了某个虚函数时,该函数在基类中的形参必须与派生类中的形参严格匹配。

  7. 派生类如果定义了一个函数与基类中虚函数的名字相同但是形参列表不同,这仍然是合法的行为。编译器将认为新定义的这个函数与基类中原有的函数是相互独立的。这时,派生类的函数并没有覆盖掉基类中的版本。就实际的编程习惯而言,这种声明往往意味着发生了错误,因为我们可能原本希望派生类能覆盖掉基类中的虚函数,但是一不小心把形参列表弄错了。

  8. 派生类中的成员函数被 override 修饰后,会强迫编译器检查该成员函数是否能够重写基类中的同名虚函数,因此,一个最佳实践是,派生类中的虚函数都应用 override 修饰。如果我们使用 override 标记了某个函数,但该函数并没有覆盖已存在的虚函数,此时编译器将报错:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    struct B
    {
    virtual void fl(int) const;
    virtual void f2();
    void f3();
    };
    struct D1 : B
    {
    void fl(int) const override; // 正确:f1 与基类中的 f1 匹配
    void f2(int) override; // 错误:B 没有形如 f2(int) 的函数
    void f3() override; // 错误:f3 不是虚函数
    void f4() override; // 错误:B 没有名为 f4 的函数
    };

    只有虚函数才能被覆盖。

  9. 还能把某个函数指定为 final,如果我们已经把函数定义成 final 了,则之后任何尝试覆盖该函数的操作都将引发错误(用于终止继承链中某个虚函数的虚周期——博主注):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    struct D2 : B
    {
    // 从 B 继承 f2() 和 f3(),覆盖 f1(int)
    void fl(int) const final; // 不允许后续的其他类覆盖 f1(int)
    };
    struct D3 : D2
    {
    void f2(); // 正确:覆盖从间接基类 B 继承而来的 f2
    void f1(int) const; // 错误:D2 已经将 f1 声明成 final
    };

    finaloverride 说明符出现在形参列表(包括任何 const 或引用修饰符)以及尾置返回类型之后。

    之我见:override 描述的是对上的关系,final 描述的是对下的关系。

  10. 和其他函数一样,虚函数也可以拥有默认实参如果某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定。

    换句话说,如果我们通过基类的引用或指针调用函数,则使用基类中定义的默认实参,即使实际运行的是派生类中的函数版本也是如此。此时,传入派生类函数的将是基类函数定义的默认实参。如果派生类函数依赖不同的实参,则程序结果将与我们的预期不符。

    Best Practices:如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。

  11. 在某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本。使用作用域运算符可以实现这一目的,例如下面的代码:

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

    该代码强行调用 Quotenet_price 函数,而不管 basep 实际指向的对象类型到底是什么。该调用将在编译时完成解析。

    Note:通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制。

    什么时候我们需要回避虚函数的默认机制呢?通常是当一个派生类的虚函数调用它覆盖的基类的虚函数版本时。在此情况下,基类的版本通常完成继承层次中所有类型都要做的共同任务,而派生类中定义的版本需要执行一些与派生类本身密切相关的操作。

    WARNING:如果一个派生类虚函数需要调用它的基类版本,但是没有使用作用域运算符,则在运行时该调用将被解析为对派生类版本自身的调用,从而导致无限递归。

15.4 抽象基类

  1. 一个纯虚(pure virtual)函数无须定义。我们通过在函数体的位置(即在声明语句的分号之前)书写 =0 就可以将一个虚函数说明为纯虚函数。其中,=0 只能出现在类内部的虚函数声明语句处。也可以为纯虚函数提供定义,不过函数体必须定义在类的外部。也就是说,我们不能在类的内部为一个 =0 的函数提供函数体。

  2. 含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类(abstract base class)。抽象基类负责定义接口,而后续的其他类可以覆盖该接口。我们不能(直接)创建一个抽象基类的对象。

  3. 抽象基类的派生类必须给出自己的接口实现,否则它们仍将是抽象基类。

  4. 派生类的构造函数只初始化它的直接基类。

15.5 访问控制与继承

  1. 一个类使用 protected 关键字来声明那些它希望与派生类分享但是不想被其他公共访问使用的成员。protected 说明符可以看做是 publicprivate 中和后的产物:

    • 和私有成员类似,受保护的成员对于类的用户来说是不可访问的
    • 和公有成员类似,受保护的成员对于派生类的成员和友元来说是可访问的
    • 派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问特权

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    class Base
    {
    protected:
    int prot_mem; // protected 成员
    };
    class Sneaky : public Base
    {
    friend void clobber(Sneaky &); // 能访问 Sneaky::prot mem
    friend void clobber(Base &); // 不能访问 Base::prot mem
    int j; // j 默认是 private
    };
    // 正确:c1obber 能访问 Sneaky 对象的 private 和 protected 成员
    void clobber(Sneaky &s) { s.j = s.prot_mem = 0; }
    // 错误:clobber 不能访问 Base 的 protected 成员
    void clobber(Base &b) { b.prot_mem = 0; }

    派生类的成员和友元只能访问派生类对象中的基类部分的受保护成员;对于普通的基类对象中的成员不具有特殊的访问权限。

    某个类的友元(包括友元类和友元函数)有权访问这个类的私有和受保护成员。

  2. 某个类对其继承而来的成员的访问权限受到两个因素影响:

    • 一是在基类中该成员的访问说明符
    • 二是在派生类的派生列表中的访问说明符

    派生访问说明符对于派生类的成员(及友元)能否访问其直接基类的成员没什么影响。对基类成员的访问权限只与基类中的访问说明符有关。派生访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限。

    之我见:派生类对基类成员的访问权限只取决于基类中该成员的访问说明符;派生访问说明符只决定了派生类中继承自基类的成员的受访问权限,该权限为:

    例如,基类成员访问说明符为 protected,派生访问说明符为 private,则派生类可以访问继承而来的基类受保护成员,但这些继承而来的基类受保护成员在派生类中的受访问权限为:

    因此派生类实例及派生类的派生类都无权访问。

  3. 派生类向基类的转换是否可访问由使用该转换的代码决定,同时派生类的派生访问说明符也会有影响。假定 D 继承自 B

    • 只有当 D 公有地继承 B 时,用户代码才能使用派生类向基类的转换;如果 D 继承 B 的方式是受保护的或者私有的,则用户代码不能使用该转换
    • 不论 D 以什么方式继承 BD 的成员函数和友元都能使用派生类向基类的转换;派生类向其直接基类的类型转换对于派生类的成员和友元来说永远是可访问的
    • 如果 D 继承 B 的方式是公有的或者受保护的,则 D 的派生类的成员和友元可以使用 DB 的类型转换;反之,如果 D 继承 B 的方式是私有的,则不能使用

    Tip:对于代码中的某个给定节点来说,如果基类的公有成员是可访问的,则派生类向基类的类型转换也是可访问的;反之则不行。

  4. 关键概念:类的设计与受保护的成员。不考虑继承的话,我们可以认为一个类有两种不同的用户:普通用户和类的实现者。其中,普通用户编写的代码使用类的对象,这部分代码只能访问类的公有(接口)成员;实现者则负责编写类的成员和友元的代码,成员和友元既能访问类的公有部分,也能访问类的私有(实现)部分。

    如果进一步考虑继承的话就会出现第三种用户,即派生类。基类把它希望派生类能够使用的部分声明成受保护的。普通用户不能访问受保护的成员,而派生类及其友元仍旧不能访问私有成员。

    和其他类一样,基类应该将其接口成员声明为公有的;同时将属于其实现的部分分成两组:一组可供派生类访问,另一组只能由基类及基类的友元访问。对于前者应该声明为受保护的,这样派生类就能在实现自己的功能时使用基类的这些操作和数据;对于后者应该声明为私有的。

  5. 就像友元关系不能传递一样,友元关系同样也不能继承。基类的友元在访问派生类成员时不具有特殊性,类似的,派生类的友元也不能随意访问基类的成员

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class Base
    {
    // 添加 friend 声明,其他成员与之前的版本一致
    friend class Pal; // Pal 在访问 Base 的派生类时不具有特殊性
    };
    class Pal
    {
    public:
    int f(Base b) { return b.prot_mem; } // 正确:Pal 是 Base 的友元
    int f2(Sneaky s) { return s.j; } // 错误:Pa1 不是 Sneaky 的友元
    // 对基类的访问权限由基类本身控制,即使对于派生类的基类部分也是如此
    int f3(Sneaky s) { return s.prot_mem; } // 正确:Pal 是 Base 的友元
    };

    之我见:一个基类的友元可以访问派生类对象中继承自基类的成员。某个类的友元类的基类或派生类对这个类并不具有特殊访问权限。

    Note:不能继承友元关系;每个类负责控制各自成员的访问权限。

  6. 有时我们需要改变派生类继承的某个名字的访问级别,通过使用 using 声明可以达到这一目的:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    class Base
    {
    public:
    std::sizet size() const { return n; }

    protected:
    std::size_t n;
    };
    class Derived : private Base // 注意:private 继承
    {
    public:
    // 保持对象尺寸相关的成员的访问级别
    using Base::size;

    protected:
    using Base::n;
    };

    通过在类的内部使用 using 声明语句,我们可以将该类的直接或间接基类中的任何可访问成员(例如,非私有成员)标记出来。using 声明语句中名字的访问权限由该 using 声明语句之前的访问说明符来决定。也就是说,如果一条 using 声明语句出现在类的 private 部分,则该名字只能被类的成员和友元访问;如果 using 声明语句位于 public 部分,则类的所有用户都能访问它;如果 using 声明语句位于 protected 部分,则该名字对于成员、友元和派生类是可访问的。

    派生类只能为那些它可以访问的名字提供 using 声明。

  7. 使用 structclass 关键字定义的类具有不同的默认访问说明符。类似的,默认派生运算符也由定义派生类所用的关键字来决定。默认情况下,使用 class 关键字定义的派生类是私有继承的;而使用 struct 关键字定义的派生类是公有继承的

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class Base
    {
    /* ... */
    };
    struct D1 : Base // 默认 public 继承
    {
    /* ... */
    };
    class D2 : Base // 默认 private 继承
    {
    /* ... */
    };

    使用 struct 关键字和 class 关键字定义的类之间唯一的差别就是默认成员访问说明符及默认派生访问说明符。

    一个私有派生的类最好显式地将 private 声明出来,而不要仅仅依赖于默认的设置。显式声明的好处是可以令私有继承关系清晰明了,不至于产生误会。

  8. 之我见:对于私有继承得到的派生类,派生类的派生类的成员或友元不能执行向间接基类的类型转换。

15.6 继承中的类作用域

  1. 当存在继承关系时,派生类的作用域嵌套在其基类的作用域之内。如果一个名字在派生类的作用域内无法正确解析,则编译器将继续在外层的基类作用域中寻找该名字的定义。所以派生类才能像使用自己的成员一样使用基类的成员。

  2. 一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。即使静态类型与动态类型可能不一致(当使用基类的引用或指针时会发生这种情况),但是我们能使用哪些成员仍然是由静态类型决定的。

  3. 和其他作用域一样,派生类也能重用定义在其直接基类或间接基类中的名字,此时定义在内层作用域(即派生类)的名字将隐藏定义在外层作用域(即基类)的名字,但可以通过作用域运算符来使用一个被隐藏的基类成员。

    Best Practices:除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字。

  4. 关键概念:名字查找与继承
    理解函数调用的解析过程对于理解 C++ 的继承至关重要,假定我们调用 p->mem()(或者 obj.mem()),则依次执行以下 4 个步骤:

    • 首先确定 p(或 obj)的静态类型。因为我们调用的是一个成员,所以该类型必然是类类型
    • p(或 obj)的静态类型对应的类中查找 mem。如果找不到,则依次在直接基类中不断查找直至到达继承链的顶端。如果找遍了该类及其基类仍然找不到,则编译器将报错(从当前的静态类型开始向继承链的上游查找——博主注)
    • 一旦找到了 mem,就进行常规的类型检查以确认对于当前找到的 mem,本次调用是否合法(找到了就不再继续往上找了——博主注)
    • 假设调用合法,则编译器将根据调用的是否是虚函数而产生不同的代码:
      • 如果 mem 是虚函数且我们是通过引用或指针进行的调用,则编译器产生的代码将在运行时确定到底运行该虚函数的哪个版本,依据是对象的动态类型
      • 反之,如果 mem 不是虚函数或者我们是通过对象(而非引用或指针)进行的调用,则编译器将产生一个常规函数调用(这意味着,将基类指针或引用绑定到派生类对象上,并通过该指针或引用调用一个基类和派生类中都存在的同名同参非虚函数,则基类中的版本将隐藏派生类中的版本——博主注)
  5. 声明在内层作用域的函数并不会重载声明在外层作用域的函数。因此,定义派生类中的函数也不会重载其基类中的成员。和其他作用域一样,如果派生类(即内层作用域)的成员与基类(即外层作用域)的某个成员同名,则派生类将在其作用域内隐藏该基类成员。即使派生类成员和基类成员的形参列表不一致,基类成员也仍然会被隐藏掉

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    struct Base
    {
    int memfcn();
    };
    struct Derived : Base
    {
    int memfcn(int); // 隐藏基类的 memfcn
    };
    Derived d;
    Base b;
    b.memfcn(); // 调用 Base::memfcn
    d.memfcn(10); // 调用 Derived::memfcn
    d.memfcn(); // 错误:参数列表为空的 memfcn 被隐藏了
    d.Base::memfcn(); // 正确:调用 Base::memfcn

    为了解析 d.memfcn(); 这条调用语句,编译器首先在 Derived 中查找名字 memfcn;因为 Derived 确实定义了一个名为 memfcn 的成员,所以查找过程终止。一旦名字找到,编译器就不再继续查找了。Derived 中的 memfcn 版本需要一个 int 实参,而当前的调用语句无法提供任何实参,所以该调用语句是错误的。

  6. 现在可以理解为什么基类与派生类中的虚函数必须有相同的形参列表了。假如基类与派生类的虚函数接受的实参不同,则我们就无法通过基类的引用或指针调用派生类的虚函数了。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    class Base
    {
    public:
    virtual int fcn();
    };
    class D1 : public Base
    {
    public:
    // 隐藏基类的 fcn,这个 fcn 不是虚函数
    // D1 继承了 Base::fcn() 的定义
    int fcn(int); // 形参列表与 Base 中的 fcn 不一致
    virtual void f2(); // 是一个新的虚函数,在 Base 中不存在
    };
    class D2 : public D1
    {
    public:
    int fcn(int); // 是一个非虚函数,隐藏了 D1::fcn(int)
    int fcn(); // 覆盖了 Base 的虚函数 fcn
    void f2(); // 覆盖了 D1 的虚函数 f2
    };

    D1fcn 将隐藏 Basefcn。此时拥有了两个名为 fcn 的函数:一个是 D1Base 继承而来的虚函数 fcn;另一个是 D1 自己定义的接受一个 int 参数的非虚函数 fcn

    观察下面的示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    Base bobj;
    D1 d1obj;
    D2 d2obj;

    Base *bp1 = &bobj, *bp2 = &d1obj, *bp3 = &d2obj;
    bp1->fcn(); // 虚调用,将在运行时调用 Base::fcn
    bp2->fcn(); // 虚调用,将在运行时调用 Base::fcn
    bp3->fcn(); // 虚调用,将在运行时调用 D2::fcn

    D1 *d1p = &d1obj;
    D2 *d2p = &d2obj;
    bp2->f2(); // 错误:Base 没有名为 f2 的成员
    d1p->f2(); // 虚调用,将在运行时调用 D1::f2()
    d2p->f2(); // 虚调用,将在运行时调用 D2::f2()

    再观察下面的示例,在每条调用语句中,指针都指向了 D2 类型的对象,但是由于我们调用的是非虚函数,所以不会发生动态绑定。实际调用的函数版本由指针的静态类型决定

    1
    2
    3
    4
    5
    6
    Base *p1 = &d2obj;
    D1 *p2 = &d2obj;
    D2 *p3 = &d2obj;
    p1->fcn(42); // 错误:Base 中没有接受一个 int 的 fcn
    p2->fcn(42); // 静态绑定,调用 D1::fcn(int)
    p3->fcn(42); // 静态绑定,调用 D2::fcn(int)
  7. 和其他函数一样,成员函数无论是否是虚函数都能被重载。派生类可以覆盖重载函数的 0 个或多个实例。如果派生类希望所有的重载版本对于它来说都是可见的,那么它就需要覆盖所有的版本,或者一个也不覆盖。

    有时一个类仅需覆盖重载集合中的一些而非全部函数,一种好的解决方案是为重载的成员提供一条 using 声明语句,这样我们就无须覆盖基类中的每一个重载版本了。using 声明语句指定一个名字而不指定形参列表,所以一条基类成员函数的 using 声明语句就可以把该函数的所有重载实例添加到派生类作用域中。此时,派生类只需要定义其特有的函数就可以了,而无须为继承而来的其他函数重新定义

    类内 using 声明的一般规则同样适用于重载函数的名字;基类函数的每个实例在派生类中都必须是可访问的。对派生类没有重新定义的重载版本的访问实际上是对 using 声明点的访问

15.7 构造函数与拷贝控制

  1. 如果一个类(基类或派生类)没有定义拷贝控制操作,则编译器将为它合成一个版本。当然,这个合成的版本也可以定义成被删除的函数(使用 delete 关键字,被删除的函数将不能再被调用——博主注)。

15.7.1 虚析构函数

  1. 继承关系对基类拷贝控制最直接的影响是基类通常应该定义一个虚析构函数,这样我们就能动态分配继承体系中的对象了(与《Effective C++》第三版条款 07“为多态基类声明 virtual 析构函数”相对应——博主注)。当我们 delete 一个动态分配的对象的指针时将执行析构函数。如果该指针指向继承体系中的某个类型,则有可能出现指针的静态类型与被删除对象的动态类型不符的情况。例如,如果我们 delete 一个 Quote* 类型的指针,则该指针有可能实际指向了一个 Bulk_quote 类型的对象。如果这样的话,编译器就必须清楚它应该执行的是 Bulk_quote 的析构函数。和其他函数一样,我们通过在基类中将析构函数定义成虚函数以确保执行正确的析构函数版本

    1
    2
    3
    4
    5
    6
    class Quote
    {
    public:
    // 如果我们删除的是一个指向派生类对象的基类指针,则需要虚析构函数
    virtual ~Quote() = default; // 动态绑定析构函数
    };

    和其他虚函数一样,析构函数的虚属性也会被继承。因此,无论 Quote 的派生类使用合成的析构函数还是定义自己的析构函数,都将是虚析构函数。只要基类的析构函数是虚函数,就能确保当我们 delete 基类指针时将运行正确的析构函数版本

    1
    2
    3
    4
    Quote *itemP = new Quote; // 静态类型与动态类型一致
    delete itemP; // 调用 Quote 的析构函数
    itemP = new Bulk_quote; // 静态类型与动态类型不一致
    delete itemP; // 调用 Bulk_quote 的析构函数

    WARNING:如果基类的析构函数不是虚函数,则 delete 一个指向派生类对象的基类指针将产生未定义的行为。

  2. 如果一个类定义了析构函数,即使它通过 =default 的形式使用了合成的版本,编译器也不会为这个类合成移动操作。

15.7.2 合成拷贝控制与继承

  1. 基类或派生类的合成拷贝控制成员的行为与其他合成的构造函数、赋值运算或析构函数类似:它们对类本身的成员依次进行初始化、赋值或销毁的操作。此外,这些合成的成员还负责使用直接基类中对应的操作对一个对象的直接基类部分进行初始化、赋值或销毁的操作。

  2. 无论基类成员是合成的版本(如 Quote 继承体系的例子)还是自定义的版本都没有太大影响。唯一的要求是相应的成员应该可访问并且不是一个被删除的函数。

  3. 合成的析构函数体是空的,其隐式的析构部分负责销毁类的成员。对于派生类的析构函数来说,它除了销毁派生类自己的成员外,还负责销毁派生类的直接基类;该直接基类又销毁它自己的直接基类,以此类推直至继承链的顶端。

  4. 某些定义基类的方式可能导致有的派生类成员成为被删除的函数

    • 如果基类中的默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的函数或者不可访问,则派生类中对应的成员将是被删除的,原因是编译器不能使用基类成员来执行派生类对象基类部分的构造、赋值或销毁操作
    • 如果在基类中有一个不可访问或删除掉的析构函数,则派生类中合成的默认和接贝构造函数将是被删除的,因为编译器无法销毁派生类对象的基类部分
    • 编译器将不会合成一个删除掉的移动操作。当我们使用 =default 请求一个移动操作时,如果基类中的对应操作是删除的或不可访问的,那么派生类中该函数将是被删除的,原因是派生类对象的基类部分不可移动。同样,如果基类的析构函数是删除的或不可访问的,则派生类的移动构造函数也将是被删除的

  5. 大多数基类都会定义一个虚析构函数。因此在默认情况下,基类通常不含有合成的移动操作,而且在它的派生类中也没有合成的移动操作。

    因为基类缺少移动操作会阻止派生类拥有自己的合成移动操作,所以当我们确实需要执行移动操作时应该首先在基类中进行定义。我们的 Quote 可以使用合成的版本,不过前提是 Quote 必须显式地定义这些成员。一旦 Quote 定义了自己的移动操作,那么它必须同时显式地定义拷贝操作:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class Quote
    {
    public:
    Quote() = default; // 对成员依次进行默认初始化
    Quote(const Quote &) = default; // 对成员依次拷贝
    Quote(Quote &&) = default; // 对成员依次拷贝
    Quote &operator=(const Quote &) = default; // 拷贝赋值
    Quote &operator=(Quote &&) = default; // 移动赋值
    virtual ~Quote() = default;
    // 其他成员与之前的版本一致
    };

    除非 Quote 的派生类中含有排斥移动的成员,否则它将自动获得合成的移动操作。

15.7.3 派生类的拷贝控制成员

  1. 派生类构造函数在其初始化阶段中不但要初始化派生类自己的成员,还负责初始化派生类对象的基类部分。因此,派生类的拷贝和移动构造函数在拷贝和移动自有成员的同时,也要拷贝和移动基类部分的成员。类似的,派生类赋值运算符也必须为其基类部分的成员赋值。

    和构造函数及赋值运算符不同的是,析构函数只负责销毁派生类自己分配的资源。如前所述,对象的成员是被隐式销毁的;类似的,派生类对象的基类部分也是自动销毁的

    WARNING:当派生类定义了拷贝或移动操作时,该操作负责拷贝或移动包括基类部分成员在内的整个对象。

  2. 当为派生类定义拷贝或移动构造函数时,我们通常使用对应的基类构造函数初始化对象的基类部分

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    class Base
    {
    /* ... */
    };
    class D : public Base
    {
    public:
    // 默认情况下,基类的默认构造函数初始化对象的基类部分
    // 要想使用拷贝或移动构造函数,我们必须在构造函数初始值列表中
    // 显式地调用该构造函数
    D(const D &d) : Base(d) // 拷贝基类成员
    /* D 的成员的初始值*/
    {
    /* ... */
    }
    D(D &&d) : Base(std::move(d)) // 移动基类成员
    /* D 的成员的初始值*/
    {
    /* ... */
    }
    };

    Base 的拷贝构造函数负责将 d 的基类部分拷贝给要创建的对象。假如我们没有提供基类的初始值的话:

    1
    2
    3
    4
    5
    6
    // D 的这个拷贝构造函数很可能是不正确的定义
    // 基类部分被默认初始化,而非拷贝
    D(const D &d) /* 成员初始值,但是没有提供基类初始值 */
    {
    /* ... */
    }

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

  3. 与拷贝和移动构造函数一样,派生类的赋值运算符也必须显式地为其基类部分赋值

    1
    2
    3
    4
    5
    6
    7
    8
    // Base::operator=(const Base&) 不会被自动调用
    D &D::operator=(const D &rhs)
    {
    Base::operator=(rhs); // 为基类部分赋值
    // 按照过去的方式为派生类的成员赋值
    // 酌情处理自赋值及释放已有资源等情况
    return *this;
    }
  4. 在析构函数体执行完成后,对象的成员会被隐式销毁。类似的,对象的基类部分也是隐式销毁的。因此,和构造函数及赋值运算符不同的是,派生类析构函数只负责销毁由派生类自己分配的资源

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class D : public Base
    {
    public:
    // Base::~Base 被自动调用执行
    ~D()
    {
    /* 该处由用户定义清除派生类成员的操作 */
    }
    };

    对象销毁的顺序正好与其创建的顺序相反:派生类析构函数首先执行,然后是基类的析构函数,以此类推,沿着继承体系的反方向直至最后。

    如果构造函数或析构函数调用了某个虚函数,则我们应该执行与构造函数或析构函数所属类型相对应的虚函数版本。

15.7.4 继承的构造函数

  1. 一个类只初始化它的直接基类,出于同样的原因,一个类也只继承其直接基类的构造函数。类不能继承默认、拷贝和移动构造函数。 如果派生类没有直接定义这些构造函数,则编译器将为派生类合成它们。派生类继承基类构造函数的方式是提供一条注明了(直接)基类名的 using 声明语句:

    1
    2
    3
    4
    5
    6
    class Bulk_quote : public Disc_quote
    {
    public:
    using Disc_quote::Disc_quote; // 继承 Disc_quote 的构造函数
    double net_price(std::size_t) const;
    };

    通常情况下,using 声明语句只是令某个名字在当前作用域内可见。而当作用于构造函数时,using 声明语句将令编译器产生代码。对于基类的每个构造函数,编译器都生成一个与之对应的派生类构造函数。换句话说,对于基类的每个构造函数,编译器都在派生类中生成一个形参列表完全相同的构造函数。

  2. 和普通成员的 using 声明不一样,一个构造函数的 using 声明不会改变该构造函数的访问级别。例如,不管 using 声明出现在哪儿,基类的私有构造函数在派生类中还是一个私有构造函数;受保护的构造函数和公有构造函数也是同样的规则

    一个 using 声明语句不能指定 explicitconstexpr。如果基类的构造函数是 explicit 或者 constexpr,则继承的构造函数也拥有相同的属性。

  3. 当一个基类构造函数含有默认实参时,这些实参并不会被继承。相反,派生类将获得多个继承的构造函数,其中每个构造函数分别省略掉一个含有默认实参的形参。 例如,如果基类有一个接受两个形参的构造函数,其中第二个形参含有默认实参,则派生类将获得两个构造函数:一个构造函数接受两个形参(没有默认实参),另一个构造函数只接受一个形参,它对应于基类中最左侧的没有默认值的那个形参。

  4. 如果基类含有几个构造函数,则除了两个例外情况,大多数时候派生类会继承所有这些构造函数。

    • 第一个例外是派生类可以继承一部分构造函数,而为其他构造函数定义自己的版本。如果派生类定义的构造函数与基类的构造函数具有相同的参数列表,则该构造函数将不会被继承。定义在派生类中的构造函数将替换继承而来的构造函数
    • 第二个例外是默认、拷贝和移动构造函数不会被继承。这些构造函数按照正常规则被合成。继承的构造函数不会被作为用户定义的构造函数来使用,因此,如果一个类只含有继承的构造函数,则它也将拥有一个合成的默认构造函数

15.8 容器与继承

  1. WARNING:当派生类对象被赋值给基类对象时,其中的派生类部分将被“切掉”,因此容器和存在继承关系的类型无法兼容。

  2. 当我们希望在容器中存放具有继承关系的对象时,我们实际上存放的通常是基类的指针(更好的选择是智能指针。和往常一样,这些指针所指对象的动态类型可能是基类类型,也可能是派生类类型:

    1
    2
    3
    4
    5
    vector<shared_ptr<Quote>> basket;
    basket.push_back(make_shared<Quote>("0-201-82470-1", 50));
    basket.push_back(make_shared<Bulk_quote>("0-201-54848-8", 50, 10, .25));
    // 调用 Quote 定义的版本;打印 562.5,即在 15*&50 中扣除掉折扣金额
    cout << basket.back()->net_price(15) << endl;

    正如我们可以将一个派生类的普通指针转换成基类指针一样,我们也能把一个派生类的智能指针转换成基类的智能指针

15.8.1 编写 Basket 类

  1. 对于容器的 upper_bound 函数来说,它返回的是容器的一个迭代器,该迭代器指向容器中所有与 upper_bound 输入值相等的元素中最后一个元素的下一位置。

  2. 如果父类虚函数返回的是内建数据类型,那么派生类虚函数的返回类型要与父类严格一致;如果父类虚函数返回的是某个父类(可以是自身也可以是其它的类,这里我们称之为 Base)的指针或引用,那么派生类虚函数的返回类型可以是 Base 类的派生类的指针或引用。(博主注)

15.9 文本查询程序再探

15.9.1 面向对象的解决方案

  1. 当我们令一个类公有地继承另一个类时,派生类应当反映与基类的“是一种(IsA)”关系。在设计良好的类体系当中,公有派生类的对象应该可以用在任何需要基类对象的地方。

    类型之间的另一种常见关系是“有一个(HasA)”关系,具有这种关系的类暗含成员的意思。

小结

  1. 动态绑定使得我们可以忽略类型之间的差异,其机理是在运行时根据对象的动态类型来选择运行函数的哪个版本

  2. 在 C++ 语言中,动态绑定只作用于虚函数,并且需要通过指针或引用调用。

  3. 在派生类对象中包含有与它的每个基类对应的子对象。因为所有派生类对象都含有基类部分,所以我们能将派生类的引用或指针转换为一个可访问的基类引用或指针。

  4. 当执行派生类的构造、拷贝、移动和赋值操作时,首先构造、拷贝、移动和赋值其中的基类部分,然后才轮到派生类部分。析构函数的执行顺序则正好相反,首先销毁派生类,接下来执行基类子对象的析构函数。

  5. 基类通常都应该定义一个虚析构函数,即使基类根本不需要析构函数也最好这么做。将基类的析构函数定义成虚函数的原因是为了确保当我们删除一个基类指针,而该指针实际指向一个派生类对象时,程序也能正确运行(防止内存泄漏——博主注)。

术语表

  1. 动态绑定(dynamic binding)直到运行时才确定到底执行函数的哪个版本。在 C++ 语言中,动态绑定的意思是在运行时根据引用或指针所绑定对象的实际类型来选择执行虚函数的某一个版本。

  2. 动态类型(dynamic type)对象在运行时的类型。引用所引对象或者指针所指对象的动态类型可能与该引用或指针的静态类型不同。基类的指针或引用可以指向一个派生类对象。在这样的情况中,静态类型是基类的引用(或指针),而动态类型是派生类的引用(或指针)。

  3. 多态性(polymorphism)当用于面向对象编程的范畴时,多态性的含义是指程序能通过引用或指针的动态类型获取类型特定行为的能力。

  4. 私有继承(private inheritance)在私有继承中,基类的公有成员和受保护成员是派生类的私有成员。

  5. 纯虚函数(pure virtual)在类的内部声明虚函数时,在分号之前使用了=0。一个纯虚函数不需要(但是可以)被定义。含有纯虚函数的类是抽象基类。如果派生类没有对继承而来的纯虚函数定义自己的版本,则该派生类也是抽象的。

  6. 运行时绑定(run-time binding)与动态绑定等价。

  7. 切掉(sliced down)当我们用一个派生类对象初始化基类对象或者为基类对象赋值时发生的情况。对象的派生类部分将被“切掉”,只剩下基类部分赋值给基类对象。

  8. 静态类型(static type)对象被定义的类型或表达式产生的类型。静态类型在编译时是已知的。

  9. 虚函数(virtual function)用于定义类型特定行为的成员函数。通过引用或指针对虚函数的调用直到运行时才被解析,依据是引用或指针所绑定对象的类型。

Thank you for your donate!