Цикл loop во всю мощь и обзор MOP
Цикл loop во всю мощь и обзор метаобъектного протокола (MOP): как один макрос заменяет десяток циклов и как CLOS позволяет менять самого себя.
loop — макрос-DSL для итерации: его «почти английский» синтаксис описывает источник данных, накопление и условия одним выражением. MOP (метаобъектный протокол) — это API, делающий саму объектную систему программируемой: классы и методы становятся объектами, чьё поведение можно менять.
Зачем это: вершина итерации и вершина абстракции
Этот урок про два «потолка» Common Lisp. Первый — loop, самый мощный итерационный инструмент языка: там, где do требует ручной возни с переменными, loop декларативно говорит «собери», «суммируй», «по каждому». Второй — MOP, который отвечает на вопрос «а можно ли менять сам CLOS?»: да, потому что классы и методы — это объекты, и их поведение определяется обобщёнными функциями, которые тоже можно специализировать. Вместе они показывают предельную выразительность Lisp: от удобного цикла до перепрограммирования объектной модели.
Эти две темы — любопытная пара противоположностей, и поставлены рядом они намеренно. loop — про удобство повседневности: это инструмент, которым вы будете пользоваться ежедневно, чтобы коротко выразить рутинные итерации и накопления. MOP — про предельную власть над языком: инструмент, которым большинство не воспользуется напрямую почти никогда, но который объясняет, почему CLOS такой гибкий, и лежит в основе библиотек, которыми пользуются все. Вместе они очерчивают диапазон Common Lisp: от приземлённого «собрать список квадратов одной строкой» до возвышенного «переопределить, как вообще работают объекты». Понимание обоих полюсов даёт полную картину языка — и практичного для ежедневной работы, и бездонного для тех, кто хочет копать глубже. Не пугайтесь MOP: цель этого урока — не научить вас писать метаклассы, а показать, что граница языка в Lisp подвижна, и объяснить, откуда берётся его легендарная расширяемость.
loop: основы декларативной итерации
loop бывает «простым» (тело повторяется до явного выхода) и «расширенным» (с ключевыми словами-фразами). Расширенный loop и есть его сила: он читается как описание задачи. Базовые фразы — источник (for ... in/from/across), накопление (collect, sum, count, maximize), условия (when, unless, while, until).
;; собрать квадраты чисел от 1 до 5
(loop for i from 1 to 5 collect (* i i)) ; => (1 4 9 16 25)
;; пройти по списку, суммировать чётные
(loop for x in '(1 2 3 4 5 6)
when (evenp x) sum x) ; => 12
;; пройти по вектору (across), найти максимум
(loop for v across #(3 7 2 9 4) maximize v) ; => 9
;; счёт с шагом и параллельные переменные
(loop for i from 0 below 10 by 2
for label in '(a b c d e)
collect (cons i label))
;; => ((0 . A) (2 . B) (4 . C) (6 . D) (8 . E))
Обратите внимание, насколько это короче эквивалента на do: loop сам заводит счётчик, аккумулятор и условие выхода. Несколько for-фраз идут параллельно и цикл останавливается, когда любой источник исчерпан (как i и label выше).
loop: накопление и завершение
Накопительные глаголы — сердце loop. collect строит список, append склеивает списки, sum/count/maximize/minimize агрегируют числа. Фразы into позволяют копить в именованную переменную (несколько аккумуляторов), а finally выполняет код в конце (например, вернуть составной результат). Есть и initially (код до цикла), и thereis/always/never (булевы свёртки).
;; несколько аккумуляторов через into + финальный результат
(loop for x in '(1 -2 3 -4 5)
when (plusp x) collect x into positives
when (minusp x) collect x into negatives
finally (return (list :pos positives :neg negatives)))
;; => (:POS (1 3 5) :NEG (-2 -4))
;; булева свёртка: есть ли элемент > 100?
(loop for x in '(5 50 500) thereis (> x 100)) ; => T
;; обход хеш-таблицы (как в уроке про хеш-таблицы)
(loop for k being the hash-keys of *table*
using (hash-value v)
collect (list k v))
Особая черта loop — он понимает разные источники единообразно: in (список), across (вектор/строка), from/to/by (числа), being the hash-keys (хеш-таблица), on (по хвостам списка). Один макрос покрывает почти все итеративные сценарии — поэтому в практическом коде loop вытесняет do и часто dolist/dotimes.
loop: критика и дисциплина
Стоит знать и спор вокруг loop: его синтаксис не похож на остальной Lisp (нет привычной вложенности скобок, есть «английские» ключевые слова), и пуристы (особенно из мира Scheme) считают это чужеродным. Практика, однако, на стороне loop: для типичных накоплений он несравнимо читаемее. Дисциплина: используйте loop для прямолинейной итерации/агрегации; если логика становится ветвистой и запутанной, разбейте её на функции или возьмите рекурсию/do. Существует и альтернатива — библиотека iterate с более «лисповым» синтаксисом, но loop стандартен и вездесущ.
Этот спор поучителен сам по себе как иллюстрация культурного раскола в мире Lisp. Аргумент критиков: loop вводит несколько десятков специальных ключевых слов и собственную грамматику, нарушая священный принцип «всё — единообразные s-выражения»; его нельзя разобрать и преобразовать обычными списочными функциями так же легко, как остальной код. Аргумент защитников: реальные накопления (собрать одно, просуммировать другое, посчитать третье, всё за один проход с фильтрами) на «чистых» конструкциях выходят многословными и менее ясными, чем декларативный loop, а читаемость кода важнее синтаксической чистоты. Истина, как обычно, контекстна: для простых и для очень сложных случаев лучше другие инструменты (dolist/mapcar для простого, разбиение на функции для сложного), а loop блистает в «среднем» диапазоне многокомпонентных накоплений. Полезный вывод не «кто прав», а умение выбирать инструмент под задачу, не догматизируя ни чистоту, ни удобство, — это и есть зрелость инженера.
MOP: классы и методы как объекты
Теперь — обзор метаобъектного протокола. Мы уже отмечали: класс CLOS — это объект (экземпляр метакласса standard-class), обобщённая функция и метод — тоже объекты. MOP делает это явным и программируемым: поведение CLOS определяется обобщёнными функциями над метаобъектами, которые можно специализировать. Иначе говоря, CLOS реализован сам в себе и открыт для расширения — редчайшее свойство среди объектных систем.
;; класс — это объект; его можно интроспектировать (часть MOP):
(class-name (find-class 'standard-object)) ; => STANDARD-OBJECT
(class-direct-superclasses (find-class 'integer))
;; метакласс задаёт, КАК устроены классы данного вида.
;; Свой метакласс позволяет менять поведение всех своих классов:
(defclass counted-class (standard-class) ()) ; новый вид классов
;; разрешаем counted-class быть метаклассом обычных классов
(defmethod sb-mop:validate-superclass ; SBCL-специфично
((class counted-class) (super standard-class))
t)
;; класс с этим метаклассом получает особое поведение при создании:
(defclass widget ()
((id :initarg :id))
(:metaclass counted-class)) ; ИСПОЛЬЗУЕМ свой метакласс
Идея: указав :metaclass, вы меняете правила для класса — например, можно перехватить создание экземпляров (через метод на make-instance), доступ к слотам (slot-value-using-class), вычисление наследования. Это позволяет строить ORM (слот «знает» про колонку БД), персистентные объекты, наблюдаемые поля, специальные виды классов — не меняя компилятор, а лишь специализируя метаобъектные обобщённые функции.
MOP: что им делают на практике
MOP — продвинутый инструмент, и в повседневном коде вы его не пишете. Но он лежит в основе многих библиотек, которыми пользуются все: ORM вроде Mito, системы валидации, фреймворки сериализации, расширенные системы типов слотов. Все они через MOP «вклиниваются» в механику CLOS: перехватывают инициализацию, добавляют метаданные слотам, меняют диспетчеризацию. Знать о существовании MOP важно по двум причинам: во-первых, понять, почему CLOS так гибок (потому что он не «зашит», а реализован настраиваемыми обобщёнными функциями); во-вторых, чтобы при необходимости читать и расширять такие библиотеки. Стандарт ANSI описывает CLOS, а MOP описан в отдельной книге «The Art of the Metaobject Protocol» и поддержан реализациями (в SBCL — пакет sb-mop) как фактический стандарт. Главный вывод, который стоит унести: гибкость CLOS не «вшита» как набор привилегированных возможностей компилятора, а вытекает из того, что система описана в собственных терминах и открыта для специализации на каждом уровне.
Как работает под капотом
loop — это макрос: он разбирает свои фразы (это полноценный парсер мини-языка внутри макроса) и генерирует обычный итеративный код на tagbody/go с заведёнными переменными-аккумуляторами. То есть «английские» ключевые слова превращаются в эффективный цикл на этапе компиляции — никакого рантайм-разбора синтаксиса loop не остаётся, и по скорости он не уступает ручному do. MOP работает иначе: ключевые операции CLOS (создание экземпляра, доступ к слоту, вычисление CPL, выбор применимых методов) определены как обобщённые функции над метаобъектами. Когда вы задаёте свой метакласс и специализируете, скажем, slot-value-using-class, вы добавляете метод в ту самую обобщённую функцию, которую CLOS вызывает при каждом доступе к слоту объектов вашего класса. Получается «черепахи до самого низа»: объектная система реализована средствами объектной системы, и потому открыта. Кеширование диспетчеризации (из урока про методы) гарантирует, что эта гибкость не делает обычные обращения дорогими.
Частые ошибки
- Запутанный
loop. Для прямой итерации/агрегацииloopидеален; при ветвистой логике он становится нечитаемым — выносите в функции или беритеdo/рекурсию. - Считать фразы
loopобычными формами. Это ключевые слова DSL, а не вызовы функций; их порядок и сочетания подчиняются грамматикеloop, а не общим правилам Lisp. - Путать параллельные и последовательные
for. Несколькоforидут параллельно и цикл кончается с первым исчерпавшимся источником; для зависимых переменных используйте вложенность илиdo*-логику. - Лезть в MOP без нужды. MOP — для авторов библиотек и редких задач (ORM, персистентность). Обычные классы и методы покрывают подавляющее большинство задач.
- Полагаться на MOP как на переносимый стандарт. MOP — фактический, но не часть ANSI; детали (имена пакета, как
sb-mop) зависят от реализации.
Итоги
loop— декларативный DSL итерации: источники (for in/across/from), накопление (collect/sum/count/maximize), условия (when/while).- Несколько
forидут параллельно;intoдаёт именованные аккумуляторы,finally— финальный код/результат. loopединообразно обходит списки, векторы, числа и хеш-таблицы; компилируется в эффективныйtagbody/go.- MOP делает CLOS программируемым: классы/методы — объекты, а поведение системы — настраиваемые обобщённые функции.
- Через метаклассы и метаобъектные функции (
slot-value-using-classи др.) строят ORM, персистентность, валидацию — не меняя компилятор. - MOP — продвинутый, в основном для библиотек; CLOS гибок потому, что реализован сам в себе и открыт для расширения.