Java 编程问题:1~5

原文:Java Coding Problems

协议:CC BY-NC-SA 4.0

译者:飞龙

本文来自【ApacheCN Java 译文集】,采用译后编辑(MTPE)流程来尽可能提升效率。

一、字符串、数字和数学

本章包括 39 个涉及字符串、数字和数学运算的问题。我们将从研究字符串的一系列经典问题开始,例如计算重复项、反转字符串和删除空格。然后,我们将研究专门用于数字和数学运算的问题,例如两个大数求和和和运算溢出,比较两个无符号数,以及计算除法和模的下限。每个问题都要经过几个解决方案,包括 Java8 的函数风格。此外,我们将讨论与 JDK9、10、11 和 12 有关的问题。

在本章结束时,您将知道如何使用一系列技术,以便您可以操纵字符串并应用、调整它们以适应许多其他问题。你还将知道如何解决可能导致奇怪和不可预测的结果的数学角落的情况。

问题

使用以下问题来测试您的字符串操作和数学角大小写编程能力。我强烈建议您在使用解决方案和下载示例程序之前,先尝试一下每个问题:

  1. 重复字符计数:编写一个程序,对给定字符串中的重复字符进行计数。

  2. 寻找第一个非重复字符:编写一个程序,返回给定字符串中的第一个非重复字符。

  3. 反转字母和单词:编写一个反转每个单词字母的程序,以及一个反转每个单词字母和单词本身的程序。

  4. 检查字符串是否只包含数字:编写一个程序检查给定字符串是否只包含数字。

  5. 计数元音和辅音:编写一个程序,计算给定字符串中元音和辅音的数量。对于英语,有五个元音(a、e、i、o 和 u)。

  6. 计数某个字符的出现次数:编写一个程序,对给定字符串中某个字符的出现次数进行计数。

  7. String转换成intlongfloatdouble:编写一个程序,将给定的String对象(代表数字)转换成intlongfloatdouble

  8. 删除字符串中的空格:编写一个程序,删除给定字符串中的所有空格。

  9. 用一个分隔符连接多个字符串:编写一个程序,用给定的分隔符连接给定的字符串。

  10. 生成所有排列:编写一个程序,生成给定字符串的所有排列。

  11. 检查字符串是否为回文:编写一个程序,确定给定的字符串是否为回文。

  12. 删除重复字符:编写一个程序,从给定字符串中删除重复字符。

  13. 删除给定字符:编写一个从给定字符串中删除给定字符的程序。

  14. 查找出现次数最多的字符:编写一个程序,在给定的字符串中查找出现次数最多的字符。

  15. 按长度排序字符串数组:编写按给定字符串数组长度排序的程序。

  16. 检查字符串是否包含子字符串:编写程序检查给定字符串是否包含给定子字符串。

  17. 计算子串在字符串中出现的次数:编写一个程序,计算给定字符串在另一个给定字符串中出现的次数。

  18. 检查两个字符串是否是:编写一个检查两个字符串是否是异序词的程序。假设一个字符串的一个异序词是这个字符串的一个排列,忽略了大小写和空格。

  19. 声明多行字符串(文本块):编写声明多行字符串或文本块的程序。

  20. 连接同一字符串n:编写一个程序,将同一字符串连接给定次数。

  21. 删除前导和尾随空格:编写一个程序,删除给定字符串的前导和尾随空格。

  22. 查找最长公共前缀:编写一个程序,查找给定字符串的最长公共前缀。

  23. 应用缩进:编写几个代码片段,对给定的文本应用缩进。

  24. 转换字符串:写几段代码将一个字符串转换成另一个字符串。

  25. 计算两个数的最小值和最大值:编写一个程序,返回两个数的最小值和最大值。

  26. 两个大int/long数的求和运算溢出:编写一个程序,对两个大int/long数求和,运算溢出时抛出算术异常。

  27. 作为基数的字符串中的无符号数:编写一个程序,将给定字符串解析为给定基数中的无符号数(intlong)。

  28. 无符号数字的转换:编写一个程序,将给定的int数字无符号转换成long

  29. 比较两个无符号数字:编写一个程序,将给定的两个数字作为无符号数字进行比较。

  30. 无符号值的除法和模:编写一个程序,计算给定无符号值的除法和模。

  31. double/float是否是有限浮点值:编写一个程序来确定给定的double/float值是否是一个有限浮点值。

  32. 对两个布尔表达式应用逻辑 AND/OR/XOR:编写一个程序,对两个布尔表达式应用逻辑 AND/OR/XOR。

  33. BigInteger转换成原始类型:编写程序,从给定的BigInteger中提取原始类型值。

  34. long转换成int:编写一个将long转换成int的程序。

  35. 计算下限除法和模:编写程序,计算给定除法(x)和除法(y)的下限除法和下限模。

  36. 下一个浮点值:编写一个程序,返回给定的float/double值在正负无穷方向上相邻的下一个浮点值。

  37. 两个大int/long值相乘运算溢出:编写一个程序,将两个大int/long值相乘,运算溢出时抛出算术异常。

  38. 融合乘加FMA):编写一个程序,取三个float/double值(abc),高效计算ab+c*。

  39. 紧凑数字格式化:编写一个程序,将数字 1000000 格式化为 1M(美国地区)和 1ML(意大利地区)。另外,将一个字符串中的 1M 和 1MLN 解析为一个数字。

解决方案

以下各节介绍上述问题的解决方案。记住,通常没有一个正确的方法来解决一个特定的问题。另外,请记住,这里显示的解释只包括解决问题所需的最有趣和最重要的细节。您可以从这个页面下载示例解决方案以查看更多详细信息并尝试程序。

1 重复字符计数

计算字符串中的字符(包括特殊字符,如#、$和%)的解决方案意味着取每个字符并将它们与其他字符进行比较。在比较过程中,计数状态是通过一个数字计数器来保持的,每次找到当前字符时,该计数器都会增加一个。

这个问题有两种解决办法。

第一种解决方案迭代字符串,并使用Map将字符存储为键,将出现的次数存储为值。如果当前字符从未添加到Map,则将其添加为(character, 1)。如果当前字符存在于Map中,则只需将其出现次数增加 1,例如(character, occurrences+1)。如下代码所示:

public Map<Character, Integer> countDuplicateCharacters(String str) {

  Map<Character, Integer> result = new HashMap<>();

  // or use for(char ch: str.toCharArray()) { ... }
  for (int i = 0; i<str.length(); i++) {
    char ch = str.charAt(i); 

    result.compute(ch, (k, v) -> (v == null) ? 1 : ++v);
  }

  return result;
}

另一个解决方案依赖于 Java8 的流特性。这个解决方案有三个步骤。前两步是将给定的字符串转换成Stream<Character>,最后一步是对字符进行分组和计数。步骤如下:

  1. 对原始字符串调用String.chars()方法。这将返回IntStream。这个IntStream包含给定字符串中字符的整数表示。
  2. 通过mapToObj()方法将IntStream转换成字符流(将整数表示转换成人性化的字符形式)。
  3. 最后,将字符分组(Collectors.groupingBy())并计数(Collectors.counting())。

以下代码片段将这三个步骤粘在一个方法中:

public Map<Character, Long> countDuplicateCharacters(String str) {

  Map<Character, Long> result = str.chars()
    .mapToObj(c -> (char) c)
    .collect(Collectors.groupingBy(c -> c, Collectors.counting()));

  return result;
}

Unicode 字符呢?

我们非常熟悉 ASCII 字符。我们有 0-31 之间的不可打印控制码,32-127 之间的可打印字符,128-255 之间的扩展 ASCII 码。但是 Unicode 字符呢?对于每个需要操作 Unicode 字符的问题,请考虑本节

因此,简而言之,早期的 Unicode 版本包含值小于 65535(0xFFFF)的字符。Java 使用 16 位char数据类型表示这些字符。只要i不超过 65535,调用charAt(i)就可以正常工作。但随着时间的推移,Unicode 添加了更多字符,最大值达到了 1114111(0x10FFFF)。这些字符不适合 16 位,因此 UTF-32 编码方案考虑 32 位值(称为码位)。

不幸的是,Java 不支持 UTF-32!尽管如此,Unicode 还是提出了一个解决方案,仍然使用 16 位来表示这些字符。此解决方案意味着:

  • 16 位高位代理:1024 个值(U+D800 到 U+DBFF)
  • 16 位低位代理:1024 个值(U+DC00 到 U+DFFF)

现在,一个高代理后跟一个低代理定义了所谓的代理对。代理项对用于表示 65536(0x10000)和 1114111(0x10FFFF)之间的值。因此,某些字符(称为 Unicode 补充字符)被表示为 Unicode 代理项对(一个字符(符号)适合于一对字符的空间),这些代理项对被合并到一个代码点中。Java 利用了这种表示,并通过一组方法来公开它,如codePointAt()codePoints()codePointCount(),和offsetByCodePoints()(请查看 Java 文档了解详细信息)。调用codePointAt()而不是charAt()codePoints()而不是chars()等等,可以帮助我们编写覆盖 ASCII 和 Unicode 字符的解决方案。

例如,众所周知的双心符号是 Unicode 代理项对,可以表示为包含两个值的char[]\uD83D\uDC95。此符号的码位为128149。要从此代码点获取一个String对象,请调用String str = String.valueOf(Character.toChars(128149))str中的码点计数可以通过调用str.codePointCount(0, str.length())来完成,即使str长度为 2,也返回 1;调用str.codePointAt(0)返回128149,调用str.codePointAt(1)返回56469。调用Character.toChars(128149)返回 2,因为需要两个字符来表示此代码点是 Unicode 代理项对。对于 ASCII 和 Unicode 16 位字符,它将返回 1。

因此,如果我们尝试重写第一个解决方案(迭代字符串并使用Map将字符存储为键,将出现次数存储为值)以支持 ASCII 和 Unicode(包括代理项对),我们将获得以下代码:

public static Map<String, Integer> 
    countDuplicateCharacters(String str) {

  Map<String, Integer> result = new HashMap<>();

  for (int i = 0; i < str.length(); i++) {
 int cp = str.codePointAt(i);
 String ch = String.valueOf(Character.toChars(cp));
 if(Character.charCount(cp) == 2) { // 2 means a surrogate pair
 i++;
 }

    result.compute(ch, (k, v) -> (v == null) ? 1 : ++v);
  }

  return result;
}

突出显示的代码也可以编写如下:

String ch = String.valueOf(Character.toChars(str.codePointAt(i)));
if (i < str.length() - 1 && str.codePointCount(i, i + 2) == 1) {
  i++;
}

最后,尝试重写 Java8 函数式解决方案以覆盖 Unicode 代理项对,可以执行以下操作:

public static Map<String, Long> countDuplicateCharacters(String str) { 

  Map<String, Long> result = str.codePoints()
    .mapToObj(c -> String.valueOf(Character.toChars(c)))
    .collect(Collectors.groupingBy(c -> c, Collectors.counting()));

  return result;
}

对于第三方库支持,请考虑 Guava:Multiset<String>

下面的一些问题将提供包括 ASCII、16 位 Unicode 和 Unicode 代理项对的解决方案。它们是任意选择的,因此,通过依赖这些解决方案,您可以轻松地为没有提供此类解决方案的问题编写解决方案。

2 找到第一个非重复字符

找到字符串中第一个不重复的字符有不同的解决方案。主要地,这个问题可以通过字符串的一次遍历或更完整/部分的遍历来解决。

在单遍历方法中,我们填充一个数组,该数组用于存储字符串中恰好出现一次的所有字符的索引。使用此数组,只需返回包含非重复字符的最小索引:

private static final int EXTENDED_ASCII_CODES = 256;
...
public char firstNonRepeatedCharacter(String str) {

  int[] flags = new int[EXTENDED_ASCII_CODES];

  for (int i = 0; i < flags.length; i++) {
    flags[i] = -1;
  }

  for (int i = 0; i < str.length(); i++) {
    char ch = str.charAt(i);
    if (flags[ch] == -1) {
      flags[ch] = i;
    } else {
      flags[ch] = -2;
    }
  }

  int position = Integer.MAX_VALUE;

  for (int i = 0; i < EXTENDED_ASCII_CODES; i++) {
    if (flags[i] >= 0) {
      position = Math.min(position, flags[i]);
    }
  }

  return position == Integer.MAX_VALUE ?
    Character.MIN_VALUE : str.charAt(position);
}

此解决方案假定字符串中的每个字符都是扩展 ASCII 表(256 个代码)的一部分。如果代码大于 256,则需要相应地增加数组的大小。只要数组大小不超过char类型的最大值,即Character.MAX_VALUE,即 65535,该解决方案就可以工作。另一方面,Character.MAX_CODE_POINT返回 Unicode 码位的最大值 1114111。为了覆盖这个范围,我们需要另一个基于codePointAt()codePoints()的实现

由于采用了单次遍历的方法,所以速度非常快。另一种解决方案是循环每个字符的字符串并计算出现的次数。每出现一次(重复)就会打断循环,跳到下一个字符,并重复算法。如果到达字符串的结尾,则返回当前字符作为第一个不可重复的字符。本书附带的代码中提供了此解决方案。

这里介绍的另一个解决方案依赖于LinkedHashMap。这个 Java 映射是一个插入顺序映射(它保持了键插入到映射中的顺序),对于这个解决方案非常方便。LinkedHashMap以字符作为键,以出现次数作为值填充。一旦LinkedHashMap完成,它将返回值等于 1 的第一个键。由于插入顺序功能,这是字符串中第一个不可重复的字符:

public char firstNonRepeatedCharacter(String str) {

  Map<Character, Integer> chars = new LinkedHashMap<>();

  // or use for(char ch: str.toCharArray()) { ... }
  for (int i = 0; i < str.length(); i++) {
    char ch = str.charAt(i);

    chars.compute(ch, (k, v) -> (v == null) ? 1 : ++v);
  }

  for (Map.Entry<Character, Integer> entry: chars.entrySet()) {
    if (entry.getValue() == 1) {
      return entry.getKey();
    }
  }

  return Character.MIN_VALUE;
}

在本书附带的代码中,前面提到的解决方案是用 Java8 函数式编写的。此外,支持 ASCII、16 位 Unicode 和 Unicode 代理项对的函数式解决方案如下:

public static String firstNonRepeatedCharacter(String str) {

  Map<Integer, Long> chs = str.codePoints()
    .mapToObj(cp -> cp)
    .collect(Collectors.groupingBy(Function.identity(),
      LinkedHashMap::new, Collectors.counting()));

  int cp = chs.entrySet().stream()
   .filter(e -> e.getValue() == 1L)
   .findFirst()
   .map(Map.Entry::getKey)
   .orElse(Integer.valueOf(Character.MIN_VALUE));

  return String.valueOf(Character.toChars(cp));
}

要更详细地理解这段代码,请考虑“Unicode 字符是什么?计数重复字符”部分的小节

3 反转字母和单词

首先,让我们只反转每个单词的字母。这个问题的解决方案可以利用StringBuilder类。第一步包括使用空格作为分隔符(Spring.split(" "))将字符串拆分为单词数组。此外,我们使用相应的 ASCII 码反转每个单词,并将结果附加到StringBuilder。首先,我们将给定的字符串按空格拆分。然后,我们循环得到的单词数组,并通过charAt()按相反的顺序获取每个字符来反转每个单词:

private static final String WHITESPACE = " ";
...
public String reverseWords(String str) {

 String[] words = str.split(WHITESPACE);
 StringBuilder reversedString = new StringBuilder();

 for (String word: words) {
   StringBuilder reverseWord = new StringBuilder();

   for (int i = word.length() - 1; i >= 0; i--) {
     reverseWord.append(word.charAt(i));
   }

   reversedString.append(reverseWord).append(WHITESPACE);
 }

 return reversedString.toString();
}

在 Java8 函数样式中获得相同的结果可以如下所示:

private static final Pattern PATTERN = Pattern.compile(" +");
...
public static String reverseWords(String str) {

  return PATTERN.splitAsStream(str)
    .map(w -> new StringBuilder(w).reverse())
    .collect(Collectors.joining(" "));
}

请注意,前面两个方法返回一个字符串,其中包含每个单词的字母,但单词本身的初始顺序相同。现在,让我们考虑另一种方法,它反转每个单词的字母和单词本身。由于内置的StringBuilder.reverse()方法,这非常容易实现:

public String reverse(String str) {

  return new StringBuilder(str).reverse().toString();
}

对于第三方库支持,请考虑 ApacheCommonsLang,StringUtils.reverse()

4 检查字符串是否只包含数字

这个问题的解决依赖于Character.isDigit()String.matches()方法

依赖于Character.isDigit()的解决方案是非常简单和快速地循环字符串,如果此方法返回false,则中断循环:

public static boolean containsOnlyDigits(String str) {

  for (int i = 0; i < str.length(); i++) {
    if (!Character.isDigit(str.charAt(i))) {
      return false;
    }
  }

  return true;
}

在 Java8 函数式中,前面的代码可以使用anyMatch()重写:

public static boolean containsOnlyDigits(String str) {

  return !str.chars()
    .anyMatch(n -> !Character.isDigit(n));
}

另一种解决方案依赖于String.matches()。此方法返回一个boolean值,指示此字符串是否与给定的正则表达式匹配:

public static boolean containsOnlyDigits(String str) {

  return str.matches("[0-9]+");
}

请注意,Java8 函数式和基于正则表达式的解决方案通常比较慢,因此如果速度是一个要求,那么最好使用第一个使用Character.isDigit()的解决方案。

避免通过parseInt()parseLong()解决此问题。首先,捕捉NumberFormatException并在catch块中进行业务逻辑决策是不好的做法。其次,这些方法验证字符串是否为有效数字,而不是仅包含数字(例如,-4 有效)。
对于第三方库支持,请考虑 ApacheCommonsLang,StringUtils.isNumeric()

5 元音和辅音计数

以下代码适用于英语,但取决于您所涵盖的语言种类,元音和辅音的数量可能会有所不同,因此应相应调整代码。

此问题的第一个解决方案需要遍历字符串并执行以下操作:

  1. 我们需要检查当前字符是否是元音(这很方便,因为英语中只有五个纯元音;其他语言有更多元音,但数量仍然很小)。
  2. 如果当前字符不是元音,则检查它是否位于'a''z'之间(这意味着当前字符是辅音)。

注意,最初,给定的String对象被转换为小写。这有助于避免与大写字符进行比较。例如,仅对'a'进行比较,而不是对'A''a'进行比较。

此解决方案的代码如下:

private static final Set<Character> allVowels
            = new HashSet(Arrays.asList('a', 'e', 'i', 'o', 'u'));

public static Pair<Integer, Integer> 
    countVowelsAndConsonants(String str) {

  str = str.toLowerCase();
  int vowels = 0;
  int consonants = 0;

  for (int i = 0; i < str.length(); i++) {
    char ch = str.charAt(i);
    if (allVowels.contains(ch)) {
      vowels++;
    } else if ((ch >= 'a' && ch <= 'z')) {
      consonants++;
    }
  }

  return Pair.of(vowels, consonants);
}

在 Java8 函数式中,可以使用chars()filter()重写此代码:

private static final Set<Character> allVowels
            = new HashSet(Arrays.asList('a', 'e', 'i', 'o', 'u'));

public static Pair<Long, Long> countVowelsAndConsonants(String str) {

  str = str.toLowerCase();

  long vowels = str.chars()
    .filter(c -> allVowels.contains((char) c))
    .count();

  long consonants = str.chars()
    .filter(c -> !allVowels.contains((char) c))
    .filter(ch -> (ch >= 'a' && ch<= 'z'))
    .count();

  return Pair.of(vowels, consonants);
}

相应地过滤给定的字符串,count()终端操作返回结果。依赖partitioningBy()将减少代码,如下所示:

Map<Boolean, Long> result = str.chars()
  .mapToObj(c -> (char) c)
  .filter(ch -> (ch >= 'a' && ch <= 'z'))
  .collect(partitioningBy(c -> allVowels.contains(c), counting()));

return Pair.of(result.get(true), result.get(false));

完成!现在,让我们看看如何计算字符串中某个字符的出现次数。

6 计算某个字符的出现次数

此问题的简单解决方案包括以下两个步骤:

  1. 将给定字符串中出现的每个字符替换为""(基本上,这类似于删除给定字符串中出现的所有字符)。
  2. 从初始字符串的长度中减去在第一步中获得的字符串的长度。

此方法的代码如下:

public static int countOccurrencesOfACertainCharacter(
    String str, char ch) {

  return str.length() - str.replace(String.valueOf(ch), "").length();
}

以下解决方案还包括 Unicode 代理项对:

public static int countOccurrencesOfACertainCharacter(
    String str, String ch) { 

  if (ch.codePointCount(0, ch.length()) > 1) {
    // there is more than 1 Unicode character in the given String
    return -1; 
  }

  int result = str.length() - str.replace(ch, "").length();

  // if ch.length() return 2 then this is a Unicode surrogate pair
  return ch.length() == 2 ? result / 2 : result;
}

另一个易于实现且快速的解决方案包括循环字符串(一次遍历)并将每个字符与给定的字符进行比较。每一场比赛增加一个计数器:

public static int countOccurrencesOfACertainCharacter(
    String str, char ch) {

  int count = 0;

  for (int i = 0; i < str.length(); i++) {
    if (str.charAt(i) == ch) {
      count++;
    }
  }

  return count;
}

涵盖 Unicode 代理项对的解决方案包含在本书附带的代码中。在 Java8 函数风格中,一种解决方案是使用filter()reduce()。例如,使用filter()将产生以下代码:

public static long countOccurrencesOfACertainCharacter(
    String str, char ch) {

  return str.chars()
    .filter(c -> c == ch)
    .count();
}

涵盖 Unicode 代理项对的解决方案包含在本书附带的代码中。

对于第三方库支持,请考虑 Apache Commons Lang、StringUtils.countMatches()、Spring Framework、StringUtils.countOccurrencesOf()和 Guava、CharMatcher.is().countIn()

7 将字符串转换为intlongfloatdouble

让我们考虑以下字符串(也可以使用负数):

private static final String TO_INT = "453"; 
private static final String TO_LONG = "45234223233"; 
private static final String TO_FLOAT = "45.823F";
private static final String TO_DOUBLE = "13.83423D";

String转换为intlongfloatdouble的合适解决方案包括使用IntegerLongFloatDouble类的以下 Java 方法-parseInt()parseLong()parseFloat()parseDouble()

int toInt = Integer.parseInt(TO_INT);
long toLong = Long.parseLong(TO_LONG);
float toFloat = Float.parseFloat(TO_FLOAT);
double toDouble = Double.parseDouble(TO_DOUBLE);

String转换成IntegerLongFloatDouble对象,可以通过以下 Java 方法来完成:Integer.valueOf()Long.valueOf()Float.valueOf()Double.valueOf()

Integer toInt = Integer.valueOf(TO_INT);
Long toLong = Long.valueOf(TO_LONG);
Float toFloat = Float.valueOf(TO_FLOAT);
Double toDouble = Double.valueOf(TO_DOUBLE);

String无法成功转换时,Java 抛出NumberFormatException异常。以下代码不言自明:

private static final String WRONG_NUMBER = "452w";

try {
  Integer toIntWrong1 = Integer.valueOf(WRONG_NUMBER);
} catch (NumberFormatException e) {
  System.err.println(e);
  // handle exception
}

try {
  int toIntWrong2 = Integer.parseInt(WRONG_NUMBER);
} catch (NumberFormatException e) {
  System.err.println(e);
  // handle exception
}

对于第三方库支持,请考虑 ApacheCommons BeanUtils:IntegerConverterLongConverterFloatConverterDoubleConverter

8 从字符串中删除空格

这个问题的解决方案是使用带有正则表达式的String.replaceAll()方法。主要是\s删除所有的空白,包括不可见的空白,如\t\n\r

public static String removeWhitespaces(String str) {
  return str.replaceAll("\\s", "");
}

从 JDK11 开始,String.isBlank()检查字符串是空的还是只包含空格代码点。对于第三方库支持,请考虑 Apache Commons Lang,StringUtils.deleteWhitespace()和 Spring 框架,StringUtils.trimAllWhitespace()

9 用分隔符连接多个字符串

有几种解决方案很适合解决这个问题。在 Java8 之前,一种方便的方法依赖于StringBuilder,如下所示:

public static String joinByDelimiter(char delimiter, String...args) {

  StringBuilder result = new StringBuilder();

  int i = 0;
  for (i = 0; i < args.length - 1; i++) {
    result.append(args[i]).append(delimiter);
  }
  result.append(args[i]);

  return result.toString();
}

从 Java8 开始,这个问题至少还有三种解决方案。其中一个解决方案依赖于StringJoiner工具类。此类可用于构造由分隔符(例如逗号)分隔的字符序列。

它还支持可选的前缀和后缀(此处忽略):

public static String joinByDelimiter(char delimiter, String...args) {
  StringJoiner joiner = new StringJoiner(String.valueOf(delimiter));

  for (String arg: args) {
    joiner.add(arg);
  }

  return joiner.toString();
}

另一种解决方案依赖于String.join()方法。此方法是在 Java8 中引入的,有两种风格:

String join​(CharSequence delimiter, CharSequence... elems)
String join​(CharSequence delimiter,
  Iterable<? extends CharSequence> elems)

连接由空格分隔的多个字符串的示例如下:

String result = String.join(" ", "how", "are", "you"); // how are you

更进一步说,Java8 流和Collectors.joining()也很有用:

public static String joinByDelimiter(char delimiter, String...args) {
  return Arrays.stream(args, 0, args.length)
    .collect(Collectors.joining(String.valueOf(delimiter)));
}

注意通过+=运算符以及concat()String.format()方法连接字符串。这些字符串可以用于连接多个字符串,但它们容易导致性能下降。例如,下面的代码依赖于+=并且比依赖于StringBuilder慢得多:

String str = "";``for(int i = 0; i < 1_000_000; i++) {``  str += "x";

+=被附加到一个字符串并重建一个新的字符串,这需要时间。

对于第三方库支持,请考虑 Apache Commons Lang,StringUtils.join()和 Guava,Joiner

10 生成所有置换

涉及置换的问题通常也涉及递归性。基本上,递归性被定义为一个过程,其中给定了一些初始状态,并且根据前一个状态定义了每个连续状态

在我们的例子中,状态可以通过给定字符串的字母来具体化。初始状态包含初始字符串,每个连续状态可通过以下公式计算字符串的每个字母将成为字符串的第一个字母(交换位置),然后使用递归调用排列所有剩余字母。虽然存在非递归或其他递归解决方案,但这是该问题的经典解决方案。

将这个解决方案表示为字符串ABC,可以这样做(注意排列是如何完成的):

对该算法进行编码将产生如下结果:

public static void permuteAndPrint(String str) {

  permuteAndPrint("", str);
}

private static void permuteAndPrint(String prefix, String str) {

  int n = str.length();

  if (n == 0) {
    System.out.print(prefix + " ");
  } else {
    for (int i = 0; i < n; i++) {
      permuteAndPrint(prefix + str.charAt(i),
        str.substring(i + 1, n) + str.substring(0, i));
    }
  }
}

最初,前缀应该是一个空字符串""。在每次迭代中,前缀将连接(固定)字符串中的下一个字母。剩下的字母将再次通过该方法传递。

假设这个方法存在于一个名为Strings的实用类中。你可以这样称呼它:

Strings.permuteAndStore("ABC");

这将产生以下输出:

ABC ACB BCA BAC CAB CBA

注意,这个解决方案在屏幕上打印结果。存储结果意味着将Set添加到实现中。最好使用Set,因为它消除了重复:

public static Set<String> permuteAndStore(String str) {

  return permuteAndStore("", str);
}

private static Set<String> 
    permuteAndStore(String prefix, String str) {

  Set<String> permutations = new HashSet<>();
  int n = str.length();

  if (n == 0) {
    permutations.add(prefix);
  } else {
    for (int i = 0; i < n; i++) {
      permutations.addAll(permuteAndStore(prefix + str.charAt(i),
        str.substring(i + 1, n) + str.substring(0, i)));
    }
  }

  return permutations;
}

例如,如果传递的字符串是TEST,那么Set将导致以下输出(这些都是唯一的排列):

ETST SETT TEST TTSE STTE STET TETS TSTE TSET TTES ESTT ETTS

使用List代替Set将产生以下输出(注意重复项):

TEST TETS TSTE TSET TTES TTSE ESTT ESTT ETTS ETST ETST ETTS STTE STET STET STTE SETT SETT TTES TTSE TEST TETS TSTE TSET

有 24 个排列。通过计算n阶乘(n!)。对于n = 4(字符串长度),4! = 1 x 2 x 3 x 4 = 24。当以递归方式表示时,这是n! = n x (n-1)!

自从n!以极快的速度生成大量数据(例如,10! = 3628800),建议避免存储结果。对于 10 个字符的字符串(如直升机),有 3628800 个排列!

尝试用 Java8 函数式实现此解决方案将导致如下结果:

private static void permuteAndPrintStream(String prefix, String str) {

  int n = str.length();

  if (n == 0) {
    System.out.print(prefix + " ");
  } else {
    IntStream.range(0, n)
      .parallel()
      .forEach(i -> permuteAndPrintStream(prefix + str.charAt(i),
        str.substring(i + 1, n) + str.substring(0, i)));
  }
}

作为奖励,本书附带的代码中提供了返回Stream<String>的解决方案。

11 检查字符串是否为回文

作为一个快速的提醒,回文(无论是字符串还是数字)在反转时看起来是不变的。这意味着可以从两个方向处理(读取)回文,并且将获得相同的结果(例如,单词madam是回文,而单词madam不是)。

一个易于实现的解决方案是用中间相遇的方法比较给定字符串的字母。基本上,此解决方案将第一个字符与最后一个字符进行比较,第二个字符与最后一个字符逐个进行比较,依此类推,直到到达字符串的中间。实现依赖于while语句:

public static boolean isPalindrome(String str) {

  int left = 0;
  int right = str.length() - 1;

  while (right > left) {
    if (str.charAt(left) != str.charAt(right)) {
      return false;
    }

    left++;
    right--;
  }
  return true;
}

以更简洁的方法重写上述解决方案将包括依赖于for语句而不是while语句,如下所示:

public static boolean isPalindrome(String str) {

  int n = str.length();

  for (int i = 0; i < n / 2; i++) {
    if (str.charAt(i) != str.charAt(n - i - 1)) {
      return false;
    }
  }
  return true;
}

但是这个解决方案可以简化为一行代码吗?答案是肯定的。

JavaAPI 提供了StringBuilder类,该类使用reverse()方法。顾名思义,reverse()方法返回相反的给定字符串。对于回文,给定的字符串应等于它的反向版本:

public static boolean isPalindrome(String str) {

  return str.equals(new StringBuilder(str).reverse().toString());
}

在 Java8 函数风格中,也有一行代码用于此。只需定义从 0 到给定字符串一半的IntStream,并使用noneMatch()短路终端操作和谓词,按照中间相遇的方法比较字母:

public static boolean isPalindrome(String str) {

  return IntStream.range(0, str.length() / 2)
    .noneMatch(p -> str.charAt(p) != 
      str.charAt(str.length() - p - 1));
}

现在,让我们讨论从给定字符串中删除重复字符。

12 删除重复字符

让我们从依赖于StringBuilder的这个问题的解决方案开始。解决方案主要应该循环给定字符串的字符,并构造一个包含唯一字符的新字符串(不可能简单地从给定字符串中删除字符,因为在 Java 中,字符串是不可变的)。

StringBuilder类公开了一个名为indexOf()的方法,该方法返回指定子字符串(在本例中是指定字符)第一次出现的给定字符串中的索引。因此,这个问题的一个潜在解决方案是,每次应用于当前字符的indexOf()方法返回 -1(这个负数意味着StringBuilder不包含当前字符)时,循环给定字符串的字符并将它们逐个添加到StringBuilder

public static String removeDuplicates(String str) {

  char[] chArray = str.toCharArray(); // or, use charAt(i)
  StringBuilder sb = new StringBuilder();

  for (char ch : chArray) {
    if (sb.indexOf(String.valueOf(ch)) == -1) {
      sb.append(ch);
    }
  }
  return sb.toString();
}

下一个解决方案依赖于HashSetStringBuilder之间的协作。主要是,HashSet确保消除重复,而StringBuilder存储结果字符串。如果HashSet.add()返回true,那么我们也在StringBuilder中添加字符:

public static String removeDuplicates(String str) {

  char[] chArray = str.toCharArray();
  StringBuilder sb = new StringBuilder();
  Set<Character> chHashSet = new HashSet<>();

  for (char c: chArray) {
    if (chHashSet.add(c)) {
      sb.append(c);
    }
  }
  return sb.toString();
}

到目前为止,我们提供的解决方案使用toCharArray()方法将给定字符串转换为char[]。或者,两种解决方案也可以使用str.charAt(position)

第三种解决方案依赖于 Java8 函数式风格:

public static String removeDuplicates(String str) {

  return Arrays.asList(str.split("")).stream()
    .distinct()
    .collect(Collectors.joining());
}

首先,解决方案将给定的字符串转换为Stream<String>,其中每个条目实际上是一个字符。此外,该解决方案应用了有状态的中间操作distinct()。此操作将从流中消除重复项,因此它返回一个没有重复项的流。最后,该解决方案调用了collect()终端操作并依赖于Collectors.joining(),它只是将字符按照相遇顺序连接成一个字符串。

13 删除给定字符

依赖于 JDK 支持的解决方案可以利用String.replaceAll()方法。此方法将给定字符串中与给定正则表达式(在本例中,正则表达式是字符本身)匹配的每个子字符串(在本例中,每个字符)替换为给定的替换(在本例中,替换为空字符串,""):

public static String removeCharacter(String str, char ch) {

  return str.replaceAll(Pattern.quote(String.valueOf(ch)), "");
}

注意,正则表达式被包装在Pattern.quote()方法中。这是转义特殊字符所必需的,例如“<,(,[,{,\,^,-,=,$,!”!, |, ], }, ), ?、*、+、、和>。该方法主要返回指定字符串的文本模式字符串。

现在,让我们来看一个避免正则表达式的解决方案。这一次,解决方案依赖于StringBuilder。基本上,解决方案循环给定字符串的字符,并将每个字符与要删除的字符进行比较。每次当前字符与要删除的字符不同时,都会在StringBuilder中追加当前字符:

public static String removeCharacter(String str, char ch) {

  StringBuilder sb = new StringBuilder();
  char[] chArray = str.toCharArray();

  for (char c : chArray) {
    if (c != ch) {
      sb.append(c);
    }
  }

  return sb.toString();
}

最后,让我们关注 Java8 函数式方法。这是一个四步方法:

  1. 通过String.chars()方法将字符串转换成IntStream
  2. 过滤IntStream消除重复
  3. 将结果IntStream映射到Stream<String>
  4. 将此流中的字符串连接起来,并将它们收集为单个字符串

此解决方案的代码可以编写如下:

public static String removeCharacter(String str, char ch) {

  return str.chars()
    .filter(c -> c != ch)
    .mapToObj(c -> String.valueOf((char) c))
    .collect(Collectors.joining());
}

或者,如果我们想要移除 Unicode 代理项对,那么我们可以依赖于codePointAt()codePoints(),如下面的实现所示:

public static String removeCharacter(String str, String ch) {

   int codePoint = ch.codePointAt(0);

   return str.codePoints()
     .filter(c -> c != codePoint)
     .mapToObj(c -> String.valueOf(Character.toChars(c)))
     .collect(Collectors.joining());
 }

对于第三方库支持,请考虑 ApacheCommonsLang,StringUtils.remove()

现在,让我们来谈谈如何找到最形象的字符。

14 寻找最形象的字符

一个非常简单的解决方案依赖于HashMap。此解决方案包括三个步骤:

  1. 首先,循环给定字符串的字符,并将键值对放入HashMap,其中键是当前字符,值是当前出现的次数
  2. 其次,计算HashMap中表示最大出现次数的最大值(例如,使用Collections.max()
  3. 最后,通过循环HashMap条目集,得到出现次数最多的字符

工具方法返回Pair<Character, Integer>,其中包含出现次数最多的字符和出现次数(注意,忽略了空格)。如果你不喜欢这个额外的类,也就是说,Pair,那就依赖Map.Entry<K, V>

public static Pair<Character, Integer> maxOccurenceCharacter(
  String str) {

  Map<Character, Integer> counter = new HashMap<>();
  char[] chStr = str.toCharArray();

  for (int i = 0; i < chStr.length; i++) {
    char currentCh = chStr[i];
    if (!Character.isWhitespace(currentCh)) { // ignore spaces
      Integer noCh = counter.get(currentCh);
      if (noCh == null) {
        counter.put(currentCh, 1);
      } else {
        counter.put(currentCh, ++noCh);
      }
    }
  }

  int maxOccurrences = Collections.max(counter.values());
  char maxCharacter = Character.MIN_VALUE;

  for (Entry<Character, Integer> entry: counter.entrySet()) {
    if (entry.getValue() == maxOccurrences) {
      maxCharacter = entry.getKey();
    }
  }

  return Pair.of(maxCharacter, maxOccurrences);
}

如果使用HashMap看起来很麻烦,那么另一个解决方案(稍微快一点)就是依赖 ASCII 代码。此解决方案从 256 个索引的空数组开始(256 是扩展 ASCII 表代码的最大数目;更多信息可在“查找第一个非重复字符”部分中找到)。此外,此解决方案循环给定字符串的字符,并通过增加此数组中的相应索引来跟踪每个字符的出现次数:

private static final int EXTENDED_ASCII_CODES = 256;
...
public static Pair<Character, Integer> maxOccurenceCharacter(
  String str) {

  int maxOccurrences = -1;
  char maxCharacter = Character.MIN_VALUE;
  char[] chStr = str.toCharArray();
  int[] asciiCodes = new int[EXTENDED_ASCII_CODES];

  for (int i = 0; i < chStr.length; i++) {
    char currentCh = chStr[i];
    if (!Character.isWhitespace(currentCh)) { // ignoring space
      int code = (int) currentCh;
      asciiCodes[code]++;
      if (asciiCodes[code] > maxOccurrences) {
        maxOccurrences = asciiCodes[code];
        maxCharacter = currentCh;
      }
    }
  }

  return Pair.of(maxCharacter, maxOccurrences);
}

我们将在这里讨论的最后一个解决方案依赖于 Java8 函数式风格:

public static Pair<Character, Long> 
    maxOccurenceCharacter(String str) {

  return str.chars()
    .filter(c -> Character.isWhitespace(c) == false) // ignoring space
    .mapToObj(c -> (char) c)
    .collect(groupingBy(c -> c, counting()))
    .entrySet()
    .stream()
    .max(comparingByValue())
    .map(p -> Pair.of(p.getKey(), p.getValue()))
    .orElse(Pair.of(Character.MIN_VALUE, -1L));
}

首先,这个解决方案收集不同的字符作为Map中的键,以及它们的出现次数作为值。此外,它使用 Java8Map.Entry.comparingByValue()max()终端操作来确定映射中具有最高值(最高出现次数)的条目。因为max()是终端操作,所以解决方案可以返回Optional<Entry<Character, Long>>,但是这个解决方案增加了一个额外的步骤,并将这个条目映射到Pair<Character, Long>

15 按长度对字符串数组排序

排序时首先想到的是比较器的使用。

在这种情况下,解决方案应该比较字符串的长度,因此通过调用给定数组中每个字符串的String.length()来返回整数。因此,如果整数被排序(升序或降序),那么字符串将被排序。

JavaArrays类已经提供了一个sort()方法,该方法使用数组进行排序和一个比较器。在这种情况下,Comparator<String>应该完成这项工作。

在 Java7 之前,实现比较器的代码依赖于compareTo()方法。这种方法的常见用法是计算x1 - x2类型的差值,但这种计算可能导致溢出。这使得compareTo()相当乏味。从 Java7 开始,Integer.compare()就是一条路(没有溢出风险)。

下面是一个依赖于Arrays.sort()方法对给定数组排序的方法:

public static void sortArrayByLength(String[] strs, Sort direction) {
  if (direction.equals(Sort.ASC)) {
    Arrays.sort(strs, (String s1, String s2) 
      -> Integer.compare(s1.length(), s2.length()));
  } else {
    Arrays.sort(strs, (String s1, String s2) 
      -> (-1) * Integer.compare(s1.length(), s2.length()));
  }
}

原始数字类型的每个包装器都有一个compare()方法。

从 Java8 开始,Comparator接口被大量有用的方法丰富了。其中一个方法是comparingInt(),它接受一个函数,该函数从泛型类型中提取int排序键,并返回一个Comparator<T>值,将其与该排序键进行比较。另一个有用的方法是reversed(),它反转当前的Comparator值。

基于这两种方法,我们可以授权Arrays.sort()如下:

public static void sortArrayByLength(String[] strs, Sort direction) {
  if (direction.equals(Sort.ASC)) {
    Arrays.sort(strs, Comparator.comparingInt(String::length));
  } else {
    Arrays.sort(strs, 
      Comparator.comparingInt(String::length).reversed());
  }
}

比较器可以用thenComparing()方法链接。

我们在这里给出的解决方案返回void,这意味着它们对给定的数组进行排序。为了返回一个新的排序数组而不改变给定的数组,我们可以使用 Java8 函数样式,如下代码片段所示:

public static String[] sortArrayByLength(String[] strs, 
    Sort direction) {

  if (direction.equals(Sort.ASC)) {
    return Arrays.stream(strs)
      .sorted(Comparator.comparingInt(String::length))
      .toArray(String[]::new);
  } else {
    return Arrays.stream(strs)
      .sorted(Comparator.comparingInt(String::length).reversed())
      .toArray(String[]::new);
  }
}

因此,代码从给定的数组中创建一个流,通过有状态的中间操作对其进行排序,并将结果收集到另一个数组中。

16 检查字符串是否包含子字符串

一个非常简单的一行代码解决方案依赖于String.contains()方法。

此方法返回一个boolean值,指示字符串中是否存在给定的子字符串:

String text = "hello world!";
String subtext = "orl";

// pay attention that this will return true for subtext=""
boolean contains = text.contains(subtext);

或者,可以通过依赖于String.indexOf()(或String.lastIndexOf()来实现解决方案,如下所示:

public static boolean contains(String text, String subtext) {

  return text.indexOf(subtext) != -1; // or lastIndexOf()
}

另一种解决方案可以基于正则表达式实现,如下所示:

public static boolean contains(String text, String subtext) {

  return text.matches("(?i).*" + Pattern.quote(subtext) + ".*");
}

注意,正则表达式被包装在Pattern.quote()方法中。这是转义特殊字符(如“<”)([{^-=$)所必需的!|]})?*+. 给定子串中的>。

对于第三方库支持,请考虑 ApacheCommonsLang,StringUtils.containsIgnoreCase()

17 计算字符串中子字符串的出现次数

计算一个字符串在另一个字符串中出现的次数是一个问题,它至少有两种解释:

  • 11/111 发生 1 次
  • 11/111 发生 2 次

在第一种情况下(111 中的 11 发生 1 次),可以依赖于String.indexOf()方法来解决。此方法的一种风格允许我们从指定的索引(如果没有这样的索引,则为 -1)开始获取指定子字符串第一次出现的字符串中的索引。基于此方法,该解决方案可以简单地遍历给定的字符串并计算给定子字符串的出现次数。遍历从位置 0 开始,直到找不到子字符串为止:

public static int countStringInString(String string, String toFind) {

  int position = 0;
  int count = 0;
  int n = toFind.length();

  while ((position = string.indexOf(toFind, position)) != -1) {
    position = position + n;
    count++;
  }

  return count;
}

或者,该溶液可以使用String.split()方法。基本上,该解决方案可以使用给定的子字符串作为分隔符来拆分给定的字符串。结果String[]数组的长度应等于预期出现的次数:

public static int countStringInString(String string, String toFind) {

  int result = string.split(Pattern.quote(toFind), -1).length - 1;

  return result < 0 ? 0 : result;
}

在第二种情况下(111 中的 11 出现了 2 次),解决方案可以在一个简单的实现中依赖于PatternMatcher类,如下所示:

public static int countStringInString(String string, String toFind) {

  Pattern pattern = Pattern.compile(Pattern.quote(toFind));
  Matcher matcher = pattern.matcher(string);

  int position = 0;
  int count = 0;

  while (matcher.find(position)) {

    position = matcher.start() + 1;
    count++;
  }

  return count;
}

很好!让我们继续讨论字符串的另一个问题。

18 检查两个字符串是否为异序词

两个具有相同字符但顺序不同的字符串是异序词。一些定义强制要求字谜不区分大小写和/或应忽略空格(空格)。

因此,与应用的算法无关,解决方案必须将给定的字符串转换为小写并删除空格(空格)。除此之外,我们提到的第一个解决方案通过Arrays.sort()对数组进行排序,并通过Arrays.equals()检查它们的相等性。

一旦它们被排序,如果它们是字谜,它们将相等(下图显示了两个字谜):

此解决方案(包括其 Java8 函数式版本)在本书附带的代码中提供。这两种解决方案的主要缺点是排序部分。以下解决方案消除了这一步骤,并依赖于 256 个索引的空数组(最初仅包含 0)(字符的扩展 ASCII 表代码更多信息可在“查找第一个非重复字符”部分中找到)。

算法非常简单:

  • 对于第一个字符串中的每个字符,此解决方案将此数组中对应于 ASCII 代码的值增加 1
  • 对于第二个字符串中的每个字符,此解决方案将此数组中对应于 ASCII 代码的值减少 1

代码如下:

private static final int EXTENDED_ASCII_CODES = 256;
...
public static boolean isAnagram(String str1, String str2) {

  int[] chCounts = new int[EXTENDED_ASCII_CODES];
  char[] chStr1 = str1.replaceAll("\\s", 
    "").toLowerCase().toCharArray();
  char[] chStr2 = str2.replaceAll("\\s", 
    "").toLowerCase().toCharArray();

  if (chStr1.length != chStr2.length) {
    return false;
  }

  for (int i = 0; i < chStr1.length; i++) {
    chCounts[chStr1[i]]++;
    chCounts[chStr2[i]]--;
  }

  for (int i = 0; i < chCounts.length; i++) {
    if (chCounts[i] != 0) {
      return false;
    }
  }

  return true;
}

在遍历结束时,如果给定的字符串是异序词,那么这个数组只包含 0。

19 声明多行字符串(文本块)

在写这本书的时候,JDK12 有一个关于添加多行字符串的建议,称为 JEP326:原始字符串字面值。但这是在最后一刻掉的

从 JDK13 开始,重新考虑了这个想法,与被拒绝的原始字符串字面值不同,文本块被三个双引号包围,""",如下所示:

String text = """My high school,
the Illinois Mathematics and Science Academy,
showed me that anything is possible
and that you're never too young to think big.""";

文本块对于编写多行 SQL 语句、使用 polyglot 语言等非常有用。更多详情见这个页面

尽管如此,在 JDK13 之前有几种替代解决方案可以使用。这些解决方案有一个共同点,即使用行分隔符:

private static final String LS = System.lineSeparator();

从 JDK8 开始,解决方案可以依赖于String.join(),如下所示:

String text = String.join(LS,
  "My high school, ",
  "the Illinois Mathematics and Science Academy,",
  "showed me that anything is possible ",
  "and that you're never too young to think big.");

在 JDK8 之前,一个优雅的解决方案可能依赖于StringBuilder。本书附带的代码中提供了此解决方案。

虽然前面的解决方案适合于相对大量的字符串,但如果我们只有几个字符串,下面的两个就可以了。第一个使用+运算符:

String text = "My high school, " + LS +
  "the Illinois Mathematics and Science Academy," + LS +
  "showed me that anything is possible " + LS +
  "and that you're never too young to think big.";

第二个使用String.format()

String text = String.format("%s" + LS + "%s" + LS + "%s" + LS + "%s",
  "My high school, ",
  "the Illinois Mathematics and Science Academy,",
  "showed me that anything is possible ",
  "and that you're never too young to think big.");

如何处理多行字符串的每一行?好吧,快速方法需要 JDK11,它与String.lines()方法一起提供。该方法通过行分隔符(支持\n\r\r\n对给定字符串进行拆分,并将其转换为Stream<String>。或者,也可以使用String.split()方法(从 JDK1.4 开始提供)。如果字符串的数量变得重要,建议将它们放入一个文件中,并逐个读取/处理它们(例如,通过getResourceAsStream()方法)。其他方法依赖于StringWriterBufferedWriter.newLine()

对于第三方库支持,请考虑 Apache Commons Lang、StringUtils.join()、Guava、Joiner和自定义注解@Multiline

20 连接相同字符串 n 次

在 JDK11 之前,可以通过StringBuilder快速提供解决方案,如下:

public static String concatRepeat(String str, int n) {

  StringBuilder sb = new StringBuilder(str.length() * n);

  for (int i = 1; i <= n; i++) {
    sb.append(str);
  }

  return sb.toString();
}

从 JDK11 开始,解决方案依赖于String.repeat(int count)方法。此方法返回一个字符串,该字符串通过将此字符串count连接几次而得到。在幕后,这个方法使用了System.arraycopy(),这使得这个速度非常快:

String result = "hello".repeat(5);

其他适合不同场景的解决方案如下:

  • 以下是基于String.join()的解决方案:
String result = String.join("", Collections.nCopies(5, TEXT));
  • 以下是基于Stream.generate()的解决方案:
String result = Stream.generate(() -> TEXT)
  .limit(5)
  .collect(joining());
  • 以下是基于String.format()的解决方案:
String result = String.format("%0" + 5 + "d", 0)
  .replace("0", TEXT);
  • 以下是基于char[]的解决方案:
String result = new String(new char[5]).replace("\0", TEXT);

对于第三方库支持,请考虑 ApacheCommonsLang,StringUtils.repeat()和 Guava,Strings.repeat()

要检查字符串是否是相同子字符串的序列,请使用以下方法:

public static boolean hasOnlySubstrings(String str) {

  StringBuilder sb = new StringBuilder();

  for (int i = 0; i < str.length() / 2; i++) {
    sb.append(str.charAt(i));
    String resultStr = str.replaceAll(sb.toString(), "");
    if (resultStr.length() == 0) {
      return true;
    }
  }

  return false;
}

该解决方案循环给定字符串的一半,并通过逐字符将原始字符串追加到StringBuilder中,逐步用""(子字符串构建)替换它。如果这些替换结果是空字符串,则表示给定的字符串是相同子字符串的序列。

21 删除前导空格和尾随空格

这个问题的最快解决方法可能依赖于String.trim()方法。此方法能够删除所有前导和尾随空格,即代码点小于或等于 U+0020 或 32 的任何字符(空格字符):

String text = "\n \n\n hello \t \n \r";
String trimmed = text.trim();

前面的代码片段将按预期工作。修剪后的字符串将为hello。这只适用于所有正在使用的空格小于 U+0020 或 32(空格字符)。有 25 个字符定义为空格,trim()只覆盖其中的一部分(简而言之,trim()不知道 Unicode)。让我们考虑以下字符串:

char space = '\u2002';
String text = space + "\n \n\n hello \t \n \r" + space;

\u2002trim()无法识别的另一种类型的空白(\u2002\u0020之上)。这意味着,在这种情况下,trim()将无法按预期工作。从 JDK11 开始,这个问题有一个名为strip()的解决方案。此方法将trim()的功能扩展到 Unicode 领域:

String stripped = text.strip();

这一次,所有的前导和尾随空格都将被删除。

此外,JDK11 还提供了两种类型的strip(),用于仅删除前导(stripLeading())或尾部(stripTrailing())空格。trim()方法没有这些味道。

22 查找最长的公共前缀

让我们考虑以下字符串数组:

String[] texts = {"abc", "abcd", "abcde", "ab", "abcd", "abcdef"};

现在,让我们把这些线一根接一根,如下所示:

abc
abcd
abcde
ab abcd
abcdef

通过对这些字符串的简单比较可以看出,ab是最长的公共前缀。现在,让我们深入研究解决此问题的解决方案。我们在这里提出的解决方案依赖于一个简单的比较。此解决方案从数组中获取第一个字符串,并将其每个字符与其余字符串进行比较。如果发生以下任一情况,算法将停止:

  • 第一个字符串的长度大于任何其他字符串的长度
  • 第一个字符串的当前字符与任何其他字符串的当前字符不同

如果由于上述情况之一而强制停止算法,则最长的公共前缀是从 0 到第一个字符串的当前字符索引的子字符串。否则,最长的公共前缀是数组中的第一个字符串。此解决方案的代码如下:

public static String longestCommonPrefix(String[] strs) {

  if (strs.length == 1) {
    return strs[0];
  }

  int firstLen = strs[0].length();

  for (int prefixLen = 0; prefixLen < firstLen; prefixLen++) {
    char ch = strs[0].charAt(prefixLen);
    for (int i = 1; i < strs.length; i++) {
      if (prefixLen >= strs[i].length() 
         || strs[i].charAt(prefixLen) != ch) {
          return strs[i].substring(0, prefixLen);
      }
    }
  }

  return strs[0];
}

这个问题的其他解决方案使用众所周知的算法,例如二分搜索Trie。在本书附带的源代码中,还有一个基于二分搜索的解决方案。

23 应用缩进

从 JDK12 开始,我们可以通过String.indent(int n)方法缩进文本。

假设我们有以下String值:

String days = "Sunday\n" 
  + "Monday\n" 
  + "Tuesday\n" 
  + "Wednesday\n" 
  + "Thursday\n" 
  + "Friday\n" 
  + "Saturday";

用 10 个空格的缩进打印这个String值可以如下所示:

System.out.print(days.indent(10));

输出如下:

现在,让我们试试层叠缩进:

List<String> days = Arrays.asList("Sunday", "Monday", "Tuesday",
  "Wednesday", "Thursday", "Friday", "Saturday");

for (int i = 0; i < days.size(); i++) {
  System.out.print(days.get(i).indent(i));
}

输出如下:

现在,让我们根据String值的长度缩进:

days.stream()
  .forEachOrdered(d -> System.out.print(d.indent(d.length())));

输出如下:

缩进一段 HTML 代码怎么样?让我们看看:

String html = "<html>";
String body = "<body>";
String h2 = "<h2>";
String text = "Hello world!";
String closeH2 = "</h2>";
String closeBody = "</body>";
String closeHtml = "</html>";

System.out.println(html.indent(0) + body.indent(4) + h2.indent(8) 
  + text.indent(12) + closeH2.indent(8) + closeBody.indent(4)
  + closeHtml.indent(0));

输出如下:

24 转换字符串

假设我们有一个字符串,我们想把它转换成另一个字符串(例如,把它转换成大写)。我们可以通过应用像Function<? super String,​ ? extends R>这样的函数来实现这一点。

在 JDK8 中,我们可以通过map()来实现,如下两个简单的例子所示:

// hello world
String resultMap = Stream.of("hello")
  .map(s -> s + " world")
  .findFirst()
  .get();

// GOOOOOOOOOOOOOOOOL! GOOOOOOOOOOOOOOOOL!
String resultMap = Stream.of("gooool! ")
  .map(String::toUpperCase)
  .map(s -> s.repeat(2))
  .map(s -> s.replaceAll("O", "OOOO"))
  .findFirst()
  .get();

从 JDK12 开始,我们可以依赖一个名为transform​(Function<? super String, ​? extends R> f)的新方法。让我们通过transform()重写前面的代码片段:

// hello world
String result = "hello".transform(s -> s + " world");

// GOOOOOOOOOOOOOOOOL! GOOOOOOOOOOOOOOOOL!
String result = "gooool! ".transform(String::toUpperCase)
  .transform(s -> s.repeat(2))
  .transform(s -> s.replaceAll("O", "OOOO"));

虽然map()更一般,但transform()专用于将函数应用于字符串并返回结果字符串。

25 计算两个数的最小值和最大值

在 JDK8 之前,一个可能的解决方案是依赖于Math.min()Math.max()方法,如下所示:

int i1 = -45;
int i2 = -15;
int min = Math.min(i1, i2);
int max = Math.max(i1, i2);

Math类为每个原始数字类型(intlongfloatdouble提供了min()max()方法。

从 JDK8 开始,每个原始数字类型的包装器类(IntegerLongFloatDouble都有专用的min()max()方法,在这些方法后面,还有来自Math类的对应调用。请参见下面的示例(这是一个更具表现力的示例):

double d1 = 0.023844D;
double d2 = 0.35468856D;
double min = Double.min(d1, d2);
double max = Double.max(d1, d2);

在函数式风格的上下文中,潜在的解决方案将依赖于BinaryOperator函数式接口。此接口有两种方式,minBy()maxBy()

float f1 = 33.34F;
final float f2 = 33.213F;
float min = BinaryOperator.minBy(Float::compare).apply(f1, f2);
float max = BinaryOperator.maxBy(Float::compare).apply(f1, f2);

这两种方法能够根据指定的比较器返回两个元素的最小值(分别是最大值)。

26 两个大的int/long值求和并导致操作溢出

让我们从+操作符开始深入研究解决方案,如下例所示:

int x = 2;
int y = 7;
int z = x + y; // 9

这是一种非常简单的方法,适用于大多数涉及intlongfloatdouble的计算。

现在,让我们将此运算符应用于以下两个大数(与其自身相加为 2147483647):

int x = Integer.MAX_VALUE;
int y = Integer.MAX_VALUE;
int z = x + y; // -2

此时,z将等于 -2,这不是预期的结果,即 4294967294。仅将z类型从int更改为long将无济于事。但是,将xy的类型从int改为long将有所帮助:

long x = Integer.MAX_VALUE;
long y = Integer.MAX_VALUE;
long z = x + y; // 4294967294

但如果不是Integer.MAX_VALUE,而是Long.MAX_VALUE,问题就会再次出现:

long x = Long.MAX_VALUE;
long y = Long.MAX_VALUE;
long z = x + y; // -2

从 JDK8 开始,+操作符被一个原始类型数字类型的包装器以一种更具表现力的方式包装。因此,IntegerLongFloatDouble类具有sum()方法:

long z = Long.sum(); // -2

在幕后,sum()方法也使用+操作符,因此它们只产生相同的结果。

但同样从 JDK8 开始,Math类用两种addExact()方法进行了丰富。一个addExact()用于两个int变量的求和,一个用于两个long变量的求和。如果结果容易溢出intlong,这些方法非常有用,如前面的例子所示。在这种情况下,这些方法抛出ArithmeticException,而不是返回误导性的结果,如下例所示:

int z = Math.addExact(x, y); // throw ArithmeticException

代码将抛出一个异常,例如java.lang.ArithmeticException: integer overflow。这是很有用的,因为它允许我们避免在进一步的计算中引入误导性的结果(例如,早期,-2 可以悄悄地进入进一步的计算)。

在函数式上下文中,潜在的解决方案将依赖于BinaryOperator函数式接口,如下所示(只需定义相同类型的两个操作数的操作):

BinaryOperator<Integer> operator = Math::addExact;
int z = operator.apply(x, y);

addExact()外,Math还有multiplyExact()substractExact()negateExact()。此外,众所周知的增量和减量表达式i++i--可以通过incrementExact()decrementExact()方法(例如Math.incrementExact(i))来控制溢出它们的域。请注意,这些方法仅适用于intlong

在处理大量数字时,还要关注BigInteger(不可变任意精度整数)和BigDecimal(不可变任意精度带符号十进制数)类。

27 字符串按照基数转换为无符号数

对无符号算术的支持从版本 8 开始添加到 Java 中。ByteShortIntegerLong类受此影响最大。

在 Java 中,表示正数的字符串可以通过parseUnsignedInt()parseUnsignedLong()JDK8 方法解析为无符号的intlong类型。例如,我们将以下整数视为字符串:

String nri = "255500";

将其解析为以 36 为基数(最大可接受基数)的无符号int值的解决方案如下:

int result = Integer.parseUnsignedInt(nri, Character.MAX_RADIX);

第一个参数是数字,第二个参数是基数。基数应在[2, 36][Character.MIN_RADIX, Character.MAX_RADIX]范围内。

使用基数 10 可以很容易地完成如下操作(此方法默认应用基数 10):

int result = Integer.parseUnsignedInt(nri);

从 JDK9 开始,parseUnsignedInt()有了新的味道。除了字符串和基数之外,这个方法还接受一个范围的[beginIndex, endIndex]类型。这一次,解析是在这个范围内完成的。例如,可以按如下方式指定范围[1, 3]

int result = Integer.parseUnsignedInt(nri, 1, 4, Character.MAX_RADIX);

parseUnsignedInt()方法可以解析表示大于Integer.MAX_VALUE的数字的字符串(尝试通过Integer.parseInt()完成此操作将引发java.lang.NumberFormatException异常):

// Integer.MAX_VALUE + 1 = 2147483647 + 1 = 2147483648
int maxValuePlus1 = Integer.parseUnsignedInt("2147483648");

对于Long类中的长数存在相同的方法集(例如,parseUnsignedLong()

28 通过无符号转换转换数字

这个问题要求我们通过无符号转换将给定的有符号的int转换成long。那么,让我们考虑签名的Integer.MIN_VALUE,即 -2147483648。

在 JDK8 中,使用Integer.toUnsignedLong()方法,转换如下(结果为 2147483648):

long result = Integer.toUnsignedLong(Integer.MIN_VALUE);

下面是另一个将有符号的Short.MIN_VALUEShort.MAX_VALUE转换为无符号整数的示例:

int result1 = Short.toUnsignedInt(Short.MIN_VALUE);
int result2 = Short.toUnsignedInt(Short.MAX_VALUE);

其他同类方法有Integer.toUnsignedString()Long.toUnsignedString()Byte.toUnsignedInt()Byte.toUnsignedLong()Short.toUnsignedInt()Short.toUnsignedLong()

29 比较两个无符号数

让我们考虑两个有符号整数,Integer.MIN_VALUE(-2147483648)和Integer.MAX_VALUE(2147483647)。比较这些整数(有符号值)将导致 -2147483648 小于 2147483647:

// resultSigned is equal to -1 indicating that
// MIN_VALUE is smaller than MAX_VALUE
int resultSigned = Integer.compare(Integer.MIN_VALUE, 
  Integer.MAX_VALUE);

在 JDK8 中,这两个整数可以通过Integer.compareUnsigned()方法作为无符号值进行比较(这相当于无符号值的Integer.compare())。该方法主要忽略了符号位的概念,最左边的被认为是最重要的位。在无符号值保护伞下,如果比较的数字相等,则此方法返回 0;如果第一个无符号值小于第二个无符号值,则此方法返回小于 0 的值;如果第一个无符号值大于第二个无符号值,则此方法返回大于 0 的值。

下面的比较返回 1,表示Integer.MIN_VALUE的无符号值大于Integer.MAX_VALUE的无符号值:

// resultSigned is equal to 1 indicating that
// MIN_VALUE is greater than MAX_VALUE
int resultUnsigned 
  = Integer.compareUnsigned(Integer.MIN_VALUE, Integer.MAX_VALUE);

compareUnsigned()方法在以 JDK8 开始的IntegerLong类中可用,在以 JDK9 开始的ByteShort类中可用。

30 无符号值的除法和模

JDK8 无符号算术 API 通过divideUnsigned()remainderUnsigned()方法支持计算两个无符号值的除法所得的无符号商和余数。

让我们考虑一下Interger.MIN_VALUEInteger.MAX_VALUE有符号数,然后应用除法和模。这里没有什么新鲜事:

// signed division
// -1
int divisionSignedMinMax = Integer.MIN_VALUE / Integer.MAX_VALUE; 

// 0
int divisionSignedMaxMin = Integer.MAX_VALUE / Integer.MIN_VALUE;

// signed modulo
// -1
int moduloSignedMinMax = Integer.MIN_VALUE % Integer.MAX_VALUE; 

// 2147483647
int moduloSignedMaxMin = Integer.MAX_VALUE % Integer.MIN_VALUE; 

现在,我们将Integer.MIN_VALUEInteger.MAX_VALUE视为无符号值,并应用divideUnsigned()remainderUnsigned()

// division unsigned
int divisionUnsignedMinMax = Integer.divideUnsigned(
  Integer.MIN_VALUE, Integer.MAX_VALUE); // 1
int divisionUnsignedMaxMin = Integer.divideUnsigned(
  Integer.MAX_VALUE, Integer.MIN_VALUE); // 0

// modulo unsigned
int moduloUnsignedMinMax = Integer.remainderUnsigned(
  Integer.MIN_VALUE, Integer.MAX_VALUE); // 1
int moduloUnsignedMaxMin = Integer.remainderUnsigned(
  Integer.MAX_VALUE, Integer.MIN_VALUE); // 2147483647

注意它们与比较操作的相似性。这两种操作,即无符号除法和无符号模运算,都将所有位解释为值位,并忽略符号位

divideUnsigned() and remainderUnsigned() are present in the Integer and Long classes, respectively.

31 double/float是一个有限的浮点值

这个问题产生于这样一个事实:一些浮点方法和操作产生InfinityNaN作为结果,而不是抛出异常。

检查给定的float/double是否为有限浮点值的解决方案取决于以下条件:给定的float/double值的绝对值不得超过float/double类型的最大正有限值:

// for float
Math.abs(f) <= Float.MAX_VALUE;

// for double
Math.abs(d) <= Double.MAX_VALUE

从 Java8 开始,前面的条件通过两个专用的标志方法Float.isFinite()Double.isFinite()公开。因此,以下示例是有限浮点值的有效测试用例:

Float f1 = 4.5f;
boolean f1f = Float.isFinite(f1); // f1 = 4.5, is finite

Float f2 = f1 / 0;
boolean f2f = Float.isFinite(f2); // f2 = Infinity, is not finite

Float f3 = 0f / 0f;
boolean f3f = Float.isFinite(f3); // f3 = NaN, is not finite

Double d1 = 0.000333411333d;
boolean d1f = Double.isFinite(d1); // d1 = 3.33411333E-4,is finite

Double d2 = d1 / 0;
boolean d2f = Double.isFinite(d2); // d2 = Infinity, is not finite

Double d3 = Double.POSITIVE_INFINITY * 0;
boolean d3f = Double.isFinite(d3); // d3 = NaN, is not finite

这些方法在以下情况下非常方便:

if (Float.isFinite(d1)) {
  // do a computation with d1 finite floating-point value
} else {
  // d1 cannot enter in further computations
}

32 对两个布尔表达式应用逻辑与/或/异或

基本逻辑运算的真值表(异或)如下:

在 Java 中,逻辑运算符表示为&&,逻辑运算符表示为||,逻辑异或运算符表示为^。从 JDK8 开始,这些运算符被应用于两个布尔值,并被包装在三个static方法中—Boolean.logicalAnd()Boolean.logicalOr()Boolean.logicalXor()

int s = 10;
int m = 21;

// if (s > m && m < 50) { } else { }
if (Boolean.logicalAnd(s > m, m < 50)) {} else {}

// if (s > m || m < 50) { } else { }
if (Boolean.logicalOr(s > m, m < 50)) {} else {}

// if (s > m ^ m < 50) { } else { }
if (Boolean.logicalXor(s > m, m < 50)) {} else {}

也可以结合使用这些方法:

if (Boolean.logicalAnd(
    Boolean.logicalOr(s > m, m < 50),
    Boolean.logicalOr(s <= m, m > 50))) {} else {}

33 将BigInteger转换为原始类型

BigInteger类是表示不可变的任意精度整数的非常方便的工具。

此类还包含用于将BigInteger转换为bytelongdouble等原始类型的方法(源于java.lang.Number)。然而,这些方法会产生意想不到的结果和混乱。例如,假设有BigInteger包裹Long.MAX_VALUE

BigInteger nr = BigInteger.valueOf(Long.MAX_VALUE);

让我们通过BigInteger.longValue()方法将这个BigInteger转换成一个原始long

long nrLong = nr.longValue();

到目前为止,由于Long.MAX_VALUE是 9223372036854775807,nrLong原始类型变量正好有这个值,所以一切都按预期工作。

现在,让我们尝试通过BigInteger.intValue()方法将这个BigInteger类转换成一个原始的int值:

int nrInt = nr.intValue();

此时,nrInt原始类型变量的值将为 -1(相同的结果将产生shortValue()byteValue()。根据文档,如果BigInteger的值太大,无法容纳指定的原始类型,则只返回低位n位(n取决于指定的原始类型)。但是如果代码没有意识到这个语句,那么它将在进一步的计算中把值推为 -1,这将导致混淆。

但是,从 JDK8 开始,添加了一组新的方法。这些方法专门用于识别从BigInteger转换为指定的原始类型过程中丢失的信息。如果检测到丢失的信息,则抛出ArithmeticException。这样,代码表示转换遇到了一些问题,并防止了这种不愉快的情况。

这些方法是longValueExact()intValueExact()shortValueExact()byteValueExact()

long nrExactLong = nr.longValueExact(); // works as expected
int nrExactInt = nr.intValueExact();    // throws ArithmeticException

注意,intValueExact()没有返回 -1 作为intValue()。这一次,由于试图将最大的long值转换为int而导致的信息丢失通过ArithmeticException类型的异常发出信号。

34 将long转换为int

long值转换为int值似乎是一件容易的工作。例如,潜在的解决方案可以依赖于以下条件:

long nr = Integer.MAX_VALUE;
int intNrCast = (int) nr;

或者,它可以依赖于Long.intValue(),如下所示:

int intNrValue = Long.valueOf(nrLong).intValue();

两种方法都很有效。现在,假设我们有以下long值:

long nrMaxLong = Long.MAX_VALUE;

这一次,两种方法都将返回 -1。为了避免这种结果,建议使用 JDK8,即Math.toIntExact()。此方法获取一个long类型的参数,并尝试将其转换为int。如果得到的值溢出了int,则该方法抛出ArithmeticException

// throws ArithmeticException
int intNrMaxExact = Math.toIntExact(nrMaxLong); 

在幕后,toIntExact()依赖于((int)value != value)条件。

35 除法的下限与模的计算

假设我们有以下划分:

double z = (double)222/14;

这将用这个除法的结果初始化z,即 15.85,但是我们的问题要求这个除法的下限是 15(这是小于或等于代数商的最大整数值)。获得该期望结果的解决方案将包括应用Math.floor(15.85),即 15。

但是,222 和 14 是整数,因此前面的除法如下:

int z = 222/14;

这一次,z将等于 15,这正是预期的结果(/运算符返回最接近零的整数)。无需申请Math.floor(z)。此外,如果除数为 0,则222/0将抛出ArithmeticException

到目前为止的结论是,两个符号相同的整数(都是正的或负的)的除法底可以通过/运算符得到。

好的,到目前为止,很好,但是假设我们有以下两个整数(相反的符号;被除数是负数,除数是正数,反之亦然):

double z = (double) -222/14;

此时,z将等于 -15.85。同样,通过应用Math.floor(z),结果将是 -16,这是正确的(这是小于或等于代数商的最大整数值)。

让我们用int再次讨论同样的问题:

int z = -222/14;

这次,z将等于 -15。这是不正确的,Math.floor(z)在这种情况下对我们没有帮助,因为Math.floor(-15)是 -15。所以,这是一个应该考虑的问题。

从 JDK8 开始,所有这些病例都通过Math.floorDiv()方法被覆盖和暴露。此方法以表示被除数和除数的两个整数为参数,返回小于或等于代数商的最大值(最接近正无穷大)int值:

int x = -222;
int y = 14;

// x is the dividend, y is the divisor
int z = Math.floorDiv(x, y); // -16

Math.floorDiv()方法有三种口味:floorDiv(int x, int y)floorDiv(long x, int y)floorDiv(long x, long y)

Math.floorDiv()之后,JDK8 附带了Math.floorMod(),它返回给定参数的地板模量。这是作为x - (floorDiv(x, y) * y)的结果计算的,因此对于符号相同的参数,它将返回与%运算符相同的结果;对于符号不相同的参数,它将返回不同的结果。

将两个正整数(a/b)的除法结果四舍五入可以快速完成,如下所示:

long result = (a + b - 1) / b;

下面是一个例子(我们有4/3=1.33,我们想要 2):

long result = (4 + 3 - 1) / 3; // 2

下面是另一个例子(我们有17/7=2.42,我们想要 3):

long result = (17 + 7 - 1) / 7; // 3

如果整数不是正的,那么我们可以依赖于Math.ceil()

long result = (long) Math.ceil((double) a/b);

36 下一个浮点值

有一个整数值,比如 10,使得我们很容易得到下一个整数值,比如10+1(在正无穷方向)或者10-1(在负无穷方向)。尝试为floatdouble实现同样的目标并不像对整数那么容易。

从 JDK6 开始,Math类通过nextAfter()方法得到了丰富。此方法采用两个参数,即初始数字(floatdouble)和方向(Float/Double.NEGATIVE/POSITIVE_INFINITY)——并返回下一个浮点值。在这里,返回 0.1 附近的下一个负无穷方向的浮点值是这种方法的一个特色:

float f = 0.1f;

// 0.099999994
float nextf = Math.nextAfter(f, Float.NEGATIVE_INFINITY);

从 JDK8 开始,Math类通过两个方法进行了丰富,这两个方法充当了nextAfter()的快捷方式,而且速度更快。这些方法是nextDown()nextUp()

float f = 0.1f;

float nextdownf = Math.nextDown(f); // 0.099999994
float nextupf = Math.nextUp(f); // 0.10000001

double d = 0.1d;

double nextdownd = Math.nextDown(d); // 0.09999999999999999
double nextupd = Math.nextUp(d); // 0.10000000000000002

因此,在负无穷大方向上的nextAfter()可通过Math.nextDown()nextAfter()获得,而在正无穷大方向上的Math.nextUp()可通过Math.nextUp()获得。

37 将两个大int/long值相乘并溢出

让我们从*操作符开始深入研究解决方案,如下例所示:

int x = 10;
int y = 5;
int z = x * y; // 50

这是一种非常简单的方法,对于大多数涉及intlongfloatdouble的计算都很好。

现在,让我们将此运算符应用于以下两个大数(将 2147483647 与自身相乘):

int x = Integer.MAX_VALUE;
int y = Integer.MAX_VALUE;
int z = x * y; // 1

此时,z将等于 1,这不是预期的结果,即 4611686014132420609。仅将z类型从int更改为long将无济于事。但是,将xy的类型从int改为long将:

long x = Integer.MAX_VALUE;
long y = Integer.MAX_VALUE;
long z = x * y; // 4611686014132420609

但是如果我们用Long.MAX_VALUE代替Integer.MAX_VALUE,问题会再次出现:

long x = Long.MAX_VALUE;
long y = Long.MAX_VALUE;
long z = x * y; // 1

因此,溢出域并依赖于*运算符的计算将最终导致误导性结果。

与其在进一步的计算中使用这些结果,不如在发生溢出操作时及时得到通知。JDK8 附带了Math.multiplyExact()方法。此方法尝试将两个整数相乘。如果结果溢出,int只抛出ArithmeticException

int x = Integer.MAX_VALUE;
int y = Integer.MAX_VALUE;
int z = Math.multiplyExact(x, y); // throw ArithmeticException

在 JDK8 中,Math.muliplyExact(int x, int y)返回intMath.muliplyExact(long x, long y)返回long。在 JDK9 中,还添加了Math.muliplyExact(long, int y)返回long

JDK9 带有返回值为longMath.multiplyFull(int x, int y)。此方法对于获得两个整数的精确数学积long非常有用,如下所示:

int x = Integer.MAX_VALUE;
int y = Integer.MAX_VALUE;
long z = Math.multiplyFull(x, y); // 4611686014132420609

为了记录在案,JDK9 还附带了一个名为Math.muliptlyHigh(long x, long y)的方法,返回一个long。此方法返回的long值表示两个 64 位因子的 128 位乘积的最高有效 64 位:

long x = Long.MAX_VALUE;
long y = Long.MAX_VALUE;
// 9223372036854775807 * 9223372036854775807 = 4611686018427387903
long z = Math.multiplyHigh(x, y);

在函数式上下文中,潜在的解决方案将依赖于BinaryOperator函数式接口,如下所示(只需定义相同类型的两个操作数的操作):

int x = Integer.MAX_VALUE;
int y = Integer.MAX_VALUE;
BinaryOperator<Integer> operator = Math::multiplyExact;
int z = operator.apply(x, y); // throw ArithmeticException

对于处理大数,还应关注BigInteger(不可变任意精度整数)和BigDecimal(不可变任意精度带符号十进制数)类。

38 融合乘法加法

数学计算a * b + c在矩阵乘法中被大量利用,在高性能计算、人工智能应用、机器学习、深度学习、神经网络等领域有着广泛的应用。

实现此计算的最简单方法直接依赖于*+运算符,如下所示:

double x = 49.29d;
double y = -28.58d;
double z = 33.63d;
double q = (x * y) + z;

这种实现的主要问题是由两个舍入误差(一个用于乘法运算,一个用于加法运算)引起的精度和性能低下。

不过,多亏了 Intel AVX 执行 SIMD 操作的指令和 JDK9,JDK9 添加了Math.fma()方法,这种计算才得以提高。依靠Math.fma(),使用“四舍五入到最接近的偶数四舍五入”模式只进行一次四舍五入:

double fma = Math.fma(x, y, z);

请注意,这种改进适用于现代 Intel 处理器,因此仅使用 JDK9 是不够的。

39 紧凑数字格式

从 JDK12 开始,添加了一个用于紧凑数字格式的新类。这个类被命名为java.text.CompactNumberFormat。这个类的主要目标是扩展现有的 Java 数字格式化 API,支持区域设置和压缩。

数字可以格式化为短样式(例如,1000变成1K),也可以格式化为长样式(例如,1000变成1000)。这两种风格在Style枚举中分为SHORTLONG

除了CompactNumberFormat构造器外,还可以通过两个static方法创建CompactNumberFormat,这两个方法被添加到NumberFormat类中:

  • 第一种是默认语言环境的紧凑数字格式,带有NumberFormat.Style.SHORT
public static NumberFormat getCompactNumberInstance()
  • 第二种是指定区域设置的紧凑数字格式,带有NumberFormat.Style
public static NumberFormat getCompactNumberInstance​(
    Locale locale, NumberFormat.Style formatStyle)

让我们仔细看看格式化和解析。

格式化

默认情况下,使用RoundingMode.HALF_EVEN格式化数字。但是,我们可以通过NumberFormat.setRoundingMode()显式设置舍入模式。

尝试将这些信息压缩成一个名为NumberFormatters的工具类可以实现如下:

public static String forLocale(Locale locale, double number) {

  return format(locale, Style.SHORT, null, number);
}

public static String forLocaleStyle(
  Locale locale, Style style, double number) {

  return format(locale, style, null, number);
}

public static String forLocaleStyleRound(
  Locale locale, Style style, RoundingMode mode, double number) {

  return format(locale, style, mode, number);
}

private static String format(
  Locale locale, Style style, RoundingMode mode, double number) {

  if (locale == null || style == null) {
    return String.valueOf(number); // or use a default format
  }

  NumberFormat nf = NumberFormat.getCompactNumberInstance(locale,
     style);

  if (mode != null) {
    nf.setRoundingMode(mode);
  }

  return nf.format(number);
}

现在,我们将数字100010000001000000000格式化为US语言环境、SHORT样式和默认舍入模式:

// 1K
NumberFormatters.forLocaleStyle(Locale.US, Style.SHORT, 1_000);

// 1M
NumberFormatters.forLocaleStyle(Locale.US, Style.SHORT, 1_000_000);

// 1B
NumberFormatters.forLocaleStyle(Locale.US, Style.SHORT, 
  1_000_000_000);

我们可以对LONG样式做同样的处理:

// 1thousand
NumberFormatters.forLocaleStyle(Locale.US, Style.LONG, 1_000);

// 1million
NumberFormatters.forLocaleStyle(Locale.US, Style.LONG, 1_000_000);

// 1billion
NumberFormatters.forLocaleStyle(Locale.US, Style.LONG, 1_000_000_000);

我们也可以使用ITALIAN区域设置和SHORT样式:

// 1.000
NumberFormatters.forLocaleStyle(Locale.ITALIAN, Style.SHORT, 
  1_000);

// 1 Mln
NumberFormatters.forLocaleStyle(Locale.ITALIAN, Style.SHORT, 
  1_000_000);

// 1 Mld
NumberFormatters.forLocaleStyle(Locale.ITALIAN, Style.SHORT, 
  1_000_000_000);

最后,我们还可以使用ITALIAN语言环境和LONG样式:

// 1 mille
NumberFormatters.forLocaleStyle(Locale.ITALIAN, Style.LONG, 
  1_000);

// 1 milione
NumberFormatters.forLocaleStyle(Locale.ITALIAN, Style.LONG, 
  1_000_000);

// 1 miliardo
NumberFormatters.forLocaleStyle(Locale.ITALIAN, Style.LONG, 
  1_000_000_000);

现在,假设我们有两个数字:12001600

从取整方式来看,分别取整为10002000。默认取整模式HALF_EVEN1200取整为1000,将1600取整为2000。但是如果我们想让1200变成20001600变成1000,那么我们需要明确设置取整模式如下:

// 2000 (2 thousand)
NumberFormatters.forLocaleStyleRound(
  Locale.US, Style.LONG, RoundingMode.UP, 1_200);

// 1000 (1 thousand)
NumberFormatters.forLocaleStyleRound(
  Locale.US, Style.LONG, RoundingMode.DOWN, 1_600);

解析

解析是格式化的相反过程。我们有一个给定的字符串,并尝试将其解析为一个数字。这可以通过NumberFormat.parse()方法来实现。默认情况下,解析不利用分组(例如,不分组时,5,50K解析为5;分组时,5,50K解析为550000)。

如果我们将此信息压缩为一组助手方法,则获得以下输出:

public static Number parseLocale(Locale locale, String number) 
    throws ParseException {

  return parse(locale, Style.SHORT, false, number);
}

public static Number parseLocaleStyle(
  Locale locale, Style style, String number) throws ParseException {

  return parse(locale, style, false, number);
}

public static Number parseLocaleStyleRound(
  Locale locale, Style style, boolean grouping, String number)
    throws ParseException {

  return parse(locale, style, grouping, number);
}

private static Number parse(
  Locale locale, Style style, boolean grouping, String number)
    throws ParseException {

  if (locale == null || style == null || number == null) {
    throw new IllegalArgumentException(
      "Locale/style/number cannot be null");
  }

  NumberFormat nf = NumberFormat.getCompactNumberInstance(locale, 
    style);
  nf.setGroupingUsed(grouping);

  return nf.parse(number);
}

让我们将5K5 thousand解析为5000,而不显式分组:

// 5000
NumberFormatters.parseLocaleStyle(Locale.US, Style.SHORT, "5K");

// 5000
NumberFormatters.parseLocaleStyle(Locale.US, Style.LONG, "5 thousand");

现在,我们用显式分组解析5K5 thousand5000

// 550000
NumberFormatters.parseLocaleStyleRound(
  Locale.US, Style.SHORT, true, "5,50K");

// 550000
NumberFormatters.parseLocaleStyleRound(
  Locale.US, Style.LONG, true, "5,50 thousand");

通过setCurrency​()setParseIntegerOnly()setMaximumIntegerDigits()setMinimumIntegerDigits()setMinimumFractionDigits()setMaximumFractionDigits()方法可以获得更多的调优。

总结

本章收集了一系列涉及字符串和数字的最常见问题。显然,这样的问题有很多,试图涵盖所有这些问题远远超出了任何一本书的范围。然而,了解如何解决本章中提出的问题,为您自己解决许多其他相关问题提供了坚实的基础

从本章下载应用以查看结果和其他详细信息。

二、对象、不变性和switch表达式

本章包括 18 个涉及对象、不变性和switch表达式的问题。本章从处理null引用的几个问题入手。它继续处理有关检查索引、equals()hashCode()以及不变性(例如,编写不可变类和从不可变类传递/返回可变对象)的问题。本章的最后一部分讨论了克隆对象和 JDK12switch表达式。本章结束时,您将掌握对象和不变性的基本知识。此外,你将知道如何处理新的switch表达式。在任何 Java 开发人员的武库中,这些都是有价值的、非可选的知识。

问题

使用以下问题来测试您的对象、不变性和switch表达式编程能力。我强烈建议您在转向解决方案和下载示例程序之前,尝试一下每个问题:

  1. 使用命令式代码检查null函数式引用:编写程序,对给定的函数式引用和命令式代码进行null检查。
  2. 检查null引用并抛出一个定制的NullPointerException错误:编写一个程序,对给定的引用执行null检查并抛出带有定制消息的NullPointerException
  3. 检查null引用并抛出指定的异常(例如,IllegalArgumentException:编写一个程序,对给定的引用执行null检查并抛出指定的异常。
  4. 检查null引用并返回非null默认引用:编写程序,对给定引用执行null检查,如果是非null,则返回;否则返回非null默认引用。
  5. 检查从 0 到长度范围内的索引:编写一个程序,检查给定索引是否在 0(含)到给定长度(不含)之间。如果给定索引超出 0 到给定长度的范围,则抛出IndexOutOfBoundsException
  6. 检查从 0 到长度范围内的子范围:编写一个程序,检查给定的开始到给定的结束的给定的子范围,是否在 0 到给定的长度的范围内。如果给定的子范围不在范围内,则抛出IndexOutOfBoundsException
  7. 解释equals()hashCode()并举例说明equals()hashCode()方法在 Java 中是如何工作的。
  8. 不可变对象概述:解释并举例说明什么是 Java 中的不可变对象。
  9. 不可变字符串:解释String类不可变的原因。
  10. 编写不可变类:写一个表示不可变类的程序。
  11. 向不可变类传递或从不可变类返回可变对象:编写一个程序,向不可变类传递或从不可变类返回可变对象。
  12. 通过构建器模式编写一个不可变类:编写一个表示不可变类中构建器模式实现的程序。51. 避免不可变对象中的坏数据:编写防止不可变对象中的坏数据的程序。
  13. 克隆对象:编写一个程序,演示浅层和深层克隆技术。
  14. 覆盖toString():解释并举例说明覆盖toString()的实践。
  15. switch表达式:简要概述 JDK12 中的switch表达式。
  16. 多个case标签:写一段代码,用多个case标签举例说明 JDK12switch
  17. 语句块:编写一段代码,用于举例说明 JDK12 switch,其中的case标签指向花括号块。

以下各节介绍上述每个问题的解决方案。记住,通常没有一个正确的方法来解决一个特定的问题。另外,请记住,这里显示的解释仅包括解决问题所需的最有趣和最重要的细节。下载示例解决方案以查看更多详细信息,并尝试程序

40 在函数式和命令式代码中检查空引用

与函数样式或命令式代码无关,检查null引用是一种常用且推荐的技术,用于减少著名的NullPointerException异常的发生。这种检查被大量用于方法参数,以确保传递的引用不会导致NullPointerException或意外行为。

例如,将List<Integer>传递给方法可能需要至少两个null检查。首先,该方法应该确保列表引用本身不是null。其次,根据列表的使用方式,该方法应确保列表不包含null对象:

List<Integer> numbers 
  = Arrays.asList(1, 2, null, 4, null, 16, 7, null);

此列表将传递给以下方法:

public static List<Integer> evenIntegers(List<Integer> integers) {

  if (integers == null) {
    return Collections.EMPTY_LIST;
  }

  List<Integer> evens = new ArrayList<>();
  for (Integer nr: integers) {
    if (nr != null && nr % 2 == 0) {
      evens.add(nr);
    }
  }

  return evens;
}

注意,前面的代码使用依赖于==!=运算符(integers==nullnr !=null的经典检查。从 JDK8 开始,java.util.Objects类包含两个方法,它们基于这两个操作符包装null检查:object == null包装在Objects.isNull()中,object != null包装在Objects.nonNull()中。

基于这些方法,前面的代码可以重写如下:

public static List<Integer> evenIntegers(List<Integer> integers) {

  if (Objects.isNull(integers)) {
    return Collections.EMPTY_LIST;
  }

  List<Integer> evens = new ArrayList<>();

  for (Integer nr: integers) {
    if (Objects.nonNull(nr) && nr % 2 == 0) {
      evens.add(nr);
    }
  }

  return evens;
}

现在,代码在某种程度上更具表现力,但这并不是这两种方法的主要用法。实际上,这两个方法是为了另一个目的(符合 API 注解)而添加的——在 Java8 函数式代码中用作谓词。在函数式代码中,null检查可以如下例所示完成:

public static int sumIntegers(List<Integer> integers) {

  if (integers == null) {
    throw new IllegalArgumentException("List cannot be null");
  }

  return integers.stream()
    .filter(i -> i != null)
    .mapToInt(Integer::intValue).sum();
}

public static boolean integersContainsNulls(List<Integer> integers) {

  if (integers == null) {
    return false;
  }

  return integers.stream()
    .anyMatch(i -> i == null);
}

很明显,i -> i != nulli -> i == null的表达方式与周围的代码不一样。让我们用Objects.nonNull()Objects.isNull()替换这些代码片段:

public static int sumIntegers(List<Integer> integers) {

  if (integers == null) {
    throw new IllegalArgumentException("List cannot be null");
  }

  return integers.stream()
    .filter(Objects::nonNull)
    .mapToInt(Integer::intValue).sum();
}

public static boolean integersContainsNulls(List<Integer> integers) {

  if (integers == null) {
    return false;
  }

  return integers.stream()
    .anyMatch(Objects::isNull);
}

或者,我们也可以使用Objects.nonNull()Objects.isNull()方法作为参数:

public static int sumIntegers(List<Integer> integers) {

  if (Objects.isNull(integers)) {
    throw new IllegalArgumentException("List cannot be null");
  }

  return integers.stream()
    .filter(Objects::nonNull)
    .mapToInt(Integer::intValue).sum();
}

public static boolean integersContainsNulls(List<Integer> integers) {

  if (Objects.isNull(integers)) {
    return false;
  }

  return integers.stream()
    .anyMatch(Objects::isNull);
}

令人惊叹的!因此,作为结论,无论何时需要进行null检查,函数式代码都应该依赖于这两种方法,而在命令式代码中,这是一种偏好。

41 检查空引用并引发自定义的NullPointerException

检查null引用并用定制消息抛出NullPointerException可以使用以下代码完成(此代码执行这四次,在构造器中执行两次,在assignDriver()方法中执行两次):

public class Car {

  private final String name;
  private final Color color;

  public Car(String name, Color color) {

    if (name == null) {
      throw new NullPointerException("Car name cannot be null");
    }

    if (color == null) {
      throw new NullPointerException("Car color cannot be null");
    }

    this.name = name;
    this.color = color;
  }

  public void assignDriver(String license, Point location) {

    if (license == null) {
      throw new NullPointerException("License cannot be null");
    }

    if (location == null) {
      throw new NullPointerException("Location cannot be null");
    }
  }
}

因此,这段代码通过结合==操作符和NullPointerException类的手动实例化来解决这个问题。从 JDK7 开始,这种代码组合隐藏在一个名为Objects.requireNonNull()static方法中。通过这种方法,前面的代码可以用表达的方式重写:

public class Car {

  private final String name;
  private final Color color;

  public Car(String name, Color color) {

    this.name = Objects.requireNonNull(name, "Car name cannot be 
      null");
    this.color = Objects.requireNonNull(color, "Car color cannot be 
      null");
  }

  public void assignDriver(String license, Point location) {

    Objects.requireNonNull(license, "License cannot be null");
    Objects.requireNonNull(location, "Location cannot be null");
  }
}

因此,如果指定的引用是null,那么Objects.requireNonNull()将抛出一个包含所提供消息的NullPointerException。否则,它将返回选中的引用。

在构造器中,当提供的引用是null时,有一种典型的抛出NullPointerException的方法。但在方法上(例如,assignDriver()),这是一个有争议的方法。一些开发人员更喜欢返回一个无害的结果或者抛出IllegalArgumentException。下一个问题,检查空引用并抛出指定的异常(例如,IllegalArgumentException),解决了IllegalArgumentException方法。

在 JDK7 中,有两个Objects.requireNonNull()方法,一个是以前使用的,另一个是抛出带有默认消息的NullPointerException,如下例所示:

this.name = Objects.requireNonNull(name);

从 JDK8 开始,还有一个Objects.requireNonNull()。这个将NullPointerException的自定义消息封装在Supplier中。这意味着消息创建被推迟,直到给定的引用是null(这意味着使用+操作符连接消息的各个部分不再是一个问题)。

举个例子:

this.name = Objects.requireNonNull(name, () 
  -> "Car name cannot be null ... Consider one from " + carsList);

如果此引用不是null,则不创建消息。

42 检查空引用并引发指定的异常

当然,一种解决方案需要直接依赖于==操作符,如下所示:

if (name == null) {
  throw new IllegalArgumentException("Name cannot be null");
}

因为没有requireNonNullElseThrow()方法,所以这个问题不能用java.util.Objects的方法来解决。抛出IllegalArgumentException或其他指定的异常可能需要一组方法,如下面的屏幕截图所示:

让我们关注一下requireNonNullElseThrowIAE()方法。这两个方法抛出IllegalArgumentException,其中一个自定义消息被指定为StringSupplier(在null被求值为true之前避免创建):

public static <T> T requireNonNullElseThrowIAE(
    T obj, String message) {

  if (obj == null) {
    throw new IllegalArgumentException(message);
  }

  return obj;
}

public static <T> T requireNonNullElseThrowIAE(T obj,
    Supplier<String> messageSupplier) {

  if (obj == null) {
    throw new IllegalArgumentException(messageSupplier == null 
      ? null : messageSupplier.get());
  }

  return obj;
}

所以,投掷IllegalArgumentException可以通过这两种方法来完成。但这还不够。例如,代码可能需要抛出IllegalStateExceptionUnsupportedOperationException等。对于这种情况,最好采用以下方法:

public static <T, X extends Throwable> T requireNonNullElseThrow(
    T obj, X exception) throws X {

  if (obj == null) {
    throw exception;
  }

  return obj;
}

public static <T, X extends Throwable> T requireNotNullElseThrow(
    T obj, Supplier<<? extends X> exceptionSupplier) throws X {

  if (obj != null) {
    return obj;
  } else {
    throw exceptionSupplier.get();
  }
}

考虑将这些方法添加到名为MyObjects的助手类中。如以下示例所示调用这些方法:

public Car(String name, Color color) {

  this.name = MyObjects.requireNonNullElseThrow(name,
    new UnsupportedOperationException("Name cannot be set as null"));
  this.color = MyObjects.requireNotNullElseThrow(color, () ->
    new UnsupportedOperationException("Color cannot be set as null"));
}

此外,我们也可以通过这些例子来丰富MyObjects中的其他异常。

43 检查空引用并返回非空默认引用

通过if-else(或三元运算符)可以很容易地提供该问题的解决方案,如以下示例所示(作为变体,namecolor可以声明为非final,并在声明时用默认值初始化):

public class Car {

  private final String name;
  private final Color color;
  public Car(String name, Color color) {

    if (name == null) {
      this.name = "No name";
    } else {
      this.name = name;
    }

    if (color == null) {
      this.color = new Color(0, 0, 0);
    } else {
      this.color = color;
    }
  }
}

但是,从 JDK9 开始,前面的代码可以通过Objects类的两个方法简化。这些方法是requireNonNullElse()requireNonNullElseGet()。它们都有两个参数,一个是检查空值的引用,另一个是在检查的引用为null时返回的非null默认引用:

public class Car {

  private final String name;
  private final Color color;

  public Car(String name, Color color) {

    this.name = Objects.requireNonNullElse(name, "No name");
    this.color = Objects.requireNonNullElseGet(color,
      () -> new Color(0, 0, 0));
  }
}

在前面的示例中,这些方法在构造器中使用,但也可以在方法中使用。

44 检查从 0 到长度范围内的索引

首先,让我们用一个简单的场景来突出这个问题。此场景可能在以下简单类中实现:

public class Function {

  private final int x;

  public Function(int x) {

    this.x = x;
  }

  public int xMinusY(int y) {

    return x - y;
  }

  public static int oneMinusY(int y) {

    return 1 - y;
  }
}

注意,前面的代码片段没有对xy进行任何范围限制。现在,让我们施加以下范围(这在数学函数中非常常见):

  • x必须介于 0(含)和 11(不含)之间,所以x属于[0, 11)
  • xMinusY()方法中,y必须在 0(含)x(不含)之间,所以y属于[0, x)
  • oneMinusY()方法中,y必须介于 0(包含)和 16(排除)之间,所以y属于[0, 16)

这些范围可以通过if语句在代码中施加,如下所示:

public class Function {

  private static final int X_UPPER_BOUND = 11;
  private static final int Y_UPPER_BOUND = 16;
  private final int x;

  public Function(int x) {

    if (x < 0 || x >= X_UPPER_BOUND) {
      throw new IndexOutOfBoundsException("..."); 
    }

    this.x = x;
  }

  public int xMinusY(int y) {

    if (y < 0 || y >= x) {
      throw new IndexOutOfBoundsException("...");
    }

    return x - y;
  }

  public static int oneMinusY(int y) {

    if (y < 0 || y >= Y_UPPER_BOUND) {
      throw new IndexOutOfBoundsException("...");
    }

    return 1 - y;
  }
}

考虑用更有意义的异常替换IndexOutOfBoundsException(例如,扩展IndexOutOfBoundsException并创建一个类型为RangeOutOfBoundsException的自定义异常)。

从 JDK9 开始,可以重写代码以使用Objects.checkIndex()方法。此方法验证给定索引是否在 0 到长度的范围内,并返回该范围内的给定索引或抛出IndexOutOfBoundsException

public class Function {

  private static final int X_UPPER_BOUND = 11;
  private static final int Y_UPPER_BOUND = 16;
  private final int x;

  public Function(int x) {

    this.x = Objects.checkIndex(x, X_UPPER_BOUND);
  }

  public int xMinusY(int y) {

    Objects.checkIndex(y, x);

    return x - y;
  }

  public static int oneMinusY(int y) {

    Objects.checkIndex(y, Y_UPPER_BOUND);

    return 1 - y;
  }
}

例如,调用oneMinusY(),如下一个代码片段所示,将导致IndexOutOfBoundsException,因为y可以取[0, 16]之间的值:

int result = Function.oneMinusY(20);

现在,让我们进一步检查从 0 到给定长度的子范围。

45 检查从 0 到长度范围内的子范围

让我们遵循上一个问题的相同流程。所以,这一次,Function类将如下所示:

public class Function {

  private final int n;

  public Function(int n) {

    this.n = n;
  }

  public int yMinusX(int x, int y) {

    return y - x;
  }
}

注意,前面的代码片段没有对xyn进行任何范围限制。现在,让我们施加以下范围:

  • n必须介于 0(含)和 101(不含)之间,所以n属于[0, 101]
  • yMinusX()方法中,由xyxy限定的范围必须是[0, n]的子范围。

这些范围可以通过if语句在代码中施加,如下所示:

public class Function {

  private static final int N_UPPER_BOUND = 101;
  private final int n;

  public Function(int n) {

    if (n < 0 || n >= N_UPPER_BOUND) {
      throw new IndexOutOfBoundsException("...");
    }

    this.n = n;
  }

  public int yMinusX(int x, int y) {

    if (x < 0 || x > y || y >= n) {
      throw new IndexOutOfBoundsException("...");
    }

    return y - x;
  }
}

基于前面的问题,n的条件可以替换为Objects.checkIndex()。此外,JDK9Objects类还提供了一个名为checkFromToIndex(int start, int end, int length)的方法,该方法检查给定的子范围给定的开始给定的结束是否在 0 到给定的长度的范围内。因此,此方法可应用于yMinusX()方法,以检查xy所限定的范围是否为 0 到n的子范围:

public class Function {

  private static final int N_UPPER_BOUND = 101;
  private final int n;

  public Function(int n) {

    this.n = Objects.checkIndex(n, N_UPPER_BOUND);
  }

  public int yMinusX(int x, int y) {

    Objects.checkFromToIndex(x, y, n);
    return y - x;
  }
}

例如,由于x大于y,下面的测试将导致IndexOutOfBoundsException

Function f = new Function(50);
int r = f.yMinusX(30, 20);

除了这个方法之外,Objects还有另一个名为checkFromIndexSize(int start, int size, int length)的方法。该方法检查给定开始时间给定开始时间加给定大小的子范围,是否在 0 到给定长度的范围内。

46 equals()hashCode()

equals()hashCode()方法在java.lang.Object中定义。因为Object是所有 Java 对象的超类,所以这两种方法对所有对象都可用。他们的主要目标是为比较对象提供一个简单、高效、健壮的解决方案,并确定它们是否相等。如果没有这些方法和它们的契约,解决方案依赖于庞大而繁琐的if语句来比较对象的每个字段。

当这些方法没有被覆盖时,Java 将使用它们的默认实现。不幸的是,默认实现并不能真正实现确定两个对象是否具有相同值的目标。默认情况下,equals()检查相等性。换言之,当且仅当两个对象由相同的内存地址(相同的对象引用)表示时,它认为这两个对象相等,而hashCode()返回对象内存地址的整数表示。这是一个本机函数,称为标识**哈希码。

例如,假设以下类:

public class Player {

  private int id;
  private String name;

  public Player(int id, String name) {

    this.id = id;
    this.name = name;
  }
}

然后,让我们创建包含相同信息的此类的两个实例,并比较它们是否相等:

Player p1 = new Player(1, "Rafael Nadal");
Player p2 = new Player(1, "Rafael Nadal");

System.out.println(p1.equals(p2)); // false
System.out.println("p1 hash code: " + p1.hashCode()); // 1809787067
System.out.println("p2 hash code: " + p2.hashCode()); // 157627094

不要使用==运算符来测试对象的相等性(避免使用if(p1 == p2)==操作符比较两个对象的引用是否指向同一个对象,而equals()比较对象值(作为人类,这是我们关心的)。

根据经验,如果两个变量拥有相同的引用,则它们相同,但是如果它们引用相同的值,则它们相等相同值的含义由equals()定义。

对我们来说,p1p2是相等的,但是请注意equals()返回了falsep1p2实例的字段值完全相同,但是它们存储在不同的内存地址)。这意味着依赖于equals()的默认实现是不可接受的。解决方法是覆盖此方法,为此,重要的是要了解equals()合同,该合同规定了以下声明:

  • 自反性:对象等于自身,即p1.equals(p1)必须返回true
  • 对称性p1.equals(p2)必须返回与p2.equals(p1)相同的结果(true/false)。
  • 传递性:如果是p1.equals(p2)p2.equals(p3),那么也是p1.equals(p3)
  • 一致性:两个相等的物体必须一直保持相等,除非其中一个改变。
  • null返回false:所有对象必须不等于null

因此,为了遵守此约定,Player类的equals()方法可以覆盖如下:

@Override
public boolean equals(Object obj) {

  if (this == obj) {
    return true;
  }

  if (obj == null) {
    return false;
  }

  if (getClass() != obj.getClass()) {
    return false;
  }

  final Player other = (Player) obj;

  if (this.id != other.id) {
    return false;
  }

  if (!Objects.equals(this.name, other.name)) {
    return false;
  }

  return true;
}

现在,让我们再次执行相等性测试(这次,p1等于p2

System.out.println(p1.equals(p2)); // true

好的,到目前为止还不错!现在,让我们将这两个Player实例添加到集合中。例如,让我们将它们添加到一个HashSet(一个不允许重复的 Java 集合):

Set<Player> players = new HashSet<>();
players.add(p1);
players.add(p2);

让我们检查一下这个HashSet的大小以及它是否包含p1

System.out.println("p1 hash code: " + p1.hashCode()); // 1809787067
System.out.println("p2 hash code: " + p2.hashCode()); // 157627094
System.out.println("Set size: " + players.size());    // 2
System.out.println("Set contains Rafael Nadal: "
  + players.contains(new Player(1, "Rafael Nadal"))); // false

与前面实现的equals()一致,p1p2是相等的,因此HashSet的大小应该是 1,而不是 2。此外,它应该包含纳达尔。那么,发生了什么?

一般的答案在于 Java 是如何创建的。凭直觉很容易看出,equals()不是一种快速的方法;因此,当需要大量的相等比较时,查找将面临性能损失。例如,在通过集合中的特定值(例如,HashSetHashMapHashTable进行查找的情况下,这增加了一个严重的缺点,因为它可能需要大量的相等比较。

基于这个语句,Java 试图通过添加来减少相等比较。桶是一个基于散列的容器,它将相等的对象分组。这意味着相等的对象应该返回相同的哈希码,而不相等的对象应该返回不同的哈希码(如果两个不相等的对象具有相同的哈希码,则这是一个散列冲突,并且对象将进入同一个桶)。因此,Java 会比较散列代码,只有当两个不同的对象引用的散列代码相同(而不是相同的对象引用)时,它才会进一步调用equals()。基本上,这会加速集合中的查找。

但我们的案子发生了什么?让我们一步一步来看看:

  • 当创建p1时,Java 将根据p1内存地址为其分配一个哈希码。
  • p1被添加到Set时,Java 会将一个新的桶链接到p1哈希码。
  • 当创建p2时,Java 将根据p2内存地址为其分配一个哈希码。
  • p2被添加到Set时,Java 会将一个新的桶链接到p2哈希码(当这种情况发生时,看起来HashSet没有按预期工作,它允许重复)。
  • 当执行players.contains(new Player(1, "Rafael Nadal"))时,基于p3存储器地址用新的哈希码创建新的播放器p3
  • 因此,在contains()的框架中,分别测试p1p3 p2p3的相等性涉及检查它们的哈希码,由于p1哈希码不同于p3哈希码,而p2哈希码不同于p3哈希码,比较停止,没有求值equals(),这意味着HashSet不包含对象(p3

为了回到正轨,代码也必须覆盖hashCode()方法。hashCode()合同规定如下:

  • 符合equals()的两个相等对象必须返回相同的哈希码。
  • 具有相同哈希码的两个对象不是强制相等的。
  • 只要对象保持不变,hashCode()必须返回相同的值。

根据经验,为了尊重equals()hashCode()合同,遵循两条黄金法则:

  • equals()被覆盖时,hashCode()也必须被覆盖,反之亦然。
  • 以相同的顺序对两个方法使用相同的标识属性。

对于Player类,hashCode()可以被覆盖如下:

@Override
public int hashCode() {

  int hash = 7;
  hash = 79 * hash + this.id;
  hash = 79 * hash + Objects.hashCode(this.name);

  return hash;
}

现在,让我们执行另一个测试(这次,它按预期工作):

System.out.println("p1 hash code: " + p1.hashCode()); // -322171805
System.out.println("p2 hash code: " + p2.hashCode()); // -322171805
System.out.println("Set size: " + players.size());    // 1
System.out.println("Set contains Rafael Nadal: "
  + players.contains(new Player(1, "Rafael Nadal"))); // true

现在,让我们列举一下使用equals()hashCode()时的一些常见错误:

  • 您覆盖了equals()并忘记覆盖hashCode(),反之亦然(覆盖两者或无)。

  • 您使用==运算符而不是equals()来比较对象值。

  • equals()中,省略以下一项或多项:

    • 从添加自检if (this == obj)...开始。
    • 因为没有实例应该等于null,所以继续添加空校验if(obj == null)...)。
    • 确保实例是我们期望的(使用getClass()instanceof
    • 最后,在这些角落案例之后,添加字段比较。
  • 你通过继承来破坏对称。假设一个类A和一个类B扩展了A并添加了一个新字段。B类覆盖从A继承的equals()实现,并将此实现添加到新字段中。依赖instanceof会发现b.equals(a)会返回false(如预期),而a.equals(b)会返回true(非预期),因此对称性被破坏。依赖切片比较是行不通的,因为这会破坏及物性和自反性。解决这个问题意味着依赖于getClass()而不是instanceof(通过getClass(),类型及其子类型的实例不能相等),或者更好地依赖于组合而不是继承,就像绑定到本书中的应用(P46_ViolateEqualsViaSymmetry一样)。

  • 返回一个来自hashCode()的常量,而不是每个对象的唯一哈希码。

自 JDK7 以来,Objects类提供了几个帮助程序来处理对象相等和哈希码,如下所示:

  • Objects.equals(Object a, Object b):测试a对象是否等于b对象。
  • Objects.deepEquals(Object a, Object b):用于测试两个对象是否相等(如果是数组,则通过Arrays.deepEquals()进行测试)。
  • Objects.hash(Object ... values):为输入值序列生成哈希码。

通过EqualsVerifier库(确保equals()hashCode()尊重 Java SE 合同)。

依赖Lombok库从对象的字段生成hashCode()equals()。但请注意Lombok与 JPA 实体结合的特殊情况。

47 不可变对象简述

不可变对象是一个一旦创建就不能更改的对象(其状态是固定的)。

在 Java 中,以下内容适用:

  • 原始类型是不可变的。
  • 著名的 JavaString类是不可变的(其他类也是不可变的,比如PatternLocalDate
  • 数组不是不变的。
  • 集合可以是可变的、不可修改的或不可变的。

不可修改的集合不是自动不变的。它取决于集合中存储的对象。如果存储的对象是可变的,那么集合是可变的和不可修改的。但是如果存储的对象是不可变的,那么集合实际上是不可变的。

不可变对象在并发(多线程)应用和流中很有用。由于不可变对象不能更改,因此它们无法处理并发问题,并且不会有损坏或不一致的风险。

使用不可变对象的一个主要问题与创建新对象的代价有关,而不是管理可变对象的状态。但是请记住,不可变对象在垃圾收集期间利用了特殊处理。此外,它们不容易出现并发问题,并且消除了管理可变对象状态所需的代码。管理可变对象状态所需的代码往往比创建新对象慢。

通过研究以下问题,我们可以更深入地了解 Java 中的对象不变性。

48 不可变字符串

每种编程语言都有一种表示字符串的方法。作为基本类型,字符串是预定义类型的一部分,几乎所有类型的 Java 应用都使用它们。

在 Java 中,字符串不是由一个像intlongfloat这样的原始类型来表示的。它们由名为String的引用类型表示。几乎所有 Java 应用都使用字符串,例如,Java 应用的main()方法获取一个String类型的数组作为参数。

String的臭名昭著及其广泛的应用意味着我们应该详细了解它。除了知道如何声明和操作字符串(例如,反转和大写)之外,开发人员还应该理解为什么这个类是以特殊或不同的方式设计的。更确切地说,String为什么是不可变的?或者这个问题有一个更好的共鸣,比如说,String不变的利弊是什么?

字符串不变性的优点

在下一节中,我们来看看字符串不变性的一些优点。

字符串常量池或缓存池

支持字符串不变性的原因之一是由字符串常量池SCP)或缓存池表示的。为了理解这种说法,让我们深入了解一下String类是如何在内部工作的。

SCP 是内存中的一个特殊区域(不是普通的堆内存),用于存储字符串文本。假设以下三个String变量:

String x = "book";
String y = "book";
String z = "book";

创建了多少个String对象?说三个很有诱惑力,但实际上 Java 只创建一个具有"book"值的String对象。其思想是,引号之间的所有内容都被视为一个字符串文本,Java 通过遵循这样的算法(该算法称为字符串内化)将字符串文本存储在称为 SCP 的特殊内存区域中:

  • 当一个字符串文本被创建时(例如,String x = "book"),Java 检查 SCP 以查看这个字符串文本是否存在。
  • 如果在 SCP 中找不到字符串字面值,则在 SCP 中为字符串字面值创建一个新的字符串对象,并且相应的变量x将指向它。
  • 如果在 SCP 中找到字符串字面值(例如,String y = "book"String z = "book"),那么新变量将指向String对象(基本上,所有具有相同值的变量都将指向相同的String对象):

但是x应该是"cook"而不是"book",所以我们用"c"-x = x.replace("b", "c");来代替"b"

x应该是"cook"yz应该保持不变。这种行为是由不变性提供的。Java 将创建一个新对象,并对其执行如下更改:

因此,字符串不变性允许缓存字符串文本,这允许应用使用大量字符串文本,对堆内存和垃圾收集器的影响最小。在可变上下文中,修改字符串字面值可能导致变量损坏。

不要创建一个字符串作为String x = new String("book")。这不是字符串文本;这是一个String实例(通过构造器构建),它将进入普通内存堆而不是 SCP。在普通堆内存中创建的字符串可以通过显式调用String.intern()方法作为x.intern()指向 SCP。

安全

字符串不变性的另一个好处是它的安全性。通常,许多敏感信息(用户名、密码、URL、端口、数据库、套接字连接、参数、属性等)都以字符串的形式表示和传递。通过使这些信息保持不变,代码对于各种安全威胁(例如,意外或故意修改引用)变得安全。

线程安全性

想象一个应用使用成千上万个可变的String对象并处理线程安全代码。幸运的是,在这种情况下,由于不变性,我们想象的场景不会变成现实。任何不可变对象本质上都是线程安全的。这意味着字符串可以由多个线程共享和操作,没有损坏和不一致的风险。

哈希码缓存

equals()hashCode()部分讨论了equals()hashCode()。每次对特定活动进行哈希运算(例如,搜索集合中的元素)时,都应该计算哈希码。因为String是不可变的,所以每个字符串都有一个不可变的哈希码,可以缓存和重用,因为它在创建字符串后不能更改。这意味着可以从缓存中使用字符串的哈希码,而不是每次使用时重新计算它们。例如,HashMap为不同的操作(例如,put()get())散列其键,如果这些键属于String类型,则哈希码将从缓存中重用,而不是重新计算它们。

类加载

在内存中加载类的典型方法依赖于调用Class.forName(String className)方法。注意表示类名的参数String。由于字符串不变性,在加载过程中不能更改类名。然而,如果String是可变的,那么想象加载class A(例如,Class.forName("A")),在加载过程中,它的名称将被更改为BadA。现在,BadA物体可以做坏事!

字符串不变性的缺点

在下一节中,我们来看看字符串不变性的一些缺点。

字符串不能扩展

应该声明一个不可变的类final,以避免扩展性。然而,开发人员需要扩展String类以添加更多的特性,这一限制可以被认为是不变性的一个缺点。

然而,开发人员可以编写工具类(例如,Apache Commons Lang、StringUtils、Spring 框架、StringUtils、Guava 和字符串)来提供额外的特性,并将字符串作为参数传递给这些类的方法。

敏感数据长时间存储在内存中

字符串中的敏感数据(例如密码)可能长时间驻留在内存(SCP)中。作为缓存,SCP 利用了来自垃圾收集器的特殊处理。更准确地说,垃圾收集器不会以与其他内存区域相同的频率(周期)访问 SCP。作为这种特殊处理的结果,敏感数据在 SCP 中保存了很长一段时间,并且很容易被不必要的使用。

为了避免这一潜在缺陷,建议将敏感数据(例如密码)存储在char[]而不是String中。

OutOfMemoryError错误

SCP 是一个很小的内存区,可以很快被填满。在 SCP 中存储过多的字符串字面值将导致OutOfMemoryError

字符串是完全不变的吗?

在幕后,String使用private final char[]来存储字符串的每个字符。通过使用 Java 反射 API,在 JDK8 中,以下代码将修改此char[](JDK11 中的相同代码将抛出java.lang.ClassCastException):

String user = "guest";
System.out.println("User is of type: " + user);

Class<String> type = String.class;
Field field = type.getDeclaredField("value");
field.setAccessible(true);

char[] chars = (char[]) field.get(user);

chars[0] = 'a';
chars[1] = 'd';
chars[2] = 'm';
chars[3] = 'i';
chars[4] = 'n';

System.out.println("User is of type: " + user);

因此,在 JDK8 中,String有效不可变的,但不是完全

49 编写不可变类

一个不可变的类必须满足几个要求,例如:

  • 该类应标记为final以抑制可扩展性(其他类不能扩展该类;因此,它们不能覆盖方法)
  • 所有字段都应该声明为privatefinal(在其他类中不可见,在这个类的构造器中只初始化一次)
  • 类应该包含一个参数化的public构造器(或者一个private构造器和用于创建实例的工厂方法),用于初始化字段
  • 类应该为字段提供获取器
  • 类不应公开设置器

例如,以下Point类是不可变的,因为它成功地通过了前面的检查表:

public final class Point {

  private final double x;
  private final double y;

  public Point(double x, double y) {
    this.x = x;
    this.y = y;
  }

  public double getX() {
    return x;
  }

  public double getY() {
    return y;
  }
}

如果不可变类应该操作可变对象,请考虑以下问题。

50 向不可变类传递/从不可变类返回可变对象

将可变对象传递给不可变类可能会破坏不可变性。让我们考虑以下可变类:

public class Radius {

  private int start;
  private int end;

  public int getStart() {
    return start;
  }

  public void setStart(int start) {
    this.start = start;
  }

  public int getEnd() {
    return end;
  }

  public void setEnd(int end) {
    this.end = end;
  }
}

然后,让我们将这个类的一个实例传递给一个名为Point的不可变类。乍一看,Point类可以写为:

public final class Point {

  private final double x;
  private final double y;
  private final Radius radius;

  public Point(double x, double y, Radius radius) {
    this.x = x;
    this.y = y;
    this.radius = radius;
  }

  public double getX() {
    return x;
  }

  public double getY() {
    return y;
  }

  public Radius getRadius() {
    return radius;
  }
}

这个类仍然是不变的吗?答案是否定的,Point类不再是不变的,因为它的状态可以改变,如下例所示:

Radius r = new Radius();
r.setStart(0);
r.setEnd(120);

Point p = new Point(1.23, 4.12, r);

System.out.println("Radius start: " + p.getRadius().getStart()); // 0
r.setStart(5);
System.out.println("Radius start: " + p.getRadius().getStart()); // 5

注意,调用p.getRadius().getStart()返回两个不同的结果;因此,p的状态已经改变,所以Point不再是不可变的。该问题的解决方案是克隆Radius对象并将克隆存储为Point的字段:

public final class Point {

  private final double x;
  private final double y;
  private final Radius radius;

  public Point(double x, double y, Radius radius) {
    this.x = x;
    this.y = y;

    Radius clone = new Radius();
    clone.setStart(radius.getStart());
    clone.setEnd(radius.getEnd());

    this.radius = clone;
  }

  public double getX() {
    return x;
  }

  public double getY() {
    return y;
  }

  public Radius getRadius() {
    return radius;
  }
}

这一次,Point类的不变性级别增加了(调用r.setStart(5)不会影响radius字段,因为该字段是r的克隆)。但是Point类并不是完全不可变的,因为还有一个问题需要解决,从不可变类返回可变对象会破坏不可变性。检查下面的代码,它分解了Point的不变性:

Radius r = new Radius();
r.setStart(0);
r.setEnd(120);

Point p = new Point(1.23, 4.12, r);

System.out.println("Radius start: " + p.getRadius().getStart()); // 0
p.getRadius().setStart(5);
System.out.println("Radius start: " + p.getRadius().getStart()); // 5

再次调用p.getRadius().getStart()返回两个不同的结果;因此,p的状态已经改变。解决方案包括修改getRadius()方法以返回radius字段的克隆,如下所示:

...
public Radius getRadius() {
    Radius clone = new Radius();
    clone.setStart(this.radius.getStart());
    clone.setEnd(this.radius.getEnd());

    return clone;
  }
...

现在,Point类又是不可变的。问题解决了!

在选择克隆技术/工具之前,在某些情况下,建议您花点时间分析/学习 Java 和第三方库中可用的各种可能性(例如,检查本章中的”克隆对象“部分)。对于浅拷贝,前面的技术可能是正确的选择,但是对于深拷贝,代码可能需要依赖不同的方法,例如复制构造器、Cloneable接口或外部库(例如,Apache Commons LangObjectUtils、JSON 序列化与Gson或 Jackson,或任何其他方法)。

51 通过生成器模式编写不可变类

当一个类(不可变或可变)有太多字段时,它需要一个具有许多参数的构造器。当其中一些字段是必需的,而其他字段是可选的时,这个类将需要几个构造器来覆盖所有可能的组合。这对于开发人员和类的用户来说都是很麻烦的。这就是构建器模式的用武之地。

根据四人帮GoF),构建器模式将复杂对象的构造与其表示分离,以便相同的构造过程可以创建不同的表示

生成器模式可以作为一个单独的类或内部的static类来实现。让我们关注第二个案例。User类有三个必填字段(nicknamepasswordcreated)和三个可选字段(emailfirstnamelastname)。

现在,依赖于构建器模式的不可变的User类将显示如下:

public final class User {

  private final String nickname;
  private final String password;
  private final String firstname;
  private final String lastname;
  private final String email;
  private final Date created;

  private User(UserBuilder builder) {
    this.nickname = builder.nickname;
    this.password = builder.password;
    this.created = builder.created;
    this.firstname = builder.firstname;
    this.lastname = builder.lastname;
    this.email = builder.email;
  }

  public static UserBuilder getBuilder(
      String nickname, String password) {
    return new User.UserBuilder(nickname, password);
  }

  public static final class UserBuilder {

    private final String nickname;
    private final String password;
    private final Date created;
    private String email;
    private String firstname;
    private String lastname;

    public UserBuilder(String nickname, String password) {
      this.nickname = nickname;
      this.password = password;
      this.created = new Date();
    }

    public UserBuilder firstName(String firstname) {
      this.firstname = firstname;
      return this;
    }

    public UserBuilder lastName(String lastname) {
      this.lastname = lastname;
      return this;
    }

    public UserBuilder email(String email) {
      this.email = email;
      return this;
    }

    public User build() {
      return new User(this);
    }
  }

  public String getNickname() {
    return nickname;
  }

  public String getPassword() {
    return password;
  }

  public String getFirstname() {
    return firstname;
  }

  public String getLastname() {
    return lastname;
  }

  public String getEmail() {
    return email;
  }

  public Date getCreated() {
    return new Date(created.getTime());
  }
}

以下是一些用法示例:

import static modern.challenge.User.getBuilder;
...
// user with nickname and password
User user1 = getBuilder("marin21", "hjju9887h").build();

// user with nickname, password and email
User user2 = getBuilder("ionk", "44fef22")
  .email("ion@gmail.com")
  .build();

// user with nickname, password, email, firstname and lastname
User user3 = getBuilder("monika", "klooi0988")
  .email("monika@gmail.com")
  .firstName("Monika")
  .lastName("Ghuenter")
  .build();

52 避免不可变对象中的坏数据

坏数据是任何对不可变对象有负面影响的数据(例如,损坏的数据)。最有可能的是,这些数据来自用户输入或不受我们直接控制的外部数据源。在这种情况下,坏数据可能会击中不可变的对象,最糟糕的是没有修复它的方法。不可变的对象在创建后不能更改;因此,只要对象存在,坏数据就会快乐地存在。

这个问题的解决方案是根据一组全面的约束来验证输入到不可变对象中的所有数据。

执行验证有不同的方法,从自定义验证到内置解决方案。验证可以在不可变对象类的外部或内部执行,具体取决于应用设计。例如,如果不可变对象是通过构建器模式构建的,那么可以在 Builder 类中执行验证。

JSR380 是用于 bean 验证的 Java API(JavaSE/EE)规范,可用于通过注解进行验证。Hibernate 验证器是验证 API 的参考实现,它可以很容易地作为 Maven 依赖项在pom.xml文件中提供(请查看本书附带的源代码)。

此外,我们依赖于专用注解来提供所需的约束(例如,@NotNull@Min@Max@Size@Email)。在以下示例中,将约束添加到生成器类中,如下所示:

...
public static final class UserBuilder {

  @NotNull(message = "cannot be null")
  @Size(min = 3, max = 20, message = "must be between 3 and 20 
    characters")
  private final String nickname;

  @NotNull(message = "cannot be null")
  @Size(min = 6, max = 50, message = "must be between 6 and 50 
    characters")
  private final String password;

  @Size(min = 3, max = 20, message = "must be between 3 and 20 
    characters")
  private String firstname;

  @Size(min = 3, max = 20, message = "must be between 3 and 20 
    characters")
  private String lastname;

  @Email(message = "must be valid")
  private String email;

  private final Date created;

  public UserBuilder(String nickname, String password) {
    this.nickname = nickname;
    this.password = password;
    this.created = new Date();
  }
...

最后,验证过程通过ValidatorAPI 从代码中触发(这仅在 JavaSE 中需要)。如果进入生成器类的数据无效,则不创建不可变对象(不要调用build()方法):

User user;
Validator validator 
  = Validation.buildDefaultValidatorFactory().getValidator();

User.UserBuilder userBuilder 
  = new User.UserBuilder("monika", "klooi0988")
    .email("monika@gmail.com")
    .firstName("Monika").lastName("Gunther");

final Set<ConstraintViolation<User.UserBuilder>> violations 
  = validator.validate(userBuilder);
if (violations.isEmpty()) {
  user = userBuilder.build();
  System.out.println("User successfully created on: " 
    + user.getCreated());
} else {
  printConstraintViolations("UserBuilder Violations: ", violations);
}

这样,坏数据就不能触及不可变的对象。如果没有生成器类,则可以直接在不可变对象的字段级别添加约束。前面的解决方案只是在控制台上显示潜在的冲突,但是根据情况,该解决方案可能执行不同的操作(例如,抛出特定的异常)。

53 克隆对象

克隆对象不是一项日常任务,但正确地克隆对象很重要。克隆对象主要是指创建对象的副本。拷贝主要有两种类型:拷贝(尽可能少拷贝)和拷贝(复制所有内容)。

假设下面的类:

public class Point {

  private double x;
  private double y;

  public Point() {}
  public Point(double x, double y) {
    this.x = x;
    this.y = y;
  }

  // getters and setters
}

所以,我们在一个类中映射了一个类型点(x, y)。现在,让我们进行一些克隆。

手动克隆

快速方法包括添加一个手动将当前Point复制到新Point的方法(这是一个浅复制):

public Point clonePoint() {
  Point point = new Point();
  point.setX(this.x);
  point.setY(this.y);

  return point;
}

这里的代码非常简单。只需创建一个新的Point实例,并用当前Point的字段填充其字段。返回的Point是当前Point的浅拷贝(因为Point不依赖其他对象,所以深拷贝是完全相同的):

Point point = new Point(...);
Point clone = point.clonePoint();

通过clone()克隆

Object类包含一个名为clone()的方法。此方法对于创建浅拷贝非常有用(也可以用于深拷贝)。为了使用它,类应该遵循给定的步骤:

  • 实现Cloneable接口(如果该接口没有实现,则抛出CloneNotSupportedException
  • 覆盖clone()方法(Object.clone()protected)。
  • 调用super.clone()

Cloneable接口不包含任何方法。这只是 JVM 可以克隆这个对象的一个信号。一旦实现了这个接口,代码就需要覆盖Object.clone()方法。这是需要的,因为Object.clone()protected,为了通过super调用它,代码需要覆盖这个方法。如果将clone()添加到子类中,这可能是一个严重的缺点,因为所有超类都应该定义一个clone()方法,以避免super.clone()链调用失败。

此外,Object.clone()不依赖构造器调用,因此开发人员无法控制对象构造:

public class Point implements Cloneable {

  private double x;
  private double y;

  public Point() {}

  public Point(double x, double y) {
    this.x = x;
    this.y = y;
  }

  @Override
  public Point clone() throws CloneNotSupportedException {
    return (Point) super.clone();
  }

  // getters and setters
}

创建克隆的步骤如下:

Point point = new Point(...);
Point clone = point.clone();

通过构造器克隆

此克隆技术要求您使用构造器来丰富类,该构造器接受表示将用于创建克隆的类实例的单个参数。

让我们看看代码:

public class Point {

  private double x;
  private double y;

  public Point() {}

  public Point(double x, double y) {
    this.x = x;
    this.y = y;
  }

  public Point(Point another) {
    this.x = another.x;
    this.y = another.y;
  }

  // getters and setters
}

创建克隆的步骤如下:

Point point = new Point(...);
Point clone = new Point(point);

通过克隆库进行克隆

当一个对象依赖于另一个对象时,需要一个深度副本。执行深度复制意味着复制对象,包括其依赖链。例如,假设Point有一个Radius类型的字段:

public class Radius {

  private int start;
  private int end;

  // getters and setters
}

public class Point {

  private double x;
  private double y;
  private Radius radius;

  public Point(double x, double y, Radius radius) {
    this.x = x;
    this.y = y;
    this.radius = radius;
  }

  // getters and setters
}

执行Point的浅拷贝将创建xy的拷贝,但不会创建radius对象的拷贝。这意味着影响radius对象的修改也将反映在克隆中。是时候进行深度复制了。

一个麻烦的解决方案将涉及到调整以前提出的浅拷贝技术以支持深拷贝。幸运的是,有一些现成的解决方案可以应用,其中之一就是克隆库

import com.rits.cloning.Cloner;
...
Point point = new Point(...);
Cloner cloner = new Cloner();
Point clone = cloner.deepClone(point);

代码是不言自明的。请注意,克隆库还附带了其他一些好处,如下面的屏幕截图所示:

通过序列化克隆

这种技术需要可序列化的对象(实现java.io.Serializable。基本上,对象在新对象中被序列化(writeObject())和反序列化(readObject())。可以实现这一点的助手方法如下所示:

private static <T> T cloneThroughSerialization(T t) {

  try {
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    ObjectOutputStream oos = new ObjectOutputStream(baos);
    oos.writeObject(t);

    ByteArrayInputStream bais 
      = new ByteArrayInputStream(baos.toByteArray());
    ObjectInputStream ois = new ObjectInputStream(bais);

    return (T) ois.readObject();
  } catch (IOException | ClassNotFoundException ex) {
    // log exception
    return t;
  }
}

因此,对象在ObjectOutputStream中序列化,在ObjectInputStream中反序列化。通过此方法克隆对象的步骤如下:

Point point = new Point(...);
Point clone = cloneThroughSerialization(point);

ApacheCommonsLang 通过SerializationUtils提供了一个基于序列化的内置解决方案。在它的方法中,这个类提供了一个名为clone()的方法,可以如下使用:

Point point = new Point(...);
Point clone = SerializationUtils.clone(point);

通过 JSON 克隆

几乎所有 Java 中的 JSON 库都可以序列化任何普通的旧 Java 对象POJO),而不需要任何额外的配置/映射。在项目中有一个 JSON 库(很多项目都有)可以避免我们添加额外的库来提供深度克隆。主要来说,该解决方案可以利用现有的 JSON 库来获得相同的效果。

以下是使用Gson库的示例:

private static <T> T cloneThroughJson(T t) {

  Gson gson = new Gson();
  String json = gson.toJson(t);

  return (T) gson.fromJson(json, t.getClass());
}

Point point = new Point(...);
Point clone = cloneThroughJson(point);

除此之外,您还可以选择编写专用于克隆对象的库。

54 覆盖toString()

toString()方法在java.lang.Object中定义,JDK 附带了它的默认实现。此默认实现自动用于print()println()printf()、开发期间调试、日志记录、异常中的信息消息等的所有对象。

不幸的是,默认实现返回的对象的字符串表示形式信息量不大。例如,让我们考虑下面的User类:

public class User {
  private final String nickname;
  private final String password;
  private final String firstname;
  private final String lastname;
  private final String email;
  private final Date created;

  // constructor and getters skipped for brevity
}

现在,让我们创建这个类的一个实例,并在控制台上打印它:

User user = new User("sparg21", "kkd454ffc",
  "Leopold", "Mark", "markl@yahoo.com");

System.out.println(user);

这个println()方法的输出如下:

在前面的屏幕截图中,避免输出的解决方案包括覆盖toString()方法。例如,让我们覆盖它以公开用户详细信息,如下所示:

@Override
public String toString() {
  return "User{" + "nickname=" + nickname + ", password=" + password
    + ", firstname=" + firstname + ", lastname=" + lastname
    + ", email=" + email + ", created=" + created + '}';
}

这次,println()将显示以下输出:

User {
  nickname = sparg21, password = kkd454ffc, 
  firstname = Leopold, lastname = Mark, 
  email = markl@yahoo.com, created = Fri Feb 22 10: 49: 32 EET 2019
}

这比以前的输出信息更丰富。

但是,请记住,toString()是为不同的目的自动调用的。例如,日志记录可以如下所示:

logger.log(Level.INFO, "This user rocks: {0}", user);

在这里,用户密码将命中日志,这可能表示有问题。在应用中公开日志敏感数据(如密码、帐户和秘密 IP)绝对是一种不好的做法。

因此,请特别注意仔细选择进入toString()的信息,因为这些信息最终可能会被恶意利用。在我们的例子中,密码不应该是toString()的一部分:

@Override
public String toString() {
  return "User{" + "nickname=" + nickname
    + ", firstname=" + firstname + ", lastname=" + lastname
    + ", email=" + email + ", created=" + created + '}';
}

通常,toString()是通过 IDE 生成的方法。因此,在 IDE 为您生成代码之前,请注意您选择了哪些字段。

55 switch表达式

在简要概述 JDK12 中引入的switch表达式之前,让我们先来看一个典型的老式方法示例:

private static Player createPlayer(PlayerTypes playerType) {

  switch (playerType) {

    case TENNIS:
      return new TennisPlayer();
    case FOOTBALL:
      return new FootballPlayer();
    case SNOOKER:      
      return new SnookerPlayer();
    case UNKNOWN:
      throw new UnknownPlayerException("Player type is unknown");
    default:
      throw new IllegalArgumentException(
        "Invalid player type: " + playerType);

  }
}

如果我们忘记了default,那么代码将无法编译。

显然,前面的例子是可以接受的。在最坏的情况下,我们可以添加一个伪变量(例如,player),一些杂乱的break语句,如果default丢失,就不会收到投诉。所以,下面的代码是一个老派,非常难看的switch

private static Player createPlayerSwitch(PlayerTypes playerType) {

  Player player = null;

  switch (playerType) {
    case TENNIS:
      player = new TennisPlayer();
      break;
    case FOOTBALL:
      player = new FootballPlayer();
      break;
    case SNOOKER:
      player = new SnookerPlayer();
      break;
    case UNKNOWN:
      throw new UnknownPlayerException(
        "Player type is unknown");
    default:
      throw new IllegalArgumentException(
        "Invalid player type: " + playerType);
  }

  return player;
}

如果我们忘记了default,那么编译器方面就不会有任何抱怨了。在这种情况下,丢失的default案例可能导致null播放器。

然而,自从 JDK12 以来,我们已经能够依赖于switch表达式。在 JDK12 之前,switch是一个语句,一个用来控制流的构造(例如,if语句),而不表示结果。另一方面,表达式的求值结果。因此,switch表达可产生结果。

前面的switch表达式可以用 JDK12 的样式写成如下:

private static Player createPlayer(PlayerTypes playerType) {

  return switch (playerType) {
    case TENNIS ->
      new TennisPlayer();
    case FOOTBALL ->
      new FootballPlayer();
    case SNOOKER ->
      new SnookerPlayer();
    case UNKNOWN ->
      throw new UnknownPlayerException(
        "Player type is unknown");
    // default is not mandatory
    default ->
      throw new IllegalArgumentException(
        "Invalid player type: " + playerType);
  };
}

这次,default不是强制性的。我们可以跳过它。

JDK12switch足够聪明,可以在switch没有覆盖所有可能的输入值时发出信号。这在 Java enum值的情况下非常有用。JDK12switch可以检测所有enum值是否都被覆盖,如果没有被覆盖,则不会强制一个无用的default。例如,如果我们删除default并向PlayerTypes enum添加一个新条目(例如GOLF),那么编译器将通过一条消息向它发送信号,如下面的屏幕截图(这是来自 NetBeans 的):

注意,在标签和执行之间,我们将冒号替换为箭头(Lambda 样式的语法)。此箭头的主要作用是防止跳转,这意味着只执行其右侧的代码块。不需要使用break

不要断定箭头将switch语句转换为switch表达式。switch表达可用于结肠和break,如下所示:

private static Player createPlayer(PlayerTypes playerType) {

  return switch (playerType) {
    case TENNIS:
      break new TennisPlayer();
    case FOOTBALL:
      break new FootballPlayer();
    case SNOOKER:
      break new SnookerPlayer();
    case UNKNOWN:
      throw new UnknownPlayerException(
        "Player type is unknown");
    // default is not mandatory
    default:
      throw new IllegalArgumentException(
        "Invalid player type: " + playerType);
  };
}

我们的示例在enum上发布了switch,但是 JDK12switch也可以在intIntegershortShortbyteBytecharCharacterString上使用。

注意,JDK12 带来了switch表达式作为预览特性。这意味着它很容易在接下来的几个版本中发生更改,需要在编译和运行时通过--enable-preview命令行选项来解锁它。

56 多个case标签

在 JDK12 之前,switch语句允许每个case有一个标签。从switch表达式开始,case可以有多个用逗号分隔的标签。请看下面举例说明多个case标签的方法:

private static SportType 
  fetchSportTypeByPlayerType(PlayerTypes playerType) {

  return switch (playerType) {
    case TENNIS, GOLF, SNOOKER ->
      new Individual();
    case FOOTBALL, VOLLEY ->  
      new Team();    
  };
}

因此,如果我们传递给这个方法TENNISGOLFSNOOKER,它将返回一个Individual类的实例。如果我们通过了FOOTBALLVOLLEY,它将返回一个Team类的实例。

57 case语句块

标签的箭头可以指向单个语句(如前两个问题中的示例)或大括号中的块。这与 Lambda 块非常相似。查看以下解决方案:

private static Player createPlayer(PlayerTypes playerType) {
  return switch (playerType) {
    case TENNIS -> {
      System.out.println("Creating a TennisPlayer ...");
      break new TennisPlayer();
    }
    case FOOTBALL -> {
      System.out.println("Creating a FootballPlayer ...");
      break new FootballPlayer();
    }
    case SNOOKER -> {
      System.out.println("Creating a SnookerPlayer ...");
      break new SnookerPlayer();
    }
    default ->
      throw new IllegalArgumentException(
        "Invalid player type: " + playerType);
  };
}

注意,我们通过break而不是return从花括号块中退出。换句话说,虽然我们可以从一个switch语句中return,但我们不能从一个表达式中return

总结

这就是所有的人!本章向您介绍了几个涉及对象、不变性和switch表达式的问题。虽然覆盖对象和不变性的问题代表了编程的基本概念,但覆盖switch表达式的问题致力于引入新的 JDK12 特性来解决这个问题。

从本章下载应用以查看结果和其他详细信息。****

三、使用日期和时间

本章包括 20 个涉及日期和时间的问题。这些问题通过DateCalendarLocalDateLocalTimeLocalDateTimeZoneDateTimeOffsetDateTimeOffsetTimeInstant等涵盖了广泛的主题(转换、格式化、加减、定义时段/持续时间、计算等)。到本章结束时,您将在确定日期和时间方面没有问题,同时符合您的应用的需要。本章介绍的基本问题将非常有助于了解日期-时间 API 的整体情况,并将像拼图中需要拼凑起来的部分一样解决涉及日期和时间的复杂挑战。

问题

使用以下问题来测试您的日期和时间编程能力。我强烈建议您在使用解决方案和下载示例程序之前,先尝试一下每个问题:

  1. 将字符串转换为日期和时间编写一个程序,演示字符串和日期/时间之间的转换。

  2. 格式化日期和时间:**解释日期和时间的格式模式。

  3. 获取当前日期/时间(不含日期/时间):编写程序,提取当前日期(不含时间或日期)。

  4. LocalDateLocalTimeLocalDateTime:编写一个程序,从LocalDate对象和LocalTime构建一个LocalDateTime。它将日期和时间组合在一个LocalDateTime对象中。

  5. 通过Instant类获取机器时间:解释并举例说明InstantAPI。

  6. 定义使用基于日期的值的时间段(Period)和使用基于时间的值的时间段(Duration):解释并举例说明PeriodDurationAPI 的用法。

  7. 获取日期和时间单位:编写一个程序,从表示日期时间的对象中提取日期和时间单位(例如,从日期中提取年、月、分钟等)。

  8. 对日期时间的加减:编写一个程序,对日期时间对象加减一定的时间(如年、日、分等)(如对日期加 1 小时,对LocalDateTime减 2 天等)。

  9. 获取 UTC 和 GMT 的所有时区:编写一个程序,显示 UTC 和 GMT 的所有可用时区。

  10. 获取所有可用时区的本地日期时间:编写一个程序,显示所有可用时区的本地时间。68. 显示航班日期时间信息:编写程序,显示 15 小时 30 分钟的航班时刻信息。更确切地说,是从澳大利亚珀斯飞往欧洲布加勒斯特的航班。

  11. 将 Unix 时间戳转换为日期时间:编写将 Unix 时间戳转换为java.util.Datejava.time.LocalDateTime的程序。

  12. 查找月份的第一天/最后一天:编写一个程序,通过 JDK8,TemporalAdjusters查找月份的第一天/最后一天。

  13. 定义/提取区域偏移:编写一个程序,展示定义和提取区域偏移的不同技术。

  14. DateTemporal之间的转换:编写DateInstantLocalDateLocalDateTime等之间的转换程序。

  15. 迭代一系列日期:编写一个程序,逐日(以一天的步长)迭代一系列给定日期。

  16. 计算年龄:编写一个计算一个人年龄的程序。

  17. 一天的开始和结束:编写一个程序,返回一天的开始和结束时间。

  18. 两个日期之间的差异:编写一个程序,计算两个日期之间的时间量(以天为单位)。

  19. 实现象棋时钟:编写实现象棋时钟的程序。

以下各节介绍上述问题的解决方案。记住,通常没有一个正确的方法来解决一个特定的问题。另外,请记住,这里显示的解释仅包括解决问题所需的最有趣和最重要的细节。下载示例解决方案以查看更多详细信息,并在这个页面中试用程序。

58 将字符串转换为日期和时间

String转换或解析为日期和时间可以通过一组parse()方法来完成。从日期和时间到String的转换可以通过toString()format()方法完成。

JDK8 之前

在 JDK8 之前,这个问题的典型解决方案依赖于抽象的DateFormat类的主扩展,名为SimpleDateFormat(这不是线程安全类)。在本书附带的代码中,有几个示例说明了如何使用此类。

从 JDK8 开始

从 JDK8 开始,SimpleDateFormat可以替换为一个新类—DateTimeFormatter。这是一个不可变(因此是线程安全的)类,用于打印和解析日期时间对象。这个类支持从预定义的格式化程序(表示为常量,如 ISO 本地时间2011-12-03,是ISO_LOCAL_DATE)到用户定义的格式化程序(依赖于一组用于编写自定义格式模式的符号)。

此外,除了Date类之外,JDK8 还提供了几个新类,它们专门用于处理日期和时间。其中一些类显示在下面的列表中(这些类也被称为临时类,因为它们实现了Temporal接口):

  • LocalDate(ISO-8601 日历系统中没有时区的日期)
  • LocalTime(ISO-8601 日历系统中无时区的时间)
  • LocalDateTime(ISO-8601 日历系统中无时区的日期时间)
  • ZonedDateTime(ISO-8601 日历系统中带时区的日期时间),依此类推
  • OffsetDateTime(在 ISO-8601 日历系统中,有 UTC/GMT 偏移的日期时间)
  • OffsetTime(在 ISO-8601 日历系统中与 UTC/GMT 有偏移的时间)

为了通过预定义的格式化程序将String转换为LocalDate,它应该遵循DateTimeFormatter.ISO_LOCAL_DATE模式,例如2020-06-01LocalDate提供了一种parse()方法,可以如下使用:

// 06 is the month, 01 is the day
LocalDate localDate = LocalDate.parse("2020-06-01");

类似地,在LocalTime的情况下,字符串应该遵循DateTimeFormatter.ISO_LOCAL_TIME模式;例如,10:15:30,如下面的代码片段所示:

LocalTime localTime = LocalTime.parse("12:23:44");

LocalDateTime的情况下,字符串应该遵循DateTimeFormatter.ISO_LOCAL_DATE_TIME模式,例如2020-06-01T11:20:15,如下代码片段所示:

LocalDateTime localDateTime 
  = LocalDateTime.parse("2020-06-01T11:20:15");

ZonedDateTime的情况下,字符串必须遵循DateTimeFormatter.ISO_ZONED_DATE_TIME模式,例如2020-06-01T10:15:30+09:00[Asia/Tokyo],如下代码片段所示:

ZonedDateTime zonedDateTime 
  = ZonedDateTime.parse("2020-06-01T10:15:30+09:00[Asia/Tokyo]");

OffsetDateTime的情况下,字符串必须遵循DateTimeFormatter.ISO_OFFSET_DATE_TIME模式,例如2007-12-03T10:15:30+01:00,如下代码片段所示:

OffsetDateTime offsetDateTime 
  = OffsetDateTime.parse("2007-12-03T10:15:30+01:00");

最后,在OffsetTime的情况下,字符串必须遵循DateTimeFormatter.ISO_OFFSET_TIME模式,例如10:15:30+01:00,如下代码片段所示:

OffsetTime offsetTime = OffsetTime.parse("10:15:30+01:00");

如果字符串不符合任何预定义的格式化程序,则是时候通过自定义格式模式使用用户定义的格式化程序了;例如,字符串01.06.2020表示需要用户定义格式化程序的日期,如下所示:

DateTimeFormatter dateFormatter 
  = DateTimeFormatter.ofPattern("dd.MM.yyyy");
LocalDate localDateFormatted 
  = LocalDate.parse("01.06.2020", dateFormatter);

但是,像12|23|44这样的字符串需要如下用户定义的格式化程序:

DateTimeFormatter timeFormatter 
  = DateTimeFormatter.ofPattern("HH|mm|ss");
LocalTime localTimeFormatted 
  = LocalTime.parse("12|23|44", timeFormatter);

01.06.2020, 11:20:15这样的字符串需要一个用户定义的格式化程序,如下所示:

DateTimeFormatter dateTimeFormatter 
  = DateTimeFormatter.ofPattern("dd.MM.yyyy, HH:mm:ss");
LocalDateTime localDateTimeFormatted 
  = LocalDateTime.parse("01.06.2020, 11:20:15", dateTimeFormatter);

01.06.2020, 11:20:15+09:00 [Asia/Tokyo]这样的字符串需要一个用户定义的格式化程序,如下所示:

DateTimeFormatter zonedDateTimeFormatter 
  = DateTimeFormatter.ofPattern("dd.MM.yyyy, HH:mm:ssXXXXX '['VV']'");
ZonedDateTime zonedDateTimeFormatted 
  = ZonedDateTime.parse("01.06.2020, 11:20:15+09:00 [Asia/Tokyo]", 
    zonedDateTimeFormatter);

2007.12.03, 10:15:30, +01:00这样的字符串需要一个用户定义的格式化程序,如下所示:

DateTimeFormatter offsetDateTimeFormatter 
  = DateTimeFormatter.ofPattern("yyyy.MM.dd, HH:mm:ss, XXXXX");
OffsetDateTime offsetDateTimeFormatted 
  = OffsetDateTime.parse("2007.12.03, 10:15:30, +01:00", 
    offsetDateTimeFormatter);

最后,像10 15 30 +01:00这样的字符串需要一个用户定义的格式化程序,如下所示:

DateTimeFormatter offsetTimeFormatter 
  = DateTimeFormatter.ofPattern("HH mm ss XXXXX");
OffsetTime offsetTimeFormatted 
  = OffsetTime.parse("10 15 30 +01:00", offsetTimeFormatter);

前面示例中的每个ofPattern()方法也支持Locale

LocalDateLocalDateTimeZonedDateTimeString的转换至少可以通过两种方式完成:

  • 依赖于LocalDateLocalDateTimeZonedDateTime.toString()方法(自动或显式)。请注意,依赖于toString()将始终通过相应的预定义格式化程序打印日期:
// 2020-06-01 results in ISO_LOCAL_DATE, 2020-06-01
String localDateAsString = localDate.toString();

// 01.06.2020 results in ISO_LOCAL_DATE, 2020-06-01
String localDateAsString = localDateFormatted.toString();

// 2020-06-01T11:20:15 results 
// in ISO_LOCAL_DATE_TIME, 2020-06-01T11:20:15
String localDateTimeAsString = localDateTime.toString();

// 01.06.2020, 11:20:15 results in 
// ISO_LOCAL_DATE_TIME, 2020-06-01T11:20:15
String localDateTimeAsString 
  = localDateTimeFormatted.toString();

// 2020-06-01T10:15:30+09:00[Asia/Tokyo] 
// results in ISO_ZONED_DATE_TIME,
// 2020-06-01T11:20:15+09:00[Asia/Tokyo]
String zonedDateTimeAsString = zonedDateTime.toString();

// 01.06.2020, 11:20:15+09:00 [Asia/Tokyo] 
// results in ISO_ZONED_DATE_TIME,
// 2020-06-01T11:20:15+09:00[Asia/Tokyo]
String zonedDateTimeAsString 
  = zonedDateTimeFormatted.toString();
  • 依靠DateTimeFormatter.format()方法。请注意,依赖于DateTimeFormatter.format()将始终使用指定的格式化程序打印日期/时间(默认情况下,时区将为null),如下所示:
// 01.06.2020
String localDateAsFormattedString 
  = dateFormatter.format(localDateFormatted);

// 01.06.2020, 11:20:15
String localDateTimeAsFormattedString 
  = dateTimeFormatter.format(localDateTimeFormatted);

// 01.06.2020, 11:20:15+09:00 [Asia/Tokyo]
String zonedDateTimeAsFormattedString 
  = zonedDateTimeFormatted.format(zonedDateTimeFormatter);

在讨论中添加一个明确的时区可以如下所示:

DateTimeFormatter zonedDateTimeFormatter 
  = DateTimeFormatter.ofPattern("dd.MM.yyyy, HH:mm:ssXXXXX '['VV']'")
    .withZone(ZoneId.of("Europe/Paris"));
ZonedDateTime zonedDateTimeFormatted 
  = ZonedDateTime.parse("01.06.2020, 11:20:15+09:00 [Asia/Tokyo]", 
    zonedDateTimeFormatter);

这次,字符串表示欧洲/巴黎时区中的日期/时间:

// 01.06.2020, 04:20:15+02:00 [Europe/Paris]
String zonedDateTimeAsFormattedString 
  = zonedDateTimeFormatted.format(zonedDateTimeFormatter);

59 格式化日期和时间

前面的问题包含一些通过SimpleDateFormat.format()DateTimeFormatter.format()格式化日期和时间的风格。为了定义格式模式,开发人员必须了解格式模式语法。换句话说,开发人员必须知道 Java 日期时间 API 使用的一组符号,以便识别有效的格式模式。

大多数符号与SimpleDateFormat(JDK8 之前)和DateTimeFormatter(从 JDK8 开始)通用。下表列出了 JDK 文档中提供的最常见符号的完整列表:

字母 含义 演示 示例
y 1994; 94
M 数字/文本 7; 07; Jul; July; J
W 每月的一周 数字 4
E 星期几 文本 Tue; Tuesday; T
d 日期 数字 15
H 小时 数字 22
m 分钟 数字 34
s 数字 55
S 秒的分数 数字 345
z 时区名称 时区名称 Pacific Standard Time; PST
Z 时区偏移 时区偏移 -0800
V 时区 ID(JDK8) 时区 ID America/Los_Angeles; Z; -08:30

下表提供了一些格式模式示例:

模式 示例
yyyy-MM-dd 2019-02-24
MM-dd-yyyy 02-24-2019
MMM-dd-yyyy Feb-24-2019
dd-MM-yy 24-02-19
dd.MM.yyyy 24.02.2019
yyyy-MM-dd HH:mm:ss 2019-02-24 11:26:26
yyyy-MM-dd HH:mm:ssSSS 2019-02-24 11:36:32743
yyyy-MM-dd HH:mm:ssZ 2019-02-24 11:40:35+0200
yyyy-MM-dd HH:mm:ss z 2019-02-24 11:45:03 EET
E MMM yyyy HH:mm:ss.SSSZ Sun Feb 2019 11:46:32.393+0200
yyyy-MM-dd HH:MM:ss VV(JDK8) 2019-02-24 11:45:41 Europe/Athens

在 JDK8 之前,可以通过SimpleDateFormat应用格式模式:

// yyyy-MM-dd
Date date = new Date();
SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd");
String stringDate = formatter.format(date);

从 JDK8 开始,可以通过DateTimeFormatter应用格式模式:

  • 对于LocalDate(ISO-8601 日历系统中没有时区的日期):
// yyyy-MM-dd
LocalDate localDate = LocalDate.now();
DateTimeFormatter formatterLocalDate 
  = DateTimeFormatter.ofPattern("yyyy-MM-dd");
String stringLD = formatterLocalDate.format(localDate);

// or shortly
String stringLD = LocalDate.now()
  .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
  • 对于LocalTime(ISO-8601 日历系统中没有时区的时间):
// HH:mm:ss
LocalTime localTime = LocalTime.now();
DateTimeFormatter formatterLocalTime 
  = DateTimeFormatter.ofPattern("HH:mm:ss");
String stringLT 
  = formatterLocalTime.format(localTime);

// or shortly
String stringLT = LocalTime.now()
  .format(DateTimeFormatter.ofPattern("HH:mm:ss"));
  • 对于LocalDateTime(ISO-8601 日历系统中没有时区的日期时间):
// yyyy-MM-dd HH:mm:ss
LocalDateTime localDateTime = LocalDateTime.now();
DateTimeFormatter formatterLocalDateTime 
  = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
String stringLDT 
  = formatterLocalDateTime.format(localDateTime);

// or shortly
String stringLDT = LocalDateTime.now()
  .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
  • 对于ZonedDateTime(ISO-8601 日历系统中带时区的日期时间):
// E MMM yyyy HH:mm:ss.SSSZ
ZonedDateTime zonedDateTime = ZonedDateTime.now();
DateTimeFormatter formatterZonedDateTime 
  = DateTimeFormatter.ofPattern("E MMM yyyy HH:mm:ss.SSSZ");
String stringZDT 
  = formatterZonedDateTime.format(zonedDateTime);

// or shortly
String stringZDT = ZonedDateTime.now()
  .format(DateTimeFormatter
    .ofPattern("E MMM yyyy HH:mm:ss.SSSZ"));
  • 对于OffsetDateTime(在 ISO-8601 日历系统中,与 UTC/GMT 有偏移的日期时间):
// E MMM yyyy HH:mm:ss.SSSZ
OffsetDateTime offsetDateTime = OffsetDateTime.now();
DateTimeFormatter formatterOffsetDateTime 
  = DateTimeFormatter.ofPattern("E MMM yyyy HH:mm:ss.SSSZ");
String odt1 = formatterOffsetDateTime.format(offsetDateTime);

// or shortly
String odt2 = OffsetDateTime.now()
  .format(DateTimeFormatter
    .ofPattern("E MMM yyyy HH:mm:ss.SSSZ"));
  • 对于OffsetTime(在 ISO-8601 日历系统中与 UTC/GMT 有偏移的时间):
// HH:mm:ss,Z
OffsetTime offsetTime = OffsetTime.now();
DateTimeFormatter formatterOffsetTime 
  = DateTimeFormatter.ofPattern("HH:mm:ss,Z");
String ot1 = formatterOffsetTime.format(offsetTime);

// or shortly
String ot2 = OffsetTime.now()
  .format(DateTimeFormatter.ofPattern("HH:mm:ss,Z"));

60 获取没有时间/日期的当前日期/时间

在 JDK8 之前,解决方案必须集中在java.util.Date类上。绑定到本书的代码包含此解决方案。

从 JDK8 开始,日期和时间可以通过专用类LocalDateLocalTimejava.time包中获得:

// 2019-02-24
LocalDate onlyDate = LocalDate.now();

// 12:53:28.812637300
LocalTime onlyTime = LocalTime.now();

61 LocalDateLocalTime中的LocalDateTime

LocalDateTime类公开了一系列of()方法,这些方法可用于获取LocalDateTime的不同类型的实例。例如,从年、月、日、时、分、秒或纳秒获得的LocalDateTime类如下所示:

LocalDateTime ldt = LocalDateTime.of​(2020, 4, 1, 12, 33, 21, 675);

因此,前面的代码将日期和时间组合为of()方法的参数。为了将日期和时间组合为对象,解决方案可以利用以下of()方法:

public static LocalDateTime of​(LocalDate date, LocalTime time)

这导致LocalDateLocalTime,如下所示:

LocalDate localDate = LocalDate.now(); // 2019-Feb-24
LocalTime localTime = LocalTime.now(); // 02:08:10 PM

它们可以组合在一个对象LocalDateTime中,如下所示:

LocalDateTime localDateTime = LocalDateTime.of(localDate, localTime);

格式化LocalDateTime显示日期和时间如下:

// 2019-Feb-24 02:08:10 PM
String localDateTimeAsString = localDateTime
  .format(DateTimeFormatter.ofPattern("yyyy-MMM-dd hh:mm:ss a"));

62 通过Instant类的机器时间

JDK8 附带了一个新类,名为java.time.Instant。主要地,Instant类表示时间线上的一个瞬时点,从 1970 年 1 月 1 日(纪元)的第一秒开始,在 UTC 时区,分辨率为纳秒。

Java8Instant类在概念上类似于java.util.Date。两者都代表 UTC 时间线上的一个时刻。当Instant的分辨率高达纳秒时,java.util.Date的分辨率为毫秒。

这个类对于生成机器时间的时间戳非常方便。为了获得这样的时间戳,只需调用如下的now()方法:

// 2019-02-24T15:05:21.781049600Z
Instant timestamp = Instant.now();

使用以下代码段可以获得类似的输出:

OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);

或者,使用以下代码段:

Clock clock = Clock.systemUTC();

调用Instant.toString()产生一个输出,该输出遵循 ISO-8601 标准来表示日期和时间。

将字符串转换为Instant

遵循 ISO-8601 标准表示日期和时间的字符串可以通过Instant.parse()方法轻松转换为Instant,如下例所示:

// 2019-02-24T14:31:33.197021300Z
Instant timestampFromString =
  Instant.parse("2019-02-24T14:31:33.197021300Z");

Instant添加/减去时间

对于添加时间,Instant有一套方法。例如,向当前时间戳添加 2 小时可以如下完成:

Instant twoHourLater = Instant.now().plus(2, ChronoUnit.HOURS);

在减去时间方面,例如 10 分钟,请使用以下代码段:

Instant tenMinutesEarlier = Instant.now()
  .minus(10, ChronoUnit.MINUTES);

plus()方法外,Instant还包含plusNanos()plusMillis()plusSeconds()。此外,除了minus()方法外,Instant还包含minusNanos()minusMillis()minusSeconds()

比较Instant对象

比较两个Instant对象可以通过Instant.isAfter()Instant.isBefore()方法来完成。例如,让我们看看以下两个Instant对象:

Instant timestamp1 = Instant.now();
Instant timestamp2 = timestamp1.plusSeconds(10);

检查timestamp1是否在timestamp2之后:

boolean isAfter = timestamp1.isAfter(timestamp2); // false

检查timestamp1是否在timestamp2之前:

boolean isBefore = timestamp1.isBefore(timestamp2); // true

两个Instant对象之间的时差可以通过Instant.until()方法计算:

// 10 seconds
long difference = timestamp1.until(timestamp2, ChronoUnit.SECONDS);

InstantLocalDateTimeZonedDateTimeOffsetDateTime之间转换

这些常见的转换可以在以下示例中完成:

  • InstantLocalDateTime之间转换-因为LocalDateTime不知道时区,所以使用零偏移 UTC+0:
// 2019-02-24T15:27:13.990103700
LocalDateTime ldt = LocalDateTime.ofInstant(
  Instant.now(), ZoneOffset.UTC);

// 2019-02-24T17:27:14.013105Z
Instant instantLDT = LocalDateTime.now().toInstant(ZoneOffset.UTC);
  • InstantZonedDateTime之间转换—将InstantUTC+0 转换为巴黎ZonedDateTimeUTC+1:
// 2019-02-24T16:34:36.138393100+01:00[Europe/Paris]
ZonedDateTime zdt = Instant.now().atZone(ZoneId.of("Europe/Paris"));

// 2019-02-24T16:34:36.150393800Z
Instant instantZDT = LocalDateTime.now()
  .atZone(ZoneId.of("Europe/Paris")).toInstant();
  • InstantOffsetDateTime之间转换-指定 2 小时的偏移量:
// 2019-02-24T17:34:36.151393900+02:00
OffsetDateTime odt = Instant.now().atOffset(ZoneOffset.of("+02:00"));

// 2019-02-24T15:34:36.153394Z
Instant instantODT = LocalDateTime.now()
  .atOffset(ZoneOffset.of("+02:00")).toInstant();

63 使用基于日期的值定义时段,使用基于时间的值定义持续时间

JDK8 附带了两个新类,分别命名为java.time.Periodjava.time.Duration。让我们在下一节中详细了解它们。

使用基于日期的值的时间段

Period类意味着使用基于日期的值(年、月、周和天)来表示时间量。这段时间可以用不同的方法获得。例如,120 天的周期可以如下获得:

Period fromDays = Period.ofDays(120); // P120D

ofDays()方法旁边,Period类还有ofMonths()ofWeeks()ofYears()

或者,通过of()方法可以得到 2000 年 11 个月 24 天的期限,如下所示:

Period periodFromUnits = Period.of(2000, 11, 24); // P2000Y11M24D

Period也可以从LocalDate中得到:

LocalDate localDate = LocalDate.now();
Period periodFromLocalDate = Period.of(localDate.getYear(),
  localDate.getMonthValue(), localDate.getDayOfMonth());

最后,可以从遵循 ISO-8601 周期格式PnYnMnDPnWString对象获得Period。例如,P2019Y2M25D字符串表示 2019 年、2 个月和 25 天:

Period periodFromString = Period.parse("P2019Y2M25D");

调用Period.toString()将返回时间段,同时也遵循 ISO-8601 时间段格式,PnYnMnDPnW(例如P120DP2000Y11M24D)。

但是,当Period被用来表示两个日期之间的一段时间(例如LocalDate时,Period的真实力量就显现出来了。2018 年 3 月 12 日至 2019 年 7 月 20 日期间可表示为:

LocalDate startLocalDate = LocalDate.of(2018, 3, 12);
LocalDate endLocalDate = LocalDate.of(2019, 7, 20);
Period periodBetween = Period.between(startLocalDate, endLocalDate);

年、月、日的时间量可以通过Period.getYears()Period.getMonths()Period.getDays()获得。例如,以下辅助方法使用这些方法将时间量输出为字符串:

public static String periodToYMD(Period period) {

  StringBuilder sb = new StringBuilder();

  sb.append(period.getYears())
   .append("y:")
   .append(period.getMonths())
   .append("m:")
   .append(period.getDays())
   .append("d");

 return sb.toString();
}

我们将此方法称为periodBetween(差值为 1 年 4 个月 8 天):

periodToYMD(periodBetween); // 1y:4m:8d

当确定某个日期是否早于另一个日期时,Period类也很有用。有一个标志方法,名为isNegative()。有一个A周期和一个B周期,如果BA之前,应用Period.between(A, B)的结果可以是负的,如果AB之前,应用isNegative()的结果可以是正的,如果BA之前,falseA之前,则isNegative()返回true B,如我们的例子所示(基本上,如果年、月或日为负数,此方法返回false):

// returns false, since 12 March 2018 is earlier than 20 July 2019
periodBetween.isNegative();

最后,Period可以通过加上或减去一段时间来修改。方法有plusYears()plusMonths()plusDays()minusYears()minusMonths()minusDays()等。例如,在periodBetween上加 1 年可以如下操作:

Period periodBetweenPlus1Year = periodBetween.plusYears(1L);

添加两个Period类可以通过Period.plus()方法完成,如下所示:

Period p1 = Period.ofDays(5);
Period p2 = Period.ofDays(20);
Period p1p2 = p1.plus(p2); // P25D

使用基于时间的值的持续时间

Duration类意味着使用基于时间的值(小时、分钟、秒或纳秒)来表示时间量。这种持续时间可以通过不同的方式获得。例如,可以如下获得 10 小时的持续时间:

Duration fromHours = Duration.ofHours(10); // PT10H

ofHours()方法旁边,Duration类还有ofDays()ofMillis()ofMinutes()ofSeconds()ofNanos()

或者,可以通过of()方法获得 3 分钟的持续时间,如下所示:

Duration fromMinutes = Duration.of(3, ChronoUnit.MINUTES); // PT3M

Duration也可以从LocalDateTime中得到:

LocalDateTime localDateTime 
  = LocalDateTime.of(2018, 3, 12, 4, 14, 20, 670);

// PT14M
Duration fromLocalDateTime 
  = Duration.ofMinutes(localDateTime.getMinute());

也可从LocalTime中获得:

LocalTime localTime = LocalTime.of(4, 14, 20, 670);

// PT0.00000067S
Duration fromLocalTime = Duration.ofNanos(localTime.getNano());

最后,可以从遵循 ISO-8601 持续时间格式PnDTnHnMn.nSString对象获得Duration,其中天被认为正好是 24 小时。例如,P2DT3H4M字符串有 2 天 3 小时 4 分钟:

Duration durationFromString = Duration.parse("P2DT3H4M");

调用Duration.toString()将返回符合 ISO-8601 持续时间格式的持续时间PnDTnHnMn.nS(例如,PT10HPT3MPT51H4M)。

但是,与Period的情况一样,当Duration用于表示两次之间的时间段(例如,Instant时,揭示了它的真实功率。从 2015 年 11 月 3 日 12:11:30 到 2016 年 12 月 6 日 15:17:10 之间的持续时间可以表示为两个Instant类之间的差异,如下所示:

Instant startInstant = Instant.parse("2015-11-03T12:11:30.00Z");
Instant endInstant = Instant.parse("2016-12-06T15:17:10.00Z");

// PT10059H5M40S
Duration durationBetweenInstant 
  = Duration.between(startInstant, endInstant);

以秒为单位,可通过Duration.getSeconds()方法获得该差值:

durationBetweenInstant.getSeconds(); // 36212740 seconds

或者,从 2018 年 3 月 12 日 04:14:20.000000670 到 2019 年 7 月 20 日 06:10:10.000000720 之间的持续时间可以表示为两个LocalDateTime对象之间的差异,如下所示:

LocalDateTime startLocalDateTime 
  = LocalDateTime.of(2018, 3, 12, 4, 14, 20, 670);
LocalDateTime endLocalDateTime 
  = LocalDateTime.of(2019, 7, 20, 6, 10, 10, 720);
// PT11881H55M50.00000005S, or 42774950 seconds
Duration durationBetweenLDT 
  = Duration.between(startLocalDateTime, endLocalDateTime);

最后,04:14:20.000000670 和 06:10:10.000000720 之间的持续时间可以表示为两个LocalTime对象之间的差异,如下所示:

LocalTime startLocalTime = LocalTime.of(4, 14, 20, 670);
LocalTime endLocalTime = LocalTime.of(6, 10, 10, 720);

// PT1H55M50.00000005S, or 6950 seconds
Duration durationBetweenLT 
  = Duration.between(startLocalTime, endLocalTime);

在前面的例子中,Duration通过Duration.getSeconds()方法以秒表示,这是Duration类中的秒数。然而,Duration类包含一组方法,这些方法专用于通过toDays()以天为单位、通过toHours()以小时为单位、通过toMinutes()以分钟为单位、通过toMillis()以毫秒为单位、通过toNanos()以纳秒为单位来表达Duration

从一个时间单位转换到另一个时间单位可能会产生残余。例如,从秒转换为分钟可能导致秒的剩余(例如,65 秒是 1 分钟,5 秒是剩余)。残差可以通过以下一组方法获得:天残差通过toDaysPart(),小时残差通过toHoursPart(),分钟残差通过toMinutesPart()等等。

假设差异应该显示为天:小时:分:秒:纳秒(例如,9d:2h:15m:20s:230n)。将toFoo()toFooPart()方法的力结合在一个辅助方法中将产生以下代码:

public static String durationToDHMSN(Duration duration) {

  StringBuilder sb = new StringBuilder();
  sb.append(duration.toDays())
    .append("d:")
    .append(duration.toHoursPart())
    .append("h:")
    .append(duration.toMinutesPart())
    .append("m:")
    .append(duration.toSecondsPart())
    .append("s:")
    .append(duration.toNanosPart())
    .append("n");

  return sb.toString();
}

让我们调用这个方法durationBetweenLDT(差别是 495 天 1 小时 55 分 50 秒 50 纳秒):

// 495d:1h:55m:50s:50n
durationToDHMSN(durationBetweenLDT);

Period类相同,Duration类有一个名为isNegative()的标志方法。当确定某个特定时间是否早于另一个时间时,此方法很有用。有持续时间A和持续时间B,如果BA之前,应用Duration.between(A, B)的结果可以是负的,如果AB之前,应用Duration.between(A, B)的结果可以是正的,进一步逻辑,isNegative()如果BA之前,则返回true,如果AB之前,则返回false,如以下情况:

durationBetweenLT.isNegative(); // false

最后,Duration可以通过增加或减少持续时间来修改。有plusDays()plusHours()plusMinutes()plusMillis()plusNanos()minusDays()minusHours()minusMinutes()minusMillis()minusNanos()等方法来执行此操作。例如,向durationBetweenLT添加 5 小时可以如下所示:

Duration durationBetweenPlus5Hours = durationBetweenLT.plusHours(5);

添加两个Duration类可以通过Duration.plus()方法完成,如下所示:

Duration d1 = Duration.ofMinutes(20);
Duration d2 = Duration.ofHours(2);

Duration d1d2 = d1.plus(d2);

System.out.println(d1 + "+" + d2 + "=" + d1d2); // PT2H20M

64 获取日期和时间单位

对于Date对象,解决方案可能依赖于Calendar实例。绑定到本书的代码包含此解决方案。

对于 JDK8 类,Java 提供了专用的getFoo()方法和get​(TemporalField field)方法。例如,假设下面的LocalDateTime对象:

LocalDateTime ldt = LocalDateTime.now();

依靠getFoo()方法,我们得到如下代码:

int year = ldt.getYear();
int month = ldt.getMonthValue();
int day = ldt.getDayOfMonth();
int hour = ldt.getHour();
int minute = ldt.getMinute();
int second = ldt.getSecond();
int nano = ldt.getNano();

或者,依赖于get​(TemporalField field)结果如下:

int yearLDT = ldt.get(ChronoField.YEAR);
int monthLDT = ldt.get(ChronoField.MONTH_OF_YEAR);
int dayLDT = ldt.get(ChronoField.DAY_OF_MONTH);
int hourLDT = ldt.get(ChronoField.HOUR_OF_DAY);
int minuteLDT = ldt.get(ChronoField.MINUTE_OF_HOUR);
int secondLDT = ldt.get(ChronoField.SECOND_OF_MINUTE);
int nanoLDT = ldt.get(ChronoField.NANO_OF_SECOND);

请注意,月份是从 1 开始计算的,即 1 月。

例如,2019-02-25T12:58:13.109389100LocalDateTime对象可以被切割成日期时间单位,结果如下:

Year: 2019 Month: 2 Day: 25 Hour: 12 Minute: 58 Second: 13 Nano: 109389100

通过一点直觉和文档,很容易将此示例改编为LocalDateLocalTimeZonedDateTime和其他示例。

65 日期时间的加减

这个问题的解决方案依赖于专用于处理日期和时间的 Java API。让我们在下一节中看看它们。

使用Date

对于Date对象,解决方案可能依赖于Calendar实例。绑定到本书的代码包含此解决方案。

使用LocalDateTime

跳转到 JDK8,重点是LocalDateLocalTimeLocalDateTimeInstant等等。新的 Java 日期时间 API 提供了专门用于加减时间量的方法。LocalDateLocalTimeLocalDateTimeZonedDateTimeOffsetDateTimeInstantPeriodDuration以及许多其他方法,如plusFoo()minusFoo(),其中Foo可以用单位替换时间(例如,plusYears()plusMinutes()minusHours()minusSeconds()等等)。

假设如下LocalDateTime

// 2019-02-25T14:55:06.651155500
LocalDateTime ldt = LocalDateTime.now();

加 10 分钟和调用LocalDateTime.plusMinutes(long minutes)一样简单,减 10 分钟和调用LocalDateTime.minusMinutes(long minutes)一样简单:

LocalDateTime ldtAfterAddingMinutes = ldt.plusMinutes(10);
LocalDateTime ldtAfterSubtractingMinutes = ldt.minusMinutes(10);

输出将显示以下日期:

After adding 10 minutes: 2019-02-25T15:05:06.651155500
After subtracting 10 minutes: 2019-02-25T14:45:06.651155500

除了每个时间单位专用的方法外,这些类还支持plus/minus(TemporalAmount amountToAdd)plus/minus(long amountToAdd, TemporalUnit unit)

现在,让我们关注Instant类。除了plus/minusSeconds()plus/minusMillis()plus/minusNanos()之外,Instant类还提供了plus/minus(TemporalAmount amountToAdd)方法。

为了举例说明这个方法,我们假设如下Instant

// 2019-02-25T12:55:06.654155700Z
Instant timestamp = Instant.now();

现在,让我们加减 5 个小时:

Instant timestampAfterAddingHours 
  = timestamp.plus(5, ChronoUnit.HOURS);
Instant timestampAfterSubtractingHours 
  = timestamp.minus(5, ChronoUnit.HOURS);

输出将显示以下Instant

After adding 5 hours: 2019-02-25T17:55:06.654155700Z
After subtracting 5 hours: 2019-02-25T07:55:06.654155700Z

66 使用 UTC 和 GMT 获取所有时区

UTC 和 GMT 被认为是处理日期和时间的标准参考。今天,UTC 是首选的方法,但是 UTC 和 GMT 在大多数情况下应该返回相同的结果。

为了获得 UTC 和 GMT 的所有时区,解决方案应该关注 JDK8 前后的实现。所以,让我们从 JDK8 之前有用的解决方案开始。

JDK8 之前

解决方案需要提取可用的时区 ID(非洲/巴马科、欧洲/贝尔格莱德等)。此外,每个时区 ID 都应该用来创建一个TimeZone对象。最后,解决方案需要提取特定于每个时区的偏移量,并考虑到夏令时。绑定到本书的代码包含此解决方案。

从 JDK8 开始

新的 Java 日期时间 API 为解决这个问题提供了新的工具。

在第一步,可用的时区 id 可以通过ZoneId类获得,如下所示:

Set<String> zoneIds = ZoneId.getAvailableZoneIds();

在第二步,每个时区 ID 都应该用来创建一个ZoneId实例。这可以通过ZoneId.of(String zoneId)方法实现:

ZoneId zoneid = ZoneId.of(current_zone_Id);

在第三步,每个ZoneId可用于获得特定于所识别区域的时间。这意味着需要一个“实验室老鼠”参考日期时间。此参考日期时间(无时区,LocalDateTime.now())通过LocalDateTime.atZone()与给定时区(ZoneId)组合,以获得ZoneDateTime(可识别时区的日期时间):

LocalDateTime now = LocalDateTime.now();
ZonedDateTime zdt = now.atZone(ZoneId.of(zone_id_instance));

atZone()方法尽可能地匹配日期时间,同时考虑时区规则,例如夏令时。

在第四步,代码可以利用ZonedDateTime来提取 UTC 偏移量(例如,对于欧洲/布加勒斯特,UTC 偏移量为+02:00):

String utcOffset = zdt.getOffset().getId().replace("Z", "+00:00");

getId()方法返回规范化区域偏移 ID,+00:00偏移作为Z字符返回;因此代码需要快速将Z替换为+00:00,以便与其他偏移对齐,这些偏移遵循+hh:mm+hh:mm:ss格式。

现在,让我们将这些步骤合并到一个辅助方法中:

public static List<String> fetchTimeZones(OffsetType type) {

  List<String> timezones = new ArrayList<>();
  Set<String> zoneIds = ZoneId.getAvailableZoneIds();
  LocalDateTime now = LocalDateTime.now();

  zoneIds.forEach((zoneId) -> {
    timezones.add("(" + type + now.atZone(ZoneId.of(zoneId))
      .getOffset().getId().replace("Z", "+00:00") + ") " + zoneId);
  });

  return timezones;
}

假设此方法存在于DateTimes类中,则获得以下代码:

List<String> timezones 
  = DateTimes.fetchTimeZones(DateTimes.OffsetType.GMT);
Collections.sort(timezones); // optional sort
timezones.forEach(System.out::println);

此外,还显示了一个输出快照,如下所示:

(GMT+00:00) Africa/Abidjan
(GMT+00:00) Africa/Accra
(GMT+00:00) Africa/Bamako
...
(GMT+11:00) Australia/Tasmania
(GMT+11:00) Australia/Victoria
...

67 获取所有可用时区中的本地日期时间

可通过以下步骤获得此问题的解决方案:

  1. 获取本地日期和时间。
  2. 获取可用时区。
  3. 在 JDK8 之前,使用SimpleDateFormatsetTimeZone()方法。
  4. 从 JDK8 开始,使用ZonedDateTime

JDK8 之前

在 JDK8 之前,获取当前本地日期时间的快速解决方案是调用Date空构造器。此外,还可以使用Date在所有可用的时区中显示,这些时区可以通过TimeZone类获得。绑定到本书的代码包含此解决方案。

从 JDK8 开始

从 JDK8 开始,获取默认时区中当前本地日期时间的一个方便解决方案是调用ZonedDateTime.now()方法:

ZonedDateTime zlt = ZonedDateTime.now();

所以,这是默认时区中的当前日期。此外,该日期应显示在通过ZoneId类获得的所有可用时区中:

Set<String> zoneIds = ZoneId.getAvailableZoneIds();

最后,代码可以循环zoneIds,对于每个区域 ID,可以调用ZonedDateTime.withZoneSameInstant(ZoneId zone)方法。此方法返回具有不同时区的此日期时间的副本,并保留以下瞬间:

public static List<String> localTimeToAllTimeZones() {

  List<String> result = new ArrayList<>();
  Set<String> zoneIds = ZoneId.getAvailableZoneIds();
  DateTimeFormatter formatter 
    = DateTimeFormatter.ofPattern("yyyy-MMM-dd'T'HH:mm:ss a Z");
  ZonedDateTime zlt = ZonedDateTime.now();

  zoneIds.forEach((zoneId) -> {
    result.add(zlt.format(formatter) + " in " + zoneId + " is "
      + zlt.withZoneSameInstant(ZoneId.of(zoneId))
        .format(formatter));
  });

  return result;
}

此方法的输出快照可以如下所示:

2019-Feb-26T14:26:30 PM +0200 in Africa/Nairobi 
  is 2019-Feb-26T15:26:30 PM +0300
2019-Feb-26T14:26:30 PM +0200 in America/Marigot 
  is 2019-Feb-26T08:26:30 AM -0400
...
2019-Feb-26T14:26:30 PM +0200 in Pacific/Samoa 
  is 2019-Feb-26T01:26:30 AM -1100

68 显示航班的日期时间信息

本节提供的解决方案将显示有关从澳大利亚珀斯到欧洲布加勒斯特的 15 小时 30 分钟航班的以下信息:

  • UTC 出发和到达日期时间
  • 离开珀斯的日期时间和到达布加勒斯特的日期时间
  • 离开和到达布加勒斯特的日期时间

假设从珀斯出发的参考日期时间为 2019 年 2 月 26 日 16:00(或下午 4:00):

LocalDateTime ldt = LocalDateTime.of(
  2019, Month.FEBRUARY, 26, 16, 00);

首先,让我们将这个日期时间与澳大利亚/珀斯(+08:00)的时区结合起来。这将产生一个特定于澳大利亚/珀斯的ZonedDateTime对象(这是出发时珀斯的时钟日期和时间):

// 04:00 PM, Feb 26, 2019 +0800 Australia/Perth
ZonedDateTime auPerthDepart 
  = ldt.atZone(ZoneId.of("Australia/Perth"));

此外,让我们在ZonedDateTime中加上 15 小时 30 分钟。结果ZonedDateTime表示珀斯的日期时间(这是抵达布加勒斯特时珀斯的时钟日期和时间):

// 07:30 AM, Feb 27, 2019 +0800 Australia/Perth
ZonedDateTime auPerthArrive 
  = auPerthDepart.plusHours(15).plusMinutes(30);

现在,让我们计算一下布加勒斯特的日期时间和珀斯的出发日期时间。基本上,以下代码表示从布加勒斯特时区的珀斯时区出发的日期和时间:

// 10:00 AM, Feb 26, 2019 +0200 Europe/Bucharest
ZonedDateTime euBucharestDepart 
  = auPerthDepart.withZoneSameInstant(ZoneId.of("Europe/Bucharest"));

最后,让我们计算一下到达布加勒斯特的日期和时间。以下代码表示布加勒斯特时区珀斯时区的到达日期时间:

// 01:30 AM, Feb 27, 2019 +0200 Europe/Bucharest
ZonedDateTime euBucharestArrive 
  = auPerthArrive.withZoneSameInstant(ZoneId.of("Europe/Bucharest"));

如下图所示,从珀斯出发的 UTC 时间是上午 8:00,而到达布加勒斯特的 UTC 时间是晚上 11:30:

这些时间可以很容易地提取为OffsetDateTime,如下所示:

// 08:00 AM, Feb 26, 2019
OffsetDateTime utcAtDepart = auPerthDepart.withZoneSameInstant(
  ZoneId.of("UTC")).toOffsetDateTime();

// 11:30 PM, Feb 26, 2019
OffsetDateTime utcAtArrive = auPerthArrive.withZoneSameInstant(
  ZoneId.of("UTC")).toOffsetDateTime();

69 将 Unix 时间戳转换为日期时间

对于这个解决方案,假设下面的 Unix 时间戳是 1573768800。此时间戳等效于以下内容:

  • 11/14/2019 @ 10:00pm (UTC)
  • ISO-8601 中的2019-11-14T22:00:00+00:00
  • Thu, 14 Nov 2019 22:00:00 +0000,RFC 822、1036、1123、2822
  • Thursday, 14-Nov-19 22:00:00 UTC,RFC 2822
  • 2019-11-14T22:00:00+00:00在 RFC 3339 中

为了将 Unix 时间戳转换为日期时间,必须知道 Unix 时间戳的分辨率以秒为单位,而java.util.Date需要毫秒。因此,从 Unix 时间戳获取Date对象的解决方案需要将 Unix 时间戳乘以 1000,从秒转换为毫秒,如下两个示例所示:

long unixTimestamp = 1573768800;

// Fri Nov 15 00:00:00 EET 2019 - in the default time zone
Date date = new Date(unixTimestamp * 1000L);

// Fri Nov 15 00:00:00 EET 2019 - in the default time zone
Date date = new Date(TimeUnit.MILLISECONDS
  .convert(unixTimestamp, TimeUnit.SECONDS));

从 JDK8 开始,Date类使用from(Instant instant)方法。此外,Instant类附带了ofEpochSecond(long epochSecond)方法,该方法使用1970-01-01T00:00:00Z的纪元的给定秒数返回Instant的实例:

// 2019-11-14T22:00:00Z in UTC
Instant instant = Instant.ofEpochSecond(unixTimestamp);

// Fri Nov 15 00:00:00 EET 2019 - in the default time zone
Date date = Date.from(instant);

上一示例中获得的瞬间可用于创建LocalDateTimeZonedDateTime,如下所示:

// 2019-11-15T06:00
LocalDateTime date = LocalDateTime
  .ofInstant(instant, ZoneId.of("Australia/Perth"));

// 2019-Nov-15 00:00:00 +0200 Europe/Bucharest
ZonedDateTime date = ZonedDateTime
  .ofInstant(instant, ZoneId.of("Europe/Bucharest"));

70 查找每月的第一天/最后一天

这个问题的正确解决将依赖于 JDK8、TemporalTemporalAdjuster接口。

Temporal接口位于日期和时间的表示后面。换句话说,表示日期和/或时间的类实现了这个接口。例如,以下类只是实现此接口的几个类:

  • LocalDate(ISO-8601 日历系统中没有时区的日期)
  • LocalTime(ISO-8601 日历系统中无时区的时间)
  • LocalDateTime(ISO-8601 日历系统中无时区的日期时间)
  • ZonedDateTime(ISO-8601 日历系统中带时区的日期时间),依此类推
  • OffsetDateTime(在 ISO-8601 日历系统中,从 UTC/格林威治时间偏移的日期时间)
  • HijrahDate(希吉拉历法系统中的日期)

TemporalAdjuster类是一个函数式接口,它定义了可用于调整Temporal对象的策略。除了可以定义自定义策略外,TemporalAdjuster类还提供了几个预定义的策略,如下所示(文档包含了整个列表,非常令人印象深刻):

  • firstDayOfMonth()(返回当月第一天)
  • lastDayOfMonth()(返回当月最后一天)
  • firstDayOfNextMonth()(次月 1 日返回)
  • firstDayOfNextYear()(次年第一天返回)

注意,前面列表中的前两个调整器正是这个问题所需要的。

考虑一个修正-LocalDate

LocalDate date = LocalDate.of(2019, Month.FEBRUARY, 27);

让我们看看二月的第一天/最后一天是什么时候:

// 2019-02-01
LocalDate firstDayOfFeb 
  = date.with(TemporalAdjusters.firstDayOfMonth());

// 2019-02-28
LocalDate lastDayOfFeb 
  = date.with(TemporalAdjusters.lastDayOfMonth());

看起来依赖预定义的策略非常简单。但是,假设问题要求您查找 2019 年 2 月 27 日之后的 21 天,也就是 2019 年 3 月 20 日。对于这个问题,没有预定义的策略,因此需要自定义策略。此问题的解决方案可以依赖 Lambda 表达式,如以下辅助方法中所示:

public static LocalDate getDayAfterDays(
    LocalDate startDate, int days) {

  Period period = Period.ofDays(days);
  TemporalAdjuster ta = p -> p.plus(period);
  LocalDate endDate = startDate.with(ta);

  return endDate;
}

如果此方法存在于名为DateTimes的类中,则以下调用将返回预期结果:

// 2019-03-20
LocalDate datePlus21Days = DateTimes.getDayAfterDays(date, 21);

遵循相同的技术,但依赖于static工厂方法ofDateAdjuster(),下面的代码片段定义了一个静态调整器,返回下一个星期六的日期:

static TemporalAdjuster NEXT_SATURDAY 
    = TemporalAdjusters.ofDateAdjuster(today -> {

  DayOfWeek dayOfWeek = today.getDayOfWeek();

  if (dayOfWeek == DayOfWeek.SATURDAY) {
    return today;
  }

  if (dayOfWeek == DayOfWeek.SUNDAY) {
    return today.plusDays(6);
  }

  return today.plusDays(6 - dayOfWeek.getValue());
});

我们将此方法称为 2019 年 2 月 27 日(下一个星期六是 2019 年 3 月 2 日):

// 2019-03-02
LocalDate nextSaturday = date.with(NEXT_SATURDAY);

最后,这个函数式接口定义了一个名为adjustInto()abstract方法。在自定义实现中,可以通过向该方法传递一个Temporal对象来覆盖该方法,如下所示:

public class NextSaturdayAdjuster implements TemporalAdjuster {

  @Override
  public Temporal adjustInto(Temporal temporal) {

    DayOfWeek dayOfWeek = DayOfWeek
      .of(temporal.get(ChronoField.DAY_OF_WEEK));

    if (dayOfWeek == DayOfWeek.SATURDAY) {
      return temporal;
    }

    if (dayOfWeek == DayOfWeek.SUNDAY) {
      return temporal.plus(6, ChronoUnit.DAYS);
    }

    return temporal.plus(6 - dayOfWeek.getValue(), ChronoUnit.DAYS);
  }
}

下面是用法示例:

NextSaturdayAdjuster nsa = new NextSaturdayAdjuster();

// 2019-03-02
LocalDate nextSaturday = date.with(nsa);

71 定义/提取区域偏移

通过区域偏移,我们了解需要从 GMT/UTC 时间中添加/减去的时间量,以便获得全球特定区域(例如,澳大利亚珀斯)的日期时间。通常,区域偏移以固定的小时和分钟数打印:+02:00-08:30+0400UTC+01:00,依此类推。

因此,简而言之,时区偏移量是指时区与 GMT/UTC 之间的时间差。

JDK8 之前

在 JDK8 之前,可以通过java.util.TimeZone定义一个时区,有了这个时区,代码就可以通过TimeZone.getRawOffset()方法得到时区偏移量(原始部分来源于这个方法不考虑夏令时)。绑定到本书的代码包含此解决方案。

从 JDK8 开始

从 JDK8 开始,有两个类负责处理时区表示。首先是java.time.ZoneId,表示欧洲雅典等时区;其次是java.time.ZoneOffset(扩展ZoneId),表示指定时区的固定时间(偏移量),以 GMT/UTC 表示。

新的 Java 日期时间 API 默认处理夏令时;因此,使用夏令时的夏-冬周期区域将有两个ZoneOffset类。

UTC 区域偏移量可以很容易地获得,如下所示(这是+00:00,在 Java 中用Z字符表示):

// Z
ZoneOffset zoneOffsetUTC = ZoneOffset.UTC;

系统默认时区也可以通过ZoneOffset类获取:

// Europe/Athens
ZoneId defaultZoneId = ZoneOffset.systemDefault();

为了使用夏令时进行分区偏移,代码需要将日期时间与其关联。例如,关联一个LocalDateTime类(也可以使用Instant),如下所示:

// by default it deals with the Daylight Saving Times
LocalDateTime ldt = LocalDateTime.of(2019, 6, 15, 0, 0);
ZoneId zoneId = ZoneId.of("Europe/Bucharest");

// +03:00
ZoneOffset zoneOffset = zoneId.getRules().getOffset(ldt);

区域偏移量也可以从字符串中获得。例如,以下代码获得+02:00的分区偏移:

ZoneOffset zoneOffsetFromString = ZoneOffset.of("+02:00");

这是一种非常方便的方法,可以将区域偏移快速添加到支持区域偏移的Temporal对象。例如,使用它将区域偏移添加到OffsetTimeOffsetDateTime(用于在数据库中存储日期或通过电线发送的方便方法):

OffsetTime offsetTime = OffsetTime.now(zoneOffsetFromString);
OffsetDateTime offsetDateTime 
  = OffsetDateTime.now(zoneOffsetFromString);

我们问题的另一个解决方法是依赖于从小时、分钟和秒来定义ZoneOffsetZoneOffset的一个助手方法专门用于:

// +08:30 (this was obtained from 8 hours and 30 minutes)
ZoneOffset zoneOffsetFromHoursMinutes 
  = ZoneOffset.ofHoursMinutes(8, 30);

ZoneOffset.ofHoursMinutes()旁边有ZoneOffset.ofHours()ofHoursMinutesSeconds()ofTotalSeconds()

最后,每个支持区域偏移的Temporal对象都提供了一个方便的getOffset()方法。例如,下面的代码从前面的offsetDateTime对象获取区域偏移:

// +02:00
ZoneOffset zoneOffsetFromOdt = offsetDateTime.getOffset();

72 在日期和时间之间转换

这里给出的解决方案将涵盖以下Temporal类—InstantLocalDateLocalDateTimeZonedDateTimeOffsetDateTimeLocalTimeOffsetTime

Date-Instant

为了从Date转换到Instant,可采用Date.toInstant()方法求解。可通过Date.from(Instant instant)方法实现反转:

  • DateInstant可以这样完成:
Date date = new Date();

// e.g., 2019-02-27T12:02:49.369Z, UTC
Instant instantFromDate = date.toInstant();
  • InstantDate可以这样完成:
Instant instant = Instant.now();

// Wed Feb 27 14:02:49 EET 2019, default system time zone
Date dateFromInstant = Date.from(instant);

请记住,Date不是时区感知的,但它显示在系统默认时区中(例如,通过toString())。Instant是 UTC 时区。

让我们快速地将这些代码片段包装在两个工具方法中,它们在一个工具类DateConverters中定义:

public static Instant dateToInstant(Date date) {

  return date.toInstant();
}

public static Date instantToDate(Instant instant) {

  return Date.from(instant);
}

此外,让我们使用以下屏幕截图中的方法来丰富此类:

屏幕截图中的常量DEFAULT_TIME_ZONE是系统默认时区:

public static final ZoneId DEFAULT_TIME_ZONE = ZoneId.systemDefault();

DateLocalDate

Date对象可以通过Instant对象转换为LocalDate。一旦我们从给定的Date对象中获得Instant对象,解决方案就可以应用于它系统默认时区,并调用toLocaleDate()方法:

// e.g., 2019-03-01
public static LocalDate dateToLocalDate(Date date) {

  return dateToInstant(date).atZone(DEFAULT_TIME_ZONE).toLocalDate();
}

LocalDateDate的转换应该考虑到LocalDate不包含Date这样的时间成分,所以解决方案必须提供一个时间成分作为一天的开始(关于这个问题的更多细节可以在“一天的开始和结束”问题中找到):

// e.g., Fri Mar 01 00:00:00 EET 2019
public static Date localDateToDate(LocalDate localDate) {

  return Date.from(localDate.atStartOfDay(
    DEFAULT_TIME_ZONE).toInstant());
}

DateLocalDateTime

DateDateLocalTime的转换与从DateLocalDate的转换是一样的,只是溶液应该调用toLocalDateTime()方法如下:

// e.g., 2019-03-01T07:25:25.624
public static LocalDateTime dateToLocalDateTime(Date date) {

  return dateToInstant(date).atZone(
    DEFAULT_TIME_ZONE).toLocalDateTime();
}

LocalDateTimeDate的转换非常简单。只需应用系统默认时区并调用toInstant()

// e.g., Fri Mar 01 07:25:25 EET 2019
public static Date localDateTimeToDate(LocalDateTime localDateTime) {

  return Date.from(localDateTime.atZone(
    DEFAULT_TIME_ZONE).toInstant());
}

DateZonedDateTime

DateZonedDateTime的转换可以通过从给定Date对象获取Instant对象和系统默认时区来完成:

// e.g., 2019-03-01T07:25:25.624+02:00[Europe/Athens]
public static ZonedDateTime dateToZonedDateTime(Date date) {

  return dateToInstant(date).atZone(DEFAULT_TIME_ZONE);
}

ZonedDateTime转换为Date就是将ZonedDateTime转换为Instant

// e.g., Fri Mar 01 07:25:25 EET 2019
public static Date zonedDateTimeToDate(ZonedDateTime zonedDateTime) {

  return Date.from(zonedDateTime.toInstant());
}

DateOffsetDateTime

DateOffsetDateTime的转换依赖于toOffsetDateTime()方法:

// e.g., 2019-03-01T07:25:25.624+02:00
public static OffsetDateTime dateToOffsetDateTime(Date date) {

  return dateToInstant(date).atZone(
    DEFAULT_TIME_ZONE).toOffsetDateTime();
}

OffsetDateTimeDate的转换方法需要两个步骤。首先将OffsetDateTime转换为LocalDateTime;其次将LocalDateTime转换为Instant,对应偏移量:

// e.g., Fri Mar 01 07:55:49 EET 2019
public static Date offsetDateTimeToDate(
    OffsetDateTime offsetDateTime) {

  return Date.from(offsetDateTime.toLocalDateTime()
    .toInstant(ZoneOffset.of(offsetDateTime.getOffset().getId())));
}

DateLocalTime

Date转换为LocalTime可以依赖LocalTime.toInstant()方法,如下所示:

// e.g., 08:03:20.336
public static LocalTime dateToLocalTime(Date date) {

  return LocalTime.ofInstant(dateToInstant(date), DEFAULT_TIME_ZONE);
}

LocalTime转换为Date应该考虑到LocalTime没有日期组件。这意味着解决方案应将日期设置为 1970 年 1 月 1 日,即纪元:

// e.g., Thu Jan 01 08:03:20 EET 1970
public static Date localTimeToDate(LocalTime localTime) {

  return Date.from(localTime.atDate(LocalDate.EPOCH)
    .toInstant(DEFAULT_TIME_ZONE.getRules()
      .getOffset(Instant.now())));
}

Date-OffsetTime

Date转换为OffsetTime可以依赖OffsetTime.toInstant()方法,如下所示:

// e.g., 08:03:20.336+02:00
public static OffsetTime dateToOffsetTime(Date date) {

  return OffsetTime.ofInstant(dateToInstant(date), DEFAULT_TIME_ZONE);
}

OffsetTime转换为Date应该考虑到OffsetTime没有日期组件。这意味着解决方案应将日期设置为 1970 年 1 月 1 日,即纪元:

// e.g., Thu Jan 01 08:03:20 EET 1970
public static Date offsetTimeToDate(OffsetTime offsetTime) {

  return Date.from(offsetTime.atDate(LocalDate.EPOCH).toInstant());
}

73 迭代一系列日期

假设范围是由开始日期 2019 年 2 月 1 日和结束日期 2019 年 2 月 21 日界定的。这个问题的解决方案应该循环【2019 年 2 月 1 日,2019 年 2 月 21 日】间隔一天,并在屏幕上打印每个日期。基本上要解决两个主要问题:

  • 一旦开始日期和结束日期相等,就停止循环。
  • 每天增加开始日期直到结束日期。

JDK8 之前

在 JDK8 之前,解决方案可以依赖于Calendar工具类。绑定到本书的代码包含此解决方案。

从 JDK8 开始

首先,从 JDK8 开始,可以很容易地将日期定义为LocalDate,而不需要Calendar的帮助:

LocalDate startLocalDate = LocalDate.of(2019, 2, 1);
LocalDate endLocalDate = LocalDate.of(2019, 2, 21);

一旦开始日期和结束日期相等,我们就通过LocalDate.isBefore(ChronoLocalDate other)方法停止循环。此标志方法检查此日期是否早于给定日期。

使用LocalDate.plusDays(long daysToAdd)方法逐日增加开始日期直到结束日期。在for循环中使用这两种方法会产生以下代码:

for (LocalDate date = startLocalDate; 
       date.isBefore(endLocalDate); date = date.plusDays(1)) {

  // do something with this day
  System.out.println(date);
}

输出的快照应如下所示:

2019-02-01
2019-02-02
2019-02-03
...
2019-02-20

从 JDK9 开始

JDK9 可以用一行代码解决这个问题。由于新的LocalDate.datesUntil(LocalDate endExclusive)方法,这是可能的。此方法返回Stream<LocalDate>,增量步长为一天:

startLocalDate.datesUntil(endLocalDate).forEach(System.out::println);

如果增量步骤应以天、周、月或年表示,则依赖于LocalDate.datesUntil(LocalDate endExclusive, Period step)。例如,1 周的增量步骤可以指定如下:

startLocalDate.datesUntil(endLocalDate, Period.ofWeeks(1)).forEach(System.out::println);

输出应为(第 1-8 周,第 8-15 周),如下所示:

2019-02-01
2019-02-08
2019-02-15

74 计算年龄

可能最常用的两个日期之间的差异是关于计算一个人的年龄。通常,一个人的年龄以年表示,但有时应提供月,甚至天。

JDK8 之前

在 JDK8 之前,试图提供一个好的解决方案可以依赖于Calendar和/或SimpleDateFormat。绑定到本书的代码包含这样一个解决方案。

从 JDK8 开始

更好的方法是升级到 JDK8,并依赖以下简单的代码片段:

LocalDate startLocalDate = LocalDate.of(1977, 11, 2);
LocalDate endLocalDate = LocalDate.now();

long years = ChronoUnit.YEARS.between(startLocalDate, endLocalDate);

由于Period类的原因,将月和日添加到结果中也很容易实现:

Period periodBetween = Period.between(startLocalDate, endLocalDate);

现在,可以通过periodBetween.getYears()periodBetween.getMonths()periodBetween.getDays()获得以年、月、日为单位的年龄。

例如,在当前日期 2019 年 2 月 28 日和 1977 年 11 月 2 日之间,我们有 41 年 3 个月 26 天。

75 一天的开始和结束

在 JDK8 中,可以通过几种方法来找到一天的开始/结束。

让我们考虑一下通过LocalDate表达的一天:

LocalDate localDate = LocalDate.of(2019, 2, 28);

找到 2019 年 2 月 28 日一天的开始的解决方案依赖于一个名为atStartOfDay()的方法。此方法从该日期午夜 00:00 返回LocalDateTime

// 2019-02-28T00:00
LocalDateTime ldDayStart = localDate.atStartOfDay();

或者,该溶液可以使用of(LocalDate date, LocalTime time)方法。该方法将给定的日期和时间组合成LocalDateTime。因此,如果经过的时间是LocalTime.MIN(一天开始时的午夜时间),则结果如下:

// 2019-02-28T00:00
LocalDateTime ldDayStart = LocalDateTime.of(localDate, LocalTime.MIN);

一个LocalDate物体的一天结束时间至少可以用两种方法得到。一种解决方案是依靠LocalDate.atTime(LocalTime time)。得到的LocalDateTime可以表示该日期与一天结束时的组合,如果解决方案作为参数传递,LocalTime.MAX(一天结束时午夜前的时间):

// 2019-02-28T23:59:59.999999999
LocalDateTime ldDayEnd = localDate.atTime(LocalTime.MAX);

或者,该解决方案可以通过atDate(LocalDate date)方法将LocalTime.MAX与给定日期结合:

// 2019-02-28T23:59:59.999999999
LocalDateTime ldDayEnd = LocalTime.MAX.atDate(localDate);

由于LocalDate没有时区的概念,前面的例子容易出现由不同的角落情况引起的问题,例如夏令时。有些夏令时会在午夜(00:00 变为 01:00 AM)更改时间,这意味着一天的开始时间是 01:00:00,而不是 00:00:00。为了缓解这些问题,请考虑以下示例,这些示例将前面的示例扩展为使用夏令时感知的ZonedDateTime

// 2019-02-28T00:00+08:00[Australia/Perth]
ZonedDateTime ldDayStartZone 
  = localDate.atStartOfDay(ZoneId.of("Australia/Perth"));

// 2019-02-28T00:00+08:00[Australia/Perth]
ZonedDateTime ldDayStartZone = LocalDateTime
  .of(localDate, LocalTime.MIN).atZone(ZoneId.of("Australia/Perth"));

// 2019-02-28T23:59:59.999999999+08:00[Australia/Perth]
ZonedDateTime ldDayEndZone = localDate.atTime(LocalTime.MAX)
  .atZone(ZoneId.of("Australia/Perth"));

// 2019-02-28T23:59:59.999999999+08:00[Australia/Perth]
ZonedDateTime ldDayEndZone = LocalTime.MAX.atDate(localDate)
  .atZone(ZoneId.of("Australia/Perth"));

现在,我们来考虑一下-LocalDateTime,2019 年 2 月 28 日,18:00:00:

LocalDateTime localDateTime = LocalDateTime.of(2019, 2, 28, 18, 0, 0);

显而易见的解决方案是从LocalDateTime中提取LocalDate,并应用前面的方法。另一个解决方案依赖于这样一个事实,Temporal接口的每个实现(包括LocalDate)都可以利用with(TemporalField field, long newValue)方法。主要是,with()方法返回这个日期的一个副本,其中指定的字段ChronoField设置为newValue。因此,如果解决方案将ChronoField.NANO_OF_DAY(一天的纳秒)设置为LocalTime.MIN,那么结果将是一天的开始。这里的技巧是通过toNanoOfDay()LocalTime.MIN转换为纳秒,如下所示:

// 2019-02-28T00:00
LocalDateTime ldtDayStart = localDateTime
  .with(ChronoField.NANO_OF_DAY, LocalTime.MIN.toNanoOfDay());

这相当于:

LocalDateTime ldtDayStart 
   = localDateTime.with(ChronoField.HOUR_OF_DAY, 0);

一天的结束是非常相似的。只需通过LocalTime.MAX而不是MIN

// 2019-02-28T23:59:59.999999999
LocalDateTime ldtDayEnd = localDateTime
  .with(ChronoField.NANO_OF_DAY, LocalTime.MAX.toNanoOfDay());

这相当于:

LocalDateTime ldtDayEnd = localDateTime.with(
  ChronoField.NANO_OF_DAY, 86399999999999L);

LocalDate一样,LocalDateTime对象不知道时区。在这种情况下,ZonedDateTime可以帮助:

// 2019-02-28T00:00+08:00[Australia/Perth]
ZonedDateTime ldtDayStartZone = localDateTime
  .with(ChronoField.NANO_OF_DAY, LocalTime.MIN.toNanoOfDay())
  .atZone(ZoneId.of("Australia/Perth"));

// 2019-02-28T23:59:59.999999999+08:00[Australia/Perth]
ZonedDateTime ldtDayEndZone = localDateTime
  .with(ChronoField.NANO_OF_DAY, LocalTime.MAX.toNanoOfDay())
  .atZone(ZoneId.of("Australia/Perth"));

作为奖励,让我们看看 UTC 一天的开始/结束。除了依赖于with()方法的解决方案外,另一个解决方案可以依赖于toLocalDate(),如下所示:

// e.g., 2019-02-28T09:23:10.603572Z
ZonedDateTime zdt = ZonedDateTime.now(ZoneOffset.UTC);

// 2019-02-28T00:00Z
ZonedDateTime dayStartZdt 
  = zdt.toLocalDate().atStartOfDay(zdt.getZone());

// 2019-02-28T23:59:59.999999999Z
ZonedDateTime dayEndZdt = zdt.toLocalDate()
  .atTime(LocalTime.MAX).atZone(zdt.getZone());

由于java.util.DateCalendar存在许多问题,因此建议避免尝试用它们实现此问题的解决方案。

76 两个日期之间的差异

计算两个日期之间的差值是一项非常常见的任务(例如,请参阅“计算年龄”部分)。让我们看看其他方法的集合,这些方法可以用来获得以毫秒、秒、小时等为单位的两个日期之间的差异。

JDK8 之前

建议通过java.util.DateCalendar类来表示日期时间信息。最容易计算的差异用毫秒表示。绑定到本书的代码包含这样一个解决方案。

从 JDK8 开始

从 JDK8 开始,建议通过Temporal(例如,DateTimeDateLocalTimeZonedDateTime等)来表示日期时间信息。

假设两个LocalDate对象,2018 年 1 月 1 日和 2019 年 3 月 1 日:

LocalDate ld1 = LocalDate.of(2018, 1, 1);
LocalDate ld2 = LocalDate.of(2019, 3, 1);

计算这两个Temporal对象之间差异的最简单方法是通过ChronoUnit类。除了表示一组标准的日期周期单位外,ChronoUnit还提供了几种简便的方法,包括between(Temporal t1Inclusive, Temporal t2Exclusive)。顾名思义,between()方法计算两个Temporal对象之间的时间量。让我们看看计算ld1ld2之间的差值的工作原理,以天、月和年为单位:

// 424
long betweenInDays = Math.abs(ChronoUnit.DAYS.between(ld1, ld2));

// 14
long betweenInMonths = Math.abs(ChronoUnit.MONTHS.between(ld1, ld2));

// 1
long betweenInYears = Math.abs(ChronoUnit.YEARS.between(ld1, ld2));

或者,每个Temporal公开一个名为until()的方法。实际上,LocalDate有两个,一个返回Period作为两个日期之间的差,另一个返回long作为指定时间单位中两个日期之间的差。使用返回Period的方法如下:

Period period = ld1.until(ld2);

// Difference as Period: 1y2m0d
System.out.println("Difference as Period: " + period.getYears() + "y" 
  + period.getMonths() + "m" + period.getDays() + "d");

使用允许我们指定时间单位的方法如下:

// 424
long untilInDays = Math.abs(ld1.until(ld2, ChronoUnit.DAYS));

// 14
long untilInMonths = Math.abs(ld1.until(ld2, ChronoUnit.MONTHS));

// 1
long untilInYears = Math.abs(ld1.until(ld2, ChronoUnit.YEARS));

ChronoUnit.convert()方法也适用于LocalDateTime的情况。让我们考虑以下两个LocalDateTime对象:2018 年 1 月 1 日 22:15:15 和 2019 年 3 月 1 日 23:15:15:

LocalDateTime ldt1 = LocalDateTime.of(2018, 1, 1, 22, 15, 15);
LocalDateTime ldt2 = LocalDateTime.of(2018, 1, 1, 23, 15, 15);

现在,让我们看看ldt1ldt2之间的区别,用分钟表示:

// 60
long betweenInMinutesWithoutZone 
  = Math.abs(ChronoUnit.MINUTES.between(ldt1, ldt2));

并且,通过LocalDateTime.until()方法以小时表示的差异:

// 1
long untilInMinutesWithoutZone 
  = Math.abs(ldt1.until(ldt2, ChronoUnit.HOURS));

但是,ChronoUnit.between()until()有一个非常棒的地方,那就是它们与ZonedDateTime一起工作。例如,让我们考虑欧洲/布加勒斯特时区和澳大利亚/珀斯时区的ldt1,加上一小时:

ZonedDateTime zdt1 = ldt1.atZone(ZoneId.of("Europe/Bucharest"));
ZonedDateTime zdt2 = zdt1.withZoneSameInstant(
  ZoneId.of("Australia/Perth")).plusHours(1);

现在,我们用ChronoUnit.between()来表示zdt1zdt2之间的差分,用ZonedDateTime.until()来表示zdt1zdt2之间的差分,用小时表示:

// 60
long betweenInMinutesWithZone 
  = Math.abs(ChronoUnit.MINUTES.between(zdt1, zdt2));

// 1
long untilInHoursWithZone 
  = Math.abs(zdt1.until(zdt2, ChronoUnit.HOURS));

最后,让我们重复这个技巧,但是对于两个独立的ZonedDateTime对象:一个为ldt1获得,一个为ldt2获得:

ZonedDateTime zdt1 = ldt1.atZone(ZoneId.of("Europe/Bucharest"));
ZonedDateTime zdt2 = ldt2.atZone(ZoneId.of("Australia/Perth"));

// 300
long betweenInMinutesWithZone 
  = Math.abs(ChronoUnit.MINUTES.between(zdt1, zdt2));

// 5
long untilInHoursWithZone 
  = Math.abs(zdt1.until(zdt2, ChronoUnit.HOURS));

77 实现象棋时钟

从 JDK8 开始,java.time包有一个名为Clock的抽象类。这个类的主要目的是允许我们在需要时插入不同的时钟(例如,出于测试目的)。默认情况下,Java 有四种实现:SystemClockOffsetClockTickClockFixedClock。对于每个实现,Clock类中都有static方法。例如,下面的代码创建了FixedClock(一个总是返回相同Instant的时钟):

Clock fixedClock = Clock.fixed(Instant.now(), ZoneOffset.UTC);

还有一个TickClock,它返回给定时区整秒的当前Instant滴答声:

Clock tickClock = Clock.tickSeconds(ZoneId.of("Europe/Bucharest"));

还有一种方法可以用来在整分钟内打勾tickMinutes(),还有一种通用方法tick(),它允许我们指定Duration

Clock类也可以支持时区和偏移量,但是Clock类最重要的方法是instant()。此方法返回Clock的瞬间:

// 2019-03-01T13:29:34Z
System.out.println(tickClock.instant());

还有一个millis()方法,它以毫秒为单位返回时钟的当前时刻。

假设我们要实现一个时钟,它充当象棋时钟:

为了实现一个Clock类,需要遵循以下几个步骤:

  1. 扩展Clock类。
  2. 执行Serializable
  3. 至少覆盖从Clock继承的抽象方法。

Clock类的框架如下:

public class ChessClock extends Clock implements Serializable {

  @Override
  public ZoneId getZone() {
    ...
  }

  @Override
  public Clock withZone(ZoneId zone) {
    ...
  }

  @Override
  public Instant instant() {
    ...
  }
}

我们的ChessClock将只与 UTC 一起工作;不支持其他时区。这意味着getZone()withZone()方法可以实现如下(当然,将来可以修改):

@Override
public ZoneId getZone() {
  return ZoneOffset.UTC;
}

@Override
public Clock withZone(ZoneId zone) {
  throw new UnsupportedOperationException(
    "The ChessClock works only in UTC time zone");
}

我们实现的高潮是instant()方法。难度在于管理两个Instant,一个是左边的玩家(instantLeft),一个是右边的玩家(instantRight)。我们可以将instant()方法的每一次调用与当前玩家已经执行了一个移动的事实相关联,现在轮到另一个玩家了。所以,基本上,这个逻辑是说同一个玩家不能调用instant()两次。实现这个逻辑,instant()方法如下:

public class ChessClock extends Clock implements Serializable {

  public enum Player {
    LEFT,
    RIGHT
  }

  private static final long serialVersionUID = 1L;

  private Instant instantStart;
  private Instant instantLeft;
  private Instant instantRight;
  private long timeLeft;
  private long timeRight;
  private Player player;

  public ChessClock(Player player) {
    this.player = player;
  }

  public Instant gameStart() {

    if (this.instantStart == null) {
      this.timeLeft = 0;
      this.timeRight = 0;
      this.instantStart = Instant.now();
      this.instantLeft = instantStart;
      this.instantRight = instantStart;
      return instantStart;
    }

    throw new IllegalStateException(
      "Game already started. Stop it and try again.");
  }

  public Instant gameEnd() {

    if (this.instantStart != null) {
      instantStart = null;
      return Instant.now();
    }

    throw new IllegalStateException("Game was not started.");
  }

  @Override
  public ZoneId getZone() {
    return ZoneOffset.UTC;
  }

  @Override
  public Clock withZone(ZoneId zone) {
    throw new UnsupportedOperationException(
      "The ChessClock works only in UTC time zone");
  }

  @Override
  public Instant instant() {

    if (this.instantStart != null) {
      if (player == Player.LEFT) {
        player = Player.RIGHT;

        long secondsLeft = Instant.now().getEpochSecond() 
          - instantRight.getEpochSecond();
        instantLeft = instantLeft.plusSeconds(
          secondsLeft - timeLeft);
        timeLeft = secondsLeft;

        return instantLeft;
      } else {
        player = Player.LEFT;

        long secondsRight = Instant.now().getEpochSecond() 
          - instantLeft.getEpochSecond();
        instantRight = instantRight.plusSeconds(
          secondsRight - timeRight);
        timeRight = secondsRight;

        return instantRight;
      }
    }

    throw new IllegalStateException("Game was not started.");
  }
}

因此,根据哪个玩家调用了instant()方法,代码计算出该玩家在执行移动之前思考所需的秒数。此外,代码会切换播放器,因此下一次调用instant()将处理另一个播放器。

让我们考虑一个从2019-03-01T14:02:46.309459Z开始的国际象棋游戏:

ChessClock chessClock = new ChessClock(Player.LEFT);

// 2019-03-01T14:02:46.309459Z
Instant start = chessClock.gameStart();

此外,玩家执行以下一系列动作,直到右边的玩家赢得游戏:

Left moved first after 2 seconds: 2019-03-01T14:02:48.309459Z
Right moved after 5 seconds: 2019-03-01T14:02:51.309459Z
Left moved after 6 seconds: 2019-03-01T14:02:54.309459Z
Right moved after 1 second: 2019-03-01T14:02:52.309459Z
Left moved after 2 second: 2019-03-01T14:02:56.309459Z
Right moved after 3 seconds: 2019-03-01T14:02:55.309459Z
Left moved after 10 seconds: 2019-03-01T14:03:06.309459Z
Right moved after 11 seconds and win: 2019-03-01T14:03:06.309459Z

看来时钟正确地记录了运动员的动作。

最后,比赛在 40 秒后结束:

Game ended:2019-03-01T14:03:26.350749300Z
Instant end = chessClock.gameEnd();

Game duration: 40 seconds
// Duration.between(start, end).getSeconds();

总结

任务完成了!本章提供了使用日期和时间信息的全面概述。广泛的应用必须处理这类信息。因此,将这些问题的解决方案放在你的工具带下不是可选的。从DateCalendarLocalDateLocalTimeLocalDateTimeZoneDateTimeOffsetDateTimeOffsetTimeInstant——它们在涉及日期和时间的日常任务中都是非常重要和有用的。

从本章下载应用以查看结果和其他详细信息。******

四、类型推断

本章包括 21 个涉及 JEP286 或 Java 局部变量类型推断LVTI)的问题,也称为var类型。这些问题经过精心设计,以揭示最佳实践和使用var时所涉及的常见错误。到本章结束时,您将了解到将var推向生产所需的所有知识。

问题

使用以下问题来测试您的类型推断编程能力。我强烈建议您在使用解决方案和下载示例程序之前,先尝试一下每个问题:

  1. 简单var示例:编写一个程序,举例说明类型推断(var)在代码可读性方面的正确用法。

  2. var与原始类型结合使用:编写一个程序,举例说明将var与 Java 原始类型(intlongfloatdouble结合使用。

  3. 使用var和隐式类型转换来维持代码的可维护性:编写一个程序,举例说明var隐式类型转换如何维持代码的可维护性。

  4. 显式向下转换或更好地避免var:编写一个程序,举例说明var和显式向下转换的组合,并解释为什么要避免var

  5. 如果被调用的名称没有包含足够的人性化类型信息,请避免使用var:请举例说明应避免使用var,因为它与被调用的名称的组合会导致人性化信息的丢失。

  6. 结合 LVTI 和面向接口编程技术:编写一个程序,通过面向接口编程技术来举例说明var的用法。

  7. 结合 LVTI 和菱形运算符:编写一个程序,举例说明var菱形运算符的用法。

  8. 使用var分配数组:编写一个将数组分配给var的程序。

  9. 在复合声明中使用 LVTI:解释并举例说明 LVTI 在复合声明中的用法。

  10. LVTI 和变量范围:解释并举例说明为什么 LVTI 应该尽可能地缩小变量的范围。

  11. LVTI 和三元运算符:编写几个代码片段,举例说明 LVTI 和三元运算符组合的优点。

  12. LVTI 和for循环:写几个例子来举例说明 LVTI 在for循环中的用法。

  13. LVTI 和流:编写几个代码片段,举例说明 LVTI 和 Java 流的用法。

  14. 使用 LVTI 分解嵌套的/大的表达式链:编写一个程序,举例说明如何使用 LVTI 分解嵌套的/大的表达式链。

  15. LVTI 和方法返回和参数类型:编写几个代码片段,举例说明 LVTI 和 Java 方法在返回和参数类型方面的用法。

  16. LVTI 和匿名类:编写几个代码片段,举例说明 LVTI 在匿名类中的用法。

  17. LVTI 可以是final和有效的final:写几个代码片段,举例说明 LVTI 如何用于final和有效的final变量。

  18. LVTI 和 Lambda:通过几个代码片段解释如何将 LVTI 与 Lambda 表达式结合使用。

  19. LVTI 和null初始化器、实例变量和catch块变量:举例说明如何将 LVTI 与null初始化器、实例变量和catch块结合使用。

  20. LVTI 和泛型类型T:编写几个代码片段,举例说明如何将 LVTI 与泛型类型结合使用。

  21. LVTI、通配符、协变和逆变:编写几个代码片段,举例说明如何将 LVTI 与通配符、协变和逆变结合使用。

解决方案

以下各节介绍上述问题的解决方案。记住,通常没有一个正确的方法来解决一个特定的问题。另外,请记住,这里显示的解释仅包括解决问题所需的最有趣和最重要的细节。您可以下载示例解决方案以查看更多详细信息并尝试程序

78 简单var示例

从版本 10 开始,Java 附带了 JEP286 或 JavaLVTI,也称为var类型。

var标识符不是 Java 关键字,而是保留类型名

这是一个 100% 编译特性,在字节码、运行时或性能方面没有任何副作用。简而言之,LVTI 应用于局部变量,其工作方式如下:编译器检查右侧并推断出实类型(如果右侧是一个初始化器,则使用该类型)。

此功能可确保编译时安全。这意味着我们不能编译一个试图实现错误赋值的应用。如果编译器已经推断出var的具体/实际类型,我们只能赋值该类型的值。

LVTI 有很多好处;例如,它减少了代码的冗长,减少了冗余和样板代码。此外,LVTI 可以减少编写代码所花的时间,特别是在涉及大量声明的情况下,如下所示:

// without var
Map<Boolean, List<Integer>> evenAndOddMap...

// with var
var evenAndOddMap = ...

一个有争议的优点是代码可读性。一些声音支持使用var会降低代码可读性,而另一些声音则支持相反的观点。根据用例的不同,它可能需要在可读性上进行权衡,但事实是,通常情况下,我们非常关注字段(实例变量)的有意义的名称,而忽略了局部变量的名称。例如,让我们考虑以下方法:

public Object fetchTransferableData(String data)
    throws UnsupportedFlavorException, IOException {

  StringSelection ss = new StringSelection(data);
  DataFlavor[] df = ss.getTransferDataFlavors();
  Object obj = ss.getTransferData(df[0]);

  return obj;
}

这是一个简短的方法;它有一个有意义的名称和一个干净的实现。但是检查局部变量的名称。它们的名称大大减少(它们只是快捷方式),但这不是问题,因为左侧提供了足够的信息,我们可以很容易地理解每个局部变量的类型。现在,让我们使用 LVTI 编写以下代码:

public Object fetchTransferableData(String data)
    throws UnsupportedFlavorException, IOException {

  var ss = new StringSelection(data);
  var df = ss.getTransferDataFlavors();
  var obj = ss.getTransferData(df[0]);

  return obj;
}

显然,代码的可读性降低了,因为现在很难推断出局部变量的类型。如下面的屏幕截图所示,编译器在推断正确的类型方面没有问题,但是对于人类来说,这要困难得多:

这个问题的解决方案是在依赖 LVTI 时为局部变量提供一个有意义的名称。例如,如果提供了局部变量的名称,代码可以恢复可读性,如下所示:

public Object fetchTransferableData(String data)
    throws UnsupportedFlavorException, IOException {

  var stringSelection = new StringSelection(data);
  var dataFlavorsArray = stringSelection.getTransferDataFlavors();
  var obj = stringSelection.getTransferData(dataFlavorsArray[0]);

  return obj;
}

然而,可读性问题也是由这样一个事实引起的:通常,我们倾向于将类型视为主要信息,将变量名视为次要信息,而这应该是相反的。

让我们再看两个例子来执行上述语句。使用集合(例如,List)的方法如下:

// Avoid
public List<Player> fetchPlayersByTournament(String tournament) {

  var t = tournamentRepository.findByName(tournament);
  var p = t.getPlayers();

  return p;
}

// Prefer
public List<Player> fetchPlayersByTournament(String tournament) {

  var tournamentName = tournamentRepository.findByName(tournament);
  var playerList = tournamentName.getPlayers();

  return playerList;
}

为局部变量提供有意义的名称并不意味着陷入过度命名技术。

例如,通过简单地重复类型名来避免命名变量:

// Avoid
var fileCacheImageOutputStream​ 
  = new FileCacheImageOutputStream​(..., ...);

// Prefer
var outputStream​ = new FileCacheImageOutputStream​(..., ...);

// Or
var outputStreamOfFoo​ = new FileCacheImageOutputStream​(..., ...);

79 对原始类型使用var

将 LVTI 与原始类型(intlongfloatdouble)一起使用的问题是,预期类型和推断类型可能不同。显然,这会导致代码中的混乱和意外行为。

这种情况下的犯罪方是var类型使用的隐式类型转换

例如,让我们考虑以下两个依赖显式原始类型的声明:

boolean valid = true; // this is of type boolean
char c = 'c';         // this is of type char

现在,让我们用 LVTI 替换显式原始类型:

var valid = true; // inferred as boolean
var c = 'c';      // inferred as char

很好!到目前为止没有问题!现在,让我们看看另一组基于显式原始类型的声明:

int intNumber = 10;       // this is of type int
long longNumber = 10;     // this is of type long
float floatNumber = 10;   // this is of type float, 10.0
double doubleNumber = 10; // this is of type double, 10.0

让我们按照第一个示例中的逻辑,用 LVTI 替换显式原始类型:

// Avoid
var intNumber = 10;    // inferred as int
var longNumber = 10;   // inferred as int
var floatNumber = 10;  // inferred as int
var doubleNumber = 10; // inferred as int

根据以下屏幕截图,所有四个变量都被推断为整数:

这个问题的解决方案包括使用显式 Java 字面值

// Prefer
var intNumber = 10;     // inferred as int
var longNumber = 10L;   // inferred as long
var floatNumber = 10F;  // inferred as float, 10.0
var doubleNumber = 10D; // inferred as double, 10.0

最后,让我们考虑一个带小数的数字的情况,如下所示:

var floatNumber = 10.5; // inferred as double

变量名表明10.5float,但实际上是推断为double。因此,即使是带小数的数字(尤其是带小数的数字),也建议使用字面值

var floatNumber = 10.5F; // inferred as float

80 使用var和隐式类型转换来维持代码的可维护性

在上一节中,“将var与原始类型结合使用”,我们看到将var隐式类型转换结合使用会产生实际问题。但在某些情况下,这种组合可能是有利的,并维持代码的可维护性。

让我们考虑以下场景,我们需要编写一个方法,该方法位于名为ShoppingAddicted的外部 API 的两个现有方法之间(通过推断,这些方法可以是两个 Web 服务、端点等)。有一种方法专门用于返回给定购物车的最佳价格。基本上,这种方法需要一堆产品,并查询不同的在线商店,以获取最佳价格。

结果价格返回为int。此方法的存根如下所示:

public static int fetchBestPrice(String[] products) {

  float realprice = 399.99F; // code to query the prices in stores
  int price = (int) realprice;

  return price;
}

另一种方法将价格作为int接收并执行支付。如果支付成功,则返回true

public static boolean debitCard(int amount) {

  return true;
}

现在,通过对该代码进行编程,我们的方法将充当客户端,如下所示(客户可以决定购买哪些商品,我们的代码将为他们返回最佳价格并相应地借记卡):

// Avoid
public static boolean purchaseCart(long customerId) {

  int price = ShoppingAddicted.fetchBestPrice(new String[0]);
  boolean paid = ShoppingAddicted.debitCard(price);

  return paid;
}

但是过了一段时间,ShoppingAddictedAPI 的拥有者意识到他们通过将实际价格转换成int来赔钱(例如,实际价格是 399.99,但在int形式中,它是 399.0,这意味着损失 99 美分)。因此,他们决定放弃这种做法,将实际价格返回为float

public static float fetchBestPrice(String[] products) {

  float realprice = 399.99F; // code to query the prices in stores

  return realprice;
}

因为返回的价格是float,所以debitCard()也会更新:

public static boolean debitCard(float amount) {

  return true;
}

但是,一旦我们升级到新版本的ShoppingAddictedAPI,代码将失败,并有可能从floatint异常的有损转换。这是正常的,因为我们的代码需要int。由于我们的代码不能很好地容忍这些修改,因此需要相应地修改代码。

然而,如果我们已经预见到这种情况,并且使用了var而不是int,那么由于隐式类型转换,代码将不会出现问题:

// Prefer
public static boolean purchaseCart(long customerId) {

  var price = ShoppingAddicted.fetchBestPrice(new String[0]);
  var paid = ShoppingAddicted.debitCard(price);

  return paid;
}

81 显式向下转换或更好地避免var

在“将var与原始类型结合使用”一节中,我们讨论了将字面值与原始类型结合使用(intlongfloatdouble来避免隐式类型转换带来的问题。但并非所有 Java 原始类型都可以利用字面值。在这种情况下,最好的方法是避免使用var。但让我们看看为什么!

检查以下关于byteshort变量的声明:

byte byteNumber = 25;     // this is of type byte
short shortNumber = 1463; // this is of type short

如果我们用var替换显式类型,那么推断的类型将是int

var byteNumber = 25;    // inferred as int
var shortNumber = 1463; // inferred as int

不幸的是,这两种基本类型没有可用的字面值。帮助编译器推断正确类型的唯一方法是依赖显式向下转换:

var byteNumber = (byte) 25;     // inferred as byte
var shortNumber = (short) 1463; // inferred as short

虽然这段代码编译成功并按预期工作,但我们不能说使用var比使用显式类型带来了任何价值。因此,在这种情况下,最好避免var和显式的向下转型。

82 如果被调用的名称没有包含足够的类型信息,请避免使用var

好吧,var不是一颗银弹,这个问题将再次凸显这一点。以下代码片段可以使用显式类型或var编写,而不会丢失信息:

// using explicit types
MemoryCacheImageInputStream is =
  new MemoryCacheImageInputStream(...);
JavaCompiler jc = ToolProvider.getSystemJavaCompiler();
StandardJavaFileManager fm = compiler.getStandardFileManager(...);

因此,将前面的代码片段迁移到var将产生以下代码(通过从右侧目视检查被调用的名称来选择变量名称):

// using var
var inputStream = new MemoryCacheImageInputStream(...);
var compiler = ToolProvider.getSystemJavaCompiler();
var fileManager = compiler.getStandardFileManager(...);

同样的情况也会发生在过度命名的边界上:

// using var
var inputStreamOfCachedImages = new MemoryCacheImageInputStream(...);
var javaCompiler = ToolProvider.getSystemJavaCompiler();
var standardFileManager = compiler.getStandardFileManager(...);

因此,前面的代码在选择变量的名称和可读性时不会引起任何问题。所谓的名称包含了足够的信息,让人类对var感到舒服。

但让我们考虑以下代码片段:

// Avoid
public File fetchBinContent() {
  return new File(...);
}

// called from another place
// notice the variable name, bin
var bin = fetchBinContent();

对于人类来说,如果不检查名称fetchBinContent()的返回类型,就很难推断出名称返回的类型。根据经验,在这种情况下,解决方案应该避免var并依赖显式类型,因为右侧没有足够的信息让我们为变量选择合适的名称并获得可读性很高的代码:

// called from another place
// now the left-hand side contains enough information
File bin = fetchBinContent();

因此,如果将var与被调用的名称组合使用导致清晰度损失,则最好避免使用var。忽略此语句可能会导致混淆,并会增加理解和/或扩展代码所需的时间。

考虑另一个基于java.nio.channels.Selector类的例子。此类公开了一个名为open()static方法,该方法返回一个新打开的Selector。但是,如果我们在一个用var声明的变量中捕获这个返回值,我们很可能会认为这个方法可能返回一个boolean,表示打开当前选择器的成功。使用var而不考虑可能的清晰度损失会产生这些问题。像这样的一些问题和代码将成为一个真正的痛苦。

83 LVTI 与面向接口编程技术相结合

Java 最佳实践鼓励我们将代码绑定到抽象。换句话说,我们需要依赖于面向接口编程的技术。

这种技术非常适合于集合声明。例如,建议声明ArrayList如下:

List<String> players = new ArrayList<>();

我们也应该避免这样的事情:

ArrayList<String> players = new ArrayList<>();

通过遵循第一个示例,代码实例化了ArrayList类(或HashSetHashMap等),但声明了一个List类型的变量(或SetMap等)。由于ListSetMap以及更多的都是接口(或契约),因此很容易用ListSetMap的其他实现来替换实例化,而无需对代码进行后续修改。

不幸的是,LVTI 不能利用面向接口编程技术。换句话说,当我们使用var时,推断的类型是具体的实现,而不是合同。例如,将List<String>替换为var将导致推断类型ArrayList<String>

// inferred as ArrayList<String>
var playerList = new ArrayList<String>();

然而,有一些解释支持这种行为:

  • LVTI 在局部级别(局部变量)起作用,其中面向接口编程技术的的使用少于方法参数/返回类型或字段类型。
  • 由于局部变量的作用域很小,因此切换到另一个实现所引起的修改也应该很小。切换实现对检测和修复代码的影响应该很小。
  • LVTI 将右侧的代码视为一个用于推断实际类型的初始化器。如果将来要修改这个初始化器,那么推断的类型可能不同,这将导致使用此变量的代码出现问题。

84 LVTI 和菱形运算符相结合

根据经验,如果右侧不存在推断预期类型所需的信息,则 LVTI 与菱形运算符结合可能会导致意外的推断类型。

在 JDK7 之前,即 Coin 项目,List<String>将声明如下:

List<String> players = new ArrayList<String>();

基本上,前面的示例显式指定泛型类的实例化参数类型。从 JDK7 开始,Coin 项目引入了菱形操作符,可以推断泛型类实例化参数类型,如下所示:

List<String> players = new ArrayList<>();

现在,如果我们从 LVTI 的角度来考虑这个例子,我们将得到以下结果:

var playerList = new ArrayList<>();

但是现在推断出的类型是什么呢?好吧,我们有一个问题,因为推断的类型将是ArrayList<Object>,而不是ArrayList<String>。解释很明显:推断预期类型(String所需的信息不存在(注意,右侧没有提到String类型)。这指示 LVTI 推断出最广泛适用的类型,在本例中是Object

但是如果ArrayList<Object>不是我们的意图,那么我们需要一个解决这个问题的方法。解决方案是提供推断预期类型所需的信息,如下所示:

var playerList = new ArrayList<String>();

现在,推断的类型是ArrayList<String>。也可以间接推断类型。请参见以下示例:

var playerStack = new ArrayDeque<String>();

// inferred as ArrayList<String>
var playerList = new ArrayList<>(playerStack);

也可以通过以下方式间接推断:

Player p1 = new Player();
Player p2 = new Player();
var listOfPlayer = List.of(p1, p2); // inferred as List<Player>

// Don't do this!
var listOfPlayer = new ArrayList<>(); // inferred as ArrayList<Object>
listOfPlayer.add(p1);
listOfPlayer.add(p2);

85 将数组赋给var

根据经验,将数组分配给var不需要括号[]。通过相应的显式类型定义一个int数组可以如下所示:

int[] numbers = new int[10];

// or, less preferred
int numbers[] = new int[10];

现在,尝试直觉地使用var代替int可能会导致以下尝试:

var[] numberArray = new int[10];
var numberArray[] = new int[10];

不幸的是,这两种方法都无法编译。解决方案要求我们从左侧拆下支架:

// Prefer
var numberArray = new int[10]; // inferred as array of int, int[]
numberArray[0] = 3;            // works
numberArray[0] = 3.2;          // doesn't work
numbers[0] = "3";              // doesn't work

通常的做法是在声明时初始化数组,如下所示:

// explicit type work as expected
int[] numbers = {1, 2, 3};

但是,尝试使用var将不起作用(不会编译):

// Does not compile
var numberArray = {1, 2, 3};
var numberArray[] = {1, 2, 3};
var[] numberArray = {1, 2, 3};

此代码无法编译,因为右侧没有自己的类型。

86 在复合声明中使用 LVTI

复合声明允许我们声明一组相同类型的变量,而无需重复该类型。类型只指定一次,变量用逗号分隔:

// using explicit type
String pending = "pending", processed = "processed", 
       deleted = "deleted";

String替换为var将导致无法编译的代码:

// Does not compile
var pending = "pending", processed = "processed", deleted = "deleted";

此问题的解决方案是将复合声明转换为每行一个声明:

// using var, the inferred type is String
var pending = "pending";
var processed = "processed";
var deleted = "deleted";

因此,根据经验,LVTI 不能用在复合声明中。

87 LVTI 和变量范围

干净的代码最佳实践包括为所有局部变量保留一个小范围。这是在 LVTI 存在之前就遵循的干净代码黄金规则之一。

此规则支持可读性和调试阶段。它可以加快查找错误和编写修复程序的过程。请考虑以下打破此规则的示例:

// Avoid
...
var stack = new Stack<String>();
stack.push("John");
stack.push("Martin");
stack.push("Anghel");
stack.push("Christian");

// 50 lines of code that doesn't use stack

// John, Martin, Anghel, Christian
stack.forEach(...);

因此,前面的代码声明了一个具有四个名称的栈,包含 50 行不使用此栈的代码,并通过forEach()方法完成此栈的循环。此方法继承自java.util.Vector,将栈作为任意向量(JohnMartinAnghelChristian循环。这是我们想要的遍历顺序。

但后来,我们决定从栈切换到ArrayDeque(原因无关紧要)。这次,forEach()方法将是由ArrayDeque类提供的方法。此方法的行为不同于Vector.forEach(),即循环将遍历后进先出LIFO)遍历(ChristianAnghelMartinJohn之后的条目:

// Avoid
...
var stack = new ArrayDeque<String>();
stack.push("John");
stack.push("Martin");
stack.push("Anghel");
stack.push("Christian");

// 50 lines of code that doesn't use stack

// Christian, Anghel, Martin, John
stack.forEach(...);

这不是我们的本意!我们切换到ArrayDeque是为了其他目的,而不是为了影响循环顺序。但是很难看出代码中有 bug,因为包含forEach()部分的代码部分不在我们完成修改的代码附近(代码行下面 50 行)。我们有责任提出一个解决方案,最大限度地提高快速修复这个 bug 的机会,避免一堆上下滚动来了解正在发生的事情。解决方案包括遵循我们之前调用的干净代码规则,并使用小范围的stack变量编写此代码:

// Prefer
...
var stack = new Stack<String>();
stack.push("John");
stack.push("Martin");
stack.push("Anghel");
stack.push("Christian");

// John, Martin, Anghel, Christian
stack.forEach(...);

// 50 lines of code that doesn't use stack

现在,当我们从Stack切换到ArrayQueue时,我们应该更快地注意到错误并能够修复它。

88 LVTI 与三元运算符

只要写入正确,三元运算符允许我们在右侧使用不同类型的操作数。例如,以下代码将不会编译:

// Does not compile
List evensOrOdds = containsEven ?
  List.of(10, 2, 12) : Set.of(13, 1, 11);

// Does not compile
Set evensOrOdds = containsEven ?
  List.of(10, 2, 12) : Set.of(13, 1, 11);

但是,可以通过使用正确/支持的显式类型重写代码来修复此代码:

Collection evensOrOdds = containsEven ?
  List.of(10, 2, 12) : Set.of(13, 1, 11);

Object evensOrOdds = containsEven ?
  List.of(10, 2, 12) : Set.of(13, 1, 11);

对于以下代码片段,类似的尝试将失败:

// Does not compile
int numberOrText = intOrString ? 2234 : "2234";

// Does not compile
String numberOrText = intOrString ? 2234 : "2234";

但是,可以这样修复:

Serializable numberOrText = intOrString ? 2234 : "2234";

Object numberOrText = intOrString ? 2234 : "2234";

因此,为了在右侧有一个具有不同类型操作数的三元运算符,开发人员必须匹配支持两个条件分支的正确类型。或者,开发人员可以依赖 LVTI,如下所示(当然,这也适用于相同类型的操作数):

// inferred type, Collection<Integer>
var evensOrOddsCollection = containsEven ?
  List.of(10, 2, 12) : Set.of(13, 1, 11);

// inferred type, Serializable
var numberOrText = intOrString ? 2234 : "2234";

不要从这些例子中得出结论,var类型是在运行时推断出来的!不是的!

89 LVTI 和for循环

使用显式类型声明简单的for循环是一项琐碎的任务,如下所示:

// explicit type
for (int i = 0; i < 5; i++) {
  ...
}

或者,我们可以使用增强的for循环:

List<Player> players = List.of(
  new Player(), new Player(), new Player());
for (Player player: players) {
  ...
}

从 JDK10 开始,我们可以将变量的显式类型iplayer替换为var,如下所示:

for (var i = 0; i < 5; i++) { // i is inferred of type int
  ...
}

for (var player: players) { // i is inferred of type Player
  ...
}

当循环数组、集合等的类型发生更改时,使用var可能会有所帮助。例如,通过使用var,可以在不指定显式类型的情况下循环以下array的两个版本:

// a variable 'array' representing an int[]
int[] array = { 1, 2, 3 };

// or the same variable, 'array', but representing a String[]
String[] array = {
  "1", "2", "3"
};

// depending on how 'array' is defined 
// 'i' will be inferred as int or as String
for (var i: array) {
  System.out.println(i);
}

90 LVTI 和流

让我们考虑以下Stream<Integer>流:

// explicit type
Stream<Integer> numbers = Stream.of(1, 2, 3, 4, 5);
numbers.filter(t -> t % 2 == 0).forEach(System.out::println);

使用 LVTI 代替Stream<Integer>非常简单。只需将Stream<Integer>替换为var,如下所示:

// using var, inferred as Stream<Integer>
var numberStream = Stream.of(1, 2, 3, 4, 5);
numberStream.filter(t -> t % 2 == 0).forEach(System.out::println);

下面是另一个例子:

// explicit types
Stream<String> paths = Files.lines(Path.of("..."));
List<File> files = paths.map(p -> new File(p)).collect(toList());

// using var
// inferred as Stream<String>
var pathStream = Files.lines(Path.of(""));

// inferred as List<File>
var fileList = pathStream.map(p -> new File(p)).collect(toList());

看起来 Java10、LVTI、Java8 和StreamAPI 是一个很好的团队。

91 使用 LVTI 分解嵌套/大型表达式链

大型/嵌套表达式通常是一些代码片段,它们看起来非常令人印象深刻,令人生畏。它们通常被视为智能智慧代码的片段。关于这是好是坏是有争议的,但最有可能的是,这种平衡倾向于有利于那些声称应该避免这种代码的人。例如,检查以下表达式:

List<Integer> ints = List.of(1, 1, 2, 3, 4, 4, 6, 2, 1, 5, 4, 5);

// Avoid
int result = ints.stream()
  .collect(Collectors.partitioningBy(i -> i % 2 == 0))
  .values()
  .stream()
  .max(Comparator.comparing(List::size))
  .orElse(Collections.emptyList())
  .stream()
  .mapToInt(Integer::intValue)
  .sum();

这样的表达式可以是有意编写的,也可以表示一个增量过程的最终结果,该过程在时间上丰富了一个最初很小的表达式。然而,当这些表达式开始成为可读性的空白时,它们必须通过局部变量被分解成碎片。但这并不有趣,可以被认为是我们想要避免的令人筋疲力尽的工作:

List<Integer> ints = List.of(1, 1, 2, 3, 4, 4, 6, 2, 1, 5, 4, 5);

// Prefer
Collection<List<Integer>> evenAndOdd = ints.stream()
  .collect(Collectors.partitioningBy(i -> i % 2 == 0))
  .values();

List<Integer> evenOrOdd = evenAndOdd.stream()
  .max(Comparator.comparing(List::size))
  .orElse(Collections.emptyList());

int sumEvenOrOdd = evenOrOdd.stream()
  .mapToInt(Integer::intValue)
  .sum();

检查前面代码中局部变量的类型。我们有Collection<List<Integer>>List<Integer>int。很明显,这些显式类型需要一些时间来获取和写入。这可能是避免将此表达式拆分为碎片的一个很好的理由。然而,如果我们希望采用局部变量的样式,那么使用var类型而不是显式类型的琐碎性是很诱人的,因为它节省了通常用于获取显式类型的时间:

var intList = List.of(1, 1, 2, 3, 4, 4, 6, 2, 1, 5, 4, 5);

// Prefer
var evenAndOdd = intList.stream()
  .collect(Collectors.partitioningBy(i -> i % 2 == 0))
  .values();

var evenOrOdd = evenAndOdd.stream()
  .max(Comparator.comparing(List::size))
  .orElse(Collections.emptyList());

var sumEvenOrOdd = evenOrOdd.stream()
  .mapToInt(Integer::intValue)
  .sum();

令人惊叹的!现在,编译器的任务是推断这些局部变量的类型。我们只选择打破表达的点,用var来划分。

92 LVTI 和方法返回值和参数类型

根据经验,LVTI 不能用作return方法类型或参数方法类型;相反,var类型的变量可以作为方法参数传递或存储return方法。让我们通过几个例子来迭代这些语句:

  • LVTI 不能用作以下代码不编译的方法返回类型:
// Does not compile
public var fetchReport(Player player, Date timestamp) {

  return new Report();
}
  • LVTI 不能用作方法参数类型以下代码不编译:
public Report fetchReport(var player, var timestamp) {

  return new Report();
}
  • var类型的变量可以作为方法参数传递,也可以存储一个返回方法。下面的代码编译成功并且可以工作:
public Report checkPlayer() {

  var player = new Player();
  var timestamp = new Date();
  var report = fetchReport(player, timestamp);

  return report;
}

public Report fetchReport(Player player, Date timestamp) {

  return new Report();
}

93 LVTI 和匿名类

LVTI 可以用于匿名类。下面是一个匿名类的示例,该类对weighter变量使用显式类型:

public interface Weighter {
  int getWeight(Player player);
}

Weighter weighter = new Weighter() {
  @Override
  public int getWeight(Player player) {
    return ...;
  }
};

Player player = ...;
int weight = weighter.getWeight(player);

现在,看看如果我们使用 LVTI 会发生什么:

var weighter = new Weighter() {
  @Override
  public int getWeight(Player player) {
    return ...;
  }
};

94 LVTI 可以是最终的,也可以是有效最终的

作为一个快速提醒,从 JavaSE8 开始,一个局部类可以访问封闭块的局部变量和参数,这些变量和参数是final或实际上是final。一个变量或参数,其值在初始化后从未改变,实际上是最终的

下面的代码片段表示一个有效最终变量(尝试重新分配ratio变量将导致错误,这意味着该变量是有效最终)和两个final变量(尝试重新分配limitbmi变量将导致错误)的用例在一个错误中,这意味着这些变量是final

public interface Weighter {
  float getMarginOfError();
}

float ratio = fetchRatio(); // this is effectively final

var weighter = new Weighter() {
  @Override
  public float getMarginOfError() {
    return ratio * ...;
  }
};

ratio = fetchRatio(); // this reassignment will cause error

public float fetchRatio() {

  final float limit = new Random().nextFloat(); // this is final
  final float bmi = 0.00023f;                   // this is final

  limit = 0.002f; // this reassignment will cause error
  bmi = 0.25f;    // this reassignment will cause error

  return limit * bmi / 100.12f;
}

现在,让我们用var替换显式类型。编译器将推断出这些变量(ratiolimitbmi的正确类型并保持它们的状态-ratio将是有效最终,而limitbmifinal。尝试重新分配其中任何一个将导致特定错误:

var ratio = fetchRatio(); // this is effectively final 
var weighter = new Weighter() {
  @Override
  public float getMarginOfError() {
    return ratio * ...;
  }
};

ratio = fetchRatio(); // this reassignment will cause error 
public float fetchRatio() {

  final var limit = new Random().nextFloat(); // this is final
 final var bmi = 0.00023f; // this is final
 limit = 0.002f; // this reassignment will cause error
 bmi = 0.25f; // this reassignment will cause error
  return limit * bmi / 100.12f;
}

95 LVTI 和 Lambda

使用 LVTI 和 Lambda 的问题是无法推断具体类型。不允许使用 Lambda 和方法引用初始化器。此语句是var限制的一部分;因此,Lambda 表达式和方法引用需要显式的目标类型。

例如,以下代码片段将不会编译:

// Does not compile
// lambda expression needs an explicit target-type
var incrementX = x -> x + 1;

// method reference needs an explicit target-type
var exceptionIAE = IllegalArgumentException::new;

由于var不能使用,所以这两段代码需要编写如下:

Function<Integer, Integer> incrementX = x -> x + 1;
Supplier<IllegalArgumentException> exceptionIAE 
  = IllegalArgumentException::new;

但是在 Lambda 的上下文中,Java11 允许我们在 Lambda 参数中使用var。例如,下面的代码在 Java11 中工作(更多详细信息可以在《JEP323:Lambda 参数的局部变量语法》中找到:

@FunctionalInterface
public interface Square {
  int calculate(int x);
}

Square square = (var x) -> x * x;

但是,请记住,以下操作不起作用:

var square = (var x) -> x * x; // cannot infer

96 LVTI 和null初始化器、实例变量和catch块变量

LVTI 与null初始化器、实例变量和catch块变量有什么共同点?嗯,LVTI 不能和它们一起使用。以下尝试将失败:

  • LVTI 不能与null初始化器一起使用:
// result in an error of type: variable initializer is 'null'
var message = null;

// result in: cannot use 'var' on variable without initializer
var message;
  • LVTI 不能与实例变量(字段)一起使用:
public class Player {

  private var age; // error: 'var' is not allowed here
  private var name; // error: 'var' is not allowed here
  ...
}
  • LVTI 不能用于catch块变量:
try {
  TimeUnit.NANOSECONDS.sleep(1000);
} catch (var ex) {  ... }

资源尝试使用

另一方面,var类型非常适合资源尝试使用,如下例所示:

// explicit type
try (PrintWriter writer = new PrintWriter(new File("welcome.txt"))) {
  writer.println("Welcome message");
}
// using var
try (var writer = new PrintWriter(new File("welcome.txt"))) {
  writer.println("Welcome message");
}

97 LVTI 和泛型类型,T

为了理解 LVTI 如何与泛型类型相结合,让我们从一个示例开始。以下方法是泛型类型T的经典用例:

public static <T extends Number> T add(T t) {
  T temp = t;
  ...
  return temp;
}

在这种情况下,我们可以将T替换为var,代码将正常工作:

public static <T extends Number> T add(T t) {
  var temp = t;
  ...
  return temp;
}

因此,具有泛型类型的局部变量可以利用 LVTI。让我们看看其他一些示例,首先使用泛型类型T

public <T extends Number> T add(T t) {

  List<T> numberList = new ArrayList<T>();
  numberList.add(t);
  numberList.add((T) Integer.valueOf(3));
  numberList.add((T) Double.valueOf(3.9));

  // error: incompatible types: String cannot be converted to T
  // numbers.add("5");

  return numberList.get(0);
}

现在,我们将List<T>替换为var

public <T extends Number> T add(T t) {

  var numberList = new ArrayList<T>();
  numberList.add(t);
  numberList.add((T) Integer.valueOf(3));
  numberList.add((T) Double.valueOf(3.9));

  // error: incompatible types: String cannot be converted to T
  // numbers.add("5");

  return numberList.get(0);
}

注意并仔细检查ArrayList实例化是否存在T。不要这样做(这将被推断为ArrayList<Object>,并将忽略泛型类型T后面的实际类型):

var numberList = new ArrayList<>();

98 LVTI、通配符、协变和逆变

用 LVTI 替换通配符、协变和逆变是一项微妙的工作,应该在充分意识到后果的情况下完成。

LVTI 和通配符

首先,我们来讨论 LVTI 和通配符(?。通常的做法是将通配符与Class关联,并编写如下内容:

// explicit type
Class<?> clazz = Long.class;

在这种情况下,使用var代替Class<?>没有问题。根据右边的类型,编译器将推断出正确的类型。在本例中,编译器将推断出Class<Long>

但是请注意,用 LVTI 替换通配符应该小心,并且您应该意识到其后果(或副作用)。让我们看一个例子,用var替换通配符是一个错误的选择。考虑以下代码:

Collection<?> stuff = new ArrayList<>();
stuff.add("hello"); // compile time error
stuff.add("world"); // compile time error

由于类型不兼容,此代码无法编译。一种非常糟糕的方法是用var替换通配符来修复此代码,如下所示:

var stuff = new ArrayList<>();
strings.add("hello"); // no error
strings.add("world"); // no error

通过使用var,错误将消失,但这不是我们在编写前面的代码(存在类型不兼容错误的代码)时想到的。所以,根据经验,不要仅仅因为一些恼人的错误会神奇地消失,就用var代替Foo<?>!试着思考一下预期的任务是什么,并相应地采取行动。例如,可能在前面的代码片段中,我们试图定义ArrayList<String>,但由于错误,最终得到了Collection<?>

LVTI 和协变/逆变

用 LVTI 替换协变(Foo<? extends T>)或逆变(Foo<? super T>)是一种危险的方法,应该避免。

请查看以下代码片段:

// explicit types
Class<? extends Number> intNumber = Integer.class;
Class<? super FilterReader> fileReader = Reader.class;

在协变中,我们有一个上界,由Number类表示,而在逆变中,我们有一个下界,由FilterReader类表示。有了这些边界(或约束),以下代码将触发特定的编译时错误:

// Does not compile
// error: Class<Reader> cannot be converted 
//        to Class<? extends Number>
Class<? extends Number> intNumber = Reader.class;

// error: Class<Integer> cannot be converted 
//        to Class<? super FilterReader>
Class<? super FilterReader> fileReader = Integer.class;

现在,让我们用var代替前面的协变和逆变:

// using var
var intNumber = Integer.class;
var fileReader = Reader.class;

此代码不会导致任何问题。现在,我们可以将任何类赋给这些变量,这样我们的边界/约束就消失了。这不是我们打算做的:

// this will compile just fine
var intNumber = Reader.class;
var fileReader = Integer.class;

所以,用var代替协变和逆变是个错误的选择!

总结

这是本章的最后一个问题。请看《JEP323:Lambda 参数的局部变量语法》《JEP301:增强枚举》了解更多信息。只要您熟悉本章介绍的问题,采用这些特性应该是相当顺利的。

从本章下载应用以查看结果和其他详细信息。

五、数组、集合和数据结构

本章包括 30 个问题,涉及数组、集合和几个数据结构。其目的是为在广泛的应用中遇到的一类问题提供解决方案,包括排序、查找、比较、排序、反转、填充、合并、复制和替换。提供的解决方案是用 Java8-12 实现的,它们也可以作为解决其他相关问题的基础。在本章的最后,您将掌握广泛的知识,这些知识对于解决涉及数组、集合和数据结构的各种问题非常有用。

问题

使用以下问题测试基于数组、集合和数据结构的编程能力。我强烈建议您在使用解决方案和下载示例程序之前,先尝试一下每个问题:

  1. 数组排序:编写几个程序,举例说明数组的不同排序算法。另外,编写一个数组洗牌程序。

  2. 寻找数组中的元素:编写几个程序,举例说明如何在给定的数组中找到给定的元素(原始类型和对象)。查找索引和/或简单地检查值是否在数组中。

  3. 检查两个数组是否相等或不匹配:编写一个程序,检查给定的两个数组是否相等或不匹配。

  4. 按字典比较两个数组:编写一个程序,按字典法比较给定的数组。

  5. 从数组创建流:编写从给定数组创建流的程序。

  6. 数组的最小值、最大值和平均值:编写一个程序,计算给定数组的最大值、最小值和平均值。

  7. 反转数组:写一个程序反转给定的数组。

  8. 填充和设置数组:写几个填充数组和基于生成函数设置所有元素的例子来计算每个元素。

  9. 下一个较大的元素NGE):编写一个程序,返回数组中每个元素的 NGE。

  10. 改变数组大小:编写一个程序,通过将数组的大小增加一个元素来增加数组的大小。另外,编写一个程序,用给定的长度增加数组的大小。

  11. 创建不可修改/不可变集合:编写几个创建不可修改和不可变集合的示例。

  12. 映射的默认值:编写一个程序,从Map获取一个值或一个默认值。

  13. 计算Map中的键是否缺失/存在:编写一个程序,计算缺失键的值或当前键的新值。

  14. Map中删除条目:编写一个程序,用给定的键从Map删除。

  15. 替换Map中的条目:编写一个程序来替换Map中给定的条目。

  16. 比较两个映射:编写一个比较两幅映射的程序。

  17. 合并两个映射:编写一个程序,合并两个给定的映射。

  18. 复制HashMap:编写一个程序,执行HashMap的浅复制和深复制。

  19. 排序Map:编写一个程序对Map进行排序。

  20. 删除集合中与谓词匹配的所有元素:编写一个程序,删除集合中与给定谓词匹配的所有元素。

  21. 将集合转换成数组:编写一个程序,将集合转换成数组。

  22. 过滤List集合:写几个List过滤集合的方案。揭示最好的方法。

  23. 替换List的元素:编写一个程序,将List的每个元素替换为对其应用给定运算符的结果。

  24. 线程安全集合、栈和队列:编写几个程序来举例说明 Java 线程安全集合的用法。

  25. 广度优先搜索BFS):编写实现 BFS 算法的程序。

  26. Trie:编写一个实现 Trie 数据结构的程序。

  27. 元组:编写实现元组数据结构的程序。

  28. 并查:编写实现并查算法的程序。

  29. Fenwick 树或二叉索引树:编写一个实现 Fenwick 树算法的程序。

  30. 布隆过滤器:编写实现布隆过滤器算法的程序。

**# 解决方案

以下各节介绍上述问题的解决方案。记住,通常没有一个正确的方法来解决一个特定的问题。另外,请记住,这里显示的解释仅包括解决问题所需的最有趣和最重要的细节。下载示例解决方案以查看更多详细信息,并在这个页面中试用程序。

99 排序数组

排序数组是许多域/应用中遇到的常见任务。Java 提供了一个内置的解决方案,使用比较器对原始类型和对象的数组进行排序,这一点非常常见。这种解决方案效果很好,在大多数情况下都是比较可取的方法。让我们在下一节中看看不同的解决方案。

JDK 内置解决方案

内置的解决方案名为sort(),它在java.util.Arrays类中有许多不同的风格(15 种以上的风格)。

sort()方法的背后,有一个性能良好的快速排序类型的排序算法,称为双轴快速排序。

假设我们需要按自然顺序对整数数组进行排序(原始类型int。为此,我们可以依赖于Arrays.sort(int[] a),如下例所示:

int[] integers = new int[]{...};
Arrays.sort(integers);

有时,我们需要对一个对象数组进行排序。假设我们有一个类Melon

public class Melon {

  private final String type;
  private final int weight;

  public Melon(String type, int weight) {
    this.type = type;
    this.weight = weight;
  }

  // getters omitted for brevity
}

Melon的数组可以通过适当的Comparator按升序权重排序:

Melon[] melons = new Melon[] { ... };

Arrays.sort(melons, new Comparator<Melon>() {
  @Override
  public int compare(Melon melon1, Melon melon2) {
    return Integer.compare(melon1.getWeight(), melon2.getWeight());
  }
});

通过 Lambda 表达式重写前面的代码可以获得相同的结果:

Arrays.sort(melons, (Melon melon1, Melon melon2) 
  -> Integer.compare(melon1.getWeight(), melon2.getWeight()));

此外,数组提供了一种并行排序元素的方法parallelSort()。幕后使用的排序算法是一种基于ForkJoinPool的并行排序合并,它将数组分解为子数组,子数组本身进行排序,然后进行合并。举个例子:

Arrays.parallelSort(melons, new Comparator<Melon>() {
  @Override
  public int compare(Melon melon1, Melon melon2) {
    return Integer.compare(melon1.getWeight(), melon2.getWeight());
  }
});

或者,通过 Lambda 表达式,我们有以下示例:

Arrays.parallelSort(melons, (Melon melon1, Melon melon2) 
  -> Integer.compare(melon1.getWeight(), melon2.getWeight()));

前面的示例按升序对数组排序,但有时需要按降序对其排序。当我们对一个Object数组进行排序并依赖于一个Comparator时,我们可以简单地将返回的结果乘以Integer.compare()再乘以 -1:

Arrays.sort(melons, new Comparator<Melon>() {
  @Override
  public int compare(Melon melon1, Melon melon2) {
    return (-1) * Integer.compare(melon1.getWeight(), 
      melon2.getWeight());
  }
});

或者,我们可以简单地在compare()方法中切换参数。

对于装箱原始类型的数组,解决方案可以依赖于Collections.reverse()方法,如下例所示:

Integer[] integers = new Integer[] {3, 1, 5};

// 1, 3, 5
Arrays.sort(integers);

// 5, 3, 1
Arrays.sort(integers, Collections.reverseOrder());

不幸的是,没有内置的解决方案来按降序排列原始类型数组。最常见的情况是,如果我们仍然要依赖于Arrays.sort(),那么这个问题的解决方案是在数组按升序排序后反转数组(O(n)):

// sort ascending
Arrays.sort(integers);

// reverse array to obtain it in descending order
for (int leftHead = 0, rightHead = integers.length - 1;
       leftHead < rightHead; leftHead++, rightHead--) {

  int elem = integers[leftHead];
  integers[leftHead] = integers[rightHead];
  integers[rightHead] = elem;
}

另一个解决方案可以依赖于 Java8 函数式风格和装箱(请注意装箱是一个非常耗时的操作):

int[] descIntegers = Arrays.stream(integers)
  .boxed() //or .mapToObj(i -> i)
  .sorted((i1, i2) -> Integer.compare(i2, i1))
  .mapToInt(Integer::intValue)
  .toArray();

其他排序算法

嗯,还有很多其他的排序算法。每种方法都有优缺点,最好的选择方法是对应用特定的情况进行基准测试。

让我们研究其中的一些,如下一节中强调的,从一个非常慢的算法开始。

冒泡排序

冒泡排序是一个简单的算法,基本上气泡数组的元素。这意味着它会多次遍历数组,并在相邻元素顺序错误时交换它们,如下图所示:

时间复杂度情况如下:最佳情况O(n)、平均情况O(n<sup>2</sup>)、最坏情况O(n<sup>2</sup>)

空间复杂度情况如下:最坏情况O(1)

实现冒泡排序的实用方法如下:

public static void bubbleSort(int[] arr) {

  int n = arr.length;

  for (int i = 0; i < n - 1; i++) {
    for (int j = 0; j < n - i - 1; j++) {

      if (arr[j] > arr[j + 1]) {
        int temp = arr[j];
        arr[j] = arr[j + 1];
        arr[j + 1] = temp;
      }
    }
  }
}

还有一个依赖于while循环的优化版本。你可以在捆绑到这本书的代码中找到它,名字是bubbleSortOptimized()

作为时间执行的性能比较,对于 100000 个整数的随机数组,优化后的版本将快 2 秒左右。

前面的实现可以很好地对原始类型数组进行排序,但是,要对Object数组进行排序,我们需要在代码中引入Comparator,如下所示:

public static <T> void bubbleSortWithComparator(
    T arr[], Comparator<? super T> c) {

  int n = arr.length;

  for (int i = 0; i < n - 1; i++) {
    for (int j = 0; j < n - i - 1; j++) {

      if (c.compare(arr[j], arr[j + 1]) > 0) {
        T temp = arr[j];
        arr[j] = arr[j + 1];
        arr[j + 1] = temp;
      }
    }
  }
}

还记得以前的类吗?好吧,我们可以通过实现Comparator接口为它写一个Comparator

public class MelonComparator implements Comparator<Melon> {

  @Override
  public int compare(Melon o1, Melon o2) {
    return o1.getType().compareTo(o2.getType());
  }
}

或者,在 Java8 函数式风格中,我们有以下内容:

// Ascending
Comparator<Melon> byType = Comparator.comparing(Melon::getType);

// Descending
Comparator<Melon> byType 
  = Comparator.comparing(Melon::getType).reversed();

在一个名为ArraySorts的工具类中,有一个Melon数组、前面的Comparator数组和bubbleSortWithComparator()方法,我们可以按照下面的思路编写一些东西:

Melon[] melons = {...};
ArraySorts.bubbleSortWithComparator(melons, byType);

为简洁起见,跳过了带有Comparator的冒泡排序优化版本,但它在绑定到本书的代码中可用。

当数组几乎已排序时,冒泡排序速度很快。此外,它还非常适合对兔子(接近数组开头的大元素)和海龟(接近数组结尾的小元素)进行排序。但总的来说,这是一个缓慢的算法。

插入排序

插入排序算法依赖于一个简单的流。它从第二个元素开始,并将其与前面的元素进行比较。如果前面的元素大于当前元素,则算法将交换这些元素。此过程将继续,直到前面的元素小于当前元素。

在这种情况下,算法将传递到数组中的下一个元素并重复该流,如下图所示:

时间复杂度情况如下:最佳情况O(n)、平均情况O(n<sup>2</sup>)、最坏情况O(n<sup>2</sup>)

空间复杂度情况如下:最坏情况O(1)

基于此流程,原始类型的实现如下所示:

public static void insertionSort(int arr[]) {

  int n = arr.length;

  for (int i = 1; i < n; ++i) {

    int key = arr[i];
    int j = i - 1;

    while (j >= 0 && arr[j] > key) {
      arr[j + 1] = arr[j];
      j = j - 1;
    }

    arr[j + 1] = key;
  }
}

为了比较一个Melon数组,我们需要在实现中引入一个Comparator,如下所示:

public static <T> void insertionSortWithComparator(
  T arr[], Comparator<? super T> c) {

  int n = arr.length;

  for (int i = 1; i < n; ++i) {

    T key = arr[i];
    int j = i - 1;

    while (j >= 0 && c.compare(arr[j], key) > 0) {
      arr[j + 1] = arr[j];
      j = j - 1;
    }

    arr[j + 1] = key;
  }
}

在这里,我们有一个Comparator,它使用thenComparing()方法,按照 Java8 函数式编写的类型和重量对西瓜进行排序:

Comparator<Melon> byType = Comparator.comparing(Melon::getType)
  .thenComparing(Melon::getWeight);

在一个名为ArraySorts的实用类中,有一个Melon数组、前面的Comparator数组和insertionSortWithComparator()方法,我们可以编写如下内容:

Melon[] melons = {...};
ArraySorts.insertionSortWithComparator(melons, byType);

对于较小且大部分排序的数组,这可能会很快。此外,在向数组中添加新元素时,它的性能也很好。它也是非常有效的内存,因为一个单一的元素是移动。

计数排序

计数排序流从计算数组中的最小和最大元素开始。该算法根据计算出的最小值和最大值定义一个新的数组,该数组将使用元素作为索引对未排序的元素进行计数。此外,以这样的方式修改这个新数组,使得每个索引处的每个元素存储先前计数的总和。最后,从这个新的数组中得到排序后的数组。

时间复杂度情况如下:最佳情况O(n + k)、平均情况O(n + k)、最坏情况O(n + k)

空间复杂度情况如下:最坏情况O(k)

k is the number of possible values in the range.
n is the number of elements to be sorted.

让我们考虑一个简单的例子。初始数组包含以下元素,arr4262685

最小元件为2,最大元件为8。新数组counts的大小等于最大值减去最小值+1=8-2+1=7

对每个元素进行计数将产生以下数组(counts[arr[i] - min]++):

counts[2] = 1 (4); counts[0] = 2 (2); counts[4] = 2 (6);
counts[6] = 1 (8); counts[3] = 1 (5);

现在,我们必须循环此数组,并使用它重建排序后的数组,如下所示:

public static void countingSort(int[] arr) {

  int min = arr[0];
  int max = arr[0];

  for (int i = 1; i < arr.length; i++) {
    if (arr[i] < min) {
      min = arr[i];
    } else if (arr[i] > max) {
      max = arr[i];
    }
  }

  int[] counts = new int[max - min + 1];

  for (int i = 0; i < arr.length; i++) {
    counts[arr[i] - min]++;
  }

  int sortedIndex = 0;

  for (int i = 0; i < counts.length; i++) {
    while (counts[i] > 0) {
      arr[sortedIndex++] = i + min;
      counts[i]--;
    }
  }
}

这是一个非常快速的算法。

堆排序

堆排序是一种依赖于二进制堆(完全二叉树)的算法。

时间复杂度情况如下:最佳情况O(n log n)、平均情况O(n log n)、最坏情况O(n log n)

空间复杂度情况如下:最坏情况O(1)

可以通过最大堆(父节点总是大于或等于子节点)按升序排序元素,通过最小堆(父节点总是小于或等于子节点)按降序排序元素。

在第一步,该算法使用提供的数组来构建这个堆,并将其转换为一个最大堆(该堆由另一个数组表示)。因为这是一个最大堆,所以最大的元素是堆的根。在下一步中,根与堆中的最后一个元素交换,堆大小减少 1(从堆中删除最后一个节点)。堆顶部的元素按顺序排列。最后一步由建堆(以自顶向下的方式构建堆的递归过程)和堆的根(重构最大堆)组成。重复这三个步骤,直到堆大小大于 1:

例如,假设上图中的数组-45271

  1. 因此,在第一步,我们构建堆:45271
  2. 我们构建了最大堆75241(我们将544757进行了交换)。
  3. 接下来,我们将根(7)与最后一个元素(1)交换并删除7。结果:15247
  4. 进一步,我们再次构建最大堆5421(我们将51进行了交换,将14进行了交换)。
  5. 我们将根(5)与最后一个元素(1)交换,并删除5。结果:14257
  6. 接下来,我们再次构建最大堆412(我们将14进行了交换)。
  7. 我们将根(4)与最后一个元素(2)交换,并删除4。结果:21
  8. 这是一个最大堆,因此将根(2)与最后一个元素(1)交换并移除212457
  9. 完成!堆中只剩下一个元素(1)。

在代码行中,前面的示例可以概括如下:

public static void heapSort(int[] arr) {
  int n = arr.length;

  buildHeap(arr, n);

  while (n > 1) {
    swap(arr, 0, n - 1);
    n--;
    heapify(arr, n, 0);
  }
}

private static void buildHeap(int[] arr, int n) {
  for (int i = arr.length / 2; i >= 0; i--) {
    heapify(arr, n, i);
  }
}

private static void heapify(int[] arr, int n, int i) {
  int left = i * 2 + 1;
  int right = i * 2 + 2;
  int greater;

  if (left < n && arr[left] > arr[i]) {
    greater = left;
  } else {
    greater = i;
  }

  if (right < n && arr[right] > arr[greater]) {
    greater = right;
  }

  if (greater != i) {
    swap(arr, i, greater);
    heapify(arr, n, greater);
  }
}

private static void swap(int[] arr, int x, int y) {
  int temp = arr[x];
  arr[x] = arr[y];
  arr[y] = temp;
}

如果我们想要比较对象,那么我们必须在实现中引入一个Comparator。此解决方案在捆绑到本书的代码中以heapSortWithComparator()的名称提供。

这里是一个用 Java8 函数式编写的Comparator,它使用thenComparing()reversed()方法按类型和重量降序排列瓜类:

Comparator<Melon> byType = Comparator.comparing(Melon::getType)
  .thenComparing(Melon::getWeight).reversed();                                                                                                                                                                                                                                                                                                                        

在一个名为ArraySorts的实用类中,有一个Melon数组、前面的Comparator数组和heapSortWithComparator()方法,我们可以编写如下内容:

Melon[] melons = {...};
ArraySorts.heapSortWithComparator(melons, byType);

堆排序相当快,但不稳定。例如,对已排序的数组进行排序可能会使其保持不同的顺序。

我们将在这里停止关于排序数组的论文,但是,在本书附带的代码中,还有一些排序算法可用:

还有许多其他算法专门用于排序数组。其中一些是建立在这里介绍的基础上的(例如,梳排序、鸡尾酒排序和奇偶排序是冒泡排序的风格,桶排序是通常依赖于插入排序的分布排序,基数排序(LSD)是类似于桶排序的稳定分布,Gnome 排序是插入排序的变体)。

其他则是不同的方法(例如,Arrays.sort()方法实现的快速排序,Arrays.parallelSort()方法实现的合并排序)。

作为对本节的奖励,让我们看看如何洗牌一个数组。实现这一点的有效方法依赖于 Fisher-Yates 洗牌(称为 Knuth 洗牌)。基本上,我们以相反的顺序循环数组,然后随机交换元素。对于原始类型(例如,int),实现如下:

public static void shuffleInt(int[] arr) {

  int index;

  Random random = new Random();

  for (int i = arr.length - 1; i > 0; i--) {

    index = random.nextInt(i + 1);
    swap(arr, index, i);
  }
}

在绑定到本书的代码中,还有一个实现,用于对Object的数组进行洗牌。

通过Collections.shuffle(List<?> list)洗牌列表非常简单。

100 在数组中查找元素

当我们在数组中搜索一个元素时,我们可能感兴趣的是找出这个元素出现的索引,或者只找出它是否存在于数组中。本节介绍的解决方案具体化为以下屏幕截图中的方法:

让我们在下一节中看看不同的解决方案。

只检查是否存在

假设以下整数数组:

int[] numbers = {4, 5, 1, 3, 7, 4, 1};

由于这是一个原始类型数组,解决方案可以简单地循环数组并返回给定整数的第一个匹配项,如下所示:

public static boolean containsElement(int[] arr, int toContain) {

  for (int elem: arr) {
    if (elem == toContain) {
      return true;
    }
  }

  return false;
}

这个问题的另一个解决方法可以依赖于Arrays.binarySearch()方法。这种方法有几种风格,但在这种情况下,我们需要这个:int binarySearch​(int[] a, int key)。该方法将搜索给定数组中的给定键,并返回相应的索引或负值。唯一的问题是,此方法仅适用于已排序的数组;因此,我们需要事先对数组排序:

public static boolean containsElement(int[] arr, int toContain) {

  Arrays.sort(arr);
  int index = Arrays.binarySearch(arr, toContain);

  return (index >= 0);
}

如果数组已经排序,那么可以通过删除排序步骤来优化前面的方法。此外,如果数组被排序,前面的方法可以返回数组中元素出现的索引,而不是一个boolean。但是,如果数组没有排序,请记住返回的索引对应于排序的数组,而不是未排序的(初始)数组。如果不想对初始数组进行排序,则建议将数组的克隆传递给此方法。另一种方法是在这个辅助方法中克隆数组。

在 Java8 中,解决方案可以依赖于函数式方法。这里一个很好的候选者是anyMatch()方法。此方法返回流中是否有元素与提供的谓词匹配。因此,我们需要做的就是将数组转换为流,如下所示:

public static boolean containsElement(int[] arr, int toContain) {

  return Arrays.stream(arr)
    .anyMatch(e -> e == toContain);
}

对于任何其他原始类型,改编或概括前面的示例都非常简单。

现在,让我们集中精力在数组中寻找Object。让我们考虑一下Melon类:

public class Melon {

  private final String type;
  private final int weight;

  // constructor, getters, equals() and hashCode() skipped for brevity
}

接下来,让我们考虑一个Melon数组:

Melon[] melons = new Melon[] {new Melon("Crenshaw", 2000),
  new Melon("Gac", 1200), new Melon("Bitter", 2200)
};

现在,假设我们要在这个数组中找到 1200 克的木瓜。一个解决方案可以依赖于equals()方法,该方法用于确定两个对象的相等性:

public static <T> boolean 
    containsElementObject(T[] arr, T toContain) {

  for (T elem: arr) {
    if (elem.equals(toContain)) {
      return true;
    }
  }

  return false;
}

同样,我们可以依赖于Arrays.asList(arr).contains(find)。基本上,将数组转换为一个List并调用contains()方法。在幕后,这种方法使用的是equals()合同。

如果此方法存在于名为ArraySearch的工具类中,则以下调用将返回true

// true
boolean found = ArraySearch.containsElementObject(
  melons, new Melon("Gac", 1200));

只要我们想依赖equals()合同,这个解决方案就行。但是我们可以认为,如果甜瓜的名字出现(Gac),或者它的重量出现(1200),那么我们的甜瓜就存在于数组中。对于这种情况,更实际的做法是依赖于Comparator

public static <T> boolean containsElementObject(
    T[] arr, T toContain, Comparator<? super T> c) {

  for (T elem: arr) {
    if (c.compare(elem, toContain) == 0) {
      return true;
    }
  }

  return false;
}

现在,一个只考虑瓜的类型的Comparator可以写为:

Comparator<Melon> byType = Comparator.comparing(Melon::getType);

由于Comparator忽略了瓜的重量(没有 1205 克的瓜),下面的调用将返回true

// true
boolean found = ArraySearch.containsElementObject(
  melons, new Melon("Gac", 1205), byType);

另一种方法依赖于binarySearch()的另一种风格。Arrays类提供了一个binarySearch()方法,该方法获取一个Comparator<T> int binarySearch(T[] a, T key, Comparator<? super T> c)。这意味着我们可以如下使用它:

public static <T> boolean containsElementObject(
    T[] arr, T toContain, Comparator<? super T> c) {

  Arrays.sort(arr, c);
  int index = Arrays.binarySearch(arr, toContain, c);

  return (index >= 0);
}

如果初始数组状态应保持不变,则建议将数组的克隆传递给此方法。另一种方法是在这个辅助方法中克隆数组。

现在,一个只考虑瓜重的Comparator可以写为:

Comparator<Melon> byWeight = Comparator.comparing(Melon::getWeight);

由于Comparator忽略了甜瓜的类型(没有蜜瓜类型的甜瓜),下面的调用将返回true

// true
boolean found = ArraySearch.containsElementObject(
  melons, new Melon("Honeydew", 1200), byWeight);

只检查第一个索引

对于一组原始类型,最简单的实现就说明了这一点:

public static int findIndexOfElement(int[] arr, int toFind) {

  for (int i = 0; i < arr.length; i++) {
    if (arr[i] == toFind) {
      return i;
    }
  }

  return -1;
}

依靠 Java8 函数风格,我们可以尝试循环数组并过滤与给定元素匹配的元素。最后,只需返回找到的第一个元素:

public static int findIndexOfElement(int[] arr, int toFind) {

  return IntStream.range(0, arr.length)
    .filter(i -> toFind == arr[i])
    .findFirst()
    .orElse(-1);
}

对于Object数组,至少有三种方法。首先,我们可以依据equals()合同:

public static <T> int findIndexOfElementObject(T[] arr, T toFind) {

  for (int i = 0; i < arr.length; i++) {
    if (arr[i].equals(toFind)) {
      return i;
    }
  }

  return -1;
}

同样,我们可以依赖于Arrays.asList(arr).indexOf(find)。基本上,将数组转换为一个List并调用indexOf()方法。在幕后,这种方法使用的是equals()合同。

其次,我们可以依赖于Comparator

public static <T> int findIndexOfElementObject(
    T[] arr, T toFind, Comparator<? super T> c) {

  for (int i = 0; i < arr.length; i++) {
    if (c.compare(arr[i], toFind) == 0) {
      return i;
    }
  }

  return -1;
}

第三,我们可以依赖 Java8 函数式风格和一个Comparator

public static <T> int findIndexOfElementObject(
    T[] arr, T toFind, Comparator<? super T> c) {

  return IntStream.range(0, arr.length)
    .filter(i -> c.compare(toFind, arr[i]) == 0)
    .findFirst()
    .orElse(-1);
}

101 检查两个数组是否相等或不匹配

如果两个原始数组包含相同数量的元素,则它们相等,并且两个数组中所有对应的元素对都相等

这两个问题的解决依赖于Arrays实用类。下面几节给出了解决这些问题的方法。

检查两个数组是否相等

通过Arrays.equals()方法可以很容易地检查两个数组是否相等。对于基本类型、Object和泛型,这个标志方法有很多种风格。它还支持比较器。

让我们考虑以下三个整数数组:

int[] integers1 = {3, 4, 5, 6, 1, 5};
int[] integers2 = {3, 4, 5, 6, 1, 5};
int[] integers3 = {3, 4, 5, 6, 1, 3};

现在,让我们检查一下integers1是否等于integers2,以及integers1是否等于integers3。这很简单:

boolean i12 = Arrays.equals(integers1, integers2); // true
boolean i13 = Arrays.equals(integers1, integers3); // false

前面的例子检查两个数组是否相等,但是我们也可以通过boolean equals(int[] a, int aFromIndex, int aToIndex, int[] b, int bFromIndex, int bToIndex)方法检查数组的两个段(或范围)是否相等。因此,我们通过范围[aFromIndex, aToIndex)来划分第一个数组的段,通过范围[bFromIndex, bToIndex)来划分第二个数组的段:

// true
boolean is13 = Arrays.equals(integers1, 1, 4, integers3, 1, 4);

现在,让我们假设Melon的三个数组:

public class Melon {

  private final String type;
  private final int weight;

  public Melon(String type, int weight) {
    this.type = type;
    this.weight = weight;
  }

  // getters, equals() and hashCode() omitted for brevity
}

Melon[] melons1 = {
  new Melon("Horned", 1500), new Melon("Gac", 1000)
};

Melon[] melons2 = {
  new Melon("Horned", 1500), new Melon("Gac", 1000)
};

Melon[] melons3 = {
  new Melon("Hami", 1500), new Melon("Gac", 1000)
};

基于equals()合同或基于指定的Comparator,两个Object数组被视为相等。我们可以很容易地检查melons1是否等于melons2,以及melons1是否等于melons3,如下所示:

boolean m12 = Arrays.equals(melons1, melons2); // true
boolean m13 = Arrays.equals(melons1, melons3); // false

在明确的范围内,使用boolean equals(Object[] a, int aFromIndex, int aToIndex, Object[] b, int bFromIndex, int bToIndex)

boolean ms13 = Arrays.equals(melons1, 1, 2, melons3, 1, 2); // false

虽然这些示例依赖于Melon.equals()实现,但以下两个示例依赖于以下两个Comparator

Comparator<Melon> byType = Comparator.comparing(Melon::getType);
Comparator<Melon> byWeight = Comparator.comparing(Melon::getWeight);

使用布尔值equals(T[] a, T[] a2, Comparator<? super T> cmp),我们得到以下结果:

boolean mw13 = Arrays.equals(melons1, melons3, byWeight); // true
boolean mt13 = Arrays.equals(melons1, melons3, byType);   // false

并且,在显式范围内,使用Comparator<T> boolean equals(T[] a, int aFromIndex, int aToIndex, T[] b, int bFromIndex, int bToIndex, Comparator<? super T> cmp),我们得到:

// true
boolean mrt13 = Arrays.equals(melons1, 1, 2, melons3, 1, 2, byType);

检查两个数组是否包含不匹配项

如果两个数组相等,则不匹配应返回 -1。但是如果两个数组不相等,那么不匹配应该返回两个给定数组之间第一个不匹配的索引。为了解决这个问题,我们可以依赖 JDK9Arrays.mismatch()方法。

例如,我们可以检查integers1integers2之间的不匹配,如下所示:

int mi12 = Arrays.mismatch(integers1, integers2); // -1

结果是 -1,因为integers1integers2相等。但是如果我们检查integers1integers3,我们会得到值 5,这是这两个值之间第一个不匹配的索引:

int mi13 = Arrays.mismatch(integers1, integers3); // 5

如果给定的数组有不同的长度,而较小的数组是较大数组的前缀,那么返回的不匹配就是较小数组的长度。

对于Object的数组,也有专用的mismatch()方法。这些方法依赖于equals()合同或给定的Comparator。我们可以检查melons1melons2之间是否存在不匹配,如下所示:

int mm12 = Arrays.mismatch(melons1, melons2); // -1

如果第一个索引发生不匹配,则返回值为 0。这在melons1melons3的情况下发生:

int mm13 = Arrays.mismatch(melons1, melons3); // 0

Arrays.equals()的情况下,我们可以使用Comparator检查显式范围内的不匹配:

// range [1, 2), return -1
int mms13 = Arrays.mismatch(melons1, 1, 2, melons3, 1, 2);

// Comparator by melon's weights, return -1
int mmw13 = Arrays.mismatch(melons1, melons3, byWeight);

// Comparator by melon's types, return 0
int mmt13 = Arrays.mismatch(melons1, melons3, byType);

// range [1,2) and Comparator by melon's types, return -1
int mmrt13 = Arrays.mismatch(melons1, 1, 2, melons3, 1, 2, byType);

102 按字典顺序比较两个数组

从 JDK9 开始,我们可以通过Arrays.compare()方法按字典顺序比较两个数组。既然不需要重新发明轮子,那么就升级到 JDK9,让我们深入研究一下。

两个数组的词典比较可能返回以下结果:

  • 0,如果给定数组相等并且包含相同顺序的相同元素
  • 如果第一个数组按字典顺序小于第二个数组,则值小于 0
  • 如果第一个数组按字典顺序大于第二个数组,则该值大于 0

如果第一个数组的长度小于第二个数组的长度,则第一个数组在词典上小于第二个数组。如果数组具有相同的长度,包含原始类型,并且共享一个共同的前缀,那么字典比较就是比较两个元素的结果,精确地说就是Integer.compare(int, int)Boolean.compare(boolean, boolean)Byte.compare(byte, byte)等等。如果数组包含Object,那么字典比较依赖于给定的ComparatorComparable实现。

首先,让我们考虑以下原始类型数组:

int[] integers1 = {3, 4, 5, 6, 1, 5};
int[] integers2 = {3, 4, 5, 6, 1, 5};
int[] integers3 = {3, 4, 5, 6, 1, 3};

现在,integers1在词典上等于integers2,因为它们相等并且包含相同顺序的相同元素,int compare(int[] a, int[] b)

int i12 = Arrays.compare(integers1, integers2); // 0

但是,integers1在字典上大于integers3,因为它们共享相同的前缀(3,4,5,6,1),但是对于最后一个元素,Integer.compare(5,3)返回一个大于 0 的值,因为 5 大于 3:

int i13 = Arrays.compare(integers1, integers3); // 1

可以在不同的数组范围内进行词典比较。例如,下面的示例通过int compare(int[] a, int aFromIndex, int aToIndex, int[] b, int bFromIndex, int bToIndex)方法比较范围[3, 6]中的integers1integers3

int is13 = Arrays.compare(integers1, 3, 6, integers3, 3, 6); // 1

对于Object的数组,Arrays类还提供了一组专用的compare()方法。还记得Melon类吗?好吧,为了比较两个没有显式ComparatorMelon数组,我们需要实现Comparable接口和compareTo()方法。假设我们依赖于瓜的重量,如下所示:

public class Melon implements Comparable {

  private final String type;
  private final int weight;

  @Override
  public int compareTo(Object o) {
    Melon m = (Melon) o;

    return Integer.compare(this.getWeight(), m.getWeight());
  }

  // constructor, getters, equals() and hashCode() omitted for brevity
}

注意,Object数组的词典比较不依赖于equals()。它需要显式的ComparatorComparable元素。

假设Melon的以下数组:

Melon[] melons1 = {new Melon("Horned", 1500), new Melon("Gac", 1000)};
Melon[] melons2 = {new Melon("Horned", 1500), new Melon("Gac", 1000)};
Melon[] melons3 = {new Melon("Hami", 1600), new Melon("Gac", 800)};

让我们通过<T extends Comparable<? super T>> int compare(T[] a, T[] b)melons1melons2进行词汇对比:

int m12 = Arrays.compare(melons1, melons2); // 0

因为melons1melons2是相同的,所以结果是 0。

现在,让我们对melons1melons3做同样的事情。这一次,结果将是否定的,这意味着在词典中,melons1小于melons3。这是真的,因为在指数 0 时,角瓜的重量是 1500 克,比哈密瓜的重量要轻,哈密瓜的重量是 1600 克:

int m13 = Arrays.compare(melons1, melons3); // -1

我们可以通过<T extends Comparable<? super T>> int compare(T[] a, int aFromIndex, int aToIndex, T[] b, int bFromIndex, int bToIndex)方法在数组的不同范围内进行比较。例如,在公共范围[1, 2]中,melons1在字典上大于melons2,因为 Gac 的重量在melons1中为 1000g,在melons3中为 800g:

int ms13 = Arrays.compare(melons1, 1, 2, melons3, 1, 2); // 1

如果我们不想依赖Comparable元素(实现Comparable,我们可以通过<T> int compare(T[] a, T[] b, Comparator<? super T> cmp)方法传入一个Comparator

Comparator<Melon> byType = Comparator.comparing(Melon::getType);
int mt13 = Arrays.compare(melons1, melons3, byType); // 14

也可以通过<T> int compare(T[] a, int aFromIndex, int aToIndex, T[] b, int bFromIndex, int bToIndex, Comparator<? super T> cmp)使用范围:

int mrt13 = Arrays.compare(melons1, 1, 2, melons3, 1, 2, byType); // 0

如果数字数组应该被无符号处理,那么依赖于一堆Arrays.compareUnsigned​()方法,这些方法可用于byteshortintlong

根据String.compareTo()int compareTo(String anotherString)按字典顺序比较两个字符串。

103 从数组创建流

一旦我们从一个数组中创建了一个Stream,我们就可以访问所有流 API。因此,这是一个方便的操作,这是很重要的,在我们的工具带。

让我们从字符串数组开始(也可以是其他对象):

String[] arr = {"One", "Two", "Three", "Four", "Five"};

从这个String[]数组创建Stream最简单的方法是依赖于从 JDK8 开始的Arrays.stream()方法:

Stream<String> stream = Arrays.stream(arr);

或者,如果我们需要来自子数组的流,那么只需添加范围作为参数。例如,让我们从(0, 2)之间的元素创建一个Stream,即 1 到 2:

Stream<String> stream = Arrays.stream(arr, 0, 2);

同样的情况,但通过一个List可以写为:

Stream<String> stream = Arrays.asList(arr).stream();
Stream<String> stream = Arrays.asList(arr).subList(0, 2).stream();

另一种解决方案依赖于Stream.of()方法,如以下简单示例所示:

Stream<String> stream = Stream.of(arr);
Stream<String> stream = Stream.of("One", "Two", "Three");

Stream创建数组可以通过Stream.toArray()方法完成。例如,一个简单的方法如下所示:

String[] array = stream.toArray(String[]::new);

另外,让我们考虑一个原始数组:

int[] integers = {2, 3, 4, 1};

在这种情况下,Arrays.stream()方法可以再次提供帮助,唯一的区别是返回的结果是IntStream类型(这是Streamint原始类型特化):

IntStream intStream = Arrays.stream(integers);

但是IntStream类还提供了一个of()方法,可以如下使用:

IntStream intStream = IntStream.of(integers);

有时,我们需要定义一个增量步长为 1 的有序整数的Stream。此外,Stream的大小应该等于数组的大小。特别是对于这种情况,IntStream方法提供了两种方法range(int inclusive, int exclusive)rangeClosed(int startInclusive, int endInclusive)

IntStream intStream = IntStream.range(0, integers.length);
IntStream intStream = IntStream.rangeClosed(0, integers.length);

从整数的Stream创建数组可以通过Stream.toArray()方法完成。例如,一个简单的方法如下所示:

int[] intArray = intStream.toArray();

// for boxed integers
int[] intArray = intStream.mapToInt(i -> i).toArray();

除了流的IntStream特化之外,JDK8 还提供longLongStream)和doubleDoubleStream)的特化。

104 数组的最小值、最大值和平均值

计算数组的最小值、最大值和平均值是一项常见的任务。让我们看看在函数式和命令式编程中解决这个问题的几种方法。

计算最大值和最小值

计算数字数组的最大值可以通过循环数组并通过与数组的每个元素进行比较来跟踪最大值来实现。就代码行而言,可以编写如下:

public static int max(int[] arr) {

  int max = arr[0];

  for (int elem: arr) {
    if (elem > max) {
      max = elem;
    }
  }

  return max;
}

在可读性方面,可能需要使用Math.max()方法而不是if语句:

...
max = Math.max(max, elem);
...

假设我们有以下整数数组和一个名为MathArrays的工具类,其中包含前面的方法:

int[] integers = {2, 3, 4, 1, -4, 6, 2};

该数组的最大值可以容易地获得如下:

int maxInt = MathArrays.max(integers); // 6

在 Java8 函数式风格中,此问题的解决方案需要一行代码:

int maxInt = Arrays.stream(integers).max().getAsInt();

在函数式方法中,max()方法返回一个OptionalInt。同样,我们有OptionalLongOptionalDouble

此外,我们假设一个对象数组,在本例中是一个Melon数组:

Melon[] melons = {
  new Melon("Horned", 1500), new Melon("Gac", 2200),
  new Melon("Hami", 1600), new Melon("Gac", 2100)
};

public class Melon implements Comparable {

  private final String type;
  private final int weight;

  @Override
  public int compareTo(Object o) {
    Melon m = (Melon) o;

    return Integer.compare(this.getWeight(), m.getWeight());
  }

  // constructor, getters, equals() and hashCode() omitted for brevity
}

很明显,我们前面定义的max()方法不能用于这种情况,但逻辑原理保持不变。这一次,实现应该依赖于ComparableComparator。基于Comparable的实现可以如下:

public static <T extends Comparable<T>> T max(T[] arr) {

  T max = arr[0];

  for (T elem : arr) {
    if (elem.compareTo(max) > 0) {
      max = elem;
   }
  }

  return max;
}

检查Melon.compareTo()方法,注意我们的实现将比较瓜的重量。因此,我们可以很容易地从我们的数组中找到最重的瓜,如下所示:

Melon maxMelon = MathArrays.max(melons); // Gac(2200g)

依赖于Comparator的实现可以写为:

public static <T> T max(T[] arr, Comparator<? super T> c) {

  T max = arr[0];

  for (T elem: arr) {
    if (c.compare(elem, max) > 0) {
      max = elem;
    }
  }

  return max;
}

并且,如果我们根据甜瓜的类型定义一个Comparator,我们有以下结果:

Comparator<Melon> byType = Comparator.comparing(Melon::getType);

然后,我们得到与字符串的词典比较相一致的最大值:

Melon maxMelon = MathArrays.max(melons, byType); // Horned(1500g)

在 Java8 函数式风格中,此问题的解决方案需要一行代码:

Melon maxMelon = Arrays.stream(melons).max(byType).orElseThrow();

计算平均值

计算一组数字(在本例中为整数)的平均值可以通过两个简单的步骤实现:

  1. 计算数组中元素的和。
  2. 将此总和除以数组的长度。

在代码行中,我们有以下内容:

public static double average(int[] arr) {

  return sum(arr) / arr.length;
}

public static double sum(int[] arr) {

  double sum = 0;

  for (int elem: arr) {
    sum += elem;
  }

  return sum;
}

整数数组的平均值为 2.0:

double avg = MathArrays.average(integers);

在 Java8 函数式风格中,此问题的解决方案需要一行代码:

double avg = Arrays.stream(integers).average().getAsDouble();

对于第三方库支持,请考虑 Apache Common Lang(ArrayUtil)和 Guava 的CharsIntsLongs以及其他类。

105 反转数组

这个问题有几种解决办法。它们中的一些改变了初始数组,而另一些只是返回一个新数组

假设以下整数数组:

int[] integers = {-1, 2, 3, 1, 4, 5, 3, 2, 22};

让我们从一个简单的实现开始,它将数组的第一个元素与最后一个元素交换,第二个元素与倒数第二个元素交换,依此类推:

public static void reverse(int[] arr) {

  for (int leftHead = 0, rightHead = arr.length - 1; 
      leftHead < rightHead; leftHead++, rightHead--) {

    int elem = arr[leftHead];
    arr[leftHead] = arr[rightHead];
    arr[rightHead] = elem;
  }
}

前面的解决方案改变了给定的数组,这并不总是期望的行为。当然,我们可以修改它以返回一个新的数组,也可以依赖 Java8 函数样式,如下所示:

// 22, 2, 3, 5, 4, 1, 3, 2, -1
int[] reversed = IntStream.rangeClosed(1, integers.length)
  .map(i -> integers[integers.length - i]).toArray();

现在,让我们反转一个对象数组。为此,让我们考虑一下Melon类:

public class Melon {

  private final String type;
  private final int weight;

  // constructor, getters, equals(), hashCode() omitted for brevity
}

另外,让我们考虑一个Melon数组:

Melon[] melons = {
  new Melon("Crenshaw", 2000), 
  new Melon("Gac", 1200),
  new Melon("Bitter", 2200)
};

第一种解决方案需要使用泛型来塑造实现,该实现将数组的第一个元素与最后一个元素交换,将第二个元素与最后一个元素交换,依此类推:

public static <T> void reverse(T[] arr) {

  for (int leftHead = 0, rightHead = arr.length - 1; 
      leftHead < rightHead; leftHead++, rightHead--) {

    T elem = arr[leftHead];
    arr[leftHead] = arr[rightHead];
    arr[rightHead] = elem;
  }
}

因为我们的数组包含对象,所以我们也可以依赖于Collections.reverse()。我们只需要通过Arrays.asList()方法将数组转换成List

// Bitter(2200g), Gac(1200g), Crenshaw(2000g)
Collections.reverse(Arrays.asList(melons));

前面的两个解决方案改变了数组的元素。Java8 函数式风格可以帮助我们避免这种变异:

// Bitter(2200g), Gac(1200g), Crenshaw(2000g)
Melon[] reversed = IntStream.rangeClosed(1, melons.length)
  .mapToObj(i -> melons[melons.length - i])
  .toArray(Melon[]:new);

对于第三方库支持,请考虑 Apache Common Lang(ArrayUtils.reverse()和 Guava 的Lists类。

106 填充和设置数组

有时,我们需要用一个固定值填充数组。例如,我们可能希望用值1填充整数数组。实现这一点的最简单方法依赖于一个for语句,如下所示:

int[] arr = new int[10];

// 1, 1, 1, 1, 1, 1, 1, 1, 1, 1
for (int i = 0; i < arr.length; i++) {
  arr[i] = 1;
}

但我们可以通过Arrays.fill()方法将此代码简化为一行代码。对于基本体和对象,此方法有不同的风格。前面的代码可以通过Arrays.fill(int[] a, int val)重写如下:

// 1, 1, 1, 1, 1, 1, 1, 1, 1, 1
Arrays.fill(arr, 1);

Arrays.fill() also come with flavors for filling up just a segment/range of an array. For integers, this method is fill​(int[] a, int fromIndexInclusive, int toIndexExclusive, int val).

现在,应用一个生成函数来计算数组的每个元素怎么样?例如,假设我们要将每个元素计算为前一个元素加 1。最简单的方法将再次依赖于for语句,如下所示:

// 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
for (int i = 1; i < arr.length; i++) {
  arr[i] = arr[i - 1] + 1;
}

根据需要应用于每个元素的计算,必须相应地修改前面的代码。

对于这样的任务,JDK8 附带了一系列的Arrays.setAll()Arrays.parallelSetAll()方法。例如,前面的代码片段可以通过setAll​(int[] array, IntUnaryOperator generator)重写如下:

// 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
Arrays.setAll(arr, t -> {
  if (t == 0) {
    return arr[t];
  } else {
    return arr[t - 1] + 1;
  }
});

除此之外,我们还有setAll​(double[] array, IntToDoubleFunction generator)setAll​(long[] array, IntToLongFunction generator)setAll​(T[] array, IntFunction<? extends T> generator)

根据生成器的功能,此任务可以并行完成,也可以不并行完成。例如,前面的生成器函数不能并行应用,因为每个元素都依赖于前面元素的值。尝试并行应用此生成器函数将导致不正确和不稳定的结果。

但是假设我们要取前面的数组(1,2,3,4,5,6,7,8,9,10),然后将每个偶数值乘以它本身,将每个奇数值减去 1。因为每个元素都可以单独计算,所以在这种情况下我们可以授权一个并行进程。这是Arrays.parallelSetAll()方法的完美工作。基本上,这些方法是用来并行化Arrays.setAll()方法的。

现在我们将parallelSetAll​(int[] array, IntUnaryOperator generator)应用于这个数组:

// 0, 4, 2, 16, 4, 36, 6, 64, 8, 100
Arrays.parallelSetAll(arr, t -> {
  if (arr[t] % 2 == 0) {
    return arr[t] * arr[t];
  } else {
    return arr[t] - 1;
  }
});

对于每个Arrays.setAll()方法,都有一个Arrays.parallelSetAll()方法。

作为奖励,Arrays附带了一组名为parallelPrefix()的方法。这些方法对于将数学函数应用于数组的元素(累积和并发)非常有用。

例如,如果我们要将数组中的每个元素计算为前面元素的和,那么我们可以如下所示:

// 0, 4, 6, 22, 26, 62, 68, 132, 140, 240
Arrays.parallelPrefix(arr, (t, q) -> t + q);

107 下一个更大的元素

NGE 是一个涉及数组的经典问题。

基本上,有一个数组和它的一个元素e,我们要获取下一个(右侧)大于e的元素。例如,假设以下数组:

int[] integers = {1, 2, 3, 4, 12, 2, 1, 4};

获取每个元素的 NGE 将产生以下对(-1 被解释为右侧的元素不大于当前元素):

1 : 2   2 : 3   3 : 4   4 : 12   12 : -1   2 : 4   1 : 4   4 : -1

这个问题的一个简单解决方案是循环每个元素的数组,直到找到一个更大的元素或者没有更多的元素要检查。如果我们只想在屏幕上打印对,那么我们可以编写一个简单的代码,如下所示:

public static void println(int[] arr) {

  int nge;
  int n = arr.length;

  for (int i = 0; i < n; i++) {
    nge = -1;
    for (int j = i + 1; j < n; j++) {
      if (arr[i] < arr[j]) {
        nge = arr[j];
        break;
      }
    }

    System.out.println(arr[i] + " : " + nge);
  }
}

另一个解决方案依赖于栈。主要是,我们在栈中推送元素,直到当前处理的元素大于栈中的顶部元素。当这种情况发生时,我们弹出那个元素。本书附带的代码中提供了解决方案。

108 更改数组大小

增加数组的大小并不简单。这是因为 Java 数组的大小是固定的,我们不能修改它们的大小。这个问题的解决方案需要创建一个具有所需大小的新数组,并将所有值从原始数组复制到这个数组中。这可以通过Arrays.copyOf()方法或System.arraycopy()(由Arrays.copyOf()内部使用)完成。

对于一个原始数组(例如,int),我们可以将数组的大小增加 1 后将值添加到数组中,如下所示:

public static int[] add(int[] arr, int item) {

  int[] newArr = Arrays.copyOf(arr, arr.length + 1);
  newArr[newArr.length - 1] = item;

  return newArr;
}

或者,我们可以删除最后一个值,如下所示:

public static int[] remove(int[] arr) {

  int[] newArr = Arrays.copyOf(arr, arr.length - 1);

  return newArr;
}

或者,我们可以按如下所示调整给定长度数组的大小:

public static int[] resize(int[] arr, int length) {

  int[] newArr = Arrays.copyOf(arr, arr.length + length);

  return newArr;
}

捆绑到本书中的代码还包含了System.arraycopy()备选方案。此外,它还包含泛型数组的实现。签名如下:

public static <T> T[] addObject(T[] arr, T item);
public static <T> T[] removeObject(T[] arr);
public static <T> T[] resize(T[] arr, int length);

在有利的背景下,让我们将一个相关的主题引入讨论:如何在 Java 中创建泛型数组。以下操作无效:

T[] arr = new T[arr_size]; // causes generic array creation error

有几种方法,但 Java 在copyOf(T[] original, int newLength)中使用以下代码:

// newType is original.getClass()
T[] copy = ((Object) newType == (Object) Object[].class) ?
  (T[]) new Object[newLength] :
  (T[]) Array.newInstance(newType.getComponentType(), newLength);

109 创建不可修改/不可变的集合

在 Java 中创建不可修改/不可变的集合可以很容易地通过Collections.unmodifiableFoo()方法(例如,unmodifiableList())完成,并且从 JDK9 开始,通过来自ListSetMap和其他接口的一组of()方法完成。

此外,我们将在一组示例中使用这些方法来获得不可修改/不可变的集合。主要目标是确定每个定义的集合是不可修改的还是不可变的。

在阅读本节之前,建议先阅读第 2 章、“对象、不变性和switch表达式”中有关不变性的问题。

好吧。对于原始类型来说,这非常简单。例如,我们可以创建一个不可变的整数List,如下所示:

private static final List<Integer> LIST 
  = Collections.unmodifiableList(Arrays.asList(1, 2, 3, 4, 5));

private static final List<Integer> LIST = List.of(1, 2, 3, 4, 5);

对于下一个示例,让我们考虑以下可变类:

public class MutableMelon {

  private String type;
  private int weight;

  // constructor omitted for brevity

  public void setType(String type) {
    this.type = type;
  }

  public void setWeight(int weight) {
    this.weight = weight;
  }

  // getters, equals() and hashCode() omitted for brevity
}

问题 1 (Collections.unmodifiableList())

让我们通过Collections.unmodifiableList()方法创建MutableMelon列表:

// Crenshaw(2000g), Gac(1200g)
private final MutableMelon melon1 
  = new MutableMelon("Crenshaw", 2000);
private final MutableMelon melon2 
  = new MutableMelon("Gac", 1200);

private final List<MutableMelon> list 
  = Collections.unmodifiableList(Arrays.asList(melon1, melon2));

那么,list是不可修改的还是不变的?答案是不可更改的。虽然增变器方法会抛出UnsupportedOperationException,但底层的melon1melon2是可变的。例如,我们把西瓜的重量设为0

melon1.setWeight(0);
melon2.setWeight(0);

现在,列表将显示以下西瓜(因此列表发生了变异):

Crenshaw(0g), Gac(0g)

问题 2 (Arrays.asList())

我们直接在Arrays.asList()中硬编码实例,创建MutableMelon列表:

private final List<MutableMelon> list 
  = Collections.unmodifiableList(Arrays.asList(
    new MutableMelon("Crenshaw", 2000), 
    new MutableMelon("Gac", 1200)));

那么,这个列表是不可修改的还是不变的?答案是不可更改的。当增变器方法抛出UnsupportedOperationException时,硬编码实例可以通过List.get()方法访问。一旦可以访问它们,它们就可以变异:

MutableMelon melon1 = list.get(0);
MutableMelon melon2 = list.get(1);

melon1.setWeight(0);
melon2.setWeight(0);

现在,列表将显示以下西瓜(因此列表发生了变异):

Crenshaw(0g), Gac(0g)

问题 3 (Collections.unmodifiableList()和静态块)

让我们通过Collections.unmodifiableList()方法和static块创建MutableMelon列表:

private static final List<MutableMelon> list;
static {
  final MutableMelon melon1 = new MutableMelon("Crenshaw", 2000);
  final MutableMelon melon2 = new MutableMelon("Gac", 1200);

  list = Collections.unmodifiableList(Arrays.asList(melon1, melon2));
}

那么,这个列表是不可修改的还是不变的?答案是不可更改的。虽然增变器方法会抛出UnsupportedOperationException,但是硬编码的实例仍然可以通过List.get()方法访问。一旦可以访问它们,它们就可以变异:

MutableMelon melon1l = list.get(0);
MutableMelon melon2l = list.get(1);

melon1l.setWeight(0);
melon2l.setWeight(0);

现在,列表将显示以下西瓜(因此列表发生了变异):

Crenshaw(0g), Gac(0g)

问题 4 (List.of())

让我们通过List.of()创建MutableMelon的列表:

private final MutableMelon melon1 
  = new MutableMelon("Crenshaw", 2000);
private final MutableMelon melon2 
  = new MutableMelon("Gac", 1200);

private final List<MutableMelon> list = List.of(melon1, melon2);

那么,这个列表是不可修改的还是不变的?答案是不可更改的。虽然增变器方法会抛出UnsupportedOperationException,但是硬编码的实例仍然可以通过List.get()方法访问。一旦可以访问它们,它们就可以变异:

MutableMelon melon1l = list.get(0);
MutableMelon melon2l = list.get(1);

melon1l.setWeight(0);
melon2l.setWeight(0);

现在,列表将显示以下西瓜(因此列表发生了变异):

Crenshaw(0g), Gac(0g)

对于下一个示例,让我们考虑以下不可变类:

public final class ImmutableMelon {

  private final String type;
  private final int weight;

  // constructor, getters, equals() and hashCode() omitted for brevity
}

问题 5(不可变)

现在我们通过Collections.unmodifiableList()List.of()方法创建ImmutableMelon列表:

private static final ImmutableMelon MELON_1 
  = new ImmutableMelon("Crenshaw", 2000);
private static final ImmutableMelon MELON_2 
  = new ImmutableMelon("Gac", 1200);

private static final List<ImmutableMelon> LIST 
  = Collections.unmodifiableList(Arrays.asList(MELON_1, MELON_2));
private static final List<ImmutableMelon> LIST 
  = List.of(MELON_1, MELON_2);

那么,这个列表是不可修改的还是不变的?答案是不变的。增变器方法会抛出UnsupportedOperationException,我们不能对ImmutableMelon的实例进行变异。

根据经验,如果集合是通过unmodifiableFoo()of()方法定义的,并且包含可变数据,则集合是不可修改的;如果集合是不可修改的,并且包含可变数据(包括原始类型),则集合是不可修改的。

需要注意的是,不可穿透的不变性应该考虑 Java 反射 API 和类似的 API,它们在操作代码时具有辅助功能。

对于第三方库支持,请考虑 Apache Common Collection、UnmodifiableList(和同伴)和 Guava 的ImmutableList(和同伴)。

Map的情况下,我们可以通过unmodifiableMap()Map.of()方法创建一个不可修改/不可修改的Map

但我们也可以通过Collections.emptyMap()创建一个不可变的空Map

Map<Integer, MutableMelon> emptyMap = Collections.emptyMap();

emptyMap()类似,我们有Collections.emptyList()Collections.emptySet()。在返回一个MapListSet的方法中,这些方法作为返回非常方便,我们希望避免返回null

或者,我们可以通过Collections.singletonMap(K key, V value)用单个元素创建一个不可修改/不可变的Map

// unmodifiable
Map<Integer, MutableMelon> mapOfSingleMelon 
  = Collections.singletonMap(1, new MutableMelon("Gac", 1200));

// immutable
Map<Integer, ImmutableMelon> mapOfSingleMelon 
  = Collections.singletonMap(1, new ImmutableMelon("Gac", 1200));

类似于singletonMap(),我们有singletonList()singleton()。后者用于Set

此外,从 JDK9 开始,我们可以通过一个名为ofEntries()的方法创建一个不可修改的Map。此方法以Map.Entry为参数,如下例所示:

// unmodifiable Map.Entry containing the given key and value
import static java.util.Map.entry;
...
Map<Integer, MutableMelon> mapOfMelon = Map.ofEntries(
  entry(1, new MutableMelon("Apollo", 3000)),
  entry(2, new MutableMelon("Jade Dew", 3500)),
  entry(3, new MutableMelon("Cantaloupe", 1500))
);

或者,不可变的Map是另一种选择:

Map<Integer, ImmutableMelon> mapOfMelon = Map.ofEntries(
  entry(1, new ImmutableMelon("Apollo", 3000)),
  entry(2, new ImmutableMelon("Jade Dew", 3500)),
  entry(3, new ImmutableMelon("Cantaloupe", 1500))
);

另外,可以通过 JDK10 从可修改/可变的Map中获得不可修改/不可变的MapMap.copyOf​(Map<? extends K,​? extends V> map)方法:

Map<Integer, ImmutableMelon> mapOfMelon = new HashMap<>();
mapOfMelon.put(1, new ImmutableMelon("Apollo", 3000));
mapOfMelon.put(2, new ImmutableMelon("Jade Dew", 3500));
mapOfMelon.put(3, new ImmutableMelon("Cantaloupe", 1500));

Map<Integer, ImmutableMelon> immutableMapOfMelon 
  = Map.copyOf(mapOfMelon);

作为这一节的奖励,让我们来讨论一个不可变数组。

问题:我能用 Java 创建一个不可变数组吗?

答案:不可以。或者。。。有一种方法可以在 Java 中生成不可变数组:

static final String[] immutable = new String[0];

因此,Java 中所有有用的数组都是可变的。但是我们可以在Arrays.copyOf()的基础上创建一个辅助类来创建不可变数组,它复制元素并创建一个新数组(在幕后,这个方法依赖于System.arraycopy()

因此,我们的辅助类如下所示:

import java.util.Arrays;

public final class ImmutableArray<T> {

  private final T[] array;

  private ImmutableArray(T[] a) {
    array = Arrays.copyOf(a, a.length);
  }

  public static <T> ImmutableArray<T> from(T[] a) {
    return new ImmutableArray<>(a);
  }

  public T get(int index) {
    return array[index];
  }

  // equals(), hashCode() and toString() omitted for brevity
}

用法示例如下:

ImmutableArray<String> sample =
  ImmutableArray.from(new String[] {
    "a", "b", "c"
  });

110 映射的默认值

在 JDK8 之前,这个问题的解决方案依赖于辅助方法,它基本上检查Map中给定键的存在,并返回相应的值或默认值。这种方法可以在工具类中编写,也可以通过扩展Map接口来编写。通过返回默认值,我们可以避免在Map中找不到给定键时返回null。此外,这是依赖默认设置或配置的方便方法。

从 JDK8 开始,这个问题的解决方案包括简单地调用Map.getOrDefault()方法。此方法获取两个参数,分别表示要在Map方法中查找的键和默认值。当找不到给定的键时,默认值充当应该返回的备份值。

例如,假设下面的Map封装了多个数据库及其默认的host:port

Map<String, String> map = new HashMap<>();
map.put("postgresql", "127.0.0.1:5432");
map.put("mysql", "192.168.0.50:3306");
map.put("cassandra", "192.168.1.5:9042");

我们来看看这个Map是否也包含 Derby DB 的默认host:port

map.get("derby"); // null

由于映射中没有 Derby DB,因此结果将是null。这不是我们想要的。实际上,当搜索到的数据库不在映射上时,我们可以在69:89.31.226:27017上使用 MongoDB,它总是可用的。现在,我们可以很容易地将此行为塑造为:

// 69:89.31.226:27017
String hp1 = map.getOrDefault("derby", "69:89.31.226:27017");

// 192.168.0.50:3306
String hp2 = map.getOrDefault("mysql", "69:89.31.226:27017");

这种方法可以方便地建立流利的表达式,避免中断代码进行null检查。请注意,返回默认值并不意味着该值将被添加到MapMap保持不变。

111 计算映射中是否不存在/存在

有时,Map并不包含我们需要的准确的开箱即用条目。此外,当条目不存在时,返回默认条目也不是一个选项。基本上,有些情况下我们需要计算我们的入口。

对于这种情况,JDK8 提供了一系列方法:compute()computeIfAbsent()computeIfPresent()merge()。在这些方法之间进行选择是一个非常了解每种方法的问题。

现在让我们通过示例来看看这些方法的实现。

示例 1(computeIfPresent()

假设我们有以下Map

Map<String, String> map = new HashMap<>();
map.put("postgresql", "127.0.0.1");
map.put("mysql", "192.168.0.50");

我们使用这个映射为不同的数据库类型构建 JDBC URL。

假设我们要为 MySQL 构建 JDBC URL。如果映射中存在mysql键,则应根据相应的值jdbc:mysql://192.168.0.50/customers_db计算 JDBC URL。但是如果不存在mysql键,那么 JDBC URL 应该是null。除此之外,如果我们的计算结果是null(无法计算 JDBC URL),那么我们希望从映射中删除这个条目。

这是V computeIfPresent​(K key, BiFunction<? super K,​? super V,​? extends V> remappingFunction)的工作。

在我们的例子中,用于计算新值的BiFunction如下所示(k是映射中的键,v是与键关联的值):

BiFunction<String, String, String> jdbcUrl 
  = (k, v) -> "jdbc:" + k + "://" + v + "/customers_db";

一旦我们有了这个函数,我们就可以计算出mysql键的新值,如下所示:

// jdbc:mysql://192.168.0.50/customers_db
String mySqlJdbcUrl = map.computeIfPresent("mysql", jdbcUrl);

由于映射中存在mysql键,结果将是jdbc:mysql://192.168.0.50/customers_db,新映射包含以下条目:

postgresql=127.0.0.1, mysql=jdbc:mysql://192.168.0.50/customers_db

再次调用computeIfPresent()将重新计算值,这意味着它将导致类似mysql= jdbc:mysql://jdbc:mysql://....的结果。显然,这是不可以的,所以请注意这方面。

另一方面,如果我们对一个不存在的条目进行相同的计算(例如,voltdb),那么返回的值将是null,映射保持不变:

// null
String voldDbJdbcUrl = map.computeIfPresent("voltdb", jdbcUrl);

示例 2(computeIfAbsent()

假设我们有以下Map

Map<String, String> map = new HashMap<>();
map.put("postgresql", "jdbc:postgresql://127.0.0.1/customers_db");
map.put("mysql", "jdbc:mysql://192.168.0.50/customers_db");

我们使用这个映射为不同的数据库构建 JDBC URL。

假设我们要为 MongoDB 构建 JDBC URL。这一次,如果映射中存在mongodb键,则应返回相应的值,而无需进一步计算。但是如果这个键不存在(或者与一个null值相关联),那么它应该基于这个键和当前 IP 进行计算并添加到映射中。如果计算值为null,则返回结果为null,映射保持不变。

嗯,这是V computeIfAbsent​(K key, Function<? super K,​? extends V> mappingFunction)的工作。

在我们的例子中,用于计算值的Function将如下所示(第一个String是映射中的键(k),而第二个String是为该键计算的值):

String address = InetAddress.getLocalHost().getHostAddress();

Function<String, String> jdbcUrl 
  = k -> k + "://" + address + "/customers_db";

基于此函数,我们可以尝试通过mongodb键获取 MongoDB 的 JDBC URL,如下所示:

// mongodb://192.168.100.10/customers_db
String mongodbJdbcUrl = map.computeIfAbsent("mongodb", jdbcUrl);

因为我们的映射不包含mongodb键,它将被计算并添加到映射中。

如果我们的Function被求值为null,那么映射保持不变,返回值为null

再次调用computeIfAbsent()不会重新计算值。这次,由于mongodb在映射中(在上一次调用中添加),所以返回的值将是mongodb://192.168.100.10/customers_db。这与尝试获取mysql的 JDBC URL 是一样的,它将返回jdbc:mysql://192.168.0.50/customers_db,而无需进一步计算。

示例 3(compute()

假设我们有以下Map

Map<String, String> map = new HashMap<>();
map.put("postgresql", "127.0.0.1");
map.put("mysql", "192.168.0.50");

我们使用这个映射为不同的数据库类型构建 JDBC URL。

假设我们要为 MySQL 和 Derby DB 构建 JDBC URL。在这种情况下,不管键(mysql还是derby存在于映射中,JDBC URL 都应该基于相应的键和值(可以是null)来计算。另外,如果键存在于映射中,并且我们的计算结果是null(无法计算 JDBC URL),那么我们希望从映射中删除这个条目。基本上,这是computeIfPresent()computeIfAbsent()的组合。

这是V compute​(K key, BiFunction<? super K,​? super V,​? extends V> remappingFunction)的工作。

此时,应写入BiFunction以覆盖搜索键的值为null时的情况:

String address = InetAddress.getLocalHost().getHostAddress();
BiFunction<String, String, String> jdbcUrl = (k, v) 
  -> "jdbc:" + k + "://" + ((v == null) ? address : v) 
    + "/customers_db";

现在,让我们计算 MySQL 的 JDBC URL。因为mysql键存在于映射中,所以计算将依赖于相应的值192.168.0.50。结果将更新映射中mysql键的值:

// jdbc:mysql://192.168.0.50/customers_db
String mysqlJdbcUrl = map.compute("mysql", jdbcUrl);

另外,让我们计算 Derby DB 的 JDBC URL。由于映射中不存在derby键,因此计算将依赖于当前 IP。结果将被添加到映射的derby键下:

// jdbc:derby://192.168.100.10/customers_db
String derbyJdbcUrl = map.compute("derby", jdbcUrl);

在这两次计算之后,映射将包含以下三个条目:

  • postgresql=127.0.0.1
  • derby=jdbc:derby://192.168.100.10/customers_db
  • mysql=jdbc:mysql://192.168.0.50/customers_db

请注意,再次调用compute()将重新计算值。这可能导致不需要的结果,如jdbc:derby://jdbc:derby://...
如果计算的结果是null(例如 JDBC URL 无法计算),并且映射中存在键(例如mysql),那么这个条目将从映射中删除,返回的结果是null

示例 4(merge()

假设我们有以下Map

Map<String, String> map = new HashMap<>();
map.put("postgresql", "9.6.1 ");
map.put("mysql", "5.1 5.2 5.6 ");

我们使用这个映射来存储每个数据库类型的版本,这些版本之间用空格隔开。

现在,假设每次发布数据库类型的新版本时,我们都希望将其添加到对应键下的映射中。如果键(例如,mysql)存在于映射中,那么我们只需将新版本连接到当前值的末尾。如果键(例如,derby)不在映射中,那么我们现在只想添加它。

这是V merge​(K key, V value, BiFunction<? super V,​? super V,​? extends V> remappingFunction)的完美工作。

如果给定的键(K与某个值没有关联或与null关联,那么新的值将是V。如果给定键(K与非null值相关联,则基于给定的BiFunction计算新值。如果此BiFunction的结果是null,并且该键存在于映射中,则此条目将从映射中删除。

在我们的例子中,我们希望将当前值与新版本连接起来,因此我们的BiFunction可以写为:

BiFunction<String, String, String> jdbcUrl = String::concat;

我们在以下方面也有类似的情况:

BiFunction<String, String, String> jdbcUrl 
  = (vold, vnew) -> vold.concat(vnew);

例如,假设我们希望在 MySQL 的映射版本 8.0 中连接。这可以通过以下方式实现:

// 5.1 5.2 5.6 8.0
String mySqlVersion = map.merge("mysql", "8.0 ", jdbcUrl);

稍后,我们还将连接 9.0 版:

// 5.1 5.2 5.6 8.0 9.0
String mySqlVersion = map.merge("mysql", "9.0 ", jdbcUrl);

或者,我们添加 Derby DB 的版本10.11.1.1。这将导致映射中出现一个新条目,因为不存在derby键:

// 10.11.1.1
String derbyVersion = map.merge("derby", "10.11.1.1 ", jdbcUrl);

在这三个操作结束时,映射条目如下所示:

postgresql=9.6.1, derby=10.11.1.1, mysql=5.1 5.2 5.6 8.0 9.0

示例 5(putIfAbsent()

假设我们有以下Map

Map<Integer, String> map = new HashMap<>();
map.put(1, "postgresql");
map.put(2, "mysql");
map.put(3, null);

我们使用这个映射来存储一些数据库类型的名称。

现在,假设我们希望基于以下约束在该映射中包含更多数据库类型:

  • 如果给定的键存在于映射中,那么只需返回相应的值并保持映射不变。
  • 如果给定的键不在映射中(或者与一个null值相关联),则将给定的值放入映射并返回null

嗯,这是putIfAbsent​(K key, V value)的工作。

以下三种尝试不言自明:

String v1 = map.putIfAbsent(1, "derby");     // postgresql
String v2 = map.putIfAbsent(3, "derby");     // null
String v3 = map.putIfAbsent(4, "cassandra"); // null

映射内容如下:

1=postgresql, 2=mysql, 3=derby, 4=cassandra

112 从映射中删除

Map中删除可以通过一个键或者一个键和值来完成。

例如,假设我们有以下Map

Map<Integer, String> map = new HashMap<>();
map.put(1, "postgresql");
map.put(2, "mysql");
map.put(3, "derby");

通过键删除就像调用V Map.remove(Object key)方法一样简单。如果给定键对应的条目删除成功,则返回关联值,否则返回null

检查以下示例:

String r1 = map.remove(1); // postgresql
String r2 = map.remove(4); // null

现在,映射包含以下条目(已删除键 1 中的条目):

2=mysql, 3=derby

从 JDK8 开始,Map接口被一个新的remove()标志方法所丰富,该方法具有以下签名:boolean remove​(Object key, Object value)。使用这种方法,只有在给定的键和值之间存在完美匹配时,才能从映射中删除条目。基本上,这种方法是以下复合条件的捷径:map.containsKey(key) && Objects.equals(map.get(key), value)

让我们举两个简单的例子:

// true
boolean r1 = map.remove(2, "mysql");

// false (the key is present, but the values don't match)
boolean r2 = map.remove(3, "mysql");

结果映射包含一个剩余条目3=derby

迭代和从Map中移除至少可以通过两种方式来完成:第一,通过Iterator(捆绑代码中存在的解决方案),第二,从 JDK8 开始,我们可以通过removeIf​(Predicate<? super E> filter)来完成:

map.entrySet().removeIf(e -> e.getValue().equals("mysql"));

有关从集合中删除的更多详细信息,请参见“删除集合中与谓词匹配的所有元素”。

113 替换映射中的条目

Map替换条目是一个在很多情况下都会遇到的问题。要实现这一点并避免在辅助方法中编写一段意大利面条代码,方便的解决方案依赖于 JDK8replace()方法。

假设我们有下面的Melon类和Melon的映射:

public class Melon {

  private final String type;
  private final int weight;

  // constructor, getters, equals(), hashCode(),
  // toString() omitted for brevity
}

Map<Integer, Melon> mapOfMelon = new HashMap<>();
mapOfMelon.put(1, new Melon("Apollo", 3000));
mapOfMelon.put(2, new Melon("Jade Dew", 3500));
mapOfMelon.put(3, new Melon("Cantaloupe", 1500));

通过V replace​(K key, V value)可以完成按键 2 对应的甜瓜的更换。如果替换成功,则此方法将返回初始的Melon

// Jade Dew(3500g) was replaced
Melon melon = mapOfMelon.replace(2, new Melon("Gac", 1000));

现在,映射包含以下条目:

1=Apollo(3000g), 2=Gac(1000g), 3=Cantaloupe(1500g)

此外,假设我们想用键 1 和阿波罗甜瓜(3000g)替换条目。所以,甜瓜应该是同一个,才能获得成功的替代品。这可以通过布尔值replace​(K key, V oldValue, V newValue)实现。此方法依赖于equals()合同来比较给定的值,因此Melon需要执行equals()方法,否则结果不可预知:

// true
boolean melon = mapOfMelon.replace(
  1, new Melon("Apollo", 3000), new Melon("Bitter", 4300));

现在,映射包含以下条目:

1=Bitter(4300g), 2=Gac(1000g), 3=Cantaloupe(1500g)

最后,假设我们要根据给定的函数替换Map中的所有条目。这可以通过void replaceAll​(BiFunction<? super K,​? super V,​? extends V> function)完成。

例如,将所有重量超过 1000g 的瓜替换为重量等于 1000g 的瓜,下面的BiFunction形成了这个函数(k是键,vMap中每个条目的值):

BiFunction<Integer, Melon, Melon> function = (k, v) 
  -> v.getWeight() > 1000 ? new Melon(v.getType(), 1000) : v;

接下来,replaceAll()出现在现场:

mapOfMelon.replaceAll(function);

现在,映射包含以下条目:

1=Bitter(1000g), 2=Gac(1000g), 3=Cantaloupe(1000g)

114 比较两个映射

只要我们依赖于Map.equals()方法,比较两个映射是很简单的。在比较两个映射时,该方法使用Object.equals()方法比较它们的键和值。

例如,让我们考虑两个具有相同条目的瓜映射(在Melon类中必须存在equals()hashCode()

public class Melon {

  private final String type;
  private final int weight;

  // constructor, getters, equals(), hashCode(),
  // toString() omitted for brevity
}

Map<Integer, Melon> melons1Map = new HashMap<>();
Map<Integer, Melon> melons2Map = new HashMap<>();
melons1Map.put(1, new Melon("Apollo", 3000));
melons1Map.put(2, new Melon("Jade Dew", 3500));
melons1Map.put(3, new Melon("Cantaloupe", 1500));
melons2Map.put(1, new Melon("Apollo", 3000));
melons2Map.put(2, new Melon("Jade Dew", 3500));
melons2Map.put(3, new Melon("Cantaloupe", 1500));

现在,如果我们测试melons1Mapmelons2Map是否相等,那么我们得到true

boolean equals12Map = melons1Map.equals(melons2Map); // true

但如果我们使用数组,这将不起作用。例如,考虑下面两个映射:

Melon[] melons1Array = {
  new Melon("Apollo", 3000),
  new Melon("Jade Dew", 3500), new Melon("Cantaloupe", 1500)
};
Melon[] melons2Array = {
  new Melon("Apollo", 3000),
  new Melon("Jade Dew", 3500), new Melon("Cantaloupe", 1500)
};

Map<Integer, Melon[]> melons1ArrayMap = new HashMap<>();
melons1ArrayMap.put(1, melons1Array);
Map<Integer, Melon[]> melons2ArrayMap = new HashMap<>();
melons2ArrayMap.put(1, melons2Array);

即使melons1ArrayMapmelons2ArrayMap相等,Map.equals()也会返回false

boolean equals12ArrayMap = melons1ArrayMap.equals(melons2ArrayMap);

这个问题源于这样一个事实:数组的equals()方法比较的是标识,而不是数组的内容。为了解决这个问题,我们可以编写一个辅助方法如下(这次依赖于Arrays.equals(),它比较数组的内容):

public static <A, B> boolean equalsWithArrays(
    Map<A, B[]> first, Map<A, B[]> second) {

  if (first.size() != second.size()) {
    return false;
  }

  return first.entrySet().stream()
    .allMatch(e -> Arrays.equals(e.getValue(), 
      second.get(e.getKey())));
}

115 对映射排序

排序一个Map有几种解决方案。首先,假设Melon中的Map

public class Melon implements Comparable {

  private final String type;
  private final int weight;

  @Override
  public int compareTo(Object o) {
    return Integer.compare(this.getWeight(), ((Melon) o).getWeight());
  }

  // constructor, getters, equals(), hashCode(),
  // toString() omitted for brevity
}

Map<String, Melon> melons = new HashMap<>();
melons.put("delicious", new Melon("Apollo", 3000));
melons.put("refreshing", new Melon("Jade Dew", 3500));
melons.put("famous", new Melon("Cantaloupe", 1500));

现在,让我们来研究几种排序这个Map的解决方案。基本上,我们的目标是通过一个名为Maps的工具类公开以下屏幕截图中的方法:

让我们在下一节中看看不同的解决方案。

通过TreeMap和自然排序按键排序

Map进行排序的快速解决方案依赖于TreeMap。根据定义,TreeMap中的键按其自然顺序排序。此外,TreeMap还有一个TreeMap​(Map<? extends K,​? extends V> m)类型的构造器:

public static <K, V> TreeMap<K, V> sortByKeyTreeMap(Map<K, V> map) {

  return new TreeMap<>(map);
}

调用它将按键对映射进行排序:

// {delicious=Apollo(3000g), 
// famous=Cantaloupe(1500g), refreshing=Jade Dew(3500g)}
TreeMap<String, Melon> sortedMap = Maps.sortByKeyTreeMap(melons);

通过流和比较器按键和值排序

一旦我们为映射创建了一个Stream,我们就可以很容易地用Stream.sorted()方法对它进行排序,不管有没有Comparator。这一次,让我们使用一个Comparator

public static <K, V> Map<K, V> sortByKeyStream(
    Map<K, V> map, Comparator<? super K> c) {

  return map.entrySet()
    .stream()
    .sorted(Map.Entry.comparingByKey(c))
    .collect(toMap(Map.Entry::getKey, Map.Entry::getValue,
      (v1, v2) -> v1, LinkedHashMap::new));
}

public static <K, V> Map<K, V> sortByValueStream(
    Map<K, V> map, Comparator<? super V> c) {

  return map.entrySet()
    .stream()
    .sorted(Map.Entry.comparingByValue(c))
    .collect(toMap(Map.Entry::getKey, Map.Entry::getValue,
      (v1, v2) -> v1, LinkedHashMap::new));
}

我们需要依赖LinkedHashMap而不是HashMap。否则,我们就不能保持迭代顺序。

让我们把映射分类如下:

// {delicious=Apollo(3000g), 
//  famous=Cantaloupe(1500g), 
//  refreshing=Jade Dew(3500g)}
Comparator<String> byInt = Comparator.naturalOrder();
Map<String, Melon> sortedMap = Maps.sortByKeyStream(melons, byInt);

// {famous=Cantaloupe(1500g), 
//  delicious=Apollo(3000g), 
//  refreshing=Jade Dew(3500g)}
Comparator<Melon> byWeight = Comparator.comparing(Melon::getWeight);
Map<String, Melon> sortedMap 
  = Maps.sortByValueStream(melons, byWeight);

通过列表按键和值排序

前面的示例对给定的映射进行排序,结果也是一个映射。如果我们只需要排序的键(我们不关心值),反之亦然,那么我们可以依赖于通过Map.keySet()创建的List作为键,通过Map.values()创建的List作为值:

public static <K extends Comparable, V> List<K>
    sortByKeyList(Map<K, V> map) {

  List<K> list = new ArrayList<>(map.keySet());
  Collections.sort(list);

  return list;
}

public static <K, V extends Comparable> List<V>
    sortByValueList(Map<K, V> map) {

  List<V> list = new ArrayList<>(map.values());
  Collections.sort(list);

  return list;
}

现在,让我们对映射进行排序:

// [delicious, famous, refreshing]
List<String> sortedKeys = Maps.sortByKeyList(melons);

// [Cantaloupe(1500g), Apollo(3000g), Jade Dew(3500g)]
List<Melon> sortedValues = Maps.sortByValueList(melons);

如果不允许重复值,则必须依赖于使用SortedSet的实现:

SortedSet<String> sortedKeys = new TreeSet<>(melons.keySet());
SortedSet<Melon> sortedValues = new TreeSet<>(melons.values());

116 复制哈希映射

执行HashMap的浅拷贝的简便解决方案依赖于HashMap构造器HashMap​(Map<? extends K,​? extends V> m)。以下代码是不言自明的:

Map<K, V> mapToCopy = new HashMap<>();
Map<K, V> shallowCopy = new HashMap<>(mapToCopy);

另一种解决方案可能依赖于putAll​(Map<? extends K,​? extends V> m)方法。此方法将指定映射中的所有映射复制到此映射,如以下助手方法所示:

@SuppressWarnings("unchecked")
public static <K, V> HashMap<K, V> shallowCopy(Map<K, V> map) {

  HashMap<K, V> copy = new HashMap<>();
  copy.putAll(map);

  return copy;
}

我们还可以用 Java8 函数式风格编写一个辅助方法,如下所示:

@SuppressWarnings("unchecked")
public static <K, V> HashMap<K, V> shallowCopy(Map<K, V> map) {

  Set<Entry<K, V>> entries = map.entrySet();
  HashMap<K, V> copy = (HashMap<K, V>) entries.stream()
    .collect(Collectors.toMap(
       Map.Entry::getKey, Map.Entry::getValue));

  return copy;
}

然而,这三种解决方案只提供了映射的浅显副本。获取深度拷贝的解决方案可以依赖于克隆库在第 2 章中介绍,“对象、不变性和switch表达式”。将使用克隆的助手方法可以编写如下:

@SuppressWarnings("unchecked") 
public static <K, V> HashMap<K, V> deepCopy(Map<K, V> map) {
  Cloner cloner = new Cloner();
  HashMap<K, V> copy = (HashMap<K, V>) cloner.deepClone(map);

  return copy;
}

117 合并两个映射

合并两个映射是将两个映射合并为一个包含两个映射的元素的映射的过程。此外,对于键碰撞,我们将属于第二个映射的值合并到最终映射中。但这是一个设计决定。

让我们考虑以下两个映射(我们特意为键 3 添加了一个冲突):

public class Melon {

  private final String type;
  private final int weight;

  // constructor, getters, equals(), hashCode(),
  // toString() omitted for brevity
}

Map<Integer, Melon> melons1 = new HashMap<>();
Map<Integer, Melon> melons2 = new HashMap<>();
melons1.put(1, new Melon("Apollo", 3000));
melons1.put(2, new Melon("Jade Dew", 3500));
melons1.put(3, new Melon("Cantaloupe", 1500));
melons2.put(3, new Melon("Apollo", 3000));
melons2.put(4, new Melon("Jade Dew", 3500));
melons2.put(5, new Melon("Cantaloupe", 1500));

从 JDK8 开始,我们在Map: V merge​(K key, V value, BiFunction<? super V,​? super V,​? extends V> remappingFunction)中有以下方法。

如果给定的键(K与值没有关联,或者与null关联,那么新的值将是V。如果给定键(K与非null值相关联,则基于给定的BiFunction计算新值。如果此BiFunction的结果是null,并且该键存在于映射中,则此条目将从映射中删除。

基于这个定义,我们可以编写一个辅助方法来合并两个映射,如下所示:

public static <K, V> Map<K, V> mergeMaps(
    Map<K, V> map1, Map<K, V> map2) {  

  Map<K, V> map = new HashMap<>(map1);

  map2.forEach(
    (key, value) -> map.merge(key, value, (v1, v2) -> v2));

  return map;
}

请注意,我们不会修改原始映射。我们更希望返回一个包含第一个映射的元素与第二个映射的元素合并的新映射。在键冲突的情况下,我们用第二个映射(v2中的值替换现有值。

基于Stream.concat()可以编写另一个解决方案。基本上,这种方法将两个流连接成一个Stream。为了从一个Map创建一个Stream,我们称之为Map.entrySet().stream()。在连接从给定映射创建的两个流之后,我们只需通过toMap()收集器收集结果:

public static <K, V> Map<K, V> mergeMaps(
    Map<K, V> map1, Map<K, V> map2) {

  Stream<Map.Entry<K, V>> combined 
    = Stream.concat(map1.entrySet().stream(), 
      map2.entrySet().stream());

  Map<K, V> map = combined.collect(
    Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue,
      (v1, v2) -> v2));

  return map;
}

作为奖励,Set(例如,整数的Set可以按如下方式排序:

List<Integer> sortedList = someSetOfIntegers.stream()
  .sorted().collect(Collectors.toList());

对于对象,依赖于sorted(Comparator<? super T>

118 删除集合中与谓词匹配的所有元素

我们的集合将收集一堆Melon

public class Melon {

  private final String type;
  private final int weight;

  // constructor, getters, equals(), 
  // hashCode(), toString() omitted for brevity
}

让我们在整个示例中假设以下集合(ArrayList,以演示如何从集合中移除与给定谓词匹配的元素:

List<Melon> melons = new ArrayList<>();
melons.add(new Melon("Apollo", 3000));
melons.add(new Melon("Jade Dew", 3500));
melons.add(new Melon("Cantaloupe", 1500));
melons.add(new Melon("Gac", 1600));
melons.add(new Melon("Hami", 1400));

让我们看看下面几节给出的不同解决方案。

通过迭代器删除

通过Iterator删除是 Java 中最古老的方法。主要地,Iterator允许我们迭代(或遍历)集合并删除某些元素。最古老的方法也有一些缺点。首先,根据集合类型的不同,如果多个线程修改集合,那么通过一个Iterator删除很容易发生ConcurrentModificationException。此外,移除并不是所有集合的行为都相同(例如,从LinkedList移除要比从ArrayList移除快,因为前者只是将指针移动到下一个元素,而后者则需要移动元素)。不过,解决方案在捆绑代码中是可用的。

如果您所需要的只是Iterable的大小,那么请考虑以下方法之一:

// for any Iterable
StreamSupport.stream(iterable.spliterator(), false).count();

// for collections
((Collection<?>) iterable).size()

移除通孔集合.removeIf()

从 JDK8 开始,我们可以通过Collection.removeIf()方法将前面的代码缩减为一行代码。此方法依赖于Predicate,如下例所示:

melons.removeIf(t -> t.getWeight() < 3000);

这一次,ArrayList迭代列表并标记为删除那些满足我们的Predicate的元素。此外,ArrayList再次迭代以移除标记的元素并移动剩余的元素。

使用这种方法,LinkedListArrayList以几乎相同的方式执行。

通过流删除

从 JDK8 开始,我们可以从集合(Collection.stream()中创建一个Stream,并通过filter(Predicate p)过滤它的元素。过滤器将只保留满足给定Predicate的元件。

最后,我们通过合适的收集器收集这些元素:

List<Melon> filteredMelons = melons.stream()
  .filter(t -> t.getWeight() >= 3000)
  .collect(Collectors.toList());

与其他两个解决方案不同,这个解决方案不会改变原始集合,但它可能会更慢,占用更多内存。

通过Collectors.partitioningBy()

有时,我们不想删除与谓词不匹配的元素。我们实际上想要的是基于谓词来分离元素。好吧,这是可以通过Collectors.partitioningBy(Predicate p)实现的。

基本上,Collectors.partitioningBy()将把元素分成两个列表。这两个列表作为值添加到Map。此Map的两个键是truefalse

Map<Boolean, List<Melon>> separatedMelons = melons.stream()
  .collect(Collectors.partitioningBy(
    (Melon t) -> t.getWeight() >= 3000));

List<Melon> weightLessThan3000 = separatedMelons.get(false);
List<Melon> weightGreaterThan3000 = separatedMelons.get(true);

因此,true键用于检索包含与谓词匹配的元素的List,而false键用于检索包含与谓词不匹配的元素的List

作为奖励,如果我们想检查List的所有元素是否相同,那么我们可以依赖Collections.frequency(Collection c, Object obj)。此方法返回指定集合中等于指定对象的元素数:

boolean allTheSame = Collections.frequency(
  melons, melons.get(0)) == melons.size());

如果allTheSametrue,那么所有元素都是相同的。注意,List中的对象的equals()hashCode()必须相应地实现。

119 将集合转换为数组

为了将集合转换为数组,我们可以依赖于Collection.toArray()方法。如果没有参数,此方法会将给定集合转换为一个Object[],如下例所示:

List<String> names = Arrays.asList("ana", "mario", "vio");
Object[] namesArrayAsObjects = names.toArray();

显然,这并不完全有用,因为我们期望的是一个String[]而不是Object[]。这可以通过Collection.toArray​(T[] a)实现,如下所示:

String[] namesArraysAsStrings = names.toArray(new String[names.size()]);
String[] namesArraysAsStrings = names.toArray(new String[0]);

从这两种解决方案中,第二种方案更可取,因为我们避免计算集合大小。

但从 JDK11 开始,还有一种方法专门用于此任务,Collection.toArray​(IntFunction<T[]> generator)。此方法返回一个包含此集合中所有元素的数组,使用提供的生成器函数分配返回的数组:

String[] namesArraysAsStrings = names.toArray(String[]::new);

除了固定大小可修改的Arrays.asList()之外,我们可以通过of()方法从数组中构建一个不可修改的List/Set

String[] namesArray = {"ana", "mario", "vio"};

List<String> namesArrayAsList = List.of(namesArray);
Set<String> namesArrayAsSet = Set.of(namesArray);

120 按列表过滤集合

我们在应用中遇到的一个常见问题是用一个List来过滤一个Collection。主要是从一个巨大的Collection开始,我们想从中提取与List元素匹配的元素。

在下面的例子中,让我们考虑一下Melon类:

public class Melon {

  private final String type;
  private final int weight;

  // constructor, getters, equals(), hashCode(),
  // toString() omitted for brevity
}

这里,我们有一个巨大的Collection(在本例中,是一个ArrayList Melon

List<Melon> melons = new ArrayList<>();
melons.add(new Melon("Apollo", 3000));
melons.add(new Melon("Jade Dew", 3500));
melons.add(new Melon("Cantaloupe", 1500));
melons.add(new Melon("Gac", 1600));
melons.add(new Melon("Hami", 1400));
...

我们还有一个List,包含我们想从前面ArrayList中提取的瓜的类型:

List<String> melonsByType 
  = Arrays.asList("Apollo", "Gac", "Crenshaw", "Hami");

这个问题的一个解决方案可能涉及循环收集和比较瓜的类型,但是生成的代码会非常慢。这个问题的另一个解决方案可能涉及到List.contains()方法和 Lambda 表达式:

List<Melon> results = melons.stream()
  .filter(t -> melonsByType.contains(t.getType()))
  .collect(Collectors.toList());

代码紧凑,速度快。在幕后,List.contains()依赖于以下检查:

// size - the size of melonsByType
// o - the current element to search from melons
// elementData - melonsByType
for (int i = 0; i < size; i++)
  if (o.equals(elementData[i])) {
    return i;
  }
}

然而,我们可以通过依赖于HashSet.contains()而不是List.contains()的解决方案来提高性能。当List.contains()使用前面的for语句来匹配元素时,HashSet.contains()使用Map.containsKey()Set主要是基于Map实现的,每个增加的元素映射为elementPRESENT类型的键值。所以,element是这个Map中的一个键,PRESENT只是一个伪值。

当我们调用HashSet.contains(element)时,实际上我们调用Map.containsKey(element)。该方法基于给定元素的hashCode(),将给定元素与映射中的适当键进行匹配,比equals()快得多。

一旦我们将初始的ArrayList转换成HashSet,我们就可以开始了:

Set<String> melonsSetByType = melonsByType.stream()
  .collect(Collectors.toSet());

List<Melon> results = melons.stream()
  .filter(t -> melonsSetByType.contains(t.getType()))
  .collect(Collectors.toList());

嗯,这个解决方案比上一个快。它的运行时间应该是上一个解决方案所需时间的一半。

121 替换列表的元素

我们在应用中遇到的另一个常见问题是替换符合特定条件的List元素。

在下面的示例中,让我们考虑一下Melon类:

public class Melon {

  private final String type;
  private final int weight;

  // constructor, getters, equals(), hashCode(),
  // toString() omitted for brevity
}

然后,让我们考虑一下MelonList

List<Melon> melons = new ArrayList<>();

melons.add(new Melon("Apollo", 3000));
melons.add(new Melon("Jade Dew", 3500));
melons.add(new Melon("Cantaloupe", 1500));
melons.add(new Melon("Gac", 1600));
melons.add(new Melon("Hami", 1400));

让我们假设我们想把所有重量在 3000 克以下的西瓜换成其他同类型、重 3000 克的西瓜。

解决这个问题的方法是迭代List,然后使用List.set(int index, E element)相应地替换瓜。

下面是一段意大利面代码:

for (int i = 0; i < melons.size(); i++) {

  if (melons.get(i).getWeight() < 3000) {

    melons.set(i, new Melon(melons.get(i).getType(), 3000));
  }
}

另一种解决方案依赖于 Java8 函数式风格,或者更准确地说,依赖于UnaryOperator函数式接口。

基于此函数式接口,我们可以编写以下运算符:

UnaryOperator<Melon> operator = t 
  -> (t.getWeight() < 3000) ? new Melon(t.getType(), 3000) : t;

现在,我们可以使用 JDK8,List.replaceAll(UnaryOperator<E> operator),如下所示:

melons.replaceAll(operator);

两种方法的性能应该几乎相同。

122 线程安全的集合、栈和队列

每当集合/栈/队列容易被多个线程访问时,它也容易出现特定于并发的异常(例如,java.util.ConcurrentModificationException。现在,让我们简要地概述一下 Java 内置的并发集合,并对其进行介绍。

并行集合

幸运的是,Java 为非线程安全集合(包括栈和队列)提供了线程安全(并发)的替代方案,如下所示。

线程安全列表

ArrayList的线程安全版本是CopyOnWriteArrayList。下表列出了 Java 内置的单线程和多线程列表:

单线程 多线程
ArrayList LinkedList CopyOnWriteArrayList(经常读取,很少更新)Vector

CopyOnWriteArrayList实现保存数组中的元素。每次我们调用一个改变列表的方法(例如,add()set()remove(),Java 都会对这个数组的一个副本进行操作。

此集合上的Iterator将对集合的不可变副本进行操作。因此,可以修改原始集合而不会出现问题。在Iterator中看不到原始集合的潜在修改:

List<Integer> list = new CopyOnWriteArrayList<>();

当读取频繁而更改很少时,请使用此集合。

线程安全集合

Set的线程安全版本是CopyOnWriteArraySet。下表列举了 Java 内置的单线程和多线程集:

单线程 多线程
HashSet TreeSet(排序集)LinkedHashSet(维护插入顺序)BitSet EnumSet ConcurrentSkipListSet(排序集)CopyOnWriteArraySet(经常读取,很少更新)

这是一个Set,它的所有操作都使用一个内部CopyOnWriteArrayList。创建这样一个Set可以如下所示:

Set<Integer> set = new CopyOnWriteArraySet<>();

当读取频繁而更改很少时,请使用此集合。

NavigableSet的线程安全版本是ConcurrentSkipListSet(并发SortedSet实现,最基本的操作在O(log n)中)。

线程安全映射

Map的线程安全版本是ConcurrentHashMap

下表列举了 Java 内置的单线程和多线程映射:

单线程 多线程
HashMap TreeMap(排序键)LinkedHashMap(维护插入顺序)IdentityHashMap(通过==比较按键)WeakHashMap EnumMap ConcurrentHashMap ConcurrentSkipListMap(排序图)Hashtable

ConcurrentHashMap允许无阻塞的检索操作(例如,get())。这意味着检索操作可能与更新操作重叠(包括put()remove()

创建ConcurrentHashMap的步骤如下:

ConcurrentMap<Integer, Integer> map = new ConcurrentHashMap<>();

当需要线程安全和高性能时,您可以依赖线程安全版本的Map,即ConcurrentHashMap

避免HashtableCollections.synchronizedMap(),因为它们的性能较差。

对于支持NavigableMapConcurrentMap,操作依赖ConcurrentSkipListMap

ConcurrentNavigableMap<Integer, Integer> map 
  = new ConcurrentSkipListMap<>();

由数组支持的线程安全队列

Java 提供了一个先进先出FIFO)的线程安全队列,由一个数组通过ArrayBlockingQueue支持。下表列出了由数组支持的单线程和多线程 Java 内置队列:

单线程 多线程
ArrayDeque PriorityQueue(排序检索) ArrayBlockingQueue(有界)ConcurrentLinkedQueue(无界)ConcurrentLinkedDeque(无界)LinkedBlockingQueue(可选有界)LinkedBlockingDeque(可选有界)LinkedTransferQueue PriorityBlockingQueue SynchronousQueue DelayQueue Stack

ArrayBlockingQueue的容量在创建后不能更改。尝试将一个元素放入一个完整的队列将导致操作阻塞;尝试从一个空队列中获取一个元素也将导致类似的阻塞。

创建ArrayBlockingQueue很容易,如下所示:

BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(QUEUE_MAX_SIZE);

Java 还提供了两个线程安全的、可选的有界阻塞队列,它们基于通过LinkedBlockingQueueLinkedBlockingDeque链接的节点(双向队列是一个线性集合,支持在两端插入和删除元素)。

基于链接节点的线程安全队列

Java 通过ConcurrentLinkedDeque/ConcurrentLinkedQueue提供了一个由链接节点支持的无边界线程安全队列/队列。这里是ConcurrentLinkedDeque

Deque<Integer> queue = new ConcurrentLinkedDeque<>();

线程安全优先级队列

Java 通过PriorityBlockingQueue提供了一个基于优先级堆的无边界线程安全优先级阻塞队列。

创建PriorityBlockingQueue很容易,如下所示:

BlockingQueue<Integer> queue = new PriorityBlockingQueue<>();

非线程安全版本名为PriorityQueue

线程安全延迟队列

Java 提供了一个线程安全的无界阻塞队列,在该队列中,只有当元素的延迟通过DelayQueue过期时,才能获取该元素。创建一个DelayQueue如下所示:

BlockingQueue<TrainDelay> queue = new DelayQueue<>();

线程安全传输队列

Java 通过LinkedTransferQueue提供了基于链接节点的线程安全的无界传输队列。

这是一个 FIFO 队列,是某个生产者在队列中停留时间最长的元素。队列的是某个生产者在队列中停留时间最短的元素。

创建此类队列的一种方法如下:

TransferQueue<String> queue = new LinkedTransferQueue<>();

线程安全同步队列

Java 提供了一个阻塞队列,其中每个插入操作必须等待另一个线程执行相应的移除操作,反之亦然,通过SynchronousQueue

BlockingQueue<String> queue = new SynchronousQueue<>();

线程安全栈

栈的线程安全实现是StackConcurrentLinkedDeque

Stack类表示对象的后进先出LIFO)栈。它通过几个操作扩展了Vector类,这些操作允许将向量视为栈。Stack的每一种方法都是同步的。创建一个Stack如下所示:

Stack<Integer> stack = new Stack<>();

ConcurrentLinkedDeque实现可以通过其push()pop()方法用作Stack(后进先出):

Deque<Integer> stack = new ConcurrentLinkedDeque<>();

为了获得更好的性能,请选择ConcurrentLinkedDeque而不是Stack

绑定到本书中的代码为前面的每个集合提供了一个应用,用于跨越多个线程,以显示它们的线程安全特性。

同步的集合

除了并行集合,我们还有synchronized集合。Java 提供了一套包装器,将集合公开为线程安全的集合。这些包装在Collections中提供。最常见的有:

  • synchronizedCollection​(Collection<T> c):返回由指定集合支持的同步(线程安全)集合
  • synchronizedList​(List<T> list):返回指定列表支持的同步(线程安全)列表:
List<Integer> syncList 
  = Collections.synchronizedList(new ArrayList<>());
  • synchronizedMap​(Map<K,​V> m):返回指定映射支持的同步(线程安全)映射:
Map<Integer, Integer> syncMap 
  = Collections.synchronizedMap(new HashMap<>());
  • synchronizedSet​(Set<T> s):返回指定集支持的同步(线程安全)集:
Set<Integer> syncSet 
  = Collections.synchronizedSet(new HashSet<>());

并发集合与同步集合

显而易见的问题是“并发集合和同步集合的区别是什么?”好吧,主要区别在于它们实现线程安全的方式。并发集合通过将数据划分为段来实现线程安全。线程可以并发地访问这些段,并且只能在所使用的段上获得锁。另一方面,同步集合通过内部锁定锁定整个集合(调用同步方法的线程将自动获取该方法对象的内在锁,并在方法返回时释放它)。

迭代同步的集合需要手动同步,如下所示:

List syncList = Collections.synchronizedList(new ArrayList());
...
synchronized(syncList) {
  Iterator i = syncList.iterator();
  while (i.hasNext()) {
    // do_something_with i.next();
  }
}

由于并发集合允许线程的并发访问,因此它们的性能比同步集合高得多。

123 广度优先搜索

BFS 是遍历(访问)图或树的所有节点的经典算法。

理解这个算法最简单的方法是通过伪代码和一个例子。BFS 的伪码如下:

  1. 创建队列Q
  2. v标记为已访问,并将v放入Q
  3. Q为非空
  4. 取下Q的头部h
  5. 标记h的所有(未访问的)邻居并入队

假设下图中的图,步骤 0

在第一步(步骤 1),我们访问顶点0。我们把它放在visited列表中,它的所有相邻顶点放在queue(3,1)中。此外,在步骤 2 中,我们访问queue3前面的元素。顶点3步骤 2 中有一个未访问的相邻顶点,所以我们将其添加到queue的后面。接下来,在步骤 3 中,我们访问queue 1前面的元素。该顶点有一个相邻的顶点(0),但该顶点已被访问。最后,我们访问顶点2,最后一个来自queue。这个有一个已经访问过的相邻顶点(3)。

在代码行中,BFS 算法可以实现如下:

public class Graph {

  private final int v;
  private final LinkedList<Integer>[] adjacents;

  public Graph(int v) {

    this.v = v;
    adjacents = new LinkedList[v];

    for (int i = 0; i < v; ++i) {
      adjacents[i] = new LinkedList();
    }
  }

  public void addEdge(int v, int e) {
    adjacents[v].add(e);
  }

  public void BFS(int start) {

    boolean visited[] = new boolean[v];
    LinkedList<Integer> queue = new LinkedList<>();
    visited[start] = true;

    queue.add(start);

    while (!queue.isEmpty()) {
      start = queue.poll();
      System.out.print(start + " ");

      Iterator<Integer> i = adjacents[start].listIterator();
      while (i.hasNext()) {
        int n = i.next();
        if (!visited[n]) {
          visited[n] = true;
          queue.add(n);
        }
      }
    }
  }
}

并且,如果我们引入以下图表(从前面的图表),我们有如下:

Graph graph = new Graph(4);
graph.addEdge(0, 3);
graph.addEdge(0, 1);
graph.addEdge(1, 0);
graph.addEdge(2, 3);
graph.addEdge(3, 0);
graph.addEdge(3, 2);
graph.addEdge(3, 3);

输出将为0 3 1 2

124 Trie

Trie(也称为数字树)是一种有序的树结构,通常用于存储字符串。它的名字来源于 Trie 是 reTrieval数据结构。它的性能优于二叉树。

除 Trie 的根外,Trie 的每个节点都包含一个字符(例如,单词hey将有三个节点)。Trie 的每个节点主要包含以下内容:

  • 值(字符或数字)
  • 指向子节点的指针
  • 如果当前节点完成一个字,则为true的标志
  • 用于分支节点的单个根

下图表示构建包含单词catcaretbye的 Trie 的步骤顺序:

因此,在代码行中,Trie 节点的形状可以如下所示:

public class Node {

  private final Map<Character, Node> children = new HashMap<>();
  private boolean word;

  Map<Character, Node> getChildren() {
    return children;
  }

  public boolean isWord() {
    return word;
  }

  public void setWord(boolean word) {
    this.word = word;
  }
}

基于这个类,我们可以定义一个 Trie 基本结构如下:

class Trie {

  private final Node root;

  public Trie() {
    root = new Node();
  }

  public void insert(String word) {
    ...
  }

  public boolean contains(String word) {
    ...
  }

  public boolean delete(String word) {
    ...
  }
}

插入 Trie

现在,让我们关注在 Trie 中插入单词的算法:

  1. 将当前节点视为根节点。
  2. 从第一个字符开始,逐字符循环给定的单词。
  3. 如果当前节点(Map<Character, Node>)为当前字符映射一个值(Node),那么只需前进到该节点。否则,新建一个Node,将其字符设置为当前字符,并前进到此节点。
  4. 重复步骤 2(传递到下一个字符),直到单词的结尾。
  5. 将当前节点标记为完成单词的节点。

在代码行方面,我们有以下内容:

public void insert(String word) {

  Node node = root;

  for (int i = 0; i < word.length(); i++) {
    char ch = word.charAt(i);
    Function function = k -> new Node();

    node = node.getChildren().computeIfAbsent(ch, function);
  }

  node.setWord(true);
}

插入的复杂度为O(n),其中n表示字长。

搜索 Trie

现在,让我们在 Trie 中搜索一个单词:

  1. 将当前节点视为根节点。
  2. 逐字符循环给定的单词(从第一个字符开始)。
  3. 对于每个字符,检查其在 Trie 中的存在性(在Map<Character, Node>中)。
  4. 如果字符不存在,则返回false
  5. 从第 2 步开始重复,直到单词结束。
  6. 如果是单词,则在单词末尾返回true,如果只是前缀,则返回false

在代码行方面,我们有以下内容:

public boolean contains(String word) {

  Node node = root;

  for (int i = 0; i < word.length(); i++) {
    char ch = word.charAt(i);
    node = node.getChildren().get(ch);

    if (node == null) {
      return false;
    }
  }

  return node.isWord();
}

查找的复杂度为O(n),其中n表示字长。

从 Trie 中删除

最后,让我们尝试从 Trie 中删除:

  1. 验证给定的单词是否是 Trie 的一部分。
  2. 如果它是 Trie 的一部分,那么只需移除它。

使用递归并遵循以下规则,以自下而上的方式进行删除:

  • 如果给定的单词不在 Trie 中,那么什么也不会发生(返回false
  • 如果给定的单词是唯一的(不是另一个单词的一部分),则删除所有相应的节点(返回true
  • 如果给定的单词是 Trie 中另一个长单词的前缀,则将叶节点标志设置为false(返回false
  • 如果给定的单词至少有另一个单词作为前缀,则从给定单词的末尾删除相应的节点,直到最长前缀单词的第一个叶节点(返回false

在代码行方面,我们有以下内容:

public boolean delete(String word) {
  return delete(root, word, 0);
}

private boolean delete(Node node, String word, int position) {

  if (word.length() == position) {
    if (!node.isWord()) {
      return false;
    }

    node.setWord(false);

    return node.getChildren().isEmpty();
  }

  char ch = word.charAt(position);
  Node children = node.getChildren().get(ch);

  if (children == null) {
    return false;
  }

  boolean deleteChildren = delete(children, word, position + 1);

  if (deleteChildren && !children.isWord()) {
    node.getChildren().remove(ch);

    return node.getChildren().isEmpty();
  }

  return false;
}

查找的复杂度为O(n),其中n表示字长。

现在,我们可以构建一个 Trie,如下所示:

Trie trie = new Trie();
trie.insert/contains/delete(...);

125 元组

基本上,元组是由多个部分组成的数据结构。通常,元组有两到三个部分。通常,当需要三个以上的部分时,一个专用类是更好的选择。

元组是不可变的,每当我们需要从一个方法返回多个结果时就使用元组。例如,假设有一个方法返回数组的最小值和最大值。通常,一个方法不能同时返回这两个值,使用元组是一个方便的解决方案。

不幸的是,Java 不提供内置元组支持。然而,Java 附带了Map.Entry<K,​V>,用于表示来自Map的条目。此外,从 JDK9 开始,Map接口被一个名为entry(K k, V v)的方法丰富,该方法返回一个包含给定键和值的不可修改的Map.Entry<K, V>

对于一个由两部分组成的元组,我们可以编写如下方法:

public static <T> Map.Entry<T, T> array(
    T[] arr, Comparator<? super T> c) {

  T min = arr[0];
  T max = arr[0];

  for (T elem: arr) {
    if (c.compare(min, elem) > 0) {
      min = elem;
    } else if (c.compare(max, elem)<0) {
      max = elem;
    }
  }

  return entry(min, max);
}

如果这个方法存在于一个名为Bounds的类中,那么我们可以如下调用它:

public class Melon {

  private final String type;
  private final int weight;

  // constructor, getters, equals(), hashCode(),
  // toString() omitted for brevity
}

Melon[] melons = {
  new Melon("Crenshaw", 2000), new Melon("Gac", 1200),
  new Melon("Bitter", 2200), new Melon("Hami", 800)
};

Comparator<Melon> byWeight = Comparator.comparing(Melon::getWeight);
Map.Entry<Melon, Melon> minmax = Bounds.array(melons, byWeight);

System.out.println("Min: " + minmax1.getKey());   // Hami(800g)
System.out.println("Max: " + minmax1.getValue()); // Bitter(2200g)

但我们也可以编写一个实现。一个由两部分组成的元组通常被称为一个;因此,一个直观的实现可以如下所示:

public final class Pair<L, R> {

  final L left;
  final R right;

  public Pair(L left, R right) {
    this.left = left;
    this.right = right;
  }

  static <L, R> Pair<L, R> of (L left, R right) {

    return new Pair<>(left, right);
  }

  // equals() and hashCode() omitted for brevity
}

现在,我们可以重写计算最小值和最大值的方法,如下所示:

public static <T> Pair<T, T> array(T[] arr, Comparator<? super T> c) {
  ...
  return Pair.of(min, max);
}

126 并查集

并查算法在不相交集数据结构上运行。

不相交的集合数据结构定义了在某些不相交的子集中分离的元素集合,这些子集是不重叠的。从图形上看,我们可以用三个子集表示不相交集,如下图所示:

在代码中,不相交集表示为:

  • n是元素的总数(例如,在上图中,n是 11)。
  • rank是一个用 0 初始化的数组,用于决定如何合并两个具有多个元素的子集(具有较低rank的子集成为具有较高rank的子集的子子集)。
  • parent是允许我们构建基于数组的并查的数组(最初为parent[0] = 0; parent[1] = 1; ... parent[10] = 10;):
public DisjointSet(int n) {

  this.n = n;
  rank = new int[n];
  parent = new int[n];

  initializeDisjointSet();
}

并查算法主要应具备以下功能:

  • 将两个子集合并为一个子集
  • 返回给定元素的子集(这对于查找同一子集中的元素很有用)

为了在内存中存储不相交的集合数据结构,我们可以将它表示为一个数组。最初,在数组的每个索引处,我们存储该索引(x[i] = i。每个索引可以映射到一段对我们有意义的信息,但这不是强制性的。例如,这样一个数组的形状可以如下图所示(最初,我们有 11 个子集,每个元素都是它自己的父元素):

或者,如果我们使用数字,我们可以用下图来表示:

在代码行方面,我们有以下内容:

private void initializeDisjointSet() {

  for (int i = 0; i < n; i++) {
    parent[i] = i;
  }
}

此外,我们需要通过并集操作来定义我们的子集。我们可以通过(对)序列来定义子集。例如,让我们定义以下三对-union(0,1);union(4, 9);union(6, 5);。每次一个元素(子集)成为另一个元素(子集)的子元素时,它都会修改其值以反映其父元素的值,如下图所示:

这个过程一直持续到我们定义了所有的子集。例如,我们可以添加更多的联合-union(0, 7);union(4, 3);union(4, 2);union(6, 10);union(4, 5);。这将产生以下图形表示:

根据经验,建议将较小的子集合并为较大的子集,反之亦然。例如,检查包含4的子集与包含5的子集相统一的时刻。此时,4是子集的父项,它有三个子项(239),而5紧挨着106的两个子项。因此,包含5的子集有三个节点(6510),而包含4的子集有四个节点(4239)。因此,4成为6的父,并且隐含地成为5的父。

在代码行中,这是rank[]数组的工作:

现在让我们看看如何实现findunion操作

实现查找操作

查找给定元素的子集是一个递归过程,通过跟随父元素遍历子集,直到当前元素是其自身的父元素(根元素):

public int find(int x) {

  if (parent[x] == x) {
    return x;
  } else {
    return find(parent[x]);
  }
}

实现并集操作

并集操作首先获取给定子集的根元素。此外,如果这两个根是不同的,它们需要依赖于它们的秩来决定哪一个将成为另一个的父(较大的秩将成为父)。如果它们的等级相同,则选择其中一个并将其等级增加 1:

public void union(int x, int y) {

  int xRoot = find(x);
  int yRoot = find(y);

  if (xRoot == yRoot) {
    return;
  }

  if (rank[xRoot] < rank[yRoot]) {
    parent[xRoot] = yRoot;
  } else if (rank[yRoot] < rank[xRoot]) {
    parent[yRoot] = xRoot;
  } else {
    parent[yRoot] = xRoot;
    rank[xRoot]++;
  }
}

好吧。现在让我们定义一个不相交集:

DisjointSet set = new DisjointSet(11);
set.union(0, 1);
set.union(4, 9);
set.union(6, 5);
set.union(0, 7);
set.union(4, 3);
set.union(4, 2);
set.union(6, 10);
set.union(4, 5);

现在让我们来玩玩它:

// is 4 and 0 friends => false
System.out.println("Is 4 and 0 friends: " 
  + (set.find(0) == set.find(4)));

// is 4 and 5 friends => true
System.out.println("Is 4 and 5 friends: " 
  + (set.find(4) == set.find(5)));

该算法可以通过压缩元素间的路径来优化。例如,检查下图:

在左侧,在寻找5的父代时,必须经过6直到4。同样,在寻找10的父代时,必须经过6,直到4为止。然而,在右侧,我们通过直接链接到4来压缩510的路径。这一次,我们不需要通过中间元素就可以找到510的父代。

路径压缩可以针对find()操作进行,如下所示:

public int find(int x) {

  if (parent[x] != x) {
    return parent[x] = find(parent[x]);
  }

  return parent[x];
}

捆绑到本书中的代码包含两个应用,有路径压缩和没有路径压缩。

127 Fenwick 树或二叉索引树

芬威克树FT)或二叉索引树BIT)是为存储对应于另一给定数组的和而构建的数组。构建数组的大小与给定数组的大小相同,并且构建数组的每个位置(或节点)都存储给定数组中某些元素的总和。由于 BIT 存储给定数组的部分和,因此通过避免索引之间的循环和计算和,它是计算给定数组中两个给定索引(范围和/查询)之间的元素和的非常有效的解决方案。

位可以在线性时间或O(n log n)中构造。显然,我们更喜欢线性时间,所以让我们看看如何做到这一点。我们从给定的(原始)数组开始,该数组可以是(下标表示数组中的索引):

3(1), 1(2), 5(3), 8(4), 12(5), 9(6), 7(7), 13(8), 0(9), 3(10), 1(11), 4(12), 9(13), 0(14), 11(15), 5(16)

构建位的想法依赖于最低有效位LSB)概念。更准确地说,假设我们正在处理索引中的元素,a。那么,紧靠我们上方的值必须位于索引b,其中b = a + LSB(a)。为了应用该算法,索引 0 的值必须是 0;因此,我们操作的数组如下:

0(0), 3(1), 1(2), 5(3), 8(4), 12(5), 9(6), 7(7), 13(8), 0(9), 3(10), 1(11), 4(12), 9(13), 0(14), 11(15), 5(16)

现在,让我们应用算法的几个步骤,用和填充位。在位的索引 0 处,我们有 0。此外,我们使用b = a + LSB(a)公式计算剩余和,如下所示:

  1. a=1:如果a=1=0b00001,则b=0b00001+0b00001=1+1=2=0b00010。我们说 2 负责a(也就是 1)。因此,在位中,在索引 1 处,我们存储值 3,在索引 2 处,我们存储值的和,3+1=4
  2. a=2:如果a=2=0b00010,则b=0b00010+0b00010=2+2=4=0b00100。我们说 4 负责a(即 2)。因此,在索引 4 处,我们以位的形式存储值的和,8+4=12
  3. a=3:如果a=3=0b00011,则b=0b00011+0b00001=3+1=4=0b00100。我们说 4 负责a(也就是 3)。因此,在位中,在索引 4 处,我们存储值的和,12+5=17
  4. a=4。如果a=4=0b00100,则b=0b00100+0b00100=4+4=8=0b01000。我们说 8 负责a(也就是 4)。因此,在位中,在索引 8 处,我们存储值的和,13+17=30

算法将以相同的方式继续,直到位完成。在图形表示中,我们的案例可以如下所示:

如果索引的计算点超出了界限,那么只需忽略它。

在代码行中,前面的流的形状可以如下所示(值是给定的数组):

public class FenwickTree {

  private final int n;
  private long[] tree;
  ...

  public FenwickTree(long[] values) {

    values[0] = 0 L;
    this.n = values.length;
    tree = values.clone();

    for (int i = 1; i < n; i++) {

      int parent = i + lsb(i);
      if (parent < n) {
        tree[parent] += tree[i];
      }
    }
  }

  private static int lsb(int i) {

      return i & -i;

      // or
      // return Integer.lowestOneBit(i);
    }

    ...
}

现在,位准备好了,我们可以执行更新和范围查询。

例如,为了执行范围求和,我们必须获取相应的范围并将它们相加。请考虑下图右侧的几个示例,以快速了解此过程:

就代码行而言,这可以很容易地形成如下形状:

public long sum(int left, int right) {

  return prefixSum(right) - prefixSum(left - 1);
}

private long prefixSum(int i) {

  long sum = 0L;

  while (i != 0) {
    sum += tree[i];
    i &= ~lsb(i); // or, i -= lsb(i);
  }

  return sum;
}

此外,我们还可以增加一个新的值:

public void add(int i, long v) {

  while (i < n) {
    tree[i] += v;
    i += lsb(i);
  }
}

我们还可以为某个索引设置一个新值:

public void set(int i, long v) {
  add(i, v - sum(i, i));
}

具备所有这些功能后,我们可以按如下方式为数组创建位:

FenwickTree tree = new FenwickTree(new long[] {
  0, 3, 1, 5, 8, 12, 9, 7, 13, 0, 3, 1, 4, 9, 0, 11, 5
});

然后我们可以玩它:

long sum29 = tree.sum(2, 9); // 55
tree.set(4, 3);
tree.add(4, 5);

128 布隆过滤器

布隆过滤器是一种快速高效的数据结构,能够提供问题的概率答案“值 X 在给定的集合中吗?”

通常情况下,当集合很大且大多数搜索算法都面临内存和速度问题时,此算法非常有用。

布隆过滤器的速度和内存效率来自这样一个事实,即该数据结构依赖于位数组(例如,java.util.BitSet)。最初,该数组的位被设置为0false

比特数组是布隆过滤器的第一个主要组成部分。第二个主要成分由一个或多个哈希函数组成。理想情况下,这些是成对独立的均匀分布的散列函数。另外,非常重要的是要非常快。murrur、fnv系列和HashMix是一些散列函数,它们在布鲁姆过滤器可以接受的范围内遵守这些约束。

现在,当我们向布隆过滤器添加一个元素时,我们需要对这个元素进行散列(通过每个可用的散列函数传递它),并将这些散列的索引处的位数组中的位设置为1true

下面的代码片段应该可以阐明主要思想:

private BitSet bitset; // the array of bits
private static final Charset CHARSET = StandardCharsets.UTF_8;
...
public void add(T element) {

  add(element.toString().getBytes(CHARSET));
}

public void add(byte[] bytes) {

  int[] hashes = hash(bytes, numberOfHashFunctions);

  for (int hash: hashes) {
    bitset.set(Math.abs(hash % bitSetSize), true);
  }

  numberOfAddedElements++;
}

现在,当我们搜索一个元素时,我们通过相同的散列函数传递这个元素。此外,我们检查结果值是否在位数组中标记为1true。如果不是,那么元素肯定不在集合中。但如果它们是,那么我们就以一定的概率知道元素在集合中。这不是 100% 确定的,因为另一个元素或元素的组合可能已经翻转了这些位。错误答案称为假正例

在代码行方面,我们有以下内容:

private BitSet bitset; // the array of bits
private static final Charset CHARSET = StandardCharsets.UTF_8;
...

public boolean contains(T element) {

  return contains(element.toString().getBytes(CHARSET));
}

public boolean contains(byte[] bytes) {

  int[] hashes = hash(bytes, numberOfHashFunctions);

  for (int hash: hashes) {
    if (!bitset.get(Math.abs(hash % bitSetSize))) {

      return false;
    }
  }

  return true;
}

在图形表示中,我们可以用大小为 11 的位数组和三个哈希函数来表示布隆过滤器,如下所示(我们添加了两个元素):

显然,我们希望尽可能减少假正例的数量。虽然我们不能完全消除它们,但我们仍然可以通过调整位数组的大小、哈希函数的数量和集合中元素的数量来影响它们的速率。

以下数学公式可用于塑造最佳布隆过滤器:

  • 过滤器中的项数(可根据mkp估计):

n = ceil(m / (-k / log(1 - exp(log(p) / k))));

  • 假正例的概率,介于 0 和 1 之间的分数,或表示p中的 1 的数量:

p = pow(1 - exp(-k / (m / n)), k);

  • 过滤器中的位数(或按 KB、KiB、MB、MB、GiB 等表示的大小):

m = ceil((n * log(p)) / log(1 / pow(2, log(2))));

  • 散列函数个数(可根据mn估计):

k = round((m / n) * log(2));

根据经验,一个较大的过滤器比一个较小的过滤器具有更少的假正例。此外,通过增加散列函数的数量,我们可以获得较少的假正例,但我们会减慢过滤器的速度,并将其快速填充。布隆过滤器的性能为O(h),其中h是散列函数的个数。

在本书附带的代码中,有一个布隆过滤器的实现,它使用基于 SHA-256 和 murrur 的散列函数。由于这段代码太大,无法在本书中列出,因此请考虑将Main类中的示例作为起点。

总结

本章涵盖了涉及数组、集合和几个数据结构的 30 个问题。虽然涉及数组和集合的问题是日常工作的一部分,但涉及数据结构的问题引入了一些不太知名(但功能强大)的数据结构,如并查集和 Trie。

从本章下载应用以查看结果并检查其他详细信息。**

posted @ 2023-04-14 01:32  绝不原创的飞龙  阅读(44)  评论(0)    收藏  举报