Comments 795
Доступно всё рассказано, и ни одного упоминания монад — спасибо!
Но, определения в начале статьи всё-таки отличаются от общепринятых (см википедию):
- Функциональная программа — программа, состоящая из чистых функций
- Функция f является чистой если выражение f(x) является ссылочно прозрачным для всех ссылочно прозрачных x
Есть ссылки на первоисточники этих определений?
Доступно всё рассказано, и ни одного упоминания монад — спасибо!
Про монады расскажу в следующий раз (если увижу что эта статья зашла), они совсем не такие страшные как кажется. Не сложнее того что уже в статье написано. Не знаю почему, но видимо ФПшникам нравится нагонять страху. А на самом деле монада — это просто интерфейс с парой методов. Но это недостаточно магически, поэтоум обязательно нужно запудрить мозги про моноид в категории эндофункторов. Тогда собеседнику сразу станет страшно, а значит вы победили)
Есть ссылки на первоисточники этих определений?
Я взял определения всё из той же книги. Судя по тому как я сейчас понимаю эти вещи, определения вполне точные. Самое главное, в отличие от субъективных оценочных суждений и смутных интуитивных представлений, эти свойства можно объективно проверять, нужно только определиться, какие эффекты совершаемые программой мы считаем важными (запись в логи/хождение в БД/совершение HTTP запросов/...), а какие — нет (нагревание процессора/порядок вычисления аргументов/...)
Монада это переопределенная функция композиции функций. Все остальное от лукавого.
А ещё функтор и аппликативный функтор. И ещё законы там всякие должны выполняться.
На самом деле, не совсем. Есть много вещей, которые являются формально не монадами, а чем-то похожим (ну, например, потому что соответствующий "функтор" — нифига не функтор, а про выполнение монадических законов я вообще молчу, они постоянно нарушаются в реальных монадах) и их можно спокойно считать монадами и использовать как монады, несмотря на то, что монадами они не являются. Так что с точки зрения прикладной монада — это именно интерфейс. И тот факт, что для этого интерфейса не выполняются какие-то специфические доп. условия, не накладывает каких-либо ограничений на основные варианты его использования.
Иными словами, если вы говорите "монада" в контексте математическом (теоркат и вот это вот все) — то, конечно, тут вполне строго и четкое определение. Если же это контекст прикладного программирования — этими факторами можно пренебречь (и постоянно на практике пренебрегается).
Можно пример того, что используется как монада, но не монада? Просто мне тяжело представить, как можно пренебречь, например, отсутствием ассоциативности.
Ну, вот я например недавно использовал монадический интерфейс для сетевого взаимодействия, чтобы работать с запросами в виде аналогичном x <- query1, y <- query2, return x + y (ну для примера), надо это было в силу не совсем тривиального процесса отправки и получения ответа. Если у нас query — это промисы (ну как при обычных хттп запросах), то мы получаем, с-но, промис-монаду (которая, к слову, в том же js не является монадой из-за некоторых особенностей семантики, нарушающих монадические законы. но всем пофиг, кто вообще знает, что promise в js — не монада?). В моем же случае у меня типы x и query1 были типами произвольных сообщений (x тип приходящих, query — тип исходящих), с-но, не было никакого соответствующего функтора и даже нельзя было написать сколько-нибудь осмысленные return и fmap. Но, несмотря на то, что полученная конструкция была прям уж совсем не монада, взаимодействие с ней выглядит вполне монадически ну и концептуально оно тоже вполне монада.
Как же тогда у вас в коде return x+y
используется, когда нельзя осмысленный return написать?
У меня в коде и не используется, очевидно же :)
Это просто пример был, для того чтобы понятно, о чем речь в общем. В реальном коде вместо этого везде что-то вроде someFunction(x+y) где someFunction исполняет какой-то нужный сайд-эффект (setState реактовский, например).
Но это достаточно вырожденный пример, когда от монады, действительно, мало что осталось и такое не так уж распространено. А вот неисполнение монадических законов — как в промис-монаде жс — штука обыденная. Try в скале, насколько я помню, еще из распространенных примеров.
В Linq, кстати, тоже return не используется (если даже написать аналог return'а, то шарп не сможет вывести полиморфный аргумент). И вообще монадическая нотация там рассахаривается совсем не как в хаскеле, т.к. SelectMany — не бинд.
Смысл в том, что есть математические абстракции, и есть реальные объекты реального мира. И одно не в точности соответствует другому — абстракции описывают реальные объекты лишь с точностью до. Всегда есть какая-то погрешность. Даже какой-нибудь канонический Maybe в хаскеле — это не идеальная сферическая монада в вакууме (просто потому, что ваш пека — это не идеальная сферическая в вакууме машина Тьюринга, а просто обычный конечный автомат) и можно построить специфические кейзы, когда оно не будет себя вести как должно. Но мы разумно пренебрегаем подобными кейзами.
Ключевой момент тут "разумно", коненчо — т.е. надо понимать какие есть погрешности и где они важны.
Например, альтернативное рассахаривание шарпа работает т.к. fmap f x = x >>= return. f (т.к. from x' in x select f(x') как раз непосредственно в Select aka fmap и преходит), и вы, будучи неосторожным, вполне можете встретиться с неожиданным поведением монады, которая этому закону не удовлетворяет
Because of these difficulties, Haskell developers tend to think in some subset of Haskell where types do not have bottom values. This means that it only includes functions that terminate, and typically only finite values. The corresponding category has the expected initial and terminal objects, sums and products, and instances of Functor and Monad really are endofunctors and monads.
В Linq, кстати, тоже return не используется (если даже написать аналог return'а, то шарп не сможет вывести полиморфный аргумент).
static IEnumerable<T> Return<T>(T value) {
yield return value;
}
Который из аргументов не получится тут вывести?
SelectMany — не бинд
а что это такое тогда?
а что это такое тогда?
Просто функция, никакой теоретической конструкции, которой бы она соответствовала, мне неизвестно (хотя может она и есть). Есть много функций, которые "не бинд". Не понятно, что вас удивило тут. У SelectMany, собственно, три аргумента. А у бинда — два.
Который из аргументов не получится тут вывести?
Причем тут определение Return, я про применение. Если вы напишите Return(1) то за счет какой магии шарп узнает, что там должен быть IEnumerable? Хотя, сейчас вот я вспомнил, что недавно в определенных контекстах шарп научился по возвращаемому типу выводить аргументы, может, сейчас и выведет (хотя не уверен). Раньше точно не мог.
Просто функция, никакой теоретической конструкции, которой бы она соответствовала, мне неизвестно (хотя может она и есть). Есть много функций, которые "не бинд". Не понятно, что вас удивило тут. У SelectMany, собственно, три аргумента. А у бинда — два.
Потому что это liftM2
, из которого бинд можно вывести.
У SelectMany, собственно, три аргумента. А у бинда — два.
А это тогда что? SelectMany<TSource,TResult>(IEnumerable<TSource>, Func<TSource,IEnumerable<TResult>>)
Если вы напишите Return(1) то за счет какой магии шарп узнает, что там должен быть IEnumerable?
Понял. Но направление вывода типов в C# никак не мешает IEnumerable<>
быть монадой.
А это тогда что? SelectMany<TSource,TResult>(IEnumerable, Func<TSource,IEnumerable>)Это перегрузка НЕ используется при десугаринге Linq, в том-то и дело. В Linq используется вот эта перегрузка:
this IEnumerable<TSource> source, Func<TSource, IEnumerable<TCollection>> collectionSelector, Func<TSource, TCollection, TResult> resultSelector )``` она позволяет десугарить Linq не во вложенные замыкания, а просто в последовательность вызовов something.SelectMany(...).SelectMany(...).SelectMany(). и если вы хотите что-то сделать используемым в Linq, то вам надо будет именно эту перегрузку писать (ну только IEnumerable на свой генерик замените), иначе ошибка типов. А обычный бинд, напротив, не требуется. Понятно, конечно, что одно можно написать через другое. >Понял. Но направление вывода типов в C# никак не мешает IEnumerable<> быть монадой. А кто говорил, что ей что-то мешает быть монадой?
Ну, начинали-то вы ветку не с десугаринга Linq, а с неисполнения монадических законов...
Ну, начинали-то вы ветку не с десугаринга Linq, а с неисполнения монадических законов...
Так а где я говорил, что конкретно для IEnumerable что-то не исполняется (ну, если пренебречь тем, что "настоящих" монад в программировании не бывает в принципе)?
Linq был приведен как пример того, что чтобы что-то было "linq-монадой" этому чему-то не нужен ни стандартный bind, ни return.
ФП без ТК это конечно хорошо, но — плохо.
Спойлер: return в хаскеле НЕ ключевое слово.
ФП без ТК это конечно хорошо, но — плохо.
ТК без ФП — хорошо, и ФП без ТК — тоже хорошо. А вот вместе — всегда плохо, т.к. эти вещи несовместимы. Нельзя о функторах/монадах/етц. из фп рассуждать так, как будто это обычные функторы/монадц/етц. Так как это не они.
как они связаны с join и причем тут какие-то «законы»
join вам в ФП не нужен, а законы не при чем вообще. На практике хватит одного бинда. Даже кривого.
Вам просто нужны неизменяемые коллекции и репл. Когда вы пишете класс в половину экрана ради элементарных действий, это никакое не ФП, а имитация.
Да я на самом деле только рад. Я же говорю, что это подход, направленный на большее переиспользование и меньшую связность (coupling) компонентов. То, что нам так любят продавать на ООП конференциях.
Кто развел всё это шаманство и мистицизм вокруг ФП не берусь судить. Берусь только немного развеять и показать, что всё очень приземленно, практично и полезно для живых разработчиков, а не только для высоколобых математиков.
И я — на Паскале лет тридцать назад. ;)
единственное разумное использование такого аргумента — вернуть дефолтное значение когда массив пустой
Найти первое недефолтное значение массива.
Нет, так не получится — у вас не определена операция равенства для T.
Зависит от языка? В той же scala у всех есть equals(). Может имеется ввиду не равный, а идентичный (та же ссылка)?
Ну, зависит от языка. Как я уже говорил, в большинстве популярных вы этого сделать не сможете:
Чтобы это сделать в нормальном языке вам нужно будет явно затребовать возможность сравнивать:
bool Eq<T>(T a, T b) where T : IEquatable<T> => a.Equals(b);
Хотя с языках с недостаточно сильной типизацией можно нахачить, но я как раз и говорю о том, что лучше их не использовать. В том же сишарпе можно обойти это через object.Equals/object.ReferenceEquals/..., но это скорее всего только выйдет боком (потом бегать выяснять, почему класс поменяли на структуру и всё сломалось).
В конце концов это лишняя писанина, поэтому если автор это написал, то значит как-то скорее всего это использует.
Может использует, может нет. Гадание по сигнатуре — такое себе занятие. А уж предполагать, что все люди всегда предельно рациональны — вообще глупо.
Вряд ли вы так делаете для всех функций которые явно не подозреваете во вредительских намерениях?
Здесь речь о том что для чистых функций предположения о их работе по сигнатуре можно делать легче и с большей степенью вероятности они будут подтверждаться.
Ну а если всё же ракета на Марс запустится, то поможет «многочасовая отладка». Но вероятность что к ней придётся прибегнуть будет меньше.
Если есть сомнения в чистоплотности автора кода, то такой код я не буду использовать независимо от сигнатуры. А если сомнений нет, то достаточно и её названия.
Если бы каждый раз когда функция с именем вроде GetItem
обновляет записи в БД или глобальном стейте мне давали бы рубль я бы уже давно стал миллионером.
Где же ваша дедукция, которой вы так кичитесь? Префикс Get означает, что Item будет возвращён из базы в любом случае. А если его там ещё нет, то очевидно он должен быть тут же создан.
Префикс Get означает, что Item будет возвращён из базы в любом случае. А если его там ещё нет, то очевидно он должен быть тут же создан.
Если по префиксу Get
вам понятно что будет идти запись в базу, то я даже не знаю что вам на это сказать..
Что до дедукции, то она отлично работает в языках, где сигнатуры не врут. В сишарпе, как я показал, увы, это не так.
Это называется "абстракция". Клиента не волнует, что система делает там у себя под капотом, её задача выдать элемент по идентификатору. Если допускается его несуществование, то метод называется findItem, если не допускается — getItem. Яркий пример такой абстракции: https://ru.wikipedia.org/wiki/Touch
Если допускается несуществование записи — метод обычно должен называться GetOrCreate
или как-то схоже по смыслу. А еще лучше, когда по типам видно, что возвращается Option<T>
, и понятно что происходит, если объект не нашелся.
Это уже протечка абстракции. Клиента не должно волновать создаётся оно там, клонируется или ещё что — для него ресурс существует всегда. То, что он создаётся лениво — не его ума дело. Собственно, мне ли объяснять это любителю бесконечных списков?
Во-вторых, getItem — должен вернуть либо Item, либо пустой элемент/null/ошибку, если item'а не существует. Создавать что-либо он не должен, для этого должны быть методы getItemOrCreate или getItemOrDefault.
getItem должен делать ровно то, что указано в названии. Не больше и не меньше. Не null возвращать ибо тогда он был бы getItemOrNull. Не ошибку кидать, иначе он был бы getItemOrThrows. А вот создавать ли новый, дефолтный или вообще прокси — скрыто за абстракцией.
Боюсь, с таким чинильщиком абстракций, никакого ломальщика абстракций уже не нужно.
Если у него машины нет, но он подписал со мной контракт, что по моему запросу предоставит мне машину, то он должен её предоставить. Где он её достанет — его проблемы, а не мои. Может угнать, может купить за свой счёт, может собрать в гараже из мусора.
Извините, но вы с таким подходом не сможете реализовать ни одну реальную работающую программу, потому что идеальных программ не бывает и не учитывать ситуацию «что-то пошло не так» — делает все вашу архитектуру изначально нежизнеспособной.
Если контракт нарушен — паника и экстренное закрытие матрицы.
С каким "таким" подходом? Что за слова надо отвечать? Так я и не называют метод getItem, если не могу гарантировать его возврат.
Если назовёте её deliveryMessage, то да, должна гарантировать.
А вам не кажется, что если тип возвращаемого значения в сигнатуре функции может принимать значение null, то возможность его возврата это уже часть контракта?
И это при том, что у вас полный холодильник жратвы, есть и уха, и икра с маслом, есть всё кроме борща.
Но жена ваша проста — сказал «ещё борща» — запускаю протокол «борщ», еду по ночной Москве искать свёклу.
А вот если бы вместо добавки вы получили ответ «борща нет», а затем спросили бы «а что есть» и уже на основе этого принимали бы решение что делать (не есть, есть что есть, ехать за продуктами) — то не попали бы в идиотскую ситуацию, когда детей укладывать некому, потому что жена уехала на ночь глядя за проклятой свёклой…
Такой пример, без заумных слов DDOS и RPS, покажется вам более полезным.
Правильно реализованная жена, поддерживающая контракт "ещё борща" приготовит этого самого борща с запасом именно на случай, если я попрошу добавки. Причём с таким запасом, чтобы как бы быстро и много я ни ел, она всегда могла сгонять в Ашан за свеклой.
Давайте закончим с этой спец олимпиадой по наркоманским метафорам и попробуем понять, что вам говорит собеседник?
А если борща вдруг не хватило, то это жена неправильная, ага
В данном случае
Правильно реализованная женавообще не должа заниматься добычей свеклы, это должен делать «правильно реализованный» муж.
P. S: Получается анти-паттерн «Отказ от ответственности».
В современных реалиях правильно реализованный муж майнит число на сервере банка, а правильно реализованная жена наоборот уменьшает это число до неотрицательного значения и предоставляет сервис "борщ on demand".
1) Ресурсы бесконечны
2) Несвежий борщ вкусен
Когда речь идёт о производительности, выраженной в RPS / Core, то все эти варианты с «запасом» уже не могут быть использованы.
А в программировании я вообще ожидаю что Get это идемпотентная операция, которая не изменяет состояние системы.
Я же код пишу не для того чтобы его писать. Я, в первую очередь, пишу его так чтобы большинство моих коллег могли его читать, понимать, модифицировать и использовать.
Если вы имеете ввиду, что в некоторых случаях нужно иметь возможность получить существующий «борщ» или сварить новый — используйте FindOrCreate, например. И любой другой разработчик поймёт что именно произойдёт при вызове метода, не глядя на сигнатуру.
в программировании я вообще ожидаю что Get это идемпотентная операция, которая не изменяет состояние системы
Она не изменяет видимого состояния системы. Внутреннее состояние меняется всегда (банально пишутся логи).
То есть вы ожидаете что Get
какой-нибудь сущности типа "дай мне текущее время" упадет с HttpException потому что он полез на сервер часы синхронизировать?
Как раз таки не упадёт и выдаст мне его в любом случае.
Ну вообще может упасть, потому что полез в, например, базу время последнего доступа к ресурсу модифицировать. Или место на диске под логи кончилось, а у нас бизнес-правило: ни одного ответа без логов, лучше не отдать ответ, записав его в логи как отданный (почти отданный), чем отдать и не записать.
Но чаще всё же превалирует конвенция getItem() без видимых сайд-эффектов и getOrCreateItem(тут обычно полные данные), поэтому новичкам в вашем проекте нужно больше времени для адаптации (дороже).
Если уж говорить про мою конвенцию, то я пишу просто Item
безо всяких get
. Выглядит это так:
@ $mol_mem_key
User( id : string ) {
return new this.$.$my_user( id )
}
Есть такой гайдлайн, называть сущность — существительными, а методы — глаголами. Очень помогает не запутаться, особенно во всяких флюент интерфейсах.
Это уже протечка абстракции. Клиента не должно волновать создаётся оно там, клонируется или ещё что — для него ресурс существует всегда
Именно в ФП клиента не волнует что внутри функции если тип возвращаемого значения T. А вот в императивных языках нет такого четкого контракта, приходится везде проверять на null либо потом ловить NRE в runtime, вместо compile time проверки.
В нормальных императивных языках есть чёткие контракты и non-nullable типы.
А можно пример этих нормальных языков? А то на вскидку только Swift на ум приходит.
TypeScript, например. Ну и в принципе легко добавляется в любой язык с дженериками.
TypeScript, например.Вот написали Вы либу с надеждой на TypeScript, а я стал ее использовать в проекте без strictNullChecks или вообще с чистым JavaScript… а потом буду разбираться со стректрейсом, который полностью в node_modules из-за того, что какой-то умник не проверил на null понадеявшийся на «строгий» (нет!) компилятор…
И таких библиотек на npm в последнее время стало очень много…
Ну и в принципе легко добавляется в любой язык с дженериками.А можно пример такого добавления?
я стал ее использовать в проекте без strictNullChecks или вообще с чистым JavaScript
Ну и ССЗБ.
С серьезным кодом я не работал, так что могу быть неправ.
Когда в проекте используются сотни библиотек, и у каждой новая версия выходит раз в пару месяцев, а раз в пару лет АПИ полностью меняется, уследить за всем невозможно.
Поэтому поддержка со стороны компилятор и системы типов жизненно необходима.
Начитавшись всяких «best practices» многие с умным видом пишут избыточный код, попросту забыв про KISS. Универсального подхода попросту не может быть, все зависит от ситуации.
Я до сих пор не могу понять от кого мы пытаемся защитить код: от программиста, которому мешает нога, или от подлой машины возвращающей неожиданные результаты? От непонимания этого вопроса и возникают такие выводы.
Никакого непонимания нет, машина делает то, что вы ей приказали, а не то, что вы хотели, чтобы она сделала.
Соответственно, нужно стремиться к тому, чтобы и вы, и машина написанный текст воспринимали одинаково. Этому способствует, когда и для вас, и для машины метод GetItem
возвращает результат. И не способствеует, кона для вас он возвращает результат, а для машины должен еще емэйл на почту отослать.
Видимо неправильно сформулировал вопрос:
Для кого введены жесткие ограничения? Машина выполняет только заложеные человеком комманды. Следовательно и типы и приватные поля существуют для человека. Опишу на примере JS: простых приватных полей там нет, и многие изгибаются реализуя их на замыканиях пытаясь спрятать — вопрос: от кого? От себя? Ведь давным-давно условились что поля с префиксом "_" являются приватными, и это всем известно. Но нет, кто как может пишет мудреный код, теряя читабельность и свое время. Зачем нужна эта защита от дурака?
P. S: Возможно я задаю себе(и Вам) слишком глубокие вопросы уходящие корнями в психологию и философию, на которые невозможно ответить сразу.
Это делают скорее потому, что это интересная задача. У нормального разработчика это всё обычно проходит как только появляется потребность дебажить и тут выясняется, что исследовать спрятанные от себя же поля — геморрой.
Не могу понять как это связано с системой типов. Можно вернуть ожидаемый результат и отослать емэйл. От кривой реализации панацеи нет.
Ну вот вам система типов не позволяет вместо строк передавать числа и наоборот? Точно так же система типов может запретить отсылать емейлы, когда вы этого не хотите. Товарищ 0xd34df00d выше расписал как раз этот сценарий.
Компьютер штука глупая. Глупость туда — глупость оттуда.Говорила моя коллега с работы)
Увы, но эта самая протечка обусловлена не архитектурой приложения, а характеристиками СУБД и нефункциональными требованиями к ПО. Клиенту, может быть, и правда не важно появилась запись в БД или нет (при сохранении прочих инвариантов) — но вот просадку производительности так просто не заабстрагировать.
Подписал контракт — изволь исполнять. Хочешь сэкономить — подписывай иной контракт. Или вы предлагаете делать не то, на что подписались?
Я предлагаю включать в контракт действительно важную информацию, а не отмахиваться от неё на основании "ну, то же абстракция!"
В частности, функция GetItem
не должна ничего писать в БД. Это и есть её контракт. Который, увы, иногда нарушается, потому что никто не следит.
Такой контракт ничего не говорит о возможности или невозможности писать в СУБД. Ок, вот вам простой пример. Вам нужно получить сессию, вы пишете что-нибудь типа SessionManager.get()
. Если сессия автоматически создаётся заранее, то вам будет просто возвращён её объект. Если же SessionManager решили сделать ленивым, то метод get
будет создавать сессию под капотом в момент первого обращения. В какой момент будет запись в бд — спрятано за абстракцией SessionManager. Это не ваше дело в какой момент ему ходить в свою же базу.
Во-первых, я бы метод с описываемой вами функциональностью назвал start
, а не get
.
Во-вторых, в чём смысл хранить в базе пустые сессии?
start предполагает начало какого-то процесса. Тут же сессия уже может уже быть, может ещё не быть — в контракте это никак не ограничено. Present Simple — она просто есть, как безвременная абстракция.
Почему же пустые? У неё есть время старта, id узла и прочая нужная информация. А ещё есть админка, позволяющая смотреть список активных сессий и килять неугодных.
А что будет после "киляния"? Автоматически начнётся новая? Так всё-таки, в чём смысл пустой сессии?
Так это вариант существующей сессии же, я говорю про другой случай.
Вам нужно получить сессию, вы пишете что-нибудь типа SessionManager.get()
Я пишу SessionManager.getOrCreate()
(как это сделано в Spark, например). И заранее знаю, что он именно что её гарантированно выдаст — или уже существующую, или новую, — именно потому что и в названии функции это чётко сказано, и сигнатура не подразумевает неудачного исхода (хотя и допускает выброс исключения, да, это всё-таки JVM).
Если сессия безусловно создаётся мидлварой ещё на подлёте к вашему обработчику, то имя getOrCreate врёт, так как при её вызове собственно create не происходит никогда.
Нет, если она к моменту вызова get гарантированно существует — тогда всё логично, не спорю. Но Вы же выше описывали случай, когда она может не существовать и создаваться по запросу, или я что-то не так понял?
for (;;) {
cup = new Coffee();
if (card.Balance < cup.Price) break;
resultList.Add(cup);
card.Charge(cup.Price);
}
Получается, что чистая функция должна вернуть не просто список изменений, который нужно применить к Card, а алгоритм, изменяющий Card определённым образом и строящий resultList? Но тогда мы входим в рекурсию — наш алгоритм возвращает алгоритм, который нужно интерпретировать.
Ну если бы мы были в хаскелле, то можно было бы красиво использовать ленивый бесконечный список и через фолд выразить эту семантику.
Ну а так да, хороший пример когда ST
помогает в строгом языке написать алгоритм, который не выглядит как мешанина из коллбеков:
var currentCharge = Charge.Empty(card);
for (;;) {
var (cup, charge) = Cafe.BuyCoffee();
var newCharge = charge.Combine(currentCharge);
if (card.Balance < newCharge.Amount) break;
resultList.Add(cup);
currentCharge = newCharge;
}
Можно использовать рекурсию:
Buy(card, coffies, charge){
var cup = new Coffee();
return card.Balance < charge.Amount + cup.Price
? (coffies, charge)
: Buy(card, coffies.Push(cup), charge.Combine(new Charge(card, cup.Price));
}
То есть, по сути, вместо того, чтобы передавать во все функции мутабельный объект card, нужно передавать начальное состояние originalCard и отдельно все объекты-изменёния его полей (например, за изменение Balance пусть отвечает Charge, за изменение owner пусть отвечает некий Renamer и т.п.)
Вместо простой сигнатуры BuyCoffee(Card) -> (List), мы делаем BuyCoffee(Card, Charge) -> (Charge, List), которая показывает, что была исходная карта и набор списаний, а получен список покупок и расширенный набор списаний (который можно применить к исходной карте).
Тут пока только одно действие с картой. А если типов действий десяток, так и таскать между всеми функциями исходное состояние и все списки применённых действий?
Самое главное. Мы это делаем для упрощения работы программиста. Не скажу за других, но императивный код
if (card.Balance >= cup.Price) card.Balance -= cup.Price;
для меня понятнее, чем введение новый сущностей (Charge) и операций с ними (Charge.Combine). И такие сущности надо вводить на каждую мелочь, чтобы сохранить чистоту функций?
Я так понимаю, currentCharge не должен создаваться в этой функции, а должен передаваться снаружи и изменённый отдаваться обратно.
Да нет, совершенно нормально создать его в функции. Это же просто начальный элемент. Как единица для факториала, вам не надо передавать её снаружи.
Поэтому дальнейшие рассуждения немного некорректны.
Было у вас BuyCoffee(Card, IPaymentProvider) -> (List)
Стало BuyCoffee(Card) -> (List, Charge)
.
Если действий десяток, то у вас на выбор: сделать ADT энум "один из вариантов действия с картой", сделать какой-нибудь аггрегатор с кастомной логикой который что-то подобное сделает или что-то еще. Звучит сложно, наверное, но на самом деле таковым не является.
Самое главное. Мы это делаем для упрощения работы программиста. Не скажу за других, но императивный код
if (card.Balance >= cup.Price) card.Balance -= cup.Price;
для меня понятнее, чем введение новый сущностей (Charge) и операций с ними (Charge.Combine).
Ну если вам проще, то можно мутировать локальное состояние через ST, я ж говорил :) Главное не нарушить прозрачность. А нарушение прозрачности это плохая штука, я выше вроде показывал какие проблемы оно вызывает. Да и рассуждения в терминах трансформаций данных со временем помогают лучше представлять, что в коде происходит. Не надо думать "так, сейчас пятая итерация, какое состояние у карты там? А какое значение было когда метод был вызван? Забыл..."
И такие сущности надо вводить на каждую мелочь, чтобы сохранить чистоту функций?
А вот тут вы и подбираетесь к монадам. Да, формально вам нужно такие функции писать для каждого типа. И именно для того чтобы не заниматься лишней писаниной, монады и изобрели. Ну примерно как придумали виртуальные функции, чтобы не писать if employee.Type == "Manager" { ... }
.
Да нет, совершенно нормально создать его в функции. Это же просто начальный элемент. Как единица для факториала, вам не надо передавать её снаружиТак откуда мы узнаем, сколько денег с карты уже списано?
Если я вызову 10 раз ф-цию BuyCoffeesForAllMoney(Card), то она 10 раз и купит одно и то же, я же ожидаю от второго и последующих вызовов, что денег на карте больше нет и ничего купить нельзя.
Так откуда мы узнаем, сколько денег с карты уже списано?
Ну так нисколько, мы же реально не списываем.
Если же мы хотим всё же "поменять" значение то нам подойдет монада State, тогда мы сможем запомнить что куда списали до того как до этого момента дошли. Но это опять в монады углубляться. Просто монады это как интерфейсы и паттерны в ООП, любой нетривиальный вопрос — и вы в них попадаете.
Если я вызову 10 раз ф-цию BuyCoffeesForAllMoney(Card), то она 10 раз и купит одно и то же, я же ожидаю от второго и последующих вызовов, что денег на карте больше нет и ничего купить нельзя.
Вот именно то что вызов одной и той же функции может дать разный результат и есть плохой сценарий, от которого стоит избавляться. Функция просто должна возвращать значение, и ничего более. Для осуществления действий есть другие подходы, о которых в другой статье.
То, что она 10 раз вернет одно и то же — это совершенно ожидаемое поведение, и так и должно быть.
Вот именно то что вызов одной и той же функции может дать разный результат и есть плохой сценарий, от которого стоит избавляться. Функция просто должна возвращать значение, и ничего болееВот потому у Вирта есть функции и процедуры.
Вызывая последовательно процедуры BuyCola и BuyCoffeesForAllMoney я ожидаю, что сначала будет куплена кола, а на остаток денег — кофе. Это просто и естественно. Без новых сущностей, таких как Charge.
Если же «всё есть функция», надо думать по-другому.
Если же «всё есть функция», надо думать по-другому.
Да. надо думать по-другому. Это несет только плюсы. Старые представления стоит время от времени пересматривать. Чистые функции проще тестировать, проще композировать, и обычно проще писать.
Вирт и прочие коллеги, которые разрабатывали ЯП тех времён вероятно очень большую дыру в абстракции допустили. Ибо технически как преподают — никакой разницы между функциями и процедурами нет. Только первые возвращают значение. И в результате в головах у учеников нет понимания почему нужно использовать одно или другое. Что ещё хуже — передача параметров по ссылке, а не по значению. Для эффективности — это не плохо, но потом резко выясняется, что аргумент функции или процедуры мутабелен. Жесть. С/с++ этим же страдают в полный рост. Поглядите на какие-нибудь Win32API или стандартную библиотеку языка. Пойди без поллитра разберись что происходит. Поэтому более абстрактные языки новых поколений — это прекрасно. Твои ожидания от того, как будет выполняться код ПОЧТИ совпадают с реальностью...
Наверное, потому что языки того поколения — по сути были надмножеством ассемблера? Который как раз и имеет обе эти (на самом желе) одну абстракцию — подпрограмму (заметьте, как я изящно избежал использования слова функция)
А сейчас теория продвинулась. Вычислительные мощности выросли на порядки. И появились предпосылки для написания… Более математических программ
В контексте этой ветки — затем, что процедура возвращает на самом деле не Unit, а IO Unit.
А что если усложнить пример. Допустим, у нас есть две платёжных карты: основная и резервная. Если с основной транзакция не прошла, списываем с резервной. Причём, критерий обслуживания мы не знаем: например, с одной суммой транзакция может не пройти, а с другой может пройти (все чашки имеют разную цену).
Как вот такой алгоритм перевести в «чистый»?
for (;;) {
cup = new Coffee();
if (card1.TryPay(cup.Price)) {
resultList.Add(cup);
} else if (card2.TryPay(cup.Price)) {
resultList.Add(cup);
} else {
break;
}
}
Ну тут уже придется расчехлять монады (в сишарпе монадический do-синтаксис спрятан за async/await поэтому использую его):
for (;;) {
cup = new Coffee();
if (await card1.TryPay(cup.Price)) {
resultList.Add(cup);
} else if (await card2.TryPay(cup.Price)) {
resultList.Add(cup);
} else {
break;
}
}
нужно понимать что await
тут в более широком смысле чем запуск асинхронной операции.
Тогда вызов нашей функции будет чистым — мы просто создаем описатель вычисления (в сишарпе это тип Task), который ничего не делает пока мы его не запустим и не получим результат.
Достаточно любую процедурную лапшу обвешать async/await, и получим чистую функцию, которую легко сопровождать и в которой минимум ошибок, ведь это теперь ФП!
Ну, какой-то смысл в этом есть. Любую лапшу действительно можно так переписать, это скорее плюс. Другой вопрос что лучше постараться вынести переиспользуемые куски. Но если не получается, в худшем случае вы остаетесь там, где начинали. То есть даже в худшем случае вы ничего не теряете.
Зато явность авейтов часто помогает недопустить ошибку которую я сделал. Если бы в том коде было вот такое:
var something = await function();
await DoStuff(this.Field, something);
я бы знал, что переставлять эти строчки может быть опасно.
я бы знал, что переставлять эти строчки может быть опасно.А может и не опасно. Нет никакой гарантии, что в этом коде function меняет this.Field.
В императивном коде тоже было известно, что перестановка операторов может привести к изменению поведения. А может и не привести. Тут в любом случае надо смотреть реализацию.
Так тут фишка как с unsafe
в расте. Если я вижу явный забор "опасно" я пойду проверять.
А в сишарпе мне никакого терпения не хватит при каждом инлайне каждой функции идти проверять все возможные последствия.
Добавляя await, получаем предупреждение, что теперь возможно у кода есть побочные эффекты )))
Скорее авейт говорит, что у кода обязательно есть побочные последствия. В зависимости от монады это может быть разный эффект. В State это будет изменение стейта, в IO асинхронный запрос куда-то, в Writer запись в лог, и так далее.
Дальше остется оценить, устраивает ли меня что эффект поменяется или нет. Например, если запись в лог будет чуть позже то мне наверное пофиг, а вот если раньше стейт менялся в другой момент, то наверное так делать нельзя.
Сейчас я слабо представляю, как написать State через await-ы, но интуитивно мне кажется неправильным писать в State через await-ы, а читать его напрямую обращением к изменившемуся полю, а не через другую async-функцию. То есть, ваш пример конкретно с this.Field и await-ами мне кажется несколько надуманным.
Способ перенести управление во вне — это скорее генераторы. А async/await — не более чем частный их случай.
1. Весь генератор должен yield-ить из одной функции, когда await-ы могут вызываться и из вложенных.
2. Генератор привязан строго к одному типу значений, когда типизация await-ов намного богаче.
3. Внешний код не может передавать данные внутрь генератора по мере выдачи им значений, чтобы влиять на работу генератора.
Весь генератор должен yield-ить из одной функции, когда await-ы могут вызываться и из вложенных.
Как минимум несколько раз я видел возможность написать что-то вроде yield* anotherGenerator()
(пример из JavaScript).
Внешний код не может передавать данные внутрь генератора по мере выдачи им значений, чтобы влиять на работу генератора.
А если может (см. как минимум всё тот же JavaScript) — это уже не генератор, а корутина получается?
это уже не генератор, а корутина получается?Ну так комментатор выше утверждал, что генератор — более общий случай. Значит, должен уметь всё, что и корутины.
Я сейчас чисто терминологически пытаюсь понять: граница проходит именно по возможности сказать не просто next()
, а next(passed_value)
?
А генератор — просто функция. Не чистая, то есть со своим внутренним состоянием, но в целом ничем не отличающаяся от любой другой функции.
Весь генератор должен yield-ить из одной функции, когда await-ы могут вызываться и из вложенных.
Нет, не могут. Вы принимаете за возможность await-тов возможности Task-ов.
- yield и await — одно и то же. async function и generator function — тоже. Вся разниа в том, то async/await передаёт между функциями промисы, а генераторы могут передавать что угодно.
- Не привязан он ни к чему. Вы так же можете возвращать типизированный промис, а внешняя функция будет вас вызывать, когда он отрезолвится.
- Может.
3. МожетКак это выглядит синтаксически?
Это именно что использование встроенной в язык конструкции. Эта возможность есть в С++, Javascript и Python, но отсутствует в C# и в Kotlin
Если любую функцию next() называть генератором, то зачем вообще вводить новый термин?
Генератор — это сопрограмма (обычно безстековая), которая императивно пушит "наружу" последовательность значений при помощи yield-подобной конструкции.
Любую функцию next назвать генератором нельзя.
Внутри генератора используются этим самые yield, снаружи это просто объект с методом "держи значение, которое вернёт yield и сделай ещё немного работы". К коллекциям это отношения не имеет.
Коллекции — это про итераторы. Итераторы можно реализовать через генераторы, а можно и через обычные функции.
А внутри, допустим, «yield 5;».
Или там не «yield 5;», а что-то типа «var localRes = yield 5;»?
Как параметр res попадает внутрь генератора?
Именно так, let x = yield 5
Что как-то нелогично. Было бы удобно, например, написать генератор случайных чисел и синтаксисом next(N) получать очередное случайное число в интервале (0,N).
А что мешает?
function rnd() {
let next = NaN;
for(;;) {
let N = yield next;
// генерируем случайное число в интервале (0, N)
next = ...;
}
}
Тут разве что больше проблема с первым элементом возникает, его придётся пропустить.
function myRandom(maxValue) {
let dummy = rnd(maxValue);
return rnd(0);
}
Зачем 2 раза-то?
let dummy = rnd();
dummy.next();
function myRandom(maxValue) {
return dummy.next(maxValue).value;
}
А создавать новый ГСЧ для получения каждого следующего числа ни в одном языке не рекомендуется.
Но как это работает?
Когда клиет вызывает
dummy.next(maxValue)
фукция-генератор работает до следующего yield, т.е. до строки
let N = yield next;
выражение в yield возвращает клиенту как результат next(maxValue), а параметр maxValue присваивает переменной N.
При этом, «генерируем случайное число в интервале (0, N)» находится ниже по коду.
И при первом вызове
dummy.next();
функция выполняется до
let N = yield next;
откуда будет взято значение N?
На инструкции yield выполнение сопрограммы останавливается. Пока не будет сделан вызов next — yield не вернет управления.
А next, в свою очередь, не вернет управления пока не выполнение сопрограммы не дойдет до yield.
return dummy.next(maxValue)
возвращает значение не для переданного здесь maxValue, а для предыдущего?
Получается, что параметры next() сдвинуты ровно на 1 yield. Например, фукция
function * rnd() {
console.log("start");
let x1 = yield 0;
console.log("x1="+x1);
let x2 = yield x1*2;
console.log("x2="+x2);
let x3 = yield x2*3;
console.log("x3="+x3);
}
Выполняется так:z=rnd();
z.next(5); // параметр 5 не используется
console.log("start");
>> "start"
let x1 = yield 0;
>> z.next(5).value = 0
// хотя x1=yield 0; выполнен,
// значение x1 пока не задано
// вот это меня и смущало!
z.next(6); // параметр 6 передаётся в x1
console.log("x1="+x1);
>> "x1=6"
let x2 = yield x1*2;
>> z.next(6).value = 12
z.next(7); // параметр 7 передаётся в x2
console.log("x2="+x2);
>> "x2=12"
let x3 = yield x2*3;
>> z.next(7).value = 21
...
</souce>
Пока не будет сделан вызов next — yield не вернет управления.
Неочевидно было, что вызов next(5), который вернул 6 из let x = yield 6;
это самое значение 5 не пишет в переменную x, а только параметр следующего next будет записан в x.
Как это может быть неочевидно? Сопрограмма "висит" на yield 6
, и её разбудит только следующий вызов next. Он и определит значение x. Это единственное логичное поведение...
Хотя, этот дизайн можно понять: для клиента удобнее, чтобы параметр next повлиял на значение ф-ции next.
А так, получается, yield выполнен наполовину
Но ведь точка, где переключается контекст исполнения, должна быть именно что "выполнена наполовину" и никак иначе...
Хотя, конечно нет никакого instruction pointer-а: корутина компилируется в state-машину, где стейты соотносятся с инструкциями весьма условно, поэто наверное нет большой разницы в реализации, поделён yield между стейтами или нет.
Если я правильно понимаю, загвоздка в том, что let x = yield y;
семантически представляет собой не одну, а две инструкции: собственно yield
и присваивание. И "после yield
" как раз и значит "между yield
и присваиванием".
Вычисление оператора и запись в переменную — разные инструкции.
let x = (yield 5) + (yield 6)
Сложно придумать, каким должен быть сценарий обмена данными между генератором и клиентом, чтобы подобные конструкции выглядели уместно.
Ну, например, если yield отправляет некоторый запрос, а в next(...) мы засовываем ответ на него (аналога async/await).
можно было бы сделать сопоставление один-к-одному next и yield. Именно это и было ожидаемо.
Они 1 к 1 и сопоставляются, кроме первого .next(), который можно интерпретировать как .start()
Вот так и попадает. next помещает переданное значение в стек и вызывает генератор, который в зависимости от счётчика у себя внутри делает goto на следующую после yield инструкцию.
Они идут вместе, и один их механизмов всегда можно выразить через другой, а потому некорректно говорить кто чьим частным случаем является.
В C++ Coroutines TS, к примеру, именно co_await является основным механизмом.
Сейчас я слабо представляю, как написать State через await-ы, но интуитивно мне кажется неправильным писать в State через await-ы, а читать его напрямую обращением к изменившемуся полю, а не через другую async-функцию. То есть, ваш пример конкретно с this.Field и await-ами мне кажется несколько надуманным.
Попробовал реализовать стейт через аваейты. К сожалению, из-за того как GetAwaiter оказался спроектирован, он прибит гвоздями конкретно к асинхронности.
Слава Богу, у LINQ такой проблемы нет, это полноценный do-синтаксис, так что на нем стейт монаду спокойно реализовать можно:
State<int, Unit> s =
from state in State.Get<int>()
let newState = state + 547
from _ in State.Set(newState)
select Unit.Instance;
Console.WriteLine(s.Run(5));
Полный код можно найти по ссылке: https://wandbox.org/permlink/Thcf0CPQparKVQiG
Да, первоначально выглядит немного чужеродно, "как же так, ведь from .. in
это для итераторов!", но на самом деле это не правда. from x in y
это полный аналог хаскеллевского x <- y
.
Правда, в отличие от полноценных монад это ад-хок решение, которое работает для известных во время компиляции типов. Написать функцию, которая работает таким образом с любой монадой не выйдет. Если только на динамиках каких-нибудь.
Допустим, я хочу написать «чистую» функцию с аргументом int a, которая увеличит state на 1, если a > 0, на 2, если a = 0, и на 3, если a < 0.
Ну вот так например:
State<int, Unit> Foo(State<int, Unit> state) =>
from a in State.Get<int>()
let valueToSet = a > 0 ? 1 : a == 0 ? 2 : 3
from _ in State.Set(valueToSet)
select Unit.Instance;
Запускаем:
var initialState = State<int, Unit>.Return(Unit.Instance);
Console.WriteLine(Foo(initialState).Run(5));
Console.WriteLine(Foo(initialState).Run(0));
Console.WriteLine(Foo(initialState).Run(-5));
Получаем
(ConsoleApp28.Unit, 1)
(ConsoleApp28.Unit, 2)
(ConsoleApp28.Unit, 3)
Неправильно распарсил. Тогда так.
State<int, Unit> Foo(int a) =>
State.Set(a > 0 ? 1 : a == 0 ? 2 : 3);
Можно было бы передавать старый стейт, но он все равно затирается, поэтому не нужен.
Нужна сигнатура
static State<int, Unit> Foo(State<int, Unit> state, int a) =>
Простите, что-то я туплю. Вот так конечно же:
static State<int, Unit> Foo(int a, State<int, Unit> state) =>
from stateValue in State.Get<int>()
let increment = a > 0 ? 1 : a == 0 ? 2 : 3
from _ in State.Set(stateValue + increment)
select Unit.Instance;
var initialState = State<int, Unit>.Return(Unit.Instance);
Console.WriteLine(Foo(5, initialState).Run(10));
Console.WriteLine(Foo(0, initialState).Run(20));
Console.WriteLine(Foo(-5, initialState).Run(30));
получаем:
(ConsoleApp28.Unit, 11)
(ConsoleApp28.Unit, 22)
(ConsoleApp28.Unit, 33)
static State<int, Unit> Foo(int a, State<int, Unit> state) =>
from stateValue in State.Get<int>()
let increment = a > 0 ? 1 : a == 0 ? 2 : 3
from _ in State.Set(stateValue + increment)
select Unit.Instance;
static void Main(string[] args)
{
var initial = State<int, Unit>.Return(Unit.Instance);
var next = Foo(0, initial);
var next2 = Foo(-1, next);
Console.WriteLine(next2.Run(100));
}
Потому что я снова тупанул и стейт не читаю, он всегда новый создается (если обратите внимание, переменная state
не использовалась). Вот так должно быть:
static State<int, Unit> Foo(int a, State<int, Unit> state) =>
from __ in state
from stateValue in State.Get<int>()
let increment = a > 0 ? 1 : a == 0 ? 2 : 3
from _ in State.Set(stateValue + increment)
select Unit.Instance;
Я еще начинающий ФПшник, тем более в сишарпе где язык сопротивляется :)
P.S. Когда уже стабилизируют деприкейт _
как имени переменной… Мешает очень.
А вот так можно два стейта разных читать и формировать новый:
static State<string, Unit> ConcatStates(State<string, Unit> a, State<string, Unit> b) =>
from __ in a
from aValue in State.Get<string>()
from _ in b
from bValue in State.Get<string>()
from ___ in State.Set<string>(aValue + bValue)
select Unit.Instance;
static void Main(string[] args)
{
var a = State.Set("Hello ");
var b = State.Set("World!");
Console.WriteLine(ConcatStates(a, b).Run(""));
}
В общем, мощная штука)) И как видите, работает абсолютно так же, как итераторы или async/await.
Правда, сперва непривычно и дико, ведь LINQ по традиции используется только с итераторами. Потом привыкаешь.
static bool IsZero(State<int, Unit> state)
возвращающую true, если текущее значение state равно 0.Если бы так было можно, задача покупки кофе «на все деньги» могла быть решена через такой state (покупаем, пока не 0 на карточке).
Да, так нельзя, потому что это нарушает правило, что значение не может покинуть пределы монады. С такой функцией можно было бы нарушить ссылочную прозрачность. Функция должа выглядеть
static State<int, bool> IsZero(State<int, Unit> state)
К стате, в хаскелле такая нечистая функция есть, правда, не для стейта, а для IO. Называется она unsafePerformIO
, и как следует из названия, лучше ей не пользоваться. Против неё есть целая прагма {-# LANGUAGE Safe #-}
которая запрещает её использование.
Значения не должны покидать контекста монады, в котором созданы. Это так же плохо, как выходить из асинхронного контекста функции через task.Result
(который тоже является монадой, и именно поэтому так делать не надо). Чревато дедлоками и всеми остальными плохими вещами.
Поэтому покидать контексты монад — плохо. Для каждой монады последствия такого выхода свои, но всегда — плохие.
Если кто-то будет это читать, вот тут можно посмотреть итоговый вариант кода после всех исправлений:
https://gist.github.com/Pzixel/05d6fc18f389149e64995b148147345e
Вы неправильно распарсили, как и я в первый раз) Вам нужен аргумент a
чтобы понять насколько инкрементировать текущий стейт. А потом вам нужен сам стейт чтобы узнать его текущее значение.
Так что там всё правильно написано.
Ну тогда так:
State<int, Unit> Foo(int a) =>
from x in State.Get<int>()
let valueToAdd = a > 0 ? 1 : a == 0 ? 2 : 3
from _ in State.Set(x + valueToAdd)
select Unit.Instance;
В любом случае, параметр state принимать не надо ни при каких условиях. Монада как раз для того и сделана, чтобы не нужно было принимать этот параметр.
Да, вы правы. Тогда использование становится тривиальным:
var next = Foo(0);
var next2 = Foo(-1);
Console.WriteLine(next.Bind(_ => next2).Run(100));
Правда, в таком случае непонятно как склеивать 2 стейта, там такого эмбиента не получится ведь.
А для двух стейтов надо использовать либо трансформер монад, либо общий стейт и линзы.
Но в C# ни то, ни другое нормально работать не будет из-за направления вывода типов.
Console.WriteLine(
next.Bind(_ => next2.Bind(__ => next3)).Run(100));
А если 100?
Ну для небольшого количества стейтов можно переписать в linq опять же :)
var final = from x in next
from y in next2
select Unit.Instance;
Console.WriteLine(final.Run(100));
Ну а для 100 нужно будет их собирать в список и делать траверс или sequence. Примитивный вариант
public static State<S, A> Sequence<S, A>(this IEnumerable<State<S, A>> states) =>
states.Aggregate((prev, next) => prev.Bind(_ => next));
Используем:
var states = Enumerable.Range(-5, 10).Select(Foo);
Console.WriteLine(states.Sequence().Run(100)); // 121
static Func<int, int> Foo2(int a) =>
a > 0
? x => x + 1
: a == 0
? x => x + 2
: (Func<int, int>)(x => x + 3);
А потом вручную создать композицию этих «стейтов», и вызвать полученную композицию с начальным значением. Но это же не будет State в терминах монад? А в чём разница?
var next = Foo2(0);
var next2 = Foo2(-1);
var next3 = Foo2(1);
Console.WriteLine(next3(next2(next(100))));
Вы верно уловили суть, State<S, T>
— это и есть обёрнутая Func<S, T>
(точнее, Func<S, (T result, S state)>
).
Смысл этой монады — во-первых, в том что это простейшая из монад, удобная для изучения (проще нее разве что Maybe/Either — но те вообще ничего интересного не делают). А во-вторых, монада IO<T>
— это, в некотором роде, State<RealWorld, T>
(если забыть про невыразимость RealWorld) и у нее во многом схожие свойства.
С тем же успехом я могу написать
Верно, в ФП любую монаду можно описать как чистую функцию, принимающая аргументами другие функции и возвращающие еще одни функции. В этом ведь и весь смысл, а то наши рассуждения про чистоту развалились бы. Правда, выглядит оно как стейт, и работать (и думать) о нем можно как о стейте. В чём и состоит ценность.
А еще вы поняли, что такое монада (ну, одна из них). Не так уж сложно как говорят, правда?)
Совершенно не интуитивно и не похоже на императивное состояние. К тому же, даёт как неоправданную нагрузку на GC, так и на CPU сначала при генерации функторов, затем с их вложенным вычислением.
Вы же знаете, что такие заявления без бенчмарков делать не надо.
А как показывают бенчмарки, тот же хаскель быстрее и Java, и C#. Ну да, помедленнее плюсов, но я и не предлагаю его использовать на тех же задачах. А вот на задачах обычных сервисов и хождений в БД одни плюсы. Разве нет?
P.S. Когда уже стабилизируют деприкейт _ как имени переменной… Мешает очень.
Делаешь на монаде экстеншн-метод .And() (аналог >>) и пишешь так:
from stateValue in state.And(State.Get())
Тоже сам сорт оф костыль, конечно, но в итоге лучше выглядит чем черточки или переменные с натужно выдуманными именами.
По-моему проблема в том что должно быть this.function(). А что в си шарпе this не явный?
Или где то есть глобальная ссылка на this?
В данном случае function
был коллбеком которйы попадал в функцию как параметр. И не во всех случаях передавался коллбек, который что-то менял, поэтому и на тестах и на ревью это не всплыло. А только на регрессе всех сценариев.
Что до this
, то это ключевое слово в сишарпе необязательно, и this.Field
и просто Field
равнозначны.
Тогда вызов нашей функции будет чистым — мы просто создаем описатель вычисления (в сишарпе это тип Task), который ничего не делает пока мы его не запустим и не получим результат.
Любая функция ничего не делает, пока мы её не запустим и не получим результат.
Ну вот я запустил LazyPrint, на экране ничего не появилось:
void Main()
{
LazyPrint();
Print();
}
Task LazyPrint() => new Task(() => Console.WriteLine("I'm lazy print"));
void Print() => Console.WriteLine("I'm strict print");
Не будем повторяться: https://habr.com/ru/post/479238/#comment_20980982
мы просто создаем описатель вычисления (в сишарпе это тип Task), который ничего не делает пока мы его не запустим и не получим результат
Вот с такими заявлениями надо быть аккуратнее. Task в C#, напомню, ленивым не является (если говорить про тот, который создаётся через async)
Ну тут я немного слукавил, признаю. Таски являются холодными если создаются через new Task
, и не являются таковыми если создаются через асинк/авейт и Task.Run.
Честно говоря, мне кажется что это ошибка в дизайне языка. В том же F# кстати это было исправлено, там асинки честно ждут пинка от рантайма.
В том же F# кстати это было исправлено, там асинки честно ждут пинка от рантайма.
Фшарповые Асинки появились на пару лет раньше тасков. Так что это скорее авторы сишарпа не смогли списать домашку и запороли дизайн Тасков
Да всё проще же. Шарповые таски сделаны в императивной парадигме, потому что их так проще воспринимать.
Вон в прошлой статье при попытке изобразить что-то на ленивых тасках Питона один комментатор не смог в рекурсию, а второй запутался при портировании кода 1 в 1. Хотя, казалось бы, что тут сложного...
Сам по себе IO ничего не делает, если мы напишем print "Hello world" в хаскелле ничего не произойдет.
То есть человек ошибся, а компилятор вместо того, чтобы сообщить об ошибке, молча это скушал? Я бы не восхищался таким поведением.
- полагаю, на это есть ворнинг. В расте ведь есть MustUse атрибут
- Можно не писать 10 сообщений в корне?
- Опять вы что-то там предполагаете. У вас астроголов в роду не было?
- Можно не писать. а можно и писать. У вас в сигнатуре статьи не написано, что нельзя писать 10 сообщений в корне.
- как и предполагалось, ворнинг на это есть: https://repl.it/@Pzixel/Haskell-playground
- очень жаль, что в правилах этого нет. И "не у меня", это правила площадки, и я к ним отношения не имею.
хаскель программа это алгоритм записанный на листочке, а рантайм — это робот, который этот алгоритм выполняет
Любая программа — это алгоритм, записанный на листочке, а процессор — это робот, который этот алгоритм выполняет.
Вопрос в том, в какой момент происходит выполнение того, что написано — в тот же, когда выполнение дошло до некоторой строчки или потом при интерпретации.
Точно так же работают итераторы в сишарпе, например. Вы строите вычисление, но оно не срабатывает пока вы не начнете его собственно исполнять.
var query = Enumerable.Range(1, 10).Select(_ => throw new Exception());
var result = query.ToArray();
Смысл именно в том, что вы получите эксепшн не в первой строчке, а во второй. Потому что сама запись "throw new Exception" ничего не делает. Это инструкция для дальнейшей интерпетации (через ToArray(), например). Поэтому создание query
это чистая функция. Если я напишу:
var query = Enumerable.Range(1, 10).Select(_ => throw new Exception());
То никаких исключений, ожидаемо, не произойдет.
Вопрос в том, в какой момент происходит выполнение того, что написано — в тот же, когда выполнение дошло до некоторой строчки или потом при интерпретации.
В императивных языках это называется кодогенерация.
Смысл именно в том, что вы получите эксепшн не в первой строчке, а во второй.
То есть программа остановится не в месте возникновения ошибки, а где-то в случайном месте программы, где решили привести коллекцию к массиву? Счастливой отладки, да.
В императивных языках это называется кодогенерация.
Каким образом кодогенерация к этому относится?
То есть программа остановится не в месте возникновения ошибки, а где-то в случайном месте программы, где решили привести коллекцию к массиву? Счастливой отладки, да.
Так работают ленивые вычисления, вне зависимости от языка. И это единственный способ сделать взаимодействие с внешним миром вроде БД или консоли чистым.
На практике проблем с этим нет, по крайней мере я не знаю сишарп разработчиков которым было бы больно от того что итераторы ленивые. Обычно это наоборот удобно.
На практике проблем с этим нет, по крайней мере я не знаю сишарп разработчиковКогда начинали писать с Entity Framework, коллеги бывало недоумевали: почему мой запрос выдаёт непонятные исключения при выполнении ToList(). Внутри каких-то expressions использовались выражения, который EF не мог превратить в SQL, а stack trace был очень далеко от того места, где ошибочный expression был добавлен в дерево запроса.
То есть программа остановится не в месте возникновения ошибки, а где-то в случайном месте программы, где решили привести коллекцию к массиву? Счастливой отладки, да.Именно по этому в ФП языках не кидают исключений, а возвращают рантайм ошибку как результат вычислений, а для ошибок программиста есть паника, у которой будет адекватный стектрейс…
С другой стороны, псевдокод на C#, эквивалент которого вполне можно встретить в императивном коде:
class A
{
public void MethodA()
{
MethodB();
}
private void MethodB()
{
throw new Exception();
}
}
class B
{
public void MethodA()
{
MethodB(new A());
}
private void MethodB(A a)
{
try {
a.MethodA();
}
catch(Exception e) {
Logger.Error(e);
throw e;
}
}
}
Счастливой отладкиСчастливой отладки
Тут хорошей идеей было бы использование InnerException. Вместо throw e:
throw new MethodBException(e);
И тогда в catch ничего логировать не надо, т.к. исключение брошено дальше, и должно быть залогированно там, где перехватывается.
Второй хорошей идеей было бы в методах типа
Logger.Error(e);
всегда фиксировать стектрейс, сохранённый внутри e (и в цепочке его InnerExceptions), потому даже в вышеприведённом варианте нет никаких проблем узнать, где первоначально было выкинуто исключение.Но я скорее предположу, что Ваша претензия к типу Result, но и тут проблем меньше.
Во-первых, в Rust четко разделены паники и ошибки как результат вычисления. Паники — это ошибки программиста, их почти никогда не ловят (хотя такая возможность есть, это крайний случай) и у них четкий стектрейс и дебажить их никаких проблем. Ошибки как результат вычисления — это штатная ситуация (сеть не доступна, файл не открылся), ее не нужно дебажить, ее нужно обрабатывать, ну или пробрасывать дальше, программисту чаще всего до лампочки, какой сискол там использовался под капотом, если его программа не смогла открыть файл из-за того, что юзер выдернул флешку.
Во-вторых, что бы пробросить ошибку, тип ошибки должен совпадать, а если моя функция возвращает Result<(), MyError>, а вызываемая Result<(), OtherError>, то чтоб пробросить его мне придется как то преобразовать OtherError к MyError, я могу сделать это на месте с помощью .map_err() или обобщенно, с помощью From/Into типажей, но сделать я это обязан, иначе моя программа просто не скомпилируется.
И это все резко отличает Rust/Ocaml/Haskell от языков с эксепшенами, где я мало того, что не вижу из сигнатуры, что там могут быть ошибки, так еще мне в одну кучу сыпят то, что я забыл написать if(x != null)
Каких, например?
Провалились скорее потому, что никому эта строгость не сдалась.
Это всё можно было бы реализовать через дженерики.
Имхо компилятор знает все типы исключений которые выбрасывает функция и при желании мог бы предоставлять эту информацию IDE
Хотя принципиально это возможно, наверно. Но никак не поможет с декларативностью сигнатур.
Как сделать следующее?Месье знает толк в извращениях, или вот что с людьми делает изучение систем типов :-o
Если мне пришел None — мне глубоко фиолетовоРовно до тех пор, пока в этом месте None не должно было прийти и нужно выяснить, почему тут None.
Ошибки как результат вычисления — это штатная ситуация (сеть не доступна, файл не открылся), ее не нужно дебажить, ее нужно обрабатывать, ну или пробрасывать дальше, программисту чаще всего до лампочки, какой сискол там использовался под капотом, если его программа не смогла открыть файл из-за того, что юзер выдернул флешкуНо программа не должна падать от того, что юзер выдернул флешку?
Во-вторых, что бы пробросить ошибку, тип ошибки должен совпадать, а если моя функция возвращает Result<(), MyError>, а вызываемая Result<(), OtherError>, то чтоб пробросить его мне придется как то преобразовать OtherError к MyError, я могу сделать это на месте с помощью .map_err() или обобщенно, с помощью From/Into типажей, но сделать я это обязан, иначе моя программа просто не скомпилируетсяМожет быть, может быть. С checked exceptions это не взлетело, потому что просто лень предусматривать все возможные варианты конверсий ошибок. Если где-то из глубин вылезет FileNotFoundException или HostNameNotResolved, конечно «правильным» действием для «правильного» языка будет закрыть программу и отправить дамп к программисту разбираться.
В реальности гораздо гибче показать юзеру FileNotFound (C:\Users\...\config.xml) не отличая его от NullReferenceException и других ошибок, и сказать, что вот это конкретное действие не выполнилось, и часто юзеру будет очевидна причина.
Если None там не должно придти, то это надо выразить в типах.Так у нас декомпозиция задач, и программер, который читает из конфига настройку, может просто вернуть None, если настройки в конфиге нет, а программер, пишущий код уровнем выше, может не заморачиваясь вернуть None в ответ на None. Замечу, что мы рассматриваем в этой ветке не абстрактно-идеальный код, где на каждом уровне написано всё верно, а «компилятор не выдал ошибку, а значит и так сойдёт». Кидание конкретного исключения, которое без изменений придёт на самый верх, тут не самая плохая идея.
Ну так и тут, вы можете на самом верхнем уровне просто сконвертировать конкретное значение ошибки в строку и показать его пользователю.То есть, нафиг эта строгость и типизация, пусть будет generic exception с текстом в виде строки.
У эксепшена тем не менее будет стектрейс, позволяющий понять откуда он взялся. А у блуждающего по программе None — нет.
Давайте не вспоминать протухшие языки, а то они так и не помрут.
В том и суть, что "можете", а не "должны", а ещё лучше "оно как-то само".
Давайте не вспоминать протухшие языки, а то они так и не помрут.
Ну, нужно же на чем-то всякие CLR'ы и JRE писать.
Существует полно современных компилируемых языков. D, Rust, Nim.
Вы вот зря иронизируете. Или вы протухание определеяете по популярности?
По устарелости дизайна. По тому как современные достижения компьютерной науки вписываются в дизайн языка. По количеству WTF.
В контексте статьи там есть ключевое слово pure. Больше киллер-фичей D к сожалению не знаю.
Как минимум, адекватная реализация шаблонов, исполнения времени компиляции и вот этого всего.
Извините, не понял Вас. По-Вашему, С++ — протухший? То-то на нем до сих пор Яндекса и Касперский пишут. А ещё выходят новые стандарты...
Хотя согласен… С++ пора закапывать
Непонятно, почему вы рассматриваете ленивых программистов, везде кидающих None, но не рассматриваете ленивых программистов, кидающих бессмысленные экзепшоны.Потому что ленивым программистам не надо кидать бессмысленные exceptions, вместо этого осмысленные exceptions будут кинуты из слоя ниже, ленивым программистам нужно лишь их не ловить.
github.com/hyperrealm/libconfig/blob/master/lib/libconfigcpp.c%2B%2B#L282
Забавно. Приверженцы ИП так любят говорить любителям ФП "счастливой отладки", но как-то так получилось, что за 8 месяцев работы со скалой и хаскелем мне ни разу не пришлось запускать отладчик.
То есть программа остановится не в месте возникновения ошибки, а где-то в случайном месте программы, где решили привести коллекцию к массиву?Это не «случайное место» — это место, где из ленивой последовательности требуется результат. И в стектрейсе будет явно указано, что исключение выкинуто на первой строчке из анонимной функции, но произойдет это после выполнения второй и функция `ToArray` также будет в стектрейсе где-то ниже. Все логично и ожидаемо.
del
Отличный вариант! Правда он не всегда может сработать. Во-первых потому что иногда оно может по типам не сойтись (с этим я столкнулся когда работал с акка стримами), а во-вторых у вас могут другие соображения. Например, производительность.
Но, наверное мы не просто так ту строчку в функции написали, и логи в кибане все же хотели бы увидеть, поэтому сочтем такую точку зрения маловероятной)
Если функция исполнилась — хотели бы. Если функция не исполнилась — не хотели бы. В данном случае логируется сам факт исполнения функции.
Хотя люди обычно признают удобства ФП фич, ведь намного приятнее писать:
int Factorial(int n) { Log.Info($"Computing factorial of {n}"); return Enumerable.Range(1, n).Aggregate((x, y) => x * y); }
чем ужасные императивные программы вроде
int Factorial(int n) { int result = 1; for (int i = 2; i <= n; i++) { result *= i; } return result; }
Нет, не приятнее и выглядит ужасно.
Ну тут еще и синтаксис сишарпа не очень. Возьмем пример с ренжами из C# 8.0:
int Factorial(int n) => (1..n).Fold((x, y) => x * y);
Неужели это читается менее понятно чем вариант с циклами?
Тут буквально написано "возьми числа от 1 до n и перемножь их".
int Factorial(int n)
{
return Enumerable.Range(1, n).Aggregate((x, y) => x * y);
}
int Factorial(int n)
{
int result = 1;
for (int i = 2; i <= n; i++)
{
result *= i;
}
return result;
}
int Factorial(int n) => (1..n).Fold((x, y) => x * y);
Вот запулить опрос что более понятно. По мне так первый вариант наиболее лаконичен, но я почти не пишу на шарпе.=)
В этом весь смысл. Когда вы видите var x = foos.Fold(...)
вы можете дальше не смотреть, вы знаете, что в результате получите одно значение определенного типа.
А когда вы видите for (...)
то вы не знаете ничего. Надо проверить начальное значение, условие выхода, что происходит с аккумулятором (там часто умножение, битовые сдвиги или еще что-нибудь такое происходит). Потом еще тело посмотреть, что цикл делает. Может он значение возвращает, а может что-то нехорошее делает.
Поэтому когда вы вызываете более конкретную функцию, а не более общую, вы сужаете множество возможных действий, и упрощаете чтение кода.
Я как раз к тому, что эти абстракции хороши, и ими надо пользоваться. Не даром же они есть в стандартной библиотеке практически всех современных языков.
Пример из заголовка статьи скорее противопоставлял то, что флюент синтаксис с модными функциональными именами методов не делает ваш код автоматически функциональным. И с другой стороны, использование низкоуровневых циклов без абстракций не делает ваш код грязным. Выбор функции был лишь с точки зрения большего контраста.
А когда вы видите for (...) то вы не знаете ничего. Надо проверить начальное значение, условие выхода, что происходит с аккумулятором (там часто умножение, битовые сдвиги или еще что-нибудь такое происходит). Потом еще тело посмотреть, что цикл делает. Может он значение возвращает, а может что-то нехорошее делает.Вот это всё я наоборот считаю плюсом. В смысле что оно всё сразу в одном месте. У меня всё это происходит на автомате для императивщины.
Могу ответить такой аналогией: как вы отнесетесь к человеку, который все циклы всегда пишет for(;;)
? Вы вот пользуетесь for/while/do_while/..., а он всегда пишет только for(;;)
? Как по мне, это не очень хорошо.
ну или например, как вы думаете, что понятнее:
int[] b = new int[a.Length];
for (int i = 0; i < a.Length; i++) {
int value = a[i];
b[i] = a[i] * a[i];
}
return b;
или
return a.Map(x => x*x).ToArray();
Если вам ближе вариант №1, то я просто рекомендую получить опыт написания во втором стиле. Это вопрос привычки
Перепешите на свёртках списков код чуть сложнее, чем "привет мир", без потери наглядности:
bool isEqual( int[] left , int[] right ) {
if( left.length != right.length ) return false;
foreach( int i ; 0 .. left.length ) {
if( left[i] != right[i] ) return false;
}
return true;
}
А в чем тут должна возникнуть проблема?
bool isEqual( int[] left , int[] right ) {
if( left.length != right.length ) return false;
return left.Zip(right, (left, right) => new {left, right})
.Foldr(true, (acc, item) => acc && item.left == item.right);
}
Наглядность как по мне только выросла.
Это не эквивалентный код. Нужно заменить Foldr
на что-то вроде FoldrUntil
.
В ленивом языке (например, в хаскелле) это будет эквивалентный код.
В неленивом придется извращаться как тут: https://docs.rs/itertools/0.6.0/itertools/trait.Itertools.html#method.fold_while
С другой стороны, мне пришло тут в голову, что вот так можно написать с сохранением раннего выхода и читаемости
bool isEqual( int[] left , int[] right ) =>
left.length == right.length &&
left.Zip(right, (left, right) => new {left, right})
.All(item => item.left == item.right);
Ну или с таплами
bool isEqual( int[] left , int[] right ) =>
left.length == right.length &&
left.Zip(right)
.All(x => x.Item1 == x.Item2);
Не уверен что так лучше. Но на мой взгляд почище и проще, чем вариант на циклах.
Еще один плюс, знаете как будет выглядеть функция которая считает это всё дело параллельно? А вот так:
bool isEqual(int[] left, int[] right) =>
left.Length == right.Length &&
left.Zip(right)
.AsParallel()
.All(x => x.Item1 == x.Item2);
Достаточно дописать одну строчку.
С циклами так не работает, к сожалению.
В ленивом языке (например, в хаскелле) это будет эквивалентный код.
Тут дело не в ленивости, а в понимании, что дальнейшее итерирование не поменяет результата. Вы уверены, что компилятор хаскеля достаточно умный, чтобы добавить соответствующую проверку?
Да, уверен. Например, делаем правую свертку на бесконечном списке (то есть идем справа от бесконечности), но компилятор хаскеля как-то догадывается посчитать правильный ответ:
foldr (\x xs -> if x > 10 then [] else x:xs) [] [1..]
[1,2,3,4,5,6,7,8,9,10]
Да, на главной странице https://www.haskell.org/ встроен рабочий интерпретатор
У вас тут есть явно терминирующее значение []. С логическими значениями всё не так очевидно.
Как? 0_о
Если развернуть последовательность foldr то вы получите последовательность:
foldr (:) [] [1..]
1 : foldr (:) [] [2..]
1 : 2 : foldr (:) [] [3..]
...
Когда компилятор дойдет до 10 он увидет, что правое значение никак не используется (условие if x > 10
в лямбде), значит дальше вычислять не надо, и успешно завершит функцию.
Чуть измененный пример:
foldr (\ x y -> if (mod x 10 == 0) then [-1] else x:y) [] [1,2..]
На первой встреченной десятке в выражении для foldr:
foldr f r0 A = A1 `f` (A2 `f` (... An-2 `f` (An-1 `f` (An `f` r0)) ...))
правый операнд f просто заменяется на значение [-1], и на этом вычисление оказываются полностью построенным как задано в описании. Остается только редуцировать его до результирующего значения (с помощью такой же процедуры последовательных простых подстановок).
Мне кажется, это объяснение подходит для того что уже это знает, но они и так знают :)
А кто не знает — ничего не понял.
res = foldr (\ x y -> if (mod x 10 == 0) then [-1] else x:y) [] [1,2..]
выдаёт res = [1,2,3,4,5,6,7,8,9,-1]. А 11-й элемент компилятор «поленился» выдать? Так можно ему помочь: take 11 res
Только вот его там нет.Это ленивость работает. С булевыми значениями она, как будто бы, не поможет — в отличие от списков.
Функциональное программирование — это не то, что нам рассказывают