你是否曾经需要一种方法,能够根据不同的情况返回不同类型的值?比如,一个支付处理函数可以根据不同的支付方式返回相应的结果;一个订单状态可能会有多种变化,因此也需要存储不同状态下的相应数据;或者,更理想的是,有一个文件加载器能够处理多种格式的文件。

在C#中,我们通常会使用继承层次结构、标记接口或包装对象来解决这类问题,但这些方法都会增加代码的复杂性,并降低类型安全性。不过幸运的是,还有另一种更好的解决方案:那就是利用OneOf库来实现区分联合体。

如果你曾经使用过TypeScript进行编程,那么你可能已经熟悉联合体的概念了,因为它们是这种语言的核心特性之一。虽然C#本身并不原生支持联合体,但未来版本中计划加入这一功能。在那一天到来之前,你可以使用OneOf<T1,T2..>这个库来解决问题。

在这篇文章中,我将向你展示如何利用OneOf将类似F#中的区分联合体的机制引入C#,从而让你能够在各种场景下编写出更简洁、更具表现力且类型安全的代码——无论是处理多态返回类型、状态机,还是实现优雅的错误处理机制。

目录

什么是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种或4种以上的不同返回类型。

安装OneOf

在终端中,进入你的项目文件夹,然后运行以下命令:

dotnet add package OneOf

选项2:

使用你的集成开发环境(如Visual Studio、Rider或VS Code):

  1. 右键点击你的项目文件。
  2. 选择“管理NuGet包”。
  3. 搜索“OneOf”。
  4. 点击“安装”。

核心概念与功能

要充分了解OneOf库的用途并掌握它的真正优势,你需要理解以下几个核心概念:

联合类型:多种可能性中的一种

从根本上说,OneOf代表的是一种联合类型。这种类型的值在任何时候都可能是你预先定义的几种类型中的一种。可以把它想象成一个类型安全的容器,它只能包含一个值,但这个值可以是你指定的任何类型。

// 这个变量可以存储字符串、整数或布尔值
// 但一次只能存储其中一种类型
OneOf〈span class="hljs-keyword">string, int, bool</span〉> myValue;

myValue = "hello";     // 目前存储的是字符串
myValue = 42;          // 现在存储的是整数
myValue = true;        // 现在存储的是布尔值

这与C#中的Tuple类型有本质区别:Tuple类型可以同时存储多个值:

// Tuple:可以同时存储所有值
var tuple = ("hello", 42, true); // 同时包含字符串、整数和布尔值

// OneOf:一次只能存储一种类型
OneOf〈span class="hljs-keyword">string, int, bool</span〉> union = "hello"; // 只存储字符串、整数或布尔值中的一种

类型安全性与全面处理机制

“OneOf”不仅使用起来非常方便,而且其功能是由编译器强制执行的。当你使用“OneOf”类型时,编译器会确保你在.Match()方法中处理所有可能的类型。这样一来,就可以避免因忘记处理某些情况而导致的各种错误。

举个例子:

OneOf<Success, Failure, Pending> result = GetResult();

// 编译器会强制你处理这三种所有可能的情况
result.Match(
    success => HandleSuccess(success),
    failure => HandleFailure(failure),
);

// 如果遗漏了任何一种情况,编译器就会报错!

如果你在集成开发环境或代码编辑器中将光标悬停在错误提示上,会看到如下提示信息:

显示智能提示信息的图像,告知开发者他们遗漏了某个处理函数。根据指定的3种类型,只有2个处理函数被定义

.Match()方法

.Match()方法是“OneOf”类型最核心的功能之一。它要求你为联合体中的每种可能类型都提供一个处理函数,这样就能确保你永远不会忘记处理任何一种情况。

可以把这个方法想象成一种由编译器强制执行的类型安全的switch语句:

OneOf<CreditCardInfo, PayPalUser, CryptoAccount> result = GetPaymentMethod(); // 可能的值类型为MasterCard

result.Match(
    creditCard => ProcessCreditCard(creditCard),
    paypal => ProcessPayPal(paypal),
    crypto => ProcessCrypto(crypto)
);

.Match()的工作原理如下:

  1. “OneOf”会确定当前值所代表的类型。
  2. 然后它会执行与该类型对应的处理函数。
  3. 接着,它会把实际的值(以及正确的类型)传递给相应的处理函数。
  4. 最后,它会返回被执行的处理函数所返回的结果。

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

展示返回类型顺序的代码示例:CreditCard、PayPal和CryptoWallet,以及如何通过.match方法为每种类型定义对应的处理函数

  • 泛型的类型顺序:如果你声明OneOf<CreditCard, PayPal, CryptoWallet>,那么CreditCard就是T0PayPalT1CryptoWalletT2。这种顺序决定了在.Match(...)方法中会执行哪个处理函数,而与类型的本身无关。
  • 处理函数的参数名称是可以随意指定的:你可以将它们命名为option1foocreditCard。名称并不会决定处理的类型,而是位置才起作用。编译器会将第一个处理函数与CreditCard类型关联起来,第二个与PayPal类型关联起来,第三个与CryptoWallet类型关联起来。
  • 每个处理函数都会接收一个与其在联合体中的位置相对应的强类型参数。当第一个处理函数被执行时,它的参数会是一个CreditCard对象(此时会提供完整的智能提示功能,并进行编译时的类型检查)。
  • 为了提高代码的可读性,建议使用有意义的名称(例如creditCardpayPalcrypto),而不要使用option1/2/3这样的名称——因为后者只是为了演示目的而使用的。

访问值

虽然.Match()是推荐使用的方法,但OneOf也提供了直接的类型检测和访问功能,不过这种方式相当繁琐,也不够直观。

OneOf〈span class="hljs-keyword">string, int</span〉 example = "hello";

// 检查它包含哪种类型
if (example.IsT0)  // 是第一种类型(string)吗?
{
    string str = example.AsT0;  // 将其转换为string类型
    Console.WriteLine(str);
}
else if (example.IsT1)  // 是第二种类型(int)吗?
{
    int num = example.AsT1;  // 将其转换为int类型
    Console.WriteLine(num);
}

在大多数情况下,你应该避免使用这种方法,原因有以下几点:

首先,你会失去编译器带来的那种强制检查机制,而正是这种机制使得.Match()如此强大。后来如果你想添加第三种类型,编译器不会提醒你如何处理这种情况,因此你的代码可能会变得脆弱,更容易出错。

其次,这种写法冗长且杂乱无章。你需要使用多个if-else语句块,而不是简单地调用一次.Match(),这样一来,代码的可读性和可维护性都会大大降低。

第三,T0T1T2这样的命名规则容易引起混淆。究竟哪种类型是T0呢?你必须不断查看方法签名才能记住这些类型的顺序,这对你自己和开发团队来说都会带来麻烦。

最后,这种写法也更容易出错。当处理三种或更多种类型时,你完全有可能忘记检查IsT2这个条件。

只要可能,就使用.Match()。只有当你有特定的理由只需要检查其中一种类型,而其他类型在当前的代码流程中并不重要时,才应该使用IsT0/AsT0这些方法。

异常驱动控制流的解决方案

许多代码库过度依赖异常来进行控制流的处理,这使得代码变得更难阅读和调试。当你看到一个方法调用时,方法签名中并不会说明该方法是否可能会抛出异常,也不会指出会遇到哪种类型的错误。这就会导致一些问题:

隐藏的控制流

// 这里可能会出现什么问题?签名中并没有说明。
public User GetUser(int id)
{
    var user = _dbContext.Users.Find(id);
    if (user == null)
        throw new UserNotFoundException();  // 控制流中出现了隐藏的跳转!

    return user;
}

// 调用者根本不知道这个方法可能会抛出异常
var user = _userService.GetUser(123);  // 这可能会导致程序崩溃!
Console.WriteLine(user.Name);

异常其实属于预期结果

当用户输入无效的电子邮件地址,或者系统找不到相应的记录时,这些情况其实并不属于“异常”现象——它们是完全可以预见的、属于正常业务逻辑范围内应该出现的结果。如果在这种情况下使用异常处理机制,就等于把常规的验证操作当作一场危机来对待了。

