رابطه‌ی Closure با حافظه

مقدمه

در دو درس قبل دیدیم که Closure چگونه شکل می‌گیرد و چه کاربردهای عملی‌ای دارد. اما همان ویژگی‌ای که Closure را قدرتمند می‌کند -یعنی نگه داشتن Environment Record حتی پس از پایان تابع بیرونی- روی مدیریت حافظه هم تأثیر مستقیم دارد. در این درس، این رابطه را دقیق بررسی می‌کنیم؛ پیش‌زمینه‌ای که در فصل Memory با جزئیات کامل‌تری به آن بازخواهیم گشت.

مرور کوتاه: چرا حافظه معمولاً آزاد می‌شود؟

در حالت عادی، وقتی یک تابع اجرایش تمام می‌شود و از Call Stack خارج می‌شود، اگر هیچ ارجاعی به Lexical Environment آن باقی نمانده باشد، Garbage Collector جاوااسکریپت آن حافظه را آزاد می‌کند:

function createTemp() {
  let bigData = "مقداری داده";
  console.log(bigData);
}

createTemp();
// پس از پایان این فراخوانی، چون هیچ Closure ای ساخته نشده،
// حافظه‌ی bigData توسط Garbage Collector آزاد می‌شود

وقتی Closure حافظه را نگه می‌دارد

اما همان‌طور که در دو درس قبل دیدیم، وقتی یک تابع داخلی برگردانده شود یا در جایی نگه داشته شود، Environment Record تابع بیرونی به‌طور کامل زنده نگه داشته می‌شود؛ حتی اگر فقط به یکی از متغیرهای آن نیاز داشته باشیم:

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

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

در این مثال، تابع بازگشتی فقط از usedValue استفاده می‌کند و هیچ ارجاعی به unusedLargeData ندارد. اما موتورهای مدرن جاوااسکریپت (مثل V8) معمولاً به‌اندازه‌ی کافی هوشمند هستند که متوجه شوند unusedLargeData در بدنه‌ی Closure استفاده نشده، و آن را زودتر Garbage Collect کنند؛ درحالی‌که usedValue که واقعاً در Closure استفاده می‌شود، همچنان زنده نگه داشته می‌شود. با این حال، این رفتار بهینه‌سازی داخلی موتور است و نباید به‌عنوان یک قانون قطعی و تضمین‌شده در زبان به آن تکیه کرد؛ رفتار دقیق آن می‌تواند بین پیاده‌سازی‌های مختلف موتور جاوااسکریپت متفاوت باشد.

نکته‌ی مهم‌تر این است: تا زمانی که handler در جایی از برنامه نگه داشته شده و قابل دسترسی است، Environment Record مربوط به فراخوانی createHandler (دست‌کم بخش‌های موردنیازش) در حافظه باقی می‌ماند و آزاد نمی‌شود.

Memory Leak ناشی از Closure

اگر یک Closure برای مدت طولانی (یا برای همیشه) در برنامه نگه داشته شود، درحالی‌که دیگر واقعاً به آن نیازی نیست، این وضعیت می‌تواند به Memory Leak (نشتی حافظه) منجر شود؛ یعنی حافظه‌ای که باید آزاد می‌شد، به دلیل وجود یک ارجاع فراموش‌شده، هرگز آزاد نمی‌شود.

let handlers = [];

function registerHandler() {
  let largeData = new Array(100000).fill("داده");

  handlers.push(function () {
    console.log("در حال اجرا");
  });
}

registerHandler();
registerHandler();
registerHandler();
// آرایه‌ی handlers مدام بزرگ‌تر می‌شود و Closure های قدیمی
// هرگز آزاد نمی‌شوند، چون همچنان در handlers نگه داشته شده‌اند

در این مثال، هر بار که registerHandler فراخوانی می‌شود، یک Closure جدید به آرایه‌ی سراسری handlers اضافه می‌شود. چون این آرایه هرگز خالی نمی‌شود، تمام این Closure ها -و به‌طور بالقوه، بخشی از Environment Record مربوط به هرکدام- برای همیشه در حافظه باقی می‌مانند؛ حتی اگر هیچ‌کدام از این Handler ها دیگر واقعاً استفاده نشوند.

سناریوی رایج: Event Listener فراموش‌شده

یکی از رایج‌ترین منابع واقعی Memory Leak مبتنی بر Closure، افزودن Event Listener بدون حذف آن در زمان مناسب است. جزئیات کامل Event Listener ها را در فصل مربوط به Events بررسی خواهیم کرد، اما در همین‌جا ارزش دارد به‌طور مفهومی اشاره کنیم: اگر یک Closure به یک عنصر DOM یا داده‌ای حجیم ارجاع داشته باشد و آن Listener هرگز حذف نشود، آن حافظه تا پایان عمر صفحه در حافظه باقی می‌ماند؛ حتی اگر آن عنصر دیگر در صفحه استفاده نشود.

چگونه از این مشکل جلوگیری کنیم؟

راه‌حل اصلی، آگاهی از طول عمر Closure هایی است که می‌سازیم:

  • اگر یک Closure دیگر نیازی نیست، ارجاع به آن را (مثلاً با حذف از آرایه یا مقداردهی null) پاک کنید.
  • در داخل Closure، فقط به متغیرهایی ارجاع دهید که واقعاً به آن‌ها نیاز دارید؛ نه به کل داده‌های تابع بیرونی.
  • Event Listener هایی که دیگر نیاز نیستند را به‌موقع حذف کنید (این موضوع در فصل Events با جزئیات عملی بیشتری بررسی می‌شود).

Closure به‌خودی‌خود بد نیست

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

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

وضعیتنتیجه از نظر حافظه
Closure ای که دیگر ارجاعی به آن نیستتوسط Garbage Collector آزاد می‌شود
Closure ای که در یک متغیر یا آرایه‌ی طولانی‌مدت نگه داشته شدهتا زمانی که آن ارجاع باقی است، در حافظه می‌ماند
Event Listener فراموش‌شده با Closure سنگینمنبع رایج Memory Leak در برنامه‌های واقعی

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

این درس، پیش‌زمینه‌ی مستقیمی است برای فصل Memory که بعداً در این دوره بررسی می‌کنیم؛ جایی که مفاهیم Reachability و Garbage Collection را با جزئیات کامل و فارغ از محدودیت Closure بررسی خواهیم کرد. همچنین، در فصل Events، هنگام بررسی Event Listener ها، به همین مبحث Memory Leak دوباره بازخواهیم گشت.

جمع‌بندی

Closure باعث می‌شود Environment Record تابع بیرونی، تا زمانی که حداقل یک ارجاع به آن (از طریق تابع داخلی) وجود دارد، در حافظه باقی بماند. اگر این ارجاعات به‌صورت ناخواسته و طولانی‌مدت نگه داشته شوند -مثلاً در یک آرایه‌ی سراسری که هرگز خالی نمی‌شود یا یک Event Listener که هرگز حذف نمی‌شود- می‌تواند به Memory Leak منجر شود. راه‌حل، آگاهی از طول عمر واقعی موردنیاز هر Closure و پاک‌سازی به‌موقع ارجاعات غیرضروری است.