What & How & Why

面向对象程序设计

C++ Primer 笔记 第十五章


面向对象编程的核心点有三个:

  • 数据抽象Data abstraction
  • 继承Inheritance
  • 动态绑定Dynamic binding

本章主要会介绍继承与动态绑定。这两个特性会让定义功能类似的类变得更简单,也会让用户在使用这些类时不用考虑一些细小的区别。

面向对象总览

继承

当类中存在着继承关系时,这些类实际上处于层级关系。这种层级关系通常:

  • 根部有一个基类Base class),用于定义层级关系中所有类的共同成员
  • 派生类Derived class),用于定义自身特有的成员
虚函数概述

在基类中,函数被分为两类:

  • 依赖类型的函数,也就是需要在派生类中重定义的函数
  • 基类中已经定义,希望被派生类继承,但不希望被派生类修改的函数

对于第一类函数,C++ 允许我们在不改变该函数名的同时,在派生类中对其进行重新定义。这一类的函数被称为虚函数Virtual functions)。

Quote 类实例

假设我们希望建立一个类 Quote,用于表示书籍的贩卖信息。Quote 的成员有:

  • isbn(),用获取书的 ISBN
  • net_price(),根据书的购买数量决定价格

同时,我们希望指定不同的售卖书籍的策略。这里使用 Quote 的派生类 Bulk_quote 表示批发书籍的贩售信息策略。根据上述信息:

  • isbn() 成员并不依赖于特定的类,因此只需在基类 Quote 中定义
  • net_price(size_t) 成员需要根据不同的策略实现不同的版本,因此需要声明为虚函数

因此,基类 Quote 的声明如下:

class Quote {
public:
	std::string isbn() const;
	virtual double net_price(std::size_t n) const;
};

派生类的定义

派生类的定义中必须指明该类继承自哪些基类。通常我们通过 : 加类名列表的组合方式来表达该信息。这样的列表被称为类派生列表(Class derivation list)。上例中,Bulk_quote 的定义可以写作:

class Bulk_quote : public Quote { // Bulk_quote inherits from Quote
public:
    double net_price(std::size_t) const override;
};
需要注意的几个点:

  • 列表中的 public,其意义在于允许我们将 Bulk_quote 类型的对象像 Quote 类型的对象一样使用。
  • 派生类中必须声明所有需要重新定义的虚函数。这些函数可以不加 virtual 关键字。
  • C++11 中允许显式的使用 override 关键字标记派生类中的虚函数(需要重写基类的函数)

动态绑定

动态绑定允许用户使用同样的代码处理不同的对象。比如我们可以使用 print_total 函数处理 QuoteBulk_quote

// calculate and print the price for the given number of copies, applying any discounts
double print_total(ostream &os,
                   const Quote &item, size_t n)
{
    // depending on the type of the object bound to the item parameter
    // calls either Quote::net_price or Bulk_quote::net_price
    double ret = item.net_price(n);
    os << "ISBN: " << item.isbn() // calls Quote::isbn
       << " # sold: " << n << " total due: " << ret << endl;
     return ret;
}
几个需要注意的地方:

  • item 参数接收的是 Qoute引用;这样做可以不但能传递 Quote 对象,也能传递 Bulk_quote 对象
  • net_pirce() 为虚函数。由于调用对象的类型为引用,因此 net_price() 的具体执行版本与调用对象的类型有关。

调用的结果如下:

// basic has type Quote; bulk has type Bulk_quote
print_total(cout, basic, 20); //  calls Quote version of net_price
print_total(cout, bulk, 20);  //  calls Bulk_quote version of net_price
上述的调用过程中,选择 net_pirce() 的决定需要得知对应的对象才能完成。由于对象创建于运行期,因此该选择(绑定)也只能在运行期才能确定。因此,这种选择的操作被称为动态绑定,也被称为运行时绑定run-time binding)。

C++ 中,当使用指向基类的引用或者指针调用虚函数时,会发生动态绑定。

定义基类和派生类

定义基类

接着 Quote 的例子。Quote 的定义如下:

class Quote {
public:
    Quote() = default;  // = default see § 7.1.4 (p. 264)
    Quote(const std::string &book, double sales_price):
                     bookNo(book), price(sales_price) { }
    std::string isbn() const { return bookNo; }
    // returns the total sales price for the specified number of items
    // derived classes will override and apply different discount algorithms
    virtual double net_price(std::size_t n) const
               { return n * price; }
    virtual ~Quote() = default; // dynamic binding for the destructor
private:
    std::string bookNo; // ISBN number of this item
protected:
    double price = 0.0; // normal, undiscounted price
};
该实现中出现了三个新的知识点:

  • virtual 关键字的使用
  • 析构函数也被定义为了 virual(之后会提到)
  • protected 成员

基类需要定义虚析构函数,即便该析构函数不会投入使用。

继承中需要被重新定义的成员

派生类往往需要对某些成员进行重新定义。比如 Quote 实例中的 net_pirce,该成员根据书籍购买的数量会得出不同的结果,是一个依赖类型的成员。对于这样的成员,派生类需要通过对其进行重写override),来实现该成员在派生类中的定义。这样的成员具有如下的特点:

  • 在基类中被定义为虚函数
  • 通过间接访问的方式(指针,引用)调用该函数可以实现动态绑定
  • 虚函数可以是除了构造函数以外的任何非静态成员

定义该类函数时,只需要在基类的声明前面加上 virtual 关键字即可。派生类中,该成员不要求被关键字 virtual;而该成员的类外定义中不能加上 virtual



继承中被直接继承的成员

成员被直接继承意味着派生类对其的要求与基类相同。这种成员的特点是:

  • 不会被声明为虚函数
  • 解析会发生在编译期
  • 无论是基类还是派生类,调用该类成员都会产生相同的逻辑行为,比如例子中的 isbn(),无论是 Quote 对象与 Bulk_quote 对象,调用该函数均会返回书的 ISBN。
继承中的访问控制

根据基类成员中的访问说明符:

  • public:这类基类成员可以被派生类访问,也可以被类外的其他用户访问
  • private:只能被基类的其他成员访问
  • protected:可以被基类与派生类的成员访问,但无法被类外的其他用户访问

可以看出在 Quote 的实现中,存在重写的函数(虚函数)往往会用到 protected 的成员;而直接继承的成员往往只会用到 private 的成员。

定义派生类

派生类的定义有几个特点:

  • 派生类需要指明继承自哪些类;该过程通过派生列表(derived list)实现
  • 需要重写的成员(虚函数)必须在派生类中进行声明
  • C++11 中,被重写的函数可以通过 override 关键字显式的标记

来看一下 Bulk_quote 的实现:

class Bulk_quote : public Quote { // Bulk_quote inherits from Quote
    Bulk_quote() = default;
    Bulk_quote(const std::string&, double, std::size_t, double);
    // overrides the base version in order to implement the bulk purchase discount policy
    double net_price(std::size_t) const override;
private:
    std::size_t min_qty = 0; // minimum purchase for the discount to apply
    double discount = 0.0;   // fractional discount to apply
};

如果虚函数没有在派生类中进行重写,那么派生类会直接继承基类中该函数的定义

派生列表中的访问说明符

派生列表中的说明符也分为三种:publicprotectedprivate。总的来说,该说明符决定了基类中的成员是否对派生类成员可见。当派生是公有的时候:

  • 基类中的公有成员会成为派生类接口的一部分
  • 该派生类类型的指针和引用可以绑定到基类对象上

以上面的实现举例,由于 Bulk_quote 的派生列表是公有类型的,因此 isbn() 成员则成为了 Bulk_quote 接口的一部分,而我们也可以使用 Bulk_quote 对象作为 Quote&Quote* 参数的 argument。

Bulk_quote 这样只继承了一个基类的情况被称为单继承(single inheritance)。

派生类对象与派生类到基类的转换

实际上,一个派生类的对象可以大致被分为两个部分:

  • 自定义部分:该部分是一个子对象,包含了所有的,需要在派生类中重写非静态成员
  • 继承部分:该部分也是一个子对象,包含了所有继承自基类的成员

比如 Bulk_quote 的对象,实际上的组成是下面这样的:



因为派生类对象包含了对应的基类成员,因此我们可以像使用基类对象一样使用派生类对象。具体的来说,我们可以将基类的引用或指针绑定到派生类对象中的基类部分上:
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
这种转换通常被称为派生类到基类的转换derived-tobase conversion)。这类转换是被隐式的执行的。这也意味着,当需要使用基类的引用或指针时,我们可以使用派生类对象的引用或指针来代替。

派生类的子对象在内存中并不一定是连续存储的

派生类的构造函数

尽管派生类对象包含了继承自基类的成员,但这些成员并不能被派生类直接初始化,而是需要使用基类的构造函数进行初始化。派生类中的成员初始化顺序如下:

  1. 派生类将需要的参数传递给基类构造函数
  2. 基类构造函数初始化派生类对象的基类部分成员
  3. 派生类构造函数按声明的顺序初始化派生类对象自身的成员

Bulk_quote 为例,其构造函数实现如下:

Bulk_quote(const std::string& book, double p,
           std::size_t qty, double disc) :
           Quote(book, p), min_qty(qty), discount(disc) { }
    // as before
};
Quote 的构造函数执行完毕时(初始化列表以及函数体执行完毕以后),Bulk_quote 才会对 min_qtydiscount 成员进行初始化;最后执行的是 Bulk_quote 构造函数的函数体。

派生类中调用基类的构造函数与正常的调用一致:如果想调用指定的构造函数版本,则需要提供对应的参数;如果不提供参数,则基类构造函数会进行默认初始化

成员的初始化由其对应的类控制。

在派生类中使用基类的成员

基类成员可以以 protected 的方式被派生类使用:

// if the specified number of items are purchased, use the discounted price
double Bulk_quote::net_price(size_t cnt) const
{
    if (cnt >= min_qty)
        return cnt * (1 - discount) * price;
    else
        return cnt * price;
}
该实现需要访问基类中的 price 成员,因此 price 需要被声明为 protected

值得一提的是,派生类的作用域是嵌套在基类作用域中的。因此,派生类使用成员的方式与基类是一致的。

关键概念:遵循基类的接口

需要明确的是,每个类都定义了自己的接口;因此即便是基类与派生类,类与类之间的交互应该通过接口来实现。这也是为什么我们需要使用基类的构造函数来初始化派生类中的基类部分的原因。

继承与静态成员

如果基类中包含了静态成员,那么该成员在整个继承体系(层级)中有且只有唯一的一个实例

class Base {
public:
    static void statmem();
};
class Derived : public Base {
    void f(const Derived&);
};
静态成员遵循一般的访问控制:
void Derived::f(const Derived &derived_obj)
{
    Base::statmem();    // ok: Base defines statmem
    Derived::statmem(); // ok: Derived inherits statmem
    // ok: derived objects can be used to access static from base
    derived_obj.statmem(); // accessed through a Derived object
    statmem();             // accessed through this object
}

派生类的声明

与定义不同,派生类的声明不包括派生列表派生列表必须与派生类的定义同时出现

class Bulk_quote : public Quote; // error: derivation list can't appear here
class Bulk_quote;                // ok: right way to declare a derived class

使用基类

基类在被派生类使用之前,必须拥有完整定义

class Quote;   // declared but not defined
// error: Quote must be defined
class Bulk_quote : public Quote { ... };
这是因为派生类中包含了基类部分。如果希望使用这个部分中的成员,必须要有成员的定义。这也暗示了基类的派生类不能是自己

直接基类与间接基类
  • 直接基类direct base):自己是基类,且不是其他基类的派生类
  • 间接基类indirect base):自己是基类,同时也是别的基类的派生类

class Base { /* ... */ } ;
class D1: public Base { /* ... */ };
class D2: public D1 { /* ... */ };
如果一个派生类继承自间接基类,那么该类的基类部分将继承层级中之前所有间接基类基类部分

阻止继承

C++ 11 中通过在类声明后面添加 final 关键字,阻止该类被其他类继承:

class NoDerived final { /*  */ }; // NoDerived can't be a base class
class Base { /*  */ };
// Last is final; we cannot inherit from Last
class Last final : Base { /*  */ }; // Last can't be a base class
class Bad : NoDerived { /*  */ };   // error: NoDerived is final
class Bad2 : Last { /*  */ };       // error: Last is final

类型转换与继承

之前提到过,基类的引用 / 指针可以绑定 / 指向其派生类对象:

double print_total(ostream &os, const Quote &item, size_t n) {....};
Bulk_quote bulk;   //  object of derived type
Quote &r = bulk;   //  r bound to the Quote part of bulk
Quote *p = &bulk; // p points to the Quote part of bulk
当使用基类的引用或者指针类型的对象时,我们不能确定该对象绑定的实际类型;该类型可能是基类对象,也可能是派生类对象。因此,使用此类型对象时,需要考虑两种类型:静态类型static type)与动态类型dynamic type)的区别。

智能指针也支持派生类到基类的转换。

静态类型与动态类型

静态类型与动态类型存在如下的区别:

  • 静态类型指变量声明时定义的类型,或是表达式得出的结果的类型。这类类型在编译期就能确认
  • 动态类型指变量或者表达式在内存中表示的实际类型,这类类型往往需要在运行期才能确认

print_total 函数为例,其接受一个 Quote& 类型的参数,因此该参数的静态类型是 Quote&。当我们提供一个 Bulk_quote 对象给该参数时,该参数的动态类型就是 Bulk_quote

需要注意的是,如果表达式的类型(接受参数的类型)不是指针或者引用类型,那么其静态类型与动态类型是致的。

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

之所以存在派生类到基类的转换,是因为派生类中的存在基类部分,且该部分可以被基类的指针或者引用绑定。而基类对象不一定是派生类的一部分(绝大部分情况下,派生类都会定义基类中没有的成员)。因此,从基类到派生类的转换是不允许的,因为该转换很可能导致访问基类中不存在的成员

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 将类型检查放到运行期执行
  • 如果已知基类到派生类的类型转换是安全的时候,可以使用 static_cast 覆盖编译器的检查工作
不存在基类对象与派生类对象之间的隐式转换

派生类到基类的隐式转换只能应用于引用 / 指针类型上:

  • 不存在基类到派生类的对象转换
  • 派生类的对象可能转换为基类对象,但结果可能不满足预期

由于派生类到基类的转换应用于引用类型,因此拷贝 / 移动 / 赋值操作都可以使用派生类的对象作为参数。但需要注意的是,当传递派生类对象到这些操作中时,会调用基类的拷贝构造/移动构造/赋值函数。因此,只有基类部分会被 拷贝 / 移动 / 赋值,而派生类部分将被忽略(Recall 一下,类只负责对自身成员的初始化)。这种情况下,我们称派生类部分被切掉Sliced down)了:

Bulk_quote bulk;   // object of derived type
Quote item(bulk);  // uses the Quote::Quote(const Quote&) constructor
item = bulk;       // calls Quote::operator=(const Quote&)
上面的例子中,bulk 中属于派生类部分的成员会在 item 初始化 / 赋值的过程中被忽略。

几个关键的概念:

  • 派生类到基类的隐式转换只能应用于指针 / 引用类型
  • 不存在基类到派生类的隐式转换
  • 派生类到基类的隐式转换可能会受访问控制的影响
  • 可以将派生类对象作为基类的对象的拷贝控制成员的参数,但这类操作只会对派生类对象中的基类部分进行处理。

虚函数

当某个继承层级中定义了一个虚函数,该层级中的所有同名函数都被隐式的定义为虚函数。由于虚函数的调用直到运行期才可以确定具体的版本,因此层级中所有的虚函数都必须被定义,无论该函数是否会被调用。

虚函数的解析时机

虚函数的解析时机取决于调用虚函数的方式。如果通过引用或者指针的方式调用某个虚函数,那么该虚函数将直到运行期才能确定被调用的具体版本。也就是说,被调用的版本取决于调用者的动态类型。比如之前的 print_total 例子:

Quote base("0-201-82470-1", 50);
print_total(cout, base, 10);    // calls Quote::net_price
Bulk_quote derived("0-201-82470-1", 50, 5, .19);
print_total(cout, derived, 10); // calls Bulk_quote::net_price
如果调用的方式是通过非引用或者指针的方式,那么调用的虚函数版本有调用者的静态类型决定
base = derived;         // copies the Quote part of derived into base
base.net_price(20);     // calls Quote::net_price
上例中,对 net_price() 的调用在编译期就已经确定了。

关键概念:C++ 中的多态

面向对象编程的关键概念是多态polymorphism)。我们将具有继承关系的多种类型称为多态类型,因为通过使用多态类型,我们可以使用该类型的多种形式,而无需在意其差异。

当使用引用或者指针调用基类中的函数时,调用对象的类型是不确定的。如果被调用的函数时虚函数,那么该函数的版本需要等到运行期决定,因为只有到了运行期才能决定引用或指针绑定对象的真正类型。

另外,以下两种调用都会在编译期绑定:

  • 对非虚函数的调用
  • 通过对象本身调用任何函数(无论是不是虚函数)

这种情况下,对象的静态类型与动态类型不做区分,也无法区分。

只有在使用引用或者指针调用虚函数的时候,才会在运行期解析该调用。也只有在这种情况下,调用者的静态类型与动态类型才会不同。

派生类中的虚函数

派生类中的虚函数有两个重要的特点:

  • 当基类中某个函数被声明为虚函数时,其所有派生类中的该函数都是虚函数。因此,派生类中虚函数不需要显式的使用 virtual 关键字。
  • 定义派生类中的虚函数时,其参数列表返回值需要完全与基类中的被覆盖版本完全一致

“返回值与基类中版本一致”这个论断有一个例外。当虚函数的返回类型是调用者自身的引用或是指针时,该规则无效。比如,如果 D 继承自 B,则基类的虚函数会返回 B*,而派生类的函数会返回 D*
该例外受继承访问控制的影响(需要可访问)。

final 和 override 说明符

C++ 中允许对派生类中的虚函数赋予与基类同名函数不同的参数列表。但在这种情况下,编译器会将其视作不同的函数,而非派生类对基类的重写。实践中,这样的写法通常是错误的;这可能是因为作者原本希望重写,但弄错了参数列表。

C++ 11 中提供了关键字 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
};
C++ 11 中还提供了另外一个关键字 final。被 final 修饰的函数无法被之后的派生类再次重写
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
};

虚函数与默认参数

虚函数也可以拥有默认参数。当虚函数被调用时,如果需要使用默认参数,则默认参数的值以调用者的静态类型中的默认值为准。举例来说,如果基类中与派生类中的虚函数拥有不同值的默认参数,当使用基类的引用或指针调用该虚函数时,即便我们传递的是派生类的对象,最后该虚函数也会使用基类中的默认参数值。

如果虚函数使用默认参数,应该基类和派生类中默认参数一致。

回避虚函数的机制

某些情况下,我们不希望使用虚函数的机制来调用虚函数,而是希望调用某个类中指定的版本,此时需要通过指定该版本的作用域来调用指定的虚函数版本。比如强制调用 Quote 类中 net_price()

//  calls the version from the base class regardless of the dynamic type of baseP
double undiscounted = baseP->Quote::net_price(42);
这样调用的话,无论 baseP 绑定了哪种类型,最终都会调用 Quotenet_price() 函数。此类调用会在编译期完成绑定。

当使用派生类虚函数调用其基类版本时,省略作用域运算符将导致该虚函数调用其自身,从而导致无限递归。

抽象基类

之前的 Quote 例子中,几个派生类中的价格策略各有不同,但其策略的实现(net_price())都围绕着两个成员 quantitydiscount 来实现的。针对这种情况,我们可以提供一个类 Disc_quote 用于存储这两个成员,而其他的策略类则作为该类的派生类来使用这些成员。

但有一个问题是, Disc_quote 只用于存储成员。比起具体的价格策略,该类更像是体现书籍概念的类。因此, Disc_quote 类中不应该涉及 net_price() 的实现。

一个办法是不在 Disc_quote 中定义 net_price()。但这样做问题在于,由于 Disc_quote 继承自 Quote,当使用 Disc_quote 的对象作为调用者时,会调用 Quote 版本的 net_price()。这将导致结果不会带有任何折扣。

纯虚函数

实际上,像 Disc_quote 这样单纯的表示概念的类,是不应该实体化的。也就是说:

  • 某些没有任何意义的虚函数不应该在这种类型的类中实现
  • 这种类型的类不应该实体化,也就是不应该拥有对象

C++ 中通过将对应的虚函数定义为纯虚函数Pure Virtual Functions)来阻止用户对这样的类进行实体化。纯虚函数通过在函数添加 =0 实现。纯虚函数拥有下面的特点:

  • 与虚函数不同,纯虚函数不需要定义(如果要定义只能在类外)
  • =0 只需要出现在虚函数声明的地方

下面是 Disc_quote 的设计:

// class to hold the discount rate and quantity
// derived classes will implement pricing strategies using these data
class Disc_quote : public Quote {
public:
    Disc_quote() = default;
    Disc_quote(const std::string& book, double price,
              std::size_t qty, double disc):
                 Quote(book, price),
                 quantity(qty), discount(disc) { }
    double net_price(std::size_t) const = 0;
protected:
    std::size_t quantity = 0; //  purchase size for the discount to apply
    double discount = 0.0;    //  fractional discount to apply
};
需要注意的是, Disc_quote 类中依然保留了构造函数。即便 Disc_quote 本身无法实例化,其构造函数依然可以被用于之后的派生类。

抽象基类

Disc_quote 这样定义了纯虚函数的类,我们将其称为抽象基类Abstract base class)。抽象基类用于为之后的派生类定义需要被重写的接口。继承自抽象基类的派生类必须对纯虚函数进行重写,否则也会被视作是为抽象类。抽象类不能被实例化

// Disc_quote declares pure virtual functions, which Bulk_quote will override
Disc_quote discounted; // error: can't define a Disc_quote object
Bulk_quote bulk;       // ok: Bulk_quote has no pure virtual functions

关键概念:重构

向层级中添加 Disc_quote 属于重构Refactoring)的一个例子。重构意味着重新设计层级的关系,是一种面向对象中常见的应用。本例中,重构并不会对使用 Bulk_quoteQuote 内容的代码造成影响。

派生类的构造函数只会初始化直接基类

接着之前的例子。定义了 Disc_quote 之后,我们就可以将 Bulk_quote 作为其派生类,使用其内部成员了:

// the discount kicks in when a specified number of copies of the same book are sold
// the discount is expressed as a fraction to use to reduce the normal price
class Bulk_quote : public Disc_quote {
public:
    Bulk_quote() = default;
    Bulk_quote(const std::string& book, double price,
              std::size_t qty, double disc):
          Disc_quote(book, price, qty, disc) { }
    // overrides the base version to implement the bulk purchase discount policy
    double net_price(std::size_t) const override;
};
需要注意的是,派生类中的构造函数只会对直接基类的基类部分初始化,比如上面的
Bulk_quote(const std::string& book, double price,std::size_t qty, double disc):
          Disc_quote(book, price, qty, disc) { }
实际上进行了三个部分的初始化:

  • Bulk_quote 的构造函数初始化了一个空的 Bulk_quote 部分,并传递参数给 Disc_quote
  • Disc_quote 的构造函数初始化了 quantitydiscount,并将其余两个参数传递给 Quote
  • Quote 的构造函数负责初始化余下的部分

可以看出来的是,每个类控制着自身部分的初始化。而派生类传递的参数只能传递给直接基类;如果是间接基类,需要由派生类的直接基类再进行一次传递。

访问控制和继承

在类继承的同时,被继承的类还可以指定派生类对自身成员的访问方式。

protected 成员

protected 的成员表达的意愿是,希望与自身的派生类分享,但不希望接受一般的调用。被 protected 修饰的成员有如下的几个特性:

  • 当前类的使用者无法访问 protected 的成员
  • 当前类的实现者(类成员,友元函数)可以访问 protected 的成员
  • 派生类的实现者(类成员,友元函数)只能通过派生类对象 访问基类中的 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
};
// 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; }
假设上述的程序中可以通过基类的对象来访问基类中的受保护成员,那么:
void clobber(Base &b) { b.prot_mem = 0; }
这个版本的 clobber 将可以修改基类中的 protected 成员。也就是说,类的使用者可以修改 protected 成员。该结果显然与 protected 的设计理念不符。因此,派生类的实现者只能通过派生类的对象去访问 protected 成员,换句话说,只能访问派生类中内嵌的基类部分中的 protected 成员。

三种继承方式

继承的成员是否能访问取决于以下两个因素的组合:

  • 成员本身是否可以访问
  • 派生列表是否允许改成员被访问

派生列表用于指定派生类的使用者 对基类成员的访问方式。继承的访问控制(方式)有三种:

  • public 公有继承:继承的成员维持其可访问性不变
  • private 私有继承:继承的所有成员的访问性都将变为 private
  • protected 保护继承:继承的公有成员的访问性将转变为 protected

派生类的访问控制符不影响派生类成员(友元)对基类成员的访问。基类成员是否能被派生类成员访问只取决于该基类成员在基类中的权限。比如下面的例子:

  • 无论以公有或私有的方式继承 Base 中的 priv_mem 成员,该成员都无法被派生类访问
  • pub_mem 无法通过私有继承的派生类对象访问,因为私有继承将其的访问权限从公有改为了私有

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; }
};
Pub_Derv d1;   //  members inherited from Base are public
Priv_Derv d2;  //  members inherited from Base are private
d1.pub_mem();  //  ok: pub_mem is public in the derived class
d2.pub_mem();  //  error: pub_mem is private in the derived class
需要注意的是,继承列表的访问控制也会影响派生类的派生类,比如以 private 方式继承自基类的派生类,即便其自身的派生类以 public 的方式继承,也无法访问基类中的 protected 成员:
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; }
};

派生类到基类的转换与访问性

派生类到基类的转换是否可以执行,取决于两点:

  • 使用该转换的代码
  • 派生列表的继承方式

假设 D 继承自 B

  • D 类的使用者只有在 D 公有的继承自 B 的时候,才能进行派生类到基类的转换
  • D 类的实现者(类成员 / 友元)可以进行派生类到基类的转换,无需考虑继承的方式
  • D 类的派生类实现者 只有在继承方式是公有受保护的前提下,才能进行派生类到基类的转换

如果我们可以访问基类中的公有成员,那么我们就可以进行派生类到基类的转换。

受保护的成员与类的设计

当不考虑的继承的时候,类的用户被分为两种:

  • 使用者:这种用户通过接口类的公有成员)访问类
  • 实现者(类成员 / 友元):这种用户需要既可以访问类的公有成员,也可以访问类的私有成员

当考虑有继承情况的时候,类的用户需要被分为三种:

  • 使用者:该类用户同样通过接口(公有成员)访问类
  • 基类的实现者:该类用户要求可以访问基类中的所有成员
  • 派生类实现者:该类用户要求可以访问派生类自身的所有成员,以及基类中提供给派生类专用的成员(protected 成员)

鉴于这样的分类,基类的设计应该作如下分割:

  • 接口部分:定义为公有成员,供使用者使用
  • 实现部分:
    • 派生类使用的部分:定义为受保护成员
    • 供基类使用的部分:定义为私有成员

友元与继承

友元关系是无法继承的。友元关系与所在的类绑定,因此通过派生类的友元访问基类成员,或是通过基类的友元访问派生类的成员都是不可行的。来看下面一个例子:

class Base {
    // added friend declaration; other members as before
    friend class Pal; // Pal has no access to classes derived from Base
};
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
    // access to a base class is controlled by the base class, even inside a derived object
    int f3(Sneaky s) { return s.prot_mem; } // ok: Pal is a friend
};
需要注意的是,我们可以通过基类的友元函数访问派生类中基类部分的成员。注意这里的 f3 成员,尽管 Pal 是基类 Base 的成员,但其依然可以访问基类中的成员 prot_mem。严格意义上来说,这里的 prot_mem 属于派生类 Sneaky 中的基类部分。

同时,友元关系只对被声明的友元类有效,对其派生类无效:
// 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
};

“豁免”单个成员

如果希望对某个指定的成员进行访问级别的变更,我们可以使用 using 声明:

class Base {
public:
    std::size_t size() const { return n; }
protected:
    std::size_t n;
};
class Derived : private Base {    //  note: private inheritance
public:
    // maintain access levels for members related to the size of the object
    using Base::size;
protected:
    using Base::n;
};
上例中,由于 Derived 通过私有的方式进行继承,因此默认情况下,继承的 sizen 两个成员都转变为了派生类的私有成员。当使用 using 指定这两个成员的位置时(此处为 Base 类),这两个成员在派生类中的访问控制将被基类中原有的访问控制覆盖。此时,派生类中的 size 是公有的,而 n 是受保护的。

可见的是,被 using 修饰的成员,其可访问性由其声明名字所在的访问说明符决定。 比如 Base 中的 size 被声明为 public 的成员,通过 using 声明,则可以被所有的用户使用。

using 声明应该只提供给那些被允许访问的成员。

默认继承的保护级别

由于类可以定义为 classstruct 两种类型,当类成员没有被显示的访问说明符修饰时,根据类型的不同,默认的继承方式也不同:

  • class 中,默认的继承方式是私有
  • struct 中,默认的继承方式是公有

class Base { /* ...   */ };
struct D1 : Base { /* ...   */ };   // public inheritance by default
class D2 : Base { /* ...   */ };    // private inheritance by default

需要进行私有继承的成员应该显式的提供私有关键字,而不是使用默认的方式来继承。这样做可以令私有关系明确。

要点总结

成员的访问控制
  • 成员的访问控制由当前类决定。
  • 成员的访问控制可以被继承方式改变
派生类到基类的转换
  • 是否可以转换取决于基类中的公有成员是否可访问
    • 使用者需要公有继承这些成员保证访问性
    • 派生类实现者没有限制,因为基类部分对于派生类实现者是没有访问限制的
    • 派生类的子派生类实现者需要通过公有 / 保护继承的方式来保证访问性
protected 相关
  • protected 的成员是给派生类的对象使用的
  • 派生类成员只能通过对象的方式访问 Protected 成员
友元的不可传递性
  • 基类的友元只能访问对应的基类部分数据(基类成员,以及派生类中的基类部分成员)
  • 友元的派生类无法继承友元的访问权限

继承中的类作用域

在继承中,派生类的作用域嵌套在基类的作用域中。如果名字在派生类中不能解析,编译器会到其直接基类中查找改名字。这种特性使得我们可以用派生类对象调用基类中的成员,比如之前 Qoute 的例子中:

Bulk_quote bulk;
cout << bulk.isbn();
isbn() 成员在派生类 Bulk_quote 中并不存在。此时编译器跳转到其直接基类 Quote 中 找到了该成员,因此 isbn() 调用被成功解析。

名字查找发生在编译期

对象(或者引用,指针)的静态类型决定了该对象的哪些成员是可见的。也就是说,无论对象的动态类型最终是什么,也不会影响某个成员的可见性。一个常见的例子是,使用基类访问派生类中的成员。此时有两种方式访问:

  • 通过基类对象访问,也就是基类的静态类型与动态类型相同
  • 通过基类的指针或者引用访问,也就是基类的静态类型与动态类型可能不相同

但无论哪种方式,都无法访问派生类中的成员。比如下面的例子:

class Disc_quote : public Quote {
public:
    std::pair<size_t, double> discount_policy() const
        { return {quantity, discount}; }
    // other members as before
};

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->discount_policy();  //  error: itemP has type Quote*
上面的例子中, discount_policy() 成员只对派生类的对象(引用,指针)可见;即便基类的动态类型是派生类 Bulk_quote,也无法访问该派生类中的成员。这是由于 itemP 的类型是 Quote*,其名字查找的作用域从 Quote 开始,而并不包括子域 Bulk_quote。换句话说:

  • 名字的查找发生在编译期,因此只能由静态类型决定
  • 名字的查找是逐级往上的。由于派生类的作用域嵌套在基类中,因此派生类的元素对基类并不可见。当使用基类对象调用成员时,名字搜寻的范围只有基类(以及其上级),而不包括派生类。

命名冲突与继承

由于派生类的作用域属于嵌套作用域,因此派生类中定义的,任意与基类中相同的名字,都会隐藏基类中的定义:

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
protected:
    int mem;   // hides mem in the base
};

Derived d(42);
cout << d.get_mem() << endl;       // prints 42
与一般情况类似,如果想使用基类中的同名成员,可以通过指定作用域来实现:
struct Derived : Base {
    int get_base_mem() { return Base::mem; }
    // ...
};

派生类中的重名成员会隐藏基类中的成员。因此,不推荐在派生类中使用基类中已存在的名字定义成员。

关键概念:名字查找与继承

名字查找在存在继承关系的类中会有如下的查找顺序: 假设有如下调用:

p->mem();

  1. 首先,编译器会确定 p 的静态类型(显然对应 p 的静态类型应该是类类型)
  2. 其次,查看 mem 所在类的静态类型:
    1. 如果 mem 没有找到,持续向上(基类中)查找,直到找到或者到达最上层的基类为止
    2. 如果 mem 还是没有找到,编译器报错
  3. 当找到 mem 以后,进行类型检查,查看该调用是否合法。如果合法,则编译器生成代码。此时:
    1. 如果 mem 是虚函数,且调用者的类型是引用或是指针,则编译器生成的代码用于确定调用者的动态类型
    2. 否则,按一般的非虚函数调用处理
名字查找在类型检查之前

与一般名字查找规则相同:

  • 派生类中定义的函数不会重载基类中的同名函数
  • 基类中的函数会被派生类中的同名函数隐藏,无论两个函数是否具有相同的参数列表

这是因为编译器会首先查找名字。假设派生类中存在同名函数,当编译器找到该函数时,名字查找也就结束了。之后,编译器才会进行类型的匹配。比如下面的例子中,被调用的 memfcn() 版本属于派生类。当调用 d.memfcn() 时,首先查找派生类中 memfcn() 的定义。由于编译器在当前作用域已经找到找到 memfcn,搜寻停止。此时,由于该调用参数不匹配,编译器报错:

struct Base {
    int memfcn();
};
struct Derived : Base {
    int memfcn(int);   // hides memfcn in the base
};
Derived d; Base b;
b.memfcn();       //  calls Base::memfcn
d.memfcn(10);     //  calls Derived::memfcn
d.memfcn();       //  error: memfcn with no arguments is hidden
d.Base::memfcn(); //  ok: calls Base::memfcn

虚函数与作用域

可见,虚函数为什么需要保证一致的参数列表。如果参数列表不一致,则重写不会发生;取而代之的是,派生类会隐藏基类中的同名函数。在没有重写的情况下,我们无法通过基类指针或应用的方式调用派生类中的重写版本:

class Base {
public:
    virtual int fcn();
};
class D1 : public Base {
public:
    // hides fcn in the base; this fcn is not virtual
    // D1 inherits the definition of Base::fcn()
    int fcn(int);      // parameter list differs from fcn in Base
    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
};
上例中,D1 中的 fcn(int) 由于参数列表与基类不配,因此没有进行重写。D1::fcn(int) 被视作 D1 自己定义的成员函数。在 D1 中存在两个 fcn 的函数:

  • fcn() 继承自基类
  • fcn(int) 为派生类自身的成员函数。

如果对上述的类进行如下的调用:

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
当使用基类的指针对 fcn() 进行调用时,由于 fcn() 是基类中定义的虚函数,因此 bp1, bp2, bp3 都是虚调用。具体的来说:

  • Base 对象调用的版本是 Base::fcn()
  • D1 对象调用的版本也是 Base::fcn(),因为 D1fcn() 继承自 Base::fcn(),且没有重写
  • D2 对象调用的版本是 D2::fcn(), 因为 D2 中对 Base::fcn() 进行了重写

当调用 f2() 时:

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

  • bp2 无法完成调用,因为 Base 中不存在 f2()
  • d1p 调用的是 D1::f2()
  • d2p 由于定义了 f2() 重写,调用的也是 D1::f2()

如果调用 fcn(int) 的话:

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)
因为 fcn(int)D1 自身的成员函数,因此不存在重写。因此:

  • Base 中无法调用 fcn(int),该函数在基类中并未定义
  • D1D2 均调用的是自身定义的版本。所有的绑定都是静态绑定,均在编译期完成。

重写被重载的函数

在 C++ 中,虚函数也可以被重载。而在派生类中,我们可以对虚函数的重载版本进行重写。重写可以针对不同的重载版本,但需要注意的是,如果希望所有重载版本的定义对派生类可见,我们有两个选择:

  • 要么重写所有的重载版本
  • 要么一个不重写

这样会带来一个问题,当只需要重写一部分重载版本时,这个过程会变得很麻烦。

一种解决方案是使用 using 在需要重载的地方进行该函数的声明。由于 using 在声明的时候只针对函数的名字,而不考虑参数列表,因此 using 声明默认会将基类中该函数的所有版本“添加到”派生类中(也就是将该系列函数的作用域并入了派生类中)。这样的方式导致继承的重载函数的定义对派生类可见,因此只需要对需要重写的函数进行操作即可。

需要注意的是,使用 using 声明基类中的函数,其前提是该函数机器重载版本必须可以被派生类访问。否则,如果访问没有被派生类重新定义的重载版本,都是对 using 声明的访问。

构造函数与拷贝控制

处于继承层级中的类同样存在着拷贝控制。

虚析构函数

继承对拷贝控制最大的一个影响是,基类中的析构函数通常应该被定义为虚函数。这样做是因为需要对拥有动态资源的派生类进行准确的析构。也就是说,虚析构函数允许我们使用动态绑定的方式来释放资源。比如之前的例子 Quote,其析构函数需要声明为:

class Quote {
public:
    // virtual destructor needed if a base pointer pointing to a derived object is deleted
    virtual ~Quote() = default; // dynamic binding for the destructor
};
如果此时 Quote* 绑定的是 Bluk_quote,那么对 Quote* 申请的资源进行 delete 就可以正确释放 Bluk_quote 对象申请的资源。此时,Bluk_quote 也拥有析构函数,该函数继承自基类;而通过对该析构函数的重写则可以正确的释放 Bluk_quote 对象申请的资源:
Quote *itemP = new Quote;   //  same static and dynamic type
delete itemP;               //  destructor for Quote called
itemP = new Bulk_quote;     //  static and dynamic types differ
delete itemP;               //  destructor for Bulk_quote called

如果基类中,析构函数不被声明为虚函数,那么通过基类指针 对派生类资源进行 delete 操作是未定义行为

需要注意的是,基类的析构函数是三五原则一个重要的例外。基类需要一个内容为空的虚析构函数,而并不意味着需要添加其他拷贝控制的部分。

这也带来一个影响:编译器不会为定义了析构函数的类合成移动操作。

合成的拷贝控制与继承

C++ 中,派生类中的合成拷贝控制成员,在处理基类部分的资源时,会调用基类中对应的拷贝控制成员来处理。比如之前的 Quote 实例:

  • Bluk_quote 的合成构造函数会调用 Disc_quote 中的构造函数,也就是 Quote 的构造函数
  • Quote 的默认构造函数为 Disc_quote 的直接基类部分 bookNoprice 进行初始化,之后由 Disc_quoteBluk_quote 的直接基类部分 qtydiscount 进行初始化。
  • 整个过程类似于一个递归的过程,最内层的基类中的成员会被首先初始化




该规则同样适用于拷贝成员。比如拷贝构造函数,析构函数等。需要注意的是,该规则被应用的前提是基类中对应的拷贝控制成员是可访问的(没有被删除的),而与成员是否是合成的并没有关系。

基类,派生类和被删除的拷贝控制成员

C++ 11 中允许拷贝成员被删除。鉴于派生类拷贝控制成员会使用基类的拷贝控制成员,基类中的拷贝控制成员会影响派生类中对应成员的可删除性。总的来说:

  • 基类中的默认构造函数,拷贝构造函数,拷贝赋值运算符以及析构函数被删除无法访问,会导致派生类中对应成员被删除。因为派生类的拷贝成员无法使用基类中的对应成员完成拷贝控制。
  • 基类中的析构函数无法访问或者被删除,会导致派生类中的合成构造函数被删除,因为派生类无法释放其基类部分中申请的资源。
  • 默认情况下,编译器不会合成移动成员。如果使用 = default 在派生类中显式的要求合成移动成员时,如果基类中无法提供对应的,未删除(可访问的)移动成员,则该移动成员是被删除的,因为基类部分无法移动。如果基类的析构函数也是被删除的(无法访问的),则该移动成员也将是被删除的。

下面的例子中,B 中的拷贝构造函数被删除,导致了派生类中 D 中的拷贝构造函数,以及使用该拷贝构造函数的移动成员都被删除:

class B {
public:
    B();
    B(const B&) = delete;
    // other members, not including a move constructor
};
class D : public B {
    // no constructors
};
D d;     // ok: D's synthesized default constructor uses B's default constructor
D d2(d); // error: D's synthesized copy constructor is deleted
D d3(std::move(d)); // error: implicitly uses D's deleted copy constructor
这种情况下,由于 B 定义了拷贝构造函数,B 不会合成移动构造函数。因此 B 不能被拷贝和移动,这也导致 D 的对象也是无法被拷贝和移动的。D 需要通过自定义拷贝 / 移动构造函数来完成这些操作(函数需要处理基类部分)

通常情况下,如果基类中没有可用的拷贝控制成员,则派生类中也不会有。

移动操作与继承

由于多数基类都会定义虚析构函数, 基类中通常不会有合成移动操作;这也同时会影响到派生类。为此,基类中通常会定义移动操作(如果移动操作有意义的话),来为其派生类提供移动操作。比如我们的 Quote 类中,就可以进行显式的移动成员定义:

class Quote {
public:
    Quote() = default;             // memberwise default initialize
    Quote(const Quote&) = default; // memberwise copy
    Quote(Quote&&) = default;      // memberwise move
    Quote& operator=(const Quote&) = default; // copy assign
    Quote& operator=(Quote&&) = default;      // move assign
    virtual ~Quote() = default;
    // other members as before
};
由于基类中定义了拷贝/移动控制成员,该基类以及其派生类都会支持这些操作。

派生类中的拷贝控制成员

派生类中的拷贝控制成员分为两类:

  • 需要同时负责处理基类部分与派生类部分的成员:拷贝 / 移动构造函数,拷贝 / 移动赋值运算符
  • 只需要处理所在类的成员:析构函数()
定义派生类的拷贝 / 移动构造函数

定义派生类的拷贝 / 移动构造函数时,需要显式的委托基类中对应的拷贝 / 移动构造函数,以此来初始化派生类对象中的基类部分:

class Base { /* ...    */ } ;
class D: public Base {
public:
    // by default, the base class default constructor initializes the base part of an object
    // to use the copy or move constructor, we must explicitly call that
    // constructor in the constructor initializer list
    D(const D& d): Base(d)      // copy the base members
                 /* initializers for members of D */ { /* ...  */ }
    D(D&& d): Base(std::move(d)) // move the base members
                 /* initializers for members of D */ { /* ...  */ }
};
被用于初始化的派生类对象 d,其内部的基类部分被用于初始化 D 类型的新对象中的基础部分(传递给 Base&)。基类的拷贝 / 移动构造函数会拷贝 / 移动这部分内容用于初始化。

需要注意的是,这部分不可省略。如果以下面的形式对派生类对象进行拷贝 / 移动构造:
// probably incorrect definition of the D copy constructor
// base-class part is default initialized, not copied
D(const D& d) /* member initializers, but no base-class initializer    */
    { /* ...   */ }
这种情况下,D 类型的新对象中的两个部分:

  • 基类部分由基类的默认构造函数初始化
  • 派生类部分由派生类的拷贝 / 移动构造函数初始化

这样得到的结果是一个只拷贝了一半的对象,显然是不合理的。

派生类拷贝 / 移动构造函数必须显式的委托基类中对应的构造函数进行基类部分的构造。

派生类的赋值运算符

派生类的赋值过程分为两个份:

  • 调用基类的赋值运算符
  • 按照赋值运算符的常规设计方法设计派生类中的赋值过程

// Base::operator=(const Base&) is not invoked automatically
D &D::operator=(const D &rhs)
{
    Base::operator=(rhs); // assigns the base part
    // assign the members in the derived class, as usual,
    // handling self-assignment and freeing existing resources as appropriate
    return *this;
}
无论基类中的赋值运算符是合成的还是自定义的,都可以通过以 Base::operator=(rhs); 的方式调用。

派生类的析构函数

当析构函数的函数体执行完毕后,类成员会被隐式的自动销毁。因此,派生类的析构函数不需要对基类部分进行额外的销毁工作;也就是说派生类的析构函数只负责释放被派生类申请的资源(成员):

class D: public Base {
public:
    // Base::~Base invoked automatically
    ~D() { /* do what it takes to clean up derived members   */ }
};
与一般析构函数的销毁顺序类似,派生类对象的销毁过程也是反向的,会先运行派生类的析构函数,再运行基类的析构函数

在构造函数和析构函数中调用虚函数

在对派生类对象进行构建与销毁中,基类部分与派生类部分是分别进行的;而在此期间,被构建的对象属于一种不完全的状态。以构造函数为例,其基类初始化完毕,但派生类还没有初始化。

由于这种不完全的状态并不满足动态绑定的要求,因此编译器将该处于不完全状态的对象的类型,视作与其构造函数一个类型。也就是说,派生类对象在构造过程中,如果没有完成派生类成员的初始化,则此时该对象会被视作基类类型的对象。

而当我们在派生类成员被构造之前尝试调用虚函数;具体的来说,在基类的构造函数中调用虚函数,会导致该调用的绑定不再是动态绑定,而直接会绑定到与构造函数类型相同的对象上(基类类型上)。此时对虚函数的调用,实际上是在调用基类中的虚函数版本。来看下面的例子:

class Dog
{
public:
    Dog() {
        cout<< "Constructor called" <<endl;
        bark() ;
    }
    ~Dog() { bark(); }
    virtual void bark() { cout<< "Virtual method called" <<endl; }
    void seeCat() { bark(); }
};
 
class Yellowdog : public Dog
{
public:
        Yellowdog() { cout<< "Derived class Constructor called" <<endl; }
        void bark() { cout<< "Derived class Virtual method called" <<endl; }
};
 
int main()
{
    Yellowdog d;
    d.seeCat();
}
基类的构造函数 Dog() 调用了虚函数 bark(),但由于此时派生类对象并没有完成构造,因此调用的是 Dog::bark()。而当派生类对象 d 构造完毕之后,再对 bark() 进行调用(使用 seeCat()),此时则进行了动态绑定,d.seeCat() 调用了 Yellowdog::bark()。因此,整个程序的输出应该为:
Constructor called //Dog::cstr
Virtual method called //Dog::bark() in Dog::cstr
Derived class Constructor called // Yellowdog::cstr
Derived class Virtual method called //Yellowdog::bark()
Virtual method called //Dog::bark() in Dog::dstr
这种行为也很好解释:如果允许未完成对象进行动态绑定,则实际上是允许对未初始化成员的访问;这种访问很可能会导致程序的崩溃。

