Блог CREATIVE

Композиция vs Наследование

Статьи Разработка



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


И хотя довольно часто может казаться, что два класса делают одно и тоже, это не означает, что один из них может быть наследником другого. И «человек», и «утка» могут крякать, но это ведь не означает, что они родственники? Что же делать, когда ну уж совсем не хочется дублировать один и тот же код в разных частях программы?


В качестве примера плохого наследования рассмотрим такой вариант:


class Vehicle
attr_reader :power
def do_whroom # делает Вруум!
# процедура подготовка двигателя для вруум!
"#{power}hp Whroom!"
end
end
class ElectroVehicle < Vehicle
def do_whroom # переопределяем метод и делает Зуум!
# процедура подготовка двигателя для зуум!
"#{power}hp Zoom!"
end
end
class Car < Vehicle
def beep # делает Бип!
# готовимся к бип!
"Beep!"
end
end
class ElectroCar < ElectroVehicle
def beep # делает Бип!
# готовимся к бип!
"Beep!"
end
end
class Airplane < Vehicle
def fly
# летим!
end
end
class Ford < Car
def initialize
@power = 100
end
end
class BMW < Car
def initialize
@power = 200
end
end
class Tesla < ElectroCar
def initialize;
@power = 150
end
end
class Boing < Airplane
def initialize
@power = 1000
end
end


У нас есть класс Vehicle (средство передвижения на ДВС), которое умеет издавать звук («Whroom!»). Так же у нас есть класс ElectroVehicle (средство передвижения на электродвигателе), который также умеет издавать звук («Зуум!»). Еще у нас есть наследники этих классов: Car (автомобиль), ElectroCar (электромобиль) и Airplane (самолет). Car и ElectroCar кроме прочего умеют издавать предупреждающий сигнал («Beep!»), а Airplane умеет летать. Также у нас есть несколько реализаций этих классов: автомобили Ford и BMW, электромобиль Tesla и самолет Boing. С первого взгляда может показаться, что все вышло довольно-таки неплохо, но это не так:

Мы имеем дублирование кода в классах Car и ElectroCar (метод beep). Это произошло из-за того, что мы не смогли разместить метод beep в общем предке Vehicle, потому что не все Vehicle умеют издавать предупреждающий сигнал.Классом ElectroVehicle мы переопределили, а не расширили метод do_whroom родителя. Тем самым мы нарушили принцип подстановки Барбары Лисков и в дальнейшем, когда в коде программы мы будем работать с экземпляром ElectroVehicle как с обычным Vehicle у нас будут проблемы. Например, наша программа будет ожидать появления строки «Whroom!» после вызова метода do_whroom, но сколько она не будет пытаться получить этот результат от Tesla — на выходе всегда будет «Zoom!»


image


Со временем, мы обнаружим, что электромобили — это не родственники автомобилей, но будет уже поздно. Чем больше мы будем писать кода в Vehicle, тем чаще нам будет необходимо переопределять его в ElectroVehicle.К счастью, есть такая вещь как композиция. Композиция – это техника программирования при которой новые классы создаются путем помещения обобщенных функциональных модулей в объект-контейнер, который впоследствии использует и управляет этими модулями. Если при наследовании говорят, что наследник «является» родителем (Пользователь IS_A Администратор), то при композиции говорят, что один объект «владеет» другим (Пользователь HAS_A РольАдминистратора).


На Ruby простая композиция для описанного выше примера может быть реализована следующим образом:


class GasEngine # бензиновый двигатель
attr_reader :power
def initialize(power)
@power = power
end
def sound
"#{power}hp Whroom!"
end
end
class ElectroEngine # электродвигатель
attr_reader :power
def sound
"#{power}hp Zoom!"
end
end
class Horn # клаксон
def beep
# готовимся к бип!
"Beep!"
end
end
class Ford
def initialize
@engine = GasEngine.new 100
@horn = Horn.new
end
def do_whroom # делает Вруум!
# процедура подготовки двигателя для вруум!
@engine.sound
end
def beep
@horn.beep
end
end
class BMW
def initialize
@engine = GasEngine.new 200
@horn = Horn.new
end
def do_whroom # делает Вруум!
# процедура подготовки двигателя для вруум!
@engine.sound
end
def beep
@horn.beep
end
end
class Tesla
def initialize
@engine = ElectroEngine.new 150
@horn = Horn.new
end
def do_zoom # делает Зуум!
# процедура подготовки двигателя для зуум!
@engine.sound
end
end
class Boing
def initialize
@engine = GasEngine.new 1000
end
def do_whroom # делает Вруум!
# процедура подготовки авиадвигателя для вруум!
@engine.sound
end
def fly
# летим!
end
end


Теперь, т.к. мы полностью отказались от наследования, можно сказать, что мы избавились от проблем 2 и 3. Все это удобно и хорошо, но в тоже время, необходимо понимать, что композиция – это не замена механизма наследования и не хитрый трюк, для реализации множественного наследования в тех языках, где его нет. Композиция – это прежде всего такой же инструмент для написания чистого кода, который также требует правильного и бережного обращения. Проблема 1 никуда не делась. Более того, она приобрела куда более серьезные масштабы.


image


Решение ее кроется в компромиссе между композицией и наследованием, рассмотрим еще один пример:


class EngineBase
attr_reader :power
def initialize(power)
@power = power
end
end
class GasEngine < EngineBase
def sound
"#{power}hp Whroom!"
end
end
class ElectroEngine < EngineBase
def sound
"#{power}hp Zoom!"
end
end
class Horn
def beep
# готовимся к бип!
"Beep!"
end
end
class CarBase
def initialize(engine, horn)
@engine = engine
@horn = horn
end
def beep
@horn.beep
end
end
class GasCar < CarBase
def initialize(power)
super GasEngine.new(power), Horn.new
end
def do_whroom # делает Вруум!
# процедура подготовки двигателя для вруум!
@engine.sound
end
end
class ElectroCar < CarBase
def initialize(power)
super ElectroEngine.new(power), Horn.new
end
def do_zoom # делает Зуум!
# процедура подготовки двигателя для зуум!
@engine.sound
end
end
class Airplane
def initialize(power)
@engine = GasEngine.new(power)
end
def do_whroom # делает Вруум!
# процедура подготовки авиадвигателя для вруум!
@engine.sound
end
def fly
# летим!
end
end
class Ford < GasCar
def initialize
super 100
end
end
class BMW < GasCar
def initialize
super 200
end
end
class Tesla < ElectroCar
def initialize
super 150
end
end
class Boing < Airplane
def initialize
super 1000
end
end


Классов стало больше, но в то же время ответственность каждого класса сокращена. Это действительно удобно и правильно с точки зрения кода – каждый класс отвечает только за свои методы, каждый из них определен в одном единственном месте, их легко поддерживать и развивать, зная, что пока они следуют определенному интерфейсу – ничего не поломается. В Ruby on Rails для класса ActiveRecord::Base есть даже специальный метод composed_of, который обеспечивает подобную реализацию:

class Car < ActiveRecord::Base
composed_of :engine, mapping: %w(engine_power_hp power)
def engine_power
engine.power
end
end


Теперь объекты Car при извлечении из БД с помощью композиции получат свойство engine класса Engine, параметром power конструктора будет передано значение столбца engine_power_hp из БД. Кстати, имя класса rails определит по имени первого параметра метода composed_of. Это пример того, как в rails реализуется принцип CoC — Convention Over Configuration (соглашение важнее настроек). :engine — соответствует классу Engine, :wheel, например, будет соответствовать классу Wheel. Для переопределения этого правила есть специальный параметр class_name, но это уже совсем другая история.


image


Невозможно ответить, на вопрос: «Что лучше, наследование или композиция?», т.к. сама постановка такого вопроса не верна. Да, они служат одной и той же цели, но предназначены для использования в разных ситуациях. Просто заменить одно другим – это неправильно. Тут важен баланс, и, если посмотреть со стороны, этот баланс выглядит вполне естественным. Когда и что выбрать? С некоторыми упрощениями, можно утверждать следующее:

  • Если новый класс по смыслу является тем же, что и существующий, если он делает все тоже самое, но только лучше/быстрее/точнее и, если при этом в любой гипотетической ситуации экземпляр существующего класса легко и безболезненно заменяется экземпляром нового класса (принцип подстановки Барбары Лисков) – смело наследуемся.
  • Если же новый класс по смыслу лишь отдаленно соответствует уже существующему и в основном используется для других целей (пусть даже очень похожих), и уж тем более если часть реализации существующего класса теряет свой смысл или вообще желательно бы скрыть от чужих глаз – используем композицию.