在关键路径中,异常处理会影响性能

虽然这种影响并不总是非常显著,但抛出异常会导致程序执行流程回溯,而这一过程的速度往往比直接返回结果慢数百倍。在循环次数较多或处理量较大的API中,这种性能开销会迅速累积起来。

“应该捕获哪些异常呢?全部吗?还是特定的某些异常?”
try
{
    var user = _userService.GetUser(id);
    var order = _orderService.CreateOrder(user);
    var payment = _paymentService.ProcessPayment(order);
}
catch (Exception ex)  “捕获范围太广了吗?会不会抓到一些本不该被捕获的异常?”
{
    “到底是哪一步操作失败了呢?很难判断。”
    return StatusCode(500, “发生了错误”</span});
}

OneOf提供了一种更清晰的解决方案

OneOf机制能够明确地表明操作可能会失败,并且这种处理方式是类型安全的,同时也会被显式地体现在方法签名中。当你看到一个返回类型为OneOf<Success, Failure>的方法时,你就能立刻明白以下几点:

    1. 这个方法有可能失败。
    2. 你必须同时处理成功和失败这两种情况。
    3. 编译器会强制要求你这样做。

下面的代码展示了如何实现这一机制:

“首先定义你的结果类型。”
public record Success(T value);
public record Failure(ErrorType type, string[] messages);

public enum ErrorType 
{
    Validation,
   NotFound,
    Database,
    Conflict,
}

“现在方法签名已经明确说明了这个方法可能会失败。”
public OneOf<Success, Failure> GetUser(int id)
{
    try
    {
        var user = _dbContext.Users.Find(id);

        if (user == null)
            return new Failure(ErrorTypeNotFound, new[] { "用户“ + id + “未找到" });

        return new Success(user);
    }
    catch (DbException ex)
    {
        return new Failure(ErrorType.Database, new[] { "数据库错误", ex.Message });
    }
}

“现在调用者必须同时处理成功和失败这两种情况——编译器会强制要求这样做。”
public IActionResult GetUserEndpoint(int id)
{
    var result = _userService.GetUser(id);

    return result.Match(
        success => Ok(result.Value),
        failure => 
        {
            case ErrorTypeNotFound:
                return NotFound(new { errors = failure Messages });
            case ErrorType.Database:
                return StatusCode(500, new { errors = failureMessages });
            case ErrorType Validation:
                return BadRequest(new { errors = failure Messages });
            case ErrorTypeConflict:
                return Conflict(new { errors = failure Messages });
            default:
                returnStatusCode(500, new { errors = failure Messages });
        }
    );
}

是什么让这种方法更优秀呢?

      • 它具有自文档说明功能:方法签名明确说明了“该方法返回的是一个 User 对象,或者是一个 Failure 对象”——没有任何隐藏的意外情况。
      • 编译器会强制要求开发者处理这些异常情况:如果忘记了处理失败情况,编译器会报错。编译器不会允许你忽略潜在的错误。
      • 其设计意图非常清晰:当你调用一个返回类型为 OneOf<Success, Failure> 的方法时,你就立刻知道需要同时处理这两种情况。根本不需要去猜测可能会抛出哪些异常。

在什么情况下仍应使用异常:

我们的目标并不是完全消除异常,而是将它们保留用于真正属于异常情况的场景,而将 OneOf 用于那些可以预测的、与业务逻辑相关的失败情况。在以下这些场景中,你仍然可以使用异常:

      • 真正意外的故障(如内存不足、硬件故障等)
      • 框架或库本身要求使用异常的情况
      • 构造函数在处理失败时的情况(构造函数不能返回 Result 类型的对象)
      • 第三方代码所规定的契约要求

其他使用 OneOf 的场景

使用案例 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 ArgumentException("未知的支付方式")
    };
}
// 使用方法——编译器会强制要求开发者处理所有可能返回的类型
var payment = GetPaymentMethod(request);
payment.Match(
    card => ChargeCard(card),
    paypal => ProcessPayPal(paypal),
    crypto => ProcessCrypto(crypto)
);

为什么这种方法比使用继承机制更好呢?

      • 不需要人为地创建基类
      • 每种支付方式都可以拥有完全不同的属性
      • 每种情况都能被清晰、明确地处理
      • 添加新的支付方式也非常方便(编译器会提示你需要修改哪些地方)

用例2:包含丰富数据的状态机

在工作流中表示不同的状态,其中每个状态都携带不同的信息:

public class Order
{
    public OneOf〈Pending, Processing, Shipped, Delivered, Cancelled〉 Status { get; set; }
}

public record Pending(DateTime OrderedAt);
public record Processing(DateTime StartedAt, string WarehouseId);
public record Shipped(DateTime ShippedAt, string TrackingNumber, string Carrier);
public record Delivered(DateTime DeliveredAt, string SignedBy);
public record Cancelled(DateTime CancelledAt, string Reason);

// 每个状态都携带相关的信息
var statusMessage = order.Status. Match(
    pending => $"订单已于{pending.OrderedAt:d}下单",
    processing => 仓库处理",
    shipped => {shipped.Carrier}发货,追踪号码为:{shipped.TrackingNumber}",
    delivered => 送达,签收人为:{delivered.SignedBy}",
    cancelled => {cancelled.Reason}"
);

为什么不直接使用枚举呢?

      • 枚举仅用于存储状态,无法携带额外的数据
      • 通过使用OneOf结构,Processing状态能够知道货物在哪个仓库中处理,而Shipped状态则能获取追踪号码,这样就能提供更多的功能,并且便于实现其他相关的逻辑
      • 可以安全地访问与特定状态相关的数据
      • 编译器会防止错误地访问某个状态对应的数据

使用案例 3:多渠道通知

通过不同的渠道发送通知,而每种渠道都有其特定的要求:

public record EmailNotification(string 收件人地址, string 主题, string 内容);
public record SmsNotification(string 手机号码, string 消息内容);
public record PushNotification(string 设备令牌, string 标题, string 内容);
public record InAppNotification(int 用户ID, string 消息内容);

public async Task SendNotification(
    OneOf<EmailNotification, SmsNotification, PushNotification, InAppNotification&gt> notification)
{
    await notification.Match(
        async email => await _emailService.SendAsync(email.To, email.Subject, email.Body),
        async sms => await _smsService.SendAsync(sms.PhoneNumber, sms.Message),
        async push => await _pushService.SendAsync(push.DeviceToken, push.Title, push.Body),
        async inApp => await _notificationRepo.CreateAsync(inAppUserId, inApp.Message)
    );
}

// 使用方法
await SendNotification(new EmailNotification("user@example.com", "欢迎", "你好!"));
await SendNotification(new SmsNotification("+1234567890", "你的验证码是123456"));

优势:

      • 能够使用统一的接口来处理所有通知发送操作
      • 每种渠道都只接收其所需的数据参数
      • 对于无关字段,不存在可选或可为空的属性
      • 通知路由逻辑清晰明了

用例4:文件格式处理

如何处理不同类型的文件及数据格式:

public record CsvData(string[] Lines);
public record JsonData(string Content);
public record ExcelData(IWorkbook Workbook);

public OneOf<CsvData, JsonData, ExcelData> LoadDataFile(string path)
{
    var extension = Path.GetExtension(path).ToLower();

    return extension switch
    {
        ".csv" => new CsvData(File.ReadAllLines(path)),
        ".json" => new JsonData(File.ReadAllText(path)),
        ".xlsx" => new ExcelData(LoadExcelFile(path)),
        _ => 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中的某一种”,从而迫使调用者必须明确处理所有可能的返回结果。这样一来,代码就会更加健壮,也更容易理解,同时更不容易被误用。

结论

OneOf为C#语言带来了“选择性联合”这一功能,使得在各种场景中都能编写出表达力更强、类型安全性更高的代码。无论你是需要建模支付方式、订单状态、通知渠道,还是处理错误情况,OneOf都能提供一种简洁且由编译器强制执行的机制,帮助你处理多种返回类型的问题。

尽早将OneOf应用到你的项目中吧,你会发现自己的代码会变得更加有条理、更易于维护,出错的概率也会降低。

 

Comments are closed.