Skip to content

Lecture 2 - Loops, switch, Functions, Multi-file Code & Makefiles

0. Recap & the for loop, properly

  • Last time, in brief: the edit -> compile -> run loop with clang, static typing, int/char/float/double, if/else, printf, and scanf (remember: scanf needs the & - the address to store into).
  • We ran low on time, so loops are where we begin today. Three loop shapes - for, while, do/while - are three ways of writing the same idea: do something repeatedly while a condition holds.
  • The for loop, the one we glimpsed last time, spelled out:
    for (init; condition; update) {
        /* body */
        printf("%d\n", i);
    }
    
  • init runs once at the start (int i = 0)
  • condition is checked before each iteration (i < n) - 0 is false, anything non-zero is true
  • update runs after each iteration (i++)
  • Reading order for for (int i = 0; i < n; i++): init -> test -> body -> update -> test -> body -> ... until the test is false.

The for loop also allows for multiple statements in each of its three components:

for (int i = 0, j = 1; i < n && j < m; i++, j++) {
    /* body */
}

WARNING: If you have multiple statements in the condition - you should use a logical operator (like && or ||) to combine the conditions, otherwise the condition may be evaluated incorrectly. We will cover logical operators in detail next week.


1. while loops

  • The most stripped-down loop: just a condition and a body.
    while (condition) {
        /* body */
    }
    
  • The condition is tested before every iteration - so the body may run zero times if the condition is false to begin with.
  • Use it when you don't have a simple counter - when you loop until something happens (a sentinel), not a fixed number of times.
  • The classic hazard: the infinite loop. If nothing in the body ever makes the condition false, the loop never ends.
    int i = 0;
    while (i < 5) {
        printf("%d\n", i);
        i++;            /* forget this line -> infinite loop */
    }
    
  • A for loop is really just a while loop with the init/update bundled into the header. Anything you can write as one, you can write as the other.

2. do/while loops

  • Same idea, but the condition is tested after the body:
    do {
        /* body */
    } while (condition);     /* note the semicolon */
    
  • The key difference: the body always runs at least once, before the first test. while can run zero times; do/while cannot.
  • The natural fit: input validation and menus - you always have to ask once before you can check the answer.
  • Don't forget the trailing ; after while (condition) - a common compile error.

In-class exercise break - for vs while vs do-while

Handout: Exercises 1-3 (Part A). Solutions below.

Exercise 1 - Sum 1..n, three ways. Read n, then compute the sum 1 + 2 + ... + n once with a for, once with a while, and once with a do/while. Print all three - they should agree.

#include <stdio.h>

int main(void) {
    int n;
    printf("Sum 1..n; enter n: ");
    scanf("%d", &n);

    int sum_for = 0;
    for (int i = 1; i <= n; i++) {
        sum_for += i;
    }

    int sum_while = 0, i = 1;
    while (i <= n) {
        sum_while += i;
        i++;
    }

    int sum_do = 0, j = 1;
    do {
        sum_do += j;
        j++;
    } while (j <= n);

    printf("for=%d while=%d do-while=%d\n", sum_for, sum_while, sum_do);
    return 0;
}
  • The teaching moment - run it with n = 0: the for and while versions print 0, but the do/while prints 1. The body ran once before the test, adding 1 when it shouldn't have. This is exactly the difference between "test first" and "test after." Lesson: do/while is the wrong tool when zero iterations is a legal answer.

Exercise 2 - Input validation (where do/while shines). Keep asking until the user gives a positive number.

int n;
do {
    printf("Enter a positive number: ");
    scanf("%d", &n);
} while (n <= 0);
printf("Thanks! You entered %d\n", n);
  • Ask the room: why is do/while cleaner here than while? (With while you'd have to prompt once before the loop and again inside it - duplication.)

Exercise 3 - Stretch: multiplication table. Read n, print n x 1 through n x 10, one per line. A for (or while) reads best - we count a known number of times.

#include <stdio.h>

int main(void) {
    int n;
    printf("Multiplication table for: ");
    scanf("%d", &n);

    for (int i = 1; i <= 10; i++) {
        printf("%2d x %d = %d\n", i, n, i * n);
    }
    return 0;
}
  • %2d right-aligns the counter in a 2-wide field so the table lines up.

  • Rule of thumb to put on the board:

  • Counting a known number of times -> for
  • Looping until a condition, maybe zero times -> while
  • Looping until a condition, but at least once -> do/while

3. switch / case

You may have seen long if-else if- else ladders in your code. For example:

int day;
printf("Enter a day (1-7): ");
scanf("%d", &day);

if (day == 1) {
    printf("Monday\n");
} else if (day == 2) {
    printf("Tuesday\n");
} else if (day == 3) {
    printf("Wednesday\n");
} else if (day == 4) {
    printf("Thursday\n");
} else if (day == 5) {
    printf("Friday\n");
} else if (day == 6) {
    printf("Saturday\n");
} else if (day == 7) {
    printf("Sunday\n");
} else {
    printf("Invalid day.\n");
}

  • A switch is a clean multi-way branch on a single integer or char value
  • an alternative to a long if / else if ladder.
    switch (expression) {
        case 1:
            /* ... */
            break;
        case 2:
            /* ... */
            break;
        default:
            /* none of the above */
    }
    
  • case labels must be constant integers (or chars, which are integers). You cannot switch on a double, a string, or a range.
  • break matters. Without it, execution falls through into the next case. Forgetting break is one of the most common switch bugs...
  • ...but deliberate fall-through is a useful idiom when several cases share one body:
    switch (c) {
        case 'a': case 'e': case 'i': case 'o': case 'u':
            printf("'%c' is a vowel\n", c);
            break;
        default:
            printf("'%c' is not a vowel\n", c);
    }
    
  • default is optional but recommended - it's your "unexpected input" branch.
  • When to prefer switch over if/else if: when you're dispatching on the discrete values of one variable (a menu choice, an operator character, a state). For ranges or compound conditions, stick with if.

In-class exercise break - switch

Handout: Exercises 4-5 (Part B). Solutions below.

Exercise 4 - A menu with switch. Print a small menu, read the user's choice, and switch on it; let default handle anything unexpected.

#include <stdio.h>

int main(void) {
    int choice;
    printf("1) Say hello\n2) Say goodbye\n3) Quit\nChoose: ");
    scanf("%d", &choice);

    switch (choice) {
        case 1: printf("Hello!\n");   break;
        case 2: printf("Goodbye!\n"); break;
        case 3: printf("Quitting.\n"); break;
        default: printf("Not a valid choice.\n");
    }
    return 0;
}
  • This menu pattern is the skeleton of the calculator we build later - keep it in mind.

Exercise 5 - Vowel check (deliberate fall-through). Read one character; group the vowel cases so they share a single body.

#include <stdio.h>

int main(void) {
    char c;
    printf("Enter a letter: ");
    scanf(" %c", &c);          /* the leading space skips whitespace */

    switch (c) {
        case 'a': case 'e': case 'i': case 'o': case 'u':
            printf("'%c' is a vowel\n", c);
            break;
        default:
            printf("'%c' is not a vowel\n", c);
    }
    return 0;
}
  • Note the " %c" - the leading space tells scanf to skip any leftover whitespace (like the newline from a previous entry) before reading the char.

4. Functions

  • Why functions? Reuse (write once, call many times), naming (a good name documents intent), and decomposition (break a big problem into small, testable pieces). We've been calling printf and scanf all along - now we write our own.
  • Three distinct things, often confused:
  • Declaration (prototype): the function's signature - return type, name, parameter types - ending in a ;, with no body. It's a promise that the function exists.
    int square(int n);
    
  • Definition: the prototype plus the body - the actual code.
    int square(int n) {
        return n * n;
    }
    
  • Call: using it. int y = square(5);
  • Anatomy: int square(int n) -> returns an int, takes one int parameter named n. return hands a value back to the caller. A function that returns nothing has return type void.
  • Where do prototypes go, and why? C reads top to bottom. If you call a function before the compiler has seen its definition, you need a prototype near the top of the file so the compiler knows the types. The usual layout: prototypes up top -> main -> definitions below. (Next section: prototypes move into a header file.)
  • Parameters are passed by value. The function gets its own copy of each argument; changing a parameter inside the function does not change the caller's variable. (Getting a function to modify the caller's data needs pointers - Week 3.)
  • Common errors to preview:
  • Calling a function with no prototype in sight -> implicit declaration warning (and trouble).
  • Return type / argument type mismatches.

In-class exercise break - write some functions

Handout: Exercises 6-8 (Part C, and the start of Part D). Solutions below.

Exercise 6 - Refactor Lecture 1's Celsius -> Fahrenheit into a function.

#include <stdio.h>

double c_to_f(double c);          /* prototype */

int main(void) {
    double celsius;
    printf("Enter temperature in Celsius: ");
    scanf("%lf", &celsius);
    printf("%.1f C = %.1f F\n", celsius, c_to_f(celsius));   /* call */
    return 0;
}

double c_to_f(double c) {          /* definition */
    return c * 9.0 / 5.0 + 32.0;
}
  • Point at each of the three roles - prototype, call, definition - by name.

Exercise 7 - A small number toolkit. Three functions, each pairing a loop with a function:

#include <stdio.h>

int factorial(int n);   /* 5! = 120 */
int is_prime(int n);    /* returns 1 if prime, else 0 */
int gcd(int a, int b);  /* greatest common divisor */

int main(void) {
    printf("5! = %d\n", factorial(5));
    printf("is_prime(7) = %d, is_prime(8) = %d\n", is_prime(7), is_prime(8));
    printf("gcd(48, 36) = %d\n", gcd(48, 36));
    return 0;
}

int factorial(int n) {
    int result = 1;
    for (int i = 2; i <= n; i++) {
        result *= i;
    }
    return result;
}

int is_prime(int n) {
    if (n < 2) {
        return 0;
    }
    for (int i = 2; i * i <= n; i++) {
        if (n % i == 0) {
            return 0;          /* found a divisor -> not prime */
        }
    }
    return 1;
}

int gcd(int a, int b) {
    while (b != 0) {           /* Euclid's algorithm */
        int temp = b;
        b = a % b;
        a = temp;
    }
    return a;
}
  • is_prime returning 1/0 is idiomatic C - recall conditions are just integers. The i * i <= n test stops at sqrt(n). gcd is a tidy while loop.

Exercise 8 - The calculator, in one file (functions + switch). add, sub, mul, divide, each int (int, int); main reads two numbers and an operator char, then a switch dispatches to the right function.

#include <stdio.h>

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }
int divide(int a, int b) { return a / b; }

int main(void) {
    int x, y;
    char op;

    printf("Enter: <num> <op> <num>  (op is + - * /): ");
    scanf("%d %c %d", &x, &op, &y);

    int result;
    switch (op) {
        case '+': result = add(x, y); break;
        case '-': result = sub(x, y); break;
        case '*': result = mul(x, y); break;
        case '/':
            if (y == 0) {
                printf("Error: division by zero\n");
                return 1;
            }
            result = divide(x, y);
            break;
        default:
            printf("Unknown operator '%c'\n", op);
            return 1;
    }

    printf("%d %c %d = %d\n", x, op, y, result);
    return 0;
}
  • The spaces in "%d %c %d" let scanf skip whitespace around the operator. We guard division by zero, and default catches unknown operators.
  • This exact program is what we split across files next (Exercise 9).

