2
\$\begingroup\$

The rationale behind this code is to implement a runtime-safe number conversions in situations when precision loss is possible, but is not expected. Example: passing a size_t value from a 64-bit code to a library (filesystem, database, etc.) which uses 32-bit type for size, assuming you will never pass more than 4Gb of data. Safety here means cast result would have an exactly same numeric (not binary) value (i.e. any rounding, value wrapping, sign re-interpretation, etc. would be treated as casting failure). At the same time, simple check-free implicit casting between compatible types for maximum performance is highly desired. This is especially useful for template classes, which usually assumed to have no special treatment to the types they operate on. Since it would be used in many places of my code, I'm wondering if I've overlooked something.

Here's the code (note that "to"-template argument goes before "from"-argument for automatic argument deduction in real-world usage):

#include <limits>
#include <type_traits>
#include <stdexcept>
#include <typeinfo>

class SafeNumericCast {
    public:
        template <typename TFrom> class AutoCast {
            private:
                const TFrom _value;

            public:
                constexpr AutoCast(TFrom value) : _value(value) { }
                template <typename TTo> constexpr operator TTo() const {
                    return cast<TTo>(_value);
                }
        };

    protected:
        enum class NumberClass {
            UNSIGNED_INTEGER, SIGNED_INTEGER, IEEE754
        };

    protected:
        template <typename T> static constexpr NumberClass resolveNumberClass() {
            static_assert(std::numeric_limits<T>::radix == 2, "Safe numeric casts can only be performed on binary number formats!");
            if constexpr (std::numeric_limits<T>::is_integer) {
                if constexpr (!std::is_same<T, bool>::value) { // NOTE Boolean is conceptually not a number (while it is technically backed by one)
                    return std::numeric_limits<T>::is_signed ? NumberClass::SIGNED_INTEGER : NumberClass::UNSIGNED_INTEGER;
                }
            } else if constexpr (std::numeric_limits<T>::is_iec559) {
                return NumberClass::IEEE754;
            }
            throw std::logic_error("SafeNumericCast > Unsupported numeric type!");
        }

    public:
        template <typename TTo, typename TFrom> static constexpr bool isSafelyCastable() {
            if constexpr (!std::is_same<TTo, TFrom>::value) {
                const NumberClass toNumberClass = resolveNumberClass<TTo>();
                const NumberClass fromNumberClass = resolveNumberClass<TFrom>();
                if constexpr (toNumberClass == NumberClass::UNSIGNED_INTEGER) {
                    if constexpr (fromNumberClass == NumberClass::UNSIGNED_INTEGER) {
                        return std::numeric_limits<TTo>::digits >= std::numeric_limits<TFrom>::digits;
                    }
                } else if constexpr (toNumberClass == NumberClass::SIGNED_INTEGER) {
                    if constexpr ((fromNumberClass == NumberClass::UNSIGNED_INTEGER) || (fromNumberClass == NumberClass::SIGNED_INTEGER)) {
                        return std::numeric_limits<TTo>::digits >= std::numeric_limits<TFrom>::digits;
                    }
                } else if constexpr (toNumberClass == NumberClass::IEEE754) {
                    if constexpr ((fromNumberClass == NumberClass::UNSIGNED_INTEGER) || (fromNumberClass == NumberClass::SIGNED_INTEGER) || (fromNumberClass == NumberClass::IEEE754)) {
                        return std::numeric_limits<TTo>::digits >= std::numeric_limits<TFrom>::digits;
                    }
                }
                return false;
            }
            return true;
        }
        template <typename TTo, typename TFrom> static constexpr TTo cast(TFrom value) {
            static_assert(isSafelyCastable<TTo, TFrom>());
            return value;
        }
        template <typename TFrom> static constexpr AutoCast<TFrom> autocast(TFrom value) {
            return AutoCast<TFrom>(value);
        }
};

