前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
社区首页 >专栏 >今天你学C++了吗?——C++中的多态

今天你学C++了吗?——C++中的多态

作者头像
用户11352420
发布2025-03-16 20:32:05
发布2025-03-16 20:32:05
6900
代码可运行
举报
文章被收录于专栏:编程学习编程学习
运行总次数:0
代码可运行

什么是多态?

从名字上来看,多态不就是多种形态吗?接下来看看多态的概念~

概念: 多态(polymorphism)是一个在面向对象编程中非常重要的概念,它通俗地可以理解为“多种形态”。多态性允许我们在程序中通过统一的接口来调用不同的实现,从而提高了代码的灵活性和可扩展性

类型: 多态主要分为两种类型:编译时多态(静态多态)运行时多态(动态多态)

编译时多态(静态多态)主要通过函数重载和函数模板来实现 函数重载允许在同一个作用域内定义多个同名但参数类型或数量不同的函数,这样在编译时就可以根据传入的参数类型或数量来确定调用哪个函数。函数模板则是一种更通用的方式,它允许我们编写与类型无关的代码,编译器在编译时会根据传入的类型来生成相应的函数实现。由于这两种多态性的实现都是在编译时完成的参数匹配,因此它们被称为编译时多态或静态多态

运行时多态(动态多态)则是通过虚函数和继承关系来实现的 在运行时多态中,基类指针或引用可以指向派生类对象,并通过基类指针或引用来调用虚函数。此时,调用的虚函数是派生类中的实现,而不是基类中的实现。这种多态性的实现是在运行时完成的,因为只有在运行时才能确定基类指针或引用实际指向的是哪个类的对象。因此,运行时多态也被称为动态多态

具体来说,运行时多态允许我们在完成某个行为(函数)时,传入不同的对象就会完成不同的行为,从而达到多种形态。假设我们正在开发一个图形绘制应用程序,该程序需要支持多种图形对象(如圆形、矩形和三角形)的绘制。我们可以设计一个基类Shape,并在其中定义一个虚函数draw()用于绘制图形。不同的图形对象(圆形、矩形和三角形)将作为Shape的派生类,并在这些派生类中重写draw()函数以实现各自的绘制逻辑。同样地,在动物叫声的模拟中,传入猫对象时发出“喵”的叫声,传入狗对象时发出“汪汪”的叫声,这也是通过多态性来实现的。

总之,多态性是面向对象编程中一个非常重要的特性,它允许我们在程序中通过统一的接口来调用不同的实现,从而提高了代码的灵活性和可扩展性。无论是编译时多态还是运行时多态,都在不同的场景下发挥着重要的作用。

多态的实现

虚函数

什么是虚函数?

当一个类成员函数的前面被添加了virtual关键字进行修饰时,这个成员函数就被称为虚函数。值得注意的是,非成员函数(即不属于任何类的函数)是不能被virtual关键字修饰的。

虚函数的重写

虚函数的重写,简单来说,就是子类里有一个和父类一模一样的虚函数(它们的函数名、参数列表以及返回值的类型都完全相同)。所以有时候我们说子类重写了父类的虚函数。这里有个小细节要注意:子类在重写父类的虚函数时,即使不写virtual关键字,因为继承的关系,这个函数在子类里还是会被当作虚函数来处理。但是这种做法不太规范,我们最好还是写上virtual,这样代码看起来更清晰,大家也更明白你的意图。

多态的构成条件

多态是指在继承关系中,不同的类对象通过调用同一个函数而展现出不同的行为。这种特性使得程序能够根据不同的对象类型来执行不同的操作,从而提高了程序的灵活性和可扩展性。

实现多态的两个必要条件

要实现多态效果,必须满足以下两个重要条件:

  1. 必须是基类的指针或者引用调用虚函数:只有基类的指针或引用才能同时指向基类对象和派生类对象。这是因为在C++等编程语言中,基类的指针或引用可以指向其派生类的对象,这是多态性的基础。当通过基类的指针或引用调用虚函数时,程序会根据实际指向的对象类型来确定调用哪个类的函数实现。
  2. 被调用的函数必须是虚函数,并且派生类完成了对基类的虚函数的重写/覆盖:虚函数是在基类中声明,并在一个或多个派生类中被重写的函数。通过在基类中声明函数为虚函数,我们告诉编译器这个函数可能会在派生类中被重写。当派生类重写了基类的虚函数后,通过基类的指针或引用调用该函数时,将调用派生类中的实现。这样,不同的派生类对象在调用同一个虚函数时会表现出不同的行为,从而实现多态性。

综上所述,要实现多态效果,首先需要一个基类,并在其中声明虚函数;其次,需要有一个或多个派生类,它们重写基类的虚函数以实现不同的行为;最后,通过基类的指针或引用来调用这些虚函数,以实现多态性。这种机制使得程序能够根据对象的实际类型来执行不同的操作,从而提高了程序的灵活性和可扩展性。

说了这么多,我们来看看代码,就以图像编辑系统为例

假设我们正在开发一个图形绘制应用程序,该程序需要支持多种图形对象(如圆形、矩形和三角形)的绘制。我们可以设计一个基类Shape,并在其中定义一个虚函数draw()用于绘制图形。不同的图形对象(圆形、矩形和三角形)将作为Shape的派生类,并在这些派生类中重写draw()函数以实现各自的绘制逻辑。

代码语言:javascript
代码运行次数:0
运行
复制
#include<iostream>
using namespace std;
class Shape
{
public:
    virtual void draw()
    {
        cout << "Shape draw()" << endl;
    }
};

class Circle : public Shape
{
public:
    //虚函数的重写,可以不写virtual关键字
    void draw()
    {
        // 实现圆形的绘制逻辑
        std::cout << "Drawing a circle." << std::endl;
    }
};

class Rectangle : public Shape
{
public:
    //最好还是写上virtual,这样代码看起来更清晰
    virtual void draw()
    {
        // 实现矩形的绘制逻辑
        std::cout << "Drawing a rectangle." << std::endl;
    }
};

class Triangle : public Shape
{
public:
    void draw()
    {
        // 实现三角形的绘制逻辑
        std::cout << "Drawing a triangle." << std::endl;
    }
};
int main()
{
    //1.必须是基类的指针或者引用调用虚函数
    //2.被调用的函数必须是虚函数,并且派生类完成了对基类的虚函数的重写/覆盖
    Circle c;
    Shape* s1 = &c;
    s1->draw();
    Shape& s2 = c;
    s2.draw();
    
    Rectangle r;
    Shape* s3 = &r;
    s3->draw();
    Shape& s4 = r;
    s4.draw();

    Triangle t;
    Shape* s5 = &t;
    s5->draw();
    Shape& s6 = t;
    s6.draw();
    //根据不同对象类型来执行不同的操作,从而提高了程序的灵活性和可扩展性

    Shape s7;
    s7.draw();
    return 0;
}

上面的这段代码就体现了C++面向对象编程中的多态性特性。通过定义一个基类Shape及其多个派生类(如CircleRectangleTriangle),并在这些派生类中重写基类中的虚函数draw(),实现了多态性。当使用基类Shape的指针或引用来指向不同的派生类对象并调用draw()函数时,会根据对象的实际类型执行相应的派生类中的函数实现。这种机制极大地增强了程序的灵活性和可扩展性,使得我们可以在不修改现有代码的情况下,轻松添加新的形状类并自动适应这些新类。

选择题

了解了多态之后,我们来看看一个选择题

以下程序输出结果是什么()

A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确

程序:

代码语言:javascript
代码运行次数: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->0,这里我们就需要考虑到基类和派生类的默认参数了 当执行 p->test(); 时,以下事情发生:

  1. p 是一个指向 B 类对象的指针。
  2. test 函数在 A 类中定义,但在 B 类中没有重写。因此,调用的是 A 类中的 test 函数。
  3. A 类的 test 函数中,func 被调用,没有提供参数。因此,将使用 A 类(父类)中 func 函数的默认参数值,即 1
  4. 由于 func 是虚函数,并且 p 指向一个 B 类的对象,因此 B 类的 func 函数将被调用。
  5. 但是,传递给 Bfunc 函数的参数值是 1,而不是 B 类中定义的默认参数值 0。这是因为默认参数值是在编译时确定的,基于的是调用该函数时所使用的函数签名(在这里是 A 类中的 func 函数签名)

