Understanding Float>>asFraction and its variants

A Float is a data structure that codifies a number, which regardless of how we see or interpret it, mathematically speaking, cannot be anything but a rational quantity (i.e., an integer or fraction). This codification is appropriate for arithmetic operations, which the CPU performs at high speed. The price we pay is that the codification doesn't exhibit the numerator and denominator it represents. The method Float >> #asTrueFraction answers with these numbers, in other words, it decodes the bits enclosed in the instance of Float, and answers with the actual fraction it codifies.

What you have to understand is that when you write 0.001 you are telling the Compiler to create a Float that approximates the fraction 1/1000. Had the CPU used decimal rather than binary representations, this would have been similar to asking it to codify 1/3 using a finite number of decimal places, which leads irrevocably to 0.33333..3, for some maximum number of digits 3. In the case where the denominator is not a power of 2, the CPU has to solve a similar problem and ends up approximating the provided quantity so that it fits in the number of bits allocated to Floats. The method #asTrueFraction reverses that process and reveals the exact value of the approximation, which Float hides behind the way it prints its instances.

In Pharo, Float >> #asFraction is the same as Float >> #asTrueFraction, so no difference there.

The comment in Float >> #asMinimalDecimalFraction is very clear, it will give what you usually expect, this is, the shortest decimal Fraction that will equal self when converted back asFloat.

Finally, Float >> #asApproximateFraction uses some algorithm to produce an acceptable approximation of the receiver.


For obvious reasons, GNU's asFraction and Pharo's asMinimalDecimalFraction and asApproximateFraction make the most sense to me since they are producing, mathematically, more "exact" results.

On the contrary, the operation they perform is to find an approximation to the input. But the input they receive is not, in fact, the number 0.001, even though that seems to be what you wrote—and there is no way for any of these methods to know what you originally wrote.

So some of the methods return exactly the number they are given (in different representation), while others return approximations that fortuitously (if confusingly!) coincide with the text you originally wrote.


It might help to rephrase the code a little bit so you see where the approximations are realy happening. Let's focus on GNU Smalltalk first.

x := '0.001' asNumber.
y := x asExactFraction.

In this fragment, '0.001' asNumber is the only operation that does any approximation: instead of returning a Float instance representing the number 0.001 (there is, in fact, no such float!), it returns a Float representing the nearest (IEEE 754 binary64) floating-point number, which can be variously written as 1152921504606847/1152921504606846976, or as 0.001000000000000000020816681711721685132943093776702880859375, or as 0x1.0624dd2f1a9fcp−10 in the most convenient form for writing binary floating-point numbers exactly.

You get the same result by just writing 0.001: Smalltalk will automatically round to the nearest floating-point number. I write it explicitly as '0.001' asNumber to make it clear that this is the operation that returns an approximation to the number 0.001 you wrote.

Then y := x asExactFraction sets to a Fraction instance representing exactly the same number; likewise with y := x asTrueFraction in Pharo. The number is still 1152921504606847/1152921504606846976; asExactFraction will never return a number with anything but a power of two in the denominator (at least, not with a class for storing binary floating-point numbers).


If, instead, you evaluate (in GNU Smalltalk)

z := x asFraction.

then what you get in is a Fraction instance representing the simplest rational number that rounds to —very roughly, the simplest rational number in the interval [ − ulp()/2, + ulp()/2], where ulp() ≈ 2−52 is the magnitude of the least significant digit of the floating-point representation of (with caveats around the edges of the intervals and when is equal to a power of two). Here the “simplest” rational number within an interval is the rational number with the smallest denominator. This approximation to is obtained by expanding the continued fraction representation of until the first convergent that rounds to .1

This is probably (though I haven't looked closely enough to verify) the same as what you get with Pharo's definition of asApproximateFraction. In contrast, Pharo's asMinimalDecimalFraction doesn't return the simplest rational; instead, it considers only rational numbers with powers of 10 = 2⋅5 in the denominator, and returns the one with the smallest numerator that will be rounded to .


In summary:

  • x := '0.001' asNumber sets to a Float instance representing the (IEEE 754 binary64) floating-point number nearest to 0.001, which is 1152921504606847/1152921504606846976 = 0.001000000000000000020816681711721685132943093776702880859375 = 0x1.0624dd2f1a9fcp−10; you get the same effect by writing x := 0.001 but that makes it a little more obscure that approximation is happening
  • y := x asExactFraction in GNU Smalltalk, or y := x asTrueFraction or y := asFraction in Pharo, sets to a Fraction instance representing exactly the same number as
  • z := x asFraction in GNU Smalltalk or z := x asApproximateFraction in Pharo sets to a Fraction instance representing the simplest rational number that would be rounded to
  • w := x asMinimalDecimalFraction in Pharo sets to a Fraction instance representing the number with the shortest decimal expansion that would be rounded to ; you can use this if you want to write floating-point numbers in decimal notation and make sure you get the same number back without writing more digits than you have to

(As you can see, GNU Smalltalk and Pharo disagree on whether asFraction should return an approximation or not: in GNU Smalltalk it does, while in Pharo it does not. Which is unfortunate, because it's the only name that the two share!)


For fun, try the following examples in Pharo:

3.141592653589793 asApproximateFractionAtOrder: 1
3.141592653589793 asApproximateFractionAtOrder: 2
3.141592653589793 asApproximateFractionAtOrder: 3
3.141592653589793 asApproximateFractionAtOrder: 4
3.141592653589793 asApproximateFractionAtOrder: 5
3.141592653589793 asApproximateFraction
3.141592653589793 asMinimalDecimalFraction
3.141592653589793 asTrueFraction

1.618033988749895 asApproximateFractionAtOrder: 1
1.618033988749895 asApproximateFractionAtOrder: 2
1.618033988749895 asApproximateFractionAtOrder: 3
1.618033988749895 asApproximateFractionAtOrder: 4
1.618033988749895 asApproximateFractionAtOrder: 5
1.618033988749895 asApproximateFraction
1.618033988749895 asMinimalDecimalFraction
1.618033988749895 asTrueFraction

See if you notice anything about the outputs—maybe you'll recognize some of the fractions; see how far they are in absolute and relative error from the true fraction; see how large the denominators are.


1 This is what GNU Smalltalk's definition of asFraction currently does. Technically the documentation makes no promises about the nature of the approximation, but this is the most natural approach for Fraction, since it provides the best rational approximation independent of any choice of radix. See A. Ya. Khinchin, Continued Fractions, University of Chicago Press, 1964, §6 “Convergents as best approximations” for further discussion of continued fraction convergents as best rational approximations. Continued fractions are a beautiful corner of mathematics but sadly neglected in modern education!


While the other answers delve into why the fraction 1/1000 is not equal to the 64-bit binary float 0.001, here is slightly different answer:

0.001 printStringBase: 2
"=>" '1.00000110001001001101110100101111000110101001111111e-10'

This is what 0.001 really looks like under the hood, as a binary float of limited precision (64 bits only). And that is why it is not equal to 1/1000:

1/1000 = 0.001
"=>" false

If you want exact decimals with unlimited precision, you need to tell the system. A decimal number like 0.001s is indeed exactly equal to the fraction 1/1000:

0.001s asFraction
"=>" (1/1000)

1/1000 = 0.001s
"=>" true

The reason we are not using decimals as often is that they are less efficient - 64-bit binary float math is implemented in hardware, exact math is implemented in software, making it orders of magnitudes slower.