使用多参数函数有效地进行排序
本章包括
- 使用具有提升类型的多参数函数
- 在任何 monadic 类型中使用 LINQ 语法
- 基于属性的测试的基本原理
本章的主要目的是教你在有效类型的世界中使用多参数函数,所以标题中的 "有效 "也是一个双关语。请记住,有效类型是指诸如Option(增加了可选性的效果)、Exceptional(异常处理)、IEnumerable(聚合)和其他类型。 在第三部分,你会看到更多与状态、, laziness 和异步相关的效果。
随着你的代码功能的增加,你将会非常依赖这些效果。你可能已经经常使用IEnumerable了。 如果你接受这样一个事实,即像Option和Either的一些变体为你的程序增加了健壮性,你很快就会在你的大部分代码中使用提升的类型。
尽管你已经看到了像Map和Bind这样的核心函数的威力,但还有一个重要的技术你还没有看到:鉴于Map和Bind都是采取单项函数,如何在你的工作流中整合多参数函数。
事实证明,有两种可能的方法:应用性方法和单体性方法。 我们首先看一下应用性方法,它使用Apply函数–一种你还没有看到的模式。然后我们将重新审视单体,你将看到如何在多参数函数中使用Bind,以及LINQ语法在这一领域是如何的有用。然后我们将比较这两种方法,看看为什么这两种方法在不同的情况下都是有用的。
在此过程中,我还会介绍一些与单体和应用工具有关的理论,并介绍一种名为基于属性的测试的单元测试技术。
8.1 提升世界的函数应用
在这一节中,我将介绍应用性方法,它依赖于对一个新函数Apply的定义,该函数在提升的世界中执行函数应用。Apply,像Map和Bind一样,是FP的核心函数之一。
为了预热,启动REPL,像往常一样导入LaYumba.Functional库,并输入以下内容:
Func<int, int> @double = i => i * 2;
Some(3).Map(@double) // => Some(6)
到目前为止,没有什么新的东西:你有一个用 Option 包裹的数字,你可以用 Map 对它应用二进制函数 @double。现在,假设你有一个类似于乘法的二进制函数,并且你有两个分别被包裹在一个 Option 中的数字。你如何将这个函数应用于它的参数?
这是关键概念:柯里化(第 7 章已介绍)允许您将任何 n 元函数转换为一元函数,当给定其参数时,将返回 (n-1) 元函数。这意味着您可以将 Map 与任何函数一起使用,只要它是柯里化的!让我们在实践中看到这一点。
清单 8.1 将柯里化函数映射到Option
Func<int, Func<int, int>> multiply = x => y => x * y;
var multBy3 = Some(3).Map(multiply);
// => Some(y => 3 * y))
请记住,当您将函数映射到Option时,映射“提取”Option中的值并将给定的函数应用于它。在前面的清单中,Map 将从 Option 中提取值 3 并将其提供给乘法函数:3 将替换变量 x,产生函数 y => 3 * y
我们来看看类型:
multiply : int -> int -> int
Some(3) : Option< int >
Some(3).Map(multiply) : Option< int -> int>
因此,当您映射多参数函数时,该函数会部分应用于包装在 Option 中的参数。让我们从更一般的角度来看这个。 这是functor F 的 Map 签名:
Map : F< T > -> (T -> R) -> F< R >
现在假设 R 的类型恰好是 T1 -> T2,所以 R 实际上是一个函数。在这种情况下,签名扩展为
F< T > -> (T -> T1 -> T2) -> F< T1 -> T2 >
但是看看第二个参数:T -> T1 -> T2——这是一个柯里化形式的二元函数。这意味着您真的可以将 Map 与任何数量的函数一起使用!为了让调用者不必柯里化函数,我的函数库包含了 Map 的重载,它接受各种参数的函数并处理柯里化;例如:
public static Option<Func<T2, R>> Map<T1, T2, R>
(this Option<T1> opt, Func<T1, T2, R> func)
=> opt.Map(func.Curry());
因此,以下代码也有效。
清单 8.2 将二元函数映射到Option
Func<int, int, int> multiply = (x, y) => x * y;
var multBy3 = Some(3).Map(multiply);
// => Some(y => 3 * y))
现在您知道可以有效地将 Map 与多参数函数一起使用,让我们看看结果值。这是你以前从未见过的:提升的函数——封装在提升类型中的函数,如图 8.1 所示。
提升的函数没有什么特别之处。 函数只是值,所以它只是另一个被包裹在常用容器中的值。
然而,你如何处理一个作为函数的提升值呢?现在你有了一个用 Option 包裹的一元函数,你如何提供它的第二个参数? 如果第二个参数也是用一个 Option 包裹的呢? 一个粗略的方法是明确地解开两个值,然后将函数应用到参数上,像这样:
Func < int, int, int > multiply = (x, y) => x * y;
Option < int > optX = Some(3), optY = Some(4);
var result = optX.Map(multiply).Match(
() => None,
(f) => optY.Match(
() => None,
(y) => Some(f(y))));
result // => Some(12)
这段代码并不好:它离开了 Option 的提升世界来应用函数,只是为了将结果提升回一个 Option。是否有可能在不离开提升的世界的情况下将其抽象化并在工作流中集成多参数功能?这确实是 Apply 函数所做的,我们接下来会看看它。
8.1.1 理解应用性术语
在我们研究为升高值定义Apply之前,让我们简单回顾一下我们在第7章中定义的Apply函数,它在常规值的世界中执行部分应用。我们为Apply定义了各种重载,这些重载接受一个n次方函数和一个参数,并返回将该函数应用于参数的结果。这些签名的形式是
Apply : (T -> R) -> T -> R
Apply : (T1 -> T2 -> R) -> T1 v (T2 -> R)
Apply : (T1 -> T2 -> T3 -> R) -> T1 -> (T2 -> T3 -> R)
这些签名说,“给我一个函数和一个值,我会给你将该函数应用于该值的结果”,无论是函数的返回值,还是部分应用的函数。
在提升的世界中,我们需要定义Apply的重载,其中输入和输出的值被包裹在提升的类型中。一般来说,对于任何可以定义Apply的functor A来说,Apply的签名都是这样的
Apply : A< T -> R > -> A< T > -> A< R >
Apply : A< T1 -> T2 -> R > -> A< T1 > -> A< T2 -> R >
Apply : A< T1 -> T2 -> T3 -> R > -> A< T1 > -> A< T2 -> T3 -> R >
它就像普通的Apply,但在升高的世界中:“给我一个用A包裹的函数,和一个用A包裹的值,我会给你将函数应用于值的结果,当然也是用A包裹的。” 这在图8.2中得到了说明。
Apply的实现必须解开函数,解开值,将函数应用于值,并将结果包起来。当Apply的一个合适的实现被定义为一个 functor A时,它被称为一个应用性 functor,或者简单地称为一个应用性。
让我们看看 Apply 是如何为 Option 定义的,使它成为一个 applicative。
清单 8.3 Apply for Option 的实现
public static Option < R > Apply < T, R >
(this Option < Func < T, R >> optF, Option < T > optT)
=> optF.Match(
() => None,
(f) => optT.Match(
() => None,
(t) => Some(f(t)))); // 如果两个选项都是 Some,则仅将包装函数应用于包装值
public static Option < Func < T2, R >> Apply < T1, T2, R >
(this Option < Func < T1, T2, R >> optF, Option < T1 > optT)
=> Apply(optF.Map(F.Curry), optT); //柯里化封装的函数并使用带有 Option 封装一元函数的重载
第一个重载是很重要的一个。 它接收一个用Option包装的一元函数和一个同样用Option包装的函数的参数。只有当两个输入都是Some时,该实现才会返回Some,而在其他情况下都是None。
像往常一样,包装函数的各种算数都需要重载,但这些都可以用一元版本来定义,正如第二个重载所展示的。
现在处理了包装和解包的低级细节,让我们看看如何将 Apply 与二元函数一起使用:
Func < int, int, int > multiply = (x, y) => x * y;
Some(3).Map(multiply).Apply(Some(4));
// => Some(12)
Some(3).Map(multiply).Apply(None);
// => None
简而言之,如果您有一个包装在容器中的函数,Apply 允许您为其提供参数。让我们将这个想法更进一步。
8.1.2 提升函数
在到目前为止的示例中,您已经看到通过将多参数函数映射到提升的值,将函数“提升”到容器中,如下所示:
Some(3).Map(multiply)
或者,您可以通过简单地使用容器的返回函数将函数提升到容器中,就像使用任何其他值一样。毕竟,被包装的函数并不关心它是如何到达那里的。所以你可以这样写:
Some(multiply) // 将函数提升为 Option
.Apply(Some(3)) // 使用 Apply 提供参数
.Apply(Some(4)) // 使用 Apply 提供参数
// => Some(12)
这可以被推广到任何元的函数。而且,像往常一样,你可以得到Option的安全性,因此,如果沿途的任何值都是None,最终的结果也是None。
正如你所看到的,在提升的世界中,有两种截然不同但又相当的方法来评估二元函数。你可以在下面的列表中看到这两种方式并排的情况。
清单 8.4 两种等价方式在提升世界中实现函数应用
第二种方式是先用Return提升函数,然后再应用参数,这种方式更易读也更直观,因为它类似于正则值世界中的部分应用,如列表8.5所示。
清单 8.5 在常规值和提升值的世界中的部分应用
无论你是通过使用Map获得函数,还是用Return提升函数,就所产生的函数器而言都不重要。这是一个要求,如果正确地实现了应用性,它就会成立,所以它有时被称为应用性法则。
8.1.3 基于属性的测试介绍
我们能不能写一些单元测试来证明我们用来处理 Option 的函数满足应用法则?对于这种测试,有一种特殊的技术,即测试一个实现是否满足某些规律或属性。 它被称为基于属性的测试,一个叫做FsCheck的支持框架可以在.NET中进行基于属性的测试。
基于属性的测试是参数化的单元测试,其断言对任何可能的参数值都是成立的。也就是说,你写一个参数化的测试,然后让一个框架,如FsCheck,用一大组随机生成的参数值重复运行测试。
通过例子来理解这一点是最容易的。下面的列表显示了适用法的属性测试可能是什么样子。
清单 8.6 一个基于属性的测试,说明了应用法则
using FsCheck.Xunit;
using Xunit;
// ...
Func < int, int, int > multiply = (i, j) => i * j;
[Property] //标记基于属性的测试
void ApplicativeLawHolds(int a, int b) { //FsCheck 将随机生成一大组输入值来运行测试。
var first = Some(multiply)
.Apply(Some(a))
.Apply(Some(b));
var second = Some(a)
.Map(multiply)
.Apply(Some(b));
Assert.Equal(first, second);
}
如果你看一下这个测试方法的签名,你会发现它是用两个int值作为参数的。但是与你在第二章中看到的参数化测试不同,这里我们没有为参数提供任何值。 相反,我们只是用FsCheck.Xunit中定义的属性来装饰测试方法。当你运行你的测试时,FsCheck将随机生成大量的输入值,并使用这些值运行测试。这使你不必想出样本输入,并给你更好的信心,使边缘情况被覆盖。
这个测试通过了,但是我们把整数作为参数并把它们提升到了选项中,所以它只说明了选项在Some状态下的行为。 我们还应该测试在无状态下会发生什么。我们的测试方法的签名实际上应该是
void ApplicativeLawHolds(Option<int> a, Option<int> b)
也就是说,我们也希望FsCheck能随机生成Some或None状态的Options,并将其送入测试。
如果我们尝试运行这个,FsCheck会抱怨说它不知道如何随机生成一个Option< int >。幸运的是,我们可以教FsCheck如何做这件事。
清单 8.7 教 FsCheck 创建任意Option
static class ArbitraryOption {
public static Arbitrary < Option < T >> Option < T > () {
var gen = from isSome in Arb.Generate < bool > ()
from val in Arb.Generate < T > ()
select isSome && val != null ? Some(val) : None;
return gen.ToArbitrary();
}
}
FsCheck知道如何生成诸如bool和int这样的原始类型,所以生成一个Option< int >应该很容易:生成一个随机的bool和一个随机的int;如果bool是假的,返回None,否则将生成的int包成一个Some。这就是上面前面代码的基本含义–在这一点上不要担心具体的细节。
现在我们只需要指示FsCheck在需要一个随机的Option< T >时查看ArbitraryOption类。
清单 8.8 基于属性的测试,用任意的Option来设置参数
[Property(Arbitrary = new [] { typeof (ArbitraryOption) })]
void ApplicativeLawHolds(Option < int > a, Option < int > b)
=> Assert.Equal(
Some(multiply).Apply(a).Apply(b),
a.Map(multiply).Apply(b)
);
当然,FsCheck现在能够随机地生成这个测试的输入,它通过了,并且很好地说明了应用法则。这是否证明我们的实现总是满足应用法则? 不完全是,因为它只测试了乘法函数的属性是否成立,而这个定律应该对任何函数都成立。不幸的是,与数字和其他数值不同,我们不可能随机地生成一组有意义的函数。但是这种基于属性的测试仍然给了我们很好的信心–肯定比单元测试,甚至是参数化的测试好。
基于真实世界的属性测试 基于属性的测试不仅仅是理论上的东西,而且可以有效地应用于LOB应用。只要你有一个不变量,你就可以写属性测试来捕获它。这里有一个非常简单的例子:如果你有一个随机填充的购物车,并且你从其中移除随机数量的物品,那么修改后的购物车的总数必须总是小于或等于原始购物车的总数。你可以从这种简单的属性开始,然后添加其他属性,直到抓住你的模型的本质。
现在我们已经介绍了Apply函数的机制,让我们把applicatives和我们之前讨论过的其他模式进行比较。一旦完成了这些,我们将通过一个更具体的例子来看看Applicatives是如何运作的,以及它们与 monads 的比较。