2020年 再看开闭原则

目录

午餐时的争吵

今天中午我偶然路过二楼的厨房,听到同事们边吃午饭边争论着什么。我好奇的前去观摩,原来是在代码评审上有了分歧。

第一个同事说这个代码虽然能满足现在的需求,但是却不易于向某个他描述的方向拓展,他言之凿凿,认为我们的软件不久就会有他描述的需求。与其等到新需求到来时在对软件进行改动,不如开始实现时就考虑到可能的拓展,让这个模块变得灵活以适应将来的需求。

而另一位同事则不为所动,觉得YAGNI(You aren’t gonna need it)。就算我们真的要满足第一个同事所描述的需求,但是根据他的判断那也是很长时间(一年)之后的事了。如果现在就把模块变得易于拓展,那么无疑会增加代码的抽象层次,本来一个类就可以实现的功能,现在要多实现几个类。

第三位同事也加入了讨论。把软件设计的灵活以适应将来的变化是一件好事,但是这么做也是有代价的。灵活意味着这个模块需要更高的抽象层次,在新的需求到来前,这个模块都需要保持它本不需要的抽象层次。况且现在我们假设对于需求的预测是正确的,假如需求根本不是我们想象的那样变化,那么现在的努力不就白费了吗?

第一位同事似乎没有被说服,另外两位同事也各执已见。午饭结束,大家各自回到工位上继续搬砖了。

虽然这次争论还没有结果,也还不知道谁对谁错。那么我们在实现某个模块时,究竟是只实现现有需求还是要考虑到将来的需求而将模块实现的灵活一点呢?

经验法则与惯例

面向对象程序设计至今已至少经存在了50年了(如果我们从1970年的Smalltalk语言开始算起,如果从1960年的Simula 67语言开始算起,那么时间会更长),我们现在遇到的各种问题,前人也几乎都遇到过了。这也是为什么面向对象程序设计会有如此多的模式(Pattern),甚至是名言警句(Maxim)。

如果问一位极限编程(extreme programming,XP)的专家,他会说

YAGNI(You aren’t gonna need it),一个程序员不应该为软件增加不必要的功能

SOLID主义者会说

我们应该遵守开闭原则(The Open-Closed Principle),软件中的对象(类,模块,函数等等)应该对于扩展是开放的,但是对于修改是封闭的

而不久前Java大师Bruce Eckel恰巧在他的博客中写道

质疑抽象的合理性,在不使用教条的情况下如果不能解释抽象的合理性,就不要抽象

原文是

Question your abstractions. If you can’t justify it (without quoting a maxim), take it out.

但是不论是YAGNI还是SOLID,这些经验法则都是有适用条件的。在不同情况下,急切地套用这些法则还不如不去了解他们。在1988年Bertrand Meyer提出开闭法则32年后的今天,我们真的了解什么是开闭法则吗(The Open-Closed Principal)?

重温开闭原则

软件中的对象(类,模块,函数等等)应该对于扩展是开放的,但是对于修改是封闭的。

Bertrand MeyerObject-Oriented Software Construction. Prentice Hall. 1988

开闭原则说了两件事

  • 软件中的对象应该对于扩展是开放的

    对象的行为可以被拓展,也就是说新的需求来临时,我们可以改变对象的行为以满足新的需求

  • 软件中的对象应该对于修改是封闭的

    实现原有需求的的源代码不应该被修改

难道这两句话不矛盾吗?如果我们不修改源代码怎么拓展对象原有的行为?难道有不写代码就满足需求的魔法吗?当然,不写代码时不肯能的。但是不修改原先的代码,而在软件的某一处增加代码来拓展对象的行为确实有可能的。

抽象是解决问题的关键

Robert C. MartinThe Open-Close Principal

Robert C. Martin 拿了C++写了一个关于在GUI打印形状的例子。比如我们现在有在GUI上打印方块和圆的需求,熟悉过程式编程人会实现类似于下面的程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class OpenClosedPrincipal {

static class Circle {
double radius;
Point center;
}

static class Square {
double side;
Point topLeft;
}

static void DrawCircle(final Circle circle) {
// 实现略去
}

static void DrawSquare(final Square square) {
// 实现略去
}

static void drawAllShapes(final List<Object> shapes) {
for (final Object shape : shapes) {
if (shape instanceof Circle) {
DrawCircle((Circle) shape);
} else if (shape instanceof Square) {
DrawSquare((Square) shape);
} else {
throw new UnsupportedOperationException();
}
}
}
}

由于我拿了Java重写了Robert C. Martin 当年拿C++语言写的例子。所以上面有很多地方看起来都很奇怪,但是暴露的问题是一样的。假如不久之后我们有了新的需求,需要打印菱形,那么我们不可避免的需要修改DrawAllShapes方法。但是假如我们利用接口进行抽象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class OpenClosedPrincipal {

interface Shape {
void draw();
};

static class Circle implements Shape {
double radius;
Point center;
@Override
public void draw() {
// 实现略去
}
}

static class Square implements Shape {
double side;
Point topLeft;
@Override
public void draw() {
// 实现略去
}
}

static void drawAllShapes(final List<Shape> shapes) {
for (final Shape shape : shapes) {
shape.draw();
}
}
}

这里抽象出Shape接口并定义draw()方法,让不同的具体类去实现draw()方法。这样当打印新的形状的需求出现时我们就不需要修改 DrawAllShapes方法了。这样就符合了开闭原则

  • 若需要打印新的形状只需要实现新的类,新的类的实现与原先的各种实现是解耦的,并不需要修改原先的实现
  • drawAllShapes() 没有改变

但是就好像老鼠团队开大会,谁去给猫挂铃铛的故事一样。开闭法则听起来诱人,可是又有谁知道一个软件里哪个模块,类应该关闭,哪个模块,类应该开放呢?假如新的需求不是打印新的形状而是先打印圆形再打印方形呢?那么上面的例子就不再试用了。可能会有人说,那么我在实现 drawAllShapes()就考虑到打印顺序的需求,再去要求每个形状实现 Comparable<Shape>接口就好了。哈哈,,今天有打印顺序的需求,明天又你想象不到的需求呢?只可惜软件开放工程师并不是预言家,要是软件开放工程师能准确语言客户的需求,那么现在软件开放领域也不会有那么多问题了。

这就是开闭原则的陷阱,它要求使用者有非比寻常的见解。但是没有个十年软件开放经验,又怎么能获得寻常的见解呢?

拓展阅读

  1. Yagni, Martin Fowler
  2. The Open-Closed Principle, Robert C. Martin
  3. Unspoken Assumptions Underlying OO Design Maxims