Proposal: Disallow arithmetic binary operators with operand types that differ from their result types

#24908 proposes a rule for the binary operator ++ to propagate the result type to its operands.
As far as I know, most binary operators currently do not propagate the result type to their operands; this is the current behavior:

test "shift, wrapping and saturating" {
    const a: u8 = 255;
    const b: u8 = 255;
    var c: u32 = a +% b;
    try std.testing.expectEqual(254, c);
    c = a +| b;
    try std.testing.expectEqual(255, c);
    c = a << 1;
    try std.testing.expectEqual(254, c);
    const d: u16 = 255;
    c = a + d;
    try std.testing.expectEqual(510, c);
}

If we accept the concept of type deduction for both the operands and the result of a binary operation, such as ++, then we might expect:

    const a: u8 = 255;
    const b: u8 = 255;
    var c: u32 = a +% b;
    try std.testing.expectEqual(510, c); // This is wrong in the current version

However, the objection is that this rule can also be surprising and lead to misuse:

Therefore, my proposal is to infer the result type of binary operations from the operands and prohibit implicit conversion of the operand types to the result type.

This means that the following will become a compilation error:

    const a: u8 = 255;
    const b: u8 = 255;
    var c: u32 = a +% b;
    try std.testing.expectEqual(254, c);

We need to rewrite it to the following code to keep the behavior consistent with the current version:

    const a: u8 = 255;
    const b: u8 = 255;
    var c: u32 = @intCast(@as(u8, a +% b));
    try std.testing.expectEqual(254, c);

Or convert the type of the operand to achieve another expected result:

    const a: u8 = 255;
    const b: u8 = 255;
    var c: u32 = @as(u32, a) +% @as(u32, b);
    try std.testing.expectEqual(510, c);

Increasing this limit also works well with #22182.

I don’t understand why this should be a compile error, with the current ‘Zig semantics’ the result is expected to be 254, since Zig has no integer promotion like C (for better or worse) - e.g. the result type of wraparound-adding two u8 is u8, and this result then gets assigned to an u32, which is allowed because no information is lost. Why should this operation require any casting since it is already ‘well-defined’?

try std.testing.expectEqual(510, c); // This is wrong in the current version

This expectEqual doesn’t make any sense for wraparound-adding two u8 IMHO.

For non-wrapping + a compile error might make sense if the compiler knows the inputs at compile time (e.g. moving the runtime overflow error to a comptime error).

Please don’t add even more @-clutter if possible, it’s already way past the acceptable pain-point :slight_smile:

What’s arguable IMHO is whether a non-wrapping add should produce a result which can fit the espression result (e
g adding two u8 could lead to an inferred result type of u9 and thus cannot produce an overflow), but the result of a wrapping-add of two u8 should always be an u8.

PS: I sometimes wonder if the C designers ran into all those issues too and then simply “invented” integer promotion as the solution :wink:

2 Likes

If zig insisted on not inferring result types for all arithmetic and binary operators, then I could assume that zig’s behavior was consistent, and I would expect its result to be 254.
However, the following proposals exist:

These proposals imply that the concept of result type propagation will be extended to operands.
If zig were to adopt the concept of result type propagation for some operators in the future, while others would not, it would be very confusing. If the above proposals are accepted, I would undoubtedly assume that the expected result here is 510.
At the same time, I agree that some people would expect it to be 254 based on its past behavior. To avoid inconsistency among different users here, I believe the only solution is to disallow automatic type conversion here.

IMHO this would only make sense for a non-wrapping add, but not for a wrapping add.

The idea of extending the result type to fit the result of an expression isn’t bad IMHO, but for wraparound-operations I would expect that the result is not extended. E.g. the result of a regular add of two u8’s is u9 (in CPU’s the 9-th bit is basically the carry-flag), but with a modulo-add the carry-flag should be ignored (e.g. the result doesn’t need an extra bit) because a wraparound within 8-bit is expected.

Funny enough, the rules for integer arithmetics are much simpler down on the CPU level, then up in Zig. Maybe it makes sense to expose the concept of a carry flag to Zig’s integer arithmetic :wink:

The problem is simply a syntactic inconsistency. I understand that most people’s intuition here is 254, as most languages ​​don’t adopt result positional semantics. However, once you accept that zig will adopt result positional semantics for most operators in the future and propagate the result type to the operands, the inconsistency in the wraparound operation here becomes a significant dissonance. In practice, cases where the operand and result types of a wraparound operation don’t match should be rare, so explicitly specifying them is acceptable.

I don’t see an inconsistency tbh.

The result of adding two u8 without wraparound is guaranteed to fit into an u9, so the result type is u9:

u8 + u8 => u9


but the result of adding two u8 with wraparound is u8, so the result type is u8:

u8 +% u8 => u8

A wraparound into an u9 simply doesn’t make sense since the MSB will always be zero anyway, and if you allow an additional bit, then adding two u8s cannot wraparound, the ‘wraparound’ would spill into the additional bit instead.

FWIW, I see wraparound-operations more in the ‘bit-operation corner’ than the ‘arithmetic’ corner, for instance modulo operations should mostly only be applied to unsigned integers.

3 Likes

This is the way most languages ​​think.
Based on current proposals to infer the result position type from the operands, this effectively introduces a new way of thinking: when the result position of an expression is u32, the operand type of the binary expression is inferred to be u32. Consequently, the operands are first converted to the inferred type before the operation is performed.
Proposals #16489 and #24167 both follow this logic. If we accept this logic for some operators, we naturally assume that other operators will follow suit.

IMHO this is a bad proposal then :smiley:

I would expect that a complex expression is split into simple subexpressions, and each subexpression has a result type, which is then carried upward the expression-AST to the root. E.g. if I have something like this:

const a: u2 = 2;
const b: u2 = 3;
const c: u1 = 1:
const d = c + (a + b);


or in types:

? = u1 + (u2 + u2);

I would expect this to resolve in the following steps:

? = u1 + (u2 + u2)
// subexpression u2 + u2 resolves to u3
? = u1 + u3;
// maybe u1 is extended to u3, but that doesn't matter for the result
? = u3 + u3;
// the result type becomes u4
u4 = u3 + u3;


and if the left hand side already defines a type, that doesn’t matter, since u4 can be assigned to u32 without loss of information
 but IMHO this shouldn’t mean that all expression inputs are first propagated to u32 (which wouldn’t work anyway if the left-hand-side type needs to be inferred from the expession result).

PS: the interesting part is now, what happens when I try to modulo-add two types of different length
 does the wraparound work on the short or longer type? IMHO the longer type should be picked:

? = u1 +% (u2 +% u2);
? = u1 +% u2;
u2 = u2 +% u2;
1 Like

Honestly, I don’t dislike your paradigm at all. It’s the automatic type deduction paradigm, and it’s the paradigm Rust uses.
But what bothers me most about Zig right now is that it currently has two distinct paradigms: the result type paradigm and the automatic type deduction paradigm. Most current Zig proposals encourage the result type paradigm, not the automatic type deduction paradigm. Furthermore, most existing proposals attempt to enable backward deduction of existing expressions based on the result type, rather than forward automatic type deduction.
While I prefer the automatic type deduction paradigm, I’d prefer a consistent paradigm across all Zig operators. If Zig advocates for the result type paradigm for some operators, I’d like all operators to follow it. This way, I can have appropriate expectations about operator behavior without having to remember inconsistent behavior across different operators.

2 Likes

PS: of course my proposal also means that the type of c is u65 here :smiley:

const a: u64 = ...;
const b: u64 = ...;
const c = a + b;


