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

2015年2月5日
晴 周四

虽然今天天气很好但是长沙重度污染还是呆在机房写笔记好了。昨天写了面向对象的三大特征的前两个——封装和继承。只有你好好理解了继承,我们才好开始今天的【多态】的讲解(如果不懂得请看我的【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类是父类,M4和Note类都是继承后的子类,都函数重写了XiaoMi的show函数。

全局函数Buy的参数是XiaoMi类(父类)的引用。分别将M4对象m4和Note对象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程序猿就会说了,什么private不private的,还不是放在内存中的,只要你在内存中我就能用指针把你弄出来!看程序:

#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.动态类型识别

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

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

(1)dynamic_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),可以表现出灵活的多态特性。这就是为什么要讲抽象类。

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

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

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

时间: 2024-10-14 10:55:57

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

面向对象编程(十四)——面向对象三大特性之多态②

面向对象最核心的机制——动态绑定,也叫多态. 通过下面的例子理解动态绑定,即多态 1 package javastudy.summary; 2 3 class Animal { 4 /** 5 * 声明一个私有的成员变量name. 6 */ 7 private String name; 8 9 /** 10 * 在Animal类自定义的构造方法 11 * @param name 12 */ 13 Animal(String name) { 14 this.name = name; 15 } 16

【c++笔记十一】面向对象三大特征之《封装》与《继承》

2015年2月4日 晴 周三 今天立春,长沙终于见到了久违的太阳.心情好,复习一下知识点,就来说说面向对象的三大特征中的两点:封装和继承. ----------------------------------分割线------------------------------------- 一.封装 其实封装没办法具体的去讲,就像一种保密措施,在实践自己去体会. 该公开的数据,就用public权限:该隐藏的数据,就用private权限.那就看你在设计类的时候,想把什么公开给别人看,想把什么保护起来

面向对象三大特征之多态

面向对象的三大特征:封装.继承.多态.今天重点来看一下"多态". 封装 指一种将抽象性函式接口的实现细节部份包装.隐藏起来的方法.将属性或方法进行封装,防止外部程序随机访问,加强代码的安全性. 继承 子类继承父类的特征和行为,使得子类对象(实例)具有父类的实例域和方法,或子类从父类继承方法,使得子类具有父类相同的行为. 子类继承父类,使用extends关键字. 子类可以访问父类那些属性和方法? private 本类       默认省略(default) 本类 同包     prote

【原生js】js面向对象三大特征之多态笔记 (

1 <!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 <meta charset="UTF-8"> 5 <title>Document</title> 6 7 </head> 8 <script> 9 /*多态的基本概念:一个引用类型(变量)在不同情况下的多种状态. 10 js本身是无态的,天生就支持多态.*/ 11 12 //M

面向对象三大特征之多态——Java笔记(七)

多态: 同一个实体同时具有多种形式 编译时的类型有声明该变量时使用的类型决定,运行时的类型有实际赋值给变量的对象决定 如果编译时类型和运行时类型不同,就出现多态 例: 1 class Preson{ 2 } 3 class Teacher extends Preson{ 4 } 5 6 public class Demo { 7 public static void main(String args[]){ 8 //这行代码就会产生多态 9 Preson p = new Teacher(): 1

【华为云技术分享】Python 面向对象三大特征之多态

[摘要] 面向对象的三大特性多态相关知识. 多态 1.多态 多态指的是一类事物有多种形态 动物有多种形态:人,狗,猪 1 import abc 2 class Animal(metaclass=abc.ABCMeta): #同一类事物:动物 3 @abc.abstractmethod 4 def talk(self): 5 pass 6 7 class People(Animal): #动物的形态之一:人 8 def talk(self): 9 print('say hello') 10 11

c++面向对象三大特征封装、继承和多态知识总结

面向对象三大特征:封装,继承,多态: 一.封装:该公开的就公开话,该私有的就隐藏掉,主要是由public,private实现:作用是便于分工和分模块,防止不必要的扩展: 二.继承:就是一种传承,可以把父类型中的数据传承到子类中,子类除了传承了父类的数据之外,还可以对父类型进行扩展: 公开继承  public 保护继承  protected 私有继承  private 保护成员:在子类和本类中可以访问,其他不行: 1.公开继承:在公开继承下,父类型中的数据是公开的到子类型中权限是公开的:父类型中保

类与对象 面向对象和面向过程对比 面向对象三大特征:封装 继承 多态

 初识面向对象 面向过程: 一切以事务的发展流程为中心. 面向对象: 一切以对象为中心. 一切皆为对象. 具体的某一个事务就是对象 打比方: 大象进冰箱 步骤: 第一步, 开门, 第二步, 装大象, 第三步, 关门 面向对象:大象, 你进冰箱. 此时主语是大象. 我操纵的是大象. 此时的大象就是对象 1. 面向过程: 一切以事物的流程为核心. 核心是"过程"二字, 过程是指解决问题的步骤, 即, 先?干什么, 后干什么. 基于该思想编写程序就好比在编写一套流水线. 是一种机械 式的编程

(一)Python入门-6面向对象编程:07面向对象三大特征(封装、继承、多态)-继承

一:面向对象三大特征介绍 Python是面向对象的语言,也支持面向对象编程的三大特性:继承.封装(隐藏).多态. 封装(隐藏) 隐藏对象的属性和实现细节,只对外提供必要的方法.相当于将“细节封装起来”,只 对外暴露“相关调用方法”. 通过前面学习的“私有属性.私有方法”的方式,实现“封装”.Python 追求简洁的语法,没有严格的语法级别的“访问控制符”,更多的是依靠程序员自觉实现. 继承 继承可以让子类具有父类的特性,提高了代码的重用性. 从设计上是一种增量进化,原有父类设计不变的情况下,可以

Swift学习笔记十二:下标脚本(subscript)

下标脚本就是对一个东西通过索引,快速取值的一种语法,例如数组的a[0].这就是一个下标脚本.通过索引0来快速取值.在Swift中,我们可以对类(Class).结构体(structure)和枚举(enumeration)中自己定义下标脚本的语法 一.常规定义 class Student{ var scores:Int[] = Array(count:5,repeatedValue:0) subscript(index:Int) -> Int{ get{ return scores[index];