第05章 C++语言专题(二.01)单继承

声明:仅为个人学习总结,还请批判性查看,如有不同观点,欢迎交流。

摘要

内容主要包括:基类和派生类的定义、派生类和基类的类型转换、虚函数与抽象基类,以及继承中的访问控制、作用域、构造函数和拷贝控制。


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

  • 通过数据抽象,可以定义将接口与实现分离的类;
  • 通过继承,可以定义相似的类型,并对其相似关系进行建模;
  • 通过动态绑定,可以在一定程度上忽略相似类型的区别,以统一的方式使用它们的对象。

C++(一.03)类 中已经整理了数据抽象相关知识,本章将继续整理继承和动态绑定相关知识。

1 定义基类和派生类

通过继承(inheritance)联系在一起的类构成一种层次关系:

  • 基类(base class),在层次关系的根部,定义层次关系中所有类共同拥有的成员;
  • 派生类(derived class),直接或间接地从基类继承而来,定义各自特有的成员。

1.1 定义基类

在 C++ 语言中,基类必须区分两种成员函数:

  • 基类希望其派生类直接继承而不要改变的函数;
  • 基类希望其派生类进行覆盖的函数,通常将其定义为虚函数。
// 定义基类 Quote,其对象表示按原价销售的书籍
class Quote
{
public:
  Quote() = default;
  Quote(const string &book, double sales_price) : bookNo(book), price(sales_price) {}

  // 返回对象的 ISBN 编号;
  // 未被声明为 virtual 的成员函数,其解析过程发生在编译时,而非运行时
  // 函数的执行与派生类的情况无关,无论作用于基类对象还是派生类对象,函数的行为都一样
  string isbn() const { return bookNo; }

  // 返回给定数量的书籍的销售总额,派生类使用不同的折扣计算算法进行改写(覆盖,override)
  // 基类通过在其成员函数的声明语句之前加上关键字 virtual 使得该函数执行动态绑定
  // 当使用指针或引用调用虚函数时,根据绑定对象类型的不同,可能执行基类的版本,也可能执行某个派生类的版本
  // 关键字 virtual 只能出现在类内部的声明语句之前,而不能用于类外部的函数定义
  virtual double net_price(size_t n) const { return n * price; }

  // 基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作
  virtual ~Quote() = default; // 对析构函数进行动态绑定

  // 派生类可以继承定义在基类中的成员,但是,派生类的成员函数不一定有权访问从基类继承而来的成员
  // 和其他使用基类的代码一样,派生类能访问基类的公有 public 成员,不能访问私有 private 成员
  // 另外,基类中受保护的 protected 成员,允许派生类访问,禁止其他用户访问
private:
  string bookNo; // 书籍的 ISBN 编号

protected:
  // 派生类需要访问 price 成员,来定义各自的 net_price 函数
  double price = 0.0; // 普通状态下不打折的价格
};

1.2 定义派生类

// 定义派生类 Bulk_quote,其对象表示可以打折销售的书籍
// 派生类必须通过 类派生列表(class derivation list)明确指出它是从哪个(哪些)基类继承而来
// 大多数类都只继承自一个类,这种形式的继承被称作“单继承”
// Bulk_quote 类从基类 Quote 那里继承了 isbn 函数和 bookNo、price 数据成员
class Bulk_quote : public Quote // Bulk_quote 继承自 Quote
{
public:
  Bulk_quote() = default;
  Bulk_quote(const string &, double, size_t, double);

  // 派生类必须重新声明那些“其继承而来的、并且需要覆盖的”成员函数
  // 覆盖基类的 net_price 函数版本,从而实现折扣政策
  double net_price(size_t) const override;

  // 派生类经常(但不总是)覆盖它继承的虚函数。
  // 如果派生类没有覆盖其基类中的某个虚函数,派生类会直接继承这个函数在基类中的版本。

private:
  // 派生类 Bulk_quote 拥有两个新增加的数据成员
  size_t min_qty = 0;    // 适用折扣政策的最低购买量
  double discount = 0.0; // 以小数表示的折扣额
};

// 如果达到了购买书籍的某个最低限量值,就可以享受折扣价
double Bulk_quote::net_price(size_t cnt) const
{
  // 派生类的作用域嵌套在基类的作用域内
  // 所以,派生类成员函数使用基类成员的方式,与使用派生类成员的方式一致
  if (cnt >= min_qty)
    return cnt * (1 - discount) * price;
  else
    return cnt * price;
}

1.2.1 派生类向基类的转换

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

  • 一个“含有派生类自己定义的(非静态)成员的”子对象;
  • 一个“与该派生类继承的基类对应的”子对象(如果有多个基类,则每个基类对应一个);
  • 对于一个继承链末端的派生类来说,将包含它的直接基类的子对象以及每个间接基类的子对象:
    • 末端的派生类会继承其直接基类的成员,
    • 该直接基类又继承其基类的成员,
    • 依此类推,直至继承链的顶端。

因为在派生类对象中含有与其基类对应的组成部分(基类部分),所以:

  • 能够把派生类的对象当成基类对象来使用;
  • 能够将基类的指针或引用绑定到派生类对象中的基类部分上。
  • 能够进行派生类到基类的(derived-to-base)隐式类型转换:
    • 在需要基类引用的地方,可以使用派生类对象或者派生类对象的引用;
    • 在需要基类指针(内置/智能指针)的地方,可以使用派生类对象的指针(内置/智能指针)。
  // 一个 Bulk_quote 对象包含 2 个组成部分,4 个数据元素:
  // 基类部分:从 Quote 继承而来的 bookNo 和 price 数据成员;
  // 派生类部分:Bulk_quote 自己定义的 min_qty 和 discount 数据成员。

  Quote item;       //  object of base type
  Bulk_quote bulk;  //  object of derived type
  Quote *p = &item; //  p points to a Quote object
  p = &bulk;        //  p points to the Quote part of bulk
  Quote &r = bulk;  //  r bound to the Quote part of bulk

1.2.2 派生类构造函数

派生类对象的基类部分与派生类部分,都在构造函数的初始化阶段执行初始化操作,并且每个类控制它自己的成员初始化过程:

  1. 首先,初始化基类部分:
    • 使用基类构造函数来初始化,通过派生类构造函数初始化列表,将实参传递给基类构造函数;
    • 如果没有显式调用基类构造函数,基类部分会像数据成员一样,执行默认初始化。
  2. 接着,按照声明的顺序初始化派生类的成员。

复习回顾:C++(一.03)类 => 3.3 构造函数初始值列表

例如,接受四个参数的 Bulk_quote 构造函数如下所示:

Bulk_quote::Bulk_quote(const string &book, double p, size_t qty, double disc)
    : Quote(book, p), min_qty(qty), discount(disc) {}
  1. 首先,该函数将它的前两个参数(分别表示 ISBN 和价格)传递给 Quote 的构造函数;
  2. 然后,由 Quote 的构造函数初始化 Bulk_quote 基类部分的 bookNoprice 成员,运行(空的)函数体,完成对象基类部分的构建;
  3. 接下来,初始化由派生类直接定义的 min_qtydiscount 成员;
  4. 最后,运行 Bulk_quote 构造函数的(空的)函数体,完成对象的构建。

遵循基类的接口:
每个类负责定义各自的接口。要想与类的对象交互必须使用该类的接口,即使这个对象是派生类的基类部分。

  • 派生类应该遵循基类的接口,与基类部分交互;
  • 派生类应该调用基类的构造函数来初始化那些从基类继承而来的成员。

1.2.3 继承与静态成员

如果基类定义了一个静态成员,那么在整个继承体系中只存在该成员的唯一定义。
无论从基类派生出多少个派生类,每个静态成员都只存在唯一的实例。复习回顾:C++(一.03)类 => 7、类的静态成员

class Base {
public:
  // 静态成员遵循通用的访问控制规则,
  // 如果基类中的成员是 private 的,则派生类无权访问。
  static void statmem();
};
class Derived : public Base {
  void f(const Derived &);
};

void Derived::f(const Derived &derived_obj)
{
  // 假设基类的某静态成员是可访问的,可以通过基类/派生类的作用域运算符直接访问
  Base::statmem();    // 正确:Base 定义了 statmem
  Derived::statmem(); // 正确:Derived 继承了 statmem
  // 可以通过派生类的对象访问
  derived_obj.statmem(); // 通过 Derived 对象访问
  statmem();             // 通过 this 对象访问
}

1.2.4 防止继承的发生(C++11)

// 在类名后跟一个 final 关键字,可以防止继承发生
class NoDerived final { /*  */ };   // NoDerived 不能作为基类
class Base { /*  */ };
class Last final : Base { /*  */ }; // Last 不能作为基类
class Bad : NoDerived { /*  */ };   // 错误:NoDerived 是 final 的
class Bad2 : Last { /*  */ };       // 错误:Last 是 final 的

1.3 类型转换与继承

1.3.1 静态类型与动态类型

因为 派生类对象的组成特征 ,所以可以将基类的引用或指针绑定到派生类对象上。这也意味着:当使用基类的引用(或指针)时,实际上我们并不清楚该引用(或指针)所绑定对象的真正类型。这个对象可能是基类的对象,也可能是派生类的对象。

  • 静态类型(static type),是变量声明时的类型,或表达式生成的类型,在编译时就能知道;
  • 动态类型(dynamic type),是变量或表达式表示的内存中的对象的类型,直到运行时才能知道。

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

  // 对象的类型是确定不变的,没有动态类型与静态类型的区分
  Quote item; 
  Bulk_quote bulk;

  // 基类指针 p 的静态类型为 Quote
  Quote *p = &item; // p 的动态类型与静态类型一致,也为 Quote
  p = &bulk;        // p 的动态类型与静态类型不一致,为 Bulk_quote

  // 基类引用 r 的静态类型与动态类型不一致,分别为 Quote 和 Bulk_quote
  Quote &r = bulk;

1.3.2 基类到派生类不存在隐式转换

  • 之所以存在派生类向基类的隐式类型转换,是因为

  • 之所以不存在从基类向派生类的隐式类型转换,是因为

    • 一个基类的对象既可以以独立的形式存在,也可以作为派生类对象的一部分存在;
    • 如果基类对象不是派生类对象的一部分,那么它只含有基类定义的成员,不含有派生类定义的成员。
// 如果下面的赋值合法,则可能会使用 bulkP 或 bulkRef 访问 base 中不存在的成员
Quote base;
Bulk_quote *bulkP = &base;  // error: can't convert base to derived
Bulk_quote &bulkRef = base; // error: can't convert base to derived

// 即使一个基类指针或引用绑定到一个派生类对象上,也不能执行从基类向派生类的自动转换
// 编译器只能通过检查指针或引用的静态类型来推断该转换是否合法,
// 无法在编译时确定某个特定的转换在运行时是否安全。
Bulk_quote bulk;
Quote *itemP = &bulk;      // ok: dynamic type is Bulk_quote
Bulk_quote *bulkP = itemP; // error: can't convert base to derived

// 如果在基类中含有一个或多个虚函数,可以使用 dynamic_cast 请求一个类型转换。
// 该转换的安全检查将在运行时执行
Bulk_quote *bulkP2 = dynamic_cast<Bulk_quote *>(itemP);

// 如果已知某个基类向派生类的转换是安全的,可以使用 static_cast 来强制覆盖编译器的检查工作
Bulk_quote *bulkP3 = static_cast<Bulk_quote *>(itemP);

1.3.3 派生类到基类的初始化/赋值

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

  • 用派生类对象为基类对象初始化时,实际上是在调用基类的拷贝/移动构造函数,虽然传递的是派生类的对象,但该构造函数只能处理基类部分的成员;
  • 用派生类对象为基类对象赋值时,实际上是在调用基类的拷贝/移动赋值运算符,同样,虽然传递的是派生类的对象,但该运算符也只能处理基类部分的成员。
Bulk_quote bulk;  // object of derived type

// 当构造 item 时,运行 Quote 的拷贝构造函数。
// 该函数只能拷贝 bulk 中 Quote 部分的 bookNo 和 price 两个成员,
// 同时忽略 bulk 中 Bulk_quote 部分的成员,可以说 bulk 的 Bulk_quote 部分被切掉(sliced down)了
Quote item(bulk); // uses the Quote::Quote(const Quote&) constructor

// 同样,也只有 bulk 中 Quote 部分的成员被赋值给 item
item = bulk; // calls Quote::operator=(const Quote&)

存在继承关系的类型之间的转换规则:

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

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

2 虚函数

在 C++ 语言中,基类将“派生类不做改变直接继承的函数”与“类型相关的函数”区分对待。对于希望派生类自己定义的函数,基类将其声明为虚函数(virtual function)。任何构造函数之外的非静态成员函数都可以是虚函数。

通常情况下,如果不使用某个函数,则无须为该函数提供定义;但对于虚函数,不管是否被用到了,都必须为其提供定义。这是因为,当使用基类的引用或指针调用一个虚成员函数时,编译器在编译时并不能确定应该使用哪个版本的虚函数,直到运行时才能知道函数的版本。

2.1 动态绑定

当使用基类的引用(或指针)调用一个虚成员函数时,将发生动态绑定(dynamic binding,也称为 运行时绑定 run-time binding),被调用的函数与绑定到引用(或指针)上的对象的动态类型相匹配。

通过动态绑定,能够实现:使用同一段代码既可以处理基类的对象,又可以处理派生类的对象。

// 计算并打印“销售给定数量的某种书籍所得的”费用
// 因为 形参 item 是基类 Quote 的一个引用,
// 所以 既能使用基类 Quote 的对象调用该函数,也能使用派生类 Bulk_quote 的对象调用该函数
double print_total(ostream &os, const Quote &item, size_t n)
{
  // item 的静态类型是 Quote&
  // item 的动态类型取决于其绑定的实参,在运行过程中,调用函数时才会知道

  // 调用 net_price 的哪个版本,依赖于运行时绑定到 item 的实参的实际(动态)类型
  // 如果 item 绑定到 Bulk_quote 的对象,则调用 Bulk_quote::net_price,否则调用 Quote::net_price
  double ret = item.net_price(n);

  os << "ISBN: " << item.isbn() // 调用 Quote::isbn
     << " # sold: " << n << " total due: " << ret << endl;
  return ret;
}

int main()
{
  Quote basic("0-201-82470-1", 50);
  // 形参 item 绑定到 Quote 类型的对象 basic,会调用 Quote::net_price
  print_total(cout, basic, 10);

  Bulk_quote bulk("0-201-82470-1", 50, 5, .19);
  // 形参 item 绑定到 Bulk_quote 类型的对象 bulk,会调用 Bulk_quote::net_price
  print_total(cout, bulk, 10); 

  // 动态绑定只有在通过指针或引用调用虚函数时才会发生
  // 通过一个具有普通类型(非引用、非指针)的表达式调用虚函数时,会在编译时确定函数版本
  basic = bulk;        // copies the Quote part of bulk into basic
  basic.net_price(20); // calls Quote::net_price
}

函数绑定时机:

  • 对非虚函数的调用,在编译时进行绑定;
  • 通过对象进行的函数(虚函数或非虚函数)调用,在编译时绑定;
  • 当且仅当通过基类的引用或指针调用虚函数时,才会在运行时进行动态绑定:
    • 引用或指针的静态类型与动态类型有可能不同;
    • 依据引用或指针所绑定的对象的真实类型(基类或某个派生类),决定执行哪个版本的虚函数。

C++ 的多态性(polymorphism)
多态性,是 OOP 的核心思想,字面含义是“多种形式"。我们把具有继承关系的多个类型称为多态类型,因为我们能够使用这些类型的“多种形式”而无须在意它们的差异。
引用或指针的静态类型与动态类型不同这一事实,是 C++ 语言支持多态性的基础。

2.2 派生类中的虚函数

在派生类中覆盖基类的某个虚函数时:

  • 可以再一次使用 virtual 关键字,但并非必须,因为一旦某个函数被声明为虚函数,在所有派生类中它都是虚函数;
  • 函数的形参类型必须与基类函数完全一致
  • 函数的返回类型也必须与基类函数匹配。存在一个例外:
    • 如果虚函数的返回类型是类本身的引用或指针,并且从派生类到基类的类型转换是可访问的,那么返回类型可以不同。
class Quote
{
public:
    // 该虚函数返回当前对象的一份动态分配的拷贝
    virtual Quote *clone() const & { return new Quote(*this); }
    virtual Quote *clone() && { return new Quote(std::move(*this)); }
};

class Bulk_quote : public Quote
{
public:
    // 虚函数的返回类型是类本身的引用或指针时,返回类型可以不同
    Bulk_quote *clone() const & { return new Bulk_quote(*this); }
    Bulk_quote *clone() && { return new Bulk_quote(std::move(*this)); }
};

2.3 finaloverride(C++11)

finaloverride 说明符,需要放在形参列表(包括任何 const 和引用说明符)以及尾置返回类型(C++11)之后。

2.3.1 override 说明符

可以使用 override 说明符来说明派生类中的虚函数:

  • 可以使程序意图更加清晰;
  • 可以让编译器协助发现错误,即:使用 override 标记了某个函数,但该函数并没有覆盖基类的任何虚函数。
struct B
{
    virtual void f1(int) const;
    virtual void f2();
    void f3();
};
struct D1 : B
{
    void f1(int) const override; // ok: f1 matches f1 in the base
    void f2(int) override;       // error: B has no f2(int) function
    void f3() override;          // error: f3 not virtual
    void f4() override;          // error: B doesn't have a function named f4
};

2.3.2 final 说明符

可以使用 final 说明符来阻止函数被覆盖:

struct B
{
    virtual void f1(int) const;
    virtual void f2();
    void f3();
};
struct D2 : B
{
    // inherits f2() and f3() from B and overrides f1(int)
    void f1(int) const final; // subsequent classes can't override f1 (int)
};
struct D3 : D2
{
    void f2();          // ok: overrides f2 inherited from the indirect base, B
    void f1(int) const; // error: D2 declared f2 as final
};

2.4 虚函数与默认实参

虚函数也可以拥有默认实参:

  • 如果某次函数调用使用了默认实参,则该实参的值由本次调用的静态类型决定;
  • 如果虚函数使用默认实参,那么基类和派生类中定义的默认实参最好一致。
class Base 
{
public:
    virtual void print(int x = 1) {
        cout << "Base: " << x << endl;
    }
};

class Derived : public Base
{
public:
    virtual void print(int x = 2) {
        cout << "Derived: " << x << endl;
    }
};

int main()
{
    // 如果通过基类的引用或指针调用函数,则使用基类中定义的默认实参
    // 即使实际运行的是派生类中的函数版本,传入派生类函数的也是基类函数定义的默认实参
    Base *b = new Derived();
    b->print(); // 输出 "Derived: 1",而不是 "Derived: 2"

    Derived *d = new Derived();
    d->print(); // 输出 "Derived: 2"
}

2.5 回避虚函数的机制

通过使用作用域运算符,可以回避虚函数调用的动态绑定,强行调用虚函数的某个特定版本。并且,该调用将在编译时完成解析。

  • 通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制。例如派生类的虚函数调用它覆盖的基类的虚函数版本,这样,既可以实现基类的处理,又可以执行派生类自身的操作。
  • 如果一个派生类虚函数需要调用它的基类版本,但是没有使用作用域运算符,那么该调用会在运行时被解析为对派生类版本自身的调用,从而导致无限递归。
// 强行调用基类中定义的函数版本,而不管 baseP 的动态类型到底是什么
double undiscounted = baseP->Quote::net_price(42);

3 抽象基类

抽象基类(abstract base class),含有(或者未经覆盖直接继承)纯虚函数的类。

  • 抽象基类负责定义接口,后续的派生类可以覆盖接口;
  • 不能(直接)创建一个抽象基类的对象;
  • 如果派生类没有覆盖接口,给出纯虚函数的定义,就仍然是抽象基类。
// 抽象基类 Disc_quote,用于保存折扣值和购买量,派生类使用这些数据可以实现不同的价格策略
class Disc_quote : public Quote
{
public:
  Disc_quote() = default;
  
  // 尽管不能直接定义 Disc_quote 类的对象
  // 但是 Disc_quote 派生类的构造函数会使用 Disc_quote 的构造函数
  // 来构建每个派生类对象的 Disc_quote 部分
  Disc_quote(const string &book, double price, size_t qty, double disc)
      : Quote(book, price), quantity(qty), discount(disc) {}

  // 将 net_price 定义成纯虚(pure virtual)函数,说明它并没有实际意义
  // 纯虚函数无须定义,= 0 只能出现在类内部的虚函数声明语句位置
  // 也可以为纯虚函数提供定义,但是,函数体必须定义在类的外部
  double net_price(size_t) const = 0;

protected:
  size_t quantity = 0;    // 折扣适用的购买量
  double discount = 0.0;  // 表示折扣的小数值
};

// 派生类 Bulk_quote,当同一书籍的销售量超过某个值时启用折扣
// 新版 Bulk_quote 的直接基类是 Disc_quote,间接基类是 Quote
// 每个 Bulk_quote 对象包含三个子对象:
// 一个(空的)Bulk_quote 部分、一个 Disc_quote 子对象、一个 Quote 子对象
class Bulk_quote : public Disc_quote
{
public:
  Bulk_quote() = default;

  // 每个类各自控制其对象的初始化过程:
  // 1. Bulk_quote 的构造函数将实参传递给 Disc_quote 的构造函数;
  // 2. Disc_quote 的构造函数将实参传递给 Quote 的构造函数;
  // 3. Quote 的构造函数初始化 bookNo、price 成员,执行(空的)函数体后返回;
  // 4. Disc_quote 的构造函数初始化 quantity、discount 成员,执行(空的)函数体后返回;
  // 5. Bulk_quote 的构造函数,没有需要初始化的数据成员,直接执行(空的)函数体后返回。
  Bulk_quote(const string &book, double price, size_t qty, double disc)
      : Disc_quote(book, price, qty, disc) {}

  // 覆盖基类中的函数版本,以实现一种新的折扣策略
  double net_price(size_t) const override;
};

// Disc_quote 将 net_price 定义成了纯虚函数,所以不能定义 Disc_quote 的对象
Disc_quote discounted; // error: can't define a Disc_quote object

// Disc_quote 的派生类 Bulk_quote 覆盖了 net_price 函数,可以定义 Bulk_quote 的对象
Bulk_quote bulk; // ok: Bulk_quote has no pure virtual functions

重构(refactoring)
Quote 的继承体系中增加 Disc_quote 类是重构的一个典型示例。

  • 重构负责重新设计类的体系,以便将操作和/或数据从一个类移动到另一个类中;
  • 即使我们改变了整个继承体系,那些使用了 Bulk_quoteQuote 的代码也无须进行任何改动;
  • 一旦类被重构(或以其他方式被改变),就必须重新编译含有这些类的代码。

4 访问控制与继承

每个类不但要控制自己成员的初始化过程,还要控制自己的成员对于派生类来说是否可访问(accessible)。

4.1 受保护的成员

一个类使用 protected 关键字声明那些“它希望与派生类分享,但是不想被普通用户访问的”成员。

  • 对于类的用户来说,受保护的成员是不可访问的;
  • 对于派生类的成员和友元来说,受保护的成员是可访问的,但是:
    • 只能通过“派生类对象”访问基类的受保护成员;
    • 不能通过“基类对象”访问基类的受保护成员。
class Base
{
protected:
  int prot_mem; // protected member
};
class Sneaky : public Base
{
  friend void clobber(Sneaky &); // can access Sneaky::prot_mem
  friend void clobber(Base &);   // can't access Base::prot_mem
  int j;                         // j is private by default

  bool comp(Sneaky &d) { return prot_mem < d.prot_mem; } // ok: can access Sneaky::prot_mem
  bool comp(Base &b) { return prot_mem < b.prot_mem; }   // error: can't access Base::prot_mem
};

// ok: clobber can access the private and protected members in Sneaky objects
void clobber(Sneaky &s) { s.j = s.prot_mem = 0; }
// error: clobber can't access the protected members in Base
void clobber(Base &b) { b.prot_mem = 0; }

4.2 公有、私有和受保护继承

对于从基类中继承而来的成员,其访问权限受到两个因素的影响:

  1. 在基类中该成员的访问说明符;
  2. 在派生类的派生列表中的访问说明符(派生访问说明符)。
序号基类成员访问说明符派生类成员及友元派生访问说明符成员在派生类中派生类普通用户派生类的派生类成员及友元
1public可访问publicpublic可访问可访问
2protected可访问publicprotected不可可访问
3private不可访问publicprivate不可不可
4publicprotectedprotected不可可访问
5protectedprotectedprotected不可可访问
6privateprotectedprivate不可不可
7publicprivateprivate不可不可
8protectedprivateprivate不可不可
9privateprivateprivate不可不可

派生类的成员(及友元)对基类成员的访问权限只与基类中的访问说明符有关,与派生访问说明符无关。

class Base
{
public:
  void pub_mem(); // public member
protected:
  int prot_mem;   // protected member
private:
  char priv_mem;  // private member
};

struct Pub_Derv : public Base
{
  // ok: derived classes can access protected members
  int f() { return prot_mem; }
  // error: private members are inaccessible to derived classes
  char g() { return priv_mem; }
};

struct Priv_Derv : private Base
{
  // private derivation doesn't affect access in the derived class
  int f1() const { return prot_mem; }
};

struct Prot_Derv : protected Base {
  int f1() const { return prot_mem; }
};

派生访问说明符对于派生类的成员(及友元)能否访问其直接基类的成员并没有影响。
派生访问说明符的目的是:

  • 控制派生类用户,对于基类成员的访问权限;
  • 控制继承自派生类的新类(派生类的派生类)的成员(及友元),对于基类成员的访问权限;
// 控制派生类用户,对于基类成员的访问权限
Pub_Derv dPub;   //  对于公有继承,继承自基类的成员,在派生类中将遵循其原有的访问说明符
Priv_Derv dPriv; //  对于私有继承,继承自基类的所有成员,在派生类中都是 private 的
Prot_Derv dProt; //  对于保护继承,继承自基类的公有成员,在派生类中变 protected,其他不变

dPub.pub_mem();  //  ok: pub_mem is public in the derived class
dPriv.pub_mem(); //  error: pub_mem is private in the derived class
dProt.pub_mem(); //  error: pub_mem is protected in the derived class
// 控制派生类的派生类,对于基类成员的访问权限
struct Derived_from_Public : public Pub_Derv
{
  // ok: Base::prot_mem remains protected in Pub_Derv
  int use_base() { return prot_mem; }
};
struct Derived_from_Private : public Priv_Derv
{
  // error: Base::prot_mem is private in Priv_Derv
  int use_base() { return prot_mem; }
};
struct Derived_from_Protected : public Prot_Derv
{
  // ok: Base::prot_mem remains protected in Prot_Derv
  int use_base() { return prot_mem; }
};

4.3 派生类向基类转换的可访问性

派生类向直接基类的转换是否可访问,由“使用该转换的代码”和“派生类的派生访问说明符”共同决定。假定 D 继承自 B

  • 对于用户代码:
    • D 公有继承 B 时,能够使用派生类向基类的转换;
    • D 受保护或私有继承 B 时,不能使用该转换。
  • 对于 D 的成员函数和友元:
    • 无论 D 以什么方式继承 B,都能使用派生类向基类的转换。
  • 对于 D 的派生类的成员和友元:
    • D 公有或受保护继承 B 时,可以使用 DB 的类型转换;
    • D 私有继承 B 时,不能使用该转换。

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

类的设计

一个类有三种不同的用户:普通用户、类的实现者、派生类

  • 普通用户,使用类的对象,只能访问类的公有(接口)成员;
  • 类的实现者(成员和友元),能够访问类的所有成员;
  • 派生类及其友元,能够访问公有和受保护的成员,不能访问私有成员。

设计类时,

  • 将其接口成员声明为公有的;
  • 将其实现部分分成两组:
    • 一组可供派生类访问,声明为受保护的;
    • 一组只能由基类及其友元访问,声明为私有的。

4.4 友元与继承

就像友元关系不能传递一样,复习回顾:C++(一.03)类 => 2.4 示例类2
友元关系同样也不能继承(每个类负责控制各自成员的访问权限):

  • 派生类的友元不能随意访问基类的成员;
  • 基类的友元也不能随意访问其派生类的成员;
  • 友元的基类和派生类,对于当前类不具有特殊的访问能力。
class Base
{
    // added friend declaration;
    friend class Pal; // Pal has no access to classes derived from Base
protected:
    int prot_mem; // protected member
};

// 派生类的友元不能随意访问基类的成员
class Sneaky : public Base
{
    friend void clobber(Sneaky &s) { s.j = s.prot_mem = 0; } // ok: can access Sneaky::prot_mem
    friend void clobber(Base &b) { b.prot_mem = 0; }         // error: can't access Base::prot_mem
    int j;                                                   // j is private by default
};

// 基类的友元不能随意访问其派生类的成员
class Pal
{
public:
    int f(Base b) { return b.prot_mem; } // ok: Pal is a friend of Base
    int f2(Sneaky s) { return s.j; }     // error: Pal not friend of Sneaky

    // 对基类的访问权限由基类本身控制,即便对于派生类的基类部分也是如此
    int f3(Sneaky s) { return s.prot_mem; } // ok: Pal is a friend
};

// 一个类将另一个类声明为友元时,这种友元关系只对做出声明的类有效。
// 友元的基类和派生类,对于当前类不具有特殊的访问能力。
// D2 has no access to protected or private members in Base
class D2 : public Pal
{
public:
    int mem(Base b) { return b.prot_mem; } // error: friendship doesn't inherit
};

4.5 改变个别成员的可访问性

对于继承的某个名字,派生类可以使用 using 声明改变其访问级别:

  • 通过在类的内部使用 using 声明语句,可以将该类的直接或间接基类中的任何可访问成员标记出来;
  • 名字的访问权限由该 using 声明语句之前的访问说明符来决定;
  • 派生类只能为那些它可以访问的名字提供 using 声明。
class Base
{
public:
    size_t size() const { return n; }
protected:
    size_t n;
};

// 因为 Derived 使用了私有继承,
// 所以 继承而来的成员 size 和 n (在默认情况下)是 Derived 的私有成员。
// 然而 可以使用 using 声明语句改变这些成员的可访问性
class Derived : private Base
{
public:
    // using 声明语句位于 public 部分,类的所有用户都能访问该名字
    using Base::size;

protected:
    // using 声明语句位于 protected 部分,成员、友元和派生类可以访问该名字
    using Base::n;

    // 如果 using 声明语句出现在类的 private 部分,则该名字只能被类的成员和友元访问
};

4.6 默认的继承保护级别

使用 structclass 关键字定义的类,具有不同的默认访问说明符。复习回顾:C++(一.03)类 => 5.1 访问说明符
类似地,默认派生说明符也由定义派生类所用的关键字来决定。

  • 使用 class 关键字定义的派生类是 private 继承;
  • 使用 struct 关键字定义的派生类是 public 继承。

注:一个“私有派生的”类最好显式地声明 private,而不要仅仅依赖于默认的设置,以避免误会。

class Base { /* ... */ };
struct D1 : Base { /* ... */ }; // 默认 public 继承
class D2 : Base { /* ... */ };  // 默认 private 继承

关于 structclass
人们常常有一种错觉,认为在使用 struct 关键字和 class 关键字定义的类之间还有更深层次的差别。事实上,唯一的差别就是默认成员访问说明符及默认派生访问说明符;除此之外,再无其他不同之处。

5 继承中的类作用域

每个类定义自己的作用域,在这个作用域内定义类的成员。复习回顾:C++(一.03)类 => 6、名字查找与类的作用域

当存在继承关系时,派生类的作用域嵌套在其基类的作用域之内。如果一个名字在派生类的作用域内无法正确解析,编译器将按照由内向外的顺序,继续在外层的基类作用域中寻找该名字的定义。

Bulk_quote bulk;
cout << bulk.isbn();

名字 isbn 的解析过程为:

  1. 因为通过 Bulk_quote 的对象调用 isbn,所以首先在 Bulk_quote 中查找,这一步没有找到;
  2. 因为 Bulk_quoteDisc_quote 的派生类,所以接下来在 Disc_quote 中查找,仍然没有到;
  3. 因为 Disc_quoteQuote 的派生类,所以接着查找 Quote,此时找到了名字 isbn,并将其解析为 Quote 中的 isbn

5.1 在编译时进行名字查找

一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。

class Disc_quote : public Quote
{
public:
    // 只能通过 Disc_quote 及其派生类的对象、引用或指针使用 discount_policy
    pair<size_t, double> discount_policy() const { return {quantity, discount}; }
    // ...
};

class Bulk_quote : public Disc_quote {
    // ...
};

