Design-Pattern-Design-Principle
设计模式是一系列优秀的代码构思实践。但从代码的功能性角度来说,很难定义什么是优秀的。“这不是也能跑吗”是对这个想法的准确体现。如果两份代码执行表现完全相同,凭什么说,其中一份比另外一份代码要好?设计原则回答了这个问题。
在日常开发中,看到一份代码变量名规范,注释规范,我们会说代码是好的。这是从可读性上对代码进行的评判。而设计原则从另一个角度进行评价:代码复用和拓展性。有时候设计原则和可读性是相违背的,一些设计模式为了增加复用性和可扩展性,反而会把类的结构变得更复杂,影响可读性。
代码优秀特征
代码复用
可以通过观察代码中重复或相似的片段的多少,来体现代码复用执行的程度。
如果一份代码在多处被使用,那么可以抽取成一个方法,并在使用处改写为对方法的调用。方法中可能发生变化的地方,可以抽取成方法的参数。
1 |
|
这样做的好处是,如果这些反复使用的代码要进行改动,那么只需要改动抽取后的方法就可以了。不采取复用的话,需要找到所有使用这些代码的地方进行修改。而且代码复用本身也可以减少开发量,缩短开发周期。
从小的层面讲,将多处使用到的代码,抽取成一个方法,或者把相关的方法再次打包成一个类,就是代码复用。开发中经常会有一些工具类,提供一些静态方法,可以在多个地方被使用。工具类就是代码复用的一个体现。
往大了说,框架本身也是代码复用的体现。不同的项目可以根据业务特殊需求,自定义子类,而使用共同的SpringBoot
框架。
而在这两个层次之间,就是设计模式。设计模式通常用来组织少数对象之间的关系和其间的互动行为。在设计模式中,可以将描述的角色替换成不同的具体类,来实现代码复用。
拓展性
变化是程序员生命中唯一不变的事情。
程序员经常会面对新出现的需求,进行新功能的开发。这些新功能可能和已有的功能有些相似,但也不完全相同。考虑之前提到的代码复用,可能需要对相似的部分进行抽取,或者所有代码都得重写。但这样不可避免就会破坏已有的代码,没有人能保证代码重构后,原功能仍然能正常运行。如果每次添加新功能,都需要将已有的功能重新测试,这无疑增加了工作量。因此在首次开发功能时,就需要对未来可能会出现的新需求进行预测和假设。
一份代码,在增加新功能时非常方便,并且不会破坏原有代码时,则称其有良好扩展性。
设计原则
为了使代码更优秀,也就是使代码拥有更好的复用性和可拓展性,程序员总结出了一系列设计原则。在开发中遵守这些原则,就可以提高代码质量。设计模式中或多或少都体现了对这些原则的遵守。值得一提的是,原则不是必须遵守的,程序员需要根据实际情况,衡量遵守原则的收益和代价。代价通常是前文提到的代码复杂性和可读性。
封装变化的原则
找到程序中的变化内容并将其与不变的内容区分开。
该原则的主要目的是将变更造成的影响最小化。根据未来是否可能会发生变化,将变化部分的代码抽取成一个方法。当进行修改时,只在抽取后的方法中进行修改。这样不会对调用处的原方法造成更多影响。要说影响的话,也就具体返回值可能会发生变化,从而将影响控制到了最小程度。
在代码中,通常会有根据字符串或者某些标识符,进行switch case
判断,来决定执行的逻辑。通常可以认为,未来会有更多的case
情况,也就是会发生变化。从而将switch case
部分进行方法抽取,将变量或标识符通过参数传递到抽取的方法中。
1 |
|
将变化部分的代码抽取成方法,这是方法层面的封装。如果将类中常发生变动的成员变量和方法进行抽取成一个新的类,这是类层面的封装。多个变化的类,甚至可以以成员变量的形式共享同一个不变的类,这也是享元
设计模式的核心思想。
面向接口而不是面向实现的原则
之前提到,接口可以视为一些具体类的集合。
在代码中,如果将变量定义为某个具体类(相对于抽象类/接口),那么需要使用新扩展的具体类时,就需要改动使用处的变量类型。这也违反了上一条封装变化
的原则。说明原则之间不全是相互独立的,其间也可能存在交叉的关系。
符合原则的做法是,将可能会扩展的类,和已有的具体类中方法相似的部分,抽取成一个接口,将新扩展和已有的具体类都实现这个接口,并将所有用到这些类的地方的变量类型设置为接口类型,通过多态性调用具体类的实现。接口中定义了允许调用的方法,规范了类和类之间交互的行为。新定义的类只要符合规范(实现接口),就可以无缝扩展到已有代码中。程序员通过查看接口代码,就基本能了解其具体实现类能做的行为。
在一些SpringBoot
项目中,会采用分层模型:Controller
、Service
、DAO
。在Service
层中,可以定义一系列的Service接口
,并使用ServiceImpl
去实现具体类。然后在Controller
中使用Service
时,通过接口进行调用。
1 |
|
但接口也会使代码更复杂。本来只有两个具体类,现在还多了一个接口。如果项目代码中ServiceImpl
和Service
是一一对应关系,也就是没有进行扩展,可能也不需要将其拆成一个接口和一个类。具体是否拆成两个的代码习惯得参照公司规范。
组合优于继承
如果几个类都具有共同的属性和方法,那么可以通过继承,来实现代码复用。这些类通常在概念上是互相相关的,例如丘丘人和史莱姆都属于怪物,体现出is-a
关系。
具体做法是将共同的属性和方法都抽取到一个怪物类中,然后将丘丘人和史莱姆都继承于怪物类。为了使子类必须实现怪物类中定义的方法,通常会将怪物类设置为抽象类。而且怪物本身也是个抽象概念,不应该被实例化。
但问题在于,无法选择性继承,这意味着对于某些怪物,基类中的某些方法可能不是必须的。例如有些丘丘人萨满无法攻击,那么攻击力属性和攻击方法,可能就是不需要的。风史莱姆会飞,但对于土史莱姆来说,飞行方法可能是不需要的。当然可以从怪物抽象类中,继承出会攻击怪物和不会攻击怪物抽象类,再将会攻击怪物分成会攻击会飞行怪物,和会攻击不会飞行怪物。到这里已经能体会到继承可能存在的问题了。
因为无法选择性继承,而某些具体类又不需要完全用到被继承类的所有属性和方法时,就需要多加一层继承关系。当在多个独立的维度上同时发生这些问题时,会造成类的组合爆炸。
更加好的做法是,定义两个接口,Flyable
和Attackable
。并根据具体类的情况,实现其中对应的接口。而如果多个会飞的怪物,他们的飞行属性和飞行方法中,代码相似度非常高,为了实现代码复用,可以将飞行相关的属性和方法抽取成FlyAbility
类,并实现Flyable
接口。然后以成员变量的形式将FlyAbility
组合/聚合到具体怪物类中。调用具体怪物类的飞行方法时,将调用FlyAbility
成员变量的飞行方法。
1 |
|
代码中,风史莱姆是通过组合了FlyAbility
变量,而不是继承的形式,来获得了飞行相关的属性,实现了代码复用。这就是组合优于继承。
但原则不是绝对的。从代码可以观察到类的结构确实因为组合而变得复杂了许多。一般来说,如果一些对象,其属性和方法没有在多个维度有区分,继承关系比较简单,那么使用继承也是可以的。当需要对对象进行细粒度拆分,可能组合是更好的选择。
总结
限于篇幅,SOLID原则
放到下篇讲。
在代码能按照设计功能正常运行时,我们通常通过可读性、复用性、可拓展性来衡量一份代码的好坏。
为了提高代码的复用性和可拓展性,程序员总结了一系列的设计原则,遵守这些原则可以写出更优秀的代码。设计模式是这些原则的优秀实践。