Lesson 05 / 12
Functions and Expression Orientation
A function's body is a block, and a block is already an expression: if the last line has no semicolon, that is the body's — and therefore the function's — return value; add a semicolon and the body returns (); the semicolon discards the value, but the discarded value's computation still happens, and this is shown through the discarded value's own side effect.
Published Updated
In this lesson
- Shows by running it how whether a body's last line ends with a semicolon determines the return value
- Proves with a side effect that a semicolon silently discards a value, but the discarded value's computation still happens
- Distinguishes by comparison that return is needed only to exit before the body's natural end, and is not needed in a tail expression
Contents
The previous four lessons established, in turn, the three classes, the source’s mandatory skeleton, binding, and primitive types. This lesson brings them all together at a single point: a function’s body is a block, and a block was already defined as an expression in Rust’s Design Goals. A function’s return value, therefore, is not a separate mechanism — it is the body block’s own value. This lesson carries the course’s axis in its clearest form: the presence or absence of a semicolon, by itself, determines what a function returns.
This explains why the concept of a “function” needs no separate return syntax in Rust. In many writing styles, a function’s return value is treated as its own structure, separate from the rest of the body. Rust instead reuses a rule already established: what happens when a function is called is that its body is evaluated as a block, and whatever the block evaluation’s result is, that is the function’s result too. There is no new concept to learn — the “a block is an expression” rule established in the previous lessons is enough to explain a function call in its entirety here.
The Body’s Last Line Is the Return Value
As seen in the previous lessons, a block gives the value of its last line if that line has no
semicolon; it gives () if it ends with a semicolon or is empty. Because a function’s body is
a block too, the same rule applies to it: if the body’s last expression takes no semicolon, the
function itself returns that value.
This rule applies not to every line of the body, but only to its last line. All the
intermediate lines in a function body are ordinary statements and usually end with a semicolon
— let bindings, calls with side effects, loops. Only the body’s very last line stands in a
special position: that is tail position, the only place that determines the value of the
block itself (and therefore the function). The example below shows the effect of this
single-line rule across two different bodies.
// function_body.rs — the body's last line determines the return value
fn compute_and_log(x: i32) -> i32 {
println!("computing: for {}", x);
x * 2
}
fn as_expression(x: i32) -> i32 {
compute_and_log(x)
}
fn as_statement(x: i32) {
compute_and_log(x);
}
fn main() {
let result = as_expression(21);
println!("as_expression return value: {}", result);
let statement_result = as_statement(21);
println!("as_statement return value: {:?}", statement_result);
}
computing: for 21 as_expression return value: 42 computing: for 21 as_statement return value: ()
as_expression and as_statement wrap the same call — both call compute_and_log(x) — but
their bodies’ last line was written differently. In as_expression the call has no semicolon,
the body’s tail expression; in as_statement a semicolon was added to the end of the same
call. This one-character difference forced both functions’ signatures to change too:
as_expression’s return type can be written as -> i32, while as_statement’s return type
was never written at all, because the default return type is () and the body now produces
(). These two signatures cannot stand in for one another — if -> i32 were written on
as_statement, the compiler would reject it because its body produces ().
LB13. This shows the same fact as the previous lesson’s unit: () example, at the
boundary of a function. A function’s return type must match the type of the value the body
produces exactly; if the body produces i32, the signature must write -> i32 or a
compatible type, if the body produces (), the signature must either write () or write
nothing at all. This match is checked at compile time — a mismatch between body and signature
is stopped before it ever reaches runtime.
This check has a concrete consequence: changing only as_statement’s signature to -> i32
without touching its body is not enough, because the body still produces (). Likewise,
adding a semicolon to the tail call in as_expression‘s body breaks the signature too — the
body would now produce () while the signature still wrote -> i32. Both cases are rejected
by the compiler; they are not placed into a source block because both are examples of a
rejection, but the result is this: this link between body and signature cannot be changed
one-sidedly anywhere in the source. Adding or removing a semicolon changes not only the body’s
last line, but also the promise the function makes to the outside world.
Limiting Measurement: A Semicolon Silently Discards a Value
The output’s first two lines are also worth noticing: the message “computing: for 21” was
printed twice — once for the as_expression call, once for the as_statement call. The
second call’s result (42) was used nowhere, statement_result held only (). But the print
line inside compute_and_log still ran, because the function’s body was run in full on both
calls; what changed was not what the result was, but where the result went.
This clarifies what a semicolon does: a semicolon does not cancel a computation, it only
discards that computation’s result. The writing compute_and_log(x); first runs
compute_and_log(x) in exactly the same way as its semicolon-free form — every line inside it
runs, every side effect inside it (here, printing) happens — then leaves the produced value
42 unsaved under any name. This discarding is silent: the compiler prints no warning, the
runtime leaves no trace; the only thing showing it was discarded is that the value itself
appears nowhere in the next line. The only way to make this silence visible in this lesson was
to place an independent side effect (printing) inside the computation — without the side
effect, we could not tell from the outside whether a multiplication was really running inside
as_statement.
This finding has a practical consequence: ignoring a function’s return value does not mean
ignoring the work inside that function. The call as_statement(21), even though its result is
never read, ran the entire body of compute_and_log. Whether a function is called only for a
side effect or only for a value is read from whether a semicolon is placed at the end of the
call in the source — both do the same work, the only difference is whether the result is read.
This observation also explains why a print macro call can carry both a side effect and a
return value: the call prints a line to the screen (side effect) and at the same time produces
the value () (return value). The two sides existing separately shows that using one does not
cancel the other — even if a call’s result is not read, every line inside that call still
runs.
This distinction is a reverse-angle view of the previous lesson’s Vec::push versus
HashSet::insert comparison. There, the same work (insertion) gave two separate methods two
separate classes, because the methods’ signatures differed. Here, the same call
(compute_and_log(x)) can fall into two separate classes depending on how the caller writes
it: used as an expression if written without a semicolon, used as a statement if written with
one. There, the class came from the method’s own signature; here it comes from the caller’s
choice — but in both cases, the work inside the body stays the same, independent of the class.
Where return Is Required
Given that the body’s last line determines the return value, when is the return keyword
needed? Answer: only when you want to exit before the body’s natural end. If a function
reaches an early result inside a conditional branch and does not want to run the rest of the
body at all, it must write that early exit with return — because that branch is not in the
body’s tail position, but in its middle.
This shows that return is a choice, not a requirement. Both functions in the previous
section were valid, and both compiled; the difference between them was how the body organized
branching. If a body can organize all its possible outcomes as branches of a single large
expression, that expression can be placed in tail position and the same result reached without
writing return at all. If a body wants to exit early in some cases without running the rest
of the logic at all, that exit cannot be in tail position — because tail position is, by
definition, the body’s very end, and an early exit means precisely “not going all the way to
the end.”
// early_return.rs — return is needed only to exit before the body's natural end
fn sign_with_return(x: i32) -> i32 {
if x < 0 {
return -1;
}
if x == 0 {
return 0;
}
1
}
fn sign_with_expression(x: i32) -> i32 {
if x < 0 {
-1
} else if x == 0 {
0
} else {
1
}
}
fn main() {
for value in [-5, 0, 5] {
println!("{}: with_return={} with_expression={}", value, sign_with_return(value), sign_with_expression(value));
}
}
-5: with_return=-1 with_expression=-1 0: with_return=0 with_expression=0 5: with_return=1 with_expression=1
Both functions produce the same three results, but only sign_with_return uses return. In
sign_with_return, every if block stands in the middle of the body — the body continues
after them with the line 1 — so a branch wanting to exit early must declare its own value
explicitly with return; otherwise that if block itself is evaluated as an expression, but
its result is bound nowhere and the body continues on to its own natural end. There is a point
worth noticing here: the if block itself in the writing if x < 0 { return -1; } still
produces (), because return jumps from inside the block to the whole function, leaving no
value to the block itself. return‘s effect crosses the block boundary; while the semicolon
and tail-position rules only determine that block’s own value, return jumps directly to the
outermost function call. In sign_with_expression, on the other hand, all the branching was
gathered into a single if/else if/else chain, and this chain itself became the body’s
tail expression; no branch exits early, all three are evaluated as part of the same large
expression, and the branch the chain selects becomes the function’s return value directly.
LB14. return does not change an expression’s value; it determines when an expression
is evaluated, or more precisely, that the rest of the body will not be evaluated at all. In
sign_with_return, if x < 0 is true, no line after return -1; runs — not even the second
if block is evaluated. In sign_with_expression, because all three branches are part of the
same chain, only one is selected, but there is no concept of “exiting early”; even though the
branches not selected are never evaluated, this is ordinary branching behavior, not an early
exit.
The output does not directly show this difference — for all three values, the two functions
give the same result, because both encode the same logic with a different arrangement. To see
where the difference lies, one must look not at the number of lines that ran, but at the
shape of the control flow in the source: in sign_with_return, for a negative input, only
one if body runs and the function exits right there; in sign_with_expression, for the same
input, all three branches are evaluated as part of the same chain, and only one is selected.
Both approaches reach the correct result, but one has a moment that can be called an “early
exit,” the other has none at all — there, there is only a selection.
The choice between these two writing styles is a question of readability, not correctness:
both produce the same three results, and both are equally valid. But as a body grows longer
and the number of special cases requiring early exit grows, the form written with return
generally simplifies the rest of the body by moving special cases out of the tail expression to
their own control points; bodies simple enough to be expressed with a single branch can reach
the same result with a single tail expression, without writing return.
sign_with_return‘s structure makes this concrete: two special cases (x < 0, x == 0) are
handled in their own control lines and separated from the rest of the body, while the one
remaining general case (1) sits at the body’s natural end, tied to no condition. A reader can
read the first two ifs as “special case, exit here,” and the last line as “this is now the
one remaining possibility.” In sign_with_expression, all three cases are presented with
equal weight, as parts of a single chain — none is “earlier” or “more exceptional” than
another. Both writings are correct, but the signal they give a reader differs: one says “most
of the time it is the one line left behind, everything before it is a deviation,” the other
says “all three cases matter equally.”
There is one more pattern in functions written with return: the body’s natural end usually
carries the most general or most expected case, because reaching it means having passed all
the earlier special checks. This gives a reader going through the source top to bottom a kind
of ordering: the most commonly reached path is reduced to a minimum, and special cases are
pulled forward and eliminated. This ordering is not a rule Rust requires; it is a habit born
from return’s natural way of being used.
This lesson showed why the three classes (expression, statement, item) established in the
course’s first lesson form a valid frame not only for individual lines, but for a whole
function body too. An fn declaration is an item and produces no value itself; but that
item’s body is a block, a block is an expression, and what an expression produces — here too,
as always — depends on whether its last line carries a semicolon. The smallest unit in the
source (a single line) and one of the largest units (an entire function) can be read by the
same rule; the classification established at the start of the course gains no new exception as
its scale grows.
Summary
- A function’s body is a block; a block is already an expression, and produces the value of
its last line if that line has no semicolon,
()if it does — the function’s return value is this produced value. - The type of the value the body produces must match the function’s declared return type exactly; this match is checked at compile time.
- A semicolon does not cancel a computation, it only silently discards its result; side effects inside the discarded computation still happen.
returnis needed only to exit before the body’s natural end; if all branching can be gathered into a single tail expression,returnis never written.- Two writings that produce the same result (with an early exit and with a tail expression) are equally valid; the choice concerns readability, not correctness.
Next Step
In this lesson, the if/else if/else chain was already used as an expression, but which of
the five control-flow forms (if, match, loop, while, for) produce a value was not
measured one by one. The next lesson runs these five side by side and shows why an if‘s
branches always have to be the same type, and when a loop produces a value and when it never
does. That all three branches of sign_with_expression return i32 was not a coincidence — if
one of the branches returned a different type, this body would not compile at all; the source
of this restriction is the next lesson’s subject.
To keep your progress and take notes, Log in
My notes
Log in to take notes.