Fork me on GitHub

【读薄Effective Java】类和接口

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

好的模块最重要的一点是好的模块会把所有的实现和其他细节隐藏起来,模块之间的通信只通过API。
当设计完一个类的API之后,应该防止散乱的类,接口和成员变量变成API的一部分。
但是让类暴露final域的危害比较小,所以除了公有静态final域之外,共有类都不应该包含public域。
但是,格外要注意一点的是,final域引用的对象必须是不可变的,如果是可变的(例如数组)就会引起灾难。这样说比较抽象,我们看一个例子。

#!java
public class Main {
    public static final int[] NUM = new int[5];

    public static void main(String[] args) {
        NUM[1] = 2;
        System.out.println(NUM[1]);

    }
}

在这里NUM数组如果暴露出去,则会让其他的类随意修改。

2. 不可变类

编写不可变类时要遵循这几条规则

  • 不提供修改对象的方法
  • 保证类不会被拓展,防止子类化破坏类的不可变性(在声明类的时候用final关键字)
  • 所有域都是private并且final的
  • 确保对于任何可变组件的互斥访问。
#!java
public final class Complex {
    private final double re;
    private final double im;


    public Complex(double re, double im) {
        this.re = re;
        this.im = im;
    }

    public Complex add(Complex that) {
        return new Complex(this.re + that.re, this.im + that.im);
    }

}

2.1 不可变类的好处

  • 这些类都是线程安全的,多个线程并发访问也没有什么问题
  • 不仅可以共享对象,也可以共享内部的信息。例如BigInteger有一个negate方法返回负数的对象,而正数和负数都是共用一个数组的。
  • 因为内部不会改变,所以维护方便,放进set或者map中不担心对象会改变。

2.1 不可变类的坏处和解决方法

但是final类也有坏处,其中最明显的就是每次改变对象都要重新创建一个新对象。如果开销很大的话,我们可以考虑提供一个共有的可变套类(例如StringBuilder)

#!java
//错误示范
String s = "";
for(int i = 0;i < 200;i++) {
    s += d;
}

由于String是不可变的,所以以上的代码会多创建200个String

#!java
//正确示范
StringBuilder s = new StringBuilder();
for(int i = 0;i < 200;i++) {
    s.append("d");
}

用了可变套类,没有重复创建对象。

3. 复合优先于继承

在包内部使用继承是非常安全的,因为是在同一个程序员的控制下的。有详细文档说明的继承也是非常安全的。然而,因为继承破坏了封装性,如果超类(别人写的)发生了变化,继承过来的子类(你写的)可能会炸。

3.1 为什么继承是脆弱的

3.1.1 继承依赖于超类的实现细节

举个例子
为了让例子不复杂,有很多细节没有写上(比如泛型)
我们想为HashSet加上一个新方法,让它能记录操作add的次数。

#!java
public class MyHashSet extends HashSet {
    int time = 0;
    @Override
    public boolean addAll(Collection c) {
        time++;
        return super.addAll(c);
    }

    @Override
    public boolean add(Object o) {
        time++;
        return super.add(o);
    }
}


然后我们测验一下这个类

#!java
    public static void main(String[] args) {
        MyHashSet myHashSet = new MyHashSet();
        HashSet set = new HashSet();
        myHashSet.add("a");
        myHashSet.add("b");
        myHashSet.addAll(set);
        System.out.println(myHashSet.time);

    }

如果运行这段代码,会发现操作数是3,而不是我们期待的1。因为addAll里面调用了add方法,但是我们并不知道这个细节,所以会出错。如果我们把它改过来,如果下个版本的HashSet里的AddAll方法不调用add方法,我们的类又不能正常工作了。

3.1.2 超类增添了新方法引起的问题

比如我们继承Set实现StringSet,重写add方法判断加入的元素是否为String,只把String加入Set里,如果Set在以后版本更新了新的addMotherFucker方法,我们的StringSet就会能通过这个方法来加入其他元素。

3.2 复合--解决继承脆弱的方法

复合需要两个类,一个类用于转发,一个类用于包装

#!java
//转发类,需要实现全部方法,这里为了代码简洁只转发了部分方法
public class ForwardSet<E> {
    private HashSet<E> set = new HashSet<>();

    //这个会在后面用到
    public ForwardSet(HashSet<E> set) {
        this.set = set;
    }
        public ForwardSet() {
    }
    public void add(E e) {
        set.add(e);
    }

    public void addAll(Set set) {
        set.addAll(set);
    }


}
#!java
public class WrapperSet<E> extends ForwardSet<E> {
    int time = 0;
    public WrapperSet(Set s) {
        super(s);
    }
    
    
    @Override
    public void add(E e) {
        time++;
        super.add(e);
    }

    @Override
    public void addAll(Set set) {
        time++;
        super.addAll(set);
    }
}

经过测试,这次出来的结果就是预想到的1了。复合在超类扩展或者方法实现有改变的时候也不会有问题。
复合提供了灵活性,这里的包装类可以结合任何类的实现Set myHashSet = new MyHashSet();

比如我们想为Set包装上计数方法。

#!java
//上面有声明set
MyHashSet<String> s = new MyHashSet(new ForwardSet<String>(set));

3.3 总结

当你想要继承一个类的时候,你需要确认

  1. 两个类存在A is B的关系,而不是只是想用超类的一部分API
  2. 原有API是否有缺陷,有的话是否愿意带到子类中,复合可以设计新的API

4. 接口优于抽象类

4.1 接口与抽象类的比较

  1. 接口是用于实现的,而抽象类是用于继承的。
  2. 接口相当于提供了一个对象可选的行为,比如Comparable接口就是让对象可以比较,这种关系并不适合继承
  3. 接口不灵活,因为接口在定义后无法修改,而抽象类在修改的时候很灵活。

总结:如果需要频繁更新的话,用抽象类,否则应该用认真测试过的接口。

5. 实现导出常量的好方法

5.1 愚蠢的常量接口

有一种接口叫做常量接口,这样做可以避免类名来修饰常量,这种接口只有final域,没有方法。以下是实现。

#!java
//反面教材
public interface FoolishConstants {
    static final int me = 250;
    
}

如果后面的版本用不到这个常量了,为了兼容,新版本的类也要继承这种接口,造成污染。

5.2 导出常量的好方法

导出常量最好用工具类(utility class)。
如果大量用到常量,每次都用类名修饰常量,显得很罗嗦,所以我们可以用静态导入。

import static java.lang.System.out;

6. 用函数对象表示策略

JAVA并没有和C语言一样有函数指针,所以需要把函数存在对象里,然后通过传递引用达到传递函数的目的。这种对象也叫做函数对象。作为策略类,它没有域,所以设计成Singleton很合适。

#!java
public class StringLenthComparator implements Comparator<String> {
    public static final StringLenthComparator INSTANCE = new StringLenthComparator();

    private StringLenthComparator() {
    }
    

    @Override
    public int compare(String o1, String o2) {
        return o1.length() - o2.length();
    }
}

String a[] = {"a","bbb","cc"};
//传递实现接口的对象,其实也就是传递方法,输出长度顺序而不是字母序
Arrays.sort(a,new StringLenthComparator());

当然java8里提供了lambda表达式,不用实现接口很方便。

#!java
String a[] = {"a","bbb","cc"};
Arrays.sort(a,(o1,o2) -> {return o1.length() - o2.length();});

这里需要注意一下,为了阅读方便,lambda表达式最好把参数名写短一点。

7. 优先使用静态成员类

静态类与非静态类语法上区别基本上只有是否有static而已。
但是实际上,非静态类每一个实例都包含了一个额外只想外围对象的引用。这样会导致

  1. 浪费了时间和空间
  2. 垃圾回收器不知道回收外围类

如果一个类并没有访问到外围类的时候(比如BST中的Node),最好把内部类做成静态成员类。

posted @ 2018-03-28 18:00  zjmeow  阅读(176)  评论(0编辑  收藏  举报