I found control flow statements such as if
, match
, and loop
to offer a much better developer experience compared to other programming languages due to their flexibility. Almost all of them can be written as either expressions or statements. We will discuss this shortly. Unlike some programming languages, Rust doesn’t support do-while
, switch
, or traditional C-like for
loops. However, there are easier ways to achieve the same functionality using Rust’s control flow semantics.
if/else
We use an if
statement when we want to perform an action if something we expect is true. This is a vague statement, so let me break it down in terms of implementation.
if <condition> {
// do some task
}
A normal if
statement looks like the one above. The <condition>
is a placeholder for a value or an expression that evaluates to a bool
value. If this bool
value is true
, then the code inside {}
will be executed. It’s as simple as that.
Unlike some programming languages like C and JavaScript, the
if
statement in Rust doesn’t require parentheses around the<condition>
. If you nevertheless provide them, such asif (<condition>) {...}
, they are stripped by the compiler and have no runtime impact.
fn is_even(value: u32) -> bool {
value % 2 == 0 // same as `return value % 2 == 0;`
}
fn main() {
let a = true;
let b = 2;
let c = 3;
let d = 4;
if a {
println!("Value 'a' is true.");
}
if is_even(b) {
println!("Integer 'b' is even.");
}
if !is_even(c) {
println!("Integer 'c' is odd.")
}
if d % 2 == 0 {
println!("Integer 'd' is even.")
}
}
$ cargo run
Value 'a' is true.
Integer 'b' is even.
Integer 'c' is odd.
Integer 'd' is even.
In the example above, the code inside all if
statements is executed since a
, is_even(b)
, !is_even(c)
, and d % 2 == 0
all evaluate to true
.
fn is_even(value: u32) -> bool {
value % 2 == 0 // same as `return value % 2 == 0;`
}
fn main() {
let b = 2;
let c = 3;
if is_even(b) {
println!("Integer 'b' is even.");
} else {
println!("Integer 'b' is odd.");
}
if is_even(c) {
println!("Integer 'c' is even.");
} else {
println!("Integer 'c' is odd.");
}
}
$ cargo run
Integer 'b' is even.
Integer 'c' is odd.
If we want to execute some code conditionally when something is not true, we can optionally use the else
block.
fn main() {
let value = 3;
if value < 0 {
println!("Value is negative.");
} else if value > 0 {
println!("Value is positive.");
} else {
println!("Value is zero.");
}
}
$ cargo run
Value is positive.
We can also have an optional else if
statement that will try to match the condition if the previous if
or else if
condition doesn’t match. In the example above, since the value is greater than 0, the value > 0
expression evaluates to true
, and that block is executed. If there are more else if
blocks after it, they won’t be evaluated for condition checking.
An if/else
statement in Rust can also be an expression. Unlike a statement, an expression evaluates to a value. In the examples so far, the if/else
blocks are performing some actions, but they do not evaluate to a value. Let’s see how we can use if/else
as an expression.
fn main() {
let value = 3;
let square_of_odd = if value % 2 == 0 {
println!("Value is even.");
value
} else {
println!("Value is odd.");
value * value
};
println!("square_of_odd: {}", square_of_odd);
}
$ cargo run
Value is odd.
square_of_odd: 9
In the example above, we have assigned the if/else
statement to a variable square_of_odd
. This makes the if/else
statement an expression, and the value evaluated by it will be stored in this variable. Notice that we have to put a ;
(semicolon) at the end of the else
block to qualify it for the let square_of_odd = <expr>;
construct to, where <expr>
is the expression which is our if/else
blocks. But what value does the if/else
evaluate to?
In Rust, if a block has an expression at the end, that block evaluates to that expression. The same goes for functions. If you find the is_even
function strange above, it’s because instead of writing the return value % 2 == 0;
statement, we placed the value % 2 == 0
expression. Since it’s at the end, it’s returned implicitly by the function. The same is happening here with our if/else
block. Since we put value
(without ;
) in the if
block and value * value
in the else
block, they will be returned (evaluated) based on which block is executed.
A block or a function that doesn’t have an expression at the end will return
()
(unit type) implicitly.
let square_of_odd = if value % 2 == 0 { value } else { value * value };
Rust doesn’t have a conditional ternary operator, which is quite common in C, JavaScript, or Python. However, since in Rust, if/else
is also an expression, it’s quite easy to format it like a ternary conditional operator, as above.
match
The match
statement is quite powerful in Rust. It’s similar to the switch
statement in other programming languages, but it can do more, such as pattern matching, which is quite important when it comes to handling enums. Let’s uncover some interesting capabilities of the match
statement.
fn main() {
let value = 3;
match value {
1 => println!("value is 1."),
2 => println!("value is 2."),
3 => println!("value is 3."),
_ => println!("Value is something else."),
}
}
$ cargo run
value is 3.
In the program above, we have a match
statement that is trying to match one of its arms with the value
.
match <expr> {
<pattern1> => <action1>,
<pattern2> => <action2>,
<pattern3> => <action3>,
}
A match
arm has the <pattern> => <action>
syntax, where <pattern>
is a value or an expression that could match the value
, and <action>
is an expression (or a block {}
) that will be evaluated if the pattern matches. It’s quite similar to the if/else
statement but more flexible.
Here, the value
is 3
, and our match
arms specify all possibilities of what the value
could be. We say all possibilities because the _
pattern would match everything else not exclusively matched by the other match
arms. If the match
arms do not match all possibilities or if match
doesn’t have _
(also called catch-all or wildcard pattern) arm, then we get a non-exhaustive patterns
compile-time error.
fn main() {
let value = 3;
match value {
1 => println!("value is 1."),
2 => println!("value is 2."),
3 => println!("value is 3."),
}
}
$ cargo run
error[E0004]: non-exhaustive patterns: `i32::MIN..=0_i32` and `4_i32..=i32::MAX` not covered
--> src/main.rs:4:11
|
4 | match value {
| ^^^^^ patterns `i32::MIN..=0_i32` and `4_i32..=i32::MAX` not covered
|
= note: the matched value is of type `i32`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern, a match arm with multiple or-patterns as shown, or multiple match arms
In Rust, match
statement is designed to enforce exhaustiveness. It requires us to cover all possible cases to prevent missing any inputs, which helps avoid unexpected errors. By forcing us to handle every possible outcome, it makes our code more reliable and secure, reducing the risk of bugs or crashes. It also ensures that as our program evolves, we won’t accidentally forget to address new cases.
fn main() {
let value = 3;
let message = match value {
1 => "value is 1.",
2 => "value is 2.",
3 => "value is 3.",
_ => "Value is something else.",
};
println!("{}", message);
}
// value is 3.
Like if/else
, match
can also be expressed as an expression. However, you must ensure that all arms evaluate to the same data type, &str
in this case; otherwise, you would get a “match
arms have incompatible types” compilation error.
fn main() {
let value = 3;
let message = match value {
1 | 2 | 3 => "value is 1 or 2 or 3.",
_ => "Value is something else.",
};
println!("{}", message);
}
// value is 1 or 2 or 3.
We can also use the |
(pipe) operator to match multiple patterns in a single match
arm and execute a single action, as shown above.
Match Guards
fn main() {
let value = 3;
match value {
n if n > 0 => println!("Value is positive."),
n if n < 0 => println!("Value is negative."),
_ => println!("Value is zero."),
}
}
// Value is positive.
A match guard is an additional condition used within a match arm that allows us to further refine or filter when a particular arm should be executed. With the match guard, the match
arm pattern becomes <pattern> if <condition> => <expr>
. A limitation in Rust with this is that we always need to provide _
(catch-all) arms since the conditions are evaluate at runtime and the compiler won’t take guard conditions into account when checking if all patterns are covered by the match expression.
Match Range
fn main() {
let value = 3;
match value {
1..10 => println!("Value is between [0,10)."),
10..=100 => println!("Value is between [10,100]."),
_ => println!("Value is something else."),
}
}
// Value is between [0,10).
We can also match the a value in the match
statement using ranges. In the example above, the match
arm pattern uses a range to match the value
. Since value 3
is in the range of 1..10
, that arm is matched.
fn main() {
let value = 300;
match value {
n @ 1..10 => println!("Value {} is between [0,10).", n),
n @ 10..=100 => println!("Value {} is between [10,100].", n),
n @ _ => println!("Value {} is something else.", n),
}
}
// Value 300 is something else.
If we would like to use the value that was used by the range pattern, we can use the @
symbol to bind the matched value to the name n
in the case above. Similar to a match
statement with match guards, a match
statement with range patterns must also have the _
(catch-all) arm.
for
loop
Unlike the traditional C-like for
loop syntax for i = 0; i < ...
, the for
loop in Rust provides a simpler syntax that matches Python’s for in
loop syntax.
fn main() {
for i in 0..3 {
println!("i: {i}");
}
}
$ cargo run
i: 0
i: 1
i: 2
In the example above, the for
loop is iterating over values in the range 0..3
. If we also want to include 3
in the range, we would use the 0..=3
expression.
fn main() {
let v: Vec<i32> = vec![0, 1, 2];
// i: i32
for i in v {
println!("i: {}", i);
}
// Error: borrow of moved value: `v`
// println!("Vector v: {:?}", v); // <-- uncomment
}
$ cargo run
i: 0
i: 1
i: 2
In the example above, we are iterating over a vector v
using the for
loop. If we try to use the vector v
again after the loop, such as in the println!
statement above, the Rust compiler complains about it. This happens because the value of v
is moved into the for
loop and can no longer be used. The for
loop takes ownership of the data in v
, and v
no longer has it, which is why we get this error. The type of i
in the for
loop is i32
because the for
loop now has the data of the vector, allowing it to access its elements directly. We will discuss ownership and borrowing in detail in a separate lesson. For now, if you don’t want the for
loop to take ownership of the data, we can instead use a reference to that data to iterate over it.
fn main() {
let v: Vec<i32> = vec![0, 1, 2];
// i: &i32
for i in &v {
println!("i: {}", i);
}
println!("Vector v: {:?}", v);
}
$ cargo run
i: 0
i: 1
i: 2
Vector v: [0, 1, 2]
In the example above, we have made a subtle modification. Instead of passing the value v
, we passed a reference to it &v
in the for
loop. Now the for
loop will iterate over references to the elements it contains, which is why i
has the type &i32
.
We saw in earlier lessons that data types such as array
, vector
, String
, &str
, and slices
can be iterated over using the for
loop. But what qualifies them to be used in the for
loop? The answer is the Iterator
trait. These types implement Iterator
trait, which enforce that certain methods must be implemented on these types, which are needed by the for
loop.
We are going to talk about iterators in detail in another dedicated lesson.
First of all, a type must implement the Iterator
trait to be iterated over. This trait enforces the type to implement the next
method. This method should return an Option
enum that either contains Some(item)
, where item
is the value in the iteration, or None
, marking that there are no more values to be iterated over. Let’s imagine we have a custom struct type:
struct MyIterator {
current: u8,
max: u8,
}
Here, the MyIterator
type is a struct that has current
and max
fields. The current
field will be incremented on each iteration until it reaches the max
value, which will mark the termination of the iteration.
impl Iterator for MyIterator {
type Item = u8;
fn next(&mut self) -> Option<Self::Item> {
if self.current < self.max {
let return_val = self.current;
self.current += 1;
Some(return_val)
} else {
None
}
}
}
The Iterator
trait is part of Rust’s standard library and is included in the prelude, so we don’t need to import it explicitly. We implement the Iterator
trait for our type MyIterator
using the impl Iterator for MyIterator
syntax. Once you do that, normally your IDE will intervene and autocomplete the rest of the boilerplate. For this trait, we need to provide a type for the values that will be yielded during the iteration, which is u8
in our case, along with the implementation of the next
method that should return an Option
enum for each iteration.
In the program above, the next
method returns Option<Self::Item>
, which translates to Option<u8>
. When we return None
from this method, the iteration ends. Otherwise, we need to return a Some(u8)
value. Using the if/else
statement, we check if self.current
has reached self.max
. If not, we return Some(u8)
; otherwise, we return None
, which indicates the end of the iteration. self
here is the mutable reference to the value we are iterating over, which is of type MyIterator
.
fn main() {
let mi = MyIterator { current: 0, max: 3 };
// i: u8
for i in mi {
println!("i: {}", i);
}
}
$ cargo run
i: 0
i: 1
i: 2
In the example above, mi
is a struct of type MyIterator
with field value current = 0
and max = 3
. When we iterate over it using the for
loop, the for
loop calls the .next()
method unless this method returns None
. The value of i
here is u8
which is extracted by the for
loop from Some(u8)
.
fn main() {
let v: Vec<i32> = vec![0, 1, 2];
// i: (usize, &i32)
for i in v.iter().enumerate() {
println!("index: {}, value: {}", i.0, i.1);
}
}
$ cargo run
index: 0, value: 0
index: 1, value: 1
index: 2, value: 2
If you would like to get the index of the element in the current iteration, most iterators support the .enumerate()
method. This method returns an iterator that produces a tuple in each iteration, containing the current iteration index/count (usize
) and the value returned by the .next()
method of the iterator. In the example above, we had to use the .iter()
method, which returns an iterator that does not consume or move the original vector data. This is the same as the &v
reference we used before in the for
loop; it internally calls the .iter()
method in this case. Here, the .enumerate()
returns an iterator that yields a reference to the value (usize, &i32)
. If you want the for
loop to take ownership of v
, you can use the .into_iter()
method instead of .iter()
, in which case the type of i
would be (usize, i32)
.
fn main() {
let v: Vec<i32> = vec![0, 1, 2];
// (usize, &i32)
for (index, value) in v.iter().enumerate() {
println!("index: {}, value: {}", index, value);
}
}
$ cargo run
index: 0, value: 0
index: 1, value: 1
index: 2, value: 2
We can also destructure complex types like tuples, structs, or enums directly within a for
loop. In the example above, instead of receiving the value in the iteration in a variable i
, we destructure it in place and receive it in index
and value
separately.
fn main() {
for i in 1..10 {
if i % 2 == 0 {
continue;
}
if i == 9 {
break;
}
println!("i: {}", i);
}
}
$ cargo run
i: 1
i: 3
i: 5
i: 7
We can use the break
and continue
statements in the for
loop to control the flow. In the example above, when i
is even, the continue;
statement is executed. When Rust encounters this statement in the for
loop, it stops the current iteration and moves to the next one. When i = 9
, Rust encounters the break;
statement, which abruptly exits the for
loop.
while
loop
Rust also provides a while
loop, which allows us to execute a piece of code indefinitely until a certain condition becomes false
. In the while <condition>
syntax, condition
is a value or an expression that should evaluate to a bool
. The while
loop continues executing until it evaluates to false
.
fn main() {
let mut counter = 0;
while counter < 10 {
let curr_counter = counter;
counter += 1;
if curr_counter % 2 == 0 {
continue;
}
if curr_counter == 9 {
break;
}
println!("curr_counter: {}", curr_counter);
}
}
$ cargo run
curr_counter: 1
curr_counter: 3
curr_counter: 5
curr_counter: 7
In the example above, our while
loop continues executing until the counter
reaches 10
. Like the for
loop, we can also use the break;
and continue;
statements to further adjust the control flow.
loop
Rust also provides us with a generic loop
statement that doesn’t exit until we deliberately break it using the break
statement. This is why loop
is called an infinite loop
, while while
is referred to as a predicate loop
.
fn main() {
let mut counter = 0;
loop {
let curr_counter = counter;
counter += 1;
if curr_counter % 2 == 0 {
continue;
}
if curr_counter >= 10 {
break;
}
println!("curr_counter: {}", curr_counter);
}
}
$ cargo run
curr_counter: 1
curr_counter: 3
curr_counter: 5
curr_counter: 7
curr_counter: 9
In the example above, the loop
doesn’t have any conditions. It will loop infinitely, but as soon as the counter
reaches 10
, we break out of it.
fn main() {
let mut counter = 0;
let result: i32 = loop {
let curr_counter = counter;
counter += 1;
if curr_counter % 2 == 0 {
continue;
}
if curr_counter >= 10 {
break 100;
}
};
println!("Result: {}", result);
}
// Result: 100
One interesting thing about loop
is that it can also be an expression. The value returned by loop
has to be specified by the break
statement; otherwise, it will return ()
(unit type). In the example above, the loop
breaks when counter
reaches 10
, and break 100;
is executed, which will return the value 100
.
fn main() {
let result: i32 = 'outer: loop {
'inner: loop {
break 'outer 100;
}
};
println!("Result: {}", result);
}
// Result: 100
The break
statement in a loop
always breaks the parent loop
. If we have nested loop
s, how can we break the outer loop from the inner loop using the break
statement? For that, we need to provide labels to the loop
s. We provide labels using the '<label>:
prefix and placing it before the loop
keyword. In the example above, we have 'outer
and 'inner
labels for the outer and inner loops, respectively. When we use the break
statement, we can specify which loop to break using the break '<label>;
or break '<label> <value>;
statements.
You can also use the
break
andcontinue
expressions in the arms of amatch
statement that is inside aloop
. This is a common control flow practice.