Exercise: Calculator with Error Handling
calculator
Now that you can write basic functions, let's tackle something more challenging: a calculator that handles errors gracefully.
In real-world code, things go wrong. Users pass invalid input. Operations fail. Good Rust code uses the type system to handle these cases explicitly.
🎯 Learning Objectives
Thinking
Doing
💬 Discussion
- What should happen if someone tries to divide by zero?
- In other languages, how do you typically handle this? (exceptions, null, etc.)
- Why might returning a Result be better than throwing an exception?
- What information should an error message include?
💡 Hints
Hint 1: Using match on enums
Use a match expression to handle each operation:
match op {
Operation::Add => { /* ... */ },
Operation::Subtract => { /* ... */ },
Operation::Multiply => { /* ... */ },
Operation::Divide => { /* ... */ },
}
Each arm should return a Result<f64, CalculatorError>.
Hint 2: Handling Division
For division, check for zero before performing the operation:
Operation::Divide => {
if b == 0.0 {
return Err(CalculatorError::DivisionByZero);
}
Ok(a / b)
}
Note: We check b == 0.0 before dividing to prevent the error.
Hint 3: Complete Implementation
pub fn calculate(a: f64, b: f64, op: Operation) -> Result<f64, CalculatorError> {
match op {
Operation::Add => Ok(a + b),
Operation::Subtract => Ok(a - b),
Operation::Multiply => Ok(a * b),
Operation::Divide => {
if b == 0.0 {
Err(CalculatorError::DivisionByZero)
} else {
Ok(a / b)
}
}
}
}
⚠️ Try the exercise first! Show Solution
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Operation {
Add,
Subtract,
Multiply,
Divide,
}
#[derive(Debug, Clone, PartialEq)]
pub enum CalculatorError {
DivisionByZero,
Overflow,
}
impl fmt::Display for CalculatorError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CalculatorError::DivisionByZero => write!(f, "Cannot divide by zero"),
CalculatorError::Overflow => write!(f, "Arithmetic overflow occurred"),
}
}
}
impl std::error::Error for CalculatorError {}
pub fn calculate(a: f64, b: f64, op: Operation) -> Result<f64, CalculatorError> {
match op {
Operation::Add => Ok(a + b),
Operation::Subtract => Ok(a - b),
Operation::Multiply => Ok(a * b),
Operation::Divide => {
if b == 0.0 {
Err(CalculatorError::DivisionByZero)
} else {
Ok(a / b)
}
}
}
}
Explanation
Why use Result instead of panicking?
In Rust, we distinguish between:
- Recoverable errors: Use
Result<T, E>- the caller can handle the error - Unrecoverable errors: Use
panic!- the program cannot continue
Division by zero is recoverable - the caller might want to show an error message, use a default value, or try a different calculation.
Why check before dividing?
With floating-point numbers, 10.0 / 0.0 actually produces inf (infinity)
rather than panicking. But for a calculator, we want to treat this as an
error. By checking first, we can return a clear error message.
The match pattern
Each arm of the match returns the same type: Result<f64, CalculatorError>.
This is enforced by the compiler - all arms must return the same type.
Error type design
We created a custom CalculatorError enum rather than using a string. This:
- Allows pattern matching on specific error types
- Provides type safety
- Implements
std::error::Errorfor compatibility with?operator
🧪 Tests
Run these tests locally with:
cargo test
View Test Code
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_addition() {
assert_eq!(calculate(5.0, 3.0, Operation::Add), Ok(8.0));
}
#[test]
fn test_subtraction() {
assert_eq!(calculate(10.0, 4.0, Operation::Subtract), Ok(6.0));
}
#[test]
fn test_multiplication() {
assert_eq!(calculate(7.0, 6.0, Operation::Multiply), Ok(42.0));
}
#[test]
fn test_division() {
assert_eq!(calculate(15.0, 3.0, Operation::Divide), Ok(5.0));
}
#[test]
fn test_division_by_zero() {
assert_eq!(
calculate(10.0, 0.0, Operation::Divide),
Err(CalculatorError::DivisionByZero)
);
}
#[test]
fn test_negative_numbers() {
assert_eq!(calculate(-5.0, 3.0, Operation::Add), Ok(-2.0));
}
#[test]
fn test_floating_point() {
let result = calculate(0.1, 0.2, Operation::Add).unwrap();
assert!((result - 0.3).abs() < 0.0001);
}
}
🤔 Reflection
- **Error handling**: What are the tradeoffs between returning `Result`,
- **Type design**: We used an enum for `CalculatorError`. What if we had
- **Floating-point**: The test for `0.1 + 0.2` uses approximate comparison.
- **Extension**: How would you add a `Power` operation? What about handling
- **Real-world**: In production code, would you use `f64` for a financial