【C++ 】多态


多态

1. 多态的定义

多态就是不同对象执行同一行为产生不同结果。具有继承关系的对象才会产生多态,如父子对象通过父类指针引用,调用重写函数。

1.1 多态的条件

  1. 子类必须重写父类的虚函数
  2. 通过父类的指针或引用调用重写函数;

重写条件:一虚三同。多态条件:父类的指针引用,重写的虚函数。

struct person {
    virtual void buyTicket() { cout << "成人全价" << endl; }
};
struct student : public person {
    virtual void buyTicket() { cout << "学生半价" << endl; }
};

void func_ref(person& p) { p.buy_ticket();  }
void func_ptr(person* p) { p->buy_ticket(); }

void test()
{
    person p;
    student s;
    
    func_ref(p);  // 成人全价
    func_ref(s);  // 学生半价
    
    func_ptr(&p); // 成人全价
    func_ptr(&s); // 学生半价
}

一个例外–协变

返回值可以不同,必须是父子关系指针或者引用

struct person {
    virtual person* buyTicket() { cout << "成人全价" << endl; }
};
struct student : public person {
    virtual student* buyTicket() { cout << "学生半价" << endl; }
};

但是实际用途不大,了解一下

3.2 多态的效果

构成多态的话,传入子类对象才会调用到子类的虚函数。如果不构成多态,只会调用父类的虚函数。

看看这两个题目

第一题

struct A {
    virtual void func(int i = 1) { cout << "A->" << i << endl; }
    virtual void test() { func(); }
};

struct B : public A {
    void func(int i = 0) { cout << "B->" << i << endl; }
};

void test()
{
    B* p = new B;
    p->test();
}

在调用 p->test() 时,将子类对象指针传递给了 test 函数,而 test 函数的 this 形参类型是父类指针,实际上相当于父类指针指向子类对象。在这个过程中,尽管 test() 函数是在类 A 中定义的,但它是一个虚函数,因此它会根据对象的实际类型来确定调用的版本。在这里,对象的实际类型是类 B。在类 B 中,虽然我们重写了 func() 函数,但我们没有重新定义 test() 函数。因此,当调用 test() 函数时,实际上会调用类 A 中的版本,因为它没有在类 B 中被重写。然而,在 func() 函数中存在一个默认参数。在类 A 中,这个默认参数是 1,而在类 B 中,它是 0

尽管实际上调用的是 B 类中的 func() 函数,但它仍然会使用 A 类中的默认参数值。这是因为重写不会改变函数的声明,而默认参数的解析是在编译时进行的,编译器将使用基类 A 中的默认参数值。

在这里插入图片描述

第二题


struct A {
    virtual void func(int i = 1) { cout << "A->" << i << endl; }
};

struct B : public A {
    void func(int i = 0) { cout << "B->" << i << endl; }
    void test() { func(); }
};

void test()
{
    B* p = new B;
    p->test();
}

不构成多态,就不用关心重写,func 函数构成隐藏,默认调用子类 func。B->0

 

2. 虚函数的定义

virtual修饰的、非静态的成员函数称为虚函数。

struct Test {
	virtual void func() { cout << "func" << endl; }
};

虚函数可以像普通函数一样调用,但虚函数的作用主要是实现多态。

虚继承也用到virtual关键字,但虚函数和虚继承没有任何关系。

3. 重写的定义

子类重新定义父类中的虚函数,就叫做子类重写/覆盖了父类的虚函数。

具有继承关系的两个类中,返回类型、函数名、参数列表完全相同的虚函数构成重写,不构成隐藏。

3.1 重写的条件

重写允许虚函数返回类型不同,但必须是父子类对象的指针或引用。父类的返回父类引用指针,子类的返回子类引用指针,这种情况被称为协变。

不一定是自身父子类指针引用,也可以是其他父子类指针引用。

