How would you explain Elixir immutability?

Hi All, new to Elixir and still rather confused about Elixir/Erlang’s immutability despite the numerous attempts to explain it.

As a non-pro, the claim that variables are immutable appears on its face to be a stretch or just false.
A simple look at any dictionary will generally all give the same definition of immutability.

If I can do
X=1
X=2
Print X = 2 printed, then its not actually immutable.
Now it seems as though the common reply is 'But…", and an explanation of rebinding/resetting.
So assuming that is true, what is the rationale for calling this behaviour out prominently as ‘immutable’ when it actual reality from programmers perspective it is mutuable?
Is this some sort of inside baseball FP bona fide that needed to be included for some reason? Not trying to be snarky, but saying a variable can not change when it actually can as per what seems to be normal operation, regardless of whats happening below the surface, just seems incongruent.

I thought I read somewhere where doing as above does change X, whereas trying to chage X in the middle of an If statement did not.
Is there a difference between a simple statement x=1 then another with x=2 vs simple statement x=1 but an attempt to modify x in an expression like if something x=2, or perhaps better explained, a non-simple x= statement?
I think I just made myself more confused.

Here’s the simplest demonstration of the difference between “mutating” and “rebinding” I can think of:

x = 1
captured_fun = fn -> x end
x = 2
captured_fun.()

This results in 1, because the x in captured_fun refers to the value bound to x when it was defined - not when it was called.

Compare the result of an identical sequence in a language with mutable variables, for instance Javascript:

x = 1;
captured_fun = function () { return x; }
x = 2;
captured_fun()

Here the x in captured_fun refers to the mutable variable x, so it results in 2.

4 Likes

The important thing is that nothing else can have a reference to that memory. Rebinding does make it confusing but it’s just a convenience in a very tight scope. If you rebind once, you can still access the old value via the pin (^) operator. And by tight scope I mean:

x = 1
if true, do: x = 2
x # x is still `1` here.

So rebinding may not fit an extremely strict definition of immutability, but you’re getting the most important part which is that data can’t change from under you.


Ops, didn’t see @al2o3cr responding but I went and wrote this so I’m gonna post, heh.

PS, welcome to the forum!

1 Like

Yes you are confusing yourself more I guess :smiley: because rebinding and immutability are not actually related.

Immutability is the fact that you cannot change values in memory. If you pass some value to a function (for instance by passing a variable), you have the guarantee that the function cannot change whatever you hold in that variable. And on the other hand the author of that function has the guarantee that external code cannot change what is passed down.

n = 123
do_stuff(n, some_fun)
# Here `n` cannot be something else than `123`

def do_stuff(arg, fun) do
  fun.(arg)
  # Here `arg` cannot be something else than `123`
end

Rebinding or not has nothing to do with that, it’s just syntax. Edit: But the syntax is convenient, as said above. Erlang chose to have the <var> = expression match on already-bound variables, whereas Elixir chose to implement rebinding. Another language could make that a syntax error, like const in JavaScript (though those are not actual “constants” since you can mutate object properties for instance). But AFAIK no language (at the parser/AST/bytecode-compiler level) on the BEAM will make that expression mutate the values because the underlying VM offer no means to do so (though I’m now talking about something I know too little about).

4 Likes

It’s also helpful to see example with complex data structures:

# python
a = {'foo': 'bar'}
b = a
b = {'foo': 'BAZ'}
a['foo'] # This is 'BAZ'

In Elixir, this is impossible. As @lud alluded to, there isn’t even any syntax you can use to enable this. If you do want mutability you have to get into things like ETS (built-in key-value store).

EDIT: Figured I may as well show the Elixir example:

a = %{foo: "bar"}
b = a
b = Map.put(b, :foo, "BAZ")
a.foo # This is still "bar"
1 Like

Thank you both, that was quick..

LOL, I’m so new I used Grok to explain al203cr’s snippet.
I’m probably way off, but here goes.

So, captured_fun is an anonymous function that simply returns x, currently is 1.
Next statement rebinds x to 2.
Calling captured_fun prints 1 because when it was created it x was 1.
So it sounds as though the function is not actually using the variable as a variable is non-FP would expect, but more like when the function was created/instanced(?), the value of x was copied into the function.
If after the last statement you were to simply print x though, it would print 2, right?

If true, then this seems like when you define a function is pretty important.

What if my program were actually naively written similar to that, and I needed x to return 2?
Do I need to have to think about when I am defining functions, or is this my imperative experience mismatching with FP?

I read somewhere IIRC that this is done in part for safety/data corruption as with numerous processes this reduces the chance for one process to change a variable while another is working on it.
That makes sense if true, however if a variable is changing it would seem difficult to know whether or not your function will be accessing the actual current value.

Appreciate anyone willing to waste their time enlightening me.

Well, in this discussion we somehow swept under a rug the fact that = is not an assignment. It’s rather a match.

You might do the following:

x = 1
1 = x

And the code above is perfectly legit.

Once it is a match, all variables on the LHO are assigned (or, if you wish, reassigned.) That is a design decision, unlike erlang, and José explained it in details here. Actually, one might do %{foo: x} = %{foo: 42} and this is exactly the same construct as in x = 1.

If one wants to prevent rebinding on the LHO, they do pin variables with ^.

I hope that clarifies things a bit.

1 Like

Processes in Elixir have isolated memory, and as such a process cannot alter a variable of another process.

You would need to do it in another way, reaching for process state, ETS, :atomics, :counters, :persistent_term or the process dictionary. It would completely depend on the situation and without an example situation it is not useful to suggest any particular technique.

From my personal experience, the situation where you actually want to use the mutable value is a minority case, and in the majority of cases you want to use the value as it was when the function was defined. This also applies when I’m writing in languages like JavaScript, where I have to spend time thinking if I’m now using a copy or a mutable reference if I’m writing a long-lived closure. In immutable languages like Elixir I have to spend a lot less time thinking about that.

1 Like

Yes, you can just reword “rebinding” as “creating a new variable with a recycled name”. It’s a new variable, but it’s given the same name as another one. The previous one is now hidden and can’t be accessed. In the fun body, the code is referring to the previous one and doesn’t “know” that the name is going to be recycled.

1 Like

I’d explain (the benefits of) immutability differently:

let array = [1, 2, 3];
let reversed = array.reverse();

In JS you cannot know just from reading that piece of code if array is now equal to reversed or still the original value assigned in the first line. You’d need to familiarize yourself with the implementation of Array.prototype.reverse(). In this case reversed === array, but code like that causes/caused enough problems that there’s also a Array.prototype.toReversed() nowadays, which returns a new array instead of modifying the existing one.

In elixir you can have:

list = [1, 2, 3]
reversed = Enum.reverse(list)

No matter how Enum.reverse/1 happens to be implemented it cannot make the list variable resolve to any value other than [1, 2, 3]. Essentially immutability is equal to having pass by value everywhere and no pass by reference. That’s the model upheld by the VM.

14 Likes

Your example wasn’t quite right, you are instantiating a new dict for b so it actually wouldn’t mutate the dict pointed to by a.

This should be correct:

# Python
a = {'foo': 'bar'}
b = a
b['foo'] = 'hello world!'

print(a) # -> {'foo': 'hello world!'}
print(b) # -> {'foo': 'hello world!'}
# Elixir
a = %{"foo" => "bar"}
b = a
b = Map.put(b, "foo", "hello world!")
# Or: b = Map.put(a, "foo", ...)

dbg a # -> %{"foo" => "bar"}
dbg b # -> %{"foo" => "hello world!"}

We could get copy-on-write behavior (“immutability”) in Python by copying manually:

