Неявные параметры. Когда их следует использовать? Часть 1

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Имплиситы (implicits) – одна из наиболее вызывающих опасения фич языка программирования Scala, и на то есть веские причины!

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

Во-вторых, в Scala 2 ключевое слово implicit используется слишком часто (подобно _). Поэтому потребуется достаточное количество времени и практики, чтобы провести грань между различными вариантами использования имплиcитов. В этом отношении Scala 3 значительно улучшила ситуацию, введя специальный синтаксис для каждого случая использования имплиcита.

Данная публикация блога будет посвящена Scala 2, поскольку в настоящее время это наиболее используемая основная версия Scala. Однако по ходу статьи я буду упоминать о тех различиях, которые появились в Scala 3 относительно имплиcитов.

Итак, перед тем как углубиться в шаблоны проектирования с неявными параметрами, стоит потратить несколько минут на рассмотрение того, как они [неявные параметры] работают.

Определение

Конструктор функции или класса может иметь явные и неявные параметры, которые являются явными по умолчанию. Они становятся неявными, только если мы добавляем ключевое слово implicit в начале круглых скобок.

def sorted[A](list: List[A])(implicit ordering: Ordering[A]): List[A]
​
class UserService(config: Config)(implicit ec: ExecutionContext) { }

В приведенных выше примерах list является явным параметром, а ordering - неявным параметром функции sorted. Конструктор класса ведет себя идентично простым функциям, поэтому в оставшейся части статьи я буду использовать только примеры с обычными функциями.

Обратите внимание, что явные и неявные параметры всегда определяются в виде отдельных наборов круглых скобок. В Scala 2 все неявные параметры должны быть определены в последнем наборе круглых скобок. В Scala 3 этого ограничения больше не существует.

Варианты использования

Допустим, у нас есть метод createEmptyBlogPost, который принимает как явный, так и неявный параметр (позже я объясню, почему я сделал такой выбор).

def createEmptyBlogPost(title: String)(implicit requesterId: UserId): BlogPost =
  BlogPost(
    author  = requesterId,
    title   = title,
    content = ""
  )
​
case class BlogPost(
  userId : UserId,
  title  : String,
  content: String,
)
​
case class UserId(value: String)

Как вызвать функцию createEmptyBlogPost? Первый вариант - передать неявный параметр в явном виде.

createEmptyBlogPost("Scala Implicits: The complete guide")(`UserId("john_1234")`)
// res: BlogPost = BlogPost(
//   author  = UserId("john_1234"),
//   title   = "Scala Implicits: The complete guide",
//   content = "",
// )

Однако это не является идиоматичным. Обычно неявные параметры не указываются разработчиками. Вместо этого компилятор автоматически передает их в функцию. Это одна из форм внедрения зависимости.

createEmptyBlogPost("Scala Implicits: The complete guide") // Implicit call
// res: BlogPost = BlogPost(
//   author  = UserId("john_1234"),
//   title   = "Scala Implicits: The complete guide",
//   content = "",
// )

Теперь возникает вопрос: как компилятор узнает, какое значение следует ввести?

Компилятор поддерживает карту, где ключ – это тип, а значение – значение типа ключа (это не совсем то, как это действительно реализовано в компиляторе, но является хорошей ментальной моделью). Например,

val ImplicitValues: Map[Type, Value] =  // pseudo-code
  Map(
    Int     -> 5,
    String  -> "",
    UserId  -> UserId("john_1234"),
  )

Затем, когда компилятору нужно передать неявный параметр типа UserId, он находит значение по ключу UserId, которое равно UserId("john_1234"), и вставляет его в функцию createEmptyBlogPost. Все это происходит во время компиляции, а значит, на рантайм программы имплиситы не влияют!

Что будет, если для типа UserId не найдется ключа? В этом случае компилятор выдает ошибку компиляции. Например,

val ImplicitValues: Map[Type, Value] =  // pseudo-code
  Map(
    Int     -> 5,
    String  -> "",
    // No entry for UserId
  )
​
createEmptyBlogPost("Scala Implicits: The complete guide")
error: could not find implicit value for parameter requesterId: UserId

Наконец, как сообщить компилятору, что UserId("john_1234") должно быть неявным значением для типа UserId?

Нужно использовать ключевое слово implicit, но на этот раз перед val или def. Например,

implicit val requesterId: UserId = UserId("john_1234")

Обратите внимание, что неявные определения имеют область видимости, как и обычные значения. В следующем примере первый вызов createEmptyBlogPost компилируется, потому что неявное значение requesterId определено в тех же фигурных скобках, где вызывается функция, а второй вызов </span><span style="font-size:11pt;font-family:'Roboto Mono',monospace;color:#000000;background-color:transparent;font-weight:700;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">createEmptyBlogPost</span><span style="font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;"> не компилируется, потому что здесь этого значения не видно.

class BlogPostTest extends AnyFunSuite {
​
  test("createEmptyBlogPost gets the author implicitly") {
    implicit val requesterId: UserId = UserId("john_1234")
​
    val result = createEmptyBlogPost("Scala Implicits: The complete guide") // ✅ Compile
​
    assert(result.author == requesterId)
  }
​
  test("createEmptyBlogPost has no content") {
    val result = createEmptyBlogPost("Scala Implicits: The complete guide") // ❌ could not find implicit value
​
    assert(result.content.isEmpty)
  }
​
}

Если мы переместим определение requesterId на строчку выше (первая строчка внутри класса BlogPostTest), то оба вызова createEmptyBlogPost будут скомпилированы.

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

object User {
  implicit val defaultUser: User = UserId("john_1234")
}
​
import User.defaultUser // or User._
​
createEmptyBlogPost("Scala Implicits: The complete guide") // ✅ Compile

Давайте подытожим то, что мы видели до сих пор о неявных параметрах: Компилятор отслеживает все неявные параметры, доступные в области видимости. Во время компиляции компилятор вводит все неявные параметры, не переданные явно. Если неявный параметр отсутствует, мы получаем ошибку компиляции "could not find implicit value… (не удалось найти неявное значение)". Если в одной области видимости есть два или более неявных значения одного типа, мы получим другую ошибку компиляции: "ambiguous implicit ... (неоднозначное неявное значение)".

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

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

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

Шаблон окружения

Шаблон окружения получил свое название от переменных среды окружения, используемых в скриптах оболочки и платформах CI/CD. Идея заключается в том, что большинство параметров меняется каждый раз, когда мы вызываем функцию, но некоторые из них являются статичными в рамках сессии, например JAVA_HOME или SBT_OPTS. Эти параметры окружения обычно инициализируются в начале сеанса и остаются неизменными до его завершения.

Давайте посмотрим, как этот паттерн реализуется в Scala. Допустим, мы работаем над http-сервисом для управления записями в блогах.

val httpService = {
  case req @ POST   -> Root / "blog"      => // create a blog
  case req @ PUT    -> Root / "blog" / id => // update a blog
  case req @ DELETE -> Root / "blog" / id => // delete a blog
}

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

Первое, что мы делаем при реализации эндпоинта, - аутентифицируем пользователя, сделавшего запрос. После аутентификации мы присваиваем UserId неявное значение и вызываем метод create класса BlogAPI.

case req @ POST -> Root / "blog" =>
  implicit val requesterId: UserId = extractRequesterId(req)
​
  for {
    payload <- req.parseBodyAs[NewBlog]
    _       <- blogAPI.create(payload.title)
  } yield Ok()

BlogAPI.create в свою очередь вызывает чистую функцию createEmptyBlogPost и сохраняет результат в базе данных.

class BlogAPI(db: DB) {
  def create(title: String)(implicit requesterId: UserId): Future[Unit] = {
    val newBlog = createEmptyBlogPost(title)
    db.save(newBlog)
  }
}
​
def createEmptyBlogPost(title: String)(`implicit requesterId: UserId`): BlogPost =
  BlogPost(
    author  = requesterId,
    title   = title,
    content = "",
  )

Резюмируя, можно сказать, что паттерны окружения работают следующим образом: Когда сервер получает http-запрос, мы отмечаем UserId запрашивающего как неявное значение. Все последующие методы принимают UserId в качестве неявного параметра.

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

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

Представьте, что мы хотим расширить нашу структуру данных BlogPost, чтобы включить метку времени, указав таким образом, когда он был создан. Самый простой способ сделать это - добавить поле createdAt в кейс-класс BlogPost и модифицировать createEmptyBlogPost, инициализировав createdAt с помощью Instant.now().

case class BlogPost(
  author  : UserId,
  title   : String,
  content : String,
  createdAt: Instant,
)
​
def createEmptyBlogPost(title: String)(implicit requesterId: UserId): BlogPost =
  BlogPost(
    author   = requesterId,
    title    = title,
    content  = "",
    createAt = Instant.now(),
  )

Это отлично работает и отличается простотой. К сожалению, такой код сложно тестировать, поскольку Instant.now() недетерминирована. Каждый раз, когда мы вызываем данную функцию, мы получаем разный результат, что делает нашу логику трудно тестируемой.

test("create blog post") {
  implicit val requesterId: UserId = UserId("john_1234")
​
  val result = createEmptyBlogPost("Test")
​
  assert(result == BlogPost(requesterId, "Test", "", ???)) // which timestamp?
}

Есть способы обойти эту проблему, например: Игнорировать метку времени при сравнении двух BlogPost в наших тестах. Перехватить вызов Instant.now() с помощью какого-нибудь мок-фреймворка и переопределить его.

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

trait Clock {
  def now(): Instant
}
​
object Clock {
  val real: Clock = new Clock {
    def now(): Instant = Instant.now()
  }
​
  def static(timestamp: Instant): Clock = new Clock {
    def now(): Instant = timestamp
  }
}

Clock.real – это реальные системные часы, использующие Instant.now(), в то время как Clock.static всегда возвращают одно и то же время.

Затем нам нужно обновить createEmptyBlogPost, чтобы он принимал имплисит Clock:

def createEmptyBlogPost(title: String)(implicit requesterId: UserId, clock: Clock): BlogPost =
  BlogPost(
    author   = requesterId,
    title    = title,
    content  = "",
    createAt = clock.now(),
  )

Наконец, нам нужно установить значение среды Clock как неявное в начале контекста.

Clock.real предназначено для всего нашего продакшн-кода, поэтому мы должны инициализировать его в классе Main нашего приложения:

object Main extends App {
  implicit val clock: Clock = Clock.real
  ...
}

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

class BlogPostTest extends AnyFunSuite {
  implicit val clock: Clock = Clock.static(Instant.EPOCH)
  ...
​
}

Если вы работали со Scala Futures, то уже сталкивались с этим паттерном. Действительно, почти все методы API Future требуют неявного ExecutionContext (своего рода пул потоков). Обычно приложения, использующие Futures, определяют продакшн ExecutionContext в основной части приложения:

object Main extends App {
  implicit val ec: ExecutionContext = ExecutionContext.global
  ...
}

Отдельные тестовые файлы могут решить использовать собственный ExecutionContext, например, с одним потоком:

class BlogDatabaseTest extends AnyFunSuite {
  implicit val ec: ExecutionContext = `fixedSizeExecutionContext(1)`
  ...
​
}

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

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

Ещё одним важным моментом является использование точных типов для переменных окружения. Это связано с тем, что имплисит требует уникального значения для каждого типа, а дженерик-типы, такие как Int, Boolean, String или LocalDate, могут использоваться для различных кодировок.

def createQueue[A](implicit size: Int): Queue[A] =
  ...
​
def connect(hostname: String)(implicit port: Int): Unit =
  ...

Если вы хотите передавать номер порта в качестве параметра среды, я рекомендую создать тип-обертку:

case class PortNumber(value: Int)
​
// or even better
case class HttpServerPortNumber(value: Int)

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

Бонус: Альтернативные реализации паттерна окружения

Параметры класса

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

ThreadLocal

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

Reader

Reader - это конструктор типов: Reader[R, A], где R представляет тип параметров окружения, а A - результат вычислений.

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

def createEmptyBlogPost(title: String): Reader[(UserId, Clock), BlogPost] = ...

Как вы видите, возвращаемый тип изменился с BlogPost на Reader[R, BlogPost], а два неявных параметра переместились в параметр типа R.

Reader позволяет нам объединить несколько Reader вместе с помощью for-генерации. Однако это работает только в том случае, если все Reader имеют одинаковый R. Например, этот код не компилируется, потому что createProject требует только UserId, а не Clock.

def createProject(projectName: String): Reader[UserId,  Project] = ...
​
for {
  blog    <- createBlogPost("Implicits for the noob")
  project <- createProject("tutorial") ❌doesn’t compile
} yield ...

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

В cats (typelevel) Reader называется Kleisli. Более подробную информацию вы можете найти здесь.

ZIO

ZIO также является конструктором типов, но с тремя параметрами типа: ZIO[R, E, A], где R тоже представляет тип параметров окружения. Однако ZIO устраняет недостаток Reading, используя дисперсию. В двух словах это означает, что мы можем компоновать значения ZIO с различными переменными окружения, и компилятор автоматически расширяет тип R соответствующим образом.

Более подробную информацию можно найти на официальном сайте ZIO.


А продолжить знакомство с ZIO предлагаем на открытом уроке в OTUS. На этом уроке мы:
– Узнаем о предпосылках и истории возникновения ZIO.
– Сформируем представление, какие задачи решают так называемые «функциональные эффекты» в целом и ZIO в частности.
– Попрактикуемся в создании и комбинировании ZIO-эффектов.

Занятие будет полезно Scala-разработчикам, которые пока не знакомы с концепцией функционального программирования эффектов в целом или с ZIO в качестве представителя этой концепции.

Регистрация на урок открыта на странице курса «Scala-разработчик».

Источник: https://habr.com/ru/company/otus/blog/718628/


Интересные статьи

Интересные статьи

Недавно я столкнулся с нетривиальной задачей, которую мне необходимо было решить в работе над нашей следующей (пока еще не анонсированной) игры. Мне нужна была детерминированная физика. То есть мне ну...
Внедрение новой платформы базы данных требует тщательного исследования внутри компании. Приобретение СУБД — сложная процедура, если учитывать огромные вложения в необходимые аппаратные и программные...
В нескольких предыдущих заметках данной серии мы уже упоминали понятие дифференциального рендеринга. Сегодня пришло время разъяснить что это такое и с чем это едят. Мы поговорим о ...
Джош Эванс рассказывает о хаотичном и ярком мире микросервисов Netflix, начиная с самых основ — анатомии микросервисов, проблем, связанных с распределенными системами и их преимуществ...
В первой части статьи мы разобрали загрузчик оригинальной версии и выяснили, куда загружается код игры и как он запускается. Теперь нужно перенести файлы на диск. Обычно это делается простым к...