effective java

ch2 创建和销毁对象

考虑使用静态工厂方法代替构造器

1、静态工厂方法相比构造方法来说,可以指定名称;

构造函数的名称必须和类名一致,丧失了灵活性。如果有构造方法重载,又存在多个构造参数的时候,这个问题更明显。

// 存在多个构造参数,不易读
Date date0 = new Date();
Date date1 = new Date(0L);
Date date2 = new Date("0");
Date date3 = new Date(1, 2, 1);
Date date4 = new Date(1, 2, 1, 1, 1);
Date date5 = new Date(1, 2, 1, 1, 1, 1);

代码引用:关于 Java 的静态工厂方法,看这一篇就够了!

2、静态工厂方法可以控制单例

如果只需要对外提供一个实例,无需关心是否是新实例,可以使用静态工厂控制单例。

public class SingletonItem {

    private int value;

    private static SingletonItem singletonItem = null;

    private SingletonItem() {
        this.value = new Random().nextInt(100);
    }

    public static SingletonItem getInstance() {
        if (singletonItem != null) {
            return singletonItem;
        }
        singletonItem = new SingletonItem();
        return singletonItem;
    }

    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }
}

3、可以返回原返回类型的子类型

构造方法只能返回确定的类型,静态工厂方法可以更加灵活的返回其子类型。

// 使用静态工厂方法返回子类型
public static Item newSubItem(float square) {
  SubItem subItem = new SubItem(square);
  subItem.setName("test");
  subItem.setWidth(0);
  subItem.setLength(0);
  return new SubItem(square);
}

遇到多个构造器参数时要考虑用构建器

静态工厂方法和构造方法都有一个缺点:不能很好的扩展到大量可选参数。有时候可能一个类涉及到的可选构造参数比较多,针对每种情况编写一个构造器,工作量非常大而且代码很难控制,调用也需要传许多原本不需要的可选字段。这时候使用构建器就能很好的解决这个问题,因为构造器是通过方法实现的,因此还可以对传入的构造参数进行校验,如果传入的参数不满足约束条件,可以抛出 IllegalState Exception 异常,显示该参数违反了哪个约束条件。

在内部类 Builder 中区分必须字段(final)和可选字段,并对其进行赋值,最后在 build 方法中返回需要的对象。

public class Product {

    private final String name;

    private final String desc;

    private final float price1;

    private final float price2;

    public static class Builder {

        // 必须字段
        private final String name;

        // 可选字段
        private String desc;

        private float price1;

        private float price2;

        public Builder(String name) {
            if (name == null || name.length() < 5 || name.length() > 8) {
                throw new IllegalArgumentException("产品名称只能由 5 至 8 个字符组成");
            }
            this.name = name;
        }

        public Builder desc(String desc) {
            this.desc = desc;
            return this;
        }

        public Builder price1(float price) {
            this.price1 = price;
            return this;
        }

        public Builder price2(float price) {
            this.price2 = price;
            return this;
        }

        public Product build() {
            return new Product(this);
        }
    }

    public Product(Builder builder) {
        if ((builder.price1 + builder.price2) < 5) {
            throw new IllegalStateException("价格 1 与价格 2 总和不能小于 5");
        }
        this.name = builder.name;
        this.desc = builder.desc;
        this.price1 = builder.price1;
        this.price2 = builder.price2;
    }
}

Product product = new Product.Builder("product1")
                .desc("desc1")
                .price1(1.01F)
                .price2(2.01F)
                .build();

使用构建器需要注意的点:

  • 为了创建对象必须先创建他的构建器,在某些注重性能的场景,要十分谨慎;
  • 确实存在很多参数时才使用,否则使用更常规的方式会更简洁;
  • 别等到字段扩展到很多时才使用,那时会导致旧的构造器和构建器混用,最好一开始就使用构建器。

用私有构造器和枚举强化 Singleton 类型

Singleton:仅仅被实例化一次的类。

1、使用私有构造创建 Singleton

public class EnhanceSingleton {

    public static final EnhanceSingleton INSTANCE = new EnhanceSingleton();

    private EnhanceSingleton() {
        System.out.println("new EnhanceSingleton");
    }

    public static void main(String[] args) {
        EnhanceSingleton instance1 = EnhanceSingleton.INSTANCE;
        EnhanceSingleton instance2 = EnhanceSingleton.INSTANCE;
        System.out.println(instance1 == instance2);
    }
}

// output
new EnhanceSingleton
true

2、使用静态工厂方法创建 Singleton

public class EnhanceSingleton {

    private static final EnhanceSingleton INSTANCE = new EnhanceSingleton();

    private EnhanceSingleton() {
        System.out.println("new EnhanceSingleton");
    }

    public static EnhanceSingleton getInstance() {
        return INSTANCE;
    }

    public static void main(String[] args) {
        EnhanceSingleton instance3 = EnhanceSingleton.getInstance();
        EnhanceSingleton instance4 = EnhanceSingleton.getInstance();
        System.out.println(instance3 == instance4);
    }
}

3、使用单个元素的枚举创建 Singleton

单个元素的枚举类型是实现 Singleton 最佳方法。

public enum TestEnum {
    INSTANCE;
}

通过私有构造方法强化不可实例化的能力

有时候编写一些只包含静态属性和静态方法的通用工具类或者常量类,这些类实例化没有意义,可以通过显示定义一个私有的构造方法覆盖默认构造方法阻止这些类被实例化。

public class TestUtil {

    // 通过私有构造方法强化不可实例化的能力
    private TestUtil() {
        throw new AssertionError("工具类不可实例化");
    }

    public static String filterCharA(String str) {
        if (str == null || "".equals(str)) {
            return str;
        }
        return str.replaceAll("a", "");
    }
}

避免创造不必要的对象

1、字符串

一般来说,最好能重用对象而不是在每次需要的时候创建一个具有相同功能的新对象。

如果对象是不可变的(Immutable),那么它始终可以被重用。

反例: String str = new String("hello world!");

该语句每次执行的时候都会创建一个新的 String 实例,参数 “hello world!” 本身就是一个 String 实例,功能方面等同于构造器创建的所有对象。如果在循环中被调用,就会创建大量不必要的实例。

正例: String str = "hello world!";

2、静态工厂方法优于构造器

对于同时提供静态工厂方法和构造器的不可变类,通常可以使用静态工厂方法而不是构造器,可以避免创建不必要的对象。

反例: Boolean flag = new Boolean("true");

正例: Boolean flag = Boolean.valueOf("true");

3、静态属性

已知不会被修改的对象也可以重用。

public class AvoidNewObj {

    private Date birthDate;

    public AvoidNewObj(Date birthDate) {
        this.birthDate = birthDate;
    }

    // 每次调用都会创建不必要的 Calenda, TimeZone 实例
    public boolean isBabyBoomer() {
        Calendar gmt = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
        gmt.set(1946, Calendar.JANUARY, 1, 0, 0, 0);
        Date startTime = gmt.getTime();
        gmt.set(1965, Calendar.JANUARY, 1, 0, 0, 0);
        Date endTime = gmt.getTime();
        return birthDate.after(startTime) && birthDate.before(endTime);
    }
}
public class AvoidNewObj1 {

    private static final Date startTime;

    private static final Date endTime;

    private Date birthDate;

    // 只需要创建一次 Calendar,TimeZone 实例
    static {
        Calendar gmt = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
        gmt.set(1946, Calendar.JANUARY, 1, 0, 0, 0);
        startTime = gmt.getTime();
        gmt.set(1965, Calendar.JANUARY, 1, 0, 0, 0);
        endTime = gmt.getTime();
    }

    public AvoidNewObj1(Date birthDate) {
        this.birthDate = birthDate;
    }

    public boolean isBabyBoomer() {
        return birthDate.after(startTime) && birthDate.after(endTime);
    }
}

在同一个机器上循环调用 一千万次 isBabyBoomer() 方法,优化钱耗时 3600 毫秒,优化后耗时 330 毫秒,相差十倍左右。

4、基本类型与包装类

优先使用基本类型而不是包装类,当心无意识的自动装箱。

// 计算所有 int 值的和
// 使用包装类耗时 6000 毫秒
public static void sum() {
    Long sum = 0L;
    for (int i = 0; i < Integer.MAX_VALUE; i++) {
      sum += i;
    }
}
// 计算所有 int 值的和
// 使用基本类型,耗时 630 毫秒
public static void sum() {
    long startTime = System.currentTimeMillis();
    long sum = 0L;
    for (int i = 0; i < Integer.MAX_VALUE; i++) {
      sum += i;
    }
    long endTime = System.currentTimeMillis();
    System.out.println("耗时(ms): ");
    System.out.println(endTime - startTime);
}

消除过期的对象引用

1、一个类自己管理内存容易导致内存泄漏

在支持垃圾回收的语言中,内存泄漏(无意识的对象保持)通常比较隐蔽。如果一个对象被无意识的保持下来,垃圾回收机制不仅不会处理这个对象,而且也不会处理这个对象所引用的其他对象。即便只有少量的对象被保持下来,也会导致许多对象被排除在垃圾回收之外,从而对性能造成影响。

这类问题的修复很简单:如果该引用已过期,手动清空该引用。

public Object pop() {
    if (size == 0) {
        throw new EmptyStackException();
    }
    Object result = elements[--size];
    // 引用过期时,手动清空该引用
    elements[size] = null;
    return result;
}

清空过期引用的另一个好处:如果以后又被错误的引用,程序会立即抛出异常,而不是悄悄的运行下去,可以提前检测出程序中的错误。

清空对象引用应该是一种例外,而不是一种规范,不需要过分小心。

2、缓存容易导致内粗泄漏

被放到缓存中的对象很容易被遗忘,从而导致对象过期相当长的一段时间,依然被保留在缓存中。

解决方案:

  1. 使用 WeakHashMap;
  2. 每次添加新条目的同时,对超期或者无用的引用进行清理;
  3. 交给后台线程(Timer 或者 ScheduledThreadPoolExecutor)完成。

ch3 对于所有对象都通用的方法

尽管 Object 类是一个具体的类,但是设计它主要是为了扩展,它的所有非 final 方法 (equals(), hashCode(), toString(), clone() 和 finalize()) 都有明确的通用约定,它们被设计成要被覆盖的。如果覆盖这些方法的时候不遵守通用约定,那么对于其他依赖这些约定的类结合该类就不能正常工作。

覆盖 equals 的时候请遵守通用约定

不需要覆盖 equals 方法的场景

equals() 方法看起来简单,但是许多覆盖方式会导致很多意想不到的严重错误,最容易避免这类问题的方法就是不覆盖该方法,在这种情况下,类的每个实例都只与它自身相等。

  1. 类的每个实例本质上都是唯一的;
  2. 不关心实例的逻辑相等;
  3. 超类已经覆盖了 equals 方法,从超类继承过来的行为对于子类也是适用的;
  4. equals 用于不会被调用。

如果类有自己想等的概念,并且父类没有实现相应的 equals 方法时,就需要进行覆盖。

如果实例是受控实例,如静态工厂方法控制的实例,最多只存在一个对象时,值相等与对象相等是同一个概念,这时不需要覆盖 equals 方法。

覆盖 equals 方法的通用约定:

  1. 自反性;
  2. 对称性;
  3. 传递性;
  4. 一致性;
  5. 对于任何非 null 引用值 x,x.equals(null) 必须返回 false。

