تکرار کننده ها و مولدها (Iterators & Generators)
در بخش قبل با استفاده از بستارها یک تابع شمارنده ایجاد کردیم. ویژگی این تابع این بود که مقدار بازگشتی از آن در هر فراخوانی، وابسته به مقدار بازگشتی در فراخوانی قبلی بود. به بیان دیگر تابع شمارنده میتوانست تعداد فراخوانیهای خود را به خاطر سپرده و در هر فراخوانی با توجه به تعداد فراخوانیهای قبلی، خروجی متناسبی را تولید کند.
البته بستارها از گذشته در جاوا اسکریپت وجود داشته و قابل استفاده بودهاند. اما در ES6 یک ویژگی جدید به نام مولد (Generator) به جاوا اسکریپت اضافه شده است که امکان پیادهسازی چنین توابعی را بسیار سادهتر میکند. یعنی با استفاده از Generator ها نیز میتوان توابعی ایجاد کرد که بر اساس تعداد فراخوانیهای انجام شده، خروجیهای متفاوتی را تولید کنند.
نحوهی تعریف یک مولد بسیار شبیه به تعریف توابع معمولی است. با این تفاوت که اولاً باید بعد از کلمهی کلیدی function از علامت ستاره (*) استفاده شود. ثانیاً برای بازگرداندن یک مقدار از تابع مولد، به جای کلمهی کلیدی return باید از کلمهی کلیدی yield استفاده کرد. قطعه کد زیر نحوهی تعریف یک تابع مولد ساده را نشان میدهد.
function* myGenerator(){
yield 1;
yield 2;
}
توجه کنید که فراخوانی تابع فوق، موجب اجرای هیچ کدی نمیشود. بلکه با فراخوانی این تابع، یک شئ تکرار کننده (Iterator) بازگردانده میشود. حال با فراخوانی متد next از شئ تکرار کننده، کد موجود در تابع myGenerator اجرا میشود. اما نحوهی اجرای کدهای موجود در توابع مولد با توابع معمولی متفاوت است.
همانطور که میدانید در اجرای توابع معمولی، با رسیدن مفسر به دستور return، اجرای تابع به پایان میرسد. و مقدار مقابل کلمهی return به عنوان نتیجه از تابع بازگردانده میشود. اما در توابع مولد اجرای تابع با رسیدن به کلمهی کلیدی yield متوقف میشود. در این نوع توابع نیز مقدار مقابل کلمهی yield به عنوان نتیجه بازگردانده میشود.
اما نکتهی مهمی که توابع مولد را از توابع معمولی متمایز میکند این است که با فراخوانی مجدد متد next از شئ تکرار کننده، اجرای تابع مولد از نقطهای آغاز میشود که در فراخوانی قبلی متوقف شده بود. یعنی از دستوری که بعد از دستور yield قرار دارد. همچنین تمام متغیرهای محلی تابع مولد، مقدار قبلی خود را در فراخوانی جدید حفظ میکنند. این در حالی است که در توابع معمولی با هر بار فراخوانی تابع، اجرای تابع از ابتدای تابع شروع میشود و تمام متغیرهای محلی نیز مقدار خود در فراخوانی قبلی را از دست دادهاند. قطعه کد زیر نحوهی استفاده از تابع مولد فوق را نشان میدهد.
let it = myGenerator();
console.log(it.next());
← {value: 1, done: false}
console.log(it.next());
← {value: 2, done: false}
در قطعه کد فوق ابتدا با فراخوانی تابع مولد myGenerator یک تکرار کننده ایجاد شده و در متغیر it ذخیره شده است. سپس در خط بعدی متد next از این تکرار کننده فراخوانی شده است. این کار باعث اجرا شدن دستورات داخل تابع myGenerator میشود. در نتیجه این تابع اجرا شده و در همان دستور اول متوقف میشود. زیرا دستور اول این تابع کلمهی کلیدی yield است. در نتیجه مقدار مقابل این کلمه، یعنی عدد 1 به عنوان نتیجه بازگردانده میشود.
البته همانطور که مشاهده میکنید مقداری که در کنسول نمایش داده شده است، یک شئ است نه یک عدد. این شئ دارای دو خاصیت به نامهای value و done است. در واقع خاصیت value همان مقدار مقابل کلمهی کلیدی yield را نگهداری میکند.
در فراخوانی بعدی متد next، مقدار خاصیت value برابر با عدد 2 است. زیرا در این فراخوانی، اجرای تابع مولد از خط دوم شروع میشود. به همین دلیل دستور yield دوم اجرا شده و مقدار 2 نیز به عنوان نتیجه بازگردانده میشود.
اما مقدار ذخیره شده در خاصیت done به چه معنی است و چه کاربردی دارد؟ برای پاسخ دادن به این سوال کافی است یک بار دیگر متد next را فراخوانی کرده و نتیجه را مشاهده کنید.
console.log(it.next());
← {value: undefined, done: true}
مشاهده میکنید که در این فراخوانی، برخلاف فراخوانیهای قبلی مقدار خاصیت done برابر با true است. همچنین مقدار خاصیت value نیز undefined است. در واقع خاصیت done نشان میدهد که تابع مولد به طور کامل اجرا شده است یا خیر؟
یعنی تا زمانی که اجرای تابع مولد به پایان نرسیده باشد، در تمام فراخوانیهای متد next، مقدار بازگشتی برای خاصیت done برابر با false است. اما پس از پایان اجرای تابع مولد، فراخوانی متد next، مقدار true را برای خاصیت done و مقدار undefined را برای خاصیت value بازمیگرداند. همچنین اگر فراخوانی متد next را تکرار کنیم، باز هم نتیجه مشابهی را دریافت خواهیم کرد.
مثالهایی از تکرار کنندهها و مولدها
مثالی که برای بیان مفهوم تکرار کنندهها و مولدها به کار رفت مثال بسیار سادهای بود. در عمل میتوان توابع مولد بسیار پیچیدهتر و مفیدتری را ایجاد کرد و در حل مسائل عملی از آنها استفاده کرد. به عنوان مثال تابع تکرار کنندهای که در بخش قبل با استفاده از بستارها ایجاد کردیم را در نظر بگیرید. فرض کنید قصد داریم کار مشابهی را با استفاده از توابع مولد انجام دهیم. برای این منظور میتوان از تابع مولد زیر استفاده کرد.
function* myCounter(){
let i = 1;
while(true){
yield i++;
}
}
حال میتوان با استفاده از این تابع مولد یک تکرار کننده (Iterator) ایجاد کرد. قطعه کد زیر نحوهی ایجاد تکرار کننده و نحوهی استفاده از آن را نشان میدهد.
let counter = myCounter();
console.log(counter.next());
← {value: 1, done: false}
console.log(counter.next().value);
← 2
console.log(counter.next().value);
← 3
توجه کنید که برخلاف مثال قبلی، در این مثال مقدار خاصیت done هیچگاه برابر با true نخواهد شد. زیرا به دلیل قرار دادن دستور yield در یک حلقهی بینهایت، اجرای تابع مولد هیچگاه به پایان نمیرسد. در نتیجه به هر تعداد دلخواهی میتوان متد next را فراخوانی کرد. و به ازای هر فراخوانی، مقدار خاصیت value یک واحد نسبت به فراخوانی قبلی اضافه میشود. همچنین توجه کنید که در فراخوانیهای دوم و سوم متد next، جهت نمایش مقدار خاصیت value در کنسول، از خاصیت value به صورت زنجیرهای پس از متد next استفاده شده است.
به عنوان مثالی دیگر، "دنبالهی اعداد فیبوناچی" را در نظر بگیرید. فرض کنید قصد داریم یک تابع مولد برای تولید جملات این دنباله ایجاد کنیم. در این دنباله، دو عدد ابتدایی به صورت دلخواه تعیین میشوند. سپس اعداد بعدی از مجموع دو عدد قبلی به دست میآیند. مثلاً اگر دو عدد ابتدایی را 1 و 2 در نظر بگیریم، 10 جملهی ابتدایی این دنباله به صورت زیر خواهند بود.
1 , 2 , 3 , 5 , 8 , 13 , 21 , 34 , 55 , 89
برای تولید چنین دنبالهای از اعداد میتوان از تابع مولد زیر استفاده کرد. توجه کنید که در این تابع جهت کوتاهتر شدن کدها از Destructuring استفاده شده است.
function* fibonacci(a , b) {
let [prev , current] = [a , b];
while(true) {
[prev , current] = [current , prev + current];
yield current;
}
}
مشاهده میکنید که این تابع مولد دو پارامتر ورودی نیز دارد. از پارامترهای ورودی در توابع مولد معمولاً برای تعیین شرایط اولیه استفاده میشود. در نتیجه با استفاده از یک تابع مولد میتوان چندین تکرار کنندهی مختلف با شرایط اولیهی متفاوت ایجاد کرد.
در تابع فوق، ابتدا مقدار پارامترهای ورودی a و b در متغیرهای محلی prev و current ذخیره میشوند. سپس یک حلقهی بینهایت آغاز شده و در هر تکرار مقدار current در متغیر prev ذخیره میشود. و مجموع current و prev در متغیر current ذخیره میشود. این یعنی محاسبهی عدد جدید از مجموع دو عدد قبلی. سپس مقدار ذخیره شده در متغیر current با دستور yield بازگردانده میشود. دقت کنید که این تابع مولد نیز مانند تابع مولد قبلی به صورت نامحدود میتواند اجرا شود و هیچگاه اجرای آن به پایان نخواهد رسید. در نتیجه مقدار خاصیت done در شئ بازگشتی از متد next همیشه false خواهد بود. در قطعه کد زیر دو تکرار کننده با شرایط اولیهی متفاوت از این تابع مولد ایجاد شده است.
let sequence1 = fibonacci(1 , 2);
let sequence2 = fibonacci(2 , 7);
console.log(sequence1.next().value);
← 3
console.log(sequence1.next().value);
← 5
console.log(sequence1.next().value);
← 8
console.log(sequence1.next().value);
← 13
console.log(sequence1.next().value);
← 21
console.log(sequence2.next().value);
← 9
console.log(sequence2.next().value);
← 16
console.log(sequence2.next().value);
← 25
مشاهده میکنید که دنبالهی اعداد تولید شده توسط دو تکرار کننده، به دلیل آرگومانهای ارسالی متفاوت به تابع مولد fibonacci، متفاوت است. تابع مولد myCounter را نیز میتوان به صورت زیر تغییر داد تا امکان تعیین نقطهی شروع تکرار کننده، به این تابع مولد اضافه شود.
function* myCounter(start = 1){
let i = start;
while(true){
yield i++;
}
}
نکته : در تعریف توابع مولد، تعداد فضاهای خالی بین کاراکتر ستاره (*) و کلمهی کلیدی function و نام تابع مولد، هیچ اهمیتی ندارد. یعنی هر سه روش زیر برای تعریف تابع مولدی با نام myGenerator صحیح هستند.
function* myGenerator(){}
function *myGenerator(){}
function * myGenerator(){}
نکته : توابع مولد را نیز میتوان مانند قطعه کد زیر به صورت Function Expression تعریف کرد.
const myGenerator = function*(){}
نکته : در صورت نیاز میتوان از توابع مولد به عنوان متدهای اشیاء استفاده کرد. قطعه کد زیر یکی از روشهای انجام این کار را نشان میدهد.
const obj = {
*generator () {
yield 'a';
yield 'b';
}
}
تکرار کنندهها و حلقههای for-of
در فصل سوم با حلقههای for-of آشنا شدیم. و دیدیم که بسیاری از انواع دادهها در جاوا اسکریپت، از جمله آرایهها، مجموعهها و نقشهها را میتوان همراه با حلقههای for-of به کار برد. میدانید که مزیت اصلی این نوع حلقهها نسبت به حلقههای for، عدم نیاز به بررسی اندیس آرایهها (یا سایر انواع دادهها) است. نکتهی مهم در رابطه با حلقهی for-of این است که نحوهی عملکرد این نوع حلقهها کاملاً وابسته به مفهوم تکرار کننده یا Iterator است.
در واقع اشیائی مانند Array یا Set و یا Map، نوعی تکرار کننده هستند. به همین دلیل نیز در حلقههای for-of قابل استفاده میباشند. بنابراین هر شئ تکرار کنندهی دیگری را نیز میتوان در حلقههای for-of به کار برد. مثلاً تکرار کنندهی بازگردانده شده از تابع مولد fibonacci را میتوان به این شکل در یک حلقهی for-of به کار برد.
let sequence = fibonacci(0 , 1);
for(let n of sequence){
console.log(n);
}
در قطعه کد فوق در هر تکرار از حلقهی for-of، متد next از شئ تکرار کنندهی sequence به صورت ضمنی فراخوانی شده و از شئ بازگردانده شده از این متد،.مقدار خاصیت done بررسی میشود. در صورتی که مقدار این خاصیت false باشد، مقدار خاصیت value در متغیر n قرار گرفته و یک بار دستورات داخل حلقه اجرا میشوند. اما در صورتی که مقدار خاصیت done برابر با true باشد، اجرای حلقه به پایان میرسد. توجه کنید که رفتار حلقههای for-of با سایر انواع تکرار کنندهها (مانند آرایهها یا مجموعهها) نیز دقیقاً به همین شکل است.
البته در این مثال خاص، با توجه به اینکه حلقهی موجود در تابع مولد fibonacci هیچگاه به پایان نمیرسد، اجرای حلقهی for-of نیز هیچگاه به پایان نخواهد رسید. زیرا مقدار خاصیت done هیچگاه true نخواهد شد. اما میتوان کد فوق را به صورت زیر اصلاح کرد تا فقط تعداد مشخصی از جملات دنبالهی فیبوناچی در خروجی نمایش داده شوند. (در این مثال اعدادی که کوچکتر از 100 هستند)
let sequence = fibonacci(0 , 1);
for(let n of sequence){
if(n < 100){
console.log(n);
}else{
break;
}
}
با اجرای قطعه کد فوق، خروجی زیر تولید خواهد شد.
← 1
← 2
← 3
← 5
← 8
← 13
← 21
← 34
← 55
← 89
این برنامه را میتوانید اینجا اجرا کنید. همچنین میتوان تابع مولد fibonacci را به نحوی اصلاح کرد که حلقهی for-of به صورت نامحدود اجرا نشود. مثلاً میتوان تابع مولد را به شکل زیر اصلاح کرد تا حداکثر امکان تولید 20 جمله از دنبالهی فیبوناچی را داشته باشد.
function* fibonacci(a , b) {
let [prev , current] = [a , b];
let i = 0;
while(true) {
[prev , current] = [current , prev + current];
yield current;
i++;
if(i >= 20){
return;
}
}
}
در این تابع مولد، پس از 20 بار اجرا شدن حلقهی while، شرط موجود در خط 8 برقرار شده و با استفاده دستور return در خط 9 اجرای تابع متوقف میشود. توجه کنید که در صورت اجرای دستور return در توابع مولد، اجرای تابع به صورت کامل متوقف میشود. یعنی از این به بعد فراخوانی متد next، شیئی را بازمیگرداند که مقدار خاصیت done در آن برابر با true است. در نتیجه در صورت اجرای قطعه کد زیر، حلقهی for-of بعد از 20 بار تکرار متوقف خواهد شد.
let sequence = fibonacci(0 , 1);
for(let n of sequence){
console.log(n);
}
پس با استفاده از تکرار کنندهها و مولدها میتوان توابعی ایجاد کرد که پس از هر بار اجرا شدن، وضعیت خود را حفظ کنند. یعنی در هر بار فراخوانی شدن این نوع توابع، اجرای تابع از نقطهای شروع میشود که در فراخوانی قبلی اجرای تابع در آن نقطه متوقف شده بود. همچنین تکرار کنندههای ایجاد شده را میتوان به سادگی در حلقههای for-of به کار برد. و همانطور که اشاره شد، برخی از انواع دادهها در جاوا اسکریپت، در واقع نوعی تکرار کننده هستند. به همین دلیل میتوان آنها را در حلقههای for-of به کار برد.
البته نکات بیشتری در رابطه با تکرار کنندهها و مولدها در جاوا اسکریپت وجود دارد که در فصلهای بعدی این کتاب به آنها خواهیم پرداخت.