Easy📖Теория5 min

Области видимости и замыкания

LEGB правило, global, nonlocal, замыкания, late binding и практические паттерны

Области видимости и замыкания

Понимание областей видимости (scope) и замыканий (closures) -- ключ к написанию правильного Python-кода. Разберём правило LEGB, ключевые слова global/nonlocal и типичные ловушки.

Правило LEGB

Python ищет имена переменных по правилу LEGB (от внутреннего к внешнему):

L - Local:      тело текущей функции
E - Enclosing:  тело внешней (вложенной) функции
G - Global:     уровень модуля (файла)
B - Built-in:   встроенные имена Python (print, len, range...)
# Built-in scope
# print, len, range, etc.

# Global scope
x = "глобальная"

def outer():
    # Enclosing scope
    x = "внешняя"

    def inner():
        # Local scope
        x = "локальная"
        print(f"inner: {x}")  # 'локальная'

    inner()
    print(f"outer: {x}")     # 'внешняя'

outer()
print(f"global: {x}")        # 'глобальная'

Поиск имён на практике

# Python looks up names at RUNTIME, not at definition time
name = "Глобальное"

def show_name():
    print(name)  # will look up 'name' when called

show_name()      # 'Глобальное'
name = "Изменённое"
show_name()      # 'Изменённое'

# UnboundLocalError - a common trap
x = 10

def broken():
    # print(x)  # UnboundLocalError! Python sees assignment below
    x = 20      # This makes 'x' a LOCAL variable in the entire function
    print(x)

# Python determines scope at COMPILE time (not runtime)
# If there's ANY assignment to x in the function, x is local

global и nonlocal

global

counter = 0

def increment():
    global counter  # declare that we want to modify the global variable
    counter += 1

increment()
increment()
increment()
print(counter)  # 3

# Without global:
count = 0

def broken_increment():
    # count += 1  # UnboundLocalError! count is treated as local
    pass

# global allows reading AND writing to global variables
settings = {}

def update_settings(**kwargs):
    global settings
    settings.update(kwargs)

update_settings(theme="dark", lang="ru")
print(settings)  # {'theme': 'dark', 'lang': 'ru'}

# NOTE: global is generally discouraged
# Better approach: use function parameters and return values
def increment_value(current: int) -> int:
    return current + 1

counter = 0
counter = increment_value(counter)

nonlocal

def make_counter(start: int = 0):
    """Create a counter with enclosing scope."""
    count = start

    def increment() -> int:
        nonlocal count  # modify variable in enclosing scope
        count += 1
        return count

    def decrement() -> int:
        nonlocal count
        count -= 1
        return count

    def get() -> int:
        return count  # reading doesn't need nonlocal

    return increment, decrement, get

inc, dec, get = make_counter(10)
print(inc())  # 11
print(inc())  # 12
print(dec())  # 11
print(get())  # 11

# nonlocal works only with enclosing function scope, NOT global
x = 10
def outer():
    x = 20
    def inner():
        nonlocal x  # refers to outer's x, not global x
        x = 30
    inner()
    print(x)  # 30

outer()
print(x)  # 10 (global x unchanged)

Замыкания (Closures)

Замыкание -- это функция, которая запоминает переменные из окружающей области видимости:

def make_greeting(greeting: str):
    """Create a greeting function with a captured greeting word."""
    def greet(name: str) -> str:
        return f"{greeting}, {name}!"
    return greet

hello = make_greeting("Привет")
goodbye = make_greeting("До свидания")

print(hello("Иван"))     # Привет, Иван!
print(goodbye("Мария"))  # До свидания, Мария!

# The closure captures the variable, not just the value
print(hello.__closure__)
print(hello.__closure__[0].cell_contents)  # 'Привет'

Практические примеры замыканий

# Multiplier factory
def make_multiplier(factor: int):
    """Return a function that multiplies by factor."""
    def multiply(x: int) -> int:
        return x * factor
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5))   # 10
print(triple(5))   # 15

# Logger with prefix
def make_logger(prefix: str):
    """Return a logging function with a fixed prefix."""
    def log(message: str) -> None:
        from datetime import datetime
        ts = datetime.now().strftime("%H:%M:%S")
        print(f"[{ts}] [{prefix}] {message}")
    return log

db_log = make_logger("DB")
api_log = make_logger("API")
db_log("Connected")     # [14:30:00] [DB] Connected
api_log("Request GET")  # [14:30:00] [API] Request GET

# Memoization closure
def memoize(func):
    """Cache function results."""
    cache = {}
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return wrapper

@memoize
def fibonacci(n: int) -> int:
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(100))  # 354224848179261915075 (instant with memoization)

# Validator factory
def make_range_validator(min_val: float, max_val: float):
    """Return a validator function for a numeric range."""
    def validate(value: float) -> bool:
        return min_val <= value <= max_val
    validate.__doc__ = f"Check if value is between {min_val} and {max_val}"
    return validate

is_valid_age = make_range_validator(0, 150)
is_valid_score = make_range_validator(0, 100)

print(is_valid_age(25))     # True
print(is_valid_age(200))    # False
print(is_valid_score(85))   # True

Ловушка Late Binding

Одна из самых частых ошибок с замыканиями:

# PROBLEM: late binding in closures
functions = []
for i in range(5):
    functions.append(lambda: i)

# All functions return the SAME value!
print([f() for f in functions])  # [4, 4, 4, 4, 4]
# Why? lambda captures the VARIABLE i, not its VALUE
# When called, i is already 4 (after the loop)

# FIX 1: default argument (captures value at definition time)
functions = []
for i in range(5):
    functions.append(lambda i=i: i)  # default argument captures current value

print([f() for f in functions])  # [0, 1, 2, 3, 4]

# FIX 2: closure factory
def make_func(x):
    return lambda: x

functions = [make_func(i) for i in range(5)]
print([f() for f in functions])  # [0, 1, 2, 3, 4]

# FIX 3: functools.partial
from functools import partial

def return_value(x):
    return x

functions = [partial(return_value, i) for i in range(5)]
print([f() for f in functions])  # [0, 1, 2, 3, 4]

# The same problem with callbacks
callbacks = {}
for name in ["save", "load", "delete"]:
    callbacks[name] = lambda: print(f"Action: {name}")

callbacks["save"]()    # Action: delete (WRONG!)
callbacks["delete"]()  # Action: delete

# Fix
callbacks = {}
for name in ["save", "load", "delete"]:
    callbacks[name] = lambda n=name: print(f"Action: {n}")

callbacks["save"]()    # Action: save (CORRECT)

Область видимости comprehensions

# List comprehensions have their own scope (Python 3+)
x = 10
squares = [x**2 for x in range(5)]
print(x)  # 10 (x from comprehension doesn't leak)

# But in Python 2, x would be 4! (loop variable leaked)

# Walrus operator inside comprehension
results = [y := x**2 for x in range(5)]
print(y)  # 16 (walrus DOES leak to enclosing scope!)

# Nested comprehension scopes
matrix = [[i * j for j in range(3)] for i in range(3)]
# i and j are local to their comprehensions

Глобальное состояние: паттерны и антипаттерны

# ANTI-PATTERN: mutable global state
_cache = {}

def get_cached(key):
    global _cache
    return _cache.get(key)

# BETTER: module-level with controlled access
class _Config:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._data = {}
        return cls._instance

    def get(self, key, default=None):
        return self._data.get(key, default)

    def set(self, key, value):
        self._data[key] = value

config = _Config()

# BEST: dependency injection
def process_data(data: list[int], *, config: dict[str, str]) -> list[int]:
    """Process data using injected configuration."""
    threshold = int(config.get("threshold", "10"))
    return [x for x in data if x > threshold]

result = process_data([5, 15, 25], config={"threshold": "10"})

Итоги

  • LEGB -- порядок поиска имён: Local, Enclosing, Global, Built-in
  • global -- объявляет переменную как глобальную для записи
  • nonlocal -- изменяет переменную из enclosing scope
  • Замыкания захватывают переменные (не значения!) из окружения
  • Late binding -- частая ловушка в циклах с лямбдами, исправляется через i=i
  • Python определяет scope на этапе компиляции -- присваивание делает переменную локальной
  • Избегайте global -- используйте параметры функций и возвращаемые значения
  • Comprehensions имеют свою область видимости (Python 3+), но walrus operator протекает наружу

Проверь себя

🧪

Что означает аббревиатура LEGB в контексте Python?

🧪

Когда нужно ключевое слово nonlocal?

🧪

Что такое замыкание (closure) в Python?

🧪

Что выведет код: funcs = [lambda: i for i in range(3)]; print([f() for f in funcs])?