Chapter13 类继承

Chapter13 类继承

从一个类派生出另一个类时,原始类称为基类,继承类称为派生类。派生类继承了基类的实现(数据成员)和接口(方法)。

使用公有派生,基类的公有成员将成为派生类的公有成员;基类的私有部分也将成为派生类的一部分,但只能通过基类的公有和保护方法访问;基类保护部分成为派生类的一部分,且在派生类中可以直接访问。

派生类构造函数必须给新成员(如果有的话)和继承的成员提供数据,由于派生类不能直接访问基类的私有成员,因此派生类构造函数必须使用基类构造函数。创建派生对象时,程序首先创建基类对象,从概念上说,这意味着基类对象应当在程序进入派生类之前被创建,C++使用成员初始化语法来完成这种工作。

1
derived::derived(type1 x, type2 y) : base(x, y){...}//derived是派生类,base是基类

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

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

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

派生类对象可以使用基类的非私有方法;基类指针可以在不进行显式类型转换的情况下指向派生类对象,基类引用可以在不进行显式类型转换的情况下引用派生类对象,然而基类指针或引用只能用调用基类方法。

13.1 继承: is-a 关系

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

公有继承不能建立is-like-a的关系,也就是说,它不采用明喻。继承可以在积累的基础上添加属性,但不能删除基类的属性。

13.2 多态公有继承

实现多态公有继承的方法:

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

如果方法是通过引用或指针而不是对象调用的,在没有使用关键字virtual时,程序将根据引用类型或指针类型选择方法,如果使用了virtual,程序将根据引用或指针指向的对象的类型来选择方法。故如果要在派生类中重新定义基类的方法,通常应将基类方法声明为虚的。

方法在基类中被声明为虚的后,它在派生类中将自动成为虚方法。

如果析构函数不是虚的,则将只能调用对应于指针类型的析构函数,如果析构函数是虚的,将调用相应对象类型的析构函数。

13.3 静态联编和动态联编

将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编(binding)。在编译过程中进行联编被称为静态联编(static binding),又称为早期联编(early binding)。在程序运行时进行联编称为动态联编,或晚期联编。

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

指向基类的引用或指针可以引用派生类对象,而不必进行显式类型转换。将派生类引用或指针类型转换为基类引用或指针被称为向上强制转换(upcasting),这使公有继承不需要进行显式类型转换。

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

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

image-20201030173225440

使用虚函数时,在内存和执行速度方面有一定的成本,包括:

  • 每个对象都将增大,增大量为存储地址的空间;
  • 对于每个类,编译器都创建一个虚函数地址表;
  • 对于每个函数调用,都需要执行一项额外的操作,即到表中查找地址。

有关虚函数的注意事项:

  • 在基类方法的声明中使用关键字virtual可使该方法在基类以及所有的派生类中是虚的。

  • 如果使用指向对象的引用或指针来调用虚方法,程序将使用为对象类型定义的方法,而不使用为引用或指针类型定义的方法,这被称为动态联编或晚期联编。

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

  • 构造函数不能是虚函数。创建对象时,将调用派生类的构造函数,而不是基类的构造函数

  • 析构函数应当是虚函数,除非类不用做基类。(通常应给基类提供一个虚析构函数,即使它不需要析构函数

  • 友元不能是虚函数,因为友元不是类成员,只有成员才能是虚函数。(可让友元函数使用虚成员函数

  • 如果派生类没有重新定义函数,将使用该函数的基类版本,如果派生类位于派生链中,则将使用最新的虚函数版本。

  • 如果在派生类中重新定义函数,将不是使用相同的函数特征标覆盖基类声明,而是隐藏同名的基类方法,不管参数特征标如何。

    如果重新定义继承的方法,应确保与原来的原型完全相同,但如果返回类型是基类引用或指针,则可以修改为指向派生类的引用或指针(返回类型协变)。

    如果基类声明被重载了,则应在派生类中重新定义所有的基类版本。(如果只重新定义了一个版本,则另外两个版本将被隐藏,派生类对象将无法使用它们)

13.4 访问控制: protected

在类外只能用公有类成员来访问 protected部分中的类成员。 派生类的成员可以直接访问基类的保护成员,但不能直接访问基类的私有成员。因此,对于外部世界来说保护成员的行为与私有成员相似;但对于派生类来说,保护成员的行为与公有成员相似。

13.5 抽象基类

C++通过使用纯虚函数(pure virtual function)提供未实现的函数。纯虚函数声明的结尾处未=0。当类声明中包含纯虚函数时,不能创建该类的对下个。包含纯虚函数的类只能用作基类,要成为真正的ABC(abstract base class),必须至少包含一个纯虚函数。

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

可以将ABC看作是一种必须实施的接口。ABC要求具体派生类覆盖其纯虚函数——迫使派生类遵循ABC设置的接口规则。

友元不是成员函数,所以不能使用作用域解析运算符来指出要使用哪个函数,解决办法是使用强制类型转换,以便匹配原型时能够选择正确的函数。

13.6 类设计回顾

13.6.1 编译器生成的成员函数

  • 默认构造函数

    默认构造函数要么没有参数,要么所有的参数都有默认值。

    自动生成的默认构造函数的另一项功能是,调用基类的默认构造函数以及调用本身是对象的成员所属类的默认构造函数。

    如果派生类构造函数的成员初始化列表中没有显式地调用基类构造函数,则编译器将使用基类的默认构造函数来构造派生类对象的基类部分。

    如果定义了某种构造函数,编译器将不会定义默认构造函数。在这种情况下,如果需要默认构造函数,则必须自己提供。

    如果类包含指针成员,则必须初始化这些成员。

  • 复制构造函数

    复制构造函数接受其所属类的对象作为参数。

    在下述情况下,将使用复制构造函数:

    1. 将新对象初始化为一个同类对象;
    2. 按值将对象传递给函数;
    3. 函数按值返回对象;
    4. 编译器生成临时对象。

    如果程序没有使用(显式或隐式)复制构造函数,编译器将提供原型,但不提供函数定义;否则,程序将定义一个执行成员初始化的复制构造函数。

  • 赋值运算符

    默认的赋值运算符用于处理同类对象之间的赋值。如果语包创建新的对象,则使用初始化;如果语句修改已有对象的值,则是赋值。

    默认赋值为成员赋值。如果成员为类对象,则默认成员赋值将使用相应类的赋值运算符。如果需要显式定义复制构造函数,则基于相同的原因,也需要显式定义赋值运算符。

13.6.2 其它的类方法

  • 构造函数

    构造函数不同于其他类方法,因为它创建新对象,而其它类方法只是被现有的对象调用。

  • 析构函数

    一定要定义显式析构函数来释放类构造函数使用new分配的所有内存,并完成类对象所需的任何特殊的清理工作。对于基类,即使它不需要析构函数,也应提供一个虚析构函数。

  • 转换

    使用一个参数就可以调用的构造函数定义了从参数类型到类类型的转换。

    将可转换的类型传递给以类未参数的函数时,将调用转换构造函数。

    要将类对象转换为其他类型,应定义转换函数。转换函数可以是没有参数的类成员函数,也可以是返回类型被声明为目标类型的类成员函数。即使没有声明返回类型,函数也应返回所需的转换值。

    C++11支持将关键字explicit用于转换函数。与构造函数一样,explicit允许使用强制类型转换进行显式转换,但不允许隐式转换。

  • 按值传递与按引用传递
    通常,编写使用对象作为参数的函数时,应按引用而不是按值来传递对象。(提高效率)

    按引用传递对象的另外一个原因是,在继承使用虚函数时,被定义为接受基类引用参数的函数可以接受派生类。

  • 返回对象和返回引用

    应返回引用而不是返回对象的的原因在于,返回对象涉及生成返回对象的临时副本,这是调用函数的程序可以使用的副本。因此,返回对象的时间成本包括调用复制构造函数来生成副本所需的时间和调用析构函数删除副本所需的时间。返回引用可节省时间内存

    函数不能返回在函数中创建的临时对象的引用,因为当函数结束时,临时象将消失,因此这种引用将是非法的。在这种情况下,应返回对象,以生成一个调用程序可以使用的副本。如果函数返回的是通过引用或指针传递给它的对象,则应按引用返回对象

  • 使用const

    可以用它来确保方法不修改参数,如:

    1
    Star::Star(const char * s){...};

    可以使用它来确保方法不修改调用它的对象:

    1
    void Star::show() const{...}

    如果函数将参数声明未指向const的引用或指针,则不能将该参数传递给另一个函数,除非后者也确保了参数不会被修改。

13.6.3 公有继承的考虑因素

  • is-a关系

    要遵循is-a关系,如果派生类不是一种特殊的基类,则不要使用公有派生。

  • 什么不能被继承

    构造函数不能被继承。派生类的构造函数通常使用成员初始化列表语法来调用基类构造函数,以创建派生对象的基类部分。如果派生类构造函数没有使用成员初始化列表显式调用基类构造函数,将使用基类的默认构造函数。

    析构函数不能被继承。在释放对象时,程序将首先调用派生类的析构函数,然后调用基类的析构函数。

    赋值运算符是不能继承。派生类继承的方法的特征标与基类完全相同,但赋值运算符的特征标随类而异,这是因为它包含一个类型为其所属类的形参。

  • 赋值运算符

    如果编译器发现程序将一个对象赋给同一个类的另一个对象,它将自动为这个类提供一个赋值运算符。这个运算符的默认或隐式版本将采用成员赋值,即将原对象的相应成员赋给目标对象的每个成员。

    如果类构造函数使用new来初始化指针,则需要提供一个显式赋值运算符。

  • 私有成员与保护成员

    对派生类而言,保护成员类似于共有类;但对于外部而言,保护成员与私有成员类似。

  • 虚方法

    如果希望派生类能够重新定义方法,则应在基类中将方法定义为虚的,这样可以启用动态联编。

  • 析构函数

    基类的析构函数应当是虚的。这样,当通过指向对象的基类指针或引用来删除派生象时,程序将首先调用派生类的析构函数,然后调用基类的析构函数,而不仅仅是调用基类的析构函数。

  • 友元函数

    由于友元函数并非类成员,因此不能继承。若希望派生类的友元函数使用基类的友元函数,可以通过强制类型转换,将派生类引用或指针转换为基类引用或指针,然后使用转换后的指针或引用来调用基类的友元函数。

  • 有关使用基类方法的说明

    • 派生类对象自动使用继承而来的基类方法,如果派生类没有重新定义该方法。
    • 派生类的构造函数自动调用基类的构造函数。
    • 派生类的构造函数自动调用基类的默认构造函数,如果没有在成员初始化列表中指定其他构造函数。
    • 派生类构造函数显式地调用成员初始化列表中指定的基类构造函数。
    • 派生类方法可以使用作用域解析运算符来调用公有的和受保护的基类方法。
    • 派生类的友元函数可以通过强制类型转换,将派生类引用或指针转换为基类引用或指针,然后使用该引用或指针来调用基类的友元函数。

13.6.4 类函数小结

image-20201030190138671