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

面向对象程序设计

继承和动态绑定对程序的编写有两份方面的影响:定义与其他类相似但是不完全相同的类;使用彼此相似的类编写程序时候,可以在一定程度上忽略掉他们的区别。


1 OOP:概述

OOP(面向程序的设计)核心思想数据抽象(将类的接口和实现分离),继承(定义相似的类型并对其相似关系进行建模),动态绑定(可以在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象)。

继承

通过 继承 联系在一起的类构成了一种层次关系。层次关系的根部叫做 基类,其他类则直接或者间接从基类中继承来,通过继承得到的类叫做 派生类。基类负责定义在层次关系中所有类共同具备的成员,而每一个派生类则会定义各自特有的属性。

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

// 基类
class Quote {
public:
	std::string isbn() const;
	virtual double new_price(std::size_t n) const;
};
// 派生类
class Bulk_queto : public Quote {  // Bulk_queto 继承了 Quote
public:
	double new_price(std::size_t) const override;
};

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

派生类必须在其内部对于所有重新定义的虚函数进行声明

动态绑定

使用 动态绑定 可以使用同一段代码分别处理基类和派生类。

double print_total(ostream &os, const Quote &item, size_t n) { 
    // 根据传入 item 的形参的对象类型调用 Quote::net_price
    // 或者使用 Bulk_quote::net_price
    double ret = item.net_price(n);
    os << "ISBN: " << item.isbn() << " # sold: " << n << "total due :" << ret << endl;
    return ret;
}

print_total 的 item 形参是基类的一个引用,所以我们既能使用基类的对象调用该函数,也能使用派生类的对象调用它

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

上述过程中函数的运行版本由实参决定,即在运行时选择函数的版本,所以动态绑定有时又被称为 运行时绑定
注: 在 C++ 中,当我们使用基类的引用(或指针)调用一个虚函数时将发生动态绑定。


2 定义基类和派生类

2.1 定义基类

class Quote{
public:
    Quote() = default;
    Quote(const string &book, double sales_price):
	    bookNo(book), price(sales_price) { }
    string  isbn() const { return bookNo };
    // 返回给定数量的书籍的销售总额
    // 派生类负责改写并且使用不同的折扣计算方法
    virtual double net_price(size_t n) const {
        return n * price;
    }
    virtual ~Quote() = default;  // 对于析构函数进行动态绑定
private:
    string bookNo;  // 书籍的 ISBN 序列号
protected:
    double price = 0.0;  // 代表普通状态下不打折的价格
};

继承关系中根节点的类通常会定义一个虚析构函数,即使该函数不执行任何实际操作也需要定义析构函数。

成员函数与继承

基类需要将两种成员函数区别开来:

  1. 基类希望派生类进行覆盖的函数;
  2. 基类需要派生类直接继承但是不要改变的函数。

对于前者,基类通常将其定义为 虚函数,在使用指针或者引用调用虚拟函数的时候,该调用将会动态绑定。根据引用或者指针所绑定的对象不同,该引用可能会执行基类或者执行某个派生类。

任何 构造函数之外非静态函数都可以是虚函数。关键字 virtual 只可以出现在类内部的声明之前而不能用于对于类外部的函数的定义。对于基类声明的虚函数,则该函数在派生类中 隐式地也是虚函数

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

访问控制与继承

派生类可以继承定义在基类中的成员,但是派生类成员不一定有访问从基类继承而来的成员。派生类可以访问公有成员,但是不能访问私有成员。

存在这样一种成员,基类希望它的派生类有权访问该成员,同时禁止其他用户访问。我们用 受保护的 访问运算符 protected 说明这样的成员。

2.2 定义派生类

派生类必须通过 类派生列表 明确指出它是从哪个(些)基类派生而来的。类派生的形式是:首先一个分号,后面紧跟着以逗号分隔的基类列表,其中每个基类的前面都可以有访问说明符之一,比如 public、protected 和 private。

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

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

大多数类都继承自一个类,这种机制叫做 单继承

派生类中的虚函数

派生类经常(但不是总是)覆盖定义它继承的虚函数,如果派生类中没有覆盖基类中的缪一个虚函数,派生类会直接继承自基类中的对于虚函数的定义

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

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

一个派生类对象包含多个组成部分:一个含有派生类自己定义的(非静态)成员的子对象,以及一个与派生类继承的基类对应的子对象。
注: 继承自基类的部分和派生类自定义的部分不一定是连续存储的。

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

Quote item;  // 基类对象
Bulk_quote bulk;  // 派生类对象
Quote *p = &item;  // p 指向 item(Quote) 对象
p = &bulk;  // p 指向 bulk(Bulk_quote) 对象的 Quote 部分
Quote *r = &bulk;  // r 绑定到 bulk(Bulk_quote) 对象的 Quote 部分

这种转换通常称为 派生类到基类的 类型转换,编译器会隐式的执行派生类到基类的转换。

这种隐式特性意味着,我们可以将把派生类对象或者派生类对象的引用,用在需要基类引用的地方。同样可以将派生类对象的指针用在需要基类指针的地方。

派生类构造函数

尽管派生类对象中含有从基类继承而来的成员,但是派生类不可以直接初始化这些成员,派生类必须使用基类的构造函数来初始化它的基类部分。
注: 每个类控制它自己的成员初始化过程。

派生类对象的基类部分与派生类对象的自己的数据成员都是在构造函数的初始化阶段执行初始化操作的。

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

除非我们特定指出,否则派生类对象的基类部分会像数据成员一样被默认初始化。但是如果想使用其他的基类构造函数,需要以类名加圆括号的实参列表的形式为构造函数提供初始化数值。这些实参会帮助编译器决定使用哪个构造函数来初始化派生类对象的基类部分。
注: 派生类继承的成员交由基类进行初始化,首先初始化基类的部分,然后按照声明的顺序一次初始化派生类的成员。

派生类使用基类的成员

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

关键概念: 遵循基类的接口
每个类负责定义各自的接口,想要与类的对象交互必须使用该类的接口,即使这个对象是派生类的基类部分也是如此。
因此,派生类对象不能直接初始化基类的成员。尽管从语法上来说我们可以在派生类构造函数体内给它的共有或受保护的基类成员赋值,但最好不要这么做。派生类应该遵循基类的接口,并且通过调用基类的构造函数来初始化那些从基类继承来的成员。

继承与静态成员

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

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

派生类的声明

派生类的声明需要包含类名但是不包含他的派生列表。

class Bulk_quote: public Quote;  // 错误,派生列表不能出现在这里
class Bulk_quote;  // 正确

一条声明语句的目的是令程序知晓某个名字的存在以及改名字表示一个什么样的实体,如一个类、一个函数或一个变量。派生列表以及与定义有关的其他细节必须与类的主体一起出现。

被用作基类的类

如果想将某一个类用作基类,则该类必须已经定义而非仅仅声明:

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

派生类包含并且可以使用它从基类继承而来的成员,为了使用这些成员,派生类当然要知道它是什么。因此该规定还有一层隐含的意思,一个类不可以派生它本身

一个类是基类,同时它也可以是一个派生类。

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

在这个继承关系中,Base 是 D1 的 直接基类,同时也是 D2 的 间接基类

防止继承的发生

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

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

2.3 类型转换与继承

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

静态类型与动态类型

表达式的静态类型在编译时总是已知的,它是变量声明时的类型或表达式生成的类型;动态类型则是变量或表达式表示内存中的对象的类型,动态类型直到运行时才知。

如果表达式既不是引用也不是指针,则它的动态类型永远与静态类型保持一致。例如,Quote 类型的变量永远都是一个 Quote 对象,我们无论如何都不能改变改变量对应的对象的类型。

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

之所以存在派生类向基类的类型转换是因为每个派生类对象都包含一个基类部分,而基类的引用或指针可以绑定到该基类部分上。一个基类的对象既可以以独立的形式存在,也可以作为派生类对象的一部分存在。

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

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

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

即使一个基类指针或引用绑定在一个派生类对象上,我们也不能执行从基类向派生类的转换:

Bulk_quote bulk;
Quote *itemP = &bulk;  // 正确 动态类型时 Bulk_quote
Bulk_quote *bulkP = itemP;  // 错误 不能将基类转换成派生类
对象之间不存在类型转换

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

派生类向基类的转换允许我们给基类的拷贝/移动操作传递要给派生类的对象。这些操作不是虚函数。当我们给积累的构造函数传递一个派生类对象时,实际运行的构造函数时基类中定义的那个,显然该构造函数只能处理基类自己的成员。类似的,如果我们将一个派生类对象赋值给一个基类对象,则实际运行的赋值运算符也是基类中定义的那个,该运算符同样只能处理基类自己的成员。

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

上述过程会忽略 Bulk_quote 独有的部分,所以我们可以说 bulk 的 Bulk_quote 部分被切掉了。
注: 当我们用一个派生类对象为一个基类对象初始化或赋值时,只有该派生类对象中的基类部分会被拷贝、移动或赋值,它的派生类部分将被忽略掉。


3 虚函数

在 C++ 语言中,当我们使用基类的引用(或指针)调用一个虚函数时将发生动态绑定。因为我们直到运行时才能知道到底调用了哪个版本的虚函数,所以所有虚函数都必须有定义。通常情况下,如果我们不适用某个函数,则无须为该函数提供定义。但是我们必须为每一个虚函数都提供定义,而不管它是否被用到了,这是因为连编译器也无法确定到底会用哪个虚函数。
注: 当且仅当对通过指针或引用调用虚函数时,才会在运行时解析该调用,也只有在这种情况下对象的动态类型才有可能与静态类型不同。

派生类中的虚函数

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

一个派生类的函数如果覆盖了某个继承而来的虚函数,则它的形参类型必须与被它覆盖的基类函数完全一致。同样,派生类中虚函数的返回类型也必须与基类函数匹配,但是当类的虚函数返回类型是类本身的指针或引用时,上述规则无效。也就是说,如果 D 由 B 派生得到,则基类的虚函数可以返回 B* 而派生类的对性函数可以返回 D*,只不过这一的返回类型要求从 D 到 B 的类型转换时可访问的。
注: 基类中的虚函数在派生类中隐含地也是一个虚函数。当派生类覆盖了某个虚函数时,该函数在基类中的形参必须与派生类中的形参严格匹配。

final 和 override 说明符

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

C++ 新标准中使用 override 关键字来说明派生类中的虚函数,这样做的好处是在使得程序员的意图更加清晰的同时让编译器为我们发现一些错误。如果我们使用 override 标记了某个函数,但该函数并没有覆盖已存在的虚函数,此时编译器将报错。

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

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

虚函数与默认实参

和其他函数一样,虚函数也拥有默认实参。如果某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定。
注: 如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。

回避虚函数的机制

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

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

该调用将在编译时完成解析。
注: 通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制。

什么时候我们需要回避虚函数的默认机制呢?通常是当一个派生类的虚函数调用它覆盖的基类的徐函数版本时。在此情况下,基类的版本通常完成继承层次中所有类型都要做的共同任务,而派生类中定义的版本需要执行一些与派生类本身密切相关的操作。
注: 如果一个派生类虚函数需要调用它的基类版本,但是没有使用作用域运算符,则在运行时该调用将被解析为对派生类版本自身的调用,从而导致无限递归。


4 抽象基类

纯虚函数

和普通的虚函数不一样,一个纯虚函数无序定义。我们通过在函数体的位置(即在声明语句的分号之前)书写 =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(bookm price), quantity(qty), discount(disc) { }
	double net_price(std::size_t) const = 0;
protected:
	std::size_t quantity = 0;  // 折扣适用的购买量
	double discount = 0.0;  // 表示折扣的小数值
};

我们也可以为纯虚函数提供定义,不过函数体必须定义在类的外部。也就是说,我们不能再类的内部为一个 =0 的函数提供函数体。

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

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

// Disc_quote 声明了纯虚函数
Disc_quote discounted;  // 错误 不能定义 Disc_quote 的对象

注: 我们不能创建抽象基类的对象。

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

5 访问控制与继承

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

受保护的成员

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

  • 和私有成员类似,受保护的成员对于类的用户来说是不可访问的。
  • 和公有成员类似,受保护的成员对于派生类的成员和友元来说是可访问的。
  • 派生类的成员或友元只能访问派生类对象中的基类部分的受保护成员;对于普通的基类对象中的成员不具有特殊的访问权限。

对于最后一条规则,考虑如下例子:

class Base {
protected:
    int pro_mem;  // protected 成员
};

class Sneaky : public Base {
    friend void clobber(Sneaky&);  // 能访问 Sneaky::pro_mem
    friend void clobber(Base&);  // 不能访问 Base::pro_mem
    int j;  // j 默认是 private 的
};
// 正确 clobber 能访问 Sneaky 对象的 private 和 protected 成员
void clobber(Sneaky &s) {
    s.j = s.pro_mem = 0;
}
// 错误 clobber 不能访问 Base 对象的 protected 成员
void clobber(Base &b) {
    b.pro_mem = 0;
}

