您是否曾经需要一种方法,能够根据不同的情况返回不同类型的对象呢?比如,一个支付处理程序可能需要返回多种支付方式;一个订单可能包含多种状态,每种状态对应着不同的数据;或者,一个文件加载器需要处理多种格式的文件。
在C#中,我们通常通过使用继承层次结构、标记接口或包装对象来解决这个问题——不过这些方法都会增加代码的复杂性,同时也会降低类型的安全性。不过幸运的是,还有更好的解决方案:使用OneOf库来实现这种功能。
如果您之前使用过TypeScript进行编程,那么您可能已经熟悉了联合类型这个概念。联合类型并不是C#原生支持的功能,但未来会加入这一特性。在此之前,您可以使用OneOf库来解决问题。
在这篇文章中,我将向您展示如何使用OneOf库来实现类似F#中的判别性联合类型,从而让您的代码更加简洁、富有表现力,并且具有更强的类型安全性,适用于各种场景——从多态性的返回值到状态机,再到优雅的错误处理。
目录
什么是OneOf?
OneOf包为C#提供了判别性联合类型,使得您可以从一个方法中返回多个预定义的类型之一。与Tuple不同,后者将多个值组合在一起,而OneOf则表示一种选择(A或B或C)。
可以将其视为一种类型安全的方式来表示:“该方法返回类型A、B或C中的一种”。编译器会确保您能够处理所有可能性。
// 而不是这样(无论是否需要,都返回两者)
public (User user, Error error) GetUser(int id) { ... }
// 您可以这样做(只返回其中一种)
public OneOf<User, NotFound, DatabaseError> GetUser(int id) { ... }
为什么需要OneOf
-
类型安全性: 编译器确保您能够处理所有可能的返回值类型
-
自文档化: 方法签名清楚地显示了所有可能的结果
-
不需要继承: 可以返回不同的类型,而不必将它们强制放入类层次结构中
-
模式匹配: 使用
.Match()方法来彻底处理每个情况 -
灵活性: 可以根据需要支持2、3甚至更多种不同的返回值类型
安装OneOf
选项1(推荐):
在终端中导航到项目文件夹,然后运行以下命令:
dotnet add package OneOf
选项2:
使用您的IDE(Visual Studio、Rider或VS Code):
-
右键点击项目文件
-
选择“管理NuGet包”
-
搜索“OneOf”
-
点击“安装”
核心概念与功能
要充分利用OneOf库并理解其真正的好处,您需要了解几个核心概念。
联合类型:众多选项中的一种
本质上,OneOf代表了一种联合类型。它可以在任何时候只包含几种预定义类型的其中之一。可以将其视为一种类型安全的容器,但它可以容纳您指定的任何类型。
// 这个变量可以存储字符串、整数或布尔值中的一个
// 但是一次只能包含一个
OneOf<string, int, bool> myValue;
myValue = "hello"; // 当前存储的是字符串
myValue = 42; // 现在存储的是整数
myValue = true; // 现在存储的是布尔值
这与C#的Tuple类型不同,后者可以同时存储多个值:
// Tuple:同时存储所有值(且顺序固定)
var tuple = ("hello", 42, true); // 包含字符串、整数和布尔值
// OneOf:每次只存储一种值(或几种值中的某一种)
OneOf<string, int, bool> union = "hello"; // 包含字符串、整数或布尔值中的一种
类型安全性和全面的处理方式
OneOf不仅方便使用,而且由编译器强制执行。当您使用OneOf值时,编译器会确保您处理所有可能的类型。这避免了因为遗漏某些情况而导致的错误。
例如:
OneOf<Success, Failure, Pending> result = GetResult();
// 编译器会强制您处理所有三种情况
result.Match(
success => Ok(success.Value),
failure => HandleFailure(failure.Type),
);
// 如果漏处理了某种情况,编译时会报错!
您会收到编译器提示,如果在IDE或代码编辑器中悬停查看该提示,您会看到如下提示:
.Match()方法
.Match()方法是OneOf的一个非常有用的功能。它要求您为联合中的每个类型提供一个处理函数,这样就能确保不会错过任何情况。
可以将其想象成一种类型安全的切换语句,由编译器来强制执行:
OneOf<CreditCardInfo,PayPalUser,CryptoAccount> result = GetPaymentMethod();
// 例如,MasterCard
result.Match(
creditCard => ProcessCreditCard(creditCard),
paypal => ProcessPayPal(paypal),
crypto => ProcessCrypto(crypto)
);
如何工作?
-
OneOf确定当前值属于哪种类型
-
执行与该类型对应的处理函数
-
将实际的值传递给处理函数
-
返回处理函数的执行结果
泛型类型的排序非常重要,尤其是在.Match()方法和定义的处理函数中。
OneOf<CreditCard, PayPal, CryptoWallet>,那么CreditCard就是PayPal就是.Match(...)中哪个处理函数会被调用,而不是根据类型来决定。
option1、foo或creditCard。参数名并不决定类型,而是决定其在结构体中的位置。编译器会将第一个处理函数绑定到creditCard,第二个绑定到PayPal,第三个绑定到每个处理函数都会接收一个强类型化的参数,该参数对应于其所在的位置。当第一个处理函数被调用时,其参数就是一个CreditCard对象(带有完整的IntelliSense功能和编译时检查功能)。
为了可读性,建议使用有意义的名称,如creditCard、paypal或crypto,而不是option1/2/3,因为这只是为了演示目的而设计的。
用例1:无需继承的多态返回值
当需要根据逻辑返回不同类型的数据时,但又不想强制使用继承关系时,就可以使用OneOf。
// 不同的支付方式——不需要共享基类
public OneOf<CreditCardPayment, PayPalPayment, CryptoPayment> GetPaymentMethod(PaymentRequest request)
{
return request.Method switch
{
"card" => new CreditCardPayment(request.CardNumber, request.CVV),
"paypal" => new PayPalPayment(request.Email),
"crypto" => new CryptoPayment(request.WalletAddress),
_ => throw new UnsupportedFileFormatException(extension)
};
// 以统一的方式处理不同的格式
var data = LoadDataFile(filePath);
var records = data.Match(
csv => ParseCsv(csv.Lines),
json => ParseJson(json.Content),
excel => ParseExcel(excel.Workbook)
);
这种处理方式非常适合:
-
提供多种输出格式的API
-
接受多种文件的导入向导
-
支持多种格式的配置加载器
OneOf的主要优势
当有以下情况时,OneOf表现得最为出色:
-
存在多个有效的返回值类型,而这些类型之间没有共同的基类
-
针对不同场景有不同的数据类型
-
类型安全的分支处理,编译器会强制处理所有情况
-
领域建模中,不同的状态携带不同的信息
-
明确的结果,这些结果应该是方法签名的一部分
本质上,OneOf是一种类型安全的方式来表达“该方法返回A或B或C”,从而迫使使用者必须明确处理每种情况。这样可以使代码更加健壮、易于维护,同时也减少了出错的可能性。
如果你喜欢阅读这篇文章,请随时在Twitter上联系我吧。
