Arithmetic feels like the most basic thing a programming language can do, but Rust taught me that “basic” and “simple” aren’t the same thing. Rust’s approach to numbers is strict, explicit, and deeply tied to memory safety and performance. In this article, I’ll walk through arithmetic operators, type casting, and the math functions I reach for most often, along with the gotchas I ran into as a beginner.
Why Rust Is Strict About Numbers
Unlike dynamically typed languages, Rust requires every number to have a known, fixed type — i32, u64, f64, and so on. This isn’t bureaucracy for its own sake. Fixed-size numeric types let the compiler generate extremely efficient machine code, and they prevent an entire class of bugs, like silent overflow or unintended type coercion, that plague looser languages.
Basic Arithmetic Operators
Rust supports the standard set of arithmetic operators: addition, subtraction, multiplication, division, and remainder.
fn main() {
let a = 10;
let b = 3;
println!("Addition: {}", a + b);
println!("Subtraction: {}", a - b);
println!("Multiplication: {}", a * b);
println!("Division: {}", a / b);
println!("Remainder: {}", a % b);
}
Output:
Addition: 13
Subtraction: 7
Multiplication: 30
Division: 3
Remainder: 1
Notice that 10 / 3 returns 3, not 3.333.... Since both a and b are integers (i32 by default), Rust performs integer division and truncates the result. This caught me off guard the first time — coming from Python 3, I expected float-like division automatically.
Floating-Point Arithmetic
If I want a precise decimal answer, I need to use floating-point types explicitly:
fn main() {
let a: f64 = 10.0;
let b: f64 = 3.0;
println!("Division: {}", a / b);
}
Output:
Division: 3.3333333333333335
Rust supports f32 and f64 for floating-point numbers, with f64 being the default when I write a decimal literal without an explicit type.
Integer Overflow: A Memory Safety Feature in Disguise
This is where Rust really separates itself from C and C++. In debug builds, if an arithmetic operation overflows, Rust panics rather than silently wrapping around:
fn main() {
let max: u8 = 255;
let overflowed = max + 1;
println!("{}", overflowed);
}
In debug mode, this panics with:
thread 'main' panicked at 'attempt to add with overflow'
In release mode (compiled with --release), the same code wraps around silently to 0, following two’s complement arithmetic — which is actually dangerous if you’re not aware of it. This difference between debug and release behavior is intentional: Rust wants you to catch overflow bugs during development, while still giving you the raw performance of unchecked arithmetic in production.
To handle overflow explicitly and safely, Rust gives you dedicated methods:
fn main() {
let max: u8 = 255;
let wrapped = max.wrapping_add(1);
let checked = max.checked_add(1);
let saturated = max.saturating_add(1);
println!("Wrapping: {}", wrapped);
println!("Checked: {:?}", checked);
println!("Saturating: {}", saturated);
}
Output:
Wrapping: 0
Checked: None
Saturating: 255
I use checked_* methods whenever I’m dealing with user input or untrusted data, because they force me to explicitly handle the overflow case with an Option, rather than letting a bug slip through silently.
Type Casting with as
Rust doesn’t perform implicit type conversions between numeric types — ever. If I want to convert an i32 to an f64, I have to say so explicitly using the as keyword.
fn main() {
let integer_value: i32 = 10;
let float_value = integer_value as f64;
println!("Integer: {}", integer_value);
println!("As float: {}", float_value);
}
Output:
Integer: 10
As float: 10
Casting downward (from a larger type to a smaller one) truncates the value rather than rounding it:
fn main() {
let float_value: f64 = 9.9;
let integer_value = float_value as i32;
println!("{}", integer_value); // 9, not 10
}
I learned to be careful here — as casting is a blunt tool. It won’t warn you about precision loss or truncation. For safer conversions, I often reach for TryFrom and TryInto, which return a Result instead of silently truncating:
use std::convert::TryFrom;
fn main() {
let big_number: i64 = 300;
let small_number = u8::try_from(big_number);
match small_number {
Ok(n) => println!("Converted: {}", n),
Err(e) => println!("Conversion failed: {}", e),
}
}
Output:
Conversion failed: out of range integral type conversion attempted
This is a much safer pattern for production code where the input range isn’t guaranteed.
Compound Assignment Operators
Rust supports the shorthand operators I’d expect from a C-family language:
fn main() {
let mut score = 10;
score += 5;
score -= 2;
score *= 3;
score /= 2;
println!("Final score: {}", score);
}
Output:
Final score: 19
Note that these require the variable to be declared with mut, tying back into Rust’s immutability-by-default philosophy.
Using the Standard Library’s Math Functions
Beyond basic operators, Rust’s standard library provides a rich set of math functions on numeric types themselves, not as free-floating functions.
fn main() {
let x: f64 = 16.0;
println!("Square root: {}", x.sqrt());
println!("Power of 2: {}", x.powi(2));
println!("Power of 2.5: {}", x.powf(2.5));
println!("Absolute value: {}", (-x).abs());
println!("Floor: {}", 9.7_f64.floor());
println!("Ceil: {}", 9.2_f64.ceil());
println!("Rounded: {}", 9.5_f64.round());
}
Output:
Square root: 4
Power of 2: 256
Power of 2.5: 1024
Absolute value: 16
Floor: 9
Ceil: 10
Rounded: 10
I appreciate that these are all methods on the f64 (and f32) types rather than standalone functions — it keeps the API discoverable through autocomplete, and it’s consistent with Rust’s method-oriented design.
For integers, there are equally useful helpers:
fn main() {
let a: i32 = -8;
println!("Absolute: {}", a.abs());
println!("Max of 10 and 20: {}", 10.max(20));
println!("Min of 10 and 20: {}", 10.min(20));
println!("Power: {}", 2_i32.pow(10));
}
Output:
Absolute: 8
Max of 10 and 20: 20
Min of 10 and 20: 10
Power: 1024
Real-World Example: A Simple Interest Calculator
Here’s a small program that ties several of these concepts together — operators, casting, and math functions — in a realistic context:
fn main() {
let principal: f64 = 5000.0;
let rate: f64 = 7.5;
let time_years: u32 = 3;
let interest = principal * rate * (time_years as f64) / 100.0;
let total = principal + interest;
println!("Principal: {:.2}", principal);
println!("Interest: {:.2}", interest);
println!("Total amount: {:.2}", total);
}
Output:
Principal: 5000.00
Interest: 1125.00
Total amount: 6125.00
Notice the {:.2} formatting specifier — I’ll cover formatting more deeply in the next article on printing, but it’s worth flagging here since it pairs so naturally with arithmetic output.
Performance Considerations
Because Rust’s numeric types map directly to native CPU registers (an i32 is genuinely a 32-bit integer at the hardware level, not a boxed object), arithmetic operations in Rust are as fast as equivalent C code. There’s no hidden allocation, no boxing/unboxing, and no runtime type checks during arithmetic — all the type checking happens at compile time. This is part of what makes Rust suitable for systems programming, game engines, and performance-critical services.
Common Mistakes I Made
- Assuming automatic type conversion: Trying to add an
i32and af64directly results in a compile error. Rust requires explicit casting every time. - Ignoring overflow in debug vs release: I once had code that worked fine in
cargo run(debug mode panics loudly) but behaved unexpectedly incargo run --releasebecause of silent wraparound. - Truncating unintentionally with
as: Casting a float to an integer silently drops the decimal part instead of rounding — I now call.round()first if I want proper rounding behavior. - Dividing integers and expecting decimals: A classic beginner trap. Always cast at least one operand to a float type if you need fractional results.
Best Practices
- Choose the smallest type that fits your data’s range for memory efficiency, but don’t over-optimize prematurely —
i32is a reasonable default. - Use
checked_*,saturating_*, orwrapping_*methods explicitly when overflow is a real possibility, especially with user-supplied data. - Prefer
TryFrom/TryIntooveraswhen correctness matters more than convenience. - Always test arithmetic-heavy code in both debug and release modes if overflow behavior matters to your logic.
Troubleshooting and FAQs
Q: Why do I get “mismatched types” when adding an integer and a float? Rust never implicitly converts between numeric types. You need to explicitly cast one of them using as, e.g., integer as f64.
Q: Why does my program panic in debug mode but not in release mode? Overflow checks are enabled by default in debug builds and disabled in release builds for performance. Use checked_add or similar methods if you need consistent behavior across both.
Q: What’s the difference between wrapping_add and saturating_add? wrapping_add lets the value wrap around (e.g., 255 + 1 becomes 0 for a u8), while saturating_add clamps the result at the type’s maximum or minimum value instead.
Q: How do I round a float to a specific number of decimal places? Use formatting, like println!("{:.2}", value), for display purposes, or multiply, round, and divide back if you need the actual rounded numeric value.
Summary
Arithmetic in Rust looks familiar on the surface but carries real depth once you dig in. Operators behave predictably, but only within a single, explicit type — Rust simply won’t let ambiguity slip through. Overflow handling is a first-class concern rather than an afterthought, and the standard library’s math methods give you everything from square roots to power functions without needing external crates for common cases. Once I internalized that Rust wants me to be explicit about intent, arithmetic stopped feeling restrictive and started feeling like a safety net.
References
- The Rust Programming Language Book — Data Types: https://doc.rust-lang.org/book/ch03-02-data-types.html
- Rust Standard Library documentation for numeric types: https://doc.rust-lang.org/std/primitive.i32.html
- Rust Reference on Operator Expressions: https://doc.rust-lang.org/reference/expressions/operator-expr.html
- Cargo Documentation: https://doc.rust-lang.org/cargo/
