测试驱动开发(TDD)学习分享-下篇
5. 要做TDD模块如何设计
5.1 模块划分原则
嵌入式设备代码一般分4层:
- 应用层:与业务相关
- 中间层:与业务无关并为应用层提供不同功能
- 适配层:为中间层与驱动层提供隔离的作用,以保证不同平台下中间层模块不用发生改动
- 驱动层:提供外设的驱动功能
5.2 SOLID灵活并可测试的设计
为了模块便于进行单元测试,模块的设计需要遵循SOLID设计原则
5.2.1 SOLID设计原则
S ( Single Responsibility Principle)单一职责原则
O ( Open Closed Principle)开闭原则
L (Liskov Substitution Principle)列丝科芙代换原则
I ( Interface Segregation Principle)接口分离原则
D ( Dependency Inversion Principle)依赖倒置原则
5.2.1.1单一职责原则
单一职责原则(Single Responsibility Principle,SRP)是说一个模块应当只有一个单一的职责,它应该只做一件事,并且只有一个理由来改变它。应用SRP所产生的模块具有更好的聚合性,这些模块由具有共同目的的函数和数据聚在一处而构成,他们只会做一件事并且把这件事做好。
例子:
锁中的视频管理模块,其现有实现的功能包括:视频数据采集功能、视频传输功能、抓拍功能。很明显其是不满足单一职责原则的,这样就会导致当后续我们新增视频存储功能时,就需要对整个模块进行改动并对整个模块的视频数据采集功能、视频传输功能、抓拍功能进行重新的测试
如果我们讲视频管理模块分拆成四个模块:视频数据采集模块、视频传输模块、抓拍模块,则如果后续增加视频存储功能,则只要新增视频存储模块代码而不用对原有3个模块进行任何修改
5.2.1.2 开闭原则
开闭原则(Open Close Principle, OCP),一个模块应当“对扩展开放而对修改关闭”。
通过打比方来解释OCP:一个USB端口可以扩展(可以在端口插上任何USB应用设备)但不需要做任何修改来接受一个新的设备。因此,对于USB应用设备来讲,一部有USB端口的计算机是对扩展开放而对修改关闭的
当设计的某些方面遵从了OCP时,它可以通过增加新代码来进行扩展,而不是修改已有的代码。
5.2.1.3列丝科芙代换原则
与开闭原则类似,略
5.2.1.4 接口分离原则
接口分离原则(Interface Segregation Principle,ISP)建议客户代码不应该依赖于臃肿的接口。接口应该按照客户的需要而裁剪,TimeService,它就有一个非常专注的接口。在目标操作系统中可能还有很多和时间相关的函数。尽管目标OS要尽量给每个应用程序提供所有服务,TimeService却只关注我们这个系统的需要。通过裁剪接口,我们限制了依赖,使代码更容易移植,并且使得使用这个接口的代码更容易测试(多余功能的接口不暴露)
5.2.1.5 依赖倒置原则
在依赖倒置原则(Dependency Inversion Principle, DIP)中,高层次模块不应依赖于低层次模块。两者都应该依赖于抽象。抽象不应依赖于细节。细节应该依赖于抽象。(抽象出的接口不能表现出驱动的特殊性,特殊性应该隐藏在抽象的接口下)
在C语言中,我们经常使用函数指针来实现DIP,以断开不想要的直接依赖,下图的左边,LightScheduler直接依赖于RandomMinute_Get。图中的箭头指向这个依赖关系。高层次直接依赖于细节。图的右边给出了一个倒置的依赖关系。在这里高层次依赖于抽象,也就是一个函数指针形式的接口。细节也依赖于抽象。回调函数也是依赖倒置的一种形式
在需要达到以下目的时,我们会使用DIP:
- 把实现细节隐藏在接口之后
- 不在接口中揭示实现的细节
- 客户通过函数指针调用服务
- 服务能通过函数指针来回调客户函数
- 用ADT来隐藏数据类型的细节
5.2.2 C语言中的SOLID模型
5.2.2.1单一实例模块
在只需要一个实例的模块中把模块的内部状态封装起来LightScheduler已经涉及这个模型了。除了没有状态的独立函数外,这是最简单的模块形式,也可能是最常用的。LightScheduler的接口:
对于单一实例模块来讲,头文件定义了与模块交互所需的所有东西,包括定义时间常量的枚举类型以及函数原型。
调度器需要用来完成其工作的数据结构隐藏在.c文件内可见的变量中。调度器的数据不需要在头文件中出现,因为其它模块不关心。这使得其它模块无法依赖于这个结构,并且确保了它的完备性是调度器的职责
5.2.2.2 多实例模块
封装模块接口的内部状态,并且可以创建多个模块数据的实例
有时一个应用程序需要一个模块的几个实例来包含不同的数据和状态。例如,一个应用程序可能需要几个先入先出的数据结构。CircularBuffer就是一个多实例模块的例子。每个CircularBuffer可以有它自己独特的容量和内容。下面是CircularBuffer的接口:
如我们之前见到的那样,CircularBufferStruct的成员并没有在头文件暴露出来。这里的typedef语句声明有一个叫某个名字的数据结构,但把这个数据结构的成员对接口的用户隐藏起来。这会防止CircularBuffer的用户直接依赖于结构体中的数据。结构体在.c文件中定义,外面看不到
6. 进行TDD后模块的优化
6.1 三项关键技能
- 开发者要对代码中的坏味道有敏锐的嗅觉
- 开发者要能预想到更好的设计
一旦感知到了代码中的不对的地方,接下来就要利用我们对代码和设计的知识来预想到一个更好的解决方案。SOLID设计原则为结构与耦合提供了一些指导。掌握这项技能所要的时间最久,需要学习和经验。也有一些容易掌握的设计预见技巧,如选择一个好名字以及找到可以抽取的代码段落
3.开发者要能把设计从一种结构转化成另一种,在整个过程中要保持测试可以运行并通过
6.2 代码中的坏味道以及如何改进他们
6.2.1 重复代码
重复基本上是大部分代码坏味道的根本原因。重复的问题是众所皆知的,并且我在前面已经讨论过。
6.2.2 坏名字
- 让名字可读。避免使用缩写和首字母缩略字。用LightScheduler,而不是lht_sched
- 在函数名字中揭示其目标输出,而不是其内部如何工作,如:用Find(),而不是BinarySearch()。
6.2.3 坏意大利面
- 意大利细面条式代码:一种一团糟的代码,它让你无法知道它到底在做什么,这种代码的特征是有很高的“圈复杂度”
- 意大利饺子式代码:相反,由很多的完备的小模块构成,将处理过程过多的分散到了太多小的模块中。这种问题比较少
6.2.4 长函数
- 如果不能将一个函数快速地记住,那么这个函数就太长了。
- 如果代码不能装入一页中,将会发生很多的滚动查找变量定义、索引以及操作
- 一个虽然只有几行代码,但是有着复杂条件、循环以及全局数据索引的函数同样不能快速地装到脑子里
6.2.5 抽象注意力分散
- 每个函数都应当有一个一致的抽象层次。高层次的思想迷失在基本类型和操作产生的噪音里了
- 像过山车一样的抽象层次会分散注意力。抽象层次的转换应当有目的地发生。有“抽象注意力分散”问题的代码通常用修正长函数一样的方式来修正,也就是抽取函数
6.2.6 眼花缭乱的布尔运算
6.2.7 不光彩的switch/case
- 有些switch/case可能会连续几页。有些case里又有嵌套的switch或者if语句。这种代码没有办法一下子装到我们的脑子里
- 有switch/case的函数应当遵循单一职责原则。这个函数应当是关于判断条件然后做些简单的事情或者代理给其它代码来做一些工作的
6.2.8 重复的switch/case
当switch/case的逻辑重复出现,但是有不同的动作时,就是时候考虑替换他们了。应用开闭原则并使用某个设计模式替换他们
6.2.9 邪恶的嵌套
如果代码中有循环,其中又有嵌套的条件逻辑,可以考虑把它做成一个只是关于循环的函数。让辅函数在每次循环中干活。这么做的一个有趣的结果是,辅助函数的测试可以从循环中分离出来了。这对于使代码可测来讲很重要
6.2.10 依恋情结
在c语言中,这基本就是把一个结构体传来传去,或者全局都可访问。至于是哪个模块拥有这个结构体,同时它又是哪个数据结构或者可以随便被调用的函数的一部分,这可能不是很明显
6.2.11 参数太多
- 多少个参数才算多?这依赖于上下文。当多个函数声明中有一个共同的参数时,这很可能就表明需要一个新的数据结构了
- 参数太多也倾向于随时间而发生。开始,只需要两三个参数。然后又加了一个,再然后又是一个。函数开始变长,于是有人要做正确的事情来抽取出辅助函数。在这样做的时候,长长的参数被拷贝了一份。在经过几次细胞分裂,你就得到了一团糟的重复的长参数表
- 要改进这种情况,我们需要一个新的模块。重复的参数将是新模块的核心。从某个使用长参数表的调用函数将成为模块的初始化函数。其它函数则用指向新定义结构体的指针来替代长参数表
6.2.12 不管三七二十一的初始化
- 这种坏味道在你开始为以及存在的遗留代码增加测试时让你鼻孔发痒。你会发现在能够运行测试而不会让测试崩溃之前,需要做很多的手工的数据初始化
- 这个问题很有可能的一个根源是代码没有包含明确的初始化函数,数据结构倾向于被不管三七二十一地初始化。代码没有明确表明初始化和运行之间的区别
- 对这种坏味道的改进起码要把初始化的代码收集起来,放到一处,并使初始化过程很明显
6.2.13 随便访问的全局变量
“随便访问的全局数据”这种坏味道由数个全局变量和数据结构组成。数据没有明确的所有者。任何函数都可以访问任何全局数据。这些全局数据又常常受“不管三七二十一的初始化”之苦。它们同时也是一种强大的耦合力量。为有很多全局量的代码增加测试是很有挑战的,一个测试可能会因为另一个测试留在全局量里的状态信息而遭到破坏,另一件同样的坏事是其他测试开始依赖于这种状态。
文件范围的变量也会对测试产生不利的影响。在static变量中保留的数据也会在测试与测试之间互相影响。一个前一分钟还能通过的测试在下一分钟就失败了,因为一个静态(或全局)变量没有处在测试所需的状态。单一实例模块依赖于static变量,但当它提供了初始化函数之后就可以免于受数据保持的影响。
要同随便访问的全局量做斗争,可以考虑把全局量封装到受保护的函数调用中。其中的一个函数调用应该是用来正确地初始化全局量的。如果是结构体类型的全局数据,可以考虑把它转换成抽象数据类型。
6.2.14 注释
有时注释是必要的,但更多的情况下它只是个弱点。重构的目的是得到有良好结构能表明自己做了什么的代码。当没有其他方式来做到这一点时再用注释。
为什么我要这样贬低注释?注释倾向于过时。在较长的时间里,注释倾向于进入失修的状态并且变成谎言。程序员们不信任注释。Fowler还说过:“注释过期后就是一种代码中的坏味道”。
6.2.15 注释掉的代码
充满了注释掉代码的源文件看起来很丑。新人或者是回头来看这些代码的人会不明白这些代码本来是做什么的。“要把这些代码从注释中拿出来吗?”“这些代码不再需要了么?”“什么时候,在什么情况下还需要它?”“为什么要注释掉它? !”
对这种代码坏味道的解决方法很简单,删除注释掉的代码。总是能从你的源代码库中恢复它。
6.2.16 条件编译
充满条件编译的代码很难读。有时,条件编译难以避免,但应该把它当做处理多平台情况的最后选择。集中关注的条件编译方式不见得很差,但通常条件编译的做法都是把对平台的依赖分布到代码的各处。
6.3 代码重构实战
为了让你找到重构的感觉,我们会重构一个长函数。在我们重构的过程中,我会介绍一些有帮助的技术以及一个指导性的原则。
像在真实世界中一样,你大部分时间会花在从长函数中抽取函数上。除了重命名,抽取函数是最常用的重构技术。抽取函数会揭示长函数到底在做什么,提升它的抽象层次。在这个过程中,我“怀疑”我们会发现一个没用到的函数,我们会移除它。
这是什么味道?当然,这是一个长函数,其根本原因是令人眼花缭乱的布尔运算和邪恶的嵌套。现在我们需要识别出哪些思想要从代码中拖出来,放到属于它们自己的函数中。
6.3.1 展望你所希望的代码
当展望更好的代码结构时,在违反结构的代码前添加注释来代表你希望的代码,这样会很有帮助。像下面这样:
我为我希望拥有的几个函数调用增加了些注释。这些不是永久性的注释,它们只是用来帮助展望更好的代码的。更常见的做法是一次只增加一个注释,尤其是在相当长的函数里。之所以我一次给出很多是因为无论从其中的哪一个开始都可以。
从调用者的角度选择一个能够揭示其目的的函数名字。尝试让函数不再需要内嵌的注释。代码应当读起来像故事一样——给计算机极客们( geek)读的故事。
6.3.2 函数声明评估
当你要抽取一个函数时,评估一下所需的参数和返回值。让我们为每个抽取的候选者考虑一下所需的参数,然后我会调整我的决定。你可能会有不同的结论。
- 应该把time传给isEventDueNow(),还是让它自己去获取?它应该知道事件(event),还是只知道感兴趣的星期和分钟? 让我们传人时间,因为我们希望所有的事件都能用同一个时间来计算。我们也传人事件。这样会保证isEventDueNow()处在一个较高的抽象层次。isEventDueNow()会取代令人眼花缭乱的布尔运算,因此它必须返回一个BOOL类型。
- operateLight()应该知道事件吗?还是仅知道灯光操作及其ID﹖我们会传入事件。需要两个结构体的成员,而且这个函数只在这个.c文件中有用。operateLight()没有返回值。
- 看上去resetRandomize()需要知道事件,因为它不仅询问事件,它还要修改。resetRandomize()也没有返回值
6.3.3 “不要烧掉退路”原则(抽取函数原则)
6.3.4 避免抽象注意力分散
从代码行数来讲,不能说这是个很长的函数,但还是有什么地方不对了。这里面有两个抽象层次。值得注意的是,询问if(event->id!= UNUSED)与函数中其他的语句很不相同。在scheduleEvent()中做过类似的比较。让我们把代码变得DRY,把这个条件抽取到一个新函数中:
一旦抽取出的函数能编译,用新函数来代替条件语句,并运行测试。
有了isInUse(),抽象层次就一致了。让我们用一个保卫语句来替换嵌套,从而让函数更扁平
6.3.5 移除重复
在让scheduleEvent()使用isInUse()之后,我们会再回到isEventDueNow()上来,消除重重的条件逻辑。
scheduleEvent()读起来并不是很好。它实际上有两个想法在里面:在事件表中找到一个可用的位置,然后填充这个位置。
6.3.6 想法分离
让我们把查找空闲位置和存储调度数据分离开。找到空闲的位置应当是它自己的函数。但是,除非我们希望它返回一个索引,否则代码并没有准备好来抽取出一个查找函数。我倾向于让它返回一个指向可用位置的指针。对于刚刚才开始的人来说,让我们先摆脱所有这些杂乱的数组索引。
我们引人了一个指针,从数组语法改成了指针语法。运行一下测试。(我在一个“简单到不可能会错”的步骤中犯了一个编辑错误。测试抓住了这个错误。)现在我们可以把查找循环从事件数据初始化中分离出了:
有了这个改动,我们现在可以把循环的代码拷贝到findUnusedEvent()中并编译它:
在一次干净的编译后,我们把scheduleEvent()改成调用findUnusedEvent()。
这样就好多了,但是在scheduleEvent()中仍有两个层次的抽象。把事件初始化抽取出来会让它的抽象层次变平。
下面是有着一致的抽象层次的scheduleEvent():
我们现在已经有了scheduleEvent()以及它的辅助函数,让我们回到isEventDueNow()上来。
6.3.7 组织让人眼花缭乱的布尔运算
我们把一大堆乱七八糟的条件逻辑都扫到了地毯的下面:
对这段代码来说,起码的问题是:“这是调度星期吗?是调度分钟吗?”
让我们把嵌套的条件从组合条件的函数体中分离出。如果分钟不对,那么星期也就无所谓了。
把复杂的条件逻辑拷贝到新的位置
这个能编译,但还是太乱了。在我们切换到抽取出来的daysMatch()之前,先清理它
它看起来好些了,并且也编译通过了。但是有很多测试失败了。抽取出来的函数有什么地方不对了。用几次撤销操作让isEventDueNow()回到测试通过的状态。
在查找问题的过程中,我们需要改变已有的isEventDueNow()和新写的daysMatch(函数中的代码。在错误之后的撤销操作看上去有点多此一举。让我们来试一下“快速换出”技术。
6.3.8 快速换出
快速换出能让你在两种实现之间快速切换。在你试着让新的代码通过测试时,它保留你旧的可工作的代码。快速换出使用条件编译来在重构前和重构后的代码间切换,例如:
在找到这个很蠢的错误后,测试通过了。下面是可工作的daysMatch():
不要忘记快速换出的最后一步,删除快速换出中用到的条件编译。不要因为你还会再需要它而留下它,你不会需要它。它只会让将来读这段代码的程序员困惑。
现在我们把 daysMatch()独立出来了,它看上去有点不太协调。这个函数有明显的依赖情结。这看上去很像是“函数搬家”要解决的问题。
6.3.9 函数搬家
比起LightScheduler来,daysMatch()与TimeService更相关。所有用到的常量都是TimeService的一部分,并且一个参数来自Time数据结构。现在或者将来,在另一个TimeService的客户代码中如果也需要比较星期,很可能会重复daysMatch()的功能。TimeService没有在做它自己的工作。这是依恋情结的基本问题。
在我们给daysMatch()搬家之前,通过给它传入time,而不是today来让它知道TimeService。这样就消除了LightScheduler对 time的成员day0fweek的依赖。
在测试过改变以后的声明后,拷贝一份daysMatch()。选一个能让它很好地符合TimeService的名字,并让它编译通过。
我用前缀Time 来命名新函数,因为这个函数就是关于访问Time结构体的。在一次干净的编译之后,修改isEventDueNow()让它来使用Time_MatchesDayofweek()。测试应当会通过。
编译器应该会警告你现在没有用到daysMatch()。现在直接删掉它。
为了保持一致,对于minute的匹配条件也应当移到TimeService中。这使得Light-Scheduler不再有任何关于Time的内部信息的知识。我们用同样的方式来抽取这个辅助函数。
在测试通过后,isEventDueNow()变成了这个样子:
6.3.10 拆分源文件
LightScheduler的测试夹具使用链接器来对依赖于操作系统的TimeService函数测试替身做代换。有了最后的这个改动,TimeService既有平台相关的函数,又有平台无关的函数。测试应该使用平台无关的Time_MatchesDayOfweek()和Time_MatchesMinuteOfDay(),但是不要使用TimeService_GetTime()。在面向对象编程中,这称为抽象类或者部分抽象。为了模仿C语言中的链接时测试替身的概念,我们需要把源文件拆分到两个文件中。我们会用到以下3个文件。
TimeService.c:存放平台相关的代码,会被链接器覆盖。
FakeTimeService.c:存放测试替身的实现。
Time.c:存放平台无关的Time的函数。
6.3.11 测试移动后的函数
把Time_MatchesDayofweek()移到Time.c后,它应当有自己的测试。已有的测试为重构提供了安全网,但从长远的角度来看,新函数需要有自己的测试。测试是代码职责的文档,并确保任何将来Time函数的错误都能直接被它自己的测试所捕获,而不是其他测试的副产品。
6.3.12 封装数据结构
从Lightscheduler的角度来看,Time可以是一个抽象数据类型,如我们在11.2节讨论的那样,它不再访问Time的任何成员变量了。如果也没有其他客户直接访问Time的成员变量,我们就可以隐藏起它的内部结构。Time 的内部变得不再透明。
隐藏数据很重要。在千禧年之末的千年虫问题诠释了这一点。有太多的代码依赖于数据的表现形式。当一个众所周知的数据表现形式发生变化时,那么可能就要做相当严重的臧弹枪手术了。