目录
单一职责原则(The Single Responsibility Principle)
开闭原则(The Open-Closed Principle)
里氏替换原则(Liskov Substitution Principle)
接口隔离原则(Interface Segregation Principle)
依赖倒置原则(Dependency Inversion Principle)
五大基本原则
- 单一职责原则(The Single Responsibility Principle)
- 开闭原则(The Open-Closed Principle)
- 里氏替换原则(The Liskov Substitution Principle)
- 接口隔离原则(The Interface Segregation Principle)
- 依赖倒置原则(The Dependency Inversion Principle)
这里的原则可以理解为前辈们在无数实践下总结出来的经验,大部分场合都是适用的。
原则与原则之间并非孤立的存在,需要结合起来理解。
但不管怎么样,我们的目的是不变的:为了编写易于拓展、易于理解、可测试的代码。
本文中的代码示例大多出自:The SOLID Principles of Object-Oriented Programming Explained in Plain English
外加一些个人理解。
单一职责原则(The Single Responsibility Principle)
一个类只应该做一件事,因此这个类只有一个改变的理由。
其实可以这么理解:不要把鸡蛋放在一个篮子里。
每个类应该只实现一个大的功能点,不应该把很多功能全部糅杂在一个类里面。
例如有一个类Book
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;
}
}
还有一个类Invoice(发票),它有很多的功能,例如计算书本的总价、打印发票、保存到文件中等等。
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
}
}
上面的代码因为功能实现的比较简单,代码看起来还不是很长。
试想一下,如果每个方法的逻辑更加复杂,在public方法的基础上可能还要再加上一些private方法。随着复杂程度的提升,“需求变更”带来的威胁将越来越大。
“小张啊,我觉得你的计算规则太落后了,我这有个新的计算规则,你来实现下”,“存到文件还不够,我还想存到数据库里”。。。。。。
每一次需求变更都迫使你重新阅读这个类的代码。尤其是多人合作的时候,更容易出错。
归根揭底都是这个类太“累”了,承受了不属于这个年纪应该承受的压力。
这个时候就需要给这个类“减负”了。
这个“减负”的过程,也叫做“解耦”。
编写InvoicePrinter类用于打印发票
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 + " $");
}
}
编写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
}
}
类职责的单一化,也能带来其他的好处,例如每一个功能点都可以有多种实现策略,当需求变更时,只要切换策略即可。
开闭原则(The Open-Closed Principle)
类应该对拓展开放,对修改关闭
SRP原则可以让一个功能点的修改不影响到其他的功能点。
而开闭原则则有着更高一点要求,我们在新增方案时,可以复用之前的方案、可以拓展之前的方案,但是不能直接修改之前的方案。
通过拓展一个类的方式,来规避修改一个类可能带来的风险。
如上面的类InvoicePersistence:
假设需要新增一种基于数据库的存储方式,如果直接在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本身就是一个大的功能点——“存储”(save),但是这个功能点的实现方案有两种,所以我们可以先定义功能接口
interface InvoicePersistence {
public void save(Invoice invoice);
}
再分配实现两种方案
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
}
}
两种方案之间互相独立,互不影响。
调用方需要使用到发票存储功能时,只要持有InvoicePersistence类型的引用,即可借助Spring之类的对象管理工具快速完成策略的替换,调用方无需与具体的实现方案耦合。
说到底,这又是一种“解耦”,将实现方案与实现方案解耦、调用方(客户端)与具体服务解耦。
里氏替换原则(Liskov Substitution Principle)
子类(派生类)可以替换他们的基类
当我们定义一个子类时,通常是为了拓展基类的功能。“拓展”意味着保留原有的功能,在原有的功能基础上,加一些定制化的功能。但如果随意修改原有的功能,将会带来语义上的不明确,以及一些奇奇怪怪的bug。
以JDK中的List为例
List代表着一种特征,List种定义了很多的接口,用于维护这种特征。
所有List的实现类,都需要满足这种特征。不管是基于链表的LinkedList,还是基于数组的ArrayList,都是一种List。当客户端调用size()方法时,只能返回这个List的长度,而不是容量;调用get(int) 方法时,只能是获取指定下标的元素,至于获取方式可以定制化,数组的随机读取亦或者是链表的遍历读取都可以。
基类的方法(接口亦是),起到约束功能的作用,当子类覆盖(实现)父类方法时,可以采用不同的实现方式,但是总体方向不能变。
原文中有一个比较有意思的案例,虽说这个案例有些争议,但不影响我们学习这种思想。
在数学上,正方形是一种特殊的长方形。
定义一个类:Rectangle(长方形)
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;
}
}
定义一个类正方形Square,继承自Rectangle。
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);
}
}
测试类
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);
}
}
Square由于本身的特征约束(正方形四条边相等),height与width必须相等。
但是这种约束不应该放在set方法中维持,对于客户端来说,Square就是一种Rectangle。用多态来描述就是 Rectangle sq = new Square(),那么Square类型的对象也可以作为getAreaTest(Rectangle r)的合法传参,这个时候就可能得到意料之外的结果。
更好的方式是Square和Rectangle定义成两个独立的类。在现实世界中比较合理的关系,直接抽象成代码时,可能就没那么合理了。
关于长方形和正方形的抽象问题,颇有争议,有兴趣的自行研究下。
里氏替换原则,注重的是子类与父类之间的兼容性。子类继承父类的方法(功能),可以换一种实现方式,但必须达到同样的目的。
接口隔离原则(Interface Segregation Principle)
客户端不应该依赖它不需要的接口
接口隔离原则很好理解,以停车场ParkingLot为例:
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");
}
}
费用计算固定返回0、支付接口直接抛异常,这显然是一种不优雅的做法。究其根本,还是抽象的不够彻底,并非所有的停车场都是付费的,对于“付费”这一特征可以单独抽象出来。
搬个图
“接口”代表着一种特征,接口内所有的方法构成了这种特征。当特征无法适用于所有子类时,需要进行“隔离”,拆分成更多接口。粒度的细化有利于实现下面即将讲到的“依赖倒置”,也更加符合上面讲的SRP原则。
依赖倒置原则(Dependency Inversion Principle)
我们定义的类应该依赖于接口或者抽象类,不应该依赖具体的类
当我们平时开发时,习惯性定义一个XXXService,以及对应的实现方法XXXServiceImpl时,是否有想过为什么要这么做。
我曾经也有过这种疑问,当时不明白为什么每写一个service时,都要先定义接口,再写实现类。其实如果能保证需求不会变更,代码后续不会改动,对复用性没有要求,或者每个功能都只有一种实现方式,那也可以不定义接口。但现实往往......你懂的~
在Spring环境下,需要使用某个类的功能时,我们通常会这么写
@Autowired
private XXXService service;
这其实就是一种依赖倒置思想,当某个类与其他类产生联系时,最好只与接口(或抽象类)做绑定,只依赖接口,不依赖具体的实现类。
当经历需求变更时,可以在不修改调用方代码的前提下,替换掉之前的XXXServiceImpl,换成XXXServiceImpl2、XXXServiceImpl3等等即可。
这也是开闭原则的一种体现。当一个功能需求改变时,最好的结果是替换整个功能的实现(或者说做成策略),而不是牵一发动全身,一处修改,处处修改。
如有错误,欢迎指正。