[译] 快速理解开闭原则

Posted by maybelence on 2021-04-20

原文地址:The Open-Closed principle made simple
原文作者:Mihai Sandu
译者:maybelence

原文的示例代码都是采用的 Scala 语言,其实 Scala 本身和 java 的语法非常像,考虑到目前更多的开发者都是 java ,因此我把原文的代码都转成了 java 。一些和 java 语法有冲突的地方也都做了中文注释。


卡车是一种多功能的设备。他们可以根据车厢挂载的零件类型来执行不同的任务,如果载重允许的话,甚至可以
装载多个车厢。

简而言之,卡车可以通过不同的零件以进行扩展,但不能改装每个零件的内部单元(例如发动机或机舱)。可拓展的代码也应该像卡车挂载零件一样得心应手。

制冰机问题

假设我们必须要编写一个程序来为 Ted&Kelly 公司制作可可冰激凌。

1
2
3
4
5
6
7
public class IceCreamMachine
{
public void MakeIceCream()
{
System.out.println("Cacao ice-cream"); //logic to create cacao ice cream
}
}

我们将程序交付给客户,虽然这段代码很简单,但是看上去没有什么问题,而且没有违反任何原则。客户目前是非常满意的。

但是事情并不是到这里就结束了,客户总是会有一些新的需求。这就意味着我们需要不断调整我们的代码。

添加新的口味

没想到程序刚上线就如此火爆,于是 Ted&Kelly 希望扩大规模。他们要求我们还要能够生产香草味的冰激凌。

短暂思考之后,我们给出了以下解决方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class IceCreamMachine 
{

public void MakeIceCream(String flavor) throws Exception {
if("cacao".equals(flavor))
{
System.out.println("Cacao ice-cream"); //logic to create cacao ice-cream
}
else if ("vanilla".equals(flavor))
{
System.out.println("Vanilla ice-cream"); //logic to create vanilla ice-cream
}
else
{
throw new Exception("Flavor not supported");
}
}
}

我们修改了最初的代码,添加了一个参数以指定所需的口味,并添加了 if 语句以在逻辑之间切换。由于我们已经改变了最初的函数签名,因此调用我们代码的方法将被破坏,但是至少从现在开始,我们应该在不破坏更改的情况下支持其他口味的生产。

创建可可和香草组合

业务进展顺利,但是很快客户就提出了第二个需求,希望我们能够生产由可可和香草制成的冰淇淋。事情开始变得有点复杂,但是我们仍然能够处理。

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
public class IceCreamMachine
{
public void MakeIceCream(String flavor) throws Exception
{
if("cacao".equals(flavor))
{
System.out.println("Cacao ice-cream"); //logic to create cacao ice-cream
}
else if ("vanilla".equals(flavor))
{
System.out.println("Vanilla ice-cream"); //logic to create vanilla ice-cream
}
else if ("cacao-vanilla".equals(flavor))
{
//copy & paste the cacao ice-cream logic
System.out.println("Cacao ice-cream");

//copy & paste the vanilla ice-cream logic
System.out.println("Vanilla ice-cream");
}
else
{
throw new Exception("Flavor not supported");
}
}
}

我们又添加了一个 if 语句,在这种情况下,生产冰淇淋的逻辑被复制到每个 if 内部。在实际的应用程序中,我可能会在单独的服务中提取生产的逻辑。但是,正如我们将看到的那样,提取服务并不总是最好的方案。

Ted&Kelly 要求支持生产更多口味时,会发生什么。如果他想进一步将它们结合起来怎么办?仅添加 if 子句并不是理想的解决方案。

这种解决方案产生的问题

每次我们添加新的口味或组合时,我们都必须更新 IceCreamMachine 类,这就意味着:

  • 我们更新已经部署的代码
  • 这个类变得越来越复杂,易读性变得越来越差。假设我们有 100 种口味,这个类很容易膨胀到 5000+ 行代码
  • 我们可能会破坏现有的单元测试

现在回想一下开头卡车的类比,当你每次更换车厢挂载的零件时,你会每次都更换引擎吗?显然不会,看我们如何解决这个问题。

传统的可拓展性方法

Bertrand Meyer 是最早提出开闭原则这一术语的人,他定义 “软件实体(类,模块,函数,等) 应当对扩展开放,对修改关闭。”

换句话说,每当我们需要向旧对象添加新行为时,都可以根据需要继承和更新它们。开闭原则是那些易于理解却难以应用的原则之一。

让我们遵循这种方法重写刚刚的代码:

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
public abstract class BaseIceCream {
public abstract void MakeIceCream();
}

public class CacaoIceCream extends BaseIceCream {
@Override
public void MakeIceCream() {
System.out.println("Cacao ice-cream");
}
}


public class VanillaIceCream extends BaseIceCream {
@Override
public void MakeIceCream() {
System.out.println("Vanilla ice-cream");
}
}

public class CacaoAndVanilla extends CacaoIceCream {
@Override
public void MakeIceCream() {
super.MakeIceCream();
System.out.println("Vanilla ice-cream"); //duplicate vanilla logic because we can't iherit both cacao and vanilla
}
}

原始类分为了四个类,每个类代表一种生产口味。通过此解决方案,我们解决了所有最初的问题:

  • 每个类都短小精悍,可维护性和易读性都很高
  • 当需要添加新口味时,我们只需要添加一个新的类
  • 不会影响现有的单元测试

代码看起来已经没有任何问题了,我们完全可以到这里就停止思考,但是其实这种解决方案还是存在一些问题的:

  • 不能继承多个类,因此对于两种口味的结合,我们必须复制一些代码或将逻辑提取到服务中
  • 如果基类中的代码被更新,所有子类都会受到影响。假设基类通过构造函数注入了一些依赖关系,每当我们添加新的依赖关系时,所有子代都必须将该参数解析为基本构造函数

现在可拓展性的方法

Robert C. Martin 重申 Meyer 的开闭原则时,他做了一些更新。偏爱继承而不是继承

组合对象时,我们可以自由地随意组合任意数量和所需的组合。而且,如果我们针对抽象类(接口)进行编程,则可以修改已有代码的行为而无需进行修改代码。让我们看看最终的解决方案:

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
33
34
35
36
37
38
39
40
public interface IMakeIceCream {
void Make();
}

class CacaoIceCream implements IMakeIceCream {
private IMakeIceCream iceCreamMaker;

public CacaoIceCream(IMakeIceCream iceCreamMaker) //可选参数,如果不需要另一个口味传null
{
this.iceCreamMaker = iceCreamMaker;
}

public void Make() {
if (iceCreamMaker != null) //if flavor passed in, make that first
{
iceCreamMaker.Make();
}

System.out.println("Cacao ice-cream");
}
}

class VanillaIceCream implements IMakeIceCream {
private IMakeIceCream iceCreamMaker;

public VanillaIceCream(IMakeIceCream iceCreamMaker) //可选参数,如果不需要另一个口味传null
{
this.iceCreamMaker = iceCreamMaker;
}

@Override
public void Make() {
if (iceCreamMaker != null) //if flavor passed in, make that first
{
iceCreamMaker.Make();
}

System.out.println("Vanilla ice-cream");
}
}

现在将原始的类分成了三个对象:

  • IMakeIceCream 接口定义了制作冰淇淋的通用抽象
  • CacaoIceCream 实现了 IMakeIceCream
  • VanillaIceCream 实现了 IMakeIceCream

通过使用接口,我们将类和实现解耦。接口是封闭不可修改的,因此一旦我们定义了对象,就无法更改。但是我们可以为新的功能定义新接口并继承它们。这使得代码可扩展。

为什么要向每个构造函数添加 IMakeIceCream 参数?新代码是否具有旧方法或旧方法的所有功能?


答案是肯定的。可可香草组合仍在这里,但我们不需要 if 子句或专门针对它的类。我们可以利用构造函数参数。类似这样:

1
2
var cacaoVanillaIceCream = new CacaoIceCream(new VanillaIceCream());
cacaoVanillaIceCream.Make();

组合的好处在于,我们可以根据需要合成任意数量的对象。需要 4 种口味?只需编写一个构造函数链即可。这称为装饰器模式。你可以在 这里 读更多关于它的内容。

请注意, IMakeIceCream 参数是可选的。这使我可以组合使用或单独使用该类。

像这样编写代码会实现一个可拔插式的体系结构。这是非常 nice 的,因为我们可以通过编写代码来添加新功能,而不更改现有功能。任务完成。


Copyright by @maybelence.

...

...

00:00
00:00