如果派生类(及其友元)能访问基类对象的受保护成员,则上面第二个 clobber 将是合法的。

该函数不是 Base 的友元,但是它任然能够改变一个 Base 对象的内容。如果按照这样的思路,则我们只要顶一个一个形如 Sneaky 的新类就能非常简单地规避掉 protected 提供的访问保护了。

要想阻止以上用法,我们就要做出如下规定,即派生类的成员和友元只能访问派生类对象中的基类部分的受保护成员;对于普通的基类对象中的成员不具有特殊访问权限。

公有、私有和受保护继承

某个类对其继承而来的成员的访问权限受到两个因素影响:一是在基类中该成员的访问说明符,二是在派生类的派生列表中的访问说明符。

class Base {
public:
	void pub_mem;
protected:
	void pro_mem;
private:
	void 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() { return prot_mem; }
};

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

派生类说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限。

Pub_Derv d1;
Priv_Derv d2;
d1.pub_mem();  // 正确 pub_mem 在派生类中是 public 的
d2.pub_mem();  // 错误 pub_mem 在派生类中是 private 的

如果继承是公有的,则成员将遵循其原有的访问说明符。

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

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

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

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

改变个别成员的可访问性

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

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

class Derrived : private Base {  // 注意 private 继承
public:
	using Base::size;
protected:
	using Base::n;
};

因为继承是 private 的,所以继承而来的成员 size 和 n (在默认情况下)是 Derived 的私有成员。
注: 派生类只能为那些它可以访问的名字提供 using 声明。

默认的继承保护级别

struct 和 class 关键字定义类有不同的默认访问说明符(struct 是 public,class 是 private)。类似的,默认派生运算符也由定义派生类所用的关键字来决定。默认情况下,使用 class 关键字的派生类是私有继承的;使用 struct 关键字定义的派生类是公有继承的。
注: 使用 class 和 struct 定义类唯一的区别就是默认的访问权限(默认成员访问说明符及默认派生访问说明符)。


6 继承中的类作用域

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

在编译时进行名字查找

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

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

我们只能通过 Disc_quote 及其派生类的对象、引用或指针使用 discount_policy:

Bulk_quote bulk;
Bulk_quote *bulkP = &bulk;  // 静态类型与动态类型一致
Quote *itemP = &bulk;  // 静态类型与动态类型不一致
bulkP->discount_policy();  // 正确 bulk 的类型是 Bulk_quote*
itemP->discount_policy();  // 错误 itemP 的类型是 Quote*
名字冲突与继承

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

 struct Base { 
 Base() : mem(0) { }
 protected:
     int mem;
 };
 
 struct Derived : Base {
     Derived(int i) : mem(i) { }
     int get_mem() { return mem; }
  protected:
     int mem;  // 隐藏基类中的 mem
 };

派生类的成员将隐藏同名的基类成员。

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

我们可以通过使用作用域运算符来使用一个被隐藏的基类成员:

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

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

名字查找先于类型检查

声明在内层作用域的函数并不会重载声明在外层作用域的函数。因此,定义在派生类中的函数也不会重载其基类中的成员。如果派生类(即内层作用域)的成员与基类(即外层作用域)的某个成员同名,则派生类将在其作用域内隐藏掉该基类成员。即使派生类成员和基类成员的形参列表不一致,基类成员也仍然会被隐藏掉:

struct Base {
	int memfcn();
};

struct Derived : Base {
	int memfcn(int);  // 隐藏基类的 memfcn
};

Derived d;
Base b;
b.memfcn();  // 调用 Base::memfcn
d.memfcn(10);  // 调用 Derived::memfcn
d.memfcn();  // 错误 参数列表为空的 memfcn 被隐藏了
d.Base::memfcn();  // 正确 调用 Base::memfcn

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

虚函数与作用域

上面也说明了为什么基类与派生类中的虚函数必须有相同的形参列表了。加入积累与派生类的虚函数接受的实参不同,则我们就无法通过基类的引用或指针调用派生类的虚函数了。

覆盖重载的函数

和其他函数一样,成员函数无论是否是虚函数都能被重载。派生类可以覆盖重载函数的 0 个或多个实例。如果派生类希望所有的重载版本对于它来说都是可见的,那么它就需要覆盖所有的版本,或者一个也不覆盖。

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

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


