Rust’s focus on expressions is an underrated aspect of the language. Code feels more natural to compose once you embrace expressions as a core mechanic in Rust. I would go as far as to say that expressions shaped the way I think about control flow in general.
“Everything is an expression” is a bit of an exaggeration, but it’s a useful mental model while you internalize the concept.[1]
But what’s so special about them?
Expressions produce values, statements do not.
The difference between expressions and statements can easily be dismissed as a minor detail. Underneath the surface, though, the fact that expressions return values has a profound impact on the ergonomics of a language.
In Rust, most things produce a value: literals, variables, function calls, blocks, and control flow statements like if
, match
, and loop
.
Even &
and *
are expressions in Rust.
Expressions In Rust vs other languages
Rust inherits expressions from its functional roots in the ML family of languages; they are not so common in other languages. Go, C++, Java, and TypeScript have them, but they pale in comparison to Rust.
In Go, for example, an if
statement is… well, a statement and not an expression. This has some surprising side-effects. For example, you can’t use if
statements in a ternary expression like you would in Rust:
// This is not valid Go code!
var x = if condition else ;
Instead, you’d have to write a full-blown if
statement
along with a slightly unfortunate upfront variable declaration:
var x int
if condition else
Since if
is an expression in Rust, using it in a ternary expression is perfectly normal.
let x = if condition else ;
That explains the absence of the ternary operator in Rust (i.e. there is no syntax like x = condition ? 1 : 2;
).
No special syntax is needed because if
is comparably concise.
Also note that in comparison to Go, our variable x
does not need to be mutable.
As we will see, Rust’s expressions often lead to less mutable code.
In combination with pattern matching, expressions in Rust become even more powerful:
let = if condition else ;
Here, the left side of the assignment (a, b) is a pattern that destructures the tuple returned by the if-else
expression.
What if you deal with more complex control flow?
That’s not a problem. match
is an expression, too.
It is common to assign the result of a match
expression to a variable.
let color = match duck ;
Combining match
and if
Expressions
Let’s say you want to return a duck’s color, but you want to return the correct color based on the year. (In the early Disney comics, the nephews were wearing different colors.)
let color = match duck ;
Neat, right? You can combine match
and if
expressions to create complex logic in a few lines of code.
Note: those if
s are called match arm guards, and they really are full-fledged if
expressions.
You can put anything in there just like in a regular if
.
Lesser known facts about expressions
break
is an expression
You can return a value from a loop with break
:
let foo = loop ;
// foo is 1
More commonly, you’d use it like this:
let result = loop ;
// result is 20
dbg!()
returns the value of the inner expression
You can wrap any expression with dbg!()
without changing the behavior of your code (aside from the debug output).
let x = dbg!;
Real-World Refactoring With Expressions
So far, I showed you some fancy expression tricks, but how do you apply this in practice?
To illustrate this, imagine you have a Config
struct that reads a configuration file from a given path:
/// Configuration for the application
/// Creates a new Config with the given path
///
/// The path is resolved against the home directory if relative.
/// Validates that the path exists and has the correct extension.
Here’s how you might implement the with_config_path
method in an imperative style:
There are a few things we can improve here:
- The code is quite imperative
- Lots of temporary variables
- Explicit mutation with
mut
- Nested if statements
- Manual unwrapping with
is_none()
/unwrap()
Tip 1: Remove the unwraps
It’s always a good idea to examine unwrap()
calls and find safer alternatives.
While we “only” have two unwrap()
calls here, both point at flaws in our design.
Here’s the first one:
let mut config_path;
if path.is_absolute else
We know that home
is not None
when we unwrap
it, because we checked it right above.
But what if we refactor the code? We might forget the check and introduce a bug.
This can be rewritten as:
let config_path = if path.is_absolute else ;
Or, if we introduce a custom error type:
let config_path = if path.is_absolute else ;
The other unwrap
is also unnecessary and makes the happy path harder to read.
Here is the original code:
if config_path.is_file
We can rewrite this as:
if config_path.is_file
Or we return early to avoid the nested if
:
if !config_path.is_file
let Some = config_path.extension else
if ext != "conf"
Tip 2: Remove the mut
s
Usually, my next step is to get rid of as many mut
variables as possible.
Note how there are no more mut
keywords after our first refactoring.
This is a typical pattern in Rust: often when we get rid of an unwrap()
, we can remove a mut
as well.
Nevertheless, it is always a good idea to look for all mut
variables and see if they are really necessary.
Tip 3: Remove the explicit return statements
The last expression in a block is implicitly returned
and that return
is an expression itself, so you can often get rid of explicit return
statements.
In our case that means:
return Ok;
becomes
Ok
Another simple heuristic is to hunt for returns
and semicolons in the middle of your code.
These are like “seams” in our program; stop signs, which break the natural data flow.
Almost effortlessly, removing these blockers often improves the flow; it’s like magic.
For example, the above validation code can also be written without returns:
match config_path
I like that, because we avoid one error message duplication and all conditions start on the left.
Whether you prefer that over let-else
is a matter of taste. [2]
Don’t take it too far
Remember when I said “everything is an expression”? Don’t take this too far or people will stop inviting you to dinner parties.
It’s fun to know that you could use then_some
, unwrap_or_else
, and map_or
to chain expressions together, but
don’t use them to show off.
The below code is correct, but the combinators get in the way of readability. Now it feels more like a Lisp program than Rust code.
Keep your friends and colleagues in mind when writing code. Find a balance between expressiveness and readability.
Conclusion
If you find that your code doesn’t feel idiomatic, see if expressions can help. They tend to guide you towards more ergonomic Rust code.
Once you find the right balance, expressions are a joy to use – especially in smaller context where data flow is key. The “trifecta” of iterators, expressions, and pattern matching is the foundation of data transformations in Rust. I wrote a complementary article about iterators here.
Of course, it’s not forbidden to mix expressions and statements!
For example, I personally like to use let-else
statements when it makes my code easier to understand.
If you’re unsure about whether using an expression is worth it, seek feedback from someone less familiar with Rust.
If they look confused, you probably tried to be too clever.
Now, try to refactor some code to train that muscle.
-
The Rust Reference puts it like this: “Rust is primarily an expression language. This means that most forms of value-producing or effect-causing evaluation are directed by the uniform syntax category of expressions. Each kind of expression can typically nest within each other kind of expression, and rules for evaluation of expressions involve specifying both the value produced by the expression and the order in which its sub-expressions are themselves evaluated.” ↩
-
By the way,
let-else
is not an expression, but a statement. That’s because theelse
branch doesn’t produce a value. Instead, it moves the “failure” case into the body block, while allowing the “success” case to continue in the surrounding context without additional nesting. I recommend reading the RFC for more details. ↩