Контекстные функции - одна из новых контекстных абстракций, появившихся в Scala 3. Релиз приближается быстро, дизайн завершен, поэтому давайте рассмотрим эту функцию более подробно!
Если вы предпочитаете версию видео с кодированием в реальном времени, посмотрите недавнюю встречу Scala In The City по той же теме.
Что такое контекстная функция?
Прежде чем мы погрузимся в примеры использования и рассмотрим, почему вам вообще может быть интересно использовать контекстные функции, давайте посмотрим, что они из себя представляют и как их использовать.
Обычную функцию можно написать на Scala следующим образом:
val f: Int => String = (x: Int) => s"Got: $x"
Контекстная функция выглядит похожей, однако принципиальное отличие состоит в том, что параметры неявны. То есть, при использовании функции параметры должны быть в неявной области и предоставлены компилятору раньше, например. используя given
; по умолчанию они не передаются явно.
Тип контекстной функции записывается с использованием ?=>
вместо =>
, и в реализации мы можем ссылаться на неявные параметры, которые находятся в области видимости, как определено типом. В Scala 3 это делается с помощью summon[T]
, который в Scala 2 был известен как implicitly[T]
. Здесь, в теле функции, мы можем получить доступ к значению given Int
:
val g: Int ?=> String = s"Got: ${summon[Int]}"
Так же, как f
имеет тип Function1
, g
является экземпляром ContextFunction1
:
val ff: Function1[Int, String] = f val gg: ContextFunction1[Int, String] = g
Функции контекста - это обычные значения, которые можно передавать как параметры или хранить в коллекциях. Мы можем вызвать контекстную функцию, явно указав неявные параметры:
println(g(using 16))
Или мы можем предоставить значение в неявной области видимости. Все остальное компилятор разберется:
println { given Int = 42 g }
Примечание: вы никогда не должны использовать «общие» типы, такие как
Int
илиString
для заданных / неявных значений. Вместо этого все, что попадает в неявную область видимости, должно иметь узкий настраиваемый тип, чтобы избежать случайного загрязнения неявной области видимости.
Несколько параметров и их порядок
Как и обычные функции, контекстные функции могут иметь несколько параметров как в каррированной, так и в несжатой форме:
val g1: Int ?=> Boolean ?=> String = s"${summon[Int]} ${summon[Boolean]}" val g2: (Int, Boolean) ?=> String = s"${summon[Int]} ${summon[Boolean]}"
Но это еще не все - компилятор Scala при необходимости скорректирует форму контекстных функций. Изменение порядка параметров или использование каррированной функции там, где требуется необработанная функция, не является проблемой. Например, если у нас есть следующие функции, которые используют контекстные функции:
def run1(f: Int ?=> Boolean ?=> String): Unit = println(f(using 9)(using false)) def run2(f: Boolean ?=> Int ?=> String): Unit = println(f(using true)(using 10)) def run3(f: (Boolean, Int) ?=> String): Unit = println(f(using false, 11))
мы можем вызывать их напрямую с g1
и g2
:
run1(g1) run2(g1) run3(g1)
На сайте вызова компилятор создаст новую контекстную функцию желаемой формы, если предоставленная функция имеет все необходимые параметры для удовлетворения требований.
Разумное исполнение
Давайте начнем с некоторых примеров использования! Если вы занимались программированием с использованием Scala 2 и Akka, вы, вероятно, встречали ExecutionContext
. Практически любой метод, имеющий дело с Future
s, вероятно, имел дополнительный список параметров implicit ec: ExecutionContext
.
Например, вот как может выглядеть в Scala 2 упрощенный фрагмент функции бизнес-логики, которая сохраняет нового пользователя в базе данных, если пользователь с данным адресом электронной почты еще не существует:
case class User(email: String) def newUser(u: User)( implicit ec: ExecutionContext): Future[Boolean] = { lookupUser(u.email).flatMap { case Some(_) => Future.successful(false) case None => saveUser(u).map(_ => true) } } def lookupUser(email: String)( implicit ec: ExecutionContext): Future[Option[User]] = ??? def saveUser(u: User)( implicit ec: ExecutionContext): Future[Unit] = ???
Мы предполагаем, что методы lookupUser
и saveUser
взаимодействуют с базой данных асинхронным или синхронным образом.
Обратите внимание, как ExecutionContext
необходимо пропустить через все вызовы. Это не препятствие, но все же досада и еще один шаблонный шаблон. Было бы замечательно, если бы мы могли зафиксировать тот факт, что нам требуется ExecutionContext
в некоторой абстрактной форме ...
Оказывается, со Scala 3 это возможно! Вот для чего нужны контекстные функции. Давайте определим псевдоним типа:
type Executable[T] = ExecutionContext ?=> Future[T]
Любой метод, где тип результата - Executable[T]
, потребует заданного (неявного) контекста выполнения для получения результата (Future
). Вот как может выглядеть наш код после рефакторинга:
case class User(email: String) def newUser(u: User): Executable[Boolean] = { lookupUser(u.email).flatMap { case Some(_) => Future.successful(false) case None => saveUser(u).map(_ => true) } } def lookupUser(email: String): Executable[Option[User]] = ??? def saveUser(u: User): Executable[Unit] = ???
Типовые подписи короче - это одно преимущество. В остальном код не изменился - это еще одно преимущество. Например, для метода lookupUser
требуется ExecutionContext
. Он автоматически предоставляется компилятором, поскольку находится в области видимости - как указано в сигнатуре метода контекстной функции верхнего уровня.
Исполняемый как абстракция
Однако чисто синтаксическое изменение, которое мы видели выше, дающее нам более четкие сигнатуры типов, не единственное различие. Поскольку теперь у нас есть абстракция для «вычисления, требующего контекста выполнения», мы можем создавать комбинаторы, которые работают с ними. Например:
// retries the given computation up to `n` times, and returns the // successful result, if any def retry[T](n: Int, f: Executable[T]): Executable[T] // runs all of the given computations, with at most `n` running in // parallel at any time def runParN[T](n: Int, fs: List[Executable[T]]): Executable[List[T]]
Это возможно из-за кажущейся невинной синтаксической, но огромной семантической разницы. Результат метода:
def newUser(u: User)(implicit ec: ExecutionContext): Future[Boolean]
- это текущее вычисление, которое в конечном итоге вернет логическое значение. С другой стороны:
def newUser(u: User): Executable[Boolean]
возвращает ленивое вычисление, которое будет выполняться только в том случае, если указан ExecutionContext
(либо через неявную область видимости, либо явно). Это позволяет реализовать описанные выше операторы, которые могут определять, когда и как выполняются вычисления.
Если вы раньше сталкивались с типами данных IO
, ZIO
или Task
, это может показаться вам знакомым. Основная идея, лежащая в основе этих типов данных, аналогична: фиксировать асинхронные вычисления как лениво вычисляемые значения и предоставлять богатый набор комбинаторов, формирующих инструментарий параллелизма. Взгляните на Кошки-эффект, Моникс или ЗИО, чтобы узнать подробности!
Если вам нужна помощь в навигации по библиотекам функционального программирования Scala, взгляните на эту статью, недавно опубликованную Кшиштофом Атласиком.
Но следите за подписями типов!
Однако использование контекстных функций, как описано выше (для захвата лениво вычисляемых вычислений), имеет одну важную ошибку. Поведение может измениться в зависимости от того, предоставляете ли вы подпись типа или нет. Например:
def save(u: User): Executable[Boolean] = ??? given ExecutionContext = ??? val result1 = save(u) val result2: Executable[Boolean] = save(u)
result1
будет Future[Boolean]
- текущим вычислением. Компилятор будет охотно использовать данный контекст выполнения из текущей области видимости. Однако, поскольку мы добавили тип в result2
, теперь у нас есть (контекстная) функция, которая в конечном итоге будет выдавать Future
только при наличии контекста выполнения.
Компилятор также с радостью адаптирует Future[Boolean]
к Executable[Boolean]
:
val v: Future[Boolean] = ??? val f: Executable[Boolean] = v
даже если это может быть не то, что вы ожидаете - каждый раз, когда контекст выполнения предоставляется f
, он будет возвращать такое же (запущенное или завершенное) будущее v
. Следовательно, кажется, что «овеществленные» _38 _ / _ 39 _ / _ 40_ типы данных по-прежнему превосходят Future
или Executable
при описании параллельно выполняющихся вычислений.
Явные зависимости с использованием неявных значений
Наш второй пример использования затронет тему, которая часто обсуждается программистами: внедрение зависимостей.
Расширяя наш предыдущий пример, давайте взглянем на следующий код:
case class User(email: String) class UserModel { def find(email: String): Option[User] = ??? def save(u: User): Unit = ??? } class UserService(userModel: UserModel) { def newUser(u: User): Boolean = { userModel.find(u.email) match { case Some(_) => false case None => userModel.save(u) true } } } class Api(userService: UserService) { val result: Boolean = userService.newUser(User("[email protected]")) }
Асинхронный аспект (Future
s) здесь опущен, но его можно добавить таким же образом, как и раньше. Мы разделили функциональность на три класса:
UserModel
взаимодействует с базой данных и предоставляет функции поиска и сохранения экземпляраUser
UserService
реализует нашу бизнес-логику в зависимости отuserModel
Api
предоставляет функциональность внешнему миру - здесь не как, например, конечная точка HTTP, но в значительно упрощенной форме прямого вызова.
Обратите внимание, что зависимость между UserService
и UserModel
скрыта. Клиент UserService
, которым здесь является класс Api
, не знает, какие зависимости имеет служба. Это деталь реализации этого класса.
В реализации отсутствует одна вещь, которую мы добавим: управление транзакциями. Предполагая, что мы работаем с реляционной базой данных, мы хотели бы, чтобы логика, реализованная в UserService
(поиск и условное сохранение), выполнялась в одной транзакции.
Есть много подходов, которые мы можем использовать, но мы выберем тот, который является композиционным, не требует фреймворков или манипуляций с байт-кодом и в то же время читается. Методы в UserModel
вернут фрагменты транзакции, которые затем будут объединены внутри UserService
в более крупный фрагмент.
Что такое фрагмент транзакции? Это будет любое вычисление, которое требует открытого Connection
(мы предполагаем, что это представляет собой соединение с нашей СУБД) и возвращает некоторое значение. Чтобы упростить работу с такими вычислениями - функциями от Connection
до некоторого типа T
, мы будем использовать контекстные функции:
trait Connection type Connected[T] = Connection ?=> T class UserModel { def find(email: String): Connected[Option[User]] = ??? def save(u: User): Connected[Unit] = ??? } class UserService(userModel: UserModel) { def newUser(u: User): Connected[Boolean] = { userModel.find(u.email) match { case Some(_) => false case None => userModel.save(u) true } } }
Обратите внимание, что мы ничего не изменили в реализации - только сигнатуры типов! При вызове, например, userModel.find
компилятору нужно заданное Connection
- и оно доступно благодаря типу newUser
.
И снова мы имеем дело с отложенными вычислениями. Здесь они зависят от данного соединения. И снова, если вы работали с библиотеками Scala 2, такими как Doobie или Slick, это может показаться знакомым:
Connected[IO[T]] ~= ConnectionIO[T] // doobie Connected[Future[T]] ~= DBIOAction[T] // slick
Идея не нова, но в нашем распоряжении появляется новый мощный инструмент для моделирования вычислений - контекстные функции.
В классе Api
нам нужен способ устранить зависимость вычислений от Connection
. То есть нам нужен способ запустить транзакцию. Это может быть зафиксировано классом DB
:
class DB { def transact[T](f: Connected[T]): T = ??? } class Api(userService: UserService, db: DB) { val result: Boolean = db.transact(userService.newUser(User("[email protected]"))) }
Скрытые и явные зависимости
Мы заметили, что UserModel
является скрытой зависимостью от UserService
. В Connected
мы используем другой вид: явные зависимости (или, может быть, лучше было бы название: зависимости контекста). Эти зависимости распространяются на вызывающего. То есть тот, кто использует наш метод, знает о зависимости и должен предоставить ее значение (что может означать распространение зависимости дальше по уровню).
Оба типа зависимостей полезны и служат разным ролям. Конструкторы отлично подходят для реализации «традиционного» внедрения зависимостей, когда зависимости скрыты. Контекстные функции, с другой стороны, можно использовать для простой реализации явных зависимостей.
Читатель монада
Еще один набег на мир функционального программирования. Мы уже видели различие между скрытыми и явными / контекстными зависимостями. Это проявилось при сравнении читающей монады и конструкторов для внедрения зависимостей.
Действительно, контекстные функции реализуют монаду читателя на уровне языка. Реализация имеет меньше синтаксических и временных накладных расходов, поэтому ее определенно стоит рассмотреть!
Множественные контекстные зависимости
Как мы уже отмечали в начале, контекстные функции могут иметь несколько параметров, и точная форма контекстной функции (каррированная / не ускоренная) и порядок параметров не имеют значения.
Благодаря этому мы можем масштабировать явные зависимости, описанные ранее, для обработки большего количества зависимостей. Например, предположим, что у нас есть две зависимости, которые мы хотим отслеживать явно и которые необходимо передать вызывающей стороне:
trait Connection type Connected[T] = Connection ?=> T trait User type Secure[T] = User ?=> T
Затем мы можем провести вычисление, которое потребует их обоих:
trait Resource def touch(r: Resource): Connected[Secure[Unit]] = ???
touch
- это операция, которая является фрагментом транзакции, и ее необходимо запускать в контексте вошедшего в систему пользователя. Мы можем устранить внутреннюю зависимость, указав заданное значение типа User
:
def update(r: Resource): Connected[Unit] = { given User = ??? touch(r) }
Опять же, способ вложенности требований Connected
и Secure
не имеет значения. Следовательно, мы можем вводить и устранять явные зависимости без каких-либо синтаксических накладных расходов.
Зависимости контекста и монады ввода-вывода
Если вы сегодня используете _72 _ / _ 73_ или ZIO
, вам может быть интересно - будут ли контекстные функции работать с этими типами данных?
Давай попробуем! Мы будем использовать те же зависимости, использование которых мы хотим отслеживать, как и раньше. Предположим, у нас есть очень простая реализация IO
монады и две программы: одна представляет собой фрагмент транзакции, а другая требует авторизованного пользователя:
case class IO[T](run: () => T) { def flatMap[U](other: T => IO[U]): IO[U] = IO(() => other(run()).run()) } val p1: Secure[IO[Int]] = ??? val p2: Connected[IO[String]] = ???
Возникает естественный вопрос - что произойдет, если мы попытаемся совместить эти два вычисления? То есть, что происходит, когда мы flatMap
p1
и p2
, или наоборот?
val r1: Secure[Connected[IO[String]]] = p1.flatMap(_ => p2) val r2: Connected[Secure[IO[String]]] = p1.flatMap(_ => p2)
Все компилируется отлично: требования распространяются на внешний уровень, даже если они используются внутри функции flatMap
.
Обратите внимание, что компилятор не будет определять для нас типы для _80 _ / _ 81_, но он будет направлять нас через сообщения об ошибках, какие зависимости отсутствуют. Например, если мы попытаемся ввести
r1: Connected[IO[String]]
, мы получим (Int
относится к зависимостиp1
):
no implicit argument of type User was found for parameter of Secure[IO[Int]]
Зависимости контекста и среда ZIO
И последнее упоминание о функциональном программировании на Scala. Тип данных ZIO[R, E, A]
описывает вычисление, которое в данной среде R
выдает либо ошибку E
, либо значение A
. Это звучит похоже - разве среда и контекстные функции не совпадают?
В какой-то степени да. Мы могли бы провести такое сравнение:
ZIO[R, E, A] ~= R ?=> asynchronously Either[E, A]
Контекстные функции имеют много преимуществ, предоставляемых средой ZIO: возможность компоновки, простое устранение и введение зависимостей, низкие синтаксические накладные расходы. У них также есть некоторые преимущества перед ними - зависимости не нужно заключать в Has[T]
, чтобы использовать и представлять несколько зависимостей в одном параметре типа.
Но у них есть и недостатки. Среда ZIO поддерживает эффективное создание зависимостей и безопасное освобождение ресурсов зависимостей (если таковые имеются). Это прекрасно интегрируется с остальной частью библиотеки, которая представляет собой набор инструментов для работы с побочными эффектами и параллельными вычислениями.
Возможно, можно было бы ввести функциональные возможности безопасности ресурсов в контекстно-функциональный подход через библиотеку. Интересный участок для будущей работы!
Контекстные функции и побочные эффекты
Ранее мы упоминали, что при работе с псевдонимом типа Executable
добавление сигнатуры типа может изменить поведение программы. Это действительно может стать препятствием для этой абстракции. А как насчет второго описанного нами варианта использования - управления явными зависимостями?
Если рассматриваемая контекстная функция чистая, то есть ее вызов не имеет побочных эффектов, точный момент, когда мы применяем параметр контекста, не имеет значения. Результат всегда будет одним и тем же (однако, оптимизацией может быть, например, вызов функции один раз, а не несколько раз).
Обратите внимание, что это исключает наш Executable[T]
тип - поскольку это псевдоним для ExecutionContext ?=> Future[T]
, применение контекста выполнения может иметь побочные эффекты: может быть создано новое будущее, и вычисления могут быть запущены в фоновом режиме.
С другой стороны, Connected[IO[T]]
должна быть чистой функцией: применение открытого соединения с БД даст только ленивое описание вычислений - никаких побочных эффектов возникнуть не должно. Следовательно, контекстные функции в сочетании с функциональными эффектами кажутся выигрышной комбинацией.
Контекст везде
Контекстные функции - отличное дополнение к системе типов Scala. Они делают язык более регулярным, поскольку теперь так же, как обычный метод можно преобразовать в значение функции, то же самое можно сделать и с методом с заданными / неявными параметрами.
Но не только регулярность: контекстные функции открывают двери новым возможностям абстракции, начиная от правильного представления ExecutionContext
требований и заканчивая беспрепятственным распространением контекстных зависимостей и дополнением того, как внедрение зависимостей может быть реализовано с использованием языка Scala.
К тому же, вероятно, появится еще много вариантов использования, которые появятся, когда люди начнут работать со Scala 3 на ежедневной основе. Жду с нетерпением! :)