hansvm 21 minutes 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.

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

  • packetlost 13 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!

    • jerf 12 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.

      • toast0 10 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.

        • hinkley an hour 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.

        • jerf 10 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.)

      • LegionMammal978 12 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

        • dartos 11 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!

      • mrkeen 12 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.)

        • jerf 11 hours ago

          So, this is something I think we've learned since the 1990s as a community, and, well, it's still not widely understood but: The core reason mutability is bad is not the mutation, it is "unexpected" mutation. I scare quote that, because that word is doing a lot of heavy lifting, and I will not exactly 100% nail down what that means in this post, but bear with me and give me some grace.

          From a the perspective of "mutability", how dangerous is this Python code?

              x = 1
              x = 2
              print(x)
          
          Normally little snippets like this should be understood as distilled examples of a general trend, but in this case I mean literally three lines. And the answer is, obviously, not at all. At least from the perspective of understanding what is going on. A later programmer reading this probably has questions about why the code is written that way, but the what is well in hand.

          As the distance between the two assignments scales up, it becomes progressively more difficult to understand the what. Probably everyone who has been in the field for a few years has at some point encountered the Big Ball Of Mud function, that just goes on and on, assigning to this and assigning to that and rewriting variables with wild abandon. Mutability makes the "what" of such functions harder.

          Progressing up, consider:

              x = [1]
              someFunction(x)
              print(x)
          
          In Python, the list is mutable; if someFunction appends to it, it will be mutated. Now to understand the "what" of this code you have to follow in to someFunction. In an immutable language you don't. You still need to know what is coming out of it, of course, but you can look at that code and know it prints "[1]".

          However, this is still at least all in one process. As code scales up, mutation does make things harder to understand, and it can become hard enough to render the entire code base pathologically difficult to understand, but at least it's not as bad as this next thing.

          Concurrency is when mutation just blows up and becomes impossible for humans to deal with. Consider:

             x = [1]
             print(x)
          
          In a concurrent environment where another thread may be mutating x, the answer to the question "what does the print actually print?" is "Well, anything, really." If another thread can reach in and "directly" mutate x, at nondeterministic points in your code's execution, well, my personal assertion is nobody can work that way in practice. How do you work with a programming language where the previous code example could do anything, and it will do it nondeterministically? You can't. You need to do something to contain the mutability.

          The Erlang solution is, there is literally no way to express one actor reaching in to another actor's space and changing something. In Python, the x was a mutable reference that could be passed around to multiple threads, and they all could take a crack at mutating it, and they'd all see each other's mutations. In languages with pointers, you can do that by sharing pointers; every thread with a pointer has the ability to write through the pointer and the result is visible to all users. There's no way to do that in Erlang. You can't express "here's the address of this integer" or "here's a reference to this integer" or anything like that. You can only send concrete terms between actors.

          Erlang pairs this with all values being immutable. (Elixir, sitting on top of BEAM, also has immutable values, they just allow rebinding variables to soften the inconvenience, but under the hood, everything's still immutable.) But this is overkill. It would be fine for an Erlang actor to be able to do the equivalent of the first example I wrote, as long as nobody else could come in and change the variable unexpectedly before the print runs. Erlang actors tend to end up being relatively small, too, so it isn't even all that hard to avoid having thousands of variables in a single context. A lot of Erlang actors have a dozen or two variables tops, being modified in very stereotypical manners through the gen_* interfaces, so having in-actor truly mutable variables would probably have made the language generally easier to understand and code in.

          In the case of OO, the "direct mutation" problem is related to the fact that you don't have these actor barriers within the system, so as a system scales up, this thing "way over there" can end up modifying an object's value, and it becomes very difficult over time to deal with the fact that when you operate that way, the responsibility for maintaining the properties of an object is distributed over the entire program. Technically, though, I wouldn't necessarily chalk this up to "mutability"; even in an immutable environment distributing responsibility for maintaining an object's properties over the entire program is both possible and a bad idea. You can well-encapsulated mutation-based objects and poorly-encapsulated immutable values. I'd concede the latter is harder than the former, as the affordances of an imperative system seems to beg you to make that mistake, but it's certainly possible to accidentally distribute responsibilities incorrectly in an immutable system; immutability is certainly not a superset of encapsulation or anything like that. So I'd class that as part of what I mentioned in this post before I mentioned concurrency. The sheer size of a complex mutation-based program can make it too hard to track what is happening where and why.

          Once you get used to writing idiomatic Erlang programs, you contain that complexity by writing focused actors. This is more feasible than anyone who hasn't tried thinks, and is one of the big lessons of Erlang that anyone could stand to learn. It is then also relatively easy to take this lesson back to your other programming languages and start writing more self-contained things, either actors running in their own thread, or even "actors" that don't get their own thread but still are much more isolated and don't run on the assumption that they can reach out and directly mutate other things willy-nilly. It can be learned as a lesson on its own, but I think one of the reasons that learning a number of languages to some fluency is helpful is that these sorts of lessons can be learned much more quickly when you work in a language that forces you to work in some way you're not used to.

          • mrkeen 7 hours ago

            > In the case of OO, the "direct mutation" problem is related to the fact that you don't have these actor barriers

            Right, the OO guys said to use "encapsulation" rather than direct mutation.

            > so as a system scales up, this thing "way over there" can end up modifying an object's value

            Can you not send a message way over there?

            • asa400 5 hours ago

              You're correct that any process can more or less send a message to any other process, but the difference is what guarantees the Erlang runtime provides around that idea.

              For example, in Erlang, if I have processes A, B, and C, and B and C both send messages to A at the same time, the runtime guarantees that A processes the messages one at a time, in order, before moving on to the next message (there is some more detail here but it is not important to the point).

              The runtime guarantees that from A's perspective, the messages from B and C cannot arrive "simultaneously" and trample on each other. The runtime also guarantees that A cannot process both messages at the same time. It processes the messages one at a time. All code in A is run linearly, single-threaded. The VM takes care of scheduling all of these single-threaded processes to run on the same hardware in parallel.

              As other posters have pointed out, the runtime also guarantees that B and C cannot reach in and observe A's raw memory in an uncontrolled fashion (like you could in C, Java, etc.), so B and C cannot observe any intermediate states of A. The only way for B and C to get any information out of A is to send a message to A and then A can send a reply, if it wants. These replies are just normal messages, so they also obey all of the guarantees I've already described, so A will send the replies one at time, and they will end up in the mailboxes of B and C for their own processing.

              Given all this (and more which I haven't gone into), Erlang doesn't have the concept of a data race where 2 or more threads are concurrently accessing the same memory region, as you might have in say, the C language (note that this is different than a logical race condition, which Erlang of course still can have).

              I hope this is useful, you're asking good questions.

          • AnimalMuppet 8 hours ago

            I've run into something like this when working on embedded systems using OO. You have persistent mutable data, you have an object that encapsulates some data, you have multiple sources of control that each have their own threads that can modify that data, and you have consistency relationships that have to be maintained within that data.

            The way you deal with that is, you have the object defend the consistency of the data that it controls. You have some kind of a mutex so that, when some thread is messing with certain data, no other thread can execute functions that mess with that data. They have to wait until the first thread is done, and then they can proceed to do their own operations on that data.

            This has the advantage that it puts the data and the protection for the data in the same place. Something "way over there" can still call the function, but it will block until it's safe for it to modify the data.

            (You don't put semaphores around all data. You think carefully about which data can be changed by multiple threads, and what consistency relationships that could violate, and you put them where you need to.)

            Is that better or worse than Erlang's approach? Both, probably, depending on the details of what you're doing.

            • jerf 8 hours ago

              That's possibly what I meant by the "actors that don't get their own thread" at the very end. I've switched away from Erlang and write most of my stuff in Go now, and while I use quite a few legit "actors" in Go, that have their own goroutine, I also have an awful lot of things that are basically "actors" in that they have what is effectively the same isolation, the same responsibilities, the same essential design, except they don't actually need their own control thread. In Erlang you often just give them one anyhow because it's the way the entire language, library, and architecture is set up anyhow, but in Go I don't have to and I don't. They architecturally have "one big lock around this entire functional module" and sort of "borrow" the running thread of whoever is calling them, while attaining the vast majority of benefits of an actor in their design and use.

              If you have an "actor", that never does anything on its own due to a timer or some other external action, that you never have to have a conversation with but are interacting with strictly with request-response and aren't making the mistake someone discussed here [1], then you can pretty much just do a One Big Lock and call it a day.

              I do strictly follow the rule that no bit of code ever has more than one lock taken at a time. The easiest way to deal with the dangers of taking multiple locks is to not. Fortunately I do not deal in a performance space where I have no choice but to take multiple locks for some reason. Though you can get a long way on this rule, and building in more communication rather than locking.

              [1]: https://news.ycombinator.com/item?id=41722440

            • mrkeen 7 hours ago

              > You don't put semaphores around all data

              You're talking about putting semaphores around code.

              Locking data, not code, is a great way to do things. It composes and you don't run into too-many-locks, too-few-locks, forgetting-to-take-a-lock, or deadlocking problems.

              https://www.adit.io/posts/2013-05-15-Locks,-Actors,-And-STM-...

  • p1necone 3 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.

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

  • toast0 10 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.

    • Nevermark 5 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.

    • gr4vityWall 9 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.

      • toast0 9 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. :)

  • azeirah 11 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".

    • gr4vityWall 9 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.

    • cpill 8 hours ago

      Hey, and hence the rampant adoption of Haskell :P

  • hinkley an hour 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.

  • rkangel 10 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.

    • gr4vityWall 9 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.

      • rkangel 8 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.

  • jimbokun 9 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.

    • gr4vityWall 8 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?

      • jimbokun 6 hours ago

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

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

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

Closi 14 hours ago

So VB6 had it right all along?

  • snapcaster 12 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

  • vanderZwan 13 hours ago

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

    • iamwil 13 hours ago

      What are the other ways?

      • mappu 3 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_ 12 hours ago

        On error resume next /jk

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

  • borromakot 14 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`.

prerok 11 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_grass 10 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

  • steveklabnik 9 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.

    • prerok 9 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...

  • munificent 10 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.

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

  • heeton 10 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.

    • prerok 9 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.

      • samatman 8 hours ago

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

nmadden 15 hours ago

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

  • xavxav 14 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.

    • Izkata 14 hours ago

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

      • kreetx 13 hours ago

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

    • nmadden 12 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.
      • tonyg 9 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
          ...
        • nmadden 8 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.

  • jerf 13 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.

tromp 19 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?

  • finder83 19 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]
    • tromp 17 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]
      ?
  • torginus 19 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?

    • kqr 18 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.)

      • eru 18 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.

        • kqr 17 hours ago

          Oh, yeah, it's right there in the standard library. But one has to be a little more explicit about the fact that one is accessing truly mutable variables. For example, given the helper utilities

              checkIORef r p = fmap p (readIORef r)
              usingIORef r a = readIORef r >>= a
          
          the example can be written as

              main = do
                i <- newIORef (0 :: Int)
                whileM_ (checkIORef i (< 5)) $ do
                  modifyIORef i (+1)
                  usingIORef i (printf "i: %d\n")
          
          That said, even if one actually needs to mix mutable variables with I/O actions like printing, I'm not sure I would recommend using IORefs for it. But opening the can of MonadIO m => StateT Int m () is for another day.
          • eru 12 hours ago

            You can also use the State monad, instead of IORef.

            • kqr 12 hours ago

              Not while printfing. That is what takes a MonadIO m => StateT s m () transformer, or at least a stack with a Writer in it.

              • eru 12 hours ago

                Yes, that's true.

fracus 20 hours ago

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

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

  • bux93 19 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.

  • finder83 20 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.

    • borromakot 15 hours ago

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

  • colonwqbang 20 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.

  • borromakot 13 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.

sailorganymede 20 hours ago

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

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

  • borromakot 15 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.

    • cies 14 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.

      • borromakot 13 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...
        • cies 12 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!

  • nuancebydefault 15 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.

    • cies 14 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.

    • cies 14 hours ago

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

      • nuancebydefault 13 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.

        • mrkeen 12 hours ago

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

          Not really. Shadowing is compiler-warning in Haskell.

  • mrkeen 12 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.