Const slice can be mutated?

The following piece of code confused me:

const std = @import("std");
const assert = std.debug.assert;

pub fn evil(x: []i64) void {
    x[0] = 42;
}

pub fn main() !void {
    var x: [2]i64 = .{ 0, 1 };
    const y: []const i64 = &x;
    assert(y[0] == 0);
    evil(&x);
    assert(y[0] == 42); // wait I thought it is const????
}

My expectation was, that this was a compile error. Or that y is a copy of x and that this is a runtime error.
So my question is, what assumptions can I make, when reading const in the code? What guarantees does const give? It seems const makes it impossible to rebind a variable and also prevents mutating sub data directly. But there can still be some “far range” mutations? Or is this a bug?

A slice is a pointer + a length, so y is just a const pointer to the mutable x array. That is, y[0] = 42 would be an error, but x[0] = 42 is totally allowed.

Perhaps this behavior might make more sense with a manually created slice type:

// ~equivalent to `[]const i64` under the hood
const ConstSlice = struct {
    ptr: [*]const i64, // pointer to some number of `i64`s
    len: usize, // the number of `i64`s
};

pub fn main() !void {
    var x: [2]i64 = .{ 0, 1 };
    const y = ConstSlice{
        .ptr = &x, // coerces to [*]const i64
        .len = x.len,
    };
    assert(y.ptr[0] == 0);
    evil(&x);
    assert(y.ptr[0] == 42); // wait I thought it is const????
                            // ^ ask yourself what "it" is you're referring to here
}

You may be interested in the documentation on arrays and slices if you haven’t already seen it.


Also worth noting that if you want to make sure evil cannot mutate the slice, you can make the parameter a const slice:

pub fn evil(x: []const i64) void {
    x[0] = 42; // error: cannot assign to constant
}
5 Likes

Now this may seem a bit counter intuitive, but const does not mean that the underlying memory is constant. const only means, that you can’t change it through this variable.

For example if you see const slice/pointer in a function parameter, then the function is not allowed to change the underlying memory, but you as the caller can change it at any time.
Or if you get a const slice/pointer from a function, it means that you are not allowed to change it, but the data structure you got it from may change it.

So I would say that const is a useful tool for defining function contracts, but not so useful for ensuring that the memory never changes.

If you do want to ensure that the memory doesn’t change, then you need to make the source constant, in your example you could do:

const x: [2]i64 = .{0, 1};

If you have a non-trivial initialization, it’s also quite common to see patterns like this:

const x: [2]i64 = blk: {
    var x_var: [2]i64 = undefined;
    for(&x_var, 0..) |*elem, i| {
        elem.* = @intCast(i);
    }
    break :blk x_var;
};
3 Likes

Thanks a lot, these are both great answer! I could not decide, which to select, so zig to the rescue:

const std = @import("std");
const rand = std.rand;

pub fn main() void {
    var rng = rand.DefaultPrng.init(42);

    const Answer = enum {
        squeek502,
        IntegratedQuantum,
    };
    const answer = (rng.random().enumValue(Answer));

    std.debug.print("Accept: {any}\n", .{answer});
}
Accept: main.main.Answer.IntegratedQuantum
6 Likes