新书抢读 | 优化耦合一枪命中:助你代码排坑的趣味思路

余叶 2019-04-12 11:47:13
PREEMPTIVE READING
 
 

dbaplus社群 · 新书抢读-01期-

编者有言:如何选对技能进阶好书,一直是个有点纠结的难题。为了帮助大家抢读新鲜内容,把握重要技术能力,dbaplus社群全新开启新书抢读栏目,助力大家的技术进阶之路。

 
 
 

本文将带你抢先阅读第四章:无处不在的耦合

 
 

内容概览

 

一、耦合的种类

  • 数据之间的耦合

  • 函数之间的耦合

  • 数据和函数之间的耦合

  • 耦合的复杂度关系

二、耦合中既有敌人也有朋友

  • 关于招式:

    不要吝啬颁发“结婚证”

    七个葫芦娃合成一个金刚娃

三、坏耦合的原因

  • 刻舟求剑

  • “谈恋爱”是个危险的行为

  • 侵占公共资源

  • 需求变化——防不胜防

四、解耦的原则

  • 让模块逻辑独立而完整

  • 让连接桥梁坚固而兼容

五、总结

 

作者介绍

余叶现任IBM架构师,曾就职于是德科技和中国航信。爱代码,爱思考。研究过已经死亡的MFC,还精通步入颓势的.NET,之后又在方兴未艾的iOS领域做架构师,顺势玩了玩Java,了解了服务端开发。不知不觉中,前端也积累了一定的经验。越做越杂之后,有了个体面的称呼“遮羞”:全栈工程师。

 

每当评论代码的时候,我们经常听到这有耦合啊,那要解耦啊,耳朵都听出茧子了。可真被问到什么是耦合时,可能就愣住了。也难怪,这确实不太容易理解。

 

很多时候对某样东西理解困难,常常是因为对概念没有理解。如果给耦合下个通俗一点的定义,我认为可以是耦合代表各种元素之间的依赖性和相关性。而且耦合在代码里无处不在。

 

一、耦合的种类

 

耦合的种类一直很少被人谈及。本文站在数据和代码的角度进行总结,它共有三大类。

 

1、数据之间的耦合
 

 

这个最简单。假如在 Person 类里有两个成员变量,例如:

 

 

其中 name 和 age 被框在了同一个类里面,它们就产生了耦合:当你访问 person.name 时,就知道隔壁一定还有一个 person.age 数据。

 

2、函数之间的耦合
 

 

同理,如果两个函数处于同一个类中,它们也会有相关性,例如:

 

 

其中 person.GetName 和 person.GetAge 这两个功能一定是同时存在的。如果两个函数之间有调用,即使不在同一个类,也肯定有耦合,例如:

 

 

那么,DriveCar 函数就和 FillFuel 函数产生了耦合。如果 FillFuel 函数出错,也会导致

DriveCar 函数出错。

 

3、数据和函数之间的耦合
 

 

数据和函数之间的耦合形式及其变种是最复杂的,这里列举几个案例:

 

案例 1

 

我们常见的控制型代码:

 

 

那么,到底是执行Hire 还是执行Reject 呢?具体的执行流程还是取决于person.isEligible这个 bool 类型的数据。

 

案例 2

 

这里给出一个再形象点的例子:

 

 

你跳我就跳,你不跳我也不跳。我的行为紧紧耦合在你的行为之上。

 

案例 3

 

这里给出一个隐藏更深的例子:

 

 

总之,业务需求是在执行 PlayMusic 之前,必须要先执行一遍 PowerOn 函数。表面上是 PlayMusic 对 PowerOn 有依赖性,是函数之间的耦合,但背后的原因是:

 

 

这两个函数是通过 this.isPowerOn 这个数据进行沟通的。这本质上还是数据和函数之间的耦合。

 

4、总结
 

 

上面针对每一种耦合形式仅仅举了一个例子,这其实是不够的。耦合的形式多种多样,更有复合型的耦合。其他部分会陆续介绍很多耦合的例子。

 

从复杂程度而言,三种耦合的复杂度是依次递增的:

 

  • 数据之间的耦合较简单;

  • 函数之间的耦合较复杂;

  • 数据和函数之间的耦合最多变、最复杂。

 

二、耦合中既有敌人也有朋友

 

可能我们平时过于强调解耦,所以很多人误以为耦合是个贬义词,都是不好的。这里要着重澄清一下:

 

其实大部分的耦合是业务逻辑的要求,是为了满足正当的需求所产生的。

 

这样的耦合正是我们所需要的。前文介绍的所有的耦合,都是反映业务需求的。现实中,还有很多耦合是系统或者底层模块的限制所致,例如:

 

 

这种耦合虽然不是用户的需求,但也是合理的。我们必须通过代码反映出来。

 

不是每一种耦合都是有害的,区分耦合的敌我关系非常重要。对耦合要一分为二地看:世界上既有好耦合,也有坏耦合:

 

  • 好耦合:很多耦合对应着业务需求或者系统限制,这种耦合是合理的,我们将其称为“好耦合”。对于好耦合,我们有时还要强化它们:将隐式的变成显式的,将松散的变成内聚的。实际上,在我们的代码中,绝大部分耦合都是好耦合,是朋友。

  • 坏耦合:对于那些考虑不足或缺乏经验造成的预料之外的耦合,我们称为“坏耦合”,是敌人,要尽量剔除。

 

这里介绍两个很具代表性的、针对好耦合强化元素之间关联的例子。

 

招法一:不要吝啬颁发“结婚证”
 

 

在界面上的不同位置要显示多种不同的图形,如三角形、正方形等,这里所有的信息浓缩在下面两个数组里:

 

  • 一个是 shape 数组:{"三角形", "正方形", "长方形", "菱形"}。

  • 一个是 position 数组:{point1, point2, point3, point4}。

 

两个数组的元素个数是一样多的,它们是一对一的关系。比如,第一个 position 就是第一个 shape 的位置信息。那么代码如下:

 

 

这样做方便但不好。它会为以后的修改埋藏隐患。因为两个数组元素之间的对应关系,并没有得到正式承认。这好比是两个人在一起生活(没有领结婚证),就以为结婚了,但其实是不会受到法律保护的。

 

一旦在某个数组中插入或删除一条数据,就会轻易导致两个数组的对应关系彻底乱套。

 

那么如何让它们变成强关联,好比颁发结婚证一样呢?

 

可以封装到散列表里,其中每个 key 代表 shape 类型,value 就是 position 信息。这样它们之间的对应关系就彻底绑定了,例如:

 

 

这个例子是将隐式的对应关系变成了显式的对应关系,强化了耦合。就像之前是隐婚,现在光明正大地结婚了。

 

招法二:七个葫芦娃合成一个金刚娃
 

 

实例如下:在每个界面中都有一个按钮,其大小一样,长度和高度分别是 25 像素和 16 像素。

 

很多人初始这样写:将 25 和 16 这两个数据放在第一个页面中,然后顺势复制到每个页面中。

 

于是每个页面都有 25 和 16 这两个常量。

 

这是不好的!显然按钮的大小可能会经常调整,一旦修改,需要修改所有页面的 25。

 

大家要切记:数据之间若存在相关性,一定要有体现!应该定义两个全局变量,代替所有页面里的相关数字。这个变量就是合并后的金刚娃:

 

 

static readonly int width = 25; 

static readonly int height = 16;

 

width 替换所有的 25,height 替换所有的 16,而且这两个全局变量本身会透露出一条隐含业务需求:所有页面的按钮大小是一致的,而这个值就是我。

 

此外,我能很方便地控制所有的按钮大小,一旦需要修改,改一处即可。而常量 25 和 16 再多都不能表达这个信息,它们的力量是分散的。

 

