设计模式(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;// 规则4Animal::makeSound(); // 显式调用基类的 makeSound() 方法}
};int main() {// 规则1Animal* animal1 = new Dog();Animal* animal2 = new Cat();animal1->makeSound(); // 输出: Dog barksanimal2->makeSound(); // 输出: Cat meowsdelete animal1;delete animal2;// 规则2Dog dog();Cat cat();dog().makeSound(); // 输出: Dog barkscat().makeSound(); // 输出: Cat meowsreturn 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++的默认选择,因为静态联编在编译时完成,效率高于动态联编。
相关文章:

设计模式(1)——面向对象和面向过程,封装、继承和多态
文章目录 一、day11. 什么是面向对象2. 面向对象的三要素:继承、封装和多态2.1 封装**2.1.1 封装的概念****2.1.2 如何实现封装****2.1.3 封装的底层实现**2.1.4 为什么使用封装?(好处)**2.1.5 封装只有类能做吗?结构体…...
培训机构Day24
今天讲了一些javaee比较过时的技术,虽然已经过时,该学的还得学学。 知识点: http://localhost:8080/demo01/demo1?a1&b2&c3 pattern: /demo1 上下文路径:ContextPath,/demo01,不包含请求参数。 …...

1/7 C++
练习:要求在堆区连续申请5个int的大小空间用于存储5名学生的成绩,分别完成空间的申请、成绩的录入、升序排序、成绩输出函数,并在主程序中完成测试 要求使用new #include <iostream>using namespace std; double *addr_new() {double …...

C语言初阶习题【23】输出数组的前5项之和
1. 题目描述 求Snaaaaaaaaaaaaaaa的前5项之和,其中a是一个数字, 例如:222222222222222 2.思路 分析下,222222222222222,怎么把它每一项算出来 2 210222 22102222 2221022222 我们的多项式就是a a*102,…...

Android audio(1)-音频模块概述
Audio模块是Android系统的重要组成部分,在 Android 中负责音频路由,数据处理,音频控制,音频设备管理/切换。 下面的内容大多翻译自android官网,读者可跳过阅读后面的博客。 一、系统架构 下图说明了音频模块的组成,并指出各组成部分所涉及的相关源代码。所谓架构就是说模…...

园林与消防工程:选择正确工程项目管理软件的重要性
在园林与消防工程领域,选择正确的工程项目管理软件对于提高项目效率、优化资源配置以及确保项目质量至关重要。以下是对园林与消防工程中选择正确工程项目管理软件重要性的详细分析: 1.提升项目管理效率 实时监控与跟踪:工程项目管理软件能够…...
分布式环境下定时任务扫描时间段模板创建可预订时间段
🎯 本文详细介绍了场馆预定系统中时间段生成的实现方案。通过设计场馆表、时间段模板表和时间段表,系统能够根据场馆的提前预定天数生成未来可预定的时间段。为了确保任务执行的唯一性和高效性,系统采用分布式锁机制和定时任务,避…...

SQL刷题笔记——高级条件语句
目录 1题目:SQL149 根据指定记录是否存在输出不同情况 2 作答解析 3 知识点 3.1 count函数 3.2 内连接与左连接 1题目:SQL149 根据指定记录是否存在输出不同情况 2 作答解析 #正确答案 select uid, incomplete_cnt, incomplete_rate from (select …...
与 Oracle Dataguard 相关的进程及作用分析
与 Oracle Dataguard 相关的进程及作用分析 目录 与 Oracle Dataguard 相关的进程及作用分析与 Oracle Dataguard 相关的进程及作用分析一、主库的进程1、LGWR 进程2、ARCH进程3、LNS 进程 二、备库的进程1、RFS 进程2、ARCH3、MRP(Managed Recovery Process&#x…...

游戏语音趋势解析,社交互动有助于营造沉浸式体验
语音交互的新架构出现 2024 年标志着对话语音 AI 取得了突破,出现了结合 STT → LLM → TTS 模型来聆听、推理和回应对话的协同语音系统。 OpenAI 的 ChatGPT 语音模式将语音转语音技术变成了现实,引入了基于音频和文本信息进行端到端预训练的模型&…...

美食烹饪互动平台
本文结尾处获取源码。 一、相关技术 后端:Java、JavaWeb / Springboot。前端:Vue、HTML / CSS / Javascript 等。数据库:MySQL 二、相关软件(列出的软件其一均可运行) IDEAEclipseVisual Studio Code(VScode)Navica…...

【51单片机零基础-chapter5:模块化编程】
模块化编程 将以往main中泛型的代码,放在与main平级的c文件中,在h中引用. 简化main函数 将原来main中的delay抽出 然后将delay放入单独c文件,并单独开一个delay头文件,里面放置函数的声明,相当于收纳delay的c文件里面写的函数的接口. 注意,单个c文件所有用到的变量需要在该文…...

Redis中的主从/Redis八股
四、Redis主从 1.搭建主从架构 不像是负载均衡,这里是主从,是因为redis大多数是读少的是写 步骤 搭建实例(建设有三个实例,同一个ip不同端口号) 1)创建目录 我们创建三个文件夹,名字分别叫700…...

ROS笔记
自定义消息的发布 1.创建空间包 1.创建ROS工作空间: mkdir -p ~/catkin_ws/src cd ~/catkin_ws/ catkin_make source devel/setup.bash 创建工作空间,编译设置环境 2.创建工作空间中的ROS包: cd ~/catkin_ws/src catkin_create_pkg your_pa…...
在 Linux 上调试 C++ 程序
在 Linux 上调试 C 程序是一个常见的开发任务,Linux 提供了多种强大的工具来帮助你进行调试。以下是常用的调试方法和工具. 1. 使用 GDB (GNU Debugger) GDB 是最常用且功能强大的命令行调试器,适用于 C、C 和其他语言。它允许你逐步执行代码、设置断点…...

让跨 project 联查更轻松,SLS StoreView 查询和分析实践
作者:章建(处知) 概述 日志服务 SLS 是云原生观测和分析平台,为 Log、Metric、Trace 等数据提供大规模、低成本、实时的平台化服务。SLS 提供了多地域支持 [ 1] ,方便用户可以根据数据源就近接入 SLS 服务࿰…...
20240107-类型转换
1. 自动类型转换 不损失数据精度的前提下,可自动完成变量的类型转换;不损失数据精度指不将超出变量可表示范围的值赋给该变量。 2.强制类型转换 若出现精度损失,java不会自动完成类型转换,需强制进行,见下代码的第8…...
关于Linux PAM模块下的pam_listfile
讲《Linux下禁止root远程登录访问》故事的时候,说好会另开一篇讲讲pam_listfile。我们先看看pam_listfile的man文档怎么介绍的。 下面这些就好比人物的简介,甚是恼人;让人看得不明就里,反正“他大舅他二舅都是他舅”。可以直接跳…...

OKHttp调用第三方接口,响应转string报错okhttp3.internal.http.RealResponseBody@4a3d0218
原因分析 通过OkHttp请求网络,结果请求下来的数据一直无法解析并且报错,因解析时String res response.body().toString() 将toString改为string即可!...

弱电与电力工程领域,如何通过工程项目管理软件提升效率
在弱电与电力工程领域,通过益企工程云等工程项目管理软件提升效率的方法主要体现在以下几个方面: 1.智能化管理 自动化流程:益企工程云通过自动化处理日常任务和流程,减少手动操作,提高工作效率。 智能预警ÿ…...

React项目在ios和安卓端要做一个渐变色背景,用css不支持,可使用react-native-linear-gradient
以上有个模块是灰色逐渐到白的背景色过渡 如果是css,以下代码就直接搞定 background: linear-gradient(180deg, #F6F6F6 0%, #FFF 100%);但是在RN中不支持这种写法,那应该写呢? 1.引入react-native-linear-gradient插件,我使用的是…...

网络拓扑如何跨网段访问
最近领导让研究下跟甲方合同里的,跨网段访问怎么实现,之前不都是运维网工干的活么,看来裁员裁到动脉上了碰到用人的时候找不到人了, 只能赶鸭子上架让我来搞 IP 网络中,不同网段之间的通信需要通过路由器,…...

基于深度学习的工业OCR实践:仪器仪表数字识别技术详解
引言 在工业自动化与数字化转型的浪潮中,仪器仪表数据的精准采集与管理成为企业提升生产效率、保障安全运营的关键。传统人工抄录方式存在效率低、易出错、高危环境风险大等问题,而OCR(光学字符识别)技术的引入,为仪器…...
Conda更换镜像源教程:加速Python包下载
Conda更换镜像源教程:加速Python包下载 为什么要更换conda镜像源? Conda作为Python的包管理和环境管理工具,默认使用的是国外镜像源,在国内下载速度往往较慢。通过更换为国内镜像源,可以显著提高包下载速度ÿ…...
阿里云云效对接SDK获取流水线制品
参考文档: API旧版 企业令牌 https://help.aliyun.com/zh/yunxiao/developer-reference/api-reference API新版 个人令牌 https://help.aliyun.com/zh/yunxiao/developer-reference/api-reference-standard-proprietary API 个人令牌 https://www.alibabacloud.com…...

MonitorSDK_性能监控(从Web Vital性能指标、PerformanceObserver API和具体代码实现)
性能监控 性能指标 在实现性能监控前,先了解Web Vitals涉及的常见的性能指标 Web Vitals 是由 Google 推出的网页用户体验衡量指标体系,旨在帮助开发者量化和优化网页在实际用户终端上的性能体验。Web Vitals 强调“以用户为中心”的度量,而不…...

Rust 和 Python 如何混合使用
Rust 与 Python 可以通过多种方式混合使用,如 FFI 接口、PyO3 库、CFFI、CPython API、wasm 模块嵌入等。这种混合开发模式可结合 Rust 的性能优势与 Python 的开发效率。其中,PyO3 是目前最受欢迎的桥接工具,它允许使用 Rust 编写 Python 扩…...

Dest建筑能耗模拟仿真功能简介
Dest建筑能耗模拟仿真功能简介 全球建筑能耗占终端能源消费的30%以上,掌握建筑能耗模拟是参与绿色建筑认证(如LEED、WELL)、超低能耗设计、既有建筑节能改造的必备能力。DEST作为国内主流建筑能耗模拟工具,广泛应用于设计院、咨询…...
5G 网络中的双向认证机制解析
一、5G 网络中的双向认证机制解析 在 5G 核心网中,双向认证是指UE(用户设备)与网络互相验证对方身份的过程。这一机制通过多层次的安全协议和密钥交换,确保通信双方的合法性,防止中间人攻击和身份伪造。 1. UE 存储的关键信息 UE 作为用户终端,存储以下核心安全信息:…...
【仿生系统】爱丽丝机器人的设想(可行性优先级较高)
非程序化、能够根据环境和交互动态产生情感和思想,并以微妙、高级的方式表达出来的能力 我们不想要一个“假”的智能,一个仅仅通过if-else逻辑或者简单prompt来模拟情感的机器人。您追求的是一种更深层次的、能够学习、成长,并形成独特“个性…...