Constness of fields and coercion

Okay, I’ll try to clarify a few things.

First of all, I think it was a bit unfortunate to use the terms super-type, sub-type, co-variance and contra-variance without really explaining them. We don’t use that terminology in Zig, I guess?

Zig, however, allows automatic coercion from some types to other types. For example:

  • []T[]const T
  • u8u16
  • i32i64

So we could say that []const T is a super-type of []T, because we can store any value of type []T in a variable of typ []const T. Also u16 is a super-type of u8. And i64 is a super-type of i32. Coercion is possible where the target type is a super-type of the original type. Let’s try:

pub fn main() void {
    var buf: [3]usize = [3]usize{ 0, 0, 0 };
    const a: []usize = &buf;
    const b: []const usize = a; // this works!
    _ = b;
    const c: u8 = 5;
    const d: u16 = c; // this works!
    _ = d;
    const e: i32 = 1000000;
    const f: i64 = e; //this works!
    _ = f;
}

Interestingly (though maybe not surprisingly), Zig doesn’t always allow coercion if this sub-type/super-type relationship exists nestedly. Consider:

const U8Dispenser = fn () u8;
const OtherU8Dispenser = fn () u8;
const U16Dispenser = fn () u16;

fn u8Dispenser() u8 {
    return 5;
}

fn u16Dispenser() u16 {
    return 1000;
}

pub fn main() void {
    @import("std").debug.assert(U8Dispenser == OtherU8Dispenser);
    const a: U8Dispenser = u8Dispenser;
    const b: OtherU8Dispenser = a; // this works!
    _ = b;
    //const c: U16Dispenser = a; // but this doesn't work!
    //_ = c;
}

I guess it makes sense because both functions may work differently in assembler, so we can’t just cast them.

However, that the following works might be surprising :tada: (but I appreciate that it does work):

const Dispenser = fn () []u8;
const ConstDispenser = fn () []const u8;

var buf: [3]u8 = [3]u8{ 1, 2, 3 };

fn dispenser() []u8 {
    return &buf;
}   

fn constDispenser() []const u8 {
    return &buf;
}   

pub fn main() void {
    const a: Dispenser = dispenser;    
    const b: ConstDispenser = a; // this works!  
    _ = b;
}

Even more surprising might be that the following works (and I think it’s right that it works):

const Consumer = fn (arg: []u8) void;
const ConstConsumer = fn (arg: []const u8) void;

fn consumer(arg: []u8) void {
    for (0..arg.len) |i| arg[i] = i;
}

fn constConsumer(arg: []const u8) void {
    for (0..arg.len) |i| {
        @import("std").debug.print("{}", .{i});
    }
}

pub fn main() void {
    const a: ConstConsumer = constConsumer;
    const b: Consumer = a; // this works!
    _ = b;
}

So we can coerce a Dispenser to a ConstDispenser, but for consumers it works the other way around: a ConstConsumer can be coerced into a Consumer.

(These code examples are all valid Zig as of version 0.15.0-dev.1148+67e6df431.)

Why is it reversed in case of the consumer? That is because a function type acts contra-variant with regard to its argument types. That means the super-type/sub-type relationship changes:

  • []const u8 is a super-type of []u8, but
  • fn (arg: []const u8) void is a sub-type of fn (arg: []u8) void)

In other words, we can store a value of type fn (arg: []const u8) void in a variable or constant of type fn (arg: []u8) void).

Until here, Zig works really nice in my opinion.

Now I know that two distinct structs with equal fields are distinct types, and that is also good and should not be changed.

However, I do believe that it would be possible to have a similar super-type/sub-type relationship with structs that only differ in const-ness of their fields and(!) which share the same “source location”. Citing the 0.12 release notes:

In 0.12.0, this has changed. Namespace types are now deduplicated based on two factors: their source location, and their captures.

Now the issue is that these captures always seem to result in a totally different type, even if the captured type only differs by const-ness and when it’s only used for a field type, where co-variance might be applicable, i.e. where a super-type should/could/would result in the resulting type being a super-type as well.

This isn’t extremely exotic. As demonstrated above, Zig already follows the notion of co-variance in case of function types (example above that used the Dispenser and ConstDispenser types).

I wonder if

  • it would make (theoretically) sense to extend this concept to returned structs,
  • there would be unforseen side-effects, and if
  • it is technically feasible.

Moreover, I wonder how to proceed when types are captured in other places than for field types. Maybe there could exist some co-variance, contra-variance, or invariance rules, but I’m really not sure.