一文简单理解《Effective Java》建议

考虑用静态工厂方法替代构造方法

传统的获取一个对象实例,通常是通过构造方法,new一个对象;不同数量的入参,会有不同的构造方法;

例如,统一的返回结果类,传统方式(伪代码)如下:

//成功
return new Result(200);
//成功,返回信息、对象
return new Result(200,"成功",data);
//失败,返回信息
return new Result(500,"xxx不能为空");

​ 我们使用静态工厂方法替代方式重写,如下:

//成功,无参
return Result.success();
//成功,返回对象
return Result.ofSuccess(data);
//失败,返回信息
return Result.ofFail("xxx不能为空");

使用静态工厂方法主要有以下优点:

  1. 不像构造方法相同的名字,它们可以自定义方法名称,便于区别使用;
  2. 不像构造方法每次调用new新对象,它们可以不用每次都新建对象;(享元模式或单例模式)
  3. 不同于构造方法,它们可以返回,定义的返回类型和其子类型;
  4. 返回对象的类,可以根据输入参数的不同而不同;(面向抽象编程)
  5. 在编写包含该方法的类时,返回对象的类可以不存在;(面向抽象编程,可返回派生类)

静态工厂方法主要缺点有:

  1. 因为是静态类,且面向抽象编程,不好子类实例化

  2. 因为自定义方法名,如果没有文档或查看源码,程序员不好查找

当构造方法参数过多时,使用Builder模式

​ 例如当前有一个用户类,有多个属性:

public class User {
    
    
    private String name;
    private String nickname;
    private int sex;
    private int age;
    private String phone;
    private String mail;
    private String address;
    private int height;
    private int weight;

	//构造方法
    public User(String name, String nickname, int sex, int age) {
    
    
        this.name = name;
        this.nickname = nickname;
        this.sex = sex;
        this.age = age;
    }

	//getter和setter方法
    public String getName() {
    
    
        return name;
    }

    public void setName(String name) {
    
    
        this.name = name;
    }
    
    ...

​ 按照传统方式传建对象方式如下:

 		//1.可选参数,构造方法实例化
        User user = new User("张天空", "张三", 1, 20);

        //2.set方法复制实例化
        User user2 = new User();
        user2.setName("张天空");
        user2.setNickname("张三");
        user2.setSex(1);
        user2.setAge(20);
        user2.setPhone("14785236915");
        user2.setHeight(175);
        user2.setWeight(157);

​ 构造方法的不足是,就是面对多个可选参数时,不方便伸缩;需要针对每种情况定义对应的构造方法;

​ JavaBeans方式(setter方法),赋值可能冗长;更严重的不足是,由于构造赋值方法,可在多次调用中被分割,所以在构造过程中,JavaBean可能处于不一致状态;(例如,JavaBean对象作为参数,进行引用传递时,不同方法对其进行set赋值,就可能导致JavaBean不一致)

​ Builder模式对构造方法的优势是,可以有多个可变参数,且构造比较灵活;

使用私有构造方法或枚举来实现单例

// Singleton.java
public enum Singleton {
    
    
    INSTANCE;
 
    public void testMethod() {
    
    
        System.out.println("执行了单例类的方法");
    }
}
 
// Test.java
public class Test {
    
    
 public static void main(String[] args) {
    
    
        //演示如何使用枚举写法的单例类
        Singleton.INSTANCE.testMethod();
        System.out.println(Singleton.INSTANCE);
    }
}

//输出:
执行了单例类的方法
INSTANCE

​ 枚举实现单例类似于公共属性方法,但更简洁,提供了免费的序列化机制,并提供了针对多个实例化的坚固保证,即 使是在复杂的序列化或反射攻击的情况下

​ 这种方法可能感觉有点不自然,但是单一元素枚举类通常是实现单例的 佳方式。注意,如果单例必须继承 Enum 以外的父类 (尽管可以声明一个 Enum 来实现接口),那么就不能使用这种 方法

​ 参考链接:https://juejin.cn/post/7229660119658512441

使用私有构造方法来避免实例化

​ 偶尔你会想写一个类,它只是一组静态方法和静态属性;(比如一个工具类或常量类)

​ 这样的实用类(utility classes)不是设计用来被实例化的,因此可以通过包含一个私有构造方法来实现类的非实例化

使用依赖注入取代硬连接资源

​ 许多类依赖于一个或多个底层资源;所谓硬连接资源,就是将所依赖的资源设置为静态或者单例;这种硬链接会不方便扩展,不够灵活;可使用依赖注入的方式来取代;静态实用类和单例对于那些行为被底层资源参数化的类来说是不合适的

硬链接资源方式:

public class SpellChecker {
    
    
    private static final Lexicon dictionary = ...;
    private SpellChecker() {
    
    } // Noninstantiable
    public static boolean isValid(String word) {
    
     ... }
    public static List<String> suggestions(String typo) {
    
     ... }
}

依赖注入方式:

public class SpellChecker {
    
    
    private final Lexicon dictionary;
    public SpellChecker(Lexicon dictionary) {
    
    
        this.dictionary = Objects.requireNonNull(dictionary);
    }
    public boolean isValid(String word) {
    
     ... }
    public List<String> suggestions(String typo) {
    
     ... }
}

避免创建不必要的对象

​ 此条目不应该被误解为暗示对象创建是昂贵的,应该避免创建对象。 相反,使用构造方法创建和回收小的对 象是非常廉价,构造方法只会做很少的显示工作,尤其是在现代 JVM 实现上。 创建额外的对象以增强程序的清晰 度,简单性或功能性通常是件好事。

相反,除非池中的对象非常重量级,否则通过维护自己的对象池来避免对象创建是一个坏主意。对象池的典型例 子就是数据库连接。建立连接的成本非常高,因此重用这些对象才是有意义的

消除过期的对象引用

如果一个栈增长后收缩,那么从栈弹出的对象不会被垃圾收集,即使使用栈的程序 不再引用这些对象。 这是因为栈维护对这些对象的过期引用( obsolete references)。 过期引用简单来说就是永远不 会解除的引用。 在这种情况下,元素数组“活动部分(active portion)”之外的任何引用都是过期的。 活动部分是由 索引下标小于 size 的元素组成

​ 垃圾收集语言中的内存泄漏(更适当地称为无意的对象保留 unintentional object retentions)是隐蔽的。 如果无 意中保留了对象引用,那么不仅这个对象排除在垃圾回收之外,而且该对象引用的任何对象也是如此。 即使只有少 数对象引用被无意地保留下来,也可以阻止垃圾回收机制对许多对象的回收,这对性能产生很大的影响。

​ 这类问题的解决方法很简单:一旦对象引用过期,将它们设置为 null

​ 当程序员第一次被这个问题困扰时,他们可能会在程序结束后立即清空所有对象引用。这既不是必要的,也不是 可取的;它不必要地搞乱了程序。清空对象引用应该是例外而不是规范。消除过期引用的好方法是让包含引用的变 量超出范围。如果在近的作用域范围内定义每个变量 (条目 57),这种自然就会出现这种情况

避免使用Finilizer和Cleaner机制

Finalizer 机制是不可预知的,往往是危险的,而且通常是不必要的。 它们的使用会导致不稳定的行为,糟糕的性 能和移植性问题。 Finalizer 机制有一些特殊的用途,我们稍后会在这个条目中介绍,但是通常应该避免它们。 从 Java 9 开始,Finalizer 机制已被弃用,但仍被 Java 类库所使用。 Java 9 中 Cleaner 机制代替了 Finalizer 机制。 Cleaner 机制不如 Finalizer 机制那样危险,但仍然是不可预测,运行缓慢并且通常是不必要的

​ Finalizer 和 Cleaner 机制的一个缺点是不能保证他们能够及时执行[JLS,12.6]。 在一个对象变得无法访问时,到 Finalizer 和 Cleaner 机制开始运行时,这期间的时间是任意长的。 这意味着你永远不应该 Finalizer 和 Cleaner 机制做 任何时间敏感(time-critical)的事情

try-with-resources 语句替代 try-finally 语句

​ 当 Java 7 引入了 try-with-resources 语句时,所有这些问题一下子都得到了解决[JLS,14.20.3]。要使用这个构造, 资源必须实现 AutoCloseable 接口,该接口由一个返回为 void 的 close 组成。Java 类库和第三方类库中的 许多类和接口现在都实现或继承了 AutoCloseable 接口。如果你编写的类表示必须关闭的资源,那么这个类也应 该实现 AutoCloseable 接口

在处理必须关闭的资源时,使用 try-with-resources 语句替代 try-finally 语句。 生成的代码更简洁,更 清晰,并且生成的异常更有用。 try-with-resources 语句在编写必须关闭资源的代码时会更容易,也不会出错,而使 用 try-finally 语句实际上是不可能的。

重写 equals 方法时遵守通用约定

​ 虽然 Object 是一个具体的类,但它主要是为继承而设计的。它的所有非 final 方法 (equals、hashCode、 toString、clone 和 finalize) 都有清晰的通用约定( general contracts),因为它们被设计为被子类重写。任何类都有 义务重写这些方法,以遵从他们的通用约定;如果不这样做,将会阻止其他依赖于约定的类 (例如 HashMap 和 HashSet) 与此类一起正常工作

什么时候需要重写 equals 方法呢?如果一个类包含一个逻辑相等( logical equality)的概念,此概念有别于对象 标识(object identity),而且父类还没有重写过 equals 方法。这通常用在值类( value classes)的情况。值类只是一 个表示值的类,例如 Integer 或 String 类。程序员使用 equals 方法比较值对象的引用,期望发现它们在逻辑上是否相 等,而不是引用相同的对象。重写 equals 方法不仅可以满足程序员的期望,它还支持重写过 equals 的实例作为 Map 的键(key),或者 Set 里的元素,以满足预期和期望的行为

​ 当你重写 equals 方法时,必须遵守它的通用约定。Object 的规范如下: equals 方法实现了一个等价关系 (equivalence relation)。它有以下这些属性:

  • 自反性:对于任何非空引用 x, x.equals(x) 必须返回 true
  • 对称性: 对于任何非空引用 x 和 y,如果且仅当 y.equals(x) 返回 true 时 x.equals(y) 必须返回 true
  • 传递性: 对于任何非空引用 x、y、z,如果 x.equals(y) 返回 true, y.equals(z) 返回 true,则 x.equals(z) 必须返回 true
  • 一致性: 对于任何非空引用 x 和 y,如果在 equals 比较中使用的信息没有修改,则 x.equals(y) 的多次调用 必须始终返回 true 或始终返回 false
  • 对于任何非空引用 x, x.equals(null) 必须返回 false

综合起来,以下是编写高质量 equals 方法的配方:

  1. 使用 == 运算符检查参数是否为该对象的引用。如果是,返回 true。这只是一种性能优化,但是如果这种比较可 能很昂贵的话,那就值得去做。
  2. 使用 instanceof 运算符来检查参数是否具有正确的类型。 如果不是,则返回 false。 通常,正确的类型是 equals 方法所在的那个类。 有时候,改类实现了一些接口。 如果类实现了一个接口,该接口可以改进 equals 约 定以允许实现接口的类进行比较,那么使用接口。 集合接口(如 Set,List,Map 和 Map.Entry)具有此特性。 3. 参数转换为正确的类型。因为转换操作在 instanceof 中已经处理过,所以它肯定会成功。
  3. 对于类中的每个“重要”的属性,请检查该参数属性是否与该对象对应的属性相匹配。如果所有这些测试成功, 返回 true,否则返回 false。如果步骤 2 中的类型是一个接口,那么必须通过接口方法访问参数的属性;如果类型 是类,则可以直接访问属性,这取决于属性的访问权限。

​ 总之,除非必须:在很多情况下,不要重写 equals 方法,从 Object 继承的实现完全是你想要的。 如果你确实重 写了 equals 方法,那么一定要比较这个类的所有重要属性,并且以保护前面 equals 约定里五个规定的方式去比较

重写 equals 方法时同时也要重写 hashcode 方法

在每个类中,在重写 equals 方法的时侯,一定要重写 hashcode 方法。 如果不这样做,你的类违反了 hashCode 的通用约定,这会阻止它在 HashMap 和 HashSet 这样的集合中正常工作

​ 根据 Object 规范,以下是具体约定:

  • 当在一个应用程序执行过程中,如果在 equals 方法比较中没有修改任何信息,在一个对象上重复调用 hashCode 方法时,它必须始终返回相同的值。从一个应用程序到另一个应用程序的每一次执行返回的值可以是 不一致的
  • 如果两个对象根据 equals(Object) 方法比较是相等的,那么在两个对象上调用 hashCode 就必须产生的结果是相 同的整数
  • 如果两个对象根据 equals(Object) 方法比较并不相等,则不要求在每个对象上调用 hashCode 都必须产生不同的 结果。 但是,程序员应该意识到,为不相等的对象生成不同的结果可能会提高散列表(hash tables)的性能

​ 当无法重写 hashCode 时,所违反第二个关键条款是:相等的对象必须具有相等的哈希码

@Override 
public int hashCode() {
    
     return 42; } 
这是合法的,因为它确保了相等的对象具有相同的哈希码。这很糟糕,因为它确保了每个对象都有相同的哈希 码。因此,每个对象哈希到同一个桶中,哈希表退化为链表。应该在线性时间内运行的程序,运行时间变成了平方级 别。对于数据很大的哈希表而言,会影响到能够正常工作。

 **一个好的 hash 方法趋向于为不相等的实例生成不相等的哈希码**。这也正是 hashCode 约定中第三条的表达。理 想情况下,hash 方法为集合中不相等的实例均地分配 int 范围内的哈希码

​ 总之,每次重写 equals 方法时都必须重写 hashCode 方法,否则程序将无法正常运行。你的 hashCode 方法必须 遵从 Object 类指定的常规约定,并且必须执行合理的工作,将不相等的哈希码分配给不相等的实例

始终重写 toString 方法

​ 虽然 Object 类提供了 toString 方法的实现,但它返回的字符串通常不是你的类的用户想要看到的。 它由类名后 跟一个“at”符号(@)和哈希码的无符号十六进制表示组成,例如 User@163b91 。

​ toString 的通用约定要 求,返回的字符串应该是“一个简洁但内容丰富的表示,对人们来说是很容易阅读的”。

​ 虽然它并不像遵守 equals 和 hashCode 约定那样重要,但是提供一个良好的 toString 实现使你的 类更易于使用,并对使用此类的系统更易于调试。当对象被传递到 println、printf、字符串连接操作符或断言,或者 由调试器打印时,toString 方法会自动被调用

谨慎地重写 clone 方法

​ 假设你希望在一个类中实现 Cloneable 接口,它的父类提供了一个行为良好的 clone 方法。首先调用 super.clone。 得到的对象将是原始的完全功能的复制品。 在你的类中声明的任何属性将具有与原始属性相同的值。 如果每个属性包含原始值或对不可变对象的引用,则返回的对象可能正是你所需要的,在这种情况下,不需要进一步 的处理

​ 考虑到与 Cloneable 接口相关的所有问题,新的接口不应该继承它,新的可扩展类不应该实现它。 虽然实现 Cloneable 接口对于 final 类没有什么危害,但应该将其视为性能优化的角度,仅在极少数情况下才是合理的(条目 67)。 通常,复制功能好由构造方法或工厂提供。 这个规则的一个明显的例外是数组,它好用 clone 方法复 制。

考虑实现 Comparable 接口

​ 有时,你可能会看到 compareTo 或 compare 方法依赖于两个值之间的差值,如果第一个值小于第二个值, 则为负;如果两个值相等则为零,如果第一个值大于,则为正值。这是一个例子:

static Comparator<Object> hashCodeOrder = new Comparator<>() {
    
         
    public int compare(Object o1, Object o2) {
    
             
        return o1.hashCode() - o2.hashCode();     
    } 
}; 

不要使用这种技术!它可能会导致整数大长度溢出和 IEEE 754 浮点运算失真的危险[JLS 15.20.1,15.21.1]。 此 外,由此产生的方法不可能比使用上述技术编写的方法快得多。 使用静态 compare 方法:

static Comparator<Object> hashCodeOrder = new Comparator<>() {
    
         
    public int compare(Object o1, Object o2) {
    
             
        return Integer.compare(o1.hashCode(), o2.hashCode());     
    } 
}; 

​ 或者使用 Comparator 的构建方法:

static Comparator<Object> hashCodeOrder =         
Comparator.comparingInt(o -> o.hashCode()); 

​ 总而言之,无论何时实现具有合理排序的值类,你都应该让该类实现 Comparable 接口,以便在基于比较的集 合中轻松对其实例进行排序,搜索和使用。 比较 compareTo 方法的实现中的字段值时,请避免使用"<“和”>"运算 符。 相反,使用包装类中的静态 compare 方法或 Comparator 接口中的构建方法

使类和成员的可访问性小化

一个设计良好的组件隐藏了它的所有实现细节,干净地将它的 API 与它的实现分离开来。然后,组件只通 过它们的 API 进行通信,并且对彼此的内部工作一无所知。这一概念,被称为信息隐藏或封装,是软件设计的基本原 则(迪米特法则,也即最少知道原则)

​ 信息隐藏很重要有很多原因,其中大部分来源于它将组成系统的组件分离开来,允许它们被独立地开发,测试, 优化,使用,理解和修改。这加速了系统开发,因为组件可以并行开发。它减轻了维护的负担,因为可以更快速地理 解组件,调试或更换组件,而不用担心损害其他组件。

​ Java 提供了许多机制来帮助信息隐藏。 访问控制机制(access control mechanism)[JLS,6.6] 指定了类,接口和 成员的可访问性。 实体的可访问性取决于其声明的位置,以及声明中存在哪些访问修饰符(private,protected 和 public)。 正确使用这些修饰符对信息隐藏至关重要。

​ 经验法则很简单:让每个类或成员尽可能地不可访问。 换句话说,使用尽可能低的访问级别,与你正在编写的 软件的对应功能保持一致

在公共类中使用访问方法而不是公共属性

​ 对于公共类来说,坚持面向对象是正确的:如果一个类在其包之外是可访问的,则提供访问方法来保留更 改类内部表示的灵活性。 如果一个公共类暴露其数据属性,那么以后更改其表示形式基本上没有可能,因为客户端 代码可以散布在很多地方。

但是,如果一个类是包级私有的,或者是一个私有的内部类,那么暴露它的数据属性就没有什么本质上的错误 ——假设它们提供足够描述该类提供的抽象

​ 总之,公共类不应该暴露可变属性。 公共累暴露不可变属性的危害虽然仍然存在问题,但其危害较小。 然而, 有时需要包级私有或私有内部类来暴露属性,无论此类是否是可变的。

最小化可变性

不可变类简单来说是它的实例不能被修改的类。 包含在每个实例中的所有信息在对象的生命周期中是固定的, 因此不会观察到任何变化。 Java 平台类库包含许多不可变的类,包括 String 类,基本类型包装类以及 BigInteger 类和 BigDecimal 类。 有很多很好的理由:不可变类比可变类更容易设计,实现和使用。 他们不太 容易出错,更安全。

​ 要使一个类不可变,请遵循以下五条规则:

  1. 不要提供修改对象状态的方法
  2. 确保这个类不能被继承。 这可以防止粗心的或恶意的子类,假设对象的状态已经改变,从而破坏类的不可变 行为。 防止子类化通常是通过 final 修饰类,但是我们稍后将讨论另一种方法。
  3. 把所有属性设置为 final。 通过系统强制执行,清楚地表达了你的意图。 另外,如果一个新创建的实例的引用 从一个线程传递到另一个线程而没有同步,就必须保证正确的行为,正如内存模型[JLS,17.5; Goetz06,16] 所 述。
  4. 把所有的属性设置为 private。 这可以防止客户端获得对属性引用的可变对象的访问权限并直接修改这些对 象。 虽然技术上允许不可变类具有包含基本类型数值的公共 final 属性或对不可变对象的引用,但不建议这 样做,因为它不允许在以后的版本中更改内部表示
  5. 确保对任何可变组件的互斥访问。 如果你的类有任何引用可变对象的属性,请确保该类的客户端无法获得对 这些对象的引用。 切勿将这样的属性初始化为客户端提供的对象引用,或从访问方法返回属性。 在构造方法, 访问方法和 readObject 方法(条目 88)中进行防御性拷贝

不可变对象很简单。 一个不 可变的对象可以完全处于一种状态,也就是被创建时的状态

不可变对象本质上是线程安全的; 它们不需要同步。 被多个线程同时访问它们时并不会被破坏。 这是实现线程 安全的简单方法。 由于没有线程可以观察到另一个线程对不可变对象的影响,所以不可变对象可以被自由地共 享

不仅可以共享不可变的对象,而且可以共享内部信息;

不可变对象为其他对象提供了很好的构件 ,无论是可变的还是不可变的。 如果知道一个复 杂组件的内部对象不会发生改变,那么维护复杂对象的不变量就容易多了

不可变对象提供了免费的原子失败机制。 它们的状态永远不会改变,所以不可能出现临时的不一 致

不可变类的主要缺点是对于每个不同的值都需要一个单独的对象

总而言之,坚决不要为每个属性编写一个 get 方法后再编写一个对应的 set 方法。 除非有充分的理由使类成为可 变类,否则类应该是不可变的。 不可变类提供了许多优点,唯一的缺点是在某些情况下可能会出现性能问题。 你应 该始终使用较小的值对象,使其不可变。

​ 对于一些类来说,不变性是不切实际的。如果一个类不能设计为不可变类,那么也要尽可能地限制它的可变性 。减少对象可以存在的状态数量,可以更容易地分析对象,以及降低出错的可能性。因此,除非有足够的理由把属性 设置为非 final 的情况下,否则应该每个属性都设置为 final 的。把本条目的建议与条目 15 的建议结合起来, 你自然的倾向就是:除非有充分的理由不这样做,否则应该把每个属性声明为私有 final 的

组合优于继承

与方法调用不同,继承打破了封装。 换句话说,一个子类依赖于其父类的实现细节来保证其正确的功 能。 父类的实现可能会从发布版本不断变化,如果是这样,子类可能会被破坏,即使它的代码没有任何改变。 因 此,一个子类必须与其超类一起更新而变化,除非父类的作者为了继承的目的而专门设计它,并对应有文档的说明。

​ 这两个问题都源于重写方法。 如果仅仅添加新的方法并且不要重写现有的方法,可能会认为继承一个类是安全 的。 虽然这种扩展更为安全,但这并非没有风险。 如果父类在后续版本中添加了一个新的方法,并且你不幸给了子 类一个具有相同签名和不同返回类型的方法,那么你的子类编译失败。 如果已经为子类提供了一个与新 的父类方法具有相同签名和返回类型的方法,那么你现在正在重写它,因此将遇到前面所述的问题。 此外,你的方 法是否会履行新的父类方法的约定,这是值得怀疑的,因为在你编写子类方法时,这个约定还没有写出来

​ 幸运的是,有一种方法可以避免上述所有的问题。不要继承一个现有的类,而应该给你的新类增加一个私有属 性,该属性是 现有类的实例引用,这种设计被称为组合(composition),因为现有的类成为新类的组成部分。新类 中的每个实例方法调用现有类的包含实例上的相应方法并返回结果。这被称为转发(forwarding),而新类中的方法 被称为转发方法。

​ 总之,继承是强大的,但它是有问题的,因为它违反封装。 只有在子类和父类之间存在真正的子类型关系时才 适用。 即使如此,如果子类与父类不在同一个包中,并且父类不是为继承而设计的,继承可能会导致脆弱性。 为了 避免这种脆弱性,使用合成和转发代替继承,特别是如果存在一个合适的接口来实现包装类。 包装类不仅比子类更 健壮,而且更强大。

若用继承则设计,并文档说明

​ 那么为了继承而设计和文档说明一 个类是什么意思呢?

​ 首先,这个类必须准确地描述重写这个方法带来的影响。 换句话说,该类必须文档说明可重写方法的自用性 (self-use)。 对于每个公共或受保护的方法,文档必须指明方法调用哪些重写方法,以何种顺序以及每次调用的结 果如何影响后续处理。 (重写方法,这里是指非 final 修饰的方法,无论是公开还是保护的。)更一般地说,一 个类必须文档说明任何可能调用可重写方法的情况。

​ 那么当你设计一个继承类的时候,你如何决定暴露哪些的受保护的成员呢? 不幸的是,没有灵丹妙药。 所能做 的好的就是努力思考,做出好的测试,然后通过编写子类来进行测试。 应该尽可能少地暴露受保护的成员,因 为每个成员都表示对实现细节的承诺。

测试为继承而设计的类的唯一方法是编写子类。 如果你忽略了一个关键的受保护的成员,试图编写一个子类将 会使得遗漏痛苦地变得明显。 相反,如果编写的几个子类,而且没有一个使用受保护的成员,那么应该将其设为私 有。

构造方法绝不能直接或间接调用可重写的方法。 如果违反这个规则,将 导致程序失败。 父类构造方法在子类构造方法之前运行,所以在子类构造方法运行之前,子类中的重写方法被调 用。 如果重写方法依赖于子类构造方法执行的任何初始化,则此方法将不会按预期运行。

​ 解决这个问题的好办法是,在没有想要安全地子类化的设计和文档说明的类中禁止子类化。 有两种方法禁止 子类化。 两者中较容易的是声明类为 final 。 另一种方法是使所有的构造方法都是私有的或包级私有的,并且添 加公共静态工厂来代替构造方法

接口优于抽象类

​ Java 有两种机制来定义允许多个实现的类型:接口和抽象类。 由于在 Java 8 中引入了接口的默认方 法(default methods ),因此这两种机制都允许为某些实例方法提供实现。 一个主要的区别是要实现由抽象类定义 的类型,类必须是抽象类的子类。

​ 接口是定义混合类型(mixin)的理想选择。 一般来说,mixin 是一个类,除了它的“主类型”之外,还可以声明它 提供了一些可选的行为。 例如, Comparable 是一个类型接口,它允许一个类声明它的实例相对于其他可相互比较 的对象是有序的。 这样的接口被称为类型,因为它允许可选功能被“混合”到类型的主要功能。 抽象类不能用于定义 混合类,这是因为它们不能被加载到现有的类中:一个类不能有多个父类,并且在类层次结构中没有合理的位置来插 入一个类型。

​ 接口允许构建非层级类型的框架。 类型层级对于组织某些事物来说是很好的,但是其他的事物并不是整齐地落 入严格的层级结构中。

​ 但是,你可以通过提供一个抽象的骨架实现类(abstract skeletal implementation class)来与接口一起使用,将接口和抽象类的优点结合起来。 接口定义了类型,可能提供了一些默认的方法,而骨架实现类在原始接口方法的顶层 实现了剩余的非原始接口方法。 继承骨架实现需要大部分的工作来实现一个接口。 这就是模板方法设计模式 ,例如, 集合框架( Collections Framework)提供了一个框架实现以配合每个主要集合接口: AbstractCollection , AbstractSet , AbstractList 和 AbstractMap 。

​ 总而言之,一个接口通常是定义允许多个实现的类型的佳方式。 如果你导出一个重要的接口,应该强烈考虑 提供一个骨架的实现类。 在可能的情况下,应该通过接口上的默认方法提供骨架实现,以便接口的所有实现者都可 以使用它。 也就是说,对接口的限制通常要求骨架实现类采用抽象类的形式

为后代设计接口

​ 在 Java 8 之前,不可能在不破坏现有实现的情况下为接口添加方法。 如果向接口添加了一个新方法,现有的实 现通常会缺少该方法,从而导致编译时错误。 在 Java 8 中,添加了默认方法(default method)构造,目的 是允许将方法添加到现有的接口。 但是增加新的方法到现有的接口是充满风险的。

默认方法的声明包含一个默认实现,该方法允许实现接口的类直接使用,而不必实现默认方法。 虽然在 Java 中 添加默认方法可以将方法添加到现有接口,但不能保证这些方法可以在所有已有的实现中使用。 默认的方法被“注入 (injected)”到现有的实现中,没有经过实现类的知道或同意。 在 Java 8 之前,这些实现是用默认的接口编写的, 它们的接口永远不会获得任何新的方法。

​ 因此,在发布之前测试每个新接口是非常重要的。 至少,你应该 准备三种不同的实现。 编写多个使用每个新接口的实例来执行各种任务的客户端程序同样重要。 这将大大确保每个 接口都能满足其所有的预期用途。 这些步骤将允许你在发布之前发现接口中的缺陷,但仍然可以轻松地修正它们。 虽然在接口被发布后可能会修正一些存在的缺陷,但不要太指望这一点

接口仅用来定义类型

一种失败的接口就是所谓的常量接口(constant interface)。 这样的接口不包含任何方法; 它只包含静态 final 属 性,每个输出一个常量。 使用这些常量的类实现接口,以避免需要用类名限定常量名。

常量接口模式是对接口的糟糕使用。 类在内部使用一些常量,完全属于实现细节。实现一个常量接口会导致这 个实现细节泄漏到类的导出 API 中。对类的用户来说,类实现一个常量接口是没有意义的。事实上,它甚至可能使他 们感到困惑。更糟糕的是,它代表了一个承诺:如果在将来的版本中修改了类,不再需要使用常量,那么它仍然必须 实现接口,以确保二进制兼容性。如果一个非 final 类实现了常量接口,那么它的所有子类的命名空间都会被接口中 的常量所污染

​ 如果你想导出常量,有几个合理的选择方案。 如果常量与现有的类或接口紧密相关,则应将其添加到该类或接 口中。 例如,所有数字基本类型的包装类,如 Integer 和 Double ,都会导出 MIN_VALUE 和 MAX_VALUE 常 量。 如果常量好被看作枚举类型的成员,则应该使用枚举类型导出它们。 否则,你应该用一个不可实 例化的工具类来导出常量

​ 总之,接口只能用于定义类型。 它们不应该仅用于导出常量

优先使用类层次而不是标签类

​ 有时你可能会碰到一个类,它的实例有两个或更多的风格,并且包含一个标签属性(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 语句。 可读性 更差,因为多个实现在一个类中混杂在一起

​ 如果你添加一个风格,你必须记得给每个 switch 语句添加一个 case ,否则这个类将 在运行时失败。 最后,一个实例的数据类型没有提供任何关于风格的线索。 总之,标签类是冗长的,容易出错的, 而且效率低下

​ 幸运的是,像 Java 这样的面向对象的语言为定义一个能够表示多种风格对象的单一数据类型提供了更好的选 择:子类型化(subtyping)。标签类仅仅是一个类层次的简单的模仿。

​ 要将标签类转换为类层次,首先定义一个包含抽象方法的抽象类,该标签类的行为取决于标签值。 接下来,为原始标签类的每种类型定义一个根类的具体子类。

// 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; 
    } 
}

类层次的另一个优点是可以使它们反映类型之间的自然层次关系,从而提高了灵活性,并提高了编译时类型检查 的效率。

​ 总之,标签类很少有适用的情况。 如果你想写一个带有明显标签属性的类,请考虑标签属性是否可以被删除, 而类是否被类层次替换。 当遇到一个带有标签属性的现有类时,可以考虑将其重构为一个类层次中

优先考虑静态成员类

