Comments 46
По поводу удобства спорно. Много ли DI-фреймворков позволяют определить контекст для произвольного куска кода? Много ли позволяют работают по стеку вызова, а не по стеку конструкторов? Много ли позволяют делать инъекции опциональными? Можно ли как-то гибко реализовать DI для отдельно лежащего пакета без привязки к конкретному DI-фреймворку?
Имхо, вариант «я запрос отправил, а дальше обрабатывайте как хотите» более удобен.
Ну и с «открытой декларацией» проблем как-то особо нет. Возможные эксепшены легко вычисляются по анализу кода, для эффектов тут даже переделывать ничего не придётся.
Еще один плюс — переопределение эффектов на разных уровнях вложености без протаскивания локального контекста или без необходимости запоминать старые хендлеры.
С DI можно добиться обычной делегации, но очень ограниченно: приходится все равно протаскивать делегация по всему стеку либо вываливать кишки (особенности имплементации) наружу, нарушая инкапсуляцию и усложняя API.
Идея делегирования, конечно, стара как мир и в разных вариантах возникала неоднократно. Кажется, на этот раз есть шанс получить нечто продуманное и стройное. И совместимое с функциональными ЯП. А, напомню, для них все и делается изначально. Через 10 лет это будет и в Джаве, как в свое время туда перекочевали из функциональных языков лямбды. Интерфейсы и анонимные классы в Джаве уже и так были, но с лямбдами стало жить намного проще. Туда все и идем — к более выразительным концепциям и конструкциям языка.
Плюс добавим возможность асинхронного выполнения эффектов, даже если сама библиотека синхронная.Во всей этой конструкции хуже поиска обработчика по стеку только блокировка потока для выполнения асинхронного обработчика. Ни за что бы не хотел увидеть эту «фичу» в языке, на котором пишу.
Вместо «цвета» такая функция явно приобретет не самый приятный «запах»
Так а с чего вы взяли, что будет блокировка потока?
Хотя бы с того, что если у нас не Go с горутинами и сегментированным стеком, то поток во время выполнения обработчика просто не получится ни для чего использовать. Стек-то занят.
А что мешает обработчику выполняться асинхронно и не блокировать поток на обработке одного действия?
Могу предложить такую метафору.
У вас есть код с эффектами. Этот код чистый, т.е. сам по себе он не взаимодействует с внешним миром.
Чтобы выполнить этот код нужен интерпретатор эффектов. И уже этот интерпретатор взаимодействует с внешним миром.
Этот интерпретатор вполне может использовать event loop вместо зеленых потоков.
Т.е. код с эффектами ни синхронный ни ассинхронный, это зависит от интерпретатора.
Или интерпретатор может идти по заранее подготовленному списку эффектв и их результатов — коэффектов. Таким образом мы можем проверить работу нашего кода.
Или "отмотать" код назад.
Или перенести частично исполненный код на другую машину/платформу, перенеся результаты уже исполненных эффектов и имея совместимый код на обеих машинах.
Этот интерпретатор вполне может использовать event loop вместо зеленых потоков.Что произойдет, когда код с эффектами будет вызван в том же потоке, в котором работает event loop? Deadlock при попытке применить первый же асинхронный обработчик.
Т.е. код с эффектами ни синхронный ни ассинхронный, это зависит от интерпретатора.
Много ли DI-фреймворков позволяют определить контекст для произвольного куска кода? Много ли позволяют работают по стеку вызова, а не по стеку конструкторов?
Если использовать pure DI (оно же poor man DI) — то все.
Много ли позволяют делать инъекции опциональными?
"Опциональная инъекция" — это вообще как?
Можно ли как-то гибко реализовать DI для отдельно лежащего пакета без привязки к конкретному DI-фреймворку?
Да, это называется pure DI.
Возможные эксепшены легко вычисляются по анализу кода, для эффектов тут даже переделывать ничего не придётся.
Ну-ну. Успехов в вычислении возможных исключений при вызове виртуального метода в абстрактном классе.
Много ли DI-фреймворков позволяют определить контекст для произвольного куска кода?
Практически любой, так как инжекторы обычно имеют свойство наследования и изолированности. Создавай свои сколько влезет.
class Something {
private myField: MyField; //Тут нам нужно создать филд с каким-то специфичным контекстом;
constructor(parentInjector: Injector) {
const localInjector = InjectorFactory.createInjector(parentInjector, {/*...тут ваше кастомный контекст, переопределение каких-то сервисов и т.д.*/});
this.myField = localInjector.create(MyField);
}
}
Много ли позволяют работают по стеку вызова, а не по стеку конструкторов?
Любой. В случае ts нужно помещать инжекторы в глобальную область и использовать с помощью декораторов для обработки вызовов методов, полей, etc.
Много ли позволяют делать инъекции опциональными?
Практически все. Кто мешает делать NOOP реализации?
Можно ли как-то гибко реализовать DI для отдельно лежащего пакета без привязки к конкретному DI-фреймворку?
Можно написанием DI-враппера(-ов), это уже чисто на усмотрение разработчика, который пилит код. Почти никогда эта задача не встречается в мире небольших проектов или продуктов. В кровавом же Ынтерпрайсе ресурс всегда найдётся.
У нас на проекте, например, уживались следующие DI: гуисовый, спринговый и кондовый cdi.
А если серьезно, то это вводит формальную «алгебру эффектов». Подробности надо читать в соответствующих статьях.
Вообще идея интересная, хотя и goto-ориентированная (как впрочем и исключения). Произвольный код (возможно, очень большой и сложный чужой код) из своих недр запрашивает нечто (обработчик), о чем программист может не знать и не догадываться, и если этого нет — программа падает. На уровне кода никакой спецификации требуемых обработчиков нет. То есть все скомпилируется, никаких ошибок компиляции не будет — но из-за незнания всех требуемых обработчиков или даже опечатки в одной букве всё падает. В случае скриптовых языков компиляция не предусмотрена, но упадет в любом случае…
В типизированных языках вы код использующий алгебраические эффекты не передав нужные обработчики не запустите.
Насколько я понял, здесь основная фишка в том, что ничего явно передавать не нужно.
Явно именно что "передавать" не нужно, но декларировать скорее всего нужно.
Как implicit в Scala.
Т. е. в сигнатурах ваших методов/функций, всегда будет какое-то указание, какие эффекты нужны, но вот писать код протаскивающий интерфейс/делегат/указатель не надо, его сгенерирует уже компилятор
Альтернативный пример неявного протаскивания сейчас пытаются сделать в Kotlin
Когда прочитал исходную статью, сделал игрушечную реализацию на Kotlin
А если вместо perform log(«aaa»); написать log(«aaa»); то что изменится? Ах да, мы сможем написать функцию log() лог где-то рядом, а не в месиве кода, с кучей if-ов, имитирующих switch и в рамках дополнительно усложняющей язык конструкции по обработке эффектов. А что же ещё «улучшилось» в предлагаемом в статье варианте? Обработка эффектов «позднее», да ещё и кем-то другим? Ну есть в мире понятия интерфейс или абстрактный метод, это не подходит? В скриптовом языке нет полных аналогов? А не до конца полный аналог что мешает использовать? Асинхронность нужна? Так а в функции её никак? Всё равно она будет, так или иначе, но тогда — зачем иначе, если и так всё работает?
В общем — посыл автора статьи не понят. Может кто просветит меня, недалёкого? Зачем это всё? В чём профит?
А если вместо perform log(«aaa»); написать log(«aaa»); то что изменится?Изменится то, что вы можете переопределить функцию «log» как вам нужно в месте вызова enumerateFiles, и не заморачиваться, как её прокинуть внутрь enumerateFiles.
Основной профит — DI, вшитое в язык, без DI-контейнеров и/или ручного прокидывания зависимостей по стеку.
То есть коротко — всё давно решено. Зачем усложнять?
Получается, как говорится — Оккамом, да по тому, что между ног.
С этой информацией контроль правильности использования эффектов можно выполнить ещё на этапе компиляции. Т. е., например, компилятор проконтролирует, что используемым эффектам есть их реализации.
Явная реализация эффектов также позволяет программисту их прозрачно контролировать: время, способ и результаты выполнения, так как принципиально нет возможности ввести неотслеживаемый эффект.
Также возникает возможность для реализации других методов статического анализа исходного кода на предмет соответствия каким-либо правилам.
Контроль по типам в функции есть (для типизированных языков). Реализацию, так же как и для эффектов, компилятор предсказать не сможет, потому что есть перегрузка и виртуальные функции, чему можно придумать аналог в виде эффектов, а если не придумывать, то получаются обычные неполиморфные функции. Прозрачность контроля равна возможности написать в теле функции/эффекта контролирующий код — опять аналогично. Статистический анализ опять полностью идентичен.
Я бы зашёл на тему с другой стороны — от математики. Раз уж назвали груздем, то и разговаривать надо про грибы. То есть где те алгебраические преобразования, которые даёт нам алгебра? Наличие или отсутствие преобразований — это вменяемый аргумент за эффекты. Но я пока не вижу отличий от функции, которые дали бы именно алгебраические эффекты.
Я так понял, именно здесь и проявляется та самая "алгебраичность".
В случае с монадами какие-нибудь ReaderT Foo WriterT Bar Maybe ()
и WriterT Bar ReaderT Foo Maybe ()
— это два разных несовместимых типа, типы эффектов же коммутативны.
Но в динамически типизированном языке вся идея вырождается просто в ещё один способ запутывания кода.
Ну, операцию 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, а нечто, связанное с предметной областью. Например, в одном из этих классов может быть скрыт запрос имени пользователя. То есть любые трансформеры надо самому писать, библиотечных нет...
На этом моменте я совсем перестал понимать, чем это отличается от какого-нибудь 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) — вот так вот делаем произвольную монаду
Вообще, это старая, давно известная в узких кругах лиспо-коммунити (scheme конечно в основном) техника, которую уже лет 10+ назад на волне хайпа ФП все обсосали, а теперь вот фронтендеры, как водится, в силу своих скромных (в плане знания CS) возможностей пытаются как-то вписать в свой дискурс.
Это классно. Но далее крайне опытный аффтор, видимо в силу своих нескромных (в плане знания CS) возможностей, вместо простого и понятного пояснения для писателей фронта, выдаёт текст в духе:
но если монада one entrance, то только в той, в которой фунарг фмапа применяется не больше раза
Поэтому возникает вопрос — а зачем это всё написано? Сторонники ФП и без дополнительных пояснений всегда будут фанатеть по своему ФП, а вот писатели с фронта никогда и ничего в подобном тексте не поймут. Так для кого это?
Скромный совет — уж если разбирать некие смыслы, выдаваемые императивно ориентированными джунами, то и излагать стоит именно с использованием императивного подхода. Иначе получается, что зазнавшийся сторонник ФП хихикает над абсолютно стандартной для любого джуна (которым он и сам был) ситуацией, и при этом задирает нос до небес, не опускаясь (или просто не умея?) до объяснений, понятных джунам.
Так для кого это?
Очевидно же — для человека, которому в ответ был написан комментарий, и я не думаю, что у него могут возникнуть проблемы с пониманием фразы "фунарг фмапа применяется не более раза". Это комментарий, направленный в ответ на другой комментарий, а не статья, расчитанная, по умолчанию, на широкий круг читателей.
Если вдруг условный "писатель с фронта" да и кто вообще угодно действительно захочет разобраться — легкогугление по "the mother of all monads" и тыкание языка с нативной поддержкой продолжений (любой scheme-dervied диалект) — решает вопрос, т.к. просто элементарно пишутся реализации perform/handle и все ясно сразу. Если у вас есть конкретные вопросы — я тоже могу пояснить.
ЗЫ: "но если монада one entrance" — да, это опечатка, конечно, продолжение one entrance.
Хорошо, я несколько недостаточно уделил внимание контексту. Признаюсь — ожидал пояснения фразы «техника, которую уже лет 10+ назад на волне хайпа ФП все обсосали», но так и не заметил сути.
>> Если у вас есть конкретные вопросы — я тоже могу пояснить.
Лично мне интересно понять, как могли бы выглядеть алгебраические эффекты в императивном языке.
Первый вопрос в том насколько эта фича языка будет эффективной? Ведь раскручивание стека и его обратное закручивание — дело дорогостоящее.
Второй вопрос в том насколько это востребовано? Если вы не можете писать код без использования этих самых алгебраических эффектов, то, возможно, что-то с вашим кодом не так.
В общем, как обычно, хипстеры придумали себе проблему и придумали для неё оригинальное решение.
На основе примера из статьи набросал минимальный рабочий пример на 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)];
}
}
Нельзя просто так хранить Dictionary внутрях AsyncLocal — там запросто возможен многопоточный доступ. Async flow, в отличии от Thread, нелинеен.
Возможно, ImmutableDictionary<Type, object>
будет лучшей идеей.
Кстати, с каких пор конструкция where T : Delegate
разрешена?
В частности это касается и идеи возобновления выполнения выполнения как способа обработки исключений.
Первая известная мне реализация появилась в продукте, с которым почти все наверняка сталкивались — в 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, к тому же — не часто используемой.
Так что это хорошо, что старая идея обретает новую жизнь на уровне широко распространенного языка, пусть и под новым именем. Но, естественно, новая жизнь — это не просто повторение старого: доработка неизбежно потребуется. Например, как в статье написано, для совмещения концепции возобновления выполнения после исключения с современной моделью асинхронной обработки.
«Алгебраические эффекты» человеческим языком