如何覆盖 equals 方法

  1. 使用 == 检查参数是否为该对象的引用;

  2. 使用 instanceof 判断参数是否是正确的类型(包含了参数为 null 的判断);

  3. 将参数转换成正确的类型;

  4. 逐个检查对象的关键属性,如果全部通过,返回 true ,否则返回 false;

    对于既不是 float 又不是 double 的基本类型,可以使用 == 进行比较。对于引用属性,可以递归调用 equals。如果是 float 或者是 double 类型,可以使用 Double.compare() 和 Float.compare() 进行比较。

  5. 编写完 equals 方法之后,重新审视 对称性、传递性和一致性。

    最好编写单元测试进行检验。

覆盖 equals 方法的一些告诫

  1. 覆盖 equals 方法时总是要同时覆盖 hashCode() 防范;
  2. 不要企图让 equals 方法过于智能;
  3. 不要将 equals 方法中的参数声明外 Object 之外的类型。

覆盖 equals 方法时总是要覆盖 hashCode

一个很常见的错误在于没有覆盖 hashCode 方法。覆盖 equals 时一定要覆盖 hashCode,否则就会违反 Object.hashCode 的通用规则,从而导致该类无法结合所有基于散列的集合一起正常运作,包括 HashMap、HashSet、HashTable。

📃 hashCode 规范:

  • 在应用程序执行期间,只要对象的 equals 方法的比较操作所用到的信息没有被修改,那么对这个对象调用多次,hashCode 方法都必须始终返回同一个整数;
  • 如果两个对象根据 equals 方法是相等的,那么这两个对象 hashCode 也必须一致;
  • 如果两个对象根据 equals 方法是不相等的,则 hashCode 值不一定要相等;但是不相等的对象产生不同的 hashCode,可以提高散列表的性能。
public class PhoneNumber {

    private final short areaCode;

    private final short prefix;

    private final short lineNumber;

    public PhoneNumber(int areaCode, int prefix, int lineNumber) {
        this.areaCode = (short) areaCode;
        this.prefix = (short) prefix;
        this.lineNumber = (short) lineNumber;
    }

    // 覆盖 equals 方法但是未覆盖 hashCode 方法
    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof PhoneNumber)) {
            return false;
        }
        PhoneNumber that = (PhoneNumber) o;
        return areaCode == that.areaCode
                && prefix == that.prefix
                && lineNumber == that.lineNumber;
    }
}
Map<PhoneNumber, String> map = new HashMap<>();
PhoneNumber phoneNumber = new PhoneNumber(1, 2, 3);
map.put(phoneNumber, "tony");

// hashCode: 356573597
System.out.println("hashCode: " + phoneNumber.hashCode());

PhoneNumber phoneNumber1 = new PhoneNumber(1, 2, 3);
// hashCode: 1735600054
System.out.println("hashCode: " + phoneNumber1.hashCode());
// 获取值
String name = map.get(phoneNumber1);
// return false
// name: false
System.out.println("name: " + "tony".equals(name));

虽然覆盖了 equals 方法,但是未覆盖 hashCode,三个属性相同的两个对象 equals 返回相等,hashCode 却是不相等的,违反了 hashCode 约定的第二点。在 HashMap 中因为有不同的 hashCode,所以返回结果不是 “tony” 而是 null。

覆盖 hashCode 方法可以解决上述问题。而且散列函数对散列表的性能影响很大,因此理想的散列函数,应当能将集合中不相等的实例均匀的分布到所有的散列值上。

📝 编写散列函数的方法:

  1. 确定一个非零常数值(17),保存到 result 的 int 类型变量中;
  2. 对于对象中的每个域 f:
    1. 计算该域的散列值 c:
      1. boolean类型: f ? 1 : 0
      2. byte short int: (int) f
      3. long: (int) (f ^ (f >>> 32))
      4. float: Float.floatToIntBits(f)
      5. double: Double.doubleToLongBits(f)
      6. 对象引用: 递归调用 hashCode
      7. 数组: 对每个元素当作单独的域来处理
    2. 计算 result = 31 * result + c
  3. 返回 result
  4. 编写单元测试用例,测试 相等的实例拥有相等的散列码。

⚠️ 注意点:

  • 必须排除 equals 中未使用的域
  • 如果计算散列值开销较大,对于不变类,可以考虑缓存散列码;
  • 让 hashCode 返回一个确定的数值并不是明智的做法,会限制将来改进散列函数的能力。

始终要覆盖 toString

✅ 建议所有子类覆盖 toString 方法。

提供好的 toString 实现可以使类用起来更加舒适,对于调试有好处。toString 方法应该返回所有值得关注的域。

谨慎的覆盖 clone

Cloneable 接口的目的是作为对象的一个 mixin 接口,表明这样的对象允许拷贝。遗憾的是,它没有达到这个目的,因为它缺少一个 clone 方法。Object 类的 clone 方法是受保护的,如果不借助反射,就不能仅仅因为一个类实现了 Cloneable 接口,就可以调用 clone 方法,即使使用反射,也有可能失败,因为不能保证该对象具有可访问的 clone 方法。

// Object 类中受保护的 clone() 方法
protected native Object clone() throws CloneNotSupportedException;
// Cloneable 接口
public interface Cloneable {
}

既然 Cloneable 没有包含任何方法,那它的作用是什么呢?它决定了 Object 类中受保护的 clone 方法的实现的行为,如果一个类实现了 Cloneable 接口,Object 类中的 clone 方法就返回该对象逐域拷贝,否则直接抛出 java.lang.CloneNotSupportedException 异常

通常实现接口的目的,是为了表明类可以为它的客户做些什么。然而对于 Cloneable 接口,它改变了超类中受保护的方法的行为。

无需调用构造器就可以创建对象。

clone 方法的通用约定:

  • x.clone() != x 为 true
  • x.clone.getClass() == x.getClass 为true

但是这些都不是绝对的要求。

实现了 Cloneable 接口的类,都应该提供一个公有的 clone 方法,此方法内部先调用 super.clone() ,然后修正任何需要修正的域。一般情况下,这意味着要拷贝任何包含内部深层结构的可变对象,并用指向新对象的引用代替原来指向这些对象的引用。

@Override
public Company clone() throws CloneNotSupportedException {
  Company clone = (Company) super.clone();
  clone.numbers = numbers.clone();
  return clone;
}

另一个实现对象拷贝的好办法是提供一个拷贝构造器或拷贝工厂。拷贝构造器的唯一参数为该构造器的类,比如 new TreeSet<>() 。

public Company newInstance() {
    return new Company(name, numbers);
}

拷贝构造器及静态工厂方法相对于 clone 的好处:

  1. 不用遵守约定;
  2. 不会与 final 域冲突;
  3. 不会抛出不受检查的异常;
  4. 不需要进行类型转换。

建议:不应该扩展、实现 Cloneable 接口,也不应该覆盖、调用 clone 方法。

考虑实现 Comparable 接口

compareTo 方法是 Comparable 接口中唯一的方法,且是一个范型方法。一个类实现了 Comparable 接口,则表明它的实例具有内在的排序关系(大小、状态等)。而且,实现 Comparable 接口之后,它就可以和许多范型算法以及依赖于该接口实现的集合进行协作,付出很小的努力就可以获得强大的功能。Java 平台类库中所有值类都实现了这个接口。如果你正在编写一个值类,其具有明显的内在排序关系,比如按字母、数值、年代等排序,则应坚决考虑实现这个接口。

public interface Comparable<T> {
}

编写 compareTo 方法与编写 equals 方法不同之处:Comparable 接口是范型接口,而且是静态类型,因此不必进行类型检查,也不用进行类型转换。如果参数为 null,则会抛出 NullPointerException。

compareTo 方法是对于顺序的比较,比较对象引用域可以通过递归调用 compareTo 方法实现。比较整形基本类型数据,可以使用 < 或 > 来实现;比较浮点数可以使用 Double.compareTo 或 Float.compareTo 来实现;对于数组,则要对每个元素进行比较。

public class Task implements Comparable<Task> {

    public static List<String> TASK_STATUS_LIST = Arrays.asList("草稿", "待审批", "审批通过", "归档");