class SafeRuntimeNumericCast : public SafeNumericCast {
    public:
        template <typename TFrom> class AutoCast {
            private:
                const TFrom _value;

            public:
                constexpr AutoCast(TFrom value) : _value(value) { }
                template <typename TTo> constexpr operator TTo() const {
                    return cast<TTo>(_value);
                }
        };

    private:
        template <typename TTo, typename TFrom> static constexpr bool isRuntimeCastable(TFrom value, TTo casted) {
            static_assert(!SafeNumericCast::isSafelyCastable<TTo, TFrom>());
            const NumberClass toNumberClass = resolveNumberClass<TTo>();
            const NumberClass fromNumberClass = resolveNumberClass<TFrom>();
            if constexpr (toNumberClass == NumberClass::UNSIGNED_INTEGER) {
                if constexpr (fromNumberClass == NumberClass::UNSIGNED_INTEGER) {
                    return value <= std::numeric_limits<TTo>::max();
                } else if constexpr (fromNumberClass == NumberClass::SIGNED_INTEGER) {
                    if (value >= 0) {
                        return value <= std::numeric_limits<TTo>::max();
                    }
                } else if constexpr (fromNumberClass == NumberClass::IEEE754) {
                    return casted == value;
                }
            } else if constexpr (toNumberClass == NumberClass::SIGNED_INTEGER) {
                if constexpr (fromNumberClass == NumberClass::UNSIGNED_INTEGER) {
                    return value <= std::numeric_limits<TTo>::max();
                } else if constexpr (fromNumberClass == NumberClass::SIGNED_INTEGER) {
                    return ((value >= std::numeric_limits<TTo>::min()) &&(value <= std::numeric_limits<TTo>::max()));
                } else if constexpr (fromNumberClass == NumberClass::IEEE754) {
                    return casted == value;
                }
            } else if constexpr (toNumberClass == NumberClass::IEEE754) {
                if constexpr (fromNumberClass == NumberClass::UNSIGNED_INTEGER) {
                    return value <= (1ULL << std::numeric_limits<TTo>::digits); // NOTE Can't do "casted == value" check because of int-> float promotion
                } else if constexpr (fromNumberClass == NumberClass::SIGNED_INTEGER) {
                    return static_cast<TFrom>(casted) == value; // NOTE Presumable faster than doing abs(value)
                } else if constexpr (fromNumberClass == NumberClass::IEEE754) {
                    return (casted == value) || (value != value);
                }
            }
            return false;
        }
    public:
        using SafeNumericCast::isSafelyCastable;
        template <typename TTo, typename TFrom> static constexpr bool isSafelyCastable(TFrom value) {
            if constexpr (!SafeNumericCast::isSafelyCastable<TTo, TFrom>()) {
                return isRuntimeCastable<TTo>(value, static_cast<TTo>(value));
            }
            return true;
        }
        template <typename TTo, typename TFrom> static constexpr TTo cast(TFrom value) {
            if constexpr (!SafeNumericCast::isSafelyCastable<TTo, TFrom>()) {
                TTo casted = static_cast<TTo>(value);
                if (isRuntimeCastable<TTo>(value, casted)) {
                    return casted;
                }
                throw std::bad_cast();
            }
            return value;
        }
        template <typename TFrom> static constexpr AutoCast<TFrom> autocast(TFrom value) {
            return AutoCast<TFrom>(value);
        }
};

The usage is simple:

SafeNumericCast::cast<uint64_t>(42); // Statically check for possible precision loss
SafeRuntimeNumericCast::cast<float>(1ULL); // Dynamically check for precision loss

SafeNumericCast::isSafelyCastable<uint64_t, uint32_t>(); // Non-throwing static check
SafeRuntimeNumericCast::isSafelyCastable<float>(1ULL); // Non-throwing dynamic check

