2
\$\begingroup\$

I'm gradually writing an arbitrary-precision arithmetic library for C++. I've decided it could be useful to have the default constructor produce an object of indeterminate value, rather than relying upon {} = 0, to make my code more explicit.

I'm not yet sure whether I want operations like division by zero or square root of negatives to return NaN —currently I throw an exception for zero division, but it could be used for these later too.

I also think that for my library, it is more useful for NaN to compare equal with itself —I know this is the opposite behaviour to floating point, but I'm not intending to ever implement fp behaviour with my library and from reading up the history of why float NaN doesn't equal itself, it seems this was done mostly for technical limitations at the time rather than reasoning.

Here's the implementation I have come up with, using an empty tag-like struct to represent NaN, so it can be used with multiple different arbitrary-precision types easily:

#include <cstdint>

#include <compare>

static constexpr struct {} NaN = {};

class Number {
public:
    constexpr Number() : Number(NaN) {}
    constexpr Number(decltype(NaN)) : _is_nan(true) {}
    constexpr Number(std::intmax_t number) : _number(number) {}
    constexpr std::partial_ordering operator<=>(const Number& rhs) const {
        // NaNs are stritly unordered --unless when checking for NaN == NaN
        if (_is_nan or rhs._is_nan) { return std::partial_ordering::unordered; }
        // otherwise compare values by default
        return _number <=> rhs._number; // NOTE: implicitly converts strong to partial ordering
    }
    // for our equality operator, we actually *DO* want the default, as we want NaN == NaN
    constexpr bool operator==(const Number& rhs) const = default;
private:
    std::intmax_t _number = 0;
    bool _is_nan = false;
};

int main() {
    static_assert(Number(0) == Number(0));
    static_assert(Number() == Number());
    static_assert(Number() == NaN);
    static_assert(Number() == Number(NaN));
    static_assert(Number(45) <= Number(67));
    static_assert(Number(37) >= 21);
    static_assert(not (Number(32) > NaN));
    static_assert(not (Number() > Number()));
    static_assert(not (Number() <= NaN));
    static_assert(not (Number() < NaN));
}

Notes:

  1. Obviously this class isn't arbitrary precision. That code is too long and it's not relevant to this question, which is about API design. I have included only a tiny stub implementation of the value part of the class here, to focus on the implementation of NaNs.
  2. In practice, I will probably represent NaN internally as an object with no digits in its digit array, and there will be no _is_nan flag.
  3. Yes, constexpr is deliberate and yes, the existing current working version of the library does provide a fully working constexpr arbitrary-precision type. You can use it at compile-time but only if it's deallocated before the constant expression it is used in goes out of scope. This limits its utility but it is still a highly useful thing to have.
  4. All code is C++20

What do you think of my API design? I pondered whether I should just use a static constant NAN in the class and use that to get NAN, but this approach feels more user-friendly because one constant can be used with all of the arbitrary-precision classes I'm going to provide.

\$\endgroup\$
2
  • \$\begingroup\$ I originally assigned the tag operator-overloading but it has changed to overloading automatically and this happens every time I try and change it back. \$\endgroup\$
    – saxbophone
    Commented Feb 24, 2023 at 23:48
  • 2
    \$\begingroup\$ You can agree or disagree with Dr. Kahan, that's fine. But the technical term "NaN" comes with a lot of 754 baggage at this point. Users of your library will believe they know what the term means, and they will be astonished. Consider adopting a similar but different name for the concept you wish to convey to an audience of engineers who have already used numeric libraries. Also, "NaNs are strictly unordered" seems like trouble for <=, <, ==. Show us real caller code! \$\endgroup\$
    – J_H
    Commented Feb 25, 2023 at 1:48

1 Answer 1

2
\$\begingroup\$

Avoid surprising behaviour

I've decided it could be useful to have the default constructor produce an object of indeterminate value, rather than relying upon {} = 0, to make my code more explicit.

I don't see how it makes anything more explicit, you just changed the implicit behaviour of the default constructor. And when you write:

Number n{}; // NaN

It now works very different compared to regular integeres:

int n{}; // 0

Which will be surprising for programmers used to the behaviour of the built-in types.

for our equality operator, we actually DO want the default, as we want NaN == NaN

Are you really sure you want that? Consider a scenario where you did allow division by zero and the square root of a negative number to return a NaN instead of throwing an exception, would you want the following expression to be true?

Number(0) / Number(0) == Number(-1).sqrt()

There is a good reason why NaNs don't compare equal to themselves, and often this will cause code that was not written specifically to handle NaNs to do the better thing. And as J_H mentioned, programmers who know about IEE754 NaNs might rely on your NaNs to behave the same, and will be surprised if they don't.

Be careful with implicit constructors

The following constructor allows implicit conversion of any integer type to a Number:

constexpr Number(std::intmax_t number) : _number(number) {}

However, it will first convert the value you give it to a std::intmax_t. Consider that there is also a std::uint_max_t, and that is has an implicit conversion operator to std::intmax_t, which will change the actual value if it was larger than the maximum value std::intmax_t can hold. This will lead to surprising results, especially if one did not enable or pay attention to compiler warnings.

It is better to make this constructor explicit, but then it would only work on values that have exactly the same type as std::intmax_t. You could add overloads for all integer types of course. Alternatively, it might be possible to provide both implicit constructors for std::intmax_t and std::uintmax_t. In any case, I would add unit tests that check these kinds of corner cases.

\$\endgroup\$
5
  • \$\begingroup\$ Thank you very much for your detailed analysis, I must admit I had quite a giggle when I read your pointing out all the clumsy things I've done RE default constructor and NaN == NaN —I've never seen this particular rationale for why NaN shouldn't equal itself before and I find your case is quite logical. \$\endgroup\$
    – saxbophone
    Commented Feb 25, 2023 at 15:28
  • \$\begingroup\$ As for the implicit constructors, good spot! In my existing codebase, the only numeric type I've implemented so far is arbitrary-precision unsigned (Natural) numbers, so I have an implicit constructor taking uintmax_t —I'll have to check integer promotion and implicit conversion rules to check if that one is also vulnerable to the issues you mention. In general, I feel like the benefit of being able to do Number = 1234 is very valuable, so I'm a little reluctant to make the constructors only explicit. But I also want to avoid the issues you rightly bring up. Maybe separate overloads? \$\endgroup\$
    – saxbophone
    Commented Feb 25, 2023 at 15:30
  • 1
    \$\begingroup\$ I am tempting to say overloads, but even then there might be things that are implicitly convertible to integers that you might not want to accept, like bool, float and double. You can create overloads for those that you then delete, but who knows what other standard and/or custom types are out there... \$\endgroup\$
    – G. Sliepen
    Commented Feb 25, 2023 at 15:39
  • \$\begingroup\$ Good point. I will test this against standard types. I feel like if there's a non-standard type that is convertible to those that I do accept, at that point it's a bit out of my control and if someone makes their type implicitly convertible to say uintmax_t or intmax_t, that is an assertion that the thing is a valid form of said type and I am clear to accept it. \$\endgroup\$
    – saxbophone
    Commented Feb 25, 2023 at 16:01
  • 1
    \$\begingroup\$ Alas, I think I am ok for other custom types implicitly convertible to standard types: godbolt.org/z/5TYWfeav4 \$\endgroup\$
    – saxbophone
    Commented Feb 25, 2023 at 16:05

Not the answer you're looking for? Browse other questions tagged or ask your own question.