Цикл 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 гибок потому, что реализован сам в себе и открыт для расширения.
Проверьте себя
1. Как ведут себя несколько фраз for в одном loop, например (loop for i from 0 below 10 for x in short-list ...)?
AОни выполняются последовательно, один цикл за другим
BОни идут параллельно, и цикл завершается, как только исчерпан любой из источников
CВторой for игнорируется
DЭто синтаксическая ошибка
2. Что такое метаобъектный протокол (MOP) в CLOS?
AСетевой протокол для объектов
BAPI, делающий саму объектную систему программируемой: классы и методы — объекты, а поведение CLOS задаётся настраиваемыми обобщёнными функциями над метаобъектами
CСпособ ускорить вызовы методов
DФормат сериализации объектов