想必用Java的人都用过JDK的容器类吧,什么List, Set, Map啦。每天这些代码在全世界成千上万的JVM里面运行,每天数以万记的程序员在使用这些类。你知道这些这么cool的代码是谁写的吗?是Joshua Bloch。他以前是在Sun工作,现在跳到Google了,Google是无敌了。他可是大师级别的人物了。虽说他是大师但是人家也在写代码,所以这样的人的文章必定是脚踏实地。而非有些只会动嘴皮子,开会的时候在白板上画出一堆框框线线然后让别人来coding的“专家”。虽然自己写了多年Java也设计了不少东西,但是自己从他的经验和设计上还也实实在在受到了不小的帮助。虽说这是01年的采访,但是内容仍旧值得一读,我把这篇采访翻译了一下(Joshua Bloch: A conversation about design - JavaWorld)。下面就来听听别人采访他关于设计方面的对话。作为Java开发者,我把自己的注释用/**/来标示:)

  • 为什么要设计API?

Bill Venners:在你的Effective Java Language Programming Guide的序言里面谈到API的设计。我目前也在做这样的工作。如果我来管理一个大型的软件项目,我会把整个系统分解(decompose)到子系统中,让开发人员设计这些子系统的interface。这些interface就是API。这里我们如果把API的设计理念和流行的极限编程(extreme programming)相比较的话,API的设计理念在软件开发中所处的地位如何?
Joshua Bloch: 在我自己的开发经历中,看到了太多的顽石(monolithic,指复杂庞大但是耦合紧密的结构)结构的软件。就好像有人要设计面向记录(record-oriented)的文件系统,他会直接去设计系统,而不是像你刚才所说的那样把整个系统分解设计。/*估计每个程序员都写过这样的"顽石"代码*/ 分解系统是很重要的,但不要忽略对每个子系统的良好独立抽象(freestanding abstraction)设计也是一样重要。
 同分解子系统相比,事实上,人们更容易把软件设计成依赖颠倒(reverse dependencies)。也就是当设计底层系统的时候只是面向最初的高层系统客户。特别是当缺乏经验的程序员来设计的时候更是如此。最初的高层客户系统所用的变量名字和会渗透到底层模块。当开发结束的时候,没有一个模块是能重用(reusable)的,结果就是得到我之前说的顽石。
 在我的书里描述了分解子系统的各种理由,这样就可以把子系统解耦合。有的时候你原先写的代码在别的地方也可以被用得很好,前提就是这个子系统必须是一个设计良好,独立抽象的系统。

  • 复用有多重要?

Venners: 很早开始就OO宣称可以提高软件复用性。但事实上人们发现它很难达到软件复用。因为每个人的需求总会和现有的软件提供的功能有所不同,所以人们会从头编写新的软件。也许就算有人设计不是针对复用性,但是人们还是努力编写代码。那么你认为软件的复用性有多重要呢?
Bloch: 我认为复用性极其重要但是也是很难实现。我现在在Sun开发的Java Collections Framework, java.math等等这些公司就是些可以复用的组件,因为已经有成千上万的人在使用这些API了。
 在我之前的工作中我发现自己写的75%的代码在被其他的系统所使用。为了达到这样高的复用程度,我不得不非常仔细地来设计这些代码,不得不花费很多的时间来清晰独立地分解子系统。我为这些子系统独立debug,写单元测试。
 但是很多开发者并没有这样做。当你使用极限编程的时候,极限编程理念用最简单的方法来解决事情。这是个不错的理念,但是这很容易发生误解。
 其实那些极限编程的大师并没有主张仅仅用最快编写的代码来解决问题,而且他们也没有让大家放弃设计。他们是主张先把不需要的细枝末节的功能先放一下,然后再加上。这是超级重要的理念,因为新的功能可以随时增加,但是既有的功能是不能随意拿掉的。你不能说“啊对不起,我们把这块功能搞砸了,我们要把这个功能砍掉,因为有其他代码依赖于它”。这样的后果就是大家会抓狂,所以如果对功能需求有疑问的话就先把它放一边。
 极限编程还强调了重构。在重构的过程中大量的时间花在清理代码和API,抽出模块。这些工作相当重要,而且我还要建议大家不要过早固定这些API。反言之,如果在开发早期就仔细做了这些设计和模块划分的话,到最后就可以省下这些工作了。
