Featured image of post 通用程序设计

通用程序设计

☘️ 通用程序设计

Effective Java 阅读笔记。本章主要讨论了 Java 语言的具体细节。包括:

  • 局部变量
  • 控制结构
  • 类库
  • 数据结构
  • 不是由语言本身提供的机制
  • 优化和命名惯例。

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

🌸 建议

  • 🌱 最小化局部变量的范围,可提高代码的可读性和可维护性,降低出错的可能性。在第一次使用它的地方声明变量。

  • 🍀 每个局部变量声明都应该包含一个初始化表达式。try-catch 语句除外。

    如果一个变量初始化,会抛出一个 checked 异常,必须在 try 中初始化(除非所包含的方法可以抛异常)。

    如果该值必须在 try 块之外使用,那么它必须在 try 块之前声明,此时它还不能“合理地初始化“。

    1
    2
    3
    4
    5
    
    Set<String> set = null;
    try {
      set = cons.newInstance();
    } catch () {
    }
    
  • 🍀 循环结束后不再需要循环变量,for 循环就优于 while 循环(for 循环允许声明循环变量)。

  • 🌱 保持方法小而集中,每个操作都用一个方法来完成。避免一个操作相关的局部变量可能在另一个操作中。

🌻 案例

  • 🌾 遍历集合的首选习惯用法

    1
    2
    3
    
    for (Element e : c) {
      // do something with e
    }
    
  • 💐 需要访问 Iterator / 调用 Iteratorremove(),首选 for

    1
    2
    3
    4
    
    for (Iterator<Element> i = c.iterator(); i.hasNext(); ) {  // i is loop variable
      Element e = i.next();
      // do something with i and e
    }
    
  • 💐 循环习惯用法:最小化局部变量的范围

    1
    2
    3
    4
    
    // 每次循环都会调用 expensiveComputation(),且返回结果相同
    for (int i = 0, n = expensiveComputation(); i < n; i++) {
      // do something with i
    }
    

    它有两个循环变量:i 和 n,都具有完全正确的作用域。第二个变量 n 用于存储第一个变量的极限,避免了每次迭代中冗余计算的成本。 如果循环涉及一个方法调用,并且保证在每次迭代中返回相同的结果,应该使用这个习惯用法。

58. for-each 循环优于传统的 for 循环

🌸 建议

  • 🌱 for-each 隐藏迭代器或索引变量,可消除混乱和出错。

  • 🌱 : 表示 “在…里面”。使用 for-each 循环不会降低性能。

  • 🥀 以下情况不应该使用 for-each

    • 🌰 破坏性过滤。如果需要遍历一个集合并删除选定元素,需要使用显式的迭代器,以便调用其 remove()

    • 🌰 转换。遍历时需要替换其中元素的值,需要 List 迭代器或数组索引来替换元素的值。

    • 🌰 并行迭代。如果需要并行遍历多个集合,那么需要显式地控制迭代器或索引变量,以便所有迭代器或索引变量都可以同步执行。

🌻 案例

  • 💐 使用 Collection.removeIf() 可避免显式的遍历(Java8)。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    
    public interface Collection<E> extends Iterable<E> {
      default boolean removeIf(Predicate<? super E> filter) {
        Objects.requireNonNull(filter);
        boolean removed = false;
        final Iterator<E> each = interator();
        while (each.hasNext()) {
          if (filter.test(each.next)) {
            each.remove();
            removed = true;
          }
        }
        return removed;
      }
    }
    

59. 了解并使用库

🌸 建议

能用库方法就用库方法。

具有算法背景的高级工程师花了大量时间设计、实现和测试库方法,然后将库方法展示给该领域的几位专家,以确保库方法是正确的。 这些库方法经过 beta 测试、发布,并被数百万程序员广泛使用了近 20 年。

  • 🌱 使用标准类库的好处:

    • 🌰 利用编写它的专家的知识和以前使用它的人的经验。
    • 🌰 不必浪费时间为那些与你的工作无关的问题编写专门的解决方案。
    • 🌰 随着时间的推移,它们的性能会不断提高,无需付出任何努力。(版本更新)
    • 🌰 可以将代码放在主干中。这样的代码更容易被开发人员阅读、维护和复用。
  • 🌱 在每个主要版本中,都会向库中添加许多特性,了解这些新增特性是值得的。

  • 🍀 每个程序员都应该熟悉 java.langjava.utiljava.io 的基础知识及其子包。 其他库的知识可以根据需要获得。尤其是:

    • 🌰 java.util.Collections
    • 🌰 java.util.Streams
    • 🌰 java.util.concurrent
  • 🍀 如果你在 Java 平台库中找不到你需要的东西,你的下一个选择应该是寻找高质量的第三方库,比如谷歌的优秀的开源 Guava 库。如果你无法在任何适当的库中找到所需的功能,只能自己实现它。

🌻 案例

  • 💐 从 Java 7 开始,就不应该再使用 Random。

    • 🌰 在大多数情况下,选择的随机数生成器现在是 ThreadLocalRandom。 它能产生更高质量的随机数,而且速度非常快。ThreadLocalRandom.current().nextInt(100)

    • 🌰 对于 Fork Join Pool 和并行 Stream,使用 SplittableRandom

    假设你想要生成 0 到某个上界之间的随机整数,许多程序员会编写一个类似这样的小方法:

    1
    2
    3
    4
    5
    
    // 有严重缺陷
    static Random rnd = new Random();
    static int random(int n) {
      return Math.abs(rad.nextInt()) % n;
    }
    

    它有三个缺点:

    • 🌩 n 是小的平方数,随机数序列会在相当短的时间内重复
    • 🌩 如果 n 不是 2 的幂,那么平均而言,一些数字将比其他数字更频繁地返回
    • 🌩 在极少数情况下会返回超出指定范围的数字 Integer.MAX_VALUE

    应该直接使用类库 Ramdom.nextInt(n) ,而不是自己封装

    1
    
    public int nextInt(int n);
    
  • 🌾 Linux curl in Java 9

假设你想编写一个程序来打印命令行中指定的 URL 的内容(这大致是 Linux curl 命令所做的)。在 Java 9 之前,这段代码有点乏味,但是在 Java 9 中,transferTo() 被添加到 InputStream 中。这是一个使用这个新方法执行这项任务的完整程序:

1
2
3
4
5
public static void main(String[] args) throws IOException {
  try (InputStream in = new URL(args[0]).openStream()) {
    in.transferTo(System.out);
  }
}

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

🌸 建议

  • 🌱 floatdouble 类型特别不适合进行货币计算,因为不能将 0.1(或 10 的任意负次幂)精确地表示。
1
2
// 0.6100000000000001
System.out.println(1.03 - 0.42);
  • 🍀 数值不是太大,可以使用 intlong
    • 🌰 如果数值不超过 9 位小数,可以使用 int
    • 🌰 如果不超过 18 位,可以使用 long
    • 🌰 如果数量可能超过 18 位,则使用 BigDecimal

例如:在微信支付的 Java SDK 中,货币的类型为 int,结算单位为 分; 在支付宝支付的 Java SDK 中,货币的类型为 String,结算单位为 元

  • 🌱 使用 BigDecimal 类型代替 double / float。注意,使用 BigDecimalString 构造函数而不是它的 double / float 构造函数。

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

🌸 建议

  • 🌱 Java 类型系统由两部分组成:

    • 🌰 基本类型(primitive type):int double boolean
    • 🌰 引用类型(reference type):String List 每个基本类型都有一个对应的引用类型,称为包装类型。
  • 🌱 基本类型和包装类型之间有三个主要区别:

    • 🌰 基本类型只有它们的值,而包装类型具有与其值不同的标识
    • 🌰 基本类型只有全功能值,而每个包装类型除了对应的基本类型的所有功能值外,还有一个非功能值,即 null
    • 🌰 基本类型比包装类型更节省时间和空间
  • 🍀 要有选择,就应该优先使用基本类型,而不是包装类型。基本类型更简单、更快。

  • 🥀 以下情况必须使用包装类型:

    • 🌰 作为集合中的元素、键和值
    • 🌰 必须使用包装类型作为类型参数,Java 不允许使用基本类型。如 ThreadLocal<Integer>
    • 🌰 进行反射方法调用时,必须使用包装类型

🌻 案例

  • 🌾 以下三个例子为常见的包装类型问题:

    • 🌩 将 == 操作符应用于包装类型几乎都是错误的

      1
      2
      
      🙅
      Comparator<Integer> naturalOrder =(i, j) -> (i < j) ? -1 : (i == j ? 0 : 1);
      

      表达式 i < j 会使 i 和 j 引用的 Integer 实例自动拆箱。 表达式 i == j 对两个对象引用执行比较,返回 false

      1
      2
      3
      4
      5
      
      🙆
      Comparator<Integer> naturalOrder = (iBoxed, jBoxed) -> {
          int i = iBoxed, j = jBoxed; // 自动拆箱
          return i < j ? -1 : (i == j ? 0 : 1);
      };
      
    • 🌩 NullPointerException

      1
      2
      3
      4
      5
      6
      7
      
      public class Unbelievable {
      static Integer i;
      public static void main(String[] args) {
          if (i == 42)
              System.out.println("Unbelievable");
          }
      }
      

      计算表达式 i == 42 时抛出 NullPointerException。空对象引用自动拆箱,将得到一个 NullPointerException

    • 🌩 严重的性能问题

      1
      2
      3
      4
      5
      6
      7
      
      public static void main(String[] args) {
          Long sum = 0L;
          for (long i = 0; i < Integer.MAX_VALUE; i++) {
              sum += i;
          }
          System.out.println(sum);
      }
      

      局部变量 sum,它是包装类型 Long,而不是基本类型 long。变量被反复装箱和拆箱(超出缓存部分时),导致产生明显的性能下降。

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

🌸 建议

  • 🌱 当存在或可以编写更好的数据类型时,应避免将字符串用来表示对象。

  • 🌱 如果使用不当,字符串比其他类型更麻烦、灵活性更差、速度更慢、更容易出错。

  • 🍀 字符串不适合代替其他的值类型。

    • 🌰 如果是数值类型,则应将其转换为适当的数值类型,如 intfloatBigInteger
    • 🌰 如果是问题的答案,如“是”或“否”这类形式,则应将其转换为适当的 Enumboolean
    • 🌰 更一般地,如果有合适的值类型,无论是基本类型还是对象引用,都应该使用它;如果没有,应该写一个。
  • 🌱 字符串不适合代替枚举类型。

  • 🌱 字符串不适合代替聚合类型。

    如果一个实体有多个组件,将其表示为单个字符串通常是很不合适的。 例如,下面这行代码来自一个真实的系统标识符:

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

    这种方法有很多缺点:如果用于分隔字段 # 的字符出现在其中一个字段中,可能会导致混乱。要访问各个字段,你必须解析字符串。不能提供 equalstoStringcompareTo 方法,但必须接受 String 提供的行为。 更好的方法是编写一个类来表示聚合,通常是一个私有静态成员类。(Item-24)。

  • 🍀 字符串不适合代替 capabilities

    有时,字符串用于授予对某些功能的访问权。

    例如,考虑线程本地变量机制的设计。这样的机制提供了每个线程都有自己的变量值。 这种方法的问题在于:字符串键表示线程本地变量的共享全局名称空间。 为了使这种方法有效,客户端提供的字符串键必须是唯一的:如果两个客户端各自决定为它们的线程本地变量使用相同的名称,它们无意中就会共享一个变量,这通常会导致两个客户端都失败。 而且安全性很差。恶意客户端可以故意使用与另一个客户端相同的字符串密钥来非法访问另一个客户端的数据。

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

🌸 建议

  • 🌱 不要使用字符串连接操作符合并多个字符串,除非性能无关紧要。使用字符串串联运算符 +,重复串联 n 个字符串,需要 n 的平方级时间。

  • 🍀 使用 StringBuilder 代替 String

64. 通过接口引用对象

🌸 建议

  • 🍀 优先使用接口而不是类来引用对象。如果存在合适的接口类型,那么应该使用接口类型声明参数、返回值、变量和字段。

  • 🌱 使用接口作为类型的习惯,程序将更加灵活。

  • 🥀 以下情况使用 类 来引用对象:

    • 🌰 没有合适的接口存在
      • StringBigInteger
    • 🌰 框架的基本类型是类,不是接口
      • java.io
    • 🌰 实现接口的类提供了接口中不存在的额外方法
      • PriorityQueue 中实现了 Queue 不存在的 comparator

🌻 案例

  • 🌾 优先使用接口作为类型
1
2
3
4
5
🙆
Set<Son> sonSet = new LinkedHashSet<>();

🙅
LinkedHashSet<Son> sonSet = new LinkedHashSet<>();

65. 接口优先反射机制

🌸 建议

  • 🌱 核心反射机制 java.lang.reflect 提供对任意类的编程访问。给定一个 Class 对象,可以获得 ConstructorMethodFiled 实例。

  • 🌱 通过过调用 ConstructorMethodFiled 实例上的方法,可以构造底层的实例、调用底层类的方法、访问底层类中的字段。

    • 🌰 Method.invoke()
  • 🥀 反射允许一个类使用另一个类,即使在编译前者时后者并不存在。然而,这种能力是有代价的:

    • 🌰 失去了编译时类型检查的所有好处,包括异常检查。
    • 🌰 执行反射访问所需的代码既笨拙又冗长。写起来很乏味,读起来也很困难。
    • 🌰 性能降低。
  • 🍀 如果你对应用程序是否需要反射有任何疑问,那么它可能不需要。

  • 🌱 如果必须用到在编译时无法获取的类,在编译时存在一个适当的接口或超类来,可以用反射方式创建实例,并通过它们的接口或超类正常地访问它们。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    
      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>> cl = null) Class.forNmae(args[0]) // Unchecked cast
        } 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;  // 🙋‍♂️ use interface
        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);
      }
    
  • 🥀 上例中反射机制的缺点

    • 🌰 产生 6 个运行时异常
    • 🌰 根据类名生产实例需要 25 行冗长代码,而调用构造器只需要一行。
  • 🌱 反射的合法用途(很少)是管理类对运行时可能不存在的其他类、方法或字段的依赖关系。

  • 🌱 如果编写的程序必须在编译时处理未知的类,则应该尽可能只使用反射实例化对象,并使用在编译时已知的接口或超类访问对象。

66. 谨慎地使用本地方法

🌸 建议

  • 🌱 非常不建议使用本地方法。JVM 变得更快了,并且 Java 平台的成熟,它提供了对许多以前只能在宿主平台中上找到的特性。

例如,Java 9 中添加的流 API 提供了对 OS 流程的访问。

  • 🥀 使用本地方法有严重的缺点:
    • 🌰 本地语言不安全(Item-50),使用本地方法的应用程序不再能免受内存毁坏错误的影响。
    • 🌰 Java 更依赖于平台,因此使用本地方法的程序的可移植性较差。
    • 🌰 它们更难调试。
    • 🌰 如果不小心,本地方法可能会降低性能,因为垃圾收集器无法自动跟踪本地内存使用情况,而且进出本地代码会产生相关的成本。
    • 🌰 本地方法需要“粘合代码”,这很难阅读,而且编写起来很乏味。

67. 谨慎地进行优化

🌸 建议

  • 🍀 优化弊大于利,尤其是如果过早地进行优化。在此过程中,你可能会生成既不快速也不正确且无法轻松修复的软件。

  • 🍀 不要为了性能而牺牲合理的架构。努力编写好的程序,而不是快速的程序。

  • 🍀 好的程序体现了信息隐藏的原则:在可能的情况下,它们在单个组件中本地化设计决策,因此可以在不影响系统其余部分的情况下更改单个决策

  • 🍀 不优化不意味着在程序完成之前可以忽略性能问题。实现上的问题可以通过以后的优化来解决,但是对于架构缺陷,如果不重写系统,就不可能解决限制性能的问题,特别是在设计API、线路层协议和持久数据格式时。

  • 🍀 再多的底层优化也不能弥补算法选择的不足。

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

🌸 建议

  • 🌱 Java 平台有一组完善的命名约定,其中许多约定包含在《The Java Language Specification》

    • 🌰 typographical 字面约定
    • 🌰 grammatical 语法约定
  • 🌱 字面惯例示例:

    Identifier TypeExample
    Package or moduleorg.junit.jupiter.api, com.google.common.collect
    Class or InterfaceStream, FutureTask, LinkedHashMap, HttpClient
    Method or Fieldremove(), groupingBy(), getCrc()
    Constant FieldMIN_VALUE, NEGATIVE_INFINITY
    Local Variablei, denom, houseNum
    Type ParameterT, E, K, V, X, R, U, V, T1, T2
  • 🌱 语法命名约定比排版约定更灵活,也更有争议。