Trở lại năm 2023, tôi đã viết về discriminated union trong C# và cách các nhà phát triển C# phải xử lý việc ngôn ngữ chưa hỗ trợ tính năng này bằng những giải pháp như kiểu Results<> của ASP.NET Core hoặc gói NuGet OneOf. Vấn đề hỗ trợ trực tiếp trong ngôn ngữ C# (csharplang issue #113) đã được mở nhiều năm mà vẫn chưa có giải pháp hoàn chỉnh.
Giờ đây, thời gian chờ đợi đã kết thúc. C# 15, được phát hành cùng .NET 11 (bản xem trước), giới thiệu union type như một tính năng gốc của ngôn ngữ. Hãy cùng xem qua.
Mục lục
Ôn lại nhanh: vấn đề cần giải quyết
Bài viết năm 2023 của tôi tập trung vào một tình huống phổ biến: một phương thức có thể trả về một trong nhiều kiểu riêng biệt và bạn muốn trình biên dịch biết tất cả các kiểu đó. Hãy hình dung phương thức RegisterUser() có thể trả về User, UserAlreadyExists hoặc InvalidUsername. Các kiểu này không dùng chung một lớp cơ sở; bạn chỉ muốn nói rằng “giá trị này là một trong ba kiểu trên” và để trình biên dịch kiểm tra điều đó.
Minimal API của ASP.NET Core cung cấp Results<> như một giải pháp gần đúng, trong đó kết quả có thể là Ok<T> hoặc NotFound:
app.MapGet("/data", Results<Ok<Data>, NotFound> () =>
{
return TypedResults.Ok(new Data());
});
Về bản chất, kiểu Results<> là một discriminated union: nó biết những kiểu cụ thể nào hợp lệ, đồng thời trình biên dịch giúp bạn nhận ra rằng trong trường hợp này BadRequest không phải là một lựa chọn hợp lệ.
Tuy nhiên, vẫn có một điểm hạn chế. Nếu thay đổi GetData() để trả về bốn kiểu thay vì ba, biểu thức switch ở nơi sử dụng sẽ không phát sinh lỗi biên dịch để thông báo rằng còn thiếu một nhánh xử lý. Cơ chế bảo đảm xử lý đầy đủ các trường hợp chưa tồn tại. Đây chính là điểm mấu chốt của bài viết năm 2023: những giải pháp thay thế đã giúp chúng ta đi gần đến đích, nhưng vẫn chưa hoàn toàn đạt được mục tiêu.
Khai báo union type
C# 15 đã lấp đầy khoảng cách này. Từ khóa mới union là một từ khóa ngữ cảnh, vì vậy sẽ không làm hỏng mã hiện có sử dụng tên union. Từ khóa này cho phép bạn khai báo một kiểu chỉ có thể là một trong tập hợp các trường hợp đã định nghĩa:
public union Pet(Cat, Dog, Bird);
Một dòng trên cho trình biên dịch biết rằng Pet chỉ có thể là Cat, Dog hoặc Bird, không phải bất kỳ kiểu nào khác. Bên dưới, trình biên dịch tạo ra một kiểu giá trị (struct) triển khai IUnion và lưu giữ giá trị thực tế.
Union generic hoạt động theo cách tương tự và đặc biệt hữu ích khi mô hình hóa kết quả:
public union Result<TSuccess, TError>(TSuccess, TError);
Giờ đây, bạn có kiểu Result<T, E> thực sự được ngôn ngữ hiểu một cách tự nhiên, không cần thư viện bên thứ ba cũng không cần tự viết implicit operator cho từng trường hợp. Bạn có thể xem tài liệu các tính năng mới trong C# 15 và tài liệu tham chiếu về union type để biết đầy đủ chi tiết.
Chuyển đổi ngầm định
Mỗi kiểu trường hợp đều tự động có chuyển đổi ngầm định sang union type. Bạn không cần thêm mã hỗ trợ:
Pet pet = new Dog("Rex"); // hoạt động ngay, không cần ép kiểu hay phương thức factory
So với cách làm cũ, trong đó bạn phải tự viết các toán tử implicit operator cho từng kiểu trường hợp hoặc dùng thư viện để sinh mã, cách làm mới đơn giản hơn nhiều. Trình biên dịch sẽ xử lý phần này: chỉ cần gán một Cat, Dog hoặc Bird, bạn sẽ có một Pet.
Điều này cũng giúp việc trả về giá trị từ phương thức trở nên gọn gàng, không cần bao bọc hay chuyển đổi thủ công:
Result<User, Error> Register(string username)
{
if (string.IsNullOrEmpty(username))
return new Error("Username cannot be empty");
return new User(username);
}
Đối sánh mẫu đầy đủ mọi trường hợp (cuối cùng cũng có)
Đối sánh mẫu với union type là đầy đủ mọi trường hợp: trình biên dịch sẽ cảnh báo nếu bạn chưa xử lý tất cả các trường hợp.
Ví dụ bỏ sót một trường hợp:
string Describe(Pet pet) => pet switch
{
Dog d => d.Name,
Cat c => c.Name,
// CS8509: biểu thức switch chưa xử lý mọi giá trị có thể có của kiểu đầu vào (Bird)
};
Bạn sẽ nhận được cảnh báo CS8509, cho biết rõ rằng Bird đang bị bỏ sót. Sau khi bổ sung:
string Describe(Pet pet) => pet switch
{
Dog d => d.Name,
Cat c => c.Name,
Bird b => b.Name,
};
Cảnh báo biến mất và bạn không còn phải thêm nhánh dự phòng _ => throw new InvalidOperationException("unreachable") ở cuối mọi biểu thức switch. Nếu sau này thêm một kiểu trường hợp mới vào union, trình biên dịch sẽ đánh dấu mọi biểu thức switch chưa xử lý kiểu đó.
Đối sánh mẫu kèm tháo gói giá trị
Mẫu được áp dụng trực tiếp lên union; bạn không cần truy cập .Value hoặc .Result để lấy kiểu bên trong. Trình biên dịch biết cách tự tháo gói:
if (pet is Dog d)
{
Console.WriteLine(d.Name);
}
Trong biểu thức switch, cách viết cũng tương tự:
string Describe(Pet pet) => pet switch
{
Dog d => $"Dog named {d.Name}",
Cat c => $"Cat named {c.Name}",
Bird b => $"Bird named {b.Name}",
};
Các mẫu bạn đã quen thuộc trong C# hoạt động ở đây mà không cần điều chỉnh. Union type trong suốt đối với bộ máy đối sánh mẫu. Bạn có thể xem tài liệu tham chiếu về union type để hiểu thêm cách các mẫu tương tác với thuộc tính IUnion.Value bên dưới.
Xử lý giá trị null
Giá trị mặc định của union type có Value == null. Bạn có thể đối sánh rõ ràng với mẫu null:
string Describe(Pet pet) => pet switch
{
null => "no pet",
Dog d => d.Name,
Cat c => c.Name,
Bird b => b.Name,
};
Bạn có cần nhánh null hay không còn tùy thuộc vào việc bạn có công khai tham số hoặc trường được kiểu hóa bằng union và cho phép giá trị null hay không. Trong phần lớn trường hợp, các chú thích nullable thông thường sẽ định hướng cho bạn.
Ví dụ thực tế: mô hình hóa miền nghiệp vụ
Union type trở nên đặc biệt hữu ích khi kết hợp với record để mô hình hóa miền nghiệp vụ. Hãy xem một hệ thống xử lý đơn hàng với các kiểu sự kiện riêng biệt:
public record OrderPlaced(Guid OrderId, decimal Total);
public record OrderShipped(Guid OrderId, string TrackingNumber);
public record OrderCancelled(Guid OrderId, string Reason);
public union OrderEvent(OrderPlaced, OrderShipped, OrderCancelled);
Giờ đây, bạn có thể viết bộ xử lý sự kiện và để trình biên dịch buộc mã phải xử lý đầy đủ mọi trường hợp:
string Summarize(OrderEvent evt) => evt switch
{
OrderPlaced p => $"Order {p.OrderId} placed for {p.Total:C}",
OrderShipped s => $"Order {s.OrderId} shipped, tracking: {s.TrackingNumber}",
OrderCancelled c => $"Order {c.OrderId} cancelled: {c.Reason}",
};
Ba tháng sau, khi bạn thêm OrderRefunded vào union, mọi biểu thức switch chưa xử lý trường hợp này sẽ lập tức phát sinh cảnh báo. Trình biên dịch trở thành công cụ kiểm tra tính đầy đủ của bạn. Nếu từng phải truy tìm lỗi trong môi trường production do một kiểu sự kiện chưa được xử lý, bạn sẽ thấy tính năng này hữu ích.
Lưu ý rằng union cũng có thể chứa phương thức và thuộc tính, nhưng không thể chứa trường:
public union Length(Meters, Feet)
{
public double TotalMeters => this switch
{
Meters m => m.Value,
Feet f => f.Value * 0.3048,
_ => throw new InvalidOperationException(),
};
}
Union tùy chỉnh với thuộc tính [Union]
Từ khóa union tạo ra một struct. Nếu cần kiểu tham chiếu, chẳng hạn để có phép so sánh định danh, hỗ trợ kế thừa hoặc kiểm soát bố cục bộ nhớ cụ thể, bạn có thể tự xây dựng một union class bằng cách dùng trực tiếp thuộc tính [System.Runtime.CompilerServices.Union]:
[System.Runtime.CompilerServices.Union]
public class Shape : System.Runtime.CompilerServices.IUnion
{
private readonly object? _value;
public Shape(Circle value) { _value = value; }
public Shape(Rectangle value) { _value = value; }
public object? Value => _value;
}
Trên thực tế, từ khóa union đáp ứng tốt phần lớn nhu cầu. Cách tiếp cận dựa trên thuộc tính chỉ phù hợp khi bạn có các yêu cầu cụ thể mà struct do trình biên dịch sinh ra không thể đáp ứng.
Lời kết
Union type trong C# 15 hiện vẫn đang ở giai đoạn xem trước. Để dùng thử, bạn cần cài đặt .NET 11 Preview SDK và thêm cấu hình sau vào tệp dự án:
<PropertyGroup>
<LangVersion>preview</LangVersion>
<TargetFramework>net11.0</TargetFramework>
</PropertyGroup>
Ghi chú phát hành .NET 11 Preview 5 trình bày những nội dung đã được phát hành cho đến thời điểm hiện tại. Tính năng này vẫn tiếp tục được hoàn thiện trước khi bản chính thức ra mắt.
Tôi thực sự hào hứng với tính năng này. F# đã có discriminated union ngay từ đầu, còn các nhà phát triển C# đã yêu cầu một tính năng tương đương kể từ khi csharplang issue #113 được mở. Giờ đây, với khả năng kiểm tra tính đầy đủ, chuyển đổi ngầm định và đối sánh mẫu gọn gàng, union type sẽ thay đổi cách bạn mô hình hóa các khái niệm miền nghiệp vụ trong C#. Không phải vấn đề nào cũng cần một hệ thống phân cấp lớp, và giờ đây bạn đã có một cách ở cấp độ ngôn ngữ để thể hiện điều đó.
Hãy thử tính năng này trên một cơ sở mã mà bạn đang phát triển. Tôi tin rằng bạn sẽ tìm thấy một số nơi union type phù hợp hơn so với cách triển khai hiện tại.



