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.
Проверьте себя
1. Зачем в Common Lisp нужны #' и funcall, а в Scheme нет?
AДля ускорения вызовов функций
BCommon Lisp — Lisp-2 с раздельными пространствами имён: #' берёт функцию по имени как значение, а funcall вызывает функцию-значение из переменной. В Scheme (Lisp-1) пространство единое, поэтому эти мосты не нужны
CПотому что в Scheme нет функций высшего порядка
DПотому что #' и funcall — устаревший синтаксис
2. Чем funcall отличается от apply?
AНичем, это синонимы
Bfuncall принимает аргументы по отдельности, а apply берёт последний аргумент как список и раскрывает его в отдельные аргументы
Capply работает только в Scheme
Dfuncall не может вызывать функции из переменных
3. Почему в Common Lisp можно назвать локальную переменную list, не сломав функцию list, а в Scheme это проблематично?
AПотому что list — зарезервированное слово только в Scheme
BCommon Lisp (Lisp-2) хранит функцию и переменную в разных ячейках символа, поэтому переменная list и функция list сосуществуют; в Scheme (Lisp-1) одно пространство, и переменная list затенила бы функцию
CПотому что в Common Lisp нельзя создавать переменные
DПотому что Scheme вообще не имеет функции list