Skip to content

Proposal: allow peer type resolution to expand integer sizes #15737

Description

@mlugg

Motivation

Consider the following code snippet:

var a: u8 = undefined;
var b: i8 = undefined;
var c = a + b;

What is the type of c? Under the current semantics, this code triggers a compile error, with peer type resolution incorrectly resolving the type as u8 (if you flip the operands around, it instead resolves it as an i8). But there's clearly a type which both can coerce to: i9!

Proposal

In #15726, I've been fixing some peer type resolution cases, and this was a thing I ended up looking into. Essentially, peer type resolution on conflicting int types should choose a final type by considering the bounds of each type in question and combining them (this is somewhat similar to the recent work on @min/@max). Practically, this means that when you perform peer type resolution on a set of fixed-width integer types, if their signedness differs, the resulting type may have a bit size one more than the bit size of the input - i.e. if uX and iX are peers, the result will be i(X+1).

This follows very nicely into #3806, which would allow the bounds to be specified even more precisely than the arbitrary-bit-width integers we already have. If that proposal is implemented, this proposal essentially becomes that PTR between @Int(a, b) and @Int(c, d) should result in @Int(@min(a, c), @max(b, d)). I think that's a stance most people would agree upon if ranged integers are implemented; this proposal is simply to implement the equivalent of this behaviour for status quo integers.

Implementation

I looked into this as a part of my initial implementation of #15726, so have the work saved locally. It's relatively simple (I'd argue simpler than the implementation of status quo in fact), plus should pretty much automatically retain the correct behaviour if #3806 is implemented.

