I like the idea here, but it's a lot further from Haskell than the author implies.
IO expressions in Haskell are values, and that's really important to understand what's going on. It's the reason why things like `timeout :: Int -> IO a -> IO (Maybe a)' can exist. It's not a function marked as being impure somehow. It's a function that takes an IO value and returns an IO value. It is still a pure function. Pass in the same arguments, you get the same value back. Executing the resulting IO action multiple times may do vastly different things each time, but the important part is that executing IO actions is fully independent of evaluating functions.
(Note that monads are completely irrelevant to the above. What makes IO in Haskell different and a big hurdle to learn is that it turns interaction with the world into values that can exist without being executed.)
This proposal... It doesn't let you pass IO values around without executing them. At least it doesn't in its current form. It's not really first-class IO. It's explicit IO, as it eventually calls out. It would be an interesting experiment, but it's a lot less expressive than what Haskell does.
First-class values are values which you can pass as arguments, return as return values, and hold in local variables. Values of type `File` or `TcpStream` are first-class in this sense. They show up in function signatures in the same way as any other first-class values, which is what this blog post is interested in.
This is independent of whether the actual I/O is done via mutation/side-effects/execution/etc. or monads/purity/referential-transparency/etc.
I think this is a quite effective approach and I hope it is used more. In some sense it's just good coding style, which lets you do effect-system-style reasoning in any language.
Can someone tell me a positive reason to do I/O with monads or try to make it pure-functional?
It's always smelled like zealotry to me -- I/O is, definitionally, about creating a side-effect, and papering over that seems to add complexity and unnecessary concepts before eventually just calling the same libc functions anyways at the bottom of the stack.
The important thing is that it separates the order of side-effects from the order of evaluation of expressions. This isn't a particularly important distinction in an imperative language, but in a functional language when you introduce things like laziness and memoization, the order in which expressions will be evaluated is not obvious.
Because pure I/O preserves referential transparency, it gives the compiler far more ability the optimize the code by changing how, when and whether things are evaluated, while still being able to prove that it has not modified the external behaviour of the program.
Haskell doesn't use pure I/O out of zealotry - without it, Haskell couldn't and be lazy-by-default and the compiled code would probably be much slower.
Thank you, that's a great, practical answer. "We have to do this to make lazy evaluation, memoization, etc work" is very sensible and not ideological at all. That makes sense.
Let's separate two related but distinct concepts here: (1) effect tracking, and (2) monadic IO.
---
The concept you're asking about is called "effect tracking", and the argument for effect tracking is very similar to the argument for static types.
In a world without static types, functions can be passed values of any type, and can return values of any type. Adding static types makes this code easier to reason about because we can statically enforce (i.e. enforce without needing any runtime checks) that functions only take values of a certain type or return values of a certain type. This makes reasoning about the code easier because it limits the possible things that a function can take or return, so instead of thinking about "what happens if I provide any kind of value to this function?", you can think "I know this function takes an integer, so I only need to worry about understanding how it behaves when provided something that is an integer".
In a world without effect tracking (this is most programming languages today), functions can perform any side effect. Adding effect tracking makes this code easier to reason about because we can statically enforce which side effects a function does. For example, effect tracking makes it possible to express "this function takes a callback function as an argument, but that callback argument is not allowed to print anything". Alternatively, you can also look at a function and know for a fact that might do IO, or that it doesn't do IO. When you're debugging, this helps narrow down the scope of places that could be doing something wrong, because now you'll never have a function that _looks_ like it's doing something innocuous, but is secretly writing a log statement to a file, or pinging an API over the network, or drawing something to your GUI window.
---
Monads are a way to _implement_ effect tracking within an existing type system, rather than inventing a separate, orthogonal "effect tracking system". This allows us to reuse a lot of the existing tools and theories that we understand about types and type theory, and apply them to how we do effect tracking.
Exactly how this implementation works is another topic that gets a bit involved. But note: (1) there are ways to do effect tracking that are _not_ monads, or that do _not_ integrate with the type system that the language uses for values; and (2) monads have applications that are _not_ effect tracking.
I'd like to add to the other replies that first-class i/o is less about "papering over" side effects, and more about reining in exactly where side-effects can occur, the kinds of side effects that are allowed, and requiring a full enumeration of possible outcomes (resource exhaustion, you don't have permission, etc). Kind of like checked exceptions, in a way.
Scroll past the Haskell example for a moment and look at the Python. The program p2 is the same as p1, except a refactoring is performed: the call to readFile(file) is DRY'd into a variable, and this changes the meaning of the program.
In the Haskell example, the same refactoring is performed. Because IO actions are treated as values in their own right, the refactoring does not change the meaning of the program. Executing p1 or p2 will have the same effect.
> A possible direction for future exploration would be to use the rustc driver API to create a custom static analysis tool that could recognize some form of syntax for this and then check that "explicit" functions don't accidentally call "non-explicit" functions without explicit overrides, much like unsafe.
By “some form of syntax”, it could be as simple as an attribute. Those are easily consumable by rustc plugins* or even proc-macros.
#[explicit]
fn my_explicit_fn() { ... }
If traits could be implemented on functions, you could even pull it off with a proc-macro-derive: no need to use a custom rustc binary for that. Your proc-macro just scans all the function calls made within said function and then adds something like:
unsafe impl Explicit for my_explicit_fn
where
fn_called_from_my_explicit_fn: Explicit,
other_fn_called_from_my_explicit_fn: Explicit
{}
You’d need to enable the trivial trait bounds feature for something like the above to work.
Cool ideas. I’d love to see rust become flexible enough that people are able to experiment with more things like this without having to hack at rustc’s source code.
* outdated terminology, but I think conceptually you still do this sort of thing with a plugin-like system.
IO expressions in Haskell are values, and that's really important to understand what's going on. It's the reason why things like `timeout :: Int -> IO a -> IO (Maybe a)' can exist. It's not a function marked as being impure somehow. It's a function that takes an IO value and returns an IO value. It is still a pure function. Pass in the same arguments, you get the same value back. Executing the resulting IO action multiple times may do vastly different things each time, but the important part is that executing IO actions is fully independent of evaluating functions.
(Note that monads are completely irrelevant to the above. What makes IO in Haskell different and a big hurdle to learn is that it turns interaction with the world into values that can exist without being executed.)
This proposal... It doesn't let you pass IO values around without executing them. At least it doesn't in its current form. It's not really first-class IO. It's explicit IO, as it eventually calls out. It would be an interesting experiment, but it's a lot less expressive than what Haskell does.