Использование композиции в Kotlin

16 сентября 2021

Узнайте, как композиция делает ваш код на Kotlin более открытым и простым в обслуживании.

Объектно-ориентированное программирование (ООП) вводит концепции, способствующие многократному использованию и открытости кода, защите от непредусмотренных изменений/состояний и сохранению целостности данных, позволяя пользователям моделировать сущности. Но при непреднамеренном использовании эти концепции, которые делают объектно-ориентированное программирование одной из самых популярных парадигм программирования, также могут сделать программное обеспечение уязвимым и трудным в обслуживании.

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

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

В этом туториале вы:

  • Разберетесь в наследовании и композиции;
  • Примените метод, основанный на наследовании, чтобы писать классы, а также узнаете об их недостатках;
  • Изучите шаблоны делегирования;
  • Примените композицию для рефакторинга классов на основе наследования;
  • Узнаете о by, ключевом слове в Kotlin.

В процессе вы ознакомитесь с различными примерами классов и узнаете, как лучше их реализовать.

Пришло время готовить!

Приступаем к работе

Скачайте стартовый проектЗапустите IntelliJ IDEA и выберите Open…. Затем перейдите в папку начального проекта и откройте ее.

Вы увидите классы, сгруппированные по соответствующим пакетам. Для удобства эти пакеты имеют названия, основанные на разделах данного туториала.

 

Каждый пакет содержит файлы *Demo.kt с расширением main(). Самая важная вещь, на которую следует обратить внимание, - это то, что начальный проект содержит множество плохо спроектированных классов, поэтому не используйте его как «вдохновение» - вы будете их рефакторить по мере продвижения.

Наследование

Наследование устанавливает «is-a» отношения между классами. Таким образом, дочерний класс наследует все non-private поля и методы своего родительского класса. В результате чего вы можете заменить родительский класс дочерним.

// 1
abstract class Pizza() {
  abstract fun prepare()
}

// 2
class CheesePizza() : Pizza() {
  override fun prepare() {
    println("Prepared a Cheese Pizza")
  }
}

class VeggiePizza() : Pizza() {
  override fun prepare() {
    println("Prepared a Veggie Pizza")
  }
}

fun main() {
  // 3
  val cheesePizza: Pizza = CheesePizza()
  val veggiePizza: Pizza = VeggiePizza()
  val menu = listOf(cheesePizza, veggiePizza)
  for (pizza in menu) {
    // 4
    pizza.prepare()
  }
}

Если вы соберете и запустите этот файл, вы получите следующий результат:

Prepared a Cheese Pizza
Prepared a Veggie Pizza

Итак, что здесь происходит?

  1. У вас есть абстрактный класс Pizza, содержащий prepare().
  2. CheesePizza и VeggiePizza являются дочерними классами Pizza.
  3. Поскольку дочерний класс является родительским, вы можете использовать CheesePizza или VeggiePizza в любом месте, где вам нужен Pizza.
  4. Даже если cheesePizza и veggiePizza приводят к типу Pizza, prepare() вызовет реализацию соответствующему дочернему классу, демонстрируя полиморфное поведение. Это связано с тем, что объект Pizza определяет операцию, которую вы можете вызвать, тогда как объект, на который имеется ссылка, определяет фактическую реализацию.

Заметка

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

Более того, вы можете переопределить неокончательные доступные методы родительского класса в его дочернем классе. Но вы должны убедиться, что переопределенные методы сохраняют взаимозаменяемость, обеспечиваемую принципом подстановки Барбары Лисков (LSP), о чем вы узнаете в следующем разделе.

Принцип подстановки Барбары Лисков

Суть LSP заключается в том, что подклассы должны заменять свои суперклассы. И для того, чтобы это произошло, контракты, определенные суперклассом, должны выполняться его подклассами. Такие контракты, как сигнатуры функций (имя функции, возвращаемые типы и аргументы), выполняются как ошибки времени компиляции статически типизированными языками, такими, как Java и Kotlin.

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

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

Реализация антипаттернов наследования

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

Наследование от одного класса

Языки Java Virtual Machine, такие как Kotlin и Java, не позволяют классу наследовать более, чем от одного родительского класса.

Распакуйте пакет userservice. Он содержит два класса обслуживания: UserCacheService, который хранит записи User в структуре данных в памяти, и UserApiService, который имеет задержку для моделирования сетевого вызова. На UserMediator пока не обращайте внимания.

Предположим, вам нужно написать класс, который будет взаимодействовать с UserCacheService и UserApiService для получения записи User. Вам необходимо ускорить выполнение операции, поэтому сначала произойдет поиск пользователя в UserCacheService и вывод результата поиска, если он существует. В противном случае вам необходимо выполнить медленный «сетевой» вызов. Когда UserApiService возвращает User, вы сохраняете его в кэше для использования в будущем. Можете ли вы смоделировать это, используя наследование реализации?

// Error: Only one class may appear in a supertype list
/**
 * Mediates repository between cache and server. 
 * In case of cache hit, it returns the data from the cache;
 * else it fetches the data from API and updates the cache before returning the result.
**/
class UserMediator: UserApiService(), UserCacheService() {
}

Во-первых, приведенный выше код не компилируется. И даже если бы это было так, отношения не имели бы смысла, потому что, вместо того, чтобы быть отношениями, UserMediator использует UserCacheService и UserApiService в качестве деталей реализации. Вы увидите, как это исправить, позже.

Тесная связь (Tight Coupling)

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

Таким образом, от вас требуется наличие общих предположений о будущих требованиях. Вам нужно заранее построить иерархию и следить за тем, чтобы отношения оставались неизменными с добавлением каждого нового требования. Таким образом, вам, возможно, придется использовать подход BDUF (Big Design Up Front), что приведет к чрезмерному техническому усложнению и сложной конструкции.

В следующем разделе вы увидите, как наследование реализации нарушает инкапсуляцию.

Необоснованное раскрытие API суперкласса

Наследование реализации уместно только в тех случаях, когда подкласс действительно является подтипом суперкласса. Другими словами, класс B должен расширять класс A только в том случае, если между ними существует связь «is-a». В противном случае, вы излишне раскрываете детали реализации суперкласса пользователю. Это открывает возможности для клиентов вашего класса нарушать его внутренние инварианты путем непосредственного изменения суперкласса.

Посмотрите на ExposureDemo.kt, который находится внутри пакета exposuredemo. Переменная properties является экземпляром Properties из пакета java.util. Он унаследован от конкрета Hashtable. Это означает, что вы также можете получить доступ к общедоступным полям и методам Hashtable, например, put() и get(), через экземпляр Properties вместе с собственными. 

Чтобы получить представление об API-интерфейсах, предоставляемых с помощью Properties, перейдите к Properties.java (расположенному в java.util) в своей среде IDE и щелкните вкладку Structure. Вы увидите структуру Properties на боковой панели.

 

Теперь, используя значки в верхней части панели, снимите флажок Show non-public и выберите Show inherited. Вы увидите что-то похожее на изображение выше. Светло-серые методы - это унаследованные общедоступные методы, которые вы можете использовать через экземпляр Properties.

// [Properties] class extends from Hashtable. So, the methods from Hashtable can also be used.
val properties = Properties()

// Using [Hashtable]'s methods
properties.put("put1", "val1")
properties.put("put2", 100)

// Using [Properties]'s methods
properties.setProperty("setProperty1", "val1")
properties.setProperty("setProperty2", "100")

Но есть загвоздка. Если вы посмотрите документацию для Properties, то увидите, что не рекомендуется использовать методы Hashtable, даже если они открытого типа.

// Note: [Properties] 'getProperty()' returns null if the type is not a String;
// However, [Hashtable] 'get()' returns the correct value
properties.propertyNames().toList().forEach {
  println("Using Hashtable's get() $it: ${properties.get(it)}")
  println("Using Properties' getProperty() $it :  ${properties.getProperty(it.toString())}")
  println()
}

