原文:http://www.codeproject.com/Articles/567768/Object-Oriented-Design-Principles
作者:Marla Sukesh
——文记:本文翻译纯属兴趣,如有雷同,不胜荣幸
谁适合对这篇文章?
这篇文章适合对面向对象编程有个基本的概念的程序员。至少能区分类和对象,以及能谈论一些面向对象编程的基本主题,如:封装,抽象,多态和继承。
简介
在面向对象的世界里我们只看对象,对象相互之间交互。类,对象,继承,多态,抽象等术语是我们日常工作中耳熟能详的词汇。
在当今软件开发行业每一个软件开发者都使用某种类别的面向对象编程语言,但是问题是,是否每一个人都真正理解了面向对象编程的的含义?是否知道自己正在使用面向对象编程?如果答案是YES,那么你真正发挥了面向对象编程的作用吗?
这篇文章我们除了讨论基本的面向对象编程,还将讨论面向对象设计。
面向对象设计
在软件系统中一系列计划通过对象之间的相互交互来解决特定的问题。俗话说:适当的使用面向对象设计会使得开发者很轻松,然而糟糕的设计会带来灾难性的损失。
从何开始
当开发者开始创建一个软件架构时,他们的目标往往是很好的。他们用已有的经验创造出优雅而干净的设计。
随着时间的推移,软件开始变得糟糕。当每一个新特性要求被注入或改变软件设计来改变其形态,最终一些简单的变更却需要更多的努力,更糟糕的是为Bug创造了更多的机会。
这是谁的错?
软件解决现实生活中的业务问题,由于业务一直在发展,所以软件也一直在改变。
变更是软件行业必不可少的一部分。显然客户花了钱是为了能满足他们所期望的需求。所以我们不能由于变更而抱怨退化的软件设计。而是由于我们的设计还不够强大。
对破坏软件设计最大的因素是在系统中引入了未考虑到的依赖关系。每一个系统模块依赖其他的模块,当修改一个模块时会影响到其它模块。如果我们能很好的管理这些依赖关系,那维护这个软件系统将变得很容易,并且能保证软件的质量。
例如:
解决方案:原则,设计模式,软件架构
同时,面向对象设计也需要遵从一些原则,它使我们通过软件设计来管理问题。
Mr Robert Martin (众所周知的Bob大叔)将这些原则归类如下:
本文将通过一些实用的例子来介绍类设计原则:SOLID。
SOLID
J5原则是Bob大叔总结出的五个原则的简称,单一职责原则,开闭原则,里氏替换原则,接口隔离原则和依赖倒置原则。维基百科上面说当将这五个应用到一起使得程序员能创造出一个可维护,可扩展的系统。下面详细介绍每一个原则。
I) S - SRP - Single responsibility Principle(单一职责原则)
现实生活
假设我是一个印度软件公司里面的小组leader,在业余时间我从事写作,新闻编辑,还做着其他不同的项目。简单的说,我身兼多职。
当某些不好的事情发生在我的工作场所里时,比如老板因为某些错误责骂我,或者我被其他工作干扰。基本上,当一件事情变得糟糕时,所有事情都陷入困境。
发现问题
在我们讨论这个原则之前先看看下面的代码
问题出在哪里?
每次当一个发生改变时其他的也会跟随着改变,原因是他们都住在同一个屋子里,拥有相同的父母。我们无法控制一切。所以一个改变导致双倍的测试,甚至更多。
什么是单一职责原则?(SRP)
SRP:每一个软件模块只有一个能引起其改变的原因
不违反SRP原则的方法
现在看我们怎样去实现他。我们将创建3个不同的类
public class Employee { public string EmployeeName { get; set; } public int EmployeeNo { get; set; } } public class EmployeeDB { public void Insert(Employee e) { //Database Logic written here } public Employee Select() { //Database Logic written here } } public class EmployeeReport { public void GenerateReport(Employee e) { //Set report formatting } }
NOTE:单一职责原则同样适用于方法,每一个方法应该只干一件事。
类可以有多个方法吗?
答案是肯定的。但是你可能会问:
当然这个问题的答案非常简单。那就是从整体上下文看待问题。这里,职责是跟我们所说的上下文是相关的。说到类的职责时往往是从更高层次上看待的。对于实例,比如EmployeeDB类主要的职责是对employee数据库相关的操作,然而EmployeeReport则是对员工报表相关的操作。
当我们说到方法的职责时那就是较低层次上。看下面的例子:
//Method with multiple responsibilities – violating SRP public void Insert(Employee e) { string StrConnectionString = ""; SqlConnection objCon = new SqlConnection(StrConnectionString); SqlParameter[] SomeParameters=null;//Create Parameter array from values SqlCommand objCommand = new SqlCommand("InertQuery", objCon); objCommand.Parameters.AddRange(SomeParameters); ObjCommand.ExecuteNonQuery(); } //Method with single responsibility – follow SRP public void Insert(Employee e) { SqlConnection objCon = GetConnection(); SqlParameter[] SomeParameters=GetParameters(); SqlCommand ObjCommand = GetCommand(objCon,"InertQuery",SomeParameters); ObjCommand.ExecuteNonQuery(); } private SqlCommand GetCommand(SqlConnection objCon, string InsertQuery, SqlParameter[] SomeParameters) { SqlCommand objCommand = new SqlCommand(InsertQuery, objCon); objCommand.Parameters.AddRange(SomeParameters); return objCommand; } private SqlParameter[] GetParaeters() { //Create Paramter array from values } private SqlConnection GetConnection() { string StrConnectionString = ""; return new SqlConnection(StrConnectionString); }
测试本身是有利的,使得代码可读性强是另一个好处。代码可读性越强就越简单易懂。
II) O - OCP – Open Close Principle(开闭原则)
现实生活
假设你想要在图中第一层和第二层之间在加一层楼。你觉得这个可能吗?当然可能,但是可行吗?
这里有一些方法:
发现问题
我们假设EmployeeDB的Select方法会被两个不同的场景调用。一个是普通的员工调用,一个是管理员调用,但是管理员调用的Select方法需要修改。
如果我们修改现有Select方法来满足新的需求,原来老的代码逻辑将会受到影响。同时还要改变现有的测试方案,这可能会导致意想不到的Bug。
什么是OCP?
OCP:软件模块应该对修改关闭,对扩展开放。一个正交的声明。
不违反OCP的解决方案
1)使用继承
创建一个EmployeeManagerDB继承EmployeeDB类,并且根据新的需求重写select方法
public class EmployeeDB { public virtual Employee Select() { //Old Select Method } } public class EmployeeManagerDB : EmployeeDB { public override Employee Select() { //Select method as per Manager //UI requirement } }
NOTE:如果这是预期的设计并且提供了可扩展的虚方法,那么可以称得上是良好的面向对象设计。下面是调用代码:
//Normal Screen EmployeeDB objEmpDb = new EmployeeDB(); Employee objEmp = objEmpDb.Select(); //Manager Screen EmployeeDB objEmpDb = new EmployeeManagerDB(); Employee objEmp = objEmpDb.Select();
2)扩展方法
如果你使用.NET 3.5或者更高的版本,他提供了另外一种解决方案叫做扩展方法,它可以让我们在现有类型中添加新的方法而不用干涉它。
NOTE:当然有更多的方法来实现我们想要结果。就像我们说这些原则不是戒律一样。
III) L – LSP – Liskov substitution principle(里氏替换原则)
什么是LSP?
你可能会疑问为什么我们先定义好例子和要讨论的问题。简而言之,我觉得这样会更好。
LSP: 在任何时候父类可以被派生类替换。不要觉得奇怪?如果我们总是可以这样编码:
BaseClass b = new DerivedClass(),怎么还会有这条原则?
现实生活
父亲是房地产商人,而他的儿子却想成为一名板球运动员。
儿子无法取代父亲的位置,尽管他们是两爷儿。
发现问题
下面我们讲个非常通用的例子
通常会讨论几何形状的问题,就是正方形继承矩形的问题。看下面代码片段:
public class Rectangle { public int Width { get; set; } public int Height { get; set; } } public class Square:Rectangle { //codes specific to //square will be added }
用法如下:
Rectangle o = new Rectangle(); o.Width = 5; o.Height = 6;
完美,但是遵守LSP原则我们将用正方形替换矩形 ,我们来试试:
Rectangle o = new Square(); o.Width = 5; o.Height = 6;
出啥问题了?正方形的边长不能不相等
这意味着什么?这意味着我们不能用子类替换父类,这就违反了LSP原则
那么我们为什么不在矩形中虚拟长和宽,然后在正方形中重写它们?
代码如下:
public class Square : Rectangle { public override int Width { get{return base.Width;} set { base.Height = value; base.Width = value; } } public override int Height { get{return base.Height;} set { base.Height = value; base.Width = value; } } }
这样做不行,还是违反了LSP原则,由于在子类改变了宽和高的属性(对于矩形来说宽和高不能相等,相等就不是矩形了)。(看来这个方法行不通)
解决方法
这里应该抽象一个名为Shape的基类,Shape代码如下:
public abstract class Shape { public virtual int Width { get; set; } public virtual int Height { get; set; } }
现在这里应该有两个具体的且相互独立的子类。一个是正方形,一个是矩形。他们都将继承自Shape类。
现在程序员可以使用如下代码:
Shape o = new Rectangle(); o.Width = 5; o.Height = 6; Shape o = new Square(); o.Width = 5; //both height and width become 5 o.Height = 6; //both height and width become 6
即使我们在派生类中不改变宽和高的行为,当我们讨论Shape对象时,宽和高没有具体的限制,它们可以相等也可以不相等。
IV) I – ISP– Interface Segregation principle
(接口隔离原则)
现实生活
假设你购买了一台新电脑。你会看到一系列的USB接口,串行接口,VGA接口等等。如果你打开机箱,你会看到很多插槽在主板上用于连接各个部件,主要由硬件工程师装配的时候使用。
这些插槽都是看不见的除非你打开机箱。简单说,它只暴露出你需要的接口。想象这么一种情况,所有接口都在外部或内部,这样会使得硬件故障几率变大。
假设我们要去商店买东西(比如要买个一板球棒)。想象一下商店老板开始向你介绍球和球桩。我们可能会迷惑,最终可能买了我们并不需要的东西。甚至可能忘了来这里的最初目的。
发现问题
假设我们想要开发一个报告管理系统。现在,第一首要任务是创建一个业务层,它将被用在三个不同角色的UI层级上。
public interface IReportBAL { void GeneratePFReport(); void GenerateESICReport(); void GenerateResourcePerformanceReport(); void GenerateProjectSchedule(); void GenerateProfitReport(); } public class ReportBAL : IReportBAL { public void GeneratePFReport() {/*...............*/} public void GenerateESICReport() {/*...............*/} public void GenerateResourcePerformanceReport() {/*...............*/} public void GenerateProjectSchedule() {/*...............*/} public void GenerateProfitReport() {/*...............*/} } public class EmployeeUI { public void DisplayUI() { IReportBAL objBal = new ReportBAL(); objBal.GenerateESICReport(); objBal.GeneratePFReport(); } } public class ManagerUI { public void DisplayUI() { IReportBAL objBal = new ReportBAL(); objBal.GenerateESICReport(); objBal.GeneratePFReport(); objBal.GenerateResourcePerformanceReport (); objBal.GenerateProjectSchedule (); } } public class AdminUI { public void DisplayUI() { IReportBAL objBal = new ReportBAL(); objBal.GenerateESICReport(); objBal.GeneratePFReport(); objBal.GenerateResourcePerformanceReport(); objBal.GenerateProjectSchedule(); objBal.GenerateProfitReport(); } }
现在在每一个UI层当开发者输入”objBal”下面的提示将会出现:
有什么问题?
开发者在使用EmployeeUI时却可以调用其他方法,这可能会导致开发者困惑。
什么是ISP?
ISP:客户不应该被迫实现他不使用的借口。 还可以说成:多个特定的客户接口比一个通用接口更好。 简单的说,如果你的接口太臃肿,应该把它拆分成多个接口。
根据ISP原则更新后的代码
public interface IEmployeeReportBAL { void GeneratePFReport(); void GenerateESICReport(); } public interface IManagerReportBAL : IEmployeeReportBAL { void GenerateResourcePerformanceReport(); void GenerateProjectSchedule(); } public interface IAdminReportBAL : IManagerReportBAL { void GenerateProfitReport(); } public class ReportBAL : IAdminReportBAL { public void GeneratePFReport() {/*...............*/} public void GenerateESICReport() {/*...............*/} public void GenerateResourcePerformanceReport() {/*...............*/} public void GenerateProjectSchedule() {/*...............*/} public void GenerateProfitReport() {/*...............*/} }
public class EmployeeUI { public void DisplayUI() { IEmployeeReportBAL objBal = new ReportBAL(); objBal.GenerateESICReport(); objBal.GeneratePFReport(); } }
public class ManagerUI { public void DisplayUI() { IManagerReportBAL objBal = new ReportBAL(); objBal.GenerateESICReport(); objBal.GeneratePFReport(); objBal.GenerateResourcePerformanceReport (); objBal.GenerateProjectSchedule (); } }
public class AdminUI { public void DisplayUI() { IAdminReportBAL objBal = new ReportBAL(); objBal.GenerateESICReport(); objBal.GeneratePFReport(); objBal.GenerateResourcePerformanceReport(); objBal.GenerateProjectSchedule(); objBal.GenerateProfitReport(); } }
这样通过遵守ISP原则,可以让客户看到他们期望的内容。
V) D–DIP– Dependency Inversion principle (依赖倒置原则)
现实生活
我们拿PC机来说。不同的部件,比如内存,硬盘,光驱等等,全都松散的连接在主板上。这意味着,假如某一天某些部件坏了可以很容易更换。想像一下所有部件都紧密的耦合在一起,这意味着它不能从主板上拆下来。在这种情况下如果内存坏了,我们不得不更换新的主板,这将非常浪费money,如果你是土豪那就无所谓了。
发现问题
看下面代码:
public class CustomerBAL { public void Insert(Customer c) { try { //Insert logic } catch (Exception e) { FileLogger f = new FileLogger(); f.LogError(e); } } } public class FileLogger { public void LogError(Exception e) { //Log Error in a physical file } }
在上面代码中CustomerBAL直接依赖于FileLogger,FileLogger会将异常日志写入文件。现在我们假设明天管理决定将异常日志输出到事件视图上。那将会怎么样?改变现有的代码。Oh no!My God,那将会创造出新的错误。
什么是DIP?
DIP:高层模块不应该依赖于底层模块,相反,两者都应该依赖于抽象。
DIP解决方案
public interface ILogger { void LogError(Exception e); } public class FileLogger:ILogger { public void LogError(Exception e) { //Log Error in a physical file } } public class EventViewerLogger : ILogger { public void LogError(Exception e) { //Log Error in a physical file } } public class CustomerBAL { private ILogger _objLogger; public CustomerBAL(ILogger objLogger) { _objLogger = objLogger; } public void Insert(Customer c) { try { //Insert logic } catch (Exception e) { _objLogger.LogError(e); } } }
跟你所看到的一样客户依赖于抽象,ILogger可以作为实例替代任何它的派生类。
到目前为止我们讨论了SOLID得五个原则。感谢Bob大叔。
结束了吗?
除了Bob大叔总结的这些原则,还有其他原则吗?答案是肯定的,但是这里就不在一一讲述其细节了。下面大概列一下:
总结
我们无法阻止需求变更,唯一可做的是开发设计出可以适应变更的软件。
希望这篇文章能给你带来收获。多谢你的耐心阅读。
许可
本文及相关代码和文件都遵循CPOL协议(Code Project Open License)
关于作者
Marla Sukesh
Technical Lead ShawMan Softwares
India
关于译者
此处省略若干字节