زبان‌های برنامه نویسی

اصول SOLID در برنامه‌نویسی چیست؟ آموزش کامل با مثال‌های کاربردی

1405/08/07 مقاله توت24

یکی از تفاوت‌های اصلی بین یک برنامه‌نویس مبتدی و یک برنامه‌نویس باتجربه، فقط توانایی نوشتن کد نیست؛ بلکه توانایی طراحی کدی است که در آینده بتوان آن را به‌راحتی تغییر داد، توسعه داد و نگهداری کرد.

ممکن است برنامه‌ای در ابتدا با چند کلاس ساده و تعداد کمی دستور به‌خوبی کار کند؛ اما با اضافه شدن قابلیت‌های جدید، تعداد شرط‌ها افزایش پیدا کند، کلاس‌ها بیش از حد بزرگ شوند و تغییر یک بخش باعث ایجاد مشکل در بخش‌های دیگر شود. در چنین شرایطی، توسعه نرم‌افزار به‌تدریج دشوار و پرهزینه خواهد شد.

برای جلوگیری از بسیاری از این مشکلات، اصول مختلفی در طراحی نرم‌افزار معرفی شده‌اند. یکی از مجموعه اصول شناخته‌شده در برنامه‌نویسی شیءگرا، SOLID نام دارد. این اصول به ما کمک می‌کنند مسئولیت‌های کلاس‌ها را بهتر تقسیم کنیم، وابستگی‌های غیرضروری را کاهش دهیم و نرم‌افزاری طراحی کنیم که توسعه و آزمایش آن ساده‌تر باشد.

در این مقاله با هر پنج اصل SOLID آشنا می‌شویم و با استفاده از مثال‌های C# بررسی می‌کنیم که چگونه می‌توان آن‌ها را در پروژه‌های واقعی به کار برد.

اصول SOLID چیست؟

SOLID نامی است که از حروف اول پنج اصل مهم در طراحی شیءگرا تشکیل شده است. این اصول مجموعه‌ای از راهنماها برای طراحی کلاس‌ها، رابط‌ها و وابستگی‌های نرم‌افزار هستند.

هر حرف در کلمه SOLID به یکی از اصول زیر اشاره می‌کند:

حرف نام انگلیسی نام فارسی هدف اصلی
S Single Responsibility Principle اصل مسئولیت واحد هر کلاس مسئولیتی مشخص و منسجم داشته باشد.
O Open/Closed Principle اصل باز و بسته بودن امکان توسعه رفتار نرم‌افزار بدون تغییر مداوم کدهای موجود فراهم شود.
L Liskov Substitution Principle اصل جایگزینی لیسکوف پیاده‌سازی‌های فرزند بتوانند بدون برهم زدن انتظارهای برنامه جایگزین نوع پایه شوند.
I Interface Segregation Principle اصل تفکیک رابط‌ها کلاس‌ها مجبور نباشند به متدهایی وابسته باشند که به آن‌ها نیاز ندارند.
D Dependency Inversion Principle اصل وارونگی وابستگی وابستگی بخش‌های مهم برنامه به انتزاع‌ها باشد، نه جزئیات پیاده‌سازی.

این اصول به یک زبان برنامه‌نویسی خاص محدود نیستند و می‌توان از آن‌ها در زبان‌هایی مانند C#، Java، Python و بسیاری از زبان‌های دیگر استفاده کرد. البته مثال‌های این مقاله با زبان C# نوشته شده‌اند تا کاربرد آن‌ها در برنامه‌نویسی شیءگرا روشن‌تر شود.

چرا رعایت اصول SOLID اهمیت دارد؟

در یک پروژه کوچک، ممکن است بدون توجه جدی به اصول طراحی نیز بتوان نرم‌افزاری کاربردی ساخت. مشکل معمولاً زمانی ظاهر می‌شود که پروژه بزرگ‌تر می‌شود، قابلیت‌های جدیدی به آن اضافه می‌شوند یا چند برنامه‌نویس به‌صورت هم‌زمان روی آن کار می‌کنند.

برای مثال، فرض کنید کلاسی در یک فروشگاه اینترنتی هم اطلاعات سفارش را مدیریت می‌کند، هم مبلغ نهایی را محاسبه می‌کند، هم اطلاعات را در پایگاه داده ذخیره می‌کند و هم ایمیل تأیید سفارش را می‌فرستد. اگر روش ارسال ایمیل یا ساختار پایگاه داده تغییر کند، ممکن است لازم باشد همان کلاس را تغییر دهیم؛ حتی اگر منطق اصلی سفارش هیچ تغییری نکرده باشد.

اصول SOLID به ما کمک می‌کنند چنین وابستگی‌هایی را شناسایی و مدیریت کنیم.

مهم‌ترین مزایای استفاده درست از این اصول عبارت‌اند از:

  • نگهداری آسان‌تر: هر بخش از نرم‌افزار مسئولیت روشن‌تری دارد و پیدا کردن محل مناسب برای تغییر ساده‌تر می‌شود.
  • توسعه‌پذیری بیشتر: می‌توان قابلیت‌های جدید را با تغییرات محدودتری به سیستم اضافه کرد.
  • کاهش وابستگی‌ها: تغییر یک پیاده‌سازی لزوماً به تغییر تمام بخش‌های استفاده‌کننده از آن منجر نمی‌شود.
  • آزمایش‌پذیری بهتر: می‌توان وابستگی‌های یک کلاس را با نمونه‌های آزمایشی جایگزین کرد و رفتار آن را مستقل‌تر آزمود.
  • خوانایی بیشتر: تقسیم درست مسئولیت‌ها درک ساختار برنامه را برای اعضای تیم آسان‌تر می‌کند.
  • کاهش احتمال خطاهای جانبی: اگر تغییرات در بخش‌های مشخصی محدود شوند، احتمال خراب شدن قابلیت‌های نامرتبط کاهش می‌یابد.

البته رعایت SOLID به معنی حذف کامل خطاها یا تضمین کیفیت نرم‌افزار نیست. این اصول ابزارهایی برای تصمیم‌گیری بهتر در طراحی هستند و باید متناسب با اندازه، نیازها و پیچیدگی پروژه به کار گرفته شوند.

۱. اصل مسئولیت واحد (Single Responsibility Principle)

اولین اصل SOLID با نام Single Responsibility Principle یا به اختصار SRP شناخته می‌شود.

مفهوم اصلی این اصل آن است که هر کلاس باید مسئولیتی مشخص و منسجم داشته باشد و تغییرات مرتبط با مسئولیت‌های نامرتبط در یک کلاس جمع نشوند.

این اصل را گاهی به شکل ساده‌تر این‌گونه بیان می‌کنند: هر کلاس باید فقط یک دلیل برای تغییر داشته باشد.

منظور از مسئولیت واحد این نیست که یک کلاس حتماً فقط یک متد داشته باشد یا تعداد خطوط کد آن بسیار کم باشد. مهم این است که متدها و رفتارهای کلاس در راستای یک مسئولیت مشخص قرار داشته باشند.

مثال نقض اصل مسئولیت واحد

فرض کنید در یک سیستم فروشگاهی، کلاسی برای مدیریت سفارش‌ها نوشته‌ایم. این کلاس هم اطلاعات سفارش را مدیریت می‌کند، هم آن را در پایگاه داده ذخیره می‌کند و هم رسید سفارش را چاپ می‌کند.

public class Order
{
    public int OrderId { get; set; }

    public decimal TotalAmount { get; set; }

    public void SaveToDatabase()
    {
        // ذخیره سفارش در پایگاه داده
    }

    public void PrintInvoice()
    {
        // چاپ فاکتور سفارش
    }
}

این کد از نظر نحوی می‌تواند معتبر باشد؛ اما کلاس Order چند مسئولیت متفاوت دارد:

  • نگهداری اطلاعات سفارش
  • ذخیره اطلاعات در پایگاه داده
  • چاپ فاکتور

اکنون فرض کنید روش ذخیره‌سازی اطلاعات تغییر کند یا قالب چاپ فاکتور عوض شود. در هر دو حالت، کلاس Order باید تغییر کند؛ در حالی که این تغییرات الزاماً ارتباطی با اطلاعات اصلی سفارش ندارند.

راه‌حل: تقسیم مسئولیت‌ها

برای نزدیک شدن به اصل مسئولیت واحد، می‌توان اطلاعات سفارش را از مسئولیت ذخیره‌سازی و چاپ فاکتور جدا کرد.

public class Order
{
    public int OrderId { get; set; }

    public decimal TotalAmount { get; set; }
}

public class OrderRepository
{
    public void Save(Order order)
    {
        // ذخیره سفارش در پایگاه داده
    }
}

public class InvoicePrinter
{
    public void Print(Order order)
    {
        // چاپ فاکتور سفارش
    }
}

در این طراحی:

  • کلاس Order اطلاعات اصلی سفارش را نگهداری می‌کند.
  • کلاس OrderRepository مسئولیت ذخیره‌سازی سفارش را بر عهده دارد.
  • کلاس InvoicePrinter مسئولیت چاپ فاکتور را انجام می‌دهد.

اکنون اگر روش چاپ فاکتور تغییر کند، معمولاً نیازی به تغییر کلاس Order نخواهیم داشت. به همین ترتیب، تغییر روش ذخیره‌سازی نیز می‌تواند در کلاس مربوط به ذخیره‌سازی محدود بماند.

چگونه تشخیص دهیم یک کلاس چند مسئولیت دارد؟

برای بررسی یک کلاس، می‌توانید این پرسش‌ها را مطرح کنید:

  • آیا متدهای این کلاس همگی در راستای یک هدف مشخص هستند؟
  • آیا تغییر قوانین کسب‌وکار ممکن است باعث تغییر کلاس شود؟
  • آیا تغییر پایگاه داده یا نحوه نمایش اطلاعات نیز همین کلاس را تحت تأثیر قرار می‌دهد؟
  • آیا می‌توان برای توضیح مسئولیت کلاس، چند وظیفه کاملاً متفاوت نام برد؟

اگر یک کلاس به دلایل نامرتبط زیادی تغییر می‌کند، احتمال دارد لازم باشد مسئولیت‌های آن را دوباره بررسی کنید.

نکته مهم: اصل مسئولیت واحد به معنی ساختن یک کلاس جداگانه برای هر متد نیست. تقسیم بیش از حد کد به کلاس‌های کوچک و فاقد انسجام نیز می‌تواند درک برنامه را دشوار کند. هدف، جداسازی مسئولیت‌های واقعاً متفاوت است.

۲. اصل باز و بسته بودن (Open/Closed Principle)

دومین اصل SOLID با نام Open/Closed Principle یا به اختصار OCP شناخته می‌شود.

بر اساس این اصل، بخش‌های نرم‌افزار باید تا حد امکان برای توسعه رفتار باز باشند، اما برای تغییرات مکرر در کدهای موجود بسته بمانند.

این عبارت در نگاه اول ممکن است متناقض به نظر برسد. چگونه می‌توان یک کلاس را توسعه داد، اما کدهای آن را تغییر نداد؟

ایده اصلی این است که از ابتدا ساختاری طراحی کنیم که بتوان با افزودن پیاده‌سازی‌های جدید یا استفاده از انتزاع‌ها، رفتارهای جدیدی به سیستم اضافه کرد؛ بدون اینکه هر بار لازم باشد منطق اصلی و آزموده‌شده برنامه را تغییر دهیم.

مثال نقض اصل باز و بسته بودن

فرض کنید در یک فروشگاه، برای محاسبه تخفیف مشتریان از کلاس زیر استفاده می‌کنیم:

public class DiscountCalculator
{
    public decimal Calculate(string customerType, decimal amount)
    {
        if (customerType == "Regular")
        {
            return amount * 0.05m;
        }

        if (customerType == "Vip")
        {
            return amount * 0.15m;
        }

        return 0;
    }
}

در این مثال، برای مشتری عادی ۵ درصد و برای مشتری ویژه ۱۵ درصد تخفیف در نظر گرفته شده است.

اکنون فرض کنید نوع جدیدی از مشتری با تخفیف متفاوت به سیستم اضافه شود. باید متد Calculate را تغییر دهیم و یک شرط جدید به آن اضافه کنیم.

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

راه‌حل: استفاده از انتزاع و چندریختی

می‌توانیم برای محاسبه تخفیف یک رابط تعریف کنیم و هر نوع تخفیف را در کلاس جداگانه‌ای پیاده‌سازی کنیم.

public interface IDiscountPolicy
{
    decimal Calculate(decimal amount);
}

public class RegularDiscount : IDiscountPolicy
{
    public decimal Calculate(decimal amount)
    {
        return amount * 0.05m;
    }
}

public class VipDiscount : IDiscountPolicy
{
    public decimal Calculate(decimal amount)
    {
        return amount * 0.15m;
    }
}

public class DiscountCalculator
{
    public decimal Calculate(
        IDiscountPolicy discountPolicy,
        decimal amount)
    {
        return discountPolicy.Calculate(amount);
    }
}

اکنون کلاس DiscountCalculator به یک نوع مشخص از تخفیف وابسته نیست؛ بلکه با هر کلاسی کار می‌کند که رابط IDiscountPolicy را پیاده‌سازی کرده باشد.

برای افزودن تخفیف جدید، می‌توانیم کلاس دیگری بسازیم:

public class SeasonalDiscount : IDiscountPolicy
{
    public decimal Calculate(decimal amount)
    {
        return amount * 0.20m;
    }
}

برای اضافه شدن این تخفیف، نیازی نیست متد اصلی DiscountCalculator.Calculate را تغییر دهیم؛ زیرا کلاس جدید از همان قرارداد موجود پیروی می‌کند.

آیا اصل باز و بسته بودن به معنی ممنوعیت هرگونه تغییر است؟

خیر. این اصل نمی‌گوید که کدهای موجود را هرگز نباید تغییر داد. در عمل، اصلاح خطاها، تغییر نیازمندی‌ها و بهبود طراحی گاهی به تغییر کدهای قبلی نیاز دارند.

هدف این است که برای اضافه کردن هر قابلیت جدید، مجبور نباشیم بخش‌های مرکزی و نامرتبط برنامه را بارها تغییر دهیم. بهتر است این اصل را در قسمت‌هایی از سیستم به کار ببریم که احتمال تغییر و توسعه آن‌ها بیشتر است.

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

۳. اصل جایگزینی لیسکوف (Liskov Substitution Principle)

سومین اصل SOLID با نام Liskov Substitution Principle یا به اختصار LSP شناخته می‌شود.

این اصل بیان می‌کند که اگر یک کلاس فرزند از یک نوع پایه مشتق شده باشد، باید بتوان از نمونه آن در جاهایی استفاده کرد که نوع پایه انتظار می‌رود، بدون اینکه صحت رفتار برنامه به دلیل این جایگزینی از بین برود.

