اصول SOLID در برنامه نویسی
S.O.L.I.D: پنج قانون طلایی برای ارتقای مهارتهای برنامهنویسی شما
در دنیای توسعهی نرمافزار، حوزهای با نظرات متنوع و اغلب متناقض، کمتر قوانینی میتوان یافت که بهعنوان یک مسیر تضمینی برای تبدیل شدن به یک مهندس نرمافزار بهتر، مانند اصول S.O.L.I.D، به اجماع و توافق عمومی دست یافته باشند.
این 5 قانون طلایی که در اوایل دهه 2000 توسط رابرت سی. مارتین تدوین شد، تاثیر چشمگیری بر صنعت توسعهی نرمافزار داشته و با تعیین استانداردهای جدید برای کیفیت بهتر کد و فرآیند تصمیمگیری، تا به امروز همچنان اهمیت خود را حفظ کرده است
قوانین S.O.L.I.D به صورت خاص برای پشتیبانی از پارادایم برنامهنویسی شیءگرا (OOP) طراحی شدهاند. بنابراین، این مقاله برای توسعهدهندگانی نوشته شده که با OOP کار میکنند و به دنبال ارتقای مهارتهای خود و نوشتن کدهای منظمتر، قابل نگهداری و مقیاسپذیرتر هستند.
زبان استفادهشده در این مقاله TypeScript خواهد بود که الگوهای مرسوم OOP بین زبانهای مختلف را دنبال میکند. داشتن دانش پایهای از OOP ضروری است.
1- اصل مسئولیت واحد (SRP) Single Responsibility Principle
اصل مسئولیت واحد (SRP) یکی از پنج اصل S.O.L.I.D است که بیان میکند هر کلاس باید فقط یک مسئولیت داشته باشد تا جداسازی معنیدار خواسته ها حفظ شود.
این الگو راهحلی برای یک الگوی ضد الگو به نام "god object" است که به کلاسی یا شیای اشاره میکند که مسئولیتهای بسیار زیادی دارد و درک، تست و نگهداری آن را دشوار میکند.
پیروی از قاعده SRP به ساخت اجزای کد قابل استفاده مجدد، با وابستگیهای کم و قابل فهم کمک میکند. بیایید این اصل را با نمایش نقض SRP و رفع آن بررسی کنیم.
enum Color {
BLUE = 'blue',
GREEN = 'green',
RED = 'red'
}
enum Size {
SMALL = 'small',
MEDIUM = 'medium',
LARGE = 'large'
}
class Product {
private _name: string;
private _color: Color;
private _size: Size;
constructor (name: string, color: Color, size: Size) {
this._name = name;
this._color = color;
this._size = size;
}
public get name(): string { return this._name; }
public get color(): Color { return this._color; }
public get size(): Size { return this._size; }
}
نقض اصل مسئولیت واحد
در کد زیر، کلاس ProductManager هم وظیفه ایجاد و هم وظیفه ذخیرهسازی محصولات را بر عهده دارد که ناقض اصل مسئولیت واحد است.
class ProductManager {
private _products: Product[] = [];
createProduct (name: string, color: Color, size: Size): Product {
return new Product(name, color, size);
}
storeProduct (product: Product): void {
this._products.push(product);
}
getProducts (): Product[] {
return this._products;
}
}
const productManager: ProductManager = new ProductManager();
const product: Product = productManager.createProduct('Product 1', Color.BLUE, Size.LARGE);
productManager.storeProduct(product);
const allProducts: Product[] = productManager.getProducts();
رفع مشکل
با جدا کردن مدیریت ایجاد و ذخیرهسازی محصولات به دو کلاس مجزا، تعداد مسئولیتهای کلاس ProductManager کاهش مییابد. این رویکرد باعث ماژولار شدن کد و افزایش قابلیت نگهداری آن میشود.
class ProductManager {
createProduct (name: string, color: Color, size: Size): Product {
return new Product(name, color, size);
}
}
class ProductStorage {
private _products: Product[] = [];
storeProduct (product: Product): void {
this._products.push(product);
}
getProducts (): Product[] {
return this._products;
}
}
استفاده:
const productManager: ProductManager = new ProductManager();
const productStorage: ProductStorage = new ProductStorage();
const product: Product = productManager.createProduct("Product 1", Color.BLUE, Size.LARGE);
productStorage.storeProduct(product);
const allProducts: Product[] = productStorage.getProducts();
2. اصل باز-بسته (OCP) Open-Closed Principle
"طبق این اصل در نرم افزار توسعه کد و افزودن امکانات جدید آزاد است، اما دستکاری و تغییر کد ممنوع"
اصل باز-بسته (OCP) بر این رویکرد تاکید میکند که "آن را یک بار بنویسید، به اندازه کافی قابل توسعه بنویسید اما بعد از نوشتن به تغییر کد آن فکر نکنید"
اهمیت این اصل به این واقعیت مربوط می شود که یک ماژول ممکن است بر اساس نیازهای جدید به مرور زمان تغییر کند. در صورتی که پس از نوشته شدن، تست شدن و آپلود شدن ماژول به محیط اجرایی ،نیازهای جدید به وجود بیاید ، تغییر دادن این ماژول، به خصوص زمانی که سایر ماژول ها به آن وابسته هستند، معمولاً روش خوبی نیست. برای جلوگیری از این وضعیت، می توانیم از اصل باز-بسته استفاده کنیم.
کدهای سراسری
enum Color {
BLUE = 'blue',
GREEN = 'green',
RED = 'red'
}
enum Size {
SMALL = 'small',
MEDIUM = 'medium',
LARGE = 'large'
}
class Product {
private _name: string;
private _color: Color;
private _size: Size;
constructor (name: string, color: Color, size: Size) {
this._name = name;
this._color = color;
this._size = size;
}
public get name(): string { return this._name; }
public get color(): Color { return this._color; }
public get size(): Size { return this._size; }
}
class Inventory {
private _products: Product[] = [];
public add(product: Product): void {
this._products.push(product);
}
addArray(products: Product[]) {
for (const product of products) {
this.add(product);
}
}
public get products(): Product[] {
return this._products;
}
}
نقض اصل باز-بسته
بیایید سناریویی را تصور کنیم که در آن یک کلاس فیلتر محصولات را پیادهسازی میکنیم. اکنون میخواهیم قابلیت فیلتر کردن محصولات بر اساس رنگ را نیز اضافه کنیم.
class ProductsFilter {
byColor(inventory: Inventory, color: Color): Product[] {
return inventory.products.filter(p => p.color === color);
}
}
کد را آزمایش و در محیط اجرایی مستقر کردیم.
چند روز بعد مشتری درخواست قابلیت جدیدی میکند - فیلتر کردن بر اساس سایز. سپس کلاس را برای پشتیبانی از نیاز جدید تغییر میدهیم.
با این کار، اصل باز-بسته نقض میشود!
class ProductsFilter {
byColor(inventory: Inventory, color: Color): Product[] {
return inventory.products.filter(p => p.color === color);
}
bySize(inventory: Inventory, size: Size): Product[] {
return inventory.products.filter(p => p.size === size);
}
}
رفع نقض با استفاده از کلاسهای «Specifications»
برای پیادهسازی مکانیزم فیلترینگ بدون نقض اصل باز-بسته (OCP)، میتوان از کلاسهای «مشخصات» (Specifications) استفاده کرد.
این رویکرد با جداسازی منطق فیلترینگ از کلاس اصلی، تغییرات و توسعههای آینده را بدون نیاز به دستکاری کد موجود تسهیل میکند.
به عبارت دیگر، با استفاده از کلاسهای «Specifications»:
- کلاس اصلی فیلترینگ به یک رابط یا کلاس انتزاعی تبدیل میشود که وظیفه اصلی برآورده کردن معیارهای فیلترینگ را بر عهده دارد.
- کلاسهای «Specifications» مجزا برای هر معیار فیلترینگ، مانند رنگ و سایز، ایجاد میشوند. هر کلاس وظیفه ارزیابی یک شرط خاص را برای یک محصول بر عهده دارد.
- کلاس اصلی فیلترینگ میتواند از چندین کلاس «مشخصات» برای ایجاد شرایط فیلترینگ پیچیده با استفاده از منطق AND/OR بهره ببرد.
- با استفاده از این الگو، اضافه کردن یک فیلتر جدید مانند فیلتر سایز، تنها نیازمند ایجاد یک کلاس «Specifications» جدید است، بدون اینکه نیاز به تغییر کد کلاس اصلی فیلترینگ باشد. این باعث می شود کد قابل انعطاف تر، قابل نگهداری و سازگار با تغییرات جدید در آینده باشد.
abstract class Specification {
public abstract isValid(product: Product): boolean;
}
class ColorSpecification extends Specification {
private _color: Color;
constructor (color) {
super();
this._color = color;
}
public isValid(product: Product): boolean {
return product.color === this._color;
}
}
class SizeSpecification extends Specification {
private _size: Size;
constructor (size) {
super();
this._size = size;
}
public isValid(product: Product): boolean {
return product.size === this._size;
}
}
// A robust mechanism to allow different combinations of specifications
class AndSpecification extends Specification {
private _specifications: Specification[];
// "...rest" operator, groups the arguments into an array
constructor ((...specifications): Specification[]) {
super();
this._specifications = specifications;
}
public isValid (product: Product): boolean {
return this._specifications.every(specification => specification.isValid(product));
}
}
class ProductsFilter {
public filter (inventory: Inventory, specification: Specification): Product[] {
return inventory.products.filter(product => specification.isValid(product));
}
}
استفاده:
const p1: Product = new Product('Apple', Color.GREEN, Size.LARGE);
const p2: Product = new Product('Pear', Color.GREEN, Size.LARGE);
const p3: Product = new Product('Grapes', Color.GREEN, Size.SMALL);
const p4: Product = new Product('Blueberries', Color.BLUE, Size.LARGE);
const p5: Product = new Product('Watermelon', Color.RED, Size.LARGE);
const inventory: Inventory = new Inventory();
inventory.addArray([p1, p2, p3, p4, p5]);
const greenColorSpec: ColorSpecification = new ColorSpecification(Color.GREEN);
const largeSizeSpec: SizeSpecification = new SizeSpecification(Size.LARGE);
const andSpec: AndSpecification = new AndSpecification(greenColorSpec, largeSizeSpec);
const productsFilter: ProductsFilter = new ProductsFilter();
const filteredProducts: Product[] = productsFilter.filter(inventory, andSpec); // All large green products
با استفاده از کلاسهای «Specifications»، مکانیزم فیلترینگ کاملاً قابل توسعه میشود. دیگر نیازی به تغییر کلاسهای موجود نیست.
هر زمان نیاز به فیلتر جدیدی باشد، به سادگی یک کلاس «مشخصات» جدید ایجاد میکنیم. همچنین، ترکیب فیلترها از طریق کلاس «AndSpecification» به راحتی قابل تغییر است.
در نتیجه، کد ما انعطافپذیرتر، قابل نگهداریتر و سازگارتر با نیازهای آینده است. اصل باز-بسته تضمین میکند که کد ما باز برای توسعه (افزودن فیلترهای جدید) و بسته برای اصلاح (حفظ ثبات کد موجود) باقی میماند.
3.اصل جایگزینی لیسکوف (LSP) Liskov Substitution Principle
اصل جایگزینی Liskov (LSP) یک قانون مهم برای انعطافپذیری و استحکام اجزای نرمافزاری است. این اصل توسط باربارا لیسکوف معرفی شد و به عنصری بنیادی از اصول S.O.L.I.D تبدیل شد.
LSP بیان میکند که اشیاء یک سوپرکلاس باید با اشیاء زیرکلاس قابل تعویض باشند بدون اینکه صحت برنامه تحت تأثیر قرار گیرد. به عبارت دیگر، یک زیرکلاس باید رفتارهای یک سوپرکلاس را بدون تغییر عملکرد اصلی آن انجام دهد. اتخاذ این رویکرد منجر به افزایش کیفیت اجزای نرمافزاری، تضمین قابلیت استفاده مجدد و کاهش عوارض جانبی ناخواسته میشود.
نقض اصل
مثال زیر سناریویی را نشان میدهد که در آن اصل جایگزینی Liskov (LSP) نقض میشود. نشانههایی از این نقض را میتوان با بررسی رفتار برنامه در هنگام جایگزینی شیء مستطیل با شیء مربع مشاهده کرد.
تعاریف:
class Rectangle {
protected _width: number;
protected _height: number;
constructor (width: number, height: number) {
this._width = width;
this._height = height;
}
get width (): number { return this._width; }
get height (): number { return this._height; }
set width (width: number) { this._width = width; }
set height (height: number) { this._height = height; }
getArea (): number {
return this._width * this._height;
}
}
// A square is also rectangle
class Square extends Rectangle {
get width (): number { return this._width; }
get height (): number { return this._height; }
set height (height: number) {
this._height = this._width = height; // Changing both width & height
}
set width (width: number) {
this._width = this._height = width; // Changing both width & height
}
}
function increaseRectangleWidth(rectangle: Rectangle, byAmount: number) {
rectangle.width += byAmount;
}
استفاده:
const rectangle: Rectangle = new Rectangle(5, 5);
const square: Square = new Square(5, 5);
console.log(rectangle.getArea()); // Expected: 25, Got: 25 (V)
console.log(square.getArea()); // Expected: 25, Got: 25 (V)
// LSP Violation Indication: Can't replace object 'rectangle' (superclass) with 'square' (subclass) since the results would be different.
increaseRectangleWidth(rectangle, 5);
increaseRectangleWidth(square, 5);
console.log(rectangle.getArea()); // Expected: 50, Got: 50 (V)
// LSP Violation, increaseRectangleWidth() changed both width and height of the square, unexpected behavior.
console.log(square.getArea()); //Expected: 50, Got: 100 (X)
رفع نقض
کد اصلاحشده اکنون با اطمینان از اینکه اشیاء سوپرکلاس «Shape» را میتوان با اشیاء زیرکلاسهای «Rectangle» و «Square» جایگزین کرد بدون اینکه صحت ناحیه محاسبهشده تحت تأثیر قرار گیرد یا عوارض جانبی ناخواستهای که رفتار برنامه را تغییر میدهند، معرفی شود، به LSP پایبند است.
برای دستیابی به این هدف، کد را به گونهای بازنویسی کردهایم که:
زیرکلاسها (مستطیل و مربع) به طور کامل پیادهسازی انتزاع ارائه شده توسط سوپرکلاس (Shape) را رعایت کنند.
روش محاسبه مساحت در سوپرکلاس به گونهای تعریف شده است که برای تمام اشکال (چه مستطیل، چه مربع و چه اشکال احتمالی دیگر در آینده) به درستی کار کند.
هیچ منطق اضافی خاصی در زیرکلاسها وجود ندارد که رفتار غیرمنتظرهای را هنگام جایگزینی با سوپرکلاس ایجاد کند.
با این اصلاحات، اطمینان حاصل میشود که کد ما انعطافپذیرتر، قابل نگهداریتر و قابل اعتمادتر است و از نقضهای احتمالی LSP که میتواند منجر به خطاها یا نتایج غیرمنتظره شود، اجتناب میکند.
تعاریف:
abstract class Shape {
public abstract getArea(): number;
}
class Rectangle extends Shape {
private _width: number;
private _height: number;
constructor (width: number, height: number) {
super();
this._width = width;
this._height = height;
}
getArea (): number { return this._width * this._height; }
}
class Square extends Shape {
private _side: number;
constructor (side: number) {
super();
this._side = side;
}
getArea (): number { return this._side * this._side; }
}
function displayArea (shape: Shape): void {
console.log(shape.getArea());
}
استفاده:
const rectangle: Rectangle = new Rectangle(5, 10);
const square: Square = new Square(5);
// The rectangle's area is correctly calculated
displayArea(rectangle); // Expected: 50, Got: 50 (V)
// The square's area is correctly calculated
displayArea(square); // Expected: 25, Got: 25 (V)
4.اصل تفکیک اینترفیس (ISP) Interface Segregation Principle
اصل تفکیک اینترفیس (ISP) بر اهمیت ایجاد اینترفیسهای اختصاصی به جای یک اینترفیس کلی تأکید میکند.
این رویکرد با تمرکز روی کلاسها بر اساس نیازمندی ها، سناریوهایی را از بین میبرد که در آن ، یک کلاس باید متدهایی را پیادهسازی کند که در واقع استفاده نمیکند یا نیازی به آنها ندارد.
با استفاده از اصل تفکیک اینترفیس، سیستمهای نرمافزاری را میتوان به شیوهای بسیار انعطافپذیرتر، قابل درکتر و راحتتر پیاده سازی کرد. به مثال زیر نگاهی بیندازیم.
نقض اصل
قانون ISP در اینجا نقض میشود زیرا رُبات باید تابع eat() را اجرا کند که کاملاً غیرضروری است.
interface Worker {
work(): void;
eat(): void;
}
class Developer implements Worker {
public work(): void {
console.log('Coding..');
}
public eat(): void {
console.log('Eating..');
}
}
class Robot implements Worker {
public work(): void {
console.log('Building a car..');
}
// ISP Violation: Robot is forced to implement this function even when unnecessary
public eat(): void {
throw new Error('Cannot eat!');
}
}
رفع نقض
مثال زیر راه حلی برای مشکلی است که قبلاً با آن مواجه شدیم را نشان می دهد. اکنون رابط ها مختصرتر و اختصاصی هستند و به کلاس های زیر مجموعه اجازه می دهند فقط توابعی را که برای آنها مرتبط هستند پیاده سازی کنند.
interface Workable {
work(): void;
}
interface Eatable {
eat(): void;
}
class Developer implements Workable, Eatable {
public work(): void {
console.log('Coding..');
}
public eat(): void {
console.log('Eating...');
}
}
class Robot implements Workable {
public work(): void {
console.log('Building a car..');
}
// No need to implement eat(), adhering ISP.
}
ISP قبل و بعد:
5. اصل وارونگی وابستگی (DIP) Dependency Inversion Principle
اصل وارونگی وابستگی (DIP) آخرین اصل از اصول S.O.L.I.D است که با استفاده از انتزاع بر کاهش وابستگی بین اجزای سطح پایین (مانند خواندن/نوشتن داده) و اجزای سطح بالا (مسئول عملیات کلیدی) تمرکز دارد.
DIP برای طراحی نرم افزارهایی که در برابر تغییر منعطف، مدولار و به راحتی قابل به روز رسانی هستند، ضروری است.
نکات کلیدی DIP:
- اجزای سطح بالا نباید به اجزای سطح پایین وابسته باشند. هر دو باید به انتزاعات وابسته باشند. به این معنی که عملکرد برنامه نباید به پیادهسازیهای خاص متکی باشد تا سیستم انعطافپذیرتر و جایگزینی یا بهروزرسانی پیادهسازیهای سطح پایین آسانتر شود.
- انتزاعات نباید به جزئیات وابسته باشند. جزئیات باید به انتزاعات وابسته باشند. این امر طراحی را به سمت تمرکز بر آنچه مورد نیاز است به جای چگونگی انجام عملیات هدایت میکند.
نقض اصل وارونگی وابستگی (DIP)
بیایید با یک مثال به بررسی نقض اصل وارونگی وابستگی (DIP) بپردازیم.
کلاس MessageProcessor (ماژول سطح بالا) وابستگی نزدیکی به کلاس FileLogger (ماژول سطح پایین) دارد. این وابستگی نقض DIP است، زیرا به لایه انتزاعی تکیه نمیکند، بلکه به پیادهسازی یک کلاس مشخص متکی است.
علاوه بر این، نقضی از اصل «باز-بسته» (OCP) نیز وجود دارد. اگر بخواهیم مکانیزم لاگگیری را به گونهای تغییر دهیم که به جای فایل، دادهها را در پایگاه داده ثبت کند، ناچار به تغییر مستقیم تابع MessageProcessor خواهیم بود.
import fs from 'fs';
// Low Level Module
class FileLogger {
logMessage(message: string): void {
fs.writeFileSync('somefile.txt', message);
}
}
// High Level Module
class MessageProcessor {
// DIP Violation: This high-level module is is tightly coupled with the low-level module (FileLogger), making the system less flexible and harder to maintain or extend.
private logger = new FileLogger();
processMessage(message: string): void {
this.logger.logMessage(message);
}
}
رفع نقض DIP با استفاده از انتزاع
کد اصلاح شده زیر تغییرات مورد نیاز برای پیروی از اصل وارونگی وابستگی (DIP) را نشان می دهد. برخلاف مثال قبلی که کلاس سطح بالا MessageProcessor یک ویژگی خصوصی از کلاس سطح پایین مشخص FileLogger داشت، اکنون یک ویژگی خصوصی از نوع Logger دارد که واسطی برای نشان دادن لایه انتزاع است.
این رویکرد بهتر با کاهش وابستگی بین کلاسها، کد را بسیار مقیاسپذیرتر و قابل نگهداریتر میکند.
تعاریف:
import fs from 'fs';
// Abstraction Layer
interface Logger {
logMessage(message: string): void;
}
// Low Level Module #1
class FileLogger implements Logger {
logMessage(message: string): void {
fs.writeFileSync('somefile.txt', message);
}
}
// Low Level Module #2
class ConsoleLogger implements Logger {
logMessage(message: string): void {
console.log(message);
}
}
// High Level Module
class MessageProcessor {
// Resolved: The high level module is now loosely coupled with the low level logger modules.
private _logger: Logger;
constructor (logger: Logger) {
this._logger = logger;
}
processMessage (message: string): void {
this._logger.logMessage(message);
}
}
استفاده:
const fileLogger = new FileLogger();
const consoleLogger = new ConsoleLogger();
// Now the logging mechanism can be easily replaced
const messageProcessor = new MessageProcessor(consoleLogger);
messageProcessor.processMessage('Hello');
DIP قبل و بعد:
نتیجهگیری
با به کارگیری اصول S.O.L.I.D، توسعهدهندگان میتوانند از مشکلات رایجی مانند وابستگیهای شدید، کمبود انعطافپذیری، قابلیت استفاده مجدد ضعیف کد و دشواریهای کلی در نگهداری سیستمهای نرمافزاری در هر مقیاسی اجتناب کنند. تسلط بر این اصول گام دیگری برای تبدیل شدن به یک مهندس نرمافزار بهتر است.