Iterator و Generator؛ پیمایش داده با قرارداد روشن
در این درس ۵ بخش
در فهرست پیام یا گزارش، همیشه نمیخواهیم تمام نتیجههای میانی را یکجا بسازیم. گاهی میخواهیم مرحلهبهمرحله از یک منبع بخوانیم، چند ردیف را در یک دسته پردازش کنیم یا نمایش را پیش از پایان متوقف کنیم. Iterator و Generator قرارداد این پیمایش را روشن میکنند. این درس جای دورهٔ آرایه نیست؛ از for...of آشنا شروع میکنیم و میبینیم زبان چگونه مقدار بعدی را میخواهد. مثالها همگام و مستقلاند و در Node.js ۲۴ اجرا میشوند. هیچ درخواست شبکه، بارگیری صفحه یا ذخیرهٔ پنهانی پشت آنها نیست؛ هر انتظار را از روی همین کد میتوان پیشبینی کرد.
پیمایشپذیر با پیمایشگر فرق دارد
پیمایشپذیر یا iterable چیزی است که روش گرفتن پیمایشگر را دارد؛ در جاوااسکریپت این روش با کلید Symbol.iterator معرفی میشود. پیمایشگر یا iterator شیئی است که next دارد و در هر گام یک نتیجه با value و done میدهد. value مقدار گام و done نشانهٔ پایان است. آرایه برای هر پیمایش معمول، پیمایشگر تازه میسازد، ولی یک پیمایشگر مشخص، جای فعلی خودش را نگه میدارد. #for-of مصرفکنندهٔ این قرارداد است. تفاوت این دو مفهوم توضیح میدهد چرا دوبار چرخیدن روی آرایه با دوبار مصرفکردن همان iterator لزوماً یک نتیجه ندارد.
بلوک iterator-basics.mjs زیر را ذخیره و اجرا کنید. نخستین next یک مقدار را مصرف میکند؛ spread بعدی از همان نقطه ادامه میدهد، نه از ابتدای آرایه. پس از تمامشدن، مقدار تازهای باقی نمیماند. در مقابل، spread خود آرایه میتواند دوباره از شروع بخواند، چون پیمایشگر تازه گرفته میشود. هنگام اشکالزدایی، فقط شکل منبع را چاپ نکنید؛ ببینید آیا یک iterator مصرفشده را بین دو بخش به اشتراک گذاشتهاید. نامی مانند cursor میتواند این حالتداربودن را روشنتر از نام مبهم data نشان دهد.
const rows = ['m1', 'm2', 'm3'];
const cursor = rows[Symbol.iterator]();
console.log(cursor.next()); // { value: 'm1', done: false }
console.log([...cursor]); // ['m2', 'm3']
console.log([...cursor]); // []
console.log([...rows]); // ['m1', 'm2', 'm3']Generator تابعی است که میان گامها متوقف میشود
یک generator با function* نوشته میشود و فراخوانی آن شیء پیمایشگر میسازد. بدنه هنگام درخواست گام اجرا میشود و yield یک مقدار را تحویل میدهد؛ سپس اجرا تا درخواست بعدی متوقف میماند. این توقف با await یک نتیجهٔ شبکهای یکی نیست. Generator همگام از خودش نخ جدا یا کار موازی نمیسازد. مدخل #function را برای قرارداد ورودی و خروجی مبنا بگیرید، اما خروجی این نوع تابع را با خروجی تابع عادی اشتباه نگیرید: فراخوانی، فهرست کامل نتیجهها را فوراً محاسبه و برنمیگرداند. جای اجرای خطا نیز ممکن است به زمان مصرف منتقل شود.
در batches-lab.mjs، یک فهرست کوچک را به دستههای حداکثر دوتایی تقسیم میکنیم. size باید عدد صحیح در بازهٔ آموزشی یک تا صد باشد. این سقف، قانون تمام سامانههای چت نیست و صرفاً قرارداد تابع تمرین است. هر دسته با slice ساخته میشود و آرایهٔ ورودی کوتاه نمیشود. نتیجهٔ دستهها را ابتدا با next و سپس با spread مشاهده کنید. هدف این نیست که برای هر آرایهٔ سهعضوی Generator لازم است؛ میخواهیم الگوی تولید مرحلهای را بفهمیم تا بعداً هزینه و مالکیت مصرفکننده روشن باشد.
function* batches(items, size) {
if (!Array.isArray(items)) throw new TypeError('Expected array');
if (!Number.isInteger(size) || size < 1 || size > 100) {
throw new RangeError('Size must be 1..100');
}
for (let index = 0; index < items.length; index += size) {
yield items.slice(index, index + size);
}
}
const input = ['m1', 'm2', 'm3'];
const cursor = batches(input, 2);
console.log(cursor.next()); // { value: ['m1', 'm2'], done: false }
console.log(cursor.next()); // { value: ['m3'], done: false }
console.log(cursor.next()); // { value: undefined, done: true }
console.log(input); // ['m1', 'm2', 'm3']در این generator، بررسی ورودی داخل بدنه قرار دارد. بنابراین batches([], 0) بهتنهایی هنوز بدنه را اجرا نکرده است؛ هنگام next یا پیمایش، خطا آشکار میشود. اگر برنامه به رد فوری هنگام فراخوانی نیاز دارد، باید یک تابع بیرونی اعتبارسنج بسازید که بعداً generator داخلی را برگرداند. این انتخاب را در قرارداد بنویسید و آزمون را در نقطهٔ درست قرار دهید. همچنین #array-copy-spread کپی سطحی را توضیح میدهد: تازهبودن آرایهٔ دسته به معنی مستقلبودن همهٔ شیءهای درون آن نیست. در نمونه فقط شناسههای متنی داریم، پس این اشتراک پیچیدهتر را عمداً وارد مثال نکردهایم.
تولید تدریجی با منبعِ تدریجی یکسان نیست
در مثال، items از قبل یک آرایهٔ کامل در حافظه است. Generator فقط ساخت دستههای خروجی را تا زمان مصرف عقب میاندازد؛ دریافت همهٔ داده از سرور را کم نمیکند. اگر داده قبلاً دانلود شده، این الگو بهتنهایی صفحهبندی شبکه یا محدودیت حافظهٔ منبع نیست. spread همهٔ خروجی یک generator نیز دوباره آنها را در یک آرایه جمع میکند. برای منبع نامحدود، چنین کاری میتواند اجرا یا حافظه را گرفتار کند. پیش از ادعای بهترشدن کارایی، بدانید چه چیزی واقعاً تنبل تولید میشود و چه چیزی از قبل کامل نگهداری شده است.
در پروژهٔ چت، تاریخچهٔ نمونه عمداً محدود است. پیمایش دستهای میتواند تمرینی برای رندر مرحلهای باشد، اما جای محدودیت سرور یا قرارداد snapshot را نمیگیرد. اگر در میانهٔ پیمایش منبع را تغییر دهید، باید رفتار آن تغییر مشخص باشد؛ یک generator ساده، نسخهٔ ثابت داده یا قفل همزمانی ایجاد نمیکند. برای رابط قابل بازسازی، میتوانید ابتدا snapshot محدود و معتبر را بگیرید و سپس از همان نسخه نمایش بسازید. این توصیه دربارهٔ معنی داده است، نه الزام به کپی عمیق همهٔ اشیاء در هر بار نمایش.
پایان زودهنگام و واگذاری پیمایش را ببینید
گاهی مصرفکننده پس از نخستین نتیجه دیگر چیزی نمیخواهد. در for...of، خروج زودهنگام با break میتواند روش return پیمایشگر را برای پایاندادن فراخوانی کند. Generator اجازه میدهد کار پاکسازی را در finally بنویسیم. در مثال زیر فقط پیام چاپ میکنیم تا ترتیب روشن باشد؛ واقعاً فایل یا اتصال شبکهای باز نشده است. این سازوکار به معنی پاکشدن خودکار هر منبعی در سراسر برنامه نیست. باید منبع متعلق به همین پیمایش را بشناسید و مسیر خروج را آزمایش کنید. در فراخوانی دستی next نیز مسئولیت ادامه یا پایان مصرف را رها نکنید.
function* previewRows() {
try {
yield 'm1';
yield 'm2';
} finally {
console.log('preview closed');
}
}
for (const id of previewRows()) {
console.log(id); // m1
break;
}
// سپس preview closed چاپ میشود.فایل generator-close.mjs را اجرا کنید و خروجی را پیش از اجرا حدس بزنید. سپس break را بردارید و تفاوت را ببینید: این بار هر دو مقدار خوانده میشوند و finally در پایان طبیعی اجرا میشود. اگر فرایند با اجبار متوقف شود، از این مثال نتیجه نگیرید که فرصت پاکسازی همیشه وجود دارد. مرزهای خروج زودهنگامِ کنترلشده و پایان ناگهانی سیستم متفاوتاند. در ادامهٔ دوره، لغو تایمر و شنوندهٔ رابط را جدا بررسی میکنیم؛ نگهداشتن یک generator روی صفحهای که بسته شده، خودبهخود چرخهٔ عمر آن رابط را مدیریت نمیکند.
yield* پیمایش یک منبع دیگر را به همان جریان واگذار میکند. در بلوک delegation.mjs، ابتدا یک نشان آغاز و سپس شناسهها تولید میشوند. #return با yield یکی نیست: مقدار return نتیجهٔ پایان پیمایشگر است و for...of یا spread آن را بهعنوان عضو عادی فهرست تحویل نمیدهد. این تفاوت در ساخت زنجیرهٔ ابزارها مهم است؛ اگر انتظار دارید یک پیام پایانی در رابط دیده شود، صرفاً return کردن آن در generator کافی نیست. تصمیم بگیرید آن پیام، دادهٔ جریان است یا اطلاعات پایان عملیات و سپس آن را مطابق همان قرارداد بخوانید.
function* previewWithHeader(ids) {
yield 'preview';
yield* ids;
return 'finished';
}
console.log([...previewWithHeader(['m1', 'm2'])]);
// ['preview', 'm1', 'm2']
const manual = previewWithHeader([]);
console.log(manual.next()); // { value: 'preview', done: false }
console.log(manual.next()); // { value: 'finished', done: true }تمرین، پاسخ و معیار تحویل
تابع batches را برای پنج شناسه و اندازهٔ سه اجرا کنید. پاسخ باید دو دسته، یکی سهعضوی و دیگری دوعضوی، باشد و ورودی تغییر نکند. سپس اندازهٔ صفر را امتحان و خطا را هنگام مصرف بررسی کنید؛ پاسخ راهنما این است که ساخت generator را با اجرای بدنه اشتباه نگیرید. در آزمایش سوم یک پیمایشگر را دوبار با spread مصرف کنید؛ دور دوم خالی میشود. معیار تحویل، توضیح نقطهٔ اجرای کد، رفتار مصرف دوباره و علت اجراشدن finally است. افزودن yield به تابع، بدون توان توضیح این سه تصمیم، هنوز طراحی پیمایش قابل اتکا نیست.
برای نحو حلقه و پیمایش مقدارها، این درس پایه را مرور کنید: https://www.darsavand.com/courses/javascript-from-zero-to-project?lesson=7b537da1-eebf-4d59-8078-2022c6d49b72
برای مرجعهای مشترک و مرز کپی سطحی، به این درس متوسط برگردید: https://www.darsavand.com/courses/javascript-project-lab?lesson=506b935f-8cb8-4681-a728-c6c1776307e0
در پایان، کنار هر مثال بنویسید منبع از قبل کامل بوده یا تدریجی آمده، مصرفکننده چه زمانی پایان داده و کدام بخش مالک پاکسازی بوده است. این سه پرسش، پیوند مفید این درس با Stream در مسیر Node.js هستند؛ مفهوم stream را صرفاً از روی شباهت نامها با generator یکی ندانید.
منابع رسمی انگلیسی:
MDN — Iteration protocols: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols
MDN — Generator function: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function%2A
MDN — yield delegation: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/yield%2A
MDN — Generator.return: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator/return
تألیف و مثالهای مستقل: تحریریه درسآوند. منابع رسمی برای بررسی رفتار زبان و مطالعهٔ بیشتر معرفی شدهاند.
تألیف و مثالهای اختصاصی: تحریریه درسآوند. فایلهای کامل پروژه و راهنمای اجرا و دیباگ:
https://www.darsavand.com/resources/simple-websocket-chat-project
متن درسهای عمومی را بدون ورود بخوانید.