1. 起源
在C++的早期设计中,通过基类指针可以访问派生类的成员变量,这是由于派生类对象在内存中的布局是基类成员变量在前,派生类成员变量在后。因此,当我们使用基类指针指向派生类对象时,可以正常访问到派生类中从基类继承来的成员变量。
然而,对于成员函数,情况就不同了。在编译时期,成员函数并不会被放入对象的内存空间中,而是存放在一块单独的内存区域,每个类只有一份成员函数的代码。当我们通过基类指针调用成员函数时,编译器会根据指针的静态类型(也就是基类类型)去查找对应的成员函数,而不是动态类型(也就是实际指向的派生类类型)。这就导致了我们无法通过基类指针调用派生类的成员函数。
为了解决这个问题,C++引入了虚函数的概念。通过将基类的成员函数声明为虚函数,我们就可以通过基类指针调用派生类的成员函数,实现了所谓的多态性。这是C++支持面向对象编程的一个重要特性。
2. 构成多态的条件
多态是面向对象编程的一个重要特性,它允许我们通过基类指针或引用来操作派生类对象。在C++中,要实现多态,需要满足以下条件:
- 存在继承关系:多态基于继承,因为只有在存在基类和派生类的情况下,我们才能通过基类来操作派生类。这是多态的基础。
- 被调用的函数必须是虚函数:在C++中,只有声明为虚函数的成员函数才能实现多态。虚函数允许在派生类中被重写,这样当我们通过基类指针或引用调用这个函数时,会根据实际的对象类型来调用相应的函数,这就是动态绑定。
- 虚函数必须被重写:虚函数的重写意味着在派生类中提供了一个与基类虚函数具有相同函数签名(即函数名和参数类型)的函数。这样,当我们通过基类指针或引用调用这个函数时,会调用派生类中的版本,而不是基类中的版本。
满足以上所有条件,我们就可以通过基类指针或引用来操作派生类对象,实现多态。这使得我们的代码更具有通用性和可扩展性,因为我们可以添加新的派生类,只要它们正确地重写了基类的虚函数,就可以被同样的基类指针或引用操作,而无需修改已有的代码。
3. 虚函数
虚函数指针 (virtual function pointer) 从本质上来说就只是一个指向函数的指针,与普通的指针并无区别。它指向用户所定义的虚函数,具体是在子类里的实现,当子类调用虚函数的时候,实际上是通过调用该虚函数指针从而找到接口。
虚函数指针是确实存在的数据类型,在一个被实例化的对象中,它总是被存放在该对象的地址首位,这种做法的目的是为了保证运行的快速性。与对象的成员不同,虚函数指针对外部是完全不可见的,除非通过直接访问地址的做法或者在DEBUG模式中,否则它是不可见的也不能被外界调用。
只有拥有虚函数的类才会拥有虚函数指针,每一个虚函数也都会对应一个虚函数指针。所以拥有虚函数的类的所有对象都会因为虚函数产生额外的开销,并且也会在一定程度上降低程序速度。与JAVA不同,C++将是否使用虚函数这一权利交给了开发者,所以开发者应该谨慎的使用。
3.1 虚函数特征
- 当在基类中定义了虚函数时,如果派生类没有定义新的函数来遮蔽此函数,那么将使用基类的虚函数。
- 将基类中的函数声明为虚函数,这样所有派生类中具有遮蔽关系的同名函数都将自动成为虚函数。
- 只需要在虚函数的声明处加上 virtual 关键字,函数定义处可以加也可以不加。
- 只有派生类的虚函数覆盖基类的虚函数(函数原型相同)才能构成多态(通过基类指针访问派生类函数)。
- 构造函数不能是虚函数。对于基类的构造函数,它仅仅是在派生类构造函数中被调用,这种机制不同于继承。也就是说,派生类不继承基类的构造函数,将构造函数声明为虚函数没有什么意义。
3.2 虚函数表的生成
- 先将基类中的虚表内容拷贝一份到派生类虚表中;
- 如果派生类重写了基类中的某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数;
- 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类的虚表的最后。
3.3 不规范的重写行为
- 在派生类中依旧保持虚函数的属性,我们只是重写了它,这是非常不规范的。
- 在重写基类虚函数的时候,派生类的虚函数不加关键字virtual也可以构成重写,但是这种写法不规范,不建议这样使用。
补充:虚函数的主要目的是允许用基类的引用或指针调用派生类的实现,这称为动态绑定或延迟绑定。如果一个函数不是虚函数,那么编译器在编译时就会解析出函数的调用,这称为静态绑定或早期绑定。
4. 抽象类和纯虚函数
在C++中,纯虚函数是一种特殊的虚函数,它在基类中没有定义,只有声明。纯虚函数的声明形式如下:
virtual 返回值类型 函数名 (函数参数) = 0;
这里的= 0
就表示这是一个纯虚函数。纯虚函数在基类中没有实现,需要在派生类中被重写。
包含纯虚函数的类被称为抽象类(也叫接口类)。抽象类不能被实例化,也就是说,你不能创建一个抽象类的对象。这是因为抽象类包含至少一个没有实现的函数,所以抽象类的对象是不完整的。
当派生类继承了抽象类后,如果派生类没有重写所有的纯虚函数,那么这个派生类也还是一个抽象类,也不能被实例化。只有当派生类重写了所有的纯虚函数,这个派生类才不再是抽象类,可以被实例化。
纯虚函数的存在,规定了所有继承这个抽象类的派生类都必须实现这个函数,这就是所谓的接口继承。接口继承强调的是派生类必须实现的一组公共接口,而不是继承了一些已经实现的行为。
总的来说,抽象类和纯虚函数是面向对象多态性的一个重要机制,它使得基类可以定义接口,而将具体的实现留给派生类去完成。
5. 虚拟继承
在C++中,virtual
关键字在继承中的使用主要是为了解决多重继承中的菱形继承问题(Diamond Problem)。
菱形继承问题是指在多重继承过程中,一个类可能会通过多个路径继承到同一个基类,这会导致在最底层的派生类中,基类的成员会出现重复,造成资源浪费和可能的命名冲突。
当你使用public Animal
进行继承时,这是普通的公有继承。如果一个类通过多个路径继承了Animal
,那么在最底层的派生类中,Animal
的每个实例都会有一个副本。这可能会导致二义性和不必要的资源浪费。
例如,假设你有以下的类结构:
class Animal { public: void eat(); }; class Mammal : public Animal { }; class Bird : public Animal { }; class Bat : public Mammal, public Bird { };
在这个例子中,Bat
类通过Mammal
和Bird
类继承了Animal
类。这意味着在Bat
类的对象中,有两个Animal
类的实例。如果你调用Bat
对象的eat
方法,编译器将无法确定应该调用哪个Animal
实例的eat
方法,这就产生了二义性。
然而,如果你使用virtual public Animal
进行继承,这就是虚拟继承。虚拟继承确保无论一个类通过多少个路径继承了基类,基类在派生类中只有一个实例。这就解决了菱形继承问题。
以下是使用虚拟继承的版本:
class Animal { public: void eat(); }; class Mammal : virtual public Animal { }; class Bird : virtual public Animal { }; class Bat : public Mammal, public Bird { };
在这个例子中,Bat
类的对象只有一个Animal
类的实例,因此调用eat
方法时就不会有二义性了。
结语
在我们的编程学习之旅中,理解是我们迈向更高层次的重要一步。然而,掌握新技能、新理念,始终需要时间和坚持。从心理学的角度看,学习往往伴随着不断的试错和调整,这就像是我们的大脑在逐渐优化其解决问题的“算法”。
这就是为什么当我们遇到错误,我们应该将其视为学习和进步的机会,而不仅仅是困扰。通过理解和解决这些问题,我们不仅可以修复当前的代码,更可以提升我们的编程能力,防止在未来的项目中犯相同的错误。
我鼓励大家积极参与进来,不断提升自己的编程技术。无论你是初学者还是有经验的开发者,我希望我的博客能对你的学习之路有所帮助。如果你觉得这篇文章有用,不妨点击收藏,或者留下你的评论分享你的见解和经验,也欢迎你对我博客的内容提出建议和问题。每一次的点赞、评论、分享和关注都是对我的最大支持,也是对我持续分享和创作的动力。