C++多态---面向对象的心动信号:多态之美
公主请阅
- 1.多态基本概念
- 多态的概念
- 2.虚函数
- 2.1 多态的构成条件
- 2.1.3 虚函数的重写/覆盖
- 题目
- 2.1.5 虚函数重写的一些其他问题
- 基类的析构函数要不要定义成虚函数,如果不定义成会出现什么问题呢?定义成在什么场景起作用呢?
- 基类析构函数定义为虚函数的必要
- 1. 多态情况下确保派生类对象被正确析构
- 如果基类析构函数不是虚函数可能出现的问题
- 1. 资源泄漏
- 2. 未定义行为
- 什么时候需要将基类的析构函数定义为虚函数
- 什么时候不需要将析构函数定义为虚函数
- 总结
- 3.override和final关键字
- 重载/重写/隐藏的对比
- 5.纯虚函数和抽象类
- 6.多态的原理
- 虚函数指针的介绍
- 7.动态绑定与静态绑定
- 8.虚函数表
1.多态基本概念
多态的概念
多态(polymorphism)的概念:通俗来说,就是多种形态。多态分为编译时多态(静态多态)和运行时多态(动态多态),这里我们重点讲运行时多态,编译时多态(静态多态)和运行时多态(动态多态)。编译时多态(静态多态)主要就是我们前面讲的函数重载和函数模板,他们传不同类型的参数就可以调用不同的函数,通过参数不同达到多种形态,之所以叫编译时多态,是因为他们实参传给形参的参数匹配是在编译时完成的,我们把编译时一般归为静态,运行时归为动态。
运行时多态,具体点就是去完成某个行为(函数),可以传不同的对象就会完成不同的行为,就达到多种形态。比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是优惠买票(5折或75折);军人买票时是优先买票。再比如,同样是动物叫的一个行为(函数),传猫对象过去,就是”(>w<)喵“,传狗对象过去,就是"汪汪"。
2.虚函数
2.1 多态的构成条件
多态是一个继承关系的下的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。Person对象买票全价,Student对象优惠买票。
实现多态还有两个必须重要条件,
必须是基类的指针或者引用调用虚函数
被调用的函数必须是虚函数,并且完成了虚函数重写/覆盖。
说明:要实现多态效果,第一必须是基类的指针或引用,因为只有基类的指针或引用才能既指向基类对象又指向派生类对象;第二派生类必须对基类的虚函数完成重写/覆盖,重写或者覆盖了,基类和派生类之间才能有不同的函数,多态的不同形态效果才能达到。
第一个必须是父类的指针,不然是不能进行子类的指向的,如果是子类的指针的话是不能指向父类的
class Person {
public:
//让一个函数有多重形态的话,就使用虚函数
//那么如果使用虚函数呢?
//我们在函数的前面加上 virtual 关键字,那么这个函数就称为虚函数。
virtual void BuyTicket() { cout << "买票 -全价 " << endl; }
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票 -打折 " << endl; }
};
//void Func(Person* ptr)
void Func(Person& ptr)
{
// 这里可以看到虽然都是Person指针Ptr在调用BuyTicket(),
// 但是跟Ptr没关系,而是由Ptr指向的对象决定的
// 指向父类调用父类,指向子类调用子类
//ptr->BuyTicket();
ptr.BuyTicket();
}
int main()
{
Person ps;
Student st;
/*Func(&ps);
Func(&st);*/
Func(ps);
Func(st);
return 0;
}
2.1.3 虚函数的重写/覆盖
虚函数的重写/覆盖:派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称派生类的虚函数重写了基类的虚函数。
注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用,不过在考试选择题中,经常会故意买这个坑,让你判断是否构成多态。
下面就是重写的样例
基类里面的virtual一定要加,派生类里面可以不加,但是可能不规范
class Animal
{
public:
virtual void talk() const
{
}
};
class Dog : public Animal
{
public:
virtual void talk() const
{
std::cout << "汪汪" << std::endl;
}
};
class Cat : public Animal
{
public:
virtual void talk() const
{
std::cout << "(>^ω^<)喵" << std::endl;
}
};
void letsHear(const Animal& animal)
{
animal.talk();
}
int main()
{
Cat cat;
Dog dog;
letsHear(cat);
letsHear(dog);
return 0;
}
题目
多态的第一个条件是父类的指针和指针进行调用
第二个条件是虚函数的重写
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
为什么呢?
创建一个 B
类对象,使用指针 p
调用 test()
在类 A
中定义的成员函数中(包括 test()
),this
指针的静态类型是 A*
,无论运行时实际的对象类型是什么。
所以就满足了多态的成立条件,利用基类指针进行调用
问题出在重写这一步上面
派生类对基类虚函数的重写是相当于将基类的函数放到自己类里面了,然后加上自己函数里面之前的代码
满足多态,调用的是重写的函数
那么这里的默认值就是1了,那么打印出来的就是1了
所以以后重写的话不要让缺省参数不一样,不然是不会出现很多问题的
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();*/
B bb;
bb.func();//这里调用的不是父类的指针,所以不构成多态
return 0;
}
2.1.5 虚函数重写的一些其他问题
- 协变(了解)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。协变的实际意义并不大,所以我们了解一下即可。
class A {};
class B : public A {};
class Person {
public:
virtual A* BuyTicket()
{
cout << "买票-全价" << endl;
return nullptr;
}
};
class Student : public Person {
public:
virtual B* BuyTicket()
{
cout << "买票-打折" << endl;
return nullptr;
}
};
void Func(Person* ptr)
{
ptr->BuyTicket();
}
int main()
{
Person ps;
Student st;
Func(&ps);
Func(&st);
return 0;
}
只要返回值的指针构成父子类的话,那么就构成多态的,不管是当前类的指针还是其他类的指针,只要构成了父子类就行了,我们这里用了父子类A和B,我们用Person和Student也是可以的,只要是父子类就行了
- 析构函数的重写
基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor
,所以基类的析构函数加了virtual修饰,派生类的析构函数就构成重写。
下面的代码我们可以看到,如果~A(),不加virtual,那么delete p2时只调用的A的析构函数,没有调用B的析构函数,就会导致内存泄漏问题,因为B()中在释放资源。注意:这个问题面试中经常考察,大家一定要结合类似下面的样例才能讲清楚,为什么基类中的析构函数建议设计为虚函数。
派生类的析构函数,我们在调用完派生类的析构函数之后,我们编译器会自动进行父类析构函数的调用的
构成多态,第一个是基类指针的调用,第二个是虚函数的重写
构成多态,指向谁就调用谁的析构函数,正确释放资源
class A
{
public:
virtual ~A()
{
cout << "~A()" << endl;
}
};
class B : public A {
public:
~B()
{
cout << "~B()->delete:" << _p << endl;
delete _p;
}
protected:
int* _p = new int[10];
};
//只有派生类student的析构函数重写了Person的析构函数,
// 下面的delete对象调用析构函数,才能构成多态,
// 才能保证p1和p2指向的对象正确的调用析构函数
int main()
{
A* p1 = new A;
B* p2 = new B;
delete p1;
delete p2;
return 0;
}
基类的析构函数要不要定义成虚函数,如果不定义成会出现什么问题呢?定义成在什么场景起作用呢?
在C++中,基类的析构函数是否需要定义为虚函数,主要取决于你的设计是否需要通过基类指针或引用删除派生类对象。如果不定义为虚函数,在某些场景下可能会导致资源泄漏或行为未定义的问题。
基类析构函数定义为虚函数的必要
1. 多态情况下确保派生类对象被正确析构
当基类析构函数是虚函数时,通过基类指针删除派生类对象会调用派生类的析构函数,从而正确地释放派生类资源。例如:
class Base {
public:
virtual ~Base() { std::cout << "Base destructor\n"; }
};
class Derived : public Base {
public:
~Derived() { std::cout << "Derived destructor\n"; }
};
Base* obj = new Derived();
delete obj; // 会先调用 Derived 的析构函数,再调用 Base 的析构函数
- 如果基类的析构函数不是虚函数,
delete obj
只会调用基类的析构函数,而不会调用派生类的析构函数。这可能导致派生类特有资源(如动态分配的内存)未被释放。
如果基类析构函数不是虚函数可能出现的问题
1. 资源泄漏
当派生类中分配了动态资源(如new
或其他文件句柄等),但基类析构函数未定义为虚函数时,通过基类指针删除派生类对象不会调用派生类析构函数,导致资源无法释放。例如:
class Base {
public:
~Base() { std::cout << "Base destructor\n"; }
};
class Derived : public Base {
public:
~Derived() { std::cout << "Derived destructor\n"; }
};
Base* obj = new Derived();
delete obj; // 只调用 Base 的析构函数,Derived 的析构函数不会被调用
输出:
Base destructor
- 问题:派生类的资源没有正确释放,可能导致内存泄漏或其他资源问题。
2. 未定义行为
如果基类的析构函数不是虚函数,而基类指针指向一个派生类对象,通过基类指针删除对象时的行为未定义。这在标准中被明确规定为错误。
什么时候需要将基类的析构函数定义为虚函数
一般来说,如果一个类被设计为基类且可能会被继承,就应该将析构函数定义为虚函数。特别是当你需要通过基类指针或引用操作派生类对象时,这是必要的。例如:
-
多态类:如果基类有其他虚函数,通常也应该将析构函数定义为虚函数。
-
动态分配的对象:如果基类指针可能指向派生类对象且需要动态删除。
什么时候不需要将析构函数定义为虚函数
-
非多态类:如果类不是为了继承而设计,析构函数不需要是虚函数。
-
性能要求较高的场景:虚函数表带来一定的内存和运行时开销,如果确定类不会被用作多态基类,可以避免虚函数。
例如:
class NonPolymorphic {
public:
~NonPolymorphic() { std::cout << "Destructor\n"; }
};
总结
-
定义为虚函数:当基类用于多态,且可能通过基类指针或引用删除派生类对象时,必须定义为虚函数。
-
不定义为虚函数:如果基类不会被继承或不会通过基类指针删除派生类对象,可以不定义为虚函数。
良好的实践是:基类设计时尽量明确其用途,需要多态功能时,为析构函数添加virtual
关键字。
3.override和final关键字
从上面可以看出,C++对虚函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写错参数写错等导致无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此C++11提供了override,可以帮助用户检测是否重写。如果我们不想让派生类重写这个虚函数,那么可以用final去修饰。
对于override来说的话,我们是在子类虚函数的后面加上的,如果没有完成重写操作的话是会进行报错的
一个虚函数不想被重写的话,那么我们就可以加一个final
我们在父类的虚函数后面加上final,然后那么这个虚函数就不能被重写了
重载/重写/隐藏的对比
5.纯虚函数和抽象类
在虚函数的后面写上 =0,则这个函数为纯虚函数,纯虚函数不需要定义实现(实现没啥意义因为要被派生类重写,但是语法上可以实现),只要声明即可。包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。纯虚函数某种程度上强制了派生类重写虚函数,因为不重写实例化不出对象。
这个就是抽象类了
凡是包含纯虚函数的话,那么就都是抽象类就不能进行实例化了
下面的派生类就不能进行实例化了
那么我们怎么变成不是抽象类呢?
那么我们就进行重写的操作,那么我们就不是纯虚函数了,那么我们就不是抽象类了就能进行实例化操作了
所以纯虚函数强制了派生类的重写操作,因为不重写的话就不能实例化出对象了
6.多态的原理
虚函数指针的介绍
上面题目运行结果12bytes,除了_b和_ch成员,还多一个_vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为一个类所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表。
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
protected:
int _b = 1;
char _ch = 'x';
};
int main()
{
Base b;
cout << sizeof(b) << endl;
return 0;
}
//正常思路下我们是认为这个是8字节大小的
//因为我们这里1存在一个虚函数表指针,大小为4字节
//所以总的就是12字节了
//虚函数表,存储的是虚函数的地址
指向父类,运行时到指向父类对象的虚函数表中找到对应的虚函数进行调用
指向子类,运行时到指向子类对象切片出的父类对象的虚函数表中找到对应的虚函数进行调用
7.动态绑定与静态绑定
对不满足多态条件(指针或者引用+调用虚函数)的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。
满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址,也就做动态绑定。
绑定就是指的是到底调用的是哪个函数
8.虚函数表
- 基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共用同一张虚表,不同类型的对象各自有独立的虚表,所以基类和派生类有各自独立的虚表。
虚函数表:函数指针数组
-
派生类由两部分构成,继承下来的基类和自己的成员,一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意的这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同一个,就像基类对象的成员和派生类对象中的基类对象成员也独立的。
-
派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。
-
派生类的虚函数表中包含,(1)基类的虚函数地址,(2)派生类重写的虚函数地址完成覆盖,派生类自己的虚函数地址三个部分。
-
虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个0x00000000标记。(这个C++并没有进行规定,各个编译器自行定义的,vs系列编译器会再后面放个0x00000000标记,g++系列编译不会放)
-
虚函数存在哪的?虚函数和普通函数一样的,编译好后是一段指令,都是存在代码段的,只是虚函数的地址又存到了虚表中。
-
虚函数表存在哪的?这个问题严格说并没有标准答案C++标准并没有规定,我们写下面的代码可以对比验证一下。vs下是存在代码段(常量区)
虚函数表是同类型数据共享的
同类型的数据共享一张虚函数表
函数编译好是一段指令
那么虚函数编译好也是一段指令,都是存在代码段里面的