快速带你看完《Effective Java》—— 对象通用方法篇

简介: 10 覆盖equals时要遵守通用约定11 覆盖equals时总要覆盖hashCode12 始终要覆盖toString13 谨慎地覆盖clone14 考虑实现Comparable接口

我个人在Java领域也已经学习了近5年,在修炼“内功”的方面也通过各种途径接触到了一些编程规约,例如阿里巴巴的泰山版规约,在此基础下读这本书的时候仍是让我受到了很大的冲激,学习到了很多约定背后的细节问题,还有一些让我欣赏此书的点是,书中对于编程规约的解释让我感到十分受用,并愿意将他们应用在我的工作中,也提醒了我要把阅读JDK源码的任务提上日程。


最后想分享一下我个人目前的看法,内功修炼不像学习一个新的工具那么简单,其主旨在于踏实,深入探索底层原理的过程很缓慢并且是艰辛的,但一旦开悟,修为一定会突破瓶颈,达到更高的境界,这远远不是我通过一两篇博客就能学到的东西。


接下来就针对此书列举一下我的收获与思考。


不过还是要吐槽一下的是翻译版属实让人一言难尽,有些地方会有误导的效果,你比如java语言里extends是继承的关键字,书本中全部翻译成了扩展 就完全不是原来的意思了。所以建议有问题的地方对照英文原版进行语义上的理解。


没有时间读原作的同学可以参考我这篇文章。


10 覆盖equals时要遵守通用约定


覆盖equals方法要注意的点特别多,并且很容易出错,下面四种情况最好不覆盖equals:


类的每个实例本质上都是唯一的

例如:Thread这种代表活动实体而不是值的类


类没有必要提供“逻辑相等”的测试功能

例如:正则表达式类Pattern就没有必要设计检查两个Pattern实例是否代表同一个正则表达式,因为没意义


父类已经覆盖了equals,如果功能合适的话,子类能不覆盖就不要覆盖

例如:List从AbstractList继承equals


类是private或default的

这样以来可以确定它的equals方法不会被调用


需要覆盖equals方法的情况:


类有自己的“逻辑相等”的概念,比如假设两个Student类如果name相同则认为是同一个实例的话,就需要覆盖equals方法

枚举类型也不需要覆盖equals方法,因为每个值至多只有一个对象。


接下来的讨论将重点围绕在等价关系的5个属性上:

1. 自反性:若x != null,则x.equals(x)=true



2. 对称性:若x != null,y != null,当y.equals(x)=true时,x.equals(y)=true

编写自定义类和Java标准类库中比较相等逻辑时,可能会无意间违反这一条规则



public final class CaseInsensitiveString {
    private final String s;
    public CaseInsensitiveString(String s){
        this.s = s;
    }
    @Override
    public boolean equals(Object o) {
        if (o instanceof CaseInsensitiveString)
            return s.equalsIgnoreCase(((CaseInsensitiveString) o).s);
        if (o instanceof String)
            return s.equalsIgnoreCase((String) o);
        return false;
    }
}

上面代码实现了一个不区分大小写的字符串,接下来使用下面两个对象进行比较


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

为了解决这个问题,可以将和String互操作的代码删掉:


@Override
    public boolean equals(Object o) {
        return o instanceof CaseInsensitiveString && (((CaseInsensitiveString) o).s.equalsIgnoreCase(s));
    }

3. 传递性:若x != null,y != null,z != null,当x.equals(y)=true,y.equals(z)=true时,x.equals(z)=true

当子类在父类的基础上添加属性时会无意中出现这个问题:


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 o) {
        if (!(o instanceof Point))
            return false;
        Point p = (Point) o;
        return p.x == x && p.y == y;
    }
}
public class ColorPoint extends Point{
    private final Color color;
    public ColorPoint(int x, int y, Color color){
        super(x, y);
        this.color = color;
    }
  @Override
    public boolean equals(Object o) {
        if (!(o instanceof Point))
            return false;
        if (!(o instanceof ColorPoint))
            return o.equals(this);
        return super.equals(o) && ((ColorPoint) o).color == color;
    }
}


