Как и почему эффекты помогают писать хороший код

от автора

Привет! В этой статье я расскажу об эффектах и надеюсь, что мой многолетний опыт работы с языками 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-запросы, обрабатывают пользовательские команды, взаимодействуют с внешними базами данных, записывают файлы на диск и решают множество других задач. В таких условиях приоритет отдается качеству, удобству поддержки и масштабируемости кода, а не абсолютной скорости выполнения.

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


Вывод

Эффект объединяет лучшие практики ООП и ФП.

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

Эффект открывает путь к созданию программного обеспечения, которое сочетает в себе надежность ФП с понятностью и структурированностью ООП.

Дополнительные материалы

Книга про эффект-ориентированное программирование:

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


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

А как вы пишите функции?

46.43% Я не кидаю ошибки в теле функций а использую Result type (Either, Option, и тп)13
50% Я использую throw и await без try catch, пусть ошибки ловит вызывающая сторона14
3.57% Я использую/буду использовать эффекты1

Проголосовали 28 пользователей. Воздержались 11 пользователей.

ссылка на оригинал статьи https://habr.com/ru/articles/882946/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *