铜剑技校

仝键的个人技术博客

0%

模型代码一致的实现模式(一)—— 导语&有意义的接口

导语

最近想明白一个道理,设计模式这种东西,在技术维度已经基本穷举了,不管是23种GoF设计模式里的工厂、策略、命令、状态什么的还是23种之外的空对象模式、全局对象模式,仔细看看,描述这些个模式解决的问题,就是业务领域无关的通用问题。即便我们算上函数式和并发设计模式,也是一样,还是业务领域无关的通用问题。也就是说这些模式解决的问题,大都是技术维度的语言就可以描述的问题。

而后续很多发展并不是在技术维度,是在如何将代码与领域模型写的更一致方向上发展了很多模式。当我去跟人交流的时候,他们经常会说,这属于实现吧。很多人总觉得相对于传统设计模式,这种专注于如何把代码写的跟领域模型一致的模式不够高大上,好吧实现就实现吧,纠正人们的偏见太累了,正好Kent Beck写过一本书叫《实现模式》,在这本书前言上他就写到:

这是一本关于编程的书,更具体一点,是关于“如何编写别人能懂的代码”的书。编写出别人能读懂的代码没有任何神奇之处,这就与任何其他形式的写作一样:了解你的阅读者,在脑子里构想一个清晰的整体结构,让每个细节为故事的整体作出贡献。
……
也可以把实现模式看作思考“关于这段代码,我想要告诉阅读者什么?”的一种方式。程序员大部分的时间都在自己的世界里绞尽脑汁,以至于用别人的视角来看待世界对他们来说是一次重大的转变。他们不仅要考虑“计算机会用这段代码做什么”,还要考虑“如何用这段代码与别人沟通我的想法”。这种视角上的转换有利于你的健康,也很可能有利于你的钱包,因为在软件开发中有大量的开销都被用在理解现有代码上了。

正好,那我们就可以遵循着大师的思路,把如何把代码写的与模型一致的这一系列模式,都叫做实现模式。一个程序员,他一定是先获得领域知识输入,然后开始看代码,在领域知识的框架指导下试图理解代码,所以将代码写的与模型一致,就是为了让程序员更好的读懂代码。

那么设计模式高大上,实现模式就不高大上吗?我看未必。这很可能是个屁股问题,而我的屁股,坐在双手沾泥的程序员一边。我觉得实现模式跟设计模式一样高大上,一样值得花很多精力去写,被很多人关注。

有意义的接口

有意义的接口,这个名字我是取自DDD中柔性设计里的第一个模式INTENTION-REVEALING INTERFACES(揭示意图的接口)。里面提到:

如果开发人员为了使用一个组件而必须要去研究它的实现,那么就失去了封装的价值。

这个点非常重要,但是我在实际工作中,看到的更多是对这个点的无视。很多人理解的可读性,只考虑你能读懂我的代码就可以了,但实际上,我们的软件蕴含的信息几乎是可以无限增加的,所以代码的复杂度自然也是无限提升的。毕竟任何问题都可以加一个中间层解决嘛,但是你有没有想过中间层这种东西加多了那本身就是问题。

很多权威的文献也是这么认为的,在《中国学科发展战略·软件科学与工程》中就提到:

软件是人类制造的最复杂的一类制品。高度灵活性使得软件不仅仅是系统中的信息处理工具,也是管理各类资源融合人机物的“万能集成器”。这就使得整个人工系统的复杂性向软件集中,驾驭复杂性的能力(复杂性的种类程度的提升),体现了软件发展的水平。

因为复杂性的可以无限叠加,一个长期存在的软件,其代码的复杂性最终都会膨胀到难以驾驭。简单粗暴地讲,相对于人脑的算力,软件的复杂度可以将看做是无限的。所以在IEEE的《软件工程知识体系指南》里在讲道软件构造的时候,也把“复杂性最小化”排在了第一位。书中写道:

大多数人在工作记忆中保持复杂结构和信息的能力是很有限的,特别是长时间保持记忆。这导致了软件构造的一个最强烈的驱动因素:将复杂性最小化。降低复杂性的要求几乎存在于软件构造的每个方面,特别是软件构造的验证和测试过程。在软件构造中,通过创建简单、可读的代码,而不是“聪明”的代码,可以达到降低复杂性的目标。

我经常说,可读性的意义,在于不读。 我们应该经常对着自己的代码审视,思考哪些代码别人不需要读也能理解自己代码的意图,让自己的代码有层次起来。其中最简单的方法就是像前面说的,把实现封装起来,在接口上把意图揭示。

书里举了一个例子:

Paint类

这是一个Paint类,其要解决的领域问题是调色。按照书上原话就是,一家油漆商店的程序能够为客户显示出标准调漆的结果。 从业务领域的角度说,它的需求是调色,那这里面哪哪都不合理。

首先,属性采用了缩写。r、y、b这三个应该是red、yellow和blue的意思,这还能猜一猜,那v是什么?但是不管能不能猜出来,用缩写其实都是有问题的。缩写从让别人听懂的角度就没有帮助还有害。很多学习材料里,一方面是历史遗留的习惯造成的,一方面是作者自己偷懒,造成了很多人学到了不好的习惯,然后写代码的时候又出于懒惰的心理,干脆就少些几个字母,这不由让我想起了,齐普夫教授的最省力原则:

