Skip to content

Latest commit

 

History

History
1156 lines (802 loc) · 48.3 KB

面向对象程序设计.md

File metadata and controls

1156 lines (802 loc) · 48.3 KB

面向对象程序设计

面向对象程序设计基于三个基本概念:

  1. 数据抽象
  2. 继承
  3. 动态绑定

继承和动态绑定对程序编写的影响:

  1. 可以更容易地定义与其它类相似但不完全相同的类
  2. 在使用这些彼此相似的类编写程序时,可以一定程度忽略它们的区别

OOP:概述

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

  • 数据抽象:在第七章已经学过数据抽象。
  • 继承:通过继承可以定义相似的类型并对其相似关系建模。
  • 动态绑定:通过动态绑定可以在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象。

继承

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

层次关系的根部称为基类(base class)。其它的类则直接或间接地从基类继承而来,这些继承得到的类称为派生类(derived class)。

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

示例基类Quote,表示按原价销售的书籍。派生出Bulk_quote类,表示可以打折销售的书籍:

这些类包含下面两个成员函数:

  • isbn(),返回书籍的ISBN号。该操作不涉及派生类的特殊性,因此只定义在Quote类中
  • net_price(size_t),返回书籍的实际销售价格,前提是用户购买该书的数量达到一定标准。
    • 该操作与类型有关,基类Quote和派生类Bulk_quote都应该包含该函数。

C++中,基类将(类型相关的函数)与派生类(不做改变直接继承的函数)区分对待。

对于派生类自定义(重写)了基类的某些函数的版本,基类将这些函数声明成虚函数(virtual function)。

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

派生类要通过类派生列表(class derivation list)指出它是从哪些/个基类继承而来:

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

因为Bulk_quote在它的派生列表中使用了public关键字,所以我们完全可以把Bulk_quote对象当成Quote对象来用。

派生类必须在内部对所有重定义的虚函数进行声明,只要在要重定义的函数前加上virtual关键字,但也不是必须。

C++11允许派生类显式注明它要用哪个成员函数改写基类的虚函数,只要在该函数形参列表后增加一个override关键字。

动态绑定

通过动态绑定(dynamic binding)能用同一段代码分别处理QuoteBulk_quote对象。

例如,当要购买的书籍和购买数量都已经知道的时候,下面的函数负责打印总的费用。

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

因为函数print_totalitem形参是基类Quote的一个引用,所以我们既能使用基类Quote对象来调用这个函数,也能用派生类Bluk_quote来调用它。

函数的运行版本由实参决定,也就是在运行的时候选择函数的版本,所以动态绑定有时又叫运行时绑定(run-time binding)。

C++中,使用基类的引用或指针调用一个虚函数时将发生动态绑定。

定义基类和派生类

Quote类定义:

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 double net_price(std::size_t n) const { return n * price; }
    virtual ~Quote() = default;     // 析构函数动态绑定
private:
    std::string bookNo;     // 书籍ISBN号
protected:
    double price = 0.0;     // 代表普通状态下不打折的价格
}

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

成员函数与继承

派生类遇到基类写有virtual这样的虚函数就必须要提供自己的新定义以覆盖(override)从基类继承而来的旧定义。

C++的基类必须区分希望派生类能够覆盖的(虚)函数和希望派生类直接继承而不改变的函数。

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

基类通过在成员函数前加上virtual使得该函数执行动态版本。任何构造函数外的非静态函数都可以是虚函数。virtual只能出现在类内的声明语句前不能在类外的函数定义。基类中把一个函数声明成虚函数,那么这个函数在派生类里隐式地也是虚函数。

成员函数如果没被声明成虚函数,那么它的解析过程发生在编译的时候而不是运行时。

访问控制与继承

派生类只能访问基类的公有成员,这使得派生类的成员函数不一定有权访问从基类继承而来的成员。

不过在某些时候基类还有这么一种成员,基类希望它的派生类有权访问该成员,但是禁止其它用户访问,使用受保护的(protected)访问运算符说明这样的成员。

此处Quote类希望它的派生类定义各自的net_price函数,因此派生类需要访问Quoteprice成员。此时我们将price定义成受保护的。相反,派生类访问bookNo成员和其它用户一样,都是通过isbn函数调用。因此bookNo被定义成私有的,就算是Quote派生出来的类也不能直接访问它。

定义派生类

派生类必须通过类派生列表(class derivation list)明确指出它是从哪个/些基类继承而来。

派生类必须把基类中的虚函数重新声明:

class Bulk_quote : public Quote {       // Bulk_quote继承自Quote
public:
    Bulk_quote() = default;
    Bulk_quote(const std::string&, double, std::size_t, double);
    // 覆盖基类的函数版本以实现基于大量购买的折扣政策
    double net_price(std::size_t) const override;
private:
    std::size_t min_qty = 0;        // 适用折扣政策的最低购买量
    double discount = 0.0;          // 以小数表示的折扣额
};

现在只需要知道派生列表用到的访问说明符的作用是控制派生类从基类继承而来的成员是否对派生类的用户可见。

如果一个派生是公有的,那么基类的公有成员也是派生类接口的组成部分。我们也能将公有派生类型对象绑定到基类的引用或指针上。因为我们在派生列表中使用了public,所以Bluk_quote隐式包含isbn函数,同时在任何需要使用Quote的引用或指针的地方都能用Bulk_quote对象。

继承自一个类的形式称为“单继承”,派生列表有多个类的情况会在第18章介绍。

派生类的虚函数

派生类经常覆盖它继承的虚函数。如果没有覆盖,那么派生类会直接继承其在基类中的版本。

派生类可以在它覆盖的函数前加个virtual关键字,但不是必须。

C++11后允许派生类显式地注明它使用某个成员函数覆盖了它继承的虚函数。具体做法是在(形参列表)或(const成员函数的const关键字)或(引用成员函数的引用限定符)后面添加override关键字。

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

派生类对象组成部分:

  • 派生类自己定义的(非静态)成员的子对象
  • 继承的基类对应的子对象

Bulk_quote对象含有四个数据元素:

  • 继承自QuotebookNoprice数据成员
  • 自己定义的min_qtydiscount成员

因为派生类对象含有与基类对应的组成部分,所以能把派生类对象当成基类对象来使用,并且也能将基类的指针或引用绑定到派生类对象的基类部分上。

    Quote item;                 // 基类对象
    Bulk_quote bulk;            // 派生类对象

    Quote *p = &item;           // p指向Quote对象
    p = &bulk;                  // p指向bulk的部分
    Quote &r = bulk;            // r绑定到bulk的Quote部分

这种转换通常称为派生类到基类的(derived-to-base)类型转换。编译器会隐式执行派生类到基类的转换。

派生类构造函数

派生类必须用基类的构造函数来初始化它的基类部分。

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

派生类构造函数同样是通过构造函数初始化列表来将实参传递给基类构造函数的。

例如Bulk_quote构造函数:

    Bulk_quote(
        const std::string& book, 
        double p,
        std::size_t qty,
        double disc
    ) : Quote(book, p), min_qty(qty), discount(disc) { }
	...

该函数将其前两个参数传递给Quote的构造函数。接下来初始化由派生类直接定义的min_qty成员和discount的成员。

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

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

派生类使用基类的成员

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

// 如果达到了购买书籍的某个最低限量值,就可以享受折扣价格
double Bulk_quote::net_price(size_t cnt) const
{
    if (cnt >= min_qty)
        return cnt * (1 - discount) * price;
    else
        return cnt * price; 
}

该函数产生一个打折后的价格:如果给定的数量超过了min_qty,则将discount(折扣比例)作用于price

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

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

继承与静态成员

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

class Base {
public:
    static void statmem();
};

class Derived : public base {
    void f(const Derived&);
};

静态成员遵循通用的访问控制规则,如果基类里的成员是private的,那么派生类无权访问它。

假设某静态成员是可访问的,那么我们既能够通过基类使用它也能通过派生类使用它:

void Derived::f(const Derived &derived_obj)
{
    Base::statmem();		// 正确 Base定义了statmem
    Derived::statmem();		// 正确 Derived继承了statmem
    // 正确 派生类的对象能访问基类的静态成员
    derived_obj.statmem();		// 通过Derived对象访问
    statmem();					// 通过this对象访问
}

派生类声明

声明中包含类名但是不包含它的派生列表:

class Bulk_quote : public Quote;		// 错误 派生列表不能出现在这里
class Bulk_quote;						// 正确 就像声明类一样

声明语句的目的是让程序知道某个名字的存在以及该名字有个什么样的实体,如一个类、一个函数或一个变量等。

被用作基类的类

被用作基类的类必须已经定义而不是声明:

class Quote;			// 声明但没定义
// 错误 Quote未被定义
class Bulk_quote : public Quote {...};

原因:派生类包含并可以使用它从基类继承而来的成员,为了使用这些成员,派生类必须要知道它们是什么。另一层隐含意思:一个类不能派生它自己。

类既可以是基类也可以是派生类:

class Base { ... };
class D1 : public Base { ... };
class D2 : public D1 { ... };

