Пакеты вглубь и форматирование (format)
Модульность через пакеты вглубь и язык форматирования: как организовать большой проект из пакетов и как format с директивами заменяет десяток функций печати.
Пакет — единица пространства имён символов; format — встроенный мини-язык вывода, где строка-шаблон с директивами
~описывает, как печатать аргументы: числа, списки, выравнивание, ветвления и циклы.
Зачем это: имена и вывод в реальном проекте
Два навыка отделяют учебный код от рабочего: умение организовать имена в большом проекте (чтобы они не сталкивались и образовывали ясный интерфейс) и умение форматировать вывод компактно и выразительно. Первое решают пакеты — мы видели их основы, теперь углубимся в зависимости, конфликты и стиль. Второе решает format — один из самых мощных (и поначалу пугающих) инструментов Common Lisp: целый язык вывода внутри строки. Оба — про «инженерную гигиену»: пакеты структурируют код, format структурирует его общение с миром.
Эти две темы объединены в один урок не случайно: обе про переход от «программы, которая работает» к «программе, которую можно поддерживать и развивать в команде». Маленький скрипт обходится без пакетов (всё в одном CL-USER) и без format (хватает простого print). Но как только проект растёт — появляются десятки файлов, чужие библиотеки, требования к читаемому выводу и логам, — оба инструмента становятся необходимыми. Пакеты не дают именам столкнуться и задают публичный контракт модуля; format превращает разрозненные print'ы в аккуратный, структурированный вывод одним выразительным шаблоном. Это «инфраструктурные» навыки: они не про алгоритмы, а про то, как код живёт в большом проекте и общается с людьми. Поэтому осваивать их стоит именно тогда, когда вы переходите от упражнений к настоящим программам.
Пакеты вглубь: use, import, конфликты
Реальный пакет редко изолирован — он использует другие. defpackage описывает все зависимости имён декларативно. Разберём опции, важные для больших проектов:
(defpackage :myapp.core
(:use :common-lisp) ; видеть символы стандарта CL
(:import-from :alexandria ; взять ОТДЕЛЬНЫЕ символы из чужого пакета
:flatten :curry)
(:export :run :config)) ; публичный интерфейс пакета
(defpackage :myapp.web
(:use :common-lisp :myapp.core) ; видеть и CL, и экспорт myapp.core
(:local-nicknames (:json :myapp.json)) ; локальное короткое имя для пакета
(:export :start-server))
Опции решают разные задачи. :use «вливает» все экспортированные символы пакета — удобно для базовых (как :common-lisp), но рискованно для многих сразу (конфликты имён). :import-from берёт именно нужные символы — аккуратнее, явно видно, что откуда. :local-nicknames даёт пакету короткий локальный псевдоним (json:parse вместо myapp.json:parse) без глобального засорения. Стиль зрелых проектов: :use только для :common-lisp, остальное — через :import-from или префиксы, чтобы зависимости были явными.
Конфликты символов и их разрешение
Если два используемых пакета экспортируют символ с одним именем, возникает конфликт: какой position вы имеете в виду? CL не выбирает молча — он сигналит ошибку при создании пакета, заставляя разрешить явно: либо :shadowing-import-from (выбрать конкретный символ, затенив остальные), либо :shadow (объявить свой одноимённый символ, перекрыв все внешние). Это сознательная строгость: лучше явный конфликт на этапе сборки, чем тихая путаница в рантайме.
;; разрешаем конфликт: берём position именно из sequence-utils
(defpackage :myapp.geo
(:use :common-lisp)
(:shadowing-import-from :sequence-utils :position))
Полезно различать модульность имён (пакеты) и модульность сборки (системы ASDF — следующий урок). Пакет говорит «как называются символы и что видно»; ASDF говорит «какие файлы в каком порядке грузить». Это ортогональные оси: один пакет может собираться из многих файлов, и наоборот. Распространённая практика — один пакет на «модуль» проекта, плюс пакет-фасад, реэкспортирующий публичный API.
Эта ортогональность поначалу сбивает с толку тех, кто привык к языкам, где «модуль = файл» (как в Python или JavaScript, где файл сам является пространством имён). В Common Lisp это не так: пакет и файл — независимые понятия. Вы можете определить один пакет и наполнять его символами из десятка разных файлов; или, наоборот, в одном файле объявить несколько пакетов. Разделение возникло исторически, но оказалось удачным: оно даёт свободу организовать имена по логике предметной области, а файлы — по удобству редактирования и сборки, не привязывая одно к другому. На практике большие проекты обычно заводят файл packages.lisp со всеми defpackage в начале (чтобы пакеты существовали до загрузки кода, который их использует), а затем раскладывают код по файлам как удобно. Понимание, что «пакет — про имена, файл — про текст и сборку» — ключ к правильной организации проекта на Lisp.
format: язык вывода в строке
Теперь — format. Его первый аргумент — назначение: t (в стандартный вывод), nil (вернуть строку), или поток. Второй — контрольная строка с директивами, начинающимися с ~. Остальные — аргументы для подстановки. Базовые директивы:
| Директива | Что делает |
~a | «aesthetic» — печатает аргумент человекочитаемо (без кавычек) |
~s | «standard» — печатает так, чтобы reader смог прочесть (с кавычками) |
~d | целое в десятичном виде |
~f | число с плавающей точкой |
~% | перевод строки (newline) |
~~ | литеральный знак ~ |
(format t "Имя: ~a, возраст: ~d~%" "Анна" 30)
;; печатает: Имя: Анна, возраст: 30
(format nil "~s" "текст") ; => "\"текст\"" (с кавычками, для reader)
(format nil "~a" "текст") ; => "текст" (без кавычек, для человека)
;; точность float и ширина поля:
(format t "Цена: ~,2f руб.~%" 19.5) ; => Цена: 19.50 руб.
Разница ~a и ~s важна: ~a — для людей (логи, UI), ~s — для машин (когда вывод должен быть снова читаем Lisp'ом, например при сериализации). Параметры перед буквой (~,2f — два знака после точки) настраивают директиву.
Мощные директивы: списки, ветвление, цикл
Настоящая сила format — в директивах, которые делают то, ради чего в других языках пишут циклы и условия. ~{...~} итерирует по списку-аргументу, применяя внутренний шаблон к каждому элементу. ~^ внутри неё пропускает остаток на последнем элементе (идеально для разделителей). ~[...~;...~] — выбор по индексу, ~:[ложь~;истина~] — булево ветвление. ~p — автоматическое множественное число.
;; ~{ ~} — обойти список; ~^ — запятая между, но не после последнего:
(format nil "~{~a~^, ~}" '("яблоко" "груша" "слива"))
;; => "яблоко, груша, слива"
;; ~:[ ~; ~] — булево ветвление по аргументу:
(format nil "Статус: ~:[выключено~;включено~]" t) ; => "Статус: включено"
;; ~p — множественное число (по числовому аргументу):
(format nil "~d файл~:p" 1) ; => "1 файл"
(format nil "~d файл~:p" 5) ; => "5 файлов" (англ. правило; для рус. сложнее)
;; всё вместе — таблица:
(format t "~:{~a: ~d~%~}"
'(("alpha" 1) ("beta" 2) ("gamma" 3)))
;; печатает три строки вида "alpha: 1"
За эти директивы format называют «языком в языке»: ~{~} — это цикл, ~[~] — это case, ~:[~;~] — это if, всё внутри строки. Освоив их, вы заменяете громоздкую сборку строк одним выразительным шаблоном. Это и удобно, и быстро, потому что format компилируется.
У мощи format есть и обратная сторона, о которой честно стоит сказать: сложные контрольные строки становятся нечитаемыми. Строка с пятью вложенными директивами, модификаторами и параметрами выглядит как шифр, и через месяц её не разберёт даже автор. Поэтому действует та же дисциплина, что и с любым мощным инструментом: используйте выразительные возможности format там, где они дают явный выигрыш (обход списка с разделителями через ~{~^~}, простое ветвление через ~:[~;~], выравнивание чисел), но не превращайте контрольную строку в монстра ради экономии пары строк кода. Если форматирование становится по-настоящему сложным, разбейте его на несколько format-вызовов или соберите результат обычным кодом — читаемость важнее «однострочника». Хороший ориентир: если, глядя на контрольную строку, вы не можете быстро сказать, что она напечатает, — она слишком сложна. format — превосходный слуга, но плохой господин: пусть он упрощает типовой вывод, а не становится отдельным ребусом в вашем коде.
Как работает под капотом
Контрольная строка format — это, по сути, программа на встроенном языке. Реализация её разбирает (директивы ~ с параметрами) и исполняет, потребляя аргументы по мере встречи директив. Многие компиляторы (включая SBCL) при константной контрольной строке компилируют её заранее в эффективный код вывода — поэтому format не платит разбором при каждом вызове. Директивы вроде ~{~} реализованы как настоящие управляющие конструкции: ~{ запускает итерацию по списку-аргументу, повторяя подшаблон до исчерпания; ~^ проверяет, остались ли элементы, и при их отсутствии прерывает повтор. То есть format — это интерпретатор (часто компилируемый) маленького языка вывода, а контрольная строка — его исходник. Пакеты же реализуются как таблицы символов с отношениями use/import/shadow; конфликт — это попытка сделать видимыми два разных символа под одним именем, и система требует явного разрешения, потому что иначе нарушится принцип «имя → один символ».
Частые ошибки
- Злоупотреблять
:use. Множественный:useведёт к конфликтам имён. Для чужих библиотек предпочитайте:import-fromили префиксы/локальные псевдонимы. - Путать
~aи~s.~a— для людей (без кавычек),~s— машинно-читаемо (с кавычками). Для логов обычно~a, для сериализации —~s. - Забыть
~%. Перевод строки вformat— это~%, а не символ новой строки в исходнике. Без него вывод «слипается». - Не закрывать
~{. Директивы-конструкции (~{~},~[~]) парные; забытая закрывающая~}— ошибка контрольной строки. - Игнорировать конфликт пакетов. CL сигналит ошибку при конфликте имён намеренно — разрешите его
:shadowing-import-from/:shadow, а не обходите.
Итоги
- Пакеты организуют имена:
:useвливает весь экспорт,:import-from— отдельные символы,:local-nicknames— короткие псевдонимы. - Конфликты имён CL сигналит явно; разрешайте через
:shadowing-import-fromили:shadow. Стиль::useтолько для:common-lisp. - Модульность имён (пакеты) ортогональна модульности сборки (ASDF).
format— мини-язык вывода:~a/~s(человеку/машине),~d/~f,~%, параметры (~,2f).- Директивы-конструкции:
~{~}— цикл по списку,~^— разделители,~[~]/~:[~;~]— ветвление,~p— множественное число. - Под капотом контрольная строка — компилируемая программа; пакет — таблица символов с явным разрешением конфликтов.
Пакеты и format — это инфраструктура зрелого кода: первое наводит порядок в именах большого проекта, второе делает вывод аккуратным и выразительным. Оба окупаются ровно тогда, когда вы переходите от одиночных скриптов к настоящим программам, которые читают и поддерживают другие люди.