سلسلهمراتب کلاسهای بسته (Closed Class Hierarchies) در C# 15.0
نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۳/۲۰ ۰۹:۵۵
آدرس: www.dntips.ir
abstract تعریف میکردید، هر کلاس دیگری در هر اسمبلی (Assembly) مختلفی میتوانست از آن ارثبری کند (مگر اینکه دسترسی آن را با internal محدود میکردید که آن هم محدودیتهای خاص خود را داشت). این سیستم ارثبریِ باز، یک چالش بزرگ برای بررسی جامعیت (Exhaustiveness Checking) در ساختارهای تکهکدِ تطبیق الگو (Pattern Matching) ایجاد میکرد.switch پوشش دادهاید یا خیر؛ زیرا همیشه این احتمال وجود داشت که یک توسعهدهنده در اسمبلی دیگری، فرزند جدیدی برای آن کلاس ایجاد کند. در نتیجه، همیشه مجبور بودید یک حالت پیشفرض (_ یا default) بنویسید.// تعریف کلاس پایه به صورت بسته public closed record class GateState; // تعریف فرزندان در همان اسمبلی public record class Closed : GateState; public record class Open(float Percent) : GateState;
GateState خارج از این دو حالت نیست. بنابراین میتوانید متد زیر را بدون نیاز به حالت پیشفرض بنویسید:static string Describe(GateState state) => state switch
{
Closed => "بسته",
Open(var percent) => $"دروازه {percent}% باز است"
};Closed) را فراموش کنید، کامپایلر به شما خطای زمان کامپایل (یا هشدار) میدهد که تمام حالتهای ممکن پوشش داده نشدهاند.closed تعریف میشود، به صورت خودکار abstract است. شما نمیتوانید مستقیماً از خود آن شیء بسازید (New کنید).public باشند و خارج از اسمبلی استفاده شوند، اما فرزند جدیدی نمیتوان خارج از اسمبلی برای کلاس پایه ساخت.switch روی یک کلاس بسته، زمانی جامع (Exhaustive) شناخته میشود که تمام زیرنوعهای مستقیم و در دسترس آن را پوشش داده باشد. در این حالت نیازی به پرتاب استثنای دستی یا حالت _ نیست.namespace System.Runtime.CompilerServices;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class ClosedAttribute : Attribute { }نکته تکمیلی: در نسخه نهایی داتنت 11، این اتریبیوت به صورت پیشفرض در Runtime تعبیه شده است و نیازی به تعریف دستی آن توسط شما نیست؛ کامپایلر با دیدن کلمه کلیدی closed این ساختار را در کدهای intermediate (یا همان IL) پیادهسازی میکند.internal استفاده نکنیم؟»internal میکنید، کل آن کلاس از خارج از اسمبلی غیرقابل دسترس میشود. اما با closed class شما میتوانید کلاس پایه و فرزندانش را public نگه دارید تا پروژههای دیگر بتوانند از آنها استفاده کنند و روی آنها switch بزنند، اما در عین حال جلوی آنها را میگیرید که نتوانند فرزند جدیدی به این زنجیره اضافه کنند. این یعنی پایداری در معماری نرمافزار.private protected یا internal تعریف کنید، عملاً هیچ اسمبلی دیگری نمیتواند از آن ارثبری کند:// روش سنتی و قدیمی
public abstract class LegacyAnimal
{
private protected LegacyAnimal() { } // جلوگیری از ارثبری خارجی
}closedنیاز داریم؟closed نسبت به ترفند قدیمی، آگاهی کامپایلر (Compiler Awareness) است. در روش سنتی، کامپایلر از نظر تئوری حدس نمیزند که فرزندان این کلاس محدود هستند؛ بنابراین در عبارات switch همچنان به شما هشدار عدم جامعیت (عدم پوشش تمام حالتها) میدهد. اما با کلمه کلیدی closed، کامپایلر در زمان ساخت اسمبلی، لیست دقیق فرزندان را استخراج کرده و قابلیت Exhaustiveness Checking را به طور واقعی فعال میکند._) اضافه کنید که استثنا پرتاب کند:static string Speak(Animal animal) => animal switch
{
Dog => "Woof",
Cat => "Meow",
_ => throw new InvalidOperationException() // یک کد زشت اما اجباری!
}Hamster به پروژه اضافه کنید، سیستم بدون هیچ خطایی کامپایل میشود، اما در زمان اجرا (Runtime) با خطای وحشتناک InvalidOperationException مواجه خواهید شد!closed نیازی به بخش _ نیست و به محض اضافه شدن Hamster، کامپایلر مچ شما را در همان زمان کدنویسی میگیرد:warning CS8509: The switch expression does not handle all possible values of its input type... 'Hamster' is not covered.
abstract صریح (چون خودش ضمناً abstract است).sealed یا static روی کلاس پایه.public closed class Animal { } // درست
public closed abstract class Animal { } // خطای کامپایل CS9384
public closed sealed class Animal { } // خطای کامپایل CS9381closed یا sealed شوند:public closed class Animal { }
public closed class Dog : Animal { } // فرزند اول خودش بسته شد
public class Labrador : Dog { } // مجاز استpublic closed class Animal<T> { }
class Dog<U> : Animal<U> { } // مجاز (U به کلاس پایه منتقل شده)
class Horse<W> : Animal<int> { } // خطای کامپایل CS9383 (پارامتر W رها شده است!)sealed (مهروموم شده) تعریف کنید، یک Sealed Hierarchy شکل میگیرد. در این حالت کامپایلر هوشمندتر عمل کرده و خطاهای زمان اجرا را به زمان کامپایل میآورد. به عنوان مثال، اگر اینترفیسی به نام IPet داشته باشیم و هیچکدام از فرزندان Animal آن را پیادهسازی نکرده باشند، کامپایلر متوجه میشود که تبدیل این کلاس به آن اینترفیس هرگز ممکن نیست و جلویش را میگیرد:public closed class Animal { }
public sealed class Dog : Animal { }
public sealed class Cat : Animal { }
// در جای دیگر کد:
Animal animal = GetAnimal();
var pet = (IPet)animal; // خطای زمان کامپایل CS0030! کامپایلر میداند این تبدیل همیشه شکست میخورد.switch دارد. تفاوت ساختاری این دو مفهوم به زبان ساده این است:public closed class Animal { } مینویسید، کامپایلر در خروجی (کدهای IL) چیزی شبیه به این تولید میکند:[Closed]
public class Animal
{
[CompilerFeatureRequired("ClosedClasses")]
public Animal() { }
}[CompilerFeatureRequired] یک شاهکار مهندسی در داتنت است. فرض کنید کتابخانهای با داتنت ۱۱ مینویسید اما خروجی آن را برای داتنت ۸ (net8.0) هدفگذاری میکنید. اگر توسعهدهنده دیگری با SDK قدیمی داتنت ۸ بخواهد از کلاس شما ارثبری کند، چون SDK قدیمی مفهوم [Closed] را نمیفهمد، ممکن بود محدودیت را دور بزند. اما به لطف اتریبیوت CompilerFeatureRequired، کامپایلر قدیمی به محض دیدن عبارت "ClosedClasses" متوقف شده و اجازه ارثبری نمیدهد..csproj) خود فعال کنید:<PropertyGroup> <TargetFramework>net11.0</TargetFramework> <LangVersion>preview</LangVersion> </PropertyGroup>
namespace System.Runtime.CompilerServices;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
internal sealed class ClosedAttribute : Attribute { }abstract (ارثبری کاملاً باز): هر توسعهدهندهای در هر پروژهای میتواند از کلاس شما ارثبری کند.sealed (انسداد کامل ارثبری): حتی خود شما هم در داخل همان پروژه نمیتوانید فرزندی برای کلاس بسازید!«این کلاس باید قابلیت ارثبری داشته باشد، اما فقط و فقط توسط کلاسهایی که خودِ من طراحی کردهام.»
closed، این مرز میانِ برنامهنویسی داخلی و استفادهی خارجی شفاف شده است.CreditCardPaymentDebitCardPaymentBankTransferPaymentCryptoPayment از کلاس پایه ارثبری کند، زیرساخت پردازشی شما شکسته خواهد شد چون برای آن کدی ننوشتهاید. با closed کردن کلاس پایه، ساخت هرگونه روش پرداخت جدید خارج از DLL شما ناممکن میشود.Transaction فقط باید فرزندانی مثل Deposit، Withdraw و Transfer داشته باشد. ارثبری آزادانه میتواند امکان تزریق رفتارهای غیرمجاز مانند BitcoinTransaction را توسط سایر تیمها فراهم کند.closed به سطحهای پایینتر است.closed کنید، فقط فرزندان مستقیم محدود به همان اسمبلی میشوند، نه فرزندانِ فرزندان!// اسمبلی شماره ۱ (DLL شما)
public closed class Animal { } // کلاس پایه بسته است
public class Mammal : Animal { } // فرزند مستقیم؛ مجاز است
public class Bird : Animal { } // فرزند مستقیم؛ مجاز است// پروژه خارجی
public class Lion : Animal { } // ❌ خطای کامپایل؛ ارثبری مستقیم غیرمجاز است
public class Lion : Mammal { } // ✅ مجاز است! چون Mammal خودش closed نشده است!closed را روی فرزندان نیز صریحاً تکرار کنید:public closed class Animal { }
public closed class Mammal : Animal { } // حالا Mammal هم بسته شد
public class Dog : Mammal { } // فقط درون همین پروژه مجاز استsealedوclosedpublic sealed closed class Animal { } // ❌ غیرمجاز!sealed یعنی «هیچکس تحت هیچ شرایطی حق ارثبری ندارد»، اما closed یعنی «ارثبری مجاز است، به شرطی که داخل همین اسمبلی باشد». این دو مفهوم در ذات با هم در تناقض هستند.| ویژگی / رفتار | sealed | abstract | closed (C# 15) |
| قابلیت ارثبری دارد؟ | ❌ خیر | ✅ بله | ✅ بله |
| چه کسانی میتوانند ارثبری کنند؟ | هیچکس | همه پروژهها/اسمبلیها | فقط کلاسهای داخل همان اسمبلی |
امکان نمونهسازی (new) دارد؟ | ✅ بله | ❌ خیر | ❌ خیر (ضمناً abstract است) |
| آیا کامپایلر تمام فرزندان را میشناسد؟ | مربوط نمیشود | ❌ خیر | ✅ بله |
| پشتیبانی از Exhaustive Switch؟ | مربوط نمیشود | ❌ خیر | ✅ بله |
| بهترین مورد مصرف | جلوگیری کامل از ارثبری جهت امنیت/کارایی | ایجاد نقاط توسعه آزاد برای دیگران | بستن دامنه ارثبری به چارچوب/کتابخانه خود |
چکیده: چندریختی شیءگرا (Polymorphism) از دیرباز یکی از ارکان بنیادی معماری نرمافزار در اکوسیستم داتنت (.NET) بوده است. این رویکرد به کمک رابطها (interface) و کلاسهای پایه انتزاعی (abstract class)، توسعهپذیری باز (Open Extensibility) را ممکن میسازد. با این حال، ماهیت نامحدود این سیستم مانع از آن میشود که کامپایلر بتواند تمام مشتقات یک نوع را بهطور کامل شناسایی کند؛ پیامد این محدودیت، اجبار توسعهدهندگان به استفاده از الگوهای پیشفرض یا Fallback (مانند_ => throw ...) در تطبیق الگو (Pattern Matching) است. در بحثهای طراحی نسخههای نوین سیشارپ (نظیر سیشارپ 14 و 15 و طرحهای پیشنهادی پیشرفته زبانی)، مفاهیمی چون سلسلهمراتب بسته (Closed Hierarchies) و انواع اجتماع یا گسسته (Union Types / Discriminated Unions) بهمنظور مدلسازی صریح مجموعههای محدود و متناهی از حالات ارائه شدهاند. این مقاله به بررسی تحلیلی تفاوتهای بین چندریختی باز سنتی و سلسلهمراتب بسته، مکانیزم تطبیق الگوی جامع (Exhaustive Pattern Matching)، تأثیر آن بر نگهداریپذیری و صحت دامنه، جنبههای کارایی و سنجش عملکردی با BenchmarkDotNet، و خطاهای رایج در پیادهسازی میپردازد.
public abstract class PaymentResult
{
private protected PaymentResult() { } // تا حدی وراثت را به داخل اسمبلی محدود میکند اما کامپایلر آن را بسته در نظر نمیگیرد
}
public sealed class PaymentSucceeded : PaymentResult
{
public decimal Amount { get; init; }
}
public sealed class PaymentFailed : PaymentResult
{
public string Reason { get; init; } = string.Empty;
}switch:static string FormatResult(PaymentResult result) => result switch
{
PaymentSucceeded success => $"تراکنش موفق: {success.Amount}",
PaymentFailed failure => $"تراکنش ناموفق: {failure.Reason}",
_ => throw new ArgumentOutOfRangeException(nameof(result))
};_): از آنجا که کامپایلر نمیتواند تضمین کند که کلاس جدیدی از PaymentResult مشتق نخواهد شد، عدم استفاده از شاخه پیشفرض موجب هشدار (Warning) عدم جامعیت میگردد.PaymentPending به دامنه اضافه شود، کدهای قدیمی بدون هیچ خطای کامپایلی کامپایل میشوند، اما در زمان اجرا با استثنای پیشبینینشده (ArgumentOutOfRangeException) مواجه خواهند شد.public record class PaymentSucceeded(decimal Amount);
public record class PaymentFailed(string Reason);
// تعریف نوع اجتماع (بسته)
public union PaymentResult(
PaymentSucceeded,
PaymentFailed);PaymentResult منحصراً یکی از دو وضعیت فوق خواهند بود. در نتیجه، متد پردازشگر بدون نیاز به شاخه پیشفرض پیادهسازی میشود:static string FormatResult(PaymentResult result) => result switch
{
PaymentSucceeded success => $"تراکنش موفق: {success.Amount}",
PaymentFailed failure => $"تراکنش ناموفق: {failure.Reason}"
};PaymentPending به تعریف اجتماع افزوده شود، کامپایلر بلافاصله تمام تطبیقهای الگوی ناکامل را شناسایی کرده و به عنوان خطای کامپایل یا هشدار جدی گزارش میدهد.| مؤلفه / ویژگی | چندریختی سنتی (Traditional Polymorphism) | اجتماع بسته (Closed Union) |
| باز بودن برای پیادهسازیهای جدید | بله (طراحی باز) | خیر (طراحی بسته و ایزوله) |
| تعداد حالات | نامحدود / غیرقابل پیشبینی | محدود و دقیقاً تعریفشده |
| تطبیق الگوی جامع (Exhaustiveness) | نیازمند شاخه پیشفرض (_) | پشتیبانی بومی زمان کامپایل |
| تطابق با اینترفیسها | بسیار بالا | مناسب برای مقادیر دادهای خالص |
| پشتیبانی از ارائهدهندگان ثالث | ایدهآل برای افزونهها و بستههای خارجی | نامناسب برای معماریهای پلاگینمحور |
| آگاهی کامپایلر از کل حالات | عموماً خیر | بله |
| بهترین کاربرد | سیستمهای سرویسمحور و انتزاع رفتار | ماشینهای وضعیت متناهی و مدلسازی دامنه |
نکته کلیدی معماری: پرسش اصلی این نیست که کدام روش مدرنتر یا سریعتر است؛ بلکه پرسش بنیادی مهندسی این است: آیا دامنه این نوع داده باید ذاتاً باز باشد یا بسته؟
public record class Pending;
public record class Paid;
public record class Cancelled;
public record class Refunded;
public union OrderState(
Pending,
Paid,
Cancelled,
Refunded);
public static class OrderPolicy
{
public static string GetStatusMessage(OrderState state) => state switch
{
Pending => "در انتظار پرداخت",
Paid => "پرداخت انجام شد",
Cancelled => "سفارش لغو شد",
Refunded => "مبلغ به مشتری عودت داده شد"
};
}isinst) یا برچسبگذاری درونی (Tagged Representation) استفاده کنند. در نتیجه، تصمیمگیری پیرامون کارایی باید صرفاً بر پایه سنجههای معتبر (Profiling / Benchmarking) استوار باشد.using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser]
public class DispatchBenchmark
{
private readonly PaymentResult _unionResult = new PaymentSucceeded(100m);
private readonly PaymentResultBase _traditionalResult = new PaymentSucceededBase(100m);
[Benchmark(Baseline = true)]
public string TraditionalDispatch() => ProcessTraditional(_traditionalResult);
[Benchmark]
public string UnionDispatch() => ProcessUnion(_unionResult);
private static string ProcessUnion(PaymentResult result) => result switch
{
PaymentSucceeded success => success.Amount.ToString(),
PaymentFailed failure => failure.Reason
};
private static string ProcessTraditional(PaymentResultBase result) => result switch
{
PaymentSucceededBase success => success.Amount.ToString(),
PaymentFailedBase failure => failure.Reason,
_ => throw new ArgumentOutOfRangeException(nameof(result))
};
}توصیه اجرایی: همواره بنچمارکها را تحت پیکربندی Release و در محیط ایزوله اجرا کنید (dotnet run -c Release). از استناد به نتایج تئوریک بدون سنجش بر روی سناریوهای بار کاری واقعی اجتناب ورزید.public interface IPaymentGateway
{
Task<ExecutionResult> ProcessAsync(PaymentDetails details, CancellationToken ct);
}
// ارائهدهندگان مختلف میتوانند آزادانه به پروژه اضافه شوند:
// StripeGateway, ZarinPalGateway, PayPalGateway, ...Result و اعتبارسنجیها)_): قرار دادن _ => ... در تطبیق الگوهای بسته، اصلیترین مزیت این ویژگی (ایمنی کامپایلر در برابر تغییرات مدل) را بیاثر میکند.// کامپایلر این کد را تایید میکند، اما منطق بیزینس معکوس و نادرست است!
return result switch
{
PaymentSucceeded => "خطا در تراکنش",
PaymentFailed => "تراکنش موفق"
};internal و public) است:// درون Assembly مرجع (مثلاً CoreLib.dll) public closed record class Shape; public record class Circle : Shape; internal record class Triangle : Shape; // فقط داخل این پروژه دیده میشود
Shape سوییچ بزند، چون هر دو شکل Circle و Triangle را میبیند، نیازی به بازوی دورریز (_) ندارد و بررسی جامعیت کامل است.Circle را میبیند، اما به Triangle دسترسی ندارد._ یا default را به پروژههای خارجی نمیدهد!Billing منتشر کردهاید که نوع PaymentMethod را با سه فرزند مستقیم Cash، Card و BankTransfer اکسپوز کرده است.default) نوشتهاند و کد بهخوبی کار میکند.Crypto اضافه کنید و نسخه پکیج را بهصورت جزئی (Minor Bump، مثلاً ۱.۱ به ۱.۲) ارتقا دهید؛ چراکه از نظر شما قابلیت جدیدی افزوده شده و کدهای قبلی تغییر نکردهاند.قانون طلایی برای توسعهدهندگان پکیج: افزودن فرزند مستقیم جدید به یک سلسلهمراتب بسته عمومی، رفتاری مشابه افزودن مقادیر جدید به enum دارد. این کار باید یک Breaking Change تلقی شده و همراه با ارتقای نسخه اصلی (Major Version Bump) منتشر شود.public closed record class PaymentMethod; public sealed record class Cash : PaymentMethod; // کلاس Card مهروموم (sealed) نمیشود تا نقطه توسعه بیرونی باقی بماند public record class Card(string Last4) : PaymentMethod;
PaymentMethod ارثبری کنند.Card ارثبری کرده و مثلاً PrepaidCard : Card بسازند.PrepaidCard همچنان با بازوی Card card => ... تطبیق داده میشود و کدهای بیرونی دچار شکست در کامپایل نمیشوند.Dog : Animal). این ابزار زمانی ایدهآل است که شما مالک کل ساختار دامنه هستید.public union Pet(Cat, Dog);، کلاسهای Cat و Dog هیچ ریشه مشترکی ندارند و اصلاً نمیدانند که عضو یک اتحادیه هستند. این مفهوم مناسب زمانی است که انواع از کتابخانههای متفرقه وارد شدهاند یا نباید ساختار شیءگرایی یکسانی به آنها تحمیل شود.sealed با بند صریح permits استفاده میکند. کلاسهای Sealed در جاوا لزوماً abstract نیستند، اما در #C کلمه کلیدی جدید closed به کار گرفته شده و نوع پایه ذاتاً abstract فرض میشود و لیست فرزندان از محدوده اسمبلی استنتاج میشود.sealed class و sealed trait به عنوان ساختار اصلی زبان عمل میکند، در حالی که در #C رفتار ارثبری متداول مبتنی بر Reference Type حفظ شده و بنابراین هر نمونه همچنان روی Heap تخصیص حافظه (Allocation) دارد و از رفتار Value Types (مانند Sum Types در F# یا Rust) متمایز است.dotnet/runtime #129041) تا نیازی به تعریف دستی فیلدهای Discriminator نباشد.ClosedAttribute یا IsClosedTypeAttribute تغییر کند.