EffectiveJava学习笔记(四)

第15条:使类和成员的可访问性最小化

软件设计的基本原则之一:信息隐藏--一个模块不需要知道其他模块的内部工作情况。
实现这个原则很简单,就是尽可能的使每个类或者成员不被外部访问。

对于顶层的类,只有两种访问级别:包级私有(package-private)、公有(public)。
一个类声明为包级私有时,它实际上是这个包的实现的一部分,而非外部接口,在以后的版本中可以自由的修改或删除。如果声明为公有的,则需要永远的支持它,维护它的兼容性。

  • 公有类的实例域绝不能是公有的。如果实例域是非final的或者指向一个可变对象的final引用,如果设置为公有的,就等于放弃了对存储在这个域中对值进行限制对能力。
  • 让类具有公有对final数组域或者返回数组域的返回方法,也是错误的,客户端将可以修改数组的内容。修正这个问题有两种方式:

使公有数组变为私有,增加公有的不可变列表

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

数组变为私有,添加公有方法返回数组的拷贝

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

第16条:要在公有类而非公有域中使用访问方法

公有类永远不应该暴露可变的域。虽然还是有问题,但是让公有类暴露不可变的域,可通过添加约束条件降低危害性。有时会需要包级私有或私有嵌套类暴露域,无论这个类是可变的还是不可变的。

第17条:使可变性最小化

不可变类遵循以下五条原则:

  • 不要提供任何会修改对象状态的方法(也称设值方法)
  • 保证类不会被扩展
  • 声明所有域都是final的
  • 声明所有的域都为私有的
  • 确保对于任何可变组件的互斥访问。 如果类具有指向可变对象的域,必须确保使用该类的客户端无法获得指向这些对象的引用,永远不要用客户端提供的对象来初始化这样的域。

不可变对象特点:

  • 不可变对象比较简单,只有一种状态即被创造时的状态
  • 不可变对象本质上是线程安全的,不要求同步,可以自由的共享。
  • 不可变对象为其他对象提供了大量的构件
  • 缺点:对于每个不同的值都需要一个单独的对象,如果有一个上百万位的BigInteger,想要改变它的低位,就要创建一个新的BigInteger实例,也有上百万位,但是与原来的对象只有一位不同。(解决这个问题可以使用BigSet,可以在固定时间内仅改变单个位的状态) 如果无法预测客户端要在不可变类上进行哪些复杂的操作,可提供一个公有的可变配套类,如String-->StringBuilder

构造不可变类有两种方式,一种方法是使类成为final的,另一种方法是是让类的构造器变为私有的,并提供公有的静态方法来代替公有的构造器。

public class Point{
    private final int x;
    private final int y;
    private Point(int x,int y){
        this.x = x;
        this.y = y;
    }
    public static Point valueOf(int x,int y){
        return new Point(x,y);
    }
}

总结:

  1. 除非有很好的理由要让类成为可变的类,否则它就应该是不可变的
  2. 如果类不能做成不可变的,要尽可能的限制它的可变性
  3. 构造器应该创建完全初始化的对象,并建立起所有的约束关系

第18条:复合优先于继承

继承与方法调用不同的是,继承破坏了封装性,如果父类发生变化,子类必须跟着父类的变化而更新,除非父类是为了扩展而设计的,随便继承没有清晰文档说明的类,可能无法得到预期的结果。

举例:想知道一段程序中,Set集合自从创建以来,一共添加过多少元素。

创建InstrumentedHashSet类

class InstrumentedHashSet<E> extends HashSet<E> {
    private int addCount = 0;

    public InstrumentedHashSet() {
    }

    public InstrumentedHashSet(int initCap, float loadFactor) {
        super(initCap, loadFactor);
    }

    @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;
    }
}

main方法

public static void main(String[] args) {

        InstrumentedHashSet instrumentedHashSet = new InstrumentedHashSet();
        instrumentedHashSet.addAll(Arrays.asList("a", "b", "c"));
        System.out.println(instrumentedHashSet.getAddCount());
    }

运行输出为6,但是我们只添加三个元素,期望结果为3,这是因为HashSet的addAll方法是基于add方法实现的,程序先调用addAll方法addCount增加3,又循环调用三次add方法每次增加1。

如果想修正这个方法,我们可以覆盖addAll方法,这样可以保证结果的正确性,但是我们这样做是基于 "HashSet的addAll方式是基于add方法实现的"这个事实,并且并不能保证这个规则在java以后的发行版本中会不会改变,所以说我们这样设计出来的InstrumentedHashSet也是脆弱的,健壮性差的。

导致子类脆弱的另一个原因是:父类可能会增加新的方法。假设一个程序的安全性基于这样的事实:能够插入某个集合的元素都必须满足一个先决条件。我们可以通过对集合子类化,覆盖所有插入方法,确保在添加元素时是满足条件的。如果父类在以后的版本中没有添加新的插入方法,我们这样做可以保证安全性,但是如果父类增加了新的插入方法,那么程序将可以通过这个未被子类覆盖的方法插入不符合条件的数据,这样就出现了安全漏洞。

"复合"可以解决前面提到的所有问题,这种设计模式是指不扩展现有的类,而是在新的类增加一个私有域,指向现有的类的一个实例。现有的类变成了新类中的一个组件,新类中的所有方法都可以通过调用被包含的现有的类的方法实现,这种方式也叫转发。

以刚刚的InstrumentedHashSet类为例,使用复合方式实现:

class InstrumentedHashSet2<E> extends ForwardingSet<E> {

    private int addCount = 0;

    public InstrumentedHashSet2(Set<E> set) {
        super(set);
    }

    @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;
    }
}

class ForwardingSet<E> implements Set<E> {

    private final Set<E> s;

    public ForwardingSet(Set<E> set) {
        this.s = set;
    }

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

    @Override
    public boolean isEmpty() {
        return s.isEmpty();
    }

    @Override
    public boolean contains(Object o) {
        return s.contains(o);
    }

    @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.removeAll(c);
    }

    @Override
    public boolean removeAll(Collection<?> c) {
        return s.removeAll(c);
    }

    @Override
    public void clear() {
        s.clear();
    }
}

InstrumentedHashSet2相当于把一个Set转换成了一个有计数功能的另一个Set,并且可以兼容Set的任何构造方法,这种模式也被称为 "修饰者模式",InstrumentedHashSet2将一个Set实例包装起来,所以InstrumentedHashSet2也被称为包装类。

需要注意的是,包装类不适合用于回调框架,在回调框架中,对象把自身的引用传给其他对象,被包装起来的对象并不知道它外面的包装类,所以它传递的是自身的引用。

第19条:要么设计继承并提供文档说明,要么禁止继承

专门为了继承而设计的类,需在文档中清晰描述覆盖每个方法所带来的影响。对于每个公有的或者受保护的方法,必须说明该方法是否调用了可被覆盖方法,调用的顺序是怎样的,每个调用的结果是如何处理后续影响的。

构造器绝不能调用可被覆盖方法,父类的构造器在子类的构造器之前运行,所以子类的覆盖版本的方法将会在子类的构造器运行之前被执行。

举例:

class Super {

    public Super() {
        overrideMe();
    }

    public void overrideMe() {
    }
}

final class Sub extends Super {

    private final Instant instant;

    Sub() {
        instant = Instant.now();
    }

    @Override
    public void overrideMe() {
        System.out.println(instant);
    }
}

main方法:

public static void main(String[] args) {
        new Sub().overrideMe();
    }

输出:

null

2020-03-22T08:43:29.201Z

第一行会打印null,程序会先调用父类的构造方法,再调用子类的overrideMe方法,此时子类的构造器还没运行,Instant还未初始化,所以为null。

通过构造器调用私有的方法、final方法或静态方法是安全的,这些都是不可以被覆盖的方法。

原文地址:https://www.cnblogs.com/youtang/p/12641574.html