类设计者的工具(四):面向对象程序设计 (继承)

本文为《C++ Primer》的读书笔记

目录

面向对象程序设计(object-oriented programming) 基于三个核心思想:

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

之前的文章中已经介绍了数据抽象的知识, 本文将介绍继承和动态绑定

继承

  • 通过继承(inheritance) 联系在一起的类构成一种层次关系
  • 通常在层次关系的根部有一个基类(base class),基类负责定义在层次关系中所有类共同拥有的成员
  • 其他类则直接或间接地从基类继承而来, 这些继承得到的类称为派生类(derived class),每个派生类定义各自特有的成员

例如,对书店中图书的不同定价策略建模。我们首先定义一个名为Quote的基类。Quote的对象表示按原价销售的书籍。Quote 派生出另一个名为Bulk_quote的类, 它表示可以打折销售的书籍

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

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

  • 一个类是基类, 同时它也可以是一个派生类:
    • 在下面的继承关系中,BaseD1直接基类(direct base), 同时是D2间接基类(indirect base)。最终的派生类将包含它的直接基类的子对象以及每个间接基类的子对象
class Base { /* ... */ };
class D1: public Base { /* ... */ } ;
class D2: public D1 { /* ... */ };

基类

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;
protected:
	double price = 0.0;		// 代表普通状态下不打折的价格
};

虚函数

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

  • 一种是基类希望其派生类各自定义适合自身的版本,基类通常使用关键字virtual将其定义为虚函数(virtual function)
    • 任何构造函数之外的非静态函数都可以是虚函数
    • 关键字 virtual只能出现在类内部的声明语句之前 而不能用于类外部的函数定义
    • 如果基类把一个函数声明成虚函数, 则该函数在派生类中隐式地也是虚函数
    • 基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此
  • 另一种是基类希望派生类直接继承而不要改变的函数
class Quote {
public:
	virtual double net_price(std::size_t n) const;
	virtual ~Quote() = default; // 对析构函数进行动态绑定
	// ...
};

动态绑定 (dynamic binding)

  • 当我们使用指针或引用调用虚函数时, 该调用将被动态绑定
    • 根据引用或指针所绑定的对象类型不同, 该调用可能执行基类的版本, 也可能执行某个派生类的版本
    • 因为我们直到运行时才能知道到底调用了哪个版本的虚函数, 所以 所有虚函数都必须有定义, 而不管它是否被用到了
  • 成员函数如果没被声明为虚函数,则其解析过程发生在编译时而非运行时

因为在动态绑定过程中函数的运行版本由实参决定, 即在运行时选择函数的版本, 所以动态绑定有时又被称为运行时绑定(run-time binding)


  • 通过使用动态绑定, 我们能用同一段代码分别处理QuoteBulk_quote的对象
    • 例如下面的代码中,因为函数print_totalitem形参是基类Quote的一个引用, 所以我们既能使用基类Quote的对象调用该函数,也能使用派生类Bulk_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() // 调用Quote::isbn
		<< " # sold: " << n << " total due: " << ret << endl;
	return ret;
}

派生类

class Bulk_quote : public 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; // 以小数表示的折扣额
}
  • 派生类必须将其继承而来的成员函数中需要覆盖的那些重新声明
    • 我们的Bulk_quote类从它的基类Quote那里继承了isbn函数和bookNoprice等数据成员。此外, 它还定义了net_price 的新版本, 同时拥有两个新增加的数据成员min_qtydiscount

大多数类都只继承自一个类, 这种形式的继承被称作“ 单继承

类派生列表

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

  • 首先是一个冒号, 后面紧跟以逗号分隔的基类列表
  • 其中每个基类前面可以有以下三种访问说明符中的一个: publicprotected或者private
    • 访问说明符的作用是控制派生类从基类继承而来的成员是否对派生类的用户可见
    • 如果一个派生是公有的,则基类的公有成员也是派生类接口的组成部分。此外, 我们能将公有派生类型的对象绑定到基类的引用或指针上

派生类的声明

  • 一条声明语句的目的是令程序知晓某个名字的存在以及该名字表示一个什么样的实体, 如一个类、一个函数或一个变量等
  • 因此,派生列表以及与定义有关的其他细节必须与类的主体一起出现,派生类的声明中包含类名但是不包含它的派生列表
class Bulk_quote : public Quote; 	// 错误
class Bulk_quote; 					// 正确

继承的基类必须已经定义

  • 如果我们想将某个类用作基类, 则该类必须已经定义而非仅仅声明:
    • 这一规定的原因显而易见:派生类中包含并且可以使用它从基类继承而来的成员, 为了使用这些成员, 派生类当然要知道它们是什么。因此该规定还有一层隐含的意思, 即一个类不能派生它本身
class Quote; // 声明但未定义
// 错误: Quote必须被定义
class Bulk_quote : public Quote { ... };

继承基类中的虚函数

  • 派生类可以在从基类继承来的虚函数之前加上virtual关键字, 但是并不是非得这么做 (在派生类中,继承自基类的虚函数隐式地也是虚函数)

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

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

override 说明符

  • 派生类如果定义了一个函数与基类中虚函数的名字相同但是形参列表不同, 这仍然是合法的行为。编译器将认为新定义的这个函数与基类中原有的函数是相互独立的。这时, 派生类的函数并没有覆盖掉基类中的版本。就实际的编程习惯而言, 这种声明往往意味着发生了错误, 因为我们可能原本希望派生类能覆盖掉基类中的虚函数, 但是一不小心把形参列表弄错了。要想调试并发现这样的错误显然非常困难

  • C++11新标准允许派生类显式地注明它将使用哪个成员函数改写基类的虚函数,如果我们使用override标记了某个函数, 但该函数并没有覆盖已存在的虚函数, 此时编译器将报错
  • override说明符出现在形参列表(包括const或引用修饰符)以及尾置返同类型之后,而且只应该出现在成员函数的声明中,而不应该出现在定义中
struct B {
	virtual void f1(int) const;
	virtual void f2();
	void f3();
};

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

派生类向基类的类型转换

在一个对象中, 继承自基类的部分和派生类自定义的部分不一定是连续存储的

  • 因为在派生类对象中含有与其基类对应的组成部分,所以我们能把派生类的对象当成基类对象来使用,而且我们也能将基类的指针或引用绑定到派生类对象中的基类部分上
  • 这种转换通常称为派生类到基类的(derived-to-base)类型转换。这种隐式特性意味着
    • 可以把派生类对象或者派生类对象的引用用在需要基类引用的地方
    • 也可以把派生类对象的指针用在需要基类指针的地方
Quote item;
Bulk_quote bulk;
Quote *p = &bulk;			// p指向bulk的Quote部分
Quote &r = bulk;			// r绑定到bulk的Quote部分

派生类构造函数 (遵循基类的接口)

尽管在派生类对象中含有从基类继承而来的成员,但是派生类并不能直接初始化这些成员。和其他创建了基类对象的代码一样,派生类也必须使用基类的构造函数来初始化它的基类部分

  • 派生类首先初始化基类的部分,然后按照声明的顺序依次初始化派生类的成员
Bulk_quote(const std::string& book, double p,
			std::size_t qty, double disc) :
			Quote(book, p), min_qty(qty), discount(disc) {}

除非我们特别指出,否则派生类对象的基类部分会像数据成员一样执行默认初始化


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

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

派生类使用基类的成员

  • 派生类的作用域嵌套在基类的作用域之内。因此, 对于派生类的一个成员来说, 它使用派生类成员的方式与使用基类成员的方式没什么不同
  • 派生类可以访问基类的公有成员 public 和受保护成员 protected
// 如果达到了购买书籍的某个最低限值, 就可以享受折扣价格了
double Bulk_quote::net_price(size_t cnt) const
{
	if (cnt >= min_qty)
		return cnt * (1 - discount) * price;
	else
		return cnt * 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对象访问
}

类型转换与继承

通常情况下,如果我们想把引用或指针绑定到一个对象上,则引用或指针的类型应与对象的类型一致,或者对象的类型含有一个可接受的const类型转换规则。存在继承关系的类是一个重要的例外

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

和任何其他成员一样,派生类向基类的类型转换也可能会由于访问受限而变得不可行

静态类型与动态类型

  • 表达式的静态类型编译时总是已知的,它是变量声明时的类型或表达式生成的类型
  • 动态类型则是变量或表达式表示的内存中的对象的类型。动态类型直到运行时才可知
    • 如果表达式既不是引用也不是指针,则它的动态类型永远与静态类型一致。只有基类的指针或引用的静态类型可能与其动态类型不一致
// item的静态类型是Quote&, 它的动态类型则依赖于item绑定的实参
double ret = item.net_price(n);

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

  • OOP 的核心思想是多态性(polymorphism)。我们把具有继承关系的多个类型称为多态类型, 因为我们能使用这些类型的“ 多种形式” 而无须在意它们的差异。引用或指针的静态类型与动态类型不同是C++支持多态性的根本所在

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

  • 之所以存在派生类向基类的类型转换是因为每个派生类对象都包含一个基类部分,而基类的引用或指针可以绑定到该基类部分上。一个基类的对象既可以以独立的形式存在,也可以作为派生类对象的一部分存在
  • 因为一个基类的对象可能是派生类对象的一部分,也可能不是,所以不存在从基类向派生类的自动类型转换
