Immutability by default

Hello,
Why does Zig not have immutability by default for slices/pointers?

Currently:
const s1: [] u8 = ...; → elemenst s1 points to can be mutated
const s1: [] const u8 = ...; → elements s1 points to cannot be mutated
(s1 can’t be reassigned to in both cases)

My suggestion: why not the other way around?
const s1: [] u8 = ...; → elemenst s1 points to cannot mutated
const s1: [] var u8 = ...; → elements s1 points to can be mutated
It feels safer to me: I often forget to make a pointer / slice immutable (therefore opening the door for accidental mutation later).

Zig puts a big emphasis on safety so I was surprised when I found out it got the defaults for constness (IMO) the other way around.
Rust, for example, does it this way and I think it helps a lot.

When it comes to readibility: In my experience (feel free to share yours), the ratio of mutable to immutable pointers is more or less 50/50, tending slightly more to immutable to (e.g. string literals). So IMO this change would either keep readability the same or improve slightly by removing unnecessary consts. Also, var is the smaller keyword (though I’m being pedantic at this point)

I found this issue where this exact topic is suggested, but it was closed without much comment. @andrewrk, can you elaborate on why this isn’t the right choice for Zig? Thanks in advance

I know the language as come a long way since this sort of questions was an open problem, but it feels like it would make the language safer and more consistent. Also, intuitively it seems that this change could be applied algorithmically to existing codebases. (someone correct me if I’m wrong)

EDIT 1: Some people have conflated two separate suggestions :slight_smile:

  1. Making [] u8 / * u8mean “slice/pointer to const data”
  2. Requiring [] var u8 / * var u8 (i.e. not having a default “const/var ness” to pointer types) when using a slice/pointer to mutable data.

This post was made to suggest 1), not 2). That aside, 2) has some merit in my view. They both achieve the same goal of removing what I consider to be a (slightly) unsafe default. 2) feels more consistent than 1), but I’d say I don’t like 2) as much as 1) just because it increases verbosity, whereas 1) decreases it (if you subscribe to my and some other people’s argument that const pointers are more frequent than non-const pointers)

EDIT 2: An example of a programming mistake that can be prevented by this change
Currently, if you want to have a safe zig program, then invariably you will often have to think: “should this pointer be const?”.
My suggestion: turns this around and makes you think “should this pointer be var?”
In the scenario where you fail to think, with this suggestion you get a safer outcome.

Example: a function that takes in two slices: one (a1) can be mutated, the other (a2) shouldn’t be mutated:

// poorly named variables, but sometimes you don't have control over that.
// a2 should be const, but I forgot to add the keyword :( because I was distracted e.g. by the smell of freshly baked quiche
fn f(a1: [] u8, a2: [] u8) void {
    // I have now created an unsafe/less-safe zone where
    // I can mix them up on accident and modify a2
}

My suggestion would change this to:

// still poorly named variables 
// a2 is const by default. if you try to modify it, you get a compile error
fn f(a1: [] var u8, a2: [] u8) void {
    // if you forget the `var` on a1, compiler will remind you when you try to mutate it
    // you cannot accidentally mutate a2
}
12 Likes

We can only guess at the reason, unless you ask andrew himself.

My guess is influence/bias/catering to C, where things are mutable by default.

It indeed seems inconsistent that for variable declarations, var and const are symmetrical with no default, but then for pointer targets there suddenly is a default.

But i guess the symmetry for declarations was never an intentional feature, it was just that a keyword was needed to avoid syntax ambiguities (otherwise x = 0; could be either a declaration or an assignment expression).

2 Likes

I’m okay with pointer types having a default!
But let it be the right one!!

1 Like

i’m all for this change
this is more tedious

fn func(a: *const BigStructA, b: *const BigStructB, c: *const BigStructC) void {}

than this

fn func(a: *BigStructA, b: *BigStructB, c: *BigStructC) void {}

and i imagine you’d need more const pointers than var pointers

6 Likes

Yeah!
This case where I have a function with several const parameters also happens to me very often.

I don’t dislike this change, but I have some guesses and understanding about the current design.

*const T means more like a “compatible pointer for mutable and immutable locations,” but it doesn’t guarantee immutability. E.g., the src parameter of @memmove can be *const T, yet src can still be modified by @memmove.

Back when parameter reference optimization still existed, if the expected parameter type was stateless and immutable, passing T directly as a parameter was more popular than using *const T. If the parameter type was assumed to be mutable and stateful, then *T should be used. Only in the special case where the parameter type is stateful but this function is designed to make no changes to it (although it might still be modified due to aliases) would *const T be appropriate. So if there’s PRO, *T should have more use cases than *const T, and directly using T as a parameter replaces many scenarios that actually expect an immutable reference.

