欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

减少 Java 8 的学习指南

最编程 2024-03-15 20:15:40
...

简介

reduce() 方法是Java 8对Stream API中的折叠实现需求的回应。

折叠是一个非常有用和常见的函数式编程特性。它对一个元素的集合进行操作,使用某种操作返回一个单一的结果。

注意: 折叠也被称为减少、聚集、累积和压缩,这些术语都适用于同一个概念。

也就是说--它是最具延展性、灵活性和适用性的操作之一--它非常常用于计算集合的聚合结果,并在分析和数据驱动的应用中以这样或那样的形式广泛采用。reduce() 操作为Stream API配备了类似的折叠能力。

因此,如果你有一些int ,比如说[11, 22, 33, 44, 55] ,你可以使用reduce() ,找到它们的总和,以及其他结果。

在函数式编程中,寻找这些数字的总和将应用这样的步骤。

0 + 11 = 11
11 + 22 = 33
33 + 33 = 66
66 + 44 = 110
110 + 55 = 165

使用reduce() 的方法,可以达到这样的效果。

int[] values = new int[]{11, 22, 33, 44, 55};

IntStream stream = Arrays.stream(values);
int sum = stream.reduce(0, (left, right) -> left + right);

sum

165

reduce() 是很直接的。例如,如果你看一下函数式例程,你可以把+ 运算符左边的所有数值称为left ;而右边的数值称为right 。然后,在每次求和操作之后,结果就成为下一次求和的新left

同样,Java的reduce() 方法所做的与函数例程完全一样。它甚至包括一个起始值,0 ,而函数例程也有这个起始值。

从操作上看,reduce() 方法将一个left 的值加到下一个right 的值上。然后,它把这个值加到下一个right...以此类推。

你甚至可以把reduce() 实现对这些值的折叠想象成。

((((0 + 11) + 22) + 33) + 44) + 55 = 165

不过,Stream API并不像上面的例子那样只提供reduce() 的折叠功能。

它全力以赴地将其功能接口包含在三个reduce() 方法的实现中。正如你将在随后的章节中看到的那样,API提供的reduce() ,其口味如下。

T reduce(T identity, BinaryOperator<T> accumulator)

这个版本是我们之前使用的。其中,0identity ;而(left, right) -> left + right) 是实现了BinaryOperator 功能接口的accumulator

还有

Optional<T> reduce(BinaryOperator<T> accumulator)

还有

<U> U reduce(U identity,
             BiFunction<U,? super T,U> accumulator,
             BinaryOperator<U> combiner)

**注意:**Stream API的sum()average()max()min() 操作是还原变化,等同于调用。

// Equivalent to stream.sum()
stream.reduce(0, Integer::sum);
// Equivalent to stream.max()
stream.reduce(0, Integer::max);
// Equivalent to stream.min()
stream.reduce(0, Integer::min);

在接下来的章节中,我们将深入研究reduce() 方法、它的变体、用例和良好实践,让你对底层机制有更深的理解和体会。

reduce()的种类和例子

Stream API提供了三种reduce() 操作的变体。让我们来看看它们各自的定义和实际用法。

1.reduce(),其结果与流元素的类型相同

方法签名

T reduce(T identity, BinaryOperator<T> accumulator)

使用所提供的标识值和关联累加函数,对这个流的元素进行还原,并返回还原后的值。

现在,我们知道了这种类型的reduce() 的操作方式。但是,在使用这种reduce() 类型时,有一个小问题你应该小心。(实际上,对于任何减少操作)。

你的reduce() 实现的关联性

当你使用reduce() ,你应该为你的例程也提供在并行设置中运行的可能性。还原操作不受限制,可以按顺序执行。

为此,关联性是至关重要的,因为它将使你的累加器产生正确的结果,无论流元素的相遇顺序如何。如果关联性在这里不成立,累加器就不可靠了。

案例:比如,你有三个int 值,[8, 5, 4]

关联性要求以任何顺序对这些值进行操作都应该产生匹配的结果。比如说。

(8 + 5) + 6 == 8 + (5 + 6)

另外,当并行化发生时,累积可能以更小的单位处理这些值。例如,拿一个包含数值[7, 3, 5, 1] 的流来说。一个并行的流可能会使累加以这样的方式工作。

7 + 3 + 5 + 1 == (7 + 3) + (5 + 1)

但是,这些要求有效地禁止你用reduce() 方法进行某些类型的操作。例如,你不能用reduce() 进行减法运算。这是因为它违反了关联性原则。

你看,如果你使用前面一个例子中的数值。[8, 5, 4].然后试图用reduce() 来求出它们的累积之差。

结果会是这样的

(8 - 5) - 6 != 8 - (5 - 6)

否则,身份参数是另一个需要注意的因素。选择一个身份值,i ,这样:对于流中的每个元素e ,对其进行操作应该总是返回 。 *op*应该总是返回e

这意味着什么?

e op identity = e

在加法的情况下,身份是0 。在乘法的情况下,身份是1 (因为与0的乘法总是0,不是e)。在字符串的情况下,身份是String ,等等。

这种操作在Java中的功能是:

IntStream intStream = IntStream.of(11, 22, 33, 44, 55);
Stream stringStream = Stream.of("Java", "Python", "JavaScript");

int sum = intStream.reduce(0, (left, right) -> left + right);
int max = intStream.reduce(0, Integer::max);
int min = intStream.reduce(0, Integer::min);

// Mapping elements to a stream of integers, thus the return type is the same type as the stream itself
int sumOfLengths = stringStream.mapToInt(String::length)
        .reduce(0, Integer::sum);

这些reduce() 的调用非常普遍,所以它们被更高层次的调用所取代--sum(),min(),max(), 你完全可以用这些调用来代替reduce() 的调用,不过请记住,它们被修改为返回Optional 的变体。

int sum = intStream.sum();
OptionalInt max = intStream.max();
OptionalInt min = intStream.min();

reduce() 的闪光点是在你想从任何序列中得到任何标量结果的情况下--比如将一个集合减少到一个具有最大长度的元素,这将导致一个Optional 。我们现在将看一下这个。

2.reduce(),其结果是一个可选项

方法签名

Optional<T> reduce(BinaryOperator<T> accumulator)

对这个流的元素进行缩减,使用一个关联的累积函数,并返回一个描述缩减值的Optional,如果有的话。

在操作上,这是使用reduce() 方法的最简单的方式。它只要求一个参数。一个BinaryOperator 实现,它将作为一个累加器。

所以,与其这样,不如这样。

int sum = stream
        .reduce(0, (left, right) -> left + right);

你只需要这样做(即省去身份值)。

Optional<Integer> sum = stream
        .reduce((left, right) -> left + right);

前者和后者的区别在于,在后者中,结果可能不包含任何值。

例如,当你传递一个空流进行评估时就会发生这种情况。然而,当你使用一个身份作为参数之一时,这种情况就不会发生,因为当你向它提供一个空流时,reduce() 会返回身份本身作为结果。

另一个例子是将集合减少到某些元素,比如将几个字符串创建的流减少到一个单一的元素。

List<String> langs = List.of("Java", "Python", "JavaScript");

Optional longest = langs.stream().reduce(
        (s1, s2) -> (s1.length() > s2.length()) ? s1 : s2);

这里发生了什么?我们正在流化一个列表并将其还原。对于每两个元素(s1, s2),它们的长度被比较,根据结果,使用三元操作符,返回s1s2

具有最大长度的元素将通过这些调用传播,缩减的结果是它被返回并打包成一个Optional ,如果这样的元素存在的话。

longest.ifPresent(System.out::println);  

这就导致了

JavaScript

3.reduce(),使用一个组合函数

方法签名

<U> U reduce(U identity, BiFunction<U,? super T,U> accumulator, BinaryOperator<U> combiner)

对这个流的元素进行还原,使用提供的身份、累积和组合函数。

虽然这个定义看起来很直接,但它隐藏了一个强大的能力。

这个reduce() 变体可以让你处理一个类型与流的元素不匹配的结果。

我们以前没有这样做过吗?并非如此。

int sumOfLengths = stringStream
    .mapToInt(String::length)
    .reduce(0, Integer::sum);

mapToInt() 方法返回一个IntStream ,所以即使我们一开始是一个字符串流 -reduce() 方法是在一个IntStream 上调用的,并返回一个整数,这就是流中元素的类型。

mapToInt() 是一个快速的黑客,它允许我们 "返回一个不同的类型",尽管它并没有真正返回一个不同的类型。

就拿你想计算一段话的累积长度的情况来说,或者像我们之前的那样计算单词的长度。

这表明你可能有一个String 的元素流。然而,你需要reduce() 操作的返回类型有一个int 的值来表示该段的长度。

这就是组合器开始发挥作用的地方。

String string = "Our Mathematical Universe: My Quest for the Ultimate Nature of Reality";
List<String> wordList = List.of(string.split(" "));

  int length = wordList
        .stream()
        .reduce(
                0,
                (parLength, word) -> parLength + word.length(),
                (parLength, otherParLength) -> parLength + otherParLength
        );

System.out.println(String.format("The sum length of all the words in the paragraph is %d", length));

这段代码将段落中所有字符串的长度相加,细分到每个空格(因此计算中不包括空格),结果是。

The sum length of all the words in the paragraph is 60

这个reduce() 变体值得注意的特点是,它很好地服务于并行化。

以本例中的累加器为例。

(parLength, word) -> parLength + word.length()

reduce() 操作会多次调用它,这是毫无疑问的。然而,在一个并行化的流中,管道中可能最终会有相当多的累加器。这就是组合器函数的作用。

本例中的组合器函数是

(parLength, otherParLength) -> parLength + otherParLength

它将可用的累加器的结果相加,产生最后的结果。

这使得reduce() ,将一个庞大的过程分解成许多更小的、可能更快的操作。这也让我们进入了下一个重要的话题--并行化。

在并行流中使用reduce()

你可以通过调用parallel() 方法将任何顺序流变成一个并行的。

同样,让我们考虑一个用例,即你想对给定范围内的所有int 值进行求和,以测试reduce() 是如何并行工作的。

有几种方法可以使用流API在给定的范围内生成一个int 值的序列

  1. 使用Stream.iterate
  2. 使用IntStream.rangeClosed

使用Stream.iterate()

private final int max = 1_000_000;
Stream<Integer> iterateStream = Stream.iterate(1, number -> number + 1).limit(max);

使用IntStream.rangeClosed()

IntStream rangeClosedStream = IntStream.rangeClosed(1, max);

那么,如果我们有这两种产生int 值流的方法,对于我们的用例来说,是否有一种方法比另一种方法更有效?

答案是响亮的

当你对它们应用reduce() 操作时,Stream.iterate() 的效率就不如IntStream.rangeClosed() 。我们很快就会看到原因。

当你使用这两种策略来寻找数字的总和时,你会写这样的代码。

Integer iterateSum = iterateStream
            .parallel()
            .reduce(0, (number1, number2) -> number1 + number2);
int rangeClosedSum = rangeClosedStream
            .parallel()
            .reduce(0, (number1, number2) -> number1 + number2);

诚然,这两种方式总是会产生匹配和正确的结果。

例如,如果你将变量max 设为1,000,000 ,你将从两种reduce() 方法中得到1,784,293,664

然而,计算iterateSum 要比rangeClosedSum 慢。

造成这种情况的原因是,Stream.iterate() 对其管道中遇到的所有数字值都进行了拆箱和装箱。例如,请注意,我们向它提供了int 的值,它的结果是返回一个Integer 对象。

IntStream.rangeClosed() 而GitHub的 "Single "则没有这个缺点,因为它直接处理了 ,甚至还返回了一个 的结果。int int

下面是GitHub上的一些测试,说明了这种现象。克隆该 repo 并运行这些测试,以进一步探索reduce()Stream.iterate()IntStream.rangeClosed() 中运行时的表现。

何时使用reduce()

reduce() 操作需要使用一个无状态和无干扰的累积器

这意味着,累加器最好是不可变的。而且,为了实现这一点,大多数累加器都会创建新的对象来保存下一次累加的值。

以一个案例为例,你想把几个元素的String 对象连接成一个String 对象。比如说,你想把几个词组成一个句子的地方。或者,甚至是通过连锁几个char 值来组成一个词。

官方文档提供了一个这样的例子

String concatenated = strings.reduce("", String::concat);

在这里,如果strings 流有大量的元素,reduce() 操作将创建非常多的字符串对象。

而且,根据strings 流的大小,由于所有正在进行的对象分配,性能将快速下降。

