Back

Serialization Is the Secret

262 points4 dayszachdaniel.dev
hansvm2 hours ago

> The first is an actual mutation of memory. The second is an allocation of new memory. The old memory is unaffected. One could argue that this is a form of mutation. This mutation, however, is syntactic.

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.

LegionMammal97815 hours ago

I'd note that 'immutability everywhere' isn't the only way to solve the issue of uncontrolled observation of mutations, despite that issue often being cited as a justification. You can also design a language to just directly enforce static restrictions on who may mutate a referenced value and when. Rust with its aliasing rules is easily the most famous implementation of this, but other languages have continued to experiment with this idea.

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.)

packetlost14 hours ago

I think having some sort of structured mutability is a very, very good idea. Look at Clojure's atoms and transient data-structures for some other ways that this has been done. There's probably others, I'd love to see more examples!

jerf14 hours ago

The historically-ironic thing to me is that Erlang/BEAM brushed up against the idea and just didn't quite get it. What's important to the properties that Erlang maintains is that actors can't reach out and directly modify other actor's values. You have to send messages. It is sufficient to maintain this properly that you can't send references in messages, and it is sufficient to maintain that property to simply not have references, which Erlang and BEAM do not. Full immutability is sufficient but not necessary.

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.

toast012 hours ago

I think you're right that "interior immutability" of actors isn't really necessary to the programming model that you get from requiring message passing between actors.

However, interior immutability is not without its benefits. It enables a very simple GC. GC is easily done per-actor because each actor has independent, exclusive, access to its own memory. But the per-actor GC is very simple because all references are necessarily backwards in time, because there's no way to update a reference. With this, it's very simple to make a copying GC that copies any active references in order; there's no need for loop checking, because loops are structurally impossible.

I don't know that this was the intent of requiring immutability, but it's a nice result that pops out. Today, maybe you could pull in an advanced GC from somewhere else that already successfully manages mutable data, but these were not always available.

Of course, it should be noted that BEAM isn't entirely immutable. Sometimes it mutates things when it knows it can get away with it; I believe tuples can be updated in some circumstances when it's clear the old tuple would not be used after the new one is created. The process dictionary is direct mutable data. And BIFs, NIFs, and drivers aren't held to strict immutability rules either, ets has interior mutability, for example.

hinkley2 hours ago

It took me a long time when implementing Norvig's Sudoku solver to realize those one-way pointers were going to force me to do something very different to implement this code with immutability.

Norvig's secret sauce is having 3 different projections of the same data, and that involves making updates in one part of a graph that are visible from three entry points.

I'm sure there are other solutions but mine didn't settle down until I started treating the three views as 3 sets of cell coordinates instead of lists of cells.

jerf11 hours ago

"Of course, it should be noted that BEAM isn't entirely immutable."

Mutability is relative to the layer you're looking at. BEAM is, of course, completely mutable from top to bottom because it is constantly mutating RAM, except, of course, that's not really a helpful way of looking at it, because at the layer of abstraction you program at values are immutable. Mutatable programs can be written in terms of immutable abstractions with a well-known at-most O(n log n) penalty, and immutable programs can be written on a mutable substrate by being very careful never to visibly violate the abstraction of immutability, which is good since there is (effectively for the purposes of this conversation) no such thing as "immutable RAM". (That is, yes, I'm aware of WORM as a category of storage, but it's not what this conversation is about.)

LegionMammal97813 hours ago

IIRC, Rust's idea of controlled mutability originally came directly from the Erlang idea of immutable messages between tasks. Certainly, in the classic "Project Servo" presentation [0], we can see that "no shared mutable state" refers specifically to sharing between different tasks. I think it was pretty early on in the project that the idea evolved into the fine-grained aliasing rules. Meanwhile, the lightweight tasks stuck around until soon before 1.0 [1], when they were abandoned in the standard library, to be later reintroduced by the async runtimes.

[0] http://venge.net/graydon/talks/intro-talk-2.pdf

[1] https://rust-lang.github.io/rfcs/0230-remove-runtime.html

dartos13 hours ago

I have mixed feelings about rust’s async story, but it is really nice having good historical documentation like this.

Thanks for the links!

mrkeen13 hours ago

> What's important to the properties that Erlang maintains is that actors can't reach out and directly modify other actor's values. You have to send messages.

I just cannot make this mental leap for whatever reason.

How does 'directly modify' relate to immutability? (I was sold the lie about using setters in OO a while back, which is also a way to prevent direct modification.)

+2
jerf13 hours ago
p1necone4 hours ago

Yeah rust totally changed my opinion on mutability. When I first started using it I thought that the lack of explicitly immutable struct members was a glaring flaw in the language, but it turns out the ability to choose between ownership, reference and mutable reference at function/method callsites is just as useful for ensuring correctness.

gr4vityWall12 hours ago

This article is exceptionally well written. The author did a good job at making the subject approachable.

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.

toast011 hours ago

I think understandable can mean different things here. If we're looking at a state dump and your data is in a weird place, I'd want to understand how the data got into that weird place.

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.

Nevermark6 hours ago

Well said.

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.

gr4vityWall11 hours ago

That's an interesting point of view, thanks for taking the time to write that.

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.

toast010 hours ago

> I wonder how much that learning curve is worth it.

It really depends on how well your application fits the model. If it's a stretch to apply the model and you're already comfortable with something else, it might not be worth it. But if you're building a chat server, or something similar, I think it's very worthwhile. I think it can be pretty useful if you have a lot of complex state per user, in general, as your frontend process could send process inbound requests into messages into a mailbox where each user would only be processed by a single actor (each user doesn't need their own actor, you could hash users in some way to determine which actor handles a given user). Then you have a overall concurrent system, but each user's complex state is managed in a single threaded manner.

I think process/actor per connection is a superior mental model to explicit event loops for something like a large HTTP server, but unless the server is performing complex work within it, I would assume without testing that the explicit event loop would win on performance. I think it would be more fun to build a large HTTP server in Erlang than with c and kqueue, but nginx and lighttpd are already written, and quite fast. When I needed to do a TCP proxy for million + connections, I took HAProxy and optimized it for my environment, rather than writing a new one in Erlang; that was still fun, but maybe different fun; and being able to handle tens of thousands of connections with minutes of configuration work was a better organization choice than having fun building a proxy from scratch. :)

azeirah12 hours ago

> 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 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".

gr4vityWall11 hours ago

> Of course it's not as understandable to someone who's not used to it.

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.

cpill9 hours ago

Hey, and hence the rampant adoption of Haskell :P

hinkley2 hours ago

People seem to do okay figuring out concurrency in programming languages with global interpreter locks. At some point you know actions are happening serially, so you have to worry less about having to update three pieces of state to finish a task because unless you start a side quest in the middle of the calculation, any other code will either see the Before or After state, not any of the 2 intermediate states.

rkangel11 hours ago

> 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.

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.

gr4vityWall10 hours ago

Question: do you know any of such more complex systems which are Free Software, so that I could take a look?

Sounds like it would be a fun learning experience.

rkangel9 hours ago

I'd love to point you at some code from work but it's all closed source unfortunately.

One example that gets used is https://github.com/hexpm/hex which is the code behind the Elixir package management website. It's more a good example of a Phoenix app than it is general OTP stuff, but there are some GenServers in there.

jimbokun11 hours ago

> that's a mental model for programs that goes against how most people I know reason about code.

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.

gr4vityWall9 hours ago

> the mental model most of us have for reasoning about code in environments with concurrent execution is simply wrong.

Could you elaborate on that?

jimbokun7 hours ago

It is very difficult to write thread based code with no bugs.

evilotto3 hours ago

The talk about immutabilty and serialization makes me think of tcl, where "everything is a string" also means "everything is serializable" and you don't copy references around, you copy strings around, which are immutable. What's neat is that it always looks like you are just copying things, but you can mutate them, just so long as you can't possibly tell that you have mutated them, meaning that the old value isn't visible from anywhere. With the caveat that sometimes you have to jump through hoops to mutate something.

skybrian11 hours ago

I can see this being useful for the same reason that it's useful that non-async functions in JavaScript can't be interrupted - it's as if you held a lock for the entire function call.

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.

Closi15 hours ago

So VB6 had it right all along?

snapcaster13 hours ago

i loved VB6 (our school intro programming class used it) and i sometimes wonder how much influence that had on my future career choices. It was a programming environment that felt fun, powerful and not scary at all

vanderZwan15 hours ago

In more ways than one, I've been told.

iamwil14 hours ago

What are the other ways?

mappu4 hours ago

Some of the things I liked about VB6 that are not widely done well today, IMO, are (A) the RAD GUI builder (B) small, native binaries (C) deeply integrated, language-agnostic RPC framework

avaldez_13 hours ago

On error resume next /jk

akoboldfrying16 hours ago

In most other languages, your newScore() example would indeed race as you claim, but in JS it actually won't. JS uses a single-threaded event loop, meaning asynchronous things like timers going off and keys being pressed pile up in a queue until the currently executing call stack finishes, and are only processed then.

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...

borromakot15 hours ago

Gross :)

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`.

prerok12 hours ago

What I don't understand about immutability is performance. How do these languages achieve small memory footprints and avoiding continuous allocations of new object versions, because a single property changed?

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 :)

greener_grass12 hours ago

1. Structural sharing - most data doesn't change so you create a copy that reuses much of the original structure

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

steveklabnik11 hours ago

Just because something is semantically a copy doesn't mean that they will not be optimized out. Take a look at this example: https://godbolt.org/z/roxn43eMc

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.

prerok10 hours ago

Thank you!

Yeah, I will have to take a closer look at just why the copy elision isn't happening in the cases I looked at...

munificent11 hours ago

> The production Rust code I did see, is passing copies of objects left and right.

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.

sroussey9 hours ago

I too come from a time of worrying about memory access patterns and usage (pre virtual memory in hardware), so my initial reaction is like the parent comment, at least instinctively.

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.

whateveracct12 hours ago

"Purely Functional Data Structure" by Chris Okasaki is a classic and imo a must-read data structures book.

https://www.cs.cmu.edu/~rwh/students/okasaki.pdf

heeton12 hours ago

Small example to show that performance can be great: Phoenix (the Rails-comparable web framework for Elixir) defaults to listing microseconds instead of milliseconds for response times.

prerok11 hours ago

Hmm, is rails really comparable to anything that's precompiled (genuine question, I don't mean this dismissively).

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.

samatman9 hours ago

Ruby/Rails and Elixir/Phoenix both run on a garbage-collected virtual machine. I think that makes the comparison fair.

nmadden16 hours ago

Rebinding is nicer than mutation, but neither are referentially transparent.

xavxav16 hours ago

What do you mean? let-bindings don't interfere with referential transparency. `let x = 1 in let x = 2 in foo` is referentially transparent.

Izkata15 hours ago

I think you're thinking of shadowing, not re-binding.

kreetx14 hours ago

Yup, as a Haskeller, it's important to remember that rebinding means something else in other languages.

nmadden13 hours ago

The example given in the article is:

    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.
tonyg11 hours ago

It's a straightforward syntactic transformation. The two are equivalent. The scope of the rebound variable begins at the rebinding and ends when the surrounding scope ends. Perfectly clear - the only difference is a "let" keyword.

  counter = 0
  ...
  counter = counter + 1
  ...
vs

  let counter = 0 in
  ...
  let counter = counter + 1 in
  ...
nmadden9 hours ago

The ellipses in your straightforward transformation are doing some heavy lifting there. Typically the let…in construct has some way to indicate where the scope of the “in” part ends: indentation (Haskell), explicit “end” marker (SML) etc. Even with that, shadowing does make equational reasoning harder (you have to look at more surrounding context) and should generally be avoided.

jerf15 hours ago

Referential transparency is not a property of Elixir or Erlang anyhow. In Haskell terms, everything is always in IO. So this doesn't seem particularly relevant.

tromp20 hours ago

> One of the major elements that sets Elixir apart from most other programming languages is immutability.

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?

finder8320 hours ago

Infinite, yes, but I would say it's not quite as core to the language as it is in Haskell where everything's lazy. Infinite streams are quite simple though:

  Stream.iterate(1, fn(x) -> x end) 
  |> Enum.take(5)
  [1, 1, 1, 1, 1]
tromp18 hours ago

How do you use that for lists that do not simply iterate, like

    ghci> fibs = 0 : 1 : zipWith (+) fibs (drop 1 fibs)
    ghci> take 10 fibs
    [0,1,1,2,3,5,8,13,21,34]
?
darcien17 hours ago

You can use Stream.unfold/2:

  Stream.unfold({0,1}, fn {a,b} -> {a,{b,a+b}} end)
  |> Enum.take(10)
  [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
https://rosettacode.org/wiki/Fibonacci_sequence#Elixir
torginus20 hours ago

I'm a bit confused - isn't this how all Static Single Assignment representations in compilers work? And those are used in things like LLVM IR to represent C and C++ code. Is C++ immutable now?

kqr19 hours ago

The difference at the high level is that assigning a variable creates a new scope. E.g. in C I would expect to be able to

    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.)

eru19 hours ago

Btw, Haskell also supports mutable re-assignment of variables. But it's not something that's built into the language, you get mutable variables via a library. Just like you can get loops in Haskell via a library.

+1
kqr18 hours ago
chucky_z20 hours ago

https://hexdocs.pm/elixir/Stream.html

Although I don’t think it’ll be quite as elegant as the Haskell code.

fracus22 hours ago

This was very enlightening for me on the subject of immutability.

mrkeen22 hours ago

> I would argue that, from the perspective of our program, it is not more or less mutable than any other thing. The reason for this, is that in Elixir, all mutating state requires calling a function to observe it.

Are you never not inside a called function?

This just sounds like pervasive mutability with more steps.

bux9321 hours ago

I think the author means "I said everything is immutable, and rebinding is obviously changing something, but the thing it changes doesn't count!". The idea being, if you read a bunch of code, none of the variables in that piece of code can have the value of it changed unless there is some explicit line of code.

finder8321 hours ago

The functions don't return a mutable version of a variable or anything. You still get an immutable copy (it may not be an actual copy, I don't know the internals) of the state, and the state he's referencing in a Genserver is the current state of a running process that runs in a loop handling messages. For example in liveview, each connection (to an end-user) is a process that keeps state as part of the socket. And the editing is handled through events and lifecycle functions, not through directly mutating the state, so things tend to be more predictable in my experience. It's kind of like mutation by contract. In reality, it's more like for each mailbox message, you have another loop iteration, and that loop iteration can return the same value or a new value. The new values are always immutable. So it's like going from generations of variables, abandoning the old references, and using the new one for each iteration of the loop. In practice though, it's just message handling and internal state, which is what he means by "from the perspective of our program".

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.

borromakot17 hours ago

`send` is a function. `receive` is a special form but in this context it counts as a function

colonwqbang21 hours ago

It sounds like all old bindings to the value stay the same. So you have a "cell" inside which a reference is stored. You can replace the reference but not mutate the values being referred to.

If so, this sounds a lot like IORef in Haskell.

borromakot15 hours ago

I didn't mean you "must be inside of a function".

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.

sailorganymede21 hours ago

I really enjoyed reading this because it explained the topic quite simply. It was well written !

cies18 hours ago

I'm much stricter when it comes to what means immutable.

    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.

borromakot16 hours ago

The point wasn’t to encourage shadowing bindings from a parent scope, only to illustrate that it is not the same thing as mutable references. Whether to, and when to, use rebinding is a different conversation, but in some cases it can lead to subtle bugs just like you described.

cies15 hours ago

I still think the difference to me as a programmer is largely semantic (except when the performance has to be considered): "rebinding" or "in-place mutation", in both cases it is a mutable variable to me.

borromakot15 hours ago

There is a guarantee that you have when rebinding is the only option.

with rebinding:

    def thing() do
      x = 10
      do_something(x)
      # x is 10, non negotiably, it is never not 10
    end
with mutability

    def thing() do
      x = 10
      do_something(x)
      # who knows what x is? Could be anything.
    end
Additionally, because rebinding is a syntactic construct, you can use a linter to detect it: https://hexdocs.pm/credo/Credo.Check.Refactor.VariableRebind...
cies14 hours ago

I find this a much better example than the example shown in the article. I even find it quite useful when explained like this.

Thanks!

nuancebydefault17 hours ago

>Most of the IDEs I've worked with in the past 15 years warn me of 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.

cies15 hours ago

I'd say that rebinding a value to a variable is similar to shadowing IN THE SAME SCOPE. Pretty much what Haskell does with the do-notation.

cies15 hours ago

I find FP and shadowing have nothing to do with eachother (maybe except though Haskell's do-notation)

nuancebydefault15 hours ago

You have a point. Maybe the shadowing is a paradigm on itself, supporting FP. If you know it happens all the time, and you know every var lives in the current scope, all you have to care about is the current scope. Just like in C function-local-vars (which might or not shadow globals) , but then applied to every code block.

mrkeen13 hours ago

> Maybe the shadowing is a paradigm on itself, supporting FP.

Not really. Shadowing is compiler-warning in Haskell.

mrkeen13 hours ago

> Haskell's do-notation, which is basically syntactic sugar for overshadowing.

Do-notation does not relate to variable shadowing.

It's syntactic sugar over excessive flatmapping / pyramid of doom.