Sajiron
📖 If you haven't read it yet, check out the previous blog: Rust Basics: Syntax, Data Types, and Naming Conventions
Control flow is fundamental to programming, allowing decisions and repetitions in code execution. Rust provides familiar constructs like if
statements, match
expressions, and different looping mechanisms. Additionally, functions in Rust help create reusable, modular code while ensuring type safety. In this guide, we'll explore these concepts with explanations and practical examples.
if
and match
if
ExpressionsRust uses if
statements for both expressions and statements:
As an expression: if
returns a value and requires all branches to return the same type.
As a statement: if
executes a block of code but does not return a value.
Using if
as an Expression
fn main() {
let condition = true;
let number = if condition { 10 } else { 5 }; // ✅ Returns a value
println!("The number is: {}", number);
} // Used as an expression
Using if
as a Statement (No Return Value)
fn main() {
let number = 10;
if number > 5 {
println!("Number is greater than 5"); // Action, no return value
} else {
println!("Number is 5 or less");
}
} // Used as a statement
else if
fn main() {
let number = 6;
if number % 4 == 0 {
println!("Number is divisible by 4");
} else if number % 3 == 0 {
println!("Number is divisible by 3");
} else if number % 2 == 0 {
println!("Number is divisible by 2");
} else {
println!("Number is not divisible by 4, 3, or 2");
}
}
✅ Rust evaluates conditions in order, executing only the first matching condition.
match
Expressions: Rust’s Powerful Alternative to switch
Rust’s match
expression is similar to the switch
statement in languages like C++ and JavaScript, but it is more powerful and safer. Unlike switch
, match
can work with any type (not just integers or strings), requires all cases to be handled, meaning Rust enforces exhaustive pattern matching. If a new case is introduced in an enum, Rust will force you to handle it, reducing potential logic errors and ensuring no cases are missed, and acts as an expression that returns a value.
fn main() {
let status = 200;
match status {
200 => println!("OK"),
404 => println!("Not Found"),
500 => println!("Internal Server Error"),
_ => println!("Unknown Status"),
}
}
match
Is Safer and More Powerful Than switch
Rust’s match
expression is not just a decision-making tool—it also acts as an expression that returns a value, making it more versatile than switch
. Rust enforces exhaustive pattern matching, meaning you must handle all possible cases. If a new case is added to an enum or data structure, the compiler will warn you if you haven’t accounted for it. This ensures your code remains robust and error-free.
match
works with any type, while switch
is usually limited to integers and strings.
match
enforces exhaustive pattern matching, ensuring all cases are handled.
match
returns a value, making it more useful in expressions.
No need for break;
statements—each arm executes exactly one block.
Here’s how match
compares to switch
:
Feature |
|
|
Supports Any Type | ✅ Yes (Integers, Enums, Structs, etc.) | ❌ No (Usually only primitives) |
Exhaustiveness Check | ✅ Required | ❌ Optional (May miss cases) |
Pattern Matching | ✅ Yes | ❌ No (Only checks equality) |
Returns a Value | ✅ Yes | ❌ No |
Using match
to Return a Value:
fn main() {
let status = 200;
let message = match status {
200 => "OK",
404 => "Not Found",
500 => "Internal Server Error",
_ => "Unknown Status",
};
println!("Response: {}", message);
}
Here, match
assigns a value to message
, just like a ternary operator would.
❌ Common Error: Mismatched Types If different match arms return different types, Rust will throw an error:
fn main() {
let number = 10;
let result = match number {
1 => "One", // Returns `&str`
2 => "Two", // Returns `&str`
_ => 100, // ❌ ERROR: Returns an integer
};
}
✅ Fix: Ensure All Arms Return the Same Type
let result = match number {
1 => "One",
2 => "Two",
_ => "Other", // Now all return `&str`
};
The _
case is a catch-all that matches any value not explicitly listed, similar to default
in switch
. This ensures no case is missed and allows match
to always return a valid value., similar to default
in switch
. This ensures no case is missed.
Rust provides three primary loop types:
loop
: Infinite Loop with Breakfn main() {
let mut count = 0;
let result = loop {
count += 1;
if count == 5 {
break count * 2;
}
};
println!("Result: {}", result);
}
✅ Useful for retry mechanisms and event polling.
fn main() {
let mut num = 5;
while num > 0 {
println!("Countdown: {}", num);
num -= 1;
}
println!("Liftoff!");
}
✅ Use while
when the number of iterations is not known in advance.
for
: Iterating Over Collectionsfn main() {
let numbers = [1, 2, 3, 4, 5];
for num in numbers {
println!("Number: {}", num);
}
}
✅ Preferred for iterating over arrays, ranges, and collections.
0..length
)Rust provides a simple and safe way to define ranges using 0..length
. This range syntax represents a sequence of numbers from 0 up to (but not including) length
.
How 0..length
Works
0..length
means start at 0, iterate up to (but not including) length
(exclusive upper bound).
0..=length
includes length
(inclusive upper bound).
Exclusive Upper Bound (0..length
)
fn main() {
for i in 0..5 {
println!("Iteration: {}", i);
}
}
Output:
Iteration: 0
Iteration: 1
Iteration: 2
Iteration: 3
Iteration: 4
✅ The loop runs from 0 to 4 (does not include 5).
Inclusive Upper Bound (0..=length
)
fn main() {
for i in 0..=5 {
println!("Iteration: {}", i);
}
}
Output:
Iteration: 0
Iteration: 1
Iteration: 2
Iteration: 3
Iteration: 4
Iteration: 5
✅ The loop runs from 0 to 5, including length
.
Using Step (step_by(n)
)
If you want to skip numbers while looping, use .step_by(n)
:
fn main() {
for i in (0..10).step_by(2) {
println!("Iteration: {}", i);
}
}
Output:
Iteration: 0
Iteration: 2
Iteration: 4
Iteration: 6
Iteration: 8
✅ This skips every second number.
Using a Variable for Loop Length
If the loop length is stored in a variable, you can use it in the range:
fn main() {
let length = 5; // Length stored in a variable
for i in 0..length {
println!("Iteration: {}", i);
}
}
✅ This ensures flexibility if the length changes dynamically.
Functions in Rust are defined using the fn
keyword. Function arguments are type-annotated, just like variables. If the function returns a value, the return type must be specified after an arrow (->
).
The last expression in the function is implicitly returned, meaning there is no need for a return
keyword unless an early return is required (e.g., inside loops or conditionals). Rust functions help modularize code, improve readability, and enforce type safety.
fn greet() {
println!("Hello, Rust!");
}
fn main() {
greet(); // Function call
}
✅ Functions improve code modularity and reusability.
Rust functions can accept parameters and return values. When defining parameters, each must have an explicit type. The return type is declared after ->
.
fn add(a: i32, b: i32) -> i32 {
a + b // No semicolon = return expression
}
fn main() {
let sum = add(5, 10);
println!("Sum: {}", sum);
}
📌 Key Points:
The function takes two i32
parameters and returns an i32
.
The last expression in the function is automatically returned (without return
).
If a semicolon (;
) is added to the last expression, it turns into a statement and does not return a value.
return
and Early ReturnsIf you want to return early from a function, use the return
keyword:
fn check_number(num: i32) -> &'static str {
if num > 0 {
return "Positive";
}
"Non-positive" // Implicit return
}
fn main() {
let num = 5;
let result = check_number(num);
println!("The number is: {}", result);
}
✅ Here, return "Positive";
is used for an early exit, while the last expression ("Non-positive"
) is returned implicitly.
Generics allow functions to return different types while maintaining type safety.
fn identity<T>(value: T) -> T {
value // Returns the same type as input
}
fn main() {
let number = identity(10);
let text = identity("Hello");
println!("Number: {}", number);
println!("Text: {}", text);
}
📌 Key Points:
<T>
is a generic type parameter.
The function returns the same type as it receives.
Works with any type (integers, strings, etc.).
Rust allows defining associated functions and methods within structs. A struct is a custom data type that groups related values together. We will discuss this in more detail in upcoming blogs.
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn new(width: u32, height: u32) -> Self {
Self { width, height }
}
fn area(&self) -> u32 {
self.width * self.height
}
}
fn main() {
let rect = Rectangle::new(30, 50);
println!("The area of the rectangle is {} square pixels.", rect.area());
}
✅ Associated functions are used to create new instances of a struct without requiring an existing instance. Methods, on the other hand, operate on an instance and usually take self
as a parameter to access its fields.
Closures are anonymous functions that can capture their environment. Unlike normal functions, closures:
Use ||
instead of ()
for parameters.
Can infer parameter and return types.
Can access variables from the surrounding scope.
Closures are particularly useful when working with iterators, event handlers, or functional programming patterns. Since closures can capture variables from their surrounding scope, they allow functions to use external values without explicitly passing them as parameters.
fn main() {
let x = 5;
let add_x = |val| val + x; // Captures `x`
println!("Result: {}", add_x(10));
}
Closures can accept multiple parameters, just like functions.
fn main() {
let sum = |a: i32, b: i32| a + b; // Closure with two parameters
println!("Sum: {}", sum(5, 10));
}
📌 Key Points:
The closure takes two parameters (a
and b
).
The parameters must have explicit types if they are used in a complex expression.
✅ Rust can also infer types in simple cases:
fn main() {
let multiply = |a, b| a * b; // Type inferred
println!("Product: {}", multiply(3, 4));
}
Closures can be used without returning a value (similar to a "void" function in other languages).
fn main() {
let print_message = || {
println!("Hello from a closure!");
};
print_message(); // Call the closure
}
✅ Closure with Parameters, No Return Value
fn main() {
let greet = |name: &str| {
println!("Hello, {}!", name);
};
greet("Alice");
greet("Bob");
}
!
)Diverging functions are functions that never return and are marked with !
, meaning they do not produce a valid return value. These functions are useful when handling errors, infinite loops, or program termination.
Unlike functions that return ()
, which simply do not return a meaningful value, diverging functions never return control to the caller at all. This makes them useful for:
Handling critical failures using panic!()
.
Running infinite loops like event-driven servers.
Terminating the process with std::process::exit()
.
Example: A Function That Never Returns
fn crash_program() -> ! {
panic!("Unexpected error: crashing the program!");
}
fn main() {
crash_program(); // This will panic and terminate execution
println!("This line will never be reached."); // Unreachable
}
📌 Key Takeaways:
crash_program()
never returns because panic!()
stops execution.
The last line in main()
is unreachable.
Example: An Infinite Loop
fn run_forever() -> ! {
loop {
println!("Running indefinitely...");
}
}
fn main() {
run_forever();
println!("This will never print."); // Unreachable
}
📌 Key Takeaways:
run_forever()
never exits because of the infinite loop {}
.
Useful for long-running background processes like a game loop or server.
!
in Match ArmsDiverging functions are flexible because they can be used in expressions where Rust expects a value. This is useful for exhaustive match
patterns:
fn process_value(value: Option<i32>) -> i32 {
match value {
Some(num) => num,
None => panic!("Unexpected None value!"), // `panic!()` never returns
}
}
fn main() {
let x = process_value(Some(10));
println!("Value: {}", x);
let y = process_value(None); // This will panic
}
📌 Why is this useful?
The match
statement expects both arms to return an i32
, but panic!()
has type !
, which can be treated as any type.
This allows diverging functions to be used in expressions without causing type errors.
If a diverging function is called before another function, the second function will never execute because execution never returns.
fn never_returns() -> ! {
panic!("This function never returns!");
}
fn print_message() {
println!("Hello, Rust!"); // This will never execute
}
fn main() {
never_returns(); // Panics and never returns
print_message(); // 🚨 Unreachable code
}
📌 Rust will warn about unreachable code, because execution stops at never_returns()
.
Now that you’ve learned about Control Flow & Functions, the next step is understanding Ownership & Borrowing, one of Rust’s core features that ensures memory safety without a garbage collector.
Stay tuned for the next blog on Ownership & Borrowing in Rust! 🚀
💡 If you found this helpful, please remember to leave a like! 👍