面向对象设计原则概述:
软件的可维护性和可复用性是两个非常重要的用于衡量软件质量的属性,软件的可维护性是指软件能够被理解、改正、适应及拓展的难易程度,软件的可复用性是指软件能够被重复使用的难易程度。面向对象的设计的目标之一在于支持可维护性复用,一方面需要实现设计方案或者源代码的复用,另一方面要确保系统能够易于拓展和修改,具有良好的可维护性。面向对象设计原则为支持可维护性复用而诞生,这些原则蕴含在许多的设计模式中,他们是从许多设计方案中总结出来的指导性原则,但不是强制性的。
七个常用的面向对象设计原则:
设计原则名称 | 定义 |
---|---|
单一职责原则 (Single Responsibility Principle, SRP) | 一个对象应该只包含单一的职责,并且该职责被完整的封装在一个类中 |
开闭原则 (Open-Closed Principle , OCP) | 软件实体应当对拓展开放,对修改关闭 |
里氏代换原则 (Liskov Substitution Principle , LSP) | 所有引用基类的地方必须能透明的使用其子类对象 |
依赖倒转原则 (Dependence Inversion Principle , DIP) | 高层模块不应该依赖低层模块,他们都应该依赖抽象。抽象不应该依赖细节,细节应该依赖于抽象 |
接口隔离原则 (Interface Segregation Principle , ISP) | 客户端不应该依赖那些它不需要的接口 |
合成复用原则 (Conposite Reuse Principle ,CRP) | 优先使用对象组合,而不是通过继承来达到复用的目的 |
迪米特法则 (Law of Demeter , LoD) | 每一个软件单位对其他单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位 |
一、开闭原则:(Open-Closed Principle , OCP)
对扩展开放,对更改封闭.
类模块应该是可扩展的,但是不可修改.
这一条放在第一位来理解,它的含义是对扩展开放,对修改关闭。解释一下就是,我们写完的代码,不能因为需求变化就修改。我们可以通过新增代码的方式来解决变化的需求。
当然,这是一种理想的状态,在现实中,我们要尽量的缩小这种修改。
再解释一下这条原则的意义所在,我们采用逆向思维方式来想。如果每次需求变动都去修改原有的代码,那原有的代码就存在被修改错误的风险,当然这其中存在有意和无意的修改,都会导致原有正常运行的功能失效的风险,这样很有可能会展开可怕的蝴蝶效应,使维护工作剧增。
说到底,开闭原则除了表面上的可扩展性强以外,在企业中更看重的是维护成本。
所以,开闭原则是设计模式的第一大原则,它的潜台词是:控制需求变动风险,缩小维护成本。
以下几种原则,都是为此原则服务的。
举个栗子:
class DrawingBoard{ var lineArray:Array<Line>? var rectArray:Array<Rect>? //新的改变需求 var circleArray:Array<Circle>? func onPaint(event:PaintEventArgs){ //旧代码 for lineInstance in lineArray{ //同下 } for rectInstance in rectArray{ //同下 } //新代码 for circleInstance in circleArray{ event.Graphics.DrawCircle(Pens.Red, circleInstance.leftUp, circleInstance.width, circleInstance.height) } } } class Line{//底层模块(代表容易变化的模块) //... } class Rect{//底层模块(代表容易变化的模块) //... } class Circle{ } |
这种代码就违反了开放封闭原则,它是在改变代码,这就意味着这块代码需要重新编译、重新测试、重新部署,改变的代价十分高昂。
我们依旧像之前那样,重新修改代码:
class DrawingBoard{//绘画板,代表高层模块 var shapeArray:Array<Shape>? func onPaint(){ for shape in shapeArray{ shape.Draw(); } } } abstract class Shape{//抽象接口,同时也是一种稳定的模块(高层和低层都依赖抽象类) func Draw(){ } } class Line extends Shape{//底层模块(代表容易变化的模块) override func Draw() { }//实现细节应该依赖于抽象 } class Rect extends Shape{//底层模块(代表容易变化的模块) override func Draw() { }//实现细节应该依赖于抽象 } class Circle extends Shape{//底层模块(代表容易变化的模块) override func Draw() { }//实现细节应该依赖于抽象 } |
第二种方法明显就是一种以扩展的方式应对新的需求,这就是来自面向对象的智慧。
二、里氏替换选择:(Liskov Substitution Principle , LSP)
子类必须能够替换他们的基类(IS-A)
继承表达类型抽象
此原则的含义是子类可以在任何地方替换它的父类。解释一下,这是多态的前提,我们后面很多所谓的灵活,都是不改变声明类型的情况下,改变实例化类来完成的需求变更。当然,继承的特性看似天然就满足这个条件。但这里更注重的是继承的应用问题,我们必须保证我们的子类和父类划分是精准的。
里氏替换原则的潜台词是:尽量使用精准的抽象类或者接口。
举个栗子:
一般而言,这个原则看起来似乎天经地义,子类替换父类似乎是理所当然的,的确如此,但是不排除有以下情况的出现:
abstract class 乐器{ func 奏乐() -> Void { } func 调音() -> Void { } } class 武器 extends 乐器{ override func 奏乐() -> Void { fatalError("无法奏乐") } override func 调音() -> Void { fatalError("无法调音") } } |
这个设计看上去似乎十分可笑,但是很多程序员在现实设计时,会发现子类有时候确实就是不应该使用父类的方法,于是直接抛出异常。例子看上去很傻瓜,但当真实投入实践,有时候我们就会犯糊涂。
这显然就违背了我们的原则,证明了武器这个类压根就不应该设计为子类。
三、单一职责原则:SRP:Single responsibility principle
一个类应该仅有一个引起它变化的原因
变化的方向隐含着类的责任
单一职责的含义是:类的职责单一,引起类变化的原因单一。
解释一下,这也是灵活的前提,如果我们把类拆分成最小的职能单位,那组合与复用就简单的多了,如果一个类做的事情太多,在组合的时候,必然会产生不必要的方法出现,这实际上是一种污染。
举个栗子:
我们在绘制图案的时候,用“点”组成图和用“直线”组成图,哪个更灵活呢?一定是“点”,它可以绘制任何图形,而直线只能绘制带有直线条的图案,它起码无法画圆。
单一职责的潜台词是:拆分到最小单位,解决复用和组合问题。
四、接口隔离原则:(Interface Segregation Principle , ISP)
不应该强迫客户程序依赖它们不用的方法
接口应该小而完备
接口隔离原则可以说是单一职责的必要手段,它的含义是尽量使用职能单一的接口,而不使用职能复杂、全面的接口。
很好理解,接口是为了让子类实现的,如果子类想达到职能单一,那么接口也必须满足职能单一。
相反,如果接口融合了多个不相关的方法,那它的子类就被迫要实现所有方法,尽管有些方法是根本用不到的。这就是接口污染。
接口隔离原则的潜台词是:拆分,从接口开始。
问题由来:
类A通过接口I依赖类B,类C通过接口I依赖类D,如果接口I对于类A和类B来说不是最小接口,
而实现接口的话又必须实现接口的所有方法,则类B和类D必须去实现他们不需要的方法。
接口隔离解决办法:
将臃肿的接口I拆分为独立的几个接口,类A和类C分别与他们需要的接口建立依赖关系。
这样做的好处:
建立单一接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少。也就是说,我们要为各个类建立专用的接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。在程序设计中,依赖几个专用的接口要比依赖一个综合的接口更灵活。接口是设计时对外部设定的“契约”,通过分散定义多个接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。
五、依赖倒置原则:(Dependence Inversion Principle , DIP)
1.高层模块(稳定)不应该依赖于低层模块(变化),二者都依赖于抽象(稳定)
2.抽象(稳定)不应该依赖于实现细节(变化),实现细节应该依赖于抽象(稳定)
想要理解依赖倒置原则,必须先理解传统的解决方案。面相对象的初期的程序,被调用者依赖于调用者。也就是调用者决定被调用者有什么方法,有什么样的实现方式,这种结构在需求变更的时候,会付出很大的代价,甚至推翻重写。
依赖倒置原则就是要求调用者和被调用者都依赖抽象,这样两者没有直接的关联和接触,在变动的时候,一方的变动不会影响另一方的变动。
其实,依赖倒置和前面的原则是相辅相成的,都强调了抽象的重要性。
依赖倒置的潜台词是:面向抽象编程,解耦调用和被调用者。
举个栗子:
我们没有人是生而知之的,在了解是什么是抽象类之前,我们一定都写过这样的代码:
class DrawingBoard{//绘画板,代表高层模块 var lineArray:Array<Line>? var rectArray:Array<Rect>? func onPaint(){ for lineInstance in lineArray{ event.Graphics.DrawLine(Pens.Red, lineInstance.leftUp, lineInstance.width, lineInstance.height) } for rectInstance in rectArray{ event.Graphics.DrawRect(Pens.Red, rectInstance.leftUp, rectInstance.width, rectInstance.height) } } } class Line{//底层模块(代表容易变化的模块) func Draw(){ ... } } class Rect{//底层模块(代表容易变化的模块) func Draw(){ ... } } |
而这个设计原则告诉我们应该像这样去思考:
class DrawingBoard{//绘画板,代表高层模块 var shapeArray:Array<Shape>? func onPaint(){ for shape in shapeArray{ shape.Draw(); } } } abstract class Shape{//抽象接口,同时也是一种稳定的模块(高层和低层都依赖抽象类) func Draw(){ } } class Line extends Shape{//底层模块(代表容易变化的模块) override func Draw() { }//实现细节应该依赖于抽象 } class Rect extends Shape{//底层模块(代表容易变化的模块) override func Draw() { }//实现细节应该依赖于抽象 } |
结构就变成了这样,看看现在是不是这样的规则:
高层模块(稳定)不应该依赖于低层模块(变化),二者都依赖于抽象(稳定)
抽象(稳定)不应该依赖于实现细节(变化),实现细节应该依赖于抽象(稳定)
六、迪米特法则:(Law of Demeter , LoD)
迪米特原则要求尽量的封装,尽量的独立,尽量的使用低级别的访问修饰符。这是封装特性的典型体现。
一个类如果暴露太多私用的方法和字段,会让调用者很茫然。并且会给类造成不必要的判断代码。所以,我们使用尽量低的访问修饰符,让外界不知道我们的内部。这也是面向对象的基本思路。这是迪米特原则的一个特性,无法了解类更多的私有信息。
另外,迪米特原则要求类之间的直接联系尽量的少,两个类的访问,通过第三个中介类来实现。
迪米特原则的潜台词是:不和陌生人说话,有事去中介。
举个栗子:
当我们按下计算机的关机按钮的时候,计算机会执行一些列的动作会被执行:比如保存当前未完成的任务,然后是关闭相关的服务,接着是关闭显示器,最后是关闭电源,这一系列的操作以此完成后,计算机才会正式被关闭。
现在,我们来用简单的代码表示这个过程,在不考虑迪米特法则情况下,我们可能写出以下代码:
//计算机类 public class Computer { public func saveCurrentTask(){ //do something } public func closeService(){ //do something } public func closeScreen(){ //do something } public func closePower(){ //do something } public func close(){ saveCurrentTask() closeService() closeScreen() closePower() } } //人 public class Person { private var c = Computer() public func clickCloseButton(){ //现在你要开始关闭计算机了,正常来说你只需要调用close()方法即可, //但是你发现Computer所有的方法都是公开的,该怎么关闭呢?于是你写下了以下关闭的流程: c.saveCurrentTask(); c.closePower(); c.close(); //亦或是以下的操作 c.closePower(); //还可能是以下的操作 c.close(); c.closePower(); } } |
发现上面的代码中的问题了没?
我们观察clickCloseButton()方法,我们发现这个方法无法编写:c是一个完全暴露的对象,其方法是完全公开的,那么对于Person来说,当他想要执行关闭的时候,却发现不知道该怎么操作:该调用什么方法?靠运气猜么?如果Person的对象是个不按常理出牌的,那这个Computer的对象岂不是要被搞坏么?
对应迪米特法则的第一要义:代码应该改成如下:
//计算机类 public class Computer { private func saveCurrentTask(){ //do something } private func closeService(){ //do something } private func closeScreen(){ //do something } private func closePower(){ //do something } public func close(){ saveCurrentTask() closeService() closeScreen() closePower() } } //人 public class Person { private var c = Computer() public func clickCloseButton(){ //现在你要开始关闭计算机了,正常来说你只需要调用close()方法即可, c.close(); } } |
七、组合/聚合复用原则:
类继承通常为"白箱复用",对象组合通常为"黑箱复用"
继承在某种程度上破坏了封装性,子类父类耦合度高。
而对象组合则只要求被组合的对象具有良好定义的接口,耦合度低
此原则的含义是,如果只是达到代码复用的目的,尽量使用组合与聚合,而不是继承。这里需要解释一下,组合聚合只是引用其他的类的方法,而不会受引用的类的继承而改变血统。
继承的耦合性更大,比如一个父类后来添加实现一个接口或者去掉一个接口,那子类可能会遭到毁灭性的编译错误,但如果只是组合聚合,只是引用类的方法,就不会有这种巨大的风险,同时也实现了复用。
组合聚合复用原则的潜台词是:我只是用你的方法,我们不一定是同类。
许多初学面向对象的程序员都非常喜欢使用继承。因为面向对象中的继承更符合我们直观的世界观。
就像相较于函数式编程,我们更加会适应命令式编程,因为函数式编程的数学思想不容易被接受,使用命令式编程更明显地看到如何将真实世界中的对象和程序语言中的对象一一对应。
举个栗子:
比如我需要连接数据库 然后写他的DAO层的 crud逻辑:
public class DBConnection { public String getConnection(){ return "MySQL数据库连接"; } } //------------------------------------------------ public class ProductDao extends DBConnection { public void addProduct(){ String conn = super.getConnection(); System.out.println("使用"+conn+"增加产品"); } } |
UML:
这个时候我要切库 咋办?这个时候我要在 DBConnection
添加新的数据库连接方法,违反开闭原则。
使用的组合/复用原则:
public interface DBConnection { public String getConnection(); } //------------------------------------------------ public class MySQLConnection implements DBConnection{ @Override public String getConnection() { return "MySQL数据库连接"; } } //------------------------------------------------ public class PostgreSQLConnection implements DBConnection{ @Override public String getConnection() { return "PostgreSQL数据库连接"; } } //------------------------------------------------ public class ProductDao { private DBConnection dbConnection; public void setDbConnection(DBConnection dbConnection){ this.dbConnection = dbConnection; } public void addProduct(){ String conn = dbConnection.getConnection(); System.out.println("使用"+conn+"增加产品"); } } //------------------------------------------------ public class Test { public static void main(String[] args) { ProductDao productDao = new ProductDao(); productDao.setDbConnection(new MySQLConnection()); productDao.addProduct(); } } |
UML: