该内容中的代码以及解释都是在vs2022下的x86环境中,涉及的指针都是4个字节,如果要在其他的平台下运行,部分代码需要改动。
通俗来说,就是不同的类型对象,去完成同一件事情的时候会产生不一样的状态。
多态分为静态多态和动态多态:
静态多态 是在编译时的,体现就是函数重载。
int i = 0, j = 1;
double a = 1.1, b = 2.2;
swap(i, j);
swap(a, b);
动态多态是在程序 运行时 的,根据不同的对象调用不同的函数完成不同的行为(到指向的对象的虚表中找到要调用的虚函数)。
举个例子:对于买票找个行为,普通人去买票可能是全价,而学生买票可以半价,而对于军人来说可以优先购票。
那么问题来啦,什么是虚函数?重写又是什么?请看下面!
虚函数的定义比如:
class Person
{
public:
virtual void BuyTicket()
{
cout << " PersonBuyTicket()" << endl;
}
};
派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的 返回值类型、函数名称、参数列表 完全相同),称子类的虚函数重写了基类的虚函数。
举个例子:
class Person
{
public:
virtual void BuyTicket()
{
cout << "全价购票" << endl;
}
};
class Student : public Person
{
public:
virtual void BuyTicket()
{
cout << "半价购票" << endl;
}
};
class Soldier : public Person
{
public:
virtual void BuyTicket()
{
cout << "优先购票" << endl;
}
};
//void Fun(person* p) 用指针也是可以的
void Fun(Person& p) //用父类的引用接收参数,去调用其他对象的虚函数
{
p.BuyTicket();
}
int main()
{
Person p; //普通人
Student st; //学生
Soldier sd; //军人
Fun(p);
Fun(st);
Fun(sd);
//Fun(&p); 用指针传参
//Fun(&st);
//Fun(&sd);
return 0;
}
运行结果:
全价购票
半价购票
优先购票
解析: 这里 Fun 函数的参数是基类对象的引用,通过接收对象,来调用不同派生类对象的虚函数,实现多态。
派生类重写基类虚函数的时候,与基类虚函数的返回值类型不同。即基类虚函数返回基类对象的指针或引用,派生类虚函数返回派生类对象的指针或引用时,称为协变。
class A
{};
class B : public A
{};
class Person
{
public:
virtual A* f()
{
cout << "A::f()" << endl;
return new A;
}
};
class Student : public Person
{
public:
virtual B* f()
{
cout << "B::f()" << endl;
return new B;
}
};
int main()
{
Person p;
Student s;
Person* ptr;
ptr = &p;
ptr->f();
ptr = &s;
ptr->f();
return 0;
}
运行结果:
A::f()
B::f()
假设将 B 类中的继承 A 的关系去掉,那么这个程序就会报错,因为这样子就不构成了虚函数的重写了!
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加 virtual 关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后任何类的析构函数的名称统一处理成 destructor。
❓ 问题: 那么如果我们不对基类的析构函数处理为虚函数会发生什么?
解答: 可能会发生内存泄漏!假设有以下的情况:
class Person
{
public:
~Person()
{
cout << "~Person()" << endl;
}
};
class Student : public Person
{
public:
~Student()
{
cout << "~Student()" << endl;
}
};
int main()
{
//若为下面的普通对象则没有问题,因为他们会分别去调用他们的析构函数,与是否构成重写没有关系
//Person p;
//Student s;
// 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
Person* p1 = new Person;
Person* p2 = new Student;
delete p1;
delete p2;
return 0;
}
运行结果:
~Person()
~Person()
假设基类的析构函数没有被处理为虚函数,那么它其实这里和派生类的析构函数就构成了隐藏(编译器统一将析构函数处理为destructor()),而这里的 p1 和 p2 都是 Person 类的指针,也就是说他们在 delete 的时候只能去调用 Person 的析构函数,即 p2 没办法去调用到 Student 的析构函数去清理类内的数据。
若此时 Student 类中没有需要清理的成员,那么没有问题;但是如果有需要清理的成员,比如说开辟的动态内存,那么就没有释放,就会造成内存泄漏…
所以 我们对于基类的析构函数,一律处理为虚函数,这样子即使是 Person 类的指针调用 Student,也能去访问 Student 的析构函数,达到清理的目的!
这也是为什么编译器要将析构函数统一处理为 destructor() 的原因,因为这样子他们的函数名就相同了,且都是没有返回值,也没用参数,也就构成了重写的条件!
class Person
{
public:
virtual ~Person()
{
cout << "~Person()" << endl;
}
};
class Student : public Person
{
public:
virtual ~Student()
{
cout << "~Student()" << endl;
}
};
int main()
{
//若为下面的普通对象则没有问题,因为他们会分别去调用他们的析构函数,与是否构成重写没有关系
//Person p;
//Student s;
// 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
Person* p1 = new Person;
Person* p2 = new Student;
delete p1;
delete p2;
return 0;
}
运行结果:
~Person()
~Student()
~Person()
第一个析构是 p1 所指对象的析构,后两个析构是 p2 所指对象的析构,分别调了派生类和父类的析构函数完成析构,符合继承原理!
这里去掉子类重写的虚函数的 virtual 是可以的,但是注意不能去掉基类中虚函数的 virtual,因为它认为派生类是先继承父类的虚函数的,继承下来之后就有了 virtual 的属性了,派生类只是重写了这个 virtual 函数。
class Person
{
public:
virtual void BuyTicket()
{
cout << "全价购票" << endl;
}
};
class Student : public Person
{
public:
void BuyTicket()
{
cout << "半价购票" << endl;
}
};
void Fun(Person& p)
{
p.BuyTicket();
}
int main()
{
Person p;
Student s;
Fun(p);
Fun(s);
return 0;
}
运行结果:
全价购票
半价购票
从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来 debug 会得不偿失,因此:C++11提供了 override 和 final 两个关键字,可以帮助用户检测是否重写。
class Car
{
public:
virtual void Drive() final {} //在不想被继承的函数之前加上final
};
class Benz :public Car
{
public:
virtual void Drive() {cout << "Benz-舒适" << endl;}
};
运行结果:
error C3248: “Car::Drive”: 声明为“final”的函数无法被“Benz::Drive”重写
class Car
{
public:
virtual void Drive(char ch) {}
};
class Benz :public Car
{
public:
//在想检测的虚函数的实现之前加上override
virtual void Drive(int i) override {cout << "Benz-舒适" << endl;}
};
运行结果:
error C3668: “Benz::Drive”: 包含重写说明符“override”的方法没有重写任何基类方法
对于重载的条件:
对于**重写(覆盖)**的条件:
对于 隐藏(重定义) 的条件:
在虚函数的后面写上 = 0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有派生类重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
除此之外,纯虚函数是可以有实现内容的,但是由于无法实例出对象,以及它一般会被派生类重写该纯虚函数,并且纯虚函数是用来表达一些比较抽象的事物比如植物、动物等,所以一般基类的纯虚函数的实现内容是没必要给的,因为没什么意义。
抽象类的定义如下:
class A
{
public:
A(){ cout << "A()" << endl; }
virtual void fun() = 0
{
cout << "可以有实现内容,但是没有意义" << endl;
}
};
class B : public A
{
public:
B() { cout << "B()" << endl; }
virtual void fun()
{
cout << "B::fun()" << endl;
}
};
int main()
{
//A a; //❌不允许实例化出抽象类
//A* ppa = new A; //❌也不允许通过动态内存开辟A类
B b;
b.A::fun(); //可以通过b来调用A的虚函数
A& pa = b; //也可以用A类的引用来调用子类的虚函数
b.fun();
return 0;
}
运行结果:
A()
B()
可以有实现内容,但是没有意义
B::fun()
假设这里我们没有重写 B 类的虚函数,那么 B 类也是没办法生成对象的。
结论:
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
我们先来看一道经典的面试题:
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
char _c = '\0';
int _b = 1;
};
int main()
{
cout << sizeof(Base) << endl;
return 0;
}
很明显,这道题要我们求基类 Base 的大小,如果我们不了解多态的底层原理的话,在这里我们可能觉得这只是一个比较简单的结构体内存对齐问题,假设在32位平台下面运行,我们可能会以为这是8个字节,但是答案是12个字节!为什么?
运行结果:
12
我们通过定义一个基类 Base 的对象,通过监视窗口来看看是什么情况:
Base b;
咦,很奇怪是不是?我们本以为 Base 类里只有 _b 和 _c 两个成员,但是这里的成员前面又多了一个 _vfptr(注意有些平台可能把该指针放到下面,这个跟平台有关) ?这个是个什么东西?
但是从这里可以看出来,_vfptr 是个指针,所以最后我们的类的大小加上4个字节,就是12个字节了!下面让我们来一探究竟!
上面类中出现的 _vfptr 指针我们叫做 虚函数表指针(v代表virtual,f表示function,又称为虚表指针)。
一个含有虚函数的类中都至少有一个虚函数表指针(可能有多个)
注意: 这里跟虚继承是不一样的,他们之间都用了 virtual ,都是他们的使用场景完全不一样,解决的问题也是不一样的。虚继承产生的是虚基表,由虚基表指针指向它,虚基表里面存的是距离虚基类的偏移量!
既然有了虚函数表指针,那这个指针肯定是用来指向我们的虚函数表的!
我们给 Base
再增加一个 虚函数Func2
和一个 普通函数Func3
来观察一下:
//代码一
//Base再增加一个虚函数Func2和一个普通函数Func3
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
virtual void Func2()
{
cout << "Func2()" << endl;
}
void Func3()
{
cout << "Func3()" << endl;
}
private:
char _c = '\0';
int _b = 1;
};
int main()
{
Base bs;
return 0;
}
❓ 可能有些童鞋就会问:咦!那 func3 去哪里了?
解答: 哎呀,知识不能乱了,还记得吗,类的普通成员函数是不包含在类中的,它是存在于公共代码段中的,只是我们这里有了虚函数之后,为了实现多态的行为,必须得有虚函数表,所以才将虚函数表指针算入了成员变量中!而普通函数是不算入的!
好啦,接下来我们继续观察,这次我们写一个派生类,来观察他们的虚表指针以及虚表:
//代码二
class A
{
public:
virtual void fun()
{
cout << "A::fun()" << endl;
}
};
class B : public A
{
public:
virtual void fun()
{
cout << "B::fun()" << endl;
}
};
void Func(A& a)
{
a.fun();
}
int main()
{
A a;
Func(a);
B b;
Func(b);
return 0;
}
运行结果:
A::fun()
B::fun()
下图分别对三种情况进行解析:
void Func(A& a) //为什么这里不能是父类对象?而一定要是父类的引用或指针呢?
{
a.fun();
}
解析: 先来看一下构成多态和不构成多态时候的区别(忘记的童鞋可到上面复习构成多态的条件):
若构成多态:父类的指针或引用,在程序运行时到指定的对象中的虚表去找对应的虚函数调用,所以指向的是父类对象,则调用的是父类的虚函数,指向的是子类的对象,则调用的是子类的虚函数。
若不构成多态,也就是以下情况:
void Func(A a) //这里用父类对象
{
a.fun();
}
那么这里调用的就是编译时确定的调用那个函数,主要看的是 a 的类型,这里是 A 类对象,所以只会去调用 A 类对象的函数,传其他的派生类对象过来也没有影响。
那还是那个问题啊,为什么父类对象不能构成多态的条件?
解答: 其实这也是一个切片问题,我们对上面的代码,每个类都加个成员变量,来观察一下切片的过程:
class A
{
public:
virtual void fun()
{
cout << "A::fun()" << endl;
}
int _a = 1;
};
class B : public A
{
public:
virtual void fun()
{
cout << "B::fun()" << endl;
}
int _b = 250;
};
void Func(A& p)
{
p.fun();
}
int main()
{
A a;
Func(a);
B b;
Func(b);
return 0;
}
对于父类的对象:
void Func(A p) //这里用父类对象
{
p.fun();
}
int main()
{
A a;
Func(a);
B b;
b._a = 100; //将b中的_a置为100,才容易观察其中的切片变化
Func(b);
return 0;
}
简单地说,就是切片时候不会将 _vfptr 也切过去,所以如果是对象赋值,这样子就达不到多态的目的!
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
void Func(Person* p) {
p->BuyTicket();
}
int main()
{
Person mike;
Func(&mike);
mike.BuyTicket();
return 0;
}
// 以下汇编代码中跟你这个问题不相关的都被去掉了
void Func(Person* p) {
...
p->BuyTicket();
// p中存的是mike对象的指针,将p移动到eax中
001940DE mov eax,dword ptr [p]
// [eax]就是取eax值指向的内容,这里相当于把mike对象头4个字节(虚表指针)移动到了edx
001940E1 mov edx,dword ptr [eax]
// [edx]就是取edx值指向的内容,这里相当于把虚表中的头4字节存的虚函数指针移动到了eax
00B823EE mov eax,dword ptr [edx]
// call eax中存虚函数的指针。这里可以看出满足多态的调用,不是在编译时确定的,是运行起来以后到对象的中取找的。
001940EA call eax
001940EC cmp esi,esp
}
int main()
{
...
// 首先BuyTicket虽然是虚函数,但是mike是对象,不满足多态的条件,所以这里是普通函数的调用转换成地址时,是在编译时已经从符号表确认了函数的地址,直接call 地址
mike.BuyTicket();
00195182 lea ecx,[mike]
00195185 call Person::BuyTicket (01914F6h)
...
}
运行结果:
买票-全价
买票-全价
简单点说就是这样子:
只有同一类型的对象,才共享同一张表。且要注意如果虚表指针的地址不同,则代表他们的虚表不一样,若虚表地址一样的话,则他们是共享同一张虚表的!
虚表是在编译阶段产生的,而不是在运行的时候产生!且虚表是存放在==代码段(常量区)==的!,而 虚表指针是在构造函数初始化列表的时候产生的!这个下面会有实例解释。
覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
虚表本质是一个存虚函数指针的指针数组,这个数组最后面放了一个nullptr,也就是说 虚表存的是指针,而不是虚函数,这一点很容易混淆, 虚函数和普通函数一样,都是存在代码段(常量区) 中的。除此之外,vs中有bug,有时候数组后面没放nullptr,导致调试的时候容易搞错。
总结一下派生类的虚表生成:
静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载
动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
思路:我们可以将虚表的地址和存放在栈、堆、静态区、常量区的数据进行比较,看看与谁最接近
class Base
{
public:
virtual void fun1()
{
cout << "Base::fun1()" << endl;
}
};
class Derive : public Base
{
public:
virtual void fun1()
{
cout << "Derive::fun1()" << endl;
}
};
int main()
{
Base b;
Base* p = &b;
//在vs中取虚表地址就是取对象的前四个字节
//所以这里我们把对象指针先转化为int*,然后再将其解引用得到他的地址
printf("_vfptr:%p\n", *((int*)p));
int i; //栈上的数据
int* j = new int; //堆上的数据
static int Global = 0; //静态区的数据
const char* c = "liren"; //常量区的数据
printf("栈上的地址:%p\n", &i);
printf("堆上的地址:%p\n", j);
printf("静态区上的地址:%p\n", &Global);
printf("常量区上的地址:%p\n", c);
return 0;
}
运行结果:
_vfptr:00639B34
栈上的地址:012FF7F8
堆上的地址:014FD5A8
静态区上的地址:0063C3FC
常量区上的地址:00639B70
可以很直观的看见,虚表是放在常量区的!
我们先来观察一下下面的代码:
class Base
{
public:
virtual void func1() { cout << "Base::func1()" << endl; }
virtual void func2() { cout << "Base::func2()" << endl; }
private:
int _a;
};
class Derive : public Base
{
public:
virtual void func1() { cout << "Derive::func1()" << endl; }
virtual void func3() { cout << "Derive::func3()" << endl; }
virtual void func4() { cout << "Derive::func4()" << endl; }
private:
int _b;
};
int main()
{
Base b;
Derive d;
return 0;
}
很奇怪是不是,监视窗口里面找不到派生类对象 d 自己的两个虚函数 func3 和 func4,既然这样子,我们只能通过内存窗口来看看这两个虚函数究竟被vs编译器藏在了哪里!
唉~有没有发现我们只是用了前面虚表的知识才比较感性的说明这两个是 func3 和 func4 的地址,那要是只是刚刚好是巧合呢?
class Base
{
public:
virtual void func1() { cout << "Base::func1()" << endl; }
virtual void func2() { cout << "Base::func2()" << endl; }
private:
int _a;
};
class Derive : public Base
{
public:
virtual void func1() { cout << "Derive::func1()" << endl; }
virtual void func3() { cout << "Derive::func3()" << endl; }
virtual void func4() { cout << "Derive::func4()" << endl; }
private:
int _b;
};
//写一个程序,打印出虚表里面的函数,确认一下是否真的在虚表里面
typedef void(*VFunc)(); //由于等会要传_vfptr也就是存函数指针的数组指针,类型是void*,所以我们把他们都统一重命名为 VFTunc
void PrintVFT(VFunc* ptr) //这里ptr是个存函数指针的数组指针
{
// 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数
printf("_vfptr:%p\n", ptr);
for (int i = 0; ptr[i] != nullptr; ++i)
{
printf("_vfptr[%d]:%p --> ", i, ptr[i]);
ptr[i](); //通过函数指针来调用虚函数来打印函数的内容
}
cout << endl;
}
int main()
{
// 思路:取出b、d对象的头4bytes,就是虚表的指针,前面我们说了虚函数表本质是一个存虚函数指针的指针数组,这个数组最后面放 了一个nullptr
// 1.先取b的地址,强转成一个int*的指针
// 2.再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的指针
// 3.再强转成VFunc*,因为虚表就是一个存VFunc类型(虚函数指针类型)的数组。
// 4.虚表指针传递给PrintVTable进行打印虚表
// 5.需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后面没有放nullptr,
// 导致越界,这是编译器的问题。我们只需要点目录栏的 - 生成 - 清理解决方案,再编译就好了。
Base b;
PrintVFT((VFunc*)(*((int*)&b)));
Derive d;
PrintVFT((VFunc*)(*((int*)&d)));
return 0;
}
多继承中的虚表那就更复杂啦!但是我们依然可以用单继承中打印虚表内容的程序来测试以下,假设有以下的情况:
//多继承
class Base1
{
public:
virtual void func1() { cout << "Base1::func1" << endl; }
virtual void func2() { cout << "Base1::func2" << endl; }
private:
int b1;
};
class Base2
{
public:
virtual void func1() { cout << "Base2::func1" << endl; }
virtual void func2() { cout << "Base2::func2" << endl; }
private:
int b2;
};
class Derive : public Base1, public Base2
{
public:
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
private:
int d1;
};
int main()
{
Base1 b1;
Base2 b2;
Derive d;
return 0;
}
比起单继承,多继承的派生类会生成多份虚表,也就印证了一个道理:一个对象的虚表不只有一张!
很明显,对于其他的函数我们都能理解,这里和单继承一样,还是找不到派生类对象自己的虚函数 func3。
所以啊,又得再来打印一遍他们的虚表地址和函数调用,看看是否也符合我们想的(是否 func3 也在虚表内)。
借助单继承里面打印地址的代码,我们来测试一下:
typedef void(*VFunc)(); //由于等会要传_vfptr也就是存函数指针的数组指针,类型是void*,所以我们把他们都统一重命名为VFTunc
void PrintVFT(VFunc* ptr) //这里ptr是个存函数指针的数组指针
{
// 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数
printf("_vfptr:%p\n", ptr);
for (int i = 0; ptr[i] != nullptr; ++i)
{
printf("_vfptr[%d]:%p --> ", i, ptr[i]);
ptr[i]();
}
cout << endl;
}
int main()
{
Base1 b1;
Base2 b2;
Derive d;
PrintVFT((VFunc*)(*((int*)&d)));
PrintVFT((VFunc*)(*((int*)((char*)&d + sizeof(Base1))))); //括号比较多,看的时候注意看仔细
return 0;
}
注意: 因为派生类有两张虚表,不过借助他们之间是紧挨着的关系,我们可以直接把第一张虚表取出来,然后第二张虚表就是第一张虚表加上第一个继承的父类 Base 的大小 sizeof(Base1),注意要先将 &d 强转为 char* ,因为 &d 是一个 Derive*,所以加了 sizeof(Base1) 后并不是跳到第二张虚表,为了让其加上相隔的字节数,所以我们得将 &d 转化为 char*。
由此可以看出,派生类的成员函数被放到了第一个父类的表中,(所谓的第一个父类是按照声明顺序来判断的)!然后其他的规则是和单继承一样的!
实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。