​ 嵌套类(nested class)是在另一个类中定义的类。 嵌套类应该只存在于其宿主类(enclosing class)中。 如果一 个嵌套类在其他一些情况下是有用的,那么它应该是一个顶级类。

有四种嵌套类:静态成员类,非静态成员类,匿 名类和局部类。 除了第一种以外,剩下的三种都被称为内部类(inner class)。 这个条目告诉你什么时候使用哪种类 型的嵌套类以及为什么使用。

静态成员类的一个常见用途是作为公共帮助类,仅在与其外部类一起使用时才有用。 例如,考虑一个描述计算 器支持的操作的枚举类型(条目 34)。 Operation 枚举应该是 Calculator 类的公共静态成员类。 Calculator 客户端可以使用 Calculator.Operation.PLUS 和 Calculator.Operation.MINUS 等名称来引 用操作。

​ 在语法上,静态成员类和非静态成员类之间的唯一区别是静态成员类在其声明中具有 static 修饰符。 非静态成员类的每个实例都隐含地与其包含的类的宿主实例相关联。 在非 静态成员类的实例方法中,可以调用宿主实例上的方法,或者使用限定的构造[JLS,15.8.4] 获得对宿主实例的引用。 如果嵌套类的实例可以与其宿主类的实例隔离存在,那么嵌套类必须是静态成员类:不可能在没有宿主实例的情况下 创建非静态成员类的实例

非静态成员类实例和其宿主实例之间的关联是在创建成员类实例时建立的,并且之后不能被修改。 通常情况 下,通过在宿主类的实例方法中调用非静态成员类构造方法来自动建立关联。

如果你声明了一个不需要访问宿主实例的成员类,把 static 修饰符放在它的声明中,使它成为一个静态成员 类,而不是非静态的成员类。 如果你忽略了这个修饰符,每个实例都会有一个隐藏的外部引用给它的宿主实例。 如 前所述,存储这个引用需要占用时间和空间。 更严重的是,并且会导致即使宿主类在满足垃圾回收的条件时却仍然 驻留在内存中(条目 7)。 由此产生的内存泄漏可能是灾难性的。 由于引用是不可见的,所以通常难以检测到。

单个源文件中定义单个顶级类

​ 虽然 Java 编译器允许在单个源文件中定义多个顶级类,但这样做没有任何好处,并且存在重大风险。 风险源于 在源文件中定义多个顶级类使得为类提供多个定义成为可能。 使用哪个定义会受到源文件传递给编译器的顺序的影 响。

// 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"; 
} 

​ 如果试 图将多个顶级类放入单个源文件中,请考虑使用静态成员类(条目 24)作为将类拆分为单独的源文件的替代方法。 如果这些类从属于另一个类,那么将它们变成静态成员类通常是更好的选择,因为它提高了可读性,并且可以通过声 明它们为私有来减少类的可访问性

不要直接使用原始类型(raw type)

​ 一个类或接口,它的声明有一个或多个类型参数(type parameters ),被称之为泛型类或泛 型接。 例如, List 接口具有单个类型参数 E,表示其元素类型。 接口的全名是 List (读 作“E”的列表),但是人们经常称它为 List 。 泛型类和接口统称为泛型类型(generic types)。

每个泛型定义了一个原始类型(raw type),它是没有任何类型参数的泛型类型的名称[JLS,4.8]。 例 如,对应于 List 的原始类型是 List 。 原始类型的行为就像所有的泛型类型信息都从类型声明中被清除一 样。 它们的存在主要是为了与没有泛型之前的代码相兼容

​ 使用原始类型(没有类型参数的泛型)是合法的,但是你不应该这样做。 如果你使用原始类型,则 会丧失泛型的所有安全性和表达上的优势。 鉴于你不应该使用它们,为什么语言设计者首先允许原始类型呢? 答案 是为了兼容性。

消除未检查警告

​ 使用泛型编程时,会看到许多编译器警告:未经检查的强制转换警告,未经检查的方法调用警告,未经检查的参 数化可变长度类型警告以及未经检查的转换警告。 你使用泛型获得的经验越多,获得的警告越少,但不要期望新编 写的代码能够干净地编译。

​ 当你收到需要进一步思考的警告时,坚持不懈! 尽可能 地消除每一个未经检查的警告如果你消除所有的警告,你可以放心,你的代码是类型安全的,这是一件非常好的 事情。 这意味着在运行时你将不会得到一个 ClassCastException 异常,并且增加了你的程序将按照你的意图行 事的信心。

如果你不能消除警告,但你可以证明引发警告的代码是类型安全的,那么(并且只能这样)用 @SuppressWarnings(“unchecked”) 注解来抑制警告。 如果你在没有首先证明代码是类型安全的情况下压制警 告,那么你给自己一个错误的安全感。 代码可能会在不发出任何警告的情况下进行编译,但是它仍然可以在运行时 抛出 ClassCastException 异常。

​ SuppressWarnings 注解可用于任何声明,从单个局部变量声明到整个类。 始终在尽可能小的范围内使用 SuppressWarnings 注解。 通常这是一个变量声明或一个非常短的方法或构造方法。 切勿在整个类上使用 SuppressWarnings 注解。 这样做可能会掩盖重要的警告。

每当使用 @SuppressWarnings(“unchecked”) 注解时,请添加注释,说明为什么是安全的。 这将有助于他 人理解代码,更重要的是,这将减少有人修改代码的可能性,从而使计算不安全。

列表优于数组

​ 数组在两个重要方面与泛型不同。

  1. 数组是协变的(covariant)。 这意味着如果 Sub 是 Super 的子类型,则数组类型 Sub[] 是数组类型 Super[] 的子类型。
  2. 泛型是不变的(invariant): 对于任何两种不同的类型 Type1 和 Type2 , List 既不是 List 的子类型也不是父类型。

​ 你可能认为这意味着泛型是不足的,但可以说是数组缺陷。 这段代码是合法的:

// 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 类型容器中,但是用一个数组,你会发现在运行时产生了一个错误;对于列表,可以在编译时就能发现错误。 当然,你宁愿在编译时找出错误。

​ 数组和泛型之间的第二个主要区别是数组被具体化了(reified)。

  1. 数组在运行时知道并强制 执行它们的元素类型。 如前所述,如果尝试将一个 String 放入 Long 数组中,得到一个 ArrayStoreException 异常。
  2. 泛型通过擦除(erasure)来实现。 这意味着它们只在编译时执行 类型约束,并在运行时丢弃(或擦除)它们的元素类型信息。 擦除是允许泛型类型与不使用泛型的遗留代码自由互 操作(条目 26),从而确保在 Java 5 中平滑过渡到泛型。

​ 总之,数组和泛型具有非常不同的类型规则。 数组是协变和具体化的; 泛型是不变的,类型擦除的。 因此,数组 提供运行时类型的安全性,但不提供编译时类型的安全性,反之亦然一般来说,数组和泛型不能很好地混合工 作。 如果你发现把它们混合在一起,得到编译时错误或者警告,你的第一个冲动应该是用列表来替换数组。

优先考虑泛型

泛型类型比需要在客户端代码中强制转换的类型更安全,更易于使用。 当你设计新的类型时,确保它们可以在没有这种强制转换的情况下使用。 这通常意味着使类型泛型化。 如果你有任何现有的类型,应该是泛型的但实际上却不是,那么把它们泛型化。 这使这些类型的新用户的使用更容易,而不会破坏现有的客户端。

优先使用泛型方法

​ 总之,像泛型类型一样,泛型方法比需要客户端对输入参数和返回值进行显式强制转换的方法更安全,更易于使 用。 像类型一样,你应该确保你的方法可以不用强制转换,这通常意味着它们是泛型的。 应该泛型化现有的方法, 其使用需要强制转换。 这使得新用户的使用更容易,而不会破坏现有的客户端。

使用限定通配符来增加灵活性

​ 为了获得大的灵活 性,对代表生产者或消费者的输入参数使用通配符类型。 如果一个输入参数既是一个生产者又是一个消费者,那么 通配符类型对你没有好处:你需要一个精确的类型匹配,这就是没有任何通配符的情况。

​ 这里有一个助记符来帮助你记住使用哪种通配符类型: PECS 代表: producer-extends,consumer-super。换句话说,如果一个参数化类型代表一个 T 生产者,使用 <? extends T> ;如果它代表 T 消费者,则使 用 <? super T> 。

​ 总之,在你的 API 中使用通配符类型,虽然棘手,但使得 API 更加灵活。 如果编写一个将被广泛使用的类库,正 确使用通配符类型应该被认为是强制性的。 记住基本规则: producer-extends, consumer-super(PECS)。 还要记 住,所有 Comparable 和 Comparator 都是消费者。

合理的结合泛型和可变参数

​ 为什么声明一个带有泛型可变参数的方法是合法的,当明确创建一个泛型数组 是非法的时候呢? 答 案是,具有泛型或参数化类型的可变参数参数的方法在实践中可能非常有用,因此语言设计人员选择忍受这种不一 致。 事实上,Java 类库导出了几个这样的方法,包括 Arrays.asList(T… a) , Collections.addAll(Collection<? super T> c, T… elements) , EnumSet.of(E first, E… rest) 。 与前面显示的危险方法不同,这些类库方法是类型安全的。

在 Java 7 中, @SafeVarargs 注解已添加到平台,以允许具有泛型可变参数的方法的作者自动禁止客户端警 告。 实质上, @SafeVarargs 注解构成了作者对类型安全的方法的承诺。 为了交换这个承诺,编译器同意不要警 告用户调用可能不安全的方法。

​ 除非它实际上是安全的,否则注意不要使用 @SafeVarargs 注解标注一个方法。 那么需要做些什么来确保这 一点呢? 回想一下,调用方法时会创建一个泛型数组,以容纳可变参数。 如果方法没有在数组中存储任何东西(它 会覆盖参数)并且不允许对数组的引用进行转义(这会使不受信任的代码访问数组),那么它是安全的。 换句话 说,如果可变参数数组仅用于从调用者向方法传递可变数量的参数——毕竟这是可变参数的目的——那么该方法是安 全的。

​ 总而言之,可变参数和泛型不能很好地交互,因为可变参数机制是在数组上面构建的脆弱的抽象,并且数组具有 与泛型不同的类型规则。 虽然泛型可变参数不是类型安全的,但它们是合法的。 如果选择使用泛型(或参数化)可 变参数编写方法,请首先确保该方法是类型安全的,然后使用 @SafeVarargs 注解对其进行标注,以免造成使用不 愉快。

优先考虑类型安全的异构容器

​ 泛型的常见用法包括集合,如 Set 和 Map<K,V> 和单个元素容器,如 ThreadLocal 和 AtomicReference 。 在所有这些用途中,它都是参数化的容器。 这限制了每个容器只能有固定数量的类型参 数。 通常这正是你想要的。 一个 Set 有单一的类型参数,表示它的元素类型; 一个 Map 有两个,代表它的键和 值的类型;等等。

​ 然而有时候,你需要更多的灵活性。 例如,数据库一行记录可以具有任意多列,并且能够以类型安全的方式访 问它们是很好的。 幸运的是,有一个简单的方法可以达到这个效果。 这个想法是参数化键(key)而不是容器。 然 后将参数化的键提交给容器以插入或检索值。 泛型类型系统用于保证值的类型与其键一致

​ 作为这种方法的一个简单示例,请考虑一个 Favorites 类,它允许其客户端保存和检索任意多种类型的 favorite 实例。 该类型的 Class 对象将扮演参数化键的一部分。其原因是这 Class 类是泛型的。 类的类型从字 面上来说不是简单的 Class ,而是 Class 。 例如, String.class 的类型为 Class , Integer.class 的类型为 Class 。 当在方法中传递字面类传递编译时和运行时类型信息时,它被称 为类型令牌(type token)。

示例代码:

// Typesafe heterogeneous container pattern - API 
public class Favorites {
    
         
    
    public <T> void putFavorite(Class<T> type, T instance);  
    
    public <T> T getFavorite(Class<T> type); 
}

​ 总之,泛型 API 的通常用法(以集合 API 为例)限制了每个容器的固定数量的类型参数。 你可以通过将类型参数 放在键上而不是容器上来解决此限制。 可以使用 Class 对象作为此类型安全异构容器的键。 以这种方式使用的 Class 对象称为类型令牌。 也可以使用自定义键类型。 例如,可以有一个表示数据库行(容器)的 DatabaseRow 类型和一个泛型类型 Column 作为其键。

使用枚举类型替代整型常量

​ 在将枚举类型添加到该语言之前,表示枚举类型的常见模式是声明一组名为 int 的常量,每个类型的成员都 有一个常量:

// The int enum pattern - severely deficient! 
public static final int APPLE_FUJI         = 0; 
public static final int APPLE_PIPPIN       = 1; 

​ 这种被称为 int 枚举模式的技术有许多缺点。 它没有提供类型安全的方式,也没有提供任何表达力。没有简单的方法将 int 枚举常量转换为可打印的字符串。 如果你打印这样一个常量或者从调试器中显示出 来,你看到的只是一个数字,这不是很有用。 没有可靠的方法来迭代组中的所有 int 枚举常量,甚至无法获得 int 枚举组的大小

​ 幸运的是,Java 提供了一种避免 int 和 String 枚举模式的所有缺点的替代方法,并提供了许多额外的好 处。

​ Java 枚举类型背后的基本思想很简单:它们是通过公共静态 final 属性为每个枚举常量导出一个实例的类。 由于没有可访问的构造方法,枚举类型实际上是 final 的。 由于客户既不能创建枚举类型的实例也不能继承它, 除了声明的枚举常量外,不能有任何实例。 换句话说,枚举类型是实例控制的(第 6 页)。 它们是单例(条目 3) 的泛型化,基本上是单元素的枚举。

枚举提供了编译时类型的安全性。 尝试传递错误类型的值将导致编译时错误,因为会尝试将一个枚举类型的 表达式分配给另一个类型的变量,或者使用 == 运算符来比较不同枚举类型的值。

有一种更好的方法可以将不同的行为与每个枚举常量关联起来:在枚举类型中声明一个抽象的 apply 方法,并用常量特定的类主体中的每个常量的具体方法重写它。 这种方法被称为特定于常量(constant-specific)的 方法实现:

// Enum type with constant-specific method implementations 
public enum Operation {
    
       
    PLUS  {
    
    public double apply(double x, double y){
    
    return x + y;}},   
    MINUS {
    
    public double apply(double x, double y){
    
    return x - y;}},  
    TIMES {
    
    public double apply(double x, double y){
    
    return x * y;}},   
    DIVIDE{
    
    public double apply(double x, double y){
    
    return x / y;}}; 
 
  public abstract double apply(double x, double y); 
} 

​ 如果向以上示例中操作添加新的常量,则不太可能会忘记提供 apply 方法,因为该方法紧跟在每个常量声 明之后。 万一忘记了,编译器会提醒你,因为枚举类型中的抽象方法必须被所有常量中的具体方法重写

​ 总之,枚举类型优于 int 常量的优点是令人信服的。 枚举更具可读性,更安全,更强大。 许多枚举不需要显 式构造方法或成员,但其他人则可以通过将数据与每个常量关联并提供行为受此数据影响的方法而受益。 使用单一 方法关联多个行为可以减少枚举。 在这种相对罕见的情况下,更喜欢使用常量特定的方法来枚举自己的值。 如果一 些(但不是全部)枚举常量共享共同行为,请考虑策略枚举模式。

使用实例属性代替序数

​ 永远不要从枚举的序号中得出与它相关的值; 请将其保存在实例属 性中:

public enum Ensemble {
    
         
    SOLO(1), 
    DUET(2), 
    TRIO(3), 
    QUARTET(4), 
    QUINTET(5),     
    SEXTET(6), 
    SEPTET(7), 
    OCTET(8), 
    DOUBLE_QUARTET(8),     
    NONET(9), 
    DECTET(10), 
    TRIPLE_QUARTET(12); 
 
    private final int numberOfMusicians;
    
    Ensemble(int size) {
    
     
        this.numberOfMusicians = size; 
    }     
    
    public int numberOfMusicians() {
    
     
        return numberOfMusicians; 
    } 
} 

使用EnumSet代替位属性

​ 下面是前一个使用枚举和枚举集合替代位属性的示例。 它更短,更清晰,更安全:

// EnumSet - a modern replacement for bit fields 
public class Text {
    
         
    
public enum Style {
    
     BOLD, ITALIC, UNDERLINE, STRIKETHROUGH } 
 
// Any Set could be passed in, but EnumSet is clearly best     
    public void applyStyles(Set<Style> styles) {
    
     ... } 
}

​ 总之,仅仅因为枚举类型将被用于集合中,所以没有理由用位属性来表示它。 EnumSet 类将位属性的简洁性 和性能与条目 34 中所述的枚举类型的所有优点相结合。 EnumSet 的一个真正缺点是,它不像 Java 9 那样创建一个 不可变的 EnumSet ,但是在即将发布的版本中可能会得到补救。 同时,你可以用 Collections.unmodifiableSet 封装一个 EnumSet ,但是简洁性和性能会受到影响

使用EnumMap代替序数索引

​ 总之,使用序数来索引数组很不合适:改用 EnumMap。 如果你所代表的关系是多维的,请使用 EnumMap <…,EnumMap <… >> 。 应用程序员应该很少使用 Enum.ordinal (条目 35),如果使用了,也是一般原则 的特例。

使用示例:

// Adding a new phase using the nested EnumMap implementation 
public enum Phase {
    
     
 
    SOLID, LIQUID, GAS, PLASMA; 
 
    public enum Transition {
    
             
        MELT(SOLID, LIQUID), 
        FREEZE(LIQUID, SOLID),         
        BOIL(LIQUID, GAS),   
        CONDENSE(GAS, LIQUID),         
        SUBLIME(SOLID, GAS), 
        DEPOSIT(GAS, SOLID),         
        IONIZE(GAS, PLASMA), 
        DEIONIZE(PLASMA, GAS);         ... 
            // Remainder unchanged     
    } 
} 

使用接口实现可扩展的枚举

​ 大多数情况下,枚举的可扩展性是一个糟糕的主意。 令人困惑的是,扩展类型的元素是基类型的实例,反之亦然。 枚举基本类型及其扩展的所有元素没有好的方法。 后,可扩展性会使设计和实现的很多方面复杂化。

​ 也就是说,对于可扩展枚举类型至少有一个有说服力的用例,这就是操作码(operation codes),也称为 opcodes。 操作码是枚举类型,其元素表示某些机器上的操作,例如条目 34 中的 Operation 类型,它表示简单计 算器上的功能。 有时需要让 API 的用户提供他们自己的操作,从而有效地扩展 API 提供的操作集。

// Emulated extensible enum using an interface 
public interface Operation {
    
         
    double apply(double x, double y); 
} 
 
 
public enum BasicOperation implements Operation {
    
         
    PLUS("+") {
    
             
        public double apply(double x, double y) {
    
     return x + y; }     
    },     
    MINUS("-") {
    
             
        public double apply(double x, double y) {
    
     return x - y; }     
    },     
    TIMES("*") {
    
             
        public double apply(double x, double y) {
    
     return x * y; }     
    },     
    DIVIDE("/") {
    
             
        public double apply(double x, double y) {
    
     return x / y; }     
    };     
    
    private final String symbol; 
 
    BasicOperation(String symbol) {
    
             
        this.symbol = symbol;     
    } 
 
    @Override public String toString() {
    
             
        return symbol;     
    } 
} 

​ 总之,虽然不能编写可扩展的枚举类型,但是你可以编写一个接口来配合实现接口的基本的枚举类型,来对它进 行模拟。 这允许客户端编写自己的枚举(或其它类型)来实现接口。如果 API 是根据接口编写的,那么在任何使 用基本枚举类型实例的地方,都可以使用这些枚举类型实例.

注解优于命名方式

过去,通常使用命名模式(naming patterns)来指示某些程序元素需要通过工具或框架进行特殊处理。 例如, 在第 4 版之前,JUnit 测试框架要求其用户通过以 test[Beck04] 开始名称来指定测试方法。 这种技术是有效的,但它 有几个很大的缺点。 首先,拼写错误导致失败,但不会提示。 例如,假设意外地命名了测试方法 tsetSafetyOverride 而不是 testSafetyOverride 。 JUnit 3 不会报错,但它也不会执行测试,导致错误 的安全感。

​ 命名模式的第二个缺点是无法确保它们仅用于适当的程序元素。

​ 命名模式的第三个缺点是它们没有提供将参数值与程序元素相关联的好的方法。 例如,假设想支持只有在抛出 特定异常时才能成功的测试类别。 异常类型基本上是测试的一个参数。 你可以使用一些精心设计的命名模式将异常 类型名称编码到测试方法名称中,但这会变得丑陋和脆弱。

​ 这个项目中的测试框架只是一个演示,但它清楚地表明了注解相对于命名模式的优越性,而且它仅仅描绘了你可 以用它们做什么的外观。 如果编写的工具要求程序员将信息添加到源代码中,请定义适当的注解类型。 当可以使用 注解代替时,没有理由使用命名模式

​ 这就是说,除了特定的开发者(toolsmith)之外,大多数程序员都不需要定义注解类型。 但所有程序员都应该 使用 Java 提供的预定义注解类型(条目 40,27)。 另外,请考虑使用 IDE 或静态分析工具提供的注解。 这些注解 可以提高这些工具提供的诊断信息的质量。 但请注意,这些注解尚未标准化,因此如果切换工具或标准出现,可能 额外需要做一些工作。

始终使用Override注解

​ 因此,应该在你认为要重写父类声明的每个方法声明上使用 Override 注解。 这条规则有一个小例外。 如果 正在编写一个没有标记为抽象的类,并且确信它重写了其父类中的抽象方法,则无需将 Override 注解放在该方法 上。 在没有声明为抽象的类中,如果无法重写抽象父类方法,编译器将发出错误消息。 但是,你可能希望关注类中 所有重写父类方法的方法,在这种情况下,也应该随时注解这些方法。 大多数 IDE 可以设置为在选择重写方法时自动 插入 Override 注解。

​ Override 注解可用于重写来自接口和类的方法声明。 随着 default 默认方法的出现,在接口方法的具体实 现上使用 Override 以确保签名是正确的是一个好习惯。 如果知道某个接口没有默认方法,可以选择忽略接口方法 的具体实现上的 Override 注解以减少混乱。

使用标记接口定义类型

标记接口(marker interface),不包含方法声明,只是指定(或“标记”)一个类实现了具有某些属性的接口。 例 如,考虑 Serializable 接口(第 12 章)。通过实现这个接口,一个类表明它的实例可以写入 ObjectOutputStream (或“序列化”)。

你可能会听说过标记注解(条目 39)标记一个接口是废弃过时的。 这个断言是不正确的。 标记接口与标记注解 相比具有两个优点:

  1. 首先,标记接口定义了一个由标记类实例实现的类型;标记注解则不会。 标记接口类型的存在允许在编译时捕 获错误,如果使用标记注解,则直到运行时才能捕获错误;
  2. 标记接口对于标记注解的另一个优点是可以更精确地定位目标。 如果使用目标 ElementType.TYPE 声明注解 类型,它可以应用于任何类或接口。 假设有一个标记仅适用于特定接口的实现。 如果将其定义为标记接口,则可以 扩展它适用的唯一接口,保证所有标记类型也是适用的唯一接口的子类型;

​ 总之,标记接口和标记注释都有其用处。 如果你想定义一个没有任何关联的新方法的类型,一个标记接口是一 种可行的方法。 如果要标记除类和接口以外的程序元素,或者将标记符合到已经大量使用注解类型的框架中,那么 标记注解是正确的选择。 如果发现自己正在编写目标为 ElementType.TYPE 的标记注解类型,请花点时间确定它 是否应该是注释类型,是不是标记接口是否更合适

lambda表达式优于匿名类

与方法和类不同,lambda 没有名称和文档; 如果计算不是自解释的,或者超过几行,则不要将其放入 lambda 表达式中。 一行代码对于 lambda 说是理想的,三行代码是合理的大值。 如果违反这一规定,可能会严重损害程 序的可读性。 如果一个 lambda 很长或很难阅读,要么找到一种方法来简化它或重构你的程序来消除它。

​ 同样,你可能会认为匿名类在 lambda 时代已经过时了。 这更接近事实,但有些事情你可以用匿名类来做,而 却不能用 lambdas 做。 Lambda 仅限于函数式接口。 如果你想创建一个抽象类的实例,你可以使用匿名类来实现, 但不能使用 lambda。 同样,你可以使用匿名类来创建具有多个抽象方法的接口实例。 最后,lambda 不能获得对自 身的引用。 在 lambda 中, this 关键字引用封闭实例,这通常是你想要的。 在匿名类中, this 关键字引用匿 名类实例。 如果你需要从其内部访问函数对象,则必须使用匿名类。

​ 综上所述,从 Java 8 开始,lambda 是迄今为止表示小函数对象的佳方式。 除非必须创建非函数式接口类型 的实例,否则不要使用匿名类作为函数对象

方法引用优于lambda表达式

​ lambda 优于匿名类的主要优点是它更简洁。Java 提供了一种生成函数对象的方法,比 lambda 还要简洁,那就 是:方法引用( method references)。

//lambda
map.merge(key, 1, (count, incr) -> count + incr); 

//method references
map.merge(key, 1, Integer::sum); 

​ 如果 lambda 变得太长或太复杂,它 们也会给你一个结果:你可以从 lambda 中提取代码到一个新的方法中,并用对该方法的引用代替 lambda。 你可以 给这个方法一个好名字,并把它文档记录下来。

方法引用通常为 lambda 提供一个更简洁的选择。 如果方法引用看起来更简短更清晰,请使用它们;否 则,还是坚持 lambda

优先使用标准的函数式接口

java.util.function 包提供了大量标准函数式接口供你使用。 如果其中一个标准函数式接口完成这项工作,则 通常应该优先使用它,而不是专门构建的函数式接口。 这将使你的 API 更容易学习,通过减少其不必要概念,并将提 供重要的互操作性好处,因为许多标准函数式接口提供了有用的默认方法。 例如, Predicate 接口提供了组合判 断的方法。 在我们的 LinkedHashMap 示例中,标准的 BiPredicate<Map<K,V>, Map.Entry<K,V>> 接口应优 先于自定义的 EldestEntryRemovalFunction 接口的使用。

​ 在 java.util.Function 中有 43 个接口。不能指望全部记住它们,但是如果记住了六个基本接口,就可以在 需要它们时派生出其余的接口。基本接口操作于对象引用类型。

  1. Operator 接口表示方法的结果和参数类型相同。
  2. Predicate 接口表示其方法接受一个参数并返回一个布尔值。
  3. Function 接口表示方法其参数和返回类型不 同。
  4. Supplier 接口表示一个不接受参数和返回值 (或“供应”) 的方法。
  5. Consumer 表示该方法接受一个参数 而不返回任何东西,本质上就是使用它的参数。

六种基本函数式接口概述如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lEnJWnyc-1685157110629)(C:\Users\lixuewen\AppData\Roaming\Typora\typora-user-images\image-20230522140251781.png)]

​ 总之,现在 Java 已经有了 lambda 表达式,因此必须考虑 lambda 表达式来设计你的 API。 在输入上接受函数式 接口类型并在输出中返回它们。 一般来说,好使用 java.util.function.Function 中提供的标准接口,但请 注意,在相对罕见的情况下,好编写自己的函数式接口。

明智审慎的使用Stream

​ 在 Java 8 中添加了 Stream API,以简化顺序或并行执行批量操作的任务。 该 API 提供了两个关键的抽象:流 (Stream),表示有限或无限的数据元素序列,以及流管道 (stream pipeline),表示对这些元素的多级计算。 Stream 中 的元素可以来自任何地方。 常见的源包括集合,数组,文件,正则表达式模式匹配器,伪随机数生成器和其他流。

流管道由源流(source stream)的零或多个中间操作和一个终结操作组成。每个中间操作都以某种方式转换流, 例如将每个元素映射到该元素的函数或过滤掉所有不满足某些条件的元素。中间操作都将一个流转换为另一个流,其 元素类型可能与输入流相同或不同。终结操作对流执行后一次中间操作产生的终计算,例如将其元素存储到集合 中、返回某个元素或打印其所有元素

通过提供操作名称并将实现细节保留在主 程序之外来增强可读性。 使用辅助方法对于流管道中的可读性比在迭代代码中更为重要,因为管道缺少显式类型信 息和命名临时变量。(提取函数以简化代码)

​ 当开始使用流时,你可能会感到想要将所有循环语句转换为流方式的冲动,但请抵制这种冲动。尽管这是可能 的,但可能会损害代码库的可读性和可维护性。 通常,使用流和迭代的某种组合可以好地完成中等复杂的任务。 因此,重构现有代码以使用流,并仅在有意义的情况下在新代码中使用它们

​ 总之,有些任务好使用流来完成,有些任务好使用迭代来完成。将这两种方法结合起来,可以好地完成许 多任务。对于选择使用哪种方法进行任务,没有硬性规定,但是有一些有用的启发式方法。在许多情况下,使用哪种 方法将是清楚的;在某些情况下,则不会很清楚。如果不确定一个任务是通过流还是迭代更好地完成,那么尝试这两 种方法,看看哪一种效果更好。

优先考虑流中无副作用的函数

​ Java 程序员知道如何使用 for-each 循环,而 forEach 终结操作是类似的。 但 forEach 操作是终端操作中不强大的操作之一,也是不友好的流操作。 它是明确的迭代,因此不适合并行 化。 forEach 操作应仅用于报告流计算的结果,而不是用于执行计算。 有时,将 forEach 用于其他目的是有意 义的,例如将流计算的结果添加到预先存在的集合。

​ 改进后的代码使用了收集器(collector),这是使用流必须学习的新概念。 Collectors 的 API 令人生畏:它 有 39 个方法,其中一些方法有多达 5 个类型参数。好消息是,你可以从这个 API 中获得大部分好处,而不必深入研 究它的全部复杂性。对于初学者来说,可以忽略收集器接口,将收集器看作是封装缩减策略(reduction strategy)的 不透明对象。将流的元素收集到真正的集合中的收集器非常简单。有三个这样的收集器: toList() 、 toSet() toCollection(collectionFactory) 。它们分别返回集合、列表和程序员指定的集合类型

​ 总之,编程流管道的本质是无副作用的函数对象。 这适用于传递给流和相关对象的所有许多函数对象。 终结操 作 forEach 仅应用于报告流执行的计算结果,而不是用于执行计算。 为了正确使用流,必须了解收集器。 重要的 收集器工厂是 toList , toSet , toMap , groupingBy 和 join 。

优先使用Collection而不是Stream作为返回类型

Collection 接口是 Iterable 的子类型,并且具有 stream 方法,因此它提供迭代和流访问。 因此, Collection 或适当的子类型通常是公共序列返回方法的佳返回类型。 数组还使用 Arrays.asList 和 Stream.of 方法提供简单的迭代和流访问。 如果返回的序列小到足以容易地放入内存中,那么好返回一个标准 集合实现,例如 ArrayList 或 HashSet 。 但是不要在内存中存储大的序列,只是为了将它作为集合返回。

​ 总之,在编写返回元素序列的方法时,请记住,某些用户可能希望将它们作为流处理,而其他用户可能希望迭代 方式来处理它们。 尽量适应两个群体。 如果返回集合是可行的,请执行此操作。 如果已经拥有集合中的元素,或者 序列中的元素数量足够小,可以创建一个新的元素,那么返回一个标准集合,比如 ArrayList 。 否则,请考虑实 现自定义集合,就像我们为幂集程序里所做的那样。 如果返回集合是不可行的,则返回流或可迭代的,无论哪个看 起来更自然。 如果在将来的 Java 版本中, Stream 接口声明被修改为继承 Iterable ,那么应该随意返回流,因 为它们将允许流和迭代处理。

谨慎使用流并行

不要无差别地并行化流管道(stream pipelines)。性能后果可能是灾难性的。

​ 通常,并行性带来的性能收益在 ArrayList 、 HashMap 、 HashSet 和 ConcurrentHashMap 实例、数 组、 int 类型范围和 long 类型的范围的流上好。 这些数据结构的共同之处在于,它们都可以精确而廉价地分 割成任意大小的子程序,这使得在并行线程之间划分工作变得很容易。用于执行此任务的流泪库使用的抽象是 spliterator ,它由 spliterator 方法在 Stream 和 Iterable 上返回。

​ 总之,甚至不要尝试并行化流管道,除非你有充分的理由相信它将保持计算的正确性并提高其速度。不恰当地并 行化流的代价可能是程序失败或性能灾难。如果您认为并行性是合理的,那么请确保您的代码在并行运行时保持正 确,并在实际情况下进行仔细的性能度量。如果您的代码是正确的,并且这些实验证实了您对性能提高的怀疑,那么 并且只有这样才能在生产代码中并行化流。

检查参数有效性

​ 如果将无效参数值传递给方法,并且该方法在执行之前检查其参数,则它抛出适当的异常然后快速且清楚地以失 败结束。 如果该方法无法检查其参数,可能会发生一些事情。 在处理过程中,该方法可能会出现令人困惑的异常。 更糟糕的是,该方法可以正常返回,但默默地计算错误的结果。 糟糕的是,该方法可以正常返回但是将某个对象 置于受损状态,在将来某个未确定的时间在代码中的某些不相关点处导致错误。 换句话说,验证参数失败可能导致 违反故障原子性(failure atomicity )。

​ 构造方法是这个原则的一个特例,你应该检查要存储起来供以后使用的参数的有效性。检查构造方法参数的有效 性对于防止构造对象违反类不变性(class invariants)非常重要。

​ 你应该在执行计算之前显式检查方法的参数,但这一规则也有例外。 一个重要的例外是有效性检查昂贵或不切 实际的情况,并且在进行计算的过程中隐式执行检查。

​ 总而言之,每次编写方法或构造方法时,都应该考虑对其参数存在哪些限制。 应该记在这些限制,并在方法体 的开头使用显式检查来强制执行这些限制。 养成这样做的习惯很重要。 在第一次有效性检查失败时,它所需要的少 量工作将会得到对应的回报。

必要时进行防御性拷贝

​ 即使在一种安全的语言中,如果不付出一些努力,也不会与其他类隔离。必须防御性地编写程序,假定类的客户 端尽力摧毁类的不变量。随着人们更加努力地试图破坏系统的安全性,这种情况变得越来越真实,但更常见的是,你 的类将不得不处理由于善意得程序员诚实错误而导致的意外行为。不管怎样,花时间编写在客户端行为不佳的情况下 仍然保持健壮的类是值得的。

在可能的情况下,应该使用不可变对象作为对象的组件,这样就不必担心防御 性拷贝 (条目 17)。在我们的 Period 示例中,使用 Instant (或 LocalDateTime 或 ZonedDateTime ),除非 使用的是 Java 8 之前的版本。如果使用的是较早的版本,则一个选项是存储 Date.getTime() 返回的基本类型 long 来代替 Date 引用。

​ 可能存在与防御性拷贝相关的性能损失,并且它并不总是合理的。如果一个类信任它的调用者不修改内部组件, 也许是因为这个类和它的客户端都是同一个包的一部分,那么它可能不需要防御性的拷贝。在这些情况下,类文档应 该明确指出调用者不能修改受影响的参数或返回。

​ 总之,如果一个类有从它的客户端获取或返回的可变组件,那么这个类必须防御性地拷贝这些组件。如果拷贝的 成本太高,并且类信任它的客户端不会不适当地修改组件,则可以用文档替换防御性拷贝,该文档概述了客户端不得 修改受影响组件的责任。

仔细设计方法签名

仔细选择方法名名称。名称应始终遵守标准命名约定 (条目 68)。你的主要目标应该是选择与同一包中的其他名 称一致且易于理解的名称。其次是应该是选择与更广泛的共识一致的名称。避免使用较长的方法名;

不要过分地提供方便的方法。每种方法都应该“尽其所能”。太多的方法使得类难以学习、使用、文档化、测试和 维护。对于接口更是如此,在接口中,太多的方法使实现者和用户的工作变得复杂;

避免过长的参数列表。目标是四个或更少的参数。大多数程序员不能记住更长的参数列表。

对于参数类型,优先选择接口而不是类(条目 64)。如果有一个合适的接口来定义一个参数,那么使用它来支持一 个实现该接口的类。例如,没有理由在编写方法时使用 HashMap 作为输入参数,相反,而是使用 Map 作为参数,这 允许传入 HashMap、TreeMap、ConcurrentHashMap、TreeMap 的子 Map(submap)或任何尚未编写的 Map 实现。

与布尔型参数相比,优先使用两个元素枚举类型,除非布尔型参数的含义在方法名中是明确的。枚举类型使代码 更容易阅读和编写。此外,它们还可以方便地在以后添加更多选项

明智审慎的使用重载

​ 示例代码:

public class CollectionClassifier {
    
     
 
    public static String classify(Set<?> s) {
    
             
        return "Set";     
    } 
 
    public static String classify(List<?> lst) {
    
             
        return "List";     
    } 
 
    public static String classify(Collection<?> c) {
    
             
        return "Unknown Collection";     
    } 
 
    public static void main(String[] args) {
    
             
        Collection<?>[] collections = {
    
                 
            new HashSet<String>(),             
            new ArrayList<BigInteger>(),             
            new HashMap<String, String>().values()         
        }; 
 
        for (Collection<?> c : collections)             
            System.out.println(classify(c));     
    } 
} 

​ 您可能希望此程序打印 Set,然后是 List 和 Unknown Collection 字符串,实际上并没有。 而是打印了三次 Unknown Collection 字符串。 为什么会这样? 因为 classify 方法被重载了,在编译时选择要调用哪个重载方法。 对于循环的所有三次迭代,参数的编译时类型是相同的: Collection<?> 。

​ 因为重载(overloaded)方法之间的选择是静态的,而重写(overridden)方法之 间的选择是动态的当调用重写方法 时,对象的编译时类型对执行哪个方法没有影响; 总是会执行“具体 (most specific)”的重写方法。 将此与重载进行比 较,其中对象的运行时类型对执行的重载没有影响; 选择是在编译时完成的,完全基于参数的编译时类型。

一个安全和保守的策略是永远不要导出两个具有相同参数数量的 重载。如果一个方法使用了可变参数,保守策略是根本不重载它。如果遵守这些限制,程序 员就不会怀疑哪些重载适用于任何一组实际参数。这些限制并不十分繁重,因为总是可以为方法赋予不同的名称,而 不是重载它们。

​ 总而言之,仅仅可以重载方法并不意味着应该这样做。通常,最好避免重载具有相同数量参数的多个签名的方 法。在某些情况下,特别是涉及构造方法的情况下,可能无法遵循此建议。在这些情况下,至少应该避免通过添加强 制转换将相同的参数集传递给不同的重载。如果这是无法避免的,例如,因为要对现有类进行改造以实现新接口,那 么应该确保在传递相同的参数时,所有重载的行为都是相同的。如果做不到这一点,程序员将很难有效地使用重载方 法或构造方法,也无法理解为什么它不能工作。

明智审慎的使用可变参数

​ 可变参数方法正式名称称为可变的参数数量方法『variable arity methods』 [JLS, 8.4.1],接受零个或多个指定类 型的参数。 可变参数机制首先创建一个数组,其大小是在调用位置传递的参数数量,然后将参数值放入数组中, 后将数组传递给方法。

​ 在性能关键的情况下使用可变参数时要小心。每次调用可变参数方法都会导致数组分配和初始化。如果你从经验 上确定负担不起这个成本,但是还需要可变参数的灵活性,那么有一种模式可以让你鱼与熊掌兼得。假设你已确定 95% 的调用是三个或更少的参数的方法,那么声明该方法的五个重载。每个重载方法包含 0 到 3 个普通参数,当参 数数量超过 3 个时,使用一个可变参数方法。

​ 总之,当需要使用可变数量的参数定义方法时,可变参数非常有用。 在使用可变参数前加上任何必需的参数, 并注意使用可变参数的性能后果。

返回空的数组或集合,不要返回null

​ 有时有人认为,null 返回值比空集合或数组更可取,因为它避免了分配空容器的开销。这个论点有两点是不成立 的。首先,除非测量结果表明所讨论的分配是性能问题的真正原因,否则不宜担心此级别的性能。 第二,可以在不分配空集合和数组的情况下返回它们。如果有证据表明分配空集合会损害性能,可以通过重复返回相同的不可变空集合来避免分配,因为不可变对象可 以自由共享

​ 总之,永远不要返回 null 来代替空数组或集合。它使你的 API 更难以使用,更容易出错,并且没有性能优势。

明智审慎的返回Optional

​ 在 Java 8 之前,编写在特定情况下无法返回任何值的方法时,可以采用两种方法,这两种方法都不完美:

  1. 要么抛出异常,但应该为异常条件保留异常,并且抛出异常 代价很高,因为在创建异常时捕获整个堆栈跟踪。返回 null 没有这些缺点,但是它有自己的缺陷。
  2. 要么返回 null(假设返回类型是对象是引用类型);如果方法返回 null,客户端必须包含特殊情况代码来处理 null 返回的可能性,除非程序员能够证明 null 返回是不可能的。如果客户 端忽略检查 null 返回并将 null 返回值存储在某个数据结构中,那么会在将来的某个时间在与这个问题不相关的代码 位置上,抛出 NullPointerException 异常的可能性。

​ 在 Java 8 中,还有第三种方法来编写可能无法返回任何值的方法。 Optional 类表示一个不可变的容器, 它可以包含一个非 null 的 T 引用,也可以什么都不包含。不包含任何内容的 Optional 被称为空(empty)。非空的 包含值称的 Optional 被称为存在(present)。Optional 的本质上是一个不可变的集合,多可以容纳一个元素。 Optional 没有实现 Collection 接口,但原则上是可以。

​ Optional.of(value) 方法接受一个可能为 null 的值,如果传入 null 则返回一个空的 Optional。永远不要通过返回 Optional 的方法返回一个空值:它破坏 Optional 设计的初衷

​ 总之,如果发现自己编写的方法不能总是返回值,并且认为该方法的用户在每次调用时考虑这种可能性很重要, 那么或许应该返回一个 Optional 的方法。但是,应该意识到,返回 Optional 会带来实际的性能后果;对于性能关键 的方法,好返回 null 或抛出异常。

为所有已公开的 API 元素编写文档注释

​ 如果 API 要可用,就必须对其进行文档化。传统上,API 文档是手工生成的,保持文档与代码的同步是一件苦差 事。Java 编程环境使用 Javadoc 实用程序简化了这一任务。 Javadoc 使用特殊格式的文档注释 (通常称为 doc 注 释),从源代码自动生成 API 文档。

​ 要正确地记录 API,必须在每个导出的类、接口、构造方法、方法和属性声明之前加上文档注释。

​ 文档注释在源代码和生成 的文档中都应该是可读的通用原则。

为避免混淆,类或接口中 的两个成员或构造方法不应具有相同的概要描述。 要特别注意重载方法,为此通常使用相同的第一句话是自然的 (但在文档注释中是不可接受的)

​ 总之,文档注释是记录 API 的佳、有效的方法。对于所有导出的 API 元素,它们的使用应被视为必需的。 采 用符合标准惯例的一致风格 。请记住,在文档注释中允许任意 HTML,但必须转义 HTML 的元字符。

最小化局部变量的作用域

​ 这条目在性质上类似于“最小化类和成员的可访问性”。通过最小化局部变量的作用域,可以提高代码 的可读性和可维护性,并降低出错的可能

用于最小化局部变量作用域的强大的技术是再首次使用的地方声明它。 如果变量在使用之前被声明,那就变 得更加混乱—— 这也会对试图理解程序的读者来讲,又增加了一件分散他们注意力的事情。 到使用该变量时,读者可 能不记得变量的类型或初始值。

​ 过早地声明局部变量可能导致其作用域不仅过早开始而且结束太晚。 局部变量的作用域从声明它的位置延伸到 封闭块的末尾。 如果变量在使用它的封闭块之外声明,则在程序退出该封闭块后它仍然可见。如果在其预定用途区 域之前或之后意外使用变量,则后果可能是灾难性的。

几乎每个局部变量声明都应该包含一个初始化器。如果还没有足够的信息来合理地初始化一个变量,那么应该推 迟声明,直到认为可以这样做。这个规则的一个例外是 try-catch 语句。

最小化局部变量作用域的终技术是保持方法小而集中。 如果在同一方法中组合两个行为(activities),则与一 个行为相关的局部变量可能会位于执行另一个行为的代码范围内。 为了防止这种情况发生,只需将方法分为两个: 每个行为对应一个方法。

for-each 循环优于传统 for 循环

​ for-each 循环 (官方称为“增强的 for 语句”) 解决了所有这些问题。它通过隐藏迭代器或索引变量来消除混乱和出 错的机会。但是,有三种常见的情况是你不能分别使用 for-each 循环的:

  1. 有损过滤(Destructive filtering)——如果需要遍历集合,并删除指定选元素,则需要使用显式迭代器,以便可 以调用其 remove 方法。 通常可以使用在 Java 8 中添加的 Collection 类中的 removeIf 方法,来避免显式遍历。
  2. 转换——如果需要遍历一个列表或数组并替换其元素的部分或全部值,那么需要列表迭代器或数组索引来替换元 素的值
  3. 并行迭代——如果需要并行地遍历多个集合,那么需要显式地控制迭代器或索引变量,以便所有迭代器或索引变 量都可以同步进行 (正如上面错误的 card 和 dice 示例中无意中演示的那样)

​ 如果发现自己处于这些情况中的任何一种,请使用传统的 for 循环,并警惕本条目中提到的陷阱。

​ 总之,for-each 循环在清晰度,灵活性和错误预防方面提供了超越传统 for 循环的令人注目的优势,而且没有性 能损失。 尽可能使用 for-each 循环优先于 for 循环。

了解并使用库

通过使用 标准库,你可以利用编写它的专家的知识和以前使用它的人的经验

​ 从 Java 7 开始,就不应该再使用 Random。在大多数情况下,选择的随机数生成器现在是 ThreadLocalRandom。 它能产生更高质量的随机数,而且速度非常快。在我的机器上,它比 Random 快 3.6 倍。对于 fork 连接池和并行流,使用 SplittableRandom。

​ 使用这些库的第二个好处是,你不必浪费时间为那些与你的工作无关的问题编写专门的解决方案。如果你像大多 数程序员一样,那么你宁愿将时间花在应用程序上,而不是底层管道上。

​ 使用标准库的第三个优点是,随着时间的推移,它们的性能会不断提高,而你无需付出任何努力。由于许多人使 用它们,而且它们是在行业标准基准中使用的,所以提供这些库的组织有很强的动机使它们运行得更快。

​ 考虑到所有这些优点,使用库工具而不选择专门的实现似乎是合乎逻辑的,但许多程序员并不这样做。为什么不 呢?也许他们不知道库的存在。在每个主要版本中,都会向库中添加许多特性,了解这些新增特性是值得的

​ 有时,类库工具可能无法满足你的需求。你的需求越专门化,发生这种情况的可能性就越大。虽然你的第一个思 路应该是使用这些库,但是如果你已经了解了它们在某些领域提供的功能,而这些功能不能满足你的需求,那么可以 使用另一种实现。任何有限的库集所提供的功能总是存在漏洞。如果你在 Java 平台库中找不到你需要的东西,你的 下一个选择应该是寻找高质量的第三方库,比如谷歌的优秀的开源 Guava 库 [Guava]。如果你无法在任何适当的库中 找到所需的功能,你可能别无选择,只能自己实现它

​ 总而言之,不要白费力气重新发明轮子。如果你需要做一些看起来相当常见的事情,那么库中可能已经有一个工 具可以做你想做的事情。如果有,使用它;如果你不知道,检查一下。一般来说,库代码可能比你自己编写的代码更 好,并且随着时间的推移可能会得到改进。这并不反映你作为一个程序员的能力。规模经济决定了库代码得到的关注 要远远超过大多数开发人员所能承担的相同功能。

若要精确,避免使用float和double

​ float 和 double 类型主要用于科学计算和工程计算。它们执行二进制浮点运算,该算法经过精心设计,能够在很 大范围内快速提供精确的近似值。但是,它们不能提供准确的结果,也不应该在需要精确结果的地方使用。float 和 double 类型特别不适合进行货币计算,因为不可能将 0.1(或 10 的任意负次幂)精确地表示为 float 或 double。

​ 总之,对于任何需要精确答案的计算,不要使用 float 或 double 类型。如果希望系统来处理十进制小数点,并且 不介意不使用基本类型带来的不便和成本,请使用 BigDecimal。使用 BigDecimal 的另一个好处是,它可以完全控制 舍入,当执行需要舍入的操作时,可以从八种舍入模式中进行选择。如果你使用合法的舍入行为执行业务计算,这将 非常方便。如果性能是重要的,那么你不介意自己处理十进制小数点,而且数值不是太大,可以使用 int 或 long。 如果数值不超过 9 位小数,可以使用 int;如果不超过 18 位,可以使用 long。如果数量可能超过 18 位,则使用 BigDecimal。

基本数据类型优于包装类

​ 基本类型和包装类型之间有三个主要区别。首先,基本类型只有它们的值,而包装类型具有与其值不同的标识。 换句话说,两个包装类型实例可以具有相同的值和不同的标识。第二,基本类型只有全功能值,而每个包装类型除了 对应的基本类型的所有功能值外,还有一个非功能值,即 null。最后,基本类型比包装类型更节省时间和空间。如果 你不小心的话,这三种差异都会给你带来真正的麻烦。

​ 总之,只要有选择,就应该优先使用基本类型,而不是包装类型。基本类型更简单、更快。如果必须使用包装类 型,请小心!自动装箱减少了使用包装类型的冗长,但没有减少危险。 当你的程序使用 == 操作符比较两个包装类 型时,它会执行标识比较,这几乎肯定不是你想要的。当你的程序执行包含包装类型和基本类型的混合类型计算时, 它将进行拆箱,当你的程序执行拆箱时,将抛出 NullPointerException。 后,当你的程序将基本类型装箱时,可能 会导致代价高昂且不必要的对象创建。

其他类型更合适时避免使用字符串

​ 字符串被设计用来表示文本,它们在这方面做得很好。因为字符串是如此常见,并且受到 Java 的良好支持,所 以很自然地会将字符串用于其他目的,而不是它们适用的场景。

​ 字符串是其他值类型的糟糕替代品。一般地说,如果有合适的值类型,无论是基本类型还是对象引用,都应 该使用它;如果没有,你应该写一个。

​ 字符串是枚举类型的糟糕替代品。 正如条目 34 中所讨论的,枚举类型常量比字符串更适合于枚举类型常量。

​ 字符串是聚合类型的糟糕替代品。 如果一个实体有多个组件,将其表示为单个字符串通常是一个坏主意。

​ 示例代码:

String compoundKey = className + "#" + i.next(); 

​ 这种方法有很多缺点。如果用于分隔字段的字符出现在其中一个字段中,可能会导致混乱。要访问各个字段,你 必须解析字符串,这是缓慢的、冗长的、容易出错的过程。

​ 总之,当存在或可以编写更好的数据类型时,应避免将字符串用来表示对象。如果使用不当,字符串比其他类型 更麻烦、灵活性更差、速度更慢、更容易出错。字符串经常被误用的类型包括基本类型、枚举和聚合类型。

注意字符串连接引起的性能问题

​ 字符串连接操作符 (+) 是将几个字符串组合成一个字符串的简便方法。对于生成单行输出或构造一个小的、固 定大小的对象的字符串表示形式,它是可以的,但是它不能伸缩。使用 字符串串联运算符重复串联 n 个字符串需要 n 的平方级时间。 这是字符串不可变这一事实导致的结果(Item-17)。当连接两个字符串时,将复制这两个字符串 的内容。

​ 道理很简单:不要使用字符串连接操作符合并多个字符串,除非性能无关紧要。否则使用 StringBuilder 的 append 方法。或者,使用字符数组,再或者一次只处理一个字符串,而不是组合它们。

通过接口引用对象

​ 一般地说,你应该优先使用接口而不是类来引用对象。 如果存在合适的接口类型,那么应该使用接口类型声明参数、返回值、变量和字段。 惟一真正需要引用对象的类的 时候是使用构造函数创建它的时候。

如果你养成了使用接口作为类型的习惯,那么你的程序将更加灵活。 如果你决定要切换实现,只需在构造函数 中更改类名(或使用不同的静态工厂)。

如果没有合适的接口存在,那么用类引用对象是完全合适的。 例如,考虑值类,如 String 和 BigInteger。值类很 少在编写时考虑到多个实现。它们通常是 final 的,很少有相应的接口。使用这样的值类作为参数、变量、字段或返 回类型非常合适。

​ 在实际应用中,给定对象是否具 有适当的接口应该是显而易见的。如果是这样,如果使用接口引用对象,程序将更加灵活和流行。如果没有合适的接 口,就使用类层次结构中提供所需功能的底层的类 。

接口优于反射

核心反射机制 java.lang.reflect 提供对任意类的编程访问。给定一个 Class 对象,你可以获得 Constructor、Method 和 Field 实例,分别代表了该 Class 实例所表示的类的构造器、方法和字段。这些对象提供对类 的成员名、字段类型、方法签名等的编程访问。

​ 此外,Constructor、Method 和 Field 实例允许你反射性地操作它们的底层对应项:你可以通过调用 Constructor、 Method 和 Field 实例上的方法,可以构造底层类的实例、调用底层类的方法,并访问底层类中的字段。例如, Method.invoke 允许你在任何类的任何对象上调用任何方法(受默认的安全约束)。反射允许一个类使用另一个类, 即使在编译前者时后者并不存在。然而,这种能力是有代价的:

  • 你失去了编译时类型检查的所有好处, 包括异常检查。如果一个程序试图反射性地调用一个不存在的或不可访 问的方法,它将在运行时失败,除非你采取了特殊的预防措施。
  • 执行反射访问所需的代码既笨拙又冗长。 写起来很乏味,读起来也很困难。 性能降低。
  • 反射方法调用比普通方法调用慢得多。到底慢了多少还很难说,因为有很多因素在起作用。在我的 机器上,调用一个没有输入参数和返回 int 类型的方法时,用反射执行要慢 11 倍

通过非常有限的形式使用反射,你可以获得反射的许多好处,同时花费的代价很少。 对于许多程序,它们必须 用到在编译时无法获取的类,在编译时存在一个适当的接口或超类来引用该类(详见第 64 条)。如果是这种情况, 可以用反射方式创建实例,并通过它们的接口或超类正常地访问它们

​ 总之,反射是一种功能强大的工具,对于某些复杂的系统编程任务是必需的,但是它有很多缺点。如果编写的程 序必须在编译时处理未知的类,则应该尽可能只使用反射实例化对象,并使用在编译时已知的接口或超类访问对象。

明智审慎的使用本地方法

​ Java 本地接口(JNI)允许 Java 程序调用本地方法,这些方法是用 C 或 C++ 等本地编程语言编写的。从历史上 看,本地方法主要有三种用途。它们提供对特定于平台的设施(如注册中心)的访问。它们提供对现有本地代码库的 访问,包括提供对遗留数据访问。后,本地方法可以通过本地语言编写应用程序中注重性能的部分,以提高性能。

为了提高性能,很少建议使用本地方法

​ 总之,在使用本地方法之前要三思。一般很少需要使用它们来提高性能。如果必须使用本地方法来访问底层资源 或本地库,请尽可能少地使用本地代码,并对其进行彻底的测试。本地代码中的一个错误就可以破坏整个应用程序。

明智审慎的进行优化

不要为了性能而牺牲合理的架构。努力编写 好的程序,而不是快速的程序。 如果一个好的程序不够快,它的架 构将允许它被优化。好的程序体现了信息隐藏的原则:在可能的情况下,它们在单个组件中本地化设计决策,因此可 以在不影响系统其余部分的情况下更改单个决策。

尽量避免限制性能的设计决策。 设计中难以更改的组件是那些指定组件之间以及与外部世界的交互的组件。 这些设计组件中主要的是 API、线路层协议和持久数据格式。这些设计组件不仅难以或不可能在事后更改,而且所 有这些组件都可能对系统能够达到的性能造成重大限制。

考虑API设计决策的性能结果。 使公共类型转化为可变,可能需要大量不必要的防御性复制(详见第 50 条)。 类似地,在一个公共类中使用继承(在这个类中组合将是合适的)将该类永远绑定到它的超类,这会人为地限制子类 的性能(详见第 18 条)。后一个例子是,在 API 中使用实现类而不是接口将你绑定到特定的实现,即使将来可能 会编写更快的实现也无法使用。

在每次尝 试优化之前和之后测量性能。值得特别提及的工具是 jmh,它不是一个分析器,而是一个微基准测试框架,提供了对 Java 代码性能无与伦比的预测性。

​ 总而言之,不要努力写快的程序,要努力写好程序;速度自然会提高。但是在设计系统时一定要考虑性能,特别 是在设计API、线路层协议和持久数据格式时。当你完成了系统的构建之后,请度量它的性能。如果足够快,就完成 了。如果没有,利用分析器找到问题的根源,并对系统的相关部分进行优化。第一步是检查算法的选择:再多的底层 优化也不能弥补算法选择的不足。根据需要重复这个过程,在每次更改之后测量性能,直到你满意为止。

遵守广泛认可的命令约定

​ Java 平台有一组完善的命名约定,其中许多约定包含在《The Java Language Specification》[JLS, 6.1]。不严格地 讲,命名约定分为两类:排版和语法。

​ 包名和模块名应该是分层的,组件之间用句点分隔。组件应该由小写字母组成,很少使用数字。任何在你的组织 外部使用的包,名称都应该以你的组织的 Internet 域名开头,并将组件颠倒过来,例如,edu.cmu、com.google、 org.e。

​ 总之,将标准命名约定内在化,并将其作为第二性征来使用。排版习惯是直接的,而且在很大程度上是明确的; 语法惯例更加复杂和松散。引用《The Java Language Specification》[JLS, 6.1] 中的话说,「如果长期以来的传统用法 要求不遵循这些约定,就不应该盲目地遵循这些约定。」,应使用常识判断。

只针对异常情况下才使用异常

​ 异常应该只用于异常的情况下;他们永远不应该用于正常的程序控制流程。

​ 设计良好的 API 不应该强迫它的客户端为了正常的控制流程而使用异常。如果 类中具有“状态相关”(state-dependent)的方法,即只有在特定的不可预知的条件下才可以被调用的方法,这个类往 往也应该具有一个单独的“状态测试”(state-testing)方法,即表明是否可以调用这个状态相关的方法。比如 Iterator 接口含有状态相关的 next 方法,以及相应的状态测试方法 hasNext。这使得利用传统的 for 循环(以及 for-each 循 环,在内部使用了 hasNext 方法)对集合进行迭代的标准模式成为可能

​ 总而言之,异常是为了在异常情况下被设计和使用的。不要将它们勇于普通的控制流程,也不要编写迫使它们这 么做的 API。

可恢复的情况使用可检异常,编程错误使用运行时异常

​ Java 程序设计语言提供了三种 throwable:受检异常(checked exceptions)、运行时异常(runtime exceptions) 和错误(errors)。程序员中存在着什么情况适合使用哪种 throwable 的困惑。虽然这种决定不总是那么清晰,但还 是有一些一般性的原则提出了强有力的指导。

​ 在决定使用受检异常还是非受检异常时,主要的原则是: 如果期望调用者能够合理的恢复程序运行,对于这种 情况就应该使用受检异常通过抛出受检异常,强迫调用者在一个 catch 子句中处理该异常,或者把它传播出去。因 此,方法中声明要抛出的每个受检异常都是对 API 用户的一个潜在提示:与异常相关联的条件是调用这个方法一种可 能结果。

​ 有两种非受检的 throwable:运行时异常和错误。在行为上两者是等同的:它们都是不需要也不应该被捕获的 throwable。如果程序抛出非受检异常或者错误,往往属于不可恢复的情形,程序继续执行下去有害无益。如果程序 没有捕捉到这样的 throwable,将会导致当前线程中断(halt),并且出现适当的错误消息。

​ 用运行时异常来表明编程错误。大多数运行时异常都表示前提违例(precondition violations)。所谓前提违例是 指 API 的客户没有遵守 API 规范建立的约定。例如,数组访问的预定指明了数组的下标值必须在 0 和数组长度-1 之 间。ArrayIndexOutOfBoundsException 表明违反了这个前提。

​ 虽然 JLS(Java 语言规范)并没有要求,但是按照惯例,错误(Error)往往被 JVM 保留下来使用,以表明资源 不足、约束失败,或者其他使程序无法继续执行的条件。由于这已经是个几乎被普遍接受的管理,因此好不需要在 实现任何新的 Error 的子类。因此,你实现的所有非受检的 throwable 都应该是 RuntimeExceptiond 子类(直接或者 间接的)。不仅不应该定义 Error 的子类,也不应该抛出 AssertionError 异常。

​ 总而言之,对于可恢复的情况,要抛出受检异常;对于程序错误,就要抛出运行时异常。不确定是否可恢复,就 跑出为受检异常。不要定义任何既不是受检异常也不是运行异常的抛出类型。要在受检异常上提供方法,以便协助程 序恢复。

避免不必要的受检异常

​ Java 程序员不喜欢受检异常,但是如果使用得当,它们可以改善 API 和程序。不返回码和未受检异常的原因是,它们 强迫程序员处理异常的条件,大大增强了可靠性。也就是说,过分使用受检异常会使 API 使用起来非常不方便。如果 方法抛出受检异常,调用该方法代码就必须在一个或者多个 catch 块中处理这些异常,或者它必须声明抛出这些异 常,并让它们传播出去。无论使用哪一种方法,都给程序员增添了不可忽视的负担。这种负担在 Java 8 中更重了, 因为抛出受检异常的方法不能直接在 Stream 中使用

​ 总而言之,在谨慎使用的前提之下,受检异常可以提升程序的可读性;如果过度使用,将会使 API 使用起来非常 痛苦。如果调用者无法恢复失败,就应该抛出未受检异常。如果可以恢复,并且想要迫使调用者处理异常的条件,首 选应该返回一个 optional 值。当且仅当万一失败时,这些无法提供足够的信息,才应该抛出受检异常。

