四个有害的 Java 习惯用法和如何克服它们
原本地址:http://www.javaworld.com/javaworld/jw-07-2008/jw-07-harmful-idioms.html?page=1
译注:个人认为文中观点极好,特翻译一下,与大家共享。对于文中错误之处、不通之处、不妥之处,请随时提出意见或建议。由于白天极累,所以脑子有点打浆,容易出错。尽管已经校对,但错误难免,请多包涵!
任何语言的习惯用法都能在你写代码时帮到你。但是 John O'Hanley 指出,一些流传极广的 Java 习惯用法对于代码的可维护性是有害的。如果你准备好打破习惯并优化你代码的可维护性,那么请继续读下去。
编码规约的出现是为了使程序员的生活更容易 —— 特别是对经常阅读别人代码的程序维护人员来说。编码规约基本上就是向这类维护人员展现“同情”的。你选择的规约应使他们能尽可能快,尽可能没有痛苦地理解你所写的代码。你越是为了可维护性而优化你的代码风格,你的代码就越显得“慈悲”,它们被理解的速度就会越快。
相似地,较高层次的习惯用法(比如说不变体和包结构)的出现是为了改善设计和使代码更易读。事实上,有些人可能会说,“改善设计”和“使代码更易读”最终是一回事。
在这篇文章里,你会看到有些很受欢迎的习惯用法应该被改变。因为有些方法对阅读代码的人来说更慈悲。也许有的人会争论说已经广泛流行的习惯是不应该被抛弃的。道理很简单,就因为读者已经习惯它们了。他们读代码的时候在会希望这种风格的代码出现。然而,读者的期望只是等式的一边。它不应该把所有其它的考虑都掩盖掉。最基本的问题不应该是“读者希望读到这样的代码吗?”,而是“一个人能多快地读懂这段代码?”。我会轮流阐述四个有问题而又常见的习惯用法。
一、局部变量、方法参数和域:哪个是哪个?
(译注:这里域指类变量,原文使用 field 一词)
当试图理解一段代码的时候,读者经常会问这样一个简单的问题:这个数据是从哪儿来的?特别是当理解一个方法时,读者需要知道哪个项是局部变量,哪个是域,哪个是方法参数。为了向读者展示你的同情心,也为了尽可能减少理解你代码所花的精力,最好是用一个简单的命名规则来区分这三个情况。
很多组织在域前冠以“this”来把它们和其它的变量区分开来。不错,但是还不够:还应该有一个规约来把方法参数也区分开来。
列表 1 展示了一个对这三种变量不加任何区分的 equals() 方法。
列表 1. 一个对这三种变量不加任何区分的 equals() 方法
Java代码
public boolean equals (Object arg) {
if (! (arg instanceof Range)) return false;
Range other = (Range) arg;
return start.equals(other.start) && end.equals(other.end);
}
法国作家 Marcel Proust 因其在微观层次上研究人类体验而出名。一个现代的 Proust 应该如何来描述这个 equals() 方法?当你遇到 start 时,你会有一点不舒服。因为它就这么突然间跑出来了。当看到陌生的东西时,你的大脑会被狠狠地颠一下 —— 啊噢,这是什么东西?在通过一段烦人的筛选后(筛选的时间和方法的长度成正比),你终于发现 start 其实就是一个域。同样麻烦的过程也会在把方法参数和其它类型的数据区分开时发生。
列表 1 的代码不是很长。如果方法非常大,那么为了理解它而花费的精力可就会很多了。
不管多少微小,为什么让读者颠簸这么一下,经历这么一个困惑的时刻?为什么不去掉让你的读者不愉快的经历?为什么强迫他们自已去搞明白?如果一个简单的命名规范可以让读者在潜意识里就区分出这三种变量的话,有什么理由不去使用呢?就像 Steve McConnell 在《代码大全》里说的一样,“费劲儿搞明白凶杀案是可以的,但不应该这么来对待代码。你应该能读懂它。”
有的人也许会争论说,这就是为什么域总是应该在类的开头被声明。但是这并不是一个好的解决方案。因为它强迫读者去记住域的名字或者在一大段类的代码中玩查询。不管哪种情况都是让读者做更多的活。全部的焦点应该放在让读你代码的人用最少的精力来理解你的代码。
列表 2 是将列表 1 重写了的代码。它使用这样一个命名规范:
方法参数使用前缀 a
域使用前缀 f
局部变量不使用前缀
列表 2. 变量类型现在被清晰地区分开了
Java代码
public boolean equals (Object aOther) {
if (! (aOther instanceof Range)) return false;
Range other = (Range) aOther;
return fStart.equals(other.fStart) && fEnd.equals(other.fEnd);
}
你可能会反对列表 2 所展现的代码风格,抗议说“匈牙利命名法已经被弃用了”。但是样的抗议是有问题的:匈牙利命名法指明类型信息。上面的命名规范跟类型没有关系。它是用来区分域、参数和局部变量的。它们是两个完全不同的概念。
使用这样的命名规范也许看起来不太重要。但相反,当这样的规范被从头到尾应用到你的代码的时候,理解代码的精力花费就会显著地减少。因为你可以做更多的事而不用去想它们。
引用
把大脑从不必要的工作中解脱出来,一套好的符号表示系统能让人们把更多的精力放在更高级的问题上,并且有效地降低精力的精耗。在阿拉伯数字表示法被引入以前,乘法是很难的。甚至整数的除法被认为是数学的最高技巧。大概现代世界最能让一个古希腊数学家震惊的是……西欧的一大部分人都能做任意大的数的除法。这个事实在他看起来会是完全不可能的。现在,我们可以很轻松地做小数的乘方。这样一个奇迹要归功于一个完美符号系统的逐步发掘。”
—— Alfred North Whitehead, An Introduction to Mathematics
二、按层次分包:阻止包私有范围的使用
一个普遍的分割应用程序的方法是按层次分包:
com.blah.action
com.blah.dao
com.blah.model
com.blah.util
换一种方式说,把属于某一功能或特性的项分布于各个不同的包里就是所谓的行为式分类法。因为同一功能下的项之间需要互相可见,这就暗示着在这样一程序下几乎所有的类都是公共类。
事实上,这种普遍的划分包的风格把“包私有范围”给甩出窗外了。包私有范围不只是被简单地忽略了,你事实上被禁止使用了。现在,包私有范围被 Java 语言的设计者选择为默认的范围。之所以做出这个选择,当然,是为了轻轻地把你往好的设计方向上推 —— 以最小的范围开始,只有当必要时才增大它。(这就是通常所说的“保守秘密,以防涟漪效应”的技术。它是面向对象编程的核心)因为一些非常奇怪的原因,社区中可观的一部分采用了按层次分包的方法,拒绝最小化范围的方式。这看起来是不正当的。
一种替换方案是按功能分包:
com.blah.painting
com.blah.buyer
com.blah.seller
com.blah.auction
com.blah.webmaster
com.blah.useraccess
com.blah.util
这里,各个项不是按它们的行为分组的。各个类的行为被看作是一种实现细节。类是按最高的抽象层次来划分的 —— 抽象的层次。这里所有与某一功能相关联(并且只与它关联)的项都放在同一个包里。这不是一种革命式的,或者说异端的想法。这正是当初创建包这个机制的初衷。
举例来说,在一个 Web 应用程序中,com.blah.painting 包可能会包含下面这些项:
Painting.java:一个模型对象
PaintingDAO.java:一个数据访问对象
PaintingAction.java:一个控制器对象
statements.sql:DAO 对象所使用的 SQL 语句
view.jsp:渲染结果给用户看的 JSP 页面
注意这点很重要,在这种方式中,每个包应该包含与某一功能相关的(并且只与它相关的)所有项。特别地,一个包可以包含不只是 Java 源代码的文件。在按功能分包中,理想的状态是可以通过删除测试:你应该能通过删除一个文件夹的方式来删除某一个功能,而不会留下任何垃圾。
这样划分的好处较之按层次划分来说是非常明显的:
包有更高的内聚性和模块性。包之间的耦合变得最小化。
代码更加自我注释化。阅读代码的人看看包的名字就知道功能都有什么。在《代码大全》中,Steve McConnell 把自注释称为“易读性的圣杯”。
这种设计仍然实践分层的想法。可以在每一个功能里使用单独的类。
相关的项在一个地方。不再有必要在代码树里来回找相关的项了。
每个项默认是包私有的,而且也应该是包私有的。如果一个项需要对其它包公开,那么把它改成“public”。(注意,把一个类改成公开的,并不意味着它所有的成员都应该是公开的。在同一个类中可以混合公开和包私有成员。)
删除一个功能就跟删除一个目录这么简单。
每一个包内包含的项更少了。整个包结构组织得更自然了。比如,一个包太大了,那么可以将其重构成两个或更多的包。而在按层划分包的方式中,就根本不存在这样的可能:它的包包含任意多的类,而且你没法容易地重构它的包结构。
有些框架提倡按层划分的方式。因为传统的包命名很常见,程序员知道哪里可以找到自己要的东西。但是为什么强迫他们去找?用按功能划分的方式,这样烦人的寻找通常就不需要了。所以它完全优于任何这样的命名规范。
引用
一个区分好设计和坏设计的最重要的因素是,一个模块能把内部数据和实现细节藏得多深。
-- Joshua Bloch, Effective Java
三、JavaBeans:当不变体可以胜任的时候,为什么还用它们?
不变体对象是一种在创建后不改变状态(另一种说法,数据)的对象。Scala 的主要创始人 Martin Odersky 最近赞扬了不变体的优点。在《Effective Java》中,Joshua bloch 给出了一个有力支持不变体的情况。概述 Bloch 的观点,不变体是:
简单的
线程安全的,不需要同步
能自由共享
不需要用防御式拷贝来复制它
不需要复制构建函数或者 clone() 方法
是其它类很好的基石
是不错的 Map 键和 Set 元素
有原子性 —— 就是说,当有错误发生时,它们不会处于一种不一致或者无效状态
Bloch 说,“除非有一个非常好的理由,否则类应该被设计成不变体。”但是它的建议好像被大面积忽略了。大多数用 JavaBeans 而不是不变体。JavaBeans 明显比不变体要复杂。它们的复杂性来源于很广的状态区间。通俗点说,你可以把一个 JavaBean 想成是一个不变体的反面:它允许最大程度上的可变性。
现在用 JavaBeans 来模型化数据库记录是很常见的。有没有一种更合适的设计?不妨这样想:如果你正在为从数据库的数据集里取出一条记录建立模型,没有任何偏见,或者带这样限制的框架,你会怎样设计?它是像一个 JavaBean 呢,还是远远不一样?
我想它会完全不一样:
它很可能不会包含无参构建函数。因为这样的构建函数不带任何数据。一个为数据库的数据集建立的模型不带任何数据,哪怕是临时数据,这样合理吗?不,它不合理。一个无参的构建函数能让你自然联想到它的模型吗?不,它不会。(数据确实经常是可选的。但是有多经常一个记录所有的列都是可选的?)
它不会需要事件和监听器。
它不会强迫你用可变体。特别是在一个 Web 应用程序中,模型对象通常只存在于一个请求的时间段里。这样的对象的生命周期不是很长,所以通常不需要根据用户动作来改变状态。
它应该会定义一个数据校验机制。这样的机制是数据库应用程序中最重要的功能之一,应该被模型直接支持。(记住你所学到的关于对象的第一件事:一个对象封装数据和针对数据的操作。在本情况下,操作就是校验。)
校验机制应该允许向用户报告错误。
JavaBeans 规范是为一种非常特殊的问题域而创建的:设计时操作图形化小部件。规范里一点没提到数据库。因为它从一开始就不是为了数据库而建立的。结果是,发现 JavaBeans 并不是一个非常自然地为数据记录建模的工具就很正常了。
从现实的角度看,很多广泛使用的框架要求应用程序的程序员使用 JavaBeans(或者什么相似的东西)来为数据记录建模。这是非常不幸的。因为它不允许程序员利用不变体很多非常有用的东西。
四、私有成员:为什么把它们放在前面?
老的好莱坞电影总是以致谢开头 —— 所有的致谢。相似地,大多数 Java 类把实现细节(私有成员)放在前头。列表 3 展示了一下这种风格的典型例子。
列表3. 放在前面的私有成员
Java代码
public class OilWell implements EnergySource {
private Long id;
private String name;
private String location;
private Date discoveryDate;
private Long totalReserves;
private Long productionToDate;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
//..省略
}
然而,把私有成员放在最后,而不是最前,看上去对于阅读者来说是更慈悲的。当试图去理解某事 —— 任何事 —— 你应该以从普遍到特殊的方式来看。更明确地说,你应该从高层抽象到低层抽象。如果反着来,那么从一开始可能就不会把握住整体要点,也可能把握不住各个部件是怎么协同工作的。最终导致迷失在一大堆可能并没有什么联系的点里。
抽象的关键就是忽略细节。抽象的层次越高,细节被忽略得越多。读者忽略的细节越多他们越高兴。把很多细节记在脑子里是很痛苦的。所以,细节越少越好。这样,把私有成员放在最后看起来对读者就更具同情心。因为它把与读者现有任务(不管是什么任务)无关紧要的细节给剔除了。
原来在 C++ 编程文化里,私有成员是放在前面的。就跟现在的 Java 一样。然而,不像 Java 社区,C++ 社区很快地认识到这是一个没有任何帮助的习惯。所以它现在被反过来了。这里是一段来自《A Typical C++ Style Guide》的话:
引用
注意,把公共接口放在类中的最前面,然后是保护成员,最后是私有成员。原因有下:
程序员更关心类的接口而不是实现
当程序员需要使用一个类的时候,他们需要接口而不是实现
把接口放在前面看起来很有道理。把实现、私有部分放在前面是由于历史原因。因为最初的样板都使用私有成员前置方法。随着时间的推移,重点已经发生了转移。
类似地,《Imperical College London C++ Style Guide》说,“把公共区域放在前面,所有用户感兴趣的东西就都被集中放置在了类定义的前面。保护区域也许会让考虑继承它的设计者感兴趣。但私有区域包含的细节应该是对人最没有吸引力的。”
当读者要理解一个类的时候,应该通过 Javadoc 生成的文档,而不是源代码来看。然而, Javadoc 生成的文档不包含实现细节。当维护一个类的时候,程序显然还是须要访问源代码。
对于任何类型的技术文档来说,把高层的信息放在开头是很正常的 —— 比如说一本书的目录,或者是一篇文学论文的摘要。为什么 Java 类要反着做?把私有成员放在前面似乎是一个应该被打破的坏习惯。它一开始之所以存在很可能是因为受早期代码风格地影响。比如说 Sun 公布的编程规约。就像 Joshua Bloch 所说,“我不会太把那份文档(Sun 的编程规约)当回事。Sun 并不积极维护和使用它。”
仿照 Javadoc 生成的文档风格来编排代码看起来更合理:先是构造函数,然后是非私有方法,最后是私有域和方法。这是唯一可以让读者的思路从高层抽象到低层抽象自然转移的风格。
总结
我已经论证了这里面提到的四种应该被打破的 Java 习惯。评判某一改变是否合理的终级标准是它是否让可以让代码明显地更易读、理解和使用,并且在这个过程中,它是否会让读者的精神体验更愉悦。不变体和分包风格的例子也应该会刺激你朝改善设计的方向迈进。
总之,我建议下面的习惯应该被推荐的习惯所代替:
使用一种命名规范来区分:局部变量、域和方法参数。
优先使用按功能分包法而不是按层次分包法。
优先使用不变体而不是 JavaBeans。
把类中的项按作用范围从高到低排列。也就是说,把私有成员放到最后。
如果这篇文章所说的事是正确的,那么引出一个问题:这么多年过去后,为什么这样有害的习惯会在 Java 社区文化中延续到今天?为了回答这个问题,我提出下面的推测。早期的风格指导和其它工具发布的时候,大家还不是很有经验。他们把大多数事都做对了,但是还是有极小一部分错了。这是非常正常的,并不能说明它们的作者很糟糕。这其中 Sun 公布的一些指导和例子被广泛模仿。它们有着非常强的影响力,并且经常被当作是权威。也许它们被过分看重了。
有些有害的习惯在 Java 的早期稳固下来是因为它们已经变成了非常流行的、根深地固的习惯 —— 也许有点舆论催眠或者说群体思维的味道。然而正是流行和权威经常会把所有的相反看法给掩盖掉。
Java 编程文化会逐渐去掉这些坏习惯吗?还是尽管知道它们的坏处还依然如故?我的猜测是,不幸地,它们还会在 Java 编程文化中占有一席之地。但是我很希望我是错的!
作者传
John O'Hanley 是 javapractices.com 的创始者,并且是 WEB4J 框架的作者。他有着 10 年的编程经验。现居住于加拿大爱德华王子岛。