前言
不知道大家有没有这种感觉,大学就学了程序设计的6大设计原则,23种设计模式,但是实际开发中运用的很少。或者说看了很多博客文章,讲了很多理论和要求,但是就是不能运用到实际的coding中去。我认为是这些文章存在缺陷,上来就讲各种让人头大的理论,好像要把这套东西硬塞给读者一样。本文换个思路,先介绍一些基本概念,然后举一些贴合实际业务场景的例子让你感受没有遵循6大设计模式会产生哪些问题,然后我们根据问题提出新的优化方案(运用6大设计原则)以及具体的代码。
我认为23种设计模式相对还好掌握,毕竟很容易就能去套上了。但是6大设计原则真的很难彻底掌握。包括一些资深专家也会在设计中违背6大原则,导致程序扩展性和维护性变差。所以关于6大设计原则,是需要持续学习,持续思考,持续践行的。
希望你耐心看完本文,没有收获,你找我
6大设计原则简介
- 单一职责原则(Single Responsibility Principle);
- 开闭原则(Open Closed Principle);
- 里氏替换原则(Liskov Substitution Principle);
- 迪米特法则(Law of Demeter),又叫“最少知道法则”;
- 接口隔离原则(Interface Segregation Principle);
- 依赖倒置原则(Dependence Inversion Principle)。
把这 6 个原则的首字母联合起来就是:SOLID(稳定的),代表的含义是把这 6 个原则结合使用的好处:建立稳定、灵活、健壮的程序。
单一职责原则
定义是:应该有且仅有一个原因引起类的变更。
强调的是类的责任边界划分要明确,类设计要符合“高内聚,低耦合”的设计思想。类和接口的设计都适合这个原则,甚至方法也适用。
举例:做一个点对点的语音通话功能,现在有一个CallManager类来管理通话,代码如下:
public class CallManager {
private boolean isCallActive;
private boolean isRecording;
public void startCall() {
// 通话开始
isCallActive = true;
// ...
}
public void accept() {
// 通话接听
isCallActive = true;
// ...
}
public void hangup() {
// 通话挂断
isCallActive = false;
// ...
}
public void startRecord() {
// 通话之后,就要开启录制器
if (isCallActive) {
isRecording = true;
// ...
}
}
public void stopRecord() {
//停止录制
isRecording = false;
// ...
}
public void displayCallUI() {
// 显示通话界面
// ...
}
public void updateCallStatus(String status) {
// 更新通话状态
// ...
}
}
CallManager
类负责管理通话的发起、接听和结束,同时还包含了与通话过程中录制器,通话界面相关的逻辑。这违反了单一职责原则,使得这个类的职责过于庞大,不易于维护和扩展。 所以说这个会有多个原因导致类 / 接口变化,明显这个设计并不符合单一职责原则,需要拆分,可以按如下设计:
- CallManager类:负责管理通话的发起、接听、结束等操作;
- CallRecorder类:负责通话中录制器控制;
- CallUI类:负责展示通话界面,例如显示通话时间、通话状态等
// CallManager类,负责管理通话的发起、接听和结束
public class CallManager {
public void startCall() {
// 通话开始
isCallActive = true;
// ...
}
public void accept() {
// 通话接听
isCallActive = true;
// ...
}
public void hangup() {
// 通话挂断
isCallActive = false;
// ...
}
}
CallRecorder类,负责录制通话的音频
public class CallRecorder {
public void startRecord() {
// 通话之后,就要开启录制器
if (isCallActive) {
isRecording = true;
// ...
}
}
public void stopRecord() {
//停止录制
isRecording = false;
// ...
}
}
CallUI类,负责展示通话界面
public class CallUI {
public void displayCallUI() {
// 显示通话界面
// ...
}
public void updateCallStatus(String status) {
// 更新通话状态
// ...
}
}
通过将不同的功能划分到不同的类中,我们实现了单一职责原则。每个类只负责自己特定的功能,使得代码更加清晰、可维护和可扩展。
单一职责的好处
类的复杂性降低,实现什么职责都有清晰明确的定义;
可读性高,复杂性降低,可读性自然就提高了;
可维护性提高,可读性提高了,那自然更容易维护了;
变更引起的风险降低,变更是必不可少的,如果接口的单一职责做得好,一个接口修改只对相应的实现类有影响,对其他的接口无影响,这对系统的扩展性、维护性都有非常大的帮助。
依赖倒置原则
实现上有如下要求:
- 高层模块(如类、模块、组件)应该依赖于抽象接口或抽象类,而不是具体的实现类。
- 抽象接口或抽象类不应该依赖于具体实现类
说白了,就是“面向接口编程”。
举个支付的例子,比如现在订单模块需要调用支付模块支付,目前采用的是支付宝支付。大致代码如下。
//支付模块
public class Alipay {
public void processPayment(double amount) {
// 使用支付宝支付
System.out.println("Using Alipay to process payment: ¥" + amount);
}
}
//订单模块
public class OrderProcessor {
// 处理订单逻辑
public void processOrder(double amount,Alipay alipay) {
System.out.println("Processing order...");
// 调用支付模块的方法进行支付
alipay.processPayment(amount);
// 完成订单处理逻辑
System.out.println("Order processed successfully.");
}
}
在这段代码中,订单模块直接使用的是AliPay这个实现类来完成支付的,但是如果后面我的业务有变化,加入了微信支付,PayPal支付,改动是不是就很大呢?所以我们需要对上面设计做改良
//定义支付的接口
public interface PaymentService {
void processPayment(double amount);
}
//支付的实现类,这里是阿里的支付宝支付
public class AlipayPaymentService implements PaymentService {
@Override
public void processPayment(double amount) {
// 使用支付宝支付处理逻辑
System.out.println("Using Alipay to process payment: ¥" + amount);
}
}
再来看订单模块,调用支付模块
public class OrderProcessor {
private PaymentService paymentService;
public OrderProcessor(PaymentService paymentService) {
this.paymentService = paymentService;
}
public void processOrder(double amount) {
// 处理订单逻辑
System.out.println("Processing order...");
// 调用支付模块的方法进行支付
paymentService.processPayment(amount);
// 完成订单处理逻辑
System.out.println("Order processed successfully.");
}
}
通过这样的设计,订单处理模块 OrderProcessor 不依赖于具体的支付模块实现类,而是依赖于抽象的 PaymentService 接口。这样,我们可以轻松地替换支付模块的具体实现,而无需修改订单处理模块的代码。
通过依赖倒置原则,我们实现了模块之间的解耦,使得系统更加灵活和可扩展。无论是使用PayPal支付还是支付宝支付,订单处理模块的代码都不需要修改。
接口隔离原则
定义:实现类不应该依赖它不需要的接口,或者说类间的依赖关系应该建立在最小的接口上。
举个支付的例子
public interface PaymentProcessor {
//处理支付
void processPayment();
//退款
void refund();
}
接下来看2种不同的支付方式实现这个接口
public class AlipayPaymentProcessor implements PaymentProcessor {
public void processPayment() {
// Alipay支付逻辑
}
public void refund() {
// Alipay退款逻辑
}
}
public class CreditCardPaymentProcessor implements PaymentProcessor {
public void processPayment() {
// 信用卡支付逻辑
}
public void refund() {
throw new UnsupportedOperationException("Credit card payment does not support refund.");
}
}
这个例子中,信用卡支付不支持退款操作,但仍然需要实现refund方法,这样就不符合接口隔离原则。这个demo可能并不是很恰当,但是表达的意思是实现类最好不要去实现接口中不必要的方法。
使用多个隔离的接口,比使用单个接口要好,降低类之间的耦合度。强调的是接口设计粒度的把控。
迪米特法则
有一个电商平台,包括用户(User)、商家(Merchant)和订单(Order)三个类。现有的设计如下:
// 订单类
public class Order {
private User user;
private Merchant merchant;
public Order(User user, Merchant merchant) {
this.user = user;
this.merchant = merchant;
}
public void createOrder() {
System.out.println("User " + user.getName() + " creates an order from Merchant " + merchant.getName());
}
}
订单类直接依赖于商家类,这样的设计违反了迪米特法则。订单类应该只关注订单本身的操作,而不应该直接依赖于商家类。如果商家类发生变化,订单类也需要相应地进行修改,导致耦合性增加。
改进的方式是引入一个中间类(购物车类)来管理用户与商家之间的关系。订单类只需要依赖于中间类,而不需要直接依赖于商家类。
改进后的设计 如下:
public class Cart {
private User user;
private Merchant merchant;
public Cart(User user, Merchant merchant) {
this.user = user;
this.merchant = merchant;
}
// 其他与购物车相关的方法
}
public class Order {
private Cart cart;
public Order(Cart cart) {
this.cart = cart;
}
public void createOrder() {
System.out.println("User " + cart.getUser().getName() + " creates an order from Merchant " + cart.getMerchant().getName());
// 其他订单相关的操作
}
}
遵循迪米特法则,可以降低类之间的耦合度,提高代码的可维护性和扩展性。
开闭原则(个人认为最难完全做到的)
定义;模块设计时,应该对扩展开放,对修改关闭。简单来说,应该通过扩展现有的代码来实现新的功能,而不是修改已有的代码。
开闭原则的核心思想是通过抽象和多态来实现可扩展性。我们应该将系统中的不变部分抽象出来,形成稳定的抽象接口或基类,而将可变的部分留给具体的实现类去扩展。当需要添加新的功能时,我们只需要添加新的实现类,并基于抽象接口进行编程,而不需要修改原有的代码。
举例说明
现在电商平台需要实现商品促销的功能。目前,我们只有一种促销策略,即打折促销。
//商品类
public class Product {
private double price; //价格
private double discount; //折扣比例
public Product(double price, double discount) {
this.price = price;
this.discount = discount;
}
//计算实际价格
public double calculateFinalPrice() {
return price * discount;
}
}
现有的设计确实满足打折促销的策略,但是现在要新增一种满减促销的策略,这种设计明显不符合要求了,因为这需要再次修改商品类的代码。
这个demo明显不符合开闭原则,我们应该通过扩展而不是修改现有的代码来添加新的功能。接下来我们看下修改后的代码
//促销策略接口
public interface PromotionStrategy {
//计算促销价格
double calculatePromotionPrice(double price);
}
//满减促销策略
public class FullReductionPromotionStrategy implements PromotionStrategy {
@Override
public double calculatePromotionPrice(double price) {
// 实现满减促销逻辑
// ...
return price;
}
}
//折扣促销策略
public class DiscountPromotionStrategy implements PromotionStrategy {
@Override
public double calculatePromotionPrice(double price) {
// 实现折扣促销逻辑
// ...
return price;
}
}
商品类只需要关注商品价格和促销策略就能得到实际的价格
public class Product {
private double price;
private PromotionStrategy promotionStrategy;
public Product(double price, PromotionStrategy promotionStrategy) {
this.price = price;
this.promotionStrategy = promotionStrategy;
}
public double calculateFinalPrice() {
return promotionStrategy.calculatePromotionPrice(price);
}
}
在这个demo中,当需要添加新的促销策略时,只需要实现新的促销策略类,并将其传递给商品对象即可,而不需要修改现有的代码。这样就符合了开闭原则,使系统更加灵活和可扩展。
里氏替换原则
定义:要求子类对象能够替换父类对象并且不会破坏程序的正确性。换句话说,子类应该能够在不影响程序正确性的前提下扩展或修改父类的行为。
里氏替换原则可以通过以下方式来遵循:
1. 子类必须完全实现父类的抽象方法。子类不能删除父类中的任何方法,否则会破坏程序的正确性。
2. 子类可以有自己的特定实现,但不能修改父类的实现。子类可以通过重写父类的方法来实现自己的行为,但不能修改父类方法的预期行为。
3. 子类的前置条件(输入参数)必须与父类的前置条件相同或更宽松。子类方法的输入参数类型必须与父类方法的输入参数类型相同或是其子类型。
4. 子类的后置条件(返回结果)必须与父类的后置条件相同或更严格。子类方法的返回结果类型必须与父类方法的返回结果类型相同或是其父类型。
举个电商平台计算商品价格的例子
class Product {
// 计算商品的价格
public double calculatePrice() {
return 0;
}
}
//实物,按重量称的这种
class PhysicalProduct extends Product {
private double weight;
private double price;
public void setWeight(double weight) {
this.weight = weight;
}
public void setPrice(double price) {
this.price = price;
}
@Override
public double calculatePrice() {
return weight * price;
}
}
//数字藏品
class DigitalProduct extends Product {
private double size;
private double price;
public void setSize(double size) {
this.size = size;
}
public void setPrice(double price) {
this.price = price;
}
@Override
public double calculatePrice() {
return size * price;
}
}
里氏替换原则要求子类可以替换基类并且不会破坏原有的功能。但在这个示例中,子类 PhysicalProduct 和 DigitalProduct 的价格计算方式不同,无法在不破坏原有功能的情况下替换基类 Product 。
为了遵守里氏替换原则,我们可以重新设计类的结构。一个解决方案是将价格计算方法放在基类中,这样就能够在不破坏原有功能的情况下替换基类。
以下是一个修改后的示例:
abstract class Product {
protect double price;
public void setPrice(double price) {
this.price = price;
}
// 定义抽象方法,让子类实现自己的价格计算逻辑
public abstract double calculatePrice();
}
class PhysicalProduct extends Product {
private double weight; //子类特有属性
public void setWeight(double weight) {
this.weight = weight;
}
@Override
public double calculatePrice() {
//子类自己实现计算价格
return weight * price;
}
}
class DigitalProduct extends Product {
private double size; //子类特有属性
public void setSize(double size) {
this.size = size;
}
@Override
public double calculatePrice() {
return size * price;
}
}
上面修改后的demo中,我们确保了子类可以替换基类而不会破坏原有的功能,从而遵守了里氏替换原则。还有一种更复杂的优化方案,使用策略模式来实现不同的计算逻辑。这样,子类只需要提供特定的属性,而不需要实现自己的计算逻辑。
创建商品抽象类和具体的商品
abstract class Product {
protected double price; //价格
private PriceStrategy priceStrategy; //使用的策略
public void setPrice(double price) {
this.price = price;
}
public void setPricingStrategy(PriceStrategy priceStrategy) {
this.priceStrategy = priceStrategy;
}
//使用策略计算价格
public double calculatePrice() {
return priceStrategy.calculatePrice(this);
}
}
//暂时不需要具体的实现
class PhysicalProduct extends Product {
}
//暂时不需要具体的实现
class DigitalProduct extends Product {
}
策略接口和不同的策略实现类
interface PriceStrategy {
double calculatePrice(Product product);
}
class WeightPriceStrategy implements PriceStrategy {
private double weight;
public WeightPriceStrategy(double weight) {
this.weight = weight;
}
@Override
public double calculatePrice(Product product) {
return weight * product.price;
}
}
class SizePriceStrategy implements PriceStrategy {
private double size;
public SizePriceStrategy(double size) {
this.size = size;
}
@Override
public double calculatePrice(Product product) {
return size * product.price;
}
}
策略模式的调用
public static void main(String[] args) {
Product physicalProduct = new PhysicalProduct();
physicalProduct.setPrice(10.0);
//创建对应的策略
PriceStrategy weightPriceCalculator = new WeightPriceStrategy(2.5);
//设置对应的策略
physicalProduct.setPricingStrategy(weightPriceCalculator);
double physicalPrice = physicalProduct.calculatePrice();
System.out.println("Physical product price: $" + physicalPrice);
}
小结:遵循里氏替换原则可以提高代码的可扩展性和可维护性。它使得代码更加灵活,可以在不破坏现有功能的情况下进行扩展和修改。同时,它也有助于降低代码的耦合性,提高代码的重用性。
总结
1、单一职责原则
它指出一个类应该只有一个引起变化的原因。换句话说,一个类应该只负责一项职责或功能。 单一职责原则的核心思想是将一个类的职责限制在一个合理的范围内,避免一个类承担过多的责任。这样可以提高代码的可维护性、可扩展性和可重用性。当一个类只有一个职责时,它的变化引起的风险和影响也会更小。
2、开闭原则
开闭原则的意思是:对扩展开放,对修改关闭。在程序需要进行拓展的时候,不能去修改原有的代码。想要达到这样的效果,我们需要使用接口和抽象类。
3、里氏代换原则
里氏代换原则是说,任何基类可以出现的地方,子类一定可以出现。LSP 是继承复用的基石,只有当派生类可以替换掉基类,且软件单位的功能不受到影响时,基类才能真正被复用,而派生类也能够在基类的基础上增加新的行为。里氏代换原则是对开闭原则的补充。实现开闭原则的关键步骤就是抽象化,而基类与子类的继承关系就是抽象化的具体实现,所以里氏代换原则是对实现抽象化的具体步骤的规范。
4、依赖倒转原则
这个原则是开闭原则的基础,具体内容:针对接口编程,依赖于抽象而不依赖于具体。
5、接口隔离原则
这个原则的意思是:使用多个隔离的接口,比使用单个接口要好。它还有另外一个意思是:降低类之间的耦合度。
6、迪米特法则,又称最少知道原则
最少知道原则是指:一个实体应当尽量少地与其他实体之间发生相互作用,使得系统功能模块相对独立。