上面的代码子类为父类扩展了一个颜色属性,子类的equals方法保证了对称性,但却牺牲了传递性:


ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p1 = new ColorPoint(1, 2, Color.BLUE);

上面的三个实例中,p1.equals(p2)和p2.equals(p3)都是true,但p1.equals(p3)返回的却是false。这里就引出了面向对象语言中关于等价关系的一个基本问题:无法在继承可实例化类的同时,即增加新的类属性,同时又保留equals约定。好在是有一个其他的方案,即遵循复合优先于继承的原则:


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 o) {
        if (!(o instanceof ColorPoint))
            return false;
        ColorPoint cp = (ColorPoint) o;
        return cp.point.equals(point) && cp.color.equals(color);
    }
}


此时ColorPoint就不继承Point了,而是在类的定义里添加一个Point属性,并提供getter方法


此时由于没有了继承关系,所以Point instanceof ColorPoint和ColorPoint instanceof Point结果都是false,所以在前面对称性和传递性的例子里都返回false,相应的也满足了这两条性质


Java标准类库里甚至也有违反对称性的例子,java.sql.Timstamp对java.util.Date扩展了一个属性,所以也违反了对称性,Timstamp和Date混用时会出现错误,当然这一点也被作者狠狠吐槽,我们不要模仿 ~~


如果类是abstract的,我们可以在其子类李加新属性且不违反equals约定

只要不能创建超类的实例,前面的问题就没有。

4. 一致性:若x != null,y != null,只要对象没有被修改,多次执行x.equals(y)都是相同的结果

equals方法禁止依赖于不可靠的资源,否则就会违反一致性。


例如:java.net.URL的equals方法依赖于对URL中主机IP地址的比较。将一个主机名转变成IP地址可能会有不同的结果,这也是一个大错误不值得提倡!


5. 非空性:若x != null,x.equals(null)=false

为了避免抛出空指针异常,通常会有这样的写法:


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

但实际上更好地写法是:


@Override
public boolean equals(Object o) {
    if (!(o instanceof Type))
            return false;
  Type t = (Type) o;
    ......
}


这样既可以类型检查,也可以保证传入null时返回false


综上所述,实现一个高质量的equals分以下四步即可:


用==检查入参是否是本对象的引用

用instanceof检查参数类型

将参数转换成正确的类型

对于类中的每个属性,检查入参的每个属性和自身是否匹配

不比较不属于对象逻辑状态的属性,比如保证同步的Lock(这种情况比较少)

有些对象的引用属性中包含null可能是合法的,为了避免出现空指针,建议使用Objects.equals(Object, Object)来检查是否相同


一个标准的equals示例:


public class PhoneNumber {
    private final int areaCode, prefix, lineNum;
    public PhoneNumber(int areaCode, int prefix, int lineNum){
        this.areaCode = areaCode;
        this.prefix = prefix;
        this.lineNum = lineNum;
    }
    @Override
    public boolean equals(Object o) {
        if (o == this)
            return true;
        if (!(o instanceof PhoneNumber))
            return false;
        PhoneNumber pn = (PhoneNumber) o;
        return pn.lineNum == lineNum && pn.prefix == prefix && pn.areaCode == areaCode;
    }
}


代替手工编写和测试equals和hashCode方法的最佳途径是使用Google开源的AutoValue框架。它可以通过注解自动生成这两个方法。


注意:


覆盖equals时还要覆盖hashCode

equals的逻辑不要太复杂

equals的入参类型只能是Object


11 覆盖equals时总要覆盖hashCode


这是一个老生常谈的话题,没有覆盖hashCode违反的是相等的对象必须具有相等的hash code这一约定。


例如下面的代码:


Map<PhoneNumber, String> m = new HashMap<>();
m.put(new PhoneNumber(707, 867, 5309), "Jenny");


如果没有覆盖hashCode方法,m.get(new PhoneNumber(707, 867, 5309))会返回null,这里涉及到两个PhoneNumber对象,因为在put和get操作里都使用new关键字,因为没有覆盖hashCode方法,所以这两个实例有不同的hash code,所以返回结果是null


即使这两个实例正好被放到同一个桶中,get方法返回的也是null,因为HashMap会将每一项的hash code缓存起来,如果code不匹配则不会去检查对象的等同性


下面给出一种简单且有效的hashCode计算方法:


初始化int result = c,其中c是对象中第一个关键属性的散列码

对于剩下的每一个属性f,循环进行以下步骤:

a. 为该属性计算散列码c

 ① 如果是基本数据类型,直接计算hashCode(f)

 ② 如果是引用,则针对这个属性递归调用hashCode

 ③ 如果是数组,对其中的元素单独求hash code

b. 按照如下公式将上面算出来的c合并的result里

最终的result就是求出的hash code


result = 31 * result + c;

31有一个很好的特性:31 * i == (i << 5) - i,使用移位和减法可以代替乘法,在硬件层面上执行速度更快


上面给出的hashCode实现方法可以满足日常需要,如果执意不想让散列函数有冲突,可以使用Guava的com.google.common.hash.Hashing


如果是不太注重性能的情况,可以在上面定义的PhoneNumber类里使用下面这种简单的实现方式:


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

如果计算hash code的开销较大的话,可以考虑使用缓存 + 延迟加载技术来提高性能:


private int hashCode;
@Override
public int hashCode(){
  if(this.hashCode == 0){
    // 计算散列码的逻辑
    this.hashCode = XXX;
  }
  return this.hashCode;
}


12 始终要覆盖toString


当对象传递给println、printf、字符串联接操作符+,assert或者被调试器打印出来时,toString方法会被自动调用。


所以提供好的toString实现可以使类用起来更舒服,也更容易调试。


例如有一个电话号码类PhoneNumber,没有覆盖和覆盖了toString方法分别会输出:


PhoneNumber@163b91
18373xxxxxx


我们肯定希望看到后面一种输出


对此,阿里巴巴开发规约里面也专门做了要求:


33.png


在实现toString时,还要决定是否要返回指定的格式,比如对于一般的类而言,使用json格式输出就很直观;但是对于一些特殊的类,比如电话号码类,它是有自己固定的格式的


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


注意点:


无论是否指定格式,toString中返回的所有属性都应该有一个getter方法

如果很多子类都是同一个字符串表示法,在其抽象类里一定要写一个toString

比如大多数集合实现的toString都是继承自抽象集合类


13 谨慎地覆盖clone


这里主要讨论何时,以及如何实现一个较好的clone方法。


Java的Cloneable接口没有任何方法:如果一个类实现了Cloneable,Object的clone方法就返回该对象的拷贝,否则会抛CloneNotSupportedException异常。


这是接口一种不常用的用法,依据子类是否实现这个接口来决定返回什么。不值得提倡!


我们实现Cloneable接口的目的就是为了提供一个public的clone方法(clone方法来自于Object类,

实现了这个接口也意味着要提供一个合适的clone方法),此外,不可变的类不应该提供clone方法(鼓励尽可能复现现有实例)。


由于类里面定义的属性可能是变的或不变的,所以还要分情况讨论:

1. 如果每个属性都是基本数据类型或指向一个不可变对象的引用,这种情况不需要特殊处理。

例如之前的电话号码类PhoneNumber:


@Override
public PhoneNumber clone(){
    try {
        return (PhoneNumber) super.clone();
    } catch (CloneNotSupportedException e){
        throw new AssertionError();
    }
}


2. 如果对象中的域引用了可变对象,用上面简单的clone会产生问题。

如果要对一个栈做克隆操作:


package com.wjw;
import java.util.Arrays;
import java.util.EmptyStackException;
/**
 * 2 * @Author: 小王同学
 * 3 * @Date: 2021/11/23 20:50
 * 4
 */
public 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);
    }
}


如果仅仅返回super.clone(),这样得到的新Stack实例的elements属性与原来的Stack指向的其实是同一个数组,这两个对象任意一个修改了elements都会影响另外一个。解决方案是使用深拷贝技术,递归地拷贝栈的内部信息:


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


如果elements被final修饰,上面的代码会报错,clone方法禁止给final域赋新值。


递归调用clone了可能还不够,比如HashTable这种引用套引用再套引用的数据结构:


package com.wjw;
public class HashTable implements Cloneable{
    private Entry[] buckets = new Entry[10];
    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;
        }
    }
}

如果向上面clone栈一样,仅仅递归地克隆buckets


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


这样一来,这个数组引用的链表与原始对象又是一样的了,继续出现上面的问题。


34.png


为了解决这个问题,必须单独拷贝buckets每个位置的链表:


package com.wjw;
public class HashTable implements Cloneable{
    private Entry[] buckets = new Entry[10];
    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();
        }
    }
}


这样一来,HashTable的clone方法重新new了一个新的buckets数组,并且遍历原先的buckets,对其中的非空位置进行深度的拷贝。


35.png


需要注意的点:

1. public的clone方法应该省略throws声明

Object的clone方法声明时抛了一个异常


36.png


我们覆盖clone之后可以忽略这个声明(继承后,不能抛出比父类更多的异常)


2. 如果是线程安全的类要实现Cloneable接口,它的clone方法必须保证严格的同步。

Object的clone方法没有同步,所以必须实现synchronized clone()方法来调用super.clone()


3. 对象拷贝的更好方法是实现一个 拷贝构造器 或 拷贝工厂


public Yum(Yum yum){
  this.属性 = yum.属性;
}

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


这样做有很多优势:

a. 不依赖于语言之外的对象创建机制;

b. 不会与final域发生冲突;

c. 不会抛不必要的异常;

d. 不需要进行类型转换。


假设希望将一个HashSet对象s拷贝成TreeSet对象,clone方法无法完成,但用构造器就容易实现:new TreeSet<>(s)



综上所述:除了数组最好用clone方法复制之外,其余的类不要实现Cloneable接口

14 考虑实现Comparable接口


这一条主要说明实现了Comparable接口后可以获得非常强大的功能。



比如下面一段代码就实现了args数组去重并打印的功能:


public class WordList {
  Set<String> s = new TreeSet<>();
  Collections.addAll(s, args);
  System.out.println(s);
}


可以看到String类实现了Comparable接口后上面的代码量其实非常少。

如果一个类具有按照字母、数字、年代等排序的需求,就强烈建议实现这个接口


compareTo方法的约束与之前的equals方法的约束类似,这里需要借助数学里面的sgn函数:

37.png


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)相同


compareTo和equals的限制也类似,子类继承父类并扩展了新的属性时,同时保持compareTo约定,所以上面equals章节里的解决方案同时也适用于这里。



最后一条是一个强烈的建议,例如Java里面有一个BigDecimal类,它的compareTo与equals方法不一致。如果创建两个对象new BigDecimal("1.0")和new BigDecimal("1.00"),并添加到HashSet里,这个集合里面将会有两个元素,因为这两个对象通过equals方法比较结果是不同的。但将他们添加到TreeSet里,这个集合里将只包含一个元素,这又是因为这两个BigDecimal实例在通过compareTo方法进行比较时是相等的。



需要注意的是,不要在compareTo方法中使用操作符<,>


下面介绍Comparator接口配置比较器构造方法,这样做会使得构造工作变得流畅,还是以手机号码的比较为例:


private static final Comparator<PhoneNumber> COMPARATOR =
            comparingInt((PhoneNumber pn) -> pn.areaCode)
            .thenComparingInt(pn -> pn.prefix)
            .thenComparingInt(pn -> pn.lineNum);
    public int compareTo(PhoneNumber pn) {
        return COMPARATOR.compare(this, pn);
    }


comparingInt返回一个按areaCode对手机号进行排序的Comaprator<PhoneNumber>,如果两个手机号的areaCode相同,需要进行prefix和lineNum的比较。


如果要比较两个hashCode值的话,千万不要使用减法运算符:


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


上面的代码段很容易造成整数溢出,解决方案也很简单,就是使用现成的API:


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


