《Effective Java 3rd》读书笔记——对于所有对象都通用的方法

覆盖equals方法时遵守通用约定

覆盖equals()方法看起来似乎简单,但是许多覆盖方式会导致错误,并且后果非常严重。最能避免这些问题的办法就是不覆盖equals()方法,在这种情况下,类的每个实例都与它自己相等。如果满足了以下任意一个条件,那可以不覆盖equals()方法:

  • 类的每个实例本质上都是唯一的
  • 类没有必要提供逻辑相等的功能
  • 超类已经覆盖了equals(),超类的行为对于子类也是合适的。
    例如,大多数的Set实现都从AbstractSet继承equals()实现
  • 类是私有的,或包级私有的,可以确定它的equals()方法永远不会被调用

那么,什么时候应该覆盖equals()方法呢?如果类具有自己特有的逻辑相等概念,而且超类还没有覆盖equals()方法。这通常属于值类的情形。值类仅仅是一个表示值的类,例如IntegerString
程序员在利用equals()方法来比较值对象的引用时,希望知道它们在逻辑上是否相等,而不是想了解它们是否指向同一个对象。 此时,不仅必须覆盖equals()方法,而且这样做也使得这个类的实例可以被用作MapKey,或集合的元素,使Map或集合表现出预期的行为。

在覆盖equals()方法的时候,必须要遵循它的通用约定。下面是约定的内容,来自Object的规范
equals方法实现了等价关系,其属性如下:

  • 自反性:对于任何为null的引用值x,x.equals(x)必须返回true
  • 对称性:对于任何非null的引用值xy,当且仅当y.equals(x)返回true时,x.equals(y)必须返回true
  • 传递性:对于任何非null的引用值xyz,如果x.equals(y)返回truey.equals(z)也返回true,那么x.equals(z)也必须返回true
  • 一致性:对于任何非null的引用值xy,只要equals的比较操作在对象中所用的信息没有被修改,多次调用x.equals(y)就会一致地返回true,或者一致地返回false
  • 对于任何非null的引用值xx.equals(null)必须返回false

有许多类,包括所有的集合类,都依赖于传递给它们的对象是否遵守了equals约定。

我们按照顺序逐一查看以下5个要求:

  • 自反性——第一个要求仅仅说明对象必须等于其自身。这是很容易理解的
  • 对称性——第二个要求是说,任何两个对象对于"它们是否相等"的问题都必须保持一致。这是很容易无意中违反的。例如下面的例子实现一个区分大小写的字符串。字符串由toString保存,但在equals操作中被忽略:
public class CaseInsensitiveString {
    private final String s;
    public CaseInsensitiveString(String s) {
        this.s = Objects.requireNonNull(s);
    }

    /**
     * 违反对称性
     * @param that
     * @return
     */
    @Override
    public boolean equals(Object that) {
        if (that instanceof CaseInsensitiveString) {
            return s.equalsIgnoreCase(((CaseInsensitiveString) that).s);
        }
        /**
         * 单方面的想与String比较
         */
        if (that instanceof String) {
            return s.equalsIgnoreCase((String) that);
        }
        return false;
    }
}

在这个类中,equals方法企图与普通的String对象进行互操作。假设我们有一个CaseInsensitiveString和一个普通的String:

CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "polish";

不出所料,cis.equals(s)返回true,但是String不知道CaseInsensitiveString,因此s.equals(cis)返回false,显然违反了对称性。

一旦违反了equals约定,当其他对象面对你的对象时,你完成不知道这些对象的行为会怎样

为了修复上面这个类的问题,把企图与String对象互操作的代码删掉即可。

  • 传递性——第三个要求是,如果一个对象等于第二个对象,而第二个对象又等于第三个对象,则第一个对象一定等于第二个对象。同样的,无意中违反这条规则也不难想象。用子类举个例子。假设它将一个新的值组件添加到了超类中。换句话说,子类增加的信息会影响equals的比较结果。我们首先以一个简单的不可变的二维整数型Point类 作为开始:
public class Point {
    private final int x;
    private final int y;
    public Point(int x,int y) {
        this.x = x;
        this.y = y;
    }

    @Override
    public boolean equals(Object that) {
        if (!(that instanceof Point)) {
            return false;
        }
        Point p = (Point) that;
        return p.x == x && p.y == y;
    }
}

假设你想要扩展这个类,为Point添加颜色信息:

public class ColorPoint extends Point {
    private final Color color;
    public ColorPoint(int x, int y,Color color) {
        super(x, y);
        this.color = color;
    }
}

如果完全不提供equals方法,而是直接从Point继承过来,在equals做比较的时候颜色信息就被忽略掉了。虽然这不会违反equals约定,但明显这是无法接受的。
假设编写了一个equals方法,只有当它的参数是另一个ColorPoint,并且具有同样的位置和颜色时,它才会返回true:

@Override
public boolean equals(Object that) {
    if (!(that instanceof ColorPoint)) {
        return false;
    }
    return super.equals(that) && ((ColorPoint) that).color == color;
}

该方法的问题在于,在比较PointColorPoint,以及相反的情形时,可能会得到不同的结果。前一种比较忽略了颜色信息,而后一种比较总是返回false,因为参数的类型不正确。

public static void main(String[] args) {
    Point p = new Point(1,2);
    ColorPoint cp = new ColorPoint(1,2,Color.RED);
    System.out.println(p.equals(cp));//true
    System.out.println(cp.equals(p));//false
}

可以通过这样的尝试来修复这个问题,让ColorPoint.equals在进行混合比较时忽略颜色信息:

//ColorPoint的方法
@Override
public boolean equals(Object that) {
    if (!(that instanceof Point)) {
        return false;
    }
    //如果that是Point,则进行忽略Color的比较
    if (!(that instanceof ColorPoint)) {
        return that.equals(this);
    }
    //that是ColorPoint
    return super.equals(that) && ((ColorPoint) that).color == color;
}

这种方法虽然提供了对称性,但牺牲了传递性:

ColorPoint p1 = new ColorPoint(1,2,Color.RED);
Point p2 = new Point(1,2);
ColorPoint p3 = new ColorPoint(1,2,Color.BLUE);
System.out.println(p1.equals(p2));//true
System.out.println(p2.equals(p3));//true
System.out.println(p1.equals(p3));//false

从上面输出可以看出,违反了传递性。此外,这种方法还会导致无限递归问题:假设Point有两个子类,如ColorPointSmellPoint,它们各自都带有这种equals方法。那么对colorPoint.equals(smellPoint)的调用将会抛出StackOverflowError异常。

那么该如何解决呢?

其实这是面向对象语言中关于等价关系的一个基本问题。我们无法在扩展可实例化的类的同时,既增加新的值组件,同时又保留equals约定,除非愿意放弃面向对象的抽象所带来的优势。

你可能听过在equals方法中用getClass测试代替instanceof测试,可以扩展可实例化的类的同时,又保留equals约定:

@Override
public boolean equals(Object that) {
   if(that == null || that.getClass() != getClass()) {
       return false;
   }
   Point p = (Point)that;
   return p.x == x && p.y == y;
}

这段程序只有当对象具有相同的实现类是,才能使对象相等。虽然这样也不算太糟糕,但结果却是无法接受的:Point子类的实例仍然是一个Point,它仍然需要发挥作用,但如果采用了这种方法,它就无法完成任务。
假定我们要编写一个方法,以检验某个点是否处在圆中:

private static final Set<Point> unitCircle = Set.of(
    new Point(1,0),new Point(0,1),
    new Point(-1,0),new Point(0,-1));
public static boolean onUnitCircle(Point p) {
    return unitCircle.contains(p);
}

假设你通过某种不添加值组件的方法扩展了Point,并创建了多个实例:

public class CounterPoint extends Point {
    private static final AtomicInteger counter = new AtomicInteger();
    public CounterPoint(int x,int y) {
        super(x,y);
        counter.incrementAndGet();
    }
    public static int numberCreated() {
        return counter.get();
    }
}

里氏替换原则认为,一个类的任何重要属性也将使用于它的子类。因此为该类编写的任何方法,在它的子类上也应该同样运行得很好。

假设我们将CounterPoint实例传递给onUnitCircle方法。如果Point使用了基于getClassequals方法,无论CounterPoint实例的xy值是什么,该方法都会返回false

虽然没有一种令人满意的方法既可以扩展不可实例化的类,又增加值组件,但还是有一种不错的权宜之计:遵从复合优于继承的建议。不再让ColorPoint继承Point,而是添加一个私有的Point属性,以及一个公有的视图方法,该方法返回一个与该ColorPoint在相同位置的普通Point对象:

public class ColorPoint {
    private final Point point;
    private final Color color;
    public ColorPoint(int x, int y,Color color) {
        point = new Point(x,y);
        this.color = color;
    }

    public Point asPoint() {
        return point;
    }

    @Override
    public boolean equals(Object that) {
        if (!(that instanceof ColorPoint)) {
            return false;
        }
        ColorPoint cp = (ColorPoint) that;
        return cp.point.equals(point) && cp.color.equals(color);
    }
}

  • 一致性——equals约定的第四个要求是,如果两个对象相等,它们就始终保持相等,除非它们中有一个对象被修改了。
    如果你设计的某个类是不可变的:那么应该满足,相等的对象永远相等,不相等的对象永远不相等。无论类是否是不可变的,都不要使equals方法依赖于不可靠(可变)的资源。

  • 非空性——所有的对象都不能等于null
    许多类的equals方法都通过一个显式的null测试来防止这种情况:

@Override
public boolean equals(Object that) {
    if(that == null) {
        return false;
    }
    ...
}

这项测试其实是不必要的。为了测试其参数的等同性,equals方法必须先把参数转换成适当的类型,以便可以调用它的访问方法,或者访问它的属性。在进行转换之前,equals方法必须使用instanceof操作符,以检查其参数的类型是否正确:

