از رویداد تا معماری قابل کنترل، قسمت پنجم: Computed Value و Memoization
در قسمت چهارم یاد گرفتیم رابطه میان داده و effect را خودکار پیدا کنیم. حالا سیستم میداند یک تابع چه زمانی باید دوباره اجرا شود. سوال بعدی این است که آیا لازم است نتیجه هر بار از صفر محاسبه شود؟
در قسمت پنجم با computed value و memoization آشنا میشویم. این دو مفهوم کمک میکنند داده مشتقشده را درست مدل کنیم و محاسبه تکراری را فقط وقتی لازم است انجام دهیم.
بعضی دادهها را ذخیره نمیکنیم چون از دادههای دیگر به دست میآیند. مثلا مبلغ نهایی سبد خرید از جمع قیمت کالاها ساخته میشود. به چنین مقداری Computed Value میگوییم.
اگر محاسبه سنگین باشد یا زیاد تکرار شود، میتوانیم نتیجه قبلی را نگه داریم. این کار Memoization نام دارد.
یک قانون ساده برای تشخیص computed این است: اگر بتوانیم مقدار را از stateهای دیگر به دست بیاوریم، احتمالا لازم نیست جداگانه ذخیرهاش کنیم. ذخیره همزمان price، count و total ممکن است آنها را ناسازگار کند. بهتر است total از ضرب دو مقدار دیگر ساخته شود.
یک مثال ساده
فرض کنید محاسبه تخفیف تابعی خالص است؛ یعنی با ورودی یکسان همیشه خروجی یکسان میدهد و چیزی بیرون از خودش را تغییر نمیدهد.
function memoize(fn) {
const cache = new Map();
return value => {
if (cache.has(value)) return cache.get(value);
const result = fn(value);
cache.set(value, result);
return result;
};
}
let calculations = 0;
const discountedPrice = memoize(price => {
calculations++;
return price * 0.9;
});
console.log(discountedPrice(200000)); // 180000
console.log(discountedPrice(200000)); // 180000
console.log(calculations); // 1
بار اول نتیجه محاسبه و در cache ذخیره میشود. بار دوم همان نتیجه برمیگردد و بدنه تابع دوباره اجرا نمیشود.
در اینجا خود Map نقش cache را دارد. کلید آن ورودی تابع و مقدار آن نتیجه محاسبه است. استفاده از cache.has مهم است؛ اگر نتیجه معتبر تابع undefined یا صفر باشد، بررسی سادهای مثل if (cache.get(value)) اشتباه عمل میکند.
Computed Value کجای این مثال است؟
discountedPrice(200000) یک مقدار محاسبهشده است چون از قیمت اصلی به دست میآید. memoize فقط یک روش برای نگهداری نتیجه آن است. پس این دو یکی نیستند: computed درباره رابطه بین دادههاست و memoization درباره جلوگیری از کار تکراری.
در سیستمهای reactive، یک computed معمولا هر دو ویژگی را دارد. وابستگیهایش را میشناسد و تا وقتی آنها عوض نشدهاند، مقدار قبلی را پس میدهد.
مثلا fullName به firstName و lastName وابسته است. خواندن چندباره آن نباید هر بار محاسبه تازهای انجام دهد. اما به محض تغییر یکی از دو نام، cache باید نامعتبر شود تا خواندن بعدی نتیجه جدید را بسازد. به این فرایند cache invalidation میگویند.
چه زمانی استفاده کنیم؟
برای فیلتر کردن یک فهرست بزرگ، محاسبه گزارش آماری یا تبدیل دادهای که ورودی مشخص و محاسبه پرهزینه دارد مفید است. برای جمع دو عدد یا ساختن یک رشته کوتاه معمولا cache کردن ارزشی ندارد و حتی کد را پیچیدهتر میکند.
قبل از استفاده بهتر است دو سوال بپرسیم: آیا این تابع واقعا چند بار با ورودی تکراری اجرا میشود؟ آیا هزینه محاسبه از هزینه نگهداری cache بیشتر است؟ اگر پاسخ روشن نیست، ابتدا اندازهگیری بهتر از اضافه کردن خودکار memoization است.
این نسخه فقط یک آرگومان از نوع primitive را پوشش میدهد. استفاده از JSON.stringify برای ساخت کلید همه مسئلهها را حل نمیکند؛ ترتیب ویژگیها، دادههای بزرگ و مقادیر غیرقابل تبدیل دردسرساز میشوند. بهتر است cache را متناسب با شکل واقعی ورودی طراحی کنیم و فقط وقتی اندازهگیری نشان داد محاسبه تکراری هزینه دارد، سراغ memoization برویم.
هزینه پنهان cache
هر نتیجهای که نگه میداریم حافظه مصرف میکند. اگر ورودیها دائما تازه باشند، Map بدون محدودیت رشد میکند و تقریبا هیچ cache hit هم نداریم. در چنین شرایطی باید اندازه یا زمان اعتبار cache محدود شود، یا اصلا memoization را کنار بگذاریم.
تابع مناسب memoization باید تا حد ممکن خالص باشد. اگر نتیجه به ساعت، شبکه یا یک متغیر بیرونی وابسته باشد، یک ورودی ظاهرا یکسان ممکن است نتیجه متفاوتی بخواهد. cache کردن چنین تابعی میتواند داده قدیمی و اشتباه برگرداند.
ادامه دارد
به نیمه این مسیر آموزشی رسیدیم. قسمت ششم چند روز دیگر منتشر میشود و داستان را از همینجا ادامه میدهد.