Pull to refresh

Comments 46

Вообще-то говоря эту концепцию придумали еще в далеком 90-м и назвали Dependency Injection. Причем канонический poor man DI через интерфейсы и контракты намного лучше предложенного хотя бы тем, что открыто декларирует API, который нужен функции, чтобы корректно работать.
Не сказал бы, что особо лучше. DI во всех реализациях, что я видел — это какой-нибудь монструозный диспетчер на уровне фреймворка, к которому надо обращаться +- в явном виде. А тут всё-таки конструкция языка.
По поводу удобства спорно. Много ли DI-фреймворков позволяют определить контекст для произвольного куска кода? Много ли позволяют работают по стеку вызова, а не по стеку конструкторов? Много ли позволяют делать инъекции опциональными? Можно ли как-то гибко реализовать DI для отдельно лежащего пакета без привязки к конкретному DI-фреймворку?
Имхо, вариант «я запрос отправил, а дальше обрабатывайте как хотите» более удобен.
Ну и с «открытой декларацией» проблем как-то особо нет. Возможные эксепшены легко вычисляются по анализу кода, для эффектов тут даже переделывать ничего не придётся.
Плюс добавим возможность асинхронного выполнения эффектов, даже если сама библиотека синхронная.
Еще один плюс — переопределение эффектов на разных уровнях вложености без протаскивания локального контекста или без необходимости запоминать старые хендлеры.
С DI можно добиться обычной делегации, но очень ограниченно: приходится все равно протаскивать делегация по всему стеку либо вываливать кишки (особенности имплементации) наружу, нарушая инкапсуляцию и усложняя API.

Идея делегирования, конечно, стара как мир и в разных вариантах возникала неоднократно. Кажется, на этот раз есть шанс получить нечто продуманное и стройное. И совместимое с функциональными ЯП. А, напомню, для них все и делается изначально. Через 10 лет это будет и в Джаве, как в свое время туда перекочевали из функциональных языков лямбды. Интерфейсы и анонимные классы в Джаве уже и так были, но с лямбдами стало жить намного проще. Туда все и идем — к более выразительным концепциям и конструкциям языка.
Плюс добавим возможность асинхронного выполнения эффектов, даже если сама библиотека синхронная.
Во всей этой конструкции хуже поиска обработчика по стеку только блокировка потока для выполнения асинхронного обработчика. Ни за что бы не хотел увидеть эту «фичу» в языке, на котором пишу.
Вместо «цвета» такая функция явно приобретет не самый приятный «запах»

Так а с чего вы взяли, что будет блокировка потока?

Хотя бы с того, что если у нас не Go с горутинами и сегментированным стеком, то поток во время выполнения обработчика просто не получится ни для чего использовать. Стек-то занят.

А что мешает обработчику выполняться асинхронно и не блокировать поток на обработке одного действия?

А что мешает обработчику
Синхронный код, который неявно его использует. Он может вернуть управление вызвавшему его коду только когда обработчик вернет результат. Никаких способов это сделать кроме как заблокировать поток до получения результата в синхронном коде не предусмотрено.
UFO just landed and posted this here

Да не важно. Тут важно что:


  1. прямо сейчас в JS гринтредов нет;
  2. идея автора о том, что алгебраические эффекты — это простой способ добавить языку гринтреды, полностью несостоятельна, поскольку основная проблема тут в реализации гринтредов, а не в синтаксисе.

Могу предложить такую метафору.
У вас есть код с эффектами. Этот код чистый, т.е. сам по себе он не взаимодействует с внешним миром.
Чтобы выполнить этот код нужен интерпретатор эффектов. И уже этот интерпретатор взаимодействует с внешним миром.


Этот интерпретатор вполне может использовать event loop вместо зеленых потоков.
Т.е. код с эффектами ни синхронный ни ассинхронный, это зависит от интерпретатора.


Или интерпретатор может идти по заранее подготовленному списку эффектв и их результатов — коэффектов. Таким образом мы можем проверить работу нашего кода.
Или "отмотать" код назад.


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

Этот интерпретатор вполне может использовать event loop вместо зеленых потоков.
Т.е. код с эффектами ни синхронный ни ассинхронный, это зависит от интерпретатора.
Что произойдет, когда код с эффектами будет вызван в том же потоке, в котором работает event loop? Deadlock при попытке применить первый же асинхронный обработчик.

В браузере или nodejs есть единственный поток/процесс. Да, тяжелая математика его заблокирует. Но ввод вывод там асинхронный. На основе эффектов можно делать async/await только без цветных функций. Например передавать в map или reduce функцию с эффектом, что нельзя сейчас сделать.

Много ли DI-фреймворков позволяют определить контекст для произвольного куска кода? Много ли позволяют работают по стеку вызова, а не по стеку конструкторов?

Если использовать pure DI (оно же poor man DI) — то все.


Много ли позволяют делать инъекции опциональными?

"Опциональная инъекция" — это вообще как?


Можно ли как-то гибко реализовать DI для отдельно лежащего пакета без привязки к конкретному DI-фреймворку?

Да, это называется pure 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.

Ну собственно, примерно о чём я и говорил, если и можно, то ценой такой кучи гемороя, что лучше вообще от такой идеи отказаться, и писать «тупой не расширяемый императив», который хотя бы читать можно будет без боли.
В простейшем виде это было ещё в древнем BASIC. Собственно оператор RESUME и RESUME NEXT (для возврата к следующей за вызвавшей ошибку строке). Примерно так и использовалось, только приходилось городить более сложные обработчки с учётом либо номеров строк, либо переключая блоки обработчиков по ходу работы.
Это яйцеголовые придумали чтобы нормальным людям голову морочить.

