funcall, apply и пространства имён: Lisp-2 против Lisp-1
Разбираем до конца самое глубокое различие между Common Lisp и Scheme — раздельные пространства имён.
Common Lisp — это Lisp-2: у него отдельные пространства имён для функций и для переменных, поэтому функцию-значение вызывают через
funcallилиapply, а берут по имени через#'. Scheme — это Lisp-1: единое пространство имён, где функция — обычное значение.
Мы откладывали этот разговор на протяжении всего раздела, отмечая то #', то funcall как «следствие Lisp-2». Настало время разобрать различие до конца — оно фундаментально и определяет всю эргономику работы с функциями. Понять Lisp-2 против Lisp-1 — значит перестать спотыкаться об эти конструкции и осознанно переходить между диалектами.
Суть различия: одно имя или два смысла
Вопрос, разделяющий два мира, звучит так: может ли одно имя одновременно обозначать функцию и переменную? Common Lisp отвечает «да». У символа в нём, как мы помним из раздела про символы, есть отдельная ячейка под значение-переменную и отдельная под функцию. Поэтому имя list может быть функцией, и при этом переменная list может хранить какой-то конкретный список — они не мешают друг другу, потому что живут в разных пространствах имён. За это Common Lisp и называют Lisp-2: два пространства имён.
;; В Common Lisp одно имя может быть И функцией, И переменной:
(defun list-demo ()
(let ((list '(1 2 3))) ; list — переменная со значением (1 2 3)
(list list list))) ; первый list — ФУНКЦИЯ, остальные — переменная
(list-demo) ; => ((1 2 3) (1 2 3))
;; функция list построила список из двух значений переменной list
Вглядитесь в (list list list): первый list в позиции оператора — это функция, а два других в позиции аргументов — это переменная. Common Lisp различает их по позиции: первый элемент списка ищется в функциональном пространстве, остальные — в пространстве переменных. Для новичка это выглядит как фокус, но это строгое и последовательное правило.
Почему тогда нужны #' и funcall
Раздельные пространства имён создают проблему: а как взять функцию как значение, чтобы передать её, например, в mapcar? Если просто написать имя в позиции аргумента, Common Lisp поищет переменную с этим именем и не найдёт функцию. Поэтому нужен явный способ сказать «возьми функцию по этому имени» — это и есть #'имя (сокращение для (function имя)). И обратная задача: как вызвать функцию, которая лежит в переменной (или пришла как аргумент)? Записать (f x) нельзя — Common Lisp поищет функцию с именем f, а не значение переменной. Для этого есть funcall.
;; #' берёт функцию по имени как значение:
(defparameter op #'+) ; положили функцию + в переменную op
;; funcall вызывает функцию-значение из переменной:
(funcall op 2 3) ; => 5
(funcall #'* 4 5) ; => 20
;; Без funcall не получится — (op 2 3) искало бы функцию с именем op:
;; (op 2 3) ; ОШИБКА: нет функции с именем op
Итак, #' и funcall — это «мосты» между двумя пространствами. #' достаёт функцию из функционального пространства, чтобы положить её в переменную (пространство значений). funcall берёт функцию из переменной и вызывает её как функцию. Вся «лишняя церемония» Common Lisp с функциями объясняется ровно этим: пространства разделены, и нужны явные переходы между ними.
apply: вызов с готовым списком аргументов
Близкий родственник funcall — это apply. Разница в том, как передаются аргументы. funcall принимает аргументы по отдельности, а apply берёт последний аргумент как список и «раскрывает» его в отдельные аргументы. Это незаменимо, когда аргументы у вас уже собраны в список (например, пришли через &rest).
;; apply раскрывает список как отдельные аргументы:
(apply #'+ '(1 2 3 4)) ; => 10 как (+ 1 2 3 4)
(apply #'max '(3 7 2 9)) ; => 9
;; Можно часть аргументов передать отдельно, а хвост — списком:
(apply #'+ 1 2 '(3 4 5)) ; => 15 как (+ 1 2 3 4 5)
;; Типичная связка с &rest:
(defun sum-all (&rest nums)
(apply #'+ nums)) ; nums — список, раскрываем его в +
(sum-all 10 20 30) ; => 60
Сравните: (funcall #'+ 1 2 3) и (apply #'+ '(1 2 3)) дают одно и то же, но первый получает аргументы россыпью, а второй — списком. Выбор зависит от того, в каком виде у вас аргументы. apply особенно важен в паре с &rest: собранный там список аргументов раскрывают обратно именно через apply.
Как это устроено в Scheme: Lisp-1
Scheme пошёл противоположным путём — единое пространство имён, Lisp-1. Имя означает ровно одно: либо функцию, либо переменную, но не то и другое сразу. Функция в Scheme — это обычное значение, наравне с числами и строками. Огромное практическое следствие: не нужны ни #', ни funcall. Функцию передают, просто называя её; функцию-значение вызывают, просто ставя её первым элементом списка.
; Scheme (R7RS): единое пространство имён — без #' и funcall
(define op +) ; функция + кладётся в op как обычное значение
(op 2 3) ; => 5 вызывается напрямую!
(map sqrt '(1 4 9 16)) ; => (1 2 3 4) просто имя, без #'
; apply есть и в Scheme — она нужна не из-за пространств имён:
(apply + '(1 2 3 4)) ; => 10
Заметьте: apply существует и в Scheme — она нужна для раскрытия списка аргументов независимо от числа пространств имён. А вот #' и funcall в Scheme отсутствуют как ненужные: раз пространство одно, переходить не между чем. Код на Scheme в этом смысле «легче» — меньше церемоний вокруг функций.
Какой подход лучше — и почему оба существуют
Это давний спор, и у обеих сторон есть резоны. Сторонники Lisp-1 (Scheme) ценят простоту и единообразие: функция — это просто значение, никаких особых правил, код чище. Сторонники Lisp-2 (Common Lisp) указывают на практическое удобство: можно назвать переменную list, count или stream, не «затирая» одноимённую стандартную функцию. В Lisp-1 локальная переменная list сделала бы недоступной функцию list в той же области, что вынуждает избегать таких имён; в Lisp-2 эта проблема не возникает.
Это различие не «лучше/хуже», а компромисс между чистотой и практичностью, и обе традиции живут десятилетиями именно потому, что обе по-своему правы. Clojure, кстати, выбрал путь Lisp-1, как и большинство нелисповых функциональных языков, — так что исторически побеждает скорее единое пространство имён. Но в Common Lisp Lisp-2 закреплён намертво, и работать с ним нужно осознанно, понимая, откуда берутся #' и funcall.
Любопытно, что спор Lisp-1 против Lisp-2 — один из самых известных и затяжных в истории языков программирования; вокруг него в своё время ломалось немало копий, и даже создатели Scheme и Common Lisp публично обменивались доводами. Аргумент за Lisp-2 тоньше, чем кажется: в реальном коде имена-существительные (list, count, stream, type) одновременно напрашиваются и как удачные имена переменных, и как имена функций, и Lisp-2 снимает этот конфликт без переименований. Аргумент за Lisp-1 — что единообразие важнее: когда функция ничем не отличается от прочих значений, исчезает целый пласт правил и специального синтаксиса, а функциональный стиль (где функции постоянно передают и возвращают) становится визуально легче. Обе позиции выдержали проверку временем, и именно поэтому полезно знать оба подхода, а не считать один «правильным»: переходя между диалектами семьи Lisp, вы будете встречать то одно решение, то другое.
Как это работает под капотом
Механика на удивление проста, если вспомнить устройство символа. В Common Lisp символ хранит несколько независимых ячеек, и две из них — это ячейка значения (value cell) и ячейка функции (function cell). Когда вычислитель видит список, он берёт первый элемент и ищет его определение в функциональной ячейке символа; когда видит символ в позиции аргумента — берёт из ячейки значения. #'имя — это явный доступ к функциональной ячейке, а funcall — приказ «вызови вот это значение как функцию». В Scheme же у имени одна-единственная привязка, поэтому все эти различия исчезают, и механизм проще.
Полезно осознать, что Lisp-2 — это не недостаток реализации, а сознательный выбор семантики, имеющий и техническую цену, и выгоду. Цена — лишние #' и funcall при работе с функциями как с данными. Выгода — свобода именования и историческая совместимость со множеством диалектов, которые объединял Common Lisp. Зная, что за #' и funcall стоит именно раздельность ячеек символа, вы будете применять их машинально и без раздражения, понимая их необходимость.
Частые ошибки
Первая ошибка, особенно болезненная при переходе из Scheme или других языков, — вызвать функцию-значение напрямую: написать (f x), когда f — переменная с функцией. В Common Lisp это ищет функцию с именем f и падает; нужен (funcall f x). Это, пожалуй, ошибка номер один у новичков в Common Lisp с опытом других языков.
Вторая ошибка — забыть #' при передаче функции: написать (mapcar + ...) вместо (mapcar #'+ ...). Без #' Common Lisp ищет переменную +, а не функцию, и выдаёт ошибку «переменная не определена». Правило простое: передаёшь именованную функцию как аргумент — ставь #'.
Третья ошибка — путать funcall и apply. funcall хочет аргументы россыпью, apply — последний аргумент списком. Если передать funcall список целиком ((funcall #'+ '(1 2 3))), вы попытаетесь сложить один аргумент-список, что приведёт к ошибке типа. Когда аргументы лежат в списке — нужен apply; когда по отдельности — funcall.
Итоги
- Common Lisp — Lisp-2: раздельные пространства имён функций и переменных, поэтому одно имя может быть и функцией, и переменной (различаются по позиции).
#'имяберёт функцию по имени как значение (доступ к функциональной ячейке);funcallвызывает функцию-значение из переменной.applyвызывает функцию, раскрывая последний аргумент-список в отдельные аргументы; незаменим в паре с&rest.- Scheme — Lisp-1: единое пространство имён, функция — обычное значение, поэтому
#'иfuncallне нужны (ноapplyесть). - Lisp-2 против Lisp-1 — компромисс чистоты (Scheme) и практичности именования (Common Lisp); обе традиции живы, и переходя между ними, нужно помнить про
#'иfuncall.