Conditionals and Error Handling
Learning objectives
- You know how to control execution flow with conditional statements in Rust.
- You know how to handle null values in Rust.
- You know how to handle errors in Rust.
Control flow is the order in which the computer executes statements in a program. We have already manipulated control flow through calling functions — the computation flow of the program jumps into the called function instead of directly continuing on the next line of the code.
For more fine-grained control, we can use conditional statements such as if statements, which we'll look at next.
If, else if and else
We can use if
, else if
and else
keywords combined with conditions to control the flow of execution, or in other words, to decide what to execute and when. The syntax is similar to many other languages such as C, Java and Python.
As an example, the following code snippet prints out the polarity of the integer x
.
The condition operand (e.g. x > 0
in the example) is the expression after the if
keyword which determines whether to enter or skip the if block. The condition operand must evaluate to a boolean value, i.e. true
or false
. Numbers 1
and 0
do not equal true
and false
in Rust like in the C language.
Note also that the condition operand is not surrounded by parentheses, since they are not necessary in Rust unlike in many other languages. The compiler will even warn if we add unnecessary parentheses around the condition operand.
On the other hand, the curly braces for the if block are mandatory and the if block must contain at least one statement, lest we face the wrath of the compiler.
Ifs are expressions
Similarly to code blocks, an if statement is an expression like any other expression in Rust. Leaving out semicolons from each branch causes the if expression to evaluate into the value. When the branches end in semicolons, the if expression evaluates to the unit value ()
.
By leaving out the semicolon from an if expression, we can return the value of the if expression as a value from a function just like any other value.
A conditional ternary operator is a common feature in many programming languages that allows concise syntax for simple if-else expressions. Most commonly (for instance in C, JavaScript and Dart) the ternary operator is represented by a question mark (?
) for the true condition and a colon (:
) for the false condition. For example in C we could do the following.
Rust does not have such a ternary operator, but since the if statements in Rust are expressions, we can achieve the same result almost as easily (but more expressively).
When if is used as an expression, all cases must be covered by the branches and the branches must evaluate to the same type (unless returning early, e.g. with the return
keyword). Otherwise, the compiler will complain.
We can use the return
keyword to return a value from a function early. The value after return
will be the value of the function call and the function will exit immediately.
In more complex code, returning early with guards can be a good way to keep the control flow readable by avoiding deeply nested blocks.
The convention in Rust is to not use return
keyword when same behavior is achieved by removing the semicolon from the lines that return a value.
By running the Rust linter Clippy with cargo clippy
for the above code, we get a code style suggestion to remove the unnecessary return keywords.
After modifying the code as Clippy suggests, it will still behave in the exact same way.
Clippy will not suggest removing the return
keyword when doing so would change the behavior of the program, such as when using early returns.
Comparison operators
An if statement need to be paired with a boolean expression (an expression that evaluates to either true
or false
) after the if
keyword. We can write boolean expressions using comparison operators like we did in the previous examples.
The following table lists the comparison operators available in Rust.
Operator | Meaning |
---|---|
== | equals |
!= | not equals |
< | less than |
> | greater than |
<= | less than or equals |
>= | greater than or equals |
The logical operators !
(negation), &&
(and) and ||
(or) may be applied to boolean expressions. The result will also be a boolean and thus valid for use in an if statement.
Operator | Meaning |
---|---|
! | negation |
&& | and |
|| | or |
As an example, we can use the logical and operator (&&
) to reduce the number nesting levels.
Integer comparison works like we are used to and have already seen.
Floating point comparison on the other hand might not work as we expect due to the way floating point numbers are encoded in bytes — floating point encoding is not a topic for this course but more info can be found here.
The important thing to notice here is that floating point calculations are not always exact.
For more reliable floating point comparison, we can use an error margin suitable for our purposes and see if the values are within the margin's radius of each other. Rust provides a handy constant EPSILON
that can be used as a baseline error margin.
Matching patterns
Besides the if expression for conditional control flow, Rust has a match expression that is based on pattern matching. The simplest pattern is a literal value, like the boolean value true
. Matches are always exhaustive, meaning that all possible values of the type must be covered by the patterns.
The match syntax consists of the match
keyword, a scrutinee expression, and match arms. The value of the scrutinee expression is compared to patterns in the arms, which are enclosed in braces {}. Each arm has a pattern, followed by a fat arrow =>
and a target expression. Like with if
and else if
conditions, the matching is done from top to bottom, and the first matching arm of the expression is evaluated.
Switch to match
The match
expression in Rust is a lot like the switch
statement you may have come across in other programming languages, but more functional and powerful as it is an expression and supports pattern matching.
As stated earlier, all values of the type must be covered by the patterns. For booleans it is enough for the patterns to cover the two values true
and false
. For integers, we need to match the full range an integer can take. We can achieve this by using range patterns with the ..
operator. The pattern ..a
matches all numbers from minimum (e.g. i32::MIN
for i32
) to a
, a..
from a
to maximum, and a..=b
from a
to b
(inclusive range).
We can use a variable name as the pattern in the final arm in case we don't want to explicitly match all possible values (or it is impossible, like when working with values such as strings that can take an arbitrary number of values). Using a variable as a pattern will match any value and bind it to the variable name.
This leaves us a redundant variable though, since we might as well use the scrutinee instead of the new variable as in the above example. Also, in many cases we don't need to do anything with the value that matched the pattern, but the compiler will warn us about any unused variables, as it should.
To get rid of the warning, we can use the underscore _
wildcard pattern. Like a variable name, this pattern will match any value but also ignore the value, thus keeping us safe from unused variable warnings.
The above example also shows how to combine patterns into one with |
.
Match target expressions (which come after the =>
) work the same as branches in an if expression. The expressions can be any expression as long as they evaluate to a value of the correct type or return early from the function.
Match guards
We can use match guards to add conditions for when to match arm patterns. A match guard is defined by adding an if
keyword and a condition after the pattern in a match arm.
The condition is evaluated after the pattern is matched, and the arm is only evaluated if the condition is true. The compiler isn't however capable of determining whether a collection of guards cover all the possible cases of a pattern. To fix the above example, we can remove the final guard to have the wildcard _
capture all the remaining possible values.
Handling errors by panicking
Errors are bound to happen in real-world programs and they need to be handled accordingly. Rust has a few mechanisms to handle errors, and one of them is panicking — panicking is the technical term used for crashing and stopping the execution of a Rust program.
A Rust program can panic in two ways, either by an action that leads to panic, such as integer division by zero, or by explicitly calling Rust's panic!
macro.
The panic!
macro is used to indicate that something went wrong and the program will not continue. It takes a format string as its argument like the print
and println
macros, and the argument is printed as the error message when the program halts.
As a more practical example, consider a program responsible for handling the state of a drink machine that can only hold 50 drinks in total at a time. If the program states that the machine has more drinks than it can physically hold, it should panic and show an error message.
Optional values
Many programming languages incorporate a special value called null to represent the absence of a value. Consider for example the previous example function that matched integers to their respective Roman numerals and returned an empty string when given an unsupported number. Assuming that we don't want to panic instead of returning a value, the function behavior would be clearer if in such a case it would return an empty (null) value (an empty string is not an empty value).
Rust does not allow a value of a variable to be empty or null. Instead the possible absence of a value is represented by the Option
type.
The Option type
The Option
type is an enum with two variants: Some
and None
. The Some
variant indicates that the value is present, while the None
variant indicates that the value is absent.
Using the Option
for null values is Rust's way of making the presence of null values explicit in the code. This allows null errors to be easily identified during compilation so that they can be quickly found and fixed before running the program.
With the Option
type, the previous Roman numeral example can be rewritten as
This makes it clear that the function can return a null value, and the caller of the function knows that the null value needs to be handled appropriately. In the example function, the null values are handled using the unwrap_or_default()
method, which returns a default value for the given type — an empty string for a String
in the example's case.
The inner type of Option
is generic, so Option
can be used with any type. The inner type of the option needs to be included in Option
's type signature with angle brackets <>
, like Option<String>
in the above example.
Printing an Option and debug formatting
Some types in Rust cannot be printed out normally, but support debug formatting which is commonly used for printing more information about the value. Option
is one such type.
To print out such values, the debug formatting parameter ?
can be used in the format string of the print(ln)!
macros using {:?}
instead of just the braces {}
.
As a side note, printing a String
with {:?}
will allow us to see the more clearly if the string is empty.
Sometimes we want to debug what's happening. Writing println!("{variable:?}")
every time is quite inconvenient — luckily Rust has a convenient macro for doing the same thing: dbg!
.
Not only does the dbg!
macro print the filename, line number and debugged expression, it also returns the argument so that we can debug parts of expressions without having to extract them to a variable! Similar to the print macros is the format!
macro that writes formatted text to a string. The format!
macro is especially useful for concatenating strings and data types.
Handling null values
A simple way to handle null errors is by explicitly causing the program to crash if the value is None
. This can be done by unwrapping the Option
using the method unwrap()
.
When we want to avoid crashing, a common way to handle null values is to define a default value in case the Option
is None
— like in the Roman numerals example with the unwrap_or_default()
, which returns a default value based on the Option
s inner type.
We can also provide a custom default value with the unwrap_or
method.
Another useful method is expect
, which causes panic like unwrap
, but also allows us to specify a custom error message to be shown after the panic.
None and its type
While floating point values require explicit type information for certain operations, there are also cases where simply defining a variable is not allowed without explicit typing. For instance, if we try to assign None
(a "null" value) to a variable, the compiler would not allow it, because it cannot infer the type of the None
value nor the type of the variable.
Rust is able to hint that None
is of type Option<T>
, but it also needs to know what it is an option of — that's the <T>
part in the type. To fix the error, we need to define the data type of the variable in the code (to see the solution, press the red icon). In the solution, None
was arbitrarily chosen to be an Option
of i32
.
Destructuring Options and if let
Patterns can be used to destructure complex values such as enums. This enables capturing the possible inner value of an Option
in a match arm with concise syntax.
However, one does not simply assign a variable to the Some
variant of an Option
because the None
case would not be covered.
There can be times when we are only interested in a single pattern and want to ignore the rest. This can make using match cumbersome since it forces us to handle each possible case.
Instead, we might resort to the if statement to check whether the value is Some
or None
and then use the unwrap
method to get the value out of the Some
variant.
To make things easier, Rust provides the if let
expression. Using the if let
syntax we can refactor the code into.
The if let
syntax takes the form if let $pattern = $expression
. If the lefthand pattern matches the righthand expression, Rust can safely unwrap the expression some_value
into value
. The same logic works for all enums in Rust.
Note that if let
requires the lefthand and righthand sides to be the same type.
Recoverable errors
We have already been introduced to the Option
type that is used to convey null values and resulting errors. Another error handling type in Rust is the Result
type, which is encountered often in typical Rust code — Rust does not have a try-catch clause that is common in imperative languages, instead it has the Result
type for handling recoverable (non-crashing) errors.
For example, trying to parse a string into a number with the parse
method returns a Result
type.
The Result type
The Result
type, much like Option
with its variants Some
and None
, is an enum with two variants, Ok
and Err
. Unlike None
, Err
wraps a value which indicates some kind of an error. The compiler needs to always know the types of both Ok
and Err
variants.
Working with Result
s is similar to working with Option
s. We can use multiple methods to work with Result
s, such as unwrap
(may panic), except
(may panic), unwrap_or
, is_ok
, is_err
, etc. Here, as well as for other types, the exhaustive list can be found in Rust's documentation.
As with Option
variants, we can use the match
expression to pattern match the Ok
and Err
variants and destructure the values inside. Likewise, the if let
expression can be used to handle the Ok
and Err
variants.
Returning a Result
from a function requires both Ok
and Err
to be explicitly defined. Below is an example in a program that mimics (poorly) a general artificial intelligence.
Summary of symbols
Symbol | Description |
---|---|
if | Starts a conditional statement. |
if let | Combines if and let for a concise way to handle values that match a single pattern (e.g. if let Some(value) = foo { /* do stuff with value */ } ). |
match | Matches patterns. The first matching arm is evaluated and every possible value needs to be covered. |
panic! | Crashes and stops a running program. |
Hi! Please help us improve the course!
Please consider the following statements and questions regarding this part of the course. We use your answers for improving the course.
I can see how the assignments and materials fit in with what I am supposed to learn.
I find most of what I learned so far interesting.
I am certain that I can learn the taught skills and knowledge.
I find that I would benefit from having explicit deadlines.
I feel overwhelmed by the amount of work.
I try out the examples outlined in the materials.
I feel that the assignments are too difficult.
I feel that I've been systematic and organized in my studying.
How many hours (estimated with a precision of half an hour) did you spend reading the material and completing the assignments for this part? (use a dot as the decimal separator, e.g 8.5)
How would you improve the material or assignments?