面向对象设计的五大原则

208次阅读
没有评论

共计 9339 个字符,预计需要花费 24 分钟才能阅读完成。

内容目录

 在20世纪90年代末到21世纪初罗伯特·C·马丁将面向对象编程和设计中广为接受和应用的一组准则总结为SOLID,其分别表示:

  • 单一职责原则(Single Responsibility Principle,SRP)
  • 开闭原则(Open-Closed Principle,OCP)
  • 里氏替换原则(Liskov Substitution Principle,LSP)
  • 接口隔离原则(Interface Segregation Principle,ISP)
  • 依赖倒置原则(Dependency Inversion Principle,DIP)

SOLID是面向对象设计的主要基本原则,这些原则旨在提高软件的可维护性、灵活性和可拓展性。除了上述的SOLID原则以外,可能还听说过迪米特原则合成复用原则,这些原则都是对SOLID的扩展解释和进一步的强调。

1. 单一职责原则(S)

This principle states that each class should have one responsibility, one single purpose.

 单一职责原则(SRP)规定一个类应该只有一个功能领域中的相应职责,一个单一的需求目的。这或者可以定义为:就一个类而言,应该只有一个引起它变化的原因,不应该存在多于一个导致类变更的原因。一个类/接口/方法只负责一项职责。

It should have only one reason to change.

 在面向对象设计中,我们不希望一个类知道太多或拥有太多与它不相干的行为,因为这些类是很难进行维护的。例如有一个因为一些原因,被我们修改了很多的类,我们不应该继续将这臃肿的类继续维护下去,而是将其负责的多个功能分解为更多的类,让这些类承担不同的职责。当程序出现问题时,我们可以非常方便地对出现问题的功能所负责的类进行修改。

代码示例

 假设我们有下述的接口,既包括对学生信息的查询又包括对课程信息的查询。

public interface IStudent {
    Student getStudent(String sId);
    List<Student> getAllStudents();
    void addStudent(Student student);
    Course getCourse(String sId);
    void addCourse(Course course,Student sId);
}

 很明显,上述接口负责的职责过多,显得整个接口十分臃肿,为了方便维护,我们可以根据职责划分为多个接口。

public interface IStudent {
    Student getStudent(String sId);
    List<Student> getAllStudents();
    void addStudent(Student student); 
}
public interface ICourse {
    Course getCourse(String sId);
    void addCourse(Course course,Student sId); 
}

 单一职责原则是高内聚低耦合的指导方针,可以降低类的复杂度,提高类的可读性,提高系统的可维护性、降低变更引起的风险。通俗理解,就是不能让一个类负责太多不同领域的职责,不能让它太累。在软件设计过程中,要尝试将职责进行分离,不同职责封装在不同的类中,这样就可以降低我们设计软件的复杂度了。

单一职责原则不仅仅适用于面向对象编程语言设计,只要是模块化的编程,都适用。

2. 开闭原则(O)

This principle states that software entities should be open for extension, but closed for modification.

 开闭原则(OCP)要求我们软件实体应该对扩展开放,对修改关闭。结合实际来看,当软件需求发生改变时,我们只能在原有的软件基础上进行扩展而不能对软件内部进行修改。

 在Java中,使用接口就是一种遵循OCP原则的一种方式。

代码示例

public interface IStudent{
    void addStudent(Student student);
    List<Student> getAllStudent();
    void deleteStudent(Student student);
    void updateStudent(Student student);
} 
public class StudentService implements IStudent {
    // 具体的业务代码
}

 现在我们需要添加处理访学、参军、延毕等学生的业务,这种情况下,我们只需要写一个StudentSpecialService类去继承Student类进行扩展即可。

public class StudentSpecialService extends StudentService {
    // 扩展业务
}

 当我们需要使用StudentSpecialService类中定义的功能时,可以通过下述代码进行调用:

IStudent studentService = new StudentService();
StudentSpecialService studentSpecialService = (StudentSpecialService) studentService; 

 用抽象构建框架,用实现扩展细节。优点:提高软件系统的可复用性和可维护性。实现开闭原则的核心思想,就是面向抽象继承。

3. 里氏替换原则(L)

Subtypes must be substitutable for their base types.

 里氏代换原则(Liskov Substitution Principle, LSP):所有引用基类(父类)的地方必须能透明地使用其子类的对象。简单点就是子类可以替换其父类。

 LSP的定义是:如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所有的对象o1代换o2时,程序P的行为没有变化,那么类型S是类型T的子类型。

If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.

 里氏替换原则告诉我们:在软件中将一个基类对象替换成它的子类对象,程序将不会产生任何错误和异常,反过来则不成立,如果一个软件实体使用的是一个子类对象的话,那么它不一定能够使用基类对象。例如:我喜欢吃蔬菜,那么我一定喜欢吃青菜;但是我喜欢吃青菜,不能断定我喜欢吃蔬菜。

 引申拓展:子类可以扩展父类的功能,但是不能修改父类的功能

示例代码

 假设有一个基类Shape,它有一个方法calculateArea()。然后,我们有两个子类RectangleSquare。根据LSP,Square类应该能够替换Rectangle类而不影响程序的行为。

 错误示例:

// 基类
class Shape {
    public double calculateArea() {
        return 0;
    }
}

// 矩形类
class Rectangle extends Shape {
    private double width;
    private double height;

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

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

    public double getWidth() {
        return width;
    }

    public double getHeight() {
        return height;
    }

    @Override
    public double calculateArea() {
        return width * height;
    }
}

// 正方形类
class Square extends Rectangle {
    public void setWidth(double w) {
        super.setWidth(w);
        super.setHeight(w);
    }

    public void setHeight(double h) {
        super.setWidth(h);
        super.setHeight(h);
    }
}

public class Main {
    public static void main(String[] args) {
        Rectangle rect = new Square();
        rect.setWidth(5);
        rect.setHeight(10);
        System.out.println("Expected area of square (50.0), got: " + rect.calculateArea());
    }
}

 在上述示例中,SquareRectangle的子类。按照里氏替换原则,我们可以用 Square 的实例替换 Rectangle 的实例。但是,当我们尝试设置 Square 的宽和高不一致时,由于 Square 类中的 setWidthsetHeight 方法将宽和高设置为相同的值,这会违反矩形的定义,因此这个设计违反了LSP。

 正确示例:

// 形状基类
abstract class Shape {
    public abstract double calculateArea();
}

// 矩形类
class Rectangle extends Shape {
    private double width;
    private double height;

    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

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

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

    public double getWidth() {
        return width;
    }

    public double getHeight() {
        return height;
    }

    @Override
    public double calculateArea() {
        return width * height;
    }
}

// 正方形类
class Square extends Shape {
    private double side;

    public Square(double side) {
        this.side = side;
    }

    public void setSide(double side) {
        this.side = side;
    }

    public double getSide() {
        return side;
    }

    @Override
    public double calculateArea() {
        return side * side;
    }
}

public class Main {
    public static void main(String[] args) {
        Shape shape1 = new Rectangle(5, 10);
        System.out.println("Rectangle Area: " + shape1.calculateArea());

        Shape shape2 = new Square(5);
        System.out.println("Square Area: " + shape2.calculateArea());
    }
}

 在这个修正后的设计中,RectangleSquare 都继承自 Shape 类。它们各自实现了 calculateArea 方法。由于 RectangleSquare 没有直接的继承关系,因此它们的行为不会互相影响,从而符合LSP原则。

 里氏替换原则约束了继承泛滥,是开闭原则的一种体现;它加强了程序的健壮性,同时在变更时也可以做到非常好的兼容性,提高程序的维护性、扩展性,降低需求变更时引入的风险。

4. 接口隔离原则(I)

This principle was first defined by Robert C. Martin as: “Clients should not be forced to depend upon interfaces that they do not use“.

 接口隔离原则(ISP)要求我们使用多个专门的接口,而不使用单一的总接口,即客户端不应该依赖那些它不需要的接口

  • 一个类对一个类的依赖应该建立在最小的接口上;
  • 要建立单一接口不要建立庞大臃肿的接口;
  • 尽量细化接口,接口中的方法应该尽量少;

在使用接口隔离原则时,我们需要注意控制即可的粒度,接口不能太小,如果太小会导致系统中接口泛滥,不利于维护;接口也不能太大,太大的接口将违背接口隔离原则,灵活性较差,使用起来很不方便。一般而言,接口中仅包含为某一类用户定制的方法即可,不应该强迫客户端依赖于那些它们不用的方法。这同样符合我们高内聚,低耦合的思想。

示例代码

 接口:

public interface Payment { 
    void initiatePayments();
    Object status();
    List<Object> getPayments();
}

 接口实现类:

public class BankPayment implements Payment {

    @Override
    public void initiatePayments() {
       // ...
    }

    @Override
    public Object status() {
        // ...
    }

    @Override
    public List<Object> getPayments() {
        // ...
    }
}

 为了方便起见,我先忽略具体的实现代码。从上述代码可以知道,BankPayment需要Payment接口中的所有方法,因此它并不违法ISP原则。

 随着业务的发展,我们需要实现一个LoanPayment类,它也是一种付款方式,但会有更多的操作。为了开发这一功能,就需要对上面的Payment接口添加一些新方法。

public interface Payment {

    void initiatePayments();
    Object status();
    List<Object> getPayments();
    // 接口被污染的两个方法
    void intiateLoanSettlement();
    void initiateRePayment();
}

LoanPayment类如下:

public class LoanPayment implements Payment {

    @Override
    public void initiatePayments() {
        throw new UnsupportedOperationException("This is not a bank payment");
    }

    @Override
    public Object status() {
        // ...
    }

    @Override
    public List<Object> getPayments() {
        // ...
    }

    @Override
    public void intiateLoanSettlement() {
        // ...
    }

    @Override
    public void initiateRePayment() {
        // ...
    }
}

 但是为了实现这个LoanPayment类,原来的BankPayment类就需要这样实现:

public class BankPayment implements Payment {

    @Override
    public void initiatePayments() {
        // ...
    }

    @Override
    public Object status() {
        // ...
    }

    @Override
    public List<Object> getPayments() {
        // ...
    }

    @Override
    public void intiateLoanSettlement() {
        throw new UnsupportedOperationException("This is not a loan payment");
    }

    @Override
    public void initiateRePayment() {
        throw new UnsupportedOperationException("This is not a loan payment");
    }
}

 为接口添加一些子类不必要的方法,这就导致了接口污染,同时违背了ISP原则。为了解决这一问题,我们需要将不同方法分离出来,单独作为接口进行实现。

 首先可以先确定父类接口,这个接口包含每种支付方式均具备的方法。

public interface Payment {
    Object status();
    List<Object> getPayments();
}

 然后定义不同功能的接口,继承该Payment接口。

public interface Bank extends Payment {
    void initiatePayments();
}
public interface Loan extends Payment {
    void intiateLoanSettlement();
    void initiateRePayment();
}

 然后依次实现BankPaymentLoanPayment即可.

public class BankPayment implements Bank {

    @Override
    public void initiatePayments() {
        // ...
    }

    @Override
    public Object status() {
        // ...
    }

    @Override
    public List<Object> getPayments() {
        // ...
    }
}
public class LoanPayment implements Loan {

    @Override
    public void intiateLoanSettlement() {
        // ...
    }

    @Override
    public void initiateRePayment() {
        // ...
    }

    @Override
    public Object status() {
        // ...
    }

    @Override
    public List<Object> getPayments() {
        // ...
    }
}

 这样就解决了接口污染,并且不会违背ISP原则。

5. 依赖倒置原则(D)

The general idea of this principle is as simple as it is important: High-level modules, which provide complex logic, should be easily reusable and unaffected by changes in low-level modules, which provide utility features. To achieve that, you need to introduce an abstraction that decouples the high-level and low-level modules from each other.

 依赖倒置原则(DIP)的思想非常简单且十分重要:提供复杂逻辑的高层模块应该易于复用,并且不受提供具体实现功能的低层模块的更改影响。为了实现这一点,就需要引入抽象,将高级模块和低级模块进行解耦。基于这点,就引出了两条重要论述:

  • 高层模块不应该依赖于低层模块,两者都应该依赖于抽象;
  • 抽象不应该依赖于细节,细节应该取决于抽象。

 它通过向高层模块和低层模块之间引入了一个抽象概念,拆分了它们之间的依赖关系。因此,我们就能得到两个依赖关系:

  • 高层模块依赖于抽象
  • 低层模块依赖于同一抽象

示例代码

 假设我们需要实现一个消息发送服务。在不遵循DIP的情况下,高层模块(如消息发送服务)可能直接依赖于低层模块(如具体的邮件发送器或短信发送器)。为了遵循DIP,我们可以引入一个抽象的消息发送接口,然后让高层模块依赖于这个接口,而具体的发送器实现这个接口。

// 消息发送接口(抽象)
interface MessageSender {
    void sendMessage(String message);
}

// 邮件发送实现(具体实现)
class EmailSender implements MessageSender {
    public void sendMessage(String message) {
        System.out.println("Sending email: " + message);
    }
}

// 短信发送实现(具体实现)
class SMSSender implements MessageSender {
    public void sendMessage(String message) {
        System.out.println("Sending SMS: " + message);
    }
}

// 消息服务类(高层模块)
class MessageService {
    private MessageSender messageSender;

    public MessageService(MessageSender messageSender) {
        this.messageSender = messageSender;
    }

    public void send(String message) {
        messageSender.sendMessage(message);
    }
}

public class Main {
    public static void main(String[] args) {
        MessageSender emailSender = new EmailSender();
        MessageService emailService = new MessageService(emailSender);
        emailService.send("Hello via Email");

        MessageSender smsSender = new SMSSender();
        MessageService smsService = new MessageService(smsSender);
        smsService.send("Hello via SMS");
    }
}

在这个例子中,MessageService(高层模块)不直接依赖于邮件发送(EmailSender)或短信发送(SMSSender)的具体实现(低层模块),而是依赖于一个抽象接口 MessageSender。这样,如果未来需要添加新的消息发送方式,我们只需添加一个新的 MessageSender 实现类,而无需修改 MessageService 类。这样的设计更加灵活和可维护,符合依赖倒置原则。

其他原则

 OO设计的五大基本原则SOLID已经介绍完毕了,在此基础上,可能还听说过勒米特法则(LoD)合成复用原则(CARP).这两个原则都是对SOLID的扩展论述和强调。

 LoD:个对象应该对其他对象保持最少的了解,又叫最少知道原则。它要求我们在设计系统时,应该尽量减少对象之间的交互,如果两个对象之间不必彼此直接通信,那么这两个对象就不应当发生任何直接的相互作用,如果其中的一个对象需要调用另一个对象的某一个方法的话,可以通过第三者转发这个调用。简言之,就是通过引入一个合理的第三者来降低现有对象之间的耦合度。

 CARP:对象组合/聚合,而不是继承来达到复用的目的

正文完
 
PG Thinker
版权声明:本站原创文章,由 PG Thinker 2023-11-30发表,共计9339字。
转载说明:除特殊说明外本站文章皆由CC-4.0协议发布,转载请注明出处。
评论(没有评论)
热评文章
Rust中所有权与借用规则概述

Rust中所有权与借用规则概述

在GC与手动管理内存之间,Rust选择了第三种:所有权机制...