struct person {
	virtual person* buyTicket(){ cout << "成人全价" << endl; }
};
struct student : public person {
	virtual student* buyTicket() { cout << "学生半价" << endl; }
};
析构函数的重写
struct person {
    ~person() { cout << "~person()" << endl; }
};
struct student : public person {
    ~student() { cout << "~student()" << endl; }
};

void test() {
    person* p = new student;
    delete p;  // p->_destructor(); + operator delete(p);
}

析构函数名会被编译器处理为 destructor,所以父子类的析构函数名是相同的。

不构成重写的话,父类指针指向子类对象会发生切片,只能访问到父类的内容,所以 delete 只能调用父类析构函数。

struct person {
    virtual ~person() { cout << "~person()" << endl; }
};
struct student : public person {
    virtual ~student() { cout << "~student()" << endl; }

};

void test() {
    person* p = new student;
    delete p; // p->_destructor(); + operator delete(p);
}

virtual 修饰父子类析构函数可以构成重写。这样父类指针就能调用子类析构,不会有资源泄漏的风险。

子类允许省略virtual

重写可以只给父类函数加 virtual,允许省略子类的。

struct person {
	virtual void buyTicket(){ cout << "成人全价" << endl; }
};
struct student : public person {
	virtual void buyTicket() { cout << "学生半价" << endl; }
/*注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因
为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议
这样使用*/
/*void buyTicket() { cout << "学生半价" << endl; }*/
};

接口继承和实现继承

概念解释
实现继承普通成员函数继承就是实现继承,继承的是父类成员函数的实现。
接口继承虚函数的继承是一种接口继承,继承接口声明并重写其实现,以构成多态。

重写是一种接口继承,子类把父类的虚函数继承下来重新实现,只改变函数内容,不会改变函数声明和属性。

子类会继承下重写的虚函数的属性,再完成重写所以不加声明也是虚函数。

3.2 重载重写重定义的对比

概念要求1要求2要求3
重载同一作用域函数名相同,参数列表不同
重定义/隐藏父子类作用域函数名相同
重写/覆盖父子类作用域函数名、参数、返回值完全相同(协变例外)函数必须是虚函数

 

4. final override

C++11中新出的两个关键字final和override,与继承和多态有关。

4.1 final修饰类

final修饰的类无法被继承。

class A final // final修饰类
{};
class B : public A // Error
{}; 

final 表示最终的意思,也就是该类是继承关系中最后的类。

4.2 final修饰虚函数

final修饰的虚函数无法被重写。

class A {
protected:
	virtual void f() final // final修饰虚函数
	{}
};
class B : public A {
protected:
	virtual void f() // Error
	{}
};

4.3 override

override修饰虚函数,用来检查该虚函数是否和父类虚函数构成重写,相当于对重写的断言。

class A {
protected:
	virtual void f(int)
    {}
};
class B : public A {
protected:
	virtual void f() override // Error
    {}
};

 

5. 纯虚函数和抽象类

  • =0修饰的虚函数为纯虚函数,也称接口。纯虚函数无法调用,一般只声明不实现。
  • 包含纯虚函数的类叫做抽象类或接口类。抽象类作为框架无法实例化,子类继承后重写其中所有纯虚函数,子类才能实例化。
class car {
	virtual void drive() = 0;
};
class BWN : public car {
  	void drive() 
    {}
};

 

6. 多态的原理

class A { // 普通类
	int _a;
};
class B { // 包含虚函数的类
	virtual void func() {}
	int _a;
};

void test()
{
    cout << sizeof(A) << endl; // 4
    cout << sizeof(B) << endl; // 8 只有一个整型成员和一个虚函数,类的大小为8,难道虚函数占用类的空间?
}

6.1 虚表指针

含有虚函数的类,实例化后会有虚函数表指针_vfptr这一隐藏成员,存储在类的头四个字节空间。

虚表指针指向的空间称为虚函数表,用来存储虚函数的地址。

在这里插入图片描述

  • 同类的不同对象的虚表指针都指向同一块空间。
  • 虚表指针只能由编译器调用,故不考虑它作为成员变量的属性。

6.2 虚函数表

虚函数表本质是函数指针数组。构成多态时,编译器访问对象空间拿到虚表,再根据偏移量找到对应虚函数。这就是多态原理。

在这里插入图片描述

  • 父子类不共用虚表,子类的虚表是父类的一份拷贝。
  • 子类会将子类重写的虚函数地址覆盖到父类对应虚函数的虚表位置上。
  • 子类新增的虚函数依次增加在虚表的尾部。

重写体现在语法上,覆盖体现在原理上。

  • 虚表在编译阶段就生成好了。编译时就可以确定地址。
  • 对象的虚表会在构造函数的初始化列表中初始化。
  • 虚表一般存在于代码段,具体还要看编译器。

6.3 多态条件的解释

以下两个例子图对比一下:

图一:赋值

在这里插入图片描述

图二:引用或者指针

在这里插入图片描述

多态的构成条件可以解释为:

  1. 重写的虚函数:虚函数地址存储在虚表中,运行时才从虚表指针中确定地址。
  2. 父类指针引用:父类指针或引用指向子类空间,能拿到子类虚表,从而访问子类的虚函数。

对象直接调用不构成多态,因为父子类赋值切片时不允许拷贝虚表指针,就拿不到子类虚表。

在这里插入图片描述

 

7. 不同继承下的虚表

vs的监视会隐藏子类本身具有的虚函数。我们只能打印。

// 虚表指针存储在类的最头上四个字节
// 解引用虚表指针,该空间直接裸存虚函数指针

typedef void(*vf_t)();

void print_vftable(vf_t* vft)
{
    for (int i = 0; vft[i]; i++)
    {
        printf("vft[%d]=0x%p->", i, vft[i]);
        vft[i]();
    }
}

7.1 单继承的虚表

namespace signle_inherit {
class A {
public:
    virtual void vFunc1() { cout << "A::vFunc1()" << endl; }
    virtual void vFunc2() { cout << "A::vFunc2()" << endl; }
protected:
    int _a;
};
class B : public A {
public:
    virtual void vFunc1() { cout << "B::vFunc1()" << endl; } 
    virtual void vFunc3() { cout << "B::vFunc3()" << endl; }
    virtual void vFunc4() { cout << "B::vFunc4()" << endl; }
protected:
    int _b;
};
void test() {
    A a;
    B b;
    vf_t* vft_a = *(vf_t**)&a;
    vf_t* vft_b = *(vf_t**)&b;
    print_vftable(vft_a);
    print_vftable(vft_b);
}
}

7.2 多继承的虚表

namespace mutiple_inherit {
class A {
public:
    virtual void vFunc1() { cout << "A::vFunc1()" << endl; }
    virtual void vFunc2() { cout << "A::vFunc2()" << endl; }
    int _a;
};
class B {
public:
    virtual void vFunc1() { cout << "B::vFunc1()" << endl; }
    virtual void vFunc2() { cout << "B::vFunc2()" << endl; }
    int _b;
};
class C : public A, public B {
public:
    virtual void vFunc1() { cout << "C::vFunc1()" << endl; }
    virtual void vFunc3() { cout << "C::vFunc3()" << endl; }
    int _c;
};
void test() {
    C c;
    vf_t* vft_from_a = *(vf_t**)&c;
    // vf_t* vft_from_b = *(vf_t**)((char*)&c + sizeof(A));
    B* pc_from_b = &c;
    vf_t* vft_from_b = *(vf_t**)(pc_from_b);
    print_vftable(vft_from_a);
    print_vftable(vft_from_b);
}
}

在这里插入图片描述

  • 多继承中子类会有多个虚表,分别记录来自不同父类的虚函数。
  • 子类新增的虚函数会放在第一个虚表中。

C类重写了父类A和父类B的函数vFunc1,但两张虚表中vFunc1的地址却不同,这是为什么?

// 观察下面程序的汇编代码。
C c;
A* pa =&c;
B* pb =&c;
pa->vFunc1();
pb->vFunc1();

在这里插入图片描述

从第一张虚表和第二张虚表,调用 vfunc1 的汇编是不同的。从第二张虚表调用 vFunc1 需要去第一张虚表中跳转地址。

8. 两个经典问题

1 为什么 C++ 构造函数不能是虚函数?

在c++中,基类的构造函数不能定义为虚函数,原因有以下几点:

  1. 创建对象时,需要确定对象的类型,而虚函数是在运行时动态确定其类型的。在构造一个对象时,由于对象还未创建成功,编译器无法知道对象的实际类型;

  2. 虚函数的调用需要通过vptr虚函数表指针,而该指针是存放在对象的内存空间中的,若构造函数声明为虚函数,那么由于对象尚未创建,还没有内存空间,也就没有对应虚函数表来调用虚构造函数了;

  3. 虚函数的作用在于通过父类的指针或者引用,在调用它的时候能够通过动态链编调用子类重写的虚成员函数。而构造函数是在创建对象时是系统自动调用的,不可能通过父类或者引用去调用,因此就规定构造函数不能是虚函数。

比如:

class A{
	A()
}
class B: public A{
	B():A(){}
};
int main(){
	B b;
	B *pb = &b;
}

则构造B类的时候,构造函数执行顺序是:A() ->B()
根据虚函数的性质,如果A的构造函数为虚函数,且B类也给出了构造函数,则应该只执行B类的构造函数,不再执行A类的构造函数,这样A就无法构造了,产生了矛盾;因此,构造函数不能为虚函数。

2 为什么 C++ 基类析构函数需要是虚函数?

在C++中,将基类的析构函数定义为虚函数是为了确保正确释放派生类对象的资源。当我们通过基类指针或引用删除派生类对象时,如果基类的析构函数不是虚函数,那么只会调用基类的析构函数,而不会调用派生类的析构函数,这可能导致派生类中的资源得不到释放,造成内存泄漏。通过将基类的析构函数定义为虚函数,可以在运行时动态绑定到正确的析构函数,确保派生类的析构函数被正确调用,从而正确释放资源。

看这个示例代码

#include <iostream>

class Base {
public:
    Base() {
        std::cout << "Base constructor called." << std::endl;
    }
    
    virtual ~Base() {
        std::cout << "Base destructor called." << std::endl;
    }
};

class Derived : public Base {
public:
    Derived() {
        std::cout << "Derived constructor called." << std::endl;
    }
    
    ~Derived() {
        std::cout << "Derived destructor called." << std::endl;
    }
};

int main() {
    Base* ptr = new Derived();
    delete ptr; // 调用Derived的析构函数,然后调用Base的析构函数
    return 0;
}

在这个示例中,基类Base的析构函数被定义为虚函数。当我们通过Base指针删除Derived对象时,会首先调用Derived的析构函数,然后再调用Base的析构函数。这样就可以确保Derived对象中的资源得到正确释放。

为啥默认的析构函数不是虚函数
在C++中,默认的析构函数不是虚函数的原因是为了避免不必要的内存开销和复杂性。虚函数需要额外的内存来维护虚函数表和虚表指针,如果所有析构函数都是虚函数,会增加每个类的内存占用,即使这些类不需要多态性支持。因此,默认情况下将析构函数定义为非虚函数,符合C++的设计理念“零成本抽象”,即在不需要的情况下不引入额外的开销。只有当需要多态性支持时,程序员才会显式地将基类的析构函数定义为虚函数。

C++ 虚函数表解析 (coolshell.cn)

C++ 对象内存布局 (coolshell.cn)

  • 17
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

SuhyOvO

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值