zig
zig copied to clipboard
Simplify converting between numerical ranges with the inclusion of built-ins such as @clampTo, and @wrapTo
Like a saturating operator for assignment/conversions. Safe for all values with the exception of nan -> int.
@clampTo(i2, @as(f32, @trunc(-1023.33))) == -1
Like int -> int conversions in C, truncating bits. but can now be applied to float -> int conversions as well.
@wrapTo(u4, @trunc(-1023.33)) == 0
And so we go from the current state of rewriting subsets of this logic at every nearly every assignment, making it very easy to screw up, harder to read, and still ending up with runtime panics if not done correctly... To these simpler, well-defined conversions for passing values through different numerical ranges. Leaving the only concern being a nan -> int, which could resolved in a similar way to @divExact().
e.g:
const x: u8 = @bitCast(@as(i8, @truncate(y -% @as(i32, @bitCast(@as(u32, @truncate(i)))))));
becomes:
const x = @wrapTo(u8, y +% @wrapTo(i32, i));
or with more developed inference, simply:
const x: u8 = @wrap(y +% @wrap(i));
(note: still using version 0.11, so just close if this has already been addressed in 0.12.0-dev)
note that @clampTo
can already be estimated with @max
. for example @max(unsigned_int, 255)
will always yield a u8
.
Thanks @nektro, while not immediately obvious, I did eventually learn that @max/@min
actually stops the compiler from failing due to signed to unsigned conversions, though it does seem to be a hit-or-miss at times.
Its not about Zig lacking the ability to do these things modularly, its about improving the ergonomics of how it does it to:
- reduce the verbosity for simple assignments and logic
- improve the readability (especially important for those of us with dyslexia)
- reduce the friction and frustration (i want to be thinking about algorithms, not recalling what specific order of ops I need to rewrite to add a usize that only goes from 0..10 to a i16 or someting)
- improving the safety; as its trivial to get it to compile, only to end up with runtime panics because the underlying reason of why there is any type safety around primitives in the first place wasn't even addressed... Its unsafe because its unclear what action should be taken when a value cannot fit into the destination's range. As such the solution is to trivially pick whether it should "wrap to fit" or "clamp to fit". After all we're going to re-implement one or the other inline...
Excuse me if this comes across as a rant, it just feels like it brings out the worst aspect of Zig (verbosity).
note that
@clampTo
can already be estimated with@max
. for example@max(unsigned_int, 255)
will always yield au8
.
This is simply not true. @max
will yield the larger type in the coerce.
EDIT: you're probably thinking of @min
.
I think it's better to not add new builtins for the sake of simplification.
Buitins should be keep for things we can't do "by hand" or things that can be highly optimized when done as compiler intrinsics.
They could be implemented in user land, and added to std if useful enough.
@Lking03x While I understand your point and strongly agree in principal, I don't think that's the case here.
What I'm requesting isn't simplification for the sake of simplification, its a refinement which improves Zig in many aspects, through addressing a type of boilerplate that is forced, frequent, and trivial to do incorrectly (runtime UB).
As for why it should be a built-in that is readily-available and easy to access, because:
-
It is frequent, and involves the most primitive aspects of the language.
-
It is necessary, as Zig requires us to explicitly handle range conversions ourselves anyway.
-
The compiler is already capable of presuming most of what we need to and are going to write anyway and will guide us towards it, but it cannot do it for us because: 1) In most cases there are two equally applicable options to pick from for handling range-fitting (at least AFAIK). 2) Automatically choosing either would be a form of hidden control flow.
-
Writing out specifically-tailored versions of 'wrapping' and 'clamping' inline seems like something the compiler should be doing instead, with less room for mistakes.
-
Use throughout Zig will likely solve those OOB errors caused from small mistakes writing that the compiler can't catch. eg:
// type difference has been resolved and so will compile, but the 'why' has not, and so may eventually panic .y = @as(i16, @intFromFloat(@round(cursor_position[1]))), // this handles the *why*, and will not panic .y = core.clampTo(i16, @round(cursor_position[1])), // @clamp(@round(cursor_position[1])) as a built in
-
Only built-ins can infer return types, which simplifies the syntax and reduces verbosity. For something as frequent as a simple range conversion it's significant.
Hope you can see what I mean by this.
As a bit of an update; I've implemented both in user space and made them accessible through a module "core", it is extremely useful when working with fields of packed structs, though I realised that "wrapTo" can only work on integers without error reporting, for when a float is +-inf.
It really feels like exactly what has been missing; sat and wrap ops for "type" conversions. Would greatly appreciate these as built-ins someday. (with better names of course)
Also, to the team congratulations and great work on the 0.12 release!
I thought I had done this already but seems I forgotten; here's what I have so far:
//! fit.zig, fit.cap, fit.wrap
const std = @import("std");
/// fit by limiting to bounds
pub inline fn cap(comptime ToType: type, value: anytype) ToType {
const math = std.math;
const FromType = @TypeOf(value);
const from_info: std.builtin.Type = @typeInfo(FromType);
const error_message = "unsupported cast to: '" ++ @typeName(ToType) ++ "', from: '" ++ @typeName(FromType) ++ "'.";
return switch(@typeInfo(ToType)) {
.ComptimeFloat,.Float => @as(ToType, switch(from_info) {
.ComptimeInt,.Int => @floatFromInt(value), // safe
.ComptimeFloat,.Float => @floatCast(value), // safe
.Bool => @floatFromInt(@intFromBool(value)), // safe
else => @compileError(error_message),
}),
.ComptimeInt,.Int => @as(ToType, switch(from_info) {
.ComptimeFloat,.Float =>
if (@trunc(value) <= @as(FromType, math.minInt(ToType))) @truncate(math.minInt(ToType)) // cap to lower, handles -inf
else if (@trunc(value) >= @as(FromType, math.maxInt(ToType))) @truncate(math.maxInt(ToType)) // cap to upper, handles +inf
else if (std.math.isNan(value)) @panic("nan prevention must be handled at callsite") else @intFromFloat(@trunc(value)),
.Int,.ComptimeInt =>
if (value <= @as(ToType, math.minInt(ToType))) @truncate(math.minInt(ToType)) // safe cast
else if (value >= @as(ToType, math.maxInt(ToType))) @truncate(math.maxInt(ToType)) // safe
else @intCast(value), // safe
.Bool => @intFromBool(value),
else => @compileError(error_message),
}),
else => @compileError(error_message),
};
}
/// returns error on NaN so may provide a fallback using 'catch'
pub inline fn capOrNaN(comptime ToType: type, value: anytype) error{NaN}!ToType {
const math = std.math;
const FromType = @TypeOf(value);
const from_info: std.builtin.Type = @typeInfo(FromType);
const compile_error_message = "unsupported fit to: '" ++ @typeName(ToType) ++ "', from: '" ++ @typeName(FromType) ++ "'.";
return switch(@typeInfo(ToType)) {
.ComptimeFloat,.Float => @as(ToType, switch(from_info) {
.ComptimeInt,.Int => @floatFromInt(value), // safe
.ComptimeFloat,.Float => @floatCast(value), // safe
.Bool => @floatFromInt(@intFromBool(value)), // safe
else => @compileError(compile_error_message),
}),
.ComptimeInt,.Int => @as(ToType, switch(from_info) {
.ComptimeFloat,.Float =>
if (@trunc(value) <= @as(FromType, math.minInt(ToType))) @truncate(math.minInt(ToType)) // cap to lower, handles -inf
else if (@trunc(value) >= @as(FromType, math.maxInt(ToType))) @truncate(math.maxInt(ToType)) // cap to upper, handles +inf
else if (std.math.isNan(value)) return error.NaN else @intFromFloat(@trunc(value)), // nan or truncated float
.Int,.ComptimeInt =>
if (value <= @as(ToType, math.minInt(ToType))) @truncate(math.minInt(ToType)) // safe
else if (value >= @as(ToType, math.maxInt(ToType))) @truncate(math.maxInt(ToType)) // safe
else @intCast(value), // safe
.Bool => @intFromBool(value),
else => @compileError(compile_error_message),
}),
else => @compileError(compile_error_message),
};
}
/// fit by wrapping, ints only. as how else should I wrap +-inf floats, also floats lack upper and lower bounds to wrap around
pub inline fn wrap(comptime ToType: type, value: anytype) ToType {
const math = std.math;
const FromType = @TypeOf(value);
const error_message = "unsupported fit to: '" ++ @typeName(ToType) ++ "', from: '" ++ @typeName(FromType) ++ "'.";
return @as(ToType, switch(@typeInfo(ToType)) {
// extended std.zig.c_translation.castInt, with support for comptime int
.Int => |dest| switch(@typeInfo(FromType)) {
.Int => |source| @bitCast(@as(std.meta.Int(source.signedness, dest.bits), @truncate(value))),
.ComptimeInt => @truncate(value),
else => @compileError(error_message),
},
else => @compileError(error_message),
});
}
Feel free to try this out and use it however you'd like.
Notes:
- renamed clampTo to 'cap' to avoid confusion and shorten name
- prevented fitting into booleans as a destination as booleans are not a numerical range, but does accept them as a source for the sake of convenience. May remove this though.
- created a error variant which allows using catch to provides a fallback in the case of NaN
- wrap fitting is limited to ints, is pretty much std.zig.c_translation.castInt (found this much later than I would've liked) with support for comptime_int
- while wrap fitting could support wrapping from floats, I've never needed it, and rarely even use wrapping fit anyway.