ziglang/zig
259
6.0k
Fork
You've already forked zig
771

make @round, @trunc, @floor, and @ceil support integers; delete std.math.lossyCast #31602

Open
opened 2026年03月20日 20:02:15 +01:00 by andrewrk · 3 comments

Migrated from https://github.com/ziglang/zig/issues/19575

Original proposal below. It was accepted, discussed, and then un-accepted for re-evaluation, and is now open for comments and counter proposals.

A pull request partially implementing some of this will be landing imminently: #30906


Motivation

  • Make using floating point values in game development less cumbersome. It should be low friction to convert a float to an int. A chained builtin is too much friction.
  • Make the points where illegal behavior can possibly occur obvious.
  • Delete std.math.lossyCast. This functionality should be provided by the language.

Updated Builtins

These four functions have clarified definitions:

  • @round: Returns the nearest representable integer to the operand; away from zero in halfway case.
  • @trunc: Returns the nearest representable integer to the operand, towards zero.
  • @floor: Returns the nearest representable integer to the operand, towards negative infinity.
  • @ceil: Returns the nearest representable integer to the operand, towards positive infinity.

When the result type is non-integer, the behavior is the same as before. If the operand is nan or infinite, the operand is returned. In this case an integer operand is not allowed except where it would be allowed by @as.

When the result type is integer, these functions have a saturating effect. A value outside the integer range is clamped; nan is illegal. In this case an integer operand is allowed.

Conformance Tests

conststd=@import("std");constexpect=std.testing.expect;test"@round on integers"{varx:i32=1<<18;x+=1;consty:i16=@round(x);tryexpect(y==std.math.maxInt(i16));}test"@floor to integer"{varx:f32=300.4;x+=1;consty:u8=@floor(x);tryexpect(y==255);}test"@floor to integer in bounds"{varx:f32=-100.4;x+=1;consty:i8=@floor(x);tryexpect(y==-101);}test"@ceil to integer"{varx:f32=300.4;x+=1;consty:u8=@ceil(x);tryexpect(y==255);}test"@ceil to integer in bounds"{varx:f32=-100.4;x+=1;consty:i8=@ceil(x);tryexpect(y==-100);}test"@trunc to integer"{varx:f32=300.4;x+=1;consty:u8=@trunc(x);tryexpect(y==255);}test"@trunc to integer in bounds"{varx:f32=-100.4;x+=1;consty:i8=@trunc(x);tryexpect(y==-100);}test"@round to integer"{varx:f32=300.4;x+=1;consty:u8=@round(x);tryexpect(y==255);}test"@round to integer in bounds"{varx:f32=-100.4;x+=1;consty:i8=@round(x);tryexpect(y==-100);}

Uses of std.math.lossyCast can be upgraded to @round.

Related:

Migrated from https://github.com/ziglang/zig/issues/19575 Original proposal below. It was accepted, discussed, and then un-accepted for re-evaluation, and is now open for comments and counter proposals. A pull request partially implementing some of this will be landing imminently: https://codeberg.org/ziglang/zig/pulls/30906 ---- ## Motivation * Make using floating point values in game development less cumbersome. It should be low friction to convert a float to an int. A chained builtin is too much friction. * Make the points where illegal behavior can possibly occur obvious. * Delete `std.math.lossyCast`. This functionality should be provided by the language. ## Updated Builtins These four functions have clarified definitions: * `@round`: Returns the nearest representable integer to the operand; away from zero in halfway case. * `@trunc`: Returns the nearest representable integer to the operand, towards zero. * `@floor`: Returns the nearest representable integer to the operand, towards negative infinity. * `@ceil`: Returns the nearest representable integer to the operand, towards positive infinity. When the result type is non-integer, the behavior is the same as before. If the operand is nan or infinite, the operand is returned. In this case an integer operand is not allowed except where it would be allowed by `@as`. When the result type is integer, these functions have a saturating effect. A value outside the integer range is clamped; nan is illegal. In this case an integer operand is allowed. ## Conformance Tests ```zig const std = @import("std"); const expect = std.testing.expect; test "@round on integers" { var x: i32 = 1 << 18; x += 1; const y: i16 = @round(x); try expect(y == std.math.maxInt(i16)); } test "@floor to integer" { var x: f32 = 300.4; x += 1; const y: u8 = @floor(x); try expect(y == 255); } test "@floor to integer in bounds" { var x: f32 = -100.4; x += 1; const y: i8 = @floor(x); try expect(y == -101); } test "@ceil to integer" { var x: f32 = 300.4; x += 1; const y: u8 = @ceil(x); try expect(y == 255); } test "@ceil to integer in bounds" { var x: f32 = -100.4; x += 1; const y: i8 = @ceil(x); try expect(y == -100); } test "@trunc to integer" { var x: f32 = 300.4; x += 1; const y: u8 = @trunc(x); try expect(y == 255); } test "@trunc to integer in bounds" { var x: f32 = -100.4; x += 1; const y: i8 = @trunc(x); try expect(y == -100); } test "@round to integer" { var x: f32 = 300.4; x += 1; const y: u8 = @round(x); try expect(y == 255); } test "@round to integer in bounds" { var x: f32 = -100.4; x += 1; const y: i8 = @round(x); try expect(y == -100); } ``` Uses of `std.math.lossyCast` can be upgraded to `@round`. Related: * https://github.com/ziglang/zig/issues/3806 * https://github.com/ziglang/zig/issues/11234 * https://github.com/ziglang/zig/issues/13642
andrewrk added this to the Urgent milestone 2026年03月20日 20:02:15 +01:00

I hope this is not my sleep deprivation, but aren't all the "in bounds"- tests off by one? As in they'd be correct if it weren't for the x += 1 in there?

I hope this is not my sleep deprivation, but aren't all the "in bounds"- tests off by one? As in they'd be correct if it weren't for the `x += 1` in there?
Contributor
Copy link

@zirunis wrote in #31602 (comment):

I hope this is not my sleep deprivation, but aren't all the "in bounds"- tests off by one? As in they'd be correct if it weren't for the x += 1 in there?

look at the sign of x 😄 (though some of them are wrong like floor)

@zirunis wrote in https://codeberg.org/ziglang/zig/issues/31602#issuecomment-11864445: > I hope this is not my sleep deprivation, but aren't all the "in bounds"- tests off by one? As in they'd be correct if it weren't for the `x += 1` in there? look at the sign of x 😄 (though some of them are wrong like floor)
Contributor
Copy link

Further down below is a copy/paste of my counter proposal https://github.com/ziglang/zig/issues/19575#issuecomment-2053790074 from the original issue. Arithmetic in Zig has seen a few changes since then (most notably the implementation of implicit int to float coercions if all integer values are representable by the destination float type) and I might have some new thoughts about integer and float arithmetic better suited for discussion elsewhere, but overall I think the proposal still holds water because it eliminates entire classes of footguns:

  • @as(u32, @round(@as(f64, 4294967295.5))) has three equally valid outcomes: saturated conversion to 4294967295, truncated conversion to 0, and illegal behavior due to integer overflow. Favoring one implicit behavior over the other may result in bugs if the programmer assumes the wrong default (e.g. when porting code from JavaScript, where bitwise operators and typed arrays use truncating conversion). Requiring @saturate(@round(x)), @truncate(@round(x)) or @intFromFloat(@round(x)) prevents such mistakes by making author intent explicit.
  • Similarly, @as(u32, @intFromFloat(@as(f64, 8.5))) has three valid outcomes: 8 (truncating/flooring/ties-to-even), 10 (ceiling/ties-to-away), and illegal behavior due to the value not being a whole integer, but here the latter behavior is the most consistent with other casting builtins. A rounding behavior by default also leaves the door open for bugs if the programmer assumes the wrong rounding rules (easy mistake to make since truncating, ties-to-even and ties-to-away conversions are all commonly used by different languages). Requiring @intFromFloat(@floor(x)), @intFromFloat(@ceil(x)) or @intFromFloat(x) eliminates such risks.

Original comment below (as of its last edit from 2024年04月14日):


Counter proposal

I'm splitting my counter proposal up into two parts. The first part directly addresses the original use case of lossily converting a float to an int and can be evaluated and implemented in isolation. The second part is not explicitly related to the original use case, and after thinking a lot about it I'm not sure how I feel about it, but it extends and synergizes with the first part and touches upon rounding.

Part 1: Leave rounding builtins as is; add @saturate for clamping conversion to int

The @saturate builtin is introduced, which can be used to convert an integer or a float to an integer.

@saturate

@saturate(int_or_float:anytype)anytype

Converts an integer or float to the inferred integer result type, clamping the value between the minimum and maximum representable values of the destination type.

For integer arguments, this conversion is always safe.

For float arguments, the integer part of the value is converted. If the value is NaN, safety-checked Undefined Behavior is invoked. Infinities are clamped.

For integer arguments,
const y: T = @saturate(x)
would be equivalent to
const y: T = @intCast(@min(@max(x, @max(std.math.minInt(T), std.math.minInt(@TypeOf(x)))), std.math.maxInt(T)))

For float arguments,
const y: T = @saturate(x)
would be equivalent to
const y: T = @intFromFloat(if (std.math.isNan(x)) x else @min(@max(x, std.math.minInt(T)), std.math.maxInt(T)))

For integers, @saturate pairs well with @truncate and forms a nice counterpart to +| saturating and +% wrapping operators.

For floats, just like @intFromFloat, @saturate would implicitly round any values toward zero (equivalent to @trunc) before converting. Users who desire different rounding behavior should explicitly round the value using @round, @floor or @ceil before passing the result to @saturate.

std.math.lossyCast can be deleted in favor of @floatFromInt, @floatCast, @saturate or regular coercion, depending on destination and source types. The only unsupported case is converting NaN to an int, which users will need to handle explicitly.