Bulk_quote bulk;
Bulk_quote *bulkP = &bulk; //  static and dynamic types are the same
Quote *itemP = &bulk;      //  static and dynamic types differ
bulkP->discount_policy();  //  ok: bulkP has type Bulk_quote*

// itemP 的类型是 Quote 的指针,意味着对 discount_policy 的搜索将从 Quote 开始
// 显然 Quote 不包含名为 discount_policy 的成员
// 所以 无法通过 Quote 的对象、引用或指针调用 discount_policy
itemP->discount_policy();  //  error: itemP has type Quote*

5.2 名字冲突与继承

和其他作用域一样,派生类也能重用定义在其直接基类或间接基类中的名字,此时定义在内层作用域(即派生类)的名字将隐藏定义在外层作用域(即基类)的名字。

  • 可以通过作用域运算符来使用一个被隐藏的外层作用域的名字,作用域运算符将指示编译器从指定的作用域开始查找。
  • 除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字。
struct Base
{
    Base() : mem(0) {}
protected:
    int mem;
};
struct Derived : Base
{
    Derived(int i) : mem(i) {}    // initializes Derived::mem to i
                                  // Base::mem is default initialized
    int get_mem() { return mem; } // returns Derived::mem

    // 作用域运算符将指示编译器从 Base 类的作用域开始查找 mem
    int get_base_mem() { return Base::mem; }

protected:
    // 派生类的成员将隐藏同名的基类成员
    int mem; // hides mem in the base
};

Derived d(42);
cout << d.get_mem() << endl;      // prints 42
cout << d.get_base_mem() << endl; // prints 0

5.3 名字查找与继承

理解函数调用的解析过程,对于理解 C++ 中的继承至关重要。假定我们调用 p->mem()(或 obj.mem()),会依次执行以下 4 个步骤:

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

因为声明在内层作用域的函数不会重载声明在外层作用域的函数,所以,定义在派生类中的函数也不会重载其基类中的成员。

如果派生类(即内层作用域)的成员,与基类(即外层作用域)的某个成员同名,那么派生类将在其作用域内隐藏该基类成员,即使派生类成员和基类成员的形参列表不一致(名字查找先于类型检查)。

struct Base {
  int memfcn();
};
struct Derived : Base {
  int memfcn(int); // hides memfcn in the base
};

int main()
{
  Derived d;
  Base b;
  b.memfcn();       //  calls Base::memfcn
  d.memfcn(10);     //  calls Derived::memfcn

  // 编译器首先在 Derived 中查找名字 memfcn,因为有找到,所以查找过程终止
  // Derived 中的 memfcn 版本需要一个 int 实参,调用语句缺少实参,产生错误
  d.memfcn();       //  error: memfcn with no arguments is hidden

  d.Base::memfcn(); //  ok: calls Base::memfcn
}

5.4 虚函数与作用域

基类与派生类中的虚函数必须有相同的形参列表。如果基类与派生类的虚函数接受的实参不同,就无法通过基类的引用或指针调用派生类的虚函数。

class Base
{
public:
  virtual int fcn();
};
class D1 : public Base
{
public:
  // 如果派生类没有覆盖其基类中的某个虚函数,派生类会直接继承其在基类中的版本
  // D1 的 fcn 并没有覆盖 Base 的虚函数 fcn,因为它们的形参列表不同
  // 此时 D1 拥有两个名为 fcn 的函数:
  // 一个是 D1 从 Base 继承而来的虚函数 fcn
  // 一个是 D1 定义的接受一个 int 参数的非虚函数 fcn(它将隐藏 Base 的 fcn)
  int fcn(int);
  virtual void f2(); // new virtual function that does not exist in Base
};
class D2 : public D1
{
public:
  int fcn(int); // nonvirtual function hides D1::fcn(int)
  int fcn();    // overrides virtual fcn from Base
  void f2();    // overrides virtual f2 from D1
};

int main()
{
  Base bobj; D1 d1obj; D2 d2obj;

  // 通过基类的指针,调用(隐藏的)虚函数
  Base *bp1 = &bobj, *bp2 = &d1obj, *bp3 = &d2obj;
  bp1->fcn(); // virtual call, will call Base::fcn at run time  
  bp2->fcn(); // virtual call, will call Base::fcn at run time
  bp3->fcn(); // virtual call, will call D2::fcn at run time

  // 通过不同类型的指针进行调用,每个指针分别指向继承体系中的一个类型
  D1 *d1p = &d1obj; D2 *d2p = &d2obj;
  bp2->f2(); // error: 静态类型 Base has no member named f2
  d1p->f2(); // virtual call, will call D1::f2() at run time
  d2p->f2(); // virtual call, will call D2::f2() at run time

  // 对于非虚函数的调用,不会发生动态绑定,由指针的静态类型决定调用的函数版本
  Base *p1 = &d2obj; D1 *p2 = &d2obj; D2 *p3 = &d2obj;
  p1->fcn(42); // error: Base has no version of fcn that takes an int
  p2->fcn(42); // statically bound, calls D1::fcn(int)
  p3->fcn(42); // statically bound, calls D2::fcn(int)
}

5.5 覆盖重载的函数

和其他函数一样,成员函数(无论是否是虚函数)也能被重载。派生类可以覆盖重载函数的零个或多个实例。如果派生类希望,可以通过其类型使用所有重载版本,那么它就需要覆盖所有的版本,或者一个也不覆盖

有时,一个派生类需要提供所有重载版本,但又仅需覆盖重载集合中的一些而非全部函数,在这种情况下,可以为重载的成员提供一条 using 声明语句

  • using 声明语句指定一个名字而不指定形参列表,所以一条基类成员函数的 using 声明语句就可以把该函数的所有重载实例添加到派生类作用域中;
  • 派生类只需要定义其特有的函数,无须重新定义继承来的其他函数;
  • 对派生类没有重新定义的重载版本的访问,实际上是对 using 声明点的访问;
  • 类内 using 声明的一般规则同样适用于重载函数的名字,基类函数的每个实例在派生类中都必须是可访问的。
class Base
{
public:
    virtual void fcn(int i) {
        cout << "Base::fcn(int)" << endl;
    }
    virtual void fcn(string s) {
        cout << "Base::fcn(string)" << endl;
    }
};

class Derived : public Base
{
public:
    using Base::fcn; // 将基类函数的所有重载实例添加到派生类作用域中

    void fcn(int i) { // 仅覆盖 int 版本
        cout << "Derived::fcn(int)" << endl;
    }
};

void someFunc(Derived &d)
{
    d.fcn(42);    // 调用 Derived::fcn(int)
    d.fcn("abc"); // 调用 Base::fcn(string)
}

6 构造函数与拷贝控制

