Продвинутый Python
November 30

Замыкания в Python: примеры использования

Замыкание — это функция, которая определена внутри другой функции и использует переменные из локальной области видимости внешней функции. Эти переменные остаются доступными для вложенной функции даже после завершения выполнения внешней функции, что позволяет ей использовать их в дальнейшем.

Замыкания часто используются в функциональных языках программирования. Python их поддерживает, замыкания в нём могут быть очень полезными, так как они, например, позволяют создавать декораторы на основе функций. В этой статье мы:

  • Разберем, что такое замыкания и как они работают в Python.
  • Узнаем, как и где их можно применять.
  • Изучим альтернативы замыканиям.

Чтобы всё понять в этой статье, вам желательно знать, что такое функции, вложенные функции, декораторы и классы в Python.

Чтобы лучше понять замыкания, давайте сначала разберём внутренние функции, так как замыкания также являются их разновидностью.

Вложенные функции

В Python вложенная функция — это функция, которую мы определяем внутри другой функции. Она может использовать и изменять переменные из своей внешней функции, которая является для неё нелокальной областью видимости.

Вот небольшой пример:

def outer_func():
    name = "username"
    def inner_func():
        print(f"Hello, {name}!")
    inner_func()

outer_func() # Hello, username!

greeter = outer_func()
print(greeter) # None

В этом примере мы определяем outer_func() в глобальной области видимости. Внутри этой функции мы определяем локальную переменную name. Затем мы определяем другую функцию под названием inner_func(). Поскольку эта вторая функция находится в теле outer_func(), она является вложенной. Наконец, мы вызываем внутреннюю функцию, которая использует переменную name, определенную во внешней функции.

В приведенном примере внутренняя функция может использовать переменные из внешней функции.

Однако, когда мы вызываем внешнюю функцию, мы не получаем ссылку на внутреннюю функцию. Внутренняя функция и локальные её локальные переменные не будут доступны вне внешней функции.

Далее мы посмотрим, как превратить вложенную функцию в замыкание, чтобы сделать её и сохраненные в ней переменные доступными для использования.

Замыкания

Все замыкания являются вложенными функциями, но не все вложенные функции являются замыканиями. Чтобы превратить вложенную функцию в замыкание, нужно вернуть объект вложенной функции из внешней функции. Звучит как скороговорка, но делается не сложно:

def outer_func():
    name = "username"
    def inner_func():
        print(f"Hello, {name}!")
    return inner_func

outer_func() # .inner_func at ...>

greeter = outer_func()

greeter() # Hello, username!

В этой новой версии outer_func() мы возвращаем объект функции inner_func вместо её вызова. Когда мы вызываем outer_func(), то получаем объект функции, который является замыканием (а не просто выводим приветствие). Этот объект замыкания помнит и может получить доступ к значению name, даже после того, как outer_func() вернула значение. Поэтому вы получаете сообщение приветствия, когда вызываете greeter().

Для создания замыкания в Python нам понадобятся три компонента:

  1. Внешняя функция: Это функция, которая содержит другую функцию, называемую внутренней. Внешняя функция может принимать аргументы и определять переменные, к которым внутренняя функция может получить доступ и обновить..
  2. Локальные переменные внешней функции: Это переменные, определенные внутри внешней функции. Python сохраняет эти переменные, позволяя использовать их в замыкании, даже после того, как внешняя функция завершила свою работу.
  3. Вложенная функция: Это функция, определенная внутри внешней функции. Она может получать доступ и обновлять переменные из внешней функции, даже после того, как внешняя функция вернула значение.

В примере выше у нас есть внешняя функция, локальная переменная (name) и вложенная функция. Последний шаг для создания замыкания — вернуть объект внутренней функции из внешней функции.

Кстати, вы также можете использовать lambda-функции для создания замыканий:

def outer_func():
    name = "username"
    return lambda: print(f"Hello, {name}!")

greeter = outer_func()
greeter() # Hello, username!

В этой модифицированной версии outer_func() мы используем lambda-функцию для создания замыкания, которое работает так же, как в предыдущем варианте функции.

Переменные, захваченные замыканием

Как вы уже знаете, замыкание сохраняет переменные из своей внешней области видимости. Давайте рассмотрим простой пример:

def outer_func(outer_arg):
    local_var = "Внешняя локальная переменная"
    def closure():
        print(outer_arg)
        print(local_var)
        print(another_local_var)
    another_local_var = "Другая внешняя локальная переменная"
    return closure

closure = outer_func("Внешний аргумент")

closure()
# Внешний аргумент
# Внешняя локальная переменная
# Другая внешняя локальная переменная

В этом примере переменные outer_arg, local_var и another_local_var сохраняются в замыкании при вызове outer_func(), даже если их область видимости больше не доступна. Замыкание может получить доступ к этим переменным, потому что они теперь являются частью самого замыкания. Таким образом, замыкание — это функция с расширенной областью видимости.

Замыкания также могут обновлять значение этих переменных, и тут может быть два сценария: переменные могут ссылаться либо на неизменяемый, либо на изменяемый объект.

Чтобы обновить значение переменной, указывающей на неизменяемый объект, вам нужно использовать оператор nonlocal. Рассмотрим следующий пример:

def make_counter():
    count = 0
    def counter():
        nonlocal count
        count += 1
        return count
    return counter

counter = make_counter()

counter() # 1
counter() # 2
counter() # 3

Тут count содержит ссылку на целочисленное значение, которое является неизменяемым. Чтобы обновить значение count, мы используем оператор nonlocal. Этот оператор говорит Python, что нужно использовать переменную из внешней области видимости.

Если же переменная указывает на изменяемый объект, например, список, вы можете изменить её значение напрямую:

def make_appender():
    items = []
    def appender(new_item):
        items.append(new_item)
        return items
    return appender

appender = make_appender()

appender("Первый элемент")
# ['Первый элемент']

appender("Второй элемент")
# ['Первый элемент', 'Второй элемент']

appender("Третий элемент")
# ['Первый элемен', 'Второй элемент', 'Третий элемент']

Тут items указывает на список, а они изменяемы. В этом случае нам не нужно использовать ключевое слово nonlocal.

Создание замыканий для сохранения состояния

На практике замыкания в Python могут быть полезны в различных ситуациях. Давайте рассмотрим, как использовать замыкания для создания фабричных функций, сохранения состояния между вызовами функций и реализации функций обратного вызова.

Фабричные функции

Замыкания позволяют создавать функции с начальной конфигурацией или параметрами. Это полезно, когда нужно создать несколько похожих функций с разными настройками.

Предположим, что мы хотим вычислять числовые корни разных степеней и с разной точностью результатов. В этом случае можно написать фабричную функцию, которая возвращает замыкания с предопределенными степенями и точностью:

def make_root_calculator(root_degree, precision=2):
    def root_calculator(number):
        return round(pow(number, 1 / root_degree), precision)
    return root_calculator

square_root = make_root_calculator(2, 4)
square_root(42) # 6.4807

cubic_root = make_root_calculator(3)
cubic_root(42) # 3.48

Функция make_root_calculator() позволяет создавать функции для вычисления различных числовых корней. Она принимает степень корня и желаемую точность в качестве параметров.

Внутри этой функции мы определяем внутреннюю функцию, которая принимает число и вычисляет указанный корень с заданной точностью. Затем мы возвращаем эту внутреннюю функцию, создавая замыкание.

Теперь можно использовать make_root_calculator() для создания функций, которые вычисляют квадратные, кубические и другие корни с нужной точностью.

Функции с сохранением состояния

Замыкания позволяют сохранять состояние между вызовами функций. Такие функции называются функциями с сохранением состояния, и замыкания — отличный способ их создать.

Предположим, что мы хотим написать функцию, которая принимает последовательные числовые значения из потока данных и вычисляет их кумулятивное среднее. Функция должна помнить ранее переданные значения между вызовами. Можно сделать так:

def cumulative_average():
    data = []
    def average(value):
        data.append(value)
        return sum(data) / len(data)
    return average


stream_average = cumulative_average()

stream_average(12) # 12.0
stream_average(13) # 12.5
stream_average(11) # 12.0
stream_average(10) # 11.5

В cumulative_average() локальная переменная data позволяет сохранять состояние между последовательными вызовами объекта замыкания, который возвращает эта функция.

Затем мы создаём замыкание под названием stream_average() и вызываем его с различными числовыми значениями. Замыкание запоминает ранее переданные значения и вычисляет среднее значение, добавляя новое предоставленное значение.

Функции обратного вызова

Замыкания часто используются в событийно-ориентированном программировании, когда нужно создать функции обратного вызова, которые передают дополнительный контекст или информацию о состоянии. Создание графического пользовательского интерфейса (GUI) — отличный пример использования таких функций.

Предположим, что мы хотим создать простое приложение "Hello, World!" с помощью Tkinter, стандартной библиотеки для создания GUI в Python. Приложению нужна метка для отображения приветствия и кнопка для его запуска. Вот код для этого небольшого приложения:

import tkinter as tk

app = tk.Tk()
app.title("GUI App")
app.geometry("320x240")

label = tk.Label(
    app,
    font=("Helvetica", 16, "bold"),
)
label.pack()

def callback(text):
    def closure():
        label.config(text=text)

    return closure

button = tk.Button(
    app,
    text="Приветствие",
    command=callback("Hello, World!"),
)
button.pack()

app.mainloop()

Этот код создает небольшое приложение с помощью Tkinter, которое состоит из окна с меткой и кнопкой. Когда вы нажимаете кнопку "Приветствие", метка отображает сообщение "Hello, World!".

Функция callback() возвращает замыкание, которое можно использовать для передачи аргумента команде кнопки. Это позволяет передавать аргументы в функцию обратного вызова, даже если она не принимает аргументов напрямую.

Декораторы с замыканиями

Декораторы — это мощный инструмент в Python, который позволяет динамически изменять поведение функций. В Python существует два типа декораторов:

  • Декораторы на основе функций
  • Декораторы на основе классов

Декоратор на основе функции — это функция, которая принимает объект функции в качестве аргумента и возвращает другой объект функции с расширенной функциональностью. Этот последний объект функции также является замыканием. Таким образом, для создания декораторов на основе функций используются замыкания.

Декораторы позволяют изменять поведение функций без изменения их внутреннего кода. На практике декораторы на основе функций являются замыканиями, и их основная цель — изменять поведение функции, которую вы передаете в качестве аргумента.

Вот простейший пример декоратора, который добавляет сообщения поверх функциональности входной функции:

def decorator(function):
    def closure():
        print("Делаем что-то до вызова функции.")
        function()
        print("Делаем что-то после вызова функции.")
    return closure

В этом примере внешняя функция decorator возвращает замыкание, которое изменяет исходное поведение входной функции, добавляя дополнительные действия. Замыкание может действовать на входную функцию, даже после того, как функция decorator вернула значение.

Вот как можно использовать синтаксис декоратора для динамического изменения поведения обычной функции Python:

@decorator
def greet():
    print("Hi, username!")

greet()
# Делаем что-то до вызова функции.
# Hi, username!
# Делаем что-то после вызова функции.

В этом примере мы используем @decorator для изменения поведения функции greet(). Теперь, когда мы вызываем greet(), то получаем её исходную функциональность плюс дополнительные действия, добавленные декоратором. Таким образом, декораторы позволяют легко и эффективно расширять функциональность существующих функций.

Мемоизация с замыканиями