5. Code Organization - headers & multi-file builds

  • So far, one .c file. Real programs are split across many files: for organization, for faster rebuilds, and so code can be shared and reused.
  • The convention:
  • .h (header) = declarations - function prototypes, type definitions, constants. What exists.
  • .c (source) = definitions - the actual code. How it works.
  • A file uses another's functions by #include-ing its header:
  • #include <stdio.h> - angle brackets for system/standard headers
  • #include "mathops.h" - quotes for your own headers (look in this directory first)
  • #include is literally copy-paste done by the preprocessor: it drops the header's text into your file before compiling.
  • Because a header can get included more than once, guard it so its contents are only processed once - an include guard:
    #ifndef MATHOPS_H
    #define MATHOPS_H
    
    int add(int a, int b);
    /* ... other prototypes ... */
    
    #endif /* MATHOPS_H */
    
  • Building is two steps:
  • Compile each .c to an object file (.o) - machine code with "holes" where it calls things defined elsewhere: clang -c mathops.c -> mathops.o
  • Link the object files into one executable - filling the holes: clang main.o mathops.o -o calc
  • A teaching demo worth doing live: compile main.c alone and try to run - or link without mathops.o - and read the undefined reference to 'add' linker error. That error means "you declared it, you called it, but I never got its definition to link in."

Live-coding / exercise (within section 5) - split the calculator

Handout: Exercise 9 (Part D). Solution below.

Take the single-file calculator from Exercise 8 and split it three ways.

mathops.h - declarations, behind an include guard:

#ifndef MATHOPS_H
#define MATHOPS_H

int add(int a, int b);
int sub(int a, int b);
int mul(int a, int b);
int divide(int a, int b);

#endif /* MATHOPS_H */

mathops.c - definitions; it includes its own header so the compiler checks they match:

#include "mathops.h"

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }
int divide(int a, int b) { return a / b; }

main.c - exactly the main from Exercise 8, now with #include "mathops.h" instead of the inline function definitions:

#include <stdio.h>
#include "mathops.h"

int main(void) {
    int x, y;
    char op;

    printf("Enter: <num> <op> <num>  (op is + - * /): ");
    scanf("%d %c %d", &x, &op, &y);

    int result;
    switch (op) {
        case '+': result = add(x, y); break;
        case '-': result = sub(x, y); break;
        case '*': result = mul(x, y); break;
        case '/':
            if (y == 0) {
                printf("Error: division by zero\n");
                return 1;
            }
            result = divide(x, y);
            break;
        default:
            printf("Unknown operator '%c'\n", op);
            return 1;
    }

    printf("%d %c %d = %d\n", x, op, y, result);
    return 0;
}

Build by hand and run:

clang -c mathops.c
clang -c main.c
clang main.o mathops.o -o calc
./calc
  • Break it on purpose: link main.o alone (clang main.o -o calc) and read the undefined reference to 'add' error - add was declared (so main.c compiled) but its definition never got linked in.

6. Makefiles

  • Retyping those clang lines every time gets old fast - and as projects grow you don't want to recompile everything on every small change. make automates the build.
  • A Makefile is a set of rules. Each rule:
    target: prerequisites
      recipe        # the shell command(s) to build target
    
  • The #1 gotcha: recipe lines must start with a real TAB, not spaces. make will error (missing separator) if you use spaces. (We'll point this out because every editor handles it differently.)
  • Use variables to avoid repetition:
    CC = clang
    CFLAGS = -Wall -Wextra -std=c17
    
    calc: main.o mathops.o
      $(CC) $(CFLAGS) main.o mathops.o -o calc
    
    main.o: main.c mathops.h
      $(CC) $(CFLAGS) -c main.c
    
    mathops.o: mathops.c mathops.h
      $(CC) $(CFLAGS) -c mathops.c
    
    clean:
      rm -f calc *.o
    
  • Run make (builds the first target, calc) or make clean (a "phony" target that just deletes build artifacts).
  • The payoff - incremental builds. make compares file timestamps and rebuilds only what's out of date. Edit only mathops.c, run make, and it recompiles mathops.o and relinks - but does not recompile main.c. Listing mathops.h as a prerequisite means changing the header rebuilds both .os, which is what you want.

In-class exercise break (within section 6) - write a Makefile

Handout: Exercise 10 (Part D). The Makefile shown above is the solution.

  • Write that Makefile for your split calculator (mind the tabs).
  • Run make from scratch; run ./calc.
  • Edit just mathops.c (e.g. tweak a message), run make again, and watch which files recompile. Then make clean.
  • What to observe: after editing only mathops.c, make recompiles mathops.o and relinks calc, but does not recompile main.c - nothing it depends on changed.