总结: 在C++中,当通过基类指针或引用调用虚函数时,虽然函数解析会基于对象的实际类型(即多态性),但默认参数值是基于函数声明的类型(即编译时类型)来确定的。

override 和 final关键字

由于C++语言的灵活性,有时候程序员可能会因为疏忽而未能正确地重写虚函数,比如函数名拼写错误、参数列表不匹配、或者返回类型不符合要求等。这些错误在编译期间可能不会立即被检测出来,因为C++编译器在默认情况下并不会对虚函数的重写进行严格的类型检查。

为了解决这个问题,C++11标准引入了overridefinal这两个关键字,它们提供了更强的类型检查和更清晰的代码意图表达。

  1. override关键字: 当派生类中的函数意图重写基类中的虚函数时,可以在派生类函数声明后加上override关键字。这样做有两个好处:
    • 编译时检查:如果派生类中的函数没有正确地重写基类中的虚函数(比如函数名、参数列表或返回类型不匹配),编译器将报错。这有助于在编译期间就捕获潜在的错误。
    • 代码可读性override关键字清晰地表明了派生类函数的意图,即它是为了重写基类中的某个虚函数。这有助于其他程序员理解代码的结构和意图。
  2. final关键字: 如果不希望某个类虚函数在派生类中被重写,或者某个类不希望被其他类继承,可以使用final关键字来修饰该类或该函数。
    • 修饰函数:当final修饰一个虚函数时,表示该函数在派生类中不能被重写。
    • 修饰类:当final修饰一个类时,表示该类不能被继承。

例:

代码语言:javascript
代码运行次数:0
运行
复制
#include<iostream>
using namespace std;
class A
{
public:
	virtual void test1()
	{
		cout << "A test1()" << endl;
	}
	virtual void test2()final//该函数在派生类中不能被重写
	{
		cout << "A test2()" << endl;
	}
};
class B :public A 
{
public:
	//virtual void test1(int a)override//err——错误重写,编译报错
	//	//添加override关键字,在编译期间检查是否正确地重写虚函数
	//{
	//	cout << "B test1()" << endl;
	//}
	//正确重写:
	virtual void test1()override
	{
		cout << "B test1()" << endl;
	}
	//virtual void test2()//err——无法重写final test2函数
	//{
	//	cout << "B test2()" << endl;
	//}
};
class Base final
	//final修饰一个类时,表示该类不能被继承
{
public:
	int _a;
	void test()
	{

	}
};
//class Derive :public Base//err——不能将final类类型作为基类
//{
//
//};
int main()
{
	B b;
	A& a = b;
	a.test1();
	a.test2();
	return 0;
}

协变

协变是指在C++继承中,派生类重写基类虚函数时,返回类型可以是基类返回类型的派生类类型(指针或引用)。这增强了类型安全性,但需确保返回类型间存在继承关系,并遵循C++的虚函数重写规则,C++11的override关键字有助于此。

析构函数

析构函数是一个特殊的成员函数,它在对象的生命周期结束时被自动调用,用于执行清理操作,比如释放对象占用的资源。析构函数的名称与类名相同,但前面有一个波浪号(~

基类中的虚析构函数

当基类指针指向派生类对象时(多态性),如果基类的析构函数不是虚函数,那么在删除这个指针时,只会调用基类的析构函数,而不会调用派生类的析构函数。这可能导致资源泄露,因为派生类可能分配了需要在析构时释放的资源。

有人会说,基类和派生类名字不是不相同吗?怎么只调用基类的析构函数呢?

虽然基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统⼀处理成destructor~

为了解决这个问题,基类的析构函数通常被声明为虚函数。这样,当通过基类指针删除对象时,会首先调用派生类的析构函数,然后是基类的析构函数,确保资源被正确释放。

析构函数的重写

在C++中,析构函数的名字在编译时会被特殊处理,以支持多态性。即使派生类的析构函数没有显式地使用virtual关键字(在C++11及之后的标准中,这是允许的,因为析构函数自动继承基类的虚属性),它仍然被视为对基类虚析构函数的重写。这是因为编译器知道当对象通过基类指针删除时,需要调用正确的析构函数链。

使用例子

我们首先来看看如果不将析构函数写成虚函数是什么样子?

代码语言:javascript
代码运行次数:0
运行
复制
class A
{
public:
	A()
	{
		cout << "A()" << endl;
	}
	~A()
	{
		cout << "~A()" << endl;
	}
};
class B :public A
{
public:
	B()
	{
		cout << "B()" << endl;
	}
	~B()
	{
		cout << "~B()" << endl;
	}
};
int main()
{
	A* p = new B(); // 基类指针指向派生类对象
	delete p; // 只会调用调用A的析构函数,如果B类中有内存管理,容易造成内存泄漏
	return 0;
}

我们可以发现,只会调用调用A的析构函数,如果B类中有内存管理,容易造成内存泄漏~所以我们要把析构函数设置为虚函数~接下来看看正确的写法

代码语言:javascript
代码运行次数:0
运行
复制
class A
{
public:
	A()
	{
		cout << "A()" << endl;
	}
	virtual ~A()//将析构函数写成虚函数
	{
		cout << "~A()" << endl;
	}
};
class B :public A
{
public:
	B()
	{
		cout << "B()" << endl;
	}
	~B()//派生类的析构函数可以不显式地使用virtual关键字
	{
		cout << "~B()" << endl;
	}
};
int main()
{
	A* p = new B(); // 基类指针指向派生类对象
	delete p; // 首先调用B的析构函数,然后调用A的析构函数
	return 0;
}

重载/重写/隐藏的对比

这里我们来简单进行一下重载/重写/隐藏的对比

重载(Overloading)
  1. 定义:在同一作用域内,允许存在多个同名函数,但这些函数的参数列表必须不同(参数类型、参数个数或参数顺序不同)。
  2. 范围:重载可以发生在同一个类或命名空间中,也就是说两个函数在同一个作用域
  3. 函数签名:重载函数的函数签名(函数名+参数列表)必须唯一。
  4. 返回类型:重载函数的返回类型可以相同也可以不同,但返回类型不是区分重载的唯一因素,必须与参数列表一起考虑。
  5. 用途:重载使得函数名可以具有多种用途,增强了代码的可读性和灵活性。
重写/覆盖(Overriding)
  1. 定义:在类的继承关系中,子类提供一个与基类中的虚函数具有相同签名(函数名、参数列表和返回类型相同)的函数实现。
  2. 范围:重写发生在基类和子类之间。
  3. 虚函数:重写通常涉及虚函数,即基类中的函数被声明为virtual
  4. 动态绑定:重写允许在运行时根据对象的实际类型来调用相应的函数实现,实现多态性。
  5. 用途:重写用于在子类中提供特定的实现,同时保持与基类接口的兼容性。
隐藏(Hiding)
  1. 定义:在类的继承关系中,子类中的函数屏蔽了基类中的同名函数,使得通过基类指针或引用调用该函数时,调用的是基类中的版本,而不是子类中的版本。隐藏也可以发生在成员变量上。
  2. 范围:隐藏发生在基类和子类之间,但通常是由于函数签名不匹配或作用域不同导致的。
  3. 函数签名:隐藏不要求函数签名完全相同,只要子类中的函数与基类中的某个函数在名称上冲突(且不满足重写的条件),就可能发生隐藏。
  4. 静态绑定:隐藏导致的是静态绑定,即编译时确定调用哪个函数。
  5. 用途:隐藏通常不是有意为之的,它可能是由于设计不当或命名冲突导致的。隐藏可能导致意外的行为,因此应该避免。
对比总结
  • 重载是在同一作用域内对同名函数进行扩展,通过不同的参数列表来区分。
  • 重写/覆盖是在继承关系中,子类提供基类虚函数的特定实现,实现多态性。
  • 隐藏是在继承关系中,由于函数签名不匹配或作用域不同,子类函数屏蔽了基类中的同名函数,通常不是有意为之的。
动态绑定与静态绑定

这里出现了新的概念动态绑定与静态绑定

  • 静态绑定:当函数调用不满足多态的条件(即不是通过指针或引用调用虚函数)时,该调用在编译时就已经确定了要调用的函数地址。这种在编译时确定函数调用的方式被称为静态绑定
  • 动态绑定:当函数调用满足多态的条件(即是通过指针或引用调用虚函数)时,该调用在运行时才会确定要调用的函数地址。这是通过在运行时查找对象的虚函数表(后面关于多态的原理会提到虚函数表)来实现的,因此被称为动态绑定

我们来看看下面的例子:

代码语言:javascript
代码运行次数:0
运行
复制
class A
{
public:
	//函数重载
	virtual void test1()
	{
		cout << "A test1()" << endl;
	}
	void test1(int a)
	{
		cout << "A test1(int a)" << endl;
	}
};
class B :public A
{
public:
	//函数重写
	virtual void test1()
	{
		cout << "B test1()" << endl;
	}
};
int main()
{
	A* a = new B;
	//动态绑定——满足多态,在运行时查找对象的虚函数表来实现的
	a->test1();
	//静态绑定——在编译时确定函数调用的方式
	a->test1(1);
	return 0;
}

我们可以通过反汇编来进行查看区分:

纯虚函数和抽象类

纯虚函数是一个没有具体实现的虚函数,它在类中被声明为 = 0 包含纯虚函数的类被称为抽象类,抽象类不能创建对象,它们的主要作用是提供一个接口或规范,要求继承它们的子类必须实现这些纯虚函数。这样,抽象类就强制了子类必须按照一定的规则来行事。通过纯虚函数和抽象类,C++实现了多态性的一种形式,允许我们用基类指针或引用来操作不同的子类对象,而不需要知道具体的子类类型。这使得代码更加灵活和可扩展。

就比如前面的图形编辑系统,我们可以不需要实现Shape类里面的draw()函数,因为这是没有实际意义的,我们可以把它设计成纯虚函数,让派生类进行实现就好了~

例:

代码语言:javascript
代码运行次数:0
运行
复制
class Shape //抽象类
{
public:
    virtual void draw() = 0; // 纯虚函数,强制派生类实现
};

class Circle : public Shape
{
public:
    void draw() override 
    {
        // 实现圆形的绘制逻辑
        std::cout << "Drawing a circle." << std::endl;
    }
};

class Rectangle : public Shape
{
public:
    void draw() override 
    {
        // 实现矩形的绘制逻辑
        std::cout << "Drawing a rectangle." << std::endl;
    }
};

class Triangle : public Shape 
{
public:
    void draw() override
    {
        // 实现三角形的绘制逻辑
        std::cout << "Drawing a triangle." << std::endl;
    }
};
int main()
{
   // Shape s;//err 无法实例化抽象类类型
    Rectangle r;
    r.draw();
    Shape* s1 = &r;
    Shape& s2 = r;
    s1->draw();
    s2.draw();
    return 0;
}

多态的原理

前面说了这么多多态的概念以及使用,那么多态的底层原理是什么呢?为什么通过多态会达到这么神奇的效果呢?我们一起来看看接下来的内容,多态的原理~

多态的原理,简单来说就是运行时到指向的对象的虚表中找到对应的虚函数的地址进行调用~

有人可能就会好奇,虚表?什么是虚表?我们怎么知道虚表的存在呢?别急,我们慢慢来看~

虚函数表指针

我们首先来看看下面的这道题:

下面编译为32位程序的运行结果是什么()

A. 编译报错 B. 运行报错 C. 8 D. 12

代码语言:javascript
代码运行次数:0
运行
复制
class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
protected:
	int _a = 1;
	char _ch = 'd';
};
int main()
{
	Base b;
	cout << sizeof(b) << endl;
	return 0;
}