Activity

  1. added
    proposalThis issue suggests language modifications. If it also has the "accepted" label then it is planned.
    on May 16, 2023
  2. e4m2 commented on May 17, 2023

    @e4m2
    Contributor

    Question

    Would this also mean that (u|i)N * (u|i)N -> (u|i)(2*N) (essentially equivalent to std.math.mulWide), etc. for every integer operation, or am I misunderstanding?

    Regarding the original

    In C, embedded programmers often have to go out of their way to avoid accidental int promotion. It seems this is not that different (despite the semantics being much saner overall) and could be a foot-gun for some users, especially given the widespread use of type inference.

    Just for reference, with the new _BitInt feature in C23, the expression evaluates as unsigned _BitInt(N) + _BitInt(N) -> unsigned _BitInt(N).

  3. likern commented on May 17, 2023

    @likern

    I like the idea. It will immediately signal "Something is wrong, you are doing unsafe operations". But this should be very clearly articulated in language specs.

    And we should clearly provide way to make this operations safe and staying within u8 (math with overflow checking?). As I'm working with database blocks, I can't just write u9 to disk, where u8 was expected.

    Also what to do with performance? As I understand on hardware u16 will be allocated and it will be just u16 addition operation? Don't think working with u9 is a wise idea.

    From practical view I think this proposal bans mixin u8 + i8 operations. No one will deal with u9 in reality. But from security perspective making this explicit is very important, so many hidden bugs 🪲 in such promotions/conversions.

    Security and safety is much more important than faster integer math, especially considering memory access is much slower.

  4. mlugg commented on May 17, 2023

    @mlugg
    MemberAuthor

    @e4m2

    Would this also mean that (u|i)N * (u|i)N -> (u|i)(2*N) (essentially equivalent to std.math.mulWide), etc. for every integer operation, or am I misunderstanding?

    Ah, no - that's about something else. @likern, I think you have the same misunderstanding - that's my bad, let me clarify.

    The promotion here is not happening to ensure the addition result is always in range. That's a separate proposal, and something that may well end up being a part of #3806 if it gets in. That would be the thing where uX * uX gives you a u(2*X) for instance; again, it's something which I think would fit nicely with ranged ints, but is certainly a more drastic change.

    This change is about peer type resolution, which is essentially the question of: "given types T1..Tn, what type can I use which fits all of them"? Arithmetic operations do (currently) use PTR, but in hindsight they're a misleading example here - instead, consider something like this:

    var a: u8 = undefined;
    var b: i8 = undefined;
    const c = if (foo()) a else b;

    What type should c have? It needs to be a type which can hold a u8 or an i8. This also comes up a lot with large switches:

    switch (thing) {
        .foo => a,
        .bar => b,
        .baz => c,
        else => d,
    }

    The result type of this expression is found through peer type resolution of the types of a, b, c, and d. Peer type resolution can do things like stripping off sentinels if needed, adding const to pointers, wrappings things in error unions or optionals, etc. For instance, if you run PTR on the types error{Foo} and u32, you get error{Foo}!u32, while *const [4]u8 and [*]u8 resolve to [*]const u8. The idea isn't to make operations like arithmetic safe, but instead to find a supertype of all your input types that they can coerce to in order to be "combined" in some way (in this case just by being the result of the same conditional expression).

    Now we can consider this again in the context of integers. The results of PTR where all operand types are the same are unchanged under this proposal. For instance, arithmetic like u8 + u8 still yields a u8; i16 + i16 gives an i16; etc. The point here is specifically about the case of peer type resolution between signed and unsigned integers. Again, I think this is much clearer to understand if you consider an if or switch statement (see the first code snippet in this comment). Currently, that statement would emit an error; this proposal is to instead make it emit an i9. (Again, the equivalent in #3806 is that you just get a ranged int containing both ranges, which in fact appears to be an actual part of that proposal!)

    I hope this explanation makes sense? Sorry for the slight ambiguity in my initial overview :-]

  5. likern commented on May 17, 2023

    @likern

    This change is about peer type resolution, which is essentially the question of: "given types T1..Tn, what type can I use which fits all of them"?

    What does mean "fits" in this proposal?

    As I understand if I const c = @as(u8, 255) + @as(i8, 127) what would I get? Looks like overflow for i9? What the usefulness in this regard of that i9?
    And how u8 + i8 fits into i9?

    I think intentional compilation error with good explanation is more sane and safe, as in if else branches.

    Develop will stop here and think, probably double check condition branches (check overflow, convert to u8, etc.).

    What I think more useful is to be able to borrow u16 type of c in this example and check all branches fits into u16. This helpful message to add type to c could be provided by the compiler.

    const c: u16 = if(cond) @as(u8, 255) else @as(i8, 127)`;
  6. ehaas commented on May 17, 2023

    @ehaas
    SponsorContributor

    @likern
    Peer Type Resolution is about finding a common type that all of the operands can coerce to (meaning, it is an unambiguous and safe transformation); the result type is not really under discussion in this proposal. So u8 + u8 PTR is still u8 since both operands trivially coerce to u8. u8 + i8 could coerce to i9 in this proposal because i9 can hold the full range of values of both i8 and u8.

    Ensuring that an arithmetic result fits into the result type is a bigger change (e.g. u8 * u8 would require u16, which this proposal doesn't address; you'd probably need to introduce new operators like +^ or something like that to avoid breaking almost all existing code, but I could see that being useful for writing code that is guaranteed not to overflow regardless of the build mode, without needing explicit @as casts).

  7. added this to the 0.12.0 milestone on May 17, 2023
  8. likern commented on May 17, 2023

    @likern

    @likern Peer Type Resolution is about finding a common type that all of the operands can coerce to (meaning, it is an unambiguous and safe transformation); the result type is not really under discussion in this proposal. So u8 + u8 PTR is still u8 since both operands trivially coerce to u8. u8 + i8 could coerce to i9 in this proposal because i9 can hold the full range of values of both i8 and u8.

    Ensuring that an arithmetic result fits into the result type is a bigger change (e.g. u8 * u8 would require u16, which this proposal doesn't address; you'd probably need to introduce new operators like +^ or something like that to avoid breaking almost all existing code, but I could see that being useful for writing code that is guaranteed not to overflow regardless of the build mode, without needing explicit @as casts).

    Thanks, I get it. "Unambiguous coercion". But what to do later with this i9 and how it would be represented in hardware?

    Since Zig is a system programming language, there is not much use of these i9 types. Except, of course, packing as unified struct of these bit types. But I think it's very specific use case and should stay that.

    People might otherwise start passing these i9 integers as function parameters, etc. to "shut down" compiler.

    And what should we get with i9 + u8? If just i9 why not just coerce to i16? Zig should represent real-life hardware, since it's system programming language.

  9. mlugg commented on May 18, 2023

    @mlugg
    MemberAuthor

    Thanks, I get it. "Unambiguous coercion". But what to do later with this i9 and how it would be represented in hardware?

    You can use arbitrary-bit-sized integers like any other integer. I believe it'll be represented as an i16 while doing arithmetic on it, with negligible runtime cost in release builds.

    Since Zig is a system programming language, there is not much use of these i9 types. Except, of course, packing as unified struct of these bit types. But I think it's very specific use case and should stay that.

    What makes you say that? Bit-sized integers are a useful feature of Zig for safety and documentation. For instance:

    • small uX integers are commonly used as enum tag types
    • the RHS of shifts is a bit-sized integer whose size is log2 of the LHS' size; e.g. you can only shift a u64 by a u6
    • the standard library represents Unicode codepoints as u21
    • the standard library represents alignments as u29

    People might otherwise start passing these i9 integers as function parameters, etc. to "shut down" compiler.

    These types are already used as parameters etc, and that's totally fine. What do you mean by "shut down the compiler"?

    And what should we get with i9 + u8? If just i9 why not just coerce to i16? Zig should represent real-life hardware, since it's system programming language.

    You could just as well ask why not coerce to an i32, or an i64. Informally, the point of peer type resolution is kind of to find the "closest" possible type which all peers coerce to. For instance, PTR between [*]u8 and *[5]u8 could technically result in *anyopaque, but that's not very useful, because it's unnecessarily different from the peer types (so it instead results in [*]u8). In this context, the type that's closest to our peer int types will be the one that's not any larger than it needs to be; for now, that's a type that's only one bit larger.

  10. likern commented on May 18, 2023

    @likern

    Thanks for clarification.

    What makes you say that? Bit-sized integers are a useful feature of Zig for safety and documentation. For instance:
    small uX integers are commonly used as enum tag types
    the RHS of shifts is a bit-sized integer whose size is log2 of the LHS' size; e.g. you can only shift a u64 by a u6
    the standard library represents Unicode codepoints as u21
    the standard library represents alignments as u29

    👍 Agree! Well, what I can say - lets do it! Thank you spending time for clarification and resolving objections.

  11. likern commented on May 18, 2023

    @likern

    @mlugg Is there proposal to borrow desired type from variable c for peer type resolution (can't find)?

    var a: u8 = undefined;
    var b: i8 = undefined;
    const c = if (foo()) a else b;

    Like to solve this issue with declaring type for c

    var a: u8 = undefined;
    var b: i8 = undefined;
    const c: u8 = if (foo()) a else b;

    I still think it's more clear. At the same time we can leave u8 + i8 as is, as a compiler error with clear message. Moreover while we have u21 for unicode code points, it has clear semantic.

  12. mlugg commented on May 18, 2023

    @mlugg
    MemberAuthor

    Is there proposal to borrow desired type from variable c for peer type resolution (can't find)?

    I think this already does what you're expecting. PTR is only used if the expression doesn't have a known "result type"; annotating the type of c in that snippet forwards it as the result type for both cases of the if, so in your example you'll get a compile error on the b since it can't coerce to a u8, and if you made the annotation i9 it would work.

    Moreover while we have u21 for unicode code points, it has clear semantic.

    What do you mean by this?

  13. likern commented on May 18, 2023

    @likern

    @mlugg It's stated here

    Unicode Standard has encoded characters in the range U+0000..U+10FFFF, which amounts to a 21-bit code space

  14. likern commented on May 18, 2023

    @likern

    I think this already does what you're expecting. PTR is only used if the expression doesn't have a known "result type"; annotating the type of c in that snippet forwards it as the result type for both cases of the if, so in your example you'll get a compile error on the b since it can't coerce to a u8, and if you made the annotation i9 it would work.

    Yes, thanks. I think problem is already solved in this regard, as I can see it.

  15. sg-james commented on May 18, 2023

    @sg-james

    When adding u8 to u16, you almost certainly mean for the result to be a u16, so it's ok for the compiler to just go ahead and do that. But when adding u8 to i8, this is not clear. You might want either a u8, an i8 or an error, depending on the context. Widening to i9 only pushes the problem down the road in most cases, I think, as the error will still resurface when the result is stored into a typed location, only now it may be on a different line.

    I think this line is the crux of the argument. Is the intent more clear here or not?

    For mixing signedness, I lean towards status quo

    var a: u8 = undefined;
    var b: i8 = undefined;
    var c = a + b; // error: expected type 'u8', found 'i8'
    

    vs. proposed

    var a: u8 = undefined;
    var b: i8 = undefined;
    var c = a + b; 
    // ...
    const v = some_arr[c]; // error: expected type 'usize', found 'i9'
    

    where I (suddenly) have an i9 I didn't expect

  16. nektro commented on May 18, 2023

    @nektro
    Contributor

    status quo has said that Zig will never automatically up-cast without the use of something explicit like @intCast. for that and the others mentioned above I dont think this is a good idea for Zig

  17. rohlem commented on May 20, 2023

    @rohlem
    Contributor

    Under the current semantics, this code triggers a compile error, with peer type resolution incorrectly resolving the type as u8 (if you flip the operands around, it instead resolves it as an i8).

    After rereading a bit, I'm unsure what this exactly means:

    • Is this an internal implementation detail?
    • Or are there scenarios in which this peer-type-resolution result can be accessed/queried/used via the language? (Maybe with multi-arg @TypeOf?)

    Since apparently there are compile errors regarding this in some scenarios, I would be inclined to view the cases where there is no compile error as bugs.
    From that perspective, it seems clear to me that we shouldn't strive to keep status-quo with those bugs.
    But in that case the actual pertinent question is:
    "Always allow such peer-type resolution (making it widen by one bit) or always make it a compile error?"

    While I personally think widening operations (followed by explicit @intCast) are the saner default overall,
    compile errors seem more in-line with the fixed-size operators we have in the language today.

  18. modified the milestones: 0.13.0, 0.12.0 on Jul 9, 2023
  19. modified the milestones: 0.14.0, 0.15.0 on Feb 9, 2025
  20. mlugg commented on Apr 24, 2026

    @mlugg
    MemberAuthor

    This proposal has problems compared with #3806, which I support as the appropriate solution to this issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    proposalThis issue suggests language modifications. If it also has the "accepted" label then it is planned.

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions