文章目录
- 第十三章 类继承
- 13.1 一个简单的基类
- 13.1.1 派生一个类
- 13.1.2 构造函数:访问权限的考虑
- 13.1.3 使用派生类
- 13.1.4 派生类和基类之间的特殊关系
- 13.2 继承:is-a关系
- 13.3 多态公有继承
- 13.4 静态联编与动态联编
- 13.4.1 指针和引用类型的兼容性
- 13.4.2 虚成员函数和动态联编
- 为什么有两种类型的联编以及为什么默认为静态联编?
- 虚函数的工作原理
- 13.4.3 有关虚函数注意事项
- 13.5 访问控制:protected
- 13.6 抽象基类(abstract base class,ABC)
- 13.6.1 应用ABC概念
- 13.6.2 ABC理念
- 13.7 继承和动态内存分配
- 13.7.1 第一种情况:派生类不使用new
- 13.7.2 第二种情况:派生类使用new
- 13.7.3 使用动态内存分配和友元的继承示例
- 13.8 类设计回顾
- 13.8.1 公有继承的考虑因素
- 1.is-a关系
- 2.什么不能被继承
- 3.赋值运算符
- 4.友元函数
- 5.有关使用基类方法的说明
- 13.9 总结
第十三章 类继承
面向对象编程的主要目的之一是提供可重用的代码。你可以通过使用编写库函数去达到这个目的,但是更好的解决方式是通过设计类的形式去达到该目的。这种方式的好处有:
- 可以在已有类的基础上添加功能。
- 可以给类添加数据成员。
- 可以修改类方法的行为。
13.1 一个简单的基类
Table(const string &);
Table("zhangsan");
构造函数的形参写成const string&,但是实例化的时候传入的是const char*,这导致类型不匹配,但是sting类存在接收const char*的构造函数,就会先自动调用这个构造函数,先初始化string对象,再进行初始化。
13.1.1 派生一个类
class A : public B(){}
这里A继承自B,且B为一公有基类,使用公有派生,基类的公有成员将成为派生类的公有成员;基类的私有部分也将成为派生类的一部分,但只能通过基类的公有和保护方法访问。
- 派生类对象存储了基类的数据成员;
- 派生类对象可以使用基类的方法;
需要在派生类添加什么?
- 派生类需要自己的构造函数;
- 派生类可以根据需要添加额外的数据成员和成员函数;
13.1.2 构造函数:访问权限的考虑
派生类的构造函数:
RatedPlayer::RatedPlayer(int r,const string & fn,const string & ln,bool ht):TableTennisPlayer(fn,ln,ht){rating = r;
}
写成这种是最标准的。必须首先创建基类对象,如果不调用基类构造函数,程序将使用默认的基类构造函数,因此上述的代码与下面等效:
RatedPlayer::RatedPlayer(int r,const string & fn,const string & ln,bool ht)//:TableTennisPlayer()
{rating = r;
}//除非要使用默认构造函数,否则应显示调用正确的基类构造函数
派生类的构造函数总是调用一个基类构造函数,可以使用初始化列表语法指明要使用的基类构造函数,否则将使用默认的基类构造函数。派生类过期时,程序将首先调用派生类析构函数,然后再调用基类析构函数。
13.1.3 使用派生类
13.1.4 派生类和基类之间的特殊关系
派生类和基类之间有一些特殊关系。其中之一是派生类对象可以使用基类的方法,条件是方法不是私有的。另外两个重要关系是:基类指针可以在不进行显示类型转换的情况下引用派生类对象。
然而,基类指针或引用只能用于调用基类方法。C++要求引用和指针类型与赋给的类型匹配,但这一规则对继承来说是例外,但这种例外只是单向的,不可以将基类对象和地址赋给派生类引用和指针。
引用兼容性属性也让您能够将基类对象初始化为派生类对象。
RatedPlayer olaf(1840,"olaf","Loaf",true);//派生类
TableTennisPlayer olaf1(olaf);//基类
如果要这样做,匹配的构造函数的原型如下:
TableTennisPlayer(const RatedPlayer &);
显然,类定义中大概率不会定义这样的构造函数,但是存在隐式复制构造函数:
TableTennisPlayer(const TableTennisPlayer &);
换句话说,它将olaf1初始化为嵌套在olaf中的TableTennisPlayer对象。
同理,也可以将派生对象赋值给基类对象:
RatedPlayer olaf(1840,"olaf","Loaf",true);//派生类
TableTennisPlayer olaf1;//基类
olaf1 = olaf;
在这种情况下,将调用隐式重载赋值运算符:
TableTennisPlayer & operator=(const TableTennisPlayer &) const;
基类引用指向的也是派生类对象,因此olaf的基类部分被复制给olaf1.
13.2 继承:is-a关系
C++有3种继承方式:公有继承,保护继承和私有继承。公有继承是常用的方式,它建立一种is-a关系(实际上是is a kind of)。
13.3 多态公有继承
多态公有继承的两种实现机制:
- 在派生类中重新定义基类的方法
- 使用虚方法
在派生类方法中,标准技术是使用作用域解析运算符来调用基类方法。
void BrassPlus :: ViewAcct()const{ViewAcct();//这里如果不使用作用域解析运算符,将会调用自己,这样创建的就是一个不会终止的递归函数了
}
为何需要虚析构函数?
使用delete释放由new分配的对象的代码说明了为何基类应包含一个虚析构函数,虽然有时好像并不需要析构函数。
如果析构函数不是虚的,则将只调用对应于指针类型的析构函数。这意味着只有Brass(基类)的析构函数被调用,即使指针指向的是一个BrassPlus(派生类)对象。如果析构函数是虚的,将调用相应对象类型的析构函数。
因此,如果指针指向的是BrassPlus对象,将调用BrassPlus的析构函数,然后自动调用基类的析构函数。因此,使用虚析构函数可以确保正确的析构函数序列被调用。所以建议基类应该具有一个虚析构函数,即使该析构函数不执行任何操作。
13.4 静态联编与动态联编
将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编。在编译过程中进行的联编被称为静态联编(static binding),又称为早期联编。然而,虚函数使得这项工作变得更困难,因为使用哪一个函数是不能在编译时确定的,因为编译器不知道用户将选择哪种类型的对象,所以,编译器必须生成能够在程序运行时选择正确的虚方法的代码,这被称为动态联编(dynamic binding),又称为晚期联编。
13.4.1 指针和引用类型的兼容性
将派生类引用或指针转换为基类引用或指针被称为向上强制转换,这使公有继承不需要进行显示类型转换。向上的强制类型转换是可传递的,也就是说,如果从BrassPlus类派生出BressPlusPlus类,则Brass(基类)指针或引用可以引用Brass对象,BrassPlus对象,BrassPlusPlus对象。
将基类指针或引用转换为派生类指针或引用,称为向下强制转换,如果不使用显示类型转换,则向下强制转换是不允许的。
隐式向上强制转换使基类指针或引用可以指向基类对象或派生类对象,因此需要动态联编。C++使用虚成员函数满足这种需求。
13.4.2 虚成员函数和动态联编
编译器对非虚方法使用静态联编,对虚方法使用动态联编。
- 为什么有两种类型的联编?
- 既然动态联编如此之好,为什么不将它设置成默认的?
- 动态联编是如何工作的?
为什么有两种类型的联编以及为什么默认为静态联编?
原因有两个:效率和概念模型。
由于静态联编的效率更高,因此被设置为C++的默认选择。C++的指导原则之一是,不要为不使用的特性付出代价。仅仅当程序设计确实需要虚函数时,才使用它们。如果在派生类中重新定义基类的方法,则将它设置为虚方法,否则,设置为非虚方法。
虚函数的工作原理
通常,编译器处理虚函数的方法是:给每个对象添加一个隐藏成员。隐藏成员中保存了一个指向函数地址数组的指针。这种数组称为虚函数表(virtual function table,vtbl)。
虚函数表中存储了为类对象进行声明的虚函数的地址。例如,基类对象包含一个指针,该指针指向基类中所有虚函数的地址表。派生类对象将包含一个指向独立地址表的指针。如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址;如果派生类没有重新定义虚函数,该vtbl将保存函数原始版本的地址。如果派生类定义了新的虚函数,则该函数的地址也将被添加到vtbl中。注意,无论类中包含的虚函数是1个还是10个,都只需要在对象中添加1个地址成员,只是表的大小不同而已。
调用虚函数时,程序将查看存储在对象中的vtbl地址,然后转向相应的函数地址表。如果使用类声明中定义的第一个虚函数,则程序将使用数组中的第一个函数地址,并执行具有该地址的函数。如果使用类声明中的第三个虚函数,程序将使用地址为数组中第三个元素的函数。
总之,使用虚函数时,在内存和执行速度方面有一定的成本,包括:
- 每个对象都将增大,增大量为存储地址的空间;
- 对于每个类,编译器都创建一个虚函数地址表(数组);
- 对于每个函数调用,都需要执行一项额外的操作,即到表中查找地址。
虽然非虚函数的效率比虚函数稍高,但不具备动态联编功能。
13.4.3 有关虚函数注意事项
- 在基类方法的声明中使用关键字virtual可使该方法在基类以及所有的派生类(包括从派生类派生出来的类)中是虚的。
- 如果使用指向对象的引用或指针来调用虚方法,程序将使用为对象类型定义的方法,而不使用为引用或指针类型定义的方法。这称为动态联编或晚期联编。这种行为非常重要,因为这样基类指针或引用可以指向派生类对象。
- 如果定义的类将被用作基类,则应将那些要在派生类中重新定义的类方法声明为虚的。
对于虚方法,还需要了解其他一些知识,其中有的已经介绍过。下面来看看这些内容。
1.构造函数
构造函数不能是虚函数。创建派生类对象时,将调用派生类的构造函数,而不是基类的构造函数,然后,派生类的构造函数将使用基类的一个构造函数,这种顺序不同于继承机制。因此,派生类不继承基类的构造函数,所以将类构造函数声明为虚的没什么意义。
2.析构函数
析构函数应当是虚函数,除非类不用做基类。顺便说一句,给类定义一个虚析构函数并非错误,即使这个类不用做基类;这只是一个效率方面的问题。
3.友元
友元不能是虚函数,因为友元不是类成员,而只有成员才能是虚函数。如果由于这个原因引起了设计问题,可以通过让友元函数使用虚成员函数来解决。
4.没有重新定义
如果派生类没有重新定义函数,将使用该函数的基类版本。如果派生类位于派生链中,则将使用最新的虚函数版本,例外的情况是基类版本是隐藏的(稍后将介绍)。
5.重新定义将隐藏方法
class Dwelling{public:virtual void showperks(int a) const;
}
class Hovel : public Dwelling{public:virtual void showperks() const;
}
新定义将showperks( )定义为一个不接受任何参数的函数。重新定义不会生成函数的两个重载版本,而是隐藏了接受一个int参数的基类版本。总之,重新定义继承的方法并不是重载。如果在派生类中重新定义函数,将不是使用相同的函数特征标覆盖基类声明,而是隐藏同名的基类方法,不管参数特征标如何。
这引出了两条经验规则:第一,如果重新定义继承的方法,应确保与原来的原型完全相同,但如果返回类型是基类引用或指针,则可以修改为指向派生类的引用或指针(这种例外是新出现的)。这种特性被称为返回类型协变(covariance of return type),因为允许返回类型随类类型的变化而变化:注意,这种例外只适用于返回值,而不适用于参数。
class Dwelling{public:virtual Dwelling & build(int n);
}
class Hovel : public Dwelling{public:virtual Hovel & build (int n);
}
第二,如果基类声明被重载了,则应在派生类中重新定义所有的基类版本。如果只重新定义一个版本,则另外两个版本将被隐藏,派生类对象将无法使用它们。注意,如果不需要修改,则新定义可只调用基类版本。
13.5 访问控制:protected
关键字protected与private相似,在类外只能用公有类成员来访问protected部分中的类成员。private和protected之间的区别只有在基类派生的类中才会表现出来。派生类的成员可以直接访问基类的保护成员,但不能直接访问基类的私有成员。因此,对于外部世界来说,保护成员的行为与私有成员相似;但对于派生类来说,保护成员的行为与公有成员相似。
13.6 抽象基类(abstract base class,ABC)
C++通过使用纯虚函数(pure virtual function)提供未实现的函数。纯虚函数声明的结尾处为=0。当类声明中包含纯虚函数时,则不能创建该类的对象。要成为真正的ABC,必须至少包含一个纯虚函数。C++甚至允许纯虚函数有定义。
总之,在原型中使用=0指出类是一个抽象基类,在类中可以不定义该函数。
13.6.1 应用ABC概念
13.6.2 ABC理念
可以将ABC看作是一种必须实施的接口。ABC要求具体派生类覆盖其纯虚函数——迫使派生类遵循ABC设置的接口规则。
13.7 继承和动态内存分配
13.7.1 第一种情况:派生类不使用new
基类中使用了动态内存分配,但是声明中包含了构造函数使用new时需要的特殊方法:析构函数、复制构造函数和重载赋值运算符。
但是其派生类中不使用new。也未包含其他一些不常用的、需要特殊处理的设计特性:
是否需要为lackDMA类定义显式析构函数、复制构造函数和赋值运算符呢?不需要。
首先,来看是否需要析构函数。如果没有定义析构函数,编译器将定义一个不执行任何操作的默认构造函数。实际上,派生类的默认构造函数总是要进行一些操作:执行自身的代码后调用基类析构函数。因为我们假设lackDMA成员不需执行任何特殊操作,所以默认析构函数是合适的。
接着来看复制构造函数。第12章介绍过,默认复制构造函数执行成员复制,这对于动态内存分配来说是不合适的,但对于新的lacksDMA成员来说是合适的。因此只需考虑继承的baseDMA对象。要知道,成员复制将根据数据类型采用相应的复制方式,因此,将long复制到long中是通过使用常规赋值完成的;但复制类成员或继承的类组件时,则是使用该类的复制构造函数完成的。所以,lacksDMA类的默认复制构造函数使用显式baseDMA复制构造函数来复制lacksDMA对象的baseDMA部分。因此,默认复制构造函数对于新的lacksDMA成员来说是合适的,同时对于继承的baseDMA对象来说也是合适的。
对于赋值来说,也是如此。类的默认赋值运算符将自动使用基类的赋值运算符来对基类组件进行赋值。因此,默认赋值运算符也是合适的。
派生类对象的这些属性也适用于本身是对象的类成员。例如,第10章介绍过,实现Stock类时,可以使用string对象而不是char数组来存储公司名称。标准string类和本书前面创建的String类一样,也采用动态内存分配。现在,读者知道了为何这不会引发问题。Stock的默认复制构造函数将使用string的复制构造函数来复制对象的company成员;Stock的默认赋值运算符将使用string的赋值运算符给对象的company成员赋值;而Stock的析构函数(默认或其他析构函数)将自动调用string的析构函数。
13.7.2 第二种情况:派生类使用new
在这种情况下,必须为派生类定义显式析构函数、复制构造函数和赋值运算符。
派生类析构函数自动调用基类的析构函数,故其自身的职责是对派生类构造函数执行工作的进行清理。因此,hasDMA析构函数必须释放指针style管理的内存,并依赖于baseDMA的析构函数来释放指针label管理的内存。
接下来看复制构造函数。BaseDMA的复制构造函数遵循用于char数组的常规模式,即使用strlen( )来获悉存储C-风格字符串所需的空间、分配足够的内存(字符数加上存储空字符所需的1字节)并使用函数strcpy( )将原始字符串复制到目的地:
hasDMA复制构造函数只能访问hasDMA的数据,因此它必须调用baseDMA复制构造函数来处理共享的baseDMA数据。
需要注意的一点是,成员初始化列表将一个hasDMA引用传递给baseDMA构造函数。没有参数类型为hasDMA引用的baseDMA构造函数,也不需要这样的构造函数。因为复制构造函数baseDMA有一个baseDMA引用参数,而基类引用可以指向派生类型。因此,baseDMA复制构造函数将使用hasDMA参数的baseDMA部分来构造新对象的baseDMA部分。
接下来看赋值运算符。
由于hasDMA也使用动态内存分配,所以它也需要一个显式赋值运算符。作为hasDMA的方法,它只能直接访问hasDMA的数据。然而,派生类的显式赋值运算符必须负责所有继承的baseDMA基类对象的赋值,可以通过显式调用基类赋值运算符来完成这项工作
baseDMA::operator=(hs);
但通过使用函数表示法,而不是运算符表示法,可以使用作用域解析运算符。实际上,该语句的含义如下:
*this = hs;
当然编译器将忽略注释,所以使用后面的代码时,编译器将使用hasDMA ::operator=( ),从而形成递归调用。使用函数表示法使得赋值运算符被正确调用。
总之,当基类和派生类都采用动态内存分配时,派生类的析构函数、复制构造函数、赋值运算符都必须使用相应的基类方法来处理基类元素。这种要求是通过三种不同的方式来满足的。对于析构函数,这是自动完成的;对于构造函数,这是通过在初始化成员列表中调用基类的复制构造函数来完成的;如果不这样做,将自动调用基类的默认构造函数。对于赋值运算符,这是通过使用作用域解析运算符显式地调用基类的赋值运算符来完成的。
13.7.3 使用动态内存分配和友元的继承示例
因为友元不是成员函数,所以不能使用作用域解析运算符来指出要使用哪个函数。这个问题的解决方法是使用强制类型转换,以便匹配原型时能够选择正确的函数。
13.8 类设计回顾
在下述情况下,将使用复制构造函数:
- 将新对象初始化为一个同类对象;
- 按值将对象传递给函数;
- 函数按值返回对象;
- 编译器生成临时对象。
使用explicit将禁止进行隐式转换,但仍允许显式转换。
C++11支持将关键字explicit用于转换函数。与构造函数一样,explicit允许使用强制类型转换进行显式转换,但不允许隐式转换。
在继承使用虚函数时,被定义为接受基类引用参数的函数可以接受派生类。
13.8.1 公有继承的考虑因素
1.is-a关系
在某些情况下,最好的方法可能是创建包含纯虚函数的抽象类,并从它派生出其他的类。
请记住,表示is-a关系的方式之一是,无需进行显式类型转换,基类指针就可以指向派生类对象,基类引用可以引用派生类对象。
2.什么不能被继承
构造函数是不能继承的,也就是说,创建派生类对象时,必须调用派生类的构造函数。然而,派生类构造函数通常使用成员初始化列表语法来调用基类构造函数,以创建派生对象的基类部分。如果派生类构造函数没有使用成员初始化列表语法显式调用基类构造函数,将使用基类的默认构造函数。
析构函数也是不能继承的。然而,在释放对象时,程序将首先调用派生类的析构函数,然后调用基类的析构函数。如果基类有默认析构函数,编译器将为派生类生成默认析构函数。通常,对于基类,其析构函数应设置为虚的。
3.赋值运算符
如果对象属于派生类,编译器将使用基类赋值运算符来处理派生对象中基类部分的赋值。
如果类构造函数使用new来初始化指针,则需要提供一个显式赋值运算符。因为对于派生对象的基类部分,C++将使用基类的赋值运算符,所以不需要为派生类重新定义赋值运算符。
如果派生类使用了new,则必须提供显式赋值运算符。必须给类的每个成员提供赋值运算符,而不仅仅是新成员。总之,可以将派生对象赋给基类对象,但这只涉及基类的成员。派生类引用不能自动引用基类对象。
总之,问题“是否可以将基类对象赋给派生对象?”的答案是“也许”。如果派生类包含了这样的构造函数,即对将基类对象转换为派生类对象进行了定义,则可以将基类对象赋给派生对象。如果派生类定义了用于将基类对象赋给派生对象的赋值运算符,则也可以这样做。如果上述两个条件都不满足,则不能这样做,除非使用显式强制类型转换。
4.友元函数
由于友元函数并非类成员,因此不能继承。然而,您可能希望派生类的友元函数能够使用基类的友元函数。为此,可以通过强制类型转换将,派生类引用或指针转换为基类引用或指针,然后使用转换后的指针或引用来调用基类的友元函数。也可以使用第15章将讨论的运算符dynamic_cast<>来进行强制类型转换。
5.有关使用基类方法的说明
以公有方式派生的类的对象可以通过多种方式来使用基类的方法。
- 派生类对象自动使用继承而来的基类方法,如果派生类没有重新定义该方法。
- 派生类的构造函数自动调用基类的构造函数。
- 派生类的构造函数自动调用基类的默认构造函数,如果没有在成员初始化列表中指定其他构造函数。
- 派生类构造函数显式地调用成员初始化列表中指定的基类构造函数。
- 派生类方法可以使用作用域解析运算符来调用公有的和受保护的基类方法。
- 派生类的有元函数可以通过强制类型转换,将派生类引用或指针转换为基类引用或指针,然后使用该引用或指针来调用基类的友元函数。
13.9 总结
继承通过使用已有的类(基类)定义新的类(派生类),使得能够根据需要修改编程代码。公有继承建立is-a关系,这意味着派生类对象也应该是某种基类对象。作为is-a模型的一部分,派生类继承基类的数据成员和大部分方法,但不继承基类的构造函数、析构函数和赋值运算符。派生类可以直接访问基类的公有成员和保护成员,并能够通过基类的公有方法和保护方法访问基类的私有成员。可以在派生类中新增数据成员和方法,还可以将派生类用作基类,来做进一步的开发。每个派生类都必须有自己的构造函数。程序创建派生类对象时,将首先调用基类的构造函数,然后调用派生类的构造函数;程序删除对象时,将首先调用派生类的析构函数,然后调用基类的析构函数。
如果要将类用作基类,则可以将成员声明为保护的,而不是私有的,这样,派生类将可以直接访问这些成员。然而,使用私有成员通常可以减少出现编程问题的可能性。如果希望派生类可以重新定义基类的方法,则可以使用关键字virtual将它声明为虚的。这样对于通过指针或引用访问的对象,能够根据对象类型来处理,而不是根据引用或指针的类型来处理。具体地说,基类的析构函数通常应当是虚的。
可以考虑定义一个ABC:只定义接口,而不涉及实现。例如,可以定义抽象类Shape,然后使用它派生出具体的形状类,如Circle和Square。ABC必须至少包含一个纯虚方法,可以在声明中的分号前面加上=0来声明纯虚方法。
不一定非得定义纯虚方法。对于包含纯虚成员的类,不能使用它来创建对象。纯虚方法用于定义派生类的通用接口。