Effective-Java学习笔记(八)

第九章 通用程序设计

57. 将局部变量的作用域最小化

本条目在性质上类似于Item-15,即「最小化类和成员的可访问性」。通过最小化局部变量的范围,可以提高代码的可读性和可维护性,并降低出错的可能性。

  1. 将局部变量的作用域最小化,最具说服力的方式就是在第一次使用它的地方声明
  2. 每个局部变量声明都应该包含一个初始化表达式。 如果你还没有足够的信息来合理地初始化一个变量,你应该推迟声明,直到条件满足

58. for-each循环优于for循环

for-each 更简洁更不容易出错,且没有性能损失,只有三种情况不能用for-each

  1. 破坏性过滤。如果需要遍历一个集合并删除选定元素,则需要使用显式的迭代器,以便调用其 remove 方法。通过使用 Collection 在 Java 8 中添加的 removeIf 方法,通常可以避免显式遍历。
  2. 转换。如果需要遍历一个 List 或数组并替换其中部分或全部元素的值,那么需要 List 迭代器或数组索引来替换元素的值。
  3. 并行迭代。如果需要并行遍历多个集合,那么需要显式地控制迭代器或索引变量,以便所有迭代器或索引变量都可以同步执行

59. 了解并使用库

假设你想要生成 0 到某个上界之间的随机整数,有些程序员会写出如下代码:

// Common but deeply flawed!
static Random rnd = new Random();
static int random(int n) {
    return Math.abs(rnd.nextInt()) % n;
}

这个方法有三个缺点:

  1. 如果 n 是比较小的 2 的幂,随机数序列会在相当短的时间内重复
  2. 如果 n 不是 2 的幂,一些数字出现的频率会更高,当 n 很大时效果明显
  3. 会返回超出指定范围的数字,当nextInt返回Integer.MIN_VALUE时,abs方法也会返回Integer.MIN_VALUE,假设 n 不是 2 幂,那么Integer.MIN_VALUE % n 将返回负数

我们不需要为这个需求自己编写方法,已经存在经过专家设计测试的标准库Random 的 nextInt(int)

从 Java 7 开始,就不应该再使用 Random。在大多数情况下,选择的随机数生成器现在是 ThreadLocalRandom。 它能产生更高质量的随机数,而且速度非常快。

总而言之,不要白费力气重新发明轮子。如果你需要做一些看起来相当常见的事情,那么库中可能已经有一个工具可以做你想做的事情。如果有,使用它;如果你不知道,查一下。一般来说,库代码可能比你自己编写的代码更好,并且随着时间的推移可能会得到改进。

60. 若需要精确答案就应避免使用 float 和 double 类型

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

正确做法是使用 BigDecimal、int 或 long 进行货币计算。还要注意 BigDecimal 的构造函数要使用String参数而不是double,避免初始化时就用了不精确的值。int和long可以存储较小单位的值,将小数转换成整数存储

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

61. 基本数据类型优于包装类

Java 的类型系统有两部分,基本类型(如 int、double 和 boolean)和引用类型(如String和List)。每个基本类型都有一个对应的引用类型,称为包装类。如Integer、Double 和 Boolean

基本类型和包装类型之间的区别如下

  1. 基本类型只有它们的值,而包装类型具有与其值不同的标识。换句话说,两个包装类型实例可以具有相同的值和不同的标识。
  2. 基本类型只有全部功能值,而每个包装类型除了对应的基本类型的所有功能值外,还有一个非功能值,即 null
  3. 基本类型比包装类型更节省时间和空间
  4. 用 == 来比较包装类型几乎都是错的
  5. 在操作中混用基本类型和包装类型时,包装类型会自动拆箱,可能导致 NPE,如果操作结果保存到包装类型的变量中,还会发生自动装箱,导致性能问题

包装类型的用途如下:

  1. 作为集合的元素、键和值
  2. 泛型和泛型方法中的类型参数
  3. 反射调用

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

62. 其它类型更合适时应避免使用字符串

本条目讨论一些不应该使用字符串的场景

  1. 字符串是枚举类型的糟糕替代品
  2. 字符串是聚合类型的糟糕替代品。如果一个对象有多个字段,将其连接成一个字符串会出现许多问题,更好的方法是用私有静态成员类表示聚合
  3. 字符串是功能的糟糕替代品。例如全局缓存池要求 key 不能重复,如果用字符串做 key 可能在不同线程中出现问题

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

63. 当心字符串连接引起的性能问题

字符串连接符(+)连接 n 个字符串的时间复杂度是 n2n^2。这是字符串不可变导致的

如果要连接的字符串数量较多,可以使用 StringBuilder 代替 String

64. 通过接口引用对象

  1. 如果存在合适的接口类型,那么应该使用接口类型声明参数、返回值、变量和字段。好处是代码可以非常方便切换性能更好或功能更丰富的实现,例如 HashMap 替换成 LinkedHashMap 可以保证迭代顺序和插入一致

  2. 如果没有合适的接口存在,那么用类引用对象是完全合适的。

    • 值类,如 String 和 BigInteger。值类很少在编写时考虑到多个实现。它们通常是 final 的,很少有相应的接口。使用这样的值类作为参数、变量、字段或返回类型非常合适。
    • 如果一个对象属于一个基于类的框架,那么就用基类引用它
    • 接口的实现类有接口没有的方法,例如 PriorityQueue 类有 Queue 接口没有的比较器方法

65. 接口优于反射

反射有几个缺点:

  1. 失去了编译时类型检查的所有好处,包括异常检查。如果一个程序试图反射性地调用一个不存在的或不可访问的方法,它将在运行时失败,除非你采取了特殊的预防措施(大量 try-catch)
  2. 反射代码既笨拙又冗长。写起来很乏味,读起来也很困难
  3. 反射调用方法比普通调用方法更慢

反射优点:

对于许多程序,它们必须用到在编译时无法获取的类,在编译时存在一个适当的接口或父类来引用该类(Item-64)。如果是这种情况,可以用反射方式创建实例,并通过它们的接口或父类正常地访问它们。

例如,这是一个创建 Set<String> 实例的程序,类由第一个命令行参数指定。程序将剩余的命令行参数插入到集合中并打印出来。不管第一个参数是什么,程序都会打印剩余的参数,并去掉重复项。然而,打印这些参数的顺序取决于第一个参数中指定的类。如果你指定 java.util.HashSet,它们显然是随机排列的;如果你指定 java.util.TreeSet,它们是按字母顺序打印的,因为 TreeSet 中的元素是有序的

// Reflective instantiation with interface access
public static void main(String[] args) {

    // Translate the class name into a Class object
    Class<? extends Set<String>> cl = null;
    try {
        cl = (Class<? extends Set<String>>) // Unchecked cast!
        Class.forName(args[0]);
    } catch (ClassNotFoundException e) {
        fatalError("Class not found.");
    }

    // Get the constructor
    Constructor<? extends Set<String>> cons = null;
    try {
        cons = cl.getDeclaredConstructor();
    } catch (NoSuchMethodException e) {
        fatalError("No parameterless constructor");
    }

    // Instantiate the set
    Set<String> s = null;
    try {
        s = cons.newInstance();
    } catch (IllegalAccessException e) {
        fatalError("Constructor not accessible");
    } catch (InstantiationException e) {
        fatalError("Class not instantiable.");
    } catch (InvocationTargetException e) {
        fatalError("Constructor threw " + e.getCause());
    } catch (ClassCastException e) {
        fatalError("Class doesn't implement Set");
    }

    // Exercise the set
    s.addAll(Arrays.asList(args).subList(1, args.length));
    System.out.println(s);
}

private static void fatalError(String msg) {
    System.err.println(msg);
    System.exit(1);
}

反射的合法用途(很少)是管理类对运行时可能不存在的其他类、方法或字段的依赖关系。如果你正在编写一个包,并且必须针对其他包的多个版本运行,此时反射将非常有用。该技术是根据支持包所需的最小环境(通常是最老的版本)编译包,并反射性地访问任何较新的类或方法。如果你试图访问的新类或方法在运行时不存在,要使此工作正常进行,则必须采取适当的操作。适当的操作可能包括使用一些替代方法来完成相同的目标,或者使用简化的功能进行操作

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

66. 明智地使用本地方法

JNI 允许 Java 调用本地方法,这些方法是用 C 或 C++ 等本地编程语言编写的。

历史上,本地方法主要有三个用途:

  1. 提供对特定于平台的设施(如注册中心)的访问
  2. 提供对现有本地代码库的访问,包括提供对遗留数据访问
  3. 通过本地语言编写应用程序中注重性能的部分,以提高性能

关于用途一:随着 Java 平台的成熟,它提供了对许多以前只能在宿主平台中上找到的特性。例如,Java 9 中添加的流 API 提供了对 OS 进程的访问。在 Java 中没有等效库时,使用本地方法来使用本地库也是合法的。

关于用途3:在早期版本(Java 3 之前),这通常是必要的,但是从那时起 JVM 变得更快了。对于大多数任务,现在可以在 Java 中获得类似的性能

本地方法的缺点:

  1. 会受到内存损坏错误的影响
  2. 垃圾收集器无法自动追踪本地方法的内存使用情况,导致性能下降

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

67. 明智地进行优化

关于优化的三条名言

  1. 以效率的名义(不一定能达到效率)犯下的计算错误比任何其他原因都要多——包括盲目的愚蠢
  2. 不要去计较效率上的一些小小的得失,在 97% 的情况下,不成熟的优化才是一切问题的根源。
  3. 在优化方面,我们应该遵守两条规则:规则 1:不要进行优化。规则 2 (仅针对专家):还是不要进行优化,也就是说,在你还没有绝对清晰的未优化方案之前,请不要进行优化。

在设计系统时,我们要仔细考虑架构,好的架构允许它在后面优化,不良的架构导致很难优化。设计中最难更改的是组件之间以及组件与外部世界交互的组件,主要是 API、线路层协议和数据持久化格式,尽量避免做限制性能的设计

JMH 是一个微基准测试框架,主要是基于方法层面的基准测试,精度可以达到纳秒级

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

68. 遵守被广泛认可的命名约定

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

排版

和排版有关的命名约定,包括包、类、接口、方法、字段和类型变量

  1. 包名和模块名应该是分层的,组件之间用句点分隔。组件应该由小写字母组成,很少使用数字。任何在你的组织外部使用的包,名称都应该以你的组织的 Internet 域名开头,并将组件颠倒过来,例如,edu.cmu、com.google、org.eff。以 java 和 javax 开头的标准库和可选包是这个规则的例外。用户不能创建名称以 java 或 javax 开头的包或模块。
    包名的其余部分应该由描述包的一个或多个组件组成。组件应该很短,通常为 8 个或更少的字符。鼓励使用有意义的缩写,例如 util 而不是 utilities。缩写词是可以接受的,例如 awt。组件通常应该由一个单词或缩写组成。
  2. 类和接口名称,包括枚举和注释类型名称,应该由一个或多个单词组成,每个单词的首字母大写,例如 List 或 FutureTask
  3. 方法和字段名遵循与类和接口名相同的排版约定,除了方法或字段名的第一个字母应该是小写,例如 remove 或 ensureCapacity
  4. 前面规则的唯一例外是「常量字段」,它的名称应该由一个或多个大写单词组成,由下划线分隔,例如 VALUES 或 NEGATIVE_INFINITY
  5. 局部变量名与成员名具有类似的排版命名约定,但允许使用缩写,也允许使用单个字符和短字符序列,它们的含义取决于它们出现的上下文,例如 i、denom、houseNum。输入参数是一种特殊的局部变量。它们的命名应该比普通的局部变量谨慎得多,因为它们的名称是方法文档的组成部分。
  6. 类型参数名通常由单个字母组成。最常见的是以下五种类型之一:T 表示任意类型,E 表示集合的元素类型,K 和 V 表示 Map 的键和值类型,X 表示异常。函数的返回类型通常为 R。任意类型的序列可以是 T、U、V 或 T1、T2、T3。

为了快速参考,下表显示了排版约定的示例。

Identifier Type Example
Package or module org.junit.jupiter.api, com.google.common.collect
Class or Interface Stream, FutureTask, LinkedHashMap,HttpClient
Method or Field remove, groupingBy, getCrc
Constant Field MIN_VALUE, NEGATIVE_INFINITY
Local Variable i, denom, houseNum
Type Parameter T, E, K, V, X, R, U, V, T1, T2

语法

语法命名约定比排版约定更灵活,也更有争议

  1. 可实例化的类,包括枚举类型,通常使用一个或多个名词来命名,例如 Thread、PriorityQueue 或 ChessPiece
  2. 不可实例化的工具类通常用复数名词来命名,例如 Collectors 和 Collections
  3. 接口的名称类似于类,例如 Collection 或 Comparator,或者以 able 或 ible 结尾的形容词,例如 Runnable、Iterable 或 Accessible
  4. 因为注解类型有很多的用途,所以没有哪部分占主导地位。名词、动词、介词和形容词都很常见,例如,BindingAnnotation、Inject、ImplementedBy 或 Singleton。
  5. 执行某些操作的方法通常用动词或动词短语(包括对象)命名,例如,append 或 drawImage。
  6. 返回布尔值的方法的名称通常以单词 is 或 has(通常很少用)开头,后面跟一个名词、一个名词短语,或者任何用作形容词的单词或短语,例如 isDigit、isProbablePrime、isEmpty、isEnabled 或 hasSiblings。
  7. 返回被调用对象的非布尔函数或属性的方法通常使用以 get 开头的名词、名词短语或动词短语来命名,例如 size、hashCode 或 getTime。有一种说法是,只有第三种形式(以 get 开头)才是可接受的,但这种说法几乎没有根据。前两种形式的代码通常可读性更强 。以 get 开头的形式起源于基本过时的 Java bean 规范,该规范构成了早期可复用组件体系结构的基础。有一些现代工具仍然依赖于 bean 命名约定,你应该可以在任何与这些工具一起使用的代码中随意使用它。如果类同时包含相同属性的 setter 和 getter,则遵循这种命名约定也有很好的先例。在本例中,这两个方法通常被命名为 getAttribute 和 setAttribute。
  8. 转换对象类型(返回不同类型的独立对象)的实例方法通常称为 toType,例如 toString 或 toArray。
  9. 返回与接收对象类型不同的视图(Item-6)的方法通常称为 asType,例如 asList
  10. 返回与调用它们的对象具有相同值的基本类型的方法通常称为类型值,例如 intValue
  11. 静态工厂的常见名称包括 from、of、valueOf、instance、getInstance、newInstance、getType 和 newType
  12. 字段名的语法约定没有类、接口和方法名的语法约定建立得好,也不那么重要,因为设计良好的 API 包含很少的公开字段。类型为 boolean 的字段的名称通常类似于 boolean 访问器方法,省略了开头「is」,例如 initialized、composite。其他类型的字段通常用名词或名词短语来命名,如 height、digits 和 bodyStyle。局部变量的语法约定类似于字段的语法约定,但要求更少。