Last active
May 7, 2025 10:01
-
-
Save dtonhofer/3a8f89be72627efdf0c49e1a00dc1a81 to your computer and use it in GitHub Desktop.
Explaining a floating-point number in Kotlin
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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