和其他类一样,位于继承体系中的类也需要控制其对象的创建、拷贝、移动(C++11)、赋值和销毁操作。如果一个类(基类或派生类)没有定义相应的操作,编译器会为它提供一个合成的版本,这个合成的版本也可以定义成被删除的函数。

6.1 虚析构函数

基类通常应该定义一个虚析构函数,这样当 delete 一个基类的指针时,可以根据指针实际指向对象的类型,执行对应的析构函数。

  • 如果基类的析构函数不是虚函数,那么 delete 一个指向派生类对象的基类指针,将产生未定义的行为。
  • 如果一个类定义了虚析构函数,即使它通过 =default 的形式使用了合成的版本,编译器也不会为这个类合成移动操作。
  • 三/五法则中曾提到,如果一个类需要析构函数,那么它也需要拷贝和赋值操作。基类的析构函数是一个例外,因为,如果只是为了实现动态绑定,那么基类的虚析构函数体可以为空,并不需要进行资源管理,也就不需要赋值运算符或拷贝构造函数。
class Quote
{
public:
    // 和其他虚函数一样,析构函数的虚属性也会被继承。
    // 无论 Quote 的派生类使用合成的析构函数,还是自定义的析构函数,都将是虚析构函数
    virtual ~Quote() = default; // 动态绑定析构函数
};

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

6.2 合成拷贝控制与继承

基类或派生类的合成拷贝控制成员的行为,与其他合成的构造函数、赋值运算符或析构函数类似:

  • 对类本身的成员,依次进行初始化、赋值或销毁操作;
  • 使用直接基类中对应的操作,对一个对象的直接基类部分,进行初始化、赋值或销毁操作;
  • 基类成员是合成的版本,还是自定义的版本,并没有太大影响;但是,相应的成员应该可访问,并且不是一个被删除的函数。
class Quote
{
public:
    Quote() = default;
    virtual ~Quote() = default;
    // ...
private:
    string bookNo;
protected:
    double price = 0.0;
};

class Disc_quote : public Quote
{
public:
    Disc_quote() = default;
    // ...
protected:
    size_t quantity = 0;
    double discount = 0.0;
};

class Bulk_quote : public Disc_quote
{
public:
    Bulk_quote() = default;
    // ...
};

Quote 继承体系中,对于 Bulk_quote 合成的默认构造函数,执行过程如下:

  1. 合成的 Bulk_quote 默认构造函数运行 Disc_quote 的默认构造函数,后者又运行 Quote 的默认构造函数;
  2. 执行 Quote 的默认构造函数,将 bookNo 成员默认初始化为空字符串,使用类内初始值将 price 初始化为 0
  3. 执行 Disc_quote 的默认构造函数,使用类内初始值初始化 quantitydiscount
  4. 执行 Bulk_quote 的构造函数,但它什么具体工作也不做。

对于 Bulk_quote 合成的拷贝构造函数,执行过程如下:

  1. 合成的 Bulk_quote 拷贝构造函数使用(合成的)Disc_quote 拷贝构造函数,后者又使用(合成的)Quote 拷贝构造函数;
  2. 执行 Quote 拷贝构造函数,拷贝 bookNoprice 成员;
  3. 执行 Disc_quote 拷贝构造函数,拷贝 quantitydiscount 成员;
  4. 执行 Bulk_quote 拷贝构造函数,但它什么具体工作也不做。

对于 Bulk_quote 合成的析构函数,执行顺序与构造函数相反:

  1. 执行 Bulk_quote 析构函数,它的函数体为空,没有需要销毁的成员,隐式调用其直接基类 Disc_quote 的析构函数。
  2. 执行 Disc_quote 析构函数,它的函数体也为空,也没有需要销毁的成员,隐式调用其直接基类 Quote 的析构函数。
  3. 执行 Quote 析构函数,首先执行空函数体,然后执行析构部分销毁 bookNo 成员,此时到达继承链顶端,完成 Bulk_quote 的析构工作。

关于合成的移动操作:

  • 因为 Quote 定义了虚析构函数,所以它不能拥有合成的移动操作,因此当移动 Quote 对象时,实际使用的是合成的拷贝操作;
  • 因为 Quote 没有移动操作,所以它的派生类也没有。

6.2.1 派生类中删除的拷贝控制

与其他类的情况一样,基类或派生类也能出于同样的原因,将其合成的默认构造函数或者任何一个拷贝控制成员定义成被删除的函数。复习回顾:C++(一.04)拷贝控制 => 1.6.1 使用 =delete

此外,某些定义基类的方式也可能导致有的派生类成员成为被删除的函数:

基类派生类
默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是删除的或不可访问的对应的成员将是被删除的(因为不能执行基类部分的构造、赋值或销毁操作)
析构函数是删除的或不可访问的合成的默认和拷贝构造函数将是被删除的(因为无法销毁基类部分)
移动构造函数或移动赋值运算符是删除的或不可访问的当使用 =default 请求一个移动操作时,对应的成员将是被删除的(因为基类部分不可移动)
析构函数是删除的或不可访问的移动构造函数也将是被删除的(因为无法销毁基类部分)
class B
{
public:
    B();                   // 可访问的默认构造函数
    B(const B &) = delete; // 显式删除的拷贝构造函数,所以不能拷贝 B 的对象

    // 其它成员,不含有移动构造函数
    // 因为定义了拷贝构造函数,所以编译器将不会合成一个移动构造函数,所以不能移动 B 的对象
};
class D : public B
{
    // 没有声明任何构造函数

    // 如果 B 的派生类希望它自己的对象能被移动和拷贝,那么派生类需要自定义相应版本的构造函数
    // 当然,在这一过程中派生类还必须考虑如何移动或拷贝其基类部分的成员
    // 通常,如果在基类中没有默认、拷贝或移动构造函数,派生类也不会定义相应的操作
};

D d;                // 正确:D 的合成默认构造函数使用 B 的默认构造函数
D d2(d);            // 错误:D 的合成拷贝构造函数是被删除的
D d3(std::move(d)); // 错误:隐式地使用 D 的被删除的拷贝构造函数

6.2.2 移动操作与继承

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

因为基类缺少移动操作会阻止派生类拥有自己的合成移动操作,所以,当确实需要执行移动操作时,应该首先在基类中进行定义:

// 除非 Quote 的派生类中含有排斥移动的成员,否则它将自动获得合成的移动操作
class Quote
{
public:
    Quote() = default;                         // 对成员依次进行默认初始化
    Quote(const Quote &) = default;            // 对成员依次拷贝
    Quote(Quote &&) = default;                 // 对成员依次移动
    Quote &operator=(const Quote &) = default; // 拷贝赋值
    Quote &operator=(Quote &&) = default;      // 移动赋值
    virtual ~Quote() = default;
    // ...
};

