代码重用的优点
在早期的软件开发中,都认为开发人员的生产率与他编写的代码数量成正比。在那时,有这么一个看似合理的标尺:代码最终导致一个大概能用的二进制资产,所以编写很多代码的人必定是在勤奋地工作,以实现一个可用的应用程序。这个标尺似乎也适用于其他行业。处理大量纳税申报的会计,或者做浓咖啡饮料的咖啡师一定是富有生产率的,对吗?他们都为各自的生意带来更多的收入,因为他们都按预期生产了很多的产品。
但是,不久前我们才知道,更多的代码并等不同于高生产率。大量的代码当然表明高度的积极性,但是积极性并不一定等同于进步。每天产生大量错误的纳税申报表的会计确实很积极,但是他们为客户和雇员带来的价值却很少。每天以闪电般的速度制造咖啡,但弄错了订单的咖啡师的确很积极,不过其生产率显然不高。
幸好,软件行业普遍接受这样一个观点:太多的代码可能是一件坏事。有两项研究发现,一般的应用程序每 1,000 行代码就包含 20 到 250 个 bug(参见 参考资料)!这个度量被称作缺陷密度(defect density)。据此可得出一个重要的结论:更少的代码意味着更少的缺陷。
当然,仍然需要编写代码。虽然应用程序本身不能为我们编写代码,但是现在我们可以借用很多的代码。我们还没有实现业务组件中的重用(例如,开发人员可以重用其他人的 Account
对象),但是平台中的重用已经存在。开源框架和支持代码的激增可以帮助您以尽可能少的代码编写出一个 Account
对象(举个例子)。
例如,Hibernate 和 Spring 在 Java 社区中十分普遍。以 Account
对象为例,着手于 greenfield 开发项目的团队准备构建一个在线订购应用程序(需要一个 Account
对象)。与从头开始编写一个面向对象映射(ORM)框架不同,他们可通过利用 Hibernate 或一个有竞争力的面向对象映射(ORM)框架,并且会因此大大受益。对于应用程序的其他方面也是一样的,例如单元测试(您使用 JUnit,对吗?)或依赖项注入(显然,Spring 是不错的选择)。那就是重用。只是与我们曾经想象的不一样。
通过借用或重用这些框架只需编写更少的代码,从而可以更专注于业务问题。这些框架本身有大量的代码,不过关键的是,您不需要编写或维护它。这正是成功的开源项目的妙处:其他人在为您做这些事,而且他们可能比您更专业。
更少的代码意味着可以更快地将缺陷更少的软件推向市场。但是,重用很重要,因为它不仅意味着编写更少的代码,而且还意味着可以利用 “群众的智慧”。一些流行的开源框架和工具 — 例如 Hibernate、Spring、JUnit 和 Apache Web server — 正在被全球各地的人在不同的应用程序中使用。这种久经考验、经过测试的软件并不是没有缺陷,但是您可以放心地认为,即使出现问题,也可以很快发现并将其解决,而且无需付出成本。
Apache Commons 项目已经存在多年了,它非常稳定。最新的发行版包含大约 90 个类和将近 1,800 个单元测试。虽然没有公布覆盖信息(当然,有人可能会说这个项目的代码覆盖率比较低),但是数据是最好的说明。每个类基本上有 20 个测试。我敢打赌,对这个项目的代码的测试是很严格的,至少不亚于您测试自己的代码。
对象契约
Commons Lang 库带有一套方便的类,它们统称为 builders。在本节中,您将学习如何使用其中一个类来构建 java.lang.Object equals
方法,以帮助减少编写的代码数量。
所有 Java 类都自动继承 java.lang.Object
。您可能已经知道,Object
类有 3 个方法通常需要被覆盖:
equals
hashCode
toString
equals
和 hashCode
方法的特殊之处在于,Java 平台的其他方面,例如集合甚至是持久性框架(包括 Hibernate),要依赖于这两个方法的正确实现。
如果您没有实现过 equals
和 hashCode
,那么您可能会认为这很简单 — 但是您错了。Joshua Bloch 在 Effective Java 一书(参见 参考资料)以超过 10 页的篇幅论述了实现 equals
方法的特殊之处。如果最终实现 equals
方法,那么还需要实现 hashCode
方法(因为 equals
的契约表明,两个相等的对象必须有相同的散列码)。Bloch 又以 6 页的篇幅解释了 hashCode
方法。也就是说,至少有 16 页关于适当实现两个看上去很简单的方法的详细信息。
实现 equals
方法的挑战在于该方法必须遵从的契约。equals
必须:
- 具有反射性:
- 对于某个对象,
foo
(不为null
),foo.equals(foo)
必须返回true
。
- 对于某个对象,
- 具有对称性:
- 对于对象
foo
和bar
(不为null
),如果foo.equals(bar)
返回true
,那么bar.equals(foo)
也必须返回true
。
- 对于对象
- 具有传递性:
- 对于对象
foo
、bar
和baz
(不为null
),如果foo.equals(bar)
为true
且bar.equals(baz)
为true
,那么foo.equals(baz)
必须也返回true
。
- 对于对象
- 具有一致性:
- 对于对象
foo
和bar
,如果foo.equals(bar)
返回true
,那么无论equals
方法被调用多少次,equals
方法总是应该返回true
(假设两个对象都没有实际的变化)。
- 对于对象
- 能够适当地处理
null
:foo.equals(null)
应该返回false
。
读到这里或者研读过 Effective Java 之后,您将面临在 Account
对象上适当地实现 equals
方法的挑战。但是请记住前面我就生产率和积极性所说的话。
假设您要为企业构建一个在线 Web 应用程序,这个应用程序越早投入使用,您的企业就能越早赚钱。在此情况下,您还会花几个小时(或者数天)来适当地实现和测试 对象上的 equals
契约吗?— 还是重用其他人的代码?
当实现 equals
方法时,Commons Lang EqualsBuilder
比较有用。这个类很容易理解。实际上,您需要知道它的两个方法:append
和 isEquals
。append
方法带有两个属性:一个是本身对象的属性,一个是相比较的对象的同一个属性。由于 append
方法返回 EqualsBuilder
的一个实例,因此可以将随后的调用链接起来,比较一个对象所有必需的属性。最后,可以通过调用 isEquals
方法完成这个链。
例如,像清单 1 中那样创建一个 Account
对象:
我们需要的 Account
对象很简单,而且是独立的。此时,您可以运行一个快速测试,如清单 2 所示,看看是否可以信赖 equals
的默认实现:
清单 2. 测试 Account
对象的默认 equals
方法
在清单 2 中可以看到,我创建了两个相同的 Account
对象,每个对象有它自己的引用(因此 ==
将返回 false
)。当我想看看它们是否相等时,JUnit 友好地通知我返回的是 false
。
记住,Java 平台的许多方面都可以利用 equals
方法,包括 Java 语言的集合类。所以,有必要为这个方法实现一个有效的版本。因此,我将覆盖 equals
方法。
记住,equals
契约不适用于 null
对象。而且,两种不同类型的对象(例如 Account
和 Person
)不能相等。最后,在 Java 代码中,equals
方法显然有别于 ==
操作符(还记得吗,如果两个对象有相同 的引用,后者将返回 true
;因此,那两个对象必定相等)。两个对象可以相等(并且 equals
返回 true
),但是不使用相同的引用。
因此,可以像清单 3 中这样编写 equals
方法的第一个方面:
if (this == obj) { return true; } if (obj == null || this.getClass() != obj.getClass()) { return false; } |
在清单 3 中,我创建了两个条件,在比较基对象和传入的 obj
参数各自的属性之前,应该验证这两个条件。
接下来,由于 equals
方法带有 Object
类型的参数,所以可以将 obj
参数转换为 Account
,如清单 4 所示:
Account account = (Account) obj; |
假设 equals
逻辑到此为止,接下来可以利用 EqualsBuilder
对象。记住,这个对象被设计为使用 append
方法将基对象(this
)的类似属性和传入 equals
方法的类型进行比较。由于这些方法可以链接起来,最终可以以 isEquals
方法完成这个链,该方法返回 true
或 false
。因此,实际上只需编写一行代码,如清单 5 所示:
return new EqualsBuilder()。append(this.id, account.id) .append(this.firstName, account.firstName) .append(this.lastName, account.lastName) .append(this.emailAddress, account.emailAddress) .append(this.creationDate, account.creationDate) .isEquals(); |
合并之后,可以产生如清单 6 所示的 equals
方法:
现在,重新运行之前失败的测试(参见 清单 2)。应该会成功。
您没有花任何时间来编写自己的 equals
。如果您仍然想知道自己如何编写一个适当的 equals
方法,那么只需知道这涉及到很多的条件。例如,清单 7 中是一个非 EqualsBuilder
实现的 equals
方法的一个小片段,它比较 creationDate
属性:
if (creationDate != null ? !creationDate.equals( person.creationDate) : person.creationDate != null){ return false; } |
注意,在这种情况下,虽然可以使用一个三元操作符(ternary)使代码更精确,但是代码更加费解。关键是我本可以编写一系列的条件来比较每个对象的属性的不同方面,或者可以利用 EqualsBuilder
(它也会做相同的事情)。您会选择哪种方法?
还需注意,如果您真的想优化自己的 equals
方法,并编写尽可能少的代码(这也意味着维护更少的代码),那么可以利用反射的威力,编写清单 8 中的代码:
清单 8. 使用 EqualsBuilder
的反射 API
public boolean equals(Object obj) { return EqualsBuilder.reflectionEquals(this, obj); } |
这对于减少代码是否有帮助?
清单 8 的确减少了代码。但是,EqualsBuilder
必须关闭基对象中的访问控制(以便比较 private
字段)。如果在配置 VM 时考虑了安全性,那么这可能失败。而且,清单 8 中使用的反射会影响 equals
方法的运行时性能。但是,从好的方面考虑,如果使用反射 API,当增加新的属性时,就不需要更新 equals
方法(如果不使用反射,则需要更新)。
通过 EqualsBuilder
,可以利用重用的威力。它为您提供两种方法来实现 equals
方法。选择哪一种方法由具体情况决定。单行风格比较简单,但是您现在已经理解,这样做并非没有风险。
对象的散列
现在,您已经实现一个适当的 equals
方法,而且也没有编写太多的代码,但是别忘了还要覆盖 hashCode
。本节展示如何操作。
hashCode
方法也有一个契约,但是不像 equals
的契约那样正式。然而,重要的是要理解它。和 equals
一样,结果必须一致。对于对象 foo
和 bar
,如果 foo.equals(bar)
返回 true
,那么 foo
和 bar
的 hashCode
方法必须返回相同的值。如果 foo
和 bar
不相等,则不要求返回不同的散列码。但是,Javadocs 提到,如果这些对象有不同的结果,那么通常会运行得更好一些。
还需注意,和之前一样,如果没有覆盖它,hashCode
会返回一个看似随机的整数。这是因为底层平台通常会将基对象的地址位置转换成一个整数;虽然如此,但文档中提到这并不是必需的,因此可以改变。无论如何,如果最终覆盖 equals
方法,那么也有必要覆盖 hashCode
方法。(记住,虽然 hashCode
方法看上去是开箱即用的,但是 Joshua Bloch 的 Effective Java 花了 6 页的篇幅讨论如何适当地实现 hashCode
方法)。
Commons Lang 库提供一个 HashCodeBuilder
类,这个类与 EqualsBuilder
几乎是一样的。但是,它不是比较两个属性,而是附加一个属性,以生成遵从我刚才描述的契约的一个整数。
在您的 Account
对象中,覆盖 hashCode
方法,如清单 9 所示:
public int hashCode() { return 0; } |
由于生成一个散列码时没有什么可以比较的,因此使用 HashCodeBuilder
只需一行代码。重要的是正确地初始化 HashCodeBuilder
。构造函数带有两个 int
,它使用这两个参数来创建一个散列码。这两个 int
必须是奇数。append
方法带有一个属性,因此,和之前一样,这些方法可以链接起来。最后可以通过调用 toHashCode
方法完成这个链。
根据这些信息,您可以像清单 10 中那样实现一个 hashCode
方法:
清单 10. 用 HashCodeBuilder
实现一个 hashCode
方法
public int hashCode() { return new HashCodeBuilder(11, 21)。append(this.id) .append(this.firstName) .append(this.lastName) .append(this.emailAddress) .append(this.creationDate) .toHashCode(); } |
注意,我在构造函数中传入了一个 11 和一个 21。这些完全是为该对象随机选择的奇数。打开前面的 AccountTest
(参见 清单 2)。添加一个快速检查,以验证如下契约:对于这两个对象,如果 equals
返回 true
,那么 hashCode
应该返回相同的数字。清单 11 显示了修改后的测试:
清单 11. 验证 hashCode
关于两个相等对象的契约
在清单 11 中,我验证了两个相等的对象具有相同的散列码。接下来,在清单 12 中,我还验证两个不同的 对象具有不同的散列码:
清单 12. 验证 hashCode
关于两个不同的对象的契约
@Test public void verifyAccountDifferentHashCodes(){ Date now = new Date(); Account acct1 = new Account(1, "John", "Smith", "john@smith.com", now); Account acct2 = new Account(2, "Andrew", "Glover", "ajg@me.com", now); Assert.assertFalse(acct1.equals(acct2)); Assert.assertTrue(acct1.hashCode() != acct2.hashCode()); } |
如果您出于好奇想自己编写一个 hashCode
方法,应该怎么做呢?记住 hashCode
契约,您可以编写如清单 13 所示的代码:
public int hashCode() { int result; result = (int) (id ^ (id >>> 32)); result = 31 * result + (firstName != null ? firstName.hashCode() : 0); result = 31 * result + (lastName != null ? lastName.hashCode() : 0); result = 31 * result + (emailAddress != null ? emailAddress.hashCode() : 0); result = 31 * result + (creationDate != null ? creationDate.hashCode() : 0); return result; } |
不用说,这段代码也可以作为有效的 hashCode
方法,这两个 hashCode
方法您更愿意维护哪一个?哪一个更易于理解?还要注意,清单 13 中如何利用三元操作符语句来避免大量的条件逻辑。您可能会想,Commons Lang 的 HashCodeBuilder
也许可以做类似的事情 — 但更好的是 Commons Lang 的开发人员在维护和测试它。
和 EqualsBuilder
一样,HashCodeBuilder
有另一个利用反射的 API。如果使用该 API,就不需要手动地用 append
方法添加基对象的每个属性,这样可以得到如清单 14 所示的一个 hashCode
方法:
清单 14. 使用 HashCodeBuilder
的反射 API
public int hashCode() { return HashCodeBuilder.reflectionHashCode(this); } |
和之前一样,由于这个方法在幕后应用 Java 反射,因此当进行安全性调整时,可能破坏该方法的功能,而且性能下降很多。
相对的 comparable
另一个有趣的方法也有一个相当正式的契约,那就是 Comparable
接口的 compareTo
方法。如果要控制特定的对象如何排序,那么这个接口非常重要。在本节中,您将学习如何利用 Commons Lang 的 CompareToBuilder
。
在过去的 Java 编程中您可能已经注意到,对于对象如何按一定的顺序排序有默认的机制,例如 Collections
类的 sort
方法。
例如,清单 15 中的 Collection
未经排序,如果不对它做任何事情,那么这个顺序将一直维持下去:
ArrayList<String> list = new ArrayList<String>(); list.add("Megan"); list.add("Zeek"); list.add("Andy"); list.add("Michelle"); |
然而,如果像清单 16 中那样将 list
传递给 Collections
的 sort
方法,那么将应用默认的排序,在这里是按字母顺序。清单 16 将清单 15 中的名称列表按字母顺序排序,并打印出排序后的结果:
Collections.sort(list); for(String value : list){ System.out.println("sorted is " + value); } |
清单 17 显示输出:
sorted is Andy sorted is Megan sorted is Michelle sorted is Zeek |
当然,这样之所以行得通,是因为 Java String
类实现了 Comparable
接口,因此有 compareTo
方法的一个实现,该方法允许按字母顺序排序。实际上,Java 语言中几乎所有的核心类都实现这个接口。
如果您想允许按不同的方式对一个 Account
集合进行排序 — 例如按 id
或 last name,应该怎么做呢?
当然,首先必须实现 Comparable
接口,然后实现 compareTo
方法。这个方法实际上只能用于自然排序 — 根据对象的属性对对象排序。因此,compareTo
非常类似于 equals
方法,只是通过它可以将一个集合的 Account
按它们的属性排序,排序的顺序与使用 compareTo
方法处理属性的顺序相同。
如果阅读用于实现该方法的文档,您将发现,它非常类似于 equals
;也就是说,要正确地实现它比较棘手。(Effective Java 花了 4 页的篇幅讨论这个专题)。到现在,您很可能已经想出这样的模式:利用 Commons Lang。
Commons Lang 提供一个 CompareToBuilder
类,它的功能与 EqualsBuilder
几乎一样。它包括一个可链接的 append
方法,最终可以通过 toComparison
方法返回一个 int
。
因此,首先必须修改 Account
类,以实现 Comparable
接口,如清单 18 所示:
public class Account implements Comparable {} |
接下来,必须实现 compareTo
方法,如清单 19 所示:
public int compareTo(Object obj) { return 0; } |
实现这个方法需要分两步。首先,必须将传入的参数的类型转换为需要的类型(在这里是 Account
)。然后,利用 CompareToBuilder
比较对象的属性。Commons Lang 文档表明,应该像 equals
方法中那样比较相同的属性;因此,Account
对象的 compareTo
方法看上去应该如清单 20 所示:
public int compareTo(Object obj) { Account account = (Account) obj; return new CompareToBuilder()。append(this.id, account.id) .append(this.firstName, account.firstName) .append(this.lastName, account.lastName) .append(this.emailAddress, account.emailAddress) .append(this.creationDate, account.creationDate) .toComparison(); } |
别忘了,如果您真的想减少自己编写的代码,那么可以总是利用反射风格的 CompareToBuilder
API,如清单 21 所示:
清单 21. 使用 CompareToBuilder
的反射 API
public int compareTo(Object obj) { return CompareToBuilder.reflectionCompare(this, obj); } |
现在,如果需要依赖用于一个 Account
集合的自然排序,那么可以利用 Collections.sort
,如清单 22 所示:
Date now = new Date(); ArrayList<Account> list = new ArrayList<Account>(); list.add(new Account(41, "Amy", "Glover", "ajg@me.com", now)); list.add(new Account(10, "Andrew", "Glover", "ajg@me.com", now)); list.add(new Account(1, "Andrew", "Blover", "ajg@me.com", new Date())); list.add(new Account(2, "Andrew", "Smith", "b@bb.com", now)); list.add(new Account(0, "Andrew", "Glover", "z@zell.com", new Date())); Collections.sort(list); for(Account acct : list){ System.out.println(acct); } |
这段代码先后根据 id
、first name 和 last name 等属性以自然顺序输出对象。因此,排序后的顺序如清单 23 所示:
new Account(0, "Andrew", "Glover", "z@zell.com", new Date()) new Account(1, "Andrew", "Blover", "ajg@me.com", new Date()) new Account(2, "Andrew", "Smith", "b@bb.com", now) new Account(10, "Andrew", "Glover", "ajg@me.com", now) new Account(41, "Amy", "Glover", "ajg@me.com", now) |
这种输出的作用则是另一回事。在下一节中,您将看到 Commons Lang 如何帮助您构建可读性更强的结果。
对象的字符串表示
Object
的 toString
方法的默认实现返回对象的完全限定名称,后面跟上一个 @
字符,然后是对象的散列码的值。您可能早就明白,这对于区分不同的对象帮助不大。Commons Lang 有一个方便的 ToStringBuilder
类,这个类可帮助构建可读性更强的 toString
结果。
您可能已经不止一次编写过 toString
方法 — 我就是这样。这些方法并不复杂,编写起来很难出错。但是,它们也可能令人讨厌。由于您的 Account
对象已经依赖于 Commons Lang 库,让我们看看 ToStringBuilder
的实际效果。
ToStringBuilder
与我在前面谈到的其他 3 个类相似。您可以创建它的一个实例,附加一些属性,然后调用 toString
。就是这样!
覆盖 toString
方法,添加清单 24 中的代码:
public String toString() { return new ToStringBuilder(this)。append("id", this.id)。 .append("firstName", this.firstName) .append("lastName", this.lastName) .append("emailAddress", this.emailAddress) .append("creationDate", this.creationDate) .toString(); } |
您可以总是利用反射,如清单 25 所示:
清单 25. 使用 ToStringBuilder
的反射 API
public String toString() { return ToStringBuilder.reflectionToString(this); } |
无论您选择如何使用 ToStringBuilder
,调用 toString
总会产生一个可读性更强的 String
。例如,看看清单 26 中的对象实例:
new Account(10, "Andrew", "Glover", "ajg@me.com", now); |
如清单 27 所示,输出的可读性很好:
com.acme.app.Account@47858e[ id=10,firstName=Andrew,lastName=Glover,emailAddress=ajg@me.com, creationDate=Tue Nov 11 17:20:08 EST 2008] |
如果您不喜欢对象的这种 String
表示,Commons Lang 库还有一些 helper 类可帮助定制输出。例如,使用 ToStringBuilder
可以在日志文件中一致地显示对象实例。