عنوان:

‫سلسله‌مراتب کلاس‌های بسته (Closed Class Hierarchies) در C# 15.0


نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۳/۲۰ ۰۹:۵۵
آدرس: www.dntips.ir
در دات‌نت 11 و سی‌شارپ 15، یکی از قابلیت‌های بسیار جذاب که به زبان اضافه شده، مفهوم سلسله‌مراتب کلاس‌های بسته (Closed Class Hierarchies) است. این ویژگی به توسعه‌دهندگان اجازه می‌دهد تا کنترل دقیق‌تری روی ارث‌بری داشته باشند و به کامپایلر کمک می‌کند تا کدهای امن‌تر و بهینه‌تری تولید کند. در این مقاله، به بررسی عمیق این قابلیت، قوانین آن، نحوه استفاده و مزایای آن در مدیریت الگوها (Pattern Matching) می‌پردازیم.

مقدمه: چالش ارث‌بری باز و نیاز به سیستم‌های بسته
در نسخه‌های قبلی سی‌شارپ، وقتی یک کلاس را به صورت abstract تعریف می‌کردید، هر کلاس دیگری در هر اسمبلی (Assembly) مختلفی می‌توانست از آن ارث‌بری کند (مگر اینکه دسترسی آن را با internal محدود می‌کردید که آن هم محدودیت‌های خاص خود را داشت). این سیستم ارث‌بریِ باز، یک چالش بزرگ برای بررسی جامعیت (Exhaustiveness Checking) در ساختارهای تکه‌کدِ تطبیق الگو (Pattern Matching) ایجاد می‌کرد.
کامپایلر هرگز نمی‌توانست مطمئن شود که آیا تمام فرزندان ممکنِ یک کلاس را در یک عبارت switch پوشش داده‌اید یا خیر؛ زیرا همیشه این احتمال وجود داشت که یک توسعه‌دهنده در اسمبلی دیگری، فرزند جدیدی برای آن کلاس ایجاد کند. در نتیجه، همیشه مجبور بودید یک حالت پیش‌فرض (_ یا default) بنویسید.
قابلیت Closed Class در دات‌نت 11 این مشکل را با معرفی مفهومی شبیه به کلاس‌های Sealed در جاوا یا گزاره‌های چیدمانی (Sum Types / Algebraic Data Types) در زبان‌هایی مانند کاتلین و راست حل کرده است.

توضیح فنی و نحوه عملکرد Closed Class
یک کلاس بسته (Closed Class)، کلاسی است که فرزندان مستقیم آن فقط و فقط باید در همان اسمبلی تعریف شوند. این محدودیتِ آگاهانه، به کامپایلرِ روزلین (Roslyn) این قدرت را می‌دهد که در زمان کامپایل، تمام فرزندان ممکن یک کلاس را بشناسد.

یک مثال عملی و ساده
فرض کنید می‌خواهید وضعیت یک گیت یا دروازه را مدل‌سازی کنید. این گیت یا کاملاً بسته است یا به میزان مشخصی باز است:
// تعریف کلاس پایه به صورت بسته
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 Class
برای استفاده درست از این قابلیت، باید قوانین سه گانه زیر را مد نظر داشته باشید:
  • انتزاعی بودن ضمنی (Implicitly Abstract): یک کلاس که با کلمه کلیدی closed تعریف می‌شود، به صورت خودکار abstract است. شما نمی‌توانید مستقیماً از خود آن شیء بسازید (New کنید).
  • محدودیت قلمرو (Assembly-Scoped): زیرنوع‌ها یا کلاس‌های فرزند مستقیم، حتماً باید در همان اسمبلیِ کلاس پایه تعریف شوند. البته این فرزندان خودشان می‌توانند public باشند و خارج از اسمبلی استفاده شوند، اما فرزند جدیدی نمی‌توان خارج از اسمبلی برای کلاس پایه ساخت.
  • جامعیت در سوییچ (Exhaustive Switch): یک عبارت switch روی یک کلاس بسته، زمانی جامع (Exhaustive) شناخته می‌شود که تمام زیرنوع‌های مستقیم و در دسترس آن را پوشش داده باشد. در این حالت نیازی به پرتاب استثنای دستی یا حالت _ نیست.

پشت صحنه کامپایلر (نسخه پیش‌نمایش)
در حال حاضر و در نسخه‌های پیش‌نمایش (Preview)، پروژههایی که از کلاس‌های بسته استفاده می‌کنند، نیاز به یک اتریبیوت پشتیبان در سطح کامپایلر دارند. کامپایلر از این اتریبیوت برای شناسایی کلاس‌های بسته استفاده می‌کند:
namespace System.Runtime.CompilerServices;

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class ClosedAttribute : Attribute { }
نکته تکمیلی: در نسخه نهایی دات‌نت 11، این اتریبیوت به صورت پیش‌فرض در Runtime تعبیه شده است و نیازی به تعریف دستی آن توسط شما نیست؛ کامپایلر با دیدن کلمه کلیدی closed این ساختار را در کدهای intermediate (یا همان IL) پیاده‌سازی می‌کند.

مقایسه کلاس‌های Closed با کلاس‌های Internal
شاید بپرسید: «چرا از همان دسترسی internal استفاده نکنیم؟»
وقتی یک کلاس را internal می‌کنید، کل آن کلاس از خارج از اسمبلی غیرقابل دسترس می‌شود. اما با closed class شما می‌توانید کلاس پایه و فرزندانش را public نگه دارید تا پروژه‌های دیگر بتوانند از آن‌ها استفاده کنند و روی آن‌ها switch بزنند، اما در عین حال جلوی آن‌ها را می‌گیرید که نتوانند فرزند جدیدی به این زنجیره اضافه کنند. این یعنی پایداری در معماری نرم‌افزار.

نتیجه‌گیری
معرفی Closed Class Hierarchies در سی‌شارپ 15 و دات‌نت 11، گام بزرگی به سمت برنامه‌نویسیِ امن‌تر و مبتنی بر نوع (Type-Safe) است. این قابلیت با ایجاد یک ساختار بسته و قابل پیش‌بینی برای کامپایلر:
  • خطاهای انسانی ناشی از فراموش کردن هندل کردن یک وضعیت (State) را به شدت کاهش می‌دهد.
  • خوانایی کد را بالا برده و نیاز به کدهای اضافه (Boilerplate Code) مانند حالت‌های پیش‌فرضِ اجباری را از بین می‌برد.
  • ابزاری قدرتمند برای پیاده‌سازی الگوهای معماری مانند Domain-Driven Design (DDD) و سیستم‌های مدیریت وضعیت (State Machines) فراهم می‌کند.
این ویژگی پختگی هرچه بیشتر سی‌شارپ در پشتیبانی از مفاهیم برنامه‌نویسی تابع‌محور (Functional Programming) در کنار شیءگرایی را نشان می‌دهد.

نظرات

  • وحید نصیری در ۱۴۰۵/۰۴/۱۰ ۰۹:۴۰
    بخش تکمیلی: کالبدشکافی عمیق، محدودیت‌ها و رفتار داخلی Closed Class در سی‌شارپ ۱۵

    در بخش نخست به فلسفه وجودی کلاس‌های بسته و نقش آن‌ها در بهبود فرآیند تطبیق الگو (Pattern Matching) پرداختیم. اما برای استفاده عملی از این قابلیت در پروژه‌های واقعی، نیاز است که ابعاد پنهان آن، نحوه مدیریت خطاها توسط کامپایلر، مقایسه آن با ترفندهای قدیمی و روش فعال‌سازی آن در زمان پیش‌نمایش را به طور دقیق بررسی کنیم.

    ۱. تفاوت ساختاری با راهکارهای سنتی (ترفند Constructor پنهان)
    توسعه‌دهندگان ارشد سی‌شارپ سال‌هاست که برای شبیه‌سازی کلاس‌های بسته، از ترفند محدود کردن سازنده (Constructor) استفاده می‌کنند. اگر سازنده یک کلاس پایه را به صورت 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.

    ۳. قوانین سخت‌گیرانه و محدودیت‌های Closed Class
    کامپایلر روزلین قوانین مشخصی را برای حفظ پایداری سلسله‌مراتب بسته وضع کرده است که باید به آن‌ها توجه کنید:
    الف) تداخل کلمات کلیدی
    یک کلاس بسته به طور ضمنی انتزاعی است؛ بنابراین استفاده از کلمات کلیدی زیر خطای کامپایل به همراه دارد:
    • استفاده از abstract صریح (چون خودش ضمناً abstract است).
    • استفاده از sealed یا static روی کلاس پایه.
    public closed class Animal { } // درست
    public closed abstract class Animal { } // خطای کامپایل CS9384
    public closed sealed class Animal { } // خطای کامپایل CS9381

    ب) وضعیت کلاس‌های فرزند
    کلاس‌های فرزندِ یک کلاس بسته، به طور خودکار بسته نیستند. آن‌ها می‌توانند باز بمانند یا خودشان مجدداً closed یا sealed شوند:
    public closed class Animal { }
    public closed class Dog : Animal { } // فرزند اول خودش بسته شد
    public class Labrador : Dog { }     // مجاز است

    ج) محدودیت در انواع جنریک (Generic Types)
    اگر کلاس بسته شما جنریک باشد، هر کلاس فرزندی که تعریف می‌کنید باید تمام پارامترهای جنریک کلاس پایه را در ساختار خود مصرف یا مشخص کند:
    public closed class Animal<T> { }
    
    class Dog<U> : Animal<U> { }       // مجاز (U به کلاس پایه منتقل شده)
    class Horse<W> : Animal<int> { }   // خطای کامپایل CS9383 (پارامتر W رها شده است!)

    ۴. ایده فوق‌العاده: سلسله‌مراتب مهروموم شده (Sealed Hierarchy)
    اگر تمام فرزندان مستقیم یک کلاس بسته را به صورت 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! کامپایلر می‌داند این تبدیل همیشه شکست می‌خورد.

    ۵. مقایسه مفهوم کلاس‌های بسته با اتحادیه‌ها (Unions)
    در دات‌نت ۱۱ قابلیت Union نیز معرفی شده است که رفتار مشابهی در switch دارد. تفاوت ساختاری این دو مفهوم به زبان ساده این است:
    • Closed Class: بر پایه رابطه ارث‌بری (Is-A) کار می‌کند و فرزندان یک ریشه مشترک دارند.
    • Union Support: هیچ سلسله‌مراتب و ارث‌بری در کار نیست؛ بلکه صرفاً چند نوع داده‌ایِ کاملاً مستقل (مانند ساختارهای سیستم‌عامل‌های مختلف) را در یک ظرف مشترک جمع می‌کند تا کامپایلر جامعیت آن‌ها را بررسی کند.

    ۶. در پشت صحنه کامپایلر چه می‌گذرد؟ (Implementation)
    زمانی که شما کدی مانند public closed class Animal { } می‌نویسید، کامپایلر در خروجی (کدهای IL) چیزی شبیه به این تولید می‌کند:
    [Closed] 
    public class Animal
    {
        [CompilerFeatureRequired("ClosedClasses")] 
        public Animal() { }
    }
    استفاده از اتریبیوت [CompilerFeatureRequired] یک شاهکار مهندسی در دات‌نت است. فرض کنید کتابخانه‌ای با دات‌نت ۱۱ می‌نویسید اما خروجی آن را برای دات‌نت ۸ (net8.0) هدف‌گذاری می‌کنید. اگر توسعه‌دهنده دیگری با SDK قدیمی دات‌نت ۸ بخواهد از کلاس شما ارث‌بری کند، چون SDK قدیمی مفهوم [Closed] را نمی‌فهمد، ممکن بود محدودیت را دور بزند. اما به لطف اتریبیوت CompilerFeatureRequired، کامپایلر قدیمی به محض دیدن عبارت "ClosedClasses" متوقف شده و اجازه ارث‌بری نمی‌دهد.

    ۷. نحوه فعال‌سازی در محیط توسعه (.NET 11 Preview)
    برای استفاده از این قابلیت در حال حاضر، باید مراحل زیر را طی کنید:
    ۱. مطمئن شوید نسخه SDK دات‌نت ۱۱ (نسخه Preview 5 یا بالاتر) را نصب کرده‌اید.
    ۲. ویژگی پیش‌نمایش زبان را در فایل پروژه (.csproj) خود فعال کنید:
    <PropertyGroup>
      <TargetFramework>net11.0</TargetFramework>
      <LangVersion>preview</LangVersion>
    </PropertyGroup>
    ۳. اتریبیوت پشتیبان را به صورت دستی در بخشی از پروژه خود تعریف کنید (این کار در نسخه‌های نهایی بر عهده خود Runtime خواهد بود):
    namespace System.Runtime.CompilerServices;
    
    [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
    internal sealed class ClosedAttribute : Attribute { }

    نتیجه‌گیری بخش تکمیلی
    قابلیت کلاس‌های بسته (Closed Classes) صرفاً یک ویژگی تزیینی برای زیبایی کد نیست؛ بلکه یک ابزار قدرتمند معماری است که با پر کردن شکاف بین برنامه‌نویسی شیءگرا و تابع‌محور، امنیت کد را در سطح سیستم‌های بزرگ تضمین کرده و خطاهای احتمالی آینده در توسعه نرم‌افزار را در زمان کامپایل غیرممکن می‌کند.
  • وحید نصیری در ۱۴۰۵/۰۵/۰۳ ۰۹:۳۲
    بخش تکمیلی: طراحی API و زنجیره ارث‌بری چندسطحی با Closed Class در سی‌شارپ ۱۵

    تاکنون از زاویه نحوه عملکرد کامپایلر و تطبیق الگو به کلاس‌های بسته نگاشته شد. اما اصلی‌ترین دلیل اضافه شدن این قابلیت به سی‌شارپ ۱۵، حل یک چالش دیرینه در طراحی API، توسعه پکیج‌های NuGet و معماری سیستم‌های بزرگ مالی و سازمانی است.

    ۱. نقطه میانی مفقوده در ارث‌بری سی‌شارپ
    در سی‌شارپ کلاسیک، تنها دو حالت افراطی برای ارث‌بری وجود داشت:
    • abstract (ارث‌بری کاملاً باز): هر توسعه‌دهنده‌ای در هر پروژه‌ای می‌تواند از کلاس شما ارث‌بری کند.
    • sealed (انسداد کامل ارث‌بری): حتی خود شما هم در داخل همان پروژه نمی‌توانید فرزندی برای کلاس بسازید!
    سالیان سال جای یک حالت سوم و بسیار ضروری خالی بود:
    «این کلاس باید قابلیت ارث‌بری داشته باشد، اما فقط و فقط توسط کلاس‌هایی که خودِ من طراحی کرده‌ام.»
    با معرف کلمه کلیدی closed، این مرز میانِ برنامه‌نویسی داخلی و استفاده‌ی خارجی شفاف شده است.

    ۲. سناریوهای واقعی در معماری (توسعه SDK و پکیج‌های مالی)
    برای درک بهتر کاربرد عملی، دو سیستم زیر را در نظر بگیرید:
    الف) سیستم‌های پرداختی (Payment Gateway SDK)
    اگر پکیجی برای پردازش پرداخت‌ها می‌نویسید، شاید فقط سه روش را پشتیبانی کنید:
    • CreditCardPayment
    • DebitCardPayment
    • BankTransferPayment
    اگر مصرف‌کننده پکیج شما یک کلاس جدید مانند CryptoPayment از کلاس پایه ارث‌بری کند، زیرساخت پردازشی شما شکسته خواهد شد چون برای آن کدی ننوشته‌اید. با closed کردن کلاس پایه، ساخت هرگونه روش پرداخت جدید خارج از DLL شما ناممکن می‌شود.

    ب) تراکنش‌های مالی در سیستم‌های بانکی (Core Banking)
    در یک دامنه‌ی حساس مالی (Domain Model)، کلاس پایه Transaction فقط باید فرزندانی مثل Deposit، Withdraw و Transfer داشته باشد. ارث‌بری آزادانه می‌تواند امکان تزریق رفتار‌های غیرمجاز مانند BitcoinTransaction را توسط سایر تیم‌ها فراهم کند.

    ۳. یک نکته کلیدی و حیاتی: Closed بودن خاصیت «انتقالی» (Transitive) ندارد!
    یکی از مهم‌ترین و گول‌زننده‌ترین نکات فنی درباره کلاس‌های بسته که باید حتماً مدنظر قرار دهید، عدم انتقال خودکار خاصیت 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وclosed
    استفاده همزمان از این دو کلمه کلیدی تناقض منطقی (Semantic Conflict) ایجاد می‌کند و کامپایلر خطای مجزا صادر می‌کند:
    public sealed closed class Animal { } // ❌ غیرمجاز!
    علت:sealed یعنی «هیچ‌کس تحت هیچ شرایطی حق ارث‌بری ندارد»، اما closed یعنی «ارث‌بری مجاز است، به شرطی که داخل همین اسمبلی باشد». این دو مفهوم در ذات با هم در تناقض هستند.

    ۵. جدول مقایسه جامع اصلاح‌کننده‌های ارث‌بری (Inheritance Modifiers)
    برای داشتن یک دید همه‌جانبه، رفتار اصلاح‌کننده‌های مختلف ارث‌بری در سی‌شارپ ۱۵ در جدول زیر خلاصه شده است:

    ویژگی / رفتارsealedabstractclosed (C# 15)
    قابلیت ارث‌بری دارد؟❌ خیر✅ بله✅ بله
    چه کسانی می‌توانند ارث‌بری کنند؟هیچ‌کسهمه پروژه‌ها/اسمبلی‌هافقط کلاس‌های داخل همان اسمبلی
    امکان نمونه‌سازی (new) دارد؟✅ بله❌ خیر❌ خیر (ضمناً abstract است)
    آیا کامپایلر تمام فرزندان را می‌شناسد؟مربوط نمی‌شود❌ خیر✅ بله
    پشتیبانی از Exhaustive Switch؟مربوط نمی‌شود❌ خیر✅ بله
    بهترین مورد مصرفجلوگیری کامل از ارث‌بری جهت امنیت/کاراییایجاد نقاط توسعه آزاد برای دیگرانبستن دامنه ارث‌بری به چارچوب/کتابخانه خود
    جمع‌بندی نهایی
    قابلیت Closed Class Hierarchies باعث می‌شود که قصد و معماری مدنظر شما (Design Intent) مستقیماً در کدهای #C درج شود و دیگر نیازی به تکیه بر مستندات متنی یا هشدارهای شفاهی به سایر توسعه‌دهندگان نباشد. این ویژگی تمایز واضحی بین «توسعه‌پذیری عمومی» و «محدودیت‌های دامنه نرم‌افزار» ایجاد می‌کند.
  • وحید نصیری در ۱۴۰۵/۰۶/۱۵ ۰۹:۰۲
    سلسله‌مراتب بسته در سی‌شارپ (Closed Hierarchies): مقایسه تطبیق الگوی جامع با چندریختی سنتی

    چکیده: چندریختی شیءگرا (Polymorphism) از دیرباز یکی از ارکان بنیادی معماری نرم‌افزار در اکوسیستم دات‌نت (.NET) بوده است. این رویکرد به کمک رابط‌ها (interface) و کلاس‌های پایه انتزاعی (abstract class)، توسعه‌پذیری باز (Open Extensibility) را ممکن می‌سازد. با این حال، ماهیت نامحدود این سیستم مانع از آن می‌شود که کامپایلر بتواند تمام مشتقات یک نوع را به‌طور کامل شناسایی کند؛ پیامد این محدودیت، اجبار توسعه‌دهندگان به استفاده از الگوهای پیش‌فرض یا Fallback (مانند _ => throw ...) در تطبیق الگو (Pattern Matching) است. در بحث‌های طراحی نسخه‌های نوین سی‌شارپ (نظیر سی‌شارپ 14 و 15 و طرح‌های پیشنهادی پیشرفته زبانی)، مفاهیمی چون سلسله‌مراتب بسته (Closed Hierarchies) و انواع اجتماع یا گسسته (Union Types / Discriminated Unions) به‌منظور مدل‌سازی صریح مجموعه‌های محدود و متناهی از حالات ارائه شده‌اند. این مقاله به بررسی تحلیلی تفاوت‌های بین چندریختی باز سنتی و سلسله‌مراتب بسته، مکانیزم تطبیق الگوی جامع (Exhaustive Pattern Matching)، تأثیر آن بر نگهداری‌پذیری و صحت دامنه، جنبه‌های کارایی و سنجش عملکردی با BenchmarkDotNet، و خطاهای رایج در پیاده‌سازی می‌پردازد.

    ۱. مقدمه
    در مهندسی نرم‌افزار شیءگرا، چندریختی سنتی به کلاینت‌ها اجازه می‌دهد رفتارهای مختلف را بر پایه یک قرارداد مشترک فراخوانی کنند، بدون اینکه نیازی به شناخت کلاس‌های ملموس (Concrete Classes) داشته باشند. بر اساس اصل «باز/بسته» (Open/Closed Principle)، یک ماژول باید برای توسعه باز و برای اصلاح بسته باشد. در نتیجه، کامپایلر سی‌شارپ اساساً فرض می‌کند که همواره ممکن است در اسمبلی‌ها، پکیج‌ها یا سورس‌کدهای دیگر، کلاس‌های جدیدی پیاده‌سازی یا مشتق شوند.
    اگرچه این ویژگی برای سناریوهایی نظیر معماری‌های مبتنی بر پلاگین یا تزریق وابستگی (DI) ایده‌آل است، اما هنگام مدل‌سازی دامنه‌های متناهی و حالات بسته (مانند نتیجه اعتبارسنجی، وضعیت پرداخت، یا خروجی پارسر) به یک معضل بدل می‌شود. در چنین شرایطی، سیستم به قابلیت «بسته بودن نسبت به حالات جدید» احتیاج دارد تا بتواند از افزودن حالت ناخواسته جلوگیری کرده و بازخورد خطاها را از زمان اجرا (Runtime) به زمان کامپایل (Compile-Time) منتقل سازد.

    ۲. بررسی سلسله‌مراتب سنتی و محدودیت‌های کامپایلر
    برای درک ریشه مسئله، سناریوی مدل‌سازی نتیجه تراکنش مالی را در سی‌شارپ کلاسیک در نظر بگیرید:
    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) مواجه خواهند شد.

    ۳. سلسله‌مراتب بسته و انواع اجتماع (Union Types)
    سلسله‌مراتب بسته، تعریفی است که در آن تمام حالات مجاز از پیش مشخص و محدود شده‌اند. با ارائه سینتکس انواع اجتماع (Union Types)، رابطه بین حالات به صراحت در تعریف نوع گنجانده می‌شود:
    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)نیازمند شاخه پیش‌فرض (_)پشتیبانی بومی زمان کامپایل
    تطابق با اینترفیس‌هابسیار بالامناسب برای مقادیر داده‌ای خالص
    پشتیبانی از ارائه‌دهندگان ثالثایده‌آل برای افزونه‌ها و بسته‌های خارجینامناسب برای معماری‌های پلاگین‌محور
    آگاهی کامپایلر از کل حالاتعموماً خیربله
    بهترین کاربردسیستم‌های سرویس‌محور و انتزاع رفتارماشین‌های وضعیت متناهی و مدل‌سازی دامنه
    نکته کلیدی معماری: پرسش اصلی این نیست که کدام روش مدرن‌تر یا سریع‌تر است؛ بلکه پرسش بنیادی مهندسی این است: آیا دامنه این نوع داده باید ذاتاً باز باشد یا بسته؟

    ۵. کاربرد در مدل‌سازی دامنه (Domain-Driven Design)
    در مدل‌سازی منطق کسب‌وکار، به‌ویژه در الگوهای DDD، نمایش حالات صریح سیستم مانع از نقض قواعد اعتبارسنجی می‌شود.

    مثال: چرخه حیات سفارش (Order Lifecycle)
    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  => "مبلغ به مشتری عودت داده شد"
        };
    }
    هرگونه تغییر در نیازمندی‌های بیزینس (مثلاً حذف یا اضافه شدن مرحله‌ای جدید) مستقیماً زنجیره تغییرات را به کمک کامپایلر در کل سورس‌کد هدایت می‌کند؛ در نتیجه ریسک نقص بیزینس در زمان اجرا به حداقل می‌رسد.

    ۶. بررسی و ارزیابی عملکرد (Performance & Benchmarking)
    این یک تصور اشتباه است که فرض کنیم تطبیق الگوی مبتنی بر انواع اجتماع همواره به دلیل دانش کامپایلر سریع‌تر از ارسال متد مجازی (Virtual Method Dispatch) یا سوییچ سنتی عمل می‌کند. کامپایلر و JIT ممکن است در پس‌زمینه از جدول پرش (Jump Table)، بررسی نوع شیء (isinst) یا برچسب‌گذاری درونی (Tagged Representation) استفاده کنند. در نتیجه، تصمیم‌گیری پیرامون کارایی باید صرفاً بر پایه سنجه‌های معتبر (Profiling / Benchmarking) استوار باشد.

    ساختار تست بنچمارک با BenchmarkDotNet
    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). از استناد به نتایج تئوریک بدون سنجش بر روی سناریوهای بار کاری واقعی اجتناب ورزید.

    ۷. همپوشانی و تمایز: چه زمان از کدام الگو استفاده کنیم؟

    الف) زمان استفاده از چندریختی باز (Polymorphism):
    هنگامی که رفتارها متغیر بوده و سیستم نیازمند پیاده‌سازی توسط تیم‌ها یا پکیج‌های ثالث است:
    public interface IPaymentGateway
    {
        Task<ExecutionResult> ProcessAsync(PaymentDetails details, CancellationToken ct);
    }
    // ارائه‌دهندگان مختلف می‌توانند آزادانه به پروژه اضافه شوند:
    // StripeGateway, ZarinPalGateway, PayPalGateway, ...

    ب) زمان استفاده از انواع اجتماع (Closed Unions):
    هنگامی که تمام گزینه‌ها تحت مالکیت کامل برنامه یا زیرسیستم جاری است:
    • نتایج توابع (مانند ساختارهای Result و اعتبارسنجی‌ها)
    • پیام‌های پروتکل و بسته‌های ارتباطی داخلی
    • دستورات متناهی در معماری‌های CQRS / Event-Sourcing
    • ماشین‌های وضعیت (State Machines)

    ۸. خطاهای رایج و ضدالگوها (Anti-Patterns)
    • استفاده از Union برای انتزاعات توسعه‌پذیر: تعریف اجتماع روی سرویس‌هایی که باید با افزونه‌ها گسترش یابند، کوپلینگ شدید و شکنندگی ایجاد می‌کند.
    • افزودن کورکورانه شاخه پیش‌فرض (_): قرار دادن _ => ... در تطبیق الگوهای بسته، اصلی‌ترین مزیت این ویژگی (ایمنی کامپایلر در برابر تغییرات مدل) را بی‌اثر می‌کند.
    • اشتباه گرفتن جامعیت نوع با درستی منطق بیزینس: کامپایلر اطمینان می‌دهد که تمام حالات بررسی شده‌اند؛ اما نمی‌تواند صحت محاسبات را ارزیابی کند. برای مثال:
    // کامپایلر این کد را تایید می‌کند، اما منطق بیزینس معکوس و نادرست است!
    return result switch
    {
        PaymentSucceeded => "خطا در تراکنش",
        PaymentFailed    => "تراکنش موفق"
    };
    • بنابراین تست‌های یکپارچگی و Unit Test همچنان غیرقابل جایگزین هستند.

    ۹. نتیجه‌گیری
    معرفی سلسله‌مراتب بسته و انواع اجتماع در زبان‌های مدرن و نسخه‌های پیشرفته سی‌شارپ، تعادلی هوشمندانه میان ساختارهای تابعی و شیءگرایی برقرار می‌کند. بزرگ‌ترین دستاورد این رویکرد نه صرفاً ارتقای کارایی، بلکه بهبود چشمگیر قابلیت نگهداری (Maintainability) و ایمنی نوع (Type Safety) در پروژه‌های سازمانی دات‌نت است.
    قاعده نهایی سرراست است:
    • برای دامنه‌های باز و توسعه‌پذیر، از چندریختی باز و رابط‌ها بهره بگیرید.
    • برای دامنه‌های بسته و حالت‌های متناهی، از ساختارهای بسته و تطبیق الگوی جامع استفاده کنید.
  • وحید نصیری در ۱۴۰۵/۰۶/۲۹ ۰۸:۲۶
    بخش تکمیلی: مدیریت نسخه‌گذاری (Versioning)، چالش‌های Public API و افق‌های پیش‌رو در C# 15
    تاکنون بیشتر بر مزایای جذاب تطبیق الگو و بسته‌شدن دامنه ارث‌بری تمرکز شد. اما به‌کارگیری این قابلیت در دنیای واقعی—به‌ویژه در کتابخانه‌های عمومی (Public Libraries)، بسته‌های NuGet و لایه‌های سریال‌سازی داده‌ها—ابعاد و چالش‌هایی پدید می‌آورد که آگاهی از آن‌ها برای معماران نرم‌افزار و نویسندگان فریم‌ورک‌ها حیاتی است.

    ۱. تداخل سطح دسترسی (Accessibility) و خطای گمراه‌کننده سوییچ
    یکی از سناریوهایی که بی‌شک به یکی از پرسش‌های متداول توسعه‌دهندگان تبدیل خواهد شد، ترکیب کلاس‌های بسته با سطوح دسترسی مختلف (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 را به پروژه‌های خارجی نمی‌دهد!

    توسعه‌دهندگان بیرونی ممکن است گمان کنند با یک باگ مواجه شده‌اند، اما این رفتار منطقی است: کامپایلر نمی‌تواند فرضیات پوشش کامل را روی انواعی استوار کند که از دید کدِ فراخواننده مخفی هستند.

    ۲. چالش نسخه‌گذاری معنایی (SemVer) و ریسک شکست در CI/CD
    در کتابخانه‌های عمومی، اضافه کردن عضو جدید به یک کلاس بسته می‌تواند یک تغییر شکننده پنهان (Subtle Breaking Change) ایجاد کند:
    • فرض کنید یک پکیج مالی به نام Billing منتشر کرده‌اید که نوع PaymentMethod را با سه فرزند مستقیم Cash، Card و BankTransfer اکسپوز کرده است.
    • مصرف‌کنندگان در پروژه‌های خود سوییچ‌های جامع (بدون default) نوشته‌اند و کد به‌خوبی کار می‌کند.
    • در نسخه بعد، شما تصمیم می‌گیرید نوع جدیدی مانند Crypto اضافه کنید و نسخه پکیج را به‌صورت جزئی (Minor Bump، مثلاً ۱.۱ به ۱.۲) ارتقا دهید؛ چراکه از نظر شما قابلیت جدیدی افزوده شده و کدهای قبلی تغییر نکرده‌اند.
    • فاجعه: خط لوله CI/CD پروژه‌های مشتریانی که وابستگی‌ها را به‌صورت خودکار به‌روزرسانی می‌کنند، در اولین بیلد با خطای کامپایل مواجه می‌شود؛ چون سوییچ‌های جامع آن‌ها دیگر پوشش‌دهنده عضو چهارم نیستند!

    قانون طلایی برای توسعه‌دهندگان پکیج: افزودن فرزند مستقیم جدید به یک سلسله‌مراتب بسته عمومی، رفتاری مشابه افزودن مقادیر جدید به enum دارد. این کار باید یک Breaking Change تلقی شده و همراه با ارتقای نسخه اصلی (Major Version Bump) منتشر شود.

    ۳. ترفند طراحی: باز گذاشتن شاخه‌های در حال رشد (Unsealed Extension Points)
    برای اینکه اثر شکنندگی نسخه‌ها در کتابخانه‌های عمومی کاهش یابد، یک راهکار هوشمندانه معماری وجود دارد: باز گذاشتن آگاهانه کلاس‌های برگ (Leaves) که احتمال توسعه دارند.
    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 => ... تطبیق داده می‌شود و کدهای بیرونی دچار شکست در کامپایل نمی‌شوند.

    ۴. تمایز کلیدی فلسفی: Closed Hierarchies در برابر Union Types
    در C# 15، دو قابلیت متفاوت در کنار هم معرفی شده‌اند که گاهی اشتباهاً با یکدیگر یکسان فرض می‌شوند:
    • کلاس‌های بسته (Closed Hierarchies): بر اساس مالکیت و ارث‌بری مشترک کار می‌کنند. همه گزینه‌ها توافق کرده‌اند که از یک کلاس ریشه مشترک ارث ببرند (Dog : Animal). این ابزار زمانی ایده‌آل است که شما مالک کل ساختار دامنه هستید.
    • اتحادیه‌ها (Union Types): بر اساس بسته‌بندی انواع مستقل و بی‌ارتباط کار می‌کنند. برای مثال در public union Pet(Cat, Dog);، کلاس‌های Cat و Dog هیچ ریشه مشترکی ندارند و اصلاً نمی‌دانند که عضو یک اتحادیه هستند. این مفهوم مناسب زمانی است که انواع از کتابخانه‌های متفرقه وارد شده‌اند یا نباید ساختار شیءگرایی یکسانی به آن‌ها تحمیل شود.

    ۵. جایگاه در مقایسه با سایر زبان‌ها
    سیستم پیاده‌سازی سی‌شارپ تفاوت‌های ملموسی با زبان‌های دیگر دارد:
    • جاوا (Java 21): از کلمه کلیدی sealed با بند صریح permits استفاده می‌کند. کلاس‌های Sealed در جاوا لزوماً abstract نیستند، اما در #C کلمه کلیدی جدید closed به کار گرفته شده و نوع پایه ذاتاً abstract فرض می‌شود و لیست فرزندان از محدوده اسمبلی استنتاج می‌شود.
    • کاتلین و اسکالا: مفهوم sealed class و sealed trait به عنوان ساختار اصلی زبان عمل می‌کند، در حالی که در #C رفتار ارث‌بری متداول مبتنی بر Reference Type حفظ شده و بنابراین هر نمونه همچنان روی Heap تخصیص حافظه (Allocation) دارد و از رفتار Value Types (مانند Sum Types در F# یا Rust) متمایز است.

    ۶. چشم‌انداز آینده و گام‌های در حال تکمیل
    قابلیت Closed Hierarchies هنوز در حال تکامل است و نکات باز زیر در طراحی آن در جریان است:
    • پشتیبانی در سریالایزرها (مانند System.Text.Json): مسئله مهم، نگاشت خودکار این سلسله‌مراتب در داده‌های چندریختی JSON است (طبق Issue شماره dotnet/runtime #129041) تا نیازی به تعریف دستی فیلدهای Discriminator نباشد.
    • پشتیبانی از Interfaceها: در پیش‌نمایش فعلی، این ویژگی تنها مختص کلاس‌ها و رکورد-کلاس‌ها است و اعمال آن روی Interfaceها همچنان در کارگروه طراحی زبان (LDM) در حال بررسی است.
    • تغییر نام‌های موقت: نام اتریبیوت کامپایلر در پروپوزال رسمی ممکن است در نسخه نهایی بین ClosedAttribute یا IsClosedTypeAttribute تغییر کند.

    نتیجه‌گیری
    کلاس‌های بسته، ابزاری فوق‌العاده برای مدل‌سازی رفتارهای درون‌برنامه‌ای (ماشین‌های وضعیت، نودهای AST مفسرها، و الگوهای Command/Event) هستند. با این حال، در مرزهای Public API و پکیج‌های عمومی، قدرت بالای این قابلیت مستلزم انضباط سخت‌گیرانه در نسخه‌گذاری و پیش‌بینی دقیق نقاط رشد درختی است تا مزیت خطای زمان کامپایل، به غافلگیری در خطوط تولید و انتشار تبدیل نشود.