说话的人希望使用最少的词汇来表现最多的含义,让自己最省力。 但对于听的人来,则希望说话者用简单易懂的词汇来表达,以帮助自己理解。

大家有这样的矛盾,要考虑到代码的生命周期里,阅读的时间要远远大于编写的时间,所以业界还是更推荐为了阅读省力,而不是为了写省力。

当然这对于很多管理不是很正规的团队,就比较难做到,而对于管理比较正规的团队,有Committer机制的,一个Committer的设置就解决了这个问题,因为毕竟Committer的身份就是读而不是写,你写起来费劲关我什么事,我读起来费劲就不让你合入主干。没有Committer机制的,也可以靠Daily Code Reivew、结对编程、代码集体所有制等手段也可以很好的做到这一点。

其次,这个paint也很有问题,paint是干啥的?你这个领域是解决调色问题的,那在调色领域里paint什么意思?

在书中,作者打开了paint的实现代码和对应的测试代码,得出结论,paint干的是调色的功能,就是把两种颜色调成一种。既然如此,你叫paint干什么,直接叫调色不就好了,所以后来就把paint改成了mixin。

整个重构如下:

Paint类重构

面向过程也需要有意义的接口

当然很多人会说,这是面向对象,如果我们写很多过程式的代码,比如C、js,也要注意这些么?其实这个跟你写什么范式的语言并没有关系,我们可以看看SICP第一章里的一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(define (square-iter guess x)
(if (good-enough? guess x)
guess
(square-iter (improve guess x) x)))

(define (improve guess x)
(average guess (/ x guess)))


(define (square x)
(* x x))

(define (good-enough? guess x)
(< (abs (- (square guess) x)) 0.001))

(define (sqrt x)
(square-iter 1.0 x))

上面的代码阐述的是牛顿法解平方根,看不懂没关系,我用js再写一遍给你看:

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
/**
* 嗯,好像用缩写了……
* 这不重要,毕竟作者会懒嘛,领会精神……
* 而且即便这么懒的作者(对不起了,Harold和二位Sussman老师),他们在觉得你可能看不懂的地方还是没用缩写不是吗?
**/
function sqrt(x) {
return squre_iter(1.0, x);
}

function square_iter(guess, x) {
if(good_enough(guess, x)){
return guess;
}else {
return square_iter(improve(guess, x), x);
}
}

function improve(guess,x) {
return average(guess, (x/guess));
}

function square(x){
return x*x;
}

function good_enough(guess, x){
return abs(x-square(guess)) < 0.001;
}

//average和abs没有定义,但是看名字也知道什么意思了,前者是求平均值,后者是求绝对值

这样写的代码,每一个函数都看做是一个接口,在读的时候不需要深入往下打开就可以连接代码,比如我们读square_iter的实现的时候,就可以看懂,他的逻辑是,猜的数字也就是 guess,如果 good enough 了,就返回猜的数字,否则继续迭代,迭代的之前,把猜的数字根据x提升(improve)一下,作为新的猜的数字传进去。至于怎么算 good enough ,怎么进行的提升,我们不需要了解就能看懂这个主干逻辑。我们看到接口就知道每一步具体做了什么事(也就是do what),至于怎么做的(也就是how),当我们需要了解的时候,再打开看。这种不需要了解就能理解主干逻辑,需要了解下层实现逻辑时再打开看的效果,又被称之为抽象屏障。即便是在面向过程的场景下,我们也可以靠函数的名字就完成了一层抽象屏障,使得我们不需要关注下面的实现,也就屏蔽了复杂度。所以有意义的接口不仅仅在面向对象的场景下有用,在面向过程的场景下,依然是有价值的,而且即便是面向对象的场景下,这种对函数名做抽象的实践也是很常用的,毕竟面向对象也都是要给“方法”起名字的。

最后的话

这个做法看起来简单,但是行业里这么做的人其实并不多,到处的文章都在说要写好文档,但其实,任何一个长期存在的系统(也不用太长,三年就很长了),我们最终都是要通过代码来了解领域逻辑的,因为文档都过时了,人员也流失的差不多了。相对于文档的过时,代码其实是存在一个契机持续提升可读性的:即每次修改的时候。每次修改的时候都需要先读懂代码,那么每次读代码的时候,发现不好理解其实都是修改的良好契机,维护的越多,代码应该变得更好读。所以抛弃代码本身的可读性去追求文档的面面俱到是不可持续的,只有提升代码的可读性从而提升软件的可维护性是一条正路,为了这个目的,有意义的接口是我们应该追求的一种实现模式。

参考资料

[1] 实现模式/(美)贝克(Beck,K.)著;李剑,熊节,郭晓刚译.修订本.北京:人民邮电出版社,2012.12
[2] 领域驱动设计:软件核心复杂性应对之道/(美)埃文斯(Evans,E.)著;赵俐等译.2版(修订本).北京:人民邮电出版社,2016.6
[3] 《中国学科发展战略·软件科学与工程》
[4] 《软件工程知识体系指南》
[5] 《计算机的构造与解释》