GrabDuck

Pipe matching в ЯП Clojure (метапрограммирование в Lisp для начинающих)

:


Несколько дней назад я открыл для себя замечательный ЯП Clojure — один из современных диалектов Lisp, особенностью которого является хорошая реализация средств многопоточности, компиляция в байткод jvm, соответственно возможность использования java — библиотек, jit-компиляция и т.д. Про Clojure можно почитать например тут. Но в этой статье речь пойдёт о метапрограммировании. Lisp устроен таким образом, что данные и код в нём — одно и то же. Объявления функций, макросов, вызовы функций, развёртывание макросов — в Lisp это всё просто списки, возможно вложенные друг в друга.
(defn square [foo] (* foo foo))
(defmacro show-it [foo] `(println ~foo))

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

(defmacro recurs [foo bar]
	(println "hello from compiler" foo)
	(case (<= bar 0)
	true `(defn foo [] ~foo)
	false `(recurs ~(- foo 1) ~(- bar 1))))

И в коде вставим выражение

(recurs 2 1)

То во время компиляции мы увидим

hello from compiler 2
hello from compiler 1

И макрос в данном случае развернётся в определение функции foo с арностью 1 и возвращаемым значением 1, т.е.

(def user/foo (clojure.core/fn ([] 1)))

Если мы напишем (recurs 3 1) то функция foo будет возвращать значение 2 и т.д. Макросы в Lisp — отличное средство для сокрытия какой-либо сложной логики или управляющих конструкций за простым синтаксисом и соответственно для расширения выразительных средств самого языка. Многие конструкции языка типа «defn», "->>", "->" в действительности — тоже просто макросы.

Макросы Lisp — это обычные функции, за тем лишь исключением что они выполняются во время компиляции, и чтобы освоить технику их написания — в принципе достаточно знать как работают следующие 4 «special forms»

`(expr) '(expr) ~(expr) ~@(expr)

Об этом можно подробно почитать тут. Если совсем в двух словах — конструкция quote ( ' и ` ) это просто нотация компилятору: «данное выражение не надо пытаться выполнить, а надо вернуть как есть, т.е. в виде кода», а конструкция unquote (~ и ~@) приблизительно означает «выполнить данное выражение и вставить результат в это место кода». Естественно unqoute-конструкции имеют смысл только внутри quote-конструкций. Таким образом макросы — это функции, принимающие в качестве аргументов quote-конструкции, возвращающие в качестве значений quote-конструкции и выполняемые в compile-time.


Для демонстрации мощи Lisp-макросов будем писать реализацию pipe matching для Lisp. Что же такое pipe matching? Как можно понять из названия — это композиция двух управляющих конструкций: pipe и pattern matching.

Pipes в ЯП Clojure это просто макросы, принимающие в качестве аргументов n-ное количество выражений и раскрывающиеся определённым образом в композицию этих выражений, выражаясь совсем простым языком происходит следующее:
(-> expr1 expr2 expr3… ) здесь пайп "->" вставит expr1 в качестве первого аргумента в expr2 ( все остальные аргументы сдвинутся на 1 позицию вправо ), получившийся таким образом expr12 вставит в качестве первого аргумента в expr3 и т.д. Пример

(->   (func1 "foo") 
	(func2 "bar") 
	(func3 123))
(func3 (func2 (func1 "foo") "bar") 123)

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

Pattern Matching — очевидная вещь например для erlang / elixir, haskell, ml — девелопера, но в двух словах тут так просто не объяснить, это скорее надо прочувствовать. Очень отдалённо p.m. можно определить как композицию присваивания и сравнения. Непосвящённые могут обратиться к википедии или посмотреть мою предыдущую статью, где я в нескольких словах расписал использование p.m. в ЯП elixir / erlang. В Clojure нет native pattern matching, но как нетрудно догадаться есть библиотеки, реализующие с помощью макросов p.m. почти идентичный p.m. в erlang. Не будем изобретать велосипедов и используем библиотечный макрос match в этом проекте.

Теперь вернёмся к вопросу — зачем нужно соединять p.m. и pipe в одной сущности pipe matching? Когда мы пишем программы уровня «hello world», где все функции чистые, нет side — effects и всё предельно детерминированно, возможно это и не имеет большого смысла. Но в реальном продакшне без грязных функций не обойтись. Например, нам нужно загрузить фотографию в альбом в соц сети VK. Согласно документации для этого нам нужен id альбома, ключ доступа и собственно данные которые надо загрузить. Общий алгоритм загрузки следующий

прочитать файл
получить url загрузки (get-http запрос, парсинг json)
загрузить данные (post-http запрос, парсинг json)
сохранить загруженные данные (get-http запрос, парсинг json)

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

Но ведь это позор) Который трудно не то что поддерживать и дебажить, а просто читать / писать / понимать. Вот в таких ситуациях (которые согласитесь сплошь и рядом) pipe matching сделает код очень простым, спрятав в себя всю сложную логику. Пример применения макросов pipe_matching и pipe_not_matching

(defn nested_process foo bar baz
      (pipe_matching {:ok some_data}
                     (simple_func1 foo)
                     (simple_func2)
                     (simple_func3 bar)
                     (simple_func4 baz)))

Что здесь происходит: функция nested_process принимает аргументы foo, bar и baz. И дальше начинаются послеовательные вызовы возможно грязных функций simple_func1 с арностью 1, simple_func2 с арностью 1, simple_func3 с арностью 2 и simple_func4 с арностью 2, при этом как в обычных pipes результат предыдущего выражения — первый аргумет слудующего. А теперь самое главное — первым аргументом макроса pipe_matching мы задали pattern {:ok some_data}. Под этот pattern подойдёт map, где есть ключ :ok с любым значением. И пока функции simple_func возвращают значения, подходящие под этот pattern — вызывается следующая функция (как в обычном pipe). Но как только какая-то из функций simple_func вернёт значение не подходящее под этот pattern — оно вернётся как значение функции nested_process и не будет передано дальше по цепочке. Например если simple_func3 тут вернёт {:error «on simple_func3 server ans 500»}, функция simple_func4 вообще не будет вызвана, а nested_process вернёт {:error «on simple_func3 server ans 500»}. Также можно сделать аналогичный макрос pipe_not_matching, который можно использовать вот так

(defn nested_process foo bar baz
      (pipe_not_matching {:error some_error}
                         (simple_func1 foo)
                         (simple_func2)
                         (simple_func3 bar) 
                         (simple_func4 baz)))

Думаю как он работает — понятно из контекста. Если simple_func3 тут вернёт {:error «on simple_func3 server ans 500»}, а 1я и 2я функции до этого вернут значения не подходящие под pattern — результат будет такой же как и в предыдущем примере. На практике я предпочитаю именно pipe_not_matching. В итоге мы имеем практически дословную сериализацию логики задачи в код без всяких if / else / elseif / case / switch etc. В общем всё то за что мы любим ФП — код это сама задача без лишних абстрактных сущностей. Круто? А теперь посмотрим как написать такие макросы на Lisp буквально в пару строчек.


Перво-наперво пропишем зависимости для namespace нашей библиотеки — нам нужен всего один макрос «match»
(ns pmclj.core
    (:use [clojure.core.match :only (match)]))

Определим в общем виде 2 макроса, которые являются нашей целью

(defmacro pipe_matching [pattern init_expression & other_expressions ]
          (pipe_matching_inner {:pattern pattern, :result init_expression, :expressions other_expressions, :continue_on_match true}))
(defmacro pipe_not_matching [pattern init_expression & other_expressions ]
          (pipe_matching_inner {:pattern pattern, :result init_expression, :expressions other_expressions, :continue_on_match false}))

Макрос будет принимать в качестве первого аргумента pattern, остальные аргументы — выражения собственно для p.m. Логично что нам нужно хотя бы одно выражение, поэтому назовём его init_expression и поставим обязательным вторым аргументом. Обратите внимание на знак & — здесь он означает что other_expressions — список любой длины (возможно пустой) соответственно состоящий из возможных аргументов (третий, четвёртый и тд). Таким образом макрос может быть развёрнут с любым числом аргументов больше 1.

Далее просто для удобства сериализуем аргументы в map с ключами :pattern, :result, :expressions, :continue_on_match. result здесь — результат полученный на предыдущем шаге, expressions — оставшиеся не развёрнутые в результирующий код макроса выражения, continue_on_match — true / false: означает что делать если result совпал с образцом — продолжать цепочку вызовов или вернуть значение.

Полученный map передаём в рекурсивную функцию pipe_matching_inner которая вернёт нужный нам код. Да-да, в этом прелесть Lisp, в compile-time можно вызывать функции, лишь бы они были к моменту вызова уже скомпилированы. Функция выглядит следующим образом.

(defn pipe_matching_inner [{pattern :pattern, result :result, expressions :expressions, continue_on_match :continue_on_match}]
      (case (or (= expressions nil) (= expressions ()))
            true result
            false  (let [to_pipe (first expressions) rest_expr (rest expressions)]
                        `(let [~'res ~result]
                              (case (= ~continue_on_match (check_match ~'res ~pattern))
                                    true ~(pipe_matching_inner {:pattern pattern, :result `(-> ~'res ~to_pipe), :expressions rest_expr, :continue_on_match continue_on_match})
                                    false ~'res)))))

Она принимает map, который мы сформаровали в теле макроса. Тут кстати можно видеть что native p.m. в каком-то виде и с довольно странным синтаксисом в Clojure всё же есть: [{pattern :pattern, result :result, expressions :expressions, continue_on_match :continue_on_match}].
Case — expression тут говорит следующее: если мы обработали уже все выражения — ничего не остаётся как вернуть результат. В противном случае — обрабатываем. Далее идёт очень важная special form языка Lisp — let. Глобальных переменных и присваивания тут нет и быть не может, но мы можем делать локальный binding в духе выражение => символ. Функции first и rest собственно возвращают первый и все кроме первого элемента списка, тут всё прозрачно — берём следующее выражение для того чтобы обработать.
Далее самое интересное — собственно код, который мы динамически генерируем в compile-time. Тут уже придётся приложить небольшие умственные усилия чтобы понять что происходит. Мы локально биндим результат из предыдущего выражения символу res (чтобы в runtime не исполнять это выражение больше одного раза). Обратите внимание на ~' — это небольшой хак, связанный с тем что при компиляции символы прикрепляются к соответствующему namespace, сочетание ~' их так сказать открепляет от него, не будем в контексте этого повествования углубляться в эти вопросы. Далее следует case — выражение, которое честно выполнится в runtime — мы проверим подходит ли под образец pattern наш res. check_match — собственно тоже макрос, очень простой, основанный на библиотечном макросе match

(defmacro check_match [obj pattern]
      `(match ~obj
             ~pattern true
             :else false))

И дальше в зависимости от того является ли выражение check_match ожидаемым true — для pipe_matching и false — для pipe_not_matching либо продолжится цепочка вызовов, либо вернётся res. Последующая цепочка вызовов так же красиво нарисуется рекурсивными вызовами функции pipe_matching_inner.

Посмотрим как теперь это будет выглядеть на деле.
Вот такой красивый pipe matching

(pipe_matching {:ok some}
               (func1 "foo")
               (func2 "bar")
               (func3 123))

На деле разворачивается вот в такой локальный ад

(clojure.core/let [res (func1 "foo")] (clojure.core/case (clojure.core/= true (pmclj.core/check_match res {:ok some})) true (clojure.core/let [res (clojure.core/-> res (func2 "bar"))] (clojure.core/case (clojure.core/= true (pmclj.core/check_match res {:ok some})) true (clojure.core/-> res (func3 123)) false res)) false res))

Хочу обратить ваше внимание что для построения той же самой логики без pipe matching пришлось бы выписывать весь этот ад своими руками)

Полностью код можно посмотреть тут.

В заключение хочу сказать что на первый взгляд синтаксис Lisp конечно не очень user-friendly, но для построения больших / сложных систем имхо он подходит хорошо. Конечно после erlang / elixir чувствуешь себя без otp как без рук, но я уверен что это вопрос времени. Так или иначе это мой первый опыт соприкосновения с jvm, и я думаю он будет довольно удачным)

Сделал некоторый рефакторинг после которого сами макросы не выглядят так страшно. Прислушался к советам и добавил ещё 4 макроса:

pred_matching / pred_not_matching — то же самое, только принимает первым аргументом лямбду с арностью 1.
key_matching / key_not_matching — то же самое, только принимает первым аргументом какой-либо ключ, и в runtime ищет в результатах вызовов значения по заданному ключу, если значение nil или отсутствует это равносильно false иначе — true.

пример использования

(defn func1 [arg]
      {:ok arg})
(defn func2 [arg1 arg2]
      {:fail (+ (get arg1 :ok) arg2)})
(defn func3 [arg1 arg2]
      {:ok (+ (get arg1 :ok) arg2)})

(defn example_pred []
      (pred_matching #(contains? % :ok) 
                     (func1 1) 
                     (func2 2) 
                     (func3 3)))

(defn example_pm []
      (pipe_matching {:ok some}
                     (func1 1) 
                     (func2 2) 
                     (func3 3)))

(defn example_key []
      (key_matching :ok
                     (func1 1) 
                     (func2 2) 
                     (func3 3)))

Вот кстати вам и пример кода где нужнен корректный возврат из предыдущей функции: место (+ (get arg1 :ok) arg2) потенциально содержит эксепшны, например если функция get вернёт nil.
Как и ожидается все три функции example вернут в данном случае одно и то же значение.

(example_pred)
{:fail 3}
(example_pm)
{:fail 3}
(example_key)
{:fail 3}

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

Универсальность управляющих конструкций тут естественно такая pred_matching > pipe_matching > rkey_matching > key_matching. Что из этого использовать — зависит от личного вкуса и от сложности данных с которыми мы работаем.

Получившийся в результате код здесь.