Если вы какое-то время писали код на Kotlin, вы почти наверняка столкнулись с делегированием. Одно из наиболее распространенных применений шаблона делегирования - ленивым, когда значение не вычисляется до тех пор, пока оно не будет запрошено, и это значение не пересчитывается при последующих запросах.

Еще одно распространенное использование шаблона делегата - когда он используется для реализации interface by некоторого введенного параметра, например CoroutineScope by injectedScope. Это позволяет нам вызывать общедоступные члены для введенного параметра без необходимости постоянно ссылаться на него напрямую, подобно тому, как мы используем with(something).

Улучшение контейнеров состояний фиксированного размера

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

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

В Kotlin мы обычно представляем этот тип данных, используя одно из двух: либо класс данных, либо Set<T>.

Класс данных

Класс данных - хорошая отправная точка при разработке такого контейнера, как этот.

data class FeatureSuite(
  val featureOne: Feature.TypeA.FeatureOne,
  val featureTwo: Feature.TypeA.FeatureTwo,
  val featureThree: Feature.TypeB.FeatureThree,
  ...
)

Одним из основных преимуществ data class представления этих функций является то, что мы можем гарантировать, что каждая функция будет учтена при создании пакета. Это потому, что каждая функция является обязательным параметром конструктора нашего класса. Кроме того, у нас есть доступ, не допускающий значения NULL, к каждой функции в качестве общедоступных свойств пакета, поэтому, если нам нужно проверить, включена ли данная функция, все, что нам нужно сделать, это suite.featureOne.isEnabled(). Кроме того, использование data class вместо обычного class гарантирует, что каждый параметр должен быть val или var, что помогает усилить характеристики контейнера этого объекта домена.

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

Установить ‹Feature›

Второй подход, который мы можем использовать, - это использовать Set<Feature> в качестве контейнера для наших функций. Это может выглядеть примерно так:

typealias FeatureSuite = Set<Feature>

Затем мы могли бы создать FeatureSuite, выполнив:

val featureSuite = setOf(
  Feature.TypeA.FeatureOne(...),
  Feature.TypeA.FeatureTwo(...),
  Feature.TypeB.FeatureThree(...),
  ...
)

Используя Set, мы теперь можем делать такие вещи, как filterIsInstance, чтобы получить все функции типа TypeA, не беспокоясь о том, что один из них пропадет, если мы добавим новую функцию.

Однако мы утратили преимущества data class! Мы больше не можем гарантировать, что каждая функция будет передана при создании набора, и у нас нет нулевого безопасного доступа к каждой функции, потому что нет гарантии, что функция существует в Set! Отсутствие нулевой безопасности будет распространяться на все случаи использования пакета и может вызвать RuntimeException, если будет запрошена функция, которая не была добавлена ​​в набор.

Итак, как мы можем получить преимущества data class и Set в одном устройстве? Вы угадали, делегация!

Делегация, ваша новая сверхдержава!

Мы должны определить наш FeatureSuite следующим образом:

data class FeatureSuite(
  val featureOne: Feature.TypeA.FeatureOne,
  val featureTwo: Feature.TypeA.FeatureTwo,
  val featureThree: Feature.TypeB.FeatureThree,
  ...
) : Set<Feature> by setOf(
  featureOne,
  featureTwo,
  featureThree,
  ...
)

Обратите внимание, что теперь у нас есть преимущества как data class, так и Set за счет делегирования Set, состоящего из всех параметров функции.

  • Гарантия того, что все функции передаются в пакет через конструктор
  • Нуль-безопасный доступ к функциям через featureSuite.someFeature
  • Возможность filterIsInstance и получать все функции данного типа, не беспокоясь о том, что они пропадут.

Мы можем начать добавлять дополнительные функции, чтобы сделать сайты звонков более чистыми:

val typeAFeatures: List<Feature.TypeA> by lazy {
  filterIsInsance(Feature.TypeA::class.java)
}
val enabledFeatures: List<Feature> by lazy {
  filter { it.isEnabled() }
}

Тестирование

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

  • Все функции должны быть сопоставлены с базовым Set (нужно убедиться, что мы не упустили ни одной!). Это самый важный тест.
  • Функции, переданные в набор, на самом деле являются теми же функциями (равенство объектов), которые присутствуют в базовом Set

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

// Notes: `primaryConstructor` is made available through `kotlin-reflect` and `FakeFeatureSuite` is just a convenience method to create a (real) test object that can be re-used in other tests.
@Test
fun `All features mapped to Set`() {
  val featuresRequiredInConstructor =
    FeatureSuite::class.primaryConstructor!!.parameters.size
  val featuresAddedToSet = FakeFeatureSuite().size 
  assertThat(featuresAddedToSet)
    .isEqualTo(featuresRequiredInConstructor)
}

Второй тест менее пуленепробиваемый, но все же стоящий:

@Test
fun `Object equality of features`() {
  val featureSuite = FakeFeatureSuite()
  val featureOneFromSet = featureSuite.single {
    it is Feature.TypeA.FeatureOne
  }
  assertThat(featureSuite.featureOne)
    .isSameAs(featureOneFromSet)
}

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

val assertions = listOf<AbstractAssert<*,*>>(
  // Wrap our assertions in a List
  assertThat(featureSuite.featureOne)
    .isSameAs(featureOneFromSet)
)
val expectedAssertionCount =
  FeatureSuite::class.primaryConstructor!!.parameters.size
assertThat(assertions.size)
  .`as`( "Ensure all constructor params checked for object equality").isEqualTo(expectedAssertionCount)

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

Заключение

Объединив преимущества обычного data class с преимуществами Set, мы можем обеспечить все желаемые функциональные возможности для нашего набора функций безопасным способом, избегая использования RuntimeException. Как всегда, если у вас есть предложения о том, как я могу улучшить эту реализацию, определенно снимайте их по-моему!

РАСКРЫТИЕ ИНФОРМАЦИИ: © 2020 Capital One. Мнения принадлежат отдельному автору. Если в этом посте не указано иное, Capital One не является аффилированным лицом и не поддерживается ни одной из упомянутых компаний. Все используемые или отображаемые товарные знаки и другая интеллектуальная собственность являются собственностью соответствующих владельцев.

Первоначально опубликовано на https://brandontrautmann.com 27 мая 2020 г.