当前位置: 首页 > article >正文

C++多态的认识与理解

多态的定义

多态其实就是同一操作在不同的对象上可以有不同的实现方式。

多态的类型

多态分为静态多态和动态多态两种,而静态多态其实我们之前就了解过,今天主要是讲解一下动态多态。

  • 静态多态(编译时多态):静态多态其实就是在函数进行编译阶段根据函数的参数列表来确定调用哪个具体的函数的,其中主要就是函数重载和运算符重载。        
  • 动态多态(运行时多态):运行时多态是在程序运行阶段才能确定具体调用哪个函数(通过对象的虚表指针)。它是基于函数重写实现的。当通过基类指针或引用调用虚函数时,实际调用的是派生类中重写后的虚函数。

构成多态的条件

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

虚函数重写

虚函数就是被virtual关键字修饰的成员函数

虚函数的重写就是派生类中有一个跟基类完全相同的虚函数,这就称子类的虚函数重写了基类的虚函数。而这里的完全相同是派生类虚函数与基类虚函数的返回值类型、函数名、参数列表类型完全相同 

class Person 
{
public:
	virtual void test() { ... }//虚函数
};
class Student : public Person 
{
public:
	virtual void test() { ... }//虚函数重写
};

注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,也是可以构成重写,因为继承时基类的虚函数被继承下来了,而在派生类依旧保持虚函数属性,所以可以不加virtual关键字修饰。但是有点不咋规范

 父类的指针或引用调用虚函数

首先我们要知道指针是一个变量,指向的是一个地址,而指针的类型恰恰就决定了通过该地址可以访问的空间大小。而引用是取别名(声明时初始化,之后不能绑定其他对象)。基类的指针或引用指向派生类的话,就会使得基类的对象指向的是派生类的前部分空间的内容。所以当基类指针或引用的对象去调用虚函数时就形成了多态,从而调用的实际就是派生类的虚函数。而内部实现机制后文有解释。

多态调用实例

class Person 
{
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; return nullptr; }//
};
class Student : public Person 
{
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; return nullptr; }//
};
void Func(Person& p)//父类的引用为形参
{
	p.BuyTicket();
}
int main()
{
	Person ps;
	Student st;
	Func(ps);//传递父类对象
	Func(st);//传递子类对象(形成多态)
	return 0;
}

通过指针或引用调用虚函数看的并不是通过参数的类型去调用对应的函数方法,而是该参数所实际指向的对象,指向父类对象就调用父类的虚函数,指向子类对象就调用子类的虚函数。

虚函数重写的两个例外

协变

协变就是虚函数重写的时候,基类和派生类的函数返回值类型可以不同但是必须是父子类关系的指针或引用。如下:

class A {};
class B :public A{};

class Person 
{
public:
	virtual A* test() {return nullptr; }//
};
class Student : public Person 
{
public:
	virtual B* test() {return nullptr; }//
};

就像上面的例子,父子类虚函数的返回值 必须是父子类关系的指针或引用,因为A类与B类也是父子关系,所以虚函数的返回值也应该将person类与student类的父子关系对应起来,即父类A对应父类Person,子类Student对应子类B。

析构函数的重写

其实基类与派生类的析构函数也是可以构成虚函数重写的,因为针对一些特定的情况,必须要使得基类与派生类实现重写才可以实现代码的正确安全性。

如以下情况:

看以下代码运行:

class Person {
public:
    Person()
    {
        cout << "Person()" << endl;
    }
    ~Person()
    {
        cout << "~Person()" << endl;
    }
};
class Student : public Person {
public:
    Student()
    {
        cout << "Student()" << endl;
    }
    ~Student()
    {
        cout << "~Student()" << endl;
    }
};
int main()
{
    Person* p1 = new Person;
    Person* p2 = new Student;
    delete p1;
    delete p2;

    return 0;
}


 首先我们知道new对象时,会为该对象分配空间,所以会自动调用该对象的构造函数。但是student类继承了person类,所以在创建student的对象时,会先调用父类的构造函数,然后再调用自己的构造函数。

同样当我们进行delete时也会先进行调用析构函数然后在进行空间的释放。但是delete是根据具体的delete对象的类型去调用析构函数的,并不是根据该对象原先new的方式进行delete的。所以出现以上现象:delete p2时只会调用的就是Person类的析构函数,并不会调用Student的析构。

因为我们new的是Student对象,所以释放肯定是想着要释放Student对象的。但是delete是根据具体的delete对象的类型去调用析构函数的,是父类的指针就调用父类的析构,是子类的指针就调用子类的析构。而并不会根据你原先new的空间进行析构。但是我们创建的是子类的对象:因为实现了多态,所以父类的指针不仅仅是源于父类也可能经过子类赋值给父类的。所以此时析构的调用实际上就与我们意想中的不同了,这种情况就极有可能造成了内存泄漏。

 

解决:

首先我们要知道在子类中是不能显示调用析构函数的,其原因就是父子类的析构函数的函数名其实是被修饰过成一样的:destructor。而父子类的函数名相同则会构成隐藏。

所以可以通过以下方式在子类中进行显示的调用父类的析构函数:

Person::~Person();

其实C++这一设计(父子类的析构函数名相同)其实就是为了解决以上的问题,所以既然函数名相同,且都没有参数。那么通过virtual进行修饰的话,那么父子类的析构函数不就构成了重载吗。所以回到题目中delete p2,而p2指向的是子类的对象,那么不就会调用子类Student的析构函数吗。又因为父子类的继承关系,调用子类的析构结束以后不就也会调用父类的析构吗。

如下:

class Person {
public:
    Person()
    {
        cout << "Person()" << endl;
    }
    virtual ~Person()//virtual构成重写
    {
        cout << "~Person()" << endl;
    }
};
class Student : public Person {
public:
    Student()
    {
        cout << "Student()" << endl;
    }
    virtual ~Student()
    {
        cout << "~Student()" << endl;
    }
};
int main()
{
    Person* p = new Student;
    delete p;

    return 0;
}


 多态例题解析

class A
{
public:
    virtual void func(int val = 1)
    {
        cout << "A->" << val << endl;
    }
    virtual void test() //隐藏的this指针类型A*
    {
        func();
    }
};
class B : public A
{
public:
    void func(int val = 0)
    {
        cout << "B->" << val << endl;
    }
};
int main()
{
    B* p = new B;
    p->test();//p的类型是B*
    return 0;
}


解析:首先A类的test函数会被B类继承下来,但是B类并没有对test进行重写,而func函数在父子类之间形成了重写。

我们知道p的类型是B*的,然后调用了test函数父类的test函数。而test函数内部又调用了func()函数,但是别忘了test()成员函数内部有一个隐藏的this指针,类型是A*,所以在test内部调用func函数的过程就形成了父类对象的指针调用虚函数,同时虚函数进行了重写。此时就构成了多态的条件。

但最关键的一点是:子类在虚函数重写时重写的是函数内部的实现,而函数接口声明的部分是会从父类继承下来的(也就是会复用父类的函数接口参数)

所以参数部分就取决于A类的func()函数,而内部的实现就看B类的func()函数。

重写隐藏重载的区别 

函数重载:重载函数发生在同一作用域里,函数名相同,参数列表不同。

函数隐藏(重定义):发生在基类和派生类中,只要求函数名相同即可。

函数重写(覆盖):发生在基类和派生类中,且都为虚函数,同时函数名相同,函数参数列表相同,返回值相同(除了协变)


基类和派生类的同名函数不是重写就是隐藏(绝不是函数重载)。

认识抽象类 

在了解抽象类之前要知道什么是纯虚函数,纯虚函数就是在虚函数后面写上=0;而该纯虚函数所在的类就是抽象类。(包含纯虚函数的类就是抽象类)

抽象类的特点:不能实例化出对象,而且继承了抽象类的子类也不能实例化对象,除非该子类重写纯虚函数,才可以实例化类对象。

所以纯虚函数规范了派生类必须进行重写。故纯虚函数用于定义一个通用的接口,让不同的派生类去实现这个接口,以适应不同的具体需求。纯虚函数主要体现接口继承。 

而接口继承其实就是派生类继承了基类纯虚函数的接口,目的是为了重写达成多态。

class Shape //图形抽象类
{
public:
    virtual void draw() = 0;
    virtual double area() = 0;
};

class Circle : public Shape 
{
public:
    void draw() override 
    {
        // 绘制圆形的具体代码
    }
    double area() override 
    {
        // 计算圆形面积的具体代码
    }
};

class Rectangle: public Shape 
{
public:
    void draw() override 
    {
        // 绘制长方形的具体代码
    }
    double area() override 
    {
        // 计算长方形面积的具体代码
    }
};

多态的原理(X64)

虚函数表

虚函数是形成多态的重要条件,我们知道类的成员函数是存在代码段的,其实虚函数也不例外,但是虚函数的地址是单独被拿出来了,放到了一个虚函数表(相当于一个函数指针数组)当中,而一个有虚函数的类中不仅仅存放成员变量,还存放这个虚函数表的地址即虚表指针

在VS的X64环境下演示

class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
	virtual void Func2()
	{
		cout << "Func2()" << endl;
	}
	void Func3()//非虚函数
	{
		cout << "Func3()" << endl;
	}
private:
	int _b = 1;
};
int main()
{
	Base b;
	cout << sizeof(Base) << endl;
}

重写后的的虚函数表

 我们只知道,有成员虚函数就有虚函数表,在多态中我们知道虚函数的重写是构成多态的重要条件,下面我们就来看一下重写后的的虚函数表是什么样子:

class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
	virtual void Func2()//非虚函数
	{
		cout << "Func2()" << endl;
	}
private:
	int _a = 1;
};
class Child:public Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
private:
	int _b = 3;
};

首先我们能发现父子类的虚函数表不是共有的,是各自独有一份的。

父类的虚函数表就如之前演示的一样,因为父类中有两个虚函数,所以虚函数表中就有两个数据(虚函数指针)。而子类继承了父类,但是子类的func1()函数与父类的func1()函数构成重写,同时子类也会继承父类未重写的虚函数。子类的虚函数表就是上图的样子,虚函数表中子类的非重写虚函数继承了父类的非重写虚函数,所以函数指针不变。而对于重写的虚函数则会进行覆盖父类的虚函数,因此重写后的虚函数指针发生改变。所以可以说子类重写虚函数就相当于父类虚函数被子类覆盖。


所以言归正传,当我们将子类对象给父类的指针或引用时,此时父类对象所指向的内容就会发生改变,从而指向的就是子类那一部分空间(红框框部分),所以当父类对象调用重写后的虚函数时,其实访问就是子类的虚函数表指针,故而访问的就是子类的虚函数,所以就形成了多态。

 其实尽管子类中没有虚函数,全是继承父类的虚函数的情况下(父子类虚表存放的函数指针是一模一样的),父类也不会套用子类的虚表指针,也是单独存一份虚函数表指针。虚表指针不同,所以虚表也不同,但是虚表里面的虚函数指针是一模一样的。而且一个类无论创建多少个对象,所存储的的虚表指针都是一样的。    所以说一个类的所有对象都共用一张虚表

 

通过切片无法形成多态

根据前面的认识我们知道当一个类中有虚函数时,编译器会为该类创建一个虚函数表。虚函数表是一个存储虚函数指针的数组,每一个元素对应着一个虚函数。

首先我们要知道动态多态的实现是依靠对象中的虚表指针所指向的虚表。指向父类就调用父类的虚函数,指向子类就调用子类的虚函数。

首先假设我们有两个类,父类:Animal,子类Dog。当Dog d = Animal();时:

此时就会进行切片处理,将子类的成员拷贝给父类,但是虚表指针并不会拷贝过去。所以d对象在调用虚函数时,只会调用Dog类的成员虚函数,而不是Animal类。因为编译器在编译阶段,对于通过对象(而非指针或引用)调用虚函数的情况,会按照对象的静态类型来确定虚函数表的入口。因为 animal 的静态类型是 Animal,所以它的 vptr 会指向 Animal 类的虚函数表,即使它是从 Dog 对象切片得来的。其实说实话就是虚表指针并不会随着切片的过程中赋值给父类对象。因为非指针或引用的对象在编译阶段就已经通过对象的静态类型确定好了哪个类的虚表。还有就是因为如果简单的赋值切片的过程就会赋值虚表指针的话,那么仅通过够通过父类对象就会形成多态,那么后续的父类对象就无法保证父类的虚表。后续使用会十分紊乱。

