Почему я не могу использовать конструктор ввода-вывода

Почему я не могу этого сделать:

import Data.Char

getBool = do
  c <- getChar
  if c == 't' 
    then IO True 
    else IO False

вместо return?

Ответ 1

Фон

Я отвечу на несколько более широкий (и более интересный) вопрос. Это связано с тем, что по крайней мере с семантической точки зрения имеется более одного конструктора ввода-вывода. Существует более одного "вида" значения IO. Мы можем думать, что для печати на экран, возможно, есть один вид IO, один вид значения IO для чтения из файла и т.д.

Мы можем вообразить, что для обоснования IO определяется как нечто вроде

data IO a = ReadFile a
          | WriteFile a
          | Network a
          | StdOut a
          | StdIn a
          ...
          | GenericIO a

с одним видом значения для любого вида действия IO. (Тем не менее, имейте в виду, что на самом деле это не так, как реализовано IO. IO лучше всего не играть с игрушкой, если только вы не являетесь компилятором-хакером.)

Теперь интересный вопрос - почему они сделали это так, что мы не можем создать их вручную? Почему они не экспортировали эти конструкторы, чтобы мы могли их использовать? Это приводит к гораздо более широкому вопросу.

Почему вы хотите не экспортировать конструкторы для типа данных?

И для этого есть две причины: первая, вероятно, самая очевидная.

1. Конструкторы также являются деконструкторами

Если у вас есть доступ к конструктору, у вас также есть доступ к де-конструктору, с которым можно выполнить сопоставление шаблонов. Подумайте о типе Maybe a. Если я дам вам значение Maybe, вы можете извлечь все, что "внутри", что Maybe с помощью сопоставления с образцом! Это легко.

getJust :: Maybe a -> a
getJust m = case m of
              Just x -> x
              Nothing -> error "blowing up!"

Представьте, можете ли вы сделать это с помощью IO. Это означает, что IO перестанет быть безопасным. Вы можете просто сделать то же самое внутри чистой функции.

getIO :: IO a -> a
getIO io = case io of
             ReadFile s -> s
             _ -> error "not from a file, blowing up!"

Это ужасно. Если у вас есть доступ к конструкторам IO, вы можете создать функцию, которая превращает значение IO в чистое значение. Это отстой.

Итак, это одна из причин не экспортировать конструкторы типа данных. Если вы хотите сохранить некоторые данные "секретными", вы должны хранить свои конструкторы в секрете, иначе кто-то может просто извлечь любые данные, которые они хотят с помощью сопоставления с образцом.

2. Вы не хотите разрешать любое значение

Эта причина будет знакома объектно-ориентированным программистам. Когда вы впервые изучаете объектно-ориентированное программирование, вы узнаете, что у объектов есть специальный метод, который вызывается при создании нового объекта. В этом методе вы также можете инициализировать значения полей внутри объекта, и самое лучшее - вы можете выполнить проверку работоспособности этих значений. Вы можете убедиться, что значения "имеют смысл" и выдают исключение, если они этого не делают.

Хорошо, вы можете сделать что-то одно в Haskell. Скажите, что вы компания с несколькими принтерами, и вы хотите отслеживать, сколько им лет и на каком этаже в здании они расположены. Таким образом, вы пишете программу Haskell. Ваши принтеры можно сохранить следующим образом:

data Printer = Printer { name :: String
                       , age :: Int
                       , floor :: Int
                       }

Теперь ваше здание имеет только 4 этажа, и вы не хотите, чтобы случайно сказали, что у вас есть принтер на этаже 14. Это можно сделать, не экспортируя конструктор Printer, а вместо этого имея функцию mkPrinter который создает для вас принтер, если все параметры имеют смысл.

mkPrinter :: String -> Int -> Maybe Printer
mkPrinter name floor =
  if floor >= 1 && floor <= 4
     then Just (Printer name 0 floor)
     else Nothing

Если вы экспортируете эту функцию mkPrinter, вы знаете, что никто не может создать принтер на несуществующем этаже.

Ответ 2

Вы можете использовать IO вместо return. Но это не так просто. И вам также нужно импортировать некоторые внутренние модули.

Посмотрим на источник Control.Monad:

instance  Monad IO  where
    {-# INLINE return #-}
    {-# INLINE (>>)   #-}
    {-# INLINE (>>=)  #-}
    m >> k    = m >>= \ _ -> k
    return    = returnIO
    (>>=)     = bindIO
    fail s    = failIO s

returnIO :: a -> IO a
returnIO x = IO $ \ s -> (# s, x #)

Но даже для использования IO вместо return вам нужно импортировать GHC.Types(IO(..)):

newtype IO a = IO (State# RealWorld -> (# State# RealWorld, a #))

После этого вы можете написать IO $ \ s -> (# s, True #) (IO is a State) вместо return True:

Решение:

{-# LANGUAGE UnboxedTuples #-}  -- for unboxed tuples (# a, b #)
{-# LANGUAGE TupleSections #-}  -- then (,b) == \a -> (a, b)
import GHC.Types (IO (..))
import Data.Char

getBool = do
  c <- getChar
  if c == 't' 
    then IO (# , True #)
    else IO (# , False #)

Ответ 3

В моделях IO и ST очень мало магии, намного меньше, чем полагает большинство людей.

Страшный тип ввода-вывода - это просто newtype, определенный в GHC.Prim:

newtype IO a = IO (State# RealWorld -> (# State# RealWorld, a #))

Прежде всего, как видно выше, аргумент конструктора IO не совпадает с аргументом return. Вы можете получить лучшую идею, посмотрев наивную реализацию монады State:

newtype State s a = State (s -> (s, a))

Во-вторых, IO является абстрактным типом: это намеренное решение не экспортировать конструктор, чтобы вы не могли построить конструкцию IO или шаблон. Это позволяет Haskell обеспечивать ссылочную прозрачность и другие полезные свойства даже при наличии ввода-вывода.