Quote base;
Bulk_quote* bulkP = &base;		// 错误: 不能将基类转换成派生类
Bulk_quote& bulkRef = base;		// 错误: 不能将基类转换成派生类

  • 还有一种情况显得有点特别,即使一个基类指针或引用绑定在一个派生类对象上, 我们也不能执行从基类向派生类的转换
    • 编译器在编译时无法确定某个特定的转换在运行时是否安全, 这是因为编译器只能通过检查指针或引用的静态类型来推断该转换是否合法。如果在基类中含有一个或多个虚函数,我们可以使用dynamic_cast请求一个类型转换, 该转换的安全检查将在运行时执行。同样, 如果我们已知某个基类向派生类的转换是安全的, 则我们可以使用static_cast来强制覆盖掉编译器的检查工作
Bulk_quote bulk;
Quote *itemP = &bulk;
Bulk_quote *bulkP = itemP;		// 错误: 不能将基类转换成派生类

……在对象之间不存在类型转换

  • 派生类向基类的自动类型转换只对指针或引用类型有效,在派生类类型和基类类型之间不存在这样的转换。很多时候, 我们确实希望将派生类对象转换成它的基类类型, 但是这种转换的实际发生过程往往与我们期望的有所差别
    • 请注意, 当我们初始化或赋值一个类类型的对象时, 实际上是在调用某个函数。当执行初始化时, 我们调用构造函数; 而当执行赋值操作时, 我们调用赋值运算符。这些成员通常都包含一个参数, 该参数的类型是类类型的const版本的引用。因为这些成员接受引用作为参数, 所以派生类向基类的转换允许我们给基类的拷贝移动操作传递一个派生类的对象

  • 例如, 我们的书店类使用了合成版本的拷贝和赋值操作
    • 当构造item时, 运行Quote的拷贝构造函数。该函数负责拷贝bulkQuote部分的成员, 同时忽略掉bulkBulk_quote 部分的成员。类似的,对于将bulk赋值给item 的操作来说,只有bulkQuote部分的成员被赋值给item
    • 因为在上述过程中会忽略Bulk_quote部分,所以我们可以说bulkBulk_quote部分被切掉 (sliced down)了
Bulk_quote bulk;		// 派生类对象
Quote item(bulk);		// 使用Quote::Quote(const Quote&)构造函数
item = bulk;			// 调用Quote::operator= (const Quote&)

final 说明符:防止继承的发生

  • C++11 新标准提供了一种防止继承发生的方法, 即在类名后跟一个关键字final
  • 我们还能把某个虚函数指定为**final。之后任何尝试覆盖该函数的操作都将引发错误**

  • finaloverride说明符一样,都出现在形参列表(包括const或引用修饰符)以及尾置返同类型之后
struct D2 : B {
	// 从B继承f2() 和f3() 覆盖f1(int)
	virtual void f1(int) const final; //不允许后续的其他类覆盖f1(int)
};

struct D3 : D2 {
	void f2();				// 正确: 覆盖从间接基类B继承而来的f2
	void f1(int) const;		// 错误: D2已经将f2声明成final
};
class NoDerived final { /* */ };
class Base {/* */};

class Last final : Base { /* */ };		// Last是final的;我们不能继承Last
class Bad : NoDerived { /* */ };		// 错误: NoDerived是final的
class Bad2 : Last { /* */);				// 错误: Last是final的

虚函数 (补充)

虚函数与默认实参

参考 E f f e c t i v e   C + + Effective\ C++ Effective C++

  • 虚函数也可以拥有默认实参。虽然虚函数是动态绑定,但默认实参却是静态绑定如果某次函数调用使用了默认实参,则该实参由本次调用的静态类型决定 (在编译期决定函数缺省值比在运行期决定更慢更复杂,C++采用这样的实现方式是为了顾及效率)
    • 例如,如果我们通过基类的引用或指针调用函数,则使用基类中定义的默认实参,即使实际运行的是派生类中的函数版本也是如此。也就是说,在调用一个派生类虚函数时,它使用的可能是基类虚函数的默认实参
  • 因此,如果虚函数使用默认实参,则派生类不应重新定义默认实参
class Shape {
public:
	enum ShapeColor { Red, Green, Blue };
	virtual void draw(ShapeColor color = Red) const = 0;
	...
};

class Rectangle: public Shape {
public:
	// 注意,赋予不同的缺省参数值。这真糟糕!
	virtual void draw(ShapeColor color = Green) const;
	...
};

class Circle: public Shape {
public:
	virtual void draw(ShapeColor color) const;
// 请注意,以上这么写则当客户以对象调用此函数,一定要指定参数值。
// 因为静态绑定下这个函数并不从其 base 继承缺省参数值。
// 但若以基类指针(或 reference) 调用此函数,可以不指定参数值,
// 因为动态绑定下这个函数会从其base 继承缺省参数值。
	...

NVI (non-virtual interface)

  • 如果我们一定想在虚函数中加上默认实参,或许会像下面这么做。但不幸的是,这种实现方法导致了代码重复,而且具有相依性:如果 Shape 的默认实参改变了,派生类的默认实参也得改变
class Shape {
public:
	enum ShapeColor { Red, Green, Blue };
	virtual void draw(ShapeColor color = Red) const = 0;
	...
};

class Rectangle: public Shape {
public:
	// 代码重复
	virtual void draw(ShapeColor color = Red) const;
	...
};

  • 一个替代方案是使用 NVI: 令 base class 内的一个 public non-virtual 函数调用 private virtual 函数,后者可被 derived classes 重新定义
class Shape {
public:
	enum ShapeColor { Red, Green, Blue };
	void draw(ShapeColor color = Red) const	// 如今它是 non-virtual
	{
		doDraw(color);		// 调用一个 virtual
	}
private:
	virtual void doDraw(ShapeColor color) const = 0; // 真正的工作在此处完成
}; 

class Rectangle: public Shape {
public:
	...
private:
	// 注意,无需指定缺省参数值
	virtual void doDraw(ShapeColor color) const; 
};

回避虚函数的机制

  • 在某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本。可以用作用域运算符可以实现这一目的,例如下面的代码:
// 强行调用基类中定义的虚函数版本而不管baseP的动态类型到底是什么
// 该调用将在编译时完成解析
double undiscounted = baseP->Quote::net_price(42);

通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制


什么时候我们需耍回避虚函数的默认机制呢?

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

抽象基类

  • 假设我们希望扩展书店程序并令其支持几种不同的折扣策略:
    • 购买超过一定数量享受折扣
    • 购买不超过某个限额时可以享受折扣,但是一旦超过限额就要按原价支付
    • 购买量超过一定数量后购买的全部书籍都享受折扣, 否则全都不打折
  • 上面的每个策略都要求一个购买量的值和一个折扣值。我们可以定义一个新的名为Disc_quote的类来支持不同的折扣策略, 其中Disc_quote负责保存购买量的值和折扣值。其他的表示某种特定策略的类(如Bulk_quote)将分别继承自Disc_quote, 每个派生类通过定义自己的net_price函数来实现各自的折扣策略
  • 在定义Disc_quote类之前, 首先要确定它的net_price函数完成什么工作。显然我们的 Disc_quote 类与任何特定的折扣策略都无关, 因此Disc_quote 类中的net_price函数是没有实际含义的。然而, 这样的设计可能导致用户编写出一些无意义的代码。用户可能会创建一个Disc_quote对象并为其提供购买量和折扣值,如果将该对象传给一个像print_total这样的函数, 则程序将调用Quote版本的net_price。显然, 最终计算出的销售价格并没有考虑我们在创建对象时提供的折扣值, 因此上述操作毫无意义

纯虚函数

认真思考上面描述的情形我们可以发现, 关键问题并不仅仅是不知道应该如何定义net_price, 而是我们根本就不希望用户创建一个Disc_quote 对象Disc_quote类表示的是—本打折书籍的通用概念, 而非某种具体的折扣策略

  • 我们可以将net_price定义成纯虚(pure virtual)函数。这样做可以清晰明了地告诉用户当前这个net_price函数是没有实际意义的
  • 和普通的虚函数不一样,一个纯虚函数无须定义。纯虚函数主要为它的派生类提供函数接口而非函数实现
    • 值得注意的是,我们也可以为纯虚函数提供定义,不过函数体必须定义在类的外部
  • 通过在函数体的位置(即在声明语句的分号之前)书写 =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的构造函数来构建各个派生类对象的Disc_quote部分

可以为纯虚函数提供定义

E f f e c t i v e   C + + Effective\ C++ Effective C++

  • 假设你定义了一个抽象基类 Airplane,为了提供缺省实现,你为它实现了一个虚函数 fly (定义了函数接口以及缺省实现)。它的一个派生类 ModelA 可以直接使用 fly 的缺省实现,但它的另一个派生类 ModelB 却一定要定义自己的版本,但如果 ModelB 忘了定义,则会默认使用缺省实现而不会报错,这是一个隐藏的错误
class Airplane {
public:
	virtual void fly(const Airport& destination);
	... // 下面还定义了别的纯虚函数
};

void Airplane::fly(const Airport& destination)
{
	// 缺省代码,将飞机飞至指定目的地
}

class ModelA: public Airplane { ... }; 	// 直接使用 fly 的缺省实现
class ModelB: public Airplane {
	// 如果 ModelB 中忘了实现 fly,将直接使用缺省实现,这是极大的错误
};
  • 为了避免 ModelB 忘了定义 fly 的情况,可以将 fly 定义为纯虚函数 (接口) 并为它提供定义 (缺省实现)
class Airplane {
public:
	virtual void fly(const Airport& destination) = 0;
	...
};

void Airplane::fly(const Airport& destination)
{
	// 缺省代码,将飞机飞至指定目的地
}

class ModelA: public Airplane { 
public:
	virtual void fly(const Airport& destination)
	{ Airplane::fly(destination); }		// 直接使用 fly 的缺省实现
	...
}; 	
class ModelB: public Airplane {
	virtual void fly(const Airport& destination);
	...
};
void ModelB::fly(const Airport& destination)
{
	...
}

纯虚析构函数

  • 有时候你希望拥有抽象基类, 但这个类中没有任何可以作为纯虚函数的函数,怎么办?
    • 声明一个纯虚析构函数必须为其提供一份定义,否则继承自该基类的派生类在销毁其基类部分时将无函数可用
class AWOV {		// AWOV= "Abstract w/o Virtuals"
public:
	virtual ~AWOV () = 0;	// 声明纯虚析构函数
};

AWOV::~AWOV() {} 		// 纯虚析构函数的定义

抽象基类

  • 含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类(abstract base class)
  • 抽象基类负责定义接口,而后续的其他类可以覆盖该接口
  • 我们不能(直接)创建一个抽象基类的对象,但可以声明指向抽象基类的指针或引用
// Disc_quote 声明了纯虚函数,而 Bulk_quote 将覆盖该函数
Disc_quote discounted; 	// 错误:不能定义 Disc_quote 的对象
Bulk_quote bulk; 		// 正确: Bulk_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_quote 的直接基类是Disc_quote, 间接基类是Quote
  • 每个Bulk_quote对象包含三个子对象:一个(空的)Bulk_quote部分、一个Disc_quote子对象和一个Quote子对象

关键概念: 重构

  • Quote的继承体系中增加Disc_quote类是重构(refactoring) 的一个典型示例。重构负责重新设计类的体系以便将操作和/或数据从一个类移动到另一个类中。对于面向对象的应用程序来说, 重构是一种很普遍的现象
  • 值得注意的是,即使我们改变了整个继承体系,那些使用了Bulk_quoteQuote的代码也无须进行任何改动

访问控制与继承

受保护的成员 protected

一个类使用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; }

如果派生类(及其友元)能访问基类对象的受保护成员, 则上面的第二个clobber (接受一个Base&)将是合法的。该函数不是Base的友元, 但是它仍然能够改变一个Base对象的内容。如果按照这样的思路, 则我们只要定义一个形如Sneaky的新类就能非常简单地规避掉protected提供的访问保护了


关键概念:类的设计与受保护的成员

  • 不考虑继承的话,我们可以认为一个类有两种不同的用户普通用户类的实现者。其中,普通用户编写的代码使用类的对象,这部分代码只能访问类的公有(接口)成员;实现者则负责编写类的成员和友元的代码。
  • 如果进一步考虑继承的话就会出现第三种用户,即派生类。基类把它希望派生类能够使用的部分声明成受保护的。普通用户不能访问受保护的成员,而派生类及其友元仍旧不能访问私有成员
  • 和其他类一样.基类应该将其接口成员声明为公有的;同时将其实现的部分分成两组: 一组可供派生类访问protected,另一组只能由基类及基类的友元访问private

公有、私有和受保护继承

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

  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; } 
};

派生访问说明符

  • 派生访问说明符对派生类的成员(及友元)能否访问其直接基类的成员没什么影响。对基类成员的访问权限只与基类中的访问说明符有关
  • 派生访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对基类成员的访问权限
    • 如果继承是公有的,则成员将遵循其原有的访问说明符
    • 如果继承是私有的,则从基类继承来的所有成员在派生类中都会变成 private 属性
    • 如果继承是受保护的,则从基类继承来的所有成员在派生类中都会变成 protected 属性

// 客户代码
Pub_Derv d1;
Priv_Derv d2;
d1.pub_mem();	// 正确: pub_mem在派生类中是 public 的
d2.pub_mem();	// 错误: pub_mem在派生类中是 private 的
struct Derived_from_Public : public Pub_Derv {
	// 正确: Base::prot_mem 在 Pub_Derv 中仍然是 protected 的
	int use_base() { return prot_mem; }
}

struct Derived_from_Private : public Priv_Derv {
	// 错误: Base::prot_mem 在 Priv_Derv 中是 private 的
	int use_base() { return prot_mem; }
};

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

  • 对于代码中的某个给定节点来说,如果基类的公有成员是可访问的,则派生类中向基类的类型转换也是可访问的;反之则不行 (下面只考虑派生访问说明符的影响)
    • 只有当D公有地继承B时,用户代码才能使用派生类向基类的转换。如果D继承B的方式是受保护的或者私有的,则用户代码不能使用该转换
    • 不论D以什么方式继承B, D的成员函数和友元都能使用派生类向基类的转换:派生类向其直接基类的类型转换对派生类的成员和友元来说永远是可访问的
    • 如果D继承B的方式是公有的或者受保护的,则D的派生类的成员和友元可以使用DB的类型转换;反之,如果D继承B的方式是私有的, 则不能使用

友元与继承

  • 就像友元关系不能传递一样,友元关系同样也不能继承
  • 基类的友元在访问派生类成员时不具有特殊性
  • 派生类的友元也不能随意访问基类的成员
class Base {
	friend class Pal;	// Pal在访问Base的派生类时不具有特殊性
public:
	void pub_mem();
protected:
	int prot_mem;
private:
	char priv_mem;
};

class Sneaky: public Base{
	int j;
};

class Pal {
public:
	int f2(Sneaky s) { return s.j; } 		//错误: Pal不是Sneaky的友元
	// 对基类的访问权限由基类本身控制,即使对于派生类的基类部分也是如此
	int f3(Sneaky s) { return s.prot_mem; } //正确: Pal是Base的友元
};
  • 如前所述,每个类负责控制自己的成员的访问权限,因此尽管看起来有点儿奇怪,但f3确实是正确的。PalBase的友元,所以Pal能够访问Base对象的成员,这种可访问性包括了Base对象内嵌在其派生类对象中的情况
class D2 : public Pal {
public:
	int mem(Base b)
		{ return b.prot_mem; } // 错误:友元关系不能继承
}

改变个别成员的可访问性

  • 使用 using声明可以改变派生类继承的某个名字的访问级别
  • 通过在类的内部使用using声明语句, 我们可以将该类的直接或间接基类中的任何可访问成员标记出来using声明语句中名字的访问权限由该using声明语句之前的访问说明符来决定
class Base {
public:
	std::size_t size() const { return n; }
protected:
	std::size_t n;
};

class Derived : private Base {	// 注意: private 继承
public:
	// 保持对象尺寸相关的成员的访问级别
	// `Derived`的用户将可以使用`size`成员
	using Base::size;
protected:
	// `Derived`的派生类将能使用`n`
	using Base::n;
};

默认的继承保护级别

使用structclass关键字定义的类具有不同的默认访问说明符。类似的,默认派生运算符也由定义派生类所用的关键字来决定

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

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

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

private 继承的意义

参考 E f f e c t i v e   C + + Effective\ C++ Effective C++

private 继承意味着 "implemented-in-terms-of"

  • 这是因为继承自 private base class 的每样东西在派生类中都是 private,也就是说它们都是实现细节而已。private 继承意味着只有实现部分被继承,接口部分应略去。private 继承在软件“设计”层面上没有意义,其意义只在于软件实现层面
    • 也就是说,如果 Dprivate 形式继承 B,意思是 D 对象根据 B 对象实现而得,在没有其他意义

"复合" 与 private 继承

  • 如果类中含有其他的类对象,则这种关系称为 “复合”。它可以表示 “implemented-in-terms-of” 或 “has-a” 关系
  • 这里建议尽可能使用复合,必要时才使用 private 继承
    • 何时才是必要?
      • 主要是当 protected 成员 和/或 virtual 函数牵扯进来的时候. 例如两个不存在 “is-a” 关系的类,其中一个需要访问另一个的 protected 成员,或需要重新定义一个或多个虚函数,这种情况下 private 继承极有可能成为正统设计策略
      • 还有一种激进情况涉及空间最优化,可能会促使你选择 "private 继承" 而不是 “继承加复合”

  • 下面用一个例子来说明。假设我们要设计一个类 Widget, 它需要周期性记录数据。而我们恰有一个定时器类 Timer, 该定时器每滴答一次,就自动调用一个虚函数
class Timer {
public:
	explicit Timer(int tickFrequency);
	virtual void onTick () const; // 定时器每滴答一次,此函数就被自动调用一次
};
  • 我们可以private 形式继承 Timer (不要直接用 public 形式继承 Timer,因为它们并不是 “is-a” 关系):
class Widget: private Timer {
private:
	virtual void onTick() const;
};
  • 但是更建议用下面的 public 继承加复合的方式, 理由如下:
    • 首先,你或许会想设计 Widget 使它得以拥有 derived classes, 但同时你可能会想阻止 derived classes 重新定义 onTick,这种形式能满足这个要求
    • 第二,你或许会想要将 Widget编译依存性降至最低。如果 Widget 继承 Timer, 当 Widget 被编译时 Timer 的定义必须可见,所以定义 Widget 的那个文件恐怕必须 #include Timer. 但如果 WidgetTimer 移出 Widget 之外而 Widget 内含指针指向一个 WidgetTimer, Widget 可以只带着一个简单的 WidgetTimer 声明式,不再需要 #include 任何与 Timer 有关的东西。对大型系统而言,如此的解耦 (decouplings) 可能是重要的措施
class Widget {
private:
	class WidgetTimer: public Timer {
	public:
		virtual void onTick() const;
	};
	WidgetTimer timer;
};

  • 下面介绍一种激进情况涉及空间最优化可能会促使你选择 "private 继承" 而不是 "继承加复合"
  • 这个激进情况只适用于你所处理的 class 不带任何数据时。这样的 classes 没有 non-static 成员变量,没有 virtual 函数(因为这种函数的存在会为每个对象带来一个 vptr 指向函数指针组成的数组,用来在运行期决定调用哪一个虚函数),也没有 virtual base classes (因为这样的 base classes 也会招致体积上的额外开销) 。于是这种所谓的 empty classes 对象不使用任何空间,因为没有任何隶属对象的数据需要存储
    • 现实中的 “empty” classes 并不真的是 empty 。它们往往内含 typedef, enum, static 成员变量,或 non-virtual 函数
  • 然而由于技术上的理由, C++ 裁定凡是独立(非附属)对象都必须有非零大小,所以如果你像下面这样做就会发现 sizeof(HoldsAnInt) > sizeof(int); 一个 Empty 成员变量竟然要求内存。在大多数编译器中 sizeof(Ernpty) 获得1, 因为面对 “大小为零之独立(非附属)对象”,通常C++官方勒令默默安插一个 char 到空对象内。然而齐位需求(alignment) 可能造成编译器为类似 HoldsAnInt 这样的 class 加上一些衬垫 (padding) , 所以有可能 HoldsAnInt 对象不只获得一个 char 大小,也许实际上被放大到足够又存放一个 int
class Empty { };	// 没有数据,所以其对象应该不使用任何内存
class HoldsAnInt {	// 应该只需要一个 int 空间
private:
	int x;
	Empty e;		// 应该不需要任何内存
};
  • 但上述约束不适用于 derived class 对象内的 base class 成分,因为它们并非独立(非附属)。如果你继承 Empty, 而不是内含一个那种类型的对象, 那么几乎可以确定 sizeof(HoldsAnInt) == sizeof (int) 。这是所谓的 EBO (empty base optimization; 空白基类最优化)
    • 如果你是一个程序库开发人员,而你的客户非常在意空间,那么值得注意EBO
    • 另外还值得知道的是, EBO 一般只在单一继承(而非多重继承)下才可行
class HoldsAnInt: private Empty {
private:
	int x;
}

继承中的类作用域

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

在编译时进行名字查找

  • 一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。即使静态类型与动态类型可能不一致,我们能使用哪些成员仍然是由静态类型决定的
    • 也就是说,在用基类引用或指针调用成员函数时,不能调用基类中没有定义的成员函数 (即使派生类中定义了该成员函数且该基类引用或指针指向一个派生类对象时也是如此)
class Disc_quote : public Quote {
public:
	std::pair<size_t, double> discount_policy() const
		{ return {quantity, discount}; }
	...
};
Bulk_quote bulk;			// Bulk_quote 是 Disc_quote 的派生类
Bulk_quote *bulkP = &bulk;	// 静态类型与动态类型一致
Quote *itemP = &bulk;		// 静态类型与动态类型不一致
bulkP->discount_policy();	// 正确: bulkP的类型是Bulk_quote*
itemP->discount_policy();	// 错误: itemP的类型是Quote*
  • 尽管bulk中确实含有一个名为discount_policy的成员,但是该成员对itemP却是不可见的。itemP的类型是Quote的指针,意味着对discount_policy的搜索将从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
};


// get_mem中mem引用的解析结果是定义在Derived中的名字
Derived d(42);
cout << d.get_mem() << endl; // 打印 42

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

  • 可以通过作用域运算符来使用一个被隐藏的基类成员:
struct Derived : Base {
	int get_base_mem() { return Base::mem; }
	// ...
}

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

假定我们调用p->mem()(或者obj.mem()), 则依次执行以下4个步骤:

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

名字查找先于类型检查

  • 声明在内层作用域的函数并不会重载声明在外层作用域的函数。因此, 派生类中定义的函数会覆盖(而非重载)其基类中的成员
  • 当然,也可以通过作用域运算符调用基类被隐藏的函数
struct Base {
	int memfcn();
};
struct Derived : Base (
	int memfcn(int);
};

Derived d; 
d.memfcn(10);	// 调用 Derived::memfcn
d.memfcn();		// 错误:参数列表为空的 memfcn 被隐藏了
d.Base::memfcn();	// 正确:调用Base::memfcn
  • 要注意的是, d.memfcn()是非法的。为了解析这条调用语句,编译器首先在Derived 中查找名字memfcn,所以查找过程终止。Derived 中的memfcn 版本需要一个int 实参,所以该调用语句是错误的

虚函数 与 作用域

  • 我们现在可以理解为什么基类与派生类中的虚函数必须有相同的形参列表了:假如基类与派生类的虚函数接受的实参不同,则我们无法通过基类的引用或指针调用派生类的虚函数
class Base {
public:
	virtual int fcn();
};
class D1 : public Base {
public:
	int fcn(int);	// 隐藏基类的fcn, 这个fcn不是虚函数
	// D1 继承了Base::fcn()的定义,此时D1 拥有了两个名为`fcn` 的函数
	virtual void f2();
};
class D2 : public D1 {
public:
	int fcn(int);		// 是一个非虚函数, 隐藏了D1::fcn(int)
	int fcn();			// 覆盖了Base的虚函数fcn
	void f2();			// 覆盖了D1的虚函数f2
};
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;
bp2->f2(); 			//错误: Base没有名为f2的成员
d1p->f2(); 			//虚调用, 将在运行时调用D1::f2()
d2p->f2(); 			//虚调用, 将在运行时调用D2::f2()

Base *p1 = &d2obj; D1 *p2 = &d2obj; D2 *p3 = &d2obj;
p1->fcn(42); 	//错误: Base中没有接受一个int的fcn
p2->fcn(42);	//静态绑定, 调用D1::fcn(int)
p3->fcn(42);	//静态绑定, 调用D2::fcn(int)

覆盖重载的函数

using 声明

如果派生类希望基类某个成员函数的所有重载版本对它来说都是可见的, 那么它就需要覆盖所有的版本, 或者一个也不覆盖。但有时一个类仅需覆盖重载集合中的一些而非全部函数, 此时, 如果我们不得不覆盖基类中的每一个版本的话, 显然操作将极其烦琐

  • 一种好的解决方案是为重载的成员提供一条using声明语句,这样我们就无须覆盖基类中的每一个重载版本了。using声明语句指定一个名字而不指定形参列表, 所以一条基类成员函数的using声明语句就可以把该函数的所有重载实例添加到派生类作用域中。此时, 派生类只需要定义其特有的函数就可以了, 而无须为继承而来的其他函数重新定义
  • 类内using声明的一般规则同样适用于重载函数的名字:基类函数的每个实例在派生类中都必须是可访问的。对派生类没有重新定义的重载版本的访问实际上是对using声明点的访问
class B {
public:
    void f() { cout << "B::f()\n"; }
    void f(int i) { cout << "B::f(" << i << ")\n"; } 
    void f(double d) { cout << "B::f(" << d << ")\n"; } 
};
class D1 : public B {
public:
    using B::f;		// 并且为 public
    void f(int i) { cout << "D1::f(" << i << ")\n"; }
};
D1 d;
d.f(10);    // D1::f(10)
d.f(4.9);   // B::f(4.9)
d.f();      // B::f()

转交函数 (forwarding function)

E f f e c t i v e   C + + Effective\ C++ Effective C++

class Base {
public:
	virtual void mf1() = 0;
	virtual void mf1(int);
	...
};
class Derived: private Base {
public:
	// 只想继承 mf1() 而不继承 mf1(int)
	virtual void mf1() 		// forwarding function
	{ Base::mf1(); }
	...
};

Derived d;
int x;
d.mf1();		// 调用 Derived::mf1
d.mf1(x);		// 错误! Base::mf1() 被隐藏了

构造函数与拷贝控制

  • 和其他类一样,位于继承体系中的类也需要控制当其对象执行一系列操作时发生什么样的行为, 这些操作包括创建、拷贝、移动、赋值和销毁
  • 派生类无法继承基类的构造函数以及析构函数

派生类其实可以重用直接基类的构造函数(默认、拷贝和移动构造函数除外),但必须要用到 using声明,具体的之后会说明

虚析构函数

  • 继承关系对基类拷贝控制最直接的影响是基类通常应该定义一个虚析构函数, 这样我们就能动态分配继承体系中的对象了
    • 当我们delete一个动态分配的对象的指针时将执行析构函数。如果该指针指向继承体系中的某个类型, 则有可能出现指针的静态类型与被删除对象的动态类型不符的情况。如果这样的话, 编译器就必须清楚它应该执行的是基类还是派生类的析构函数。和其他函数一样, 我们通过在基类中将析构函数定义成虚函数以确保执行正确的析构函数版本

但如果确定某个类不会被作为基类,就不要把它的析构函数变成虚析构函数,这样会带来额外开销;一个准则是:只有当 class 内至少含有一个虚函数,才为它声明虚析构函数

class Quote {
public:
	// 如果我们删除的是一个指向派生类对象的基类指针, 则需要虚析构函数
	virtual ~Quote() = default; //动态绑定析构函数
};
  • 和其他虚函数一样, 析构函数的虚属性也会被继承。因此, 无论Quote的派生类使用合成的析构函数还是定义自己的析构函数, 都将是虚析构函数。只要基类的析构函数是虚函数, 就能确保当我们delete基类指针时将运行正确的析构函数版本:
Quote *itemP = new Quote;
delete itemP;				// 调用Quote的析构函数
itemP = new Bulk_quote;
delete itemP;				// 调用Bulk_quote的析构函数

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

根据 三 / 五法则,如果一个类需要析构函数, 那么它也同样需要拷贝和赋值操作。基类的析构函数并不遵循上述准则, 它是一个重要的例外。一个基类总是需要析构函数, 而且它能将析构函数设定为虚函数。此时,该析构函数为了成为虚函数而令内容为空,我们显然无法由此推断该基类还需要赋值运算符或拷贝构造函数


  • 不要尝试继承一个标准容器 / string
    • 所有的 STL 容器都未被设计为作为基类来使用,因此它们都没有定义虚析构函数;这样如果你用基类指针指向动态分配的派生类对象并 delete 该指针就会导致资源泄漏!

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

基类需要一个虚析构函数这一事实还会对基类和派生类的定义产生另外一个间接的影响:

  • 如果一个类定义了析构函数, 即使它通过=default的形式使用了合成的版本, 编译器也不会为这个类合成移动操作
  • 派生类的合成移动操作需要调用其基类的移动操作,因此基类没有移动操作意味着它的派生类没有合成移动操作

合成拷贝控制与继承

  • 派生类的合成拷贝控制成员不仅对类本身的成员依次进行初始化、赋值或销毁的操作, 还负责使用直接基类中对应的操作对一个对象的直接基类部分进行初始化、赋值或销毁
    • 要求相应的成员应该可访问并且不是一个被删除的函数

例如:

  • 在我们的Quote继承体系中, 所有类都使用合成的析构函数 。其中, 派生类隐式地使用而基类通过将其虚析构函数定义成=default 而显式地使用。派生类的析构函数除了销毁派生类自己的成员外,还负责销毁派生类的直接基类,该直接基类又销毁它自己的直接基类, 以此类推直至继承链的顶端

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

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

  • 如果基类中的默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的函数或者不可访问, 则派生类中对应的成员将是被删除的, 原因是编译器不能使用基类成员来执行派生类对象基类部分的构造、赋值或销毁操作
  • 如果在基类中有一个不可访问或删除掉的析构函数,则派生类中合成的默认和拷贝构造函数将是被删除的, 因为编译器无法销毁派生类对象的基类部分
  • 和过去一样,编译器将不会合成一个删除掉的移动操作。当我们使用=default请求一个移动操作时,如果基类中的对应操作是删除的或不可访问的, 那么派生类中该函数将是被删除的, 原因是派生类对象的基类部分不可移动
  • 同样, 如果基类的析构函数是删除的或不可访问的, 则派生类的移动构造函数也将是被删除的
class B {
public:
	B();
	B(const B&) = delete;
	// 因为我们定义了拷贝构造函数,所以编译器将不会为`B`合成一个移动构造函数
	// 因此, 我们既不能移动也不能拷贝`B`的对象
	// ...
};
class D : public B (
	// 没有声明任何构造函数
};
D d; //正确: D的合成默认构造函数使用B的默认构造函数
D d2(d); //错误: D的合成拷贝构造函数是被删除的
D d3(std::move(d)); // 错误: 隐式地使用D的被删除的拷贝构造函数
  • 在实际编程过程中, 如果在基类中没有默认、拷贝或移动构造函数, 则一般情况下派生类也不会定义相应的操作
  • 如果B 的派生类希望它自己的对象能被移动和拷贝, 则派生类需要自定义相应版本的构造函数。当然, 在这一过程中派生类还必须考虑如何移动或拷贝其基类部分的成员

移动操作与继承

  • 如前所述, 大多数基类都会定义一个虚析构函数。因此在默认情况下, 基类通常不含有合成的移动操作, 而且在它的派生类中也没有合成的移动操作
  • 当我们确实需要执行移动操作时应该首先在基类中进行定义。一旦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::operator=(const Base&) 不会被自动调用
D &D::operator=(const D &rhs)
{
	Base::operator=(rhs); //为基类部分赋值
	// ...
	return *this;
}

派生类析构函数

  • 在析构函数体执行完成后, 对象的成员会被隐式销毁。类似的, 对象的基类部分也是隐式销毁的。因此, 派生类析构函数只负责销毁由派生类自己分配的资源
  • 对象销毁的顺序正好与其创建的顺序相反:派生类析构函数首先执行, 然后是基类的析构函数, 以此类推, 沿着继承体系的反方向直至最后
class D: public Base {
public:
	// Base: : ~Base被自动调用执行
	~D() { /* 该处由用户定义清除派生类成员的操作*/ }
};

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

  • 如果构造函数或析构函数调用了某个虚函数,则我们应该执行与构造函数或析构函数所属类型相对应的虚函数版本
    • 因为在构造函数中,派生类对象的基类部分将首先被构建。当执行基类的构造函数时, 该对象的派生类部分是未被初始化的状态。类似的, 销毁派生类对象的次序正好相反, 因此当执行基类的析构函数时, 派生类部分已经被销毁掉了。由此可知, 当我们执行上述基类成员的时候, 该对象处于未完成的状态。因此,如果在基类构造/析构函数中使用虚函数的派生类版本,则程序可能会崩溃
  • 为了能够正确地处理这种未完成状态,编译器认为对象的类型在构造或析构的过程中仿佛发生了改变一样 (在派生类对象的基类部分构造期间,对象的类型为基类,若使用运行期类型信息 (dynamic_casttypeid) 也会把对象视作基类)

绝不在构造和析构过程中调用 virtual 函数 ( E f f e c t i v e   C + + Effective\ C++ Effective C++)

  • 这样的调用很可能给你带来意想不到的结果
class Transaction {		// 所有交易的 base class
public:
	Transaction();
	virtual void logTransaction() const = 0;	// 做出一份因类型不同而不同的日志记录 (log entry)
};

Transaction::Transaction()
{
	...
	logTransaction(); 	// 最后动作是记录这笔交易
}

class BuyTransaction: public Transaction { 
public:
	// BuyTransaction 的默认构造函数调用 Transaction 的默认构造函数
	virtual void logTransaction() const;
	...
};

class SellTransaction: public Transaction {
public:
	virtual void logTransaction() const;
};
  • 但当我们初始化一个 BuyTransaction 对象,在调用 Transaction 的默认构造函数初始化其基类部分时,并没有调用派生类版本的 logTransaction() 而是调用的基类版本!
  • 一个好的解决方案是将基类的虚函数改为非虚函数,由派生类将必要的构造信息传递给基类构造函数
class Transaction {
public:
	explicit Transaction(const std::string& loginfo);
	void logTransaction(const std::string& loginfo) const; // non-virtual
};

Transaction::Transaction(const std::string& loginfo)
{
	logTransaction(loginfo);
}

class BuyTransaction: public Transaction {
public:
	BuyTransaction(parameters)
	 : Transaction(createLogString(parameters)) // 将 log 信息传给基类构造函数
	{...} 
private:
	// 比起直接在初始化列表里向基类构造函数传递数据,设置一个辅助函数往往更加方便可读
	// 同时将其设置为 static 可以保证其不会指向那些未初始化的成员变量
	static std::string createLogString(parameters);
};

继承的构造函数

  • 在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声明语句将令编译器产生代码。对基类的每个构造函数,编译器都生成一个与之对应的派生类构造函数。这些编译器生成的构造函数形如:
derived(parms) : base(args) { }
  • 其中, derived是派生类的名字, base是基类的名字,parms是构造函数的形参列表,args将派生类构造函数的形参传递给基类的构造函数。在我们的Bulk_quote类中,继承的构造函数等价于:
Bulk_quote(const std::string& book, double price, std::size_t qty, double disc):
	Disc_quote(book, price, qty, disc) {}
  • 如果派生类含有自己的数据成员, 则这些成员将被默认初始化

继承的构造函数的特点

  • 和普通成员的using声明不一样,一个构造函数的 using声明不会改变该构造函数的访问级别,如果直接基类中某个构造函数是private的,那么该构造函数将无法被继承
  • using声明语句不能指定explicitconstexpr。如果基类的构造函数是explicit 或者constexpr,则继承的构造函数也拥有相同的属性
  • 当一个基类构造函数含有默认实参时, 这些实参并不会被继承。相反, 派生类将获得多个继承的构造函数, 其中每个构造函数分别省略掉一个含有默认实参的形参
    • 例如, 如果基类有一个接受两个形参的构造函数, 其中第二个形参含有默认实参,则派生类将获得两个构造函数: 一个构造函数接受两个形参(没有默认实参),另一个构造函数只接受一个形参, 它对应于基类中最左侧的没有默认值的那个形参
  • 如果派生类重用了直接基类的构造函数,编译器就不会再为派生类生成默认构造函数

如果基类含有几个构造函数, 则除了两个例外情况, 大多数时候派生类会继承所有这些构造函数

  • 第一个例外是派生类可以继承一部分构造函数, 而为其他构造函数定义自己的版本。如果派生类定义的构造函数与基类的构造函数具有相同的参数列表,则定义在派生类中的构造函数将替换继承而来的构造函数
  • 第二个例外是默认、拷贝和移动构造函数不会被继承。这些构造函数按照正常规则被合成

容器与继承

当我们使用容器存放继承体系中的对象时, 通常必须采取间接存储的方式。因为不允许在容器中保存不同类型的元素,所以我们不能把具有继承关系的多种类型的对象直接存放在容器当中。

即使可以使用基类的容器存储派生类对象,但当派生类对象被赋值给基类对象时, 其中的派生类部分将被“切掉" , 因此容器和存在继承关系的类型无法兼容

在容器中放置(智能)指针而非对象

当我们希望在容器中存放具有继承关系的对象时, 我们实际上存放的通常是基类的指针(更好的选择是智能指针)。这些指针所指对象的动态类型可能是基类类型, 也可能是派生类类型:

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, 即在15*&50中扣除掉折扣全额
couT << basket.back()->net_price(15) << endl;

编写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(); }
	// multiset保存多个报价, 按照compare成员排序
	// 因为shared_ptr 没有定义小于运算符, 所以为了对元素排序我们必须提供自己的比较运算符
	std::multiset<std::shared_ptr<Quote>, decltype(compare)*>  items { compare };
};
double Basket::total_receipt(ostream &os) const
{
	double sum = 0.0; //保存实时计算出的总价格
	// iter 指向ISBN 相同的一批元素中的第一个
	// upper_bound 返回一个迭代器, 该迭代器指向这批元素的尾后位置
	for (auto iter = items.cbegin(); iter != items.cend(); iter = items.upper_bound(*iter)) {
		//print_total调用了虚函数net_price, 因此最终的计算结果依赖于**iter 的动态类型
		sum += print_total(os, **iter, items.count(*iter));
	}
	os << "Total Sale: " << sum << endl; 		// 打印最终的总价格
	return sum;
}

我们的下一步是重新定义add_item, 使得它接受一个Quote对象而非shared_ptr。我们将定义两个版本, 一个拷贝它给定的对象, 另一个则采取移动操作:

void add_item(const Quote& sale);
void add_item(Quote&& sale);

唯一的问题是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很容易地写出新版本的add_item:

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( ))); }
	// 其他成员与之前的版本一致
};

多重继承 与 虚继承

为了探讨有关多重继承的问题, 我们将以动物园中动物的层次关系作为实例

  • 动物园中的动物存在于不同的抽象级别上。有个体的动物, 如 Ling-Ling、Mowgli 和 Balou 等, 它们以名字进行区分
  • 每个动物属于一个物种, 例如 Ling-Ling 是一只大熊猫
  • 物种又是科的成员, 大熊猫是熊科的成员
  • 每个科是动物界的成员, 在这个例子中动物界是指一个动物园中所有动物的总和

  • 我们将定义一个抽象类ZooAnimal, 用它来保存动物园中动物共有的信息并提供公共接口,类Bear将存放Bear科特有的信息, 以此类推
  • 除了类ZooAnimal之外, 我们的应用程序还包含其他一些辅助类, 这些类负责封装不同的抽象, 如濒临灭绝的动物Endangered类。以类Panda的实现为例,Panda 是由BearEndangered共同派生而来的

多重继承

  • 多重继承(multiple inheritance)是指从多个直接基类中产生派生类的能力,多重继承的派生类继承了所有父类的属性,即 派生类的对象包含有每个基类的子对象
  • 每个基类包含一个可选的访问说明符。如果访问说明符被忽略掉了, 则关键字class对应的默认访问说明符是private, 关键字struct对应的是public
  • 在某个给定的派生列表中, 同一个基类只能出现一次
class Bear : public ZooAnimal { /* ... */ };
class Panda : public Bear, public Endangered { /* ... */ };

在这里插入图片描述

构造函数

  • 构造一个派生类的对象将同时构造并初始化它的所有基类子对象
  • 多重继承的派生类的构造函数初始化也只能初始化它的直接基类,基类的构造顺序与派生列表中基类的出现顺序保持一致, 而与派生类构造函数初始值列表中基类的顺序无关
// 显式地初始化所有基类
Panda::Panda(std::string name, bool onExhibit)
		: Bear(name, onExhibit, "Panda"),
		  Endangered(Endangered::critical) {}

// 隐式地使用Bear的默认构造函数初始化Bear子对象
Panda::Panda()
	: Endangered(Endangered::critical) { }
继承的构造函数
  • 在C++11新标准中, 允许派生类从它的一个或几个基类中继承构造函数
  • 如果一个类从它的多个基类中继承了相同的构造函数(即形参列表完全相同),则这个类必须为该构造函数定义它自己的版本
struct Base1 {
	Base1() = default;
	Base1(const std::string&);
	Base1(std::shared_ptr<int>);
};

struct Base2 {
	Base2() = default;
	Base2(const std::string&);
	Base2(int);
};

// 错误: D1 试图从两个基类中都继承D1::D1(const string&) (默认构造函数无法被继承)
struct D1: public Base1, public Base2 {
	using Base1::Base1; //从Base1 继承构造函数
	using Base2::Base2; //从Base2 继承构造函数
};

// 正确
struct D2: public Base1, public Base2 {
	using Base1::Base1; 	// 从Base1 继承构造函数
	using Base2::Base2; 	// 从Base2 继承构造函数
	// D2 必须自定义一个接受 string 的构造函数
	D2(const string &s): Base1(s), Base2(s) { }
	D2() = default; // 一旦D2 定义了它自己的构造函数则最好定义默认构造函数
};

析构函数

  • 派生类的析构函数只负责清除派生类本身分配的资源,派生类的成员及基类都是自动销毁的。合成的析构函数体为空
  • 析构函数的调用顺序正好与构造函数相反

拷贝与移动操作

  • 只有当派生类使用的是合成版本的拷贝、移动或赋值成员时, 才会自动对其基类部分执行这些操作。在合成的拷贝控制成员中,每个基类分别使用自己的对应成员隐式地完成构造、赋值或销毁等工作

例如, 假设Panda 使用了合成版本的成员ling_ling 的初始化过程:

Panda ying_yang("ying_yang");
Panda ling_ling = ying_yang;  // 拷贝构造函数
  • 将调用Bear的拷贝构造函数, 后者又在执行自己的拷贝任务之前先调用ZooAnimal的拷贝构造函数
  • 一旦ling_ling的Bear 部分构造完成, 接着就会调用Endangered的拷贝构造函数来创建对象相应的部分
  • 最后, 执行Panda的拷贝构造函数

合成的移动构造函数、拷贝赋值运算符、移动赋值运算符的工作机理与之类似

类型转换

  • 我们可以令某个可访问基类的指针或引用直接指向一个派生类对象
    • 例如, 一个ZooAnimalBearEndangered类型的指针或引用可以绑定到Panda对象上
  • 编译器不会在派生类向基类的几种转换中进行比较和选择, 因为在它看来转换到任意一种基类都一样好

例如, 如果存在如下所示的print重载形式:

void print(const Bear&);
void print(const Endangered&);

则通过Panda对象对不带前缀限定符的print函数进行调用将产生编译错误:

Panda ying_yang("ying_yang");
print(ying_yang);	// 二义性错误

基于指针类型或引用类型的查找

  • 与只有一个基类的继承一样, 对象、指针和引用的静态类型决定了我们能够使用哪些成员
    • 如果我们使用一个ZooAnimal指针, 则只有定义在ZooAnimal中的操作是可以使用的, Panda接口中的BearPandaEndangered特有的部分都不可见

举个例子,已知我们的类已经定义了表18.1列出的虚函数

在这里插入图片描述
考虑下面的这些函数调用:

Bear *pb = new Panda("ying_yang");
pb->print();	// 正确: Panda::print()
pb->cuddle(); 	// 错误: 不属于Bear的接口
pb->highlight();	// 错误: 不属于Bear的接口
delete pb;		// 正确: Panda::~Panda()

多重继承下的类作用域

  • 单继承的情况下, 派生类的作用域嵌套在直接基类和间接基类的作用域中。查找过程沿着继承体系自底向上进行, 直到找到所需的名字。派生类的名字将隐藏基类的同名成员
  • 多重继承的情况下, 相同的查找过程在所有直接基类中同时进行。如果名字在多个基类中都被找到, 则对该名字的使用将具有二义性
    • 对于一个派生类来说, 从它的几个基类中分别继承名字相同的成员是完全合法的, 只不过在使用这个名字时必须明确指出它的版本 (加前缀限定符)
    • 和往常一样, 先查找名字后进行类型检查以及可访问性。当编译器在两个作用域中同时发现了同一个名字时, 将直接报告一个调用二义性的错误
      • 即使派生类继承的两个函数形参列表不同也可能发生二义性错误
      • 即使该名字在一个类中是私有的, 而在另一个类中是公有的或受保护的同样也可能发生二义性错误
      • 例如,两个直接基类中有相同名字的函数,,
class B1 {
public:
	int f(int a, int b) {}
};

class B2 {
private:
	int f(int a) {}
};

class D : public B1, public B2 {
};

int main(void)
{
	D d;
	d.f(1);		// 二义性错误

	return 0;
}

  • 在我们的例子中, 如果我们通过Panda的对象、指针或引用使用了某个名字, 则程序会并行地在EndangeredBear/ZooAnimal这两棵子树中查找该名字。如果名字在超过一棵子树中被找到, 则该名字的使用具有二义性
  • 例如, 如果ZooAnimalEndangered都定义了名为max_weight的成员, 并且Panda没有定义该成员, 则:
double d = ying_yang.max_weight(); 				// 二义性错误

double d = ying_yang.ZooAnimal::max_weight(); 	// 正确
double d = ying_yang.Endangered::max_weight();	// 正确

  • 要想避免潜在的二义性, 最好的办法是在派生类中为该函数定义一个新版本。例如:
double Panda::max_weight() const
{
	return std::max(ZooAnimal::max_weight(),
					Endangered::max_weight());
}

虚继承

  • 尽管在派生列表中同一个基类只能出现一次, 但实际上派生类可以多次继承同一个类。派生类可以通过它的两个直接基类分别继承同一个间接基类, 也可以直接继承某个基类, 然后通过另一个基类再一次间接继承该类
    • 例如,istreamostream分别继承了一个共同的名为base_ios的抽象基类。该抽象基类负责保存流的缓冲内容并管理流的条件状态。iostream类从istreamostream直接继承而来,可以同时读写流的内容。因为istreamostream都继承自base_ios, 所以iostream继承了base_ios两次
  • 默认情况下, 派生类中含有继承链上每个类对应的子部分。如果某个类在派生过程中出现了多次, 则派生类中将包含该类的多个子对象
    • 这种默认的情况对某些形如iostream的类显然是行不通的。一个iostream对象肯定希望在同一个缓冲区中进行读写操作,也会要求条件状态能同时反映输入和输出操作的情况
class A {
protected:
    int av; 
public:
    A(int v = 0) : av(v) { cout << "A(int)\n"; }
    ~A() { cout << "~A()\n"; }
};

class B1 : public A {
protected:
    int bv; 
public:
    B1(int v = 0) : A(v), bv(v) { cout << "B1(int)\n"; }
    ~B1() { cout << "~B1()\n"; }
};

class B2 : public A {
protected:
    int bv;
public:
    B2(int v = 0) : A(v), bv(v) { cout << "B2(int)\n"; }
    ~B2() { cout << "~B2()\n"; }
};

class D :public B1, public B2 {
private:
    int dv;
public:
    D() :dv(0) { cout << "D()\n"; }
    D(int v1, int v2, int v3) :B1(v1), B2(v2), dv(v3) { cout << "D(int, int, int)\n"; } 
    ~D() { cout << "~D()\n"; }
    void Show() {
    	// 使用限定名来访问基类的同名成员
        cout << "B1(av=" << B1::av << " bv=" << B1::bv << ") ";     
        cout << "B2(av=" << B2::av << " bv=" << B2::bv << ") ";
        cout << "dv=" << dv << endl;
    }
};
D d1;
cout << "d1: "; d1.Show(); 

output:

A(int)
B1(int)
A(int)
B2(int)
D()
d1: B1(av=0 bv=0) B2(av=0 bv=0) dv=0
~D()
~B2()
~A()
~B1()
~A()

在C++中我们通过虚继承(virtual inheritance)解决上述问题

  • 虚继承的目的是令某个类做出声明, 承诺愿意共享它的基类
  • 其中, 共享的基类子对象称为虚基类 (virtual base class)
  • 在这种机制下, 不论虚基类在继承体系中出现了多少次, 在派生类中都只包含唯一一个共享的虚基类子对象

非必要不使用虚基类 ( E f f e c t i v e   C + + Effective\ C++ Effective C++)。因为为避免继承得来的成员变量重复,编译器必须提供若干幕后戏法,而其后果是:使用 virtual 继承的那些 classes 所产生的对象往往比使用 non-virtual 继承的兄弟们体积大,访问 virtual base classes 的成员变量时,也比访问 non-virtual base classes 的成员变量速度慢。种种细节因编译器不同而异,但基本重点很清楚:你得为 virtual 继承付出代价


  • 因为在每个共享的虚基类中只有唯一一个共享的子对象,所以该虚基类的成员可以被直接访问, 并且不会产生二义性
  • 此外, 如果虚基类的成员只被一条派生路径覆盖, 则我们仍然可以直接访问这个被覆盖的成员
  • 但是如果成员被多余一个基类覆盖, 则一般情况下派生类必须为该成员自定义一个新的版本

例如, 假定类B定义了一个名为x的成员, D1D2都是从B虚继承得到的, D继承了 D1D2, 则在D的作用域中, x通过D的两个基类都是可见的。如果我们通过D的对象使用X, 有三种可能性:

  • 如果在 D1D2中都没有x的定义, 则x将被解析为B的成员, 此时不存在二义性, 一个D的对象只含有x的一个实例
  • 如果xB的成员, 同时是 D1D2中某一个的成员, 则同样没有二义性, 派生类的x比共享虚基类Bx优先级更高
  • 如果在 D1D2中都有x的定义, 则直接访问x将产生二义性问题

例如,在过去, 科学界对大熊猫属于Raccoon科还是Bear科争论不休。为了如实地反映这种争论, 我们可以对Panda类进行修改, 令其同时继承BearRaccoon 。此时, 为了避免赋予Panda 两份ZooAnimal的子对象, 我们将BearRaccoon 继承ZooAnimal的方式定义为虚继承

在这里插入图片描述
观察这个新的继承体系, 我们将发现虚继承的一个不太直观的特征:

  • 必须在虚派生的真实需求出现前就已经完成虚派生的操作

例如在我们的类中, 当我们定义Panda时才出现了对虚派生的需求,但是如果BearRaccoon不是从ZooAnimal虚派生得到的,那么Panda的设计者就显得不太幸运了

  • 虚派生只影响从指定了虚基类的派生类中进一步派生出的类,它不会影响派生类本身

使用虚基类

  • 指定虚基类的方式是在派生列表中添加关键字virtual:
// 关键字public和virtual的顺序随意
class Raccoon : public virtual ZooAnimal (/* ... */ };
class Bear : virtual public ZooAnimal { /* ... */ };

// `Panda`中只有一个`ZooAnimal`基类部分
class Panda : public Bear,
			public Raccoon, public Endangered {
};

支持向基类的常规类型转换

  • 不论基类是不是虚基类, 派生类对象都能被可访问基类的指针或引用操作
  • 如果派生类中包含多个基类子对象,则不能将该派生类对象赋值给该基类对象的引用,因为会产生二义性 (指针同理)
void dance(const Bear&);
void rummage(const Raccoon&);
ostream& operator<<(ostream&, const ZooAnimal&);

Panda ying_yang;
dance(ying_yang); //正确:把一个Panda对象当成Bear传递
rummage(ying_yang); //正确:把一个Panda对象当成Raccoon传递
cout << ying_yang; //正确:把一个Panda对象当成ZooAnimal传递

构造函数 与 虚继承

  • 在虚派生中,虚基类是由最低层的派生类初始化的
    • 例如,当创建Panda对象时, 由Panda的构造函数独自控制ZooAnimal的初始化过程

为了理解这一规则,我们不妨假设当以普通规则处理初始化任务时会发生什么。在此例中, 虚基类将会在多条继承路径上被重复初始化。以ZooAnimal为例, 如果应用普通规则, 则RaccoonBear都会试图初始化Panda对象的ZooAnimal部分

  • 当然, 继承体系中的每个类都可能在某个时刻成为“ 最低层的派生类”。只要我们能创建虚基类的派生类对象, 该派生类的构造函数就必须初始化它的虚基类

例如在我们的继承体系中, 当创建一个Bear (或Raccoon)的对象时, 它已经位于派生的最低层,因此Bear (或Raccoon)的构造函数将直接初始化其ZooAnimal基类部分:

Bear::Bear(std::string name, bool onExhibit)
		: ZooAnimal(name, onExhibit, "Bear") { }
Raccoon::Raccoon(std::string name, bool onExhibit)
		: ZooAnimal(name, onExhibit, "Raccoon") { }

而当创建一个Panda 对象时, Panda位于派生的最低层并由它负责初始化共享的ZooAnimal基类部分。即使ZooAnimal不是Panda的直接基类, Panda的构造函数也可以初始化ZooAnimal:

Panda::Panda(std::string name, bool onExhibit)
		// 如果`Panda`没有显式地初始化`ZooAnimal`基类, 则`ZooAnimal` 的默认构造函数将被调用; 如果`ZooAnimal`没有默认构造函数, 则代码将发生错误
	:  	ZooAnimal(name, onExhibit, "Panda"),	// 首先构造虚基类部分
		Bear(name, onExhibit),		// 依次构造直接基类部分
		Raccoon(name, onExhibit),
		Endangered(Endangered::critical),
		sleeping_flag(false) { }	// 最后构造`Panda`部分

也就是说,含有虚基类的对象在构造时:

  1. 首先使用提供给最低层派生类构造函数的初始值初始化该对象的虚基类子部分 (虚基类总是先于非虚基类构造,与它们在继承体系中的次序和位置无关)
  2. 接下来按照直接基类在派生列表中出现的次序依次对其进行初始化
  3. 最后构造自身成员

构造函数 与 析构函数的次序

  • 一个类可以有多个虚基类。此时,这些虚的子对象按照它们在派生列表中出现的顺序从左向右依次构造
  • 对象的销毁顺序与构造顺序正好相反

例如,在下面这个稍显杂乱的TeddyBear 派生关系中有两个虚基类:ToyAnimal 是直接虚基类, ZooAnimalBear 的虚基类:

class Character { /* ... */ };
class BookCharacter : public Character { /* ... */ };
class ToyAnimal { /* ... */ };
class TeddyBear : 	public BookCharacter,
					public Bear, public virtual ToyAnimal
					(/* ... */};

编译器按照直接基类的声明顺序对其依次进行检查,以确定其中是否含有虚基类。如果有,则先构造虚基类,然后按照声明的顺序逐一构造其他非虚基类。因此,要想创建一个TeddyBear对象,需要按照如下次序调用这些构造函数:

ZooAnimal();	// Bear 的虚基类
ToyAnimal();	// 直接虚基类
Character();
BookCharacter();
Bear();
TeddyBear();

销毁顺序与构造顺序正好相反:首先销毁TeddyBear 部分,最后销毁ZooAnimal 部分

运行时类型识别

