Bootstrap

面向对象设计的原则详解

引言

面向对象设计的原则是软件工程中的一套指导思想,旨在帮助开发者设计出结构良好、易于维护、可扩展和可复用的软件系统。以下是面向对象设计中最著名的五个原则,通常被称为 SOLID 原则:单一职责原则、开放封闭原则、里氏替换原则、接口隔离原则、依赖倒置原则。除了 SOLID 原则之外,还有其他的面向对象设计原则,比如迪米特法则、组合/聚合复用原则、最少知识原则。

1. 单一职责原则(Single Responsibility Principle, SRP) 

单一职责原则是面向对象设计中的一个核心原则,由 Robert C. Martin 提出,是 SOLID 原则的一部分。该原则指出,一个类应该有且仅有一个引起它变化的原因。这意味着一个类应该只负责一个功能领域内的事务,当需求发生变化时,该类的修改应当只影响到该功能领域,而不应波及其他功能领域。

特性说明

  • 职责定义:在面向对象编程中,“职责”通常指一个类的功能或任务。如果一个类承担了多项功能,那么它就有多个职责。
  • 变化原因:单一职责原则关注的是引起类变化的原因。如果一个类有多个职责,那么当其中一个职责发生变化时,就可能需要修改该类,这可能会影响到其他职责的实现。
  • 分离职责:为了遵守单一职责原则,设计者应该将不同的职责分配给不同的类。每个类应该专注于完成一个特定的任务,这样就可以独立地修改和扩展,而不会影响到其他部分。

目的

  • 可维护性:当一个类只负责一个功能时,修改该类的代码变得更加简单,因为不需要担心这种修改会对其他功能造成影响。
  • 可测试性:单一职责的类更容易进行单元测试,因为它们的功能相对独立,测试时不需要考虑与其他类的交互。
  • 可扩展性:当需要增加新功能时,可以简单地添加新的类,而不需要修改现有的类,这使得系统更加灵活和易于扩展。
  • 复用性:遵循单一职责原则的类更有可能被复用,因为它们的功能更加纯粹,可以更容易地适应不同的场景。 

正例:
假设我们有一个Order类,它负责处理订单的创建、修改、删除以及发送邮件通知客户等功能。按照单一职责原则,我们可以将其重构为以下结构:

// 订单管理
class OrderManager {
    private List<Order> orders;

    public void addOrder(Order order) {
        orders.add(order);
    }

    public void removeOrder(Order order) {
        orders.remove(order);
    }

    // 其他订单管理功能...
}

// 邮件通知
class EmailNotifier {
    public void sendOrderConfirmation(Order order) {
        // 发送订单确认邮件的逻辑
    }
}

// 订单实体
class Order {
    // 订单属性和方法
}

在这个例子中,OrderManager负责订单的管理,EmailNotifier负责发送邮件通知,而Order类则封装了订单的具体信息。这样,每个类都有明确的职责,当需要修改或扩展功能时,可以独立地进行,不会影响到其他部分。

反例:

假设我们有一个Order类,它不仅包含了订单的管理,还包含了发送邮件通知的功能: 

class Order {
    private List<Item> items;
    private String customerEmail;

    public void addOrderItem(Item item) {
        items.add(item);
    }

    public void removeOrderItem(Item item) {
        items.remove(item);
    }

    public void sendOrderConfirmation() {
        // 发送订单确认邮件的逻辑
    }

    // 其他订单管理功能...
}

在这个例子中,Order类承担了订单管理和邮件通知的职责,这违反了单一职责原则。如果邮件通知的逻辑需要修改,那么Order类也需要修改,这可能会影响到订单管理的部分。此外,如果需要扩展邮件通知的功能,比如支持短信通知,那么在Order类中添加这样的功能也会变得复杂。

通过上述正反例对比,可以看出遵循单一职责原则能够带来更加清晰、可维护和可扩展的代码结构。

2. 开放封闭原则(Open/Closed Principle, OCP) 

开放封闭原则是面向对象设计中的一个核心原则,由 Bertrand Meyer 在 1988 年首次提出,后被 Robert C. Martin 强调并推广。该原则主张软件实体(如类、模块、函数等)应该是可以扩展的,但无需修改现有代码。换句话说,当需求变化时,应该能够通过添加新代码来满足新需求,而不是修改已有的代码。

特性说明

  • 开放性:实体应该对扩展开放,这意味着当需要添加新功能时,可以通过添加新的代码来实现,而不是修改已存在的代码。
  • 封闭性:实体应该对修改封闭,即在添加新功能时,不应修改已有的代码,以避免引入新的错误或破坏原有的功能。

目的

  • 可维护性:由于减少了对现有代码的修改,降低了引入新错误的风险,提高了代码的稳定性。
  • 可扩展性:通过扩展而非修改的方式,可以轻松地添加新功能,使系统更加灵活。
  • 可测试性:新功能的添加不会影响旧代码的测试结果,使得测试更加可靠。
  • 复用性:遵循 OCP 的代码更易于复用,因为它们更稳定,且不轻易受到外部变化的影响。

假设有一个系统用于处理各种类型的支付,开始时只支持信用卡支付,后来需要支持借记卡支付。

正例

public interface PaymentMethod {
    boolean process(double amount);
}

public class CreditCard implements PaymentMethod {
    // 信用卡的属性和方法
    @Override
    public boolean process(double amount) {
        // 处理信用卡支付的逻辑
        return true;
    }
}

public class DebitCard implements PaymentMethod {
    // 借记卡的属性和方法
    @Override
    public boolean process(double amount) {
        // 处理借记卡支付的逻辑
        return true;
    }
}

public class PaymentProcessor {
    public boolean processPayment(PaymentMethod method, double amount) {
        return method.process(amount);
    }
}

PaymentProcessor类依赖于PaymentMethod接口,当需要添加新的支付方式时,只需实现PaymentMethod接口并添加新的支付类,而无需修改PaymentProcessor类的代码。 

反例 

public class PaymentProcessor {
    public boolean processPayment(double amount, CreditCard card) {
        // 处理信用卡支付的逻辑
        return true;
    }
}

// 后来需要支持借记卡,于是修改了 PaymentProcessor 类
public class PaymentProcessor {
    public boolean processPayment(double amount, Card card) {
        if (card instanceof CreditCard) {
            // 处理信用卡支付的逻辑
        } else if (card instanceof DebitCard) {
            // 处理借记卡支付的逻辑
        }
        return true;
    }
}

在不遵循 OCP 的代码示例中,当需要添加新的支付方式时,必须修改PaymentProcessor类的processPayment方法,添加新的条件判断语句。这样做的缺点是,每添加一种新的支付方式,都需要修改现有代码,这违反了开放封闭原则,增加了代码的复杂性和维护难度。

因此,我们可以使用抽象(如接口或抽象类)来定义行为,通过多态来实现扩展,而不是通过修改现有代码。这样可以设计出更加健壮、可维护和可扩展的软件系统。

3. 里氏替换原则(Liskov Substitution Principle, LSP)

里氏替换原则是由 Barbara Liskov 在 1987 年的 OOPSLA 大会上提出的一个面向对象设计原则。该原则强调了继承关系中子类应当能够替换其基类,并且在替换后,程序的行为不应发生改变。换句话说,所有引用基类的地方必须能够透明地使用其子类的对象。

特性说明 

  • 如果S是T的子类型,则所有T类型的对象可以被替换为S类型的对象,而不会影响程序的正确性。

目的

  • 保证继承的有效性:确保继承关系中子类的行为与基类一致,避免由于继承带来的副作用。
  • 增强代码的可复用性:子类可以安全地替换基类,从而提高代码的复用性和可维护性。
  • 减少耦合度:遵循LSP有助于减少类之间的耦合,提高系统的灵活性和可扩展性。

正例

假设我们有一个Bird类,表示会飞的鸟,和一个Kiwi类,表示不会飞的新西兰几维鸟。

abstract class Bird {
    abstract void fly();
}

class Kiwi extends Bird {
    @Override
    void fly() {
        System.out.println("Kiwi cannot fly.");
    }
}

class Swallow extends Bird {
    @Override
    void fly() {
        System.out.println("Swallow is flying.");
    }
}

在这个例子中,Kiwi和Swallow都继承自Bird类,并覆盖了fly()方法。即使Kiwi不会飞,它仍然提供了fly()方法的实现,只是输出一条消息说明它不能飞。这样,任何期望Bird对象能飞的代码,在使用Kiwi或Swallow对象时,都不会抛出异常或产生错误的结果,符合里氏替换原则。 

反例 

 考虑一个Rectangle类和一个Square类,其中Square继承自Rectangle。

class Rectangle {
    private int width;
    private int height;

    public void setWidth(int width) {
        this.width = width;
    }

    public void setHeight(int height) {
        this.height = height;
    }

    public int getWidth() {
        return width;
    }

    public int getHeight() {
        return height;
    }
}

class Square extends Rectangle {
    public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width);
    }

    public void setHeight(int height) {
        super.setWidth(height);
        super.setHeight(height);
    }
}

在这个例子中,Square类试图保持其宽度和高度相等的特性。然而,这违反了里氏替换原则,因为任何期望Rectangle对象的宽度和高度可以独立设置的代码,在使用Square对象时可能会产生意外的结果。例如,如果有一段代码先设置了Rectangle的宽度,然后又设置了高度,那么在使用Square时,宽度将会被高度覆盖,这与Rectangle的行为不符。 

通过以上正反例,我们可以看到,遵循里氏替换原则可以避免继承关系中的潜在问题,确保代码的健壮性和一致性。 

4. 接口隔离原则(Interface Segregation Principle, ISP)

接口隔离原则是罗伯特·C·马丁(Robert C. Martin)提出的面向对象设计原则之一,它指出“客户端不应该依赖它不需要的接口”。这意味着一个类对另一个类的依赖应该建立在最小的接口上,避免使用庞大的接口,而是应该提供多个具体的、细粒度的接口。

特性说明 

  • 将大型接口拆分成更小的、特定功能的接口,让类实现它们真正需要的接口。
  • 避免使用“肥接口”(Fat Interfaces),即包含大量无关方法的接口。

目的

  • 减少耦合:通过将接口分解成更小、更具体的接口,可以减少类之间的耦合度。
  • 提高灵活性:更小的接口使得类的设计更加灵活,容易扩展和维护。
  • 易于重构:接口隔离原则使得系统更容易进行重构,因为更改接口的影响范围被限制在较小的范围内。

假设我们有一个系统,其中有一个Machine接口,它包含了打印、扫描和发送传真等功能。但是,不是所有的设备都会使用这些功能。例如,打印机可能不需要发送传真,扫描仪可能不需要打印。

正例

interface Printer {
    void print(String document);
}

interface Scanner {
    void scan(String document);
}

interface FaxMachine {
    void sendFax(String document);
}

反例 

interface Machine {
    void print(String document);
    void scan(String document);
    void sendFax(String document);
}
class Device implements Machine {
    @Override
    public void print(String document) {
        // 实现打印功能
    }

    @Override
    public void scan(String document) {
        // 实现扫描功能
    }

    @Override
    public void sendFax(String document) {
        // 如果设备不需要发送传真,这里可能是空实现或抛出异常
    }
}

如果Device类实际上是一个简单的打印机,那么它不需要实现sendFax方法,但因为Machine接口包含了这个方法,所以Device类必须提供一个实现,这可能导致代码冗余或错误。

通过遵循接口隔离原则,我们可以设计出更加模块化、解耦和可维护的系统。 

5. 依赖倒置原则(Dependency Inversion Principle, DIP)

依赖倒置原则是 Robert C. Martin 提出的 SOLID 设计原则之一,它强调的是高层次的模块不应该依赖于低层次的模块,而应该依赖于抽象;同时,抽象不应该依赖于细节,细节应该依赖于抽象。简单来说,就是“针对接口编程,不要针对实现编程”。

特性说明 

1. 高层模块不应该依赖低层模块,两者都应该依赖于抽象。

  • 高层模块是指业务逻辑层或控制层,低层模块通常指的是数据访问层或具体实现层。
  • 抽象是指接口或抽象类,它们定义了高层模块和低层模块交互的契约。

2. 抽象不应该依赖于细节,细节应该依赖于抽象。

  • 抽象是相对稳定的,而细节是易变的。依赖于抽象可以减少模块间的耦合度,提高系统的灵活性和可维护性。

目的

  • 降低耦合度:通过依赖于抽象,可以降低模块间的耦合度,使得模块更加独立。
  • 提高可复用性:依赖于抽象的模块更容易被复用,因为它们不直接依赖于具体的实现。
  • 提高可测试性:依赖于抽象的模块更容易进行单元测试,因为可以使用模拟对象(mocks)或存根(stubs)来代替真实的依赖。


假设我们有一个系统,需要发送通知给用户。我们有多种通知方式,如电子邮件、短信和推送通知。

正例

public interface NotificationGateway {
    void send(String recipient, String message);
}

public class EmailGateway implements NotificationGateway {
    @Override
    public void send(String recipient, String message) {
        // 实现邮件发送逻辑
    }
}

public class SmsGateway implements NotificationGateway {
    @Override
    public void send(String recipient, String message) {
        // 实现短信发送逻辑
    }
}

public class PushNotificationGateway implements NotificationGateway {
    @Override
    public void send(String recipient, String message) {
        // 实现推送通知逻辑
    }
}

public class NotificationService {
    private NotificationGateway gateway;

    public NotificationService(NotificationGateway gateway) {
        this.gateway = gateway;
    }

    public void sendNotification(String recipient, String message) {
        gateway.send(recipient, message);
    }
}

在这个例子中,NotificationService 依赖于 NotificationGateway 接口,而不是具体的实现。这样,当我们需要添加新的通知方式时,只需要创建一个新的实现类,而不需要修改 NotificationService 的代码。

反例 

public class NotificationService {
    private EmailGateway emailGateway;
    private SmsGateway smsGateway;
    private PushNotificationGateway pushGateway;

    public NotificationService() {
        this.emailGateway = new EmailGateway();
        this.smsGateway = new SmsGateway();
        this.pushGateway = new PushNotificationGateway();
    }

    public void sendEmailNotification(String recipient, String message) {
        emailGateway.send(recipient, message);
    }

    public void sendSmsNotification(String recipient, String message) {
        smsGateway.send(recipient, message);
    }

    public void sendPushNotification(String recipient, String message) {
        pushGateway.send(recipient, message);
    }
}

在这个例子中,NotificationService 直接依赖于具体的实现类(EmailGateway, SmsGateway, PushNotificationGateway),这违反了依赖倒置原则。

通过遵循依赖倒置原则,可以设计出更加健壮、可扩展和易于维护的软件架构。依赖于抽象而非具体实现,可以有效地降低模块间的耦合度,提高系统的灵活性和可复用性。 

6. 迪米特法则(Law of Demeter, LoD)

迪米特法则,也被称为最少知识原则(Least Knowledge Principle, LKP),是由 Ian Holland 和 Daniel Steinberg 在 1987 年提出的一个面向对象设计原则。这个原则的核心思想是减少对象之间的耦合度,即一个对象应当对其他对象保持最少的知识,只与它的“朋友”通信。

迪米特法则的规则:

  • 一个对象应该只与它的直接朋友通信。
  • 一个对象的“朋友”包括:
  1. 它自己(self)
  2. 它的成员变量
  3. 参数
  4. 它的返回值
  5. 它的父类
  6. 它的子类
  • 不应该通过朋友去访问朋友的朋友,即避免链式调用。

目的

  • 降低耦合度:减少对象之间的依赖,提高系统的可维护性和可扩展性。
  • 增强封装性:限制对象之间的直接通信,增强对象的封装性。
  • 简化调试过程:由于对象之间的交互减少,调试和理解代码变得更加容易。

假设我们有一个系统,其中包含Student、Course和University类。Student类需要获取他所选课程的名称。 

正例

class Student {
    private Course course;

    public Student(Course course) {
        this.course = course;
    }

    public String getCourseName() {
        return course.getName();
    }
}

class Course {
    private String name;

    public Course(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

Student类只与Course类通信,不再直接访问University类,因此遵循了迪米特法则。

反例 

class Student {
    private University university;

    public Student(University university) {
        this.university = university;
    }

    public String getCourseName() {
        return university.getCourses().get(0).getName(); // 这里学生直接访问大学的课程列表
    }
}

class Course {
    private String name;

    public Course(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

class University {
    private List<Course> courses;

    public University(List<Course> courses) {
        this.courses = courses;
    }

    public List<Course> getCourses() {
        return courses;
    }
}

Student类直接访问了University类的内部状态,这违反了迪米特法则。

通过遵循迪米特法则,可以设计出更加模块化、解耦和易于维护的系统。对象之间的通信应保持在最低限度,每个对象只应该知道它需要知道的信息,这有助于构建更加健壮和可扩展的软件架构。 

7. 组合/聚合复用原则(Composite/Aggregate Reuse Principle, CARP)

组合/聚合复用原则主张在需要复用现有功能时,优先选择使用组合或聚合而非继承。这一原则鼓励通过对象间的关联(组合或聚合)来重用代码,而不是通过继承机制。

组合与聚合的区别:

  • 组合:是一种强关联形式,表示部分和整体的生命周期紧密相连。部分对象无法独立于整体对象存在,当整体对象被销毁时,部分对象也会被销毁。例如,车轮是汽车的一部分,没有汽车就没有车轮的概念。
  • 聚合:是一种弱关联形式,表示部分和整体的生命周期可以独立。部分对象可以属于多个整体对象,也可以独立于整体对象存在。例如,员工可以属于多个项目,即使项目结束,员工依然存在。

目的

  • 增强封装性:组合和聚合允许在不暴露内部实现的情况下重用代码。
  • 减少耦合度:通过组合和聚合,可以减少类之间的依赖,提高系统的灵活性和可维护性。
  • 避免继承的局限性:避免因继承带来的类层次结构复杂、难以理解和维护的问题。

假设我们有一个系统,其中需要表示一个“文档”和“文本”功能。 

正例(使用组合)

interface Text {
    String getContent();
}

class PlainText implements Text {
    private String content;

    public PlainText(String content) {
        this.content = content;
    }

    @Override
    public String getContent() {
        return content;
    }
}

class RichText implements Text {
    private String content;
    private boolean bold;
    private boolean italic;

    public RichText(String content, boolean bold, boolean italic) {
        this.content = content;
        this.bold = bold;
        this.italic = italic;
    }

    @Override
    public String getContent() {
        // 这里可以添加格式化逻辑
        return content;
    }
}

class Document {
    private Text text;

    public Document(Text text) {
        this.text = text;
    }

    public void setText(Text text) {
        this.text = text;
    }

    public String getContent() {
        return text.getContent();
    }
}

在这个例子中,Document类通过组合Text接口,可以使用PlainText或RichText对象,这样Document类就具有了更好的灵活性和扩展性,同时保持了较低的耦合度。

反例 

class Document {
    private String content;

    public Document(String content) {
        this.content = content;
    }

    public void setContent(String content) {
        this.content = content;
    }

    public String getContent() {
        return content;
    }
}

class RichDocument extends Document {
    private boolean bold;
    private boolean italic;

    public RichDocument(String content, boolean bold, boolean italic) {
        super(content);
        this.bold = bold;
        this.italic = italic;
    }

    public void setBold(boolean bold) {
        this.bold = bold;
    }

    public void setItalic(boolean italic) {
        this.italic = italic;
    }
}

在这个例子中,RichDocument继承自Document,但这可能不是最佳实践,因为RichDocument可能需要添加更多与格式相关的功能,而这些功能并不适合Document。

通过遵循组合/聚合复用原则,可以设计出更加健壮、可扩展和易于维护的软件架构。组合和聚合不仅提供了代码重用的手段,还增强了系统的灵活性和可维护性,避免了因过度使用继承而带来的问题。 

8. 最少知识原则(Principle of Least Knowledge, POLK)

最少知识原则,也被称为迪米特法则(Law of Demeter),是一种面向对象设计原则,用于减少软件系统中类与类之间的耦合度。该原则提倡一个对象应该对其他对象尽可能少地了解,只与“直接的朋友”通信,这里的“直接的朋友”包括:

  • 对象自身(self)
  • 对象的成员变量
  • 方法参数
  • 方法的返回值
  • 对象的父类和子类

目的

  • 降低耦合度:减少对象之间不必要的依赖,提高系统的可维护性和可扩展性。
  • 增强封装性:限制对象之间的直接通信,使对象的内部状态更加隐蔽,增强封装性。
  • 简化调试:由于对象之间的交互减少,调试和理解代码变得更加容易。


假设我们有一个系统,其中包含Person、Address和City类。Person需要访问其地址所在的城市名称。 

正例

class Person {
    private Address address;

    public Person(Address address) {
        this.address = address;
    }

    public String getCityName() {
        return address.getCityName(); // 通过Address间接访问City
    }
}

class Address {
    private City city;

    public Address(City city) {
        this.city = city;
    }

    public String getCityName() {
        return city.getName();
    }
}

class City {
    private String name;

    public City(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

在这个修正后的例子中,Person类通过Address类的getCityName方法间接访问城市名称,而不是直接访问City类,这样就遵循了最少知识原则。

反例 

class Person {
    private Address address;

    public Person(Address address) {
        this.address = address;
    }

    public String getCityName() {
        return address.getCity().getName(); // 这里Person直接访问了City的内部
    }
}

class Address {
    private City city;

    public Address(City city) {
        this.city = city;
    }

    public City getCity() {
        return city;
    }
}

class City {
    private String name;

    public City(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

在这个例子中,Person类直接访问了City类的getName方法,这违反了最少知识原则,因为Person不应该知道City的内部结构。

通过遵循最少知识原则,可以设计出更加模块化、解耦和易于维护的系统。对象之间的通信应保持在最低限度,每个对象只应该知道它需要知道的信息,这有助于构建更加健壮和可扩展的软件架构。在实践中,这通常意味着避免长链式的对象引用和方法调用,转而使用更直接的接口或方法来完成任务。  

;