SOLID 原则

SOLID 原则是由著名的鲍勃大叔(Robert J. Martin)在其论文里提出的,鲍勃大叔在架构领域建树颇多,《代码整洁之道》、《架构整洁之道》都出资他手。

SOLID 是五个面向对象编程和设计的基本原则的首字母缩写,它们分别是:

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

单一职责原则

单一职责原则是说,一个类应该只有一个引起它变化的原因。这个原则的主要目的是降低类的复杂性,并提高类的可读性和可维护性。

比如说,有一个类叫做UserManager,它负责处理用户的添加和删除,同时也负责发送通知。这样的设计就违反了单一职责原则。因为UserManager类有两个改变的理由:用户管理的逻辑变化和通知的逻辑变化。

如果用户管理的逻辑发生变化,比如说需要添加用户的年龄验证,那么你需要修改UserManager类。如果通知的逻辑发生变化,比如说需要增加短信通知,你也需要修改UserManager类。这样的设计使得UserManager类变得复杂,难以理解和维护。

如果遵守单一职责原则,应该将用户管理和通知分离到两个类中,比如UserManager类只负责用户的添加和删除,NotificationManager类只负责发送通知。这样,当用户管理的逻辑变化时,你只需要修改UserManager类,当通知的逻辑变化时,你只需要修改NotificationManager类。这样的设计使得每个类都只有一个改变的理由,降低了类的复杂性,提高了类的可读性和可维护性。

在函数层面,单一职责原则也是适用。

函数应该做一件事情,做好一件事情。如果一个函数尝试去做多于一件的事情,它将会变得复杂,难以理解和维护。

举个例子,如果你有一个函数叫做handleUser,它负责验证用户输入,处理用户请求,并且记录日志。这个函数就违反了单一职责原则,因为它有三个改变的理由:验证逻辑的变化,请求处理的变化,和日志记录的变化。

func handleUser(user User) {
    // 验证用户输入
    // 处理用户请求
    // 记录日志
}

如果遵守单一职责原则,你应该将这三个功能分离到三个函数中,比如validateUser函数只负责验证用户输入,processUser函数只负责处理用户请求,logUser函数只负责记录日志。这样,每个函数都只有一个改变的理由,降低了函数的复杂性,提高了函数的可读性和可维护性。

func validateUser(user User) {
    // 验证用户输入
}

func processUser(user User) {
    // 处理用户请求
}

func logUser(user User) {
    // 记录日志
}

handleUser函数可以被重构为只负责调用其他处理具体任务的函数:

func handleUser(user User) {
    validateUser(user)
    processUser(user)
    logUser(user)
}

handleUser函数的职责就变成了协调validateUserprocessUserlogUser这三个函数。这样,如果验证用户输入的逻辑需要变更,你只需要修改validateUser函数;如果处理用户请求的逻辑需要变更,你只需要修改processUser函数;如果记录日志的逻辑需要变更,你只需要修改logUser函数。每个函数都只有一个改变的理由,遵守了单一职责原则。

这样的设计不仅使得代码更加清晰,也提高了代码的可维护性。

常见陷阱和反模式

我们将以一个简单的书店发票程序的代码为例。首先定义一个用于发票的书籍类。

class Book {
	String name;
	String authorName;
	int year;
	int price;
	String isbn;

	public Book(String name, String authorName, int year, int price, String isbn) {
		this.name = name;
		this.authorName = authorName;
		this.year = year;
        this.price = price;
		this.isbn = isbn;
	}
}

这是一个带有一些字段的简单书籍类。没什么特别的。我没有将字段设为私有,这样我们就不需要处理 getter 和 setter,而是可以专注于逻辑。

现在让我们创建发票类,它将包含创建发票和计算总价的逻辑。现在,假设我们的书店只卖书,不卖其他东西。


public class Invoice {

	private Book book;
	private int quantity;
	private double discountRate;
	private double taxRate;
	private double total;

	public Invoice(Book book, int quantity, double discountRate, double taxRate) {
		this.book = book;
		this.quantity = quantity;
		this.discountRate = discountRate;
		this.taxRate = taxRate;
		this.total = this.calculateTotal();
	}

	public double calculateTotal() {
	        double price = ((book.price - book.price * discountRate) * this.quantity);

		double priceWithTaxes = price * (1 + taxRate);

		return priceWithTaxes;
	}

	public void printInvoice() {
            System.out.println(quantity + "x " + book.name + " " +          book.price + "$");
            System.out.println("Discount Rate: " + discountRate);
            System.out.println("Tax Rate: " + taxRate);
            System.out.println("Total: " + total);
	}

        public void saveToFile(String filename) {
	// Creates a file with given name and writes the invoice
	}

}

这是我们的发票类。它还包含一些有关发票的字段和 3 种方法:

  • calculateTotal方法,计算总价,
  • printInvoice方法,应该将发票打印到控制台,以及
  • saveToFile方法,负责将发票写入文件。

在阅读下一段之前,你应该花一点时间思考一下这个类设计有什么问题。

好的,这是怎么回事?我们的类在多个方面违反了单一职责原则。

第一个违规行为是printInvoice方法,该方法包含我们的打印逻辑。SRP 规定我们的类应该只有一个更改原因,并且该原因应该是我们类的发票计算发生变化。

但在这种架构下,如果我们想改变打印格式,就需要改变类。这就是为什么我们不应该将打印逻辑与业务逻辑混合在同一个类中。

我们的类中还有另一个违反 SRP 的方法:saveToFile方法。将持久性逻辑与业务逻辑混合在一起也是一个极其常见的错误。

不要只考虑写入文件——它可以保存到数据库、进行 API 调用或进行与持久性相关的其他操作。

那么您可能会问,我们如何修复此打印功能。

我们可以为我们的打印和持久逻辑创建新的类,这样我们就不再需要为了这些目的修改发票类。

我们创建 2 个类,InvoicePrinter和**InvoicePersistence,**并移动方法。

public class InvoicePrinter {
    private Invoice invoice;

    public InvoicePrinter(Invoice invoice) {
        this.invoice = invoice;
    }

    public void print() {
        System.out.println(invoice.quantity + "x " + invoice.book.name + " " + invoice.book.price + " $");
        System.out.println("Discount Rate: " + invoice.discountRate);
        System.out.println("Tax Rate: " + invoice.taxRate);
        System.out.println("Total: " + invoice.total + " $");
    }
}
public class InvoicePersistence {
    Invoice invoice;

    public InvoicePersistence(Invoice invoice) {
        this.invoice = invoice;
    }

    public void saveToFile(String filename) {
        // Creates a file with given name and writes the invoice
    }
}

现在我们的类结构遵循单一职责原则,每个类负责应用程序的一个方面。太棒了!

开放封闭原则

开放封闭原则要求类应该对扩展开放,对修改关闭。

修改意味着改变现有类的代码,扩展意味着添加新的功能。

因此,该原则的意思是:我们应该能够在不触碰类的现有代码的情况下添加新功能。这是因为每当我们修改现有代码时,我们都冒着产生潜在错误的风险。因此,如果可能的话,我们应该避免触碰经过测试且可靠的(大多数)生产代码。

但是你可能会问,我们如何在不触及类的情况下添加新功能。这通常是借助接口和抽象类来完成的。

现在我们已经介绍了该原理的基础知识,让我们将其应用到我们的发票应用程序中。

假设我们的老板来找我们,说他们希望将发票保存到数据库中,以便我们轻松搜索。我们认为好的,老板,这很容易,请给我一点时间!

我们创建数据库,连接到它,并向我们的InvoicePersistence类添加一个保存方法:

public class InvoicePersistence {
    Invoice invoice;

    public InvoicePersistence(Invoice invoice) {
        this.invoice = invoice;
    }

    public void saveToFile(String filename) {
        // Creates a file with given name and writes the invoice
    }

    public void saveToDatabase() {
        // Saves the invoice to database
    }
}

不幸的是,作为书店的懒惰开发人员,我们没有设计出将来易于扩展的类。因此,为了添加此功能,我们修改了InvoicePersistence类。

如果我们的类设计遵循开放-封闭原则,我们就不需要改变这个类。

因此,作为书店懒惰但聪明的开发人员,我们看到了设计问题并决定重构代码以遵循该原则。

interface InvoicePersistence {

    public void save(Invoice invoice);
}

我们将InvoicePersistence**的类型改为Interface,并添加一个save方法,每个持久化类都会实现这个save方法。

public class DatabasePersistence implements InvoicePersistence {

    @Override
    public void save(Invoice invoice) {
        // Save to DB
    }
}
public class FilePersistence implements InvoicePersistence {

    @Override
    public void save(Invoice invoice) {
        // Save to file
    }
}

所以我们的班级结构现在看起来像这样:

solid

现在我们的持久性逻辑很容易扩展。如果我们的老板要求我们添加另一个数据库,并且拥有 2 种不同类型的数据库,如 MySQL 和 MongoDB,我们可以轻松做到这一点。

您可能认为我们可以创建多个没有接口的类,并为所有类添加保存方法。

但是假设我们扩展了我们的应用程序并且有多个持久性类,如InvoicePersistenceBookPersistence,并且我们创建一个管理所有持久性类的PersistenceManager类:

public class PersistenceManager {
    InvoicePersistence invoicePersistence;
    BookPersistence bookPersistence;

    public PersistenceManager(InvoicePersistence invoicePersistence,
                              BookPersistence bookPersistence) {
        this.invoicePersistence = invoicePersistence;
        this.bookPersistence = bookPersistence;
    }
}

现在,我们可以借助多态性将任何实现InvoicePersistence接口的类传递给此类。这就是接口提供的灵活性。

里氏替换原则

里氏替换原则指出子类应该可以替换其基类。

这意味着,假设 B 类是 A 类的子类,我们应该能够将 B 类的对象传递给任何需要 A 类对象的方法,并且在这种情况下该方法不应该给出任何奇怪的输出。

这是预期的行为,因为当我们使用继承时,我们假设子类继承了超类拥有的所有内容。子类扩展了行为,但从未缩小其范围。

因此,当一个类不遵循这个原则时,就会导致一些难以检测的严重错误。

里氏原则很容易理解,但在代码中很难发现。让我们看一个例子。


class Rectangle {
	protected int width, height;

	public Rectangle() {
	}

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

	public int getWidth() {
		return width;
	}

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

	public int getHeight() {
		return height;
	}

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

	public int getArea() {
		return width * height;
	}
}

我们有一个简单的 Rectangle 类和一个返回矩形面积的getArea函数。

现在我们决定为正方形创建另一个类。您可能知道,正方形只是一种特殊类型的矩形,其宽度等于高度。

class Square extends Rectangle {
	public Square() {}

	public Square(int size) {
		width = height = size;
	}

	@Override
	public void setWidth(int width) {
		super.setWidth(width);
		super.setHeight(width);
	}

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

我们的 Square 类扩展了 Rectangle 类。我们在构造函数中将高度和宽度设置为相同的值,但我们不希望任何客户端(在代码中使用我们类的人)以违反 square 属性的方式更改高度或重量。

因此,我们重写 setter,以便当其中一个属性发生变化时同时设置两个属性。但这样做却违反了里氏替换原则。

让我们创建一个主类来对getArea函数进行测试。

class Test {

   static void getAreaTest(Rectangle r) {
      int width = r.getWidth();
      r.setHeight(10);
      System.out.println("Expected area of " + (width * 10) + ", got " + r.getArea());
   }

   public static void main(String[] args) {
      Rectangle rc = new Rectangle(2, 3);
      getAreaTest(rc);

      Rectangle sq = new Square();
      sq.setWidth(5);
      getAreaTest(sq);
   }
}

你们团队的测试人员刚刚拿出了测试函数getAreaTest,并告诉你,你的getArea函数未能通过方形物体的测试。

在第一个测试中,我们创建一个宽度为 2、高度为 3 的矩形,然后调用getAreaTest。输出结果与预期一致,为 20,但传入正方形时,结果出错。这是因为测试中对setHeight函数的调用也会设置宽度,导致输出结果出乎意料。

接口隔离原则

隔离意味着保持事物分离,接口隔离原则就是关于分离接口的。

该原则指出,多个客户端专用接口比一个通用接口更好。不应强迫客户端实现他们不需要的功能。

这是一个很容易理解和应用的原则,让我们看一个例子。

public interface ParkingLot {

	void parkCar(); // Decrease empty spot count by 1
	void unparkCar(); // Increase empty spots by 1
	void getCapacity(); // Returns car capacity
	double calculateFee(Car car); // Returns the price based on number of hours
	void doPayment(Car car);
}

class Car {

}

我们建立了一个非常简单的停车场模型。这是按小时收费的停车场类型。现在假设我们想要实现一个免费的停车场。

public class FreeParking implements ParkingLot {

	@Override
	public void parkCar() {

	}

	@Override
	public void unparkCar() {

	}

	@Override
	public void getCapacity() {

	}

	@Override
	public double calculateFee(Car car) {
		return 0;
	}

	@Override
	public void doPayment(Car car) {
		throw new Exception("Parking lot is free");
	}
}

我们的停车场界面由两部分组成:停车相关逻辑(停车、取车、获取容量)和支付相关逻辑。

但是它太具体了。正因为如此,我们的 FreeParking 类被迫实现与支付无关的方法。让我们分离或隔离接口。

solid2

我们现在已经将停车场分开了。有了这个新模型,我们甚至可以更进一步,将PaidParkingLot分开,以支持不同类型的付款。

现在我们的模型更加灵活、可扩展,并且客户端不需要实现任何不相关的逻辑,因为我们只在停车场界面提供与停车相关的功能。

依赖倒置原则

依赖倒置原则指出我们的类应该依赖于接口或抽象类,而不是具体的类和函数。

鲍勃大叔在他的中对这一原则进行了如下总结:

“如果 OCP 阐明了 OO 架构的目标,那么 DIP 则阐明了主要机制”。

这两个原则确实是相关的,我们之前在讨论开放封闭原则时就应用过这种模式。

我们希望我们的类可以扩展,所以我们重新组织了依赖关系,使其依赖于接口而不是具体的类。我们的 PersistenceManager 类依赖于 InvoicePersistence,而不是实现该接口的类。

参考

[1] solid-principles-explained-in-plain-english

[2] Design Principles And Patterns