Замыкания и функции высшего порядка

Поднимаемся на новый уровень: функции, которые помнят своё окружение, и функции, работающие с другими функциями.

Замыкание — функция вместе с захваченным окружением: она «помнит» переменные из области, где была создана, даже после выхода из неё. Функция высшего порядка — функция, которая принимает другие функции как аргументы или возвращает функцию.

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

Функции высшего порядка: mapcar, reduce, remove-if

Раз функцию можно передать как аргумент, можно написать функцию, которая применяет переданную ей функцию к данным. Это и есть функции высшего порядка, и три из них — рабочие лошадки функционального стиля. mapcar применяет функцию к каждому элементу списка и собирает результаты. reduce «сворачивает» список в одно значение, последовательно применяя бинарную функцию. remove-if отбрасывает элементы, удовлетворяющие предикату.

;; mapcar — преобразовать каждый элемент:
(mapcar #'(lambda (x) (* x x)) '(1 2 3 4))   ; => (1 4 9 16)

;; reduce — свернуть список в одно значение:
(reduce #'+ '(1 2 3 4 5))            ; => 15   (((((1+2)+3)+4)+5))
(reduce #'* '(1 2 3 4 5))            ; => 120  (произведение)
(reduce #'max '(3 7 2 9 4))          ; => 9    (максимум)

;; remove-if — отбросить подходящие под предикат:
(remove-if #'evenp '(1 2 3 4 5 6))   ; => (1 3 5)   убрали чётные
(remove-if-not #'evenp '(1 2 3 4 5 6)) ; => (2 4 6) оставили чётные

Сравните это с императивным стилем, где для каждой такой операции пришлось бы писать цикл с накоплением. Функции высшего порядка позволяют выразить намерение («преобразовать каждый», «свернуть в сумму», «отфильтровать») одной строкой, без ручного управления итерацией. Это не только короче, но и яснее: читатель сразу видит, что происходит, не разбираясь в механике цикла.

Стоит присмотреться к reduce внимательнее — это самая универсальная из трёх. Она берёт бинарную функцию и список и применяет функцию к накопленному результату и очередному элементу, проходя список слева направо. По сути почти любую агрегацию — сумму, произведение, максимум, склейку строк, подсчёт — можно выразить через reduce с подходящей функцией. У неё есть и важный именованный параметр :initial-value, задающий стартовое значение свёртки; он нужен, в частности, чтобы корректно обрабатывать пустой список (без него reduce по пустому списку вызвал бы функцию без аргументов). Понимание, что mapcar, фильтрация и подсчёт — частные случаи одного общего приёма «свёртки», — один из тех концептуальных скачков, ради которых и стоит осваивать функциональный стиль в его первоисточнике.

Знак #' и передача функций

Вы заметили загадочный #' перед именами функций. Это снова проявление Lisp-2: поскольку у функций и переменных раздельные пространства имён, чтобы взять функцию по имени как значение и передать её, нужен специальный синтаксис #'имя (это сокращение для (function имя)). Без него Common Lisp искал бы переменную с таким именем. Перед lambda знак #' ставить не обязательно, но принято для единообразия. Подробно механику #' и funcall мы разберём в следующем уроке; пока запомните, что #' означает «возьми это как функцию».

;; #' берёт именованную функцию как значение:
(mapcar #'1+ '(1 2 3))           ; => (2 3 4)    передали функцию 1+
(mapcar #'sqrt '(1 4 9 16))      ; => (1.0 2.0 3.0 4.0)

;; В Scheme это не нужно — функция и так значение:
; (map sqrt '(1 4 9 16))   ; Scheme: без #', просто имя

Замыкания: функции, помнящие окружение

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

;; Функция, возвращающая функцию-замыкание:
(defun make-adder (n)
  (lambda (x) (+ x n)))        ; lambda захватывает n

(defparameter add5 (make-adder 5))   ; add5 помнит n=5
(defparameter add10 (make-adder 10)) ; add10 помнит n=10

(funcall add5 100)    ; => 105   (100 + захваченное 5)
(funcall add10 100)   ; => 110   (100 + захваченное 10)

Вдумайтесь: make-adder завершилась, её параметр n формально «исчез», но возвращённая лямбда продолжает помнить значение n, с которым была создана. add5 и add10 — это два разных замыкания одной и той же лямбды, но с разным захваченным n. Каждое несёт собственную копию окружения. Это и есть суть замыкания — функция плюс «прилипшее» к ней окружение.

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

Особенно эффектно замыкания проявляются, когда захваченная переменная изменяется. Тогда замыкание получает приватное, инкапсулированное состояние, доступное только через него, — почти как объект с private-полем. Классический пример — счётчик.

;; Замыкание с изменяемым приватным состоянием:
(defun make-counter ()
  (let ((count 0))             ; приватная переменная
    (lambda ()
      (incf count)             ; увеличиваем захваченную count
      count)))

(defparameter c (make-counter))
(funcall c)    ; => 1
(funcall c)    ; => 2
(funcall c)    ; => 3   (count "живёт" внутри замыкания между вызовами)

;; Другой счётчик независим — у него своя count:
(defparameter c2 (make-counter))
(funcall c2)   ; => 1   (своё состояние)

Здесь переменная count существует только внутри замыкания c; снаружи к ней не подобраться никаким способом, кроме как вызвав c. Это инкапсуляция в чистом виде, без всякого объектно-ориентированного аппарата. А c2 — независимое замыкание со своим count. Замыкания, по сути, — это «объекты для бедных»: функция, носящая в себе приватное состояние. Неудивительно, что объектные системы исторически часто реализовывали именно через замыкания.

Функции, конструирующие функции

Объединив замыкания и идею «функция как значение», получаем мощный приём: функции, которые изготавливают другие функции под конкретную задачу. make-adder из предыдущего раздела — простейший пример: это «фабрика» сумматоров. Но идея идёт гораздо дальше. Можно написать функцию, которая по предикату строит его отрицание, или которая комбинирует две функции в их композицию. Это позволяет собирать поведение из кирпичиков прямо во время работы программы.

;; Фабрика: по числу n строит функцию "умножить на n":
(defun multiplier (n)
  (lambda (x) (* x n)))

(defparameter triple (multiplier 3))
(mapcar triple '(1 2 3 4))      ; => (3 6 9 12)

;; Композиция двух функций в одну (тоже через замыкание):
(defun compose2 (f g)
  (lambda (x) (funcall f (funcall g x))))

(defparameter inc-then-sq (compose2 (lambda (x) (* x x))
                                    #'1+))
(funcall inc-then-sq 4)         ; => 25   сначала 4+1=5, потом 5*5=25

Функция compose2 принимает две функции и возвращает новую функцию-замыкание, которая применяет их одну за другой. Это чистое функциональное конструирование: из существующих функций мы строим новую, не написав ни одного цикла и не объявив ни одной именованной вспомогательной функции. В развитом виде такие приёмы — каррирование, частичное применение, композиция — образуют целый стиль программирования, и Lisp был одной из первых сред, где он стал естественным.

Зачем это на практике

Может показаться, что замыкания и функции высшего порядка — красивые игрушки. На деле это рабочие инструменты, экономящие массу кода. Функции высшего порядка убирают повторяющийся «скелет» циклов: вместо того чтобы в десятый раз писать «заведи аккумулятор, пройди список, накапливай», вы пишете reduce с нужной операцией. Замыкания дают лёгкий способ нести настройку или состояние без громоздких классов: колбэк, помнящий контекст; генератор, помнящий позицию; обработчик с преднастроенными параметрами. Там, где в императивном языке завели бы целый объект с одним методом, в Lisp обходятся замыканием в пару строк.

Особенно ярко это в задачах обработки данных. Цепочка из remove-if, mapcar и reduce выражает «отфильтровать — преобразовать — свернуть» декларативно и читается почти как описание задачи на естественном языке. Этот стиль — прямой предок того, что сегодня называют «функциональными конвейерами» и что есть в потоковых API современных языков. Снова видим знакомый сюжет: идея, рождённая в Lisp десятилетия назад, стала повседневностью в мейнстриме много позже.

Как это работает под капотом

Чтобы замыкание работало, захваченные переменные не могут жить на стеке (который освобождается при выходе из функции) — они должны пережить породившую их функцию. Поэтому реализация Lisp размещает захваченные переменные в куче, и замыкание хранит ссылку на эту «коробку» с переменными, называемую окружением. Когда вы вызываете замыкание, оно обращается к своему сохранённому окружению, а не к стеку. Сборщик мусора держит окружение живым ровно до тех пор, пока живо хоть одно ссылающееся на него замыкание, и освобождает, когда замыкание исчезает.

Ключевой момент — что именно захватывается. В Lisp и Scheme замыкания захватывают переменную, а не её мгновенное значение, поэтому изменения через одно замыкание видны другим, делящим ту же переменную, — как мы видели со счётчиком. Это лексическое замыкание: лямбда «помнит» те переменные, что были видны в тексте в месте её определения. Само существование таких замыканий возможно только при лексической области видимости, которую мы детально разберём в следующем разделе. Исторически именно Scheme в 1970-е первым сделал лексические замыкания центральным механизмом, и эта идея оказала колоссальное влияние на все последующие языки.

Частые ошибки

Первая ошибка — думать, что замыкание захватывает значение, а не переменную. Если в цикле создавать замыкания, захватывающие переменную цикла, все они могут «увидеть» одно финальное значение, а не то, что было на каждой итерации, — классическая ловушка, знакомая и программистам на JavaScript. Решение — на каждой итерации заводить свежую локальную переменную (через let), которую и захватывать.

Вторая ошибка — забыть funcall при вызове замыкания в Common Lisp. Замыкание, лежащее в переменной, вызывается через funcall: (funcall add5 100), а не (add5 100). Последнее в Common Lisp искало бы функцию с именем add5, а не значение переменной. Это снова Lisp-2; в Scheme замыкание вызывается напрямую.

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

Итоги

  • Функции высшего порядка (mapcar, reduce, remove-if) принимают функции как аргументы и выражают намерение («преобразовать», «свернуть», «отфильтровать») без ручных циклов.
  • Знак #' берёт именованную функцию как значение (следствие Lisp-2); в Scheme он не нужен, потому что функция — обычное значение.
  • Замыкание — функция, захватившая переменные из области своего создания; она помнит их и после выхода из породившей функции.
  • Захватывается переменная, а не значение, поэтому замыкание может нести приватное изменяемое состояние (счётчик) — инкапсуляция без ООП.
  • Захваченные переменные живут в куче (окружении), а не на стеке; лексические замыкания возможны только при лексической области видимости и впервые расцвели в Scheme.
Проверьте себя
1. Что такое замыкание в Lisp?
AФункция, у которой закрыт доступ извне
BФункция вместе с захваченным окружением — она помнит переменные из области, где была создана, даже после выхода из породившей её функции
CСпособ закрыть программу без ошибок
DФункция, которая не принимает аргументов
2. Почему два счётчика, созданные через (make-counter), независимы и не делят счёт?
AПотому что они используют глобальную переменную
BПотому что каждый вызов make-counter создаёт новое окружение со своей переменной count, и каждое замыкание захватывает свою отдельную count
CПотому что счётчики в Lisp всегда сбрасываются
DПотому что incf обнуляет состояние
3. Замыкание в Lisp захватывает значение переменной или саму переменную?
AМгновенное значение на момент создания
BСаму переменную, поэтому последующие изменения через замыкание (например, incf count) видны при следующих вызовах и другим замыканиям, делящим ту же переменную
CКопию всей программы
DТолько имя переменной как строку