Вы слышали об алгебраических эффектах?
Мои первые попытки выяснить, кто они такие и почему они должны меня волновать, оказались безуспешными. Я нашел несколько PDF-ов, но они еще больше меня запутали. (Я почему-то засыпаю во время чтения академических статей.)
Но мой коллега Себастьян продолжал называть их ментальной моделью некоторых вещей, которые мы делаем в React. (Себастьян работает в команде React и выдвигал немало идей, среди которых Hooks и Suspense.) В какой-то момент это стало локальным мемом в команде React, и многие наши разговоры заканчивались следующим:
Оказалось, что алгебраические эффекты — это крутая концепция, и она не так страшна, как мне вначале показалось после прочтения этих PDF-ов. Если вы просто используете React, вам не нужно ничего о них знать, но если вам, как и мне, интересно, читайте дальше.
(Дисклеймер: я не исследователь в области языков программирования и, возможно, что-то напутал в своем объяснении. Поэтому дайте мне знать, если я ошибаюсь!)
В продакшен пока рано
Алгебраические эффекты в настоящий момент — это экспериментальная концепция из области исследования языков программирования. Это означает, что в отличие от выражений if
, for
или даже async/await
, у вас скорее всего не получится воспользоваться ими прямо сейчас в продакшене. Они поддерживаются только несколькими языками, которые были созданы специально для изучения этой идеи. Есть прогресс в их внедрении в OCaml, который… пока еще продолжается. Другими словами, смотреть, но руками не трогать.
Почему это должно меня волновать?
Представьте, что вы пишете код с помощью goto
, и кто-то рассказывает вам о существовании конструкций if
и for
. Или, может быть, вы погрязли в callback-аду, и кто-то показывает вам async/await
. Довольно круто, не так ли?
Если вы относитесь к тому типу людей, которым нравится изучать новинки программирования за несколько лет до того, как это станет модным, возможно, сейчас самое время заинтересоваться алгебраическими эффектами. Хотя и не обязательно. Это как рассуждать про async/await
в 1999 году.
Ну ладно, что вообще за эффекты такие?
Название может быть немного непонятным, но идея проста. Если вы знакомы с блоками try/catch
, вы очень быстро поймете алгебраические эффекты.
Давайте вспомним сначала try/catch
. Скажем, у вас есть функция, которая бросает исключения. Возможно, есть несколько вложенных вызовов между ней и блоком catch
:
function getName(user) {
let name = user.name;
if (name === null) {
throw new Error('Девочка без имени');
}
return name;
}
function makeFriends(user1, user2) {
user1.friendNames.add(getName(user2));
user2.friendNames.add(getName(user1));
}
const arya = {
name: null
};
const gendry = {
name: 'Джендри'
};
try {
makeFriends(arya, gendry);
} catch (err) {
console.log("Упс, кажись не сработало: ", err);
}
Мы бросаем исключение внутри getName
, но оно «всплывает» сквозь makeFriends
до ближайшего блока catch
. Это главное свойство try/catch
. Промежуточные код не обязан заботиться об обработке ошибок.
В отличие от error codes в языках типа C, при использовании try/catch
вам не нужно вручную передавать ошибки через каждый промежуточный уровень, чтобы обработать ошибку на верхнем уровне. Исключения всплывают автоматически.
Какое это имеет отношение к алгебраическим эффектам?
В приведенном выше примере, как только мы увидим ошибку, мы не сможем продолжить исполнение программы. Когда мы окажемся в блоке catch
, нормальное выполнение программы прекратится.
Все кончено. Уже слишком поздно. Лучшее, что мы можем сделать — это восстановиться после сбоя и, возможно, каким-то образом повторить то, что мы делали, но мы не можем волшебным образом «вернуться» туда, где мы были, и сделать что-то другое. А с алгебраическими эффектами — можем.
Это пример, написанный на гипотетическом JavaScript-диалекте (давайте для прикола назовем его ES2025), который позволяет нам продолжить работу после отсутствующего user.name
:
function getName(user) {
let name = user.name;
if (name === null) {
name = perform 'ask_name';
}
return name;
}
function makeFriends(user1, user2) {
user1.friendNames.add(getName(user2));
user2.friendNames.add(getName(user1));
}
const arya = {
name: null
};
const gendry = {
name: 'Джендри'
};
try {
makeFriends(arya, gendry);
} handle(effect) {
if (effect === 'ask_name') {
resume with 'Арья Старк';
}
}
(Я прошу прощения у всех читателей из 2025 года, кто ищет в Интернете «ES2025» и попадает на эту статью. Если к тому времени алгебраические эффекты станут частью JavaScript, я буду рад обновить статью!)
Вместо throw
мы используем гипотетическое ключевое слово perform
. Аналогично, вместо try/catch
мы используем гипотетический try/handle
. Точный синтаксис здесь не имеет значения — я просто придумал нечто, чтобы проиллюстрировать идею.
Так что же здесь происходит? Давайте посмотрим поближе.
Вместо того, чтобы выдавать ошибку, мы выполняем эффект. Так же, как мы можем бросить (throw
) любой объект, здесь мы можем передать некое значение для обработки. В этом примере я передаю строку, но это может быть объект или любой иной тип данных:
function getName(user) {
let name = user.name;
if (name === null) {
name = perform 'ask_name';
}
return name;
}
Когда мы бросаем исключение, движок ищет ближайший обработчик try/catch
в стеке вызовов. Аналогично, когда мы выполняем эффект, движок будет искать ближайший обработчик эффекта try/handle
сверху по стеку:
try {
makeFriends(arya, gendry);
} handle(effect) {
if (effect === 'ask_name') {
resume with 'Арья Старк';
}
}
Этот эффект позволяет нам решить, как обрабатывать ситуацию, когда имя не указано. Новым здесь (по сравнению с исключениями) является гипотетическое resume with
:
try {
makeFriends(arya, gendry);
} handle(effect) {
if (effect === 'ask_name') {
resume with 'Арья Старк';
}
}
Это то, что вы не можете сделать с помощью try/catch
. Оно позволяет нам вернуться туда, где мы выполнили эффект, и передать что-то обратно из обработчика. :-O
function getName(user) {
let name = user.name;
if (name === null) {
// 1. Мы выполняем эффект тут name = perform 'ask_name';
// 4. ...а потом снова оказываемся здесь (name теперь равно 'Арья Старк') }
return name;
}
// ...
try {
makeFriends(arya, gendry);
} handle(effect) {
// 2. Прыгаем к обработчику (как try/catch) если (effect === 'ask_name') {
// 3. Однако, можем вернуться сюда со значением (совсем не так как try/catch!)
resume with 'Арья Старк';
}
}
Нужно немного времени чтобы освоиться, но концептуально это мало чем отличаются от «try/catch
с возвратом».
Обратите внимание, однако, что алгебраические эффекты гораздо более мощный инструмент, чем просто try/catch
. Восстановление после ошибок — лишь один из многих возможных вариантов использования. Я начал с этого примера только потому, что мне его было проще всего понять.
Функция не имеет цвета
Алгебраические эффекты имеют интересные последствия для асинхронного кода.
В языках с async/await
функции обычно имеют «цвет». Например, в JavaScript мы не можем просто сделать getName
асинхронным, не «заразив» makeFriends
и вызывающих его функций async’ом. Это может стать настоящей болью, если часть кода иногда должна быть синхронной, а иногда асинхронной.
// Если мы хотим сделать это асинхронным ...
async getName(user) {
// ...
}
// Тогда это тоже должно быть асинхронно ...
async function makeFriends(user1, user2) {
user1.friendNames.add(await getName(user2));
user2.friendNames.add(await getName(user1));
}
// И так далее...
async getName(user) {
// ...
}
Генераторы JavaScript работают похожим образом: если вы работаете с генераторами, то весь промежуточный код тоже должен знать о генераторах.
Ну и при чем тут это?
На мгновение давайте забудем об async/await и вернемся к нашему примеру:
function getName(user) {
let name = user.name;
if (name === null) {
name = perform 'ask_name';
}
return name;
}
function makeFriends(user1, user2) {
user1.friendNames.add(getName(user2));
user2.friendNames.add(getName(user1));
}
const arya = {
name: null
};
const gendry = {
name: 'Джендри'
};
try {
makeFriends(arya, gendry);
} handle(effect) {
if (effect === 'ask_name') {
resume with 'Арья Старк';
}
}
Что если наш обработчик эффектов не может вернуть «запасное имя» синхронно? Что если мы хотим получить его из базы данных?
Оказывается, мы можем вызвать resume with
асинхронно из нашего обработчика эффекта, не внося никаких изменений в getName
или makeFriends
:
function getName(user) {
let name = user.name;
if (name === null) {
name = perform 'ask_name';
}
return name;
}
function makeFriends(user1, user2) {
user1.friendNames.add(getName(user2));
user2.friendNames.add(getName(user1));
}
const arya = {
name: null
};
const gendry = {
name: 'Джендри'
};
try {
makeFriends(arya, gendry);
} handle(effect) {
if (effect === 'ask_name') {
setTimeout(() => {
resume with 'Арья Старк';
}, 1000);
}
}
В этом примере мы вызываем resume with
только секундой позже. Вы можете считать resume with
callback’ом, который вы можете вызвать только один раз. (Вы также можете выпендриться перед друзьями, назвав эту штуку «одноразовым ограниченным продолжением» (термин delimited continuation пока не получил устойчивого перевода на русский язык — прим. перев.).)
Теперь механика алгебраических эффектов должна стать немного понятнее. Когда мы выдаем ошибку, движок JavaScript «раскручивает стек», уничтожая локальные переменные в процессе. Однако, когда мы выполняем эффект, наш гипотетический движок создает callback (на самом деле “фрейм продолжения”, прим. перев.) с остальной частью нашей функции, а resume with
вызовет его.
Опять же, напоминание: конкретный синтаксис и конкретные ключевые слова полностью выдуманы только для этой статьи. Суть не в нем, а в механике.
Примечание о чистоте
Стоит отметить, что алгебраические эффекты возникли в результате исследования функционального программирования. Некоторые из проблем, которые они решают, уникальны только для функционального программирования. Например, в языках, которые не допускают произвольных побочных эффектов (например, Haskell), вы должны использовать такие понятия, как монады, для протаскивания эффектов сквозь вашу программу. Если вы когда-нибудь читали туториал по монадам, то вы знаете, что их бывает сложно понять. Алгебраические эффекты помогают сделать нечто похожее с чуть меньшими усилиями.
Вот почему большинство дискуссий об алгебраических эффектах мне совершенно непонятно. (Я не знаю Хаскеля и его “друзей”.) Однако я думаю, что даже на таком нечистом языке как JavaScript, алгебраические эффекты могут быть очень мощным инструментом для отделения “что” от “как” в вашем коде .
Они позволяют вам писать код, который описывает то, что вы делаете:
function enumerateFiles(dir) {
const contents = perform OpenDirectory(dir);
perform Log('Enumerating files in ', dir);
for (let file of contents.files) {
perform HandleFile(file);
}
perform Log('Enumerating subdirectories in ', dir);
for (let directory of contents.dir) {
// можем вызвать себя рекурсивно или вызвать другие функции с эффектами
enumerateFiles(directory);
}
perform Log('Done');
}
А позже обернуть его чем-то, что описывает “как” вы это делаете:
let files = [];
try {
enumerateFiles('C:\\');
} handle(effect) {
if (effect instanceof Log) {
myLoggingLibrary.log(effect.message);
resume;
} else if (effect instanceof OpenDirectory) {
myFileSystemImpl.openDir(effect.dirName, (contents) => {
resume with contents;
});
} else if (effect instanceof HandleFile) {
files.push(effect.fileName);
resume;
}
}
// Массив `files` теперь содержит все файлы
Что означает, что эти части могут стать библиотекой:
import {
withMyLoggingLibrary
} from 'my-log';
import {
withMyFileSystem
} from 'my-fs';
function ourProgram() {
enumerateFiles('C:\\');
}
withMyLoggingLibrary(() => {
withMyFileSystem(() => {
ourProgram();
});
});
В отличие от async/await или генераторов, алгебраические эффекты не требуют усложнения “промежуточных” функций. Наш вызов enumerateFiles
может быть глубоко внутри нашей программы, но до тех пор, пока где-то выше есть обработчик эффекта для каждого из эффектов, которые он может выполнять, наш код будет продолжать работать.
Обработчики эффектов позволяют нам отделить логику программы от конкретных реализаций ее эффектов без лишних танцев и шаблонного кода. Например, мы могли бы полностью переопределить поведение в тестах, чтобы использовать фейковую файловую систему и делать снепшоты логов вместо вывода их на консоль:
import {
withFakeFileSystem
} from 'fake-fs';
function withLogSnapshot(fn) {
let logs = [];
try {
fn();
} handle(effect) {
if (effect instanceof Log) {
logs.push(effect.message);
resume;
}
}
// Snapshot полученных логов.
expect(logs).toMatchSnapshot();
}
test('my program', () => {
const fakeFiles = [ /* ... */ ];
withFakeFileSystem(fakeFiles, () => {
withLogSnapshot(() => {
ourProgram();
});
});
});
Поскольку у функций нет “цвета” (промежуточный код не обязан знать об эффектах), а обработчики эффектов можно компоновать (их можно вкладывать), вы можете создавать с их помощью очень выразительные абстракции.
Примечание о типах
Поскольку алгебраические эффекты происходят из статически типизированных языков, большая часть споров о них сосредоточена на способах их выражения в типах. Это, без сомнения, важно, но может также усложнить понимание концепции. Вот почему в этой статье вообще не говорится о типах. Однако я должен отметить, что обычно тот факт, что функция может выполнять эффект, будет закодирован в сигнатуре ее типа. Таким образом, вы будете защищены от ситуации, когда выполняются непредсказуемые эффекты, или нельзя отследить, откуда они исходят.
Тут вы можете заявить, что технически алгебраические эффекты «придают цвет» функциям в статически типизированных языках, поскольку эффекты являются частью сигнатуры типа. Это действительно так. Однако исправление аннотации типа для промежуточной функции с целью включения нового эффекта само по себе не является семантическим изменением — в отличие от добавления async или превращения функции в генератор. Выведение типов (type inference) также может помочь избежать необходимости каскадных изменений. Важным отличием является то, что вы можете «подавлять» эффекты, вставив пустую заглушку или временную реализацию (например, синхронизирующий вызов для асинхронного эффекта), что при необходимости позволяет вам предотвратить его влияние на внешний код — или превратить его в другой эффект.
Нужны ли алгебраические эффекты в JavaScript?
Честно говоря, я не знаю. Они очень мощные, и можно утверждать, что они слишком мощные для такого языка, как JavaScript.
Я думаю, что они могли бы быть очень полезными для языков, где редка мутабельность, и где стандартная библиотека полностью поддерживает эффекты. Если вы сначала выполняете perform Timeout(1000), perform Fetch('http://google.com')
, и perform ReadFile('file.txt')
, и ваш язык имеет “pattern matching” и статическую типизацию для эффектов, то это может будет очень приятной средой программирования.
Может быть, этот язык будет даже компилироваться в JavaScript!
Какое это имеет отношение к React’у?
Не очень большое. Вы даже можете сказать, что я натягиваю сову на глобус.
Если вы смотрели мой доклад о Time Slicing и Suspense, то вторая часть включает компоненты, считывающие данные из кэша:
function MovieDetails({ id }) {
// А что если оно еще только получается из сети?
const movie = movieCache.read(id);
}
(В докладе используется немного другой API, но суть не в этом.)
Этот код основан на функции React для выборок данных под названием «Suspense
», которая сейчас находится в активной разработке. Интересным тут, конечно, является то, что данных, возможно, еще нет в movieCache — в этом случае нам нужно что-то сначала сделать, потому что мы не можем продолжить выполнение. Технически, в этом случае вызов read() бросает Promise (да, throw Promise — придется проглотить этот факт). Это «приостанавливает» выполнение. React перехватывает этот Promise и запоминает, что надо повторить рендеринг дерева компонентов после того, как брошенный Promise отработает.
Это не алгебраический эффект сам по себе, хотя создание этого трюка было вдохновлено ими. Этот трюк достигает той же цели: некоторый код ниже в стеке вызовов временно уступает чему-то выше в стеке вызовов (в данном случае React), при этом все промежуточные функции не обязаны знать об этом или быть «отравленными» async или генераторами. Конечно, мы не сможем “на самом деле” возобновить выполнение в JavaScript, но с точки зрения React, повторное отображение дерева компонентов после разрешения Promise — это почти то же самое. Можно и схитрить, когда ваша модель программирования предполагает идемпотентность!
Хуки являются еще одним примером, который может напомнить вам об алгебраических эффектах. Один из первых вопросов, который задают люди: откуда вызов useState “знает”, на какой компонент он ссылается?
function LikeButton() {
// Откуда useState знает, в каком оно компоненте?
const [isLiked, setIsLiked] = useState(false);
}
Я уже объяснял это в конце этой статьи: в объекте React существует изменяемое состояние «текущий диспетчер» (current dispatcher), которое указывает на реализацию, которую вы используете в данный момент (например, такую, как в react-dom
). Аналогичным образом, существует свойство «текущий компонент» (current component), которое указывает на внутреннюю структуру данных LikeButton. Вот как useState узнает, что надо делать.
Прежде чем привыкнуть к этому, люди часто думают, что это смахивает на «грязный хак» по очевидной причине. Неправильно полагаться на общее мутабельное состояние. (Примечание: а как вы думаете, как try/catch реализован в движке JavaScript?)
Тем не менее, концептуально вы можете рассматривать useState() как эффект выполнения State(), который обрабатывается React при выполнении вашего компонента. Это «объясняет», почему React (то, что вызывает ваш компонент) может предоставить ему состояние (оно находится выше в стеке вызовов, поэтому он может предоставить обработчик эффекта). Действительно, явная реализация состояния является одним из наиболее распространенных примеров в учебниках по алгебраическим эффектам, с которыми я сталкивался.
Опять же, конечно, это не то, как на самом деле работает React, потому что у нас нет алгебраических эффектов в JavaScript. Вместо этого есть скрытое поле, в котором мы сохраняем текущий компонент, а также поле, которое указывает на текущий «диспетчер» с реализацией useState. В качестве оптимизации производительности существуют даже отдельные реализации useState для маунтов и апдейтов. Но если вы сейчас сильно скривились от этого кода, то можете считать их обычными обработчики эффектов.
Подводя итог, можно сказать, что в JavaScript throw
может работать, как первое приближение для эффектов ввода-вывода (при условии, что код можно безопасно повторно выполнить позже, и до тех пор, пока он не привязан к CPU), а изменяемое поле «диспетчер», восстанавливаемое в try / finally, может служить грубым приближением для обработчиков синхронных эффектов.
Вы можете получить гораздо более высококачественную реализацию эффектов с помощью генераторов, но это означает, что вам придется отказаться от «прозрачной» природы функций JavaScript и вам придется сделать все генераторами. А это — “ну такое...”
Где узнать больше
Лично я был удивлен, насколько большой смысл приобрели алгебраические эффекты для меня. Я всегда изо всех сил пытался понять абстрактные понятия, такие как монады, но алгебраические эффекты просто взяли и “включились” в голове. Я надеюсь, что эта статья поможет им «включиться» и у вас.
Я не знаю, начнут ли они когда-нибудь массового использоваться. Я думаю, что я буду разочарован, если они не приживутся ни на одном из основных языков к 2025 году. Напомните мне проверить через пять лет!
Я уверен, что с ними можно делать гораздо больше интересного, но действительно трудно почувствовать их силу пока не начнешь писать код и их использованием. Если этот пост разбудил в вас любопытство, вот еще несколько ресурсов, где вы можете почитать поподробнее:
- github.com/ocamllabs/ocaml-effects-tutorial
- www.janestreet.com/tech-talks/effective-programming
- www.youtube.com/watch?v=hrBq8R_kxI0
Многие люди также указывали, что если опустить аспект типизирования (также как я делал в этой статье), вы можете найти более раннее использование такой техники в системе условий (condition system) в Common Lisp. Вам также может быть интересным пост Джеймса Лонга о продолжениях, в котором объясняется, как примитив call/cc может служить основой для создания возобновляемых исключений в пользовательской среде.