    private String name;

    private String taskStatus;

    public Task(String name, String taskStatus) {
        this.name = name;
        this.taskStatus = taskStatus;
    }

    @Override
    public String toString() {
        return "Task{" +
                "name='" + name + '\'' +
                ", taskStatus='" + taskStatus + '\'' +
                '}';
    }

    @Override
    public int compareTo(Task o) {
        int index = getStatusIndex(this);
        int index1 = getStatusIndex(o);
        return Integer.compare(index, index1);
    }

    private static int getStatusIndex(Task task) {
        for (int i = 0; i < TASK_STATUS_LIST.size(); i++) {
            if (TASK_STATUS_LIST.get(i).equals(task.getTaskStatus())) {
                return i;
            }
        }

        System.out.println("任务状态不合法");
        return 99;
    }
}

// data
Task task1 = new Task("task1", "草稿");
Task task2 = new Task("task2", "待审批");
Task task3 = new Task("task3", "审批通过");
Task task4 = new Task("task4", "归档");
Task task5 = new Task("task5", "其他");

// output
[task1 -> 草稿]

[task2 -> 待审批]

[task3 -> 审批通过]

[task4 -> 归档]

[task5 -> 其他]

因为 compareTo 方法没有指定返回值的大小,只是指定了符号,因此可以利用这一点简化对多个域进行比较时的代码。但是使用这种方法时,要特别考虑结果溢出的情况,溢出会导致 compareTo 返回错误的结果,并且这样的错误很难调试。

ch4 类和接口

使类和成员的可访问性最小化

信息隐藏可以使各系统各模块之间解耦,使得这些模块可以独立的开发、测试、优化和使用。

信息隐藏的规则:

  1. 尽可能的使每个类或成员不被外界访问;

  2. 实例域绝不能公有;如果域是非 final 的,或者指向可变对象的 final 引用,这个域一旦公有,将对它失去控制。静态域也不能公有,除非要通过 public static final 来暴露一些常量。通常用大写字母加上下划线来表示,很重要的一点是,这些域要么是包含基本类型的值,要么指向不可变对象的引用。如果 final 域包含可变对象的引用,它便具有非 final 域所有的缺点。虽然引用本身不能被修改,但是引用它引用的对象却是可以被修改的。

    长度非 0 的数组总是可变的。所以,具有 public static final 数组域,或者返回这种域的访问方法,几乎总是错误的!因为这种域可以被任意修改。

    public static String[] TASK_STATUS_LIST = {"草稿", "待审批", "审批通过", "归档"};
    
    // 修改长度非 0 的公有静态数组域
    Task.TASK_STATUS_LIST[0] = "修改后的内容";
    
    // 解决方案1
    // 将公有变为私有,并增加一个公有的不可变列表
    private static String[] TASK_STATUS_LIST = {"草稿", "待审批", "审批通过", "归档"};
    public static String[] getTaskStatusList() {
        return Collections.unmodifiableList(TASK_STATUS_LIST);
    }
    
    // 解决方案2
    // 将数组变为私有,并且添加一个公有方法,返回一个数组的备份
    public static String[] getTaskStatusList() {
        return TASK_STATUS_LIST.clone();
    }
    

总而言之:应当始终降低可访问性且防止将散乱的类、接口或成员变成 API 的一部分,除了公有静态 final 域特殊情况外,公有类不应包含公有域,并且要确保公有静态 final 引用的对象都是不可变的。

在公有类中使用公有方法而非公有域

有时候可能会编写一些退化类,这种类仅仅用来集中实例域。

public class Point {

    public double x;
    
    public double y;
}

由于这种类的数据域是直接可以访问的,这些类没有提供封装 API 的功能,如果不改变 API,就无法改变的它的数据表示法,也无法强加任何约束条件,当域被访问的时候,无法采取任何辅助措施。这种类应该用私有域和公有访问方法来代替,getter 和 setter 方法。

使可变形最小

