可区分联合 (DU) 是一种定义类型(或面向对象世界中的类)的方法,该类型实际上是一组不同类型中的一种。在使用之前,必须检查 DU 的实例在任何给定时刻实际上是哪种类型。
F# 本身就有 DU,这是 F# 开发人员常用的功能。尽管与 C# 共享一个通用运行时,并且该功能在理论上可供我们使用,但据我所知,没有计划将 DU 添加到 C#。在 C# 中,我们可以用抽象类粗略地模拟它们,这就是我将在本章中讨论的技术。
本章是我们首次涉足函数式编程的一些更高级领域。本书前面的章节更侧重于您(开发人员)如何能够聪明地工作,而不是努力工作。我们还研究了减少样板代码的方法,并使代码更健壮和更易于维护。
可区分联合是一种编程结构,它也能完成所有这些工作,但它不仅仅是一种简单的扩展方法,或者一个单行修复来删除一些样板代码。DU 在概念上更接近于设计模式 - 它们有一个结构,以及一些需要围绕它实现的逻辑。
假期时间
让我们想象一个老式的面向对象问题,我们正在创建一个打包假期系统。你知道 - 旅行社为您安排旅行、住宿等。我让你想象一下你要去的是什么可爱的目的地。就我个人而言,我非常喜欢希腊群岛。
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
);
如果我想通过一些功能性想法快速改进这一点,我可以考虑引入一个 Fork 组合器(参见上一章),基本类型是假日,子类型是带餐点的假日。本质上是同一件事,但多了一个或两个字段。
如果……公司启动了一个项目。现在,他们将开始提供除假期之外的其他类型的服务。他们还将开始提供不涉及酒店、航班或其他任何此类服务的一日游。也许是进入伦敦塔桥。或者快速游览巴黎的埃菲尔铁塔。随你喜欢。世界尽在你掌握。
对象看起来会像这样:
public class DayTrip
{
public int Id { get; set; }
public DateTime DateOfTrip { get; set; }
public Location Attraction { get; set; }
public bool CoachTripRequired { get; set; }
}
但问题是,如果我们想通过从 Holiday 对象继承来表示这种新场景,这是行不通的。我看到一些人采用的方法是将所有字段合并在一起,并使用布尔值来指示哪些字段是您应该查看的字段。
类似于以下内容:
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; }
}
这是一个糟糕的想法,原因有几个。首先,你打破了接口隔离原则。无论它到底是哪种类型,你都会强迫它保存与它无关的字段。为了避免重复,我们还将“目的地”和“景点”以及“旅行日期”和“开始日期”的概念加倍,但这意味着我们失去了一些使处理一日游的代码有意义的术语。
另一种选择是将它们作为完全独立的对象类型,彼此之间没有任何关系。但是,这样做会让我们失去对每个对象进行简洁、简单的循环的能力。我们无法按日期顺序在单个表中列出所有内容。必须有多个表。
所有这些可能性似乎都不太好。但这正是 DU 发挥作用的地方。在下一节中,我将向您展示如何使用它们来提供此问题的最佳解决方案。
具有可区分联合的假期
在 F# 中,您可以为我们的客户产品示例创建一个联合类型,如下所示:
type CustomerOffering =
| Holiday
| HolidayWithMeals
| DayTrip
这意味着您可以实例化 CustomerOffering 的新实例,但它可能有三种不同的类型,每种类型都可能具有完全不同的属性。
这是我们在 C# 中可以最接近此方法的方法:
public abstract 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) =>
s switch
{
HolidayWithMeal => this.formatHolidayWithMean(hm),
Holiday h => this.formatHoliday(h),
DayTrip dt => this.formatDayTrip(tp)
};
这简化了所有接收到判别式并集的地方的代码,并产生了更具描述性的代码,以及更准确地描述函数所有可能结果的代码。
薛定谔的并集
如果你想类比这些东西是如何工作的,想想可怜的老薛定谔猫。这是奥地利物理学家埃尔温·薛定谔提出的一个思想实验,旨在强调量子力学中的一个悖论。这个想法是,给定一个装有猫和放射性同位素的盒子,有 50% 的几率杀死这只猫。重点是,根据量子物理学,在有人打开盒子检查猫之前,两种状态——活着和死去——同时存在。这意味着猫同时活着和死去。
这也意味着,如果薛定谔将他的猫/同位素盒子邮寄给朋友,他们就会得到一个盒子,里面可能包含两种状态中的一种,在打开它之前,他们不知道是哪种状态。
这就是可区分联合的本质。返回一个单一的值,但可能存在于两种或多种状态中。你不知道是哪种状态,直到你检查它。
如果一个类不关心哪种状态,你甚至可以将它不打开就传递到下一个目的地。
薛定谔的猫代码可能如下所示:
public abstract class SchrödingersCat { }
public class AliveCat : SchrödingersCat { }
public class DeadCat : SchrödingersCat { }
我希望您现在清楚了 Discriminated Unions 到底是什么。我将用本章的剩余部分来演示它们的一些用途。
命名约定
让我们想象一个代码模块,用于从各个组件中写出人们的名字。如果您有一个传统的英国名字,就像我自己的一样,那么这相当简单。一个用于写出像我这样的名字的类看起来会像这样:
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"
都完成了,对吧?好吧,这对于传统的英国名字是可行的,但是对于中文名字呢?中文名字的书写顺序与英国名字不同。中文名字的书写顺序为 ,许多中国人都取“礼名”——一种西式名字,用于专业用途。
让我们以传奇演员、导演、作家、特技演员、歌手和全能型的杰出人物成龙为例。他的真名是方世龙。在那组名字中,他的姓氏(即姓氏)是方。他的个人名字(通常在英文中称为 First name 或 Christian Name)是 Shilong。Jackie 是他从很小的时候就使用的礼名。这种名字风格与我上面创建的 formatName 函数完全不兼容。
我 可以 稍微修改一下数据以使其正常工作。如下所示:
var jackie = new BritishName
{
Honorific = "xiānsheng", // equivalent of "Mr."
FirstName = "Fang",
LastName = "Shilong"
}
// results in "xiānsheng Fang Shilong"
很好,这正确地以正确的顺序写出了他的两个正式姓名。但是他的尊称呢?没有东西可以写出来。此外,“先生”的中文对应词 - xiānsheng - 实际上位于名字之后,所以这真的很粗制滥造 - 即使我们尝试重新利用现有字段。
我们可以在代码中添加大量的 if
语句来检查所描述的人的国籍,但如果我们试图将其扩展到包含两个以上的国籍,这种方法很快就会变成一场噩梦。
再次,更好的方法是使用可区分联合来表示完全不同的数据结构,其形式可以反映它们试图表示的事物的现实。
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; }
}
使用这个联合,我们实际上可以创建一个包含我和成龙的名字数组
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)
{
// 在这里填写一些代码。无论你想使用什么数据
// 存储。迷你光盘除外。
}
但是如果你仔细想想,返回 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,
_ => "Email send successful"
};
this.Console.WriteLine(messageToWriteToConsole);
这再次意味着我可以从函数返回错误消息和失败状态,但没有任何依赖于它不需要的属性的内容。
控制台输入
前段时间,我想到了一个疯狂的想法,通过将用 HP Timeshare BASIC 编写的旧文本游戏转换为函数式 C# 来尝试我的函数式编程技能。
这款游戏名为 Oregon Trail,可以追溯到 1975 年。虽然很难相信,但它比我还要老!甚至比星球大战还要老。事实上,它甚至比显示器还早,而且必须在看起来像打字机的东西上才能有效地播放。在那些日子里,当代码说“打印”时 - 就是这个意思!
游戏代码必须做的最重要的事情之一是定期从用户那里获取输入。大多数时候需要一个整数 - 要么从列表中选择一个命令,要么输入要购买的商品数量。其他时候,接收文本并确认用户输入的内容非常重要 - 例如在狩猎小游戏中,用户需要尽快输入“BANG”以模拟尝试准确击中目标。
我可以简单地在代码库中有一个模块,从控制台返回原始用户输入。这意味着整个代码库中每个需要整数值的地方都需要进行检查,然后解析为整数,然后才能继续执行实际需要的逻辑。
一个更聪明的想法是使用可区分联合来表示游戏逻辑从用户输入中识别出的不同状态,并将必要的 int 检查代码放在一个地方。
像这样:
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
{
}
public class ErrorFromConsole
{
public Exception Error { get; set; }
}
说实话,我不确定控制台可能出现什么错误,但我认为排除这种可能性并不明智,尤其是因为它超出了我们应用程序代码的控制范围。
这里的想法是,我正在逐渐从代码库之外的不纯净区域转移到代码库内部的纯净、受控区域。就像一个多级气闸。
说到控制台超出了我们的控制范围……如果我们想让我们的代码库尽可能地保持功能性,那么最好将其隐藏在接口后面,以便我们可以在测试期间注入模拟,并将我们代码的非纯区域推回一点。
就像这样:
public interface IConsole
{
UserInput ReadInput(string messageToUser);
}
public class ConsoleShim : IConsole
{
public UserInput ReadInput(String messageToUser)
{
try
{
Console.WriteLine(messageTouser);
var input = Console.ReadLine();
return new TextInput
{
Input = input
};
}
catch(Exception e)
{
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) =>
new NoInput(),
TextInput x when int.TryParse(x) =>
new IntegerInput
{
Input = int.Parse(x)
},
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。这已经由较低级别的函数处理。
在我的 Oregon Trail 转换中,有很多地方需要此代码检查整数。想象一下,通过将整数检查包装到返回对象的结构中,我为自己节省了多少代码!
通用联合
到目前为止,所有可区分联合都是完全特定于情况的。在结束本章之前,我想讨论一些创建完全通用、可重用版本的相同想法的选项。
首先,让我重申一下 - 我们不能拥有可以轻松、动态声明的可区分联合,就像 F# 中的人可以做到的那样。我们做不到。抱歉。我们能做的最好的事情就是尽可能地模仿它,并进行某种样板权衡。
这里有几个您可以使用的功能结构。顺便说一句,下一章将介绍更高级的方法来使用这些功能。敬请期待。
也许
如果您使用可区分联合的目的是表示函数可能找不到数据,那么 Maybe 结构可能适合您。
实现如下所示:
public abstract class Maybe<T>
{
}
public class Something<T> : Maybe<T>
{
public Something<T>(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<T> : Result<T>
{
public Success<T>(T value)
{
this.Value = value;
}
public T Value { get; init; }
}
public class Failure<T> : Result<T>
{
public Failure<T>(Exception e)
{
this.Error = e;
}
public Exception Error { get; init; }
}
现在,“Get Doctor”功能的结果版本如下所示:
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
};
现在,我正在介绍 Discriminated Union 的可能状态之一中的错误场景,但空值检查的负担落在了接收函数上。
Maybe vs Result
此时,一个完全有效的问题 - 哪个更好用? Maybe 还是 Result?
Maybe 给出的状态通知用户未找到任何数据,无需进行空值检查,但实际上默默地吞噬了错误。这比未处理的异常要好,但可能会导致未报告的错误。
Result 可以优雅地处理错误,但会给接收函数带来检查空值的负担。
我个人的偏好?这可能不严格符合这些结构的标准定义,但我将它们合并为一个。我通常有一个 3 状态的 Maybe - Something、Nothing、Error。它可以处理代码库可能抛给我的几乎所有问题。
这将是我个人对这个问题的解决方案:
public abstract class Maybe<T>
{
}
public class Something<T> : Maybe<T>
{
public Something<T>(T value)
{
this.Value = value;
}
public T Value { get; init; }
}
public class Nothing<T> : Maybe<T>
{
}
public class Error<T> : Maybe<T>
{
public Error<T>(Exception e)
{
this.Error = e;
}
public Exception Error { 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 chap. 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()
};
当然,您可以将其扩展为三种或更多不同的可能类型。我不完全确定您会如何称呼它们,但这肯定是可能的。只是有很多尴尬的样板,因为您必须在很多地方包含对泛型类型的所有引用。不过,至少它是有效的……
结论
在本章中,我们讨论了可区分联合。它们到底是什么,如何使用,以及它们作为代码功能有多么强大。
可区分联合可用于大量减少样板代码,并使用描述性地表示系统所有可能状态的数据类型,以强烈鼓励接收函数适当地处理它们。
可区分联合不能像在 F# 或其他函数式语言中那样容易实现,但至少在 C# 中是有可能的。
在下一章中,我将研究一些更高级的函数式概念,这些概念将使可区分联合更上一层楼!