MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java Stream 对比传统 foreach 的逻辑解耦优势

2022-06-231.3k 阅读

Java Stream 概述

Java 8 引入了 Stream API,它为处理集合数据提供了一种全新的方式。Stream 代表着来自数据源的元素序列,支持一系列聚合操作。与传统集合不同,Stream 并不存储数据,而是在管道操作中按需处理数据。它的设计理念借鉴了函数式编程思想,使得代码更加简洁、易读,并且具备并行处理的能力。

Stream 操作主要分为中间操作和终端操作。中间操作会返回一个新的 Stream,允许链式调用多个中间操作,例如 filtermap 等。终端操作会触发 Stream 的处理,并返回一个结果,如 forEachcollect 等。

传统 foreach 循环简介

传统的 foreach 循环是 Java 中遍历集合的一种常用方式。它语法简洁,适用于大多数简单的遍历场景。例如,遍历一个 List 并打印其中的元素:

import java.util.ArrayList;
import java.util.List;

public class TraditionalForEachExample {
    public static void main(String[] args) {
        List<Integer> numbers = new ArrayList<>();
        numbers.add(1);
        numbers.add(2);
        numbers.add(3);

        for (Integer number : numbers) {
            System.out.println(number);
        }
    }
}

在上述代码中,for (Integer number : numbers) 这种语法就是 foreach 循环,它依次从 numbers 集合中取出每个元素并赋值给 number 变量,然后执行循环体中的代码。

逻辑解耦的概念

逻辑解耦指的是将不同功能的代码块分离,使每个部分只专注于自己的职责。在编程中,这有助于提高代码的可维护性、可扩展性和可读性。例如,在处理集合数据时,可能会有筛选数据、转换数据、计算结果等不同的逻辑,将这些逻辑解耦可以让代码结构更加清晰。

传统 foreach 循环在逻辑耦合上的问题

  1. 数据遍历与业务逻辑耦合 在传统的 foreach 循环中,数据的遍历和业务逻辑紧密结合在一起。比如,假设我们要从一个整数列表中筛选出偶数并计算它们的平方和。使用传统 foreach 循环实现如下:
import java.util.ArrayList;
import java.util.List;

public class TraditionalForEachCouplingExample {
    public static void main(String[] args) {
        List<Integer> numbers = new ArrayList<>();
        numbers.add(1);
        numbers.add(2);
        numbers.add(3);
        numbers.add(4);

        int sumOfSquaresOfEvens = 0;
        for (Integer number : numbers) {
            if (number % 2 == 0) {
                sumOfSquaresOfEvens += number * number;
            }
        }
        System.out.println("Sum of squares of evens: " + sumOfSquaresOfEvens);
    }
}

在这段代码中,数据的遍历(for (Integer number : numbers))和筛选偶数(if (number % 2 == 0))以及计算平方和(sumOfSquaresOfEvens += number * number)的业务逻辑都写在了同一个循环体中。如果业务逻辑发生变化,比如要改为筛选奇数并计算立方和,就需要在这个循环体中多处修改代码,这增加了出错的风险,并且代码的可读性也会受到影响,因为不同的逻辑混合在一起。

  1. 代码复用性差 当有多个地方需要执行类似的筛选和计算逻辑时,在传统 foreach 循环的模式下,往往需要复制粘贴代码。例如,假设有另一个列表也需要进行相同的筛选偶数并计算平方和的操作,就不得不重新写一遍循环逻辑。这不仅增加了代码量,而且后续如果逻辑需要修改,需要在多个地方进行修改,难以维护。

  2. 不利于并行处理 传统 foreach 循环本质上是顺序执行的。如果要将其改为并行处理,需要对代码进行较大的改动,并且需要自己管理线程、同步等复杂操作。例如,要并行计算上述的平方和,就需要手动创建线程池、分配任务等,这对于开发者来说是一项较为复杂的工作,而且容易出错。

Java Stream 的逻辑解耦优势

数据遍历与业务逻辑解耦

  1. 使用 Stream 实现筛选和计算 使用 Java Stream 来实现上述筛选偶数并计算平方和的功能,代码如下:
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

public class StreamDecouplingExample {
    public static void main(String[] args) {
        List<Integer> numbers = new ArrayList<>();
        numbers.add(1);
        numbers.add(2);
        numbers.add(3);
        numbers.add(4);

        int sumOfSquaresOfEvens = numbers.stream()
               .filter(number -> number % 2 == 0)
               .mapToInt(number -> number * number)
               .sum();

        System.out.println("Sum of squares of evens: " + sumOfSquaresOfEvens);
    }
}

在这段代码中,numbers.stream() 将集合转换为 Stream,filter(number -> number % 2 == 0) 负责筛选出偶数,这是筛选逻辑;mapToInt(number -> number * number) 将偶数映射为其平方,这是转换逻辑;最后 sum() 计算平方和,这是计算逻辑。可以看到,数据的遍历由 Stream 内部机制负责,而业务逻辑通过不同的操作方法清晰地分离出来。每个操作方法专注于自己的功能,使得代码结构更加清晰,可读性更强。

  1. 逻辑的独立维护和扩展 如果业务逻辑发生变化,比如改为筛选奇数并计算立方和,只需要修改相应的 filtermapToInt 方法即可,而不需要像传统 foreach 循环那样在一个大的循环体中到处找相关代码进行修改。例如:
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

public class StreamDecouplingChangeExample {
    public static void main(String[] args) {
        List<Integer> numbers = new ArrayList<>();
        numbers.add(1);
        numbers.add(2);
        numbers.add(3);
        numbers.add(4);

        int sumOfCubesOfOdds = numbers.stream()
               .filter(number -> number % 2 != 0)
               .mapToInt(number -> number * number * number)
               .sum();

        System.out.println("Sum of cubes of odds: " + sumOfCubesOfOdds);
    }
}

这样的代码结构使得业务逻辑的维护和扩展变得更加容易,降低了出错的可能性。

提高代码复用性

  1. 封装 Stream 操作 由于 Stream 的操作方法清晰地分离了不同的逻辑,我们可以将常用的 Stream 操作封装成方法,提高代码的复用性。例如,假设我们经常需要筛选偶数并计算平方和,可以封装如下:
import java.util.List;
import java.util.stream.Collectors;

public class StreamReusabilityExample {
    public static int sumOfSquaresOfEvens(List<Integer> numbers) {
        return numbers.stream()
               .filter(number -> number % 2 == 0)
               .mapToInt(number -> number * number)
               .sum();
    }

    public static void main(String[] args) {
        List<Integer> numbers1 = List.of(1, 2, 3, 4);
        List<Integer> numbers2 = List.of(5, 6, 7, 8);

        int sum1 = sumOfSquaresOfEvens(numbers1);
        int sum2 = sumOfSquaresOfEvens(numbers2);

        System.out.println("Sum1: " + sum1);
        System.out.println("Sum2: " + sum2);
    }
}

在上述代码中,sumOfSquaresOfEvens 方法封装了筛选偶数并计算平方和的 Stream 操作逻辑。当有不同的列表需要执行相同的操作时,直接调用这个方法即可,避免了重复编写代码。这大大提高了代码的复用性,也使得代码更加简洁和易于维护。

  1. 复用不同的 Stream 操作组合 Stream 的另一个复用优势在于可以灵活地组合不同的操作方法。例如,我们有一个需求是筛选出长度大于 3 的字符串并转换为大写,然后收集到一个新的列表中。同时,还有另一个需求是筛选出长度大于 5 的字符串并转换为小写,然后收集到一个新的列表中。我们可以复用 filtermap 等操作来实现不同的需求:
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

public class StreamOperationReusabilityExample {
    public static List<String> filterAndTransformStrings(List<String> strings, int length, boolean toUpperCase) {
        return strings.stream()
               .filter(str -> str.length() > length)
               .map(toUpperCase? String::toUpperCase : String::toLowerCase)
               .collect(Collectors.toList());
    }

    public static void main(String[] args) {
        List<String> words = new ArrayList<>();
        words.add("apple");
        words.add("banana");
        words.add("cherry");
        words.add("date");

        List<String> result1 = filterAndTransformStrings(words, 3, true);
        List<String> result2 = filterAndTransformStrings(words, 5, false);

        System.out.println("Result1: " + result1);
        System.out.println("Result2: " + result2);
    }
}

filterAndTransformStrings 方法中,通过传入不同的参数来决定筛选条件和转换方式,复用了 filtermap 操作,实现了不同的业务需求。这种复用方式使得代码更加灵活,减少了重复代码的编写。

天然支持并行处理

  1. 简单实现并行计算 Java Stream 天然支持并行处理,只需要将 stream() 改为 parallelStream() 即可轻松实现并行计算。继续以上述筛选偶数并计算平方和为例,改为并行处理的代码如下:
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

public class StreamParallelExample {
    public static void main(String[] args) {
        List<Integer> numbers = new ArrayList<>();
        numbers.add(1);
        numbers.add(2);
        numbers.add(3);
        numbers.add(4);

        int sumOfSquaresOfEvens = numbers.parallelStream()
               .filter(number -> number % 2 == 0)
               .mapToInt(number -> number * number)
               .sum();

        System.out.println("Sum of squares of evens: " + sumOfSquaresOfEvens);
    }
}

通过将 stream() 替换为 parallelStream(),Stream 会自动利用多核 CPU 的优势并行处理数据。这对于处理大数据集时可以显著提高计算效率,而开发者只需要进行简单的方法替换,无需手动管理线程、同步等复杂操作。

  1. 并行处理的性能优势 为了更直观地展示并行处理的性能优势,我们可以进行一个简单的性能测试。假设我们有一个包含大量整数的列表,分别使用顺序 Stream 和并行 Stream 来计算平方和:
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.stream.Collectors;

public class StreamPerformanceExample {
    public static void main(String[] args) {
        List<Integer> largeList = new ArrayList<>();
        Random random = new Random();
        for (int i = 0; i < 10000000; i++) {
            largeList.add(random.nextInt(100));
        }

        long startTimeSequential = System.currentTimeMillis();
        int sumSequential = largeList.stream()
               .filter(number -> number % 2 == 0)
               .mapToInt(number -> number * number)
               .sum();
        long endTimeSequential = System.currentTimeMillis();

        long startTimeParallel = System.currentTimeMillis();
        int sumParallel = largeList.parallelStream()
               .filter(number -> number % 2 == 0)
               .mapToInt(number -> number * number)
               .sum();
        long endTimeParallel = System.currentTimeMillis();

        System.out.println("Sequential sum: " + sumSequential + ", Time taken: " + (endTimeSequential - startTimeSequential) + " ms");
        System.out.println("Parallel sum: " + sumParallel + ", Time taken: " + (endTimeParallel - startTimeParallel) + " ms");
    }
}

在上述代码中,我们生成了一个包含 1000 万个随机整数的列表,分别使用顺序 Stream 和并行 Stream 计算偶数的平方和,并记录计算时间。在多核 CPU 的环境下,并行 Stream 的计算时间通常会比顺序 Stream 短很多,这体现了并行处理在大数据集处理上的性能优势。

实际应用场景分析

数据处理与分析

  1. 日志分析 在日志分析场景中,通常需要从大量的日志记录中筛选出特定条件的记录,并进行进一步的分析。例如,从一个日志文件中读取所有包含 “ERROR” 关键字的日志记录,并统计这些记录中每个错误代码出现的次数。使用 Java Stream 可以如下实现:
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;

