11丨软件设计的开闭原则:如何不修改代码却能实现需求变更?

我在上篇文章讲到,软件设计应该为需求变更而设计,应该能够灵活、快速地满足需求变更的要求。优秀的程序员也应该欢迎需求变更,因为持续的需求变更意味着自己开发的软件保持活力,同时也意味着自己为需求变更而进行的设计有了用武之地,这样的话,技术和业务都进入了良性循环。

但是需求变更就意味着原来开发的功能需要改变,也意味着程序需要改变。如果是通过修改程序代码实现需求变更,那么代码一定会在不断修改的过程中变得面目全非,这也意味着代码的腐坏。

有没有办法不修改代码却能实现需求变更呢?

这个要求听起来有点玄幻,事实上却是软件设计需要遵循的最基本的原则:开闭原则。

开闭原则

开闭原则说:软件实体(模块、类、函数等等)应该对扩展是开放的,对修改是关闭的

对扩展是开放的,意味着软件实体的行为是可扩展的,当需求变更的时候,可以对模块进行扩展,使其满足需求变更的要求。

对修改是关闭的,意味着当对软件实体进行扩展的时候,不需要改动当前的软件实体;不需要修改代码;对于已经完成的类文件不需要重新编辑;对于已经编译打包好的模块,不需要再重新编译。

通俗的说就是,软件功能可以扩展,但是软件实体不可以被修改

功能要扩展,软件又不能修改,似乎是自相矛盾的,怎样才能做到不修改代码和模块,却能实现需求变更呢?

一个违反开闭原则的例子

在开始讨论前,让我们先看一个反面的例子。

假设我们需要设计一个可以通过按钮拨号的电话,核心对象是按钮和拨号器。那么简单的设计可能是这样的:

按钮类关联一个拨号器类,当按钮按下的时候,调用拨号器相关的方法。代码是这样的:

public class Button {
    public final static int SEND_BUTTON = -99;

    private Dialer          dialer;
    private int             token;

    public Button(int token, Dialer dialer) {
        this.token = token;
        this.dialer = dialer;
    }

    public void press() {
        switch (token) {
            case 0:
            case 1:
            case 2:
            case 3:
            case 4:
            case 5:
            case 6:
            case 7:
            case 8:
            case 9:
                dialer.enterDigit(token);
                break;
            case SEND_BUTTON:
                dialer.dial();
                break;
            default:
                throw new UnsupportedOperationException("unknown button pressed: token=" + token);
        }
    }
}
public class Dialer {
    public void enterDigit(int digit) {
        System.out.println("enter digit: " + digit);
    }

    public void dial() {
        System.out.println("dialing...");
    }
}

按钮在创建的时候可以创建数字按钮或者发送按钮,执行按钮的press()方法的时候,会调用拨号器Dialer的相关方法。这个代码能够正常运行,完成需求,设计似乎也没什么问题。

