跳转至

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


OOP概述

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

通过数据抽象,我们可以将类的接口与实现分离。

使用继承,可以定义相似的类型并对其相似关系建模。

使用动态绑定,可以在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象。

继承

通过继承(inheritance)联系在一起的类构成一种层次关系。层次关系的根部有一个基类(base class),其他类直接或间接地从基类继承而来,这些继承得到的类称为派生类(derived class)。

基类负责定义在层次关系中所有类共同拥有的成员,而每个派生类定义各自特有的成员。

// 基类的部分定义
class Quote {
public:
    std::string isbn() const;
    virtual double net_price(std::size_t n) const;
};

class Bulk_quote : public Quote {
public:
    double net_price(std::size_t) const override;
};

对于某些函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明成虚函数(virtual function)

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

派生类必须在其内部对所有重新定义的虚函数进行声明,最好在后面加上override关键字,以确保编译器为我们做语法检查。

定义基类

class Quote {
public:
    Quote() = default;
    Quote(const std::string &book, double sales_price) : bookNo(book), price(sales_price) {}

    std::string isbn() const { return bookNo; }

    virtual ~Quote() = default; // 对析构函数进行动态绑定
    virtual double net_price(std::size_t n) const { return n * price; }

private:
    std::string bookNo;

protected:
    double price = 0.0;
};

Note

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

成员函数与继承

派生类可以继承其基类的成员,但遇到像net_price这样的与类型相关的操作时,派生类必须对其重新定义。即,派生类需要对这些操作提供自己的新定义以覆盖(override)从基类继承而来的旧定义。

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

  • 一种是基类希望其派生类进行覆盖的函数,则将其定义为虚函数。

  • 另一种是基类希望派生类直接继承而不要改变的函数。

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

访问控制与继承

派生类可以继承定义在基类中的成员,但派生类的成员函数不一定有权访问从基类继承而来的成员。

派生类能访问公有成员、受保护的成员,不能访问私有成员。

定义派生类

class Bulk_quote : public Quote {
public:
    Bulk_quote() = default;
    Bulk_quote(const std::string &book, double p, std::size_t qty, double disc) : 
        Quote(book, p), min_qty(qty), discount(disc) {}

    // 覆盖基类的函数版本以实现基于大量购买的折扣政策
    double net_price(std::size_t) const override;

private:
    std::size_t min_qty = 0;    // 适用折扣最低购买量
    double discount = 0.0;      // 折扣
};

派生类中的虚函数

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

派生类可以在它覆盖的函数前使用virtual关键字,但不是必须的。C++11新标准允许派生类显式地注明它使用某个成员函数覆盖了它继承的虚函数,办法函数后面加一个关键字override。

派生类对象及派生类向基类的类型转换

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

  • 一个包含派生类自己定义的(非静态)成员的子对象。

  • 一个与派生类继承的基类对应的子对象,如果有多个基类,那么这样的子对象也有多个。

C++标准并没有明确规定派生类的对象在内存中如何分布。

我们能把派生类的对象当成基类对象来使用,而且也能将基类的指针或引用绑定到派生类对象的基类部分上。

Quote item;             // 基类对象
Bulk_quote bulk;        // 派生类对象
Quote *p = &item;       // p指向Quote对象
p = &bulk;              // p指向bulk的Quote部分

这种转换称为派生类到基类的转换(derived-to-base),这是一种隐式转换。

派生类的构造函数

尽管派生类对象中含有从基类继承而来的成员,但派生类并不能直接初始化这些成员。派生类必须使用基类的构造函数来初始化它的基类部分。

Note

每个类控制它自己的成员初始化过程。

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

继承与静态成员

如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一实例。

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

防止继承的发生

有时候我们会定义这样一种类,我们不希望其他类继承它,或者不想考虑它是否适合作为一个基类。为了实现这一目的,C++11新标准提供了一种防止继承发生的方法,即在类名后跟一个关键字final:

class NoDerived final { /**/ };         // NoDerived不能作为基类

类型转换与继承

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

Note

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

静态类型与动态类型

表达式的静态类型在编译时总是已知的,它是变量声明时的类型或表达式生成的类型。

动态类型则是变量或表达式表示的内存中的对象的类型。动态类型直到运行时才可知。

double ret = item.net_price(n);

item的静态类型是Quote&,它的动态类型则依赖于item绑定的实参。

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

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

更多关于基类和派生类之间类型转换的讨论见p534。

虚函数

对虚函数的调用可能在运行时才被解析

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

派生类中的虚函数

当我们在派生类中覆盖了某个虚函数时,可以再一次使用virtual关键字指出该函数的性质。然而这么做并非必须,因为一旦某个函数被声明成虚函数,则在所有派生类中它都是虚函数。

覆盖的虚函数的形参类型必须与被它覆盖的基类函数完全一致。返回类型也必须与基类函数匹配。

final和override说明符

如果派生类定义了一个函数与基类中虚函数的名字相同但是形参列表不同,这仍然是合法的行为。编译器将认为新定义的这个函数与基类中原有的函数是相互独立的。这时,派生类的函数并没有覆盖掉基类中的函数,这往往是一种失误,我们原本很可能希望覆盖。

在C++11新标准中我们可以使用override关键字来说明派生类中的虚函数,这么做的好处是编译器可以为我们发现上述错误。如果我们使用override标记了某个函数,但该函数并没有覆盖已存在的虚函数,此时编译器将报错。

我们还能把某个虚函数函数指定为final,如果虚函数函数定义成了final,则之后任何尝试覆盖该函数的操作都将引发错误。

struct D {
    void f(int) const final;    // 不允许后续的其他类覆盖
}

虚函数与默认实参

虚函数可以拥有默认实参,如果某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定,即使用基类中定义的默认实参,即使实际运行的是派生类中的函数版本也是如此。

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

回避虚函数机制

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

double undiscounted = baseP->Quote::net_price(42);

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

Warning

  1. 如果一个派生类虚函数需要调用它的基类版本,但是没有使用作用域运算符,则在运行时该调用将被解析为对派生类版本的自身调用,从而导致无限递归。
  2. 在构造函数和析构函数中调用虚函数无法完成动态绑定, 因为此时派生类处于 destroy 的状态, 使用它是不正确的.

抽象基类

通过在函数体的位置(即在声明语句的分号)书写=0就可以将一个虚函数说明为纯虚函数。

double net_price(std::size_t) const = 0;

含有纯虚函数的类是抽象基类

抽象基类负责定义接口,而后续的其他类可以覆盖该接口。我们不能(直接)创建一个抽象基类的对象。

Note

但可以给 net_price 这样的纯虚函数一个实现,并显式地调用它。它可以作为给默认的实现给派生类型调用。

访问控制与继承

每个类分别控制自己的成员初始化过程,与之类似,每个类还分别控制着其成员对于派生类来说是否可访问(accessible)

受保护的成员

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

  • 和私有成员类似,受保护的成员对于类的用户来说是不可访问的。

  • 和公有成员类似,受保护的成员对于派生类的成员和友元来说是可访问的。

此外,protected还有一条重要的性质:

  • 派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中受保护成员没有任何访问权限。

上述规则可以用下面的例子来理解:

class Base {
protected:
    int prot_mem;
};

class Sneaky : public Base {
    friend void clobber(Sneaky&);       // 能访问Sneaky::prot_mem
    friend void clobber(Base&);         // 不能访问Base::prot_mem
    int j;                              // j默认是private
};

// 正确:clobber能访问Sneaky对象的private和protected成员
void clobber(Sneaky &s) { s.j = s.prot_mem = 0; }

// 错误:clobber不能访问Base的protected成员
void clobber(Base &b) { b.prot_mem = 0; }

公有、私有和受保护继承

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

  • 一是在基类中该成员的访问说明符。

  • 二是在派生类的派生列表中的访问说明符。

派生访问说明符对于派生类的成员(及友元)能否访问其直接基类的成员没什么影响,对基类成员的访问权限只与基类中的访问说明符有关。

my note: 派生列表中的访问说明符限制了派生类用户对继承而来的成员的访问权限,而基类的成员访问说明符限制了基类用户的访问权限,这里的用户也包括了派生类。

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

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

  • 只有当D公有地继承B时,用户代码才能使用派生类向基类的转换;如果D继承B的方式是受保护的或者私有的,则用户代码不能使用该转换。

  • 不论以什么方式继承B,D的成员和友元都能使用派生类向基类的转换;派生类向其直接基类的类型转换对于派生类的成员和友元来说都是可访问的。

  • 如果D继承B的方式是公有的或者受保护的,则D的派生类的成员和友元可以使用D向B的转换;反之,如果D继承B的方式是私有的,则不能使用。