А если серьезно, то это вводит формальную «алгебру эффектов». Подробности надо читать в соответствующих статьях.
Хочу уточнить, под «алгебраическими эффектами» понимается только концепция передачи управления try-handle-perform-resume, описанная в статье, или это нечто большее? Еще и название «эффекты» подразумевает множественное число…
Вообще идея интересная, хотя и goto-ориентированная (как впрочем и исключения). Произвольный код (возможно, очень большой и сложный чужой код) из своих недр запрашивает нечто (обработчик), о чем программист может не знать и не догадываться, и если этого нет — программа падает. На уровне кода никакой спецификации требуемых обработчиков нет. То есть все скомпилируется, никаких ошибок компиляции не будет — но из-за незнания всех требуемых обработчиков или даже опечатки в одной букве всё падает. В случае скриптовых языков компиляция не предусмотрена, но упадет в любом случае…

В типизированных языках вы код использующий алгебраические эффекты не передав нужные обработчики не запустите.

Передать обработчики я могу и без «алгебраических эффектов»:) Просто передам интерфейс или делегат/указатель на функцию и всё.
Насколько я понял, здесь основная фишка в том, что ничего явно передавать не нужно.

Явно именно что "передавать" не нужно, но декларировать скорее всего нужно.

Как implicit в Scala.

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

Альтернативный пример неявного протаскивания сейчас пытаются сделать в Kotlin

Из статьи не понял — зачем это всё? Очередная дань моде?

А если вместо perform log(«aaa»); написать log(«aaa»); то что изменится? Ах да, мы сможем написать функцию log() лог где-то рядом, а не в месиве кода, с кучей if-ов, имитирующих switch и в рамках дополнительно усложняющей язык конструкции по обработке эффектов. А что же ещё «улучшилось» в предлагаемом в статье варианте? Обработка эффектов «позднее», да ещё и кем-то другим? Ну есть в мире понятия интерфейс или абстрактный метод, это не подходит? В скриптовом языке нет полных аналогов? А не до конца полный аналог что мешает использовать? Асинхронность нужна? Так а в функции её никак? Всё равно она будет, так или иначе, но тогда — зачем иначе, если и так всё работает?

В общем — посыл автора статьи не понят. Может кто просветит меня, недалёкого? Зачем это всё? В чём профит?
А если вместо perform log(«aaa»); написать log(«aaa»); то что изменится?
Изменится то, что вы можете переопределить функцию «log» как вам нужно в месте вызова enumerateFiles, и не заморачиваться, как её прокинуть внутрь enumerateFiles.

Основной профит — DI, вшитое в язык, без DI-контейнеров и/или ручного прокидывания зависимостей по стеку.
Я же написал выше — это решается стандартными средствами, встроенными во множество языков. Чаще всего это абстрактные методы или интерфейсы. Ну а конкретно в JS можно в прототип засунуть этот log() и далее подменять прототип, если есть желание.

То есть коротко — всё давно решено. Зачем усложнять?

Получается, как говорится — Оккамом, да по тому, что между ног.
UFO just landed and posted this here
Основная выгода в том, что эффекты явно прописываются в типах.
С этой информацией контроль правильности использования эффектов можно выполнить ещё на этапе компиляции. Т. е., например, компилятор проконтролирует, что используемым эффектам есть их реализации.
Явная реализация эффектов также позволяет программисту их прозрачно контролировать: время, способ и результаты выполнения, так как принципиально нет возможности ввести неотслеживаемый эффект.
Также возникает возможность для реализации других методов статического анализа исходного кода на предмет соответствия каким-либо правилам.
А в чём отличие от обычной функции?

Контроль по типам в функции есть (для типизированных языков). Реализацию, так же как и для эффектов, компилятор предсказать не сможет, потому что есть перегрузка и виртуальные функции, чему можно придумать аналог в виде эффектов, а если не придумывать, то получаются обычные неполиморфные функции. Прозрачность контроля равна возможности написать в теле функции/эффекта контролирующий код — опять аналогично. Статистический анализ опять полностью идентичен.

Я бы зашёл на тему с другой стороны — от математики. Раз уж назвали груздем, то и разговаривать надо про грибы. То есть где те алгебраические преобразования, которые даёт нам алгебра? Наличие или отсутствие преобразований — это вменяемый аргумент за эффекты. Но я пока не вижу отличий от функции, которые дали бы именно алгебраические эффекты.
UFO just landed and posted this here

Я так понял, именно здесь и проявляется та самая "алгебраичность".


В случае с монадами какие-нибудь ReaderT Foo WriterT Bar Maybe () и WriterT Bar ReaderT Foo Maybe () — это два разных несовместимых типа, типы эффектов же коммутативны.


Но в динамически типизированном языке вся идея вырождается просто в ещё один способ запутывания кода.

UFO just landed and posted this here

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

UFO just landed and posted this here
На этом моменте я совсем перестал понимать, чем это отличается от какого-нибудь 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, к тому же — не часто используемой.
Так что это хорошо, что старая идея обретает новую жизнь на уровне широко распространенного языка, пусть и под новым именем. Но, естественно, новая жизнь — это не просто повторение старого: доработка неизбежно потребуется. Например, как в статье написано, для совмещения концепции возобновления выполнения после исключения с современной моделью асинхронной обработки.
Извините, не совсем по теме. Я понимаю catch как способ заявить программисту «У тебя возможная ошибка в логике программы, или на вход поданы некорректные данные» и откатить состояние на до исключения. В идеале этот код вообще вызываться не должен, а если таки вызывается, не должен быть конструктивной частью основной логики программы. Это я неправильно понимаю исключения, или автор из них пытается сделать что-то, чем они не являются?
Sign up to leave a comment.

Articles