چگونه Closure در جاوااسکریپت شکل می‌گیرد

مقدمه

در فصل قبل، مفاهیم Lexical Scope، Scope Chain، Execution Context، Lexical Environment و Call Stack را با جزئیات کامل بررسی کردیم. هر یک از این مفاهیم، آجری بود برای رسیدن به یکی از قدرتمندترین و در عین حال بدفهم‌ترین ویژگی‌های جاوااسکریپت: Closure. در این درس، دقیقاً می‌بینیم Closure چیست و چگونه شکل می‌گیرد؛ و در درس‌های بعدی همین فصل، کاربردها و نکات ظریف آن را بررسی خواهیم کرد.

یک مشاهده‌ی عجیب برای شروع

پیش از تعریف رسمی Closure، بیایید با یک مثال شروع کنیم که در نگاه اول ممکن است غیرمنتظره به‌نظر برسد:

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

تحلیل کد

در این مثال، تابع createCounter فراخوانی می‌شود، مقدار increment را برمی‌گرداند و طبق چیزی که در فصل قبل درباره‌ی Call Stack یاد گرفتیم، Execution Context مربوط به createCounter باید از Call Stack خارج شده و از بین رفته باشد. بر همین اساس، انتظار می‌رفت متغیر count که داخل آن Execution Context تعریف شده بود، دیگر در دسترس نباشد.

اما خروجی این کد نشان می‌دهد که هر بار counter() صدا زده می‌شود، مقدار count درست به‌خاطر سپرده شده و افزایش پیدا می‌کند: 1، سپس 2، سپس 3. یعنی تابع increment، حتی پس از پایان کامل اجرای createCounter، همچنان به متغیر count دسترسی دارد و آن را به‌خاطر می‌سپارد. این دقیقاً پدیده‌ای است که Closure نام دارد.

تعریف دقیق Closure

Closure یعنی: یک تابع، همراه با Lexical Environment‌ای که در آن تعریف شده است. به بیان دقیق‌تر، هر تابع در جاوااسکریپت، به‌طور طبیعی و خودکار، ارجاعی به Lexical Environment محل تعریف خودش نگه می‌دارد؛ این ارجاع، صرف‌نظر از این‌که تابع کِی یا کجا فراخوانی شود، همیشه با آن تابع همراه است. Closure دقیقاً همین ترکیب تابع + دسترسی پایدار به Environment Record محل تعریفش است.

چرا این اتفاق ممکن است؟

در درس Lexical Environment از فصل قبل دیدیم که هر Execution Context، یک Lexical Environment مرتبط دارد که شامل یک Environment Record است. نکته‌ی کلیدی این است: Environment Record یک Scope، تا زمانی که حداقل یک ارجاع به آن وجود داشته باشد، توسط Garbage Collector جاوااسکریپت از حافظه پاک نمی‌شود؛ حتی اگر Execution Context اصلی که آن را ساخته، از Call Stack خارج شده و به پایان رسیده باشد.

در مثال بالا، وقتی createCounter تابع increment را return می‌کند، این تابع بازگشتی همچنان (به‌صورت داخلی و نامرئی برای برنامه‌نویس) ارجاعی به Lexical Environment مربوط به فراخوانی createCounter نگه می‌دارد. چون این ارجاع باقی می‌ماند، Environment Record مربوطه -که شامل count است- هرگز حذف نمی‌شود، حتی با اینکه Execution Context اصلی از Call Stack خارج شده است.

هر فراخوانی، یک Closure مستقل می‌سازد

نکته‌ی مهم دیگر این است که هر بار createCounter فراخوانی می‌شود، یک Lexical Environment کاملاً جدید و مستقل ساخته می‌شود؛ به همین دلیل، دو Closure مختلف که از دو فراخوانی جداگانه‌ی createCounter ساخته شده‌اند، هیچ ارتباطی با یکدیگر ندارند:

دو Closure مستقل از دو فراخوانی جداگانه
خروجی کنسول اینجا نمایش داده می‌شود...

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

خروجی این کد 1، 2، 1 است، نه 1، 2، 3. دلیل این است که counterA و counterB، هرکدام از یک فراخوانی جداگانه‌ی createCounter ساخته شده‌اند و در نتیجه، هرکدام Lexical Environment و متغیر count کاملاً مستقل خودشان را دارند. افزایش count در counterA، هیچ تأثیری روی count مربوط به counterB ندارد؛ درست همان‌طور که دو Function Execution Context مختلف از یک تابع، در فصل قبل، هیچ‌گاه در متغیرهای محلی یکدیگر تداخل نمی‌کردند.

Closure نیازی به return ندارد

Closure لزوماً از طریق return کردن یک تابع شکل نمی‌گیرد؛ هر شکلی که یک تابع داخلی، پس از پایان تابع بیرونی، همچنان «زنده» بماند و قابل فراخوانی باشد، Closure محسوب می‌شود. یک نمونه‌ی رایج دیگر، پاس دادن تابع داخلی به‌عنوان Callback است:

function delayedGreeting(name) {
  setTimeout(function () {
    console.log("سلام " + name);
  }, 1000);
}

delayedGreeting("علی");

در این مثال، تابعی که به setTimeout پاس داده شده، یک ثانیه بعد از پایان کامل اجرای delayedGreeting اجرا می‌شود؛ اما همچنان به پارامتر name که در Lexical Environment آن تابع بیرونی تعریف شده، دسترسی دارد. جزئیات کامل نحوه‌ی کار setTimeout و زمان‌بندی اجرای آن را در فصل Asynchronous JavaScript به‌طور کامل بررسی خواهیم کرد؛ در این مرحله فقط به این نکته توجه کنید که این هم یک نمونه‌ی Closure است.

تصور نادرست رایج درباره‌ی Closure

یک تصور نادرست رایج این است که Closure یعنی تابع، «یک کپی» از مقدار متغیرهای بیرونی را در لحظه‌ی تعریف برمی‌دارد. این تصور اشتباه است؛ Closure به خودِ Environment Record ارجاع دارد، نه یک عکس لحظه‌ای (Snapshot) از مقادیر آن:

Closure به متغیر زنده ارجاع دارد، نه یک کپی از مقدار آن
خروجی کنسول اینجا نمایش داده می‌شود...

تحلیل کد

خروجی این کد "نسخه‌ی دوم" است، نه "نسخه‌ی اول"؛ با اینکه text در لحظه‌ی تعریف showMessage برابر "نسخه‌ی اول" بود. این نشان می‌دهد که showMessage واقعاً به خودِ متغیر text در Environment Record متصل است، نه به مقداری که آن متغیر در یک لحظه‌ی خاص داشت. هر تغییری که پیش از فراخوانی نهایی تابع، روی آن متغیر اعمال شود، در Closure هم منعکس خواهد شد.

جدول جمع‌بندی

مفهومتوضیح
Closureتابع + دسترسی پایدار به Lexical Environment محل تعریف خودش
دلیل ماندگاریوجود حداقل یک ارجاع، مانع پاک شدن Environment Record توسط Garbage Collector می‌شود
استقلال فراخوانی‌هاهر فراخوانی تابع بیرونی، Lexical Environment و Closure مستقل خودش را می‌سازد
ارجاع، نه کپیClosure به خودِ متغیر متصل است، نه به مقدار لحظه‌ای آن

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

Closure مستقیماً روی مفهوم Garbage Collection که در فصل Memory بررسی خواهیم کرد تأثیر می‌گذارد؛ چون یک Closure فعال، می‌تواند مانع آزاد شدن حافظه‌ی مربوط به یک Environment Record شود. در درس بعدی، کاربردهای عملی Closure -از جمله ساخت حالت خصوصی (Private State)- را بررسی می‌کنیم؛ و در ادامه، به مبحث حساسی به نام Closure و Memory و اشتباهات رایج مرتبط با آن خواهیم رسید.

جمع‌بندی

Closure زمانی شکل می‌گیرد که یک تابع، پس از پایان اجرای تابع بیرونی خودش، همچنان به Lexical Environment آن دسترسی داشته باشد. این اتفاق به این دلیل ممکن است که Environment Record یک Scope، تا زمانی که حداقل یک ارجاع فعال به آن وجود دارد، از حافظه پاک نمی‌شود. هر فراخوانی از تابع بیرونی، Closure کاملاً مستقل خودش را می‌سازد، و Closure همیشه به خودِ متغیر متصل است، نه به یک کپی ثابت از مقدار آن در لحظه‌ی تعریف.