Wrangling casts when adding signed values to an unsigned integer

I have some code where I have a Position which is just a struct { x: usize, y: usize }, and I want to obtain a new Position with new x and y based on a given direction enum value.

My initial attempt looked like this, which should show my intent:

        const new_position: Position = .{
            .x = position.x + switch (direction) {
                .right => 1,
                .left => -1,
                else => 0,
            },
            .y = position.y + switch (direction) {
                .down => 1,
                .up => -1,
                else => 0,
            },
        };

However, the first error I get from this is:

src/grid.zig:11:27: error: value with comptime-only type 'comptime_int' depends on runtime control flow
            .x = position.x + switch (direction) {
                              ^~~~~~

So okay, we’ll make it so the switch evaluates to an isize instead of a comptime_int (although it doesn’t feel like a very concise way to do it):

        const new_position: Position = .{
            .x = position.x + @as(isize, switch (direction) {
                .right => 1,
                .left => -1,
                else => 0,
            }),
            .y = position.y + @as(isize, switch (direction) {
                .down => 1,
                .up => -1,
                else => 0,
            }),
        };

But then we get:

src/grid.zig:11:25: error: incompatible types: 'usize' and 'isize'
            .x = position.x + @as(isize, switch (direction) {
                 ~~~~~~~~~~~^

Okay, so the problem now is adding an isize to a usize. I’ve read other posts on this subject, but I’m still not sure I understand the issue. I get that these types each represent a different range of values but… is the suggestion that the problem is over/underflow or something? But subtracting an isize from an isize can still underflow, so that can’t be right. So my understanding of the problem must be wrong

So okay, let’s cast the left-side to an isize too and then cast the results back to a usize:

        const new_position: Position = .{
            .x = @intCast(@as(isize, @intCast(position.x)) + @as(isize, switch (direction) {
                .right => 1,
                .left => -1,
                else => 0,
            })),
            .y = @intCast(@as(isize, @intCast(position.y)) + @as(isize, switch (direction) {
                .down => 1,
                .up => -1,
                else => 0,
            })),
        };

Great, now the compiler is happy! But I really just did the most basic thing in response to the reported errors, and I feel like there must be some better way to do it and that I’m missing something important.

Ultimately, I can rewrite it as this, which requires no casts, but I would prefer the other approach if there were a nice way to express it without too much clutter:

        const new_position: Position = switch (direction) {
            .up => .{
                .x = position.x,
                .y = position.y - 1,
            },
            .right => .{
                .x = position.x + 1,
                .y = position.y,
            },
            .down => .{
                .x = position.x,
                .y = position.y + 1,
            },
            .left => .{
                .x = position.x - 1,
                .y = position.y,
            },
        };

So my question has two parts:

  1. What’s the nicer way to implement this?
  2. What am I not understanding about why I’m not allowed to add an isize to a usize? Why is it okay to add a negative comptime_int to an usize, but not okay to add an isize?

Thanks!

1 Like

Because it is unclear what the semantics of that would be. e.g. what would be the result type of usize + isize? What about isize + usize? Should it take the one on the left? In that case addition wouldn’t be commutative anymore.
Zig tries to be explicit instead of relying on rules that are difficult to understand and remember.

Because you are not adding a negative comptime_int, you are subtracting a positive comptime_int, which can be turned into a usize.
e.g. the following example fails, as it should

    var x: usize = 0;
    x += -1;

I personally would propose the following

        const new_position: Position = .{
            .x = switch (direction) {
                .right => position.x + 1,
                .left => position.x - 1,
                else => position.x,
            },
            .y = switch (direction) {
                .down => position.y + 1,
                .up => position.y - 1,
                else => position.y,
            },
        };
1 Like

I wasn’t thinking it would use the left type - I thought that it would look at the result location type, which is usize. That would still be commutative, right? Given that Zig does a lot with return location semantics, I’m surprised it doesn’t here. (Decided to rewrite this bit of my response)

Isn’t the result type usize because I’m assigning to .x which is usize? So then there’s only one reasonable way to interpret the expression, isn’t there?

Edit: Sorry, I’m thinking about this as we go, so my thoughts are a bit all over the place. I suppose you could still say that there are two ways this could go: implicitly cast both to usize before doing the addition (which would obviously be bad in my particular case), or implicitly cast both to isize. (Another edit: wait, is this even true? Do they both have to be cast to the same type at all?) For some reason, the latter feels like the “one reasonable way”, but maybe that’s where I’m wrong, since that would be bad in the case where the usize value is greater than the max isize value.

I suppose I understand that in principle, but I don’t understand the reason behind it. Why is x += -1; something for the compiler to have a strong opinion about, but x -= 1; is not? The potential consequences of both are identical, no? I’m beginning to think the reasoning something to do with how they’ll map to machine code, but I’m not sure.

Edit: I think I’m starting to understand this too. I think I’m getting stuck on the fact that I know I’m adding a negative number in this case (and I suppose the compiler should know that too, since it’s a comptime_int). However, in the general case, x += -1; can’t be transformed into x -= 1; because the right hand side might not be negative (it can be any possible isize). And then because of that, we’re back to the problem of some_usize = some_usize + some_isize and there’s ambiguity about whether to cast the usize to an isize or the isize to a usize, both of which have downsides depending on the values being represented. I’m still not 100% confident on this yet, but this where I’m at.

And thanks for the proposed example - that’s a nice way of doing it that I hadn’t considered.

The first solution to try (imo) is to change the maths to avoid this issue, as @IntegratedQuantum proposed.

If you can’t do that, there are a couple of ways to go:

  • cast the types, which way you go depends on what you want, you might need multiple casts
  • zig coerces integers when doing so will never lose information, meaning smaller types coerce to larger types. Now, signed types will never coerce to unsigned as there is no case where that could never lose information.
    But unsigned types can coerce to signed types, if the signed type can represent all the positive values of the unsigned type.

Which is a lot to say you can do this:

const a: u8 = 8;
const b: i8 = -5;
const c = @as(i9, a) + b;

ofc the result is now an i9, but you can cast it back down.

The benefit is that you won’t lose information during the maths, there are plenty of cases where during some maths, a value might dip outside the range of an integer type but will be in the range once all the maths done.

2 Likes

Also consider the saturating and truncating operators.

2 Likes

There is a truncating cast, truncating operators are the saturating operators.

You were probably thinking about the wrapping operators.

Operator table

1 Like

Usually I use either a function like this:


Or instead just use a type like this struct { x: isize, y: isize }, I think especially with vector math it is helpful when your vector type can represent both positions and directions.

That way your switch on direction can return a direction vector and you can do const new_position = position.add(direction);

If you later have some code that always needs to act on position values that never can be negative you also could have 2 types, something like Position and ChunkCoord where the latter would be used to index into some arrays, then you can have some function that converts your position to a chunk coord and that can assert at the same time that no part is negative.

1 Like

Hello, regarding your requirement: a point where x and y are both unsigned integers, but you need to perform operations using signed vectors.

You’re using unsigned integers to ensure the points’ x and y won’t be lower than 0, right?

Then, when the point is (0,0), if you receive a vector operation with a negative direction, what behavior do you expect? Do you want it to be truncated and still be (0,0)? Do you want it to become a wraparound unsigned overflow value? Do you want it to throw an error directly?

I believe that this definition is ambiguous in operations between unsigned and signed integers. You must specify a definition for this, therefore you need to create a unique API for this type of operation; ordinary arithmetic notation is unlikely to meet your needs.

2 Likes

I should have mentioned that a precondition of this particular bit of code is that the direction won’t move the position out of bounds.

Also, the reason I’m not using a general vector type is just because is more domain-specific code where positions must be positive and movement is only ever represented by a single step in one direction.

However, these questions have gotten me thinking more about it. I suppose I could change the position to use isizes since I have to enforce the lower and upper bounds either way - just feels like a waste of a bit, but in the grand scheme of things, it probably isn’t going to matter.

What am I not understanding about why I’m not allowed to add an isize to a usize?

Possibly nothing – there may eventually be language changes to make this sort of thing less verbose:

(tho to be clear, the other responses here aren’t wrong – I believe it’s like you said above: its ambiguous “whether to cast the usize to an isize or the isize to a usize, both of which have downsides depending on the values being represented”, and zig currently throws up its hands and makes you figure it out yourself)

Interesting reference, thanks!

I think one thing that keeps throwing me off is my understanding that in 2’s complement you can add unsigned and signed integers without any kind of binary conversion necessary. So that’s why I said “Wait, is this even true? Do they both have to be cast to the same type at all?”

Can’t we just add a signed int to an unsigned int with the same bit width and then interpret it as an unsigned int and all is well and good? Of course, there are overflows/underflows that can happen, but Zig could still put in runtime checks for that in debug builds, right? Particularly as it knows the result location type.

I’m possibly just deriving the same “arithmetic compatibility” as described in the other thread, but I’m not certain. Is there something else I’m missing here?

The thing with that description is that it makes it sound easier than it is.

Zig likes being explicit about what the code is doing and when there are a bunch of things that happen implicitly and automatically then you would easily create cases where it is difficult to understand what it is doing (especially in corner cases and their cascading effects).

Lets say we use two’s complement to add -1 to another number, well -1 is all bits set to 1 so we are guaranteed to overflow except if we add it to 0.
So if we practically always overflow and always use wrap-around arithmetic then there is nothing left to perform runtime checks on.

So we can do this using wrap around arithmetic:

const std = @import("std");

pub fn main() !void {
    const minus_one: i32 = -1;
    const x_pos: u32 = 5;

    const treat_as_unsigned: u32 = @bitCast(minus_one);
    const result = x_pos +% treat_as_unsigned;

    std.debug.print("result: {}\n", .{result});
}

But this isn’t useful if we specifically want to avoid wrap-around, because doing this we opt-in to wrap around behavior and the compiler can no longer give us errors or safety-checks about it (because how is the compiler supposed to know in which cases you like overflow vs not).

If you instead go through the “hurdles” and describe your arithmetic to Zig without using wrap around, then you for example retain the ability to use Zig’s overflow detection as a useful tool that tells you about unexpected results.


Also note that some of the builtins aren’t actually operations, but instead just tell the compiler that something is allowed to happen, so instead of getting a compile error, the compiler allows you to do the thing when you explicitly ask for it.

So it isn’t so much about whether it is possible to do the operation on the lowlevel (lowlevel everything is just bits anyways), but more so whether the programmer is expecting specific semantics within that part of the program.

There are cases where it would be good to have some things work without giving the compiler the full description of every minute detail, but from reading the sort of discussions around making math operations easier with less need for type-casts and builtins, I think I will leave it to others* to figure out when and how it can be made simpler (which would be nice to have), without losing the more important part that makes Zig good:

  • that you can precisely specify the semantics your program should use
  • without needing to deal with imprecise magical behavior you can’t turn off
  • or have to explicitly opt-out of, instead of opt-in to

*Not saying you have to leave it up to others, that is just my personal choice, because the topic makes my head spin and I am not sure whether I would come up with a meaningful improvement over what we already have or what people already discuss. I think I would like having different “algebras” you could use in some limited scope that then had their own rules on how the operators work, but that is probably a bit too operator-overload-y/multi-paradigm-language-esc for Zig

6 Likes

That’s basically why I use an “almost always (signed) int” approach both in C and Zig and also compile my C code with -Wsign-conversion. Unsigned integers are really only useful for bit twiddling and modulo math, but not for regular values that “can’t be negative” like texture dimensions or array indices, since you’ll inevitably want to compute such values using signed integers (like adding a negative offset to an array index). And yes, I think loop counters, array indices and array/slice lengths in Zig should be signed integers :slight_smile:

PS: also, before that’s coming up: optimizing compilers optimize a signed range check ((i >= 0) && (i < len)) to a single unsigned comparison ((uint)i < len), since the concept of associating an integer value with a persistent “signedness” doesn’t exist down in assembly, instead signedness is a property of a specific operation on sign-agnostic integers.

PPS: I sometimes wonder if languages should have signed/unsigned operators on sign-agnostic integers instead of signed/unsigned integer types - especially now that 2s complement has won - the signedness hint is only needed for sign extension of narrower to wider integers, e.g. whether to fill the top bits with zeroes (for unsigned operations) or with a copy of the MSB (for signed operations.

7 Likes

“usize” is my bane. It costs me way more useless casts than just about anything else in Zig.

An index to an array really needs to just @intCast() by default regardless of size or sign. Arrays have bounds checks anyhow, so the implicit @intCast() doesn’t even cost anything or make things less safe. (Well, I guess you’d need to do a check against 0 if the type is signed …)

A loop variable really needs to be able to define its type. The number of times I wind up with this boilerplate:

    {  // Scope needed or need_a_u32 will name collide since loop variable is not contained
        var need_a_u32: u32 = 0;
        while(need_a_u32 < 12)  : (need_a_u32 += 1){
            doSomethingVulkan_0(need_a_u32);
            doSomethingVulkan_1(need_a_u32);
            doSomethingVulkan_2(need_a_u32);
        }
    }

instead of the moral equivalent of:

    for (0..12) |need_a_u32: u32| {
        doSomethingVulkan_0(need_a_u32);
        doSomethingVulkan_1(need_a_u32);
        doSomethingVulkan_2(need_a_u32);
    }

simply because I’m on a 64-bit machine and usize is 64-bits instead of 32-bits is maddening.

3 Likes

Another option is:

    for (0..12) |i_usize| {
        const need_a_u32: u32 = @intCast(i_usize);
        doSomethingVulkan_0(need_a_u32);
        doSomethingVulkan_1(need_a_u32);
        doSomethingVulkan_2(need_a_u32);
    }

Sure, but some of the Vulkan functions and structs need a u32, some an i32, some a u64, and some an i16.

I already have the issue that those Vulkan functions are actually:

{
    const vkrv = vkFoo(u64_handle, some_u16, &some_u32);
    assert(vkrv == VK_SUCCESS);
}

which is adding even more visual and scope noise.

Given that Zig does runtime checks on @intCast() anyway, it’s not clear how much value there is in not just doing the conversion when the semantics are clearly defined (array indices, function arguments, struct member assignment, etc.).

I do agree that Zig should not make assumptions when the different sides of operators are unclear, though. This is the case where I have had real bugs that were a PITA to track down.

The “assignment operators” (+=, -=, *=, etc.) are a grey area where it seems like the LHS type should dominate.

1 Like

this is unrelated to the main point here, but fyi this works: (even in release mode)

std.debug.assert(VK_SUCCESS == vkFoo(u64_handle, some_u16, &some_u32));

(see e.g. Key semantics of std.debug.assert and surrounding discussion)

2 Likes

Thanks, I still hate it :wink: (yeah, C habits die hard, but it just looks wrong to me)

2 Likes
  1. It buries the important part (the function call) in the visual noise of the assert. Writing Vulkan is hard enough without hiding your function calls and making it easy to miss important details in the function call.

  2. That works until that assert fires and now you need to look at that result value with either a logging statement or a debugger. And now you have to separate it anyway.

  3. If I have to think about the semantics of X, it is FAR less bug prone to simplify the code until someone reading it doesn’t have to think about such semantics.

2 Likes