Ref:Calling virtual methods in constructor/destructor in C++

习题中的问题

为什么要在派生类移动成员中使用 std::move()

比如下面的:

Disc_quote(Disc_quote&& rhs) : Quote(std::move(rhs))
第一眼看上去很奇怪,为什么参数命名是右值,却仍然要使用 std::move() 再转换一遍?这是因为所有有名字的右值都被当做左值处理。因此 rhs 在此处实际上是左值。

之所以有这样的机制,是为了防止对象被移动两次

Ref: Move constructor on derived object

为什么纯虚析构函数需要函数体?

本节习题中,抽象类中的析构函数应该被定义为纯虚函数。如果只在类中写:
virtual ~Disc_quote() = 0;
而不进行类外定义的话,会出现如下错误:
undefined reference to `Disc_quote::~Disc_quote()'
这是因为,即便抽象基类不需要实体化,也需要提供一个析构函数供派生类销毁属于该抽象基类中的资源。

Ref: Pure Virtual Destructor in C++

构造函数的继承

C++11 中提供了让派生类继承直接基类的构造函数的方法,格式如下:

using derived(parms) : base(args) { }
该方法使用 using 关键字。普通情况下,using 的使用是为了让某个名字在当前作用域可见;但当 using 应用到构造函数上时,则代表了当前派生类会继承基类中的构造函数进行使用。比如下面的例子:
class Bulk_quote : public Disc_quote {
public:
    using Disc_quote::Disc_quote; // inherit Disc_quote's constructors
    double net_price(std::size_t) const;
};
如果派生类拥有属于自己的成员,那么这些成员会进行默认初始化

通过作业中的实际测试,如果只是单纯的使用继承的构造函数,是无法对这些派生类的成员进行初始值设定的。

该方法实际等同于:

Bulk_quote(const std::string& book, double price,
          std::size_t qty, double disc):
      Disc_quote(book, price, qty, disc) { }

继承构造函数的特性

在继承的过程中:

  • 构造函数的访问控制级别不会改变;比如私有的构造函数继承后依然是私有的
  • 构造函数的继承不影响 explicitconstexpr 的修饰

默认情况下,构造函数的继承会将基类中所有的构造函数继承到派生类中。但有几个例外:

  1. 当派生类中定义的构造函数与基类构造函数具有相同的参数列表,则此时该基类构造函数不会被继承,而是会被派生类中的对应构造函数取代。
  2. 拷贝 / 移动构造函数,默认构造函数均不会被继承。
继承构造函数中有默认参数的情况

基类构造函数中的默认参数不会被继承。这种情况下,该基类构造函数可能会被分为多个构造函数分别继承。总的来说可以分为两部分:

  • 参数与基类个数相同的构造函数(可以覆盖所有的默认值的构造函数)
  • 参数中不带有某些带默认值参数的构造函数

下面是一个测试的例子:

struct Base
{
	Base(int a, int b = 1, int c = 2):x(a), y(b), z(c) {}
	int x {0};
	int y {0};
	int z {0};
};

struct D :public Base
{
	using Base::Base;
	void print() {std::cout << x << " " << y << " " << z << std::endl; }
};

int main(int argc, char const *argv[])
{
	D d1(1, 2, 3);
	d1.print();
	D d2(4,5);
	d2.print();
	D d3(6);
	d3.print();
输出为:
1 2 3
4 5 2
6 1 2
当继承的构造函数中参数数量小于基类构造函数时,就会有带默认值的参数被省略。而该默认值会作为被构造对象的初始值。被省略的参数都是带有默认值的;被省略的方向是从右到左。

容器与继承

基类的类型与派生类的类型不同,而容器只允许指定单个类型。因此,如果希望将存在继承关系的对象同时存入某个容器时,必须采用间接访问的办法。如果直接进行存储,那么将会导致派生类对象被转换为基类对象,且派生类部分的成员会丢失。比如下面的例子:

vector<Quote> basket;
basket.push_back(Quote("0-201-82470-1", 50));
// ok, but copies only the Quote part of the object into basket
basket.push_back(Bulk_quote("0-201-54848-8", 50, 10, .25));
// calls version defined by Quote, prints 750, i.e., 15 * $50
cout << basket.back().net_price(15) << endl;
push_back() 的操作完成之后,被操作的对象被转换为了 Qoute 类型,并丢失了折扣计件数量以及折扣率两个派生类中的成员。

在容器中使用智能指针代替对象

我们可以通过间接的方式(指针),也就是定义一个存储智能指针的容器(通常)来解决与继承相关的对象的存储问题。由于多态的存在,这些指针指向对象的动态类型可以是基类,也可以是派生类:

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));
// calls the version defined by Quote; prints 562.5, i.e., 15 * $50 less the discount
cout << basket.back()->net_price(15) << endl;
push_back() 的操作完成之后,智能指针的类型从 shared_ptr<Bulk_qoute> 转变为了 shared_ptr<Quote>;但由于此时该智能指针指向的依然是 Bulk_quote 类型的对象,因此当使用该指针访问 net_pirce() 成员的时,对象的动态类型是 Bulk_quote,因此访问的成员是 Bulk_quote::net_price()

实例:Basket 类

由上一节的内容得知,使用指针作为管理容器的中对象的工具是非常方便的事情。本节中,我们可以通过利用 multisetshared_ptr 的组合,来达到管理,并能够对所有书籍的出售情况进行统计的功能。

容器的选择

在这个实例中,我们选择 multiset 来作为书籍管理的容器。这是因为:

  • 每一本书都可以对应多个 Quote 对象,也就是多个销售实例
  • multiset 是有序关联容器,可以通过自定义排序规则对书籍进行排序

该容器的实现如下:

// function to compare shared_ptrs needed by the multiset member
    static bool compare(const std::shared_ptr<Quote> &lhs,
                        const std::shared_ptr<Quote> &rhs)
    { return lhs->isbn() < rhs->isbn(); }
    
    // multiset to hold multiple quotes, ordered by the compare member
    std::multiset<std::shared_ptr<Quote>, decltype(compare)*>
                  items{compare};
multiset items 的 key 类型是我们用于管理销售实例 Quote 对象的智能指针。之后的类型 decltype(compare)* 意味着我们指定了函数 compare() 作为 items 元素的自定义排序规则。该函数按照实例中书籍的 ISBN 进行排序。需要注意的是,该函数被定义为静态成员函数,以便所有的容器 set 共享该规则。

由于静态成员函数的可见范围是 file scope, 因此其定义必须处于其声明所在文件中
ref: Static functions outside classes

再回过来看 items 的定义,该 set 采用了如下的构造函数来定义:

explicit multiset( const Compare& comp,
                   const Allocator& alloc = Allocator() );

很显然,{ compare } 是为 item 的规则提供 argument。一些参考:

基本容器选择好之后,我们需要为 Basket 类提供两个功能:

  • 添加 Quote 对象到 items 中的功能 add_item()
  • 计算并打印每本书的销售额的功能 total_receipt()
add_items() 成员的实现

由于 items 通过智能指针来管理 Quote 对象,因此 add_item() 需要接受类型为 Quote 的智能指针,并使用成员 insert() 将其添加到 items 中:

void add_item(const std::shared_ptr<Quote> &sale)
        { items.insert(sale); }
在使用的时候,只需要使用对象调用该函数即可。该函数的初始值可以通过 make_shared 生成:
Basket bsk;
bsk.add_item(make_shared<Quote>("123", 45));
bsk.add_item(make_shared<Bulk_quote>("345", 45, 3, .15));

另外一个小关注点,作者在传递 shared_ptr 的时候使用了引用的方式。可以看看相关的讨论:
C++ - passing references to std::shared_ptr or boost::shared_ptr

total_receipt() 成员的实现

total_receipt() 成员需要做两件事:

  • 遍历整个 items ,查询所有的书籍
  • 对同一本书籍的销售额进行累加并打印

实现如下:

double Basket::total_receipt(ostream &os) const
{
    double sum = 0.0;   // holds the running total
    // iter refers to the first element in a batch of elements with the same ISBN
    // upper_bound returns an iterator to the element just past the end of that batch
    for (auto iter = items.cbegin();
              iter != items.cend();
              iter = items.upper_bound(*iter)) {
        // we know there's at least one element with this key in the Basket
        // print the line item for this book
        sum += print_total(os, **iter, items.count(*iter));
    }
    os << "Total Sale: " << sum << endl; // print the final overall total
    return sum;
}
这里使用了成员 upper_bound() 来设定循环条件:
iter = items.upper_bound(*iter)
当当前循环结束之后,upper_bound() 会自动跳到下个 key 的第一个位置(如果存在的话),也就是下一本书的第一个销售记录。由此可以看出,该循环是以所有相同 key 为单位的;也就是说,一次循环包括了所有 ISBN 相同的销售记录,也就是所有同一本书的销售记录。

接下来我们使用 print_total() 函数对当前书的所有销售记录进行累加:
sum += print_total(os, **iter, items.count(*iter));
有两点要注意:

  • print_total 的参数要求是 Quote&iter 代表的是当前迭代器,指向的是智能指针,因此需要解引用两次才能得到 Quote 对象
  • count(*iter) 计算的是当前的 key 有几个元素,也就是当前的书总共卖掉了多少本。将该值传递进 print_total() 中,即可计算出当前书的销售总额(调用了 net_price() 计算)
改进需求:隐藏智能指针

之前的实现中,add_item() 接受智能指针参数。这样做并不是很方便,因为针对不同的售书实体需要指定不同类型的智能指针:

sk.add_item(make_shared<Quote>("123", 45));
bsk.add_item(make_shared<Bulk_quote>("345", 45, 3, .15));
我们希望将其改造为接受 Quote 对象,也就是类似拷贝构造函数的方式来添加实体到 items 中:
void add_item(const Quote& sale);  // copy the given object
void add_item(Quote&& sale);       // move the given object
但这样做会带来一个问题。由于 new 只能为我们指定的类型 Quote 分配空间,因此,当以 Bulk_quote 类型对象作为参数时,我们只能得到一个以该对象基类部分为内容的 Quote 对象。

改进策略:模拟虚拷贝

由于 new 不能直接对派生类对象进行正确的空间分配,我们采用虚函数接口的方式来实现该功能。也就是说,创建一个虚函数,该虚函数在不同的类下有不同的空间分配方法。我们将该函数命名为 clone。该函数需要在 QuoteBluk_Quote 中有分别的实现:

class Quote {
public:
    // virtual function to return a dynamically allocated copy of itself
    // these members use reference qualifiers; see §13.6.3 (p. 546)
    virtual Quote* clone() const & {return new Quote(*this);}
    virtual Quote* clone() &&
                        {return new Quote(std::move(*this));}
    // other members as before
};
class Bulk_quote : public Quote {
    Bulk_quote* clone() const & {return new Bulk_quote(*this);}
    Bulk_quote* clone() &&
                   {return new Bulk_quote(std::move(*this));}
    // other members as before
};
而通过重写 clone(),我们就能使用同一个 add_item() 来对不同的类分配空间了:
class Basket {
public:
    void add_item(const Quote& sale) // copy the given object
      { items.insert(std::shared_ptr<Quote>(sale.clone())); }
    void add_item(Quote&& sale)      // move the given object
      { items.insert(
          //named rvalue is treated as lvaule, so std::move here.
          std::shared_ptr<Quote>(std::move(sale).clone())); }
    // other members as before
};
clone() 会返回一个指向当前类型对象的指针,而该指针被用于初始化对应的智能指针。由于该指针是临时变量,因此也不用担心野指针的问题。同时,也因为智能指针支持派生类到基类的转换,因此将 Bulk_qoite* 类型的指针绑定到 shared_ptr<Quote> 类型智能指针也是合理的。

除此之外,还注意的是,为了与 add_item() 的不同版本适配, clone() 使用了引用限定符 &&&,这将强制 add_item() 根据参数类型的不同选择拷贝或者移动的方式来完成实例的添加。

再探实例:TextQueries

相较于之前使用单个关键词进行查询的 TextQueries 实例,本章的实例要求提供使用关键词的逻辑表达式来作为查询的关键词。比如如下的查询方式,且支持这些查询方式的组合使用:

# ~Negation operator, yield lines that don’t match the query
~(Alice)
# | Or operator, return lines matching either of two queries
hair | Alice
# & and operator, return lines matching both queries
hair & Alice
输出与之前的实例相同,输出结果为关键词出现的次数,以及所在行的打印:
Executing Query for: ((fiery & bird) | wind)
((fiery & bird) | wind) occurs 3 times
(line 2) Her Daddy says when the wind blows
(line 4) like a fiery bird in flight.
(line 5) A beautiful fiery bird, he tells her,

解决方案:面向对象

通过仔细观察,我们发现这些运算实际上都可以以对象的形式来呈现(感觉有点像 functor)。具体如下:

WordQuery // Daddy
NotQuery  // ~Alice
OrQuery   // hair | Alice
AndQuery  // hair & Alice
以对象的方式来表示运算的过程意味着:

  • 查询的关键次表达式将以对象的形式进行构造
  • 根据查询关键词的结果,以表达式的意义来对最终结果进行组合

从实现上来讲,有两个函数需要动态绑定:

  • eval(),接收的 TextQuery 对象,返回 QueryResult 对象,也就是根据已有字典查询,查询的结果经过处理,输出为 QueryResult 对象做最后处理
  • rep(),将返回一个 string 用于表示输出结果中的关键词(也就是上例子中 occur 后的那一行表达式)





recover: TextQuery 对象是从导入的数据中的词为单位,并存储这些词所在对应行的数据对象;同时,TextQuery 带有成员函数 query() 用于对单个关键词进行输出。 QueryResult 存储的是指定的关键词,以及其在 TextQuery 中对应的行信息。

而最后的打印结果,将通过调用 QueryResult 对象配套的 print() ( 作业使用了 « 重载实现),通过读取 QueryResult 中的行信息,来打印出对应的行内容。

~Query() 的特殊解决思路

~ 运算与其他运算不太一样。~ 运算是打印出没有关键词的行;如果按照正常的逻辑,存储关键词所在的行号的话,该运算是无法得出结果的,因为结果并不存在。解决方案是,修改 NotQueryeval() 执行方式,将正常查询结果的内容从总行数中剔除;剩下的结果就是对该关键词查询求反的结果了。

抽象类的设计

通过上面的信息可以发现,这些用于表示关键词组合的表达式对象可以完全表现为,以单个关键词为基础的查询输出结果(行号 set)的组合关系。具体的来说:

  • 对对应组合对象中所有的单个关键词均进行一次查询
  • 将得到的结果使用重写的 eval() 进行组合,得到最终的行号

根据这样的思路,我们定义一个抽象类 Query_base;其他所有的具体类都将基于该抽象类实现。

需要注意的是,逻辑运算的算子数量要求存在不同。对于要求双算子的两种运算 &|,给予一个额外的抽象类 BinaryQuery 有助于简化这两个类的实现:


关键概念:组合与继承

在类设计中有两个关键的关系:是(Is-A拥有 (Has-A)。派生类与基类应该体现出派生类是 (Is-A) 一个基类的关系;而 Has-A 更加体现了成员member)的概念,比如 Bulk_quote 的成员拥有(Has-AISBN().

使用接口类隐藏类的层级

接口类是一个很重要的概念(也就是我们经常提到的句柄类(handle class))。

在应用中,查询是以表达式的形式出现的。如果在表达式中直接使用已有类类型的对象作为算子,显然是很不方便的。为解决这个问题,我们希望用一个接口类(Interface classQuery 来管理层级中所有会用到的类。实现上来讲,Query 需要有三个成员:

  • 指向 Query_base 的指针,用于绑定派生类对象(推荐使用智能指针,方便内存管理)
  • eval() 的定义,通过指针成员访问该函数可以达到动态绑定的效果
  • rep() 的定义,同上

由于我们希望使用者只基于 Query 来描述整个表达式,因此 Query 还需要有能够创建不同派生类对象的手段。根据逻辑运算的定义,我们可以重载三种不同的逻辑运算,这些运算会创建并返回一个绑定了派生类对象的 Query 对象:

  • & 会绑定 AndQueryQuery
  • | 会绑定 OrQueryQuery
  • ~ 会绑定 NotQueryQuery

除此之外,单个关键词的对象类型 WordQuery 作为表达式的基础部分,需要可以使用 Query 的构造函数直接创建。

表达式是如何以对象的形式表现的

根据之前的内容,我们的表达式的最终结果,实质上是不同单个关键词查询结果的逻辑运算。因此,一个查询表达式的执行,实际上是在以 WordQuery 为单位构建执行的过程:



而具体执行的过程,也是按照这个树的顺序进行调用,直到达到最基本的单位时得出查询结果后,再依次返回上级,进行最后的结果的运算(类似递归的概念)。

Query_Base & Query 的实现

几个要点:

  • Query_base 作为抽象类,其实现需要定义两个纯虚函数eval()rep()
  • 上述两个函数的参数和返回类型都必须匹配 Query(虚函数的要求)。
  • 析构函数需要是虚函数。由于该虚函数实际上是被派生类使用,因此放在 protected
  • 需要添加 Query 对其访问的权限

// abstract class acts as a base class for concrete query types; all members are private
class Query_base {
    friend class Query;
protected:
    using line_no = TextQuery::line_no; // used in the eval functions
    virtual ~Query_base() = default;
private:
    // eval returns the QueryResult that matches this Query
    virtual QueryResult eval(const TextQuery&) const = 0;
    // rep is a string representation of the query
    virtual std::string rep() const = 0;
};

Query 类

首先,由于 Query 使用指针惯例 Query_base 以及其派生类,Query 需要拥有指针数据成员,以及对其初始化的构造函数。该构造函数会初始化一个指向抽象类类型的指针,因此被定义为私有成员。

private:
    Query(std::shared_ptr<Query_base> query): q(query) { }
    std::shared_ptr<Query_base> q;
其次,由于逻辑运算符需要借助 Query 创建并绑定对应对象,那么实际上返回的是一个已经绑定好对应对象的,但类型是 Query_base 的指针。该指针需要通过上述的私有构造函数完成对应 Query 的构造,因此需要在 Query 内部提供访问权限。

除此之外,Query 内部也需要通过重写 eval()rep 来调用不同的派生类版本,具体是那个取决于 q 指向的对象:
QueryResult eval(const TextQuery &t) const { return q->eval(t); }
std::string rep() const { return q->rep(); }
大致的实现如下:
// interface class to manage the Query_base inheritance hierarchy
class Query {
    // these operators need access to the shared_ptr constructor
    friend Query operator~(const Query &);
    friend Query operator|(const Query&, const Query&);
    friend Query operator&(const Query&, const Query&);
public:
    Query(const std::string&);  // builds a new WordQuery
    // interface functions: call the corresponding Query_base operations
    QueryResult eval(const TextQuery &t) const
                            { return q->eval(t); }
    std::string rep() const { return q->rep(); }
private:
    Query(std::shared_ptr<Query_base> query): q(query) { }
    std::shared_ptr<Query_base> q;
};
需要注意的是,任何需要用到之后类类型具体定义的函数,都只能在次做出声明,比如构造 WordQuery 的函数。

Query 的输出

Query 的输出通过指针访问对应的 rep() 版本即可。实际上,Query 通过了 Query_base 进行了动态绑定:

std::ostream &
operator<<(std::ostream &os, const Query &query)
{
    // Query::rep makes a virtual call through its Query_base pointer to rep()
    return os << query.rep();
}

其他派生类的实现

之前提到,派生类对象是通过 Query 指向 Query_base 的指针来管理的。除开基本单位 WordQuery 可以使用 string 直接初始化,其他的类对象都需要使用指针初始化,从而来保证逻辑运算可以在任意的两个派生类之间执行(或是单个派生类,比如 ~)。

除此之外,派生类也需要自身的 eval()rep() 版本来输出最终的查询结果。

WordQuery 的实现

WordQuery 作为基本单位,需要实现几个功能:

  • 存储关键词,这里使用私有 string 成员
  • 根据关键词初始化该类,这里使用 string 作为初始值进行构造
  • 打印相关信息;由于单个的关键次查询可以直接调用 TextQuery 的成员 query,因此该类 eval() 只需要做查询,而 rep() 只需要打印关键词即可。

class WordQuery: public Query_base {
    friend class Query; // Query uses the WordQuery constructor
    WordQuery(const std::string &s): query_word(s) { }
    // concrete class: WordQuery defines all inherited pure virtual functions
    QueryResult eval(const TextQuery &t) const
                     { return t.query(query_word); }
    std::string rep() const { return query_word; }
    std::string query_word;    // word for which to search
};
WordQuery 定义完毕之后,Query 中对应管理 wordQuery 初始化的构造函数也可以实现了:
inline Query::Query(const std::string &s): q(new WordQuery(s)) { }

NotQuery 和 ~ 运算

NoQuery 的设计要点与 WordQuery 类似,最终也需要通过来自 Query_base 的指针进行初始化。因此,主要的设计点有:

  • “指针的存放”:本来应该有一个指向 Query_base 的指针;但由于我们使用 Query 代管,因此此处的数据成员类型为 Query,之后会使用 Query 使用指针的方式间接的进行冬天胖丁。
  • eval() 的实现:该实现牵涉到求单个查询结果的差集,放到后面再说
  • rep() 的实现:这里就可以看出使用 Query 代管的思路了:
    1. 该实现使用了 Query 类型的成员对其成员 Query::rep() 进行访问。需要注意的是,这一步是对象访问成员函数,不是 virtual call。
    2. Query 中的实现是 q→rep(),此时通过 Query_base 虚调用了对应的 rep()。(一连串的调用,最终实现会使用 WordQuery
    3. 之后在添加额外信息辅助理解即可

class NotQuery: public Query_base {
    friend Query operator~(const Query &);
    NotQuery(const Query &q): query(q) { }
    // concrete class: NotQuery defines all inherited pure virtual functions
    std::string rep() const {return "~(" + query.rep() + ")";}
    QueryResult eval(const TextQuery&) const;
    Query query;
};
其对应的运算符重载的实现如下:
inline Query operator~(const Query &operand)
{
    return std::shared_ptr<Query_base>(new NotQuery(operand));
}

抽象类 BinaryQuery 的实现
  • 除开 NotQuery 以外,剩余的 AndQueryOrQuery 都需要两个算子进行运算(初始化)。因此需要有 2 个 Query 对象作为数据成员
  • 由于 AndQueryOrQuery 的运算符号不同,需要一个 string 来存放对应符号
  • AndQueryOrQueryrep() 可以公用一套 rep(),只需要指定不同的运算符号即可。
  • 由于运算的意义不同,eval() 并不能用一套方案,因此 BinaryQuery 不做实现;而 BinaryQuery 需要被设计为抽象类,继承 Query_base::eval() 可以保证这一点。

class BinaryQuery: public Query_base {
protected:
    BinaryQuery(const Query &l, const Query &r, std::string s):
          lhs(l), rhs(r), opSym(s) { }
    // abstract class: BinaryQuery doesn't define eval
    std::string rep() const { return "(" + lhs.rep() + " "
                                         + opSym + " "
                                         + rhs.rep() + ")"; }
    Query lhs, rhs;    // right- and left-hand operands
    std::string opSym; // name of the operator
};

AndQuery 与 OrQuery
  1. 这两个类均继承自 BinaryQuery,其构造函数均使用 BinaryQuery() 的构造函数,只是在调用的时候会指定不同的运算符号
  2. 这两个类是具体类,需要分别实现自身的 eval() 和对应的运算符重载
  3. rep() 直接继承自 BinaryQuery,而通过 BinaryQuery 中的调用,会再次回到 Query 中的虚调用。

class AndQuery: public BinaryQuery {
    friend Query operator& (const Query&, const Query&);
    AndQuery(const Query &left, const Query &right):
                        BinaryQuery(left, right, "&") { }
    // concrete class: AndQuery inherits rep and defines the remaining pure virtual
    QueryResult eval(const TextQuery&) const;
};
inline Query operator&(const Query &lhs, const Query &rhs)
{
    return std::shared_ptr<Query_base>(new AndQuery(lhs, rhs));
}

class OrQuery: public BinaryQuery {
    friend Query operator|(const Query&, const Query&);
    OrQuery(const Query &left, const Query &right):
                BinaryQuery(left, right, "|") { }
    QueryResult eval(const TextQuery&) const;
};
inline Query operator|(const Query &lhs, const Query &rhs)
{
    return std::shared_ptr<Query_base>(new OrQuery(lhs, rhs));
}

eval() 函数的实现

eval() 函数的主要功能是获取查询表达式在关键词 TextQuery 中的位置。根据表达式的不同,eval() 也需要被重写成几个版本。最简单的 WordQuery 已经实现,其功能就是返回当前关键词的查询结果(行 set)。而之后的几个派生类的查询结果,都是基于该结果进行的求反 / 求并 / 求交 等逻辑操作。

OrQuery::eval()

OrQuery 代表查询两个关键词任意一个存在的行 set,因此只需要对两个关键词分别查询,之后将其结果求并集即可。实现中采用了将 lhs.set 插入(insert())到 rhs.set 中来完成该运算。运算的结果被存放到 ret_lines 中并返回;返回的是一个 QueryResult 的结果,可以使用之前配套的 Print 函数打印出结果:

// returns the union of its operands' result sets
QueryResult
OrQuery::eval(const TextQuery& text) const
{
    // virtual calls through the Query members, lhs and rhs
    // the calls to eval return the QueryResult for each operand
    auto right = rhs.eval(text), left = lhs.eval(text);
    // copy the line numbers from the left-hand operand into the result set
    auto ret_lines =
         make_shared<set<line_no>>(left.begin(), left.end());
    // insert lines from the right-hand operand
    ret_lines->insert(right.begin(), right.end());
    // return the new QueryResult representing the union of lhs and rhs
    return QueryResult(rep(), ret_lines, left.get_file());

AndQuery::eval()

AndQuery 流程与 OrQuery 类似。唯一不同的地方在于 AndQuery 代表了 & 的运算,因此求的是两个行 set 的交集。这里使用了算法 set::set_intersection 进行运算:

// returns the intersection of its operands' result sets
QueryResult
AndQuery::eval(const TextQuery& text) const
{
    // virtual calls through the Query operands to get result sets for the operands
    auto left = lhs.eval(text), right = rhs.eval(text);
    // set to hold the intersection of left and right
    auto ret_lines = make_shared<set<line_no>>();
    // writes the intersection of two ranges to a destination iterator
    // destination iterator in this call adds elements to ret
    set_intersection(left.begin(), left.end(),
                   right.begin(), right.end(),
                   inserter(*ret_lines, ret_lines->begin()));
    return QueryResult(rep(), ret_lines, left.get_file());
}

NotQuery::eval()

NotQuery 代表的是求反操作 ~。实现上来说分几步:

  1. 按照关键词查询,得到结果行 set
  2. 读取整个字典的总行数
  3. 以字典的总行数作为循环次数,在每一个循环中检查结果行 set 中的行号是否在字典中
  4. 如果不在,则证明当前行没有查询的关键词,将该行行号插入到最后的结果 set(ret_lines) 中

书上的算法应该是某种字符串比较的算法,待研究。

// returns the lines not in its operand's result set
QueryResult
NotQuery::eval(const TextQuery& text) const
{
    // virtual call to eval through the Query operand
    auto result = query.eval(text);
    // start out with an empty result set
    auto ret_lines = make_shared<set<line_no>>();
    // we have to iterate through the lines on which our operand appears
    auto beg = result.begin(), end = result.end();
    // for each line in the input file, if that line is not in result,
    // add that line number to ret_lines
    auto sz = result.get_file()->size();
    for (size_t n = 0; n != sz; ++n) {
        // if we haven't processed all the lines in result
        // check whether this line is present
        if (beg == end || *beg != n)
            ret_lines->insert(n);  // if not in result, add this line
        else if (beg != end)
            ++beg; // otherwise get the next line number in result if there is one
    }
    return QueryResult(rep(), ret_lines, result.get_file());
}