c++继承与多态(下)

派生类同名处理

隐藏redifine

        当我们在派生类中写了一个与基类同名、同参的成员方法时,编译器不会报错,当我们通过派生类定义的对象去访问重名函数时,会访问到派生类的那个函数,这种现象叫做“重定义”有时候也叫“隐藏”,实际上在派生类中两个函数都存在,只是在不同类域名中,新成员方法会隐藏掉旧方法,这个是当然,如果旧方法隐藏新方法,那还要新方法干嘛。

访问被隐藏成员的方法       

        如果想要访问被隐藏的方法,可以将派生类强制转换成基类的类型,再去调用,编译器会以为是基类在调用,就能访问到,这种方法很少会这么操作,另外一种方法就是在派生类内部,使用作用域符号来指定访问,“父类::方法”的语法可以调用到,示例如下。

void man::get_name(void)
{
       cout << name<<endl;  //完整是this->name,也可以写man::name
       cout << person::name << endl;//只能person::name访问隐藏的name。
      spek();  //完整是this-> spek( ); 可以写成man::spek
      person::spek(); //访问person中的spek方法
}

        在派生类中使用“父类::方法”的语法除了可以指定访问方法的作用域,也可以指定变量的作用域,同样用来解决变量名重名问题。隐藏本质上是大小作用域内同名变量的认领,就像局部变量与全局变量重名后,函数内部访问的是局部变量,也可以说是局部变量隐藏了全局变量,实际上两个变量都存在与内存中。

派生类与基类的兼容规则

        在c/c++中long int 兼容int兼容short int,而int与float虽然内存大小相同,但是他们完全不兼容,这就是普通变量的兼容规则。

        派生类中包含了基类,所以他们之间有关联,但这个关联不是简单的向普通变量那样的兼容特性,使用cast关键字,可以将派生类对象中部分内容裁剪掉,留下基类的内容,考虑到指针和引用后,派生类和基类的访问规则就是所谓兼容规则。

基类的指针可以访问派生类的变量,如下示例

int main(void)
{
       man  a;         //man类继承了person类,创建一个a对象
       person  *p = &a; //使用基类person指针指向派生类man的对象a
       p->print();       //调用speak
}

不良继承

       继承就是为了代码复用,继承方式很适合用来做框架设计,一层层向下剥离,然而继承时,父类有些元素,在子类中并不需要,这就是不良继承,例如“圆不是椭圆”问题,圆属于特殊椭圆,然而椭圆在抽象的时候有长轴、短轴,而圆只需要半径,所以如果直接把椭圆定义为父类,而圆定义为子类,那么长轴、短轴就是不良继承。这种良继承并不是程序问题,而是大自然中实际存在的问题。

        解决不良继承有多种方法,例如前面圆与椭圆的问题,我们可以去掉继承关系,然后把圆与椭圆共建一个通用类,然后在继承创建圆和椭圆。

组合介绍

        生活中的组合是将多个东西放在一起,例如集装箱内部装了很多小箱子,小箱子内部装了很多零件,c++中的组合本质上也是这个道理,就是一个class中包含多个其它class作为成员,目的也是为了实现代码复用,本质上其实就和结构体包含结构体变量是一个道理。当然组合的实现也可以是继承来实现,如下:

继承方式                                     

class a:public class b,public class c
{
int d;
}

组合方式

class a
{
public :
class b;
class c;
}

组合与继承对比

  • 继承的思路是谁属于谁的关系,例如男人属于人,具有传递性,不具有对称性。
  • 而组合是包含关系,例如人包含有手、脚、头等,但是不能说手属于人,人属于手.。
  • 继承是白盒复用,因为继承允许根据我们自己的实现来覆盖或重写父类的实现细节,父类的实现对于子类是可见的。继承的白盒复用特点一定程度上破坏了类的封装特性,因为会将父类的实现细节暴露给子类。
  • 组合是黑盒复用,被包含的对象内部细节对外是不可见的,他的封装特性相对较好,实现上互相依赖也较小。组合中被包含类时,在创建组合类变量,中间会创建很多包含在内部的变量,可通过获取其它具有相同类型的对象或引用或指针,在运行时动态定义组合,而缺点就是建立的对象过多。
  • 基于组合的一些优点,我们在设计时应优先考虑使用组合,而后继承。