If parameter reference optimization is removed, I think it might need to be reconsidered. But still it’s worth noting that *const T can’t directly benefit from immutability-related optimizations; it’s still considered potentially mutable, and whether it’s actually immutable needs to be determined through semantic analysis. But this might be another reason supporting the OP’s idea, because explicitly writing const gives more of an impression that it’s immutable, though that might not actually be the case.

5 Likes

Don’t know the original reasoning, but const-by-default also makes more sense to me:

  • More things are const than var
  • With removal of PRO, some x: T will change to X: *const T, skewing the ratio further
  • With more restrictive default, omitting the keyword by accident gives you an actionable compilation error immediately. With status quo, you often write *T instead of *const T, and things often end up working initially, and fixing this later requires restoring lost context.

With that being said, I must say I am not 100% sold on the usefulness of “non-transitive” const in the first place: in Rust, &mut is clearly load bearing and pays the rent. In JavaScript, I wish we only had let, as, with everything being a reference, and absence of “everything is an expression”, tracking mutability of stack variables is a chore with little correctness payoff, imo. Zig is in the middle, const works up to the pointer.

Though, I am unsure what are the compiler backend implications here? Is there information communicated by const that can’t be inferred by compiler by looking at the code, and which helps optimization (taking into account that const != immutable, it can still get mutated through a different pointer)

EDIT:

  • More things are const than var

Changed my mind here, looking at TigerBeetle, it is not clear cut there are more const variables and var pointers:

λ rg ' var ' | wc -l
    2916
λ rg ' const ' | wc -l
    6360
λ rg  ': (\[\]|\*)' | wc -l # var+const pointers pointers
    3905
λ rg  ': (\[\]|\*)const' | wc -l # const pointers
    1323
8 Likes

Meh, as far as semantics, I think matching the way C works is correct for a language without move semantics. *const T means “I cannot write to this location but I may read from it”. That’s extremely clearly not going to pass through pointers.

Mutability is obviously the actual default, right? You’re working on the level of a von Neumann machine, not an ML that realized it was fast. All addresses may be written to unless protected by, e.g., the kernel.

4 Likes

Sort of off-topic but I often see people mentioning “ML”. What is ML?

in this context I mean ML the programming language, spiritual ancestor of OCaml and Rust (I was obliquely referencing Rust).

In other contexts it often means “Machine Learning”.

1 Like

well mutability is the actual default but having to expilcitly specify mutable pointers makes sense for a language that gives an error when there’s a variable that was never mutated. it’s easy to forget to write const for a pointer that wasn’t supposed to be a mutable pointer but it’s not possible the other why around cuz there will be a compile time error in that case

2 Likes

This is a matter of opinion. Which one is “right” depends on how you look at it.

The way I see it, a pointer is just an address. By default, this address is both readable and writeable. The computer doesn’t care, that’s something we layer on afterwards.
So *T should reflect that. This is the address of some type. It is both readable and writeable.
For convenience/type checking, we layer on additional information to restrict it. So *const T reflects that I have an address, but I will not write to it.

Now, an argument could be made that we should go the other way. That is a valid argument, but it is not inherently the “right” one. More a matter of perspective.

8 Likes

The computer (MMU) definitely does care, addresses are not read/write by default the OS has to explicitly mark it as writable.

It is possible for memory to be read-only but it is not possible for it to be write-only (atleast on x86).

3 Likes

Oh, interesting. Thanks for the clarification. TIL.

Interesting. And, would consistency dictate:

var s1: [] var u8 = ...;

?

Not sure I understood your question!
var s1: [] var u8 = ...; would mean “a pointer which can be reassigned; the elements it points to can be mutated”

2 Likes

Right. Right now you would not need the second “var” in there for s1 to be mutable:
var s1: []u8 = &buffer; // s1[3] = 'c' would subsequently work
But, for consistency, would the proposal include having to make that
var s1: [] var u8 = &buffer;
?

If []T now means the old []const T, s1[3] = 'c' wouldn’t work.

Edit: Did I misunderstand your scenario? I think a case where a slice for a buffer itself is var is quite rare.

1 Like

It’s not rare enough that I can’t put my finger on examples. But let’s assume it’s quite rare… is that sufficient to say that const s1[] u8 should result in immutable elements and var s1[] u8 should result in mutable elements? That doesn’t quite make sense even if it’s quite rare. Furthermore, if it’s quite rare, couldn’t that be an argument for, “ok, fine, if we’re changing the default constness of elements of an array, then we’ll change it across the board, whether the array itself is declared a const or a var.” ? Maybe I’m missing something… perhaps there’s some reason they should be different.