The LEGB Rule
When Python encounters a variable name, it searches for it in a specific order: Local, Enclosing, Global, Built-in. This is called the LEGB rule and determines which variable you get when multiple scopes have the same name.
Scope
A scope is a region of a program where a variable is accessible. Python uses lexical scoping, meaning the scope of a variable is determined by where it is defined in the source code, not where it is called from.
Key insight: Python determines scope at compile time based on where assignments occur, not at runtime.
Namespace
A namespace is a mapping from names to objects. Each scope has its own namespace dictionary. When you access a variable, Python looks up the name in the appropriate namespace based on the LEGB rule.
Example: Use locals() and globals() to inspect namespace dictionaries.
LEGB Scope Hierarchy
Local Scope
Variables created inside a function are local to that function. They exist only while the function runs and cannot be accessed from outside.
def my_function():
x = 10 # Local variable
print(f"Inside function: x = {x}")
my_function() # Output: Inside function: x = 10
# This would cause an error:
# print(x) # NameError: name 'x' is not defined
def another_function():
y = 20 # Different local scope
print(y)
# Each function has its own local scope
Local variables are created when the function starts and destroyed when it returns. They are isolated from other functions and cannot be accessed from outside their defining function. This isolation is a key feature of Python's scope system, preventing accidental modification of variables across different parts of your code. Each function call creates a fresh set of local variables.
Global Scope
Variables defined at the module level (outside any function) are global. They can be read from inside functions, but modifying them requires the global keyword.
count = 0 # Global variable
def show_count():
print(f"Count is: {count}") # Reading global is OK
show_count() # Output: Count is: 0
def try_to_modify():
# This creates a NEW local variable, not modifying global
count = 100
print(f"Inside: {count}")
try_to_modify() # Output: Inside: 100
print(f"Global still: {count}") # Output: Global still: 0
Assignment inside a function creates a local variable, even if a global with the same name exists. This behavior is called "shadowing" because the local variable hides the global one within that function's scope. Python decides at compile time whether a variable is local based on whether it appears on the left side of an assignment. Understanding shadowing prevents many confusing bugs in Python programs.
Enclosing Scope
When you have nested functions, inner functions can access variables from the enclosing (outer) function. This is the "E" in LEGB.
def outer():
message = "Hello from outer" # Enclosing scope
def inner():
print(message) # Accesses enclosing variable
inner()
outer() # Output: Hello from outer
# The inner function "remembers" the enclosing scope
def make_multiplier(n):
def multiplier(x):
return x * n # n comes from enclosing scope
return multiplier
double = make_multiplier(2)
print(double(5)) # Output: 10
Enclosing scope sits between local and global in the LEGB hierarchy. It only applies when you have functions defined inside other functions, creating nested scopes. The inner function can read variables from the outer function, but cannot modify them without the nonlocal keyword. This mechanism enables powerful patterns like closures and function factories.
Practice: LEGB Rule
Task: Without running the code, predict what will be printed. Then verify by running it.
x = "global"
def test():
x = "local"
print(x)
test()
print(x)
Show Answer
# Output:
# local (function uses its local x)
# global (global x is unchanged)
Task: Predict the output of this nested function code.
value = "global"
def outer():
value = "enclosing"
def inner():
value = "local"
print(value)
inner()
print(value)
outer()
print(value)
Show Answer
# Output:
# local (inner's local)
# enclosing (outer's local)
# global (module level)
Task: This code has a scope-related bug. Find it and explain why it fails.
counter = 0
def increment():
counter = counter + 1
return counter
print(increment())
Show Answer
# UnboundLocalError: local variable 'counter'
# referenced before assignment
# The assignment makes Python treat counter as local,
# but it tries to read it before the assignment completes.
# Fix: use 'global counter' at the start of the function
Task: Explain what happens when you run this code and why it's problematic.
list = [1, 2, 3]
print(list)
# Later in the code...
numbers = list(range(5)) # What happens?
Show Answer
# TypeError: 'list' object is not callable
# The first line shadows the built-in 'list' function
# with a list object. When we try to call list(range(5)),
# we're calling the list [1,2,3] as a function.
# Fix: Never use built-in names as variable names
# Use descriptive names like 'my_list' or 'numbers'
Global and Nonlocal Keywords
Sometimes you need to modify variables from outer scopes. The global keyword lets you modify module-level variables. The nonlocal keyword lets you modify enclosing function variables.
global
The global keyword declares that a variable name refers to the global (module-level) namespace. Without it, assigning to a variable inside a function creates a new local variable, even if a global with that name exists.
Syntax: global variable_name - Must appear before any use of the variable.
nonlocal
The nonlocal keyword declares that a variable refers to a name in the nearest enclosing scope (excluding global). It is used in nested functions when the inner function needs to modify a variable from the outer function.
Requirement: The variable must already exist in an enclosing function scope - nonlocal cannot create new variables.
global
Declares that a variable refers to the global (module-level) scope. Allows modification of global variables from inside functions.
nonlocal
Declares that a variable refers to the nearest enclosing scope. Used in nested functions to modify outer function variables.
Using global
The global keyword tells Python that a variable inside a function refers to the global variable, not a new local one.
counter = 0
def increment():
global counter # Declare we're using the global
counter = counter + 1
return counter
print(increment()) # Output: 1
print(increment()) # Output: 2
print(increment()) # Output: 3
print(f"Final: {counter}") # Output: Final: 3
The global declaration must come before any use of the variable in the function. It tells Python that all references to that name within the function refer to the module-level variable, not a local one. Once declared global, both reading and writing affect the same global variable. This is useful for maintaining state across function calls, but should be used sparingly.
Using nonlocal
The nonlocal keyword is for nested functions. It lets an inner function modify a variable from the enclosing function.
def make_counter():
count = 0 # Enclosing variable
def increment():
nonlocal count # Modify enclosing, not create local
count += 1
return count
return increment
counter = make_counter()
print(counter()) # Output: 1
print(counter()) # Output: 2
print(counter()) # Output: 3
# Each counter is independent
counter2 = make_counter()
print(counter2()) # Output: 1
The nonlocal keyword finds the nearest enclosing scope that contains the specified variable. Unlike global, it does not reach up to module-level scope. If no enclosing function has that variable, Python raises a SyntaxError. This keyword is essential for creating closures that need to modify their captured state, enabling patterns like counters and accumulators.
Practice: Global and Nonlocal
Task: Fix this code so that the function properly increments the global total.
total = 100
def add_to_total(amount):
total = total + amount
return total
add_to_total(50)
print(total) # Should print 150
Show Solution
total = 100
def add_to_total(amount):
global total
total = total + amount
return total
add_to_total(50)
print(total) # Output: 150
Task: Create a function make_accumulator that returns a function. The returned function takes a number and adds it to a running total, returning the new total. Use nonlocal.
Show Solution
def make_accumulator():
total = 0
def add(n):
nonlocal total
total += n
return total
return add
acc = make_accumulator()
print(acc(10)) # Output: 10
print(acc(5)) # Output: 15
print(acc(3)) # Output: 18
Task: Create a make_toggle function that returns a function. Each call to the returned function toggles a boolean state and returns it. Start with False, then True, False, True, etc.
Show Solution
def make_toggle():
state = False
def toggle():
nonlocal state
state = not state
return state
return toggle
switch = make_toggle()
print(switch()) # Output: True
print(switch()) # Output: False
print(switch()) # Output: True
Task: Create two functions that share a global configuration dictionary. One function updates settings, the other retrieves them. Demonstrate with theme and font_size settings.
Show Solution
config = {"theme": "light", "font_size": 12}
def update_config(key, value):
global config
config[key] = value
return f"Updated {key} to {value}"
def get_config(key):
return config.get(key, "Not found")
print(get_config("theme")) # Output: light
print(update_config("theme", "dark"))
print(get_config("theme")) # Output: dark
print(update_config("font_size", 16))
print(get_config("font_size")) # Output: 16
Closures
A closure is a function that remembers values from its enclosing scope even after that scope has finished executing. Closures enable powerful patterns like function factories and data encapsulation.
What is a Closure?
A closure is created when a nested function references variables from its enclosing function. The inner function "closes over" these variables, keeping them alive even after the outer function returns.
Three requirements: 1) Nested function, 2) Inner function references outer variables, 3) Outer function returns the inner function.
Closure Example
When you return an inner function, it carries its enclosing scope with it. This scope persists independently for each call to the outer function.
def make_greeting(greeting):
def greet(name):
return f"{greeting}, {name}!"
return greet
# Create specialized greeting functions
say_hello = make_greeting("Hello")
say_hi = make_greeting("Hi")
say_welcome = make_greeting("Welcome")
print(say_hello("Alice")) # Output: Hello, Alice!
print(say_hi("Bob")) # Output: Hi, Bob!
print(say_welcome("Carol")) # Output: Welcome, Carol!
Each returned function remembers its own greeting value from when it was created. The closure captures the variable itself, not just its value at creation time, which means changes to the variable would be reflected. This pattern creates specialized functions from a general template. Function factories like this are common in Python for creating configurable behavior.
Practical Closure: Caching
Closures can store state, making them perfect for caching expensive computations.
def make_cached_function(func):
cache = {}
def cached(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return cached
@make_cached_function
def slow_square(n):
print(f"Computing {n}^2...")
return n ** 2
print(slow_square(5)) # Computing 5^2... 25
print(slow_square(5)) # 25 (cached, no computation)
print(slow_square(3)) # Computing 3^2... 9
The cache dictionary lives in the enclosing scope and persists between calls to the wrapped function. This pattern is called memoization and dramatically improves performance for expensive computations with repeated inputs. The closure maintains private state that cannot be accessed or modified externally. Python's functools module provides a built-in decorator called lru_cache for this purpose.
Closure: Private State
Closures can create private state that cannot be accessed directly from outside, similar to private variables in object-oriented programming.
def make_bank_account(initial_balance):
balance = initial_balance # Private state
def deposit(amount):
nonlocal balance
balance += amount
return balance
def withdraw(amount):
nonlocal balance
if amount > balance:
return "Insufficient funds"
balance -= amount
return balance
def get_balance():
return balance
return deposit, withdraw, get_balance
dep, wd, bal = make_bank_account(100)
print(bal()) # Output: 100
print(dep(50)) # Output: 150
print(wd(30)) # Output: 120
The balance variable is completely private and cannot be accessed directly from outside the closure. It can only be modified through the returned functions, enforcing controlled access similar to private attributes in object-oriented programming. This encapsulation pattern provides data hiding without using classes. Multiple independent accounts can coexist, each with their own private balance.
Common Closure Pitfall: Loop Variables
A common mistake with closures involves capturing loop variables. All closures end up sharing the same variable, leading to unexpected results.
# WRONG: All functions share the same i
functions = []
for i in range(3):
functions.append(lambda: i)
for f in functions:
print(f()) # Output: 2, 2, 2 (not 0, 1, 2!)
# FIX: Capture the value with a default parameter
functions = []
for i in range(3):
functions.append(lambda i=i: i) # i=i captures current value
for f in functions:
print(f()) # Output: 0, 1, 2 (correct!)
In the first example, all lambdas share the same variable i, which has value 2 after the loop ends. The fix uses a default parameter i=i to capture the current value at each iteration. This is a classic Python gotcha that trips up even experienced developers. Always be careful when creating closures inside loops.
| Use Case | Description | Example |
|---|---|---|
| Function Factories | Create specialized functions from a template | make_multiplier(n) |
| Memoization | Cache expensive computation results | make_cached(func) |
| Private State | Encapsulate data without classes | make_counter() |
| Decorators | Wrap functions with additional behavior | @timer, @retry |
| Callbacks | Preserve context for async operations | Event handlers |
Practice: Closures
Task: Create a function make_multiplier that takes a factor and returns a function that multiplies its input by that factor. Create triple (x3) and quadruple (x4) functions.
Show Solution
def make_multiplier(factor):
def multiply(x):
return x * factor
return multiply
triple = make_multiplier(3)
quadruple = make_multiplier(4)
print(triple(10)) # Output: 30
print(quadruple(10)) # Output: 40
Task: Create a function count_calls that wraps any function and counts how many times it has been called. Return both the result and a way to check the count.
Show Solution
def count_calls(func):
count = 0
def wrapper(*args, **kwargs):
nonlocal count
count += 1
return func(*args, **kwargs)
def get_count():
return count
wrapper.get_count = get_count
return wrapper
@count_calls
def add(a, b):
return a + b
print(add(1, 2)) # Output: 3
print(add(3, 4)) # Output: 7
print(add.get_count()) # Output: 2
Task: Create a rate_limiter that allows a function to be called at most n times. After the limit, it returns "Rate limit exceeded" instead of calling the function.
Show Solution
def rate_limiter(max_calls):
def decorator(func):
calls = 0
def wrapper(*args, **kwargs):
nonlocal calls
if calls >= max_calls:
return "Rate limit exceeded"
calls += 1
return func(*args, **kwargs)
return wrapper
return decorator
@rate_limiter(3)
def greet(name):
return f"Hello, {name}!"
print(greet("Alice")) # Output: Hello, Alice!
print(greet("Bob")) # Output: Hello, Bob!
print(greet("Carol")) # Output: Hello, Carol!
print(greet("Dave")) # Output: Rate limit exceeded
Task: Create a make_history_tracker that returns functions to add values and retrieve all previous values. The tracker should maintain a list of all values ever added.
Show Solution
def make_history_tracker():
history = []
def add(value):
history.append(value)
return f"Added: {value}"
def get_all():
return history.copy()
def get_last(n=1):
return history[-n:]
return add, get_all, get_last
add, get_all, get_last = make_history_tracker()
print(add("first")) # Output: Added: first
print(add("second")) # Output: Added: second
print(add("third")) # Output: Added: third
print(get_all()) # Output: ['first', 'second', 'third']
print(get_last(2)) # Output: ['second', 'third']
Namespace Introspection
Python provides built-in functions to examine namespaces at runtime. You can inspect local and global variables, check what names exist in any scope, and even modify namespaces dynamically. This introspection is powerful for debugging and metaprogramming.
Namespace Inspection Tools
locals() returns a dictionary of the current local namespace. globals() returns the global namespace dictionary. dir() returns a list of names in the current scope or an object's attributes.
Note: locals() inside a function returns a snapshot - modifying it does not affect actual local variables.
locals() has no effect inside functions. However, modifying globals() does change global variables.
Using locals() and globals()
These functions return dictionaries representing the current namespace, allowing you to see exactly what variables exist and their values.
module_var = "I'm global"
def show_namespaces():
local_var = "I'm local"
another_local = 42
print("Local namespace:")
for name, value in locals().items():
print(f" {name} = {value}")
print("\nGlobal namespace (filtered):")
for name, value in globals().items():
if not name.startswith('_'):
print(f" {name} = {value}")
show_namespaces()
# Output:
# Local namespace:
# local_var = I'm local
# another_local = 42
# Global namespace (filtered):
# module_var = I'm global
# show_namespaces =
The locals() function returns a dictionary containing all variables in the current local scope. Similarly, globals() returns the module's namespace as a dictionary. We filter out dunder names (starting with underscore) to show only user-defined items. This is invaluable for debugging when you need to see what variables are available.
Using dir() for Exploration
The dir() function lists names in the current scope or, when given an object, lists that object's attributes and methods.
# dir() with no argument shows current scope
x = 10
y = 20
def my_func():
pass
print("Current scope names:")
print([n for n in dir() if not n.startswith('_')])
# Output: ['my_func', 'x', 'y']
# dir() with an object shows its attributes
text = "hello"
print("\nString methods (sample):")
print([m for m in dir(text) if not m.startswith('_')][:10])
# Output: ['capitalize', 'casefold', 'center', 'count', 'encode', ...]
Without arguments, dir() returns names in the current local scope. When called with an object, it returns that object's attributes and methods. This is extremely useful for interactive exploration and discovering what operations are available. Combined with help(), it forms the core of Python's introspection capabilities.
Dynamic Variable Access
Using namespace dictionaries, you can access variables by name dynamically, which is useful for configuration and reflection.
settings = {
"debug_mode": True,
"max_retries": 3,
"timeout": 30
}
def get_setting(name):
return globals()['settings'].get(name, None)
def create_variables_from_dict(data):
for key, value in data.items():
globals()[key] = value
# Access setting dynamically
print(get_setting("max_retries")) # Output: 3
# Create global variables from dictionary
config = {"api_url": "https://api.example.com", "version": "2.0"}
create_variables_from_dict(config)
print(api_url) # Output: https://api.example.com
print(version) # Output: 2.0
Accessing globals() as a dictionary allows dynamic variable creation and lookup. This technique is powerful for configuration systems where variable names come from external sources. However, use this sparingly as it can make code harder to understand and debug. Explicit dictionaries are usually clearer than dynamic global variables.
| Function | Returns | Modifiable? | Use Case |
|---|---|---|---|
locals() |
Local namespace dict | No (inside functions) | Debugging, logging |
globals() |
Global namespace dict | Yes | Dynamic variable creation |
dir() |
List of names | N/A | Exploration, discovery |
vars(obj) |
Object's __dict__ | Yes (if object allows) | Object introspection |
Practice: Namespace Introspection
Task: Create a function that takes any number of keyword arguments and prints each variable name and value using locals().
Show Solution
def print_variables(**kwargs):
for name, value in locals()['kwargs'].items():
print(f"{name} = {value}")
print_variables(x=10, name="Alice", active=True)
# Output:
# x = 10
# name = Alice
# active = True
Task: Create a function that returns a list of all callable objects (functions) in the current global namespace, excluding built-ins.
Show Solution
def get_user_functions():
return [
name for name, obj in globals().items()
if callable(obj) and not name.startswith('_')
and not isinstance(obj, type) # Exclude classes
]
def greet(): pass
def calculate(): pass
print(get_user_functions())
# Output: ['get_user_functions', 'greet', 'calculate']
Task: Create a context manager that prints all new local variables created within its block (comparing before and after).
Show Solution
from contextlib import contextmanager
import sys
@contextmanager
def track_new_variables():
frame = sys._getframe(1)
before = set(frame.f_locals.keys())
yield
after = set(frame.f_locals.keys())
new_vars = after - before
if new_vars:
print(f"New variables: {new_vars}")
# Usage requires careful scope handling
# This is an advanced introspection technique
Task: Create a function that takes an object and a dictionary, then sets attributes on the object from the dictionary keys and values.
Show Solution
def set_attributes(obj, attributes):
for name, value in attributes.items():
setattr(obj, name, value)
return obj
class User:
pass
user = User()
set_attributes(user, {"name": "Alice", "age": 30, "active": True})
print(user.name) # Output: Alice
print(user.age) # Output: 30
print(vars(user)) # Output: {'name': 'Alice', 'age': 30, 'active': True}
Scope Best Practices
Understanding scope is one thing; using it effectively is another. Following best practices helps you write code that is easier to understand, test, and maintain. Proper scope management prevents bugs and makes your intentions clear to other developers.
Principle of Least Privilege
Variables should have the smallest scope necessary to accomplish their task. This reduces the risk of unintended modifications and makes code easier to reason about. Prefer local variables over global ones whenever possible.
Rule of thumb: If a variable is only needed in one function, make it local. If needed across functions, consider passing it as a parameter instead of making it global.
Avoid Mutable Global State
Mutable global variables are particularly dangerous because any part of your program can modify them, leading to unpredictable behavior.
# BAD: Mutable global state
user_list = []
def add_user(name):
user_list.append(name) # Modifies global list
def get_users():
return user_list
This approach uses a global list that any function can modify. The problem is that changes happen invisibly - you cannot tell from looking at add_user() alone that it modifies external state. This makes debugging difficult because bugs can originate anywhere in your codebase. Testing is also hard since each test affects the global state.
# GOOD: Encapsulated state with a class
class UserManager:
def __init__(self):
self._users = []
def add_user(self, name):
self._users.append(name)
def get_users(self):
return self._users.copy() # Return copy for safety
The class-based approach encapsulates the user list as an instance attribute. Each UserManager instance has its own independent list. The underscore prefix signals that _users is internal. Returning a copy from get_users() prevents external code from accidentally modifying the internal list. This pattern is testable, maintainable, and thread-safe with proper locking.
# GOOD: Encapsulated state with closures
def create_user_manager():
users = []
def add(name):
users.append(name)
def get_all():
return users.copy()
return add, get_all
add_user, get_users = create_user_manager()
The closure approach provides similar encapsulation without defining a class. The users list is completely private - it cannot be accessed except through the returned functions. This is ideal for simple cases where you need private state but a full class feels like overkill. Each call to create_user_manager() creates an independent set of functions with their own private list.
Parameter Passing vs Global Access
Functions that read global variables have hidden inputs, making them harder to test and understand. Prefer explicit parameters.
# BAD: Hidden dependency on global
config = {"debug": True, "retries": 3}
def process_data(data):
if config["debug"]: # Hidden dependency!
print(f"Processing: {data}")
for i in range(config["retries"]):
# process...
pass
This function has a hidden dependency on the global config variable. Looking at just the function signature process_data(data), you cannot tell it depends on external state. This makes the function impure - its behavior changes based on something outside its control. Testing requires setting up global state first, and bugs can be hard to trace.
# GOOD: Explicit parameters
def process_data(data, debug=False, retries=3):
if debug:
print(f"Processing: {data}")
for i in range(retries):
# process...
pass
This version makes all dependencies explicit through parameters with sensible defaults. The function signature tells you exactly what inputs it needs. Testing is simple - just pass different values. The function is pure and self-documenting. Callers can easily customize behavior without modifying global state.
# ALSO GOOD: Configuration object passed explicitly
from dataclasses import dataclass
@dataclass
class Config:
debug: bool = False
retries: int = 3
def process_data(data, config: Config):
if config.debug:
print(f"Processing: {data}")
# ...
When you have many configuration options, passing them individually becomes unwieldy. A configuration object groups related settings together while still being passed explicitly. The dataclass decorator provides a clean way to define configuration with defaults. This pattern scales well and supports type hints for better IDE support.
When Global Variables Are Acceptable
Not all global variables are bad. Constants, module-level configurations loaded once, and certain design patterns benefit from global scope.
# OK: Constants (by convention, UPPERCASE)
MAX_CONNECTIONS = 100
DEFAULT_TIMEOUT = 30
API_BASE_URL = "https://api.example.com"
# OK: Module-level logger
import logging
logger = logging.getLogger(__name__)
def process():
logger.info("Processing started") # Logger is a reasonable global
# OK: Singleton pattern (sometimes)
_instance = None
def get_database():
global _instance
if _instance is None:
_instance = Database()
return _instance
Constants are safe as globals because they never change. Module-level loggers are conventional and widely accepted. Singleton patterns use globals carefully to ensure only one instance exists. The key distinction is that these globals are either immutable or their mutation is intentional and controlled. Avoid globals that any function might modify unexpectedly.
| Practice | Why | Example |
|---|---|---|
| Prefer local variables | Isolation, easier debugging | Define vars inside functions |
| Pass explicit parameters | Clear dependencies, testable | def process(data, config) |
| Use UPPERCASE for constants | Signal immutability | MAX_SIZE = 100 |
| Encapsulate mutable state | Controlled access | Classes or closures |
| Return values, don't modify globals | Pure functions, no side effects | return result |
Practice: Best Practices
Task: Identify what's wrong with this code and explain why it's a problem.
total = 0
def add_sale(amount):
global total
total += amount
def get_total():
return total
def reset():
global total
total = 0
Show Answer
# Problems:
# 1. Hidden mutable state - total can change unexpectedly
# 2. Hard to test - functions depend on global state
# 3. Not thread-safe - concurrent calls can corrupt total
# 4. Hard to have multiple independent totals
# Better approach: use a class or closure
class SalesTracker:
def __init__(self):
self.total = 0
def add_sale(self, amount):
self.total += amount
def get_total(self):
return self.total
def reset(self):
self.total = 0
Task: Refactor this code to eliminate the global variable while preserving functionality.
cache = {}
def expensive_calculation(n):
if n in cache:
return cache[n]
result = n ** 2 + n * 2 + 1 # Pretend this is slow
cache[n] = result
return result
Show Solution
# Solution 1: Closure
def create_cached_calculator():
cache = {}
def calculate(n):
if n not in cache:
cache[n] = n ** 2 + n * 2 + 1
return cache[n]
return calculate
expensive_calculation = create_cached_calculator()
# Solution 2: Use functools.lru_cache
from functools import lru_cache
@lru_cache(maxsize=128)
def expensive_calculation(n):
return n ** 2 + n * 2 + 1
Task: Design a configuration system that avoids global variables but allows settings to be accessed throughout the application.
Show Solution
from dataclasses import dataclass
from typing import Optional
@dataclass
class AppConfig:
debug: bool = False
database_url: str = "sqlite:///app.db"
max_connections: int = 10
class Application:
def __init__(self, config: AppConfig):
self.config = config
self.db = Database(config.database_url, config.max_connections)
def run(self):
if self.config.debug:
print("Running in debug mode")
# ...
# Usage - config passed explicitly
config = AppConfig(debug=True)
app = Application(config)
app.run()
Task: Create a thread-safe counter class that can be safely used from multiple threads without using global variables.
Show Solution
import threading
class ThreadSafeCounter:
def __init__(self, initial=0):
self._value = initial
self._lock = threading.Lock()
def increment(self, amount=1):
with self._lock:
self._value += amount
return self._value
def decrement(self, amount=1):
with self._lock:
self._value -= amount
return self._value
@property
def value(self):
with self._lock:
return self._value
# Usage
counter = ThreadSafeCounter()
# Pass counter to threads that need it
Interactive Demo: Scope Explorer
Experiment with Python's scope rules. Select different scenarios to see how variable names are resolved through the LEGB hierarchy.
Select a Scenario
Code Example
x = "global"
def show():
x = "local"
print(x)
show()
print(x)
Output
Explanation
The function creates its own local x, which shadows the global. Printing inside the function shows "local", but the global x remains unchanged.
Concept Summary
| Concept | Keyword/Function | Purpose | Example |
|---|---|---|---|
| Local Scope | (default) | Variables inside functions | def f(): x = 1 |
| Global Scope | global |
Module-level variable access | global counter |
| Enclosing Scope | nonlocal |
Outer function variable access | nonlocal count |
| Built-in Scope | (automatic) | Python's built-in names | print, len, int |
| Closure | nested function | Remember enclosing variables | return inner |
| Shadowing | (assignment) | Local hides global | x = 1 # local |
| Namespace | locals(), globals() |
Name-to-object mapping | globals()['x'] |
| Introspection | dir(), vars() |
Examine namespaces | dir(obj) |
Key Takeaways
LEGB Order
Python searches Local, Enclosing, Global, then Built-in scopes in that order to resolve variable names
Local Scope
Variables created inside functions are local and cannot be accessed from outside
global Keyword
Use global to modify module-level variables from inside functions (use sparingly)
nonlocal Keyword
Use nonlocal in nested functions to modify variables from the enclosing function scope
Closures
Closures let functions remember enclosing scope variables, enabling factories and private state
Variable Shadowing
Local variables with the same name as globals hide the global without modifying it
Knowledge Check
Test your understanding of Python scope and namespaces:
What does LEGB stand for in Python's scope resolution?
What happens when you assign to a variable inside a function without using global?
What is a closure in Python?
When would you use nonlocal instead of global?
What will this code print?x = 10; (lambda: print(x))(); x = 20; (lambda: print(x))()
What is the best practice regarding global variables?