这样的代码我们司空见惯,但是它的设计违反了开闭原则:当我们想要增加按钮类型的时候,比如,当我们需要按钮支持星号(*)和井号(#)的时候,我们必须修改Button类代码;当我们想要用这个按钮控制一个密码锁而不是拨号器的时候,因为按钮关联了拨号器,所以依然要修改Button类代码;当我们想要按钮控制多个设备的时候,还是要修改Button类代码。

似乎对Button类做任何的功能扩展,都要修改Button类,这显然违反了开闭原则:对功能扩展是开放的,对代码修改是关闭的。

违反开闭原则的后果是,这个Button类非常僵硬,当我们想要进行任何需求变更的时候,都必须要修改代码。同时我们需要注意,大段的switch/case语句是非常脆弱的,当需要增加新的按钮类型的时候,需要非常谨慎地在这段代码中找到合适的位置,稍不小心就可能出现bug。粗暴一点说,当我们在代码中看到else或者switch/case关键字的时候,基本可以判断违反开闭原则了

而且,这个Button类也是难以复用的,Button类强耦合了一个Dialer类,在脆弱的switch/case代码段耦合调用了Dialer的方法,即使Button类自身也将各种按钮类型耦合在一起,当我想复用这个Button类的时候,不管我需不需要一个Send按钮,Button类都自带了这个功能。

所以,这样的设计不要说不修改代码就能实现功能扩展,即使我们想修改代码进行功能扩展,里面也很脆弱,稍不留心就掉到坑里了。这个时候你再回头审视Button的设计,是不是就感觉到了代码里面腐坏的味道,如果让你接手维护这些代码实现需求变更,是不是头疼难受?

很多设计开始看并没有什么问题,如果软件开发出来永远也不需要修改,也许怎么设计都可以,但是当需求变更来的时候,就会发现各种僵硬、脆弱。所以设计的优劣需要放入需求变更的场景中考察。当需求变更时发现当前设计的腐坏,就要及时进行重构,保持设计的强壮和代码的干净。

使用策略模式实现开闭原则

设计模式中很多模式其实都是用来解决软件的扩展性问题的,也是符合开闭原则的。我们用策略模式对上面的例子重新进行设计。

我们在Button和Dialer之间增加了一个抽象接口ButtonServer,Button依赖ButtonServer,而Dialer实现ButtonServer。

当Button按下的时候,就调用ButtonServer的buttonPressed方法,事实上是调用Dialer实现的buttonPressed方法,这样既完成了Button按下的时候执行Dialer方法的需求,又不会使Button依赖Dialer。Button可以扩展复用到其他需要使用Button的场景,任何实现ButtonServer的类,比如密码锁,都可以使用Button,而不需要对Button代码进行任何修改。

而且Button也不需要switch/case代码段去判断当前按钮类型,只需要将按钮类型token传递给ButtonServer就可以了,这样增加新的按钮类型的时候就不需要修改Button代码了。

策略模式是一种行为模式,多个策略实现同一个策略接口,编程的时候client程序依赖策略接口,运行期根据不同上下文向client程序传入不同的策略实现。

在我们这个场景中,client程序就是Button,策略就是需要用Button控制的目标设备,拨号器、密码锁等等,ButtonServer就是策略接口。通过使用策略模式,我们使Button类实现了开闭原则。

使用适配器模式实现开闭原则

Button符合开闭原则了,但是Dialer又不符合开闭原则了,因为Dialer要实现ButtonServer接口,根据参数token决定执行enterDigit方法还是dial方法,又需要if/else或者switch/case,不符合开闭原则。

那怎么办?

这种情况可以使用适配器模式进行设计。适配器模式是一种结构模式,用于将两个不匹配的接口适配起来,使其能够正常工作。

不要由Dialer类直接实现ButtonServer接口,而是增加两个适配器DigitButtonDialerAdapter、SendButtonDialerAdapter,由适配器实现ButtonServer接口,在适配器的buttonPressed方法中调用Dialer的enterDigit方法和dial方法,而Dialer类保持不变,Dialer类实现开闭原则。

在我们这个场景中,Button需要调用的接口是buttonPressed,和Dialer的方法不匹配,如何在不修改Dialer代码的前提下,使Button能够调用Dialer代码?就是靠适配器,适配器DigitButtonDialerAdapter和SendButtonDialerAdapter实现了ButtonServer接口,使Button能够调用自己,并在自己的buttonPressed方法中调用Dialer的方法,适配了Dialer。

使用观察者模式实现开闭原则

通过策略模式和适配器模式,我们使Button和Dialer都符合了开闭原则。但是如果要求能够用一个按钮控制多个设备,比如按钮按下进行拨号的同时,还需要扬声器根据不同按钮发出不同声音,将来还需要根据不同按钮点亮不同颜色的灯。按照当前设计,可能需要在适配器中调用多个设备,增加设备要修改适配器代码,又不符合开闭原则了。

怎么办?

这种情况可以用观察者模式进行设计:

这里,ButtonServer被改名为ButtonListener,表示这是一个监听者接口,其实这个改名不重要,仅仅是为了便于识别。因为接口方法buttonPressed不变,ButtonListener和ButtonServer本质上是一样的。

重要的是在Button类里增加了成员变量List和成员方法addListener。通过addListener,我们可以添加多个需要观察按钮按下事件的监听者实现,当按钮需要控制新设备的时候,只需要将实现了ButtonListener的设备实现添加到Button的List列表就可以了。

Button代码:

public class Button {
    private List<ButtonListener> listeners;

    public Button() {
        this.listeners = new LinkedList<ButtonListener>();
    }

    public void addListener(ButtonListener listener) {
        assert listener != null;
        listeners.add(listener);
    }

    public void press() {
        for (ButtonListener listener : listeners) {
            listener.buttonPressed();
        }
    }
}

Dialer代码和原始设计一样,如果我们需要将Button和Dialer组合成一个电话,Phone代码如下:

public class Phone {
    private Dialer   dialer;
    private Button[] digitButtons;
    private Button   sendButton;

    public Phone() {
        dialer = new Dialer();
        digitButtons = new Button[10];
        for (int i = 0; i < digitButtons.length; i++) {
            digitButtons[i] = new Button();
            final int digit = i;
            digitButtons[i].addListener(new ButtonListener() {
                public void buttonPressed() {
                    dialer.enterDigit(digit);
                }
            });
        }
        sendButton = new Button();
        sendButton.addListener(new ButtonListener() {
            public void buttonPressed() {
                dialer.dial();
            }
        });
    }

    public static void main(String[] args) {
        Phone phone = new Phone();
        phone.digitButtons[9].press();
        phone.digitButtons[1].press();
        phone.digitButtons[1].press();
        phone.sendButton.press();
    }
}

观察者模式是一种行为模式,解决一对多的对象依赖关系,将被观察者对象的行为通知到多个观察者,也就是监听者对象。

在我们这个场景中,Button是被观察者,目标设备拨号器、密码锁等是观察者。被观察者和观察者通过Listener接口解耦合,观察者(的适配器)通过调用被观察者的addListener方法将自己添加到观察列表,当观察行为发生时,被观察者会逐个遍历Listener List,通知观察者。

使用模板方法模式实现开闭原则

如果业务要求按下按钮的时候,除了控制设备,按钮本身还需要执行一些操作,完成一些成员变量的状态更改,不同按钮类型进行的操作和记录状态各不相同。按照当前设计可能又要在Button的press方法中增加switch/case了。

怎么办?

这种情况可以用模板方法模式进行设计:

在Button类中定义抽象方法onPress,具体类型的按钮,比如SendButton实现这个方法。Button类中增加抽象方法onPress,并在press方法中调用onPress方法:

abstract void onPress();

public void press() {
     onPress();
     for (ButtonListener listener : listeners) {
         listener.buttonPressed();
     }
}

所谓模板方法模式,就是在父类中用抽象方法定义计算的骨架和过程,而抽象方法的实现则留在子类中。

在我们这个例子中,press方法就是模板,press方法除了调用抽象方法onPress,还执行通知监听者列表的操作,这些抽象方法和具体操作共同构成了模板。而在子类SendButton中实现这个抽象方法,在这个方法中修改状态,完成自己类型特有的操作,这就是模板方法模式。

通过模板方法模式,每个子类可以定义自己在press执行时的状态操作,无需修改Button类,实现了开闭原则。

小结

实现开闭原则的关键是抽象。当一个模块依赖的是一个抽象接口的时候,就可以随意对这个抽象接口进行扩展,这个时候,不需要对现有代码进行任何修改,利用接口的多态性,通过增加一个新实现该接口的实现类,就能完成需求变更。不同场景进行扩展的方式是不同的,这时候就会产生不同的设计模式,大部分的设计模式都是用来解决扩展的灵活性问题的。

开闭原则可以说是软件设计原则的原则,是软件设计的核心原则,其他的设计原则更偏向技术性,具有技术性的指导意义,而开闭原则是方向性的,在软件设计的过程中,应该时刻以开闭原则指导、审视自己的设计:当需求变更的时候,现在的设计能否不修改代码就可以实现功能的扩展?如果不是,那么就应该进一步使用其他的设计原则和设计模式去重新设计。

更多的设计原则和设计模式,我将在后面陆续讲解。

思考题

我在观察者模式小节展示的Phone代码示例中,并没有显式定义DigitButtonDialerAdapter和SendButtonDialerAdapter这两个适配器类,但它们是存在的。在哪里呢?

欢迎在评论区写下你的思考,我会和你一起交流,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。

精选留言

  • 山猫

    2019-12-16 09:20:31

    我同意老师通过这个例子简单的描述开闭原则。但如果项目初始就对button按钮需要进行这么复杂的设计,那么这个项目后期的维护成本也是相当之高。
    作者回复

    是否要使用各种设计模式设计一个非常灵活的程序,主要是看你的需求场景,而不是看项目的阶段。

    如果你的场景就是需要这么灵活,就是要各种复用,应对各种变更,那么你一开始就应该这样设计。

    如果你的场景根本不需要一个可复用的button,那么就不需要这样设计。

    关键还是看场景。

    但是场景也会变化,一开始不需要复用,但是后来又需要复用了,那么在在需要复用的第一个场景,就重构代码,而不是等将来维护困难局面hold不住了再重构。

    ps 如果你习惯了这种灵活的设计,你会觉得这种设计并不复杂。对于软件开发而言,复杂的永远是业务逻辑,而不是设计模式。设计模式是可重复的,可重复的东西即使看起来复杂,熟悉了就会觉得很简单。

    pps 看起来复杂的设计模式就是用来解决维护困难问题的,正确使用设计模式,看起来复杂了,其实维护简单了,因为关系和边界更清晰了,你不需要在一堆强耦合的代码里搅来搅去。真正维护成本高的其实是你所谓的简单的设计,牵一发动全身,稍不注意就是各种bug。

    ppps 重要的话再说一次:
    关键还是看场景。

    没有银弹,没有一种必然就是好的设计方案,能理解场景的才是真·高手。

    2019-12-16 11:59:36

  • 陈小龙 Cheney

    2019-12-17 17:49:10

    希望老师给出几个阶段的代码. 方便对着代码对比学习. 直接看文字感觉抽象模糊了.
  • 山猫

    2019-12-19 14:11:18

    没有想到我的评论老师会有那么多文字进行评论,也没有想到会有那么多赞。

    看了老师的评论,其实我也知道这个和所处的场景有很大关系。

    在项目开发过程初期,有很多东西是想不到的。譬如一个登陆页面,

    第一版可能就是简单的账号口令登陆,
    第二版可能就需要加上第三方登陆,
    再往后可能需要分类型登陆,
    在过一段时间可能有客户端的登陆。

    还有一种可能就是这个项目真的就这一个登陆,其他的登录方式又采取微服务的办法。

    有时候真的是看每个工程师的经验,还有对客户的熟悉程度。做开发好多年了,过度开发我也写过,预估不足我也写过。

    需求每天都在变,虽然设计模式能够照顾到大多数的需求变化,但总有坑死人的客户和打不死的需求。

    祝每个开发者好好学习技术,祝每个开发者都不要遇到坑死人的项目。

    疯了,累了,痛了,人间喜剧@_@
  • Jesse

    2019-12-16 15:05:03

    思考题
    匿名内部类,已经数字按钮注册的listener其实就是DigitButtonDailerAdepter适配器的实现,sendButton中注册的listener其实就是SendButtonDailerAdepter适配器的实现。
  • Roy Liang

    2019-12-17 18:50:38

    /*适配器模式*/
    public class Button {
    private ButtonServer server;
    private int token;

    public Button(int token, ButtonServer server) {
    this.token = token;
    this.server = server;
    }

    public void press() {
    server.buttonPressed(token);
    }
    }

    public interface ButtonServer {
    void buttonPressed(int token);
    }

    public class Dialer {
    public final static int SEND_BUTTON = -99;

    public void enterDigit(int digit) {
    System.out.println("enter digit: " + digit);
    }

    public void dial() {
    System.out.println("dialing...");
    }
    }

    public class DigitButtonDialerAdapter implements ButtonServer {
    private Dialer dailer = new Dialer();

    @Override
    public void buttonPressed(int token) {
    dailer.enterDigit(token);
    }
    }

    public class SendButtonDialerAdapter implements ButtonServer {
    private Dialer dialer = new Dialer();

    @Override
    public void buttonPressed(int token) {
    dialer.dial();
    }
    }
  • QQ怪

    2019-12-29 23:44:05

    看了2遍,敲了一遍,https://github.com/xqq1994/DesignPatternLearn
  • Paul Shan

    2019-12-16 08:46:35

    开闭原则是移除底层的if else,取而代之的是上层的类结构。不过,我个人以为一开始的if else, 甚至switch 也没什么不妥的,毕竟代码简单直接。引入了很多类,读代码也是负担,而且也很难预料到哪些修改是必要的。当if else数量多于一定的数目,再开始重构。 不知道李老师如何看待这种观点。
    作者回复

    当你准备写第一个else的时候,就说明你的代码即将陷入僵化、牢固和脆弱,而且为将来的需求变更引入了一个糟糕的“设计模式”。

    如果其他人接手你的代码,他有两个选择,要么继续写更多的else以应对需求变更;要么心理暗骂一声然后重构你的代码。你希望他选择哪个?

    2019-12-16 11:36:40

  • 虢國技醬

    2020-01-20 15:15:00

    “开闭原则可以说是软件设计原则的原则,是软件设计的核心原则,其他的设计原则更偏向技术性,具有技术性的指导意义,而开闭原则是方向性的,在软件设计的过程中,应该时刻以开闭原则指导、审视自己的设计:当需求变更的时候,现在的设计能否不修改代码就可以实现功能的扩展?如果不是,那么就应该进一步使用其他的设计原则和设计模式去重新设计。”
    读的过程中一直有这种感觉:开闭原则可能是软件设计和实现时最重要的原则;果然和老师最后的总结一样。👍
    作者回复

    👍

    2020-01-21 09:13:29

  • Jonathan Chan

    2019-12-16 10:11:21

    求老师后续给出完整代码学习!
  • x-ray

    2021-09-14 17:42:48

    老师展示的这个过程其实还是很好的,设计的程度,其实真的是和经验有关系的,很多场景具有通用性,就是这种场景的需求变化,以后大概率会出现的,这个时候就需要提前去布局去设计。这个代码里我有个疑问,就是main函数,我认为实际的场景应该是一个指令过来,是一个String字符串,而不是直接调用sendButton或者digitalButton,这样的话,这里还是免不了if else处理,当然工厂模式或者map是可以解决这个问题的
  • 任鑫

    2021-06-10 12:09:08

    发现部分同学的一些问题:
    1. 希望在这样一个篇幅有限的地方得到所有的答案,这不现实
    2. 一些技术性的比如代码怎么写,可以很容易的在搜索引擎上找到,但你看过干巴巴的代码很难理解其原则和场景
    3. 任何理论的探讨有其范围限制,我们必须理解这个大前提才能从中获益
    4. 现实和理想有差距,但是并不冲突,设计模式并不能干掉每一个if-else,也不需要干掉每一个if-else,在这一点上杠的同学其实自己也该也清楚
  • Winon

    2020-07-05 22:42:33

    请教老师,模板方法是否也是另外一种面向过程设计?是否在充血对象模型中,模板方法的使用会相对少?
    作者回复

    模板方法常和策略模式结合,为各种策略实现类提供模板和公共处理逻辑。
    充血模型也需要策略和模板。

    2020-07-06 10:20:50

  • yes

    2020-01-27 08:12:24

    我不是想找茬,我就想知道以上的代码怎么对说好的加“*” 和“#” 开闭
    作者回复

    starButton = new Button();
    starButton.addListener(
    new ButtonListener() {
    public void buttonPressed() {
    dialer.enterDigit(STAR);
    }
    }
    );

    2020-01-29 12:08:53

  • 夏天

    2020-05-21 15:54:46

    【 typescript实现(二)】
    /**
    * 一个电话应用
    * 有一系列的数字按钮
    * 有一个拨打按钮
    */
    class Phone {
    private dialer: Dialer;
    public digitButtons: Button[];
    public sendButton: Button;

    constructor() {
    this.dialer = new Dialer();
    this.digitButtons = new Array(10);

    for (let i = 0; i < this.digitButtons.length; i++) {
    this.digitButtons[i] = new MyButton(i);
    this.digitButtons[i].addListener(
    new DigitButtonDailerAdepter(this.dialer, i),
    );
    }

    this.sendButton = new MyButton(99); // 假设拨打是99
    this.sendButton.addListener(new SendButtonDailerAdepter(this.dialer));
    }
    }

    /**
    * 启动函数
    */
    function start() {
    const phone = new Phone();
    phone.digitButtons[9].press();
    phone.digitButtons[1].press();
    phone.digitButtons[1].press();
    phone.sendButton.press();
    }

    start();

    // MyButton-onPress
    // enter digit: 9
    // MyButton-onPress
    // enter digit: 1
    // MyButton-onPress
    // enter digit: 1
    // MyButton-onPress
    // dialing...
  • 夏天

    2020-05-21 15:54:29

    【 typescript实现(一)】
    // 拨号器
    class Dialer {
    public enterDigit(digit: number) {
    console.log('enter digit: ' + digit);
    }

    public dial() {
    console.log('dialing...');
    }
    }

    abstract class ButtonLister {
    abstract buttonPressed(token: number): void;
    }

    // 新增两个适配器,适配器去实现接口,然后调用Dialer;
    class DigitButtonDailerAdepter implements ButtonLister {
    private dialer: Dialer;
    private token: number;
    constructor(dialer: Dialer, token: number) {
    // super();
    this.dialer = dialer;
    this.token = token;
    }
    buttonPressed() {
    this.dialer.enterDigit(this.token);
    }
    }

    class SendButtonDailerAdepter implements ButtonLister {
    private dialer: Dialer;
    constructor(dialer: Dialer) {
    this.dialer = dialer;
    }
    buttonPressed() {
    this.dialer.dial();
    }
    }

    abstract class Button {
    private listeners: ButtonLister[] = [];
    private token: number;

    constructor(token: number) {
    this.listeners = [];
    this.token = token;
    }

    addListener(listener: ButtonLister) {
    this.listeners.push(listener);
    }

    abstract onPress(): void;

    public press(): void {
    this.onPress();
    this.listeners.forEach((listener) => {
    listener.buttonPressed(this.token);
    });
    }
    }
    // 利用多态实现不同的onPress效果
    class MyButton extends Button {
    onPress() {
    console.log('MyButton-onPress');
    }
    }
  • 张希音

    2019-12-23 11:25:01

    以前感觉面试的时候问设计模式都是为了面试而设计的问题,现在经过这么一分析,终于明白了设计模式的重要性,是时候去隔壁补补设计模式的知识漏洞了
  • 唐二毛

    2019-12-19 20:17:16

    有一点想不通,在adapter里面还是需要判断呀?这并没有 达到老师说的 避免做switch/if判断的效果,而且判断的逻辑一点不少,还无端弄出这么多类,有必要非得这么做吗?
    作者回复

    Adapter不需要判断,请看思考题

    2019-12-20 11:39:39

  • 2019-12-16 22:55:06

    结合王争老师的设计模式课,会理解的更透彻
  • Paul Shan

    2019-12-16 08:40:19

    思考题
    Adapter 是在继承接口的时候调用了dialer不同的函数实现了,没有显式的Adapter。
  • 小妖儿爱吃肉

    2023-03-14 14:36:27

    还有一种符合开闭原则的思想,就是把经常改变的内容配置化,结合条件语句if、else编写业务逻辑,可变的部分通过配置化方式来控制,不修改业务逻辑就可以实现需求变更。