SOLID یا اصول سالید، نام پنج اصل برای طراحی نرمافزارهای قابل نگهداری و توسعهپذیر است. این نام از حروف اول Single Responsibility Principle، Open/Closed Principle، Liskov Substitution Principle، Interface Segregation Principle و Dependency Inversion Principle ساخته شده است. این اصول بیشتر در برنامهنویسی آبجکتگرا مطرح میشوند، اما ایدههایی مثل جدا کردن مسئولیتها و وابسته نبودن به جزئیات، در زبانهایی مانند Go و پایتون نیز کاربرد دارند.
سالید دستور ثابت برای زیاد کردن تعداد کلاسها نیست. هدف این است که وقتی قوانین تخفیف، روش پرداخت یا سیاست ارسال یک فروشگاه تغییر کرد، مجبور نشویم چندین قسمت نامرتبط را دستکاری کنیم. برای آموزش تصویری همین پنج اصل، دوره دیزاین پترن باورژن، بخش اصول سالید را ببینید. در آن دوره هر پنج اصل به ترتیب تدریس شدهاند.
پنج اصل سالید در یک نگاه
| حرف | اصل و نام انگلیسی | معنی کاربردی | مثال در فروشگاه اینترنتی |
|---|---|---|---|
| S | اصل تک مسئولیتی (Single Responsibility) | هر ماژول یک دلیل اصلی برای تغییر داشته باشد | محاسبه فاکتور از ارسال ایمیل جدا باشد |
| O | اصل باز-بسته (Open/Closed) | رفتار جدید را با توسعه اضافه کنیم، نه ویرایش مداوم کد پایدار | افزودن روش تخفیف بدون تغییر هسته تسویهحساب |
| L | اصل جایگزینی لیسکوف (Liskov Substitution) | پیادهسازی جایگزین، قرارداد و انتظارات مصرفکننده را نشکند | روش ارسال جدید قرارداد اعلامشده را رعایت کند |
| I | اصل جداسازی اینترفیسها (Interface Segregation) | مصرفکننده مجبور به وابستگی به متدهای بینیاز نباشد | گزارشگیر فقط اینترفیس خواندن سفارش را بشناسد |
| D | اصل وارونگی وابستگی (Dependency Inversion) | منطق اصلی به قرارداد وابسته باشد نه به سرویس مشخص | تسویهحساب به PaymentGateway وابسته باشد نه یک درگاه خاص |
۱. اصل تک مسئولیتی (SRP)
SRP مخفف Single Responsibility Principle است. «تک مسئولیتی» به معنای الزاماً داشتن یک متد در هر کلاس نیست. منظور این است که یک کلاس یا ماژول، یک دلیل اصلی برای تغییر داشته باشد، یعنی تغییر سیاست مالیاتی نباید ما را مجبور به تغییر کد ارسال ایمیل کند. در پروژه واقعی بهتر است مسئولیت را بر اساس نیاز و ذینفع تغییر مشخص کنیم.
قبل از اصلاح، چند مسئولیت در یک کلاس
class InvoiceService
{
public function total(array $items): int
{
return array_sum(array_column($items, 'price'));
}
public function sendEmail(string $email, int $total): void
{
mail($email, 'Invoice', "Total: {$total}");
}
}
این کلاس هم مبلغ فاکتور را حساب میکند و هم مسئول ارسال ایمیل است. تغییر در منطق قیمتگذاری و تغییر در سرویس ایمیل دو علت مستقل برای تغییر هستند.
بعد از اصلاح، مرز مسئولیت روشنتر است
final class InvoiceCalculator
{
public function total(array $items): int
{
return array_sum(array_column($items, 'price'));
}
}
final class InvoiceNotifier
{
public function __construct(private Mailer $mailer) {}
public function send(string $email, int $total): void
{
$this->mailer->send($email, "Total: {$total}");
}
}
در این مثال Mailer یک وابستگی فرضی و دارای متد send است. حالا تغییر موتور ایمیل به محاسبات فاکتور ارتباطی ندارد. البته برای یک اسکریپت چندخطی، اضافه کردن چندین کلاس ممکن است از خود مسئله پرهزینهتر باشد.
۲. اصل باز-بسته (OCP)
OCP مخفف Open/Closed Principle است. یک جزء بهتر است برای گسترش رفتار باز و برای تغییر مکرر کد تثبیتشده بسته باشد. بسته بودن به معنی ممنوعیت اصلاح باگ نیست. منظور این است که با هر نوع تخفیف تازه، یک شرط جدید به کلاس تسویهحساب اضافه نکنیم.
قبل از اصلاح، شرطهای رو به افزایش
function discount(int $subtotal, string $kind): int
{
if ($kind === 'vip') return intdiv($subtotal, 10);
if ($kind === 'campaign') return intdiv($subtotal, 5);
return 0;
}
بعد از اصلاح، اضافه شدن سیاست جدید با کلاس تازه
interface DiscountPolicy
{
public function calculate(int $subtotal): int;
}
final class VipDiscount implements DiscountPolicy
{
public function calculate(int $subtotal): int
{
return intdiv($subtotal, 10);
}
}
final class CampaignDiscount implements DiscountPolicy
{
public function calculate(int $subtotal): int
{
return intdiv($subtotal, 5);
}
}
final class CheckoutCalculator
{
public function __construct(private DiscountPolicy $policy) {}
public function payable(int $subtotal): int
{
return max(0, $subtotal - $this->policy->calculate($subtotal));
}
}
با افزودن سیاست تخفیف مناسبتی، قرارداد DiscountPolicy و منطق CheckoutCalculator تغییری نمیکنند. این رویکرد با دیزاین پترن استراتژی ارتباط نزدیکی دارد. در برابر، اگر تعداد سیاستها اندک و ثابت است، یک ساختار سادهتر شاید انتخاب بهتری باشد.
۳. اصل جایگزینی لیسکوف (LSP)
LSP مخفف Liskov Substitution Principle است. اگر یک کلاس یا آبجکت پیادهسازی یک قرارداد باشد، جایگزین کردن آن با پیادهسازی دیگر نباید رفتار مورد انتظار مصرفکننده را خراب کند. صرفاً یکسان بودن امضای متد کافی نیست. شرطهای ورودی، نتیجه خروجی و خطاهای قابل انتظار نیز بخشی از قرارداد رفتاری هستند.
مثال نامناسب در محاسبه هزینه ارسال
class ShippingCalculator:
def quote(self, postal_code: str) -> int:
return 50_000
class RemoteShipping(ShippingCalculator):
def quote(self, postal_code: str) -> int:
raise NotImplementedError("Remote delivery not supported")
اگر کد اصلی فرض کرده هر ShippingCalculator برای هر کد پستی قیمت عددی میدهد، آبجکت دوم این فرض را میشکند. مشکل این نیست که خطا دادن همیشه ممنوع است، مشکل این است که قرارداد اولیه محدودیت ارسال را اعلام نکرده بود.
قراردادی که پشتیبانی از مقصد را صریح میکند
from typing import Protocol
class ShippingQuote(Protocol):
def supports(self, postal_code: str) -> bool: ...
def quote(self, postal_code: str) -> int: ...
def shipping_cost(provider: ShippingQuote, postal_code: str) -> int:
if not provider.supports(postal_code):
raise ValueError("Shipping is unavailable for this destination")
return provider.quote(postal_code)
در قرارداد جدید، مصرفکننده میداند ابتدا باید قابلیت پشتیبانی را بررسی کند و سپس قیمت بگیرد. اگر یک پیادهسازی حتی برای مقصد پشتیبانیشده مقدار نامعتبر برگرداند، باز هم LSP نقض میشود. لیسکوف فقط درباره وراثت کلاس نیست و برای پیادهسازی اینترفیسها هم اهمیت دارد.
۴. اصل جداسازی اینترفیسها (ISP)
ISP مخفف Interface Segregation Principle است. هیچ مصرفکنندهای نباید مجبور شود به متدهایی وابسته باشد که استفاده نمیکند. برای مثال، سرویس نمایش سفارش نیازی به قابلیت حذف یا خروجی اکسل ندارد.
قبل از اصلاح، اینترفیس بیش از حد بزرگ
interface OrderRepository
{
public function find(int $id): ?Order;
public function save(Order $order): void;
public function delete(int $id): void;
public function exportCsv(): string;
}
بعد از اصلاح، قراردادهای کوچک و متناسب با مصرفکننده
interface OrderReader
{
public function find(int $id): ?Order;
}
interface OrderWriter
{
public function save(Order $order): void;
}
interface OrderExporter
{
public function exportCsv(): string;
}
final class OrderDetails
{
public function __construct(private OrderReader $orders) {}
public function show(int $id): ?Order
{
return $this->orders->find($id);
}
}
در این نمونه Order یک کلاس دامنه فرضی است. سرویس مشاهده سفارش فقط قرارداد خواندن را میشناسد. البته تقسیم یک اینترفیس به دهها اینترفیس تکمتدی بدون نیاز واقعی هم طراحی را پیچیده میکند.
۵. اصل وارونگی وابستگی (DIP)
DIP مخفف Dependency Inversion Principle است. منطق سطح بالای برنامه نباید مستقیماً به جزئیات یک سرویس مشخص گره بخورد. هر دو بهتر است به یک اینترفیس یا قرارداد پایدار متکی باشند. DIP با تزریق وابستگی یکسان نیست. تزریق وابستگی یکی از روشهای اجرای طراحی مبتنی بر DIP است.
قبل از اصلاح، وابستگی مستقیم به درگاه پرداخت
final class CheckoutService
{
public function pay(int $amount): bool
{
$gateway = new BankXGateway();
return $gateway->charge($amount);
}
}
بعد از اصلاح، تکیه بر قرارداد پرداخت
interface PaymentGateway
{
public function charge(int $amount): bool;
}
final class CheckoutService
{
public function __construct(private PaymentGateway $gateway) {}
public function pay(int $amount): bool
{
return $this->gateway->charge($amount);
}
}
حالا میتوان درگاه واقعی و درگاه آزمایشی را بدون تغییر کلاس تسویهحساب جایگزین کرد. در لاراول، Binding اینترفیس به پیادهسازی معمولاً در متد register() یک سرویس پرووایدر تعریف میشود:
public function register(): void
{
$this->app->bind(PaymentGateway::class, BankXGateway::class);
}
کلاسهای BankXGateway و PaymentGateway در این مثال آموزشی هستند و باید در برنامه واقعی پیادهسازی شوند. توضیح جزئیات ساخت آبجکتها در صفحه سرویس کانتینر آمده است.
پیادهسازی همین ایده در Go و ASP.NET
سالید به PHP محدود نیست. در Go حتی بدون وراثت کلاس، میتوان وابستگی به رفتار را با اینترفیس نشان داد:
type PaymentGateway interface {
Charge(amount int64) error
}
type CheckoutService struct {
Gateway PaymentGateway
}
func (s CheckoutService) Pay(amount int64) error {
return s.Gateway.Charge(amount)
}
در C# و برنامههای ASP.NET Core میتوان سرویس تسویهحساب را به یک اینترفیس وابسته کرد و پیادهسازی موردنظر را در DI Container ثبت کرد:
public interface IPaymentGateway
{
Task<bool> ChargeAsync(long amount);
}
public sealed class CheckoutService(IPaymentGateway gateway)
{
public Task<bool> PayAsync(long amount) => gateway.ChargeAsync(amount);
}
// In Program.cs, after defining StripeGateway:
builder.Services.AddScoped<IPaymentGateway, StripeGateway>();
برای تست، کافی است پیادهسازی ساختگی اینترفیس را به برنامه بدهیم. این امکان معمولاً از ساخت مستقیم سرویس خارجی داخل کلاس بهتر است.
تفاوت اصول سالید و دیزاین پترنها
سالید مجموعهای از اصول برای سنجش کیفیت وابستگیها و مسئولیتهاست. دیزاین پترن راهحلی تکرارپذیر برای مسئله طراحی مشخص است. برای نمونه، دیزاین پترن استراتژی میتواند به اجرای OCP کمک کند و دیزاین پترن آداپتر ارتباط با سرویس خارجی را پشت قرارداد داخلی پنهان کند. بااینحال هیچ دیزاین پترنی بهتنهایی تضمین نمیکند تمام اصول سالید رعایت شدهاند.
چه وقت باید از سالید استفاده کنیم؟
وقتی یک تغییر کوچک چند بخش بیربط را درگیر میکند، تستنویسی سخت شده یا افزودن قابلیت نیازمند اضافه کردن شرط در چندین محل است، این اصول برای شناسایی مشکل مفیدند. هر جا جداسازی مسئولیتها پیچیدگی بیشتری نسبت به مسئله ایجاد میکند، بهتر است طراحی سادهتر باقی بماند.
برای مطالعه و آموزش بیشتر
دوره آموزشی: دوره دیزاین پترن باورژن و آموزش پنج اصل سالید، مقاله مرتبط: مقاله دیزاین پترن در برنامهنویسی.
اصطلاحات فنی: دیزاین پترن، کلاس، آبجکت، اینترفیس، تزریق وابستگی، انکپسولیشن، کلین کد، ریفکتورینگ، سرویس کانتینر.