所有虚函数都存在虚表中

我们知道如果一个类中有虚函数的话就存在虚函数表。所以而我们监视窗口中还可以看到类中是存放有一个指向虚表首元素地址的指针,虚表中存放的是虚函数的指针,所以当我们计算类的大小时要将虚指针也计算在内。

回到问题,我们先用监视窗口查看一下虚表的内容:

class Base {
public:
	virtual void func1() { cout << "Base::func1" << endl; }
	virtual void func2() { cout << "Base::func2" << endl; }
private:
	int a;
};
class Derive :public Base {
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
	virtual void func4() { cout << "Derive::func4" << endl; }
private:
	int b;
};

此时从监视窗口中可以看出Derive类中虚表只存放了两个虚函数一个是继承并重写的func1 虚函数,另一个是继承下来的func2 函数,但是为什么不见Derive类中的func3和func4呢。其实监视窗口只会展开部分内容,最终想查看详细信息还是直接看内存空间的内容:(X64平台)

 很明显全0的位置就是分界线的地方(仅猜测),而且虚表中存放了四个函数指针的值,自然知道分别是重写的func1继承的func2,以及原有的func3和func4.为了验证这一问题我们可以先初步的通过函数指针去调用对应的虚函数,如果调用没问题则证明我们的猜想是正确的,反之则是错误的:

typedef void(*VF)();//将无参的函数指针类型重命名成VF
void Print(VF* p)//该函数接收虚表指针,然后依次调用虚函数
{
	int i = 1;
	while (*p)//vs结束标记:函数指针内容是全0
	{
		printf("func%d = %p->",i++, *p);
		(*p)();//*p就是虚表里的函数指针,可以代替函数名直接调用函数
		p++;//跳过当前函数指向下一个函数
	}

}
int main()
{
	Base b;
	Derive d;
	Print((VF*)*(long long*)&b);//传虚表的地址
	cout << endl;
	Print((VF*)*(long long*)&d);
	//*(long long*)&b 是拿到该对象前八个字节的内容,也就是虚表指针
	//防止传参时类型不匹配,强转

 	return 0;
}

所以可以证明:一个类中所有的虚函数都会存在该类对应的虚函数表中

 多继承下的虚表

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(*VF)();//将无参的函数指针类型重命名成VF
void Print(VF* p)//该函数是为了实现打印虚表内的函数指针并且直接调用
{
	int i = 1;
	while (*p)
	{
		printf("func%d = %p->", i++, *p);
		(*p)();//*p就是虚表里的函数指针,可以代替函数名直接调用函数
		p++;
	}

}

首先我们要了解,多继承的派生类中不是只有一个虚表。就拿上面的例子来说,Derive类同时先后继承了Base1类和Base2类,而Base1和Base2类都有各自对应的虚表,同时三个类都有同一个func1函数,被Derive继承以后也就构成了重写,所以说将两个基类的func1函数都进行了重写。但是问题是Derive类对象中会有几张虚表指针呢?而且Derive类对象的func3虚函数该存放到哪里呢?通过打印内存情况展示:

其实经过虚表内容的打印后,不难看出func3其实是放在第一个继承下来的虚表内部的。对于重写的func1函数,我们可以看到Derive类中对继承的两个Base类的func1函数都进行了重写。

所以说多继承中子类继承了几个父类(都有虚表指针)就有几个虚表指针,内存布局情况取决于继承顺序。

可以为虚函数吗?

1.构造函数可以是虚函数吗?

编译报错,因为对象中的虚函数表指针是在构造函数阶段才开始初始化的,虚函数调用要在虚表中去寻找函数指针,但此时虚表指针还未初始化。

2.inline函数可以是虚函数吗?

可以,符合内联的函数没有函数地址,是直接在调用处进行展开的。但是多态调用时会忽略inline的作用,只有普通调用inline才会起作用。所以准确说:内联函数不可以是虚函数,但是多态调用时会忽略inline,即不再是被当做内联函数。

