14 ms·
Serialization Is the Secret
- fracus 2y agoThis was very enlightening for me on the subject of immutability.
- mrkeen 2y 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.
- colonwqbang 2y agoIt 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.
- finder83 2y agoThe 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 2y ago`send` is a function. `receive` is a special form but in this context it counts as a function
- bux93 2y agoI 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.
- borromakot 2y agoI 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 2y agoI really enjoyed reading this because it explained the topic quite simply. It was well written !
- deleted 2y ago[deleted]
- tromp 2y 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?
- chucky_z 2y agohttps://hexdocs.pm/elixir/Stream.html https://hexdocs.pm/elixir/Stream.html Although I don’t think it’ll be quite as elegant as the Haskell code.
- finder83 2y agoInfinite, 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 2y agoHow 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] ?
- darcien 2y agoYou 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 https://rosettacode.org/wiki/Fibonacci_sequence#Elixir
- cies 2y agoI'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.
- nuancebydefault 2y 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.
- nmadden 2y agoRebinding is nicer than mutation, but neither are referentially transparent.
- xavxav 2y agoWhat do you mean? let-bindings don't interfere with referential transparency. `let x = 1 in let x = 2 in foo` is referentially transparent.
- Izkata 2y agoI think you're thinking of shadowing, not re-binding.
- kreetx 2y agoYup, as a Haskeller, it's important to remember that rebinding means something else in other languages.
- nmadden 2y agoThe 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 2y agoIt'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 2y ago
- akoboldfrying 2y agoIn 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/Reference/Global_Objec1ts/Promise#:~:text=An%20action%20can%20be%20assigned%20to%20an%20already%20settled%20promise.%20In%20this%20case%2C%20the%20action%20is%20added%20immediately%20to%20the%20back%20of%20the%20job%20queue%20and%20will%20be%20performed%20when%20all%20existing%20jobs%20are%20completed https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
- borromakot 2y agoGross :) 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`.
- Closi 2y agoSo VB6 had it right all along?
- vanderZwan 2y agoIn more ways than one, I've been told.
- snapcaster 2y agoi 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
- Closi 2y agoTotally agree! IMO VB.NET was just disappointing - tried too hard to turn VB into something 'proper' with 'better standards' rather than just being what it was (the YOLO language that you could build scrappy things in fast)
- LegionMammal978 2y agoI'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 2y agoI 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 2y agoThe 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.
- gr4vityWall 2y agoThis 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.
- azeirah 2y 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 2y 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 2y agoHey, and hence the rampant adoption of Haskell :P
- rkangel 2y 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.
- prerok 2y agoWhat 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 2y ago1. 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 https://www.youtube.com/watch?v=vzfy4EKwG_Y
- whateveracct 2y 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 https://www.cs.cmu.edu/~rwh/students/okasaki.pdf
- heeton 2y agoSmall 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 2y agoHmm, 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.
- skybrian 2y agoI 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.
- evilotto 2y agoThe 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.
- hansvm 2y 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.
- yawnxyz 2y agoFYI in JS if you really need an immutable object, check out the immer library. it's pretty awesome