Most active commenters
  • (4)
  • skeezyjefferson(3)
  • Thorrez(3)
  • DemocracyFTW2(3)

←back to thread

498 points azhenley | 64 comments | | HN request time: 2.204s | source | bottom
Show context
EastLondonCoder ◴[] No.45770007[source]
After a 2 year Clojure stint I find it very hard to explain the clarity that comes with immutability for programmers used to trigger effects with a mutation.

I think it may be one of those things you have to see in order to understand.

replies(17): >>45770035 #>>45770426 #>>45770485 #>>45770884 #>>45770924 #>>45771438 #>>45771558 #>>45771722 #>>45772048 #>>45772446 #>>45773479 #>>45775905 #>>45777189 #>>45779458 #>>45780612 #>>45780778 #>>45781186 #
1. rendaw ◴[] No.45770924[source]
I think the explanation is: When you mutate variables it implicitly creates an ordering dependency - later uses of the variable rely on previous mutations. However, this is an implicit dependency that isn't modeled by the language so reordering won't cause any errors.

With a very basic concrete example:

x = 7

x = x + 3

x = x / 2

Vs

x = 7

x1 = x + 3

x2 = x1 / 2

Reordering the first will have no error, but you'll get the wrong result. The second will produce an error if you try to reorder the statements.

Another way to look at it is that in the first example, the 3rd calculation doesn't have "x" as a dependency but rather "x in the state where addition has already been completed" (i.e. it's 3 different x's that all share the same name). Doing single assignment is just making this explicit.

replies(10): >>45770972 #>>45771110 #>>45771163 #>>45771234 #>>45771937 #>>45772126 #>>45773250 #>>45776504 #>>45777296 #>>45778328 #
2. ◴[] No.45770972[source]
3. skeezyjefferson ◴[] No.45771110[source]
whats the difference between immutable and constant, which has been in use far longer? why are you calling it mutable?
replies(4): >>45771178 #>>45771248 #>>45773965 #>>45774112 #
4. jstimpfle ◴[] No.45771163[source]
The immutable approach doesn't conflate the concepts of place, time, and abstract identity, like in-place mutation does.

In mutating models, typically abstract (mathematical / conceptual) objects are modeled as memory locations. Which means that object identity implies pointer identity. But that's a problem when different versions of the same object need to be maintained.

It's much easier when we represent object identity by something other than pointer identity, such as (string) names or 32-bit integer keys. Such representation allows us to materialize us different versions (or even the same version) of an object in multiple places, at the same time. This allows us to concurrently read or write different versions of the same abstract object. It's also an enabler for serialization/deserialization. Not requiring an object to be materialized in one particular place allows saving objects to disk or sending them around.

replies(1): >>45774510 #
5. Thorrez ◴[] No.45771178[source]
Immutable and constant are the same. rendaw didn't use the word mutable. One reason someone might use the word "mutable" is that it's a succinct way of expressing an idea. Alternative ways of expressing the same idea are longer words (changeable, non-constant).
replies(3): >>45771258 #>>45771300 #>>45773072 #
6. EastLondonCoder ◴[] No.45771234[source]
I agree that the explicit timeline you get with immutability is certainly helpful, but I also think its much easier to understand the total state of a program. When an imperative program runs you almost always have to reproduce a bug in order to understate the state that caused it, fairly often in Clojure you can actually deduct whats happening.
replies(2): >>45772345 #>>45772528 #
7. inanutshellus ◴[] No.45771248[source]
"Constant" implies a larger context.

As in - it's not very "constant" if you keep re-making it in your loop, right?

Whereas "immutable" throws away that extra context and means "whatever variable you have, for however long you have it, it's unchangeable."

replies(1): >>45773019 #
8. kgwxd ◴[] No.45771258{3}[source]
They aren't the same for object references. The reference can't be changed, but the properties can.
replies(1): >>45781637 #
9. a4isms ◴[] No.45771300{3}[source]
In languages like JavaScript, immutable and constant may be theoretically the same thing, but in practice "const" means a variable cannot be reassigned, while "immutable" means a value cannot be mutated in place.

They are very, very different semantically, because const is always local. Declaring something const has no effect on what happens with the value bound to a const variable anywhere else in the program. Whereas, immutability is a global property: An immutable array, for example, can be passed around and it will always be immutable.

JS has always hade 'freeze' as a kind of runtime immutability, and tooling like TS can provide for readonly types that provide immutability guarantees at compile time.

replies(1): >>45772243 #
10. ape4 ◴[] No.45771937[source]
I would be nicer if you gave x1 and x2 meaningful names
replies(1): >>45772547 #
11. Tarean ◴[] No.45772126[source]
Sometimes keeping a fixed shape for the variable context across the computation can make it easier to reason about invariants, though.

Like, if you have a constraint is_even(x) that's really easy to check in your head with some informal Floyd-Hoare logic.

And it scales to extracting code into helper functions and multiple variables. If you must track which set of variables form one context x1+y1, x2+y2, etc I find it much harder to check the invariants in my head.

These 'fixed state shape' situations are where I'd grab a state monad in Haskell and start thinking top-down in terms of actions+invariants.

12. everforward ◴[] No.45772243{4}[source]
Arrays are a very notable example here. You can append to a const array in JS and TS, even in the same scope it was declared const.

That’s always felt very odd to me.

replies(4): >>45773093 #>>45773609 #>>45779719 #>>45781098 #
13. ◴[] No.45772345[source]
14. ryandv ◴[] No.45772528[source]
That's right - immutability enables equational reasoning, where it becomes possible to actually reason through a program just by inspection and evaluation in one's head, since the only context one needs to load is contained within the function itself - not the entire trace, where anything along the thread of execution could factor into your function's output, since anybody can just mutate anybody else's memory willy-nilly.

People jump ahead using AI to improve their reading comprehension of source code, when there are still basic practices of style, writing, & composition that for some reason are yet to be widespread throughout the industry despite already having a long standing tradition in practice, alongside pretty firm grounding in academics.

replies(1): >>45773559 #
15. catlifeonmars ◴[] No.45772547[source]
What would those names be in this example?
replies(1): >>45775677 #
16. skeezyjefferson ◴[] No.45773019{3}[source]
> As in - it's not very "constant" if you keep re-making it in your loop, right?

you cant change a constant though

replies(1): >>45773195 #
17. skeezyjefferson ◴[] No.45773072{3}[source]
but we already had the word variable for values that can change. on both counts it seems redundant
replies(1): >>45781429 #
18. raddan ◴[] No.45773093{5}[source]
That's because in many languages there is a difference between a stored reference being immutable and the contents of the thing the reference points to being immutable.
19. veilrap ◴[] No.45773195{4}[source]
He’s implying that the variable it’s being defined within the loop. So, constant, but repeatedly redefined.
replies(1): >>45773473 #
20. raincole ◴[] No.45773250[source]
Yet even Rust allows you to shadow variables with another one with the same name. Yes, they are two different variables, but for a human reader they have the same name.

I think that Rust made this decision because the x1, x2, x3 style of code is really a pain in the ass to write.

replies(3): >>45773392 #>>45773898 #>>45773931 #
21. suspended_state ◴[] No.45773392[source]
Or they got inspired by how this is done in OCaml, which was the host language for the earliest versions of Rust. Actually, this is a behaviour found in many FP languages. Regarding OCaml, there was even a experimental version of the REPL where one could access the different variables carrying the same name using an ad-hoc syntax.
22. ghurtado ◴[] No.45773473{5}[source]
That's the opposite of what any reasonable engineer means by "constant".
replies(2): >>45774034 #>>45774230 #
23. adrianN ◴[] No.45773559{3}[source]
In theory it’s certainly right that imperative programs are harder to reason about. In practice programmers tend to avoid writing the kind of program where anything can happen.
replies(1): >>45773696 #
24. ◴[] No.45773609{5}[source]
25. ryandv ◴[] No.45773696{4}[source]
> In practice programmers tend to avoid writing the kind of program where anything can happen.

My faith in this presumption dwindles every year. I expect AI to only exacerbate the problem.

Since we are on the topic of Carmack, "everything that is syntactically legal that the compiler will accept will eventually wind up in your codebase." [0]

[0] https://www.youtube.com/watch?v=1PhArSujR_A&t=15m54s