BaseD1直接基类(direct base),同时也是D2间接基类(indirect base)。直接基类出现在派生列表里,而间接基类由派生类通过其直接基类继承而来。

类会继承直接基类里的所有成员,最终的派生类会继承其直接基类的成员。该直接基类的成员又含有其基类的成员。以此类推直至继承类的顶端。所以最终的派生类会包含它的直接基类以及每个间接基类的子对象。

防止继承的发生

有时会定义一种不希望被其它类继承的类,或者不像考虑它是否适合当成一个基类。

为实现该目的,C++11提供了防止继承的方法:

class NoDerived final { ... };		// 在类名后加上final 此时NoDerived不能用作基类
class Base { ... };

class Last final : Base { ... };	// Last继承了Base 但是Last不能用作基类

class Bad : NoDerived { ... };		// 错误 NoDerived是final 不能被继承
class Bad2 : Last { ... };			// 错误 Last是final 不能被继承 

类型转换与继承

可以将基类的指针或引用绑定到派生类对象上。

这里面的一层重要含义:当使用基类的引用或指针时,实际上我们不清楚该引用或指针所绑定对象的真实类型。该对象可能是基类的对象,也可能是派生类的对象。

智能指针类也支持派生类向基类的类型转换,意味可以把派生类对象指针绑定到基类智能指针内。

不存在从基类向派生类的转换。派生类转基类是因为派生类包含基类,但是基类并不一定完全包含派生类。

编译器无法在编译时确定某个特定的转换在运行时是否安全,因为编译器只能通过检查指针或者引用的静态类型来推断该转换是否合法。

如果在基类里含有一个或者多个虚函数,那我们可以用dynamic_cast请求一个类型转换,这个转换的安全检查会在运行时执行。同样,如果已知基类向派生类的转换时安全的,也可以用static_cast来强制覆盖编译器的检查工作。

派生转基的自动转换也只在指针或引用有效,它们之间不存在这样的转换。

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

因为这些成员接受引用作为参数,所以派生类向基类的转换允许我们给基类的拷贝/移动操作传递一个派生类的对象。这些操作不是虚函数。给基类的构造函数传递一个派生类对象时,实际运行的构造函数是基类里定义的那个,显然该构造函数只能处理基类自己的成员。同样,把派生对象赋给基对象,实际运行的赋值运算符也是基类的那个。

用一个派生对象初始化基对象时,派生对象的派生部分会被切掉(sliced down):

Bulk_quote bulk;			// 派生类对象
Quote item(bulk);			// 使用Quote::quote(const Quote&)的构造函数 bulk的Bulk_quote部分会被切掉
item = bulk;				// 调用Quote::operator=(const Quote&) bulk的Bulk_quote部分会被切掉
  • 派生类向基类的类型转换只在指针或引用类型有效。
  • 基类向派生类不存在隐式类型转换。
  • 派生类向基类的类型转换可能会由于访问受限而变得不可行。

静态类型与动态类型

使用继承关系的类型时,必须把一个变量或其它表达式的静态类型(static type)与该表达式表示对象的动态类型(dynamic type)区分开来。

  • 表达式的静态类型在编译时是已知的,它是变量声明时的类型或表达式生成的类型。
  • 动态类型是变量或表达式表示的内存中的对象的类型。动态类型直到运行时才可知。
// 例如print_total调用net_price
double ret = item.net_price(n);

item的静态类型是Quote&的,它的动态类型则依赖于item绑定的实参,有可能是Quote也有可能是Bulk_quote

如果表达式既不是引用也不是指针,那么它的动态类型永远和静态类型一样。例如Quote类型的变量永远是Quote对象,无论如何也不能改变该变量对应的对象类型。

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

虚函数

因为直到运行的时候才能确定到底用了哪个版本的虚函数,所以所有虚函数都必须有定义。

通常如果不用哪个函数,那么就不用给该函数提供定义。但对于虚函数不久必须要提供定义,因为编译器无法确定到底会用哪个函数。

例如print_total函数,到底调用item哪个版本完全依赖于运行时绑定到item的实参(动态)类型:

Quote base("0-201-82470-1", 50);
print_total(cout, base, 10);		// 调用Quote::net_price
Bulk_quote derived("0-201-82470-1", 50, 5, .19);
print_total(cout, derived, 10);		// 调用Bulk_quote::net_price

动态绑定只有当我们通过指针或者引用调用虚函数时才会发生。

当通过一个非引用非指针的普通类型表达式调用虚函数时,在编译时就会把调用的版本确定下来。

OOP核心思想是多态性(polymorphism)。多态性这个词源自希腊语,含义是“多种形式”。

将具有继承关系的多个类型称为多态类型,因为能用这些类型的“多种形式”而无须在意它们之间的差异。引用或指针的静态类型与动态类型不同这一事实正是C++语言支持多态性的根本所在。

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

派生类覆盖虚函数后可以再用virtual关键字指出该函数的性质(非必须)。某个函数是虚函数,则在所有派生类中都是虚函数。

派生类函数覆盖的虚函数,形参列表必须与被覆盖的基类函数完全一致。唯一的例外是,如果虚函数返回类型是类本身的指针或者引用时,该规则无效。

基类中的虚函数在派生类中隐含地也是个虚函数。如果派生类覆盖了某个虚函数,那么这个函数在基类地形参必须和派生类一样。

final和override说明符

派生类如果定义了一个和基类中虚函数名字相同但是形参不同,这也是合法行为,但是编译器会认为这个函数和基类原有的函数相互独立。也就是没有覆盖基类的版本。但就实际的编程习惯来说,这种声明一般都是错的。

C++11可以用override来说明派生类中的虚函数。表示让程序员意图更加清晰并且希望编译器帮助我们发现一些错误。

如果用override标记某个函数,但该函数没有覆盖已存在的虚函数,这时候编译器就会报错。

struct B {
    virtual void f1(int) const;
    virtual void f2();
    void f3();
}

struct D1 : B {
    void f1(int) const override;		// 正确 与B中的f1匹配
    void f2(int) override;				// 错误 B没有形如f2(int)的函数
    void f3() override;					// 错误 f3不是虚函数
    void f4() override;					// 错误 甚至没有f4函数
}

还能够将某个函数指定为final,此后任何尝试覆盖该函数的操作都会引发错误:

struct D2 : B {
    // 从B继承f2和f3, 覆盖f1(int)
    void f1(int) const final;			// 不允许此类的派生类覆盖f1(int)
};

struct D3 : D2 {
    void f2();			// 正确 覆盖从间接基类B继承而来的f2
    void f1(int) const;	// 错误 f1不可覆盖
}

虚函数也可以有默认实参。如果某次函数调用使用了默认实参,那么这个实参值由本次调用的静态类型决定。

如果虚函数使用默认上次按,那么基类和派生类里定义的默认实参最好一样。

回避虚函数的机制

有时候想要对虚函数的调用不要动态绑定,而是强迫其执行某个特定版本。使用作用域运算符可以实现这一目的:

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

表示强行调用Quotenet_price函数,而不管baseP实际指向的对象类型到底是什么。该调用会在编译时候完成解析。

通常只有成员函数/友元中的代码才需要使用作用域运算符来回避虚函数的机制,或是当一个派生类的虚函数调用它覆盖的基类的虚函数版本时。这种情况下,基类版本通常完成继承层次中所有类型都要做的共同任务,而派生类中定义的版本需要执行一些与派生类本身密切相关的操作。

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

抽象基类

纯虚函数

纯虚(pure virtual)函数无须定义。通过在函数体的声明语句的分号前加=0就可以把一个虚函数说明为纯虚函数。

=0只能出现在类内部的虚函数声明语句处:

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;           // 折扣适用的购买量
    double discount = 0.0;              // 表示折扣的小数值
};

我们不能直接定义该类对象,但是该类的派生类构造函数会使用Disc_quote的构造函数来构建各个派生类对象的Disc_quote部分。

也可以给纯虚函数提供定义,但是函数体必须在类外,不能再类内为一个=0的函数提供函数体。

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

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

抽象基类负责定义接口,后继的其他类可以覆盖该接口。

不能直接创建一个抽象基类对象。

可以定义抽象基类的派生类的对象,前提是这些类覆盖了纯虚函数。

// Disc_price声明了抽象基类 而Bulk_quote将覆盖该函数
Disc_quote discounted;			// 错误 不能定义Disc_quote类对象
Bulk_quote bulk;				// 正确 Bulk_quote里没有纯虚函数

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

重新实现Bulk_quote,让其继承Disc_quote而非直接继承Quote

// 当同一书籍的销售量超过某个值时启用折扣
// 折扣的值是个小于1的正的小数值 以此来降低正常销售价格
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) { }
    // 覆盖基类中的函数版本以实现一种新的折扣策略
    double net_price(std::size_t) const override;
};

该版本的Bulk_qutoe的直接基类是Disc_quote,间接基类是Quote

每个Bulk_quote对象包含三个对象:

  • 空的Bulk_quote部分
  • Disc_quote子对象
  • Quote子对象

每个类各自控制对象的初始化过程。

