Ленивый hGetContents. Баг или фича? (Haskell)

    Меня давно беспокоит одна тема. Вот решил высказаться и услышать, что думают люди по этому поводу. Речь пойдет о функции hGetContents. Если вы когда-нибудь работали с файлами, то вы знаете, что эта функция возвращает содержимое файла (потока). Вот типичный пример использования этой функции.
    import System.IO
    
    main = do 
    	file <- openFile "1.txt" ReadMode
    	content <- hGetContents file
    	print content
    	hClose file
    -- результат: выводит содержимое файла на экран
    


    Все очень банально — открываем файл, считываем содержимое, выводим на экран, закрываем файл. Фича фунции hGetContents (а точнее фича Haskell-я) в том, что она ленивая. А именно, данные из файла считаются не сразу целиком, а будут считываться по мере необходимости. Например, вот такая программа
    import System.IO
    
    main = do 
    	file <- openFile "1.txt" ReadMode
    	content <- hGetContents file
    	print $ take 3 content  
    	hClose file
    -- результат: выводит первые 3 символа из файла на экран
    

    считает из файла только первые 3 символа.
    Что тут можно сказать, отличная фича! Это очень удобно, оперировать переменной content (содержимое файла), но при этом знать, что Haskell возмет из него только то, что нужно, и ничего лишнего.
    А теперь рассмотрим следующий пример
    import System.IO
    
    main = do 
    	file <- openFile "1.txt" ReadMode
    	content <- hGetContents file
    	hClose file
    	print content
    -- результат: выводит пустую строку
    

    Здесь мы просто переставил местами две последние строчки. Результатом будет выведенная на экран пустая строка. Почему так получилось? Причина в том, что мы пытаемся обратиться к содержимому файла после того, как файл уже закрыт. При вызове hGetContents данные никуда не считывались, просто в переменной content сохранилась некоторая ссылка на этот файл. А потом, когда нам понадобилось содержимое content, оказалось, что файл уже закрыт.
    Ну и что такого, подумаете Вы, просто нужно использовать переменную content до закрытия файла.

    Проблема тут еще в том, что мы никак не можем «заставить» Haskell вычислить переменную content до закрытия файла (если кто-то знает способ, напишите). Например, нам нужно написать функцию, которая открывает файл, читает содержимое, парсит некоторую структуру, закрывает файл, выдает полученную структуру как результат.
    {-# OPTIONS_GHC -XScopedTypeVariables #-}
    import System.IO
    
    parseFile :: (Read a) => String -> IO a
    parseFile fileName = do
    	file <- openFile fileName ReadMode
    	content <- hGetContents file
    	rezult <- return $ read content
    	hClose file
    	return rezult
    
    main = do
    	a :: Int <- parseFile "1.txt"
    	print a
    --результат: ошибка
    
    Вроде бы элементарный пример, но он НЕ РАБОТАЕТ. По той же причине. То, что мы передали содержимое файла в некоторую функцию read, ничего не изменило, потому что она тоже отложилась «на потом». И что бы мы не сделали с content, это не заставит Haskell считать содержимое из файла (если мы, конечно, не выведем его на экран).
    Можно, конечно, всю программу поместить между openFile и hClose, а что делать, если нужно считать содержимое из файла, подкорректировать и записать в тот же файл?

    Я долго думал над этим примером и чувство «что-то здесь не так» не оставляло меня в покое. Разве это не нарушает чистоту Haskell-я. Я же написал корректную по сути программу, а она выдает не правильный результат. Я что должен думать о том, в какой момент он обратится к этой переменной и что он там вообще делает под капотом? Это уже не Haskell, это, извините за выражение, C++ какой-то.
    В итоге я пришел к выводу, что эта «фича» — не фича, а баг. Если я вас не убедил, то вот несколько доводов:
    • 1. В лямбда-исчислении существуют различные стратегии редукции лямбда терма: полная бета-редукция, нормальный порядок вычислений, вызов по имени (в haskell используется оптимизированный вариант вызов по необходимости), вызов по значению(энергичные вычисления). Существует теорема, которая гласит, что любой лямбда-терм независимо от стратегии вычислений редуцируется к одной и той же нормальной форме, если она вообще существует. Т.е. при любом порядке вычислений (ленивом или энергичном) должен получаться один и тот же результат. Правда, существуют примеры лямбда-термов, вычисление которых зацикливается при энергичной стратегии, но не зацикливается при ленивой (например работа с бесконечными списками). Но если оба вычисления завершаются, то они должны давать одинаковый результат!
      (Кстати, обратного примера, когда ленивый вариант зациклился, а энергичный — нет, не существует. В этом смысле ленивая стратегия самая «аккуратная»)
      Если мы рассмотрим наш последний пример и представим, что Haskell вычисляет его энергично, то получится, что наша программа должна работать корректно и выдавать содержимое файла. Т.е. в энергичном режиме один результат, а в ленивом — другой. Вы можете возразить, что программа — это не лямбда-терм и не чистая функция. В каком-то смысле это верно. Но давайте вспомним, для чего были придуманы монады. Разве не для того, чтобы представить программу, как некоторую чистую функцию, которая берет на вход состояние внешнего мира, и выдает на выход новое состояние?
    • 2. Все-таки почему так получается? В Haskell все функции чистые. Результат вычисления зависит только от переданных аргументов. Это значит, что мы можем отложить вычисление «на потом», и результат от этого не изменится. В случае, когда мы вызываем hGetContents, результат функции зависит не только от аргумента, но и от состояния системы в данный момент. Имеем ли мы право откладывать «на потом» вычисление, которое зависит от состояния системы? В идеале программа должна работать так: открываем файл, закрываем файл, при вызове print возвращаемся в прошлое (когда файл был еще открыт), читаем его содержимое, возвращаемся в будущее, выводим на экран.


    К чему я все это? Вы только не поймите меня не правильно. Я очень люблю Haskell. За его чистоту, ленивость, функциональность и еще много чего, что не выразить словами. Просто я вдруг узнал, что он не такой «чистый», как я раньше думал. Осталось какое-то смешанное чувство, что вроде бы и фича-то хорошая, но как-то «грязьненько» от неё стало.
    Возможно у меня просто паранойя. Хотелось бы услышать ваше мнение, баг или фича?
    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 20

      +1
      >> вроде бы и фича-то хорошая

      это вы про то что файл сразу не читается?
      это да, прекрасно. но вот с закрытием действительно получается какая то лажа. получается, что нужно открывать файлы заранее, например в main, а дальше передавать IO String или Handle, вместо того чтоб передать просто имя файла.
        +2
        ну вообще можно заставить Haskell полностью считать содержимое файла до закрытия handle

        вот тут описанно как это делать

        stackoverflow.com/questions/296792/haskell-io-and-closing-files

        ну а так в чём-то ваши размышления правильны но с другой стороны считывание файла весьма
        дорогая операция в плане времени поэтому я думаю что lazy подход весьма оправдываемо правилен в плане быстро действия
          +4
          1. При использовании hGetContents файл закрывать не надо, он закроется сам, когда дочитается до конца, либо когда не останется ссылок.
          2. Если вам надо прочесть и закрыть, используйте withFile и строгие функции чтения данных
          3. "«заставить» Haskell вычислить переменную content до закрытия файла (если кто-то знает способ, напишите)"
          Пишу:

          length content `seq` do ...
          

          в данном случае будет достаточно, так как для вычисления длины списка, придётся этот список заполнить.

          seq вычисляет первый аргумент до WHNF, т.е., грубо говоря, до конструктора, аргументы конструктора не трогает. Поэтому если вычислить seq (Just undefined) 10, результат будет просто 10, без ошибки, так как то, что внутри — не трогается.

          Если надо форсировать вычисление до конца, то можно использовать deepseq/force из Control.DeepSeq: (Just undefined) `deepseq` 10 === undefined.
            +1
            Кончено, можно открыть файл и не париться, пускай сам закроется в конце программы. Но есть пример, где такой подход не сработает. Например, мне нужно прочитать из файла содержимое, а потом записать в ТОТ ЖЕ файл новое значение.
            Такой код не работает
            import System.IO
            main = do 
                c <- withFile "1.txt" ReadMode $ hGetContents
                withFile "1.txt" WriteMode $ \h -> hPutStr h $ "<" ++ c ++ ">" 
            

            И никакие другие функции, типа withFile, readFile, writeFile, тут не помогут, потому что они всего лишь обертка над openFile, hGetContetns и hClose.

            >Пишу:
            > length content `seq` do…
            За пример спасибо :)
              +1
              Работать со строками через String не совсем принято, надо использовать text, а там есть как строгий readFile, так и ленивый.
              И в ByteString есть тоже две версии.

              Если мы читаем бинарные данные, надо использовать ByteString, если текстовые, то встаёт вопрос кодировки, и тут нужен ByteString+Text.

              String — это на коленке наваять и посмотреть.
          • UFO just landed and posted this here
              +1
              Я так понял, все подходят к этому вопросу с практической точки зрения. Если нужен ленивый вариант, вот одна функция, если не ленивый — другая.
              А меня больше задевает идеологическая сторона вопроса. Почему я пишу корректную (в энергичном смысле) программу, а она выдает некорректный результат. Вот представьте, вы бы нашли какую-нибудь лазейку, как протащить side-effect в чистую функцию. Что бы вы подумали? Я бы, например, немножко разочаровался. Я бы подумал: «ну так не интересно, я-то думал, что в haskell это невозможно».

              p.s.: Я знаю что существуют функции, типа trace, которые не совсем чистые. Но, поскольку они применяются для отладочных целей, им прощается :)
              • UFO just landed and posted this here
                  0
                  hGetContents реализована через функцию с говорящим названием unsafeInterleaveIO, потому она таит в себе опасность
                  Т.е. с если смотреть на идеологическую сторону вопроса — нечего использовать unsafe функции.
                    0
                    Странно, что нет стандартной safe функции, и приходится придумывать какие-то костыли.
                      0
                      System.IO написан был очень давно и входит в стандарт. base же распилена на куски, чтобы не держать зоопарк, а text и bytestring уже стандарт де факто, но вносить их прямо в base не стали.
                        +1
                        Спасибо за пояснение
                0
                Спасибо за пояснение.
                  0
                  Но давайте вспомним, для чего были придуманы монады. Разве не для того, чтобы представить программу, как некоторую чистую функцию, которая берет на вход состояние внешнего мира, и выдает на выход новое состояние?

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

                  Это значит, что мы можем отложить вычисление «на потом», и результат от этого не изменится

                  Нет. С такой логикой вот такие две функции совершенно одинаковы:
                  foo = do 
                      putStrLn "Hello, "
                      putStrLn "World"
                  bar = do 
                      putStrLn "World"
                      putStrLn "Hello, "
                  

                  что, очевидно, не верно.

                  То есть, вы ошибаетесь в том, что подразумеваете «неважность» порядка записи вызовов функций всегда. Это верно для чистых функций, но ошибочно, если пытаться использовать его внутри do-нотации.
                    0
                    Монады уж точно не для этого «придумывали». У вас в рассуждениях телега стоит впереди лошади — это наличие монад (с синтаксическим сахаром в виде do-нотации) позволяет представлять программу в виде функции, которая выглядит императивно. А не наоборот.

                    Не понял, в чем я был не прав. По-моему я написал то же самое.

                    То есть, вы ошибаетесь в том, что подразумеваете «неважность» порядка записи вызовов функций всегда

                    Я подразумеваю «неважность» порядка редукции. Это значит, что для одной и той же функции ленивое и энергичное вычисление должно давать один и тот же результат.
                      0
                      Так оно и редуцируется до одного и того же. Вы свою функцию main запишите без do-нотации, и сразу увидите, что «на вход» функции read разное состояние передаётся в разных примерах. Оттого и разное поведение.

                      Не редукция виновата, у вас просто разные функции написаны, с разным поведением.
                        0
                        Давайте рассмотрим ОДНУ функцию и вычислим её ДВУМЯ разными способами. Объясните мне, почему они дают разный результат?
                        main = do 
                            file <- openFile "1.txt" ReadMode
                            content <- hGetContents file
                            hClose file
                            print content
                        

                          0
                          Давайте. Запишите её без do-нотации для начала.
                            0
                            main = openFile "1.txt" ReadMode >>= \file -> 
                            	hGetContents file >>= \content -> 
                            	hClose file >> 
                            	print content
                            

                              0
                              Собственно, вот тут и видно, что в print content передаётся монада IO в которой файл уже закрыт, а с содержимым файла — никто не работал, соответственно, и читать из него не надо.

                              При этом, не важно, как именно её редуцировать, итоговая последовательность действий будет одинакова.

                  Only users with full accounts can post comments. Log in, please.