为了更清楚地了解这个操作是如何工作的,请考虑它的for 循环等效。然后,注意新的String 对象是如何在每个循环通道中实现的。

String concatenated = "";
for (String string : strings) {    
    concatenated += string;
}

然而,你可以尝试通过首先使用可变对象来补救在reduce() 操作中创建新对象的问题。

然而,请记住,如果你试图通过使用像List 这样的可变身份容器来弥补这一缺陷,我们就会将该容器暴露在ConcurrentModification 异常中。

以一个案例为例,你想把reduce() 一个int 的值流变成一个ListInteger 对象。你可以做这样的事情。

Stream<Integer> numbersStream = Arrays.asList(12, 13, 14, 15, 16, 17).stream();
List<Integer> numbersList = numbersStream.reduce(
        // Identity
        new ArrayList<>(),
        // Accumulator
        (list, number) -> {
            list.add(number);
            return list;
       },
        // Combiner
        (list1, list2) -> {
            list1.addAll(list2);
            return list1;
        }
);

这段代码会给你一个正确的结果

[12, 13, 14, 15, 16, 17]

但是,它要付出一定的代价。

首先,本例中的累加器干扰了身份识别。它引入了一个副作用,在作为身份的列表中增加了一个值。

然后,如果你碰巧把流,numbersStream ,变成一个并行的,你就会把列表累积暴露在并发的修改中。而且,这势必会使操作在某些时候抛出一个ConcurrentModification

因此,你的整个reduce() 操作可能完全失败。

将*reduce()*付诸实践

由于其功能性质,Stream API要求我们对如何设计Java代码进行全面的重新思考。它要求使用的方法能够符合诸如reduce() 等操作的功能接口的模式。

因此,我们将设计我们的代码,以便当我们对其调用reduce() 操作时,会产生简洁的代码。比如说,你可以用成员引用重写。

但是,首先,让我们探讨一下我们将用来测试reduce() 操作的用例。

  • 我们有一家杂货店,出售各种产品。例如,奶酪、西红柿和黄瓜。
  • 现在,每个产品都有属性,如名称、价格和单位重量。
  • 顾客通过交易从商店获得产品。

作为这样一家杂货店的经理,有一天你进来,问店员几个问题。

  • 你从所有的交易中赚了多少钱?
  • 卖出去的东西有多重?也就是说,你卖出的产品的累计重量是多少?
  • 顾客支付最多的交易的价值是多少?
  • 哪笔交易的价值最低(就其总价格价值而言)?

设计领域

我们将创建一个类Product ,以表示将那家杂货店将储存的物品。

public class Product {

    private final String name;
    private final Price price;
    private final Weight weight;

    public Product(String name, Price price, Weight weight) {
        this.name = name;
        this.price = price;
        this.weight = weight;
    }

    // Getters
}

请注意,我们将两个价值类作为Product 的字段,名为WeightPrice

然而,如果我们想天真地做,我们会让这两个字段的值为double

像这样

public Product(String name, double price, double weight) {    
    this.name = name;
    this.price = price;
    this.weight = weight;
}

这样做有一个绝对好的理由,你很快就会发现原因。否则,PriceWeight 都是对double 值的简单包装。

public class Price {
    private final double value;
    
    public Price(double value) {
        this.value = value;
    }  
          
    //Getters
 }
 
public class Weight {
    private final double value;
    
    public Weight(double value) {
        this.value = value;
    }
    
    // Getters
}

然后,我们有Transaction 类。这个类将包含一个Productint 值,该值代表了客户将购买的产品的数量。

Product 因此,Transaction 应该能够告知我们客户购买的PriceWeight 的总数。因此,它应该包括这样的方法。

public class Transaction {
    private final Product product;
    private final int quantity;
    
    public Transaction(Product product, int quantity) {
        this.product = product;
        this.quantity = quantity;
    }    
    
    //Getters ommited 
    
    public Price getTotalPrice() {
        return this.product.getPrice().getTotal(quantity);
    }    
    
    public Weight getTotalWeight() { 
        return this.product.getWeight().getTotal(quantity);
    }
}

请注意getTotalPrice()getTotalWeight() 方法是如何将其计算委托给PriceWeight 的。

这些委托是相当重要的,这也是我们使用类而不是简单的double 字段的原因。

