Символы и пакеты: пространства имён 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-символы не интернированы и потому безопасны для макросов.
Проверьте себя
1. Почему (eq 'foo 'foo) возвращает T, а сравнение двух строк обычно нет?
AСимволы всегда быстрее строк
BОдноимённые символы интернированы в один объект, поэтому eq (сравнение идентичности) истинно; строки — разные объекты
Ceq для символов сравнивает посимвольно
D'foo на самом деле строка
2. Что означает, что Common Lisp — это «Lisp-2»?
AВ нём ровно две встроенные функции
BУ символа раздельные ячейки для значения-переменной и для функции, поэтому имя может быть и переменной, и функцией одновременно
CОн работает только в двух пакетах
DОн поддерживает максимум два аргумента