1. 设计模式概述
1.1 什么是设计模式
设计模式是基于面向对象的软件设计经验总结,是针对软件开发中常见问题和模式的通用解决方案。
1.2 常见的设计模式
① GoF设计模式
《Design Patterns: Elements of Reusable Object-Oriented Software》(即后述《设计模式》一书),由 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 合著(Addison-Wesley,1995)。这几位作者常被称为四人组(Gang of Four)。
这种模式包括了23种,常用的为单例模式、工厂模式、代理模式、模板方法、适配器模式、观察者模式、策略模式。
② 架构设计模式(Architectural Pattern)
主要用于软件系统的整体架构设计,包括多层架构、MVC架构、微服务架构、REST架构和大数据架构等。
③ 企业级设计模式(Enterprise Pattern)
主要用于企业级应用程序设计,包括基于服务的架构(SOA)、企业集成模式(EIP)、业务流程建模(BPM)和企业规则引擎(BRE)等。
④ 领域驱动设计模式(Domain Driven Design Pattern)
主要用于领域建模和开发,包括聚合、实体、值对象、领域事件和领域服务等。
⑤ 并发设计模式(Concurrency Pattern)
主要用于处理并发性问题,包括互斥、线程池、管道、多线程算法和Actor模型等。
⑥ 数据访问模式(Data Access Pattern)
主要用于处理数据访问层次结构,包括数据访问对象(DAO)、仓库模式和活动记录模式等。
1.3 GoF设计模式的分类
-
创建型:主要解决对象的创建问题,GoF(四人组)书中提供了单例、原型、工厂方法、抽象工厂、建造者 5 种创建型模式。
-
结构型:通过设计和构建对象之间的关系,以达到更好的重用性、扩展性和灵活性。GoF(四人组)书中提供了代理、适配器、桥接、装饰、外观、享元、组合 7 种结构型模式。
-
行为型:主要用于处理对象之间的算法和责任分配。GoF(四人组)书中提供了模板方法、策略、命令、职责链、状态、观察者、中介者、迭代器、访问者、备忘录、解释器 11 种行为型模式。
2. 软件开发七大设计原则
- 开闭原则是总纲,告诉我们要对扩展开放,对修改关闭。
- 里氏替换原则,告诉我们不要破坏继承体系。
- 依赖倒置原则,告诉我们要面向接口编程。
- 单一职责原则,告诉我们实现类要职责单一。
- 接口隔离原则,告诉我们在设计接口的时候要精简单一。
- 迪米特法则,告诉我们要降低耦合度。
- 合成复用原则,告诉我们要优先使用组合或者聚合关系复用,少用继承关系复用。
2.1 开闭原则
对扩展开放,对修改关闭。该选择可以使程序的扩展性好,易于维护和升级。
对扩展开放,意味着有新的需求或变化时,可以对现有代码进行扩展,以适应新的情况。
对修改封闭,意味着类一旦设计完成,就可以独立完成其工作,而不要对类进行任何修改。
实现方式
可以通过“抽象约束、封装变化”来实现开闭原则。
即通过接口或者抽象类为实体,定义一个相对稳定的抽象层,而将相同的可变因素封装在相同的具体实现类中。当软件需要发生变化时,只需要根据需求重新派生一个实现类来扩展就可以了。
2.2 里氏代换原则
子类可以扩展父类的功能,但不能改变父类原有的功能。
实现方式
里氏替换原则通俗来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。也就是说:子类继承父类时,除添加新的方法完成新增功能外,尽量不要重写父类的方法**。**// 可以扩展,但不能改变
如果通过重写父类的方法来完成新的功能,这样写起来虽然简单,但是整个继承体系的可复用性会比较差,特别是运用多态比较频繁时,程序运行出错的概率会非常大。// 违背了里氏代换原则
如果程序违背了里氏替换原则,则继承类的对象在基类出现的地方会出现运行错误。这时其修正方法是:取消原来的继承关系,重新设计它们之间的关系。例如重新抽取一个公共的接口,使其实现该接口。
关于里氏替换原则的例子,最有名的是“正方形不是长方形”。当然,生活中也有很多类似的例子,例如,企鹅、鸵鸟和几维鸟从生物学的角度来划分,它们属于鸟类;但从类的继承关系来看,由于它们不能继承“鸟”会飞的功能,所以它们不能定义成“鸟”的子类。同样,由于“气球鱼”不会游泳,所以不能定义成“鱼”的子类;“玩具炮”炸不了敌人,所以不能定义成“炮”的子类等。
例子–正方形不是长方形
在数学领域内,正方形可以被视为一种特殊的长方形,即长宽相等。所以我们在设计代码结构时,可以设计长方形为父类,正方形为子类
上述类图种,Square
继承自Rectangle
,如果我们尝试在代码中将Square
对象替换为Rectangle
对象,可能会引起问题。比如:
Rectangle rect = new Square();
rect.setLength(5);
rect.setWidth(10);
这段代码违反了正方形的定义,因为正方形的长和宽应该始终相等。但是由于
Square
继承自Rectangle
,使得Square
的行为无法完全符合Rectangle
的预期,这就违反了里氏代换原则。
改进
-
引入了一个
Quadrilateral
(四边形)接口,Square
类和Rectangle
类都实现了这个接口。 -
RectangleDemo
类依赖Quadrilateral
接口,而不是具体的Rectangle
类。 -
Square
类不直接继承Rectangle
类,而是独立实现Quadrilateral
接口。
通过引入
Quadrilateral
四边形接口,Square
和Rectangle
独立实现接口方法,确保了每个类都有各自的行为,实现了更好的设计。这样,无论是Square
还是Rectangle
对象,都可以作为Quadrilateral
对象使用,这遵循了里氏代换原则。
Quadrilateral quad = new Square();
// 由于接口中没有设置宽度的方法,避免了违反正方形定义的情况
quad.setLength(5);
2.3 依赖倒置原则
高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。
面向接口编程,不要面向实现编程。
依赖倒置原则是实现开闭原则的重要途径之一,它降低了客户与实现模块之间的耦合。
实现方式
- 每个类尽量提供接口或抽象类,或者两者都具备。
- 变量的声明类型尽量是接口或者是抽象类。
- 任何类都不应该从具体类派生。
- 使用继承时尽量遵循里氏替换原则。
举例说明
以顾客去不同商店购物为例。
在顾客类中,定义一个shopping方法,参数列表传入对应的商店对象,然后完成购物。
该场景中,每当顾客换一家商店,都需要改变参数对象,这很显然违背了开闭原则。
改进
将不同的商店抽象为一个shop接口,使每个商店实现该接口。顾客在进入不同商店购买时,传入接口对象,在测试实现类中,再传入具体的new商店对象即可。
public class DIPtest{ public static void main(String[] args){ Customer wang = new Customer(); System.out.println("顾客购买以下商品:"); wang.shopping(new Shop1()); wang.shopping(new Shop2()); } } //商店 interface Shop{ public String sell(); //卖 } //网店1 class Shop1 implements Shop{ public String sell(){ return "111--土特产:香菇、木耳……"; } } //网店2 class Shop2 implements Shop{ public String sell(){ return "222--土特产:绿茶、酒糟鱼……"; } } //顾客 class Customer{ public void shopping(Shop shop){ //购物 System.out.println(shop.sell()); } }
2.4 单一职责原则
控制类的粒度大小、将对象解耦、提高其内聚性。
实现方式
设计人员需要根据类的不同职责并将其分离,再封装到不同的类或模块中。
注意
单一职责同样也适用于方法。一个方法应该尽可能做好一件事情。
如果一个方法处理的事情太多,其颗粒度会变得很粗,不利于重用。
举例说明
大学生工作主要包括学生生活辅导、学生学业指导
生活辅导——班委建设、出勤统计、心理辅导、费用催缴、班级管理等
学业指导——专业引导、学习辅导、科研指导、学习总结等
如果将这些工作交给一位老师负责显然不合理,正确的做法是生活辅导由辅导员负责,学业指导由学业导师负责。
2.5 接口隔离原则
为各个类建立它们需要的专用接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。
面试题
接口隔离原则和单一职责原则的区别?
接口隔离原则和单一职责都是为了提高类的内聚性、降低它们之间的耦合性,体现了封装的思想,但两者是不同的:
单一职责原则注重的是职责;接口隔离原则注重的是对接口依赖的隔离。
单一职责原则主要是约束类,它针对的是程序中的实现和细节;接口隔离原则主要约束接口,主要针对抽象和程序整体框架的构建。
实现方式
接口尽量小,但是要有限度。一个接口只服务于一个子模块或业务逻辑。
提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。
为依赖接口的类只提供调用者需要的方法,屏蔽不需要的方法。
举例说明
学生成绩管理——插入成绩、删除成绩、修改成绩、计算总分、计算均分、打印成绩信息、査询成绩信息等
如果将这些功能全部放到一个接口中显然不太合理,正确的做法是将它们分别放在输入模块、统计模块和打印模块等 3 个接口中。
2.6 迪米特法则
如果两个软件实体无须直接通信,那么就不应当发生直接的相互调用,可以通过第三方转发该调用。
过度使用迪米特法则会使系统产生大量的中介类,从而增加系统的复杂性,使模块之间的通信效率降低。
所以,在釆用迪米特法则时需要反复权衡,确保高内聚和低耦合的同时,保证系统的结构清晰。
实现方式
只依赖应该依赖的对象
只暴露应该暴露的方法
举例说明
角色描述:
- 明星:专注于艺术创作和表演,不直接处理日常事务。
- 经纪人:负责处理明星的日常事务,与粉丝和媒体公司进行沟通。
- 粉丝:希望与明星见面。
- 媒体公司:希望与明星进行业务洽谈。
迪米特法则的应用:
- 明星与粉丝和媒体公司(实体间不直接通信)之间不直接交流,而是通过经纪人(第三方调用)来处理相关事务。
- 经纪人是明星的朋友,可以直接与明星交流。
- 粉丝和媒体公司是明星的陌生人,他们的请求和交流必须通过经纪人来完成。
package principle; public class LoDtest{ public static void main(String[] args){ Agent agent=new Agent(); agent.setStar(new Star("林心如")); agent.setFans(new Fans("粉丝韩丞")); agent.setCompany(new Company("中国传媒有限公司")); agent.meeting(); agent.business(); } } //经纪人 class Agent{ private Star myStar; private Fans myFans; private Company myCompany; public void setStar(Star myStar){ this.myStar=myStar; } public void setFans(Fans myFans){ this.myFans=myFans; } public void setCompany(Company myCompany){ this.myCompany=myCompany; } public void meeting(){ System.out.println(myFans.getName()+"与明星"+myStar.getName()+"见面了。"); } public void business(){ System.out.println(myCompany.getName()+"与明星"+myStar.getName()+"洽淡业务。"); } } //明星 class Star{ private String name; Star(String name){ this.name=name; } public String getName(){ return name; } } //粉丝 class Fans{ private String name; Fans(String name){ this.name=name; } public String getName(){ return name; } } //媒体公司 class Company{ private String name; Company(String name){ this.name=name; } public String getName(){ return name; } }
2.7 合成复用原则
尽量先使用组合或者聚合等关联关系来实现,其次才考虑使用继承关系来实现。
合成复用原则同里氏替换原则相辅相成的,两者都是开闭原则的具体实现规范。
① 继承复用的缺点(优点是简单、易实现)
- ==继承复用破坏了类的封装性。==继承会将父类的实现细节暴露给子类,父类对子类是透明的,所以这种复用又称为“白箱”复用。
- ==子类与父类的耦合度高。==父类的实现的任何改变都会导致子类的实现发生变化,这不利于类的扩展与维护。
- ==它限制了复用的灵活性。==从父类继承而来的实现是静态的,在编译时已经定义,所以在运行时不可能发生变化。
② 组合或聚合复用优点
该方式将已有对象纳入新对象中,使之成为新对象的一部分,新对象可以调用已有对象的功能
- ==它维持了类的封装性。==因为成分对象的内部细节是新对象看不见的,所以这种复用又称为“黑箱”复用。
- ==新旧类之间的耦合度低。==这种复用所需的依赖较少,新对象存取成分对象的唯一方法是通过成分对象的接口。
- ==复用的灵活性高。==这种复用可以在运行时动态进行,新对象可以动态地引用与成分对象类型相同的对象。
③ 实现方式
通过将已有的对象纳入新对象中,作为新对象的成员对象来实现的,新对象可以调用已有对象的功能,从而达到复用。
④ 举例说明
汽车分类——
按“动力源”划分可分为汽油汽车、电动汽车等;
按“颜色”划分可分为白色汽车、黑色汽车和红色汽车等。
如果同时考虑这两种分类,其组合就很多。
使用继承
用继承关系实现会产生很多子类,而且增加新的“动力源”或者增加新的“颜色”都要修改源代码,这违背了开闭原则,显然不可取。
使用组合
// 颜色接口
interface Color {
String getColor();
}
// 具体颜色实现类
class White implements Color {
@Override
public String getColor() {
return "White";
}
}
class Black implements Color {
@Override
public String getColor() {
return "Black";
}
}
class Red implements Color {
@Override
public String getColor() {
return "Red";
}
}
// 汽车基类
abstract class Car {
protected Color color;
public Car(Color color) {
this.color = color;
}
public abstract void move();
}
// 汽油汽车类
class GasolineCar extends Car {
public GasolineCar(Color color) {
super(color);
}
@Override
public void move() {
System.out.println("Gasoline car with color " + color.getColor() + " is moving.");
}
}
// 电动汽车类
class ElectricCar extends Car {
public ElectricCar(Color color) {
super(color);
}
@Override
public void move() {
System.out.println("Electric car with color " + color.getColor() + " is moving.");
}
}
// 主类
public class Main {
public static void main(String[] args) {
Color white = new White();
Color black = new Black();
Color red = new Red();
Car whiteGasolineCar = new GasolineCar(white);
Car blackElectricCar = new ElectricCar(black);
Car redGasolineCar = new GasolineCar(red);
whiteGasolineCar.move(); // 输出: 白色汽油汽车正在移动
blackElectricCar.move(); // 输出: 黑色电动汽车正在移动
redGasolineCar.move(); // 输出: 红色汽油汽车正在移动
}
}