版本发布记录
时间 | 发布版本 | 描述 |
2013.12.24 | 驯服烂代码_2013.12.24 | 第1章,第1次修订。添加了“提示”和小结。 |
2013.12.30 | 驯服烂代码_2013.12.30 | 第1章,第2次修订。统一了code kata, coding dojo和code retreat的译法。 |
|
|
|
【按】想要更好的阅读体验,不妨下载本文PDF格式:http://vdisk.weibo.com/s/BMad7TpZycUiB。本文是我正在撰写的《驯服烂代码:测试先行的编程操练》的前面章节的手稿,供各位网友试读。期待指点!欢迎转帖,恭请转帖时注明出处:http://blog.csdn.net/wubinben28/article/details/17527505谢谢。联系作者可查找我的新浪微博:@伍斌_Ben
本书章节规划参见:http://blog.csdn.net/wubinben28/article/details/17535077
本书选题思路、读者对象和读者特点:
l 选题思路:通过编程操练的形式,启发读者学会自己找到提高自身编程技能来驯服烂代码的方法。
l 读者对象:
n 正在学习某编程技能的初学者:如仅限于看书来提高技术的初学者、想学习一门新语言或新工具的程序员、想掌握一个开发新方法如测试驱动开发TDD的程序员、正在学编程的计算机专业的学生、正在学编程的非计算机专业的学生;
n 有工作经验但总感觉自己在写烂代码的程序员:被动地套用各种开发框架的程序员、一边改一边骂别人的烂代码、同时又无奈地继续写新的烂代码的程序员;
n 苦于团队编程技能难以提高且烂代码横行的CTO、开发总监及开发经理。
l 读者特点:尚未使用TDD编程;不熟悉敏捷软件开发,不了解极限编程;不了解编程操练和编程招式;不知道如何正确地写测试;没有掌握解耦入测试的技巧。
第1章 “刻舟求剑”的文档
“什么是软件?”上个世纪90年代初的一个冬日,在北京东南近郊的一所大学里,一位年近花甲的老师,给我们这些计算机系的学生讲软件工程这门课时,问了这个问题。对于那个时候几乎没有多少机会摸电脑的我来说,软件就是学校机房里那些DEC小型机上令人费解的的命令,和286个人电脑里那些好玩的吃豆子和赛车的游戏。 “软件不仅仅是程序,还包括描述程序的文档。软件就是程序加文档。”老师对软件的定义,深深地刻在我的脑子里,其程度之深,在之后的很长一段时间里,总令我觉得文档对于软件的影响力,似乎要盖过程序。
这一点在我大学毕业后二十年的软件开发相关工作的实践中,不断地得到印证。各种各样的文档——需求文档、概要设计文档、详细设计文档、测试文档等等,在我所先后经历的多个软件开发项目中,始终占据着重要的地位。“毕竟,只要文档在,就不怕开发人员的频繁流动。”一位软件开发经理这样对我说。除了要写Word或Excel的文档,程序员们还被要求在源代码中写尽量详尽的注释。在软件开发经理眼中,撰写文档和编写注释的习惯,是衡量程序员是否称职的一项重要标准。
“为保证客观性,软件开发完成后,应该由不同的人来对其进行测试。”老师的这句话也时时萦绕在我的耳边。这句话的影响力是如此巨大,以至于当我在前些年做程序员时,我和周围的程序员们,都一致认为测试就是测试工程师的事情。在十多年中,我经历的每一个项目,都无一例外地有一个独立于开发团队的测试团队,开发团队将代码开发完成后,简单地在自己机器上跑一跑,然后就提交代码并丢给测试团队去测试。
十几年来,不管是开发新功能还是修复bug,我一直在努力地撰写文档,编写和修改代码及注释,然后交给测试人员去测试,再去改测试人员提出的bug,这一切看起来都像教科书上描述的那样地完美和正确。但是我最后却难过地发现,用这种方法开发出来的软件,无一例外地逐渐沦为烂代码。在烂代码的沼泽里,即使有文档,也读不懂代码;即使bug很小,也不敢修改代码。我甚至怀疑,这种方法或许会助长烂代码的滋生。让我们先用这种传统的瀑布式开发方法,做一个编程操练,来看看其中会有什么问题?
提示:编程操练,即英文Code Kata的中译,是《程序员修炼之道:从小工到专家》(The Pragmatic Programmer:From Journeyman to Master)一书的合著者、美国程序员Dave Thomas大约在2003年前后创造的字眼,表示一个编程练习,程序员可以通过反复地操练该练习来提高自身的编程技能。Kata是一个日语片假名かた的英译,对应的汉字是“型”或者“形”,表示供单人或双人进行操练的、经过仔细编排的动作模式。[1]
编程操练,说白了其实就是程序员练功时对一个编程题目进行练习。说起练功,我就能很自然地联想到京剧演员练习压腿和踢腿,相声演员练习绕口令和开声,和习武之人的独自站桩和与人过招切磋。与之相比,程序员的练功似乎就没有那么讲究。从上世纪80年代面向对象的编程语言出现以来至今这30多年的时间里,国内的绝大部分程序员的所谓“练功”,仅仅停留在读一些技术书籍和博客,顶多再照着示例代码写一些程序,运行一下而已。即使在程序员编程水平很高的国外,直到2004年5月,法国程序员Laurent Bossavit才写了一篇有关多位程序员在一起做编程操练的“编程道场”的博客。这里的“编程道场”是英文coding dojo的中译,其中的dojo同样也来自于日语,是片假名どうじょう的英译,对应的汉字是“道场”,指一个正式的训练场所,来供学习日本武术的学生聚在一起进行操练。
编程道场意指多位程序员聚在一起,用两人结对的形式,做编程操练的过程。编程操练和编程道场在国外已经发展了近10年,其影响力不断扩大,到2009年又出现了编程静修(code retreat)的新的形式,即几十个程序员聚在一起,用一整天的时间来在编程道场中轮流结对做编程操练。编程操练、编程道场和编程静修这几年在国内也陆续得到一些发展,比如挪威程序员Mike Long于2011年12月3日,在北京发起了“编程静修全球日”(Global Day of Coderetreat[2])北京站的活动,从那以后到撰写本书时,Mike每年12月都在北京举办一次编程静修的活动,我有幸参加了其中2012和2013年的活动。另外,在撰写本书时,我受《重构与模式》(Refactoringto Patterns)一书的作者JoshuaKerievsky于1995年在美国纽约创办设计模式学习小组的启发,于2013年4月在北京创办了免费公益的“北京设计模式学习组[3]”,到撰写本书时,已举办12次活动,每次能吸引8~20位程序员来进行结对操练编程技艺。这一切似乎都在表明,编写程序不再仅仅是按照既定的软件架构或框架,来像垒砖那样被动地“填”代码,而是像唱京戏、说相声、练武术那样,更加强调人的创造性,是一门需要反复操练才能悟道出师的手艺。
既然编程操练是供程序员在没有工作压力的情况下练功时所使用的,为了能够让程序员们在操练时获得更有趣的体验,编程操练需要设计得“有趣”,即除了题目的内容可以是生活中有意思的场景外,最好还能通过实现这个操练,练习一些有挑战性的技能,比如结对编程和设计模式。
对于本书中所有的编程操练,我都将邀请您——我的亲爱的读者——来与我一起进行结对编程[4]。
R:“啊?什么是结对编程?我从来没有尝试过哩!”
结对编程其实一点都不神秘,如果把编程比作打网络游戏,结对编程就好比两个人结伴去打魔兽,除了可以相互学习切磋之外,还能相互有个照应。好了,一起来看看本书的第一个编程操练。
1.1 酒店世界时钟操练
这个操练是我于2013年9月,为在“北京设计模式学习组”的第9次活动中操练Observer设计模式而编写的。灵感来自于我在酒店下榻时,在大堂里看到的那些墙壁上悬挂的显示世界上各个主要城市的时间的时钟。我在想,如果这些时钟都走时不准,一个个地分别调时间太麻烦,要是能够只调准一个城市的时钟,其余城市的时钟都能根据时差相应地自动调准,那该多好。
图 1-1 酒店世界时钟 |
比方说在一家酒店的大堂里,有5个时钟,分别显示北京、伦敦、莫斯科、悉尼和纽约的时间。其中,伦敦与UTC(Coordinated Universal Time,协调世界时)时间[5]保持一致,北京比UTC时间早8小时,莫斯科比UTC时间早4小时,悉尼比UTC时间早10小时,纽约比UTC时间晚5小时。若所有这些城市的时钟都多少有些走时不准,需要调整时间时,只需调准其中任意一个城市的时间,其余4个城市的时间能够相应地自动调整准确。酒店世界时钟如图1-1所示。
在程序员中,熟悉Java语言的人数相对较多。那么咱们能不能用Java语言,实现上面这个编程操练呢?
R:“好吧,需求已经说得很清楚了。正好前段时间我刚刚读完一本讲ICONIX过程[6]的书,正好可以用这个编程操练来练习一下UML和用例驱动的对象建模。”
“不错!编程操练的一大特色就是可以用来实践自己学到的任何新东西。咱们可以试试看。”
1.2 需求描述
R:“首先把功能性需求整理成下面这样的需求列表,并编上号。”
1)REQ01:酒店的大堂里,有5个时钟,分别显示北京、伦敦、莫斯科、悉尼和纽约的时间。
2)REQ02:伦敦与UTC时间保持一致,北京比UTC时间早8小时,莫斯科比UTC时间早4小时,悉尼比UTC时间早10小时,纽约比UTC时间晚5小时。
3)REQ03:若某个城市的时钟走时不准,需要调整时间时,只需调准该城市的时间,其余4个城市的时间能够相应地自动调整准确。
R:“把需求编上号,将来实现和测试这些需求时就好跟踪了。”
1.3 领域模型
R:“领域模型定义‘系统能够做什么’这样的功能需求,重在解决沟通误解的问题。它关注项目中所有概念的‘准确性’,需要建立描述问题领域的通用词汇表,来消除误解和增强概念的准确性。这个词汇表会随着项目的进展,不断地完善和更新。”
“噢,听起来不错,那么设计领域模型的第一步需要做什么呢?”
R:“找出领域类。从上面那个需求列表里找出一些重要的名词,可以作为初步的领域类。”
“嗯,依我看重要的名词有:北京钟、伦敦钟、莫斯科钟、悉尼钟、纽约钟、UTC时间。或许还应该有酒店员工。”
R:“对。先用这些名词,以后再继续调整。下一步咱们可以创建词汇表,来描述这些名词。”
这样就有了如表1-1所示的词汇表。
表1-1词汇表
中文词条 | 英文词条 | 含义 |
北京钟 | BeijingClock | 酒店大堂中显示北京时间的钟 |
伦敦钟 | LondonClock | 酒店大堂中显示伦敦时间的钟 |
莫斯科钟 | MoscowClock | 酒店大堂中显示莫斯科时间的钟 |
悉尼钟 | SydneyClock | 酒店大堂中显示悉尼时间的钟 |
纽约钟 | NewYorkClock | 酒店大堂中显示纽约时间的钟 |
UTC时间 | UtcTime | Coordinated Universal Time,协调世界时,是全世界用于调整时钟和时间的主要时间标准 |
酒店员工 | HotelEmployee | 在大堂中调整城市时钟的酒店员工 |
R:“下一步就可以画领域模型类图了。为了让领域模型类图更有条理,可以从这5个城市的时钟抽象出一个‘城市时钟’类。现在可以先在词汇表中添加一行,表示‘城市时钟’。”
在词汇表中添加的那一行如表1-2所示。
表 1‑1词汇表中增加的一行
中文词条 | 英文词条 | 含义 |
城市时钟 | CityClock | 从各个城市的时钟中抽象出来的类 |
R:“这5个城市的时钟都继承这个‘城市时钟’类,是泛化关系。而‘城市时钟’类中又包含一个UTC时间类,是聚合关系。”
领域模型类图如图1-2所示。
图 1‑2 领域模型类图
R:“接下来,可以画一个用户界面草图。再结合前面的需求列表、词汇表、领域模型类图,就可以进行Use Case用例模型的分析,来定义用户与系统之间该如何交互。最后再绘制健壮图,进行健壮图分析,引入边界对象、实体对象和控制器这三个对象,这样咱们这个操练的对象就设计好了。”
“什么是边界对象和实体对象?”
R:“边界对象是系统与外部世界的接口,比如用户界面或网页,相当于MVC设计模式中的View。实体对象是领域模型中定义的领域类。”
“哦,咱们能不能先不考虑这个编程操练的用户界面?直接使用领域模型类图开始编程了呢?”
R:“可以。用户界面设计起来也挺麻烦的,为这个小小的编程操练也犯不上。等这些类都编写完了,最后再写一个main方法来调用一下这些类,测一测就好了。不过Use Case用例图还是画一下吧,工作单位一直严格要求我们程序员写这类文档,还是操练一下吧。”
用例图如图1-3所示。
图 1-3 用例图 |
R:“首先,酒店员工这个角色与‘Update the time of one clock’这个用例打交道,来更新一个时钟的时间。然后这个用例会调用‘Update the time of all other clocks automatically’这个用例,表示自动更新其余城市时钟的时间。”
1.4 使用设计模式
“等等。咱们不妨看看这个操练的场景是否有设计模式可以适用,这样可以借鉴前人的经验,而不用自己闭门造车了。”
R:“嗯,好主意!可以快速浏览一下四巨头的23个设计模式[7]的意图……嗯,看起来Observer观察者模式的意图正好和咱们的编程操练相吻合。‘定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。’这不正好是调整一个城市时钟的时间,其余城市的时钟都能自动更新时间吗。这样咱们可以把上面的领域模型类图照着四巨头画的类图改一改。”
在四巨头的《设计模式》一书中Observer模式的UML类图如图1-4所示。
图 1-4 四巨头书中的Observer 模式的类图
更新后的领域模型类图如图1-5所示。
图1-5 更新后的领域模型类图
1.5 详细设计
R:“现在类图有了,下面可以参考四巨头的类图来细化咱们的类图,在每个类上添加暴露给外界的接口,也就是公共方法。”
细化后的类图如图1-6所示。
图 1-6 细化后的类图
你开始为我解释这张细化后的类图。
R:“为简化起见,对于时间咱们只考虑小时,所以时间都用int类型来表示。”
R:“在细化后的类图中,TimeSubject类可以用一个名叫cityClocks的HashMap来保存所有5个城市的CityClock类的对象。为了便于向HashMap中添加或从其中移除城市的对象,需要有attach()和detach()这两个方法。TimeSubject类的notify()方法所要做的事情在图中用一个备注框标出来了,即对于TimeSubject类的cityClocks这个HashMap成员变量,用一个for循环来调用其中保存的每一个CityClock对象中的updateCityTime()方法,来将所有城市的时间进行自动更新。而这个notify()方法,可以通过UtcTime的setUtcTime()方法来触发调用。”
R:“CityClock类有一个私有的成员变量cityTime,用于保存各个城市当地的时间;它还有一个成员变量utcTime,用来保存一个指向UtcTime类的引用,以便于从UtcTime中取出UTC时间,来计算自己所表示的城市的当地时间cityTime。它的每一个子类,都有一个UTC_OFFSET的私有静态成员变量,来保存每个城市相对于UTC时间的时差。这5个子类所继承的那个updateCityTime()方法由TimeSubject类的notify()方法来调用,刚才已经说过了,而这5个子类所继承的另一个方法setUtcTime(),是用来调用UtcTime类的setUtcTime()方法的,进而能够触发调用TimeSubject类的notify()方法,从而能够实现更新一个城市的时间,就能自动更新所有城市的时间。”
R:“UtcTime类扩展了其父类TimeSubject,且有一个utcTime私有成员变量,用来保存UTC时间。”
“很好!现在可以编程了吧。”
提示:事先做细致的详细设计看起来很好,但是上面那张细化后的类图中的细节,会随着下面编程的进行而逐渐发生更改,这使得这份类图会逐渐过时。
1.6 编程实现
R:“现在,空项目已经建好了,github也配好了。咱们按照细化后的类图来编写第一个类TimeSubject[8]。”
下面就是TimeSubject类的代码:
public abstract class TimeSubject {
protected staticHashMap<String, CityClock> cityClocks = new HashMap<String,CityClock>();
public static voidattach(String cityName, CityClock cityClock) {
cityClocks.put(cityName,cityClock);
}
public static voiddetach(String cityName) {
cityClocks.remove(cityName);
}
public abstract voidnotifyAllCityClocks();
}
R:“如果按照类图来实现,由于这个类是个抽象类,没法实例化,所以我就把成员变量cityClocks和attach()与detach()这两个成员方法都搞成静态的。成员方法notify()是抽象的,留给它的子类来实现。哦,方法名notify()已经被Object类给占用了,notifyAll()也被占用了,所以只好把notify()改名叫notifyAllCityClocks()了。”
提示:详细设计中的想得很好的、言简意赅的类、方法或变量等的命名,因被编程语言所占用而被迫改为较长的名字,真是令人扼腕叹息!
“现在CityClock下面有红线,表示这个类还没有定义。咱们现在写这个类吧。在IntelliJ里,你可以把光标移到CityClock中,然后敲Alt+Enter键,就能让IntelliJ自动帮你写这个类。”
R:“哦,这么方便!你要是不说,我还要傻乎乎地一点点地写呢。”
提示:结对编程能够为程序员之间相互传递知识提供良好的机会。如果在团队内部长期坚持结对编程,且每天交换结对搭档,一方面可以整体提升团队的开发技能,另一方面也可以解决因负责某模块开发的程序员生病、休假等原因造成的开发人力资源短缺的问题。
在IntelliJ的帮助下,按照类图写出的CityClock类如下所示:
public abstract class CityClock {
protected int cityTime;
protected UtcTime utcTime;
public abstract voidsetUtcZeroTime(int utcZeroTime);
public abstract voidupdateCityTime(int utcZeroTime);
}
R:“呃,utcTime这个名字真的让我有点纠结,它有两个含义,既可以指UtcTime这个类的一个对象,也可以指UtcTime这个类中的用来保存UTC时间的那个成员变量。为了区分,我把后者改名叫utcZeroTime,表示与UTC时间的时差为0的时间。所以CityClock类中抽象方法setUtcTime()改名成为setUtcZeroTime(),它和抽象方法updateCityTime()的输入参数也改名为utcZeroTime了。”
提示:在编程过程中,随着程序员对于要解决的领域问题的理解不断地深入,原先在详细设计中设计好的类、方法和变量的命名会变得词不达意。随时修正代码中的这些用词不当的命名,使其表达程序员对于所解决问题的最新理解,能便于将来自己和他人维护这段代码。
“嗯,把那个类图的文件传给我一份。这样你一边写代码,我一边在我的电脑上修改那个类图。”
R:“好的。用QQ传给你吧。CityClock类中的UtcTime下面标出了红线,咱们该创建这个类了。还是用Alt+Enter来帮我创建,这个快捷键真是太好使了!”
创建出的UtcTime类如下所示:
public class UtcTime extends TimeSubject {
private int utcZeroTime;
@Override
public voidnotifyAllCityClocks() {
Iterator<CityClock>cityClockIterator = cityClocks.values().iterator();
while(cityClockIterator.hasNext()) {
CityClock cityClock =cityClockIterator.next();
cityClock.updateCityTime(utcZeroTime);
}
}
public intgetUtcZeroTime() {
return utcZeroTime;
}
public voidsetUtcZeroTime(int utcZeroTime) {
this.utcZeroTime =utcZeroTime;
notifyAllCityClocks();
}
}
R:“在UtcTime类里,成员变量utcTime改名为utcZeroTime了。notifyAllCityClocks()方法里有一个循环,更新所有城市的时钟的时间。setUtcZeroTime()方法里会调用notifyAllCityClocks()方法。”
“嗯。接下来做什么?”
R:“接下来,该一个一个地实现那5个城市的时钟类了。先从BeijingClock开始。”
BeijingClock类的代码如下所示:
public class BeijingClock extends CityClock {
private static final intUTC_OFFSET = 8;
@Override
public voidupdateCityTime(int utcZeroTime) {
cityTime = utcZeroTime+ UTC_OFFSET;
}
}
R:“BeijingClock类实现了父类中抽象的updateCityTime()方法,来把UTC时间加上北京与UTC时间之间的时差UTC_OFFSET,就能得到北京时间。本来我还想继续在BeijingClock类中实现父类中抽象的setUtcZeroTime()方法,但我发现这个方法的实现完全可以放到父类中,从而可以让它的5个子类复用。所以我改了一下父类CityClock,把抽象的setUtcZeroTime()方法改成一个已经实现了的方法。”
提示:无论详细设计准备得多么充分,总会有瑕疵。在编程过程中,总会发现详细设计中可以改进的地方。随时改进这些地方,能使得代码“流水不腐”。
下面就是CityClock类中被修改了的那部分代码,其中带有“-”号的行表示删除的行,带有“+”号的行表示添加的行,下面的代码表示用后面3个带有“+”号的行替换了第1个带有“-”号的行。
- public abstract voidsetUtcZeroTime(int utcZeroTime);
+ public voidsetUtcZeroTime(int utcZeroTime) {
+ utcTime.setUtcZeroTime(utcZeroTime);
+ }
R:“写好了北京时钟类,剩下4个城市的时钟类就可统统照此办理,它们之间主要的区别就是每个城市与UTC时间的时差不同罢了。”
“很好。按照类图,该写的代码都写完了。下一步就是该如何测试这些代码了。”
1.7 测试一下
R:“要是这个操练能有个用户界面就可以测试了。不过咱们可以写一个main()方法,调用一下这些代码就可以当做测试。”
“嗯。你把代码都实现了,辛苦了。现在交换一下。我来编写这个main()方法。”
下面就是一个包含有main()方法的Main类的代码:
public class Main {
public static voidmain(String[] args) {
// Attach 5 cities toTimeSubject class
TimeSubject.attach("Beijing", new BeijingClock());
TimeSubject.attach("London", new LondonClock());
TimeSubject.attach("Moscow", new MoscowClock());
TimeSubject.attach("Sydney", new SydneyClock());
TimeSubject.attach("NewYork", new NewYorkClock());
// Adjust the time ofBeijing clock to be 9
TimeSubject.getCityClock("Beijing").setUtcZeroTime(9);
// Display the time ofthe 5 cities
System.out.println("The time of Beijing is " +TimeSubject.getCityClock("Beijing").getCityTime());
System.out.println("The time of London is " +TimeSubject.getCityClock("London").getCityTime());
System.out.println("The time of Moscow is " +TimeSubject.getCityClock("Moscow").getCityTime());
System.out.println("The time of Sydney is " +TimeSubject.getCityClock("Sydney").getCityTime());
System.out.println("The time of New York is " +TimeSubject.getCityClock("NewYork").getCityTime());
}
}
“这个main()方法有3步工作需要做。第1步,利用TimeSubject类的attach()方法,把5个城市的对象都添加到该类中那个静态的HashMap成员变量里。第2步,把北京时钟的时间调整到9点,第3步,把这5个城市的时间都打印出来,看看结果。第1步没有问题,不过第2步和第3步中要用到的两个接口,在目前已经编写的代码中没有提供。一个没有提供的接口是第2步中的TimeSubject类的getCityClock()这个方法,因为北京时钟对象是保存在TimeSubject类中的,所以最好TimeSubject类能提供一个getCityClock(String cityName)方法作为接口,该方法接受城市名称作为输入参数,然后返回城市名称所对应的城市时钟对象;另一个没有提供的接口是第3步中的CityClock类的getCityTime()方法,我需要获取某个城市时钟的当地时间来打印出来。即使现在没有这两个接口,我也在这里把实际使用它们的调用代码在main()方法里给先写出来。”
提示:详细设计由于着重考虑生产代码[9]的接口,所以难免遗漏了测试代码的接口。而测试代码可以认为是生产代码除实际客户端外的另一个客户端,所以一部分被测试代码所使用的生产代码的接口,也能反映出生产代码的实际客户端的一些普遍需求。这些被测试代码所使用的接口,可以补充到生产代码的接口设计中,使其更加完备。
R:“这与我的习惯正好相反。我还是习惯先设计并实现好一个接口,然后再使用它。比如我会先在TimeSubject类中添加getCityClock()方法,然后再回来写main()方法来调用它。”
“嗯,以前我也和你一样。不过我后来发现Alt+Enter真是太好使了。先写你期望但尚不存在的接口,然后让IntelliJ来帮你实现接口代码,比你自己一点点写接口要快多了。”
提示:在现代的IDE集成开发环境中,只要写好你期望但尚不存在的接口,你就能使用快捷键方便地让IDE帮你创建那些尚不存在的类、方法、变量等等代码。而传统的先实现你所期望的接口代码,再编写调用这些接口的客户端代码的方式,有点像你去一家餐馆吃饭,先不向服务员点菜,而是先直奔后厨,炒出你想要的菜,再自己端出来享用一样,超级费力。省事的办法是先在这些IDE中点出你期望的菜,然后咱们专业的IDE就会殷勤地为你一样一样地将菜端上桌。
下面就是在TimeSubject类中添加getCityClock()方法的代码。
+ public static CityClockgetCityClock(String cityName) {
+ if (cityClocks.keySet().contains(cityName)){
+ returncityClocks.get(cityName);
+ }
+ throw newIllegalStateException("The city name " + cityName + " does notexist.");
+ }
“接下来用Alt+Enter快捷键来在CityClock类中实现getCityTime()方法。”
下面是在CityClock类中的添加的getCityTime()方法的代码:
+ public int getCityTime(){
+ return cityTime;
+ }
“现在Main类的源代码里面已经没有红色的编译出错代码了。咱们可以运行一下main()方法。呃……空指针错!”
出错信息如下所示:
Exception in thread "main" java.lang.NullPointerException
atcom.wubinben.tamingbadcode.waterfall.hotelworldclock.CityClock.setUtcZeroTime(CityClock.java:15)
atcom.wubinben.tamingbadcode.waterfall.hotelworldclock.Main.main(Main.java:20)
“在CityClock类文件的第15行,是调用成员变量utcTime所引用的一个UtcTime类的对象的setUtcZeroTime()方法,来设置UtcTime类的UTC时间的。哦,这里的成员变量utcTime没有初始化,所以是空值。给它初始化就好了。”
提示:测试代码此时充当了代码开发过程中的贴身保镖,随时保护已有代码的安全。
CityClock类的成员变量utcTime的代码修改如下:
- protected UtcTimeutcTime;
+ protected UtcTime utcTime= new UtcTime();
R:“现在再运行一下main()方法。噢耶!终于出来结果啦!可是……结果好像不大对。”
运行main()方法的结果如下:
The time of Beijing is 17
The time of London is 9
The time of Moscow is 13
The time of Sydney is 19
The time of New York is 4
R:“嗯,北京时间开始时被调整到9点,可结果显示出来却是17点。看来时间设置有问题。要不先看看把北京时钟设置为9点的CityClock类的setUtcZeroTime()方法是如何实现的。”
main()方法中将北京时钟设置为9点的那行代码如下:
TimeSubject.getCityClock("Beijing").setUtcZeroTime(9);
上述代码所调用的CityClock类的setUtcZeroTime()方法代码如下:
public voidsetUtcZeroTime(int utcZeroTime) {
utcTime.setUtcZeroTime(utcZeroTime);
}
“啊!这里有问题!setUtcZeroTime()方法的输入参数期望得到UTC时间,但是实际传给它的参数却是北京时间9点,根本就不是UTC时间。”
R:“那就先把setUtcZeroTime()方法的输入参数名改为cityTime,与实际传入的参数含义保持一致,因为这样对于main()方法来说调用起来最自然,然后再把这个当地时间cityTime转换为UTC时间。”
“好的。改名字用IntelliJ提供的重构工具很方便。嗯,把当地时间转换为UTC时间这件事应该放到哪里去做呢?因为这种转换需要时差UTC_OFFSET,而那5个城市时钟类具有UTC_OFFSET,所以这个工作由5个城市时钟类来完成,而在它们的父类中声明一个抽象方法来规范一下这项工作。这个抽象方法的名字可以叫convertCityTimeToUtcZeroTime(),它把当地时间转换为UTC时间。”
提示:随着编程的逐步深入,总会渐渐发现一些被详细设计所遗漏的接口,这是一件很正常的事情。这也同时说明,任何详细设计,只能告诉你大致的方向,但无法包含所有的细节。
在CityClock类中更改setUtcZeroTime()方法的参数名并添加一抽象方法的代码如下:
- public void setUtcZeroTime(intutcZeroTime) {
- utcTime.setUtcZeroTime(utcZeroTime);
+ public voidsetUtcZeroTime(int cityTime) {
+ utcTime.setUtcZeroTime(convertCityTimeToUtcZeroTime(cityTime));
}
+ protected abstract intconvertCityTimeToUtcZeroTime(int cityTime);
“既然在父类CityClock里添加了一个抽象方法,那么那5个子类需要实现这个抽象方法。一个一个用Alt+Enter来帮我实现吧。”
在BeijingClock类中添加的实现上述抽象方法的代码如下(其他城市时钟的代码与之相同):
+ @Override
+ protected intconvertCityTimeToUtcZeroTime(int cityTime) {
+ return cityTime -UTC_OFFSET;
+ }
“好了,可以再次运行main()方法了。耶!这次北京时间是9点了!”
再次运行main()方法的输出结果如下:
The time of Beijing is 9
The time of London is 1
The time of Moscow is 5
The time of Sydney is 11
The time of New York is -4
R:“对。不过最后一个城市纽约的时间怎么是-4点?”
“哦,咱们为了省事,用整数来代表小时,如果与时差UTC_OFFSET进行运算时,肯定会出现0到24这个范围之外的数字。咱们可以再编写一个方法,来让所有与时差UTC_OFFSET进行运算的结果都回到0到24这个范围里面。而这个方法可以放到CityClock父类中供各个子类复用。这个方法可以叫做keepInRange0To24()。”
提示:在北京上班高峰时做过公交车的人都知道,没有压力地排队上车,比大家都互相挤压在别人身上挤上车要快很多。编程也是如此,对于保存时间的Java变量,应该使用像Time、Date和GregorianCalendar这样的类型,但若使用简单很多的int型来表示时间,能够缓解编写测试代码和重构到设计模式的工作的压力,使其能快速完成。之后可以在测试的保护下进行重构,将int型的时间变量,转变为Java的类。
BeijingClock类中添加keepInRange0To24()方法的调用如下(其他4个城市时钟类与之相同):
- return cityTime -UTC_OFFSET;
+ returnkeepInRange0To24(cityTime - UTC_OFFSET);
- cityTime = utcZeroTime+ UTC_OFFSET;
+ cityTime =keepInRange0To24(utcZeroTime + UTC_OFFSET);
CityClock类中添加的keepInRange0To24()方法代码如下:
+ protected intkeepInRange0To24(int hour) {
+ if (hour < 0) {
+ return hour + 24;
+ }
+ if (hour > 24) {
+ return hour - 24;
+ }
+ return hour;
+ }
“再运行一下main()方法。这次全对了!”
再次运行main()方法的结果如下:
The time of Beijing is 9
The time of London is 1
The time of Moscow is 5
The time of Sydney is 11
The time of New York is 20
1.8 暴露的问题
R:“这个操练终于可以告一段落了。哎,对了,咱们对设计做的所有的修改,你都更新到那个类图文件里了吗?”
“噢!我光顾着看你的代码,早把这茬儿给忘了!要不你根据最新的代码再更新一下类图?”
R:“哦,回头我会更新。不过咱们的代码还有很多地方需要进一步修改,比如我早就看着TimeSubject这个类里面那些静态成员变量和成员方法不顺眼,这和全局变量没啥区别,将来或许是一些难缠的问题的根源,最好能把静态给去掉。不过这样一来,那个HashMap的成员变量就得下移到其子类UtcTime中,如果这样的话,TimeSubject就没有存在的必要。对了,当初为什么要有TimeSubject类呢?都是照搬四巨头的类图惹的祸。如果真要这么改,那个类图又得有不少需要更新的地方。”
提示:原封不动地照搬四巨头的设计模式的UML类图进行设计,而不根据实际的具体情况进行调整,会设计出一些没有必要存在的类,增大系统的复杂性,造成时间和人力成本的浪费。
“是呀。因为代码是程序员写的,所以维护像类图这样的文档与代码一致这项工作,只能由程序员自己来做。但在项目进度的压力下,有几个程序员能坚持这样繁重的维护工作?这就好比“刻舟求剑”,只要你写完停笔,不断变化的代码会让你无法根据刚刚刻下的记号找到你需要的“剑”。这些过时的文档,随着时间的推移,会越来越离谱,最终会变成一个撒谎的路标,其效果还不如没有路标。还是代码不会撒谎。”
提示:任何有关软件的文档编写和修改工作,就如同“刻舟求剑”,无论你多么敬业地创建和维护文档,一旦你写完停笔,时刻变化的需求、设计和代码,就会使你刚刚写好的文档,成为“刻舟求剑”故事中的那艘刻在船身上的记号,让你无法找到你真正想要找的“剑”。
R:“我倒是能够通过一些工具来让代码与UML类图相互转换。不过转换过来的类图过段时间还得过时。这个问题真有点难办呀!”
“另外,咱们这个操练发现的那几个问题,比如空指针、时间不准、超出范围等问题,都在什么时候发现的?”
R:“都在咱们写了main()方法并运行后发现的。但这正是main()方法测试的意义呀。”
“对。但是你想过没有,你完成了这一操练的功能,把main()方法连同其他源代码提交到版本控制系统后,再开发其他功能的时,你还会想起来运行这个操练的main()方法吗?如果你在开发其他功能的时候,修改了咱们这个操练的代码,你又没有运行它的main()方法,那效果不就和你在写main()方法前所处的情形是一样的吗?你可能会引入新的bug。咱们这个操练的main()方法所做的测试工作,就像你打魔兽时穿戴的装备一样,起到了保护的作用。这个测试需要频繁地运行。而没有频繁运行的测试来保护的代码,就是裸奔的代码。”
提示:在编写代码的过程中,需要频繁地运行自动化测试,来保护你已有的代码所实现的功能不被破坏。
R:“刻舟求剑?裸奔代码?嗯,有点意思……那么,怎样才能解决这两个问题呢?”
1.9 小结
编程如同练武,是门手艺。要想精通任何一门技艺,需要不断地刻意操练[10]一万小时。要精通任何一门技艺,需要“刻意地操练”一万小时[11]。
有趣的编程操练,能吸引程序员刻意地练习自己所欠缺的技能。
有专家指导的、经常进行的、合理更换搭档的结对编程,能够为团队内程序员之间相互传递知识提供良好的机会,一方面可以整体提升团队的开发技能,另一方面也可以解决因负责某模块开发的程序员生病、休假等原因造成的开发人力资源短缺的问题。
在现代的IDE集成开发环境中,只要写好你期望但尚不存在的接口,你就能使用快捷键方便地让IDE帮你创建那些尚不存在的类、方法、变量等等代码。这样开发起来,IDE能够帮你更快地生成代码,而针对这些期望的接口编程也会让你的开发更加精炼,从而避免开发暂时用不上的功能,节省时间,加快速度。
“从内而外”地先编写接口的实现代码,再编写接口的客户端代码,就好比女人逛超市,看到什么好东西都想买,采购了多余的东西造成浪费;“从外而内”地先编写期望的调用接口的客户端代码,再编写接口的实现代码,就好比男人逛超市,掏出纸片上的购买清单,拿货、掏钱、走人,精益适用。
先从简单的实现做起(比如先用int型表示时间变量),能够缓解编写测试代码和重构工作的压力,使其能快速完成。之后可以在测试的保护下进行重构,将简单的实现转化为完善的实现(如将int型的时间变量转变为Java的有关时间的类)。
原封不动地照搬设计模式的UML类图进行设计,而不根据实际的具体情况进行调整,会设计出一些没有必要存在的类,增大系统的复杂性,造成时间和人力成本的浪费。
事先细致的详细设计看起来很好,但在编程的进行过程中,程序员对领域问题理解的逐渐加深,会使得设计的瑕疵被逐渐修复,设计的遗漏被逐渐补全;原定的需求的逐渐变化,会使得设计的细节被逐渐改变。这些都使得详细设计会逐渐过时。任何有关软件的文档编写和修改工作,就如同“刻舟求剑”。
测试代码可以认为是生产代码的另一个客户端,一部分被测试代码所使用的生产代码的接口,也能反映出生产代码的实际客户端的一些普遍需求。这些被测试代码所使用的接口,可以补充到生产代码的接口设计中,使其更加完备。
另外,测试代码也是代码开发过程中的贴身保镖,随时保护已有代码的安全。在编写代码的过程中,需要频繁地运行自动化测试,来保护你已有的代码所实现的功能不被破坏,使之不致成为裸奔代码。
该如何解决文档如“刻舟求剑”般过时的问题呢?该如何编写测试来使得代码不会成为裸奔的代码?下一章“事后清点的镖师”中所讨论的将规格与测试合二为一的开发方法,或许能够解决这两个问题。
第2章 “事后清点”的镖师
镖师是一个古老的行业。在古代社会中,随着商业的发展、财物流通的日益增多,保护流动、流通中的人员、财物安全的保镖行业应运而生。至明清,该行业发展至鼎盛[12]。假如你是古时在杭州做买卖的有钱人,春节前打算带着太太、孩子和一些贵重礼品及货物回天津老家过年。因担心长途旅行不安全,需要请镖师护送。镖师要是说:“请您全家自行旅行,到津后通知我们来清点一下人数、礼品、货物即可。”你肯定会冒出一句天津话:“那还雇您了保镖干嘛?!”
2.1 贴身保镖
上一章最后那个写main()方法来进行的测试工作,就是这样的事后清点。虽然这样的事后清点有其必要性,但是在代码编写过程中,却始终没有一个“镖师”在为你做24小时贴身保护。在半道上被“强盗”抢去的那些货物,一直到最后才能被发现。别说精明的商人,就是一个傻子,也不会请这样的“事后清点”的镖师来当保镖。
那么,谁才能充当代码编写过程中的“贴身保镖”呢?测试代码。
在这一章中,我将和您一起结对编程,来为上一章中那个酒店世界时钟操练,配上测试代码这样的“贴身保镖”。
R:“‘事后清点’的镖师这个比喻还真是那么回事。咱们前面的代码编写过程中,确实没有贴身保镖,直到最后才写了个main()方法清点了一下,发现了接口设计中的一些问题。在现实工作中,不仅咱们程序员编写代码是这样,就连我们公司软件开发工作流程中的QA测试,其实也同样只做了事后清点的工作。”
“对。缺陷发现得越早,修复起来时间和人力成本就越低。等到了事后清点时才发现缺陷,那就会耗费更多的时间和人力。”
R:“我们公司的领导很清楚这一点,所以也要求我们程序员写测试。”
“你们是先写测试,还是后写测试?”
R:“能做到后写测试就不错了。很少有人能先写测试。”
“如果是后写测试,那其实也是事后清点,后写测试所针对的那些代码,同样在编写过程中没有贴身保镖的保护。另一方面,在后写测试时,你的测试代码是针对需求规格写呢,还是针对待测的生产代码写呢?”
R:“当然是针对需求规格写喽。”
“对,测试代码应对针对需求规格写。但是如今你已经写好了待测的生产代码,所以你在写测试代码时,会经常发现这个生产代码总是碍手碍脚的。”
R:“对呀!难怪我们都不爱写测试代码呢,原来是这样呀!”
提示:后写测试代码一方面没有对其所测试的生产代码的编写过程提供“贴身保镖”般的保护,另一方面会因为有其所针对的生产代码这个“婆婆”的存在,而使得编写测试代码时束手束脚,无法专注地针对需求规格来编写测试,增加了编写测试的复杂性。
“要想给咱们这个操练配上‘贴身保镖’,只能是先写测试。任何后写测试都是事后清点,不能提供“贴身”保护,也增加了编写测试的难度。所以我不想给咱们已经实现的那个操练补上测试,而是打算用测试先行的方法来重做一遍这个操练。”
提示:同一个编程操练可以用不同的方法来反复练习,以便体会不同方法的作用到同一个操练上的差异。这里的编程操练,就如同李小龙在家中设置的那个木桩一样,可以让他每天对着木桩反复操练师父叶问所教的咏春拳。
R:“我以前都不写测试的,最近因为领导要求,才写了一点测试,不过都是后写测试的。还从没先写过测试。”
“没关系,咱俩不是结对编程吗,我来给你演示一下。”
提示:软件开发团队成员都是运用知识的活跃分子,结对编程是团队成员之间进行知识传递的有力工具。
2.2 第一个测试
“我先建一个空的Java项目,然后编写一个测试。”
R:“第一个单元测试该如何写呢?咱们的生成代码还没有写呢,该测什么呢?”
“这是个好问题。你先告诉我什么是单元测试。”
R:“如果使用面向对象的开发方法,单元测试就是针对一个类的测试。”
“没错。不过我觉得从零开始编写代码时,一上来就针对一个类写单元测试,有点为时过早。咱们不妨先从需求规格的角度出发,写一个高层次的验收级别的测试。这个高层测试会涉及好几个类,来测试某项需求规格。”
提示:用测试先行的开发方法,从零开始编写测试时,先不要一上来就写针对某个类的单元测试,而是先根据需求规格编写一些涉及好几个类的高层测试。待将来这几个类的关联关系被重构到一个相当稳定的状态后,再为每一个类编写各自的单元测试。
[3] 参见北京设计模式学习组的网站:http://www.bjdp.org/
[4] 下文中以“R:”开始的对话,表示在作者与读者的结对编程中,读者所说的话。未标“R:”的对话表示作者所说的话。R表示Reader(读者)。
[5] UTC 时间,是全世界用于调整时钟和时间的主要时间标准。
[6] ICONIX 是一种软件开发方法,它的产生时间要早于Rational 统一过程(RUP)、极限编程(XP)和敏捷软件开发。与RUP 类似,ICONIX 过程是被UML 用例来驱动的,但ICONIX 比RUP 更加轻量级。与XP 和敏捷方法不同,ICONIX能够提供足够的需求和设计文档,但不会因过度分析而导致项目难以进行。ICONIX 过程只需在4 个步骤中,使用4 个UML图,就能把用例文本转变成可工作的代码。详见http://en.wikipedia.org/wiki/ICONIX
[7] 参见 Erich Gamma、Richard Helm、Ralph Johnson 和John Vlissides 撰写的《设计模式》一书。
[8]本章代码可在以下两个链接下载:https://github.com/wubin28/BookTamingBadCode_Waterfall,https://code.csdn.net/wubinben28/booktamingbadcode_waterfall
[9]软件的生产代码(production code),就是那些被该软件的测试代码所调用,来检查其功能是否符合预期的代码。
[12] 引自百度百科,镖师词条。