C++ primer plus 第13章类继承
1 一个简单得基类
从一个类派生出另一个类时,原始类称为基类,继承类称为派生类。
1.1 派生一个类
class RatedPlayer :public TableTennisPlayer
{
}
冒号指出RatedPlayer 类的基类是TableTennisPlayer类。派生类对象包含基类对象。使用公有派生,基类的公有成员将成为派生类的公有成员;基类的私有部分也将称为派生类的一部分,但是只能通过基类的公有和保护方法访问。
- 派生类对象存储了基类得数据成员(派生类继承了积累的实现)
- 派生类对象可以使用基类的方法(派生类继承了基类的接口)
需要在继承特性中添加什么呢?
- 派生类需要自己的构造函数
- 派生类可以根据需要添加额外的数据成员和成员函数。
1.2 构造函数:访问权限的考虑
派生类不能直接访问基类的私有成员,而必须通过基类方法进行访问。因此派生类构造函数必须使用基类构造函数;
创建派生类对象时,程序首先创建基类对象。从概念上讲,这意味着基类对象应当在程序进入派生类构造函数之前被创建。C++使用成员初始化列表来完成这种工作。
RatedPlayer::RatedPlayer(unsigned int r, const string & fn, const string & ln, bool ht):TableTennisPlayer(fn,ln,ht)
{
rating = r;
}
如果省略了基类构造函数的话:
RatedPlayer::RatedPlayer(unsigned int r, const string & fn, const string & ln, bool ht)
{
rating = r;
}
首先还是创建基类对象,如果不调用基类构造函数,程序将使用默认的基类构造函数。
因此上述代码与下面等效:
RatedPlayer::RatedPlayer(unsigned int r, const string & fn, const string & ln, bool ht):TableTennisPlayer()
{
rating = r;
}
除非要使用默认构造函数,否则应该显式调用正确的基类构造函数。
还有一种构造函数代码:
RatedPlayer::RatedPlayer(unsigned int r, const TableTennisPlayer & tp):TableTennisPlayer(tp)
{
rating = r;
}
这里也是将TableTennisPlayer的信息传递给了TableTennisPlayer构造函数。
这种方式将调用基类的复制构造函数,如果基类没有定义复制构造函数,但又要使用它,则编译器将自动生成一个。
甚至还可以对派生类成员使用成员初始化列表语法:
RatedPlayer::RatedPlayer(unsigned int r, const TableTennisPlayer & tp):TableTennisPlayer(tp),rating(r)
{
}
派生类构造函数的要点总结:
- 首先创建基类对象;
- 派生类构造函数应通过成员初始化列表(TableTennisPlayer(tp))将基类信息传递给基类构造函数;
- 派生类构造函数应初始化派生类新增的数据成员(rating = r)
释放对象的顺序与创建对象的顺序相反:首先执行派生类的析构函数,然后自动调用基类的析构函数。
1.3 使用派生类
使用派生类,程序必须要能够访问基类声明。可以把两个类的声明放在同一个头文件中,也可以放在不同的头文件中。
1.4 派生类和基类之间的特殊关系
派生类可以使用基类的方法,条件是方法不是私有的。
基类指针可以在不进行显式类型转换的情况下指向派生类对象。基类引用可以在不进行显式类型转换的情况下引用派生类对象。但是基类指针和引用只能调用基类的方法。
C++中要求引用和指针类型与赋予的类型匹配,但这一规则对继承来说是例外。这例外是单向的,也就是说不能将基类对象和地址赋给派生类引用和指针。
这样要求是有道理的:如果允许基类引用隐式地调用派生类方法,则可以使用基类引用为派生类对象调用基类的方法。因为派生类继承了基类的方法,所以这样做不会有问题。
如果可以将基类对象赋给派生类引用,将发生什么情况?派生类引用能够为基类对象调用派生类方法,这是没有意义的。例如TableTennisPlayer没有rating成员。
2 继承:is-a关系
3 多态公有继承
有时候希望同一个方法在派生类和基类中的行为是不同的。换句话说,方法的行为取决于调用该方法的对象。这种较复杂的行为称为多态——具有多种形态。即同一种方法其行为随上下文而异。有两种重要的机制可用于实现多态公有继承:
- 在派生类中重新定义基类的方法。
- 使用虚方法。
如果要在派生类中重新定义基类的方法,通常应将基类方法声明为虚的。这样,程序将根据对象类型而不是引用或指针的类型来选择方法版本。为基类声明一个虚析构函数也是一种惯例。这样做是为了确保释放派生对象时,按正确的顺序调用析构函数。注意virtual只用在声明中,不能用在定义中。
虚方法的演示:
假设要同时管理Brass和BrassPlus账户,如果能使用同一个数组来保存Brass和BrassPlus对象,将很有帮助。这是可能的使用指向Brass类型的指针,就能保证这个数组来表示多种类型的对象,又保证数组的元素都是同一种类型的。这样由于使用的是公有继承模型,因此Brass指针既可以指向Brass对象,也可以指向BrassPlus对象。
为何要使用虚析构函数:
如果析构函数不是虚的话,则将只调用对应指针类型的析构函数。
虚方法就是给引用或指针调用对象时根据对象的类型确定调用哪个方法而设计的。不加virtual的话,就会根据引用或指针的类型调用方法了,这就容易造成一些问题。
4 静态联编和动态联编
程序调用函数时,将使用哪个可执行代码块呢?编译器负责回答这个问题。
将源代码中的函数调用解释为特定的函数代码块被称为函数名联编(binding)。
在C语言中,这非常简单,因为每个函数名对应一个不同的函数。
但是在C++中由于函数重载的缘故,这项任务非常复杂。编译器必须查看函数参数才能确定使用哪个函数。编译器可以在编译过程中完成联编,这被称作静态联编,又称为早期联编。然而,虚函数使这项工作变得更加困难。使用哪个函数不是在编译时就能确定的,因为编译器不知道用户将选择哪个类型的对象。所以编译器必须生成能够在程序运行时选择正确的虚方法的代码,这被称为动态联编,又称为晚期联编。
4.1 指针和引用类型的兼容性
将派生类引用或指针转换为基类引用或指针被称为向上强制转换(upcasting)。这使公有继承不需要进行显式类型转换。该规则是is-a关系的一部分。
相反的过程——将基类指针或引用转换为派生类指针或引用——称为向下强制转换(downcasting)。如果不使用显式类型转换,向下强制转换是不允许的。
is-a关系是不可逆的,派生类可以新增数据成员,因此使用这些数据成员的类成员函数不能应用于基类。
4.2 虚成员函数和动态联编
编译器对非虚方法使用静态联编。对虚方法采用动态联编。
- 为什么有两种类型的联编以及为什么默认为静态联编?
这涉及到效率和概念模型。为了使程序能够在运行阶段进行决策,必须采用一些方法跟踪基类指针或引用指向的对象类型,这增加了额外的的处理开销。例如,如果这个类不用做基类,则不需要动态联编。如果派生类不重新定义基类的任何方法,也不需要使用动态联编。这些情况下使用静态联编更合理,效率也更高。因此被设置为C++的默认选择。C++的指导原则之一就是不要为不使用的特性付出代价(内存或处理时间)。仅当程序确实需要虚函数时,才使用它们。
概念模型:仅将那些预期被重新定义的方法声明为虚的。如果要在派生类中重新定义基类的方法,则将它设置为虚方法;否则,设置为非虚方法。在设计类时,方法属于哪种情况有时候并不那么明显。与现实世界中的很多方面一样,类设计并不是一个线性过程。
- 虚函数的工作原理
4.3 有关虚函数的注意事项
-
在基类方法的声明中使用关键字virtual可使该方法在基类以及所有派生类(包括从派生类派生出来的类)当中都是虚的。
-
如果使用指向对象的指针或引用来调用虚方法,程序将使用为对象类型定义的方法,而不使用为引用或指针类型定义的方法。这称为动态联编或晚期联编。这种行为非常重要,因为这样基类指针或引用可以指向派生类对象。
-
如果定义的类将被用作基类,则应该将那些要在派生类中重新定义的类方法声明为虚的。
- 构造函数
构造函数不能是虚函数,因为调用构造函数是明确的,创建派生类对象时,将调用派生类的构造函数,而不是基类的构造函数。然后,派生类的构造函数将调用基类的构造函数,这种顺序不同于继承机制。派生类不继承基类的构造函数,所以将派生类的构造函数声明为虚的没什么意义。
- 析构函数
基类的析构函数必须是虚函数,除非不用作基类,因为这样编译器才知道调用对象类型对应的析构函数,而不是指针或引用类型对应的析构函数。通常应该给基类提供一个虚析构函数,即使它不需要析构函数。
- 友元
友元不能是虚函数,因为友元不是类成员,只有成员才能是虚函数。
- 没有重新定义
如果派生类没有重新定义函数,将使用该函数的基类版本。如果派生类位于派生链中,则将使用最新的虚函数版本,例外的情况是基类版本是隐藏的。
- 重新定义将隐藏方法
class Dwelling
{
public:
virtual void showperks(int a) const;
};
class Hovel :public Dwelling
{
public:
virtual void showperks() const;
}
Hovel trump;
trump.showperks(); //valid
trump.showperks(5); //invalid
新定义将showperks()定义为一个不接受任何参数的函数。重新定义不会生成函数的两个重载版本。
载版本,而是隐藏了接受一个int参数的基类版本。总之,重新定义继承的方法并不是重载。如果重新定义派生类中的函数,将不只是使用相同的函数参数列表覆盖基类声明,无论参数列表是否相同,该操作将隐藏所有的同名基类方法。
总结两条经验:
1、如果重新定义继承的方法,应确保与原来的原型完全相同,但如果返回类型是基类引用或指针,则可以修改为指向派生类的引用或指针。这种特性被称为返回类型协变。因为允许返回类型随类类型的变化而变化。
2、 如果基类声明被重载了,则应该在派生类中重新定义所有基类版本。如果只重新定义一个版本,则另外两个版本将被隐藏,派生类对象将无法使用它们。
5 访问控制:protected
public和private来控制对类成员的访问。
还存在另外一个访问类别,这种类别用关键字protected表示。protected和private相似,在类外只能用公有类成员来访问protected部分中的类成员。但是它们之间的区别体现在基类派生的类中。派生类的成员可以直接访问基类的保护成员,但不能直接访问基类的私有成员。对于外部世界来说,保护成员的行为和私有成员相似。但对于派生类来说,保护成员的行为与公有成员相似。
保护访问控制的好处是,让派生类能够访问公众不能使用的内部函数。最好对数据成员采用私有访问控制,不要使用保护访问控制,同时通过基类方法使派生类能够访问基类数据。
使用保护数据成员可以简化代码的编写工作,但存在设计缺陷,就是派生类如果把继承的保护数据成员的修改方式写到公有成员函数里,这样通过派生类的成员函数来访问该保护成员,这样实际上使得该保护数据成员成为公有变量。
6 抽象基类
abstract base class,ABC 抽象基类
有时候is-a规则并不像看上去那么简单,例如圆和椭圆的关系。圆是椭圆的特殊情况。椭圆可以派生出圆。但是椭圆的数据成员及方法对于圆来说是信息冗余。圆类继承椭圆类会显得很繁琐。
如果把圆和椭圆分开定义,这种方法效率不高,因为它忽略了圆类和椭圆类的许多共同点。
还有一种方法:将圆和椭圆的共性抽象出来,放到ABC中,再由ABC派生出圆类和椭圆类。这样便可以使用基类指针数组同时管理圆类和椭圆类对象。即可以使用多态方法。
例如圆和椭圆的共同点是中心坐标,Move()方法(对于这两个类来说是相同的)和Area()方法(对于这两个类来说是不同的)。Area()方法不能在ABC中实现,没有包含必要的数据成员,C++通过使用纯虚函数提供未实现的函数。在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。这就是纯虚函数的作用。
纯虚函数的结尾为=0;
class BaseEllipse
{
private:
double x;
double y;
public:
BaseEllipse(double x0=0,double y0=0):x(x0),y(yo){}
virtual ~BaseEllipse() {}
void Move(int nx, ny) {x=nx; y=ny}
virtual double Area() const=0; //a pure virtual function
}
如果一个类定义了纯虚函数的话,则这个类无法创建该类的对象。包含纯虚函数的类属于基类,要成为真正的ABC,必须至少包含一个纯虚函数。原型中使用=0使虚函数称为纯虚函数。
BaseEllipse类是Ellipse类和Circle类的基类,后两者都是具体类(concrete)。
如果要设计类继承层次,则只能将那些不会被用作基类的类设计为具体的类。这种方法的设计更清晰,复杂程序更低。
可以将ABC看作是必须实施的接口。ABC要求具体派生类覆盖其纯虚函数——迫使派生类遵循ABC设置的接口规则。这种模型在基于组件的编程模式中很常见。使用ABC使得组件设计人员能够制定“接口约定”,这样确保了从ABC派生的所有组件都至少支持ABC指定的功能。
6 继承和动态内存分配
如果基类使用动态内存分配,并重新定义赋值和复制构造函数,这将怎样影响派生类的实现?这个问题的答案取决于派生类的属性。如果派生类也使用动态内存分配,那就需要注意学习新的小技巧。
6.1 第一种情况:派生类不适用new
//Base Class Using DMA
class baseDMA
{
private:
char * label;
int rating;
public:
baseDMA(const char * l ="null", int r=0);
baseDMA(const baseDMA & rs);
virtual ~baseDMA();
base DMA & operator =(const baseDMA & rs);
...
};
//derived class without DMA
class lacksDMA : public baseDMA
{
private:
char color[40];
public:
...
}
派生类不使用new,不需要定义显式的析构函数,因为我们假设lackDMA成员不需执行任何特殊操作,所以默认析构函数时合适的。
复制构造函数:默认复制构造函数执行成员复制,这对于动态内存分配来说是不合适的,但对于新的lacksDMA成员来说是合适的。LacksDMA类的默认复制构造函数使用显式baseDMA复制构造函数来复制lackDMA对象的baseDMA部分。因此,默认复制构造函数对于新的lackDMA成员来说是合适的,同时对于继承的baseDMA对象来说也是合适的。
对于赋值来说,也是如此。类的默认赋值运算符将自动使用基类的赋值运算符对基类组件进行赋值。因此,默认赋值运算符也是合适的。
派生类对象的这些属性也适用于本身就是对象的类成员。
6.2 第二种情况:派生类使用new
这种情况下,必须为派生类定义显式析构函数,赋值构造函数和赋值运算符。
//derived class with DMA
class hasDMA:public baseDMA
{
private:
char * style;
public:
...
}
派生类析构函数自动调用基类的析构函数,故其自身的职责是对派生类构造函数执行工作的进行清理。因此,hasDMA析构函数必须释放指针style管理的内存,并依赖于baseDMA的析构函数来释放指针label管理的内存。
baseDMA::~baseDMA() // takes care of baseDMA stuff
{
delete [] label;
}
hasDMA::~hasDMA() // takes care of hasDMA stuff
{
delete [] style;
}
接下来看复制构造函数。BaseDMA的复制构造函数遵循用于
baseDMA::baseDMA(const baseDMA & rs)
{
label = new char[std::strlen(rs.label)+1];
std::strcpy(label,rs.label);
rating = rs.rating;
}
hasDMA复制构造函数只能访问hasDMA的数据,因此它必须调用baseDMA复制构造函数来处理共享的baseDMA数据:
hasDMA::hasDMA(const hasDMA & hs)
:baseDMA(hs)
{
style = new char[std::strlen(hs.style)+1];
std::strcpy(style, hs.style);
}
需要注意一点是,成员初始化列表将一个hasDMA引用传递给baseDMA构造函数。
接下来看赋值运算符。BaseDMA赋值运算符遵循下述常规模式:
baseDMA & baseDMA::operator=(const baseDMA & rs)
{
if (this == &rs)
return *this;
delete [] label;
label = new char[std::strlen(rs.label)+1];
std::strcpy(label, rs.label);
rating = rs.rating;
return *this;
}
hasDMA & hasDMA::operator=(const hasDMA & hs)
{
if(this == &hs)
return *this;
baseDMA::operator=(hs);
delete [] style;
style = new char[std::strlen(hs.style)+1];
std::strcpy(style,hs.style);
return *this;
}
以下语句看起来有点奇怪:
baseDMA::operator=(hs);
但通过使用函数表示法,而不是运算符表示法,可以使用作用域解析运算符。实际上该语句的含义如下:
*this = hs;
当基类和派生类都采用动态内存分配时,派生类的析构函数,复制构造函数,赋值运算符都必须使用相应的基类方法处理基类元素。
但这种要求是通过三种不同的方式来满足的。
对于析构函数:这是自动完成的;
对于构造函数:这是通过在初始化成员列表中调用基类的复制构造函数来完成的;
对于赋值运算符,这是通过使用作用域解析运算符显式地调用基类的赋值运算符来完成的;
7 类设计回顾
7.1 编译器生成的成员函数
编译器会自动生成一些公有的成员函数——特殊成员函数。
1、 默认构造函数
提供构造函数的动机之一是确保对象总能被正确地初始化。如果类包含指针成员,则必须初始化这些成员。最好提供一个显式默认构造函数,将所有的类数据成员都初始化为合理的值。
如果定义了某种构造函数,编译器将不会定义默认构造函数。如果没有定义任何构造函数,编译器将定义默认构造函数。
默认构造函数要么没有参数,要么所有的参数都有默认值。
2、 复制构造函数
复制构造函数接受其所属类的对象作为参数。例如,Star类的复制构造函数的原型如下:
Star(const Star &);
在下述情况下,将使用复制构造函数:
- 将新对象初始化为一个同类对象;
- 按值将对象传递给函数;
- 函数按值返回对象;
- 编译器生成临时对象;
3、 赋值运算符
初始化和赋值不是一回事;
Star sirius;
Star alpha = sirius; //initialization 初始化
Star dogstar;
dogstar = sirius; //assignment 赋值
默认的赋值运算符用于处理同类对象之间的赋值;
如果需要显式定义复制构造函数,基于相同的原因,也需要显式定义赋值运算符。
7.2 其他的类方法
1、构造函数
构造函数不同于其他类方法,因为它创建新的对象。而其他类方法知识被现有的对象调用。这是构造函数不被继承的原因之一。
2、析构函数
一定要定义显式析构函数来释放类构造函数使用new分配的所有内存,并完成类对象所需的任何特殊的清理工作。对于基类,即使它不需要析构函数,也应提供一个虚析构函数。
3、转换
4、按值传递对象与传递引用
编写使用对象作为参数的函数时,应将参数声明为const引用。这样可以提高程序运行的效率。
按引用传递对象的另外一个原因是,在继承使用虚函数时,被定义为接受基类引用参数的函数可以接受派生类。
5、返回对象和返回引用
返回对象需要生成临时副本,这就要调用复制构造函数来生成副本和调用析构函数来删除副本。
返回引用可以节约内存空间和时间,但是并不总是要返回引用。如果函数返回在函数中创建的临时对象,则不要使用引用,例如返回两个对象相加的临时对象时不返回引用。
6、使用const
使用const来确保方法不修改调用它的对象。
7.3 公有继承的考虑因素
在程序中使用继承,有很多问题需要注意。
1、is-a关系
必须是is-a关系,无需进行显式类型转换,基类指针就可以指向派生类对象,基类引用可以引用派生类对象。
2、什么不能被继承
构造函数、析构函数、赋值运算符;都不可以;
3、赋值运算符
如果编译器发现程序将一个对象赋给同一个类的另一个对象,它将自动为这个类提供一个赋值运算符。
4、私有成员与保护成员
对于派生类而言,保护成员类似于公有成员。
但对于外部而言,保护成员与私有成员类似。
派生类可以直接访问基类的保护成员,但只能通过基类的成员函数来访问私有成员。
5、虚方法
设计基类时,必须确定是否将类方法声明为虚的。如果希望派生类能够重新定义方法,则应在基类中将方法定义为虚的。这样可以启动动态联编。
6、析构函数
基类的析构函数应当是虚的。这样,当通过通过对象的基类指针或引用来删除派生对象时,程序将首先调用派生类的析构函数,然后调用基类的析构函数,而不仅仅是调用基类的析构函数。
7、友元函数
友元函数并非类成员,因此不能被继承。
8、有关使用基类方法的说明
以公有方式派生的类的对象可以通过多种方式来使用基类的方法。
- 派生类对象自动使用继承而来的基类方法,如果派生类没有重新定义该方法;
- 派生类的构造函数自动调用基类的构造函数;
- 派生类的构造函数自动调用基类的默认构造函数,如果没有在成员初始化列表中指定其他构造函数;
- 派生类构造函数显式地调用成员初始化列表中指定的基类构造函数;
- 派生类方法可以使用作用域解析运算符来调用公有的和受保护的基类方法;
- 派生类的友元函数可以通过强制类型转换,将派生类引用或指针转换为基类引用或指针;
7.4 类函数小结
见书P530表13.1
- 原文作者:jchen
- 原文链接:http://jchenTech.github.io/post/C++/C++-primer-plus-%E7%AC%AC13%E7%AB%A0%E7%B1%BB%E7%BB%A7%E6%89%BF/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。