【c++笔记十二】面向对象三大特征之《多态》

201525 晴 周四

         虽然今天天气很好但是长沙重度污染还是呆在机房写笔记好了。昨天写了面向对象的三大特征的前两个——封装和继承。只有你好好理解了继承,我们才好开始今天的【多态】的讲解(如果不懂得请看我的c++笔记十一】)。

——————————————分割线————————————————

一.虚函数

       在开始讲多态之前还得给大家补充两个知识点,第一个就是弄懂虚函数。

       还记得我们昨天讲继承的最后一个知识点“多继承”时提到了,用虚继承解决成员数据访问。我们第一次看到了“virtual”关键字。其实虚函数你听名字就知道这是什么了:非静态成员函数前加virtual关键字。

       大家一定要注意:这个成员函数必须是非静态的(没有static修饰的,不懂的请看我的c++笔记七】)。

       看程序就懂了:

#include <iostream>
using namespace std;
class A{
public:
    virtual void show(){
        cout<<"virtual void show()"<<endl;
    }
};
int main()
{
    A a;
    a.show();
    return 0;
}
技术分享

       大家先不必关注虚函数的作用是什么,只要记住虚函数是什么就行了。从本文的后面你就懂虚函数是多态的关键

 

二.函数重写

       这是我们要给大家补充的第二个知识点。

1.什么是函数重写?

       在子类中提供一个和父类同名的虚函数,要求返回值、函数名、参数列表都必须相同,这叫做函数重写。

2.函数重载、名字隐藏和函数重写的区别:

       如果大家仔细看函数重写的概念,你会发现它和名字隐藏(不懂请看c++笔记十一】)的概念很像,并且函数重写(overwrite)和函数重载(overload)的名字很相近,所以我们把这三个概念在讲一下,以便区分他们。

1)函数重载:同一作用域中,函数名相同,参数列表不同的函数构成重载关系。

2)名字隐藏:子类中提供了和父类同名的数据叫做名字隐藏。

3)函数重写:子类中提供了和父类同名的虚函数叫做函数重写。

       函数重写和函数重载的区别在于:函数重载的两个函数要在同一个作用域中并且参数列表不同。但是函数重写的函数必须参数列表也要一模一样,而且是在子类和父类之间的。

       函数重写和名字隐藏的区别在于:名字隐藏的所有父类成员数据,而函数重写的必须是父类中的虚函数

       大家认真看完这两个知识点之后,我们可以开始我们【多态】的讲解了。


三.多态

1.什么是多态?

       当父类型的指针(引用)指向(引用)子类对象时,如果调用父类中的虚函数并且子类重写了这个虚函数,则调用的函数实现是子类的函数实现。

       a.继承是构成多态的基础

       b.虚函数是构成多态的关键

       c.函数重写是多态的必备条件

 

       可能当你现在还不是很懂多态的概念,没关系,我们通过不断地举例来解释第一段话。

#include <iostream>
using namespace std;
class A{
public:
    virtual void show(){
        cout<<"A::show()"<<endl;
    }
};
class B:public A{
public:
    void show(){
        cout<<"B::show()"<<endl;
    }
};
int main()
{
    A a;
    a.show();
    B b;
    A* a1 = &b;
    A& a2 = b;
    a1->show();
    a2.show();
    return 0;
}
技术分享 

        A类中有一个虚函数virtual void show(),子类B继承A之后,函数重写void show()函数。我们创建了父类A的对象a之后调用show函数,表现的是父类的函数实现。

我们创建了A类型的指针a1,但是指向的子类B的对象b,我们用对象a1调用show函数的表现确是子类B的函数实现。

我们还创建了A类型的引用a2,但却引用的是子类B的对象b,我们在用a2调用show函数的时候,表现的却是子类B的函数实现。

 

       这就是多态的表现,所以掌握多态一定要弄懂“继承、虚函数、函数重写”。你可以开始慢慢体会为什么要叫做“引用”。父类可能有很多子类的,但是我们只需要通过调用父类的这个虚函数,就能让这个成员有各种各样的实现方式,呈现出一种函数调用的“多样性”。

       你以为我们多态讲完了?不,这还只是开始。

 

2.多态的使用

       在弄懂什么是多态之后,我们要开始学会运用多态。

       多态的好处就是:类型通用(只需要使用父类)和便于扩展功能(扩展的功能交给子类去实现)。

       多态的使用一般是将父类型作为函数参数或者函数返回值

       我们一起来看看我们怎么用多态吧:

#include <iostream>
using namespace std;
class XiaoMi{
public:
    virtual void show(){}
};
class M4:public XiaoMi{
public:
    void show(){
        cout<<"小米4:1999元,现货供应"<<endl;
    }
};
class Note:public XiaoMi{
public:
    void show(){
        cout<<"小米note:2299元,每周二中午12点抢"<<endl;
    }
};
void Buy(XiaoMi& mi){
    mi.show();
}
 
XiaoMi* Test(string name){
    if("小米4" == name)
        return new M4();
    else if("小米note" == name)
        return new Note();
    else
        return NULL;
}
int main()
{
    M4 m4;
    Note note;
    Buy(m4);
    Buy(note);
 
    Test("小米4")->show();
    Test("小米note")->show();
    return 0;
}
技术分享 

       仔细看上面的代码。XiaoMi类是父类,M4Note类都是继承后的子类,都函数重写了XiaoMishow函数。

       全局函数Buy的参数是XiaoMi类(父类)的引用。分别将M4对象m4Note对象note传入Buy函数中,表现出来了多态性。

       全局函数Test的返回值类型是XiaoMi(父类)。该函数可以返回M4或者Note的指针,也表现出来了多态性。

 

3.多态的实现

       个人觉得只有彻底理解多态是怎么实现的,才算真正的掌握了多态。

       多态实现是一种:动态绑定

       什么是动态绑定呢?有动态绑定那就肯定有静态绑定,所谓的静态绑定就是在编译时就能确定函数的入口。比如一个普通的全局函数,在编译的时候就能确定什么时候调用这个函数。但是动态绑定不一样,它是在运行时确定函数的入口。意思就是编译的时候我还不不能这时候会调用什么函数,只有程序运行起来了才知道到底该调用什么函数。

       多态就是运用的这种动态绑定:当父类对象指针(引用)指向(引用)子类对象时,如果调用的是虚函数,则编译器不会立即绑定调用的函数地址。只有在程序跑起来了才绑定要调用的函数的地址。因为一般我们调用的是父类型,但是实际是用的子类型,只有运行程序后才能知道到底用的是哪个子类。

 

4.多态的底层实现

       那多态到底是如何实现动态绑定的呢?追根溯源,能实现这样强大功能的在c++中只可能有一种东西——指针(万能的指针啊)。

       多态中运用的这种指针叫做——虚表指针

       所谓虚表指针,就是:如果一个类定义了虚函数,则用这个类去实例化对象时,对象会多出一个成员变量是指针类型的,这个指针指向的是一张虚函数表。一个类型只有一张虚函数,所有这个类型的对象共享这一张虚函数表。

       我们一起来验证虚表指针的存在。还是从内存大小方面开始着手。如果一个类只有一个int型的成员变量,若干成员函数。那这个类的大小是多少?(什么,你不知道?)

#include <iostream>
using namespace std;
class A{
    int num;
public:
    A(int num=0):num(num){}
    void show(){
        cout<<"A::show()"<<endl;
    }
    int getNum(){
        return num;
    }
};
int main()
{
    cout<<sizeof(A)<<endl;
    return 0;
}
技术分享 

       说答案不是4的,看来你的类是白学了啊。因为无论类有多少成员函数,这些成员函数都是放在代码区的。决定一个类的大小主要是成员变量的大小之和。

       那你在告诉我,含有一个int成员变量并且会一个或一个以上的虚函数的类,它的大小又是多少?(还是4那你就没有彻底认识到虚表指针)。

#include <iostream>
using namespace std;
class A{
    int num;
public:
    A(int num=0):num(num){}
    virtual void show(){
        cout<<"A::show()"<<endl;
    }
    virtual int getNum(){
        return num;
    }
};
int main()
{
    cout<<sizeof(A)<<endl;
    return 0;
}
技术分享

       为什么是8字节呢?因为,int型成员变量占4个字节。但是,这个类中有两个虚函数,要把这两个虚函数放到虚函数表中,并且需要新增一个虚表指针指向这个虚函数表。这个虚表指针就占了4个字节。所以这个类的大小是8个字节。

 

       那你猜猜这个虚表指针放在这个类内存的什么位置呢?最后还是最前面?我们一起用程序告诉我们答案。

       我们等下要写的这个程序很有用。你如何在类外调用c++类中的private类型的成员变量?你可能会说,不行啊,private权限的成员变量是不能在类外用的啊!!!这是我们学权限控制时说的。c程序猿就会说了,什么privateprivate的,还不是放在内存中的,只要你在内存中我就能用指针把你弄出来!看程序:

#include <iostream>
using namespace std;
class A{
    int num;
public:
    A(int num=0):num(num){}
    void show(){
        cout<<"A::show()"<<endl;
    }
};
class B{
    int num;
public:
    B(int num=0):num(num){}
    virtual void show(){
        cout<<"virtual B::show()"<<endl;
    }
};
int main()
{
    cout<<"A的大小:"<<sizeof(A)<<endl;
    A a(10);
    int* pa = reinterpret_cast<int*>(&a);
    cout<<pa<<":"<<*pa<<endl;
 
    cout<<"B的大小:"<<sizeof(B)<<endl;
    B b(10);
    int* pb = reinterpret_cast<int*>(&b);
    cout<<pb<<":"<<*pb<<endl;
    pb++;
    cout<<pb<<":"<<*pb<<endl;
    return 0;
}
技术分享

        A类是一个没有虚函数的类,所以它的大小是4字节(就是int类型的大小)。所以我先取到A类的对象a的地址,并用一个int型的指针指向这个首地址(注意类型不同所以用重解释强制类型转换)我输出这个指针pa的地址就是对象a的地址,再取其中的值,结果就是我们构造时传入的10。尽管num是一个private类型的成员变量,虽然我不能直接在类外调用它,但是在c程序猿面前这都是浮云,他们会说没有什么是指针不能解决的。

        B类却是一个含有虚函数的类,所以它的大小是8字节,除了int类型成员变量的4字节还有虚表指针的4字节。运用和上面同样的方法,我们拿到了B类对象b的首地址,先输出前4个字节值,发现却是一个看不懂的值。其实这就是虚表指针指向的地址,也就是虚函数表的地址了!!!后4个字节才是我们放进去的数字10.

        所以,我们能得出一个结论:虚表指针是放在类对象的前4个字节

        我还是画一张草图,描述一下对象、虚表指针和虚函数表之间的对应关系:

 技术分享

       其实这张图画的不是很规范,将就看一看吧。先看类,类的前4个字节永远都是虚函数表(含有虚函数的类)。一个类只有一张虚函数表,并用虚表指针指向这个虚函数表。其实虚函数表更像一个一维数组,数组的每个元素都是一个函数指针,这个函数指针才真正的指向该成员函数的实现体部分。

       XiaoMi类继承了A类,但是这两个类的虚函数表却是不一样的,所以才有了多态。父类指针(引用)指向(引用)不同的子类,该子类的具体函数实现都是不一样的。

 

       “一个父类指针(引用)指向(引用)一个对象时,先根据对象的虚表指针定位虚函数表的地址。然后根据调用的函数名,取得调用的函数地址。这个函数地址对应什么样的函数就做什么样的函数实现。”这就是多态的底层实现。

       既然知道了多态的底层实现,我们一起来做一个思考题:如何使用虚函数表来调用虚函数?

       我们可以看到虚函数表类似一个一维数组,也就是一个一级指针。该数组的每个元素又是一个函数指针。所以虚函数表是一个指向指针的指针,所以虚函数表是一个二级指针。虚表指针是指向虚函数的指针,那虚表指针实际上是一个指向一个二级指针的指针,那么虚表指针就是一个三级指针

       通过上面分析,我们知道:虚表指针是一个三级指针,虚函数表是一个二级指针,虚函数表的元素是函数指针。我们如果要调用虚函数,就必须要拿到最后的函数指针,这就需要我们一级一级的去分解了。我们一起动手写一下:

#include <iostream>
using namespace std;
class A{
public:
    virtual void show(){
        cout<<"A::show()"<<endl;
    }
    virtual void fun(){
        cout<<"A::fun()"<<endl;
    }
};
typedef void (*vfun)();
typedef vfun* vtable;
int main()
{
    A a;
    vtable vt = *(reinterpret_cast<vtable*>(&a));
    vt[0]();
    vt[1]();
    return 0;
}

技术分享

       当然这段代码有点难度,看起来有一点困难,不理解也没关系,不影响我们对多态的认识。不过你能看懂这段代码,说明你编程水平很高了并且虚表指针、虚函数表和虚函数之间的关系你已经完全懂了,多态你已经拿下了。

       还是简单的说一下。首先从typedef开始说起,之所以用typedef是为了简化指针级数,什么二级指针三级指针的你看到一大堆的***头都大了,所以取个别名方便记忆和使用。第一个typedef是一种给函数指针取别名方式,意思就是以后vfun就代表了一种void(*)()类型的函数指针,这里的vfun是一级指针(也就是虚函数表中的元素)。第二个typedef是给一个二级指针(也就是虚函数表,指向vfun的指针)取别名vtable

       我们首先通过&a获得A类对象a的首地址,并通过重解释强制类型转换先转换成vtable*类型(三级指针,虚表指针),再对这个指针(虚表指针)取值操作获得二级指针vtable类型赋值给变量vt。然后就可以像用数组一样的用vt了。结果就是我们定义的虚函数了。

       是不是有一点晕?缓一缓,实在看不懂就跳过,我们继续讲多态。

 

5.动态类型识别

       为什么突然说到一个好像和多态没有关系的东西?其实还是有关系的。

       我们运用多态的时候都是用的父类的指针或者引用对象,实际在用哪个子类我们可能不知道,怎么办呢?这就需要用到动态类型识别,让程序动态(运行时)去识别这到底是哪个子类对象。有两种方法可以做到这一点:

1dynamic_cast(动态类型强制转换)

       在c++笔记三】类型转换的最后,我欠大家一个知识点——动态类型强制转换(dynamic_cast),现在终于可以讲了。

       使用:dynamic_cast<转换成的类型指针>(对象指针),尝试把一个对象指针转换成另一个类类型的指针时,如果转换成功则返回非空指针(转换后类型的指针),如果转换失败则返回NULL

       使用动态类型强制转化,只有在子类和父类之间转换的时候才能成功。所以可以利用这一点,判断到底是哪一种子类。请看代码:

#include <iostream>
using namespace std;
class XiaoMi{
public:
    virtual void show(){}
};
class M4:public XiaoMi{
public:
    void show(){
        cout<<"小米4:1999元,现货供应"<<endl;
    }
    void M4_fun(){
        cout<<"小米4是804不锈钢边框"<<endl;
    }
};
class Note:public XiaoMi{
public:
    void show(){
        cout<<"小米note:2299元,每周二中午12点抢"<<endl;
    }
    void Note_fun(){
        cout<<"小米note前面板是2.5D玻璃"<<endl;
    }
};
void Test(XiaoMi* mi){
    if(dynamic_cast<M4*>(mi))
        ((M4*)mi)->M4_fun();
    else if(dynamic_cast<Note*>(mi))
        ((Note*)mi)->Note_fun();
}
int main()
{
    M4 m4;
    Note note;
    Test(&m4);
    Test(¬e);
    return 0;
}
技术分享

       Test全局函数是用来识别到底是哪个子类并且调用该子类特有功能的函数。只有正确判断这个父类能不能动态转换成该类型的子类才能执行该子类的特有功能。真正的做到了动态类型识别。

(2)使用typeid

       我们在之前就用到过typeid这个函数,它会返回一个type_info类型的对象。该类型有一个name()成员函数用来表示被检验类型的名字。这个类还重载了==运算符,用于判断两个type_info对象是否相等(相等就表示是同一个类型)。

       同样我们可以使用typeid来进行动态类型识别:

