Type identify rules

In the following code, T1 != T2. But should they equal to each other?

const std = @import("std");

pub fn main() !void {
    const T1 = foo(i8);
    const T2 = foo(u8);
    T1.x = @splat(2);
    std.debug.print("{any}, {any}\n", .{T1.x, T2.x}); // { 2 }, { 1 }
    std.debug.print("{}\n", .{T1 == T2});             // false
    
    const T3 = bar(i8);
    const T4 = bar(u8);
    T3.x = @splat(2);
    std.debug.print("{any}, {any}\n", .{T3.x, T4.x}); // { 2 }, { 2 }
    std.debug.print("{}\n", .{T3 == T4});             // true
}

fn foo(T: type) type {
    return struct {
        var x: [@sizeOf(T)]usize = @splat(1);
    };
}

fn bar(T: type) type {
    const N = @sizeOf(T);
    
    return struct {
        var x: [N]usize = @splat(1);
    };
}

Based on this post:

I think the answer is no.

There is a bit of wiggle room, you could argue that only the size of T should be depended upon and this shouldn’t be a full dependency on T itself, but as far as I know Zig currently doesn’t have such fine grained dependency tracking and instead just expects the programmer to write it in such a way, so that you don’t make your type depend on more than you intended.


Or said another way, I think both behaviors are a reasonable thing to want based on use-case, so it seems reasonable to have a way to write each of them.

4 Likes

So, can I describe the rule as: Two generic types are equal if their definition locations are the same and their substituted textual representations are equal? Two textual representations are equal if every pair of the corresponding types identifiers appearing in them represent the same value.

From the post I linked:


I don’t think bringing imagined textual representations / expansions into it makes sense, when the above describes the actual mechanism.

2 Likes

I do think this is better. It’s subtle, but there are a few places where Zig cuts comptime-knowability propagation short of where it could. Another example is that you can’t pass a mutable var via anytype into a function returning a type, even if all you do with it is examine the type.

The behavior isn’t wrong in any sense: it’s a decision to resolve things a certain way. I believe there are benefits to be had from taking it further, but making that case will be long and abstruse and is going to have to wait for another occasion.

I will say this: explaining why foo ā€˜captures’ T, and bar does not, is difficult. That’s motive to make it easier, although countervailing reasons not to may prove sufficient.

2 Likes

Actually I dislike this particular example when the function is inlined. This made it very inelegant to make work my ranged-integer functions work with both regular int and comptime-integers.

The rules around generic type equality are also the reason for these pieces of code in the ArrayList implementation (link, link):

pub fn ArrayList(comptime T: type) type {
    return array_list.Aligned(T, null);
}
pub fn Aligned(comptime T: type, comptime alignment: ?mem.Alignment) type {
    if (alignment) |a| {
        if (a.toByteUnits() == @alignOf(T)) {
            return Aligned(T, null);
        }
    }
    return struct { ... };
}

This makes std.ArrayList(u32) and std.array_list.Aligned(u32, @alignOf(u32)) result in the same type.

1 Like