本设计模式系列旨在以循序渐进的方式,帮助读者全面掌握设计模式的核心思想及其实际应用。内容分为四个阶段:
UML类图
从基础入手,讲解 UML 类图的基本概念与用法,包括类间关系(如继承、组合、关联等)。通过实例分析,帮助读者理解如何用 UML 描述系统结构,为后续学习奠定基础。
设计原则
深入解析面向对象设计的七大原则(如单一职责原则、开闭原则等),指导读者编写高内聚、低耦合的代码,并为理解设计模式提供理论支撑。
设计模式
系统讲解常见设计模式,按创建型、结构型和行为型分类,结合 UML 类图与代码示例,分析其应用场景、优缺点及实现方法。
项目实战
通过真实项目案例,展示设计模式的实际应用(如单例管理配置、工厂动态创建对象等)。结合主流框架(如 Spring、MyBatis)分析其原理,帮助读者将理论转化为实践能力。
通过这四个阶段的学习,读者不仅能掌握设计模式的理论知识,还能灵活运用于实际项目中,提升代码质量与开发能力。无论初学者还是开发者,都能从中受益,进一步提高软件开发水平。
1. 设计模式理解
1.1. 什么是设计模式?
对通用软件设计问题的可复用解决方案,是经验总结的代码设计模板。
1.2. 设计模式的作用?
- 加速开发:复用已验证方案(如单例、代理模式)
- 提升扩展性:隔离变化点,支持功能扩展
- 增强可维护性:规范代码结构,降低理解成本
- 统一沟通语言:设计模式术语提高团队协作效率
2. 七大原则
2.1. 开闭原则
2.1.1. 开闭原则的定义
开闭原则(OCP)由勃兰特·梅耶于1988年在《面向对象软件构造》中提出,其核心思想是:软件实体应对扩展开放,对修改关闭。
软件实体包括模块、类与接口、方法。开闭原则指在需求变化时,无需修改源码或二进制代码,即可扩展功能以满足新需求。
2.1.2. 开闭原则的作用
开闭原则是面向对象设计的核心目标,它让软件既灵活适应变化,又保持稳定和可持续性。其主要作用包括:
- 简化软件测试
遵循开闭原则时,只需测试新增或修改的部分,原有代码的测试结果不受影响。 - 提升代码复用性
通过原子化设计和抽象编程,代码粒度更小,复用性更高。 - 增强软件可维护性
符合开闭原则的软件更稳定、易扩展,从而降低维护成本。
2.1.3. 开闭原则的实现方法
通过“抽象约束、封装变化”实现开闭原则,即利用接口或抽象类构建稳定的抽象层,将可变因素封装在具体实现类中。抽象层合理设计后能保持架构稳定,而易变细节可通过扩展实现类来应对变化,需求变更时只需新增或调整实现类即可。
以 Windows 桌面主题为例说明开闭原则的应用。
示例:Windows 桌面主题设计
分析:Windows 主题由背景图片、窗口颜色和声音等元素组成,用户可自定义或下载新主题。这些主题具有共同特性,可以用一个抽象类(Abstract Subject)表示,每个具体主题(Specific Subject)作为其子类。用户可选择或添加新主题,而无需修改现有代码,符合开闭原则。类图如图 1 所示。
图1 Windows的桌面主题类图
public interface AbstractSubject {
void display();
}
public class SpecificSubject1 implements AbstractSubject{
@Override
public void display() {
System.out.println("具体主题1");
}
}
public class SpecificSubject2 implements AbstractSubject{
@Override
public void display() {
System.out.println("具体主题2");
}
}
public class Windows {
public void display(AbstractSubject abstractSubject) {
abstractSubject.display();
}
}
public class Client {
public static void main(String[] args) {
Windows windows = new Windows();
windows.display(new SpecificSubject1());
windows.display(new SpecificSubject2());
}
}
2.1.4. 代码案例
根据开闭原则,构建一个可扩展的图形绘制系统,支持轻松添加新图形类型并在GraphicEditor中绘制。
2.1.4.1. 图形绘制系统原始方案
public class Ocp {
public static void main(String[] args) {
//使用看看存在的问题
GraphicEditor graphicEditor = new GraphicEditor();
graphicEditor.drawShape(new Rectangle());
graphicEditor.drawShape(new Circle());
graphicEditor.drawShape(new Triangle());
}
}
//这是一个用于绘图的类 [使用方]
class GraphicEditor {
//接收Shape对象,然后根据type,来绘制不同的图形
public void drawShape(Shape s) {
if (s.m_type == 1)
drawRectangle(s);
else if (s.m_type == 2)
drawCircle(s);
else if (s.m_type == 3)
drawTriangle(s);
}
//绘制矩形
public void drawRectangle(Shape r) {
System.out.println(" 绘制矩形 ");
}
//绘制圆形
public void drawCircle(Shape r) {
System.out.println(" 绘制圆形 ");
}
//绘制三角形
public void drawTriangle(Shape r) {
System.out.println(" 绘制三角形 ");
}
}
//Shape类,基类
class Shape {
int m_type;
}
class Rectangle extends Shape {
Rectangle() {
super.m_type = 1;
}
}
class Circle extends Shape {
Circle() {
super.m_type = 2;
}
}
//新增画三角形
class Triangle extends Shape {
Triangle() {
super.m_type = 3;
}
}
原始方案的优缺点:
- 优点:简单直观,易于理解和操作。
- 缺点:违反了设计模式的开闭原则(OCP),即对扩展开放、对修改关闭。新增功能(如增加三角形)时需要修改现有代码,改动较大。
改进思路:
- 将
Shape
类设计为抽象类,并提供一个抽象的draw
方法,由子类实现。新增图形时,只需创建新子类继承Shape
并实现draw
方法,使用方无需修改代码,满足开闭原则。 - 定义一个
Shape
抽象类作为基础设计。
2.1.4.2. 图形绘制系统开闭原则 (符合OCP原则)
public class Ocp {
public static void main(String[] args) {
GraphicEditor graphicEditor = new GraphicEditor();
graphicEditor.drawShape(new Rectangle());
graphicEditor.drawShape(new Circle());
graphicEditor.drawShape(new Triangle());
graphicEditor.drawShape(new OtherGraphic());
}
}
/**
* 这是一个用于绘图的类 [使用方]
*/
class GraphicEditor {
/**
* 接收Shape对象,调用draw方法
*/
public void drawShape(Shape s) {
s.draw();
}
}
/**
* Shape类,基类
*/
abstract class Shape {
/**
* 抽象方法
*/
public abstract void draw();
}
class Rectangle extends Shape {
Rectangle() {
}
@Override
public void draw() {
System.out.println(" 绘制矩形 ");
}
}
class Circle extends Shape {
Circle() {
}
@Override
public void draw() {
System.out.println(" 绘制圆形 ");
}
}
/**
* 新增画三角形
*/
class Triangle extends Shape {
Triangle() {
}
@Override
public void draw() {
System.out.println(" 绘制三角形 ");
}
}
/**
* 新增一个图形
*/
class OtherGraphic extends Shape {
OtherGraphic() {
}
@Override
public void draw() {
System.out.println(" 绘制其它图形 ");
}
}
2.2. 里氏替换原则
2.2.1. 里氏替换原则的定义
里氏替换原则(LSP)由芭芭拉·里斯科夫于1987年提出,核心思想是:子类应能无缝替换父类而不影响程序的正确性(即避免重写父类方法)。它规范了继承关系,明确了基类与子类的职责,补充了开闭原则,并为抽象设计的具体实现提供了指导。简而言之,子类必须能够完全替代父类且保持程序功能正常。
2.2.2. 里氏替换原则的作用
里氏替换原则的作用可以简化为以下几点:
- 是实现开闭原则的重要手段。
- 解决了继承中重写父类导致复用性差的问题。
- 保证行为正确性,避免类扩展引入新错误,降低代码出错风险。
- 提升程序健壮性、兼容性和可维护性,降低需求变更带来的风险。
关键点:
- 子类不重写父类非抽象方法
- 方法前置条件(参数)更宽松,后置条件(返回值)更严格
2.2.3. 里氏替换原则的实现方法
里氏替换原则的核心是:子类可以扩展父类的功能,但不能改变父类原有的行为。具体来说:
- 子类可以实现父类的抽象方法,但不应覆盖父类的非抽象方法。
- 子类可以新增自己的特有方法。
- 重载父类方法时,输入参数应更宽松;实现或重写父类方法时,返回值应更严格或相等。
如果随意重写父类方法,虽然实现起来简单,但会导致继承体系的可复用性变差,尤其是在多态场景下,容易引发运行错误。
违背里氏替换原则的后果是:子类对象在基类预期的地方可能导致程序出错。此时应重新设计两者关系,取消不合适的继承。
里氏替换原则的核心例子常被概括为“正方形不是长方形”。类似地,生活中也有不少案例:从生物学角度看,企鹅、鸵鸟和几维鸟属于鸟类,但由于它们无法继承“鸟”会飞的特性,因此在类的继承关系中不能定义为“鸟”的子类。同样,“气球鱼”不会游泳,不能归为“鱼”的子类;“玩具炮”不能炸敌人,也不能视为“炮”的子类。
接下来,以“几维鸟不是鸟”为例,进一步说明里氏替换原则的应用。
【例1】里氏替换原则在“几维鸟不是鸟”实例中的应用。
分析:鸟一般都会飞行,如燕子的飞行速度大概是每小时 120 千米。但是新西兰的几维鸟由于翅膀退化无法飞行。假如要设计一个实例,计算这两种鸟飞行 300 千米要花费的时间。显然,拿燕子来测试这段代码,结果正确,能计算出所需要的时间;但拿几维鸟来测试,结果会发生“除零异常”或是“无穷大”,明显不符合预期,其类图如图 1 所示。
图1 “几维鸟不是鸟”实例的类图
public class LSPtest {
public static void main(String[] args) {
Bird bird1 = new Swallow();
Bird bird2 = new BrownKiwi();
bird1.setSpeed(120);
bird2.setSpeed(120);
System.out.println("如果飞行300公里:");
try {
System.out.println("燕子将飞行" + bird1.getFlyTime(300) + "小时.");
System.out.println("几维鸟将飞行" + bird2.getFlyTime(300) + "小时。");
} catch (Exception err) {
System.out.println("发生错误了!");
}
}
}
//鸟类
class Bird {
double flySpeed;
public void setSpeed(double speed) {
flySpeed = speed;
}
public double getFlyTime(double distance) {
return (distance / flySpeed);
}
}
//燕子类
class Swallow extends Bird {
}
//几维鸟类
class BrownKiwi extends Bird {
public void setSpeed(double speed) {
flySpeed = 0;
}
}
如果飞行300公里:
燕子将飞行2.5小时.
几维鸟将飞行Infinity小时。
程序运行错误是因为几维鸟重写了鸟类的 setSpeed(double speed)
方法,违反了里氏替换原则。正确的解决方法是:将几维鸟和鸟的共同特性提取到一个更通用的父类(如动物类)中,取消几维鸟对鸟类的直接继承。几维鸟虽然不能飞(飞行速度为 0),但可以奔跑(奔跑速度不为 0),因此可以通过其奔跑速度计算出跑完 300 千米所需时间。类图见图 2。
图2 “几维鸟是动物”实例的类图
- 子类型定义:若类型 T2 的对象能替换 T1 的对象且程序行为不变,则 T2 是 T1 的子类型。即,子类对象可透明地替代父类对象。
- 里氏替换原则:继承时应尽量避免重写父类方法,确保子类完全兼容父类的行为。
- 继承与耦合:继承会增加类间耦合,建议在适当情况下用聚合、组合或依赖代替继承。
public class Liskov {
public static void main(String[] args) {
// TODO Auto-generated method stub
A a = new A();
System.out.println("11-3=" + a.func1(11, 3));
System.out.println("1-8=" + a.func1(1, 8));
System.out.println("-----------------------");
B b = new B();
System.out.println("11-3=" + b.func1(11, 3));//这里本意是求出11-3
System.out.println("1-8=" + b.func1(1, 8));// 1-8
System.out.println("11+3+9=" + b.func2(11, 3));
}
}
// A类
class A {
// 返回两个数的差
public int func1(int num1, int num2) {
return num1 - num2;
}
}
// B类继承了A
// 增加了一个新功能:完成两个数相加,然后和9求和
// 无法透明替换为父类A,因为方法func1重写后含义由父类的求差变成了求和,含义变了
class B extends A {
//这里,重写了A类的方法, 可能是无意识
public int func1(int a, int b) {
return a + b;
}
public int func2(int a, int b) {
return func1(a, b) + 9;
}
}
11-3=8
1-8=-7
-----------------------
11-3=14
1-8=9
11+3+9=23
解决方法
- 我们发现原来运行正常的相减功能发生了错误。原因就是类B无意中重写了父类的 方法,造成原有功能出现错误。在实际编程中,我们常常会通过重写父类的方法完成新的功能,这样写起来虽然简单,但整个继承体系的复用性会比较差。特别是运行多态比较频繁的时候
- 通用的做法是:原来的父类和子类都继承一个更通俗的基类,原有的继承关系去掉, 采用依赖,聚合,组合等关系代替.
public class Liskov {
public static void main(String[] args) {
// TODO Auto-generated method stub
A a = new A();
System.out.println("11-3=" + a.func1(11, 3));
System.out.println("1-8=" + a.func1(1, 8));
System.out.println("-----------------------");
B b = new B();
//因为B类不再继承A类,因此调用者,不会再认为func1是求减法
//调用完成的功能就会很明确
System.out.println("11+3=" + b.func1(11, 3));//这里本意是求出11+3
System.out.println("1+8=" + b.func1(1, 8));// 1+8
System.out.println("11+3+9=" + b.func2(11, 3));
//使用组合仍然可以使用到A类相关方法
System.out.println("11-3=" + b.func3(11, 3));// 这里本意是求出11-3
}
}
//创建一个更加基础的基类
class Base {
//把更加基础的方法和成员写到Base类
}
// A类
class A extends Base {
// 返回两个数的差
public int func1(int num1, int num2) {
return num1 - num2;
}
}
// B类继承了A
// 增加了一个新功能:完成两个数相加,然后和9求和
class B extends Base {
//如果B需要使用A类的方法,使用组合关系
private A a = new A();
//这里,重写了A类的方法, 可能是无意识 B与A没有继承关系 目的性不一样
public int func1(int a, int b) {
return a + b;
}
public int func2(int a, int b) {
return func1(a, b) + 9;
}
//我们仍然想使用A的方法
public int func3(int a, int b) {
return this.a.func1(a, b);
}
}
11-3=8
1-8=-7
-----------------------
11+3=14
1+8=9
11+3+9=23
11-3=8
2.3. 依赖倒置原则
2.3.1. 依赖倒置原则的定义
依赖倒置原则(DIP)由罗伯特·马丁于1996年提出,核心思想是:高层模块和低层模块都应依赖抽象,而非具体实现;抽象不应依赖细节,细节应依赖抽象。简而言之,就是面向接口编程,而非实现。遵循该原则可降低耦合度,提升系统的可扩展性和可维护性。由于抽象比具体实现更稳定,基于抽象构建的架构更为稳固,接口或抽象类用于定义规范,具体实现由实现类完成,高、低层模块均依赖抽象。
2.3.2. 依赖倒置原则的作用
依赖倒置原则的主要作用包括:
- 降低类间耦合性
- 提高系统稳定性
- 减少并行开发风险
- 增强代码可读性与可维护性
2.3.3. 依赖倒置原则的实现方法
依赖倒置原则的目的是通过要面向接口的编程来降低类间的耦合性,所以我们在实际编程中只要遵循以下4点,就能在项目中满足这个规则。
- 每个类尽量提供接口或抽象类,或者两者都具备。
- 变量的声明类型尽量是接口或者是抽象类。
- 任何类都不应该从具体类派生。
- 使用继承时尽量遵循里氏替换原则。
【例1】依赖倒置原则在“顾客购物程序”中的应用。
分析:本程序反映了 “顾客类”与“商店类”的关系。商店类中有 sell() 方法,顾客类通过该方法购物以下代码定义了顾客类通过韶关网店 ShaoguanShop 购物:
class Customer {
public void shopping(ShaoguanShop shop) {
//购物
System.out.println(shop.sell());
}
}
但是,这种设计存在缺点,如果该顾客想从另外一家商店(如婺源网店 WuyuanShop)购物,就要将该顾客的代码修改如下:
class Customer {
public void shopping(WuyuanShop shop) {
//购物
System.out.println(shop.sell());
}
}
顾客每更换一家商店,都要修改一次代码,这明显违背了开闭原则。存在以上缺点的原因是:顾客类设计时同具体的商店类绑定了,这违背了依赖倒置原则。解决方法是:定义“婺源网店”和“韶关网店”的共同接口 Shop,顾客类面向该接口编程,其代码修改如下:
class Customer {
public void shopping(Shop shop) {
//购物
System.out.println(shop.sell());
}
}
这样,不管顾客类 Customer 访问什么商店,或者增加新的商店,都不需要修改原有代码了,其类图如图 1 所示。
图1 顾客购物程序的类图
package principle;
public class DIPtest {
public static void main(String[] args) {
Customer wang = new Customer();
System.out.println("顾客购买以下商品:");
wang.shopping(new ShaoguanShop());
wang.shopping(new WuyuanShop());
}
}
//商店
interface Shop {
public String sell(); //卖
}
//韶关网店
class ShaoguanShop implements Shop {
public String sell() {
return "韶关土特产:香菇、木耳……";
}
}
//婺源网店
class WuyuanShop implements Shop {
public String sell() {
return "婺源土特产:绿茶、酒糟鱼……";
}
}
//顾客
class Customer {
public void shopping(Shop shop) {
//购物
System.out.println(shop.sell());
}
}
顾客购买以下商品:
韶关土特产:香菇、木耳……
婺源土特产:绿茶、酒糟鱼……
2.4. 单一职责原则
2.4.1. 单一职责原则的定义
单一职责原则(SRP)由罗伯特·C. 马丁提出,强调一个类应只有一个变更原因,即只承担一个职责。若一个类职责过多,会导致两个问题:一是职责间的变更可能相互影响;二是使用时会引入不必要的功能,造成代码冗余。因此,每个类应专注于单一职责。
2.4.2. 单一职责原则的优点
单一职责原则的核心是控制类的粒度,解耦对象并提高内聚性。遵循这一原则有以下好处:
- 降低复杂度:一个类只负责一项职责,逻辑更简单。
- 增强可读性:复杂度降低,代码更易读。
- 提升可维护性:可读性提高,系统更易于维护。
- 减少变更风险:修改某一功能时,对其他功能的影响更小。
2.4.3. 单一职责原则的实现方法
单一职责原则看似简单,但实际运用却较难,要求设计人员识别类的多重职责并将其分离,分别封装到不同类或模块中。这需要设计人员具备较强的分析设计能力和重构经验。以下通过大学学生工作管理程序为例,说明该原则的应用。
2.4.3.1. 【例1】用户管理系统
假设我们正在设计一个简单的用户管理系统,需要实现用户的创建、修改和删除功能。
public class UserManager {
// 用户管理类负责管理用户的存储和读取
public void saveUser(User user) {
// 将用户信息存入数据库
}
public User getUser(int userId) {
// 从数据库中获取用户信息
return null;
}
// 用户管理类还负责发送邮件的功能
public void sendEmail(User user, String content) {
// 发送电子邮件给用户
}
// 用户管理类负责生成用户报表
public void generateUserReport(User user) {
// 生成用户报表
}
}
在上面的例子中,UserManager 类不仅负责用户的存取操作,还包含发送邮件和生成报表的功能。这违反了单一职责原则,因为一个类不应该有多个不相关的原因导致它发生变化。
public class UserManager {
// 用户管理类只负责管理用户的存储和读取
public void saveUser(User user) {
// 将用户信息存入数据库
}
public User getUser(int userId) {
// 从数据库中获取用户信息
return null;
}
}
public class EmailService {
// 邮件服务类负责发送邮件
public void sendEmail(User user, String content) {
// 发送电子邮件给用户
}
}
public class ReportGenerator {
// 报表生成类负责生成用户报表
public void generateUserReport(User user) {
// 生成用户报表
}
}
在上面的修改后的设计中,我们将功能拆分到了三个类中:
- UserManager 管理用户信息的存取操作。
- EmailService 负责发送邮件的功能。
- ReportGenerator 负责生成报表的功能。
每个类都只负责一种特定的功能,符合单一职责原则。这样的设计使得每个类更加清晰、可维护,当需求变化时,只需要修改相应的类而不会影响到其他部分。
2.4.3.2. 【例 2】大学学生工作管理程序。
分析:大学学生工作主要分为生活辅导和学业指导两部分。生活辅导包括班委建设、出勤统计、心理辅导、费用催缴和班级管理等任务;学业指导则涵盖专业引导、学习辅导、科研指导和学习总结等内容。将这些工作交由一位老师负责显然不合理,应由辅导员负责生活辅导,学业导师负责学业指导,其类图如图1所示。
图1 大学学生工作管理程序的类图
单一职责原则同样适用于方法:一个方法应专注于完成一件任务。如果一个方法承担过多功能,其粒度会变粗,影响代码的可重用性。
注意事项:
- 降低类的复杂度,确保一个类只负责一项职责。
- 提升类的可读性和可维护性。
- 减少因变更引发的风险。
- 通常应遵守单一职责原则,但在逻辑简单时,可在代码层面适度放宽;若类中方法数量较少,可在方法层面保持单一职责。
2.5. 接口隔离原则
2.5.1. 接口隔离原则的定义
接口隔离原则(ISP)主张将大接口拆分为小而具体的接口,确保每个接口只包含必要的方法。根据罗伯特·C. 马丁的定义,客户端不应依赖不需要的方法,类间依赖应基于最小接口。简言之,为每个类提供专门接口,避免宽泛的大接口。
虽然 ISP 和单一职责原则都旨在提升内聚性并降低耦合度,但关注点不同:单一职责聚焦于类的功能单一性,适用于实现细节;而 ISP 关注接口层面,强调最小化依赖,更适合抽象设计和系统架构规划。
2.5.2. 接口隔离原则的优点
接口隔离原则通过减少类对接口的依赖,带来以下核心优势:
- 提升灵活性与可维护性:将大接口拆分为小而具体的接口,防止外部变更影响扩散。
- 降低耦合度:减少不必要的交互,增强系统内聚力。
- 平衡接口设计:合理划分接口大小,保持系统稳定,避免过细或过大带来的问题。
- 清晰关系层级:通过多接口设计和继承机制,明确对象间的关系。
- 减少代码冗余:避免实现大接口时编写无用方法,提高代码效率。
2.5.3. 接口隔离原则的实现方法
在应用接口隔离原则时,可遵循以下关键规则:
- 接口最小化:一个接口应专注于服务单一模块或业务逻辑,但需适度。
- 按需定制:只为调用者提供必要方法,隐藏无关功能。
- 结合实际:根据具体项目环境和业务逻辑决定接口拆分标准,避免盲目套用。
- 高内聚低耦合:设计接口时,尽量减少对外交互,用最少的方法实现最多功能。
下面以学生成绩管理程序为例介绍接口隔离原则的应用。
2.5.3.1. 【例1】学生成绩管理程序。
分析:学生成绩管理程序一般包含插入成绩、删除成绩、修改成绩、计算总分、计算均分、打印成绩信息、査询成绩信息等功能,如果将这些功能全部放到一个接口中显然不太合理,正确的做法是将它们分别放在输入模块、统计模块和打印模块等 3 个模块中,其类图如图 1 所示。
图1 学生成绩管理程序的类图
package principle;
public class ISPtest {
public static void main(String[] args) {
InputModule input = StuScoreList.getInputModule();
CountModule count = StuScoreList.getCountModule();
PrintModule print = StuScoreList.getPrintModule();
input.insert();
count.countTotalScore();
print.printStuInfo();
//print.delete();
}
}
//输入模块接口
interface InputModule {
void insert();
void delete();
void modify();
}
//统计模块接口
interface CountModule {
void countTotalScore();
void countAverage();
}
//打印模块接口
interface PrintModule {
void printStuInfo();
void queryStuInfo();
}
//实现类
class StuScoreList implements InputModule, CountModule, PrintModule {
private StuScoreList() {
}
public static InputModule getInputModule() {
return (InputModule) new StuScoreList();
}
public static CountModule getCountModule() {
return (CountModule) new StuScoreList();
}
public static PrintModule getPrintModule() {
return (PrintModule) new StuScoreList();
}
public void insert() {
System.out.println("输入模块的insert()方法被调用!");
}
public void delete() {
System.out.println("输入模块的delete()方法被调用!");
}
public void modify() {
System.out.println("输入模块的modify()方法被调用!");
}
public void countTotalScore() {
System.out.println("统计模块的countTotalScore()方法被调用!");
}
public void countAverage() {
System.out.println("统计模块的countAverage()方法被调用!");
}
public void printStuInfo() {
System.out.println("打印模块的printStuInfo()方法被调用!");
}
public void queryStuInfo() {
System.out.println("打印模块的queryStuInfo()方法被调用!");
}
}
输入模块的insert()方法被调用!
统计模块的countTotalScore()方法被调用!
打印模块的printStuInfo()方法被调用!
2.5.3.2. 【例 2】 文件处理系统
假设我们正在设计一个文件处理系统,其中有不同类型的文件需要处理,例如文本文件和图片文件。我们需要实现文件的读取、写入、以及可能的格式转换功能。
错误的设计(不符合接口隔离原则)
// 定义一个通用的文件处理接口
public interface FileHandler {
void read(String fileName);
void write(String fileName);
void convert(String fileName, FileType fileType);
}
// 实现文本文件处理类
public class TextFileHandler implements FileHandler {
@Override
public void read(String fileName) {
// 读取文本文件的逻辑
}
@Override
public void write(String fileName) {
// 写入文本文件的逻辑
}
@Override
public void convert(String fileName, FileType fileType) {
if (fileType == FileType.IMAGE) {
// 错误:文本文件不能转换为图片文件
} else if (fileType == FileType.TEXT) {
// 文本文件的格式转换逻辑
}
}
}
// 实现图片文件处理类
public class ImageFileHandler implements FileHandler {
@Override
public void read(String fileName) {
// 读取图片文件的逻辑
}
@Override
public void write(String fileName) {
// 写入图片文件的逻辑
}
@Override
public void convert(String fileName, FileType fileType) {
if (fileType == FileType.TEXT) {
// 错误:图片文件不能转换为文本文件
} else if (fileType == FileType.IMAGE) {
// 图片文件的格式转换逻辑
}
}
}
在上述设计中,定义了一个通用的 FileHandler 接口,它包含了读取、写入和格式转换三个方法。然后分别实现了 TextFileHandler 和 ImageFileHandler 类来处理文本文件和图片文件。
问题分析:
冗余接口方法: FileHandler 接口中的 convert 方法对于文本文件处理类和图片文件处理类来说,并不都是必需的。
例如,文本文件处理类不需要实现图片文件的转换逻辑,反之亦然。
接口污染: convert 方法使得接口过于笨重,迫使实现类去实现不必要的方法,增加了系统的复杂性和理解难度。
符合接口隔离原则的设计
为了符合接口隔离原则,我们可以将接口进行细化,每个接口只包含相关的方法,从而避免不必要的依赖和复杂性。
// 定义文件读取接口
public interface Readable {
void read(String fileName);
}
// 定义文件写入接口
public interface Writable {
void write(String fileName);
}
// 定义文件格式转换接口
public interface Convertible {
void convert(String fileName, FileType fileType);
}
// 实现文本文件处理类
public class TextFileHandler implements Readable, Writable {
@Override
public void read(String fileName) {
// 读取文本文件的逻辑
}
@Override
public void write(String fileName) {
// 写入文本文件的逻辑
}
}
// 实现图片文件处理类
public class ImageFileHandler implements Readable, Writable, Convertible {
@Override
public void read(String fileName) {
// 读取图片文件的逻辑
}
@Override
public void write(String fileName) {
// 写入图片文件的逻辑
}
@Override
public void convert(String fileName, FileType fileType) {
// 图片文件的格式转换逻辑
}
}
设计分析:
- 接口细化: 将通用的 FileHandler 接口分解为 Readable、Writable 和 Convertible 接口,每个接口专注于一个单一的功能。
- 避免依赖: 每个文件处理类只需实现它们需要的接口,例如 TextFileHandler 只需实现 Readable 和 Writable 接口,而 ImageFileHandler 实现了 Readable、Writable 和 Convertible 接口。
通过这样的设计,我们遵循了接口隔离原则,使得系统中的接口更加专一和灵活,减少了不必要的复杂性和耦合度,同时提高了每个类的可维护性和可测试性。
2.6. 迪米特法则
2.6.1. 迪米特法则的定义
迪米特法则(Law of Demeter,LoD),又称最少知识原则,由Ian Holland于1987年提出,后因Grady Booch和《程序员修炼之道》一书而被广泛传播。其核心思想是:只与直接关联的对象交互,避免与“陌生”对象通信。具体而言,若两个软件实体无需直接通信,则应通过第三方间接调用,以减少类之间的依赖,提高模块独立性。
这里的“直接关联的对象”包括当前对象自身、其成员对象、创建的对象、方法参数及返回值等与当前对象有直接联系的对象。
💡 tips
直接的朋友:我们称出现成员变量,方法参数,方法返回值中的类为直接的朋友,而出现在局部变量中的类不是直接的朋友。也就是说,陌生的类最好不要以局部变量 的形式出现在类的内部。
2.6.2. 迪米特法则的优点
迪米特法则主张减少软件实体间的通信宽度和深度,其正确应用有两大好处:
- 降低类间耦合,增强模块独立性。
- 提高类的可复用性和系统的扩展性。
然而,过度应用可能导致大量中介类的产生,增加系统复杂性并降低通信效率。因此,在使用迪米特法则时需权衡利弊,确保在实现低耦合与高内聚的同时,保持系统结构清晰简洁。
2.6.3. 迪米特法则的实现方法
迪米特法则的核心是:依赖者只依赖必要的对象,被依赖者只暴露必要的方法。在应用该法则时,需注意以下六点:
- 弱耦合:设计类时尽量减少类之间的依赖,以增强可复用性。
- 降低访问权限:限制类成员的可见性,避免不必要的访问。
- 优先使用不变类:尽可能将类设计为不可变,提升安全性和稳定性。
- 减少对外部对象的引用:尽量降低对其他类的直接依赖。
- 封装属性:不直接暴露类的属性,而是通过访问器(如
get
和set
方法)进行操作。 - 谨慎使用序列化:小心处理
Serializable
功能,避免潜在风险。
2.6.3.1. 【例1】明星与经纪人的关系实例。
分析:明星由于全身心投入艺术,所以许多日常事务由经纪人负责处理,如与粉丝的见面会,与媒体公司的业务洽淡等。这里的经纪人是明星的朋友,而粉丝和媒体公司是陌生人,所以适合使用迪米特法则,其类图如图 1 所示。
图1 明星与经纪人的关系图
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.6.3.2. 【例 2】模拟学校管理打印员工信息
- 有一个学校,下属有各个学院和总部,现要求打印出学校总部员 工ID和学院员工的id
- 编程实现上面的功能, 看代码演示
public class Demeter1 {
public static void main(String[] args) {
//创建了一个 SchoolManager 对象
SchoolManager schoolManager = new SchoolManager();
//输出学院的员工id 和 学校总部的员工信息
schoolManager.printAllEmployee(new CollegeManager());
}
}
//学校总部员工类
class Employee {
private String id;
public void setId(String id) {
this.id = id;
}
public String getId() {
return id;
}
}
//学院的员工类
class CollegeEmployee {
private String id;
public void setId(String id) {
this.id = id;
}
public String getId() {
return id;
}
}
//管理学院员工的管理类
class CollegeManager {
//返回学院的所有员工
public List<CollegeEmployee> getAllEmployee() {
List<CollegeEmployee> list = new ArrayList<CollegeEmployee>();
for (int i = 0; i < 10; i++) { //这里我们增加了10个员工到 list
CollegeEmployee emp = new CollegeEmployee();
emp.setId("学院员工id= " + i);
list.add(emp);
}
return list;
}
}
//学校管理类
//分析 SchoolManager 类的直接朋友类有哪些 Employee、CollegeManager
//CollegeEmployee 不是 直接朋友 而是一个陌生类,这样违背了 迪米特法则
class SchoolManager {
//返回学校总部的员工
public List<Employee> getAllEmployee() {
List<Employee> list = new ArrayList<Employee>();
for (int i = 0; i < 5; i++) { //这里我们增加了5个员工到 list
Employee emp = new Employee();
emp.setId("学校总部员工id= " + i);
list.add(emp);
}
return list;
}
//该方法完成输出学校总部和学院员工信息(id)
void printAllEmployee(CollegeManager sub) {
//分析问题
//1. 这里的 CollegeEmployee 不是 SchoolManager的直接朋友
//2. CollegeEmployee 是以局部变量方式出现在 SchoolManager
//3. 违反了 迪米特法则
//获取到学院员工
List<CollegeEmployee> list1 = sub.getAllEmployee();
System.out.println("------------学院员工------------");
for (CollegeEmployee e : list1) {
System.out.println(e.getId());
}
//获取到学校总部员工
List<Employee> list2 = this.getAllEmployee();
System.out.println("------------学校总部员工------------");
for (Employee e : list2) {
System.out.println(e.getId());
}
}
}
应用实例改进
- 前面设计的问题在于 SchoolManager 中,CollegeEmployee 类并不是 SchoolManager 类的直接朋友 (分析)
- 按照迪米特法则,应该避免类中出现这样非直接朋友关系的耦合
- 对代码按照迪米特法则进行改进.
public class Demeter1 {
public static void main(String[] args) {
//创建了一个 SchoolManager 对象
SchoolManager schoolManager = new SchoolManager();
//输出学院的员工id 和 学校总部的员工信息
schoolManager.printAllEmployee(new CollegeManager());
}
}
//学校总部员工类
class Employee {
private String id;
public void setId(String id) {
this.id = id;
}
public String getId() {
return id;
}
}
//学院的员工类
class CollegeEmployee {
private String id;
public void setId(String id) {
this.id = id;
}
public String getId() {
return id;
}
}
//管理学院员工的管理类
class CollegeManager {
//返回学院的所有员工
public List<CollegeEmployee> getAllEmployee() {
List<CollegeEmployee> list = new ArrayList<CollegeEmployee>();
for (int i = 0; i < 10; i++) { //这里我们增加了10个员工到 list
CollegeEmployee emp = new CollegeEmployee();
emp.setId("学院员工id= " + i);
list.add(emp);
}
return list;
}
//输出学院员工的信息
public void printEmployee() {
//获取到学院员工
List<CollegeEmployee> list1 = getAllEmployee();
System.out.println("------------学院员工------------");
for (CollegeEmployee e : list1) {
System.out.println(e.getId());
}
}
}
//学校管理类
//分析 SchoolManager 类的直接朋友类有哪些 Employee、CollegeManager
//CollegeEmployee 不是 直接朋友 而是一个陌生类,这样违背了 迪米特法则
class SchoolManager {
//返回学校总部的员工
public List<Employee> getAllEmployee() {
List<Employee> list = new ArrayList<Employee>();
for (int i = 0; i < 5; i++) { //这里我们增加了5个员工到 list
Employee emp = new Employee();
emp.setId("学校总部员工id= " + i);
list.add(emp);
}
return list;
}
//该方法完成输出学校总部和学院员工信息(id)
void printAllEmployee(CollegeManager sub) {
sub.printEmployee();
//获取到学校总部员工
List<Employee> list2 = this.getAllEmployee();
System.out.println("------------学校总部员工------------");
for (Employee e : list2) {
System.out.println(e.getId());
}
}
}
迪米特法则要点:
- 核心是降低类之间的耦合。
- 注意:并非完全消除依赖,而是减少不必要的依赖,保持适度的耦合关系。
2.7. 合成复用原则
2.7.1. 合成复用原则的定义
合成复用原则(CRP)又称组合/聚合复用原则(CARP),主张在软件复用时优先通过组合或聚合实现,而非直接使用继承。若必须使用继承,则需严格遵守里氏替换原则。合成复用原则与里氏替换原则相辅相成,共同体现开闭原则的具体规范。
2.7.2. 合成复用原则的重要性
合成复用原则的重要性
类的复用分为继承复用和合成复用两种方式。虽然继承复用实现简单,但存在以下问题:
- 破坏封装性,子类可直接访问父类的实现细节(白箱复用)。
- 子类与父类高度耦合,父类改动会影响子类。
- 缺乏灵活性,继承关系在编译时固定,无法动态调整。
相比之下,组合或聚合复用的优势更明显: - 保持封装性,新对象无需了解被包含对象的具体实现(黑箱复用)。
- 降低耦合度,对象间仅通过接口交互。
- 提供更高灵活性,支持运行时动态调整组合关系。
2.7.3. 合成复用原则的实现方法
合成复用原则是通过将已有对象作为新对象的成员来实现复用,新对象可以调用已有对象的功能。
以汽车分类管理程序为例介绍该原则的应用:
【例1】汽车分类管理程序
分析:汽车按“动力源”可分为汽油车、电动车等;按“颜色”可分为白车、黑车、红车等。如果同时考虑这两种分类,组合会非常多。图1展示了使用继承关系实现汽车分类的类图。
图1 用继承关系实现的汽车分类的类图
从图 1 可以看出用继承关系实现会产生很多子类,而且增加新的“动力源”或者增加新的“颜色”都要修改源代码,这违背了开闭原则,显然不可取。但如果改用组合关系实现就能很好地解决以上问题,其类图如图 2 所示。
图2 用组合关系实现的汽车分类的类