نوعهای اتحادیه (Union Types) در C# 15 و NET 11.
نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۳/۱۰ ۰۹:۳۵
آدرس: www.dntips.ir
| مطالب | ۳۶۹۴ |
| نویسندگان | ۲۷۶ |
| گروههای مطالب | ۱۰۲۴ |
| نقشههای راه | ۱۱۹ |
| دورهها | ۱۴ |
| اشتراکها | ۱۷۹۱۴ |
Result و Option را بسیار تمیزتر و ایمنتر میکند و گزینهای قدرتمند برای مدیریت دادههایی است که ممکن است ساختارهای مختلفی داشته باشند. در این مقاله به بررسی syntax، نحوه استفاده، نحوه پیادهسازی و نکات پیشرفته این ویژگی میپردازیم.Result است:TSuccessTErrorResult برمیگرداند و فراخواننده مجبور است هر دو حالت را به صورت صریح مدیریت کند. این الگو (Result Pattern) خطاهای زمان اجرا را کاهش میدهد و کد را قابل پیشبینیتر میکند.public union SupportedOS(Windows, Linux, MacOS);
public record Windows(string Version); public record Linux(string Distro, string Version); public record MacOS(string Name, int Version);
SupportedOS os1 = new SupportedOS(new MacOS("Tahoe", 25));
// تبدیل ضمنی (Implicit Conversion)
SupportedOS os2 = new MacOS("Tahoe", 25);switch است:string GetDescription(SupportedOS os) => os switch
{
Windows windows => $"Windows {windows.Version}",
Linux linux => $"{linux.Distro} {linux.Version}",
MacOS macOS => $"MacOS {macOS.Name} ({macOS.Version})"
// نیازی به _ => نیست! کامپایلر بررسی میکند همه حالات پوشش داده شده باشند
};MacOS?)، باید حالت null را هم مدیریت کنید.// Result public union Result<T>(T, Exception); // Option public record class None; public union Option<T>(None, T);
.csproj گزینه زیر را اضافه کنید:<LangVersion>preview</LangVersion>
IUnion را پیادهسازی میکنند:[Union]
public struct SupportedOS : IUnion
{
public object? Value { get; }
public SupportedOS(Windows value) => this.Value = value;
public SupportedOS(Linux value) => this.Value = value;
public SupportedOS(MacOS value) => this.Value = value;
}object تبدیل (box) میکند که برای انواع value type (مانند int، bool، structهای کوچک) میتواند overhead ایجاد کند. برای سناریوهای عملکردی حساس، میتوانید Union سفارشی بدون boxing بسازید:[Union]
public struct IntOrBool : IUnion
{
private readonly bool _isBool;
private readonly int _value;
public IntOrBool(int value) { _isBool = false; _value = value; }
public IntOrBool(bool value) { _isBool = true; _value = value ? 1 : 0; }
public bool HasValue => true;
public bool TryGetValue(out int value)
{
value = _value;
return !_isBool;
}
public bool TryGetValue(out bool value)
{
value = _isBool && _value == 1;
return _isBool;
}
public object? Value => _isBool ? (_value == 1) : _value;
}TryGetValue را پیادهسازی کنید، کامپایلر به طور خودکار از آنها در عبارتهای switch استفاده میکند و از boxing جلوگیری میشود.OneOf. با معرفی C# 15 و .NET 11، ویژگی منحصربهفرد Union Types به صورت آزمایشی به زبان اضافه شده است تا این شکاف ساختاری را برای همیشه برطرف کند. در این مقاله، به بررسی عمیق چالشهای موجود و نحوه بازنویسی الگوی Result با این قابلیت جدید میپردازیم.// کنترل جریان برنامه از طریق پرتاب استثنا (رویکرد سنتی)
public User GetUser(int id) =>
_users.TryGetValue(id, out var user)
? user
: throw new UserNotFoundException(id);User است، اما در واقعیت این یک ادعای ناقص است. متد فوق در صورت عدم یافتن کاربر، استثنا پرتاب میکند. این رویکرد دو ایراد ساختاری عمده دارد:| روش جایگزین | مزیت | محدودیت اصلی |
الگوی Try-Parse (خروجی bool و پارامتر out) | عدم پرتاب استثنا | عدم پشتیبانی از برنامهنویسی ناهمگام (Async) و پیچیدگی ترکیب کدهای چندگانه |
کلاس سفارشی Result با فیلدهای Nullable | امضای صریح متد | افزایش کدهای کلیشهای (Ceremony) و عدم اجبار کامپایلر به بررسی خطا |
کتابخانه OneOf | شبیهسازی مناسب نوعهای اتحادی | وابستگی به کتابخانه خارجی و عدم پشتیبانی بومی در سطح زبان |
public union Result<T>(T, Exception);
Result دقیقاً و صرفاً میتواند حاوی مقداری از نوع T یا نوع Exception باشد. در ادامه، یک نمونه ملموستر از ترکیب انواع سوابق (Record Types) مستقل برای مدلسازی روشهای پرداخت آورده شده است:public record CreditCard(string Last4, string Brand); public record PayPal(string Email); public record BankTransfer(string Iban); public union PaymentMethod(CreditCard, PayPal, BankTransfer);
PaymentMethod method = new CreditCard("4242", "Visa");switch نمایان میشود: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 یا دیسکارد (_)default) یا علامت دیسکارد (_) نیست. از آنجا که کامپایلر از بسته بودن این مجموعه آگاه است، جامعیت یا Exhaustiveness Check را ارزیابی میکند. اگر توسعهدهندهای یکی از حالتها را فراموش کند، کامپایلر هشدار زیر را صادر خواهد کرد: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.
GetUser را بازنویسی میکنیم. ابتدا نوعهای خروجی ممکن را به صورت شفاف تعریف میکنیم:public record NotFound(int Id); public union UserResult(User, NotFound);
public UserResult GetUser(int id) =>
_users.TryGetValue(id, out var user)
? user
: new NotFound(id);IResult response = GetUser(42) switch
{
User user => Results.Ok(user),
NotFound found => Results.NotFound($"No user with id {found.Id}"),
};struct تبدیل میکند. این ساختار حاوی یک فیلد متمایزکننده (Discriminator/Tag) برای تشخیص نوع فعلی و یک ارجاع تخصیصیافته به داده است.object? ذخیره شوند که این امر برای انواع ساختاری منجر به بروز پدیده Boxing میشود. با این حال، تیم توسعه داتنت در حال توسعه یک مسیر بهینهسازی بدون نیاز به Boxing (Non-boxing path) با استفاده از الگوهای پیشرفتهتر چیدمان حافظه (LayoutKind.Explicit) برای کدهای با کارایی بالا (Performance-sensitive) است.Result با این ابزار نوظهور، ما را از معماریهای پیچیده قدیمی و پنهانکاری متدها نجات میدهد. به کمک این قابلیت، امضای متدها بازتابدهنده واقعیت خروجی برنامه خواهد بود و سیستم کنترل خطای زمان کامپایل، ریسک بروز خطاهای پیشبینینشده در زمان اجرا (Runtime) را به حداقل میرساند. این گامی بزرگ به سمت کدهای خواناتر، پایدارتر و با نگهداری آسانتر در اکوسیستم داتنت است.Variant آشنا هستند. این نوع داده انعطافپذیر اجازه میداد یک تابع گاهی یک رشته، گاهی یک عدد، گاهی یک آرایه، و گاهی هم مقدار Nothing یا خطا برگرداند. این الگو، ساده، سریع و بسیار عملی بود، اما هنگام مدرنسازی این کدبیسها به سمت #C، همیشه یکی از بزرگترین چالشها بودهاست.Variant استفاده میکردند:Public Function LookupCustomer(ByVal customerID As Long) As Variant
Dim rs As ADODB.Recordset
On Error GoTo ErrorHandler
Set rs = New ADODB.Recordset
rs.Open "SELECT * FROM Customers WHERE ID = " & customerID, _
gConnection, adOpenStatic, adLockReadOnly
If rs.EOF Then
LookupCustomer = Empty
Else
LookupCustomer = Array(rs!CustomerName, rs!CreditLimit, rs!Status)
End If
rs.Close
Exit Function
ErrorHandler:
LookupCustomer = "ERROR: " & Err.Description
End FunctionIsEmpty، IsArray یا VarType بررسی میکرد که چه چیزی دریافت کرده است. این رویکرد هرچند غیررسمی بود، اما دقیقاً منظور برنامهنویس را بیان میکرد: تابع یکی از سه حالت ممکن را برمیگرداند.public union CustomerLookupResult
{
Customer(string Name, decimal CreditLimit, CustomerStatus Status);
NotFound;
Error(string Message);
}
public CustomerLookupResult LookupCustomer(long customerId)
{
try
{
using var connection = new SqlConnection(_connectionString);
connection.Open();
using var command = new SqlCommand(
"SELECT Name, CreditLimit, Status FROM Customers WHERE Id = @id",
connection);
command.Parameters.AddWithValue("@id", customerId);
using var reader = command.ExecuteReader();
if (!reader.Read())
return new CustomerLookupResult.NotFound();
return new CustomerLookupResult.Customer(
reader.GetString(0),
reader.GetDecimal(1),
Enum.Parse<CustomerStatus>(reader.GetString(2)));
}
catch (SqlException ex)
{
return new CustomerLookupResult.Error(ex.Message);
}
}var result = LookupCustomer(12345);
var message = result switch
{
CustomerLookupResult.Customer c =>
$"Found {c.Name}, credit limit {c.CreditLimit:C}",
CustomerLookupResult.NotFound => "No customer with that ID",
CustomerLookupResult.Error e => $"Lookup failed: {e.Message}"
};Suspended) به اتحادیه اضافه کند، کامپایلر همه switchهای موجود را علامتگذاری میکند تا مطمئن شود همه حالتها مدیریت شدهاند. این چیزی است که نوع Variant در VB6 هرگز نداشت.Any) یا Clarion هستند، اتحادیهها تحول بزرگی ایجاد کردهاند. دیگر نیازی به استفاده از کتابخانههای شخص ثالث مانند OneOf یا ساختن boilerplate زیاد نیست. کد تولیدی هم وفادار به معنای اصلی است و هم ایدیوماتیک برای #C مدرن. این ویژگی بخشی از روند گستردهتری است که در سالهای اخیر در .NET رخ داده: Records، Pattern Matching پیشرفته، Required Members و اکنون Union Types. هر کدام از این امکانات، هدف مهاجرت را جذابتر و هزینه نگهداری روی پلتفرم قدیمی را بالاتر میبرند.public union CustomerLookupResultunion نوعی از نوع داده است که میگوید: «مقدار این متغیر دقیقاً یکی از این حالتها (cases) را دارد، نه بیشتر و نه کمتر.»class معمولی یا record، اینجا کامپایلر میداند که فقط یکی از حالتها ممکن است وجود داشته باشد.public union CustomerLookupResult
{
Customer(string Name, decimal CreditLimit, CustomerStatus Status);
NotFound;
Error(string Message);
}public union CustomerLookupResultrecord یا class عمل میکند اما رفتار متفاوتی دارد.Customer(...)record موقعیتی (positional record) است.Name، CreditLimit و Status را بدهید.NotFound;NotFound یک حالت خالی و ثابت.Error(string Message);CustomerLookupResult ایجاد میکنند.// حالت موفق
return new CustomerLookupResult.Customer(علی رضایی", 15000000", CustomerStatus.Active);
// حالت پیدا نشد
return new CustomerLookupResult.NotFound();
// حالت خطا
return new CustomerLookupResult.Error("اتصال به دیتابیس برقرار نشد");switch expression است:var message = result switch
{
CustomerLookupResult.Customer c =>
$"مشتری یافت شد: {c.Name} - اعتبار: {c.CreditLimit:C}",
CustomerLookupResult.NotFound =>
"مشتری با این شناسه وجود ندارد",
CustomerLookupResult.Error e =>
$"خطا در جستجو: {e.Message}",
// اگر حالتی اضافه شود و اینجا پوشش داده نشود، کامپایلر هشدار میدهد
};Suspended اضافه کنید، تمام switchهای قبلی خطای کامپایل یا warning میگیرند.Variant در VB6 یا object در C# قدیمی، اینجا اشتباه نوع (مثل فرض کردن string بودن وقتی NotFound است) در زمان کامپایل گرفته میشود.abstract class + recordهای مشتقشده + enum discriminator میساختید که خیلی طولانیتر بود.when، property patterns و غیره هم استفاده کنید.| ویژگی | VB6 Variant | C# Union (.NET 11) |
| انعطافپذیری | بسیار بالا | محدود به موارد تعریفشده |
| ایمنی نوع | بسیار کم | بسیار بالا (کامپایلر-محور) |
| بررسی کامل حالتها | دستی (IsEmpty, VarType) | خودکار توسط کامپایلر |
| خوانایی کد | متوسط | بسیار بالا |
| نگهداری در بلندمدت | سخت | آسان |
public union در .NET 11 پلی بسیار قوی بین تجربهٔ عملی زبانهای قدیمی Basic و قدرت مدرن #C ایجاد کرده است.