自学内容网 自学内容网

C++草原三剑客之一:多态

       时光流逝,愿你与珍爱之人,携手余生。

        Hello,各位大佬好,今天我们来讲讲C++中的最后一位 " 刺客 " :多态。

目录

1 多态的概念

2 多态的定义及实现

    2.1 定义

    2.2 实现动态所需要的条件

    2.3 虚函数

    2.4 虚函数的重写 / 覆盖

   2.5 虚函数重写的一些其他问题

       2.5.1 协变(了解)

       2.5.2 析构函数的重写

    2.6 override和final关键字

    2.7 重载 / 重写 / 隐藏的对比

3 纯虚函数和抽象类(了解知识)

4 多态的原理

    4.1 虚函数表指针

    4.2 多态的原理

       4.2.1 实现原理

       4.2.2 动态绑定与静态绑定

    4.3 虚函数表


1 多态的概念

       通俗来讲的话,就是多种形态。多态分为编译时多态(静态多态)和运行时多态(动态多态),这里我们重点讲讲运行时多态。编译时多态(静态动态)主要就是我们前面所讲的函数重载和函数模板,它们传不同的参数就可以调用不同的函数,通过参数的不同以达到多种形态,之所以叫编译时多态,是因为他们实参传给形参的匹配是在编译时完成的,因此把编译时多态归为静态,把运行时多态归为动态。运行时动态,具体点就是去完成某个行为(函数),可以传不同的对象就会完成不同的行为,就会达到多种形态。

2 多态的定义及实现

    2.1 定义

       多态是一个继承关系下的类类型的对象,去调用同一函数,产生了不同的行为。

    2.2 实现动态所需要的条件

       1>.必须是基类指针引用去调用虚函数。

       2>.被调用的函数必须是虚函数。

       说明:要实现多态的效果,第一必须是基类的指针或是引用,因为只有基类的指针或引用才能既指向派生类对象,又能指向基类对象;第二派生类必须对基类的虚函数构成重写 / 覆盖,只有重写或覆盖了,派生类才能有不同的函数,多态的不同形态效果才能达到效果。

    2.3 虚函数

       类成员函数前面加上virtual关键字进行修饰,那么这个成员函数被称之为是虚函数。注意非成员函数是不能加virtual关键字修饰的。

class person
{
public:
virtual void BuyTucket()
{
cout << "买票——全价" << endl;
}//注意:virtual这个关键字要加在成员函数的返回值的前面,因为只有这样,才说明被修饰的那个函数是虚函数。
};

    2.4 虚函数的重写 / 覆盖

       虚函数的重写 / 覆盖:派生类中有一个跟基类完全相同的虚函数(即派生类中的虚函数与基类中的虚函数的返回值类型,函数名字,参数列表(参数列表的类型)完全相同,称之为是派生类的虚函数重写了基类的虚函数)。

       对于这个虚函数的重写,我们这里还需要特别来强调一下,就是这个虚函数的重写在这里确实是将基类中的虚函数给重写了,只不过这里的重写只是将函数的实现给重写了,换句话说,其实就是在多态的场景下,基类虚函数的实现过程被替换成了派生类虚函数的实现过程,在编译时就被替换好了。

class A
{
public:
virtual void func(int val = 0) 
{ 
std::cout << "class A:" << val << std::endl; 
}
};
class B : public A
{
public:
void func(int val = 1) 
{ 
std::cout << "class B:" << val << std::endl; 
}
};//B和A构成继承关系,符合多态的形成条件
int main()
{
A* p = new B;//这里我们一定要用基类的指针去接,因为构成重写的条件之一就是必须要用基类的指针去调用虚函数。
p->func();//class B:0;当我们大家看到这个输出结果时,是不是有一种很疑惑的感觉,不明白为何会输出这个结果,其实这楼里就是利用了重写,将A中的func函数的实现过程给替换成了B中的func函数的实现过程,因此编译器会在这里输出"class B:0"这个结果,而不是"class B:1"这个结果。
return 0;
}//编译器之所以在这里会输出0而并非是1,是因为派生类B中的func函数就相当于是基类A类中的func函数的实现进行了重写,将func函数的实现过程重写成了B类func函数中的函数实现,也就是B类中的func函数成了如下代码所示:
//virtual void func(int val = 0)
//{
//cout << class B :<< val << endl;
//}
//如上述代码所写的那样,重写只是将实现换了,其余的过程均没有换,我们眼睛所看到的B类中的val是1,但是实际在编译是也就是重写之后就变成了0。

        对于这个虚函数的重写这个知识点,我们还需要注意一个知识点:在重写基类的虚函数时,派生类的虚函数在不加virtual这个关键字时,也可以构成重写(因为继承后基类的虚函数被继承下来了,在派生类中依旧保持虚函数的属性)(这里大家如果不怎么明白的话,就可以去参照前面的那个代码所写的那个过程),也就是说,如果有一个虚函数构成重写的话,那么派生类中的那个虚函数实际上就是基类中的那个虚函数,实现部分的代码给换成了派生类中的这个虚函数的实现部分的代码(就比如说上面那个代码,虚函数func构成重写,基类中的func函数前面没有加virtual这个关键字,func函数还是形成了重写,原因就是派生类中的那个func函数其实就是基类中的那个func函数,并将实现部分的代码替换成了派生类中的那个func函数的实现部分的代码),但是该种写法并不是很规范,不建议我们这样去使用,不过在考试的选择题中,经常会故意埋这个坑,让你判断是否构成多态,因此我们这里需要注意一下。

class person
{
public:
virtual void BuyTicket()
{
cout << "买票——全折" << endl;
}
};
class student :public person
{
virtual void BuyTicket()
{
cout << "买票——半折" << endl;
}
};//BuyTicket构成虚函数的重写,接下来,我们借着这两个继承关系的类来实现一个多态。
int main()
{
person* p1 = new person;
p1->BuyTicket();//买票——全折;
person& p2 = *p1;
p2.BuyTicket();//买票——全折;
person* s1 = new student;//这里会发生切割关系,也就是我们在前面一篇博客中所学的基类和派生类之间会相互转换的相关知识。
s1->BuyTicket();//买票——半折;这里调用的是派生类中的BuyTicket函数。
person& s2 = *s1;
s2.BuyTicket();//买票——半折;这里调用的其实也是派生类中的BuyTicket函数。
//这里我们来说明一下,就是这个多态它在调用某一个函数时,规定是必须要用基类的指针或引用去调用,若某一个指针或引用调用的这个函数构成多态,那么它所调用的函数所在的作用域取决于这个指针(引用)指向(引用)的那个对象,并不取决于指针(引用)它本身自己的类型,如果平时在不构成多态的情况下去调用函数的话,那么我们调用的这个函数所在的作用域是由指针(引用)它自己本身的类型去决定的,这一点我们要稍微去注意一下。
return 0;
}

   2.5 虚函数重写的一些其他问题

       2.5.1 协变(了解)

       派生类重写基类的虚函数时,与基类虚函数的返回值不同(这个问题打破了 " 三同 " 条件中的返回值不同)。即基类虚函数返回基类对象的指针或引用,派生类虚函数返回派生类对象的指针或者引用时,称之为是协变。(这里注意一下,就是基类虚函数返回基类对象的指针或引用,不一定是返回当前基类对象的指针或引用,当然也可以是其它基类对象的指针或引用)协变的实际意义其实并不大,所以我们这里只需了解一下即可。

class A
{ };
class B:public A
{ };//A类和B类构成继承,在这个继承体系中,A类是基类,B类是派生类。
class person
{
public:
virtual A* BuyTicket()
{ }
virtual person* Print()
{ }
//返回值既可以是当前这个基类person类类型对象的指针(引用),也可以是其他的继承体系中基类对象的指针(引用)。
};
class student
{
public:
virtual B* BuyTicket()
{ }
virtual student* Print()
{ }
};

       2.5.2 析构函数的重写

       将基类的析构函设置为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数的名字不同显然不符合重写的规则,但是实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称会统一处理成destructor,所以基类的析构函数加上virtual修饰,派生类的析构函数就会构成重写。下面,我们就通过代码来看一看为什么析构函数一定要构成重写?

class A
{
public:
virtual ~A()//我们这里假设virtual为(1)
{
cout << "~A()" << endl;
}
};
class B:public A
{
public:
~B()
{
delete[] _p;
cout << "~B()" << endl;
}
protected:
int* _p = new int[10];
};
int main()
{
//我们这里先不写(1)我们来看一个场景会不会报错。
A* p1 = new A;
A* p2 = new B;//这里采用了"切割"方面的知识。
delete p1;//~A;
delete p2;//编译器会在这里报错,错误原因是有内存泄露的问题,因为我们这个在调用析构函数的时候,它只调用了A的析构函数,并没有调用B的析构函数,因为p2的类型是A类类型,因此它只会调用A类的析构函数,并不会要用B的析构函数去释放_p这块连续的空间,因此这里才会报错,所以我们这里想让它调用B类的析构,换句话说,也就是在调用析构函数的时候,根据指针指向的那个对象的类型去调用相应的析构函数,为了解决这个问题,我们这里就可以使用虚函数的重写这部分知识来解决这个问题,因为只要构成重写,我们就可以通过使用多态来解决,因为多态它是根据指针指向的那个对象的的类型去调用相应的函数的,为了构成重写,必须保证函数名要相同,因此编译器才会对析构函数的函数名做了特殊处理。
return 0;
}

       注意:这个问题在面试中经常会被考察,大家一定要结合类似上面的样例才能讲清楚,为什么基类中的析构函数建议设计为虚函数。

    2.6 override和final关键字

       从上面我们可以看出,C++对虚函数重写的要求比较严格,但是有些情况下由于疏忽,比如说函数名写错、参数写错等等导致无法构成重写,而这种错误在编译时期是不会报出的,只有在程序运行时且没有得到预期结果才来找debug会得不偿失,因此C++11提供了override这个关键字,可以帮助用户检测是否构成了重写。如果我们不想让派生类重写这个虚函数的话,那么就可以用final这个关键字去修饰一下就可以了。

class car
{
public:
virtual void drive() { }
virtual void print() final { }//在虚函数的后面加上一个final,就说明这个虚函数不能构成重写操作
};
class xiaomisu7 :public car
{
public:
void dirve() override
{ }//编译器会报错,我们在dirve这个函数的后面加上了override这个关键字后,编译器在编译过程中就会检查这个dirve函数是否构成重写,到基类中去找,发现没有构成重写,就会报错。
void print() { }//编译器在这里也会报错,基类中print函数被final关键字修饰,因此不能构成重写。
};

    2.7 重载 / 重写 / 隐藏的对比

       为了更加方便的去表示,这里我们选择采用图片的形式来展示:

3 纯虚函数和抽象类(了解知识)

       在虚函数的后面写上=0,则这个函数为纯虚函数,纯虚函数不需要定义实现(实现没啥意义,因为要被派生类重写,但是语法上可以实现),只要声明即可,包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数的话,那么派生类也是抽象类,纯虚函数,在某种程度上强制了派生类重写虚函数,因为不重写的话就实例化不出对象来。

class tea
{
public:
virtual void drink() = 0;//对纯虚函数的一个简单声明
};
class hc :public tea
{ };//hc继承了tea类,也就相当于是继承了纯虚函数,因此hc这个类也就相当于是抽象类,因此,它也不能实例化出对象。
class lc :public tea
{
public:
virtual void drink()
{
cout << "绿茶" << endl;
}//lc类继承了tea类,也就继承了对drink这个纯虚函数的声明。在lc这个类中重写了drink函数,也就是对drink的实现,也可以理解为是在父类中声明,在子类中实现,实现了之后,lc类就相当于是不包含纯虚函数了。如果实在理解不了的话,我们就认为只要对纯虚函数进行了重写操作,那么这个纯虚函数它就会变成虚函数。
};
int main()
{
//tea t;//编译器在这里会报错,抽象类无法进行进行实例化操作。
//抽象类虽然不能实例化出对象来,但是可以定义指针和引用。
tea* t1 = new lc;
t1->drink();//绿茶
tea* t2 = new lc;
tea& t3 = *t2;
t3.drink();//绿茶
return 0;
}

4 多态的原理

    4.1 虚函数表指针

       前情提要,我们先来看下面一段代码:

class base
{
public:
virtual void func()
{
cout << "void func()" << endl;
}
protected:
int _n = 1;
char _ch = 'x';
};
int main()
{
base b;
cout << sizeof(b) << endl;//12;当我们大家看到这个输出结果时,会感到很奇怪,因为我们是用我们前面所学过的知识求出的空间大小是8,可这个输出结果为什么却是12,这是因为这个b对象中除了有_b和_ch这两个成员变量以外,其实还多了一个_vfptr的指针变量(这个_vfptr在对象中的位置不确定,有可能是在对象空间的最后面,也有可能在最前面,这个跟平台有关,这里我们认为它的位置在最前面),因此这个输出结果才会是12。
return 0;
}

       b对象中的这个指针,我们将它称之为是虚函数表指针,一个含有虚函数的类中至少都有一个虚函数表指针,这个虚函数表指针指向虚表,因为一个类中所有的虚函数的地址都要被放到这个类对象的虚函数表中,简称虚表(对于虚函数表指针和虚函数表这个东西,我们可以通过VS2022编译器的调试窗口就可以看到)。

    4.2 多态的原理

       4.2.1 实现原理

       多态是如何实现的:这个问题我们在这里要使用一段代码才会解释清楚。

class person
{
public:
virtual void BuyTicket() { cout << "买票——全价" << endl; }
protected:
string _name;
};
class student:public person
{
public:
virtual void BuyTicket() { cout << "买票——半价" << endl; }
protected:
string _id;
};
class soider:public person
{
public:
virtual void BuyTicket() { cout << "买票——优先" << endl; }
protected:
string _codename;
};
void func(person* ptr)
{
ptr->BuyTicket();
}

       从底层的角度看func函数中的ptr->BuyTicket(),ptr是如何做到当其指向person对象时调用的是person::BuyTicket(),ptr指向student对象时调用的是student:BuyTicket()的呢?通过4.1中的解释,我们可以得知每一个含有虚函数的类中,它的空间中除了这个类的成员变量,其实还有一个虚函数标志,它指向虚表,而这个虚表本质上就是一个函数指针数组,我们来将上述代码画成如下所示:

       (画完上面那幅图,我们这里首先要了解一个东西,就是student类它经常了person,但是student的虚表中却没有继承person的虚函数的地址,这是因为student类中的虚函数的地址将person的虚函数的地址给覆盖了,在student的虚表中,这个知识我们先知道一下,后面会讲到。)

       我们上面这幅图将实际的内存空间给画了出来,现在我们来说一下,为什么在执行 " ptr->BuyTicket(); " 这一句代码会做到ptr指向谁就调用谁的虚函数,(这里我们这里从汇编的角度出发来解释一下这个问题,因为这些代码它们最终都会被变成一条条指令,编译器就是靠识别这些指令去进行一系列操作的)编译器在运行到这里时会先判断是否构成多态,如果构成多态的话,会将这条指令变成到这个指针指向的对象的虚表中去找相应的函数,注意派生类里面的虚表其实是继承基类的,ptr是person类类型的指针,不管你传什么样的派生类过来,他在这里都会进行切割,因此ptr它始终都指向父类的那一部分,因此就能通过_vfptr这个指针找到虚表中所对应的函数,通过这种方式就实现了指向谁就调用谁的BuyTicket()函数。

       4.2.2 动态绑定与静态绑定

       1>.对不满⾜多态条件(指针或者引⽤+调⽤虚函数)的函数调⽤是在编译时绑定,也就是编译时确定调⽤函数的地址,叫做静态绑定。
       2>.满⾜多态条件的函数调⽤是在运⾏时绑定,也就是在运⾏时到指向对象的虚函数表中找到调⽤函数的地址,也就做动态绑定。(这两种绑定方式所生成的编译指令是完全不一样的,可以通过调用去看看汇编窗口)

    4.3 虚函数表

       1>.基类对象的虚函数表中存放基类所有虚函数的地址。
       2>.派⽣类由两部分构成,继承下来的基类和⾃⼰的成员,⼀般情况下,继承下来的基类中有虚函数表指针,⾃⼰就不会再⽣成虚函数表指针。但是要注意的这⾥继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同⼀个,就像基类对象的成员和派⽣类对象中的基类对象成员也独⽴的。
       3>.派⽣类中重写的基类的虚函数,派⽣类的虚函数表中对应的虚函数就会被覆盖成派⽣类重写的虚函数地址。

       4>.派⽣类的虚函数表中包含,基类的虚函数地址,派⽣类重写的虚函数地址,派⽣类⾃⼰的虚函数地址三个部分。
       5>.虚函数表本质是⼀个存虚函数指针的指针数组,⼀般情况这个数组最后⾯放了⼀个0x00000000标记。(这个C++并没有进⾏规定,各个编译器⾃⾏定义的,vs系列编译器会再后⾯放个0x00000000标记,g++系列编译不会放)
       6>.虚函数存在哪的?虚函数和普通函数⼀样的,编译好后是⼀段指令,都是存在代码段的,只是虚函数的地址⼜存到了虚表中。
       7>.虚函数表存在哪的?这个问题严格说并没有标准答案C++标准并没有规定,我们写下⾯的代码可以对⽐验证⼀下。(vs下是存在代码段(常量区),我们可以通过下面这段代码来判断一下虚函数表是存放在那个区域的。)

int main()
{
int i = 0;//i存放在栈中
static int j = 0;//j存放在静态区中
int* p1 = new int;//p1中存放的地址是堆中的地址
const char* p2 = "xxx";//p2中存放的地址是常量区的
cout << "栈:" << &i << endl;
cout << "静态区:" << &j << endl;
cout << "堆:" << p1 << endl;
cout << "常量区:" << p2 << endl;
person p;
student s;
person* p1 = &p;
student* s1 = &s;
cout << "person虚表地址:" << *(int*)p1 << endl;//通过我们前面对虚函数内存的分析可知,虚函数表指针是在整个内存的最上面(vs2022编译器的内存分布是这样的),地址在32位环境下占4个字节,将其强转为int*类型后,得到的就是前4个字节的地址,刚好就是虚表的地址
cout << "student虚表地址:" << *(int*)s1 << endl;
cout << "虚函数地址:" << &person::BuyTicket << endl;//函数名就是该函数的地址
return 0;
}//通过它们所输出来的地址来比较与哪一个区域的地址最相近,最相近的就说明它在那个区域,同时,这也是一个很好的检测某一个变量在哪个区域的方法。

       OK,今天我们就先讲到这里了,那么,我们下一篇再见,谢谢大家的支持!


原文地址:https://blog.csdn.net/2301_81390458/article/details/143647912

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!