《C++ Primer Plus》第13章 类继承

第13章 类继承

一个简单的基类

从一个类派生出另一个类时,原始类称为基类,继承类称为派生类。

成员初始化列表语法直接使用string的复制构造函数将firstname初始化为fn:

TableTennisPlayer::TableTennisPlayer(const string & fn,
const string & ln, bool ht) : firstname(fn),
lastname(ln), hasTable(ht) {}

而:

TableTennisPlayer::TableTennisPlayr(const string & fn, const string & ln, bool ht)
{
    firstname = fn;
    lastname = ln;
    hasTable = ht;
}

将首先为firstname调用string的默认构造函数,再调用string的赋值运算符将firstname设置为fn。初始化列表语法可减少一个步骤。

class RatedPlayer : public TableTennisPlayer
{
    ...
};

冒号指出RatedPlayer的基类是TableTennisPlayer类。上述声明表明TableTennisPlayer是一个公有基类,这被称为公有派生。派生类对象包含基类对象。使用公有派生,基类的公有成员将称为派生类的公有成员;基类的私有部分也将称为派生类的一部分,但只能通过基类的公有和保护方法访问。

  • 派生类对象存储了基类的数据成员(派生类继承了基类的实现)
  • 派生类对象可以使用基类的方法(派生类继承了基类的接口)
  • 派生类需要自己的构造函数
  • 派生类可以根据需要添加额外的数据成员和成员函数

派生类不能直接访问基类的私有成员,而必须通过基类方法进行访问。派生类构造函数必须使用基类构造函数。

创建派生类对象时,程序首先创建基类对象。基类对象应当在程序进入派生类构造函数之前被创建。C++使用成员初始化列表语法来完成这种工作:

RatePlayer::RatedPlayer(unsigner int r, const TableTennisPlayer & tp) : TableTennisPlayer(tp)
{
    rating = r;
}

第二个构造函数:

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 TableTennisPlayer & tp)
: TableTennisPlayer(tp), rating(r) {}

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

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

释放对象的顺序与创建对象的顺序相反,即首先执行派生类的析构函数,然后自动调用基类的析构函数。

创建派生类对象时,程序首先调用基类构造函数,然后再调用派生类构造函数。基类构造函数负责初始化继承的数据成员;派生类构造函数主要用于初始化新增的数据成员。派生类的构造函数总是调用一个基类的构造函数。可以使用初始化器列表语法指明要使用的基类构造函数,否则将使用默认的基类构造函数。派生类对象过期时,程序将首先调用派生类析构函数,然后再调用基类析构函数。

成员初始化列表只能用于构造函数。

派生类对象可以使用基类的方法,条件是方法不是私有的。

基类指针可以在不进行显示类型转换的情况下指向派生类对象;基类引用可以在不进行显示类型转换的情况下引用派生类对象。

不可以将基类对象和地址赋给派生类指针。

基类引用定义的函数或指针参数可用于基类对象或派生类对象。

对于形参为指向基类指针的函数,可以使用基类对象的地址或派生类对象的地址作为实参。

引用兼容性属性能够将基类对象初始化为派生类对象:

RatedPlayer olaf1(1840, "Olaf", "Loaf", true);
TableTennisPlayer olaf2(olaf1);

这将用到基类的隐式复制构造函数,而该函数的形参为基类引用,因此可以引用派生类。

同样,也可以将派生对象赋给基类对象,在这种情况下,程序将使用隐式重载赋值运算符。

继承:is-a关系

C++有三种继承方式:公有继承、保护继承和私有继承。公有继承是最常用的方式,它建立一种is-a关系,即派生类对象也是一个基类对象,可以对基类对象执行的任何操作,也可以对派生类对象执行。

多态公有继承

同一个方法在派生类和基类中的行为不同,换句话来说,方法的行为取决于调用该方法的对象,这种较复杂的行为称为多态——具有多种形态,即同一个方法的行为随上下文而异。有两种重要的机制可用于实现多态公有继承:

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

方法在派生类与基类中的行为不同,需要通过在类声明中使用具有不同限定名的方法原型来指出。

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

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

基类声明了一个虚析构函数。这样做是为了确保释放派生对象时,按正确的顺序调用析构函数。

如果要在派生类中重新定义基类的方法,通常应该将基类方法声明为虚的。这样,程序将根据对象类型而不是引用或指针的类型来选择方法版本,为基类声明一个虚析构函数也是一种惯例。

关键字virtual只用于类声明的方法原型中,而没有用于方法定义中。

派生类并不能直接访问基类的私有数据,而必须使用基类的公有方法才能访问这些数据。访问的方式取决于方法。构造函数使用一种技术,而其他成员函数使用另一种技术。

派生类构造函数在初始化基类私有数据时,采用的是成员初始化列表语法,将基类信息传递给基类构造函数,然后使用构造函数体初始化派生类新增的数据项。

非构造函数不能使用成员初始化列表语法,但派生类方法可以调用公有的基类方法。

在派生类方法中,标准技术是使用作用域解析运算符来调用基类方法。如果没有使用作用域解析运算符,将创建一个不会有终止的递归函数。

派生类没有重新定义的基类方法在调用时不必使用作用域解析运算符。

静态联编和动态联编

将源代码中的函数调用解释为执行特定的函数代码块被称为函数联编。在编译过程中进行联编被称为静态联编,又称为早期联编。

编译器生成能够在程序运行时选择正确的虚方法的代码,称为动态联编,又称为晚期联编。

将派生类引用或指针转换为基类引用或指针被称为向上强制转换,这使公有继承不需要进行显示类型转换。相反的过程——将基类指针或引用转换为派生类指针或引用——称为向下强制转换。如果不使用显示类型转换,则向下强制转换是不允许的。

编译器对虚方法使用动态联编。

如果要在派生类中重新定义基类的方法,则将它设置为虚方法;否则,设置为非虚方法。

虽然非虚函数的效率比虚函数稍高,但不具备动态联编功能。

在基类方法的声明中使用关键字virtual可使该方法在基类以及其所有派生类(包括从派生类派生出来的类)中是虚的。

如果使用指向对象的引用或指针来调用虚方法,程序将使用为对象类型定义的方法,而不使用为引用或指针类型定义的方法。这称为动态联编或晚期联编。这种行为非常重要,因为这样基类指针或引用可以指向派生类对象。

如果定义的类将被用作基类,则应将那些要在派生类中重新定义的类方法声明为虚的。

构造函数不能是虚函数。创建派生类对象时,将调用派生类的构造函数,而不是基类的构造函数,然后,派生类的构造函数将使用基类的一个构造函数,这种顺序不同于继承机制。因此,派生类不继承基类的构造函数,所以将类构造函数声明为虚的没有什么意义。

析构函数应当是虚函数,除非类不用做基类。如果使用默认的静态联编,delete语句将调用基类的析构函数,这将释放派生类中基类部分指向的内存,但不会释放新的类成员指向的内存。但如果析构函数是虚的,则将调用派生类的析构函数释放派生类组件指向的内存,然后调用基类析构函数释放基类组件指向的内存。这意味着,即使基类不需要显示析构函数提供服务,也不应依赖于默认构造函数,而应提供虚析构函数,即使它不执行任何操作。

给类定义一个虚析构函数并非错误,即使这个类不用做基类;这只是一个效率方面的问题。

通常应该给基类提供一个虚析构函数,即使它并不需要析构函数。

友元不能是虚函数,因为友元不是类成员,而只有成员才能是虚函数。如果由于这个原因引起了设计问题,可以通过让友元函数使用虚成员函数来解决。

如果派生类没有重新定义函数,将使用该函数的基类版本。如果派生类位于派生链中,则将使用最新的虚函数版本,例外的情况是基类版本是隐藏的。

重新定义将隐藏方法。重新定义不会生成函数的两个重载版本,而是隐藏了基类版本。总之,重新定义方法并不是重载。如果重新定义派生类中的函数,将不只是使用相同的函数参数列表覆盖基类声明,无论参数列表是否相同,该操作将隐藏所有的同名基类方法。

如果重新定义继承的方法,应确保与原来的原型完全相同,但如果返回类型是基类引用或指针,则可以修改为指向派生类的引用或指针。这种特性被称为返回类型协变。注意,这种例外只适用于返回值,而不适用于参数。