3.析构函数可以是虚函数吗?

可以,而且最好是实现虚函数的重写,此时内存空间可以正确的进行释放。

4.静态函数可以是虚函数吗?

编译报错,因为虚函数实际是通过类内的虚表指针去访问的,而static修饰的静态成员函数没有this指针,所以没有虚表指针,也就无法访问虚表。


经典题目 

test_1

class A
{
public:

    A() 
        :m_iVal(0) 
    { 
        test();
    }
    virtual void func() 
    {
        cout << m_iVal << ' '; 
    }
    void test() 
    {
        func(); 
    }

public:
    int m_iVal;

};

class B : public A
{
public:
    B() 
    {
        test();
    }
    virtual void func()
    {
        ++m_iVal;
        cout << m_iVal << ' ';
    }

};
int main(int argc, char* argv[])
{
    A* p = new B;
    p->test();
    return 0;
}

 

在 A 类的构造函数中调用 test() 函数,而 test() 函数又调用了虚函数 func()。当创建 B 类的对象时,会先调用 A 的构造函数,此时 B 类对象的虚表指针(vptr)还未被正确设置为指向 B 类的虚函数表即虚函数表指针还未初始化),因为在 A 的构造函数中,对象被认为是 A 类对象,其虚表指针指向 A 类的虚函数表。因此在 A 的构造函数中调用 test() 并调用 func() 时,会调用 A::func() 而不是 B::func()。此后开始调用类的构造函数,此时 B 类对象的虚表指针(vptr)已经被正确设置为指向 B 类的虚函数表,所以之后的调用就符合多态。

所以说:虚函数指针是在对象构造阶段才开始初始化,而虚表是在编译阶段就已经生成(存储在代码段)

 test_2

class A
{
public:
	virtual void f()
	{
		cout << "A::f()" << endl;
	}

};

class B : public A
{
private://私有限定符
	virtual void f()
	{
		cout << "B::f()" << endl;
	}
};
int main()
{
	A* pa = (A*)new B;
	pa->f();
	B b;

	return 0;
}

多态不会受到访问限定符的限制。

在C++中,访问限定符(如public、private、protected)用于控制类中成员的访问权限。

但是,在多态中,派生类必须能够访问基类中的虚函数,以便实现多态性,无论这个虚函数的访问限定符是什么派生类都可以通过继承方式获取基类中的所有成员函数,包括私有成员函数。


http://www.kler.cn/a/508723.html

相关文章:

  • RV1126+FFMPEG推流项目(9)AI和AENC模块绑定,并且开启线程采集
  • 计算机网络 (41)文件传送协议
  • 【开源免费】基于SpringBoot+Vue.JS欢迪迈手机商城(JAVA毕业设计)
  • 单元测试与unittest框架
  • HTML中如何保留字符串的空白符和换行符号的效果
  • 菜品管理(day03)
  • doris: Flink导入数据
  • AI自动化编程:编程教育的变革之风
  • MarsCode青训营打卡Day1(2025年1月14日)|稀土掘金-16.最大矩形面积问题
  • EAMM: 通过基于音频的情感感知运动模型实现的一次性情感对话人脸合成
  • JAVA-Exploit编写(5)--http-request库使用
  • Python 爬虫:获取网页数据的 5 种方法
  • Maven在Win10上的安装教程
  • 家政服务小程序,打造智慧家政新体验
  • Rust:指针 `*T` 和引用 `T`的区别
  • 农业农村大数据应用场景|珈和科技“数字乡村一张图”解决方案
  • Docker 搭建mysql 连接超时问题,xxl-job启动mysql连接报错,禁用dns
  • HTML5+Canvas实现的鼠标跟随自定义发光线条源码
  • MyBatisPlus--分页插件
  • 【第四课】冒泡排序,快速排序(acwing-785)
  • Python与PyTorch的浅拷贝与深拷贝
  • 梯度下降与权重更新解析
  • 有限元分析学习——Anasys Workbanch第一阶段笔记(12)静力学分析基本参数及重力对计算结果的影响
  • 基于Android 位置定位的考勤 APP 设计与实现
  • 虚幻基础2:gameplay框架
  • 鸿蒙中选择地区