使用 Joshua Bloch 邮件获取 Java 技术问题的帮助






1.63/5 (6投票s)
2004年6月24日
13分钟阅读

38135
讨论 Java 接口以及覆盖 equals 时的一般约定
引言
以下是我和 Joshua Bloch 之间的一些电子邮件。我希望这些邮件能对你有所帮助。这些邮件已经保存了一段时间了。我想我可以和大家分享一下。
关于 Joshua Bloch,这里有一些信息
Joshua Bloch,Sun Microsystems, Inc. 的高级软件工程师。Bloch 是核心 Java 平台组的架构师,设计并实现了获奖的 Java 集合框架、java.math 包,并为平台的许多其他部分做出了贡献。Bloch 发表了大量文章和论文,还撰写了《Effective Java 编程语言指南》一书,该书荣获《软件开发杂志》颁发的著名的 Jolt 奖。Bloch 拥有卡内基梅隆大学的计算机科学博士学位。
这些邮件讨论了覆盖 equals 时的一般约定和 Java 接口版本问题。这些问题在《Effective Java 编程语言指南》一书中都有提及。
--------------------------------------------------------------------------------------------------------------------------------------
建议
在我阅读了《Effective Java》(作者 Joshua Bloch mailto:joshua.bloch@sun.com)后,我发现了一些问题。以下是我的建议:
1. 在第 7 条:覆盖 equals 时遵守通用约定
“事实证明,这是面向对象语言中
等价关系的一个根本问题。根本没有办法
在扩展一个可实例化的类的同时添加一个方面,又能保留 equals
约定。”
我认为我们可以修改 Point.equals 函数来做到这一点,让它仅在两个对象具有相同类类型时才返回 true。
public class Point extends Point2D implements java.io.Serializable {
//...
public boolean equals(Object obj) {
if (obj == null) {
return false;
}
if (getClass().equals(obj.getClass()) == false) {
return false;
}
Point pt = (Point)obj;
return (x == pt.x) && (y == pt.y);
}
}
public class ColorPoint extends Point {
private Color color;
public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
public boolean equals(Object o) {
if (obj == null) {
return false;
}
if (getClass().equals(obj.getClass()) == false) {
return false;
}
ColorPoint cp = (ColorPoint)o;
return super.equals(o) && cp.color == color;
}
}
对称性或传递性都没有问题。
我建议这应该成为覆盖 equals() 函数的一般规则。
2. 在第 16 条:使用接口而不是抽象类。
我认为 Sun 应该从 Java 中移除接口。规则应该是“用抽象类替换接口”。
接口导致了一个问题,即版本不兼容。一旦你发布了一个 Java 接口,别人使用了它,你就不能再修改它了。因为如果你给那个 Java 接口添加一个函数,别人的代码就无法编译。当你改用抽象类时,一切都好,你可以以后自由地添加新函数。
例如,如果 Sun 在 java.lang.Runnable 中添加了一个新函数,许多用 Runnable 接口编写的 Java 多线程程序就无法编译了。这种情况也发生在 Java JDBC 中,Sun 使用了像 Connection 这样的接口。当 Sun 发布一个新的 JDK 版本,例如从 jdk1.4.1 到 jdk1.4.2,Sun 在 Connection 接口中添加了一些新函数。而那些将 JDK 升级到 jdk1.4.2 的程序员发现他们无法编译代码了,因为他们使用的 JDBC 驱动程序没有实现 Connection 接口中的那些新函数。这是不可接受的。为什么新版 JDK 无法编译在旧版本中可以正常编译的代码?没有理由。
另一个例子是 LayoutManager,Sun 需要为这个接口添加更多功能,他们不得不增加一个新的 LayoutManager2 接口。这不是一个好方法,但他们别无选择。
在 Java Swing 模块中,有很多用于事件的接口。但是 Sun 在新的 JDK 中不断地在旧接口中添加更多功能。这会导致旧代码编译错误。他们不得不为每个事件接口添加一个名为 XXXAdapter 的新抽象类。程序员应该使用这些 XXXAdapter 类而不是事件接口。这样,他们就可以在新 JDK 下无错地编译旧代码。这不是一个好方法。
我认为 Sun 应该像这样修改 Java 的 Object 类:
public class Object {
//... 旧代码
private HashMap m_InterfacesMap = new HashMap();
public void addInterface(Object o) {
m_InterfacesMap.put(o.getClass(), o);
}
public Object getInterface(Class c) {
Object result = m_InterfacesMap.get(c);
if (result != null) {
return result;
}
Iterator iteratorInterfaceObj = m_InterfacesMap.values().iterator();
while (iteratorInterfaceObj.hasNext()) {
Object o = iteratorInterfaceObj.next();
if (c.isInstance(o)) {
return o;
}
}
return null;
}
public boolean supportInterface(Class c) {
if (m_InterfacesMap.get(c) != null) {
return true;
}
Iterator iteratorInterfaceObj = m_InterfacesMap.values().iterator();
while (iteratorInterfaceObj.hasNext()) {
Object o = iteratorInterfaceObj.next();
if (c.isInstance(o)) {
return true;
}
}
return false;
}
}
现在我们可以从 Java 中移除接口了。
Jacklondon Chen
Joshua Bloch 回复了关于覆盖 equals 时的一般约定的邮件。至于接口版本问题,他没说几句。
>建议
>在我阅读了《Effective Java》(作者 Joshua Bloch mailto:joshua.bloch@sun.com)后,我发现了一些问题。以下是我的建议:
>
>1. 在第 7 条:覆盖 equals 时遵守通用约定
>
>“事实证明,这是面向对象语言中
>等价关系的一个根本问题。根本没有办法
>在扩展一个可实例化的类的同时添加一个方面,又能保留 equals
>约定。”
>
>我认为我们可以修改 Point.equals 函数来做到这一点,让它仅在两个对象具有相同类类型时才返回 true。
>
>public class Point extends Point2D implements java.io.Serializable {
>
>//...
>
> public boolean equals(Object obj) {
> if (obj == null) {
> return false;
> }
> if (getClass().equals(obj.getClass()) == false) {
> return false;
> }
>
> Point pt = (Point)obj;
> return (x == pt.x) && (y == pt.y);
>
> }
>
>}
>
>public class ColorPoint extends Point {
> private Color color;
>
> public ColorPoint(int x, int y, Color color) {
> super(x, y);
> this.color = color;
> }
>
> public boolean equals(Object o) {
> if (obj == null) {
> return false;
> }
> if (getClass().equals(obj.getClass()) == false) {
> return false;
> }
>
> ColorPoint cp = (ColorPoint)o;
> return super.equals(o) && cp.color == color;
> }
>}
>
>对称性或传递性都没有问题。
>我建议这应该成为覆盖 equals() 函数的一般规则。
>
>
从技术上讲,你是对的。一个基于 getClass 的 equals 方法
允许你在子类中添加一个“方面”(一个影响 equals 比较的字段)
而不违反 equals 约定的字面规定,但是
它有其他更严重的问题。特别是,你牺牲了
可替代性(里氏替换原则),随之也牺牲了
最小惊讶原则。这件事有些
争议。大多数 Java 专家(包括 Doug Lea)都同意
我对此的立场,但有少数人(特别是 Angelika Langer)
不同意。我曾计划就此争议写一篇文章,但从未
完成……这是一份粗略、未完成的草稿:
许多人给我发邮件说,在扩展一个可实例化的类
并添加一个方面的同时,是*可以*保留 equals 约定的,
这与我在第 7 条中的说法相反。其他书籍推荐了这些
“基于 getClass 的 equals 方法”,而我的书(第 7 条)没有讨论
它们。我
曾考虑在第 7 条中讨论这个话题,但最终决定不这么做,因为
第 7 条已经太长太复杂了。总的来说,这可能是一个
错误。如果我知道这个话题如此有争议,以及 getClass 方法如此出名,
我就会讨论它。我计划发布一篇关于
这个话题的
文章在书的网站上,但我还没时间完成
写作。
这里是它的草稿形式:
这种技术(“基于 getClass 的 equals 方法”)确实满足了 equals
约定,但代价巨大。getClass 方法的缺点
是它违反了“里氏替换原则”,该原则指出
(粗略地说)一个期望接收超类实例的方法,在接收
子类实例时必须行为正常。如果一个子类
添加了一些新方法,或者对行为做了微不足道的修改(例如,在每次方法调用时
输出一条跟踪信息),当子类和超类实例不能
正常交互时,程序员会感到惊讶。
那些“本应相等”的对象将不相等,导致程序
失败或行为异常。这个问题因 Java 的集合是基于
equals 方法而更加严重。
这里有一个不涉及集合的简单例子。假设你
有一个复数类,它有一个基于 getClass 的 equals 方法:
public class Complex {
private double re;
private double im;
public static final Complex ORIGIN = new Complex(0.0, 0.0);
public Complex(double re, double im) {
this.re = re;
this.im = im;
}
final public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) // 可疑的写法
return false;
Complex c = (Complex)o;
return c.re == re && c.im == im;
}
final public double norm() { return Math.sqrt(re*re + im*im); }
// 复数上 signum 的标准定义
final public Complex signum() {
if (this.equals(ORIGIN))
return ORIGIN;
double norm = norm();
return new Complex(re/norm, im/norm);
}
...
}
这一切看起来可能没问题,但看看如果你以某种无害的方式扩展 Complex,
创建一个子类的实例,其 re 和
im 值都为 0.0,然后调用 signum 方法会发生什么。初始的
equals 测试返回 false,然后 signum 会除以零(两次)并
悄悄地返回错误的答案,一个实部和
虚部都为 NaN 的复数!(正确答案是 ORIGIN。)这并非
孤例。编写一个带有基于 getClass 的 equals 方法的非 final 类需要
更加小心。
基于 getClass 的 equals 方法提供了与
基于 instanceof 的方法截然不同的语义。许多 Java 程序员期望的是后者
的语义,部分原因是 Java
平台库中的绝大多数类都使用基于 instanceof 的 equals 方法。如果类间
比较都返回 false(就像基于 getClass 的 equals 方法那样),
子类可能会行为异常,而程序员可能不知所措,
不明白为什么。另外需要注意的是,在一个
继承体系中混合使用这两种方法会产生无意义的结果。
getClass 方法的一个小缺点是它需要一个
单独的 null 测试来达到正确的行为。null 测试
由 instanceof 运算符自动执行,如第
32.
页所述。我应该重申,两种方法都有其支持者。使用
instanceof 方法,很容易编写“平凡的子类”,但
不可能添加一个方面(在 equals 比较中使用的字段)。使用
getClass 方法,可以添加一个方面(假设你
愿意让子类实例不与超类实例交互),
但不可能创建一个在任何超类可用之处都可用的子类,
即使是“平凡的子类”也不行。虽然
instanceof 方法占主导地位,但一些受人尊敬的作者确实认为
getClass 方法是可以接受的。
最后值得一提的是,这个争议远没有它看起来那么
重要。绝大多数类根本不应该
覆盖 Object.equals。只有值类(或其他具有
值语义的类)应该这样做。此外,大多数值类
应该是不可变的,因此是 final 的。如果一个类是 final 的,那么它拥有
两种 equals 方法中的哪一种都无所谓。
从理论角度来看,在子类中添加一个方面通常是可疑的,
因为它常常违反了“is-a”测试。例如,一些
书将三维点(Point3)作为二维点(Point2)的子类。
然而,一个三维点*并不是*一个
二维点!一个三维点可以从三种方式*被视为*一个
二维点(投影到 X-Y 平面,
投影到 Y-Z 平面,投影到 X-Z 平面)。请注意,我
建议的解决方案(第 31 页)与这种情况非常吻合:可以
向三维点类添加三个独立的返回二维点的视图方法。
Mads Torgersen 教授这样总结道:
“令人惊讶的是,有多少有趣的面向对象
抽象问题,你可以通过
摒弃面向对象抽象来解决。这就像屏住呼吸
来止住打嗝:如果你做得足够久,它保证
会奏效。”
>2. 在第 16 条:使用接口而不是抽象类。
>
>我认为 Sun 应该从 Java 中移除接口
>
我假设你知道,即使这是可取的,也是不可能的。
这将带来巨大的不兼容性,破坏
数百万个现有程序。话虽如此,我认为这是一个非常
糟糕的主意。接口是 Java 编程
语言的核心和灵魂。
>规则应该是“用抽象类替换接口”。
>
>
不。你正确地指出,演进一个抽象类
比演进一个接口更容易。实际上,第 16 条就这么说了,并在
结尾段落中重复了这一点。当演进至关重要时,
抽象类可能比接口更可取。然而,总的来说,
接口提供的实现灵活性
使其更受青睐。这个话题比
前一个争议小。(据我所知,你是第一个
对第 16 条提出异议的人。)
新年快乐,
Josh
我发现这些关于“覆盖 equals 时的一般约定”的代码仍然存在一些错误。所以我修改了代码并重新发送了一封邮件。
尊敬的 Bloch 先生:
非常荣幸能收到您的邮件。非常感谢。
关于第 7 条:覆盖 equals 时遵守通用约定,
我找到了一个解决这个问题的好方法。那就是检查定义 equals 函数的类。
public class Point {
public boolean equals(Object other) {
if(CheckEqualsTool.hasSameDeclaringEqualsClass(this,other)){
Point otherPoint = (Point) other;
return x == otherPoint.x && y == otherPoint.y;
}
else {
return false;
}
}
}
public class ColorPoint
extends Point {
private Color color;
public ColorPoint() {
super(0, 0);
this.color = Color.black;
}
public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
public boolean equals(Object other) {
if(CheckEqualsTool.hasSameDeclaringEqualsClass(this,other)){
ColorPoint otherColorPoint = (ColorPoint) other;
return super.equals(other) && color == otherColorPoint.color;
}
else {
return false;
}
}
}
public class CheckEqualsTool {
public static boolean hasSameDeclaringEqualsClass(Object obj, Object other) {
if (obj == null || other == null) {
return false;
}
Class thisDeclaringEqualsClass = getDeclaringEqualsClass(
obj);
Class otherDeclaringEqualsClass = getDeclaringEqualsClass(
other);
return thisDeclaringEqualsClass.equals(otherDeclaringEqualsClass);
}
private static Class getDeclaringEqualsClass(Object obj) {
Class declaringEqualsClass = null;
try {
Method method = obj.getClass().getMethod("equals",
new Class[] {Object.class});
declaringEqualsClass = method.getDeclaringClass();
}
catch (Exception e) {
declaringEqualsClass = null;
}
return declaringEqualsClass;
}
}
关于第 16 条:使用接口而不是抽象类
我刚领导完成了一个 Java 应用项目。项目中不允许任何人创建 Java 接口。我们获得了非常好的版本兼容性。
我不明白为什么 Sun 不关心 Java 源码的版本兼容性。
作为一条基本的通用规则,程序员更新软件开发工具后,所有旧代码都应该能够在新版工具下编译通过。
然而,当我们使用 Java 接口时,我们无法做到这一点。没有 Java 接口,我们感觉很好。
如果你写了一个 Java 接口,别人使用了它,你就不能再向其中添加代码了。这可能会导致别人的代码编译错误。
我检查过,有太多的 Java AWT/Swing 事件使用 Java 接口作为事件监听器,并使用抽象类作为事件接口的适配器,
例如
public interface KeyListener extends EventListener {
public void keyTyped(KeyEvent e);
public void keyPressed(KeyEvent e);
public void keyReleased(KeyEvent e);
}
public abstract class KeyAdapter implements KeyListener {
public void keyTyped(KeyEvent e) {}
public void keyPressed(KeyEvent e) {}
public void keyReleased(KeyEvent e) {}
}
我认为 KeyListener 没有存在的理由,无论是 JDK 还是程序员,使用 KeyAdapter 都很好。
抱歉打扰您。
Jacklondon Chen
--------------------------------------------------------------------------------------------------------------------------------------
Jacklondon Chen (陈平)
软件工程师
中国惠普有限公司软件解决方案中心
中国上海金桥出口加工区云桥路25号T22
电话:86-21-28982061
传真:86-21-28982112
电子邮件:jacklondon.chen@hp.com