设计原则之【开放封闭原则】


设计原则是指导我们代码设计的一些经验总结,也就是“心法”;面向对象就是我们的“武器”;设计模式就是“招式”。

以心法为基础,以武器运用招式应对复杂的编程问题。

表妹今天上班又忘记打卡了

表妹:哥啊,我真的是一点记性都没有

我:发生什么事啦?

表妹:今天上班又忘记打卡了,又是白打工的一天,做什么事都提不起劲来。


你看,传统的上下班打卡制,这种模式将按时上下班作为考核指标之一,虽然强化了企业的管理,但是却限制了员工的时间自由,每个员工的情况和工作状态都不同,强制的上班时间容易导致员工为了应付打卡而打卡,实则工作效率却不高。

按时上下班其实不是老板希望达到的目的,老板希望的是,所有员工的绩效达标,最终企业能够盈利,而上下班打卡制只不过是为了达到这一目标的其中一个方法而已。

明确了将绩效作为考核指标。那么,绩效至少达标,这个是不可以修改的,在这个基础上,员工的上下班时间是可以自由安排的,这样就可以提高员工的生产效率了。这就是弹性上班制,对业绩成效的修改关闭,而对时间制度扩展的开放。

你看,这不就是我们软件开发中的开放-封闭原则嘛。


是说软件实体(类、模块、函数等)应该可以扩展,但是不可以修改。

这是一条最难理解和掌握,但是又最有用的设计原则。

之所以说难理解,是因为,“怎样的代码改动才被定义为扩展?怎样的代码改动才被定义为修改?怎样才算满足开闭原则?修改代码就一定违反了开闭原则吗?”等问题。

之所以说难掌握,是因为,“如何做到对扩展开放,修改封闭?,如何在项目中灵活地应用开闭原则,在保证扩展性的同时又不影响代码的可读性?”等问题。

之所以说最有用,是因为,扩展性是代码质量最重要的衡量标准之一。在23种经典设计模式之中,大部分设计模式都是为了解决代码的扩展性问题而存在的。

如何理解“对扩展开放、修改关闭”?

比如,书店销售图书。

图书有三个属性:书名、价格和作者。IBook是获取图书三个属性的接口,如下所示:

 1 public interface IBook { 
 2     // 图书的名称 
 3     public String getName(); 
 4 ?
 5     // 图书的售价 
 6     public int getPrice(); 
 7 ?
 8     // 图书的作者 
 9     public String getAuthor(); 
10 } 

小说类图书NovelBook是一个具体的实现类,如下所示:

 1 public class NovelBook implements IBook { 
 2     // 图书的名称 
 3     private String name; 
 4 ?
 5     // 图书的价格 
 6     private int price; 
 7 ?
 8     // 图书的作者 
 9     private String author; 
10 ?
11     // 通过构造函数传递书籍数据 
12     public NovelBook(String _name,int _price,String _author){ 
13         this.name = _name; 
14         this.price = _price; 
15         this.author = _author; 
16     } 
17 ?
18     // 获得作者是谁 
19     public String getAuthor() { 
20         return this.author; 
21     } 
22 ?
23     // 获得书名 
24     public String getName() { 
25         return this.name; 
26     } 
27 ?
28     // 获得图书的价格 
29     public int getPrice() {
30         return this.price; 
31     } 
32 } 

接下来,我们看一下,书店是如何销售图书的:

 1 public class BookStore { 
 2     private final static ArrayList bookList = new ArrayList(); 
 3 ?
 4     // 静态模块初始化,项目中一般是从持久层初始化产生 
 5     static{ 
 6         bookList.add(new NovelBook("天龙八部",3200,"金庸")); 
 7         bookList.add(new NovelBook("巴黎圣母院",5600,"雨果")); 
 8         bookList.add(new NovelBook("悲惨世界",3500,"雨果")); 
 9         bookList.add(new NovelBook("平凡的世界",4300,"路遥")); 
10     } 
11 ?
12     //模拟书店买书 
13     public static void main(String[] args) { 
14         NumberFormat formatter = NumberFormat.getCurrencyInstance(); 
15         formatter.setMaximumFractionDigits(2); 
16         System.out.println("------------书店中的小说类图书记录如下:---------------------"); 
17         for(IBook book:bookList){ 
18             System.out.println("书籍名称:" + book.getName()+"\t书籍作者:" + 
19             book.getAuthor()+  "\t书籍价格:"  +  formatter.format(book.getPrice()/100.0)+"元"); 
20         } 
21     } 
22 } 

