7.6 将列表减少为单个值
将一个值的列表缩减为一个单一的值是一个常见的操作,但我们到目前为止还没有讨论过。在FP术语中,这种操作被称为fold或reduce,这些是你在大多数语言或库以及FP文献中都会遇到的名称。从字面上看,LINQ使用了一个不同的名字:Aggregate。 如果你已经熟悉了Aggregate,你可以跳过下一小节。
7.6.1 LINQ 的Aggregate方法
请注意,到目前为止,我们用IEnumerable使用的大多数函数也会返回一个IEnumerable。 例如,Map接收一个n个事物的列表,并返回另一个n个事物的列表,可能是不同的类型。 Where和Bind也在抽象范围内;也就是说,它们接收一个IEnumerable并返回一个IEnumerable,尽管列表的大小或元素的类型可能不同。
Aggregate与这些函数不同,它接收一个包含n个事物的列表,并准确地返回一个事物(就像你可能熟悉的SQL聚合函数COUNT、SUM和AVERAGE)。
给定一个 IEnumerable,Aggregate 接受一个名为 accumulator 的初始值和一个 reducer 函数——一个二元函数接受accumulator和列表中的一个元素,并返回accumulator的新值。 Aggregate 然后遍历列表,将函数应用于累加器的当前值和列表中的每个元素。
例如,您可以列出柠檬并将其聚合成一杯柠檬汁。累加器将是一个空玻璃杯,如果柠檬列表是空的,这就是你得到的。 reducer 函数接受一个玻璃杯和一个柠檬,并返回一个挤满柠檬的玻璃杯。考虑到这些参数,Aggregate 遍历列表,将每个柠檬挤入玻璃杯中,最后将所有柠檬汁都归还给玻璃杯。
Aggregate的签名是
(IEnumerable< T >, Acc, ((Acc, T) -> Acc)) -> Acc
图 7.3 以图形方式显示了它。如果列表为空,Aggregate 只返回给定的累加器 acc。如果它包含一项,t0,则返回将f加到t0上的结果;我们称这个值为 acc1。如果它包含更多项,它将计算acc1,然后将f应用于acc1和t1以获得acc2,依此类推,最终返回accN作为结果。 Acc 可以看作是一个初始值,使用给定的函数在其上应用列表中的所有值。
Sum 函数(在 LINQ 中单独可用)是 Aggregate 的一个特例。空列表中所有数字的总和是多少?自然是0!这就是我们的累加器值。二元函数只是加法,因此我们可以将 Sum 表示如下。
清单 7.16 Sum 作为 Aggregate 的特例
Range(1, 5).Aggregate(0, (acc, i) => acc + i) // => 15
请注意,这将扩展为以下内容:
((((0 + 1) + 2) + 3) + 4) + 5
更一般地说,ts.Aggregate(acc,f)扩展为
f(f(f(f(acc, t0), t1), t2), ... tn)
Count 也可以看作是 Aggregate 的一个特例:
Range(1, 5).Aggregate(0, (count, _) => count + 1) // => 5
请注意,累加器的类型不一定是列表项的类型。例如,假设我们有一个事物的列表,我们想把它们添加到Tree。我们列表中的类型是,比如说,T,而累加器的类型是Tree。我们可以用一个空的树作为累加器开始,然后在遍历列表的过程中添加每一个项目。
清单7.17 使用Aggregate创建一个列表中所有项目的树状图
Range(1, 5).Aggregate(Tree<int>.Empty, (tree, i) => tree.Insert(i))
在这个例子中,我假设tree.Insert(i)返回一个带有新插入的值的树。
Aggregate 是一种非常强大的方法,它可以根据 Aggregate 实现 Map、Where 和 Bind——我建议将其作为练习。
还有一个不太通用的重载,它不接受累加器参数,但使用列表的第一个元素作为累加器。此重载的签名是
(IEnumerable< T >, ((T, T) -> T)) -> T
使用此重载时,结果类型与列表中元素的类型相同,列表不能为空。
7.6.2 Aggregating 验证结果
既然您知道如何将值列表缩减为单个值,让我们应用这些知识,看看我们如何将验证器列表“缩减”为单个验证器。为此,我们需要实现一个类型为
IEnumerable<Validator< T >> -> Validator< T >
请注意,因为 Validator 本身是一个函数类型,所以前面的类型扩展为:
IEnumerable< T -> Validation< T >> -> T -> Validation< T >
首先,我们需要决定我们希望组合验证如何工作:
- 快速失败——如果验证应该优化效率,一旦一个验证器失败,组合验证就应该失败,从而最大限度地减少资源的使用。如果您正在验证从应用程序以编程方式发出的请求,这是一种很好的方法。
- 收集错误——您可能希望识别所有被违反的规则,以便在发出另一个请求之前修复它们。在验证用户通过表单发出的请求时,这是一种更好的方法。
失败快速策略更容易实现:每个验证器都会返回一个Validation,而Validation暴露了一个Bind函数,只有在状态为Valid时才会应用绑定的函数(就像Option和Either),所以我们可以使用Aggregate来遍历验证器列表,并将每个验证器与运行结果绑定。
清单 7.18 使用 Aggregate 和 Bind 在序列中应用所有验证
public static Validator < T > FailFast < T >
(IEnumerable < Validator < T >> validators)
=> t
=> validators.Aggregate(Valid(t),
(acc, validator) => acc.Bind(_ => validator(t)));
请注意,FailFast函数接收一个Validators列表,并返回一个Validator:一个期望对T类型的对象进行验证的函数。在接收到验证对象t后,它使用Valid(t)作为累加器遍历验证器列表(也就是说,如果验证器列表为空,那么t就是有效的),并将列表中的每个验证器用Bind应用到累加器上。
从概念上讲,对 Aggregate 的调用展开如下:
Valid(t)
.Bind(validators[0]))
.Bind(validators[1]))
...
.Bind(validators[n - 1]));
由于Bind是为Validation定义的,只要一个验证器失败了,后面的验证器就会被跳过,而整个验证就会失败。
并非所有的验证都同样昂贵。例如,用正则表达式来验证BIC码是否正确(如列表6.7所示)是非常便宜的。假设你还需要确保给定的BIC代码是一个现有的银行分行。这可能涉及到DB查询或远程调用一个具有有效代码列表的服务,这显然更昂贵。
为确保整体验证有效,您需要对验证器列表进行相应排序。在这种情况下,您需要先应用(便宜的)正则表达式验证,然后再应用(昂贵的)远程查找。
7.6.3 收获验证错误
相反的方法是优先考虑完整性;也就是说,要包括所有失败的验证的细节。在这种情况下,你不希望失败阻止进一步的计算;相反,你想确保所有的验证器都运行,并且所有的错误(如果有的话)都被收集了。
例如,如果您正在验证具有大量字段的表单,并且您希望用户看到他们需要修复的所有内容以进行有效提交,那么这很有用。
让我们看看如何重写结合不同验证器的方法。
清单 7.19 从所有失败的验证器中收集错误
public static Validator < T > HarvestErrors < T >
(IEnumerable < Validator < T >> validators)
=> t =>
{
var errors = validators
.Map(validate => validate(t)) //独立运行所有验证器
.Bind(v => v.Match(
Invalid: errs => Some(errs), //收集验证错误
Valid: _ => None)).ToList(); //无视通过的验证
return errors.Count == 0 //如果没有错误,则整体验证通过。
? Valid(t)
: Invalid(errors.Flatten());
};
在这里,我们没有使用Aggregate,而是使用Map将验证器的列表映射到要验证的对象上运行验证器的结果。这确保了所有的验证器都被独立调用,最后我们得到一个验证器的IEnumerable。
然后我们对收获所有的错误感兴趣。 要做到这一点,我们使用 Option:我们将 Invalids 映射到一个包裹错误的 Some,而 Valids 映射到 None。 还记得第4章吗,Bind可以用来从一个Options列表中过滤出Nones,这就是我们在这里要做的,以获得一个所有错误的列表。因为每个Invalid都包含了一个错误列表,所以errors实际上是一个列表的列表。在失败的情况下,我们需要把它平铺成一个一维的列表,用它来填充一个Invalid。如果没有错误,则返回有效并输入有效。
总结
- 部分应用意味着为函数提供零散的参数,有效地为每个给定的参数创建一个更专业的函数。
- 柯里化意味着更改函数的签名,以便一次接受一个参数。
- 部分应用程序使您能够通过参数化它们的行为来编写高度通用的函数,然后提供参数以获得越来越专业化的函数。
- 参数的顺序很重要:首先给出最左边的参数,以便函数应该从一般到特定声明它的参数。
- 在 C# 中使用多参数函数时,方法解析可能会出现问题并导致语法开销。这可以通过依靠 Funcs 而不是方法来克服。
- 您可以通过将函数声明为参数来注入函数所需的依赖项。这允许您完全由函数组成您的应用程序,而不会影响关注点分离、解耦和可测试性。