عنوان:

‫سلسله‌مراتب کلاس‌های بسته (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) صرفاً یک ویژگی تزیینی برای زیبایی کد نیست؛ بلکه یک ابزار قدرتمند معماری است که با پر کردن شکاف بین برنامه‌نویسی شیءگرا و تابع‌محور، امنیت کد را در سطح سیستم‌های بزرگ تضمین کرده و خطاهای احتمالی آینده در توسعه نرم‌افزار را در زمان کامپایل غیرممکن می‌کند.