Venners: 为什么?
Bloch:因为实践证明重构大量代码是困难的。/*我自己经常看到一些人写了一大堆很乱的代码,然后以重构之名作为借口*/ 如果面对一个紧密耦合的系统,你需要在代码的各个地方找到重复代码。要合理地重构这么多代码是个巨大的工作。相反,如果系统是松耦合的,那么如果你发现模块之间的划分有问题的话,就可以轻松修改了。
 但是如果要把极限编程和我提倡的API设计方法对立起来也不是个好办法。假设你和Kent Beck这样的极限编程大师探讨编程问题之后,你也会发现Kent Beck他们也做了大量我提倡的设计方法。/*哈,也许是指K Beck的JUnit的设计*/
 现在回到刚才第一个问题,如果你是经理,你当然应该在组员开始写代码之前,给你的组员自由去做一个良好的设计。同时,你也不应该让组员设计到每一个细节;你应该确保他们能完成整个工作的前提下先做好一个最小的设计。
Venners:这样听上去极限编程是在推荐大家编写最简单的功能集合来开发系统,而不是为了能使系统跑起来随意地编写程序。
Bloch: 没错,说得对。事实上那些随意地编写程序的人经常会比仔细设计模块的人更花时间来工作。当然API的设计也是要花时间的。
如果随意编写的代码发布为公共的API,那么维护这些低质量的API就会成为巨大的负担,还会导致客户的强烈不满。

  • 改进代码质量

Bill Venners: 你在你的书里说从API的角度来思考能够改进代码质量。您能解释一下为什么您这样认为?
Josh Bloch:  我这里指的是编写大规模程序。如果你只是碰到处理比较小规模的问题,那么编写高质量的代码是件相对容易的事情。如果你能把问题很好地分解成各个功能,那么你每次只需要集中精力于一件事情,这样就可以把事情做得更好。这样就能把写好大规模的程序变为写好小规模的程序。
 而且,模块分解代表了软件质量的关键因素。假设有个紧密耦合的系统,当其中一个模块被改动时,整个系统就无法运转。但是如果你用API的角度来设计模块间清晰的划分,那你就可以很好地维护和改进其中的某个模块,而不会影响其它的模块。
Bill Venners: 你能解释一下你刚才说的“大规模程序”和“小规模程序”吗?
Josh Bloch: “大规模程序”指的就是解决一个大问题-大到不能用独立的一个小程序解决的问题;或者说这个问题大到必须分解成一个个子问题。“大规模程序”包含了大问题固有的复杂性。相比之下“小规模程序”就好比说:“怎么把一个float数组更好地排序“这样的问题。

  • 客户代码的信任问题(trust)

Venners:那我们接下来讨论对客户代码的信任问题(trust)。我们应该在何种程度上信任客户代码?在你的书里面你谈到防御性拷贝(defensive copy)来传递参数对象,这种防御性拷贝针对的就是不被信任的客户代码。这样做的话不就是牺牲效率来换取程序的健壮性?譬如每次拷贝很大的对象就会影响效率。/*这里的防御性拷贝指的为了避免转递同一个reference而去创建一个新的对象,然后把原来对象里面的值都设置进去,就像deep copy*/
Bloch:显然这是个折中(tradeoff)的问题。一方面我不会过早关注程序的效率优化问题。既然这不是个问题,那么就不需要关心。对于这个问题还有一种考量就是对象的不可修改性(immutability)。/*例如String类*/如果一个对象是不可修改的,那么我们就不需要拷贝它。
 当然在某些场合客户代码是的确可以被信任的,我们可以确信你的代码不会被错误调用。在这种场合我们可以为了效率而降低程序的健壮性。我们经常可以在C写的代码中间看到像“请确保此函数的调用者不会改动这个对象”诸如此类的注释。任何一个C或者C++的程序员都作过这样的注释。但是有时候你自己也会忘记这些对象是不可修改的。更麻烦的是虽然自己知道这些对象不能修改,但是还是不小心把它们传递到了程序的其他地方,这时候你就没法控制它们不被修改了。
 同依赖客户代码的安全性相比,直接做防御性拷贝或者使用不可修改的对象更加方便。除非程序对效率要求很高,否则最直接的方法就是不要直接传递这些对象。我建议的方式是先写好代码然后看它运行效率是否够高。万一达不到需要的效率,然后再仔细考量是否要放宽这方面的安全限制。
 一般来说,我们不应该允许有问题的客户代码来破坏我们写的程序。我们希望把程序失败在模块间隔离开来,这样的话程序失败才不会在模块间传播。这也包括我们要防止故意的破坏(hacking)。从更广的角度来说,防御性编程也对草率的代码和低质量的文档起防御作用,因为有些客户代码的编写者不清楚自己有对这些对象是否有修改的责任。

  • 防御性拷贝(defensive copying)和契约(contract)

