Is the monadic IO construct in Haskell just a convention?

后端 未结 4 1089
自闭症患者
自闭症患者 2021-01-30 20:46

Is the monadic IO construct in Haskell just a convention or is there is a implementation reason for it?

Could you not just FFI into libc.so instead to do your IO, and

相关标签:
4条回答
  • 2021-01-30 21:34

    Let's say using FFI we defined a function

    c_write :: String -> ()
    

    which lies about its purity, in that whenever its result is forced it prints the string. So that we don't run into the caching problems in Michal's answer, we can define these functions to take an extra () argument.

    c_write :: String -> () -> ()
    c_rand :: () -> CUInt
    

    On an implementation level this will work as long as CSE is not too aggressive (which it is not in GHC because that can lead to unexpected memory leaks, it turns out). Now that we have things defined this way, there are many awkward usage questions that Alexis points out—but we can solve them using a monad:

    newtype IO a = IO { runIO :: () -> a }
    
    instance Monad IO where
        return = IO . const
        m >>= f = IO $ \() -> let x = runIO m () in x `seq` f x
    
    rand :: IO CUInt
    rand = IO c_rand
    

    Basically, we just stuff all of Alexis's awkward usage questions into a monad, and as long as we use the monadic interface, everything stays predictable. In this sense IO is just a convention—because we can implement it in Haskell there is nothing fundamental about it.

    That's from the operational vantage point.

    On the other hand, Haskell's semantics in the report are specified using denotational semantics alone. And, in my opinion, the fact that Haskell has a precise denotational semantics is one of the most beautiful and useful qualities of the language, allowing me a precise framework to think about abstractions and thus manage complexity with precision. And while the usual abstract IO monad has no accepted denotational semantics (to the lament of some of us), it is at least conceivable that we could create a denotational model for it, thus preserving some of the benefits of Haskell's denotational model. However, the form of I/O we have just given is completely incompatible with Haskell's denotational semantics.

    Simply put, there are only supposed to be two distinguishable values (modulo fatal error messages) of type (): () and ⊥. If we treat FFI as the fundamentals of I/O and use the IO monad only "as a convention", then we effectively add a jillion values to every type—to continue having a denotational semantics, every value must be adjoined with the possibility of performing I/O prior to its evaluation, and with the extra complexity this introduces, we essentially lose all our ability to consider any two distinct programs equivalent except in the most trivial cases—that is, we lose our ability to refactor.

    Of course, because of unsafePerformIO this is already technically the case, and advanced Haskell programmers do need to think about the operational semantics as well. But most of the time, including when working with I/O, we can forget about all that and refactor with confidence, precisely because we have learned that when we use unsafePerformIO, we must be very careful to ensure it plays nicely, that it still affords us as much denotational reasoning as possible. If a function has unsafePerformIO, I automatically give it 5 or 10 times more attention than regular functions, because I need to understand the valid patterns of use (usually the type signature tells me everything I need to know), I need to think about caching and race conditions, I need to think about how deep I need to force its results, etc. It's awful[1]. The same care would be necessary of FFI I/O.

    In conclusion: yes it's a convention, but if you don't follow it then we can't have nice things.

    [1] Well actually I think it's pretty fun, but it's surely not practical to think about all those complexities all the time.

    0 讨论(0)
  • 2021-01-30 21:43

    Yes, monadic I/O is a consequence of Haskell being lazy. Specifically, though, monadic I/O is a consequence of Haskell being pure, which is effectively necessary for a lazy language to be predictable.

    This is easy to illustrate with an example. Imagine for a moment that Haskell were not pure, but it was still lazy. Instead of putStrLn having the type String -> IO (), it would simply have the type String -> (), and it would print a string to stdout as a side-effect. The trouble with this is that this would only happen when putStrLn is actually called, and in a lazy language, functions are only called when their results are needed.

    Here’s the trouble: putStrLn produces (). Looking at a value of type () is useless, because () means “boring”. That means that this program would do what you expect:

    main :: ()
    main =
      case putStr "Hello, " of
        () -> putStrLn " world!"
    
    -- prints “Hello, world!\n”
    

    But I think you can agree that programming style is pretty odd. The case ... of is necessary, however, because it forces the evaluation of the call to putStr by matching against (). If you tweak the program slightly:

    main :: ()
    main =
      case putStr "Hello, " of
        _ -> putStrLn " world!"
    

    …now it only prints world!\n, and the first call isn’t evaluated at all.

    This actually gets even worse, though, because it becomes even harder to predict as soon as you start trying to do any actual programming. Consider this program:

    printAndAdd :: String -> Integer -> Integer -> Integer
    printAndAdd msg x y = putStrLn msg `seq` (x + y)
    
    main :: ()
    main =
      let x = printAndAdd "first" 1 2
          y = printAndAdd "second" 3 4
      in (y + x) `seq` ()
    

    Does this program print out first\nsecond\n or second\nfirst\n? Without knowing the order in which (+) evaluates its arguments, we don’t know. And in Haskell, evaluation order isn’t even always well-defined, so it’s entirely possible that the order in which the two effects are executed is actually completely impossible to determine!

    This problem doesn’t arise in strict languages with a well-defined evaluation order, but in a lazy language like Haskell, we need some additional structure to ensure side-effects are (a) actually evaluated and (b) executed in the correct order. Monads happen to be an interface that elegantly provide the necessary structure to enforce that order.

    Why is that? And how is that even possible? Well, the monadic interface provides a notion of data dependency in the signature for >>=, which enforces a well-defined evaluation order. Haskell’s implementation of IO is “magic”, in the sense that it’s implemented in the runtime, but the choice of the monadic interface is far from arbitrary. It seems to be a fairly good way to encode the notion of sequential actions in a pure language, and it makes it possible for Haskell to be lazy and referentially transparent without sacrificing predictable sequencing of effects.

    It’s worth noting that monads are not the only way to encode side-effects in a pure way—in fact, historically, they’re not even the only way Haskell handled side-effects. Don’t be misled into thinking that monads are only for I/O (they’re not), only useful in a lazy language (they’re plenty useful to maintain purity even in a strict language), only useful in a pure language (many things are useful monads that aren’t just for enforcing purity), or that you needs monads to do I/O (you don’t). They do seem to have worked out pretty well in Haskell for those purposes, though.


    † Regarding this, Simon Peyton Jones once noted that “Laziness keeps you honest” with respect to purity.

    0 讨论(0)
  • 2021-01-30 21:47

    Could you just FFI into libc.so instead to do IO and skip the IO Monad thing?

    Taking from https://en.wikibooks.org/wiki/Haskell/FFI#Impure_C_Functions, if you declare an FFI function as pure (so, with no reference to IO), then

    GHC sees no point in calculating twice the result of a pure function

    which means the the result of the function call is effectively cached. For example, a program where a foreign impure pseudo-random number generator is declared to return a CUInt

    {-# LANGUAGE ForeignFunctionInterface #-}
    
    import Foreign
    import Foreign.C.Types
    
    foreign import ccall unsafe "stdlib.h rand"
      c_rand :: CUInt
    
    main = putStrLn (show c_rand) >> putStrLn (show c_rand)
    

    returns the same thing every call, at least on my compiler/system:

    16807
    16807
    

    If we change the declaration to return a IO CUInt

    {-# LANGUAGE ForeignFunctionInterface #-}
    
    import Foreign
    import Foreign.C.Types
    
    foreign import ccall unsafe "stdlib.h rand"
      c_rand :: IO CUInt
    
    main = c_rand >>= putStrLn . show >> c_rand >>= putStrLn . show
    

    then this results in (probably) a different number returned each call, since the compiler knows it's impure:

    16807
    282475249
    

    So you're back to having to use IO for the calls to the standard libraries anyway.

    0 讨论(0)
  • 2021-01-30 21:47

    That depends on what the meaning of "is" is—or at least what the meaning of "convention" is.

    If a "convention" means "the way things are usually done" or "an agreement among parties covering a particular matter" then it is easy to give a boring answer: yes, the IO monad is a convention. It is the way the designers of the language agreed to handle IO operations and the way that users of the language usually perform IO operations.

    If we are allowed to choose a more interesting definition of "convention" then we can get a more interesting answer. If a "convention" is a discipline imposed on a language by its users in order to achieve a particular goal without assistance from the language itself, then the answer is no: the IO monad is the opposite of a convention. It is a discipline enforced by the language that assists its users in constructing and reasoning about programs.

    The purpose of the IO type is to create a clear distinction between the types of "pure" values and the types of values which require execution by the runtime system to generate a meaningful result. The Haskell type system enforces this strict separation, preventing a user from (say) creating a value of type Int which launches the proverbial missiles. This is not a convention in the second sense: its entire goal is to move the discipline required to perform side effects in a safe and consistent way from the user and onto the language and its compiler.

    Could you just FFI into libc.so instead to do IO and skip the IO Monad thing?

    It is, of course, possible to do IO without an IO monad: see almost every other extant programming language.

    Would it work anyway or is the outcome undeterministic because of Haskell evaluating lazy or something else, like that the GHC is pattern matching for IO Monad and then handling it in a special way or something else.

    There is no such thing as a free lunch. If Haskell allowed any value to require execution involving IO then it would have to lose other things that we value. The most important of these is probably referential transparency: if myInt could sometimes be 1 and sometimes be 5 depending on external factors then we would lose most of our ability to reason about our programs in a rigorous way (known as equational reasoning).

    Laziness was mentioned in other answers, but the issue with laziness would specifically be that sharing would no longer be safe. If x in let x = someExpensiveComputationOf y in x * x was not referentially transparent, GHC would not be able to share the work and would have to compute it twice.

    What is the real reason?

    Without the strict separation of effectful values from non-effectful values provided by IO and enforced by the compiler, Haskell would effectively cease to be Haskell. There are plenty of languages that don't enforce this discipline. It would be nice to have at least one around that does.

    In the end you end you endup in a sideeffect. So why not do it the simple way?

    Yes, in the end your program is represented by a value called main with an IO type. But the question isn't where you end up, it's where you start: If you start by being able to differentiate between effectful and non-effectful values in a rigorous way then you gain a lot of advantages when constructing that program.

    0 讨论(0)
提交回复
热议问题