4.2 使用 ForEach 执行副作用
在第三章中,我们讨论了Func和Action之间的二分法。 我们在Map中又遇到了这个问题。Map需要一个Func,那么如果我们想对给定结构中的每个值执行一个Action,我们该怎么做?
您可能知道 List< T > 有一个 ForEach 方法,它接受一个 Action< T >,它为列表中的每个项目调用:
using static System.Console;
new List<int> { 1, 2, 3 }.ForEach(Write);
// prints: 123
这基本上就是我们想要的。 让我们概括一下,这样我们就可以在任何 IEnumerable 上调用ForEach:
public static IEnumerable<Unit> ForEach<T> (this IEnumerable<T> ts, Action<T> action)
=> ts.Map(action.ToFunc()).ToImmutableList();
这段代码把 Action 改成了一个返回 Unit 的函数,然后依赖于Map的实现。这只会创建一个懒惰地评估的 Unit 序列。 在这里,我们实际上希望执行副作用;因此调用了ToImmutableList。不出所料,其用法是,
Enumerable.Range(1, 5).ForEach(Write);
// prints: 12345
现在我们来看看Option的ForEach的定义。这个定义在Map中是很简单的,使用ToFunc函数将Action转换为Func:
public static Option<Unit> ForEach<T> (this Option<T> opt, Action<T> action)
=> Map(opt, action.ToFunc());
ForEach的名字可能有点反直觉–记住,一个 Option 最多只有一个内部值,所以给定的动作将被精确地调用一次(如果Option是Some)或永远不会被调用(如果它是None)。
下面是一个使用 ForEach 将一个动作应用于一个 Option 的例子:
var opt = Some("John");
opt.ForEach(name => WriteLine($"Hello {name}"));
// prints: Hello John
然而,请记住第2章,我们应该把纯逻辑和副作用分开。我们应该用Map来表示逻辑,用ForEach来表示副作用,所以最好把前面的代码改写成如下:
opt.Map(name => $"Hello {name}").ForEach(WriteLine);
隔离副作用 让你用ForEach应用的Action的范围尽可能的小:用Map处理数据转换,用ForEach处理副作用。这遵循了FP的一般理念,即如果可能的话避免副作用,否则就将其隔离。
花点时间在 REPL 中进行试验,看看 Map 和 ForEach 可以与 IEnumerable 和 Option 一起使用。下面是一个例子:
using static System.Console;
using String = LaYumba.Functional.String;
Option<string> name = Some("Enrico");
name.Map(String.ToUpper).ForEach(WriteLine);
// prints: ENRICO
IEnumerable<string> names = new[] { "Constance", "Albert" };
names.Map(String.ToUpper).ForEach(WriteLine);
// prints: CONSTANCE
// ALBERT
注意到你可以使用相同的模式,无论你是用Option还是用IEnumerable工作。这不是很好吗?现在你可以把 Option 和 IEnumerable 视为特殊的容器,你有一组核心函数可以与它们进行交互。 如果你遇到一种新的容器,并且定义了 Map 或 ForEach,你可能会对它们的作用有一个很好的概念,因为你认识到了这种模式。
注意 在前面的代码中,我使用了LaYumba.Functional.String,一个通过静态方法暴露System.String一些常用功能的类。这使得我可以将String.ToUpper作为一个函数来引用,而不需要指定ToUpper实例方法所作用的实例,如:s => s.ToUpper()
总之,ForEach与Map类似,但它接受一个Action而不是一个函数,所以它被用来执行副作用。让我们继续讨论下一个核心函数。