Вы слышали об алгебраических эффектах?
Мои первые попытки выяснить, кто они такие и почему они должны меня волновать, оказались безуспешными. Я нашел несколько 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 может служить основой для создания возобновляемых исключений в пользовательской среде.
Комментарии (43)
AxeFizik
09.10.2019 10:19А причем тут алгебра?
barbalion Автор
09.10.2019 10:31Это яйцеголовые придумали чтобы нормальным людям голову морочить.
А если серьезно, то это вводит формальную «алгебру эффектов». Подробности надо читать в соответствующих статьях.
NeoCode
09.10.2019 11:00+1Хочу уточнить, под «алгебраическими эффектами» понимается только концепция передачи управления try-handle-perform-resume, описанная в статье, или это нечто большее? Еще и название «эффекты» подразумевает множественное число…
Вообще идея интересная, хотя и goto-ориентированная (как впрочем и исключения). Произвольный код (возможно, очень большой и сложный чужой код) из своих недр запрашивает нечто (обработчик), о чем программист может не знать и не догадываться, и если этого нет — программа падает. На уровне кода никакой спецификации требуемых обработчиков нет. То есть все скомпилируется, никаких ошибок компиляции не будет — но из-за незнания всех требуемых обработчиков или даже опечатки в одной букве всё падает. В случае скриптовых языков компиляция не предусмотрена, но упадет в любом случае…nexmean
09.10.2019 12:50В типизированных языках вы код использующий алгебраические эффекты не передав нужные обработчики не запустите.
NeoCode
09.10.2019 16:14+1Передать обработчики я могу и без «алгебраических эффектов»:) Просто передам интерфейс или делегат/указатель на функцию и всё.
Насколько я понял, здесь основная фишка в том, что ничего явно передавать не нужно.
user_man
09.10.2019 16:05+1Из статьи не понял — зачем это всё? Очередная дань моде?
А если вместо perform log(«aaa»); написать log(«aaa»); то что изменится? Ах да, мы сможем написать функцию log() лог где-то рядом, а не в месиве кода, с кучей if-ов, имитирующих switch и в рамках дополнительно усложняющей язык конструкции по обработке эффектов. А что же ещё «улучшилось» в предлагаемом в статье варианте? Обработка эффектов «позднее», да ещё и кем-то другим? Ну есть в мире понятия интерфейс или абстрактный метод, это не подходит? В скриптовом языке нет полных аналогов? А не до конца полный аналог что мешает использовать? Асинхронность нужна? Так а в функции её никак? Всё равно она будет, так или иначе, но тогда — зачем иначе, если и так всё работает?
В общем — посыл автора статьи не понят. Может кто просветит меня, недалёкого? Зачем это всё? В чём профит?adictive_max
10.10.2019 04:24А если вместо perform log(«aaa»); написать log(«aaa»); то что изменится?
Изменится то, что вы можете переопределить функцию «log» как вам нужно в месте вызова enumerateFiles, и не заморачиваться, как её прокинуть внутрь enumerateFiles.
Основной профит — DI, вшитое в язык, без DI-контейнеров и/или ручного прокидывания зависимостей по стеку.user_man
10.10.2019 13:54Я же написал выше — это решается стандартными средствами, встроенными во множество языков. Чаще всего это абстрактные методы или интерфейсы. Ну а конкретно в JS можно в прототип засунуть этот log() и далее подменять прототип, если есть желание.
То есть коротко — всё давно решено. Зачем усложнять?
Получается, как говорится — Оккамом, да по тому, что между ног.0xd34df00d
10.10.2019 16:10В более других языках компилятор может гарантировать, что вы всегда логгируете/читаете из файлов/пишете в сеть, используя предоставленные через DI функции, а не «напрямую».
0xd34df00d
09.10.2019 17:46Однако исправление аннотации типа для промежуточной функции с целью включения нового эффекта само по себе не является семантическим изменением — в отличие от добавления async или превращения функции в генератор.
На этом моменте я совсем перестал понимать, чем это отличается от какого-нибудь
MonadReader
(да и вообще от произвольных таких вот монадических тайпклассов).mayorovp
09.10.2019 18:30Я так понял, именно здесь и проявляется та самая "алгебраичность".
В случае с монадами какие-нибудь
ReaderT Foo WriterT Bar Maybe ()
иWriterT Bar ReaderT Foo Maybe ()
— это два разных несовместимых типа, типы эффектов же коммутативны.
Но в динамически типизированном языке вся идея вырождается просто в ещё один способ запутывания кода.
0xd34df00d
09.10.2019 18:57В случае с монадами какие-нибудь ReaderT Foo WriterT Bar Maybe () и WriterT Bar ReaderT Foo Maybe () — это два разных несовместимых типа
Но так
никто не пишетнепонятно зачем писать. Можно же(MonadReader Foo m, MonadWriter Bar m) => ...
.
Ну и не все эффекты коммутативны.
WriterT
внутриExceptT
не то же самое, что наоборот.mayorovp
09.10.2019 19:12Ну, операцию perform таким образом реализовать можно, и это даже будет удобно. А вот с handle будут проблемы.
Представьте, что у вас есть функция с 4 эффектами:
f1 :: (Foo m, Bar m, Baz m, Qux m) => m ()
И вы собираетесь обработать Foo, пробросив остальные...
f2 :: (Bar m, Baz m, Qux m) => m () f2 = runFooT f1
Как будет выглядеть функция runFooT?
Учтите, что Bar, Baz и Qux тут — это не что-то стандартное, вроде тех же MonadReader/MonadWriter, а нечто, связанное с предметной областью. Например, в одном из этих классов может быть скрыт запрос имени пользователя. То есть любые трансформеры надо самому писать, библиотечных нет...
0xd34df00d
10.10.2019 16:08Ну, да, когда пишется свой эффект, то пишутся и обёртки для коммутирования с прочими эффектами. Есть, кстати, чувство, что это в значимой части случаев можно автоматизировать. Надо посмотреть, можно ли всё это завернуть как-то хорошо в дженерики, например.
Ну и есть обобщённый тайпкласс для прокидывания монад, но я с ним не работал.
Druu
10.10.2019 18:28На этом моменте я совсем перестал понимать, чем это отличается от какого-нибудь MonadReader (да и вообще от произвольных таких вот монадических тайпклассов).
Ну аффтар статьи же сразу сказал, что сам ничего не понял, т.к. сложна. Что не остановило его от желания кому-то что-то пообъяснять, кек.
Вообще, это старая, давно известная в узких кругах лиспо-коммунити (scheme конечно в основном) техника, которую уже лет 10+ назад на волне хайпа ФП все обсосали, а теперь вот фронтендеры, как водится, в силу своих скромных (в плане знания CS) возможностей пытаются как-то вписать в свой дискурс.
Все просто там. Если ты можешь захватить продолжение, то весь твой код, эдак незримо, в cont-монаде. cont-монада, как известно, mother of all monads, с-но, записав код в cont-монаде (а твой, получается, всегда в ней), ты можешь этот же код, не меняя, запустить в другой (вообще любой, но если монада one entrance, то только в той, в которой фунарг фмапа применяется не больше раза — например, мейби или там стейт можно, а вот лист — уже нельзя) монаде, подставив другой аргумент в качестве ф-и обертки. Ф-я обертка — она применяется к аргументу, который справа от стрелки в do-нотации, т.е. вместо x <- something у нас x <- wrapper something, внутри wrapper как раз и содержится вся логика handle из примеров в статье.
wrapper effect = cont (\c -> if effect == 'ask_name' then (c 'Арья Старк') else (c effect)) yoba perform userName = runCont (do name <- userName result <- if name == 'null' then (perform 'ask_name') else name return result) id yoba wrapper user
С-но try calculation handle = runCont (calculation handle) id
Понятно, что вообще у нас код и так по монаде в хаскеле каком-нибудь полиморфный по m, но тут мы этот полиморфизм вытаскиваем в фунарг wrapper.
ЗЫ: а, ну да, с-но wrapper x = cont (\c -> x >>= c) — вот так вот делаем произвольную монаду
user_man
11.10.2019 11:59Вообще, это старая, давно известная в узких кругах лиспо-коммунити (scheme конечно в основном) техника, которую уже лет 10+ назад на волне хайпа ФП все обсосали, а теперь вот фронтендеры, как водится, в силу своих скромных (в плане знания CS) возможностей пытаются как-то вписать в свой дискурс.
Это классно. Но далее крайне опытный аффтор, видимо в силу своих нескромных (в плане знания CS) возможностей, вместо простого и понятного пояснения для писателей фронта, выдаёт текст в духе:
но если монада one entrance, то только в той, в которой фунарг фмапа применяется не больше раза
Поэтому возникает вопрос — а зачем это всё написано? Сторонники ФП и без дополнительных пояснений всегда будут фанатеть по своему ФП, а вот писатели с фронта никогда и ничего в подобном тексте не поймут. Так для кого это?
Скромный совет — уж если разбирать некие смыслы, выдаваемые императивно ориентированными джунами, то и излагать стоит именно с использованием императивного подхода. Иначе получается, что зазнавшийся сторонник ФП хихикает над абсолютно стандартной для любого джуна (которым он и сам был) ситуацией, и при этом задирает нос до небес, не опускаясь (или просто не умея?) до объяснений, понятных джунам.Druu
11.10.2019 13:20Так для кого это?
Очевидно же — для человека, которому в ответ был написан комментарий, и я не думаю, что у него могут возникнуть проблемы с пониманием фразы "фунарг фмапа применяется не более раза". Это комментарий, направленный в ответ на другой комментарий, а не статья, расчитанная, по умолчанию, на широкий круг читателей.
Если вдруг условный "писатель с фронта" да и кто вообще угодно действительно захочет разобраться — легкогугление по "the mother of all monads" и тыкание языка с нативной поддержкой продолжений (любой scheme-dervied диалект) — решает вопрос, т.к. просто элементарно пишутся реализации perform/handle и все ясно сразу. Если у вас есть конкретные вопросы — я тоже могу пояснить.
ЗЫ: "но если монада one entrance" — да, это опечатка, конечно, продолжение one entrance.
user_man
11.10.2019 14:17>> Очевидно же — для человека, которому в ответ был написан комментарий
Хорошо, я несколько недостаточно уделил внимание контексту. Признаюсь — ожидал пояснения фразы «техника, которую уже лет 10+ назад на волне хайпа ФП все обсосали», но так и не заметил сути.
>> Если у вас есть конкретные вопросы — я тоже могу пояснить.
Лично мне интересно понять, как могли бы выглядеть алгебраические эффекты в императивном языке.
vagonpidarasov
09.10.2019 19:51Первый вопрос в том насколько эта фича языка будет эффективной? Ведь раскручивание стека и его обратное закручивание — дело дорогостоящее.
Второй вопрос в том насколько это востребовано? Если вы не можете писать код без использования этих самых алгебраических эффектов, то, возможно, что-то с вашим кодом не так.
В общем, как обычно, хипстеры придумали себе проблему и придумали для неё оригинальное решение.
Sing1e
09.10.2019 19:52На основе примера из статьи набросал минимальный рабочий пример на C# 8.0 (можно и на предыдущей версии, если убрать null-coalescing assignment оператор
??=
, который я использовал чтобы уместить тело метода в одну строку). Ещё неплохо было бы сделать очистку обработчиков при выходе из области и перегрузку обработчиков в дочерних областях, но мне лень.
Кодusing System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using static ConsoleApp1.Effects; namespace ConsoleApp1 { class Program { delegate Task<IEnumerable<FileSystemInfo>> OpenDirectory(DirectoryInfo dir); delegate void Log(string message); delegate void HandleFile(FileInfo file); static async Task Main(string[] args) { Handle<Log>(Console.WriteLine); Handle<OpenDirectory>(async dir => { await Task.Delay(100); return dir.GetFileSystemInfos(); }); var files = new List<FileInfo>(); Handle<HandleFile>(files.Add); await EnumerateFiles(new DirectoryInfo(@"D:\")); } public static async Task EnumerateFiles(DirectoryInfo dir) { var contents = await Perform<OpenDirectory>()(dir); Perform<Log>()($"Enumerating files in '{dir}'"); foreach (var file in contents.OfType<FileInfo>()) { Perform<HandleFile>()(file); } Perform<Log>()($"Enumerating subdirectories in '{dir}'"); foreach (var directory in contents.OfType<DirectoryInfo>()) { await EnumerateFiles(directory); } Perform<Log>()("Done"); } } public class Effects { private static readonly AsyncLocal<Dictionary<Type, object>> Handlers = new AsyncLocal<Dictionary<Type, object>>(); [DebuggerStepThrough] public static void Handle<T>(T handler) where T : Delegate => (Handlers.Value ??= new Dictionary<Type, object>())[typeof(T)] = handler; [DebuggerStepThrough] public static T Perform<T>() where T : Delegate => (T)Handlers.Value?[typeof(T)]; } }
mayorovp
09.10.2019 20:18Нельзя просто так хранить Dictionary внутрях AsyncLocal — там запросто возможен многопоточный доступ. Async flow, в отличии от Thread, нелинеен.
Возможно,
ImmutableDictionary<Type, object>
будет лучшей идеей.
Кстати, с каких пор конструкция
where T : Delegate
разрешена?
mvv-rus
10.10.2019 03:27Обычно всякую хорошую идею кто-нибудь, когда-нибудь и где-нибудь реализовывал.
В частности это касается и идеи возобновления выполнения выполнения как способа обработки исключений.
Первая известная мне реализация появилась в продукте, с которым почти все наверняка сталкивались — в API ядра Windows NT (которая является предшественником всех современных версий Windows) и основанном на нем API Win32. Называется она Structured Exception Handling (SEH), существует с самой первой версии WinNT и широко используется самим ядром. В целом SEH аналогична по функциональности другим системам обработки исключений, но ней есть дополнительный вариант — продолжить выполнение кода после его прерывания: для этого обработчик исключения должен вернуть значение EXCEPTION_CONTINUE_EXECUTION.
Это позволяло, например, совершенно прозрачным образом обрабатывать ошибки отсутствия страницы виртуальной памяти в режиме ядра (Windows NT позволяла деражать часть кода и данных режима ядра в виртуальной, выгружаемой на диск, памяти, и в те времена жуткого дефицита памяти, когда она появилась, это было для нее существенным плюсом).
Работало это примерно так. При обращении к отсутствующей в RAM странице виртуальной памяти аппаратура генерировала прерывание, которое обработчик этого прерывания преобразовывал в исключение. Обработчик исключения отсутствия страницы находившийся где-то далеко вверху стека вызовов, проверял, что ядро в момент прерывания находилось в режиме, позволяющем запустить операцию ввода вывода и подождать её завершения (IRQL<2), запускал операцию чтения из страничного файла и ждал завершения операции. После успешного завершения чтения обработчик выходил из ожидания и возвращал это самое значение EXCEPTION_CONTINUE_EXECUTION — после чего прерванный поток выполнения мог выполняться дальше, как будто ничего не произошло.
В Visual C/С++ SEH была реализована через специфичное для платформы расширение языка (__try… __except) — которое было, естественно, никуда не переносимо и, к тому же, конфликтовало с механизмом обработки исключений языка C++ (впрочем, это — особенность реализации: в Delphi — не конфликтовало, т.к. тамошний механизм исключений в языке был построен как раз на SEH).
Потому, по-видимому, SEH осталась специфичной чертой Windows, к тому же — не часто используемой.
Так что это хорошо, что старая идея обретает новую жизнь на уровне широко распространенного языка, пусть и под новым именем. Но, естественно, новая жизнь — это не просто повторение старого: доработка неизбежно потребуется. Например, как в статье написано, для совмещения концепции возобновления выполнения после исключения с современной моделью асинхронной обработки.
Gryphon88
11.10.2019 14:51Извините, не совсем по теме. Я понимаю catch как способ заявить программисту «У тебя возможная ошибка в логике программы, или на вход поданы некорректные данные» и откатить состояние на до исключения. В идеале этот код вообще вызываться не должен, а если таки вызывается, не должен быть конструктивной частью основной логики программы. Это я неправильно понимаю исключения, или автор из них пытается сделать что-то, чем они не являются?
ksigne
Вообще-то говоря эту концепцию придумали еще в далеком 90-м и назвали Dependency Injection. Причем канонический poor man DI через интерфейсы и контракты намного лучше предложенного хотя бы тем, что открыто декларирует API, который нужен функции, чтобы корректно работать.
adictive_max
Не сказал бы, что особо лучше. DI во всех реализациях, что я видел — это какой-нибудь монструозный диспетчер на уровне фреймворка, к которому надо обращаться +- в явном виде. А тут всё-таки конструкция языка.
По поводу удобства спорно. Много ли DI-фреймворков позволяют определить контекст для произвольного куска кода? Много ли позволяют работают по стеку вызова, а не по стеку конструкторов? Много ли позволяют делать инъекции опциональными? Можно ли как-то гибко реализовать DI для отдельно лежащего пакета без привязки к конкретному DI-фреймворку?
Имхо, вариант «я запрос отправил, а дальше обрабатывайте как хотите» более удобен.
Ну и с «открытой декларацией» проблем как-то особо нет. Возможные эксепшены легко вычисляются по анализу кода, для эффектов тут даже переделывать ничего не придётся.
barbalion Автор
Плюс добавим возможность асинхронного выполнения эффектов, даже если сама библиотека синхронная.
Еще один плюс — переопределение эффектов на разных уровнях вложености без протаскивания локального контекста или без необходимости запоминать старые хендлеры.
С DI можно добиться обычной делегации, но очень ограниченно: приходится все равно протаскивать делегация по всему стеку либо вываливать кишки (особенности имплементации) наружу, нарушая инкапсуляцию и усложняя API.
Идея делегирования, конечно, стара как мир и в разных вариантах возникала неоднократно. Кажется, на этот раз есть шанс получить нечто продуманное и стройное. И совместимое с функциональными ЯП. А, напомню, для них все и делается изначально. Через 10 лет это будет и в Джаве, как в свое время туда перекочевали из функциональных языков лямбды. Интерфейсы и анонимные классы в Джаве уже и так были, но с лямбдами стало жить намного проще. Туда все и идем — к более выразительным концепциям и конструкциям языка.
amphasis
Вместо «цвета» такая функция явно приобретет не самый приятный «запах»
nexmean
Так а с чего вы взяли, что будет блокировка потока?
mayorovp
Хотя бы с того, что если у нас не Go с горутинами и сегментированным стеком, то поток во время выполнения обработчика просто не получится ни для чего использовать. Стек-то занят.
nexmean
А что мешает обработчику выполняться асинхронно и не блокировать поток на обработке одного действия?
amphasis
0xd34df00d
Зачем Go? Любой язык с гринтредами.
mayorovp
Да не важно. Тут важно что:
mkuzmin
Могу предложить такую метафору.
У вас есть код с эффектами. Этот код чистый, т.е. сам по себе он не взаимодействует с внешним миром.
Чтобы выполнить этот код нужен интерпретатор эффектов. И уже этот интерпретатор взаимодействует с внешним миром.
Этот интерпретатор вполне может использовать event loop вместо зеленых потоков.
Т.е. код с эффектами ни синхронный ни ассинхронный, это зависит от интерпретатора.
Или интерпретатор может идти по заранее подготовленному списку эффектв и их результатов — коэффектов. Таким образом мы можем проверить работу нашего кода.
Или "отмотать" код назад.
Или перенести частично исполненный код на другую машину/платформу, перенеся результаты уже исполненных эффектов и имея совместимый код на обеих машинах.
amphasis
mkuzmin
В браузере или nodejs есть единственный поток/процесс. Да, тяжелая математика его заблокирует. Но ввод вывод там асинхронный. На основе эффектов можно делать async/await только без цветных функций. Например передавать в map или reduce функцию с эффектом, что нельзя сейчас сделать.
mayorovp
Если использовать pure DI (оно же poor man DI) — то все.
"Опциональная инъекция" — это вообще как?
Да, это называется pure DI.
Ну-ну. Успехов в вычислении возможных исключений при вызове виртуального метода в абстрактном классе.
adictive_max
Напомните, пожалуйста, «pure DI» — это «прокидывай всё ручками»?
mayorovp
Нет, это "принимай всё руками". Часть про "прокидывай" как раз должен фреймворк решать.
Raspy
Возможно я не до конца понял все ваши вопросы, но попробую ответить (несколько лет не занимаюсь боевым программированием по работе, только как хобби):
Практически любой, так как инжекторы обычно имеют свойство наследования и изолированности. Создавай свои сколько влезет.
Любой. В случае ts нужно помещать инжекторы в глобальную область и использовать с помощью декораторов для обработки вызовов методов, полей, etc.
Практически все. Кто мешает делать NOOP реализации?
Можно написанием DI-враппера(-ов), это уже чисто на усмотрение разработчика, который пилит код. Почти никогда эта задача не встречается в мире небольших проектов или продуктов. В кровавом же Ынтерпрайсе ресурс всегда найдётся.
У нас на проекте, например, уживались следующие DI: гуисовый, спринговый и кондовый cdi.
adictive_max
Ну собственно, примерно о чём я и говорил, если и можно, то ценой такой кучи гемороя, что лучше вообще от такой идеи отказаться, и писать «тупой не расширяемый императив», который хотя бы читать можно будет без боли.
DrZlodberg
В простейшем виде это было ещё в древнем BASIC. Собственно оператор RESUME и RESUME NEXT (для возврата к следующей за вызвавшей ошибку строке). Примерно так и использовалось, только приходилось городить более сложные обработчки с учётом либо номеров строк, либо переключая блоки обработчиков по ходу работы.