Expression-Based Programming
The Distinction Between "Statements" and "Expressions"
Many programming languages draw a clear line between "statements" and "expressions":
- Statement: Performs an action but doesn't return a value (e.g.,
ifstatement,forstatement, variable declaration) - Expression: Evaluates to produce a value (e.g.,
1 + 2, function call, literal)
In languages like C, Java, JavaScript, and Python, if and for are "statements" and cannot return values.
// JavaScript: if is a statement, so you can't assign it to a variable
// let x = if (condition) { 1 } else { 2 }; // SyntaxError
// You need the ternary operator to write it as an expression
let x = condition ? 1 : 2;
In Rust, if, match, blocks {}, loop -- these are all expressions. They can return values.
Rust's Expression-Oriented Design
if Expressions
let x = if condition { 1 } else { 2 };
We introduced this in Bitterless Rust. What's important here is that this isn't "alternative syntax for a ternary operator" -- it's based on the design that if is fundamentally an expression.
Rust has no ternary operator (? :). It doesn't need one. Since if itself is an expression, it can fulfill the exact role a ternary operator would. There's no need to provide duplicate syntax.
match Expressions
let label = match status_code {
200 => "OK",
404 => "Not Found",
500 => "Internal Server Error",
_ => "Unknown",
};
match is also an expression, so you can bind its value directly to a variable. The result of each arm becomes the value of the entire match expression.
Block Expressions
In Rust, a block surrounded by curly braces {} is also an expression, and the last expression in the block becomes the block's value:
let result = {
let a = 10;
let b = 20;
a + b // No semicolon -> this value becomes the block's value
};
// result == 30
This lets you group complex calculation logic within a single block while directly binding the result to a variable. Intermediate variables don't leak outside the scope.
loop Expressions
let answer = loop {
let input = get_user_input();
if is_valid(&input) {
break input; // Passing a value to break -> becomes the loop expression's value
}
println!("Invalid input, try again.");
};
When you pass a value to break, it becomes the evaluation result of the loop expression.
Function Bodies Are Expressions Too
A function body is a block expression, and its last expression (without a semicolon) becomes the return value directly:
fn double(x: i32) -> i32 {
x * 2 // No return needed, no semicolon
}
The return keyword is only used when you need to exit a function early. Returning a value with the last expression is the Rust idiom.
Influence from ML-Family Languages
Rust's expression-oriented design is heavily influenced by ML-family languages (OCaml, Haskell, F#, etc.). In ML-family languages, almost everything that makes up a program is an expression that returns a value.
Let's look at the concrete benefits this design brings.
1. Combining Immutable Bindings with Expressions
// Declare a condition-dependent value as an immutable variable
let message = if user.is_admin() {
format!("Welcome, admin {}!", user.name)
} else {
format!("Hello, {}!", user.name)
};
// message is immutable. It won't be changed later.
In an imperative style, you'd need to make message mut and assign inside the conditional branches:
// Imperative style (possible in Rust, but not idiomatic)
let mut message = String::new();
if user.is_admin() {
message = format!("Welcome, admin {}!", user.name);
} else {
message = format!("Hello, {}!", user.name);
}
With the expression-based style, mut is unnecessary, and the type system guarantees the variable's value won't change after initialization.
2. Composing Nested Expressions
let category = match score {
90..=100 => "A",
80..=89 => "B",
70..=79 => "C",
_ => "F",
};
let output = format!(
"Student: {}, Score: {}, Grade: {}",
name,
score,
category,
);
Expressions can be freely combined and composed. You can pass a match expression's result directly to the format! macro, or bind it to an intermediate variable first.
3. Balancing with Early Return Patterns
Rust's expression orientation isn't mutually exclusive with early return patterns. Using both appropriately is practical:
fn process(input: &str) -> Result<Output, Error> {
// Early return: eliminate error conditions first
if input.is_empty() {
return Err(Error::EmptyInput);
}
let parsed = parse(input)?;
// Expression-based: logic for the happy path
let result = match parsed.kind {
Kind::A => process_a(parsed.data),
Kind::B => process_b(parsed.data),
Kind::C => {
let intermediate = transform(parsed.data);
finalize(intermediate)
}
};
Ok(result)
}
The Meaning of Semicolons
In Rust, the semicolon ; functions as an operator that converts an expression into a statement:
x + 1-> Expression. Has ani32valuex + 1;-> Statement. Returns Unit type()(discards the value)
This is why you can control a function's return value by the presence or absence of a semicolon on the last expression:
fn returns_value() -> i32 {
42 // Expression: returns i32
}
fn returns_unit() {
42; // Statement: discards the value, returns ()
}
The fact that semicolons carry semantic meaning is fundamentally different from C-family languages, where semicolons serve as purely grammatical delimiters.
The Unit Type ()
To explicitly represent "no return value," Rust uses the Unit type (). This is a concept inherited from ML-family languages -- "a type with only one possible value." The value is also written ().
fn greet(name: &str) -> () {
println!("Hello, {}!", name);
}
// -> () can be omitted; the meaning is the same when omitted
The existence of the Unit type maintains the consistency that all functions "return a value of some type." In C-family languages with void, "functions that return nothing" must be treated as a special case in the type system. In Rust, they're handled uniformly as the ordinary type ().
This uniformity shines when combined with generics:
// Works perfectly fine even when T is ()
fn execute<T>(f: impl FnOnce() -> T) -> T {
f()
}
let value: i32 = execute(|| 42);
let unit: () = execute(|| println!("side effect"));
Summary
Rust's expression-oriented design isn't "syntactic convenience" -- it's a design decision backed by the theoretical foundations of ML-family languages:
if,match, blocks,loopare all expressions that return values- Semicolons function as operators that convert expressions into statements
- The Unit type
()maintains the consistency that all functions return a value - Immutable bindings combined with expressions let you bind conditional results without reassignment
Together, these enable a programming style that naturally fuses imperative and functional approaches. This is one reason Rust is called a "multi-paradigm" language.