设计模式七大原则
设计模式的七大原则是指导软件设计和架构的基本准则,帮助开发者创建更灵活、可维护和可扩展的系统。以下是这七大原则的详细介绍:
1. 单一职责原则 (SRP: Single Responsibility Principle)
定义
一个类,应当只有一个引起它变化的原因;即一个类应该只有一个职责。
解释
这意味着一个类应该只专注于一个功能或任务。如果一个类承担了多个职责,修改一个职责可能会影响到其他职责,导致系统变得脆弱且难以维护。
优点
- 降低类的复杂度:每个类只负责一个职责,使代码更清晰易懂。
- 提高代码的可维护性和可扩展性:修改一个职责不会影响其他职责。
- 更易于测试:单一职责的类更容易进行单元测试。
代码
不符合原则
class User {
private String username;
private String password;
public void setUsername(String username) {
this.username = username;
}
public void setPassword(String password) {
this.password = password;
}
public boolean login() {
// 登录逻辑
return true; // 假设总是成功
}
public void sendEmail(String message) {
// 发送电子邮件的逻辑
}
}
在这个例子中,User 类承担了多个职责:管理用户数据、处理登录和发送电子邮件。这种设计违反了单一职责原则。
符合原则
class User {
private String username;
private String password;
public void setUsername(String username) {
this.username = username;
}
public void setPassword(String password) {
this.password = password;
}
}
class UserAuthenticator {
public boolean login(User user) {
// 登录逻辑
return true; // 假设总是成功
}
}
class EmailService {
public void sendEmail(String message) {
// 发送电子邮件的逻辑
}
}
在这个例子中:
- User 类只负责用户数据。
- UserAuthenticator 类处理用户的登录逻辑。
- EmailService 类负责发送电子邮件。
这样做的好处是每个类的职责清晰,维护和扩展变得更加简单。
应用场景
- 复杂系统: 在大型应用中,遵循单一职责原则能有效管理复杂性,确保系统各部分职责明确。
- 频繁变化的功能: 如果某个功能可能会频繁变化,将其与其他功能分离,可以减少影响范围。
- 团队协作: 在团队开发中,不同开发者可以各自负责不同的类,提高工作效率。
2. 开放-封闭原则 (OCP: Open/Closed Principle)
定义
软件实体(类、模块、函数等)应该对扩展开放,对修改封闭。
解释:
这意味着应该可以在不修改现有代码的情况下扩展类的行为。可以通过继承和多态来实现这一点。
优点
- 降低修改风险:避免对现有代码的修改,降低引入新错误的风险。
- 提高可维护性:通过增加新功能而非修改现有代码,代码的可维护性和可读性更高。
- 增强可扩展性:系统可以根据需求快速适应新功能的增加,方便适应业务变化。
- 支持代码重用:通过抽象和接口,可以提高代码的复用性。
代码
假设有一个简单的图形绘制应用,最初只支持绘制圆形和矩形。
// 原始代码
class Circle {
void draw() {
System.out.println("Drawing a circle");
}
}
class Rectangle {
void draw() {
System.out.println("Drawing a rectangle");
}
}
如果需要添加新的图形,比如三角形,遵循开放封闭原则的方法是:
// 定义一个接口
interface Shape {
void draw();
}
// 实现具体的形状
class Circle implements Shape {
public void draw() {
System.out.println("Drawing a circle");
}
}
class Rectangle implements Shape {
public void draw() {
System.out.println("Drawing a rectangle");
}
}
class Triangle implements Shape {
public void draw() {
System.out.println("Drawing a triangle");
}
}
// 使用
public class ShapeDrawer {
public void drawShape(Shape shape) {
shape.draw();
}
}
使用场景
- 大型项目:在大型系统中,需求经常变化,开放封闭原则使得团队可以快速响应新需求而不影响现有功能。
- 插件系统:如果系统需要支持插件或扩展模块,采用开放封闭原则可以允许第三方开发者在不干扰核心系统的情况下添加新功能。
- 策略模式:在需要根据不同条件选择算法或行为时,可以通过定义接口和实现类来扩展,而无需修改使用这些算法的上下文代码。
- 状态管理:在状态机设计中,可以通过扩展状态类来添加新状态,而不需要改变状态机的核心逻辑。
3. 里氏替换原则 (LSP: Liskov Substitution Principle)
定义
子类对象应该能够替换任何父类对象,而不会影响程序的正确性。
解释
这意味着子类必须遵循父类的约定,确保替换时不会改变程序的行为。遵循此原则可以确保继承关系的正确性。
优点
- 增强代码复用:子类可以通过继承父类的功能,复用已有的代码。
- 提高灵活性:可以在不修改现有代码的情况下,通过使用子类来扩展功能。
- 降低耦合性:遵循里氏替换原则的代码可以实现更低的耦合,提高系统的可维护性。
- 促进正确性:确保子类在父类的基础上进行扩展,而不破坏原有的行为,从而提升程序的可靠性。
代码
// 定义一个鸟类
class Bird {
void fly() {
System.out.println("I can fly");
}
}
// 继承鸟类的类:燕子
class Swallow extends Bird {
@Override
void fly() {
System.out.println("Swallow is flying");
}
}
// 继承鸟类的类:鸵鸟
class Ostrich extends Bird {
@Override
void fly() {
throw new UnsupportedOperationException("Ostriches cannot fly");
}
}
// 使用
public class BirdTest {
public static void main(String[] args) {
Bird swallow = new Swallow();
swallow.fly(); // 输出:Swallow is flying
Bird ostrich = new Ostrich();
ostrich.fly(); // 抛出异常
}
}
在上面的代码中,Ostrich 类没有遵循里氏替换原则,因为它实现了一个不支持飞行的行为,这导致使用父类 Bird 时发生异常。正确的设计应该是:
// 定义一个抽象类
abstract class Bird {
abstract void makeSound();
}
// 可飞行的鸟类
abstract class FlyingBird extends Bird {
abstract void fly();
}
// 燕子类
class Swallow extends FlyingBird {
@Override
void makeSound() {
System.out.println("Swallow sound");
}
@Override
void fly() {
System.out.println("Swallow is flying");
}
}
// 鸵鸟类
class Ostrich extends Bird {
@Override
void makeSound() {
System.out.println("Ostrich sound");
}
}
// 使用
public class BirdTest {
public static void main(String[] args) {
FlyingBird swallow = new Swallow();
swallow.fly(); // 输出:Swallow is flying
Bird ostrich = new Ostrich();
ostrich.makeSound(); // 输出:Ostrich sound
}
}
场景
- 多态实现:在需要使用多态的场景中,确保子类能够替换父类而不改变程序的行为。
- 库和框架设计:在设计库和框架时,应该保证用户可以自由地扩展功能而不破坏原有功能。
- 维护和扩展:在进行系统维护和功能扩展时,确保新添加的子类符合父类的预期行为,减少不必要的修改。
- 设计模式:很多设计模式(如策略模式、模板方法模式)都依赖于里氏替换原则,以实现灵活的行为组合。
4. 接口隔离原则 (ISP: Interface Segregation Principle)
定义:
客户端不应该被强迫依赖于它不需要的接口。
解释:
应该将大接口拆分为多个小接口,客户端可以根据需要选择依赖的接口。这可以减少类之间的耦合,提高系统的灵活性。
优点
- 提高灵活性:通过拆分接口,客户端可以选择性地实现自己所需要的接口,避免实现多余的方法。
- 减少系统耦合性:细化接口后,接口的变化不会影响到不相关的客户端,降低了类之间的依赖性。
- 提高代码可读性和可维护性:接口更小更明确,更容易理解和实现。
- 有利于代码的重用:拆分后的小接口可以更容易地在不同的上下文中被复用。
代码
假设我们有一个大型接口Animal,其中包含所有动物类的行为
public interface Animal {
void eat();
void fly();
void swim();
}
如果有一个类Bird实现了Animal接口,它会被强制实现swim(),但鸟并不游泳,这就是接口设计不合理的地方。
违反接口隔离原则的代码:
public class Bird implements Animal {
@Override
public void eat() {
System.out.println("Bird is eating");
}
@Override
public void fly() {
System.out.println("Bird is flying");
}
@Override
public void swim() {
// Birds can't swim, but this method must be implemented.
}
}
遵守接口隔离原则的改进代码:
我们可以将Animal接口拆分为多个更小的接口,比如Eater、Flyer和Swimmer
public interface Eater {
void eat();
}
public interface Flyer {
void fly();
}
public interface Swimmer {
void swim();
}
然后,Bird类只实现它所需的接口:
public class Bird implements Eater, Flyer {
@Override
public void eat() {
System.out.println("Bird is eating");
}
@Override
public void fly() {
System.out.println("Bird is flying");
}
}
对于鱼类,可以实现Eater和Swimmer接口:
public class Fish implements Eater, Swimmer {
@Override
public void eat() {
System.out.println("Fish is eating");
}
@Override
public void swim() {
System.out.println("Fish is swimming");
}
}
这样,鸟和鱼只需要实现它们所需的功能,接口更加明确,代码也更加清晰。
场景
- 大接口被不同类型的类实现时:例如,当接口有多个方法,而不同的类只需要其中的一部分时,应该将接口细化为多个小接口。
- 系统需要较高的可维护性和灵活性时:当系统需要经常修改和扩展时,细化接口可以使得修改某个接口不会影响其他不相关的实现类。
- 客户端对接口功能的需求差异较大时:当不同客户端需要依赖不同功能时,应该设计多个小的接口,分别提供不同的功能。
5. 依赖倒置原则 (DIP: Dependency Inversion Principle)
定义:
高层模块不应依赖低层模块,二者都应该依赖抽象。抽象不应依赖细节,细节应依赖抽象。
解释
这意味着应通过接口或抽象类来解耦高层模块和低层模块的依赖关系,从而提高系统的灵活性和可维护性。
优点
- 降低耦合度:高层和低层模块之间通过接口或抽象类进行交互,使得依赖关系由具体实现转向抽象,降低了模块间的耦合度。
- 提高系统灵活性:高层模块不依赖于低层的具体实现,可以很容易地替换实现类,而不需要修改高层模块的代码。
- 增强可扩展性和可维护性:当系统需求变化时,只需要修改具体的低层实现,而无需改变高层模块。高层模块变得更加稳定和容易维护。
- 有利于单元测试:依赖倒置原则促使使用接口或抽象类,可以轻松替换依赖项为测试桩(mock),从而更方便地进行单元测试。
Java代码
违反依赖倒置原则的代码:
假设我们有一个EmailSender类负责发送电子邮件,高层模块NotificationService直接依赖于这个具体类:
class EmailSender {
public void sendEmail(String message) {
System.out.println("Sending email: " + message);
}
}
class NotificationService {
private EmailSender emailSender;
public NotificationService() {
this.emailSender = new EmailSender();
}
public void notify(String message) {
emailSender.sendEmail(message);
}
}
在这种设计中,NotificationService类直接依赖EmailSender类,如果将来需要添加新的通知方式(如短信、推送通知等),就需要修改NotificationService的实现,违反了依赖倒置原则。
遵守依赖倒置原则的改进代码:
我们可以通过引入抽象的通知发送器接口来解决问题:
// 抽象的通知发送器接口
interface MessageSender {
void send(String message);
}
// EmailSender实现MessageSender接口
class EmailSender implements MessageSender {
@Override
public void send(String message) {
System.out.println("Sending email: " + message);
}
}
// 短信发送实现
class SmsSender implements MessageSender {
@Override
public void send(String message) {
System.out.println("Sending SMS: " + message);
}
}
// NotificationService依赖抽象接口MessageSender
class NotificationService {
private MessageSender messageSender;
// 通过构造函数依赖注入
public NotificationService(MessageSender messageSender) {
this.messageSender = messageSender;
}
public void notify(String message) {
messageSender.send(message);
}
}
使用依赖注入
为了解耦依赖关系,常常使用依赖注入(Dependency Injection, DI)来实现依赖倒置原则。依赖注入可以通过以下方式实现:
- 构造函数注入:将依赖项通过构造函数传递到类中。
- Setter方法注入:通过Setter方法注入依赖。
- 接口注入:将依赖注入到接口中。
在上述代码中,NotificationService类通过构造函数注入了MessageSender,因此可以动态地选择具体的实现。
场景
- 多个实现类场景:当系统中某个功能有多种不同的实现方式(如不同的消息发送方式),可以通过接口或抽象类来依赖这些实现类,而不是直接依赖具体类。
- 松耦合设计需求:系统设计时,考虑到后期扩展性和维护性,可以通过依赖倒置原则来减少模块之间的耦合。
- 单元测试:通过依赖倒置,可以方便地使用mock对象替代真实对象进行测试,增强代码的可测试性。
6. 合成复用原则 (CRP: Composite Reuse Principle)
定义
尽量使用合成(组合)来实现功能,而不是使用继承。
解释
通过组合现有的对象而不是通过继承创建新对象,可以提高代码的灵活性和可重用性,减少类的数量和复杂度。
合成复用原则的主要观点是:
- 组合优于继承:优先考虑通过将现有类的对象组合到新类中来复用功能,而不是通过继承来扩展现有类。
- 继承可能导致高耦合和脆弱性:继承使得子类高度依赖父类,父类的任何变动可能导致子类不可预期的行为或错误。
- 组合提供更灵活的扩展方式:通过组合不同的对象可以灵活地改变行为,不需要修改已有的类结构。
优点
- 降低耦合度:对象组合使得类之间的依赖关系松散,避免了继承层级之间的紧密耦合。
- 提高灵活性:通过组合不同的对象或实现,可以动态地调整对象的行为,增加了系统的扩展性。
- 增强代码复用性:组合允许通过组合多个小类的功能来实现复杂的行为,促进代码的重用。
- 避免继承层次膨胀:继承容易导致复杂的类层次结构,组合则能够通过将功能模块化,保持类层次简单。
代码
使用继承的例子(违反合成复用原则)
假设我们有一个Vehicle类,Car和Bike类通过继承来复用Vehicle的代码:
class Vehicle {
public void start() {
System.out.println("Vehicle is starting");
}
}
class Car extends Vehicle {
@Override
public void start() {
System.out.println("Car is starting");
}
}
class Bike extends Vehicle {
@Override
public void start() {
System.out.println("Bike is starting");
}
}
在这种设计中,Car和Bike都继承了Vehicle类,并覆盖了其方法。但是,如果我们需要引入新的行为,如不同的发动机类型,这种继承关系就显得不够灵活。
使用组合的例子(遵守合成复用原则)
通过组合,Car和Bike类可以利用其他类的行为,而不是通过继承来复用代码:
// 引擎接口
interface Engine {
void start();
}
// 汽车引擎的实现
class CarEngine implements Engine {
@Override
public void start() {
System.out.println("Car engine is starting");
}
}
// 自行车引擎的实现
class BikeEngine implements Engine {
@Override
public void start() {
System.out.println("Bike engine is starting");
}
}
// 车辆类通过组合的方式持有引擎
class Vehicle {
private Engine engine;
// 通过构造函数注入引擎
public Vehicle(Engine engine) {
this.engine = engine;
}
public void start() {
engine.start();
}
}
现在,Car和Bike可以通过组合不同的引擎实现不同的行为,而不需要通过继承来覆盖方法:
class Car extends Vehicle {
public Car() {
super(new CarEngine());
}
}
class Bike extends Vehicle {
public Bike() {
super(new BikeEngine());
}
}
这种设计遵循了合成复用原则,通过引擎接口(Engine)的组合,使得Vehicle类和引擎的实现类(CarEngine、BikeEngine)之间解耦。如果将来需要引入新的引擎类型,只需实现新的引擎类即可,而不需要修改Vehicle或其他类。
场景
- 系统需要灵活扩展:当系统需要动态组合不同的行为或功能时,使用组合可以更灵活地调整对象行为。
- 避免类的层次膨胀:如果继承链过长,可能导致类的层次结构复杂难以维护。使用组合可以将行为模块化,保持类结构简单。
- 降低子类对父类的依赖:当你发现子类对父类的依赖过多(如需要频繁覆盖父类的方法,甚至违反里氏替换原则)时,使用组合可以避免这种高耦合。
- 行为变化频繁:如果某些功能的变化频率较高,使用组合可以让这些变化局限于某个类,而不影响整个继承体系。
继承 vs. 组合
7. 迪米特法则 (Law of Demeter)
定义
一个对象应该对其他对象有尽可能少的了解。即,一个对象只应与其直接的朋友进行交流,不应与陌生人交流。
解释
这可以减少对象之间的耦合,使得系统更易于维护和扩展。尽量减少对象之间的直接依赖关系。
优点
- 降低耦合性:对象之间的低耦合可以减少系统的复杂性,提升系统的灵活性。
- 提高系统可维护性:当类的内部结构变化时,由于其他类对它了解较少,变化的影响范围会被限制在局部,不会波及整个系统。
- 减少代码的复杂性:由于不能通过一长串对象访问深层对象,代码更简洁、更易理解。
- 提高模块的独立性:模块更封装,各模块之间依赖关系减少,模块的独立性增强,便于后期修改、扩展和重构。
代码
违反迪米特法则的代码:
假设我们有一个Person类,它持有一个Car对象,而Car对象持有一个Engine对象。如果Person类需要调用Engine的start()方法:
class Engine {
public void start() {
System.out.println("Engine is starting");
}
}
class Car {
private Engine engine;
public Car(Engine engine) {
this.engine = engine;
}
public Engine getEngine() {
return engine;
}
}
class Person {
private Car car;
public Person(Car car) {
this.car = car;
}
public void startCar() {
// 违反迪米特法则,直接访问Car内部的Engine
car.getEngine().start();
}
}
在这个例子中,Person类不仅依赖于Car,还通过Car直接访问了Engine,形成了较长的调用链(car.getEngine().start())。这种设计违反了迪米特法则,因为Person类了解了不应该直接了解的Engine对象,导致高耦合。
遵守迪米特法则的代码:
我们可以通过在Car类中提供一个start()方法,屏蔽掉Engine的内部细节,使Person类只与Car直接交互,从而遵守迪米特法则:
class Engine {
public void start() {
System.out.println("Engine is starting");
}
}
class Car {
private Engine engine;
public Car(Engine engine) {
this.engine = engine;
}
// Car类封装了启动引擎的行为
public void start() {
engine.start();
}
}
class Person {
private Car car;
public Person(Car car) {
this.car = car;
}
public void startCar() {
// 遵守迪米特法则,Person只与Car对象直接交互
car.start();
}
}
在这个例子中,Person类不需要了解Car的内部细节,只需调用Car的start()方法即可启动汽车。这就遵守了迪米特法则,使得各个类之间的耦合度降低。
场景
- 复杂系统设计:当系统中的类及对象之间的依赖关系较复杂时,迪米特法则可以减少不必要的对象依赖,简化设计。
- 类的重构:如果系统中的类和对象之间有较多的“火车式”方法调用,使用迪米特法则可以通过增加封装和抽象来减少调用链,从而降低耦合。
- 微服务架构:在微服务架构中,不同服务之间的依赖应当尽量减少,只通过公开的接口交互。迪米特法则的思想在这种场景下尤为适用。
违反迪米特法则的典型表现
- 长调用链:如果类A调用类B的方法,而类B又调用类C的方法,依此类推,这种“火车式”调用链违反了迪米特法则。它增加了代码的复杂性,使得代码难以维护。
- 直接访问内部类的属性或方法:一个类不应该通过另一个类的接口访问后续类的属性或方法,因为这会暴露内部实现细节,增加耦合。
遵守迪米特法则的建议
- 不要暴露内部对象:通过封装细节,类只应提供必要的接口,而不应该暴露内部对象给外部类使用。
- 尽量减少链式调用:避免在一个类中调用另一个类的返回对象的方法。相反,可以通过方法的封装提供必要的行为。
- 接口设计应简单明了:只暴露需要暴露的部分,减少不必要的依赖关系。
迪米特法则与其他设计原则的关系
- 单一职责原则(SRP):迪米特法则通常与单一职责原则一致,它们都强调类之间的职责应当明确,并且通过适当的封装减少类之间的耦合。
- 依赖倒置原则(DIP):迪米特法则要求模块之间只依赖抽象,而不依赖具体实现,这与依赖倒置原则的思想相吻合。
- 接口隔离原则(ISP):通过减少类之间的依赖关系和方法调用,迪米特法则帮助实现更加清晰的接口设计,使类和接口之间的依赖关系更加单一和明确。
总结
这七大原则为软件设计提供了重要的指导,帮助开发者创建清晰、灵活和可维护的系统。遵循这些原则能够提高代码质量,减少技术债务,并增强团队的开发效率。理解和应用这些原则是成为优秀软件工程师的重要一步。