3.⾥式替换原则
⾥⽒替换原则由2008年图灵奖得主、美国第⼀位计算机科学⼥博⼠Barbara Liskov教授和卡内基·梅隆⼤学Jeannette Wing教授于1994年提出。其严格表述如下:
如果对每⼀个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所有的对象o1代换o2时,程序P的⾏为没有变化,那么类型S是类型T的⼦类型。这个定义⽐较拗⼝且难以理解,因此我们⼀般使⽤它的另⼀个通俗版定义:
⾥⽒替换原则(Liskov Substitution Principle, LSP):所有引⽤基类(⽗类)的地⽅必须能透明地使⽤其⼦类的对象。
⾥⽒替换原则告诉我们,在软件中将⼀个基类对象替换成它的⼦类对象,程序将不会产⽣任何错误和异 常,反过来则不成⽴,如果⼀个软件实体使⽤的是⼀个⼦类对象的话,那么它不⼀定能够使⽤基类对象。例如:我喜欢动物,那我⼀定喜欢狗,因为狗是动物的⼦类;但是我喜欢狗,不能据此断定我喜欢 动物,因为我并不喜欢⽼⿏,虽然它也是动物。
例如有两个类,⼀个类为BaseClass,另⼀个是SubClass类,并且SubClass类是BaseClass类的⼦类,那么⼀个⽅法如果可以接受⼀个BaseClass类型的基类对象base的话,如:method1(base), 那么它必然可以接受⼀个BaseClass类型的⼦类对象sub,method1(sub)能够正常运⾏。反过来的换不成⽴,如⼀个⽅法method2接受BaseClass类型的⼦类对象sub为参数:method2(sub), 那么⼀般⽽⾔不可以有method2(base),除⾮是重载⽅法。
⾥⽒替换原则是实现开闭原则的重要⽅式之⼀,由于使⽤基类对象的地⽅都可以使⽤⼦类对象,因此在程序中尽量使⽤基类类型来对对象进⾏定义,⽽在运⾏时再确定其⼦类类型,⽤⼦类对象来替换⽗类对 象。
在使⽤⾥⽒替换原则时需要注意如下⼏个问题:
1)⼦类的所有⽅法必须在⽗类中声明,或⼦类必须实现⽗类中声明的所有⽅法。根据⾥⽒替换原则,为了保证系统的扩展性,在程序中通常使⽤⽗类来进⾏定义,如果⼀个⽅法只存在⼦类中,在⽗类中不提供相应的声明,则⽆法在以⽗类定义的对象中使⽤该⽅法。
2)我们在运⽤⾥⽒替换原则时,尽量把⽗类设计为抽象类或者接⼝,让⼦类继承⽗类或实现⽗接⼝,并实现在⽗类中声明的⽅法,运⾏时,⼦类实例替换⽗类实例,我们可以很⽅便地扩展系统的功能,同时⽆须修改原有⼦类的代码,增加新的功能可以通过增加⼀个新的⼦类来实现。⾥⽒替换原则是开闭原 则的具体实现⼿段之⼀。
3)Java语⾔中,在编译阶段,Java编译器会检查⼀个程序是否符合⾥⽒替换原则,这是⼀个与实现⽆关的、纯语法意义上的检查,但Java编译器的检查是有局限的。
示例:
在Sunny软件公司开发的CRM系统中,客户(Customer)可以分为VIP客户(VIPCustomer)和普通客户(CommonCustomer)两类,系统需要提供⼀个发送Email的功能,原始设计⽅案如图1所示:
图1原始结构图
在对系统进⾏进⼀步分析后发现,⽆论是普通客户还是VIP客户,发送邮件的过程都是相同的,也就是说 两个send()⽅法中的代码重复,⽽且在本系统中还将增加新类型的客户。为了让系统具有更好的扩展性,同时减少代码重复,使⽤⾥⽒替换原则对其进⾏重构。
在本实例中,可以考虑增加⼀个新的抽象客户类Customer,⽽将CommonCustomer和VIPCustomer类作为其⼦类,邮件发送类EmailSender类针对抽象客户类Customer编程,根据⾥⽒代换原则,能够接受基类对象的地⽅必然能够接受⼦类对象,因此将EmailSender中的send()⽅法的参数类型改为Customer,如果需要增加新类型的客户,只需将其作为Customer类的⼦类即可。重构后的结构如图2所示:
图2 重构后的结构图
⾥⽒替换原则是实现开闭原则的重要⽅式之⼀。在本实例中,在传递参数时使⽤基类对象,除此以外,在定义成员变量、定义局部变量、确定⽅法返回类型时都可使⽤⾥⽒替换原则。针对基类编程,在程序 运⾏时再确定具体⼦类。
4.依赖倒转原则
如果说开闭原则是⾯向对象设计的⽬标的话,那么依赖倒转原则就是⾯向对象设计的主要实现机制之⼀,它是系统抽象化的具体实现。依赖倒转原则是Robert C. Martin在1996年为“C++Reporter”所写的专栏Engineering Notebook的第三篇,后来加⼊到他在2002年出版的经典著作“Agile Software Development, Principles, Patterns, and Practices”⼀书中。依赖倒转原则定义如下:
依赖倒转原则(Dependency InversionPrinciple, DIP):抽象不应该依赖于细节,细节应当依赖于抽象。换⾔之,要针对接⼝编程,⽽不是针对实现编程。
依赖倒转原则要求我们在程序代码中传递参数时或在关联关系中,尽量引⽤层次⾼的抽象层类,即使⽤接⼝和抽象类进⾏变量类型声明、参数类型声明、⽅法返回类型声明,以及数据类型的转换等,⽽不要⽤具体类来做这些事情。为了确保该原则的应⽤,⼀个具体类应当只实现接⼝或抽象类中声明过的⽅法,⽽不要给出多余的⽅法,否则将⽆法调⽤到在⼦类中增加的新⽅法。
在引⼊抽象层后,系统将具有很好的灵活性,在程序中尽量使⽤抽象层进⾏编程,⽽将具体类写在配置⽂件中,这样⼀来,如果系统⾏为发⽣变化,只需要对抽象层进⾏扩展,并修改配置⽂件,⽽⽆须修改 原有系统的源代码,在不修改的情况下来扩展系统的功能,满⾜开闭原则的要求。
在实现依赖倒转原则时,我们需要针对抽象层编程,⽽将具体类的对象通过依赖注⼊(DependencyInjection, DI)的⽅式注⼊到其他对象中,依赖注⼊是指当⼀个对象要与其他对象发⽣依赖关系时,通过抽象来注⼊所依赖的对象。常⽤的注⼊⽅式有三种,分别是:构造注⼊,设值注⼊(Setter注⼊)和接⼝注⼊。构造注⼊是指通过构造函数来传⼊具体类的对象,设值注⼊是指通过Setter⽅法来传⼊具体类的对象,⽽接⼝注⼊是指通过在接⼝中声明的业务⽅法来传⼊具体类的对象。 这些⽅法在定义时使⽤的是抽象类型,在运⾏时再传⼊具体类型的对象,由⼦类对象来覆盖⽗类对象。
下⾯通过⼀个简单实例来加深对依赖倒转原则的理解:
Sunny软件公司开发⼈员在开发某CRM系统时发现:该系统经常需要将存储在TXT或Excel⽂件中的客户信息转存到数据库中,因此需要进⾏数据格式转换。在客户数据操作类中将调⽤数据格式转换类的⽅法 实现格式转换和数据库插⼊操作,初始设计⽅案结构如图1所示:
图1 初始设计⽅案结构图
在编码实现图1所示结构时,Sunny软件公司开发⼈员发现该设计⽅案存在⼀个⾮常严重的问题,由于每 次转换数据时数据来源不⼀定相同,因此需要更换数据转换类,如有时候需要将TXTDataConvertor改为ExcelDataConvertor,此时,需要修改CustomerDAO的源代码,⽽且在引⼊并使⽤新的数据转换类时 也不得不修改CustomerDAO的源代码,系统扩展性较差,违反了开闭原则,现需要对该⽅案进⾏重 构。
在本实例中,由于CustomerDAO针对具体数据转换类编程,因此在增加新的数据转换类或者更换数据 转换类时都不得不修改CustomerDAO的源代码。我们可以通过引⼊抽象数据转换类解决该问题,在引⼊抽象数据转换类DataConvertor之后,CustomerDAO针对抽象类DataConvertor编程,⽽将具体数据 转换类名存储在配置⽂件中,符合依赖倒转原则。根据⾥⽒代换原则,程序运⾏时,具体数据转换类对象将替换DataConvertor类型的对象,程序不会出现任何问题。更换具体数据转换类时⽆须修改源代码,只需要修改配置⽂件;如果需要增加新的具体数据转换类,只要将新增数据转换类作为DataConvertor的⼦类并修改配置⽂件即可,原有代码⽆须做任何修改,满⾜开闭原则。重构后的结构 如图2所示:
图2重构后的结构图
在上述重构过程中,我们使⽤了开闭原则、⾥⽒代换原则和依赖倒转原则,在⼤多数情况下,这三个设计原则会同时出现,开闭原则是⽬标,⾥⽒代换原则是基础,依赖倒转原则是⼿段,它们相辅相成,相互补充,⽬标⼀致,只是分析问题时所站⻆度不同⽽已。
5.接口分离原则
接⼝隔离原则定义如下:
接⼝隔离原则(Interface Segregation Principle, ISP):使⽤多个专⻔的接⼝,⽽不使⽤单⼀的总接⼝,即客户端不应该依赖那些它不需要的接⼝。
根据接⼝隔离原则,当⼀个接⼝太⼤时,我们需要将它分割成⼀些更细⼩的接⼝,使⽤该接⼝的客户端 仅需知道与之相关的⽅法即可。每⼀个接⼝应该承担⼀种相对独⽴的⻆⾊,不⼲不该⼲的事,该⼲的事都要⼲。这⾥的“接⼝”往往有两种不同的含义:⼀种是指⼀个类型所具有的⽅法特征的集合,仅仅是⼀种逻辑上的抽象;另外⼀种是指某种语⾔具体的“接⼝”定义,有严格的定义和结构,⽐如Java语⾔中的 interface。对于这两种不同的含义,ISP的表达⽅式以及含义都有所不同:
当把“接⼝”理解成⼀个类型所提供的所有⽅法特征的集合的时候,这就是⼀种逻辑上的概念,接⼝的 划分将直接带来类型的划分。可以把接⼝理解成⻆⾊,⼀个接⼝只能代表⼀个⻆⾊,每个⻆⾊都有它特 定的⼀个接⼝,此时,这个原则可以叫做“⻆⾊隔离原则”。
如果把“接⼝”理解成狭义的特定语⾔的接⼝,那么ISP表达的意思是指接⼝仅仅提供客户端需要的⾏ 为,客户端不需要的⾏为则隐藏起来,应当为客户端提供尽可能⼩的单独的接⼝,⽽不要提供⼤的总接⼝。在⾯向对象编程语⾔中,实现⼀个接⼝就需要实现该接⼝中定义的所有⽅法,因此⼤的总接⼝使⽤起来不⼀定很⽅便,为了使接⼝的职责单⼀,需要将⼤接⼝中的⽅法根据其职责不同分别放在不同的⼩ 接⼝中,以确保每个接⼝使⽤起来都较为⽅便,并都承担某⼀单⼀⻆⾊。接⼝应该尽量细化,同时接⼝ 中的⽅法应该尽量少,每个接⼝中只包含⼀个客户端(如⼦模块或业务逻辑类)所需的⽅法即可,这种 机制也称为“定制服务”,即为不同的客户端提供宽窄不同的接⼝。
下⾯通过⼀个简单实例来加深对接⼝隔离原则的理解:
Sunny软件公司开发⼈员针对某CRM系统的客户数据显示模块设计了如图1所示接⼝,其中⽅法dataRead()⽤于从⽂件中读取数据,⽅法transformToXML()⽤于将数据转换成XML格式,⽅法createChart()⽤于创建图表,⽅法displayChart()⽤于显示图表,⽅法createReport()⽤于创建⽂字报表,⽅法displayReport()⽤于显示⽂字报表。
图1 初始设计⽅案结构图
在实际使⽤过程中发现该接⼝很不灵活,例如如果⼀个具体的数据显示类⽆须进⾏数据转换(源⽂件本 身就是XML格式),但由于实现了该接⼝,将不得不实现其中声明的transformToXML()⽅法(⾄少需要提供⼀个空实现);如果需要创建和显示图表,除了需实现与图表相关的⽅法外,还需要实现创建和显 示⽂字报表的⽅法,否则程序编译时将报错。现使⽤接⼝隔离原则对其进⾏重构。
在图1中,由于在接⼝CustomerDataDisplay中定义了太多⽅法,即该接⼝承担了太多职责,⼀⽅⾯导致该接⼝的实现类很庞⼤,在不同的实现类中都不得不实现接⼝中定义的所有⽅法,灵活性较差,如果 出现⼤量的空⽅法,将导致系统中产⽣⼤量的⽆⽤代码,影响代码质量;另⼀⽅⾯由于客户端针对⼤接⼝编程,将在⼀定程序上破坏程序的封装性,客户端看到了不应该看到的⽅法,没有为客户端定制接⼝。因此需要将该接⼝按照接⼝隔离原则和单⼀职责原则进⾏重构,将其中的⼀些⽅法封装在不同的⼩ 接⼝中,确保每⼀个接⼝使⽤起来都较为⽅便,并都承担某⼀单⼀⻆⾊,每个接⼝中只包含⼀个客户端(如模块或类)所需的⽅法即可。
通过使⽤接⼝隔离原则,本实例重构后的结构如图2所示:
图2 重构后的结构图
在使⽤接⼝隔离原则时,我们需要注意控制接⼝的粒度,接⼝不能太⼩,如果太⼩会导致系统中接⼝泛滥,不利于维护;接⼝也不能太⼤,太⼤的接⼝将违背接⼝隔离原则,灵活性较差,使⽤起来很不⽅便。⼀般⽽⾔,接⼝中仅包含为某⼀类⽤户定制的⽅法即可,不应该强迫客户依赖于那些它们不⽤的⽅ 法。
6.合成复⽤原则
合成复⽤原则⼜称为组合/聚合复⽤原则(Composition/Aggregate Reuse Principle, CARP),其定义如下:
合成复⽤原则(Composite Reuse Principle, CRP):尽量使⽤对象组合,⽽不是继承来达到复⽤的⽬的。
合成复⽤原则就是在⼀个新的对象⾥通过关联关系(包括组合关系和聚合关系)来使⽤⼀些已有的对象,使之成为新对象的⼀部分;新对象通过委派调⽤已有对象的⽅法达到复⽤功能的⽬的。简⾔之:复⽤时要尽量使⽤组合/聚合关系(关联关系),少⽤继承。
在⾯向对象设计中,可以通过两种⽅法在不同的环境中复⽤已有的设计和实现,即通过组合/聚合关系或 通过继承,但⾸先应该考虑使⽤组合/聚合,组合/聚合可以使系统更加灵活,降低类与类之间的耦合度,⼀个类的变化对其他类造成的影响相对较少;其次才考虑继承,在使⽤继承时,需要严格遵循⾥⽒ 代换原则,有效使⽤继承会有助于对问题的理解,降低复杂度,⽽滥⽤继承反⽽会增加系统构建和维护 的难度以及系统的复杂度,因此需要慎重使⽤继承复⽤。
通过继承来进⾏复⽤的主要问题在于继承复⽤会破坏系统的封装性,因为继承会将基类的实现细节暴露 给⼦类,由于基类的内部细节通常对⼦类来说是可⻅的,所以这种复⽤⼜称“⽩箱”复⽤,如果基类发⽣改变,那么⼦类的实现也不得不发⽣改变;从基类继承⽽来的实现是静态的,不可能在运⾏时发⽣改变,没有⾜够的灵活性;⽽且继承只能在有限的环境中使⽤(如类没有声明为不能被继承)。
由于组合或聚合关系可以将已有的对象(也可称为成员对象)纳⼊到新对象中,使之成为新对象的⼀部 分,因此新对象可以调⽤已有对象的功能,这样做可以使得成员对象的内部实现细节对于新对象不可⻅,所以这种复⽤⼜称为“⿊箱”复⽤,相对继承关系⽽⾔,其耦合度相对较低,成员对象的变化对新对象的影响不⼤,可以在新对象中根据实际需要有选择性地调⽤成员对象的操作;合成复⽤可以在运⾏时动态进⾏,新对象可以动态地引⽤与成员对象类型相同的其他对象。
⼀般⽽⾔,如果两个类之间是“Has-A”的关系应使⽤组合或聚合,如果是“Is-A”关系可使⽤继承。"Is-A"是 严格的分类学意义上的定义,意思是⼀个类是另⼀个类的"⼀种";⽽"Has-A"则不同,它表示某⼀个⻆⾊ 具有某⼀项责任。
案例讲解
Sunny软件公司开发⼈员在初期的CRM系统设计中,考虑到客户数量不多,系统采⽤MySQL作为数据库,与数据库操作有关的类如CustomerDAO类等都需要连接数据库,连接数据库的⽅法getConnection()封装在DBUtil类中,由于需要重⽤DBUtil类的getConnection()⽅法,设计⼈员将CustomerDAO作为DBUtil类的⼦类,初始设计⽅案结构如图1所示:
图1 初始设计⽅案结构图
随着客户数量的增加,系统决定升级为Oracle数据库,因此需要增加⼀个新的OracleDBUtil类来连接Oracle数据库,由于在初始设计⽅案中CustomerDAO和DBUtil之间是继承关系,因此在更换数据库连接⽅式时需要修改CustomerDAO类的源代码,将CustomerDAO作为OracleDBUtil的⼦类,这将违反开 闭原则。【当然也可以修改DBUtil类的源代码,同样会违反开闭原则。】现使⽤合成复⽤原则对其进⾏重构。
根据合成复⽤原则,我们在实现复⽤时应该多⽤关联,少⽤继承。因此在本实例中我们可以使⽤关联复⽤来取代继承复⽤,重构后的结构如图2所示:
图2 重构后的结构图
在图2中,CustomerDAO和DBUtil之间的关系由继承关系变为关联关系,采⽤依赖注⼊的⽅式将DBUtil 对象注⼊到CustomerDAO中,可以使⽤构造注⼊,也可以使⽤Setter注⼊。如果需要对DBUtil的功能进⾏扩展,可以通过其⼦类来实现,如通过⼦类OracleDBUtil来连接Oracle数据库。由于CustomerDAO 针对DBUtil编程,根据⾥⽒代换原则,DBUtil⼦类的对象可以覆盖DBUtil对象,只需在CustomerDAO中 注⼊⼦类对象即可使⽤⼦类所扩展的⽅法。例如在CustomerDAO中注⼊OracleDBUtil对象,即可实现Oracle数据库连接,原有代码⽆须进⾏修改,⽽且还可以很灵活地增加新的数据库连接⽅式。
7.迪米特原则
迪⽶特法则来⾃于1987年美国东北⼤学(Northeastern University)⼀个名为“Demeter”的研究项⽬。迪⽶特法则⼜称为最少知识原则(LeastKnowledge Principle, LKP),其定义如下:
迪⽶特法则(Law ofDemeter, LoD):⼀个软件实体应当尽可能少地与其他实体发⽣相互作⽤。
如果⼀个系统符合迪⽶特法则,那么当其中某⼀个模块发⽣修改时,就会尽量少地影响其他模块,扩展 会相对容易,这是对软件实体之间通信的限制,迪⽶特法则要求限制软件实体之间通信的宽度和深度。 迪⽶特法则可降低系统的耦合度,使类与类之间保持松散的耦合关系。
迪⽶特法则还有⼏种定义形式,包括:不要和“陌⽣⼈”说话、只与你的直接朋友通信等,在迪⽶特法则 中,对于⼀个对象,其朋友包括以下⼏类:
当前对象本身(this);
以参数形式传⼊到当前对象⽅法中的对象;
当前对象的成员对象;
如果当前对象的成员对象是⼀个集合,那么集合中的元素也都是朋友;
当前对象所创建的对象。
任何⼀个对象,如果满⾜上⾯的条件之⼀,就是当前对象的“朋友”,否则就是“陌⽣⼈”。在应⽤迪⽶特法则时,⼀个对象只能与直接朋友发⽣交互,不要与“陌⽣⼈”发⽣直接交互,这样做可以降低系统的耦合度,⼀个对象的改变不会给太多其他对象带来影响。
迪⽶特法则要求我们在设计系统时,应该尽量减少对象之间的交互,如果两个对象之间不必彼此直接通信,那么这两个对象就不应当发⽣任何直接的相互作⽤,如果其中的⼀个对象需要调⽤另⼀个对象的某个⽅法的话,可以通过第三者转发这个调⽤。简⾔之,就是通过引⼊⼀个合理的第三者来降低现有对象之间的耦合度。
在将迪⽶特法则运⽤到系统设计中时,要注意下⾯的⼏点:在类的划分上,应当尽量创建松耦合的类,类之间的耦合度越低,就越有利于复⽤,⼀个处在松耦合中的类⼀旦被修改,不会对关联的类造成太⼤波及;在类的结构设计上,每⼀个类都应当尽量降低其成员变量和成员函数的访问权限;在类的设计上,只要有可能,⼀个类型应当设计成不变类;在对其他类的引⽤上,⼀个对象对其他对象的引⽤应当 降到最低。