Привет! В этой статье я расскажу об эффектах и надеюсь, что мой многолетний опыт работы с языками Scala, Java и TypeScript поможет мне в этом. Долгое время я размышлял, как понятнее объяснить, зачем нужны эффекты, и, кажется, нашёл подходящий способ подачи материала. Опыт предыдущей статьи показал, что лучше не торопиться.
Идея использования эффектов не привязана к конкретному языку программирования. Реализацию эффектов можно найти и в других языках; например, в Scala существует библиотека ZIO.
В ходе статьи я буду демонстрировать примеры кода на TypeScript и упоминать библиотеку Effect (Effect-ts).
Идеальной парадигмы программирования не существует
Есть две основные парадигмы программирования — объектно-ориентированное (ООП) и функциональное (ФП).
В ООП часто используются интерфейсы, чтобы можно было легко подставлять нужные реализации без изменения кода, в котором этот интерфейс применяется. В ФП функции не кидают ошибки, а всегда возвращают результат. Для сигнализации об ошибке вместо исключений используется тип Result, который описывает как успешный, так и неуспешный исход выполнения.
Заметка о том, как ФП влияет на Java
Когда-то Java относилась исключительно к ООП, однако, начиная с восьмой версии, в ней появились лямбда-функции, а в 14-й версии — неизменяемые классы (record) и сравнение по шаблону (pattern matching).
Из моего опыта работы с различными языками и парадигмами я пришёл к выводу, что ООП и ФП отлично дополняют друг друга. При использовании каждой из этих парадигм по отдельности можно столкнуться либо с избыточным «шумным» кодом, насыщенным абстракциями и наследованием, либо с кодом, понимание которого требует глубоких математических знаний.
Побочные эффекты в функциях
Создание функций, которые контролируют побочные эффекты, является залогом создания надёжных программ. Использование же грязных функций нередко приводит к тому, что программа завершает работу с длинным стектрейсом.
Заметка про характеристики функций
Чистая функция – это функция, которая при одних и тех же входных данных всегда возвращает один и тот же результат и не имеет побочных эффектов (например, не изменяет внешнее состояние).
Предсказуемая функция – функция, поведение которой можно заранее определить, зная входные данные; обычно чистые функции обладают этим свойством.
Грязная функция – функция, зависящая от внешнего состояния или имеющая побочные эффекты, поэтому ее поведение может меняться даже при одинаковых входных данных.
Рассмотрим пример «грязной» функции (notify), отправляющей HTTP-запрос с текстом сообщения:
async function notify(message: string): Promise<true> { if (message.length < 1) throw new Error("Too short message"); const response = await fetch("http://my-backend/notify", { method: "POST", body: message }); if (!response.ok) throw new Error("Bad response status"); return true as const; }
По сигнатуре функции можно понять, что функция принимает один аргумент и возвращает Promise. Но, невозможно понять какие могут быть ошибки при выполнение и что функция выполняет сетевой запрос к серверу.
Выполнение этой функции может привести к нескольким побочным эффектам. Во-первых, функция может завершиться с ошибкой, прерывая выполнение основной программы. Во-вторых, она инициирует HTTP-запрос.
Чтобы код функции notify отработал без ошибок, нужно чтобы выполнилось несколько условий:
-
длина сообщения message должна быть больше одного символа
-
функция fetch должна быть доступна
-
сервер должен ответить с 20x статусом
Что такое эффект и чем он отличается от функции
Эффект представляет собой описание процесса, которое включает в себя перечисление возможных ошибок и определение требуемого контекста для выполнения. Практическое применение эффектов аналогично применению функций, однако имеются ключевые отличия: в отличие от функций, выполняющихся сразу при вызове, эффекты являются «ленивыми».
В библиотеке Effect-ts, эффект можно представить как тип, описывающий функцию с одним аргументом — контекстом выполнения. Эта функция завершается либо успехом (тип Success), либо ошибкой (тип Error), что соответствует типу Result в ФП.
type Effect<Success, Error, Requirements> = ( context: Context<Requirements> ) => Error | Success
Функция notify, в виде эффекта может выглядеть так:
import { Effect, Context, Data } from "effect"; function notify(message: string): Effect.Effect<true, NotifyError, HttpExecutor> { return Effect.gen(function* () { if (message.length < 1) yield* new NotifyError({ kind: "validation-error" }); const httpClient = yield* HttpExecutor; const response = yield* httpClient.execute("http://my-backend/notify", { method: "POST", body: message }); if (response.ok) yield* new NotifyError({ kind: "server-error", response }); return true as const; }); } class NotifyError extends Data.TaggedError("NotifyError")<{ kind: "validation-error" | "server-error", response?: Response }> {} class HttpExecutor extends Context.Tag("HttpExecutor")<HttpExecutor, { execute: (url: string, options: RequestInit) => Effect.Effect<Response> }>() {}
Теперь функция notify возвращает эффект, в котором указано, что эффекту нужен сервис HttpExecutor, при успешном выполнении будет true, в случае ошибки будет значение типа NotifyError.
NotifyError это класс, описывающий возможные ошибки. HttpExecutor это класс, описывающий сервис, который соответствует интерфейсу с методом execute.
Система эффектов
Чтобы получить результат от функции, достаточно её вызвать – код внутри функции начинает выполняться сразу. Всё просто.
Эффект же, в отличии от функции, представляет собой описание вычисления, поэтому для получения результата необходимо запустить его в системе эффектов (Runtime), которая инициирует выполнение кода, описанного в эффекте.
Запускать эффекты так же просто как и вызывать функции
В Effect-ts существует стандартный Runtime, который отвечает за запуск эффектов. Эффекты запускаются довольно просто с использованием методов runSync или runPromise, которые инициируют выполнение описанных эффектов и возвращают соответствующий результат.
Преимущества эффектов над функциями
Эффекты меняют подход к написанию программ. Классический код, как правило, реализуется в императивном стиле, тогда как использование эффектов ведёт к декларативному подходу. Это позволяет существенно снизить когнитивную нагрузку: вместо того чтобы держать в голове весь контекст и логику выполнения программы, разработчик может сконцентрироваться на конкретном эффекте.
Эффекты наделяют функции свойствами предсказуемости из мира ФП, а побочные эффекты становятся контролируемыми.
Работа с ошибками
У меня складывается впечатление, что многие языки программирования подразумевают, что программисты будут писать идеальный код, обрабатывающий все потенциальные ошибки, nullsafe, и тп. Однако, на практике такого достичь невозможно.
Заметка про конфронтацию ООП и ФП
Разработчики, пишущие на ООП-языках, часто гордятся тем, что создают реальные приложения, и критикуют Haskell-разработчиков, утверждая, что их язык слишком академический и не подходит для решения бизнес-задач. Забавно слышать такое от тех, кто пишет код, игнорируя ошибки, и называет его кодом, решающим реальные проблемы.
Нередко можно встретить код на проектах, в котором игнорируются возможные ошибки. Например, используются операторы await или throw и не ловятся ошибки через try catch.
Ловим ошибку используя try catch
если ошибка типа Error, то печатаем сообщение, иначе пробрасываем ошибку выше
function hey(arg: number) { if (arg == 2) throw Error("boom") console.log("hey", arg); } try { // неудобный try catch hey(1) } catch (error) { // тип error = Any if (error instanceof Error) { console.log("hey was failed with expected error", error.message); } else { throw error } }
В эффектах работа с ошибками становится прозрачной и естественной:
-
Нет необходимости оборачивать код во вложенные выражения;
-
Не нужно выводить тип ошибки, так как он уже зафиксирован в типе самого эффекта.
Обрабатываем возможную ошибку в эффекте
import { Context, Effect, pipe } from "effect"; class MyConsole extends Context.Tag("MyConsole")<MyConsole, { log: (input: unknown) => void }>(){} //function heyEffect(arg: number): Effect.Effect<boolean, Error, MyConsole> function heyEffect(arg: number) { return Effect.gen(function*() { const logger = yield* MyConsole; if (arg == 2) yield* Effect.fail(Error("boom")); logger.log(logger) return true; }); } pipe( heyEffect(1), Effect.provideService(MyConsole, { log: (input) => console.log(input) }), Effect.catchAll(error => { // error соответсвует только типу Error console.log("hey was failed with expected error", error.message); return Effect.void // нужно вернуть новый эффект }), Effect.runSync //запуск эффекта )
Категории ошибок
В Effect-ts есть две категории ошибок, ожидаемые и дефекты (критические). Ожидаемые типы ошибок записаны в типе эффекта, а дефекты не оставляют следов на этом уровне. С ожидаемых ошибок есть смысл восстанавливаться, а с дефектов нет.
Пример дефекта, возможных ошибок в эффекте нет (тип never)
// function heyEffect(arg: number): Effect.Effect<boolean, never, never> function heyEffect(arg: number) { return Effect.gen(function*() { if (arg == 2) yield* Effect.die(Error("boom")); return true; }); }
Аккумулирование ошибок
В Effect-ts все ожидаемые ошибки объединяются в единый union-тип. Это позволяет получить полный обзор возможных ошибок и обрабатывать их согласно требованиям приложения.
Эффект с несколькими ожидаемыми ошибками
import { pipe, Data, Effect } from "effect" export class Error1 extends Data.TaggedError("Error1")<{}> {} export class Error2 extends Data.TaggedError("Error2")<{}> {} const todo = pipe( Effect.fail(new Error1), Effect.andThen( Effect.fail(new Error2) ) ) // const todo: Effect.Effect<never, Error1 | Error2, never>
Dependency injection
В своей практике мне всегда нравилось использовать интерфейсы и активно применять DI-библиотеки. Например, в TypeScript-проектах не раз прибегали к таким решениям, как Inversify или Tsyringe, для эффективного управления зависимостями и повышения тестируемости кода.
Использование DI мотивирует писать функции, принимающие интерфейсы, а также создавать сервисы, реализующие их. Такой подход делает код более гибким и удобным для тестирования. Благодаря ему можно легко менять реализации зависимостей: для тестов используется одна версия, а для production — другая, без необходимости изменять сам код функций.
При использовании эффектов, необходимость сторонних DI‑библиотек отпадает. Зависимости и их типы уже указаны в контексте эффекта, что позволяет системе эффектов (Runtime) самостоятельно позаботиться о создании всех необходимых зависимостей при запуске эффекта.
Запускаем эффект и подставляем тестовую реализацию сервиса MyConsole
import { Context, Effect, pipe } from "effect"; class MyConsole extends Context.Tag("MyConsole")<MyConsole, { log: (input: unknown) => void }>(){} function heyEffect(arg: number) { return Effect.gen(function*() { const logger = yield* MyConsole; if (arg == 2) yield* Effect.fail(Error("boom")); logger.log("hey") return true; }); } pipe( heyEffect(1), // Effect.provideService(MyConsole, { log: (input) => console.info("production implementation", input) } ), Effect.provideService(MyConsole, { log: (input) => console.info("test implementation", input) } ), Effect.runSync //запуск эффекта );
Результат выполнения:
% tsx article-effect/func.ts production implementation hey
Единый синхронно-асинхронный интерфейс
Одним из главных преимуществ применения эффекта является универсальный подход к работе как с синхронным, так и с асинхронным кодом. Использование эффекта не зависит от того, исполняется ли код синхронно или асинхронно.
Эффекты предоставляют единый интерфейс для работы с асинхронными значениями, что позволяет отказаться от применения конструкций async/await и сделать код более единообразным и читаемым.
Эффект, который использует Promise
import { Effect } from "effect"; function sendRequest() { return Promise.resolve("fake response") } //const heyEffect: Effect.Effect<string, UnknownException, never> const heyEffect = Effect.gen(function* () { const response: string = yield* Effect.tryPromise(sendRequest); console.log(response); return response; }); heyEffect.pipe( Effect.runPromise // запуска эффекта (так же как и runSync) );
Результат
tsx article-effect/async.ts fake response
Контроль контекста выполнения
Функции выполняются в глобальном контексте, над которым у разработчиков отсутствует полный контроль.
Эффекты же исполняются в системе эффектов (Runtime). Можно представить аналогию с песочницей, которую можно настроить под свои нужды перед началом игры. Это позволяет создать собственный Runtime и запускать в нём эффекты, что даёт больше контроля над выполнением кода и управлением зависимостями.
Пример создания Runtime с сервисом MyConsole
import { Context, Effect, Layer, ManagedRuntime, pipe, Runtime } from "effect"; class MyConsole extends Context.Tag("MyConsole")<MyConsole, { log: (input: unknown) => void }>(){} // ManagedRuntime.ManagedRuntime<MyConsole, never> const myRuntime = ManagedRuntime.make(Layer.succeed(MyConsole, { log: (input) => console.info("test implementation", input) })) function heyEffect(arg: number) { return Effect.gen(function*() { const logger = yield* MyConsole; if (arg == 2) yield* Effect.fail(Error("boom")); logger.log("hey") return true; }); } pipe( heyEffect(1), Effect.provide(myRuntime), // в Runtime уже есть сервис MyConsole Effect.runSync //запуск эффекта );
Простое управление параллельностью
Система эффектов исполняет эффекты в файберах — легковесных потоках. Это позволяет приостанавливать выполнение эффектов, запускать их партиями в параллель, работать в фоновом режиме (daemon) или создавать форкнутые эффекты, которые живут пока активен их родительский файбер.
История из практики
Мне нужно было написать код для чат-бота, который делает long polling запросы к API с паузой в 2 секунды между ними, а также должен зацикливаться и обрабатывать ошибки. Решение на чистом Promise и TypeScript оказалось бы сложным и накрученным, а с помощью эффектов я смог разобраться за пару часов — всё заработало как нужно.
Комментарий от автора: Сложно привести полный перечень преимуществ эффектов по сравнению с функциями, вы поймете их сами на практике
Когда можно не использовать эффекты
По своему опыту работы с Effect-ts, я заметил, что оборачивать функции в эффекты не обязательно в тех случаях, когда функция:
-
не требует специального контекста выполнения;
-
возвращает Result type вместо бросания ошибок;
-
является синхронной, то есть не выполняет IO-операций (не возвращает Promise) и не генерирует ошибки.
Такие функции уже обладают необходимой предсказуемостью и безопасностью, и их использование без оборачивания в эффекты упрощает код.
Экосистема эффектов
Поскольку эффект является усовершенствованной формой функций, возникли решения, использующие эффект как базовый элемент функциональности вместо традиционных функций.
-
Работа с базами данных;
-
Интеграция с платформами API (например: Node.js, Bun и т.д.);
-
Работа со схемами данных;
-
Взаимодействие с LLM;
-
Обработка потоков, pub/sub и многое другое.
Миф: Использование эффектов делает программы медленнее
Сравнивая скорость выполнения обычной функции и эффекта, можно заметить, что первая, естественно, может работать быстрее. Обычная функция вызывается и исполняется немедленно, тогда как выполнение эффекта включает дополнительные шаги – его создание и инициализацию Runtime.
Однако эффекты ориентированы на прикладное программирование, которым пользуется большинство разработчиков. Прикладные приложения отправляют HTTP-запросы, обрабатывают пользовательские команды, взаимодействуют с внешними базами данных, записывают файлы на диск и решают множество других задач. В таких условиях приоритет отдается качеству, удобству поддержки и масштабируемости кода, а не абсолютной скорости выполнения.
Если критически важны скорость выполнения и контроль над использованием памяти, стоит обратить внимание на системное программирование, используемое для совершенно иных задач.
Вывод
Эффект объединяет лучшие практики ООП и ФП.
Разработчики, начавшие использовать эффекты, выражают общее восхищение: наконец найдена «серебряная пуля», позволяющая создавать действительно качественные программы. Подход, ориентированный на эффекты, позволяет разрабатывать приложения любой сложности без увеличения когнитивной нагрузки на код.
Эффект открывает путь к созданию программного обеспечения, которое сочетает в себе надежность ФП с понятностью и структурированностью ООП.
Дополнительные материалы
Книга про эффект-ориентированное программирование:
А в этом видео Виталий очень круто отвечает на хорошо поставленные вопросы про побочные эффекты, почему программистам сложно писать хороший код, про то, как идея эффектов позволяет не добавлять новые конструкции в языки программирования и многое другое. Рекомендую!
ссылка на оригинал статьи https://habr.com/ru/articles/882946/
Добавить комментарий