多继承的二义性

        二义性,英文ambiguous,通俗的说就是“有歧义”,二义性主要是体现在同名问题上,子类继承了2个具有相同名称成员的父类,在访问时不能明确访问的是哪一个成员,所以有歧义。常见的有2种情况。

  • 程序员命名重名—c类继承a和b两个基类,而a、b两个类中有成员同名,程序太庞大,这种重名无法避免。
  • 另外一种导致二义性问题是菱形继承,如下视图,由于C继承了2个都包含有A的基类,所以自然会有成员重名,在菱形继承中成员重名是无法避免的。
  • c++继承与多态(下)

 

二义性解决措施

  • 修改a和b的任意一个类中的成员名称,使其不存在二义性问题。
  • 在访问成员时指定域名,格式为:c.a::member
  • 在c中再定义一个ab同名成员,来隐藏掉a和b中的同名成员。

        二义性问题,最好的情况是编译器报错,一般的情况是程序崩溃,最坏的情况就是运行的结果是错误的,还不能复现。

        二义性问题是不可避免的,至本阶段以后我们要研究的虚函数、虚继承、抽象类、重写、覆盖、多态都是来解决二义性问题的。

虚继承virtual

        虚继承是为解决二义性的方法之一,他的使用很简单,就是在父类(不是菱形继承时的祖类)继承class前添加virtual,语法如下:

class B1:virtual public A

        虚继承我们可以理解为类似我们在头文件中写的防重复包含宏 #ifndef __xxx_x__ ,而class中的virtual其实也是这个效果,当他检测到我们一个类中重复包含了祖class的成员时,就只包含一次,使最终的class只有一份祖class成员。

        他的实现原理其实际上比#ifdndef要巧妙,在菱形继承中,其实他根本就没有直接将基类的成员拷贝在新的class中,而是单独找了一个位置来存放要拷贝的所有成员,这些成员构成一个列表virtual table,编译器在被虚继承声明的class内部各放了一个指针vbptr指向被virtual table。到这里我们就可以理解为什么是虚继承,因为对与子类来说,只是多了指针,所以是虚的。

多态virtual

        多态polymorphism,从字面意思就是多种状态,多态指在运行过程中根据适配的不同对象来动态调用或绑定其对应的函数,多态的表现主要就是在基类的方法前面添加virtual将函数修饰成虚函数,有了virtual就是多态,没有virtual就是隐藏。

        如下示例,当基类和派生类存在同名方法时,普通情况下,当我们在使用基类的指针去调用派生类的对象中的方法时,会默认访问到基类中的方法,但是如果我们在基类的函数声明前面添加virtual关键字,将函数声明为虚函数,那么前面的指针就会调用到子类中的方法。这个现象就是多态。注意的是virtual只能在class内部的函数声明前面添加,不能在函数实体添加,或者当函数实体直接写在class内部,就可以添加virtual。

virtual  void Animal::speak(void)  //virtual修饰Animal类中声明的speak方法
{
    cout << "animal speak" << endl;
}

void Dog::speak(void) //在dog类中声明speak方法,dog类继承animal类
{
    cout << "wangwang"<<endl;
}

int main(void)

{
    Dog dog;    //定义一个dog对象
    Animal *p = &dog; //使用基类animal指针指向派生类dog
    p->speak();  //调用speak方法输出“wangwang”因为virtual修饰
}

多态的朋友

        多态的重点是函数override(覆盖或重写),override的重点是虚函数,虚函数的重点是virtual关键字,多态与以下几个概念容易混淆:

  • overload重载—同一个类中有多个同名但不同参的函数,编译器的区分识别就叫重载。
  • override覆盖、重写—多态,基类的函数添加virtual,派生类再定义一个同名函数。
  • redifine重定义隐藏—继承中,派生类的方法隐藏掉基类的方法。

        实际上多态,在我们写c代码的时候就已经用到了,只是没有提出来,例如我们定义了一个函数指针,根据用户不同来设置函数指针指向的目标,那么这种现象就是典型的在运行过程中动态选择要执行的函数,运行有多种状态。

纯虚函数        

        在实现多态的时候在基类的函数前面添加virtual来表示虚函数,实际上经过修饰后,虚函数的实体已经没有太大意义了,因为后面反正也用不着,所以又发明了纯虚函数,特征就是没有函数实体,纯虚函数如下示例:

       virtual void print(void) = 0;

        在虚函数下赋值0,这只是语法问题,没有为啥。纯虚函数不占用内存。纯虚函数在多态方面与虚函数没有任何影响。一旦类有了纯虚函数,这个类就不能实例化对象,否则就会报错。

抽象类abstract

        抽象类的基础是纯虚函数,只要类中含有1个纯虚函数,那么这个类就变成了抽象类,抽象类只能作为基类来派生新类,不能实例化对象。抽象类中的纯虚函数必须在派生类中去实现,否则抽象类会把派生类感染成抽象类,也不能实例化对象。

        抽象类的作用是将有关数据和方法组织在一个层次上,保证抽象的完整性,实现对派生类的成员方法或变量做出强制要求,例如在动物这个基类中写了一个“叫”的属性,并将其定义为纯虚函数,那么派生类就必须要写一个关于“叫”的实体,在基类中有些无法实现的属性,就可以声明为纯虚函数,留给派生类去实现,这样做可以使语法和语义上保持一致。抽象类中除了有虚函数,同样可以有其它成员变量和方法,这些变量和方法都在派生类中仍然能正常使用。

接口

        接口的基础是抽象类,接口是一种特殊类,用来定义一套访问接口和约定。程序员大多时候不爱写文档,看文档,所以我们要使用程序本身来传递更多的编程意图,既然是接口,就不应该有任何实体性的东西,例如成员变量,同时所有的成员方法都应该是纯需函数,且都是public,否则你自己又不写,也不允许别人访问,那就完全没有意义了。在有些高级语言中直接提供了interface关键字来实现接口,虽然c++没有,但是抽象类完全可以实现。

虚析构函数

        析构函数前添加virtual,将析构函数变为虚析构函数。

        基类有一个或多个虚函数(不是纯虚),则析构函数也应声明为虚析构函数。

        析构函数与普通函数在多态方面并没有区别,如下示例,当我们使用父类的指针指向了一个子类的对象,调用父类和子类的同名函数时会执行父类的函数,我们前面的解决方法就是将父类的函数声明为虚函数,而析构函数情况也类似。当我们将子类对象定义到堆上,在释放空间时会调用父类的析构,且父类的析构不会去调用子类的析构,这并不是我们希望的。

        解决方法就是将析构函数也声明为虚函数(不要求是纯虚),这样就告诉编译器,这个析构函数你不能直接编译使用,要留到代码运行时动态决定调用哪一个函数,这也是virtual关键字的本质意义。虚函数是对效率的一种牺牲。所以我们如果比较确定的东西,就不要使用虚函数,让编译器直接指向编译。

  • 普通析构函数情况
Animal *p = new Dog; //定义了一个父类指针,指向堆上的子类对象,
delete p;                      //释放时,会调用父类析构,抛弃子类析构
  • 虚析构函数情况
 Animal *p = new Dog; //定义了一个父类指针,指向堆上的子类对象,
 delete p;                      //释放时,会调用子类析构,子类析构再调用父类析构

using重定义继承访问权限

        父类中的非private成员,经过private权限继承后,继承的父类成员会变成private,导致外部不能访问,解决方法有三种:

        将private继承改为public继承。

        在类中再写一个public方法,来访问private权限的方法,实现间接访问。

        使用using关键字,修改权限,将其写在public下改为public权限(写在protected下,就改成protected权限),使其在外部可以访问,格式如下:

public:
using 基类::成员

注意,在指定成员方法时不写返回值、形参、括号,只写函数名。

上一篇:【C++】阻止类被实例化


下一篇:C++之多态