C# 函数式编程(三)
原文:
zh.annas-archive.org/md5/445c5024138799c1ed6a1899c0d17e5d译者:飞龙
第七章:函数式流程
调用外部系统,无论是数据库、Web API 还是其他,都是一件很头疼的事情,不是吗?在使用你的数据 - 函数中最重要的部分之前,你必须:
-
捕获并处理任何异常。也许是网络故障,或者数据库服务器离线了?
-
检查从数据库返回的内容不是 NULL
-
检查是否有一个实际合理的数据集,即使它不是 null。
这就是许多繁琐的样板代码,所有这些都妨碍了你的实际业务逻辑。
使用上一章的 Maybe 判别联合将在处理未找到记录或遇到错误时有所帮助,但即便如此,仍然需要一些样板代码。
如果我告诉你有一种方法,你永远不用再见到未处理的异常了?不仅如此,你甚至不需要再使用 Try/Catch 块了。至于 Null 检查?忘了吧。你再也不用做那些事了。
不相信我?好吧,准备好,我要向你介绍函数式编程中我最喜欢的一个特性。这是我在日常工作中经常使用的,希望读完这一章后,你也会用到。
再次审视 Maybe
提到上一章的 Maybe 判别联合 - 我现在想重新讨论它,但这次我要向你展示它可以比你想象的更加有用。
我要做的是在前几章使用过的 Map 扩展方法中加入一个版本。如果你还记得第五章“链式函数”中的Map组合子,它类似于 LINQ 的 Select 方法,只不过它作用于整个源对象,而不是其个别元素。
这一次,我要在 Map 里面加入一些逻辑,这将决定将产生哪种实际类型。这一次我给它起了一个不同的名字 - Bind¹
public static Maybe<TOut> Bind<TIn, TOut>(this Maybe<TIn> @this, Func<TIn, TOut> f)
{
try
{
Maybe<TOut> updatedValue = @this switch
{
Something<TIn> s when !EqualityComparer<TIn>.Default.Equals(s.Value, default) =>
new Something<TOut>(f(s.Value)),
Something<TIn> _ => new Nothing<TOut>(),
Nothing<TIn> _ => new Nothing<TOut>(),
Error<TIn> e => new Error<TOut>(e.ErrorMessage),
_ => new Error<TOut>(new Exception("New Maybe state that isn't coded for!: " + @this.GetType()))
};
return updatedValue;
}
catch (Exception e)
{
return new Error<TOut>(e);
}
}
那么,这里发生了什么?可能有几种情况:
-
当前值为
This- 即被 Maybe 持有的当前对象是一个Something- 即一个实际值,并且持有的值是非默认值²,在这种情况下,执行提供的函数,并将其输出作为新的Something返回。 -
当前值为
This是一个Something,但其内部的值是默认值(在大多数情况下是 Null),在这种情况下,我们现在返回一个Nothing。 -
当前值为
This是一个 Nothing,在这种情况下,返回另一个 Nothing。没有必要再做其他事情。 -
当前值为
This是一个错误。同样,除了将其传递下去外,没有任何其他做的必要。
这一切的意义何在?好吧,想象一下以下的过程化代码:
public string MakeGreeting(int employeeId)
{
try
{
var e = this.empRepo.GetById(employeeId);
if(e != null)
{
return "Hello " + e.Salutation + " " + e.Name
}
return "Employee not found";
}
catch(Exception e)
{
return "An error occurred: " + e.Message;
}
}
当你看它时,这段代码的实际目的非常简单。获取一个员工。如果没有问题,向他们打招呼。但是,由于空值和未处理的异常存在,我们不得不编写大量的防御性代码。空检查和 Try/Catch 块。不仅在这里,在我们的整个代码库中都是如此。
更糟糕的是,我们让调用此函数的代码知道该如何处理它。我们如何表示发生了错误,或者找不到员工?在我的例子中,我只是返回一个字符串,供我们编写的任何应用程序盲目显示。另一个选择是返回带有元数据附加的返回对象(例如 bool DataFound,bool ExceptionOccurred,Exception CapturedException - 这样的东西)。
然而,使用 Maybe 和 Bind 函数,这些都是不必要的。可以将代码重写如下:
public Maybe<string> MakeGreeting(int employeeId) =>
new Something(employeeId)
.Bind(x => this.empRepo.GetById(x))
.Bind(x => "Hello " + x.Salutation + " " + x.Name);
想想我列出的每个 Bind 的可能结果。
如果员工存储库返回一个空值,则下一个 Bind 调用将标识为某个具有默认值(即空)的东西,并且不执行构造问候字符串函数,而是返回一个 Nothing。
如果存储库发生错误(可能是网络连接问题,无法预测或防止的问题),则简单地传递错误,而不执行函数。
我要表达的最终观点是,组装问候语的箭头函数只有在前一步 a) 返回了实际值且 b) 没有抛出未处理的异常时才会执行。
这意味着上面使用 Bind 方法编写的小函数在功能上与以前的版本完全相同,覆盖了防御性代码。
情况会变得更好…
我们不再返回字符串,而是返回 Maybe<string>。这是一个可以用来通知调用我们函数的结果的辨别联合,告诉它是否工作等等。这可以在外部世界用于决定如何处理结果值,或者可以在后续的 Bind 调用链中使用。
要么像这样:
public Interface IUserInterface
{
void WriteMessage(string s);
}
// Bit of magic here because it doesn't matter
this.UserInterface = Factory.MakeUserInterface();
var message = makeGreetingResult switch
{
Something s => s.Value,
Nothing _ => "Hi, but I've never heard of you.",
Error _ => "An error occurred, try again"
};
this.UserInterface.WriteMessage(message);
或者,您可以调整 UserInterface 模块,以便它以 Maybe 作为参数:
public Interface IUserInterface
{
void WriteMessage(Maybe<string> s);
}
// Bit of magic here because it doesn't matter
this.UserInterface = Factory.MakeUserInterface();
var logonMessage = MakeGreeting(employeeId)
.Bind(x => x + Environment.NewLine + MakeUserInfo(employeeId));
this.UserInterface.WriteMessage(logonMessage);
在接口中用 Maybe<T> 替换具体值表明,消费它的类不能确定操作是否有效,并迫使消费类考虑每种可能性及其处理方式。它还完全让消费类决定如何响应。没有必要让返回 Maybe 的类对接下来发生的事情感兴趣。
我遇到的这种编程风格的最佳描述是在 Scott Wlashin 的讲座和相关文章中,称为铁路导向编程 ³。
Wlashin 将这个过程描述为像一个铁路线,有一系列的道岔。每组道岔都是一个 Bind 调用。火车从 Something 线开始,每次执行传递给 Bind 的函数时,火车要么继续前进到下一组道岔,要么切换到 Nothing 路径,直接滑向线路末端的车站,而无需做更多工作。
这是一种美丽、优雅的编码方式,大大减少了样板代码。
要是这种结构有一个方便的技术术语就好了。哦等等,有!它叫做 Monad!
我在开头就说过它们可能会突然出现在某处。如果有人告诉你 Monad 很复杂,我希望你现在能看到他们错了。
Monad 就像是围绕某种值的包装。就像一个信封或者一个墨西哥卷饼。它们保存值,但并不评论它到底设置了什么。
它们的作用是让你能够挂接函数,提供一个安全的环境进行操作,而不必担心负面后果,比如空引用异常。
Bind 函数就像接力赛一样 - 每次调用都执行某种操作,然后将其值传递给下一个运行者。它还为您处理错误和空值,因此您无需担心编写太多的防御性代码。
如果你愿意,可以想象它就像一个防爆箱。你有一个想要打开的包裹,但你不知道它是安全的,像一封信⁴,还是可能是爆炸品,等待你打开盖子时将你击倒。如果你把它放在 Monad 容器中,它可以安全地打开或爆炸,但 Monad 会保护你免受后果。
那基本上就是这样。嗯,大多数情况下是这样的。
在本章的其余部分,我将考虑我们可以用 Monad 做什么,以及还有哪些其他类型的 Monad。不过不用担心,现在“难”部分已经过去了,如果你已经通过了这一点,仍然与我在一起,那么这本书的其余部分将会很轻松⁵。
Maybe 和调试
有时关于 Bind 语句串的评论是,在 Visual Studio 中使用调试工具逐步跟踪更改变得更难。特别是当你有这样的场景时:
var returnValue = idValue.ToMaybe()
.Bind(transformationOne)
.Bind(transformationTwo)
.Bind(transformationThree);
实际上,在大多数 Visual Studio 的版本中都是可能的,但你需要确保你不断地按“Step-in”键进入 Bind 调用内部的箭头函数。如果值没有正确计算,这对于了解正在发生的事情还不是最好的。当你考虑 Step-in 将进入 Maybe 的 Bind 函数并需要执行更多步骤才能看到箭头函数的结果时,情况会更糟。
我倾向于每行写一个 Bind,每个 Bind 存储一个包含它们各自输出的变量:
var idMaybe = idValue.ToMaybe();
var transOne = idMaybe.Bind(x => transformationOne(x));
var transTwo = transOne.Bind(x => transformationTwo(x));
var returnValue = transTwo.Bind(x => transformationThree(x));
从功能上讲,这两个示例是相同的,只是我们分别捕获每个输出,而不是立即将其馈送到另一个函数并丢弃它们。
第二个示例更容易诊断问题,因为您可以检查每个中间值。由于函数式编程技术的使用,一旦设置变量就不修改它 - 这意味着处理过程中的每个中间步骤都是固定的,可以详细了解发生了什么事情,以及在出现错误时如何以及在哪里出错。
这些中间值将在它们所属的更大函数的整个生命周期内保持在作用域内。因此,如果是一个特别大的函数,并且其中一个中间值很大,那么合并它们以尽早去除这个大中间值可能是值得的。
这个决定主要是个人风格的问题,以及一个或两个代码库的约束。无论您选择哪个都可以。
Map vs Bind
严格来说,按照函数范式实现 Bind 函数时,我并没有这样做。
应该 有两个附加到 Maybe 的函数:Map 和 Bind。它们几乎相同,但有一个小而微妙的区别。
Map 函数与我在上一节中描述的函数类似 - 它连接到 Maybe,需要一个函数来从 Maybe 内部获取类型 T1 的值,并要求您将其转换为类型 T2。
实际的 Bind 需要您传入一个返回新类型的 Maybe 的函数 - 即 Maybe。它仍然返回与 Map 函数相同的结果,
public static Maybe<TOut> Map<TIn, TOut>(this Maybe<TIn> @this, Func<TIn, TOut> f) => // Some implementation here
public static Maybe<TOut> Bind<TIn, TOut>(this Maybe<TIn> @this, Func<TIn, Maybe<TOut>> f) => // Some Other implementation here
例如,如果您有一个调用数据库并返回 Maybe<IEnumerable<Customer>> 类型的函数,表示可能找到或未找到客户的列表 - 那么您将使用 Bind 函数调用它。
将 Enumerable 中的客户端转换为其他形式的任何后续链式函数调用都可以使用 Map 调用,因为这些变化是数据到数据的转换,而不是数据到可能性的转换。
这是如何实现一个正确的 Bind 的方法:
public static Maybe<TOut> Bind<TIn, TOut>(this Maybe<TIn> @this, Func<TIn, Maybe<TOut>> f)
{
try
{
var returnValue = @this switch
{
Something<TIn> s => f(s.Value),
_ => new Nothing<TOut>()
};
return returnValue;
}
catch (Exception _)
{
return new Nothing<TOut>();
}
}
下面是如何使用它的示例:
public Interface CustomerDataRepo
{
Maybe<Customer> GetCustomerById(int customerId);
}
public string DescribeCustomer(int customerId) =>
new Something<int>(customerId)
.Bind(x => this.customerDataRepo.GetCustomerById(x))
.Map(x => "Hello " + x.Name);
使用这个新的 Bind 函数并将之前的一个命名为 Map,您将更接近函数范式。
但是在生产代码中,我个人不这样做。我只是为两个目的都使用一个名为 Bind 的函数。
你可能会问,为什么呢?
这主要是为了防止混淆,说实话。JavaScript 有一个名为 Map 的原生函数,但它的操作类似于 C# 中的 Select,作用于数组的各个元素。在 C# 中,Jimmy Bogard 的 AutoMapper 库⁶ 中也有一个 Map 函数,用于将一个对象数组从一种类型转换为另一种类型。
由于许多 C#代码库中已经在使用两个 Map 函数的情况下,我认为在其中添加另一个 Map 函数可能会让查看我的代码的其他人感到困惑。因此,我为所有目的使用 Bind,因为在 C#或 JavaScript 中没有任何地方已经存在 Bind 函数 - 除了实现函数式范式的库中。
你可以自行选择使用更严格准确的同时使用 Map 和 Bind 的版本,或者在我看来更少混淆和更实用的路线,即简单地使用多个 Bind 函数实现每个目的。
我将继续假设这本书的其余部分采用第二种选项。
Maybe 和原始类型
这听起来像是一本从未写成的惊险故事小说的标题。可能涉及我们的女英雄 - Maybe 船长,在一个由侵略性洞穴居民族群组成的失落文明中挥舞着救援。
实际上,在 C#中,原始类型是一组不默认为 Null 的内置类型之一。以下是其中的一些:
-
布尔型
-
字节型
-
有符号字节型
-
字符型
-
十进制
-
双精度浮点型
-
单精度浮点型
-
整型
-
无符号整型
-
有符号自然数型
-
无符号自然数型
-
长整型
-
无符号长整型⁷
-
短整型
-
无符号短整型
这里的重点是,如果我在之前章节的Bind函数中使用任何这些类型,并将它们的值设置为 0,它们将违反对default的检查,因为大多数这些类型的默认值为 0⁸
这里有一个单元测试的示例,会失败(我正在使用 XUnit 和 FluentAssertions,以获得更友好、易读的断言风格):
[Fact]
public Task primitive_types_should_not_default_to_nothing()
{
var input = new Something<int>(0);
var output = input.Bind(x => x + 10);
(output as Something<int>).Value.Should().Be(10);
}
这个测试将一个值为 0 的整数存储在一个 Maybe 中,然后尝试将其Bind为一个比原值高 10 的值 - 即应该等于 10。在现有代码中,Bind内部的 switch 操作会将值 0 视为默认值,并将返回类型从Something<int>切换为Nothing<int>,并且不会执行加 10 的函数,这意味着在我的单元测试中输出会被切换为 null,并且测试将因为空引用异常而失败。
有人可能会说,这并不是正确的行为,0 是一个整数的有效值。
只需在Bind函数中添加一行代码即可轻松解决:
public static Maybe<TOut> Bind<TIn, TOut>(this Maybe<TIn> @this, Func<TIn, TOut> f)
{
try
{
Maybe<TOut> updatedValue = @this switch
{
Something<TIn> s when !EqualityComparer<TIn>.Default.Equals(s.Value, default) =>
new Something<TOut>(f(s.Value)),
// This is the new line
Something<TIn> s when s.GetType().GetGenericArguments()[0].IsPrimitive => new Something<TOut>(f(s.Value)),
Something<TIn> _ => new Nothing<TOut>(),
Nothing<TIn> _ => new Nothing<TOut>(),
Error<TIn> e => new Error<TOut>(e.ErrorMessage),
_ => new Error<TOut>(new Exception("New Maybe state that isn't coded for!: " + @this.GetType()))
};
return updatedValue;
}
catch (Exception e)
{
return new Error<TOut>(e);
}
}
新行检查Maybe<T>的第一个泛型参数 - 即T的“真实”类型。我在本节开头列出的所有类型的IsPrimitive值都将设置为true。
如果我使用修改后的Bind函数重新运行我的单元测试,那么值为 0 的int仍然不会匹配不是default的检查,但下一行会匹配,因为 int 是一个原始类型。
现在,这意味着所有原始类型都不能成为Nothing<T>。这是对错是你自己评估的问题。
如果 T 是一个 bool,你可能会认为它是 Nothing<T>。如果是这种情况,需要在第一两行之间的 switch 中添加另一种情况来处理 T 特定的情况为 bool 的情况。
如果一个布尔 false 被传递到执行计算的函数中可能也很重要。正如我所说的,这是一个你最好自己回答的问题。
完全避免这种情况的一种方法是始终传递一个可空的类作为 T,这样你就可以确保在尝试判断你所看到的是 Something 还是 Nothing 时能得到正确的行为。
Maybe 和 日志记录
在专业环境中考虑使用单子的另一件事情是至关重要的开发者工具 - 日志记录。关于函数进度状态的日志信息通常至关重要,不仅仅是错误信息,还包括各种重要信息。
当然可以做类似这样的事情:
var idMaybe = idValue.ToMaybe();
var transOne = idMaybe.Bind(x => transformationOne(x));
if(transOne is Something<MyClass> s)
{
this.Logger.LogInformation("Processing item " + s.Value.Id);
}
else if (transOne is Nothing<MyClass>)
{
this.Logger.LogWarning("No record found for " + idValue");
}
else if (transOne is Error<MyClass> e)
{
this.Logger.LogError(e, "An error occurred for " + idValue);
}
如果你做了很多这样的操作,这可能会失控。特别是在整个过程中有许多需要记录的 Bind。
也许可以直到最后才留出错误日志,或者甚至在控制器中,或者最终发起此请求的任何其他地方。错误消息将不经修改地从一方传递到另一方。但这仍然会留下偶尔的信息或警告用途的日志消息。
我更喜欢添加扩展方法到 Maybe 中以提供一组事件处理函数:
public static class MaybeLoggingExtensions
{
public static Maybe<T> OnSomething(this Maybe<T> @this, Action<T> a)
{
if(@this is Something<T>)
{
a(@this);
}
return @this;
}
public static Maybe<T> OnNothing(this Maybe<T> @this, Action a)
{
if(@this is Nothing<T> _)
{
a();
}
return @this;
}
}
public static Maybe<T> OnError(this Maybe<T> @this, Action<Exception> a)
{
if(@this is Error<T> e)
{
a(e.CapturedError);
}
return @this;
}
那么,我使用它的方式更像是这样:
var idMaybe idValue.ToMaybe();
var transOne = idMaybe.Bind(x => transformationOne(x))
.OnSomething(x => this.Logger.LogInformation("Processing item " + x.Id))
.OnNothing(() => this.Logger.LogWarning("No record found for " + idValue))
.OnError(e => this.Logger.LogError(e, "An error occurred for " + idValue));
这相当可用,尽管它确实有一个缺点。
OnNothing 和 OnError 状态将从 Bind 传播到未修改的 Bind,因此如果你有一长串带有 OnNothing 或 OnError 处理程序函数的 Bind 调用,它们每次都会触发。就像这样:
var idMaybe idValue.ToMaybe();
var transOne = idMaybe.Bind(x => transformationOne(x))
.OnNothing(() => this.Logger.LogWarning("Nothing happened one");
var transTwo = transOne.Bind(x => transformationTwo(x))
.OnNothing(() => this.Logger.LogWarning("Nothing happened two");
var returnValue = transTwo.Bind(x => transformationThree(x))
.OnNothing(() => this.Logger.LogWarning("Nothing happened three");
在上面的代码示例中,所有三个 OnNothing 将会触发,并写入三条警告日志。你可能希望这样,也可能不希望。在第一个 Nothing 之后,可能就不再那么有趣了。
我确实对这个问题有一个解决方案,但这意味着需要编写更多的代码。
创建一个从原始的 Nothing 和 Error 下降的新实例:
public class UnhandledNothing<T> : Nothing<T>
{
}
public class UnhandledError<T> : Error<T>
{
}
我们还需要修改 Bind 函数,以便在从 Something 路径切换到其中之一时返回这些类型。
public static Maybe<TOut> Bind<TIn, TOut>(this Maybe<TIn> @this, Func<TIn, TOut> f)
{
try
{
Maybe<TOut> updatedValue = @this switch
{
Something<TIn> s when !EqualityComparer<TIn>.Default.Equals(s.Value, default) =>
new Something<TOut>(f(s.Value)),
Something<TIn> s when s.GetType().GetGenericArguments()[0].IsPrimitive => new Something<TOut>(f(s.Value)),
Something<TIn> _ => new UnhandledNothing<TOut>(),
Nothing<TIn> _ => new Nothing<TOut>(),
UnhandledNothing<TIn> _ => new UnhandledNothing<TOut>(),
Error<TIn> e => new Error<TOut>(e.ErrorMessage),
UnhandledError<TIn> e => new UnhandledError<TOut>(e.CapturedError),
_ => new Error<TOut>(new Exception("New Maybe state that isn't coded for!: " + @this.GetType()))
};
return updatedValue;
}
catch (Exception e)
{
return new UnhandledError<TOut>(e);
}
}
然后最后,需要更新处理函数:
public static class MaybeLoggingExtensions
{
public static Maybe<T> OnNothing(this Maybe<T> @this, Action a)
{
if(@this is UnhandledNothing<T> _)
{
a();
return new Nothing<T>();
}
return @this;
}
}
public static Maybe<T> OnError(this Maybe<T> @this, Action<Exception> a)
{
if(@this is UnhandledError<T> e)
{
a(e.CapturedError);
return new Error<T>(e.CapturedError);
}
return @this;
}
所有这些意味着,当从 Something 切换到其他状态时,Maybe 会切换到一个状态,不仅表示发生了 Nothing 或异常,而且还表示尚未处理该状态。
一旦调用了处理函数之一,并发现了未处理的状态,那么回调就会触发日志记录,或者其他操作,并返回一个新对象,保持相同的状态类型,但这次指示它不再是未处理的状态。
这意味着在我上面示例中多次使用 Bind 调用并附加了 OnNothing 函数时,只有第一个 OnNothing 会被触发,其余的会被忽略。
在代码库的其他地方,你仍然可以使用模式匹配语句来检查 Maybe 的类型,以便在 Maybe 达到最终目的地时执行某些动作或其他操作。
Maybe 和异步
所以,我知道你接下来要问我什么。看,我得委婉地拒绝你了。我已经结婚了。哦?我的错。异步和 Monad。是的,好的。继续...
如何处理 Monad 内部调用到异步进程?老实说,这并不难。保留你已经编写的 Maybe Bind 函数,并将这些内容添加到代码库中:
public static async Task<Maybe<TOut>> BindAsync<TIn, TOut>(this Maybe<TIn> @this, Func<TIn, Task<TOut>> f)
{
try
{
Maybe<TOut> updatedValue = @this switch
{
Something<TIn> s when EqualityComparer<TIn>.Default.Equals(s.Value, default) =>
new Something<TOut>(await f(s.Value)),
Something<TIn> _ => new Nothing<TOut>(),
Nothing<TIn> _ => new Nothing<TOut>(),
Error<TIn> e => new Error<TOut>(e.ErrorMessage),
_ => new Error<TOut>(new Exception("New Maybe state that isn't coded for!: " + @this.GetType()))
};
return updatedValue;
}
catch (Exception e)
{
return new Error<TOut>(e);
}
}
我在这里做的所有事情就是在我们传递的值周围再包装一层。
第一层是 Maybe - 表示我们尝试的操作可能没有成功
第二层是 Task - 表示首先需要执行一个 async 操作,然后才能到达 Maybe。
在你更广泛的代码库中使用这个最好一次一行地进行,这样你可以避免在同一个 Bind 调用链中混合使用异步和非异步版本,否则你可能会得到一个 Task<T> 作为一种类型传递,而不是实际的类型 T。此外,这意味着你可以将每个异步调用分离出来,并使用 await 语句获取真实的值以传递给下一个 Bind 操作。
嵌套的 Maybe
到目前为止,我展示的 Maybe 存在一些问题。这是我真正意识到的,一旦我将许多接口更改为使用 Maybe<T> 作为涉及外部交互的返回类型后。
下面是一个需要考虑的场景。我创建了几种不同类型的数据加载器。它们可以是数据库、Web API 或其他。并不重要:
public interface DataLoaderOne
{
Maybe<string> GetStringOne();
}
public interface DataLoaderTwo
{
Maybe<string> GetStringTwo(string stringOne);
}
public interface DataLoaderThree
{
Maybe<string> GetStringThree(string stringTwo);
}
在代码的其他部分,我想通过 Bind 调用依次调用这些接口。
注意 Maybe<string> 返回类型的重点在于,我可以通过 Bind 函数引用它们,如果任何 dataLoader 调用失败,后续步骤 将不会 执行,并且我会得到一个 Nothing<string> 或 Error<string> 以供检查。
像这样:
var finalString = dataLoaderOne.GetStringOne()
.Bind(x => dataLoaderTwo.GetStringTwo(x))
.Bind(x => dataLoaderThree.GetStringThree(x));
不过我发现这段代码不会编译。你觉得为什么会这样?
这里有三个函数调用在工作,并且所有三个返回类型都是 Maybe<string>。一次看一行,看看会发生什么:
-
GetStringOne首先返回一个Maybe<string>。目前为止,一切顺利。 -
然后
Bind调用附加到Maybe<string>并解压缩到一个字符串,传递给GetStringTwo,其返回类型被弹出到一个新的Maybe中以便安全保持。 -
下一个绑定调用展开了上一个绑定的返回类型,使其仅为
GetStringTwo的返回类型 - 但GetStringTwo并未返回string,而是返回了Maybe<string>。因此,在第二个绑定调用中,x实际上等于Maybe<string>,无法传递给GetStringThree!
我可以通过直接访问存储在x中的 Maybe 值来解决这个问题,但首先我需要将其转换为Something。但如果不是Something呢?如果在GetStringOne与数据库交互时发生错误呢?如果找不到任何字符串呢?
我基本上需要一种方法来解包一个嵌套的 Maybe,但仅在它返回包含真实值的 Something 时,对其进行处理,否则我们需要匹配其不成功路径(Nothing 或 Error)。
我的做法是创建另一个 Bind 函数,与我们已经创建的其他两个并列,但这个函数专门处理嵌套的 Maybes 问题。
我会这样做:
public static Maybe<TOut> Bind<TIn, TOut>(
this Maybe<Maybe<TIn>> @this, Func<TIn, TOut> f)
{
try
{
var returnValue = @this switch
{
Something<Maybe<TIn>> s => s.Value.Bind(f),
Error<Maybe<TIn>> e => new Error<TOut>(e.ErrorMessage),
Nothing<Maybe<TIn>> => new Nothing<TOut>(),
_ => new Error<TOut>(new Exception("New Maybe state that isn't coded for!: " + @this.GetType()))
};
return returnValue;
}
catch (Exception e)
{
return new Error<TOut>(e);
}
}
我们在这里做的是获取嵌套绑定(Maybe<Maybe<string>>)并对其调用 Bind,在回调函数中展开第一层,使我们仅剩下Maybe<string>,从而可以在 Maybe 上执行与之前 Bind 函数中相同的逻辑。
这也需要针对async版本完成:
public static async Task<Maybe<TOut>> BindAsync<TIn, TOut>(this Maybe<Maybe<TIn>> @this, Func<TIn, Task<TOut>> f)
{
try
{
var returnValue = await @this.Bind(async x =>
{
var updatedValue = @this switch
{
Something<TIn> s when
EqualityComparer<TIn>.Default.Equals(s.Value, default(TIn)) =>
new Something<TOut>(await f(s.Value)),
Something<TIn> _ => new Nothing<TOut>(),
Nothing<TIn> _ => new Nothing<TOut>(),
Error<TIn> e => new Error<TOut>(e.CapturedError)
}
return updatedValue;
});
return returnValue;
}
catch(Exception e)
{
return new Error<TOut>(e);
}
}
如果你希望以另一种方式思考这个过程。想想 LINQ 中的SelectMany函数。如果你向它提供一个数组的数组 - 即多维数组,则你会得到一个单维的扁平数组。现在,处理嵌套的Maybe对象允许我们在 Monads 中执行相同的操作。这实际上是 Monads 的一个“法则” - 任何自称为 Monad 的东西都应遵循的特性。
事实上,这使我顺利过渡到下一个主题。什么是法则,它们的用途是什么,以及我们如何确保我们的 C# Monad 也遵循它们…
法则
严格来说,要被认为是一个真正的 Monad,必须遵循一组规则(称为法则)。我将简要讨论每个法则,这样你就能自己判断你是否在看一个真正的 Monad。
左单位元法则
这表明,一个 Monad,如果给定一个函数作为其 Bind 方法的参数,将返回与直接运行函数相同且没有副作用的等效结果。
这里有一些 C#代码来演示:
Func<int, int> MultiplyByTwo = x => x * 2;
var input = 100;
var runFunctionOutput = MultiplyByTwo(input);
var monadOutput = new Something<int>(input).Bind(MultiplyByTwo);
// runFunctionOutput and monadOutput.Value should both
// be identical - 200 - to conform to the Left Identity Law.
右单位元法则
在我解释这个之前,我需要往回退几步…
首先,我需要解释一下 Functor。这些是将一个事物或一组事物从一种形式转换为另一种形式的函数。Map、Bind和Select都是 Functor 的示例。
最简单的函子是 Identity 函子。Identity 是一个函数,给定一个输入,返回未更改且没有副作用的同一输入。当你组合函数时,它可能会有用。
我在此处感兴趣的唯一原因是,它是第二个 Monad 定律的基础 - 右单位元法则。这意味着当 Monad 在其 Bind 函数中给定一个 Identity Functor 时,将无副作用地返回原始值。
我可以像这样测试我上一章中创建的 Maybe:
Func<int,int> identityInt = (int x) => x;
var input = 200;
var result = new Something<int>(input).Bind(identityInt);
// result = 200
这只意味着 Maybe 接受一个不会产生错误或Null的函数,执行它,然后准确地返回其输出,而不是其他任何内容。
这两个定律的基本要点很简单,即 Monad 不能以任何方式干预传入或传出的数据,也不能干预传递给 Bind 方法作为参数的函数的执行。Monad 只是一个管道,函数和数据通过它流动。
结合律
前两个定律应该相对琐碎,而我的 Maybe 实现同时满足这两个定律。最后一个定律,即结合律,更难以解释。
它基本上意味着嵌套的 Monad 如何并不重要,最终你总是得到一个包含值的单一 Monad。
这里是一个简单的 C# 示例:
var input = 100;
var op1 = (int x) => x * 2;
var op2 = (int x) => x + 100;
var versionOne = new Something<int>(input)
.Bind(op1)
.Bind(op2);
// versionOne.Value = 100 * 2 + 100 = 300
var versionTwo = new Something<int>(input)
.Bind(x => new Something<int>(x).Bind(op1)).Bind(op2);
// If we don't implement something to fulfill the
// associativity law, we'll end up with a type of
// Something<Something<int>>, where we want this to be
// the exact same type and value as versionOne
回顾前几节关于如何处理嵌套 Maybe 的描述“嵌套 Maybe”,你会看到这是如何实现的。
幸运的话,现在我们已经看过了三个 Monad 定律,我已经证明了我的 Maybe 是一个真正的、毫不吝啬的、诚实的 Monad。
在接下来的部分,我将向您展示另一个 Monad,您可能会用它来消除需要在各处共享的变量。
Reader
让我们想象一下,我们正在组织某种报告。它从 SQL Server 数据库中进行一系列数据拉取。
首先,我们需要获取特定用户的记录。
接下来,使用该记录,我们从我们完全虚构的书店中获取他们最近的订单⁹。
最后,我们将最近的订单记录转换为订单中的物品列表,并在报告中返回其中的一些细节。
我们想利用一种 Monad 风格的 Bind 操作,那么如何确保每个步骤中的数据与数据库连接对象一起传递?这没问题,我们可以组合一个 Tuple,并简单地传递两个对象。
public string MakeOrderReport(string userName) =>
(
Conn: this.connFactory.MakeDbConnection(),
userid
)
.Bind(x => (
x.Conn,
Customer: this.customerRepo.GetCustomer(x.Conn, x.userName)
)
.Bind(x => (
x.Conn,
Order: this.orderRepo.GetCustomerOrders(x.Conn, x.Customer.Id)
),
.Bind(x => this.Order.Items.First())
.Bind(x => string.Join("\r\n", x));
这是一个可行的解决方案,但有点丑陋。存在一些重复的步骤,仅用于在 Bind 操作之间持久化连接对象。这影响了函数的可读性。
从纯粹性方面来说,这也不纯粹。这个函数必须创建数据库连接,这是一种副作用。
还有另一种功能结构,我们可以用来解决所有这些问题 - Reader Monad。这是函数式编程对依赖注入的回答,但在函数级别而不是类级别。
在上述函数的情况下,我们希望注入 IDbConnection,以便可以在其他地方实例化它,使 MakeOrderReport 保持纯净 - 即没有任何副作用。
这里是一个非常简单的读取器的使用示例:
var reader = new Reader<int, string>(e => e.ToString());
var result = reader.Run(100);
我们在这里定义的是一个读取器,它接受一个存储但不执行的函数。该函数以其参数作为“环境”变量类型 - 这是我们将来将要注入的当前未知值,并根据该参数返回一个值,在我们的案例中是一个整数。
“int → string” 函数存储在第一行的读取器中,然后在第二行我们调用“Run”函数,该函数提供了缺失的环境变量值,这里是 100。由于环境最终已提供,因此读取器因此可以使用它来返回一个实际的值。
由于这是一个单子,这也意味着我们应该有 Bind 函数来提供一个流程。这是它们将如何被使用的方式:
var reader = new Reader<int, int>(e => e * 100)
.Bind(x => x / 50)
.Bind(x => x.ToString());
var result = reader.Run(100);
注意,“reader” 变量的类型是 Reader<int, string>。这是因为每个 Bind 调用都在前一个函数周围放置了一个包装器,该函数具有相同的替代返回类型,但不同的参数。
在带有参数 e => e * 100 的第一行中,该函数将在稍后执行,然后运行等等...
这是读取器的一个更现实的用法:
public Customre GetCustomerData(string userName, IDbConnection db) =>
new Reader(this.customerRepo.GetCustomer(userName, x))
.Run(db);
或者,您实际上可以简单地返回读取器,并允许外部世界继续使用 Bind 调用来进一步修改它,然后再运行函数将其转换为正确的值。
public Reader<IdbConnection, User> GetCustomerData(string userName) =>
new Reader(this.customerRepo.GetCustomer(userName, x));
这种方式,同一个函数可以被多次调用,使用读取器的 Bind 函数将其转换为我想要的实际类型。
例如,如果我想获取客户的订单数据:
var dbConn = this.DbConnectionFactory.GetConnection();
var orders = this._customerRepo.GetCustomerData("simon.painter")
.Bind(X => x.OrderData.ToArray())
.Run(dbConn);
想象一下,您正在创建一个只能通过插入正确类型的变量来打开的盒子。
使用读取器还意味着可以轻松地将模拟的 IDbConnection 注入到这些函数中,并基于它们编写单元测试。
根据您希望如何构建代码的方式,甚至可以考虑在接口上公开读取器。它不必像传递进来的 DbConnection 那样成为一个依赖项,它可以是数据库表的 Id 值,或者任何你喜欢的东西。也许是这样:
public interface IDataStore
{
Reader<int,Customer> GetCustomerData();
Reader<Guid,Product> GetProductData();
Reader<int,IEnumerable<Order>> GetCustomerOrders();
}
有各种各样的方法可以使用这个,这完全取决于适合你的是什么,以及你试图做什么。
在下一节中,我将展示这个想法的变种 - 状态单子。
状态
原则上,状态单子与读取器非常相似。定义了一个容器,它需要某种形式的状态对象将自身转换为一个正确的最终数据。绑定可用于提供额外的数据转换,但直到提供状态之前什么都不会发生。
它与众不同的两个方面是:
-
不再是“环境”类型,而是称为“状态”类型。
-
在 Bind 操作之间传递的不是一个而是两个项。
在 Reader 中,原始的 Environment 类型仅在绑定链的开头可见。使用 State 单子,它一直持续到最后。状态类型及其当前值设置为一个元组,从一个步骤传递到下一个。值和状态都可以替换为新值。值可以更改类型,但状态在整个过程中是一个单一类型,如果需要,可以更新其值。
你还可以随意提取或替换状态单子中的状态对象,使用函数随时。
我的实现并不严格遵循你在 Haskell 等语言中看到的方式,但我认为这种类型的实现在 C# 中很麻烦,我不确定这样做有什么意义。我在这里展示的版本在日常 C# 编码中可能会有所用处。
public class State<TS, TV>
{
public TS CurrentState { get; init; }
public TV CurrentValue { get; init; }
public State(TS s, TV v)
{
CurrentValue = v;
CurrentState = s;
}
}
State 单子没有多个状态,因此不需要一个基础抽象类。它只是一个简单的类,有两个属性 - 一个值和一个状态(即我们将通过每个实例传递的东西)。
逻辑必须实现为扩展方法:
public static class StateMonadExtensions
{
public static State<TS, TV> ToState<TS, TV>(this TS @this, TV value) =>
new(@this, value);
public static State<TS, TV> Update<TS, TV>(
this State<TS, TV> @this,
Func<TS, TS> f
) => new(f(@this.CurrentState), @this.CurrentValue);
}
通常情况下,实现代码不多,但有很多有趣的效果。
这是我使用它的方式:
public IEnumerable<Order> MakeOrderReport(string userName) =>
this.connFactory.MakeDbConnection().ToState(userName)
.Bind((s, x) => this.customerRepo.GetCustomer(s, x))
.Bind((s, x) => this.orderRepo.GetCustomreOrders(s, x.Id))
这里的想法是,状态对象作为 s 传递到链中,最后一个 Bind 的结果作为 x 传递进来。基于这两个值,你可以确定下一个值应该是什么。
这仅留下了更新当前状态的功能。我会用这个扩展方法来实现:
public static State<TS, TV>Update<TS,TV>(
this State<TS,TV> @this,
Func<TS, TS> f
) => new(@this.CurrentState, f(@this.CurrentState));
为了说明的简单例子,这里是一个简单的例子:
var result = 10.ToState(10)
.Bind((s, x) => s * x)
.Bind((s, x) => x - s) // s=10, x = 90
.Update(s => s - 5) // s=5, x = 90
.Bind((s, x) => x / 5); // s=5, x = 18
使用这种方法,你可以有箭头函数,带有几个状态位,这些状态位将从一个 Bind 操作流到下一个,需要时甚至可以更新。它可以防止你被迫将整洁的箭头函数转换为带大括号的完整函数,或者通过每个 Bind 传递一个大而笨重的只读数据元组。
这个实现已经去掉了你在 Haskell 中会找到的形式,即只有在定义完整的绑定链时才传递初始状态值,但我认为在 C# 上下文中,这个版本更有用,而且编码起来更容易!
也许一个状态?
你可能会注意到在上一个代码示例中,并没有使用 Bind 函数的功能,比如 Maybe,来捕获错误条件和返回的空结果在其中一个或多个可能状态中。是否可以将 Maybe 和 Reader 合并为一个单子,既持久化一个状态对象 又 处理错误?
是的,有几种方法可以实现它,具体取决于你打算如何使用它。我将展示我首选的解决方案。首先,我会调整 State 类,以便它不再存储一个值,而是存储一个 Maybe 包含的值。
public class State<TS, TV>
{
public TS CurrentState { get; init; }
public Maybe<TV> CurrentValue { get; init; }
public State(TS s, TV v)
{
CurrentValue = new Something<TV>(v);
CurrentState = s;
}
}
}
然后我会调整 Bind 函数,以考虑 Maybe,但不改变函数的签名:
public static State<TS, TNew> Bind<TS, TOld, TNew>(
this State<TS, TOld> @this, Func<TS, TOld, TNew> f) =>
new(@this.CurrentState, @this.CurrentValue.Bind(x => f(@this.CurrentState, x)));
使用方法几乎完全相同,只是现在 Value 的类型是 Maybe,而不只是 T。不过,这实际上只影响容器函数的返回值:
public Maybe<IEnumerable<order>> MakeOrderReport(string userName) =>
this.connFactory.MakeDbConnection().ToState(userName)
.Bind((s, x) => this.customerRepo.GetCustomer(s, x)
.Bind((s, x) => this.orderRepo.GetCustomerOrders(s, x.Id))
是否希望以这种方式合并 Maybe 和 State Monad 的概念,或者更愿意将它们分开,完全取决于您。
如果您遵循这种方法,您只需要确保在某个时候使用 Switch 表达式将 Maybe 转换为单一的具体值。
还有一件事也要记住 - State Monad 的 CurrentValue 对象不一定是数据,它也可以是一个 Func 委托,允许您在 Bind 调用之间传递一些功能性。
在接下来的部分中,我将探讨在 C# 中可能已经在使用的其他单子。
您已经在使用的示例
信不信由你,如果您已经使用 C# 工作了一段时间,那么您很可能已经在使用单子。让我们看看一些例子。
Enumerable
如果 Enumerable 不是一个单子,那么它几乎是最接近的了,至少一旦我们调用 LINQ - 正如我们已经知道的那样,LINQ 是基于函数编程概念开发的。
Enumerable 的 Select 方法在可枚举对象中操作各个元素,但它仍然遵循左恒等法则:
var op = x => x * 2;
var input = new [] { 100 };
var enumerableResult = input.Select(op);
var directResult = new [] { op(input.First()) };
// both equal the same value - { 200 }
和右恒等法则:
var op = x => x;
var input = new [] { 100 };
var enumerableResult = input.Select(op);
var directResult = new [] { op(input.First()) };
// both equal the same value - { 100 }
那么仅剩下结合律 - 它仍然是被认为是真正的单子所必需的。Enumerable 遵循这一法则吗?当然是的。通过使用 SelectMany。
考虑一下这个:
var createEnumerable = (int x) => Enumerable.Range(0, x);
var input = new [] { 100, 200 }
var output = input.SelectMany(createEnumerable);
// output = single dimension array with 300 elements
在这里,我们有嵌套的可枚举对象作为单个可枚举对象输出。这就是结合律。因此,QED,等等。是的。可枚举对象是单子。
Task
那么任务呢?它们也是单子吗?我敢打赌,如果你已经使用 C# 一段时间,它们绝对是单子,而且我可以证明。
让我们再次审视这些法则。
左恒等法则,使用任务的函数调用应该与直接调用函数调用匹配。证明这一点稍微有些棘手,因为异步方法始终返回 Task 或 Task<T> 类型 - 这在许多方面与 Maybe<T> 相同,如果你仔细想想的话。它是围绕可能或可能不会解析为实际数据的类型的包装器。但是,如果我们回到抽象层次,我认为我们仍然可以证明法则是遵守的:
public Func<int> op = x => x * 2;
public async Task<int> asyncOp(int x) => await Task.FromResult(op(x));
var taskResult = await asyncOp(100);
var nonTaskResult = op(100);
// The result is the same - 200
我并不是说这是我必须引以为豪的 C# 代码,但至少它确实证明了一个观点,即无论我是通过异步包装方法调用还是直接调用 op,结果都是相同的。这就是左恒等法则的确认。那么右恒等法则呢?老实说,这几乎是相同的代码:
// Notice the function is simply returning x back again unchanged this time.
public Func<int> op = x => x;
public async Task<int> asyncOp(int x) => await Task.FromResult(op(x));
var taskResult = await asyncOp(100);
var nonTaskResult = op(100);
// The result is the same as the initial input - 100
这就是恒等法则的解决方案。那么同样重要的结合律呢?信不信由你,我们可以用任务来演示这一点。
async Task<int> op1(int x) => await Task.FromResult(10 * x);
async Task<int> pp2() => await Task.FromResult(100);
var result = await op1(await pp2());
// result = 1,000
在这里,我们有一个将 Task<int> 作为参数传递给另一个 Task<int>,但是通过嵌套调用 await,可以将其平铺为一个简单的 int - 这才是实际的结果类型。
希望我赢得了我的啤酒?我的是一品脱¹¹,请。欧式风格的半升也可以。
其他结构
诚实地说——如果你对我的 Maybe 单子版本满意,并且不介意深入进一步,那么请随意跳到下一章节。你可以仅通过 Maybe 轻松实现你可能想要实现的大多数功能。我将描述一些存在于更广泛函数编程语言世界中的其他单子类型,你可能想考虑在 C# 中实现它们。
如果你打算从 C# 中挤出最后几个非函数式代码的残余,那么这些单子可能会引起你的兴趣。它们也可能会从理论的角度引起兴趣。然而,如果你想进一步探讨 Monad 概念并继续实现这些内容,完全取决于你。
现在,严格来说,我在本章和上一章中一直在构建的 Maybe 单子版本是两种不同单子的混合。
一个真正的 Maybe 单子只有两种状态——Something(或 Just)和 Nothing(或 Empty)。就是这样。用于处理错误状态的单子是 Either(也称为 Result)单子。它有两种状态——Left 和 Right。
Right 是“幸福”路径,其中每个传递给 Bind 命令的函数都有效,一切都是正确的。
Left 是“不幸”路径,表示发生了某种错误,而该错误包含在 Left 中。
左右命名惯例可能源自许多文化中的一个重要概念,即左手是邪恶的,右手是善良的。这甚至在我们的语言中也有所体现——拉丁语中“left”的字面意思就是“Sinister(邪恶)”。然而,在这些启蒙时代,我们不再驱逐左撇子¹²离开家园,或者无论他们以前做过什么。
我不会在这里详细描述这个实现,你可以通过采用我版本的 Maybe 并移除“Nothing”类基本实现它。
同样,你可以通过移除错误类(Error class)来创建一个真正的 Maybe——尽管我认为通过将两者合并成一个单一实体,你可以处理与外部资源交互时可能遇到的几乎所有情况。
我的方法是纯粹和完全正确的经典函数理论吗?不是。它在生产代码中有用吗?100% 是。
除了 Maybe 和 Either 之外,还有许多单子,如果你转向像 Haskell 这样的编程语言,你很可能会经常使用它们。以下是一些例子:
-
Identity — 一个单子,简单地返回你输入的任何值。当深入学习更纯粹函数理论的更深层时,这些单子有其用处,但在 C# 中并没有真正的应用场景。
-
State - 用于对一个值运行一系列操作。与
Bind方法有些类似,但也有一个状态对象被传递,作为额外的对象用于计算。在 C#中,我们可以使用 LINQ 的Aggregate函数,或者使用 Maybe 的Bind函数与元组或类似的结构一起传递必要的状态对象。 -
IO - 用于允许与外部资源交互而不引入不纯的函数。在 C#中,我们可以遵循控制反转模式(即依赖注入)来解决测试等问题。
-
环境 - 在 Haskell 中被称为 Reader 单子。通常用于像日志记录这样的过程,以封装掉副作用。如果你试图确保你的语言正在强制执行函数式编程的严格规则,这是有用的,但我在 C#中看不到任何好处。
正如你从上面的列表中看到的,函数式编程世界中有许多其他的单子,但我认为大多数或全部都对我们没有实际好处。归根结底,C#是一种混合的函数式/面向对象语言。它已经扩展到支持函数式编程概念,但它永远不会成为纯函数式语言,也没有尝试将其视为这样的好处。
我强烈建议尝试使用 Maybe/Either 单子,但除此之外,我实在不会打扰,除非你对在 C#中的函数式编程的想法有兴趣,看看你能推动这个想法到什么程度¹³。不过,这不适用于你的生产环境。
在最后一节中,我将提供一个完整的示例,展示如何在应用程序中使用单子。
一个示例
好的,我们开始吧。让我们把所有内容整合到一个伟大而史诗般的单子功能堆中。我们在本章的示例中已经谈论过假期,所以这次我要集中讨论我们实际上如何去机场 - 这将需要一系列的查找、数据转换,所有这些通常需要错误处理和分支逻辑,如果我们要遵循更传统的面向对象方法。我希望您会同意,使用函数式编程技术和单子,看起来要优雅得多。
首先,我们需要我们的接口。我实际上不会编写每一个我们的代码所需的依赖项,所以我只是定义我们将需要的接口:
public interface IMappingSystem
{
Maybe<Address> GetAddress(Location l);
}
public interface IRoutePlanner
{
Task<Maybe<Route>> DetermineRoute(Address a, Address b);
}
public interface ITrafficMonitor
{
Maybe<TrafficAdvice> GetAdvice(Route r);
}
public interface IPricingCalculator
{
decimal PriceRoute(Route r);
}
做完这些,我将编写代码来消耗它们。我想象中的具体场景是这样的 - 这是不久的将来。无人驾驶汽车已经成为一种事物。以至于大多数人不再拥有个人车辆,而只需使用手机上的应用程序从自动驾驶汽车的云中直接将车辆带到他们的家中¹⁴。
这个过程大致如下:
-
最初的输入是用户提供的起始位置和目的地。
-
需要查找映射系统中的每个位置,并转换为正确的地址。
-
需要从内部数据存储中获取用户的账户。
-
路由需要通过交通服务进行检查。
-
必须调用定价服务来确定旅程的费用。
-
价格返回给用户。
在代码中,这个过程可能看起来像这样:
public Maybe<decimal> DeterminePrice(Location from, Location to)
{
var addresses = this.mapping.GetAddress(from).Bind(x =>
(From: x,
To: this.mapping.GetAddress(to)));
var route = await addresses.BindAsync (async x => await this.router.DetermineRoute(x.From, x,To));
var trafficInfo = route.Bind(x => this.trafficAdvisor.GetAdvice(x));
var hasRoadWorks = trafficInfo is Something<TrafficAdvice> s &&
s.Value.RoadworksOnRoute;
var price = route.Bind(x => this.pricing.PriceRoute(x));
var finalPrice = route.Bind(x => hasRoadWorks ? x *= 1.1 : x);
return finalPrice;
}
这样更容易,不是吗?在我结束本章之前,我想解释一下代码示例中发生的一些细节。
首先,这里没有任何错误处理。任何这些外部依赖可能导致错误被抛出,或者在它们各自的数据存储中找不到详细信息。Monad Bind 函数处理所有这些逻辑。例如,如果路由器无法确定路由(可能发生网络错误),那么此时 Maybe 将被设置为 Error<Route>,并且不会执行后续操作。最终的返回类型将是 Error<decimal>,因为 Error 类在每一步都会重新创建,但实际的 Exception 在实例之间传递。外部世界负责处理最终返回的值,直到那时为止。
如果我们按照面向对象的方法编写此代码,那么该函数很可能会长两到三倍,以包括 Try/Catch 块和针对每个对象的检查以确认它们是否有效。
我在需要建立一组输入的情况下使用了元组。对于地址对象的情况,这意味着如果第一个地址找不到,那么不会尝试查找第二个地址。这也意味着第二个函数所需的两个输入都在一个地方可用,然后我们可以使用另一个 Bind 调用来访问它们(假设地址查找返回了一个真实的值)。
最后几个步骤实际上并不涉及对外部依赖的调用,但通过继续使用 Bind 函数,可以假定其参数 Lambda 表达式内部有一个真实值可用,因为如果没有,Lambda 就不会被执行。
至此,我们几乎有一个完全功能的 C# 代码片段了。希望你喜欢。
结论
在本章中,我们探讨了一个可怕的函数式编程概念,已经众所周知,它使成年开发人员在他们廉价的鞋子上颤抖。一切顺利的话,这对你来说应该不再是一个谜。
我已经向你展示了如何:
-
大幅减少所需代码量。
-
介绍一个隐式错误处理系统。
所有都使用 Maybe Monad,以及如何自己创建一个。
在下一章中,我将简要讨论柯里化的概念。我们在下一页见。
¹ 我不知道为什么会使用那个名字。真的不知道。尽管如此,这是相当普遍的,也是一种标准。请稍作等待。
² 我会说非空值,但整数默认为 0,布尔值默认为 false。
³ 查看这里阅读文章,这绝对值得您花时间:https://fsharpforfunandprofit.com/rop/
⁴ 或活着的薛定谔的猫。实际上,这是安全的选择吗?我养过猫,我知道它们是什么样子!
⁵ 最好是芝士蛋糕。保罗·荷莱伍德会很失望地得知我不喜欢很多种蛋糕,但纽约风格的芝士蛋糕绝对是我喜欢的一种!
⁶ 您可以在这里阅读更多信息:https://automapper.org/ 如果在您的代码中经常需要快速简单地在类型之间进行转换,它是非常有用的工具。
⁷ 不是一种茶!也不是《龙珠》中的一个猪角色。
⁸ 除了布尔值,默认为 false,和字符值默认为*\0*。
⁹ 我是虚构的所有者,你可以是虚构的帮助客户找到他们想要的东西的人。我真是慷慨呢,不是吗?
¹⁰ 我喜欢黑色艾尔啤酒和欧式风格的拉格啤酒。他们在明尼苏达州酿造的浓烈的东西也非常棒。
¹¹ 那是 568 毫升。我知道其他国家对这个词有不同的定义。
¹² 其中包括我的兄弟。嗨,马克 - 你在一本编程书中被提到了!我想知道你是否会知道这件事?
¹³ C# 而不是.NET 的普遍性 - F# 也是.NET。
¹⁴ 对我来说这听起来都很棒,我不太喜欢开车,而且如果我是乘客,我会非常晕车。我非常欢迎我们的无人驾驶汽车统治者。
第八章:Currying 和 部分应用
Currying 和 部分应用 是从古老数学论文中直接衍生出来的两个更多的函数式概念。前者与印度食物毫不相关(尽管它确实很美味)¹,事实上,它是以卓越的美国数学家 Haskell Brooks Curry 命名的,他命名了不少于三种编程语言²。
Currying 源自 Haskell Curry 在组合逻辑上的工作,这为现代函数式编程提供了一个基础之一。我不会给出干燥的正式定义,我会通过例子来解释。这是一个有点像 C# 伪代码的加法函数示例:
public interface ICurriedFunctions
{
decimal Add(decimal a, decimal b);
}
var curry = // some logic for obtaining an implementation of the interface
var answer = curry.Add(100, 200);
在这个例子中,我们期望 answer 简单地是 300(即 100+200),这确实是它会得到的结果。
不过,如果我只提供一个参数呢?像这样:
public interface ICurriedFunctions
{
decimal Add(decimal a, decimal b);
}
var curry = // some logic for obtaining an implementation of the interface
var answer = curry.Add(100); // What could it be?
在这种情况下,如果这是一个假设的柯里化函数,你认为 answer 会返回什么?
在函数式编程中,我制定了一个经验法则 - 如果有一个问题,答案可能是“函数”。这在这里是适用的情况。
如果这是一个柯里化函数,那么 answer 变量将是一个函数。它将是原始 Add 函数的修改版本,但现在第一个参数已经固定为值 100 - 实际上这是一个新函数,它将 100 加到你提供的任何值上。
你可以像这样使用它:
public interface ICurriedFunctions
{
decimal Add(decimal a, decimal b);
}
var curry = // some logic for obtaining an implementation of the interface
var add100 = curry.Add(100); // Func<decimal,decimal>, adds 100 to the input
var answerA = add100(200); // 300 -> 200+100
var answerB = add100(0); // 100 -> 0+100
var answerC = add100(900); // 1000 -> 900+100
基本上,这是一种从具有多个参数的函数开始,并从中创建多个更具体版本的方法。一个单一的基本函数可以成为许多不同的函数。如果你愿意的话,可以将它与 OO 概念的继承进行比较?但实际上它与继承完全不一样。实际上只有一个具有任何逻辑的基本函数 - 其余实际上是指向该基本函数的带参数的指针,准备向其输入。
不过,Currying 究竟有什么用?你怎么使用它?
让我解释一下……
Currying 和大型函数
在上面我提供的“添加”示例中,我们只有一对参数,因此在可能进行 Currying 时,我们只有两种可能的处理方式:
-
提供第一个参数,获取一个函数
-
提供两个参数并获得一个值
Currying 如何处理具有超过 2 个基本参数的函数?为此,我将使用一个简单的 CSV 解析器的示例 - 即一个接收 CSV 文本文件,按行分割记录,然后使用一些分隔符(通常是逗号)再次分割记录内的各个属性。
让我们想象我写了一个用于加载一批书籍数据的解析器函数:
// Input in the format:
//
//title,author,publicationDate
//The Hitch-Hiker's Guide to the Galaxy,Douglas Adams,1979
//Dimension of Miracles,Robert Sheckley,1968
//The Stainless Steel Rat,Harry Harrison,1957
//The Unorthodox Engineers,Colin Kapp,1979
public IEnumerable<Book> ParseBooks(string fileName) =>
File.ReadAllText(fileName)
.Split("\r\n")
.Skip(1) // Skip the header
.Select(x => x.split(",").ToArray())
.Select(x => new Book
{
Title = x[0],
Author = x[1],
PublicationDate = x[2]
});
var bookData = parseBooks("books.csv");
这一切看起来都很好,但接下来的两组书籍有不同的格式。Books2.csv 使用管道符号而不是逗号分隔字段,而 Books3.csv 来自 Linux 环境,其行结束符是 "\n" 而不是 Windows 风格的 "\r\n"。
我们可以通过创建三个几乎相同的函数来解决这个问题。不过,我不喜欢不必要的复制,因为这会给未来想要维护代码库的开发人员带来太多问题。
一个更合理的解决方案是为可能发生变化的每一项添加参数,就像这样:
public IEnumerable<Book> ParseBooks(
string lineBreak,
bool skipHeader,
string fieldDelimiter,
string fileName
) =>
File.ReadAllText(fileName)
.Split(lineBreak)
.Skip(skipHeader ? 1 : 0)
.Select(x => x.split(fieldDelimiter).ToArray())
.Select(x => new Book
{
Title = x[0],
Author = x[1],
PublicationDate = x[2]
});
var bookData = ParseBooks(Environment.NewLine, true, ",", "books.csv");
现在,如果我想按照非函数化的方法使用这个函数,我将不得不填写每个可能的 CSV 文件风格的所有参数,就像这样:
var bookData1 = ParseBooks(Environment.NewLine, true, ",", "books.csv");
var bookData2 = ParseBooks(Environment.NewLine, true, "|", "books2.csv");
var bookData3 = ParseBooks("\n", false, ",", "books3.csv");
实际上,柯里化意味着逐个提供参数。对柯里化函数的任何调用都会产生一个新函数,该新函数的参数少一个,或者如果所有基本函数的参数都已提供,则产生一个具体值。
来自前一个代码示例中已提供参数的调用可以替换成这样:
// First some magic that curries the parseBooks function
// I'll look into implementation details later, let's just
// understand the theory for now.
var curriedParseBooks = ParseBooks.Curry();
// these two have 3 parameters - string, string, string
var parseSkipHeader = curriedParseBooks(true);
var parseNoHeader = curriedParseBooks(false);
// 2 parameters
var parseSkipHeaderEnvNl = parseSkipHeader(Environment.NewLine);
var parseNoHeaderLinux = parseNoHeader("\n");
// 1 parameter each
var parseSkipHeaderEnvNlCommarDel = parseSkipHeaderEnvNl(",");
var parseSkipHeaderEnvNlPipeDel = parseSkipHeaderEnvNl("|");
var parseNoHeaderLinuxCommarDel = parseNoHeaderLinux(",");
// Actual data, Enumerables of Book data
var bookData1 = parseSkipHeaderEnvNlCommarDel("books.csv");
var bookData2 = parseSkipHeaderEnvNlPipeDel("books2.csv");
var bookData3 = parseNoHeaderLinuxCommarDel("books3.csv");
关键在于,柯里化将具有 X 个参数的函数转变为 X 个函数的序列,每个函数只有一个参数 - 最后一个函数返回最终结果。
如果你真的非常想的话,你甚至可以像上面那样写出这些函数调用!
var bookData1 = parseBooks(true)(Environment.NewLine)(",")("books.csv")
var bookData2 = parseBooks(true)(Environment.NewLine)("|")("books2.csv")
var bookData3 = parseBooks(true)("\n")(",")("books3.csv")
函数柯里化的第一个示例的要点是,我们逐步构建一个超特定版本的函数,它只接受文件名作为参数。除此之外,我们还将所有中间版本存储起来,以便在构建其他函数时重复使用。
我们实际上在这里做的是像用乐高积木搭建墙壁一样构建函数,其中每个积木都是一个函数。或者,如果你想从另一个角度来考虑,这是一个函数家族树,每个阶段的选择都导致家族中的一个分支:
图 8-1. A 解析书籍函数的家族树
另一个可能在实际中有用的例子是将日志函数分割为多个更具体的函数:
// For the sake of this exercise, the parameters are
// an enum (log type - warning, error, info, etc.) and a string
// containing a message to store in the log file
var logger = getLoggerFunction()
var curriedLogger = logger.Curry();
var logInfo = curriedLogger(LogLevel.Info);
var logWarning = curriedLogger(LogLevel.Warning);
var logError = curriedLogger(LogLevel.Error);
// You'd use them then, like this:
logInfo("This currying lark works a treat!");
这种方法有几个有用的特性:
-
事实上,我们最终只创建了一个单一的函数,但是从这个函数中,我们设法创建了至少 3 个可用的变体,这些变体可以传递并且只需一个文件名即可使用。这将代码复用提升到了一个新的水平!
-
还有所有中间函数也是可用的。这些函数可以直接使用,也可以用作创建额外新函数的起点。
在 C# 中,柯里化还有另一个用途。我将在下一节中讨论这个问题。
柯里化和高阶函数
如果我想用柯里化来创建几个函数来在摄氏度和华氏度之间转换,我会从像这样的柯里化基本算术操作开始:
// once again, the Currying process is just magic for now.
// Keep reading for the implementation
var add = ((x,y) => x + y).Curry();
var subtract = ((x,y) => y - x).Curry();
var multiply = ((x,y) => x * y).Curry();
var divide = ((x,y) => y / x).Curry();
使用这个方法,再加上前一章的映射函数,我们可以创建一组相当简洁的函数定义:
var celsiusToFahrenheit = x =>
x.Map(multiply(9))
.Map(divide(5))
.Map(add(32));
var fahrenheitToCelsius = x=>
x.Map(subtract(32))
.Map(multiply(5))
.Map(divide(9));
是否发现任何有用的内容很大程度上取决于用例 - 你实际上想要实现什么以及柯里化是否适用于它。
现在你可以在 C# 中使用它,就像你看到的那样。只要我们能找到在 C# 中实现它的方法……
在 .NET 中进行柯里化
所以,重要的问题是:更多基于函数式的语言可以在代码库中所有函数中本地实现这一点,那么在 .NET 中我们能做类似的事情吗?
简短的答案是差不多吧。
更长的答案是是的,有点。虽然不像在函数式语言(例如 F#)中那样优雅,那里这些功能都是开箱即用的。我们需要硬编码、创建静态类,或者在语言中稍微蹒跚并跳过一些 hoops。
硬编码的方法假设你只会以柯里化的方式使用函数,就像这样:
var Add = (decimal x) => (decimal y) => x + y;
var Subtract = (decimal x) => (decimal y) => y - x;
var Multiply = (decimal x) => (decimal y) => x * y;
var Divide = (decimal x) => (decimal y) => y / x;
注意每个函数中有两组箭头,意味着我们定义了一个返回另一个 Func 委托的 Func 委托 - 即实际类型是 Func<decimal, Func<decimal, decimal>>。只要你使用的是 C# 10 或更高版本,你就能利用 var 关键字隐式地获取类型,就像上面的例子一样。较旧版本的 C# 可能需要在代码示例中显式声明委托的类型。
第二个选项是创建一个静态类,可以在代码库中的任何地方引用。你可以随意命名它,但我选择用 F 来表示函数式。
public static class CurryingExtensions
{
public static Func<T1, Func<T2, TOut>> Curry<T1, T2, TOut>(
Func<T1, T2, TOut> functionToCurry) =>
(T1 x) => (T2 y) => functionToCurry(x, y);
public static Func<T1, Func<T2, Func<T3, TOut>>> Curry<T1, T2, T3, TOut>(
Func<T1, T2, T3, TOut> functionToCurry) =>
(T1 x) => (T2 y) => (T3 z) => functionToCurry(x, y, z);
public static Func<T1, Func<T2, Func<T3, Func<T4, TOut>>>> Curry<T1, T2, T3, T4, TOut>(
Func<T1, T2, T3, T4, TOut> functionToCurry) =>
(T1 x) => (T2 y) => (T3 z) => (T4 a) => functionToCurry(x, y, z, a);
}
这实际上在调用被柯里化的最终函数和使用它的代码区域之间放置了多层 Func 委托。
这种方法的缺点是,我们必须为每种可能的参数数量创建一个柯里化方法。我的示例涵盖了具有 2、3 或 4 个参数的函数。具有更多参数的函数需要构建另一个 Curry 方法,根据相同的公式。
另一个问题是,Visual Studio 无法隐式确定传入的函数的类型,因此有必要在调用 F.Curry 时定义被柯里化的函数,并声明每个参数的类型,就像这样:
var Add = F.Curry((decimal x, decimal y) => x + y);
var Subtract = F.Curry((decimal x, decimal y) => y - x);
var Multiply = F.Curry((decimal x, decimal y) => x * y);
var Divide = F.Curry((decimal y, decimal y) => y / x);
最后一个选项 - 也是我更喜欢的选项 - 是使用扩展方法来减少必需的样板代码。对于具有 2、3 和 4 个参数的函数,定义如下:
public static class Ext
{
public static Func<T1,Func<T2, T3>> Curry<T1,T2,T3>(
this Func<T1,T2,T3> @this) =>
(T1 x) => (T2 y) => @this(x, y);
public static Func<T1,Func<T2,Func<T3,T4>>>Curry<T1,T2,T3,T4>(
this Func<T1,T2,T3,T4> @this) =>
(T1 x) => (T2 y) => (T3 z) => @this(x, y, z);
public static Func<T1,Func<T2,Func<T3,Func<T4,T5>>>>Curry<T1,T2,T3,T4,T5>(
this Func<T1,T2,T3,T4,T5> @this) =>
(T1 x) => (T2 y) => (T3 z) => (T4 a) => @this(x, y, z, a);
}
那是一个相当丑陋的代码块,不是吗?好消息是,你可以把它放在代码库的深处,并且大部分时间都可以忘记它的存在。
使用方式如下:
// specifically define the function on one line
// it has to be stored as a `Func` delegate, rather than a
// Lambda expression
var Add = (decimal x, decimal y) => x + y;
var CurriedAdd = Add.Curry();
var add10 = CurriedAdd(10);
var answer = add10(100);
// answer = 110
那就是柯里化。你们中眼尖的可能已经注意到,这一章被称为“柯里化和部分应用”。
什么是部分应用?嗯……既然你这么客气地问了……
部分应用
部分应用的工作原理与柯里化非常类似,但它们之间有微妙的区别。这两个术语经常被误用,互换使用。
Currying 专门 处理将带有一组参数的函数转换为一系列连续的函数调用,每个调用只有一个参数(技术术语是 一元 函数)。
部分应用,另一方面,允许您一次应用尽可能多的参数。有数据出现如果所有的参数都填写完毕。
返回到我之前的解析函数示例,这些是我们正在处理的格式:
-
book1 - Windows 行结束,头部,字段用逗号
-
book2 - Windows 行结束,头部,字段用管道符号
-
book3 - Linux 行结束,没有头部,字段用逗号
使用柯里化方法,我们为设置 Book3 的每个参数创建了中间步骤,即使它们最终只用于每个参数的唯一用途。我们还为 book1 和 book2 的 SkipHeader 和行结束参数做了同样的事情,尽管它们是相同的。
可以这样做来节省空间:
var curriedParseBooks = parseBooks.Curry();
var parseNoHeaderLinuxCommaDel = curriedParseBooks(false)("\n")(",");
var parseWindowsHeader = curriedParseBooks(true)(Environment.NewLine);
var parseWindowsHeaderComma = parseWindowsHeader(",");
var parseWindowsHeaderPipe = parseWindowsHeader("|");
// Actual data, Enumerables of Book data
var bookData1 = parseWindowsHeaderComma("books.csv");
var bookData2 = parseWindowsHeaderPipe("books2.csv");
var bookData3 = parseNoHeaderLinuxCommaDel("books3.csv");
但是,如果我们能够简洁地使用部分应用来应用这 2 个参数,那就更清晰了。
// I'm using an extension method called Partial to apply
// parameters. Check out the next section for implementation details
var parseNoHeaderLinuxCommarDel = ParseBooks.Partial(false,"\n",",");
var parseWindowsHeader =
curriedParseBooks.Partial(true,Environment.NewLine);
var parseWindowsHeaderComma = parseWindowsHeader.Partial(",");
var parseWindowsHeaderPipe = parseWindowsHeader.Partial("|");
// Actual data, Enumerables of Book data
var bookData1 = parseWindowsHeaderComma("books.csv");
var bookData2 = parseWindowsHeaderPipe("books2.csv");
var bookData3 = parseNoHeaderLinuxCommarDel("books3.csv");
我认为这是一个相当优雅的解决方案,它仍然允许我们在需要时具有可重用的中间函数,但仍然只有一个基本函数。
在下一节中,我将向您展示如何实际实现这一点。
.NET 中的部分应用
这是个坏消息。没有任何一种方式可以优雅地在 C# 中实现部分应用。您需要做的是为每个参数组合创建一个扩展方法。
在我刚刚给出的示例中,我需要:
-
4 个参数变成 1 个参数,用于
parseNoHeaderLinuxCommaDel -
4 个参数变成 2 个参数,用于
parseWindowsHeader -
2 个参数变成 1 个参数,用于
parseWindowsHeaderComma和parseWindowsHeaderPipe
这是每个示例将会是什么样子:
public static class PartialApplicationExtensions
{
// 4 parameters to 1
public static Func<T4,TOut> Partial<T1,T2,T3,T4,TOut>(
this Func<T1,T2,T3,T4,TOut> f,
T1 one, T2 two, T3 three) => (T4 four) => f(one, two, three, four);
// 4 parameters to 2
public static Func<T3,T4,TOut>Partial<T1,T2,T3,T4,TOut>(
this Func<T1,T2,T3,T4,TOut> f,
T1 one, T2 two) => (T3 three, T4 four) => f(one, two, three, four);
// 2 parameters to 1
public static Func<T2, TOut> Partial<T1,T2,TOut>(
this Func<T1,T2,TOut> f, T1 one) =>
(T2 two) => f(one, two);
}
如果您决定部分应用是一种您想要追求的技术,那么您可以根据需要将部分方法添加到代码库中,或者将一段时间留出来创建您可能需要的尽可能多的部分方法。
结论
Currying 和部分应用是函数式编程中两个强大且相关的概念。不幸的是,它们在 C# 中并不原生支持,并且不太可能会支持。
它们可以通过使用静态类或扩展方法来实现,这为代码库增加了一些样板代码 - 这有些讽刺,考虑到这些技术部分是为了减少样板。
鉴于 C# 不像 F# 和其他函数式语言那样完全支持高阶函数。C# 不能像转换为 Func 委托那样传递函数。
即使函数被转换为 Func,Roslyn 编译器也不能始终正确确定参数类型。
在 C#领域,这些技术可能永远不如其他语言那么有用。尽管如此,它们在减少模板代码和实现比其他方式更高的代码可重用性方面仍然有其用处。
是否使用它们是个人偏好的问题。我不认为它们对于功能型 C#是必需的,但还是值得探索的。
在我们的下一章中,我们将探索在功能型 C#中无限循环的更深层奥秘,以及什么是尾递归调用。
¹ 美食提示:如果你曾经来到孟买,一定要尝试在 Shivaji Park 的 Tibb’s Frankie,你不会后悔的!
² 显然是 Haskell,但还有 Brook 和 Curry 这两种不那么出名的语言。
第九章:无限循环
我们在前几章中看到函数式编程如何用 LINQ 函数如Select或Aggregate替换For和ForEach循环。这绝对是太棒了 - 前提是您正在处理固定长度的数组或者一个Enumerable,它会自行决定何时结束迭代。
如果你根本不确定你想要迭代多久怎么办?如果你一直迭代直到满足某个条件呢?
这里有一个非功能性代码的例子,展示了我所说的那种事情,基于 Monopoly 棋盘游戏的松散基础。想象一下,你被关在监狱里,你这个淘气鬼!有以下几种方法可以让你脱困:
-
以您所在的货币支付 50(如果您在美国,则为 50 美元,或者在英国的我这里为 50 英镑¹)
-
掷一个双子
-
使用一个“出狱卡”
在真实的 Monopoly 游戏中,还需要考虑其他玩家的回合,但我简化为无限循环,直到满足其中一个条件。如果我真的这么做了,我可能会在这里添加一些验证逻辑,但再次强调,我要保持简单。
var inJail = true;
var inventory = getInventory();
var rnd = getRandomNumberGenerator();
while(inJail)
{
var playerAction = getAction();
if(playerAction == Actions.PayFine)
{
inventory.Money -= 50;
inJail = false;
}
else if(playerAction == Actions.GetOutOfJailFree)
{
inventory.GetOutOfJailFree -= 1;
inJail = false;
}
else if(playerAction == Actions.RollDice)
{
var dieOne = rnd.Random(1, 6);
var dieTwo = rnd.Random(1,6);
inJail = dieOne != dieTwo; // Stay in jail if the dice are different
}
}
您无法使用Select语句执行上述操作。我们无法确定何时满足条件,因此我们将继续在While循环中迭代,直到其中一个条件被满足。
那么我们如何使其功能化?While循环是一个语句(具体说来是一个控制流语句),因此它不被函数式编程语言所青睐。
有几种选择,我会逐一描述每一种,但这是一个需要做出某种权衡的领域。每个选择都有后果,我会尽量考虑它们各自的利弊。
系好你的安全带,我们出发吧…
递归
处理无限循环的经典函数式编程方法是使用递归。简而言之,对于那些不熟悉的人 - 递归是使用调用自身的函数。还会有某种条件确定是否应该进行另一次迭代,或者是否实际返回数据。
如果决策是在递归函数的末尾做出的,这被称为尾递归。
解决 Monopoly 问题的纯递归解决方案可能如下所示:
public record Inventory
{
public int Money { get; set; }
public int GetOutOfJail { get; set; }
}
// I'm making the Inventory object a Record to make it
// a bit easier to be functional
var inventory = getInventory();
var rnd = getRandomNumberGenerator();
var updatedInventory = GetOutOfJail(inventory);
private Inventory GetOutOfJail(Inventory oldInv)
{
var playerAction = getAction();
return playerAction switch
{
Actions.PayFine => oldInv with
{
Money = oldInv.Money - 50
},
Actions.GetOutOfJailFree => oldInv with
{
GetOutOfJail = oldInv.GetOutOfJail - 1
},
Actions.RollDice =>
{
var dieOne = rnd.Random(1, 6);
var dieTwo = rnd.Random(1,6);
// return unmodified state, or else
// iterate again
return dieOne == dieTwo
? oldInv
: GetOutOfJail(oldInv);
}
};
}
任务完成了,对吧?嗯,并不完全是这样,我在使用上面的这种函数之前会非常谨慎地考虑。问题在于每个嵌套的函数调用都会在.NET 运行时的堆栈中添加一个新项目,如果递归调用很多,那么这可能会对性能产生负面影响,或者用堆栈溢出异常终止应用程序。
如果确保只有少数迭代,那么递归方法基本上没有什么问题。你还必须确保,如果代码的使用在增强后发生了显著变化,这一点会被重新考虑。可能会出现这样的情况,有一天这个很少使用的函数在某天变成了一个大量迭代的重度使用函数。如果这种情况发生了,业务可能会想知道为什么他们的优秀应用突然变得接近无响应。
所以,正如我所说的,在使用 C#中的递归算法之前,请非常谨慎。这样做的优点是相对简单,而且不需要编写任何样板代码来实现它。
注意
F#以及许多其他更强调函数式的语言,具有称为尾递归调用优化的特性,这意味着可以编写递归函数而不会使堆栈溢出。然而,这在 C#中是不可用的,并且将来也没有计划将其提供。根据情况,F#优化将创建带有while(true)循环的中间语言(IL)代码,或者利用一个称为goto的 IL 命令将执行环境的指针物理地移回循环的开始位置。
我确实调查了从 F#引用通用的尾递归调用,并通过编译的 DLL 将其暴露给 C#的可能性,但这会有自己的性能问题,使其成为一种徒劳的努力。
我在网上看到另一个可能性的讨论,那就是添加一个后构建事件,直接操作 C#编译成的.NET 中间语言,使其回顾性地使用 F#的尾部优化特性。这非常聪明,但对我来说听起来太像辛苦的工作了。这也可能是一个额外的维护任务。
在接下来的部分中,我将探讨一种在 C#中模拟尾递归调用优化的技术。
立体跳板(Trampolining)
我并不完全确定术语Trampolining的起源,但它早于.NET。我能找到的最早参考文献是 90 年代的学术论文,研究在 C 中实现 LiSP 的一些特性。我猜这个术语甚至比那还要年长一些。
基本思想是你有一个以thunk作为参数的函数 - thunk是存储在变量中的一段代码。在 C#中,这些是作为Func或Action实现的。
得到thunk后,你创建一个带有while(true)的无限循环,并且通过某种方式评估一个条件,以确定循环是否应该终止。这可以通过返回bool的额外Func或者某种需要通过thunk在每次迭代中更新的包装对象来完成。
但归根结底,我们看到的基本上是在代码库的后面隐藏了一个while循环。while并非纯函数式,但这是我们可能需要妥协的地方之一。从根本上讲,C#是一种混合语言,支持 OO 和 FP 范式。总会有一些地方无法像 F#那样精确地控制其行为。这就是其中之一。
有几种方法可以实现 trampolining,但我倾向于选择这种方法:
public static class FunctionalExtensions
{
public static T IterateUntil<T>(
this T @this,
Func<T, T> updateFunction,
Func<T, bool> endCondition)
{
var currentThis = @this;
while (!endCondition(currentThis))
{
currentThis = updateFunction(currentThis);
}
return currentThis;
}
}
通过附加到类型T,它是一个通用的,这个扩展方法因此会附加到 C#代码库中的所有内容。第一个参数是一个Func委托,它更新T代表的类型到基于外部定义的任何规则的新形式。第二个是另一个Func,它返回导致循环终止的条件。
由于这是一个简单的While循环,不存在堆栈大小的问题。虽然它不是纯函数式编程,但这是一个折衷的方案。至少,它是代码库中深藏的一个while循环实例。也许有一天,微软会发布一个新功能,以便以某种方式实现适当的尾递归调用优化,那么这个函数就可以重新实现,代码应该继续像之前一样工作,但少一个命令式代码特性实例。
使用这个版本的不定迭代,Monopoly 代码现在看起来像这样:
// we need everything required to both update and
// assess whether we should continue or not in a
// single object, so I'm considering it "state" rather than
// simply inventory
var playerState = geState();
var rnd = getRandomNumberGenerator();
var playerState.IterateUntil(x => {
var action = GetAction();
return action switch
{
Actions.PayFine => x with
{
Money = x.Money - 50,
LastAction = action
},
Actions.GetOutOfJailFree => x with
{
GetOutOfJail = x.GetOutOfJail - 1,
LastAction = action
},
_ => x with
{
DieOne = rnd.Random(1, 6),
DieTwo = rnd.Random(1, 6)
}
}
},
x => x.LastAction == Actions.PayFine ||
x.LastAction == Actions.GetOutOfJailFree ||
x.DieOne == x.DieTwo
);
还有另一种你可以用来实现 - Trampolining。从功能上看,它的行为与隐藏的while循环相同,性能方面也基本相同。
我并不确定是否有额外的好处,而且个人感觉它看起来比while循环不那么友好,但它可能略微更符合函数式编程范式,因为它省去了while语句。
如果你喜欢,可以使用这个,但在我看来,这是个人喜好的问题。
这个版本使用了一个 C#命令,通常在任何其他情况下我都极力建议你不要使用。自从 BASIC 时代以来,这种编码方式一直存在,今天仍以某种形式存在 - goto命令。
在 BASIC 中,你可以通过调用goto并指定行号来移动到任意的代码行。这通常是在 BASIC 中实现循环的方式。但是在 C#中,你需要创建标签,而goto只能移动到这些标签处。
这是使用两个标签重新实现IterateUntil的方法。一个称为LoopBeginning,相当于while循环开头的*{字符。第二个标签称为LoopEnding*,表示循环的结束,或while循环的*}*字符。
public static T IterateUntil<T>(
this T @this,
Func<T, T> updateFunction,
Func<T, bool> endCondition)
{
var currentThis = @this;
LoopBeginning:
currentThis = updateFunction(currentThis);
if(endCondition(currentThis))
goto LoopEnding;
goto LoopBeginning;
LoopEnding:
return currentThis;
}
我会让你决定你更喜欢哪个版本。它们几乎是等效的。但无论你做什么,绝对不要在代码中的任何其他地方使用goto,除非你绝对,完全,彻底地知道你在做什么,以及为什么没有更好的选择。
像某位爱蛇,没鼻子的邪恶巫师 - goto命令既伟大又可怕,如果不明智地使用。
它很强大,可以通过其他任何方式无法实现的方式创建效果,并在某些情况下提高效率。但它也很危险,因为在执行期间,指针可以跳转到代码库中的任意点,而不管这样做是否有任何意义。如果使用不当,您可能会在代码库中遇到难以解释和难以调试的问题。
请非常谨慎地使用goto语句。
还有第三个选项,需要相当多的样板代码,但最终看起来比之前的版本友好一些。
看一看,看看你的想法如何。
自定义迭代器
第三个选项是在IEnumerables和IEnumerators上进行调试。关于IEnumerables的一点是它们实际上并不是数组,它们只是指向数据“当前”项的指针,并包含如何获取下一项的指令。因此,我们可以创建自己的IEnumerable接口实现,但具有自己的行为。
在我们的大富翁示例中,我们希望有一个IEnumerable,它将迭代直到用户选择了一个方法来摆脱监狱,或者投掷了双倍。
我们首先创建IEnumereable的实现,它只有一个必须实现的函数:GetEnumerator()。IEnumerator是实际进行枚举工作的类,这是我们接下来要讨论的内容。
枚举器的解剖
这实际上就是IEnumerator接口的样子(它继承了一些其他接口的函数,因此这里有一些函数是为了满足继承要求而存在的):
public interface IEnumerator<T>
{
object Current { get; }
object IEnumerator.Current { get; }
void Dispose();
bool MoveNext();
void Reset();
}
每个这些函数都有一个非常具体的工作要做:
表 9-1. IEnumerator 的组成函数
| Function | Behavior | Returns |
|---|---|---|
| Current | 获取当前数据项 | 当前项,或者如果迭代尚未开始则为 null |
| IEnumerator.Current | 作为当前项,这是从 IEnumerable 实现的另一个接口引用的相同项 | 作为当前项 |
| Dispose | 将 Enumerable 中的所有内容拆分以实现 IEnumerator | Void |
| MoveNext | 移动到下一项 | 如果找到另一个项则为 true,如果枚举过程完成则为 false |
| Reset | 回到数据集的开头 | void |
大多数情况下,Enumerator只是在数组上进行枚举,这种情况下,我想实现可能应该大致如下:
public class ArrayEnumerable<T> : IEnumerator<T>
{
public readonly T[] _data;
public int pos = -1;
public ArrayEnumerable(T[] data)
{
this._data = data;
}
private T GetCurrent() => this.pos > -1 ? _data[this.pos] : default;
T IEnumerator<T>.Current => GetCurrent();
object IEnumerator.Current => GetCurrent();
public void Dispose()
{
// Run! Run for your life!
// Run before the GC gets us all!
}
public bool MoveNext()
{
this.pos++;
return this.pos < this._data.Length;
}
public void Reset()
{
this.pos = -1;
}
}
我认为微软的实际代码可能比这复杂得多——你希望有更多的错误处理和参数检查,但这个简单的实现给出了Enumerator的工作内容的一个概念。
实现自定义枚举器
知道它在表面下如何工作,你就可以看到如何在Enumerable中实现任何你想要的行为。我将通过创建一个IEnumerable实现来展示这种技术有多强大,它只通过在MoveNext中放入以下代码来遍历数组中每个其他项:
public bool MoveNext()
{
pos += 2;
return this.pos < this._data.Length;
}
// This turns { 1, 2, 3, 4 }
// into { 2, 4 }
怎么样,一个可以在枚举时每个项目循环两次的Enumerator,实际上创建了每个项目的副本:
public bool IsCopy = false;
public bool MoveNext()
{
if(this.IsCopy)
{
this.pos = this.pos + 1;
}
this.IsCopy = !this.IsCopy;
return this.pos < this._data.Length
}
// This turns { 1, 2, 3 }
// into { 1, 1, 2, 2, 3, 3 }
或者一个完整的实现,倒着开始,以Enumerator外部包装器为开始:
public class BackwardsEnumerator<T> : IEnumerable<T>
{
private readonly T[] data;
public BackwardsEnumerator(IEnumerable<T> data)
{
this.data = data.ToArray();
}
public IEnumerator<T> GetEnumerator()
{
return new BackwardsArrayEnumerable<T>(this.data);
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
然后是驱动反向运动的实际Enumerator:
public class BackwardsArrayEnumerable<T> : IEnumerator<T>
{
public readonly T[] _data;
public int pos;
public BackwardsArrayEnumerable(T[] data)
{
this._data = data ?? new T[0];
this.pos = this._data.Length;
}
T Current => (this._data != null && this._data.Length > 0 &&
this.pos >= 0 && this.pos < this._data.Length)
? _data[pos] : default;
object IEnumerator.Current => this.Current;
T IEnumerator<T>.Current => this.Current;
public void Dispose()
{
// Nothing to dispose
}
public bool MoveNext()
{
this.pos = this.pos - 1;
return this.pos >= 0;
}
public void Reset()
{
this.pos = this._data.Length;
}
}
这个逆向枚举的使用方式几乎与普通枚举完全相同:
var data = new[] { 1, 2, 3, 4, 5, 6, 7, 8 };
var backwardsEnumerator = new BackwardsEnumerator<int>(data);
var list = new List<int>();
foreach(var d in backwardsEnumerator)
{
list.Add(d);
}
// list = { 8, 7, 6, 5, 4, 3, 2, 1 }
所以,现在你已经看到了如何轻松地创建自己想要的任何自定义行为的Enumerable,应该很容易制造一个迭代无限的Enumerable。
无限循环的可枚举
试试看快速连续说十遍这个章节标题!
正如你在上一节中看到的,其实Enumerable并没有特别的原因必须从头开始循环到结尾。我们可以让它表现得任何我们想要的方式。
在这种情况下,我想要做的是——而不是一个数组——我想要传递一种单一的状态对象,以及一个用于确定循环是否应该继续的代码束(即 Thunk 或Func委托)。
逆向工作,我要做的第一件事是Enumerator。这是一个完全定制的枚举过程,所以我不会试图以任何方式使其通用化。我正在编写的逻辑在游戏状态对象之外是没有意义的。
虽然在我的假想的大富翁实现中,我可能想要进行几次不同的迭代,所以我会使操作和循环终止逻辑稍微通用化。
public class GameEnumerator : IEnumerator<Game>
{
// I need this in case of a restart
private Game StartState;
private Game CurrentState;
// old game state -> new game state
private readonly Func<Game, Game> iterator;
// Should the iteration stop?
private Func<Game, bool> endCondition;
// some tricky logic required to ensure the final
// game state is iterated. Normal logic is that if
// the MoveNext function returns false, then there isn't
// anything pulled from Current, the loop simply terminates
private bool stopIterating = false;
public GameEnumerator(Func<Game, Game> iterator,
Func<Game, bool> endCondition, Game state)
{
this.StartState = state;
this.CurrentState = state;
this.iterator = iterator;
this.endCondition = endCondition;
}
public Game Current => this.CurrentState;
object IEnumerator.Current => Current;
public void Dispose()
{
// Nothing to dispose
}
public bool MoveNext()
{
var newState = this.iterator(this.CurrentState);
// Not strictly functional here, but as always with
// this topic, a compromise is needed
this.CurrentState = newState;
// Have we completed the final iteration? That's done after
// reaching the end condition
if (stopIterating)
return false;
var endConditionMet = this.endCondition(this.CurrentState);
var lastIteration = !this.stopIterating && endConditionMet;
this.stopIterating = endConditionMet;
return !this.stopIterating || lastIteration;
}
public void Reset()
{
// restore the initial state
this.CurrentState = this.StartState;
}
}
这就完成了困难的部分!我们有一个引擎在表面下,它允许我们迭代连续的状态,直到我们完成为止——无论我们决定“完成”意味着什么。
下一个需要的项目是运行Enumerator的IEnumerable。那非常简单:
public class GameIterator : IEnumerable<Game>
{
private readonly Game _startState;
private readonly Func<Game,Game> _iterator;
private readonly Func<Game,bool> _endCondition;
public GameIterator(Game startState, Func<Game, Game> iterator,
Func<Game, bool> endCondition)
{
this._startState = startState;
this._iterator = iterator;
this._endCondition = endCondition;
}
public IEnumerator<Game> GetEnumerator() =>
new GameEnumerator(this._startState, this._iterator, this._endCondition);
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
现在一切都准备就绪,可以执行自定义迭代了。我只需要定义我的自定义逻辑,设置迭代器。
var playerState = getState();
var rnd = getRandomNumberGenerator();
var endCondition = (Game x) => x.x.LastAction == Actions.PayFine ||
x.LastAction == Actions.GetOutOfJailFree ||
x.DieOne == x.DieTwo);
var update = (Game x) => {
var action = GetAction();
return action switch
{
Actions.PayFine => x with
{
Money = x.Money - 50,
LastAction = action
},
Actions.GetOutOfJailFree => x with
{
GetOutOfJail = x.GetOutOfJail - 1,
LastAction = action
},
_ => x with
{
DieOne = rnd.Random(1, 6),
DieTwo = rnd.Random(1, 6)
}
}
}
var gameIterator = new GameIterator(playerState, update, endCondition);
有几种处理迭代本身的选项,我想花点时间讨论每个选项的细节。
使用无限迭代器
严格来说,作为一个完全成熟的Iterator,可以应用任何 LINQ 操作,以及标准的ForEach迭代。
ForEach 可能是处理这种迭代最简单的方法,但它并不严格符合函数式编程的要求。这取决于你,如果你愿意通过添加一个有限的语句来妥协,或者想寻找一个更纯粹的函数式替代方案。可能会像这样:
foreach(var g in gameIterator)
{
// store the updated state outside of the loop.
playerState = g;
// Here you can do whatever logic you'd like to do
// to message back to the player. Write a message onto screen
// or whatever is useful for them to be prompted to do another action
}
// At the end of the loop here, the player is now out of jail, and
// the game can continue with the updated version of playerState;
老实说,在生产代码中,这不会让我太担心。但是,我们所做的是否定了我们在试图从代码库中清除非函数式代码方面所做的所有工作。
另一种选择涉及使用 LINQ。作为一个完整的 Enumerable,我们的 GameIterator 可以应用任何 LINQ 操作。但哪些才是最好的呢?
Select 是一个明显的起点,但它可能不完全按照你的预期行为。用法基本上与你以前进行的任何普通 Select 列表操作一样:
var gameStates = gameIterator.Select(x => x);
这里的诀窍在于我们将 gameIterator 视为一个数组,因此从中进行 Select 将会得到一个游戏状态数组。基本上,你会得到一个包含用户经历的每一个中间步骤的数组,最后一个元素是最终状态。
将这简化为仅仅最终状态的简单方法是用 Last 替代 Select:
var endState = var gameStates = gameIterator.Last();
当然,这假设你对中间步骤不感兴趣。也许你想为每个状态更新向用户发送一条消息,那么你可能想选择并提供一个转换。
或许是这样的:
var messages = gameIterator.Select(x =>
"You chose to do " + x.LastAction + " and are " +
(x.InJail ? "In Jail" : "Free to go!");
);
不过,这会消除实际的游戏状态,因此可能 Aggregate 是一个更好的选择:
var stateAndMessages = (
Messages: Enumerable.Empty<string>(),
State: playerState
);
var updatedStateAndMessages =
stateAndMessages.Aggregate(stateAndMessages, (acc, x) => (
acc.Messages.Append("You chose to do " + x.LastAction + " and are " +
(x.InJail ? "In Jail" : "Free to go!")),
x
));
Aggregate 过程中每次迭代中的 x 是游戏状态的更新版本,它将继续聚合,直到满足声明的结束条件。每次迭代都会向列表附加一条消息,因此最终你得到的是一个包含要传递给玩家的消息数组和游戏状态的 Tuple。
请注意,任何使用 LINQ 语句的地方都会以某种方式提前终止迭代 - First、Take 等,这可能导致我们的实例中玩家仍然处于监狱中的迭代过程提前结束。
当然,这可能是你实际想要的行为!例如,也许你限制玩家在继续游戏的另一部分或另一位玩家的回合之前只能进行几个动作。类似这样的情况。
在这种技术中,你可以提出各种逻辑可能性。
结论
我们已经研究了如何在 C# 中实现无限迭代,而不使用 ForEach 语句,这将导致代码更清晰,执行过程中的副作用更少。
纯函数式地做这件事情是不太可能的,有几种选择可供选择 - 所有这些选项在某种程度上都对函数式范式进行了妥协,但这就是在 C# 中工作的本质。
你希望使用哪种选项(如果有的话),完全取决于个人选择以及适用于你的项目的任何约束条件。
但是,请在使用递归时非常小心。它是一种快速的迭代方法,完全函数化,但如果不小心,可能会导致内存使用方面的显著性能问题。
在接下来的章节中,我将介绍一种利用纯函数优化算法性能的好方法。
¹ 这在印度等国家可能行不通。50 印度卢比不会带给你太多。这也就意味着,你真的认为你能用 200 美元买整条街吗!