13
$\begingroup$

On the haskell wiki there is the following example of conditional usage of the IO monad (see here).

when :: Bool -> IO () -> IO ()
when condition action world =
    if condition
      then action world
      else ((), world)

Note that in this example, the definition of IO a is taken to be RealWorld -> (a, RealWorld) to make everything more understandable.

This snippet conditionally executes an action in the IO monad. Now, assuming that condition is False, the action action should never be executed. Using lazy semantics this would indeed be the case. However, it is noted here that Haskell is technically speaking non-strict. This means that the compiler is allowed to, for example, preemptively run action world on a different thread, and later throw away that computation when it discovers it doesn't need it. However, by that point the side-effects will already have happened.

Now, one might implement the IO monad in such a way that side-effects are only propagated when the whole program has finished, and we know exactly which side-effects should be executed. This is not the case, however, because it is possible to write infinite programs in Haskell, that clearly have intermediate side-effects.

Does this mean that the IO monad is technically wrong, or is there something else preventing this from happening?

$\endgroup$
6
  • $\begingroup$ Welcome to Computer Science! Your question is off-topic here: we deal with computer science questions, not programming questions (see our FAQ). Your question might be on-topic on Stack Overflow. $\endgroup$
    – dkaeae
    Commented May 16, 2019 at 15:30
  • 3
    $\begingroup$ In my opinion this is a computer science question, because it deals with the theoretical semantics of Haskell, not with a practical programming question. $\endgroup$
    – Lasse
    Commented May 16, 2019 at 15:30
  • 5
    $\begingroup$ I'm not too familiar with programming language theory, but I think this question is on-topic here. It might help if you clarify what 'wrong' means here. What property do you think the IO monad has that it should not have? $\endgroup$
    – Discrete lizard
    Commented May 16, 2019 at 19:01
  • 2
    $\begingroup$ This program is not well-typed. I'm not sure what you actually meant to write. The definition of when is typable, but it doesn't have the type that you declare, and I don't see what makes this particular code interesting. $\endgroup$ Commented May 16, 2019 at 21:32
  • 3
    $\begingroup$ This program is taken verbatim from the Haskell-wiki page linked directly above. It does indeed not type. This is because it is written under the assumption that IO a is defined as RealWorld -> (a, RealWorld), in order to make the internals of IO more readable. $\endgroup$
    – Lasse
    Commented May 16, 2019 at 21:52

1 Answer 1

13
$\begingroup$

This is a suggested "interpretation" of the IO monad. If you want to take this "interpretation" seriously, then you need to take "RealWorld" seriously. It's irrelevant whether action world gets speculatively evaluated or not, action doesn't have any side-effects, its effects, if any, are handled by returning a new state of the universe where those effects have occurred, e.g. a network packet has been sent. However, the result of the function is ((),world) and therefore the new state of the universe is world. We don't use the new universe that we may have speculatively evaluated on the side. The state of the universe is world.

You probably have a hard time taking that seriously. There are many ways this is at best superficially paradoxical and nonsensical. Concurrency is especially either non-obvious or crazy with this perspective.

"Wait, wait," you say. "RealWorld is just a 'token'. It's not actually the state of the entire universe." Okay, then this "interpretation" explains nothing. Nevertheless, as an implementation detail, this is how GHC models IO.1 However, this means that we do have magical "functions" that actually do have side-effects and this model provides no guidance to their meaning. And, since these functions actually have side-effects, the concern you raise is completely on point. GHC does have to go out of its way to make sure RealWorld and these special functions are not optimized in ways that change the intended behavior of the program.

Personally (as is probably evident by now), I think this "world-passing" model of IO is just useless and confusing as a pedagogical tool. (Whether it's useful for implementation, I don't know. For GHC, I think it is more of a historical artifact.)

One alternative approach is to view IO as a describing requests with response handlers. There are several ways of doing this. Probably the most accessible is to use a free monad construction, specifically we can use:

data IO a = Return a | Request OSRequest (OSResponse -> IO a)

There are many ways to make this more sophisticated and have somewhat better properties, but this is already an improvement. It doesn't require deep philosophical assumptions about the nature of reality to understand. All it states is that IO is either a trivial program Return that does nothing but return a value, or it's a request to the operating system with a handler for the response. OSRequest can be something like:

data OSRequest = OpenFile FilePath | PutStr String | ...

Similarly, OSResponse might be something like:

data OSResponse = Errno Int | OpenSucceeded Handle | ...

(One of the improvements that can be made is to make things more type safe so that you know you won't get OpenSucceeded from a PutStr request.) This models IO as describing requests that get interpreted by some system (for the "real" IO monad this is the Haskell runtime itself), and then, perhaps, that system will call the handler we've provided with a response. This, of course, also doesn't give any indication of how a request like PutStr "hello world" should be handled, but it also doesn't pretend to. It makes explicit that this is being delegated to some other system. This model is also pretty accurate. All user programs in modern OSes need to make requests to the OS to do anything.

This model provides the right intuitions. For example, many beginners view things like the <- operator as "unwrapping" IO, or have (unfortunately reinforced) views that an IO String, say, is a "container" that "contains" Strings (and then <- gets them out). This request-response view makes this perspective clearly wrong. There is no file handle inside of OpenFile "foo" (\r -> ...). A common analogy to emphasize this is that there is no cake inside of a recipe for cake (or maybe "invoice" would be better in this case).

This model also works readily with concurrency. We can easily have a constructor for OSRequest like Fork :: (OSResponse -> IO ()) -> OSRequest and then the runtime can interleave the requests produced by this extra handler with the normal handler however it likes. With some cleverness you can use this (or related techniques) to actually model things like concurrency more directly rather than just saying "we make a request to the OS and things happen." This is how the IOSpec library works.

1 Hugs used a continuation-based implementation of IO which is roughly similar to what I describe albeit with opaque functions instead of an explicit data type. HBC also used a continuation-based implementation layered over the old request-response stream-based IO. NHC (and thus YHC) used thunks, i.e. roughly IO a = () -> a though the () was called World, but it isn't doing state-passing. JHC and UHC used basically the same approach as GHC.

$\endgroup$
4
  • $\begingroup$ Thanks for your enlightning answer, it really helped. Your implementation of IO took some time to wrap my mind around, but I agree that it is more intuitive. Are you claiming that this implementation does not suffer from potential problems with side-effects ordering like the RealWorld implementation does? I can not immediately see any problems, but it is also not clear to me that they do not exist. $\endgroup$
    – Lasse
    Commented May 17, 2019 at 17:03
  • $\begingroup$ One comment: it seems that OpenFile "foo" (\r -> ...) should actually be Request (OpenFile "foo") (\r -> ...)? $\endgroup$
    – Lasse
    Commented May 17, 2019 at 17:05
  • $\begingroup$ @Lasse Yep, it should have been with Request. To answer your first question, this IO is clearly insensitive to evaluation order (modulo bottoms) because it's an inert value. All the side-effects (if any) would be made by the thing that interprets this value. In the when example, it wouldn't matter if action was evaluated, because it would just be a value like Request (PutStr "foo") (...) which we won't give to the thing interpreting these requests anyway. It's like source code; it doesn't matter if you reduce it eagerly or lazily, nothing happens until it is given to an interpreter. $\endgroup$ Commented May 17, 2019 at 20:25
  • $\begingroup$ Ah yes I see that. This is a really clever definition. At first I thought that all side-effects would necessarily have to happen when the whole program has finished executing, because you have to build the datastructure before you can interpret it. But since a request contains a continuation, you only have to build the data of the very first Request in order to start seeing side-effects. Subsequent side-effects can be created when evaluating the continuation. Clever! $\endgroup$
    – Lasse
    Commented May 17, 2019 at 22:49

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.