Привет! В этой статье я расскажу об эффектах и надеюсь, что мой многолетний опыт работы с языками Scala, Java и TypeScript поможет мне в этом. Долгое время я размышлял, как понятнее объяснить, зачем нужны эффекты, и, кажется, нашёл подходящий способ подачи материала. Опыт предыдущей статьи показал, что лучше не торопиться.
Идея использования эффектов не привязана к конкретному языку программирования. Реализацию эффектов можно найти и в других языках; например, в Scala существует библиотека ZIO.
В ходе статьи я буду демонстрировать примеры кода на TypeScript и упоминать библиотеку Effect (Effect-ts).
Идеальной парадигмы программирования не существует
Есть две основные парадигмы программирования — объектно-ориентированное (ООП) и функциональное (ФП). По моим наблюдениям, эти подходы часто смешиваются и дополняют друг друга.
Заметка о том, как ФП влияет на Java
Когда-то Java относилась исключительно к ООП, однако, начиная с восьмой версии, в ней появились лямбда-функции, а в 14-й версии — неизменяемые классы (record) и сравнение по шаблону (pattern matching).
Из моего опыта работы с различными языками и парадигмами я пришёл к выводу, что ООП и ФП отлично дополняют друг друга. При использовании каждой из этих парадигм по отдельности можно столкнуться либо с избыточным "шумным" кодом, насыщенным абстракциями и наследованием, либо с кодом, понимание которого требует глубоких математических знаний.
Проблема ООП-функции
Функция — это термин, используемый как в ООП, так и в ФП. Поэтому я буду использовать термин "ООП-функция", чтобы обозначать классическую функцию, а не математическую.
"Функциональщики" разделяют функции на предсказуемые и грязные. Они знают, что такое побочные эффекты, и это знание позволяет им писать надежные программы.
Заметка про характеристики функций
Чистая функция – это функция, которая при одних и тех же входных данных всегда возвращает один и тот же результат и не имеет побочных эффектов (например, не изменяет внешнее состояние).
Предсказуемая функция – функция, поведение которой можно заранее определить, зная входные данные; обычно чистые функции обладают этим свойством.
Грязная функция – функция, зависящая от внешнего состояния или имеющая побочные эффекты, поэтому ее поведение может меняться даже при одинаковых входных данных.
В языках программирования, ориентированных на ООП, функцию часто рассматривают как именованный фрагмент кода, который можно просто запустить.
Игнорирование особенностей функций в таких языках нередко приводит к хаосу. Некоторые разработчики осознают преимущества предсказуемых функций и используют это понимание для создания качественного кода (например, для упрощения тестирования). Однако особенности дизайна ООП-языков скрывают эти детали от менее опытных специалистов.
Заметка о влиянии дизайна языка программирования
Когда я начал писать на Scala, я постепенно замечал, как растёт мое понимание того, как писать качественный код. Такой опыт отсутствовал, когда я работал с PHP или JS — там можно было писать любой код, а потом тратить огромное количество времени на отладку и поиск ошибок.
Поэтому дизайн языка может достаточно сильно влиять на уровень разработчика писать хороший код
Анатомия ООП-функции
Функция имеет название, аргументы и, возможно, тип возвращаемого значения. Тело функции – это код, который выполняется при её вызове. При этом в теле может находиться любой код, который даже не отражает название функции.
Когда функция вызывается, она получает доступ к контексту выполнения (например, любая функция в JavaScript, запущенная в браузере, имеет доступ к объекту window). Также функция может не выполнить весь код тела, а завершить своё выполнение с ошибкой (например, посредством оператора throw).
Таким образом, ООП-функция скрывает от нас следующие моменты:
что именно используется из контекста выполнения;
с какими возможными ошибками функция может прервать своё выполнение.
// function hey(arg: number): void
function hey(arg: number) {
if (arg == 2) throw Error("boom")
console.log("hey", arg);
}
hey(1); // вызов функцииПо сигнатуре функции hey нельзя понять, что она использует console из контекста выполнения и она может прервать свое исполнение с ошибкой типа
Error. Всё это становится очевидным только при анализе самого кода функции.
Выполнение ООП-функции
Представим, что функцию hey запустили. В этот момент в неё неявно передается контекст выполнения, содержащий глобальный объект console. Это делает невозможным подставить разные реализации сервиса console для разных запусков функции hey.
В ООП интерфейсы используются именно для того, чтобы можно было легко подставлять нужные реализации без изменения кода, в котором этот интерфейс применяется.
Другой недостаток состоит в том, что вызывающая сторона должна быть готова к тому, что функция может прервать выполнение кода и выбросить ошибку.
В ФП функции не кидают ошибки, а всегда возвращают результат. Для сигнализации об ошибке вместо исключений используется тип Result, который описывает как успешный, так и неуспешный исход выполнения.
Что такое эффект и как он решает проблемы ООП-функции
Эффект представляет собой описание процесса, которое включает в себя перечисление возможных ошибок и определение требуемого контекста для выполнения. Практическое применение эффектов аналогично применению ООП-функций, однако имеются ключевые отличия: в отличие от ООП-функций, выполняющихся сразу при вызове, эффекты являются "ленивыми".
В библиотеке Effect-ts, эффект можно представить как тип, описывающий функцию с одним аргументом — контекстом выполнения. Эта функция завершается либо успехом (тип Success), либо ошибкой (тип Error), что соответствует типу Result в ФП.
type Effect<Success, Error, Requirements> = (
context: Context<Requirements>
) => Error | SuccessСистема эффектов
Чтобы получить результат от ООП-функции, достаточно её вызвать – код внутри функции начинает выполняться сразу. Всё просто.
С эффектом ситуация немного сложнее. Эффект представляет собой описание вычисления, поэтому для получения результата необходимо запустить его в системе эффектов (Runtime), которая инициирует выполнение кода, описанного эффектом.
Запускать эффекты так же просто как и вызывать ООП-функции
В Effect-ts существует стандартный Runtime, который отвечает за запуск эффектов. Эффекты запускаются довольно просто с использованием методов runSync или runPromise, которые инициируют выполнение описанных эффектов и возвращают соответствующий результат.
Пример создания эффекта и его запуск
import { Context, Effect, pipe } from "effect";
// Идентификатор сервиса с интерфейсом { log: (input: unknown) => void }
class MyConsole
extends Context.Tag("MyConsole")<MyConsole, {
log: (input: unknown) => void
}>(){}
// function heyEffect(arg: number): Effect.Effect<boolean, Error, MyConsole>
function makeHeyEffect(arg: number) {
return Effect.gen(function*() {
const logger = yield* MyConsole;
if (arg == 2) yield* Effect.fail(Error("boom"));
logger.log(logger)
return true;
});
}
pipe(
makeHeyEffect(1),
Effect.provideService(MyConsole, { log: (input) => console.log(input)} ),
Effect.runSync // запуск эффекта
);Теперь по сигнатуре функции видно, что heyEffect создает объект класса Effect. В generic параметрах эффекта указано три типа:
Boolean, тип результата успешного выполнения эффекта;
Error , тип сигнализирующий возможную ошибку эффекта;
MyConsole, тип контекста выполнения эффекта.
Преимущества эффектов над ООП-функциями
Эффекты меняют подход к написанию программ. Классический код, как правило, реализуется в императивном стиле, тогда как использование эффектов ведёт к декларативному подходу. Это позволяет существенно снизить когнитивную нагрузку: вместо того чтобы держать в голове весь контекст и логику выполнения программы, разработчик может сконцентрироваться на конкретном эффекте.
Эффекты наделяют ООП-функции свойствами предсказуемости из мира ФП, а побочные эффекты становятся контролируемыми.
Работа с ошибками
У меня складывается впечатление, что создатели нефункциональных языков программирования разрабатывали их исходя из предположения, что программисты будут способны писать идеальный код, учитывающий все потенциальные ошибки. Однако, на практике такого достичь невозможно.
Заметка про конфронтацию ООП и ФП
Разработчики, пишущие на ООП-языках, часто гордятся тем, что создают реальные приложения, и критикуют 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-запросы, обрабатывают пользовательские команды, взаимодействуют с внешними базами данных, записывают файлы на диск и решают множество других задач. В таких условиях приоритет отдается качеству, удобству поддержки и масштабируемости кода, а не абсолютной скорости выполнения.
Если критически важны скорость выполнения и контроль над использованием памяти, стоит обратить внимание на системное программирование, используемое для совершенно иных задач.
Вывод
Эффект объединяет лучшие практики ООП и ФП.
Разработчики, начавшие использовать эффекты, выражают общее восхищение: наконец найдена "серебряная пуля", позволяющая создавать действительно качественные программы. Подход, ориентированный на эффекты, позволяет разрабатывать приложения любой сложности без увеличения когнитивной нагрузки на код.
Эффект открывает путь к созданию программного обеспечения, которое сочетает в себе надежность ФП с понятностью и структурированностью ООП.
Дополнительные материалы
Книга про эффект-ориентированное программирование:
А в этом видео Виталий очень круто отвечает на хорошо поставленные вопросы про побочные эффекты, почему программистам сложно писать хороший код, про то, как идея эффектов позволяет не добавлять новые конструкции в языки программирования и многое другое. Рекомендую!