static Comparator<Object> hashCodeOrder = Comparator.comparingInt(o -> o.hashCode());
相关文章
|
17天前
|
安全 Java 编译器
Java对象一定分配在堆上吗?
本文探讨了Java对象的内存分配问题,重点介绍了JVM的逃逸分析技术及其优化策略。逃逸分析能判断对象是否会在作用域外被访问,从而决定对象是否需要分配到堆上。文章详细讲解了栈上分配、标量替换和同步消除三种优化策略,并通过示例代码说明了这些技术的应用场景。
Java对象一定分配在堆上吗?
|
10天前
|
存储 Java 程序员
Java基础的灵魂——Object类方法详解(社招面试不踩坑)
本文介绍了Java中`Object`类的几个重要方法,包括`toString`、`equals`、`hashCode`、`finalize`、`clone`、`getClass`、`notify`和`wait`。这些方法是面试中的常考点,掌握它们有助于理解Java对象的行为和实现多线程编程。作者通过具体示例和应用场景,详细解析了每个方法的作用和重写技巧,帮助读者更好地应对面试和技术开发。
50 4
|
21天前
|
Java API
Java 对象释放与 finalize 方法
关于 Java 对象释放的疑惑解答,以及 finalize 方法的相关知识。
42 17
|
14天前
|
Java 测试技术 Maven
Java一分钟之-PowerMock:静态方法与私有方法测试
通过本文的详细介绍,您可以使用PowerMock轻松地测试Java代码中的静态方法和私有方法。PowerMock通过扩展Mockito,提供了强大的功能,帮助开发者在复杂的测试场景中保持高效和准确的单元测试。希望本文对您的Java单元测试有所帮助。
27 2
|
20天前
|
存储 安全 Java
Java编程中的对象序列化与反序列化
【10月更文挑战第22天】在Java的世界里,对象序列化和反序列化是数据持久化和网络传输的关键技术。本文将带你了解如何在Java中实现对象的序列化与反序列化,并探讨其背后的原理。通过实际代码示例,我们将一步步展示如何将复杂数据结构转换为字节流,以及如何将这些字节流还原为Java对象。文章还将讨论在使用序列化时应注意的安全性问题,以确保你的应用程序既高效又安全。
|
22天前
|
Java 开发者
在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口
【10月更文挑战第20天】在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口。本文揭示了这两种方式的微妙差异和潜在陷阱,帮助你更好地理解和选择适合项目需求的线程创建方式。
16 3
|
22天前
|
Java 开发者
在Java多线程编程中,选择合适的线程创建方法至关重要
【10月更文挑战第20天】在Java多线程编程中,选择合适的线程创建方法至关重要。本文通过案例分析,探讨了继承Thread类和实现Runnable接口两种方法的优缺点及适用场景,帮助开发者做出明智的选择。
15 2
|
22天前
|
安全 Java
Java多线程通信新解:本文通过生产者-消费者模型案例,深入解析wait()、notify()、notifyAll()方法的实用技巧
【10月更文挑战第20天】Java多线程通信新解:本文通过生产者-消费者模型案例,深入解析wait()、notify()、notifyAll()方法的实用技巧,包括避免在循环外调用wait()、优先使用notifyAll()、确保线程安全及处理InterruptedException等,帮助读者更好地掌握这些方法的应用。
15 1
|
22天前
|
Java 开发者
Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点
【10月更文挑战第20天】Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点,重点解析为何实现Runnable接口更具灵活性、资源共享及易于管理的优势。
28 1
|
22天前
|
Java
在Java多线程编程中,`wait()`和`notify()`方法的相遇如同一场奇妙的邂逅
在Java多线程编程中,`wait()`和`notify()`方法的相遇如同一场奇妙的邂逅。它们用于线程间通信,使线程能够协作完成任务。通过这些方法,生产者和消费者线程可以高效地管理共享资源,确保程序的有序运行。正确使用这些方法需要遵循同步规则,避免虚假唤醒等问题。示例代码展示了如何在生产者-消费者模型中使用`wait()`和`notify()`。
23 1