EFFECTIVE JAVA 3RD EDITION — 第四章 类与接口(1)

Item 15 : Minimize the accessibility of classes and members

最小化类与成员的访问级别

隐藏实现信息或者成员信息有多个好处:

  • 有助于组件之间解耦
  • 提升开发效率,多组件之间可以并行开发
  • 减少维护成本,因为组件调试或者替换不影响其他部分
  • 增加重用率,因为组件之间都是解耦的
  • 减少开发大型系统的风险
    用尽可能低的访问级别去定义软件功能,当你需要访问包内其他类的时候才将那个类从私有改为 package-private ,但是如果经常这样考虑设计一个解耦出来的类。

私有成员和包私有成员都是类实现的一部分,通常不会影响其导出的api,如果类实现了 Serializable 可能导致这些字段”泄露”到api中。

一个类的保护成员通常是代表了一个类实现细节,所以相对用的较少。

如果一个子类重写了父类的一个方法,那么这个方法的访问级别不能超过父类,就确保了只要父类能够使用该方法的地方子类都能使用,
特别的,一个类实现了一个接口那么所有的实现方法都应该是 public。
有时候为了测试可能会让成员包内私有,但不接受更高的访问级别。

公共类的实例字段很少是公共的,也就是说公共的可改变属性是线程不安全的,因为是公共的无法在类内部进行控制。
尽管这个字段有可能是 final 并且是不变对象的引用,只要公共了就失去了可以随时替换它的灵活性。

长度非0的数组永远都是可变的,所以静态 final 数组字段或者返回这么一个字段都是错误的:

public static final Thing[] VALUES = { ... }

可以这样修改:

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

或者

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

Java 9 中新增的模块系统隐含了新的访问级别:

  • 未导出包都是模块内可访问,外部模块无法访问
  • 模块系统提供了一个共享包的方式来使包内所有类可访问而不用每一个去控制访问级别
  • 但如果你把模块化的jar包放在非 module path 下的话会被当做普通jar包,模块之间的访问级别就不存在了
  • 模块的编写需要你组合包,声明依赖,重新组织包结构来方便模块之间的访问和非模块结构的访问

总的来说尽量减小程序的访问级别,设计一个最小化的公共api,公共类尽量不要包含公共字段,保证静态 final 字段不变。

Item 16 : use accessor methods not public fields in public classes

为公共类提供内部属性访问方法而不是让内部属性成为公共的

公共类属性最好不要公共,而是提供方法访问,比如 get 方法。如果一个类是包内私有或者是内部类,暴露其内部属性也无妨

但Java 中Dimension类却暴露了其内部属性,这是一个反面例子,同时(在 item 67中提到)造成了严重的性能影响。

虽然不推荐暴露类内部属性,但如果属性是不变 final 的,暴露也是可以的,比如下面:

public final class Time {
    private static final int HOURS_PER_DAY = 24;
    private static final int MINUTES_PER_HOUR = 60;
    public final int hour;
    public final int minute;
    public Time(int hour, int minute) {
        if (hour < 0 || hour >= HOURS_PER_DAY)
            throw new IllegalArgumentException("Hour: " + hour);
        if (minute < 0 || minute >= MINUTES_PER_HOUR)
            throw new IllegalArgumentException("Min: " + minute);
        this.hour = hour;
        this.minute = minute;
    }
    ... // Remainder omitted
}

简言之类不应该暴露可变字段,暴露不变字段依旧是待商榷的,有时需要包私有或者私有类暴露其属性。

Item 17 : Minimize mutability

最小化类的可变性

不可变类设计和使用更加简单,更少出错和安全。设计一个不可变类需要遵循下面几点:

  1. 不提供方法改变类的状态
  2. 保证类不可继承,类声明 final 或者构造方法私有然后提供一个静态工厂方法返回实例,避免无意或者恶意的继承造成父类状态的改变
  3. 使所有字段 final,同时保证新建实例的引用可以跨线程而无需同步
  4. 使所有字段私有
  5. 确保独占可变组件
    不变对象是线程安全的,不需要同步,可以被自由共享,应该尽可能的被重用。

可以提供一个工厂方法来缓存被频繁请求的实例,避免重复创建实例,比如所有的包装类都这样做,这样可以让调用这复用对象而不是反复创建,减少内存占用和gc开销。
提供工厂方法比提供公共的构造器有更多的灵活性去后续可以轻松添加缓存。

可以共享不变对象,不变对象也可以共享其内部属性,比如 BigInteger 类 negate 方法每次都是使用 final 属性 signum 和 mag 来构造新的实例。

不变对象为其他类提供了构建模块,比如在你使用一个复杂的类的时候如果这个类的组件都是不可变的那就会轻松很多。

不变类创建之后状态不再改变,所以不存在一个临时的状态。

不变对象最主要的缺陷在于对于不同的值需要单独的对象,通常这些类的创建都是十分消耗巨大,特别是当他很大的时候
比如你有一个百万位的 BigInteger 你想修改他的低位:

BigInteger moby = ...;
moby = moby.flipBit(0);

flipBit 方法会创建一个新的同样长度的实例,只有一位不同,这个操作会根据 BigInteger 的长度而成比例的消耗时间和空间,BitSet 类似不过是可变的。

如果你执行多步操作,每步都生成新对象,但最后除了结果其他对象全部丢弃,这会造成性能问题。可以作下面的处理:

  1. 从多步操作中提取出基本操作,这样就不需要每一步都创建新对象
  2. 如果能够准确的预估出调用者什么时候能够进行这些复杂操作,提供一个包内私有同胞类来处理,比如String 和 StringBuilder
    上面第三点提到属性 final ,但其实可以更宽松一点来进行性能的提升,比如用一个非 final 字段来缓存一个需要大量计算的值。

如果不变类实现了 Serializable 接口,最好提供明确的 readObject 或者 readResolve 方法,或者使用 ObjectOutputStream.writeUnshared 和 ObjectInputStream.readUnshared,
否则的话攻击者可以创建一个可变的实例,这在 item 88 中有详细介绍。

总的来说,不要给每一个 get 方法提供 set 方法,类应该保持不变性除非有足够的理由让他们可变,不变类最大的不足就是某些环境下潜在的性能缺陷;
应该尽量使“小”的类不变,考虑是否写一个“大”类;
为不变类提供同胞类来满足性能需求;
如果一个类无法做到不变,那么尽可能的缩小他的变数;
声明所有的字段 final ,除非有充分理由不这么做;
构造器应该使用所有不变类的不变字段来实例化,不要提供除构造器和静态工厂方法以外的方法初始化对象;
不要提供一个重新初始化对象的方法来提高重用,这样做可能会提高性能但会大大增加程序复杂度,CountDownLatch 类是一个很好的不变类的例子。

Item 18 : Favor composition over inheritance

优先构成而不是继承

本书说的继承是指实现继承(一个类扩展另一个类),本节不包含接口实现或者说接口扩展另一个接口。

继承只在包内进行,跨包的继承是危险的,只针对确实需要继承的类(文档说明了需要),或者说子类确实“是”父类,扩展父类的功能或者属性等等;
继承同时也破坏了封装;
子类不可控的可以获得新的方法然后直接重写,如果父类扩展了的话;
可以用 extend 来避免方法重写带来的上面问题,但是如果如果父类和子类的方法一样,那也成了重写,因为你无法避免父类的方法添加不和子类重复。

java中有不少类其实是违反了上面的规则,比如 Stack 不应该继承 Vector ,Properties 不应该继承 Hashtable等等,他们都不是自己的父类类型。

解决上面问题的方法就是用构成来代替继承,就是说给你的类添加一个私有对象引用,让已有的类成为另一个类的一个组件,
通常使用类和引用类之间还有一个转发类,利于重用,比如下面的代码:

// Wrapper class - uses composition in place of inheritance
public class InstrumentedSet extends ForwardingSet {
    private int addCount = 0;
    public InstrumentedSet(Set 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;
    }
}
// Reusable forwarding class
public class ForwardingSet implements Set {
    private final Set s;
    public ForwardingSet(Set s) { this.s = s; }
    public void clear() { s.clear(); }
    public boolean contains(Object o) { return s.contains(o); }
    public boolean isEmpty() { return s.isEmpty(); }
    public int size() { return s.size(); }
    public Iterator iterator() { return s.iterator(); }
    public boolean add(E e) { return s.add(e); }
    public boolean remove(Object o) { return s.remove(o); }
    public boolean containsAll(Collection<?> c)
    { return s.containsAll(c); }
    public boolean addAll(Collection<? extends E> c)
    { return s.addAll(c); }
    public boolean removeAll(Collection<?> c)
    { return s.removeAll(c); }
    public boolean retainAll(Collection<?> c)
    { return s.retainAll(c); }
    public Object[] toArray() { return s.toArray(); }
    public  T[] toArray(T[] a) { return s.toArray(a); }
    @Override public boolean equals(Object o)
    { return s.equals(o); }
    @Override public int hashCode() { return s.hashCode(); }
    @Override public String toString() { return s.toString(); }
}

上面第一个类被叫做包装类(wrapper class),注意与基本类型包装类区分开,因为他包含了另一个 Set,
第二个类里面的方法叫做转发方法,无需担心包装类的内存占用或者是转发类的性能问题。

缺点:

  • 不适合那些回调框架,因为包装类是不知道自己的包装器是谁,只能返回自身的一个引用,并且这个引用还没有包装器
  • 转发类编写略显枯燥,不过这个类是可以重用的

Item 19 : Design and document for inheritance or else prohibit it

继承类必须精心设计文档清楚否则就不要让他可继承

可继承类的文档需要清楚的写明每个可重写方法的调用地方,结果和影响。

继承类通过保护方法或者保护字段向其子类提供访问内部的方式。

唯一一个测试可继承类的方法是在发布前写子类测试,并且最好由多个不同的人去测试而不是仅仅作者本人。

可继承类构造器不能直接或者间接用可重写方法,如果实现了 Cloneable 和 Serializable 接口,和构造器一样 clone 和 readObject 方法不能直接或者间接调用可重写方法。

实现了 Serializable 接口,其 readResolve 和 writeReplace 方法必须 protected 而不是私有。

如果一个类本身设计就是不用于安全的继承,文档也没写明需要继承那么就禁用继承:

  1. 构造器私有
  2. 声明 final
    总的来说设计一个可继承类是十分费力的,文档需要写明每一个内部调用,每一个可重写方法需要注意的地方,一旦发布,那么就要背着这个坑了,
    同时为了使用者设计出高效的子类可能需要父类暴露部分内部字段等等,可以通过提供保护方法或者字段来实现。

Item 20 : Prefer interfaces to abstract classes

优先考虑将接口抽象为抽象接口

Java有两种方式来定义一个可以多实现的类:接口和抽象类。

Java8中允许default方法之后可以在接口中直接定义接口实例化方法,比如item 1提到的instance方法。

已经存在的类可以很轻松的实现一个新接口,但是抽象类不行,因为一个类不能多继承,除非继承的两个类是父子关系。

接口非常适合定义混合功能或者说方法,因为接口更像一个方法集合,提供各种可选方法和功能,但是抽象类不行也是因为上面的原因。

接口可以构建非层次结构的框架,比如:

public interface Singer {
    AudioClip sing(Song s);
}
public interface Songwriter {
    Song compose(int chartPosition);
}

但是一个歌手很可能既能唱歌也能写歌,所以:

public interface SingerSongwriter extends Singer, Songwriter {
    AudioClip strum();
    void actSensitive();
}

你有可能不需要上面的这种结构,但是如果对每一种组合类型都进行支持,如果有n个属性,那么就会有2的n次方中组合,这相比上面的结构复杂很多,
同时多种组合会导致类方法过多难以区分,也违背了接口定义的原则。

接口通过包装类(item18)对功能进行了增加和安全加固,但是如果使用抽象类的话子类就可以通过继承无限制的扩展抽象类方法。

抽象接口提供了接口所有方法实现但同时不强制继承抽象类。

普通接口实现可以将接口方法转发给内部一个继承抽象接口的内部类,被称作 simulated multiple inheritance 模拟多重继承。

编写一个抽象接口十分简单:

  • 看文档了解接口哪些基本方法会被实现者实现,那些方法就是抽象接口中的抽象方法
  • 在接口上提供可以在上面的基本方法上实现的所有方法的 default 方法,可能不提供对象基本方法
  • 如果上面的方法覆盖了接口,那么抽象接口就完成了并且不需要实现,否则的话写一个类实现接口中剩下的方法
    比如:
    	public abstract class AbstractMapEntry<K,V> implements Map.Entry<K,V> {
        // Entries in a modifiable map must override this method
        @Override
        public V setValue(V value) {
            throw new UnsupportedOperationException();
        }
        // Implements the general contract of Map.Entry.equals
        @Override
        public boolean equals(Object o) {
            if (o == this)
                return true;
            if (!(o instanceof Map.Entry))
                return false;
            Map.Entry<!--?,?--> e = (Map.Entry) o;
            return Objects.equals(e.getKey(), getKey()) && Objects.equals(e.getValue(), getValue());
        }
        // Implements the general contract of Map.Entry.hashCode
        @Override
        public int hashCode() {
            return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
        }
        @Override
        public String toString() {
            return getKey() + "=" + getValue();
        }
    }
    

抽象接口的编写也需要遵循item19提到的规则。

总的来说接口通常是一个需要多实现最好的方式,如果有导出接口的需要定义一个抽象接口,对接口的限制通常也采用抽象接口来实现。

版权所有丨转载请注明出处:https://minei.me/archives/311.html