#include <iostream>
#include <typeinfo>
using namespace std;
class XiaoMi{
public:
    virtual void show(){}
};
class M4:public XiaoMi{
public:
    void show(){
        cout<<"小米4:1999元,现货供应"<<endl;
    }
    void M4_fun(){
        cout<<"小米4是804不锈钢边框"<<endl;
    }
};
class Note:public XiaoMi{
public:
    void show(){
        cout<<"小米note:2299元,每周二中午12点抢"<<endl;
    }
    void Note_fun(){
        cout<<"小米note前面板是2.5D玻璃"<<endl;
    }
};
void Test(XiaoMi* mi){
    if(typeid(*mi) == typeid(M4))
        ((M4*)mi)->M4_fun();
    else if(typeid(*mi) == typeid(Note))
        ((Note*)mi)->Note_fun();
}
int main()
{
    M4 m4;
    Note note;
    Test(&m4);
    Test(¬e);
    return 0;
}

       运行结果和上面那个一样就不摆出来了。都实现动态类型识别。

6.虚析构函数

       构造函数是不能设置为虚函数的,但是析构函数却可以设为虚函数。

       当父类对象的指针指向子类对象时,释放这个指针对应的内存只会调用父类的析构函数。我们看程序:

#include <iostream>
using namespace std;
class A{
public:
    ~A(){
        cout<<"~A()"<<endl;
    };
};
class B:public A{
public:
    ~B(){
        cout<<"~B()"<<endl;
    };
};
int main()
{
    A* a = new B();
    delete a;
    return 0;
}
技术分享 

       看见没有,父类指针指向子类,但是只调用父类的析构函数。凡是类中涉及到内存操作的时候析构函数至关重要,如果没有调用子类的析构函数有时会出问题的。怎么解决呢?

       只要把父类中的析构函数设置为虚析构函数,则释放父类型指针对应的子类对象时,会先调用子类的析构函数进而触发父类析构函数。

#include <iostream>
using namespace std;
class A{
public:
    virtual ~A(){
        cout<<"~A()"<<endl;
    };
};
class B:public A{
public:
    ~B(){
        cout<<"~B()"<<endl;
    };
};
int main()
{
    A* a = new B();
    delete a;
    return 0;
}
技术分享

       注意第5行,把父类的析构函数设置为虚函数,这样就会调用子类的析构函数了。

 

四.抽象类

       学到这里多态基本已经讲完了,再强调一下抽象类,我们的多态算是真的结束了。

1.什么是抽象类?

       不能实例化的类,叫做抽象类。除了不能实例化,这个类和其他类没任何区别。

2.实现抽象类

       这里就用到了:纯虚函数。类似virtual void show()=0;就是一个纯虚函数,该虚函数没有实现体,直接=0

class A{
     public:
     virtual void show() = 0;
};

       这个类A就是一个纯虚函数。如果你用这个类去实例化对象会发生什么事呢?

 技术分享

       编译器报错了,说你不能用抽象类去定义对象。

       特别需要注意一点,如果有子类继承了这个抽象类,如果没有函数重写这个纯虚函数那么这个子类也是一个抽象类。

       如果除了析构函数之外所有的成员函数都是纯虚函数,则这个类称为纯抽象类。纯抽象类在开发中一般作为接口(Interface),可以表现出灵活的多态特性。这就是为什么要讲抽象类。

 

————————————结束语——————————————

       至此,多态算是真正的讲完了。多态还是很重要的东西,个人认为是面向对象的精华部分。所以你很有必要再好好的体会一番。

       总结一下:首先了解什么是虚函数和函数重写(注意和函数重载、名字隐藏的区别)。接着我们介绍什么是多态,并且怎么去使用多态(一定要体会这种多态表现出来的多样性)。然后,我们研究了多态是怎样实现,一定要了解虚表指针、虚函数表和虚函数实现之间的关系。所有虚函数中一定要特别注意虚析构函数。最后我们说了抽象类,特别要知道纯抽象类在开发中作为接口使用。

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。