TDD笔记3
16 Jul 2014 by LelouchHe
重要的一句话
TDD allows you…no, it requires you to make conscious design choices at all times
以前很少思考过关于设计方面的东西,一来独立设计系统的机会很少,最多就是观摩下大牛们的设计思路,然后感叹下好美,二来是自己这方面的水平,基本还是停留在鉴赏这个阶段
但这句话给我的震撼很大
首先,设计不是某个人的工作,而是一个团队的工作.当然,前期设计或者更高层级的系统设计,还是需要有更高级别的牛人进行完工的,但对于小范围,比如服务级别或小系统级别上面,再到细化到具体代码层面上的设计,还是需要每个开发者自己來cover的
其次,设计是一项有意识的活动,是需要思考的,而人是由惰性的,所以只要能不思考,一般人是不会主动想设计的问题.TDD的主要成就在于,会”require”,甚至”force”我们做有意识的设计,不论是编写test(熟悉需求),还是编写实现(代码完成),或者代码重构(设计系统),都在强迫我们主动思考这个问题.设计毕竟还不是一门工程,没有固定的算法,只有不断的一小步一小步的做起,才能有经验和感觉,才会在适当的时候做出合适的选择.而这一点,我感觉是我日后工作的一个重要方向
最后就是,”all times”的影响.设计应该是随着开发不断演化的,初期的总体设计只是为完成大部分已知确定功能而提供的设计初稿,在此基础上,通过TDD循环,不断的学习理解需求,并适应需求的变更,來完成系统的设计演化.重构,或者说重新设计,不应该作为系统开发中一个单独的过程,而是每次引入新功能,就需要完成的任务
回顾过去两年的工作,主要的精力放在了功能的实现上了,工程能力尚可,但设计这方面,没有锻炼,或者没有意识到需要锻炼,或者意识到了但不晓得如何入手,这个需要从这个角度加强了
渐进设计和前期设计
TDD遵循的是渐进设计风格(Incremental Design),即在TDD循环(Red/Green/Refactor)中不断的逐步推动设计的演化,力求在每个循环中都是*simple and clean的开发设计.在重构阶段,利用test集來保证系统原有行为不会被破坏
与此对应的是前期设计风格(Up-Front Design),即将项目的某一个阶段专门拿出来,分析需求并进行系统设计
个人感觉,前期设计在TDD中也是非常重要的,对于一个从零开始的新系统,进行前期的调研,并在此基础上进行系统主体设计,是系统开发一个非常好的开始.很难想象,一个非常复杂的系统,没有任何预备,就直接开始TDD了,这就相当于徒手伐木,却忘记了磨刀之功
前期设计最大的问题,在于开发者对于前期设计的预期和本阶段和其他阶段的配合.如果开发者只是将前期设计当作设计草稿,并且利用test-before和unit test來规范开发的流程,这压根就是TDD的另一种样子.相反,如果一昧的坚持原有设计不变动,而且将测试放在了test-after和功能验收的阶段,这就成了传统的瀑布模型,那很大程度上是无法适应变动的开发过程的
TDD并不排斥前期设计,只不过前期设计只是草稿,只是手段,在开发的初期帮助我们了解需求,并提供一个可行的前进方向.比如系统基本功能点的确定,基本接口和模块交互的确定,系统分层或通信协议的确定等等,这些如果一抹黑的从下到上TDD的开发设计,一来浪费了我们开发者的经验(见多识广的开发者可能开发过类似的东西),二来琐碎的支线功能,很有可能屏蔽掉了主要功能点,三来很难完成任务的分工和团队合作
所以,前期设计是TDD的一部分,但是要记住一点,accept that your models will almost always be wrong.纲领性的东西,领会精神就好,把握方向,脚踏实地的前进,才是真的
简单设计(Simple Design)
想完成简单设计,有以下几个建议的原则,当面对设计没有头绪的时候,可以从这些角度思考
确保代码可读性高(expressive)
代码的可读性是代码最为重要的特征,因为不论算法多么精妙,设计多么神奇,最后都要有人來进行维护和升级,或者重构,有可能是其他组员,但更大的可能性还是自己.如果代码高度可读,非常的简洁明了,就能大大加速这一过程
有句话说的好,”好的代码不需要注释和文档”.当然,我们并不是排斥注释和文档的作用,这些在某些情况下是有用的(比如阐释why和复杂算法how的时候),但对于大多数代码,最好是要做到”文如其意”,即利用抽象的代码,将概念或者操作明确的表达出来
要做到这点,最主要的手段就是抽象,就代码实现细节来说(我也不知道更高层次的说法了),有以下2个原则:
单一职责原则(SRP)
不管是一个类,还是一个函数方法,重要的一点是确保SRP,即只有一个职责,或只有一个可变因素.这样,我们通过定义合适的名称,就能非常清楚的表达出类/方法的作用,后来者甚至不用查看实现,就能明白其行为
话虽好说,但实际运用其來,并不是很直接明了,比如什么叫做职责/可变因素,哪些可变/不可变,怎么判定.结合自己的工作经验,大概有以下几个迹象,可以说明此处可以使用SRP:
- 逻辑复杂 类中有多处逻辑处理,这个时候就该思考是否应该引入新类.需要注意的是,我们并不是简单的提取公共部分,放到所谓
helper
中,类是作为概念引入的,只要原来的类中逻辑多了(比如多余1段了),就要审视设计,是否将概念混杂了,是否有新的概念需要包含进来,从而隔离开不同的逻辑.这个在函数方法中也是一样的.有一个我认为很好的判断方式,如果你准备用空行隔开不同的代码块(前提是你喜欢这样的风格),而且每块代码上面都用注释写明其行为,此时就该考虑进行拆分了 - 代码过长 函数方法行数越少,表明其逻辑越简单(当然,不要把代码拆的支离破碎然后反驳这个观点),一旦超过一定行数(我觉得这里硬性规定下15行应该可以了),就需要考虑拆分了.这个也在一定程度上提醒我们,能用STL尽量使用,性能什么的可以最后profile再说(当然,和所有依赖一样,需要通过设计來保证你不会和STL绑死了)
- Tell-Don’t-Ask 如果一个类或方法过多的操作另一个对象的数据,就可以考虑下能否把这种”Ask”再操作的行为,转变为”Tell”其他对象的操作.隐藏必要的状态,是设计的要点之一,暴露太多状态,一来不好维护状态间的关系,二来难于重构,三来状态处理的逻辑就散落在各个地方了,所以最好”Tell-Don’t-Ask”
对于函数方法而言,感觉还有一种情况比较常见
- 抽象级别不同 代码中经常出现这种情况,即有的地方是调用其他函数接口,有的地方则是硬生的逻辑编码;或者虽然都是其他函数的调用,但抽象层次不同,就好像1层函数调用了2/3/4等多个层次的接口.这样的情况,我称之为”抽象级别不同”,个人感觉,虽然这并不是个大的错误,相反,很多时候,利用封装将逻辑独立开是好事,但不同级别的逻辑放到一个地方,会给人带来一定的困惑.比如刚才还判断指针是否为NULL然后有进一步逻辑,下一步就直接看对象a是否处于4维空间双向力场的平衡点,这会给人强烈的伪和感
最后一种情况的话,就看个人喜好了,我是建议最好调整调用关系或设计结构,尽量让函数方法在一个抽象层面上处理问题,这样不仅便于阅读理解,应该也是一种良好的设计风格
命名规范
这点放在这里可能对某些人颇为搞笑,但个人认为,好的命名规范,不比好的代码实现差.没有实现,我们仍然可以通过mock的方式來定义需求和逻辑,但没有好的名字,干什么都会差了一截
但这个就属于风格问题了.个人推荐的是google代码规范中的命名,对于历史代码的话,还是应该遵循历史,尽量维持一致风格的好
需要不断提醒自己的一点是,不论是类,还是方法,或者文件,模块名称,重点永远是行为的结果(输出,偶尔包含输入,因为输入明显可以另外包含在声明原型中),让别人看到名字就知道是做什么的.至于内部如何实现,则尽量不要提及
一个建议是勤用辞典,一边命名,一边就趁机学习了英语了
消除重复(duplication)
代码上的重复,是最显而易见的,一般来说,一段代码,出现一次,是可以的,一旦再次重复出现(或类似代码),就需要思考是否需要重否來消除重复了.当然,必要的重复是可以的,比如为了增强可读性,需要保留多个地方的简单代码,这个是可控的,因为这样的特例不能太多(太多说明系统设计有问题,此时可读性肯定也有问题了)
更严重的是信息的重复,这个是个新的说法,在@GeniusVczh 的知乎日报靠谱的代码和DRY中提到的,个人感觉这个才是真正的重复,代码重复只是其中的一个部分.其中提到3种常见的信息重复场景:
- 魔数
- 代码的复制-粘贴-修改
- 耦合非正交的逻辑
信息重复,归根到底,还是没有控制的变化造成的
其实就V大提供的例子而言,如果只有这么一个应用场景,我觉得完全可以认了,只是代码的感觉不是很好(有通常意义的bad smell),所幸不会有新的变化了.一旦引入新的变化,那么V大做的考虑和修改就是非常正常的了
这里就涉及到一个问题,即如何识别变化.如果分隔符不会变化,代码上也没有重复,解析的格式也固定死了,自然就不需要要V大介绍的手段
但这个我们事先并不知道,什么要变,什么不变,并不是可控的,有可能这个变化是开发人员自己提出的,这个还好,毕竟自家事(大不了不变了呗);也有可能是第三方,比如pm/客户/其他开发提出的,这个就比较难办了
此时,一个解决方案是假设最坏情况,即所有可能变化的,都会发生变化.此时,分隔符的魔数(分割符会变),重复的代码(配置格式会变),解析配置的逻辑和处理配置的逻辑的耦合(配置格式会变,配置解释会变),就需要重点的进行处理
这个方案的缺点在于需要考虑的地方太多了,对于这个简单的例子而言,还可以接受,但对于复杂系统而言,可变的地方太多了,如果假设最坏情况的话,估计系统的复杂度会上一个层次,感觉对于项目而言不是很可行(就好像STL中的Allocator,就变的很鸡肋了)
第二个方案就是利用经验判断,即看历史上哪些确实在变,或结合项目需求,某些确实可能会变,然后再对这种可变性进行控制,不让信息重复(此处应该是指可变性的处理逻辑)
这个的好处在于简化了系统复杂度,但缺点也很明显,如果没有很好的经验的话,或辅助一定的手段,很可能将设计引向偏途.我自己在开发中就遇到类似情况,错误的假设了可变的因素,自认为不会变的结果在变,导致系统复杂,但又没有很好的解决问题
个人倾向于第2种方式,其实这里的经验发挥了在类似与前期设计中的作用,确保了开发的方向大体上是没有非常大的偏差的.当然,还是需要通过TDD的方式,來确保当原先假设果然出问题之后,能非常迅速而且正确的调整设计
这也可以看到,虽然新的技术可以解决一些问题,但经验在开发过程中是非常重要的.看一眼就能发现bad smell,这个确实需要长久的锻炼和不断的积累,在开发中慢慢培养吧
不引入不必要复杂性(unnecessary complexity)
“enough”是设计中很关键的一个要素,如何权衡并适可而止是件比较抽象的问题.TDD遵循的简单设计,着重强调了不需要,不实现的原则,任何不必要的,在不违背上述二原则的前提下,都是无须实现的
我看来主要是2点,一个是不需要或没有提到的需求,不要实现,此时不能主观臆断项目需求,而是要真真正正通过需求文档,进而通过相应的test來物化需求,无需求,无test,无实现;另一个是过早的优化(万恶之源),不论是性能方面,还是结构方面,只要尚可,就不要过分优化
总结
上述3条原则优先级从高到低,首先要保证的是可读性,其次是消除重复,最后是避免不必要的复杂性
在没有头绪,或者面临诸多方案无法选择的时候,可以参考这三点來帮助判断
阻碍重构
从前文可以看到,渐进设计,主要存在于TDD循环中的Refactor阶段.如果发现重构很困难,或者压根不想重构,就说明了当前的开发出现了问题,需要先彻底解决了问题,再进行下一步的开发工作
一般来说,问题主要有以下几种征兆需要注意
- 低效测试 测试变得耗时(每次跑test集都需要分钟级别),或者测试不全面,覆盖不了所有的需求和逻辑.此时,一个常见的错误就是不再关心测试,并且不再重构原有代码
- 长期分支 组内的分支开发,很可能带来一些长时间没有合并过的分支,导致最后的代码合并非常困难.一定要避免这种情况,渐进的将分支逻辑合并起来(往往还需要更改设计结构),并随时保持对test集的依赖和敏感
- 依赖实现 test的编写针对了某接口/行为的具体实现,一旦试图重构实现,就会带来大量的fail,然后我们就不想进行下去了.时刻注意的是,测试永远要基于接口/行为,mock也只是属于test集,而且mock也只提供行为.一旦依赖于实现,那么必然会有问题
- 历史问题 项目开发难免会遇到历史遗留代码,缺少test集,甚至无法轻易的测试.一个常见错误就是破罐子破摔,继续增加”无test难test”的代码,以求所谓兼容.要记住,此刻不改变,永远都不变.即使没有时间去系统重构原先的代码,也要在添加新代码時,按照TDD流程,重构需要改变的地方,让系统朝着好的方向改善
- 缺乏知识 无法感觉到代码的bad smell,或者觉得代码不好,但无从下手,不知道怎么來修改.其实知识是最容易弥补的,多看重构/TDD经典书籍即可.知道不改是懒,但更恐怖的是不知道
- 过早优化 为了可能的性能优化,在需要之前,进行不必要的改进.这个违反了TDD中的”enough”精神,而且往往只能带来时间和精力的消耗.在profile证明性能确实有瓶颈之前,绝对不要主观臆断这种问题,enough is enough
- 管理缺陷 现实中的项目往往有一些神奇的管理考核指标,这些指标唯一的作用就是让聪明又懒惰的开发们写出很烂的代码,比如无估计的项目进度,过于精细的前期设计等等.这个很难说什么,只能表示,作为开发,为了自己的提高,该反抗还是要反抗的.如果能参与管理,就好好的在组内实践TDD;如果只是开发,就自己做好TDD.前期可能会有些缓慢,但最后长期的结果,肯定是双赢的.如果实在有很短视又白痴的领导,直接跳槽吧
- 过于短视 类似于上一条,只不过是从开发的角度.为了完成某个目标或最后时间点,我们很容易就采取了快猛糙的实现,然后就继续下一个项目了.其实要看到,每个项目对我们而言都是锻炼,使用TDD的方式,还能增强我们的设计能力,而且最后的速度往往也不慢,同时还附带test集,优良设计和灵活重构的好处,实在不该为了短期的速度來牺牲这个提升的过程