وقتی Closure رفتار غیرمنتظره تولید می‌کند: بررسی چند خطای کلاسیک

مقدمه

در درس‌های قبل این فصل، با شکل‌گیری Closure، کاربردهای آن و رابطه‌اش با حافظه آشنا شدیم. در این درس، به‌عنوان جمع‌بندی عملی، به سراغ یکی از معروف‌ترین خطاهای برنامه‌نویسان جاوااسکریپت می‌رویم؛ خطایی که مستقیماً از سوءتفاهم درباره‌ی Closure و حلقه‌ها ناشی می‌شود، و سپس چند نکته‌ی ظریف دیگر را نیز بررسی می‌کنیم.

مشکل کلاسیک: Closure داخل حلقه با var

این مثال، احتمالاً معروف‌ترین سؤال مصاحبه‌ی مرتبط با Closure در جاوااسکریپت است:

رفتار غیرمنتظره‌ی Closure با var داخل حلقه
خروجی کنسول اینجا نمایش داده می‌شود...

تحلیل رفتار جاوااسکریپت

انتظار رایج این است که این کد اعداد 1، 2 و 3 را چاپ کند. اما خروجی واقعی آن، سه‌بار عدد 4 است. دلیل این رفتار دقیقاً به همان چیزی برمی‌گردد که در فصل قبل درباره‌ی var یاد گرفتیم: var دارای Function Scope است، نه Block Scope؛ به همین دلیل، تمام سه Callback ای که داخل setTimeout تعریف شده‌اند، به یک متغیر i مشترک و واحد ارجاع می‌دهند؛ نه سه نسخه‌ی مستقل.

وقتی حلقه تمام می‌شود، مقدار نهایی i برابر 4 است (چون شرط i <= 3 در تکرار چهارم Falsy می‌شود و حلقه متوقف می‌گردد). از آن‌جا که هر سه Callback، Closure ای روی همان یک متغیر مشترک i ساخته‌اند -نه یک کپی مستقل از مقدار آن در هر تکرار- وقتی setTimeout بعد از ۱۰۰ میلی‌ثانیه Callback ها را اجرا می‌کند، هر سه به یک مقدار نهایی و یکسان، یعنی 4، دسترسی دارند.

راه‌حل اول: استفاده از let به‌جای var

ساده‌ترین راه‌حل مدرن، جایگزین کردن var با let است:

رفع مشکل با استفاده از let
خروجی کنسول اینجا نمایش داده می‌شود...

تحلیل کد

این نسخه به‌درستی 1، 2 و 3 را چاپ می‌کند. دلیل این تفاوت این است که let دارای Block Scope است؛ جاوااسکریپت در پیاده‌سازی حلقه‌های for با let، به‌صورت داخلی، در هر تکرار حلقه، یک Lexical Environment کاملاً جدید و مستقل برای متغیر i می‌سازد. به همین دلیل، هر Callback داخل setTimeout، Closure ای روی نسخه‌ی مخصوص به خودش از i تشکیل می‌دهد؛ نه یک متغیر مشترک بین همه‌ی تکرارها.

راه‌حل دوم (پیش از رواج let): استفاده از IIFE

پیش از معرفی let در ES6، برنامه‌نویسان برای حل این مشکل، از یک الگو به نام IIFE (Immediately Invoked Function Expression) استفاده می‌کردند تا به‌صورت دستی، برای هر تکرار یک Scope مستقل بسازند:

for (var i = 1; i <= 3; i++) {
  (function (capturedI) {
    setTimeout(function () {
      console.log(capturedI);
    }, 100);
  })(i);
}

در این روش، مقدار فعلی i بلافاصله (به‌عنوان آرگومان) به یک تابع مستقل پاس داده می‌شود؛ این تابع، پارامتر capturedI مخصوص به خودش را می‌سازد که در هر فراخوانی از حلقه، یک نسخه‌ی جداگانه است. جزئیات کامل نحوه‌ی کار IIFE را در فصل توابع پیشرفته بررسی خواهیم کرد؛ در این‌جا فقط لازم است بدانید که این الگو، همان مشکل را به روشی متفاوت حل می‌کند.

نکته‌ی ظریف دوم: تصور اشتباه «کپی مقدار» به‌جای «ارجاع زنده»

همان‌طور که در درس اول همین فصل دیدیم، یک تصور نادرست رایج این است که Closure یک عکس لحظه‌ای از مقدار متغیر می‌گیرد. این باور اشتباه، دقیقاً همان چیزی است که باعث می‌شود مثال بالا برای بسیاری از افراد غیرمنتظره باشد؛ چون آن‌ها فرض می‌کنند هر Callback مقدار i را «در لحظه‌ی تعریف» ذخیره کرده، درحالی‌که واقعیت این است که همه به همان متغیر زنده و مشترک وصل هستند (در حالت var).

نکته‌ی ظریف سوم: Closure در متدهای تکرارکننده‌ی آرایه

نکته‌ی مثبت این است که وقتی از متدهای تکرارکننده‌ی آرایه مثل forEach یا map که در فصل آرایه‌ها دیدیم استفاده می‌کنیم، این مشکل به‌طور طبیعی رخ نمی‌دهد؛ چون در هر فراخوانی از تابع Callback، یک پارامتر مستقل ساخته می‌شود:

[1, 2, 3].forEach(function (num) {
  setTimeout(function () {
    console.log(num);
  }, 100);
});
// خروجی به‌درستی: 1 2 3

دلیل این رفتار درست، همان چیزی است که در فصل توابع درباره‌ی Execution Context مستقل هر فراخوانی یاد گرفتیم: هر بار که forEach تابع Callback را فرا می‌خواند، یک Function Execution Context جدید با پارامتر num مستقل خودش ساخته می‌شود؛ برخلاف حلقه‌ی for سنتی با var که فقط یک Execution Context مشترک برای کل حلقه دارد.

جدول مقایسه‌ی نهایی

روشنتیجه با Closure در setTimeout
for با varتمام Callback ها به یک متغیر مشترک ارجاع می‌دهند (مقدار نهایی)
for با letهر Callback نسخه‌ی مستقل خودش از متغیر را دارد
forEach / mapهر Callback به‌طور طبیعی پارامتر مستقل خودش را دارد

ارتباط با سایر مفاهیم

این درس، فصل Closure را با نمونه‌ای عملی و بسیار رایج به پایان می‌رساند که مستقیماً سه مفهوم فصل قبل -یعنی Block Scope، Lexical Environment، و Execution Context مستقل هر فراخوانی- را در کنار هم به کار می‌گیرد. در فصل بعدی، به سراغ توابع پیشرفته می‌رویم؛ جایی که مفهوم IIFE که در این درس به‌طور مختصر دیدیم، با جزئیات کامل بررسی خواهد شد.

جمع‌بندی

رایج‌ترین خطای مرتبط با Closure، استفاده از var در حلقه‌ی for همراه با setTimeout یا هر Callback تأخیری دیگر است؛ چون تمام تکرارها به یک متغیر Function-Scoped مشترک ارجاع می‌دهند. جایگزینی var با let، با ساختن یک Lexical Environment مستقل برای هر تکرار حلقه، این مشکل را به‌طور طبیعی حل می‌کند. پیش از let، این مشکل با الگوی IIFE به‌صورت دستی حل می‌شد. متدهای تکرارکننده‌ی آرایه مثل forEach، به دلیل ساختار خودشان، ذاتاً از این مشکل مصون هستند.