7 构造函数与拷贝控制

7.1 虚析构函数

当我们 delete 一个动态分配的对象的指针时,将执行析构函数。如果该指针指向继承体系中的某个类型,则有可能出现指针的静态类型与被删除对象的动态类型不符的情况。

例如,如果我们通 delete 一个 Quote* 类型的指针,则该指针有可能实际上指向一个 Bulk_quote 类型的对象。这一的话,编译器就必须清楚它应该执行的时 Bulk_quote 的析构函数。我们通过在基类中将析构函数定义成虚函数以确保执行正确的析构函数版本:

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

和其他虚函数一样,析构函数的虚属性会被继承。因此,无论派生类使用合成的析构函数还是定义自己的析构函数,都将是虚函数。只要基类的析构函数是虚函数,就能确保当我们 delete 基类指针时将运行正确的析构函数版本。
注: 如果基类的析构函数不是虚函数,则 delete 一个指向派生类对象的基类指针将产生未定义的行为。

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

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

7.2 合成拷贝控制与继承

派生类中删除的拷贝控制与基类的关系

基类或派生类也能将其合成的默认构造函数哦或任何一个拷贝控制成员定义成被删除的函数。此外,某些定义基类的方式可能导致有的派生类成员称为被删除的函数:

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

举个例子:

class B {
public:
	B();
	B(const B&) = delete;
	// 其他成员,不含有移动构造函数
};
class D : public B {
	// 没有生命任何构造函数
};
D d;  // 正确 D 的合成默认构造函数使用 B 的默认构造函数
D d2(d);  // 错误 D 的合成拷贝构造函数是被删除的
D d3(std::move(d));  // 错误 隐式的使用 D 的被删除的拷贝构造函数
移动操作与继承

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

因为基类缺少移动操作会阻止派生类拥有自己的合成移动操作,所以当我们确实需要执行移动操作时应该首先在基类中定义。一旦定义了移动操作,就必须同时显式地定义拷贝操作:

class Qutoe {
publec:
	Quote() = default;  // 对成员依次进行默认初始化
	Quote(const Quote&) = default;  // 对成员依次拷贝
	Quote(Quote&&) = default;  // 对成员依次拷贝
	Quote& operator=(const Quote&) = default;  // 拷贝赋值
	Quote& operator=(Quote&&) = default;  // 移动赋值
	virtual ~Quote() = default;
};

7.3 派生类的拷贝控制成员

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

和构造函数及赋值运算符不同的是,析构函数只负责销毁派生类自己分配的资源。如前所述,对象的成员是被隐式销毁的;类似的,派生类的基类部分也是自动销毁的。
注: 当派生类定义了拷贝或移动操作时,该操作负责拷贝或移动包括基类部分成员在内的整个对象。

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

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

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

尽管从道理上说,Base 可以包含一个参数类型为 D 的构造函数,但是在实际编程过程中通常不会这么做。相反,Base(d) 一般会匹配 Base 的拷贝构造函数。D 类型的对象 d 将被绑定到该构造函数的 Base& 形参上。Base 的拷贝构造函数负责将 d 的基类部分拷贝给要创建的对象。
注: 在默认情况下,基类默认构造函数初始化派生类对象的基类部分。如果我们想拷贝(或移动)基类部分,则必须在派生类的构造函数初始值列表中显式地使用基类的拷贝(或移动)构造函数。

派生类赋值运算符

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

// Base::operator=(const Base&) 不会被自动调用
D &D::operator(const D &ths) {
	Base::=(ths);  // 为基类部分赋值
	return *this;
};
派生类析构函数

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

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

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

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

7.4 继承的构造函数

在 C++11 新标准中,派生类能够重用其直接基类定义的构造函数。尽管,这些构造函数并非以常规的方式继承而来。一个类值初始化它的直接基类,出于同样的原因,一个类也只继承其直接基类的构造函数。类不能继承默认、拷贝和移动构造函数。如果派生类没有直接定义这些构造函数,则编译器将为派生类合成它们。

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

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

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

这些编译器生成的构造函数形如:

derived(parms) : base(args) { }
继承的构造函数的特定

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

而且,一个 using 声明语句不能指定 explicit 或 constexpr。如果基类的构造函数时 explicit 或者 constexpr,则集成的构造函数也拥有相同的属性。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值