לדלג לתוכן

4.1 קלוז׳רים וסקופ הרצאה

סקופ - scope

סקופ הוא ה"תחום" שבו משתנה חי ונגיש. ב-JS יש שלושה סוגי scope:

סקופ גלובלי - global scope

משתנה שמוגדר מחוץ לכל פונקציה או בלוק - נגיש מכל מקום בקוד:

const globalVar = "I'm global";

function test() {
    console.log(globalVar); // accessible here
}

if (true) {
    console.log(globalVar); // accessible here too
}

console.log(globalVar); // and here
  • בפייתון אותו דבר - משתנה ברמה העליונה של הקובץ נגיש מכל מקום
  • הימנעו ממשתנים גלובליים כמה שאפשר - הם יוצרים בעיות

סקופ של פונקציה - function scope

משתנה שמוגדר בתוך פונקציה נגיש רק בתוכה:

function greet() {
    const message = "Hello";
    console.log(message); // works
}

greet();
// console.log(message); // ReferenceError - not accessible outside

זה נכון ל-var, let, ו-const - כולם מוגבלים לפונקציה שבה הם מוגדרים.

סקופ בלוק - block scope

בלוק הוא כל מה שבתוך סוגריים מסולסלים { } - כולל if, for, while:

if (true) {
    let blockVar = "I'm block-scoped";
    const alsoBlock = "Me too";
    var notBlock = "I escape blocks!";
}

// console.log(blockVar);  // ReferenceError
// console.log(alsoBlock); // ReferenceError
console.log(notBlock);     // "I escape blocks!" - var ignores block scope!

ההבדל הקריטי:
- let ו-const - מוגבלים לבלוק (block-scoped)
- var - מוגבל לפונקציה בלבד (function-scoped), מתעלם מבלוקים

function example() {
    if (true) {
        var x = 10;  // function-scoped - belongs to example()
        let y = 20;  // block-scoped - belongs to this if block only
    }
    console.log(x); // 10 - var is visible in the entire function
    // console.log(y); // ReferenceError - let is limited to the block
}

בפייתון אין block scope בכלל - משתנה שמוגדר ב-if או for נגיש מחוץ לבלוק:

# Python - no block scope
# if True:
#     x = 10
# print(x)  # 10 - works in Python!

ב-JS עם let/const זה לא עובד ככה. זה יותר בטוח כי משתנים לא דולפים ממקומות לא צפויים.


סקופ לקסיקלי - lexical scope

זה הכלל הכי חשוב להבנת closures:

פונקציה רואה את המשתנים מהמקום שבו היא הוגדרה, לא מהמקום שבו היא נקראת.

const name = "Global";

function outer() {
    const name = "Outer";

    function inner() {
        console.log(name); // where does JS look for 'name'?
    }

    return inner;
}

const fn = outer();
fn(); // "Outer" - not "Global"!

למרות ש-fn נקראת מה-scope הגלובלי, היא רואה את name מהמקום שבו היא הוגדרה - בתוך outer. זה סקופ לקסיקלי.

בפייתון עובד בדיוק אותו דבר:

# name = "Global"
#
# def outer():
#     name = "Outer"
#     def inner():
#         print(name)  # "Outer" - same behavior
#     return inner
#
# fn = outer()
# fn()  # "Outer"

שרשרת הסקופ - scope chain

כש-JS צריך למצוא משתנה, היא מחפשת מהסקופ הפנימי ביותר החוצה:

const a = "global";

function outer() {
    const b = "outer";

    function middle() {
        const c = "middle";

        function inner() {
            const d = "inner";

            // inner can see everything:
            console.log(d); // "inner"   - found in own scope
            console.log(c); // "middle"  - found in middle's scope
            console.log(b); // "outer"   - found in outer's scope
            console.log(a); // "global"  - found in global scope
        }

        inner();
    }

    middle();
}

outer();

סדר החיפוש (scope chain):
1. הסקופ של הפונקציה עצמה
2. הסקופ של הפונקציה העוטפת
3. הסקופ של הפונקציה שעוטפת את העוטפת
4. ... וכן הלאה עד ה-scope הגלובלי
5. אם לא נמצא בכלל - ReferenceError

function test() {
    console.log(nonExistent); // ReferenceError: nonExistent is not defined
}

קלוז׳ר - closure

עכשיו שאנחנו מבינים scope, אפשר להגדיר closure:

קלוז׳ר היא פונקציה ש"זוכרת" את המשתנים מהסקופ החיצוני שלה, גם אחרי שהפונקציה החיצונית סיימה לרוץ.

function createGreeter(greeting) {
    // greeting lives in createGreeter's scope

    return function(name) {
        // this inner function "remembers" greeting
        console.log(`${greeting}, ${name}!`);
    };
}

const sayHello = createGreeter("Hello");
const sayHi = createGreeter("Hi");

// createGreeter has finished running, but the returned functions
// still have access to 'greeting':
sayHello("Alice"); // "Hello, Alice!"
sayHello("Bob");   // "Hello, Bob!"
sayHi("Charlie");  // "Hi, Charlie!"

מה קורה כאן:
1. קוראים ל-createGreeter("Hello") - נוצר scope עם greeting = "Hello"
2. הפונקציה מחזירה פונקציה פנימית שמשתמשת ב-greeting
3. createGreeter סיימה לרוץ, אבל הפונקציה הפנימית עדיין מחזיקה הפניה ל-scope שלה
4. כשקוראים ל-sayHello("Alice"), היא ניגשת ל-greeting דרך ה-closure

דוגמה - מפעל מונים - counter factory

function createCounter(start = 0) {
    let count = start;

    return {
        increment: () => ++count,
        decrement: () => --count,
        getCount: () => count,
        reset: () => { count = start; }
    };
}

const counter1 = createCounter();
const counter2 = createCounter(100);

counter1.increment();
counter1.increment();
counter1.increment();
console.log(counter1.getCount()); // 3

counter2.decrement();
console.log(counter2.getCount()); // 99

// each counter has its own independent 'count'
console.log(counter1.getCount()); // still 3

כל קריאה ל-createCounter יוצרת scope חדש עם count עצמאי. הפונקציות שחוזרות סוגרות (close over) על ה-count הזה - ומכאן השם closure.

דוגמה - ממואיזציה - memoization

function createMemoized(fn) {
    const cache = {};

    return function(...args) {
        const key = JSON.stringify(args);

        if (key in cache) {
            console.log("Returning cached result");
            return cache[key];
        }

        console.log("Computing new result");
        const result = fn(...args);
        cache[key] = result;
        return result;
    };
}

const memoizedAdd = createMemoized((a, b) => a + b);

memoizedAdd(1, 2); // "Computing new result" -> 3
memoizedAdd(1, 2); // "Returning cached result" -> 3
memoizedAdd(3, 4); // "Computing new result" -> 7

ה-cache נשמר ב-closure - הוא פרטי ולא נגיש מבחוץ, אבל הפונקציה המוחזרת יכולה לגשת אליו.

דוגמה - מצב פרטי - private state

function createBankAccount(initialBalance) {
    let balance = initialBalance; // private!
    const transactions = [];       // private!

    return {
        deposit(amount) {
            if (amount <= 0) {
                console.log("Amount must be positive");
                return;
            }
            balance += amount;
            transactions.push({ type: "deposit", amount });
        },

        withdraw(amount) {
            if (amount > balance) {
                console.log("Insufficient funds");
                return;
            }
            balance -= amount;
            transactions.push({ type: "withdraw", amount });
        },

        getBalance() {
            return balance;
        },

        getHistory() {
            return [...transactions]; // return a copy
        }
    };
}

const account = createBankAccount(1000);
account.deposit(500);
account.withdraw(200);
console.log(account.getBalance()); // 1300
console.log(account.getHistory());
// [{ type: "deposit", amount: 500 }, { type: "withdraw", amount: 200 }]

// can't access balance directly:
// console.log(account.balance); // undefined - it's not a property!

אין דרך לגשת ל-balance או ל-transactions ישירות - הם קיימים רק בתוך ה-closure. זה אנקפסולציה אמיתית.


תבנית המודול - module pattern

לפני ש-ES6 הביא לנו modules (שנלמד בשיעור 4.4), השתמשו ב-closures כדי ליצור מודולים:

const Calculator = (function() {
    // private variables and functions
    let history = [];

    function addToHistory(operation, result) {
        history.push({ operation, result, time: new Date() });
    }

    // public API - returned object
    return {
        add(a, b) {
            const result = a + b;
            addToHistory(`${a} + ${b}`, result);
            return result;
        },

        subtract(a, b) {
            const result = a - b;
            addToHistory(`${a} - ${b}`, result);
            return result;
        },

        getHistory() {
            return [...history];
        },

        clearHistory() {
            history = [];
        }
    };
})();

Calculator.add(5, 3);       // 8
Calculator.subtract(10, 4); // 6
console.log(Calculator.getHistory());
// [{ operation: "5 + 3", result: 8, ... }, { operation: "10 - 4", result: 6, ... }]

התבנית הזו משלבת IIFE עם closures: הפונקציה רצה מיד, יוצרת scope פרטי, ומחזירה אובייקט עם ממשק ציבורי.


קלוז׳רים בלולאות - הבעיה הקלאסית

זו אחת הבעיות הכי מפורסמות ב-JS:

// THE CLASSIC BUG - using var in a loop
for (var i = 0; i < 3; i++) {
    setTimeout(() => {
        console.log(i);
    }, 1000);
}
// expected: 0, 1, 2
// actual:   3, 3, 3

למה? כי var הוא function-scoped, לא block-scoped. יש רק i אחד ששייך לכל הלולאה. עד שה-setTimeout רץ, הלולאה כבר סיימה ו-i הוא 3. כל שלוש הפונקציות מצביעות על אותו i.

הפתרון עם let

// FIXED - using let
for (let i = 0; i < 3; i++) {
    setTimeout(() => {
        console.log(i);
    }, 1000);
}
// prints: 0, 1, 2

