软件设计原则
设计原则
一、单一原则
1. 如何理解单一职责原则
单一职责原则(Single Responsibility Principle,简称SRP),它要求一个类或模块应该只负责一个特定的功能。实现代码的高内聚和低耦合,提高代码的可读性和可维护性。 我们可以把模块看作比类更加抽象的概念,类也可以看作模块。或者把模块看作比类更加粗粒度的代码块,模块中包含多个类,多个类组成一个模块。
单一职责原则的定义就是一个类只负责完成一个职责或者功能。不要设计大而全的类,要设计粒度小、功能单一的类。
如果一个类包含了两个或者两个以上业务不相干的功能,那它职责不够单 一,应该将它拆分成多个功能更加单一、粒度更细的类。
简单的例子 :
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 类负责了员工信息的管理、工资计算以及员工信息的持 久化。这违反了单一职责原则。为了遵循该原则,我们可以将这些功能拆分到不同的类中:
class Employee {private String name;private String position;private double baseSalary;public Employee(String name, String position, double baseSalary) {this.name = namethis.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 类的设计是否满足单一职责原则呢?
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 类实现了一个简单协议的序列化和反序列功 能,具体代码如下:
/**
* 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 类。 拆分后的具体代码如下所示:
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。我们把它翻译成中文就是:软件实体(模 块、类、方法等)应该“对扩展开放、对修改关闭”
。
以下是一个常见的生产环境中的例子,我们将展示一个简化的电商平台的订单折扣策 略。
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 方法的代码,这显然是不合理的,违反了开闭原则。
修改为遵循开闭原则的代码:
// 抽象折扣策略接口
interface DiscountStrategy {double getDiscountedAmount(double totalAmount);
}
// 节日折扣策略
class FestivalDiscountStrategy implements DiscountStrategy {@Overridepublic double getDiscountedAmount(double totalAmount) {return totalAmount * 0.9; // 9折}
}
// 季节折扣策略
class SeasonalDiscountStrategy implements DiscountStrategy {@Overridepublic 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. 修改代码就意味着违背开闭原则吗
开闭原则的核心思想是尽量减少对现有代码的修改,以降低修改带来的风险和影响。在实际开发过程中完全不修改代码是不现实的。当需求变更或者发现代码中的错误时,修改代码是正常的。开闭原则鼓励我们通过设计更好的代码结构,使得在添加新功能或者扩展系统时,尽量减少对现有代码的修改。
以下是一个简化的日志记录器的示例,展示了在适当情况下修改代码,也不违背开闭 原则。在这个例子中,我们的应用程序支持将日志输出到控制台和文件。假设我们需要添加一个新功能,以便在输出日志时同时添加一个时间戳。
interface Logger {void log(String message);
}class ConsoleLogger implements Logger {@Overridepublic void log(String message) {System.out.println("Console: " + message);}
}class FileLogger implements Logger {@Overridepublic void log(String message) {System.out.println("File: " + message);// 将日志写入文件的实现省略}
}
为了添加时间戳功能,我们需要修改现有的 ConsoleLogger 和 FileLogger 类。 虽然我们需要修改代码,但由于这是对现有功能的改进而不是添加新的功能,所以这种修改是可以接受的,不违背开闭原则。
interface Logger {void log(String message);
}class ConsoleLogger implements Logger {@Overridepublic void log(String message) {String timestamp = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);System.out.println("Console [" + timestamp + "]: " + message);}
}class FileLogger implements Logger {@Overridepublic 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)不变及正确性不被破坏。 有点儿多态的味道。
以下是一个简单的示例:
// 基类:鸟类
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");}
}
为了遵循里氏替换原则
,我们可以重新设计类结构,将能飞的行为抽象到一个接口中,让需要飞行能力的鸟类实现这个接口:
// 飞行行为接口
public interface Flyable {void fly();
}
// 基类:鸟类
public class Bird {
}
// 子类:能飞的鸟类
public class FlyingBird extends Bird implements Flyable {@Overridepublic void fly() {System.out.println("I can fly");}
}
// 子类:企鹅类,不实现Flyable接口
public class Penguin extends Bird {
}
通过这样的设计,我们遵循了里氏替换原则,同时也保证了代码的可维护性和复用性。
再来看一个基于数据库操作的案例。假设我们正在开发一个支持多种数据库的程序,包括MySQL、PostgreSQL和SQLite。可以使用里氏替换原则来设计合适的类结构,确保代码的可维护性和扩展性。
首先,定义一个抽象的 Database 基类,它包含一些通用的数据库操作方法, 如 connect() 、 disconnect() 和 executeQuery() 。这些方法的具体实现将在子类中完成。
public abstract class Database {public abstract void connect();public abstract void disconnect();public abstract void executeQuery(String query);
}
然后,为每种数据库类型创建一个子类,继承自 Database 基类。这些子类需要实现基类中定义的抽象方法,并可以添加特定于各自数据库的方法。
public class MySQLDatabase extends Database {@Overridepublic void connect() {// 实现MySQL的连接逻辑}@Overridepublic void disconnect() {// 实现MySQL的断开连接逻辑}@Overridepublic void executeQuery(String query) {// 实现MySQL的查询逻辑}// 其他针对MySQL的特定方法
}
public class PostgreSQLDatabase extends Database {// 类似地,为PostgreSQL实现相应的方法
}
public class SQLiteDatabase extends Database {// 类似地,为SQLite实现相应的方法
}
这样设计的好处是,我们可以在不同的数据库类型之间灵活切换,而不需要修改大量代码。只要这些子类遵循里氏替换原则,我们就可以放心地使用基类的引用来操作不 同类型的数据库。例如:
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 实例。
public class Bird {public void fly() {System.out.println("I can fly");}
}
public class Penguin extends Bird {@Overridepublic void fly() {throw new UnsupportedOperationException("Penguins can't fly");}
}
2.2 子类违反了基类的约束条件
当子类违反了基类中定义的约束条件(如输入、输出或异常等),也会违反LSP。
在下面这个例子中,NonNegativeStack 类违反了Stack基类的约束条件,因为它在 push() 方法中添加了一个新的约束,即只允许非负数入栈。这使得 NonNegativeStack 实例无法替换 Stack 实例,违反了LSP。
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);}@Overridepublic 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. 示例
开发一个机器人程序,机器人具有多种功能,如行走、飞行和工作。可以为这些功能创建一个统一的接口:
public interface Robot {void walk();void fly();void work();
}
然而,这个接口并不符合接口隔离原则,因为它将多个功能聚合在了一个接口中。对于那些只需要实现部分功能的客户端来说,这个接口会导致不必要的依赖。为了遵循接口隔离原则,我们应该将这个接口拆分成多个更小、更专注的接口:
public interface Walkable {void walk();
}
public interface Flyable {void fly();
}
public interface Workable {void work();
}
现在,我们可以根据需要为不同类型的机器人实现不同的接口。例如,对于一个只能行走和工作的机器人,我们只需要实现 Walkable 和 Workable 接口:
public class WalkingWorkerRobot implements Walkable, Workable {@Overridepublic void walk() {// 实现行走功能}@Overridepublic void work() {// 实现工作功能}
}
一个接口只定义一个方法确实可以满足接口隔离原则,但这并不是一个绝对的标准。 在设计接口时,我们需要权衡接口的粒度和实际需求。过度拆分接口可能导致过多的单方法接口,这会增加代码的复杂性,降低可读性和可维护性。
关键在于确保接口的职责清晰且单一,以便客户端只需依赖它们真正需要的接口。在某些情况下,一个接口包含多个方法是合理的,只要这些方法服务于一个单一的职责。例如,一个数据库操作接口可能包含 connect() 、 disconnect() 、 executeQuery() 等方法,这些方法都是数据库操作的一部分,因此可以放在同一个接口中。
五、依赖倒置原则
1. 原理
依赖倒置原则(Dependency Inversion Principle,简称 DIP)是面向对象设计的五大原则(SOLID)之一。这个原则强调要依赖于抽象而不是具体实现。遵循这个原则可以使系统的设计更加灵活、可扩展和可维护。
依赖倒置原则有两个关键点:
- 高层模块不应该依赖于低层模块,它们都应该依赖于抽象。
- 抽象不应该依赖于具体实现,具体实现应该依赖于抽象。
倒置(Inversion)在这里的确是指“反过来”的意思。在依赖倒置原则(Dependency Inversion Principle, DIP)中,我们需要改变依赖关系的方向,使得高层模块和低层模块都依赖于抽象,而不是高层模块直接依赖于低层模块。这样一来,依赖关系就从直接依赖具体实现“反过来”依赖抽象了。
在没有应用依赖倒置原则的传统软件设计中,高层模块通常直接依赖于低层模块。这 会导致系统的耦合度较高,低层模块的变化很容易影响到高层模块。当我们应用依赖倒置原则时,高层模块和低层模块的依赖关系发生了改变,它们都依赖于抽象(例如接口或抽象类),而不再是高层模块直接依赖于低层模块。这样,我们就实现了依赖 关系的“倒置”。
这种“倒置”的依赖关系使得系统的耦合度降低,提高了系统的可维护性和可扩展性。 因为当低层模块的具体实现发生变化时,只要不改变抽象,高层模块就不需要进行调整。所以这个原则叫做依赖倒置原则。
2. 如何理解抽象
当我们在讨论依赖倒置原则中的抽象时,绝对不能仅仅把他理解为一个接口。抽象的目的是将关注点从具体实现转移到概念和行为,使得我们在设计和编写代码时能够更加关注问题的本质。通过使用抽象,我们可以创建更加灵活、可扩展和可维护的系统。
事实上抽象是一个很广泛的概念,它可以包括接口、抽象类以及由大量接口,抽象类和实现组成的更高层次的模块。通过将系统分解为更小的可复用的组件,我们可以实现更高层次的抽象。这些组件可以独立地进行替换和扩展,从而使整个系统更加灵活。
在依赖倒置原则的背景下,我们可以从以下几个方面理解抽象:
2.1 接口
接口是 Java 中实现抽象的一种常见方式。接口定义了一组方法签名,表示实现该接口的类应具备哪些行为。接口本身并不包含具体实现,所以它强调了行为的抽象。
假设我们正在开发一个在线购物系统,其中有一个订单处理模块。订单处理模块需要与不同的支付服务提供商(如 PayPal、Stripe 等)进行交互。如果我们直接依赖于支付服务提供商的具体实现,那么在更换支付服务提供商或添加新的支付服务提供商时,我们可能需要对订单处理模块进行大量修改。为了避免这种情况,我们应该依赖于接口而不是具体实现。
首先,我们定义一个支付服务接口:
public interface PaymentService {boolean processPayment(Order order);
}
然后,为每个支付服务提供商实现该接口:
public class PayPalPaymentService implements PaymentService {@Overridepublic boolean processPayment(Order order) {// 实现 PayPal 支付逻辑}
}
public class StripePaymentService implements PaymentService {@Overridepublic boolean processPayment(Order order) {// 实现 Stripe 支付逻辑}
}
现在,我们可以在订单处理模块中依赖 PaymentService 接口,而不是具体的实现:
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 抽象类
抽象类是另一种实现抽象的方式。与接口类似,抽象类也可以定义抽象方法,表示子类应该具备哪些行为。不过抽象类还可以包含部分具体实现,这使得它们比接口更加灵活。
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;}@Overridedouble getArea() {return Math.PI * Math.pow(radius, 2);}
}class Square extends Shape {private final double side;Square(double side) {this.side = side;}@Overridedouble 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 实现。为了遵循依赖倒置原则,我们可以引入一个抽 象的解码器接口:
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 {@Overridepublic AudioData decode(String filePath) {// 实现 MP3 文件解码}
}
在这个例子中,我们将高层模块 AudioPlayer 和低层模块 MP3Decoder 解耦,使它们都依赖于抽象接口 AudioDecoder 。这样可以根据需要轻松地更换音频解码器(例如,支持不同的音频格式),而不影响音频播放器的逻辑。为了支持新的音频格式,我们只需要实现新的解码器类,并将其传递给 AudioPlayer 。
假设我们现在要支持 WAV 格式的音频文件,我们可以创建一个实现 AudioDecoder 接口的新类:
class WAVDecoder implements AudioDecoder {@Overridepublic AudioData decode(String filePath) {// 实现 WAV 文件解码
}
然后,在创建 AudioPlayer 对象时,我们可以根据需要选择使用 MP3Decoder 或 WAVDecoder :
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等)。
// 具体的数据库类
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 类。这违反了开闭原则(对扩展开放,对修改封闭),并增加了类与类之间的耦合。 为了遵循 "不该有直接依赖关系的类之间,不要有依赖"原则,我们可以引入抽象来解耦具体实现。下面是一个修改后的实现:
// 数据源接口
interface DataSource {String fetchData();
}
// 具体的数据库类
class Database implements DataSource {@Overridepublic String fetchData() {// 从数据库中获取数据return "data from database";}
}
// 具体的文件类
class FileDataSource implements DataSource {@Overridepublic 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. 只依赖必要的接口
有依赖关系的类之间,尽量只依赖必要的接口
这个原则强调的是,当一个类需要依赖另一个类时,应该尽可能地依赖于最小化的接口。
以用户信息管理案例给大家讲解
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即可:
public class UserController{UserService userService;// ... 省略实现代码...
}
然而用户管理员需要更多的权限,我们则可以通过组合的形式来实现,让其依赖两个必要的接口:
public class UserManagerController{UserService userService;RestrictedUserService restrictedUserService;// ... 省略实现代码...
}
再举一个例子,假如我们要开一个飞行比赛,我们可以写出如下的案例来满足迪米特法则:
// 飞行行为接口
public interface Flyable {void fly();
}
// 基类:鸟类
public class Bird {
}
// 子类:能飞的鸟类
public class Sparrow extends Bird implements Flyable {@Overridepublic void fly() {System.out.println("sparrow can fly");}
}
// 子类:飞机
public class Plane implements Flyable {@Overridepublic 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)是一种有助于降低类之间耦合度的原则,但过度地应用迪米特法则可能导致代码变得复杂和难以维护。因此在实际项目中,我们应该根据具体的场景和需求灵活地应用迪米特法则。以下是一些建议:
- 避免过度封装:尽管迪米特法则强调类之间的低耦合,但是过度封装可能导致系 统变得难以理解和维护。当一个类需要访问另一个类的属性或方法时,我们应该权衡封装的成本和收益,而不是盲目地遵循迪米特法则。
- 拒绝过度解耦:在实际项目中,过度解耦可能导致大量的中间层和传递性调用。当一个类需要访问另一个类的方法时,如果引入大量的中间层会导致系统变得复杂和低效,那么我们应该考虑放宽迪米特法则的约束。
- 与其他设计原则和模式相结合:在实际项目中,我们应该灵活地将迪米特法则与 其他设计原则(如单一职责原则、开闭原则等)和设计模式(如外观模式、代理模式等)相结合。这样可以使我们在降低耦合度的同时,保持代码的可读性、可维护性和可扩展性。
- 考虑实际需求和场景:在应用迪米特法则时,我们应该关注实际的需求和场景。如果一个项目的需求和场景较为简单,那么过度地应用迪米特法则可能导致不必 要的开发成本。相反,如果一个项目的需求和场景较为复杂,那么遵循迪米特法则可能有助于提高系统的稳定性和可维护性。
相关文章:
软件设计原则
设计原则 一、单一原则 1. 如何理解单一职责原则 单一职责原则(Single Responsibility Principle,简称SRP),它要求一个类或模块应该只负责一个特定的功能。实现代码的高内聚和低耦合,提高代码的可读性和可维护性。 …...
【面试HOT100】哈希双指针滑动窗口
系列综述: 💞目的:本系列是个人整理为了秋招面试的,整理期间苛求每个知识点,平衡理解简易度与深入程度。 🥰来源:材料主要源于LeetCodeHot100进行的,每个知识点的修正和深入主要参考…...
Ubuntu20.04 配置 yolov5_ros 功能包记录
文章目录 本文参考自博主源801,结合自己踩坑后修改 项目地址:https://github.com/mats-robotics/yolov5_ros 1.新建工作空间 新建一个工作空间 yolo_ros(名字可自定义),在 yolo_ros 下新建文件夹 src 并catkin_make进行编译 2. 安装相机驱动,可以选用较为主流的 usb_cam 或…...
Flink的处理函数——processFunction
目录 一、处理函数概述 二、Process函数分类——8个 (1)ProcessFunction (2)KeyedProcessFunction (3)ProcessWindowFunction (4)ProcessAllWindowFunction ÿ…...
Linux系统中的ps命令详解及用法介绍
文章目录 一、介绍ps命令A. ps命令的作用B. ps命令的参数 二、常见的ps命令用法A. 显示所有进程信息B. 显示指定进程信息C. 显示指定用户的进程信息D. 按CPU使用率排序显示进程信息E. 按内存使用率排序显示进程信息 三、进一步了解ps命令A. 显示进程树信息B. 显示线程和进程关系…...
机器学习笔记 - 基于pytorch、grad-cam的计算机视觉的高级可解释人工智能
一、pytorch-gradcam简介 Grad-CAM是常见的神经网络可视化的工具,用于探索模型的可解释性,广泛出现在各大顶会论文中,以详细具体地描述模型的效果。Grad-CAM的好处是,可以在不额外训练的情况下,只使用训练好的权重即可获得热力图。 1、CAM是什么? CAM全称Class Activa…...
Python 编程基础 | 第五章-类与对象 | 5.1、定义类
一、类 1、定义类 Python中使用class关键字定义类,class之后为类的名称并以:结尾,类的结构如下: class 类名:多个(≥0)类属性...多个(≥0)类方法...下面定义一个Dog类,如…...
合宙Air780e+luatos+腾讯云物联网平台完成设备通信与控制(属性上报+4G远程点灯)
1.腾讯云物联网平台 首先需要在腾讯云物联网平台创建产品、创建设备、定义设备属性和行为,例如: (1)创建产品 (2)定义设备属性和行为 (3)创建设备 (4)准备参…...
c++系列之string的模拟实现
💗 💗 博客:小怡同学 💗 💗 个人简介:编程小萌新 💗 💗 如果博客对大家有用的话,请点赞关注再收藏 🌞 string() //注意事项: 1.初始化列表随声明的顺序进行初始化 2.cons…...
Spring的beanName生成器AnnotationBeanNameGenerator
博主介绍:✌全网粉丝4W,全栈开发工程师,从事多年软件开发,在大厂呆过。持有软件中级、六级等证书。可提供微服务项目搭建与毕业项目实战,博主也曾写过优秀论文,查重率极低,在这方面有丰富的经验…...
FFmpeg 命令:从入门到精通 | ffmpeg 命令直播
FFmpeg 命令:从入门到精通 | ffmpeg 命令直播 FFmpeg 命令:从入门到精通 | ffmpeg 命令直播直播拉流直播推流 FFmpeg 命令:从入门到精通 | ffmpeg 命令直播 本节主要介绍了ffmpeg 命令进行直播拉流、推流的方法,并列举了一些例子…...
A (1087) : DS单链表--类实现
Description 用C语言和类实现单链表,含头结点 属性包括:data数据域、next指针域 操作包括:插入、删除、查找 注意:单链表不是数组,所以位置从1开始对应首结点,头结点不放数据 类定义参考 #include<…...
异常:找不到匹配的key exchange算法
目录 问题描述原因分析解决方案 问题描述 PC 操作系统:Windows 10 企业版 LTSC PC 异常软件:XshellPortable 4(Build 0127) PC 正常软件:PuTTY Release 0.74、MobaXterm_Personal_23.1 服务器操作系统:OpenEuler 22.03 (LTS-SP2)…...
Arcgis打开影像分析窗口没反应
Arcgis打开影像分析窗口没反应 问题描述 做NDVI计算的时候,一直点击窗口-影像分析,发现影像分析的小界面一直不跳出来。 原因 后来发现是被内容列表给遮住了,其实是已经出来了的。。 拖动内容列表就能找到。 解决方案 内容列表和影像分…...
Spring(JavaEE进阶系列1)
目录 前言: 1.Servlet与Spring对比 2.什么是Spring 2.1什么是容器 2.2什么是IoC 2.3SpringIoC容器的理解 2.4DI依赖注入 2.5IoC与DI的区别 3.Spring项目的创建和使用 3.1正确配置Maven国内源 3.2Spring的项目创建 3.3将Bean对象存储到Spring(…...
Flink状态管理与检查点机制
1.状态分类 相对于其他流计算框架,Flink 一个比较重要的特性就是其支持有状态计算。即你可以将中间的计算结果进行保存,并提供给后续的计算使用: 具体而言,Flink 又将状态 (State) 分为 Keyed State 与 Operator State: 1.1 算子状态 算子状态 (Operator State):顾名思义…...
【threejs】基本编程概念及海岛模型展示逻辑
采用three封装模式完成的海岛动画(点击这里查看) 直接上代码吧 <template><div class"scene"><video id"videoContainer" style"position:absolute;top:0px;left:0px;z-index:100;visibility: hidden"&g…...
python小技巧:创建单链表及删除元素
目前只有单链表(无法查找上一个元素),后面再更新循环链表和双链表。 class SingleLinkedList:def createList(self, raw_list):if len(raw_list) 0:head ListNode()else:head ListNode(raw_list[0])cur headfor i in range(1, len(raw_l…...
ADuM1250 ADuM1251 模块 I2C IIC总线2500V电磁隔离 接口保护
功能说明: 1,2500V电磁隔离,2通道双向I2C; 2,支持电压在3到5.5V,最大时钟频率可达1000KHz; 3,将该隔离模块接入总线,可以保护主MCU引脚,降低I2C总线上的干…...
C# 把多个dll合成一个dll
Nuget 下载ILMerge两个工程 dog为测试工程 TestIlmerge为准备合并的类库 如下图所示, 由于我们引用下面4个库 正常生成后,会有TestIlmerge.dll和下面的这4个dll 只生成TestIlmerge.dll 打开工程文件 在最下方加入以下两段 <Target Name"ILMerge…...
scipy.sparse.coo_matrix.sum()关于axis的用法
以下面的矩阵为例 [1,2,0] [0,3,0] [0,0,0]示例代码 from scipy.sparse import coo_matrix# 创建一个稀疏矩阵 data [1, 2, 3] row [0, 0, 1] col [0, 1, 1] sparse_matrix coo_matrix((data, (row, col)), shape(3,3))# 计算稀疏矩阵中每行非零元素的总和 sum_of_column…...
C++类与对象(下)
文章目录 1.非类型模板2.模板特化2.1.类模板特化2.1.1.全特化2.1.2.偏特化 2.2.函数模板特化 3.函数模板声明定义分离 之前我们学习的模板能达到泛型的原因是:使用了“泛型的类型”,但是如果经过后面的“造轮子”(后面会尝试实现一下 STL的一…...
SpringBoot——》引入Redis
推荐链接: 总结——》【Java】 总结——》【Mysql】 总结——》【Redis】 总结——》【Kafka】 总结——》【Spring】 总结——》【SpringBoot】 总结——》【MyBatis、MyBatis-Plus】 总结——》【Linux】 总结——》【MongoD…...
C# newtonsoft序列化将long类型转化为字符串
/// <summary> /// 转化为json的时候long类型转为string /// </summary> public class LongJsonConverter: JsonConverter {public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer){try{return r…...
黑马点评-02使用Redis代替session,Redis + token机制实现
Redis代替session session共享问题 每个Tomcat中都有一份属于自己的session,所以多台Tomcat并不共享session存储空间,当请求切换到不同tomcat服务时可能会导致数据丢失 用户第一次访问1号tomcat并把自己的信息存放session域中, 如果第二次访问到了2号tomcat就无法获取到在1号…...
arm 点灯实验代码以及现象
.text .global _start _start: 1.设置GPIOE寄存器的时钟使能 RCC_MP_AHB4ENSETR[4]->1 0x50000a28 LDR R0,0x50000A28 LDR R1,[R0] ORR R1,R1,#(0x1<<4) 第4位置1 STR R1,[R0] 1.设置GPIOF寄存器的时钟使能 RCC_MP_AHB4ENSETR[4]->1 0x50000a28 LDR R…...
选择适合普通公司的项目管理软件
不管是打工人还是学生党都适合使用Zoho Projects项目管理软件。利用项目概览功能,将整体项目尽收眼底,作为项目管理者,项目日程、进度都可见,Zoho Projects项目管理APP助推项目每一环节的进展,更便于管理者设计项目的下…...
E (1081) : DS堆栈--逆序输出(STL栈使用)
Description C中已经自带堆栈对象stack,无需编写堆栈操作的具体实现代码。 本题目主要帮助大家熟悉stack对象的使用,然后实现字符串的逆序输出 输入一个字符串,按字符按输入顺序压入堆栈,然后根据堆栈后进先出的特点࿰…...
访问者模式 行为型设计模式之九
1.定义 在不改变数据结构的前提下,增加作用于一组对象元素的新功能。 2.动机 访问者模式适用于数据结构相对稳定的系统它把数据结构和作用于数据结构之上的操作之间的耦合解脱开,使得操作集合可以相对自由的演化。访问者模式的目的是要把处理从数据结构…...
JVM垃圾回收之JVM GC算法探究
JVM垃圾回收之JVM GC算法探究 在Java虚拟机(JVM)中,垃圾回收(Garbage Collection,GC)是自动管理内存的重要机制,它负责回收程序中不再使用的对象所占用的内存。GC算法是垃圾回收的核心…...
网站做百度竞价/信息流广告接单平台
点击打开链接 在发生其他类或对象关注的事情时,类或对象可通过事件通知它们。发送(或引发)事件的类称为“发行者”,接收(或处理)事件的类称为“订户”。 事件概述 事件具有以下特点: l 发行者确…...
政府网站建设 江苏省/百度新闻官网
Android的包文件APK分为两个部分:代码和资源,所以打包方面也分为资源打包和代码打包两个方面,这篇文章就来分析资源和代码的编译打包原理。 具体说来: 1.通过AAPT工具进行资源文件(包括AndroidManifest.xml、布局文件、…...
网站建设横条/网站页面关键词优化
概述首先同步下项目概况:上篇文章分享了,路由中间件 - Jaeger 链路追踪(理论篇)。这篇文章咱们分享:路由中间件 - Jaeger 链路追踪(实战篇)。说实话,这篇文章确实让大家久等了&#…...
网站建设 小知识/爱网站查询
转自:http://www.pinlue.com/article/2020/09/0413/1211184961256.html...
如何设计个人网站/关键词搜索数据
在华为mate40没有正式发布之前,当下话题最热的机型就是iPhone12了,四款手机也是分开时间段发布,目前6299元的基础版iPhone12人气最高,眼下也没有任何一款国产手机可以抗衡苹果5G手机,实在要找出一款可能就是接下来的华…...
什么是微网站/成都seo论坛
题目: 我是超链接 题解: 题目中有三个条件,我们先不管第三个看起来很长的条件,那么就是让求。 ∑i1n∑j1mij(i,j)∑i1n∑j1mij(i,j)经过类似 luogu3768的化简,我们得到的是∑T1nsum(nT)sum(mT)T∑t|Tμ(t)t∑T1nsu…...