Понимание побочных эффектов с монадическим обходом - PullRequest
2 голосов
/ 19 июня 2019

Я пытаюсь правильно понять, как работают побочные эффекты при обходе списка в F # с использованием монадического стиля, следуя указаниям Скотта здесь

У меня есть AsyncSeq элементов и побочная функция, которая может вернуть результат <'a,' b> (это сохранение элементов на диск).

Я понял общую идею - разделить голову и хвост, применить функцию к голове. Если он вернётся Ok, то вернитесь через хвост, делая то же самое. Если в любой момент возвращается ошибка, замкните и верните ее.

Я также понимаю, почему окончательное решение Скотта использует foldBack, а не fold - он сохраняет список вывода в том же порядке, что и ввод, поскольку каждый обработанный элемент предшествует предыдущему.

Я также могу следовать логике:

  • Результат от последнего элемента списка (обработанный первым, когда мы используем фолбэк) будет передан в качестве аккумулятора следующему элементу.

  • Если это ошибка, а следующий элемент в порядке, следующий элемент отбрасывается.

  • Если следующий элемент является ошибкой, он заменяет все предыдущие результаты и становится аккумулятором.

  • Это означает, что к тому времени, как вы повторили весь список справа налево и оказались в начале, у вас либо ОК всех результатов в правильном порядке, либо самая последняя Ошибка (которая было бы первым, если бы мы пошли слева направо).

Меня смущает то, что, разумеется, поскольку мы начинаем с конца списка, все побочные эффекты обработки каждого элемента будут иметь место, даже если мы вернем только последнюю созданную ошибку?

Это, кажется, подтверждается здесь , поскольку вывод на печать начинается с [5], затем [4,5], затем [3,4,5] и т. Д.

Меня смущает то, что это не , что я вижу, когда я использую AsyncSeq.traverseChoiceAsync из библиотеки FSharpx (которую я обернул для обработки Result вместо Choice ). Я вижу, что побочные эффекты происходят слева направо, останавливаясь на первой ошибке, и именно это я и хочу.

Похоже, что рекурсивная версия Скотта без хвоста (которая не использует foldBack и просто рекурсивно переходит по списку) идет слева направо? То же самое касается версии AsyncSeq. Это объяснило бы, почему я вижу это короткое замыкание при первой ошибке, но, конечно, если оно завершится нормально, тогда выходные элементы будут изменены, и именно поэтому мы обычно используем foldback?

Я чувствую, что неправильно понимаю или неправильно понимаю что-то очевидное! Может ли кто-нибудь объяснить это мне? :)

Edit: rmunn дал действительно большое всестороннее объяснение обхода AsyncSeq ниже. TLDR было то, что

  • Первоначальная реализация Скотта и ход AsyncSeq, оба do идут слева направо, как я думал, и поэтому обрабатывают только до тех пор, пока не обнаружат ошибку

  • они сохраняют свое содержимое в порядке, добавляя голову к обработанному хвосту, а не добавляя каждый обработанный результат к предыдущему (что и делает встроенный F # сгиб).

  • Свертывание будет держать все в порядке, но на самом деле будет выполнять каждый случай (который может длиться вечно с асинхронной последовательностью)

Ответы [ 2 ]

2 голосов
/ 19 июня 2019

См. Отличный ответ @ rmunn выше для объяснения.Я просто хотел опубликовать небольшой помощник для тех, кто читает это в будущем, он позволяет вам использовать траверсу AsyncSeq с Результатами вместо старого типа выбора, который был написан:

let traverseResultAsyncM (mapping : 'a -> Async<Result<'b,'c>>) source = 
    let mapping' = 
        mapping
        >> Async.map (function
            | Ok x -> Choice1Of2 x
            | Error e -> Choice2Of2 e)

    AsyncSeq.traverseChoiceAsync mapping' source
    |> Async.map (function
        | Choice1Of2 x -> Ok x
        | Choice2Of2 e -> Error e)

Также здесь естьверсия для не асинхронных отображений:

let traverseResultM (mapping : 'a -> Result<'b,'c>) source = 
    let mapping' x = async { 
        return 
            mapping x
            |> function
            | Ok x -> Choice1Of2 x
            | Error e -> Choice2Of2 e
    }

    AsyncSeq.traverseChoiceAsync mapping' source
    |> Async.map (function
        | Choice1Of2 x -> Ok x
        | Choice2Of2 e -> Error e)
2 голосов
/ 19 июня 2019

Все довольно просто: traverseChoiceAsync не использует foldBack. Да, с foldBack последний элемент будет обработан первым, так что к тому времени, как вы доберетесь до первого элемента и обнаружите, что его результат равен Error, вы бы вызвали побочные эффекты каждого элемента. Именно поэтому, я думаю, именно поэтому тот, кто написал traverseChoiceAsync в FSharpx, предпочел не использовать foldBack, потому что он хотел убедиться, что побочные эффекты будут срабатывать по порядку и останавливаться на первых Error (или в случай Choice версии функции, первый Choice2Of2 - но с этого момента я буду делать вид, что эта функция была написана для использования типа Result.)

Давайте посмотрим на функцию traverseChoieAsync в коде, на который вы ссылаетесь, и прочитайте его пошагово. Я также перепишу его, чтобы использовать Result вместо Choice, потому что эти два типа в основном идентичны по функциям, но с разными именами в DU, и будет немного легче сказать, что происходит, если DU дела называются Ok и Error вместо Choice1Of2 и Choice2Of2. Вот оригинальный код:

let rec traverseChoiceAsync (f:'a -> Async<Choice<'b, 'e>>) (s:AsyncSeq<'a>) : Async<Choice<AsyncSeq<'b>, 'e>> = async {
  let! s = s
  match s with
  | Nil -> return Choice1Of2 (Nil |> async.Return)
  | Cons(a,tl) ->
    let! b = f a
    match b with
    | Choice1Of2 b -> 
      return! traverseChoiceAsync f tl |> Async.map (Choice.mapl (fun tl -> Cons(b, tl) |> async.Return))
    | Choice2Of2 e -> 
      return Choice2Of2 e }

А вот оригинальный код, переписанный для использования Result. Обратите внимание, что это простое переименование, и никакая логика не нуждается в изменении:

let rec traverseResultAsync (f:'a -> Async<Result<'b, 'e>>) (s:AsyncSeq<'a>) : Async<Result<AsyncSeq<'b>, 'e>> = async {
  let! s = s
  match s with
  | Nil -> return Ok (Nil |> async.Return)
  | Cons(a,tl) ->
    let! b = f a
    match b with
    | Ok b -> 
      return! traverseChoiceAsync f tl |> Async.map (Result.map (fun tl -> Cons(b, tl) |> async.Return))
    | Error e -> 
      return Error e }

Теперь давайте пройдем через это. Вся функция заключена в блок async { }, поэтому let! внутри этой функции означает «развернуть» в асинхронном контексте (по сути, «ожидать»).

let! s = s

Он принимает параметр s (типа AsyncSeq<'a>) и разворачивает его, связывая результат с локальным именем s, которое отныне будет затенять исходный параметр. Когда вы ожидаете результата AsyncSeq, вы получаете только первый элемент, в то время как остальная часть все еще находится в асинхронном режиме, который требует дальнейшего ожидания. Вы можете увидеть это, посмотрев на результат выражения match или взглянув на определение типа AsyncSeq:

type AsyncSeq<'T> = Async<AsyncSeqInner<'T>>

and AsyncSeqInner<'T> =
    | Nil
    | Cons of 'T * AsyncSeq<'T>

Поэтому, когда вы делаете let! x = s, когда s имеет тип AsyncSeq<'T>, значение x будет либо Nil (когда последовательность завершится), либо будет Cons(head, tail) где head имеет тип 'T и tail имеет тип AsyncSeq<'T>.

Итак, после этой строки let! s = s наше локальное имя s теперь относится к типу AsyncSeqInner, который содержит элемент заголовка последовательности (или Nil, если последовательность была пустой). ), а остальная часть последовательности по-прежнему заключена в в AsyncSeq, поэтому ее еще предстоит оценить (и, что особенно важно, ее побочные эффекты еще не произошли).

match s with
| Nil -> return Ok (Nil |> async.Return)

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

Теперь распаковать. Внешний return находится в ключевом слове async, поэтому он принимает Result (значение которого Ok something) и превращает его в Async<Result<something>>. Помня, что возвращаемый тип функции объявлен как Async<Result<AsyncSeq>>, внутренний something явно является AsyncSeq типом. Так что же происходит с этим Nil |> async.Return? Ну, async - это не ключевое слово F #, это имя экземпляра AsyncBuilder. Внутри вычислительного выражения foo { ... }, return x переводится в foo.Return(x). Таким образом, вызов async.Return x - это то же самое, что и запись async { return x }, за исключением того, что он избегает вложения вычислительного выражения в другое вычислительное выражение, что было бы немного неприятно пытаться проанализировать мысленно (и я не уверен на 100%, что синтаксически это позволяет компилятор F #). Таким образом, Nil |> async.Return - это async.Return Nil, что означает, что он создает значение Async<x>, где x - это тип значения Nil. И, как мы только что видели, это Nil является значением типа AsyncSeqInner, поэтому Nil |> async.Return выдает Async<AsyncSeqInner>. И еще одно имя для Async<AsyncSeqInner> - AsyncSeq. Таким образом, все это выражение выдает Async<Result<AsyncSeq>>, которое имеет значение «Мы закончили, в последовательности больше нет элементов и не было ошибки».

Уф. Теперь для следующей строки:

  | Cons(a,tl) ->

Простой: если следующий элемент в AsyncSeq с именем s был Cons, мы деконструируем его так, что фактический элемент теперь называется a, а хвост (другой *)1112 *) называется tl.

    let! b = f a

Это вызывает f для значения, которое мы только что получили из s, а затем разворачивает Async часть возврата fзначение, так что b теперь является Result<'b, 'e>.

    match b with
    | Ok b -> 

Более теневыми именами.Внутри этой ветви match, b теперь присваивается имя типа 'b, а не Result<'b, 'e>.

      return! traverseResultAsync f tl |> Async.map (Result.map (fun tl -> Cons(b, tl) |> async.Return))

Ху-мальчик.Это слишком много, чтобы заняться сразу.Давайте напишем это так, как будто операторы |> были выстроены в отдельные строки, а затем мы будем проходить каждый шаг по одному за раз.(Обратите внимание, что я обернул вокруг него лишнюю пару скобок, просто чтобы уточнить, что это конечный результат всего этого выражения, который будет передан в ключевое слово return!).

      return! (
          traverseResultAsync f tl
          |> Async.map (
              Result.map (
                  fun tl -> Cons(b, tl) |> async.Return)))

Я собираюсь разобраться с этим выражением изнутри.Внутренняя линия:

fun tl -> Cons(b, tl) |> async.Return

async.Return вещь, которую мы уже видели.Это функция, которая берет хвост (в настоящее время мы не знаем или не заботимся о том, что находится внутри этого хвоста, за исключением того, что из-за необходимости сигнатуры типа Cons это должен быть AsyncSeq) и превращает его вAsyncSeq то есть b с последующим хвостом.То есть это похоже на b :: tl в списке: оно b вставляется в front из AsyncSeq.

В одном шаге от этого внутреннего выражения:

Result.map

Помните, что о функции map можно думать двумя способами: один - «взять функцию и запустить ее против того, что находится« внутри »этой оболочки».Другой - «возьмите функцию, которая работает на 'T, и превратите ее в функцию, которая работает на Wrapper<'T>».(Если у вас еще нет ясности обо всех этих вещах, https://sidburn.github.io/blog/2016/03/27/understanding-map - довольно хорошая статья, чтобы помочь воплотить эту концепцию).Итак, для этого нужно взять функцию типа AsyncSeq -> AsyncSeq и превратить ее в функцию типа Result<AsyncSeq> -> Result<AsyncSeq>.С другой стороны, вы могли бы думать о том, что вы берете Result<tail> и вызываете fun tail -> ... против этого результата tail, а затем повторно упаковываете результат этой функции в новый Result. Важно: Поскольку для этого используется Result.map (Choice.mapl в оригинале), мы знаем, что если tail является значением Error (или если Choice было Choice2Of2 воригинал), функция не будет вызываться .Таким образом, если traverseResultAsync выдаст результат, который начинается со значения Error, он выдаст <Async<Result<foo>>>, где значение Result<foo> равно Error, и поэтому значение хвоста будет отброшено.Запомните это позже.

Хорошо, следующий шаг.

Async.map

Здесь у нас есть функция Result<AsyncSeq> -> Result<AsyncSeq>, созданная внутренним выражением, и она преобразует ее в Async<Result<AsyncSeq>> -> Async<Result<AsyncSeq>> функция.Мы только что говорили об этом, поэтому нам не нужно рассказывать, как работает map снова.Просто помните, что эффект этой функции Async<Result<AsyncSeq>> -> Async<Result<AsyncSeq>>, который мы создали, будет следующим:

  1. Ожидание внешнего async.
  2. Если результат равен Error, верните это значение Error.
  3. Если результат равен Ok tail, выведите Ok (Cons (b, tail)).

Следующая строка:

traverseResultAsync f tl

Мне, вероятно, следовало бы начать с этого, потому что это на самом деле будет запускать сначала , а затем его значение будет передано в функцию Async<Result<AsyncSeq>> -> Async<Result<AsyncSeq>>, которую мы только что проанализировали.

Итак, все, что нам нужно сделать, это сказать: «Хорошо, мы взяли первую часть AsyncSeq, которую нам передали, и передали ее f, а f дал результат Ok со значениеммы вызываем b. Так что теперь нам нужно обработать rest последовательности аналогичным образом, а затем, , если остальная часть последовательности даст результат Ok, мымы прикрепим b к передней части и вернем последовательность Ok с содержанием b :: tail. НО, если остальная часть последовательности выдаст Error, мыl выбросить значение b и просто вернуть Error без изменений. "

return!

Это просто берет только что полученный нами результат (либо Error, либо Ok (b :: tail), уже заключенный в Async) и возвращает его без изменений.Но обратите внимание, что вызов traverseResultAsync является NOT хвост-рекурсивным, потому что его значение нужно было сначала передать в выражение Async.map (...).

И теперь у нас еще есть еще один битtraverseResultAsync на что посмотреть.Помните, когда я сказал: «Имейте это в виду на потом»?Что ж, время пришло.

    | Error e -> 
      return Error e }

Вот мы и вернулись в выражении match b with.Если b был результатом Error, то дальнейшие рекурсивные вызовы не выполняются, и весь traverseResultAsync возвращает Async<Result>, где значение Result равно Error.И если мы в настоящее время вложены глубоко внутрь рекурсии (то есть мы находимся в выражении return! traverseResultAsync ...), тогда возвращаемое значение будет Error, что означает результат «внешнего» вызова, как мы сохранилив виду, также будет Error, отбрасывая любые другие Ok результаты, которые могли бы произойти "до".

Заключение

И поэтому эффект всего этого таков:

  1. Пройдите через AsyncSeq, вызывая f для каждого элемента по очереди.
  2. first time f возвращает Error, прекратите проходить через, отбросить все предыдущие Ok результаты и вернуть Error как результат всего этого.
  3. Если f никогда не возвращает Error и вместо этого возвращает Ok b каждый раз, возвращаетOk результат, содержащий AsyncSeq всех этих b значений, в их первоначальном порядке.

Почему они в своем первоначальном порядке?Поскольку логика в случае Ok такова:

  1. Если последовательность была пустой, вернуть пустую последовательность.
  2. Разделить на голову и хвост.
  3. Получить значениеb из f head.
  4. Обработка хвоста.
  5. Стик-значение b в перед результата обработки хвоста.

Итак, если мы начали с (концептуально) [a1; a2; a3], который на самом деле выглядит как Cons (a1, Cons (a2, Cons (a3, Nil))), мы получим Cons (b1, Cons (b2, Cons (b3, Nil))), что означает концептуальную последовательность [b1; b2; b3].

...