它们表明,PriceWeight 应该能够对它们的类型进行累积。

请记住,reduce() 操作总是以BinaryOperator 作为其累加器。所以,这就是我们开始为我们的类预先建立累加器的关口。

因此,添加以下方法,作为PriceWeight 的累加器。

public class Price {
    // Fields, constructor, getters
    
    public Price add(Price otherPrice) {
        return new Price(value + otherPrice.getValue());
    }    
    
    public Price getTotal(int quantity) {
        return new Price(value * quantity);
    }
}

public class Weight {
    // Fields, constructor, getters

    public Weight add(Weight otherWeight) {
        return new Weight(value + otherWeight.getValue());
    }    
    
    public Weight getTotal(int quantity) { 
        return new Weight(value * quantity);
    }
}

reduce() 操作有一些变体,也需要身份参数。由于身份是一个计算的起点(可能是具有最低值的对象),我们应该继续创建PriceWeight 的身份版本。

你可以通过简单地将这些类的身份版本作为全局变量来做到这一点。因此,让我们把名为NIL 的字段添加到PriceWeight

public class Price {
    // Adding NIL
    public static final Price NIL = new Price(0.0);
    
    private final double value;
    public Price(double value) {
        this.value = value;
     }
}

public class Weight {
    // Adding NIL
    public static final Weight NIL = new Weight(0.0);  
     
    private final double value;
    public Weight(double value) {
        this.value = value;
    }
}

正如名字NIL 所示,这些字段代表PriceWeight ,它具有最小的值。完成这些后,现在是时候创建将进行交易的Grocery 对象了。

public class Grocery {
    public static void main(String[] args) {
        //Inventory
        Product orange = new Product("Orange", new Price(2.99), new Weight(2.0));
        Product apple = new Product("Apple", new Price(1.99), new Weight(3.0));
        Product tomato = new Product("Tomato", new Price(3.49), new Weight(4.0));
        Product cucumber = new Product("Cucumber", new Price(2.29), new Weight(1.0));
        Product cheese = new Product("Cheese", new Price(9.99), new Weight(1.0));
        Product beef = new Product("Beef", new Price(7.99), new Weight(10.0));
        
        //Transactions
        List<Transaction> transactions = Arrays.asList(
                new Transaction(orange, 14),
                new Transaction(apple, 12),
                new Transaction(tomato, 5),
                new Transaction(cucumber, 15),
                new Transaction(cheese, 8),
                new Transaction(beef, 6)
        );
    }
}

如代码所示,Grocery ,它的库存中有几个Product 对象。而且,发生了几个Transaction 事件。

不过,商店的经理还是要求提供一些关于交易的数据。因此,我们应该着手让reduce() ,以帮助我们回答这些询问。

从所有交易中赚到的钱

所有交易的总价格是所有交易的总价格相加的结果。

因此,我们首先将map() 所有的Transaction 元素变成它们的Price 值。

然后,我们将Price 元素减少为其价值的总和。

在这里,将累加器抽象为Price 对象本身,使代码具有高度可读性。同时,包含了Price.NIL 的身份,使reduce() 的操作尽可能具有可读性。

Price totalPrice = transactions.stream()
                .map(Transaction::getTotalPrice)
                .reduce(Price.NIL, Price::add);
                
System.out.printf("Total price of all transactions: %s\n", totalPrice);

运行该代码片段后,你应该期待的输出是。

Total price of all transactions: $245.40

也请注意,我们将打印价格值的工作委托给Print 对象的toString() 方法,以进一步简化调试。

使用toString() 方法为对象的价值提供人性化的描述,总是好的做法。

@Override
public String toString() {
    return String.format("$%.2f", value);
}
所有售出产品的总重量

与我们对Price 所做的类似,这里我们让Weight 负责对几个元素的值进行求和。

当然,我们需要map() 管道中的每个Transaction 元素到一个Weight 对象。

然后,我们给Weight 元素布置任务,让它们自己进行值的累加。

Weight totalWeight = transactions.stream()
                .map(Transaction::getTotalWeight)
                .reduce(Weight.NIL, Weight::add);

System.out.printf("Total weight of all sold products: %s\n", totalWeight);

在运行这个片段时,你应该有这样的输出。

Total weight of all sold products: 167.00 lbs
最高价值交易的价格

这个查询要求对Price 找到两个Price 元素之间的最小值或最大值的方式进行一些重新设计。

请记住,在前面的任务中,我们所做的只是在执行reduce() 时累积数值。然而,找到一个最小或最大的值则完全是另一回事。

我们在之前的累积中做了求和,而在这里我们必须从第一个Price 元素的值开始。然后我们将用另一个值来代替它,如果该值大于我们所拥有的值。因此,最后我们会得到最高值。这个逻辑也适用于你寻求最小值的时候。

因此,包括这个代码来计算你的最大和最小值的Price 元素。

public class Price {
    // Fields, getters, constructors, other methods
    
    public Price getMin(Price otherPrice){
        return new Price(Double.min(value, otherPrice.getValue()));
    }
    
    public Price getMax(Price otherPrice){
          return new Price(Double.max(value, otherPrice.getValue()));
    }
}

而当你在你的Grocery 对象计算中包括这些能力时,你将得到一个reduce() 操作,看起来像这样。

transactions.stream()
        .map(Transaction::getTotalPrice)
        .reduce(Price::getMax)
        .ifPresent(price -> System.out.printf("Highest transaction price: %s\n", price));

输出为

Highest transaction price: $79.92

还要注意的是,我们使用了reduce() 变体,它只需要一个参数:一个BinaryOperator 。我们的想法是:我们不需要一个身份参数,因为我们不需要这个操作的默认起点。

当你从一个元素的集合中寻求最大值时,你直接开始测试这些元素,而不涉及任何外部默认值。

最低值交易

继续我们在前面的任务中开始的趋势,我们把关于哪个是最低值事务的查询委托给Transaction 元素本身。

此外,因为我们需要一个包含整个Transaction 元素的细节的结果,我们把所有的查询都指向Transaction 元素流,而不把它们映射到任何其他类型。

不过,要使一个Transaction 元素以Price 来衡量它的价值,还是有一些工作要做的。

首先,你需要找到两个Transaction 对象的最小Price

然后,检查哪个Transaction 有这个最小的Price ,并返回它。

否则,你将通过使用一个例程,如这个getMin 方法来完成这个任务。

public class Transaction {
    // Fields, getters, constructors, other methods
    
    public Transaction getMin(Transaction otherTransaction) {
        Price min = this.getTotalPrice().getMin(otherTransaction.getTotalPrice());
        return min.equals(this.getTotalPrice()) ? this : otherTransaction;
    }
}

完成这些后,将该例程纳入到一个reduce() 的操作中就变得相当简单了,比如这个。

transactions.stream()
        .reduce(Transaction::getMin)
        .ifPresent(transaction -> {
                System.out.printf("Transaction with lowest value: %s\n", transaction);
        });

为了获得一个输出

Transaction with lowest value { Product: Tomato; price: $3.49 Qty: 5 lbs Total price: $17.45}

同样,当你完全利用toString() ,就可以获得这样的输出。使用它来生成尽可能多的信息,以便在你打印对象时使其价值对人友好。

结论

作为Java的普通折叠例程的实现,reduce() 是相当有效的。然而,正如我们所看到的,它要求你完全重新考虑如何设计你的类,以便能够充分地利用它。

但是请记住,如果你错误地使用reduce() ,会降低你的代码的性能。该操作在顺序流和并行流中都能工作。然而,当你把它用在巨大的流中时,会变得很棘手,因为reduce() 在可变的减少操作中并不高效。

例如,我们看到一个案例,你可以用reduce() 来串联String 的元素。记住String 对象是不可变的。因此,当我们使用reduce() 进行累加时,我们实际上在每次累加时都会创建非常多的String 对象。

然而,如果你试图通过使用像List 这样的可变身份容器来弥补这一缺陷,我们就会将该容器暴露在ConcurrentModification 异常中。

否则,我们已经探索了一个杂货店的交易的用例。我们为这个场景设计了代码,使每个积累都进行小而快的计算。

是的,对于我们用reduce() 调用的每一个积累,新的对象分配仍然存在。但是,我们已经使它们尽可能的简单。因此,当你将Transaction 流并行化时,我们的实现也能很好地工作。 。

上一篇: 11 减少的用途

下一篇: Linux 历史