软件设计有很多原则,比如软件设计上的 SOLID principle,单元测试中的 FIRST和AAA,代码实现上的 DRY principle 等。熟悉这些原则,可以把我们的经验上升到理论高度,有利于程序员的成长,也便于团队带头人和组员控制软件质量。
我们先介绍 SOLID 原则。SOLID 是下面几个英文词组的缩写
- Single-Responsibility principle (单一职责原则)
- Open-Close principle (开放扩展,封闭修改)
- Liskov substitution principle (Liskov 替换原则)
- Interface segregation principle (接口隔离原则)
- Dependency inversion principle (依赖反转原则)
Single-Responsibility principle (单一职责原则)
这个原则是指一个软件模块只应该负责一件事情。当规范修改的时候,我们只需要修改与规范相关的模块。软件模块不应该像瑞士军刀那样,什么都能干,而应该像厨房里刀具套装里的刀具一样各司其责。
比如下面的代码:
public class ProtocolTranslator
{
public String Translate(String content)
{
try {
// Translate the protocol
}
catch(Exception ex)
{
WriteLogToFile(ex.getMessage());
}
}
public void WriteLogToFile(String log)
{
// write log to file
}
}
上面的代码是一个协议翻译类,在协议翻译的过程中如果出现了异常,则把异常写入文件日志中。粗略看来这个类没有问题,但是如果我们需要把日志写入数据库,那么我么就需要改变代码。按照单一职责原则,这个类的设计就没有达到要求,因为日志规范的修改,确需要修改协议翻译类。为此我们可以引入专门的日志类来解决这个问题。代码如下:
public class ProtocolTranslator
{
private final Logger logger;
ProtocolTranslator(Logger logger)
{
this.logger = logger;
}
public String Translate(String content)
{
try {
// Translate the protocol
}
catch(Exception ex)
{
this.logger.log(ex);
}
}
}
如果再遇到日志相关的需求变更,我们只需要修改日志类就好了。
Open-Close principle (开放扩展,封闭修改)
开放扩展说的是我们设计的模块或者类只有在收到新的需求的时候才会增加新的功能,只有在发现了缺陷 (bug) 的时候才需要修改。现代软件往往要求单元测试达到一定覆盖率,如果不遵从OCP,那么光重新修改单元测试测试就会产生巨大的工作量。如果我们增加新功能都要大量修改我们的单元测试代码,那么就说明我们需要引入OCP原则。这里说的增加新功能是指通常通过继承类来实现的。
假设我们有下面的鸡和狗的类:
public class Dog
{
}
public class Chicken
{
}
public class AnimalCounter
{
public int countFeet(ArrayList<Object> animals)
{
int count = 0;
for(Object animal: animals)
{
if(animal instanceof Dog)
count += 4;
else if(animal instanceof Chicken)
count +=2;
}
return count;
}
}
上面这个程序 AnimalCounter 负责统计动物的腿数,如果我们要增加一种新动物比如 Sheep,我们就需要给 AnimalCounter 的 countFeet 函数增加一个判断,判断数组中是不是有 Sheep 实例,这就是说当有新的需求来的时候,我们得修改 AnimalCounter 代码,而不是扩展它。
下面的代码可以解决这个问题。我们使用了一个接口,鸡类和狗类都继承了这个接口,在 AnimalCounter 中我们只要调用这个接口就可以知道动物有多少只脚了。无论是再有绵羊类或者是昆虫类,它们只要继承了这个接口,AnimalCounter 都可以计算出动物的总脚数。也就是我们通过扩展 IAnimal 接口就可以满足需求,而不用修改 AnimalCounter 类。
public interface IAnimal{
int getFeet();
}
public class Dog implements IAnimal
{
public int getFeet() { return 4; }
}
public class Chicken
{
public int getFeet() { return 2; }
}
Public class AnimalCounter
{
public int countFeet(ArrayList<IAnimal> animals)
{
int count = 0;
for(IAnimal animal: animals)
{
count += animal.getFeet();
}
return count;
}
}
Liskov substitution principle (Liskov 替换原则)
这条原则是说,程序中的对象可以被它的子类的实例替换掉而不会影响程序的正确性。这个原则跟契约式编程 (design by contract) 非常像 。
来看下面的代码。我们定义了一个类叫 Bird,这个接口有四个方法,然后我们有一个天鹅类,一个鸡类。可以看到,Bird 的四个方法用 Swan 类来代替是没有问题的,但是用 Chicken 类来代替当调用到 fly 方法的时候就会抛出异常。这个就不符合 Liskov 原则,因为作为 Bird 类的子类的 Chicken 类没有做到替换父类 Bird 类而不影响程序运行。
public class Bird {
String setName();
String getName();
void playSound()
{
...
}
void fly()
{
...
}
}
public class Swan extends Bird{
String setName() { ... }
String getName() { ... }
void playSound() {
playSwanSound();
}
}
public class Chicken extends Bird {
String setName() { ... }
String getName() { ... }
void playSound() {
playChickenSound();
}
void fly() {
throw new Exception();
}
}
解决方法是拆分Bird类的功能,因为家禽是不会飞的。
Interface segregation principle (接口隔离原则)
接口隔离原则说的是类不应该被强迫去依赖它用不到的方法。大的原则是很多小而精的接口,要好于一个大一统的接口。比如下面的 ICar 接口,我们不应该为了大一统把加油 (fuel) 和 (Charge) 充电都放在里面,因为烧汽油的汽车才需要加油,使用电池驱动的电动车才需要充电。如果子类继承了父类中用不到的方法,子类也会打破上面的 Liskov 原则,也不利于将来的重构和优化。
public interface ICar {
void fuel(int litreAmount);
void charge(int kilowatt);
}
public GasCar implements ICar {
void fuel(int litreAmount){
...
}
void charge(int kilowatt) {
// 汽油车不需要充电
throw new Exception();
}
}
public EV implements ICar {
void fuel(int litreAmount){
// 电动车不需要加油
throw new Exception();
}
void charge(int kilowatt) {
...
}
}
正确的设计,应该设计两个接口。汽车实现汽车的接口,电动车实现电动车的接口,混合动力汽车既可以充电也可以加油,所以需要同时实现汽车和电动车接口。
public IGasCar {
void fuel(int litreAmount);
}
public IEV {
void charge(int kilowatt);
}
public GasCar implements IGasCar {
void fuel(int litreAmount){
...
}
}
public EV implements IEV {
void charge(int kilowatt) {
...
}
}
// 混合动力汽车
public Hybrid implements IGasCar, IEV {
void fuel(int litreAmount){
...
}
void charge(int kilowatt) {
...
}
}
Dependency inversion principle (依赖反转原则)
依赖反转说了两点:
- 高层模块不应该依赖低层模块,双方应该依赖抽象。
- 抽象不应该依赖细节,而细节应该依赖抽象。
听起来很绕口,不过这个确实是面向对象编程里解决紧耦合问题最重要的原则之一。通常的解决方案就是大名鼎鼎的依赖注入!
下面的代码的任务是打印一个指定路径的文件,打印完成后发出 email。这个代码就违反了依赖反转原则,所有的 new 语句处都表示高层模块需要知道低层模块的细节,比如 Program 类就需要如何生成 PrinterService 和 EMailService,PrinterService 和 EMailService 的功能也没有被抽象出来。这样程序的功能在需要重构、扩展或者替换时高层模块和低层模块都需要知道对方的细节。
public Program {
public static void main(String[] args) {
var filePath = args[0];
var printerService = new PrinterService();
printerService.print(filePath);
var emailService = new EMailService();
emailService.send("[email protected]", "File printed", filePath);
}
}
public class PrinterService {
final Logger logger;
PrinterService()
{
this.logger = new Logger();
}
void print(String filePath) {
// process print task
...
this.logger.log(filePath + " printed");
}
}
public class Logger {
public log(String content)
{
System.out.println(content);
}
}
public class EmailService {
public void Send(String email, String subject, String content)
{
System.out.println("Email %s has been sent to %s. ", subject, email);
}
}
解决上面的方法可以是依赖注入,也可以通过类工厂的方法来解决。由于篇幅有限,我们在这里使用类工厂来展示解决方案。首先我们抽象出我们用到的组件的接口,然后我们通过类工厂来实现这些接口,最后通过类工厂来解决依赖问题。
下面是我们抽象出来的接口:
public inteface IPrinterService {
void print(String filePath);
}
public interface ILogger {
void log(String content);
}
public interface IEmailService {
void send(String email, String subject, String content);
}
下面是类工厂的代码,虽然看上去很简单,但是通过类工厂,我们就解决了抽象到实现细节的问题,这使我们的业务逻辑独立于我们的依赖项。依赖项可以来自外部文件,对 Java 来说就是不同的 JAR 文件,对 .Net 来说可以是不同的 DLL。
public class Factory {
public static IPrinterService CreatePrinterService()
{
return new PrinterService();
}
public static ILogger CreateLogger() {
return new Logger();
}
public static IEmailService CreateEmailService() {
return new EmailService();
}
}
下面是使用了类工厂以后的业务逻辑代码:
public Program {
public static void main(String[] args) {
var filePath = args[0];
IPrinterService printerService = Factory.CreatePrinterService();
printerService.print(filePath);
IEmailService emailService = Factory.CreateEmailService();
emailService.send("[email protected]", "File printed", filePath);
}
}
public class PrinterService implements IPrinterService {
final Logger logger;
PrinterService()
{
this.logger = Factory.CreateLogger();
}
void print(String filePath) {
// process print task
...
this.logger.log(filePath + " printed");
}
}
public class Logger implements ILogger {
public log(String content)
{
System.out.println(content);
}
}
public class EmailService implements EmailService {
public void Send(String email, String subject, String content)
{
System.out.println("Email %s has been sent to %s. ", subject, email);
}
}
结论
SOLID 是面向对象设计中5个重要原则的缩写。这5个原则可以帮助我们实现软件高内聚,低耦合的目标。到目前为止,还没有编译器或者软件设计工具能帮助我们自动应用这些原则,我们还是需要通过探索和实践才能掌握和应用它们。
在接下来的文章中,我们还会讨论 FIRST,AAA,DRY 等经常提到的软件设计原则。希望能对大家有所帮助。