چگونه اصول برنامه‌نویسی تابعی می‌تواند به ساخت برنامه‌های وب کارآمدتر، خواناتر و قابل‌نگهداری‌تر کمک کند؟

چگونه اصول برنامه‌نویسی تابعی می‌تواند به ساخت برنامه‌های وب کارآمدتر، خواناتر و قابل‌نگهداری‌تر کمک کند؟

برنامه‌نویسی تابعی (Functional Programming)

نکات کلیدی

  • نگه‌داشتن یک وضعیت داخلیِ قابل‌تغییر (mutable) دشوار است. هر بار که با یک برنامه تعامل می‌کنیم، زمینه‌ی تعاملات بعدی را تغییر می‌دهیم.

  • برنامه‌نویسی شی‌گرا (Object Oriented Programming یا OOP) و برنامه‌نویسی تابعی تلاش می‌کنند برای مدیریت نگهداشت‌پذیری و پیچیدگی نرم‌افزار راه‌حل ارائه دهند. OOP پیچیدگی را درون کپسول پنهان می‌کند، در حالی که FP روی داده و دگرگونی‌های آن تمرکز دارد.

  • مفاهیم برنامه‌نویسی تابعی پیش‌بینی‌پذیری را بهتر می‌کنند، استفاده‌مجدد از کد را ترویج می‌دهند و اثرات جانبی را مدیریت می‌کنند. تأکید FP بر تغییرناپذیری و ترکیب‌پذیری به سیستم‌های مقاوم‌تر و نگهداشت‌پذیرتر منجر می‌شود.

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

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

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

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

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

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

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

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

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

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

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

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

برنامه‌نویسی تابعی رویکردی کاملاً متفاوت دارد. روی داده و دگرگونی‌های آن با استفاده از توابع خالص تمرکز می‌کند، توابعی که به هیچ زمینه سراسری وابسته نیستند. در برنامه‌نویسی تابعی، وضعیت‌ها تغییرناپذیرند، مثل اتم‌های تجزیه‌ناپذیر. توابع این اتم‌ها را تبدیل می‌کنند و عمل‌های ساده را به عملیات پیچیده‌تر ترکیب می‌کنند. این توابع «خالص» هستند، یعنی به بخش‌های دیگر سیستم وابسته نیستند.

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

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

در نهایت، استفاده از داده‌های تغییرناپذیر و توابع خالص، موازی‌سازی (parallelisation) کارها روی چند CPU و چند ماشین در شبکه را به شکل چشمگیری ساده می‌کند. به همین دلیل، بسیاری از راه‌حل‌های موسوم به «big data» معماری‌های تابعی را پذیرفته‌اند.

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

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

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

در ادامه، بررسی می‌کنیم برنامه‌نویسی تابعی چطور می‌تواند به توسعه وب کمک کند (نمونه‌کدها در Kotlin ارائه می‌شوند). این رویکرد می‌تواند فرایند را ساده‌تر کند و در عین حال کدی تولید کند که دسترس‌پذیرتر، قابل فهم‌تر و نگهداشت‌پذیرتر باشد. و بخش سرگرم‌کننده را هم فراموش نکنیم: برنامه‌نویسی را لذت‌بخش‌تر هم می‌کند!

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

باید توابع و شکل داده‌ای را که روی آن کار می‌کنند تعریف کنیم و مطمئن شویم می‌توانند با هم ترکیب شوند. در مقایسه با رویکرد ریلکسِ وضعیت‌های سراسری و singletonها، این‌ها محدودیت‌های سخت‌گیرانه‌تری هستند و گاهی برنامه را پیچیده‌تر می‌کنند. اما از طرف دیگر، وقتی کامپایل شد، یک برنامه تابعی احتمالاً باگ‌های کمتری دارد چون خیلی از منابع بالقوه خطا از همان ابتدا حذف شده‌اند.

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

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

تفکر سنتی برنامه‌نویسی به رویه‌ها، توابع و متدها به چشم تکه‌های کدی نگاه می‌کند که کاری انجام می‌دهند. در برنامه‌نویسی تابعی، اتخاذ یک نگاه کمی متفاوت و ریاضی‌وار مفید است: «توابع خالص موجودیت‌هایی هستند که یک ورودی را به یک خروجی تبدیل می‌کنند.»

بیایید برای نشان دادن مفهوم، با چند تابع خالص ساده در Kotlin شروع کنیم.

fun celsiusToFahrenheit(celsius: Double): Double =

celsius * ۹.۰ / ۵.۰ + ۳۲.۰

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

در Kotlin، می‌توانیم نوع هر تابع را با یک پیکان نشان دهیم. هر تابع را می‌توان به صورت یک پیکان از نوع A به نوع B توصیف کرد، که A و B می‌توانند یکسان هم باشند.

A -> B

در اینجا تابع یک Double را به‌عنوان آرگومان می‌گیرد و یک Double دیگر برمی‌گرداند که دمای تبدیل‌شده است. می‌توانیم نوع آن را (Double) -> Double توصیف کنیم. این سینتکس کوتاهِ پیکانی برای نوع توابع یکی از جزئیات کوچکی است که کار کردن تابعی در Kotlin را لذت‌بخش می‌کند. این را با Java مقایسه کنید که معادلش می‌شود Function<Double, Double>، به‌علاوه انواع دیگر برای تعداد پارامترهای متفاوت مثل BiFunction، Supplier و Consumer.

توجه کنید که تعداد تقریباً بی‌نهایتی از توابع، نوع و امضای یکسان دارند. مثلاً این تابع هم امضای مشابهی دارد:

fun square(x: Double): Double = x * x

حالا تصور کنید علاوه بر تابعی از A به B، یک تابع دیگر هم داریم که از B به C می‌رود و یک نوع جدید، یعنی C، برمی‌گرداند.

چگونه اصول برنامه‌نویسی تابعی می‌تواند به ساخت برنامه‌های وب کارآمدتر، خواناتر و قابل‌نگهداری‌تر کمک کند؟

اولین نمونه‌ی ترکیب (composition) این است: «می‌توانیم این دو تابع را با هم ترکیب کنیم؟» «بله! می‌توانیم!» باید یاد بگیریم با توابع طوری رفتار کنیم که انگار قطعه‌های داده هستند.

fun compose(fn1: (A)->B, fn2: (B)->C): (A)->C = ???

برای پیاده‌سازی این نوع ترکیب، باید تابع دوم را روی نتیجه‌ی تابع اول اعمال کنیم. در Kotlin این‌طور تعریف می‌شود:

fun <A,B,C> compose(fn1: (A)->B, fn2: (B)->C): (A)->C =
{a -> fn2(fn1(a))}

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

چگونه اصول برنامه‌نویسی تابعی می‌تواند به ساخت برنامه‌های وب کارآمدتر، خواناتر و قابل‌نگهداری‌تر کمک کند؟

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

val isInLeapYear = compose(LocalDate::parse, LocalDate::isLeapYear)
isInLeapYear("۲۰۲۴-۰۲-۰۱") //true

در این مثال، compose تابع LocalDate::parse را که یک String را به LocalDate تبدیل می‌کند، با LocalDate::isLeapYear ترکیب می‌کند که بررسی می‌کند LocalDate داده‌شده در سال کبیسه هست یا نه. تابع حاصل، یعنی isInLeapYear، مستقیماً یک String (تاریخ) را ورودی می‌گیرد و یک Boolean برمی‌گرداند که نشان می‌دهد سال کبیسه است یا نه.

راه دیگری برای رسیدن به همین نتیجه، استفاده از let است، یکی از scope functionهای Kotlin.

با scope functionها، مثال قبلی می‌تواند این‌طور نوشته شود:

"۲۰۲۴-۰۲-۰۱"

.let(LocalDate::parse)
.let(LocalDate::isLeapYear) //true

مزیت استفاده از let در Kotlin در خوانایی آن و ترویج تغییرناپذیری است. با زنجیره کردن تبدیل‌ها داخل بلوک‌های let، مسیر پیشروی از نوع A به B به C آشکار می‌شود و شفافیت و سادگی کد را بالا می‌برد.

توجه کنید که می‌توانید با scope functionها از lambda هم استفاده کنید، اما استفاده از function referenceها نیت را حتی واضح‌تر نشان می‌دهد.

ترکیب توابع، سنگ‌بنای برنامه‌نویسی تابعی است و یک تغییر جدی در زاویه دید ایجاد می‌کند: توابع را نه صرفاً واحدهای کدی که کاری را اجرا می‌کنند، بلکه موجودیت‌های درجه‌اول (first-class) می‌داند. موجودیت‌هایی که می‌توان آن‌ها را به‌عنوان آرگومان پاس داد، از توابع دیگر برگرداند و به روش‌های مختلف ترکیب کرد. این رویکرد نانِ شبِ برنامه‌نویس تابعی است و ابزار حل شیکِ مسائل پیچیده را غنی‌تر می‌کند.

تزریق وابستگی تابعی

تصور کنید تابعی داریم که یک کاربر را از پایگاه‌داده می‌گیرد. این تابع به دو چیز نیاز دارد (یادتان باشد در برنامه‌نویسی تابعی وابستگی پنهان یا singleton نداریم): یک اتصال به پایگاه‌داده و یک شناسه کاربر.

fun fetchUser(db: DbConnection, userId: UserId): User = // some code

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

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

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

یک راه‌حل تمیز برای این مشکل رایج، اعمال جزئی (partial application) تابع است. می‌توانیم اول فقط یک پارامتر را بدهیم (مثلاً اتصال پایگاه‌داده)، و خروجی‌اش یک تابع جدید است که فقط به پارامتر باقی‌مانده (شناسه کاربر) نیاز دارد تا کار کند و نتیجه موردنظر را برگرداند. این شبیه شکستن تابع به دو مرحله است: اول پارامتر اول را می‌دهیم و یک تابع جدید می‌گیریم که برای تکمیل فرایند و برگرداندن پاسخ، پارامتر دوم را می‌خواهد.

این مفهوم ممکن است اول پیچیده به نظر برسد، اما یک مثال کد روشن‌ترش می‌کند:

fun userFetcherBuilder(db: DbConnection): (UserId)->User =

{ id: UserId -> fetchUser(db, id) }

تابع userFetcherBuilder اتصال پایگاه‌داده را می‌گیرد اما کاربر برنمی‌گرداند. نتیجه‌اش یک تابع دیگر است که با دادن شناسه کاربر، کاربر را برمی‌گرداند. اگر این برایتان مبهم است، دوباره به کد و امضا نگاه کنید.

این تکنیک را می‌توان برای همه توابعی که دو پارامتر می‌گیرند تعمیم داد. یعنی تابعی داریم که A و B را می‌گیرد تا C را برگرداند. ما تابعی می‌خواهیم که A را بگیرد و تابع دیگری برگرداند که B را بگیرد تا C را برگرداند.

fun <A,B,C> partialApply(a: A, operation: (A, B)->C): (B)->C =
{ b: B -> operation(a, b) }

حالا می‌توانیم تابعمان را خلاصه‌تر بازنویسی کنیم:

fun userFetcherBuilder(db: DbConnection): (UserId)->User =

partialApply(db, ::fetchUser)

چطور از این اعمال جزئی برای حل مشکل اولیه استفاده می‌کنیم؟

از همان تابع اصلی که کاربر را از پایگاه‌داده می‌گیرد شروع می‌کنیم. سپس وقتی به زیرساخت دسترسی داریم، اتصال پایگاه‌داده را یک بار اعمال می‌کنیم (partial application) قبل از این‌که وارد منطق دامنه شویم.

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

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

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

چگونه اصول برنامه‌نویسی تابعی می‌تواند به ساخت برنامه‌های وب کارآمدتر، خواناتر و قابل‌نگهداری‌تر کمک کند؟

در این مثال، partialApply یک تابع مرتبه‌بالا (higher-order function) است که هم ورودی‌اش تابع است و هم خروجی‌اش. این تابع، پارامتر اول تابع اصلی (dbConnection) را از قبل می‌گیرد و یک تابع جدید برمی‌گرداند (fetchUser). تابع جدید فقط به پارامتر باقی‌مانده (UserId) نیاز دارد تا اجرا شود.

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

کلاس‌های قابل‌فراخوانی

این رویکرد شیک و عملی است، اما فکر کردن به توابع به‌شکل انتزاعی گاهی سخت است. Kotlin راهی ساده‌تر ارائه می‌دهد: استفاده از شیءها.

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

data class UserFetcher(val db: DbConnection) : (UserId) -> User? {

override fun invoke(id: UserId): User = fetchUser(db, id)

}

در این مثال، UserFetcher یک data class است (اما همچنین یک تابع هم هست!) که DbConnection را به‌عنوان پارامتر سازنده می‌گیرد و از نوع تابع (UserId) -> User ارث‌بری می‌کند.

وقتی از یک نوع تابع ارث‌بری می‌کنیم، باید متد invoke کلاس را تعریف کنیم که همان امضای تابعی را دارد که از آن ارث برده‌ایم. در مورد ما، این تابع شناسه کاربر را ورودی می‌گیرد و یک کاربر برمی‌گرداند.

UserFetcher را می‌توان دقیقاً مثل یک تابع معمولی استفاده کرد: یک UserId می‌گیرد و یک User برمی‌گرداند. این رویکرد کد را ساده می‌کند و برای تازه‌کارهای برنامه‌نویسی تابعی مسیر یادگیری نرم‌تری می‌سازد، چون مفاهیم آشنای شی‌گرا را با پارادایم تابعی ترکیب می‌کند.

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

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

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

این راهبرد نیاز به interfaceها، کلاس‌های انتزاعی و ساختارهای پیچیده دیگر را کم می‌کند، کد را ساده‌تر می‌کند و اتصال اضافی (coupling) را حذف می‌کند.

یک مثال از وب‌سرور

بیایید این را با یک مثال عملی در Kotlin نشان دهیم؛ این‌که چطور می‌توانیم همه مفاهیمی را که تا اینجا دیدیم به کار بگیریم تا یک برنامه وب بسازیم که اطلاعاتی درباره «کاربران» ما نشان دهد. این برنامه باید شناسه کاربر را از مسیر HTTP بگیرد و بعد جزئیات را از پایگاه‌داده واکشی کند. ما از Ktor استفاده می‌کنیم، یک فریم‌ورک پرکاربرد برای برنامه‌های وب در Kotlin.

کار را با پیاده‌سازی صفحه‌ای شروع می‌کنیم که جزئیات کاربر را نشان دهد. URI این صفحه /user/{userId} خواهد بود و جزئیات کاربر با همان userId را که از پایگاه‌داده گرفته شده نمایش می‌دهد.

“Hello World” در Ktor این‌طوری است:

چگونه اصول برنامه‌نویسی تابعی می‌تواند به ساخت برنامه‌های وب کارآمدتر، خواناتر و قابل‌نگهداری‌تر کمک کند؟

این خوب و واضح است، اما چطور API را به شکل تابعی پیاده کنیم؟

اگر با چشم تابعی به نحوه کار وب‌سرور نگاه کنیم، یک تابع می‌بینیم که یک درخواست HTTP (معمولاً از مرورگر کاربر) را به یک پاسخ HTTP تبدیل می‌کند (مثلاً با HTML صفحه).

تبدیل از درخواست (Request) به پاسخ (Response)

چطور این کد پیاده کنیم؟ بیایید به این فکر کنیم که یک تابع از نوع Request -> Response برای تولید صفحه‌ای با جزئیات کاربر باید چه کاری انجام دهد.

با توجه به جریان داده، سفر از یک درخواست HTTP شروع می‌شود که شناسه کاربر را دارد. این شناسه ممکن است در بخش‌های مختلف درخواست جاسازی شده باشد: یک query parameter، بخشی از مسیر URL، یا عنصر دیگری. پس گام اول استخراج آن است.

private fun getId(parameters: Parameters): Outcome<Int> =
parameters["id"]?.toIntOrNull()

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

این‌طور می‌توانیم UserFetcher و یک اتصال پایگاه‌داده را استفاده کنیم تا تابع fetchUser مورد نیازمان را به دست آوریم:

val dbConnection = DbConnection(/* initialization */)

val fetchUser: (UserId)->User? = UserFetcher(dbConnection)

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

تکنیک‌های دیگری (که در کتاب بحث شده‌اند) می‌توانند ناخالصی را حتی دقیق‌تر محدود و علامت‌گذاری کنند. مثلاً الگوهای برنامه‌نویسی تابعی مثل monads یا algebraic data types می‌توانند در مدیریت بهتر اثرات جانبی کمک کنند. اما به‌عنوان گام اول، همین رویکرد هم نسبت به یک سبک ریلکس‌تر درباره خلوص، یک بهبود بزرگ است.

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

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

fun userHtml(user: User): HtmlContent =

HtmlContent(HttpStatusCode.OK) {

body(“Welcome, ${user.name}“)

}

همچنین باید یک صفحه HTML برای نمایش خطا تولید کنیم اگر کاربر در پایگاه‌داده ما وجود نداشته باشد.

fun userNotFound(): HtmlContent =
HtmlContent(HttpStatusCode.NotFound) {
body { “User not found!” }
}

در نهایت، می‌توانیم تابعی بسازیم که همه توابع بالا را زنجیره کند و HtmlContent مورد نیاز Ktor برای نمایش صفحه را تولید کند:

fun userPage(request: ApplicationRequest): HtmlContent =

getUserId(request)
?.let(::fetchUser)

?.let(::userHtml)

?: userNotFound()

در نهایت، می‌توانیم تابع خودمان را برای route جزئیات کاربر صدا بزنیم:

get("/user/{id}") {

call.respond(userPage(call.parameters))
}

و تمام. ما اولین API تابعی‌مان را روی وب‌سرور پیاده کردیم! ساده و جمع‌وجور. کار کردن این‌طوری، یک تابع و یک نوع در هر مرحله، واقعاً لذت‌بخش نیست؟

البته این پایان مسیر نیست. هنوز می‌توانیم این کد را بهتر کنیم: مدیریت مؤثرتر اتصال پایگاه‌داده، برگرداندن خطاهای دقیق‌تر، اضافه کردن auditing و غیره.

اما به‌عنوان اولین شیرجه در دنیای تابعی، این مثال فوق‌العاده آموزنده است و حسِ این‌که از فکر کردن با «شیءهای همکار» به فکر کردن با «تبدیل‌ها» سوئیچ کنید را نشان می‌دهد.

چگونه اصول برنامه‌نویسی تابعی می‌تواند به ساخت برنامه‌های وب کارآمدتر، خواناتر و قابل‌نگهداری‌تر کمک کند؟

نتیجه‌گیری‌

بیایید سریع مرور کنیم چه کار کردیم و چطور رسیدگی به درخواست را به چهار تابع شکستیم:

  • استخراج شناسه کاربر از درخواست HTTP: گام اول شامل parse کردن درخواست HTTP برای استخراج شناسه کاربر است. بسته به ساختار درخواست، این می‌تواند شامل کار با مسیرهای URL، query parameterها یا بدنه درخواست باشد.

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

  • تبدیل داده کاربر به قالب پاسخ HTTP: بعد از گرفتن موجودیت کاربر، آن را به قالبی مناسب برای پاسخ HTTP تبدیل می‌کنیم. بسته به نیاز برنامه، این می‌تواند HTML، JSON یا هر قالب دیگری باشد.

  • تولید پاسخ HTTP: در نهایت، داده قالب‌بندی‌شده کاربر را داخل یک پاسخ HTTP کپسوله می‌کنیم و status codeها، headerها و محتوای بدنه را مناسب تنظیم می‌کنیم.

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

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

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

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

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

تزریق پرامپت (Prompt Injection) برای مدل‌های زبانی بزرگ به چه معناست؟
مدیریت کارآمد منابع با مدل‌های زبانی کوچک (SLMs) در رایانش لبه‌ای چگونه اجرایی می‌شود؟

دیدگاهتان را بنویسید

سبد خرید
علاقه‌مندی‌ها
مشاهدات اخیر
دسته بندی ها