getProperty() из Property имеет дополнительные проверки безопасности касаемо того, выполняется ли get() из этого Hashtable или нет. Пользователи Properties могут обойти эти проверки и читать прямо из Hashtable. Вот почему, когда вы запускаете файл, вы видите вывод, показанный ниже, в консоли:

Using Hashtable's get() setProperty2: 100
Using Properties' getProperty() setProperty2 :  100

Using Hashtable's get() setProperty1: val1
Using Properties' getProperty() setProperty1 :  val1

Using Hashtable's get() put2: 100
Using Properties' getProperty() put2 :  null

Using Hashtable's get() put1: val1
Using Properties' getProperty() put1 :  val1

Для случаев, когда значение не относится к типу String, getProperty() и get(),в приведенном выше фрагменте приведены разные результаты для одного и того же ключа. Таким образом, получаемый API вводит в заблуждение, а также подвержен ошибочным вызовам.

Далее вы узнаете, как многоуровневое наследование может привести к увеличению числа подклассов.

Увеличение числа подклассов

Kotlin не поддерживает множественное наследование. Но он поддерживает многоуровневое наследование, которое обычно довольно часто используется. Например, Android SDK предоставляет TextView, наследуемый от View. Теперь, чтобы реализовать для TextView поддержку HTML, вы можете создать HtmlTextView, который будет являться наследником от TextView. Так выглядит многоуровневое наследование.

 

Вспомните пример с Pizza из предыдущего раздела. Он учитывает только один параметр - тип пиццы (вегетарианская и сырная), что было требованием клиента при написании кода. Позже клиент хочет добавить пиццу разных размеров - маленькую, среднюю и большую. Это означает, что теперь у вас есть два независимых параметра - тип и размер пиццы.

Поскольку нет смысла отделять пиццу от ее размера, вы делаете CheesePizza и VeggiePizza абстрактными. Затем вы решаете расширить их для добавления размера, создав по три конкретных реализации каждого типа пиццы. Итак, чтобы учесть новое требование, вы реорганизуете код, как показано ниже:

abstract class Pizza {
  abstract fun prepare()
}

abstract class CheesePizza : Pizza()
abstract class VeggiePizza : Pizza()

class SmallCheesePizza : CheesePizza() {
  override fun prepare() {
    println("Prepared a small cheese pizza")
  }
}

class MediumCheesePizza : CheesePizza() {
  override fun prepare() {
    println("Prepared a medium cheese pizza")
  }
}

class LargeCheesePizza : CheesePizza() {
  override fun prepare() {
    println("Prepared a large cheese pizza")
  }
}

class SmallVeggiePizza : VeggiePizza() {
  override fun prepare() {
    println("Prepared a small veggie pizza")
  }
}

class MediumVeggiePizza : VeggiePizza() {
  override fun prepare() {
    println("Prepared a medium veggie pizza")
  }
}

class LargeVeggiePizza : VeggiePizza() {
  override fun prepare() {
    println("Prepared a large veggie pizza")
  }
}

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

 

Вы можете увидеть проблему с этой реализацией. Имея всего три размера и два типа пиццы, вы получаете подклассы 3*2. Введение нового типа для любого из параметров значительно увеличило бы количество подклассов. Более того, если вы измените сигнатуру CheesePizza, чтобы включить cheeseName в его конструктор, изменение распространится на все подклассы.

Итак, как вы справитесь со всеми этими проблемами? Через использование композиции!

Композиция

Композиция - это метод, при котором вы формируете класс путем добавления private полей к классу, которые ссылается на экземпляр существующего класса, а не расширяют его. Так что связь «has-a» устанавливается между composed классом и содержащимися в нем экземплярах. Класс выполняет свои обязанности, перенаправляя или вызывая non-private методы своих private полей.

Используя подход, основанный на композиции, вы можете переписать UserMediator, как показано ниже:

class UserMediator {
  private val cacheService: UserCacheService = UserCacheService()
  private val apiService: UserApiService = UserApiService()

  // ...
}

Обратите внимание, как private экземпляры UserCacheService и UserApiService используются для создания UserMediator.

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

Рефакторинг с использованием композиции

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

Рефакторинг класса UserMediator

Поскольку вы не можете расширить более одного родительского класса, самым простым устранением неисправности UserMediator было бы удаление ключевого слова open из UserApiService и UserCacheService, но вместо этого сделайте их частными полями экземпляра UserMediator, как показано во фрагменте ниже:

class UserMediator {

  private val cacheService: UserCacheService = UserCacheService()
  private val apiService: UserApiService = UserApiService()

  /**
   * Search for [User] with [username] on cache first. If not found,
   * make API calls to fetch the [User] and persist it in server.
   *
   * @throws UserNotFoundException if it is not in the "server".
   */
  fun fetchUser(username: String): User {
    return cacheService.findUserById(username)
      ?: apiService.fetchUserByUsername(username)?.also { cacheService.saveUser(it) }
      ?: throw UserNotFoundException(username)
  }
}

Обратите внимание, как классы, которые расширяют UserMediator, преобразуются в поля частных экземпляров этого же класса.

Более того, вы можете упростить тестирование данного класса, приняв эти поля экземпляра в качестве аргументов конструктора и передав создание этих полей клиенту. Это называется внедрением зависимостей (dependency injection).

От композиции к агрегированию

Удалите эти два поля и создайте конструктор для UserMediator, принимая эти две переменные экземпляра в качестве аргументов:

class UserMediator(
  private val cacheService: UserCacheService,
  private val apiService: UserApiService
) {
   // methods...
}

В main() в UserDemo.kt скопируйте следующий код для инициализации mediator:

val mediator = UserMediator(
  cacheService = UserCacheServiceImpl(),
  apiService = UserApiServiceImpl()
)

UserMediator теперь зависит от того, как пользователь класса укажет его зависимости. А во время тестирования вы можете передавать тестовые заглушки, соответствующие непосредственно вашей тестовой ситуации, что значительно упрощает тестирование.

Заметка

Композиция - это сильная форма ассоциации, более слабая форма ассоциации называется агрегацией. Первая пересмотренная версия UserMediator (с конструктором по умолчанию) демонстрирует композиционные отношения с содержащимися в ней экземплярами, поскольку их жизненный цикл привязан к жизненному циклу контейнера. Напротив, вторая версия UserMediator (с аргументами конструктора) демонстрирует связь агрегирования, поскольку она ожидает, что клиент предоставит зависимости. Таким образом, содержащиеся в нем экземпляры могут существовать даже после уничтожения объекта UserMediator.

Поместите курсор на определение класса UserMediator и нажмите Control-Enter. Затем выберите Create test. Создастся файл - UserMediatorTest.kt - внутри тестового каталога. Откройте его и вставьте следующий фрагмент:

internal class UserMediatorTest {
  private lateinit var mockApi: UserApiService
  private lateinit var realCache: UserCacheService

  @BeforeEach
  fun setup() {
    // 1
    realCache = UserCacheServiceImpl()

    // 2
    mockApi = object : UserApiService {
      private val db = mutableListOf()

      init {
        db.add(User("testuser1", "Test User"))
      }

      override fun fetchUserByUsername(username: String): User? {
        return db.find { username == it.username }
      }
    }
  }

  @Test
  fun `Given username when fetchUser then should return user from cache and save it in cache`() {
    // 3
    val mediator = UserMediator(realCache, mockApi)
    val inputUsername = "testuser1"
    val user = mediator.fetchUser(inputUsername)
    assertNotNull(user)
    assertTrue { user.username == inputUsername }
    // Check if saved in cache
    assertNotNull(realCache.findUserById(inputUsername))
  }
}

Вот пошаговое объяснение логики представленного выше кода:

  1. Инициализируйте realCache как экземпляр UserCacheServiceImpl. Поскольку этот класс использует только структуру данных в памяти, вам не нужно имитировать его.
  2. При этом UserApiServiceImpl выполняет «сетевой» вызов, и вы не хотите, чтобы результат тестовых случаев зависел от ответа сервера или его доступности. Так что лучше использовать имитированную реализацию или тестовую заглушку. В данном примере вы заменили его реализацией, которая использует структуру данных в памяти, поэтому вы определяете ее результат и можете изменить в соответствии со своим тестовым сценарием.
  3. Поскольку UserMediator принимает экземпляры UserCacheService и в качестве аргументов UserApiService, вы можете передавать указанные выше переменные.

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

Рефакторинг класса пиццы

Ранее вы видели, как многоуровневое наследование может привести к резкому увеличению числа подклассов. Вы можете избежать этой проблемы, не моделируя отношения в форме многоуровневого наследования, а вместо этого устанавливая связь «has-a» между Pizza и параметрами.

Откройте файл Pizza.kt внутри пакета explosionofsubclassesdemo и замените содержимое следующим фрагментом:

import java.math.RoundingMode

// 1
sealed class PizzaType {
  data class Cheese(val cheeseName: String) : PizzaType()
  data class Veggie(val vegetables: List) : PizzaType()
}

enum class Size(val value: Int) {
  LARGE(12), MED(8), SMALL(6);

  fun calculateArea(): Double {
    // Area of circle given diameter
    return (Math.PI / 4).toBigDecimal().setScale(2, RoundingMode.UP).toDouble() * value * value
  }
}

// 2
class Pizza(val type: PizzaType, val size: Size) {
  fun prepare() {
    // 3 
    println("Prepared ${size.name} sized $type pizza of area ${size.calculateArea()}")
  }
}

В приведенном выше фрагменте кода происходит:

  1. Выделение параметров в отдельные классы PizzaType и Size.
  2. Исходный класс ссылается на экземпляр извлеченного класса. Здесь класс Pizza состоит из двухмерных классов.
  3. Сделайте так, чтобы composed класс делегировал любые вычисления, связанные с размером, классу Size и любые другие вычисления, относящиеся к классу PizzaType. Таким образом composed класс выполняет свою роль: взаимодействует с полями экземпляра.

Наконец, чтобы запустить класс, откройте PizzaDemo.kt и замените код в main() на:

val largeCheesePizza = Pizza(Cheese("Mozzarella"), Size.LARGE)
val smallVeggiePizza = Pizza(Veggie(listOf("Spinach", "Onion")), Size.SMALL)
val orders = listOf(largeCheesePizza, smallVeggiePizza)

orders.forEach {
  it.prepare()
}

Наконец, запустите файл, и вы получите следующий результат:

Prepared LARGE sized Cheese(cheeseName=Mozzarella) pizza of area 113.76
Prepared SMALL sized Veggie(vegetables=[Spinach, Onion]) pizza of area 28.44

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

Далее вы увидите, как можно использовать подход, основанный на композиции, для управления открытием API.

Решение проблемы экспозиции

Эмпирическое правило в ООП гласит – следует писать shy class («стеснительный класс»). Shy class класс не раскрывает другим ненужную реализацию о себе. Properties Java.util явно нарушают это. Лучшим способом реализации стал бы подход, основанный на композиции.

Поскольку Properties - это встроенный класс, предоставляемый JDK, вы не сможете его изменить. Итак, вы узнаете, как это можно улучшить на примере упрощенной версии. Для этого создайте новый класс HashtableStore и вставьте следующий фрагмент:

class HashtableStore {
  // 1
  private val store: Hashtable = Hashtable()

  // 2
  fun getProperty(key: String): String? {
    return store[key]
  }

  fun setProperty(key: String, value: String) {
    store[key] = value
  }

  fun propertyNames() = store.keys
}

Вот пошаговое объяснение логики кода:

  1. При подходе, основанном на композиции, вы создаете частное поле HashtableStore и инициализируете его как экземпляр Hashtable. Чтобы обеспечить функциональность хранения данных, вам необходимо взаимодействовать с этим экземпляром. Вспомните эмпирическое правило: пишите shy class. Если сделать экземпляр закрытым, посторонние не смогут получить к нему доступ, что поможет вам добиться инкапсуляции!
  2. Вы предоставляете общедоступные методы, к которым может получить доступ пользователь данного класса. Этот класс предоставляет три таких метода, и каждый метод перенаправляет свою операцию в private поле.

В том же файле создайте main() и вставьте в него следующий код:

val properties = HashtableStore()

properties.setProperty("setProperty1", "val1")
properties.setProperty("setProperty2", "100")

properties.propertyNames().toList().forEach {
  println("$it: ${properties.getProperty(it.toString())}")
}

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

class PropertiesStore {
  private val properties = Properties()

  fun getProperty(key: String): String? {
    return properties.getProperty(key)
  }

  fun setProperty(key: String, value: String) {
    properties.setProperty(key, value)
  }

  fun propertyNames() = properties.propertyNames()
}

Как элемент HashtableStore, PropertiesStore использует private экземпляр, но при этом public методы взаимодействуют с Properties. Поскольку вы используете Properties в качестве поля экземпляра, вы также получите преимущества от любых будущих обновлений Properties. 

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

Использование наследования

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

Наследование - мощная концепция. И вы можете видеть, что она используется во многих местах. Например, в Android SDK TextView расширяет View. Чтобы создать настроенную версию TextView, вам необходимо создать класс, который расширяет TextView и предоставляет дополнительные методы или изменяет определенные формы поведения. Поскольку оба класса демонстрируют «is-a» отношение с View, их можно передавать везде, где View ожидаем (помните о взаимозаменяемости?). Такая замена невозможна с помощью простого подхода, основанного на композиции. Следовательно, PropertiesStore не может быть заменено на Hashtable, как, например, Properties.

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

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

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

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

Шаблон делегирования в Kotlin

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

Создайте файл DelegationDemo.kt и вставьте этот фрагмент:

data class Result(val item: T)

// 1
interface CanCook {
  fun cook(item: T): Result
}

// 2
class PizzaCookingTrait : CanCook {

  override fun cook(item: Pizza): Result {
    println("Collecting ingredients")
    item.prepare()
    return Result(item)
  }
}

// 3
class Chef(private val trait: CanCook) : CanCook {
  override fun cook(item: T): Result {
    return trait.cook(item)
  }
}

fun main() {
  val pizza = Pizza(PizzaType.Cheese("Mozzarella"), Size.LARGE)
  val chef = Chef(trait = PizzaCookingTrait())
  chef.cook(pizza)
}

 Вот пошаговое разъяснение логики кода:

  1. CanCook<T>- это интерфейс, который должен быть реализован любым классом, который имеет элемент cook() типа T.
  2. cook() из PizzaCookingTrait принимает Pizza и возвращает готовое блюдо Result<Pizza>.
  3. Chef состоит из поля экземпляра trait и реализует CanCook. Переопределенный метод делегирует функции своему полю экземпляра cook().

Вы можете избежать написания таких вызовов переадресации, используя by, ключевое слово Kotlin, за которым следует экземпляр, нуждающийся в делегировании, как показано ниже:

class Chef(private val trait: CanCook) : CanCook by trait

Теперь в меню выберите View > Show Bytecode и нажмите на кнопку Decompile. Вы получите эквивалентный код Java. Теперь перейдите к Chef этого Java-файла, и вы увидите что-то вроде этого:

import kotlin.jvm.internal.Intrinsics;

// ...

public final class Chef implements CanCook {
  private final CanCook trait;

  public Chef(@NotNull CanCook trait) {
    Intrinsics.checkNotNullParameter(trait, "trait");
    super();
    this.trait = trait;
  }

  @NotNull
  public Result cook(Object item) {
    return this.trait.cook(item);
  }
}  

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

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

Оригинал статьи

Содержание