به زبان ساده، اگر کلاسی را به‌عنوان فرزند یک کلاس پایه یا پیاده‌سازی یک رابط معرفی می‌کنیم، باید قراردادها و انتظارهای تعریف‌شده برای آن نوع را رعایت کند.

صرف اینکه یک کلاس از کلاس دیگری ارث‌بری کند، به این معنی نیست که از نظر طراحی نیز جایگزین مناسبی برای آن است.

مثال نقض اصل جایگزینی لیسکوف

فرض کنید یک کلاس پایه به نام Bird داریم که متدی برای پرواز تعریف می‌کند:

public class Bird
{
    public virtual void Fly()
    {
        Console.WriteLine("Bird is flying");
    }
}

سپس کلاس Sparrow را از آن مشتق می‌کنیم:

public class Sparrow : Bird
{
    public override void Fly()
    {
        Console.WriteLine("Sparrow is flying");
    }
}

این طراحی برای پرنده‌ای مانند گنجشک قابل‌قبول است. اما اگر بخواهیم پنگوئن را نیز از کلاس Bird مشتق کنیم، با مشکل روبه‌رو می‌شویم؛ زیرا پنگوئن پرنده است، اما نمی‌تواند پرواز کند.

public class Penguin : Bird
{
    public override void Fly()
    {
        throw new NotSupportedException(
            "Penguins cannot fly.");
    }
}

اکنون متدی را در نظر بگیرید که انتظار دارد هر شیء از نوع Bird بتواند پرواز کند:

public void MakeBirdFly(Bird bird)
{
    bird.Fly();
}

اگر نمونه‌ای از Penguin را به این متد بدهیم، اجرای برنامه با استثنا روبه‌رو می‌شود. در نتیجه، کلاس فرزند نمی‌تواند بدون نقض انتظارهای متد، جایگزین کلاس پایه شود.

مشکل اصلی این نیست که پنگوئن پرنده نیست؛ مشکل این است که کلاس پایه، قابلیت پرواز را برای همه پرندگان الزامی کرده است، در حالی که این قابلیت برای تمام اعضای آن گروه معتبر نیست.

راه‌حل: تعریف انتزاع متناسب با رفتار واقعی

بهتر است مفهوم پرنده را از قابلیت پرواز جدا کنیم. می‌توانیم رابطی برای پرواز تعریف کنیم و فقط کلاس‌هایی که واقعاً قادر به پرواز هستند آن را پیاده‌سازی کنند.

public abstract class Bird
{
    public abstract void Eat();
}

public interface IFlyable
{
    void Fly();
}

public class Sparrow : Bird, IFlyable
{
    public override void Eat()
    {
        Console.WriteLine("Sparrow is eating");
    }

    public void Fly()
    {
        Console.WriteLine("Sparrow is flying");
    }
}

public class Penguin : Bird
{
    public override void Eat()
    {
        Console.WriteLine("Penguin is eating");
    }
}

در این طراحی، هر دو کلاس گنجشک و پنگوئن می‌توانند به‌عنوان پرنده استفاده شوند؛ اما فقط گنجشک قابلیت IFlyable را دارد.

اکنون می‌توانیم متدی تعریف کنیم که فقط اشیای دارای قابلیت پرواز را بپذیرد:

public void MakeItFly(IFlyable bird)
{
    bird.Fly();
}

با این تغییر، انتظار متد دقیق‌تر شده است و دیگر از تمام پرندگان انتظار پرواز نداریم.

اصل جایگزینی لیسکوف فقط درباره ارث‌بری نیست

اگرچه این اصل معمولاً هنگام بررسی کلاس‌های پایه و فرزند مطرح می‌شود، در طراحی رابط‌ها نیز اهمیت دارد.

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

برای رعایت این اصل، به نکات زیر توجه کنید:

  • کلاس فرزند نباید شرایط استفاده از متدهای به‌ارث‌رسیده را به شکل ناسازگار محدود کند.
  • رفتار متدهای بازنویسی‌شده باید با قرارداد نوع پایه سازگار باشد.
  • کلاس فرزند نباید نتیجه‌ای تولید کند که انتظارات منطقی کد استفاده‌کننده را نقض کند.
  • اگر دو کلاس رفتارهای متفاوتی دارند، آن‌ها را صرفاً به دلیل شباهت ظاهری در یک سلسله‌مراتب ارث‌بری قرار ندهید.

۴. اصل تفکیک رابط‌ها (Interface Segregation Principle)

چهارمین اصل SOLID با نام Interface Segregation Principle یا به اختصار ISP شناخته می‌شود.

این اصل می‌گوید هیچ کلاسی نباید مجبور باشد به متدهایی وابسته شود که به آن‌ها نیاز ندارد. به همین دلیل، معمولاً بهتر است به‌جای یک رابط بزرگ و عمومی، چند رابط کوچک و متناسب با نیازهای واقعی طراحی کنیم.

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

مثال نقض اصل تفکیک رابط‌ها

فرض کنید در یک سیستم مدیریت اسناد، رابط زیر را تعریف کرده‌ایم:

public interface IDocumentService
{
    void Read();
    void Write();
    void Print();
}

این رابط سه قابلیت خواندن، نوشتن و چاپ سند را تعریف می‌کند. اکنون فرض کنید یک سرویس فقط وظیفه خواندن اسناد را دارد و امکان نوشتن یا چاپ آن‌ها را ندارد.

اگر این سرویس مجبور باشد رابط IDocumentService را پیاده‌سازی کند، باید برای متدهایی که پشتیبانی نمی‌کند نیز کد بنویسد یا از استثنا استفاده کند.

public class ReadOnlyDocumentService : IDocumentService
{
    public void Read()
    {
        Console.WriteLine("Reading document");
    }

    public void Write()
    {
        throw new NotSupportedException();
    }

    public void Print()
    {
        throw new NotSupportedException();
    }
}

این طراحی نشان می‌دهد که رابط بیش از حد گسترده است و قرارداد آن با نیازهای تمام پیاده‌سازی‌ها سازگاری ندارد.

راه‌حل: تقسیم رابط بزرگ به رابط‌های کوچک‌تر

می‌توانیم هر قابلیت را در رابط جداگانه‌ای قرار دهیم:

public interface IDocumentReader
{
    void Read();
}

public interface IDocumentWriter
{
    void Write();
}

public interface IDocumentPrinter
{
    void Print();
}

اکنون سرویس فقط‌خواندنی می‌تواند رابط مناسب خود را پیاده‌سازی کند:

public class ReadOnlyDocumentService : IDocumentReader
{
    public void Read()
    {
        Console.WriteLine("Reading document");
    }
}

در مقابل، سرویسی که قابلیت خواندن و نوشتن دارد می‌تواند هر دو رابط مربوط را پیاده‌سازی کند:

public class EditableDocumentService :
    IDocumentReader,
    IDocumentWriter
{
    public void Read()
    {
        Console.WriteLine("Reading document");
    }

    public void Write()
    {
        Console.WriteLine("Writing document");
    }
}

به این ترتیب، هر کلاس فقط قراردادهایی را می‌پذیرد که واقعاً به آن‌ها نیاز دارد.

مزایای اصل تفکیک رابط‌ها

  • کلاس‌ها متدهای غیرضروری را پیاده‌سازی نمی‌کنند.
  • تغییر یک قابلیت لزوماً تمام پیاده‌سازی‌های رابط را تحت تأثیر قرار نمی‌دهد.
  • قراردادهای هر بخش روشن‌تر و قابل‌فهم‌تر می‌شوند.
  • آزمایش کلاس‌ها ساده‌تر می‌شود؛ زیرا هر کلاس به قراردادهای کوچک‌تری وابسته است.
  • امکان ترکیب قابلیت‌های مختلف در کلاس‌های گوناگون فراهم می‌شود.

نکته: کوچک بودن رابط به‌تنهایی هدف نهایی نیست. اگر چند متد از نظر مفهومی به یک مسئولیت مشخص تعلق دارند و معمولاً با هم استفاده می‌شوند، قرار دادن آن‌ها در یک رابط می‌تواند تصمیم مناسبی باشد. مهم، انسجام رابط و تناسب آن با نیاز استفاده‌کنندگان است.

۵. اصل وارونگی وابستگی (Dependency Inversion Principle)

پنجمین اصل SOLID با نام Dependency Inversion Principle یا به اختصار DIP شناخته می‌شود.

این اصل بیان می‌کند که بخش‌های سطح بالای نرم‌افزار نباید مستقیماً به جزئیات سطح پایین وابسته باشند. هر دو بخش باید به انتزاع‌ها وابسته شوند و جزئیات پیاده‌سازی نیز باید از قراردادهای انتزاعی پیروی کنند.

برای درک بهتر، دو مفهوم را در نظر بگیرید:

  • بخش سطح بالا: بخشی از برنامه که منطق اصلی یا قوانین کسب‌وکار را اجرا می‌کند؛ مانند ثبت سفارش یا محاسبه مبلغ خرید.
  • بخش سطح پایین: بخشی که جزئیات اجرایی و زیرساختی را انجام می‌دهد؛ مانند ذخیره اطلاعات در پایگاه داده یا ارسال پیام از طریق یک سرویس خارجی.

اگر منطق اصلی برنامه مستقیماً به یک پیاده‌سازی خاص وابسته باشد، تغییر آن پیاده‌سازی می‌تواند باعث تغییر کدهای سطح بالا نیز شود.

مثال نقض اصل وارونگی وابستگی

فرض کنید در یک فروشگاه اینترنتی، کلاس ثبت سفارش مستقیماً از کلاس ذخیره‌سازی اطلاعات استفاده می‌کند:

public class SqlOrderRepository
{
    public void Save()
    {
        Console.WriteLine("Saving order to SQL database");
    }
}

public class OrderService
{
    private readonly SqlOrderRepository _repository;

    public OrderService()
    {
        _repository = new SqlOrderRepository();
    }

    public void PlaceOrder()
    {
        // منطق ثبت سفارش

        _repository.Save();
    }
}

در این مثال، کلاس OrderService مستقیماً یک نمونه از SqlOrderRepository می‌سازد. در نتیجه، منطق ثبت سفارش به یک پیاده‌سازی مشخص برای ذخیره‌سازی اطلاعات وابسته است.

اگر بعداً بخواهیم اطلاعات را در یک سرویس ابری یا یک سیستم ذخیره‌سازی دیگر ثبت کنیم، ممکن است لازم باشد کلاس OrderService را نیز تغییر دهیم.

راه‌حل: وابستگی به یک رابط

برای کاهش این وابستگی، ابتدا یک رابط برای ذخیره‌سازی سفارش تعریف می‌کنیم:

public interface IOrderRepository
{
    void Save();
}

سپس پیاده‌سازی مربوط به پایگاه داده را بر اساس این رابط می‌سازیم:

public class SqlOrderRepository : IOrderRepository
{
    public void Save()
    {
        Console.WriteLine("Saving order to SQL database");
    }
}

اکنون کلاس ثبت سفارش به رابط وابسته می‌شود، نه به یک کلاس مشخص:

public class OrderService
{
    private readonly IOrderRepository _repository;

    public OrderService(IOrderRepository repository)
    {
        _repository = repository;
    }

    public void PlaceOrder()
    {
        // منطق ثبت سفارش

        _repository.Save();
    }
}

در این طراحی، وابستگی موردنیاز از طریق سازنده کلاس به آن داده می‌شود. این روش تزریق وابستگی (Dependency Injection) نام دارد.

اکنون می‌توانیم یک پیاده‌سازی دیگر برای همان رابط ایجاد کنیم، بدون اینکه لازم باشد منطق اصلی ثبت سفارش را تغییر دهیم:

public class InMemoryOrderRepository : IOrderRepository
{
    public void Save()
    {
        Console.WriteLine("Saving order in memory");
    }
}

برای استفاده از پیاده‌سازی دلخواه، می‌توان نمونه مناسب را هنگام ساخت سرویس به آن داد:

IOrderRepository repository = new SqlOrderRepository();

OrderService orderService = new OrderService(repository);

orderService.PlaceOrder();

در این مثال، کلاس OrderService از جزئیات داخلی ذخیره‌سازی اطلاعی ندارد. این کلاس فقط می‌داند که وابستگی آن باید قرارداد IOrderRepository را ارائه کند.

تفاوت اصل وارونگی وابستگی با تزریق وابستگی چیست؟

این دو مفهوم به یکدیگر مرتبط‌اند، اما یکسان نیستند.

اصل وارونگی وابستگی (DIP) یک اصل طراحی است که جهت وابستگی‌های نرم‌افزار را مشخص می‌کند. این اصل توصیه می‌کند بخش‌های اصلی برنامه به انتزاع‌ها وابسته باشند، نه به جزئیات مشخص پیاده‌سازی.

تزریق وابستگی (DI) روشی برای فراهم کردن وابستگی‌های موردنیاز یک کلاس از بیرون آن کلاس است. این کار می‌تواند از طریق سازنده، متد یا روش‌های دیگر انجام شود.

تزریق وابستگی یکی از روش‌های رایج برای پیاده‌سازی طراحی مبتنی بر وارونگی وابستگی است؛ اما استفاده از DI به‌تنهایی تضمین نمی‌کند که طراحی حتماً با اصل DIP سازگار باشد. مهم است که وابستگی‌ها و انتزاع‌ها نیز به‌درستی طراحی شده باشند.

تفاوت پنج اصل SOLID با یکدیگر چیست؟

هر پنج اصل به بهبود طراحی نرم‌افزار کمک می‌کنند؛ اما هرکدام مسئله متفاوتی را هدف قرار می‌دهند.

اصل مسئله‌ای که هدف می‌گیرد پرسشی برای بررسی طراحی
SRP جمع شدن مسئولیت‌های نامرتبط در یک کلاس آیا این کلاس بیش از یک دلیل مستقل برای تغییر دارد؟
OCP نیاز به تغییر مکرر کدهای موجود برای افزودن رفتارهای جدید آیا می‌توان قابلیت جدیدی با تغییر محدود در کدهای قبلی اضافه کرد؟
LSP جایگزینی نوع فرزند بدون حفظ رفتار مورد انتظار آیا این پیاده‌سازی می‌تواند بدون نقض قرارداد، جایگزین نوع پایه شود؟
ISP رابط‌های بزرگ با متدهای غیرضروری برای برخی استفاده‌کنندگان آیا کلاس مجبور است به متدهایی وابسته باشد که به آن‌ها نیاز ندارد؟
DIP وابستگی مستقیم منطق اصلی برنامه به جزئیات اجرایی آیا بخش اصلی برنامه به کلاس مشخصی وابسته است که می‌توان آن را با انتزاع جایگزین کرد؟

این اصول مستقل از یکدیگر نیستند و معمولاً به تقویت هم کمک می‌کنند. برای مثال، تعریف رابط‌های کوچک و منسجم می‌تواند رعایت اصل تفکیک رابط‌ها را بهبود دهد و هم‌زمان جایگزینی پیاده‌سازی‌ها را مطابق اصل جایگزینی لیسکوف آسان‌تر کند.

آیا باید در تمام پروژه‌ها از اصول SOLID استفاده کنیم؟

اصول SOLID راهنماهای مفیدی هستند، اما نباید آن‌ها را به مجموعه‌ای از قوانین خشک تبدیل کرد که بدون توجه به شرایط پروژه اجرا شوند.

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

برای تصمیم‌گیری بهتر، این نکات را در نظر بگیرید:

  • از نیاز واقعی شروع کنید: پیش از ایجاد کلاس‌ها و رابط‌های متعدد، مشخص کنید چه مسئله‌ای را حل می‌کنید.
  • مسئولیت‌ها را بر اساس معنا جدا کنید: هر کلاس باید هدفی روشن داشته باشد، نه اینکه صرفاً برای کاهش تعداد خطوط یک کلاس ایجاد شود.
  • نقاط احتمالی تغییر را بشناسید: اگر بخشی از برنامه به احتمال زیاد توسعه پیدا می‌کند، طراحی قابل‌گسترش می‌تواند مفید باشد.
  • از انتزاع‌های غیرضروری پرهیز کنید: برای هر کلاس، رابط جداگانه نسازید مگر اینکه چنین جداسازی ارزش واقعی داشته باشد.
  • رفتار را آزمایش کنید: کدهای قابل‌توسعه نیز باید با آزمون‌های مناسب بررسی شوند تا تغییرات جدید، رفتار قبلی را خراب نکنند.
  • پیچیدگی را کنترل کنید: هدف SOLID بهتر کردن طراحی است، نه افزایش تعداد کلاس‌ها یا پیچیده کردن معماری.

اشتباهات رایج در استفاده از SOLID

برنامه‌نویسان گاهی هنگام یادگیری اصول SOLID، آن‌ها را به شکلی افراطی به کار می‌گیرند. برای استفاده مؤثر از این اصول، بهتر است از اشتباهات زیر دوری کنید.

۱. تبدیل هر متد به یک کلاس مستقل

اصل مسئولیت واحد نمی‌گوید هر متد باید در کلاس جداگانه‌ای قرار بگیرد. اگر چند متد بخشی از یک مسئولیت منسجم هستند، قرار گرفتن آن‌ها در یک کلاس می‌تواند کاملاً منطقی باشد.

۲. ایجاد رابط برای هر کلاس بدون نیاز واقعی

رابط‌ها زمانی مفید هستند که قرارداد مشخصی تعریف کنند و امکان جداسازی وابستگی‌ها یا جایگزینی پیاده‌سازی‌ها را فراهم کنند. ایجاد رابط‌های متعدد بدون هدف روشن، ممکن است فقط پیچیدگی پروژه را افزایش دهد.

۳. استفاده از ارث‌بری صرفاً به دلیل شباهت ظاهری

اگر دو کلاس چند ویژگی مشترک دارند، الزاماً رابطه مناسبی برای ارث‌بری میان آن‌ها وجود ندارد. نوع فرزند باید بتواند قرارداد نوع پایه را رعایت کند. در برخی موارد، ترکیب اشیا یا استفاده از رابط‌ها انتخاب مناسب‌تری است.

۴. افزودن انتزاع‌های متعدد برای قابلیت‌هایی که هنوز به آن‌ها نیاز نداریم

اصل باز و بسته بودن به معنی پیش‌بینی تمام تغییرات احتمالی آینده نیست. طراحی باید بر اساس نیازهای شناخته‌شده و احتمال معقول توسعه انجام شود. انتزاع‌های غیرضروری می‌توانند فهم و نگهداری کد را دشوارتر کنند.

۵. اشتباه گرفتن Dependency Injection با Dependency Inversion

تزریق وابستگی یک روش اجرایی است، اما وارونگی وابستگی یک اصل طراحی محسوب می‌شود. برای رعایت DIP باید ساختار وابستگی‌ها به‌درستی طراحی شود؛ صرف دریافت یک شیء از طریق سازنده کافی نیست.

چگونه اصول SOLID را در یک پروژه واقعی تمرین کنیم؟

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

سپس مراحل زیر را انجام دهید:

  1. یک قابلیت ساده مانند ثبت سفارش یا ثبت کتاب را پیاده‌سازی کنید.
  2. کلاس‌هایی را که چند مسئولیت متفاوت دارند شناسایی کنید.
  3. مسئولیت‌های نامرتبط را از هم جدا کنید و اصل SRP را به کار ببرید.
  4. بررسی کنید برای افزودن قابلیت جدید، آیا لازم است بارها کدهای اصلی را تغییر دهید. در صورت نیاز، طراحی را با توجه به OCP اصلاح کنید.
  5. رابط‌ها و کلاس‌های پایه را بررسی کنید تا تمام پیاده‌سازی‌ها بتوانند قراردادهای آن‌ها را رعایت کنند. این کار به رعایت LSP کمک می‌کند.
  6. رابط‌های بزرگی را که کلاس‌ها را به متدهای غیرضروری وابسته می‌کنند، به قراردادهای منسجم‌تر تقسیم کنید.
  7. وابستگی‌های مستقیم به پایگاه داده، سرویس ایمیل یا سایر زیرساخت‌ها را بررسی کنید و در نقاط مناسب از انتزاع‌ها استفاده کنید.
  8. پس از هر تغییر، رفتار برنامه را با آزمون‌های مناسب بررسی کنید.

لازم نیست از همان ابتدا تمام کدها را بر اساس هر پنج اصل بازنویسی کنید. می‌توانید با شناسایی مشکلات واقعی شروع کنید و طراحی را به‌تدریج بهبود دهید.

پرسش‌های متداول درباره اصول SOLID

SOLID مخفف چیست؟

SOLID از حروف اول پنج اصل طراحی شیءگرا تشکیل شده است: مسئولیت واحد، باز و بسته بودن، جایگزینی لیسکوف، تفکیک رابط‌ها و وارونگی وابستگی.

اصول SOLID فقط برای زبان C# هستند؟

خیر. این اصول به طراحی نرم‌افزار شیءگرا مربوط‌اند و می‌توان از آن‌ها در زبان‌های مختلفی مانند C#، Java و Python استفاده کرد. نحوه پیاده‌سازی هر اصل به امکانات و ویژگی‌های زبان برنامه‌نویسی بستگی دارد.

آیا رعایت SOLID باعث سریع‌تر شدن اجرای برنامه می‌شود؟

هدف اصلی SOLID افزایش سرعت اجرای برنامه نیست؛ بلکه این اصول به بهبود ساختار، نگهداری‌پذیری، توسعه‌پذیری و آزمایش‌پذیری نرم‌افزار کمک می‌کنند. تأثیر آن‌ها بر کارایی اجرا به طراحی و پیاده‌سازی نهایی بستگی دارد.

کدام اصل SOLID از همه مهم‌تر است؟

نمی‌توان یک اصل را در تمام شرایط مهم‌تر از بقیه دانست. انتخاب اولویت به مسئله طراحی بستگی دارد. برای مثال، اگر یک کلاس چند مسئولیت نامرتبط دارد، بررسی SRP نقطه شروع خوبی است؛ اما اگر مشکل اصلی وابستگی مستقیم به زیرساخت باشد، DIP اهمیت بیشتری پیدا می‌کند.

آیا برای رعایت SOLID حتماً باید از رابط‌ها استفاده کنیم؟

خیر. برخی اصول، مانند مسئولیت واحد، بدون تعریف رابط نیز قابل رعایت هستند. با این حال، رابط‌ها در طراحی‌های مبتنی بر OCP، ISP و DIP ابزارهای مهمی محسوب می‌شوند و می‌توانند به جداسازی قرارداد از پیاده‌سازی کمک کنند.

تفاوت SOLID با الگوهای طراحی چیست؟

SOLID مجموعه‌ای از اصول راهنما برای تصمیم‌گیری درباره ساختار نرم‌افزار است؛ اما الگوهای طراحی راه‌حل‌های شناخته‌شده‌ای برای مسائل تکرارشونده طراحی ارائه می‌کنند. این دو مفهوم می‌توانند مکمل یکدیگر باشند؛ برای مثال، بعضی الگوهای طراحی از انتزاع و چندریختی برای کاهش وابستگی‌ها استفاده می‌کنند.

آیا رعایت SOLID همیشه باعث بهتر شدن کد می‌شود؟

نه لزوماً. اگر اصول بدون توجه به نیاز واقعی پروژه و به شکل افراطی به کار گرفته شوند، ممکن است کد پیچیده‌تر شود. هدف اصلی، انتخاب طراحی متناسب با نیازهای فعلی و مسیر توسعه احتمالی نرم‌افزار است.

جمع‌بندی

اصول SOLID پنج راهنمای مهم برای طراحی نرم‌افزار شیءگرا هستند که به ما کمک می‌کنند کدی منظم‌تر، قابل‌توسعه‌تر و آسان‌تر برای نگهداری بنویسیم.

اصل مسئولیت واحد بر جداسازی مسئولیت‌های نامرتبط تأکید می‌کند. اصل باز و بسته بودن به طراحی قابل‌گسترش کمک می‌کند. اصل جایگزینی لیسکوف بر سازگاری رفتار کلاس‌های فرزند با قراردادهای نوع پایه تمرکز دارد. اصل تفکیک رابط‌ها مانع وابستگی کلاس‌ها به متدهای غیرضروری می‌شود و اصل وارونگی وابستگی، وابستگی بخش‌های اصلی برنامه به جزئیات اجرایی را کاهش می‌دهد.

به خاطر داشته باشید که این اصول مجموعه‌ای از ابزارهای طراحی هستند، نه قوانینی که باید بدون استثنا و بدون توجه به شرایط پروژه اجرا شوند. مهم‌ترین مهارت یک برنامه‌نویس، تشخیص درست مسئله و انتخاب راه‌حلی است که ضمن حفظ سادگی، نیازهای واقعی نرم‌افزار را برآورده کند.

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

بازگشت به همه مقالات صفحه نخست