How to Decompose Floating-Point Numbers into an Integer and Base-2 Exponent in Modern C++
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^exponentBecause 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::frexpyields 0 with exponent 0. Short-circuiting simplifies logic and returns{0, 0}directly. - Step 2: Fractional Decomposition.
std::frexpbreaksxdown so that0.5 ≤ frac < 1.0. For instance, for2.5f,std::frexp(2.5f, &exp)setsfrac = 0.625andraw_exp = 2(since0.625 × 22 = 2.5). - Step 3: Scaling into an Integer. For a standard
float,std::numeric_limits<float>::digitsis 24. Multiplying0.625by224yields the integer10485760. - Step 4: Balancing the Exponent. We compensate by subtracting 24 from the exponent:
raw_exp - digits = 2 - 24 = -22. Note that10485760 × 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 longis 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 thatdigits ≤ 64or 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, asNaNandInfinitycannot be mapped to an integer and exponent representation.