کلاس و شیء در جاوا؛ آموزش شیگرایی به زبان ساده
کلاس در جاوا نقشه است و شیء چیزی است که از روی آن نقشه ساخته میشود. در این آموزش ساده، …
اصول SOLID پنج قاعده ساده برای نوشتن کدی است که تغییر دادنش آسان باشد. در این راهنما هر اصل را با یک مثال کوتاه «قبل و بعد» در پایتون توضیح میدهیم، نشانههای نقض هر اصل را میگوییم و یادآوری میکنیم کجا نباید زیادی سختگیر بود.
آخرین بهروزرسانی: ۱۸ آبان ۱۴۰۵ — منابع بررسیشده در این روز: نوشتههای رابرت سی. مارتین در وبلاگ Clean Coder، صفحههای ویکیپدیا درباره SOLID و اصلهای آن، پیشنهاد ۵۴۴ پایتون، مستندات abc و نوشته مارتین فاولر درباره تزریق وابستگی؛ همه مثالها با پایتون ۳.۱۳.۵ اجرا شدند.
⚡ پاسخ کوتاه
S یعنی هر کلاس فقط یک دلیل برای تغییر داشته باشد. O یعنی رفتار تازه را با کد تازه اضافه کنید، نه با باز کردن مدام کد قبلی. L یعنی زیرکلاس باید بیدردسر جای کلاس پدر بنشیند. I یعنی قراردادهای کوچک بهتر از قرارداد همهکارهاند. D یعنی قواعد اصلی به قرارداد وابسته باشند، نه به جزئیاتی مثل پایگاه داده.
📑 فهرست مطالب
SOLID نام پنج اصل طراحی نرمافزار است که هر حرف آن از اول نام یکی از اصلها گرفته شده: S برای اصل تکمسئولیتی، O برای اصل باز-بسته، L برای اصل جایگزینی لیسکوف، I برای اصل جداسازی اینترفیس و D برای اصل وارونگی وابستگی. هدف همه این اصلها یک چیز است: کدی بنویسیم که تغییر دادنش آسان باشد و با هر تغییر کوچک، جای دیگری خراب نشود. این اصلها را رابرت سی. مارتین، معروف به عمو باب، در مقالهای در سال ۲۰۰۰ با عنوان Design Principles and Design Patterns کنار هم مطرح کرد. بعضی از آنها قدیمیترند؛ مثلاً اصل باز-بسته را برتراند مایر و اصل جایگزینی را باربارا لیسکوف پیشتر مطرح کرده بودند. خود کلمه SOLID را هم حدود سال ۲۰۰۴ مایکل فدرز ساخت تا به خاطر سپردن این پنج اصل سادهتر شود. اگر میخواهید بیشتر با نویسنده آشنا شوید، معرفی رابرت سی. مارتین و معرفی کتابهای او را بخوانید. در این مقاله هر اصل را با یک مثال کوچک «قبل و بعد» به زبان پایتون توضیح میدهیم. همه نمونهها را با پایتون ۳.۱۳ اجرا کردهایم. اگر با جاوا کار میکنید هم نگران نباشید؛ ایدهها در هر زبان شیگرایی یکساناند و فقط شکل نوشتن کمی فرق میکند.
بیشتر پروژهها در روزهای اول تمیز و سادهاند. مشکل از ماههای بعد شروع میشود؛ وقتی ویژگیهای تازه اضافه میشوند و کد زیر بار تغییرها کج میشود. علامتهای آشنایی دارد: برای یک تغییر کوچک باید ده فایل را عوض کنید، تغییر در یک بخش بخش دیگری را که ظاهراً ربطی ندارد خراب میکند، یا نمیتوانید یک کلاس را بدون آوردن نیمی از پروژه در جای دیگری استفاده کنید. SOLID برای همین مشکلها راهحل نمیفروشد؛ بیشتر شبیه چند سؤال خوب است که هنگام طراحی از خودتان میپرسید. این کلاس چند دلیل برای تغییر دارد؟ برای اضافه کردن حالت تازه، باید کد قبلی را دست بزنم؟ آیا زیرکلاس واقعاً میتواند جای کلاس پدر بنشیند؟ این اینترفیس بیش از حد بزرگ نیست؟ کد اصلی من به جزئیات فنی گره خورده است؟ پنج بخش بعدی دقیقاً همین پنج سؤال را باز میکنند.

اصل تکمسئولیتی معمولاً اینطور گفته میشود: «هر ماژول یا کلاس باید فقط یک دلیل برای تغییر داشته باشد». خود مارتین بعدها توضیح داد که منظور از «دلیل تغییر» معمولاً یک گروه از آدمها یا یک بخش از کسبوکار است. یعنی اگر تیم مالی و تیم طراحی هر دو ممکن است درخواست تغییر در یک کلاس بدهند، آن کلاس احتمالاً دو مسئولیت دارد. در مثال زیر، کلاس SalesReport سه کار را با هم انجام میدهد: جمع فروش را حساب میکند، خروجی HTML میسازد و فایل را ذخیره میکند. اگر فرمول محاسبه عوض شود، یا ظاهر گزارش، یا محل ذخیره، باید همین یک کلاس را تغییر دهید و خطر خراب شدن دو بخش دیگر وجود دارد.
# before: one class, three reasons to change
class SalesReport:
def __init__(self, orders):
self.orders = orders
def total(self):
return sum(o["amount"] for o in self.orders)
def to_html(self):
return f"<p>Total: {self.total()}</p>"
def save(self, path):
with open(path, "w", encoding="utf-8") as f:
f.write(self.to_html())
راهحل این است که هر مسئولیت را به کلاس خودش بدهیم. حالا محاسبه، قالببندی و ذخیره جدا هستند. میتوانید فرمت خروجی را از HTML به PDF عوض کنید بدون اینکه به محاسبه دست بزنید، و محاسبه را بدون ساختن فایل آزمایش کنید.
# after: each class has one job
class SalesCalculator:
def total(self, orders):
return sum(o["amount"] for o in orders)
class HtmlReportFormatter:
def render(self, total):
return f"<p>Total: {total}</p>"
class FileReportWriter:
def write(self, path, content):
with open(path, "w", encoding="utf-8") as f:
f.write(content)
orders = [{"amount": 120}, {"amount": 80}]
total = SalesCalculator().total(orders)
html = HtmlReportFormatter().render(total) # "<p>Total: 200</p>"
یک هشدار مهم: تکمسئولیتی به معنی «هر کلاس فقط یک متد» نیست. اگر کد را بیش از حد خرد کنید، فهمیدن اینکه چه چیزی کجاست سخت میشود. معیار، دلیل تغییر است، نه تعداد خط یا متد.
اصل باز-بسته میگوید کد باید «برای گسترش باز و برای تغییر بسته» باشد. به زبان ساده: وقتی حالت تازهای به برنامه اضافه میکنید، بهتر است کد جدید بنویسید، نه اینکه مدام کد قدیمی و آزمودهشده را باز کنید و شاخه تازه به آن اضافه کنید. مثال رایج، محاسبه تخفیف است. در نسخه اول، برای هر نوع مشتری یک شاخه if داریم. اگر فردا تخفیف کارمندی یا تخفیف مناسبتی اضافه شود، باید همین تابع را دوباره باز کنید. هر بار این تابع بزرگتر و شکنندهتر میشود.
# before: every new customer type edits this function
def final_price(price, customer_type):
if customer_type == "regular":
return price
elif customer_type == "student":
return price * 0.9
elif customer_type == "vip":
return price * 0.8
raise ValueError("unknown customer type")
در نسخه بعد، یک قرارداد کوچک به نام DiscountPolicy داریم. هر نوع تخفیف یک کلاس جداست که این قرارداد را رعایت میکند. تابع final_price دیگر نمیداند چند نوع تخفیف وجود دارد؛ فقط از سیاستی که به او داده شده استفاده میکند. برای تخفیف تازه، یک کلاس تازه مینویسید و کد قبلی دستنخورده میماند.
# after: new discounts are new classes
from typing import Protocol
class DiscountPolicy(Protocol):
def apply(self, price: float) -> float: ...
class NoDiscount:
def apply(self, price: float) -> float:
return price
class PercentDiscount:
def __init__(self, percent: float):
self.percent = percent
def apply(self, price: float) -> float:
return price * (1 - self.percent / 100)
def final_price(price: float, policy: DiscountPolicy) -> float:
return policy.apply(price)
final_price(200, PercentDiscount(20)) # 160.0
در پایتون، Protocol امکان میدهد قرارداد را بدون وراثت اجباری تعریف کنیم؛ هر کلاسی که متد apply با همین شکل داشته باشد، از نظر ابزار بررسی نوع قابل قبول است. باز هم اغراق نکنید: اگر فقط دو حالت دارید که سالهاست تغییر نکردهاند، یک if ساده کاملاً قابل قبول است. اصل باز-بسته برای جاهایی است که واقعاً مدام تغییر میکنند.
این اصل را باربارا لیسکوف مطرح کرد و خلاصهاش این است: هر جا که برنامه از یک کلاس استفاده میکند، باید بتوان زیرکلاس آن را جایگزین کرد، بدون اینکه رفتار برنامه غافلگیرکننده شود. یعنی زیرکلاس فقط شکل متدها را حفظ نمیکند؛ باید قولهایی را هم که کلاس پدر داده، نگه دارد. مثال کلاسیک، مربع و مستطیل است. در ریاضی، مربع نوعی مستطیل است، پس وسوسه میشویم Square را زیرکلاس Rectangle کنیم. اما مستطیل قول داده که عرض و ارتفاعش مستقل از هم تغییر کنند. مربع مجبور است با تغییر عرض، ارتفاع را هم عوض کند و همین قول را میشکند.
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def set_width(self, w):
self.width = w
def set_height(self, h):
self.height = h
def area(self):
return self.width * self.height
class Square(Rectangle):
def set_width(self, w):
self.width = self.height = w
def set_height(self, h):
self.width = self.height = h
def stretch(rect: Rectangle):
rect.set_width(5)
rect.set_height(4)
return rect.area() # caller expects 20
stretch(Rectangle(1, 1)) # 20
stretch(Square(1, 1)) # 16, the contract is broken
ما این کد را اجرا کردیم: برای مستطیل ۲۰ و برای مربع ۱۶ برگرداند. تابع stretch هیچ اشتباهی نکرده؛ مشکل در رابطه وراثت است. راهحل این است که این دو را از هم جدا کنیم و فقط چیزی را مشترک کنیم که واقعاً مشترک است؛ مثلاً اینکه هر شکل مساحت دارد.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float: ...
class Rect(Shape):
def __init__(self, width, height):
self.width, self.height = width, height
def area(self):
return self.width * self.height
class Sq(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side * self.side
یک نشانه ساده برای نقض این اصل: زیرکلاسی که متدی از پدر را با raise NotImplementedError پر کرده، یا شرطهای ورودی را سختتر کرده، یا خروجیای میدهد که استفادهکننده انتظارش را ندارد. هر جا دیدید کد بیرونی مجبور است با isinstance بپرسد «این کدام زیرکلاس است؟» تا رفتار درست را انتخاب کند، احتمالاً جایگزینی درست کار نمیکند.

اصل جداسازی اینترفیس میگوید هیچ کلاسی نباید مجبور شود به متدهایی وابسته باشد که از آنها استفاده نمیکند. وقتی یک اینترفیس بزرگ و همهکاره دارید، کلاسهای ساده مجبورند متدهایی را پیاده کنند که برایشان معنی ندارد. فرض کنید قراردادی برای دستگاههای اداری داریم که چاپ، اسکن و فکس را با هم میخواهد. یک چاپگر ساده فقط چاپ بلد است، اما برای رعایت قرارداد مجبور است دو متد دیگر را با خطا پر کند. این همان بوی بدی است که در بخش قبل هم دیدیم.
# before: one fat contract
from typing import Protocol
class OfficeMachine(Protocol):
def print_doc(self, doc: str) -> None: ...
def scan_doc(self) -> str: ...
def fax_doc(self, doc: str) -> None: ...
class SimplePrinter:
def print_doc(self, doc):
print("printing", doc)
def scan_doc(self):
raise NotImplementedError("cannot scan")
def fax_doc(self, doc):
raise NotImplementedError("cannot fax")
راهحل، شکستن قرارداد بزرگ به چند قرارداد کوچک است. حالا تابعی که فقط میخواهد فاکتور چاپ کند، به Printer نیاز دارد و برایش مهم نیست دستگاه اسکن هم بلد است یا نه. هم چاپگر ساده و هم دستگاه کپی چندکاره میتوانند به این تابع داده شوند.
# after: small, focused contracts
class Printer(Protocol):
def print_doc(self, doc: str) -> None: ...
class Scanner(Protocol):
def scan_doc(self) -> str: ...
class BasicPrinter:
def print_doc(self, doc: str) -> None:
print("printing", doc)
class OfficeCopier:
def print_doc(self, doc: str) -> None:
print("copier prints", doc)
def scan_doc(self) -> str:
return "scanned page"
def print_invoice(printer: Printer) -> None:
printer.print_doc("invoice-42")
در جاوا این اصل خیلی ملموستر است، چون کلاس باید همه متدهای اینترفیسی را که implements میکند پیاده کند. اگر میخواهید ببینید اینترفیس و کلاس انتزاعی در جاوا چه فرقی دارند و کدام برای قرارداد کوچک مناسبتر است، تفاوت Interface و Abstract Class در جاوا را بخوانید. برای مبانی کلاس در جاوا هم کلاس و شیء در جاوا را ببینید.
اصل وارونگی وابستگی دو جمله دارد. اول، بخشهای اصلی برنامه، یعنی قواعد کسبوکار، نباید به جزئیات فنی مثل پایگاه داده یا سرویس ایمیل وابسته باشند؛ هر دو باید به یک قرارداد وابسته باشند. دوم، قرارداد نباید به جزئیات وابسته باشد؛ جزئیات باید از قرارداد پیروی کنند. در نسخه اول، سرویس سفارش خودش مخزن MySQL را میسازد. برای آزمایش این سرویس باید پایگاه داده واقعی داشته باشید و اگر روزی بخواهید ذخیرهسازی را عوض کنید، باید سرویس را تغییر دهید.
# before: the service builds its own concrete dependency
class MySqlOrderRepository:
def save(self, order):
print("INSERT INTO orders ...", order)
class OrderService:
def __init__(self):
self.repo = MySqlOrderRepository()
def place(self, order):
self.repo.save(order)
در نسخه بعد، سرویس فقط یک قرارداد OrderRepository میشناسد و پیادهسازی از بیرون به آن داده میشود. به این کار تزریق وابستگی میگویند. حالا در آزمون میتوانیم یک مخزن ساده در حافظه بدهیم و در برنامه واقعی، مخزن پایگاه داده را.
# after: depend on a contract and inject the detail
from typing import Protocol
class OrderRepository(Protocol):
def save(self, order: dict) -> None: ...
class OrderService:
def __init__(self, repo: OrderRepository):
self.repo = repo
def place(self, order: dict) -> None:
if order["amount"] <= 0:
raise ValueError("amount must be positive")
self.repo.save(order)
class InMemoryOrderRepository:
def __init__(self):
self.items = []
def save(self, order: dict) -> None:
self.items.append(order)
repo = InMemoryOrderRepository()
OrderService(repo).place({"id": 1, "amount": 250})
دقت کنید که «وارونگی وابستگی» با «تزریق وابستگی» یکی نیست. اولی یک اصل طراحی درباره جهت وابستگیهاست و دومی یک روش عملی برای رسیدن به آن. اگر بخواهید این ایده را در مقیاس کل برنامه و لایهها ببینید، معماری تمیز دقیقاً همین را با قانون وابستگی توضیح میدهد. اینجا وارد لایهها نمیشویم.

| اصل | سؤال کلیدی | نشانه نقض | راهحل رایج |
|---|---|---|---|
| تکمسئولیتی (SRP) | این کلاس چند دلیل برای تغییر دارد؟ | کلاسی که هم محاسبه، هم نمایش و هم ذخیره میکند | جدا کردن مسئولیتها به کلاسهای کوچکتر |
| باز-بسته (OCP) | برای حالت تازه باید کد قبلی را باز کنم؟ | زنجیره بلند if و elif که مدام بزرگ میشود | قرارداد مشترک و کلاس تازه برای هر حالت |
| جایگزینی لیسکوف (LSP) | زیرکلاس بیدردسر جای پدر مینشیند؟ | متد پرشده با خطا یا نتیجه غافلگیرکننده | بازنگری رابطه وراثت و قرارداد کوچکتر |
| جداسازی اینترفیس (ISP) | کلاس مجبور است متد بیمصرف پیاده کند؟ | اینترفیس همهکاره با متدهای نامربوط | شکستن به چند قرارداد کوچک |
| وارونگی وابستگی (DIP) | قواعد اصلی به جزئیات فنی گره خوردهاند؟ | ساختن مستقیم پایگاه داده داخل سرویس | وابستگی به قرارداد و تزریق پیادهسازی |
پایتون زبانی پویاست و به «تایپ اردکی» معروف است: اگر چیزی مثل اردک راه برود و مثل اردک صدا بدهد، اردک حسابش میکنیم. برای همین در پایتون لازم نیست همهچیز از یک کلاس پایه ارث ببرد. Protocol که در مثالها دیدید، قرارداد را برای ابزارهای بررسی نوع مشخص میکند و کلاس انتزاعی با ماژول abc هم برای وقتی است که بخواهید پیادهسازی متدها اجباری شود. در جاوا، اینترفیسها و کلاسهای انتزاعی بخش اصلی زباناند و کامپایلر رعایت قرارداد را بررسی میکند. به همین دلیل بیشتر مثالهای SOLID در کتابها جاواییاند. اما ایدهها یکی است: مسئولیتها را جدا کنید، رفتار تازه را با کد تازه اضافه کنید، قولهای کلاس پدر را نگه دارید، قراردادها را کوچک نگه دارید و قواعد اصلی را به جزئیات فنی گره نزنید.
هر اصلی اگر بیحساب اجرا شود، خودش مشکل میسازد. رایجترین اشتباه، ساختن لایهها و اینترفیسهای زیاد برای چیزهایی است که هیچوقت تغییر نمیکنند. اسکریپت کوچکی که یک بار اجرا میشود، به پنج کلاس و سه Protocol نیاز ندارد. نتیجه چنین طراحیای کدی است که برای فهمیدن یک کار ساده باید بین ده فایل رفتوآمد کنید. یک قاعده عملی: اول کد ساده و درست بنویسید. وقتی دیدید یک بخش واقعاً مدام تغییر میکند، یا آزمایشش سخت است، یا تغییرش جای دیگری را خراب میکند، همانجا SOLID را به کار ببرید. این اصلها ابزار حل مشکلاند، نه چکلیستی که باید روی هر فایل تیک بخورد.
SOLID بیشتر در سطح کلاسها و ماژولها کار میکند. کد تمیز به نامگذاری، تابعهای کوتاه و خوانایی خطبهخط میپردازد که در اصول کدنویسی حرفهای با Clean Code مرورش کردهایم. معماری تمیز یک سطح بالاتر است و درباره مرز بین لایههای برنامه حرف میزند. این سه به هم کمک میکنند، اما جای هم را نمیگیرند. در سطح سرویسها و استقرار هم موضوعهای دیگری مطرح میشود. اگر بین یکپارچه نگه داشتن برنامه و شکستن آن به سرویسهای کوچک مردد هستید، مقایسه معماری مونولیتیک و میکروسرویس را بخوانید، و برای قواعد ساخت و اجرای برنامههای ابری، راهنمای ۱۲ فاکتور را ببینید. نکته مهم این است که میکروسرویس، کد بد داخل هر سرویس را خوب نمیکند؛ SOLID همچنان داخل هر سرویس لازم است.
هنگام بازبینی کد یا قبل از ادغام یک تغییر، این سؤالها را مرور کنید. این کلاس را چند تیم یا چند دلیل مختلف ممکن است تغییر دهند؟ برای اضافه کردن این ویژگی، چه مقدار کد قدیمی باز شد و آیا راهی بود که فقط کد تازه اضافه شود؟ آیا زیرکلاسی متدی را با خطا یا رفتار عجیب پر کرده است؟ آیا کلاسی متدهایی را پیاده کرده که هیچوقت صدا زده نمیشوند؟ آیا قواعد اصلی کسبوکار مستقیماً پایگاه داده یا سرویس خارجی را میسازند؟ لازم نیست جواب همه این سؤالها همیشه کامل باشد؛ همین که آنها را بپرسید، بسیاری از مشکلات را زود پیدا میکنید.
این اصلها در فضای شیگرایی مطرح شدند و مثالهایشان معمولاً کلاسمحور است. اما ایدههایشان، مثل جدا کردن مسئولیتها و وابستگی به قرارداد به جای جزئیات، در کد تابعی و ماژولمحور هم کاربرد دارد. خود مارتین هم در نوشتهای در سال ۲۰۲۰ از مرتبط بودن این اصلها در زمانه امروز دفاع کرده است.
رتبهبندی رسمی وجود ندارد. در عمل، اصل تکمسئولیتی و وارونگی وابستگی بیشترین تأثیر را روی آزمونپذیری و نگهداری دارند و برای شروع گزینههای خوبیاند. اما هر پنج اصل به هم مربوطاند؛ مثلاً اینترفیسهای کوچک، رعایت وارونگی وابستگی را راحتتر میکنند.
وارونگی وابستگی یک اصل طراحی است: قواعد اصلی و جزئیات هر دو به قرارداد وابسته باشند. تزریق وابستگی یک روش اجرایی است: وابستگی را به جای ساختن در داخل کلاس، از بیرون به آن میدهیم. تزریق یکی از راههای رسیدن به وارونگی است.
ما برای این مقاله آزمون سرعتی اجرا نکردهایم و ادعای عددی نداریم. در بیشتر برنامهها، هزینه چند فراخوانی اضافه در برابر کار با شبکه یا پایگاه داده ناچیز است. هزینه واقعی SOLID بیشتر پیچیدگی اضافه است، نه سرعت؛ برای همین فقط جایی به کارش ببرید که مشکلی واقعی را حل کند.
هر اصل را در یک جمله بگویید و برای هر کدام یک مثال کوچک از کار خودتان آماده داشته باشید؛ مثلاً کلاسی که به خاطر چند مسئولیت شکستهاید یا وابستگیای که برای راحت شدن آزمون تزریق کردهاید. مصاحبهکنندهها معمولاً به فهم شما از «چرا» بیشتر از حفظ بودن تعریفها اهمیت میدهند.
نظرات 0
برای ارسال نظر وارد حساب کاربری شوید.
هنوز نظری ثبت نشده؛ اولین نفر باشید.