Here are the assumptions the code is based on:

  • The code is working only with built-in "fundamental" binary numeric types - this is intended for now.
  • Any unsigned integer can be exactly represented by another signed or unsigned integer as long as it has enough digit capacity, otherwise a runtime value check against max value is required.
  • Any signed integer can be exactly represented by another signed integer as long as it has enough digit capacity, otherwise a runtime value check against min and max values is required.
  • Signed integer can't be generally represented by an unsigned integer, so a runtime value check is required. We can't simply compare by value due to sign re-interpretation during signed/unsigned promotion, so we have to separately check for negative sign and positive value against possible max value
  • Integers can be represented by an IEEE 754 float as long as it has enough digit capacity, otherwise a runtime value check is required. We can't simply compare by value due to possible rounding during integer/float promotion, so we have to manually check against maximum representable integer.
  • IEEE 754 floats can't be generally represented by an integer, so we have to check at runtime by simply comparing original and cast values. This should also cover NaN/Infinity/etc cases. The only problematic case is negative zero, and without constexpr std::signbit() we don't have much to do to help here, apart from turning the whole thing non-constexpr, which, IMO, is a way bigger no-no than letting negative zeroes slip through the cast.
  • Any IEEE 754 float can be exactly represented by another IEEE 754 float as long as it has same or bigger size (that is, double simply has more capacity for both mantissa and exponent, thus any float is exactly representable by a double). Otherwise, a simple runtime value comparison is required. The only corner case is NaN and std::isnan() is, unfortunately is not constexpr, but we can work it around by checking value != value.

Update. Added "auto-casting" functionality to allow automatic deduction of return/target type and get rid of that ugly explicit template parameter specifications:

double dvalue = SafeNumericCast::autocast(0.0f);
int ivalue = SafeRuntimeNumericCast::autocast(0.0f);

...or even:

auto value = SafeNumericCast::autocast(0.0f); // Hence the namesake
double dvalue = value;
int ivalue = value;
\$\endgroup\$
1
\$\begingroup\$

Use non-member functions where possible:

The cast functions are all static, and there is no member data in SafeNumericCast and SafeRuntimeNumericCast, so these the classes don't need to exist.

We could use namespaces, or plain non-member functions instead.


Unnecessary inheritance:

Inheritance seems unnecessary and confusing here. SafeRuntimeNumericCast hides all of the names in SafeNumericCast anyway. There's a using SafeNumericCast::isSafelyCastable;, but we immediately define another isSafelyCastable function?

We could put NumberClass and resolveNumberClass() into a namespace Detail { ... } so that we don't need to use inheritance.


SafeNumericCast:

SafeNumericCast is more or less equivalent to using C++ braced initialization, which already disallows narrowing casts:

auto i = 54;
auto u = std::uint8_t{ i }; // error
auto u = SafeNumericCast::cast<std::uint8_t>(i); // error

Technically the braced initialization only has to issue a warning in such cases, so I guess there's nothing wrong with making the intent explicit and forcing a definite error.

However, checking whether we can cast an integer to floating point without losing any precision seems like rather different functionality, and maybe deserves a different name (e.g. LosslessFloatCast or something).


SafeRuntimeNumericCast:

The name SafeRuntimeNumericCast, is a bit confusing, because we're doing the opposite of what SafeNumericCast did above. SafeRuntimeNumericCast allows us to do an "unsafe" cast, and then check that we got a meaningful result. So maybe CheckedNumericCast would be a better name?


Autocast:

Autocast<From> is really just a From that we pass around until we decide to actually cast it. If we want to save typing, then better names would probably help more than anything:

auto i = safeCast<int>(u);
auto f = safeLosslessCast<float>(i);
auto d = checkedCast<double>(x);

Readability:

Perhaps the nested if-statements would be more readable if they were flattened out:

        if constexpr (a == NumberClass::UNSIGNED_INTEGER && b == NumberClass::UNSIGNED_INTEGER) {
            return value <= std::numeric_limits<TTo>::max();
        }
        else if constexpr (a == NumberClass::UNSIGNED_INTEGER && b == NumberClass::SIGNED_INTEGER) {
            return value <= std::numeric_limits<TTo>::max();
        }
        else if constexpr (a == NumberClass::UNSIGNED_INTEGER && b == NumberClass::IEEE754) {
            return casted == value;
        }
        ...
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

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