The C Path — learn C, visually

📚 The Standard Library

ctype.h, assert.h & errno.h: small headers, big habits

⏱ 13 min · free interactive lesson · quizzes, visualizations & a real compiler

▶ Open the interactive lesson — free, no signup
Why you're learning this

How does a password checker know you typed a digit? And how does "No such file or directory" actually reach your screen? Three tiny headers answer both — and teach you the professional habit of making bugs crash loudly while you develop, at zero cost in the version you ship.

Three tiny headers that shape how good C code feels: ctype.h classifies characters, assert.h catches impossible states, and errno.h is the standard library's error-reporting channel. Each has one famous trap.

ctype.h: what kind of character is this?

testtrue fortesttrue for
isalphaletters a–z A–ZisupperA–Z
isdigit0–9islowera–z
isalnumletters or digitsisxdigithex digits 0–9 a–f A–F
isspacespace \t \n \r \v \fispunctprintable, not alnum/space
isprintanything visible + spaceiscntrlcontrol codes

Plus two converters: toupper(c) and tolower(c) (non-letters pass through unchanged). Here's a mini text analyzer:

classify.c
#include <stdio.h>
#include <ctype.h>

int main(void) {
    const char *text = "Route 66, exit B-4!";
    int letters = 0, digits = 0, spaces = 0, punct = 0;

    for (const char *p = text; *p; p++) {
        unsigned char c = (unsigned char)*p;   /* the safe cast */
        if      (isalpha(c)) letters++;
        else if (isdigit(c)) digits++;
        else if (isspace(c)) spaces++;
        else if (ispunct(c)) punct++;
    }
    printf("\"%s\"\n", text);
    printf("letters=%d digits=%d spaces=%d punct=%d\n",
           letters, digits, spaces, punct);

    for (const char *p = text; *p; p++)
        putchar(toupper((unsigned char)*p));
    putchar('\n');
    return 0;
}
terminal
$ gcc classify.c -o classify && ./classify
"Route 66, exit B-4!"
letters=9 digits=3 spaces=3 punct=4
ROUTE 66, EXIT B-4!
💀

The unsigned char trap: the ctype functions accept an int that must be either EOF or a value representable as unsigned char (0–255). But plain char is often signed — so a byte like é (0xE9 in Latin-1) stored in a char is −23, and isalpha(-23) is undefined behavior (real implementations index a table at [-23]…). When the char comes from arbitrary text, cast first: isalpha((unsigned char)c).

🧠 Checkpoint: Why is isupper(c) risky when char c holds a byte read from a file?

  • isupper only works on ASCII files
  • If char is signed, bytes ≥ 128 become negative — passing a negative (non-EOF) value is UB
  • isupper modifies c
  • Files can’t contain uppercase bytes
Show answer

If char is signed, bytes ≥ 128 become negative — passing a negative (non-EOF) value is UB — ctype functions are defined only for EOF and 0–255. On signed-char platforms (x86 Linux!), byte 0xE9 arrives as −23, and isupper(−23) is undefined behavior — often an out-of-bounds table read. Cast: isupper((unsigned char)c).

assert.h: crash early, crash loudly

assert(expr) checks an invariant: if expr is false, it prints the expression, file and line, then calls abort(). It documents and enforces what must be true if your code is correct:

assert.c
#include <stdio.h>
#include <assert.h>

/* average of n values — n == 0 would be a caller BUG */
double average(const double *v, int n) {
    assert(v != NULL);
    assert(n > 0);

    double sum = 0;
    for (int i = 0; i < n; i++) sum += v[i];
    return sum / n;
}

int main(void) {
    double data[] = { 1.0, 2.0, 6.0 };
    printf("avg = %.2f\n", average(data, 3));
    printf("avg = %.2f\n", average(data, 0));   /* boom */
    return 0;
}
terminal
$ gcc assert.c -o assert && ./assert
avg = 3.00
assert: assert.c:7: average: Assertion `n > 0' failed.
Aborted (core dumped)
$ gcc -DNDEBUG assert.c -o assert   # release build:
$ ./assert                          # asserts compiled out —
avg = 3.00                          # now it's a silent
avg = inf                           # divide-by-zero instead!

Compile with -DNDEBUG and every assert vanishes completely — zero runtime cost in release builds. Two consequences: never put side effects inside an assert (assert(read_config()) silently disappears!), and never use assert for conditions that can legitimately happen in production.

This spot has an interactive flow widget — open the interactive lesson to play with it.

💡

The philosophy: assert guards against bugs, error handling guards against the world. A NULL argument that your own code should never pass → assert. A file that might not exist, input a user typed, memory that might run out → real error handling: check, report, recover. And for conditions checkable at compile time, use static_assert from Part 5 — it costs nothing even in debug builds.

🧠 Checkpoint: Why is assert(fclose(f) == 0); a bug waiting to happen?

  • fclose never returns 0
  • assert can’t contain function calls
  • With -DNDEBUG the whole expression vanishes — the file is never closed in release builds
  • It leaks errno
Show answer

With -DNDEBUG the whole expression vanishes — the file is never closed in release builds — NDEBUG makes assert(...) expand to nothing — including its side effects. The debug build closes the file; the release build doesn’t. Keep the action outside: int rc = fclose(f); assert(rc == 0);

errno.h: how the library reports failure

Many library functions signal that they failed via their return value (NULL, −1, EOF) and why via the global-ish variable errno (it's thread-local in practice, and since C11 officially a macro). The conventions matter:

errno.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <limits.h>

int main(void) {
    /* case 1: fopen — return value says IF, errno says WHY */
    FILE *f = fopen("/no/such/file", "r");
    if (!f) {
        perror("fopen");                       /* to stderr    */
        printf("errno=%d meaning \"%s\"\n",
               errno, strerror(errno));
    }

    /* case 2: strtol — must clear errno FIRST               */
    errno = 0;
    long v = strtol("99999999999999999999", NULL, 10);
    if (errno == ERANGE)
        printf("overflow: clamped to %ld (%s)\n",
               v, strerror(errno));
    return 0;
}
terminal
$ gcc errno.c -o errno && ./errno
fopen: No such file or directory
errno=2 meaning "No such file or directory"
overflow: clamped to 9223372036854775807 (Numerical result out of range)

🧠 Checkpoint: Why set errno = 0 before calling strtol but not before fopen?

  • strtol is older than errno
  • fopen’s failure (NULL) is unambiguous, but strtol’s overflow value LONG_MAX is also a legal parse result — only a fresh ERANGE distinguishes them
  • fopen clears errno itself
  • You should clear it before every call
Show answer

fopen’s failure (NULL) is unambiguous, but strtol’s overflow value LONG_MAX is also a legal parse result — only a fresh ERANGE distinguishes them — Functions set errno on failure but never clear it. fopen returning NULL already proves failure, so errno’s value is meaningful. strtol returning LONG_MAX could be a genuine parse of that number — you need to know ERANGE was set by THIS call, hence the pre-clear.

This spot has an interactive editor widget — open the interactive lesson to play with it.

You now know how the library talks about characters and errors — next we pin down how big C's types actually are, and how to stop guessing: limits.h, float.h and the fixed-width types of stdint.h.

▶ Practice this lesson interactively (with live gcc)