如果数据丢失了必要的相关性,后期的维护也容易出 bug。这个例子是将松散的联系变成了内聚的联系。

 

招法总结
 

 

对待耦合,我们不能光谈解耦。其实强化耦合,让它们高内聚,也是优化耦合的主要任务之一。

 

大部分耦合其实是我们需要的,耦合并不是贬义词。如果你不能接受,可以用好听点的名称代替:相关性。它们本质上是一样的。

 

三、坏耦合的原因

 

我们不需要的、不应该存在的耦合,或者说不灵活、不能面向将来变化的耦合,都是怎么来的呢?这里列举几种造成错误耦合的最主要的原因:

 

1、刻舟求剑
 

 

我们在小学课本里曾学过“刻舟求剑”的故事,不理解一个人怎么会这么傻。其实这样的傻事,有些程序员每天都在上演。

 

案例 1

 

假如你每天起床依赖于自家闹钟,这样做很合适。但如果有人早起是依赖于邻居每天早上唱歌,而且是在没有告知邻居的情况下,就匪夷所思了。相关代码如下:

 

 

因为邻居的行为是不受你控制的,一旦他不唱了,你就睡过头了。

 

案例 2

 

先后执行存钱和取钱的操作,相关代码如下:

 

 

void SaveMoney(float money); // 步骤一

void WithdrawMoney(float money); // 步骤二

 

如果有假币出现,那么存钱函数 SaveMoney 就提前处理了,并不会存进去。

 

但这导致取钱函数 WithdrawMoney 从来没有遇到过假钱,而它并没有处理假钱的能力,也一直没被发现!一旦业务允许先透支取钱,那么 WithdrawMoney 函数很可能把假币给用户。

 

根本原因就是长久以来,WithdrawMoney 函数一直依赖于 SaveMoney 函数去处理假币。一旦失去了这个保护伞, 它自己的逻辑缺陷便暴露出来了。

 

这个例子具有普遍性。

 

在航天事故中有个理论:任何一个大事故的产生,背后都有 300 个小事故,而每个小事故背后,又有若干事故。当所有的小事故凑巧同时打开的时候,大事故就来了。

 

所以每个依赖邻居的行为,都无意识地制造了一个小事故,这种事故的特点是它并不容易一次性地测试出来,它像有些病毒一样,发作是有潜伏期的。它的个体危害不算大,但整个身体充满了这种小病毒,那么身体迟早要被击垮。

 

要解决这类耦合,有一个非常行之有效的方法:单元测试。虽然这种耦合表面上不是 bug,因为业务暂时都能通过,但通过单元测试,很容易发现这里存在潜在的 bug。

 

2、“谈恋爱”是个危险的行为
 

 

如果有两个数据,你中有我,我中有你,形成双向依赖,这种“谈恋爱”的方式是危险的,因为一方要分手,会给另一方造成麻烦。

 

而暗恋是美好的,他只是默默注视着,却从不打扰你, 一旦遇到了变更,也能轻易地更换注视目标。“轻轻的我走了,正如我轻轻的来”,你也从来没有感受到变化,自然就不会有困扰。

 

举例:

 

 

这里的 StudentModel 和 StudentController 本来属于 MVC 架构里不同层级的对象, 它们形成的双向依赖,纠缠在一起,很难分手。

 

它们耦合在一起,对适应将来的变化是不利的。而理论上,model 层并不应该知道 controller 的具体细节。那么,如何将它们转为单相思呢?后续我们会慢慢介绍。

 

3、侵占公共资源
 

 

假如一个公共变量,你错误地修改了它,则直接影响到所有使用它的人。这种耦合导致的错误可能是非常可怕的。

 

我们熟知的多线程编程其实就是最典型的“影响公共资源”的耦合场景。多线程编程为什么那么难?本质上它是耦合复杂度的最极端体现。比如死锁,那等于耦合到了极致,完全成了一团乱麻,无法剥开了。

 

对付这种耦合,我们需要尽量做到公共资源是不可变的,或者操作它的途径非常有限、可控。

 

4、需求变化——防不胜防
 

 

前几种坏耦合都属于在同一时间线发生的结构性耦合,接下来介绍的耦合属于另外一种情况,是跨越不同时间线产生的耦合,也就是需求发生了变化而导致的坏耦合。

 

我们说每一个好耦合都对应真实的需求,但如果需求本身改变了,那么好耦合也就变成了坏耦合。但“需求变化”的含义太大了,可以分为很多种类。

 

  • 有的是硬性的需求变化。比如,你开发好了一个“五子棋”游戏,结果老板告诉你“五子棋” 大家不太喜欢,还是改成“围棋”吧,此时和“五子棋”相关的代码都要改。

  • 有的是软性的需求变化。它所带来的影响可能更多是我们自身缺乏远见造成的。比如,全世界有很多大城市的老城区,你会发现那里的街道太窄,小区也没有足够的停车位。因为人们没有预料到十几年后这里居然人会这么多,车会这么多,而拆除重建的代价是巨大的。

 

这种情况是很普遍的,造成这点的现实原因有很多:比如很多时候,写第一遍的代码,往往迫于项目进度的压力,先做个简陋版本,拿到第一期经费再说。随后重构的话也能承担,因为程序员知道:我们比建筑工人幸运,软件的重构成本要比拆房重建低多了。

 

重构的目标应该是:重构后,能一次性解决可预见的问题,即对某一个具体需求的重构有足够的远见。

 

如果你对一个停车场扩容,没两年,停车场又饱和了,这是很不好的。

 

我们要一边迭代开发,一边重构。重构也算是迭代开发的任务,因为随着项目体积越来越大, 我们也需要更好的架构支撑自己。否则,积重难返,大厦很难继续往上累加。

 

一般项目在迭代开发的时候,有两分精力是放在用户看不到的内部优化中,八分精力放在新需求的开发上,这样整个产品的质量在持续迭代中才能有很好的保障。

 

四、解耦的原则

 

每一个模块好比大海里的一座孤岛,需要桥梁和其他孤岛连接,那么通过多少桥梁相连呢? 

 

只要有需求对应,那么建立多少桥梁都没有问题。但我们经常会无意地建立很多埋在水面之下的隐形桥梁,并没有与之对应的需求,这是坏耦合。

 

如何破除这些隐形桥梁,强化模块间的连接,请看接下来介绍的两个解耦原则。

 

1、让模块逻辑独立而完整
 

 

我们做人要求人格独立而完整,代码也一样,尽量让每个模块的逻辑独立而完整。解耦的根本目的是拆除元素之间不必要的联系,一个核心原则就是让每个模块的逻辑独立而完整。这里有两个含义:

 

  • 对内有完整的逻辑,而所依赖的外部资源尽可能是不变量。

  • 对外体现的特性也是“不变量”(或者尽可能做到不变量),让别人可以放心地依赖我。

 

充分做到了这一点,元素间很多不必要的联系会自然消失。如何做到独立而完整,这个话题实在是太大了,而且手段很多,并没有一个特别标准的流程。本节中,我们只研究一种最容易上手的方法——如何让单个函数的逻辑独立而完整。

 

有的函数光明磊落,它和外界数据的沟通仅限于函数的参数和返回值,那么这种函数给人的感觉可以用两个字形容:靠谱。

 

它把自己所需要的数据都明确标识在参数列表里,把自己能提供的全集中在返回值里。如果你需要的某项数据不在参数里,你就会依赖上别人,因为你多半需要指名道姓地标明某个第三方来特供;同理,如果你提供的数据不全在返回值和参数里,别人会依赖上你。

 

有的函数让人觉得神秘莫测,规律难寻:它所需要的数据不全部体现在参数列表里,有的隐藏在函数内部,这种不可靠的变量行为很难预测;它的产出也不集中在返回值,而可能是修改了藏在某个不起眼角落里的资源。

 

这样的函数需要人们在使用过程中和它不断地磨合,才能掌握它的特性。

 

前者使用起来放心,而且是可移植、可复用的,后者使用时需要小心翼翼,而且很难移植。下面看两个案例是如何做到逻辑独立而完整的:

 

案例 1

 

在每个数据库操作函数中,都有一对 db.open 和 db.close 语句。例如 updatePersons函数的代码如下:

 

 

可是在 update 函数里面也有数据库的操作,却无须 sharedDB 的 open 和 close 语句。

 

这是因为 update 是在 updatePersons 函数中调用的,而 sharedDB 在 updatePersons 函数中已经被打开了,也将在 updatePersons 函数中关闭,所以 update 的实现为:

 

 

如果 update(Person person)是一个private 函数,这没有太大不妥;如果 update 是一个 public 函数,那么如此实现是不合格的:很明显,它里面并没有打开数据库的操作,所以它的 ExecuteSQL 语句依赖于“sharedDB 处于 opened 状态”这个条件。

 

但这个约束是隐形的, 用户并没有得到有效的提示。如何解决或优化这个问题呢?我们不妨将数据库资源参数化:

 

 

之后对 update 的调用变成了:

 

 

这样做的好处如下:

 

  • 多了一个 DBConnection 类型的参数,逼迫别人要传进来一个数据库连接变量。

  • 参数名 openedDB 已经明确指明了该 DB 的特性,能给用户有效的提示:传进来的需要是已经处于 open 状态的数据库连接。

 

从此,update(Person person, DBConnection openedDB)函数已经无须调用者专门关注它被使用的前提隐含条件,因为它自身的对外信息描述得足够清楚了。一旦具备了这个特征, 它就初步具备了可移植性。

 

所以,当程序员对一个类或一个方法的使用需要额外的记忆时,这不是好代码。我们要尽可能地让代码远离那些隐含的前提条件。这样程序员在使用的时候,才不会觉得处处是坑。

 

案例 2

 

这个案例稍微长一些,但并不难,所以请耐心地一步一步跟着我的节奏去看。例如,一个人要读书:

 

 

如果这个人没有眼镜,即 this.MyGlasses 变量为 null,直接调用 person.ReadBook(book);会出现异常,怎么办呢?

 

优化一:通过属性注入

 

于是打个补丁逻辑吧,在 ReadBook 之前先给他配副眼镜:

 

 

person.MyGlasses = new Glasses(); // 先为person 配副眼镜

person.ReadBook(book);

 

如上,加上了 person.MyGlasses = new Glasses();这行代码(别看它简单,人家也有专业叫法,叫作属性注入),这个  bug 就解决了。可解决得不够完美,因为这要求每个程序员都需要记住调用 person.ReadBook(book)之前,先进行属性注入:

 

 
person.MyGlasses = new Glasses();

 

这很容易出问题。因为 ReadBook 是一个 public 函数,使用上不应该有隐式的限定条件。

 

如今,“看书”依赖于“眼镜”的存在是个刚性的业务需求,所以这个耦合是没办法消除的。我们能做的是要减轻程序员的记忆负担,无须强行记住“每次调用 ReadBook(book),还必须先初始化 person.MyGlasses”这么一个坑。

 

这种问题相信每个人都遇到过,如何优化呢?

 

优化二:通过构造函数的注入

 

我们可以为 Person 的构造函数添加一个 glasses 参数:

 

 

这样,每当程序员去创建一个 Person 的时候,都会被逼着去创建一个 Glasses 对象。程序员再也不用记忆一些额外需求了。这样逻辑便实现了初步的自我完善。

 

当 Person 类创建得多了,会发现构造函数的注入会带来如下问题:

 

因为 Person 中的很多其他函数行为,如吃饭、跑步等,其实并不需要眼镜,而喜欢读书的人毕竟是少数,所以person.ReadBook(book);这句代码的调用次数少得可怜。

 

为了一个偏僻的 ReadBook 函数, 就要让每个 Person 都必须配一副眼镜(无论他读不读书),这不公平。也对,我们应该让各自的需求各自解决。

 

那么,还有更好的方法吗?下面介绍的“优化三”进一步解决了这个问题。

 

优化三:通过普通成员函数的注入

 

于是可以进行下一步修改:恢复为最初的无参构造函数,并单独为 ReadBook 函数添加一个 glasses 参数:

 

 

对该函数的调用如下:

 

 
person.ReadBook(book, new Glasses());

 

这样只有需要读书的人,才会被配一副眼镜了,实现了资源的精确分配。

 

可是呢,现在每次读书时都需要配一副新眼镜:new Glasses(),还是太浪费了,其实只需要一副就够了。

 

优化四:封装注入

 

好吧,每次取自己之前的眼镜最符合现实需求:

 

 
person.ReadBook(book, person.MyGlasses);

 

这又回到了最初的问题:person.MyGlasses 参数可能为空,怎么办?

 

干脆让 person.MyGlasses 封装的 get 函数自己去解决这个逻辑吧:

 

 

对 ReadBook 函数的调用如下:

 

 
person.ReadBook(book);

 

这样每次读书时,就会复用同一副眼镜了,也不会影响 person 的其他函数。最终的这段 ReadBook 代码是最具移植性的,称得上独立而完整。

 

可以看到,从优化一到优化四,绕了一圈,每一步修改都非常小,每一步都是解决一个小问题,可能每一步遇到的新问题是之前并没有预料到的。

 

优化一到优化三分别是 3 种依赖注入的手段:属性注入、构造函数注入和普通函数注入。它们并没有优劣之分,只有应用场合之分,这里我们是用一个案例将它们串起来介绍了。

 

同时大家通过这个小小的例子也可以体会到:写精益求精的代码,是需要工匠精神的。

 

让每一个模块独立而完整,其内涵是丰富的。它把自己所需要的东西全列在清单上,让外界提供,自己并不私藏。

 

这意味着和外界的关联是单向的,这样每个模块都变得规规矩矩,容易被使用。如果模块要被替换,拿掉时也不会和周围模块藕断丝连。

 

那么,问题来了,如果都做“缩头乌龟”不去关联别人,可是那么多的联系总得有人去实现, 谁去实现呢?最好让专门管理“桥梁”的模块去实现,这就涉及到了下一个解耦原则。

 

2、让连接桥梁坚固而兼容
 

 

前面说了,模块好比孤岛,孤岛之间需要桥梁去连接。而我们需要这些桥梁坚固(具有不变性),还可以兼容各种岛屿(具有兼容性)。

 

这个原则太重要了,尤其要减少前文所提到的“需求变化”所带来的影响,主要就是靠桥梁的质量来应付。

 

解决这种耦合是需要架构师提前预判的,我们要尽量让变化落在岛屿上,而不是桥梁上。因为更换桥梁的成本要更高,风险要更大!

 

指导原则说完了,还有哪些具体招数呢?书中会有更多方法介绍。

 

五、总结

 

耦合不是贬义词,它的本质是相关性。如果符合业务需求,反映底层系统限制,就是好耦合; 否则,就需要解耦。

 

解耦的手法多种多样,需要不断地积累。

 

 

最新评论
访客 2023年08月20日

230721

访客 2023年08月16日

1、导入Mongo Monitor监控工具表结构(mongo_monitor…

访客 2023年08月04日

上面提到: 在问题描述的架构图中我们可以看到,Click…

访客 2023年07月19日

PMM不香吗?

访客 2023年06月20日

如今看都很棒

活动预告