一. 软件设计模式
1. 什么是软件设计模式?
软件设计模式(Software Design Pattern),又称设计模式,是指在软件开发中,经过验证的,用于解决在特定环境下、重复出现的、特定问题的解决方案。
2. 软件设计模式的作用是什么?
设计模式的本质是面向对象设计原则的实际运用,是对类的封装性、继承性和多态性以及类的关联关系和组合关系的充分理解。正确使用设计模式具有以下优点。
- 可以提高程序员的思维能力、编程能力和设计能力。
- 使程序设计更加标准化、代码编制更加工程化,使软件开发效率大大提高,从而缩短软件的开发周期。
- 使设计的代码可重用性高、可读性强、可靠性高、灵活性好、可维护性强。
二. 面向对象设计的七大原则
- 开闭原则(Open Closed Principle,OCP)
- 单一职责原则(Single Responsibility Principle, SRP)
- 里氏代换原则(Liskov Substitution Principle,LSP)
- 依赖倒转原则(Dependency Inversion Principle,DIP)
- 接口隔离原则(Interface Segregation Principle,ISP)
- 合成/聚合复用原则(Composite/Aggregate Reuse Principle,CARP)
- 迪米特法则(Law of Demeter,LOD) 或者最少知识原则(Least Knowledge Principle,LKP)
其中,单一职责原则、开闭原则、迪米特法则、里氏代换原则和接口隔离原则的英文首字母拼在一起就是SOLID(稳定的),所以也称之为SOLID原则。
1. 单一职责原则(Single Responsibility Principle)
对类来说的,即一个类应该只负责一项职责。如类A负责两个不同职责:职责1,职责2。当职责1需求变更而改变A时,可能造成职责2执行错误,所以需要将类A的粒度分解为A1和A2。
类的职责要单一,不能将太多的职责放在一个类中。
例如:大学学生工作管理程序。
分析:大学学生工作主要包括学生生活辅导和学生学业指导两个方面的工作,其中生活辅导主要包括班委建设、出勤统计、心理辅导、费用催缴、班级管理等工作,
学业指导主要包括专业引导、学习辅导、科研指导、学习总结等工作。如果将这些工作交给一位老师负责显然不合理,正确的做 法是生活辅导由辅导员负责,学业指导由学业导师负责,其类图如图 1 所示。
单一职责原则注意事项和细节:
- 降低类的复杂度,一个类只负责一项职责。
- 提高类的可读性,可维护性。
- 降低变更引起的风险。
- 通常情况下,我们应当遵守单一职责原则,只有逻辑足够简单,才可以在代码级违反单一职责原则:只有类种方法数量足够少,可以在方法级别保持单一职责原则。
注意:单一职责同样也适用于方法。一个方法应该尽可能做好一件事情。如果一个方法处理的事情太多,其颗粒度会变得很粗,不利于重用。
2.开闭原则(Open-Closed Principle)
对扩展开放,对修改关闭。
一般情况,我们接到需求变更的通知,通常方式可能就是修改模块的源代码,然而修改已经存在的源代码是存在很大风险的,尤其是项目上线运行一段时间后,开发人员发生变化,这种风险可能就更大。
所以,为了避免这种风险,在面对需求变更时,我们一般不修改源代码,即所谓的对修改关闭。不允许修改源代码,我们如何应对需求变更呢?答案就是我们下面要说的对扩展开放。
通过扩展去应对需求变化,就要求我们必须要面向接口编程,或者说面向抽象编程。所有参数类型、引用传递的对象必须使用抽象(接口或者抽象类)的方式定义,不能使用实现类的方式定义;
通过抽象去界定扩展,比如我们定义了一个接口A的参数,那么我们的扩展只能是接口A的实现类。这样原则设计出来的系统,遇到增加功能的需求时,几乎不用修改源代码,只是增加几个类,然后调用就好。
这样既增加了新功能满足了需求,又维护了原本系统的稳定性。
例如:
- 首先创建一个手机接口:
public interface Phone {
String getName();//名称
Double getPrice();//价格
}
- 创建一个IPhone手机实现手机接口:
public class IPhone implements Phone {
private String name;
private Double price;
public IPhone(String name, Double price) {
this.name = name;
this.price = price;
}
@Override
public String getName() {
return name;
}
@Override
public Double getPrice() {
return price;
}
}
- 使用类
public class PhoneSore {
public static void main(String[] args) {
Phone phone = new IPhone("Iphone 4S", 6000.00);
System.out.println("欢迎购买:名字:" + phone.getName() + " 价格:" + String.valueOf(phone.getPrice()));
}
}
上面的代码可以正常地运行,我们可以方便地添加新的手机。但是如果需求发生了变更,手机店推出了打折地活动。我们如何解决?
有下面三种方法可以解决此问题:
修改接口
在IPhone接口中,增加一个方法getDiscountPrice,专门用于处理打折需求。但是这个方法是有问题的,接口应该是稳定且可靠的,不应该经常发生变化,否则接口作为契约的作用就失去了。且违背了开闭原则,因此否定。修改实现类
第二种方法是通过修改实现类中的getPrice方法或者增加getDiscountPrice方法实现其需求,但是这样一个类中就存在了两个读取价格的方法,且违背了开闭原则,所以此方法也不是一个最优方案。通过扩展实现变化
我们可以通过增加一个子类IPhoneDiscount,复写getPrice方法,此方法修改少,对现有的代码没有影响,风险少,是个好方法。
- 添加打折类
public class IPhoneDiscount extends IPhone {
public IPhoneDiscount(String name, Double price) {
super(name, price);
}
//打折活动
public Double getPrice() {
//九折优惠
return super.getPrice() * 0.90;
}
}
3.里式替换原则(Liskov Substitution Principle)
所有引用基类(父类)的地方,都必须能透明地使用其子类的对象。父类可被子类替换,但反之不一定成立。也就是说,代码中可以将父类全部替换为子类,程序不会出现异常。
当使用继承时,遵循里氏替换原则。类B继承类A时,除添加新的方法完成新增功能P2外,尽量不要重写父类A的方法,也尽量不要重载父类A的方法。
里氏替换原则通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。
例如:我喜欢动物,那我一定喜欢狗,因为狗是动物的子类。但是我喜欢狗,不能据此断定我喜欢动物,因为我并不喜欢老鼠,虽然它也是动物。
尽量不要重写父类方法,而是增加自己特有的方法。
继承给程序设计带来巨大便利的同时,也带来了弊端。如果一个类被其他的类所继承,则当这个类需要修改时,必须考虑到所有的子类,并且父类修改后,所有涉及到子类的功能都有可能会产生BUG。
例如:
- 先定义一个鸟的接口。
public class Bird {
private int velocity;
public int getVelocity() {
return velocity;
}
public void setVelocity(int velocity) {
this.velocity = velocity;
}
}
- 定义鸵鸟去实现鸟的功能。
public class Ostrich extends Bird{
public int getVelocity() {
//鸵鸟是不会飞的所以他的飞行时间就为0
return 0;
}
}
- 测试
public class main {
public static void main(String[] args) {
//计算鸟的飞行时间
Bird bird = new Bird();
bird.setVelocity(100);
int h = flyTime(bird);
System.out.println("飞行时间是:"+h);
//计算鸵鸟的飞行时间
Bird ostrich = new Ostrich();
ostrich.setVelocity(100);
int h = flyTime(ostrich);
System.out.println("飞行时间是:"+h);
}
/*
*计算飞行3000米需要的时间
*/
public static int flyTime(Bird bird)
{
return 3000/bird.getVelocity();
}
}
结果:
普通鸟运行结果正确,飞行时间是:30。
计算鸵鸟的飞行时间报错。
面向对象的语言的三大特点是继承,封装,多态,里氏替换原则是依赖于继承,多态这两大特性。里氏替换原则的定义是,所有引用基类的地方必须能透明地使用其子类的对象。
通俗来讲是只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何错误和异常。而我们在使用flyTime方法时 ,当使用者flyTime方法里的参数Bird被Ostrich替换掉后,
结果出现了异常,那么它明显违背了里氏替换原则。
4.接口隔离原则(Interface Segregation Principle)
使用多个专门的接口,而不使用单一的总接口。不要对外暴露没有实际意义的接口。也就是说使用多个专门的接口比使用单一的总接口要好。
例如:对于鸟的实现(Bird),我们可以定义两个功能接口,分别是Fly和Eat,我们可以让Bird分别实现这两个接口。
如果我们还有一个Dog,那么对于Eat接口,可以复用。但是如果只有一个接口(包含Fly和Eat两个功能),对于Dog来说,
它是不会飞(Fly)的,那么就需要针对Dog再声明一个新的接口,这是没有必要的设计。
5.依赖倒置原则(Dependence Inversion Principle)
高层模块不应该依赖低层模块,二者都应该依赖其抽象 。
抽象不应该依赖细节,细节应该依赖抽象 。
一开始类A依赖于类B,由于需求发生了改变。要将类A依赖于类C,则我们需要修改类A依赖于类B的相关代码,这样会对程序产生不好的影响。假如需求又发生了改变,我们又需要修改类A的代码。
例如:
public class UserService {
private Plaintext plaintext; // 明文登录注册
public void register(){
Plaintext.register(); // 调用明文的注册方法
}
public void login(){
Plaintext.login(); // 调用明文的登录方法
}
}
上面的例子可以看出,UserService类依赖于Plaintext类。有一天,由于使用明文登录注册不安全,需求改为使用密文登录注册。我们可以怎么办?
//不符合 依赖倒置原则
public class UserService {
// private Plaintext plaintext;
private Ciphertext ciphertext; // 密文登录注册
public void register(){
// Plaintext.register();
Ciphertext.register(); // 调用密文的注册方法
}
public void login(){
// Plaintext.login();
Ciphertext.login(); // 调用密文的登录方法
}
}
在上面的例子,修改一个需求几乎将整个UserService类都修改了一遍,这不但麻烦,而且会给程序带来很多风险。所以上面的例子不符合依赖倒置原则。
//符合 依赖倒置原则
public class UserService {
private Authentication authentication; // 依赖于接口(抽象)
public UserServer(Authentication auth) {
//接口与实现类对接
this.authentication = auth;
}
public void register(){
authentication.register();
}
public void login(){
authentication.login();
}
}
public interface Authentication {
//...登录注册
}
public class Ciphertext implements Authentication {
//...使用明文的实现
}
public class Plaintext implements Authentication {
//...使用密文的实现
}
在上面的例子Ciphertext类和Plaintext类实现了Authentication接口。而UserService类依赖于Authentication接口。这样可以在构造函数里随意切换登录注册的模式。
假设以后还需要更改需求,只需要实现Authentication接口然后在构造函数里注入就可以了。
6.迪米特法则(Law Of Demeter)
如果两个类不彼此通信,那么这两个类就不应当直接地发生相互作用。如果其中一个类需要另一个类的某一个方法的话,可以通过第三者转发这个调用。
迪米特法则的初衷是降低类之间的耦合,由于每个类都减少了不必要的依赖,因此的确可以降低耦合关系。
但是凡事都有度,虽然可以避免与非直接的类通信,但是要通信,必然会通过一个“中介”来发生联系,过分的使用迪米特原则,会产生大量这样的中介和传递类,导致系统复杂度变大。
所以在采用迪米特法则时要反复权衡,既做到结构清晰,又要高内聚低耦合。
7.合成复用原则(Composite/Aggregate Reuse Principle)
合成复用原则目的就是尽量使用对象组合,而不是继承来达到复用的目的。
通过继承来进行复用的主要问题在于继承复用会破坏系统的封装性,因为继承会将基类的实现细节暴露给子类,由于基类的内部细节通常对子类来说是可见的,所以这种复用又称“白箱”复用,子类与父类的耦合度高。
父类的实现的任何改变都会导致子类的实现发生变化,这不利于类的扩展与维护。而且它限制了复用的灵活性。从父类继承而来的实现是静态的,在编译时已经定义,所以在运行时不可能发生变化。
由于组合或聚合关系可以将已有的对象(也可称为成员对象)纳入到新对象中,使之成为新对象的一部分,因此新对象可以调用已有对象的功能,这样做可以使得成员对象的内部实现细节对于新对象不可见,所以这种复用又称为“黑箱”复用,相对继承关系而言,其耦合度相对较低,成员对象的变化对新对象的影响不大,可以在新对象中根据实际需要有选择性地调用成员对象的操作;
合成复用可以在运行时动态进行,新对象可以动态地引用与成员对象类型相同的其他对象。