Статьи

Замена наследования композицией

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

Код будет на языке Java, но концепции могут быть перенесены на любой объектно-ориентированный язык. Некоторые языки могут иметь конструкции, которые также упрощают эту часть (Kotlin’s Delegates).

Однако важно помнить одну вещь: даже если вы «полностью» переключитесь на композицию, у вас все равно останется наследство. Это наследование будет только от интерфейсов и интерфейсоподобных классов. На самом деле это едва ли считается наследованием, поскольку все, что он делает, ограничивается API, а не наследует детали реализации.

Основная композиция: шаблон делегата

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

Что ж, давайте начнем с интерфейса, который будет использовать наш код. Мы будем использовать Foo в качестве интерфейса.

1
2
3
4
5
public interface Foo
{
   void bar();
   int baz(int i);
}

Теперь нам нужна базовая реализация, от которой мы будем пытаться унаследовать, прежде чем конвертировать отношение в композицию. Мы назовем это BasicFoo .

01
02
03
04
05
06
07
08
09
10
11
12
public class BasicFoo implements Foo
{
   public void bar()
   {
      System.out.println("method one called");
   }
 
   public int baz(int i)
   {
      return i * 2;
   }
}

Наконец, нам нужен класс, который наследуется от BasicFoo . Мы назовем это ComplexFoo .

1
2
3
4
5
6
7
8
public class ComplexFoo extends BasicFoo
{
   @Override
   public int baz(int i)
   {
      return super.baz(i) * 8;
   }
}

По большей части, ComplexFoo просто наследует реализацию BasicFoo , но он выполняет проверку входящих аргументов в baz() .

Итак, как бы выглядело изменение ComplexFoo для использования композиции вместо наследования? Как это!

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
public class ComplexFoo implements Foo
{
   private Foo wrapped;
 
   public ComplexFoo(Foo wrapped)
   {
      this.wrapped = wrapped;
   }
 
   public void bar()
   {
      wrapped.bar();
   }
 
   public int baz(int i)
   {
      return wrapped.baz(i) * 8;
   }
}

Вместо наследования от BasicFoo , ComplexFoo наследует только от интерфейса Foo . Чтобы получить доступ к базовой функциональности, такой как BasicFoo , он принимает объект Foo в своем конструкторе, а затем делегирует все методы этому объекту, добавляя дополнительную функциональность до или после вызова делегирования, просто вы бы, если бы Вы «делегировали» через super .

Ограничение делегата

Что если вы хотите, чтобы ComplexFoo мог только «расширять» BasicFoo а не другие базовые реализации? Во-первых, это очень ограничительно, так что, вероятно, не должно быть так. Но если у вас есть веские причины для этого, у вас есть несколько вариантов.

Первый вариант — принимать только BasicFoo в конструкторе. Если BasicFoo является final (не может быть расширен; на самом деле это очень рекомендуется, особенно если вы в основном делаете композицию), это работает, но если это не так, вы можете получить что-то, что наследуется от BasicFoo а не фактический объект BasicFoo .

Второй вариант — жестко закодировать его, чтобы сделать BasicFoo в конструкторе. Просто увидев слово «жесткий код», вы поняли, что это не рекомендуемая процедура.

Мой последний вариант — сделать конструктор private или private пакета, все еще принимая любой объект Foo , и иметь статический метод фабрики, создать экземпляр BasicFoo и передать его в конструктор. Таким образом, вы можете пройти двойную проверку Foo в своих тестах, если BasicFoo может быть проблематичным для тестов.

Замена шаблона шаблона

Самый простой способ заменить шаблон шаблона — использовать шаблон стратегии. Часто мне нравится думать, что у объекта Strategy есть только один публичный метод, но это в основном потому, что он позволяет вам легче преобразовать его в лямбда-ссылку или ссылку на метод. По правде говоря, шаблон стратегии позволяет объектам стратегии реализовывать множество методов для реализации интерфейса своей стратегии. Вы можете не страдать от такого взгляда на шаблон, но я подумала, что поделюсь им с вами, на всякий случай.

