【C++】多态
目录
一、多态的概念
面向对象的三大特性:封装、继承和多态
从面向对象的角度来看,多态是指同一个方法名可以在不同的对象上具有不同的行为。
例如买票,成年票全价,儿童和学生半价。
多态提供了代码的灵活性,使得可以编写更通用、更灵活的代码,增强了代码的可扩展性和可维护性。
二、多态的定义
在C++中,多态是指在父类中定义一个虚函数,在子类中重新定义该函数。这使得可以通过父类指针或引用来调用子类的成员函数,从而实现动态绑定,即在运行时确定需要调用的函数。
?由此可以得出构成多态的两个条件:
- 必须通过基类的指针或者引用调用虚函数。
- 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
下面是相关的一些知识点:?
2.1 虚函数
虚函数(virtual function):被virtual修饰的类成员函数称为虚函数。
// MyClass.h
class MyClass {
public:
virtual void foo(); // 在类中声明为虚函数
};
// MyClass.cpp
void MyClass::foo() { // 在类外定义虚函数,无需再添加 virtual 关键字
// 函数实现
}
注:
- 只有普通成员函数和析构函数可以被声明为虚函数。
- 静态成员函数不能被声明为虚函数。因为静态成员函数(属于类)与特定的对象实例无关,而虚函数是针对对象实例的多态性特性而设计的。
- 友元函数不能作为虚函数,因为它不属于类成员。
- 在基类中声明为虚函数的成员函数,在派生类中可以进行重写(override)。?
- 当使用基类的指针或引用调用虚函数时,实际调用的是派生类中的重写版本,而不是基类中的版本。这种行为被称为动态绑定,它使得程序可以根据对象的实际类型来调用相应的函数,从而实现多态性。
- 虚函数属于类,取地址要指定类域,且不用加()
&Person::BuyTicket
- 虚函数在类中声明加virtual,类外定义不能加virtual关键字,否则语法错误。
- inline修饰虚函数,普通调用时内联起作用--展开,多态调用时内联不起作用。
- 根据 C++ 标准,虚成员函数应在类内定义声明,且必须有定义(实现)。但是,C++ 标准没有要求必须在编译期对这条规则进行诊断。也就是说,如果没有给出虚成员函数的实现,编译器可能不会报错。不过,链接器可能会提示引用了未定义的符号这样的错误。
2.2 虚函数的重写(override)
虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的
返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。
class Person
{
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person
{
public:
virtual void BuyTicket() { cout << "学生买票-半价" << endl; }
};
void Func(Person& p)
{
p.BuyTicket();
}
void Test1()
{
Person ps;
Student st;
Func(ps);
Func(st);
}
虚函数重写的两个例外:
- 协变:基类与派生类虚函数返回值类型不同
即 基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。class Base { public: virtual Base* Func1() { // 返回基类指针 return this; } }; class Derived : public Base { public: Derived* Func2() override { // 返回派生类指针 return this; } };
- 析构函数的重写:如果基类的析构函数为虚函数,此时派生类析构函数只要定义,都与基类的析构函数构成重写。
虽然基类与派生类析构函数名字不同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。
注:
- 派生类重写的虚函数不加virtual不会报错,但推荐重写的函数也加上virtual,用法更规范。(基类的虚函数被继承下来了在派生类依旧保持虚函数属性)
- 虚函数重写条件三同里面的参数列表,类型相同即可,参数的缺省值可以不相同。
- 在派生类中的虚函数声明后添加?override?关键字,可以确保该函数覆盖了基类中的同名函数。如果派生类的函数没有正确地覆盖基类的函数,编译器将会给出错误提示。
- 对应的在基类的虚函数声明后添加final关键字,表示该虚函数不能再被重写。
不是虚函数的函数,不能被final修饰。
用final修饰类,表示该类不能被继承。 - 为了确保正确释放资源,必须将基类的析构函数声明为虚函数,并在派生类中进行重写。
当通过基类指针删除派生类对象时,只会调用基类的析构函数,而不调用派生类的析构函数,这可能导致资源泄漏。
2.3 重载、重写(覆盖)、重定义(隐藏)的区别:
三、抽象类
一个类中只要包含了纯虚函数,就被称为抽象类(也叫接口类)。
抽象类特点:
- 包含了纯虚函数。
- 抽象类不能被实例化,只能作为基类来派生其他类。
- 派生类继承抽象类后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。
3.1 纯虚函数
纯虚函数规范了派生类必须重写
格式:virtual 函数类型 函数名(参数列表) = 0;
虚函数必须有定义,纯虚函数可以有定义,也可以没有定义。
但是派生类继承基类时,需要实现(重写)基类的纯虚函数,否则派生类也会成为抽象类。
class Base {
public:
virtual void func1() = 0;
};
class Derived : public Base {
public:
void virtual func1(){
// 重写基类的虚函数
}
};
3.2?实现继承和接口继承
在面向对象编程中,接口继承和实现继承是两种不同的继承方式,它们的区别在于继承的成员的特性不同,分别对应了不同的编程需求。
实现继承:用于实现功能的复用。
特点:
- 实现继承是指派生类继承了基类的接口和实现,包括数据成员和函数实现(非纯虚函数)。
- 派生类可以复用基类的代码,从而减少了代码的重复编写,同时也保证了派生类和基类的一致性。
- 这也意味着派生类和基类的实现是紧密耦合的,基类的修改可能会影响到派生类的行为。
接口继承:为了重写虚函数,达成多态。
?特点:
- 接口继承是指派生类只继承了基类的纯虚函数的接口,而没有继承基类的实现。
- 派生类必须实现从继承基类的纯虚函数,以满足接口规范(接口和实现是分离的)。
- 接口继承可以多重继承多个接口,使得一个类具有不同的接口特性。
- 常用于实现抽象类和接口,强制要求派生类实现接口中的所有函数。
总结:
- 选择接口继承和实现继承时,需要根据具体的编程需求来选择合适的继承方式。
如果需要实现接口或抽象类,或者需要避免实现的紧密耦合,那么应该选择接口继承;
如果需要复用代码,并且基类的实现不会被修改,那么可以考虑使用实现继承。- 普通函数的继承是一种实现继承。派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
有兴趣的可以看一下:读书笔记_Effective_C++_条款三十四:区分接口继承和实现继承
四、多态的原理
C++ 中的多态性是通过虚函数和动态绑定来实现的。
4.1 动态绑定
动态绑定是通过虚函数表(Virtual Function Table,简称 vtable)来实现的。
4.1.1 虚函数表
虚函数表是一个存储了虚函数指针的数据结构,它与每个包含虚函数的类相关联。每个对象都有一个指向其所属类的虚函数表的指针(vfptr)。当通过基类指针或引用调用虚函数时,程序会通过对象的虚函数表找到对应的函数并调用。
class Base
{
public:
virtual void Func1(){ cout << "Base::Func1()" << endl; }
virtual void Func2(){ cout << "Base::Func2()" << endl; }
void Func3(){ cout << "Base::Func3()" << endl; }
private:
int _b = 1;
};
class Derive : public Base
{
public:
virtual void Func1(){ cout << "Derive::Func1()" << endl; }
private:
int _d = 2;
};
int main()
{
Base b;
Derive d;
return 0;
}
?从上面的监视窗口可以看到:
- 虚函数表指针vfptr存放在对象中,存放的虚表的的地址,指针指向的是虚表,虚表中存放的是虚函数的地址。
- Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖。重写是语法的叫法,覆盖是原理层的叫法。
- Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但不是虚函
数,所以不会放进虚表。
总结:
- 派生类的虚表生成:
a.先将基类中的虚表内容拷贝一份到派生类虚表中。
b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数。
c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。 - 虚函数和普通函数一样,都是存在代码段,同时把虚函数的地址存了一份到虚函数表。
虚函数表存放在代码段,与常量区更接近。 - 所有的虚函数都放在虚函数表,同一个类共用一个虚表,子类不用父类的虚表。
- 派生类有几个基类就有几个虚函数表,如果派生类也有虚函数,那么派生类的虚函数地址放在第一个虚表中。
4.1.2?多继承关系中的虚函数表
派生类有几个基类就有几个虚函数表,即有几个虚函数指针。
如果派生类也有虚函数,那么派生类的虚函数地址放在第一个虚表中。
一个派生类继承两个基类:
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;
};
打印虚函数表:
typedef void (*VFUNC)();
void PrintVFT(VFUNC* a)
{
for (size_t i = 0; a[i] != 0; i++)
{
printf("[%d]:%p->", i, a[i]);
VFUNC f = a[i];
f();//等同于(*f)();
}
printf("\n");
}
void Test3()
{
Derive d1;
PrintVFT((VFUNC*)(*(int*)&d1));
//下面两行等同于:PrintVFT((VFUNC*)(*(int*)((char*)&d1+sizeof(Base1))));
Base2* ptr = &d1;
PrintVFT((VFUNC*)(*(int*)ptr));
}
4.1.3 动态绑定的实现
动态绑定:在程序运行时确定调用的具体函数或方法的机制。
在动态绑定中,函数或方法的绑定是根据对象的实际类型来确定的,而不是根据变量或指针的声明类型。这允许通过基类的指针或引用调用派生类的成员函数,实现多态性。
- 动态绑定的实现依赖于虚函数。
- 通过在基类中声明虚函数,并在派生类中进行重写,可以实现动态绑定。当通过基类指针或引用调用虚函数时,程序会根据对象的实际类型动态绑定调用对应的函数。
以下是一个简单示例说明虚函数表和动态绑定的关系:
void Test4()
{
Derive d;
Base1* p1 = &d;
p1->func1();
p1->func2();
Base2* p2 = &d;
p2->func1();
p2->func2();
}
Base1类和 Derive 类都包含虚函数 func1。当通过 Base1类的指针 p1 调用 func1 函数时,由于 p1 指向的是 Derive 对象,程序会根据对象的虚函数表找到 Derive 类的 func1 函数,并进行调用。
4.2 多态的实现
在上面讲到:在C++中,多态是指在父类中定义一个虚函数,在子类中重新定义该函数。这使得可以通过父类指针或引用来调用子类的成员函数,从而实现动态绑定,即在运行时确定需要调用的函数。
现在虚函数、重写、动态绑定都已经学习了,下面是多态的实现:
还是买票,成年人买成人票、学生买学生票,都是买票操作。
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);
Student Johnson;
Func(Johnson);
return 0;
}
?
- 观察图中的红色箭头我们看到,p是指向mike对象时,p->BuyTicket在mike的虚表中找到虚函数是Person::BuyTicket。
- 观察图中的蓝色箭头我们看到,p是指向johnson对象时,p->BuyTicket在johson的虚表中找到虚函数是Student::BuyTicket。
- 这样就实现出了不同对象去完成同一行为时,展现出不同的形态。
- 反过来思考我们要达到多态,有两个条件,一个是虚函数覆盖,一个是对象的指针或引用调用虚函数。
- 再通过汇编代码分析,看出满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到对象的中取找的。不满足多态的函数调用时编译时确认好的。
达到多态的两个条件各自的原因:
- 虚函数覆盖是为了:程序在运行时可以根据对象的实际类型选择正确的虚函数进行调用。
- 对象的指针或引用调用虚函数:为了实现动态绑定,我们需要通过父类的指针或引用来调用虚函数。
4.3 实现多态的函数?
实现多态的函数参数通常使用基类指针或引用作为函数参数类型。这样可以接受派生类对象,并在运行时根据对象的实际类型调用相应的函数。
- 上面的Func函数的参数可以是 Person& p,或者 Person*?p ,它们都指向子类对象中切割出来父类那一部分(包括虚函数表)
- 但是参数不能是 Person p ,因为这是切割出子类对象中父类那一部分成员,拷贝给形参,但不会拷贝虚函数表指针。
注:在使用多态的函数时,需要确保基类中的函数被声明为虚函数
4.4?动态绑定与静态绑定
多态分为编译时多态和运行时多态。
编译时多态是指在程序编译期间确定了程序的行为。也称为静态绑定。
运行时多态是在程序运行期间,根据具体拿到的类型确定程序的具体
行为,调用具体的函数。也称为动态绑定。
五、相关的注意事项
- 父类的指针也可能构成多态,因为父类有可能是另一个父类的子类。(父类是相对的)
- 继承的是接口声明,重写的是接口实现
结果为B->1 ,原因:B中继承A类的func声明,声明里的缺省值是1,重写实现的是{}里面的内容,所以val 的缺省值是1不是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; }
如果调用p->func();则不构成多态,结果是B->0 - 构造函数不能是虚函数。虚表是在编译时创建的,虚函数多态调用要到虚表中找,如果构造函数是虚函数,此时虚表指针都还没有初始化,无法使用虚函数机制。
- 虚函数表指针在构造时才初始化给对象的
- 注意不要将虚函数表和菱形继承的虚基表搞混了。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。 如若内容造成侵权/违法违规/事实不符,请联系我的编程经验分享网邮箱:veading@qq.com进行投诉反馈,一经查实,立即删除!