第四章:类与接口
第一条:使类和成员的可访问性最小化
一个设计良好的组件应该隐藏所有实现细节,只把必须要提供的功能通过API暴露出来,组件之间通过API进行通信。
组件与组件之间的耦合越低越好,一个组件对于其他组件,除了API通信,应该一无所知。所以每个类或成员应该尽可能的不可访问。
对于顶层的类和接口,不加任何修饰符则默认为包级私有,public修饰则为公共的,不能用其他修饰符修饰。
- 包级私有的类和接口,属于实现的一部分,可以修改和替换。
- public的类和接口,属于公开API,有义务一直支持他们。
对于成员,有四种可能的级别:private,package-private(默认), protected,public。
- 公共类的实例字段很少情况下采用public修饰,带有公共可变字段的类通常不是线程安全的。
- 对于类的静态常量(不可变对象),可以用 public static final修饰。但是要注意非0长度的数组对象总是可变的,不能这么写:
public static final Thing[] VALUES = { ... };
可以这么写:
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();
}
总而言之就是要确认public static final修饰的对象是不可变的。因为对于引用类型对象而言,final指的是指向的内存地址不能变,但具体对象的状态是可以更改的,所以可变对象仍然会引发数据安全问题。
第二条:在公共类中使用访问方法而不是公共属性
考虑实现一个只有属性、没有行为的类,比如
class Point {
public double x;
public double y;
}
直接暴露属性对于私有的内部类、包级私有的类来说是可以忍受的,但是对于公共类来说不要这么做。更好的方法是提供get、set方法,这也是一个比较常见的做法了:
class Point {
private double x;
private double y;
public Point(double x, double y) {
this.x = x;
this.y = y;
}
public double getX() { return x; }
public double getY() { return y; }
public void setX(double x) { this.x = x; }
public void setY(double y) { this.y = y; }
}
除非这个属性是不可变的,否则不要直接暴露属性,而应该通过接口来访问!!
第三条:最小化可变性
不可变类比起可变类更易于设计、实现和使用,更加不容易出错和安全。
实现不可变类需要遵循的规则:
- 不要提供修改对象状态(属性)的方法
- 确保这个类不能被继承(用final修饰类;或构造方法私有化,添加公共静态工厂方法。)
- 所有字段设为final
- 所有字段设为private
- 确保对可变组件的互斥访问,不要暴露可变对象字段
函数式方法
当我们对一个对象进行操作时,比如我们希望在一个把一个数加1,我们可以操作这个数本身,把他加1并返回;也可以获得一个新的数,这个数的值是原来的数+1,原来的数不变。
函数式方法就是上述的第二种,比如对于一个复数类Complex,我们编写一个plus方法:
public Complex plus(Complex c) {
return new Complex(re + c.re, im + c.im);
}
这就是函数式方法。对于不可变对象,比如String,我们在操作的时候也是返回新对象。
不可变对象的优缺点
优点:
- 不可变对象是线程安全的。
- 不可变对象不需要拷贝。
- 为其他对象提供了很好的构件,易于维护。
- 无偿提供原子失败机制。
缺点:
- 每个不同的值都需要一个单独对象,对于大型对象来说更改操作开销大。
解决方案:
可以提供一个可变伙伴类,比如String不可变,但是StringBuilder和StringBuffer是可变的。
总之:
- 除非有充分的理由使类成为可变类,否则类应该是不可变的。(如无必要,不要提供set方法)
- 如果一个类不能设计为不可变类,那么也要尽可能地限制它的可变性 。
- 除非有充分的理由不这样做,否则应该把每个属性声明为私有 final 的。
- 构造方法应该创建完全初始化的对象,并建立所有的不变性。 不要提供一个“reinitialize”方法,使对象可以被重用。
(虽然这条建议很有道理,但是在之前写的Java商城项目里,对于POJO类对象的操作有很多增删改查,主流的方法好像还是提供set方法,而不是在更改的时候创建一个新的对象返回)
第四条:组合优于继承
跨越包的边界继承,是危险的。继承打破了封装,父类如果不断变化,子类可能会被破坏,所以子类要随着父类的变化而进行更新。
由于子类的脆弱性,建议用组合代替继承。
组合
组合:不要继承一个现有的类,而应该给你的新类增加一个私有属性,该属性是 现有类的实例引用。
转发:新类中的每个实例方法调用现有类的包含实例上的相应方法并返回结果。
对于两个类A和B,只有当两者之间确实存在“is - a”关系的时候,才应该用继承;如果是“has-a”关系,则应该用组合。
第五条:要么设计继承并提供文档说明,要么禁止继承
专门为了继承而设计的类应该具有良好的文档说明,文档要精确地描述覆盖每个方法所带来的影响。比如在java.util.AbstractCollection的规范:
public boolean remove(Object o)
应该用Javadoc标签@implSpec生成,表示实现要求。在这个remove方法里调用了迭代器的remove,这个方法是可以被覆盖的,那么就应该说明覆盖iterator方法将会带来的影响。
如何确定一个方法是否应当被继承和改写?唯一方法就是编写子类,编写三个子类就可以测试一个可扩展的类。如果遗漏了关键的受保护成员,尝试编写子类就会发现这个问题;如果多个子类都没有用到protected成员,就应该考虑改为private。
注意:构造方法不能调用可被覆盖的方法,举个例子来说明:
public class Father{
public Father(){
func();
}
public void func(){
}
}
public final class Son{
private final Instant instant;
Son(){
instant = Instant.now();
}
@Override
public void func(){
System.out.println(instant);
}
public static void main(String[] args){
Son son = new Son();
son.func();
}
}
书里的原话是“你可能会期待这个程序会打印两次日期”,其实我并没有这么期待🤣,做了个简单的小实验才发现,发现自己原先在这一块没搞清楚,事实上当调用子类构造函数时会先调用缺省的父类构造函数,如果父类构造函数里有被子类覆盖的方法,那么调用的是覆盖后的方法,而不是父类的原方法。
public class Father{
public Father(){
func();
}
public void func(){
System.out.println("father");
}
}
public final class Son{
Son(){
func();
}
@Override
public void func(){
System.out.println("son");
}
public static void main(String[] args){
Son son = new Son();
}
}
原本以为结果会是:father son,结果打印了两次son。所以Son的构造函数应该是直接在开头加入了父类缺省构造函数的方法体,被修改成了:
func();
func();
那么回到之前的例子,当构造方法调用了被子类覆盖的方法,在第一次调用func的时候Instant变量还没有被赋值成now(),值为null,这时候如果func方法对instant进行操作,非常可能引发空指针异常。
因此,构造方法不用调用可被覆盖的方法。
第六条:接口优于抽象类
接口的好处在于:
1. 更加易扩展
当我们想引入一些接口时,只需要在原有类的声明中增加一个implements子句,并且添加一些必须要实现的方法。但是对于继承来说,Java只允许单继承,如果这个类已经继承了某个类,而我们又想引入新的父类,只能把抽象类放到更高的层级,使其成为祖先类,但这样有可能会伤害类的继承层次,强迫所有后代类都扩展。
2. 接口是定义mixin(混合类型)的理想选择
mixin类型是值,类除了实现它的“基本类型”之外(这个基本类型,我认为比较适合用继承抽象类的方式来定义),还可以实现这个mixin类型,表明提供了某些可供选择的行为,比如Comparable、Serializable这样的功能,他们并不是类的核心功能,而是一些附加的可供选择的行为。
3. 接口允许构造非层次结构的类型框架
简单来说,就是用接口给类进行若干个功能的增强,比较灵活。
通过对接口提供一个抽象的骨架实现类,可以把接口和抽象类的优点结合起来。骨架实现类型负责实现除基本类型接口方法之外,剩下的非基本类型接口方法。
编写骨架实现类的时候,必须认真研究接口,确定哪些方法是最基本的,其他方法可以根据它们来实现。基本方法将成为骨架实现类中的抽象方法。然后,在所有可以在基本方法之上直接实现的方法提供缺省方法(不能为Object类的方法提供缺省方法)。最后,如果基本方法和缺省方法还没有覆盖接口,就要编写一个类,声明实现接口,并实现所有剩下的接口方法。
比如Map.Entry的骨架实现类可以这么实现:
public abstract class AbstractMapEntry<K, V> implements Map.Entry{
@Override
public V setValue
}
@Override
public booelean equals(Object o){ //pass }
@Override
public int hashCode(){ //pass }
@Override
public String toString(){ //pass }
第七条:为后代设计接口
Java8中增加了接口的default方法。在此之前,接口中的方法是不能给出实现的,所有方法必须在实现该接口的类中给出定义。这样的话,一旦接口扩展了新方法,所有实现类必须更新;有了default方法,如果这个新方法有默认实现,则实现类不给出实现也不会报错。
即使有default方法,还是会出现一些问题。比如Collections接口扩展了removeIf方法(有默认实现),SynchronizedCollection实现了Collections接口,且没有覆盖removeIf方法,那么SynchronizedCollection类的对象是可以调用removeIf方法的,但是这个方法的实现并不符合该类的语义,因为这是一个同步类,而Collections给出的removeIf默认实现是不带同步机制的。
所以即使有了default方法,谨慎设计接口仍然是至关重要的。
第八条:接口只用于定义类型
有一种接口叫做常量接口,如下:
public interface PhysicalConstants{
static final double AVOGADROS_NUMBER = 6.022_140_857E23;
static final double BOLTZMANN_CONSTANT = 1.380_648_52E-23;
}
这个接口没有方法,只有静态常量字段(接口的字段默认且只能是public static final的),这就是常量接口。**常量接口模式是对接口的不良使用,不要这么做。**常量接口可以直接导出常量,实现了这个接口的类可以直接访问这些常量,不需要用类名修饰,但是会污染子类的命名空间。
如果需要导入常量,比较好的做法是:
- 枚举类型
- 工具类(不可实例化)的静态变量
- 静态导入:import static package.className.* (相当于引入这个类里的所有静态变量,如果要导入的静态变量特别多,每次访问都要用类名修饰很麻烦,就可以用静态导入)
第九条:类层次优于标签类
标签类就是一个类能够表示多种对象,比如Figure类,初始化的时候如果只给一个int参数那么就创建一个圆,如果给了两个int就创建一个矩形。
从设计模式的角度来说,标签类破坏了类的单一职责原则,而且实现的时候也比较冗长,最好别这么做(有了继承这样的特性,一般似乎也不会这么做)。
比较好的方式就是用继承,Circle类和Rectangle类继承Figure类。
第十条:静态成员类优于非静态成员类
成员类属于内部类的一种,被定义成类的成员,仅为外部类服务。
静态成员类和非静态成员类的区别,其实跟静态成员变量与非静态成员变量的区别是一样的。static修饰的成员类可以独立于外部类实例而存在,非static的成员类依赖于外部类对象。
非静态内部类仅在每个内部类实例都和一个外部类实例关联的时候才使用,比如调用map.iterator()的时候,我们要用Iterator类的对象去遍历一个map对象,这个Iterator对象是要依赖于特定的Map类实例的,所以Map类的MapIterator类要设计为nonstatic的,如下:
public class myMap<K,V> extends AbstractMap<K, V>{
// ...
@Override
public Iterator<Map.Entry<K,V>> iterator(){
return new MapIterator();
}
private class MapIterator implements Iterator<Map.Entry<K,V>>{
// pass
}
}
如果成员内部类不需要访问外部类的实例,就应当被声明为静态内部类。因为非静态内部类的每个实例都要包含一个额外的指向外围对象的引用,保存这份引用要消耗时间和空间,并且会导致内存泄漏问题。
此外,书里还谈到了其他两种内部类:匿名内部类和局部内部类。匿名内部类可以用于回调、Comparator对象、Runnable接口对象等,现在用lambda更方便;局部内部类在方法体中,比较少用。
第十一条:限制源文件为单个顶级类
一个java文件中可以定义多个顶级类(非内部类),但是只有一个可以用public修饰。
在一个原文件中定义多个顶级类,风险很大,因为这会导致给一个类提供多个定义,哪一个定义会被用到取决于源文件被传给编译器的顺序。
解决方法:每个源文件只有单个顶级类;或者用静态内部类。