🚀 ءUnion Types بالاخره به #C میآیند
هر توسعهدهنده Backend دیر یا زود با یک چالش تکراری مواجه میشود: متدی که بتواند یکی از چندین مقدار مختلف را برگرداند.
یک عملیات Parse که یا یک عدد معتبر تولید میکند یا یک خطا. یک Lookup که یا یک مقدار را برمیگرداند یا نتیجه «یافت نشد» را. یک عملیات که یا موفق میشود یا شکست میخورد.
در #C تاکنون هیچ راهکار تمیز و استانداردی برای مدلسازی مفهوم «این یا آن» (A یا B) نداشتیم. بنابراین مجبور بودیم آن را شبیهسازی کنیم؛ با استفاده از Marker Interfaceها، Abstract Base Classها، Tupleها، Nullable Returnها، Exceptionها یا کتابخانه بسیار خوب OneOf.
اکنون C# 15 (که همراه با NET 11. منتشر خواهد شد) بالاخره Union Typeها را به زبان اضافه میکند.
سالها منتظر چنین قابلیتی بودیم، بنابراین اجازه دهید یک معرفی سریع از آن داشته باشیم.
بیایید شروع کنیم.
🎯 مشکل
فرض کنید متدی داریم که میتواند یک User را برگرداند یا به دلیل وجود نداشتن کاربر با شکست مواجه شود.
امروزه معمولاً چنین چیزی مینویسیم:
// Throw for the "failure" case - control flow via exceptions
public User GetUser(int id) =>
_users.TryGetValue(id, out var user)
? user
: throw new UserNotFoundException(id);
امضای متد (Signature) میگوید که یک User برمیگرداند، اما این حقیقت کامل نیست؛ زیرا ممکن است Exception نیز پرتاب کند.
فراخواننده متد هیچ راهی برای دانستن این موضوع ندارد، مگر اینکه بدنه متد را مطالعه کند.
سایر راهکارهای رایج نیز (مانند استفاده از
bool TryGet به همراه out parameter، یک کلاس Result سفارشی با فیلدهای Nullable یا OneOf<User>, NotFound) همگی پیچیدگی بیشتری را برای بیان یک مفهوم ساده تحمیل میکنند.آنچه واقعاً نیاز دارید، یک مجموعه بسته (Closed Set) از Typeها است.
و دقیقاً همین چیزی است که Union ارائه میکند.
🏗 تعریف یک Union
ءSyntax آن به طرز دلپذیری ساده است.
کافی است نام Union و Typeهای ممکن را مشخص کنید:
public union Result<T>(T, Exception);
همین.
از این لحظه <Result<T فقط میتواند یکی از دو حالت زیر باشد:
TExceptionو هیچ چیز دیگر.
حتی لازم نیست این Typeها با یکدیگر ارتباطی داشته باشند و دقیقاً همین موضوع هدف اصلی Union است.
مثال ملموستر:
public record CreditCard(string Last4, string Brand);
public record PayPal(string Email);
public record BankTransfer(string Iban);
public union PaymentMethod(CreditCard, PayPal, BankTransfer);
⚡️ ساختن مقدار
برای هر Case Type یک تبدیل ضمنی (Implicit Conversion) وجود دارد، بنابراین کافی است مقدار را مستقیماً Assign کنید:
PaymentMethod method = new CreditCard("4242", "Visa");اگر سعی کنید Typeای را Assign کنید که جزو مجموعه تعریفشده نیست، با خطای کامپایل مواجه خواهید شد.
زیرا این مجموعه بسته است.
🔍 استفاده از Union
اینجاست که قدرت واقعی آن مشخص میشود. Pattern Matching بهصورت مستقیم کار میکند و کامپایلر نیز نوع داخلی مقدار را برای شما بررسی میکند:
string Describe(PaymentMethod method) => method switch
{
CreditCard card => $"{card.Brand} ending {card.Last4}",
PayPal paypal => $"PayPal ({paypal.Email})",
BankTransfer ach => $"Bank transfer to {ach.Iban}",
};
توجه کنید که هیچ:
_
یا
default
وجود ندارد.
چرا؟
زیرا Union یک مجموعه بسته است و کامپایلر میداند که هر سه حالت پوشش داده شدهاند.
اگر یکی از آنها را فراموش کنید، هنگام کامپایل هشدار دریافت خواهید کرد:
warning CS8509: The switch expression does not handle all possible values
of its input type (it is not exhaustive). For example, the pattern 'BankTransfer'
is not covered.
قابلیت Exhaustiveness Checking همان ویژگیای است که بیش از همه درباره آن هیجانزدهام.
اگر بعداً یک Case جدید به Union اضافه کنید، کامپایلر تمام Switchهایی را که نیاز به بروزرسانی دارند به شما نشان خواهد داد.
🔄 بازگشت به مشکل اولیه
متد
GetUser که قبلاً درباره آن صحبت کردیم را به خاطر دارید؟بیایید آن را با استفاده از Union بازنویسی کنیم.
ابتدا مشخص میکنیم که این متد واقعاً چه چیزهایی میتواند برگرداند:
public record NotFound(int Id);
public union UserResult(User, NotFound);