多态,通俗来说,就是多种形态,就是当去完成某种行为时,不同的对象会发生不同的行为。
就像学生和普通成人去景区买票,同样是买票,学生和普通成人所要花费的资金是不一样的。
多态是在不同继承关系的类对象,去调用同一函数,产生的不同的行为。如下面的例子:student继承了person,student买票半价,person买票全价。
在继承中要构成多态需要三个条件:
virtual
关键字声明,并且派生类必须对基类的虚函数进行重写(注意,这里的重写和继承中函数的隐藏(重定义)是两个概念)
重写形成的条件相对重定义更加苛刻,需要派生类虚函数和基类虚函数的返回值类型,函数名字,参数列表完全相同。
**注意:**关于在符合重写条件的情况下,可以只在基类将函数用virtual关键字修饰,而派生类该函数不用加virtual,但不能只在派生类该函数加上virtual(一般情况下建议两边都加上virtual)
派生类重写虚函数时,有一种情况允许其于基类虚函数返回值不同,那就是协变。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用。
注意,只用同时为指针或者同时为引用能完成协变,其他类型都不行,一个指针一个引用也不行,基类和派生类返回顺序相反也不能构成协变。(即基类返回派生类的指针或引用,派生类返回基类的指针或引用也是不行的)
class person
{
public:
virtual void buyTicket() { cout << "买票——全价" << endl; }
virtual person& f() { return *this; }
};
class student : public person
{
public:
virtual void buyTicket() { cout << "买票——半价" << endl; }
virtual student& f() { return *this; }
};
如果基类的析构函数为虚函数,此时其和派生类的析构函数一定构成重写,虽然派生类和基类的函数名一定不相同,看起来违背了重写的规则,但实则不然,在底层,编译器都会将析构函数的名称做统一的特殊处理,编译后析构函数的名称将会统一处理成destructor()
。
那么为什么要支持析构函数多态呢?我们看下面的场景:
void test()
{
person* p1 = new person;
person* p2 = new student;
delete p1;
delete p2;
}
正是由于这个场景,一定要支持虚函数多态,由于基类指针可以指向派生类指针,如果不支持析构函数多态,上面的这段代码将不能正常调用派生类析构函数清理多余资源,将会导致内存泄漏问题,因此只有通过多态才能正常释放资源。
class person
{
public:
virtual void buyTicket() { cout << "买票——全价" << endl; }
//virtual person& f() { return *this; }
virtual ~person() { cout << "析人\n"; }
};
class student : public person
{
public:
virtual void buyTicket() { cout << "买票——半价" << endl; }
//virtual student& f() { return *this;}
virtual ~student() { cout << "析学\n"; }
};
void test()
{
person* p = new person;
person* s = new student;
//将会调用基类析构
delete p;
//调用派生类析构释放派生类资源
//然后调用基类析构释放基类资源
delete s;
}
int main()
{
test();
return 0;
}
override
和 final
关键字从上面我们知道,虚函数对重写的要求很严格,需要三同(函数名相同,参数列表相同,返回值相同)以及基类指针或引用调用,但是在有些情况下容易疏忽,容易出现错误,因此c++11提供了这两个关键字帮助用户检查是否重写。
**final:**修饰虚函数,表示该虚函数不能再被重写(该关键字放在函数名括号之后)
**override:**检查派生类虚函数是否重写了某个虚函数,如果没有重写编译报错。
在虚函数后面加上=0,则这个函数就叫做纯虚函数。包含纯虚函数的类叫做抽象类(接口类),抽象类不能实例化出对象。派生类继承之后也不能实例化出对象,只有重写了纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现了接口继承。
override的作用是检查重写,而纯虚函数的作用是强制重写。
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实 现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成 多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
在探究 多态原理之前,我们先来看一道常考的面试题:
//请问sizeof(base)是多少
//(32位平台下)
class base
{
public:
virtual void func()
{
cout << "func()" << endl;
}
private:
int _b = 1;
}
通过测试我们可以发现base对象是8bytes(32位平台下),除了b成员,还有一个 _vfptr放在对象的最前面(与平台有关), 对象中的这一指针叫做虚函数表指针(v——virtual,f——function),一个含有虚函数的类中至少都有一个虚函数指针,因为虚函数的地址要放到虚函数表中,虚函数表也简称为虚表。
注意,这里的虚表要和虚继承中解决菱形继承问题的虚基表区分开,两者是截然不同的概念,如果有不清楚虚基表和虚继承是什么的,可以看看博主的另一篇博客,链接如下:
c++_深究继承
里面关于菱形继承的部分就有为大家讲解虚继承是什么。
那么,了解了这个之后,我们继续看看派生类在这个表中做了什么,又是如何实现多态的。
针对上面的代码,我们进行如下的改造:
class base
{
public:
virtual void func1()
{
cout << "func1()" << endl;
}
virtual void func2()
{
cout << "func2()" << endl;
}
void func3()
{
cout << "func3()" << endl;
}
private:
int _b = 1;
};
class derive : public base
{
public:
virtual void func1()
{
cout << "next::func1()" << endl;
}
private:
int _c = 2;
};
int main()
{
base b;
derive n;
return 0;
}
通过观察和测试,我们发现了几点问题:
virtual
的函数,因此在该例子中func3并没有在虚表内。nullptr
。其实放在常量区中也是一个比较合理的选择,因为虚表是不能被随意修改的。
接下来,有了虚表这个概念后,我们就可以更容易的理解多态了。
回顾一下多态需要的条件:
在学习了虚表之后,多态这个过程也就不那么神秘了,其实就是在用基类指针调用重写函数时,编译器会直接进入虚表内拿到所要调用的函数地址,也就是说在满足多态以后的函数调用,不是在编译的时候确定的,是运行起来以后到对象的虚表中去查找的。而不满足多态的函数调用在编译的时候早已确认好。
那么,再来思考一个问题,为什么一定要是**基类指针或引用调用?**直接用基类对象调用不行吗?
这里我们需要理解的一个至关重要的点就是引用或指针不会修改原来对象的虚表!正是由于这个原因,才必须用引用或者指针,如果函数参数是基类对象,那么将派生类对象传入时,就会修改对象虚表,从而不能达到多态的效果!
上面的内容又引出了一个概念,就是动态绑定和静态绑定。
看如下多继承:
class base1
{
public:
virtual void func1()
{
cout << "func1()" << endl;
}
virtual void func2()
{
cout << "func2()" << endl;
}
void func3()
{
cout << "func3()" << endl;
}
private:
int _b = 1;
};
class base2
{
public:
virtual void func1()
{
cout << "base2::func1()\n";
}
virtual void func2()
{
cout << "base2::func2()\n";
}
};
class derive : public base1, public base2
{
public:
virtual void func1()
{
cout << "next::func1()" << endl;
}
virtual void func4()
{
cout << "func4()" << endl;
}
private:
int _c = 2;
};
对于多继承来说,派生类将有多个虚表(有几个带虚函数的基类就有几个虚表),如果两个基类有构成重写的函数,并且派生类也有构成重写的该函数,那么派生类的该函数指针将会同时覆盖两个基类函数的虚表内的该函数指针,另外,如果派生类中有自己新增的虚函数,将会放进第一个继承的基类的需表中,同样可以通过监视窗口操作看到。下图可以更好的说明:
在继承的学习中,我们知道为了解决菱形继承的数据冗余和二义性问题,引入了虚继承,而虚继承是用虚基表实现的,而多态是由虚表实现的,那将这两者结合起来之后,就越能感觉到c++的恐怖了,在实际中我们并不建议设计出菱形虚拟继承,一方面太复杂容易出问题,另一方面这样庞大的模型,访问基类成员有一定的性能损耗。
因此,菱形虚拟继承的虚表我们也不需要进行深究,这里带大家简单的了解一下即可。
可以看到虚继承+虚函数是非常复杂的,另外通过观察得知最终类的虚函数同样被放在了第一个继承的类中,而不是放在person类,当然这也跟编译器有关,本编译器是vs2022的结果。
另外,还有一个疑点就是虚基表中的第一行存放的是0xfffffc,翻译成十进制是-4,博主对于-4的作用还未能得知,如果有知道的佬欢迎在评论区解答。
由于菱形虚继承过于复杂,所以在实际应用中一定要尽量避免使用菱形虚继承,否则会造成很大的麻烦。
inline
函数可以是虚函数,但是在编译器会忽略inline这一属性。很合理,因为内联函数没有地址,没办法放进需表中