I disagree with this phrase:
> By forcing the mutation of state to be serialized through a process’s mailbox, and limiting the observation of mutating state to calling functions, our programs are more understandable
My experience is quite the opposite - that's a mental model for programs that goes against how most people I know reason about code.
The examples in Elixir all looked more complicated for me to understand, generally, although I understand the value brought by that model. The cognitive load seemed fairly higher as well.
In a model with state owned by a process, and changes coming in through the mailbox, I know that all of the state changes happened through processing of incoming messages, one at a time. If I'm lucky, I might have the list of messages that came in, and be able to run them one a time, but if not, I'll just have to kind of guess how it happened, but there's probably only a limited number of message shapes that are processed, so it's not too hard. There's a further question of how those messages came to be in the process's mailbox, of course.
In a model with shared memory mutability, it can be difficult to understand how an object was mutated across the whole program. Especially if you have errors in concurrency handling and updates were only partially applied.
There's certainly a learning curve, but I've found that once you've passed the learning curve, the serialized process mailbox model makes a lot of understanding simpler. Individual actors are often quite straight forward (if the application domain allows!), and then the search for understanding focuses on emergent behavior. There's also a natural push towards organizing state into sensible processes; if you can organize state so there is clear and independent ownership of something by a single actor, it becomes obvious to do so; it's hard to put this into words, but the goal is to have an actor that can process messages on that piece of state without needing to send sub-requests to other actors; that's not always possible, sometimes you really do need sub-requests and the complexity that comes with it.
The essence: understanding scales better with mutation serialization.
Any little bumps of complexity at the beginning, or in local code, pays off for simpler interactions to understand across longer run histories, increased numbers of processes, over larger code bases.
I wonder how much that learning curve is worth it. It reminds me of the Effect library for TypeScript, in that regard, but Elixir looks more readable to me in comparison.
> The examples in Elixir all looked more complicated for me to understand, generally, although I understand the value brought that model. The cognitive load seemed fairly higher as well.
Of course it's not as understandable to someone who's not used to it.
When I read articles about a different paradigm, I assume that "better" means "with equal experience as you have in your own paradigm"
So if someone says "this convoluted mess of a haskell program is better than js", I will say "yes, if you spent 100 hours in Haskell prior to reading this".
Sorry, I didn't mean to imply otherwise. Perhaps the original quote should make what you said ("with equal experience as you have in your own paradigm") explicit.
I do believe that the paradigm proposed in the article has a much higher learning curve, and expect it to not be adopted often.
I can see that might be the case with simple examples, but with more complex systems I find the cognitive load to be much lower.
The Elixir/Erlang approach naturally results in code where you can do local reasoning - you can understand each bit of the code independently because they run in a decoupled way. You don't need to read 3 modules and synthesise them together in your head. Similarly the behaviour of one bit of code is much less likely to affect the behaviour of other code unexpectedly.
Sounds like it would be a fun learning experience.
But the mental model most of us have for reasoning about code in environments with concurrent execution is simply wrong.
So the Elixir model is more understandable, if you want a correct understanding of what your code will do when you run it.
Could you elaborate on that?
I mean, all I see are small scale examples where there are only a few properties. The production Rust code I did see, is passing copies of objects left and right. This makes me cringe at the inefficacy of such an approach.
Disclaimer: I have 0 experience in immutable languages, hence the question :)
2. Garbage collection and lexical scopes - you clean up memory quickly and in batch
3. Compiler optimizations - you turn functional constructs into imperative ones that reuse and mutate memory at compile-time, where it is provably safe to do so
Roc Lang might interest you: https://www.youtube.com/watch?v=vzfy4EKwG_Y
While create() semantically copies the struct Foo out to its caller, with optimizations on, the function isn't even invoked, let alone a copy be made.
That said, of course sometimes this optimization is missed, or it can't happen for some reason. But my point is just that some Rust code may look copy heavy but is not actually copy heavy.
Yeah, I will have to take a closer look at just why the copy elision isn't happening in the cases I looked at...
What I really meant was, as a backend engineer, I frequently deal with optimizations on too many object allocations and long running/too frequent GC cycles even without immutability built into the language.
On the Rust front, the problem is in small memory allocations, fragmented memory and then more calls to kernel to alloc.
You might be surprised how fast memcpy() is in practice on modern hardware. It's worth sitting down and writing a little C program that moves memory around and does some other stuff to get a feel for what the real world performance is like.
And yes, memcpy is fast, but I would not use a little program to convince myself. You will end up with stuff in CPU caches, etc, which will give you a very incorrect intuition.
Better to take a large program where there is a base factory and make some copies there or something and see how it affects things.
That said… for most businesses these days, developer time is more expensive than compute time, so if you’re not shipping an operating system or similar, it simply doesn’t matter.
And an optimizing compiler could do something like copy on write, and make much of the issue moot.
I had a brief period of time designing a simple CPU and it’s made everything since turn my stomach a little bit.
So in effect, most immutable languages actually do copy by reference, it's just abstracted away for the programmer and you can reason about it as copy by value.
I can't find the article now but I remember reading about this and someone can probably link it.
If you are doing brute force calculations there are much faster languages out there.
counter = counter + 1
vs counter += 1
Are exactly the same to me. In both cases you bind a new value to counter: I don't care much if the value gets updated or new memory is allocated. (sure I want my programs to run fast, but I dont want to be too much worried about it, the compiler/interpreter/runtime should do that "good enough" most of the times)In the absence of type safety immutability --IMHO-- becomes a bit of a moot point. This is valid Elixir:
x = 10
y = 25
z = x + y
y = "yeahoo"
IO.puts "Sum of #{x} and #{y} is #{z}"
Trying to add another line "z = x + y" to the end, and you have a runtime error.The "feature" of Elixir that allows scoped rebinding to not affect the outer scope, looks frightening to me. Most of the IDEs I've worked with in the past 15 years warn me of overshadowing, because that easily leads to bugs.
Haskell was already mentioned. There we can see real immutability. Once you say a = 3, you cannot change that later on. Sure sometimes (in many programs this can be limited) you need it, and in those cases there's Haskell's do-notation, which is basically syntactic sugar for overshadowing.
Most IDEs adapt their rules for warnings to the file type.
As i understand it, Elixir leans more to the functional paradigm, so the rules are different. This different paradigm has the pros described in the article. Of course it also has cons.
If shadowing is a feature of the language, a feature that is often used, the programmer, who has shifted their thinking to that paradigm, knows that, and a warning is not needed.
Do-notation does not relate to variable shadowing.
It's syntactic sugar over excessive flatmapping / pyramid of doom.
It's interesting to compare Elixir to that other immutable programming language: Haskell.
In Elixir, a binding
counter = counter + 1
binds counter to the old value of counter, plus 1.
In Haskell it instead binds counter to the new value plus 1.Of course that doesn't make sense, and indeed this causes an infinite loop when Haskell tries to evaluate counter.
BUT it does make sense for certain recursive data structures, like an infinite list of 1s:
ones = 1 : ones
We can check this by taking some finite prefix: ghci> take 5 ones
[1,1,1,1,1]
Another example is making a list of all primes, where you don't need to decide in advance how many elements to limit yourself to.Can you define such lazy infinite data structures in Elixir?
Stream.iterate(1, fn(x) -> x end)
|> Enum.take(5)
[1, 1, 1, 1, 1] ghci> fibs = 0 : 1 : zipWith (+) fibs (drop 1 fibs)
ghci> take 10 fibs
[0,1,1,2,3,5,8,13,21,34]
? int i = 0;
while (i < 5) {
i = i+1;
printf("i: %d\n", i);
}
whereas in Haskell I could hypothetically something like let i = 0 in
whileM (pure (i < 5)) $
let i = i + 1 in
printf "i: %d\n" i
but the inner assignment would not have any effect on the variable referenced by the condition in the while loop – it would only affect what's inside the block it opens.(And as GP points out, i=i+1 is an infinite loop in Haskell. But even if it was used to build a lazy structure, it would just keep running the same iteration over and over because when the block is entered, i still has the value that was set outside.)
Although I don’t think it’ll be quite as elegant as the Haskell code.
Are you never not inside a called function?
This just sounds like pervasive mutability with more steps.
You typically wouldn't just write a Genserver to hold state just to make it mutable (though I've seen them used that way), unless it's shared state across multiple processes. They're not used as pervasively as say classes in OOP. Genservers usually have a purpose, like tracking users in a waiting room, chat messages, etc. Each message handler is also serial in that you handle one mailbox message at a time (which can spawn a new process, but then that new process state is also immutable), so the internal state of a Genserver is largely predictable and trackable. So the only way to mutate state is to send a message, and the only way to get the new state is to ask for it.
There's a lot of benefits of that model, like knowing that two pieces of code will never hit a race condition to edit the same area of memory at the same time because memory is never shared. Along with the preemptive scheduler, micro-threads, and process supervisors, it makes for a really nice scalable (if well-designed) asynchronous solution.
I'm not sure I 100% agree that watching mutating state requires a function to observe it. After all, a genserver can send a message to other processes to let them know that the state's changed along with the new state. Like in a pub-sub system. But maybe he's presenting an over-simplification trying to explain the means of mutability in Elixir.
If so, this sounds a lot like IORef in Haskell.
If you call `Process.put(:something, 10)`, any references you have to whatever was already in the process dictionary will not have changed, and the only way to "observe" that there was some mutating state is that now subsequent calls to `Process.get(:something)` return a different value than it would have before.
So with immutable variables, there is a strict contract for observing mutation.
IMO VB.NET was just disappointing - tried too hard to turn VB into something 'proper' with 'better standards' rather than just being what it was (the YOLO language that you could build scrappy things in fast)
counter = 0
counter = counter + 1
This is very different to shadowing where there is a clear scope to the rebinding. In this case, I cannot employ equational reasoning within a scope but must instead trace back through every intervening statement in the scope to check whether the variable is rebound.The big benefit is that you can still have all the usual optimizations and mental simplicity that depend on non-observability, while also not having to contort the program into using immutable data structures for everything, alongside the necessary control flow to pass them around. (That isn't to say that they don't have their use cases in a mutable language, especially around cross-thread data structures, but that they aren't needed nearly as frequently in ordinary code.)
Erlang was a hair's breadth away from having mutation contained within the actor's variable space, with no external mutation, which for the time would have been quite revolutionary. Certainly Rust's mutation control is much richer, but Rust came a lot later, and at least based on its current compile performance, wasn't even on the table in the late 1990s.
But the sort of understanding of mutability explained in the original post was not generally understood. Immutability was not a brand new concept chronologically, but if you define the newness of a computer science concept as the integration of its usage over time, it was still pretty new by that metric; it had been bouncing around the literature for a long time but there weren't very many programming languages that used it at the time. (And especially if you prorate "languages" by "how easy it is to write practical programs".)
Elixir does a reasonable job of recovering it from the programmer's perspective, but I think an Erlang/BEAM that just embraced mutability within an actor probably would have done incrementally better in the programming language market.
In your example, this means profile.score will remain at 3 every time. Interestingly this would still be the case even if setTimeout() was replaced with Promise.resolve(), since the sole "await" desugars to ".then(rest-of-the-function)", and handlers passed to .then() are always added to the job queue, even if the promise they are called on is already settled [0].
To fix this (i.e., introduce an actual race), it would be enough to add a single "await" sometime after the call to newScore(), e.g., "await doSomethingElse()" (assuming doSomethingElse() is async). That would cause the final "profile.score" line to appear in the job queue at some indeterminate time in the future, instead of executing immediately.
[0]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
But I will update my example in that case. Someone else mentioned this but I was waiting to hear back. I will alter the example to `await doSomethingElse`.
Between any two event handlers, anything could change. Similarly for await calls in JavaScript. And to get true parallelism, you need to start a separate worker. Concurrency issues can still happen, but not in low-level operations that don't do I/O.
I don't see anything wrong with mutating a local variable in cases when it's purely a local effect. It's sometimes cleaner, since you can't accidentally refer to an obsolete version of a variable after mutating it.
It's not purely syntactic. You aren't mutating the referent of a pointer. You're mutating the referent of a scoped variable name. That saves you from certain swathes of bugs (in concurrent code), but it's still something you want to use sparingly. Reasoning about any kind of mutability, even in single-threaded code, isn't the easiest thing in the world.