«Состав предпочтения по наследованию», обычно приписываемый Книге « Шаблоны проектирования» 1994 года группой очень умных людей, известных как «Банда четырех», часто дается как твердый совет по разработке программного обеспечения. В этой статье я попытаюсь объяснить, почему и как вы должны отдавать предпочтение композиции, а не наследованию, и, в частности, как вы можете адаптировать ее к своему коду Ruby.
Так что не так с наследованием
Для целей этой статьи я предполагаю, что вы все такие же плохие программисты, как и я. Я также собираюсь предположить, что вы совершаете подобные ошибки и попадаете в похожие путаницы, поскольку проект переходит от разработки «зеленого и зеленого» месторождения к «о-о-о-о-лучшем» к «о-не-почему-сделал-я-» режим обслуживания «сделай это». Так что, если это звучит как часть исповеди, это потому, что я сделал все эти ошибки, чтобы вам не пришлось (снова).
Моделирование реального мира
Многие из моих головных болей в разработке возникли из-за неправильного понимания того, что такое наследование и почему вы должны его использовать (и, действительно, я уверен, что собираюсь предложить здесь неправильное определение). Начинающий программист — будь то на Ruby, Java или чем-то еще — испытывает искушение взглянуть на наследование и подумать «аккуратно, я делаю Foo
наследовать от Bar
и получаю все функции Bar
бесплатно». И это правда, Foo < Bar
сделаю именно это. То, что это не совсем улавливает, это то, что во всех программах, кроме самых маленьких, говорится, что «Foo is a Bar» действует только в течение ограниченного времени.
В конце концов, Foo и Bar будут расходиться настолько, что правильное утверждение будет больше похоже на «Foo разделяет некоторые особенности Bar». К настоящему времени, конечно, уже слишком поздно, и вы занимаетесь 3 месяца в проекте и застряли в двух тесно связанных классах, которые трудно проверить и которые трудно разделить.
Часто проблемы наследования проявляются в середине проекта или в случае изменения требований (и они будут). Это связано с тем, что отношение, которое выглядит так, как будто оно может быть смоделировано только как отношение «есть», может быть выражено как отношение «есть» — и отношение «есть» намного более гибкое.
Пример
Давайте посмотрим пример. Скажем, мы пытаемся смоделировать мир развития. У нас есть Developers
и Testers
. Можно разумно ожидать, что любое текстовое поле для новичка предложит что-то подобное для структуры класса.
class Person | |
attr_accessor :name | |
def initialize(name) | |
@name = name | |
end | |
end | |
class Employee < Person | |
attr_accessor :salary | |
def pay | |
«paid this months salary: £#{@salary}« | |
end | |
def drink_coffee | |
‘glug glug’ | |
end | |
end | |
class Developer < Employee | |
def make_bugs | |
«#{@name} does some typing» | |
end | |
end | |
class Tester < Employee | |
def test | |
«#{@name} does some testing» | |
end | |
end |
Возможно, у каждого сотрудника есть @salary
а у Tester
есть метод test
, а у Developer
есть метод make_bugs
. Теперь нам нужно добавить еще один класс, дружественный Intern
. make_bugs
такого типа, как утверждают в спецификации, могут делать make_bugs
и test
. Они также не берут @salary
— будучи фактически рабским трудом. Intern
— это почти сотрудник, почти Developer
и почти Tester
и нет четкого места, в которое они могли бы вписаться в иерархию классов.
В данном случае мы начинаем с того, что говорим, что Developer
— это Employee
что некоторое время было правдой, но теперь мы обнаружили, что поведение разработчика лишь иногда является подтипом поведения сотрудника. У нас есть два варианта здесь. Мы можем изменить наше выражение класса, чтобы у Developer
была какая-то Employment
— эффективно превращающая метод оплаты в отношение «есть». В качестве альтернативы мы могли бы превратить задачу, выполняемую Developer
в отношение «есть». Здесь вступает композиция.
Композиция на помощь
Традиционная композиция, которая работает практически одинаково на почти всех языках ОО, включает перемещение тех функций, которыми мы хотим поделиться, в класс самостоятельно. Итак, у нас было бы что-то вроде.
class Code | |
def initialize(person) | |
@person = person | |
end | |
def make_bugs | |
«#{@person.name} does some typing» | |
end | |
end | |
class Developer < Employee | |
def make_bugs | |
Code.new(self).make_bugs | |
end | |
end |
Ruby также предоставляет второй способ получить тот же эффект: модули. Используя модули, мы можем внедрить эту функцию создания ошибок в любой класс Person
, просто написав миксин.
module BugMakingSupport | |
def make_bugs | |
«#{@person.name} does some typing» | |
end | |
end | |
class Developer < Employee | |
include BugMakingSupport | |
end |
Мы можем сделать то же самое для функциональности тестирования. Теперь наш Intern
может быть просто Person
который обладает способностью кодировать и тестировать.
Как мы видели здесь, наследование — не единственный доступный вам метод совместного использования кода, и во многих случаях это не лучший выбор. Надлежащее использование наследования должно быть сосредоточено на том, чтобы представлять отношения между объектами в «реальном мире», а не на тяжелой работе совместного использования кода.
Белые коробки
«Белый ящик» и его двойник «Черный ящик» — это термины, используемые в самых разных областях и означающие множество разных вещей. Когда мы говорим о цвете блока с точки зрения повторного использования кода, в частности наследования, мы действительно говорим о видимости.
Класс белого ящика, в котором все его методы общедоступны (игнорируя тот факт, что в Ruby мы можем видеть невидимое с помощью хитрого вызова send
), то есть мы часто можем изменять данные в объекте независимо от того, делаю операцию внутри класса или снаружи.
Черный ящик не позволяет этому виду доступа к своему внутреннему состоянию. Он предоставляет определенный набор методов ввода и определенный набор вывода. То, что происходит посередине, не должно нас беспокоить. В строго типизированных языках эти входные и выходные данные обычно кодируются с использованием чего-то вроде Interface
Java
В Ruby, в отличие от Java, нет способа защитить методы родительского класса от использования в подклассе. Это означает, что каждый подкласс имеет полную видимость своего родительского класса. Это, в свою очередь, заставляет нас заботиться о данной родословной классов, когда мы ее используем.
Поскольку у нас есть эта видимость, у нас также есть большое искушение переопределить и манипулировать методами от нашего родителя. Это нормально в большинстве, если не во всех случаях, но это означает, что у нас теперь есть контракт между реализацией родителя и реализацией дочернего элемента. Теперь мы больше не можем менять одно без изменения другого.
Заворачивать
Подумайте о своих модульных тестах, в частности о своих модульных тестах ActiveRecord. ActiveRecord делится своими возможностями благодаря наследованию ActiveRecord::Base
. Скорее всего, если вы не использовали что-то вроде Mocha, чтобы значительные результаты, значительные изменения в ActiveRecord сломали бы ваш тест (не говоря уже о вашем коде). Это отличный пример того, где наследование, пожалуй, не самый подходящий выбор. Ваша модель User
не относится к типу ActiveRecord::Base
и не должна претендовать на это — и нет особого смысла тестировать сохранение записи в тестовой базе данных, когда вы можете быть почти уверены, что при передаче активной записи в любом случае вы будете использовать хорошо проверенный код.
Таким образом, сохраняйте модель User
наследующую от активной записи, — но реализуйте ключевые функции, которые должны быть общими или унаследованными, как небольшие изолированные классы или модули, которые. Бизнес-правила о User
должны быть переплетены с беспорядочной работой оператора sql munging. вместе.
Предпочитая композицию перед наследованием, как способ совместного использования кода, вы заставите вас структурировать свое приложение таким образом, чтобы создавать больше классов (или модулей), которые служат одной цели. По сути, вы будете менять большую реализацию whitebox на многие реализации blackbox.
Как всегда, я буду следить за комментариями, и если я был неясен или действительно ошибался во всем, что хотел бы услышать об этом. Вы недавно начали использовать композицию более тщательно? Вы не согласны с тем, что наследование не должно быть предпочтительным методом совместного использования кода? Можете ли вы привести какие-либо хорошие примеры, иллюстрирующие всю дилемму?