软件设计原则
- 合成复用原则
- 里氏替换原则
- 开闭原则
- 接口隔离原则
- 迪米特法则
- 依赖倒置原则
- 单一职责原则
速记:合理开接第一单(合成复用、里氏替换、开闭、接口隔离、迪米特、依赖倒置、单一职责)–哈哈,也复合设计原则是软件开发的重要性嘛。
一.合成复用原则
1.实现方法:
将已有的对象纳入新对象中,作为新对象的成员对象。新对象可以调用已有对象的功能,从而达到复用。
2.类的复用分为:
- 继承复用(里氏替换原则)
- 合成复用(合成复用原则)
3.继承复用缺点:
- a.父类的静态实现不允许重写;
- b.因为实现继承要将父类的private改成protected(“白箱”)–父类的封装性、实现细节都被子类破坏了。
4.合成复用优点:
- a.“黑箱”复用,维持了类的封装性;
- b.新旧类之间的耦合度低–通过成分对象的接口进行访问;
- c.高灵活性的复用–新对象可以动态地引用与成分对象类型相同的对象。
5.举例说明:
关于复用的例子:
- 继承复用:父类–车;子类–奔驰,宝马,奥迪;子类的继承类–奔驰S400、奔驰E200、宝马740。
- 合成复用:父类–车。车中包含两个属性类:车类型类(奔驰、宝马等)和系列类(S400、740等)。
继承复用问题:顶层属性(车)和底层属性(‘颜色)先不论,单看“品牌”这一属性,子类的继承类破坏了子类的封装性。解决办思路:使用合成复用,新建品牌和颜色两个类集成在“车”类中,这样可以避免破坏封装性。
另一个实际例子:
- 背景:假设目前只有一个数据库链接工具类–DbConn,仅实现mysql的链接。
- 问题:现在要建立oracle时,需要在DbConn新增链接,会违背开闭原则。
- 解决办法是:新建DbAbstractConn,然后重构原DbConn为MysqlConn继承DbAbstractConn并实现抽象方法,再新建OracleConn同样操作。
二.里氏替换原则
1.解释:
子类可以扩展父类的功能,但不能改变父类原有的功能。即:将一个基类对象替换成它的子类对象,程序将不会产生任何错误和异常
2.实现方法:
- 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法
- 子类中可以增加自己特有的方法
- 当子类的方法重载父类的方法时,方法的前置条件(即方法的输入参数)要比父类的方法更宽松
说明:当子类的方法条件更加宽松时,子类对象替换基类对象后,会优先匹配更加精确的父类方法。然后才是稍微模糊的子类方法。父类优先,就不会产生矛盾(参照1中的替换要求)。
- 当子类的方法实现父类的方法时(重写/重载或实现抽象方法),方法的后置条件(即方法的的输出/返回值)要比父类的方法更严格或相等
说明:若在继承重写时,子类的方法返回值类型范围比父类的方法返回值类型范围大,在子类重写该方法时编译器会报错。
3.优点:
明确什么时候应该使用继承,什么时候不应该使用继承,以及其中蕴含的原理。(里氏替换原是继承复用的基础)
- 里氏替换原则是实现开闭原则的重要方式之一。
- 它克服了继承中重写父类造成的可复用性变差的缺点。
- 它是动作正确性的保证。即类的扩展不会给已有的系统引入新的错误,降低了代码出错的可能性。
- 加强程序的健壮性,同时变更时可以做到非常好的兼容性,提高程序的维护性、可扩展性,降低需求变更时引入的风险。
4.举例说明:
“几维鸟不是鸟”。(几维鸟是一种不会飞的鸟)
- 常用的继承替换:基类–鸟;子类–燕子类、几维鸟类;
问题:在调用基类方法计算1km飞行时间时,由于几维鸟飞行速度为0(不会飞),会发生错误(任何数都无法除以零),违反了里氏替换原则。
- 里氏替换原则:建立一个鸟类和几维鸟的基类,几维鸟的飞行时间计算方法定制化。则几维鸟和燕子都可以替换改基类进行计算。
三.开闭原则
1.解释:
当应用的需求改变时,在不修改软件实体的源代码或者二进制代码的前提下,可以扩展模块的功能,使其满足新的需求。
补充:源代码方式和二进制包是软件包的两种形式。二进制包里面包括了已经经过编译,可以马上运行的程序;源代码包里面包括了程序原始的程序代码,需要在计算机上进行编译以后才可以产生可以运行。
2.实现方法:
- 抽象约束:通过接口或者抽象类为软件实体定义一个相对稳定的抽象层。
- 封装变化:将相同的可变因素封装在相同的具体实现类中。
合理的抽象,可基本保持软件架构的稳定。而当软件需要发生变化时,只需要根据需求重新派生一个实现类来扩展就可以了。
3.优点:
面向对象程序设计的终极目标。
- 代码可复用性
粒度越小,被复用的可能性就越大。 - 软件可维护性
遵守开闭原则的软件,其稳定性高和延续性强,易于扩展和维护。 - 软件测试
软件测试时只需要对扩展的代码进行测试即可。
4.举例说明:
再原来课程接口的价格方法上,新增一个特价方法。
接口:ICourse(有方法:getPrice();),实现类:EnglishCourse(实现getPrice())。英语课的特价方法如何实现?
- 在ICourse中新增getSalePrice()接口–不行!破坏了接口作为契约的作用。
- 在EnglishCourse中新增getSalePrice()方法–不行!违背了开闭原则。
- 新建子类SaleEnglishCourse继承EnglishCourse,重写getPrice()方法–可以!满足设计原则。
四.接口隔离原则
1.解释:
要为各个类建立它们需要的专用接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。
个人理解:接口根据类的需要定制且专用,应该单一纯粹,避免复杂、庞大。
2.实现方法:
在具体应用接口隔离原则时,应该根据以下几个规则来衡量。
- 接口尽量小。一个接口只服务于一个子模块或业务逻辑。
- 专门服务依赖接口的类。只提供调用者需要的方法,屏蔽不需要的方法。
- 了解环境。每个项目或产品都有选定的环境因素,环境不同,接口拆分的标准就不同深入了解业务逻辑。
- 减少对外交互。使接口用最少的方法去完成最多的事情,提高内聚。
3.优点:
接口隔离原则是为了约束接口、降低类对接口的依赖性,遵循接口隔离原则有以下 5 个优点。
-
提高系统的灵活性和可维护性:将臃肿庞大的接口分解为多个粒度小的接口,可以预防外来变更的扩散。
-
降低了系统的耦合性:接口隔离提高了系统的内聚性,减少了对外交互。
-
保证系统的稳定性:前提是接口的粒度大小定义合理;
但是,如果定义过小,则会造成接口数量过多,使设计复杂化;
如果定义太大,灵活性降低,无法提供定制服务,给整体项目带来无法预料的风险。
-
使用多个专门的接口还能够体现对象的层次,因为可以通过接口的继承,实现对总接口的定义。
-
能减少项目工程中的代码冗余:过大的大接口里面通常放置许多不用的方法,当实现这个接口的时候,被迫设计冗余的代码。
4.举例说明:
一个人会开卡车坦克,但代码无法实现
- 接口:IVehicle(有方法:Run();)和ITank(有方法:Run()、Fire())
- IVehicle实现类:Truck。
- ITank实现类:Type99
- 调用类:Driver–定义IVehicle和ITank成员,但写Drive()方法时一般并不会同时集成两个成员的Run()。即该人只能开一种。
- 解决办法:这里的ITank接口中还定义的Run()就不够纯粹,可以通过基层来控制,而加上其特有的Fire()。ITank继承IVehicle并增加Fire()。那么调用类Driver只用IVehicle来开就行。
五.迪米特原则
1.解释:
如果两个软件实体无须直接通信,那么就不应当发生直接的相互调用,可以通过第三方转发该调用。
2.实现方法:
在运用迪米特法则时要注意以下 6 点。
- 创建弱耦合的类。类与类之间的耦合越弱,就越有利于实现可复用的目标。
- 访问权限:在类的结构设计上,尽量降低类成员的访问权限。
- 不变类:在类的设计上,优先考虑将一个类设置成不变类。
- 少引用:在对其他类的引用上,将引用其他对象的次数降到最低。
- 不暴露类的属性成员,而应该提供相应的访问器(set 和 get 方法)。
- 谨慎使用序列化(Serializable)功能。具体可以参考:https://my.oschina.net/u/3847203/blog/3010010 和 https://blog.csdn.net/qq_18298439/article/details/80607057
3.优点:
迪米特法则要求限制软件实体之间通信的宽度和深度,正确使用迪米特原则将有以下两个优点。
- 降低了类之间的耦合度,提高了模块的相对独立性。
- 由于亲合度降低,从而提高了类的可复用率和系统的扩展性。
4.举例说明:
关电脑:
System接口:提供关进程、关服务、关屏幕、关电源等方法;Person类:关闭电脑–>封装一个方法,依次执行关进程、关服务、关屏幕、关电源。
- 问题1:违背接口隔离原则。应该将关进程、关服务、关屏幕、关电源等方法合成到一个接口里面
- 问题2:违背迪米特原则。System不是Person的直接朋友,Person的直接朋友是硬件容器
改进办法:System接口只提供一个接口–关闭电脑(具体实现由其对应子类完成)。此外,新建硬件容器接口IContainer及其实现类Container,用于充当“中间人”,封装System的关闭电脑操作,同时向Person提供出来。
六.依赖倒置原则
1.解释:
要面向接口编程,不要面向实现编程。
由于在软件设计中,细节具有多变性,而抽象层则相对稳定,因此以抽象为基础搭建起来的架构要比以细节为基础搭建起来的架构要稳定得多。这里的抽象指的是接口或者抽象类,而细节是指具体的实现类。
2.实现方法:
依赖倒置原则的目的是通过要面向接口的编程来降低类间的耦合性。牢记以下四点:
-
每个类尽量提供接口或抽象类,或者两者都具备。
-
变量的声明类型尽量是接口或者是抽象类。(如:IVehicle truck= new Truck();)
-
任何类都不应该从具体类派生。
为了避免底层类的修改造成派生类功能的改变。举个简单的例子。a=1b=a+1。这时你想让c=3可以用c=b+1。但是如果a变了,a=2了,c的结果也跟着错了。可以按照IVehicle 与Truck的关系来理解。
-
使用继承时尽量遵循里氏替换原则。
3.优点:
依赖倒置原则的主要作用如下。
- 依赖倒置原则可以降低类间的耦合性。
- 依赖倒置原则可以提高系统的稳定性。
- 依赖倒置原则可以减少并行开发引起的风险。
- 依赖倒置原则可以提高代码的可读性和可维护性。
4.举例说明:
以下这段代码是小白我在开发中会想到的思路,并且好像这样写过。特别贴出代码,警示自己要记住别这样写!
private HondaCar hcar = new HondaCar();
private FordCar fcar = new FordCar();
private CarType type;
public enum CarType{ Ford, Honda };
// 这个是构造方法
public AutoSystem(CarType type) {
this.type = type;
}
private void RunCar() {
if (type == CarType.Ford) {
fcar.Run();
}
else {
hcar.Run();
}
}
修改办法:增加接口ICar及Run()方法,让HondaCar和FordCar实现接口的方法。在AutoSystem类的构造方法中将ICar类作为入参即可。
七.单一职责原则
1.解释:
一个类应该有且仅有一个引起它变化的原因,否则类应该被拆分。
个人理解:要看具体的业务逻辑。比如说有Animal接口。
- 如果要求是动物都能呼吸,那么该接口没有问题;
- 但如果要区分牛在陆地上呼吸,鱼在水里呼吸。那么Animal将被拆分成两个子接口–陆生动物和水生动物。
2.实现方法:
该原则是最简单但又最难运用的原则,需要设计人员发现类的不同职责并将其分离,再封装到不同的类或模块中。
3.优点:
单一职责原则的核心就是控制类的粒度大小、将对象解耦、提高其内聚性。有以下优点。
- 降低类的复杂度。一个类只负责一项职责,其逻辑肯定要比负责多项职责简单得多。
- 提高类的可读性。复杂性降低,自然其可读性会提高。
- 提高系统的可维护性。可读性提高,那自然更容易维护了。
- 变更引起的风险降低。变更是必然的,如果单一职责原则遵守得好,当修改一个功能时,可以显著降低对其他功能的影响。
4.举例说明:
参考1中的动物能呼吸,陆生动物在陆地上呼吸、水生动物在水里呼吸两个情况。