优先使用标准的异常

​ 专家级程序员与缺乏经验的程序员一个主要的区别在于,专家追求并且通常也能够实现高度的代码重用。代码 重用是值得提倡的,这是一条通用的规则,异常也不例外。Java 平台类库提供了一组基本的未受检异常,它们满足 了绝大多数 API 的异常抛出需求。

​ 重用标准的异常有多个好处。其中主要的好处是,它使 API 更易于学习和使用,因为它与程序员已经熟悉的习惯 用法一致。第二个好处是,对于用到这些 API 程序而言,它们的可读性会更好,因为它们不会出现很多程序员不熟悉 的异常。最后(也是不重要的)一点是,异常类越少,意味着内存占用(footprint)就越小,装载这些类的时间开 销也越少。

不要直接重用 Exception、RuntimeException、Throwable 或者 Error。 对待这些类要像对待抽象类一样。你无法 可靠地测试这些异常,因为它们是一个方法可能抛出的其他异常的超类。

​ 选择重用哪一种异常并非总是那么精确,因为上表中的“使用场合”并不是相互排斥的比如,以表示一副纸牌的对 象为例。假设有一个处理发牌操作的方法,它的参数是发一手牌的纸牌张数。假设调用者在这个参数中传递的值大于 整副纸牌的剩余张数。这种情形既可以被解释为 IllegalArgumentException( handSize 参数的值太大),也可以被解释 为 IllegalStateException(纸牌对象包含的纸牌太少)。在这种情况下,如果没有可用的参数值,就抛出 llegalStateException,否则就抛出工 llegalArgumentException。

抛出与抽象对应的异常

​ 如果方法抛出的异常与它所执行的任务没有明显的联系,这种情形将会使人不知所措。当方法传递由低层抽象抛 出的异常时,往往会发生这种情况。除了使人感到困惑之外,这也“污染”了具有实现细节的更高层的 API 。如果高层 的实现在后续的发行版本中发生了变化,它所抛出的异常也可能会跟着发生变化,从而潜在地破坏现有的客户端程 序。

​ 为了避免这个问题, 更高层的实现应该捕获低层的异常,同时抛出可以按照高层抽象进行解释的异常。这种做 法称为异常转译 (exception translation),如下代码所示:

/* Exception Translation */ 
try {
    
         
    ... /* Use lower-level abstraction to do our bidding */ 
} catch ( LowerLevelException e ) {
    
         
    throw new HigherLevelException(...); 
}

​ 一种特殊的异常转译形式称为异常链 (exception chaining),如果低层的异常对于调试导致高层异常的问题非常有 帮助,使用异常链就很合适。低层的异常(原因)被传到高层的异常,高层的异常提供访问方法 (Throwable 的 getCause 方法)来获得低层的异常:

// Exception Chaining 
try {
    
     
    ... // Use lower-level abstraction to do our bidding 
} catch (LowerLevelException cause) {
    
         
    throw new HigherLevelException(cause); 
} 

​ 高层异常的构造器将原因传到支持链 (chaining-aware) 的超级构造器,因此它终将被传给 Throw able 的其中一 个运行异常链的构造器,例如 Throwable(Throwable) :

/* Exception with chaining-aware constructor */ 
class HigherLevelException extends Exception {
    
    
    
    HigherLevelException( Throwable cause ) {
    
             
        super(cause);     
    } 
} 

尽管异常转译与不加选择地从低层传递异常的做法相比有所改进,但是也不能滥用它。 如有可能,处理来自低 层异常的好做法是,在调用低层方法之前确保它们会成功执行,从而避免它们抛出异常。有时候,可以在给低层传 递参数之前,检查更高层方法的参数的有效性,从而避免低层方法抛出异常。

​ 总而言之,如果不能阻止或者处理来自更低层的异常,一般的做法是使用异常转译,只有在低层方法的规范碰巧 可以保证“它所抛出的所有异常对于更高层也是合适的”情况下,才可以将异常从低层传播到高层。异常链对高层和低 层异常都提供了佳的功能:它允许抛出适当的高层异常,同时又能捕获低层的原因进行失败分析。

每个方法抛出的异常都文档说明

​ 描述一个方法所抛出的异常,是正确使用这个方法时所需文档的重要组成部分。因此,花点时间仔细地为每个方 法抛出的异常建立文档是特别重要的。

始终要单独地声明受检异常, 并且利用 Javadoc 的@ throws 标签, 准确地记录下抛出每个异常的条件。 如果 一个公有方法可能抛出多个异常类,则不要使用“快捷方式”声明它会抛出这些异常类的某个超类。永远不要声明一个 公有方法直接“ throws Exception”,或者更糟糕的是声明它直接“ throws Throwable ”,这是非常极端的例子。这样的 声明不仅没有为程序员提供关于“这个方法能够抛出哪些异常”的任何指导信息,而且大大地妨碍了该方法的使用,因 为它实际上掩盖了该方法在同样的执行环境下可能抛出的任何其他异常。这条建议有一个例外,就是 main 方法它可 以被安全地声明抛出 Exception ,因为它只通过虚拟机调用。

​ 使用 Javadoc 的 @throws 标签记录下一个方法可能抛出的每个未受检异常,但是不要使用 throws 关键字将未 受检的异常包含在方法的声明中。

如果一个类中的许多方法出于同样的原因而抛出同一个异常,在该类的文档注释中对这个异常建立文档,这是可 以接受的, 而不是为每个方法单独建立文档。一个常见的例子是 NullPointerException。

​ 总而言之,要为你编写的每个方法所能抛出的每个异常建立文档。对于未受检异常和受检异常,以及抽象的方法 和具体的方法一概如此。这个文档在文档注释中应当采用@throws 标签的形式。要在方法的 throws 子句中为每个受 检异常提供单独的声明,但是不要声明未受检的异常。如果没有为可以抛出的异常建立文档,其他人就很难或者根本 不可能有效地使用你的类和接口。

在详细信息中包含失败-捕获信息

​ 当程序由于未被捕获的异常而失败的时’候,系统会自动地打印出该异常的堆栈轨迹。在堆栈轨迹中包含该异常 的字符串表示法 (string representation),即它的 toString 方法的调用结果。它通常包含该异常的类名,紧随其后的是 细节消息 (detail message)。通常,这只是程序员或者网站可靠性工程师在调查软件失败原因时必须检查的信息。如果 失败的情形不容易重现,要想获得更多的信息会非常困难,甚至是不可能的。因此,异常类型的 toString 方法应该尽 可能多地返回有关失败原因的信息,这一点特别重要。换句话说,异常的字符串表示法应该捕获失败,以便于后续进 行分析

​ 为了捕获失败,异常的细节信息应该包含“对该异常有贡献”的所有参数和字段的值。 例如, IndexOutOfBoundsException 异常的细节消息应该包含下界、上界以及没有落在界内的下标值。该细节消息提供了许 多关于失败的信息。

​ 对安全敏感的信息有一条忠告。由于在诊断和修正软件问题的过程中,许多人都可以看见堆栈轨迹, 因此千万 不要在细节消息中包含密码、密钥以及类似的信息

​ 异常的细节消息不应该与“用户层次的错误消息”混为一谈,后者对于终用户而言必须是可理解的。与用户层次 的错误消息不同,异常的字符串表示法主要是让程序员或者网站可靠性工程师用来分析失败的原因。因此,信息的内 容比可读性要重要得多。用户层次的错误消息经常被本地化,而异常的细节消息则几乎没有被本地化。(翻译国际化)

保证失败的原子性

​ 当对象抛出异常之后,通常我们期望这个对象仍然保持在一种定义良好的可用状态之中, 即使失败是发生在执 行某个操作的过程中间。对于受检异常而言,这尤为重要,因为调用者期望能从这种异常中进行恢复。一般而言,失 败的方法调用应该使对象保持在被调用之前的状态。 具有这种属性的方法被称为具有失败原子性 (failure atomic) 。(事务的基础)。

​ 有几种途径可以实现这种效果。简单的办法莫过于设计一个不可变的对象 (详见第 17 条) 。如果对象是不可变 的,失败原子性就是显然的。如果一个操作失败了,它可能会阻止创建新的对象,但是永远也不会使已有的对象保持 在不一致的状态之中,因为当每个对象被创建之后它就处于一致的状态之中,以后也不会再发生变化。

​ 对于在可变对象上执行操作的方法,获得失败原子性常见的办法是,在执行操作之前检查参数的有效性 。这可以使得在对象的状态被修改之前,先抛出适当的异常

​ 第三种获得失败原子性的办法是,在对象的一份临时拷贝上执行操作,当操作完成之后再用临时拷贝中的结果代 替对象的内容。如果数据保存在临时的数据结构中,计算过程会更加迅速,使用这种办法就是件很自然的事。例如, 有些排序函数会在执行排序之前,先把它的输入列表备份到一个数组中,以便降低在排序的内循环中访问元素所需要 的开销。这是出于性能考虑的做法,但是,它增加了一项优势:即使排序失败,它也能保证输入列表保持原样。

​ 最后一种获得失败原子’性的办法远远没有那么常用,做法是编写一段恢复代码 (recoverycode ),由它来拦截操作 过程中发生的失败,以及便对象回滚到操作开始之前的状态上。这种办法主要用于永久性的 (基于磁盘的) 数据结构。

​ 总而言之,作为方法规范的一部分,它产生的任何异常都应该让对象保持在调用该方法之前的状态。如果违反这 条规则, API 文档就应该清楚地指明对象将会处于什么样的状态。遗憾的是,大量现有的 API 文档都未能做到这一 点。

不要忽略异常

空的 catch 块会使异常达不到应有的目的, 即强迫你处理异常的情况。忽略异常就如同忽略火警信号一样。

​ 有些情形可以忽略异常。比如,关闭 FileinputStream 的时候。因为你还没有改变文件的状态,因此不必执行任 何恢复动作,并且已经从文件中读取到所需要的信息,因此不必终止正在进行的操作。即使在这种情况下,把异常记 录下来还是明智的做法,因为如果这些异常经常发生,你就可以调查异常的原因。 如果选择忽略异常, catch 块中应 该包含一条注释,说明为什么可以这么做,并且变量应该命名为 ignored:

Future<Integer> f = exec.submit(planarMap::chromaticNumber); 
int numColors = 4; // Default: guaranteed sufficient for any map 
try {
    
        
    numColors = f.get( 1L, TimeUnit.SECONDS ); 
} catch ( TimeoutException | ExecutionException ignored ) {
    
        
    // Use default: minimal coloring is desirable, not required 
}

​ 正确地处理异常能够彻底避免失败。只要将异常传播给外界,至少 会导致程序迅速失败,从而保留了有助于调试该失败条件的信息。

同步访问共享的可变数据

​ 关键字 synchronized 可以保证在同一时刻,只有一个线程可以执行某一个方法,或者某一个代码块。

​ 如果没有同步,一个线程的变化就不能被其他线程看 到。同步不仅可以阻止一个线程看到对象处于不一致的状态之中,它还可以保证进入同步方法或者同步代码块的每个 线程,都能看到由同一个锁保护的之前所有的修改效果。

​ 你可能昕说过,为了提高性能,在读或写原子数据的时候,应该避免使用同步。这个建议是非常危险而错误的。 虽然语言规范保证了线程在读取原子数据的时候,不会看到任意的数值,但是它并不保证一个线程写入的值对于另一 个线程将是可见的。 为了在线程之间进行可靠的通信,也为了互斥访问,同步是必要的

​ 避免本条目中所讨论到的问题的佳办法是不共享可变的数据。要么共享不可变的数据(详见第 17 条),要么 压根不共享。换句话说, 将可变数据限制在单个线程中

​ 总而言之, 当多个线程共享可变数据的时候,每个读或者写数据的线程都必须执行同步。 如果没有同步,就无 法保证一个线程所做的修改可以被另一个线程获知。未能同步共享可变数据会造成程序的活性失败( liveness failure )和安全性失败( safety failure ) 。这样的失败是难调试的。它们可能是间歇性的,且与时间相关,程序的行为在 不同的虚拟机上可能根本不同。如果只需要线程之间的交互通信,而不需要互斥, vo latile 修饰符就是一种可以接受 的同步形式,但要正确地使用它可能需要一些技巧。

避免过度同步

​ 为了避免活性失败和安全性失败,在一个被同步的方法或者代码块中,永远不要放弃对客户端的控制。 换句话 说,在一个被同步的区域内部,不要调用设计成要被覆盖的方法,或者是由客户端以函数对象的形式提供的方法(详 见第 24 条) 。从包含该同步区域的类的角度来看,这样的方法是外来的( alien ) 。这个类不知道该方法会做什么 事情,也无法控制它。根据外来方法的作用,从同步区域中调用它会导致异常、死锁或者数据损坏。

​ 事实上,要将外来方法的调用移出同步的代码块,还有一种更好的方法。Java 类库提供了一个并发集合( concurrent collection ),详见第 81 条,称作 CopyOnWriteArrayList,这是专门为此定制的。这个 CopyOnWriteArrayList 是 ArrayList 的一种变体,它通过重新拷贝整个底层数组,在这里实现所有的写操作。由于内部 数组永远不改动,因此迭代不需要锁定,速度也非常快。如果大量使用, CopyOnWriteArrayList 的性能将大受影响, 但是对于观察者列表来说却是很好的,因为它们几乎不改动,并且经常被遍历。

​ 通常来说,应该在同步区域内做尽可能少的工作。 获得锁,检查共享数据,根据需要转换数据,然后释放锁。 如果你必须要执行某个很耗时的动作,则应该设法把这个动作移到同步区域的外面,而不造成共享数据安全。

​ 。在这个多核的时代,过度同步的实际成本并不是指获取锁所花 费的 CPU 时间;而是指失去了并行的机会,以及因为需要确保每个核都有一个-致的内存视图而导致的延迟。过度 同步的另一项潜在开销在于,它会限制虚拟机优化代码执行的能力。

​ 总而言之,为了避免死锁和数据破坏,千万不要从同步区字段内部调用外来方法。更通俗地讲,要尽量将同步区 字段内部的工作量限制到少。当你在设计一个可变类的时候,要考虑一下它们是否应该自己完成同步操作。在如今 这个多核的时代,这比永远不要过度同步来得更重要。只有当你有足够的理由一定要在内部同步类的时候,才应该这 么做,同时还应该将这个决定清楚地写到文档中。

Executor 、task 和 stream 优先于线程

​ 为特殊的应用程序选择 executor service 是很有技巧的。

​ 如果编写的是小程序,或者是轻量负载的服务器,使用 Executors.newCachedThreadPool 通常是个不错的选择,因为它不需要配置,并且一般情况下能够正确地完成工作。

​ 但是对于大负载的服务器来说,缓存的线程池就不是很好的选择了!在缓存的线程池中,被提交的任务没有排成队 列,而是直接交给线程执行。如果没有线程可用,就创建一个新的线程。如果服务器负载得太重,以致它所有的 CPU 都完全被占用了,当有更多的任务时,就会创建更多的线程,这样只会使情况变得更糟。

​ 因此,在大负载的产品服务 器中,最好使用 Executors.newFixedThreadPool ,它为你提供了一个包含固定线程数目的线程池,或者为了大限度 地控制它,就直接使用 ThreadPoolExecutor 类。

​ 不仅应该尽量不要编写自己的工作队列,而且还应该尽量不直接使用线程。当直接使用线程时, Thread 是既充 当工作单元,又是执行机制。在 Executor Framework 中,工作单元和执行机制是分开的。现在关键的抽象是工作单 元,称作任务( task ) 。任务有两种:Runnable 及其近亲 Callable (它与 Runnable 类似,但它会返回值,并且能 够抛出任意的异常) 。执行任务的通用机制是 executor service 。如果你从任务的角度来看问题,并让一个 executor service 替你执行任务,在选择适当的执行策略方面就获得了极大的灵活性。从本质上讲, Executor Framework 所做 的工作是执行, Collections Framework 所做的工作是聚合(aggregation)。

​ 在 Java 7 中, Executor Framework 得到了扩展,它可以支持 fork-join 任务了,这些任务是通过一种称作 fork-join 池的特殊 executor 服务运行的。fork-join 任务用 ForkJoinTask 实例表示,可以被分成更小的子任务,包含 ForkJoinPool 的线程不仅要处理这些任务,还要从另一个线程中“偷”任务,以确保所有的线程保持忙碌,从而提高 CPU 使用率、提高吞吐量,并降低延迟。fork-join 任务的编写和调优是很有技巧的。并发的 stream (详见第 48 条) 是在 fork join 池上编写的,我们不费什么力气就能享受到它们的性能优势,前提是假设它们正好适用于我们手边的任 务。

相比 wait 和 notify 优先使用并发工具

​ java.util.concurrent 中更高级的工具分成三类: Executor Framework 、并发集合( Concurrent Collection )以及 同步器( Synchronizer)。

​ 并发集合为标准的集合接口(如 List 、Queue 和 Map )提供了高性能的并发实现。为了提供高并发性,这些实 现在内部自己管理同步 。因此, 并发集合中不可能排除并发活动;将它锁定没有什么作用,只会使 程序的速度变慢

​ ConcurrentHashMap 除了提供卓越的并发性之外,速度也非常快。在我的机器上,上面这个优化过的 intern 方法 比 String.intern 快了不止 6 倍(但是记住, String.intern 必须使用某种弱引用,避免随着时间的推移而发生内存泄 漏)。并发集合导致同步的集合大多被废弃了。比如, 应该优先使用 ConcurrentHashMap ,而不是使用 Collections.synchronizedMap 。 只要用并发 Map 替换同步 Map ,就可以极大地提升并发应用程序的性能。

同步器( Synchronizer )是使线程能够等待另一个线程的对象,允许它们协调动作。常用的同步器是 CountDownLatch 和 Semaphore 。较不常用的是 CyclicBarrier 和 Exchanger 。功能强大的同步器是 Phaser

倒计数锁存器( Countdown Latch )是一次性的障碍,允许一个或者多个线程等待一个或者多个其他线程来做某 些事情。Count DownLatch 的唯一构造器带有一个 int 类型的参数,这个 int 参数是指允许所有在等待的线程被处理之 前,必须在锁存器上调用 countDown 方法的次数。

​ 简而言之,直接使用 wait 方法和 notify 方法就像用“并发汇编语言”进行编程一样,而 java.util.concurrent 则提供 了更高级的语言。 没有理由在新代码中使用 wait 方法和 notify 方法,即使有,也是极少的。 如果你在维护使用 wait 方法和 notify 方法的代码,务必确保始终是利用标准的模式从 while 循环内部调用 wait 方法。一般情况下,应该优先 使用 notifyAll 方法,而不是使用 notify 方法。如果使用 notify 方法,请一定要小心,以确保程序的活性。

文档应该包含线程安全属性

​ 类在其方法并发使用时的行为是其与客户端约定的重要组成部分。如果你没有记录类在这一方面的行为,那么它 的用户将被迫做出假设。如果这些假设是错误的,生成的程序可能缺少足够的同步或过度的同步。无论哪种情况,都可能导致严重的错误。

​ 你可能听说过,可以通过在方法的文档中查找 synchronized 修饰符来判断方法是否线程安全。这个观点有好些方 面是错误的。在正常操作中,Javadoc 的输出中没有包含同步修饰符,这是有原因的。方法声明中 synchronized 修饰 符的存在是实现细节,而不是其 API 的一部分。它不能可靠地表明方法是线程安全的

要启用安全的并发使用,类必须清楚地记录它支持的线程安全级别

​ 总之,每个类都应该措辞严谨的描述或使用线程安全注解清楚地记录其线程安全属性。synchronized 修饰符在文 档中没有任何作用。有条件的线程安全类必须记录哪些方法调用序列需要外部同步,以及在执行这些序列时需要获取 哪些锁。如果你编写一个无条件线程安全的类,请考虑使用一个私有锁对象来代替同步方法。这将保护你免受客户端 和子类的同步干扰,并为你提供更大的灵活性,以便在后续的版本中采用复杂的并发控制方式。

明智审慎的使用延迟初始化

​ 延迟初始化是延迟字段的初始化,直到需要它的值。如果不需要该值,则不会初始化字段。这种技术既适用于静 态字段,也适用于实例字段。虽然延迟初始化主要是一种优化,但是它也可以用于破坏类中的有害循环和实例初始化 。

​ 延迟初始化也有它的用途。如果一个字段只在类的一小部分实例上访问,并且初始化该字段的代价很高,那么延 迟初始化可能是值得的。唯一确定的方法是以使用和不使用延迟初始化的效果对比来度量类的性能。

在大多数情况下,常规初始化优于延迟初始化

如果您使用延迟初始化来取代初始化的循环(circularity),请使用同步访问器,因为它是简单、清晰的替 代方法:

// Lazy initialization of instance field - synchronized accessor 
private FieldType field; 

private synchronized FieldType getField() {
    
        
    if (field == null)        
        field = computeFieldValue();    
    return field; 
}

​ 这两种习惯用法(使用同步访问器进行常规初始化和延迟初始化)在应用于静态字段时都没有改变,只是在字段 和访问器声明中添加了 static 修饰符.

如果需要使用延迟初始化来提高实例字段的性能,请使用双重检查模式。这个模式避免了初始化后访问字段时的 锁定成本

​ 总之,您应该正常初始化大多数字段,而不是延迟初始化。如果必须延迟初始化字段以实现性能目标或为了破坏 友有害的初始化循环,则使用适当的延迟初始化技术。对于字段,使用双重检查模式;对于静态字段,则应该使用 the lazy initialization holder class idiom。例如,可以容忍重复初始化的实例字段,您还可以考虑单检查模式。

不要依赖线程调度器

​ 当许多线程可以运行时,线程调度器决定哪些线程可以运行以及运行多长时间。任何合理的操作系统都会尝试公 平地做出这个决定,但是策略可能会有所不同。因此,编写良好的程序不应该依赖于此策略的细节。任何依赖线程调 度器来保证正确性或性能的程序都可能是不可移植的

​ 编写健壮、响应快、可移植程序的佳方法是确保可运行线程的平均数量不显著大于处理器的数量。这使得线程 调度器几乎没有选择:它只运行可运行线程,直到它们不再可运行为止。即使在完全不同的线程调度策略下,程序的 行为也没有太大的变化。

​ 保持可运行线程数量低的主要技术是让每个线程做一些有用的工作,然后等待更多的工作。如果线程没有做有用 的工作,它们就不应该运行。 对于 Executor 框架(详见第 80 条),这意味着适当调整线程池的大小 [Goetz06, 8.2], 并保持任务短小(但不要太短),否则分派开销依然会损害性能。

​ 总之,不要依赖线程调度器来判断程序的正确性。生成的程序既不健壮也不可移植。因此,不要依赖 Thread.yield 或线程优先级。这些工具只是对调度器的提示。线程优先级可以少量地用于提高已经工作的程序的 服务质量,但绝不应该用于「修复」几乎不能工作的程序。

优先选择Java序列化的替代方案

​ 序列化的一个根本问题是它的可攻击范围太大,且难以保护,而且问题还在不断增多:通过调用 ObjectInputStream 上的 readObject 方法反序列化对象图。这个方法本质上是一个神奇的构造函数,可以用来实例化 类路径上几乎任何类型的对象,只要该类型实现 Serializable 接口。在反序列化字节流的过程中,此方法可以执行来 自任何这些类型的代码,因此所有这些类型的代码都在攻击范围内。

​ 攻击可涉及 Java 平台库、第三方库(如 Apache Commons collection)和应用程序本身中的类。即使坚持履行实 践了所有相关的佳建议,并成功地编写了不受攻击的可序列化类,应用程序仍然可能是脆弱的。

​ 当你反序列化一个你不信任的字节流时,你就会受到攻击。避免序列化被利用的好方法是永远不要反序列化任何东西没有理由在你编写的任何新系统中使用 Java 序列化

​ 如果你不能完全避免 Java 序列化,可能是因为你需要在遗留系统环境中工作,那么你的下一个佳选择是 永远 不要反序列化不可信的数据

​ 总之,序列化是危险的,应该避免。如果你从头开始设计一个系统,可以使用跨平台的结构化数据,如 JSON 或 protobuf。不要反序列化不可信的数据。如果必须这样做,请使用对象反序列化过滤,但要注意,它不能保证阻止所 有攻击。避免编写可序列化的类。如果你必须这样做,一定要非常小心。

谨慎的实现Serializable

​ 使类的实例可序列化非常简单,只需实现 Serializable 接口即可。因为这很容易做到,所以有一个普遍的误解, 认为序列化只需要程序员付出很少的努力。而事实上要复杂得多。虽然使类可序列化的即时代价可以忽略不计,但长 期代价通常是巨大的。

​ 实现 Serializable 接口的一个主要代价是,一旦类的实现被发布,它就会降低更改该类实现的灵活性。

​ 实现 Serializable 接口的第二个代价是,增加了出现 bug 和安全漏洞的可能性

​ 实现 Serializable 接口的第三个代价是,它增加了与发布类的新版本相关的测试负担。

	实现 Serializable 接口并不是一个轻松的决定。 如果一个类要参与一个框架,该框架依赖于 Java 序列化来进行 对象传输或持久化,这对于类来说实现 Serializable 接口就是非常重要的。

​ 为继承而设计的类很少情况适合实现 Serializable 接口,接口也很少情况适合扩展它。 违反此 规则会给扩展类或实现接口的任何人带来很大的负担。

内部类不应该实现 Serializable。 它们使用编译器生成的合成字段存储对外围实例的引用,并 存储来自外围的局部变量的值。这些字段与类定义的对应关系,就和没有指定匿名类和局部类的名称一样。因此,内 部类的默认序列化形式是不确定的。但是,静态成员类可以实现 Serializable 接口。

考虑使用自定义的序列化形式

​ 当你在时间紧迫的情况下编写类时,通常应该将精力集中在设计佳的 API 上。有时,这意味着发布一个「一次 性」实现,你也知道在将来的版本中会替换它。通常这不是一个问题,但是如果类实现 Serializable 接口并使用默认 的序列化形式,你将永远无法完全摆脱这个「一次性」的实现。它将永远影响序列化的形式。这不仅仅是一个理论问 题。

在没有考虑默认序列化形式是否合适之前,不要接受它。 接受默认的序列化形式应该是一个三思而后行的决 定,即从灵活性、性能和正确性的角度综合来看,这种编码是合理的。一般来说,设计自定义序列化形式时,只有与 默认序列化形式所选择的编码在很大程度上相同时,才应该接受默认的序列化形式。

无论选择哪种序列化形式,都要在编写的每个可序列化类中声明显式的序列版本 UID。 这消除了序列版本 UID 成为不兼容性的潜在来源(详见第 86 条)。这么做还能获得一个小的性能优势。如果没有提供序列版本 UID,则需 要执行高开销的计算在运行时生成一个 UID。

​ 总而言之,如果你已经决定一个类应该是可序列化的,那么请仔细考虑一下序列化的形式应该 是什么。只有在合理描述对象的逻辑状态时,才使用默认的序列化形式;否则,设计一个适合描述对象的自定义序列 化形式。设计类的序列化形式应该和设计导出方法花的时间应该一样多,都应该严谨对待(详见第 51 条)。正如不 能从未来版本中删除导出的方法一样,也不能从序列化形式中删除字段;必须永远保存它们,以确保序列化兼容性。 选择错误的序列化形式可能会对类的复杂性和性能产生永久性的负面影响。

保护性的编写readObject方法

​ 总而言之,在编写 readObject 方法的时候,都要这样想:你正在编写一个公有的构造器,无论给它传递什么样的 字节流,它都必须产生一个有效的实例。不要假设这个字节流一定代表着一个真正被序列化的实例。虽然在本条目的 例子中,类使用了默认的序列化形式,但是所有讨论到的有可能发生的问题也同样适用于自定义序列化形式的类。下 面以摘要的形式给出一些指导方针,有助于编写出更健壮的 readObject 方法。

  • 类中的对象引用字段必须保持为私有属性,要保护性的拷贝这些字段中的每个对象。不可变类中的可变组件就 属于这一类别
  • 对于任何约束条件,如果检查失败就抛出一个 InvalidObjectException 异常。这些检查动作应该跟在所有的保护 性拷贝之后。
  • 如果整个对象图在被反序列化之后必须进行验证,就应该使用 ObjectInputValidation 接口(本书没有讨论)。
  • 无论是直接方法还是间接方法,都不要调用类中任何可被覆盖的方法。

对于实例控制,枚举优于readResolve

​ 事实上,如果依赖 readResolve 进行实例控制,带有对象引用类型的所有实例字段都必须声明为 transient 。

readResolve 的可访问性(accessibility)十分重要。 如果把 readResolve 方法放在一个 final 类上面,它 应该是私有的。如果把 readResolve 方法放在一个非 final 类上,就必须认真考虑它的的访问性。如果它是私 有的,就不适用于任何一个子类。如果它是包级私有的,就适用于同一个包内的子类。如果它是受保护的或者是公开 的,并且子类没有覆盖它,对序列化的子类进行反序列化,就会产生一个超类实例,这样可能会导致 ClassCastException 异常。

​ 总而言之,应该尽可能的使用枚举类型来实施实例控制的约束条件。如果做不到,同时又需要一个即可序列化又 可以实例受控的类,就必须提供一个 readResolve 方法,并确保该类的所有实例化字段都被基本类型,或者是 transient 的。

考虑用序列化代理,代替序列化实例

​ 实现 Serializable 接口,会增加出错和出现安全问题 的可能性,因为它允许利用语言之外的机制来创建实例,而不是使用普通的构造器。然而,有一只方法可以极大的减 少这些风险。就是序列化代理模式(seralization proxy pattern)。

序列化代理模式相当简单。首先,为可序列化的类设计一个私有的静态嵌套类,精确地表示外围类的逻辑状态。 这个嵌套类被称为序列化代理(seralization proxy),它应该有一个单独的构造器,其参数类型就是那个外围类。这 个构造器只是从它的参数中复制数据:它不需要进行任何一致性检验或者保护性拷贝。从设计的角度看,序列化代理 的默认序列化形式是外围类好的序列化形式。外围类及其序列代理都必须声明实现 Serializable 接口。

序列化代理模式有两个局限性。它不能与可以被客户端拓展的类兼容。它也不能与对象图中包含 循环的某些类兼容:如果你企图从一个对象的序列化代理的 readResovle 方法内部调用这个对象的方法,就会得到一 个 ClassCastException 异常,因为你还没有这个对象,只有它的序列化代理。

​ 最后一点,序列化代理模式所增强的功能和安全性不是没有代价。在我的机器上,通过序列化代理来序列化和反 序列化 Period 实例的开销,比使用保护性拷贝增加了 14%。

​ 总而言之,当你发现必须在一个不能被客户端拓展的类上面编写 readObject 或者 writeObject 方法时,就应该考 虑使用序列化代理模式。想要稳健的将带有重要约束条件的对象序列化时,这种模式是容易的方法。

猜你喜欢

转载自blog.csdn.net/weixin_40709965/article/details/130898972