设计模式(1)——面向对象和面向过程,封装、继承和多态
文章目录
- 一、day1
- 1. 什么是面向对象
- 2. 面向对象的三要素:继承、封装和多态
- 2.1 封装
- **2.1.1 封装的概念**
- **2.1.2 如何实现封装**
- **2.1.3 封装的底层实现**
- 2.1.4 为什么使用封装?(好处)
- **2.1.5 封装只有类能做吗?结构体如何封装?命名空间能实现封装吗?**
- 2.2 继承
- **2.2.1 继承的概念**
- **2.2.2 继承的主要作用**
- **2.2.3 如何实现继承**
- **2.2.4 构造函数和析构函数总结**
- **2.2.5 派生类和基类之间的特殊关系**
- **2.2.6 继承的底层实现**
- 2.2.7 **继承的类型**
- 2.2.8 **继承的优缺点**
- 2.3 多态
- **2.3.1 多态的概念**
- **2.3.2 多态的类型**
- **2.3.3 存在类继承的情况下,为何需要虚析构函数**
- **2.3.4 多态的底层实现**(虚函数表的实现)
- 2.3.5 使用虚方法时需注意的一些点
- 2.3.6 纯虚函数
- 2.3.7 动态联编和静态联编
一、day1
本节学习设计模式的前置知识,面向对象编程和面向过程编程的区别,以及面向对象编程的三大特征:封装、继承和多态。
参考:
设计模式 | 爱编程的大丙
封装、继承与多态究极详解(面试必问) - Further_Step - 博客园
C++ 动态联编和静态联编 - scyq - 博客园
1. 什么是面向对象
要学习设计模式,首先需要了解什么是面向对象,并掌握其三大要素:封装、继承和多态。我们可以通过一个简单的例子来说明:
假设我们想要把一头大象放进冰箱,这个过程可以分为三个步骤:1)打开冰箱门;2)把大象放进去;3)关上冰箱门。在面向过程的编程中,这三个步骤通常被抽象为三个函数,并在调用时按需提供参数。而在面向对象的编程中,需要围绕具体的对象进行设计。这里有两个关键对象:冰箱和大象。冰箱需要具备开门和关门的功能;大象则需要具备进入冰箱和离开冰箱的功能。
对象是类的实例。以大象为例,它的耳朵、鼻子、嘴巴等是属性,而“进入冰箱”“走出冰箱”或“跳起来”是行为。通过设计冰箱类和大象类,使它们具备相应的功能,就可以实现让大象进入冰箱的目标。从面向对象的角度来看,这个过程需要先调用冰箱对象的开门功能,再调用大象对象的进入功能,最后调用冰箱对象的关门功能。
B站up爱编程的大丙举了一个很形象的例子说明了面向过程和面向对象的区别:
假设现在有三个人:织女、牛郎和红娘。红娘想撮合牛郎和织女,她可以采用两种编程思路:面向过程和面向对象。
面向过程编程:
- 红娘把牛郎的牛牵到河边。
- 红娘把织女的纺车放到牛郎的牛车上。
- 红娘告诉牛郎去找牛。
- 红娘告诉织女去找纺车。
- 牛郎和织女在河边相遇,一见钟情。
- 两人过上了幸福的生活。
在这个场景中,前四步是由红娘主导完成的,后两步则是牛郎和织女的互动。如果用代码实现,每一步都会对应一个函数,函数需要传入必要的参数。例如,在第一个函数中,我们忽略了红娘这个主语,仅仅实现了“将牛牵到河边”的功能。
面向对象编程:
- 红娘:牛郎,能借你的牛用一下吗?
牛郎:好的,我去牵牛。 - 红娘:织女,能借你的纺车用一下吗?
织女:没问题,我去搬纺车。
随后发生了意外:
- 牛郎:呀!牛丢了,我得赶紧去找牛。
- 织女:呀!纺车丢了,我得赶紧去找纺车。
最终,牛郎和织女相遇并交流:
- 牛郎:织女,我知道108种牛肉做法,要不要尝尝?
- 织女:我会做很多漂亮的衣服,你想不想试试?
- 牛郎:那我们结婚吧!
- 织女:好的!
在面向对象的思路中,我们会将场景中的对象抽象出来。例如:
- 牛和牛车是牛郎的属性,牵牛、找牛、说话、结婚是牛郎的行为。
- 纺车是织女的属性,搬纺车、找纺车、说话、结婚是织女的行为。
- 红娘负责协调和推动整个事件的发生,这是她的行为。
面向对象编程的本质
面向对象编程的核心是将属性和行为解耦,明确属性和行为分别属于哪个对象。基于这些属性和行为,定义相应的类,例如牛郎类和织女类。类是模板,实例化类就会生成具体的对象(如具体的牛郎和织女)。通过对象,我们可以调用类中定义的属性和行为。
相比之下,面向过程编程没有定义牛郎、织女和红娘的类,所有的步骤都通过函数一步步实现。虽然这种方式简单直观,但随着功能复杂度的增加,函数体会变得冗长且难以维护,增加了出错的可能性。而在面向对象中:
- 织女类只处理与自己相关的行为,例如搬纺车、找纺车、说话和结婚。
- 牛郎类同样专注于自己的行为,例如牵牛、找牛、说话和结婚。
这种分工明确的设计,让代码更加模块化、可维护,也更贴近真实场景的逻辑。
总结
- 面向过程编程(POP):是一种依赖于函数调用和过程的编程范式。在POP中,程序通过执行一系列步骤(函数调用)来达到目标。数据和操作这些数据的功能是分开的。程序的核心是通过操作全局数据来进行的。
- 面向对象编程(OOP):将数据和操作这些数据的功能封装在一起,构成一个“对象”。面向对象的程序是由对象组成的,这些对象通过消息(方法调用)与其他对象交互。
2. 面向对象的三要素:继承、封装和多态
面向对象编程有三大特征:封装、继承和多态。
- 封装(Encapsulation):封装确保对象中的数据安全,通过将数据和操作数据的方法封装在一个对象中,避免外部直接访问对象的数据。
- 继承(Inheritance):继承保证了对象的可扩展性,子类可以继承父类的属性和方法,并且可以在此基础上进行扩展。
- 多态(Polymorphism):多态保证了程序的灵活性,允许不同类型的对象对于相同的消息作出不同的响应。
封装是类的一个天然特性,就像一个盒子天生可以用来装东西。类通过封装,将数据和方法保护起来,对外只提供必要的接口,从而提高了代码的安全性和可维护性。
继承是类之间的一种重要关系。尽管类之间还可以有其他关系,例如关联、依赖、实现、聚合和组合,但我们常强调继承。这是因为继承不仅是一种特殊的关系,还为类之间的代码复用提供了基础。事实上,实现可以看作是继承的一种特例,而其他关系更像是根据需求将类放在不同位置灵活组合。需要注意的是,这些关系在 C 的结构体中也可以实现,结构体并不是 C++ 的独创。但继承不同,它是一种全新的机制,需要在设计时明确约定规则。
继承的一个重要作用是引入多态性。通过继承,不同的子类可以在运行时根据相同的消息动态决定使用哪个方法,这使得资源分配更加灵活。这种多态性是继承的延伸,是面向对象编程的一大核心特点。
总结来说,封装是类的内在特性,继承是类之间的一种新型关系,而多态则是继承带来的资源分配新规则。这三者正是 C++ 相较于 C 的主要创新点,也为从面向过程编程转向面向对象编程提供了强有力的支持。
2.1 封装
2.1.1 封装的概念
在面向对象编程中,封装是将数据和方法绑定到一个对象中,并通过控制数据的访问来保证对象内部的一致性和安全性。
封装的基本思想是隐藏内部实现细节,暴露必要的接口。封装有两个主要方面:
- 数据隐藏:只允许通过公开的接口(方法)访问和修改数据。这样可以避免外部代码直接修改对象的内部状态,减少错误的发生。
- 接口与实现分离:对象暴露的是一组操作数据的接口,而不是数据本身。外部只关心如何使用这个对象提供的功能,而不需要了解它的内部实现。
2.1.2 如何实现封装
在C++中,封装是通过类和访问修饰符(如public
、private
、protected
)来实现的。
- public:类的公共部分,外部可以访问和修改。
- private:类的私有部分,外部无法直接访问,只能通过类提供的公有方法来间接访问。
- protected:类似于private,但允许派生类(子类)访问。
2.1.3 封装的底层实现
从底层的角度看,封装的实现通常依赖于内存布局和访问控制机制。在C++中,类的成员变量通常会在对象实例化时分配内存。通过访问控制(private
、public
)和get
、set
方法,编译器帮助开发者实现了对数据访问的精细控制。
- 内存分配:每个对象都有独立的内存区域来存储成员变量。当对象被创建时,内存会分配给它的所有成员变量。
private
和public
只是影响这些成员在外部代码中的访问方式,实际的内存布局不会变化。 - 访问控制:
private
、public
和protected
是由编译器支持的访问权限控制机制,确保类的私有数据只能通过特定的公有方法来修改。编译器会在编译时检查是否有非法访问的代码,防止程序出现不可预期的行为。
2.1.4 为什么使用封装?(好处)
- 数据保护:封装隐藏了数据的实现,外部无法直接改变对象的内部状态,防止了误操作或非法操作。
- 提高代码可维护性:通过暴露清晰的接口和隐藏复杂的内部实现,程序更加模块化。如果需要改变实现细节,只需要修改类的内部代码,不会影响到其他依赖这个类的代码。
- 提高安全性:封装可以确保对象的一致性和有效性。比如,
withdraw
方法中检查提款金额是否合理,确保余额不被非法提取。
2.1.5 封装只有类能做吗?结构体如何封装?命名空间能实现封装吗?
除了类之外,结构体和命名空间也可以实现一定程度的封装:
-
在类中,编译器通过访问修饰符(如
public
、private
、protected
)来实现封装。 -
struct
和class
本质上是相似的,唯一区别是:class
的成员默认是private
struct
的成员默认是public
-
命名空间(
namespace
)主要用于逻辑上的分组和避免名字冲突,但它不能像类一样提供访问控制。通过命名空间,也可以实现一种“伪封装”,但没有访问权限控制。namespace MyNamespace { namespace Detail { // 内部命名空间,相当于隐藏的实现 int hiddenFunction(int x) { return x * x; } } int publicFunction(int x) { return Detail::hiddenFunction(x) + 10; } }
虽然
Detail::hiddenFunction
仍然可以被访问,但在设计上约定为只在MyNamespace
内部使用
2.2 继承
2.2.1 继承的概念
继承是面向对象编程中的一种机制,它允许我们创建一个新的类,该类可以继承自一个或多个已存在的类。被继承的类称为父类(或基类),新创建的类称为子类(或派生类)。子类继承了父类的属性和方法,并可以在此基础上进行扩展和修改。
派生类和基类的关系是一种 is-a
关系(公有继承),即派生类对象也是一个基类对象,可以对基类对象执行的任何操作,也可以对派生类对象执行。但不是 has-a
、is-like-a
、uses-a
和is-implemented-as-a
关系。
2.2.2 继承的主要作用
- 代码复用:子类无需重新定义父类已经实现的方法和属性,可以直接使用它们。
- 扩展性:子类可以在继承的基础上扩展功能,添加特有的行为。
- 层次化设计:继承允许程序员通过类层次结构来组织和简化代码。例如,
Dog
和Cat
都可以继承自Animal
,然后你可以根据需要为Dog
和Cat
添加各自的特殊行为。
2.2.3 如何实现继承
在C++中,继承通过class
和public
、protected
、private
修饰符来实现,不同的修饰符会影响父类成员在子类中的访问权限。
1.Public 继承
- 子类会继承父类的 公有成员 和 保护成员。
- 在子类中,父类的 公有成员 仍然是 公有的,可以直接访问。
- 父类的 保护成员 在子类中仍然是 保护的。
- 私有成员 虽然不能直接被子类访问,但仍然是子类的一部分,可以通过父类的 公有或保护方法 进行间接访问。
2.Protected 继承
- 子类会继承父类的 公有成员 和 保护成员。
- 在子类中,父类的 公有成员 会变成 保护的。
- 父类的 保护成员 保持不变,仍然是 保护的。
- 私有成员 和 Public 继承一样,不能直接访问,但仍然可以通过父类的相关方法间接访问。
3.Private 继承
- 子类会继承父类的 公有成员 和 保护成员。
- 在子类中,父类的 公有成员 和 保护成员 都变成了 私有的,只能在子类的内部访问。
- 私有成员 和前两种继承方式一样,不能直接访问,但仍然是子类的一部分,可以通过父类的方法间接访问。
因为派生类不能直接访问基类的私有成员,而必须通过基类的公有方法进行访问,因此基类的构造函数不能直接设置继承的私有成员,所以派生类构造函数必须使用基类构造函数。派生类构造函数的流程如下:
- 首先创建基类对象
- 派生类构造函数应通过成员初始化列表将基类信息传递给基类构造函数
- 派生类构造函数应初始化派生类新增的数据成员
2.2.4 构造函数和析构函数总结
- 创建派生类对象时,程序首先调用基类构造函数,然后再调用派生类构造函数,派生类的构造函数总是调用一个基类构造函数。
- 派生类对象过期时,程序将首先调用派生类析构函数,然后再调用基类析构函数。
2.2.5 派生类和基类之间的特殊关系
- 派生类对象可以使用基类的方法,条件是方法不是私有的(只能是公有或保护的)。
- 基类指针可以在不进行显示类型转换的情况下指向派生类对象。
- 基类引用可以在不进行显示类型转换的情况下引用派生类对象。
class TableTennisPlayer{
// .......
}
class RatedPlayer : public TableTennisPlayer{
// .......
}
假设有上述继承关系,那么基类的指针和引用可以在不进行显示类型转换的情况下指向或引用派生类对象:
TableTennisPlayer* pt = &RatedPlayer;
TableTennisPlayer& rt = RatedPlayer;
但注意,基类指针或引用只能用于调用基类方法或成员,不能使用 rt
或 pt 来
调用派生类的方法。通常,C++要求引用和指针类型与赋给的类型匹配,但这一规则对继承来说是例外。
可以说基类的指针和引用可以在不进行显示类型转换的情况下指向或引用派生类对象,派生类指针或引用不能指向或引用基类对象;也可以说派生类对象可以复制或赋值给基类对象(只针对二者共有的成员),但不能说不能将基类对象赋值或复制给派生类对象(虽然系统没有默认函数支持,但我们可以定义重载函数实现,不过一般情况下是不允许将基类对象赋值或复制给派生类对象的)。
基类和派生类还可以进行转换:
- 将派生类引用或指针转换为基类引用或指针被称为向上强制转换(upcasting),这使得公有继承不需要进行显式类型转换,该规则是
is-a
关系的一部分。 - 将基类指针或引用转换为派生类指针或引用称为向下强制转换(downcasting)。如果不使用显式类型转换,则向下强制转换是不被允许的,原因是
is-a
关系通常是不可逆的。
但我们可以通过显式强制转换将基类指针或引用转换为派生类指针或引用,但这可能会带来不安全的操作,因为派生类的一些方法在基类中可能不存在。如下代码:
Base t1; // 基类
Baseplus* t2 = (Baseplus*)&t1; // 将基类强制转换为派生类
t2->print();
如果 print()
是 虚函数,此时调用的是 基类的版本,并不会因为强制转换调用派生类的 print
函数,而是因为 t1
是一个 基类对象,它的虚函数表(vtable)指向的是基类的虚函数表。即使通过强制转换获得了一个派生类指针,虚函数调用依然由对象的动态类型(这里是 Base
)决定,而不是指针的静态类型。
如果 print()
不是虚函数,则调用的是指针类型(即 Baseplus*
)对应的函数版本。在这种情况下,结果是未定义行为,因为 t1
是基类对象,但你尝试通过派生类指针调用派生类的方法,可能会访问未初始化的派生类成员。
2.2.6 继承的底层实现
在底层,继承通过对象布局和指针偏移来实现。每个对象都有一个虚函数表(vtable),用于支持多态(如果使用了虚函数)。当你创建一个子类对象时,它不仅包含自己的数据成员,还会包含父类的数据成员(如果父类有数据成员的话)。
内存布局:
- 对象的内存布局包含了父类部分和子类部分。父类的成员变量和成员函数会先存储在内存中,子类会在父类的基础上添加额外的成员。
- 如果有虚函数,编译器会为类创建一个虚函数表,虚函数表包含所有虚函数的指针,确保子类能够重写(覆盖)父类的虚函数。
示例内存布局:
假设有以下类继承关系:
A
是基类,B
是从A
继承的子类,C
是从B
继承的子类。
内存布局 | 说明 |
---|---|
A 类的成员 | 基类 A 中的成员数据存储在内存中 |
B 类的成员 | 子类 B 扩展的成员数据存储在内存中 |
C 类的成员 | 子类 C 扩展的成员数据存储在内存中 |
2.2.7 继承的类型
继承可以分为不同类型,常见的包括:
- 单继承:子类只继承一个父类。
- 多重继承:子类可以继承多个父类。
- 多级继承:子类继承自父类,孙类继承自子类等。
2.2.8 继承的优缺点
优点:
- 代码重用:子类继承父类的行为,可以减少代码重复,提升代码复用性。
- 模块化设计:通过继承可以构建层次结构,使得代码更具组织性。
- 扩展性:子类可以继承父类的功能,并在此基础上扩展或重写,满足更多需求。
缺点:
- 紧密耦合:继承会导致类之间的紧密耦合,子类对父类的依赖较强,修改父类可能影响子类的行为。
- 继承层次复杂:多层继承可能导致类关系复杂,尤其是多重继承时,可能出现二义性(例如“菱形继承问题”)。
- 不利于灵活性:过度使用继承可能导致代码不易扩展或维护,过度继承会使类层次过于复杂。
2.3 多态
2.3.1 多态的概念
多态(Polymorphism)是面向对象编程(OOP)中的一个核心概念,它允许不同类的对象通过相同的接口(方法名)来调用不同的实现。简单来说,多态使得不同类型的对象可以通过相同的接口执行不同的操作。多态性使得程序更加灵活和可扩展。
有两种机制可用于实现多态公有继承:
- 在派生类中重新定义基类的方法。这种方式不需要额外的语法支持,但只有当通过子类对象直接调用方法时,才能体现多态性。通过基类的指针或引用调用时,仍然会调用基类的方法。
- 使用虚方法,基类中将函数声明为
virtual
,派生类可以重写该函数。当通过基类的指针或引用调用时,会根据对象的实际类型调用重写后的函数,而不是基类的版本。
但注意:
- 虚函数必须通过基类的指针或引用调用,才能实现动态绑定,即调用派生类中重写后的方法。
- 如果直接通过对象调用,不管有没有使用虚函数,无论基类还是派生类对象,调用的都是对象所属类的版本。
- 没有被重写的虚函数,调用时会使用基类的默认实现。
- 如果需要在派生类中调用基类的版本,必须显式指定
Base::
,否则会调用派生类重写的方法。
#include <iostream>
using namespace std;
class Animal {
public:
virtual void makeSound() { // 虚函数
cout << "Animal makes a sound" << endl;
}
};
class Dog : public Animal {
public:
void makeSound() override { // 重写虚函数
cout << "Dog barks" << endl;
}
};
class Cat : public Animal {
public:
void makeSound() override { // 重写虚函数
cout << "Cat meows" << endl;
}
};
class Bird : public Animal {
public:
void makeSound() override { // 重写虚函数
cout << "Bird meows" << endl;
// 规则4
Animal::makeSound(); // 显式调用基类的 makeSound() 方法
}
};
int main() {
// 规则1
Animal* animal1 = new Dog();
Animal* animal2 = new Cat();
animal1->makeSound(); // 输出: Dog barks
animal2->makeSound(); // 输出: Cat meows
delete animal1;
delete animal2;
// 规则2
Dog dog();
Cat cat();
dog().makeSound(); // 输出: Dog barks
cat().makeSound(); // 输出: Cat meows
return 0;
}
上段代码中分别对规则1 和规则 2进行的描述,如果我们通过基类的引用或指针调用,则程序将根据引用或指针指向的对象类型来选择方法(使用了虚函数);如果直接通过派生类对象调用,即使没有使用虚函数,也会调用派生类的方法。
2.3.2 多态的类型
- 编译时多态(静态多态):在编译时决定调用哪个函数,常见的实现方式是方法重载(Overloading)和运算符重载(Operator Overloading)。
- 运行时多态(动态多态):在程序运行时决定调用哪个函数,常通过虚函数和继承实现。运行时多态通常通过虚函数来实现。虚函数是基类中声明为
virtual
的函数,子类可以重写这个函数。当通过基类指针或引用调用该函数时,程序会根据对象的实际类型(而不是指针或引用的类型)来决定调用哪个函数实现。
2.3.3 存在类继承的情况下,为何需要虚析构函数
使用虚析构函数是为了确保析构函数序列被正确调用。如果基类的析构函数不是虚函数,通过基类指针删除派生类对象时,只会调用基类的析构函数,而不会调用派生类的析构函数。这样可能导致派生类中动态分配的资源没有正确释放,进而产生资源泄漏。如下:
#include <iostream>
using namespace std;
class Base {
public:
~Base() { // 非虚析构函数
cout << "Base destructor called" << endl;
}
};
class Derived : public Base {
public:
~Derived() {
cout << "Derived destructor called" << endl;
}
};
int main() {
Base* ptr = new Derived(); // 基类指针指向派生类对象
delete ptr; // 只调用了基类的析构函数
return 0;
}
在上段代码中,Derived
类的析构函数没有被调用,因此派生类持有的资源无法正确释放。输出如下:
Base destructor called
将基类析构函数设为虚函数,可确保先调用派生类析构函数,再调用基类析构函数,结果如下:
class Base {
public:
virtual ~Base() { // 虚析构函数
cout << "Base destructor called" << endl;
}
};
// 输出
Derived destructor called
Base destructor called
2.3.4 多态的底层实现(虚函数表的实现)
多态的底层实现依赖于虚函数表(vtable)。每个包含虚函数的类,在编译时会生成一个虚函数表,其中存储着类的所有虚函数指针。当通过父类指针调用虚函数时,程序会查找虚函数表,找到对应的子类实现并调用。
虚函数表的工作原理:
我们一般利用虚表和虚表指针来实现动态绑定,那么具体是如何实现的?
通常,编译器处理虚函数的方法是:给每个对象添加一个隐藏成员。隐藏成员中保存了一个指向函数地址数组的指针,这种数组称为虚函数表。虚函数表中存储了该类所有虚函数的地址。例如,基类对象包含一个指针,该指针指向基类中所有虚函数的地址表。派生类对象将包含一个指向独立地址表的指针。如果派生类重新定义了基类的虚函数,虚函数表会更新该函数的地址,指向派生类的新定义;如果派生类没有重写基类的虚函数,虚函数表会保留基类的虚函数地址。如果派生类新增了虚函数,这些虚函数的地址会被添加到虚函数表中。
注意,无论类中包含的虚函数是1个还是10个,对象中的隐藏指针始终只有一个,占用固定的内存,只是指向表的大小不同而已。虚表是属于类的,而不是属于某个具体的对象,一个类只需要一个虚表即可。同一个类的所有对象都使用同一个虚表。
如上图,我们定义了基类 Scientist
,并声明了两个虚函数 show_name()
和 show_all()
。同时定义了一个继承自 Scientist
的子类 Physicist
,子类中重定义了 show_all()
并新增了虚函数 show_field()
。
基类 Scientist
和派生类 Physicist
的虚函数表分别如下图所示:
基类 Scientist
中声明了两个虚函数,所以它的虚函数表存在两个徐函数地址 4064 和 6400,且虚函数表的地址为 2008;派生类 Physicist
中将虚函数 show_all()
重新定义,并声明了新的虚函数 show_field()
,所以它的虚函数表中更新 show_all()
的地址为 6820,并新增了对应 show_field()
的地址 7280,且它的虚函数表地址为 2096。
并且二者都有一个隐藏的指针成员用于指向各自的虚函数表,如下图所示:
基类 Scientist
的内存空间如上图所示,其私有成员 name
的地址存储内容为 Sopjoe Fant
,但它还有一个隐藏指针成员 vptr
用于指向它的虚函数表;同样,派生类 Physicist
中也有一个隐藏指针成员 vptr
用于指向它的虚函数表,同样它的私有成员 field
内容为 Nuclear Structure
。
那么调用虚函数时,虚函数表是如何作用的呢?
调用虚函数时,程序将查看存储在对象中的虚函数表地址,然后转向相应的函数地址表。如果使用类声明中定义的第一个虚函数,则程序将使用数组中的第一个函数地址,并执行具有该地址的函数。如果使用类声明中的第三个虚函数,程序将使用地址为数组中第三个元素的函数。如下图所示:
当我们调用派生类 Physicist
的虚函数 show_all()
时,我们首先获取派生类 Physicist
的隐藏指针成员 vtpr
指向的地址 2096,并前往该处获取对应的虚函数表,然后我们依据顺序获悉表中对应函数的地址 6820(由于虚表在编译阶段就可以构造出来了,所以可以根据所调用的函数定位到虚表中的对应条目),编译器前往 6820 处执行这里的虚函数。
注意:非虚函数的调用不用经过虚表,故不需要虚表中的指针指向这些函数。而且虚函数需要消费一定的资源,所以无继承以及无虚函数的情况下,虚函数表不会生成。
什么时候会执行函数的动态绑定?这需要符合以下三个条件。
- 通过指针来调用函数
- 指针
upcast
向上转型(继承类向基类的转换称为upcast
) - 调用的是虚函数
2.3.5 使用虚方法时需注意的一些点
- 构造函数不能是虚函数。创建派生类对象时,将调用派生类的构造函数,而不是基类的构造函数,然后,派生类的构造函数将使用基类的一个构造函数,这种顺序不同于继承机制。因此,派生类不继承基类的构造函数,所以将类构造函数声明为虚函数没有意义。
- 析构函数应当是虚函数,除非类不用做基类。
- 友元不能是虚函数,因为友元不是类成员,而只有成员才能是虚函数。
- 如果派生类没有重新定义函数,将使用该函数的基类版本。
- 派生类重新定义函数会隐藏基类方法。
前面四条很浅显易懂,这里详细说一下第五条。第五条有以下两个个规则:
- 如果基类的函数被声明为
virtual
,而派生类定义了一个函数名、参数列表和返回类型完全相同的函数,那么派生类的函数将覆盖基类的函数。 - 如果基类和派生类的函数名相同,但参数列表不同,则派生类的函数会隐藏基类的同名函数,无论基类的函数是否是
virtual
。
隐藏、覆盖和重载是三个不同的概念。重载发生在同一个类内,通过定义参数列表不同的同名函数实现。隐藏和覆盖则出现在基类与派生类之间。
当派生类重新定义基类中的虚函数时:
- 如果参数列表(特征标)相同,派生类的函数会覆盖基类的虚函数。
- 如果参数列表不同,派生类的函数会隐藏基类的虚函数。
如果基类的函数被隐藏或覆盖了,但仍需要调用,使用基类类名加作用域运算符::
,显式调用基类的函数。
2.3.6 纯虚函数
纯虚函数(Pure Virtual Function)是C++中的一种特殊成员函数,通常用于定义抽象类,为派生类提供一个必须实现的接口。抽象类不能实例化。它的定义形式在基类中包含= 0
的语法,例如:
virtual void display() = 0;
- 不能在基类中实现:纯虚函数不包含函数体,只定义接口,具体实现必须由派生类完成。
- 定义抽象类:包含纯虚函数的类称为抽象类,不能直接实例化。
- 派生类的义务:派生类必须重写所有继承的纯虚函数,否则派生类本身也会变成抽象类。
在原型中使用 =0
指出类是一个抽象基类,在类中不可以定义该函数,应在派生类中定义。
纯虚函数的主要作用是定义接口规范,强制要求派生类必须实现这些函数,从而实现接口的统一和标准化。
举个例子说明:
假设我们要设计一个绘图系统,可以绘制不同的形状,如圆形、矩形等。每种形状都有一个draw()
函数负责绘图,但每种形状的绘图方式不同。我们可以用纯虚函数实现:
#include <iostream>
#include <vector>
using namespace std;
// 抽象类:Shape
class Shape {
public:
virtual void draw() = 0; // 纯虚函数,强制派生类实现
virtual double area() = 0; // 纯虚函数,计算面积
virtual ~Shape() {} // 虚析构函数,确保按正确顺序释放资源
};
// 派生类:Circle
class Circle : public Shape {
private:
double radius;
public:
Circle(double r) : radius(r) {}
void draw() override {
cout << "Drawing a Circle with radius: " << radius << endl;
}
double area() override {
return 3.14159 * radius * radius;
}
};
// 派生类:Rectangle
class Rectangle : public Shape {
private:
double length, width;
public:
Rectangle(double l, double w) : length(l), width(w) {}
void draw() override {
cout << "Drawing a Rectangle with length: " << length << ", width: " << width << endl;
}
double area() override {
return length * width;
}
};
int main() {
// 用基类指针管理不同的形状对象
vector<Shape*> shapes;
shapes.push_back(new Circle(5.0)); // 添加一个圆
shapes.push_back(new Rectangle(4.0, 6.0)); // 添加一个矩形
// 使用多态调用派生类实现
for (Shape* shape : shapes) {
shape->draw();
cout << "Area: " << shape->area() << endl;
}
// 释放资源
for (Shape* shape : shapes) {
delete shape;
}
return 0;
}
输出为:
Drawing a Circle with radius: 5
Area: 78.53975
Drawing a Rectangle with length: 4, width: 6
Area: 24
这样仅仅把抽象类 Shape
当作一个接口规范类,我们在每一个继承它的子类中都定义了专属于自身的实现(多态),而且因为抽象类中有一些共用的属性,所以相比单独的定义 Circle
、Rectangle
类,通过抽象类衍生派生类更加方便。
2.3.7 动态联编和静态联编
当我们在程序中写下一个函数并调用它时,编译器会决定如何执行这个函数。这一过程不仅仅是简单地“代码怎么写,编译器就怎么执行”。特别是在C++中,由于引入了函数重载、重写(虚函数)等机制,同一个函数名可能对应多个实现,因此编译器需要进一步确定到底调用哪个具体的函数实现。
什么是联编?
联编就是将程序中的函数调用与具体的函数实现关联起来的过程。通俗来说,联编相当于让程序知道“这个函数名对应的具体操作在哪里”。
在C语言中,联编相对简单:每个函数名唯一地对应一个函数实现,因此函数调用和具体实现之间的关系在编译时就能完全确定。但在C++中,函数重载(同名函数参数不同)和虚函数(子类覆盖父类方法)等特性增加了联编的复杂性,编译器需要更多信息来决定调用哪一个具体的函数实现。
联编的类型:
静态联编是在程序的编译阶段完成的,也叫早期联编。它在编译时确定函数调用与具体实现之间的关系,运行时无需再做额外的判断,效率较高。通常用于普通函数调用,包括非虚函数的调用和函数重载。编译器会根据函数名和参数列表,直接找到匹配的函数实现。代码执行时,已经明确知道调用的是哪段代码。
动态联编是在程序的运行阶段完成的,也叫晚期联编。它允许程序在运行时,根据实际的对象类型或上下文,动态选择函数的实现。动态联编通常用于虚函数的调用,因为在多态场景中,编译器无法在编译阶段确定具体调用的是哪个函数。编译器会为每个类生成一个虚函数表(vtable),运行时根据对象类型从虚函数表中查找并调用正确的函数。
虽然动态联编的灵活性很高,但是因为虚函数表的生成、调用需要消耗一定的资源,所以静态联编被用作C++的默认选择,因为静态联编在编译时完成,效率高于动态联编。