that’s why it would make more sense to use u63 as the ‘natural word size’ unsigned integer type, e.g. usize should be changed from 64- to 63-bits (or maybe better: dropped completely from the language, since what would be the result type of adding two usizes? :stuck_out_tongue_winking_eye:

This specific problem could be solved with the proposed ranged-integer-types which could more precisely infer the required result type (and might prevent the result type from always being one bit wider).

An interesting side effect of this ‘expression result type inference’ is that an unsigned add can never overflow, since the result type will always have enough room to hold the result - but at the cost of potentially spilling into the next 8/16/32/64/
 wide type.

But in any case, the more I think about it, the less a hardwired-width usize type makes sense.

I think you might like the idea of ​​combining this proposal.
However, zig is unlikely to move in this direction, preferring to explicitly specify the type of the result value rather than automatically inferring it.

Yeah I’m aware of the proposal and I think it makes a lot of sense.

But IMHO ranged integer types would fit even better with inferring the type of subexpressions to a type wide enough to hold the subexpression result, instead of promoting all subexpressions to some arbitrary ‘left-hand-side type’.

PS: another obvious downside of my simplistic “width-extension” idea is that each subexpression needs to add one bit to the result type (ignoring multiplication here for now)
 this would need to happen because without comptime-known inputs the compiler needs to expect that each expression input might be MAX_INT - 1 (where each uN type has its own MAX_INT of course), so it always needs to add one bit to make room for the result.

With ranged-integer-types this explosion could be tamed a bit but not entirely prevented (because the upper bound for a ranged type could be smaller than the MAX_INT - 1 for a 2^N type).

The advantage of such a ‘growing result type’ is that in turn a lot of overflow checks for subexpressions could be removed.

But once multiplication comes into play this idea would quickly run out of ‘hardware bits’ (since the result type would not need to grow by 1 bit, but by the sum of the input bits, e.g. u8 * u8 => u16 etc
).

Another way to tame the bit explosion is to use a type provided on the left-hand-side as upper limit:

const a: u32 = ...;
const b: u32 = ...;
const c: u32 = a * b;


without the explicit type or comptime-known inputs, c would be inferred as u64 since that’s needed to hold a ‘worst case result’, but since the explicit type is provided, the compiler now knows that no sub expression may go beyond 32-bits (which then of course requires to add overflow-checks for sub-expressions that may result in a value that doesn’t fit into 32-bits)


E.g. maybe a combination of inferred sub-expression types and an ‘upper bound’ result type makes the most sense? E.g. subexpression result types would grow to hold the subexpression-result, but with an optional upper-bound for all subexpressions if the left-hand-side has an explicit type.

Without an ‘upper bound type’ it’s still ‘dangerous’ (in terms of performance expectations) though, because if all inputs are u64 here:

const c = a * b * c * d * e * f;


the inferred type for c would need to be u384:

? = u128 * u64 * u64 * u64 * u64;
? = u192 * u64 * u64 * u64;
? = u256 * u64 * u64;
? = u320 * u64;
? = u384;


but I guess that’s sort of expected when fully embracing ‘variable width integer arithmetic’ :wink:

A common arithmetic scenario is based on runtime logical loops. In general, I think runtime overflow is inevitable, and the scenarios that can be used with compile-time type extension are very limited.

2 Likes

I find it quite jarring that to understand what var c: u32 = a +% b; you need to know the type of a and b.
For me this operation should work with u32.
BTW if you compile in ReleaseFast the code will return the 540 expected value. So the Zig is not pushing the more hardware friendly computation.

It also becomes a footgun. If someone changes the type of ‘a’ to u16, then the behavior changes. This is not obvious to detect if ‘a’ is the output of a function and not explicitly typed.

If that really happens then that would make it a compiler bug with current semantics. Could you please share your full code that reproduces this?

It seems you get a similar class of footguns when doing the calculation based on the result type:
if someone changes the type of c to u8 then the behavior changes.
This is not obvious to detect if this is the input of a function and not an explicitly named variable.

It also becomes a footgun. If someone changes the type of ‘a’ to u16, then the behavior changes.

The alternative is C-style integer promotion which is just a different bucket of footguns.

BTW if you compile in ReleaseFast the code will return the 540 expected value.

Hmm, I can’t reproduce that here (and if that would clearly be a bug), e.g. this code:

pub fn main() void {
    const a: u8 = 255;
    const b: u8 = 255;
    const c: u32 = a +% b;
    @import("std").debug.print("c: {}\n", .{c});
}


returns the same wraparound-result (254) both with zig build-exe bla.zig and zig build-exe -OReleaseFast bla.zig.

Did you maybe test with a non-wrapping + instead of wrapping +%?

You’re right it was with a plain +.

My point stand though.

C can’t do what I suggested cause there is no propagation of result type.

In C++ it wouldn’t work either cause ‘+’ can be overriden