重构

Quote的继承体系中增加Disc_quote类是重构(refactoring)的一个典型示例。重构负责重新设计类的体系以便将操作和/或数据从一个类移动到另一个类中。对于面向对象的应用程序来说,重构是一种很普遍的现象。

访问控制与继承

每个类除了控制自己的成员初始化过程还控制成员对于派生类是否可访问(accessible)。

受保护的成员

使用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默认是私有
};

// 正确 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; }

公有和私有和受保护继承

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

  1. 基类中该成员的访问说明符
  2. 派生类的派生列表中的访问说明符
class Base {
public:
    void pub_mem;			// 公有成员
protected:
    int prot_mem;			// 受保护成员
private:
    char priv_mem;			// 私有成员
};

struct Pub_Derv : public Base {
    // 正确 派生类能够访问protected成员
    int f() { return prot_mem; }
    // 错误 private成员对于派生类不可访问
    char g() { return priv_mem; }
};

struct Priv_Derv : private Base {
    // private不影响派生类的访问权限
    int f1() const { return prot_mem; }
};

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

派生访问说明符的目的是控制派生类用户对于基类成员的访问权限:

Pub_Derv d1;			// 继承自Base的成员是public的
Priv_Derv d2;			// 继承自Base的成员是private的 对Priv_Derv来说,所有继承自Base的成员都是私有的
d1.pub_mem();			// 正确 pub_mem在派生类中是public的
d2.pub_mem();			// 错误 pub_mem在派生类中是private的

派生类转基类的可访问性(假设D继承B)

  • 只有D公有地继承B时,用户代码才能用派生转基。
  • 不论D怎么继承BD的成员函数和友元都能用派生转基。
  • 如果D继承B的方式是公有或受保护,那么D的派生类的成员和友元都可以用DB

对于代码里的某个给定节点来说,如果基类的公有成员是可访问的,那么派生类向基类的类型转换也是可访问的。

类的设计与受保护的成员

不考虑继承,可以认为一个类有两种不同用户:

  1. 普通用户:编写的代码使用类对象,这部分代码只能访问类的公有成员。
  2. 类的实现者:负责编写类的成员和友元代码,成员和友元既能访问类的公有部分,也能访问类的私有部分。

进一步考虑继承就有第三种用户:派生类。基类将希望派生类能够访问的部分声明成受保护的。普通用户不能访问受保护成员,派生类及其友元依旧不能访问私有成员。

基类应该将接口成员声明为公有,将实现的部分分成两组:

  1. 可供派生类访问(受保护的)
  2. 由基类及基类的友元访问(私有的)

友元与继承

派生类友元不该随意访问基类成员:

class Base {
    // 添加friend声明 其它与之前版本一致
    friend class pal;			// Pal在访问Base的派生类时候不具特殊性
};

class Pal {
public:
    int f(Base b) { return b.prot_mem; }		// 正确 Pal是Base的友元
    int f2(Sneaky s) { return s.j; }			// 错误 Pal不是Sneaky的友元
    // 对基类的访问权限由基类本身控制 即使对于派生类的基类部分也是如此
    int f3(Sneaky s) { return s.prot_mem; }		// 正确 Pal是Base的友元
}

PalBase的友元,所以Pal能够访问Base对象的成员,这种可访问性包括了Base对象内嵌在其派生类对象的情况。

友元关系不能被继承,所以如果有个类继承了Pal,但那个类并不能访问Base.prot_mem

改变个别成员的可访问性

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

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

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

因为Derived用了私有继承,所以继承而来的成员sizen(在默认情况下)是Derived的私有成员。但是之后用using声明语句改变了这些成员的可访问性。改变之后,Derived的所有用户都可以用size成员,而Derived的派生类都能使用n

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

默认的继承保护级别

默认派生运算符也由定义派生类所用的关键字来决定。

  • 使用class关键字定义的派生类默认是私有继承。
  • 使用struct关键字定义的派生类默认是公有继承。
class Base { ... };
struct D1 : Base { ... };				// 默认public继承
class D2 : Base { ... };				// 默认private继承

classstruct定义的类的唯二区别:

  1. 默认成员访问说明符
  2. 默认派生访问说明符

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

继承中的类作用域

派生类的作用域嵌套在基类的作用域里。如果一个名字在派生类的作用域里无法正确解析,则编译器将继续在外层的基类作用域里寻找该名字的定义。

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

派生类也可以重用定义在基类或间接基类里的名字,此时定义在内层作用域(派生类)的名字会隐藏定义在外层作用域(基类)的名字:

struct Base {
    Base(): mem(0) { }
protected:
    int mem;
};

struct Derived : Base {
    Derived(int i): mem(i) { }		// 用i初始化Derived::mem
    								// Base::mem进行默认初始化
    int get_mem() { return mem; }	// 返回Derived::mem
protected:
    int mem;						// 隐藏基类里的mem
};

派生类的成员会隐藏同名的基类成员。即使派生类成员和基类成员的形参不一样,基类成员也会被隐藏。

但也可以用作用域运算符来使用一个被隐藏的基类成员。

struct Derived : Base {
    int get_base_mem() { return Base::mem; }
    ...
}

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

假定调用obj.mem(),实际执行步骤:

  1. 先确定obj的静态类型。
  2. obj的静态类型对应类中找mem。找不到就去基类找,再找不到就去间接基类找,如果都找不到就报错。
  3. 找到了mem就进行常规类型检查以确认对于当前找到的mem,本次调用是否合法。
  4. 假设调用合法,则编译器根据调用的是否是虚函数而产生不同的代码:
    • 如果mem是虚函数且通过引用或指针调用,则比编译器产生的代码会在运行时确定到底运行该函数的哪个版本,依据是对象的动态类型。
    • mem不是虚函数或我们是通过对象进行的调用,则编译器将产生一个常规函数调用。

如果基类和派生类的虚函数接受参数不同,就无法通过基类的引用或指针调用派生类的虚函数了:

class Base {
public:
    virtual int fcn();
};

class D1 : public Base {
public:
    // 隐藏基类的fcn 该fcn不是虚函数
    // D1继承Base::fcn()的定义
    int fcn(int);			// 形参列表和Base里的fcn不一样
    virtual void f2();		// 是个新的虚函数 Base里不存在
};

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

D1fcn函数没有覆盖Base的虚函数fcn,因为它们形参列表不同。于是D1实际上有两个fcn函数:

  • 一个是继承来的虚函数
  • 一个是自定义的接受int的成员函数

通过基类调用被隐藏的虚函数:

Base bobj;
D1 d1obj;
D2 d2obj;

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

D1 *d1p = &d1obj;
D2 *d2p = &d2obj;
dp2->f2();				// 错误 Base没有f2成员
d1p->f2();				// 虚调用 运行时调用D1::f2()
d2p->f2();				// 虚调用 运行时调用D2::f2()

派生类可以覆盖重载函数的0个或多个实例。如果派生类希望所有的重载版本都对它可见,那么它就需要覆盖所有的版本,或者一个也不覆盖。

有时候一个类只需要覆盖重载集合里的一些而不是全部,这时候又不得不覆盖基类里的每个版本,操作就很繁琐。

一个好的解决方案是给重载的成员提供一条using声明语句,这样就不用覆盖基类的每一个重载版本了1using声明语句指定一个名字而不指定形参列表,所以一条基类成员函数的using声明语句就可以把所有该函数的所有重载实例添加到派生类作用域中。这时候派生类只需要负责定义需要的版本就行了。

构造函数与拷贝控制

虚析构函数

如果指针指向继承体系里的某个类型,就有可能出现指针的静态类型与被删除对象的动态类型不符的情况。

我们在基类里把析构函数定义成虚函数以确保执行正确的析构函数版本:

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


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

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

基类的析构函数不遵循一个类同时需要析构函数、拷贝、赋值操作。

一个基类总是需要析构函数,而且它能将析构函数设定为虚函数。这时这个析构函数只是为了成为虚函数而让内容成空,所以我们无法由此推断这个基类是否还需要赋值运算符或拷贝构造函数。

虚析构函数会组织合成移动操作。

合成拷贝控制与继承

基类或派生类的合成拷贝控制成员对类的成员依次进行初始化、赋值、销毁操作。

无论基类成员是合成的版本还是自定义版本,唯一的要求是相应的成员可以访问并且不是个被删除的函数。

Quote因为定义了析构函数而不能拥有合成的移动操作,因此当我们移动Quote对象时实际用的是合成的拷贝操作。

class B {
puclic:
    B();
    B(const B&) = delete;
    ... // 不包含移动构造函数
};

class D : public B {
    // 没有声明任何构造函数
};

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

如果B的派生类希望它自己的对象能够被移动或者拷贝,那么派生类需要自定义相应版本的构造函数。

大多基类会定义一个虚析构函数,这就导致基类通常不含有合成的移动操作,这也就意味着派生类也没有合成的移动操作。

当我们确实需要执行移动操作的时候应该首先在基类里定义。Quote可以使用合成的版本,但前提是显式定义。

如果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;
    // 其他成员与之前一致
}