有的人会说这个题不是送分题吗?这里的代码没有什么问题,所以首先排除A、B,前面说过一个类的字节大小需要看这个类里面的成员变量,再根据内存对齐就可以了,所以答案是C。

我知道你很急,但是先别急,我们来看看跑出来的结果

显然,正确答案:D 有人会说怎么会这样?我说伙计,这里有虚函数啊,肯定有坑啊! 我们再来看看在64位环境下是什么结果?

是16,相信到这里,大家心里面就有谱了,一个跟我们想象的多了4个字节,一个跟我们想象的多了8个字节,这多的不正好是指针在不同环境下的字节大小嘛~所以多的这个就是指针,那么这个指针里面隐藏着什么内容呢?

接下来我们在x86(也就是32位环境)下观察:

我们可以发现,对象b里面不仅仅有_a和_ch,还有一个__vfptr放在前面(这个顺序与平台有关,有的平台可能放在后面),这个又是什么呢?

这个是指向虚函数表的指针我们通常称之为虚函数表指针(其中“v”代表virtual,即“虚”,“f”代表function,即“函数”)。任何一个包含虚函数的类中,都至少包含一个这样的虚函数表指针。原因在于,一个类的所有虚函数的地址需要被存储在这个类的对象的虚函数表(简称虚表)中~,前面的字节大小就解释得通了~

