软件设计原则

设计原则

一、单一原则

1. 如何理解单一职责原则

单一职责原则(Single Responsibility Principle,简称SRP),它要求一个类或模块应该只负责一个特定的功能。实现代码的高内聚和低耦合,提高代码的可读性和可维护性。 我们可以把模块看作比类更加抽象的概念,类也可以看作模块。或者把模块看作比类更加粗粒度的代码块,模块中包含多个类,多个类组成一个模块。

单一职责原则的定义就是一个类只负责完成一个职责或者功能。不要设计大而全的类,要设计粒度小、功能单一的类。

如果一个类包含了两个或者两个以上业务不相干的功能,那它职责不够单 一,应该将它拆分成多个功能更加单一、粒度更细的类。

简单的例子 :

java 复制代码
class Employee {
    private String name;
    private String position;
    private double baseSalary;
    public Employee(String name, String position, double baseSalary) {
        this.name = name;
        this.position = position;
        this.baseSalary = baseSalary;
}
    //... Getter 和 Setter 方法
    
    public double calculateSalary() {
        // 计算员工工资的逻辑
        return baseSalary * 1.2;
    }
    public void saveEmployee() {
        // 保存员工信息到数据库的逻辑
    }
}

上面的代码中, Employee 类负责了员工信息的管理、工资计算以及员工信息的持 久化。这违反了单一职责原则。为了遵循该原则,我们可以将这些功能拆分到不同的类中:

java 复制代码
class Employee {
    private String name;
    private String position;
    private double baseSalary;
    public Employee(String name, String position, double baseSalary) {
    	this.name = name
        this.position = position;
		this.baseSalary = baseSalary;
	}
	//... Getter 和 Setter 方法
    
	public double calculateSalary() {
        // 计算员工工资的逻辑
        return baseSalary * 1.2;
    }
}

class EmployeeRepository {
    public void saveEmployee(Employee employee) {
    	// 保存员工信息到数据库的逻辑
    }
}

在遵循单一职责原则的代码中,我们将员工信息的持久化操作从 Employee 类中抽 离出来,放到了一个新的 EmployeeRepository 类中。 Employee 类只负责员工信息的管理和工资计算,而 EmployeeRepository 类负责员工信息的持久 化操作。这样每个类都只关注一个特定的职责,更易于理解、维护和扩展。

遵循单一职责原则有助于提高代码的可读性、可维护性和可扩展性。但这个原则并不是绝对的,需要根据具体情况来判断是否需要拆分类和模块。过度拆分可能导致过多的类和模块,反而增加系统的复杂度。

2. 如何判断类的职责是否足够单一

对于一个类是否职责单一的判定,是很难拿捏的。我举一个更加贴近实际 的例子来给你解释一下。

在一个社交产品中,我们用下面的 UserInfo 类来记录用户的信息。 UserInfo 类的设计是否满足单一职责原则呢?

java 复制代码
public class UserInfo {
    private long userId;
    private String username;
    private String email;
    private String telephone;
    private String avatarUrl;
    private String province; // 省
    private String cityOf; // 市
    private String region; // 区
    private String detailedAddress; // 详细地址
    // ... 省略其他属性和方法...
}

对于这个问题,有两种不同的观点。

观点1,UserInfo 类包含的都是跟用户相关的信息,所有的属性和方法都隶属于用户这样一个业务模型,满足单一职责原则; 另一观点2,地址信息在 UserInfo 类中,所占的比重比较高,可以继续拆分成独立的 Address 类,UserInfo 只保留除 Address 之外的其他信息,拆分之后的两个类的职责更加单一。

哪种观点更对呢?

我们不能脱离具体的应用场景。

如果在这个社交产品中,用户的地址信息跟其他信息一样,只是单纯地用来展示,那 UserInfo 现在的设计就是合理的。

如果这个社交产品发展得比较好,之后又在产品中添加了电商的模块,用户的地址信息还会用在电商物流中,那我们最好将地址信息从 UserInfo 中拆分出来,独立成用户物流信息(或者叫地址信息、收货信息 等)。

如果做这个社交产品的公司发展得越来越好,公司内部又开发出了其他产品(可以理解为其他 App)。公司希望支持统一账号系统,也就是用户一个账号可以在公司内部的所有产品中登录。这个时候我们就需要继续对 UserInfo 进行拆分,将跟身份认证相关的信息(比如email、telephone 等)抽取成独立的类。

综上所述,一个类的职责是否足够单一,我们并没有一个非常明确的、可以量化的标准。 所以我们可以先写一个粗粒度的类,来满足业务需求。随着业务的发展,如果粗粒度的类越来越庞大,代码越来越多且难以维护的时候,我们就可以将这个粗粒度的类拆分成几个更细粒度的类,也就是持续重构 。

下面这几条经验,方便我们去思考类的设计是否职责单一:

  • 类中的代码行数、函数或属性过多,会影响代码的可读性和可维护性,我们就需 要考虑对类进行拆分;
  • 类依赖的其他类过多,或者依赖类的其他类过多,不符合高内聚、低耦合的设计思想,我们就需要考虑对类进行拆分;
  • 私有方法过多,我们就要考虑能否将私有方法独立到新的类中,设置为 public 方法,供更多的类使用,从而提高代码的复用性;
  • 比较难给类起一个合适名字,很难用一个业务名词概括,或者只能用一些笼统的 Manager、Context 之类的词语来命名,这就说明类的职责定义得可能不够清晰;
  • 类中大量的方法都是集中操作类中的某几个属性,比如,在 UserInfo 例子中, 如果一半的方法都是在操作 address 信息,那就可以考虑将这几个属性和对应的方法拆分出来。

3. 类的职责是否设计得越单一越好

为了满足单一职责原则,是不是把类拆得越细就越好呢?答案是否定的。我们还是通 过一个例子来解释一下。Serialization 类实现了一个简单协议的序列化和反序列功 能,具体代码如下:

java 复制代码
/**
* Protocol format: identifier-string;{gson string}
* For example: UEUEUE;{"a":"A","b":"B"}
*/
public class Serialization {
    private static final String IDENTIFIER_STRING = "UEUEUE;";
    private Gson gson;
    public Serialization() {
   		this.gson = new Gson();
    }
    
    public String serialize(Map<String, String> object) {
        StringBuilder textBuilder = new StringBuilder();
        textBuilder.append(IDENTIFIER_STRING);
        textBuilder.append(gson.toJson(object));
        return textBuilder.toString();
    }
    
	public Map<String, String> deserialize(String text) {
        if (!text.startsWith(IDENTIFIER_STRING)) {
            return Collections.emptyMap();
        }
        String gsonStr = text.substring(IDENTIFIER_STRING.length());
        return gson.fromJson(gsonStr, Map.class);
    }
}

如果我们想让类的职责更加单一,我们对 Serialization 类进一步拆分,拆分成一个 只负责序列化工作的 Serializer 类和另一个只负责反序列化工作的 Deserializer 类。 拆分后的具体代码如下所示:

java 复制代码
public class Serializer {
    private static final String IDENTIFIER_STRING = "UEUEUE;";
    private Gson gson;
    public Serializer() {
    	this.gson = new Gson();
    }
    public String serialize(Map<String, String> object) {
        StringBuilder textBuilder = new StringBuilder();
        textBuilder.append(IDENTIFIER_STRING);
        textBuilder.append(gson.toJson(object));
        return textBuilder.toString();
    }
}

public class Deserializer {
    private static final String IDENTIFIER_STRING = "UEUEUE;";
    private Gson gson;
    public Deserializer() {
    	this.gson = new Gson();
    }
    public Map<String, String> deserialize(String text) {
        if (!text.startsWith(IDENTIFIER_STRING)) {
        return Collections.emptyMap();
    }
    String gsonStr = text.substring(IDENTIFIER_STRING.length());
    return gson.fromJson(gsonStr, Map.class);
    }
}

虽然经过拆分之后,Serializer 类和 Deserializer 类的职责更加单一了,也带来了新的问题。如果我们修改了协议的格式,数据标识从"UEUEUE"改为"DFDFDF", 或序列化方式从 JSON 改为了XML,那 Serializer 类和 Deserializer 类都需要做相 应的修改,代码的内聚性显然没有原来 Serialization 高了。如果我们仅仅对 Serializer 类做了协议修改,忘记修改 Deserializer 类的代码,那就会导致序列化、反序列化不匹配,程序运行出错,拆分之后代码的可维护性变差了。

不管是应用设计原则还是设计模式,最终的目的还是提高代码的可读性、可扩展性、复用性、可维护性等。我们在考虑应用某一个设计原则是否合理的时候,也可以以此作为最终的考量标准。

二、开闭原则

1. 原理概述

开闭原则( Open Closed Principle ,简称OCP), 它的英文描述是: software entities (modules, classes, functions, etc.) should be open for extension , but closed for modification。我们把它翻译成中文就是:软件实体(模 块、类、方法等)应该"对扩展开放、对修改关闭"

以下是一个常见的生产环境中的例子,我们将展示一个简化的电商平台的订单折扣策 略。

java 复制代码
class Order {
    private double totalAmount;
    public Order(double totalAmount) {
    	this.totalAmount = totalAmount;
    }
    // 计算折扣后的金额
    public double getDiscountedAmount(String discountType) {
    	double discountedAmount = totalAmount;
        if (discountType.equals("FESTIVAL")) {
            // 节日折扣,9折
            discountedAmount = totalAmount * 0.9; 
        } else if (discountType.equals("SEASONAL")) {
            // 季节折扣,8折
            discountedAmount = totalAmount * 0.8; 
        }
    	return discountedAmount;
    }
}

上述代码中, Order 类包含一个计算折扣金额的方法,它根据不同的折扣类型计算折扣。当我们需要添加新的折扣类型时,就不得不修改getDiscountedAmount 方法的代码,这显然是不合理的,违反了开闭原则。

修改为遵循开闭原则的代码:

java 复制代码
// 抽象折扣策略接口
interface DiscountStrategy {
	double getDiscountedAmount(double totalAmount);
}
// 节日折扣策略
class FestivalDiscountStrategy implements DiscountStrategy {
    @Override
    public double getDiscountedAmount(double totalAmount) {
    	return totalAmount * 0.9; // 9折
	}
}
// 季节折扣策略
class SeasonalDiscountStrategy implements DiscountStrategy {
    @Override
    public double getDiscountedAmount(double totalAmount) {
    	return totalAmount * 0.8; // 8折
    }
}
class Order {
    private double totalAmount;
    private DiscountStrategy discountStrategy;
    public Order(double totalAmount, DiscountStrategy discountStrategy) {
        this.totalAmount = totalAmount;
        this.discountStrategy = discountStrategy;
	}
    public void setDiscountStrategy(DiscountStrategy discountStrategy) {
    	this.discountStrategy = discountStrategy;
    }
    // 计算折扣后的金额
    public double getDiscountedAmount() {
    	return discountStrategy.getDiscountedAmount(totalAmount);
    }
}

在遵循开闭原则的代码中,我们定义了一个抽象的折扣策略接口DiscountStrategy ,然后为每种折扣类型创建了一个实现该接口的策略类。 Order类使用组合的方式,包含一个 DiscountStrategy 类型的成员变量,以便 在运行时设置或更改折扣策略(可以通过编码配置、依赖注入等形式)。当我们需要添加新的折扣类型时只需实现 DiscountStrategy 接口,无需修改现有的 Order 代码。这个例子遵循了开闭原则。

2. 修改代码就意味着违背开闭原则吗

开闭原则的核心思想是尽量减少对现有代码的修改,以降低修改带来的风险和影响。在实际开发过程中完全不修改代码是不现实的。当需求变更或者发现代码中的错误时,修改代码是正常的。开闭原则鼓励我们通过设计更好的代码结构,使得在添加新功能或者扩展系统时,尽量减少对现有代码的修改。

以下是一个简化的日志记录器的示例,展示了在适当情况下修改代码,也不违背开闭 原则。在这个例子中,我们的应用程序支持将日志输出到控制台和文件。假设我们需要添加一个新功能,以便在输出日志时同时添加一个时间戳。

java 复制代码
interface Logger {
	void log(String message);
}

class ConsoleLogger implements Logger {
    @Override
    public void log(String message) {
    	System.out.println("Console: " + message);
    }
}

class FileLogger implements Logger {
    @Override
    public void log(String message) {
    	System.out.println("File: " + message);
    	// 将日志写入文件的实现省略
    }
}

为了添加时间戳功能,我们需要修改现有的 ConsoleLogger 和 FileLogger 类。 虽然我们需要修改代码,但由于这是对现有功能的改进而不是添加新的功能,所以这种修改是可以接受的,不违背开闭原则。

java 复制代码
interface Logger {
	void log(String message);
}

class ConsoleLogger implements Logger {
    @Override
    public void log(String message) {
        String timestamp = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
        System.out.println("Console [" + timestamp + "]: " + message);
    }
}

class FileLogger implements Logger {
    @Override
    public void log(String message) {
        String timestamp = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
        String logMessage = "File [" + timestamp + "]: " + message;
        System.out.println(logMessage);
        // 将日志写入文件的实现省略
    }
}

3. 如何做到"对扩展开放、修改关闭"

为了尽量写出扩 展性好的代码,我们要时刻具备扩展意识、抽象意识、封装意识。这些"潜意识"可能 比任何开发技巧都重要。

我们给自己的定位是"工程师",而非码农,那我们在写任何一段代码时都应该思 考一些问题:

  • 事先留好扩展点,以便在未来需求变更的时候,不需要改动代码整体结构的情况下,做到最小代码改动能够很灵活地插入到扩展点上,做到"对扩展开放、对修改 关闭"。
  • 我们还要识别出代码可变部分和不可变部分,要将可变部分封装起来,隔离变化,提供抽象化的不可变接口,给上层系统使用。当具体的实现发生变化的时 候,我们只需要基于相同的抽象接口,扩展一个新的实现,替换掉老的实现即可,上游系统的代码几乎不需要修改。

三、里氏替换原则

1. 原理概述

里式替换原则的英文翻译是:Liskov Substitution Principle,缩写为 LSP 。

Functions that use pointers of references to base classes must be able to use objects of derived classes without knowing it。(使用基类引用指针的函数必须能够在不知情的情况下使用派生类的对象。)

子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为 (behavior)不变及正确性不被破坏。 有点儿多态的味道。

以下是一个简单的示例:

java 复制代码
// 基类:鸟类
public class Bird {
    public void fly() {
        System.out.println("I can fly");
    }
}

// 子类:企鹅类
public class Penguin extends Bird {
    // 企鹅不能飞,所以覆盖了基类的fly方法,但这违反了里氏替换原则
    public void fly() {
    	throw new UnsupportedOperationException("Penguins can't fly");
    }
}

为了遵循里氏替换原则,我们可以重新设计类结构,将能飞的行为抽象到一个接口中,让需要飞行能力的鸟类实现这个接口:

java 复制代码
// 飞行行为接口
public interface Flyable {
	void fly();
}
// 基类:鸟类
public class Bird {
}
// 子类:能飞的鸟类
public class FlyingBird extends Bird implements Flyable {
    @Override
    public void fly() {
        System.out.println("I can fly");
    }
}
// 子类:企鹅类,不实现Flyable接口
public class Penguin extends Bird {
}

通过这样的设计,我们遵循了里氏替换原则,同时也保证了代码的可维护性和复用性。

再来看一个基于数据库操作的案例。假设我们正在开发一个支持多种数据库的程序,包括MySQL、PostgreSQL和SQLite。可以使用里氏替换原则来设计合适的类结构,确保代码的可维护性和扩展性。

首先,定义一个抽象的 Database 基类,它包含一些通用的数据库操作方法, 如 connect() 、 disconnect() 和 executeQuery() 。这些方法的具体实现将在子类中完成。

java 复制代码
public abstract class Database {
    public abstract void connect();
    public abstract void disconnect();
    public abstract void executeQuery(String query);
}

然后,为每种数据库类型创建一个子类,继承自 Database 基类。这些子类需要实现基类中定义的抽象方法,并可以添加特定于各自数据库的方法。

java 复制代码
public class MySQLDatabase extends Database {
    @Override
    public void connect() {
    	// 实现MySQL的连接逻辑
    }
    @Override
    public void disconnect() {
    	// 实现MySQL的断开连接逻辑
    }
    @Override
    public void executeQuery(String query) {
    	// 实现MySQL的查询逻辑
    }
    // 其他针对MySQL的特定方法
}
public class PostgreSQLDatabase extends Database {
	// 类似地,为PostgreSQL实现相应的方法
}
public class SQLiteDatabase extends Database {
	// 类似地,为SQLite实现相应的方法
}

这样设计的好处是,我们可以在不同的数据库类型之间灵活切换,而不需要修改大量代码。只要这些子类遵循里氏替换原则,我们就可以放心地使用基类的引用来操作不 同类型的数据库。例如:

java 复制代码
public class DatabaseClient {
    private Database database;
        public DatabaseClient(Database database) {
        this.database = database;
    }
    public void performDatabaseOperations() {
        database.connect();
        database.executeQuery("SELECT * FROM users");
        database.disconnect();
    }
}
public class Main {
    public static void main(String[] args) {
        // 使用MySQL数据库
        DatabaseClient client1 = new DatabaseClient(new MySQLDatabase());
        client1.performDatabaseOperations();
        // 切换到PostgreSQL数据库
        DatabaseClient client2 = new DatabaseClient(new PostgreSQLDatabase());
        client2.performDatabaseOperations();
        // 切换到SQLite数据库
        DatabaseClient client3 = new DatabaseClient(new SQLiteDatabase());
        client3.performDatabaseOperations();
    }
}

通过遵循里氏替换原则,我们确保了代码的可维护性和扩展性。如果需要支持新的数据库类型,只需创建一个新的子类,实现 Database 基类中定义的抽象方法即可。

虽然从定义描述和代码实现上来看,多态和里式替换有点类似,但它们关注的角度是不一样的。

多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里式替换是一种设计原则,是用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。

2. 哪些代码违背了里氏替换原则

违背里氏替换原则(LSP)的代码通常具有以下特征:

2.1 子类覆盖或修改了基类的方法

当子类覆盖或修改基类的方法时,可能导致子类无法替换基类的实例而不引起问题。这违反了LSP,会导致代码变得脆弱和不易维护。

比如在下面代码中,Penguin 类覆盖了Bird 类的fly() 方法,抛出了一个异常。这违反了LSP,因为现在Penguin实例无法替换Bird 实例。

java 复制代码
public class Bird {
    public void fly() {
    	System.out.println("I can fly");
    }
}
public class Penguin extends Bird {
    @Override
    public void fly() {
    throw new UnsupportedOperationException("Penguins can't fly");
    }
}

2.2 子类违反了基类的约束条件

当子类违反了基类中定义的约束条件(如输入、输出或异常等),也会违反LSP。

在下面这个例子中,NonNegativeStack 类违反了Stack基类的约束条件,因为它在 push() 方法中添加了一个新的约束,即只允许非负数入栈。这使得 NonNegativeStack 实例无法替换 Stack 实例,违反了LSP。

java 复制代码
public class Stack {
   private int top;
   private int[] elements;
   public Stack(int size) {
       elements = new int[size];
       top = -1;
   }
   public void push(int value) {
       if (top >= elements.length - 1) {
       	throw new IllegalStateException("Stack is full");
   	}
   	elements[++top] = value;
   }
   public int pop() {
       if (top < 0) {
       	throw new IllegalStateException("Stack is empty");
       }
   	return elements[top--];
   }
}
// 正数的栈
public class NonNegativeStack extends Stack {
   public NonNegativeStack(int size) {
   	super(size);
   }
   @Override
   public void push(int value) {
       // 只允许非负数入栈
       if (value < 0) {
       	throw new IllegalArgumentException("Only non-negative values are allowed");
   	}
   	super.push(value);
   }
}
// 正确的写法
public class NonNegativeStack extends Stack {
   public NonNegativeStack(int size) {
   	super(size);
   }
   // 重新定义新的方法实现新的约束
   public void pushNonNegative(int value) {
       if (value < 0) {
       	throw new IllegalArgumentException("Only non-negative values are allowed");
       }
   	super.push(value);
   }
}

2.3 子类与基类之间非"is-a"关系

当子类与基类之间缺乏真正的"is-a"关系时,也可能导致违反LSP。例如,如果一个类继承自另一个类,仅仅因为它们具有部分相似性而不是完全的"is-a"关系,那么这种继承关系可能不满足LSP。

为了避免违反LSP,我们需要在设计和实现过程中注意以下几点:

  • 确保子类和基类之间存在真正的"is-a"关系。
  • 遵循其他设计原则,如单一职责原则、开闭原则和依赖倒置原则。

四、接口隔离原则

1. 原理概述

接口隔离原则的英文翻译是" Interface Segregation Principle",缩写为 ISP。 "Clients should not be forced to depend upon interfaces that they do not use。"直译成中文的话就是:客户端不应该强迫依赖它不需要的接口。其中的"客户端"可以理解为接口的调用者或者使用者。

它要求我们将大且臃肿的接口拆分成更小、更专注的接口,以确保类之间的解耦。这样客户端只需要依赖它实际使用的接口,而不需要依赖那些无关的接口。

在软件开发中,既可以把接口看作一组抽象的约定,也可以具体指系统与系统之间的 API 接口,还可以特指面向对象编程语言中的接口等。 可以把"接口"理解为下面三种东西:

  • 一组 API 接口集合
  • 单个 API 接口或函数
  • OOP 中的接口概念

接口隔离原则有以下几个要点:

  • 将一个大的、通用的接口拆分成多个专用的接口。这样可以降低类之间的耦合度,提高代码的可维护性和可读性。
  • 为每个接口定义一个独立的职责。这样可以确保接口的粒度适当,同时也有助于遵循单一职责原则。
  • 在定义接口时,要考虑到客户端的实际需求。客户端不应该被迫实现无关的接口方法。

2. 示例

开发一个机器人程序,机器人具有多种功能,如行走、飞行和工作。可以为这些功能创建一个统一的接口:

java 复制代码
public interface Robot {
    void walk();
    void fly();
    void work();
}

然而,这个接口并不符合接口隔离原则,因为它将多个功能聚合在了一个接口中。对于那些只需要实现部分功能的客户端来说,这个接口会导致不必要的依赖。为了遵循接口隔离原则,我们应该将这个接口拆分成多个更小、更专注的接口:

java 复制代码
public interface Walkable {
	void walk();
}
public interface Flyable {
	void fly();
}
public interface Workable {
	void work();
}

现在,我们可以根据需要为不同类型的机器人实现不同的接口。例如,对于一个只能行走和工作的机器人,我们只需要实现 Walkable 和 Workable 接口:

java 复制代码
public class WalkingWorkerRobot implements Walkable, Workable {
    @Override
    public void walk() {
    	// 实现行走功能
    }
    @Override
    public void work() {
    	// 实现工作功能
    }
}

一个接口只定义一个方法确实可以满足接口隔离原则,但这并不是一个绝对的标准。 在设计接口时,我们需要权衡接口的粒度和实际需求。过度拆分接口可能导致过多的单方法接口,这会增加代码的复杂性,降低可读性和可维护性。

关键在于确保接口的职责清晰且单一,以便客户端只需依赖它们真正需要的接口。在某些情况下,一个接口包含多个方法是合理的,只要这些方法服务于一个单一的职责。例如,一个数据库操作接口可能包含 connect() 、 disconnect() 、 executeQuery() 等方法,这些方法都是数据库操作的一部分,因此可以放在同一个接口中。

五、依赖倒置原则

1. 原理

依赖倒置原则(Dependency Inversion Principle,简称 DIP)是面向对象设计的五大原则(SOLID)之一。这个原则强调要依赖于抽象而不是具体实现。遵循这个原则可以使系统的设计更加灵活、可扩展和可维护。

依赖倒置原则有两个关键点:

  • 高层模块不应该依赖于低层模块,它们都应该依赖于抽象。
  • 抽象不应该依赖于具体实现,具体实现应该依赖于抽象。

倒置(Inversion)在这里的确是指"反过来"的意思。在依赖倒置原则(Dependency Inversion Principle, DIP)中,我们需要改变依赖关系的方向,使得高层模块和低层模块都依赖于抽象,而不是高层模块直接依赖于低层模块。这样一来,依赖关系就从直接依赖具体实现"反过来"依赖抽象了。

在没有应用依赖倒置原则的传统软件设计中,高层模块通常直接依赖于低层模块。这 会导致系统的耦合度较高,低层模块的变化很容易影响到高层模块。当我们应用依赖倒置原则时,高层模块和低层模块的依赖关系发生了改变,它们都依赖于抽象(例如接口或抽象类),而不再是高层模块直接依赖于低层模块。这样,我们就实现了依赖 关系的"倒置"。

这种"倒置"的依赖关系使得系统的耦合度降低,提高了系统的可维护性和可扩展性。 因为当低层模块的具体实现发生变化时,只要不改变抽象,高层模块就不需要进行调整。所以这个原则叫做依赖倒置原则。

2. 如何理解抽象

当我们在讨论依赖倒置原则中的抽象时,绝对不能仅仅把他理解为一个接口。抽象的目的是将关注点从具体实现转移到概念和行为,使得我们在设计和编写代码时能够更加关注问题的本质。通过使用抽象,我们可以创建更加灵活、可扩展和可维护的系统。

事实上抽象是一个很广泛的概念,它可以包括接口、抽象类以及由大量接口,抽象类和实现组成的更高层次的模块。通过将系统分解为更小的可复用的组件,我们可以实现更高层次的抽象。这些组件可以独立地进行替换和扩展,从而使整个系统更加灵活。

在依赖倒置原则的背景下,我们可以从以下几个方面理解抽象:

2.1 接口

接口是 Java 中实现抽象的一种常见方式。接口定义了一组方法签名,表示实现该接口的类应具备哪些行为。接口本身并不包含具体实现,所以它强调了行为的抽象。

假设我们正在开发一个在线购物系统,其中有一个订单处理模块。订单处理模块需要与不同的支付服务提供商(如 PayPal、Stripe 等)进行交互。如果我们直接依赖于支付服务提供商的具体实现,那么在更换支付服务提供商或添加新的支付服务提供商时,我们可能需要对订单处理模块进行大量修改。为了避免这种情况,我们应该依赖于接口而不是具体实现。

首先,我们定义一个支付服务接口:

java 复制代码
public interface PaymentService {
	boolean processPayment(Order order);
}

然后,为每个支付服务提供商实现该接口:

java 复制代码
public class PayPalPaymentService implements PaymentService {
    @Override
    public boolean processPayment(Order order) {
    	// 实现 PayPal 支付逻辑
    }
}
public class StripePaymentService implements PaymentService {
    @Override
    public boolean processPayment(Order order) {
    	// 实现 Stripe 支付逻辑
    }
}

现在,我们可以在订单处理模块中依赖 PaymentService 接口,而不是具体的实现:

java 复制代码
public class OrderProcessor {
	private PaymentService paymentService;
    public OrderProcessor(PaymentService paymentService) {
    	this.paymentService = paymentService;
    }
    public void processOrder(Order order) {
        // 其他订单处理逻辑...
        boolean paymentResult = paymentService.processPayment(order);
        // 根据 paymentResult 处理支付结果
    }
}

当我们需要更换支付服务提供商或添加新的支付服务提供商时,只需要提供一个新的实现类,而不需要修改 OrderProcessor 类。我们可以通过构造函数注入不同的支付服务实现,使得系统更加灵活和可扩展。

2.2 抽象类

抽象类是另一种实现抽象的方式。与接口类似,抽象类也可以定义抽象方法,表示子类应该具备哪些行为。不过抽象类还可以包含部分具体实现,这使得它们比接口更加灵活。

java 复制代码
abstract class Shape {
    abstract double getArea();
    void displayArea() {
    	System.out.println("面积为: " + getArea());
    }
}
class Circle extends Shape {
    private final double radius;
    Circle(double radius) {
    	this.radius = radius;
    }
    @Override
    double getArea() {
    	return Math.PI * Math.pow(radius, 2);
    }
}

class Square extends Shape {
    private final double side;
    Square(double side) {
        this.side = side;
    }
    @Override
    double getArea() {
    	return Math.pow(side, 2);
    }
}

在这个示例中,我们定义了一个抽象类 Shape ,它具有一个抽象方法 getArea , 用于计算形状的面积。同时它还包含了一个具体方法 displayArea ,用于打印面 积。 Circle 和 Square 类继承了 Shape ,分别实现了 getArea 方法。在其他类中我们可以依赖抽象Shape而非Square和Circle。

2.3 高层模块

在某些情况下,我们可以通过将系统分解为更小的、可复用的组件来实现抽象。这些组件可以独立地进行替换和扩展,从而使整个系统更加灵活。这种抽象方法往往在软件架构和模块化设计中有所体现。

3. 如何理解高层模块和低层模块

在调用链上,调用者属于高层,被调用者属于低层。在平时的业务代码开发中,高层模块依赖低层模块是没有任何问题的。这条原则主要用来指导框架层面的设计,跟前面讲到的控制反转类似。

从业务代码上讲,举一个简单的例子就是controller要依赖service的接口而不是实现,service实现要依赖dao层的接口而不是实现,调用者要依赖被调用者的接口而不是实现。

以一个简单的音频播放器为例,高层模块 AudioPlayer 负责播放音频,而音频文件的解码由低层模块 Decoder 实现。为了遵循依赖倒置原则,我们可以引入一个抽 象的解码器接口:

java 复制代码
interface AudioDecoder {
	AudioData decode(String filePath);
}
class AudioPlayer {
    private final AudioDecoder decoder;
    public AudioPlayer(AudioDecoder decoder) {
    	this.decoder = decoder;
	}
    public void play(String filePath) {
        AudioData audioData = decoder.decode(filePath);
        // 使用解码后的音频数据进行播放
    }
}
class MP3Decoder implements AudioDecoder {
    @Override
    public AudioData decode(String filePath) {
    	// 实现 MP3 文件解码
	}
}

在这个例子中,我们将高层模块 AudioPlayer 和低层模块 MP3Decoder 解耦,使它们都依赖于抽象接口 AudioDecoder 。这样可以根据需要轻松地更换音频解码器(例如,支持不同的音频格式),而不影响音频播放器的逻辑。为了支持新的音频格式,我们只需要实现新的解码器类,并将其传递给 AudioPlayer 。

假设我们现在要支持 WAV 格式的音频文件,我们可以创建一个实现 AudioDecoder 接口的新类:

java 复制代码
class WAVDecoder implements AudioDecoder {
    @Override
    public AudioData decode(String filePath) {
    	// 实现 WAV 文件解码
}

然后,在创建 AudioPlayer 对象时,我们可以根据需要选择使用 MP3Decoder 或 WAVDecoder :

java 复制代码
public static void main(String[] args) {
    AudioDecoder mp3Decoder = new MP3Decoder();
    AudioPlayer mp3Player = new AudioPlayer(mp3Decoder);
    mp3Player.play("example.mp3");
    AudioDecoder wavDecoder = new WAVDecoder();
    AudioPlayer wavPlayer = new AudioPlayer(wavDecoder);
    wavPlayer.play("example.wav");
}

我们拿Tomcat 这个 Servlet 容器作为例子来解释一下。

Tomcat 是运行 Java Web 应用程序的容器。我们编写的 Web 应用程序只需要部署在 Tomcat 容器下,便可以被 Tomcat 容器调用执行。按照之前的划分原则,Tomcat 就是高层模块,我们编写的 Web 应用程序就是低层模块。Tomcat 和 应用程序之间并没有直接的依赖关系,两者都依赖同一个"抽象",也就是 Servlet 规范。Servlet 规范不依赖具体的 Tomcat 容器和应用程序的实现细节,而 Tomcat 容器和应用程序依赖 Servlet 规范。这样做的好处就是tomcat中可以运行任何实现了servlet规范的应用程序,同时我们编写的servlet实现(web)工程也可以 运行在不同的web服务器中。

4. IOC容器

控制反转是一种软件设计原则,它将传统的控制流程颠倒过来,将控制权交给一个中 心化的容器或框架。

依赖注入是指不通过 new 的方式在类内部创建依赖类对象,而是将依赖的类对象在外部创建好之后通过构造函数、函数参数等方式传递(或注入)给类使用。

结合控制翻转和依赖注入,我们只要保证依赖抽象而不是实现,就能很轻松的替换实现。如给容器注入一个myslq的数据,则所有依赖数据源的部分会自动使用 mysql,如果想替换数据源则仅仅需要给容器注入一个新的数据源就好了,不需要修改一行代码。

六、迪米特原则

1. 原理

迪米特法则(Law of Demeter, LoD) , 核心思想是一个对象应该 尽量少地了解其他对象,降低对象之间的耦合度,从而提高代码的可维护性和可扩展性。 主要指导原则如下:

  • 类和类之间尽量不直接依赖。
  • 有依赖关系的类之间,尽量只依赖必要的接口。

2. 类之间不直接依赖

"不该有直接依赖关系的类之间,不要有依赖"这个原则强调的是降低类与类之间的耦合度,避免不必要的依赖。这意味着我们应该使用抽象(如接口或抽象类)来解耦具体实现。举例理解:

假设我们有一个简单的报告生成系统,它需要从不同类型的数据源(如数据库、文件、API等)获取数据,并输出不同格式的报告(如CSV、JSON、XML等)。

java 复制代码
// 具体的数据库类
class Database {
    public String fetchData() {
        // 从数据库中获取数据
        return "data from database";
    }
}
// 具体的报告生成类
class ReportGenerator {
    private Database database;
    public ReportGenerator(Database database) {
    	this.database = database;
    }
    public String generateCSVReport() {
        String data = database.fetchData();
        // 将数据转换为CSV格式
        return "CSV report: " + data;
    }
}

在上述实现中, ReportGenerator 类直接依赖于具体的 Database 类。这意味着如果我们想从其他类型的数据源(如文件)获取数据,或者使用不同的数据库实现, 需要修改 ReportGenerator 类。这违反了开闭原则(对扩展开放,对修改封闭),并增加了类与类之间的耦合。 为了遵循 "不该有直接依赖关系的类之间,不要有依赖"原则,我们可以引入抽象来解耦具体实现。下面是一个修改后的实现:

java 复制代码
// 数据源接口
interface DataSource {
	String fetchData();
}
// 具体的数据库类
class Database implements DataSource {
    @Override
    public String fetchData() {
        // 从数据库中获取数据
        return "data from database";
    }
}
// 具体的文件类
class FileDataSource implements DataSource {
    @Override
    public String fetchData() {
        // 从文件中获取数据
        return "data from file";
    }
}
// 报告生成类
class ReportGenerator {
    private DataSource dataSource;
    public ReportGenerator(DataSource dataSource) {
    	this.dataSource = dataSource;
    }
    public String generateCSVReport() {
        String data = dataSource.fetchData();
        // 将数据转换为CSV格式
        return "CSV report: " + data;
    }
}

在修改后的实现中,我们引入了 DataSource 接口,并使 ReportGenerator 类依赖于该接口,而不是具体的实现。这样,我们可以轻松地为报告生成器添加新的数据源类型,而无需修改现有代码。

3. 只依赖必要的接口

有依赖关系的类之间,尽量只依赖必要的接口这个原则强调的是,当一个类需要依赖另一个类时,应该尽可能地依赖于最小化的接口。

以用户信息管理案例给大家讲解

java 复制代码
public interface UserService {
    boolean register(String cellphone, String password);
    boolean login(String cellphone, String password);
    UserInfo getUserInfoById(long id);
    UserInfo getUserInfoByCellphone(String cellphone);
}
public interface RestrictedUserService {
    boolean deleteUserByCellphone(String cellphone);
    boolean deleteUserById(long id);
}
public class UserServiceImpl implements UserService, RestrictedUserService {
	// ... 省略实现代码...
}

对于绝大部分场景,我们可能只关心和删除无关的方法,如UserController,所以他只需要依赖他所需要的接口UserService即可:

java 复制代码
public class UserController{
	UserService userService;
	// ... 省略实现代码...
}

然而用户管理员需要更多的权限,我们则可以通过组合的形式来实现,让其依赖两个必要的接口:

java 复制代码
public class UserManagerController{
    UserService userService;
    RestrictedUserService restrictedUserService;
    // ... 省略实现代码...
}

再举一个例子,假如我们要开一个飞行比赛,我们可以写出如下的案例来满足迪米特法则:

java 复制代码
// 飞行行为接口
public interface Flyable {
    void fly();
}
// 基类:鸟类
public class Bird {
}
// 子类:能飞的鸟类
public class Sparrow extends Bird implements Flyable {
    @Override
    public void fly() {
    	System.out.println("sparrow can fly");
    }
}
// 子类:飞机
public class Plane implements Flyable {
    @Override
    public void fly() {
    	System.out.println("plane can fly");
    }
}
// 子类:企鹅类,不实现Flyable接口
public class Penguin extends Bird {
}
//
public class AirRace {
    List<Flyable> list;
    public void addFlyable(Flyable flyable){
   		list.add(flyable);
    }
	// ...
}

4. 灵活应用

在实际工作中,确实需要在不同的设计原则之间进行权衡。迪米特法则(Law of Demeter,LoD)是一种有助于降低类之间耦合度的原则,但过度地应用迪米特法则可能导致代码变得复杂和难以维护。因此在实际项目中,我们应该根据具体的场景和需求灵活地应用迪米特法则。以下是一些建议:

  • 避免过度封装:尽管迪米特法则强调类之间的低耦合,但是过度封装可能导致系 统变得难以理解和维护。当一个类需要访问另一个类的属性或方法时,我们应该权衡封装的成本和收益,而不是盲目地遵循迪米特法则。
  • 拒绝过度解耦:在实际项目中,过度解耦可能导致大量的中间层和传递性调用。当一个类需要访问另一个类的方法时,如果引入大量的中间层会导致系统变得复杂和低效,那么我们应该考虑放宽迪米特法则的约束。
  • 与其他设计原则和模式相结合:在实际项目中,我们应该灵活地将迪米特法则与 其他设计原则(如单一职责原则、开闭原则等)和设计模式(如外观模式、代理模式等)相结合。这样可以使我们在降低耦合度的同时,保持代码的可读性、可维护性和可扩展性。
  • 考虑实际需求和场景:在应用迪米特法则时,我们应该关注实际的需求和场景。如果一个项目的需求和场景较为简单,那么过度地应用迪米特法则可能导致不必 要的开发成本。相反,如果一个项目的需求和场景较为复杂,那么遵循迪米特法则可能有助于提高系统的稳定性和可维护性。
相关推荐
腥臭腐朽的日子熠熠生辉16 分钟前
解决maven失效问题(现象:maven中只有jdk的工具包,没有springboot的包)
java·spring boot·maven
ejinxian18 分钟前
Spring AI Alibaba 快速开发生成式 Java AI 应用
java·人工智能·spring
杉之24 分钟前
SpringBlade 数据库字段的自动填充
java·笔记·学习·spring·tomcat
圈圈编码44 分钟前
Spring Task 定时任务
java·前端·spring
俏布斯1 小时前
算法日常记录
java·算法·leetcode
27669582921 小时前
美团民宿 mtgsig 小程序 mtgsig1.2 分析
java·python·小程序·美团·mtgsig·mtgsig1.2·美团民宿
爱的叹息1 小时前
Java 连接 Redis 的驱动(Jedis、Lettuce、Redisson、Spring Data Redis)分类及对比
java·redis·spring
程序猿chen1 小时前
《JVM考古现场(十五):熵火燎原——从量子递归到热寂晶壁的代码涅槃》
java·jvm·git·后端·java-ee·区块链·量子计算
松韬2 小时前
Spring + Redisson:从 0 到 1 搭建高可用分布式缓存系统
java·redis·分布式·spring·缓存
绝顶少年2 小时前
Spring Boot 注解:深度解析与应用场景
java·spring boot·后端