Skip to content

Control Flow

Control flow in Rust is broken into two primary methodologies: branching constructs and loop constructs. A construct, in this instance, is a high-level abstraction or building block used to represent the logical flow or structure of a program. Branching constructs essentially work by checking boolean conditions and returning a value. This means that branching constructs are, for the most part, expressions. Basic branching constructs include the if and else keywords and work similarly to other languages.

Rust also includes a match construct that work a lot like a switch (branching) statement in C/C++, Java, and JavaScript. The major difference is that instead of checking multiple explicit cases, Rust’s match construct checks for patterns within a given return type. If you’re just looking for a single type within the pattern Rust provides an if let construct. Rust puts such a large emphasis on patterns/pattern matching that this page groups match and if let constructs as top-level “match-like” constructs.

Looping constructs repeat blocks of code based on similar Boolean value checks. Rust provides three loop constructs; the loop, while, and for loops.

Patterns

Patterns are… everywhere. This section covers the following constructs and their associated/valid patterns.

  • if
  • if let
  • match
  • else
  • else if
  • else if let
  • while let

Branching Constructs

Branching constructs can be used as either plain expressions or within statements.

// Branching expressions that implicitly return values
if boolean_expression {
code to execute
} else {
different code to execute
}
match pattern_expression {
pattern_1 => code_1,
pattern_2 => code_2,
}
if let pattern = expression {
code to execute
}
// Branching expressions used in assignment statements
let result = if boolean_expression {
code to execute
} else {
different code to execute
};
let result = match pattern_expression {
pattern_1 => expression_1,
pattern_2 => expression_2,
};

Notice that the if and match blocks do not require terminal semicolon characters. This is because they are expressions and therefore implicitly return a type. The let statements use branching expressions to bind values and do not return a type, so they require semicolons.

The if/else Constructs

The if construct uses boolean logic to switch logical paths. Using if includes the keyword itself, a boolean expression, and a block (in curly braces) to execute if the boolean expression evaluates to true. The if expression can also be chained with an else keyword to execute code in the instance that the previous if expression evaluates to false.

fn if_construct(n: f64) {
let x = 12;
// The code block doesn't execute because the expression evaluates to false
if x > 23 {
println!("Hey, thats amazing!")
}
// Simple if-else expression
if n < 0.5 {
println!("false") // Returns the unit type ()
}
else {
println!("true") // Returns the unit type ()
}
}

Multiple else cases can be stacked for multiway switching. Notice that it is not possible to add the terminal ; character in between if and else if conditions because the whole thing is considered a contiguous chain of expressions. If expressions are most appropriate when there are a handful of clearly defined scenarios that may contain many possible variations, like a greater-than or less-than scenario. For more precise mutli-way switching Rust includes pattern matching discussed later in this section.

fn if_construct(n: f64) {
// Multi-way logic switching
if n < 0.33 {
println!("lower third");
}
else if (0.33 < n) && (n < 0.66) {
println!("middle third");
}
else if 0.66 < n {
println!("upper third");
};
}

Because if is an expression you can also use it on the right-hand side of an assignment statement.

fn if_in_let(n: i32) {
let i: &str = if n < 5 {"true"} else {"false"};
println!("{i}");
}

Using if in assignment statements has limitations. It is not possible to create complex switching conditions in an assignment statement. Similar to the if-let construct discussed later on this page, using if on the right-hand side of an assignment is really only useful for simple logical statements. For example, the following function does not compile and instead results in a type mismatch error.

fn so_many_times(n: f64) {
// Illegal use of multiple branching constructs in an assignment
let i: &str = if n < 0.5 {
"less than 50%"
} else if n >= 0.5 {
"more than 50%"
};
println!("{i}");
}
error[E0317]: `if` may be missing an `else` clause
--> src/bin/variables.rs:154:10
|
154 | else if n >= 0.5 {"more than 50%"};
| ^^^^^^^^^^^^^---------------^
| | |
| | found here
| expected `&str`, found `()`
|
= note: `if` expressions without `else` evaluate to `()`
= help: consider adding an `else` block that evaluates to the expected type

Match-like Constructs

Rust emphasises the use of pattern matching in control-flow mechanisms. These mechanisms are known as “match-like” constructs. The match and if let constructs compare inputs against one or more patterns and then execute code based on those pattern matches. The match construct is exhaustive and must be written to cover all possible outcomes. The if let construct checks for one specific outcome, such as a successful match.

There is a very common pattern in Rust that matches against an enum, binds a variable to the data inside that enum, and then executes code based on the result. Common enum matching includes the Ok and Err enum variants for Result<T, E> and the Some, or None enum variants in Option<T>.

Match-like constructs are considering branching statements just like the if statement, but unlike the if construct, which evaluates a Boolean condition, match-like constructs evaluate patterns and can return different types based on the matched pattern. Even though its one of the most common usage patterns, match-like constructs don’t necessarily have to match on enums. They can also match on structs/tuple structs, references, strings and arrays, literals, variable names, wildcards, any type that implements the Deref trait, and more. This makes the match-like constructs one of the most powerful tools in your Rust toolbox.

The match construct

The match construct consists of a match pattern and one or more match arms. The match arms are expressions and collectively must cover all possible pattern types. Match arms consist of a type (variant) and some code to execute for a positive match. The type and code to execute are separated by a “fat arrow” => operator. Because each arm is an expression, they implicitly return a value. This return value must be of the same type for all match arm expressions.

Let’s look at a dirt-simple example as a comparison to the if/else construct. This example matches against a number. If the variable is equal to 3 the code prints one message, and if it’s not, it prints the other message. This is easy because the value can either be an integer or not an integer.

// If-else matching
if x == 3 {
println!("Yep, its 3")
} else {
println!("Not 3")
}
// Same logic using match
match x {
3 => println!("Yep, its 3"),
_ => println!("Not 3")
}

The boolean evaluation is still implied with the match construct, but it’s become kind of decentralized. Instead of asking directly “if x is equal to 3”, you ask “which one of these options is x?” The match logic is exhaustive, meaning that Rust forces us to cover all possibilities. Because you can’t really list out all the specific things x isn’t by name you can include a catch-all variant. Indeed, if the match arms do not explicitly name what x isn’t, the last arm in the match list can be used to cover all remaining possibilities. In this example you have used the wildcard character (underscore _), but it could just as well be any other non-keyword value, such as other or literally_anything_else.

The next important thing to note here is that both expressions in this example use the println! macro. This macro doesn’t really return a value, and thus by default returns the unit type (). If one of these expressions returned different types you would hit a compiler error.

// Illegal match implementation, mismatched return types
match x {
3 => println!("Yep, its 3"), // Returns ()
_ => x + 5 // Returns integer
}

More about match with Result<T, E>

Ok, enough of the toy examples. Let’s get into one of the most common patterns to match in Rust. Let’s match against a value of a type that returns a Result<T, E>. This pattern is one of the common error handling patterns in Rust and is covered in much more depth over in the Error Handling section. Let’s take a simple look for now.

First we’ll create a type that returns Result<T, E>. This function takes two integers x and y and attempts to divide x by y. The error variants are defined in a bit of a workaround shorthand. See the error handling section for a proper way to define functions that return Result<T, E> types with proper error definitions.

fn divide(x: isize, y: isize) -> Result<isize, &'static str> {
if x == 0 {
Err("Dividend cannot be zero")
} else if y == 0 {
Err("Divisor cannot be zero")
} else {
Ok(x / y)
}
}

Now lets set up the calling function to actually use the match construct. This example illustrates an assignment statement that binds the return value of a match expression to the named variable in the let statement. If there is no error, the Ok arm simply returns the value of whatever you call the inner value of the Ok variant. In this case, you call it a. So if there are no errors, you bind return value of divide() to a variable called a in the match expression. Then, through assignment, you bind the value of a to answer. This sets the tone for the rest of the expression by fixing the return type for all other arms. This means that even the error arm must return a value of whatever type Ok is. In this case, the compiler assumes were using isize. The Err arm contains a block that prints a formatted error message with the error value, but because println! returns the unit type and Ok is of type isize you also need to make sure the Err arm returns isize.

