目录
五大基本原则
单一职责原则(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 {@Overridepublic void save(Invoice invoice) {// Save to DB}
}
public class FilePersistence implements InvoicePersistence {@Overridepublic 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;}@Overridepublic void setWidth(int width) {super.setWidth(width);super.setHeight(width);}@Overridepublic 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 {@Overridepublic void parkCar() {}@Overridepublic void unparkCar() {}@Overridepublic void getCapacity() {}@Overridepublic double calculateFee(Car car) {return 0;}@Overridepublic 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等等即可。
这也是开闭原则的一种体现。当一个功能需求改变时,最好的结果是替换整个功能的实现(或者说做成策略),而不是牵一发动全身,一处修改,处处修改。
如有错误,欢迎指正。