《C++ Primer Plus 第六版》学习笔记:第十三章 类继承

面向对象编程的主要目的之一是提供可重用的代码。尤其是当项目十分庞大时,重用经过测试的代码比重新编写代码要好得多。使用已有的代码可以节省时间,由于已有的代码已被使用和测试过,因此有助于避免在程序中引入错误。另外,必须考虑的细节越少,便越能专注于程序的整体策略。

C++类提供了比修改源码更好的方法来扩展和修改类,这种方法叫做类继承,通过继承派生出的类通常比设计新类容易得多。
本章将介绍继承简单的一面和复杂的一面。

下面是可以通过继承完成的一些工作:

  • 可以在已有类的基础上添加功能;
  • 可以给类添加数据;
  • 可以修改方法的行为。

13.1 一个简单的基类

原始类成为基类,继承类成为派生类。

13.1.1 派生一个类

class RatedPlayer : public TableTennisPlayer
{
...
}

冒号指出RatedPlayer类的基类是TableTennisplayer类。上述特殊的声明头表明TableTennisPlayer是一个公有基类,这被称为公有派生。使用公有派生,基类的公有成员将成为派生类的公有成员;基类的私有部分也将成为派生类的一部分,但只能通过基类的公有和保护方法访问。
上述代码完成了哪些工作呢?RaterPlayer对象将具有以下特性:

  • 派生类对象存储了基类的数据成员(派生类继承了基类的实现);
  • 派生类对象可以使用基类的方法(派生类继承了基类的接口)。

需要在继承特性中添加什么呢?

  • 派生类需要自己的构造函数;
  • 派生类可以根据需要添加额外的数据成员和成员函数。

13.1.2 构造函数:访问权限的考虑

派生类不能直接访问基类的私有成员,而必须通过基类方法进行访问。具体地说,派生类构造函数必须使用基类构造函数。
创建派生类对象时,程序首先创建基类对象。从概念上说,这意味着基类对象应当在程序进入派生类构造函数之前被创建。

//下面是第一个派生类构造函数代码(调用基类构造函数):
RaterPlayer::RaterPlayer(unsigned int r, string & fn, 
string & ln, bool ht):TableTennisPlayer(fn, ln, ht)
{
	rating = r;	//也可以使用成员初始化列表语法
}
//调用构造函数:
RaterPlayer rplayer(1140, "Mallory", "Duck", true);
//下面是第二个派生类构造函数代码(调用基类复制构造函数):
RaterPlayer::RaterPlayer(unsigned int r, 
const TableTennisPlayer & tp):TableTennisPlayer(tp)
{
	rating = r;	//也可以使用成员初始化列表语法
}
//调用构造函数:
RaterPlayer rplayer(1140, player);

注意,如果构造函数不显式调用基类构造函数,程序将使用默认的基类构造函数;
同理,如果构造函数不显示调用基类复制构造函数,程序将使用默认的基类复制构造函数(在这种情况下,隐式复制构造函数是合适的,因为这个类没有使用动态内存分配)。

有关派生类构造函数的要点如下:

  • 首先创建基类对象;
  • 派生类构造函数应通过成员初始化列表将基类信息传递给基类(复制)构造函数;
  • 派生类构造函数应初始化派生类新增的数据成员。

注意:释放对象的顺序与创建对象的顺序相反,即派生类对象过期时,程序将首先调用派生类析构函数,然后再调用基类析构函数。

注意:不是所有的函数都能自动地从基类继承到派生类中的。构造函数和析构函数是用来处理对象的创建和析构的,它们只知道对在它们的特殊层次的对象做什么。也就是说,构造函数和析构函数不能被继承。另外,因为operator=完成类似于构造函数的活动,所以也不能被继承

13.3.3 派生类和基类之间的特殊关系

  • 派生类可以使用基类的方法,条件是方法不是私有的;
  • 基类指针可以在不进行显示类型转换的情况下指向派生类对象;
  • 基类引用可以在不进行显示类型转换的情况下引用派生类对象。

上述规则是有道理的:例如,如果将基类对象赋给派生类引用,派生类引用将能够为基对象调用派生类方法,这样做将出现问题。
另外,兼容性属性也能让你能够将基类对象初始化为派生类对象。类定义中一种有这样的隐式复制构造函数:

TableTennisPlayer(const TableTennisPlayer &);

形参是积累引用,因此它可以引用派生类。因此将基类对象初始化为派生类对象时,它将初始化为嵌套在派生类对象中的基类对象。

13.2 is-a关系

派生类和基类之间的特殊关系是基于C++继承的底层模型的。实际上,C++有三种继承方式:公有继承、保护继承和私有继承。公有继承是最常用的方式,它建立以中is-a关系(准确的说是“is-a-kind-of”的关系,但通常使用属术语“is-a”),而不建立has-a、is-like-a、is-implemented-as-a(作为…来实现)关系。

13.3 多态公有继承

如果你希望同一个方法在派生类和基类中的行为是不同的,此时用这种技术。
有两种重要的机制可用于实现多态公有继承:

  • 在派生类中重新定义基类的方法;
  • 使用虚方法。

如果方法是通过引用或指针而不是对象调用的,它将确定使用哪一种方法。如果没有使用关键词virtual,程序将根据引用类型或指针类型选择方法;如果使用了virtual,程序将根据引用或指针指向的对象的类型来选择方法。

经常在基类中将派生类会重新定义的方法声明为虚方法。方法在基类中被声明为虚的后,它在派生类中将自动成为虚方法。然而,在派生类声明中使用关键字virtual来指出那些函数是虚函数也不失为一个好办法。

注意,关键字virtual只用于类声明的方法原型中,而不用于方法定义中。
派生类方法可以调用公有的基类方法:

void BrassPlus::ViewAcct() const
{
...
	Brass::ViewAcct();	//	display base portion
	cout << ... << endl;
	cout << ... << endl;
	...
}

在派生类方法中,标准技术是使用作用域解析运算符来调用积累方法。
注意,为何需要虚析构函数:使用虚析构函数可以确保正确的析构函数序列被调用。有时不定义虚析构函数并无大碍,但是如果派生类对象需要一个执行某些操作的析构函数,则派生类必须有一个虚析构函数。

13.4 静态联编和动态联编

将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编。在C++中,因为函数重载的缘故,这项任务更复杂。在编译过程中进行联编被称为静态联编(早期联编)。然而,虚函数的技术使得在编译过程中使用哪一个函数是不能确定的,因为编译器不知道用户将选择哪种类型的对象。所以,编译器必须生成能够在程序运行时选择正确的虚函数的代码,这被称为动态联编(晚期联编)

13.4.1 指针和引用类型的兼容性

将派生类引用或指针转换为基类引用或指针成为向上强制转换,这使公有继承不需要进行显示类型转换(Safe);相反的过程成为向下强制转换(Unsafe)。

13.4.2 虚成员函数和静态联编

编译器对非虚方法使用静态连边,对虚方法使用动态联编。

(1)既然动态联编如此之好,为什么不将它设置成默认的?

  • 效率方面:如果类不会用作基类,或者派生类不重新定义基类的任何方法,不需要使用动态联编,在这些情况下,使用静态联编更合理,效率也更高。由于静态联编的效率更高,因此被设置为C++的默认选择。

C++的指导原则之一是,不要为不使用的特性付出代价(内存或者处理时间)。

  • 概念模型:(恕我没看懂书上想表达的,我认为它有点赘述了,就不记录了)

类设计并不是一个线性过程。

(2)虚函数的工作原理:
通常,编译器处理虚函数的方法是:给每个对象添加一个隐藏成员。隐藏成员中保存了一个指向函数地址数组的指针。这种数组成为虚函数表(virtual function table, vtbl)。虚函数表中存储了为类对象进行声明的虚函数的地址。

例如,基类对象包含一个指针,该指针指向基类中所有虚函数的地址表。派生类对象将包含一个指向独立地址表的指针。如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址;如果派生类没有重新定义虚函数,该vbtl将保存函数原始版本的地址。如果派生类定义了新的虚函数,则该函数的地址也将被添加到vbtl中。注意,无论派生类中包含的虚函数是1个还是10个,都只需要在对象中添加1个地址成员,只是表的大小不同而已。
总之,使用虚函数时,在内存和执行速度方面有一定的成本,包括:

  • 每个对象都将增大,增大量为存储地址的空间;
  • 对于每个类。编译器都创建一个虚函数地址表(数组);
  • 对于每个函数调用,都需要执行一项额外的工作,即到表中查找地址。(书中说的是的“对于每个函数调用”,我不太理解,我认为应该是“对于每个虚函数调用”)。

13.4.3 有关虚函数注意事项(敲黑板划重点)

我们已经讨论了虚函数的一些要点:

  • 在基类方法的声明中使用关键字virtual可使该方法在基类以及所有的派生类(包括从派生类派生出来的类)中是虚的;
  • 如果使用指向对象的引用或指针来调用虚方法,程序将使用为对象类型定义的方法,而不使用为引用或指针类型定义的方法。这成为动态联编或晚期联编。这种行为非常重要,因为这样基类引用或指针可以只想派生类对象。
  • 如果定义的类将被用作基类,则应将那些要在派生类中重新定义的类方法声明为虚的。

对于虚方法,我们还需要了解其它一些知识,下面来看看这些内容:

  1. 构造函数不能是虚函数
  2. 析构函数应当是虚函数,除非类不用做基类。
//请看如下代码:
Employee * pe = new Singer;	//legal because Employee is base for Singer
...
delete pe;

如果使用默认的静态联编,delete语句将调用基类(Employee)的析构函数,这将释放由Singer对象中的Employee部分指向的内存,但不会释放新的类成员指向的内存。但如果析构函数是虚的,则上述代码将先调用派生类(~SInger)的析构函数释放由Singer组件指向的内存,然后,调用Employee的析构函数来释放由Employee组件指向的内存。

这意味着,即使基类不需要显示析构函数提供服务,也不应依赖于默认构造函数,而应提供虚析构函数,即使它不执行任何操作:

virtual ~BaseClass(){}
  1. 友元不能是虚函数,因为友元不是类成员,而只有类成员才能是虚函数。如果由于这个原因引起了设计问题,可以通过让友元函数使用虚成员函数来解决。
  2. 如果函数没有重新定义函数,将使用该函数的积累版本。如果派生类位于派生链(不懂…,先记下来,万一以后用到了呢)中,则将使用最新的虚函数版本,例外的情况是基类版本是隐藏的。
  3. 重新定义不会生成函数的两个重载版本,而是隐藏积累版本(重新定义继承的方法并不是重载)。这里引出了两条经验规则:第一,如果重新定义继承的方法,应确保与原来的原型完全相同,但如果返回类型是基类引用或指针,则可以修改为指向派生类的引用或指针,这种特性被称为“返回类型协变”;第二,如果及类声明被重载了,则应在派生类中重新定义所有的基类版本。

13.5 访问控制: protected

private和protected之间的区别只有在基类派生的类中才会表现出来。派生类的成员可以直接访问基类的保护成员,但不能直接访问基类的私有成员。因此,对于外部世界来说,保护成员的行为与私有成员相似:但对于派生类来说,保护成员的行为与公有成员相似。

最好对类成员数据采用私有访问控制,不要使用保护访问控制;同时通过基类方法使派生类能够访问基类数据。然而,对于成员函数来说,保护访问控制很有用,它让派生类能够访问公众不能使用的内部函数。

13.6 抽象基类

至此,介绍了简单继承和较复杂的多态继承。接下来更为复杂的是抽象基类(abstract base class, ABC)。
原谅这一部分即将被我略过(但其实是一项很有意思的技术),因为我觉得这一部分关键在于了解为什么C++要设计ABC。然后就只有代码能说明问题了。

13.7 继承和动态内存分配

13.7.1 第一种情况:派生类不使用new

假设基类使用了动态内存分配且声明中包含了构造函数使用new时需要的特殊方法:析构函数、复制构造函数和重载赋值运算符。不需要为派生类定义显式析构函数、复制构造函数和赋值运算符,因为派生类将自动调用基类的析构函数、复制构造函数、重载运算符对基类的相应部分进行操作,派生类新增加部分将调用系统默认复制构造函数、默认复制运算符、析构函数。

13.7.2 第二种情况:派生类使用new

假社派生类使用了new,这种情况下,必须为派生类定义显式析构函数、复制构造函数和赋值运算符。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值