Effective Java (3rd Editin) 读书笔记:3 类和接口

3 类和接口

Item 15:最小化类和成员的访问权限

一个设计优秀的类应该隐藏它的所有实现细节,将它的 API 和内部实现干净地分离开。这种软件设计的基本准则被称为“封装”(encapsulation)。

封装的优点:

  1. 组成系统的各组件之间解耦,使得它们能够独立地开发、测试、优化、使用、理解和修改
  2. 基于第一条,提高了组件的复用性
  3. 基于第二条,即使整个系统开发失败,某个独立的组件仍可以非常成功

经验法则很简单:最小化每个类和成员的访问权限。

对于非内部类和接口,只有两种访问权限:package-private 和 public。

对于 package-private 非内部类和接口,它们不会被暴露在 API 中,因此有了更多可维护性。如果它只被一个类使用时,可以让它成为这个类的内部类或接口。

对于成员(字段,方法,内部类和内部接口),有四种访问权限:private,package-private,protected 和 public。

private 和 package-private 的成员不会暴露在 API 中,除非类实现了 Serializable 接口(Item 86 和 Item 87)。

子类重写父类的方法时,方法的访问权限不能缩小(满足里氏替换原则)。因此,如果一个类实现了接口,那么它实现的方法都是 public。

public 类的字段的访问权限应该尽可能小于 public,对于 mutable 字段更是如此。而 public static final 的数组字段是保证不了 immutability 的,用户仍然可以修改数组的元素,解决方法参考如下:

private static final Thing[] PRIVATE_VALUES = {...};
public static final List<Thing> VALUES = 
    Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES));

或者:

private static final Thing[] PRIVATE_VALUES = {...};
public static final Thing[] values() {
    return PRIVATE_VALUES.clone();
}

Item 16:public 类中,使用 getter 而不是 public 字段

// Degenerate classes like this should not be public!
class Point {
    public double x;
    public double y;
}

上面的这种 public non-final 字段有很多反封装的缺点,不能保证不变性,不能方便地修改类,不能在访问字段时添加辅助操作。

当然,我们可以采用 JavaBean 式的解决方案,使字段为 private,并添加 setter 和 getter。如果类是 public,这样做很有必要,它屏蔽了内部的字段细节,便于将来的版本更新。如果类是 package-private 或者 private 的内部类,因为它的访问范围较小,所以要求可以放宽一点。

Java 平台中的 java.awt 包中的 Point 和 Dimension 类违反了本条规则,因此在使用它们时要非常小心。

Item 17:最小化 mutability

Immutable 类是指实例不能修改的类,每个实例中的字段信息在对象生命周期中是固定的。比如 String,原始类型的包装类,BigInteger,BigDecimal。

在设计类的时候,首先考虑能不能设计成 immutable 类,如果有特殊原因不能的话,也要尽可能限制它的 mutability,尽可能使每个字段是 final 的,构造器中初始化好所有字段。

设计一个 Immutable 类时的五条规则:

  1. 不要添加会修改对象状态的方法,如 setter 方法
  2. 确保类不被继承,两种方案
  3. 所有字段 final。更宽松一点的说法是,没有方法会产生内部可见的状态改变,String 类中就采用了非 final 的 hash 变量来缓存懒加载的哈希值。
  4. 所有字段 private。虽然 public final 字段也是安全的,但是考虑到未来的版本更新,尽量隐藏内部细节。
  5. 确保对 mutable 组件的独占访问。如果类中定义了 mutable 对象的引用,确保用户获取不到这些引用字段,也不要用用户提供的引用来初始化这些字段。如果类是 Serializable,需要显式提供 readObject 或 readResolve 方法,或者使用 ObjectOutputStream.writeUnsharedObjectInputStream.readUnshared 方法(Item 88)。

Immutable 类的缺点是,每一个不同的值需要创建一个不同的对象。比如,BigInter 的 flipBit 方法在反转每一位后返回一个新的对象,而 BigSet 的 flip 方法反转某一位后返回修改后的原对象,前者是 immutable,后者是 mutable。如果修改操作非常多的时候,这会产生大问题。解决方法是提供 mutable 的伙伴类,如 String 的 StringBuilder。有的 immutable 的类非常聪明,比如 BigInteger,它会使用 package-private 的伙伴类来加速多步计算,比如 modular exponentiation 计算。

Item 18:使用组合代替继承

继承违反了封装原则,存在很多问题:

  1. 被重写的方法的自身调用的实现细节可能会变化,我们针对它的重写有潜在风险
  2. 如果父类添加了新的方法,子类没有及时重写,子类中访问此方法可能达不到预期增强效果
  3. 子类添加了新的方法,未来的父类版本中可能也会添加同名方法,造成无意的重写
  4. 子类会继承父类的 API 中的缺点,而组合可以隐藏这些缺点

解决上述问题的一个方案是组合,让旧类的实例成为新类的组件,新类的方法调用转发给旧类。为了增加复用性,我们分成了两部分:

// Wrapper class —— 使用组合代替继承的包装类
public class InstrumentedSet<E> extends ForwardingSet<E> {
    private int addCount = 0;
    public InstrumentedSet(Set<E> s) { super(s); }
    
    @Override public boolean add(E e) { 
        addCount++;
        return super.add(e);
    }
    
    @Override public boolean addAll(Collection<? extends E> c) { 
        addCount += c.size();
        return super.addAll(c); 
    }
    
    public int getAddCount() { return addCount; }
}
// 可复用的转发类
public class ForwardingSet<E> implements Set<E> {
    private final Set<E> s;
    public ForwardingSet(Set<E> s) {this.s = s;}

    @Override public int size() { return s.size(); }
    @Override public boolean isEmpty() { return s.isEmpty(); }
    @Override public boolean contains(Object o) { return false; }
    @Override public Iterator<E> iterator() { return s.iterator(); }
    @Override public Object[] toArray() { return s.toArray(); }
    @Override public <T> T[] toArray(T[] a) { return s.toArray(a); }
    @Override public boolean add(E e) { return s.add(e); }
    @Override public boolean remove(Object o) { return s.remove(o); }
    @Override public boolean containsAll(Collection<?> c) { return s.containsAll(c); }
    @Override public boolean addAll(Collection<? extends E> c) { return s.addAll(c); }
    @Override public boolean retainAll(Collection<?> c) { return s.retainAll(c); }
    @Override public boolean removeAll(Collection<?> c) { return s.removeAll(c); }
    @Override public void clear() { s.clear(); }
}

InstrumentedSet 包装类的设计又称为“装饰器模式”(Decorator pattern),因为它通过添加装备“装饰”了一个 Set 对象。

包装类的一个注意事项是:包装类不适合在回调框架中使用(一个对象传递自己的引用给其它对象,以便将来的调用(回调))。一个被包装的对象不知道自己的包装者是谁,因此它会把自己的引用传递给其它对象,回调函数就忽略了包装类。

java.util.Properties 就是一个不合适的继承案例,Properties 类继承自 HashTable,它的设计初衷是只能添加 String 类型的键和值,getProperty(key) 可以返回默认值,但是因为继承自 HashTable,所以还可以调用 put 和 putAll 方法来添加非 String 类型的键和值,get(key) 可能返回 null。一旦违反了 Properties 的设计,就不能有效使用 Properties 的其它 API 了(load 和 store)。

Item 19:可继承类要说明自身调用

一个为继承而设计的类需要在 API 文档中说明自己的所有自身调用(self-use),比如按什么顺序调用了类中的其它可重写的方法。

// AbstractList

/**
 * Removes from this list all of the elements whose index is between
 * {@code fromIndex}, inclusive, and {@code toIndex}, exclusive.
 * Shifts any succeeding elements to the left (reduces their index).
 * This call shortens the list by {@code (toIndex - fromIndex)} elements.
 * (If {@code toIndex==fromIndex}, this operation has no effect.)
 *
 * <p>This method is called by the {@code clear} operation on this list
 * and its subLists.  Overriding this method to take advantage of
 * the internals of the list implementation can <i>substantially</i>
 * improve the performance of the {@code clear} operation on this list
 * and its subLists.
 *
 * <p>This implementation gets a list iterator positioned before
 * {@code fromIndex}, and repeatedly calls {@code ListIterator.next}
 * followed by {@code ListIterator.remove} until the entire range has
 * been removed.  <b>Note: if {@code ListIterator.remove} requires linear
 * time, this implementation requires quadratic time.</b>
 *
 * @param fromIndex index of first element to be removed
 * @param toIndex index after last element to be removed
 */
protected void removeRange(int fromIndex, int toIndex) {...}

这样显然违反了“API 文档应该描述方法做了什么,而隐藏如何做到”的教条,但是没有办法,继承本身已经违反了封装的原则。

如果一个类没有自身调用,你需要在 API 文档中说明这一点,这种父类可以被安全地继承。

对允许继承的类的要求:

  1. 构造器中禁止直接或间接调用可重写的方法(private、final、static 方法是不可重写的)。原因:父类的构造器会先执行,如果子类重写的方法中依赖了需要子类构造器初始化的变量,父类构造器调用此方法时就会出现异常。

    public class Base
    {
        private String baseName = "base";
        public Base(){ callName(); }
        public void callName() { System.out.println(baseName); } // null
     
        static class Sub extends Base {
            private String baseName = "sub";
            public void callName() { System.out.println (baseName); }
        }
        
        public static void main(String[] args) {
            Base b = new Sub(); 
        }
    }
    
  2. 不要使父类实现 Cloneable 和 Serializable 接口,替代方案见 Item 13 和 Item 86。如果非要实现的话,要注意,clone 和 readObject 方法的功能有点像构造器,因此也要满足第一条要求。

  3. 父类实现了 Serializable 接口且有 readResolve 或者 writeReplace 方法时,这两个方法应该是 protected 而非 private。

让一个类不可被继承是一种安全的做法:

  1. final 修饰
  2. 使得所有构造器 private 或 package-private,并添加静态工厂方法代替

有的类实现了包含了类的实现要素的接口,比如 Set,List 或 Map,完全可以用非继承的方式实现功能的增强,方案是 wrapper class 模式(Item 18)。

Item 20:接口优于抽象类

接口有一些显然的优势:

  1. 已经存在的类很容易通过改造,实现一个新的接口。

  2. 接口非常适合定义 mixin。定义 mixin 是指允许一个类在它的原始功能中混合进(mix in)可选择的功能,比如 Comparable 接口允许一个类声明它的实例可以排序。抽象类不能被用来定义 mixin,因为类只能有一个父类,继承抽象类会破坏类的继承结构。

结合接口和抽象类的优点,使用接口的抽象 skeletal implementation 类,又称为 AbstractInterface,接口中可以提供默认方法,而且 AbstractInterface 类中实现剩余的 non-primitives 方法(primitives 是指留给子类实现的方法),使得子类的实现更容易。比如 AbstractCollection,AbstractList 等。在设计模式中又称为“Template Method”模式。

static List<Integer> initArrayAsList(int[] a) {
    Objects.requireNonNull(a);
    return new ArrayList<Integer>() {
        @Override
        public Integer get(int index) {
            return a[index]; // Autoboxing
        }

        @Override
        public Integer set(int index, Integer element) {
            int oldVal = a[index];
            a[index] = element; // Auto-unboxing
            return oldVal; // Autoboxing
        }

        @Override
        public int size() {
            return a.length;
        }
    };
}

上面代码中就通过使用 AbstractList 很轻松地实现了 List 接口,同时又是一个“适配器模式”,将 int 数组视为了一个 Integer List 来操作。

模拟多重继承(simulated multiple inheritance):一个直接实现了接口的类,可以将接口的方法调用转发给自己的继承自 AbstactInterface 的内部类,这种设计技巧既提供了多重继承的优势,又避开了陷阱(Item 18)。

skeletal implementation 的设计步骤:

  1. 研究接口,决定哪些方法作为 primitives 不实现,其余的方法可以由我们实现
  2. 在 non-primitives 中,优先考虑哪些可以作为接口的默认方法来实现(接口的默认方法不能实现 Object 类的方法比如,equals,hashCode,toString),如果默认方法覆盖了所有 non-primitives,就不必再设计 AbstactInterface 了
  3. 定义 AbstactInterface 类,实现剩余的接口默认方法不能实现的 non-primitives 方法。
  4. 作为一个抽象类,完成必要的说明文档。

总之,skeletal implementation 中,尽可能通过接口的默认方法来实现 non-primitives,使得所有实现类都能使用这些方法,在默认方法不能实现时,再提供抽象类来实现这些 non-primitives。

Item 21:用接口的默认方法来添加新方法是不得已之举

Java 8 之前,接口中是不能添加新方法的,否则所有已经存在的实现类,因为没有实现这个方法而不能通过编译。但是 Java 8 引入了默认方法,可以给接口添加新的方法,已经存在的实现类可以通过编译,但运行时还是可能出现问题,因此一定要谨慎。

比如,Collection 接口在 Java 8 中添加了一个默认方法:

default boolean removeIf(Predicate<? super E> filter) {
    Objects.requireNonNull(filter);
    boolean removed = false;
    final Iterator<E> each = iterator();
    while (each.hasNext()) {
        if (filter.test(each.next())) {
            each.remove();
            removed = true;
        }
    }
    return removed;
}

Apache Commons 库中的 SynchronizedCollection 类并没有实现这个方法,如果在 Java 8 中使用此类的 removeIf 方法,并不能保证此类的基本承诺:自动同步每一个方法调用。在另一个线程并发修改的情景中,此类的 removeIf 方法会抛出 ConcurrentModificationException 或者产生其它异常行为。

但是另一方面,在设计接口的一开始,可以考虑使用默认方法来提供标准的方法实现,来减轻实现接口的负担。

Item 22:接口作为一种定义行为的类型来使用,而不要定义纯粹的常量接口

一个类实现了一个接口,这个接口就可以作为一个类型来引用此类的实例,根据接口的 API,就可以知道此类的实例具有哪些行为。

从这个意义上理解的话,常量接口是对接口的不恰当使用:

// 常量接口反模式 —— 不要这样定义!
public interface PhysicalConstants {
    // Avogadro's number (1/mol)
    static final double AVOGADRO_NUMBER = 6.022_140_857e23;
    
    // Boltzmann constant (J/K)
    static final double BOLTZMANN_CONSTANT = 1.380_648_52e-23;
    
    // Mass of the electron (kg)
    static final double ELECTRON_MASS = 9.109_383_56e-31;
}
  1. 类内部使用的常量属于实现细节,实现常量接口使得实现细节暴露在 API 中
  2. 如果将来发布的版本中,此类不再需要这些常量,为了使得二进制兼容,它还是要实现此接口
  3. 一个可以继承的类实现了常量接口的话,它的子类的命名空间也都会被接口中的常量污染

Java 平台库中的 java.io.ObjectStreamConstants 接口就是这样一个反例。

正确定义常量的推荐做法有:

  1. 若这些常量和类或接口紧密绑定,就将它们添加到类和接口的常量字段中。如 Integer 和 Double,暴露了常量 MIN_VALUE 和 MAX-VALUE
  2. 如果这些常量最好看作是枚举类型的成员,就使用枚举类型(Item 34)
  3. 否则,就使用不可实例化的工具类
// 常量工具类
public class PhysicalConstants {
    private PhysicalConstants() {} // Prevents instantiation
    
    public static final double AVOGADRO_NUMBER = 6.022_140_857e23;
    public static final double BOLTZMANN_CONSTANT = 1.380_648_52e-23;
    public static final double ELECTRON_MASS = 9.109_383_56e-31;
}

Item 23:将 tagged 类重构为类继承体系

Tagged 类:类的实例有多种不同的类型,通过类内部的 tag 字段来表明一个实例是哪种类型。如下:

// Tagged class —— 非常不推荐,应该改造为类继承体系
class Figure {
    enum Shape {RECTANGLE, CIRCLE};

    // Tag field - the shape of the figure
    final Shape shape;

    // These fields are used only if shape is RECTANGLE
    double length;
    double width;

    // This field is used only if shape is CIRCLE
    double radius;

    // Constructor for circle
    Figure(double radius) {
        shape = Shape.CIRCLE;
        this.radius = radius;
    }

    // Constructor for rectangle
    Figure(double length, double width) {
        shape = Shape.RECTANGLE;
        this.width = width;
        this.length = length;
    }

    double area() {
        switch (shape) {
            case CIRCLE:
                return length * width;
            case RECTANGLE:
                return Math.PI * (radius * radius);
            default:
                throw new AssertionError(shape);
        }
    }
}

tagged 类的缺点很多,概括起来就是臃肿(可读性差)、易出错(扩展麻烦)、低效(占内存)。

将 tagged 类重构为类继承体系:

  1. 定义一个抽象类,为 tagged 类中取决于 tag 值而选择性表达的每一个方法定义一个抽象方法,tagged 类中不取决于 tag 值的方法以及所有类型共用的字段直接放在抽象类中。
  2. 为 tagged 类中的每一种类型定义一个抽象类的实现类,包含此类型特有的数据字段,针对此类型实现每一个抽象方法
// 代替 tagged 类的类继承体系
abstract class Figure {
    abstract double area();
}

class Circle extends Figure {
    final double radius;
    
    Circle(double radius) { this.radius = radius; }

    @Override double area() { return Math.PI * (radius * radius); }
}

class Rectangle extends Figure {
    final double length;
    final double width;

    Rectangle(double length, double width) {
        this.length = length;
        this.width = width;
    }
    
    @Override double area() { return length * width; }
}

类继承体系改正了 tagged 的缺点,同时反映了类之间的自然等级关系,增加灵活性,编译时检查。如,正方形作为一种特殊的矩形,实现起来也非常容易:

class Square extends Rectangle {
    Square(double side) {
        super(side, side);
    }
}

Item 24:根据使用场景选择嵌套类的种类

嵌套类(nested class)有四种:静态成员类,非静态成员类,匿名类,局部类;后三种又称为内部类(inner class)。

静态成员类,最简单的嵌套类,可以看做一个普通类,恰好被声明在另一个类中,可以访问封闭类(enclosing class)的所有成员(包括 private)。如果 private,那么访问范围局限在封闭类中。一个常见的使用场景是作为 public 帮助类,比如提供常量等。

非静态成员类,隐式地和一个封闭类的实例关联,创建一个非静态成员类的实例时必须先有一个封闭类的实例。在非静态成员类实例的方法中,可以调用封闭类实例的方法,并用 EncosingClassName.this 引用封闭实例。一个常见的使用场景是适配器模式,比如,HashMap 中使用非静态成员类 KeySet、EntrySet、Values 来实现 collection 的视图;再比如 collection 接口(如 Set 和 List)的实现中,使用非静态成员类实现迭代器。

// 非静态成员类的典型应用
public class MySet<E> extends AbstractSet<E> {
    //...
    
    @Override public Iterator<E> iterator() {
        return new MyIterator();
    }
    
    private class MyIterator implements Iterator<E> {
        ...;
    }
}

非静态成员类相较于静态成员类的缺点有:

  1. 非静态成员类实例中,会存储封闭实例的引用,这个存储要花费空间和时间
  2. 更大的缺点是,对封闭实例的引用会影响 GC 过程,导致内存泄漏

所以,除非成员类真的需要使用一个封闭对象的实例,否则总是使用 static 修饰符。比如 Map 中的 private static 的 Entry。

匿名类在使用时定义和初始化,仅仅在非静态上下文中才会有封闭实例的引用,它们的静态成员只能是常量(final 原始类型,或常量表达式初始化的 String 字段)。在能用 lamda 表达式时倾向使用 lamda 代替匿名类。

Item 25:一个 Java 源文件中只定义一个顶层类或顶层接口

假设一个包中有 3 个 Java 源文件:

Main.java:

public class Main {
    public static void main(String[] args) {
        System.out.println(Utensil.NAME + Dessert.NAME);
    }   
}

Utensil.java:

// 一个源文件定义两个 top-level 类,不要这样做!
class Utensil {
    static final String NAME = "pan";
}

class Dessert {
    static final String NAME = "cake";
}

Dessert.java:

// 一个源文件定义两个 top-level 类,不要这样做!
class Utensil {
    static final String NAME = "pot";
}

class Dessert {
    static final String NAME = "pie";
}

命令行编译时:

  • 如果输入 javac Main.java Dessert.java 会报错“you’ve multiply defined the classes Utensil and Dessert”,因为编译器首先读入 Main.java 文件,在遇到 Utensil.NAME,会在目录中寻找 Utensil.java 源文件来加载类,结果就会在源文件中找到了 Utensil 和 Dessert 两个类,当编译器接着读入 Dessert.java 文件时,就会遇到 Utensil 和 Dessert 的重定义。
  • 如果输入 javac Main.java Utensil.java 或者 javac Main.java 都会正常编译,运行时输出“pancake”。
  • 如果输入 javac Dessert.java Main.java 也会通过编译,但是运行时输出“potpie”。

推荐修改方案有两种,一是,将每个顶层类放到单独的 java 源文件中;二是,将多个类作为静态成员类放到一个顶层类中,如下示例。

public class Main {
    public static void main(String[] args) {
        System.out.println(Utensil.NAME + Dessert.NAME);
    }   
    
    private static class Utensil {
        static final String NAME = "pot";
    }

    private static class Dessert {
        static final String NAME = "pie";
    }  
}

猜你喜欢

转载自blog.csdn.net/weixin_40255793/article/details/82859162
3rd