Clojure: Советы по созданию макросов
Знание правил и синтаксиса макросов, еще не гарантируют правильность их написания, однако можно направить свои рассуждения в правильную сторону придерживаясь примерно такого алгоритма (впрочем, этот алгоритм подходит и под проектирование функций):
- Подумайте о том, какие данные получает макрос
- Задумайтесь о том, какие данные макрос должен вернуть
- Подумайте, каким образом исходные данные трансформировать в возвращаемые
Хоть и алгоритм звучит немного очевидно(?) и иногда бывают случаи, когда пункт 2 идет впереди пункта 1, все же он позволяет направить мысли в нужное русло.
Попробуем сделать макрос postfix
исходя из алгоритма выше:
- Данными для макроса будут списки, например
(1 2 +)
, то есть в общем виде(operand1 operand2 operator)
- Так как мы знаем, что в Lisp-подобных языках оператор идет первым, а за ним операнды, к которым оператор будет применен. То есть, в результате данные будут иметь следующую форму
(operator operand1 operand2)
- Посмотрим на примере, как данные будут меняться
; входные данные => данные, которые возвращает макрос
(1 2 +) => (+ 1 2)
По сути, мы возвращаем список из трех элементов:
; входны данные => внутри макроса => данные, которые возвращает макрос
(1 2 +) => (list + 1 2) => (+ 1 2)
Так как нам нужно значение, вычисляемое макросом, то и пользоваться оператором цитирования '
не нужно. Значит макрос будет выглядеть так:
(defmacro postfix [[op1 op2 operator]]
(list operator op1 op2))
(postfix (1 2 +))
3
(postfix (1 2 -))
-1
Благодаря тому, что Clojure предоставляет удобный REPL, создавать макросы с помощью такого алгоритма заметно проще.
Так как макросы всего лишь инструмент, то у них есть, конечно же и недостатки. Поэтому поговорим подробнее о них.
Макросы не являются значением
Функция может быть значением, но макрос же - нет. Это означает, что мы не можем передавать макросы в функции высшего порядка:
(defmacro my-odd [x] `(odd? ~x))
(filter my-odd [1 2 3 4 5 6])
Syntax error compiling
Can't take value of a macro: #'user/my-odd
Если все же нужно передать макрос в функции высшего порядка, то придется обернуть макрос в функцию (вы это могли видеть в тестах, когда макрос, который тестируется, обернут в анонимную функцию):
(filter #(my-odd %) [1 2 3 4 5 6])
Однако это может сработать далеко не во всех случаях.
Разворачивание макроса происходит во время компиляции кода
Это то, о чем упоминалось в третьем правиле макросов, что оно не совсем точное. Когда вы вызываете макрос в своем коде, он заменяется на список, который вызванный макрос возвращает после компиляции программы. А список, который вернул макрос, будет выполнен при запуске программы.
Звучит немного запутанно, так как при экспериментировании в REPL вы не замечаете того, как компилируется и запускается код, так как это происходит моментально. Поэтому исследуем этот вопрос чуть подробнее:
(defmacro muliply-2 [xs]
`(* 2 ~@xs))
Теперь создадим файл example.clj
и используем там макрос multiply-2
:
(muliply-2 [1 3])
После того как мы скомпилируем файл и посмотрим на итоговый код, то увидим следующее:
(* 2 1 3)
И только этот код будет выполнен при запуске скомпилированной программы. Так как макросу нужно знать, что находится в xs
во время компиляции (из-за ~@xs
), мы не можем передать переменную в макрос:
(defn multiply-by-2 [nums]
(muliply-2 nums))
Syntax error (IllegalArgumentException) compiling
Don't know how to create ISeq from: clojure.lang.Symbol
Так как nums
будут переданы только во время запуска программы, макрос не знает о том, что хранится в nums
, однако внутри макроса мы пытаемся извлечь значения, которые хранятся в переданном символе (оператор ~@
). Это еще одна причина, почему нельзя передавать макросы в функции высшего порядка.
Макросы привлекают макросы
Сделаем еще один макрос:
(defmacro add [& args]
`(+ ~@args))
(add 1 2 3)
6
Но что будет, если мы захотим узнать сумму каждого вектора чисел в переданном списке? В обычном случае мы бы использовали простую комбинацию функций:
(map #(apply + %) [[1 2 3] [2 4] [3 3]])
(6 6 6)
Но как мы помним, макросы нельзя передавать как значения:
(defmacro add [& args]
`(+ ~@args))
(map #(apply add %) [[1 2 3] [2 4]])
; Syntax error compiling at
; Can't take value of a macro: #'user/add
Придется писать еще один макрос...
(defmacro add-vecs [vecs]
(loop [f (first vecs)
r (rest vecs)
res `(list)]
(if (seq r)
(recur (first r) (rest r) (concat res `((add ~@f))))
(concat res `((add ~@f))))))
(add-vecs [[1 2 3] [2 4] [3 3]])
(6 6 6)
Как быстро все начало запутываться... А мы ведь только начали...
Макросы сложнее читать, писать и понимать
Из-за того, что макросы по сути вычисляются "дважды", понимать их всегда сложнее, чем обычную функцию, а как мы знаем, понятность кода является важной частью программирования. В целом, это одна из причин, почему многие критикуют Lisp-подобные языки, так как поддерживать проект, который состоит из кучи макросов, написанных программистами, которые уже не работают в организации, задача крайне авантюрная :)
Не нужно писать макрос, если с этим справится обычная функция
Исключением являются случаи, когда макрос является более удобным способом организации кода, чем простая функция, прекрасным примером являются макросы ->
и -->
:
; это валидный код, однако он выглядит немного запутанно
(map str
(map #(* % %)
(map inc
(filter even? (range 25)))))
; тот же самый код, однако операции, которые производятся над данными, понимаются намного проще и быстрее
(->> (range 25)
(filter even?)
(map inc)
(map #(* % %))
(map str))
В целом это все, что хотелось сказать о макросах. Для углубления понимания макросов, можно заняться чтением библиотек, используемых в Clojure, например clojure.test
. Ну и практика, куда же без нее :)
Задание
В последнем упражнении основной упор был на теорию, поэтому задание будет простым :) Создайте макрос macro-inc
, который увеличивает переданное число на 1.
Упражнение не проходит проверку — что делать? 😶
Если вы зашли в тупик, то самое время задать вопрос в «Обсуждениях». Как правильно задать вопрос:
- Обязательно приложите вывод тестов, без него практически невозможно понять что не так, даже если вы покажете свой код. Программисты плохо исполняют код в голове, но по полученной ошибке почти всегда понятно, куда смотреть.
В моей среде код работает, а здесь нет 🤨
Тесты устроены таким образом, что они проверяют решение разными способами и на разных данных. Часто решение работает с одними входными данными, но не работает с другими. Чтобы разобраться с этим моментом, изучите вкладку «Тесты» и внимательно посмотрите на вывод ошибок, в котором есть подсказки.
Мой код отличается от решения учителя 🤔
Это нормально 🙆, в программировании одну задачу можно выполнить множеством способов. Если ваш код прошел проверку, то он соответствует условиям задачи.
В редких случаях бывает, что решение подогнано под тесты, но это видно сразу.
Прочитал урок — ничего не понятно 🙄
Создавать обучающие материалы, понятные для всех без исключения, довольно сложно. Мы очень стараемся, но всегда есть что улучшать. Если вы встретили материал, который вам непонятен, опишите проблему в «Обсуждениях». Идеально, если вы сформулируете непонятные моменты в виде вопросов. Обычно нам нужно несколько дней для внесения правок.
Кстати, вы тоже можете участвовать в улучшении курсов: внизу есть ссылка на исходный код уроков, который можно править прямо из браузера.