美文网首页
ITEM 45: 谨慎的使用 STREAMS

ITEM 45: 谨慎的使用 STREAMS

作者: rabbittttt | 来源:发表于2019-10-28 20:54 被阅读0次

    ITEM 45: USE STREAMS JUDICIOUSLY
      streams API 是在 Java 8 中添加的,它简化了按顺序或并行执行批量操作的任务。该 API 提供了两个关键的抽象: stream (表示有限或无限的数据元素序列)和 stream pipeline (表示这些元素上的多级计算)。流中的元素可以来自任何地方。常见的源包括集合、数组、文件、正则表达式模式匹配器、伪随机数生成器和其他流。流中的数据元素可以是对象引用或原语值。支持三种基本类型: int、long和double。
      流管道由源流、零或多个中间操作和一个终端操作组成。每个中间操作以某种方式转换流,例如将每个元素映射到该元素的一个函数,或者过滤掉不满足某个条件的所有元素。中间操作都将一个流转换为另一个流,其元素类型可能与输入流相同,也可能与输入流不同。终端操作对最后一个中间操作产生的流执行最后一次计算,例如将其元素存储到一个集合中、返回某个元素或打印其所有元素。
      流管道的计算是延迟的:直到调用终端操作才开始计算,并且永远不会计算完成终端操作所需的数据元素。这种惰性的计算使处理无限流成为可能。请注意,没有终端操作的流管道是静默的no-op,因此不要忘记包含一个。
      streams API是连贯的:它被设计成允许组成管道的所有调用都链接到一个表达式中。实际上,可以将多个管道链接到一个表达式中。默认情况下,流管道按顺序运行。使管道并行执行与在管道中的任何流上调用并行方法一样简单,但很少适合这样做(item 48)。
      streams API 具有足够的通用性,实际上任何计算都可以使用 streams 执行,但这并不意味着应该这样做。如果使用得当,流可以使程序更短、更清晰;如果使用不当,它们会使程序难于阅读和维护。对于何时使用流没有严格的规则,但是有一些启发。
      考虑下面的程序,它从字典文件中读取单词并打印大小满足用户指定的最小值的所有字谜组。记住,如果两个单词由相同的字母以不同的顺序组成,它们就是字谜。该程序从用户指定的字典文件中读取每个单词,并将这些单词放入地图中。地图键是字母按字母顺序排列的单词,因此 "staple" 的 key 是 “aelpst”,“petals” 的键也是 “aelpst”:这两个单词是字谜,所有的字谜都有相同的字母顺序形式(有时也称为字母组合)。map 的 value 是一个包含所有共享字母格式的单词的列表。处理完字典之后,每个列表都是一个完整的字谜组。然后,程序遍历 map 的 values() 视图,并打印大小满足阈值的每个列表:

    // Prints all large anagram groups in a dictionary iteratively
    public class Anagrams {
      public static void main(String[] args) throws IOException { 
        File dictionary = new File(args[0]);
        int minGroupSize = Integer.parseInt(args[1]);
        Map<String, Set<String>> groups = new HashMap<>(); 
        try (Scanner s = new Scanner(dictionary)) {
          while (s.hasNext()) {
            String word = s.next(); 
            groups.computeIfAbsent(alphabetize(word), (unused) -> new TreeSet<>()).add(word);
          } 
        }
        for (Set<String> group : groups.values()) 
          if (group.size() >= minGroupSize)
          System.out.println(group.size() + ": " + group); 
      }
      private static String alphabetize(String s) { 
        char[] a = s.toCharArray(); Arrays.sort(a);
        return new String(a);
      } 
    }
    

      注意代码中的这个步骤:将每个单词插入到映射中,使用的是 computeIfAbsent 方法,该方法是在 Java 8 中添加的。此方法在映射中查找键:如果键存在,则该方法仅返回与之关联的值。如果不是,则该方法通过将给定的函数对象应用于键来计算一个值,将该值与键关联起来,并返回计算后的值。computeIfAbsent 方法简化了将多个值与每个键关联的映射的实现。
      现在考虑下面的程序,它解决了相同的问题,但是大量使用了流。注意,除打开字典文件的代码外,整个程序都包含在一个表达式中。字典以单独的表达式打开的唯一原因是允许使用 try-with-resources 语句,这确保了字典文件是关闭的:

    // Overuse of streams - don't do this!
    public class Anagrams {
      public static void main(String[] args) throws IOException {
        Path dictionary = Paths.get(args[0]);
        int minGroupSize = Integer.parseInt(args[1]);
        try (Stream<String> words = Files.lines(dictionary)) { 
          words.collect(groupingBy(word -> word.chars().sorted()
                                                    .collect(StringBuilder::new,(sb, c) -> sb.append((char) c), StringBuilder::append).toString())) 
                                                    .values().stream()
                                                                  .filter(group -> group.size() >= minGroupSize)
                                                                  .map(group -> group.size() + ": " + group) 
                                                                  .forEach(System.out::println);
        } 
      }
    }
    

      如果您觉得这段代码很难读,不要担心;你不是一个人。它的确更短,但是可读性也更差,特别是对于那些不擅长使用流的程序员来说。过度使用流使得程序难以阅读和维护。
      幸运的是,有一个折中的办法。下面的程序解决了相同的问题,使用流而不过度使用它们。结果是一个程序比原来的更短,更清晰:

    // Tasteful use of streams enhances clarity and conciseness
    public class Anagrams {
      public static void main(String[] args) throws IOException { 
        Path dictionary = Paths.get(args[0]);
        int minGroupSize = Integer.parseInt(args[1]);
        try (Stream<String> words = Files.lines(dictionary)) { 
          words.collect(groupingBy(word -> alphabetize(word)))
                    .values().stream()
                    .filter(group -> group.size() >= minGroupSize)
                    .forEach(g -> System.out.println(g.size() + ": " + g));
        } 
      }
      // alphabetize method is the same as in original version 
    }
    

      即使您以前很少接触流,这个程序也不难理解。它在一个 try-with-resources 块中打开字典文件,获得一个由文件中的所有行组成的流。流变量被命名为 words,表示流中的每个元素都是一个单词。该流上的管道没有中间操作;它的终端操作将所有单词收集到一个地图中,然后按字母顺序将单词分组(item 46)。这与在程序的前两个版本中构造的映射完全相同。然后在 ma p的 values() 视图上打开一个新的流。当然,这个流中的元素是字谜组。对流进行过滤,以便忽略大小小于 minGroupSize 的所有组,最后,通过终端操作 forEach 打印剩余的组。
      注意,lambda 参数名是经过仔细选择的。参数 g 实际上应该命名为 group,但是结果代码行对于本书来说太宽了。在缺乏显式类型的情况下,小心命名lambda参数对于流管道的可读性至关重要。
      还要注意,单词的字母化是在单独的字母化方法中完成的。这通过为操作提供一个名称并将实现细节排除在主程序之外来增强可读性。对于流管道中的可读性来说,使用辅助方法甚至比在迭代代码中更重要,因为管道缺乏显式的类型信息和命名的临时变量。
    可以重新实 现alphabetize 方法来使用 streams,但是基于 streams 的 alphabetize 方法不太清晰,难于正确书写,速度可能更慢。这些缺陷是由于 Java 缺乏对原始 char 流的支持(这并不意味着 Java 应该支持 char 流;这样做是不可行的。为了演示使用流处理 char 值的危害,请考虑以下代码:
    "Hello world!".chars().forEach(System.out::print);
      您可能希望它打印Hello world!,但是如果运行它,您会发现它打印 721011081081113211911111410810033。这是因为 “Hello world!”.chars()返回的流的元素不是 char 值,而是 int 值,所以会调用 print 的 int 重载。不可否认,一个名为 chars 的方法返回一个 int 值流是令人困惑的。你可以修复程序使用强制调用正确的重载:
    "Hello world!".chars().forEach(x -> System.out.print((char) x));
      但是理想情况下,您应该避免使用流来处理char值。
      当你开始使用流的时候,你可能会有把所有的循环都转换成流的冲动,但是要抵制这种冲动。虽然这是可能的,但它可能会损害代码库的可读性和可维护性。一般来说,即使是中等复杂的任务,也最好使用流和迭代的组合来完成,如上面的 Anagrams 程序所示。因此,重构现有  代码以使用流,并仅在有意义的地方在新代码中使用它们。
    如本项目中的程序所示,流管道使用函数对象(通常是 lambdas 或方法引用)表示重复计算,而迭代代码使用代码块表示重复计算。有些事情  你可以从代码块做,但你不能从函数对象做:
    •从代码块中,您可以读取或修改范围内的任何局部变量;对于 lambda,您只能读取 final 或有效的 final 变量[JLS 4.12.4],并且不能修改任何局部变量。
    •从代码块中,您可以从封闭方法返回,中断或继续封闭循环,或抛出声明该方法要抛出的任何已检查的异常;从一个 lambda 你不能做这些事情。
      以上场景最好使用函数对象。相反,流使做某些事情变得非常容易:
    • 变换元素序列
    • 过滤元素序列
    • 使用单个操作组合元素序列(例如添加、连接或计算它们的最小值)
    • 将元素序列累积到一个集合中,可能根据某个公共属性将它们分组
    • 在元素序列中搜索满足某些条件的元素
      以上场景中,流的一个很好的候选方法。
      流很难同时访问来自管道的多个阶段的对应元素:一旦将一个值映射到其他值,原始值就会丢失。一种解决方法是将每个值映射到包含原始值和新值的 pair 对象,但这不是一个令人满意的解决方案,尤其是在管道的多个阶段都需要 pair 对象的情况下。产生的代码混乱而冗长,这破坏了流的主要目的。当它适用时,更好的解决方法是在需要访问早期值时反转映射。
    例如,让我们编写一个程序来打印前20个梅森素数。提醒你一下,梅森数是2p - 1的形式。如果p是素数,相应的梅森数可能是素数;如果它是素数,那就是梅森素数。作为管道中的初始流,我们需要所有的质数。这里有一个方法来返回那个(无限)流。我们假设使用了一个静态导入来方便地访问 BigInteger 的静态成员:

    static Stream<BigInteger> primes() {
      return Stream.iterate(TWO, BigInteger::nextProbablePrime);
    }
    

      方法的名称(素数)是一个复数名词,用于描述流的元素。强烈建议对所有返回流的方法使用这种命名约定,因为它增强了流管道的可读性。该方法使用 Stream.iterate,它接受两个参数:流中的第一个元素,以及一个用于从前一个元素生成流中的下一个元素的函数。下面是打印前20个梅森素数的程序:

    public static void main(String[] args) {
      primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
                   .filter(mersenne -> mersenne.isProbablePrime(50)) 
                   .limit(20)
                  .forEach(System.out::println);
    }
    

      这个程序对上面的描述进行了简单的编码:它从素数开始,计算相应的梅森数,过滤掉除素数以外的所有数(魔数50控制着概率素数测试),将结果流限制为20个元素,并将它们打印出来。
      现在假设我们要在每个Mersenne 之前加上它的指数(p)。这个值只出现在初始流中,所以在输出结果的终端操作中是不可访问的。幸运的是,通过反转第一个中间操作中发生的映射,很容易计算出梅森数的指数。指数只是二进制表示中的位数,所以这个终端操作产生了想要的结果:
    .forEach(mp -> System.out.println(mp.bitLength() + ": " + mp));
      在许多任务中,使用流还是迭代并不明显。例如,考虑初始化一副新纸牌的任务。假设 Card 是一个不可变的值类,它封装了 Rank 和 Suit,它们都是 enum 类型。此任务代表需要计算可从两个集合中选择的所有元素对的任何任务。数学家称之为两个集合的笛卡尔积。这是一个迭代实现嵌套的 for-each 循环,你应该很熟悉:

    // Iterative Cartesian product computation
    private static List<Card> newDeck() { 
      List<Card> result = new ArrayList<>(); 
      for (Suit suit : Suit.values())
        for (Rank rank : Rank.values()) 
          result.add(new Card(suit, rank));
      return result; 
    }
    

      这里是一个基于流的实现,它利用了中间操作平面映射。此操作将流中的每个元素映射到一个流,然后将所有这些新流连接到单个流中(或将它们压扁)。注意这个实现包含一个嵌套的 lambda:

    // Stream-based Cartesian product computation
    private static List<Card> newDeck() { 
      return Stream.of(Suit.values())
                            .flatMap(suit -> Stream.of(Rank.values())
                                                                 .map(rank -> new Card(suit, rank))) 
                            .collect(toList());
    }
    

      两个版本的 newDeck 哪个更好?这可以归结为个人偏好和编程环境。第一个版本更简单,可能感觉更自然。大部分 Java 程序员将能够理解并维护它,但是有些程序员对第二个(基于流的)版本会感到更舒服。如果您相当精通流和函数式编程,那么它会更简明一些,也不会太难理解。如果您不确定您更喜欢哪个版本,迭代版本可能是更安全的选择。如果您更喜欢流版本,并且相信其他使用该代码的程序员也会与您有相同的偏好,那么您应该使用它。
      总之,有些任务最好通过流来完成,而有些任务则通过迭代来完成。许多任务最好通过结合这两种方法来完成。对于选择任务使用哪种方法没有严格的规则,但是有一些有用的启发方法。在许多情况下,使用哪种方法是很清楚的;在某些情况下,它不会。如果您不确定一个任务是使用流更好还是使用迭代更好,请同时尝试这两种方法,看看哪种效果更好。

    相关文章

      网友评论

          本文标题:ITEM 45: 谨慎的使用 STREAMS

          本文链接:https://www.haomeiwen.com/subject/nxcivctx.html