Beginner

Control Flow in Scheme

Learn control flow in Scheme - conditionals with if, cond, and case, boolean short-circuiting, and how recursion and named let replace traditional loops

Control flow is how a program decides what to do next. In most languages that means statements: if blocks, for loops, switch cases. Scheme takes a different view. Because Scheme is a functional Lisp where everything is an expression that returns a value, its control-flow forms are expressions too - if doesn’t run a block, it evaluates to one of two values.

This expression-oriented design has consequences. There is no separate ternary operator because if already returns a value. The and and or forms aren’t just boolean logic - they’re genuine control-flow tools thanks to short-circuit evaluation. And most importantly, Scheme has no traditional for loop at its core. Iteration is expressed through recursion, made practical by Scheme’s guaranteed proper tail calls, or through the idiomatic named let.

In this tutorial you’ll learn Scheme’s conditional forms (if, cond, case, when, unless), how to use and/or for branching and defaults, and how to loop the Scheme way using recursion, named let, and the do form. Every example below runs unchanged in GNU Guile.

Conditionals: if, cond, case, when, and unless

Scheme’s conditional vocabulary is small but expressive. if is the primitive; cond handles multi-way branching cleanly; case dispatches on a value; and when/unless are one-armed conditionals for side effects.

Create a file named conditionals.scm:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
;; `if` takes exactly three parts: (if test then else)
;; It is an EXPRESSION - it returns a value, so there is no ternary operator.
(define (classify n)
  (if (> n 0)
      "positive"
      (if (< n 0)
          "negative"
          "zero")))

(display (classify 5))   (newline)
(display (classify -3))  (newline)
(display (classify 0))   (newline)

;; `cond` replaces a chain of nested ifs with clean clauses.
;; Each clause is (test result ...); `else` is the catch-all.
(define (grade score)
  (cond ((>= score 90) "A")
        ((>= score 80) "B")
        ((>= score 70) "C")
        (else          "F")))

(display (grade 95)) (newline)
(display (grade 82)) (newline)
(display (grade 60)) (newline)

;; `case` dispatches on a single value, comparing with eqv?.
;; Each clause lists the matching keys in parentheses.
(define (day-type day)
  (case day
    ((sat sun)             "weekend")
    ((mon tue wed thu fri) "weekday")
    (else                  "unknown")))

(display (day-type 'sat)) (newline)
(display (day-type 'wed)) (newline)

;; `when` and `unless` are one-armed conditionals for side effects.
;; They run their body (an implicit begin) only when the test passes.
(define (announce n)
  (when (even? n)
    (display n)
    (display " is even")
    (newline))
  (unless (even? n)
    (display n)
    (display " is odd")
    (newline)))

(announce 4)
(announce 7)

A few things stand out. if always has an else branch here because we want a value in every case. cond reads top to bottom and stops at the first true test. case compares the dispatch value against the keys - note the keys are plain symbols like sat, while the value we pass is the quoted symbol 'sat. Finally, when and unless are perfect when you only care about one outcome and the body is for its effect (printing), not its value.

Boolean Forms as Control Flow

In Scheme, and and or do more than compute true/false. They short-circuit and return actual values, which makes them concise control-flow tools. and returns the last value if every test is truthy (or #f on the first failure); or returns the first truthy value it finds.

Remember from Hello World that in Scheme only #f is false - every other value, including 0 and the empty list, is true.

Create a file named boolean_flow.scm:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
;; `and` stops at the first #f. Here it guards a division:
;; if b is 0, the (/ a b) is never evaluated.
(define (safe-divide a b)
  (and (not (= b 0))
       (/ a b)))

(display (safe-divide 10 2)) (newline)   ; -> 5
(display (safe-divide 10 0)) (newline)   ; -> #f

;; `or` returns its first truthy value - ideal for default fallbacks.
(define (greet name)
  (string-append "Hello, " (or name "stranger")))

(display (greet "Ada")) (newline)
(display (greet #f))    (newline)

;; Because `if` is an expression, it doubles as a ternary.
(define (max-of a b)
  (if (> a b) a b))

(display (max-of 3 9)) (newline)

safe-divide shows short-circuiting protecting a dangerous operation - (/ a b) simply never runs when b is 0, so and returns #f instead. greet uses or to substitute a default when name is #f. And max-of demonstrates that you never need a ternary operator: if already evaluates to a value you can return directly.

Looping with Recursion and Named let

Scheme has no for loop in its core. Instead, iteration is recursion, and because Scheme guarantees proper tail-call optimization, a tail-recursive procedure runs in constant stack space - exactly like a loop in other languages, with no risk of stack overflow.

The idiomatic shortcut for this pattern is the named let, which gives a loop a name and initial bindings in one form.

Create a file named recursion.scm:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
;; Simple recursion: print n down to 1, then stop.
(define (countdown n)
  (when (> n 0)
    (display n)
    (display " ")
    (countdown (- n 1))))

(countdown 5)
(newline)

;; Tail recursion with an accumulator. Proper tail calls make this
;; as efficient as an imperative loop, even for large n.
(define (sum-to n)
  (define (loop i acc)
    (if (> i n)
        acc
        (loop (+ i 1) (+ acc i))))
  (loop 1 0))

(display (sum-to 100))
(newline)

;; `let loop` (a named let) is the idiomatic loop. The name `loop`
;; becomes a procedure you call to iterate, with i and acc as state.
(define (factorial n)
  (let loop ((i n) (acc 1))
    (if (= i 0)
        acc
        (loop (- i 1) (* acc i)))))

(display (factorial 5))
(newline)

countdown is plain recursion driven by when. sum-to introduces an accumulator - carrying the running total in a parameter is the functional way to avoid mutating a variable. factorial uses a named let: loop is both the loop’s name and the procedure you call to start the next iteration. This is the construct you’ll reach for most often when writing loops in Scheme.

The do Loop and Iterating Collections

When you genuinely want imperative-style iteration, Scheme offers the do form. And to walk through an existing list, for-each applies a procedure to every element.

Create a file named loops.scm:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
;; `do` is Scheme's imperative loop:
;; (do ((var init step) ...) (stop-test result) body ...)
(do ((i 1 (+ i 1)))      ; i starts at 1, steps by 1
    ((> i 5))            ; stop when i > 5
  (display i)
  (display " "))
(newline)

;; `for-each` runs a procedure for its effect on each list element.
(for-each
  (lambda (fruit)
    (display fruit)
    (newline))
  '("apple" "banana" "cherry"))

;; `do` can also accumulate a result. Here we collect even numbers,
;; building the list in reverse and flipping it at the end.
(define (evens-up-to n)
  (do ((i 0 (+ i 1))
       (result '() (if (even? i) (cons i result) result)))
      ((> i n) (reverse result))))

(display (evens-up-to 10))
(newline)

The do form bundles three things: the loop variables with their step expressions, a stop test paired with a result expression, and an optional body. The first loop just prints. The evens-up-to example shows do doing real work - result accumulates matching numbers each iteration, and when the stop test fires, (reverse result) becomes the loop’s return value. for-each is the cleanest way to traverse a list purely for side effects like printing.

Running with Docker

These examples run on GNU Guile. Use the --no-auto-compile flag so Guile interprets the scripts directly without writing compilation caches.

1
2
3
4
5
6
7
8
# Pull the official Guile image
docker pull weinholt/guile:latest

# Run each example
docker run --rm -v $(pwd):/app -w /app weinholt/guile:latest guile --no-auto-compile conditionals.scm
docker run --rm -v $(pwd):/app -w /app weinholt/guile:latest guile --no-auto-compile boolean_flow.scm
docker run --rm -v $(pwd):/app -w /app weinholt/guile:latest guile --no-auto-compile recursion.scm
docker run --rm -v $(pwd):/app -w /app weinholt/guile:latest guile --no-auto-compile loops.scm

Expected Output

Running conditionals.scm:

positive
negative
zero
A
B
F
weekend
weekday
4 is even
7 is odd

Running boolean_flow.scm:

5
#f
Hello, Ada
Hello, stranger
9

Running recursion.scm:

5 4 3 2 1 
5050
120

Running loops.scm:

1 2 3 4 5 
apple
banana
cherry
(0 2 4 6 8 10)

Key Concepts

  • if is an expression, not a statement - it returns a value, so Scheme needs no ternary operator; just return the if directly.
  • cond for multi-way branching - cleaner than nested ifs, with else as the catch-all clause.
  • case dispatches on a value using eqv?, grouping matching keys in each clause - ideal for switch-style logic.
  • when and unless are one-armed conditionals for side effects, with an implicit begin body.
  • and and or short-circuit and return values - use and to guard dangerous operations and or to supply defaults.
  • Only #f is false - 0, "", and '() are all truthy, which affects every conditional you write.
  • Recursion replaces loops, and guaranteed proper tail calls make tail-recursive procedures as efficient as iteration with no stack growth.
  • Named let is the idiomatic loop - it names the iteration and its state in one form; reach for do only when you want explicit imperative stepping.

Running Today

All examples can be run using Docker:

docker pull weinholt/guile:latest
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining