Software Architecture

هماهنگ‌سازی برنامه‌نویسی شیءگرا و تابعی: اعمال اصول SOLID در برنامه‌نویسی تابعی

در نگاه اول، پارادایم‌های برنامه‌نویسی شیءگرا (OOP) و برنامه‌نویسی تابعی (FP) به نظر می‌رسد که قطب‌های متضاد باشند. OOP بر حالت‌های قابل تغییر، ارث‌بری و کپسوله‌سازی تکیه دارد، در حالی که FP طرفدار عدم تغییرپذیری، توابع خالص و تبدیل داده‌هاست. با این حال، هدف اصلی هر دو یکسان است: ساختن نرم‌افزارهای قابل نگهداری، مقیاس‌پذیر و قابل تست. اصول SOLID که در ابتدا برای OOP تدوین شده‌اند، چارچوبی محکم برای این هدف ارائه می‌دهند. وقتی با دقت اعمال شوند، به طور شگفت‌انگیزی با مفاهیم تابعی همسو می‌شوند و رویکردی ترکیبی ارائه می‌دهند که نقاط قوت هر دو جهان را به حداکثر می‌رساند.

اصل مسئولیت واحد (SRP): یک تابع، یک کار

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

// بد: یک تابع که کار زیادی انجام می‌دهد
function processOrder(order) {
    validateOrder(order);
    calculateTax(order);
    sendEmail(order.customer);
    updateDatabase(order);
}

// خوب: توابع خالص کوچک و متمرکز
const validateOrder = (order) => order.items.length > 0;
const calculateTax = (amount) => amount * 0.08;
const createInvoice = (order, tax) => ({ ...order, tax });

function processOrder(order) {
    if (!validateOrder(order)) throw new Error("Invalid Order");
    const tax = calculateTax(order.total);
    const invoice = createInvoice(order, tax);
    // اثرات جانبی به صورت جداگانه یا از طریق مونادها مدیریت می‌شوند
    return invoice;
}

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

اصل باز/بسته (OCP): گسترش رفتار بدون اصلاح

OCP بیان می‌کند که موجودیت‌ها باید برای گسترش باز باشند اما برای اصلاح بسته. در FP، ما این را از طریق توابع مرتبه بالاتر و الگوهای استراتژی که به عنوان پارامترهای تابع پیاده‌سازی شده‌اند، به دست می‌آوریم. به جای زیرکلاس کردن یک کلاس `Shape` برای افزودن یک `Square` جدید، ما یک تابع محاسبه را به عنوان آرگومان منتقل می‌کنیم.

// رویکرد OOP نیاز به اصلاح کلاس پایه یا ایجاد زیرکلاس‌ها دارد
// رویکرد FP: از ترکیب و پارامترهای تابع استفاده کنید

const areaCalculator = (shape) => {
    switch (shape.type) {
        case 'circle':
            return Math.PI * shape.radius ** 2;
        case 'square':
            return shape.side ** 2;
        case 'rectangle':
            return shape.width * shape.height;
        default:
            throw new Error("Unknown shape");
    }
};

// برای افزودن یک 'triangle'، تابع محاسبه‌گر را اصلاح می‌کنید.
// برای پایبندی سخت‌گیرانه به OCP، می‌توانیم از یک جدول جستجو استفاده کنیم:

const areaStrategies = {
    circle: (s) => Math.PI * s.radius ** 2,
    square: (s) => s.side ** 2,
    triangle: (s) => 0.5 * s.base * s.height // گسترش جدید
};

const getArea = (shape) => areaStrategies[shape.type](shape);

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

اصل جایگزینی لیسکوف (LSP): ایمنی نوع و قراردادها

LSP اطمینان می‌دهد که اگر `S` یک زیرنوع از `T` باشد، سپس `T` می‌تواند بدون تغییر درستی برنامه توسط `S` جایگزین شود. در زبان‌های تابعی، LSP اغلب از طریق سیستم‌های نوع قوی و انواع داده جبری برآورده می‌شود. اگر یک تابع انتظار یک `Animal` را دارد و شما یک `Dog` را منتقل می‌کنید، تابع باید همچنان به درستی رفتار کند زیرا `Dog` نیازمندی‌های ساختاری یا نامی `Animal` را برآورده می‌کند.

// مثال TypeScript که LSP را از طریق تایپینگ ساختاری نشان می‌دهد
interface Animal {
    name: string;
    eat(): void;
}

function feed(animal: Animal) {
    console.log(`Feeding ${animal.name}`);
    animal.eat();
}

class Dog implements Animal {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    eat(): void {
        console.log(`${this.name} is eating kibble`);
    }
    // Dog می‌تواند به طور ایمن به feed() منتقل شود
}

در FP خالص، ما با تکیه بر ترکیب و محدودیت‌های نوع صریح، از تله‌های ارث‌بری اجتناب می‌کنیم. اگر یک تابع `f: A -> B` باشد، هر تابعی که `A` را می‌پذیرد و `B` را برمی‌گرداند یک جایگزین معتبر است، به شرطی که به همان محدودیت‌های اثرات جانبی پایبند باشد.

اصل جداسازی رابط (ISP): رابط‌های ریزدانه

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

به جای انتقال یک شیء بزرگ `UserManager` با 20 متد که فقط 2 مورد از آن‌ها مورد نیاز است، ما دو تابع خاص مورد نیاز را منتقل می‌کنیم:

// بد: اتصال به یک شیء بزرگ
function renderProfile(userManager: UserManager) {
    const user = userManager.getUser();
    const photos = userManager.getPhotos();
    // ...
}

// خوب: اتصال شل از طریق توابع خاص
function renderProfile(
    getUser: () => User,
    getPhotos: (userId: string) => Photo[]
) {
    const user = getUser();
    const photos = getPhotos(user.id);
    // ...
}

این رویکرد اطمینان می‌دهد که `renderProfile` فقط به رفتار دقیق مورد نیاز خود وابسته است، که آن را برای تست و اصلاح آسان‌تر می‌کند.

اصل وارونگی وابستگی (DIP): انتزاع بر پیاده‌سازی

DIP بیان می‌کند که ماژول‌های سطح بالا نباید به ماژول‌های سطح پایین وابسته باشند؛ هر دو باید به انتزاعات وابسته باشند. در FP، ما این را با وابسته شدن به انواع (انتزاعات) و تزریق پیاده‌سازی‌های عینی (توابع) در زمان اجرا به دست می‌آوریم. این هسته توسعه مبتنی بر تست (TDD) در زبان‌های تابعی است.

// ماژول سطح بالا که به یک انتزاع (نوع) وابسته است
type DataStore = {
    save: (item: Item) => Promise;
    fetch: (id: string) => Promise;
};

function processItem(store: DataStore, item: Item) {
    // منطقی که فقط به رابط DataStore وابسته است
    return store.save(item);
}

// ماژول‌های سطح پایین (پیاده‌سازی‌ها)
const memoryStore: DataStore = {
    save: async (item) => { /* ... */ },
    fetch: async (id) => { /* ... */ }
};

const dbStore: DataStore = {
    save: async (item) => { /* ... */ },
    fetch: async (id) => { /* ... */ }
};

// استفاده از تابع سطح بالا با انتزاعات مختلف
processItem(memoryStore, item1);
processItem(dbStore, item2);

با تعریف `DataStore` به عنوان یک مستعار نوع یا رابط، ما یک انتزاع ایجاد می‌کنیم. تابع `processItem` به این انتزاع وابسته است، نه به پیاده‌سازی خاص. این به ما امکان می‌دهد بدون اصلاح منطق کسب‌وکار، مخزن داده را برای تست یا تولید تعویض کنیم.

نتیجه‌گیری: یک رویکرد ترکیبی برای معماری محکم

اعمال اصول SOLID در برنامه‌نویسی تابعی درباره اجبار الگوهای OOP به کد FP نیست، بلکه درک اهداف طراحی زیربنایی است: ماژولاریت، قابلیت تست و نگهداری. با بهره‌گیری از توابع خالص، عدم تغییرپذیری و سیستم‌های نوع قوی، می‌توانیم روح SOLID را برآورده کنیم و در عین حال از مزایای کد تابعی لذت ببریم. نتیجه نرم‌افزاری است که استدلال، تست و گسترش آن آسان‌تر است، صرف‌نظر از پارادایمی که انتخاب می‌کنیم بر آن تأکید کنیم.

هنگامی که معماری سیستم بعدی خود را طراحی می‌کنید، این اصول را نه به عنوان قوانین سفت و سخت، بلکه به عنوان راهنماهایی در نظر بگیرید که به شما کمک می‌کنند اجزایی بسازید که به طور هماهنگ با هم کار کنند. چه از JavaScript، TypeScript، Scala یا Rust استفاده کنید، ایده اصلی باقی می‌ماند: برای تغییر طراحی کنید و کد شما در برابر نیازهای در حال تکامل مقاوم باقی خواهد ماند.

Share: