4.3 使用 Bind 串联函数
绑定是另一个非常重要的函数,与Map类似,但稍微复杂一些。我将用一个简单的例子来介绍对Bind的需求。假设你想要一个简单的程序,从控制台中读取用户的年龄,并打印出一些相关的信息,你还想要错误处理:年龄应该是有效的!这就是Bind。
记得在上一章中,我们定义了Int.Parse来解析一个字符串为一个int.我们还定义了Age.Of,一个智能构造函数,从给定的int创建一个Age实例。这两个函数都返回一个Option:
Int.Parse : string -> Option< int >
Age.Of : int -> Option< Age >
让我们看看如果我们将它们与 Map 结合会发生什么:
string input;
Option<int> optI = Int.Parse(input);
Option<Option<Age>> ageOpt = optI.Map(i => Age.Of(i));
正如你所看到的,我们有一个问题–我们最终得到了一个嵌套的值。年龄的Option…我们如何处理这个问题?
4.3.1 组合Option返回的函数
正是在这样的情况下,Bind就显得很方便。对于 Option 来说,这就是Bind的签名:
Option.Bind : (Option< T >, (T -> Option< R >)) -> Option< R >
也就是说,Bind 接受一个 Option 和一个返回 Option 的函数,并将该函数应用于 Option 的内部值。下面是实现方法。
清单 4.3 Option的绑定和映射的实现
// Bind 接受一个返回 Option 的函数
public static Option <R> Bind <T, R>
(this Option <T> optT, Func <T, Option <R>> f)
=> optT.Match(
() => None,
(t) => f(t));
// Map 接受常规函数。
public static Option <R> Map <T, R>
(this Option <T> optT, Func <T, R> f)
=> optT.Match(
() => None,
(t) => Some(f(t)));
前面的列表复制了Map的定义,这样你就可以看到这些定义是多么的相似。 简单地说,None情况下总是返回None,所以给定的函数不会被应用。Some情况下会应用该函数;与Map不同,不需要将结果打包成一个Option,因为 f 已经返回一个Option。
现在让我们看看如何在解析代表一个人年龄的字符串的示例中使用 Bind。
清单 4.4 使用 Bind 来组合两个返回 Option 的函数
Func<string, Option<Age>> parseAge = s
=> Int.Parse(s).Bind(Age.Of);
parseAge("26"); // => Some(26)
parseAge("notAnAge"); // => None
parseAge("180"); // => None
函数 parseAge 使用 Bind 来结合 Int.Bind(它返回一个Option)和 Age.Of(它返回一个Option< Age >)。 因此,parseAge结合了检查字符串是否代表一个有效的整数和检查该整数是否是一个有效的年龄值。
现在让我们在一个简单的程序中看看这一点,该程序从console中读取一个年龄,并打印出相关的信息:
public static class AskForValidAgeAndPrintFlatteringMessage {
public static void Main()
=> WriteLine($"Only {ReadAge()}! That's young!");
//只要解析年龄失败,就会递归调用自身
static Age ReadAge()
=> ParseAge(Prompt("Please enter your age")).Match(() => ReadAge(), (age) => age);
static Option <Age> ParseAge(string s)
=> Int.Parse(s).Bind(Age.Of); //将一个字符串解析为一个int,并从int创建一个年龄。
static string Prompt(string prompt) {
WriteLine(prompt);
return ReadLine();
}
}
这是与此程序的交互示例(用户输入以粗体显示):
Please enter your age
hello
Please enter your age
500
Please enter your age
45
Only 45! That's young!
现在让我们看看 Bind 如何与 IEnumerable 一起工作。
4.3.2 使用 Bind 扁平化嵌套列表
你刚刚看到了如何使用绑定来避免嵌套的Option。同样的想法也适用于列表。 但是什么是嵌套的列表呢? 二维列表! 我们需要一个函数,它将应用一个返回列表的函数到一个列表。 但它不是返回一个二维列表,而是将结果平铺成一个一维列表。
请记住 Map 循环遍历给定的 IEnumerable 并将函数应用于每个元素。绑定是类似的,但有一个嵌套循环,因为应用“绑定”函数也会产生一个 IEnumerable。结果列表被展平为一维列表。在代码中看到这一点可能更容易:
public static IEnumerable <R> Bind <T, R>
(this IEnumerable <T> ts, Func <T, IEnumerable <R>> f)
{
foreach(T t in ts)
foreach(R r in f(t))
yield return r;
}
如果你对LINQ非常熟悉,你会发现这个实现与SelectMany几乎是一样的。所以对于IEnumerable,Bind和SelectMany是相同的。同样,在本书中我将使用Bind这个名字,因为它是FP术语中的标准名称。
让我们通过一个例子来看看它的作用。假设你有一个邻居的列表,每个邻居都有一个宠物的列表。你想要一个邻居的所有宠物的列表:
var neighbors = new [] {
new { Name = "John", Pets = new Pet[] { "Fluffy", "Thor" } },
new { Name = "Tim", Pets = new Pet[] {} },
new { Name = "Carl", Pets = new Pet[] { "Sybil" } },
};
IEnumerable < IEnumerable < Pet >> nested = neighbors.Map(n => n.Pets);
// => [["Fluffy", "Thor"], [], ["Sybil"]]
IEnumerable < Pet > flat = neighbors.Bind(n => n.Pets);
// => ["Fluffy", "Thor", "Sybil"]
注意到使用Map会产生一个嵌套的IEnumerable,而Bind会产生一个平面的IEnumerable。(还请注意,无论你以何种方式看待前面的例子,Bind都不一定比Map产生更多的项目,这确实使SelectMany这个名字的选择显得相当奇怪)。)
图 4.3 显示了 IEnumerable 绑定的图形表示,具体化为邻域示例中的类型和数据。
正如你所看到的,每个函数应用都会产生一个IEnumerable,然后所有应用的结果都被平铺到一个IEnumerable中。
4.3.3 实际上,它被称为 monad
现在让我们来概括一下Bind的模式。 如果我们用 C< T > 表示某个包含 T 类型值的结构,那么 Bind 接收一个容器的实例和一个签名为 (T -> C< R >) 的函数,并返回一个 C< R >。 所以Bind的签名总是以这种形式出现。
Bind : (C< T >, (T -> C< R >)) -> C< R >
你看到了,为了所有的实际目的,functors 是定义了合适的 Map 函数的类型。同样地,monad是定义了绑定函数的类型。
你有时会听到人们谈论 monadic bind 来澄清他们不仅仅是在谈论一些称为 Bind 的函数,而是关于允许将类型视为 monad 的 Bind 函数。
4.3.4 返回函数
除了 Bind 函数之外,monad 还必须有一个 Return 函数,它将正常值 T “提升”为 monadic 值 C< T >。对于Option,这是我们在第3章中定义的Some函数。
IEnumerable的返回函数是什么?好吧,由于IEnumerable有很多实现,所以有很多可能的方法来创建一个IEnumerable。在我的函数库中,我有一个适合IEnumerable的返回函数,叫做List。
为了坚持函数式设计原则,它使用了一个不可变的实现。
using System.Collections.Immutable;
public static IEnumerable<T> List<T>(params T[] items)
=> items.ToImmutableList();
List函数不仅满足了Return函数的要求–允许我们将一个简单的T提高到一个IEnumerable< T >–而且,由于参数的存在,还为我们提供了一个很好的初始化列表的简写语法:
using static F;
var empty = List<string>(); // => []
var singleton = List("Andrej"); // => ["Andrej"]
var many = List("Karina", "Natasha"); // => ["Karina", "Natasha"]
总而言之,monad 是一个类型M,为其定义了以下函数:
Return : T -> M< T >
Bind : (M< T >, (T -> C< R >)) -> C< R >
Bind 和 Return 必须遵守某些属性才能将类型视为“正确的”monad;这些被称为 monad 定律。为了避免在本章中过多地讨论理论,我将把对 monad 定律的讨论推迟到第 8 章。
可以这么说,Return应该只做将T提升为M< T >所需的最小量的工作,而不是其他。它应该是尽可能愚蠢的。
4.3.5 functors 和 monads 之间的关系
我说过,functors 是定义了 Map 的类型,而 monads 是定义了 Return 和 Bind 的类型。你也看到了Option和IEnumerable都是functors和monads,因为所有这些函数都被定义了。
因此,问题自然而然地产生了,每一个functor也是一个monad吗?每个monad也是一个 functor吗? 为了回答这个问题,让我们再看一下核心函数的签名:
Map : (C< T >, (T -> R)) -> C< R >
Bind : (C< T >, (T -> C< R >)) -> C< R >
Return : T -> C< T >
如果你有Bind和Return的实现,你可以用这些实现Map:Map作为输入的函数T -> R可以通过与Return的组合变成一个T -> C< R >类型的函数,这个函数可以作为Bind的输入。为了说服自己,我在练习中建议你用Bind和Return实现Map。尽管这个实现是正确的,但它可能是次要的,所以通常Map将被赋予它自己的实现,不依赖于Bind。
至于第二个问题,事实证明答案是否定的:并不是每一个放函数都是amonad。Bind不能用Map来定义,所以有一个Map的实现并不能保证Bind函数可以被定义。例如,某些类型的树支持Map,但不支持Bind。另一方面,对于我们将在本书中讨论的大多数类型,Map和Bind都可以被定义。