26. airstrike ◴[] No.45773898[source]
I do find shadowing useful. If you're writing really long code blocks in which it becomes an issue, you are probably doing too much in one place.
27. wongarsu ◴[] No.45773931[source]
In idiomatic Rust you usually shadow variables with another one of the same name when the type is the only thing meaningfully changing. For example

   let x = "29"
   let x = x.parse::<i32>()
   let x = x.unwrap()
These all use the same name, but you still have the same explicit ordering dependency because they are typed differently. The first is a &str, the second a Result<i32, ParseIntError>, the third an i32, and any reordering of the lines would provide a compiler error. And if you add another line `let y = process(x)` you would expect it to do something similar no matter where you introduce it in these statements, provided it accepts the current type of x, because the values represent the "same" data.

Once you actually "change" the value, for example by dividing by 3, I would consider it unidiomatic to shadow under the same name. Either mark it as mutable for preferably make a new variable with a name that represents what the new value now expresses

replies(4): >>45774362 #>>45775439 #>>45778129 #>>45778564 #
28. scott_w ◴[] No.45773965[source]
In plenty of languages, there's not really a difference. In Rust, there is a difference between a `let var_name = 10;` and `const var_name: u64 = 10;` in that the latter must have its value known at compile-time (it's a true constant).

> why are you calling it mutable?

Mostly just convention. Rust has immutable by default and you have to mark variables specifically with `mut` (so `let mut var_name = 10;`). Other languages distinguish between variables and values, so var and val, or something like that. Or they might do var and const (JS does this I think) to be more distinct.

29. fnordsensei ◴[] No.45774034{6}[source]
That’s the point, you’re just haggling about scopes now. All the way from being new per program invocation to new per loop.

Immutability doesn’t have this connotation.

replies(1): >>45774245 #
30. munificent ◴[] No.45774112[source]
"Constant" is ambiguous. Depending on who you ask, it can mean either:

1. A property known at compile time.

2. A property that can't change after being initially computed.

Many of the benefits of immutability accrue properties whose values are only known at runtime but which are still known to not change after that point.

31. davrosthedalek ◴[] No.45774230{6}[source]
No? It has a lifetime of one loop duration, and is constant during that duration. Seems perfectly fine to me.
32. davrosthedalek ◴[] No.45774245{7}[source]
How? I think the same argument applies: If it's changing from loop to loop, seems mutable to me.
replies(2): >>45774345 #>>45776074 #
33. fnordsensei ◴[] No.45774345{8}[source]
I think you’re after something other than immutability then.

You’re allowed to rebind a var defined within a loop, it doesn’t mean that you can’t hang on to the old value if you need to.

With mutability, you actively can’t hang on to the old value, it’ll change under your feet.

Maybe it makes more sense if you think about it like tail recursion: you call a function and do some calculations, and then you call the same function again, but with new args.

This is allowed, and not the same as hammering a variable in place.

34. ymyms ◴[] No.45774362{3}[source]
Another idiomatic pattern is using shadowing to transform something using itself as input:

let x = Foo::new().stuff()?; let x = Bar::new(x).other_stuff()?;

So with the math example and what the poster above said about type changing, most rust code I write is something like:

let x: plain_int = 7

let x: added_int = add(x, 3);

let x: divided_int = divide(x, 2);

where the function signatures would be fn add(foo: plain_int, int); fn divide(bar: added_int, int);

and this can't be reordered without triggering a compiler error.

35. SpaceNoodled ◴[] No.45774510[source]
The hardware that these programs are running on store objects in linear memory, so it doesn't not make sense to treat it as such.
replies(2): >>45774729 #>>45780295 #
36. wtallis ◴[] No.45774729{3}[source]
DRAM is linear memory. Caches, less so. Register files really aren't. CPUs spend rather a lot of transistors and power to reconcile the reality of how they manipulate data within the core against the external model of RAM in a flat linear address space.
37. waffletower ◴[] No.45775439{3}[source]
In a Clojure binding this is perfectly idiomatic, but symbolically shared bindings are not shadowed, they are immutably replaced. Mutability is certainly available, but is explicit. And the type dynamism of Clojure is a breath of fresh air for many applications despite the evangelism of junior developers steeped in laboratory Haskell projects at university. That being said, I have a Clojure project where dynamic typing is throughly exploited at a high level, allows for flexible use of Clojure's rational math mixed with floating point (or one or the other entirely), and for optimization deeper within the architecture a Rust implementation via JVM JNI is utilized for native performance, assuring homogenous unboxed types are computed to make the overall computation tractable. Have your cake and eat it too. Types have their virtues, but not without their excesses.
38. ape4 ◴[] No.45775677{3}[source]
In a real application meaningful names are nearly always possible, eg:

    const pi = 3.1415926
    const 2pi = 2 * pi
    const circumference = 2pi * radius
