Символы и пакеты: пространства имён Lisp
Символы как фундаментальный тип Lisp и пакеты как пространства имён: интернирование, ключевые слова и почему символ — это не строка.
Символ — это именованный объект Lisp с уникальной идентичностью внутри пакета; он несёт имя, может хранить значение, функцию и список свойств, и сравнивается за константное время.
Зачем это: символ — это не строка
В Lisp символ — отдельный, первоклассный тип данных, и это глубоко отличает Lisp от языков, где «имена» существуют только в исходнике. Когда вы пишете foo, это литерал символа FOO — настоящий объект в памяти. У символа есть имя (строка), но он не равен строке: два символа с одинаковым именем — это, как правило, один и тот же объект, поэтому их можно сравнивать мгновенно через eq. Это свойство — основа скорости Lisp: сравнение имён, поиск в таблицах, диспетчеризация — всё работает на сравнении указателей символов, а не посимвольном сравнении строк. Понимание символов открывает, как устроены переменные, функции, ключевые слова и пакеты.
Чтобы оценить, насколько это необычно, сравните с мейнстримом. В Python или Java идентификатор переменной — это часть синтаксиса, он исчезает после компиляции; вы не можете взять имя x и передать его как значение, спросить его текст, сравнить с другим именем. В Lisp символ — полноценное значение: его можно положить в список, передать в функцию, сравнить, хранить в хеш-таблице как ключ, прикрепить к нему свойства. Именно поэтому код в Lisp может работать с именами программ как с данными — это та же гомоиконность, что лежит в основе макросов. Символ — мостик между «текстом программы» и «значениями, которыми программа оперирует». Когда макрос получает форму (+ a b), элементы +, a, b — это символы-значения, и он работает с ними как с обычными данными. Так что понимание символов — это не отдельная тема, а ключ ко всей «магии» Lisp, которую вы уже видели в разделе про макросы.
Интернирование: почему 'foo всегда тот же символ
Ключевой механизм — интернирование. Когда reader встречает имя foo, он ищет символ с таким именем в текущем пакете; если находит — возвращает существующий, если нет — создаёт и запоминает. Поэтому каждое упоминание foo в одном пакете даёт тот же самый объект-символ. Это и делает eq применимым к символам.
(eq 'foo 'foo) ; => T — это один и тот же интернированный символ
(eq "foo" "foo") ; => NIL (обычно) — две разные строки
(symbol-name 'hello) ; => "HELLO" — имя символа как строка
(symbolp 'hello) ; => T
(symbol-name 'hello) ; обратите внимание: ИМЯ В ВЕРХНЕМ РЕГИСТРЕ
Заметьте: (symbol-name 'hello) вернёт "HELLO" в верхнем регистре. По умолчанию reader Common Lisp приводит регистр к верхнему при чтении символов. Поэтому hello, Hello и HELLO в исходнике обозначают один символ HELLO. Если нужно сохранить регистр или включить спецсимволы в имя, символ экранируют вертикальными чертами: |My Symbol| — символ с именем ровно "My Symbol" (с пробелом и регистром). Это объясняет, почему Lisp «нечувствителен к регистру» для обычного кода, но при этом точен, когда нужно.
Анатомия символа: пять ячеек
Символ — богатый объект. Концептуально у него несколько «ячеек», независимых друг от друга:
| Ячейка | Что хранит | Доступ |
| name | имя (строка) | symbol-name |
| value | значение как переменной | symbol-value |
| function | функция/макрос | symbol-function |
| plist | список свойств | symbol-plist |
| package | домашний пакет | symbol-package |
Критически важна раздельность ячейки значения и ячейки функции. В Common Lisp символ list может одновременно быть именем функции ((list 1 2)) и именем переменной ((let ((list '(a b))) ...)) — это не конфликт, потому что значение и функция живут в разных ячейках. Такой язык называют «Lisp-2» (два пространства имён). Scheme, наоборот, «Lisp-1»: одно пространство, имя означает ровно одну вещь. Из-за этого в CL для ссылки на функцию как на значение пишут #'name (читается «функция name»), а вызвать функцию из переменной можно через funcall/apply. Это фундаментальное архитектурное различие диалектов, влияющее на стиль кода.
Ключевые слова: символы-самооценки
Особый вид символов — ключевые слова (keywords), пишутся с двоеточием: :name, :red, :if-exists. Они живут в специальном пакете KEYWORD и обладают двумя удобными свойствами: во-первых, ключевое слово вычисляется само в себя (:red — это значение :red, цитировать не надо), во-вторых, они константны. Поэтому keywords — идеальные «метки», «теги» и имена именованных аргументов: они уникальны, дёшевы для сравнения и не требуют кавычек.
:red ; => :RED (вычисляется само в себя)
(eq :red :red) ; => T
(keywordp :red) ; => T
;; типичное применение — именованные аргументы и метки состояний:
(defun make-pen (&key (color :black) (width 1))
(list :color color :width width))
(make-pen :color :red :width 3) ; => (:COLOR :RED :WIDTH 3)
Пакеты: пространства имён символов
Когда программа растёт, имена начинают конфликтовать: ваш position и чужой position. Для изоляции имён в Common Lisp есть пакеты (packages) — пространства имён для символов. Каждый символ «принадлежит» домашнему пакету; одинаковые имена в разных пакетах — это разные символы. Пакет решает, какие символы видны (экспортированы) наружу, а какие внутренние.
;; объявляем пакет, что он использует и что экспортирует
(defpackage :geometry
(:use :common-lisp) ; видеть стандартные символы CL
(:export :area :perimeter)) ; наружу — только эти
(in-package :geometry) ; дальше работаем внутри geometry
(defun area (r) (* pi r r)) ; geometry:area — экспортирован
(defun helper (x) (* x 2)) ; geometry::helper — внутренний
Доступ к символу другого пакета: package:symbol — к экспортированному (одно двоеточие), package::symbol — к любому, в том числе внутреннему (два двоеточия, «лезу во внутренности»). Два двоеточия — сигнал «я обхожу инкапсуляцию», его пишут осознанно. Пакеты — это модульность уровня имён: они не про загрузку файлов (этим занимается ASDF, отдельный урок), а именно про то, какие символы как называются и видны.
Новичков часто путает одна вещь: пакеты в Common Lisp — это пространства имён символов, а не «модули» в смысле инкапсуляции данных или namespace классов, как в других языках. Пакет не скрывает значения и не ограничивает доступ к функциям в смысле прав — он лишь управляет тем, под каким именем символ виден и нужно ли писать префикс. Экспортированный символ доступен через одно двоеточие, неэкспортированный — через два, но «спрятать» его полностью нельзя: граница пакета — это соглашение о видимости имён, а не жёсткая стена приватности. Это сознательный выбор в духе Lisp: язык доверяет программисту и не строит непробиваемых барьеров, но даёт ясные сигналы (:: кричит «ты лезешь во внутренности»). Понимание, что пакет — про имена, а не про сокрытие, избавляет от ложных ожиданий «приватных полей» и помогает проектировать чистые интерфейсы через продуманный :export.
На практике именование пакетов в крупных проектах подчиняется конвенциям. Часто берут «обратно-доменный» или иерархический стиль с точками: myapp.core, myapp.web, myapp.db — это читается как структура проекта и снижает риск столкновений с чужими пакетами. Ключевые слова кладут в общий пакет KEYWORD намеренно: метки вроде :red или :if-exists должны быть одинаковы во всём образе, к какому бы пакету ни относился код, — поэтому keyword-пакет один на всех. Эти детали кажутся бюрократией, пока проект маленький, но именно они позволяют десяткам библиотек сосуществовать в одном Lisp-образе без конфликтов имён — что и делает экосистему Quicklisp (последний раздел) работоспособной.
Как работает под капотом
Пакет — это, по сути, хеш-таблица «строка-имя → символ» плюс списки экспорта и используемых пакетов. Интернирование символа x в пакете P — это поиск строки "X" в таблице P: нашли — вернули существующий символ, нет — создали новый и положили. Поэтому eq на символах работает: после интернирования любые упоминания имени дают физически один объект. (:use :common-lisp) означает, что таблица вашего пакета «наследует» видимость символов из пакета COMMON-LISP — поэтому вы пишете defun, а не cl:defun. Ключевые слова интернируются в пакете KEYWORD, где у них особый флаг «константа, значение = сам символ». Безымянные символы (gensym из урока про макросы) не интернированы ни в одном пакете — поэтому их нельзя ввести чтением и невозможно случайно столкнуться с ними.
Частые ошибки
- Считать символ строкой. Символ — отдельный тип.
(eq 'foo "foo")ложно. Для имени символа беритеsymbol-name, для символа из строки —intern. - Забыть про приведение регистра.
'helloи'HELLO— один символHELLO; reader поднимает регистр. Это удивляет при сравнении с внешними строками. - Путать одно и два двоеточия.
pkg:sym— экспортированный символ;pkg::sym— любой, включая внутренний (нарушение инкапсуляции). - Цитировать ключевые слова.
:redи так вычисляется в себя;':redизбыточно (хотя и работает). - Смешивать Lisp-2 и Lisp-1 интуиции. В CL значение и функция символа — разные ячейки; чтобы взять функцию как значение, нужен
#', а вызвать значение-функцию —funcall.
Итоги
- Символ — первоклассный тип с идентичностью; одноимённые символы в пакете — один объект, сравнимый через
eq. - Интернирование обеспечивает уникальность; reader по умолчанию приводит имена к верхнему регистру.
- У символа раздельные ячейки значения и функции — Common Lisp это «Lisp-2» (Scheme — «Lisp-1»).
- Ключевые слова
:nameвычисляются сами в себя, константны и идеальны как метки и именованные аргументы. - Пакеты — пространства имён символов;
:exportзадаёт публичный интерфейс,::обходит инкапсуляцию. - Под капотом пакет — таблица «имя → символ»;
gensym-символы не интернированы и потому безопасны для макросов.