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

Не скажу, чтобы это плохо: язык должен быть простым. Чем меньше правил и бест-практик нужно помнить, тем легче на нем писать. Однако, есть в Кложе классные штуки, точечное применение которых сэкономит время и объем кода.

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

Мы знаем, что ООП базируется на инкапсуляции, наследовании и полиморфизме. Рассмотрим последний. Полиморфизм – это когда у метода может быть несколько реализаций. Конкретная реализация выбирается в зависимости от типов параметров.

В классическом ООП нам бы привели такой пример. У класса Geometry есть метод square для вычисления фигур. На вход могут подать окружность, квадрат и треугольник. Запишу на каком-то выдуманном языке:

class Geometry:

    real square(Circle c):
        return Math.PI * c.radius * c.radius;

    real square(Rectangle rect):
        return rect.a * rect.b;

    real square(Triangle tri):
        real p = (tri.a + tri.b + tri.c) / 2;
        return Math.sqrt(p * (p - tri.a) * (p - tri.b) * (p - tri.c))

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

Уверен, каждый помнит это с университетских времен. Но полиморфизм на типах – всего лишь частный случай мультиметодов. В отличии от полиморфизма, мультиметоды не прибиты гвоздями к ООП, а значит, дают большую свободу в идеи и реализации.

Для адептов ООП это, возможно, прозвучит сюпризом, но мультиметоды существуют во многих языках, в т.ч. в которых объектов не существует. Почти любой функциональный язык поддерживает множественные клозы (clause) для функций. Но если в ООП все сводится к типам, то в функциональных языках действует более мощный механизм подбора основанный на паттерн-матчинге.

Рассмотрим Хаскель. То, что выше я записал на выдуманном языке, в Хаскеле будет так:

data Circle = Circle Float
data Rectangle = Rectangle Float Float
data Triangle = Triangle Float Float Float

square :: Circle -> Float
square (Circle r) = 3.1415 * r * r

square :: Rectangle -> Float
square (Rectangle a b) = a * b

square :: Triangle -> Float
square (Triangle a b c) = sqrt $ p * (p - a) * (p - b) * (p - c)
where
  p = (a + b + c) / 2

Видно, что функция square работает с типами окружность, прямоугольник, треугольник. В любой момент мы можем дополнить ее новой фигурой.

Есть мультиметоды и в классических диалектах Лиспа. Вот, например, копипаста из Википедии, взятая из кода Астероидов:

(defmethod collide-with ((x asteroid) (y asteroid))
   ;; deal with asteroid hitting asteroid
   )
 (defmethod collide-with ((x asteroid) (y spaceship))
   ;; deal with asteroid hitting spaceship
   )
 (defmethod collide-with ((x spaceship) (y asteroid))
   ;; deal with spaceship hitting asteroid
   )
 (defmethod collide-with ((x spaceship) (y spaceship))
   ;; deal with spaceship hitting spaceship
   )

Особенность примера с Лиспом в том, что второй параметр в каждой паре ведет себя как предикат. В данном случае тип spaceship срабатывает как проверка того, что x – экземпляр космического корабля. Однако, вместо spaceship можно передать другие проверки: integer?, string?, even?, словом, любой унарный предикат.

Столь гибкая система мультиметодов позволяет определить разное поведение для одного и того же типа, но разных значений. Например, для отрицательной суммы денег одно поведение, для нуля – второе, для положительной – третье. Для даты: выходной или нет, високосный год или нет и т.д.

Думаю, ясно теперь, чем это выгодней банального полиморфизма на типах.

В Кложе более гибкая система мультиметодов. В рассмотренных выше примерах мы не могли изменить сам принцип подбора метода, или диспатча. В Кложе, наоборот, вы обязаны определить диспатч! Рассмотрим пример:

(defmulti foo class)

Данное определение говорит: прежде чем искать реализацию, метод получит класс аргумента.

Следующее определение расширяет мульти-метод реализацией для Long: если передано длинное целое, получим строку "an integer".

> (defmethod foo Long [x] "an integer")
#multifn[foo 0x478c7d41]
> (foo 42)
"an integer"

Добавим для строки:

> (defmethod foo String [x] (format "%s is a string" x))
#multifn[foo 0x478c7d41]
> (foo "test")
"test is a string"

Если ни одно соответствие не подошло, будет ошибка:

> (foo nil)
IllegalArgumentException No method in multimethod 'foo' for dispatch value: null
clojure.lang.MultiFn.getFn (MultiFn.java:156)

Не страшно, добавим реализацию по умолчанию:

> (defmethod foo :default [x] (format "you passed %s" x))
> (foo nil)
"you passed null"

Диспатч может работать самым разным способом, например, проверять на типы все аргументы. Повторим пример с площадью фигур:

(defmulti square (fn [& args] (mapv class args)))

;; rectangle
(defmethod square [Long Long] [a b] (* a b))
(square 2 3) ;; 6

;; circle
(defmethod square [Double] [r] (* Math/PI r r))
(square 1.1) ;; 3.8013271108436504

;; triangle
(defmethod square [Long Long Long]
               [a b c]
               (let [p (/ (+ a b c) 2)]
                 (Math/sqrt (* p (- p a) (- p b) (- p c)))))
(square 2 2 2) ;; 1.7320508075688772

Замечу, что на самом деле Кложа проверяет значения диспатча и образца не простым сравнением, а функцией isa?, что подразумевает иерархию. Так, чтобы проверку проходили типы PersistentArrayMap и PersistentHashMap, достаточно указать базовый класс clojure.lang.APersistentMap.

Наконец, проверять можно не только на типы, а банально на значения, диапазоны, предикаты и тд. В текущем проекте я проверяю последний переданный аргумент. Это может быть как айдишка, так и запись БД. В зависимости от этого условия срабатывает разная логика.

Каждый мультиметод может вызвать внутри другой, тогда процесс диспатча начнется заново.

Мультиметоды в Кложе отличаются еще и тем, что расширяемы извне. Это значит, если разработчик определил мультиметод для определенных типов, ничто не мешает расширить его до других типов. И это будет все тот же экземпляр.

Напомню ситуацию с примерами на Джаве и Хаскеле выше. Пусть фукция square и класс Geometry находятся в чужих библиотеах. Тогда вы никак не сможете изменить их! В лучшем случае вы унаследуете класс Geometry, добавите свой метод для ромба. Но если есть еще одна библиотека, которая работает с Geometry, вы никак не сможете на это повлиять.

Напротив, идея расширения чужих определений работает в Кложе просто убийственно. Я нигде не видел ничего подобного.

В Кложе мультиметоды используют когда трудно предугадать, какие типы данных будут поступать на вход. Это прекрасное решение для абстракций вроде работы с БД, парсингом данных, декораторов и тд.

Мультиметоды в Лиспах – мощнейшей инструмент, порой в корне меняющий принцип мышления и разработки.