Список с первым значением из IO - PullRequest
4 голосов
/ 08 марта 2020

Я создал бесконечный список, для создания первого элемента которого требуется некоторое время:

slowOne = do
  threadDelay (10 ^ 6)
  return 1

infiniteInts :: [IO Integer]
infiniteInts = loop slowOne
where
  loop :: IO Integer -> [IO Integer]
  loop ioInt = ioInt : loop (fmap (+1) ioInt)

Когда я распечатываю список, я могу наблюдать задержку, возникающую не только у первого элемента, но и у все элементы:

main =
  mapM_
      (\ioInt -> do
        i <- ioInt
        print i
      )
    infiniteInts

Я пытаюсь улучшить свою интуицию относительно ввода-вывода: почему задержка для каждого элемента, а не только для первого, сгенерированного с slowOne?

Ответы [ 3 ]

2 голосов
/ 10 марта 2020

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

В Haskell значение типа IO a (для любого типа a так что IO Int или IO String или что-либо еще) иногда называют «действием ввода-вывода», но как @WillNess упоминается в комментарии, его лучше всего рассматривать как «рецепт ввода-вывода». Для этих рецептов мы рассматриваем «оценку» и «исполнение» как полностью отдельные операции. Оценка выражение типа IO a похоже на запись рецепта. Результатом вычисления выражения типа IO Int является значение типа IO Int. Получение этого значения не выполняет ввод-вывод и не требует времени для разговора, даже если базовый ввод-вывод включает задержки или другие медленные операции. Это оцененное значение IO a может быть передано, сохранено, продублировано, изменено, объединено с другими IO рецептами или полностью проигнорировано, и все это без каких-либо фактических операций ввода-вывода.

В отличие от этого выполнение результирующего рецепта - это процесс фактического выполнения операций ввода-вывода. Результатом выполнения и IO Int является Int. Если для получения этого Int потребуется 20 минут задержек, доступа к файлу и / или реквизиции почтового голубя, операция займет некоторое время. Если вы выполните один и тот же рецепт дважды, во второй раз он не будет go быстрее.

Почти весь код, который мы пишем в Haskell , оценивает рецепты ввода-вывода без их выполнения.

Когда выполняется код:

slowOne = do
  threadDelay (10 ^ 6)
  return 1

, он фактически просто оценивает (записывает) рецепт ввода-вывода. Оценка этого рецепта, очевидно, включает в себя оценку do-block. Это не делает I / O; он просто оценивает (записывает) каждый из рецептов в блоке do и объединяет их в больший письменный рецепт.

В частности, оценка slowOne включает в себя:

  1. Оценка рецепта threadDelay (10 ^ 6). Для этого нужно вычислить арифметическое c выражение 10 ^ 6 и вызвать для него функцию threadDelay. Эта функция реализована (для не поточной среды выполнения) как:

     threadDelay :: Int -> IO ()
     threadDelay time = IO $ \s -> some_function_of_s
    

    То есть она оборачивает функцию в конструкторе IO для получения значения типа IO (). Критически, это фактически не задерживает поток. Он просто создает (обернутое) функциональное значение. Кстати, в конструкторе IO нет ничего волшебного. Эта threadDelay функция аналогична написанию одинаково немагического:

     justAFunction :: Int -> Maybe (Int -> Int)
     justAFunction c = Just (\x -> c*x)
    
  2. Оценка рецепта return 1. Это также просто создает значение, заключенное в конструктор IO. В частности, это (обернутое и полностью немагическое) функциональное значение, которое выглядит примерно так:

     IO (\s -> (s, 1))
    
  3. Объединение этих двух оцененных рецептов последовательно в более длинный рецепт. Этот новый комбинированный рецепт представляет собой значение типа IO Int, которое будет присвоено slowOne.

Аналогично, когда вычисляется следующий код:

infiniteInts :: [IO Integer]
infiniteInts = loop slowOne
  where
    loop :: IO Integer -> [IO Integer]
    loop ioInt = ioInt : loop (fmap (+1) ioInt)

вы не выполняете IO. Вы просто оцениваете рецепты ввода-вывода и структуры данных, которые содержат рецепты ввода-вывода. В частности, вы оцениваете это выражение как значение типа [IO Integer], состоящее из бесконечного списка IO Integer значений / рецептов. Первый рецепт в списке - slowOne. Второй рецепт в списке:

fmap (+1) slowOne

Это требует слова объяснения. Когда это выражение вычисляется, оно создает новый рецепт, который может быть написан с использованием эквивалентного блока do:

fmap_plus_one_of_slowOne = do
  x <- slowOne
  return (x + 1)

Учитывая, как определено slowOne, это фактически эквивалентно автономному рецепту, который мы получить путем оценки:

fmap_plus_one_of_slowOne = do
  threadDelay (10 ^ 6)
  return 2

Аналогично, третий рецепт в списке:

fmap (+1) (fmap (+1) slowOne)

соответствует эквиваленту рецепта:

fmap_plus_one_of_fmap_plus_one_of_slowOne = do
  threadDelay (10 ^ 6)
  return 3

Теперь, последняя часть вашей программы:

mapM_
    (\ioInt -> do
      i <- ioInt
      print i
    )
  infiniteInts

Вас может удивить, что, когда этот код оценивается, мы все еще только оцениваем , а не выполняем рецепты. Когда эта функция mapM_ оценена, она создает новый рецепт. Рецепт, который он создает, можно описать словами:

"Возьмите каждый рецепт в списке infiniteInts. Извините за неправильный выбор имени - это не список целых чисел, но список рецептов IO для создания целых чисел. Хорошо, что вы компьютер и не будете смущены этим, а? В любом случае, возьмите каждый из этих рецептов в последовательности и передайте их этой функции У меня есть здесь, чтобы сгенерировать новый рецепт. Затем запустите список рецептов по порядку. Вы записываете это, верно? Стоп, пока не выполняйте ! Просто запишите его! "

Итак, подведем итог:

  • slowOne это рецепт

    do threadDelay (10 ^ 6)
       return 1
    
  • fmap (+1) slowOne is так же, как рецепт:

    do threadDelay (10 ^ 6)
       return 2
    
  • аналогично, fmap (+1) (fmap (+1) slowOne) на самом деле просто рецепт

    do threadDelay (10 ^ 6)
       return 3
    

    и так далее

  • поэтому infiniteInts - это список рецептов:

    infiniteInts =
      [ do { threadDelay (10 ^ 6); return 1 }
      , do { threadDelay (10 ^ 6); return 2 }
      , do { threadDelay (10 ^ 6); return 3 }
      , ... ]
    

Учитывая значение рецепта mapM_ ..., если Haskell допускается бесконечно длинная программа мс, мы могли бы написать весь этот рецепт с нуля следующим образом:

do   -- first recipe
     threadDelay (10 ^ 6)
     i <- return 1
     print i

     -- second recipe
     threadDelay (10 ^ 6)
     i <- return 2
     print i

     -- third recipe
     threadDelay (10 ^ 6)
     i <- return 3
     print i

     -- etc.

Это рецепт, который получается в результате вычисления выражения mapM_ ....

Наконец, мы получаем только часть вашей программы , которая выполняет рецепт ввода-вывода, а не просто оценивает его. Эта часть:

main = ...

Когда вы называете рецепт main, вы указываете Haskell выполнить его при запуске программы. Как вы можете видеть из оцененного значения рецепта, который вы присвоили main, это комбинированный рецепт, включающий рецепты с перемежением threadDelay и print, поэтому при выполнении он выводит растущий список целых чисел с задержками до каждое целое число.

Примечание о лени и строгости ... Лень не играет никакой роли в вышеописанном процессе (ну, за исключением того, что мы можем создать бесконечный список без блокировки машины). Когда я говорю «оценить» выше, не имеет значения, является ли оценка строгой и происходит ли она немедленно или технически откладывается до тех пор, пока она не понадобится. Точка, в которой необходимо , может быть , когда он выполняется, но оценка (запись рецепта) и выполнение (следование рецепту) по-прежнему являются разными процессами, даже если они происходят один за другим.

0 голосов
/ 09 марта 2020

Каждый номер задерживается, потому что все они приходят с slowOne. Посмотрите на ваше loop:

loop ioInt = ioInt : loop (fmap (+1) ioInt)
     ^----This is slowOne              ^
                                       └----This is also slowOne

Моя собственная интуиция о том, что делает ваша fmap, просто воздействует на значение IO (Integer в IO Integer), но сохраняет все его контекста (часть "IO") нетронутым.

Как прокомментировал Уилл Несс:

fmap (1+) принимает значение IO (чистое значение типа IO t для некоторого t ) который описывает действие ввода / вывода, которое будет возвращать чистое значение x :: t, когда это действие будет выполнено; и создает новое значение чистого ввода-вывода, описывающее расширенное действие ввода-вывода, которое будет возвращать чистое значение x+1 после выполнения действий ввода-вывода, как описано в первом значении ввода-вывода.

В качестве примера из этого мы могли бы заменить slowOne другой функцией timedOne:

timedOne = do
  time <- getPOSIXTime
  putStrLn $ "time: " ++ show time
  return 1

Вызов loop с timedOne вместо slowOne распечатал бы это, показывая, как fmap влияет на значение, не влияющее на контекст:

time: 1583715559.051068s
1
time: 1583715559.051705s
2
time: 1583715559.052311s
3
... and so on

Вы видите, что каждый номер, который используется, все еще несет свой собственный «багаж» IO, только в этом случае этот багаж «получает время от системных часов и печатает его» вне". Если вы хотите изменить это поведение так, чтобы задерживалось только первое число, вам необходимо очистить хвост списка, созданного с помощью loop любого багажа ввода-вывода с задержкой потока. Один из способов сделать это - использовать чистый список и обернуть каждый элемент в IO:

loop ioInt = ioInt : (return <$> [2..])
0 голосов
/ 08 марта 2020

Предупреждение:

Этот ответ неверен в отношении выполнения выражений. Сначала обратитесь к ответу К. А. Бура , чтобы лучше понять, как работает этот список значений IO.


Мы можем понять это поведение по

Вот переписывание infiniteInts для получения различное количество элементов:

Получение одного элемента

slowOne : loop (fmap (+1) slowOne)

Поскольку оператор Haskell : не является строгим, мы запускаем slowOne один раз → это занимает одну секунду.

Получение двух элементов

slowOne : (fmap (+1) slowOne) : loop (fmap (+1) (fmap (+1) slowOne))

Взятие двух элементов (вплоть до второго :) приводит к двойному вызову slowOne → это занимает две секунды.

Получение трех элементов

slowOne : (fmap (+1) slowOne) : (fmap (+1) (fmap (+1) slowOne)) : loop (fmap (+1) (fmap (+1) (fmap (+1) slowOne)))

Взятие трех элементов (вплоть до третьего :) приводит к вызову slowOne три раза → это занимает три секунды.

Резюме

Из переписывания мы видим, что slowOne вызывается для каждого элемента (например, три раза для трех элементов) и учитывая, что GH C не будет кэшировать результаты, созданные внутри IO (что имеет место для slowOne), следует, что каждому элементу требуется секунда для создания.

...