فصل پنجم – خم شو، نشکن
زندگی درجا نمیزند؛ کدی هم که مینویسیم نباید درجا بزند. برای اینکه بتوانیم همپای این شتاب تقریباً دیوانهوار تغییرات امروز حرکت کنیم، باید تمام توانمان را صرف نوشتن کدی کنیم که تا جای ممکن «شل» و انعطافپذیر باشد. اگر چنین نکنیم، کد خیلی زود کهنه میشود، یا آنقدر شکننده میشود که تعمیرش سخت یا حتی ناممکن میشود، و در نهایت ما را در مسابقه آیندهمحور عقب میاندازد.
در Topic 11 با عنوان «قابلیت بازگشت» درباره خطرهای تصمیمهای غیرقابلبرگشت صحبت کردیم. در این فصل میخواهیم یاد بدهیم چطور تصمیمهای قابلبرگشت بگیریم تا کد در برابر دنیای نامطمئن، انعطافپذیر و سازگار باقی بماند.
در ابتدا سراغ کپِلینگ یا همان وابستگی میان بخشهای کد میرویم. Topic 28 یعنی «جدا سازی» نشان میدهد چطور مفاهیم مستقل را مستقل نگه داریم تا وابستگی به حداقل برسد.
پس از آن، میرویم به سراغ تکنیکهایی که هنگام «سر و کله زدن با دنیای واقعی» به کار میآیند. Topic 29 چهار راهبرد مختلف را بررسی میکند تا بتوانیم رخدادها را بهتر مدیریت کرده و به آنها واکنش نشان دهیم؛ چیزی که در نرمافزارهای امروز نقشی حیاتی دارد.
کدهای رویهای و شیءگرای کلاسیک گاهی بیش از اندازه به هم چسبیدهاند. در Topic 30 یعنی «دگرگونی برنامهنویسی» از مزایای سبکی شفافتر و منعطفتر، یعنی خطلولههای تابعی، بهره میبریم—even اگر زبان برنامهنویسی، مستقیماً آنها را پشتیبانی نکند.
شیوه معمول برنامهنویسی شیءگرا دام دیگری هم دارد. اگر در آن بیفتید، مجبور میشوید «مالیات وراثت» بدهید. Topic 31 این دام را بررسی میکند و راهحلهای بهتری معرفی میکند تا کد تغییرپذیر و منعطف بماند.
و البته یکی از بهترین راههای انعطافپذیر ماندن این است که «کمتر کد بنویسیم». هر تغییری در کد ممکن است راهی تازه برای تولد باگها باز کند. Topic 32 یعنی «پیکربندی» توضیح میدهد چطور میتوان جزئیات را از متن کد بیرون کشید و به جایی منتقل کرد که تغییرشان سادهتر و امنتر باشد.
تمام این تکنیکها کمک میکنند کدی بنویسید که خم میشود، اما نمیشکند.
مبحث ۲۸: کاهش وابستگی (Decoupling)
وقتی سعی میکنیم چیزی را به تنهایی برداریم، میبینیم که به همه چیز دیگر در جهان وصل است.
-- جان مویر، اولین تابستان من در سیرا
در مبحث ۸، جوهرهی طراحی خوب، ما ادعا کردیم که استفاده از اصول طراحی خوب باعث میشود کدی که مینویسید به راحتی قابل تغییر باشد. وابستگی (Coupling) دشمنِ تغییر است، زیرا چیزهایی را به هم پیوند میزند که باید به صورت موازی تغییر کنند. این موضوع تغییر را دشوارتر میکند: یا باید وقت خود را صرف پیدا کردن تمام بخشهایی کنید که نیاز به تغییر دارند، یا باید وقتتان را صرف این کنید که بفهمید چرا وقتی «فقط یک چیز» را تغییر دادید، چیزهای دیگری که به آن وابسته بودند شکستند.
وقتی در حال طراحی چیزی هستید که میخواهید مستحکم و صلب باشد، شاید یک پل یا یک برج، اجزا را به هم جفت (Couple) میکنید:
(تصویر اتصالات صلب)
این اتصالات با هم کار میکنند تا ساختار را صلب و محکم کنند. آن را با چیزی شبیه به این مقایسه کنید:
(تصویر اتصالات زنجیری)
در اینجا هیچ استحکام ساختاریای وجود ندارد: حلقههای تکی میتوانند تغییر کنند و دیگران فقط خود را با آن تطبیق میدهند.
وقتی پل طراحی میکنید، میخواهید که شکل خود را حفظ کند؛ نیاز دارید که صلب باشد. اما وقتی نرمافزاری طراحی میکنید که میخواهید آن را تغییر دهید، دقیقاً برعکس آن را میخواهید: میخواهید که انعطافپذیر باشد.
و برای انعطافپذیر بودن، اجزای منفرد باید به کمترین تعداد ممکن از اجزای دیگر وابسته (Coupled) باشند. و برای اینکه اوضاع بدتر شود، وابستگی خاصیت تعدی دارد: اگر A به B و C وابسته باشد، و B به M و N، و C به X و Y وابسته باشد، آنگاه A در واقع به B، C، M، N، X و Y وابسته است.
این یعنی یک اصل ساده وجود دارد که باید از آن پیروی کنید:
نکته ۴۴: تغییرِ کدِ مستقل (Decoupled) آسانتر است
با توجه به اینکه ما معمولاً با تیرآهن و پرچ کدنویسی نمیکنیم، «مستقلسازی کد» (Decoupling) دقیقاً چه معنایی دارد؟ در این بخش درباره موارد زیر صحبت خواهیم کرد:
- قطارهای تصادفی (Train Wrecks): زنجیرهای از فراخوانی متدها.
- جهانیسازی (Globalization): خطرات چیزهای استاتیک و سراسری.
- وراثت (Inheritance): چرا زیرکلاسسازی (Subclassing) خطرناک است.
تا حدودی این لیست مصنوعی است: وابستگی میتواند تقریباً هر زمانی که دو قطعه کد چیزی را به اشتراک میگذارند رخ دهد، بنابراین همانطور که مطالب زیر را میخوانید، حواستان به الگوهای زیربنایی باشد تا بتوانید آنها را در کد خود اعمال کنید. و مراقب برخی از علائم وابستگی باشید:
- وابستگیهای عجیب و غریب بین ماژولها یا کتابخانههای نامرتبط.
- تغییرات «ساده» در یک ماژول که در ماژولهای نامرتبط سیستم پخش میشوند یا باعث خرابی بخشهای دیگر سیستم میشوند.
- توسعهدهندگانی که از تغییر کد میترسند چون مطمئن نیستند چه چیزی ممکن است تحت تأثیر قرار گیرد.
- جلساتی که همه باید در آن شرکت کنند چون هیچکس مطمئن نیست چه کسی تحت تأثیر یک تغییر قرار خواهد گرفت.
قطارهای تصادفی (TRAIN WRECKS)
همه ما کدهایی شبیه به این دیدهایم (و احتمالاً نوشتهایم):
public void applyDiscount(customer, order_id, discount) {
totals = customer
.orders
.find(order_id)
.getTotals();
totals.grandTotal = totals.grandTotal - discount;
totals.discount = discount;
}
ما یک ارجاع به تعدادی سفارش را از شیء customer میگیریم، از آن برای پیدا کردن یک سفارش خاص استفاده میکنیم، و سپس مجموعه totals (مبالغ کل) را برای آن سفارش دریافت میکنیم. با استفاده از آن مبالغ، تخفیف را از مبلغ کل سفارش کم میکنیم و همچنین مقدار تخفیف را در آن بهروزرسانی میکنیم.
این تکه کد دارد پنج سطح از انتزاع را طی میکند، از مشتری تا مقادیر کل. در نهایت، کد سطح بالای ما باید بداند که شیء customer سفارشها (orders) را در معرض دید میگذارد، اینکه orders متدی به نام find دارد که شناسه سفارش میگیرد و یک سفارش برمیگرداند، و اینکه شیء سفارش یک شیء totals دارد که دارای getter و setter برای مبلغ کل و تخفیفهاست.
این حجم زیادی از دانش ضمنی است. اما بدتر از آن، این حجم زیادی از چیزهاست که اگر قرار باشد این کد به کار خود ادامه دهد، نمیتوانند در آینده تغییر کنند. تمام واگنهای یک قطار به هم متصل هستند، همانطور که تمام متدها و ویژگیها در یک «قطار تصادفی» (Train Wreck) به هم وصلاند.
بیایید تصور کنیم که بخش تجاری تصمیم میگیرد که هیچ سفارشی نمیتواند تخفیفی بیش از ۴۰٪ داشته باشد. کدی که این قانون را اعمال میکند را کجا باید قرار دهیم؟ ممکن است بگویید جایش در تابع applyDiscount است که همین الان نوشتیم. این قطعاً بخشی از پاسخ است. اما با وضعیتی که کد در حال حاضر دارد، نمیتوانید بدانید که این کل پاسخ است. هر قطعه کدی، در هر جایی، میتواند فیلدهای شیء totals را تنظیم کند، و اگر نگهدارندهی آن کد، این بخشنامه جدید را دریافت نکرده باشد، آن کد سیاست جدید را بررسی نخواهد کرد.
یک راه برای نگاه به این موضوع، فکر کردن درباره مسئولیتها است. مطمئناً شیء totals باید مسئول مدیریت مبالغ باشد. و با این حال نیست: واقعاً فقط یک ظرف برای مشتی فیلد است که هر کسی میتواند آنها را پرسوجو (Query) و بهروزرسانی کند.
راهحل این است که چیزی را اعمال کنیم که ما به آن میگوییم:
نکته ۴۵: بگو، نپرس (Tell, Don’t Ask)
این اصل میگوید که شما نباید بر اساس وضعیت داخلی یک شیء تصمیم بگیرید و سپس آن شیء را بهروزرسانی کنید. انجام این کار، مزایای کپسولهسازی (Encapsulation) را کاملاً از بین میبرد و با این کار، دانشِ پیادهسازی را در سراسر کد پخش میکند.
بنابراین اولین اصلاح برای «قطار تصادفی» ما این است که اعمال تخفیف را به خود شیء total واگذار کنیم:
public void applyDiscount(customer, order_id, discount) {
customer
.orders
.find(order_id)
.getTotals()
.applyDiscount(discount);
}
ما همین مشکلِ «بگو-نپرس» (TDA) را با شیء customer و orders آن داریم: ما نباید لیست سفارشهایش را بگیریم و در آنها جستجو کنیم. در عوض باید سفارشی را که میخواهیم مستقیماً از خود مشتری بگیریم:
public void applyDiscount(customer, order_id, discount) {
customer
.findOrder(order_id)
.getTotals()
.applyDiscount(discount);
}
همین موضوع برای شیء order و totals آن صدق میکند. چرا دنیای بیرون باید بداند که پیادهسازی یک سفارش از یک شیء جداگانه برای ذخیره مبالغش استفاده میکند؟
public void applyDiscount(customer, order_id, discount) {
customer
.findOrder(order_id)
.applyDiscount(discount);
}
و اینجا جایی است که احتمالاً متوقف میشویم. در این نقطه ممکن است فکر کنید که TDA ما را وادار میکند یک متد applyDiscountToOrder(order_id) به مشتریان (customers) اضافه کنیم. و اگر کورکورانه از آن پیروی کنیم، بله همین کار را میکند. اما TDA قانون طبیعت نیست؛ فقط الگویی است برای کمک به ما در تشخیص مشکلات.
در این مورد، ما با این واقعیت که یک مشتری سفارشهایی دارد، و اینکه میتوانیم یکی از آن سفارشها را با درخواست از شیء مشتری پیدا کنیم، مشکلی نداریم. این یک تصمیم عملگرایانه (Pragmatic) است. در هر برنامهای مفاهیم سطح بالای خاصی وجود دارند که جهانی هستند. در این برنامه، آن مفاهیم شامل مشتریان و سفارشها میشود. هیچ معنایی ندارد که سفارشها را کاملاً درون اشیاء مشتری پنهان کنیم: آنها وجود مستقلی برای خود دارند. بنابراین ما مشکلی با ایجاد APIهایی که اشیاء سفارش را در معرض دید میگذارند، نداریم.
قانون دیمتر (The Law of Demeter)
مردم اغلب در رابطه با وابستگی (Coupling) درباره چیزی به نام قانون دیمتر یا LoD صحبت میکنند. LoD مجموعهای از دستورالعملهاست که در اواخر دهه ۸۰ توسط ایان هلند نوشته شد. او آنها را ایجاد کرد تا به توسعهدهندگان «پروژه دیمتر» کمک کند توابع خود را تمیزتر و مستقلتر (Decoupled) نگه دارند.
LoD میگوید که متدی که در کلاس C تعریف شده است باید فقط موارد زیر را فراخوانی کند:
- سایر متدهای نمونه (Instance methods) در خودِ C
- پارامترهایش
- متدهای اشیایی که خودش ایجاد میکند (هم در Stack و هم در Heap)
- متغیرهای سراسری (Global)
در نسخه اول این کتاب، ما مدتی را صرف توصیف LoD کردیم. در طول ۲۰ سال گذشته، جذابیت آن گلِ خاص کمی رنگ باخته است. ما اکنون بند «متغیر سراسری» را دوست نداریم (به دلایلی که در بخش بعدی خواهیم گفت). همچنین متوجه شدیم که استفاده از آن در عمل دشوار است: کمی شبیه به این است که هر بار که متدی را صدا میزنید مجبور باشید یک سند حقوقی را تفسیر کنید.
با این حال، اصل آن هنوز صحیح است. ما فقط روشی تا حدی سادهتر را برای بیانِ تقریباً همان چیز توصیه میکنیم:
نکته ۴۶: فراخوانیهای متد را زنجیر نکنید
سعی کنید وقتی به چیزی دسترسی پیدا میکنید، بیش از یک «نقطه» (.) نداشته باشید. و «دسترسی به چیزی» همچنین شامل مواردی میشود که از متغیرهای میانی استفاده میکنید، مانند کد زیر:
# This is pretty poor style
amount = customer.orders.last().totals().amount;
# and so is this…
orders = customer.orders;
last = orders.last();
totals = last.totals();
amount = totals.amount;
یک استثنای بزرگ برای قانونِ «یک نقطه» وجود دارد: اگر چیزهایی که زنجیر میکنید واقعاً، واقعاً بعید است تغییر کنند، این قانون اعمال نمیشود.
در عمل، هر چیزی در برنامه خودتان باید «مستعد تغییر» در نظر گرفته شود. هر چیزی در یک کتابخانه شخص ثالث (Third-party) باید «فرار» (Volatile) در نظر گرفته شود، بهویژه اگر نگهدارندگان آن کتابخانه معروف باشند به اینکه APIها را بین نسخهها تغییر میدهند.
با این حال، کتابخانههایی که همراه زبان برنامهنویسی میآیند احتمالاً کاملاً پایدار هستند، و بنابراین ما با کدهایی مانند زیر مشکلی نداریم:
people
.sort_by {|person| person.age }
.first(10)
.map {| person | person.name }
آن کد روبی زمانی که ما نسخه اول را ۲۰ سال پیش نوشتیم کار میکرد، و احتمالاً زمانی که وارد خانه سالمندان برنامهنویسان میشویم (همین روزها...) همچنان کار خواهد کرد.
زنجیرهها و خطوط لوله (Pipelines)
در مبحث ۳۰، تبدیل برنامهنویسی، ما در مورد ترکیب توابع به صورت خطوط لوله (Pipelines) صحبت میکنیم. این خطوط لوله دادهها را تبدیل میکنند و از یک تابع به تابع بعدی پاس میدهند. این با یک «قطار تصادفی» از فراخوانی متدها یکی نیست، زیرا ما به جزئیات پنهان پیادهسازی تکیه نمیکنیم. البته این بدان معنا نیست که خطوط لوله هیچ وابستگیای ایجاد نمیکنند: میکنند. فرمت دادهی برگشتدادهشده توسط یک تابع در خط لوله باید با فرمت پذیرفتهشده توسط تابع بعدی سازگار باشد. تجربه ما این است که این شکل از وابستگی مانع بسیار کمتری برای تغییر کد نسبت به شکل معرفی شده توسط «قطارهای تصادفی» است.
شرارتهای جهانیسازی (THE EVILS OF GLOBALIZATION)
دادههای قابل دسترسی به صورت سراسری (Global)، منبعی موذی برای وابستگی بین اجزای برنامه هستند. هر تکه از دادهی سراسری طوری عمل میکند که انگار هر متد در برنامه شما ناگهان یک پارامتر اضافی دریافت کرده است: هر چه باشد، آن دادهی سراسری در داخل هر متدی در دسترس است.
متغیرهای سراسری (Globals) به دلایل زیادی کد را وابسته میکنند. بدیهیترین دلیل این است که تغییری در پیادهسازیِ آن سراسری، به طور بالقوه بر تمام کد سیستم تأثیر میگذارد. البته در عمل، تأثیر نسبتاً محدود است؛ مشکل واقعاً به این برمیگردد که بدانید آیا تمام جاهایی را که نیاز به تغییر دارند پیدا کردهاید یا نه.
دادههای سراسری همچنین زمانی که نوبت به جدا کردن کدتان میرسد، ایجاد وابستگی میکنند. صحبتهای زیادی درباره مزایای «استفاده مجدد از کد» (Reuse) شده است. تجربه ما این بوده که استفاده مجدد احتمالاً نباید دغدغه اولیه در هنگام ایجاد کد باشد، اما تفکری که صرفِ قابل استفاده مجدد کردن کد میشود، باید بخشی از روال کدنویسی شما باشد.
وقتی کدی را قابل استفاده مجدد میکنید، به آن رابطهای (Interfaces) تمیز میدهید و آن را از بقیه کدتان جدا (Decouple) میکنید. این به شما اجازه میدهد که یک متد یا ماژول را استخراج کنید بدون اینکه همه چیز دیگر را با آن به دنبال خود بکشید. و اگر کد شما از دادههای سراسری استفاده میکند، جدا کردن آن از بقیه دشوار میشود.
این مشکل را زمانی خواهید دید که برای کدی که از دادههای سراسری استفاده میکند، «تست واحد» (Unit Test) مینویسید. خودتان را در حال نوشتن مشتی کد راهاندازی (Setup) برای ایجاد یک محیط سراسری مییابید، فقط برای اینکه اجازه دهید تستتان اجرا شود.
نکته ۴۷: از دادههای سراسری (Global Data) اجتناب کنید
دادههای سراسری شامل سینگلتونها (Singletons) میشود
در بخش قبلی ما دقت کردیم که درباره «دادههای سراسری» صحبت کنیم و نه «متغیرهای سراسری». دلیلش این است که مردم اغلب به ما میگویند: «ببینید! متغیر سراسری ندارم. من همه را به عنوان دادههای نمونه (Instance data) در یک شیء Singleton یا ماژول سراسری بستهبندی کردم.»
دوباره سعی کن، زرنگخان (Skippy).
اگر تمام چیزی که دارید یک سینگلتون با مشتی متغیر نمونهی صادر شده (Exported) است، پس این هنوز هم فقط دادهی سراسری است. فقط اسم طولانیتری دارد.
سپس برخی افراد این سینگلتون را برمیدارند و تمام دادهها را پشت متدها پنهان میکنند. به جای کدنویسیِ Config.log_level، حالا میگویند Config.log_level() یا Config.getLogLevel(). این بهتر است، چون به این معنی است که دادهی سراسری شما کمی هوشمندی پشت خود دارد. اگر تصمیم بگیرید نمایش سطوح لاگ را تغییر دهید، میتوانید با نگاشت بین جدید و قدیم در APIِ کانفیگ، سازگاری را حفظ کنید. اما شما هنوز فقط همان یک مجموعه از دادههای پیکربندی را دارید.
دادههای سراسری شامل منابع خارجی میشود
هر منبع خارجیِ قابل تغییر (Mutable)، دادهی سراسری است. اگر برنامه شما از دیتابیس، مخزن داده (Datastore)، فایلسیستم، API سرویس و غیره استفاده میکند، در خطر افتادن در تلهی جهانیسازی است. باز هم راه حل این است که مطمئن شوید همیشه این منابع را پشت کدی که خودتان کنترل میکنید، بستهبندی (Wrap) میکنید.
نکته ۴۸: اگر آنقدر مهم است که سراسری باشد، آن را در یک API بستهبندی کنید
وراثت وابستگی اضافه میکند (INHERITANCE ADDS COUPLING)
سوءاستفاده از زیرکلاسسازی (Subclassing)، جایی که یک کلاس وضعیت و رفتار را از کلاس دیگری به ارث میبرد، آنقدر مهم است که ما در بخش جداگانه خودش، مبحث ۳۱، مالیات وراثت، به آن میپردازیم.
باز هم، همه چیز درباره تغییر است
تغییر کدهای وابسته (Coupled) سخت است: تغییرات در یک مکان میتواند اثرات ثانویهای در جای دیگر کد داشته باشد، و اغلب در جاهایی که به سختی پیدا میشوند و فقط یک ماه بعد در محیط عملیاتی (Production) روشن میشوند.
خجالتی نگه داشتن کدتان (Keeping your code shy): اینکه کد فقط با چیزهایی سر و کار داشته باشد که مستقیماً دربارهشان میداند، کمک میکند برنامههایتان را مستقل (Decoupled) نگه دارید، و این آنها را برای تغییر آمادهتر میکند.
مبحث ۲۹: شعبدهبازی با دنیای واقعی (Juggling the Real World)
اتفاقات همینطوری رخ نمیدهند؛ آنها را وادار به رخ دادن میکنند.
-- جان اف. کندی
در روزگار قدیم، زمانی که نویسندگان شما هنوز آن جذابیت و تیپ پسرانهشان را داشتند، کامپیوترها چندان انعطافپذیر نبودند. ما معمولاً نحوه تعامل خود با آنها را بر اساس محدودیتهای آنها تنظیم میکردیم.
امروزه، ما انتظار بیشتری داریم: کامپیوترها باید با دنیای ما یکپارچه شوند، نه برعکس. و دنیای ما شلوغ و درهمبرهم است: اتفاقات دائماً در حال رخ دادن هستند، چیزها جابجا میشوند، ما نظرمان را عوض میکنیم، و... . و برنامههایی که مینویسیم باید به نحوی بفهمند که چه کار کنند.
این بخش تماماً درباره نوشتن چنین برنامههای پاسخگویی (Responsive) است. ما با مفهوم رویداد (Event) شروع میکنیم.
رویدادها (EVENTS)
یک رویداد نشاندهنده موجود بودن اطلاعات است. ممکن است از دنیای بیرون بیاید: کاربری که دکمهای را کلیک میکند، یا بهروزرسانی قیمت سهام. ممکن است داخلی باشد: نتیجهی یک محاسبات آماده است، یا یک جستجو تمام شده. حتی میتواند چیزی به پیشپاافتادگیِ دریافت عنصر بعدی در یک لیست باشد.
منبع هر چه که باشد، اگر برنامههایی بنویسیم که به رویدادها پاسخ دهند و کارشان را بر اساس آن رویدادها تنظیم کنند، آن برنامهها در دنیای واقعی بهتر کار خواهند کرد. کاربران آنها را تعاملیتر مییابند و خود برنامهها از منابع استفاده بهتری خواهند کرد.
اما چگونه میتوانیم این نوع برنامهها را بنویسیم؟ بدون داشتن نوعی استراتژی، به سرعت خودمان را سردرگم خواهیم یافت و برنامههایمان به کلافی سردرگم از کدهای شدیداً وابسته (Tightly coupled) تبدیل میشوند.
بیایید به چهار استراتژی که کمک میکنند نگاهی بیندازیم:
- ماشینهای حالت محدود (Finite State Machines)
- الگوی ناظر (The Observer Pattern)
- انتشار/اشتراک (Publish/Subscribe)
- برنامهنویسی واکنشی و جریانها (Reactive Programming and Streams)
ماشینهای حالت محدود (FINITE STATE MACHINES)
«دیو» (Dave) میگوید که تقریباً هر هفته کدی مینویسد که از یک ماشین حالت محدود (FSM) استفاده میکند. خیلی اوقات، پیادهسازی FSM فقط چند خط کد است، اما همان چند خط به باز کردن گرههای بزرگی از آشفتگیهای احتمالی کمک میکند.
استفاده از یک FSM بسیار ساده است، و با این حال بسیاری از توسعهدهندگان از آن دوری میکنند. به نظر میرسد این باور وجود دارد که آنها دشوار هستند، یا فقط زمانی کاربرد دارند که با سختافزار کار میکنید، یا اینکه نیاز به استفاده از کتابخانههای پیچیده دارید. هیچکدام از اینها درست نیست.
آناتومی یک FSM عملگرا
یک ماشین حالت اساساً فقط یک مشخصات (Specification) است برای اینکه چگونه رویدادها را مدیریت کنیم. این ماشین شامل مجموعهای از حالتها (States) است که یکی از آنها حالت فعلی است. برای هر حالت، ما رویدادهایی را که برای آن حالت اهمیت دارند لیست میکنیم. برای هر کدام از آن رویدادها، حالت جدید سیستم را تعریف میکنیم.
برای مثال، ممکن است در حال دریافت پیامهای چندبخشی از یک وبسوکت باشیم. اولین پیام یک سرآیند (Header) است. این پیام با هر تعدادی پیامِ داده (Data) دنبال میشود و در نهایت با یک پیام پایانبندی (Trailer) تمام میشود.
این را میتوان به صورت یک FSM مثل شکل زیر نمایش داد:
(تصویر نمودار ماشین حالت ساده)
ما در «حالت اولیه» (Initial state) شروع میکنیم. اگر یک پیام Header دریافت کنیم، به حالت «در حال خواندن پیام» (Reading message) منتقل میشویم. اگر در حالی که در حالت اولیه هستیم هر چیز دیگری دریافت کنیم (خطی که با ستاره علامتگذاری شده)، به حالت «خطا» (Error) میرویم و کار تمام است.
زمانی که در حالت «در حال خواندن پیام» هستیم، میتوانیم یا پیامهای Data را بپذیریم (که در این صورت در همان حالت به خواندن ادامه میدهیم)، یا میتوانیم یک پیام Trailer بپذیریم که ما را به حالت «انجام شد» (Done) منتقل میکند. هر چیز دیگری باعث انتقال به حالت خطا میشود.
نکته جالب درباره FSMها این است که میتوانیم آنها را صرفاً به صورت داده بیان کنیم. در اینجا جدولی وجود دارد که تجزیهکننده (Parser) پیام ما را نشان میدهد:
(تصویر جدول ماشین حالت ساده)
ردیفهای جدول نشاندهنده حالتها هستند. برای اینکه بفهمید وقتی یک رویداد رخ میدهد چه کاری باید انجام دهید، ردیفِ حالت فعلی را پیدا کنید، در طول آن حرکت کنید تا به ستونِ مربوط به آن رویداد برسید؛ محتویات آن سلول، حالت جدید است.
کدی که این را مدیریت میکند به همان اندازه ساده است:
TRANSITIONS = {
initial: {header: :reading},
reading: {data: :reading, trailer: :done},
}
state = :initial
while state != :done && state != :error
msg = get_next_message()
state = TRANSITIONS[state][msg.msg_type] || :error
end
کدی که انتقال بین حالتها را پیادهسازی میکند در خط ۱۰ است. این کد با استفاده از حالت فعلی، جدول انتقالات را ایندکس میکند و سپس با استفاده از نوع پیام، انتقال مربوط به آن حالت را پیدا میکند. اگر حالت جدیدِ منطبقی وجود نداشته باشد، حالت را روی :error تنظیم میکند.
اضافه کردن اکشنها (Actions)
یک FSM خالص، مانند چیزی که الان دیدیم، یک تجزیهکننده جریان رویداد است. تنها خروجی آن حالت نهایی است. ما میتوانیم با اضافه کردن اکشنهایی که در انتقالات خاصی فعال میشوند، آن را تقویت کنیم.
برای مثال، ممکن است نیاز داشته باشیم تمام رشتههای متنی (Strings) را از یک فایل منبع استخراج کنیم. یک رشته، متنی است بین دو کوتیشن، اما یک بکاسلش (\) درون رشته، کاراکتر بعدی را Escape میکند (بیاثر میکند)، بنابراین "Ignore \"quotes\"" یک رشته واحد است.
اینجا یک FSM برای انجام این کار وجود دارد:
(تصویر ماشین حالت رشته)
این بار، هر انتقال دو برچسب دارد. بالایی رویدادی است که آن را فعال میکند، و پایینی اکشنی (Action) است که هنگام جابجایی بین حالتها انجام میدهیم.
ما این را در یک جدول بیان میکنیم، همانطور که دفعه قبل انجام دادیم. با این حال، در این مورد هر ورودی در جدول یک لیست دو عنصری شامل حالت بعدی و نام اکشن است:
TRANSITIONS = {
# current new state action to take
#--------------------------------------------------------
look_for_string: {
'"' => [ :in_string, :start_new_string ],
:default => [ :look_for_string, :ignore ],
},
in_string: {
'"' => [ :look_for_string, :finish_current_string ],
'\\' => [ :copy_next_char, :add_current_to_string ],
:default => [ :in_string, :add_current_to_string ],
},
copy_next_char: {
:default => [ :in_string, :add_current_to_string ],
},
}
ما همچنین قابلیت مشخص کردن یک انتقال پیشفرض (:default) را اضافه کردهایم، که اگر رویداد با هیچیک از انتقالات دیگر برای این حالت مطابقت نداشت، انتخاب میشود. حالا بیایید به کد نگاه کنیم:
state = :look_for_string
result = []
while ch = STDIN.getc
state, action = TRANSITIONS[state][ch] || TRANSITIONS[state][:default]
case action
when :ignore
when :start_new_string
result = []
when :add_current_to_string
result << ch
when :finish_current_string
puts result.join
end
end
این شبیه به مثال قبلی است، از این جهت که ما روی رویدادها (کاراکترهای ورودی) حلقه میزنیم و انتقالات را فعال میکنیم. اما کار بیشتری نسبت به کد قبلی انجام میدهد. نتیجه هر انتقال هم یک حالت جدید است و هم نام یک اکشن. ما از نام اکشن برای انتخاب کدی که باید قبل از بازگشت به حلقه اجرا شود، استفاده میکنیم.
این کد بسیار ابتدایی است، اما کار را انجام میدهد. انواع مختلفی وجود دارد: جدول انتقال میتواند از توابع ناشناس (Anonymous functions) یا اشارهگر به تابع برای اکشنها استفاده کند؛ میتوانید کدی که ماشین حالت را پیادهسازی میکند در یک کلاس جداگانه با وضعیت (State) خودش بستهبندی کنید، و غیره.
هیچ اجباری وجود ندارد که تمام انتقالات حالت را همزمان پردازش کنید. اگر در حال طی کردن مراحل ثبتنام یک کاربر در اپلیکیشن خود هستید، احتمالاً تعدادی انتقال وجود دارد: وارد کردن جزئیات، تأیید ایمیل، موافقت با ۱۰۷ هشدار قانونی مختلف که اپلیکیشنهای آنلاین باید بدهند، و غیره. نگهداری وضعیت (State) در یک ذخیرهساز خارجی و استفاده از آن برای پیش بردن ماشین حالت، راهی عالی برای مدیریت این نوع نیازمندیهای گردش کار (Workflow) است.
ماشینهای حالت یک شروع هستند
ماشینهای حالت توسط توسعهدهندگان کمتر از حد لازم استفاده میشوند و ما میخواهیم شما را تشویق کنیم که به دنبال فرصتهایی برای اعمال آنها باشید. اما آنها تمام مشکلات مرتبط با رویدادها را حل نمیکنند. پس بیایید به سراغ روشهای دیگری برای نگاه به مشکلاتِ شعبدهبازی با رویدادها برویم.
الگوی ناظر (THE OBSERVER PATTERN)
در الگوی ناظر، ما یک منبع رویداد داریم که مشاهدهپذیر (Observable) نامیده میشود و لیستی از کلاینتها یا ناظران (Observers) که به آن رویدادها علاقهمند هستند.
یک ناظر علاقه خود را نزدِ مشاهدهپذیر ثبت میکند، معمولاً با ارسال ارجاعی به یک تابع که باید فراخوانی شود (Callback). متعاقباً، وقتی رویداد رخ میدهد، مشاهدهپذیر لیست ناظران خود را پیمایش میکند و تابعی را که هر کدام دادهاند صدا میزند. رویداد به عنوان یک پارامتر به آن فراخوانی داده میشود.
اینجا یک مثال ساده در روبی است. ماژول Terminator برای خاتمه دادن به برنامه استفاده میشود. اما قبل از انجام این کار، به تمام ناظرانش اطلاع میدهد که برنامه قرار است خارج شود. آنها ممکن است از این اعلان برای پاکسازی منابع موقت، کامیت کردن دادهها و غیره استفاده کنند:
module Terminator
CALLBACKS = []
def self.register(callback)
CALLBACKS << callback
end
def self.exit(exit_status)
CALLBACKS.each { |callback| callback.(exit_status) }
exit!(exit_status)
end
end
Terminator.register(-> (status) { puts "callback 1 sees #{status}" })
Terminator.register(-> (status) { puts "callback 2 sees #{status}" })
Terminator.exit(99)
خروجی:
$ ruby event/observer.rb
callback 1 sees 99
callback 2 sees 99
کد زیادی برای ایجاد یک مشاهدهپذیر (Observable) لازم نیست: شما یک ارجاع تابع را به یک لیست اضافه میکنید و سپس وقتی رویداد رخ داد آن توابع را صدا میزنید. این مثال خوبی است برای اینکه چه زمانی از کتابخانه استفاده نکنید.
الگوی ناظر/مشاهدهپذیر دهههاست که استفاده میشود و به ما خدمت کرده است. بهویژه در سیستمهای رابط کاربری (UI) رایج است، جایی که از کالبکها برای اطلاع دادن به برنامه استفاده میشود که تعاملی رخ داده است.
اما الگوی ناظر یک مشکل دارد: چون هر یک از ناظران باید خود را در مشاهدهپذیر ثبت کنند، این کار باعث ایجاد وابستگی (Coupling) میشود. علاوه بر این، از آنجا که در پیادهسازیهای معمول، کالبکها به صورت خطی (Inline) و همگام (Synchronous) توسط مشاهدهپذیر اجرا میشوند، میتواند باعث گلوگاههای عملکردی شود.
این مشکل توسط استراتژی بعدی حل میشود: انتشار/اشتراک.
انتشار/اشتراک (PUBLISH/SUBSCRIBE)
الگوی انتشار/اشتراک (یا Pub/Sub)، الگوی ناظر را تعمیم میدهد و همزمان مشکلات وابستگی و عملکرد را حل میکند.
در مدل Pub/Sub، ما ناشران (Publishers) و مشترکین (Subscribers) داریم. اینها از طریق کانالها (Channels) به هم متصل میشوند. کانالها در بدنهی جداگانهای از کد پیادهسازی میشوند: گاهی یک کتابخانه، گاهی یک فرآیند (Process)، و گاهی یک زیرساخت توزیعشده. تمام این جزئیات پیادهسازی از کد شما پنهان است.
هر کانال یک نام دارد. مشترکین علاقه خود را به یک یا چند مورد از این کانالهای نامگذاری شده ثبت میکنند، و ناشران رویدادها را در آنها مینویسند. برخلاف الگوی ناظر، ارتباط بین ناشر و مشترک خارج از کد شما مدیریت میشود و پتانسیل این را دارد که غیرهمگام (Asynchronous) باشد.
اگرچه میتوانید خودتان یک سیستم Pub/Sub بسیار ابتدایی پیادهسازی کنید، احتمالاً نمیخواهید این کار را بکنید. اکثر ارائهدهندگان خدمات ابری پیشنهادات Pub/Sub دارند که به شما امکان میدهد برنامهها را در سراسر جهان متصل کنید. هر زبان محبوبی حداقل یک کتابخانه Pub/Sub خواهد داشت.
Pub/Sub تکنولوژی خوبی برای جدا سازی (Decoupling) مدیریتِ رویدادهای غیرهمگام است. این اجازه میدهد که کد اضافه یا جایگزین شود (به طور بالقوه در حالی که برنامه در حال اجراست) بدون اینکه کد موجود تغییر کند.
نکته منفی این است که دیدن آنچه در سیستمی که به شدت از Pub/Sub استفاده میکند میگذرد، میتواند دشوار باشد: نمیتوانید به یک ناشر نگاه کنید و بلافاصله ببینید کدام مشترکین درگیر یک پیام خاص هستند.
در مقایسه با الگوی ناظر، Pub/Sub نمونهای عالی از کاهش وابستگی با انتزاع کردن از طریق یک رابط مشترک (کانال) است. با این حال، این هنوز اساساً فقط یک سیستم ارسال پیام است. ایجاد سیستمهایی که به ترکیبی از رویدادها پاسخ دهند نیاز به چیزی بیشتر از این دارد، پس بیایید به راههایی نگاه کنیم که میتوانیم بُعدِ زمان را به پردازش رویداد اضافه کنیم.
برنامهنویسی واکنشی، جریانها و رویدادها (REACTIVE PROGRAMMING, STREAMS, AND EVENTS)
اگر تا به حال از یک صفحه گسترده (Spreadsheet) استفاده کرده باشید، با برنامهنویسی واکنشی آشنا هستید. اگر سلولی حاوی فرمولی باشد که به سلول دوم ارجاع میدهد، بهروزرسانی آن سلول دوم باعث میشود اولی نیز بهروزرسانی شود. مقادیر واکنش نشان میدهند وقتی مقادیری که از آنها استفاده میکنند تغییر میکنند.
فریمورکهای زیادی وجود دارند که میتوانند به این نوع واکنشگرا بودن در سطح داده کمک کنند: در قلمرو مرورگرها، React و Vue.js محبوبهای فعلی هستند (اما چون اینجا جاوااسکریپت است، احتمالاً قبل از چاپ این کتاب این اطلاعات قدیمی شده است).
واضح است که از رویدادها نیز میتوان برای فعال کردن واکنشها در کد استفاده کرد، اما لولهکشی و اتصال آنها لزوماً آسان نیست. اینجاست که جریانها (Streams) وارد میشوند.
جریانها به ما اجازه میدهند با رویدادها طوری رفتار کنیم که انگار مجموعهای از دادهها هستند. انگار لیستی از رویدادها داشتیم که وقتی رویدادهای جدید میرسیدند طولانیتر میشد. زیبایی کار در این است که میتوانیم با جریانها دقیقاً مانند هر مجموعه (Collection) دیگری رفتار کنیم: میتوانیم دستکاری کنیم، ترکیب کنیم، فیلتر کنیم و تمام کارهای دادهمحوری را که خیلی خوب بلدیم انجام دهیم. ما حتی میتوانیم جریانهای رویداد و مجموعههای معمولی را ترکیب کنیم.
و جریانها میتوانند غیرهمگام باشند، که به این معنی است که کد شما فرصت پاسخگویی به رویدادها را در لحظه ورودشان پیدا میکند.
استاندارد بالفعل فعلی برای مدیریت رویداد واکنشی در سایت http://reactivex.io تعریف شده است که مجموعهای از اصول مستقل از زبان را تعریف میکند و برخی پیادهسازیهای رایج را مستند میکند. در اینجا ما از کتابخانه RxJs برای جاوااسکریپت استفاده خواهیم کرد.
اولین مثال ما دو جریان را میگیرد و آنها را به هم زیپ (Zip) میکند: نتیجه یک جریان جدید است که هر عنصرِ آن شامل یک آیتم از جریان ورودی اول و یک آیتم از دیگری است.
در این مورد، جریان اول به سادگی لیستی از پنج نام حیوان است. جریان دوم جالبتر است: یک تایمر فاصلهدار (Interval timer) است که هر ۵۰۰ میلیثانیه یک رویداد تولید میکند. چون جریانها به هم زیپ شدهاند، نتیجه تنها زمانی تولید میشود که داده در هر دو موجود باشد، و بنابراین جریان نتیجهی ما فقط هر نیم ثانیه یک مقدار صادر میکند:
import * as Observable from 'rxjs'
import { logValues } from "../rxcommon/logger.js"
let animals = Observable.of("ant", "bee", "cat", "dog", "elk")
let ticker = Observable.interval(500)
let combined = Observable.zip(animals, ticker)
combined.subscribe(next => logValues(JSON.stringify(next)))
این کد از یک تابع لاگگیری ساده استفاده میکند که آیتمها را به لیستی در پنجره مرورگر اضافه میکند. هر آیتم با زمان بر حسب میلیثانیه از شروع برنامه برچسبگذاری شده است. این چیزی است که برای کد ما نشان میدهد:
(تصویر خروجی RxJS)
به برچسبهای زمانی توجه کنید: ما هر ۵۰۰ میلیثانیه یک رویداد از جریان دریافت میکنیم. هر رویداد شامل یک شماره سریال (ایجاد شده توسط تایمر) و نام حیوان بعدی از لیست است. با تماشای زنده آن در مرورگر، خطوط لاگ هر نیم ثانیه ظاهر میشوند.
جریانهای رویداد معمولاً با رخ دادن رویدادها پر میشوند، که دلالت بر این دارد که Observableهایی که آنها را پر میکنند میتوانند به صورت موازی اجرا شوند.
اینجا مثالی است که اطلاعات مربوط به کاربران را از یک سایت راه دور دریافت میکند. برای این کار ما از https://reqres.in استفاده میکنیم، یک سایت عمومی که رابط REST باز ارائه میدهد. به عنوان بخشی از API آن، میتوانیم با انجام یک درخواست GET به users/«id» دادههای مربوط به یک کاربر (جعلی) خاص را دریافت کنیم. کد ما کاربران با شناسه ۳، ۲ و ۱ را دریافت میکند:
import * as Observable from 'rxjs'
import { mergeMap } from 'rxjs/operators'
import { ajax } from 'rxjs/ajax'
import { logValues } from "../rxcommon/logger.js"
let users = Observable.of(3, 2, 1)
let result = users.pipe(
mergeMap((user) => ajax.getJSON(`https://reqres.in/api/users/${user}`))
)
result.subscribe(
resp => logValues(JSON.stringify(resp.data)),
err => console.error(JSON.stringify(err))
)
جزئیات داخلی کد خیلی مهم نیستند. آنچه هیجانانگیز است نتیجه است، که در اسکرینشات زیر نشان داده شده:
(تصویر سه کاربر)
به برچسبهای زمانی نگاه کنید: سه درخواست، یا سه جریان جداگانه، به صورت موازی پردازش شدند. اولین پاسخی که برگشت، برای شناسه ۲، ۸۲ میلیثانیه طول کشید و دو مورد بعدی ۵۰ و ۵۱ میلیثانیه بعدتر آمدند.
جریانهای رویداد، مجموعههای غیرهمگام هستند
در مثال قبلی، لیست شناسههای کاربری ما (در users قابل مشاهده) استاتیک بود. اما لازم نیست باشد. شاید بخواهیم این اطلاعات را زمانی جمعآوری کنیم که افراد وارد سایت ما میشوند. تنها کاری که باید انجام دهیم این است که هنگام ایجاد نشست (Session) کاربر، یک رویدادِ Observable حاوی شناسه کاربر تولید کنیم و از آن Observable به جای نمونه استاتیک استفاده کنیم. در آن صورت ما جزئیات کاربران را به محض دریافت این شناسهها دریافت میکردیم (Fetch)، و احتمالاً آنها را در جایی ذخیره میکردیم.
این یک انتزاع بسیار قدرتمند است: ما دیگر نیازی نداریم که به زمان به عنوان چیزی که باید مدیریتش کنیم فکر کنیم. جریانهای رویداد، پردازش همگام و غیرهمگام را پشت یک API مشترک و راحت یکپارچه میکنند.
رویدادها همهجا هستند (EVENTS ARE UBIQUITOUS)
رویدادها همهجا هستند. برخی بدیهیاند: کلیک دکمه، منقضی شدن تایمر. برخی کمتر بدیهیاند: کسی وارد سیستم میشود، خطی در یک فایل با الگویی مطابقت پیدا میکند. اما منبع آنها هر چه باشد، کدی که حول رویدادها ساخته شده باشد میتواند پاسخگوتر (Responsive) باشد و نسبت به همتای خطی خود، بهتر مستقلسازی (Decoupled) شده باشد.
بخشهای مرتبط شامل:
- مبحث ۲۸، کاهش وابستگی (Decoupling)
- مبحث ۳۶، تختهسیاهها (Blackboards)
تمرینها
تمرین ۱۹ (پاسخ احتمالی)
در بخش FSM اشاره کردیم که میتوانید پیادهسازیِ عمومیِ ماشین حالت را به کلاس جداگانه خودش منتقل کنید. آن کلاس احتمالاً با پاس دادنِ جدولی از انتقالات و یک حالت اولیه، مقداردهی اولیه (Initialize) میشود. سعی کنید «استخراجکننده رشته» (که در متن مثال زده شد) را به آن روش پیادهسازی کنید.
تمرین ۲۰ (پاسخ احتمالی)
کدامیک از این تکنولوژیها (شاید به صورت ترکیبی) گزینهی مناسبی برای موقعیتهای زیر خواهد بود:
- اگر سه رویدادِ «قطعی رابط شبکه» (network interface down) را در عرض پنج دقیقه دریافت کردید، به کارکنان بخش عملیات اطلاع دهید.
- اگر بعد از غروب آفتاب است، و حرکتی در پایین پلهها تشخیص داده شد که به دنبالِ آن حرکتی در بالای پلهها تشخیص داده شد، چراغهای طبقه بالا را روشن کنید.
- میخواهید به سیستمهای گزارشدهی مختلف اطلاع دهید که یک سفارش تکمیل شده است.
- به منظور تعیین اینکه آیا یک مشتری واجد شرایط دریافت وام خودرو هست یا نه، برنامه نیاز دارد درخواستهایی را به سه سرویسِ بکاند (Backend) ارسال کند و منتظر پاسخها بماند.
مبحث ۳۰: تبدیل برنامهنویسی (Transforming Programming)
اگر نمیتوانید کاری را که انجام میدهید به عنوان یک فرایند توصیف کنید، پس نمیدانید چه کاری انجام میدهید.
-- دبلیو. ادواردز دمینگ (منسوب به او)
همه برنامهها دادهها را تبدیل میکنند؛ ورودی را به خروجی تبدیل میکنند. و با این حال، وقتی درباره طراحی فکر میکنیم، به ندرت به ایجاد «تبدیلها» (Transformations) فکر میکنیم. در عوض نگران کلاسها و ماژولها، ساختمان دادهها و الگوریتمها، زبانها و فریمورکها هستیم.
ما فکر میکنیم که این تمرکز روی کد، اغلب اصل مطلب را نادیده میگیرد: ما باید به این تفکر برگردیم که برنامهها چیزی هستند که ورودیها را به خروجی تبدیل میکنند. وقتی این کار را میکنیم، بسیاری از جزئیاتی که قبلاً نگرانشان بودیم، تبخیر میشوند. ساختار شفافتر میشود، مدیریت خطا یکپارچهتر میشود، و وابستگی (Coupling) به شدت کاهش مییابد.
برای شروع تحقیقاتمان، بیایید سوار ماشین زمان شویم، به دهه ۱۹۷۰ برگردیم و از یک برنامهنویس یونیکس بخواهیم برنامهای برایمان بنویسد که پنج فایل طولانی در یک درخت دایرکتوری را لیست کند، جایی که منظور از طولانیترین، «داشتن بیشترین تعداد خطوط» است.
شاید انتظار داشته باشید که آنها دست به یک ویرایشگر ببرند و شروع به تایپ کردن به زبان C کنند. اما آنها این کار را نمیکنند، زیرا آنها درباره این موضوع بر اساس چیزی که داریم (یک درخت دایرکتوری) و چیزی که میخواهیم (یک لیست از فایلها) فکر میکنند. سپس آنها به سراغ ترمینال میروند و چیزی شبیه به این را تایپ میکنند:
find . -type f | xargs wc -l | sort -n | tail -5
این مجموعهای از تبدیلهاست:
find . -type f: لیستی از تمام فایلها (-type f) در دایرکتوری فعلی یا زیرمجموعه آن (.) را در خروجی استاندارد بنویس.xargs wc -l: خطوط را از ورودی استاندارد بخوان و ترتیب بده که همه آنها به عنوان آرگومان به دستورwc -lپاس داده شوند. برنامهwcبا گزینه-lتعداد خطوط هر یک از آرگومانهایش را میشمارد و هر نتیجه را به صورت «تعداد نامفایل» در خروجی استاندارد مینویسد.sort -n: ورودی استاندارد را با فرض اینکه هر خط با یک عدد شروع میشود مرتب کن (-n) و نتیجه را در خروجی استاندارد بنویس.tail -5: ورودی استاندارد را بخوان و فقط پنج خط آخر را در خروجی استاندارد بنویس.
این را در دایرکتوری کتابمان اجرا کنیم و این را میگیریم:
470 ./test_to_build.pml
487 ./dbc.pml
719 ./domain_languages.pml
727 ./dry.pml
9561 total
آن خط آخر مجموع تعداد خطوط در تمام فایلهاست (نه فقط آنهایی که نشان داده شدهاند)، زیرا این کاری است که wc انجام میدهد. میتوانیم با درخواست یک خط بیشتر از tail و سپس نادیده گرفتن خط آخر، آن را حذف کنیم:
find . -type f | xargs wc -l | sort -n | tail -6 | head -5
470 ./debug.pml
470 ./test_to_build.pml
487 ./dbc.pml
719 ./domain_languages.pml
727 ./dry.pml
(تصویر پایپلاین wc)
بیایید به این موضوع از دید دادههایی که بین مراحل جداگانه جریان دارند نگاه کنیم. نیاز اولیه ما، «۵ فایل برتر از نظر تعداد خطوط»، به مجموعهای از تبدیلها تبدیل میشود:
نام دایرکتوری ← لیست فایلها ← لیست با شماره خطوط ← لیست مرتبشده ← ۵ تای برتر + مجموع ← ۵ تای برتر
این تقریباً شبیه یک خط مونتاژ صنعتی است: مواد خام را از یک سر وارد کنید و محصول نهایی (اطلاعات) از طرف دیگر بیرون میآید. و ما دوست داریم درباره تمام کدها به این شکل فکر کنیم.
نکته ۴۹: برنامهنویسی درباره کد است، اما برنامهها درباره دادهها هستند
پیدا کردن تبدیلها (FINDING TRANSFORMATIONS)
گاهی اوقات آسانترین راه برای پیدا کردن تبدیلها این است که با نیازمندی شروع کنید و ورودیها و خروجیهای آن را تعیین کنید. حالا تابعی را تعریف کردهاید که نمایانگر کل برنامه است. سپس میتوانید مراحلی را پیدا کنید که شما را از ورودی به خروجی میرسانند. این یک رویکرد بالا به پایین (Top-down) است.
برای مثال، میخواهید وبسایتی برای دوستداران بازیهای کلمهای بسازید که تمام کلماتی را که میتوان از مجموعهای از حروف ساخت پیدا کند. ورودی شما در اینجا مجموعهای از حروف است، و خروجی شما لیستی از کلمات سه حرفی، چهار حرفی و غیره است:
"lvyin" is transformed to ->
3 => ivy, lin, nil, yin
4 => inly, liny, viny
5 => vinyl
(بله، همه آنها کلمه هستند، حداقل طبق دیکشنری macOS.)
ترفند پشت کل برنامه ساده است: ما دیکشنریای داریم که کلمات را با یک امضا (Signature) گروهبندی میکند، به طوری که تمام کلماتی که حاوی حروف یکسانی هستند، امضای یکسانی داشته باشند. سادهترین تابعِ امضا، فقط لیست مرتبشدهای از حروفِ کلمه است. سپس میتوانیم یک رشته ورودی را با تولید امضا برای آن جستجو کنیم، و ببینیم کدام کلمات (اگر وجود داشته باشند) در دیکشنری همان امضا را دارند.
بنابراین «یابنده آناگرام» (Anagram finder) به چهار تبدیل جداگانه شکسته میشود:
| مرحله | تبدیل | نمونه داده |
|---|---|---|
| مرحله ۰ | ورودی اولیه | "ylvin" |
| مرحله ۱ | تمام ترکیباتِ سه حرفی یا بیشتر | vin, viy, vil, vny, vnl, vyl, iny, inl, iyl, nyl, viny, vinl, viyl, vnyl, inyl, vinyl |
| مرحله ۲ | امضاهای ترکیبات | inv, ivy, ilv, nvy, lnv, lvy, iny, iln, ily, lny, invy, ilnv, ilvy, lnvy, ilny, ilnvy |
| مرحله ۳ | لیست تمام کلمات دیکشنری که با هر یک از امضاها مطابقت دارند | ivy, yin, nil, lin, viny, liny, inly, vinyl |
| مرحله ۴ | کلمات گروهبندی شده بر اساس طول | 3 => ivy, lin, nil, yin4 => inly, liny, viny5 => vinyl |
تبدیلها تا پایین (Transformations All the Way Down)
بیایید با نگاه به مرحله ۱ شروع کنیم، که یک کلمه میگیرد و لیستی از تمام ترکیبات سه حرفی یا بیشتر را ایجاد میکند. این مرحله خودش میتواند به عنوان لیستی از تبدیلها بیان شود:
| مرحله | تبدیل | نمونه داده |
|---|---|---|
| مرحله ۱.۰ | ورودی اولیه | "vinyl" |
| مرحله ۱.۱ | تبدیل به کاراکترها | v, i, n, y, l |
| مرحله ۱.۲ | دریافت تمام زیرمجموعهها | [], [v], [i], … [v,i], [v,n], [v,y], … [v,i,n], [v,i,y], … [v,n,y,l], [i,n,y,l], [v,i,n,y,l] |
| مرحله ۱.۳ | فقط آنهایی که طولشان بیشتر از سه کاراکتر است | [v,i,n], [v,i,y], … [i,n,y,l], [v,i,n,y,l] |
| مرحله ۱.۴ | تبدیل دوباره به رشتهها | [vin,viy, … inyl,vinyl] |
حالا به نقطهای رسیدهایم که میتوانیم به راحتی هر تبدیل را در کد پیادهسازی کنیم (در این مورد با استفاده از Elixir):
defp all_subsets_longer_than_three_characters(word) do
word
|> String.codepoints()
|> Comb.subsets()
|> Stream.filter(fn subset -> length(subset) >= 3 end)
|> Stream.map(&List.to_string(&1))
end
قضیه اپراتور |> چیست؟
زبان Elixir، همراه با بسیاری از زبانهای تابعی دیگر، دارای یک اپراتور پایپلاین (Pipeline operator) است، که گاهی اوقات پایپِ رو به جلو (Forward pipe) یا فقط پایپ نامیده میشود. تنها کاری که انجام میدهد این است که مقدار سمت چپ خود را میگیرد و آن را به عنوان اولین پارامتر تابع سمت راست خود وارد میکند، بنابراین:
"vinyl" |> String.codepoints |> Comb.subsets()
همان نوشتنِ زیر است:
Comb.subsets(String.codepoints("vinyl"))
(سایر زبانها ممکن است این مقدارِ پایپشده را به عنوان آخرین پارامترِ تابع بعدی تزریق کنند—این عمدتاً به سبکِ کتابخانههای داخلی بستگی دارد.)
ممکن است فکر کنید که این فقط یک سینتکس قشنگ (Syntactic sugar) است. اما به معنای کاملاً واقعی، اپراتور پایپلاین فرصتی انقلابی برای متفاوت فکر کردن است. استفاده از یک پایپلاین به این معنی است که شما به طور خودکار دارید در قالبِ تبدیلِ دادهها فکر میکنید؛ هر بار که |> را میبینید، در واقع دارید جایی را میبینید که دادهها بین یک تبدیل و بعدی جریان مییابند.
بسیاری از زبانها چیزی مشابه دارند: Elm، F# و Swift دارای |> هستند، Clojure دارای -> و ->> است (که کمی متفاوت کار میکنند)، R دارای %>% است. Haskell هم اپراتورهای پایپ دارد و هم اعلام موارد جدید را آسان میکند. همین الان که داریم این را مینویسیم، صحبتهایی از اضافه کردن |> به جاوااسکریپت است. اگر زبان فعلی شما از چیزی مشابه پشتیبانی میکند، خوششانس هستید. اگر نه، کادر «زبان X پایپلاین ندارد» را ببینید.
به هر حال، برگردیم به کد.
به تبدیل ادامه دهید
حالا به مرحله ۲ از برنامه اصلی نگاه کنید، جایی که زیرمجموعهها را به امضاها تبدیل میکنیم. باز هم، این یک تبدیل ساده است—لیستی از زیرمجموعهها به لیستی از امضاها تبدیل میشود:
| مرحله | تبدیل | نمونه داده |
|---|---|---|
| مرحله ۲.۰ | ورودی اولیه | vin, viy, … inyl, vinyl |
| مرحله ۲.۱ | تبدیل به امضاها | inv, ivy … ilny, inlvy |
کد Elixir در لیست زیر به همین سادگی است:
defp as_unique_signatures(subsets) do
subsets
|> Stream.map(&Dictionary.signature_of/1)
end
حالا آن لیست امضاها را تبدیل میکنیم: هر امضا به لیستی از کلمات شناختهشده با همان امضا نگاشت میشود، یا اگر هیچ کلمهای وجود نداشته باشد، nil میشود. سپس باید nilها را حذف کنیم و لیستهای تودرتو را به یک سطح مسطح کنیم (Flatten):
defp find_in_dictionary(signatures) do
signatures
|> Stream.map(&Dictionary.lookup_by_signature/1)
|> Stream.reject(&is_nil/1)
|> Stream.concat(&(&1))
end
مرحله ۴، گروهبندی کلمات بر اساس طول، یک تبدیل ساده دیگر است، که لیست ما را به یک Map تبدیل میکند که کلیدها طولها هستند، و مقادیر تمام کلمات با آن طول:
defp group_by_length(words) do
words
|> Enum.sort()
|> Enum.group_by(&String.length/1)
end
زبان X پایپلاین ندارد
پایپلاینها مدت زیادی است که وجود دارند، اما فقط در زبانهای خاص. آنها اخیراً وارد جریان اصلی شدهاند و بسیاری از زبانهای محبوب هنوز از این مفهوم پشتیبانی نمیکنند.
خبر خوب این است که تفکر در قالبِ تبدیلها نیاز به سینتکس زبان خاصی ندارد: این بیشتر یک فلسفه طراحی است. شما همچنان کد خود را به عنوان تبدیلها میسازید، اما آنها را به عنوان مجموعهای از انتسابها مینویسید:
const content = File.read(file_name);
const lines = find_matching_lines(content, pattern)
const result = truncate_lines(lines)
کمی خستهکنندهتر است، اما کار را انجام میدهد.
کنار هم گذاشتن همه چیز
ما هر یک از تبدیلهای جداگانه را نوشتیم. حالا وقت آن است که همه آنها را در تابع اصلیمان به هم وصل کنیم:
def anagrams_in(word) do
word
|> all_subsets_longer_than_three_characters()
|> as_unique_signatures()
|> find_in_dictionary()
|> group_by_length()
end
آیا کار میکند؟ بیایید امتحانش کنیم:
iex(1)> Anagrams.anagrams_in "lyvin"
%{
3 => ["ivy", "lin", "nil", "yin"],
4 => ["inly", "liny", "viny"],
5 => ["vinyl"]
}
چرا اینقدر عالی است؟
بیایید دوباره به بدنه تابع اصلی نگاه کنیم:
word
|> all_subsets_longer_than_three_characters()
|> as_unique_signatures()
|> find_in_dictionary()
|> group_by_length()
این به سادگی زنجیرهای از تبدیلهای مورد نیاز برای برآورده کردن نیازمندی ماست، که هر کدام ورودی را از تبدیل قبلی میگیرد و خروجی را به بعدی میدهد. این تا جایی که میشود به «کد خوانا» (Literate code) نزدیک است.
اما چیز عمیقتری هم وجود دارد. اگر پیشزمینه شما برنامهنویسی شیءگرا (OO) باشد، رفلکسهای شما حکم میکند که دادهها را پنهان کنید و آنها را درون اشیاء کپسوله کنید. سپس این اشیاء با هم صحبت میکنند و وضعیت (State) یکدیگر را تغییر میدهند. این باعث ایجاد وابستگی (Coupling) زیادی میشود و دلیل بزرگی است که تغییر سیستمهای OO میتواند سخت باشد.
نکته ۵۰: وضعیت را احتکار نکنید؛ آن را دست به دست کنید
در مدل تبدیلی، ما این را وارونه میکنیم. به جای استخرهای کوچک داده که در سراسر سیستم پخش شدهاند، به دادهها به عنوان یک رودخانه قدرتمند فکر کنید، یک جریان. داده همتای عملکرد میشود: یک پایپلاین توالیِ کد -> داده -> کد -> داده... است.
داده دیگر به گروه خاصی از توابع گره نخورده است، آنطور که در تعریف کلاس هست. در عوض آزاد است تا پیشرفتِ در حالِ باز شدنِ برنامه ما را در حالی که ورودیهایش را به خروجیها تبدیل میکند، نمایندگی کند.
این بدان معناست که ما میتوانیم وابستگی را به شدت کاهش دهیم: یک تابع میتواند هر جایی که پارامترهایش با خروجی تابع دیگری مطابقت داشته باشد، استفاده (و استفاده مجدد) شود. بله، هنوز درجهای از وابستگی وجود دارد، اما طبق تجربه ما قابل مدیریتتر از سبکِ «فرمان و کنترل» OO است. و اگر از زبانی با بررسی نوع (Type checking) استفاده میکنید، هشدارهای زمان کامپایل را دریافت خواهید کرد اگر سعی کنید دو چیز ناسازگار را به هم وصل کنید.
پس مدیریت خطا چه میشود؟ (WHAT ABOUT ERROR HANDLING)
تا اینجا تبدیلهای ما در دنیایی کار کردهاند که هیچ چیز اشتباه پیش نمیرود. اما چطور میتوانیم از آنها در دنیای واقعی استفاده کنیم؟ اگر فقط میتوانیم زنجیرههای خطی بسازیم، چگونه میتوانیم تمام آن منطق شرطی را که برای بررسی خطا نیاز داریم اضافه کنیم؟
راههای زیادی برای انجام این کار وجود دارد، اما همه آنها به یک قرارداد اساسی تکیه دارند: ما هرگز مقادیر خام (Raw values) را بین تبدیلها پاس نمیدهیم. در عوض، آنها را در یک ساختمان داده (یا نوع) بستهبندی میکنیم که همچنین به ما میگوید آیا مقدارِ داخلش معتبر است یا خیر. در هسکل، مثلاً، این بستهبندی Maybe نامیده میشود. در F# و اسکالا Option است. نحوه استفاده از این مفهوم مخصوصِ زبان است.
به طور کلی اما، دو راه اساسی برای نوشتن کد وجود دارد: میتوانید بررسی خطا را درون تبدیلهای خود مدیریت کنید یا بیرون از آنها. Elixir، که تا اینجا استفاده کردهایم، این پشتیبانی را به صورت پیشفرض ندارد. برای اهداف ما این چیز خوبی است، زیرا میتوانیم یک پیادهسازی را از پایه نشان دهیم. چیزی مشابه باید در اکثر زبانهای دیگر کار کند.
اول، یک نمایش (Representation) انتخاب کنید
ما به یک نمایش برای بستهبندیکننده خود نیاز داریم (ساختمان دادهای که مقدار یا نشانه خطا را حمل میکند). میتوانید از ساختارها (Structures) برای این کار استفاده کنید، اما Elixir قبلاً یک قرارداد نسبتاً قوی دارد: توابع تمایل دارند یک تاپل (Tuple) حاوی {:ok, value} یا {:error, reason} برگردانند.
برای مثال، File.open یا :ok و یک فرآیند IO برمیگرداند یا :error و یک کد دلیل:
iex(1)> File.open("/etc/passwd")
{:ok, #PID<0.109.0>}
iex(2)> File.open("/etc/wombat")
{:error, :enoent}
ما از تاپل :ok/:error به عنوان بستهبندیکننده خود هنگام پاس دادن چیزها در پایپلاین استفاده خواهیم کرد.
سپس آن را درون هر تبدیل مدیریت کنید
بیایید تابعی بنویسیم که تمام خطوط یک فایل را که حاوی یک رشته داده شده هستند، برگرداند، که به ۲۰ کاراکتر اول کوتاه شده باشند. ما میخواهیم آن را به عنوان یک تبدیل بنویسیم، بنابراین ورودی نام فایل و رشتهای برای تطبیق خواهد بود، و خروجی یا یک تاپل :ok با لیستی از خطوط یا یک تاپل :error با نوعی دلیل خواهد بود.
تابع سطح بالا باید چیزی شبیه به این باشد:
def find_all(file_name, pattern) do
File.read(file_name)
|> find_matching_lines(pattern)
|> truncate_lines()
end
هیچ بررسی خطای صریحی در اینجا وجود ندارد، اما اگر هر مرحله در پایپلاین یک تاپل خطا برگرداند، آنگاه پایپلاین آن خطا را بدون اجرای توابع بعدی برمیگرداند. ما این کار را با استفاده از تطبیق الگوی (Pattern matching) Elixir انجام میدهیم:
defp find_matching_lines({:ok, content}, pattern) do
content
|> String.split(~r/\n/)
|> Enum.filter(&String.match?(&1, pattern))
|> ok_unless_empty()
end
defp find_matching_lines(error, _), do: error
# ----------
defp truncate_lines({ :ok, lines }) do
lines
|> Enum.map(&String.slice(&1, 0, 20))
|> ok()
end
defp truncate_lines(error), do: error
به تابع find_matching_lines نگاه کنید. اگر اولین پارامترش یک تاپل :ok باشد، از محتوای آن تاپل برای پیدا کردن خطوط مطابق با الگو استفاده میکند. با این حال، اگر پارامتر اول تاپل :ok نباشد، نسخه دوم تابع اجرا میشود که فقط همان پارامتر را برمیگرداند. به این ترتیب تابع به سادگی خطا را در طول پایپلاین به جلو میفرستد. همین موضوع برای truncate_lines نیز صدق میکند.
میتوانیم با این در کنسول بازی کنیم:
iex> Grep.find_all "/etc/passwd", ~r/www/
{:ok, ["_www:*:70:70:World W", "_wwwproxy:*:252:252:"]}
iex> Grep.find_all "/etc/passwd", ~r/wombat/
{:error, "nothing found"}
iex> Grep.find_all "/etc/koala", ~r/www/
{:error, :enoent}
میبینید که یک خطا در هر جای پایپلاین بلافاصله به مقدارِ کل پایپلاین تبدیل میشود.
یا آن را در پایپلاین مدیریت کنید
ممکن است به توابع find_matching_lines و truncate_lines نگاه کنید و فکر کنید که ما بار مدیریت خطا را به درون تبدیلها منتقل کردهایم. حق با شماست. در زبانی مانند Elixir که از تطبیق الگو در فراخوانی توابع استفاده میکند، اثر آن کمتر است، اما هنوز زشت است.
خیلی خوب میشد اگر Elixir نسخهای از اپراتور پایپلاین |> داشت که درباره تاپلهای :ok/:error میدانست و وقتی خطایی رخ میداد، اجرا را میانبر (Short-circuit) میزد. اما این واقعیت که ندارد، به ما اجازه میدهد چیزی مشابه اضافه کنیم، و به روشی که برای تعدادی از زبانهای دیگر قابل اعمال است.
مشکلی که با آن مواجه هستیم این است که وقتی خطایی رخ میدهد، ما نمیخواهیم کدهای جلوتر در پایپلاین را اجرا کنیم، و نمیخواهیم آن کدها بدانند که این اتفاق دارد میافتد. این یعنی ما باید اجرای توابع پایپلاین را تا زمانی که بدانیم مراحل قبلی موفقیتآمیز بودهاند، به تعویق بیندازیم.
برای انجام این کار، باید آنها را از فراخوانی تابع به مقادیر تابعی که بعداً میتوانند فراخوانی شوند، تغییر دهیم. این یک پیادهسازی است:
defmodule Grep1 do
def and_then({ :ok, value }, func), do: func.(value)
def and_then(anything_else, _func), do: anything_else
def find_all(file_name, pattern) do
File.read(file_name)
|> and_then(&find_matching_lines(&1, pattern))
|> and_then(&truncate_lines(&1))
end
defp find_matching_lines(content, pattern) do
content
|> String.split(~r/\n/)
|> Enum.filter(&String.match?(&1, pattern))
|> ok_unless_empty()
end
# ... (بقیه توابع ساده میشوند)
end
تابع and_then مثالی از یک تابع bind است: یک مقدارِ بستهبندیشده میگیرد، سپس تابعی را روی آن مقدار اعمال میکند و یک مقدار بستهبندیشدهی جدید برمیگرداند.
تبدیلها برنامهنویسی را متحول میکنند
تفکر درباره کد به عنوان مجموعهای از تبدیلهای (تودرتو) میتواند رویکردی آزادیبخش به برنامهنویسی باشد. کمی طول میکشد تا به آن عادت کنید، اما وقتی عادتش را پیدا کردید، خواهید دید که کدتان تمیزتر، توابعتان کوتاهتر و طراحیهایتان مسطحتر (Flatter) میشوند. امتحانش کنید.
بخشهای مرتبط شامل:
- مبحث ۸، جوهره طراحی خوب
- مبحث ۱۷، بازیهای پوسته (Shell Games)
- مبحث ۲۶، نحوه متعادل کردن منابع
- مبحث ۲۸، کاهش وابستگی
- مبحث ۳۵، بازیگران و فرآیندها
تمرینها
تمرین ۲۱ (پاسخ احتمالی)
آیا میتوانید نیازمندیهای زیر را به عنوان یک تبدیل سطح بالا بیان کنید؟ یعنی برای هر کدام، ورودی و خروجی را شناسایی کنید.
- هزینه حمل و نقل و مالیات فروش به یک سفارش اضافه میشود.
- برنامه شما اطلاعات پیکربندی را از یک فایل نامگذاری شده بارگذاری میکند.
- کسی وارد یک برنامه وب میشود.
تمرین ۲۲ (پاسخ احتمالی)
شما نیاز به اعتبارسنجی و تبدیل یک فیلد ورودی از یک رشته به یک عدد صحیح بین ۱۸ تا ۱۵۰ را شناسایی کردهاید. تبدیل کلی به صورت زیر توصیف میشود:
محتویات فیلد به عنوان رشته → [اعتبارسنجی و تبدیل] → {:ok, value} | {:error, reason}
تبدیلهای جداگانهای را که «اعتبارسنجی و تبدیل» را تشکیل میدهند، بنویسید.
تمرین ۲۳ (پاسخ احتمالی)
در کادر «زبان X پایپلاین ندارد» نوشتیم:
const content = File.read(file_name);
const lines = find_matching_lines(content, pattern)
const result = truncate_lines(lines)
بسیاری از افراد کد OO را با زنجیر کردن فراخوانی متدها مینویسند، و ممکن است وسوسه شوند که این را به صورت چیزی شبیه به این بنویسند:
const result = content_of(file_name)
.find_matching_lines(pattern)
.truncate_lines()
تفاوت بین این دو قطعه کد چیست؟ فکر میکنید ما کدام را ترجیح میدهیم؟
مبحث ۳۱: مالیات وراثت (Inheritance Tax)
شما یک موز میخواستید، اما چیزی که گیرتان آمد یک گوریل بود که موز را در دست داشت، به همراه کل جنگل.
-- جو آرمسترانگ
کمی پیشزمینه
آیا به یک زبان شیءگرا برنامهنویسی میکنید؟ آیا از وراثت (Inheritance) استفاده میکنید؟
اگر اینطور است، دست نگه دارید! احتمالاً این کاری نیست که میخواهید انجام دهید. بیایید ببینیم چرا.
وراثت برای اولین بار در سال ۱۹۶۹ در زبان Simula 67 ظاهر شد. این یک راه حل ظریف برای مشکلِ قرار دادنِ چندین نوع رویداد در یک لیستِ واحد بود.
رویکرد Simula استفاده از چیزی به نام کلاسهای پیشوند (prefix classes) بود. میتوانستید چیزی شبیه به این بنویسید:
link CLASS car;
... implementation of car
link CLASS bicycle;
... implementation of bicycle
در اینجا link یک کلاس پیشوند است که قابلیت لیستهای پیوندی را اضافه میکند. این به شما امکان میدهد که هم ماشینها و هم دوچرخهها را به لیستِ چیزهایی که (مثلاً) پشت چراغ قرمز منتظرند، اضافه کنید. در اصطلاحات امروزی، link یک کلاس والد (Parent class) خواهد بود.
مدل ذهنی برنامهنویسان Simula این بود که دادههای نمونه (Instance data) و پیادهسازی کلاس link، به ابتدای پیادهسازی کلاسهای car و bicycle چسبانده شده است. بخش link تقریباً به عنوان ظرفی دیده میشد که ماشینها و دوچرخهها را با خود حمل میکرد.
این به آنها نوعی چندریختی (Polymorphism) میداد: ماشینها و دوچرخهها هر دو رابطِ link را پیادهسازی میکردند، زیرا هر دو حاوی کدِ link بودند.
بعد از Simula، زبان Smalltalk آمد. آلن کی (Alan Kay)، یکی از خالقان Smalltalk، در پاسخ به سوالی در Quora در سال ۲۰۱۹ توضیح میدهد که چرا Smalltalk وراثت دارد:
بنابراین وقتی Smalltalk-72 را طراحی کردم—و این یک تفریح بود در حالی که داشتم به Smalltalk-71 فکر میکردم—فکر کردم جالب خواهد بود که از پویاییِ شبیه به Lispِ آن استفاده کنم تا آزمایشهایی با «برنامهنویسی دیفرانسیلی» انجام دهم (به معنی: روشهای مختلف برای انجامِ «این شبیه به آن است، به جز...»).
این زیرکلاسسازی (Subclassing) صرفاً برای رفتار بود.
این دو سبکِ وراثت (که در واقع نقاط مشترک زیادی داشتند) در طول دهههای بعد توسعه یافتند. رویکرد Simula، که پیشنهاد میکرد وراثت راهی برای ترکیب انواع (Types) است، در زبانهایی مانند ++C و Java ادامه یافت. مکتب Smalltalk، جایی که وراثت سازماندهی پویای رفتارها بود، در زبانهایی مانند Ruby و JavaScript دیده شد.
بنابراین، اکنون ما با نسلی از توسعهدهندگان OO مواجه هستیم که از وراثت به یکی از دو دلیل زیر استفاده میکنند:
- تایپ کردن را دوست ندارند (Don't like typing): کسانی که دوست ندارند زیاد تایپ کنند، با استفاده از وراثت برای اضافه کردن قابلیتهای مشترک از یک کلاس پایه به کلاسهای فرزند، به انگشتان خود استراحت میدهند: کلاس
Userو کلاسProductهر دو زیرکلاسهایActiveRecord::Baseهستند. - نوعها را دوست دارند (Like types): کسانی که نوعها (Types) را دوست دارند، از وراثت برای بیان رابطه بین کلاسها استفاده میکنند: یک ماشین نوعی از (is-a-kind-of) وسیله نقلیه است.
متأسفانه هر دو نوع استفاده از وراثت مشکلاتی دارند.
مشکلات استفاده از وراثت برای اشتراک کد
وراثت یعنی وابستگی (Coupling).
نه تنها کلاس فرزند به والد، و والدِ والد، و الی آخر وابسته است، بلکه کدی که از کلاس فرزند استفاده میکند نیز به تمام اجداد آن وابسته است.
اینجا یک مثال آوردهایم:
class Vehicle
def initialize
@speed = 0
end
def stop
@speed = 0
end
def move_at(speed)
@speed = speed
end
end
class Car < Vehicle
def info
"I'm car driving at #{@speed}"
end
end
# top-level code
my_ride = Car.new
my_ride.move_at(30)
وقتی کد سطح بالا my_car.move_at را فراخوانی میکند، متدی که اجرا میشود در Vehicle است، والدِ Car.
حالا فرض کنید توسعهدهندهی مسئولِ کلاس Vehicle، رابط برنامهنویسی (API) را تغییر میدهد، به طوری که move_at تبدیل به set_velocity میشود، و متغیر نمونه @speed به @velocity تغییر نام میدهد.
انتظار میرود که یک تغییر API باعث خرابیِ کلاینتهای کلاس Vehicle شود. اما کد سطح بالا کلاینتِ مستقیم نیست: تا جایی که به او مربوط است، دارد از یک Car استفاده میکند. اینکه کلاس Car از نظر پیادهسازی چه کاری انجام میدهد، دغدغه کد سطح بالا نیست، اما با این حال خراب میشود. به طور مشابه، نام یک متغیر نمونه صرفاً یک جزئیاتِ پیادهسازیِ داخلی است، اما وقتی Vehicle تغییر میکند، (در سکوت) Car را نیز خراب میکند.
چقدر وابستگی!
مشکلات استفاده از وراثت برای ساختن انواع (Types)
برخی افراد وراثت را راهی برای تعریف نوعهای جدید میبینند. نمودار طراحی مورد علاقه آنها سلسلهمراتبِ کلاسها را نشان میدهد. آنها به مسائل به همان روشی نگاه میکنند که دانشمندان نجیبزاده عصر ویکتوریا به طبیعت نگاه میکردند: به عنوان چیزی که باید به دستهبندیهای مختلف تقسیم شود.
متأسفانه، این نمودارها به زودی به هیولاهایی تبدیل میشوند که کل دیوار را میپوشانند؛ لایه روی لایه اضافه میشود تا کوچکترین جزئیاتِ تفاوت بین کلاسها را بیان کند. این پیچیدگی اضافه شده میتواند برنامه را شکنندهتر کند، زیرا تغییرات میتوانند در بسیاری از لایهها بالا و پایین بروند (Ripple effect).
با این حال، بدتر از آن، مسئله وراثت چندگانه است. یک ماشین (Car) ممکن است نوعی وسیله نقلیه (Vehicle) باشد، اما همچنین میتواند نوعی دارایی (Asset)، مورد بیمهشده (InsuredItem)، وثیقه وام (LoanCollateral) و غیره باشد. مدلسازی صحیح این موضوع نیاز به وراثت چندگانه دارد.
++C در دهه ۱۹۹۰ به دلیل برخی معناشناسیهای ابهامزداییِ مشکوک، نام وراثت چندگانه را بد کرد. در نتیجه، بسیاری از زبانهای OO فعلی آن را ارائه نمیدهند. بنابراین، حتی اگر با درختهای پیچیدهِ نوعها (Types) مشکلی نداشته باشید، باز هم نمیتوانید دامنه (Domain) خود را به درستی مدلسازی کنید.
نکته ۵۱: مالیات وراثت نپردازید
جایگزینها بهتر هستند
اجازه دهید سه تکنیک را پیشنهاد کنیم که به این معنی است که دیگر هرگز نیازی به استفاده از وراثت نخواهید داشت:
- رابطها و پروتکلها (Interfaces and protocols)
- نمایندگی (Delegation)
- میکسینها و صفات (Mixins and traits)
رابطها و پروتکلها
اکثر زبانهای OO به شما اجازه میدهند مشخص کنید که یک کلاس یک یا چند مجموعه از رفتارها را پیادهسازی میکند.
برای مثال، میتوانید بگویید که یک کلاس Car رفتار Drivable (قابل رانندگی) و رفتار Locatable (قابل مکانیابی) را پیادهسازی میکند. سینتکس مورد استفاده برای انجام این کار متفاوت است: در جاوا، ممکن است شبیه به این باشد:
public class Car implements Drivable, Locatable {
// Code for class Car. This code must include
// the functionality of both Drivable
// and Locatable
}
Drivable و Locatable همان چیزی هستند که جاوا رابط (Interface) مینامد؛ سایر زبانها آنها را پروتکل (Protocol) مینامند، و برخی آنها را صفت (Trait) مینامند (اگرچه این آن چیزی نیست که ما بعداً Trait خواهیم نامید).
رابطها اینگونه تعریف میشوند:
public interface Drivable {
double getSpeed();
void stop();
}
public interface Locatable() {
Coordinate getLocation();
boolean locationIsValid();
}
این اعلانها هیچ کدی ایجاد نمیکنند: آنها به سادگی میگویند که هر کلاسی که Drivable را پیادهسازی میکند باید دو متد getSpeed و stop را پیادهسازی کند، و کلاسی که Locatable است باید getLocation و locationIsValid را پیادهسازی کند. این بدان معناست که تعریف کلاس قبلیِ ما از Car تنها در صورتی معتبر خواهد بود که شامل تمام این چهار متد باشد.
چیزی که رابطها و پروتکلها را بسیار قدرتمند میکند این است که میتوانیم از آنها به عنوان نوع (Type) استفاده کنیم، و هر کلاسی که رابط مناسب را پیادهسازی کند با آن نوع سازگار خواهد بود. اگر Car و Phone هر دو Locatable را پیادهسازی کنند، میتوانیم هر دو را در لیستی از آیتمهای قابل مکانیابی ذخیره کنیم:
List<Locatable> items = new ArrayList<>();
items.add(new Car(...));
items.add(new Phone(...));
سپس میتوانیم آن لیست را پردازش کنیم، با اطمینان از اینکه هر آیتم متدهای getLocation و locationIsValid را دارد:
void printLocation(Locatable item) {
if (item.locationIsValid() {
print(item.getLocation().asString());
}
}
// ...
items.forEach(printLocation);
نکته ۵۲: برای بیان چندریختی (Polymorphism)، رابطها را ترجیح دهید
رابطها و پروتکلها به ما چندریختی را بدون وراثت میدهند.
نمایندگی (Delegation)
وراثت توسعهدهندگان را تشویق میکند کلاسهایی ایجاد کنند که اشیاءشان تعداد زیادی متد دارند. اگر یک کلاس والد ۲۰ متد داشته باشد و زیرکلاس بخواهد فقط از دو تای آنها استفاده کند، اشیاء آن زیرکلاس همچنان آن ۱۸ متد دیگر را در اطراف خود دارند و قابل فراخوانی هستند. کلاس، کنترل رابط خود را از دست داده است.
این یک مشکل رایج است—بسیاری از فریمورکهای پایداری (Persistence) و UI اصرار دارند که اجزای برنامه از یک کلاس پایه ارائهشده ارثبری کنند:
class Account < PersistenceBaseClass
end
کلاس Account اکنون تمام API کلاس پایداری را با خود حمل میکند.
در عوض، جایگزینی را با استفاده از نمایندگی (Delegation) تصور کنید، مانند مثال زیر:
class Account
def initialize(. . .)
@repo = Persister.for(self)
end
def save
@repo.save()
end
end
ما اکنون هیچ بخشی از API فریمورک را به کلاینتهای کلاس Account خود نشان نمیدهیم: آن وابستگی اکنون شکسته شده است.
اما چیزهای بیشتری وجود دارد. حالا که دیگر محدود به API فریمورکی که استفاده میکنیم نیستیم، آزادیم API مورد نیاز خود را ایجاد کنیم. بله، قبلاً هم میتوانستیم این کار را انجام دهیم، اما همیشه این ریسک وجود داشت که رابطی که نوشتیم دور زده شود و به جای آن از API پایداری استفاده شود. حالا ما همه چیز را کنترل میکنیم.
نکته ۵۳: به سرویسها نمایندگی بدهید: «داشتنِ یک» بر «بودنِ یک» برتری دارد (Has-A Trumps Is-A)
در واقع، میتوانیم این را یک قدم جلوتر ببریم. چرا یک Account (حساب کاربری) باید بداند که چگونه خودش را ذخیره (Persist) کند؟ آیا کارش این نیست که قوانین تجاری حساب کاربری را بداند و اعمال کند؟
class Account
# nothing but account stuff
end
class AccountRecord
# wraps an account with the ability
# to be fetched and stored
end
حالا ما واقعاً مستقل (Decoupled) شدهایم، اما این به قیمتی تمام شده است. ما مجبوریم کد بیشتری بنویسیم، و معمولاً بخشی از آن کد تکراری (Boilerplate) خواهد بود: مثلاً احتمالاً تمام کلاسهای رکورد ما به یک متد find نیاز خواهند داشت. خوشبختانه، این کاری است که میکسینها (Mixins) و صفات (Traits) برای ما انجام میدهند.
میکسینها، صفات، دستهها، افزونههای پروتکل و
به عنوان یک صنعت، ما عاشق نامگذاری چیزها هستیم. خیلی اوقات به یک چیز واحد نامهای زیادی میدهیم. بیشتر بهتر است، نه؟ این چیزی است که وقتی به میکسینها نگاه میکنیم با آن سر و کار داریم.
ایده اصلی ساده است: ما میخواهیم بتوانیم کلاسها و اشیاء را با قابلیتهای جدید گسترش دهیم بدون استفاده از وراثت. بنابراین مجموعهای از این توابع را ایجاد میکنیم، به آن مجموعه یک نام میدهیم، و سپس به نحوی یک کلاس یا شیء را با آنها گسترش میدهیم. در آن نقطه، شما کلاس یا شیء جدیدی ایجاد کردهاید که قابلیتهای اصلی و تمام میکسینهایش را ترکیب میکند.
در بیشتر موارد، شما قادر خواهید بود این گسترش را انجام دهید حتی اگر به کد منبعِ کلاسی که گسترش میدهید دسترسی نداشته باشید.
اکنون پیادهسازی و نام این ویژگی بین زبانها متفاوت است. ما در اینجا تمایل داریم آنها را میکسین (Mixin) بنامیم، اما واقعاً میخواهیم شما به این به عنوان یک ویژگی مستقل از زبان فکر کنید. نکته مهم قابلیتی است که همه این پیادهسازیها دارند: ادغام عملکرد بین چیزهای موجود و چیزهای جدید.
به عنوان مثال، بیایید به مثال AccountRecord برگردیم. همانطور که رهایش کردیم، یک AccountRecord نیاز داشت هم درباره حسابها بداند و هم درباره فریمورک پایداری ما. همچنین نیاز داشت تمام متدهای لایه پایداری را که میخواست به دنیای بیرون نشان دهد، نمایندگی (Delegate) کند. میکسینها به ما یک جایگزین میدهند.
اول، میتوانستیم یک میکسین بنویسیم که (مثلاً) دو یا سه تا از متدهای استاندارد finder را پیادهسازی کند. سپس میتوانستیم آنها را به عنوان یک میکسین به AccountRecord اضافه کنیم. و همانطور که کلاسهای جدیدی برای چیزهای قابل ذخیرهسازی مینویسیم، میتوانیم میکسین را به آنها هم اضافه کنیم:
mixin CommonFinders {
def find(id) { ... }
def findAll() { ... }
}
class AccountRecord extends BasicRecord with CommonFinders
class OrderRecord extends BasicRecord with CommonFinders
میتوانیم این را خیلی جلوتر ببریم. برای مثال، همه ما میدانیم اشیاء تجاری (Business objects) ما برای جلوگیری از نفوذ دادههای بد به محاسباتمان، نیاز به کد اعتبارسنجی (Validation) دارند. اما دقیقاً منظورمان از اعتبارسنجی چیست؟
اگر مثلاً یک حساب کاربری را در نظر بگیریم، احتمالاً لایههای مختلفی از اعتبارسنجی وجود دارد که میتواند اعمال شود:
- اعتبارسنجی اینکه پسورد هش شده با پسورد وارد شده توسط کاربر مطابقت دارد
- اعتبارسنجی دادههای فرم وارد شده توسط کاربر هنگام ایجاد حساب
- اعتبارسنجی دادههای فرم وارد شده توسط ادمین هنگام بهروزرسانی جزئیات کاربر
- اعتبارسنجی دادههای اضافه شده به حساب توسط سایر اجزای سیستم
- اعتبارسنجی دادهها برای سازگاری قبل از اینکه ذخیره شوند
یک رویکرد رایج (و به باور ما کمتر از ایدهآل) این است که تمام اعتبارسنجیها را در یک کلاس واحد (شیء تجاری/شیء پایداری) بستهبندی کنیم و سپس پرچمهایی (Flags) اضافه کنیم تا کنترل کنیم کدامیک در چه شرایطی فعال شوند.
ما فکر میکنیم راه بهتر استفاده از میکسینها برای ایجاد کلاسهای تخصصی برای موقعیتهای مناسب است:
class AccountForCustomer extends Account
with AccountValidations, AccountCustomerValidations
class AccountForAdmin extends Account
with AccountValidations, AccountAdminValidations
در اینجا، هر دو کلاس مشتقشده شامل اعتبارسنجیهای مشترک برای تمام اشیاء حساب هستند. نوع مشتری همچنین شامل اعتبارسنجیهای مناسب برای APIهای سمت مشتری است، در حالی که نوع ادمین شامل اعتبارسنجیهای ادمین (که احتمالاً محدودیت کمتری دارند) میباشد.
اکنون، با پاس دادن نمونههای AccountForCustomer یا AccountForAdmin به این طرف و آن طرف، کد ما به طور خودکار اطمینان حاصل میکند که اعتبارسنجی صحیح اعمال میشود.
نکته ۵۴: از میکسینها برای به اشتراکگذاری عملکرد استفاده کنید
وراثت به ندرت پاسخ است
ما نگاه سریعی به سه جایگزین برای وراثت کلاس سنتی انداختیم:
- رابطها و پروتکلها
- نمایندگی (Delegation)
- میکسینها و صفات (Mixins and traits)
هر یک از این روشها ممکن است بسته به اینکه هدف شما اشتراکگذاری اطلاعات نوع، افزودن عملکرد، یا اشتراکگذاری متدها باشد، در شرایط مختلف برای شما بهتر باشد.
مانند هر چیزی در برنامهنویسی، هدفتان استفاده از تکنیکی باشد که قصد شما را به بهترین شکل بیان میکند. و سعی کنید کل جنگل را با خودتان این طرف و آن طرف نکشید.
بخشهای مرتبط شامل:
- مبحث ۸، جوهره طراحی خوب
- مبحث ۱۰، تعامد (Orthogonality)
- مبحث ۲۸، کاهش وابستگی (Decoupling)
چالشها
دفعه بعدی که خودتان را در حال زیرکلاسسازی (Subclassing) دیدید، یک دقیقه وقت بگذارید تا گزینهها را بررسی کنید. آیا میتوانید آنچه را میخواهید با رابطها، نمایندگی و/یا میکسینها به دست آورید؟ آیا میتوانید با انجام این کار وابستگی را کاهش دهید؟
مبحث ۳۲: پیکربندی (Configuration)
بگذار تمام چیزهایت جای خود را داشته باشند؛ بگذار هر بخش از کارت زمان خود را داشته باشد.
-- بنجامین فرانکلین، سیزده فضیلت، خودزندگینامه
وقتی کد به مقادیری متکی است که ممکن است پس از راهاندازی (Go live) برنامه تغییر کنند، آن مقادیر را خارج از برنامه نگه دارید. وقتی برنامه شما در محیطهای مختلف، و احتمالاً برای مشتریان مختلف اجرا خواهد شد، مقادیرِ محیطی و مخصوصِ مشتری را خارج از برنامه نگه دارید.
به این ترتیب، شما دارید برنامه خود را پارامتری میکنید؛ کد با مکانهایی که در آن اجرا میشود سازگار میشود.
نکته ۵۵: برنامه خود را با استفاده از پیکربندی خارجی، پارامتری کنید
موارد متداولی که احتمالاً میخواهید در دادههای پیکربندی (Configuration data) قرار دهید عبارتند از:
- اطلاعات احراز هویت برای سرویسهای خارجی (پایگاه داده، APIهای شخص ثالث، و غیره)
- سطوح و مقصدهای لاگگیری (Logging)
- پورت، آدرس IP، نام ماشین و خوشهای (Cluster) که برنامه استفاده میکند
- پارامترهای اعتبارسنجیِ مخصوصِ محیط
- پارامترهای تعیینشده خارجی، مانند نرخ مالیات
- جزئیات قالببندیِ مخصوصِ سایت
- کلیدهای لایسنس
اساساً، به دنبال هر چیزی بگردید که میدانید باید تغییر کند و میتوانید آن را خارج از بدنه اصلی کدتان بیان کنید، و آن را درون نوعی سطلِ پیکربندی بیندازید.
پیکربندی ایستا (STATIC CONFIGURATION)
بسیاری از فریمورکها، و تعداد کمی از برنامههای سفارشی، پیکربندی را یا در فایلهای متنی ساده (Flat files) یا در جداول پایگاه داده نگه میدارند.
اگر اطلاعات در فایلهای متنی باشد، روند رایج استفاده از نوعی فرمت متن سادهی آماده است. در حال حاضر YAML و JSON برای این کار محبوب هستند. گاهی اوقات برنامههایی که با زبانهای اسکریپتی نوشته میشوند از فایلهای کد منبعِ خاصمنظوره استفاده میکنند که فقط به نگهداری پیکربندی اختصاص داده شدهاند.
اگر اطلاعات ساختاریافته باشد و احتمال تغییر آن توسط مشتری زیاد باشد (مثلاً نرخ مالیات فروش)، ممکن است بهتر باشد آن را در یک جدول پایگاه داده ذخیره کنید. و البته، میتوانید از هر دو استفاده کنید و اطلاعات پیکربندی را بر اساس نوع استفاده تقسیم کنید.
از هر شکلی که استفاده کنید، پیکربندی به عنوان یک ساختمان داده به برنامه شما خوانده میشود، معمولاً زمانی که برنامه شروع میشود. معمولاً، این ساختمان داده سراسری (Global) میشود، با این تفکر که این کار دسترسی هر قسمت از کد به مقادیر آن را آسانتر میکند.
ما ترجیح میدهیم که شما این کار را نکنید. در عوض، اطلاعات پیکربندی را پشت یک API (نازک) بستهبندی کنید. این کار کد شما را از جزئیاتِ نحوه نمایش پیکربندی، مستقل (Decouple) میکند.
پیکربندی به عنوان سرویس (CONFIGURATION-AS-A-SERVICE)
در حالی که پیکربندی ایستا رایج است، ما در حال حاضر رویکرد متفاوتی را ترجیح میدهیم. ما هنوز میخواهیم دادههای پیکربندی خارج از برنامه نگه داشته شوند، اما به جای فایل متنی یا دیتابیس، دوست داریم آن را پشت یک API سرویس ذخیره شده ببینیم. این کار مزایای زیادی دارد:
- چندین برنامه میتوانند اطلاعات پیکربندی را به اشتراک بگذارند، با احراز هویت و کنترل دسترسی که آنچه را هر کدام میتوانند ببینند محدود میکند.
- تغییرات پیکربندی میتواند به صورت سراسری (Global) انجام شود.
- دادههای پیکربندی میتواند از طریق یک رابط کاربری (UI) تخصصی مدیریت شود.
- دادههای پیکربندی پویا (Dynamic) میشوند.
آن نکته آخر، که پیکربندی باید پویا باشد، با حرکت ما به سمت برنامههای با دسترسی بالا (Highly available) حیاتی است. این ایده که برای تغییر یک پارامتر واحد باید برنامه را متوقف و دوباره راهاندازی کنیم، به طرز ناامیدکنندهای با واقعیتهای مدرن بیگانهاست.
با استفاده از یک سرویس پیکربندی، اجزای برنامه میتوانند برای دریافت اعلانِ بهروزرسانیِ پارامترهایی که استفاده میکنند ثبتنام کنند، و سرویس میتواند در صورت تغییر، پیامهایی حاوی مقادیر جدید برای آنها ارسال کند.
هر شکلی که به خود بگیرد، دادههای پیکربندی رفتار زمان اجرای (Runtime) یک برنامه را هدایت میکنند. وقتی مقادیر پیکربندی تغییر میکنند، نیازی به بیلدِ مجدد کد نیست.
کدِ دودو ننویسید (DON’T WRITE DODO-CODE)
بدون پیکربندی خارجی، کد شما آنقدر که میتوانست باشد سازگارپذیر یا انعطافپذیر نیست. آیا این چیز بدی است؟
خب، اینجا در دنیای واقعی، گونههایی که سازگار نمیشوند میمیرند. دودو (Dodo) با حضور انسانها و دامهایشان در جزیره موریس سازگار نشد و به سرعت منقرض شد. [۴۵]
اجازه ندهید پروژه شما (یا حرفهی شما) به سرنوشت دودو دچار شود.
- [۴۵] این اولین انقراض ثبتشدهی یک گونه به دست انسان بود.
بخشهای مرتبط شامل:
- مبحث ۹، DRY—شرارتهای تکرار
- مبحث ۱۴، زبانهای دامنه
- مبحث ۱۶، قدرت متن ساده
- مبحث ۲۸، کاهش وابستگی