Решетчатое наследование

    Наследование, при кажущейся простоте, часто приводит к сложным, сопротивляющимся изменениям структурам. Иерархии классов растут как самый настоящий лес.
    Целью наследование является привязка кода (набора методов) к минимальному набору свойств сущности (как правило — объекта), которые он обеспечивает и которые ему требуются. Это упрощает повторное использование, тестирование и анализ кода. Но наборы свойств со временем становятся очень большими, начинают пересекаться нетривиальным образом. И в структуре классов появляются миксины и прочее множественное наследование.
    Внести изменения в глубине иерархии становится проблематично, приходится думать заранее о «внедрении зависимостей», разрабатывать и использовать сложные инструменты рефакторинга.

    Возможно ли всего этого избежать? Стоит попытаться — пусть методы будут привязаны к множеству характерных свойств объекта (тегов), а иерархия наследования выстраивается автоматически по вложенности этих множеств.

    Пусть мы разрабатывает иерархию для игровых персонажей. Часть кода будет общая для всех персонажей — она привязана к пустому набору свойств. Код, отвечающий за их отображение будет представлен в виде вариантов для 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 и не слишком эффективна.
    Поделиться публикацией

    Комментарии 34

      +1
      Если правильно понял суть статьи, в ООП языках примеси (mixin) являют собой очень похожую концепцию
        0
        Похожую, только требующую явного использования. Я пытаюсь это сделать более автоматизировано.
        +3
        Если разработчик наследует кенгуру от пехотинца, то даже не знаю, что тут может помочь.
          0
          Мне кажется, что я знаю — хочу проверить. :-)
          0
          Жуткий синтаксис, куча странных повторений. По моему мощь лиспа в том, чтобы не писать на птичьем языке, а в возможности определить свой DSL и с его помощью уже описать то, что нужно, максимально лаконично.
          У меня тут есть идея, сделать «двумерный» диалект лиспа.

          Например, вот этот код:
          (defmethod canFireball (get-grid-node {:magic :fire, :limbs :hands} #'grid) [p] (> (:mana p) 0))
          ; владеющие магией огня и имеющие рука - пускают, если есть мана.
          


          Мог бы выглядеть куда более дружелюбно:
          !func can
          	doc =владеющие магией огня и имеющие рука - пускают, если есть мана.
          	>
          		action fireball
          		obj
          			magic fire
          			limbs hands
          			mana int
          		power int
          	< is-desc
          		obj/mana
          		power
          


          И пример использования:
          !obj alice
          	descr =Алиса - это слабенький такой маг огня
          	magic fire
          	limbs hands
          	mana int =10
          
          # выводим в консоль может ли Алиса кинуть минимальный огненный шар
          < can
          	action fireball
          	obj alice
          	power int =5
          


          Мне кажется, избавление от бесконечных скобочек и прочей пунктуации в пользу говорящих имён, хоть и сделает код слегка длиннее, но он будет легче восприниматься, особенно новичками. И тогда идеи лиспа смогут найти место в головах у широкой аудитории. Что вы думаете на этот счёт?
            +2
            Не уверен, что ваш вариант выглядит более дружелюбно.
              +2
              А я вот, как человек, специализирующихся на более популярных языках, вижу исходный вариант, как какое-то заклинание, которое совершенно не понятно что делает. DSL всё же должен быть ближе к предметной области.
                0
                Уточню: мне на самом деле не нравится ни оригинал, ни ваша замена для него :) Но если выбирать из двух зол, то…
              0
              Синтаксис — дело вкуса. Честно говоря я к синтаксису более-менее равнодушен, лишь бы он был не слишком многословным и не слишком сложным. Но если будут идеи, удобные разработчикам — попробую их реализовать.
              А в чем Вы видите повторения? Результат get-grid-node можно сохранить в переменную с понятным именем и повторно использовать.
                0
                Постоянно повторяется мантра «get-grid-node {} #'grid».
                  0
                  Это фактически имя типа. Можно ему создать синоним
                  (def FireHands (get-grid-node {:magic :fire, :limbs :hands} #'grid))
                  
                  (defmethod canFireball FireHands [p] (> (:mana p) 0))
                  
                  (def simpleMage (withMeta {:grid-hierarchy-cache FireHands} {:mana 100}))
                  
                    0
                    Ничего не поменялось, только кода стало больше…
                      +1
                      Следующий шаг — написать макрос, создающий тип и функцию-конструктор:
                      (defmacro def-grid-node [name props grid] ...)
                      
                      (def-grid-node FireHands {:magic :fire, :limbs :hands} #'grid)
                      
                      (defmethod canFireball FireHands [p] (> (:mana p) 0))
                      
                      (def simpleMage (->FireHands {:mana 100}))
                      
                      
                        0
                        Сделал :-)
                +1
                Но наборы свойств со временем становятся очень большими, начинают пересекаться нетривиальным образом.
                Внести изменения в глубине иерархии становится проблематично, приходится думать заранее о «внедрении зависимостей», разрабатывать и использовать сложные инструменты рефакторинга.

                Возможно ли всего этого избежать?

                Тут вы ставите правильный вопрос. Действительно, если у нас что-то сложное, то может мы ошиблись на каком-то шаге и придумали нежизнеспособный механизм? Вы предлагаете сделать шаг по улучшению этого механизма.
                С моей точки зрения естественное затруднение здесь в головах, а именно в желании сделать доступным все методы «через одну точку». Это желание пихать разные обязанности в один объект приводит к трудностям, которые вы описали. Это же желание погубит и описанный вами метод, хотя он, безусловно, может иметь какие-то преимущества в читаемости. Я считаю тут нужно сделать не один шаг, а два, а именно отказаться от наследования в пользу композиции. Разбивать объект на подсистемы. Тогда пересечение между разнотипными интерфейсами в рамках одного объекта просто исчезнет как явление.
                  0
                  Эта идея родилась из обсуждения одного проекта, сделанного на CMS (не язык программирования) с наследованием (оформление и контент странички сайта наследовались). Структура разделов сайта была двумерной, и я решил задачу используя множественное наследование. Мой оппонент хотел избавится от множественного наследования, но у нас не получилось. Тогда он предложил эту схему. Правда она так и не была реализована.
                  Потом я попытался перенести подход в область программирования. Не думаю, что это панацея. Но мне кажется, что этот метод будет удачным для систем с большим числом классов с тонкими отличиями (как типы юнитов в играх) или в библиотеках, позволяющих делать тонкую настройку (выбирать способ управления памятью, управления конкуреннтностью и тп). В этих задачах можно использовать миксины, но они могут сделать иерархию запутанной.
                  А Принцип Подстановки Лисков ни кто не отменял. И, по моему, следовать ему здесь будет удобнее, чем при классическом явном наследовании.
                  +4
                  Насколько я понял, идея заключается в диспетчеризации методов по набору мета-свойств объекта. Каждый объект помимо своих свойств получает мета-свойства «класса»: феникс из примера не просто некое существо с заданной силой и маной, это магическое существо с крыльями, владеющее магией огня.

                           раса     магия   конечности  материал  есть ручка для переноски?
                  маг      маг      огонь   руки        -         -
                  варвар   человек  -       руки        -         -
                  феникс   маг      огонь   крылья      -         -
                  эльф     эльф     воздух  руки        -         -
                  чайник   -        -       -           железо    да
                  тарелка  -        -       -           фарфор    нет
                  голем    голем    -       руки        железо    -
                  


                  Поиск метода происходит по набору мета-свойств объекта, сам метод выполняется над свойствами объекта. Невозможно выполнить метод над объектом, который не предназначен для этого.
                  Эльф не расплавится при температуре 1540 °C как чайник (и голем). Железный голем не может летать, но может развести огонь. У кенгуру нет рук, он не сможет стрелять из ПЗРК.

                  Преимущество представленного метода по сравнению с миксинами или какой-либо формой множественного наследования заключается в декларативном описании мета-свойств объектов и в безопасном выполнении любой функции над любым объектом (при условии адекватного ничего-не-делающего поведения по-умолчанию).
                    0
                    Да, все так.
                    0
                    Вопрос, как обрабатываются различные действия у различных классов? Например, у мага-человека на файрбол уйдет 10 маны, а у мага-эльфа — 20?
                      0
                      Сделать специализированный по расе метод, который возвращает стоимость заклинания.
                      (defmulti fireballCost (grid-dispatch1) :hierarchy #'grid)
                      
                      (defmethod fireballCost (get-grid-node {:race :human} #'grid) [p] 10)
                      (defmethod fireballCost (get-grid-node {:race :elf} #'grid) [p] 20)
                      
                      +2
                      Возможно ли всего этого избежать?
                      Да, и вы на самом деле движетесь в правильную сторону. Но деле в том, что это всё уже сделано за нас. Computer Science, благо, не стоит на месте. Современные языках программирования (Rust, Haskell, Swift, Scala и пр.) предоставляют более элегантные средства полиморфизма, чем традиционное для ООП наследование (которое вобще анти-паттерн и заноза в заднице). Рекомендую ознакомится с концепцией тайпклассов.
                        0
                        Мне многие уже немекали на сходство этого наследования и классами типов. Я вынужден был задуматься наж этим.
                        Но пришел к выводу, что это разные вещи и одно другого не заменил.
                        Классическое наследование обычно проще, чем классы типов и помогает повторному использованию, позволяя расширять (и иногда сцеплять) существующие типы данных. Классы типов позволяют объединять под общим интерфейсом несвязанные между собой типы данных. Одно другого на мой взгляд полностью не заменяет.
                        На классах типов можно реализовать некоторые подходы классического ООП, но порождение реазизаций класса типа на основе интерфейсов других классов типов, а не структур данных (аналог наследования), все равно порождает жесткую иерархию, встроится в середину которой будет проблематично.
                        +1
                        (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 боится перепутать причину, по которой дано это свойства
                        


                        Коммент под строкой?
                        За что вы так со мной?
                          +1
                          Такое наследование я уже пробовал реализовать на Common Lisp. Но устройство MOP я не знаю, и та реализация не встроена в CLOS и не слишком эффективна.
                          А вам не диспетчеризация по предикату нужна? Если её прикрутить в CLOS, то там можно выразить какие угодно условия для применения методов.
                            0
                            На основе аналога 'isa?'? Не обязательно. Достаточно разобраться как менять предков у ранее описанных классов.
                              0
                              Нет. Чтобы на методы обобщённых функций можно было навешивать дополнительные условия применимости. Для каждого вызова фактически применяться будут только те методы, для которых выполяются установленные для них условия.

                              Что-то вроде:
                              (defpmethod can-fire? ((char <character>))
                                (:when (eq 'hands (limbs char)))
                                t )
                              
                              Этот метод can-fire? будет вызываться, если ей передадут экземпляр класса <character>, у которого в слоте limbs лежит значение hands. Иначе этот код вообще не будет выполнен и управление пойдёт к следующему потенциально применимому методу.

                              Мне кажется, такой подход выражает вашу идею на более общем уровне.
                                0
                                А если предикат не выполняется — переходить к суперклассу?
                                  0
                                  К следующему методу; как будто бы этот метод просто сделал (call-next-method). CLOS допускает, что несколько методов могут быть применимы к набору аргументов. Все они упорядочиваются по некоторым правилам и комбинируются в эффективный метод.

                                  Например, стандартные методы упорядочиваются в соответствии с иерархией наследования классов аргументов (методы суперклассов выполняются после методов подклассов) и могут иметь один из квалификаторов:
                                  • around: выполняются первыми; должны вызвать (call-next-method), чтобы выполнить все остальные методы;
                                  • before: выполняются после around; все before-методы выполняются в поряке убывания конкретности классов аргументов;
                                  • primary: квалификатор по умолчанию; выполняется только один primary-метод, для самого конкретного набора аргументов, но он может вызвать (call-next-method), чтобы выполнить следующий менее конкретный метод (для суперклассов аргументов);
                                  • after: выполняются после primary; все after-методы выполняются в поряке возрастания конкретности классов аргументов;
                                  В цепочке должен быть как минимум один primary-метод, остальные опциональны.

                                  В вашем случае будет то же самое. Если не выполняется какой-то предикат, то переходим к следующему методу: это будет или метод такого же уровня конкретности, но с другим предикатом, или же метод для суперклассов, если методы одного уровня закончились. Плюс, по идее, необходимо дополнительное условие, что хотя бы для одного primary-метода предикат возвращает истину.
                          • НЛО прилетело и опубликовало эту надпись здесь
                              0
                              > Наследование, при кажущейся простоте, часто приводит к сложным, сопротивляющимся изменениям структурам.

                              ; поумолчанию персонажи не летают

                              Скажите, сколько кода надо перелопатить, чтобы инвертировать поведение? Вы проблему то не решили.

                              Мне кажется (в Clojure я ничего не понимаю) вы «configuration programming» переизобрели, только зачем-то наследование оставили.
                                0
                                Что значит «инвертировать поведение»? Просто переопределить метод как в примере не достаточно?
                                  0
                                  Оказалось, что у вас летает 99% персонажей, для всех переопределять метод?
                                    0
                                    Поменять реализацию в корне (узле с пустым набором тегов) и переопределить для тех, кто летать не может.
                                    Или добавить тег «overland», для которого сделать метод с запретом полетов и добавлять его к нелетающим персонажам.
                                      0
                                      и останется куча перегрузок у существующих персонажей, умеющих летать

                              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                              Самое читаемое