run-time type identification, RTTI

  • 运行时类型识别由两个运算符实现:
    • typeid运算符, 用于返回表达式的类型
    • dynamic_cast 运算符, 用于将基类的指针或引用安全地转换成派生类的指针或引用
  • 当我们将这两个运算符用于某种类型的指针或引用, 并且该类型含有虚函数时, 运算符将使用指针或引用所绑定对象的动态类型

  • RTTI 通常适用于你想在一个你认为的派生类对象上执行派生类操作函数 (非虚函数),但你手上却只有一个指向它的基类指针或引用
    • 与虚成员函数相比, 使用 RTTI 运算符蕴含着更多潜在的风险: 程序员必须清楚地知道转换的目标类型并且必须检查类型转换是否被成功执行。使用 RTTI 必须要加倍小心。在可能的情况下, 最好定义虚函数而非直接接管类型管理的重任

dynamic_cast 运算符

  • dynamic_cast 运算符, 用于将基类的指针或引用安全地转换成派生类的指针或引用 (也可以将派生类的指针或引用转化为基类指针或引用,不过这种转换就没必要用 dynamic_cast 了)
  • dynamic_cast 的许多实现版本执行速度相当慢,在注重效率的代码中要尤其注意

dynamic_cast<type*>(e)		// 转化为派生类的指针
dynamic_cast<type&>(e)		// 转化为派生类的左值引用
dynamic_cast<type&&>(e)		// 转化为派生类的右值引用
  • 其中,type必须是一个类类型, 且必须含有虚函数 (正常情况下,有继承关系的类也都会定义一个虚析构函数)
    • 在第一种形式中,e必须是一个有效的指针
    • 在第二种形式中,e必须是一个左值
    • 在第三种形式中, e不能是左值
  • 在上面的所有形式中, e的类型还必须符合以下三个条件中的任意一个,如果符合, 则类型转换可以成功。否则, 转换失败。如果一条dynamic_cast的转换目标是指针类型并且失败了, 则结果为0。如果转换目标是引用类型并且失败了,则dynamic_cast运算符将抛出一个bad_cast异常
    • e的类型是目标type公有派生类
    • e的类型是目标type公有基类
    • e的类型就是目标type的类型
  • 也可以对一个空指针执行dynamic_cast, 结果是所需类型的空指针

在什么情况下应该用 dynamic_cast 代替虚函数?

  • 如果我们需要在派生类中增加新的成员函数f,但又无法取得基类源代码增加相应虚函数时,如果我们还要使用基类指针来调用f,就必须使用 dynamic_cast 将其转化为派生类指针

指针类型的 dynamic_cast

  • 举个简单的例子,假定 Base 类至少含有一个虚函数,DerivedBase的公有派生类。如果有一个指向Base的指针bp, 则我们可以在运行时将它转换成指向Derived的指针:
// 在条件部分执行 dynamic_cast 操作可以确保类型转换和结果检查在同一条表达式中完成
if(Derived *dp = dynamic_cast<Derived*>(bp))
{
	// 使用dp 指向的Derived对象
} else { // bp 指向一个Base 对象
	// 使用bp 指向的Base对象
}
  • 如果bp指向Derived对象,则上述的类型转换初始化dp并令其指向bp 所指的Derived对象。此时,if语句内部使用Derived操作的代码是安全的
  • 否则,类型转换的结果为0, 意味着转换失败

引用类型的 dynamic_cast

  • 因为不存在所谓的空引用,所以引用类型无法使用与指针类型完全相同的错误报告策略。当对引用的类型转换失败时,程序抛出一个名为std::bad_cast的异常
void f(const Base &b)
{
	try {
		const Derived &d = dynamic_cast<const Derived&>(b);
		// 使用 b 引用的Derived对象
	} catch (bad_cast) {
		// 处理类型转换失败的情况
	}
}

typeid 运算符

  • typeid(e)e 可以是任意表达式或类型的名字
    • 当运算对象不属于类类型或者是一个不包含任何虚函数的类时, typeid运算符指示的是运算对象的静态类型
    • 当运算对象是定义了至少一个虚函数的类的左值时,typeid指示其动态类型
    • 和往常一样, 顶层const被忽略, 如果表达式是一个引用, 则typeid返回该引用所引对象的类型 (尽管引用变量是左值,但如果该引用为一个绑定在派生类上的基类引用,则typeid不会返回动态类型,而是直接返回基类类型)。不过当typeid作用于数组或函数时,并不会执行向指针的标准类型转换,而是得到相应的数组或函数类型

typeid 作用的表达式是否会被求值

  • 只有当类型含有虚函数时,编译器才会对表达式求值
    (编译器无须对表达式求值也能知道表达式的静态类型,只有求动态类型时才需要对表达式求值)
    • 例如,对于 typeid(*p)。如果指针p所指的类型不含有虚函数,则p不必非得是一个有效的指针。否则, *p 将在运行时求值, 此时p必须是一个有效指针。如果p是一个空指针, 则typeid(*p)将抛出一个名为bad_typeid的异常

使用 typeid 运算符

通常情况下, 我们使用 typeid

  • 比较两条表达式的类型是否相同
  • 或者 比较一条表达式的类型是否与指定类型相同
Derived *dp = new Derived;
Base *bp = dp;

// 比较 bp 和 dp 所指的对象的动态类型是否相同
if (typeid(*bp) == typeid(*dp)) {
	// bp 和 dp 指向同一类型的对象// 检查运行时类型是否是某种指定的类型
if (typeid(*bp) == typeid(Derived)) {
	// bp实际指向Derived对象
}

// 下面的条件永远是失败的: bp 的类型是指向 Base 的指针,typeid 返回其静态类型 Base*
if (typeid(bp) == typeid(Derived)) {}

type_info

  • typeid 操作的结果是一个常量对象的引用, 该对象的类型是标准库类型type_infotype_info 的公有派生类型

type_info 类定义在 typeinfo 头文件中, 并且至少提供如下操作:

  • t1 == t2:如果 type_info 对象t1t2表示同一种类型, 返回true;否则返回false
  • t1 != t2
  • t.name():返回一个C风格字符串, 表示类型名字的可打印形式。对某种给定的类型来说,name的返回值因编译器而异并且不一定与在程序中使用的名字一致。对name返回值的唯一要求是,类型不同则返回的字符串必须有所区别
int arr[10];

cout << typeid(42).name() << ", "
     << typeid(arr).name() << ", "
     << typeid(std::string).name() << endl;

output:

int, int [10], class std::basic_string<char,struct std::char_traits<char>,class std::allocator<char> >
  • t1.before(t2):返回一个bool值, 表示t1是否位于t2之前。before所采用的顺序关系依赖于编译器
  • 除此之外, 因为 type_info 类一般是作为一个基类出现,所以它还应该提供一个公有的虚析构函数。当编译器希望提供额外的类型信息时,通常在type_info的派生类中完成

  • type_info 类没有默认构造函数, 而且它的拷贝和移动构造函数以及赋值运算符都被定义成删除的,因此, 我们无法定义或拷贝type_info类型的对象, 也不能为type_info类型的对象赋值。创建type_info对象的唯一途径是使用typeid运算符

type_info类在不同的编译器上有所区别。有的编译器提供了额外的成员函数以提供程序中所用类型的额外信息

使用 RTTI

在某些情况下 RTTI 非常有用, 比如当我们想为具有继承关系的类实现相等运算符

  • 对两个对象来说, 如果它们的类型相同并且对应的数据成员取值相同, 则我们说这两个对象是相等的。在类的继承体系中, 每个派生类负责添加自己的数据成员, 因此派生类的相等运算符必须把派生类的新成员考虑进来
  • 一种容易想到的解决方案是定义一套虚函数, 令其在继承体系的各个层次上分别执行相等性判断。此时, 我们可以为基类的引用定义一个相等运算符, 该运算符将它的工作委托给虚函数equal, 由equal负责实际的操作
    • 遗憾的是, 上述方案很难奏效。虚函数的基类版本和派生类版本必须具有相同的形参类型。如果我们想定义一个虚函数equal, 则该函数的形参必须是基类的引用。此时,equal函数将只能使用基类的成员, 而不能比较派生类独有的成员

  • 要想实现真正有效的相等比较操作, 我们需要首先清楚一个事实:即如果参与比较的两个对象类型不同, 则比较结果为false。因此, 我们就可以使用 RTTI 解决问题了
    • 我们定义的相等运算符的形参是基类的引用,然后使用typeid检查两个运算对象的类型是否一致。类型一致才调用equal函数。每个类定义的equal函数负责比较类型自己的成员。这些运算符接受Base&形参,但是在进行比较操作前先把运算对象转换成运算符所属的类类型
class Base {
	friend bool operator==(const Base&, const Base&);
public:
	// Base的接口成员
protected:
	virtual bool equal(const Base&) const;
	// Base的数据成员和其他用于实现的成员
};
	
class Derived : public Base {
public:
	// Derived的其他接口成员
protected:
	bool equal(const Base&) const;
	// Derived的数据成员和其他用于实现的成员
};
bool operator==(const Base &lhs, canst Base &rhs)
{
	// 如果typeid不相同, 返回false; 否则虚调用equal
	return typeid(lhs) == typeid(rhs) && lhs.equal(rhs);
}
bool Derived::equal(const Base &rhs) const
{
	// 我们清楚这两个类型是相等的, 所以转换过程不会抛出异常
	auto r = dynamic_cast<const Derived&>(rhs);
	// 执行比较两个Derived对象的操作并返回结果
}

bool Base::equal(const Base &rhs) const
{
	// 执行比较Base对象的操作
}
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值