网站备案和icp备案,公司注册网站系统,wordpress 内部函数,百度代理合作平台文章目录 一、day11. 什么是面向对象2. 面向对象的三要素#xff1a;继承、封装和多态2.1 封装**2.1.1 封装的概念****2.1.2 如何实现封装****2.1.3 封装的底层实现**2.1.4 为什么使用封装#xff1f;#xff08;好处#xff09;**2.1.5 封装只有类能做吗#xff1f;结构体… 文章目录 一、day11. 什么是面向对象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关上冰箱门。在面向过程的编程中这三个步骤通常被抽象为三个函数并在调用时按需提供参数。而在面向对象的编程中需要围绕具体的对象进行设计。这里有两个关键对象冰箱和大象。冰箱需要具备开门和关门的功能大象则需要具备进入冰箱和离开冰箱的功能。
对象是类的实例。以大象为例它的耳朵、鼻子、嘴巴等是属性而“进入冰箱”“走出冰箱”或“跳起来”是行为。通过设计冰箱类和大象类使它们具备相应的功能就可以实现让大象进入冰箱的目标。从面向对象的角度来看这个过程需要先调用冰箱对象的开门功能再调用大象对象的进入功能最后调用冰箱对象的关门功能。 图片来源https://subingwen.cn/design-patterns/ 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 的成员默认是 privatestruct的成员默认是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 called2.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() {// 用基类指针管理不同的形状对象vectorShape* 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的默认选择因为静态联编在编译时完成效率高于动态联编。