《Effective Java》是Java开发领域无可争议的经典之作,连Java之父James Gosling都说: “如果说我需要一本Java编程的书,那就是它了”。它为Java程序员提供了90个富有价值的编程准则,适合对Java开发有一定经验想要继续深入的程序员。
本系列文章便是这本著作的精华浓缩,通过阅读,读者可以在5天时间内快速掌握书中要点。为了方便读者理解,笔者用通俗易懂的语言对全书做了重新阐述,避免了如翻译版本中生硬难懂的直译,同时对原作讲得不够详细的地方做了进一步解释和引证。
本文是系列的第二部分,包含对第四、五章的19个准则的解读,约1.9万字。
目录
- 第四章 类和接口
- 第15条:尽量减少类和成员的可访问性
- 第16条:在公共类中,使用访问器方法,而不是公共字段
- 第17条:减少可变性
- 第18条:优先选择组合而不是继承
- 第19条:继承要设计良好并且具有文档,否则禁止使用
- 第20条:接口优于抽象类
- 第21条:为后代设计接口
- 第22条:接口只用于定义类型
- 第23条:类层次结构优于带标签的类
- 第24条:静态成员类优于非静态成员类
- 第25条:源文件仅限有单个顶层类
- 第五章 泛型
- 第26条:不要使用原始类型
- 第27条:消除 unchecked 警告
- 第28条:list 优于数组
- 第29条:优先使用泛型类型
- 第30条:优先使用泛型方法
- 第31条:使用有界通配符增加 API 的灵活性
- 第32条:明智地合用泛型和可变参数
- 第33条:考虑类型安全的异构容器
第四章 类和接口
Chapter 4. Classes and Interfaces
第15条:尽量减少类和成员的可访问性
item15: Minimize the accessibility of classes and members
封装和信息隐藏是软件设计的基本原则。 Java的访问控制机制指定了类、接口和成员的可访问性。它们的可访问性由其声明的位置以及声明中出现的访问修饰符(private、protected 和 public)决定。
控制可访问级别的最佳实践是:在不影响软件功能的前提下,让每个类或成员尽可能不可访问。
对于顶级(非嵌套)类和接口,只有两个可能的访问级别:包级和公共。使用哪个级别,取决于是否需要将API对外导出。
如果包级顶级类或接口只被一个类使用,那么可以考虑在使用它的这个类中,将顶级类设置为私有静态嵌套类。
对于成员(字段、方法、嵌套类和嵌套接口),存在四种访问级别,按可访问性从低到高分别是:
- 私有(private):成员只能从声明它的顶级类访问。
- 包级(package-private):成员可以从声明它的类所在的包访问。不加修饰符时默认的访问级别。
- 保护(protected):成员可以从声明它的类的子类和声明它的类所在的包访问。
- 公共(public):成员可以从任意地方访问。
如果一个方法覆盖了超类方法,那么它在子类中的访问级别就不能比超类更严格。
公共类的实例字段很少用public修饰,除非是静态final常量。带有公共可变字段的类通常不是线程安全的。
Java 9的模块系统引入了两个额外的隐式访问级别。模块是包的分组,它可以显式导出一些包,未导出包的公共类的公共和保护成员在模块外不可访问,它们产生了两个隐式访问级别,即模块内公共和模块内保护。
第16条:在公共类中,使用访问器方法,而不是公共字段
item16: In public classes use accessor methods not public fields
对于公共类中的可变字段,不应该将它们设为公共,因为这样破坏了类的封装性。应该通过setter、getter方法访问。对于公共类中的不可变字段,设为公共的危害要小一些。
对于包级类或私有嵌套类,公开它们的字段是允许的。因为这两种类的访问受到限制,所以对它们字段的更改也能限制在一定范围内。
第17条:减少可变性
Item 17: Minimize mutability
应该尽量降低类的可变性。
不可变类是实例不能被修改的类。Java中有很多不可变类,如String、Integer等。不可变类的优点是:简单、线程安全,可作为缓存共享。
不可变类需满足以下5条规则:
- 不要提供修改状态的方法。
- 确保类不能被继承。可以通过为类加上final修饰符,或者通过静态工厂方法对外提供创建对象的唯一方法。
- 所有字段用final修饰。
- 所有字段设为私有。
- 确保对可变对象引用的独占访问。不要给用户提供访问这些引用的方法。
不可变类的缺点是每个不同的值都需要一个单独的对象。这样会产生很多对象创建和回收的开销。解决办法是提供一个公共可变伴随类。例如String的公共可变伴随类就是StringBuilder,用后者处理多个字符串的拼接时可以减少对象创建数量。
对于那么无法做到不可变的类,应尽量限制它的可变性。这样可以减少出错的可能性。每个字段如果可以,尽量设为私有final的。
第18条:优先选择组合而不是继承
Item 18: Favor composition over inheritance
继承并不适合于所有场合。在同一个包中使用继承是安全的,对专为扩展而设计的类使用继承也是安全的。但对普通的具体类做跨包的继承是危险的。
继承的危险在于它破坏了封装。因为子类的功能是否正确依赖于超类的实现细节。
下面例子说明了这个问题,它统计一个HashSet自创建以来添加了多少元素:
// Broken - Inappropriate use of inheritance!
public class InstrumentedHashSet<E> extends HashSet<E> {
// The number of attempted element insertions
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,而不是3。这是因为super.addAll会调用覆盖后的add方法,导致每个元素被重复算了一遍。
解决办法是通过组合,而非继承。下面是用组合和转发重新实现的类,它使用了装饰者模式:
// 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却继承了后者,Properties不是HashTable也继承了后者。这些情况本应该使用组合。
笔者注:鉴于继承的上述缺点,Go语言在设计时去除了继承的概念,转而用组合和接口实现类似的效果,但是更加灵活。Go的规则是:只要一个结构实现了接口的所有函数,那么就认为这个结构属于接口对应的类型,无需像Java一样显式声明继承或实现关系。即只要满足act-like-a,那么就能推导出is-a。
第19条:继承要设计良好并且具有文档,否则禁止使用
Item 19: Design and document for inheritance or else prohibit it
为了避免继承影响子类实现的正确性,需要为可覆盖方法提供详细的文档,说明它的实现细节,以及覆盖它产生的影响。
这看上去违反了一条准则:好的API文档应该描述一个方法做什么,而不是如何做。确实,这是继承违反封装导致的后果。
第20条:接口优于抽象类
Item 20: Prefer interfaces to abstract classes
接口相对抽象类的优点是:
- 一个类只能继承单个抽象类,却能实现多个接口。
- 接口的使用更加灵活,可以很容易对现有类进行改造,实现新的接口。
- 接口允许构造非层次化类型结构。示例如下,有一个歌手接口和一个歌曲作者接口:
public interface Singer {
AudioClip sing(Song s);
}
public interface Songwriter {
Song compose(int chartPosition);
}
如果有人既是歌手,又是歌曲作者,那么我们通过创建一个新的接口就能很容易实现这个需求:
public interface SingerSongwriter extends Singer, Songwriter {
AudioClip strum();
void actSensitive();
}
但如果是使用类继承来实现这个功能,就容易显得臃肿。特别是,如果不同属性有n个,那么就有2n种组合,这就是所谓的类型爆炸。
为了代码复用,可以为接口提供默认方法。但是默认方法有不少限制,例如编译器会阻止你提供一个与Object类中的方法重复的默认方法,而且接口不允许包含实例字段或非公共静态成员(私有静态方法除外)。
这时可以实现一个抽象骨架类来结合抽象类和接口的优点。例如Java类库中的AbstractList就是典型的抽象骨架类。抽象骨架类使用了设计模式中的模板模式。
第21条:为后代设计接口
Item 21: Design interfaces for posterity
在Java 8之前,向接口添加方法会导致现有的实现出现编译错误,影响版本兼容性。为了解决这个问题,在Java 8中添加了默认方法的功能,允许向现有接口添加方法,但是这个添加过程存在很大风险。
由于默认方法被注入到已有实现的过程对实现者是透明的,实现者无需对此做任何反应。但是有时很难为所有的实现提供一个通用的默认方法,提供的默认方法很可能在某些场合是错误的。
下面的例子是Java 8 中被添加到集合接口中的 removeIf 方法:
// Default method added to the Collection interface in Java 8
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;
}
很遗憾,它在实际使用的一些 Collection 实现中导致了问题。例如,考虑 org.apache.commons.collections4.collection.SynchronizedCollection
。这个类提供了Java Collection的同步实现版本,但由于继承了removeIf的默认实现,新增了一个非同步方法,与这个类的设计初衷不符。
总结下来,设计接口前应该三思,应仔细考虑接口应包含的方法集合。虽然默认方法可以做到接口发布后新增方法,但是你不能依赖这种有很大风险的方式。
笔者注:这个观点需要辩证理解。本书作者作为Java类库的设计者之一,他的很多观点都来自他在设计这些类库时的经验总结。对于像基础Java类库或是使用广泛的开源项目,同时需要保证严格的版本兼容性的,接口发布后新增默认方法确实会带来很大的风险。但是对于广大业务开发者来说,如果接口只是局限在自己的业务代码中,所有引用都能用IDE反向查找到,那么风险是可控的。
第22条:接口只用于定义类型
Item 22: Use interfaces only to define types
接口只应该用来定义类型,不要用来导出常量。
想要导出常量,可以把它们放在相关的类中,如Integer类中的MAX_VALUE;或者定义一个XXXConstants类来存放一组相关的常量。
第23条:类层次结构优于带标签的类
Item 23: Prefer class hierarchies to tagged classes
对于有两种或两种以上的样式的类,我们有时会定义一个标签字段来表示不同的样式。例如,下面的类能够表示一个圆或一个矩形:
// 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);
}
}
}
标签类的缺点是:不同的实现逻辑混杂在一起,可读性较低,容易出错。
标签类本质上是对类层次结构的模仿。所以还不如直接用类层次结构来编写,得到更加简单明了的代码:
// 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;
}
}
类层次结构的另一个优点是,可以反映类型之间的自然层次关系,而这是标签类做不到的。下面例子表示正方形是一种特殊的矩形:
class Square extends Rectangle {
Square(double side) {
super(side, side);
}
}
第24条:静态成员类优于非静态成员类
Item 24: Favor static member classes over nonstatic
嵌套类共有四种:静态成员类、非静态成员类、匿名类和局部类。它们各自有不同的适用场合。判断方法见如下流程图:
每种嵌套类的常见例子总结如下:
- 静态成员类:作为公共的辅助类,如Map中的Entry类。
- 非静态成员类:Map的entrySet、keySet方法返回的视图,List、Set中的迭代器。
- 匿名类:新建Comparable、Runnable接口实现类,可用lambda表达式替代。
- 局部类:与匿名类的区别仅为有名字,可重复使用。
下面重点谈谈静态成员类和非静态成员类的取舍。如果成员类不需要访问外部实例,那么始终应该设置其为静态的。因为非静态的成员类会持有对外部类的引用,增加时间空间代价,而且会影响对外部类的垃圾回收。
第25条:源文件仅限有单个顶层类
Item 25: Limit source files to a single top level class
虽然Java编译器允许在单个源文件中定义多个顶层类,但是这样做没有任何好处,反而存在重大风险。请看下面的例子,这个源文件引用了另外两个顶层类(Utensil 和 Dessert):
public class Main {
public static void main(String[] args) {
System.out.println(Utensil.NAME + Dessert.NAME);
}
}
然后你在一个名为 Utensil.java
的源文件中提供了 Utensil 类和 Dessert 类的实现:
// Two classes defined in one file. Don't ever do this!
class Utensil {
static final String NAME = "pan";
}
class Dessert {
static final String NAME = "cake";
}
不料在你不知情的情况下,另一个人在名为 Dessert.java
的源文件中定义了相同的两个类:
// Two classes defined in one file. Don't ever do this!
class Utensil {
static final String NAME = "pot";
}
class Dessert {
static final String NAME = "pie";
}
如果你使用 javac Main.java Dessert.java
命令编译程序,编译将失败,编译器将告诉你多重定义了 Utensil 和 Dessert。这是因为编译器将首先编译 Main.java
,当它看到对 Utensil 的引用(在对 Dessert 的引用之前)时,它将在 Utensil.java
中查找这个类,并找到餐具和甜点。当编译器在命令行上遇到 Dessert.java
时,(编译器)也会载入该文件,导致(编译器)同时遇到 Utensil 和 Dessert 的定义。
如果你使用命令 javac Main.java
或 javac Main.java Utensil.java
编译程序,它将按我们的预期打印出pancake。但是如果你使用命令 javac Dessert.java Main.java
编译程序,它将按别人的实现打印出 potpie。因此,程序的行为受到源文件传递给编译器的顺序的影响,这显然是不可接受的。
要避免这种问题,只需将顶层类(Utensil和Dessert)分割到单独的源文件中,或者放弃将它们作为顶层类,转而使用静态成员类。如下所示:
// Static member classes instead of multiple top-level classes
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";
}
}
第五章 泛型
Chapter 5. Generics
第26条:不要使用原始类型
Item 26: Do not use raw types
每个泛型都定义了一个原始类型,它是没有任何相关类型参数的泛型的名称。例如,List<E>
对应的原始类型是 List。原始类型的行为就好像所有泛型信息都从类型声明中删除了一样。它们的存在主要是为了与之前的泛型代码兼容。
使用原始类型是类型不安全的,编译器会发出警告,而且容易出现类型相关的运行时异常。
如下面的程序:
// Fails at runtime - unsafeAdd method uses a raw type (List)!
public static void main(String[] args) {
List<String> strings = new ArrayList<>();
unsafeAdd(strings, Integer.valueOf(42));
String s = strings.get(0); // Has compiler-generated cast
}
private static void unsafeAdd(List list, Object o) {
list.add(o);
}
因为使用了原始类型List,编译器会给出一个警告:
Test.java:10: warning: [unchecked] unchecked call to add(E) as a
member of the raw type List
list.add(o);
^
然后运行程序时,会在将strings.get(0)` 的结果强制转换为字符串的地方抛出一个 ClassCastException。
如果你想使用泛型,但不知道或不关心实际的类型参数是什么,那么可以使用问号代替。例如,泛型集合 Set<E>
的无界通配符类型是 Set<?>
。它是最通用的参数化集合类型,能够容纳任何集合:
// Uses unbounded wildcard type - typesafe and flexible
static int numElementsInCommon(Set<?> s1, Set<?> s2) { ... }
无界通配符类型和原始类型之间的区别是:前者中不能放入任何元素(除了 null)。如果你这样做,会得到一个编译报错:
WildCard.java:13: error: incompatible types: String cannot be converted to CAP#1
c.add("verboten");
^ where CAP#1
is a fresh type-variable:
CAP#1 extends Object from capture of ?
为便于参考,本条目中介绍的术语(以及后面将要介绍的一些术语)总结如下:
术语 | 例子 | 条目 |
---|---|---|
参数化类型 | List<String> | 第26条 |
实际的类型参数 | String | 第26条 |
泛型类型 | List<E> | 第26条、第29条 |
形式化类型参数 | E | 第26条 |
无界泛型表达式 | List<?> | 第26条 |
原始类型 | List | 第26条 |
有界类型参数 | <E extends Number> | 第29条 |
递归类型限制 | <T extends Comparable<T>> | 第30条 |
有界泛型表达式 | List<? extends Number> | 第31条 |
泛型方法 | static <E> List<E> asList(E[] a) | 第30条 |
类型记号 | String.class | 第33条 |
第27条:消除 unchecked 警告
Item 27: Eliminate unchecked warnings
使用泛型编程时,很容易看到unchecked编译器警告。我们应该尽可能消除这些警告。消除所有这些警告后,我们就能确保代码是类型安全的。
有时unchecked警告很容易消除,例如下面不规范的代码会导致编译器警告:
Set<Lark> exaltation = new HashSet();
我们可以修改一下,取消警告:
Set<Lark> exaltation = new HashSet<>();
但有时警告无法消除,如果我们可以证明代码是类型安全的,可以通过SuppressWarnings(“unchecked”) 注解来抑制警告。
SuppressWarnings应该在尽可能小的范围内使用。如下例在一个变量上使用这个注解:
// Adding local variable to reduce scope of @SuppressWarnings
public <T> T[] toArray(T[] a) {
if (a.length < size) {
// This cast is correct because the array we're creating
// is of the same type as the one passed in, which is T[].
@SuppressWarnings("unchecked") T[] result = (T[]) Arrays.copyOf(elements, size, a.getClass());
return result;
}
System.arraycopy(elements, 0, a, 0, size);
if (a.length > size)
a[size] = null;
return a;
}
每次使用注解时,要添加一条注释,说明这样做是安全的,以帮助他人理解代码。
第28条:list 优于数组
Item 28: Prefer lists to arrays
使用泛型时,优先考虑用list,而非数组。
数组和泛型有两个重要区别,这让它们在一起工作不那么协调。
区别一:数组是协变的,而泛型不是。如果 Sub 是 Super 的子类型,那么类型 Sub[] 就是类型 Super[] 的子类型,而List<Sub>
并非List<Super>
的子类型。
例如,下面这段代码是合法的:
// Fails at runtime!
Object[] objectArray = new Long[1];
objectArray[0] = "I don't fit in"; // Throws ArrayStoreException
但这一段代码就不是:
// Won't compile!
List<Object> ol = new ArrayList<Long>(); // Incompatible types
ol.add("I don't fit in");
两种方法都不能将 String 放入 Long 容器,但使用数组,你会得到一个运行时错误;使用 list,你可以在编译时发现问题。后者当然是更加安全的。
区别二:数组是具体化的,而泛型通过擦除来实现。这意味着,数组在运行时知道并强制执行他们的元素类型,而泛型只在编译时执行类型约束,在运行时丢弃类型信息,这样做是为了与不使用泛型的老代码兼容。
由于这些差异,数组和泛型不能很好地混合。例如,创建泛型、参数化类型或类型参数的数组是非法的。因此,这些数组创建表达式都是非法的:new List<E>[]
、new List<String>[]
、new E[]
。所有这些行为都会在编译时报错,原因是它们并非类型安全。如果合法,那么类型错误可能延迟到运行时才出现,这违反了泛型系统的基本保证。
例如下面代码:
// Why generic array creation is illegal - won't compile!
List<String>[] stringLists = new List<String>[1]; // (1)
List<Integer> intList = List.of(42); // (2)
Object[] objects = stringLists; // (3)
objects[0] = intList; // (4)
String s = stringLists[0].get(0); // (5)
如果第一行是合法的,那么会运行时到第5行才抛出运行时异常。
第29条:优先使用泛型类型
Item 29: Favor generic types
应该尽量在自己编写的类型中使用泛型,这会保证类型安全,并使代码更易使用。
下面我们通过例子来看下如何对一个现有类做泛型化改造。
首先是一个简单的堆栈实现:
// Object-based collection - a prime candidate for generics
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object e) {
ensureCapacity();
elements[size++] = e;
}
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // Eliminate obsolete reference
return result;
}
public boolean isEmpty() {
return size == 0;
}
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}
我们用适当的类型参数替换所有的 Object 类型,然后尝试编译修改后的程序:
// Initial attempt to generify Stack - won't compile!
public class Stack<E> {
private E[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new E[DEFAULT_INITIAL_CAPACITY];
}
public void push(E e) {
ensureCapacity();
elements[size++] = e;
}
public E pop() {
if (size == 0)
throw new EmptyStackException();
E result = elements[--size];
elements[size] = null; // Eliminate obsolete reference
return result;
} ... // no changes in isEmpty or ensureCapacity
}
这时生成一个错误:
Stack.java:8: generic array creation
elements = new E[DEFAULT_INITIAL_CAPACITY];
^
上一条目中讲到不能创建一个非具体化类型的数组。因此我们修改为:
// The elements array will contain only E instances from push(E).
// This is sufficient to ensure type safety, but the runtime
// type of the array won't be E[]; it will always be Object[]!
@SuppressWarnings("unchecked")
public Stack() {
elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
}
另一种解决编译错误的方法是将字段元素的类型从 E[] 更改为 Object[]。这时会得到一个不同的错误:
Stack.java:19: incompatible types
found: Object, required: E
E result = elements[--size];
^
You can change this error into a warning by casting the element retrieved from the array to E, but you will get a warning:
通过将从数组中检索到的元素转换为 E,可以将此错误转换为警告。我们对警告做抑制:
// Appropriate suppression of unchecked warning
public E pop() {
if (size == 0)
throw new EmptyStackException();
// push requires elements to be of type E, so cast is correct
@SuppressWarnings("unchecked")
E result =(E) elements[--size];
elements[size] = null; // Eliminate obsolete reference
return result;
}
第30条:优先使用泛型方法
Item 30: Favor generic methods
应尽量使方法支持泛型,这样可以保证类型安全,并让代码更容易使用。例如下面代码:
// Generic method
public static <E> Set<E> union(Set<E> s1, Set<E> s2) {
Set<E> result = new HashSet<>(s1);
result.addAll(s2);
return result;
}
有时,你需要创建一个对象,该对象是不可变的,但适用于许多不同类型,这时可以用泛型单例工厂模式来实现,如 Collections.emptySet。
下面的例子实现了一个恒等函数分发器:
// Generic singleton factory pattern
private static UnaryOperator<Object> IDENTITY_FN = (t) -> t;
@SuppressWarnings("unchecked")
public static <T> UnaryOperator<T> identityFunction() {
return (UnaryOperator<T>) IDENTITY_FN;
}
然后是对这个恒等函数分发器的使用:
// Sample program to exercise generic singleton
public static void main(String[] args) {
String[] strings = { "jute", "hemp", "nylon" };
UnaryOperator<String> sameString = identityFunction();
for (String s : strings)
System.out.println(sameString.apply(s));
Number[] numbers = { 1, 2.0, 3L };
UnaryOperator<Number> sameNumber = identityFunction();
for (Number n : numbers)
System.out.println(sameNumber.apply(n));
}
递归类型限定允许类型参数被包含该类型参数本身的表达式限制,常见的场合是用在Comparable接口上。例如:
// Using a recursive type bound to express mutual comparability
public static <E extends Comparable<E>> E max(Collection<E> c);
第31条:使用有界通配符增加 API 的灵活性
Item 31: Use bounded wildcards to increase API flexibility
在泛型中使用有界通配符,可以让API更加灵活。
考虑第29条中的堆栈类。我们创建一个Stack<Number>
类型的堆栈,并在其中插入integer。
Stack<Number> numberStack = new Stack<>();
Iterable<Integer> integers = ... ;
numberStack.pushAll(integers);
这个例子在直觉上似乎是没问题的。然而实际执行的时候会报错:
StackTest.java:7: error: incompatible types: Iterable<Integer>
cannot be converted to Iterable<Number>
numberStack.pushAll(integers);
^
解决办法是使用带extends的有界通配符类型。下面代码表示泛型参数为E的子类型(包括E类型本身):
// Wildcard type for a parameter that serves as an E producer
public void pushAll(Iterable<? extends E> src) {
for (E e : src)
push(e);
}
作为堆栈,还需要对外提供弹出元素的方法,以下是基础实现:
// popAll method without wildcard type - deficient!
public void popAll(Collection<E> dst) {
while (!isEmpty())
dst.add(pop());
}
但是这个实现在遇到下面场景时也会出现运行时报错,错误信息与前面的非常类似:Collection<Object>
不是 Collection<Number>
的子类型。
Stack<Number> numberStack = new Stack<Number>();
Collection<Object> objects = ... ;
numberStack.popAll(objects);
解决办法是使用带super的有界通配符类型。下面例子表示泛型参数为E的超类(包括E类型本身)。
// Wildcard type for parameter that serves as an E consumer
public void popAll(Collection<? super E> dst) {
while (!isEmpty()
dst.add(pop());
}
总结上面例子的经验,就是生产者用extends通配符,消费者用super通配符。可以简记为PECS原则:producer-extends, consumer-super。
第32条:明智地合用泛型和可变参数
Item 32: Combine generics and varargs judiciously
可变参数方法和泛型在一起工作时不那么协调,因此需要特别注意。
可变参数方法的设计属于一个抽象泄漏,即当你调用可变参数方法时,将创建一个数组来保存参数;该数组本应是实现细节,却是可见的。因此会出现编译器警告。
下面是一个可变参数和泛型混用,造成类型错误的例子:
// Mixing generics and varargs can violate type safety!
static void dangerous(List<String>... stringLists) {
List<Integer> intList = List.of(42);
Object[] objects = stringLists;
objects[0] = intList; // Heap pollution
String s = stringLists[0].get(0); // ClassCastException
}
有人可能会问:为什么使用泛型可变参数声明方法是合法的,而显式创建泛型数组是非法的?因为带有泛型或参数化类型的可变参数的方法在实际开发中非常有用,因此语言设计人员选择忍受这种不一致性。
要保证可变参数和泛型混用时的类型安全,有以下两种方式:
- 为方法添加 SafeVarargs标记,这代表方法的编写者承诺这个方法是类型安全的,需要方法编写者自己保证。这时编译器警告会被消除。
- 如果方法内部保证可变参数只读,不做任何修改,那么这个方法也是类型安全的。
将数组传递给另一个使用 @SafeVarargs 正确注释的可变参数方法是安全的,将数组传递给仅计算数组内容的某个函数的非可变方法也是安全的。
第33条:考虑类型安全的异构容器
Item 33: Consider typesafe heterogeneous containers
在前面的例子中,类型参数都是固定数量,例如Map<K, V>
就只有两个类型参数。如果我们需要无限数量的类型参数,可以通过将类型参数放置在键上而不是容器上。例如,可以使用 DatabaseRow 类型表示数据库行,并使用泛型类型 Column<T>
作为它的键。
下面的例子实现了以类型作为键的缓存,它是类型安全的,例如以String类型为键读取时,读到的对象肯定也是String类型,而不是Integer类型。
// Typesafe heterogeneous container pattern - implementation
public class Favorites {
private Map<Class<?>, Object> favorites = new HashMap<>();
public <T> void putFavorite(Class<T> type, T instance) {
favorites.put(Objects.requireNonNull(type), instance);
}
public <T> T getFavorite(Class<T> type) {
return type.cast(favorites.get(type));
}
// Typesafe heterogeneous container pattern - client
public static void main(String[] args) {
Favorites f = new Favorites();
f.putFavorite(String.class, "Java");
f.putFavorite(Integer.class, 0xcafebabe);
f.putFavorite(Class.class, Favorites.class);
String favoriteString = f.getFavorite(String.class);
int favoriteInteger = f.getFavorite(Integer.class);
Class<?> favoriteClass = f.getFavorite(Class.class);
System.out.printf("%s %x %s%n", favoriteString,favoriteInteger, favoriteClass.getName());
}
}