Наследование, при кажущейся простоте, часто приводит к сложным, сопротивляющимся изменениям структурам. Иерархии классов растут как самый настоящий лес.
Целью наследование является привязка кода (набора методов) к минимальному набору свойств сущности (как правило — объекта), которые он обеспечивает и которые ему требуются. Это упрощает повторное использование, тестирование и анализ кода. Но наборы свойств со временем становятся очень большими, начинают пересекаться нетривиальным образом. И в структуре классов появляются миксины и прочее множественное наследование.
Внести изменения в глубине иерархии становится проблематично, приходится думать заранее о «внедрении зависимостей», разрабатывать и использовать сложные инструменты рефакторинга.
Возможно ли всего этого избежать? Стоит попытаться — пусть методы будут привязаны к множеству характерных свойств объекта (тегов), а иерархия наследования выстраивается автоматически по вложенности этих множеств.
Пусть мы разрабатывает иерархию для игровых персонажей. Часть кода будет общая для всех персонажей — она привязана к пустому набору свойств. Код, отвечающий за их отображение будет представлен в виде вариантов для OpenGL и DirectX разных версий. Что-то будет зависеть от расы персонажа, что-то от наличия и вида магических способностей и тп. Теги персонажа первичны. Они перечисляются явно, а не наследуются. А реализация наследуется в зависимости от набора тегов (по вложенности). Таким образом умение стрелять из ПЗРК не окажется у кенгуру, потому что его унаследовали от пехотинца.
Идея такого подхода была предложена Дмитрием Кимом. Автор не стал ее воплощать в код, я попробую исправить это упущение.
Реализация такого подхода на Clojure, как обычно, на github.
Реализация этого метода наследования сделана поверх системы обобщенных функций (мультиметодов) Clojure.
С каждым мультиметодом, определенным с помощью defmulti, связана иерархия и функция диспечеризации, которая отображает аргументы в элементы (или массив элементов) иерархии. Обычно элементами иерархии являются типы данных, но в своих иерархиях можно использовать так же «ключевые слова» и «символы», которыми будут отмечены данные, отнесенные к нужному типу.
Конкретная реализация метода для элемента иерархии задается с помощью defmetod.
Выглядит это так:
(use 'inheritance.grid)
(def grid (make-grid-hierarchy))
(defmulti canFly "персонаж может летать" (grid-dispatch1) :hierarchy #'grid)
(defmulti canFireball "персонаж может пускать файрболы" (grid-dispatch1) :hierarchy #'grid)
(defmulti canFire "персонаж может поджечь" (grid-dispatch1) :hierarchy #'grid)
(defmethod canFly (get-grid-node {} #'grid) [p] false)
; поумолчанию персонажи не летают
(defmethod canFly (get-grid-node {:magic :air} #'grid) [p] true)
; владеющие магией воздуха - летают
(defmethod canFly (get-grid-node {:limbs :wings} #'grid) [p] true)
; крылатые летают
(defmethod canFireball (get-grid-node {} #'grid) [p] false)
; поумолчанию персонажи не пускают файрболы
(defmethod canFireball (get-grid-node {:magic :fire, :limbs :hands} #'grid) [p] (> (:mana p) 0))
; владеющие магией огня и имеющие рука - пускают, если есть мана.
(defmethod canFire (get-grid-node {} #'grid) [p] false)
; огнем, поумолчанию, ни кто не владее
(defmethod canFire (get-grid-node {:limbs :hands} #'grid) [p] true)
; рукастые могут развести огонь
(defmethod canFire (get-grid-node {:magic :fire} #'grid) [p] (> (:mana p) 0))
; владея магией руки иметь не обязательно
(defmethod canFire (get-grid-node {:magic :fire, :limbs :hands} #'grid) [p] true)
; магия и руки совместимы - Clojure боится перепутать причину, по которой дано это свойства
(def mage ((with-grid-node {:magic :fire, :limbs :hands :race :mage} #'grid) {:mana 100, :power 5}))
(def barbar ((with-grid-node {:magic :none, :limbs :hands :race :human} #'grid) {:power 500}))
(def phoenix ((with-grid-node {:magic :fire, :limbs :wings :race :mage} #'grid) {:mana 200, :power 4}))
(def elf ((with-grid-node {:magic :air, :limbs :hands :race :elf} #'grid) {:mana 300, :power 13}))
(canFire elf)
; true
(canFireball elf)
; false
(canFly elf)
; true
(canFly mage)
; false
(canFire mage)
; true
Как это устроено:
Для начала надо создать иерархию — это будет обычная иерархия Clojure, с таблицей, отображающей набор тегов (в виде ассоциативного массива) в участующий в иерархии символ. Таблица изначально пустая и хранится в метаинформации объекта-иерархии.
(defn make-grid-hierarchy "Создание новой решеточной иерархии" []
(let [h (make-hierarchy)]
; это стандартная иерархия
(with-meta h (assoc (or (meta h) {}) :grid-hierarchy-cache {}))))
; но с метаинформацией о решеточной структуре
Каждый используемый набор тегов должен быть зарегистрирован в иерархии — для него создан и включен в правильное место иерархии символ, и дабавлена соответствующая запись в таблицу, что бы этот символ можно было найти. Вычисление правильного места в иерархии — основа этого метода управления наследованием.
(defn register-grid-node "Регистрация нового узла решетки в иерархии" [h o]
(let [nl (get (meta h) :grid-hierarchy-cache {})]
(if-let [s (nl o)]
; а не зарегистрирован ли он уже
[h s]
; тогда возвращаем старую иерархию и символ узла
(let [s (symbol (str o))
; новый узел - создадим ему символ
hn (reduce (fn [h [tr n]]
; пройдем по существующим узлам
(if (and (subobj? tr o)
; а не надо ли этот узел унаследовать от нашего
(not (isa? h s n)))
; Clojure нервно реагирует на попытку регистрации связи,
; которую сама может вывести
(derive h s n)
(if (and (subobj? o tr) (not (isa? h n s)))
; или наш узел унаследовать от этого
(derive h n s)
h)))
h nl)]
[(with-meta hn
; добавляем метаинформацию о новом узле в обновленную иерархию
(assoc (or (meta h) {})
:grid-hierarchy-cache (assoc nl o s)))
s]))))
; и возвращаем вместе с символом нового узла
Теперь надо научиться связать тип из некоторого узла решетки, задаваемый набором тегов, с данными, которые, мы считаем, принадлежат этому типу.
(defn with-grid-node "создает функцию, добавляющую метаинформацию об узле к объекту" [n h]
(let [s (get-grid-node n h)]
(fn [v]
(with-meta v (assoc (or (meta v) {}) :grid-node s)))))
Что бы избежать повторных поисков по таблице узлов, эта функция получает символ, соответствующий узлу, и возвращает замыкание, добавляющее этот символ в метаинформацию своего аргумента.
Функции диспечеризации получаются простые.
(defn grid-dispatch "Создает диспетчер по всем аргументам метода"
[] (fn [& v] (vec (map (fn [a] (:grid-node (meta a))) v))))
(defn grid-dispatch1 "Создает диспетчер по первому аргументу"
[] (fn [v & _] (:grid-node (meta v))))
Такое наследование я уже пробовал реализовать на Common Lisp. Но устройство MOP я не знаю, и та реализация не встроена в CLOS и не слишком эффективна.
Автор: potan