Замыкания и функции высшего порядка
Поднимаемся на новый уровень: функции, которые помнят своё окружение, и функции, работающие с другими функциями.
Замыкание — функция вместе с захваченным окружением: она «помнит» переменные из области, где была создана, даже после выхода из неё. Функция высшего порядка — функция, которая принимает другие функции как аргументы или возвращает функцию.
Функции в 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.