目录
介绍
单一责任原则
开放原则
利斯科夫替代原则
接口隔离原理
依赖倒置原则
好莱坞原则
多态性
信息专家
创造者
纯粹制造
控制器
优先使用组合而不是继承
间接
不要重复自己(DRY)
学习SOLID,GRASP和其他核心的面向对象的设计OOD原则,使用独立于语言的方式,以简单的方式给其他开发人员留下深刻的印象
介绍我将以陈词滥调开始。
软件代码应描述以下品质:
- 可维护性
- 可扩展性
- 模块化
- 等等
当您询问有关任何特定代码是否描述以上质量特征的问题时,您可能会发现自己陷入困境。
一种有用的技术是查看任何软件的开发时间表。如果软件代码在其生命周期内更易于维护,扩展和模块化,那么这意味着代码具有以上质量特性。
我写的难以阅读,难以扩展和破坏软件代码。在发生变化的六个月后,我才知道这一点。因此,开发时间表对于理解质量因素非常重要。
但是这种技术(查看开发时间表)只能通过回顾过去来应用,但我们希望将来在某个地方使用高质量的软件。
了解质量的高级开发人员没有这个问题。当他们看到他们的代码具有初级开发人员梦寐以求的品质因素时,他们会感到自豪。
因此,高级开发人员和专家已经提出了一系列原则,初级开发人员可以应用这些原则来编写高质量的代码并在其他开发人员面前展示:)。如果您想写下您的原则,那么这里是根据您自己的经验提取原则的指南。
在这篇文章中,我将介绍SOLID原则。这些原则是鲍勃叔叔(Robert C. Martin)给出的。我还将介绍Craig Larman出版的GRASP(一般责任分配软件原则)和其他基本的面向对象设计原则。我从个人经历中加入了一些例子,因此你找不到任何“动物”或“鸭子”的例子。
显示的代码示例更接近于java和C#,但它们对任何了解面向对象编程基础知识的开发人员都很有帮助。
以下是本文所涵盖的完整原则列表:
- 单一责任原则(SOLID)
- 高内聚(GRASP)
- 低耦合(GRASP)
- 开放封闭原则(SOLID)
- 利斯科夫替代原则(SOLID)
- 接口隔离原理(SOLID)
- 依赖倒置原则(SOLID)
- 编程到接口,而不是实现
- 好莱坞原则
- 多态性(GRASP)
- 信息专家(GRASP)
- 创作者(GRASP)
- 纯粹制造(GRASP)
- 控制器(GRASP)
- 优先使用组合而不是继承
- 间接(GRASP)
- 不要重复自己
SRP说:
引用:
一个类应该只有一个责任。
类使用其函数或契约(以及数据成员帮助函数)履行其职责。
参加以下示例类:
Class Simulation{
Public LoadSimulationFile()
Public Simulate()
Public ConvertParams()
}
这个类处理两个职责。首先,这个类正在加载模拟数据,其次,它正在执行模拟算法(使用Simulate和ConvertParams函数)。
类使用一个或多个功能履行责任。在上面的例子中,加载模拟数据是一个责任,执行模拟是另一个责任。加载模拟数据需要一个功能(即LoadSimulationFile)。执行模拟需要剩余两个函数。
我们怎么知道类上有多少责任?考虑一下与责任类似的短语“改变的原因”。因此,寻找一个类必须改变的所有原因。如果改变一个类的原因不止一个,则意味着该类不遵循单一责任原则。
在我们上面的示例类中,此类不应包含LoadSimulationFile函数(或加载模拟数据责任)。如果我们创建一个单独的类来加载模拟数据,那么这个类不会违反SRP。
一个类只能承担一项责任。您如何设计具有如此硬性规则的软件?
让我们考虑另一个与SRP密切相关的原则,它被称为高内聚。高内聚为您提供主观尺度,而不是像SRP那样客观的尺度。
非常低的内聚意味着一个类正在履行许多责任。例如,一个类负责的职责超过10个。
低内聚意味着一个类正在履行约5个职责,而中等内聚意味着一个类履行3个职责。高内聚意味着一个类正在履行一项责任。
因此,经验法则是在设计时争取高内聚。
这里应该讨论的另一个原则是低耦合。这个原则规定,应该分配一个责任,以便类之间的依赖性保持较低。
再考虑上面的示例类。应用SRP和高内聚原理后,我们决定创建一个单独的类来处理模拟文件。通过这种方式,我们创建了两个相互依赖的类。
看起来应用高内聚导致我们违反另一个低耦合原则。允许这种耦合水平,因为目标是最小化耦合而不使耦合归零。某种程度的耦合对于创建面向对象的设计是正常的,其中任务通过对象的协作来完成。
另一方面,考虑一个GUI类,它连接到数据库,通过HTTP处理远程客户端并处理屏幕布局。这个GUI类依赖于太多的类。这个GUI类明显违反了低耦合原理。如果不涉及所有相关类,则不能重用此类。对数据库组件的任何更改都会导致更改GUI类。
开放原则开放原则说:
引用:
软件模块(可以是类或方法)应该是开放的以进行扩展,但是关闭以进行修改。
简单来说,您无法更新已为项目编写的代码,但可以向项目添加新代码。
有两种方法可以应用开闭原理。您可以通过继承或通过组合来应用此原则。
以下是使用继承应用开放原则的示例:
Class DataStream{
Public byte[] Read()
}
Class NetworkDataStream:DataStream{
Public byte[] Read(){
//Read from the network
}
}
Class Client {
Public void ReadData(DataStream ds){
ds.Read();
}
}
在此示例中,客户端从网络流中读取数据(ds.Read())。如果我想扩展客户端类的功能以从另一个流中读取数据,例如PCI数据流,那么我将添加另一个DataStream类的子类,如下面的清单所示:
Class PCIDataStream:DataStream{
Public byte[] Read(){
//Read data from PCI
}
}
在这种情况下,客户端代码将运行,没有任何错误。Client类知道基类,我可以传递DataStream的两个子类中的任何一个的对象。通过这种方式,客户端可以在不知道底层子类的情况下读取数据。无需修改任何现有代码即可实现此目的。
我们可以使用组合来应用这个原则,并且还有其他方法和设计模式来应用这个原则。其中一些方法将在本文中讨论。
我们是否必须将此原则应用于您编写的每一段代码?答案是不。这是因为大多数代码都不会改变。在您怀疑将来会改变一段代码的情况下,您必须战略性地应用此原则。
在前面的示例中,我从我的领域经验,我知道将有多个流。因此,我采用开放式原则,以便它可以处理未来的变化而无需修改。
利斯科夫替代原则LSP说:
引用:
派生类必须可替代其基类。
查看此定义的另一种方法是抽象(接口或抽象类),对于客户端是足够的。
为了详细说明,让我们考虑一个例子,这里有一个接口,其清单如下:
Public Interface IDevice{
Void Open();
Void Read();
Void Close();
}
此代码表示数据采集设备抽象。数据采集设备基于其接口类型进行区分。数据采集设备可以使用USB接口,网络接口(TCP或UDP),PCI Express接口或任何其他计算机接口。
IDevice的客户端不需要知道他们正在使用哪种设备。这为程序员提供了极大的灵活性,可以适应新设备,而无需更改依赖于IDevice接口的代码。
让我们回顾一下实现IDevice接口的两个具体类的历史,如下所示:
public class PCIDevice:IDevice {
public void Open(){
// Device specific opening logic
}
public void Read(){
// Reading logic specific to this device
}
public void Close(){
// Device specific closing logic.
}
}
public class NetWorkDevice:IDevice{
public void Open(){
// Device specific opening logic
}
public void Read(){
// Reading logic specific to this device
}
public void Close(){
// Device specific closing logic.
}
}
这三种方法(open
, read
和close)足以处理来自这些设备的数据。后来,需要添加另一个基于USB接口的数据采集设备。
USB 设备的问题在于,当您打开连接时,来自先前连接的数据仍保留在缓冲区中。因此,在第一次read
USB设备时,会从前一个会话中返回数据。该行为破坏了该特定采集会话的数据。
幸运的是,基于USB的设备驱动程序提供了刷新功能,可以清除基于USB的采集设备中的缓冲区。如何在代码中实现此功能,以便代码更改保持最小化?
一个简单的解决方案是通过识别您是否正在调用USB对象来更新代码:
public class USBDevice:IDevice{
public void Open(){
// Device specific opening logic
}
public void Read(){
// Reading logic specific to this device
}
public void Close(){
// Device specific closing logic.
}
public void Refresh(){
// specific only to USB interface Device
}
}
//Client code...
Public void Acquire(IDevice aDevice){
aDevice.Open();
// Identify if the object passed here is USBDevice class Object.
if(aDevice.GetType() == typeof(USBDevice)){
USBDevice aUsbDevice = (USBDevice) aDevice;
aUsbDevice.Refresh();
}
// remaining code….
}
在此解决方案中,客户端代码直接使用具体类以及接口(或抽象)。这意味着抽象不足以让客户履行其职责。
另一种陈述相同的方法,基类不能满足所需的行为(刷新行为),但派生类有这种行为。因此派生类与基类不兼容,因此无法替换派生类。因此,这种解决方案违反了利斯科夫替代原则。
在上面的示例中,客户端依赖于更多实体(IDevice
和USBDevice),并且一个实体中的任何更改都将导致其他实体发生更改。因此违反LSP会导致类之间的依赖性。
LSP之后的这个问题的解决方案?我用这种方式更新了接口:
Public Interface IDevice{
Void Open();
Void Refresh();
Void Read();
Void Close();
}
现在IDevice
的客户端是:
Public void Acquire(IDevice aDevice)
{
aDevice.open();
aDevice.refresh();
aDevice.acquire()
//Remaining code...
}
现在客户端不依赖于IDevice
的具体实现。因此,在此解决方案中,我们的接口(IDevice
)足以满足客户端的需求。
在面向对象分析的上下文中,还有另一个角度来看待LSP原理。总之,在OOA期间,我们考虑可能成为我们软件一部分的类及其层次结构。
当我们考虑类和层次结构时,我们可以提出违反LSP的类。
让我们考虑矩形和正方形的经典例子,它多次被错误引用。从一开始看,看起来该正方形是矩形的专用版本,一个快乐的设计师将绘制以下继承层次结构。
Public class Rectangle{
Public void SetWidth(int width){}
Public void SetHeight(int height){}
}
Public Class Square:Rectangle{
//
}
接下来发生的是你不能用square
对象代替rectangle
对象。因为Square
继承自Rectangle
,所以它继承了它的方法setWidth()和setHeight()。Square
对象的客户端可以将其width
和height
更改为不同的维度。但是square
的width
和height
总是相同的,因此无法正常运行软件。
这只能通过根据不同的使用场景和条件查看类来避免。因此,当您单独设计类时,您的假设可能会失败。与Square
和Rectangle
的情况一样,is-a关系在初始分析期间看起来很好,但是当我们查看不同的条件时,这是一个失败的is-a关系,软件的正确行为。
接口隔离原则(ISP)说:
引用:
客户端不应该被迫依赖于他们不使用的接口。
再考虑前面的例子:
Public Interface IDevice{
Void Open();
Void Read();
Void Close();
}
实现此接口有三个类。 USBDevice
, NetworkDevice
和PCIDevice。该接口足以与网络和PCI设备配合使用。但USB设备需要另一个功能(Refresh())才能正常工作。
与USB设备类似,将来还有另外一种设备可能需要刷新功能才能正常工作。为了实现zhedian ,IDevice
更新如下所示:
Public Interface IDevice{
Void Open();
Void Refresh();
Void Read();
Void Close();
}
问题是现在每个实现IDevice
的类都必须提供refresh函数的定义。
例如,我必须将以下代码行添加到NetworkDevice
类和PCIDevice
类以使用此设计:
public void Refresh()
{
// Yes nothing here… just a useless blank function
}
因此,IDevice
代表一个胖接口(功能太多)。此设计违反了接口隔离原则,因为胖接口导致不必要的客户端依赖它。
有很多方法可以解决这个问题,但我会使用我的领域特定知识来解决这个问题。
我知道在open
函数之后直接调用refresh。因此,我将刷新的逻辑从IDevice
的客户端移动到特定的具体类。在我们的例子中,我将调用刷新逻辑移动到USBDevice
类,如下所示:
Public Interface IDevice{
Void Open();
Void Read();
Void Close();
}
Public class USBDevice:IDevice{
Public void Open{
// open the device here…
// refresh the device
this.Refresh();
}
Private void Refresh(){
// make the USb Device Refresh
}
}
通过这种方式,我减少了IDevice
类中的函数数量,减少了它的负担。
这个原则是其他原则的概括。上面讨论的原则,LSP和OCP,取代了依赖性反转原理。
在跳到DIP的教科书定义之前,让我介绍一个有助于理解DIP的密切相关的原则。
原则是:
引用:
“编程到接口,而不是实现”
这很简单。请考虑以下示例:
Class PCIDevice{
Void open(){}
Void close(){}
}
Static void Main(){
PCIDevice aDevice = new PCIDevice();
aDevice.open();
//do some work
aDevice.close();
}
上面的例子违反了“程序到接口原理”,因为我们正在使用具体类PCIDevice的参考。下面列出了这个原则:
Interface IDevice{
Void open();
Void close();
}
Class PCIDevice implements IDevice{
Void open(){ // PCI device opening code }
Void close(){ // PCI Device closing code }
}
Static void Main(){
IDevice aDevice = new PCIDevice();
aDevice.open();
//do some work
aDevice.close();
}
因此,遵循这一原则很容易。依赖倒置原则与此原则类似,但DIP要求我们再做一步。
DIP说:
引用:
高级模块不应该依赖于低级模块。两者都应该依赖于抽象。
您可以轻松地理解“两者都应该依赖于抽象”这一行,因为它说每个模块应该编程到一个接口。但是什么是高级模块和低级模块?
要理解第一部分,我们必须学习什么是高级模块和低级模块?
请参阅以下代码:
Class TransferManager{
public void TransferData(USBExternalDevice usbExternalDeviceObj,SSDDrive ssdDriveObj){
Byte[] dataBytes = usbExternalDeviceObj.readData();
// work on dataBytes e.g compress, encrypt etc..
ssdDriveObj.WrtieData(dataBytes);
}
}
Class USBExternalDevice{
Public byte[] readData(){
}
}
Class SSDDrive{
Public void WriteData(byte[] data){
}
}
在此代码中,有三个类。TransferManager
类代表一个高级模块。这是因为它在一个函数中使用了两个类。因此,其他两个类是低级模块。
高级模块功能(TransferData)定义数据如何从一个设备传输到另一个设备的逻辑。任何控制逻辑并使用低级模块执行此操作的模块称为高级模块。
在上面的代码中,高级模块直接(没有任何抽象)使用较低级别的模块,因此违反了依赖倒置原则。
违反此原则会导致软件难以更改。例如,如果要添加其他外部设备,则必须更改更高级别的模块。因此,您的更高级别模块将依赖于较低级别的模块,并且该依赖性将使代码难以更改。
如果您了解上述“程序到接口”的原则,那么解决方案很简单。以下是程序清单:
Class USBExternalDevice implements IExternalDevice{
Public byte[] readData(){
}
}
Class SSDDrive implements IInternalDevice{
Public void WriteData(byte[] data){
}
}
Class TransferManager implements ITransferManager{
public void Transfer(IExternalDevice externalDeviceObj, IInternalDevice internalDeviceObj){
Byte[] dataBytes = externalDeviceObj.readData();
// work on dataBytes e.g compress, encrypt etc..
internalDeviceObj.WrtieData(dataBytes);
}
}
Interface IExternalDevice{
Public byte[] readData();
}
Interface IInternalDevice{
Public void WriteData(byte[] data);
}
Interface ITransferManager {
public void Transfer(IExternalDevice usbExternalDeviceObj,SSDDrive IInternalDevice);
}
在上面的代码中,高级模块和低级模块都依赖于抽象。该代码遵循依赖性倒置原则。
好莱坞原则该原理类似于依赖性倒置原则。这个原则说
引用:
不要打电话给我们,我们会打电话给你
这意味着高级组件可以以不相互依赖的方式指示低级组件(或调用它们)。
这个原则防止依赖腐败。当每个组件依赖于每个其他组件时,依赖性腐烂就会发生。换句话说,依赖性腐烂是指在每个方向(向上,侧向,向下)发生依赖时。好莱坞原则限制我们只在一个方向上依赖。
与依赖性倒置原则的不同之处在于DIP给出了一个通用的指导方针“高级和低级组件都应该依赖于抽象而不是具体的类”。另一方面,好莱坞原则指定更高级别的组件和更低级别的组件如何交互而不创建依赖关系。
多态性什么——多态性是一个设计原则?但我们已经知道多态性是面向对象编程的基本特征。
是的,这是任何oop语言提供多态性功能的基本要求,其中派生类可以通过父类引用。
这也是GRASP的设计原则。该原则提供了有关如何在面向对象设计中使用此oop语言功能的指南。
该原则限制了运行时类型识别(RTTI)的使用。我们通过以下方式在C#中实现RTTI:
if(aDevice.GetType() == typeof(USBDevice)){
//This type is of USBDEvice
}
在java中,RTTI是使用函数getClass()或instanceOf()完成的 。
if(aDevice.getClass() == USBDevice.class){
// Implement USBDevice
Byte[] data = USBDeviceObj.ReadUART32();
}
如果您已在项目中编写此类型代码,那么现在是时候重构该代码并使用多态原则对其进行改进。
请看下图:
这里我概括了接口中的read
方法,并将设备特定的实现委托给它们的类(例如USBDevice中的ReadUART32())。
现在我只使用read
方法。
//RefactoreCode
IDevice aDevice = dm.getDeviceObject();
aDevice.Read();
getDeviceObject()的实现将从何而来?我们将在创建者原则和信息专家原则中讨论,您将学习如何将职责分配给类。
信息专家这是一个简单的GRASP原则,并给出了关于赋予类职责的指导。
它表示将责任分配给具有履行该职责所必需信息的类。
考虑以下类:
在我们的场景中,模拟以全速(每秒600个循环)执行,而用户显示以降低的速度更新。在这里,我必须分配是否显示下一帧的责任。
哪个类应该承担这个责任?我有两个选项,simulation
类或SpeedControl类。
现在,SpeedControl类具有关于当前序列中显示哪些帧的信息,因此根据信息专家原则SpeedControl应该承担此责任。
创造者创造者是GRASP原则,有助于确定哪个类应该负责创建类的新实例。
对象创建是一个重要的过程,在决定谁应该创建类的实例时有一个原则是有用的。
根据Larman的说法,如果满足以下任何条件为true,则“B”类应负责创建另一个类“A”。
a)B含有A.
b)B聚合A
c)B具有A的初始化数据
d)B记录A.
e)B密切使用A.
在我们的多态性示例中,我使用了信息专家和创作者原则来赋予DeviceManager类创建Device
对象(dm.getDeviceObject())的职责。这是因为DeviceManager具有创建Device
对象的信息。
为了理解纯粹制造,先决条件是您了解面向对象分析(OOA)。
总之,面向对象分析是一个过程,通过它您可以识别问题域中的类。例如,银行系统的域模型包含诸如账户、分支、现金、支票、交易等类。
在银行示例中,领域类需要存储有关客户的信息。为此,一个选项是将数据存储责任委托给领域类。此选项将降低领域类的内聚性(多个职责)。最终,此选项违反了SRP原则。
另一个选择是引入另一个不代表任何领域概念的类。在银行示例中,我们可以引入一个类“PersistenceProvider”。此类不代表任何领域实体。此类的目的是处理数据存储功能。因此“PersistenceProvider”是纯粹的制作。
控制器当我开始开发时,我使用Java的swing组件编写了大部分程序,并且我将大部分逻辑写在了监听器之后。
然后我学习了领域模型。因此,我将逻辑从侦听器移到了领域模型。但我直接从侦听器调用领域对象。这在GUI组件(侦听器) 领域模型之间创建了依赖关系。控制器设计原则有助于最小化GUI组件和域模型类之间的依赖关系。
控制器有两个目的。控制器的第一个目的是封装系统操作。系统操作是您的用户想要实现的,例如购买产品或将物品输入购物车。然后通过调用软件对象之间的一个或多个方法调用来完成该系统操作。控制器的第二个目的是在UI和域模型之间提供一个层。
UI使用户能够执行系统操作。控制器是UI层之后的第一个对象,它处理系统操作请求,然后将责任委托给底层领域对象。
例如,这里是MAP
类,它代表我们的一个软件代码中的控制器。
从UI中,我们将“移动光标”的责任委托给该控制器,然后调用底层领域对象来移动光标。
通过使用控制器原则,您可以灵活地插入另一个用户界面,如命令行界面或Web界面。
优先使用组合而不是继承主要是面向对象编程中有两个工具来扩展现有代码的功能。第一个是继承。
第二种方法是组合。在编程语言中,通过引用另一个对象,您可以扩展该对象的功能。如果使用组合,添加一个新类创建其对象,然后使用其对象来扩展代码。
该组合的一个非常有用的功能是可以在运行时设置行为。另一方面,使用继承只能在编译时设置行为。这将在下面的示例中显示。
当我是一个新手并使用继承来扩展行为时,这些是我设计的类:
最初,我只知道处理传入的数据流,并且有两种(流A和流B)数据。几周后,我才知道应该处理数据的字节顺序。因此,我想出了如下所示的类设计:
后来,另一个变量被添加到需求中。这次我必须处理数据的极性。想象一下我要添加多少个类?streamA
, streamB
的两种极性,具有字节序等的Stream
。类会爆炸的!现在我将不得不维护大量的类。
现在,如果我使用以下组合处理同样的问题,以下是类设计:
我添加新类,然后使用他们的引用在我的代码中使用它们,请参阅下面的列表:
clientData.setPolarity(new PolarityOfTypeA); // or clientData.setPolarity(new PolarityOfTypeB)
clientData.FormatPolarity;
clientData.setEndianness(new LittleEndiannes());// setting the behavior at run-time
clientData.FormatStream();
因此,我可以根据我想要的行为提供类的实例。此功能减少了类的总数和最终的可维护性问题。因此,优先使用组合而不是继承将减少可维护性问题和在运行时设置行为的灵活性。
间接这个原则回答了一个问题:
如何让对象以他们之间的联系仍然薄弱的方式进行交互?
解决方案是:
将交互的责任交给中间对象,以便不同组件之间的耦合保持较低。
例如:
软件应用程序使用不同的配置和选项。要将领域代码与配置分离,请添加以下清单中显示的特定类:
Public Configuration{
public int GetFrameLength(){
// implementation
}
public string GetNextFileName(){
}
// Remaining configuration methods
}
这样,如果任何领域对象想要读取某个配置设置,它将询问Configuration类对象。因此,主代码与配置代码分离。
如果您已阅读纯粹制造原责,则此Configuration
类是纯粹制造的示例。但间接的目的是创建解耦。另一方面,纯粹制造的目的是保持领域模型的清洁,并仅代表领域的概念和责任。
许多软件设计模式如Adapter,Facade和observer都是间接原则的专门化。
不要重复自己(DRY)不要重复自己意味着不要一次又一次地尝试编写相同的代码。我们的想法是,如果您一次又一次地编写几行代码,那么您应该将它们组合在一个函数中,然后调用该函数。
最大的好处是,现在如果您想要更新这些特定的代码行,您可以在一个地方更新它们。否则,您将必须搜索写入代码的所有位置。
我一直犹豫是否应用这个原则。这是因为在一本旧的编程书中我已经读过,编写一个单独的函数会使你的处理器工作得更多。例如,当您调用函数时,总是会在汇编语言中进行额外调用,这称为“JUMP”调用。
此jump
调用会产生额外的执行成本。现在,如果函数处于执行100万次的循环中,则意味着处理器需要执行100万条额外指令。
嗯。挺昂贵的!
这阻碍了我很长一段时间。也有解决方案。现在编译器已经过优化,不会跳转到函数。相反,当你调用一个函数时,这些编译器只是用实际的代码行替换函数调用。因此,当处理器运行时,没有“JUMP”的额外成本。
其他的一切都由编译器负责。所以尽可能多地使用DRY原则,但要确保你的编译器足够聪明:)。
原文地址:https://www.codeproject.com/Articles/1166136/S-O-L-I-D-GRASP-And-Other-Basic-Principles-of-Obje