Меня давно беспокоит одна тема. Вот решил высказаться и услышать, что думают люди по этому поводу. Речь пойдет о функции hGetContents. Если вы когда-нибудь работали с файлами, то вы знаете, что эта функция возвращает содержимое файла (потока). Вот типичный пример использования этой функции.
Все очень банально — открываем файл, считываем содержимое, выводим на экран, закрываем файл. Фича фунции hGetContents (а точнее фича Haskell-я) в том, что она ленивая. А именно, данные из файла считаются не сразу целиком, а будут считываться по мере необходимости. Например, вот такая программа
считает из файла только первые 3 символа.
Что тут можно сказать, отличная фича! Это очень удобно, оперировать переменной content (содержимое файла), но при этом знать, что Haskell возмет из него только то, что нужно, и ничего лишнего.
А теперь рассмотрим следующий пример
Здесь мы просто переставил местами две последние строчки. Результатом будет выведенная на экран пустая строка. Почему так получилось? Причина в том, что мы пытаемся обратиться к содержимому файла после того, как файл уже закрыт. При вызове hGetContents данные никуда не считывались, просто в переменной content сохранилась некоторая ссылка на этот файл. А потом, когда нам понадобилось содержимое content, оказалось, что файл уже закрыт.
Ну и что такого, подумаете Вы, просто нужно использовать переменную content до закрытия файла.
Проблема тут еще в том, что мы никак не можем «заставить» Haskell вычислить переменную content до закрытия файла (если кто-то знает способ, напишите). Например, нам нужно написать функцию, которая открывает файл, читает содержимое, парсит некоторую структуру, закрывает файл, выдает полученную структуру как результат.
Можно, конечно, всю программу поместить между openFile и hClose, а что делать, если нужно считать содержимое из файла, подкорректировать и записать в тот же файл?
Я долго думал над этим примером и чувство «что-то здесь не так» не оставляло меня в покое. Разве это не нарушает чистоту Haskell-я. Я же написал корректную по сути программу, а она выдает не правильный результат. Я что должен думать о том, в какой момент он обратится к этой переменной и что он там вообще делает под капотом? Это уже не Haskell, это, извините за выражение, C++ какой-то.
В итоге я пришел к выводу, что эта «фича» — не фича, а баг. Если я вас не убедил, то вот несколько доводов:
К чему я все это? Вы только не поймите меня не правильно. Я очень люблю Haskell. За его чистоту, ленивость, функциональность и еще много чего, что не выразить словами. Просто я вдруг узнал, что он не такой «чистый», как я раньше думал. Осталось какое-то смешанное чувство, что вроде бы и фича-то хорошая, но как-то «грязьненько» от неё стало.
Возможно у меня просто паранойя. Хотелось бы услышать ваше мнение, баг или фича?
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. За его чистоту, ленивость, функциональность и еще много чего, что не выразить словами. Просто я вдруг узнал, что он не такой «чистый», как я раньше думал. Осталось какое-то смешанное чувство, что вроде бы и фича-то хорошая, но как-то «грязьненько» от неё стало.
Возможно у меня просто паранойя. Хотелось бы услышать ваше мнение, баг или фича?