如此之后,Quote对象就能拷贝、移动、赋值、销毁操作。除非Quote的派生类中含有排斥移动的成员,否则将自动获得合成的移动操作。

派生类的拷贝控制成员

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

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

派生类定义拷贝或移动构造函数时,通常应该用对应的基类构造函数初始化对象的基类部分:

class Base { ... };

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

实际环境中Base(d)一般会匹配Base的拷贝构造函数。D类型的对象d会被绑定到该构造函数的Base&形参上。Base的拷贝构造函数负责把d的基类部分拷贝给要创建的对象。

如果没有提供基类初始值:

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

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

派生类赋值运算符

派生类地赋值运算符必须显式地给基类部分赋值:

// Base::operator=(const Base&) 不会被自动调用
D &D::operator=(const D &rhs)
{
    Base::operator=(rhs);		// 基类部分赋值
    // 按照过去地方式给派生类成员赋值
    // 酌情处理自赋值以及释放已有资源等情况
    return *this;
}

派生类析构函数

对象的基类部分也是隐式销毁。派生类析构函数只负责销毁由派生类自己分配的资源:

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

先执行派生类的析构函数,然后才是基类的析构函数。

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

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

假如B继承了A,那么B在构造的时候,首先构造A的部分,在构造A的时候,如果要用某个虚函数,它调用的应该是A的虚函数,因为此时B还没开始构造,甚至还没开始构造B

继承的构造函数

C++11后,派生类能够重用其直接基类定义的构造函数。类只初始化它的直接基类,同样原因,类只继承直接基类的构造函数,不能继承默认、拷贝、移动构造函数。若派生类没有直接定义这些构造函数,则由编译器合成。

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

示例重新定义Bulk_quote

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

通常using声明语句只是让某个名字在当前作用域内可见。但是作用在构造函数的时候,using声明语句会令编译器产生代码。对基类的每个构造函数,编译器都生成一个与之对应的派生类构造函数。

也就是编译器会在派生类里生成形参列表和基类里每个构造函数完全相同的构造函数。

形如:

derived(parms) : base(args) { }
  • derived:派生类名字
  • base:基类名字
  • parms:构造函数形参列表
  • args派生类构造函数的形参传递给基类的构造函数

等价:

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

若派生类有自己的数据成员,那这些成员会被默认初始化。

继承的构造函数的特点

构造函数using不会改变该构造函数的访问级别。

using声明语句不能指定explicitconstexpr。如果基类的构造函数是它们两个的话,那么继承的构造函数也拥有相同属性。

基类构造函数的默认实参不会被继承。派生类会得到多个继承的构造函数,每个构造函数分别省略一个含有默认实参的形参。假如基类有两个形参的构造函数,第二个形参有默认实参,那么派生类会有两个构造函数:

  1. 一个构造函数接受两个形参
  2. 另一个构造函数接受一个形参(对应基类有默认实参那个版本)

如果基类有多个构造函数,那除了两个例外情况,大多派生类会继承这些构造函数。

例外情况:

  1. 派生类继承一部分构造函数,其他构造函数定义自己的版本。派生类定义的构造函数与基类的构造函数有同样参数列表,则替换继承来的构造函数。
  2. 默认、拷贝、移动构造函数不会被继承。这些构造函数按照正常规则被合成。继承的构造函数不会被作为用户定义的构造函数来使用。所以如果一个类只有继承的构造函数,那么它也拥有一个合成的默认构造函数。

容器与继承

容器存放继承体系的对象时,通常得用采用间接存储的方式。因为容器里不能存放不同类型的元素。

基类和派生类终究有不同之处,派生类容器不能存储基类。基类容器存储派生类后,派生类元素也就不是派生类了(派生部分将被忽略)。

vector<Quote> basket;
basket.push_back(Quote("0-201-82470-1", 50));

// 正确 但是只能把对象的Quote部分拷贝给basket
basket.push_back(Bulk("0-201-54848-8", 50, 10, .25));

// 调用Quote定义的版本 打印750 也就是15 * 50
cout << basket.back().net_price(15) << endl;

当希望在容器中存放具有继承关系的对象时,通常放的是基类指针或智能指针。这些指针所指对象的动态类型可能是基类类型,也可能是派生类型:

vector<shared_ptr<Quote>> basket;

basket.push_back(make_shared<Quote>("0-201-82470-1", 50));
basket.push_back(make_shared<Bulk_quote>("0-201-54848-8", 50, 10, .25));

// 调用Quote定义的版本 打印562.5
cout < basket.back()->net_price(15) << endl;

也能把一个派生类的智能指针转成基类的智能指针。此例中,make_shared<Bulk_quote>返回一个shared_ptr<Bulk_quote>对象,调用push_back时该对象被转换成shared_ptr<Quote>。因此虽然在形式上有所差别,但实际上basket的所有元素的类型都是相同的。

编写Basket类

C++面向对象编程悖论:无法直接使用对象进行面向对象编程。必须用指针和引用。

由于指针会增加程序的复杂性,所以经常需要定义一些辅助类来处理这种复杂情况。

class Basket {
public:
    // Basket使用合成的默认构造函数和拷贝控制成员
    void add_item(const std::shared_ptr<Quote> &sale) { items.insert(sale); }
    
    // 打印每本书的总价和购物篮中所有书的总价
    double total_receipt(std::ostream&) const;

private:
    // 用于比较shared_ptr, multiset成员常用到它
    static bool compare(
        const std::shared_ptr<Quote> &lhs,
        const std::shared_ptr<Quote> &rhs
    )	{ return lhs->isbn() < rhs->isbn(); }
    
    // 保存多个报价 按照compare成员排序
    std::multiset<std:shared_ptr<Quote>, decltype(compare)*> items(compare);
};
  • 局部静态对象compare将接受两个指向Quote类的参数,并返回它们将的大小关系。
  • items则是个重复集合成员,存放指向Quote对象的shared_ptr。这个multiset将用一个与compare成员类型相同的函数来对其中的元素进行排序。

定义Basket的成员

该类只有两个操作:

  1. add_item:接受一个指向Quoteshared_ptr,将该share_ptr添加到multiset中。
  2. total_receipt:负责将购物篮内容逐项打印成清单,然后返回购物篮中所有物品的总价格。
double Basket::total_receipt(ostream &os) const
{
    double sum = 0.0;			// 保存实时计算出的总价格
    // item指向ISBN相同的一批元素中的第一个
    // upper_bound返回一个迭代器 迭代器指向这批元素的尾后位置
    for (auto iter = items.cbegin(); iter != items.cend(); iter = items.upper_bound(*iter)) {
        // 已知当前Basket内至少有个该关键词的元素
        // 打印该书籍对应的项目
        sum += print_total(os, **iter, items.count(*iter));
    }
    os << "Total Sale: " << sum << endl;		// 打印最终的总价格
    return sum;
}

upper_bound:令我们跳过与当前关键字相同的所有元素,返回一个指向与当前关键字最后一个相同元素的下一个位置的迭代器。所以这里要么指向下一本书,要么是集合末尾。

for循环内部,调用print_total来打印购物篮中每本书的细节。

解引用iter后会得到一个指向准备打印的对象shared_ptr,为得到该对象,需要解引用两次,才能得到一个要么是Quote要么是Bulk_quote对象。然后再用multisetcount成员来统计在multiset中有多少元素的键值相同。

print_total打印并返回给定书的总价格,再将该结果添加到sum里。循环结束后打印sum

隐藏指针

Basket的用户还是得处理动态内存,因为add_item需要接受一个shared_ptr参数。所以用户不得不:

Basket bsk;
bsk.add_item(make_shared<Quote>("123", 45));
bsk.add_item(make_shared<Bulk_quote>("345", 45, 3, .15));

所以要重定义add_item,使之接受一个Quote对象而非shared_ptr。新版本的add_item将负责处理内存分配,这样用户就不用受困于此。

void add_item(const Quote& sale);			// 拷贝给定对象
void add_item(Quote&& sale);				// 移动给定对象

到此唯一问题是add_item不知道要分配的类型。

为解决add_item接受类型的问题,给Quote类添加一个虚函数,该函数将申请一份当前对象的拷贝。

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 {
    Bulk_quote* clone() const & { return new Bulk_quote(*this); }
    Bulk_quote* clone() && { return new Bulk_quote(std::move(*this)); }
    ...
};

我们分别定义了clone的左值和右值版本。每个clone函数分配当前类型的一个新对象。左值版本将自己拷贝给新分配的对象,右值版本将自己移动到新数据。

之后再重新定义add_item,使其调用对象的clone版本。

class Basket {
public:
    // 拷贝给定的对象
    void add_item(const Quote& sale) { 
        items.insert(std::shared_ptr<Quote>(sale.clone()));
    }
    
    // 移动给定对象
    void add_item(Quote&& sale) {
        items.insert(std::shared_ptr<Quote>(std::move(sale).clone()));
    }
    ...
};

公有派生类的对象应该可以用再任何需要基类对象的地方。

Footnotes

  1. 参考访问控制与继承->改变个别成员的可访问性