Как быстро прочитать нотацию do без перевода на >> = сочинения? - PullRequest
0 голосов
/ 07 февраля 2020

Этот вопрос относится к этому сообщению: Понимание обозначений для простой монады Reader: a <- (* 2), b <- (+10), возврат (a + b) </a>

Мне все равно, трудно ли понять язык, обещает ли он решить некоторые проблемы, которые нам дают простые для понимания языки. Мне пообещали, что невозможность изменить состояние в Haskell (и других функциональных языках) может изменить игру, и я верю в это. У меня было слишком много ошибок в моем коде, связанных с состоянием, и я полностью согласен с в этом посте , что рассуждения о взаимодействии объектов в OOP языках практически невозможны, потому что они могут изменить состояния, и, таким образом, чтобы рассуждать о коде, мы должны рассмотреть все возможные перестановки этих состояний.

Однако я обнаружил, что рассуждать о Haskell монадах также очень сложно. Как вы можете видеть в ответах на вопрос, который я связал, нам нужна большая диаграмма, чтобы понять 3 строки записи do. Я всегда заканчиваю тем, что открываю stackedit.io, чтобы вручную десагировать нотацию do и шаг за шагом писать >>= приложения нотации do, чтобы понять код.

Проблема более или менее такова: в большинстве случаев, когда у нас есть S a >>= f, нам нужно развернуть a из S и применить к нему f. Тем не менее, f на самом деле является другой вещью, более или менее в форме S a >>= g, которую мы также должны развернуть и так далее. Человеческий мозг не работает таким образом, мы не можем легко применить эти вещи в голове и остановиться, держать их в стеке мозга и продолжать применять оставшуюся часть >>=, пока мы не достигнем конец. Когда конец достигнут, мы собираем все эти вещи в стек мозга и склеиваем их вместе.

Поэтому я, должно быть, что-то делаю не так. Должен быть простой способ понять «1025 * композицию» в мозге. Я знаю, что делать нотацию очень просто, но я могу только думать об этом как о способе легко писать >>= композиций. Когда я вижу обозначение do, я просто перевожу его на связку >>=. Я не рассматриваю это как отдельный способ понимания кода. Если есть способ, я бы хотел, чтобы кто-нибудь сказал мне.

Итак, вопрос в том, как читать обозначения do?

Ответы [ 4 ]

5 голосов
/ 07 февраля 2020

Часть 1: нет необходимости go в сорняки

На самом деле, за монадами существует очень простая, простая для понимания интуиция: они кодируют порядок вещи происходят Мол, сначала сделай это, потом сделай другое, потом сделай третье. Например:

executeMadDoctrine = do
    wait oneYear
    s <- evaluatePoliticalSituation
    case s of
        Stable -> do
            printInNewspapers "We're going to live another day"
            executeMadDoctrine -- recursive call
        Unstable -> do
            printInNewspapers "Run for your lives"
            launchMissiles
            return ()

Или чуть более реалистичный c (а также компилируемый и исполняемый) пример:

main = do
    putStrLn "What's your name?"
    name <- getLine
    if name == "EXIT" then
        return ()
    else do
        putStrLn $ "Hi, " <> name
        main

Простой. Так же, как Python. Человеческий мозг действительно работает точно , как это.

Видите ли, вам не нужно знать, как все это работает внутри, если вы не начнете делать более сложные вещи. В конце концов, вы, вероятно, не думаете о порядке запуска цилиндров каждый раз, когда заводите машину? Вы просто нажали на газ, и он уходит. То же самое с do.

Часть 2: вы выбрали плохой пример

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

Проблема здесь в математике. Кровавая вещь оказывается неоправданно эффективной время от времени, особенно когда никто не просит об этом.

Подумайте об этом: сначала у нас были очень хорошие натуральные числа, которые мы могли очень хорошо понять. У меня два глаза, а у вас один меч, мне лучше бежать. Но потом оказалось, что нам нужен ноль. Зачем, черт возьми, это нужно? Это кощунство! Вы не можете записать то, что не так! Но, оказывается, ты должен иметь это. Это однозначно следует из других вещей, которые мы знаем, правда. И тогда мы получили иррациональные числа. Что это за хрень? Как я вообще это понимаю? Я не могу иметь π апельсинов в конце концов, я могу? Но они тоже должны существовать. Это просто следует. Обойти это невозможно. А потом комплексные числа, трансцендентные, гиперкомплексные, не поддающиеся разрушению ... Мой мозг кипит в этой точке.

Это похоже на монады: есть этот специфический математический объект, и в какой-то момент кто-то заметил, что это очень хорошо для выражения порядка вычислений, поэтому мы присвоили монады для этого. Но тогда оказывается, что все виды вещей могут быть похожи на монады, говоря математически. Обойти это невозможно, это просто так.

Итак, у нас есть все эти забавные случаи. И нотация do все еще работает для них, потому что они монады (математически говоря), но это больше не о порядке. Мол, ты знал, что списки тоже были монадами? Но, как и в случае с функциями, интерпретация списков - это не «порядок», это вложенные циклы. И если вы комбинируете списки с чем-то другим, вы получаете недетерминизм. Забавные вещи.

Но, как и с разными видами чисел, вы можете учиться. Вы можете создать интуицию со временем. Вы абсолютно обязаны? Смотрите часть 1.

5 голосов
/ 07 февраля 2020

Учитывая простой код, такой как

foo :: Monad m => m Int -> m Int -> m Int
foo x y = do
    a <- y  -- I'm intentionally doing y first; see the Either example
    b <- x
    return (a + b)

, вы не можете много говорить о <-, за исключением того, что он "получает" значение Int из x или y. Что означает «получить», очень сильно зависит от того, что есть m.


Некоторые примеры:

m ~ Возможно

foo (Just 3) (Just 5) оценивается как Just 8 ; замените любой аргумент Nothing, и вы получите Nothing. <- пытается получить значение из значения Maybe Int, но прерывает оставшуюся часть блока в случае сбоя.

m ~ Либо

Почти так же, как Maybe , но заменив Nothing на первое значение Left, с которым он столкнулся. foo (Right 3) (Right 5) возвращает Right 8. foo x (Left "foo") возвращает Left "foo", является ли x значением Right или Left.

m ~ []

Теперь вместо получения an Int, <- получает каждые Int из числа указанных вариантов. Это делает это недетерминированным; Вы можете себе представить, что функция «разветвляется» на несколько параллельных копий, каждая из которых выбрала отдельное значение из своего списка. В конце концов, окончательный результат представляет собой список всех результатов, которые были вычислены.

foo [1,2] [3,4] возвращает [4, 5, 5, 6] ([3 + 1, 3 + 2, 4 + 1, 4 + 2]).

m ~ IO

Это хитро, потому что, в отличие от предыдущих мад, на которые мы смотрели, не обязательно получить значение. foo readLn readLn вернет любую сумму двух чисел, считанных из стандартного ввода, с возможностью ошибки времени выполнения, если прочитанные строки не будут разбираться как значения Int.

Вы можете подумать работает как монада Maybe, но с исключениями времени выполнения, заменяющими Nothing.

4 голосов
/ 07 февраля 2020

Любые длинные do цепочки могут быть перегруппированы в эквивалентный двоичный файл do по закону ассоциативности монад, сгруппировав все справа, как

do { A ;           B ; C ; ...              } 
=== 
do { A ; r <- do { B ; C ; ... } ; return r }. 

Так что нам нужно только понять эту двоичную do форму, чтобы понять все остальное. И это выражается в виде единой комбинации >>=.

Затем трактуйте интерпретацию кода do (для конкретной монады) аксиоматически вместо этого как набор правил перезаписи. Убедитесь в правильности этих правил для конкретной монады, просто один раз (да, используя, возможно, обширные переписывания на основе >>=, один раз ).

Таким образом, для монады Reader из связанной записи ,

(do { S f }) x                       ===   f x 

(do { a <- S f ; return (h a) }) x   ===   let {a = f x} in h a 
                                     ===   h (f x) 

(do { a <- S f ;                     ===   let {a = f x ; 
      b <- S g ;                                b = g x} in h a b 
      return (h a b) }) x            ===   h (f x) (g x) 

и любой более длинной цепочки let s можно выразить как вложенные двоичные let s, что эквивалентно.

Последний действительно liftM2, поэтому можно привести аргумент, что понимание конкретной монады означает понимание ее конкретной liftM2 (*) , действительно.

И эти S s, в итоге мы просто игнорируем их как шум, навязанный нам синтаксисом Haskell (ну, этот вопрос вообще не использовал их, но мог).


(*) , точнее, liftBind, (do { a <- S f ; b <- k a ; return (h a b) }) x === let {a = f x ; b = g x } in h a b, где (S g) x === k a x. (в частности, это , после слов "длинная версия")


Итак, ваше отношение "Когда я вижу do нотация. Я просто перевожу это в кучу >>=. Я не рассматриваю это как отдельный способ понимания кода " может на самом деле быть проблемой.

do запись - твой друг . Лично я сначала ненавидел это, затем научился любить это, и теперь я вижу переписывания на основе >>= как его (низкоуровневую) реализацию, все больше и больше.

И, что еще более абстрактно, do можно эквивалентно записать как Монад Постижения, выглядящие так же, как список Понимания!

1 голос
/ 07 февраля 2020

@ chepner уже включил в свой ответ довольно много того, что я бы сказал, но я хотел бы sh подчеркнуть другой аспект, который, как мне кажется, весьма уместен в этом вопросе: тот факт, что запись do является для большинства разработчиков гораздо более простой и понятный способ работы с любым выражением monadi c, которое хотя бы в меру сложное.

Причина этого в том, что почти чудесным образом do блоки в конечном итоге очень напоминают код, написанный на императивном языке. Код императивного стиля гораздо легче понять для большинства разработчиков, и не только потому, что он является самой распространенной парадигмой: он дает явный «рецепт» того, что делает кусок кода, тогда как более типичные выражения Haskell, особенно монади c, включающие вложенные лямбды и >>= повсюду, очень легко понять трудно.

Говоря об этом, я, конечно, не имею в виду, что нужно кодировать на императивном языке, а не Haskell , Преимущества чисто функционального стиля хорошо документированы и, по-видимому, хорошо понятны ОП, поэтому я не буду вдаваться в них здесь. Но нотация Haskell do позволяет писать код в императивно-выглядящем «стиле», который, следовательно, является явным и его легче понять - по крайней мере, в небольшом масштабе - не жертвуя ни одним из преимуществ использования чистого функциональный язык.

Этот "императивный стиль" нотации do, я чувствую, более заметен для некоторых монад, чем для других, и я sh проиллюстрирую свою точку зрения на примерах из нескольких монад, которые Я нахожу костюм "императивным стилем" хорошо. Во-первых, IO, где я могу привести такой простой пример:

greet :: IO ()
greet = do
    putStrLn "Hello, what is your name?"
    name <- readLine
    putStrLn $ "Pleased to meet you, " ++ name ++ "!"

Надеюсь, сразу становится очевидно, что делает этот код при выполнении в среде выполнения Haskell. sh Подчеркну, насколько он похож на императивный код, например, этот перевод Python (который не самый идиоматический c, но был выбран так, чтобы точно соответствовать строке кода Haskell для строка).

def greet():
       print("Hello, what is your name?")
       name = input()
       print("Pleased to meet you, " + name + "!")

Теперь спросите себя, насколько легко будет понять код в его отлаженном виде, без do?

greet = putStrLn "Hello, what is your name?" >> readLine >>= \name -> putStrLn $ "Pleased to meet you, " ++ name ++ "!"

Это не особенно сложно, предоставлено - но я надеюсь, что вы согласны с тем, что он гораздо более "шумный", чем блок do выше. Я не могу говорить за других, но я очень сомневаюсь, что я один, говоря, что последняя версия может занять у меня 10-20 секунд или около того, чтобы полностью понять, тогда как блок do мгновенно понятен. И это, конечно, чрезвычайно простое действие - что-либо более сложное, что встречается во многих реальных приложениях, значительно увеличивает разницу в понятности.

Я, конечно, выбрал IO по причине - думаю, именно при работе с IO, в частности, наиболее естественно думать в терминах «выполнить это действие, а затем, если в результате получится следующее действие, в противном случае ...». Хотя семантика монады IO идеально подходит для этого, гораздо проще преобразовать код во что-то подобное, когда он написан в квазиимперативной записи, чем использовать >>= напрямую. И запись do проще записать тоже.

Но хотя IO является ярким примером этого, он, конечно, не единственный. Другой замечательный пример - монада State. Вот простой пример его использования для нахождения суммы в списке целых чисел (и я знаю, что на самом деле вы бы этого не делали, но это всего лишь очень простой пример не совсем тривиального кода, который использовал эту монаду) :

sumList :: State [Int] Int
sumList = go 0
    where go subtotal = do
                    remaining <- get
                    case remaining of
                         [] -> return subtotal
                         (x:xs) -> do
                              put xs
                              go $ subtotal + X

Здесь, на мой взгляд, шаги очень понятны - вспомогательная функция go последовательно добавляет первый элемент списка к промежуточной сумме, одновременно обновляя внутреннее состояние хвостом список. Если списка больше нет, возвращается промежуточный итог. (Учитывая вышеизложенное, функция evalState sumList будет принимать фактический список и суммировать его.)

Вероятно, можно придумать более удачные примеры (особенно те, в которых расчеты не так просты для выполнения другими способами), но я надеюсь, что все еще ясно: переписав вышеприведенное с помощью >>=, и лямбда сделает его намного менее понятным.

do нотация, по моему мнению, почему часто цитируемая шутка о том, что Haskell является "самым лучшим императивным языком в мире", имеет больше, чем зерно правды. Используя и определяя разные монады, можно написать легко понятный «императивный» код в самых разных ситуациях - при этом сохраняя гарантии того, что различные функции не могут, например, изменить глобальное состояние. Во многом это лучшее из обоих миров.

...