ziglang / zig

General-purpose programming language and toolchain for maintaining robust, optimal, and reusable software.
https://ziglang.org
MIT License
34.09k stars 2.49k forks source link

Proposal: clarify comptime_float semantics #21205

Open ianprime0509 opened 1 month ago

ianprime0509 commented 1 month ago

The motivation behind this proposal is the analysis done in https://github.com/ziglang/zig/issues/21198#issuecomment-2308994913, with the goal of defining exactly how comptime_float should behave given conflicting implementation details and documentation.

Background

The language reference has this to say about comptime_float:

Float literals have type comptime_float which is guaranteed to have the same precision and operations of the largest other floating point type, which is f128.

As explained in https://github.com/ziglang/zig/issues/2794#issuecomment-507427285, one reason for comptime_float to exist rather than just defining all floating-point literals to be comptime-known f128s is that comptime_float may implicitly cast to other floating point types even if precision is lost. https://github.com/ziglang/zig/issues/5780#issuecomment-653332425 also solidified the connection between the implementation of comptime_float and the capabilities of f128 by reaffirming the above quote from the langref.

Despite this, there are some other differences in today's Zig between comptime_float and f128 which are not limited to implicit casting behavior (test remarks below are as of Zig 0.14.0-dev.1304+7d54c62c8):

const std = @import("std");
const expect = std.testing.expect;

test {
    // https://github.com/ziglang/zig/issues/21198
    try expect(0.0 / 0.0 == 0.0);
    try expect(std.math.isNan(@as(f128, 0.0) / @as(f128, 0.0)));

    // try expect(std.math.isInf(@as(f128, 1.0 / 0.0))); // error: division by zero here causes undefined behavior
    try expect(std.math.isInf(@as(f128, 1.0) / @as(f128, 0.0)));

    try expect(std.math.isPositiveZero(@as(f128, -0.0 / 1.0)));
    try expect(std.math.isNegativeZero(@as(f128, -0.0) / @as(f128, 1.0)));
}

The first two sets of discrepancies are included in the current compiler test suite (0.0 / 0.0, comptime_float / 0.0 error), which is why this is a proposal rather than a bug fix; evidently, some other behavior was intended at some point.

Proposed behavior

All arithmetic operations with comptime_float should behave as if they were done with comptime-known f128. That is, the test above should be updated to the following:

const std = @import("std");
const expect = std.testing.expect;

test {
    try expect(std.math.isNan(0.0 / 0.0));
    try expect(std.math.isNan(@as(f128, 0.0) / @as(f128, 0.0)));

    try expect(std.math.isInf(1.0 / 0.0));
    try expect(std.math.isInf(@as(f128, 1.0) / @as(f128, 0.0)));

    try expect(std.math.isNegativeZero(-0.0 / 1.0));
    try expect(std.math.isNegativeZero(@as(f128, -0.0) / @as(f128, 1.0)));
}

As the updated test suggests, accepting this proposal could also allow the float functions std.math (such as std.math.isInf) to be expanded to work with comptime_float by internally casting to/from f128, without concern that the semantics might be different. The formatted float printing logic already does this today: https://github.com/ziglang/zig/blob/7d54c62c8a55240bbe144ab03c78573a344598ce/lib/std/fmt/format_float.zig#L57-L58

Alternatives and follow-up changes

Signaling NaNs

This proposal makes no comment on the behavior of "signaling NaN", since as far as I can tell, Zig is currently lacking any builtins or functions to interact with the floating point environment (and frankly I lack the expertise to make an informed proposal on what such support should look like). If this proposal is accepted, then any future proposal to clarify or expand the behavior of signaling NaNs in Zig should comment on the behavior of signaling NaNs at comptime, and the behavior of comptime-known f128 and comptime_float should be identical.

Restricted comptime_float

The existing behavior of treating 1.0 / 0.0 as a compile error suggests that comptime_float may have originally been intended as not just f128, but f128 with no infinities or NaNs. If this is true, and if this still intended going forward, it will be necessary to clarify and fix implementation of comptime_float in that direction, and document it as the expected behavior. For example, as it is now, despite 1.0 / 0.0 being banned, it is trivial to create comptime_float infinities and NaNs by implicitly casting from f128 or another float type.

I can't think of any reason for 0.0 / 0.0 == 0.0, though; in a universe where comptime_float does not include infinities or NaNs, 0.0 / 0.0 should be a compile error just like 1.0 / 0.0.

std.math support for comptime_float

As mentioned in the proposed behavior section, if this is accepted and implemented, every std.math function which supports f128 could easily be updated to support comptime_float. For example, comptime_float NaNs could be produced using std.math.nan(comptime_float).

SeanTheGleaming commented 1 week ago

Perhaps this is a bit of a tangent, but to comment on your specific example with std.math.nan, maybe it would be better to define std.matn.nan not as a function, but as a comptime float to begin with. You could also say the same for std.math.inf

Eg. @TypeOf(std.math.nan) == comptime_float, and instead of using const x = std.math.nan(f32), use const x: f32 = std.math.nan.

I can't really think of a substantial downside to this, nan and inf are after all comptime known float values, so it would not only be more concise, but also more clear and intuitive