از رویداد تا معماری قابل کنترل، قسمت ششم: Lazy Evaluation
در قسمت پنجم دیدیم که با cache میتوان از محاسبه دوباره یک نتیجه جلوگیری کرد. اما بهترین محاسبه گاهی محاسبهای است که اصلا انجام نمیشود. اگر مصرفکننده فقط دو نتیجه اول را بخواهد، نیازی نیست صدها نتیجه دیگر را از قبل بسازیم.
در قسمت ششم مجموعه، زمان اجرای محاسبه را کنترل میکنیم. به جای تولید یکجای همه خروجیها، هر مقدار را فقط هنگام درخواست مصرفکننده میسازیم.
در حالت عادی وقتی یک عبارت یا زنجیره پردازش را اجرا میکنیم، نتیجه همان لحظه ساخته میشود. Lazy Evaluation میگوید محاسبه را تا زمانی که واقعا به نتیجه نیاز نداریم، عقب بیندازیم.
این کار برای دادههای زیاد مهم است. فرض کنید میخواهیم اولین سه سفارش گرانتر از یک میلیون تومان را از بین هزاران سفارش پیدا کنیم. لازم نیست ابتدا همه سفارشها را فیلتر کنیم و یک آرایه بزرگ در حافظه بسازیم.
در روش eager، معمولا filter روی کل آرایه اجرا میشود و بعد با slice چند نتیجه اول را برمیداریم. حتی اگر نتیجه موردنظر در ابتدای آرایه باشد، همه اعضا بررسی میشوند. روش lazy هر عضو را فقط زمانی بررسی میکند که مصرفکننده مقدار بعدی را بخواهد.
پردازش تنبل با generator
Generator در JavaScript مقدارها را یکییکی تولید میکند و بعد از هر yield منتظر درخواست بعدی میماند.
فراخوانی یک تابع generator بدنه آن را کامل اجرا نمیکند. نتیجه یک iterator است. حلقه for...of از iterator مقدار بعدی را میخواهد، تابع تا yield جلو میرود و همانجا متوقف میشود. این رفتوبرگشت تا پایان داده یا شکستن حلقه ادامه دارد.
function* expensiveOrders(orders, minimum) {
for (const order of orders) {
console.log(`بررسی سفارش ${order.id}`);
if (order.total >= minimum) yield order;
}
}
const orders = [
{ id: 1, total: 400000 },
{ id: 2, total: 1200000 },
{ id: 3, total: 900000 },
{ id: 4, total: 1800000 },
{ id: 5, total: 2500000 }
];
const result = [];
for (const order of expensiveOrders(orders, 1000000)) {
result.push(order);
if (result.length === 2) break;
}
console.log(result.map(order => order.id)); // [2, 4]
سفارش شماره ۵ اصلا بررسی نمیشود، چون بعد از پیدا شدن دو نتیجه حلقه را متوقف کردهایم. همچنین آرایهای شامل همه سفارشهای فیلترشده ساخته نشده است.
اگر بخواهیم رفتار را دقیقتر ببینیم، پیامهای بررسی سفارش ابتدا برای شمارههای ۱ تا ۴ چاپ میشوند. شماره ۱ شرط را ندارد. شماره ۲ اولین نتیجه است. شماره ۳ رد میشود و شماره ۴ نتیجه دوم را میسازد. سپس break اجرای iterator را تمام میکند.
به چه دردی میخورد؟
برای خواندن فایلهای بزرگ، پردازش جریان داده، صفحهبندی نتایج و ساخت دنبالههایی که ممکن است بینهایت باشند مناسب است. مثلا generator میتواند شناسههای بعدی را بدون ساختن یک آرایه بینهایت تولید کند.
در سمت سرور هم میتوان نتیجههای پایگاه داده را صفحهبهصفحه خواند و هر رکورد را پردازش کرد. این روش مصرف حافظه را ثابتتر نگه میدارد. البته lazy بودن کد برنامه، یک query نامناسب پایگاه داده را اصلاح نمیکند؛ بهتر است فیلتر و محدودیت تا جای ممکن در خود query انجام شوند.
Lazy Evaluation همیشه سریعتر نیست. اگر در نهایت به همه نتیجهها نیاز داشته باشیم، همان محاسبهها انجام میشوند و generator هم کمی هزینه مدیریتی دارد. مزیت اصلی وقتی دیده میشود که فقط بخشی از داده لازم است، ساخت نتیجه پرهزینه است یا نمیخواهیم همه چیز همزمان در حافظه باشد.
یک نکته دیگر هم وجود دارد: کد تنبل دقیقا هنگام تعریف شدن اجرا نمیشود. خطا هم ممکن است دیرتر و موقع پیمایش دیده شود. پس نامگذاری روشن و تست کردن زمان اجرای واقعی اهمیت بیشتری پیدا میکند.
Lazy Evaluation با asynchronous بودن فرق دارد
کد lazy لزوما async نیست. generator نمونه ما کاملا همزمان اجرا میشود، فقط زمان انجام محاسبه را عقب میاندازد. از طرف دیگر یک Promise ممکن است همان لحظه کارش را شروع کند، حتی اگر نتیجه آن را بعدا await کنیم.
برای جریانهای غیرهمزمان JavaScript ابزار async generator و حلقه for await...of را دارد. آن مدل برای دریافت صفحههای بعدی API یا خواندن stream مناسب است، اما اصل ماجرا همان تولید مقدار در زمان درخواست باقی میماند.
چه زمانی انتخاب خوبی نیست؟
اگر قرار است چند بار روی نتیجه حرکت کنیم، iterator یکبارمصرف ممکن است غافلگیرکننده باشد. اگر داده کم است و در نهایت همه آن لازم میشود، یک آرایه معمولی خواناتر است. Lazy Evaluation را زمانی انتخاب کنیم که توقف زودهنگام، جریان بزرگ داده یا کاهش مصرف حافظه واقعا برای مسئله اهمیت دارد.
ادامه دارد
قسمت ششم اینجا تمام میشود، اما مسیر هنوز ادامه دارد. چند روز دیگر قسمت هفتم مجموعه منتشر خواهد شد.