如果一个类里面有多个虚函数,字节大小会不会不一样呢?

我们可以看到是没有变化的,那它的派生类大小又是多少呢?

代码语言:javascript
代码运行次数:0
运行
复制
#include<iostream>
using namespace std;
class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
	virtual void Func2()
	{
		cout << "Func2()" << endl;
	}
protected:
	int _a = 1;
	char _ch = 'd';
};
class Derive :public Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
	virtual void Func2()
	{
		cout << "Func2()" << endl;
	}
protected:
	int _d = 2;
	char _chd = 'dd';
};
int main()
{
	Base b;
	cout << sizeof(b) << endl;
	Derive d;
	cout << sizeof(d) << endl;
	return 0;
}

解释:(在32位环境下)

  • 当我们创建一个 Base 类的对象 b 时,对象 b 的大小至少包括一个指向虚函数表的指针(__vfptr)的大小,以及 Base 类自己的成员变量 _a_ch 的大小。由于虚指针的存在,即使 Base 类的成员变量很小,对象的大小也会比成员变量总和大为12
  • 当我们创建一个 Derive 类的对象 d 时,对象 d 的大小包括从 Base 类继承的虚指针(因为 Derive 类也有虚函数,它可能会使用自己的虚表,但虚指针本身通常只存储一次,在继承体系的最顶层类中),Base 类的成员变量 _a_ch,以及 Derive 类自己的成员变量 _d_chd为20

那么知道了这是虚函数表指针,那么多态又是怎么实现的呢?我们一点点来看

多态怎么实现?

我们结合下面的代码来看看是怎么样实现多态的?

代码语言:javascript
代码运行次数:0
运行
复制
 class Shape //抽象类
{
public:
    virtual void draw() = 0; // 纯虚函数,强制派生类实现
};

class Circle : public Shape
{
public:
    void draw() override 
    {
        // 实现圆形的绘制逻辑
        std::cout << "Drawing a circle." << std::endl;
    }
};

class Rectangle : public Shape
{
public:
    void draw() override 
    {
        // 实现矩形的绘制逻辑
        std::cout << "Drawing a rectangle." << std::endl;
    }
};

class Triangle : public Shape 
{
public:
    void draw() override
    {
        // 实现三角形的绘制逻辑
        std::cout << "Drawing a triangle." << std::endl;
    }
};
int main()
{
   // Shape s;//err 无法实例化抽象类类型
    Rectangle r;
    r.draw();
    Shape* s1 = &r;
    Shape& s2 = r;
    s1->draw();
    s2.draw();
    cout << endl;
    Triangle t;
    t.draw();
    Shape* s3 = &r;
    Shape& s4 = r;
    s3->draw();
    s4.draw();
    return 0;
}

当满足多态条件后,底层机制转变为在运行时而非编译时确定函数调用。具体而言,程序会访问指向对象的虚表(vtable),根据该虚表来确定对应虚函数的地址。这一转变使得,当指针或引用指向基类对象时,会调用基类的虚函数;而当它们指向派生类对象时,则会调用派生类中对应的重写虚函数。这样,多态性得以实现,允许通过基类类型的指针或引用来透明地调用派生类中重写的方法。

虚函数表

虚函数表这么神奇,接下来,我们来看看虚函数表更加详细的内容~

  • 基类对象的虚函数表中存储了基类所有虚函数的地址,由于每个类(包括基类和派生类)都可能有自己独特的虚函数集合,因此它们各自拥有独立的虚函数表,同类型的对象共享同一张虚函数表。
  • 派生类对象由两部分组成:从基类继承而来的部分和派生类自己新增的成员。在派生类对象中,继承自基类的部分通常包含指向基类虚函数表的指针。需要注意的是,尽管派生类对象中的基类部分也包含虚函数表指针,但它与单独的基类对象的虚函数表指针不是同一个,因为派生类对象的虚函数表指针指向的是派生类自己的虚函数表。
  • 当派生类重写基类的虚函数时,派生类的虚函数表中对应位置的虚函数地址会被替换为派生类重写的虚函数地址,从而实现虚函数的覆盖。
  • 派生类的虚函数表包含三个部分:一是从基类继承而来的虚函数地址(如果它们没有被派生类重写);二是派生类重写的虚函数地址(这些地址会覆盖基类虚函数表中的相应地址);三是派生类自己定义的新的虚函数地址。
  • 虚函数表本质上是一个指针数组,每个指针指向一个虚函数的地址。在某些编译器(如Visual Studio系列)中,虚函数表的末尾可能会放置一个空指针(如0x00000000)作为标记,但这并不是C++标准的一部分,而是编译器自行定义的。例如,gcc系列编译器可能不会这样做。
  • 虚函数本身与普通函数一样,在编译后生成的是一段指令,存储在代码段中。不同的是,虚函数的地址被存储在虚函数表中,以便在运行时通过对象的虚函数表指针进行动态绑定。
  • 关于虚函数表存储在何处的问题,C++标准并没有规定。不同的编译器可能会选择不同的存储位置。例如,在Visual Studio编译器下,虚函数表通常存储在代码段(常量区)中。然而,这并不意味着所有编译器都会这样做,因此在编写跨平台代码时需要谨慎处理与虚函数表相关的细节。

通过虚函数表机制,C++实现了多态性,允许通过基类指针或引用来调用派生类中重写的虚函数,从而实现灵活的面向对象编程。

补充

编译报错(Compile-time Errors)

编译报错发生在源代码被编译器处理时。这些错误通常是由于语法错误、类型不匹配、缺少头文件、未定义的标识符等原因引起的。编译器在尝试将源代码转换为可执行文件时,如果发现这些问题,就会停止编译并输出错误信息。

特点

  • 错误信息由编译器提供。
  • 错误发生在源代码层面。
  • 必须在代码被编译成可执行文件之前解决
运行报错(Runtime Errors)

运行报错发生在程序执行时。这些错误通常是由于内存访问违规(如空指针解引用)、数组越界、除零错误、资源泄漏等原因引起的。编译器在编译时无法检测到这些错误,因为它们在程序运行时才会发生。

特点

  • 错误信息由操作系统或运行时库提供(如标准库)。
  • 错误发生在程序执行过程中。
  • 可以通过调试器、日志记录、异常处理等方法来检测和解决。
本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2025-03-15,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 什么是多态?
  • 多态的实现
    • 虚函数
      • 什么是虚函数?
      • 虚函数的重写
    • 多态的构成条件
    • 实现多态的两个必要条件
    • 选择题
  • override 和 final关键字
  • 协变
  • 析构函数
    • 基类中的虚析构函数
    • 析构函数的重写
    • 使用例子
  • 重载/重写/隐藏的对比
    • 重载(Overloading)
    • 重写/覆盖(Overriding)
    • 隐藏(Hiding)
    • 对比总结
    • 动态绑定与静态绑定
  • 纯虚函数和抽象类
  • 多态的原理
    • 虚函数表指针
    • 多态怎么实现?
    • 虚函数表
  • 补充
    • 编译报错(Compile-time Errors)
    • 运行报错(Runtime Errors)
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档