What other ways can state be handled in a pure functional language besides with Monads?

前端 未结 7 1913
一个人的身影
一个人的身影 2020-12-23 01:58

So I started to wrap my head around Monads (used in Haskell). I\'m curious what other ways IO or state can be handled in a pure functional language (both in theory or realit

7条回答
  •  庸人自扰
    2020-12-23 02:38

    It can't be (not if by "state" you mean "I/O or mutable variable behavior like in a procedural language"). In the first place, you have to understand where the use of monads for mutable variables or I/O comes from. Despite popular belief, monadic I/O doesn't come from languages like Haskell, but from languages like ML. Eugenio Moggi developed the original monads while studying the use of category theory for the denotational semantics of impure functional languages like ML. To see why, consider that a monad (in Haskell) can be categorized by three properties:

    • There is a distinction between values (in Haskell, of type a) and expressions (in Haskell, of type IO a).
    • Any value can be turned into an expression (in Haskell, by converting x to return x).
    • Any function over values (returning an expression) can be applied to an expression (in Haskell, by computing f =<< a).

    These properties are obviously true of (at least) the denotational semantics of any impure functional language:

    • An expression, like print "Hello, world!\n", can have side-effects, but its value, such as (), cannot. So we need to make a distinction between the two cases in the denotational semantics.
    • A value, such as 3, can be used anywhere an expression is required. So our denotational semantics needs a function to turn a value into an expression.
    • A function takes values as arguments (the formal parameters to a function in a strict language don't have side-effects), but can be applied to an expression. So we need a way to apply an (expression-returning) function of values to an expression.

    So any denotational semantics for an impure functional (or procedural) language is going to have the structure of a monad under the hood, even if that structure isn't explicitly used in describing how I/O works in the language.

    What about purely functional languages?

    There are four major ways of doing I/O in purely functional languages, that I know about (in practice) (again, restricting ourselves to procedural-style I/O; FRP is genuinely a different paradigm):

    • Monadic I/O
    • Continuations
    • Uniqueness / linear types
    • Dialogs

    Monadic I/O is obvious. Continuation-based I/O looks like this:

    main k = print "What is your name? " $
        getLine $ \ myName ->
        print ("Hello, " ++ myName ++ "\n") $
        k ()
    

    Each I/O action takes a 'continuation', performs its action, and then tail calls (under the hood) the continuation. So in the above program:

    • print "What is your name? " runs, then
    • getLine runs, then
    • print ("Hello, " ++ myName ++ "\n") runs, then
    • k runs (which returns control to the OS).

    The continuation monad is an obvious syntactic improvement to the above. More significantly, semantically, I can only see two ways to make the I/O actually work in the above:

    • Make the I/O actions (and continuations) return an "I/O type" describing the I/O you want to perform. Now you have an I/O monad (continuation monad-based) without the newtype wrapper.
    • Make the I/O actions (and continuations) return what is essentially () and do the I/O as a side-effect of calling the individual operations (e.g., print, getLine, etc.). But if evaluation of an expression in your language (which the right-hand side of the main definition above is) is side-effectful, I wouldn't consider that purely functional.

    What about uniqueness/linear types? These use special 'token' values to represent the state of the world after each action, and enforce sequencing. The code looks like this:

    main w0 = let
            w1 = print "What is your name? " w0
            (w2, myName) = getLine w1
            w3 = print $ "Hello, " ++ myName ++ "!\n"
        in w3
    

    The difference between linear types and uniqueness types is that in linear types, the result has to be w3 (it has to be of type World), whereas in uniqueness types, the result could be something like w3 `seq` () instead. w3 just has to be evaluated for the I/O to happen.

    Again, the state monad is an obvious syntactic improvement to the above. More significantly, semantically, you again have two choices:

    • Make the I/O operations, such as print and getLine, strict in the World argument (so the previous operation runs first, and side-effectful (so the I/O happens as a side-effect of evaluating them). Again, if you have side-effects of evaluation, in my opinion that's not really purely functional.
    • Make the World type actually represent the I/O that needs to be performed. This has the same problem as GHC's IO implementation with tail-recursive programs. Suppose we change the result of main to main w3. Now main tail-calls itself. Any function that tail-calls itself, in a purely functional language, has no value (is just an infinite loop); this is a basic fact about how the denotational semantics of recursion works in a pure language. Again, I wouldn't consider any language that broke that rule (especially for a 'special' data type like World) to be purely functional.

    So, really, uniqueness or linear types a) produce programs that are clearer / cleaner if you wrap them in a state monad and b) aren't actually a way to do I/O in a purely functional language after all.

    What about dialogs? This is the only way to do I/O (or, technically, mutable variables, although that's much harder) that truly is both purely functional and independent of monads. That looks something like this:

    main resps = [
        PrintReq "What is your name? ",
        GetLineReq,
        PrintReq $ "Hello, " ++ myName ++ "!\n"
      ] where
        LineResp myName = resps !! 1
    

    However, you'll notice a few disadvantages of this approach:

    • It's not clear how to incorporate I/O-performing procedures into this approach.
    • You have to use numeric or positional indexing to find the response corresponding to a given request, which is quite fragile.
    • There's no obvious way to scope a response just over the actions after it's received; if this program somehow used myName before issuing the corresponding getLine request, the compiler would accept your program but it would deadlock at runtime.

    An easy way to solve all of these problems is to wrap dialogs in continuations, like this:

    type Cont = [Response] -> [Request]
    print :: String -> Cont -> Cont
    print msg k resps = PrintReq msg : case resps of
        PrintResp () : resps1 -> k resps1
    getLine :: (String -> Cont) -> Cont
    getLine k resps = GetLineReq : case resps of
        GetLineResp msg : resps1 -> k msg resps1
    

    The code now looks identical to the code for the continuation-passing approac to I/O given earlier. In fact, dialogs are an excellent result type for your continuations in a continuation-based I/O system, or even in a continuation monad-based monadic I/O system. However, by converting back to continuations, the same argument applies, so we see that, even if the run-time system uses dialogs internally, programs should still be written to do I/O in a monadic style.

提交回复
热议问题