C# 函数式编程(二)
原文:
zh.annas-archive.org/md5/445c5024138799c1ed6a1899c0d17e5d译者:飞龙
第四章:通过功能代码工作智能,而不是努力工作
到目前为止,我所涵盖的一切都是微软 C# 团队旨在实现的功能编程。您会在微软网站上找到这些功能的提及及其示例。然而,在本章中,我想开始对 C# 进行更富创意的探索。
我不知道你怎么看,但我喜欢偷懒,或者说我不喜欢浪费时间在冗长的样板代码上。函数式编程的许多精彩之处之一就是它的简洁性,相比命令式代码。
在本章中,我将展示如何推动功能编程的边界,超出 C# 的开箱即用功能,以及如何在旧版本中实现一些较新版本的 C#,希望能让您更快地完成日常工作。
本章将探讨以下三个广泛的类别:
枚举中的 Funcs
Func 委托似乎并没有被广泛使用,但它们是 C# 的非常强大的特性。我将展示一些使用它们来扩展 C# 能力的方法。在这种情况下,通过将它们添加到 Enumerable 并使用 Linq 表达式对它们进行操作。
Funcs 作为过滤器
您还可以将 Funcs 用作过滤器 - 这是一种位于您和真正想要达到的值之间的东西。您可以使用这些原则编写一些精彩的代码。
自定义枚举
我之前讨论过 IEnumerable 及其有多酷的功能,但你知道你可以打开它们并实现自定义行为吗?我会向你展示如何做。
是时候变得“Func-y”
我在介绍中已经谈到了 Func 委托类型,但简要回顾一下 - 它们是存储为变量的函数。您定义它们接受什么参数,返回什么,并像任何其他函数一样调用它们。这里有一个快速的例子:
private readonly Func<Person, DateTime, string> SayHello =
(Person p, DateTime today) => today + " : " + "Hello " + p.Name;
在两个尖括号之间的列表中,最后一个泛型类型是返回值,之前的所有类型都是参数。我上面的例子接受两个字符串参数并返回一个字符串。
我们接下来将经常见到许多Func 委托,所以请确保在继续阅读之前对它们感到舒适。
枚举中的 Funcs
我见过很多将 Funcs 作为函数参数的例子,但我不确定有多少开发人员意识到你可以将它们放入 Enumerable,并创建一些有趣的行为。
首先,显而易见的是 - 将它们放入数组中以对同一数据进行多次操作:
private IEnumerable<Func<Employee, string>> descriptors = new []
{
x => "First Name = " + x.firstName,
x => "Last Name = " + x.lastName,
x => "MiddleNames = string.Join(" ", x.MiddleNames)
}
public string DescribeEmployee(Employee emp) =>
string.Join(Environment.NewLine, descriptors.Select(x => x(emp)));
使用这种方法,我们可以从单一的数据源(这里是一个 Employee 对象)生成多个相同类型的记录,在我的案例中,我使用内置的 .NET 方法 string.Join 聚合生成一个统一的字符串呈现给最终用户。
这种方法相对于简单的 StringBuilder 有一些优势。
首先,数组可以动态组装。每个属性和其呈现方式可能有多个规则,这些规则可以根据某些自定义逻辑从一组本地变量中选择。
其次,这是一个可枚举对象,因此通过这种方式定义它,我们利用了可枚举对象称为惰性评估的功能。关于可枚举对象的一点是,它们不是数组,甚至不是数据。它们只是指向某个东西的指针,这个东西将告诉你如何提取数据。很可能 - 实际上通常是这样的情况 - 可枚举对象背后的源头是一个简单的数组,但不一定如此。可枚举对象需要在每次通过 ForEach 循环访问下一个项目时执行一个函数。可枚举对象的开发目的是使其在最后可能的时刻才转换为实际数据 - 通常是在开始 ForEach 循环迭代时。大多数情况下,如果内存中有一个数组供可枚举对象使用,这并不重要,但如果有一个昂贵的函数或者用于驱动它的外部系统查找,则惰性加载可以非常有用,以防止不必要的工作。
可枚举对象的元素将逐一评估,并且只有当它们被某个执行枚举的过程使用时才会被使用。例如,如果我们使用 LINQ 的 Any 函数来评估可枚举对象中的每个元素,它将在找到满足指定条件的第一个元素时停止枚举,这意味着剩余的元素将保持未评估状态。
最后,从维护的角度来看,这种技术更容易维护。向最终结果添加新行就像向数组添加新元素一样简单。它还作为对未来程序员的一种限制,使他们更难在不适当的地方加入过多复杂的逻辑。
一个超级简单的验证器
让我们设想一个快速验证函数。它们通常看起来像这样:
public bool IsPasswordValid(string password)
{
if(password.Length <= 6)
return false;
if(password.Length > 20)
return false;
if(!password.Any(x => Char.IsLower(x)))
return false;
if(!password.Any(x => Char.IsUpper(x)))
return false;
if(!password.Any(x => Char.IsSymbol(x)))
return false;
if(password.Contains("Justin", StringComparison.OrdinalIgnoreCase)
&& password.Contains("Bieber", StringComparison.OrdinalIgnoreCase))
return false;
return true;
}
嗯,首先,实际上这是一堆代码,用来实现一个相当简单的规则。在这里,命令式代码迫使我们写下一堆重复的样板代码。除此之外,如果我们想要添加另一条规则,那可能会多写大约 4 行代码,但其实只有 1 行对我们特别有意义。
要是能把它压缩成几行简单的代码就好了……
嗯……既然你这么客气地问了,那就给你看看:
public bool IsPasswordValid(string password) =>
new Func<string, bool>[]
{
x => x.Length > 6,
x => x.Length <= 20,
x => x.Any(y => Char.IsLower(y)),
x => x.Any(y => Char.IsUpper(y)),
x => x.Any(y => Char.IsSymbol(y)),
x => !x.Contains("Justin", StringComparison.OrdinalIgnoreCase)
&& !x.Contains("Bieber", StringComparison.OrdinalIgnoreCase)
}.All(f => f(password));
现在不那么长了吧?这里我做了什么?我把所有规则放入了一个将字符串转换为布尔值的 Func 数组中 - 即检查单个验证规则。我使用了一个 Linq 语句 - .All()。这个函数的目的是评估我给它的任何 Lambda 表达式对其附加的数组的所有元素进行检查。如果其中一个返回 false,那么进程将提前终止,并且从All返回 false(如前所述,后续值不会被访问,因此惰性评估通过不评估它们来节省时间)。如果所有项都返回 true,则All也返回 true。
我们有效地重新创建了第一个代码示例,但我们被迫编写的样板代码 - If 语句和早期返回 - 现在在结构中是隐含的。
这也有一个优势,那就是作为代码结构非常容易维护。如果你愿意,甚至可以将其泛化为一个扩展方法。我经常这样做。类似于这样:
public static bool IsValid<T>(this T @this, params Func<T,bool>[] rules) =>
rules.All(x => x(@this));
这进一步减少了密码验证器的大小,并为您提供了一个方便的通用结构可供其他用途使用:
public bool IsPasswordValid(string password) =>
password.IsValid(
x => x.Length > 6,
x => x.Length <= 20,
x => x.Any(y => Char.IsLower(y)),
x => x.Any(y => Char.IsUpper(y)),
x => x.Any(y => Char.IsSymbol(y)),
x => !x.Contains("Justin", StringComparison.OrdinalIgnoreCase)
&& !x.Contains("Bieber", StringComparison.OrdinalIgnoreCase)
)
此时此刻,我希望你重新考虑是否再写像第一个验证代码示例那样长而臃肿的东西了。
我认为IsValid检查更易读和维护,但如果你想要一个更符合原始代码示例的代码片段,那么可以创建一个新的扩展方法,使用Any代替All:
public static bool IsInvalid<T>(this T @this, params Func<string,bool>[] rules) =>
rules.Any(x => @this);
这意味着每个数组元素的布尔逻辑可以被反转,因为它们最初是这样的:
public bool IsPasswordValid(string password) =>
!password.IsInvalid(
x => x.Length <= 6,
x => x.Length > 20,
x => !x.Any(y => Char.IsLower(y)),
x => !x.Any(y => Char.IsUpper(y)),
x => !x.Any(y => Char.IsSymbol(y)),
x => x.Contains("Justin", StringComparison.OrdinalIgnoreCase)
&& x.Contains("Bieber", StringComparison.OrdinalIgnoreCase)
)
如果您希望维护IsValid和IsInvalid两个函数,因为它们在代码库中各有用处,那么通过简单地引用其中一个来节省编码工作并避免未来的潜在维护任务可能是值得的:
public static bool IsValid<T>(this T @this, params Func<T,bool>[] rules) =>
rules.All(x => x(@this));
public static bool IsInvalid<T>(this T @this, params Func<T,bool>[] rules) =>
!@this.IsValid(rules);
聪明地使用它,我的年轻函数式学徒。
旧版本 C#的模式匹配:
模式匹配是近年来 C#中最好的功能之一,与记录类型一起,但除了最新的.NET 版本外,其他版本都不支持 - 更多有关 C# 7 及以上本机模式匹配的详情,请参见第三章。
有没有一种方法可以允许模式匹配发生,但不需要升级到较新版本的 C#?
当然有。它远不及 C# 8 中的本机语法那么优雅,但它提供了一些相同的好处。
在这个例子中,我将根据英国所得税规则的大大简化版本计算某人应缴纳的税款。请注意,这比真实情况简单得多。我不想陷入税务复杂性的泥沼。
我将要应用的规则如下:
-
年收入 ≤ £12,570,则不扣税。
-
年收入在£12,571 到£50,270 之间,则缴纳 20%的税款。
-
年收入在£50,271 到£150,000 之间,则缴纳 40%的税款。
-
年收入超过£150,000,则缴纳 45%的税款。
如果你想手写(即非功能性地)编写这段代码,看起来会像这样:
decimal ApplyTax(decimal income)
{
if (income <= 12570)
return income;
else if (income <=50270)
return income * 0.8M;
else if (income <= 150000)
return income * 0.6M;
else
return income * 0.55M;
}
现在,在 C# 8 及以后的版本中,switch 表达式可以压缩为几行代码。只要你至少运行 C# 7(即.NET Framework 4.7),这就是我可以创建的模式匹配风格:
var inputValue = 25000M;
var updatedValue = inputValue.Match(
(x => x <= 12570, x => x),
(x => x <= 50270, x => x * 0.8M),
(x => x <= 150000, x => x * 0.6M)
).DefaultMatch(x => x * 0.55M);
我传递了一个包含 2 个 lambda 表达式的元组数组。第一个确定输入是否与当前模式匹配,第二个是匹配时发生的值转换。最后检查是否应用默认模式 - 即因为没有其他模式匹配。
尽管长度只有原始代码样本的一小部分,但这包含了所有相同的功能。这里左侧元组的匹配模式很简单,但它们可以包含像您想要的那样复杂的表达式,甚至可以是调用包含详细匹配条件的整个函数。
那么,我是如何让这个工作的呢?这是一个非常简单的版本,提供了大部分所需的功能:
public static class ExtensionMethods
{
public static TOutput Match<TInput, TOutput>(
this TInput @this,
params (Func<TInput, bool> IsMatch,
Func<TInput, TOutput> Transform)[] matches)
{
var match = matches.FirstOrDefault(x => x.IsMatch(@this));
var returnValue = match.Transform(@this);
return returnValue;
}
}
我使用 Linq 方法FirstOrDefault首先遍历左侧函数,以找到返回 true 的函数(即具有正确条件的函数),然后调用右侧的转换 Func 来获取我的修改后的值。
这很好,除非没有任何模式匹配,我们可能会遇到一些问题。很可能会出现空引用异常。
要覆盖这一点,我们需要强制提供默认匹配的需要(简单的else语句的等效或 switch 表达式中的 _ 模式匹配)。
我的答案是让Match函数返回一个占位符对象,该对象保存从匹配表达式转换得到的值,或执行默认的模式 lambda 表达式。改进后的版本如下:
public static MatchValueOrDefault<TInput, TOutput> Match<TInput, TOutput>(
this TInput @this,
params (Func<TInput, bool>,
Func<TInput, TOutput>)[] predicates)
{
var match = predicates.FirstOrDefault(x => x.Item1(@this));
var returnValue = match?.Item2(@this);
return new MatchValueOrDefault<TInput, TOutput>(returnValue, @this);
}
public class MatchValueOrDefault<TInput, TOutput>
{
private readonly TOutput value;
private readonly TInput originalValue;
public MatchValueOrDefault(TOutput value, TInput originalValue)
{
this.value = value;
this.originalValue = originalValue;
}
public TOutput DefaultMatch(Func<TInput, TOutput> defaultMatch)
{
if (EqualityComparer<TOutput>.Default.Equals(default, this.value))
{
return defaultMatch(this.originalValue);
}
else
{
return this.value;
}
}
与最新版本的 C#相比,此方法受到严重限制。它没有对象类型匹配,并且语法不那么优雅,但仍然可用,并且可以节省大量样板代码,同时也鼓励良好的代码标准。
较旧版本的 C#,不包括元组的版本,可以考虑使用KeyValuePair<T,T>,尽管语法远非理想。
你不相信?好的,我们来试试。别说我没警告过你……
扩展方法本身几乎一样,并且只需少量修改即可使用KeyValuePair代替元组:
public static MatchValueOrDefault<TInput, TOutput> Match<TInput, TOutput>(
this TInput @this,
params KeyValuePair<Func<TInput, bool>, Func<TInput, TOutput>>[] predicates)
{
var match = predicates.FirstOrDefault(x => x.Key(@this));
var returnValue = match.Value(@this);
return new MatchValueOrDefault<TInput, TOutput>(returnValue, @this);
}
这里有个丑陋的地方。创建KeyValuePair对象的语法非常糟糕:
var inputValue = 25000M;
var updatedValue = inputValue.Match(
new KeyValuePair<Func<decimal, bool>, Func<decimal, decimal>>(
x => x <= 12570, x => x),
new KeyValuePair<Func<decimal, bool>, Func<decimal, decimal>>(
x => x <= 50270, x => x * 0.8M),
new KeyValuePair<Func<decimal, bool>, Func<decimal, decimal>>(
x => x <= 150000, x => x * 0.6M)
).DefaultMatch(x => x * 0.55M);
因此,在 C# 4 中仍然可以使用某种形式的模式匹配,但我不确定你这样做能获得多少好处。这可能由你来决定。至少我已经向你展示了路径。
函数式过滤
函数不仅可以用于将一种形式的数据转换为另一种形式,还可以用作过滤器,额外的层,它们位于开发者和信息或功能的原始来源之间。
本节将介绍如何使用这种方法的几个示例,使您的日常 C#编码看起来更简单,同时更少出错。
使字典更加有用
在 C#中,我绝对喜欢的一件事情是字典。如果适当使用,它们可以通过几个简单而优雅的类似数组的查找来减少大量丑陋、样板化的代码。一旦创建,它们在查找数据时也非常高效。
然而,它们存在一个问题,这使得通常需要添加大量样板代码,这些代码无效化了它们使用的原因。考虑以下代码示例:
var doctorLookup = new []
{
( 1, "William Hartnell" ),
( 2, "Patrick Troughton" ),
( 3, "Jon Pertwee" ),
( 4, "Tom Baker" )
}.ToDictionary(x => x.Item1, x => x.Item2);
var fifthDoctorInfo = $"The 5th Doctor was played by {doctorLookup[5]}";
这段代码怎么了?它触犯了我发现的字典的一个令人费解的代码特性,如果尝试查找一个不存在的条目¹,它将触发一个必须处理的异常!
处理这种情况的唯一安全方法是使用 C#中提供的几种方法之一,在编译字符串之前检查可用键,就像这样:
var doctorLookup = new []
{
( 1, "William Hartnell" ),
( 2, "Patrick Troughton" ),
( 3, "Jon Pertwee" ),
( 4, "Tom Baker" )
}.ToDictionary(x => x.Item1, x => x.Item2);
var fifthDoctorActor = doctorLookup.ContainsKey(5) ? doctorLookup[5] : "An Unknown Actor";
var fifthDoctorInfo = $"The 5th Doctor was played by {fifthDoctorActor}";
或者,使用稍新版本的 C#,还可以使用TryGetValue函数来简化这段代码:
var fifthDoctorActor = doctorLookup.TryGetValue(5, out string value) ? value : "An Unknown Actor";
那么,我们能否使用函数式编程技术来减少我们的样板代码,并为我们提供字典的所有有用功能,但又不会出现糟糕的膨胀倾向?你可以打赌!
首先,我需要一个快速的扩展方法:
public static class ExtensionMethods
{
public static Func<TKey, TValue> ToLookup<TKey,TValue>(
this IDictionary<TKey,TValue> @this)
{
return x => @this.TryGetValue(x, out TValue? value) ? value : default;
}
public static Func<TKey, TValue> ToLookup<TKey,TValue>(
this IDictionary<TKey,TValue> @this,
TValue defaultVal)
{
return x => @this.ContainsKey(x) ? @this[x] : defaultVal;
}
}
我稍后会进一步解释,但首先,这是我如何使用我的扩展方法:
var doctorLookup = new []
{
( 1, "William Hartnell" ),
( 2, "Patrick Troughton" ),
( 3, "Jon Pertwee" ),
( 4, "Tom Baker" )
}.ToDictionary(x => x.Item1, x => x.Item2)
.ToLookup("An Unknown Actor");
var fifthDoctorInfo = $"The 5th Doctor was played by {doctorLookup(5)}";
// output = "The 5th Doctor was played by An Unknown Actor"
注意区别了吗?
仔细观察,现在我使用的是圆括号函数,而不是方括号访问字典/数组的值。这是因为它实际上不再是一个字典!它是一个函数。
如果你看我的扩展方法,它们返回函数,但它们是保持原始 Dictionary 对象在其存在期间有效的函数。基本上,它们就像是位于 Dictionary 和代码库其余部分之间的过滤器层。这些函数决定了是否安全使用 Dictionary。
这意味着我们可以使用一个字典,但是当找不到键时不会再抛出异常,我们可以返回类型的默认值(通常是 Null),或者提供我们自己的默认值。简单吧。
这种方法唯一的缺点是它不再是一个字典。这意味着你不能进一步修改它,或者在它上面执行任何 LINQ 操作。然而,如果你确信你不需要这样做,那么这是你可以使用的东西。
解析值
另一个导致冗长、样板代码的常见原因是将字符串解析为其他形式。例如,对于在假设我们在.NET Framework 中工作且不可用 appsettings.JSON 和IOption<T>功能的设置对象中进行解析:
public Settings GetSettings()
{
var settings = new Settings();
var retriesString = ConfigurationManager.AppSettings["NumberOfRetries"];
var retriesHasValue = int.TryParse(retriesString, out var retriesInt);
if(retriesHasValue)
settings.NumberOfRetries = retriesInt;
else
settings.NumberOfRetries = 5;
var pollingHourStr = ConfigurationManager.AppSettings["HourToStartPollingAt"];
var pollingHourHasValue = int.TryParse(pollingHourStr, out var pollingHourInt);
if(pollingHourHasValue)
settings.HourToStartPollingAt = pollingHourInt;
else
settings.HourToStartPollingAt = 0;
var alertEmailStr = ConfigurationManager.AppSettings["AlertEmailAddress"];
if(string.IsNullOrWhiteSpace(alertEmailStr))
settings.AlertEmailAddress = "test@thecompany.net";
else
settings.AlertEmailAddress = aea.ToString();
var serverNameString = ConfigurationManager.AppSettings["ServerName"];
if(string.IsNullOrWhiteSpace(serverNameString))
settings.ServerName = "TestServer";
else
settings.ServerName = sn.ToString();
return settings;
}
这样做一些简单的事情确实需要很多代码,对吧?在那里有很多样板代码噪音,使得代码的意图除了熟悉这类操作的人之外几乎无法理解。而且,如果要添加新的设置,每个设置可能需要新增 5 到 6 行代码。这是一种浪费。
相反,我们可以更加功能化地处理,将结构隐藏在某个地方,只留下代码的意图可见。
通常情况下,这里是一个扩展方法来为我处理业务:
public static class ExtensionMethods
{
public static int ToIntOrDefault(this object @this, int defaultVal = 0) =>
int.TryParse(@this?.ToString() ?? string.Empty, out var parsedValue)
? parsedValue
: defaultVal;
public static string ToStringOrDefault(this object @this, string defaultVal = "") =>
string.IsNullOrWhiteSpace(@this?.ToString() ?? string.Empty)
? defaultVal
: @this.ToString();
}
这消除了第一个示例中所有重复的代码,并允许我们转向更可读、以结果为导向的代码示例,如下所示:
public Settings GetSettings() =>
new Settings
{
NumberOfRetries = ConfigurationManager.AppSettings["NumberOfRetries"]
.ToIntOrDefault(5),
HourToStartPollingAt = ConfigurationManager.AppSettings["HourToStartPollingAt"]
.ToIntOrDefault(0),
AlertEmailAddress = ConfigurationManager.AppSettings["AlertEmailAddress"]
.ToStringOrDefault("test@thecompany.net"),
ServerName = ConfigurationManager.AppSettings["ServerName"]
.ToStringOrDefault("TestServer"),
};
现在一目了然代码的作用,缺省值是什么,以及如何通过一行代码添加更多的设置。
除了 int 和 string 之外的任何其他设置值类型都需要创建另一个扩展方法,但这并不是什么大困难。
自定义枚举
大多数人在编码时可能都使用过 Enumerables,但您知道表面下有一个引擎可以访问并用于创建各种有趣的自定义行为吗?
使用自定义迭代器时,可以大大减少在需要更复杂行为时循环数据所需的代码行数。不过,首先需要了解 Enumerable 在表面之下是如何工作的。
在 Enumerable 表面下有一个类,驱动枚举的引擎,这使得你可以使用 ForEach 来循环遍历值。它被称为 Enumerator。
枚举器基本上有两个特性:
-
当前:从可枚举中获取当前项。可以随意调用多次,前提是不尝试移动到下一项。如果在首次调用 MoveNext 之前尝试获取当前值,则会抛出异常。
-
MoveNext:从当前项移动,并尝试看看是否有另一个可选择的值。如果找到另一个值,则返回
True;如果已经到达 Enumerable 的末尾,或者根本没有元素,则返回False。首次调用此方法时,它将 Enumerator 指向 Enumerable 的第一个元素。
查询相邻元素
一个相对简单的例子来开始。假设我想遍历一个整数的 Enumerable,看看是否包含任何连续的数字。
一种命令式的解决方案可能看起来像这样:
public IEnumerable<int> GenerateRandomNumbers()
{
var rnd = new Random();
var returnValue = new List<int>();
for (var i = 0; i < 100; i++)
{
returnValue.Add(rnd.Next(1, 100));
}
return returnValue;
}
public bool ContainsConsecutiveNumbers(IEnumerable<int> data)
{
// OK, you caught me out OrderBy isn't strictly Imperative, but
// there's no way I'm going to write out a sorting algorithm out
// here just to prove a point!
var sortedData = data.OrderBy(x => x).ToArray();
for (var i = 0; i < sortedData.Length - 1; i++)
{
if ((sortedData[i] + 1) == sortedData[i + 1])
return true;
}
return false;
}
var result = ContainsConsecutiveNumbers(GenerateRandomNumbers());
Console.WriteLine(result);
要使这段代码功能化,通常情况下,我们需要一个扩展方法。这个方法接收 Enumerable,提取其 Enumerator,并控制定制的行为。
为了避免使用命令式风格的循环,我在这里使用了递归。简而言之,递归是通过让函数重复调用自身来实现无限循环的一种方式。
我将在后面的章节中重新讨论递归的概念。但现在,我只会使用标准的简单递归版本。
public static bool Any<T>(this IEnumerable<T> @this, Func<T, T, bool> evaluator)
{
using var enumerator = @this.GetEnumerator();
var hasElements = enumerator.MoveNext();
return hasElements && Any(enumerator, evaluator, enumerator.Current);
}
private static bool Any<T>(IEnumerator<T> enumerator,
Func<T, T, bool> evaluator,
T previousElement)
{
var moreItems = enumerator.MoveNext();
return moreItems && (evaluator(previousElement, enumerator.Current)
? true
: Any(enumerator, evaluator, enumerator.Current));
}
那么,这里发生了什么?在某种程度上有点像杂耍。我首先提取枚举器,然后移到第一项。
在私有函数内部,我接受枚举器(现在指向第一个项目)、“我们完成了吗”的评估器函数以及同一个第一个项目的副本。
接着,我立即移到下一个项目,并运行评估器函数,传入第一个项目和新的“当前项目”,这样它们就可以进行比较。
此时,要么我们发现物品用完了,要么评估器返回 true,这种情况下我们可以终止迭代。如果 MoveNext 返回 true,那么我们检查previousValue和Current是否符合我们的要求(由evaluator指定)。如果符合,则完成并返回 true,否则我们递归调用以检查其余的值。
这是查找连续数字的代码的更新版本:
public IEnumerable<int> GenerateRandomNumbers()
{
var rnd = new Random();
var returnValue = Enumerable.Repeat(0, 100)
.Select(x => rnd.Next(1, 100));
return returnValue;
}
public bool ContainsConsecutiveNumbers(IEnumerable<int> data)
{
var sortedData = data.OrderBy(x => x).ToArray();
var result = sortedData.Any((prev, curr) => cur == prev + 1);
return result;
}
这样,基于相同逻辑,创建一个All方法也相当容易,如下所示:
public static bool All<T>(this IEnumerator<T> enumerator, Func<T,T,bool> evaluator, T previousElement)
{
var moreItems = enumerator.MoveNext();
return moreItems
? evaluator(previousElement, enumerator.Current)
? All(enumerator, evaluator, enumerator.Current)
: false
: true;
}
public static bool All<T>(this IEnumerable<T> @this, Func<T,T,bool> evaluator)
{
using var enumerator = @this.GetEnumerator();
var hasElements = enumerator.MoveNext();
return hasElements
? All(enumerator, evaluator, enumerator.Current)
: true;
}
唯一的区别在于决定是否继续的条件以及我们是否需要提前返回。使用All,关键是检查每对值,并且仅在找到不满足条件的情况下提前退出循环。
迭代直到满足条件
这基本上是替换 while 循环的一种方法,所以这是我们不一定需要的另一种语句。
对于我的示例,我想象文本冒险游戏的轮换系统可能是什么样子。对于年轻读者来说 - 这就是我们在旧日子里没有图形之前的样子。你过去必须写下你想做的事情,然后游戏会写下发生的事情。有点像书,只不过你自己写发生了什么。
其中一个游戏的基本结构大致如下:
-
写下当前位置的描述
-
接收用户输入
-
执行请求的命令
这里展示了命令式代码如何处理这种情况:
var gameState = new State
{
IsAlive = true,
HitPoints = 100
};
while(gameState.IsAlive)
{
var message = this.ComposeMessageToUser(gameState);
var userInput = this.InteractWithUser(message);
this.UpdateState(gameState, userInput);
if(gameState.HitPoints <= 0)
gameState.IsAlive = false;
}
原则上,我们想要的是一个类似 Linq 风格的聚合函数,但不是循环遍历数组的方式,然后结束。相反,我们希望它持续循环,直到满足我们的结束条件(玩家死亡)。我这里稍微简化了一下,显然在真正的游戏中,我们的玩家也可能会赢。但我的示例游戏就像生活一样,生活并不公平!
对于这种情况的扩展方法,我们可以从尾递归优化调用中受益,我将在后面的章节中研究这方面的选择,但目前我将仅使用简单的递归 - 如果回合很多可能会成为一个问题,但目前它会阻止我过早引入太多想法。
public static class ExtensionMethods
{
public static T AggregateUntil<T>(
this T @this,
Func<T,bool> endCondition,
Func<T,T> update) =>
endCondition(@this)
? @this
: AggregateUntil(update(@this), endCondition, update);
}
使用这个方法,我可以完全摆脱while循环,并将整个回合序列转换为一个函数,如下所示:
var gameState = new State
{
IsAlive = true,
HitPoints = 100
};
var endState = gameState.AggregateUntil(
x => x.HitPoints <= 0,
x => {
var message = this.ComposeMessageToUser(x);
var userInput = this.InteractWithUser(message);
return this.UpdateState(x, userInput);
});
这还不完美,但现在它是可以运行的。有更好的方法来处理游戏状态更新的多个步骤,以及如何以函数式的方式处理用户交互的问题。在第[X]章中将有专门的部分讨论这个问题。
结论
在本章中,我们探讨了如何使用 Funcs、Enumerables 和扩展方法来扩展 C#,使得编写函数式代码更加容易,并且解决了语言中一些现有的局限性。
我相信我只是触及到了这些技术的皮毛,还有很多其他的技术等待被发现和应用。
在我们的下一章中,我们将讨论高阶函数,以及一些可以利用它们来创建更多有用功能的结构。
¹ 顺便提一句,那是彼得·戴维森。
² 但希望不是无限的!
³ 如果你想亲自体验,请去玩一下史诗冒险游戏 Zork。试着不要被 Grue 吃掉!
第五章:高阶函数
欢迎回到我的朋友们,这场永无止境的表演。
本章,我们将探讨高阶函数的用途。我将探讨在 C#中使用它们的新颖方式,以节省您的工作量,并使代码不太可能失败。
但是,什么是高阶函数呢?
高阶函数对于一些非常简单的事情来说是一个稍微奇怪的名字。事实上,如果你花了很多时间使用 LINQ,你很可能已经在使用它们了。它们有两种风味,这是第一种:
var liberatorCrew = new []
{
"Roj Blake",
"Kerr Avon",
"Vila Restal",
"Jenna Stannis",
"Cally",
"Olag Gan",
"Zen"
};
var filteredList = liberatorCrew.Where(x => x.First() > 'M');
传递到Where函数中的是一个箭头表达式 - 这只是一种用于编写匿名函数的简写。长格式版本将如下所示:
function bool IsGreaterThanM(char c)
{
return c > 'm';
}
因此,在这里,函数已作为参数传递给另一个函数,在其内部的其他地方执行。
这是高阶函数的另一个使用示例:
public Func<int, int> MakeAddFunc(int x) => y => x + y;
请注意,这里有两个箭头,而不是一个。我们正在获取一个整数 x,并从中返回一个新函数。在该新函数中,对 x 的引用将使用在最初调用 MakeAddFunc 时提供的任何内容填充。
例如:
var addTenFunction = MakeAddFunc(10);
var answer = addTenFunction(5);
// answer is 15
通过将 10 传递到MakeAddFunc中,在上面的示例中,我创建了一个新函数,其功能只是将 10 添加到您传递给它的任何其他整数中。
简而言之,高阶函数是具有以下一项或多项属性的函数:
-
接受一个函数作为参数
-
将函数作为其返回类型返回
在 C#中,这通常通过Func(用于具有返回类型的函数)或Action(用于返回 void 的函数)委托类型完成。
这是一个相当简单的想法,甚至更容易实现 - 但它们对你的代码库可能产生的影响是不可思议的。
在本章中,我将介绍如何使用高阶函数来改进您的日常编码方式。
我还将深入研究称为组合子的高阶函数的下一级用法。这些允许以一种创建更复杂和有用行为的方式传递函数。它们之所以被称为组合子,是因为它们源自一种称为组合逻辑的数学技术。你以后不需要担心再听到这个术语,或者关于任何高级数学的引用 - 我不会去那里。只是以防你好奇...
问题报告
要开始,我们将查看一些问题代码。假设您的公司要求您编写一个函数来获取某种数据存储(XML 文件、JSON 文件,谁知道。无所谓),总结每种可能值的数量,然后将该数据传输到其他地方。除此之外,他们希望在找不到任何数据时发送一个单独的消息。我管理一个非常宽松的公司,所以让我们保持有趣,想象你在邪恶银河帝国™工作,并且你正在对你的雷达上的反抗联盟飞船进行分类。
代码可能如下所示:
public void SendEnemyShipWeaponrySummary()
{
try
{
var enemyShips = this.DataStore.GetEnemyShips();
var summaryNumbers = enemyShips.GroupBy(x => x.Type)
.Select(x => (Type: x.Key, Count: x.Count()));
var report = new Report
{
Title = "Enemy Ship Type",
Rows = summaryNumbers.Select(X => new ReportItem
{
ColumnOne = X.Type,
ColumnTwo = X.Count.ToString()
})
};
if (!report.Rows.Any())
this.CommunicationSystem.SendNoDataWarning();
else
this.CommunicationSystem.SendReport(report);
}
catch (Exception e)
{
this.Logger.LogError(e,
$"An error occurred in {nameof(SendEnemyShipWeaponrySummary)}: {e.Message}");
}
}
这没问题,对吧?对吧?好吧,想想这种情景。你坐在桌前,吃着你的每日速食面¹,突然发现——就像《侏罗纪公园》一样——你的咖啡里开始有了节奏感的涟漪。这意味着你的噩梦来了。你的老板!让我们假设你的老板是——我随便说的——一个高个子、深沉嗓音的绅士,穿着黑色斗篷,患有可怕的哮喘。而且他真的讨厌人们惹他生气。非常讨厌。
他对你创建的第一个函数感到满意。你可以松一口气了。但现在他想要第二个函数。这个函数将创建另一个摘要,但这次是关于每艘飞船的武器水平。无论它们是无武装、轻装、重装还是能毁灭行星的。那种情况。
简单啊,你想。老板会对我多快地完成这个任务感到印象深刻的。所以,你做了看起来最简单的事情 Ctrl+C,然后 Ctrl+V 复制和粘贴原始内容,改变名称,改变你要总结的属性,最后得到了这样的东西:
public void GenerateEnemyShipWeaponrySummary()
{
try
{
var enemyShips = this.DataStore.GetEnemyShips();
var summaryNumbers = enemyShips.GroupBy(x => x.WeaponryLevel)
.Select(x => (Type: x.Key, Count: x.Count()));
var report = new Report
{
Title = "Enemy Ship Weaponry Level",
Rows = summaryNumbers.Select(X => new ReportItem
{
ColumnOne = X.Type,
ColumnTwo = X.Count.ToString()
})
};
if (!report.Rows.Any())
this.CommunicationSystem.SendNoDataWarning();
else
this.CommunicationSystem.SendReport(report);
}
catch (Exception e)
{
this.Logger.LogError(e,
$"An error occurred in {nameof(GenerateEnemyShipWeaponrySummary)}: {e.Message}");
}
}
五秒钟的工作,一天或两天在你象征性的铲子上使劲,还时不时地抱怨这里的工作有多难,而你暗中又在玩今天的 Wordle。完成任务,到处拍背,对吧?对吧?
嗯……这种方法存在几个问题。
首先,让我们考虑单元测试。作为优秀的、正直的代码公民,我们对所有的代码进行单元测试。想象一下,我们对第一个函数进行了彻底的单元测试。当我们复制和粘贴第二个函数时,此时单元测试覆盖率是多少呢?
我给你一个提示——它介于零和零之间。你也可以复制并粘贴测试,这也可以,但这样我们每次复制和粘贴的代码量就更多了。
这种方法不适合扩展。如果我们的老板在这之后还想要另一个函数,再一个,再一个。如果我们最终被要求做 50 个函数?或者 100 个?!那就是大量的代码。你最终会得到一些上千行长的东西,这不是我乐意支持的。
当你考虑到我职业生涯初期发生的一件事情时,情况变得更糟。我曾在一家组织工作,他们有一个桌面应用程序,根据几个输入参数为每个客户进行一系列复杂的计算。每年规则都会改变,但旧的规则基础必须复制,因为可能需要查看以前年份的计算结果。
所以,在我加入团队之前,一直在开发该应用程序的人每年都复制了一大块代码。做了一些小改动,然后在某处添加了指向新版本的链接,完成了。
有一年,我被委派去做这些年度更改,于是我开始了,年轻、无邪,怀揣改变世界的热情。在进行更改时,我注意到了一些奇怪的现象。有一个与我的更改毫无关系的字段出现了错误。我修复了这个错误,但接着我产生了一个让我心情沉重的想法…
我查看了每个之前版本的代码库,每一年的版本,几乎所有的版本都有同样的 bug。这个 bug 大约 10 年前引入,从那以后每位开发者都精确复制了这个 bug。因此,我不得不多次修复它,使得测试工作的工作量成倍增加。
思考一下这个问题——复制粘贴真的节省了你多少时间吗?我经常处理那些可能在几十年后仍然存在且毫无放弃迹象的应用程序。
当我决定在编码工作中节省时间时,我尝试审视整个应用程序的生命周期,并考虑一个决策在十年后可能产生的后果。
要回到我们的主题,我如何使用高阶函数来解决这个问题?好了,你们准备好了吗?那么,我就开始说了…
Thunks
一个代码束,其中包含一个存储计算的存储计算,可以在请求时执行,被正式称为Thunk。就像一块木板打在你头上发出的声音一样。关于这是否比读这本书更伤脑筋,这是一个有争议的问题!
在 C#中,我们可以使用Func委托来实现这一点。我们可以编写接受Func委托作为参数值的函数,以允许我们的函数中某些计算留空,这些空缺可以通过外部世界,通过箭头函数来填补。
虽然这个技术有一个严肃的、正式的数学术语,我喜欢称之为“甜甜圈函数”,因为这更具描述性。它们就像普通函数,但中间有一个空洞!这个空洞我会请别人填补必要的功能。
这是重构问题报告函数的一种潜在方式:
public void SendEnemyShipWeaponrySummary() =>
GenerateSummary(x => x.Type, "Enemy Ship Type Summary");
public void GenerateEnemyShipWeaponryLevelSummary() =>
GenerateSummary(x => x.WeaponryLevel, "Enemy Ship WeaponryLevel");
private void GenerateSummary(Func<EnemyShip, string> summarySelector, string reportName)
{
try
{
var enemyShips = this.DataStore.GetEnemyShips();
var summaryNumbers = enemyShips.GroupBy(summarySelector)
.Select(x => (Type: x.Key, Count: x.Count()));
var report = new Report
{
Title = reportName,
Rows = summaryNumbers.Select(X => new ReportItem
{
ColumnOne = X.Type,
ColumnTwo = X.Count.ToString()
})
};
if (!report.Rows.Any())
this.CommunicationSystem.SendNoDataWarning();
else
this.CommunicationSystem.SendReport(report);
}
catch (Exception e)
{
this.Logger.LogError(e,
$"An error occurred in {nameof(GenerateSummary)}, report: {reportName}, message: {e.Message}");
}
}
在这个修订版本中,我们获得了一些优势。
首先,每个新报告的额外行数仅为一行!这使得代码库更加整洁,更易于阅读。代码与新函数的意图非常接近——即与第一个函数相同,但有一些变化。
其次,在对第一个功能进行单元测试后,当我们创建第二个功能时,单元测试水平仍然接近 100%。从功能上讲,唯一的区别是报告名称和要汇总的字段。
最后,对基础函数的任何增强或错误修复将同时应用于所有报表函数。这对相对较少的工作量来说带来了很多好处。还有非常高的信心度,如果一个报表函数测试通过了,其他所有报表函数也将会是一样。
有可能会对这个版本感到满意。但如果是我,我实际上会考虑进一步,将带有其Func参数的私有版本暴露在接口上,供希望使用它的任何人使用。
像这样:
public interface IGenerateReports
{
void GenerateSummary(Func<EnemyShip, string> summarySelector, string reportName)
}
实现方式是将前面代码示例中的私有函数公开化。这样一来,至少在希望为不同字段添加额外报告时,不需要修改接口或实现类。
这使得创建报告的工作完全可以由任何消耗这个类的代码模块任意完成。这样做不仅节省了我们这样的开发者在维护报告集方面的大量负担,而且更多地放在关心报告本身的团队手中。想象一下,现在不再需要向开发团队提交多少变更请求。
如果你真的想要野心勃勃,你可以进一步将Func参数公开为Func<ReportLine,string>,以允许报告类的用户定义自定义格式。你也可以使用Action参数来实现定制的日志记录或事件处理。这只是我那个傻乎乎、虚构的报告类。通过这种方式使用高阶函数的可能性是无限的。
尽管这是一个功能编程的特性,但这确实使我们牢牢地遵循了面向对象设计的 SOLID 原则中的O - 开闭原则²,即模块应该对扩展开放,对修改关闭。
令人惊讶的是,在 C#中,面向对象和功能编程如何能够互补。我经常认为,开发人员应该确保自己在两种范式中都能够熟练运用,这样才能有效地将它们结合使用。
函数链
请允许我介绍你可能从未意识到需要的最好朋友 - Map 函数。这个函数通常也被称为 Chain 和 Pipe,但为了保持一致性,在本书中我们将统一称它为 Map。恐怕很多功能性结构会根据编程语言和实现方式有很多不同的名称,我会尽量在适当时指出。
现在,我是英国人,有一个关于英国人的俗语是我们喜欢谈论天气。这完全是真的。我们的国家有时一天内会经历四季,所以天气对我们来说是一个持续引发兴趣的话题。
曾经我为一家美国公司工作,那时候,当我与同事通过视频通话讨论天气时,话题往往不可避免地转向了天气。他们告诉我外面的温度大约是 100 度。我使用摄氏度工作,所以对我来说这听起来非常像水的沸点。考虑到我的同事并没有因为血液沸腾而尖叫,我怀疑是其他因素在起作用。当然,他们使用的是华氏度,因此我需要将其转换为我理解的单位,用以下公式:
-
减去 32
-
然后,乘以 5
-
然后,除以 9
这将给出大约 38 度的摄氏温度,温暖而舒适,对于人类生活大多数时间是安全的。
我如何在完全这种多步操作中编码这个过程,然后返回一个格式化的字符串呢?我可以将它们全部拼接成一行,就像这样:
public string FahrenheitToCelcius(decimal tempInF) =>
Math.Round(((tempInF-32) *5 / 9), 2) + "°C";
虽然不是很易读,对吧?说实话,在实际编码中,我可能不会对此太过挑剔,但我正在展示一种技术,不想深陷其中,所以请耐心等待。
编写这个多步骤操作的方式如下:
string FahrenheitToCelcius(decimal tempInF)
{
var a = tempInF - 32;
var b = a * 5;
var c = b / 9;
var d = Math.Round(c, 2);
var returnValue = d + "°C";
return returnValue;
}
这样更易读,更易于维护,但仍然存在一个问题。我们正在创建打算仅使用一次然后丢弃的变量。在这个小函数中,这并不是太重要,但如果这是一个庞大的千行函数呢?如果不是这些小小的十进制变量,而是一个大型复杂对象呢?在第 1000 行,那个不打算再次使用的变量仍然在作用域中,并占用内存。创建一个在下一行之后不打算再使用的变量也有点混乱。这就是 Map 发挥作用的地方。
Map 类似于 LINQ Select 函数,但不是作用于可枚举的每个元素,而是作用于对象。任何对象。你传递一个 Lambda 箭头函数,方式与 Select 相同,只是你的 x 参数指的是基础对象。如果你将其应用于可枚举对象,x 参数将指整个可枚举对象,而不是其中的单个元素。
这是我修改后的华氏转摄氏度函数的样子:
public string FahrenheitToCelcius(decimal tempInF) =>
tempInF.Map(x => x - 32)
.Map(x => x * 5)
.Map(x => x / 9)
.Map(x => Math.Round(x, 2))
.Map(x => x + "°C");
完全相同的功能,友好的多阶段操作,但没有丢弃的变量。每个箭头函数执行后,它们的内容就会被垃圾回收。被乘以 5 的十进制 x 在下一个箭头函数获取其结果并将其除以 9 时,也将被处理掉。
这是实现 Map 的方法:
public static class MapExtensionMethods
{
public static TOut Map<TIn, TOut>(this TIn @this, Func<TIn, TOut> f) =>
f(@this);
}
它很小,不是吗?尽管如此,我经常使用这种特定方法。每当我想要对数据进行多步转换时,这使得将整个函数体转换为简单的箭头函数变得更容易,就像我上面基于 Map 的 FahrenheitToCelcius 函数一样。
这个方法还有更高级的版本,包括错误处理等,我将在第七章中详细介绍。但目前,这是一个您可以立即开始玩耍的奇妙小玩具。大叔西蒙送给你的提前圣诞礼物。嘿,嘿,嘿。
如果您不想在每次转换时更改类型,那么可能存在一种更简洁的 Map 实现。如果符合您的需求,这样更清晰、更简洁。
可以这样实现:
public static T Map<T>(this T @this, params Func<T,T>[] transformations) =>
transformations.Aggregate(@this, (agg, x) => x(agg));
使用它,基本的华氏温度到摄氏温度的转换将会像这样:
public decimal FahrenheitToCelcius(decimal tempInF) =>
tempInF.Map(
x => x - 32,
x => x * 5,
x => x / 9
x => Math.Round(x, 2);
这可能值得使用,以节省一些简单情况下的样板代码,比如温度转换。请参阅第八章有关柯里化的一些想法,了解如何使其看起来更好。
分支组合器
我也听说过这个被称为“Converge”。不过我更喜欢“Fork”,它更详细地描述了它的工作原理。Fork 组合器用于接收单个值,然后同时以多种方式处理它,然后将所有这些单独的分支合并为一个单一的最终值。它可以将一些相当复杂的多步计算简化为一行代码。
这个过程大致会像这样运行:
-
从一个单一值开始
-
将其输入一组“分支”函数 - 每个函数都独立作用于原始输入以产生某种输出
-
“join”函数将分支的结果合并为最终结果。
下面是我可能使用它的几个示例。
如果您想在函数定义中指定参数的数量 - 而不是从数组中具有未指定数量的分支,则可以使用 Fork 来计算平均值:
var numbers = new [] { 4, 8, 15, 16, 23, 42 }
var average = numbers.Fork(
x => x.Sum(),
x => x.Count(),
(s, c) => s / c
);
// average = 18
或者这里有个来自过去的东西,一个用于计算三角形斜边的 Fork:
var triangle = new Triangle(100, 200);
var hypotenuse = triangle.Fork(
x => Math.Pow(x.A, 2),
x => Math.Pow(x.B, 2),
(a2, b2) => Math.Sqrt(a2 + b2)
);
实现看起来像这样:
public static class ext
{
public static TOut Fork<TIn, T1, T2, TOut>(
this TIn @this,
Func<TIn, T1> f1,
Func<TIn, T2> f2,
Func<T1,T2,TOut> fout)
{
var p1 = f1(@this);
var p2 = f2(@this);
var result = fout(p1, p2);
return result;
}
}
请注意,拥有两个泛型类型,每个分支一个,意味着这些函数可以返回任何类型的组合。
你也可以轻松地为任意数量的参数编写版本,但你想考虑的每个额外参数都需要一个额外的扩展方法。
如果您想进一步,并且有无限数量的“分支”,那么只要您愿意使用每个生成的相同中间类型,这很容易实现:
public static class ForkExtensionMethods
{
public static TEnd Fork<TStart, TMiddle, TEnd>(
this TStart @this,
Func<TMiddle, TEnd> joinFunction,
params Func<TStart, TMiddle>[] prongs
)
{
var intermediateValues = prongs.Select(x => x(@this));
var returnValue = joinFunction(intermediateValues);
return returnValue;
}
例如,我们可以用它来基于对象创建一个文本描述:
var personData = this.personRepository.GetPerson(24601);
var description = personData.Fork(
prongs => string.Join(Environment.NewLine, prongs),
x => "My name is " + x.FirstName + " " + x.LastName,
x => "I am " + x.Age + " years old.",
x => "I live in " + x.Address.Town
)
// This might, for example, produce:
//
// My name is Jean Valjean
// I am 30 years old
// I live in Montreuil-sur-mer
使用这个分支示例,我们可以轻松地添加更多描述性的行,但保持相同的复杂性和可读性。
Alt 组合器
我也见过这被称为“Or”、“Alternate”和“Alternation”。它用于将一组函数绑定在一起以实现相同的目标,但应该依次尝试,直到其中一个返回一个值。
将其视为“尝试方法 A,如果不行,则尝试方法 B,如果不行,则尝试方法 C,如果还不行,我想我们没办法了”的工作方式。
让我们试着想象一种情景,我们可能希望通过尝试多种方法来查找某物:
var jamesBond = "007"
.Alt(x => this.hotelService.ScanGuestsForSpies(x),
x => this.airportService.CheckPassengersForSpies(x),
x => this.barService.CheckGutterForDrunkSpies(x));
if(jamesBond != null)
this.deathTrapService.CauseHorribleDeath(jamesBond);
- 只要这三种方法中的一种返回与英国政府的一个酒鬼、边缘厌恶女性主义者、凶恶的雇员相对应的值,那么 jamesBond 变量就不会为空。哪个函数首先返回值就是最后一个要运行的函数。
那么在找到我们的敌人已经逃跑之前,我们如何实现这个函数呢?像这样:
public static TOut Alt<TIn, TOut>(this TIn @this, params Func<TIn, TOut>[] args) =>
args.Select(x => x(@this))
.First(x => x != null);
请记住,LINQ 的Select函数采用延迟加载的原则运行,所以即使我看起来在将整个Func数组转换为具体类型,实际上我并没有,因为First函数将阻止在其中一个返回非空值后执行任何元素。LINQ 真是太棒了,不是吗?
Compose
函数式语言的一个共同特性是能够从一组较小的函数构建出一个高阶函数。任何涉及组合函数的过程都称为组合。
JavaScript 库如 RamdaJS³具有出色的组合功能,但是在这种情况下,C#的强类型实际上对其起到了反作用。
在 C#中有几种组合函数的方法。第一种是最简单的,只是使用基本的 Map 函数,如本章前面描述的那样:
var input = 100M;
var f = (decimal x) => x.Map(x => x - 32)
.Map(x => x * 5)
.Map(x => x / 9)
.Map(x => Math.Round(x, 2))
.Map(x => $"{x} degrees");
var output = f(input);
// output = "37.78 degrees"
在这里,f是一个组合的高阶函数。有 5 个函数(例如 x ⇒ x - 32,计算的那些步骤)用于创建它,这些函数被描述为匿名的 lambda 表达式。它们像乐高积木一样组合成一个更大、更复杂的行为。
此时一个有效的问题是 - 组合函数的意义何在?
答案是,您不一定要一次完成整个过程。您可以分步构建它,然后最终使用相同的基础部件创建许多函数。
现在想象一下,我还想拥有一个表示相反转换的Func委托 - 我们最终会得到两个这样的函数:
var input = 100M;
var fahrenheitToCelcius = (decimal x) => x.Map(x => x - 32)
.Map(x => x * 5)
.Map(x => x / 9)
.Map(x => Math.Round(x, 2))
.Map(x => $"{x} degrees");
var output = fahrenheitToCelcius(input);
Console.WriteLine(output);.
// 37.78 degrees
var input2 = 37.78M;
var celciusToFahrenheit = (decimal x) =>
x.Map(x => x * 9)
.Map(x => x / 5)
.Map(x => x + 32)
.Map(x => Math.Round(x, 2))
.Map(x => $"{x} degrees");
var output2 = celciusToFahrenheit(input2);
Console.WriteLine(output2);
// 100.00 degrees
每个函数的最后两行实际上是相同的。重复每次都重复它们有点浪费吗?我们可以使用 Compose 函数消除这种重复:
var formatDecimal = (decimal x) => x
.Map(x => Math.Round(x, 2))
.Map(x => $"{x} degrees");
var input = 100M;
var celciusToFahrenheit = (decimal x) => x.Map(x => x - 32)
.Map(x => x * 5)
.Map(x => x / 9);
var fToCFormatted = celciusToFahrenheit.Compose(formatDecimal);
var output = fToCFormatted(input);
Console.WriteLine(output);
var input2 = 37.78M;
var celciusToFahrenheit = (decimal x) =>
x.Map(x => x * 9)
.Map(x => x / 5)
.Map(x => x + 32);
var cToFFormatted = celciusToFahrenheit.Compose(formatDecimal);
var output2 = cToFFormatted(input2);
Console.WriteLine(output2);
从功能上讲,这些使用 Compose 的新版本与仅使用 Map 的先前版本是相同的。
Compose 函数执行的任务与 Map 几乎相同,不同之处在于我们最终生成的是一个Func委托,而不是最终值。这是执行 Compose 过程的代码:
public static class ComposeExtensionMethods
{
public static Func<TIn, NewTOut> Compose<TIn, OldTOut, NewTOut>(
this Func<TIn, OldTOut> @this,
Func<OldTOut, NewTOut> f) =>
x => f(@this(x));
}
使用 Compose,我们已经消除了一些不必要的复制。任何对格式化过程的改进都将同时应用于Func委托对象。
然而存在一个限制。在 C#中,不能将扩展方法附加到 lambda 表达式或直接到函数上。如果我们将 lambda 表达式引用为Func或Action委托,则可以将扩展方法附加到 lambda 表达式,但是在此之前,它需要被分配到一个变量中,以便自动设置为委托类型。这就是为什么在上面的示例中,在调用Compose之前需要将Map函数链分配给变量的原因 - 否则,可以简单地在Map链的末尾调用Compose并节省变量分配。
这个过程与面向对象编程中通过继承重用代码类似,只是在单行级别进行,而且需要的样板代码要少得多。它还将这些类似的相关代码放在一起,而不是必须分散在不同的类和文件中。
转换
Transducer 是一种将基于列表的操作(如 Select 和 Where)与某种形式的聚合结合起来,对值列表执行多个转换,最后将其折叠为单个最终值的方法。
虽然 Compose 是一个有用的功能,但它也有一些限制。它实际上只替代了一个 Map 函数的位置 - 即它作用于整个对象,并且无法对可枚举对象执行 LINQ 操作。你可以在数组中组合并放入 Select 和 Where 操作,但老实说,这看起来非常凌乱:
var numbers = new [] { 4, 8, 15, 16, 23, 42 };
var add5 = (IEnumerable<int> x) => x.Select(y => y + 5);
var Add5MultiplyBy10 = add5.Compose(x => x.Select(y => y * 10));
var numbersGreaterThan100 = Add5MultiplyBy10.Compose(x => x.Where(y => y > 100));
var composeMessage = numbersGreaterThan100.Compose(x => string.Join(",", x));
Console.WriteLine("Output = " + composeMessage(numbers));
// Output = 130,200,210,280,470
如果你对此感到满意,那么尽管使用它。本质上并没有什么错,除了相当不雅。
还有另一种结构可以使用 - Transduce。Transduce 操作作用于数组,并代表功能流的所有阶段:
-
过滤(即
.Where)- 减少元素的数量 -
转换(即
.Select)- 将它们转换为新的形式 -
聚合(即,嗯... 实际上就是聚合)- 使用这些规则将许多项的集合缩减为单个项。
在 C#中可以有许多实现方式,但这是一种可能性:
public static TFinalOut Transduce<TIn, TFilterOut, TFinalOut>(
this IEnumerable<TIn> @this,
Func<IEnumerable<TIn>, IEnumerable<TFilterOut>> transformer,
Func<IEnumerable<TFilterOut>, TFinalOut> aggregator) =>
aggregator(transformer(@this));
此扩展方法采用一个转换方法 - 用户定义的Select和Where的任何组合,最终将可枚举对象从一种形式和大小转换为另一种形式。该方法还接受一个聚合器,将转换器的输出转换为单个值。
这是我上面定义的组合函数如何使用此版本的 Transduce 方法实现的方式:
var numbers = new [] { 4, 8, 15, 16, 23, 42 };
// N.B - I could make this a single line with brackets, but
// I find this more readable, and it's functionally identical due
// to lazy evaluation of Enumerables
var transformer = (IEnumerable<int> x) => x
.Select(y => y + 5)
.Select(y => y * 10)
.Where(y => y > 100);
var aggregator = (IEnumerable<int> x) => string.Join(", ", x);
var output = numbers.Transduce(transformer, aggregator);
Console.WriteLine("Output = " + output);
// Output = 130, 200, 210, 280, 470
或者,如果您更喜欢将所有东西都处理为Func委托,以便可以重用 Transducer 函数,则可以这样编写:
var numbers = new [] { 4, 8, 15, 16, 23, 42 };
var transformer = (IEnumerable<int> x) => x
.Select(y => y + 5)
.Select(y => y * 10)
.Where(y => y > 100);
var aggregator = (IEnumerable<int> x) => string.Join(", ", x);
var transducer = transformer.ToTransducer(aggregator);
var output2 = transducer(numbers);
Console.WriteLine("Output = " + output2);
这是更新后的扩展方法:
public static class TransducerExtensionMethod
{
public static Func<IEnumerable<TIn>, NewTOut> ToTransducer<TIn, OldTOut, NewTOut>(
this Func<IEnumerable<TIn>,
IEnumerable<OldTOut>> @this,
Func<IEnumerable<OldTOut>, NewTOut> aggregator) =>
x => aggregator(@this(x));
}
现在我们生成了一个Func委托变量,可以作为函数在任意多个整数数组上使用,该单一Func将执行所需数量的转换和过滤,然后将数组聚合为单个最终值。
点击
我经常听到有人对函数链提出的一个普遍关注是,在其中执行记录日志是不可能的 - 除非你将链中的一个链接指向一个带有记录调用的单独函数。
函数式编程中有一种技术,可以用来在任何函数链的某个点检查函数链的内容 - Tap 函数。
Tap 函数有点像旧侦探片中的窃听器⁴。它允许监视和处理信息流,但不会干扰或改变它。
实现 Tap 的方式如下:
public static class Extensions
{
public static T Tap<T>(this T @this, Action<T> action)
{
action(@this);
return @this;
}
一个Action委托实际上就像一个无返回值的函数。在这个实例中,它接受一个参数 - 一个泛型类型 T。Tap 函数将链中当前对象的当前值传递给 Action,在那里可以进行记录,然后返回相同对象的未修改副本。
你可以像这样使用它:
var input = 100M;
var fahrenheitToCelcius = (decimal x) => x.Map(x => x - 32)
.Map(x => x * 5)
.Map(x => x / 9)
.Tap(x => this.logger.LogInformation("the un-rounded value is " + x))
.Map(x => Math.Round(x, 2))
.Map(x => $"{x} degrees");
var output = fahrenheitToCelcius(input);
Console.WriteLine(output);
// 37.78 degrees
在这个新版本的华氏度转换为摄氏度的函数链中,我现在在基本计算完成后开始窥探它,但在我开始四舍五入和格式化字符串之前。
我在 Tap 中添加了一个调用记录器的调用,但你可以将其换成Console.WriteLine或者其他你想要的东西。
尝试/捕获
函数式编程中有几种更高级的结构来处理错误。如果你只是想要一些快速简单的东西,你可以在几行代码中快速实现它,但它有其局限性,请继续阅读。否则,试着提前看看下一章的歧视联盟,以及在高级函数结构之后的章节。在那里可以找到大量关于处理没有副作用的错误的内容。
但是现在,让我们看看我们可以用几行简单的代码做些什么……
理论上,在函数风格的代码中间不应该有任何错误。如果一切都按照无副作用的代码、不可变变量等函数式原则进行,你应该是安全的。然而,在边缘处总是可能有一些被认为是不安全的交互。
假设你有一个场景,你想要在一个整数 Id 的外部系统中进行查找。这个外部系统可以是数据库,Web API,网络共享上的平面文件,或者任何其他东西。这些可能性的共同点是,它们中的任何一个都可能由于多种原因而失败,其中很少有任何一个是开发者的错。
可能会出现网络问题,本地或远程计算机上的硬件问题,无意中的人为干预。问题的列表还在继续……
这就是你通常在面向对象代码中处理这种情况的方式:
pubic IEnumerable<Snack> GetSnackByType(int typeId)
{
try
{
var returnValue = this.DataStore.GetSnackByType(typeId);
return returnValue;
}
catch(Exception e)
{
this.logger.LogError(e, $"There aren't any pork scratchings left!");
return Enumerable.Empty<Snack>()
}
}
关于这个代码块,有两件事我不喜欢。首先是我们必须用多少样板代码来填充代码。我们必须添加大量强大的编码以保护自己,以防我们没有引起的问题。
另一个问题是 try/catch 块本身。它打破了操作顺序,将程序执行从原来的位置移动到一些可能难以找到的地方。在这种情况下,这是一个很好、简单、紧凑的小函数,并且很容易确定 Catch 的位置。不过,我曾在代码库中工作过,其中 Catch 位于比故障发生位置高几层的函数中。在那个代码库中,由于假设某些代码行会被执行而实际上未被执行,经常出现错误。
说实话,我可能对上面的代码块在生产中并不会有太多问题,但如果不加以检查,不良编码实践可能会渗入其中。代码中没有任何阻止未来编码人员在此处引入多级嵌套函数的机制。
我认为最好的解决方案是采用一种方法,消除所有样板文件,并使引入不良代码结构变得困难,甚至不可能。
类似这样:
pubic IEnumerable<Snack> GetSnackByType(int typeId)
{
var result = typeId.MapWithTryCatch(this.DataStore.GetSnackByType)
?? Enumerable.Empty<Snack>();
return result;
}
我正在执行一个带有内嵌 Try/Catch 的 Map 函数。新的 Map 函数如果一切正常则返回一个值,如果失败则返回 null。
扩展方法如下:
public static class Extensions
{
public static TOut MapWithTryCatch<TIn,TOut>(this TIn @this, Func<TIn,TOut> f)
{
try
{
return f(@this);
}
catch()
{
return default;
}
}
}
不过这并不是完美的解决方案。那么错误日志记录呢?这是未记录错误消息的重大错误。
有几种方法可以考虑解决这个问题。任何一种都可以,所以按照你的兴致去做。
一种选择是改为使用一个接受 ILogger 实例并返回包含 Try/Catch 功能的 Func 委托的扩展方法。类似这样:
public static class TryCatchExtensionMethods
{
public static TOut CreateTryCatch<TIn,TOut>(this TIn @this, ILogger logger)
{
Func<TIn,TOut> f =>
{
try
{
return f(@this);
}
catch(Exception e)
{
logger.LogError(e, "An error occurred");
return default;
}
}
}
}
使用方法基本相似:
public IEnumerable<Snack> GetSnackByType(int typeId)
{
var tryCatch = typeId.CreateTryCatch(this.logger);
var result = tryCatch(this.DataStore.GetSnackByType)
?? Enumerable.Empty<Snack>();
return result;
}
只增加了一行样板文件,现在开始记录。可惜的是,除了错误本身外,我们无法在消息中添加任何具体内容。扩展方法不知道它被调用的位置或错误的上下文,这使得在整个代码库中重复使用该方法非常方便。
如果你不希望 Try/Catch 意识到 ILogger 接口,或者每次都想提供自定义错误消息,那么我们需要考虑一些更复杂的方法来处理错误消息。
另一种选择是返回一个包含正在执行的函数的返回值以及一些关于是否工作、是否存在错误及其内容的元数据对象。类似这样:
public class ExecutionResult<T>
{
public T Result { get; init; }
public Exception Error { get; init; }
}
public static class Extensions
{
public static ExtensionResult<TOut> MapWithTryCatch<TIn,TOut>(this TIn @this, Func<TIn,TOut> f)
{
try
{
var result = f(@this);
return new ExecutionResult<TOut>
{
Result = result
};
}
catch(Exception e)
{
return new ExecutionResult<TOut>
{
Error = e
};
}
}
}
我真的不喜欢这种方法。它违反了面向对象设计的 SOLID 原则之一 - 接口隔离原则。嗯,有点。从技术上讲,这适用于接口,但我尽量在任何地方都应用它。即使我写函数式代码。理念是,我们不应被迫在类或接口中包含我们实际上不需要的内容。在这里,我们强制一个成功运行包含一个 Exception 属性,它永远不会需要,同样,一个失败运行将不得不包含它永远不会需要的 Result 属性。
还有其他方法可以做到这一点,但我选择了简单的方法,并返回了一个带有结果的 ExecutionResult 类的版本,或者一个带有默认值的 Result 和返回的异常。
这意味着我可以像这样调用它:
pubic IEnumerable<Snack> GetSnackByType(int typeId)
{
var result = typeId.MapWithTryCatch(this.DataStore.GetSnackByType);
if(result.Value == null)
{
this.Logger.LogException(result.Error, "We ran out of jammy dodgers!");
return Enumerable.Empty<Snack>();
}
return result.Result;
}
除了不必要的字段外,这种方法还有另一个问题 - 现在开发者使用 Try/Catch 函数需要增加额外的样板代码来检查错误。
跳到下一章节,以更纯函数式的方式处理此类返回值的替代方法。但现在,这里有一种稍微更清洁的处理方式。
首先,我将添加另一个扩展方法。这次是附加到 ExecutionResult 对象:
public static T OnError<T>(this ExecutionResult<T> @this, Action<Exception> errorHandler)
{
if (@this.Error != null)
errorHandler(@this.Error);
return @this.Result;
}
我这里做的第一步是先检查是否有错误。如果有,那么执行用户定义的 Action - 这可能是一个日志操作。最后,将 ExecutionResult 解包成其实际返回的数据对象。
所有这些意味着你现在可以这样处理 Try/Catch:
public IEnumerable<Snack> GetSnackByTypeId(int typeId) =>
typeId.MapWithTryCatch(DataStore.GetSnackByType)
.OnError(e => this.Logger.LogError(e, "We ran out of custard creams!"));
虽然这远非完美的解决方案,但在不深入函数理论的情况下,它是可行且优雅的,足以不触发我的内部完美主义。这也迫使用户在使用时考虑错误处理,这只能是一件好事!
处理空值
空引用异常很烦人,不是吗?如果你想责怪某人,那就是一个叫 Tony Hoare 的家伙,他在 60 年代发明了 Null 的概念。不过,我们最好不要责怪任何人。我相信他是一个可爱的人,所有认识他的人都喜欢他。无论如何,我们可以希望大家都同意,空引用异常确实是一个很大的麻烦。
那么,有没有一种函数式的方式来处理它们?如果你读到这里,你可能知道答案将会是一个响亮的“是!”⁵。
Unless 函数接受一个布尔条件和一个 Action 委托,并且仅在布尔条件为假时执行 Action - 即 Action 总是执行,除非 条件为真。
这样的用法最常见的情况就是 - 你猜对了 - 检查空值。
这是一个我试图替换的代码示例。这是一个很少见的 Dalek 的源代码片段⁶:
public void BusinessAsUsual()
{
var enemies = this.scanner.FindLifeforms('all');
foreach(var e in enemies)
{
this.Gun.Blast(e.Coordinates.Longitude, e.Coordinates.Latitude);
this.Speech.ScreamAt(e, "EXTERMINATE");
}
}
这一切都很好,也许会留下许多人被一个移动的胡椒罐形状的精神病突变体杀死。但是,如果 Coordinates 对象因某种原因为空呢?没错——空引用异常。
这就是我们使其功能化并引入 Unless 函数以防止异常发生的地方。这就是 Unless 的样子:
public static class UnlessExtensionMethods
{
public void Unless<T>(this T @this, Func<bool> condition, Action<T> f)
{
if(!condition(@this)
{
f(@this);
}
}
}
很遗憾,它必须是一个 void。如果我们将 Action 换成 Func,那么从扩展方法返回 Func 的结果是可以的。然而,当条件为真时,我们不执行时怎么办?那我返回什么?这个问题真的没有一个答案。
这是我如何用它来制作我的新的、超级、更致命的功能达雷克:
public void BusinessAsUsual()
{
var enemies = this.scanner.FindLifeforms('all');
foreach(var e in enemies)
{
e.unless(
x => x.Coordinates == null,
x => this.Gun.Blast(e.Coordinates.Longitude, e.Coordinates.Latitude)
)
// May as well do this anyway, since we're here.
this.Speech.ScreamAt(e, "EXTERMINATE");
}
}
使用这个方法,一个空的 Coordinates 对象不会导致异常,枪根本不会被开火。
在接下来的几章中,有更多方法可以预防空指针异常——这些方法需要更高级的编码和一些理论,但在工作方式上更加彻底。敬请期待。
更新一个 Enumerable
我将用一个有用的示例结束这一节。它涉及更新一个 Enumerable 中的元素,而不改变任何数据!
关于可枚举的要记住的一点是,它们被设计用来利用“惰性评估”——即直到最后可能的时刻才实际从指向数据源的一组函数转换为实际数据。很多时候,使用 Select 函数并不会触发评估,因此我们可以用它们有效地创建过滤器,坐落在数据源和代码中枚举数据的位置之间。
这是修改 Enumerable 的一个示例,使位置 x 处的项目被替换:
var sourceData = new []
{
"Hello", "Doctor", "Yesterday", "Today", "Tomorrow", "Continue"
}
var updatedData = sourceData.ReplaceAt(1, "Darkness, my old friend");
var finalString = string.Join(" ", updatedData);
// Hello Darkness, my old friend Yesterday Today Tomorrow Continue
我所做的是调用一个函数来替换位置 1(即“Doctor”)的元素为一个新值。尽管有两个变量,在这段代码片段结束后,对源数据实际上并没有做任何操作。此外,直到调用 string.Join 时,才会进行实际的替换,因为那是需要具体值的时刻。
这是如何完成的:
public static class Extensions
{
public static IEnumerable<T> ReplaceAt(this IEnumerable<T> @this,
int loc,
T replacement) =>
@this.Select((x, i) => i == loc ? replacement : x);
}
这里返回的 Enumerable 实际上指向原始 Enumerable,并从那里获取其值,但有一个关键的区别。如果元素的索引等于用户定义的值(在我们的示例中是第二个元素,即 1),那么所有其他值都将不变地传递。
如果你愿意的话,你可以提供一个函数来执行更新——让用户能够基于正在被替换的旧数据项来生成新版本的数据项。
这是你会做到的:
public static class Extensions
{
public static IEnumerable<T> ReplaceAt(this IEnumerable<T> @this,
int loc,
Func<T, T> replacement) =>
@this.Select((x, i) => i == loc ? replacement(x) : x);
}
使用起来也很简单:
var sourceData = new []
{
"Hello", "Doctor", "Yesterday", "Today", "Tomorrow", "Continue"
}
var updatedData = sourceData.ReplaceAt(1, x => x + " Who");
var finalString = string.Join(" ", updatedData);
// Hello Doctor Who Yesterday Today Tomorrow Continue
我们也可能不知道要更新的元素的 Id - 实际上可能有多个要更新的项目。这是一种基于提供 T 到 Bool 转换 Func 的替代 Enumerable 更新函数,用于标识应该更新的记录。
这个例子是基于桌游 - 我最喜欢的爱好之一 - 让我永远耐心的妻子很恼火!在这种情况下,BoardGame 对象上有一个 Tag 属性,其中包含描述游戏的元数据标签(“家庭”,“合作”,“复杂”等),这将被搜索引擎应用程序使用。已决定为适合单人游戏的游戏添加另一个标签 - “独奏”。
var sourceData = this.DataStore.GetBoardGames();
var updatedData = sourceData.ReplaceWhen(
x => x.NumberOfPlayersAllowed.Contains(1),
x => x with { Tags = x.Tags.Append("solo") });
this.DataStore.Save(updatedData);
实现是我们已经讨论过的代码的变体:
public static class ReplaceWhenExtensions
{
public static IEnumerable<T> ReplaceWhen<T>(this IEnumerable<T> @this,
Func<T, bool> shouldReplace,
Func<T, T> replacement) =>
@this.Select(x => shouldReplace(x) ? replacement(x) : x);
}
此函数可用于替代许多 If 语句的需求,并将它们简化为更简单、更可预测的操作。
结论
在本章中,我们探讨了使用高阶函数概念开发丰富功能以避免需要面向对象风格语句的各种方法。
如果您有任何关于自己用于高阶函数用途的想法,请随时与我们联系。您永远不知道,它可能会出现在本书的未来版本中!
在接下来的章节中,我们将探讨辨识联合,以及这个函数式概念如何帮助更好地模拟代码库中的概念,并消除通常在非函数式项目中所需的大量防御性代码。享受吧!
¹ 理想情况下,您可以找到最热辣的口味。吃时火焰应从您的嘴里冒出!
² 在此处了解更多信息:https://en.wikipedia.org/wiki/SOLID - 或者如果您更喜欢视频,这里有一个由我亲自主持的视频:https://www.youtube.com/watch?v=0vJb_B47J6U
³ 自己看看这里:https://ramdajs.com/
⁴ 我猜那可能就是它们得名的原因
⁵ 还有,祝贺您走到了这一步。虽然你花的时间可能没有我多!
⁶ 对于未了解的人来说,它们是英国科幻电视系列《Doctor Who》中的主要反派。在这里看看它们的表现:https://www.youtube.com/watch?v=d77jOE2Cjx8
第六章:区分联合
区分联合(DUs)是一种定义类型(或在 OO 世界中的类)的方式,实际上是一组不同类型中的一种。在任何给定时刻,必须在使用之前检查 DU 实例的类型。
F#本地支持 DUs,并且这是 F#开发人员广泛使用的功能。尽管与 C#共享一个公共运行时,并且该功能理论上可用,但目前只有计划在某个时候将其引入 C#中 - 但不确定如何或何时。在此期间,我们可以用抽象类粗略模拟它们,这就是我将在本章中讨论的技术。
本章是我们首次涉足一些更高级的函数式编程领域。本书的前几章更侧重于开发者如何聪明工作,而不是辛苦工作。我们还探讨了如何减少样板文件,并使代码更健壮和可维护。
区分联合¹是一种编程结构,也可以做到这一点,但不仅仅是一个简单的扩展方法,或者是一个单行修复以消除一些样板文件。DUs 更接近于设计模式的概念 - 因为它们有一个结构,并且需要围绕它实现一些逻辑。
假日时间
让我们想象一个老式的面向对象问题,我们正在为度假套餐创建一个系统。你知道的 - 旅行社为您安排旅行、住宿等等。我会让你想象一下你要去的美丽目的地。就我个人而言,我非常喜欢希腊群岛。
public class Holiday
{
public int Id { get; set; }
public Location Destination { get; set; }
public Location DepartureAirport { get; set; }
public DateTime StartDate { get; set; }
public int DurationOfStay { get; set; }
}
public class HolidayWithMeals : Holiday
{
public int NumberOfMeals { get; set; }
}
现在想象一下,我们正在为客户创建一个账户页面²,我们想列出他们迄今为止购买的所有东西。实际上并不那么困难。我们可以使用一些相对较新的is语句来构建必要的字符串。以下是我们可以做的一种方式:
public string formatHoliday(Holiday h) =>
"From: " + h.DepartureAirport.Name + Environment.NewLine +
"To: " + h.Destination.Name + Environment.NewLine +
"Duration: " + h.DurationOfStay + " Day(s)" +
(
h is HolidayWithMeals hm
? Environment.NewLine + "Number of Meals: " + hm.NumberOfMeals
: string.Empty
);
如果我想快速引入一些功能性思想来改进这个问题,我可以考虑引入一个分支组合子(见上一章),基本类型是假日,子类型是带有餐饮的假日。本质上是相同的东西,但多了一个或两个额外的字段。
如果…公司启动了一个项目。现在,他们将开始提供与度假无关的其他类型的服务。他们还将开始提供不涉及酒店、航班或其他任何类似事物的日游。也许是伦敦的塔桥入口³,或者是巴黎的埃菲尔铁塔的快速游览。无论你喜欢什么。世界是你的。
对象看起来会像这样:
public class DayTrip
{
public int Id { get; set; }
public DateTime DateOfTrip { get; set; }
public Location Attraction { get; set; }
public bool CoachTripRequired { get; set; }
}
但问题是,如果我们想要从一个假日对象继承来表示这种新情况,这是行不通的。我见过一些人采取的方法是将所有字段合并在一起,以及一个布尔值来指示应该查看哪些字段。
类似这样:
public class CustomerOffering
{
public int Id { get; set; }
public Location Destination { get; set; }
public Location DepartureAirport { get; set; }
public DateTime StartDate { get; set; }
public int DurationOfStay { get; set; }
public bool CoachTripRequired { get; set; }
public bool IsDayTrip { get; set; }
}
这是一个糟糕的想法,原因有几个。首先,你违反了接口隔离原则。无论它真正是哪种类型,你都在强制它保存对它来说无关的字段。我们还将“Destination”和“Attraction”的概念重复了一遍,以及这里的“DateOfTrip”和“StartDate”,以避免重复,但这意味着我们失去了一些使处理日行程代码有意义的术语。
另一个选择是将它们作为完全独立的对象类型保留,彼此之间没有任何关系。尽管如此,这样做会失去一个很好的特性,即能够通过简洁的方式遍历每个对象。我们无法按日期顺序在单个表中列出所有内容。必须有多个表。
所有这些可能性似乎都不太好。但这正是 DUs 以优化解决方案应对问题的地方。在下一节中,我将向你展示如何使用它们来提供最佳解决方案。
使用鉴别联合的节日
在 F# 中,你可以像这样为我们的客户提供示例创建一个联合类型:
type CustomerOffering =
| Holiday
| HolidayWithMeals
| DayTrip
这意味着你可以实例化一个新的 CustomerOffering 实例,但有三种不同的类型,每种类型可能有其自己完全不同的属性。
这是我们在 C# 中可以接近这种方法的方式:
public abstract class CustomerOffering
{
public int Id { Get; set; }
}
public class Holiday : CustomerOffering
{
public Location Destination { get; set; }
public Location DepartureAirport { get; set; }
public DateTime StartDate { get; set; }
public int DurationOfStay { get; set; }
}
public class HolidayWithMeals : Holiday
{
public int NumberOfMeals { get; set; }
}
public class DayTrip : CustomerOffering
{
public DateTime DateOfTrip { get; set; }
public Location Attraction { get; set; }
public bool CoachTripRequired { get; set; }
}
表面上看,它似乎与类集合的第一个版本并没有完全不同,但有一个重要的区别。基类是抽象的 - 你实际上不能创建一个 CustomerOffering 类。它不是一个具有一个顶级父类的类族树,而是所有子类都是不同的,但在层次结构中是相等的。
这里有一个类层次结构图,可以更清晰地显示两种方法之间的区别:
DayTrip 类在任何情况下都不必符合与 Holiday 类有关的任何概念。DayTrip 完全是自己的东西。这意味着它可以使用与其自身业务逻辑完全相符的属性名称,而不必追溯 Holiday 的一些属性。换句话说 - DayTrip 不是 Holiday 的扩展,而是其替代品。
这还意味着你可以有所有 CustomerOfferings 的单个数组,尽管它们之间差异很大。无需分开的数据源。
我们可以在代码中处理一个 CustomerOffering 对象数组,使用模式匹配语句:
public string formatCustomerOffering(CustomerOffering c) =>
c switch
{
HolidayWithMeals hm => this.formatHolidayWithMeal(hm),
Holiday h => this.formatHoliday(h),
DayTrip dt => this.formatDayTrip(tp)
};
这简化了接收鉴别联合的所有代码,并产生了更具描述性的代码,更准确描述函数的所有可能结果。
薛定谔的联合
如果你想要一个关于这些工作原理的类比,想想可怜的薛定谔的猫。这是奥地利物理学家厄温·薛定谔提出的一个思想实验,旨在突出量子力学中的悖论。这个想法是,给定一个包含一只猫和一个有 50-50 几率衰变的放射性同位素的盒子,这会杀死猫。关键是,根据量子物理学,直到有人打开盒子检查猫时,猫同时处于生和死的两种状态。这意味着猫同时是活着和死的。
这也意味着,如果薛定谔先生将他的猫/同位素盒子邮寄给一个朋友,他们会得到一个可能包含两种状态之一的盒子,直到他们打开它,他们不知道是哪种状态。当然,邮政服务是什么样子的,猫到达时可能已经死了无论如何。这就是为什么你真的不应该在家里尝试这个。相信我,我不是医生,也不在电视上扮演医生。
这就是歧视性联合的工作原理。一个返回的值,但可能存在于两种或更多状态中。在检查之前,你不知道它是哪种状态。
如果一个类不关心它的状态,你甚至可以将其传递给它的下一个目的地而不打开它。
作为代码的薛定谔的猫可能看起来像这样:
public abstract class SchrödingersCat { }
public class AliveCat : SchrödingersCat { }
public class DeadCat : SchrödingersCat { }
我希望你现在清楚歧视性联合实际上是什么。我将在本章的其余部分演示一些它们的示例。
命名约定
让我们想象一个用于从个体组件中写出人名的代码模块。如果你有一个传统的英国名字,就像我的名字一样,那么这就相当简单。一个用于写我的名字的类看起来会像这样:
public class BritishName
{
public string FirstName { get; set; }
public IEnumerable<string> MiddleNames { get; set; }
public string LastName { get; set; }
public string Honorific { get; set; }
}
var simonsName = new BritishName
{
Honorific = "Mr.",
FirstName = "Simon",
MiddleNames = new [] { "John" },
LastName = "Painter
};
渲染代码将会像这样简单:
public string formatName(BritishName bn) =>
bn.Honorific + " " bn.FirstName + " " + string.Join(" ", bn.MiddleNames) +
" " + bn.LastName;
// Results in "Mr Simon John Painter"
全部完成了,对吧?好吧,这适用于传统的英国名字,但中国名字呢?它们的书写顺序与英国名字不同。中国名字的书写顺序是<姓><名>,许多中国人还有一个“字” - 一个西式名字,专业上使用。
让我们以传奇演员、导演、作家、特技演员、歌手和全能人类 - 成龙为例。他的真实姓名是房仕龙。在这组名字中,他的姓氏是房。他的个人姓名(通常在英语中称为名字或基督教名)是仕龙。成龙是他从很小就用的一个敬称。这种名字风格与我上面创建的 formatName 函数根本不奏效。
我可能会稍微修改数据使其工作。类似这样:
var jackie = new BritishName
{
Honorific = "Xiānsheng", // equivalent of "Mr."
FirstName = "Fang",
LastName = "Shilong"
}
// results in "xiānsheng Fang Shilong"
所以,很好,这样才能正确按顺序写出他的两个官方名称。但他的谦称呢?没有东西写出来。另外,“先生”的中文等价词 - 先生⁷ - 实际上在名字之后,所以这实际上相当糟糕 - 即使我们试图重新使用现有的字段。
我们可以向代码中添加大量的if语句来检查所描述的人员的国籍,但如果我们尝试扩展以包括超过 2 种国籍,这种方法很快就会变成噩梦。
再次强调,更好的方法是使用带标签的联合体来表示根本不同的数据结构,以一种能够反映它们试图表示的事物实际情况的形式。
public abstract class Name { }
public class BritishName : Name
{
public string FirstName { get; set; }
public IEnumerable<string> MiddleNames { get; set; }
public string LastName { get; set; }
public string Honorific { get; set; }
}
public class ChineseName : Name
{
public string FamilyName { get; set; }
public string GivenName { get; set; }
public string Honorific { get; set; }
public string CourtesyName { get; set; }
}
在我的想象场景中,可能为每种名称类型分别存在独立的数据源 - 每个都有自己的架构。也许每个国家都有一个 Web API?
使用这个联合体,我们实际上可以创建一个包含我和成龙的名字数组⁸
var names = new Name[]
{
new BritishName
{
Honorific = "Mr.",
FirstName = "Simon",
MiddleNames = new [] { "John" },
LastName = "Painter"
},
new ChineseName
{
Honorific = "Xiānsheng",
FamilyName = "Fang",
GivenName = "Shilong",
CourtestyName = "Jackie"
}
}
然后,我可以通过模式匹配表达式扩展我的格式化函数:
public string formatName(Name n) =>
n switch
{
BritishName bn => bn.Honorific + " " bn.FirstName + " "
+ string.Join(" ", bn.MiddleNames) + " " + bn.LastName,
ChineseName cn => cn.FamilyName + " " + cn.GivenName + " " +
cn.Honorific + " \"" + cn.CourtesyName + "\""
};
var output = string.Join(Environment.NewLine, names);
// output =
// Mr. Simon John Painter
// Fang Shilong Xiānsheng "Jackie"
同样的原则可以应用于世界任何地方的任何风格的命名,给定的字段名称将始终对该国家有意义,并且始终以正确的样式显示,而不是重新使用现有字段。
数据库查找
我通常会考虑在 C#中将带标签的联合体作为接口定义的函数的返回类型的情况。
我特别可能在查找数据源的查找函数中使用这种技术的领域。假设你想在某种系统中找到某人的详细信息。该函数将接受一个整数 Id 值,并返回一个 Person 记录。
至少通常会发现人们这样做。像这样的东西:
public Person GetPerson(int id)
{
// Fill in some code here. Whatever data
// store you want to use. Except mini-disc.
}
但是如果你仔细想想,返回一个Person对象只是函数可能的一种返回状态。
如果输入了一个不存在的人员 Id,会怎么样?你可以返回Null,我想,但这并不描述实际发生的情况。如果有一个处理过的Exception导致没有返回任何东西呢?Null并没有告诉你返回它的原因。
另一种可能性是引发Exception。这可能不是你的代码的错,但如果存在网络问题或其他问题,这种情况确实可能发生。在这种情况下,你会返回什么?
而不是返回一个没有解释的Null并强制代码库的其他部分处理它,或者在其中包含异常等元数据字段的替代返回类型对象,我们可以创建一个带标签的联合体:
public abstract class PersonLookupResult
{
public int Id { get; set; }
}
public class PersonFound : PersonLookupResult
{
public Person Person { get; set; }
}
public class PersonNotFound : PersonLookupResult
{
}
public class ErrorWhileSearchingPerson : PersonLookupResult
{
public Exception Error { get; set; }
}
所有这些意味着我们现在可以从我们的 GetPersonById 函数中返回一个单一的类,告诉使用该类的代码一个已返回这三种状态之一,但已经确定是哪一个。不需要对返回的对象应用逻辑来确定它是否起作用,这些状态完全描述了需要处理的每种情况。
函数看起来可能像这样:
public PersonLookupResult GetPerson(int id)
{
try
{
var personFromDb = this.Db.Person.Lookup(id);
return personFromDb == null
? new PersonNotFound { Id = id }
: new PersonFound
{
Person = personFromDb,
Id = id
};
}
catch(Exception e)
{
return new ErrorWhileSearchingPerson
{
Id = id,
Error = e
}
}
}
再次消耗它就是使用模式匹配表达式确定要做什么:
public string DescribePerson(int id)
{
var p = this.PersonRepository.GetPerson(id);
return p switch
{
PersonFound pf => "Their name is " + pf.Name,
PersonNotFound _ => "Person not found",
ErrorWhileSearchingPerson e => "An error occurred" + e.Error.Message
};
}
发送电子邮件
上一个示例适用于期望返回值的情况,但如果没有返回值呢?假设我写了一些代码给客户或家人发送电子邮件,但不想自己写信息⁹。
我不期望有任何回报,但如果发生错误,我可能想知道,所以这一次我特别关心的只有两种状态。
我会这样完成它:
public abstract class EmailSendResult
{
}
public class EmailSuccess : EmailSendResult
{
}
public class EmailFailure : EmailSendResult
{
pubic Exception Error { get; set; }
}
在代码中使用这个类可能会是这样:
public EmailSendResult SendEmail(string recipient, string message)
{
try
{
this.AzureEmailUtility.SendEmail(recipient, message);
return new EmailSuccess();
}
catch(Exception e)
{
return new EmailFailure
{
Error = e
};
}
}
在代码库中的其他地方使用函数会是这样:
var result = this.EmailTool.SendEmail("Season's Greetings", "Hi, Uncle John. How's it going?");
var messageToWriteToConsole = result switch
{
EmailFailure ef => "An error occurred sending the email: " + ef.Error.Message,
EmailSuccess _ => "Email send successful",
_ => "Unknow Response"
};
this.Console.WriteLine(messageToWriteToConsole);
这意味着我可以再次从函数中返回错误消息和失败状态,但没有任何地方依赖于不需要的属性。
控制台输入
有一段时间我产生了一个疯狂的想法,通过将一款使用 HP Timeshare BASIC 编写的旧文本游戏转换成功能式的 C#,来尝试我的函数式编程技能。
游戏名叫《俄勒冈之旅》,可以追溯到 1975 年。难以置信,竟然比我还要老!甚至比《星球大战》还要老。事实上,它甚至早于显示器问世,当时必须在看起来像打字机的设备上进行游戏。在那些日子里,代码中的“print”意味着真的要打印!
游戏代码最关键的一点是定期从用户那里获取输入。大多数时候需要一个整数 - 要么是从列表中选择一个命令,要么是输入购买货物的数量。其他时候,接收文本并确认用户输入的内容也很重要 - 就像在打猎小游戏中,用户需要尽快输入“BANG”以模拟精确击中目标。
我本可以在代码库中有一个模块,从控制台返回原始用户输入。这意味着整个代码库中每个需要整数值的地方都需要进行检查,然后解析成整数,然后再继续实际需要的逻辑。
使用歧视联合更明智的想法是,用于表示游戏逻辑识别的不同状态,并将必要的整数检查代码保留在一个地方。
像这样:
public abstract class UserInput
{
}
public class TextInput : UserInput
{
public string Input { get; set; }
}
public class IntegerInput : UserInput
{
public int Input { get; set; }
}
public class NoInput : UserInput
{
}
public class ErrorFromConsole : UserInput
{
public Exception Error { get; set; }
}
老实说,我不太确定控制台可能出现什么错误,但我认为排除这种可能性并不明智,尤其是因为这是我们应用代码无法控制的事物。
这里的想法是,我逐渐从代码库之外的不纯净区域转移到其内部的纯净控制区域。就像一个多阶段气闸一样。
谈到控制台超出我们控制范围... 如果我们希望保持尽可能函数化的代码库,最好将其隐藏在接口后面,这样我们可以在测试时注入模拟,并将代码的非纯净区域推迟一些。
就像这样:
public interface IConsole
{
UserInput ReadInput(string userPromptMessage);
}
public class ConsoleShim : IConsole
{
public UserInput ReadInput(string userPromptMessage)
{
try
{
Console.WriteLine(userPromptMessage);
var input = Console.ReadLine();
return new TextInput
{
Input = input
};
}
catch(Exception e)
{
return new ErrorFromConsole
{
Error = e
};
}
}
}
那是与用户互动的最基本表示方式。因为这是一个具有副作用的系统区域,我希望尽可能将其保持小巧。
之后,我创建了另一层,但这次实际上对从玩家接收到的文本应用了一些逻辑:
public class UserInteraction
{
private readonly IConsole _console;
public UserInteraction(IConsole console)
{
this._console = console;
}
public UserInput GetInputFromUser(string message)
{
var input = this._console.ReadInput(message);
var returnValue = input switch
{
TextInput x when string.IsNullOrWhiteSpace(x.Input) =>
new NoInput(),
TextInput x when int.TryParse(x.Input, out var _)=>
new IntegerInput
{
Input = int.Parse(x.Input)
},
TextInput x => new TextInput
{
Input = x.Input
}
};
return returnValue;
}
}
这意味着,如果我想提示用户输入,并保证他们给了我一个整数,现在编写代码就非常容易了:
public int GetPlayerSpendOnOxen()
{
var input = this.UserInteraction.GetInputFromUser("How much do you want to spend on Oxen?");
var returnValue = input switch
{
IntegerInput ii => ii.Input,
_ => {
this.UserInteraction.WriteMessage("Try again");
return GetPlayerSpendOnOxen();
}
};
return returnValue;
}
在这个代码块中,我正在提示玩家输入。然后,我检查它是否是我预期的整数 - 基于已经通过区分联合进行的检查。如果是整数,很好。任务完成,返回该整数。
如果不是整数,则需要提示玩家再试一次,然后再次调用此函数,递归地。我可以更详细地介绍捕获和记录接收到的任何错误,但我认为这已经充分演示了原则。
还要注意,这个函数中不需要 Try/Catch。这已经由更低层次的函数处理了。
在我的俄勒冈之旅转换中,有许多许多地方需要检查整数的这段代码。想象一下,通过将整数检查包装到返回对象的结构中,我节省了多少代码!
通用联合
所有迄今为止的区分联合均完全特定于情况。在结束本章之前,我想讨论一些创建完全通用、可重复使用版本的几个选项。
首先,让我再强调一下 - 我们不能像 F#中的人们那样轻松、即兴地声明区分联合。我们无法做到这一点。抱歉。我们能做的最好的就是尽可能地模拟它,以某种样板代码作为平衡。
这里有几种你可以使用的功能结构。顺便说一句,下一章节将介绍更高级的使用方式。敬请期待。
或许
如果你使用区分联合来表示函数可能未找到数据的情况,那么 Maybe 结构可能适合你。
实现看起来像这样:
public abstract class Maybe<T>
{
}
public class Something<T> : Maybe<T>
{
public Something(T value)
{
this.Value = value;
}
public T Value { get; init; }
}
public class Nothing<T> : Maybe<T>
{
}
你基本上是在将 Maybe 抽象作为另一个类的包装器使用,实际上是你的函数返回的类,但通过这种方式包装它,你向外界表明可能并不一定会返回任何东西。
下面是你可以如何用于返回单个对象的函数:
public Maybe<DoctorWho> GetDoctor(int doctorNumber)
{
try
{
using var conn = this._connectionFactory.Make();
// Dapper query to the db
var data = conn.QuerySingleOrDefault<Doctor>(
"SELECT * FROM [dbo].[Doctors] WHERE DocNum = @docNum",
new { docNum = doctorNumber });
return data == null
? new Nothing<DoctorWho>();
: new Something<DoctorWho>(data);
}
catch(Exception e)
{
this.logger.LogError(e, "An error occurred getting doctor " + doctorNumber);
return new Nothing<DoctorWho>();
}
}
你会像这样使用它:
// William Hartnell. He's the best!
var doc = this.DoctorRepository.GetDoctor(1);
var message = doc switch
{
Something<DoctorWho> s => "Played by " + s.Value.ActorName,
Nothing<DoctorWho> _ => "Unknown Doctor"
};
这并不特别有效地处理错误情况。一个 Nothing 状态至少可以防止未处理的异常发生,并且我们正在记录,但没有任何有用的内容传递给最终用户。
Result
Maybe 的一个替代方案是 Result,它表示函数可能抛出错误而不是返回任何内容。它可能看起来像这样:
public abstract class Result<T>
{
}
public class Success : Result<T>
{
public Success<T>(T value)
{
this.Value = value;
}
public T Value { get; init; }
}
public class Failure<T> : Result<T>
{
public Failure(Exception e)
{
this.Error = e;
}
public Exception Error { get; init; }
}
现在,“获取医生”的函数的 Result 版本看起来是这样的:
public Result<DoctorWho> GetDoctor(int doctorNumber)
{
try
{
using var conn = this._connectionFactory.Make();
// Dapper query to the db
var data = conn.QuerySingleOrDefault<Doctor>(
"SELECT * FROM [dbo].[Doctors] WHERE DocNum = @docNum",
new { docNum = doctorNumber });
return new Success<DoctorWho>(data);
}
catch(Exception e)
{
this.logger.LogError(e, "An error occurred getting doctor " + doctorNumber);
return new Failure<DoctorWho>(e);
}
}
你可能考虑使用它,就像这样:
// Sylvester McCoy. He's the best too!
var doc = this.DoctorRepository.GetDoctor(7);
var message = doc switch
{
Success<DoctorWho> s when s.Value == null => "Unknown Doctor!",
Success<DoctorWho> s2 => "Played by " + s2.Value.ActorName,
Failure<DoctorWho> e => "An error occurred: " e.Error.Message
};
现在,我正在处理歧视联盟的一个可能状态中的错误场景,但是 null 检查的负担落到了接收函数。
Maybe vs Result
在这一点上一个非常合理的问题是,哪一个更好使用?Maybe 还是 Result?
Maybe 提供了一个状态,通知用户没有找到任何数据,消除了空检查的需要,但实际上悄悄地吞噬了错误。这比未处理的异常要好,但可能会导致未报告的错误。
Result 优雅地处理错误,但增加了接收函数检查 null 的负担。
我个人的偏好?这可能不严格符合这些结构的标准定义,但我将它们结合成一个。我通常有一个 3 状态的 Maybe - Something,Nothing,Error。它可以处理代码库可以抛出的几乎所有情况。
这将是我个人解决问题的方式:
public abstract class Maybe<T>
{
}
public class Something<T> : Maybe<T>
{
public Something(T value)
{
this.Value = value;
}
public T Value { get; init; }
}
public class Nothing<T> : Maybe<T>
{
}
public class Error<T> : Maybe<T>
{
public Error(Exception e)
{
this.CapturedError = e;
}
public Exception CapturedError { get; init; }
}
我会这样使用它:
public Maybe<DoctorWho> GetDoctor(int doctorNumber)
{
try
{
using var conn = this._connectionFactory.Make();
// Dapper query to the db
var data = conn.QuerySingleOrDefault<Doctor>(
"SELECT * FROM [dbo].[Doctors] WHERE DocNum = @docNum",
new { docNum = doctorNumber });
return data == null
? new Nothing<DoctorWho>();
: new Something<DoctorWho>(data);
}
catch(Exception e)
{
this.logger.LogError(e, "An error occurred getting doctor " + doctorNumber);
return new Error<DoctorWho>(e);
}
}
这意味着接收函数现在可以使用模式匹配表达式优雅地处理所有三种状态:
// Peter Capaldi. The other, other best Doctor!
var doc = this.DoctorRepository.GetDoctor(12);
var message = doc switch
{
Nothing<DoctorWho> _ => "Unknown Doctor!",
Something<DoctorWho> s => "Played by " + s.Value.ActorName,
Error<DoctorWho> e => "An error occurred: " e.Error.Message
};
这使我能够对任何给定的场景提供完整的响应集,当从需要连接到冷、黑、饥饿狼充斥的程序之外的世界返回时,轻松地允许更多信息化的响应返回给最终用户。
在我们完成这个话题之前,这里是我如何使用同样的结构来处理一个返回类型为 Enumerable 的情况:
public Maybe<IEnumerable<DoctorWho>> GetAllDoctors()
{
try
{
using var conn = this._connectionFactory.Make();
// Dapper query to the db
var data = conn.Query<Doctor>(
"SELECT * FROM [dbo].[Doctors]");
return data == null || !data.Any()
? new Nothing<IEnumerable<DoctorWho>>();
: new Something<IEnumerable<DoctorWho>>(data);
}
catch(Exception e)
{
this.logger.LogError(e, "An error occurred getting doctor " + doctorNumber);
return new Error<IEnumerable<DoctorWho>>(e);
}
}
这使我可以处理来自函数的响应,就像这样:
// Great chaps. All of them!
var doc = this.DoctorRepository.GetAllDoctors();
var message = doc switch
{
Nothing<IEnumerable<DoctorWho>> _ => "No Doctors found!",
Something<IEnumerable<DoctorWho>> s => "The Doctors were played by: " +
string.Join(Environment.NewLine, s.Value.Select(x => x.ActorName),
Error<IEnumerable<DoctorWho>> e => "An error occurred: " e.Error.Message
};
再一次,既优雅又完美,一切都被考虑进去了。这是我在日常编码中经常使用的方法,我希望在阅读本章后,你也能这样做!
Either
Something 和 Result - 以某种形式 - 现在通用地处理了从函数返回的可能行为不确定的情况。那么在你可能想要返回两种或更多完全不同类型的情况下怎么办?
这就是 Either 类型的用处。语法可能不是最好的,但它确实有效。
public abstract class Either<T1, T2>
{
}
public class Left<T1, T2> : Either<T1, T2>
{
public Left(T1 value)
{
Value = value;
}
public T1 Value { get; init; }
}
public class Right<T1, T2> : Either<T1, T2>
{
public Right(T2 value)
{
Value = value;
}
public T2 Value { get; init; }
}
我可以用它来创建一个可以左右移动的类型,就像这样:
public Either<string, int> QuestionOrAnswer() =>
new Random().Next(1, 6) >= 4
? new Left<string, int>("What do you get if you mulitply 6 by 9?")
: new Right<string, int>(42);
var data = QuestionOrAnswer();
var output = data switch
{
Left<string, int> l => "The ultimate question was: " + l.Value,
Right<string, int> r => "The ultimate answer was: " + r.Value.ToString()
};
当然,你可以扩展它,以包含三种或更多不同的可能类型。我不太确定你会怎么称呼它们,但这肯定是可能的。唯一比较麻烦的是,你必须在很多地方包含所有通用类型的引用。不过至少它是有效的……
结论
本章我们讨论了可辨别联合体(Discriminated Unions)。它们究竟是什么,如何使用以及作为代码特性它们是多么强大。
可辨别联合体(Discriminated Unions)可以大大减少样板代码,并利用一种数据类型来描述系统的所有可能状态,这种方式极大地鼓励接收函数适当地处理它们。
可辨别联合体(Discriminated Unions)在 C# 中的实现并不像在 F# 或其他函数式语言中那样简单,但在 C# 中也有可能实现。
在下一章中,我将探讨一些更高级的函数概念,这将使可辨别联合体(Discriminated Unions)提升到一个新的水平!
¹ 请允许我向大家保证,尽管被称为“可辨别联合体”,它们与任何人对爱情和/或婚姻的看法或工会组织没有任何联系。
² 我没告诉过你吗?我们现在是旅游业务员了,你我!我们将向毫不知情的顾客推销廉价假期,直到我们富有和满足地退休。或者继续做我们现在正在做的事情。无论哪种方式,都行。
³ 这不是伦敦桥,你所想到的那个著名的。伦敦桥在别的地方。事实上,在亚利桑那州。不,真的。查一下吧。
⁴ 注:从未有人这样做过。我不知道有一只猫曾因量子力学而被牺牲过。
⁵ 不知怎么地。我从来没有真正理解过它的这一部分。
⁶ 哇,这将是多么糟糕的生日礼物啊。谢谢你,薛定谔!
⁷ “先生” - 它字面上意思是“出生较早的人”。有趣的是,如果你用日语写同样的字母,它会发音为“Sensei”。我是个书呆子 - 我喜欢这样的东西!
⁸ 悲伤的是,这是我与他真实接触的最接近的机会。如果你还没有看过他的香港电影,请务必看一些!我建议从《警察故事》系列开始。
⁹ 开个玩笑,朋友们,诚实的!请不要把我从你们的圣诞卡名单上划掉!