Пакеты вглубь и форматирование (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 — это инфраструктура зрелого кода: первое наводит порядок в именах большого проекта, второе делает вывод аккуратным и выразительным. Оба окупаются ровно тогда, когда вы переходите от одиночных скриптов к настоящим программам, которые читают и поддерживают другие люди.

Проверьте себя
1. В чём разница директив ~a и ~s в format?
AОни идентичны
B~a печатает человекочитаемо (без кавычек), ~s печатает так, чтобы вывод можно было снова прочитать reader'ом (с кавычками)
C~a для чисел, ~s для строк
D~s печатает без кавычек, ~a с кавычками
2. Что делает директива ~{...~} в format?
AПечатает фигурные скобки
BИтерирует по списку-аргументу, применяя внутренний шаблон к каждому элементу (цикл внутри контрольной строки)
CВыбирает один из вариантов по индексу
DВставляет перевод строки