Ловушки для Clojure

Java — суровый язык для суровых программистов.

Нет, Java-программисты не вытирают попу наждачной бумагой (хотя, это как посмотреть), но считают даже легкий намек на динамичность языка недопустимой роскошью, поэтому суровые Java-хакеры написали второй компилятор для придания своим программам малой толики функциональной комбинаторики. Саму идею ради которой собственно и был написан этот второй компилятор назвали чуть ли не новой парадигмой программирования (аспектно-ориентированное программирование, АОП) — со своей, принципиально новой терминологией, куда уж без неё.

Но это не более чем мой скромный взгляд на жизнь Java-сообщества и проект десятилетней давности «AspectJ». Определенно, создатель AspectJ, Грегор Кичалес (Gregor Kiczales), совсем не дурак — профессор Computer Science и ко всему большой лиспер, приложивший руку к Common Lisp Object System, автор книги «Art of the Metaobject Protocol», о которой небезызвестный Алан Кэй (Alan Kay) сказал

«Art of the Metaobject Protocol» это лучшая компьютерная книга написанная за последние десять лет [1990-2000].

Думаю, биография создателя AspectJ делает очевидным предположение о том, что само АОП по своей сути является попыткой перенести фичи Common Lisp в Java, на что я приведу сомнительное (citation needed) высказывание Питера Норвига

В Лиспе, если вы хотите аспектно-ориентированного программирования — нужно лишь написать немного макросов, и готово.

В Java — нужен Грегор Кичалес, создающий новую фирму, и месяцы и годы попыток заставить всё это работать.

и воспоминание Кенни Тилтона

Я помню как Грегор Кичалес на ILC 2003 [Международная конференция по Лиспу], рассказывая об AspectJ безмолвной толпе, остановился, затем жалобно добавил «Когда я показал это Java-программистам они встали и заапплодировали».

Лисп всегда был практичным языком. По крайней мере мне он всегда нравился именно за свою практичность. И если кто-то скажет, мол «лисп — академическая игрушка» — не верьте, ибо это чушь и сказка. Три больших лиспа последней четверти века: Emacs Lisp, Common Lisp и Clojure были заточены исключительно под практические нужды.

Соответственно, в старых лиспах уже очень, очень долгое время (десятки лет, насколько я могу судить) был собственный и очень добротный инструментарий для «АОП» (в кавычках — тогда это называлось по-другому): advice в Emacs Lisp и Common Lisp Object System Method Combinations (комбинаторы методов или сочетания методов… проблемы с переводом терминов).

До недавнего времени я не видел ничего подобного для Clojure, но мне хотелось испробовать АОП-шные фичи, и вот, летом 2010 года, technomancy опубликовал на гитхабе библиотеку «ловушек» Robert Hooke. Находясь под впечатлением от книги «Идеальный код», на следующий же день я её форкнул дабы привести в более кложурообразный вид и сделать чуть погибче.

С того момента когда я прочел приведенную выше цитату Норвига меня мучил вопрос — «а правда ли, что для реализации основы АОП в лиспе нужны макросы?» Я разобрался как работают ловушки technomancy, и оказалось, что макросы не нужны. Для их реализации достаточно лишь динамических переменных, метаданных и функций высшего порядка. И все это в семидесяти (!!!) строках кода. После переписывания реализации technomancy я искренне удивился компактности кода.

Ловушки по сути являются крохотным фреймворком вокруг простого, но сурового комбинатора функций — он позволяет мощным чисто функциональным механизмам сослужить нам полезную императивную службу. Technomancy в интервью назвал код этого комбинатора (2 строчки на Clojure) «своим любимым кусочком кода».

Ловушки используются для изменения поведения функции без изменения её оригинальной реализации. Это такая своеобразная техника подключения «функций-плагинов» к функции.

Функция add-hook устанавливает ловушку в одном из трех мест:

before
Ловушка вызывается перед вызовом оригинальной функции.
after
Ловушка вызывается после вызова оригинальной функции.

В before и after-ловушки имеет смысл устанавливать чисто императивные функции — только такие смогут повлиять на ход выполнения программы. При вызове в эти ловушки передаются аргументы вызова. Эти ловушки вызываются непосредственно до и после вызова оригинальной функции; все around-ловушки «оборачивают» before и after-ловушки.

around
Ловушка вызывается вместо вызова оригинальной функции. При вызове в неё передается функция которую ловушка «оборачивает» и аргументы вызова.

В around-ловушки можно устанавливать чистые функции, грязные функции, использующие всевозможные with-макросы, заменяющие аргументы, модифицирующие их количество — настоящее раздолье для программерской фантазии.

Функция add-hook принимает 4 аргумента, в стиле функции add-watch из clojure.core:

  1. Ключ :before, :after или :around.
  2. Функцию, анонимную функцию или имя переменной, содержащей функцию (например, #'+), около которой мы ставим ловушку.
  3. Уникальный ключ именующий ловушку, чтобы можно было её удалить.
  4. Функцию которую мы ставим в ловушку.

Функция remove-hook снимает ловушку. Она принимает 3 аргумента:

  1. Ключ :before, :after или :around.
  2. Функцию, анонимную функцию или имя переменной, содержащей функцию (например, #'+), около которой мы поставили ловушку.
  3. Уникальный ключ именующий ловушку.

Небольшой пример иллюстрирующий работу с ловушками.

(defn examine [x]
  (print x))

(defn microscope [f x]
  (f (.toUpperCase x)))

(defn doubler [f & xs]
  (apply f xs)
  (apply f xs))

(defn telescope [f x]
 (f (apply str (interpose " " x))))

(defn into-string [f & xs]
  (with-out-str (apply f xs)))

(add-hook :around #'examine :microscope microscope)
(add-hook :around #'examine :doubler    doubler)
(add-hook :around #'examine :telescope  telescope)
(add-hook :around #'examine :into-str   into-string)
(add-hook :after  #'examine :dotspace   (fn [& args] (print \. \space)))

(examine "Before i forget")
> B E F O R E   I   F O R G E T.  B E F O R E   I   F O R G E T.  

(remove-hook :after  #'examine :dotspace)
(remove-hook :around #'examine :doubler)

(examine "Before i forget")
> B E F O R E   I   F O R G E T

(remove-hook :around #'examine :microscope)

(examine "Before i forget")
> B e f o r e   i   f o r g e t

(remove-hook :around #'examine :telescope)

(examine "Before i forget")
> Before i forget

Вся эта магия достигается благодаря метаданным. Когда мы добавляем функцию-ловушку к оригинальной функции — они обе переезжают в метаданные третьей функции которая будет их вызывать, и эта третья занимает имя оригинальной функции. Все последующие ловушки добавляются уже непосредственно в метаданные, а когда все ловушки снимаются — все возвращается на свои места, как будто ничего и не было.

Чтобы использовать ловушки в своих программах укажите в зависимостях Leiningen [hooks "1.0.0"].