注:在BookStore中声明了一个静态模块,实现了数据的初始化,这部分应该是从持久层产生的,由持久层工具进行管理。

运行结果如下:

------------书店中的小说类图书记录如下:--------------------- 
书籍名称:天龙八部  书籍作者:金庸  书籍价格:¥32.00元 
书籍名称:巴黎圣母院 书籍作者:雨果  书籍价格:¥56.00元 
书籍名称:悲惨世界  书籍作者:雨果  书籍价格:¥35.00元 
书籍名称:平凡的世界 书籍作者:路遥 书籍价格:¥43.00元 

 

但是,最近书店的小说类图书销量下滑很严重,所以,书店希望通过打折来刺激消费:所有40元及以上的小说类图书8折销售,40元以下的按9折销售。

对于已经投产的项目来说,这就是一个变化,那么,我们应该怎么应对呢?

有三种方法可以解决这个问题:

修改接口

在IBook上新增一个getOffPrice()的方法,专门进行打折处理。

首先,IBook作为接口应该是稳定可靠的,不应该经常发生变化,否则接口作为契约的作用就失去了意义。

其次,修改了接口,NovelBook实现类也要做相应的修改,这样,为了实现这个需求,改动的面积是比较大的。

修改实现类

修改NovelBook实现类中getPrice()的方法,这样,改动的面积相对比较小了,仅仅局限在NovelBook实现类中。但是这样的话,用户就无法获得图书的原价了。

通过扩展实现变化

增加一个子类OffNovelBook,复写getPrice()方法,高层次的模块(也就是static静态模块区)通过OffNovelBook类产生新的对象。如下所示:

public class OffNovelBook extends NovelBook { 
    public OffNovelBook(String _name,int _price,String _author){ 
        super(_name,_price,_author); 
    } 
?
    // 覆写销售价格 
    @Override 
    public int getPrice(){ 
        // 原价 
        int selfPrice = super.getPrice(); 
        int offPrice=0; 
        if(selfPrice < 4000){  // 原价低于40元,则打9折 
            offPrice = selfPrice * 90 /100; 
        }else{ 
            offPrice = selfPrice * 80 /100; 
        } 
        return offPrice; 
    } 
}

你看,仅仅扩展一个子类并复写getPrice()方法,就可以完成新增的业务。接下来看一下BookStore类的修改:

public class BookStore { 
    private final static ArrayList bookList = new ArrayList(); 
?
    // 静态模块初始化,项目中一般是从持久层初始化产生 
    static{ 
        // 换成打折的小说
        bookList.add(new OffNovelBook("天龙八部",3200,"金庸")); 
        bookList.add(new OffNovelBook("巴黎圣母院",5600,"雨果")); 
        bookList.add(new OffNovelBook("悲惨世界",3500,"雨果")); 
        bookList.add(new OffNovelBook("平凡的世界",4300,"路遥")); 
    } 
?
    // 模拟书店买书 
    public static void main(String[] args) { 
        NumberFormat formatter = NumberFormat.getCurrencyInstance(); 
        formatter.setMaximumFractionDigits(2); 
        System.out.println("------------书店中的小说类图书记录如下:---------------------"); 
        for(IBook book:bookList){ 
            System.out.println("书籍名称:" + book.getName()+"\t书籍作者:" + 
            book.getAuthor()+  "\t书籍价格:"  +  formatter.format(book.getPrice()/100.0)+"元"); 
        } 
    } 
} 

上面只修改了静态模块初始化部分,其他部分没有修改。运行结果如下:

------------书店中的小说类图书记录如下:--------------------- 
书籍名称:天龙八部  书籍作者:金庸  书籍价格:¥25.60元 
书籍名称:巴黎圣母院 书籍作者:雨果  书籍价格:¥50.40元 
书籍名称:悲惨世界  书籍作者:雨果  书籍价格:¥28.00元 
书籍名称:平凡的世界 书籍作者:路遥 书籍价格:¥38.70元 

上面这个例子,通过一处扩展,一处修改,实现了打折的新需求。可能有同学就会问:“这不还是修改了代码吗?”

修改代码就意味着违反了开闭原则吗?

BookStore类确实修改了,这部分属于高层次的模块。在业务规则改变的情况下,高层模块必须有部分改变以适应新业务。添加一个新功能,不可能任何模块、类、方法的代码都不“修改”,这个是做不到的。类需要创建、组装、并且做一些初始化操作,才能构建成可运行的程序,这部分代码的修改是在所难免的。

我们要做的是,尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则。

如何做到“对扩展开放、修改关闭”?

实际上,开闭原则讲的就是代码的扩展性问题,是判断一段代码是否易扩展的“金标准”。

在讲具体的方法论之前,我们先来看一些更加偏向顶层的指导思想。为了尽量写出扩展性好的代码,我们要时刻具备扩展意识、抽象意识、封装意识。这些“潜意识”可能比任何开发技巧都重要。

扩展意识:在写代码的时候,我们要多花点时间往前多思考一下,这段代码未来可能有哪些需求变更、如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,不需要改动代码整体结构、做到最小代码改动的情况下,新的代码能够灵活地插入到扩展点上,做到“对扩展开放、对修改关闭”。

抽象意识:提供抽象化的不可变接口,给上层系统使用。当具体的实现发生变化的时候,我们只需要基于相同的抽象接口,扩展一个新的实现,替换老的实现即可,上游系统的代码几乎不需要修改。

封装意识:在识别出代码可变部分和不可变部分之后,我们要将可变部分封装起来,隔离变化。

在众多的设计原则、思想、模式中,最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如:装饰、策略、模板、责任链、状态等)。

设计模式这一块,我们另外再分享。今天重点学习一下,如何利用多态、依赖注入、基于接口而非实现编程,来实现“对扩展开放、对修改关闭”。

假如,我们现在要开发一个通过Kafka来发送异步消息。对于这样一个功能的开发,我们要学会将其抽象成一组跟具体消息队列(Kafka)无关的异步消息接口。所有上层系统都依赖这组抽象的接口编程,并且通过依赖注入的方式来调用。当我们要替换新的消息队列的时候,比如将Kafka替换成RocketMQ,可以很方便地拔掉老的消息队列实现,插入新的消息队列实现。

// 这一部分体现了抽象意识
public interface MessageQueue { //...}
public class KafkaMessageQueue implements MessageQueue { //...}
public class RocketMQMessageQueue implements MessageQueue { //...}
?
public interface MessageFromatter { //...}
public class JsonMessageFromatter implements MessageFromatter { //...}
public class ProtoBufMessageFromatter implements MessageFromatter { //...}
?
public class Demo {
    private MessageQueue msgQueue;        // 基于接口而非实现编程
    public Demo(MessageQueue msgQueue) {  // 依赖注入
        this.msgQueue = msgQueue;
    }
    
    // msgFormatter:多态、依赖注入
    public void sendNotification(Notification notification, MessageFormatter msg) {
        //..
    }
}

当然,开闭原则也不是免费的,有时候,代码的扩展性会跟可读性冲突。这个时候,我们就需要在两者之间做一个权衡。总之,没有一个放之四海而皆准的参考标准,全凭实际的应用场景来决定。

如何预留扩展点?

前面我们提到,写出支持“对扩展开放、对修改关闭”的代码的关键是预留扩展点,那么问题是,应该如何才能识别出所有可能的扩展点呢?

如果开发业务导向的系统,比如电商系统、物流系统、金融系统等,要想识别尽可能多的扩展点,就需要对业务本身有足够多的了解。

如果开发通用、偏底层的框架、类库、组件等,就需要了解它们会被如何使用,日后可能会添加什么功能。

“唯一不变的就是变化本身”,尽管我们对业务系统、框架功能有足够多的了解,也不能识别出所有的扩展点。即便我们能够识别出所有的扩展点,为这些地方做预留扩展点的设计,成本都是很大的,这就叫做“过度设计”

合理的做法,应该是对于一些比较确定的,短期内可能就会扩展,或者需要改动对代码结构影响比较大的情况,或者实现成本不高的扩展点,在编写代码的时候,我们就可以事先做预留扩展点设计。但是对于一些不确定未来是否要支持的需求,或者实现起来比较复杂的扩展点,可以通过重构代码的方式来支持扩展的需求。

好啦,设计原则是否应用得当,应该根据具体的业务场景,具体分析。

总结

对扩展开放,是为了应付变化(需求);

对修改封闭,是为了保证已有代码的稳定性;

最终结果是为了让系统更有弹性

参考

《大话设计模式》

极客时间专栏《设计模式之美》

https://blog.csdn.net/sinat_20645961/article/details/48239347