public class LogAnalysisExample {
    public static void main(String[] args) {
        String filePath = "path/to/your/logfile.log";
        try (BufferedReader br = new BufferedReader(new FileReader(filePath))) {
            Map<String, Long> errorCodeCount = br.lines()
                   .filter(line -> line.contains("ERROR"))
                   .map(line -> line.split(" ")[2]) // 假设错误代码在日志行的第三个单词
                   .collect(Collectors.groupingBy(code -> code, Collectors.counting()));

            errorCodeCount.forEach((code, count) -> System.out.println("Error code " + code + " appears " + count + " times"));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

在这段代码中,br.lines() 将日志文件按行读取为 Stream,filter(line -> line.contains("ERROR")) 筛选出包含 “ERROR” 的日志行,map(line -> line.split(" ")[2]) 提取出错误代码,最后 collect(Collectors.groupingBy(code -> code, Collectors.counting())) 统计每个错误代码出现的次数。通过 Stream 的操作,将日志读取、筛选、提取和统计的逻辑清晰地分离,使得代码易于理解和维护。

  1. 销售数据分析 假设有一个销售数据列表,每个销售记录包含产品名称、销售数量和销售金额。我们需要分析出销售数量大于 100 的产品及其总销售金额。使用 Java Stream 可以这样实现:
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

class SaleRecord {
    private String productName;
    private int quantity;
    private double amount;

    public SaleRecord(String productName, int quantity, double amount) {
        this.productName = productName;
        this.quantity = quantity;
        this.amount = amount;
    }

    public String getProductName() {
        return productName;
    }

    public int getQuantity() {
        return quantity;
    }

    public double getAmount() {
        return amount;
    }
}

public class SalesDataAnalysisExample {
    public static void main(String[] args) {
        List<SaleRecord> salesRecords = new ArrayList<>();
        salesRecords.add(new SaleRecord("ProductA", 80, 800.0));
        salesRecords.add(new SaleRecord("ProductB", 120, 1200.0));
        salesRecords.add(new SaleRecord("ProductC", 90, 900.0));
        salesRecords.add(new SaleRecord("ProductD", 150, 1500.0));

        Map<String, Double> result = salesRecords.stream()
               .filter(record -> record.getQuantity() > 100)
               .collect(Collectors.toMap(SaleRecord::getProductName, SaleRecord::getAmount, (a, b) -> a + b));

        result.forEach((product, amount) -> System.out.println(product + " total amount: " + amount));
    }
}

在上述代码中,filter(record -> record.getQuantity() > 100) 筛选出销售数量大于 100 的记录,collect(Collectors.toMap(SaleRecord::getProductName, SaleRecord::getAmount, (a, b) -> a + b)) 将符合条件的产品名称和销售金额收集到一个 Map 中,并对相同产品的金额进行累加。Stream 的使用使得销售数据分析的逻辑清晰明了,易于扩展和维护。

集合数据转换

  1. 对象属性转换 假设有一个员工列表,每个员工对象包含姓名和年龄。现在需要将这个员工列表转换为一个新的列表,新列表中每个元素是员工姓名和年龄的字符串组合,格式为 “姓名 - 年龄”。使用 Java Stream 实现如下:
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

class Employee {
    private String name;
    private int age;

    public Employee(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }
}

public class ObjectPropertyTransformationExample {
    public static void main(String[] args) {
        List<Employee> employees = new ArrayList<>();
        employees.add(new Employee("Alice", 25));
        employees.add(new Employee("Bob", 30));
        employees.add(new Employee("Charlie", 35));

        List<String> transformedList = employees.stream()
               .map(employee -> employee.getName() + " - " + employee.getAge())
               .collect(Collectors.toList());

        transformedList.forEach(System.out::println);
    }
}

在这段代码中,map(employee -> employee.getName() + " - " + employee.getAge()) 将员工对象转换为指定格式的字符串,collect(Collectors.toList()) 将转换后的字符串收集到一个新的列表中。通过 Stream 的 map 操作,将对象属性转换的逻辑清晰地与集合遍历分离,使得代码简洁易懂。

  1. 数据格式转换 假设我们有一个整数列表,需要将其转换为十六进制字符串列表。使用 Java Stream 可以这样做:
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

public class DataFormatTransformationExample {
    public static void main(String[] args) {
        List<Integer> numbers = new ArrayList<>();
        numbers.add(10);
        numbers.add(20);
        numbers.add(30);

        List<String> hexStrings = numbers.stream()
               .map(Integer::toHexString)
               .collect(Collectors.toList());

        hexStrings.forEach(System.out::println);
    }
}

在上述代码中,map(Integer::toHexString) 将每个整数转换为十六进制字符串,collect(Collectors.toList()) 收集这些字符串形成新的列表。Stream 的操作使得数据格式转换的逻辑独立出来,代码结构清晰,易于理解和维护。

总结 Stream 逻辑解耦优势在项目中的价值

  1. 维护成本降低 在大型项目中,业务逻辑复杂且经常变化。使用 Java Stream 的逻辑解耦优势,使得不同的业务逻辑通过独立的操作方法实现,当业务需求变更时,只需要修改相应的操作方法,而不会影响到其他无关的逻辑。这大大降低了代码的维护成本,减少了因修改代码而引入新 bug 的风险。例如,在上述的销售数据分析场景中,如果后续需要修改筛选条件或者统计方式,只需要在 Stream 的 filtercollect 操作中进行修改,而不会对其他部分的代码造成影响。

  2. 开发效率提升 Stream 的代码复用性使得开发者可以将常用的操作封装成方法,在不同的地方复用。同时,其简洁明了的语法和逻辑解耦的特性,使得代码编写更加高效。开发者可以更专注于业务逻辑的实现,而不需要花费大量时间在数据遍历和逻辑组织上。例如,在日志分析和对象属性转换等场景中,通过 Stream 的简洁操作,可以快速实现复杂的业务需求,提高开发效率。

  3. 性能优化 在处理大数据集时,Stream 的并行处理能力可以显著提高计算效率。通过简单地将顺序 Stream 转换为并行 Stream,就可以利用多核 CPU 的优势进行并行计算。这在实际项目中对于提高系统性能和响应速度具有重要意义。例如,在处理大量销售数据或者日志数据时,并行 Stream 可以大大缩短数据处理时间,提升系统的整体性能。

综上所述,Java Stream 在逻辑解耦方面相对于传统 foreach 循环具有明显的优势,这些优势在实际项目开发中能够带来诸多价值,值得开发者广泛应用。