Параметры функций: optional, rest, key, aux
Изучаем одну из самых развитых систем параметров среди всех языков — лямбда-список Common Lisp.
Лямбда-список — это список параметров функции, который в Common Lisp умеет описывать обязательные, необязательные (
&optional), переменные по числу (&rest), именованные (&key) и вспомогательные (&aux) параметры, в том числе со значениями по умолчанию.
Во многих языках, чтобы сделать аргумент необязательным или принять переменное их число, приходится прибегать к перегрузкам, специальным массивам или ухищрениям. Common Lisp решает всё это единообразно прямо в списке параметров с помощью особых маркеров, начинающихся с амперсанда. Это делает сигнатуры функций гибкими и выразительными, а сам код — заметно короче и понятнее. Разберём каждый вид параметров по порядку, от простого к сложному.
Обязательные параметры
Начнём с базы. Обязательные параметры — это просто имена в начале лямбда-списка, как мы видели в прошлом уроке. Их нужно передать ровно столько, сколько указано, иначе будет ошибка. Это поведение по умолчанию, привычное по любому языку.
;; Обязательные параметры — просто имена:
(defun add (a b)
(+ a b))
(add 2 3) ; => 5
;; (add 2) ; ОШИБКА: недостаточно аргументов
;; (add 2 3 4) ; ОШИБКА: слишком много аргументов
Всё, что идёт дальше в лямбда-списке после специальных маркеров, надстраивается над этой основой. Маркеры — &optional, &rest, &key, &aux — это не параметры, а разделители, переключающие режим разбора последующих имён. Их легко узнать по амперсанду в начале.
&optional: необязательные параметры со значениями по умолчанию
Маркер &optional объявляет, что последующие параметры можно не передавать. Если аргумент не передан, параметр получает значение по умолчанию — nil, либо указанное явно. Значение по умолчанию задаётся в скобках рядом с именем: (параметр значение-по-умолчанию).
;; &optional делает параметры необязательными:
(defun greet (name &optional (greeting "Привет"))
(concatenate 'string greeting ", " name "!"))
(greet "Аня") ; => "Привет, Аня!" (greeting по умолчанию)
(greet "Иван" "Здравствуй") ; => "Здравствуй, Иван!"
;; Без явного значения по умолчанию параметр получает nil:
(defun power (base &optional exponent)
(if exponent
(expt base exponent)
(* base base))) ; если exponent не задан — квадрат
(power 3) ; => 9 (exponent = nil)
(power 3 4) ; => 81
Значения по умолчанию вычисляются в момент вызова и могут зависеть от предыдущих параметров — это мощнее, чем простые константы в других языках. &optional идеален, когда у функции есть «обычный» режим и редкие вариации: основное передают всегда, а детали — по необходимости.
Подчеркнём важную деталь: выражение значения по умолчанию — это полноценный код, вычисляемый при каждом вызове, когда аргумент не передан, и в нём можно ссылаться на параметры, объявленные левее. Например, функция рисования прямоугольника может по умолчанию делать высоту равной ширине: (defun rect (width &optional (height width)) ...) — тогда при вызове с одним аргументом получится квадрат. Такая зависимость значений по умолчанию друг от друга невозможна в языках, где умолчания обязаны быть константами времени компиляции. В Lisp же умолчание — обычное выражение, и это снимает массу частных случаев: вместо отдельной функции «квадрат» достаточно удачного значения по умолчанию у функции «прямоугольник». Эта мелочь хорошо показывает общий принцип: Lisp предпочитает один гибкий механизм множеству жёстких.
&rest: переменное число аргументов
Когда функция должна принимать сколько угодно аргументов, используют &rest. Параметр после него собирает все оставшиеся аргументы в список. Так устроены, например, сами + и list — они принимают любое число аргументов именно через &rest.
;; &rest собирает все оставшиеся аргументы в список:
(defun my-sum (&rest numbers)
(apply #'+ numbers)) ; apply раскрывает список как аргументы +
(my-sum 1 2 3) ; => 6
(my-sum 1 2 3 4 5 6) ; => 21
(my-sum) ; => 0 (пустой список)
;; Обязательные и &rest вместе:
(defun describe-person (name &rest hobbies)
(list :name name :hobbies hobbies))
(describe-person "Аня" 'чтение 'бег)
;; => (:NAME "Аня" :HOBBIES (ЧТЕНИЕ БЕГ))
Внутри функции &rest-параметр — это обычный список, который можно обходить, передавать дальше, обрабатывать. Часто его комбинируют с apply (мы встретим её отдельно), чтобы «развернуть» собранные аргументы обратно при вызове другой функции. &rest — основной механизм вариативных функций в Lisp.
&key: именованные параметры
Самый, пожалуй, удобный для читаемости вид — &key. Он позволяет передавать аргументы по имени, в любом порядке, с помощью ключевых символов (тех самых :имя из раздела про символы). Это незаменимо, когда параметров много и запоминать их порядок неудобно. Именованные параметры тоже могут иметь значения по умолчанию.
;; &key — параметры по имени, в любом порядке:
(defun make-rect (&key (width 1) (height 1) (filled nil))
(list :w width :h height :filled filled))
(make-rect :width 10 :height 5) ; => (:W 10 :H 5 :FILLED NIL)
(make-rect :height 3 :width 4) ; => (:W 4 :H 3 :FILLED NIL) порядок любой!
(make-rect :filled t) ; => (:W 1 :H 1 :FILLED T) остальное по умолчанию
(make-rect) ; => (:W 1 :H 1 :FILLED NIL) всё по умолчанию
Именованные параметры делают вызовы самодокументируемыми: глядя на (make-rect :width 10 :height 5), сразу понятно, что есть что, без заглядывания в определение. Многие стандартные функции Lisp используют &key для опций — например, у функций сортировки и поиска через ключевые параметры задают, как сравнивать элементы. Это объясняет, почему ключевые символы так пронизывают язык.
Признак «передан ли аргумент»: supplied-p
С необязательными и именованными параметрами связана одна тонкость. Что, если значение по умолчанию — это nil, и при этом для функции важно различать «аргумент не передали» и «передали явный nil»? По значению параметра это не отличить: в обоих случаях там nil. Common Lisp решает это элегантно: для &optional и &key можно завести третий элемент — переменную-признак (supplied-p variable), которая получает t, если аргумент был передан, и nil, если использовано значение по умолчанию.
;; Третий элемент — переменная-признак "был ли передан аргумент":
(defun connect (host &optional (port 80 port-supplied-p))
(if port-supplied-p
(format nil "~a:~a (порт задан явно)" host port)
(format nil "~a:~a (порт по умолчанию)" host port)))
(connect "example.com") ; => "example.com:80 (порт по умолчанию)"
(connect "example.com" 80) ; => "example.com:80 (порт задан явно)"
;; различили, хотя порт в обоих случаях 80!
Это нужно реже, чем основные параметры, но в правильно спроектированных библиотеках встречается постоянно: например, функция может вести себя по-разному, когда опцию вообще не упомянули и когда её явно выставили в «выключено». Возможность различить эти случаи — признак продуманного дизайна сигнатуры, и Common Lisp даёт её «из коробки», тогда как во многих языках приходится изобретать специальные «маркеры отсутствия».
Почему такая система лучше перегрузок
Стоит остановиться на том, почему Common Lisp решает задачу гибких аргументов именно лямбда-списком, а не перегрузкой функций, как C++ или Java. В языках с перегрузкой, чтобы функция принимала разные наборы аргументов, пишут несколько отдельных её версий с одинаковым именем, и компилятор выбирает подходящую по типам и числу аргументов. Это работает, но порождает дублирование: общую логику приходится либо копировать, либо выносить во вспомогательную функцию, и число версий растёт комбинаторно с числом необязательных параметров.
Лямбда-список устроен иначе: это одна функция с одним телом, которая описывает всё разнообразие вызовов декларативно, в одном месте. Не нужно ни дублировать логику, ни плодить версии — необязательность, переменное число и именованность выражаются маркерами прямо в сигнатуре. Вдобавок Common Lisp — динамически типизированный язык, и перегрузка по типам ему чужда в принципе; зато гибкость по числу и способу передачи аргументов он доводит до совершенства. Это ещё один пример общей черты Lisp: вместо множества частных механизмов — один общий и выразительный.
&aux: вспомогательные локальные переменные
Последний и наименее используемый маркер — &aux. Строго говоря, это не параметры, а способ объявить локальные переменные прямо в лямбда-списке, с начальными значениями. По сути это сокращённая запись для let (который мы детально разберём в следующем разделе) внутри тела функции. Многие считают &aux устаревшим и предпочитают явный let ради ясности, но знать о нём полезно для чтения чужого кода.
;; &aux объявляет локальные переменные (редко используется):
(defun circle-area (radius &aux (pi-val 3.14159) (r2 (* radius radius)))
(* pi-val r2))
(circle-area 5) ; => 78.53975
;; То же яснее выражается через let в теле:
(defun circle-area-2 (radius)
(let ((pi-val 3.14159)
(r2 (* radius radius)))
(* pi-val r2)))
Как это работает под капотом
При вызове функции механизм связывания аргументов разбирает лямбда-список слева направо по чёткому алгоритму. Сначала по позиции связываются обязательные параметры. Затем, если есть &optional, по позиции связываются необязательные, а недостающим присваиваются значения по умолчанию. &rest-параметр получает список всех ещё не разобранных аргументов. Наконец, &key-параметры выбираются из оставшихся аргументов по ключам-меткам, а не по позиции. Этот порядок фиксирован стандартом, и понимание его помогает предсказывать, как именно распределятся аргументы.
Полезно знать, что &rest и &key можно использовать вместе: тогда &rest-параметр получит весь «хвост» аргументов как список, а &key вдобавок разберёт из этого же хвоста именованные пары. Реализация при этом, по сути, сканирует хвост в поисках ключевых символов и берёт следующее за каждым значение. Эта гибкость лямбда-списка — одна из причин, почему сигнатуры функций в Common Lisp такие выразительные: всё разнообразие способов передачи аргументов описывается единообразно в одном месте, без перегрузок и обёрток.
Частые ошибки
Первая ошибка — перепутать порядок маркеров. В лямбда-списке маркеры должны идти в строгом порядке: сначала обязательные, потом &optional, потом &rest, потом &key, потом &aux. Нарушение порядка — ошибка. Запомнить легко: от самого жёсткого (обязательные) к самому гибкому (именованные) и служебному (&aux).
Вторая ошибка — забыть двоеточие при вызове &key-параметров. Вызывать нужно с ключевыми символами: (make-rect :width 10), а не (make-rect width 10). Без двоеточия width воспримется как обычный символ-значение, а не как имя параметра, и связывание не сработает.
Третья ошибка — смешивать &optional и &key бездумно. Технически их можно комбинировать, но это сбивает с толку: позиционные необязательные и именованные параметры в одной сигнатуре делают вызовы неоднозначными для читателя. Хороший стиль — выбрать один подход: либо несколько &optional для коротких функций, либо &key для функций с многими опциями, но не мешать их без веской причины.
Итоги
- Лямбда-список Common Lisp описывает обязательные параметры, а затем через маркеры — гибкие виды параметров.
&optionalделает параметры необязательными со значениями по умолчанию (которые могут зависеть от предыдущих параметров).&restсобирает любое число оставшихся аргументов в список — основа вариативных функций.&keyдаёт именованные параметры (через ключевые символы:имя), передаваемые в любом порядке и самодокументирующие вызов.- Маркеры идут в строгом порядке (обязательные →
&optional→&rest→&key→&aux);&auxобъявляет локальные переменные и обычно заменяется явнымlet.