Skip to content

Instantly share code, notes, and snippets.

@dtonhofer
Last active May 7, 2025 10:01
Show Gist options
  • Save dtonhofer/3a8f89be72627efdf0c49e1a00dc1a81 to your computer and use it in GitHub Desktop.
Save dtonhofer/3a8f89be72627efdf0c49e1a00dc1a81 to your computer and use it in GitHub Desktop.
Explaining a floating-point number in Kotlin
package com.example.puzzles.operators.adddouble
import org.apache.commons.math3.fraction.BigFraction
import java.math.BigDecimal
import java.math.RoundingMode
import kotlin.text.toLong
// ---
// Deconstructing an explaining a "Float" or "Double" in Kotlin.
// Both of those are floating-point numbers following the now-common
// https://en.wikipedia.org/wiki/IEEE_754
// specification, 32-bit for "Float" and 64-bit for "Double".
// Confusingly, the single-precision floating-point number is called
// "Float". We may sometimes call it "single" instead.
//
// org.apache.commons.math3.fraction.BigFraction is used for exact
// printout.
//
// TODO: This code could use some Unit testing code.
// TODO: Improve the printout of BigFraction.
//
// License: https://en.wikipedia.org/wiki/Unlicense
// ---
// ---
// A record to hold the "bit substrings" of a Float or a Double.
// ---
data class BitSubStrings(val signStr: String, val expStr: String, val fractStr: String) {
fun getSign(): Int {
return if (signStr == "0") 1 else -1
}
// This is the "biased" exponent, i.e., the exponent with an added offset. It is >= 0.
fun getBiasedExp(): Int {
// The unsigned exponent fits into a 32-bit integer, so toInt() is okay.
return expStr.toInt(2)
}
fun getFraction(addOne: Boolean): BigFraction {
var tmpFractValue = BigFraction.ZERO
for (pos in fractStr.length - 1 downTo 0) {
if (fractStr[pos] == '1') {
tmpFractValue = tmpFractValue.add(BigFraction.ONE)
}
tmpFractValue = tmpFractValue.multiply(BigFraction.ONE_HALF)
}
if (addOne) {
tmpFractValue = tmpFractValue.add(BigFraction.ONE)
}
return tmpFractValue.reduce()
}
fun isFractionMsbSet(): Boolean {
// The MSB is at the string's leftmost position, i.e., at index 0
return fractStr[0] == '1'
}
fun getFractionAsLong(): Long {
return fractStr.toLong(2)
}
fun getFractionAsIntegerWithMsbUnset(): BigFraction {
return BigFraction(fractStr.substring(1).toLong(), 1)
}
companion object {
// https://en.wikipedia.org/wiki/Double-precision_floating-point_format
fun fromDouble(bitString: String): BitSubStrings {
val signStr = bitString.substring(0, 1) // 1 bit
val expStr = bitString.substring(1, 12) // 11 bits
val fractStr = bitString.substring(12, 64) // 52 bits
return BitSubStrings(signStr, expStr, fractStr)
}
// https://en.wikipedia.org/wiki/Single-precision_floating-point_format
fun fromSingle(bitString: String): BitSubStrings {
val signStr = bitString.substring(0, 1) // 1 bit
val expStr = bitString.substring(1, 9) // 8 bits
val fractStr = bitString.substring(9, 32) // 23 bits
return BitSubStrings(signStr, expStr, fractStr)
}
}
}
// ---
// A record to hold either a "Float" (Single) or a "Double" floating-point number
// ---
sealed class SingleOrDouble {
data class S(val value: Float) : SingleOrDouble()
data class D(val value: Double) : SingleOrDouble()
}
// ---
// The result of destructuring a Float or a Double.
// ---
class DestructuredIEEE754(val original: SingleOrDouble, val type: Type, val bitSubStrings: BitSubStrings, val sign: Int, val exponent: Int, val fraction: BigFraction) {
enum class Type {
QuietNaN, SignalingNaN, Infinity, Subnormal, Normal, SignedZero
}
fun getSignChar(): Char = if (sign == 1) '+' else '-'
fun asBigDecimal(scale: Int): BigDecimal {
val res = fraction.multiply(BigFraction.TWO.pow(exponent)).multiply(sign)
// I think the ordinal of the rounding mode is what is asked for here
return res.bigDecimalValue(scale, RoundingMode.HALF_EVEN.ordinal)
}
private fun appendNonZeroFraction(builder: StringBuilder) {
if (!BigFraction.ZERO.equals(fraction)) {
builder.append(", payload = $fraction")
}
}
private fun appendRepresentation(builder: StringBuilder, desc: String, indent: String) {
val signChar = if (sign < 0) "-" else ""
// "whole" will be 1 for normals and 0 for subnormals
val whole = fraction.toInt()
assert(whole == 0 || whole == 1)
val newFraction = fraction.subtract(whole.toBigInteger()).reduce()
if (whole > 0) {
builder.append("\n${indent}${desc}: ${signChar}(${whole} + ${newFraction}) x 2^${exponent}")
} else {
builder.append("\n${indent}${desc}: ${signChar}(${fraction}) x 2^${exponent}")
}
}
fun stringify(scale: Int = 40): String {
val builder = StringBuilder()
val indent = " "
val key = when (original) {
is SingleOrDouble.S -> "32-bit float"
is SingleOrDouble.D -> "64-bit float"
}
builder.append("${key}: ${bitSubStrings.signStr}|${bitSubStrings.expStr}|${bitSubStrings.fractStr}")
when (type) {
Type.QuietNaN -> {
builder.append("\n${indent}Quiet NaN")
appendNonZeroFraction(builder)
}
Type.SignalingNaN -> {
builder.append("\n${indent}Signaling NaN")
appendNonZeroFraction(builder)
}
Type.Infinity -> {
builder.append("\n${indent}${getSignChar()}Infinity")
}
Type.SignedZero -> {
builder.append("\n${indent}Signed zero: ${getSignChar()}0.0")
}
Type.Subnormal -> {
appendRepresentation(builder, "Subnormal number", indent)
}
Type.Normal -> {
appendRepresentation(builder, "Normal number", indent)
}
}
if (type in setOf(Type.Normal, Type.Subnormal)) {
builder.append("\n${indent}By default, printed as: ")
when (original) {
is SingleOrDouble.S -> builder.append("'${original.value}'")
is SingleOrDouble.D -> builder.append("'${original.value}'")
}
builder.append("\n${indent}As BigDecimal with scale ${scale}: '${asBigDecimal(scale)}'")
}
return builder.toString()
}
companion object {
private fun allZerosExponent(original: SingleOrDouble, bitSubStrings: BitSubStrings): DestructuredIEEE754 {
// "fraction" always fits a 64-bit "Long", whether this is a Single or Double
val fractionAsLong = bitSubStrings.getFractionAsLong()
val sign = bitSubStrings.getSign()
if (fractionAsLong == 0L) {
// "Plus or minus zero"
return DestructuredIEEE754(original, Type.SignedZero, bitSubStrings, sign, 0, BigFraction.ZERO)
} else {
// "Subnormal format" with a fixed exponent and a fraction that has a 0 before the comma rather than a 1.
val expValue = when (original) {
is SingleOrDouble.S -> -126
is SingleOrDouble.D -> -1022
}
// So do not add 1 as this is the subnormal format.
val fraction = bitSubStrings.getFraction(false)
return DestructuredIEEE754(original, Type.Subnormal, bitSubStrings, sign, expValue, fraction)
}
}
private fun allOnesExponent(original: SingleOrDouble, bitSubStrings: BitSubStrings): DestructuredIEEE754 {
// "fraction" always fits a 64-bit "Long", whether this is a Single or Double
val fractionAsLong = bitSubStrings.getFractionAsLong()
val sign = bitSubStrings.getSign()
if (fractionAsLong == 0L) {
// "Plus or minus infinity"
return DestructuredIEEE754(original, Type.Infinity, bitSubStrings, sign, 0, BigFraction.ZERO)
} else {
// "NaN". The fraction may contain any bit-pattern.
// Subtypes are:
// "Quiet NaN" - does not cause exceptions when used in most computations.
// "Signaling NaN", intended to signal exceptions when used in operations.
val type = if (bitSubStrings.isFractionMsbSet()) Type.QuietNaN else Type.SignalingNaN
// The "fraction" may contain additional data, but the IEEE 754 standard doesn't
// define how to interpret it. Just put it into an integer fraction.
return DestructuredIEEE754(original, type, bitSubStrings, sign, 0, bitSubStrings.getFractionAsIntegerWithMsbUnset())
}
}
private fun anyOtherExponent(original: SingleOrDouble, bitSubStrings: BitSubStrings): DestructuredIEEE754 {
val bias = when (original) {
is SingleOrDouble.S -> -127
is SingleOrDouble.D -> -1023
}
val expValueDebiased = bitSubStrings.getBiasedExp() + bias
val sign = bitSubStrings.getSign()
val fraction = bitSubStrings.getFraction(true)
return DestructuredIEEE754(original, Type.Normal, bitSubStrings, sign, expValueDebiased, fraction)
}
fun create(value: Float): DestructuredIEEE754 {
val numBits = 32
val rawBits: Int = java.lang.Float.floatToRawIntBits(value)
val bitString: String = rawBits.toUInt().toString(2).padStart(numBits, '0')
val bitSubStrings = BitSubStrings.fromSingle(bitString)
return when (bitSubStrings.getBiasedExp()) {
0 -> allZerosExponent(SingleOrDouble.S(value), bitSubStrings)
0xFF -> allOnesExponent(SingleOrDouble.S(value), bitSubStrings)
else -> anyOtherExponent(SingleOrDouble.S(value), bitSubStrings)
}
}
fun create(value: Double): DestructuredIEEE754 {
val numBits = 64
val rawBits: Long = java.lang.Double.doubleToRawLongBits(value)
val bitString: String = rawBits.toULong().toString(2).padStart(numBits, '0')
val bitSubStrings = BitSubStrings.fromDouble(bitString)
return when (bitSubStrings.getBiasedExp()) {
0 -> allZerosExponent(SingleOrDouble.D(value), bitSubStrings)
0x7FF -> allOnesExponent(SingleOrDouble.D(value), bitSubStrings)
else -> anyOtherExponent(SingleOrDouble.D(value), bitSubStrings)
}
}
}
}
// ---
// Exercising the code a bit
// ---
fun divideFloatByTwoUntilZeroAchieved() {
var x = 1.0f
while (x > 0.0f) {
println(DestructuredIEEE754.create(x).stringify())
x = x / 2.0f
}
}
// ChatGPT delivers:
fun machineEpsilonDouble(): Double {
var epsilon = 1.0
while (1.0 + epsilon / 2.0 != 1.0) {
epsilon /= 2.0
}
return epsilon
}
// ChatGPT delivers:
fun machineEpsilonFloat(): Float {
var epsilon = 1.0f
while (1.0f + epsilon / 2.0f != 1.0f) {
epsilon /= 2.0f
}
return epsilon
}
fun printRemarkableFloats() {
val buf = StringBuilder()
//
buf.append("Float.NaN\n")
buf.append(DestructuredIEEE754.create(Float.NaN).stringify())
buf.append("\nFloat.MAX_VALUE\n")
buf.append(DestructuredIEEE754.create(Float.MAX_VALUE).stringify())
buf.append("\nFloat.MIN_VALUE\n")
buf.append(DestructuredIEEE754.create(Float.MIN_VALUE).stringify())
buf.append("\nFloat.NEGATIVE_INFINITY\n")
buf.append(DestructuredIEEE754.create(Float.NEGATIVE_INFINITY).stringify())
buf.append("\nFloat.POSITIVE_INFINITY\n")
buf.append(DestructuredIEEE754.create(Float.POSITIVE_INFINITY).stringify())
buf.append("\nFloat machine epsilon\n")
buf.append(DestructuredIEEE754.create(machineEpsilonFloat()).stringify())
//
buf.append("\nDouble.NaN\n")
buf.append(DestructuredIEEE754.create(Double.NaN).stringify())
buf.append("\nDouble.MAX_VALUE\n")
buf.append(DestructuredIEEE754.create(Double.MAX_VALUE).stringify())
buf.append("\nDouble.MIN_VALUE\n")
buf.append(DestructuredIEEE754.create(Double.MIN_VALUE).stringify())
buf.append("\nDouble.NEGATIVE_INFINITY\n")
buf.append(DestructuredIEEE754.create(Double.NEGATIVE_INFINITY).stringify())
buf.append("\nDouble.POSITIVE_INFINITY\n")
buf.append(DestructuredIEEE754.create(Double.POSITIVE_INFINITY).stringify())
buf.append("\nDouble machine epsilon\n")
buf.append(DestructuredIEEE754.create(machineEpsilonDouble()).stringify())
println(buf.toString())
}
fun main() {
printRemarkableFloats()
divideFloatByTwoUntilZeroAchieved()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment