https://github.com/clxering/Effective-Java-3rd-edition-Chinese-English-bilingual/tree/dev/Chapter-4
准则一 减少类和成员的可访问性
- 如果一个方法覆盖了超类方法,那么它在子类中的访问级别就不能比超类 [JLS, 8.4.8.3] 更严格
- 非零长度的数组总是可变的,因此对于类来说,拥有一个公共静态 final 数组字段或返回该字段的访问器是错误的。如果一个类具有这样的字段或访问器,客户端将能够修改数组的内容。这是一个常见的安全漏洞来源。
方式一:设置不可变
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();
}
注:在开发的时候对于字段的访问性要做一定思考,如果能够选择私有就尽量不暴露给外部访问。
准则二 在公共类中,使用访问器方法,而不是公共字段
如果是公共的字段,意味着客户端可以随意的修改,我们不能进行任何发辅助操作。但是如果使用get 或者 set我们可以写出下面的代码:
class Point {
private double x;
private double y;
public void setX(double x) {
if (x < 0) {
throw new IllegalArgumentException("x can not be negative");
}
this.x = x;
}
}
setX中我们可以来控制X不能小于0,但是如果直接定义成public是不安全的,我们没有办法做这个限制,同样的我们可以在get方法中进行一些辅助性操作。
准则三 减少可变性
不可变类是实例不能被修改的类。每个实例中包含的所有信息在对象的生命周期内都是固定的,因此永远不会观察到任何更改。
设计一个不可变类,请遵循以下 5 条规则:
1 不要提供修改对象状态的方法
2 确保类不能被继承。 也就是使用final修饰类。
3 所有字段用 final 修饰。
4 所有字段设为私有。
5 确保对任何可变组件的独占访问。
不可变对象本质上是线程安全的;它们不需要同步。
不可变类可以提供静态工厂(Item-1),这些工厂缓存经常请求的实例,以避免在现有实例可用时创建新实例。
你不仅可以共享不可变对象,而且可以共享它们的内部实现。例如BigInteger:
final int[] mag;
public BigInteger negate() {
return new BigInteger(this.mag, -this.signum);
}
关于可序列化性,应该提出一个警告。如果你选择让不可变类实现 Serializable,并且该类包含一个或多个引用可变对象的字段,那么你必须提供一个显式的 readObject 或 readResolve 方法,或者使用 ObjectOutputStream.writeUnshared 或 ObjectInputStream.readUnshared 方法,即使默认的序列化形式是可以接受的。否则攻击者可能创建类的可变实例。
最佳实践
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
}
由于String 是不可变类,所以Java Lib为我们提供了StringBuilder 来操作得到不可变类String。书中也提到,如果你能够准确地预测客户端希望在不可变类上执行哪些复杂操作,那么包私有可变伴随类方法就可以很好地工作。如果不是,那么你最好的选择就是提供一个公共可变伴随类。
准则四 优先选择复合而不是继承
子类的功能正确与否依赖于它的超类的实现细节。超类的实现可能在版本之间发生变化,如果发生了变化,子类可能会崩溃,即使子类的代码没有被修改过。
看下面这个例子,入股我们使用继承,乍一看不会发现问题:
public 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(java.util.Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public static void main(String[] args) {
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(Arrays.asList("Snap", "Crackle", "Pop"));
System.out.println("s.addCount = " + s.addCount);
}
}
让我们看看运行结果:
明明我们只是添加了3个元素,为什么会有6个元素了,那是因为我们的实现依赖于父类,但是父类的实现我们看看addAll是怎样实现的呢?
看到这里获取明白了,在添加单个元素的时候数量又被加了一遍,当然我们可能不会犯这种错误,这个例子只是为了说明我们继承一个来,依赖于父类的实现的时候,可能会疏忽导致程序出现错误,这种错误往往排查起来比较困难。如果我们换一种实现方式:
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();
}
@Override
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();
}
}
这样实现有两个优点:
- 首先可以避免前面提到的问题。
- 其次我们会发现Set是接口,接口往往是比较稳定的,但是具体实现可能随着版本的更新有所变化,这样我们不用依赖于具体实现,我们可以随时更改,灵活性增强了
这就是聚合带来的好处,它的灵活性更强,且不严重依赖于某个具体的类
只有子类确实是超类的子类型的情况下,继承才合适。换句话说,两个类 A、B 之间只有 B 满足「is-a」关系时才应该扩展 A。如果你想让 B 扩展 A,那就问问自己:每个 B 都是 A 吗?如果不能对这个问题给出肯定回答,B 不应该扩展 A;如果答案是否定的,通常情况下,B 应该包含 A 的私有实例并暴露不同的 API:A 不是 B 的基本组成部分,而仅仅是其实现的一个细节。
准则五 继承要设计良好并且具有文档,否则禁止使用
好的 API 文档应该描述一个给定的方法做什么,而不是如何做?是的,它确实违背了!这是继承违反封装这一事实的不幸结果。要为一个类编制文档,使其能够安全地子类化,你必须描述实现细节,否则这些细节应该是未指定的。
构造函数不能直接或间接调用可重写的方法,这个是什么意思呢?
public static final class Sub extends Super {
// Blank final, set by constructor
private final Instant instant;
Sub() {
instant = Instant.now();
}
// Overriding method invoked by superclass constructor
@Override
public void overrideMe() {
System.out.println(instant);
}
}
public static class Super {
// Broken - constructor invokes an overridable method
public Super() {
overrideMe();
}
public void overrideMe() {
}
}
这个例子就可以看到如果在超类构造函数调用了重写的方法付可能会发生错误,例如我们这个时候如果在后面继续使用instant这个对象,那么将会产生空指针错误,原因在于在调用父类构造函数的时候,这个变量还没有进行初始化。
因此如果一个具体的类没有实现一个标准的接口,那么你可能会因为禁止继承而给一些程序员带来不便。如果你认为必须允许继承此类类,那么一种合理的方法是确保该类永远不会调用其任何可重写的方法
Cloneable 和 Serializable 在继承中会带来什么问题?
问题:如果一个类实现了 Cloneable 接口,那么它的所有子类都必须小心处理 clone 方法,以确保正确地复制对象的状态。
解决方案:
使类本身不实现 Cloneable 接口,而是提供一个受保护的 clone 方法,并在其中调用 super.clone()。
如果子类需要支持克隆功能,可以显式地覆盖这个受保护的方法。
Item-86: 使类可序列化
问题:如果一个类实现了 Serializable 接口,那么它的所有子类都必须考虑序列化的问题,特别是如何处理瞬态字段和版本控制等问题。
解决方案:
类本身不实现 Serializable 接口,而是通过提供一个受保护的序列化方法或使用默认的序列化机制来实现序列化。
子类可以选择性地覆盖序列化相关的方法或者添加序列化所需的逻辑。
准则六 接口优于抽象类
1、抽象类的局限:一个类要实现抽象类定义的类型,该类必须是抽象类的子类。因为 Java 只允许单一继承,这种限制对抽象类而言严重制约了它们作为类型定义的使用。
2、接口的优点:任何定义了所有必需的方法并遵守通用约定的类都允许实现接口,而不管该类驻留在类层次结构中何处。
接口的好处
- 接口是定义 mixin(混合类型)的理想工具。
这是什么意思呢?例如Comparable 这个接口,如果我们想要对对象进行比较排序只需要实现这个接口就可以拥有这个能力,但是如果是抽象类就不是特别方便来提供一个抽象方法用于提供这个能力,首先如果是这样所有的子类必须继承这个抽象类,本来Java是单继承这样做显然不太合适,如果这个类以后还要继承别的类怎么办呢?再就是如果有些类和这个抽象类不存在父子关系,从这个角度上来说也不太合适。 - 接口允许构造非层次化类型框架。
public interface Singer {
AudioClip sing(Song s);
}
public interface Songwriter {
Song compose(int chartPosition);
}
public interface SingerSongwriter extends Singer, Songwriter {
AudioClip strum();
void actSensitive();
}
这两个接口一个是歌唱者,一个是作词者,如果一个人是两个身份,使用接口是不是很好的整合了这两种能力呢?这里要提一下,之所以这里这么好整合,是因为我们的接口隔离 + 单一职责,我们在整合的时候不会冲突。
接口使用的最佳方式
- 如果接口方法的实现与其他接口方法类似,那么可以考虑以默认方法的形式为程序员提供实现帮助。我们来看一个例子来帮助理解:
public interface Shape {
// 计算面积
default double area(BiFunction<Double, Double, Double> formula, Double... args) {
if (formula == null) throw new IllegalArgumentException("Formula cannot be null");
return formula.apply(args[0], args[1]);
}
// 计算周长
default double perimeter(BiFunction<Double, Double, Double> formula, Double... args) {
if (formula == null) throw new IllegalArgumentException("Formula cannot be null");
return formula.apply(args[0], args[1]) * 2;
}
}
public class Rectangle implements Shape {
// 计算矩形面积
public double calculateArea(double length, double width) {
return area((l, w) -> l * w, length, width);
}
// 计算矩形周长
public double calculatePerimeter(double length, double width) {
return perimeter((l, w) -> l + w, length, width);
}
}
补充一下BiFunction这个函数式接口:
- 你可以通过提供一个抽象骨架实现类来结合接口和抽象类的优点。接口定义了类型,可能提供了一些默认方法,而骨架实现类在基本接口方法之上实现了其余的非基本接口方法。扩展骨架实现需要完成实现接口的大部分工作。
这个比较好举例子,例如我们Jdk源码中的AbstractCollection
我们可以看到Collection中有一个默认的方法,然后AbstractCollection拓展实现了大部分方法,这也是模板设计模式的应用。
在实践的过程中,我们先分析那些事基本方法需要交给子类来实现,在抽象类中的实现可以是一个抽象方法,如果是通用的默认实现则可以在抽象类中直接实现具体的逻辑。例如源码中的AbstractMapEntry:
abstract class AbstractMapEntry<K, V> implements Entry<K, V> {
@Override
public abstract K getKey();
@Override
public abstract V getValue();
@Override
public V setValue(V value) {
throw new UnsupportedOperationException();
}
@Override
public boolean equals(@NullableDecl Object object) {
if (object instanceof Entry) {
Entry<?, ?> that = (Entry<?, ?>) object;
return Objects.equal(this.getKey(), that.getKey())
&& Objects.equal(this.getValue(), that.getValue());
}
return false;
}
@Override
public int hashCode() {
K k = getKey();
V v = getValue();
return ((k == null) ? 0 : k.hashCode()) ^ ((v == null) ? 0 : v.hashCode());
}
/** Returns a string representation of the form {@code {key}={value}}. */
@Override
public String toString() {
return getKey() + "=" + getValue();
}
}
准则七 为后代设计接口
这个准则告诉我们,向接口中添加默认方法也是存在风险的。书中举例子Collection#removeIf,这个方法是非常通用的,采用迭代器实现,单线程的情况下,不会出现ConcurrentModificationException 如果是多线程情况下就需要加锁。可以看到JDK中的SynchronizedCollection从写了这个方法,如果不复写用了默认实现是存在风险的,这就是这个准则想要表达的意思。
public boolean removeIf(Predicate<? super E> filter) {
synchronized (mutex) {return c.removeIf(filter);}
}
这里补充一下ConcurrentModificationException 为什么会出现:
在于我们直接调用List的add方法和remove 方法会修改modCount, 这里判断不会通过就会抛出异常。所以实践中一般推荐使用迭代器进行删除操作,也不推荐在遍历集合的时候对集合进行修改操作,如果要进行修改操作,可以先复制一份集合然后进行操作。可以使用System.arraycopy方法。
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
准则八 接口只用于定义类型
当一个类实现了一个接口时,这个接口作为一种类型,可以用来引用类的实例。因此,实现接口的类应该说明使用者可以对类的实例做什么。为其他任何目的定义接口都是不合适的。
在实践中我们往往会将一些常量放到接口中,但是书中作者似乎更推荐我们把常量放到类中。
准则九 类层次结构优于带标签的类
你可能会遇到这样一个类,它的实例有两种或两种以上的样式,并且包含一个标签字段来表示实例的样式。这个准则直接看例子比较容易理解:
class Figure {
enum Shape {RECTANGLE, CIRCLE};
// 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);
}
}
}
// Class hierarchy replacement for a tagged class
abstract class Figure {
abstract double area();
}
class Circle extends Figure {
final double radius;
Circle(double radius) {
this.radius = radius;
}
@Override
double area() {
return Math.PI * (radius * radius);
}
}
class Rectangle extends Figure {
final double length;
final double width;
Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
@Override
double area() {
return length * width;
}
}
准则十 静态成员类优于非静态成员类
-
静态成员类是最简单的嵌套类。最好把它看做是一个普通的类,只是碰巧在另一个类中声明而已,并且可以访问外部类的所有成员,甚至那些声明为 private 的成员。静态成员类是其外部类的静态成员,并且遵守与其他静态成员相同的可访问性规则。如果声明为私有,则只能在外部类中访问,等等。
-
静态成员类的一个常见用法是作为公有的辅助类,只有与它的外部类一起使用时才有意义。这句话是什么意思呢?比如我们看下面的例子,Operation 就是一个辅助类,辅助计算机完成运算。
public class Calculator {
// Static member class for operations
public static enum Operation {
PLUS("+") {
@Override
double apply(double x, double y) {
return x + y;
}
},
MINUS("-") {
@Override
double apply(double x, double y) {
return x - y;
}
},
TIMES("*") {
@Override
double apply(double x, double y) {
return x * y;
}
},
DIVIDE("/") {
@Override
double apply(double x, double y) {
if (y == 0) throw new ArithmeticException("Cannot divide by zero");
return x / y;
}
};
private final String symbol;
Operation(String symbol) {
this.symbol = symbol;
}
abstract double apply(double x, double y);
@Override
public String toString() {
return symbol;
}
}
非静态成员类的每个实例都隐式地与外部类的外部实例相关联。在非静态成员类的实例方法中,你可以调用外部实例上的方法,或者使用受限制的 this 构造获得对外部实例的引用 [JLS, 15.8.4]。如果嵌套类的实例可以独立于外部类的实例存在,那么嵌套类必须是静态成员类:如果没有外部实例,就不可能创建非静态成员类的实例。
还是来看一个例子:
public class ExternalClass {
private final int value = 10;
public NonStaticMemberClass createNonStaticMember() {
return new NonStaticMemberClass();
}
public class NonStaticMemberClass {
public void printValue() {
// 可以访问外部类的私有成员
System.out.println(value);
}
}
}
我们通过反编译可以看到,我们访问外部变量的时候其实是持有了外部类的实例。
那如果内部内不需要访问内部的成员,或者该类可以独立的存在,一般都要定义为static class
如果声明的成员类不需要访问外部的实例,那么应始终在声明中添加 static 修饰符,使其成为静态的而不是非静态的成员类。比如Java中的Node和Entry都是静态的,他们并不需要持有外面的Map对象,Map对象中的数组来引用Node.
非静态成员类的一个常见用法是定义一个 Adapter [Gamma95],它允许外部类的实例被视为某个不相关类的实例。它的使用场景在源码中也可以看到,比如我们的List的Itr 我们需要引用外部的实例变量,但是这个实现我们在类的外部不需要访问,所以可以使用内部类。
- 匿名类除了在声明它们的时候,你不能实例化它们。你不能执行 instanceof 测试,也不能执行任何其他需要命名类的操作。你不能声明一个匿名类来实现多个接口或扩展一个类并同时实现一个接口。匿名类的使用者除了从超类继承的成员外,不能调用任何成员。因为匿名类出现在表达式中,所以它们必须保持简短——大约 10 行或更短,否则会影响可读性。现在的实践一般都是通过lambda表达式来实现。
准则十一 源文件仅限有单个顶层类
这个准则很简单,就是在一个源文件中只有一个顶层的类。