不懂SOLID,GRASP这些软件开发原则!写出来的代码都是垃圾!

640?wx_fmt=jpeg


从一些老生常谈的事情开始说起来吧,优秀的代码应符合以下特质:

1,可维护性

2,可扩展性

3,模块化


如果代码在其生命周期内保持易于维护、扩展和模块化,那么就上面列出的特性而言,这意味着代码高于平均水平。

下面所示的代码示例更接近Java和C#,但它们对于面向对象编程领域的任何开发者都是有帮助的


以下是完整的原则列表:

1.Single Responsibility Principle (SOLID) 单一责任性原则

2. High Cohesion (GRASP) 高内聚

3. Low Coupling (GRASP) 低耦合

4. Open Closed Principle (SOLID) 开闭原则

5. Liskov Substitution principle (SOLID)  里氏替换原则

6. Interface Segregation Principle (SOLID) 接口分离原则

7. Dependency Inversion Principle (SOLID) 依赖倒置原则

8. Program to an Interface, not to an Implementation 面向接口编程

9. Hollywood Principle 好莱坞原则

10. Polymorphism (GRASP) 多态原则

11. Information Expert (GRASP) 信息专家模式

12. Creator (GRASP) 创造者原则

13. Pure Fabrication (GRASP) 纯虚构原则

14. Controller (GRASP) 控制器原则

15. Favor composition over inheritance 组合优于继承

16. Indirection (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类。

 

开闭原则


开闭原则描述为:

一个软件模块(类或者方法)应该对拓展开放而对修改关闭

换句话说,不能更新已经为项目编写的代码,但可以向项目添加新代码。

 

以下则是使用继承应用开放原则的示例:

 
 
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{	
Publc byte[] Read(){	
//Read data from PCI	
 }	
}	

在这种情况下,客户端代码的运行没有任何错误。客户端认识基类,因此可以传递DataStream两个子类中的任何一个的对象,这样,客户端可以在未知子类的情况下读取数据。这是在不修改任何现有代码的情况下实现的。


我们也可以使用组合来应用这个原理,并且还有其他方法和设计模式来应用这个原理。其中一些方法将在本文中讨论。


然而,你必须将这个原则应用于每一段代码吗?当然不是了,因为大部分的代码其实是不怎么变动的,你只需要战略性的将这个原则应用到那些你预计将来会有变动的代码片上即可。


里氏替换原则


Liskov替代原则指出:

子类应当可以替换父类并出现在父类能够出现的任何地方

查看此定义的另一种方法是抽象类(接口或抽象类)对于客户端应该足够了。

为了详细说明,让我们考虑一个例子,如下:

 
 
Public Interface IDevice{	
Void Open();	
Void Read();	
Void Close();	
}	


这个例子是数据采集装置的抽象。数据采集装置按其接口类型不同而区分,它能够使用USB接口,网络接口(TCP 或者 UDP),PIC接口或者另外的计算机接口。然而,客户端设备不需要知道与其链接的是何种数据采集装置。为了在不改变客户端代码的情况下适应新的采集装置,这就需要程序员给接口提供极大的灵活性。


让我们回顾一下实现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.	
 }	
}	


这三个方法(打开、读取和关闭)对于处理设备传入的数据已经足够了。然后,假设需要添加基于USB接口的另一个数据采集设备。


USB设备的问题在于,当你打开连接时,来自先前连接的数据仍保留在缓冲区中。因此,在对USB设备的第一次读取调用时,返回来自上一个会话的数据。有针对性的采集行为会破坏这些数据。幸运的是,基于USB的设备驱动程序提供刷新功能,预先清除了基于USB的采集设备中的缓冲区。那么如何在代码中实现这个功能,并使代码的改动最小?

 

一个简单但是草率的解决方案是更新代码,通过标识识别是否调用USB设备,如下:

 
 
public class USBDevice:IDevice{	
 public void Open(){	
    // Device specific opening logic	
 }	
 public void Read(){	
    // Reading logic specific to this device<br> 	
 }	
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….	
}	


在这个解决方案中,客户端代码直接使用具体类以及接口(或抽象)。这意味着抽象不能够让客户履行其职责。

另一种陈述方式是基类无法满足需求(刷新操作),但是子类可以,实际上,子类有该项行为。因此,派生类和基类不兼容且子类不能被代替。所以,该解决方案违反了里氏替换原则。

下面这个示例中,客户端依赖于更多的实体(iDevices 和 USB Devices),一个实体的任何一点改变都将影响其他实体。因此,违反LSP原则将导致类之间的互相依赖。


 

下面是遵循LSP这个问题的解决方案:

 
 
Public Interface IDevice{	
Void Open();	
Void Refresh();	
Void Read();	
Void Close();	
}	


现在客户端如下:

 
 
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{	
//	
}	


接下来你会发现你不能使用这个正方形的对象去代替长方形的对象了。因为正方形继承自长方形,所以它也继承了设置长度和宽度的方法。于是一个正方形的客户端能够随意改变自己的长和宽为不同的大小,但是实际上正方形的长宽应该是相同的,因此我们软件的这个正常行为就失败了。

 

这个问题只能根据不同的使用场景和条件具体分析类来避免。因此如果你孤立的设计一个类很可能在实际运行中将会出错。就像我们的正方形和长方形那样,一开始认为很完美的关系设计,在不同的条件下,这种关系设计最终被认定并不符合我们软件正常运行的要求。

 

接口隔离原理


接口隔离原则(ISP)规定:

客户端不应该被强迫依赖他们不使用的接口

还是考虑前一个例子:

 
 
Public Interface IDevice{	
Void Open();	
Void Read();	
Void Close();	
}	

实现此接口有三个类:USBDeviceNetworkDevicePCIDevice 这个接口足够与网络和PCI设备配合使用。但是USB设备则需要另一个功能(Refresh())才能正常工作。

 

和USB设备一样,也许还有另外的设备也需要这个函数来支持工作。为此,接口被更新如下:

 
 
 Public Interface IDevice{	
Void Open();	
Void Refresh();	
Void Read();	
Void Close();	
}	


那么问题来了,任何一个实现该接口的类都需要去实现Refresh函数。

 

例如为了满足以上的设计,必须对网络设备和PCID设备添加下面的代码:

 
 
public  void Refresh()	
{	
// Yes nothing here… just a useless blank function	
}	


因此,iDevice代表一个Fat接口(功能太多)。此设计违反了接口隔离原则,因为Fat接口会导致不必要的客户端依赖于它。

有很多方法可以解决这个问题,但我将在保持我们预先定义的面向对象解决方案范围内的同时解决这个问题。

我们知道在open操作之后就会直接调用refresh函数,因此,我改变逻辑将设备客户端的refresh函数迁移至具体的实现类内部。在本例中我将调用refresh的逻辑移动到USB设备的具体实现类中:

 
 
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	
 }	
}	


通过这个方式,我减少了基类中的函数数目,让它变得更轻了。


依赖倒置原则(DIP


这一原则是上述其他原则的概括。
在我们给出DIP的书面定义之前,请让我介绍一个与此紧密相连的一条原则,以帮助我们理解DIP。

也就是说:

面向接口编程,而不是面向实现编程

这很简单,请考虑以下示例:

 
 
Class PCIDevice{	
Void open(){}	
Void close(){}	
}	
Static void Main(){	
PCIDevice aDevice = new PCIDevice();	
aDevice.open();	
//do some work	
aDevice.close();	
}	


上面的例子违反了“程序到接口”的原则,因为我们正在使用具体类PCI Device的实例。下面的列子遵循这个原则

 
 
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需要我们再做一步。


依赖反转:高级模块不应该依赖低级模块。二者应该依赖于抽象。


您可以很容易地理解为“两者都应该依赖于抽象”,正如它所说,每个模块都应该编程到一个接口。那么什么是高级模块和低级模块呢?


想要理解第一部分,我们必须要了解实际的高级模块和低级模块是什么?


请参阅以下代码:

 
 
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代表高级模块,因为它在一个方法中用了其它两个类。因此其他两个类则是低级模块。
在上面的代码中,高级模块使用较低级别的模块直接(没有任何抽象),因此违反了依赖性反转原则。

违背了依赖反转这条原则会让你的软件系统变得难以更改。比如,如果你想增加其他的外部设备,你将不得不改变高级模块。因此你的高级模块将会依赖于低级模块,依赖会让代码变得难以改变。


如果你理解了上面的原则:“面向接口编程”,那么这个解决方案就很容易了。

例如:

 
 
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();	
}	
Interfce IInternalDevice{	
Public void WriteData(byte[] data);	
}	
Interface ITransferManager {	
public void Transfer(IExternalDevice usbExternalDeviceObj,SSDDrive  IInternalDevice);	
}	


从上面的例子中可以看出,高级模块和低级模块都依赖于抽象。因此它遵循依赖性倒置原则。


好莱坞原则


该原理类似于依赖性倒置原则描述为:


不要调用我们,我们会给你


这意味着高级组件可以以某种方式指示低级组件(或调用它们),这样两个组件都能不依赖于另一个了。


这条原则可以防止依赖恶化。依赖恶化发生在每个组件都依赖于其他各个组件。换句话说,依赖恶化是让依赖发生在各个方向(向上,横向,向下)。Hollywood原则可以让我们时依赖只向一个方向。

 

DIP和Hollywood之间的差异给了我们一条通用原则:无论是高级组件还是低级组件,都要依赖于抽象而不是具体的类。另一方面,Hollywood原则强调了高级组件和低级组件应该以不产生依赖的方式交互。


多态原则


什么 ?多态也是设计原则?没错,多态是任何面向对象语言都要提供的基础特征,它可以让父类的引用指向子类。

这也是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();	
}	


如果您已在项目中编写此类型代码,那么现在是时候重构该代码并使用多态原则对其进行改进的时候了。
请看下图:


640?wx_fmt=jpeg


在此我在接口中生成了read方法,然后委托他们的实现类去实现该方法,现在,我只用方法Read:

 
 
//RefactoreCode	
IDevice aDevice = dm.getDeviceObject();	
aDevice.Read();	


getDeviceObject()的实现将从何而来?我们将在下面的创建者原则和信息专家原则中讨论,您将更好的学习如何将职责分配给类。


信息专家原则


这是一个简单的GRASP原则,它给出了关于赋予类职责的指导。你应该为具有履行该职责所必需信息的类分配责任。

如图:

640?wx_fmt=jpeg


在我们的场景中,模拟以全速(每秒600个循环)执行,而用户显示器将以降低的速度更新。在这里,我们必须分配一个责任来确定是否显示下一帧。

哪个类应该承担这个责任?我们有两个选项:Simulation类或SpeedControl类。

现在,SpeedControl类具有关于哪些帧已在当前序列中显示的信息,因此根据Information Expert SpeedControl应该具有此职责。


创建者原则



Creator是一个GRASP原则,它有助于确定哪个类应该负责创建一个类。对象创建是一个重要的过程,在决定谁应该创建类的实例时有一个原则是有用的。

根据Larman的说法,想要满足以下任何条件,则应该赋予班级B以创建另一个班级A的责任。
a
B含有A.
b
B聚合物A.
c
B具有A的初始化数据
d
B记录A.
e
B密切使用A.

在我们的多态性示例中,我使用了InformationExpert和Creator原则来赋予DeviceManager类创建设备对象(dm.getDeviceObject())的职责。这是因为DeviceManger具有创建设备对象的信息。


纯虚构原则


为了更好的理解Pure Fabrication,您首先必须要了解面向对象分析(OOA)。

面向对象分析是一个过程,通过它您可以识别问题域中的类。例如,银行系统的域模型包含类,如帐户,分支,现金,支票,交易等。在此示例中,域类需要存储有关客户的信息。为了做到这一点,一个选项是将数据存储责任委托给域类。此选项将降低域类的凝聚力(多个职责)。最终,此选项违反了SRP原则。

另一种选择是引入另一个不代表任何域概念的类。在银行示例中,我们可以引入一个名为“PersistenceProvider”的类。这个类不代表任何域实体。此类的目的是处理数据存储功能。因此“PersistenceProvider”是纯粹的制作。


控制器原则


当我开始开发软件时,我使用Javaswing组件来编写程序,而我的大多数逻辑都是在幕后。

然后我学习了域模型。所以,我把我的逻辑从幕后转移到了Domain模型。但我直接从侦听器调用域对象。这会在GUI组件(侦听器)和域模型之间创建依赖关系。控制器设计原则有助于把GUI组件和域模型类之间的依赖关系最小化。

控制器有两个目的。 第一个是封装系统操作。系统操作是您的用户想要实现的,例如购买产品或将商品输入购物车。然后通过调用软件对象之间的一个或多个方法调用来完成该系统操作。 第二个是在UI和域模型之间的提供层。

UI使用户能够执行系统操作。Controller是处理系统操作请求的UI层之后的第一个对象,然后将责任委派给底层域对象。


UI我们将“移动游标”的责任委托给此Controller,然后调用底层域对象来移动游标。

通过使用Controller原则,您可以灵活地插入另一个用户界面,如命令行界面或Web界面。

 

 组合优于继承


面向对象编程中主要有两个工具来扩展现有代码的功能。 第一个是继承,第二个是组合。


在编程中,通过引用另一个对象,您可以扩展该对象的功能。 如果使用合成来添加一个新类去创建其对象,那么就可以使用它的对象来扩展代码。


组合的一个非常有用的功能是可以在运行时设置行为。 另一方面,使用继承只能在编译时设置行为。 

以下是类设计:

640?wx_fmt=jpeg


我们可以添加新的类并把它们的引用在自己的代码中。 请参阅下面的列表:

 
 
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类对象。 因此,主代码必须要与配置代码分离。

如果您已经阅读了纯虚构原则,那么这个配置类就是纯虚构的一个例子。但是间接的目的是创建去耦合。而纯虚构的目的则是保持领域模型的整洁,只代表领域概念和职责。


640?wx_fmt=jpeg

通过这篇文章,相信您能够很快的了解面向对象的设计原则,并牢牢掌握SOLID和GRASP规则背后的思想,这些原则是非常基础而且重要的。正是由于这些原则的基础性,理解、融汇贯通这些原则需要不少的经验和知识的积累。上述的图片以及代码很好的注释了这些原则。希望这篇文章能够对您有所帮助!

640?wx_fmt=jpeg

长按二维码 ▲

订阅「架构师小秘圈」公众号

如有启发,帮我点个在看,谢谢↓

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值