« BackSerialization Is the Secretzachdaniel.devSubmitted by borromakot 3 days ago
  • gr4vityWall an hour 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 that model. The cognitive load seemed fairly higher as well.

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

    • LegionMammal978 3 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 3 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 2 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 2 minutes 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.

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

          • prerok 19 minutes 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 :)

            • whateveracct 9 minutes 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

              • greener_grass 16 minutes 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

              • akoboldfrying 4 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 3 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`.

                • nmadden 4 hours ago

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

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

                    • nmadden an hour 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.
                      • Izkata 4 hours ago

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

                        • kreetx 3 hours ago

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

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

                      • fracus 10 hours ago

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

                        • Closi 4 hours ago

                          So VB6 had it right all along?

                          • snapcaster 2 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 3 hours ago

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

                              • iamwil 2 hours ago

                                What are the other ways?

                                • avaldez_ 2 hours ago

                                  On error resume next /jk

                            • tromp 9 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 9 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 7 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 9 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 7 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 7 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 7 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 2 hours ago

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

                                        • kqr an hour 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 an hour ago

                                            Yes, that's true.

                                • chucky_z 9 hours ago

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

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

                                • mrkeen 10 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 9 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 10 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 5 hours ago

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

                                      • colonwqbang 10 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 3 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 10 hours ago

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

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

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

                                              • borromakot 5 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 4 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 3 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 2 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 5 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 4 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 4 hours ago

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

                                                      • nuancebydefault 3 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 an hour ago

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

                                                          Not really. Shadowing is compiler-warning in Haskell.