Символы, строки, 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 нельзя.