This may seem odd, but doing IOs actually goes beyond the usual Haskell language! 1
Haskell built-in libraries provide โbasicโ I / O, such as getLine :: IO String , functions that return I / O, such as putStrLn :: String -> IO () , and ways to create I / O from other operations I / O (mainly by providing the Monad interface, therefore everything that works on any monad, like all things in Control.Monad , is a way to work with IO ).
All this is clean and lazy, just like the non-IO Haskell code. IO not a special case for everything you can do with regular Haskell code (so you can use the general Monad code in IO; all this code is written and compiled without any knowledge of any special rules that IO has, so it can only work if they are not).
But none of this actually ever performs an IO action; it just makes new IO actions from others. This is what people mean when they talk about how "evaluating the performance of an EUT does not work." The value of "apple" ++ "banana" type String can be represented by an invaluable thunk; when it gets the "applebanana" score, it still represents the exact same value, the system just writes it as data in memory, and not a pointer to some code that you can run to create it 1 . Similarly, a putStrLn "apple" >> putStrLn "banana" value putStrLn "apple" >> putStrLn "banana" type IO () can be represented by an invaluable thunk, and when it gets evaluated, all this means that the system now represents the same value with a data structure instead of a pointer to a code that will run the (clean, lazy) function >> for the other two I / O operations. But we talked only about the systemic representation of I / O operations in RAM, nothing about actually launching them to create some side effects.
And actually Haskell language features that talk about IO actions are missing. The runtime system "just knows" how to execute the IO main action from main 3 module. Haskell cannot talk about how this happens; that everything is handled by a system that provides you with Haskell (GHC or another Haskell system). The only option the Haskell language gives you is that main is defined as a Haskell action; any I / O actions that you include as part of the main definition will be triggered.
1 I pretend that things like unsafePerformIO do not exist for the purpose of this discussion. As the name implies, it intentionally violates the usual rules. It is also not intended to introduce "performing I / O operations" as a normal part of the Haskell language, but only to use in the internal details something that is a "normal Haskell interface".
2 Usually this happens in part: only very simple types, such as Int , are evaluated as "all or nothing." Most of them can be partially evaluated in data structures that contain thunks deeper (which may or may not be evaluated later).
3 Or GHCi "just knows" how to perform the I / O actions that you enter at your prompt.