# Python
a = {'foo': 'bar}
b = copy.deepcopy(a)
b['foo'] = 'hello world!'

print(a) # -> {'foo': 'bar}
print(b) # -> {'foo': 'hello world!'}

However, this is much less efficient because Elixir uses special persistent data structures underneath which duplicate as little memory as possible while maintaining the illusion of copy-on-write. If you had a map with a thousand keys and changed one, the other thousand would still point to the same memory as the last map, whereas the Python example would duplicate the entire thing. You could implement a persistent dict in Python, and I’m sure somebody has, but in Elixir it works that way by default.

2 Likes

Oh sorry, ya, your example is what I meant to do—that’s what I get for not copy-pasting from the REPL. It mirrored my Elixir example (as much as you could call that mirroring).

1 Like

It would perhaps be better to describe it as having “copy-on-write” everywhere; it is perfectly fine to pass a reference around as long as you never mutate the thing being referenced - and hey, that’s what immutability refers to :slight_smile:

In practice the runtime passes references around all the time, which is super important for performance. But they are always copy-on-write. Note that we can construct more advanced COW data structures out of the primitives the language gives us. For example:

a = "foo"
b = "bar"
c = "baz"

l1 = {a, nil}
l2 = {b, l1}
l3 = {c, l2}

dbg l3 # -> {"baz", {"bar", {"foo", nil}}}

Those strings and tuples shouldn’t be duplicated in memory. Hey wait a minute, is that a linked list?!

I wouldn’t necessarily call this “better described” because my argument is exactly that you don’t need to care. For the mental model it doesn’t matter at all when copies are created – as long as they’re created often enough that all intermediate values interacted with remain.

1 Like

This to me does not explain immutability. You can have the same code in any mutable language, as you can write a function that returns an updated copy of the input data. Immutability is not not enforced at the language level but below.

Maybe the best way to learn for people is not to give them explanations but an exercise:

Given that code

a = 1
f.(a)
IO.inspect(a)

Write a definition of the function f so the last line prints “2”. You can use anything you want.

And let them struggle for a while :smiley: doing their research to understand it fully.

1 Like

That’s because I messed up my example and also that it’s impossible to give an exact reproduction in Elixir just because it’s impossible—moreover, I was no longer talking about rebinding. I was trying to make a point similar to Benjamin’s: in any mutable language, some function can transparently change a data structure out from under you whereas that is just not possible in Elixir, there’s not even an escape hatch.

3 Likes

If you were feeling exceptionally evil you could write a macro which replaces variables with calls to the process dictionary…

Mhm, that’s what I was trying to demonstrate in the third code block. Personally I find thinking of data structures as copy-on-write to be easiest to understand, but everyone learns differently so having as many explanations as possible is always beneficial :slight_smile:

2 Likes

lud, no idea how I missed your post before I replied.
OK, so that makes a lot more sense.
I wish that was more clearly explained in Elixir in Action, unless it is explained more fully later on.

1 Like

Thanks everyone, much clearer now.
It seems as though this also relates to the Style of programming?
I get the impression I am not going to be able to program in quite the same fashion as one might in Python, C, etc?

Depends how you write code in the other languages you mentioned. It’s not a significant switch if you already write modular function based code in the other languages.

You can do stuff like the below to “mutate” an original value even though you are technically replacing it to align with how you likely code normally.

  def update(%{record: record} = assigns, socket) do
    socket = socket
    |> assign(:flash_map, flash_map())

    form_update(Group, record, assigns, socket)
  end

Technically the above is same as the below, but on paper in the above you have “mutated” the original socket in an immutable way. The main difference with the below is that you could access the original socket by using socket after the updated_socket has been created. It’s not used in the below, but it’s fairly common in Elixir to take a starting value, and use it multiple times to create new values without changing the starting value.

  def update(%{record: record} = assigns, socket) do
    updated_socket = socket
    |> assign(:flash_map, flash_map())

    form_update(Group, record, assigns, updated_socket)
  end

If you did the next example however, the original socket will be used, because all thats happening is the value of the socket is being updated, but never assigned to a new immutable value.

Below, the socket is never replaced, by using something = socket, so even though the socket has the flash_map assigned to it, it never actually updates the original socket value.

  def update(%{record: record} = assigns, socket) do
    socket
    |> assign(:flash_map, flash_map())

    form_update(Group, record, assigns, socket)
  end

With regards to functional programming, most people are likely here because of how simple it is to follow so you will unlikely have many issues.

If you look at the example below it shows a basic functional example. You pass some params, check the values, call a function based on the result, repeat until the flow is complete. Functional programming allows you to break code down into bitesize reusable chunks like the increment_reply_count.

##########################################################
####  Reply Count :  Handle  |  Create  |  Increment  ####
##########################################################

  defp create_redix_reply_metrics(key, ancestor_id, parent_id, date) do
    case Redix.command(:redix, ["EXISTS", key]) do
      {:ok, 0} ->
        comment = Replies.get_reply(ancestor_id, parent_id, PhxieScylla.convert_date(date))

        if comment do
          Redix.pipeline(:redix, [
            ["HSET", key, "vote_count",     comment["vote_count"]],
            ["HSET", key, "vote_milestone", comment["vote_milestone"]],
            ["HSET", key, "reply_count",    comment["reply_count"]],
            
            # Primary Key Values
            ["HSET", key, "ancestor_id",    ancestor_id],
            ["HSET", key, "parent_id",      parent_id],
            ["HSET", key, "created_at",     date]
          ])
        else
          {:error, :no_comment}
        end
      _ -> 
        :ok
    end
  end

  #################################
  ####  Reply Count :  Handle  ####
  #################################

  def handle_reply_count(reply_id, ancestor_id, parent_id, date) do
    key = "reply:#{reply_id}:metrics"

    case Redix.command(:redix, ["HGETALL", key]) do
      {:ok, []} -> create_reply_count(key, ancestor_id, parent_id, date)
      {:ok, _}  -> increment_reply_count(key)
    end
  end

  #################################
  ####  Reply Count :  Create  ####
  #################################

  defp create_reply_count(key, ancestor_id, parent_id, date) do
    with {:ok, _metrics} <- create_redix_reply_metrics(key, ancestor_id, parent_id, date) do
      increment_reply_count(key)
    end
  end

  ###################################
  ####  Reply Count : Increment  ####
  ###################################

  defp increment_reply_count(key) do
    Redix.command(:redix, ["HINCRBY", key, "reply_count", "1"])
  end

Also, another basic explanation and example of mutable vs immutable:

Mutable = Update Value
Immutable = Replace Value

if x = 1

Mutable:
x + 1 = 2
x is now 2

Immutable:
x + 1 = y
y is used in place of x as a replaced value

When you write functions with immutable values, you basically just create new values each time, whilst keeping the original value intact.

Rather than change the original value, you create an updated copy based on the original value whilst leaving the original value so that it can be used elsewhere in the code.

1 Like