Pull to refresh

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

Level of difficultyMedium
Reading time10 min
Views7.7K

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

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


Вывод

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

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

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

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

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

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


Only registered users can participate in poll. Log in, please.
А как вы пишите функции?
44.83% Я не кидаю ошибки в теле функций а использую Result type (Either, Option, и тп)13
51.72% Я использую throw и await без try catch, пусть ошибки ловит вызывающая сторона15
3.45% Я использую/буду использовать эффекты1
29 users voted. 12 users abstained.
Tags:
Hubs:
Total votes 10: ↑7 and ↓3+6
Comments34

Articles