6.3 派生类的拷贝控制成员

  • 派生类构造函数,在其初始化阶段中,不但要初始化派生类自己的成员,还负责初始化派生类对象的基类部分;
  • 派生类的拷贝和移动构造函数,在拷贝和移动自有成员的同时,也要拷贝和移动基类部分的成员;
  • 派生类的拷贝和移动赋值运算符,也必须为其基类部分的成员赋值;
  • 派生类析构函数,只负责销毁派生类自己分配的资源;
    • 派生类对象的成员是被隐式销毁的;
    • 派生类对象的基类部分也是隐式销毁的。

6.3.1 派生类拷贝和移动构造函数

在为派生类定义拷贝或移动构造函数时,通常在派生类的构造函数初始值列表中,显式地使用基类的拷贝或移动构造函数,来初始化对象的基类部分。
复习回顾:C++(一.04)拷贝控制 => 1.1 拷贝构造函数、4.2.1 移动构造函数

class Base { /* ... */ };
class D : public Base
{
public:
    // 默认情况下,基类的默认构造函数初始化对象的基类部分
    // 要想使用拷贝或移动构造函数,我们必须在构造函数初始值列表中,显式地调用该构造函数

    // Base 的拷贝/移动构造函数负责将 d 的基类部分拷贝/移动给要创建的对象
    D(const D &d) : Base(d)              // 拷贝基类成员
                 /* D 的成员初始值列表 */ { /* ... */ }
    D(D &&d) : Base(std::move(d))        // 移动基类成员,变量是左值,需要使用 move
                 /* D 的成员初始值列表 */ { /* ... */ }
};

6.3.2 派生类赋值运算符

与拷贝和移动构造函数一样,派生类的赋值运算符也必须显式地为其基类部分赋值。
复习回顾:C++(一.04)拷贝控制 => 1.2 拷贝赋值运算符、4.2.2 移动赋值运算符

// Base &Base::operator=(const Base &); 不会被自动调用
D &D::operator=(const D &rhs)
{
    // 显式地调用基类赋值运算符,令其为派生类对象的基类部分赋值
    Base::operator=(rhs);

    // 按照通常的方式为派生类的成员赋值
    // 酌情处理自赋值及释放已有资源等情况

    return *this;
}

6.3.3 派生类析构函数

派生类的析构函数体执行完成后,对象的基类部分会隐式销毁。所以,析构函数只需要负责销毁派生类自己分配的资源。

class D : public Base
{
public:
    // Base::~Base 会被自动调用执行
    ~D() { /* 清除派生类成员的操作 */ }
};

对象销毁的顺序与其创建的顺序正好相反:

  • 派生类析构函数首先执行;
  • 然后是直接基类的析构函数;
  • 以此类推,沿着继承链的反方向直至顶端。

6.3.4 构造/析构函数调用虚函数

在构造函数或析构函数中调用虚函数时,虚函数运行的版本对应于构造函数或析构函数所属的类类型。

  • 在对象构建/销毁期间,它被视为正在执行的构造/析构函数所属的类类型(存在一个变化过程);
  • 对虚函数的调用,会被绑定到当前构造/析构函数所属类类型的版本;
  • 无论是直接调用,还是通过其他函数间接调用虚函数,上述绑定规则均适用。
class Base
{
public:
    Base() {
        // 虚函数绑定当前构造函数所属类类型的版本
        // 即便正在构建的是派生类的对象,在基类构造函数中,虚函数绑定的仍然是基类的版本
        virtualFunc();
    }

    virtual void virtualFunc() {
        cout << "Base::virtualFunc()" << endl;
    }
};

class Derived : public Base
{
public:
    Derived() {
        // 虚函数绑定当前构造函数所属类类型的版本
        virtualFunc();
    }

    void virtualFunc() override {
        cout << "Derived::virtualFunc()" << endl;
    }
};

int main()
{
    // 创建 Derived 对象时,
    // 首先调用 Base 类的构造函数,输出 Base::virtualFunc()
    // 然后调用 Derived 类的构造函数,输出 Derived::virtualFunc()
    Derived derived;
}

6.4 继承的构造函数(C++11)

在 C++11 标准中,派生类能够重用其直接基类定义的构造函数,类似“继承”

  • 一个类只能继承它的直接基类的构造函数,就像只初始化其直接基类一样;
  • 通过一条标识直接基类名的 using 声明语句,实现对基类构造函数的继承:
    • 用于构造函数时,using 声明语句会令编译器产生代码;(通常情况下,using 声明语句只是使某个名字在当前作用域内可见)
    • 对于基类的每个构造函数,编译器都会生成一个与之对应的(形参列表完全相同的)派生类构造函数;
    • 如果派生类含有自己的数据成员,这些成员会被默认初始化。
class Bulk_quote : public Disc_quote
{
public:
    using Disc_quote::Disc_quote; // 继承 Disc_quote 的构造函数
    double net_price(size_t) const;
};

// 编译器生成的构造函数形式如下:
// derived 是派生类的名字,parms 是构造函数的形参列表
// base 是基类的名字,args 将派生类构造函数的形参传递给基类的构造函数
derived(parms) : base(args) {}

// 在 Bulk_quote 类中,继承的构造函数等价于
Bulk_quote(const string &book, double price, size_t qty, double disc)
    : Disc_quote(book, price, qty, disc) {}

继承的构造函数的特点:

  • 构造函数的 using 声明不会改变该构造函数的访问级别;
  • 一个 using 声明语句不能指定 explicitconstexpr
    • 如果基类的构造函数是 explicitconstexpr,那么继承的构造函数拥有相同的属性。
  • 当一个基类构造函数含有默认实参时,这些实参并不会被继承,派生类将获得多个继承的构造函数;
    • 每个构造函数分别去掉一个含有默认实参的形参。
  • 如果基类含有多个构造函数,通常派生类会继承每个构造函数,除了两个例外情况:
    • 例外1,派生类可以继承一部分构造函数,而为其他构造函数定义自己的版本;
      • 如果派生类定义的构造函数与基类构造函数具有相同的形参列表,那么该构造函数将不会被继承。
    • 例外2,默认、拷贝和移动构造函数不会被继承:
      • 如果没有直接定义,编译器将会按照通常规则提供合成的版本;
      • 继承的构造函数不会被视为用户定义的构造函数,如果一个类只含有继承的构造函数,那么它也将拥有合成的默认构造函数。

参考

  1. [美] Stanley B.Lippman著.C++ Primer 中文版(第5版).电子工业出版社.2013.
  2. [美] Stephen Prata著.C++ Primer Plus(第6版)中文版.人民邮电出版社.2012.

宁静以致远,感谢 Vico 老师。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值