After reading this Docs topic, I wanted to play with @constCast to see how it works. I was surprised by the following behavior:
// The data itself is const.
const x: usize = 42;
// Remove const qualifier from pointer to data.
const ptr: *usize = @constCast(&x);
// This prints 42.
std.debug.print("{}\n", .{ptr.*});
// zig 0.11 debug mode: Bus error at address 0x100ef2478
// zig 0.11 release modes: No error
// zig 0.12 all modes: No error
ptr.* += 1;
// This prints 42.
std.debug.print("{}\n", .{ptr.*});
So I wonder why the mutation doesn’t succeed but doesn’t produce an error either (except for zig 0.11 debug’s Bus error)?
I don’t know if Zig specifies this, but because the code is generated by LLVM, it probably follows the rules of C++. In C++, mutating a constant value (that is, the variable itself is const), is undefined behavior. Removing const from a pointer and mutating the pointed-to value is only well-defined behavior if the original value was not const. Since this is undefined, the compiler probably removed the statement.
Even if, somehow, the statement made into the final binary, I believe Zig puts constants into a dedicated constant section in the binary, which is the default in LLVM. In this case, the OS enforces constantness.
I think I have an idea. The constant is placed in the constant memory that cannot be modified. LLVM silently drops the increment operation involving that range of the address space. The following modified version of your code prints addresses in addition to the values. I also included the case when variable is mutable (var). One can clearly see the completely different address segment.
const std = @import("std");
pub fn main() !void {
// c is const, v is mutable
const c: usize = 42;
// &c points to const portion of the memory
std.debug.print(" &c = {*} ({})\n", .{&c, c});
var v: usize = 42;
// &v points to stack memory
std.debug.print(" &v = {*} ({})\n", .{&v, v});
const cpc = &c; // const pointer to const value
std.debug.print("cpc = {*} ({})\n", .{cpc, cpc.*});
const pv = &v; // const pointer to mutable value
std.debug.print(" pv = {*} ({})\n", .{pv, pv.*});
// Remove const qualifier from pointer to data.
const pc: *usize = @constCast(&c);
std.debug.print(" pc = {*} ({})\n", .{pc, pc.*});
pc.* += 1; // cannot modify values in const memory. Silently ignored
pv.* += 1; // modifies memory on stack
std.debug.print("--- after increment ----\n", .{});
std.debug.print(" pc = {*} ({})\n", .{pc, pc.*});
std.debug.print(" pv = {*} ({})\n", .{pv, pv.*});
}
So many times I’ve heard that undefined behavior simply means “let the compiler do whatever it wants in this case.” But I never had a concrete example of what that meant. Now I do. :^)
Strange things are coming into my mind… if we can use const qualifier in type, why can’t we use var qualifier in a similar fashion? I mean writing this line like this:
Hey I think that’s a good idea. Some may complain about verbosity, but it would be perfectly in line with Zig’s mantra of being explicit and placing readability over ease of writing code. For example, it would eliminate much confusion in function signatures too:
fn foo(mutable_bar: *var Bar, inmutable_bar: *const Bar) ...
// and for slices
[]const []var u8
I guess the key term here is consistency. It would be more consistent that current syntax.
Yes, this is what I meant between the lines. But I do not understand at all, why change mutability from not-mutable to mutable, no matter what way, using that strange @constCast or *var. But, yeah, the latter it’s more consistent and… I would say, more symmetrical.
Normally you must get bus error, segment violation or something similar.
Since & operator is used to get it’s storage location, zig cannot eliminate x storage.
My conclusion is that this is a bug, zig stores x in mutable data section and not in a read only data section.
Rust language is using mut for this, instead of var.
They consider everything immutable (const by default) and you have to explicitly say mut in order to have a variable.