你是否曾經需要一種方法,能夠根據不同的情況返回不同類型的值?比如,一個支付處理函數可以根據不同的支付方式返回相應的結果;一個訂單狀態可能會有多種變化,因此也需要存儲不同狀態下的相應數據;或者,更理想的是,有一個文件加載器能夠處理多種格式的文件。

在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.