C / FILES AND ERRORS
NULL pointers and validating input at boundaries
After this you can decide which pointer parameters may be NULL, reject the rest in a function's first lines, and keep the interior free of repeated checks.
What you will learn
- Test every pointer against NULL before the first dereference, not after it.
- State per parameter whether NULL is allowed and what it means when it is.
- Reject at the boundary before opening, allocating or writing through an out-parameter.
- Use assert for internal invariants; NDEBUG deletes it, so never guard input with it.
Understanding NULL pointers and validating input at boundaries
NULL is a pointer value guaranteed to compare unequal to the address of any real object, which is exactly what makes it usable as an "I have nothing for you" answer. That is why fopen returns NULL instead of some special FILE, why malloc returns NULL when it cannot allocate, and why strchr returns NULL when the character is absent. Dereferencing it is undefined behaviour, not a defined crash: on Linux you usually get SIGSEGV because the lowest page is unmapped, but a large struct member offset, an optimizing compiler, or a different target can turn the same mistake into silent corruption instead. Treat the segfault as luck, not as your error handling.
Every pointer parameter carries a contract: either the caller must supply a real object, or NULL is legal and means something specific, such as "I do not want this output". Validating input at boundaries means choosing one place to settle that question, namely the function where data from outside crosses into code you own, which is usually main, an entry point of your module, or the line that receives a library's possibly-NULL return value. Interior helpers then run on the assumption the boundary already made true, which is what keeps them short and readable. Repeating the same check through five layers does not add safety; it hides which layer is responsible, so a missing check looks exactly like all the redundant ones.
When a boundary check fails, fail before anything has happened: no file opened, no memory allocated, and above all nothing written through an out-parameter, so the caller's variables still hold what they held before the call. Give the caller results it can distinguish, because "you passed me nonsense" is a bug in the calling code while "the file would not open" is a condition worth showing a user. Validate the sizes that travel with a pointer too, since a perfectly non-NULL buffer with a wrong capacity fails just as hard as a null one.
<stdio.h>
/* Contract: path and out must both be non-NULL.
0 = counted, -1 = bad argument, -2 = could not open the file. */
static int count_bytes(const char *path, long *out)
{
FILE *f;
long n = 0;
if (path == NULL || out == NULL) {
return -1; /* refuse before anything is opened */
}
f = fopen(path, "rb");
if (f == NULL) { /* fopen reports failure as NULL */
return -2;
}
while (fgetc(f) != EOF) {
n++;
}
fclose(f);
*out = n; /* safe: out passed the boundary check */
return 0;
}
int main(void)
{
FILE *f;
long n = -1;
int rc;
f = fopen("sample.txt", "wb");
if (f == NULL) {
return 1;
}
fputs("hello\n", f);
fclose(f);
rc = count_bytes("sample.txt", &n);
printf("valid call : rc=%d n=%ld\n", rc, n);
n = -1; /* so a rejected call is visible as an untouched -1 */
rc = count_bytes(NULL, &n);
printf("NULL path : rc=%d n=%ld\n", rc, n);
rc = count_bytes("sample.txt", NULL);
printf("NULL out : rc=%d\n", rc);
rc = count_bytes("no_such_file", &n);
printf("missing file : rc=%d n=%ld\n", rc, n);
return 0;
}
NULL is a value you can test, so every pointer crossing into a function is either checked right there or documented as a promise the caller must keep.
Worked examples
NULL is not an empty string
Shows that a missing pointer and a zero-length string are different states, and that strchr uses NULL to report "not found".
<stdio.h>
<string.h>
/* Convert the absent case into a usable value in one place, so the
rest of the program never receives a null string. */
static const char *or_empty(const char *s)
{
return s == NULL ? "" : s;
}
int main(void)
{
const char *missing = NULL;
const char *empty = "";
const char *name = "config.ini";
const char *dot;
printf("missing: len=%zu tag=%s\n", strlen(or_empty(missing)),
missing == NULL ? "NULL" : "set");
printf("empty : len=%zu tag=%s\n", strlen(or_empty(empty)),
empty == NULL ? "NULL" : "set");
dot = strchr(name, '.');
printf("ext of %s: %s\n", name, dot != NULL ? dot + 1 : "(none)");
dot = strchr("README", '.');
printf("ext of README: %s\n", dot != NULL ? dot + 1 : "(none)");
return 0;
}
Example explained
Line 1or_empty is the boundary: strlen(missing) would be undefined behaviour, while strlen of "" is defined and 0.
Line 2Both lines print len=0, so a length can never distinguish "no value" from "empty value" - only the pointer test can.
Line 3strchr answers "character not present" with NULL, so dot + 1 is only formed inside the checked branch.
Line 4README having no dot is a normal result rather than a failure, which is why the check produces "(none)" and not an error.
assert for invariants, return codes for input
Separates a check on data from outside, which must survive in every build, from a check on a promise between two of your own functions.
<assert.h>
<stdio.h>
<string.h>
/* Internal helper. Contract: buf non-NULL, cap >= 1. If that is ever
false, some other function is broken, so assert instead of report. */
static void fill_dashes(char *buf, size_t cap, size_t n)
{
assert(buf != NULL);
assert(cap >= 1);
if (n > cap - 1) {
n = cap - 1;
}
memset(buf, '-', n);
buf[n] = '\0';
}
/* Boundary. The caller is outside our control, so a bad argument is
an expected outcome that gets a return value. */
int make_rule(char *buf, size_t cap, size_t n)
{
if (buf == NULL || cap == 0) {
return -1;
}
fill_dashes(buf, cap, n);
return 0;
}
int main(void)
{
char line[8];
int rc;
rc = make_rule(line, sizeof line, 4);
printf("rc=%d [%s]\n", rc, line);
rc = make_rule(line, sizeof line, 20);
printf("rc=%d [%s]\n", rc, line);
rc = make_rule(NULL, sizeof line, 4);
printf("rc=%d (rejected, nothing written)\n", rc);
return 0;
}
Example explained
Line 1make_rule tests buf and cap because anyone may call it; fill_dashes only asserts because make_rule is its single caller.
Line 2cap == 0 is rejected as well, since cap - 1 on an unsigned zero wraps to a huge value and memset would run past the array.
Line 3n of 20 is clamped to cap - 1 = 7, so buf[n] still lands inside line[8]; checking the size is as much input validation as checking the pointer.
Line 4Compiling with -DNDEBUG removes both asserts, which is exactly why the check that has to survive lives in make_rule.
Important notes
The standard library is not uniform about NULL: free(NULL) is defined to do nothing, but fclose(NULL) is undefined behaviour, so a FILE pointer still has to be tested before you close it.
A non-NULL pointer is not necessarily a valid pointer - a freed or uninitialized one passes every NULL check, so initialize pointers to NULL and set them back to NULL after closing or freeing.
Common mistakes
Putting the guard after the first use, as in size_t n = strlen(s); if (s == NULL) return -1; - the undefined behaviour has already happened by the time the test runs.
Using assert(p != NULL) to validate data arriving from outside: a build with -DNDEBUG deletes the assert, so the release binary dereferences NULL instead of returning an error.
Treating NULL as an empty string, so path[0] == '\0' is read before path is tested, and a null char pointer is handed to a %s conversion, which is undefined even though glibc often prints (null).
Skipping the check because "it would just crash anyway" - undefined behaviour is not required to trap, so a passing test run proves nothing.
Try it yourself
Change, predict, then run
Write int last_byte(const char *path, int *out) that returns -1 when either pointer is NULL, -2 when fopen fails, and 0 after storing the file's final byte. Call it with a file you just wrote, then with NULL for each parameter in turn, and confirm *out keeps its initial value after every failing call.
Open the C workspaceCheck your understanding
A reviewer rejects if (path[0] != '\0' && path != NULL) even though the condition does compare path with NULL. Why is it still unsafe?
- && does not short-circuit, so both operands are evaluated no matter what.
- path[0] is guaranteed to read as 0 when path is NULL, which makes the second test dead code.
- The subscript on the left is evaluated first, so a NULL path is dereferenced before the guard can act.
- Subscripting a pointer and later comparing it with NULL is a constraint violation that the compiler rejects.
Show answer
&& evaluates its left operand first and can only skip the right one, so the order of the two tests is the entire issue: path != NULL && path[0] != '\0' is safe, and the reverse dereferences before it checks. Option 1 is tempting because a null pointer compares equal to 0, but that concerns the pointer value, not path[0], which has no object to read at all.