pub fn calling_function() {
let x = 3;
let y = 0;
let answer = match divide_by(x, y) {
Ok(a) => a,
Err(e) => {
println!("Error: {e}");
0 // Default return value
}
};
println!("{} / {} = {}", x, y, answer)
}
Error: Divisor cannot be zero
3 / 0 = 0

Notice that the function continued beyond the error case. It is also possible to “cheat” the match arm’s return value by essentially shorting out the process with the return keyword. In this example that’s only one more println! function. In the real world Rust forces us to design functions to handle these kinds of situations. Sometimes it’s ok to return early, other times you will need a default value to move forward with the function. Sometimes its important enough to exit the program entirely. The error handling section covers some of these types of use cases.

pub fn calling_function() {
let x = 3;
let y = 0;
let answer = match divide_by(x, y) {
Ok(a) => a,
Err(e) => {
println!("Error: {e}");
return // "shorts" the function to return early with no value
}
};
println!("{} / {} = {}", x, y, answer)
}
Error: Divisor cannot be zero

Most of the time we’ll want to bind a variable to the information in the Ok variant, but you can also bind to the Err variant, or any other variant that we’re matching against. In any case, all match arm expressions still must return the same value type or return early. This example illustrates binding answer to the Err return value e. Because e is an &str type both arms must now return a &str type instead of the isize type.

pub fn calling_function() {
let x = 8;
let y = 3;
let answer = match divide_by(x, y) {
Ok(a) => {
println!("{}ish", a);
"[generic return type]"
},
Err(e) => e,
};
println!("{} / {} = {}", x, y, answer.to_string());
}
2ish
8 / 3 = [generic return type]

More about match with Option<T>

Another common pattern matching scenario is to determine whether a return contains information. The Option<T> type has two variants Some and None to indicate the presence of information. In the following example you use the Option<T> type to specify some variables and pass them to a function that matches based on the type. The function executes simple addition if there is a match.

//Matches against the None and Some variants of the Option<T> enum
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some (i + 1), //Executes code based on a particular enum match
}
}
fn calling_function() {
// Binds data with variables of type Option<i32>
let five: Option<i32> = Some(5);
let none: Option<i32> = None;
// Calls the helper function that matches the variables
// Both data-bound variables and raw variant keywords are acceptable
// Illustrative purposes only; no return values are handled
plus_one(five);
plus_one(Some(11));
plus_one(none);
plus_one(None);
// Binds the return value of a match expression and prints the result
let answer: String = match plus_one(five) {
Some(a) => format!("The answer is: {a}"),
None => format!("Sorry, I didn't find anything"),
};
println!("{answer}");
}
The answer is: 6

The if let syntax

If you want to search for a value from a single variant within an enum or other pattern and don’t want to create a whole match block you can use the if let syntax to access elements directly. This syntax takes a pattern and an expression (in that order) separated by an equals sign. This works similarly to how the match construct works. The difference is that it matches a specific instance of a pattern and allows you to access the value with a code block. The if let syntax gives up the exhaustive searching of the match construct.

let n = 2;
if let (0..=3) = n {
println!("The value is in range")
} else {
println!("The value is not in range")
}
enum Message {
Contents(String),
}
pub fn if_let() {
// Creates a message
let s: String = String::from("A rising tide lifts all the homies");
// Instantiates an enum m and binds the string to the Contents variant
let m = Message::Contents(s);
// Uses if let to access the Contents value for the m Message object
if let Message::Contents(result) = m {
println!("The message is: {result}")
}
}
The message is: A rising tide lifts all the homies

This pattern is technically irrefutable and therefore inappropriate because the Message enum only has one variant. Adding another variant to Contents like Timestamp would eliminate the compiler warning.

It is possible to use an else statement with the if let syntax which serves a similar purpose to the catch-all _ case in match arms.

let mut count = 0;
if let Coin::Quarter(state) = coin {
println!("State: {:?}", state);
} else {
count += 1;
}

The if let syntax can be used to match the result of a function call that returns a Result<T, E> or Return<(), Box<dyn Error>>. Because if let is not exhaustive it can only really be used to search for and propagate an error from the Result<> types. In the if let PATTERN = EXPRESSION construct the left-hand position (pattern) checks whether the function call is an error. If the result is an error, the value is bound to e. If the result is some type, it is not bound. Because of this the underlying function being called must return the unit () type (“void” type function). In this example e is printed and the program exits as an abnormal termination.

if let Err(e) = function(struct_instance) {
println!("{e}");
process::exit(1);
}

This can be handy when calling logic processes from our main() function from somewhere else in our project. Doing it this way helps us track errors throughout the logic of the program.

Loops

Loops allow us to repeat actions based on Boolean conditions. Rust has three primary looping constructs; the loop, the while loop, and the for loop. All of these constructs share associated continue and break keywords explained in the first loop construct below.

The loop construct

The loop expression will repeat forever until you explicitly tell it to stop. The following example results in an infinite loop. Hint: because you’re dumb enough to run code that I explicitly said resulted in an infinite loop, you may value remembering that ctrl + c can terminate the action.

fn loops() {
let mut i: i32 = 0;
loop {
println!("{i}");
i = i + 1;
}
}

There are two associated keywords to exit all loop constructs. The continue keyword interrupts the current loop iteration and goes back to the start of a new loop iteration. The continue keyword doesn’t actually end the loop process. The break keyword exits the entire loop.

fn loops() {
let mut i: i32 = 12;
loop {
println!("{i}");
i += 1;
if i == 23 {
println!("{i} <- nice");
i += 1;
continue
}
if i == 30 {
println!("The max is: {i}");
break;
}
}
}

Because the loop keyword is handy for retrying an operation you know might fail you can also use the loop expression to return a value.

fn loops() {
let mut i: i32 = 12;
let x: i32 = loop { //Use the loop expression to bind a variable
println!("{i}");
i += 1;
if i == 23 {
println!("{i} <- nice");
i += 1;
continue
}
if i == 30 {
break i; //Returns the value and binds to "x"
}
};
println!("The max is: {x}");
}

Often times nested loops can become easily confused and complicated. Rust includes a feature to label loops and their associated break points. The loop labels preceed the loop keyword, start with a single ' quote character, and end with a colon : character. Loop labels may optionally be included after the escape keyword (without the terminal : character).

fn loop_labels() {
let mut count: i32 = 1;
loop {
let mut second_count: i32 = 101;
'second_loop: loop { //Loop label ends with :
println!("{second_count}");
second_count += 1;
if second_count == 110 {
break 'second_loop; //Loop label ends with ;
}
}
println!("{count}");
count += 1;
if count == 5 {
break;
}
}
}

The while loop

The while loop provides conditional looping statements. Conditional loops allow the program to evaluate a boolean to control whether to continue looping. As soon as the Boolean is evaluated to be false the program automatically calls break to end the loop. It is possible to construct while loop structures from loop, if, else, and break, but this pattern is so common that Rust ships with a built-in conditional loop structure for convenience. This is a bit of a cheap example, but it illustrates the syntax so we’re sticking with it.

fn while_loops() {
let mut i: i32 = 10;
while i >= 1 { //Use the loop expression to bind a variable
println!("{i}");
i -= 1;
};
println!("Blast off!");
}

Additionally, the while construct can be used with a let statement to repeat code as long as a pattern continues to match.

let mut stack = Vec::new();
stack.push(1);
stack.push(2);
stack.push(3);
while let Some(top) = stack.pop() {
println!("{}", top);
}
3
2
1

The for construct

The for loop is best used for iterating over a finite collection of elements. Rust’s for loop may appear trickier than similar for loop constructs in some other languages, at least on the surface. This is because the underlying logic relies on iterators which provide a large amount of flexibility in its use. In fact Rust’s for loop actually desugars into while loops over iterators. This follows the functional side of Rust’s design by making use of pattern matching logic.

The for loop consists of a header and a body just like the while loop. The for loop header contains an “iterator variable” and an “iterator expression”. Rust infers the iterator variable’s type from the base type of the items yielded by the iterator. The iterator is a product of the iterator expression. As a result iterator variables cannot be independently declared as a type. Many iterator varaibles represent primitive data elements such as array indexes, but Rust allows you to also use patterns as array variables. This is most commonly seen with destructuring.

for iterator_variable in iterator_expression {
...
}
for pattern in iterator_expression {
...
}

Ranges

There are two primary ways to define the iterator expression; ranges and iterators. The range expression is much more simple and resembles what you might be familiar with from a for loop in another language. In this example the loop prints out values from 1 to n. The ..= operator indicates that the range is lower and upper bounds inclusive. Use .. for ranges that are non-inclusive of the upper bounds definition. Easy.

fn for_loops() {
let n = 10;
for val in 1..=n {
print!("{val}, ")
}
}
1, 2, 3, 4, 5, 6, 7, 8, 9, 10,

Iterators

Ranges provide a direct and easy-to-read way to access elements of a set, but it is probably more common to deal directly with iterators in for loop headers. The general idea is that, when used properly iterators provide a more robust way to deal with out-of-bounds and off-by-one errors.

This section discusses parts of Rust that are best understood if you have an elementary grasp of iterators. Im not gonna tell you how to live your life, but it may be helpful to check out the Iterators section and Rust’s module-level documentation on iterators to understand them (and your options) deeper before proceeding to the rest of the material here. It’s mostly for reference anyway.

Consider the following example of a for loop that uses an iterator object in its iterator expression.

let a: [&str; 4] = ["a", "b", "c", "d"];
for val in a.iter() {
println!("{val}")
}

In this example val is the iterator variable and a.iter() is the iterator expression that evaluates to an iterator object. The iterator object yields elements of the collection a with each loop. The for loop header in this example might translate to natural language as, “for [each value “val”] in [the iterator object returned from the expression a.iter()]”.

As stated in the for loop section overview iterator expressions dictate the iterator value’s type. In the code snippet, the iter() method creates an iterator that yields immutable references to the elements in the collection. In this case, the iterator object yields references to the &str values stored in the array a. This means that each use of val in the loop body expects &str values. Each value in the iterator object (returned by the iterator expression) and the iterator value (val) are immutable references to the values in the array a.

Accessing values of collections with different base types is where things start to get a little tricky if you do not understand either iterators or the underlying logic of the for construct. Consider the following two functionally identical procedures. One of the functions uses a range for the iterator expression and the other uses an iterator in its iterator expression. Each of these functions take a vector and return a value equal to the sum of all of the vector’s constituent values.

pub fn array_sum_0(v: Vec<i32>) -> i32 {
let mut sum = 0;
for e in 0..v.len() {
sum += v[e]
}
sum
}
pub fn array_sum_1(v: Vec<i32>) -> i32 { // Base type is i32
let mut sum = 0;
for e in v.iter() {
sum += v[*e as usize] // Must index vector as usize
}
sum
}

Both of these functions work by creating an iterator under the hood. This is partialy why the expressions are referred to as iterator expressions. The parimary difference is how the iterator expression changes the iterator variable during the compilation process. This is evidenced by examining the sum statement for each function. The function that uses a range in its iterator expression can directly index the values of the vector as v[e], and the function that uses an iterator needs to dereference the iterator value (and cast it to usize) as v[*e as usize]. The secret here is that the for loop is really just syntactic sugar and Rust is actually making some default decisions for you in each scenario which can make using the for construct appear tricky if you’re not aware of what is actually happening. If you de-sugar the first function you get sommething like the following:

let mut iter: std::ops::Range<usize> = (0..v.len()).into_iter();
while let Some(i) = iter.next() {
// loop body
}

Note that this uses the into_iter() method which returns owned values of type usize instead of the iter() method which returns immutable references to the collection’s values. The following example illustrates this difference.

let j = vec![1, 2, 3, 4, 5, 6];
let k = 4;
// Produces an iterator that yields immutable references
// that must be dereferenced to access the value
for e in j.iter() {
if *e == k {
println!("End");
}
}
// Produces an iterator that yields owned values
// of type usize and can be used directly
for e in j.into_iter() {
if e == k {
println!("End");
}
}

As an aside, you may be wondering why Rust requres you to cast the de-referenced values returned by iter() to usize to index the base collection in the first comparison example.

pub fn array_sum_1(v: Vec<i32>) -> i32 { // Base type is i32
let mut sum = 0;
for e in v.iter() {
sum += v[*e as usize] // You must use usize values to index a vector
}
sum
}

There are two issues at play here. The first is that, for reasons out of the scope of this documentation, Rust uses usize to index all array and vector types. The second has to do with Rust’s type inference. Without explicit typing Rust assumes that a whole number is of type i32 by default. Keen eyes will notice that the example functions contain signatures that explicitly name the type parameters as Vec<i32>. The result is that the iter() method returns values of type &i32. This means that in order to use the interator’s yielded value to index the vector you need to both dereference and cast the value to usize. Mystery solved!

Iterator adapters

In a standard iterator, the iteration proceeds from the first element to the last element in the order they were defined or inserted. Iterators can be modified with custom and any number of out-of-the-box methods. Say you want to count down from an integer n. You can modify the iterator (in this case a range) with the std::iter::Iterator::rev() method. This reverses the iteration with something like (0..n).rev().

fn for_loops() {
// Using an iterator adapter on a range
let n = 10;
for i in (1..=n).rev() {
print!("{i}, ")
}
print!("Blast off!\n");
// Using an iterator adapter directly
let v = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
for i in v.iter().rev() {
print!("{}, ", *i);
}
print!("Blast off!\n");
}
10, 9, 8, 7, 6, 5, 4, 3, 2, 1, Blast off!
10, 9, 8, 7, 6, 5, 4, 3, 2, 1, Blast off!

Implicit conversions

Rust has more than a couple tricks up its sleeve designed to allow you to write more concise (or lazy) code. For example, the following for loop can be written with the same meaning without an explicit method on the iterator expression. This example also removes the explicit typing for the array because it’s pretty obvious that they’re &str values.

fn for_loops() {
let a = ["a", "b", "c", "d"];
// Explicit method handling
for val in a.iter() {
println!("{val}")
}
// Implicit method handling
for val in a {
println!("{val}")
}
}

In this variation Rust automatically creates an iterator object without needing to specify an iterator method. Specifically the implicit method handling defaults to the iter() method for the array type which creates an iterator that yields immutable references. Understanding these implicit rules is less important in understanding how the for loop works and more important for reading and writing concise code. Knowing these tricks may save you some headaches. That being said, its probably best to add the half-dozen extra characters to save the cognitive overhead when revising older code.

It is important to mention that in Rust the array type implements the IntoIterator trait for their immutable reference (&[T]) and mutable reference (&mut [T]) types by default. This means Rust is able to automatically create an iterator object from the array variable when it’s placed into the iterator expression position of the for loop header. This is also true of other collection types such as the Vector type. The Rust standard library implements a mix of iter() and into_iter() convenience methods for automatic iterator creation depending on the collection type. For example, Rust uses iter() with arrays, but into_iter() with vectors. This means that the results and implications of implicit conversion may be different depending on the base collection type.

Which loop should I use?

It is generally possible to write loops using any of the three constructs. For example, you can write a countdown with all three constructs. This is a bit of a contrived example showcasing a situation with a fixed, known number of iterations, but here we are anyway.

fn loop_countdown() {
let mut c = 10;
loop {
println!("{c}");
if c <= 1 {
println!("Blast off!");
break
}
c -= 1;
}
}
fn while_countdown() {
let mut c = 10;
while c >= 1 { //Use the loop expression to bind a variable
println!("{c}");
c -= 1;
};
println!("Blast off!");
}
fn for_countdown() {
for c in (1..=10).rev() {
println!("{c}")
}
println!("Blast off!")
}

Keep in mind that each construct has its place; just because you can doesn’t necessarily mean you should.

  • loop: Useful when you need to loop indefinitely until a certain condition is met or when you want to break out of the loop based on some internal condition
  • while: Useful when you need to repeat code based on a condition that may change during execution
  • for: Useful when you want to iterate over a collection of items, a range of values, or any type that implements the IntoIterator trait