如果基类声明被重载了,则应在派生类中重新定义所有的基类版本。如果只重新定义一个版本,则另外两个版本将被隐藏,派生类对象将无法使用他们。注意,如果不需要修改,则新定义可只调用基类版本。

访问控制:protected

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

使用保护数据成员可以简化代码的编写工作,但存在设计缺陷。基类被设计成只能通过基类的方法修改数据成员,但对于派生类对象来自基类的保护数据成员,将可以通过派生类方法进行修改,实际上使其成为了公有量。

抽象基类

从不同类中抽象出它们的共性,将这些特性放到一个抽象基类(abstract base class,ABC)中,然后从该抽象基类中派生出不同类,这样便可以使用基类指针数组同时管理多个派生类的对象(即可以使用多态方法)。

有时在抽象基类中无法实现一些方法,因为抽象基类中缺少必要的数据成员。

C++通过使用纯虚函数提供未实现的函数。纯虚函数声明的结尾处为=0。

当类声明中包含纯虚函数时,则不能创建该类的对象。

包含纯虚函数的类只能用作基类。

要成为抽象基类,必须至少包含一个纯虚函数。

在原型中使用=0指出类是一个抽象基类,在类中可以不定义该函数,但允许纯虚函数有定义。

抽象基类描述的是至少使用一个纯虚函数的接口,从纯虚函数派生出的类将根据派生类的具体特征,使用常规虚函数来实现这种接口。

可以将抽象基类看作是一种必须实施的接口。抽象基类要求具体派生类覆盖其纯虚函数——迫使派生类遵循抽象基类的接口规则。这种模型在基于组件的编程模式中很常见,在这种情况下,使用ABC使得组件设计人员能够制定“接口约定”,这样确保了从抽象基类派生的所有组件都至少支持抽象基类指定的功能。

继承和动态内存分配

基类使用了动态内存分配,但派生类不使用new,也未包含其他一些不常用的、需要特殊处理的设计特性,则不需要为派生类定义显式析构函数、复制构造函数和赋值运算符。

不使用new的派生类的析构函数会在执行自身的代码后调用基类析构函数,在此派生类成员不需执行任何特殊操作时,默认析构函数式合适的。

默认复制构造函数执行成员复制,这对于动态内存分配来说是不合适的,但对于派生出的不使用new的类来说是合适的,因为继承而来的使用new的基类数据成员可以通过基类复制构造函数进行复制。

不使用new的派生类的默认赋值运算符将自动使用基类的赋值运算符来对基类组件进行赋值。

派生类使用了new,则必须为派生类定义显示析构函数、复制构造函数和赋值运算符。

当基类和派生类都采用动态内存分配时,派生类的析构复制函数、复制构造函数、复制运算符都必须使用相应的基类方法来处理基类元素。这种要求是通过三种不同的方式来满足的。对于析构函数,这是自动完成的;对于构造函数,这是通过在初始化成员列表中调用基类的复制构造函数来完成的;如果不是这样做,将自动调用基类的默认构造函数。对于赋值运算符,这是通过使用作用域解析运算符显示的调用基类的赋值运算符来完成的。

类设计回顾

编译器生成的成员函数

  • 默认构造函数要么没有参数,要么所有的参数都有默认值。如果没有定义任何构造函数,编译器将定义默认构造函数,以便能创建对象。自动默认构造函数的另一项功能是,调用基类的默认构造函数以及调用本身是对象的成员所属类的默认构造函数。另外,如果派生类构造函数的成员初始化列表中没有显示调用基类构造函数,则编译器将使用基类的默认构造函数来构造派生类对象的基类部分,在这种情况下,如果基类没有构造函数,将导致编译阶段错误。如果定义了某种构造函数,编译器将不会定义默认构造函数。在这种情况下,如果需要默认构造函数,则必须自己提供。提供构造函数的动机之一是确保对象总能被正确的初始化。另外,如果类包含指针成员,则必须初始化这些成员。因此,最好提供一个显示默认构造函数,将所有的类数据成员都初始化未合理的值。
  • 复制构造函数接受其所属类的对象作为参数,在下述情况下将使用复制构造函数:
    • 将对象初始化为一个同类对象
    • 按值将对象传递给函数
    • 函数按值返回对象
    • 编译器生成临时对象

如果程序没有使用(显示或隐式)复制构造函数,编译器将提供原型,但不提供函数定义;否则,程序将定义一个执行成员初始化的复制构造函数。也就是说,新对象的每个成员都被初始化为原始对象相应成员的值。如果成员为类对象,则初始化该成员时,将使用相应类的复制构造函数。在某些情况下,成员初始化是不合适的。例如使用new初始化的成员指针通常要求执行深复制,或者类可能包含需要修改的静态变量。在上述情况下,需要定义自己的复制构造函数。

  • 默认的赋值运算符用于处理同类对象之间的赋值。不要将赋值与初始化混淆了。如果语句创建新的对象,则使用初始化;如果语句修改已有对象的值,则是赋值。默认赋值为成员赋值。如果成员为类对象,则默认成员赋值将使用相应的赋值运算符。如果需要显示定义复制构造函数,则基于相同原因,也需要显示定义复制运算符。编译器不会生成将一种类型赋给另一种类型的赋值运算符。有时需要显示定义使用不同类型赋值的运算符。另一种方法是使用转换函数。第一种方法的速度较快,但需要的代码较多,而使用转换函数可能导致编译器出现混乱。

其他的类方法

  • 构造函数不同于其他方法,因为它创建新的对象,而其他类方法只是被现有的对象调用。这是构造函数不能被继承的原因之一。继承意味着派生类对象可以使用基类的方法,然而,构造函数在完成其工作之前,对象并不存在。
  • 一定要定义显示析构函数来释放类构造函数使用new分配的所有内存,并完成类对象所需的任何特殊的清理工作。对于基类,即使它不需要析构函数,也应提供一个虚析构函数。
  • 使用一个参数就可以调用的构造函数定义了从参数类型到类类型的转换。将可转换的类型传递给以类为参数的函数时,将调用转换构造函数。在带一个参数的构造函数原型中使用explicit将禁止进行隐式转换,但仍允许显示转换。要将类对象转换为其他类型,应定义转换函数。转换函数可以是没有参数的类成员函数,也可以是返回类型被声明为目标类型的类成员函数。即使没有声明返回类型,函数也应返回所需的转换值。应理智的使用这样的函数,仅当它们有帮助时才使用,另外,对于某些类,包含转换函数将增加代码的二义性。将关键字explicit用于转换函数,则仅允许使用强制类型转换进行显示转换而不允许隐式转换。
  • 通常,编写使用对象作为参数的函数时,应按引用而不是按值来传递对象。这样做的原因之一是为了提高效率。按值传递对象涉及到生成临时拷贝,即调用复制构造函数,然后调用析构函数。调用这些函数需要时间,复制大型对象比传递引用花费的时间要多得多。如果函数不修改对象,应将参数声明为const引用。按引用传递的另外一个原因是,在继承使用虚函数时,被定义为接受基类引用参数的函数可以接受派生类。
  • 返回对象涉及生成返回对象的临时副本,这是调用函数的程序可以使用的副本。因此,返回对象的时间成本包括调用复制构造函数来生成副本所需的时间和调用析构函数删除副本所需的时间。返回引用可以节省时间和内存。直接返回对象与按值传递对象相似,它们都生成临时副本。同样,返回引用和按引用传递类似,调用和被调用的函数对同一个对象进行操作。并不总是可以返回引用。函数不能返回在函数中创建的临时对象的引用,因为当函数结束时,临时对象将消失,因此这种引用将是非法的。在这种情况下,应返回对象,以生成一个调用程序可以是使用的副本。通用的规则是,如果函数返回在函数中创建的临时对象,则不要使用引用。如果函数返回的是通过引用或指针传递给它的对象,则应按引用返回对象。
  • 可以使用const来确保方法不修改参数。可以使用const来确保方法不修改调用它的对象。通常,可以将返回引用的函数放在赋值语句的左侧,这实际上意味着可以将值赋给引用的对象。但可以使用const来确保引用或指针返回的值不能用于修改对象中的数据(返回的引用或指针被声明为const)。如果函数将参数声明为指向const的引用或指针,则不能将该参数传递给另一个函数,除非后者也确保了参数不会被修改(const引用或指针的参数传入函数中,在函数内部调用另一函数,需保证被调用的函数亦不修改参数)。

公有继承的考虑因素

  • 要遵循is-a关系。如果派生类不是一种特殊的基类,则不要使用公有派生。在某些情况下,最好的方法可能是创建包含纯虚函数的抽象数据类,并从它派生出其他的类。is-a关系的方式之一是,无需进行显式类型转换,基类指针就可以指向派生类对象,基类引用可以引用派生类对象。但不能在不进行显示转换的情况下,将派生类指针或引用指向基类对象。
  • 构造函数是不能继承的,也就是说,创建派生类对象时,必须调用派生类的构造函数,然后调用基类的构造函数。然而,派生类构造函数通常使用成员初始化列表来调用基类构造函数,以创建派生对象的基类部分。如果派生类构造函数没有使用成员初始化列表语法显式调用基类构造函数,将使用基类的默认构造函数。在继承链中,每个类都可以使用成员初始化列表将信息传递给相邻的基类。
  • 析构函数也是不能继承的。在释放对象时,程序将首先调用派生类的析构函数,然后调用基类的析构函数。如果基类有默认析构函数,编译器将为派生类生成默认析构函数。通常,对于基类,其析构函数应设置为虚的。
  • 赋值运算符是不能继承的。派生类继承的方法的特征标与基类完全相同,但赋值运算符的特征标随类而异,这是因为它包含一个类型为其所属类的形参。
  • 如果编译器发现程序将一个对象赋给同一个类的另一个对象,它将自动为这个类提供一个赋值运算符。这个运算符的默认或隐式版本将采用成员赋值,即将原对象的相应成员赋给目标对象的每个成员。然而,如果对象属于派生类,编译器将使用基类赋值运算符来处理派生类对象中的基类部分的赋值。如果显示的为基类提供了赋值运算符,将使用该运算符。与此相似,如果成员是另一个类的对象,则对于该成员,将使用其所属类的赋值运算符。
  • 如果类构造函数使用new来初始化指针,则需要提供一个显示赋值运算符。对于派生对象的基类部分,C++将使用基类的赋值运算符,所以不需要为派生类重新定义赋值运算符,除非它添加了需要特别留意的数据成员。然而,如果派生类使用了new,则必须提供显示赋值运算符。必须给类的每个成员提供赋值运算符,而不仅仅是新成员。
  • 对于派生类而言,保护成员类似于公有成员;但对于外部而言,保护成员于私有成员类似。派生类可以直接访问基类的保护成员,但只能通过基类的成员函数来访问私有成员。因此,将基类成员设置为私有的可以提高其安全性,而将它们设置为保护成员则可以简化代码的编写工作,并提高访问速度。
  • 设计基类时,必须确定是否将方法声明为虚的。如果希望派生类能够重新定义方法,则应在基类中将方法定义为虚的,这样可以启用动态联编;如果不希望重新定义方法,则不必将其声明为虚的,这样虽然无法禁止他人重新定义方法,但表达了不希望它被重新定义的意思。
  • 基类的析构函数应当是虚的。这样,当通过指向对象的基类指针或引用来删除派生类对象时,程序将首先调用派生类的析构函数,然后调用基类的析构函数,而不是仅仅调用基类的析构函数。
  • 由于友元函数并非成员函数,因此不能继承。可以通过强制类型转换,将派生类引用或指针转换为基类引用或指针,然后使用转换后的指针或引用来调用基类的友元函数。
  • 以公有方式派生的类的对象可以通过多种方式来使用基类的方法
    • 派生类对象自动使用继承而来的基类方法,如果派生类没有重新定义该方法
    • 派生类的构造函数自动调用基类的构造函数
    • 派生类的构造函数自动调用基类的默认构造函数,如果没有在成员初始化列表中指定其他构造函数
    • 派生类构造函数显式的调用成员初始化列表中指定的基类构造函数
    • 派生类方法可以使用作用域解析运算符来调用公有的和受保护的基类方法
    • 派生类的友元函数可以通过强制类型转换,将派生类引用或指针转换为基类引用或指针,然后使用该引用或指针来调用基类的友元函数

类函数小结

  • C++类函数有很多不同的变体,其中有些可以继承,有些不可以。有些运算符既可以是成员函数,也可以是友元,而有些运算符函数只能是成员函数。
posted @ 2020-09-29 10:20  溪嘉嘉  阅读(214)  评论(0编辑  收藏  举报