When implementing arbitrary-precision arithmetic types like a custom big_int, constructing an instance from a floating-point number is a common requirement. To avoid precision loss or awkward manual bit-twiddling, the cleanest strategy is to factor the floating-point value into an unsigned integer significand and a base-2 exponent: x = integer × 2exponent.

While low-level bit manipulation using std::bit_cast is an option, it requires specific knowledge of IEEE 754 layouts and fails to provide generic support across standard and extended types like float, double, and long double. Fortunately, you can achieve this cleanly and generically using standard library functions like std::frexp and std::numeric_limits.

The Core Insight: Leveraging std::frexp

The C++ standard library provides std::frexp (defined in <cmath>), which decomposes any floating-point number into a normalized fraction in the half-open range [0.5, 1.0) (or 0 for zero) and an integral power of two:

x = significand × 2^exponent

Because the fractional significand produced by std::frexp has at most std::numeric_limits<T>::digits bits of precision (e.g., 24 bits for 32-bit float, 53 bits for 64-bit double), scaling this fraction by 2digits turns it into an exact unsigned integer! To keep the overall value identical, you simply subtract digits from the binary exponent.

Generic C++20 Implementation

Here is a generic, modern C++ solution using C++20 concepts:

#include <cmath>
#include <concepts>
#include <limits>

struct decomposition {
    unsigned long long integer;
    int exp;
};

template <std::floating_point T>
decomposition decompose_float(T x) {
    // Handle zero explicitly
    if (x == static_cast<T>(0)) {
        return {0ull, 0};
    }

    int raw_exp = 0;
    T frac = std::frexp(x, &raw_exp);

    // Number of precision bits in the mantissa (including implicit leading bit)
    constexpr int digits = std::numeric_limits<T>::digits;
    
    // Scale the fraction into an exact integer
    // std::ldexp(frac, digits) computes frac * 2^digits
    auto scaled_integer = static_cast<unsigned long long>(std::ldexp(frac, digits));
    int adjusted_exp = raw_exp - digits;

    return {
        .integer = scaled_integer,
        .exp = adjusted_exp
    };
}

How It Works Step-by-Step

  • Step 1: Check for Zero. For x = 0.0, std::frexp yields 0 with exponent 0. Short-circuiting simplifies logic and returns {0, 0} directly.
  • Step 2: Fractional Decomposition. std::frexp breaks x down so that 0.5 ≤ frac < 1.0. For instance, for 2.5f, std::frexp(2.5f, &exp) sets frac = 0.625 and raw_exp = 2 (since 0.625 × 22 = 2.5).
  • Step 3: Scaling into an Integer. For a standard float, std::numeric_limits<float>::digits is 24. Multiplying 0.625 by 224 yields the integer 10485760.
  • Step 4: Balancing the Exponent. We compensate by subtracting 24 from the exponent: raw_exp - digits = 2 - 24 = -22. Note that 10485760 × 2-22 = 5 × 2-1 = 2.5.

Optional: Removing Trailing Binary Zeros

Notice that scaling by 2digits often leaves trailing zeros in the integer part (such as 10485760 × 2-22 instead of 5 × 2-1). If you want the smallest possible integer representation, use std::countr_zero to strip trailing zeros:

#include <bit>

template <std::floating_point T>
decomposition decompose_float_minimal(T x) {
    if (x == static_cast<T>(0)) {
        return {0ull, 0};
    }

    int raw_exp = 0;
    T frac = std::frexp(x, &raw_exp);

    constexpr int digits = std::numeric_limits<T>::digits;
    auto integer = static_cast<unsigned long long>(std::ldexp(frac, digits));
    int exp = raw_exp - digits;

    // Shift out trailing zeros to minimize the integer factor
    int shift = std::countr_zero(integer);
    integer >>= shift;
    exp += shift;

    return { integer, exp };
}

With this addition, decompose_float_minimal(2.5f) cleanly returns integer = 5 and exp = -1.

Important Edge Cases to Keep in Mind

  • Precision Limit: unsigned long long is guaranteed to be at least 64 bits. This is large enough to hold the significand of IEEE 754 single precision (24 bits) and double precision (53 bits). If you use 80-bit or 128-bit extended precision types (long double), verify that digits ≤ 64 or expand the integer field to a 128-bit type (such as __uint128_t).
  • Special Floating-Point Values: Ensure you validate that std::isfinite(x) is true before calling this function, as NaN and Infinity cannot be mapped to an integer and exponent representation.