💖 作者简介:大家好,我是菀枯😜
🎉 支持我:点赞👍+收藏⭐️+留言📝
💬格言:不要在低谷沉沦自己,不要在高峰上放弃努力!☀️
之前我们已经将面向对象三大特性中的封装和继承讲了,接下来剩下最后一个环节了,那就是 多态。
通俗来说,就是去做相同一件事时,不同的人有不同的状态。比如买火车票,普通人是一个价格,而但学生去买就有一定的折扣,这就是一种多态。
多态是在不同继承关系的类对象,去调用同一函数,产生不同的行为。而要形成多态,必须满足下面这两个条件。
1. 必须通过基类的指针或者引用调用虚函数
2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
首先解释一下上面的一些名词,基类就是父类。
虚函数,我们在上篇文章讲虚拟继承时使用了一个关键字叫virtual,而虚函数就是用这个关键字修饰的函数,比如下面的 BuyTicket函数就是一个虚函数。
class Person
{
virtual void BuyTicket()
{
cout << "全价票" << endl;
}
};
那么什么是虚函数的重写(覆盖)呢?
子类中有一个跟父类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名、参数列表完全相同),此时称子类的虚函数重写了父类的虚函数
还是以买票为例子,我们创建两个类People和Student,二者都有BuyTicket这个行为,但二者两个函数的实现不同,此时我们可以称Student重写了Person类的BuyTicket函数。
class Person
{
public:
virtual void BuyTicket()
{
cout << "全价票" << endl;
}
};
class Student : public Person
{
public:
virtual void BuyTicket()
{
cout << "半价票" << endl;
}
};
此时,BuyTicket这个行为就构成了多态,当我们使用不同对象的父类引用去调用这些函数时,就会产生不同的行为。
void Buy(Person& p)
{
p.BuyTicket();
}
int main()
{
Student s;
Person p;
Buy(s);
Buy(p);
return 0;
}
此时我们可以看到,当我们传子类对象给Buy函数时调用的为子类中的函数,而父类传过去,调用的为父类函数。
我们之前提到在C++中构成函数重写的两个条件:
1. 必须通过基类的指针或者引用调用虚函数
2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
大部分情况下,都必须遵循这两个条件,但C++不愧是C艹,没有意外的话,它一定会出意外。
协变(父类与子类返回值类型不同)
class A {}; class B : public A {}; class Person { public: virtual A& f() { return new A; } }; class Student : public Person { public: virtual B& f() { return new B; } };
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
在上面这段代码中,我们可以发现两个f()函数的返回值不同,一个为A&,一个为 B&,但此时二者仍然构成函数重写,这种情况我们称之为协变,即父类虚函数返回父类的指针或引用,子类虚函数返回父类的指针或引用。
析构函数的重写
class Person { public: virtual ~Person() { cout << "~Person()" << endl; } }; class Student : public Person { public: virtual ~Student() { cout << "~Student()" << endl; } };
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
同样是这两个类,其中Student类是从Person类处,继承而来。虽然Person和Student类的两个析构函数名字并不相同,但也构成函数的重写。
因为~Person() 和 ~Student是我们给析构函数起的名字,而不是它实际的名字,当程序真正去编译时,这两个函数的名字都会被destructor所替换,所以实际上二者名字是一样的。
目前为止,我们已经学了重载,重写,重定义(隐藏),那么三者有什么区别呢?
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。
再继续以买票为例子,首先我们将Person类定义为一个抽象类。
class Person
{
protected:
std::string _name;
int _age;
public:
void virtual BuyTicket() = 0;
};
我们将Person类的BuyTicket函数设置为纯虚函数,此时Person类就是一个抽象类,我们无法使用Person类去实例化一个对象。只有当我们的类去重写这个虚函数之后,才能去实例化对象。
class Student : public Person
{
private:
int _code;
public:
virtual void BuyTicket()
{
cout << "半价票" << endl;
}
};
此时我们就可以用Student类去实例化一个对象。
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
接下来,我们就来看看底层,去揭开多态的神秘面纱,看看多态究竟是如何去实现的。
首先我们先来看看下面这道题目,看看大家能否答对?
//32位平台下
class a
{
public:
virtual void f()
{
int c = 0;
++c;
}
private:
int _b = 0;
};
int main()
{
a tmp;
cout << sizeof(tmp);
return 0;
}
大家觉得这个程序应该输出什么呢?
我相信肯定会有人马上喊:答案是4,因为函数不存在类中,类中只存成员变量,这个类中只有b成员占4个字节,所以答案是4。好巧不巧,我一开始也以为是这个样子,并且觉得这个题这么简单有什么好考的。可当程序运行起来,我傻眼了。
答案是8,这是为什么? 接下来我们使用调试来看看,这个类中到底有什么东西,使它的大小变成了8字节。
我们可以发现,在tmp对象中还偷偷藏了一个指针,那么这个指针又是用来干什么的呢?
我们发现了一个熟悉的东西,这个不正是我们所写的虚函数的名字吗
这个隐藏在暗处的指针,我们称作虚函数指针,它指向了一片空间,这片空间称之为虚函数表,这个表存放的就是类中虚函数的地址。
现在我们知道了什么是虚函数指针,以及虚函数表的概念,那么为什么需要这个表和这个指针呢?我们接下来再往后看
class A
{
public:
virtual void Func1()
{
cout << "A::f1()" << endl;
}
virtual void Func2()
{
cout << "A::f2()" << endl;
}
void Func3()
{
cout << "A::f3()" << endl;
}
private:
int _a;
};
class B : public A
{
private:
int _b;
};
int main()
{
A a;
B b;
return 0;
}
我们分别写了两个类,一个A类作为父类,其中有三个不同的函数,一个B类作为子类,从A类继承而来。首先我们并不对B类进行任何操作,来看看A类和B类中成员的情况。
我们可以看到二者的虚函数指针指向同一片空间,存的虚函数表相同,我们还可以发现,虚函数表中并没有Func3,说明在虚函数表中存放的只有虚函数的地址。
接下来我们在B类中对Func1进行一个重写,来看看是否会发生什么变化。
class B : public A
{
public:
virtual void Func1()
{
cout << "B::f1()" << endl;
}
private:
int _b;
};
此时我们发现b对象中的虚函数指针发生了变化,它不再与a对象的虚函数指针指向相同空间,同时b对象的虚函数表中的第一个函数也发生了变化,变为了它自己的成员函数。
根据目前的现象,我们可以得出一个结论:所谓重写实际上是对虚函数指针所指向的位置进行一个重写。
为了实现重写,我们需要这个指针和这个表的存在
虚函数表和虚函数指针的事我们先告一段落,等会我们再来看它,接下来呢,我们来看看普通调用和多态调用,二者在汇编代码上有何区别。
我们把刚刚的A类和B类拿过来,再次使用。
class A
{
public:
virtual void Func1()
{
cout << "A::f1()" << endl;
}
virtual void Func2()
{
cout << "A::f2()" << endl;
}
void Func3()
{
cout << "A::f3()" << endl;
}
private:
int _a = 0;
};
class B : public A
{
public:
virtual void Func1()
{
cout << "B::f1()" << endl;
}
void Func2()
{
cout << "B::f2()" << endl;
}
private:
int _b = 0;
};
int main()
{
B b;
A* a = &b;
a->Func1();
a->Func3();
return 0;
}
我们来看看Func1()和Func3()二者汇编代码的区别。
我们可以很明显的发现多态调用一个函数,比普通调用一个函数多了很多的汇编代码,多出来的汇编代码其实就是一个查表的过程。多态调用其实就是查虚函数表,然后从虚函数中取出要调用的函数的地址,再去call。而普通函数的调用就是直接去call这个函数的地址。
这种在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,称为动态链接。
既然有动态链接,当然也有静态链接**,静态链接是在程序编译期间就确定了程序的行为,调用具体的函数**。比如:函数重载。
有了上面的虚函数表和动态链接的知识后,我们再来补上多态原理的最后一环,来解释一下为什么多态需要使用父类的指针或引用才能实现。
大家记不记得我们在聊继承的时候,讲过一个切片。不记得的朋友可以移步去看看(56条消息) C++继承_。菀枯。的博客-CSDN博客
在进行切片时,我们也会将子类的虚函数指针给父类。因此函数的重写原理如下:
子类首先会从父类处继承原本的虚函数指针。
子类改变继承下来的虚函数指针,指向新的虚函数表的地址。
当我们使用父类的指针或引用时,会发生切片,虚函数表为原来对象的虚函数表
虚函数的调用实际上是一个查虚函数表的过程,根据不同对象传递的不同的虚函数表,我们就能实现不同对象调用同一函数时产生不同的效果。
欢迎各位参考与指导!!!