每天一个设计模式——开闭原则
什么是开闭原则?
开闭原则(OCP)的核心思想是:
软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。
- 对扩展开放:当需要添加新功能时,可以通过添加新代码(比如新类或新方法)来扩展系统。
- 对修改关闭:已有代码(尤其是核心代码)不应该被修改,以避免引入错误或影响现有功能。
通俗解释
想象你在经营一家披萨店,菜单上有 Margherita 披萨和 Pepperoni 披萨:
- 如果每次有新口味(比如加个海鲜披萨),你都得改菜单的打印代码、价格计算代码、制作流程代码,那会很麻烦,还可能把原有披萨的逻辑改错。
- 开闭原则就像设计一个“模板”:菜单系统允许添加新披萨(扩展),但不需要改动已有披萨的代码(关闭修改)。比如,你可以定义一个“披萨基类”,新口味只需继承它,添加自己的配料和价格逻辑。
这样,系统既灵活又稳定,新功能不会破坏旧功能。
为什么需要开闭原则?
- 降低维护成本:修改现有代码可能引入 bug,特别是在大型项目中。OCP 通过扩展而非修改,减少了出错风险。
- 提高扩展性:新需求可以通过新增代码实现,系统更容易适应变化。
- 增强代码复用:通过抽象和继承,核心逻辑可以被复用,新功能只需实现特定部分。
- 便于测试:已有功能代码不变,测试用例无需重写,只需测试新扩展的部分。
违反开闭原则的例子
假设我们要实现一个图形面积计算器,支持计算圆形和矩形的面积。如果不遵循开闭原则,代码可能是这样的:
public class AreaCalculator {public double calculateArea(Object shape) {if (shape instanceof Circle) {Circle circle = (Circle) shape;return Math.PI * circle.getRadius() * circle.getRadius();} else if (shape instanceof Rectangle) {Rectangle rectangle = (Rectangle) shape;return rectangle.getWidth() * rectangle.getHeight();}return 0;}
}class Circle {private double radius;public Circle(double radius) {this.radius = radius;}public double getRadius() {return radius;}
}class Rectangle {private double width;private double height;public Rectangle(double width, double height) {this.width = width;this.height = height;}public double getWidth() {return width;}public double getHeight() {return height;}
}class Main {public static void main(String[] args) {AreaCalculator calculator = new AreaCalculator();Circle circle = new Circle(5);Rectangle rectangle = new Rectangle(4, 6);System.out.println("圆形面积: " + calculator.calculateArea(circle));System.out.println("矩形面积: " + calculator.calculateArea(rectangle));}
}
问题:
- 如果要添加新图形(比如三角形),必须修改
AreaCalculator
的calculateArea
方法,增加新的else if
分支。 - 每次添加新图形都要改动
AreaCalculator
,违反了“对修改关闭”。 - 修改可能引入错误,比如不小心改错了圆形或矩形的计算逻辑。
- 代码中的
instanceof
判断会导致代码复杂,难以维护。
符合开闭原则的改进
为了遵循开闭原则,我们可以通过抽象(如接口或抽象类)来设计系统,让新图形通过扩展实现,而无需修改现有代码。
// 定义一个抽象接口,所有图形都实现它
public interface Shape {double calculateArea();
}// 圆形实现 Shape 接口
public class Circle implements Shape {private double radius;public Circle(double radius) {this.radius = radius;}@Overridepublic double calculateArea() {return Math.PI * radius * radius;}
}// 矩形实现 Shape 接口
public class Rectangle implements Shape {private double width;private double height;public Rectangle(double width, double height) {this.width = width;this.height = height;}@Overridepublic double calculateArea() {return width * height;}
}// 新增三角形,无需修改 AreaCalculator
public class Triangle implements Shape {private double base;private double height;public Triangle(double base, double height) {this.base = base;this.height = height;}@Overridepublic double calculateArea() {return 0.5 * base * height;}
}// 面积计算器,依赖抽象接口
public class AreaCalculator {public double calculateArea(Shape shape) {return shape.calculateArea();}
}public class Main {public static void main(String[] args) {AreaCalculator calculator = new AreaCalculator();Shape circle = new Circle(5);Shape rectangle = new Rectangle(4, 6);Shape triangle = new Triangle(3, 4);System.out.println("圆形面积: " + calculator.calculateArea(circle));System.out.println("矩形面积: " + calculator.calculateArea(rectangle));System.out.println("三角形面积: " + calculator.calculateArea(triangle));}
}
改进后的好处:
- 对扩展开放:添加新图形(如
Triangle
)只需实现Shape
接口,无需修改AreaCalculator
。 - 对修改关闭:
AreaCalculator
的代码保持不变,核心逻辑稳定。 - 代码清晰:每个图形类负责自己的面积计算,逻辑清晰,易于测试。
- 复用性强:
Shape
接口可以被其他功能复用,比如计算周长。
如何在实际项目中应用开闭原则?
作为初学者,你可以按照以下步骤在编码时应用开闭原则:
-
抽象化设计:
- 识别系统中可能变化的部分(比如新增图形、新的通知方式),用接口或抽象类定义通用行为。
- 例如,定义
Shape
接口,规定所有图形必须实现calculateArea
方法。
-
依赖抽象而非具体实现:
- 让核心逻辑(如
AreaCalculator
)依赖接口(如Shape
),而不是具体的类(如Circle
)。 - 使用依赖注入(Dependency Injection)传递抽象接口的实例。
- 让核心逻辑(如
-
使用组合或继承:
- 通过继承接口或抽象类来扩展功能,比如新增
Triangle
类。 - 或者使用组合模式,将功能拆分成小模块,通过组合实现扩展。
- 通过继承接口或抽象类来扩展功能,比如新增
-
避免条件分支:
- 避免在代码中使用大量
if-else
或instanceof
判断,这些通常是违反 OCP 的信号。 - 用多态(Polymorphism)替代条件判断。
- 避免在代码中使用大量
-
测试驱动开发:
- 编写单元测试,确保新扩展不会破坏现有功能。
- 例如,测试
AreaCalculator
对Circle
和Rectangle
的计算正确,再添加Triangle
测试。
实际案例:结合项目
我们可以找到开闭原则的应用场景:
前端(Vue 示例)
在 formStorageQuantity.vue
中,loadChartData
方法专门负责加载柱状图数据,loadBizInventoryEquipmentWidgetData
方法负责加载表格数据。如果需要添加新的图表类型(比如饼图),可以这样做:
- 定义一个抽象的
ChartDataLoader
接口,包含loadData
方法。 - 当前的
loadChartData
实现柱状图逻辑,新的饼图可以新增一个类实现ChartDataLoader
。 - 修改
formStorageQuantity.vue
的refreshFormStorageQuantity
方法,让它根据配置选择不同的ChartDataLoader
。
// 抽象接口(模拟)
class ChartDataLoader {loadData() {throw new Error("必须实现 loadData 方法");}
}// 柱状图数据加载器
class BarChartDataLoader extends ChartDataLoader {loadData(params) {// 当前的 loadChartData 逻辑console.log("加载柱状图数据", params);// 假设调用 API 返回数据}
}// 饼图数据加载器(扩展)
class PieChartDataLoader extends ChartDataLoader {loadData(params) {console.log("加载饼图数据", params);// 新增的饼图逻辑}
}// 在 Vue 组件中使用
export default {data() {return {chartDataLoader: new BarChartDataLoader(), // 可切换为 PieChartDataLoader};},methods: {loadChartData() {this.chartDataLoader.loadData({ /* 参数 */ });},},
};
效果:新增饼图只需实现 PieChartDataLoader
,无需修改 loadChartData
的核心逻辑,符合 OCP。
后端(Java 示例)
在 BizInventoryEquipmentController.listWithGroup
中,当前代码支持按任意字段分组(通过 groupParam
)。如果需要支持新的聚合方式(比如按 equipment_type
统计),可以:
- 定义一个
GroupStrategy
接口,包含buildGroupQuery
方法。 - 当前的
listWithGroup
使用默认的GroupByFieldStrategy
。 - 新增聚合方式时,创建新的策略类(如
GroupByEquipmentTypeStrategy
),无需修改控制器。
public interface GroupStrategy {String buildGroupQuery(MyGroupParam groupParam);
}public class GroupByFieldStrategy implements GroupStrategy {@Overridepublic String buildGroupQuery(MyGroupParam groupParam) {return MyGroupParam.buildGroupBy(groupParam, BizInventoryEquipment.class);}
}public class BizInventoryEquipmentController {@Autowiredprivate GroupStrategy groupStrategy; // 注入策略@PostMapping("/listWithGroup")public ResponseResult<MyPageData<BizInventoryEquipmentVo>> listWithGroup(@MyRequestBody BizInventoryEquipmentDto bizInventoryEquipmentDtoFilter,@MyRequestBody(required = true) MyGroupParam groupParam,@MyRequestBody MyOrderParam orderParam,@MyRequestBody MyPageParam pageParam) {String orderBy = MyOrderParam.buildOrderBy(orderParam, BizInventoryEquipment.class, false);String groupBy = groupStrategy.buildGroupQuery(groupParam); // 使用策略if (groupBy == null) {return ResponseResult.error(ErrorCodeEnum.INVALID_ARGUMENT_FORMAT, "数据参数错误,分组参数不能为空!");}// 其余逻辑不变}
}
效果:新增分组方式只需实现新的 GroupStrategy
,无需修改 listWithGroup
,符合 OCP。
注意事项
-
抽象的粒度:
- 不要过度抽象,比如为每个小功能都定义接口,可能导致代码复杂。
- 抽象应针对可能变化的部分,比如图形类型、通知方式等。
-
平衡复杂度:
- 开闭原则会增加接口或类的数量,初学者可能觉得复杂。开始时可以从简单场景入手,比如用接口分离变化点。
-
与单一职责原则结合:
- OCP 常与单一职责原则(SRP)一起使用。确保每个类的职责单一,再通过抽象扩展功能。
-
常见实现方式:
- 接口/抽象类:如上例中的
Shape
接口。 - 策略模式:定义行为接口,动态切换实现。
- 工厂模式:通过工厂创建扩展对象。
- 装饰者模式:在不修改原有类的情况下,动态添加功能。
- 接口/抽象类:如上例中的
练习:尝试应用开闭原则
假设你在开发一个支付系统,支持微信支付和支付宝支付,代码如下:
public class PaymentProcessor {public void processPayment(String paymentType, double amount) {if (paymentType.equals("WeChat")) {System.out.println("处理微信支付: " + amount);} else if (paymentType.equals("Alipay")) {System.out.println("处理支付宝支付: " + amount);}}
}
练习任务:
- 重构代码,使其符合开闭原则,支持新增银联支付(UnionPay)而无需修改
PaymentProcessor
。 - 写一个
main
方法,测试微信支付、支付宝支付和银联支付。 - 思考:如果新增 PayPal 支付,需要改哪些代码?
参考答案:
public interface Payment {void processPayment(double amount);
}public class WeChatPayment implements Payment {@Overridepublic void processPayment(double amount) {System.out.println("处理微信支付: " + amount);}
}public class AlipayPayment implements Payment {@Overridepublic void processPayment(double amount) {System.out.println("处理支付宝支付: " + amount);}
}public class UnionPayPayment implements Payment {@Overridepublic void processPayment(double amount) {System.out.println("处理银联支付: " + amount);}
}public class PaymentProcessor {public void processPayment(Payment payment, double amount) {payment.processPayment(amount);}
}public class Main {public static void main(String[] args) {PaymentProcessor processor = new PaymentProcessor();Payment weChat = new WeChatPayment();Payment alipay = new AlipayPayment();Payment unionPay = new UnionPayPayment();processor.processPayment(weChat, 100);processor.processPayment(alipay, 200);processor.processPayment(unionPay, 300);}
}
练习答案分析:
- 新增 PayPal 支付只需创建
PayPalPayment
类实现Payment
接口,无需修改PaymentProcessor
。 - 符合 OCP:对扩展开放(新增支付方式),对修改关闭(核心逻辑不变)。
总结
- 开闭原则:对扩展开放,对修改关闭,通过抽象和多态实现灵活扩展。
- 好处:降低维护成本、提高扩展性、增强复用性和测试性。
- 实现方法:使用接口或抽象类,依赖抽象,结合策略模式、工厂模式等。
- 初学者建议:从简单场景入手,识别变化点,定义接口隔离变化,逐步体会 OCP 的优势。