What Rust in Action §2.3.3 says about comparing numbers, run¶
Level: 201 · a companion to a book chapter
One line: Section 2.3.3 of Rust in Action (Tim McNamara, Manning 2021) gets most rules right. But its tolerance example never tests the tolerance, its "cast to the larger type" advice fails once signs differ, its CPU-exception footnote doesn't describe floats, and two details have aged. Fifteen claims, checked against rustc 1.98.0: twelve by the program below, three from the compiler's own output.
The section covers comparison operators, comparing values of different number types, and a sidebar on floating-point hazards. This page goes through it in the book's order. Where a topic has its own lesson here, the note links there instead of repeating it: Comparing two numbers of different types for the first half, What a float actually stores for the second.
The verdicts¶
| # | The book says | Verdict |
|---|---|---|
| 1 | Values of two different types can't be compared | True for numbers, because std implements no mixed number pairs. Not a language rule: String == &str compiles |
| 2 | Casting the smaller type to the larger one is safest | Incomplete. The larger type must also cover the smaller one's sign: -1i32 as u64 < 1u64 is false |
| 3 | 300_i32 as i8 returns 44 |
True |
| 4 | b.try_into() returns a Result, and unwrap() crashes if the conversion failed |
True in general, vacuous here. u16 → i32 has error type Infallible; write i32::from(b) |
| 5 | f32 and f64 implement only PartialEq; other number types also Eq |
True |
| 6 | 0.1 + 0.2 == 0.3 fails for f64 and passes for f32 |
True, by luck: 0.1 + 0.6 == 0.7 fails for f32 and passes for f64 |
| 7 | Compare within f32::EPSILON (shown on 0.1 + 0.1 against 0.2) |
The example tests nothing: the difference is exactly 0. And EPSILON is the wrong size for large values |
| 8 | The square root of a negative number, -42.0.sqrt(), is NaN |
Only with parentheses. As written it doesn't compile, and the compiler's suggested fix gives -6.48 |
| 9 | Almost all operations involving NaN return NaN | True, and the exceptions are common ones: max, min, powi(0) |
| 10 | NaN values are never equal | True, inside a Vec too |
| 11 | Use is_nan() and is_finite() to crash near the cause |
True; is_finite() alone rejects both NaN and infinity |
| 12 | Illegal or undefined operations trigger a CPU exception | Not for floats. The operation sets a status flag and the program carries on |
| A | Listing 2.5 needs use std::convert::TryInto; |
Only in edition 2018. TryInto is in the prelude from edition 2021 |
| B | The float program crashes on line 14 with the report the book shows | Aged. The failing assert is on line 16 of the listing, and today's report puts the location first |
| C | The Rust compiler delegates the float comparison to the CPU | Half true. In a debug build the compiler has already done the arithmetic; in a release build it also did the comparison |
Twelve claims, run¶
Verified output of comparing_numbers_claims_checked.rs — regenerated by tools/run_examples.py, never hand-typed.
1. THE BOOK: two values of different types cannot be compared
for the number types, yes: std implements i32 < i32 and nothing else.
but == and < are traits with a type parameter for the right side,
and std does implement some cross-type pairs:
String == &str : true
Vec<i32> == [i32; 3] : true
2. THE BOOK: casting the smaller type to the larger one is safest
-1i32 as u64 < 1u64 : false (u64 is larger, and -1 became 18446744073709551615)
i64::from(-1i32) < 1i64 : true
larger is not enough: the target must hold every value, sign included.
3. THE BOOK: 300_i32 as i8 returns 44
300_i32 as i8 : 44
300 in hex : 0x12c; as keeps the low byte, 0x2c
i8::try_from(300) : Err(TryFromIntError(PosOverflow))
4. THE BOOK: try_into() on the u16 returns a Result, and unwrap() crashes if it failed
u16 -> i32 try_into type : core::result::Result<i32, core::convert::Infallible>
the error type is Infallible: this conversion cannot fail. i32::from(b) says so.
a conversion that can fail, unwrapped:
panic : called `Result::unwrap()` on an `Err` value: TryFromIntError(PosOverflow)
5. THE BOOK: f32 and f64 implement only PartialEq; the other number types also Eq
1_i32.cmp(&2) : Less (Ord, a total order)
1.0.partial_cmp(&NAN) : None (PartialOrd: some pairs have no answer)
6. THE BOOK: 0.1 + 0.2 == 0.3 fails for f64 and passes for f32
f32 0.1 + 0.2 = 3e99999a 0.3 = 3e99999a equal: true
f64 0.1 + 0.2 = 3fd3333333333334 0.3 = 3fd3333333333333 equal: false
true, and f32 was lucky rather than exact. Try 0.1 + 0.6 instead:
f32 0.1 + 0.6 == 0.7 : false
f64 0.1 + 0.6 == 0.7 : true
7. THE BOOK: compare within f32::EPSILON, shown on 0.1 + 0.1 against 0.2
|0.2 - (0.1 + 0.1)| in f32 : 0e0
0.1 + 0.1 == 0.2 in f32 : true
doubling is exact in binary, so the tolerance was never tested.
Where a tolerance is needed, EPSILON is the wrong size once the numbers grow:
|2000.3 - (1000.1 + 1000.2)| : 2.2737367544323206e-13
<= f64::EPSILON : false (2.220446049250313e-16)
<= 1e-12 * 2000.3 (relative) : true
8. THE BOOK: the square root of a negative number, written -42.0.sqrt(), is NaN
-42.0_f64.sqrt() : -6.48074069840786
(-42.0_f64).sqrt() : NaN
a method call binds tighter than unary minus: the first is -(42.0.sqrt()).
9. THE BOOK: almost every operation involving NAN returns NAN
NAN + 1.0 : NaN
NAN.max(1.0) : 1
NAN.min(1.0) : 1
NAN.powi(0) : 1
1.0_f64.powf(NAN) : 1
NAN.hypot(INFINITY) : inf
NAN as i32 : 0
NAN < 1.0 : false
'almost' is doing work: max, min and three more hand back a real number.
10. THE BOOK: NAN values are never equal
x == x : false
vec![x] == vec![x] : false
x.total_cmp(&x) : Equal
assert_eq!(x, x) panics : true
11. THE BOOK: use is_nan() and is_finite() to crash near the cause
1.0 / 0.0 : inf
inf.is_nan() : false
inf.is_finite() : false
NAN.is_finite() : false
is_finite() rejects both; is_nan() alone lets infinity through.
12. THE BOOK: illegal or undefined operations trigger a CPU exception
not for floats: the lines above divided by zero and took a square root
of a negative number, and this program is still running.
1_i32 / 0 : panic, "attempt to divide by zero"
1_i32.checked_div(0) : None
Rust checks an integer divisor for zero in debug and release builds
alike, so this panic is Rust's own check, not a trap from the CPU.
Reading them in order:
- Comparing different types is an impl question.
==and<arePartialEq<Rhs>andPartialOrd<Rhs>, generic in the right-hand side, and std implements every number type against itself only. It also implementsStringagainst&str,Vec<T>against arrays, and more. So rustc's error fori32 < u16isE0308 mismatched types, and it suggestsb.into(). The lesson has the transcript. - "Larger" isn't the test; "holds every value" is. A
u16in ani32is safe because everyu16value fits, not because 32 > 16. Ani32in au64is larger and loses every negative.Fromexists exactly where the conversion is safe, soi64::from(a)can't make this mistake. See pick the type that holds both. - 44 is the low byte of 300 (
0x12c→0x2c). Casting withashas the other three silent losses, andi8::try_from(300)reports this one. - The book picked a conversion that can't fail to demonstrate one that can.
TryFrom<u16> for i32comes from std's blanket impl overFrom, withtype Error = Infallible, so.unwrap()there can never panic. The unwrap that can panic is shown withi8::try_from(300). The prose next to the listing also calls this methodunsafe()once, which is a typo forunwrap(). EqandOrdare withheld from floats because of NaN. The consequences (nosort(), noHashMapkey) are on What a float actually stores.- The bits match the book exactly:
3e99999atwice forf32,3fd3333333333334against…333forf64. Butf32isn't more exact. Both widths round, and for 0.1 + 0.6 the luck runs the other way. Reading the result as "f32 is safe for this" is the mistake to avoid. 0.1 + 0.1is2 × 0.1, and doubling a binary float is exact. The sum is the float nearest 0.2, so the difference is0e0and any tolerance, even zero, passes. When the tolerance does matter,EPSILONis the gap next to 1.0, not a general tolerance: near 2000 the error in1000.1 + 1000.2is about 1000 timesEPSILON, and only a relative tolerance accepts it.- Unary minus binds looser than a method call, so
-42.0_f64.sqrt()is-(42.0_f64.sqrt()). The book's code listing has the parentheses and is right; its prose drops them. Refusal 2 below shows the prose form doesn't compile, and that rustc's suggested fix quietly computes -6.48. - The NaN exceptions are the ones you're most likely to call.
f64::maxandf64::minreturn the other argument, so a running maximum over data with a NaN in it just skips the NaN.powi(0),1.0.powf(NAN)andhypotwith an infinity follow IEEE 754's rules for those functions.NAN as i32is 0, which saturation defines. vec![x] == vec![x]isfalse, becauseVec's==compares element by element withf64's==. Python answersTruefor a list holding the same NaN object, since its containers check identity first.total_cmpis the comparison that calls a NaN equal to itself.is_finite()is the stronger check.1.0 / 0.0is infinity, not NaN, so anis_nan()guard lets it through.- A float operation with no valid result returns NaN or infinity and sets a flag. It doesn't stop the program: this one took a negative square root and divided by zero, then kept printing. IEEE 754 calls these exceptions, but by default they are status flags, not CPU traps. Rust has no stable way to read the flags; C does, through
<fenv.h>, and A float exception is a flag ↗ in the C library reads them. The operation that can trap is integer division by zero, and Rust checks for it before dividing:attempt to divide by zero, in debug and release builds alike.
What the compiler says¶
Refusal 1: use std::convert::TryInto;. The book was written for edition 2018, where TryInto had to be imported. Under 2018, rustc 1.98.0 refuses the call without the import and says why:
error[E0599]: no method named `try_into` found for type `u16` in the current scope
--> try_into_2018.rs:3:26
|
3 | let widened: i32 = b.try_into().unwrap();
| ^^^^^^^^
|
= help: items from traits can only be used if the trait is in scope
= note: 'std::convert::TryInto' is included in the prelude starting in Edition 2021
Under --edition 2021 or 2024, the same file compiles without the use and prints 100. The import is harmless in newer editions, just unnecessary. The book's general point stands: a trait method needs its trait in scope. Inherent methods such as f64::sqrt or i32::abs never do.
Refusal 2: -42.0.sqrt(). An unsuffixed float literal has no type yet, so there is no sqrt to call:
error[E0689]: can't call method `sqrt` on ambiguous numeric type `{float}`
--> negative_root.rs:2:19
|
2 | let x = -42.0.sqrt();
| ^^^^
|
help: you must specify a concrete type for this numeric value, like `f32`
|
2 | let x = -42.0_f32.sqrt();
| ++++
Apply the help and it compiles, printing -6.4807405: the square root of positive 42, negated. The fix you want is (-42.0_f32).sqrt(). Claim 8 in the output above runs both forms.
Real output 3: the report. The book says the float program crashes on line 14 and shows a one-line report naming ch2-add-floats.rs.rs:14:5. In the listing as the book prints it, the f64 assert is on line 16, and a file with that layout panics there. rustc 1.98.0 also formats the report differently: location first, message on its own line, and a thread id:
thread 'main' (28871827) panicked at ch2-add-floats.rs:16:5:
assertion failed: xyz.0 + xyz.1 == xyz.2
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
The thread id changes on every run.
Does the CPU do the comparison?¶
The sidebar says the compiler delegates float comparisons to the CPU. Take a tolerance check on constants:
In a debug build the CPU does compare, but the arithmetic before it is already gone. The compiler added and subtracted the constants itself, and loads the result, 0.0, with xorps:
__RNvCscE6FTKshoeA_9tolerance4main:
pushq %rbp
movq %rsp, %rbp
xorps %xmm0, %xmm0
callq __RNvMNtCsl7QZrza34zr_4core3f32f3absCscE6FTKshoeA_9tolerance
movaps %xmm0, %xmm1
movss LCPI4_0(%rip), %xmm0
ucomiss %xmm1, %xmm0
jae LBB4_2
leaq l_anon.e2037ec45069cb490f1439758f9c4e9d.1(%rip), %rdi
movl $51, %esi
leaq l_anon.e2037ec45069cb490f1439758f9c4e9d.3(%rip), %rdx
callq __RNvNtCsl7QZrza34zr_4core9panicking5panic
LBB4_2:
popq %rbp
retq
LCPI4_0 is .long 0x34000000, which is f32::EPSILON, and ucomiss is the CPU's comparison. In a release build nothing is left for the CPU:
__RNvCscE6FTKshoeA_9tolerance4main:
pushq %rbp
movq %rsp, %rbp
popq %rbp
retq
The compiler did IEEE 754 arithmetic itself, in software, and got the same bits the CPU would have. That's why the example programs on this page wrap their operands in std::hint::black_box ↗: to make the CPU do the work being claimed about. Integer division by zero is the opposite case. Even at -O, the division is guarded by Rust's own check:
testl %esi, %esi
je LBB4_3 ; b == 0 -> panic_const_div_by_zero
movl %esi, %eax
notl %eax
leal -2147483648(%rdi), %ecx
orl %eax, %ecx
je LBB4_2 ; MIN / -1 -> panic_const_div_overflow
movl %edi, %eax
cltd
idivl %esi
The two jumps come before idivl, the instruction that would trap. The comments are added; the instructions are rustc's.
Where this leaves you¶
- The first half of the section as a lesson: Comparing two numbers of different types
- The second half: What a float actually stores, and Making a float whole for what comes after
- The conversions: Casting with
as,TryFromandTryInto,FromandInto - The same section in Python, where
math.sqrt(-42.0)raises instead of returning NaN and[x] == [x]isTruefor a NaN: Comparing an int with a float ↗ and Float equality and NaN ↗ - The same section in C, where
-1 < 1ucompiles and is false, and where the float flags can be read: Comparing signed and unsigned ↗ and A float exception is a flag ↗ - The mathematics underneath: Machine numbers ↗ and Catastrophic cancellation ↗ in the math library
- The book itself, with its verdict: Books
Docs: PartialOrd ↗ · f64::EPSILON ↗ · f64::max ↗ · f64::total_cmp ↗ · Infallible ↗ · Rust 2021 prelude additions ↗
Po polsku¶
Podrozdział 2.3.3 książki Rust in Action dobrze opisuje zasady porównywania liczb, ale kilka szczegółów wymaga poprawki. Sprawdzone na rustc 1.98.0:
- Liczb różnych typów nie da się porównać, bo biblioteka standardowa nie ma takich implementacji cech (traits)
PartialOrd<Rhs>. To nie jest reguła języka:String == &strsię kompiluje. - Rada „rzutuj mniejszy typ na większy” nie wystarcza:
-1i32 as u64 < 1u64dajefalse. Docelowy typ musi mieścić wszystkie wartości, łącznie ze znakiem, więc piszi64::from. - W listingu 2.5
try_into().unwrap()dlau16 → i32nigdy nie spanikuje, bo typ błędu toInfallible. Ause std::convert::TryInto;jest potrzebne tylko w edycji 2018. - Przykład z
f32::EPSILONniczego nie sprawdza: wf32suma0.1 + 0.1jest dokładnie równa0.2, a różnica wynosi0. StałaEPSILONto odstęp obok1.0, nie uniwersalna tolerancja. -42.0.sqrt()się nie kompiluje, a podpowiedź kompilatora,-42.0_f32.sqrt(), daje-6.48, bo minus wiąże słabiej niż wywołanie metody. NaN daje dopiero(-42.0_f32).sqrt().NAN.max(1.0)zwraca1, więc „prawie wszystkie operacje z NaN dają NaN” to prawda z ważnymi wyjątkami.- Wbrew przypisowi działanie zmiennoprzecinkowe bez poprawnego wyniku nie wywołuje wyjątku procesora, tylko ustawia flagę stanu i program działa dalej. Pułapkę wywołuje dzielenie całkowite przez zero, a Rust sprawdza dzielnik wcześniej.
Szukaj po polsku: porównywanie liczb zmiennoprzecinkowych · epsilon maszynowy · rust f32::EPSILON · rust NaN max min · IEEE 754 exception flags · rust 2021 prelude TryInto