replies(2): >>45776064 #>>45777010 #
39. tmtvl ◴[] No.45776064{4}[source]
Calling tau 2pi is the most cursed thing I've seen all day. Appropriate for Halloween.
replies(1): >>45777049 #
40. Zambyte ◴[] No.45776074{8}[source]
I can give a specific example.

    for (0..5) |i| {
        i = i + 1;
        std.debug.print("foo {}\n", .{i});
    }
In this loop in Zig, the reassignment to i fails, because i is a constant. However, i is a new constant bound to a different value each iteration.

To potentially make it clearer that this is not mutation of a constant between iterations, technically &i could change between iterations, and the program would still be correct. This is not true with a c-style for loop using explicit mutation.

41. alain94040 ◴[] No.45776504[source]
That example is too simple for me to grasp it. How would you code a function that iterates over an array to compute its sum. No cheating with a built-in sum function. If you had to code each addition, how would that work? Curious to learn (I probably could google this or ask Claude to write me the code).
replies(2): >>45776593 #>>45776601 #
42. supergarfield ◴[] No.45776593[source]
Carmack gives updating in a loop as the one exception:

> You should strive to never reassign or update a variable outside of true iterative calculations in loops.

If you want a completely immutable setup for this, you'd likely have to use a recursive function. This pattern is well supported and optimized in immutable languages like the ML family, but is not super practical in a standard imperative language. Something like

  def sum(l):
    if not l: return 0
    return l[0] + sum(l[1:])
Of course this is also mostly insensitive to ordering guarantees (the compiler would be fine with the last line being `return l[-1] + sum(l[:-1])`), but immutability can remain useful in cases like this to ensure no concurrent mutation of a given object, for instance.
replies(2): >>45776908 #>>45776939 #
43. ◴[] No.45776601[source]
44. bmacho ◴[] No.45776908{3}[source]
You don't have to use recursion, that is, you don't need language support for it. Having first class (named) functions is enough.

For example you can modify sum such that it doesn't depend on itself, but it depends on a function, which it will receive as argument (and it will be itself).

Something like:

  def sum_(f, l):
    if not l: return 0
    return l[0] + f(f, l[1:])

  def runreq(f, *args):
    return f(f, *args)

  print(runreq(sum_, [1,2,3]))
replies(1): >>45779627 #
45. hermitdev ◴[] No.45776939{3}[source]
While your example of `sum` is a nice, pure function, it'll unfortunately blow up in python on even moderately sized inputs (we're talking thousands of elements, not millions) due to lack of tail calls in Python (currently) and the restrictions on recursion depth. The CPython interpreter as of 3.14 [0] is now capable of using tail calls in the interpreter itself, but it's not yet in Python, proper.

[0]: https://docs.python.org/3/whatsnew/3.14.html#a-new-type-of-i...

replies(1): >>45777004 #
46. dragonwriter ◴[] No.45777004{4}[source]
Yeah, to actually use tail-recursive patterns (except for known-to-be-sharply-constrained problems) in Python (or, at least, CPython), you need to use a library like `tco`, because of the implementation limits. Of course the many common recursive patterns can be cast as map, filter, or reduce operations, and all three of those are available as functions in Python's core (the first two) or stdlib (reduce).

Updating one or more variables in a loop naturally maps to reduce with the updated variable(s) being (in the case of more than one being fields of) the accumulator object.

47. catlifeonmars ◴[] No.45777010{4}[source]
Agree in real life you can come up with meaningful names (and should when the names are used far away from the point of assignment), but it doesn’t make sense for GPs example, where the whole point was to talk about assignments in the abstract.
48. smrq ◴[] No.45777049{5}[source]
If you call a variable tau in production code then you're being overly cute. I know what it means, because I watch math YouTube for fun, but $future_maintainer in all likelihood won't.
replies(1): >>45780431 #
49. zerd ◴[] No.45777296[source]
It's funny that converting the first example to the second is a common thing a compiler does, Static single assignment [0], to make various optimizations easier to reason about.

[0] https://en.wikipedia.org/wiki/Static_single-assignment_form

50. combyn8tor ◴[] No.45778129{3}[source]
I did this accidentally the other day in Rust:

let x = some_function();

... A bunch of code

let x = some_function();

The values of x are the same. It was just an oversight on my part but wondered if I could set my linter to highlight multiple uses of the same variable name in the same function. Does anyone have any suggestions?

51. amy_petrik ◴[] No.45778328[source]
Guys, guys, I don't think we're on the same page here.

The conversation I'm trying to have is "stop mutating all the dynamic self-modifying code, it's jamming things up". The concept of non-mutating code, only mutating variables, strikes me as extremely OCD and overly bureaucratic. Baby steps. Eventually I'll transition from my dynamic recompilation self-modifying code to just regular code with modifying variables. Only then can we talk about higher level transcendental OOP things such as singleton factory model-view-controller-singleton-const-factories and facade messenger const variable type design patterns. Surely those people are well reasoned and not fanatics like me

52. nayuki ◴[] No.45778564{3}[source]
In Rust, one way I use shadowing is to gather a bunch of examples into one function, but you can copy and paste any single example and it would work.

    fn do_demo() {
        let qr = QrCode::encode_text("foobar");
        print_qr(qr);
        
        let qr = QrCode::encode_text("1234", Ecc::LOW);
        print_qr(qr);
        
        let qr = QrCode::encode_text("the quick brown fox");
        print_qr(qr);
    }
In other languages that don't allow shadowing (e.g. C, Java), the first example would declare the variable and be syntactically correct to copy out, but the subsequent examples would cause a syntax error when copied out.
53. DemocracyFTW2 ◴[] No.45779627{4}[source]
> You don't have to use recursion

You're using recursion. `runreq()` calls `sum_()` which calls `sum()` in `return l[0] + f(f, l[1:])`, where `f` is `sum()`

replies(1): >>45782480 #
54. DemocracyFTW2 ◴[] No.45779719{5}[source]
I think JavaScript has a language / terminology problem here. It has to be explained constantly (see) to newcomers that `const a = []` does not imply you cannot say `a.push( x )` (mutation), it just keeps you from being able to say `a = x` further down (re-binding). Since in JavaScript objects always start life as mutable things, but primitives are inherently immutable, `const a = 4` does guarantee `a` will be `4` down the line, though. The same is true of `const a = Object.freeze( [] )` (`a` will always be the empty list), but, lo and behold, you can still add elements to `a` even after `const a = Object.freeze( new Set() )` which is, shall we say, unfortunate.

The vagaries don't end there. NodeJS' `assert` namespace has methods like `equal()`, `strictEqual()`, `deepEqual()`, `deepStrictEqual()`, and `partialDeepStrictEqual()`, which is both excessive and badly named (although there's good justification for what `partialDeepStrictEqual()` does); ideally, `equal()` should be both `strict` and `deep`. That this is also a terminology problem is borne out by explanations that oftentimes do not clearly differentiate between object value and object identity.

In a language with inherent immutability, object value and object identity may (conceptually at least) be conflated, like they are for JavaScript's primitive values. You can always assume that an `'abc'` over here has the same object identity (memory location) as that `'abc'` over there, because it couldn't possibly make a difference were it not the case. The same should be true of an immutable list: for all we know, and all we have to know, two immutable lists could be stored in the same memory when they share the same elements in the same order.

55. jstimpfle ◴[] No.45780295{3}[source]
Can you clarify?
replies(1): >>45781441 #
56. lock1 ◴[] No.45780431{6}[source]
Where do you draw the line then? Stopping at `tau` just because `$future_maintainer` might get confused feels like an arbitrary limit to me.

What about something like `gamma`? Lorentz factor? Luminance multiplier? Factorial generalization?

Why not just use the full sentence rather than assign it to an arbitrary name/symbol `gamma` and leave it dependent on the context?

And it's not that hard to add an inline comment to dispel the confusion

  const tau = 2*pi; // Alternate name for 2pi is "tau"
57. a4isms ◴[] No.45781098{5}[source]
There is no exception for ANY data structure that includes references to other data structures or primitives. Not only can you add or remove elements from an array, you can change them in place.

A const variable that refers to an array is a const variable. The array is still mutable. That's not an exception, its also how a plain-old JavaScript object works: You can add and remove properties at will. You can change its prototype to point to something else and completely change its inheritance chain. And it could be a const variable to an unfrozen POJO all along.

That is not an exception to how things work, its how every reference works.

replies(1): >>45785602 #
58. Thorrez ◴[] No.45781429{4}[source]
Oh, good point. I misunderstood your previous question.

Is there a name that refers to the broader group that includes both constants and variables? In practice, and in e.g. C++, "variable" is used to refer to both constants and actual variables, due to there not being a different common name that can be used to refer to both.

59. repstosb ◴[] No.45781441{4}[source]
Modern CPUs do out-of-order execution, which means they need to identify and resolve register sharing dependencies between instructions. This turns the notional linear model of random-access registers into a DAG in practice, where different instructions that might be in flight at once actually read from or write to different "versions" of a named register. Additionally, pretty much every modern CPU uses a register renaming scheme, where the register file at microarchitecture level is larger than that described in the software-level architecture reference, i.e. one instruction's "r7" has no relationship at all to another's r7".

Caches aren't quite as mix-and-match, but they can still internally manage different temporal versions of a cache line, as well as (hopefully) mask the fact that a write to DRAM from one core isn't an atomic operation instantly visible to all other cores.

Practice is always more complicated than theory.

replies(1): >>45785832 #
60. Thorrez ◴[] No.45781637{4}[source]
Depends on the language. In C++

  const std::vector<int>& foo = bar.GetVector();
foo is a constant object reference cannot have its properties changed (and also cannot be changed to refer to a new object).

  std::vector<int>& foo = bar.GetVector();
Is an object reference that can have its properties changed (but cannot be changed to refer to a new object).
61. bmacho ◴[] No.45782480{5}[source]
> You're using recursion.

No, see GP.

> `runreq()` calls `sum_()` which calls `sum()` in `return l[0] + f(f, l[1:])`, where `f` is `sum()`

Also no, see GP.

replies(1): >>45785308 #
62. DemocracyFTW2 ◴[] No.45785308{6}[source]
I am too stupid to understand this. This:

    def sum_(f, l):
      if not l: return 0
      return l[0] + f(f, l[1:])

    def runreq(f, *args):
      return f(f, *args)

    print(995,runreq(sum_, range(1,995)))
    print(1000,runreq(sum_, range(1,1000)))
when run with python3.11 gives me this output:

    995 494515
    Traceback (most recent call last):
      File "/tmp/sum.py", line 9, in <module>
        print(1000,runreq(sum_, range(1,1000)))
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^
      File "/tmp/sum.py", line 6, in runreq
        return f(f, *args)
               ^^^^^^^^^^^
      File "/tmp/sum.py", line 3, in sum_
        return l[0] + f(f, l[1:])
                      ^^^^^^^^^^^
      File "/tmp/sum.py", line 3, in sum_
        return l[0] + f(f, l[1:])
                      ^^^^^^^^^^^
      File "/tmp/sum.py", line 3, in sum_
        return l[0] + f(f, l[1:])
                      ^^^^^^^^^^^
      [Previous line repeated 995 more times]
    RecursionError: maximum recursion depth exceeded in comparison
A RecursionError seems to indicate there must have been recursion, no?
63. everforward ◴[] No.45785602{6}[source]
I know, and I do agree it's consistent, but then it doesn't make any sense to me as a keyword in a language where non-primitives are always by-reference.

You can't mutate the reference, but you _can_ copy the values from one array into the data under an immutable reference, so const doesn't prevent basically any of the things you'd want to prevent.

The distinction makes way more sense to me in languages that let you pass by value. Passing a const array says don't change the data, passing a const reference says change the data but keep the reference the same.

64. FooBarBizBazz ◴[] No.45785832{5}[source]
Realistically, the compiler is building a DAG called SSA; and then the CPU builds a DAG to do out of order execution, so at a fine grain -- the basic block -- it seems to me that the immutable way of thinking about things is actually closer to the hardware.