Символы, строки, t и nil

Разбираемся с тремя фундаментальными типами Lisp: символами-именами, строками-текстом и логическими значениями t и nil.

Символ в Lisp — это уникальный именованный объект, который может обозначать переменную, функцию или просто служить меткой. nil — особое значение, одновременно играющее роль пустого списка и логической лжи; всё остальное (включая t) считается истиной.

Мы уже не раз упоминали символы, строки, t и nil по ходу курса. Настало время разобрать их обстоятельно, потому что их природа в Lisp устроена не так, как в большинстве языков, и недопонимание здесь порождает тонкие, трудноуловимые ошибки. Особенно это касается nil — пожалуй, самого перегруженного смыслом объекта во всём Lisp.

Природа символов

Символ — это не строка и не просто имя; это полноценный объект-имя, живущий в памяти. Когда читатель встречает последовательность букв вроде balance, он создаёт (или находит уже существующий) символ с именем BALANCE. По умолчанию Common Lisp приводит имена символов к верхнему регистру при чтении, поэтому balance, Balance и BALANCE в исходнике обозначают один и тот же символ. Это историческая особенность, о которой важно помнить.

Ключевое свойство символов — интернирование. Все символы с одинаковым именем — это буквально один и тот же объект в памяти. Когда вы дважды пишете foo, оба раза вы ссылаетесь на единственный экземпляр символа FOO. Благодаря этому сравнение символов на тождество через eq мгновенно: достаточно сравнить указатели, а не перебирать буквы. Именно поэтому символы — идеальный материал для ключей, меток и перечислений, о чём мы говорили в уроке про кавычку.

У символа есть несколько «ячеек», которые он может хранить: значение (если символ используется как переменная), определение функции (если как функция) и список свойств. Эта многоликость символа — прямое следствие того, что Common Lisp является Lisp-2: один символ list может одновременно быть и функцией, и переменной, потому что у него раздельные ячейки под то и другое.

;; Символы интернируются: одинаковые имена — один объект
(eq 'foo 'foo)         ; => T   (это буквально один и тот же символ)

;; Имена приводятся к верхнему регистру при чтении:
(eq 'balance 'BALANCE) ; => T   (один символ BALANCE)

;; Символ можно получить из строки и обратно:
(symbol-name 'hello)   ; => "HELLO"   (имя символа как строка)
(intern "WORLD")       ; => WORLD     (символ из строки)

Строки: последовательности литер

Строка — это совсем другое: упорядоченная последовательность символов-литер (знаков), то есть текст. Строки записываются в двойных кавычках: "привет". В отличие от символов, строки не интернируются: две одинаковые с виду строки могут быть разными объектами. Поэтому сравнивать строки нужно не через eq (который сравнивает тождество объектов), а через string= (который сравнивает содержимое посимвольно) или общий equal.

;; Строки сравнивают по содержимому, НЕ через eq:
(string= "hello" "hello")   ; => T    (содержимое совпадает)
(equal  "hello" "hello")    ; => T    (тоже сравнивает содержимое)

;; Операции со строками:
(length "привет")                  ; => 6
(concatenate 'string "abc" "def")  ; => "abcdef"
(subseq "программирование" 0 7)    ; => "програм"
(char "lisp" 0)                    ; => #\l   (символ-литера по индексу)
(string-upcase "lisp")             ; => "LISP"

Обратите внимание на разницу терминов: в русском слово «символ» перегружено. В Lisp есть symbol (символ-имя, объект) и character (символ-литера, знак вроде #\l). Строка состоит из character'ов, а не из symbol'ов. Знаки-литеры записываются через #\: #\a, #\space, #\newline. Эту терминологическую путаницу важно держать в голове, читая документацию.

Ключевые символы: самоцитирующиеся метки

Особая и очень практичная разновидность символов — ключевые символы (keywords). Они записываются с двоеточием впереди: :red, :name, :if-exists. Главное их удобство в том, что они вычисляются сами в себя — их не нужно цитировать. Если обычный символ red вычислитель попытается воспринять как переменную, то :red всегда даёт сам себя, как число. Поэтому ключевые символы — идеальные неизменные метки, и именно их используют в Lisp для именованных параметров функций (мы подробно разберём это в разделе про функции) и для ключей в структурах данных.

;; Ключевые символы вычисляются сами в себя — кавычка не нужна:
:red               ; => :RED   (а обычный red потребовал бы переменную)
(eq :red :red)     ; => T      (тоже интернированы, мгновенное сравнение)

;; Их естественно применять как имена-метки и ключи:
(defparameter *config* (list :host "localhost" :port 8080))
(getf *config* :port)   ; => 8080   (getf ищет по ключу-метке)

Технически ключевой символ — это обычный символ, живущий в специальном пакете KEYWORD, и потому всегда самоопределённый. Различие «обычный символ — ключевой символ» поначалу кажется мелочью, но оно пронизывает весь практический Lisp: именованные аргументы, опции, поля записей — всё это держится на ключевых символах. Усвоив, что :foo — это самоцитирующаяся метка, а foo — имя, требующее контекста, вы избавитесь от целого класса недоумений при чтении чужого кода. Заметьте и приятное удобство: поскольку ключевые символы не нужно цитировать, вызовы с именованными параметрами читаются как естественный набор пар «метка — значение», без визуального шума из апострофов, и потому в идиоматическом Lisp ключевые символы встречаются буквально на каждом шагу.

nil: ложь, пустой список и многое другое

Теперь самый интересный объект — nil. В Lisp он несёт сразу несколько ролей, и это сознательное решение, упрощающее язык. Во-первых, nil — это пустой список: () и nil — одно и то же. Во-вторых, nil — это логическая ложь: единственное значение, которое условные конструкции считают «нет». В-третьих, nil — это ещё и символ (его имя — NIL). Такое слияние «пустоты», «лжи» и «конца списка» в одном объекте делает многие конструкции на удивление лаконичными.

Зеркальный объект — t, канонический представитель истины. Но ключевое правило шире: истиной считается всё, что не nil. Число 0 — истина (в отличие от многих языков!). Пустая строка "" — истина. Любой непустой список — истина. Ложь ровно одна — nil. Это правило «всё, кроме nil, истинно» нужно усвоить накрепко, потому что оно отличается от привычек C-подобных языков, где ноль или пустота часто означают ложь.

;; nil — это и пустой список, и ложь:
(eq nil '())          ; => T     (пустой список и есть nil)
(null nil)            ; => T     (null проверяет на nil/пустой список)
(if nil "да" "нет")   ; => "нет" (nil — ложь)

;; Истина — всё, что не nil (даже 0 и пустая строка!):
(if 0  "да" "нет")    ; => "да"  (ноль — это ИСТИНА в Lisp)
(if "" "да" "нет")    ; => "да"  (пустая строка — тоже истина)
(if t  "да" "нет")    ; => "да"

Двойная природа nil как «конца списка» и «ложь» особенно изящно проявляется при обходе списков. Функция, ищущая элемент, может вернуть nil, и это одновременно означает и «не нашлось», и «пустой результат», и «ложь» для условия — всё в одном. Многие лаконичные идиомы Lisp держатся именно на этом совпадении ролей.

Контраст со Scheme

Здесь Common Lisp и Scheme расходятся, и расхождение поучительное. В Scheme логические значения отделены от пустого списка. Истина и ложь записываются как #t и #f, и ложью считается только #f — даже пустой список в Scheme истинен! Это следствие минималистичной философии Scheme: каждое понятие должно быть отдельным и не совмещать роли. Common Lisp, наоборот, прагматично слил «пусто», «конец списка» и «ложь» в nil ради лаконичности кода.

; Scheme (R7RS): логика отделена от списков
(if #f "да" "нет")   ; => "нет"   (#f — единственная ложь)
(if '() "да" "нет")  ; => "да"    (пустой список в Scheme — ИСТИНА!)
#t                   ; истина
#f                   ; ложь

Это различие — частая ловушка для тех, кто переходит между диалектами. Привычный лисповый приём «использовать пустой список как ложь» в Scheme не работает: там пустой список истинен, и проверять его нужно явной функцией null?. Помните: в Common Lisp ложь = nil = пустой список, а в Scheme ложь = только #f.

Как это работает под капотом

Интернирование символов реализовано через пакеты (packages) — таблицы имён. Каждый символ принадлежит пакету, и пакет хранит словарь «имя → символ». Когда читатель встречает foo, он ищет имя FOO в текущем пакете и либо находит существующий символ, либо создаёт новый и заносит в таблицу. Поэтому одинаковые имена в одном пакете гарантированно дают один объект — это и есть механизм интернирования. Пакеты заодно решают проблему конфликта имён в больших программах, но это тема для более продвинутого изучения.

Что касается nil, то на уровне реализации это особый предопределённый объект, на который system ссылается напрямую, и проверка «является ли значение nil» сводится к сравнению с этим единственным объектом — операция мгновенная. Совмещение ролей не требует никаких затрат: «пустой список», «ложь» и «символ NIL» — это буквально один и тот же объект в памяти, поэтому переключение между его ролями ничего не стоит. Эта экономия — одна из причин, почему базовые операции Lisp такие быстрые.

Частые ошибки

Первая ошибка — сравнивать строки через eq. Поскольку строки не интернируются, (eq "abc" "abc") может вернуть nil (две разные строки с одинаковым содержимым). Для строк нужно string= или equal. А вот символы через eq сравнивать правильно и быстро — в этом и разница их природы.

Вторая ошибка — считать 0 или пустую строку ложью по привычке из C, Python или JavaScript. В Common Lisp ложь ровно одна — nil; ноль, пустая строка и пустой вектор истинны. Если написать (if x ...), рассчитывая, что x = 0 сработает как ложь, логика сломается — ноль пройдёт как истина.

Третья ошибка — переносить лисповую идиому «пустой список как ложь» в Scheme. В Common Lisp (if lst ...) естественно проверяет список на непустоту, потому что пустой список — это nil-ложь. В Scheme тот же код всегда пойдёт по ветке «истина», потому что пустой список там истинен; проверять нужно (if (null? lst) ...). Эта разница диалектов — классический источник багов при переходе.

Итоги

  • Символ — это уникальный интернированный объект-имя (сравнивается через eq мгновенно), а строка — последовательность литер-character'ов (сравнивается через string= или equal).
  • Имена символов по умолчанию приводятся к верхнему регистру; интернирование реализовано через пакеты.
  • nil совмещает три роли: пустой список (), логическая ложь и символ NIL — это один объект.
  • В Common Lisp истиной считается всё, что не nil, включая 0 и пустую строку; ложь ровно одна — nil.
  • В Scheme логика отделена от списков: ложь — только #f, а пустой список истинен; переносить лисповую идиому «пустой список как ложь» в Scheme нельзя.
Проверьте себя
1. Что считается логической ложью в Common Lisp?
Anil, 0 и пустая строка
BТолько nil (он же пустой список); всё остальное, включая 0 и пустую строку, — истина
CТолько число 0
Dnil и символ #f
2. Почему символы сравнивают через eq, а строки — нет?
Aeq работает только с числами
BСимволы интернируются (одинаковые имена — один объект), поэтому eq мгновенно сравнивает их по тождеству; строки не интернируются, и их сравнивают по содержимому через string=
CСтроки в Lisp вообще нельзя сравнивать
Deq сравнивает строки медленнее, но точнее
3. Чем отличается обработка пустого списка в условии между Common Lisp и Scheme?
AВ обоих языках пустой список — это ложь
BВ Common Lisp пустой список это nil и считается ложью, а в Scheme пустой список истинен (ложь только #f)
CВ обоих языках пустой список истинен
DScheme вообще не поддерживает пустой список