Venners: 如果我在构造方法里面传进来一个防御性拷贝的对象,我应该把这它写入我的文档吗?如果不写的话,我就有机会以后为了效率而去掉防御性拷贝。但是不写入文档的话,客户端程序员就无法确认这个构造方法是否做了防御性拷贝。他们可能会自己动手写防御性拷贝的对象,然后作为参数传给构造方法。这样就造成了两次防御性拷贝。
Bloch:  如果说我们没有把防御性拷贝写入文档,那么客户程序员会去修改这些对象吗?答案很明显,只有非常固执的程序员才会为了不破坏你写代码而不去修改这些对象。但是事实上程序员都不是那么固执—他们当然会去修改。既然文档没有禁止去做某件事,那么程序员就会理所应当得去做。因此要把防御性拷贝写入文档,这样即使客户代码的程序员是个固执的程序员,他也可以根据文档对输入参数作合理的处理。
 在理想的情况下我们应该把防御性拷贝写入文档。但是,如果你来看我写的代码,你会发现我自己倒是没有写这些文档。我的确是在代码里面做了防御草率的客户代码的工作,但是我没把它文档化。
 对于有广泛客户的代码来说有个问题就是:人们会翻看你的代码,然后发现文档和代码有不一致的地方。对此,我一般的回答是:嗯,我是后来才了解到一些新的信息,我会修正这个问题。所以对于这种情况,问题就在于你对待撰写文档有多仔细。也许我自己是不够仔细,也许也没必要太认真。。。

  • API设计和重构

Venners:在重构的过程中怎么注重API的设计?因为你说开发者没必要做很多的重构。
Bloch:这是我部分的想法。而且往往重构是一种事后(ex post facto)API设计。当你写好代码然后发现很多地方有重复代码,你会仔细设计把这些代码拿到一个模块。从这个角度看这和事先作设计是一样的。事实上,这两种开发方式总是共存的,因为开发是个叠加(iterative)的过程。当你尝试从头设计软件,你只有到最后使用它的时候才能验证设计是否正确。即使有很多年开发经验的人也不能一下子设计地正确。/*本来以为这样的大师能一开始就把所有的设计都搞定*/
  我和Doug Lea(Concurrent Programming in Java的作者)时常也谈论这个问题。当我们一起使用自己写的代码的的时候会发现这些代码未必都能运行。然后我们回过头来会发现API的设计问题。难道这就能说明我和Doug都是很傻?当然不是。因为没人能在使用API之前能准确预测API的确切的需求。这就是为什么每当写interface和abstract class的时候,很重要的手段是在提交这些API之前也为这些interface和abstract class写尽可能多的实现代码。在提交之后想再去改API太困难了,因此最好事先把这个事情做好。

  • 信任契约

Venners:我是否应该信任传递过来的实现契约(contract)的对象吗?最近我写了一个实现Set 接口的对象,以便序列化之后在不同的虚拟机上使用。这个类继承于你写的那个AbstractSet,我把它命名为ConsistentSet。而且我为它写了个构造方法,这个构造方法接受一个Set,然后我把这个Set放到内部的数组里面。
  在写这个ConsistentSet类的时候我怀疑那个通过构造方法传递进来的Set是否有重复元素,这样就违反了Set的接口契约。也许我应该给传进来的Set做是否有重复元素的检查呢?但是从OO的角度来说,这样的检查违反了每个对象都要各负其责的基本思想。 
Bloch: 我认为你没有选择,你只能信任这些实现了接口的对象。一旦有人违反这些契约,整个系统就会混乱。一个简单的例子就是:equals的对象必须hashCode要相等。如果有人违反这个规则,那么hash table和hash set都没法正常工作。
 概括来说,一旦有对象违反契约,也就是像我刚才举的那个例子那样,和它协作的对象都会运转异常。虽然我知道你对这个问题很困扰,但是我认为你还是得信任这些契约接口。如果你真的对此很困惑,你可以向一些社区讨教些建议以及采取“信任加验证”(trust but verify)的策略。对此最好的策略就是使用断言(assertion)/*可以通过设置Java的参数来开关这个功能*/,因为你随时可以开启和关闭断言。你可以使用断言来验证那些对象是否遵守了契约,一旦程序运行异常就可以开启断言来检查到底哪里出了问题。

译者:卢声远< michaellufhl@yahoo.com.cn>

 

 

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