now we’re cooking
Yes! I had too many accidents in other languages. The explicitness of Zig can be a bit painful sometimes. But I like that pain more than a bug. I did not write one numeric bug yet using Zig!
Those numerical bugs are pretty easy to write unit tests for so I’m not sure the possibility of making a mistake is worse than the cure here. I’ve only written such code once in Zig tho so I might get happier with the current state if I use it more.
However, I really like the suggestion of automatic coercion to ints from certain functions!
This might not be the suggestion you’re looking for, but have you thought about making your own @builtin
helper functions? My current project is riddled with typecasting, and I’ve thought about defining a few to make things cleaner to read and understand.
For example, something like this:
const x = @as(usize, @intFromFloat(position.x)
could be:
const x = @usizeFromF32(position.x)
Naming these is hard though, and I haven’t done it myself yet, but it would be less noisy for common typecasting.
The second I imported Raylib I felt that helpers might be inevitable.
raylib.drawRectangle(
@as(i32, @intCast(x)) * @as(i32, @intFromFloat(cell_width)),
@as(i32, @intCast(y)) * @as(i32, @intFromFloat(cell_height)),
@as(i32, @intFromFloat(cell_width)),
@as(i32, @intFromFloat(cell_height)),
color,
);
You can create regular helper functions, but builtins are just those that come with the language. So what do you mean with creating builtins yourself, do you mean adding more builtins for specific type casting to the language?
I suppose I think of them like globally accessible functions. You are right, they would just be a helper to replace chained builtins for typecasting.
So more like
const x = usizeFromF32(position.x)
You would have to import and declare the function in the files where you use them (because Zig doesn’t have globally available functions, builtins are available everywhere but they are part of the language), so you would need something like this:
const helpers = @import("helpers");
const usizeFromF32 = helpers.usizeFromF32;
Personally I think that is good enough. You also could do something like:
const h = @import("helpers");
...
const x = h.usizeFromF32(position.x)
…language bindings warts like this is exactly why I added separate functions for float and int to the sokol C headers, e.g.
void sg_apply_viewport(int x, int y, int width, int height, bool origin_top_left);
void sg_apply_viewportf(float x, float y, float width, float height, bool origin_top_left);
void sg_apply_scissor_rect(int x, int y, int width, int height, bool origin_top_left);
void sg_apply_scissor_rectf(float x, float y, float width, float height, bool origin_top_left);
…because even though the actual coordinates are ‘non-fractional pixel positions’, most game-y code works with float coodinates most of the time.
You don’t need such special builtins, and language primitives should stay primitive.
You can define a function like fn toFloat(FloatType: type, integer: anytype) FloatType
that at call-site would look like toFloat(f32, x)
and in its body would do the needed shaping; likewise for toInt
.
You can even consider wrapping the function to make use of decl literal syntax, if you do some conversion for that function very consistently, like
fn drawRectangle(x: FloatOrInt, y: FloatOrInt, w: f32, h: f32) Rect {
return originalDrawRectangle(x._in, y._in, w, h);
}
const FloatOrInt = struct {
_in: f32,
pub fn int(integer: anytype) FloatOrInt {
// do conversion
return .{ ._in = converted };
}
pub fn float(flt: anytype) FloatOrInt {
return .{ ._in = flt };
}
}
// call-site
drawRectangle(.int(123), .float(5.0), 5.0, 5.0);
I hadn’t considered your approach before, so I spent some time implementing something similar to play with the idea. This is what I came up with.
pub const Num = union(enum) {
I32: i32,
U32: u32,
F32: f32,
Usize: usize,
pub fn @"i32"(val: anytype) Num {
const T = @TypeOf(val);
if (!(T == i32 or T == u32 or T == usize or T == f32)) {
@compileError("Invalid type for i32 constructor");
}
return .{
.I32 = if (T == i32)
val
else if (T == u32)
@as(i32, @intCast(val))
else if (T == usize)
@as(usize, @intCast(val))
else // T == f32
@as(i32, @intFromFloat(val)),
};
}
//... similar for the other types
pub fn as(self: Num, comptime T: type) T {
if (self == .I32) {
if (T == i32) return self.I32;
if (T == u32) return if (self.I32 < 0) 0 else @as(u32, @intCast(self.I32));
if (T == usize) return if (self.I32 < 0) 0 else @as(usize, @intCast(self.I32));
if (T == f32) return @as(f32, @floatFromInt(self.I32));
@compileError("Unsupported conversion target from I32");
}
if (self == .U32) {
if (T == i32) return @as(i32, @intCast(self.U32));
if (T == u32) return self.U32;
if (T == usize) return @as(usize, @intCast(self.U32));
if (T == f32) return @as(f32, @floatFromInt(self.U32));
@compileError("Unsupported conversion target from U32");
}
if (self == .F32) {
if (T == i32) return @as(i32, @intFromFloat(self.F32));
if (T == u32) return if (self.F32 < 0) 0 else @as(u32, @intFromFloat(self.F32));
if (T == usize) return if (self.F32 < 0) 0 else @as(usize, @intFromFloat(self.F32));
if (T == f32) return self.F32;
@compileError("Unsupported conversion target from F32");
} else { // (self == .Usize) {
if (T == i32) return @as(i32, @intCast(self.Usize));
if (T == u32) return @as(u32, @intCast(self.Usize));
if (T == usize) return self.Usize;
if (T == f32) return @as(f32, @floatFromInt(self.Usize));
@compileError("Unsupported conversion target from Usize");
}
}
I chose to clamp negatives to 0, which just feels reasonable for my use cases. I swapped it in for a few raylib calls. Here’s a little before and after
const num = @import("num.zig");
const N = num.Num; // nom nom
const sidebar_width: f32 = 400.0;
const sidebar_x: f32 = window_width - sidebar_width;
const sidebar_y: f32 = 0.0;
const sidebar_height: f32 = window_height;
// before
raylib.drawRectangle(
@as(i32, @intFromFloat(sidebar_x)),
@as(i32, @intFromFloat(sidebar_y)),
@as(i32, @intFromFloat(sidebar_width)),
@as(i32, @intFromFloat(sidebar_height)),
.dark_gray,
);
// after
raylib.drawRectangle(
N.f32(sidebar_x).as(i32),
N.f32(sidebar_y).as(i32),
N.f32(sidebar_width).as(i32),
N.f32(sidebar_height).as(i32),
.dark_gray,
);
With a drawRectangle wrapper, it’s cleaner yet.
fn drawRectangle(
x: Num,
y: Num,
width: Num,
height: Num,
color: raylib.Color,
) void {
raylib.drawRectangle(
x.as(i32),
y.as(i32),
width.as(i32),
height.as(i32),
color,
);
}
drawRectangle(
N.f32(sidebar_x),
N.f32(sidebar_y),
N.f32(sidebar_width),
N.f32(sidebar_height),
raylib.Color{ .r = 30, .g = 30, .b = 30, .a = 255 }
);
I’ll have to keep playing with this. It’s working in my project so far. Thank you.
A couple of suggestions on that code snippet.
Since you are using an enum, I would use a switch statement in your as
function instead of the if/else chain. This has the added benefit of exhaustive checking, which the if/else chain will not. If you add anothes union variant, this will make sure you handle that case.
pub fn as(self: Num, comptime T: type) T {
switch (self) {
.I32 => |v| {
if (T == i32) return self.I32;
if (T == u32) return if (self.I32 < 0) 0 else @as(u32, @intCast(self.I32));
if (T == usize) return if (self.I32 < 0) 0 else @as(usize, @intCast(self.I32));
if (T == f32) return @as(f32, @floatFromInt(self.I32));
@compileError("Unsupported conversion target from I32");
},
.U32 => {...},
.F32 => {...},
.Usize => {...},
}
}
The next suggestion depends on how extensible you want the code to be. If you want the union to cast to more than just the 4 types you specified, you can get the type info and make the casting broader. This does make the code more complex and challenging to reason about though, so you will need to determine if it is worth it to you.
So instead of if (T == i32)
you would have:
switch (@typeInfo(T)) {
.Int => |i| {
if (i.signedness == .signed) {
if (i.bits < 32) {
// Do a truncation
return @as(T, @truncate(self.I32));
} else {
return @as(T, @intCast(self.I32));
}
} else {
...
}
},
.Float => |f| {
// You can do this if you only want to support a specific bit width.
if (f.bits == 32) {
return @as(T, @floatFromInt(v));
}
@compileError("Cannot convert I32 to a float that is not 32 bits");
},
else => @compileError("Unsupported conversion target from I32");
}
Because drawRectangle
already declares the type Num
you can remove the N
by using decl literal notation and color can use anonymous struct literals:
drawRectangle(
.f32(sidebar_x),
.f32(sidebar_y),
.f32(sidebar_width),
.f32(sidebar_height),
.{ .r = 30, .g = 30, .b = 30, .a = 255 },
);
But the thing I am wondering about, is whether there is some way to make the Num
union disappear from the generated code, basically whether it is possible to turn it into the fully mono-morphized equivalent, without manually writing it.
Maybe declaring drawRectangle
and Num’s functions as inline would work?
It would be nice if there was a way to declare the Num union as needing to be fully unbox-able/inline-able and otherwise you get a compile error, for using it in a way that can’t be unboxed.
My initial implementation was using a switch instead of the all the if
blocks. There was a problem with the compiler, and I bailed out of trying to fix it for the interest of time. Basically the switch wanted an else
case, but it also hated having an unreachable else
case. Sort of felt like a damned if I do, damned if I don’t situation.
error: unreachable else prong; all cases already handled
and
error: else prong required when switching on type 'type'
drawRectangle(
.f32(sidebar_x),
.f32(sidebar_y),
.f32(sidebar_width),
.f32(sidebar_height),
.{ .r = 30, .g = 30, .b = 30, .a = 255 },
);
Great to know that the N
and raylib.Color
can be dropped in the wrapped drawRectangle
function. That is pretty darn clean compared to raw typecasting with the builtins. A monomorphic approach would be sick.
When switching on type you can use an else and put @compileError(@typeName(T) ++ " is not supported")
inside of it, that way people who use your function with a type that isn’t handled will get a compile error that points to that @compileError
message.
So basically use @compileError
not unreachable
.
But I also think that the error: unreachable else prong;
message must have been in a slightly different context (not switching on type
), because I doubt that you had handled every possible type
, or that the compiler would even detect that.
This is what I originally tried to do.
pub fn as(self: Num, comptime T: type) T {
return switch (self) {
.I32 => switch (T) {
i32 => self.I32,
u32 => if (self.I32 < 0) 0 else @as(u32, @intCast(self.I32)),
usize => if (self.I32 < 0) 0 else @as(usize, @intCast(self.I32)),
f32 => @as(f32, @floatFromInt(self.I32)),
else => @compileError("Unsupported conversion target"),
},
...
The compiler told me the else
case is unreachable. I was surprised because I figured there’s more types out there that I haven’t addressed.
My guess would be that your switch on self of type Num
had an else case too; and that the compiler complained about that.
I’ve seen a need for awhile for a concept of “arithmetic compatibility”, separate from casting. Let me work a few examples.
fn intToFloat(u: u64) f: 64 {
return u;
}
This is a cast. It loses information, so it should require annotation that this is expected.
fn intPlusFloat(u: u64, f: f64) f64 {
return f + u;
}
In status-quo rules, this is also a cast, or would be. So it isn’t allowed under the same rules. But there’s no problem with it!
The numeric width of f64
is much larger than that of u64
, at the price of losing information. But clearly we’re asking it to be treated as a float, so some truncation of low-bits at the high end is fine. So converting to the nearest equivalent float and performing the addition should also be fine, and we could say that for addition and subtraction, at least, f64 = f64 + u64
is arithmetically compatible.
This comes up with unsigned-signed arithmetic as well.
fn unsignedCast(i: isize) usize {
return i;
}
Clearly this is also a cast, and not a safe one, so again, it shouldn’t be allowed.
But then, we have this:
fn signedUnsignedAdd(i: isize, u: usize) usize {
return i + u;
}
Now, you’ll notice this is not a safe operation. That’s true. But neither is this:
fn unsignedAdd(u1: usize, u2: usize) usize {
return u1 + u2;
}
And in fact, over the whole domain of these two functions, signedUnsignedAdd
has a larger set of potential values which it’s safe to pass without triggering illegal behavior. It’s worth taking the time to convince yourself of this, if you’re skeptical.
So it doesn’t really make sense, from a safety perspective, to forbid signedUnsignedAdd
but allow unsignedAdd
without annotation. So again, we could say that usize = isize + usize
is arithmetically compatible.
I think we could come up with a definition for arithmetic compatibility which is terse enough to understand and reason about, would get rid of a lot of casts which are annoying, tricky to get right, and feel unnecessary for the reasons just elucidated. The effect on safety would be if anything positive, and it would come with fewer logic bugs as well.