Operators, comparisons and bool

Rust has the operators you expect and a rule that is stricter than most languages: an operator applies only to values of the same type. There is no implicit promotion from i32 to i64 and no conversion from an integer to a float, so the compiler rejects more expressions and the ones it accepts mean exactly what they say.

The lesson goes through the arithmetic operators, the comparison and logic operators that produce a bool, the bitwise operators, the precedence table, and the one conversion that is written in the source: as.

Arithmetic and integer division

+ - * / and % work on integers and floats, and an integer division truncates towards zero: seven divided by two is three, and the remainder is one. The remainder keeps the sign of the dividend, so -7 % 3 is -1, not two.

For a fractional result both operands must be floats. f64::from(a) converts an i32 to an f64 without a cast, and the division then produces 3.5. Mixing the two types in one expression is an error rather than a silent promotion.

fn main() { let a = 7; let b = 2; println!( "add {} sub {} mul {} div {} rem {}", a + b, a - b, a * b, a / b, a % b ); println!("float div: {}", f64::from(a) / f64::from(b)); println!("negative rem: {}", -7 % 3); }

rustc --edition 2024 arithmetic.rs
./arithmetic

add 9 sub 5 mul 14 div 3 rem 1
float div: 3.5
negative rem: -1

Integer division by zero panics, and the compiler catches the constant case at build time. The checked, wrapping and saturating methods exist for code that cannot afford a panic, and the lesson on numbers uses them.

Comparisons and bool

== != < <= > >= compare two values of the same type and produce a bool. Two different numeric types do not compare directly, and an integer does not compare with a float, which is one place where the strictness catches a real mistake early.

A bool prints as true or false with {}. Comparisons chain with logic, so a range check is two comparisons and one &&, both written out.

fn main() { println!("{} {} {} {} {}", 3 < 5, 3 <= 3, 3 == 4, 4 != 4, 5 > 9); let x = 10; println!("in range: {}", 0 < x && x <= 10); }

rustc --edition 2024 comparison.rs
./comparison

true true false false false
in range: true

The result of a comparison is an ordinary value, so it can be stored, returned and passed to a function. That is what makes if an expression in the next lesson: its condition is just a bool.

Logic and short-circuit

&&, || and ! work on bool and nothing else; there is no implicit conversion from an integer. Both binary operators short-circuit: when the left side already decides the answer, the right side is not evaluated at all.

The program proves it with a function that prints when it is called. The first expression stops at false and never calls it, the second stops at true. This is not an optimisation: code with side effects depends on it, and a condition such as index < len && values[index] > 0 is only safe because of it.

fn checked() -> bool { println!("checked called"); true } fn main() { let ready = false && checked(); println!("ready: {ready}"); let ready = true || checked(); println!("ready: {ready}"); }

rustc --edition 2024 shortcircuit.rs
./shortcircuit

ready: false
ready: true

No line says checked called, which is the whole point. The bitwise operators below do not short-circuit, because they are not logic: they work on the bits of a number.

Bitwise operators

& | ^ ! << >> work on integers bit by bit. A mask selects bits, so flags & mask != 0 asks whether any of them is set; | sets bits, ^ toggles them and ! inverts the type's full width.

The binary literal 0b0000_0101 is a number with two bits set, and {:04b} prints a value in binary with four digits. A shift by the width of the type or more is an overflow, which the compiler reports for a constant shift and a debug build panics on at run time.

fn main() { let flags: u8 = 0b0000_0101; let mask: u8 = 0b0000_0100; println!("set: {}", flags & mask != 0); println!("or: {:04b}", flags | 0b0000_0010); println!("xor: {:04b}", flags ^ 0b0000_0111); println!("not: {:08b}", !flags); println!("shift: {} {}", flags << 2, flags >> 1); }

rustc --edition 2024 bits.rs
./bits

set: true
or: 0111
xor: 0010
not: 11111010
shift: 20 2

The inverted value has eight digits because u8 is eight bits wide; on a u32 the same expression would print 32. The lesson on char and bits builds a permission set out of these operators.

Precedence

Multiplication binds tighter than addition, and addition binds tighter than a shift. The third line of the program is the one to remember: 1 << 2 + 1 is 1 << 3, which is eight, because the addition happens first.

The table is short enough to know by heart for arithmetic, and parentheses cost nothing at run time. When a reader has to think about precedence, the code has already lost, so write the parentheses.

fn main() { println!("{}", 2 + 3 * 4); println!("{}", (2 + 3) * 4); println!("{}", 1 << 2 + 1); println!("{}", (1 << 2) + 1); }

rustc --edition 2024 precedence.rs
./precedence

14
20
8
5

Assignment is not an expression in Rust: a = b = c does not compile, and let is a statement. That removes a family of precedence bugs that C++ programmers learn the hard way.

Casts with as

as converts between numeric types and truncates when the target is narrower: 300 as a u8 is 44, because only the low eight bits survive. It also converts an integer to a float, and a char to its code point. It never converts a string to a number, and it never fails at run time.

A type mismatch without as is E0308, and the help line suggests .into() when a lossless conversion exists. Both directions exist in the program: a truncating cast that is deliberate, and an assignment that the compiler refuses.

fn main() { let big: i32 = 300; let small = big as u8; println!("300 as u8 = {small}"); let ratio = 7 as f64 / 2 as f64; println!("ratio = {ratio}"); }

fn main() { let count: i32 = 5; let total: i64 = count; println!("{total}"); }

rustc --edition 2024 casts.rs
./casts
rustc --edition 2024 implicit.rs

300 as u8 = 44
ratio = 3.5
error[E0308]: mismatched types
--> implicit.rs:3:22
|
3 | let total: i64 = count;
| --- ^^^^^ expected `i64`, found `i32`
| |
| expected due to this
|
help: you can convert an `i32` to an `i64`
|
3 | let total: i64 = count.into();
| +++++++

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0308`.

The failure is the point: i64 from i32 is lossless and still needs .into(), because Rust does not convert behind your back. The lesson on numbers covers the conversions that can fail and return a Result.

Exercises

  1. Change the division in arithmetic.rs to a float division without f64::from. What does the compiler say, and which of its suggestions is lossless?
  2. Print the value of -7 / 3 and -7 % 3. Which sign does each result carry?
  3. Use the bitwise operators to test whether bit 3 of a u8 is set, then clear it and print the result in binary.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. What is the result of 7 / 2 when both operands are integers?

    Answer

    3

    Integer division truncates towards zero, and the fractional part is dropped.

  2. What does -7 % 3 evaluate to in Rust?

    Answer

    -1

    The remainder keeps the sign of the dividend, so the result is negative one.

  3. What happens to the right side of false && check()?

    Answer

    It is not evaluated at all

    Logical AND short-circuits when the left side is false, so the call never happens.

  4. What does 300 as u8 produce?

    Answer

    44

    The cast keeps the low eight bits of 300, which is 44, and it never fails at run time.

  5. What does this program print? fn main() { let a = 9; let b = 4; println!("{} {} {}", a / b, a % b, a * b); }

    Answer

    2 1 36

    Nine divided by four is two with remainder one, and the product is thirty-six.

  6. What does this program print? fn main() { let flags: u8 = 0b1010; let mask: u8 = 0b0010; println!("{} {} {}", flags & mask, flags | mask, flags ^ 0b1111); }

    Answer

    2 10 5

    AND keeps bit 1, OR sets it in the same value, and XOR with 1111 inverts the low four bits.

  7. What does this program print? fn main() { println!("{}", 1 << 2 + 1); println!("{}", (1 << 2) + 1); }

    Answer

    8 / 5

    Addition binds tighter than the shift, so the first expression is 1 << 3 and the second is 4 + 1.

  8. What is the first line of the compiler output for this program? fn main() { let count: i32 = 5; let total: i64 = count; println!("{total}"); }

    Answer

    error[E0308]: mismatched types

    The annotation asks for i64 and the value is an i32, and the help suggests .into() for the lossless conversion.

  9. What does this program print? fn main() { let x = 5; let y = 3; println!("{} {}", x > y, x != y); }

    Answer

    true true

    Both comparisons hold, and a bool prints as true or false.

  10. What does this program print? fn main() { let flags: u8 = 0b1010; println!("{:08b}", !flags); }

    Answer

    11110101

    The bitwise not inverts all eight bits, and the format prints exactly eight of them.

The code on this page was built with rustc 1.96.1 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev