Why this matters
C will not stop you from making mistakes, so experienced C programmers build their own guard rails. Some come from the compiler (warnings), some from the code itself (assertions that check assumptions), and some are simply habits that make whole classes of bugs impossible. None of them are exciting, and together they are the difference between C code that is a liability and C code that runs for decades. The SQLite and curl projects, both famously reliable, use every technique in this lesson.
Let the compiler help: warning flags
-Wall -Wextra is the baseline you have used all along. Other flags worth knowing:
| flag | catches |
|---|---|
-Werror | turns every warning into an error, so none are ignored |
-Wshadow | a variable that hides another of the same name in an outer scope |
-Wconversion | implicit conversions that may change a value (double to int, int to short, signed to unsigned) |
-Wpedantic | use of gcc extensions that are not standard C |
-Wformat=2 | stricter printf/scanf format checking |
-Wnull-dereference | paths where the compiler can prove a NULL dereference (works with -O2) |
-Wvla | variable length arrays, which can blow the stack |
Here is a program that is clean under -Wall -Wextra:
#include <stdio.h>
int scale = 3;
int scaled_total(const int *v, int n)
{
int total = 0;
for (int i = 0; i < n; i++) {
int scale = 2;
total += v[i] * scale;
}
return total;
}
int main(void)
{
int data[3] = {1, 2, 3};
double avg = scaled_total(data, 3) / 3.0;
int rounded = avg + 0.5;
unsigned len = -1;
printf("%d %d %u\n", scaled_total(data, 3), rounded, len);
return 0;
}
Adding -Wshadow -Wconversion:
w.c:9:13: warning: declaration of 'scale' shadows a global declaration [-Wshadow]
w.c:19:19: warning: conversion from 'double' to 'int' may change value [-Wfloat-conversion]
w.c:20:20: warning: unsigned conversion from 'int' to 'unsigned int' changes value from '-1' to '4294967295' [-Wsign-conversion]
None of these is a bug here, but each is a pattern that frequently is one: someone later edits the loop expecting the global scale; a truncation silently loses data; a negative number becomes four billion. Fixing a warning usually means stating your intent: rename, cast explicitly, or use the right constant. A codebase with zero warnings is one where a new warning stands out.
Note:
-Wconversionis noisy in older code. Turn it on from the start of a project, when it is cheap, rather than retrofitting it.
assert: executable assumptions
<assert.h> provides the macro assert(expression). If the expression is false (zero), it prints the file, line, function and expression to stderr and calls abort(), which kills the program with the signal SIGABRT (the shell shows exit status 134 = 128 + 6):
#include <assert.h>
#include <stdio.h>
double average(const int *v, int n)
{
assert(v != NULL);
assert(n > 0);
int sum = 0;
for (int i = 0; i < n; i++)
sum += v[i];
return (double)sum / n;
}
int main(void)
{
int data[] = {4, 8, 15};
printf("%.2f\n", average(data, 3));
printf("%.2f\n", average(data, 0));
return 0;
}
$ ./prog
9.00
prog: as.c:7: average: Assertion `n > 0' failed.
Aborted (core dumped)
$ echo $?
134
(On a terminal you see 9.00 first. When output goes to a pipe, the buffered 9.00 is lost, because abort does not flush stdio buffers.)
An assertion turns a silent wrong answer (here a division by zero that yields nan) into an immediate, precise report. Use them for:
- Preconditions: what a function requires of its arguments ("v is not NULL", "n > 0").
- Postconditions: what it guarantees ("the result is sorted").
- Invariants: what must stay true in a data structure or loop ("count <= capacity").
Separate asserts per condition give better messages than one assert(v && n > 0).
NDEBUG
If the macro NDEBUG is defined when <assert.h> is included (usually with -DNDEBUG on the command line for release builds), every assert expands to nothing. Two rules follow:
- Never put side effects in an assert.
assert(fclose(f) == 0)closes the file only in debug builds. - Never use assert for errors that can really happen. Bad user input, missing files, failed
malloc: these need real error handling that is present in every build.assertis for programmer errors.
static_assert
C11 added compile-time assertions. static_assert (from <assert.h>) checks a constant expression during compilation:
#include <assert.h>
#include <limits.h>
static_assert(CHAR_BIT == 8, "this code assumes 8-bit bytes");
static_assert(sizeof(int) >= 4, "int must be at least 32 bits");
If the condition is false, the build fails with your message. Use it to document assumptions about sizes, struct layouts and array lengths, at zero run-time cost.
Habits that prevent bugs
These are the habits that experienced C programmers apply without thinking:
- Initialize at declaration.
int count = 0;,char *p = NULL;. An uninitialized variable is UB waiting for the right input. - Check every return value that can signal failure:
malloc,fopen,scanf,fgets,strtol(viaerrnoand the end pointer),write. If you choose to ignore one, write(void)fclose(f);so readers know it was deliberate. - Use
sizeof *p, notsizeof(type).p = malloc(n * sizeof *p);stays correct if the type ofpchanges. - Pass sizes with buffers. Every function that writes into a caller's array takes its length. Prefer
snprintfoversprintf,fgetsovergets(removed from C entirely). - Set pointers to NULL after free when the pointer stays in scope. A double free becomes a harmless
free(NULL), and a use after free becomes a reliable crash instead of corruption. - Use
constfor pointers to data a function only reads. The compiler then rejects accidental writes. - Keep functions small with one exit path for cleanup. The
goto cleanup;pattern for freeing resources in reverse order is idiomatic C, not a sin. - Limit scope. Declare variables in the smallest block that needs them; make file-local functions
static. - Test under sanitizers and valgrind, including the error paths.
Common mistakes
- Silencing a warning with a cast without understanding it.
(int)some_size_tmay hide a real truncation. Ask what the warning is protecting you from first. - Asserting on user input.
assert(argc == 2)crashes with an obscure message and disappears in release builds. Print a usage message and exit with status 2 instead. - Side effects in asserts, as described above.
- Turning off a warning project-wide because one line triggers it. Fix the line.
Recap
- Build with
-Wall -Wextra, add-Wshadow,-Wconversionand friends, and use-Werrorto keep the warning count at zero. assert(expr)aborts with file, line and expression whenexpris false; use it for preconditions, postconditions and invariants, never for expected runtime errors.-DNDEBUGremoves asserts entirely, so no side effects inside them.static_assert(cond, "msg")checks assumptions at compile time.- Habits: initialize, check returns,
sizeof *p, pass buffer sizes, NULL after free,const, small scopes, sanitizers.
Going further: read man 3 assert, then look at how a large project configures warnings: git and curl both document their developer warning flags. The CERT C Coding Standard is a free, searchable catalogue of rules like the ones above, each with examples.