Я полагаю, вам нужен пример, а?

01
02
03
04
05
06
07
08
09
10
11
12
13
public abstract class Bar
{
   public int foo(int i)
   {
      i = bar(i);
      i = baz(i);
      return qux(i);
   }
 
   abstract protected int bar(int i);
   abstract protected int baz(int i);
   abstract protected int qux(int i);
}

Bar использует шаблон шаблона, оставляя некоторые методы protected чтобы подклассы заполняли их для использования public методами Bar . Я надеюсь, что мне не нужно приводить пример подкласса; если вы не уверены, что такое шаблон, пожалуйста, посмотрите его.

Давайте превратим этот шаблон в шаблон стратегии:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
public class Bar
{
   private BarStrategy strategy;
 
   public Bar(BarStrategy strategy)
   {
      this.strategy = strategy;
   }
 
   public int foo(int i)
   {
      i = strategy.bar(i);
      i = strategy.baz(i);
      return strategy.qux(i);
   }
}

У нашего нового класса Bar больше нет protected методов. Вместо этого он принимает BarStrategy и делегирует ему эти вызовы.

Первоначально у меня был реальный пример этой идеи, но потом я понял, что это был паттерн стратегии. После этого я подумал, что любой пример на самом деле не будет таким уж полезным, поскольку любой пример шаблона стратегии — это пример шаблонно созданного шаблона.

Прибыль

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

Самая важная вещь, которая помогает переключаться на композицию вместо наследования, — это отсутствие того, что я люблю называть «адским комбинацией». Это самая большая проблема, с которой был разработан Pattern Decorator, но любой переход на композицию помогает в этом. Вы придумали расширение класса, которое обеспечивает определенную дополнительную функциональность вокруг базовой функциональности. Затем вы придумаете еще один с другой функциональностью. В конце концов, вы понимаете, что хотите класс с обоими. Если вы сделали это с наследованием, вам нужно создать другой класс, который как-то объединяет их. Если вы использовали композицию, у вас есть все, что вам нужно, поскольку у вас может быть только одно расширение, которое принимает, переносит и делегирует другому (что оборачивает базовый класс).

Еще одно большое преимущество использования композиции было упомянуто ранее: тестируемость. Теперь вы можете протестировать только новые функциональные возможности класса, сделав двойной тест для замены «супер» класса. Эта тестируемость является следствием лучшего следования SRP.

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

Избегать некоторой явности

Я уже упоминал выше, что для композиции требуется немного больше ясности, чем для наследования, поскольку она требует, чтобы вы заполняли все методы вызовами делегирования. Я упомянул, что некоторые языки предоставляют функции, позволяющие вам быстро обойти эту проблему, но вы можете обойти это на языках без таких функций, если хотите расширить мое значение «интерфейсоподобных классов», которые я использовал выше. Поскольку мой любимый шаблон, шаблон Decorator, использует его, я готов его растянуть.

Я имею в виду создание абстрактного класса, целью которого является делегирование. Это базовый класс Decorator в шаблоне Decorator. Это полностью проработанный — но абстрактный — класс, который технически ничего не делает, так как он делегирует все свои вызовы обернутому объекту. У меня нет сомнений в создании такого класса для наследования. Поскольку он по сути ничего не делает, его можно рассматривать как «подобный интерфейсу». Самое приятное в этом то, что вы можете наследовать от него, и если вы не вносите никаких изменений в метод, вам не нужно его включать.

Это не только приводит к более короткому коду, но также следует SRP лучше, чем составление уже было сделано: один класс используется для явного делегирования, другие — для ТОЛЬКО добавления новой функциональности (и явного делегирования при необходимости). Это также удаляет дублирование кода. Это определенно стоит наследования «сцепления».

Outro

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