Proposal: Better lossy casting for int-to-float

I’m creating a window/graphics library in Zig, and during this process I’ve hit some more headaches with the casting syntax.

This stems from the changes with implicit int-to-float conversions (small enough integer can implicitly convert to a float) as well as using @trunc, @floor, @ceil, @round in place of @intFromFloat.

These changes are great, as they drastically improve ergonomics and more importantly, code readability. However, a common pattern I’m running into is around float arithmetic involving either array length or for loop indexes.

The implicit casting makes sense for Zig’s goals, as it only occurs when no information is lost. If an integer width cannot fit into a float, an explicit cast is needed.

After doing some experiments with sine waves, I was needing to use a sine buffer’s length or the index of a for loop, which are usize, during arithmetic. This resulted in some ugly lines such as

for (sin_d, 0..) |s, i| {
  const ratio: f32 = @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(sin_d.len - scale));
  ...
}

For lossy float-to-int conversions, we have the previously mentioned @trunc, @floor, @ceil, and @round. These are a big improvement over the previous syntax because they’re short, they explain what they do immediately, and they don’t hurt code readability.

The above example, in a perfect world, would instantly convey to the reader that ratio, a f32, is simply the for loop index divided by (array length - some factor). Instead, my eyes have to search through all the “@” builtins to see what’s actually going on.

Proposal

All I’m proposing is a simpler, shorter lossy int-to-float conversion syntax, similar to @trunc and related functions. My immediate thought was along the lines of @float(value) (where “float” could be any name, preferably just as short but perhaps more descriptive), which performs a (potentially) lossy cast of an int to a float. It would work on integer widths that could implicitly convert to a float, but ommitting it would cause a compile error on widths that can’t, just like current behavior.

An issue that I see with this is that @as(T, value) is used in the example because Zig’s PTR can’t discern what type of float these values should be. In that case, a function such as @float(T, value) may be better. It might be important to mention that value is required to be an int, as this is supposed to replace @as(T, @floatFromInt(value)).

My argument for this is that if value is required to be an int, it is just as explicit as @as(T, @floatFromInt(value)), while making the code much more readable. The programmer is still bound by the current casting requirements, and the reader is getting the same information, just in a more clear and readable format.

This proposed change doesn’t affect current language syntax (in the way that value as f32 would), just a different builtin casting function (or family of).

There’s been a lot of discussion and debate around the casting in Zig over the years, I’m curious to know thoughts on this.

3 Likes

I kind of like this, using the usize of for loops as a number (float or int) is really cumbersome as right now. This addresses the float issue. Is there an easy way to use it as an int? (Other than the @as( T, …) )

If you’re asking if there’s an easy way to use loop indexes as an int, I guess it depends on what you’re doing. I run into issues a lot with unsigned ints in general, where a snippet like

const bar: i8 = -3;
var foo: u32 = 10;
foo += bar;

is a compile error, due to RLS, and must be written as

foo = @intCast(@as(i8, @intCast(foo)) + bar);

I have some loose ideas about ergonomic but intent-conveying integer and floating-point arithmetic that I’m saving doing a write-up on for a rainy day, but one of the ideas is that in a post-“allow integer types to be any range” world, I think it would make sense if integer division always required either an inferred floating-point result type or wrapping the division in a rounding builtin.

const x: i32 = ...;
const y: i32 = ...;

// error: integer division requires explicit rounding
const result = x / y;

// OK (f64 can represent all i32 values)
const result: f64 = x / y;
// (equivalent to)
const result: f64 = @as(f64, x) / @as(f64, y);

// OK (inferred f32 result type is propagated to operands)
const result: f32 = @floatFromInt(x) / @floatFromInt(y);

// OK (integer results with explicit rounding)
const result = @trunc(x / y);
const result = @ceil(x / y);
const result = @floor(x / y);
const result = @roundEven(x / y);
const result = @roundAway(x / y);
const result = @exact(x / y);

// OK (also works for remainder)
const result: f64 = x % y;
const result = @trunc(x % y);

(As a bonus, this would let you remove about half a dozen builtins like @divTrunc, @divFloor, @rem, @mod, etc.)

Provided that sin_d has a comptime-known length less than 224 and that scale is within that same range, with these changes you’d be able to rewrite your example as:

for (sin_d, 0..) |s, i| {
    const ratio: f32 = i / (sin_d.len - scale);
    // (equivalent to)
    const ratio: f32 = @as(f32, i) / @as(f32, (sin_d.len - scale));
    ...
}
7 Likes

What I’d actually really like for the OP example is a @divCast.

for (sin_d, 0..) |s, i| {
  const ratio: f32 = @divCast(i, sin_d.len - scale);
  ...
}

If the result type isn’t known, it’d still require being wrapped in @as, but it’d simplify code like this. The tricky thing about division specifically is that you need both sides to cast before the operation, but the result type of subexpressions isn’t known so you have to use @as to resolve both sides. But a builtin like this would theoretically be able to use the result type to know how to cast them.

I like this a lot! Are you planning to post the write up here or as an issue on codeberg, because I’d love to read it when you do.

I used to hate the casting in Zig overall, but after working with it and growing accustomed to the nature of explicit loss of precision, I’m okay with it now, generally. My main gripes are the wordiness of doing such simple operations.

The @as(T, val) syntax is fine with me, but one issue I take with it (in it’s current form) is either you have to default to the wordiness of @intFromFloat(), @intCast(), etc inside of @as(), or you end up with a variable that’s larger than you actually need (i.e. an f64 when an f32 would suffice).

I personally would like to see the removal of @divTrunc, @divFloor, etc as I don’t like the idea of hiding an arithmetic operation behind a builtin function. @divFloor(a, b) is MUCH less readable then @floor(a / b) in the context of communicating intent.

Hoping to see more from this.

1 Like

As I said in my reply to @castholm, I’m not a huge fan of hiding operations behind builtin functions.

@divCast(a, b) does not communicate as clearly as a / b, and when / is already an operator that is designed to operate on primitives, it seems silly to have to provide functions that do the job of the operator when you’re still working with primitive types.

Like you said, division is a tricky case in Zig as Zig has no concept (as far as I know) of an rvalue the way that C/C++ does. With the way that Zig’s PTR and RTL work right now, there’s going to have to be a cast somewhere and I think I’d personally like the cast and / operation to still be explicit to the writer and reader.

I feel that’s slightly overstated because there is no fractional division for integers. It’s not a function vs an operator, it’s

const ratio: f32 = @float(f32, i) / @float(f32, sin_d.len - scale);
// or
const result: f32 = @floatFromInt(x) / @floatFromInt(y);
// vs
const ratio: f32 = @divCast(i, sin_d.len - scale);

, using examples from the above posts

castholm did mention this syntax as well,

// OK (f64 can represent all i32 values)
const result: f64 = x / y;

but that only covers f64.

For myself, I’m attracted to the builtin because it helps keep to the “don’t repeat yourself” principle by specifying the float to cast to in the result type. Though I can see your point about this also potentially being less clear.

Fair point. It’s just my personal preference for code that reads naturally if that makes sense. From the examples you showed,

const ratio: f32 = @float(f32, i) / @float(f32, sin_d.len - scale);
// or
const result: f32 = @floatFromInt(x) / @floatFromInt(y);
// vs
const ratio: f32 = @divCast(i, sin_d.len - scale);

The first two read naturally to me as a numerator and denominator division operation, while the third, you must ascertain what @divCast() does before you understand that it’s a division. The name makes it pretty clear and I might just be nitpicking here, but to me the first two feel better to read and write.

Edit: An issue that I didn’t think about until now is, wouldn’t a @divCast builtin still produce the same issue under certain conditions?

const ratio: f32 = some_u24 * @divCast(i, sin_d.len - scale);

@divCast wouldn’t be able to resolve a return type here, and this would probably result in

const ratio: f32 = some_u24 * @as(f32, @divCast(i, sin_d.len - scale));

Yes, I mentioned this in my first post. However, I think the this solution still reduces repetition when compared side-by-side with the others in the same situation.

const ratio = some_u24 * @as(f32, @floatFromInt(x) / @floatFromInt(y));
const ratio = some_u24 * (@float(f32, x) / @float(f32, y));
const ratio = some_u24 * @as(f32, @divCast(x, y));

I checked with a small test program:

pub fn main() !void {
    const some_u24: u24 = 2;
    var ratio = some_u24 * @as(f32, 0.5);
    ratio = 5;
    @import("std").debug.print("{s}\n", .{@typeName(@TypeOf(ratio))}); // f32
}

to prove I could remove the : f32 from the ratio lines, just to make sure.

FWIW, this is basically the reason why I added alternative float-versions for some functions in the sokol header C APIs so that language bindings can be a bit more ‘friendly’ towards the target language (Zig isn’t the only language where converting between float and int is more hassle than it should be, e.g. Rust is arguable even worse).

E.g. sokol-app has ‘dual functions’ like:

int sapp_width();
float sapp_widthf();

…and sokol-gfx has functions like:

void sg_apply_viewport(int x, int y, int w, int h, bool origin_top_left);
void sg_apply_viewportf(float x, float y, float w, float h, bool origin_top_left);

…that way you can pick the function that’s a better match for your surrounding code and avoid excessive casting between ints and floats.

I didn’t go as far and offer an int-vs-float alternative for descriptor-structs though, that would lead to a huge combinatorial explosion.

2 Likes
for (sin_d, 0..) |s, i| {
  const ratio: f32 = @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(sin_d.len - scale));
  ...
}

This would be simpler if LHS f32 would propagate to RHS through the division operator.

I already suggested that on other topics, but it keeps finding new usages

1 Like

I used to find this annoying as well, but now I see @as(f32, @floatFromInt(n)) as a code smell. It usually indicates you’re doing too much in a single assignment statement.

const ratio: f32 = @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(sin_d.len - scale));                                                                                                                              

Should be broken up into:

const loop_index: f32 = @floatFromInt(i);                               
const div: f32 = @floatFromInt(sin_d.len - scale);                      
const ratio: f32 = loop_index / div;                                                                                                                                          

This makes the exact order in which the mathematical operations & typecasts happen clear and unambiguous. You can also give the variables descriptive names, or add comments next to each line individually.

A more in-depth example: Bm25er - Search local files like you're searching the web (BM25 implementation) - #3 by vincentd

1 Like

If it were any case other than basic arithmetic I would agree with you. If we get rid of all the casting,

const val = a / (b - c);

is a completely normal line. Breaking this very simple operation, that without the casts is self explanatory, into three separate lines feels like code smell to me.
However, I’d agree with you if something like this were going on instead,

const val = someFun(someOtherFun(.some_enum));

where you’re relying on returns and the code is less self-documenting.
But for arithmetic, the order of mathematical operations is already unambiguous, and the casts in the example are self-contained, so it’s clear what is being cast.

This would negate the need for @as() in the denominator, but it would still be required in the numerator I believe. The division operation makes it ambiguous to the compiler what type is the target, so unless the type from ratio get’s propogated to the numerator, which then gets propogated to the denominator, you’d end up with

const ratio: f32 = @as(f32, @floatFromInt(i)) / @floatFromInt(sin_d.len - scale);

I definitely would like to see improvements to the PTR system, and possibly RLS to make things like you suggested possible, but an easier fix in the meantime is just deprecating @floatFromInt with a shorter, more ergonomic name like they did with @intToFloat@trunc. This issue mainly exists when working with usize, which is nigh unavoidable especially when working with data.

Ignoring type-casts is a mistake, and one that will bite you in the ass when you least expect it.

  1. The machine can’t ignore them; the CPU must run an instruction (or multiple, if it needs to widen/narrow the integer) to do each cast.
  2. An entire class of bugs is caused by type-casting in the wrong place. A classic example is expressing a value as a percentage:
// C code
float percent(int val, int max) {
    float good = 100.0 * val / max;
    float bad = val / max * 100.0;     // Always 0.0 or 100.0
    return good;
}

Zig treats type-casts as being just as important as addition and multiplication; something you need to specify explicitly and unambiguously. Like many other features (e.g. unused values), it seems pedantic and tedious… until it saves you from a nasty footgun.

I’m well aware, I never mentioned anything about ignoring type casts. In my reply, when I said

it was specifically in the context of looking at what operations are involved to initialize val. If you go back and read the original post, I mentioned I agree with Zig’s philosophy of making lossy casts explicit, and I don’t necessarily want to see that removed.

I understand that casting can sometimes be annoying, but I can’t help but wonder if other options were explored first before trying to extend the language. A device of the language should only be used if it’s actually useful, instead of trying to shoe horn it in, via casting in this case. If index being usize is no good, then don’t use it. Also the denominator is constant no, it can be moved out also right?

var i: f32 = 0.0;
const den: f32 = @floatFromInt(sin_d.len - scale);
for (sin_d) |s|  {
    defer i += 1;
    const ratio: f32 = i / den;
    ...
}

Don’t get me wrong, I often write something like this to make my life easier:

inline fn i2f(i: anytype) f32 {                                                 
    return @floatFromInt(i);                                                    
} 

But doing so is a trap for the unwary:

const std = @import("std");                                                                                               
                                                                               
inline fn i2f(i: anytype) f32 {                                                 
    return @floatFromInt(i);                                                    
}

test "float math" {                                                                
    const a: f64 = 1.0 / i2f(3);                                                
    const b: f64 = 1.0 / @as(f64, @floatFromInt(3));                            
                                                                                
    try std.testing.expectEqual(a, b);  // Fails                                
}

The @as(f32) or @as(f64) is not mere boilerplate, it changes how the computation is performed! That’s not Zig’s fault; floating-point is - and always will be - a minefield of weird behaviour.

I get what you’re saying, but I’m confused how this relates to the original point. Again, I’m not talking about free or implicit casting of floating points. Everything I’ve said in the original post was about cleaner/more ergonomic builtins surrounding explicit casting of floating points.

@as() is not a conversion, it’s a type coercion. It can only be applied when the cast is safe, such as a u24 to a f32, or an integer that fits within another (u8 to u16 or u8 to i16). You cannot do

// works if values are compile time known, errors on runtime values
const a: f64 = 1.0;               
const b: f32 = @as(f32, a);

That being said, @intFromFloat() has been deprecated in favor of more ergonomic functions like @trunc(), as I’ve mentioned a few times already. Whether the intention was to provide a more ergonomic cast, or the deprecation is merely a consequence of adding the other builtins, it doens’t matter.

What I’m saying is I’d like the same treatment for the other way, int -> float. A builtin

@float(int: anytype) anytype

In that case, @as would still be required for casts that can’t be resolved by peer type resolution, so you’d have cases like

const val: f32 = foo / @as(f32, @float(some_int));

But, the fact that Zig now supports implicit (safe) casting of primitives, I wonder if a world where @as() can simply take the place of an unsafe/destructive cast, where

const val = foo / @as(f32, some_int);

Again, just as descriptive, necessary for casts that are unsafe, but much more readable. Bottom line is that Zig’s casting needs an ergonomic touch-up or rethink, and I only say that because so many people also dislike how verbose it is. You make the argument that it exists like this for the sake of making it unambiguous, but I’m arguing that it can be unambiguous while being MORE ergonomic and MORE readable.

I’m not sure what your opposition is to this, as the first example changes no behavior whatsoever, and the second really doesn’t either. They’re both explicit casts that document exactly what they’re doing.

3 Likes