Floating Point Types
In the previous chapter, we have seen that despite their ease of use, arithmetic operations on integers are prone to programming errors due to overflow and truncation. We have also seen that integers cannot have values with fractional parts, as in 1.25.
Floating point types are designed to support fractional parts. The "point" in their name comes from the radix point, which separates the integer part from the fractional part, and "floating" refers to a detail in how these types are implemented: the decimal point floats left and right as appropriate. (This detail is not important when using these types.)
We must cover important details in this chapter as well. Before doing that, I would like to give a list of some of the interesting aspects of floating point types:
- Adding 0.001 a thousand times is not the same as adding 1.
- Using the logical operators
==
and!=
with floating point types is erroneous in most cases. - The initial value of floating point types is
.nan
, not 0..nan
may not be used in expressions in any meaningful way. When used in comparison operations,.nan
is not less than nor greater than any value. - The two overflow values are
.infinity
and negative.infinity
.
Although floating point types are more useful in some cases, they have peculiarities that every programmer must know. Compared to integers, they are very good at avoiding truncation because their main purpose is to support fractional values. Like any other type, being based on a certain number of bits, they too are prone to overflow, but compared to integers, the range of values that they can support is vast. Additionally, instead of being silent in the case of overflow, they get the special values of positive and negative infinity.
As a reminder, the floating point types are the following:
Type | Number of Bits | Initial Value |
---|---|---|
float | 32 | float.nan |
double | 64 | double.nan |
real | at least 64, maybe more (e.g. 80, depending on hardware support) |
real.nan |
Floating point type properties
Floating point types have more properties than other types:
.stringof
is the name of the type..sizeof
is the length of the type in terms of bytes. (In order to determine the bit count, this value must be multiplied by 8, the number of bits in a byte.)-
.max
is the short for "maximum" and is the maximum value that the type can have. There is no separate.min
property for floating types; the negative of.max
is the minimum value that the type can have. For example, the minimum value ofdouble
is-double.max
. -
.min_normal
is the smallest positive value that this type can represent with its normal precision. (Precision is explained below.) The type can represent smaller values than.min_normal
but those values cannot be as precise as other values of the type and are generally slower to compute. The condition of a floating point value being between negative.min_normal
and positive.min_normal
(excluding 0) is called underflow. -
.dig
is short for "digits" and specifies the number of digits that signify the precision of the type. -
.infinity
is the special value used to denote overflow.
Other properties of floating point types are used less commonly. You can see all of them at Properties for Floating Point Types at dlang.org.
The properties of floating point types and their relations can be shown on a number line like the following:
+ +─────────+─────────+ ... + ... +─────────+─────────+ + │ -max -1 │ 0 │ 1 max │ │ │ │ │ -infinity -min_normal min_normal infinity
Other than the two special infinity values, the line above is to scale: the number of values that can be represented between min_normal
and 1 is equal to the number of values that can be represented between 1 and max
. This means that the precision of the fractional parts of the values that are between min_normal
and 1 is very high. (The same is true for the negative side as well.)
.nan
We have already seen that this is the default value of floating point variables. .nan
may appear as a result of meaningless floating point expressions as well. For example, the floating point expressions in the following program all produce double.nan
:
import std.stdio; void main() { double zero = 0; double infinity = double.infinity; writeln("any expression with nan: ", double.nan + 1); writeln("zero / zero : ", zero / zero); writeln("zero * infinity : ", zero * infinity); writeln("infinity / infinity : ", infinity / infinity); writeln("infinity - infinity : ", infinity - infinity); }
.nan
is not useful just because it indicates an uninitialized value. It is also useful because it is propagated through computations, making it easier and earlier to detect errors.
Specifying floating point values
Floating point values can be built from integer values without a decimal point, like 123, or created directly with a decimal point, like 123.0.
Floating point values can also be specified with the special floating point syntax, as in 1.23e+4
. The e+
part in that syntax can be read as "times 10 to the power of". According to that reading, the previous value is "1.23 times 10 to the power of 4", which is the same as "1.23 times 104", which in turn is the same as 1.23x10000, being equal to 12300.
If the value after e
is negative, as in 5.67e-3
, then it is read as "divided by 10 to the power of". Accordingly, this example is "5.67 divided by 103", which in turn is the same as 5.67/1000, being equal to 0.00567.
The floating point format is apparent in the output of the following program that prints the properties of the three floating point types:
import std.stdio; void main() { writeln("Type : ", float.stringof); writeln("Precision : ", float.dig); writeln("Minimum normalized value: ", float.min_normal); writeln("Minimum value : ", -float.max); writeln("Maximum value : ", float.max); writeln(); writeln("Type : ", double.stringof); writeln("Precision : ", double.dig); writeln("Minimum normalized value: ", double.min_normal); writeln("Minimum value : ", -double.max); writeln("Maximum value : ", double.max); writeln(); writeln("Type : ", real.stringof); writeln("Precision : ", real.dig); writeln("Minimum normalized value: ", real.min_normal); writeln("Minimum value : ", -real.max); writeln("Maximum value : ", real.max); }
The output of the program is the following in my environment. Since real
depends on the hardware, you may get a different output:
Type : float Precision : 6 Minimum normalized value: 1.17549e-38 Minimum value : -3.40282e+38 Maximum value : 3.40282e+38 Type : double Precision : 15 Minimum normalized value: 2.22507e-308 Minimum value : -1.79769e+308 Maximum value : 1.79769e+308 Type : real Precision : 18 Minimum normalized value: 3.3621e-4932 Minimum value : -1.18973e+4932 Maximum value : 1.18973e+4932
Note: Although double
and real
have more precision than float
, writeln
prints all floating point values with 6 digits of precision. (Precision is explained below.)
Observations
As you will remember from the previous chapter, the maximum value of ulong
has 20 digits: 18,446,744,073,709,551,616. That value looks small when compared to even the smallest floating point type: float
can have values up to the 1038 range, e.g. 340,282,000,000,000,000,000,000,000,000,000,000,000. The maximum value of real
is in the range 104932, a value with more than 4900 digits!
As another observation, let's look at the minimum value that double
can represent with 15-digit precision:
0.000...(there are 300 more zeroes here)...0000222507385850720
Overflow is not ignored
Despite being able to take very large values, floating point types are prone to overflow as well. The floating point types are safer than integer types in this regard because overflow is not ignored. The values that overflow on the positive side become .infinity
, and the values that overflow on the negative side become ‑.infinity
. To see this, let's increase the value of .max
by 10%. Since the value is already at the maximum, increasing by 10% would overflow:
import std.stdio; void main() { real value = real.max; writeln("Before : ", value); // Multiplying by 1.1 is the same as adding 10% value *= 1.1; writeln("Added 10% : ", value); // Let's try to reduce its value by dividing in half value /= 2; writeln("Divided in half: ", value); }
Once the value overflows and becomes real.infinity
, it remains that way even after being divided in half:
Before : 1.18973e+4932 Added 10% : inf Divided in half: inf
Precision
Precision is a concept that we come across in daily life but do not talk about much. Precision is the number of digits that is used when specifying a value. For example, when we say that the third of 100 is 33, the precision is 2 because 33 has 2 digits. When the value is specified more precisely as 33.33, then the precision is 4 digits.
The number of bits that each floating type has, not only affects its maximum value, but also its precision. The greater the number of bits, the more precise the values are.
There is no truncation in division
As we have seen in the previous chapter, integer division cannot preserve the fractional part of a result:
int first = 3; int second = 2; writeln(first / second);
Output:
1
Floating point types don't have this truncation problem; they are specifically designed for preserving the fractional parts:
double first = 3; double second = 2; writeln(first / second);
Output:
1.5
The accuracy of the fractional part depends on the precision of the type: real
has the highest precision and float
has the lowest precision.
Which type to use
Unless there is a specific reason not to, you can choose double
for floating point values. float
has low precision but due to being smaller than the other types it may be useful when memory is limited. On the other hand, since the precision of real
is higher than double
on some hardware, it would be preferable for high precision calculations.
Cannot represent all values
We cannot represent certain values in our daily lives. In the decimal system that we use daily, the digits before the decimal point represent ones, tens, hundreds, etc. and the digits after the decimal point represent tenths, hundredths, thousandths, etc.
If a value is created from a combination of these values, it can be represented exactly. For example, because the value 0.23 consists of 2 tenths and 3 hundredths it is represented exactly. On the other hand, the value 1/3 cannot be exactly represented in the decimal system because the number of digits is always insufficient, no matter how many are specified: 0.33333...
The situation is very similar with the floating point types. Because these types are based on a certain number of bits, they cannot represent every value exactly.
The difference with the binary system that the computers use is that the digits before the decimal point are ones, twos, fours, etc. and the digits after the decimal point are halves, quarters, eighths, etc. Only the values that are exact combinations of those digits can be represented exactly.
A value that cannot be represented exactly in the binary system used by computers is 0.1, as in 10 cents. Although this value can be represented exactly in the decimal system, its binary representation never ends and continuously repeats four digits: 0.0001100110011... (Note that the value is written in binary system, not decimal.) It is always inaccurate at some level depending on the precision of the floating point type that is used.
The following program demonstrates this problem. The value of a variable is being incremented by 0.001 a thousand times in a loop. Surprisingly, the result is not 1:
import std.stdio; void main() { float result = 0; // Adding 0.001 for a thousand times: int counter = 1; while (counter <= 1000) { result += 0.001; ++counter; } if (result == 1) { writeln("As expected: 1"); } else { writeln("DIFFERENT: ", result); } }
Because 0.001 cannot be represented exactly, that inaccuracy affects the result at every iteration:
DIFFERENT: 0.999991
Note: The variable counter
above is a loop counter. Defining a variable explicitly for that purpose is not recommended. Instead, a common approach is to use a foreach
loop, which we will see in a later chapter.
Unorderedness
The same comparison operators that we have covered with integers are used with floating point types as well. However, since the special value .nan
represents invalid floating point values, comparing .nan
to other values is not meaningful. For example, it does not make sense to ask whether .nan
or 1
is greater.
For that reason, floating point values introduce another comparison concept: unorderedness. Being unordered means that at least one of the values is .nan
.
The following table lists all the floating point comparison operators. All of them are binary operators (meaning that they take two operands) and used as in left == right
. The columns that contain false
and true
are the results of the comparison operations.
The last column indicates whether the operation is meaningful if one of the operands is .nan
. For example, even though the result of the expression 1.2 < real.nan
is false
, that result is meaningless because one of the operands is real.nan
. The result of the reverse comparison real.nan < 1.2
would produce false
as well. The abreviation lhs stands for left-hand side, indicating the expression on the left-hand side of each operator.
Operator |
Meaning |
If lhs is greater |
If lhs is less |
If both are equal |
If at least one is .nan |
Meaningful with .nan |
---|---|---|---|---|---|---|
== | is equal to | false | false | true | false | yes |
!= | is not equal to | true | true | false | true | yes |
> | is greater than | true | false | false | false | no |
>= | is greater than or equal to | true | false | true | false | no |
< | is less than | false | true | false | false | no |
<= | is less than or equal to | false | true | true | false | no |
Although meaningful to use with .nan
, the ==
operator always produces false
when used with a .nan
value. This is the case even when both values are .nan
:
import std.stdio; void main() { if (double.nan == double.nan) { writeln("equal"); } else { writeln("not equal"); } }
Although one would expect double.nan
to be equal to itself, the result of the comparison is false
:
not equal
isNaN()
for .nan
equality comparison
As we have seen above, it is not possible to use the ==
operator to determine whether the value of a floating point variable is .nan
:
if (variable == double.nan) { // ← WRONG // ... }
isNaN()
function from the std.math
module is for determining whether a value is .nan
:
import std.math; // ... if (isNaN(variable)) { // ← correct // ... }
Similarly, to determine whether a value is not .nan
, one must use !isNaN()
because otherwise the !=
operator would always produce true
.
Exercises
- Instead of
float
, usedouble
(orreal
) in the program above which added 0.001 a thousand times:double result = 0;
This exercise demonstrates how misleading floating point equality comparisons can be.
- Modify the calculator from the previous chapter to support floating point types. The new calculator should work more accurately with that change. When trying the calculator, you can enter floating point values in various formats, as in 1000, 1.23, and 1.23e4.
- Write a program that reads 5 floating point values from the input. Make the program first print twice of each value and then one fifth of each value.
This exercise is a preparation for the array concept of the next chapter. If you write this program with what you have seen so far, you will understand arrays more easily and will better appreciate them.