let הוא block-scoped - כל איטרציה של הלולאה מקבלת i משלה. כל closure סוגר על i אחר.

הפתרון הישן עם IIFE

לפני let, השתמשו ב-IIFE כדי ליצור scope חדש בכל איטרציה:

// OLD FIX - IIFE creates a new scope per iteration
for (var i = 0; i < 3; i++) {
    (function(j) {
        setTimeout(() => {
            console.log(j);
        }, 1000);
    })(i);
}
// prints: 0, 1, 2

ה-IIFE מקבלת את i הנוכחי כפרמטר j, ויוצרת scope חדש עם עותק של הערך. היום אין צורך בזה - פשוט משתמשים ב-let.


IIFE - ביטוי פונקציה שמופעל מיידית

כבר ראינו IIFE בשיעור על פונקציות, אבל עכשיו אנחנו מבינים למה היא שימושית - היא יוצרת scope חדש:

// basic IIFE
(function() {
    const secret = "hidden";
    console.log(secret); // works
})();

// console.log(secret); // ReferenceError

// IIFE with arrow function
(() => {
    const temp = computeSomething();
    // use temp...
})();

// IIFE that returns a value
const result = (() => {
    const x = 10;
    const y = 20;
    return x + y;
})();

console.log(result); // 30

שימושים נפוצים של IIFE:
- יצירת scope פרטי (בעיקר לפני let/const)
- תבנית המודול (module pattern)
- הרצת קוד async ב-top level (לפני top-level await)

// async IIFE - useful when you can't use top-level await
(async () => {
    const response = await fetch("/api/data");
    const data = await response.json();
    console.log(data);
})();

איסוף זבל - garbage collection וקלוז׳רים

ב-JS, המנוע מנקה אוטומטית משתנים שאף אחד לא מתייחס אליהם יותר (garbage collection). אבל closures יכולים למנוע ניקוי:

function createHeavyFunction() {
    const bigArray = new Array(1000000).fill("data"); // takes memory

    return function() {
        // this closure keeps bigArray alive!
        console.log(bigArray.length);
    };
}

const heavy = createHeavyFunction();
// bigArray can't be garbage collected because heavy() still references it

// to free the memory:
// heavy = null; // now bigArray can be collected

כלל חשוב: closure שומר הפניה לכל ה-scope החיצוני. גם אם הפונקציה הפנימית לא משתמשת בכל המשתנים, המנוע עשוי לשמור אותם בזיכרון (תלוי בהמנוע ובאופטימיזציות שלו).

function example() {
    const usedVar = "I'm used";
    const unusedVar = "I'm not used"; // may still be kept alive

    return function() {
        console.log(usedVar);
    };
}

טיפ: אם יש לכם closure שמחזיק מידע כבד, שחררו אותו כשאתם לא צריכים אותו יותר.


השוואה לפייתון

Closures עובדים באופן דומה מאוד בפייתון. ההבדל העיקרי הוא מילת המפתח nonlocal:

// JavaScript - can modify outer variables freely
function createCounter() {
    let count = 0;
    return () => {
        count++; // can modify count directly
        return count;
    };
}
# Python - need 'nonlocal' to modify outer variables
# def create_counter():
#     count = 0
#     def increment():
#         nonlocal count  # required! otherwise Python thinks count is local
#         count += 1
#         return count
#     return increment

בפייתון, אם רוצים לשנות (ולא רק לקרוא) משתנה מ-scope חיצוני, חייבים להכריז עליו עם nonlocal. ב-JS אין צורך - אפשר לשנות משתנים חיצוניים חופשי.

הבדל נוסף - ב-JS, let ב-for loop יוצר scope חדש בכל איטרציה. בפייתון אין לזה מקבילה ישירה:

# Python has the same closure-in-loop problem:
# functions = []
# for i in range(3):
#     functions.append(lambda: print(i))
#
# for f in functions:
#     f()  # prints 2, 2, 2 (not 0, 1, 2!)
#
# Fix in Python - use default parameter:
# functions = []
# for i in range(3):
#     functions.append(lambda i=i: print(i))  # i=i captures current value

סיכום

  • שלושה סוגי scope: גלובלי, פונקציה, ובלוק
  • let/const הם block-scoped, var הוא function-scoped - העדיפו תמיד let/const
  • סקופ לקסיקלי: פונקציה רואה משתנים מהמקום שבו הוגדרה, לא מהמקום שבו נקראת
  • שרשרת הסקופ: חיפוש מהסקופ הפנימי החוצה עד הגלובלי
  • קלוז׳ר: פונקציה שזוכרת את הסקופ החיצוני שלה גם אחרי שהפונקציה החיצונית סיימה
  • שימושים: counter factory, memoization, מצב פרטי, תבנית המודול
  • בעיה קלאסית: closures בלולאות עם var - הפתרון: let
  • IIFE יוצרת scope חדש - שימושי לתבנית המודול
  • נזהרים מ-closures שמחזיקים זיכרון מיותר
  • בפייתון closures עובדים דומה, אבל צריכים nonlocal כדי לשנות משתנים חיצוניים