您是否曾经需要一种方法,能够根据不同的情况返回不同类型的对象呢?比如,一个支付处理程序可能需要返回多种支付方式;一个订单可能包含多种状态,每种状态对应着不同的数据;或者,一个文件加载器需要处理多种格式的文件。

在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

在终端中导航到项目文件夹,然后运行以下命令:

dotnet add package OneOf

选项2:

使用您的IDE(Visual Studio、Rider或VS Code):

  1. 右键点击项目文件

  2. 选择“管理NuGet包”

  3. 搜索“OneOf”

  4. 点击“安装”

核心概念与功能

要充分利用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)
);

如何工作?

  1. OneOf确定当前值属于哪种类型

  2. 执行与该类型对应的处理函数

  3. 将实际的值传递给处理函数

  4. 返回处理函数的执行结果

泛型类型的排序非常重要,尤其是在.Match()方法和定义的处理函数中。

代码块显示返回类型的顺序,CreditCard、PayPal和CryptoWallet,以及为每个类型定义的处理函数

  • 泛型类型排序规则:如果您声明了OneOf<CreditCard, PayPal, CryptoWallet>,那么CreditCard就是PayPal就是,而就是。这种排序决定了在.Match(...)中哪个处理函数会被调用,而不是根据类型来决定。
  • 处理函数的参数名称可以是任意的:你可以将其命名为option1foocreditCard。参数名并不决定类型,而是决定其在结构体中的位置。编译器会将第一个处理函数绑定到creditCard,第二个绑定到PayPal,第三个绑定到
  • 每个处理函数都会接收一个强类型化的参数,该参数对应于其所在的位置。当第一个处理函数被调用时,其参数就是一个CreditCard对象(带有完整的IntelliSense功能和编译时检查功能)。

  • 为了可读性,建议使用有意义的名称,如creditCardpaypalcrypto,而不是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上联系我吧。

    Comments are closed.