Is the monadic IO construct in Haskell just a convention?

后端 未结 4 1206
自闭症患者
自闭症患者 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.

提交回复
热议问题