C++的多态及多态底层原理讲解

C++的多态及多态的底层原理

多态的定义

多态可以理解为多种形态,表现形式为:某个具体的行为,不同的对象去完成,会产生出不同的状态的现象。

比如一群人要去某个景区游玩,需要购买门票。其中普通成年人购买的是全价票,学生使用了学生证后购买的是半价票,小朋友购买了儿童票,军人使用军人证免费进入。买票这一行为,不同的对象去实现,但是产生了不同的结果,就是多态。

实现多态的两个条件:①子类必须重写父类的虚函数;②必须由父类的指针或者引用去调用虚函数。两个条件缺一不可,不满足这两个条件就构不成多态。特别注意,②中使用的只有指针和引用。使用了对象的话,无法构成多态。这里在后面的底层原理处详细讲解原因。

class Person{
public:
    /*void buyTicket(){
        cout << "全价票" << endl;
    }没有写为虚函数,无法构成多态*/
    virtual void buyTicket(){
        cout << "全价票" << endl;
    }
};

class Student : public Person{
public:
    virtual void buyTicket(){
        cout << "学生半价票" << endl;
    }
};

class Child : public Person{
    virtual void buyTicket(){
        cout << "儿童票" << endl;
    }
};

class Soldier : public Person{
public:
    virtual void buyTicket(){
        cout << "军人免费" << endl;
    }
};

void func(Person& p/ Person* p){
    p.buyTicket();//p->buyTicket();
    //必须由父类的引用或者指针去调用虚函数
}

什么是虚函数及虚函数的重写

虚函数是使用virtual关键字修饰的函数,这里与虚拟继承的地方使用了相同的关键字,但是两者之间并没有什么关系。

virtual void buyTicket(){
    cout << "全价票" << endl;
}

虚函数的重写/覆盖:子类中会有一个与父类完全相同的虚函数(子类的虚函数与父类虚函数的返回值类型、函数名称、形参列表完全相同),但是可以在子类的虚函数中修改一些操作,这样的话,就可以称为子类的虚函数重写/覆盖了父类的虚函数。

class Person{
public:
    virtual void buyTicket(){
        cout << "全价票" << endl;
    }
};

class Student : public Person{
public:
    virtual void buyTicket(){
        cout << "学生半价票" << endl;//这里就是子类重写了父类的虚函数
    }
};

当然子类在重写父类的虚函数时也可以不用写上virtual关键字,因为编译器知道子类会从父类中将虚函数继承下来,但是这样会很不规范,所以建议大家还是在重写子类虚函数的时候把关键字也带上。

虚函数重写的例外

虚函数重写会有两个例外,分别是协变和析构函数的重写

协变(父类和子类的虚函数返回值类型不同)

当子类重写了父类的虚函数后,与父类虚函数的返回值类型不一样,即父类虚函数返回父类对象的指针或者引用,子类对象虚函数返回子类对象的指针或者引用的时候,称为协变。这个特例总结来说就是,在虚函数重写过程中,可以出现返回值类型不同的情况,但是返回值类型必须是父类和子类的指针或者引用。

协变不常用,建议大家了解后知道有这种情况就行了

析构函数的重写

如果父类的析构函数是虚函数,那么只要子类定义了析构函数,根据我们上面说的,不管加不加virtual关键字,都会和父类的析构函数构成重写,虽然父类的析构和子类的析构函数名字不一样,但是并不影响。因为编译器在编译过程中会自动将析构函数的名称统一处理,都改写成destructor

在普通场景下面,析构函数是否设置为虚函数再完成重写,都是OK的,都会先调用子类的析构函数(因为子类后定义,后定义的先析构),再调用父类的析构函数去析构子类继承自父类的那部分成员。

class Person{
public:
    ~Person(){
        cout << "调用Person析构" << endl;//在析构函数中添加输出语句,方便我们观察
    }
};
class Student : public Person{
public:
    ~Student(){
        cout << "调用Student析构" <<endl;
    }
};
int main(){
    Person p;
    Student s;
    return 0;
}

注意这里没有将析构函数设置为虚函数,我们来看一下上面代码的运行结果:

image-20220327163131702

我们现在换一种情况,还是这两个类,我们在main函数中进行一下修改

int main(){
    //new 对象的特殊场景
    Person* p1 = new Person;
    Person* p2 = new Student;//切片行为
    
    delete p1;
    delete p2;
    return 0;
}

在处理p1时,delete没有什么问题,去调用了Person的析构函数,但是在处理p2时,因为p2还是一个Person类的指针,所以它只能访问到new出来的Student对象中的父类部分,所以只会去调用父类Person的析构函数,这样就与我们所需要的有些出入。

image-20220327171542480