Кэширование может значительно повысить производительность алгоритмов, избегая ненужных повторных вычислений. Мемоизация — это техника кэширования, которая предотвращает выполнение функции более одного раза для одного и того же входного значения. Она работает, сохраняя результат для заданного набора входных аргументов в памяти и затем используя его при необходимости.

Замыкания отлично подходят для реализации мемоизации. Рассмотрим простой пример:

def memoize(function):
    cache = {}
    def closure(number):
        if number not in cache:
            cache[number] = function(number)
        return cache[number]
    return closure

Функция memoize() принимает другую функцию в качестве аргумента и возвращает замыкание. Внутренняя функция выполняет входную функцию только для новых значений, а результаты кэшируются в словаре cache.

Примечание: Python включает мемоизацию в стандартную библиотеку. Если нужно делать кэширование, то можно использовать @cache или @lru_cache из модуля functools.

Теперь предположим, что у нас есть функция, которая имитирует дорогостоящее вычисление:

from time import sleep

def slow_operation(number):
    sleep(0.5)

Эта функция задерживает выполнение на полсекунды, чтобы имитировать дорогостоящую операцию. Для этого используется функция sleep() из модуля time.

Можно измерить время выполнения функции с помощью следующего кода:

from timeit import timeit

timeit(
    "[slow_operation(number) for number in [2, 3, 4, 2, 3, 4]]",
    globals=globals(),
    number=1,
)
# 3.02610950000053

В этом коде используется функция timeit() из модуля timeit, чтобы узнать время выполнения slow_operation() при запуске этой функции со списком значений. Для обработки шести входных значений код занимает немного более трех секунд.

Теперь давайте используем мемоизацию, чтобы сделать это вычисление более эффективным, пропуская повторяющиеся входные значения. Декорируем slow_operation() с помощью @memoize и запустим код таймера:

@memoize
def slow_operation(number):
    sleep(0.5)


timeit(
    "[slow_operation(number) for number in [2, 3, 4, 2, 3, 4]]",
    globals=globals(),
    number=1,
)
# 1.5151869590008573

Теперь тот же код занимает половину времени благодаря мемоизации. Это происходит потому, что функция slow_operation() не выполняется для повторяющихся входных значений.

Инкапсуляция с замыканиями

В объектно-ориентированном программировании (ООП) классы объединяют данные и поведение в одном объекте. Одним из ключевых принципов ООП является инкапсуляция данных, которая защищает данные объекта от внешнего мира и предотвращает прямой доступ к ним.

В Python достижение строгой инкапсуляции данных может быть сложной задачей, так как нет различия между приватными и публичными атрибутами, но используется просто соглашение об именовании для их различия.

Замыкания как раз могут помочь достичь более строгой инкапсуляции данных. Они позволяют создать приватную область видимости для данных, предотвращая доступ пользователей к ним. Это помогает поддерживать целостность данных и предотвращать непреднамеренные изменения.

Рассмотрим пример с классом Stack:

class Stack:
    def __init__(self):
        self._items = []

    def push(self, item):
        self._items.append(item)

    def pop(self):
        return self._items.pop()

Этот класс хранит данные в списке _items и реализует операции push и pop. Вот как можно использовать этот класс:

from stack_v1 import Stack

stack = Stack()
stack.push(1)
stack.push(2)
stack.push(3)

stack.pop() # 3

stack._items # [1, 2]

Основная функциональность работает, но даже если атрибут _items является непубличным, то можно без проблем получить доступ к его значениям. Это затрудняет инкапсуляцию данных для защиты от прямого доступа.

Замыкания предоставляют способ для достижения более строгой инкапсуляции. Рассмотрим следующий код:

def Stack():
    _items = []

    def push(item):
        _items.append(item)

    def pop():
        return _items.pop()

    def closure():
        pass

    closure.push = push
    closure.pop = pop
    return closure

В этом примере мы создаем функцию для создания объекта замыкания вместо определение класса. Внутри функции мы определяем локальную переменную _items, которая будет частью нашего объекта замыкания. Затем мы определяем две внутренние функции для операций стека. Функция closure() является плейсхолдером для нашего замыкания. Поверх этой функции мы добавляем функции push() и pop(). Наконец, мы возвращаем результирующее замыкание.

Теперь можно использовать функцию Stack() почти так же, как и класс Stack, но с одним важным отличием: у вас нет прямого доступа к _items:

from stack_v2 import stack

stack = Stack()
stack.push(1)
stack.push(2)
stack.push(3)

stack.pop() # 3

stack._items

Traceback (most recent call last):
    ...
AttributeError: 'function' object has no attribute '_items'

Функция Stack() позволяет создавать замыкания, которые работают как экземпляры класса Stack, но без прямого доступа к _items, что улучшает инкапсуляцию данных.

Но и тут, если очень захотеть, моно использовать такой трюк для доступа к содержимому _items:

stack.push.__closure__[0].cell_contents
# [1, 2]

Атрибут .__closure__ возвращает кортеж ячеек, содержащих привязки для переменных замыкания. Объект ячейки имеет атрибут cell_contents, который можно использовать для получения значения ячейки. Однако этот трюк обычно не используется. В конце концов, если вы пытаетесь достичь инкапсуляции, зачем её нарушать?

Альтернативы замыканиям

Мы увидели, что замыкания могут помочь решить разнообразные задачи. Однако понимание их работы под капотом может быть сложным, поэтому использование альтернативных инструментов может сделать код более понятным.

Одной из таких альтернатив является использование классов, которые производят вызываемые экземпляры, реализуя специальный метод .__call__(). Вызываемые экземпляры — это объекты, которые можно вызывать как функции.

Для демонстрации вернёмся к фабричной функции make_root_calculator():

def make_root_calculator(root_degree, precision=2):
    def root_calculator(number):
        return round(pow(number, 1 / root_degree), precision)
    return root_calculator


square_root = make_root_calculator(2, 4)
square_root(42) # 6.4807

cubic_root = make_root_calculator(3)
cubic_root(42) # 3.48

Функция возвращает замыкания, которые сохраняют аргументы root_degree и precision в своей расширенной области видимости. Можно заменить эту фабричную функцию следующим классом:

class RootCalculator:
    def __init__(self, root_degree, precision=2):
        self.root_degree = root_degree
        self.precision = precision

    def __call__(self, number):
        return round(pow(number, 1 / self.root_degree), self.precision)

Этот класс принимает те же два аргумента, что и make_root_calculator(), и превращает их в атрибуты экземпляра. Реализуя метод .__call__(), вы делаете экземпляры класса вызываемыми объектами, которые можно использовать как обычные функции. Вот как можно использовать этот класс для создания функций, подобных калькулятору корней:

from roots import RootCalculator

square_root = RootCalculator(2, 4)
square_root(42) # 6.4807

cubic_root = RootCalculator(3)
cubic_root(42) # 3.48

cubic_root.root_degree # 3

Как мы видим, RootCalculator работает почти так же, как и функция make_root_calculator(). Кроме того, теперь у нас есть доступ к аргументам конфигурации, таким как root_degree.

Заключение

Теперь мы знаем, что замыкание — это функция, определенная внутри другой функции, которая захватывает и сохраняет переменные из своей окружающей области видимости. Эти переменные становятся частью состояния замыкания, что позволяет вложенной функции использовать их даже после завершения выполнения внешней функции, создавая вызываемый объект с расширенной областью видимости.

Замыкания полезны в различных сценариях, особенно когда нужно сохранять состояние между вызовами функций или создавать декораторы. Знание того, как использовать замыкания, может стать отличным навыком для любого разработчика Python.

👉🏻Подписывайтесь на PythonTalk в Telegram 👈🏻

👨🏻‍💻Ещё больше полезного на OlegTalks💬

🍩 Поддержите канал 🫶

Источник: RealPython