本文介绍一下设计模式中的几大原则,这些原则共同的目标是创建高内聚、低耦合 、易于维护、扩展和复用的代码。
1. 单一职责原则
1.1 解读
单一职责原则,Single Responsibility Principle,SRP
核心思想:一个类只负责一项明确的职责或功能,一个类应该只有一个引起它变化的原因。
一个类职责过多,职责都耦合在一起,甚至还会产生一些"上帝类"。当其中任何一个职责需求发生变更时,都需要修改这个类,因为高耦合,很可能导致变更之外的其他职责也发生变化。如果修改只影响一个职责明确的类,风险最低,影响范围最小。
单一职责原则是构建可维护系统的基石,我们需要发现职责,并把职责相互分离,让每个类专注做好一件事或一类事。确保每个类职责单一、内聚。如果一个类承担过多职责,就很难做到对扩展开放或清晰地定义抽象接口。
1.2 正反举例
以处理订单为例:
违反 SRP 原则设计:
java
class OrderProcessor {
public void processOrder(Order order) {
// 1. 数据验证
if (!validateOrder(order)) {
throw new InvalidOrderException("Order validation failed");
}
// 2. 价格计算
calculateTotalPrice(order);
// 3. 数据持存储
saveOrderToDatabase(order);
// 4. 发送订单通知
sendConfirmationEmail(order);
}
// ... 具体的验证、计算、保存、发送邮件方法实现 ...
}
以上代码设计存在的问题:OrderProcessor 类承担了太多职责:验证、计算、存储、通知等。任何一方面的需求变更(如邮件模板变化)都需要修改这个类,风险高且难以测试单个功能。
遵循 SRP 原则设计:
java
class OrderValidator { // 职责:数据验证
public boolean validate(Order order) { ... }
}
class PriceCalculator { // 职责:价格计算
public void calculateTotal(Order order) { ... }
}
class OrderRepository { // 职责:数据存储
public void save(Order order) { ... }
}
class NotificationService { // 职责:发送通知
public void sendOrderConfirmation(Order order) { ... }
}
class OrderProcessor { // 职责:协调流程 (仅此一个)
private OrderValidator validator;
private PriceCalculator calculator;
private OrderRepository repository;
private NotificationService notifier;
// 通过构造器或Setter注入依赖
public OrderProcessor(OrderValidator v, PriceCalculator c, OrderRepository r, NotificationService n) {
this.validator = v;
this.calculator = c;
this.repository = r;
this.notifier = n;
}
public void processOrder(Order order) {
if (!validator.validate(order)) {
throw new InvalidOrderException("Order validation failed");
}
calculator.calculateTotal(order);
repository.save(order);
notifier.sendOrderConfirmation(order);
}
}
以上代码设计的好处:每个类职责清晰单一,需求变更影响小,一个类只有一个引起它变化的原因。如邮件格式变了,只需改 NotificationService。而且 NotificationService 还可以进行复用,可以用于发送其他类型的通知。
并且易于测试,可以独立测试验证逻辑、计算逻辑、存储逻辑、通知逻辑。
2. 开放-封闭原则
2.1 解读
开放-封闭原则,Open-Closed Principle,OCP
核心思想:软件实体(类、模块、函数等)应该对扩展开放,对修改关闭
需求会变是常态,面对新需求,对程序的改动是通过增加新代码进行的,而不是更改现有的、已经测试通过并运行稳定的代码。
不可能所有代码都绝对不修改。目标是隔离变化,让核心、稳定的部分尽量封闭,将易变的部分封装在可通过扩展来改变的抽象后面。
通常通过定义稳定的抽象层(接口或抽象类)来实现 OCP。具体实现依赖于这些抽象。当需要新行为时,创建新的实现类来扩展抽象,而不是修改已有的实现或依赖于抽象的客户端代码。
2.2 正反举例
以图形绘制系统为例:
违反OCP的设计:
java
class GraphicsRenderer {
public void drawShape(Object shape) {
if (shape instanceof Circle) {
drawCircle((Circle) shape); // 修改点:新增图形需要添加if分支
} else if (shape instanceof Rectangle) {
drawRectangle((Rectangle) shape);
}
}
private void drawCircle(Circle c) { ... }
private void drawRectangle(Rectangle r) { ... }
}
以上代码设计存在的问题:如果添加新图形 Triangle,必须修改这个 drawShape 方法,违反了对修改关闭的原则,容易引入错误,且修改点会随着图形种类增多而爆炸。
遵循OCP的设计:
java
interface Shape { // 稳定的抽象:所有图形都必须能绘制自己
void draw();
}
class Circle implements Shape { // 具体实现:圆形
@Override
public void draw() {
// 具体绘制圆形的代码
System.out.println("Drawing a Circle");
}
}
class Rectangle implements Shape { // 具体实现:矩形
@Override
public void draw() {
// 具体绘制矩形的代码
System.out.println("Drawing a Rectangle");
}
}
class GraphicsRenderer {
public void drawShape(Shape shape) { // 参数是抽象Shape
shape.draw(); // 核心逻辑稳定:只调用draw()方法,不关心具体类型
}
}
// 未来扩展:添加三角形
class Triangle implements Shape {
@Override
public void draw() {
System.out.println("Drawing a Triangle");
}
}
以上代码设计的好处:做到了对扩展开放,对修改关闭,且系统核心(GraphicsRenderer)稳定,不易因添加新功能而引入错误。
3. 里氏替换原则
3.1 解读
里氏替换原则,Liskov Substitution Principle,LSP
核心思想:子类型必须能够替换掉他们的父类型,而程序的行为不变
所有引用基类(父类)的地方必须能透明地使用其子类的对象,子类必须完全实现父类的行为契约,不能破坏父类的原有功能。子类可以扩展父类的功能,但不能改变父类原有的行为(包括输入、输出、异常等约定)。
3.2 正反举例
以几何图形系统为例:
违反LSP的设计:
java
class Rectangle {
protected int width;
protected int height;
public void setWidth(int width) { this.width = width; }
public void setHeight(int height) { this.height = height; }
public int getArea() { return width * height; }
}
class Square extends Rectangle {
// 正方形:宽高必须相等,因此重写setter方法强制相等
@Override
public void setWidth(int width) {
super.setWidth(width);
super.setHeight(width);
}
@Override
public void setHeight(int height) {
super.setHeight(height);
super.setWidth(height);
}
}
// 客户端代码:期望操作矩形
public class Client {
public void resize(Rectangle rect) {
// 假设这个操作期望只增加宽度
rect.setWidth(rect.getWidth() + 10);
// 预期:高度不变,面积增加
}
public static void main(String[] args) {
Client client = new Client();
Rectangle rect = new Rectangle();
rect.setWidth(5);
rect.setHeight(10);
client.resize(rect); // 正常:宽度15,高度10,面积150
// 使用正方形(子类替换父类)
Rectangle square = new Square();
square.setWidth(5); // 此时宽高都是5
client.resize(square); // 宽度变为15,高度也被改为15,面积225(预期是宽度15高度5,面积75)
}
}
以上代码设计存在的问题:Square 继承 Rectangle 并重写了 setWidth 和 setHeight 方法,导致在 resize 方法中(期望操作矩形)传入正方形时,行为发生了不可预期的改变(高度被意外修改)。子类不能透明替换父类。
遵循LSP的设计:
java
// 使用组合代替继承
abstract class Shape {
public abstract int getArea();
}
class Rectangle extends Shape {
private int width;
private int height;
// 构造器、getter/setter
@Override
public int getArea() { return width * height; }
}
class Square extends Shape {
private int side;
// 构造器、getter/setter for side
@Override
public int getArea() { return side * side; }
}
// 客户端代码:只依赖Shape抽象
public class Client {
public void printArea(Shape shape) {
System.out.println("Area: " + shape.getArea());
}
}
以上代码设计的好处:通过多态和抽象基类(Shape)实现了解耦与灵活性,子类(Rectangle 和 Square)各自独立实现 getArea 方法,互不干扰,确保行为独立,客户端仅依赖抽象的 Shape 接口,无需关心具体类型即可统一调用面积计算,行为一致且可扩展。
同时,正方形通过直接继承 Shape 而非矩形,避免了因继承关系导致的行为不一致(如强制继承矩形的长宽属性),从而更符合单一职责原则和里氏替换原则。
4. 依赖倒置原则
4.1 解读
依赖倒置原则,Dependency Inversion Principle,DIP
核心思想:高层模块不应该依赖于低层模块,两者都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象
传统依赖方向:高层调用并依赖具体低层实现,高层依赖低层
倒置:将传统依赖方向进行倒置,DIP 要求高层和低层都依赖于同一个稳定的抽象接口。
针对接口编程,不对实现编程,这是实现 DIP 的关键。模块间通过接口(或抽象类)进行交互,而不是直接依赖具体的实现类。
DIP 极大地降低了模块间的耦合度。高层模块只关心抽象接口提供的功能契约,不关心具体是谁、如何实现的。低层模块只要符合接口契约,就可以被高层使用,甚至可以在运行时动态替换。
4.2 正反举例
以数据存储服务为例:
违反DIP的设计:
java
class ReportService { // 高层业务模块
private FileDataStorage storage = new FileDataStorage(); // 直接依赖具体低层实现
public void generateReport(ReportData data) {
// ... 生成报告的业务逻辑 ...
storage.save(data); // 调用具体实现的方法
}
}
class FileDataStorage { // 低层实现:文件存储
public void save(ReportData data) {
// 具体保存到文件的代码
System.out.println("Saving report data to a file...");
}
}
以上代码存在的问题:ReportService(高层模块)直接依赖并实例化了具体的 FileDataStorage(低层实现),这种设计导致系统紧耦合:更换存储方式(如改用 DatabaseDataStorage)需修改 ReportService 源代码,违背开闭原则。
遵循DIP的设计:
java
interface DataStorage { // 稳定的抽象:定义存储契约
void save(ReportData data);
}
class FileDataStorage implements DataStorage { // 具体实现:文件存储
@Override
public void save(ReportData data) {
// 具体保存到文件的代码
System.out.println("Saving report data to a file...");
}
}
class DatabaseDataStorage implements DataStorage { // 具体实现:数据库存储
@Override
public void save(ReportData data) {
// 具体保存到数据库的代码
System.out.println("Saving report data to database...");
}
}
class CloudStorage implements DataStorage { ... } // 另一个具体实现:云存储
class ReportService { // 高层业务模块
private DataStorage storage; // 依赖抽象接口,而非具体实现
// 依赖注入
public ReportService(DataStorage storage) {
this.storage = storage;
}
public void generateReport(ReportData data) {
// ... 生成报告的业务逻辑 (稳定) ...
storage.save(data);
}
}
// 使用示例 (通常在应用启动或配置处组装)
public static void main(String[] args) {
// 选择使用哪种存储实现 (配置决定)
DataStorage storage = new DatabaseDataStorage();
ReportService reportService = new ReportService(storage); // 注入依赖
reportService.generateReport(someData);
}
以上代码设计的好处:以通过依赖接口而非具体实现,实现了高层与低层的解耦:ReportService 仅依赖稳定的接口,无需关心底层存储细节,更换存储方式仅需在配置层注入不同实现,无需修改其代码。同时,低层实现必须遵循接口规范,这种设计使得系统易于扩展且便于测试。
5. 迪米特法则
5.1 解读
迪米特法则,Law of Demeter,LoD
核心思想:一个对象应该对其他对象保持最少的了解
如果两个类不必彼此直接通信,那么这两个类就不应当发生直接的相互作用。如果其中一个类需要调用另一个类的某一个方法的话,可以通过第三者转发这个调用。
迪米特法则根本思想,是强调了类之间的松耦合。类之间的耦合越弱,越有利于复用,一个处在弱耦合的类被修改,不会对有关系的类造成波及。
5.2 正反举例
以超市购物系统为例:
违反LoD的设计:
java
class Customer {
private ShoppingCart cart;
public ShoppingCart getCart() { return cart; }
}
class ShoppingCart {
private List<Item> items;
public List<Item> getItems() { return items; }
}
class Item {
private String name;
private double price;
public double getPrice() { return price; }
}
class Cashier {
// 收银员直接深入到顾客的购物车内部获取商品并计算价格
public double calculateTotalPrice(Customer customer) {
double total = 0.0;
for (Item item : customer.getCart().getItems()) {
total += item.getPrice();
}
return total;
}
}
以上代码设计存在的问题:Cashier 类直接调用了 customer.getCart().getItems(),这使它不仅依赖于Customer,还依赖于 ShoppingCart 和 Item 的内部结构。如果 ShoppingCart 不再用 List<Item> 存储商品,Cashier 的代码也必须修改。
遵循LoD的设计:
java
class Customer {
private ShoppingCart cart;
public double getCartTotalPrice() {
return cart.calculateTotalPrice();
}
}
class ShoppingCart {
private List<Item> items;
public double calculateTotalPrice() {
double total = 0.0;
for (Item item : items) {
total += item.getPrice();
}
return total;
}
}
class Item { ... } // 同上
class Cashier {
public double checkout(Customer customer) {
return customer.getCartTotalPrice(); // 只调用Customer的方法
}
}
以上代码设计的好处:收银员仅与直接关联的客户交互,不再深入访问客户内部的购物车或商品等细节,仅通过客户暴露的接口完成业务。若购物车内部数据结构调整,只需修改购物车自身的方法,无需改动收银员或客户的公共接口,确保了系统各模块的独立性与可维护性,同时明确了谁的数据谁负责计算的职责边界。
6. 接口隔离原则
6.1 解读
接口隔离原则,Interface Segregation Principle,ISP
核心思想:客户端不应该被迫依赖于它不使用的接口,一个类对另一个类的依赖应该建立在最小的接口上。
当一个接口包含过多方法时,实现类可能被迫实现无用逻辑(如空方法或异常抛出),导致代码冗余、维护成本上升并增加潜在错误风险。通过接口隔离原则拆分接口为小而专一的子接口,可确保客户端仅依赖所需方法,避免不必要耦合。
6.2 正反举例
以多功能打印机为例:
违反ISP的设计:
java
interface MultiFunctionPrinter {
void print(Document doc);
void scan(Document doc);
void fax(Document doc);
}
// 经济型打印机:只有打印功能
class EconomyPrinter implements MultiFunctionPrinter {
public void print(Document doc) { ... }
public void scan(Document doc) {
throw new UnsupportedOperationException("Scan not supported");
}
public void fax(Document doc) {
throw new UnsupportedOperationException("Fax not supported");
}
}
// 高级打印机:支持所有功能
class AdvancedPrinter implements MultiFunctionPrinter { ... }
以上代码设计存在的问题:EconomyPrinter 被迫实现了它根本不需要的方法,违反了 ISP。客户端如果调用这些方法可能会遇到异常。
遵循ISP的设计:
java
// 拆分成多个专用接口
interface Printer {
void print(Document doc);
}
interface Scanner {
void scan(Document doc);
}
interface FaxMachine {
void fax(Document doc);
}
// 经济型打印机:只实现Printer
class EconomyPrinter implements Printer {
public void print(Document doc) { ... }
}
// 中档打印机:实现打印和扫描
class MidRangePrinter implements Printer, Scanner {
public void print(Document doc) { ... }
public void scan(Document doc) { ... }
}
// 高级打印机:支持所有功能
class AdvancedPrinter implements Printer, Scanner, FaxMachine { ... }
// 更灵活的接口组合
interface MultiFunctionDevice extends Printer, Scanner, FaxMachine {
// 组合功能
}
以上代码设计的好处:通过接口隔离原则实现了模块解耦,避免冗余依赖。系统扩展时,新增功能(如复印)可通过创建独立的 Copier 接口实现,无需修改现有接口或实现类,确保了高内聚、低耦合的设计目标。
7. 合成复用原则
7.1 解读
合成复用原则,Composite Reuse Principle,CRP
核心思想:尽量使用对象组合(has-a) / 聚合(contanis-a),而不是继承关系(is-a)达到软件复用的目的
继承的缺点:
- 破坏封装:子类依赖于父类的实现细节(白箱复用)。
- 紧耦合:父类变化可能迫使子类变化,即使子类不关心这些变化。
- 灵活性差:继承在编译时静态定义,无法在运行时动态改变。
组合/聚合的优点:
- 黑箱复用:新对象通过调用组合对象的方法来复用其功能,无需知道内部细节。
- 松耦合:只需保证被组合对象的接口稳定。
- 运行时动态替换:组合关系通常通过接口实现,可以在运行时动态替换具体实现。
如何选择:除非是严格的"is-a"关系且符合LSP,否则优先考虑组合。
7.2 正反举例
以汽车引擎系统为例:
违反CRP的设计:
java
// 通过继承复用
class GasEngine {
public void start() { ... }
}
class ElectricEngine {
public void start() { ... }
}
class GasCar extends GasEngine { // Car "is-a" Engine?
// 汽车除了引擎还有轮子、底盘等,不能仅通过继承引擎实现
}
class ElectricCar extends ElectricEngine { ... }
以上代码设计存在的问题:继承表达的是"is-a"关系,但汽车不是引擎,这导致逻辑混乱,且无法灵活更换引擎。
遵循CRP的设计:
java
// 定义引擎接口
interface Engine {
void start();
}
class GasEngine implements Engine {
public void start() { System.out.println("Gas engine started"); }
}
class ElectricEngine implements Engine {
public void start() { System.out.println("Electric engine started"); }
}
// 汽车拥有(has-a)一个引擎
class Car {
private Engine engine; // 组合
public Car(Engine engine) {
this.engine = engine;
}
public void start() {
engine.start();
System.out.println("Car is ready to drive!");
}
// 可以动态更换引擎
public void setEngine(Engine newEngine) {
this.engine = newEngine;
}
}
public class Main {
public static void main(String[] args) {
Engine gasEngine = new GasEngine();
Car gasCar = new Car(gasEngine);
gasCar.start(); // Gas engine started, Car is ready...
// 更换为电动引擎
Engine electricEngine = new ElectricEngine();
gasCar.setEngine(electricEngine);
gasCar.start(); // Electric engine started, Car is ready...
}
}
以上代码设计的好处:Car 通过组合(拥有引擎)复用引擎的功能,而不是继承引擎。更符合现实。
8. 总结
七大面向对象设计原则(SOLID + LoD + CRP)共同构成了设计模式的基础:
原则 | 核心思想 |
---|---|
SRP | 一个类只做一件事 |
OCP | 对扩展开放,对修改关闭 |
LSP | 子类必须能透明替换父类 |
ISP | 客户端不应依赖它不需要的接口 |
DIP | 依赖抽象,而非具体实现 |
LoD | 减少对象间的交互,降低耦合 |
CRP | 优先使用组合,而非继承 |
掌握这些原则可以创建出高内聚、低耦合 、易于维护、扩展和复用的代码。。