2020年 再看开闭原则

文章目录

午餐时的争吵

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

第一个同事说这个代码虽然能满足现在的需求,但是却不易于向某个他描述的方向拓展,他言之凿凿,认为我们的软件不久就会有他描述的需求。相比于被动等待,不如主动出击,现在就考虑将来的需求,多花些时间设计这个模块,使其变得易于拓展以适应将来的需求。

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

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

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

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

经验法则与惯例

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

“们在实现某个模块时,究竟是只实现现有需求还是要考虑到将来的需求而将模块实现的灵活一点呢?”

对于这个问题,如果问一位极限编程[3]的专家,他会说:

YAGNI,一个程序员不应该为软件增加不必要的功能。

对于这个问题,如果问一位SOLID主义者[4],他会说:

我们应该遵守开闭原则[5],软件中的对象(类,模块,函数等等)应该对于扩展是开放的,但是对于修改是封闭的。

对于这个问题,其实不久前Java大师Bruce Eckel恰巧在他的博客中写道

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

但是不论是YAGNI还是SOLID,这些经验法则都是有适用条件的。在不同情况下,教条地套用这些法则还不如对这些法则一无所知。在1988年Bertrand Meyer提出开闭法则32年后的今天,我们真的了解什么是开闭原则吗?

重温开闭原则

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

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

脚注


  1. YAGANI:You aren’t gonna need it的缩写,直译为你不会需要它。详情参见马丁·福勒的博客https://martinfowler.com/bliki/Yagni.html ↩︎

  2. 类:class,此处特指面向对象编程语言中的类。 ↩︎

  3. 极限编程:Extreme Programming,简称XP。它是一种软件工程方法学,是敏捷软件开发的一种方式。如同其他敏捷方法学,极限编程和传统方法学的本质不同在于它更强调可适应性而不是可预测性。详情参见https://en.wikipedia.org/wiki/Extreme_programming ↩︎

  4. SOLID主义者:那些把所谓软件工程中设计模式的六大原则奉为圭臬的人。 ↩︎

  5. 开闭原则:The Open-Closed Principle,软件工程中设计模式的六大原则之一。 ↩︎

  6. 质疑抽象的合理性,在不使用教条的情况下如果不能解释抽象的合理性,就不要抽象。原文是Question your abstractions. If you can’t justify it (without quoting a maxim), take it out. 详情参见https://www.bruceeckel.com/blog/2019-12-24-unspoken-assumptions-underlying-oo-design-maxims ↩︎