面向对象编程概念,系统化地编写更好的代码
面向对象编程概念的介绍,不包含杂乱的定义,而是以逻辑布局呈现 OOP 概念,以便您可以立即掌握这些概念并将其应用于代码中。
引言
哈,又一篇关于面向对象编程的文章。你可能以为我会用枯燥的例子来讨论相同的 OOP 定义。就是那些你以前学过的定义和例子。也许是在课堂上,或者准备面试时。但今天,我不会重新表述那些零散的定义,而是给你一个简单的框架,让你更容易理解 OOP 的概念。
OOP 如果用猫、鸭子、动物等比喻来理解,就很容易。但有时候,这些比喻也难以接受。孩子怎么能拥有他父亲的技能?由于 DNA,可能有一些相似之处,但你会带一个医生父亲的儿子去做年度体检吗?
大多数 OOP 材料都充满了这类比喻。这些比喻和例子在向初学者介绍 OOP 概念时并没有错。但现有的资料只充斥着这类例子。它们从未在 OOP 例子的质量上有所进步。它们以比喻开始,也以比喻结束。
嗯,我大学期间也曾为 OOP 概念而苦苦挣扎。我理解 OOP 的概念,但当面对实际编写代码时,我就卡住了,不知道从何开始。有一次,我甚至想过放弃学习 OOP,但周围的世界不断告诉我OOP 的重要性。
如果你把所有的嵌入式开发者和底层 API 开发者加起来,他们只占宇宙中所有开发者的 10%。剩下的 90% 的开发者都在使用基于 OOP 的编程语言进行编程。
此外,面向对象编程技能将伴随你一生。相反,如果你被某个特定框架束缚两三年甚至五年,那么我的朋友,你就陷入了困境。如果那个框架被另一个优化过的框架取代,会怎么样?你将不得不从零开始。
但如果你了解面向对象的概念并在工作中牢固地应用了它们,那么你就拥有了终生的经验。你可以随时利用这些面向对象编程的经验。
此外,我也不想成为那种只写“胶水代码”、仅仅是 API 消费者类型的开发者。我想学习那些可以用来创造供他人使用的框架的技能。
那么,我如何掌握 OOP 的概念呢?很简单。需要不同的思维方式。在过去 10 年的编程经验中,我形成了一种面向 OOP 的思维方式,我将在以下模板或布局中展示这种思维方式。如果我要向新学生介绍 OOP 概念,我将使用这种系统化的方法。
- 背景:以前,人们厌倦了过程式编程,因为信息对所有人都是公开的。
- 所以,他们发明了一种隐藏信息的方法,称为封装。
- 他们需要在隐藏的同时进行通信,于是发明了通信方法:关联和继承。
- 通信伴随着依赖问题。解决方案是:抽象和多态。
- 设计模式的出现是为了降低依赖问题。 关于设计模式,我这里有另一篇文章。
OOP 的背景:信息无处不在
嗯,读完史蒂夫·乔布斯的传记后,我理解了计算机、操作系统和图形用户界面的演变。所有这些都帮助我理解了现代技术。因此,了解一些背景知识是必要的。
在软件开发的更大背景下,‘OOP’ 放在哪里?它是一个过程吗?是一种架构,还是别的什么?嗯,我现在问这些问题感觉很愚蠢,但有些人会问这些类型的问题,并且仍然感到困惑。面向对象编程是一种开发方法论。
开发者,尤其是年轻的开发者,只知道 OOP 是一种开发方法论。如果他们读一点历史,就会知道还有另一种人们已经放弃或试图放弃的开发方法论,那就是过程式开发方法论。COBOL、Fortran 和 Pascal 等过程式编程语言是我们编程先辈的默认选择,而在那之前,汇编语言编程是每个计算机科学家的必修课。
在过程式编程中,我们将一大堆指令分解成过程。就像 C# 中的函数一样。在过程式编程之前,还有另一种范例,那就是单体式编程。在单体式编程中,所有代码都写在一个大方法中,所有变量都定义在顶层位置。但随着程序规模的增长,管理单体式和过程式代码变得困难,因此 OOP 被发明出来。
背景信息够多了,让我们坚持我们的布局。但在深入之前,让我介绍一下类和对象。
人们总是问的一个常见问题是:“我们如何为代码设计类?”
类以及如何设计新类
想象一下,你打开了你最喜欢的编辑器,开始了一个新项目,或者正在处理新的需求,或者将你陈旧的过程式代码转换为面向对象代码。你会如何开始?
首先,你必须设计出你要使用的类。开发者最大的挑战是他们无法设计出一组好的类,或者不知道如何在 OOP 中开始编写代码,结果却写了一个包含 100 个变量和一百万个方法的巨型类。
那么,类是什么呢?类是定义你想法的“蓝图”。类可以代表一个物理对象,如椅子、屏幕、人类和动物。类可以代表一个角色,如学生和员工。类也可以代表一个抽象概念。例如,数学概念:圆、卡尔曼滤波器等。
类有名称、属性和行为。属性或数据可以是任何 基本数据类型。类名应代表你正在处理的领域中的一个概念。以下是一些根据用户需求设计类的示例。
示例 1:如果你打算开发一个制造工厂处理系统,会有哪些类?
这是我最初设计的类图
分析一下这个设计。你认为 Employee
类应该属于这组类吗?'Employee
' 不是一个糟糕的类名,但它有点脱离了上下文。因为我们在对 Process
进行建模,而 employee
不应该成为其中的一部分。
示例 2:设计可以测量汽车在减震器上受到的冲击的类。
这是我最初设计的模型
现在,看看 'Measure
' 这个类。它是一个动词,不能作为类的名称。它可以是类的一个函数。类名应该是名词。
我脑海中的想法是,悬架系统应该从减震器获取数据,并将模型应用于传感器值。但 'Measure
' 不是一个好的类名。取而代之的是,我应该使用 'DataAcquisition
' 作为类名。我已经将设计新类的技巧整理成 PDF。下载 PDF。
我还有另一个使用系统化方法应用面向对象原理的实际例子。在此处获取 报告。
对象:真实的事物
类只存在于文本编辑器中。你的计算机处理的是内存中的事物,而内存中唯一存在的事物是对象。一旦你按下运行按钮,你的计算机内存就会填充对象。
对象是通过构造函数创建的。构造函数是一种特殊的函数,其名称与类名相同,你创建的每个类都有一个不带参数的默认构造函数。
示例
Class MotorVehicle
{
String engineType;
// rest of the class code.
}
// The default constructor
MotorVehicle aVehicle = new MotorVehicle();
对象很重要,因为它们占据计算机内存,因此,理解它们的动态性很重要。让我们通过例子来理解对象动态。
示例 1
public class Automobile
{
int NumberOfWheels = 4;
int Weight = 200;
double fuelEfficiency = 1.2;
}
计算该类的对象将占用多少内存?
如果你使用的是 C# 或 Java,'int
' 是 4 字节,'double
' 是 8 字节。因此,该类的一个对象将占用 16 字节。如果你创建该 Automobile
类的 2 个对象,它将占用 32 字节。
现在考虑一个场景,一个新手开发者从一个巨大的数据库中提取记录,并为“Automobile
”的每条记录创建对象。假设代码运行的系统有 4GB 内存。进行计算,找出系统何时会抛出“内存不足”异常?
内存可容纳的记录数 = 4GB/16 = 67108864
因此,在大约 67108864 条记录时,系统内存将已满。
示例 2
public class Automobile
{
int NumberOfWheels = 4;
int Weight = 200;
double fuelEfficiency = 1.2;
static int countVehicles =33;
}
那么,一个对象的大小是多少?20 字节。5 个对象的大小是多少?
答案是 84 字节。16*5+4 = 84。内存中将只有一个 static
变量的副本。无论你创建多少个对象,内存中只会存在 static
变量的一个副本。
此外,这个 static
变量的单个副本可供所有对象访问。它有点像全局变量。我从痛苦的经历中学到的一件事是避免使用全局变量。随着代码的增长,追踪谁在修改全局变量的值变得困难。因此,在使用类中的 static
成员时要始终小心。
现在回到我们的布局。在了解了一些背景知识并理解了类和对象之后,让我们专注于 OOP 的第一个概念。
封装:隐藏信息
让我分享一个亲身经历。一天晚上,我接到老板的电话。我需要更新我们产品的演示,并在屏幕上显示一些参数。展会上的演示对营销很重要。通常,这是一个高压情况。所有工程师都指望我。
更新很简单。我需要添加两个文本框到显示中。我创建了一个类,由于这两个字段是相关的,我把它们放在一个类里,然后使用那个类的对象。我这样做是为了让代码更整洁。因为在这样紧张的情况下,你不能犯错。更新工作得很完美。
一位工程师用一种我多年来仍记得的方式称赞了我。他对我说:“哇,你创造了一个新变量!”他不是软件工程师,所以我们可以原谅他。
首先,我希望我的读者不要像那样,即把对象当作变量。其次,你可以看到我是如何使用封装来拯救这一天的。你将一个或多个数据和/或一个或多个方法合并成一个单一的实体,这就是封装。这就是我在演示代码中所做的——我封装了两个相关的数据。
封装也定义为数据隐藏/数据保护/数据安全机制。数据隐藏是通过 访问修饰符 实现的。如果一个数据成员使用 'private
' 访问修饰符定义,它将只能在类的边界内访问;如果访问修饰符是 public
,则任何人都可以访问。但这个定义需要澄清。让我们看看下面的例子是否属实。
示例 1
class StoreItem
{
private int itemPrice;
public int GetPrice()
{
return itemPrice;
}
public void SetPrice(int price)
{
itemPrice=price;
}
}
在这里,我定义了一个 private
数据成员,并通过一个 public
方法将它的访问权限给了所有人。物品价格被隐藏了吗?让我们看另一个例子。
示例 2
class StoreItem
{
public int itemPrice;
}
现在物品价格是公开的,任何人都可以访问。那么上面两个代码示例有什么区别?这不是数据隐藏,如果有人告诉你第一个例子是数据隐藏,那么你应该赶紧跑开。
在我看来,数据隐藏是通过 public
方法对内部数据进行受控访问。如果外部用户不知道你的类中有多少变量,但仍然通过 public
方法从你的类中获得期望的结果,那么你就是隐藏了数据。
示例 3
class Process
{
private int ingredient1;
private int ingredient2;
private int ingredientFromOutside1;
private int ingredientFromOutside2;
public int GetFinalProduct(){
int result = GetLocalProduct()+GetProductFromOutside();
return result;
}
private int GetLocalProduct()
{
return ingredient1+ingredient2;
}
private int GetProductFromOutside()
{
return ingrdientFromOuside1+ingredientFromOutside2;
}
}
在示例 3 中,有几个变量,我将它们隐藏起来,只提供受控访问。在类外部,没有人知道有多少数据成员或方法。他们只知道一个名为 GetFinalPoduct
的 public
方法,仅此而已。这样,我们就通过封装将数据隐藏起来了。
访问器
在示例 1 中,我只是暴露了实例变量,这被称为属性类型。有几种方法可以访问属性类型。
- 编写自己的
public
方法来访问成员变量(第一个例子) - 将成员变量设为
public
(第二个例子——切勿使用) - 使用访问器
编写自己的方法是一个好方法,但它不具扩展性。最合适的方法是使用访问器。Java 中的getter和setter方法以及 C# 中的属性类型称为访问器。使用访问器,你可以为类的用户提供统一的访问。
以下示例使用 C# 编程语言。
public class Automobile
{
int _weight = 200;
public int Weight
Get
{
return _weight;
}
Set
{
_weight = val;
}
}
在访问器代码中,你可以做很多事情。你可以在设置任何值之前进行任何验证,你可以更新或计算任何其他值,或者你可以直接从这里存储/检索值。
通过通信共享职责/信息
OOP 的主要目的是轻松编写大型软件代码。你将一个大问题分解成小问题,然后为这些小问题编写类并创建对象。
但许多人编写一个巨大的类,并在其中填充数十个职责。我认为这是因为来自过程式编程背景的人无法将职责分配给不同的类。你可以通过使用关联和继承将职责分配给不同的类来解决这个问题。
关联
考虑有两个类,它们执行不同的任务。通过关联,一个类利用另一个类的功能来完成一个共同的目标。就是这样。关联的目的是共享职责。
例如:
public class Member{
}
public Class Community{
Member aMember;
}
通过引用另一个对象,你的对象就能够调用被引用对象的方法。你可以说这两个公民联手完成了一个目标。
在本文中,我不会告诉你 UML 最新版本中关联、聚合和组合的复杂细节,或者它们之间的区别。对我来说,关联意味着两个类之间的职责共享。但如果你对细节感兴趣,这是一篇 关于这个的优秀文章。
如果类 A
的对象在与类 B
的对象通信以完成目标,那么类 A
就依赖于类 B
。在编程世界中,依赖被认为是一件坏事(现实世界中也没有人喜欢依赖!)。如何减少这种依赖,同时仍然让对象协同工作?这就是许多设计模式和原则被发明的根本问题。我将在文章后面讨论一些减少依赖的工具,但首先讨论另一个我们可以用来共享职责的工具。
继承
继承是关于在两个类之间分配职责:父类和子类。好处是子类继承了父类的所有特性。因为孩子使用父母的能力,所以说继承支持重用性。
让我们看一些继承的例子
正如你所见,我设计了 Vehicle
类及其子类,以便将两种车辆('car
' 和 'boat
')的共同特性组合在一个父类中,即 'vehicle
'。
现在,如果我想创建另一种专门的汽车,比如跑车,我可以扩展 'car
' 类并实现跑车特有的逻辑。
多么好的层级结构!这听起来很合乎逻辑,没有人会不为上述设计喝彩。但问题是,现实生活中的问题很少是这样的。现实世界的问题是复杂的,你编写的任何代码都会随着时间而演变。不断变化的需求会导致不断变化的继承层级。
我见过一些系统,开发者们一生都在管理庞大的继承层级。因此,继承不利于维护。但不要低估继承的力量。在某些情况下,使用继承是很自然的,例如 WinForms。
与组合类似,子类和父类之间存在依赖关系,而依赖关系在编程中是一件坏事。现在我将讨论如何最小化通信的副作用,即依赖。
减少通信时的依赖
抽象
我老板有一次让我更新我的一款软件。这次更新使得该软件能够从网络设备和串行设备接收数据。
为了实现这个更新,我需要处理很多事情。例如,我必须包含条件语句来确定操作的类型(串行或网络),并更新 I/O 方法,因为从网络设备和串行设备接收数据的方法是不同的。因此,这段代码依赖于微小细节。
但通过使用抽象,我设计我的代码,使我能够推迟实现细节。我抽象掉了微小的细节,现在我的代码依赖于抽象。因此,我的代码可以轻松地使用任何新的设备代码进行更新。
因此,通过抽象,实现了他能够轻松地与任何新代码通信而没有麻烦和依赖。
接口:抽象的第一个工具
将职责推迟给实现类(implementing classes)的概念称为抽象,而实现此功能的工具是 Java 或 C# 中的‘interface
’。
这就是我为上述示例定义的接口
Interface IStream
{
byte[] getData();
}
每个实现接口的类都必须定义接口中的方法。
NetworkStream :IStream
{
public byte[] getData()
{
byte[] data = NetworkCard.GetData();// read data from network
return data;
}
}
在这里,'interface
' 充当一个抽象层。它不定义行为或实现行为,而是给出行为应该是什么样子的提示或整体图景(即方法的签名)。它将定义行为的责任推迟给实现类。
在上面的例子中,任何想要充当 Stream
的类都必须实现 IStream
接口中的方法。这样,任何想要使用网络流的代码都将与实现类的接口进行交互。这是一个示例代码。
void CoreClassMethod()
{
IStream aDataStream =new NetworkStream(); // using the interface
Byte[] data = aDataStream.getData();
Display(data);
}
因此,如果将来,我的老板要求我扩展 CoreClassMethod
的能力来处理 PCI 数据,我将只需编写一个新的实现 IStream
接口的类,然后在 CoreClassMethod
中创建该类的实例。
PCIStream:IStream{
Public byte[] getData(){
// read data here using low level I/O libraries and return it.
}
}
/////////////////////////////////////////
void CoreClassMethod()
{
IStream aDataStream =new PCIStream(); // using the interface
Byte[] data = aDataStream.getData();
Display(data);
}
好处是,几个月前编写的代码可以与新编写的代码协同工作,而且工作量很小。框架中有许多实际的例子,例如 .NET Framework 中的 ISerializable
接口。接口的另一个好用途在这篇文章中有演示。
抽象类:抽象的第二个工具
abstract
类介于功能齐全的类和接口之间。你可以定义一些行为,并将一些行为推迟给其子类来定义。
例如
abstract class GeometricalObject
{
void showOnScreen()
{
// implementation steps
}
abstract void draw();
}
/////// Child 1:
class rectangle: GeometricalObject
{
void Draw()
{
//specifics of drawing a rectangle
}
}
/////// Child 2:
class circle:GeometricalObject
{
void Draw()
{
//specifics of drawing a circle
}
}
所有子类共享的代码写在 showOnScreen()
方法中。使所有上述接口和抽象成为可能的基本原理是“多态”。
多态
简单来说,多态就是父类引用持有指向其任何子类的引用的能力。父类可以是接口、abstract
类或功能齐全的类。因此,当你通过父类引用调用时,会调用适当子类上的所需方法。请参阅以下有关流式数据的代码示例。
Interface IStream
{
byte[] getData();
}
/////////////////////////////////
NetworkStream :IStream
{
public byte[] getData()
{
byte[] data = NetworkCard.GetData();// read data from network
return data;
}
}
////////////////////////////////
SerialStream :IStream
{
public byte[] getData()
{
byte[] data = SerialPort.GetData();// read data
return data;
}
}
// Now if I have the reference of the parent class which in this case is IStream
// I can call the method of any child class
// e.g.
IStream anObject = new NetworkStream();
data= anObject.getData();
//Change this in the run-time
anObject = new SeriaStream();
data=anObject.getData();
因此,这些是减少对象之间通信造成的损害的基本工具。这些工具帮助我们减少对象之间的依赖。
结束语
因此,在这篇 OOP 文章中,我没有给你定义列表。但我试图为你提供一个 OOP 概念的布局,你可以用它来理解 OOP 最重要的概念。本文回答了大多数“为什么”的问题和一些“如何”的问题。为什么需要封装?为什么需要抽象?如何在减少通信副作用的同时进行通信。我希望这个布局能让你对 OOP 概念有一个不同的视角。