Rust from the ground up

Functions, decisions and loops

Functions that take values and give one back, if and match as expressions, and the three loops Rust gives you — including the one that returns a value.

Part 3 of 12

Beginner15 min readBack to Courses

Functions say what they take and what they give

A function is introduced with fn, its parameters carry a type, and -> announces what comes back: fn double(n: i32) -> i32 { n + n }. The last expression — with no semicolon — is the value; an early return is allowed but rarely needed.

Because the return type is written, a mistake inside the function is caught at the function, not three calls away. If the body ends with a semicolon by accident, the value is (), the empty type, and the compiler says so directly.

if is an expression

if conditions take a real bool — there is no truthiness, no if count meaning "not zero". The branches must agree on their type, because the whole if is one value: let size = if n > 10 { "big" } else { "small" };.

else if chains work as you expect. When the choice is between many fixed cases, the next chapters use match, which checks that every case is covered.

Three loops

while repeats while a condition holds. for repeats over anything that hands out items, and that is almost always what you want: for n in 1..=20 { … } runs from 1 to 20 inclusive, and for name in names { … } walks a list without an index to get wrong.

loop repeats forever, unless you say otherwise — and it can end by handing a value out: let answer = loop { … break 42; };. It is how Rust writes "search until found", and the compiler checks that a loop which is supposed to produce a value always does.

Loops can be named, which matters when one sits inside another. 'outer: loop { … break 'outer; } leaves the named loop, not the innermost one, and the same label works with continue. Without a label, break always belongs to the loop it is written in, which is why a nested search can stop one level early.

The shapes side by side

fn label(n: u32) -> &'static str {
    if n % 15 == 0 {
        "fizzbuzz"
    } else if n % 3 == 0 {
        "fizz"
    } else if n % 5 == 0 {
        "buzz"
    } else {
        "number"
    }
}

fn main() {
    for n in 1..=15 {
        println!("{}: {}", n, label(n));
    }

    let mut attempts = 0;
    loop {
        attempts += 1;
        if attempts == 3 {
            break;
        }
    }
    println!("stopped after {} attempts", attempts);
}

FizzBuzz, the honest version

Run it, then change the label function: try 7 instead of 5, or print the number itself instead of the word "number". Then make the range count to 30.

Output
Click "Run" to execute your code

1..=20 includes 20; 1..20 stops at 19. Both are ranges, and both can be used in the same places — the difference is one equals sign, and it is one of the few places Rust uses a symbol for a meaning you have to remember.

The empty type, and early returns

A function that only prints returns (), the unit type — an empty value that says "nothing to hand back". println! returns () too, and so does an assignment. Seeing () in a compiler message usually means the last line of a function ended with a semicolon, so the value it was supposed to produce became an empty statement.

return is for leaving early, and it is most useful as a guard: check the impossible case at the top, return, and let the rest of the function read straight through. A return on the last line is noise; a return three lines in usually saves an else and a level of indentation.

Ranges are values too

1..5 and 1..=5 are ranges, and they can be stored in a variable, passed to a function, or walked with for. Reach for the half-open form by default, because lengths and indices are half-open: 0..len covers every item exactly once, and the empty range 0..0 is naturally valid.

Ranges chain with the iterator methods: (1..=10).rev() counts down, (0..20).step_by(5) takes every fifth value, and .filter() works on them like anything else. while is for a condition that is not a count — retrying, draining, searching — and its body has to change something, or the loop never ends.

A block is an expression

Braces are not only a container. A block { ... } is an expression, and its last line — written without a semicolon — is the block's value. That is exactly how a function body produces its result, and the same trick works anywhere: let x = { let a = 2; a * 3 }; binds 6, and the inner a stays inside the braces.

The shape is useful when a value needs a few lines of setup but should not leak its intermediates into the surrounding scope. The rule is the one that trips people up at the end of a function: add a semicolon and the block produces (), so the annotation and the value no longer agree.

Three mistakes stop a first Rust loop. A semicolon after the last expression turns the return value into (). if and else must produce the same type, because the whole if is one value. And break value only works inside loop; in a for or while, break carries nothing, so keep the answer in a variable declared before the loop.

Try these next

  • Write count_down(n) that prints from n to 1 and returns how many lines it printed.
  • Rewrite a while loop as a for over a range, and say which version reads better.
  • Add a guard clause at the top of a function that returns early when the input is zero.

Primes, with a guard and a while loop

Run it, then raise the limit to 50 and add a second function that returns the sum of the primes instead of printing each one.

Output
Click "Run" to execute your code

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

How does a function give a value back?