Tip

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

关于这个概念可以参考测试代码

友元与继承

友元关系不能继承,基类的友元在访问派生类成员时不具有特殊性,类似的,派生类的友元也不能随意访问基类的成员。

改变个别成员的可访问性

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

class Base {
public:
    std::size_t size() const { return n; }
protected:
    std::size_t n;
};

class Derived : private Base {
public:
    // 令size成员保持public访问级别
    using Base::size;
protected:
    using Base::n;
};

因为Derived使用了私有继承,所以继承而来的成员size和n默认情况下是Derived的私有成员。然而,我们使用using声明语句改变了这些成员的可访问性。

通过在类的内部使用using声明语句,我们可以将该类的直接或间接基类中任何可访问成员标记出来。using声明语句中名字的访问权限由该using声明语句之前的访问说明符来决定。

Note

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

继承中的类作用域

每个类定义自己的作用域,在这个作用域内我们定义类的成员。

当存在继承关系时,派生类的作用域嵌套在其基类的作用域之内,所以派生类才能像使用自己的成员一样使用基类的成员。

在编译时进行名字查找

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

名字冲突与继承

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

通过作用域运算符来使用隐藏的成员

作用域运算符将覆盖掉原有的查找规则。

Note

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

构造函数与拷贝控制

和其他类一样,位于继承体系中的类也需要控制当其对象执行一系列操作时发送什么样的行为,这些操作包括创建、拷贝、移动、赋值和销毁。

虚析构函数

继承关系对基类拷贝控制最直接的影响是基类通常应该定义一个虚析构函数,这样我们就能动态分配继承体系中的对象了。

当我们delete一个动态分配的对象的指针时,将执行析构函数。如果该指针指向继承体系中的某个类型,则有可能出现指针的静态类型与被删除对象的动态类型不符的情况。我们通过在基类中奖析构函数定义成虚析构函数以确保执行正确的析构函数版本。

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

Warning

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

之前介绍的经验准则说,如果一个类需要析构函数,那么它同样需要拷贝和赋值操作。但这里基类的析构函数并不遵顼这个准则,它是一个重要的例外。

虚析构函数还将阻止合成移动操作。

合成拷贝控制与继承

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

  • 合成的Bulk_quote默认构造函数运行Disc_quote的默认构造函数,后者又运行Quote的默认构造函数。

对于派生类的析构函数来说,它除了销毁派生类自己的成员外,还负责销毁派生类的直接基类;该直接基类又销毁它自己的直接基类,以此类推直至继承链的顶端。

派生类的拷贝控制成员

派生类的拷贝和移动构造函数在拷贝和移动自有成员的同时,也要拷贝和移动基类部分的成员。类似的,派生类赋值运算符也必须为其基类部分的成员赋值。

和构造函数及赋值运算符不同的是,析构函数只负责销毁派生类自己分配的资源。派生类对象的基类部分是自动销毁的。

定义派生类的拷贝或移动构造函数

class Base { /**/ };

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

派生类赋值运算符

// Base::operator=(const Base&) 不会被自动调用
D &D::operator=(const D &rhs)
{
    Base::operator=(rhs); // 为基类部分赋值
}

派生类析构函数

在析构函数体执行完成后,对象的成员会被隐式销毁。类似的,对象的基类部分也是隐式销毁的。因此,和构造函数及赋值运算符不同的是,派生类析构函数只负责销毁由派生类自己分配的资源:

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

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

继承的构造函数

在C++11新标准中,派生类能够重用其直接基类定义的构造函数。

一个类只初始化它的直接基类,出于同样的原因,一个类也只继承其直接基类的构造函数。类不能继承默认、拷贝和移动构造函数。如果派生类没有直接定义这些构造函数,则编译器将为派生类合成它们。

派生类继承基类构造函数的方式是提供一条注明了(直接)基类名的using声明语句。

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

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

在Bulk_quote类中,继承的构造函数等价于:

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

如果派生类含有自己的数据成员,则这些成员将被默认初始化。