@Override
public boolean equals(Object that) {
    if(!(that instanceof MyType) {
        return false;
    }
    MyType mt = (MyType) that;
    ...
}

如果instanceof的第一个操作数为null,那么不管第二个操作数是什么类型,该操作符都会返回false。因此不需要显示地null检查。

结合所有这些要求,得出了一下实现高质量equals方法的诀窍:

  1. 使用==操作符检查"参数是否为这个对象的引用",如果是则返回true。这只是一种性能优化,如果比较操作可能很昂贵,就值得这么做。
  2. 使用instanceof操作符检测"参数是否为正确的类型"。如果不是则返回false
  3. 把参数转换成正确的类型。因为转换之前进行过instanceof测试,所以确保会成功。
  4. 对于该类中的每个关键属性,检查参数中的属性是否与该对象中对应的属性相匹配。如果这些测试全部成功,则返回true,否则返回false

对于既不是float也不是double的原生类型属性,可以使用==操作符进行比较;对于对象引用属性,可以递归地调用equals方法;对于float属性,可以使用静态的Float.compare(float,float)方法;
对于double属性,则使用Double.compare(double,double);对于数组属性,则要把以上原则应用到每一个元素上。如果数组属性中的每个元素都很重要,就可以使用其中一个Arrays.equals方法。

有些对象引用属性包含null可能是合法的,所以,为了避免可能导致空指针异常,则使用静态方法Objects.equals(Object,Object)来检测。

在编写equals方法之前,应该问自己三个问题:它是否是对称的、传递的、一致的?

根据上面的诀窍构造equals方法的具体例子,看下面这个类:

public class PhoneNumber {
    private final short areaCode,prefix,lineNum;
    public PhoneNumber(int areaCode,int prefix,int lineNum) {
        this.areaCode = rangeCheck(areaCode,999,"area code");
        this.prefix = rangeCheck(prefix,999,"prefix");
        this.lineNum = rangeCheck(areaCode,9999,"line num");
    }

    private static short rangeCheck(int val,int max,String arg) {
        if (val < 0 || val > max) {
            throw new IllegalArgumentException(arg + ": " + val);
        }
        return (short) val;
    }

    @Override
    public boolean equals(Object that) {
        if (that == this) {
            return true;
        }
        if (!(that instanceof PhoneNumber)) {
            return false;
        }
        PhoneNumber pn = (PhoneNumber) that;
        return pn.lineNum == lineNum && pn.prefix == prefix && pn.areaCode == areaCode;
    }
}

下面是最后一些告诫:

  • 覆盖equals时总要覆盖hashCode
  • 不要企图让equals方法过于智能
  • 不要将equals声明中的Object对象替换成其他类型

覆盖equals时总要覆盖hashcode

如果不这样做的话,就会违反hashCode的通用约定:

  • 只要对象的equals方法比较所用的信息没有被修改,那么多次调用同一个对象的hashCode方法都必须始终返回同一个值
  • 如果两个对象根据equals方法比较是相等的,那么它们的hashCode方法都必须产生相同的整数值
  • 如果两个对象根据equals比较是不等的,那么它们的hashCode不一定产生不同的结果

因为没有覆盖hashCode会违反第二条:相等的对象必须具有相等的hashCode。

一个好的散列函数通常倾向于为不等的对象产生不等的散列码(hashCode)。下面给出一种简单的解决方法:

  1. 声明一个int变量并命名为result,将它初始化为对象中第一个关键域的散列码c,如步骤2.a中计算所示
  2. 对象中剩下的每一个关键域f都完成以下步骤:
    a. 为该域计算int类型的散列码c
    * 如果该域是基本类型,则计算Type.hashCode(f)Type是装箱类型
    * 如果该域是一个对象引用,且该类的equals方法通过递归的调用equals的方式来比较这个域,则同样的为该域递归地调用hashCode。如果该域的值为null,则返回0
    * 如果该域是一个数组,则要把数组中的每个元素当做单独的域来处理。如果数组中域中的所有元素都很重要,可以使用Arrays.hashCode方法
    b. 安装下面的公式,把步骤2.a中计算得到的散列码c合并到result中: result = 31 * result + c
  3. 返回result

现在把上述解决办法用到PhoneNumber类中:

@Override
public int hashCode() {
    int result = Short.hashCode(areaCode);
    result = 31 * result + Short.hashCode(prefix);
    result = 31 * result + Short.hashCode(lineNum);
    return result;
}

Objects类有一个静态方法hash,它带有任意数量的对象,并为它们返回一个散列码。与上面编写出来的对比,质量是相当的。但是运行速度更慢一些,因为它们会引发数组的创建,以便传入数量可变的参数,还可能会装箱和拆箱。建议该静态方法用于不太注重性能的情况:

@Override
public int hashCode() {
    return Objects.hash(lineNum,prefix,areaCode);
}

如果一个类是不可变的,并且计算散列码的开销较大。建议将散列码缓存在对象内部,如String

不要试图从散列码计算中排除一个对象的关键域来提高性能。

始终要覆盖toString

提供好的toString实现可以使类用起来更加舒适,使用了这个类的系统也更易于测试。
在实际应用中,toString方法应该返回对象中包含所有值得关注的信息,如果读写中包含的状态信息难以用字符串来表达,此时应该返回一个摘要信息。

下面是PhoneNumber类的toString方法:

@Override
public String toString() {
    return String.format("%03d-%03d-%04d",areaCode,prefix,lineNum);
}

toString返回值中包含的所有信息提供一种可以通过编程访问的途径。例如上面的例子中,PhoneNumber类应该提供对areCodeprefixlineNumber的访问方法。

谨慎地覆盖clone

Cloneable接口的目的是作为对象的一个mixin接口,表明这样的对象允许克隆。
如果一个类实现了CloneableObjectclone方法就返回该对象的逐域拷贝,否则就会抛出CloneNotSupportedException

虽然规范中没有明确指出,事实上,实现Cloneable接口的类是为了提供一个功能适当的公有clone方法。由此得到一种语言之外的机制:它无须调用构造器就可以创建对象。

clone方法的通用约定是非常弱的,下面来自Object规范中的约定内容:
创建和返回该对象的一个拷贝,这个拷贝的精确含义取决于该对象的类。一个版的含义是,对于任何对象x,表达式
x.clone() != x 会返回true,并且表达式x.clone().getClass() == x.getClass()将会返回true,但这些都不是绝对的要求。
虽然在通常情况下x.clone().equals(x)将会返回true,但是,这也不是一个绝对的要求。
按照约定,这个方法返回的对象应该通过调用super.clone获得。如果类及其超类遵守这一约定,那么:
x.clone().getClass() == x.getClass()

假设你希望在一个类中实现Cloneable接口,并且它的超类都提供了行为良好的clone方法。
首先调用super.clone方法。由此得到的对象将是原始对象功能完整的克隆。在这个类中声明的域将等同于被克隆对象中相应的域。如果每个域包含一个基本类型的值(不可变的),或包含一个指向不可变对象的引用,那么被返回的对象可能正是你所需要的对象,在这种情况下不要再做进一步处理。

例如PhoneNumber类正是如此,但要注意,不可变的类永远都不要提供clone方法

public class PhoneNumber implements Cloneable{
    ...
    @Override
    protected PhoneNumber clone() {
        try {
            return (PhoneNumber) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();//不会发生
        }
    }}

首先要实现Cloneable接口。虽然Objectclone方法返回的是Object,但这个clone方法返回的却是PhoneNumber。这么做是合法的,也是我们所期望的,因为Java支持协变返回类型。换句话说,目前覆盖方法的返回类型可以是被覆盖方法的返回类型的子类。对super.clone方法的调用应当包含在一个try-catch块中。由于PhoneNumber实现了Cloneable接口,我们知道调用super.clone方法一定会成功,因此不会抛出CloneNotSupportedException

如果对象中包含的域引用了可变的对象,使用上述这种简单的clone实现可能会导致灾难性的后果。例如Stack类:

class Stack {
    private Object[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    public Stack() {
        elements = new Object[DEFAULT_INITIAL_CAPACITY];
    }

    public void push(Object e) {
        ensureCapacity();
        elements[size++] = e;
    }

    public Object pop() {
        if (size == 0) {
            throw new EmptyStackException();
        }
        Object result = elements[--size];
        elements[size] = null;
        return result;
    }

    private void ensureCapacity() {
        if (elements.length == size) {
            elements = Arrays.copyOf(elements, 2 * size + 1);
        }
    }
}

假设希望把这个类做成可克隆的。如果它的clone方法仅仅返回super.clone(),这样得到的Stack实例,它的elements域将引用与原始Stack实例相同的数组。

如果调用Stack类中唯一的构造器,这种情况就永远不会发生。实际上,clone方法就是另一个构造器;必须确保它不会伤害到原始的对象,并确保正确地创建被克隆对象中的约束条件。
必须要拷贝栈的内部信息,最容易的做法是在elements数组中递归地调用clone:

@Override
protected Stack clone() {
    try {
        Stack result = (Stack) super.clone();
        result.elements = elements.clone();
        return result;
    } catch (CloneNotSupportedException e) {
        throw new AssertionError();
    }
}

递归地调用clone有时还不够。例如,假如你正在为一个散列表编写clone方法,它的内部数据包含一个散列桶数组,每个散列桶都指向“键-值”对链表的第一项:

public class HashTable implemnts Cloneable {
    private Entry[] buckets = ...;
    private static class Entry {
        final Object key;
        Object value;
        Entry next;
    }
    ...
}

假设你仅仅递归地克隆这个散列桶数组:

@Override
public HashTable clone() {
    try {
        HashTable result = (HashTable) super.clone();
        result.buckets = buckets.clone();
        return result;
    } catch (CloneNotSupportedException e) {
        throw new AssertionError();
    }
}

虽然被克隆对象有它自己的散列桶数组,但是,这个数组引用的链表与原始对象是一样的,从而很容易引起克隆对象和原始对象中不确定的行为。

public class HashTable implemnts Cloneable {
    private Entry[] buckets = ...;
    private static class Entry {
        final Object key;
        Object value;
        Entry next;
        
        Entry(Object key,Object value,Entry next) {
            this.key = key;
            this.value = value;
            this.next = next;
        }

        Entry deepCopy() {
            return new Entry(key,value,next == null ? null: next.deepCopy());
        }
    }
    
    @Override
    public HashTable clone() {
        try {
            HashTable result = (HashTable) super.clone();
            result.buckets = new Entry[buckets.length];
            for(int i = 0;i < buckets.length; i++) {
                if(buckets[i] != null) {
                    result.buckets[i] = buckets[i].deepCopy();
                }
            }
            return result;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
    ...
}

虽然这种方法很灵活,但是这样克隆一个链表并不是一种好方法,因为针对列表中的每个元素,它都要消耗一段栈空间。如果链表较长,容易导致栈溢出,可以用迭代来代替递归:

Entry deepCopy() {
    Entry result = new Entry(key,value,next);
    for (Entry p = result; p.next != null; p = p.next) {
        p.next = new Entry(p.next.key,p.next.value,p.next.next);
    }
    return result;
}

像构造器一样,clone方法也不应该在构造的过程中,调用可以覆盖的方法。

对象拷贝的更好的办法是提供一个拷贝构造器或拷贝工厂。拷贝构造器只是一构造器,它唯一的参数类型是包含该构造器的类,例如:

public Yum(Yum yum) {
    ...
}

拷贝工厂是类似于拷贝构造器的静态工厂:

public static Yum newInstance(Yum yum) {
    ...
}

既然所有的问题都与Cloneable接口有关,新的接口就不应该继承这个接口,新的课扩展的类也不应该实现这个接口。总是,克隆功能最好由构造器或工厂提供。这条规则最绝对的列外是数组,最好利用clone方法复制数组。

考虑实现Comparable接口

一旦实现了Comparable接口,它就可以跟许多泛型算法以及依赖于该接口的集合实现进行协作。

compareTo方法的通用约定于equals方法的约定相似:
在下面的说明中,符号sgn表示数学中的signum函数,它根据表达式的值为负数、零和正值,分别返回-1,0或1。

  • 实现者必须确保所有的xy都满足sgn(x.compareTo(y)) = -sgn(y.compareTo(x))
  • 还必须确保这个比较关系是可传递的,x.compareTo(y) > 0 && y.compareTo(z) > 0意味着x.compareTo(z) > 0
  • 最后,还须确保x.compareTo(y) == 0 意味着所有的z都满足sgn(x.compareTo(z)) == sgn(y.compareTo(z))
  • 强烈建议(x.compareTo(y) == 0 ) == (x.equals(y)),但这并非绝对必要

如果一个类有多个关键域,那么,按什么样的顺序来比较这些域是非常关键的。必须从最关键的域开始,逐步进行到所有的重要域。如果某个域产生了非零的结果,则整个比较操作结束,并返回结果。

在Java8中,Comparable接口配置了一组比较器构造方法,使得比较器的构造工作变得非常流畅。之后,按照Comparable接口的要求,这些比较器可以用来实现一个compareTo方法。下面是使用了这个方法之后的PhoneNumbercompareTo方法:

private static final Comparator<PhoneNumber> COMPARATOR = Comparator.comparingInt((PhoneNumber pn) -> pn.areaCode)
        .thenComparingInt(pn -> pn.prefix)
        .thenComparingInt(pn -> pn.lineNum);

public int compareTo(PhoneNumber pn) {
    return COMPARATOR.compare(this,pn);
}

compareTocompare方法偶尔也会依赖于两个值之间的区别,即如果第一个值小于第二个值,则为负;如果两个值相等,则为零;如果第一个值大于第二个值,则为正:

static Comparator<Object> hashCodeOrder = new Comparator<>() {
    public int compare(Object o1, Object o2) {
        return o1.hashCode() - o2.hashCode();
    }
}

千万不要使用这个方法,它很容易造成整数溢出。要么使用静态方法compare:

static Comparator<Object> hashCodeOrder = new Comparator<>() {
    public int compare(Object o1, Object o2) {
        return Integer.compare(o1.hashCode(),o2.hashCode());
    }
}

要么使用一个比较器构造方法:

static Comparator<Object> hashCodeOrder = Comparator.comparingInt(o -> o.hashCode());
发布了131 篇原创文章 · 获赞 38 · 访问量 12万+

猜你喜欢

转载自blog.csdn.net/yjw123456/article/details/90813695
今日推荐