Монада IO и ввод-вывод

Чистота не запрещает ввод-вывод. Эффекты честно живут в типе IO — и это держит их отдельно от чистого кода.
Печать на экран, чтение строки, работа с файлами — всё это имеет тип IO. Тип как бы говорит: «осторожно, тут общение с внешним миром».

Как чистый язык вообще что-то печатает? Хитрость в том, что значение типа IO a — это не «выполненное действие», а описание действия, которое произойдёт при запуске программы. Чистота сохраняется: вы строите рецепт эффектов, а исполняет его рантайм.

putStrLn :: String -> IO ()      -- напечатать строку
getLine  :: IO String            -- прочитать строку
print    :: Show a => a -> IO () -- напечатать любое Show-значение

Тип IO () означает «действие, которое выполняет эффект и не возвращает полезного значения» (() — пустой кортеж, «ничего»). Тип IO String — «действие, которое даст строку».

Связываем действия через do

IO — это монада, поэтому действия соединяются той же do-нотацией, что и Maybe:

main :: IO ()
main = do
  putStrLn "Как тебя зовут?"
  name <- getLine                    -- <- достаёт String из IO
  putStrLn ("Привет, " ++ name ++ "!")
do-блок IO выполняется ПО ПОРЯДКУ сверху вниз:
  putStrLn "..."   --> эффект: печать
  name <- getLine  --> эффект: чтение, name :: String
  putStrLn "..."   --> эффект: печать

Стрелка <- «вынимает» результат действия (например, прочитанную строку) для дальнейшего использования. Чистые значения внутри do задают через let:

main :: IO ()
main = do
  putStrLn "Введи число:"
  line <- getLine
  let n = read line :: Int           -- чистое преобразование
  print (n * n)

Граница чистоты

Ключевая идея: IO отделяет эффекты от чистого кода через тип. Функция без IO в сигнатуре гарантированно ничего не печатает и не читает. Поэтому эффекты держат на «краях» программы, а ядро оставляют чистым и легко тестируемым.

В Python эффекты не отражены в типах — печать и чтение можно вызвать откуда угодно. Сама последовательность действий, однако, узнаваема:

# Та же идея на Python: последовательность IO-действий
# (в Python эффекты НЕ видны в типах — в этом ключевое отличие)
def greet():
    print("Как тебя зовут?")        # ~ putStrLn
    name = input()                  # ~ getLine  (в песочнице ввода нет)
    print("Привет, " + name + "!")  # ~ putStrLn

# вместо input() подставим значение, чтобы показать поток:
def greet_demo(name):
    print("Как тебя зовут?")
    print("Привет, " + name + "!")

greet_demo("Ада")

Чистое ядро, эффекты по краям

Гениальность подхода Haskell к вводу-выводу в том, что чистота и эффекты не воюют, а аккуратно разделены типом. Значение IO a — это не уже выполненное действие, а его описание, рецепт, который рантайм исполнит при запуске main. Благодаря этому функция без IO в сигнатуре гарантированно ничего не печатает и не лезет в сеть — и вы можете доверять ей абсолютно. Отсюда рождается фирменная архитектура «функциональное ядро, императивная оболочка»: всю содержательную логику пишут чистыми функциями, которые легко тестировать, а тонкий слой IO по краям лишь подаёт им данные и выводит результаты. Внутри do-блока действия выполняются строго по порядку, стрелка <- извлекает результаты, а let вводит чистые промежуточные значения. Так язык сохраняет все свои гарантии, не теряя способности читать файлы, общаться по сети и разговаривать с пользователем.

Как это мыслить

Считайте IO a «запечатанным конвертом с инструкцией», который вскроет рантайм при запуске main. Вы комбинируете такие конверты в do-блоке, задавая порядок. Тип IO в сигнатуре — это маркер «здесь общение с миром», и он не даёт эффектам незаметно протечь в чистый код.

Полезно осознать, почему такое разделение так ценят на практике. Когда вся логика собрана в чистых функциях, тестировать её можно без всякой инфраструктуры: подаёте вход, проверяете выход, никаких баз, файлов и сетевых заглушек. Тонкий слой IO по краям при этом остаётся настолько простым, что в нём почти нечему ломаться — он лишь подносит данные и уносит результаты. Эта архитектура масштабируется от учебной программы до крупной системы: чем больше проект, тем заметнее выигрыш от того, что эффекты строго отделены и локализованы. Вы всегда знаете, какие части программы общаются с внешним миром, а какие — чисты и предсказуемы, просто взглянув на типы. В языках без такого разделения это знание приходится держать в голове, и оно неизбежно теряется по мере роста кода.

Частые ошибки

  • Ждать значение из IO без <-. getLine — это действие; чтобы получить строку, нужно name <- getLine.
  • Путать let и <- в do. <- — для извлечения из IO, let — для чистых значений.
  • Пытаться «выйти» из IO в чистую функцию. Назад дороги нет: IO в типе остаётся, и это правильно.

Best practices

  • Держите ядро программы чистым, а IO — на краях («функциональное ядро, императивная оболочка»).
  • Чистую логику выносите из do-блоков в отдельные тестируемые функции.
  • Помните: IO в сигнатуре честно документирует, что функция имеет эффекты.

Итог. Ввод-вывод в Haskell описывается значениями типа IO, которые исполняет рантайм при запуске main. Действия связываются do-нотацией и выполняются по порядку, а тип IO чётко отделяет эффекты от чистого кода — так язык остаётся чистым, не теряя способности общаться с миром.

Проверьте себя
1. Что означает тип IO () у действия putStrLn "hi" ?
AФункция возвращает целое число
BДействие выполняет эффект ввода-вывода и не возвращает полезного значения
CЭто синтаксическая ошибка
DФункция всегда возвращает строку
2. Зачем нужна стрелка <- в do-блоке IO, например name <- getLine ?
AЧтобы объявить тип
BЧтобы извлечь результат IO-действия (прочитанную строку) для дальнейшего использования
CЧтобы завершить программу
DЧтобы создать список