2.3 软件设计的7条原则
在软件开发时,为了提高软件系统的可维护性和可复用性、增加软件的可扩展性和灵活性,通常要遵守一定的设计原则。本节将介绍在软件开发中公认的7条设计原则,即开闭原则、里式替换原则、依赖倒置原则、单一职责原则、接口隔离原则、迪米特原则、合成复用原则。很多设计模式都是基于这7条原则实现的。
2.3.1 开闭原则
勃兰特·梅耶在1988年的著作《面向对象软件设计》中提出:软件实体应当对扩展开放,对修改关闭。这成为开闭原则的经典定义。
开闭原则是软件设计的终极目标,对扩展开放可以使得软件拥有一定的灵活性,同时对修改关闭又可以保证软件的稳定性。使用开闭原则设计的软件有如下优势:
(1)测试方便。由于开闭原则对修改关闭,因此软件实体是拥有稳定性的,测试时只需要对扩展代码进行测试即可。
(2)更好地提高代码复用性。开闭原则通常采用抽象接口的方式来组织代码结构,抽象的编程本身就对代码的复用性提高有很大的帮助。
(3)提高软件的维护性和扩展性。由于开闭原则对扩展开放,因此当软件需要升级时,可以很容易地通过扩展来实现新的功能,开发效率会更高,代码也更易于维护。
在面向对象开发中,实现开闭原则可以通过继承父类与实现接口两种方式。在开闭原则中,一个类只应该因为错误而修改,新加入的功能都不应该修改原始的代码。
继承的方式通过让子类继承父类来实现扩展性。子类可以重写父类的方法来实现差异的功能,也可以部分复用父类的代码,在此基础上添加新的逻辑功能。
以应用皮肤主题设计为例来理解开闭原则,首先使用Xcode开发工具新建一个命名为OpenClosePrinciple.playground的文件,在其中编写如下代码:
上面代码描述了一个简单的主题应用的逻辑。Color枚举定义了颜色。Style类是一个主题类,其中定义了此主题的背景颜色是黑色、文字颜色是白色,并调用apply方法进行主题的应用。上面的代码在apply方法中还做了简单的打印操作。假设我们需要添加一个背景色为白色、文字颜色为黑色且按钮颜色为紫色的主题。一种方式是直接对Style类进行修改,使其符合我们的需求,但这明显违背了开闭原则;另一种方式是创建一个继承于Style的类,用来扩展新的功能,例如:
从上面的代码可以看出,通过继承方式实现的开闭原则并不彻底。通过接口可以更好地实现开闭原则,改写代码如下:
如上代码所示,StyleInterface是一个协议,协议中定义了与主题相关的一些属性和应用主题的方法,后面当我们需要扩展多个主题时,只需要对此接口进行不同的实现即可,不会影响到其他已经存在的主题类。
2.3.2 里式替换原则
里式替换原则是里斯科夫在1987年的“面向对象技术高峰会议”上提出的,其核心观念是:继承必须保证超类所拥有的性质在子类中依然成立。遵守里式替换原则,在进行类的继承时,要保证子类不对父类的属性或方法进行重写,而只是扩展父类的功能。其实,里式替换原则是对开闭原则一种更严格的补充,除了有开闭原则带来的优势外,也保证了继承中重写父类方法造成的可复用性变差与稳定性变差的问题。
里式替换原则在实际编程中主要应用在类的组织结构上,对于继承的设计,子类不可以重写父类的方法,只能为父类添加方法。如果在设计时,发现子类不得不重写父类的方法,则表明类的组织结构有问题,需要重新设计类的继承关系。
例如,假设我们的程序中需要组织鸟类与鸵鸟类,我们可能很容易写出下面这样的代码:
如上代码所示,Bird类作为鸟类的基类,其中定义了一个通用的构造方法和一个鸟类飞行的方法,在设计鸵鸟类时,我们让其继承自鸟类,并且扩展了一个奔跑的方法。因为鸵鸟虽然在很多方面都有鸟的特征,但是其不能飞行,因此我们需要重写鸟类中的fly方法来修正其飞行的行为。这样做虽然实现了需求,但是违反了里式替换原则。在这种情况下,我们需要对类的继承关系进行重构,最便捷的方式是将抽象的部分再进行一层抽离。示例如下:
如上代码所示,将通用的名称属性和构造方法抽离到了Animal类中,让Bird类与Ostrich类都继承于Animal类,Bird类对Animal类扩展了fly方法,Ostrich类对Animal类扩展了run方法,这样就遵守了里式替换原则,对原Animal类没有任何修改。
2.3.3 单一职责原则
单一职责原则是由罗伯特·C. 马丁最初在《敏捷软件开发:原则、模式和实践》一书中提出的一种软件设计原则。其核心是一个类只应该承担一项责任,在实际设计中,可以以是否只有一个引起类变化的原因作为准则,如果不止一个原因会引起类的变化,则需要对类重新进行拆分。
如果一个类或对象承担了太多的责任,则其中一个责任的变化可以带来对其他责任的影响,且不利于代码的复用性,容易造成代码的冗余和浪费。遵守单一职责原则设计的程序有以下几个特点:
- 降低类的复杂度,一个类只负责单一的职责,逻辑清晰,提高内聚,降低耦合。
- 提高代码的可读性,提高代码的可复用性。
- 增强代码的可维护性与可扩展性。
- 类的变更是必然的,功能的增加必然会产生类的变更,单一职责可以使变更带来的影响最小。
下面,我们以用户界面管理类的设计来演示单一职责原则的应用。首先使用Xcode开发工具创建一个playground文件,在其中编写如下代码:
如上代码所示,UserInterface类模拟了用户界面,其中提供了show方法来进行页面的展示,但是用户页面都是需要通过数据来展示的,因此这个类中还封装了一个名为loadData的方法来加载数据。这样设计,UserInterface类就承担了两个职责,分别为加载数据与展示界面,违反了单一职责原则。假如,后面我们需要对数据加载的逻辑进行修改,则必然会影响到界面展示的逻辑,代码演示如下:
根据单一职责原则的定义,我们可以对UserInterface类进行重构,对职责进行拆分,使UserInterface类只负责界面的展示,数据的加载交给另外的类负责,示例代码如下:
重构后的代码遵守了单一职责原则,将数据的加载与界面的展示进行拆分,之后无论是修改数据的加载逻辑还是页面的展示逻辑,他们之间都不会产生影响。
2.3.4 接口隔离原则
接口隔离原则要求编程人员将庞大臃肿的接口定义拆分成更小和更具体的接口。接口只暴露类需要实现的方法。和单一职责原则类似,接口隔离原则主要要求接口在定义时职责要单一,其“隔离”主要是指对接口依赖的隔离。
使用接口隔离原则设计的程序接口有如下几点优势:
- 将庞大的接口拆分成细粒度更小的接口,灵活性和扩展性更好,也更易于在类实现时遵守单一职责原则。
- 接口隔离提高了系统的内聚性,降低了系统的耦合性。
- 有利于代码的复用性,减少冗余代码。
以我们之前所举例过的用户页面的展示为例,首先编写如下代码:
上面的代码明显违反了单一职责原则,做简单的修改,将数据提供与页面展示逻辑分开,具体如下:
从改写后的代码可以看出,虽然使用单一职责原则将数据的加载与页面的展示进行拆分,但是接口并没有隔离,在DataLoader类与UserInterface类中都必须实现其不需要使用的接口。因此,我们需要使用接口隔离原则对UserInterfaceProtocol的定义也进行拆分,具体如下:
2.3.5 依赖倒置原则
依赖倒置原则是面向对象开发中非常重要的一个原则,在大型项目的开发中,通常会采用分层的方式进行开发,即上层调用下层,上层依赖于下层,这样就会产生上层对下层的依赖。当下层设计产生变动时,上层代码也需要跟着做调整,这样会导致模块的复用性降低,并且大大地提高开发成本。
面向对象编程很大的优势在于其方便地对问题进行抽象,一般情况下抽象层很少产生变化。依赖倒置原则的定义就是:高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。
依赖倒置原则与开闭原则核心思路相同,都是要尽量减少对已有代码的修改,同时又易于进行扩展。依赖倒置原则有如下优势:
- 由于都对接口进行依赖,因此减少了类之间的耦合。
- 封闭了对类实现的修改,增强了程序的稳定性。
- 对开发过程来说,依赖倒置原则的核心是面向接口开发,减少了并行开发的依赖与风险。
- 提高代码的可读性与可维护性。
依赖倒置原则实现的核心是面向接口编程,下面通过顾客购买商品这一逻辑的设计来演示如何实现依赖倒置原则:
如上代码所示,顾客类Customer中的shopping购物方法依赖了FoodStore类。这样一来,如果后面有新的类型的商店出现,顾客若需要在新商店购买商品,则需要对Customer类的实现进行修改。此时我们就需要使用依赖倒置原则对代码进行重构,通过定义接口来使得Customer类只对抽象的接口进行依赖,具体如下:
通过重构后的设计,Customer类去除了对FoodStore类的依赖,因此对商店进行扩展非常方便,例如:
2.3.6 迪米特原则
迪米特原则又叫最少知识原则。其核心为一个类或对象应尽可能少地与其他实体发生相互作用。其初衷是为了降低类的耦合,但是需要注意,由于要符合迪米特原则,需要创建许多额外的中介类,过多的中介类会增加系统的复杂度,有时反而会得不偿失,因此在使用迪米特原则的时候要慎重。
以公司经理工作系统为例,公司的经理每天可能会处理很多事情,例如与客户谈合作、与公司管理人员开会、参加交流会议等。示例代码如下:
如上代码所示,在Boss类中,对Customer、Manager与SocialAffair类都进行了依赖。如果使用迪米特原则进行重构,则可以通过引入一个秘书类来消除Boss类对过多类的依赖,具体如下:
如上代码所示,使用迪米特原则重构后的代码引入了Secretary类,Boss类只对Secretary进行依赖,经理一日的工作日程都交给了秘书进行处理,但是需要注意,重构后的代码比之前复杂度更高,因为引入了额外的中介类Secretary。通常,我们不会对单独的类使用迪米特原则,这样做的解耦效果并不明显,但是如果模块与模块之间的交互通过一个中介类来统一处理原则可以大大减小模块间的耦合程度,这是迪米特原则的主要应用之处。
2.3.7 合成复用原则
合成复用原则的核心为在设计类的复用时,要尽量先使用组合或聚合的方式设计,尽少的使用继承。合成复用原则与里式替换原则是互为补充的。合成复用原则提倡尽量不使用继承,如果使用继承,则要遵守里式替换原则。
合成复用原则通过组合和聚合的方式实现复用,实现上通常使用属性、参数的方式引入其他实体进行通信。相对继承,有如下优势:
- 维持了类的封装性。
- 类之间的耦合性降低。
- 复用的灵活性提高,通过协议可以动态地修改引入实体的行为。
以教师系统的设计为例,教师根据所教授的科目不同可以分为数学教师、自然教师等,他们之间可以采用继承的关系实现,例如:
其实,上面的代码所描述的场景就是一个非常适合使用合成复用原则的场景,我们可以将科目独立成类,将其作为Teacher类中的一个属性进行关联,除去继承带来的对Teacher类的封闭性破坏,重构后的代码如下:
重构后的代码中,Teacher类与Subject类都保持了很好的封闭性,并且增强了程序的扩展性,在设计软件时,合成复用原则和里式替换原则要统一考虑进行选择。
到本节为止,我们已经简单介绍了软件设计领域内通用的7种设计原则,后面我们将要介绍的设计模式都是基于这7种设计原则而总结的软件设计方法。我们所介绍的7种设计原则各有侧重点:开闭原则是核心原则,要求我们在设计软件时保持扩展的开放性与修改的封闭性;里式替换原则要求在进行继承时,子类不要破坏父类的实现;单一职责原则要求类的功能要单一;接口隔离原则要求接口的设计要精简;依赖倒置原则要求要面向抽象编程,即面向接口编程;迪米特原则提供了一种降低系统耦合性的方式;合成复用原则要求组织类的关系时谨慎使用继承。