笔记《Effective Java》03(下):类和接口
1、前言
《Effective Java》这本书可以说是程序员必读的书籍之一。这本书讲述了一些优雅的,高效的编程技巧。对一些方法或API的调用有独到的见解,还是值得一看的。刚好最近重拾这本书,看的是第三版,顺手整理了一下笔记,用于自己归纳总结使用。建议多读一下原文。今天继续整理第三章节:类和接口,下篇。
2、与抽象类相比,优先选择接口
要定义支持多种实现的类型,Java有2种机制:接口和抽象类。
- 抽象类:实现抽象类定义的类型,这个类必须是抽象类的子类。因为Java只允许单继承,对抽象类的这种限制严重制约了将其用于类型定义。
- 接口:接口只要定义了所有必需的方法,并遵守了其通用约定,任何类都可以实现一个接口,不管他处于类层次结构种的什么位置。2.1、使用接口的好处很容易改造现有的类使其实现一个新的接口。所要做的就是类声明中添加一个implements子句,并添加必需的方法实现。典型的如AutoCloseable接口被加入时,很多现有的类都被加以改造,实现了这个接口。
接口是定义mixin(混合类型)的理想选择。mixin是一个类型,类在实现其“主要类型”之外,还可以实现一个这样的类型,以表明它能够提供某个可选行为。典型的如Comparable就是一个mixin接口,类实现这个接口是因为它允许将可选的功能“混合”到类型的主要功能中。抽象类不能用于定义mixin,因为一个类不能有多个超类,而且类层次结构中也没有适合插入mixin的位置。
接口允许构建非层次结构的类型框架。例如,有2个类Singer和SongWriter:
代码语言:java复制public interface Singer {
//唱歌
AudioClip sing(Song s);
}
public interface SongWriter {
//作歌
Song compose(boolean hit);
}
如果有一个接口既需要SInger方法,同时需要SongWriter方法,那么可以:
public intreface SingerSongWriter extends Singer, SongWriter {
//弹奏
AudioClip strum();
//激情表演
void actSensitive();
}
使用抽象类未必能满足这样的灵活性,因为Java只允许单继承。
2.2、使用骨架实现类
如果一个接口方法很明显可以基于其他接口方法实现,应该考虑以默认方法的形式为程序员提供实现帮助。但是默认方法也存在一定的限制,尽管许多接口都指定了 Object 方法(如 equals 和 hashCode)的行为,但是不允许为它们提供默认方法。此外,不允许接口包含实例字段或非公共静态成员(私有静态方法除外)。最后,你无法将默认方法添加到你无法控制的接口。
不过,通过提供一个与接口配合的抽象的“骨架实现”类,可以将接口和抽象类的有点结合到一起。接口用来定义类型,可能还会提供一些默认方法,而骨架实现类负责在基本接口方法上实现其余的非基本接口方法。
通常,骨架实现会命名为AbstractInterface,如AbstractCollection、AbstractSet、AbstractList 和 AbstractMap。
骨架实现的美妙之处在于,他们为抽象类提供了实现上的帮助,但又不强加“抽象类被用作类型定义时”所特有的严格限制。对于接口的大多数实现来讲,扩展骨架实现类是个很显然的选择,但并不是必须的。如果预置的类无法扩展骨架实现类,则该类始终可以直接实现该接口。该类仍然受益于接口本身存在的任何默认方法。此外,骨架实现类仍然能够有助于接口的实现。实现了这个接口的类可以把对于这个接口方法的调用,转发到一个内部私有类的实例上,这个内部私有类扩展了骨架实现类。这种方法被称作模拟多重继承(simulated multiple inheritance),它与第 18 项中讨论的包装类模式密切相关。这项技术具有多层继承的绝大多数优点,同时又避免了相应的缺陷。
骨架实现的一个典型例子:
代码语言:java复制// Skeletal implementation class
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();
}
}
注意,骨架实现无法在 Map.Entry 接口中实现或作为子接口实现,因为不允许使用默认方法重写 Object 方法,如 equals,hashCode 和 toString。
骨架实现是为集成而设计的,所以应该要遵守所有的设计和文档编写的准则。好的文档在骨架实现中是绝对必要的,无论它是由接口上的默认方法组成还是单独的抽象类。
骨架实现还有个小变体——简单实现。如果AbstractMap.SimpleEntry。3、为传诸后世而设计接口
Java8之前,不可能在不破坏现有实现的情况下向接口中添加方法。而Java引入了默认方法,目的就是允许向现有的接口中添加方法,但是向现有的接口中添加新方法还是充满风险的。
Java8向核心集合接口中添加了许多新的默认方法,主要是为了方便使用Lambda表达式。但是想编写一个默认方法,使其能够保证每个可以想到的实现的所有不变式,未必总能做到。
例如:考虑在Java8中向Collection接口中添加removeIf方法。大致其声明如下:
代码语言:java复制default boolean removeIf(Predicate<? super E> filter) {
Objects.requireNonNull(filter);
boolean result = false;
for(Iterator<E> it = iterator(); it.hasNext();){
if(filter.test(it.next())){
it.remove();
result = true;
}
}
return result;
}
此时如果客户在一个线程中调用了removeIf方法,而另一个线程在并发修改这个集合。则有可能导致ConcurrentModificationException或其他未定义的行为。为了防止这种情况发生,例如由Collections.sunchronizedCollection返回的包私有类,JDK维护者不得不重写默认的removeIf方法。
在存在默认方法的情况下,一个接口的现有实现可能在编译时没有错误或警告,但在运行时失败了。除非必须这样做,否则应该避免使用默认方法向现有接口中添加新方法哦。如果确实需要使用,应该认真思考默认方法实现是否会破坏现有的接口实现。谨慎设计接口仍然是极其重要的。
4、接口仅用于定义类型
当一个类实现了一个接口时,该接口可以充当引用这个类的实例的类型(type)。类实现了某个接口,就表明客户端可以使用这个类的实例实施某些动作。为其他任何目的定义接口都是不合适的。
4.1、不使用常量接口
有一种接口没有满足这一点,就是所谓的常量接口。这种接口不包含任何方法,而只由静态的 final 字段组成,每个字段都导出一个常量。需要使用这些常量的类会实现该接口,这样就不用通过类名来限定常量名了。
下面是一个例子:
代码语言:java复制public interface PhysicalConstants {
//阿伏伽德罗常数(1/mol)
static final double AVOGADROS_NUMBER =6.022_140_857e23:
//玻尔兹曼常数(J/K)
static final double BOLTZMANN_CONSTANT=1.380648_52e-23;
//电子质量(kg)
static final double ELECTRON_MASS =9.109_383_56e-31;
}
常量接口模式是对接口的不恰当使用。类在内部使用了一些常量,这属于实现细节。实现常量接口会导致实现细节泄露到该类的导出API中。类实现了常量接口,对这个类的用户来说是没什么价值的。有时反而会让用户困惑。更糟糕的是,它代表了一种承诺:如果在未来的版本中,类被修改了,不再需要使用这些常量,但是为了确保二进制兼容,它仍然必须实现这个接口。如果一个非final类实现了一个常量接口,那么它的所有子类的命名空间都会被接口中的常量污染。
4.2、使用常量工具类
如果想导出常量,有几种合理的选择。如果这些常量与现有的类或接口密切相关,那么应该将其添加到类或接口中。例如,所有的数值基本类型的封装类,如Integer和Double,都导出了MIN_VALUE和MAX_VALUE常量。示例:
代码语言:java复制public class PhysicalConstants {
private PhysicalConstants() {
}
public static final double AVOGADROS_NUMBER =6.022_140_857e23;
public static final double BOLTZMANN_CONSTANT=1.380648_52e-23;
public static final double ELECTRON_MASS =9.109_383_56e-31;
}
总而言之,接口应该仅用于定义类型。如果只是要导出常量,不应该使用接口。
5、优先使用类层次结构而不是标记类
5.1、不使用标记类
有时我们可能会遇到这样的类,他的实例有两种或更多的种类,类中会包含一个标记字段来指示这个实例的具体种类。如:
代码语言:java复制public class Figure {
enum Shape { RECTANGLE, CIRCLE };
final Shape shape;
// 仅当 shape 为RECTANGLE时,才使用这些字段
double length;
double width;
// 仅当 shape 为CIRCLE时,才使用这个字段
double radius;
// 圆形的构造器
public Figure(double radius) {
shape = Shape.CIRCLE;
this.radius = radius;
}
// 矩形的构造器
public Figure(double length, double width) {
shape = Shape.RECTANGLE;
this.length = length;
this.width = width;
}
double area(){
switch (shape) {
case RECTANGLE:
return length * width;
case CIRCLE:
return Math.PI * (radius * radius);
default:
throw new AssertionError(shape);
}
}
}
这样的标记类存在很多缺点。多个实现混杂在一个类中,会进一步影响可读性。因为实例还要负担属于其他种类的不相关字段,内存占用也会增加。由于构造器不能初始化不相关字段,所以字段不能设置为final类型,这样会带来更多的样板代码。而如果添加了新的类型,还必须记住每个switch语句的case。标记类过于冗长、容易出错且效率低下。
5.2、使用层次结构
将标记类转为类层次结构,定义一个抽象类,并为标记类中的行为取决于标记值的每个方法定义一个抽象方法。如:
代码语言:java复制public abstract class Figure {
abstract double area();
}
// 圆形类
class Circle extends Figure {
final double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
double area() {
return Math.PI * (radius * radius);
}
}
// 矩形类
class Rectangle extends Figure {
final double length;
final double width;
public Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
@Override
double area() {
return length * width;
}
}
类层次结构优点:
- 没有了样板代码,每个种类的实现都由自己的类负责,而且这些类都不存在不相关数据带来的负担。所有字段都是final类型。不会因为缺少switch case而导致运行失败。
- 还可以用来反映类型之间的自然层次关系,有助于增加灵活性,并有助于更好的编译时类型v检查。
6、与非静态成员类相比,优先选中静态成员类
嵌套类是定义在另一个类中的类。嵌套类包含四种:静态成员类,非静态成员类,匿名类,以及局部类。
6.1、静态成员类
静态成员类是最简单的一种嵌套类 。 最好把它看作是普通的类,只是碰巧被声明在另一个类的内部而己,它可以访问外围类的所有成员,包括那些声明为私有的成员 。
静态成员类的一种常见用法是作为公有的辅助类,只有与它的外部类一起使用才有意义 。 例如,以枚举为例,它描述了计算器支持的各种操作 。Operation 枚举应该是 Calculator 类的公有静态成员类,之后 Calculator 类的客户端就可以用诸如 Calculator.Operation.PLUS 和Calculator.Operation.MINUS 这样的名称来引用这些操作 。
6.2、非静态成员类
从语法上讲,静态成员类和非静态成员类之间唯一的区别是,静态成员类的声明中包含修饰符 static 。在非静态成员类的实例方法内部,可以调用外围实例上的方法,或者利用修饰过的 this ( qualified this )构造获得外围实例的引用。如果嵌套类的实例可以在它外围类的实例之外独立存在,这个嵌套类就必须是静态成员类:在没有外围实例的情况下,要想创建非静态成员类的实例是不可能的 。
当非静态成员类的实例被创建的时候,它和外围实例之间的关联关系也随之被建立起来;而且,这种关联关系以后不能被修改 。 通常情况下,当在外围类的某个实例方法的内部调用非静态成员类的构造器时,这种关联关系被自动建立起来 。 使用表达式 enclosing Instance.new MemberClass(args)来手工建立这种关联关系也是有可能的,但是很少使用 。 正如你所预料的那样,这种关联关系需要消耗非静态成员类实例的空间,并且会增加构造的时间开销 。
代码语言:java复制public class MySet<E> extends AbstractSet<E> {
@Override
public Iterator<E> iterator( {
return new MyIterator();
}
private class MyIterator implements Iterator<E> {
// .....
}
}
如果声明成员类不要求访问外围实例,就要始终把修饰符 static 放在它的声明中, 使它成为静态成员类,而不是非静态成员类 。 如果省略了 static 修饰符,则每个实例都将包含一个额外的指向外围对象的引用 。 如前所述,保存这份引用要消耗时间和空间,并且会导致外围实例在符合垃圾回收时却仍然得以保留 。 由此造成的内存泄漏可能是灾难性的 。 但是常常难以发现,因为这个引用是不可见的 。
6.3、匿名类
顾名思义,匿名类是没有名字的 。 它不是外围类的一个成员 。 它并不与其他的成员一起被声明,而是在使用的同时被声明和实例化 。 匿名类可以出现在代码中任何允许存在表达式的地方。 当且仅当匿名类出现在非静态的环境中时,它才有外围实例 。 但是即使它们出现在静态的环境中,也不可能拥有任何静态成员,而是拥有常数变量( constant variable ),常数变量是 final基本型,或者被初始化成常量表达式的字符串域 。
匿名类的运用受到诸多的限制 。 除了在它们被声明的时候之外,是无法将它们实例化的 。 不能执行 instanceof 测试或者做任何需要命名类的其他事情 。 无法声明一个匿名类来实现多个接口,或者扩展一个类,并同时扩展类和实现接口 。 除了从超类型中继承得到之外,匿名类的客户端无法调用任何成员 。 由于匿名类出现在表达式中,它们必须保持简短,否则会影响程序的可读性 。
在 Java 中增加lambda之前,匿名类是动态地创建小型函数对象( function object)和过程对象( process Object)的最佳方式,但是现在会优先选择 lambda。匿名类的另一种常见用法是在静态工厂方法的内部。
6.4、局部类
局部类是四种嵌套类中使用最少的类 。 在任何“可以声明局部变量”的地方,都可以声明局部类,并且局部类也遵守同样的作用域规则 。 局部类与其他三种嵌套类中的每一种都有一些共同的属性。 与成员类一样,局部类有名字,可以被重复使用 。 与匿名类一样,只有当局部类是在非静态环境中定义的时候,才有外围实例,它们也不能包含静态成员 。 与匿名类一样,它们必须非常简短,以便不会影响可读性 。
6.5、小结
总而言之,共有四种不同的嵌套类,每一种都有自己的用途。 如果一个嵌套类需要在单个方法之外仍然是可见的,或者它太长了,不适合放在方法内部,就应该使用成员类 。 如果成员类的每个实例都需要一个指向其外围实例的引用,就要把成员类做成非静态的;否则 ,就做成静态的。 假设这个嵌套类属于一个方法的内部,如果你只需要在一个地方创建实例, 并且已经有了一个预置的类型可以说明这个类的特征,就要把它做成匿名类;否则,就做成局部类。
7、将源文件限制为单个顶层类
永远不要把多个顶层类或者接口放到单个源文件中
尽管Java编译器让你在单个源文件中定义多个顶层类,但是这么做没有任何益处。危险性来自这个事实:在单个源文件中定义多个顶层类,使得为一个类提供多个定义变得可能。使用哪个定义,受传递到编译器的源文件顺序的影响。
举个例子,一个Main类,这个类引用另外两个顶层类(Utensil和Dessert)的成员:
代码语言:java复制public class Main {
public static void main(String[] args) {
System.out.println(Utensil.NAME + Dessert.NAME);
}
}
现在假设你在命名为Utensil.java的单个源文件中,同时定义Utensil和Dessert:
代码语言:java复制// 同个文件中定义了两个类。永远不要这么做!
class Utensil {
static final String NAME = "pan";
}
class Dessert {
static final String NAME = "cake";
}
当然,主程序打印了pancake。
现在假设你恰巧在命名为Dessert.java的另外一个源文件中,定义了相同的两个类:
代码语言:java复制// 同个文件中定义了两个类。永远不要这么做!
class Utensil {
static final String NAME = "pot";
}
class Dessert {
static final String NAME = "pie";
}
如果你足够幸运用命令javac Main.java Dessert.java编译这个程序,那么编译会失败,而且这个编译器将会告诉你:你已经多次定义了Utensil and Dessert类。确实如此,因为编译器将会编译 Main.java,而且当它看见Utensil(它早于Dessert的引用)的引用,它将在Utensil.java中寻找这个类,然后发现了Utensil和Dessert。当编译器遇见了命令行上的Dessert.java,它也将引入这个文件,这造成了它同时遇见了Utensil和Dessert。
如果你使用命令javac Main.java或者javac Main.java Utensil.java编译这个程序,它表现为你编写Dessert.java文件之前的行为,即打印pancake。但是如果你使用javac Dessert.java Main.java编译这个程序,它将打印potpie。因此这个程序的行为受到源文件传递到编译器顺序的影响,这是明显不可接受的。
解决这个问题,就像分解这个顶层类(在我们例子情形中,Utensil和Dessert)到不同的源文件这么简单。如果你很想把多个顶层类放置到单个文件中,考虑使用静态成员类 作为分解类到不同源文件的替代方法。如果类从属于另外一个类,把他们变成静态成员类通常是更好的替代方法,因为它增强了可读性,而且通过声明这个类为私有来减少类的可访问性。以下是使用静态成员类我们的例子看上去的样子:
代码语言:java复制// 静态成员类而不是多个顶层类
public class Test {
public static void main(String[] args) {
System.out.println(Utensil.NAME + Dessert.NAME);
}
private static class Utensil {
static final String NAME = "pan";
}
private static class Dessert {
static final String NAME = "cake";
}
}