I always find it helpful to consider a simplified “imperative-language source code” implementation of the IO
monad:
data IO :: * -> * where
PutStr :: String -> IO ()
GetLine :: IO String
IOPure :: a -> IO a
IOSeq :: IO a -> IO b -> IO b
...
LaunchMissiles :: IO ()
Then what ioFunction
is quite clearly a proper, sensible function in the mathematical sense:
ioFunction str = do putStrLn str
putStrLn "2"
= putStr (str++"\n") >> putStrLn "2\n"
= IOSeq (PutStr (str++"\n")) (PutStrLn "2\n")
This is simply a data structure representing effectively some source code of an imperative language. ioFunction
places the given argument in a specific spot in the result structure, so it's mathematically very much not just a trivial “return ()
and nothing else (something may happen but it's a GHC implementation detail)”.
The value is indeed completely different for ioFunction2
:
ioFunction2 str = do return ()
= return ()
= IOPure ()
how do we know they are different in this sense?
That's a good question. Practically speaking, you know it by executing both programs and observing that they cause different effects, hence they must have been different. This is more than a little awkward of course – “observing what happens” isn't maths, it's physics, and the observation would scientifically require that you execute twice under exact the same environment conditions.
Worse, even with pure values that are generally taken to be mathematically the same you can observe different behaviour: print 100000000
will immediately cause the side-effect, whereas print $ last [0..100000000]
takes a significant pause (which, if you follow it with a time-printing command, can actually yield different text output).
But these issues are unavoidable in a real-world context. It hence makes only sense that Haskell's does not specify any structure on IO that could with mathematical rigour be checked for equality from within the language. So, in quite some sense you really can't know that putStrLn str >> putStrLn "2"
is not the same as return ()
.
And indeed they might be the same. It is conceivable to make a even simpler toy implementation of IO
than the above thus:
data IO :: * -> * where
IONop :: IO ()
IOHang :: IO a -> IO a
and simply map any pure output operation to the no-op (and any input to an infinite loop). Then you would have
ioFunction str = do putStrLn str
putStrLn "2"
= IONop >> IONop
= IONop
and
ioFunction2 str = return ()
= IONop
This is a bit as if we've imposed an additional axiom on the natural numbers, namely 1 = 0. From that you can then conclude that every number is zero.
Clearly though, that would be a totally useless implementation. But something similar could actually make sense if you're running Haskell code in a sandbox environment and want to be utterly sure nothing bad happens. http://tryhaskell.org/ does something like this.