Managed effects, Not purity

On managing effects in programming languages

I like haskell. It's a pure functional programming language, which means that everything you interact with is explicit (except for some escape hatches).

In practice this means that when you want to interact with the outside world, you need to go through the IO monad, like so:

main :: IO ()
main = do
  -- Read from stdio
  str <- getLine
  -- and write
  putStrLn $ "Hello " ++ str

But this also extends to other things. We can't mutate variables, so instead we need to go through another route.

One of these routes is monads. Haskell's do-notation provides some nice syntax sugar to make this easier, and makes it look more like an imperative language.

Under the hood, these monads look roughly like this:

type StateMonad state result = state -> (state, result) 

This means that whenever we want to do something on the state, we run a function that takes the current state, and return the new state, and any result that came out of it.

Under the hood, haskell rewrites the do-notation using the >> and >>= functions to chain the operations on the state.

Guarantees

This provides you with several guarantees: Variables and functions won't suddenly change without you knowing, and when you do opt in to do-notation, it's easy to reason about.

In contrast, other languages like Javascript, Python, C# don't do this at all. Because objects are pointers, calling a function can change any variable on any object without warning, making it a lot harder to reason about what will happen.

Tradeoff

While haskell mostly resolves this issue, it does so by forcing a very specific style of programming, which is not ideal, and can very much get in the way of usability and prototyping speed. For IO specifically, haskell provides performUnsafeIO, which allows performing IO functions and retrieving their result without access to the IO monad, which is not possible in pure haskell.

Rust takes another approach. Instead of requiring no mutation and outside effects at all, it uses another way to ensure when you intend things to change: the borrow checker.

The borrow checker

The borrow checker is conceptually simple: immutable references can be shared freely, and mutable references can only be shared to one thing at a time.

fn add_item(v: &mut Vec<T>, i: &T) {
  v.push(i.clone());
}

This means that in the above example, i can be shared whenever, while v can only be shared with this function, and thus can't be referenced by any other structs.

This avoids needing to use monads at the cost of implementation complexity, but makes it easier for the programmer if they come from imperative languages.

Rust also makes copying explicit, which helps implementing collections such as Vec efficienly, as copying these is rather expensive without a copy on write mechanism. Haskell still requires a garbage collector for this.

Escape hatches

Of course, this still imposes significant restrictions on what programs you can write.

To partially bypass the borrowchecker, rust introduces Cell, which allows modification even if you have an immutable reference.

RefCell goes even further, by doing the borrowchecking at runtime.

Both of these allow you to do the same 'change arbitrary values whenever' that Javascript, Python and C# provide, with one difference: It's opt-in.

Be explicit!

It is quite easy to write like rust in these languages as a result, as they are not nearly as different as haskell. Doing this makes it significantly easier to reason what's going on, but sadly it's still possible to make mistakes normally caught by the borrow checker.

Both rust and haskell do provide a better developer experience because of this, as when things go wrong due to arbitrarily changing values, you will likely know where this goes wrong, as it's opt-in instead of always on.