目录
多态的概念
多态即多种状态。同一件事,不同的对象完成会给出不同的状态,比如同样是买票,学生买的学生票,儿童买的儿童票,普通成年人买的就是成年票。c++中的多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。
多态的定义
下面是多态的的一个简单的使用场景,
cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include<iostream>
using namespace std;
class father
{
public:
virtual void money()
{
cout << 1000000 << endl;
}
};
class son : public father
{
public:
virtual void money()
{
cout << 1000 << endl;
}
};
int main()
{
father fa;
son so;
father* x = &fa;
x->money();//1000000
x = &so;
x->money();//1000
return 0;
}
可以看到,对于money这个函数,不同的对象调用会产生不同的结果,这就是多态。当然多态的构成要符合多个条件:
1.必须通过基类的指针或者引用调用虚函数。
2.被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。
看到这两个条件很多读者可能会疑问,什么是虚函数呢?可以看到,上面的例子里父类和子类中都定义了money这个函数,这两个函数在定义时有点与众不同的就是在函数前面加了 virtual 这个词,学过继承的都会对这个词眼熟,因为这与虚继承的写法类似,关键词也是同一个,virtual 有虚拟的意思,所以加在继承前面叫虚继承,加在函数前面就叫虚函数了。当然,虚继承与虚函数是两个完全不同的概念。
除了虚继承外,我们还应注意重写这个词,虚函数的重写又叫做覆盖,是指:派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。到这里为止就有三种看起来相似的概念,很容易弄混,它们分别是函数重载,函数重定义(隐藏),虚函数重写(覆盖),这三者的定义如下,
名称 | 定义 |
---|---|
函数重载 | 在同一作用域(如同一个类)中定义多个同名函数,但参数列表不同(参数类型、个数或顺序不同),返回值类型不影响重载。 |
函数重定义(隐藏) | 子类中定义了与父类同名的函数(无论参数是否相同),导致父类函数被隐藏。若父类函数非虚函数,则子类函数会覆盖父类版本。 |
虚函数重写(覆盖) | 子类中重新定义父类的虚函数,要求函数名、参数列表、返回值类型完全相同(协变返回类型除外),以实现多态性。 |

这三者的概念要好好区分,免得将虚函数重写写成函数重定义了。
此外,还需要注意的是,virtual这个词其实只要在父类中要声明的虚函数前面加就行了,子类中即使不加也是可以形成多态的,这是因为父类的虚函数被继承进了子类后又发现了有符合隐函数重写条件的函数,这时隐函数的性质会被自动延续,即使不加 virtual 编译器也会将其视为隐函数,当然这也与隐函数的底层原理------虚函数表有关,这个之后会将讲。最后想说的是虽然子类中的虚函数重写可以不加,但这种写法不规范,还是建议加上 virtual ,这样能提升代码可读性,也能少一些未知的错误。
对于隐函数重写,c++本应是严格要求函数名、参数列表、返回值类型完全相同,但是c++给出了两个例外,分别是:
1.协变:派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指
针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include<iostream>
using namespace std;
class father
{
int _money = 1000000;
public:
virtual const father& money(int a)
{
cout << 1000000 << endl;
return *this;
}
};
class son : public father
{
int _money = 1000;
public:
virtual const son& money(int b)
{
cout << 1000 << endl;
return *this;
}
};
int main()
{
father fa;
son so;
father* x = &fa;
x->money(1);
x = &so;
x->money(1);
return 0;
}
注意这里的指针或者引用是要对应的,并且的同时是指针或者引用,反过来会报错。
2.析构函数的重写(基类与派生类析构函数的名字不同):如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。
cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include<iostream>
using namespace std;
class father
{
int _money = 1000000;
public:
virtual const father& money(int a)
{
cout << 1000000 << endl;
return *this;
}
virtual ~father()
{
cout << "~father()" << endl;
}
};
class son : public father
{
int _money = 1000;
public:
virtual const son& money(int b)
{
cout << 1000 << endl;
return *this;
}
virtual ~son()
{
cout << "~son()" << endl;
}
};
int main()
{
father* x = new father;
father* y = new son;
delete x;
delete y;
return 0;
}
为什么要将析构函数这么处理呢,当我们在使用基类指针向堆申请空间创建了父类对象使用完想要释放空间时,若没有重写析构函数,就会因为是父类对象而调用父类的析构,这样就只释放了父类那一部分的空间,会造成内存泄漏,所以定义成虚函数,通过重写虚函数的方式实现只想什么对象就调用对应的析构,所以将析构定义成虚函数进行重写是非常推荐的操作。
override 和 final
由于c++对于重写的要求比较高,在函数书写时如果出现一点差错就可能导致多态构建失败,而且这种错误是不会被编译器检测到的,虚函数如果没有遇到重写就更普通函数一样,只有最后发现代码逻辑有误之后才会回过头来找,这无疑是很麻烦的。对于这样的问题,:C++11提供了override和final两个关键字,可以帮助用户检测是否重写。
cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include<iostream>
using namespace std;
class father
{
int _money = 1000000;
public:
virtual const father& money(int a) final
{
cout << 1000000 << endl;
return *this;
}
virtual ~father()
{
cout << "~father()" << endl;
}
};
class son : public father
{
int _money = 1000;
public:
virtual const son& money(int b) // 错误,不能被重写
{
cout << 1000 << endl;
return *this;
}
virtual ~son()
{
cout << "~son()" << endl;
}
};
final 修饰的函数不能被重写,但是它可以重写别人,这样这个关键字可以用来阻止自己的子类重写自己的虚函数,而自己重写父类的虚函数又不受影响。
cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include<iostream>
using namespace std;
class father
{
int _money = 1000000;
public:
//virtual const father& money(int a)
//{
// cout << 1000000 << endl;
// return *this;
//}
virtual ~father()
{
cout << "~father()" << endl;
}
};
class son : public father
{
int _money = 1000;
public:
virtual const son& money(int b) override //报错,没有重写到父类的函数
{
cout << 1000 << endl;
return *this;
}
virtual ~son()
{
cout << "~son()" << endl;
}
};
那么由此推广,怎样写一个不能被继承的类呢,可以通过构造函数或析构函数私有化再创建静态成员在堆上创建和释放的方式创建一个类,
cpp
#include<iostream>
using namespace std;
class A
{
A() {}
public:
static A* createA()
{
return new A;
}
};
class B : public A
{
};
int main()
{
A* x = A::createA();
delete x;
B y;// 报错,不能调用父类构造
}
cpp
#include<iostream>
using namespace std;
class A
{
~A() {}
public:
static void destoryA(A* x)
{
delete x;
}
};
class B : public A
{
};
int main()
{
A* x = new A;
A::destoryA(x);
B y;// 报错,不能调用父类析构
}
这样就能实现一个不能被继承的类了。
抽象类
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口
类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生
类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include<iostream>
using namespace std;
class father
{
int _money = 1000000;
public:
virtual const father& money(int a) = 0;
virtual ~father()
{
cout << "~father()" << endl;
}
};
class son : public father
{
int _money = 1000;
public:
//virtual const son& money(int b) override
//{
// cout << 1000 << endl;
// return *this;
//}
virtual ~son()
{
cout << "~son()" << endl;
}
};
int main()
{
son so; //报错,因为没有重写纯虚函数,自己就也是抽象类,处于不能被重写的状态
return 0;
}
这个函数可以被用来规范一个类的接口,比如被要求写一个有指定接口的类,这时就可以继承抽象类,这就像一个协议,不重写指定接口就报错。当然,抽象类也能包含以实现的方法和成员变量,为子类提供公共功能。抽象类提供一部分成员,有定义一部分纯虚函数要求重写,增强了代码复用性,可维护性。
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实
现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成
多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
多态的原理
再将多态原理之前,我先给出这样一段代码,
cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include<iostream>
using namespace std;
class father
{
int _money = 1;
public:
virtual const father& money()
{
cout << 1000000 << endl;
return *this;
}
};
class son : public father
{
int _money = 2;
public:
virtual const son& money()
{
cout << 1000 << endl;
return *this;
}
};
int main()
{
son so;
cout << sizeof(so); // 2
return 0;
}
打印的结果是12,我们都知道,一个类类型对象的实际内存占用就只有自己的成员变量而已,成员函数存在公共代码段,这样有效节省了空间。我们这里的类中只有两个int类型成员变量(8字节),但这里占用了12个字节,那多出来的4字节用来存什么了呢?打开内存窗口,我们看到,

对象内存开头存了个神秘数据,这个数据是什么呢?其实这是虚函数表的指针(_vfptr),我们输入这个指针看到,

这就是这个类所对应的虚函数表,虚函数中存的是这个类中虚函数在公共代码段的地址,这里由于输了这个地址我们也看不懂函数在内存中的存储方式(但可以通过指针调用函数看是不是),所以就不输了。
通过以上的一系列操作,我们就明白了,有虚函数的类会有一份虚函数表,虚函数类类型对象会存一份指针指向这份虚函数表,公用这份表,虚函数表存放了类中所有虚函数所在公共代码段的指针,是一个指针数组,且这个数组一般情况这个数组最后面放了一个nullptr(看编译器,不同编译器实现也不同,多次生成解决方案不清理也会出现没有的情况,大概是编译器在多次生成解决方案后内存碎片多也没清理导致的)表示结束。
那么这个虚函数存在那呢?这里给出了一组测试用例,
cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include<iostream>
using namespace std;
class father
{
int _money = 1;
public:
virtual const father& money()
{
cout << 1000000 << endl;
return *this;
}
};
class son : public father
{
int _money = 2;
public:
virtual const son& money()
{
cout << 1000 << endl;
return *this;
}
};
int main()
{
int* x = new int(1);
int y = 1;
const char* z = "1";
son so;
cout << "堆: " << x << endl;
cout << "栈: " << & y << endl;
cout << "代码段: " << (void*)z << endl;
printf("虚函数表: %p", *((int*)&so));
return 0;
}
这是抛出的一组结果,
相信虚函数表存在哪不用我多说,与在代码段的只读常量非常接近,可以得知,虚函数表是存在代码段的。
说了这么多,那么虚函数能够形成多态的原理是什么呢?笔者这里给出了一下用例,
cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include<iostream>
using namespace std;
class father
{
int _money = 1;
public:
virtual const father& money()
{
cout << 1000000 << endl;
return *this;
}
virtual ~father()
{
cout << "~father()" << endl;
}
virtual void fa_print()
{
cout << _money << endl;
}
void func()
{
}
};
class son : public father
{
int _money = 2;
public:
virtual const son& money()
{
cout << 1000 << endl;
return *this;
}
virtual ~son()
{
cout << "~son()" << endl;
}
virtual void so_print()
{
cout << _money << endl;
}
};
int main()
{
son so;
father fa;
return 0;
}
这里给了好几个虚函数,也有没有重写的,方便看变化,这里给出运行时父子类的虚函数表的区别,
两个虚函数表中有一个函数指针是一样的,这其实就是没有被子类重写的 virtual void fa_print() 函数,当父类中有虚函数时,运行时就会在代码段生成一份自己的虚函数表,而子类在继承父类时就会先拷贝一份父类的虚函数表下来变成自己的,在看向自己的类中有没有虚函数,出现与表中同名同参数同返回类型(或协变)时就会把原本的八个覆盖掉(重写,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法),不是与表中同名同参数同返回类型( virtual void so_print() )就将自己这个添加进自己的虚函数表中(当然这只是为了方便理解模拟的过程,实际编译器可能优化完过程就是一口气生成,没有什么先拷贝再覆盖这种概念),普通函数不会加入虚函数表( void func() )。这样在使用父类指针或引用调用虚函数时就会到指针指向的类对象中找虚函数表指针,由虚函数表指针找到虚函数表,再根据虚函数表给出的地址间接寻址找到函数,编译器在这个过程中根本不关心到底是谁的函数,编译器只管跳转寻找函数然后实现,而虚函数表这种拷贝覆盖的方法使得使用不同的指针和引用通过虚函数表中转就能找到对应的函数的方式实现了多态。虚函数表这种机制也就决定了不能使用父类指针和引用之外的方式形成多态,使用子类指针和引用会不兼容,使用父类对象在切片拷贝时也是不会拷贝虚表的,拷贝的话就乱套了。
动态绑定与静态绑定
静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,普通函数以及函数重载都是静态绑定,在编译时就能确定调用的具体方法。动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。多态就是一种动态绑定,因为编译器无法根据给出的指针或引用确定是父类还是子类,只有实际运行时才能跳转查找。动态查找相对来说更耗费资源。
多继承的虚函数表
多继承相对于单继承多态的形成是一样,但虚函数表会有什么区别呢?这里写了一段测试代码,
cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include<iostream>
using namespace std;
class father
{
int _money = 1;
public:
virtual void money()
{
cout << "virtual father* money()" << endl;
}
virtual void fa_print()
{
cout << "virtual void fa_print()" << endl;
}
};
class mother
{
int _money = 2;
public:
virtual void money()
{
cout << "virtual mother* money()" << endl;
}
virtual void mo_print()
{
cout << "virtual void mo_print()" << endl;
}
};
class son : public father, public mother
{
int _money = 3;
public:
virtual void money()
{
cout << "virtual son* money()" << endl;
}
virtual void so_print()
{
cout << "virtual void so_print()" << endl;
}
};
int main()
{
son so;
father fa;
mother mo;
father* x = &so;
x->money();
mother* y = &so;
y->money();
return 0;
}
看一下运行时的内存分布,
多继承的虚表相对于单继承,在每个继承的类的部分的开头都加入了虚表指针,这意味着多继承的类有多个虚表。然后我们还可以看到多继承下如果子类有新的虚函数是默认添加到第一个父类也就是第一个继承声明的父类的虚函数表中去的。最后,我们还会发现一件奇怪的事,就是虽然子类对父类中的虚函数进行了重写,但两次重写的结果不一样,父类中的这两个函数本身的函数名,参数名和返回类型都一样,那就以都都被覆盖成同一个子类的虚函数呀,为什么不一样呢?这时就要从汇编代码中找到答案了,
我们先看向father类指针调用函数的汇编,很多人不懂汇编,说实话我也不懂,但这里只需要记住call指令是用来调用函数的就行了,这里运行到call指令时会跳转到,
这句指令,这句指令会再次跳转,
再次跳转就到了要调用的函数内部了。

我们再看想看向mother类指针调用函数的汇编,同样是call指令,运行到这里会跳转到,
同样是jmp,再次跳转会到,
这里就和father调用的不一样了,father指针这次就到函数内部了,而这个没到。这里事先将ecx寄存器中的存的用于调用类函数的this减了8然后再次跳转,
这时跳转到的jmp指令仔细看会发现与father的一样了,不用说,再次跳转,
就到函数内部了。
看完汇编的过程,我们不禁疑问为什么后声明的的this指针要减8呢。观察过子类内存分布的都知道,继承时先声明的父类会排在内存的最上面,这时我们用子类取地址赋值给先声明继承的类类型指针,父类的指针位置和子类的相同,编译器不会自动调整偏移。而如果是后声明的父类,在内存中的位置于子类的地址存在偏移,此时编译器会自动将子类的地址加上排在前面的类的size,是指针正确指向子类中对应父类的位置。但是当使用这样的指针在虚函数中找到子类覆盖的虚函数时,this指针是指向父类的与子类自制右派内衣的地址,用这样的this指针调用子类的虚函数无疑是有问题的,所以要对偏移的this指针进行修正,于是单独封装了一层,是一个跳板函数,this指针修正完之后才会调用对应的子类函数,所以才会出现指针不一样的情况。
继承与多态的一些比较坑的面试题
cpp
class A
{
public:
virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
virtual void test() { func(); }
};
class B : public A
{
public:
void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};
int main(int argc, char* argv[])
{
B* p = new B;
p->test();
return 0;
}
输出结果是 B->1,因为子类的虚函数覆盖掉了父类函数的实现,但是函数的框架也就是函数名返回值参数都啊还是父类的,缺省值也是父类函数的缺省值,所以通过B类型this指针找到了B的函数但用的A的函数参数的缺省值。
cpp
#include<iostream>
using namespace std;
class A {
public:
A(const char* s) { cout << s << endl; }
~A() {}
};
class B :virtual public A
{
public:
B(const char* s1, const char* s2) :A(s1) { cout << s2 << endl; }
};
class C :virtual public A
{
public:
C(const char* s1, const char* s2) :A(s1) { cout << s2 << endl; }
};
class D :public B, public C
{
public:
D(const char* s1, const char* s2, const char* s3, const char* s4) :B(s1, s2), C(s1, s3), A(s1)
{
cout << s4 << endl;
}
};
int main() {
D* p = new D("class A", "class B", "class C", "class D");
delete p;
return 0;
}
结果是class A class B class C class D,无论初始化列表怎么排,都是虚基类先构造,非虚基类按声明神顺序构造,有多个虚基类也是按声明顺序构造。
inline函数可以是虚函数吗?答:可以,不过编译器就忽略inline属性,这个函数就不再是inline,因为虚函数要放到虚表中去。
静态成员可以是虚函数吗?答:不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
构造函数可以是虚函数吗?答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
析构函数可以是虚函数吗?什么场景下析构函数是虚函数?答:可以,并且最好把基类的析构函数定义成虚函数。
对象访问普通函数快还是虚函数更快?答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
虚函数表是在什么阶段生成的,存在哪的?答:虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。
什么是抽象类?抽象类的作用?答:抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。