Control Flow · about 50 min

Practice: Small Programs with Loops

Why this matters

You now have the whole basic toolkit: values, variables, functions, input, if, loops and match. Knowing the pieces is not the same as being able to build something with them. This lesson is about the step in between: taking a problem stated in words and turning it into a working program, with a method you can repeat. The exercises are small but realistic, the kind of thing you write constantly as a working programmer.

A method for small problems

  1. Restate the problem with examples. Write down a few inputs and the exact outputs, including at least one awkward case (zero, empty input, a negative number, the largest value).
  2. Find the shape. Is it "for each input line, do X"? "Search until found"? "Accumulate a total"? Most small programs are one of the loop shapes from earlier lessons.
  3. Split off functions for any part with a clear name: is_prime, reverse, parse_command. A function can be reasoned about and tested on its own.
  4. Write the simplest version, run it on your examples, then handle the awkward cases.
  5. Read the output carefully against the specification: spacing, order, the last line.

Let us apply it to three problems.

Worked example 1: greatest common divisor

Problem: read two positive integers, print their greatest common divisor.

Examples: (12, 18) gives 6; (17, 5) gives 1; (7, 7) gives 7.

Shape: a computation in a loop. Euclid's algorithm says gcd(a, b) = gcd(b, a % b), and gcd(a, 0) = a. Each step makes the numbers smaller, so it terminates.

fn gcd(mut a: u64, mut b: u64) -> u64 {
    while b != 0 {
        let r = a % b;
        a = b;
        b = r;
    }
    a
}

fn main() {
    for (a, b) in [(12, 18), (17, 5), (7, 7), (1071, 462)] {
        println!("gcd({a}, {b}) = {}", gcd(a, b));
    }
}
gcd(12, 18) = 6
gcd(17, 5) = 1
gcd(7, 7) = 7
gcd(1071, 462) = 21

mut a: u64 in the parameter list makes the local copy of the parameter mutable; the caller's values are untouched. Trace one call by hand: (12, 18) becomes (18, 12), then (12, 6), then (6, 0): answer 6. Tracing by hand on paper is the single best debugging technique for loops.

Worked example 2: running statistics over input

Problem: read numbers from stdin, one per line, and print the count, minimum, maximum and average (two decimals). With no numbers, print no data.

Shape: accumulate over lines. Awkward cases: empty input (no minimum exists, and division by zero in the average), a single number, negative numbers.

use std::io;

fn main() {
    let mut count = 0;
    let mut sum: i64 = 0;
    let mut min = i64::MAX;
    let mut max = i64::MIN;
    for line in io::stdin().lines() {
        let line = line.unwrap();
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let x: i64 = line.parse().unwrap();
        count += 1;
        sum += x;
        if x < min {
            min = x;
        }
        if x > max {
            max = x;
        }
    }
    if count == 0 {
        println!("no data");
        return;
    }
    println!("count {count}, min {min}, max {max}, average {:.2}", sum as f64 / count as f64);
}
count 4, min -7, max 12, average 3.00

Starting min at i64::MAX and max at i64::MIN means the first number always replaces them; this is an alternative to "start with the first element" that works when you do not have the first element yet. return in main ends the program early (with exit status 0).

Worked example 3: a command interpreter

Problem: read commands like push 3, pop, show, and react to each.

The key new tool is splitting a line into words. line.split_whitespace() is an iterator over the words of a line (separated by any amount of spaces or tabs). Calling .next() on it yields the next word, wrapped in Some, or None when there are no more:

fn main() {
    let line = "  add   42  ";
    let mut words = line.split_whitespace();
    println!("{:?}", words.next());
    println!("{:?}", words.next());
    println!("{:?}", words.next());
}
Some("add")
Some("42")
None

.unwrap() takes the word out of the Some (and panics on None, a missing word). Combined with match on the command word, this gives a compact command loop:

use std::io;

fn main() {
    let mut total = 0;
    for line in io::stdin().lines() {
        let line = line.unwrap();
        let mut words = line.split_whitespace();
        match words.next() {
            Some("add") => {
                let n: i32 = words.next().unwrap().parse().unwrap();
                total += n;
            }
            Some("show") => println!("total = {total}"),
            Some(other) => println!("unknown command {other}"),
            None => {}
        }
    }
}
total = 42
unknown command jump

Matching directly on words.next() handles the empty line (None) and the command at once. Some("add") is a pattern that matches only a Some holding exactly "add"; Some(other) matches any other word and binds it. You are getting a preview of module 8 here; it is fine to use this pattern before you know every detail of Option.

Edge cases are the job

Look at how much of each example is about awkward cases: the empty line, the empty input, the first element, the missing word. That is typical. The "main" logic is often a few lines; correctness lives in the boundaries. The exercise checkers in this course always include such cases, so get used to listing them before you start.

Common mistakes

Printing separators wrongly. "Numbers separated by spaces" means no space before the first one. Print the separator before every item except the first, or collect the items and join them (module 9).

Resetting an accumulator inside the loop. let mut total = 0; inside the loop body creates a fresh zero on every pass. Accumulators are declared before the loop.

Parsing before checking. parse().unwrap() on a comment or empty line panics. Filter the lines you want to skip first.

Integer overflow in a search. d * d <= n with d and n as u64 is fine for any realistic input; with u32 near the top of its range, d * d would overflow and panic in a debug build.

Recap

Going further: extend the statistics program to print the median. You will need to store all the numbers, which needs a growable list: let mut v = Vec::new(); v.push(x); v.sort();. That is a preview of module 9, and a reason for the next module: once values live on the heap, Rust's ownership rules start to matter.

Exercises

Exercise 1 · write code

Primes up to n

Read n from stdin and print every prime number from 2 up to and including n, separated by single spaces on one line. Then print the count on a second line. For input 20:

2 3 5 7 11 13 17 19
count: 8

For n below 2, print an empty first line and count: 0. Write a helper fn is_prime(n: u64) -> bool that tests divisors only while d * d <= n. Hidden tests call is_prime directly, including on large primes, so it must be fast.

File: main.rs

Starting point

use std::io;

fn is_prime(n: u64) -> bool {
    false
}

fn main() {
    let mut line = String::new();
    io::stdin().read_line(&mut line).unwrap();
    let n: u64 = line.trim().parse().unwrap();
}

Example

input

20

expected output

2 3 5 7 11 13 17 19
count: 8

Example

input

1

expected output


count: 0
Hint 1

0 and 1 are not prime; handle them first in is_prime.

Hint 2

Loop d from 2 while d * d <= n; if any d divides n, it is not prime.

Hint 3

Print a space before every prime except the first: keep a count and check count > 0.

Reference solution
use std::io;

fn is_prime(n: u64) -> bool {
    if n < 2 {
        return false;
    }
    let mut d = 2;
    while d * d <= n {
        if n % d == 0 {
            return false;
        }
        d += 1;
    }
    true
}

fn main() {
    let mut line = String::new();
    io::stdin().read_line(&mut line).unwrap();
    let n: u64 = line.trim().parse().unwrap();
    let mut count = 0;
    for k in 2..=n {
        if is_prime(k) {
            if count > 0 {
                print!(" ");
            }
            print!("{k}");
            count += 1;
        }
    }
    println!();
    println!("count: {count}");
}

Exercise 2 · write code

Reverse the digits

Read non-negative integers from stdin, one per line, until input ends. For each, print the number, its digits reversed (as a number, so 1200 reversed is 21), and whether it is a palindrome (reads the same both ways, so the number equals its reversal), in exactly this form:

1221 -> 1221 palindrome
1200 -> 21 not palindrome
7 -> 7 palindrome

Reverse the digits arithmetically with % 10 and / 10, not by converting to text. Use u64.

File: main.rs

Starting point

use std::io;

fn reverse(mut n: u64) -> u64 {
    0
}

fn main() {
    for line in io::stdin().lines() {
        let line = line.unwrap();
        let n: u64 = line.trim().parse().unwrap();
    }
}

Example

input

1221
1200
7

expected output

1221 -> 1221 palindrome
1200 -> 21 not palindrome
7 -> 7 palindrome

Example

input

0
12345678987654321

expected output

0 -> 0 palindrome
12345678987654321 -> 12345678987654321 palindrome
Hint 1

Take the last digit with n % 10, append it to the result with r = r * 10 + digit, then drop it with n /= 10.

Hint 2

0 reversed is 0, which the loop handles naturally (it runs zero times).

Reference solution
use std::io;

fn reverse(mut n: u64) -> u64 {
    let mut r = 0;
    while n > 0 {
        r = r * 10 + n % 10;
        n /= 10;
    }
    r
}

fn main() {
    for line in io::stdin().lines() {
        let line = line.unwrap();
        let n: u64 = line.trim().parse().unwrap();
        let r = reverse(n);
        let kind = if r == n { "palindrome" } else { "not palindrome" };
        println!("{n} -> {r} {kind}");
    }
}

Exercise 3 · write code

A command-driven accumulator

Write a tiny calculator. It starts with a value of 0 and reads commands from stdin, one per line:

At the end of input (or at quit), print final: and the value. Use i64. For example:

add 5
mul 3
print
sub 20
print
fly
quit
add 100

prints

15
-5
unknown: fly
final: -5

Split a line into words with let mut words = line.split_whitespace(); and take them with words.next(), as shown in the lesson.

File: main.rs

Starting point

use std::io;

fn main() {
    let mut value: i64 = 0;
    for line in io::stdin().lines() {
        let line = line.unwrap();
    }
    println!("final: {value}");
}

Example

input

add 5
mul 3
print
sub 20
print
fly
quit
add 100

expected output

15
-5
unknown: fly
final: -5

Example

input


add 2

mul -4

expected output

final: -8
Hint 1

Trim the line and continue if it is empty, before splitting it.

Hint 2

let cmd = words.next().unwrap(); is the first word; for add/sub/mul, words.next().unwrap().parse().unwrap() is the number.

Hint 3

match cmd { "add" => ..., "print" => ..., "quit" => break, _ => ... }

Reference solution
use std::io;

fn main() {
    let mut value: i64 = 0;
    for line in io::stdin().lines() {
        let line = line.unwrap();
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let mut words = line.split_whitespace();
        let cmd = words.next().unwrap();
        match cmd {
            "add" | "sub" | "mul" => {
                let n: i64 = words.next().unwrap().parse().unwrap();
                match cmd {
                    "add" => value += n,
                    "sub" => value -= n,
                    _ => value *= n,
                }
            }
            "print" => println!("{value}"),
            "quit" => break,
            _ => println!("unknown: {line}"),
        }
    }
    println!("final: {value}");
}

Exercise 4 · quiz

Why stop at the square root?

Why is it enough for a primality test to try divisors d only while d * d <= n?

Answer
  • Because if n = a * b with both a and b larger than the square root of n, then a * b would be larger than n; so any factorization has a factor at or below the square root

Factors come in pairs (a, n / a), and in each pair at least one is at most the square root of n. If no divisor up to the square root exists, none exists at all. For n around a billion, that cuts the work from a billion trial divisions to about 31,623. Writing d * d <= n instead of computing a floating-point square root avoids rounding problems.