不可变类只是其实例不可被修改的类。每个实例包含的所有信息必须在创建该实例的时候就提供,并在对象的整改生命周期内固定不变。Java 平台内的不可变类:String、基本类型的包装类、BigInteger 和 BigDecimal 类。存在许多不可变的类的理由:易于设计、实现和使用,而且更加安全。

为了使类成为不可变类,需要遵循 5 条规则:

  1. 不要提供任何会修改对象状态的方法;
  2. 保证类不会被扩展(一般使用 final );
  3. 所有域都是 final 的;
  4. 所有域都是私有的(防止被引用的可变对象);
  5. 确保任何可变组件的互斥访问(如果类具有指向可变对象的域,则必须确保该类的客户端无法获得指向这些对象的引用)。

复合优于继承

封装、继承、多态是面向对象的三个基本特征。继承运行在不改变原有类的情况下,对其进行扩展,是实现代码重用的强有力手段。

在包内使用继承是安全的,因为继承和实现处于同一个程序员的控制之下。但是对于普通类(不是专门为了继承而设计的类)进行进行跨包边界继承则是非常危险的。

继承打破了封装性。子类的实现依赖于基类的实现细节,如果基类的实现细节发生变更,则子类可能会完全遭到破坏,即使子类做任何更改。

一个继承 HashSet 类的例子,添加了一个域 addCount 记录添加元素的个数:

public class MyHashSet<E> extends HashSet<E> {

    private int addCount;

    @Override
    public boolean add(E e) {
        this.addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        this.addCount += c.size();
        return super.addAll(c);
    }
}

在该类中覆盖了 HashSet 中的 add 和 addAll 方法,看似没有什么问题,但是通过测试发现这个类是有问题的,先执行 add 然后执行 addAll 后返回的 addCount 不正确。

因为 HashSet 中,addAll 方法是调用 add 方法来执行添加操作的,所以调用 addAll 之后又对每个元素执行 add 操作,因此 addCount 会返回错误的值。

只要删除 MyHashSet 类中的 addAll 方法就可以修复这个问题,直接调用 HashSet 类的 addAll 方法,但是它的正确性依然依赖于 addAll 是在 add 之上实现这种事实,可能别的平台的实现方式并非如此,可能将来 HashSet 类会修改这个实现方式,这些情况下都会导致该类出错,总之,这个类非常脆弱!

还有一种方式就是覆盖 addAll 方法,自己实现 addAll 方法,循环 addAll 的参数集合,循环调用 add 方法。这种情况下相当于重新实现了超类的方法,如果有子类无法访问的私有域,有些方法就无法实现。

导致子类脆弱还有一个原因就是基类可能在后续的版本获得新方法,则可能仅仅由于调用了未被子类覆盖的新方法而导致错误。

如果在扩展一个类的时候,仅仅是添加新的方法,而不去覆盖原有的方法,可能是比较安全的做法。但是如果后续版本中基类新增了一个和子类同名同参数但是不同返回类型的方法,则会导致子类无法通过编译。

复合可以很好的解决以上的问题。在新的类中增加一个私有域引用到现有类的实例,新类中的每个实例方法都可以调用现有类的方法并得到其结果,这种类非常稳固,因为其不依赖于现有类的实现细节,即使现有的类增加了新的方法,也不会影响到新的类。

public class MyHashSet1<E> {

    private int addCount;

    // 新的类新增私有域引用到现有类的实例
    private HashSet<E> hashSet = new HashSet<>();

    public boolean add(E e) {
        this.addCount++;
        return this.hashSet.add(e);
    }

    public boolean addAll(Collection<? extends E> c) {
        this.addCount += c.size();
        return this.hashSet.addAll(c);
    }
}

只有当子类型真正是超类的子类型时使用继承才是恰当的。即便如此,如果子类和超类处在不同的包中,并且超类并不是为了继承而设计的,那么继承也将导致脆弱性。为了避免这种情况,可以使用复合和转发机制代替继承。