My take on how to explain basic IO without having to explain monads:
(1) Any function with interacts with the world is called an IO function and has the result type "IO something". e.g. "IO String" for getLine (because it returns a String). Functions and expressions which does not interact with the world are called 'pure'. IO is a type constructor, but for now it makes sense to think of it as a "tag" on result values which guarantees we cannot mix IO-functions with pure functions. If we could call IO functions inside pure functions, the whole idea of pure would dissolve. The IO "tag" allows the compiler to enforce that this can't happen.
(2) A function which calls IO functions uses 'do'-notation:
somefun = do
putStrLn "Hello"
putStrLn "World"
Each line is a single IO function call (with arguments which are pure expressions). Each line in the do-block should be indented to the same level. (Only in the case where the function consist of just a single IO-call can the 'do' be left out, which is why you can write 'main = putStrLn "hello World"'). The do-notation guarantees that the operations are executed sequentially. The return value of the function is the return value of the last operation in the do-block.
If you want to use the return value of an IO function, you use a left-arrow (<-) to read it into a variable. Eg.
somefun = do
putStrLn "What is your name?"
name <- getLine
putStrLn ("Hello, " ++ name)
Note that you cannot simplify the two lines to:
X putStrLn ("Hello, " ++ getLine)
The above breaks the rule that each IO function call have to be on a separate line in the do-block. IO-function calls can not be nested in expressions, they have to be sequential.
(3) Calling pure functions.
You cannot call IO function from pure code, but you can call pure functions from IO-code. This is done via 'let':
somefun = do
putStrLn "What is your name?"
name <- getLine
let name2 = reverse name
putStrLn ("Olleh, , " ++ name2)
Note how the let syntax is distinctly different from the left-arrow syntax. The let syntax just assigns the result of a pure expression to a variable. The arrow syntax "extracts" a pure value from an IO value. The result of getLine is 'IO string' but the arrow "untags" it so the type of name is just "String", and it can be used as part of a pure expression.
(4) Returning pure values.
The return type of a IO function has to be IO something. In the above example the return type is 'IO ()' since this is the result type of the last operation, putStrLn. But what if we want to return say the reversed name? The reversed name is a pure value, so this is will not compile:
somefun = do
putStrLn "What is your name?"
name <- getLine
let name2 = reverse name
putStrLn ("Olleh, " ++ name2)
X name2
We have to turn the String into an "IO String", because the result type of an IO function has to be "IO something". This is done with the "return" operation, which turns a pure value into the corresponding IO value.
The name "return" is unfortunate since it looks equivalent to "return" in imperative languages. But in Haskell "return" doesn't actually exit the function. The function exits after the last operation regardless of the use of "return". The only thing "return" does is to "tag" a pure value so we can return it from an IO function:
somefun = do
putStrLn "What is your name?"
name <- getLine
let name2 = reverse name
putStrLn ("Olleh, " ++ name2)
return name2