第 15 条: 使类和成员的可访问性最小化
软件设计基本原则:信息隐藏和封装。
信息隐藏可以有效解耦,使组件可以独立地开发、测试、优化、使用和修改。
经验法则:尽可能地使每个类或者成员不被外界访问。
对于成员(属性、方法、嵌套类和嵌套接口),有四种可能的访问级别,在这里,按照可访问性从小到大列出:
- private —— 该成员只能在声明它的顶层类内访问。
- default (package-private) —— 成员可以从被声明的包中的任何类中访问。从技术上讲,如果没有指定访问修饰符(接口成员除外,它默认是公共的),这是默认访问级别。
- protected —— 成员可以从被声明的类的子类中访问(受一些限制),以及它声明的包中的任何类。
- public —— 该成员可以从任何地方被访问。
公有类的实例域决不能是公有的。如果一个实例属性是非 final 的,或者是对可变对象的引用,那么如果将其公开,你就放弃了限制属性中的值的能力。
你放弃了强制该属性为不变量的能力。
包含公有可变域的类通常并不是线程安全的。
即使属性是 final 的,并且引用了一个不可变的对象,如果将它公开,你也放弃了切换到新的内部数据表示的灵活性。
长度非0的数组总是可变的,让类具有公有的静态final数组域,或者返回域的访问方法是错误的。
如果一个类有这样的属性或访问方法,客户端将能够修改数组的内容。这是安全漏洞的常见来源:
// Potential security hole! public static final Thing[] VALUES = { ... }; 有两种方法可以解决这个问题。你可以使公共数组私有并添加一个公共的不可变列表: private static final Thing[] PRIVATE_VALUES = { ... }; public static final List<Thing> VALUES = Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES)); 或者,可以将数组设置为 private,并添加一个返回私有数组拷贝的公共方法: private static final Thing[] PRIVATE_VALUES = { ... }; public static final Thing[] values() { return PRIVATE_VALUES.clone(); }
Java9新增两种隐式访问级别。
一个模块就是一组包,模块可以通过其模块声明(module declaration)中的导出声明(export declaration)显示地导出它的一部分包。
总之,应该尽可能地降低程序元素的可访问性。在设计一个公有API之后,应该防止把任何散乱的类、接口或者成员变成API的一部分。
除了公有静态final域的特殊情况之外(此时它们充当常量),公有类都不应该包含公有域,并且要确保公有静态final域所引用的对象都是不可变的。
第 16 条: 要在公有类而非公有域中使用访问方法
退化类(Degenerate classes),只有实例域,没有什么用。
// Degenerate classes like this should not be public! class Point { public double x; public double y; }
这种类的数据域可以被直接访问,没有封装。
应该用包含私有域的公有访问方法(setter)类替代:
// 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 条: 使可变性最小化
不可变类是一个实例不能被修改的类。包含在每个实例中的所有信息在对象的生命周期中是固定的,因此不会观察到任何变化。
Java 平台类库包含许多不可变的类,包括 String 类,基本类型包装类以及 BigInteger 类和 BigDecimal 类。
使用不可变类有很多理由:不可变类比可变类更容易设计,实现和使用。 它们不太容易出错,更安全。
不可变类五条原则:
1. 不要提供任何会修改对象状态的方法。(设值方法)
2. 保证类不会被扩展。
3. 声明所有的域都是final的。
4. 声明所有的域都是私有的。
5. 确保对于任何可变组件的互斥访问。
函数式方法的优点:
1. 不可变类比较简单。
2. 不可变对象本质上是线程安全的,不要求同步。
不可变对象可以被自由的共享。
不仅可以共享不可变对象,甚至也可以共享它们的内部信息。
不可变对象为其他对象提供了大量的构件。
不可变对象无偿地提供了失败的原子性。
缺点:
对于每个不同的值都需要一个单独的对象。创建这些对象可能代价很高,尤其是大型对象。
如果你可以准确预测客户端要在你的不可变类上执行哪些复杂的操作,那么包级私有可变伙伴类的方式可以正常工作。
如果不能的话,那么最好的办法就是提供一个公开的可变伙伴类。这种方法在 Java 平台类库中的主要例子是 String 类,它的可变伙伴类是 StringBuilder。
总之,坚决不要为每个get方法编写一个相应的set方法。除非有很好的理由要让类成为可变的类,否则它就应该是不可变的。
如果类不能被设计为不可变,要尽可能限制它的可变性。
除非有令人信服的理由要使域变成非final的,否则每个域都是private final的(赋值一次就不能再修改)。
构造器应该创建完全初始化的对象,并建立所有的约束关系。
不要在构造器或者静态工厂之外再提供公有的初始化方法。
第 18 条: 复合(组合)优先于继承
在一个包的内部使用继承是安全的,因为子类和父类的实现都在同一个程序员的控制之下。然而,从普通的具体类跨越包级边界继承是危险的。
与方法调用不同,继承打破了封装。一个子类依赖于其父类的实现细节来保证其正确的功能。
父类的实现可能会因发布版本不断变化,如果是这样,子类可能会被破坏,即使它的代码没有任何改变。因此,一个子类必须与其父类一起更新。
如果仅仅添加新的方法并且不要重写现有的方法,那么继承一个类是安全的。虽然这种扩展更为安全,但这并非没有风险。
如果父类在后续版本中添加了一个新的方法,并且你不幸给了子类一个具有相同参数和不同返回类型的方法,那么你的子类编译失败。
如果已经为子类提供了一个与新的父类方法具有相同参数和相同返回类型的方法,那么你现在就会变成正在重写这个父类方法。
一种方法可以避免上述所有的问题。不要继承一个现有的类,而应该给你的新类增加一个私有属性,该属性是对现有类的实例的引用,这种设计被称为复合(组合)(composition),因为现有的类成为新类的组成部分。
例子:
// 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(); } }
InstrumentedSet 类的设计是通过存在的 Set 接口来实现的,该接口包含 HashSet 类的功能特性。除了鲁棒性之外,这个设计也非常灵活。
InstrumentedSet 类实现了 Set 接口,并有一个构造方法,其参数也是 Set 类型的。本质上,这个类把 Set 转换为另一个 Set, 同时添加了计数的功能。
与基于继承的方法不同(该方法仅适用于单个具体类),包装类可以被用来包装任何 Set 实现,并且可以与任何预先存在的构造方法结合使用:
Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp)); Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));
InstrumentedSet 类被称为包装类,因为每个 InstrumentedSet 实例都包含(“包装”)另一个 Set 实例。
这也被称为装饰器模式,因为 InstrumentedSet类通过添加计数功能来“装饰”一个 Set。
总之,继承很强大,但也有很多问题,它违背了封装原则。只有当子类和超类之间确实存在子类型关系时,使用继承才是恰当的。
即使如此,如果子类与父类不在同一个包中,并且父类不是为继承而设计的,继承都很有可能导致脆弱性。
为了避免这种脆弱性,使用组合和转发代替继承,特别是如果存在一个合适的接口来实现包装类。包装类不仅比子类更健壮,而且更强大。
第 19 条:要么设计继承并提供文档说明,要么禁止继承
第18条提醒了为了继承而设计没有文档说明的“外来”类进行子类化的危险。
首先,这个类必须准确地描述重写这个方法带来的影响。换句话说,该类必须文档说明可重写方法的自用性(self-use)。
测试为继承而设计的类的唯一方法是编写子类。
构造方法绝不能直接或间接调用可重写的方法。
总之,在没有想要安全地子类化的设计和文档说明的类中禁止子类化。有两种方法禁止子类化。两者中较容易的是声明类为 final。
另一种方法是使所有的构造方法都是私有的或包级私有的,并且添加公共静态工厂来代替构造方法。
第 20 条:接口优于抽象类
Java有两种机制可以用来定义允许多个实现类型:接口和抽象类。
一个主要的区别是要实现由抽象类定义的类型,该类必须是抽象类的子类。
因为 Java 只允许单一继承,所以抽象类的这种局限严格限制了它作为类型定义的使用。
任何定义所有必需方法并服从通用约定的类都可以实现一个接口,而不管类在类层次结构中的位置。
现有的类可以很容易地进行改进来实现一个新的接口。你只需添加所需的方法(如果尚不存在的话),并向类声明中添加一个 implements 子句。
例如, Comparable, Iterable和 Autocloseable 接口。
接口是定义混合类型(mixin)的理想选择。mixin是一个类,除了它的“主类型”之外,还可以声明它提供了一些可选的行为。
接口通过包装类(条款 18)模式确保安全的、强大的功能增强。
如果使用抽象类来定义类型,那么就让程序员只能通过继承添加新功能,这样生成的类比包装类更脆弱。
你可以通过提供一个抽象的骨架实现类(abstract skeletal implementation class)将接口和抽象类的优点结合起来。
接口定义了类型,可能提供了一些默认的方法,而骨架实现类在原始接口方法的顶层实现了剩余的非原始接口方法。
继承骨架实现需要大部分的工作来实现一个接口,这就是模板方法模式。
按照惯例,骨架实现类被称为 AbstractInterface,其中 Interface 是它实现的接口的名称。
例如,集合框架( Collections Framework)提供了一个框架实现以配合每个主要集合接口:AbstractCollection,AbstractSet,AbstractList 和AbstractMap。
如果设计得当,骨架实现(无论是单独的抽象类还是仅由接口上的默认方法组成)可以使程序员非常容易地提供他们自己的接口实现。
总之,一个接口通常是定义一个允许多个实现的类型的最佳方式。如果你导出一个重要的接口,应该考虑提供一个骨架实现类。
在可能的情况下,应该通过接口上的默认方法提供骨架实现,以便接口的所有实现者都可以使用它。
也就是说,对接口的限制通常要求骨架实现类采用抽象类的形式。
第 21 条:为后代设计接口
在 Java 8 之前,不可能在不破坏现有实现的情况下为接口添加方法。如果向接口添加了一个新方法,现有的实现通常会缺少该方法,从而导致编译时错误。
在 Java 8 中加入了默认方法( default method)构造,目的是允许将方法添加到现有的接口。但是增加新的方法到现有的接口是充满风险的。
缺省方法的声明中包括一个缺省实现(default implementation),这个是给实现了该接口但没有实现默认方法的所有类使用的。
许多新的默认方法被添加到 Java 8 的核心集合接口中,主要是为了方便使用 lambda 表达式。
Java 类库的默认方法是高质量的通用实现,在大多数情况下,它们工作正常。但是,编写一个保留了每个可能的实现的所有不变量的默认方法并不总是可能的。
在默认方法存在的情况下,接口的现有实现类可能在没有错误或警告的情况下编译,但在运行时会失败。
尽管默认方法现在是 Java 平台的一部分,但是非常悉心地设计接口仍然是非常重要的。虽然默认方法可以将方法添加到现有的接口,但这样做有很大的风险。
如果一个接口包含一个小缺陷,可能会永远惹怒用户。如果一个接口有严重缺陷,可能会破坏包含它的 API。
因此,在发布之前测试每个新接口是非常重要的。虽然在接口被发布后仍然可以修正缺陷,但你不能依赖这一点。
第22 条:接口只用于定义类型
一个类实现了一个接口,就是表明客户端可以对这个类的实例做些什么。为其他目的定义接口是不合适的。
有一种接口称为常量接口(constant interface),它不满足上面的条件。这种接口不包含任何方法,只包含静态final域,每个域一个常量。例如:
// Constant interface antipattern - do not use! public interface PhysicalConstants { // Avogadro's number (1/mol) static final double AVOGADROS_NUMBER = 6.022_140_857e23; // Boltzmann constant (J/K) static final double BOLTZMANN_CONSTANT = 1.380_648_52e-23; // Mass of the electron (kg) static final double ELECTRON_MASS = 9.109_383_56e-31; }
常量接口模式是对接口的不良使用。
类在内部使用一些常量,完全属于实现细节。实现一个常量接口会导致这个实现细节泄漏到类的导出 API 中。
对类的用户来说,类实现一个常量接口是没有意义的。
更糟糕的是,它代表了一个承诺:如果在将来的版本中修改了类,不再需要使用常量,那么它仍然必须实现接口,以确保二进制兼容性。
如果一个非 final 类实现了常量接口,那么它的所有子类的命名空间都会被接口中的常量所污染。
总之,接口应该只被用来定义类型,不应该被用来导出常量。
第 23 条:类层次优于标签类
有时你可能会碰到一个类,它的实例有两个或更多的风格(flavor),并且包含一个标签属性(tag field),表示实例的风格。
例如下面这个类,它可以表示一个圆形或矩形:
// Tagged class - vastly inferior to a class hierarchy! 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); } } }
标签类很多缺点。杂乱无章的样板代码,包括枚举声明、标签属性和 switch 语句。可读性差,因为多个实现在一个类中混杂在一起。
标签类是冗长的,容易出错的,而且效率低下。
像 Java 这样的面向对象的语言为定义一个能够表示多种风格对象的单一数据类型提供了更好的选择:子类型化(subtyping)。
要将标签类转换为类层次,首先定义一个包含抽象方法的抽象类,这些抽象方法是标签类中依赖标签值的方法。
修改后的Figure:
// 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; } }
类层次的另一个优点是可以使它们反映类型之间的自然层次关系,从而提高了灵活性,并提高了编译时类型检查的效率。
总之,标签类很少有适用的情况。如果你想写一个带有明显标签属性的类,请考虑标签属性是否可以被删除,并且这个类是否可以被类层次替换。
第 24 条:静态成员类优先于非静态成员类
嵌套类(nested class)是在另一个类中定义的类。嵌套类存在的目的应该只是为它的外围类(enclosing class)提供服务。
如果嵌套类将来可能用于其他某个环境,则它应该是顶层类(top-level class)。
嵌套类有四种:静态成员类(static member class)、非静态成员类(nonstatic member class)、匿名类(anonymous class)和局部类(local class)。
除第一种之外,其他都成为内部类(inner class)。
静态成员类的一个常见用途是作为公共帮助类,仅在与其外部类一起使用时才有用。例如,考虑一个描述计算器支持的操作的枚举类型(条款 34)。
Operation 枚举应该是 Calculator 类的公共静态成员类。Calculator 客户端可以使用 Calculator.Operation.PLUS 和 Calculator.Operation.MINUS 等名称来引用操作。
在语法上,静态成员类和非静态成员类之间的唯一区别是静态成员类在其声明中具有 static 修饰符。尽管语法相似,但这两种嵌套类是非常不同的。
非静态成员类的每个实例都隐含地与其外部类实例相关联。
在非静态成员类的实例方法中,可以调用外部类实例上的方法,或者使用限定的 this 获得对外部类实例的引用。
如果嵌套类的实例可以与其外部类的实例隔离存在,那么嵌套类必须是静态成员类:不可能在没有外部类实例的情况下创建非静态成员类的实例。
非静态成员类实例和其外部类实例之间的关联是在创建成员类实例时建立的,并且之后不能被修改。
通常情况下,通过在外部类的实例方法中调用非静态成员类的构造方法来自动建立关联。
很少有可能使用表达式 enclosingInstance.new MemberClass(args) 手动建立关联。
正如你所预料的那样,该关联在非静态成员类实例中占用了空间,并为其构建添加了时间开销。
非静态成员类的一个常见用法是定义一个 Adapter,它允许将外部类的实例视为某个不相关类的实例。
例如,Map 接口的实现通常使用非静态成员类来实现它们的集合视图,这些视图由 Map 的 keySet,entrySet 和 values 方法返回。
同样,集合接口(如 Set 和 List)的实现通常使用非静态成员类来实现它们的迭代器:
// Typical use of a nonstatic member class public class MySet<E> extends AbstractSet<E> { ... // Bulk of the class omitted @Override public Iterator<E> iterator() { return new MyIterator(); } private class MyIterator implements Iterator<E> { ... } }
如果声明成员类不要求访问外围实例,就要始终把修饰符static放在它的声明中。使它成为静态成员类,而不是非静态成员类。
如果省略了static修饰符,则每个实例都将包含一个额外的指向外围对象的引用。
匿名类是没有名字的。它不是外围类的一个成员。它并不与其他成员一起被声明,而是在使用的同时被声明和实例化。
匿名类的使用有很多限制。除了在声明的时候之外,不能在其他地方实例化它们。你不能执行 instanceof 方法测试或者做任何其他需要类的名字的操作。
不能声明一个匿名类来实现多个接口,或者继承一个类并同时实现一个接口。
因为匿名类在表达式中出现,所以它们必须保持简短——约十行或更少——否则可读性将受损。
在将 lambda 表达式添加到 Java 之前,匿名类是创建小方法对象和处理对象的首选方法,但 lambda 表达式现在是首选(条款 42)。
匿名类的另一个常见用途是实现静态工厂方法(请参阅条款 20 中的 intArrayAsList)。
局部类是四种嵌套类中使用最少的。一个局部类可以在任何可以声明局部变量的地方声明,并遵守相同的作用域规则。
局部类与其他类型的嵌套类具有共同的属性。像成员类一样,他们有名字,可以重复使用。像匿名类一样,应该保持简短,以免损害可读性。
总之,四种不同嵌套类各有用途。如果一个嵌套的类需要在一个方法之外可见,或者太长而不能很好地放在一个方法内部,使用一个成员类。
如果一个成员类的每个实例都需要一个对其外部类实例的引用,使其成为非静态的;否则,使用静态成员类。
假设这个类属于一个方法内部,如果你只需要从一个地方创建实例,并且存在一个预置类型来说明这个类的特征,那么把它作为一个匿名类;
否则,把它变成局部类。
第 25 条: 限制源文件为单个顶级类
虽然 Java 编译器允许在单个源文件中定义多个顶级类,但这样做没有任何好处,并且存在重大风险。
风险源于在源文件中定义多个顶级类可能会为一个类提供多个定义。使用哪个定义会依赖于源文件传递给编译器的顺序。
总之,永远不要将多个顶级类或接口放在一个源文件中。遵循这个规则保证在编译时不会有多个定义。
这又保证了编译生成的类文件以及生成的程序的行为与源文件传递给编译器的顺序无关。