架构设计原则之我见(二):SOLID 原则

阅读数:6 2020 年 5 月 8 日 11:17

架构设计原则之我见(二):SOLID原则

SOLID 原则,据 WikiPedia 所说,是由 Robert C. Martin 总结的面向对象设计原则。这个名字其实是以下五个原则的首字母简写:

  • Single responsibility principle;
  • Open/closed principle;
  • Liskov substitution principle;
  • Interface segregation principle;
  • Dependency inversion principle。

“Single responsibility principle”

这句话翻译成中文是“单一职责原则”。这是一句缺乏主语的话,推断应该是指设计师所设计的系统吧。所以补充完整后,整句话的意思应该是:“设计师所设计的目标系统,其职责应该是单一的”。

如何判定“职责”是否“单一”?

判定“职责单一”的标准是什么难以回答,只能通过作者的文章进一步分析,尝试理解作者原意。

这个原则也并非 SOLID 原则作者原创,据作者原文所说:“This principle was described in the work of Tom DeMarco and Meilir Page-Jones . They called it cohesion”,原来这个原则来源于 Tom DeMarco 和 Meilir Page-Jones 两位前辈的工作,原本叫做“Cohesion”,也就是“内聚”。作者对“内聚”给出的解释是:“A class should have only one reason to change”。下文根据作者所给出的例子,来进一步理解作者的意图。

文章开头以一个保龄球游戏的编程设计来探讨这一原则。原本 Game 类有两个责任:一、负责跟踪当前帧,相当于打球;二、负责计算分数。作者认为,如果把这两个职责放在同一个类中,会引起耦合,因此要对 Game 作架构拆分,把这两个责任分别拆分给两个不同的类,并给出了拆分的理由:“Because each responsibility is an axis of change”,意思是“因为每个职责都是一个变化的维度”。猜想作者想表达的是,由于这两个职责是互相正交的维度,分拆开后,可以避免它们互相影响的意思。

这里其实有两个问题:

首先,两个职责放在同一个类中,并不代表会发生耦合。

耦合的意思是当一个职责内部发生变动时,会影响到另外一个职责的正常执行。假设把两个职责的代码糅合在一起,形成一个大的代码块,这当然是耦合的,此时修改任何一个职责都要小心,牵一发而动全身。

但是我们可以把这两个职责放在两个不同的方法中,比如拆分成 Game.trackFrame(), Game.calcScore() 两个方法后,在修改其中一个职责时,只要输入输出的参数不发生变化,也并不会产生耦合。也就是说,要解决耦合这一问题,并非只有“拆分成两个不同的类”这一个解决方案,在同一个类中拆分成两个方法也可以解决,因为拆分成方法是拆分成类的前提。是否需要拆分成类,还需要有其他方面的考虑,解耦这一理由还不够充分,此处就不详细展开。

其次,很多人都忽略了为何两个职责可以被拆分开。

我们需要回到现实生活来分析保龄球游戏的核心生命周期。

在现实生活中打保龄球时,确实有算分这一环节。在每一次打球结束时, 机器会自动给出分数。当然,在早期没有机器时,这个分数肯定是由打球人自己来算的。为什么后来可以拆分出来交给机器来算呢?因为算分活动必须等待打球结束才能进行,打球与算分二者在执行时间上是属于完全不会发生交叉的两个连续动作,且打球的结果作为算分的输入,所以两个动作本来就是没有耦合的,可以拆分开,成为保龄球游戏生命周期中的两个相续活动。

这两个活动哪一个才是核心生命周期活动呢?可以看到,人们去保龄球馆是为了亲身体验打球,而不是为了体验得分。而且即使没有算分规则,人们也 可以玩的很开心,但如果没有打球的体验,只有算分规则,那么这个游戏也就不成立了。所以,这个游戏的核心生命周期是打球,而非算分。算分只是在打球结束后对结果的计算,属于非核心生命周,因此分数计算规则代码可以从打球代码中拆分出来,以保龄球游戏所产生的结果作为算分的输入来推动执行,形成树状结构。

而在拆分后,Game 的原本功能并没发生任何变化,只不过将其中一个步骤的实现代码分离出去了而已,然后通过方法调用,以直接获取结果的方式整合回归,还是同一个整体,没有发生变化。这一做法,使得 Game 能够更加专注于其本身的职责,分数计算自身也能更加专注,各自被修改时也可以互不影响。

所以,二者能够拆分开,并非“Because each responsibility is an axis of change”,而是因为其中存在非核心生命周期活动。并且拆分也并不仅限于拆分成类,首先应该能拆分成方法,这是拆分为类的前提。

“单一”与“内聚”

再从这个例子来分析“单一”的含义,确实还是叫“内聚”比较好。

从内聚的角度来看,在打球和算分两个方法拆分开后,trackFrame() 与 calcScore() 各自都专注于自身的业务,不受对方的影响,因此二者都是内聚的,自身都是完整的,只要给出输入参数就可以独立返回输出结果。而且 Game 这个类完整包含了保龄球自身的业务,其自身也是内聚的。

可是一旦改成“单一职责”,意思就发生了变化,着重点变成了“单一”。其后文在详细解释时,又把表述从“an axis of change”改成“one reason to change”, 意思进一步发生了变化:“an axis of change”指的是一个维度,而“one reason to change”指的是一个理由。二种表述区别很大,完全误解了“内聚”的本意,难怪会有很大的争议。

另外怎样才能算“职责单一”呢?这是没有确定标准的,需要相对于某个一个参考点才能确定是否单一。比如 Game 包含打球和算分两个步骤,难道 Game 的职责就不“单一”了吗?不是的。保龄球游戏需要打球和算分两个步骤,以组成一个“单一”的运动,放在一起正是为“单一”运动而服务的,这样做并不能说不“单一”。只有把对比的对象改为打球和算分时,才可以说 Game 的职责不单一。但是打球和算分本身就是从 Game 中拆分出来的,怎么可以拿整体相对其拆分出来的部分来比“单一”呢?这不合理。如果真的这么去比,即使把打球和算分二者拆分开后,算分的职责就“单一”了吗?也不是的,算分也可以拆分为很多不同的规则,在规则的层面看,算分的职责也并不“单一”,还需要再拆分!按照这个“单一职责”分拆下去,永远没有止境,陷入死循环。

所以“单一”是一个相对的词语,必须要看针对什么来说是“单一”的,不能单独来看。也不能因为一个事情分为两个步骤,就说这个事情不“单一”,因为这两个步骤所组成的是同一个事情,是单一的。而把这两个步骤拆分开后由两个人来分别执行,对于这两个人来说,各自的职责仍然是单一的,但是不能因此而否认二者所组成的原来那个事情不“单一”。正因为这两个人各自“单一”职 责的完成,组成了原本的那个“单一”的事情。

回过头来,如果读者明白“内聚”,站在“内聚”的角度来看“单一职责”原则, 来理解作者的“A class should have only one reason to change”这个解释,就可以秒懂作者只不过是想表达“内聚”而已。因此,读者千万不要真的从“单一职责”的角度去理解这个原则,会很容易产生误解,作者不过是想通过这一原则来表述作者所理解的“内聚”含义罢了。

掌握”内聚“,才是根本!

延展阅读

架构设计原则之我见(一):反思 KISS 原则

评论

发布