写在前面
《Effective Java》原书国内的翻译只出版到第二版,书籍的编写日期距今已有十年之久。这期间,Java已经更新换代好几次,有些实践经验已经不再适用。去年底,作者结合Java7、8、9的最新特性,编著了第三版(参考https://blog.csdn.net/u014717036/article/details/80588806)。当前只有英文版本,可以在互联网搜索到PDF原书。本读书笔记都是基于原书的理解。
以下是正文部分
类与接口最佳实践(Classes and Interfaces)
实践15 最小化类与成员的访问范围(Minimize the accessibility of classes and members)
一个好的API设计,是最大化隐藏底层实现细节的,接口上只暴露最基本必要的信息。在Java中,实体的可访问性由两个要素决定:
- 实体的定义域:例如方法内部的变量以
{}
为边界 - 访问修饰符:
public, protected, private
对于一个Java文件而言,其主类(与文件名同名的类)的作用域有两种选择:Package可访问、public访问。通常我们思维定势地认为应该定义为public,实际上,如果能够Package作用域,那么它就应该只定义到Package作用域。
如果一个类只被另外的某一个类依赖使用的话,那么这个类应该挪到依赖类中,成为一个private class。
把一个类成员从默认作用域(Package访问)变成
protected
访问,实际是大大提升了作用范围和维护要求。应该尽量减少对protected
的使用。对于访问域缩小,有两个例外情况
- 重载方法的可访问性不能低于父类方法
- 接口的方法都是
public
作用域
通常,类的实例区变量都是非public的。否则就等同于失去了对实例中存储内容的控制,带有public实例区的类都是非线程安全的。这里有一个例外是public static final
类型的值,可以安全放心地向外暴露,他们通常以大小字母+下划线命名。但是注意,数组类始终是可以被改变的,因此不要这么做:
// NOT GOOD
public static final Thing[] VALUES = { ... };
// GOOD
private static final Thing[] PRIVATE_VALUES = { ... };
public static final List<Thing> VALUES = Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES));
// ALSO GOOD
private static final Thing[] PRIVATE_VALUES = { ... };
public static final Thing[] values() {
return PRIVATE_VALUES.clone();
}
实践16 将public类的成员变量设为private,并使用setter函数操作(In public classes, use accessor methods, not public fields)
虽然直接把类成员变量设置为public看起来代码很简洁,使用很方便。但是这付出的代价也很高:修改变量名必须修改API,不能控制变量的可修改性,数据修改时没法做其他操作。这些缺陷对于public类尤其明显,在Package作用域或者内部private类则不那么重要。
// Encapsulation of data by accessor methods and mutators
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; }
}
实践17 最小化可修改性(Minimize mutability)
Java标准库中包含多个不变类,例如String
,基础类型,BigInteger
,BigDecimal
。这么做的理由是不变类相比可变类在设计和实现上都更加简单(不容易出错,更加安全),且易于使用。下面列出5条准则,指导我们如何实现一个不可变类
- 不要再成员方法中修改对象的状态;
- 确保类不被继承;
方式1:声明类为final
方式2:构造函数置为private
,只提供public的静态工厂方法 - 将所有成员变量置为
final
; - 将所有成员变量置为
private
; - 不要与任何可变量进行关联访问。
代码块Complex
是一个不可变类的示例。这个类是一个复数类,注意加减乘除操作中,没有改变原有对象,而是直接new
一个新的,这被称为函数式(functional)访问。相对的,过程式(procedural)以及命令式(imperative)*编程会改变对象状态:
public class Complex {
private final double im;
private final double re;
public Complex(double re, double im) {
this.re = re;
this.im = im;
}
public double realPart() { return re; }
public double imaginaryPart() { return im; }
public Complex plus(Complex c) {
return new Complex(re + c.re, im + c.im);
}
public Complex minus(Complex c) {
return new Complex(re - c.re, im - c.im);
}
public Complex times(Complex c) {
return new Complex(re * c.re - im * c.im, re * c.im + im * c.re);
}
public Complex dividedBy(Complex c) {
double tmp = c.re * c.re + c.im * c.im;
return new Complex((re * c.re + im * c.im) / tmp, (im * c.re - re * c.im) / tmp);
}
@Override
public boolean equals(Object o) {
if (o == this) return true;
if (!(o instanceof Complex)) return false;
Complex c = (Complex) o;
return Double.compare(c.re, re) == 0 && Double.compare(c.im, im) == 0;
}
@Override
public int hashCode() {
return 31 * Double.hashCode(re) + Double.hashCode(im);
}
@Override
public String toString() {
return "(" + re + " + " + im + "i)";
}
}
优点:
- 简单:状态在创建时确定,并且一直保持。
- 安全:天然线程安全且不需要同步。可随意复用。
缺点: - 对任意不同的值,都需要创建新的对象,开销大
如果使用IDEA进行Java开发,我们通常能看到如下提示。
这正式因为String是不可变类,如果在循环里反复去操作,每次都要新建String对象,增大开销。一个可行的方法是使用String的可变形式(mutable companian)
StringBuilder
, StringBuffer
。
public String s() {
StringBuilder ret = new StringBuilder();
for (int i = 0; i < 100; i++) {
ret.append(i);
}
return ret.toString();
}
实践18 多使用组合,少使用继承(Favor composition over inheritance)
首先,这里的继承是指"extend"继承,不包括"implements"接口。继承是一个强大的代码复用手段,但是要谨防滥用。通常,在包内部使用继承是安全的,它保证了父类和子类都由同一开发者实现。
继承有如下缺点:
- 破坏了封装的特性。子类与父类绑定过深,父类修改对子类的影响很大。
1)父类的实现方式可能影响子类的结果
下面是一个不当示例。这个类继承自HashSet,可以统计集合自创建以来添加过多少个元素(注意不是当前大小,因为删除元素后,集合大小会减少)。
// Broken - Inappropriate use of inheritance!
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;
}
}
看起来代码没问题。可是当我们用addAll
尝试添加3个元素后,getAddCount
的值是6。这是因为HashSet
中,addAll
方法实际是借助add
方法实现的,因此出现了重复累加的情况。虽然我们可以修改代码适配,但谁知道下个Java版本addAll
是否还这么实现呢?
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("Snap", "Crackle", "Pop"));
System.out.println(s.getAddCount()); // will print 6
2)父类新增方法可能对子类产生不当影响。
设想子类继承父类,向容器内添加元素,处于安全考虑,子类重载了父类的add
方法要求所有元素添加前都进行某种预检。但是一旦父类新增一个添加元素的方法例如addTwo
,子类不变的情况下,很可能这新方法成为一个漏洞,引入非法元素。
就算你在子类中,不做方法重写,且很少添加新函数,同样也不安全。例如你在父类升级后,可能恰好有一个方法的函数签名和你子类的一样,这样对子类而言要不是隐藏,要不是重写,造成不可预知的行为。
综上,只有不继承才是最安全的。规避继承的最佳实践是为它们在类内创建private
对象,这种方式被称为组合(Composition)。如果一个类的方法都是调用组合类的方法,并且忠实返回,这称为转发(Forwarding)。
// Wrapper class - uses composition in place of inheritance
public class InstrumentedSet<E> extends ForwardingSet<E> {
private int addCount = 0;
public InstrumentedSet(Set<E> 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<E> implements Set<E> {
private final Set<E> s;
public ForwardingSet(Set<E> 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<E> 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> 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();
}
}
继承方式建议只在确实存在"is-a”关系的场景使用。Java标准库有时也违背了这个准则,例如Stack
显然不是Vector
,但是:
class Stack<E> extends Vector<E> {
...
}
实践19 为可继承精心设计并编写文档(Design and document for inheritance or else prohibit it)
(略)
实践20 用接口替代抽象类(Prefer interfaces to abstract classes)
接口和抽象类,在Java中都支持定义多个实现。两者的主要区别是,为了实现抽象类你必须继承它,且Java规定了只能单一继承;而任意类只要实现所有约定方法都可以配置接口,不用约束在继承体系内。
-
现有类可以快速地增加一个新的接口。例如很多类都实现了
Comparable
,Iterable
和Autocloseable
接口。但是,想增加一个父类是不可能的,必须调整继承体系。 -
接口是理想的多态定义方式。例如
Comparable
就是一个多态接口,允许一个类声明它的实例是根据其他可相互比较的对象排序的 - 接口允许建立非继承体系的框架。不是任何东西都能往继承里面套。例如歌手和作曲家,可能一个人既是歌手又是作曲家,我们用接口就可以方便实现。
public interface Singer {
AudioClip sing(Song s);
}
public interface Songwriter {
Song compose(int chartPosition);
}
public interface SingerSongwriter extends Singer, Songwriter {
AudioClip strum();
void actSensitive();
}
- 接口的良好封装性可以安全、强大地进行功能扩展。尤其是Java8引入默认方法(default method)后,降低了类与接口的耦合性,可以往接口增加方法而不必改动所有的实现类。
interface InterfaceA {
// 默认方法
default void foo() {
System.out.println("InterfaceA foo");
}
}
class ClassA implements InterfaceA {
}
public class Test {
public static void main(String[] args) {
new ClassA().foo(); // 打印:“InterfaceA foo”
}
}
当然,默认方法在实际使用中会有一些限制,尤其当你并不是接口的开发者的时候。一个好的实践是将抽象类与接口相结合,采用模板方法模式(Template Method pattern)实现一个抽象接口框架类。这种类的命名通常是“Abstract+[接口名]”。例如:AbstractList
。
// Java 标准库代码
public abstract class AbstractList<E> extends AbstractCollection<E> implements List<E> {
}
应用实例:
static List<Integer> intArrayAsList(int[] a) {
Objects.requireNonNull(a);
return new AbstractList<Integer>() {
@Override
public Integer get(int i) {
return a[i]; // Autoboxing(Item 6)
}
@Override
public Integer set(int i, Integer val) {
int oldVal = a[i];
a[i] = val; // Auto-unboxing
return oldVal; // Autoboxing
}
@Override
public int size() { return a.length; }
};
}
实践21 设计接口要考虑长远、周全(Design interfaces for posterity)
Java8新增的默认方法使得我们可以向接口添加新的方法而不影响现有的接口实现。但是这种方式具有一定的潜在问题。因此建议在早期设计接口时充分考虑,后期不要随意向接口添加方法。
- 既有实现并不知晓这些新增的方法,但是实际又是可用的,对既有实现的兼容性等存在潜在影响。例如Java8中
Collection
接口新增的removeIf
默认方法。它能实现函数式编程的特性,官方代码如下。但是对于既有实现类,可能有并发同步操作在操作前需要锁,调用该方法会造成不可预知的问题。
default boolean removeIf(Predicate<? super E> filter) {
Objects.requireNonNull(filter);
boolean removed = false;
final Iterator<E> each = iterator();
while (each.hasNext()) {
if (filter.test(each.next())) {
each.remove();
removed = true;
}
}
return removed;
}
- 新增默认方法只能保证编译通过,不保证没有运行时错误。
实践22 接口应仅用于描述类型(Use interfaces only to define types)
接口的作用是用于对象实例的类型定义,它应该描述对象实例可以做什么。除此之外,接口不应用于其他用途。
最常见的错误用途是常量接口(constant interface)。这种接口里面除了一堆final
的值域,没有定义任何方法。常量接口的实例如下。
// Constant interface antipattern - do not use!
public interface PhysicalConstants {
static final double AVOGADROS_NUMBER = 6.022_140_857e23;
static final double BOLTZMANN_CONSTANT = 1.380_648_52e-23;
static final double ELECTRON_MASS = 9.109_383_56e-31;
}
另外Java标准库中ObjectStreamConstants
也是一个常量接口,不要去效仿这种特殊情况。定义常量的合理方式是1)类成员变量、2)Enum
类、3)不能实例化的工具类(utility class),示例如下。
// Constant utility class
class PhysicalConstants {
public static final double AVOGADROS_NUMBER = 6.022_140_857e23;
public static final double BOLTZMANN_CONST = 1.380_648_52e-23;
public static final double ELECTRON_MASS = 9.109_383_56e-31;
private PhysicalConstants() {} // Prevents instantiation
}
注意:以上代码中的下划线 "_",从Java7引入,用于清晰展示数值,不影响数值本身。
实践23 用继承区分类行为而不是类内标签(Use interfaces only to define types)
如果类在函数行为上需要根据不同的实例类型做区分,那么最好用继承的方式写多个类,而不是在类内部用标签区分。下面是一个标签类(tagged classes)的示例:
// Tagged class - vastly inferior to a class hierarchy!
class Figure {
// Tag field - the shape of this figure
final Shape shape;
// These fields are used only if shape is RECTANGLE
double length;;
double width;
// This field is used only if shape is CIRCLE
double radius;
// Constructor for circle
Figure(double radius) {
shape = Shape.CIRCLE;
this.radius = radius;
}
// Constructor for rectangle
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);
}
}
enum Shape {
RECTANGLE,
CIRCLE
}
}
缺点显而易见:
- 代码冗杂,可读性差
- 无关成员变量造成内存占用上升
- 增加新的tag非常麻烦
重构代码如下:
abstract class Figure {
abstract double area();
}
class Circle extends Figure {
private final double radius;
Circle(double radius) {
this.radius = radius;
}
@Override
double area() {
return Math.PI * (radius * radius);
}
}
实践25 使用静态成员类(Favor static member classes over nonstatic)
嵌套类(nested class)应只服务于其上级类。如果业务范围超出上级类,则其应该提升为顶级类。有四种类型的嵌套类。除了第一种外,其他的都是内部类(inner class)。下面分析四种形式的最佳实践应用场景。
- 静态成员类
静态成员类具有对上级类的全部访问权限,包括private
域。它的访问方式与上级类的其他静态成员一致。不过如果声明为private
,则只能在上级类内部访问。通常,静态成员类用于辅助(helper class),在使用时需结合外部类。例如
// 外部类.静态成员类.成员
Calculator.Operation.PLUS
- 非静态成员类
虽然只比静态成员类少了个修饰符,但是区别很大。每个非静态成员类的实例都隐式地与一个外部类实例关联,1)这种关联在创建实例时绑定,不可修改;2)单独实例化非静态成员类是不可能的,3)占据空间,影响效率和垃圾回收。通常,非静态成员类用于适配器(Adapter),即将某个类以另外的形式展示。例如Map
展示位Collections
。
// Typical use of a nonstatic member class
class MySet<E> extends AbstractSet<E> {
@Override
public Iterator<E> iterator() {
return new MyIterator();
}
private class MyIterator implements Iterator<E> {}
}
- 匿名类
匿名类是没有名称的,只在使用时实例化。除了基础类型和String的常量,它不能包含任何static
成员。缺点是除了声明的地方没办法显式实例化;不能使用instanceof
;不能extends
多个接口,或者继承一个类外加实现一个接口。
在lambda
之前,匿名类常用于创建函数对象(function objects)或者过程对象(process objects)。另外,匿名类还可用于静态工厂方法。 - 本地类
不常用。
实践25 一个源文件只包含一个顶层类(Limit source files to a single top-level class)
尽管Java编译器允许一个源文件包含多个顶层类,但是不建议这么做。