test"@saturate int, positive, in bounds"{varx:u32=37;_=&x;consty:u8=@saturate(x);tryexpectEqual(37,y);}test"@saturate int, negative, in bounds"{varx:i32=-37;_=&x;consty:i8=@saturate(x);tryexpectEqual(-37,y);}test"@saturate int, positive, out of bounds"{varx:u32=999;_=&x;constyu:u8=@saturate(x);tryexpectEqual(255,yu);constyi:i8=@saturate(x);tryexpectEqual(127,yi);}test"@saturate int, negative, out of bounds"{varx:i32=-999;_=&x;constyu:u8=@saturate(x);tryexpectEqual(0,yu);constyi:i8=@saturate(x);tryexpectEqual(-128,yi);}test"@saturate float, positive, in bounds"{varx:f32=89.7;_=&x;consty:u8=@saturate(x);tryexpectEqual(89,y);}test"@saturate float, negative, in bounds"{varx:f32=-89.7;_=&x;consty:i8=@saturate(x);tryexpectEqual(-89,y);}test"@saturate float, positive, out of bounds"{varx:f32=1234.56;_=&x;constyu:u8=@saturate(x);tryexpectEqual(255,yu);constyi:i8=@saturate(x);tryexpectEqual(127,yi);}test"@saturate float, negative, out of bounds"{varx:f32=-1234.56;_=&x;constyu:u8=@saturate(x);tryexpectEqual(0,yu);constyi:i8=@saturate(x);tryexpectEqual(-128,yi);}test"@saturate float, positive infinity"{varx:f32=std.math.inf(f32);_=&x;constyu:u8=@saturate(x);tryexpectEqual(255,yu);constyi:i8=@saturate(x);tryexpectEqual(127,yi);}test"@saturate float, negative infinity"{varx:f32=-std.math.inf(f32);_=&x;constyu:u8=@saturate(x);tryexpectEqual(0,yu);constyi:i8=@saturate(x);tryexpectEqual(-128,yi);}

Alternative: Two separate saturating builtins

If @saturate working for both ints and floats is undesired, it could be split into two builtins: @saturateInt for int-from-int and @saturateIntFromFloat for int-from-float.

Could @truncate be used for wrapping conversion from floats, for feature parity with @saturate?

Maybe. I played around briefly and I think you might be able to implement an int-from-float @truncate as

fntruncateIntFromFloat(comptimeT:type,x:anytype)T{constU=std.meta.Int(.unsigned,@bitSizeOf(T));consty:T=@bitCast(@as(U,@intFromFloat(@rem(@abs(x),std.math.maxInt(U)+1))));returnif(x<0)-%yelsey;}

but I don't know if there would be precision problems with the % remainder operation for very large float values, nor do I know if it would work for non-power of 2 size integers.

Edit: I wrote a quick program that exhaustively tests all integer values in the i64 range representable by f32 and the math seems to check out for signed and unsigned 7, 8, 9, 11, 12 and 13-bit ints (these were the only ones I tested). The algorithm is functionally equivalent to as if the float value is cast to an infinitely wide int type, then truncated to a smaller destination int type, so it should be suitable for implementing @truncate for conversion from float to int.

It would be logically sound for @truncate(±std.math.inf(f32)) to return 0, because the distance between each consecutive representable float value is always a power of 2 which continuously doubles in size as we move toward infinity. The step size will eventually become a multiple of std.math.maxInt(U) + 1 and thus always have the algorithm return 0 beyond a certain point.

Regarding NaN

WebAssembly defines instructions like i32.trunc_sat_f32_u, which behave exactly as how @saturate is defined above with the exception of NaN, for which WebAssembly defines that saturating conversions should return 0. So given that there is prior art for handling NaN by returning 0, it might be worth at least considering going with the same behavior, even if it feels "incorrect". A benefit of handling NaN this way is that it would make @saturate a completely safe operation for both int and float arguments, making it a super low friction method of converting floats to ints.

Edit: I later discovered that LLVM also defines llvm.fpto[us]i.sat.* to return 0 for NaN. So it's not just WebAssembly that seems to think that returning 0 is sensible.

Part 2: Make @intFromFloat and @saturate more strict by disallowing arguments with fractional parts, requiring explicit rounding

@intFromFloat and @saturate both convert the integer part of the value, discarding the fractional part. In other words, values are always rounded towards zero before converting, which means that @intFromFloat(x) is equivalent to @intFromFloat(@trunc(x)).

Rounding toward zero when converting a float to an int is the convention in most programming languages, but there are some odd languages that use different rounding methods for int-from-float conversions (the first one to come to mind for me is PowerShell, which rounds toward nearest, ties to even). And IEEE 754 defines five integer rounding operations (nearest ties to even, nearest ties away, toward zero, toward -infinity and toward +infinity), so there is nothing intrinsic about floats that says that rounding toward zero should be the default.

What if @intFromFloat and @saturate were changed to make no assumptions about rounding at all, by making converting floats with fractional parts safety-checked undefined behavior, thus requiring users to explicitly round any non-integer arguments via @round, @trunc, @floor or @ceil?

Recall the following bullet points from zig zen:

  • Communicate intent precisely.
  • Favor reading code over writing code.
  • Reduce the amount one must remember.

@intFromFloat(x) requires the reader to remember that the conversion rounds toward zero. In comparison, @intFromFloat(@trunc(x)) is much more explicit and unambiguous.

Another point in favor of making float arguments with fractional parts UB can be seen if we look at the set of conversion builtins (after implementing part 1):

builtin lossy/lossless dst <- src
@bitCast lossless bits <- bits
@addrSpaceCast lossless ptr <- ptr
@alignCast lossless ptr <- ptr
@constCast lossless ptr <- ptr
@ptrCast lossless ptr <- ptr
@volatileCast lossless ptr <- ptr
@ptrFromInt lossless ptr <- int
@errorCast lossless error <- error
@errorFromInt lossless error <- int
@enumFromInt lossless enum <- int
@floatCast lossy float <- float
@floatFromInt lossy float <- int
@intCast lossless int <- int
@intFromBool lossless int <- bool
@intFromError lossless int <- error
@intFromEnum lossless int <- enum
@intFromFloat lossy int <- float
@intFromPtr lossless int <- ptr
@truncate lossy int <- int (and maybe float?)
@saturate lossy int <- int/float

Here, "lossless" means that no actual information is lost when converting the value; you can always reverse the conversion and get the original value back. Disregarding @truncate and @saturate which are explicitly designed to be lossy, @intFromFloat is the only conversion builtin with a non-float destination type that is lossy, due to discarding the fractional part. Making float values with fractional parts UB would make the conversion (almost*) lossless; after a successful @intFromFloat conversion, it is always possible get back a result equal to the original value via @floatFromInt.

*almost because -0.0 is the sole exception; while -0.0 == 0.0, you will never be able to get the original signed zero back after the first conversion to int.

In summary, I suggest making the following changes to the definitions of @intFromFloat and @saturate:

@intFromFloat

@intFromFloat(float:anytype)anytype

Converts a float to the inferred integer result type.

If the float value has a fractional part, is NaN or is out of range of the destination type, it invokes safety-checked Undefined Behavior.

To convert a float value with a fractional part, use @round, @trunc, @floor or @ceil to round the value to a whole number, then pass the rounded result to @intFromFloat.

@saturate

@saturate(int_or_float:anytype)anytype

Converts an integer or float to the inferred integer result type, clamping the value between the minimum and maximum representable values of the destination type.

For integer arguments, this conversion is always safe.

For float arguments, if the value has a fractional part or is NaN, it invokes safety-checked Undefined Behavior. Infinities are clamped.

To convert a float value with a fractional part, use @round, @trunc, @floor or @ceil to round the value to a whole number, then pass the rounded result to @saturate.

Implementing this change in a non-intrusive way

Changing @intFromFloat (and @saturate, if its part 1 iteration is implemented independently before part 2) to perform no implicit rounding and invoke safety-checked undefined behavior for values with fractional parts would break a lot of Zig code.

Therefore, for one full release cycle, @intFromFloat should retain its current behavior of rounding toward zero prior to conversion. The implicit rounding will be clearly documented as deprecated and to become illegal in a future release, and zig fmt will automatically rewrite expressions of the form @intFromFloat(expr), where expr is not @round(...), @trunc(...), @floor(...) or @ceil(...), into @intFromFloat(@trunc(expr)). This should give users more than enough time to update and audit their code.

Then, at the start of the subsequent release cycle, the deprecated implicit rounding is removed.

Optimizations and compiler implementation details

  • When the expression passed to @intFromFloat and @saturate is one of @round, @trunc, @floor or @ceil, or a value that can be statically known to have no fractional part (by being known to be rounded), the safety check for fractional parts can be eliminated.
  • @intFromFloat(@trunc(x)) and equivalent expressions can be lowered as one "convert to integer using truncation" instruction, instead of "round toward zero" followed by "convert to integer using truncation" (i.e. i32.trunc_f32_u instead of f32.trunc followed by i32.trunc_f32_u for WebAssembly). Same for other rounding methods when applicable instructions are available.

Future enhancements

  • Add a "ties to even" rounding builtin to cover all five integer rounding operations specified by IEEE 754, e.g. add @roundHalfEven and rename @round to @roundHalfAway for clarity.