所以我们需要将父类的析构函数设置为虚函数,这样子类才会对其进行重写,实现多态,因为多态将对象与行为进行绑定,而不再是看指针行事了。如果析构函数不是虚函数,那么父子类的析构函数是隐藏关系,如果是虚函数的话,才可以构成重写关系。

class Person{
public:
    virtual ~Person(){
        cout << "调用Person析构" << endl;
    }
};
class Student : public Person{
public:
    virtual ~Student(){
        cout << "调用Student析构" <<endl;
    }
};
int main(){
	Person* p1 = new Person;
    Person* p2 = new Student;
    
    delete p1;
    delete p2;
    return 0;
}

image-20220327174641464

C++11的final和override

final关键字

如果我们有些类确定是不能也不需要被继承的时候,为了防止有的程序员疏忽,又给它添加了继承关系而影响程序,C++11中加入了final关键字,当我们给一个类添加了final关键字后,这个类就不能再作为父类派生出子类了。

class FinalBase final{
    //被final修饰,不能再作为父类
};
class Error : public FinalBase{};//错误,不可以继承被final修饰过的类

class Father{
    //普通的类
};
class Child final : Father{
    //在继承时,可以在子类的后面加上final关键字,注意与virtual虚拟继承关键字的位置区分
};
class Error2 : public Child{};//错误,不可以继承被final修饰的类

当然,final也可以对虚函数进行修饰,如果被final关键字修饰,该虚函数就不能再被重写了。

class Base{
public:
    virtual void print() final{
        cout << "Base" <<endl;
    }
};
class Child : public Base{
public:
    virtual void print(){
        cout << "Child" << endl;//错误,不能重写被final修饰的虚函数
    }
};

image-20220327200347749

override关键字

override关键字可以用来检查子类的虚函数是否重写了父类的某个虚函数,如果没有重写,编译器就会报错了,添加override关键字可以提醒程序员对需要重写的虚函数完成重写操作。

class Base{
public:
    virtual void print(){
        cout << "Base" << endl;
    }
};

class Child : public Base{
public:
    virtual void printf() override{
        //没有重写会报错
        cout << "Child" << endl;
    }
};

抽象类

定义:

在虚函数的后面写上 = 0,这个函数就会变成纯虚函数。包含了纯虚函数的类就叫做抽象类(也可以叫做接口类),抽象类不能实例化出具体的对象。子类继承后也不可以实例化出对象,只有对纯虚函数完成重写操作后,子类才可以实例化对象。纯虚函数要求子类必须重写,所以纯虚函数这里也可以叫做接口函数,是专门提供接口让别人来使用的。

class Car{
    //Car太广泛了,就适合用来作为一个抽象类,因为不管什么车,都有一个共同的功能——行驶
public:
    virtual void Drive() = 0;//纯虚函数,必须被子类重写
};

class Taxi : public Car{
public:
    virtual void Drive(){
        cout << "出租车用来接送乘客" << endl;//必须重写才可以实例化出对象
    }
};

class Truck : public Car{
public:
    virtual void Drive(){
        cout << "货车用来运送货物" <<endl;//必须重写才可以实例化出对象
    }
};

int main(){
	Car* taxi = new Taxi;
    Car* truck = new Truck;
    taxi->Drive();//构成多态
    truck->Drive();//构成多态
    return 0;
}

多态的原理

虚函数表指针和虚函数表

当一个类有虚函数的时候,我们来看一下它的大小是多少

class Base{
public:
    virtual void func(){
		cout << "func()" << endl;
    }
private:
    int _i = 1;
};

int main(){
	Base b;
    cout << sizeof(b) << endl;
    return 0;
}

我们都知道一个类中,只有成员变量是占据该类对象的内存大小,函数是存放在代码段中,并不占据内存,所以很多人不加思索会认为,Base类的对象b的大小是4个字节,即一个int类型的大小,但是输出结果和vs的监视窗口告诉我们b对象中还另有玄机。在x86平台下显示为8字节。

image-20220328095202994

在对象b中还存放着一个名为"_vfptr"的成员变量,有些编译器平台下会将它放在最后面,根据名字我们也能猜到,它是一个指针,全称是虚函数表指针。它指向一个虚函数表,表中存放着类里面虚函数的地址。所以这个虚函数表的本质是一个函数指针数组。一个含有虚函数的类中都至少会有一个虚函数表指针。如果类中有多个虚函数,则会在这个数组中依次存入每一个虚函数的地址。虚函数表也可以简称为虚表,这里注意要与虚拟继承部分出现的虚基表进行区分。

那子类继承了父类之后,也会将父类中的虚函数继承下来,可以通过对虚函数进行重写从而完成多态,父子类的虚函数表之间是什么关系呢?我们给父类再添加一个虚函数以及一个非虚函数,并再写一个子类继承父类,继续观察一下父子类的虚函数表之间的关系。

class Base{
public:
    virtual void func1(){
        cout << "func1" << endl;
    }
    virtual void func2(){
        cout << "func2" << endl;
    }
    
    void func(){
        cout << "func" <<endl;//这个是普通函数,并不是虚函数
    }
protected:
    int _a = 1;
};

class Child : public Base{
public:
    virtual void func1(){
        cout << "Child :: func1" << endl;//实现了函数重写
    }
    virtual void func2(){
        cout << "Child :: func2" << endl;//实现了函数重写
    }
};

image-20220328103922365

根据监视窗口,我们可以确定,子类虽然将虚函数继承下来,但并没有和父类公用同一张虚表,根据虚函数表指针的不同就说明两张表不是同一张表,但是根据地址可以判断,两张表存放的物理内存位置非常相近。并且,非虚函数的函数指针不会出现在虚表中。那可能有人会产生疑问,是不是因为子类重写了父类的虚函数才导致两个虚表不同,如果子类只是继承了父类的虚函数,而不进行虚函数重写,是不是父类子类的虚函数表指针就会指向同一张虚表了?那我们将子类中对虚函数的重写操作屏蔽掉,再来观察一下父子类的虚函数表。

image-20220328110105886

这里给出结论:①父子类不管是否完成了虚函数的重写操作,都会有各自独立的一份虚表,假如没有实现重写操作,只是父子类的虚函数地址一样,但是虚函数表一定是有两份的,如果实现了重写操作,则会将子类虚函数表中父类的函数指针用子类重写过的函数指针加以覆盖,让它指向子类的虚函数,这也就是重写又称为覆盖的原因;②一个类的所用对象共享同一张虚表,即不管父类或者子类实例化出多少个对象出来,同一个类中的不同对象所包含的虚函数表指针里面的值都是一样的,即都会去指向同一个虚表。

class Base(){
public:
    virtual void func(){
        cout << "func" << endl;
    }
};

int main(){
    Base b1;
    Base b2;
    Base b3;
    //b1,b2,b3三个对象里面的_vfptr变量存放的值是一样的,即它们共享同一张虚函数表
}
多态的原理

仍然以之前在继承部分举过的买票的例子来进行演示

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 p;
    func(&p);
    Student s;
    func(&s);
}

在vs2022的监视窗口中对两个对象进行观察

image-20220328144032353

我们根据继承部分的知识以及刚才所提到的虚函数表指针等相关知识也可以画出p对象和s对象的模型

在两个对象头部都有一个虚表指针,指向各自的虚函数表,但是子类对象s因为实现了对虚函数buyTicket()的重写,所以子类虚函数表中虚函数的指针原本是指向父类虚函数的,但是现在被子类进行了覆盖。而函数void func(Person* p)的形参是一个父类指针,编译器无法识别我们传入的到底是父类对象还是子类对象的地址,但是这个父类指针可以帮我们固定每次访问传入对象的内存大小,如果传入的是一个Person对象,那么这个指针可以访问该对象所有的大小,而如果传入的是一个Student类对象,那么该指针也可以访问到Student类对象头部的父类部分,这两个部分中都包含着它们的虚表指针,可以通过这个虚表指针找到各自指向的虚函数表,拿到各自虚函数的地址,再分别调用各自的虚函数,就实现了多态

**至此我们根据上面讲解的知识再反过来理解:为什么实现多态一定要完成虚函数的重写;并且一定要使用父类的指针或者引用来调用虚函数呢?**首先必须要完成虚函数的重写,这样子类虚函数表中的虚函数地址才会用子类虚函数的地址去对父类虚函数的地址进行覆盖,才可以通过各自的虚表指针找到各自的虚表,根据虚表里存放的虚函数地址去调用各自的虚函数;而调用虚函数时使用父类的指针或引用,在传入子类对象时可以自动实现切片,拿到子类对象中父类的那部分,如果使用了父类的对象,我们知道同一个类的所有对象都指向该类唯一的一张虚表,那么形参的父类对象也是默认指向父类的虚表,传入一个子类对象时,会先切片,取到父类的部分,再用这部分对形参的父类对象进行拷贝构造。但是拷贝构造是不会把虚表指针的值赋值给形参的父类对象的!所以形参的父类对象虚表指针所指向的还是父类的虚表。

普通函数的调用,是在编译或者链接的过程中确定函数的地址,而虚函数则是在运行时,通过传入对象的虚函数表指针,才能去找到并确定虚函数的地址,因此我们也可以看到,如果通过多态的方式调用虚函数,是会有部分性能损失的。

虚函数存在哪里?虚函数表又存在哪里?虚函数编译出来跟普通函数指令是一样的,存放在代码段,而虚函数地址又存放在虚函数表中。虚函数表经过验证,在vs环境下是放在代码段的。

  • 6
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值