📚 The Standard Library
ctype.h, assert.h & errno.h: small headers, big habits
▶ Open the interactive lesson — free, no signupHow 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?
| test | true for | test | true for |
|---|---|---|---|
isalpha | letters a–z A–Z | isupper | A–Z |
isdigit | 0–9 | islower | a–z |
isalnum | letters or digits | isxdigit | hex digits 0–9 a–f A–F |
isspace | space \t \n \r \v \f | ispunct | printable, not alnum/space |
isprint | anything visible + space | iscntrl | control codes |
Plus two converters: toupper(c) and tolower(c) (non-letters pass through unchanged). Here's a mini text analyzer:
#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;
}$ 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:
#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;
}$ 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:
- Functions set errno on failure but never clear it on success — check errno only after seeing a failing return value.
- Exception: functions like
strtolwhere the failure value (LONG_MAX) is also a legal result — there you seterrno = 0before the call and inspect it after. perror("prefix")prints your prefix plus the errno message to stderr;strerror(errno)hands you the message string to format yourself.
#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;
}$ 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.