Further down below is a copy/paste of my counter proposal https://github.com/ziglang/zig/issues/19575#issuecomment-2053790074 from the original issue. Arithmetic in Zig has seen a few changes since then (most notably the implementation of implicit int to float coercions if all integer values are representable by the destination float type) and I might have some new thoughts about integer and float arithmetic better suited for discussion elsewhere, but overall I think the proposal still holds water because it eliminates entire classes of footguns: - `@as(u32, @round(@as(f64, 4294967295.5)))` has three equally valid outcomes: saturated conversion to `4294967295`, truncated conversion to `0`, and illegal behavior due to integer overflow. Favoring one implicit behavior over the other may result in bugs if the programmer assumes the wrong default (e.g. when porting code from JavaScript, where bitwise operators and typed arrays use truncating conversion). Requiring `@saturate(@round(x))`, `@truncate(@round(x))` or `@intFromFloat(@round(x))` prevents such mistakes by making author intent explicit. - Similarly, `@as(u32, @intFromFloat(@as(f64, 8.5)))` has three valid outcomes: `8` (truncating/flooring/ties-to-even), `10` (ceiling/ties-to-away), and illegal behavior due to the value not being a whole integer, but here the latter behavior is the most consistent with other casting builtins. A rounding behavior by default also leaves the door open for bugs if the programmer assumes the wrong rounding rules (easy mistake to make since truncating, ties-to-even and ties-to-away conversions are all commonly used by different languages). Requiring `@intFromFloat(@floor(x))`, `@intFromFloat(@ceil(x))` or `@intFromFloat(x)` eliminates such risks. Original comment below (as of its last edit from 2024年04月14日): --- # Counter proposal I'm splitting my counter proposal up into two parts. The first part directly addresses the original use case of lossily converting a float to an int and can be evaluated and implemented in isolation. The second part is not explicitly related to the original use case, and after thinking a lot about it I'm not sure how I feel about it, but it extends and synergizes with the first part and touches upon rounding. ## Part 1: Leave rounding builtins as is; add `@saturate` for clamping conversion to int The `@saturate` builtin is introduced, which can be used to convert an integer or a float to an integer. > ### `@saturate` > > ```zig > @saturate(int_or_float: anytype) anytype > ``` > > Converts an integer or float to the inferred integer result type, clamping the value between the minimum and maximum representable values of the destination type. > > For integer arguments, this conversion is always safe. > > For float arguments, the integer part of the value is converted. If the value is NaN, safety-checked Undefined Behavior is invoked. Infinities are clamped. For integer arguments, `const y: T = @saturate(x)` would be equivalent to `const y: T = @intCast(@min(@max(x, @max(std.math.minInt(T), std.math.minInt(@TypeOf(x)))), std.math.maxInt(T)))` For float arguments, `const y: T = @saturate(x)` would be equivalent to `const y: T = @intFromFloat(if (std.math.isNan(x)) x else @min(@max(x, std.math.minInt(T)), std.math.maxInt(T)))` For integers, `@saturate` pairs well with `@truncate` and forms a nice counterpart to `+|` saturating and `+%` wrapping operators. For floats, just like `@intFromFloat`, `@saturate` would implicitly round any values toward zero (equivalent to `@trunc`) before converting. Users who desire different rounding behavior should explicitly round the value using `@round`, `@floor` or `@ceil` before passing the result to `@saturate`. `std.math.lossyCast` can be deleted in favor of `@floatFromInt`, `@floatCast`, `@saturate` or regular coercion, depending on destination and source types. The only unsupported case is converting NaN to an int, which users will need to handle explicitly. ```zig test "@saturate int, positive, in bounds" { var x: u32 = 37; _ = &x; const y: u8 = @saturate(x); try expectEqual(37, y); } test "@saturate int, negative, in bounds" { var x: i32 = -37; _ = &x; const y: i8 = @saturate(x); try expectEqual(-37, y); } test "@saturate int, positive, out of bounds" { var x: u32 = 999; _ = &x; const yu: u8 = @saturate(x); try expectEqual(255, yu); const yi: i8 = @saturate(x); try expectEqual(127, yi); } test "@saturate int, negative, out of bounds" { var x: i32 = -999; _ = &x; const yu: u8 = @saturate(x); try expectEqual(0, yu); const yi: i8 = @saturate(x); try expectEqual(-128, yi); } test "@saturate float, positive, in bounds" { var x: f32 = 89.7; _ = &x; const y: u8 = @saturate(x); try expectEqual(89, y); } test "@saturate float, negative, in bounds" { var x: f32 = -89.7; _ = &x; const y: i8 = @saturate(x); try expectEqual(-89, y); } test "@saturate float, positive, out of bounds" { var x: f32 = 1234.56; _ = &x; const yu: u8 = @saturate(x); try expectEqual(255, yu); const yi: i8 = @saturate(x); try expectEqual(127, yi); } test "@saturate float, negative, out of bounds" { var x: f32 = -1234.56; _ = &x; const yu: u8 = @saturate(x); try expectEqual(0, yu); const yi: i8 = @saturate(x); try expectEqual(-128, yi); } test "@saturate float, positive infinity" { var x: f32 = std.math.inf(f32); _ = &x; const yu: u8 = @saturate(x); try expectEqual(255, yu); const yi: i8 = @saturate(x); try expectEqual(127, yi); } test "@saturate float, negative infinity" { var x: f32 = -std.math.inf(f32); _ = &x; const yu: u8 = @saturate(x); try expectEqual(0, yu); const yi: i8 = @saturate(x); try expectEqual(-128, yi); } ``` ### Alternative: Two separate saturating builtins If `@saturate` working for both ints and floats is undesired, it could be split into two builtins: `@saturateInt` for int-from-int and `@saturateIntFromFloat` for int-from-float. ### Could `@truncate` be used for wrapping conversion from floats, for feature parity with `@saturate`? Maybe. I played around briefly and I *think* you might be able to implement an int-from-float `@truncate` as ```zig fn truncateIntFromFloat(comptime T: type, x: anytype) T { const U = std.meta.Int(.unsigned, @bitSizeOf(T)); const y: T = @bitCast(@as(U, @intFromFloat(@rem(@abs(x), std.math.maxInt(U) + 1)))); return if (x < 0) -%y else y; } ``` but I don't know if there would be precision problems with the `%` remainder operation for very large float values, nor do I know if it would work for non-power of 2 size integers. **Edit:** [I wrote a quick program that exhaustively tests all integer values in the `i64` range representable by `f32`](https://gist.github.com/castholm/94a4fea1cee0f72dbdeca7b77f8cb280) and the math seems to check out for signed and unsigned 7, 8, 9, 11, 12 and 13-bit ints (these were the only ones I tested). The algorithm is functionally equivalent to as if the float value is cast to an infinitely wide int type, then truncated to a smaller destination int type, so it should be suitable for implementing `@truncate` for conversion from float to int. It would be logically sound for `@truncate(±std.math.inf(f32))` to return 0, because the distance between each consecutive representable float value is always a power of 2 which continuously doubles in size as we move toward infinity. The step size will eventually become a multiple of `std.math.maxInt(U) + 1` and thus always have the algorithm return 0 beyond a certain point. ### Regarding NaN WebAssembly defines instructions like [`i32.trunc_sat_f32_u`](https://webassembly.github.io/spec/core/exec/numerics.html#op-trunc-sat-u), which behave exactly as how `@saturate` is defined above **with the exception of NaN, for which WebAssembly defines that saturating conversions should return 0**. So given that there is prior art for handling NaN by returning 0, it might be worth at least considering going with the same behavior, even if it feels "incorrect". A benefit of handling NaN this way is that it would make `@saturate` a completely safe operation for both int and float arguments, making it a super low friction method of converting floats to ints. **Edit:** I later discovered that LLVM also defines [`llvm.fpto[us]i.sat.*`](https://llvm.org/docs/LangRef.html#saturating-floating-point-to-integer-conversions) to return 0 for NaN. So it's not just WebAssembly that seems to think that returning 0 is sensible. ## Part 2: Make `@intFromFloat` and `@saturate` more strict by disallowing arguments with fractional parts, requiring explicit rounding `@intFromFloat` and `@saturate` both convert the integer part of the value, discarding the fractional part. In other words, values are always rounded towards zero before converting, which means that `@intFromFloat(x)` is equivalent to `@intFromFloat(@trunc(x))`. Rounding toward zero when converting a float to an int is the convention in most programming languages, but there are some odd languages that use different rounding methods for int-from-float conversions (the first one to come to mind for me is PowerShell, which rounds toward nearest, ties to even). And IEEE 754 defines five integer rounding operations (nearest ties to even, nearest ties away, toward zero, toward -infinity and toward +infinity), so there is nothing intrinsic about floats that says that rounding toward zero should be the default. What if `@intFromFloat` and `@saturate` were changed to make no assumptions about rounding at all, by making converting floats with fractional parts safety-checked undefined behavior, thus requiring users to explicitly round any non-integer arguments via `@round`, `@trunc`, `@floor` or `@ceil`? Recall the following bullet points from `zig zen`: - Communicate intent precisely. - Favor reading code over writing code. - Reduce the amount one must remember. `@intFromFloat(x)` requires the reader to remember that the conversion rounds toward zero. In comparison, `@intFromFloat(@trunc(x))` is much more explicit and unambiguous. Another point in favor of making float arguments with fractional parts UB can be seen if we look at the set of conversion builtins (after implementing part 1): | builtin | lossy/lossless | dst <- src | |-|-|-| | `@bitCast` | lossless | bits <- bits | `@addrSpaceCast` | lossless | ptr <- ptr | `@alignCast` | lossless | ptr <- ptr | `@constCast` | lossless | ptr <- ptr | `@ptrCast` | lossless | ptr <- ptr | `@volatileCast` | lossless | ptr <- ptr | `@ptrFromInt` | lossless | ptr <- int | `@errorCast` | lossless | error <- error | `@errorFromInt` | lossless | error <- int | `@enumFromInt` | lossless | enum <- int | `@floatCast` | lossy | float <- float | `@floatFromInt` | lossy | float <- int | `@intCast` | lossless | int <- int | `@intFromBool` | lossless | int <- bool | `@intFromError` | lossless | int <- error | `@intFromEnum` | lossless | int <- enum | `@intFromFloat` | **lossy** | int <- float | `@intFromPtr` | lossless | int <- ptr | `@truncate` | lossy | int <- int (and maybe float?) | `@saturate` | lossy | int <- int/float Here, "lossless" means that no actual information is lost when converting the value; you can always reverse the conversion and get the original value back. Disregarding `@truncate` and `@saturate` which are explicitly designed to be lossy, `@intFromFloat` is the only conversion builtin with a non-float destination type that is lossy, due to discarding the fractional part. Making float values with fractional parts UB would make the conversion (almost\*) lossless; after a successful `@intFromFloat` conversion, it is always possible get back a result equal to the original value via `@floatFromInt`. <sup>\**almost* because `-0.0` is the sole exception; while `-0.0 == 0.0`, you will never be able to get the original signed zero back after the first conversion to int.</sup> In summary, I suggest making the following changes to the definitions of `@intFromFloat` and `@saturate`: > ### `@intFromFloat` > > ```zig > @intFromFloat(float: anytype) anytype > ``` > > Converts a float to the inferred integer result type. > > If the float value has a fractional part, is NaN or is out of range of the destination type, it invokes safety-checked Undefined Behavior. > > To convert a float value with a fractional part, use `@round`, `@trunc`, `@floor` or `@ceil` to round the value to a whole number, then pass the rounded result to `@intFromFloat`. > ### `@saturate` > > ```zig > @saturate(int_or_float: anytype) anytype > ``` > > Converts an integer or float to the inferred integer result type, clamping the value between the minimum and maximum representable values of the destination type. > > For integer arguments, this conversion is always safe. > > For float arguments, if the value has a fractional part or is NaN, it invokes safety-checked Undefined Behavior. Infinities are clamped. > > To convert a float value with a fractional part, use `@round`, `@trunc`, `@floor` or `@ceil` to round the value to a whole number, then pass the rounded result to `@saturate`. ### Implementing this change in a non-intrusive way Changing `@intFromFloat` (and `@saturate`, if its part 1 iteration is implemented independently before part 2) to perform no implicit rounding and invoke safety-checked undefined behavior for values with fractional parts would break a lot of Zig code. Therefore, for one full release cycle, `@intFromFloat` should retain its current behavior of rounding toward zero prior to conversion. The implicit rounding will be clearly documented as deprecated and to become illegal in a future release, and `zig fmt` will automatically rewrite expressions of the form `@intFromFloat(expr)`, where `expr` is not `@round(...)`, `@trunc(...)`, `@floor(...)` or `@ceil(...)`, into `@intFromFloat(@trunc(expr))`. This should give users more than enough time to update and audit their code. Then, at the start of the subsequent release cycle, the deprecated implicit rounding is removed. ### Optimizations and compiler implementation details - When the expression passed to `@intFromFloat` and `@saturate` is one of `@round`, `@trunc`, `@floor` or `@ceil`, or a value that can be statically known to have no fractional part (by being known to be rounded), the safety check for fractional parts can be eliminated. - `@intFromFloat(@trunc(x))` and equivalent expressions can be lowered as one "convert to integer using truncation" instruction, instead of "round toward zero" followed by "convert to integer using truncation" (i.e. `i32.trunc_f32_u` instead of `f32.trunc` followed by `i32.trunc_f32_u` for WebAssembly). Same for other rounding methods when applicable instructions are available. ## Future enhancements - Add a "ties to even" rounding builtin to cover all five integer rounding operations specified by IEEE 754, e.g. add `@roundHalfEven` and rename `@round` to `@roundHalfAway` for clarity.
Sign in to join this conversation.
No Branch/Tag specified
master
elfv2
spork8
restricted
0.16.x
panic-rewrite
parking-futex-lockfree
windows-Io-cleanup
Io-watch
ParseCommandLineOptions
windows-async-files
poll-ring
debug-file-leaks-differently
debug-file-leaks
ProcessPrng
elfv2-dyn
jobserver
threadtheft
io-threaded-no-queue
0.15.x
Io.net
comptime-allocator
restricted-function-pointers
cli
wasm-linker-writer
wrangle-writer-buffering
sha1-stream
async-await-demo
fixes
0.14.x
ast-node-methods
macos-debug-info
make-vs-configure
fuzz-macos
sans-aro
ArrayList-reserve
incr-bug
llvm-ir-nosanitize-metadata
ci-tarballs
ci-scripts
threadpool
0.12.x
new-pkg-hash
json-diagnostics
more-doctests
rework-comptime-mutation
0.11.x
ci-perf-comment
stage2-async
0.10.x
autofix
0.9.x
aro
hcs
0.8.x
0.7.x
0.16.0
0.15.2
0.15.1
0.15.0
0.14.1
0.14.0
0.12.1
0.13.0
0.12.0
0.11.0
0.10.1
0.10.0
0.9.1
0.9.0
0.8.1
0.8.0
0.7.1
0.7.0
0.6.0
0.5.0
0.4.0
0.3.0
0.2.0
0.1.1
0.1.0
Labels
Clear labels
abi/f32
abi/ilp32
abi/sf
accepted
This proposal is planned.
arch/21k
arch/6502
arch/aarch64
arch/alpha
arch/amdgcn
arch/arc
arch/arc32
arch/arc64
arch/arm
arch/avr
arch/bfin
arch/bpf
arch/colossus
arch/cris
arch/csky
arch/dlx
arch/epiphany
arch/fr30
arch/frv
arch/hexagon
arch/hppa
arch/hppa64
arch/ia64
arch/kalimba
arch/kvx
arch/lanai
arch/lm32
arch/loongarch32
arch/loongarch64
arch/m32r
arch/m68k
arch/m88k
arch/mcore
arch/microblaze
arch/mips
arch/mips64
arch/mmix
arch/moxie
arch/mrisc32
arch/msp430
arch/nds32
arch/ns32k
arch/nvptx
arch/or1k
arch/powerpc
arch/powerpc64
arch/propeller
arch/riscv32
arch/riscv64
arch/rl78
arch/rx
arch/s390x
arch/sh
arch/sparc
arch/sparc64
arch/spirv
arch/spu
arch/tricore
arch/v850
arch/vax
arch/vc4
arch/ve
arch/wasm
arch/x86
arch/x86_64
arch/xcore
arch/xtensa
autodoc
The web application for interactive documentation and generation of its assets.
backend/c
The C backend outputs C source code.
backend/llvm
The LLVM backend outputs an LLVM bitcode module.
backend/self-hosted
The self-hosted backends produce machine code directly.
binutils
Zig's included binary utilities: zig ar, zig dlltool, zig lib, zig ranlib, zig objcopy, and zig rc.
breaking
Implementing this issue could cause existing code to no longer compile or have different behavior.
build system
The Zig build system - zig build, std.Build, the build runner, and package management.
debug info
An issue related to debug information (e.g. DWARF) produced by the Zig compiler.
docs
An issue with documentation, e.g. the language reference or standard library doc comments.
error message
This issue points out an error message that is unhelpful and should be improved.
frontend
Tokenization, parsing, AstGen, ZonGen, Sema, Legalize, and Liveness.
fuzzing
An issue related to Zig's integrated fuzz testing.
incremental
Reuse of internal compiler state for faster compilation.
lib/c
This issue relates to Zig's libc implementation and/or vendored libcs.
lib/compiler-rt
This issue relates to Zig's compiler-rt library.
lib/cxx
This issue relates to Zig's vendored libc++ and/or libc++abi.
lib/std
This issue relates to Zig's standard library.
lib/tsan
This issue relates to Zig's vendored libtsan.
lib/ubsan-rt
This issue relates to Zig's ubsan-rt library.
lib/unwind
This issue relates to Zig's vendored libunwind.
linking
Zig's integrated object file and incremental linker.
miscompilation
The compiler reports success but produces semantically incorrect code.
os/android
os/contiki
os/dragonfly
os/driverkit
os/emscripten
os/freebsd
os/fuchsia
os/haiku
os/hermit
os/hurd
os/illumos
os/ios
os/linux
os/maccatalyst
os/macos
os/managarm
os/netbsd
os/ohos
os/openbsd
os/plan9
os/redox
os/rtems
os/serenity
os/tvos
os/uefi
os/visionos
os/wasi
os/watchos
os/windows
proposal
This issue suggests language modifications. If it also has the "accepted" label then it is planned.
release notes
This issue or pull request should be mentioned in the release notes.
testing
This issue is related to testing the compiler, standard library, or other parts of Zig.
zig cc
Zig as a drop-in C-family compiler.
zig fmt
The Zig source code formatter.
zig reduce
The Zig source code reduction tool.
bounty
https://ziglang.org/news/announcing-donor-bounties
bug
Observed behavior contradicts documented or intended behavior.
contributor-friendly
This issue is limited in scope and/or knowledge of project internals.
downstream
An issue with a third-party project that uses this project.
enhancement
Solving this issue will likely involve adding new logic or components to the codebase.
infra
An issue related to project infrastructure, e.g. continuous integration.
optimization
A task to improve performance and/or resource usage.
question
No questions on the issue tracker; use a community space instead.
regression
Something that used to work in a previous version stopped working
upstream
An issue with a third-party project that this project uses.
use case
Describes a real use case that is difficult or impossible, but does not propose a solution.
No labels
abi/f32
abi/ilp32
abi/sf
accepted
arch/21k
arch/6502
arch/aarch64
arch/alpha
arch/amdgcn
arch/arc
arch/arc32
arch/arc64
arch/arm
arch/avr
arch/bfin
arch/bpf
arch/colossus
arch/cris
arch/csky
arch/dlx
arch/epiphany
arch/fr30
arch/frv
arch/hexagon
arch/hppa
arch/hppa64
arch/ia64
arch/kalimba
arch/kvx
arch/lanai
arch/lm32
arch/loongarch32
arch/loongarch64
arch/m32r
arch/m68k
arch/m88k
arch/mcore
arch/microblaze
arch/mips
arch/mips64
arch/mmix
arch/moxie
arch/mrisc32
arch/msp430
arch/nds32
arch/ns32k
arch/nvptx
arch/or1k
arch/powerpc
arch/powerpc64
arch/propeller
arch/riscv32
arch/riscv64
arch/rl78
arch/rx
arch/s390x
arch/sh
arch/sparc
arch/sparc64
arch/spirv
arch/spu
arch/tricore
arch/v850
arch/vax
arch/vc4
arch/ve
arch/wasm
arch/x86
arch/x86_64
arch/xcore
arch/xtensa
autodoc
backend/c
backend/llvm
backend/self-hosted
binutils
breaking
build system
debug info
docs
error message
frontend
fuzzing
incremental
lib/c
lib/compiler-rt
lib/cxx
lib/std
lib/tsan
lib/ubsan-rt
lib/unwind
linking
miscompilation
os/android
os/contiki
os/dragonfly
os/driverkit
os/emscripten
os/freebsd
os/fuchsia
os/haiku
os/hermit
os/hurd
os/illumos
os/ios
os/linux
os/maccatalyst
os/macos
os/managarm
os/netbsd
os/ohos
os/openbsd
os/plan9
os/redox
os/rtems
os/serenity
os/tvos
os/uefi
os/visionos
os/wasi
os/watchos
os/windows
proposal
release notes
testing
zig cc
zig fmt
zig reduce
bounty
bug
contributor-friendly
downstream
enhancement
infra
optimization
question
regression
upstream
use case
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
ziglang/zig#31602
Reference in a new issue
ziglang/zig
No description provided.
Delete branch "%!s()"

Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?