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 checkingvalue != 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;