Procedural Programming with C · Intermediate · Week 16

Pointers as Function Parameters

Pointers stop being a mechanism this week and become a design tool. How a function reports failure, what it promises not to modify, who owns the memory it returns — all of that is expressed in its parameter list, and the compiler will enforce most of it if you let it.

By the end of this week you can
  • Design a function that returns a status and writes its result through an output parameter.
  • Distinguish const char *, char * const, and const char * const, and use the right one.
  • Apply const correctness as a discipline rather than decoration.
  • Return a pointer safely, and recognize every way of doing it unsafely.
  • Read any C declaration using the right-left rule, without guessing.

1Output parameters and the status return

Week 11 listed three ways a C function can report failure. With pointers available, the most generally useful one can now be written properly: return a status, write the result through a pointer.

bool parse_int(const char *text, int *out);

Read the signature as a contract. The function reads text and will not modify it. It returns true on success, in which case it has written the parsed value to *out. On failure it returns false, and the caller must not rely on *out.

int value;
if (!parse_int(input, &value)) {
    fprintf(stderr, "not a number: %s\n", input);
    return EXIT_FAILURE;
}
/* value is trustworthy only inside this branch */

This is the convention to use when every value is a legitimate result. A function returning a temperature cannot use −1 as a failure code, and cannot use 0 either. Separating "did it work" from "what is the answer" removes the problem entirely.

Rules that make output parameters pleasant

  • Put output parameters last, after the inputs. This is what the standard library does and what readers expect.
  • Do not write to *out on the failure path. Leaving the caller's variable untouched is easier to reason about than partially filling it.
  • Document whether out may be NULL. If it may, check it. If it may not, say so — and consider assert, which week 27 introduces.
  • One output parameter is fine; three is a signal that the results belong together in a structure, which is week 21.

2const and what it qualifies

With pointers there are two things that could be constant — the pointer, or what it points to — and C can express either.

DeclarationCan change the pointer?Can change the target?
char *pyesyes
const char *pyesno
char * const pnoyes
const char * const pnono

The rule for reading these: const applies to whatever is immediately to its left, unless there is nothing to its left, in which case it applies to what is on its right. So const char *p and char const *p mean the same thing — a pointer to constant characters.

const char *p = "hello";
p[0] = 'H';        /* error: target is const   */
p = "world";       /* fine: the pointer is not */

char * const q = buffer;
q[0] = 'H';        /* fine: target is writable */
q = other;         /* error: the pointer is const */

By far the most common and most useful is const char *, and more generally const T * for any parameter the function only reads.

const correctness as a discipline

The rule is simple: every pointer parameter that the function does not modify should be const. The benefits compound.

It documents the interface. void process(const char *in, char *out) tells you which way data flows before you read a line of the body.

It is enforced. An accidental write is a compile error, not a bug. Contrast with a comment saying "does not modify src", which is unchecked and rots.

It propagates. A caller holding a const char * cannot pass it to a function taking char *. If you omit const in one place, callers are forced to omit it too, or to cast it away — and a codebase with casts scattered through it has lost the benefit entirely. Add const from the start; retrofitting it is genuinely painful.

Casting const away. char *bad = (char *)some_const_pointer; compiles. If the object really is constant — a string literal, for instance — writing through the result is undefined behavior and typically crashes. A cast that removes const is a claim that you know the object is actually writable; if you cannot justify that claim, the cast is a bug.

3Returning a pointer

A function returning a pointer is making a promise about the lifetime of what it points to. There are exactly four sources, and one of them is always wrong.

Returned pointer refers toSafe?Who frees it
A local variableNo — the frame is gone
A string literal or other static objectYesNobody; it lives forever
Memory the caller suppliedYesThe caller, who already owns it
Memory from mallocYesThe caller — and this must be documented
/* WRONG: the array dies when the function returns */
char *broken(void)
{
    char buffer[32];
    strcpy(buffer, "hello");
    return buffer;
}

/* Fine: the literal has static storage duration */
const char *describe(int code)
{
    return (code == 0) ? "success" : "failure";
}

/* Fine, and the most common good design: the caller supplies the space */
bool format_into(char *dst, size_t dst_size, int code)
{
    int n = snprintf(dst, dst_size, "code %d", code);
    return n >= 0 && (size_t)n < dst_size;
}

/* Fine, but the caller must free it — say so in the name or a comment */
char *duplicate(const char *s);   /* caller frees the result */

The third form is worth adopting as a default. It puts the caller in control of where the memory comes from, it cannot leak, and it works identically whether the buffer is on the stack, in a static, or on the heap. Week 19 covers the fourth, where ownership becomes an explicit part of every interface.

4Passing strings and multidimensional arrays

A string parameter is a char *, plus const if it is only read:

size_t count_vowels(const char *s);            /* reads only        */
void   to_upper_in_place(char *s);             /* modifies in place */
bool   copy_into(char *dst, size_t dst_size,
                 const char *src);             /* both directions   */

Note the pattern in the third: a writable buffer is always accompanied by its size. There is no way to recover it inside the function — week 12 established that sizeof on a parameter gives the pointer size — so the caller must supply it. A function that takes a destination buffer and no size cannot be used safely, which is precisely the defect in strcpy.

Two-dimensional arrays

void print_grid(size_t rows, size_t cols, const int grid[rows][cols]);

This C99 form takes the dimensions as earlier parameters and uses them in the array type, which lets the compiler compute offsets correctly for any shape. It is clearer than the older alternatives:

void print_grid(const int grid[][COLS], size_t rows);   /* fixed width  */
void print_grid(const int (*grid)[COLS], size_t rows);  /* the same     */
void print_grid(const int *flat, size_t rows, size_t cols); /* manual   */

The last form — one-dimensional with flat[r * cols + c] — is what most real code uses, because it works for dynamically allocated matrices where the shape is not known until run time. Week 19 builds that.

5Reading declarations: the right-left rule

C declarations are notoriously hard to read because the syntax mirrors use rather than structure. There is a mechanical procedure, and once you know it nothing is ambiguous.

The rule. Start at the identifier. Move right as far as you can, then left, alternating, obeying parentheses. Read [] as "array of", () as "function returning", and * as "pointer to".

Worked through, from easy to unpleasant:

DeclarationReads as
int *pp is a pointer to int
int *p[5]p is an array of 5 pointers to int
int (*p)[5]p is a pointer to an array of 5 int
int *f(void)f is a function returning a pointer to int
int (*f)(void)f is a pointer to a function returning int
char *(*f[3])(int)f is an array of 3 pointers to functions taking int and returning a pointer to char

Take the last one step by step. Start at f. To the right is [3] — array of 3. Parentheses close, so go left: * — of pointers to. Now outside the parentheses, right again: (int) — functions taking int. Then left: char * — returning a pointer to char. Done, and nothing was guessed.

Two practical notes. The cdecl tool, available online and as a package, translates declarations in both directions and is worth using while the rule is still new. And in your own code, a typedef usually beats a virtuoso declaration:

typedef char *(*string_maker)(int);
string_maker f[3];          /* the same type, comprehensible */

Week 30 needs this for function pointers, and week 22 covers typedef properly.

6Worked example: a parser with an honest contract

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <ctype.h>
#include <errno.h>
#include <limits.h>

/* Parse a complete decimal integer.
   Returns true and writes *out only on success.
   text is read and never modified, which the signature enforces. */
static bool parse_int(const char *text, int *out)
{
    if (text == NULL || out == NULL) {
        return false;
    }

    while (isspace((unsigned char)*text)) {
        text++;
    }
    if (*text == '\0') {
        return false;                       /* empty or all whitespace */
    }

    errno = 0;
    char *end = NULL;                       /* strtol writes here */
    long value = strtol(text, &end, 10);

    if (end == text)      return false;     /* no digits at all      */
    if (*end != '\0')     return false;     /* trailing rubbish      */
    if (errno == ERANGE)  return false;     /* out of long's range   */
    if (value < INT_MIN || value > INT_MAX) return false;

    *out = (int)value;
    return true;
}

/* Two results, so two output parameters — and at three it would be a struct. */
static bool min_max(const int *values, size_t n, int *lo, int *hi)
{
    if (values == NULL || n == 0) {
        return false;
    }
    *lo = *hi = values[0];
    for (size_t i = 1; i < n; i++) {
        if (values[i] < *lo) *lo = values[i];
        if (values[i] > *hi) *hi = values[i];
    }
    return true;
}

/* Caller supplies the buffer and its size: cannot leak, cannot overflow. */
static bool describe_into(char *dst, size_t dst_size, int lo, int hi)
{
    int n = snprintf(dst, dst_size, "range %d..%d (span %d)", lo, hi, hi - lo);
    return n >= 0 && (size_t)n < dst_size;
}

/* Safe: the literals have static storage duration. */
static const char *verdict(int span)
{
    if (span == 0)  return "all equal";
    if (span < 10)  return "tight";
    if (span < 100) return "moderate";
    return "wide";
}

/* const correctness: this reads, so every pointer it takes is const. */
static size_t count_matching(const int *values, size_t n, int target)
{
    size_t found = 0;
    for (size_t i = 0; i < n; i++) {
        if (values[i] == target) {
            found++;
        }
    }
    return found;
}

int main(void)
{
    puts("== parsing, with failure reported rather than guessed ==");
    const char *inputs[] = { "42", "  -7 ", "abc", "", "12abc", "99999999999" };
    const size_t input_count = sizeof inputs / sizeof inputs[0];

    int parsed[8];
    size_t good = 0;

    for (size_t i = 0; i < input_count; i++) {
        int value;
        if (parse_int(inputs[i], &value)) {
            printf("  \"%s\" -> %d\n", inputs[i], value);
            parsed[good++] = value;
        } else {
            printf("  \"%s\" -> rejected\n", inputs[i]);
        }
    }

    puts("\n== two outputs ==");
    int lo, hi;
    if (min_max(parsed, good, &lo, &hi)) {
        printf("  lowest %d, highest %d\n", lo, hi);

        char text[64];
        if (describe_into(text, sizeof text, lo, hi)) {
            printf("  %s — %s\n", text, verdict(hi - lo));
        }

        char tiny[8];
        if (!describe_into(tiny, sizeof tiny, lo, hi)) {
            printf("  into an 8-byte buffer: truncated to \"%s\", and detected\n",
                   tiny);
        }
    }

    puts("\n== the empty case is a failure, not a wrong answer ==");
    printf("  min_max on 0 values -> %s\n",
           min_max(parsed, 0, &lo, &hi) ? "succeeded?!" : "correctly refused");

    puts("\n== const correctness ==");
    printf("  count of 42 = %zu\n", count_matching(parsed, good, 42));
    puts("  count_matching takes const int * — the compiler guarantees");
    puts("  it cannot modify the caller's array, and says so in the signature");

    puts("\n== declarations, read with the right-left rule ==");
    int   n1 = 5;
    int  *p1 = &n1;             /* p1: pointer to int                    */
    int  *a1[3] = { &n1, NULL, NULL };  /* a1: array of 3 pointers to int */
    int   m[2][3] = { { 1, 2, 3 }, { 4, 5, 6 } };
    int (*r1)[3] = m;           /* r1: pointer to array of 3 int         */

    printf("  *p1      = %d\n", *p1);
    printf("  *a1[0]   = %d\n", *a1[0]);
    printf("  r1[1][2] = %d\n", r1[1][2]);
    printf("  sizeof a1 = %zu (3 pointers), sizeof *r1 = %zu (3 ints)\n",
           sizeof a1, sizeof *r1);

    return EXIT_SUCCESS;
}
gcc -std=c17 -Wall -Wextra -g -fsanitize=address -o params params.c
./params

Why parse_int is written that way

Compare it with atoi, which is what most textbooks reach for. atoi("abc") returns 0 — indistinguishable from atoi("0"). It cannot report overflow. It has no way to tell you there was trailing garbage. Every one of those is a real defect, and strtol with a checked endptr closes all three.

The char *end argument is itself an output parameter, written by strtol to say where it stopped. Checking end == text detects "no digits"; checking *end != '\0' detects trailing rubbish. That pattern is worth memorizing — week 23 uses it for command-line arguments and week 48 fuzzes it.

Make the compiler enforce const

Add a write inside a function that promised not to:

static size_t count_matching(const int *values, size_t n, int target)
{
    values[0] = 0;        /* add this line */
params.c:82:15: error: assignment of read-only location '*values'

Not a warning — an error. The contract in the signature is checked by the compiler on every build, for free, forever. That is what distinguishes const from a comment.

Watch a dangling return fail

static char *broken(void)
{
    char buffer[32];
    strcpy(buffer, "hello");
    return buffer;
}
/* in main: */
printf("%s\n", broken());
warning: function returns address of local variable [-Wreturn-local-addr]

Run it under the sanitizer and the diagnosis is exact:

==1234==ERROR: AddressSanitizer: stack-use-after-return

Then fix it the right way — by making the caller supply the buffer, as describe_into does — and note that the fix changed the interface, not the body. Lifetime is an interface question.

7Common mistakes

MistakeWhat happensFix
Using a sentinel like −1 where every value is validFailure indistinguishable from a resultStatus return plus an output parameter.
Writing to *out before validatingCaller's variable clobbered on failureWrite only on the success path.
Omitting const on read-only parametersContract unstated; const callers forced to castAdd it from the start; retrofitting is painful.
Casting const away to silence an errorPossible write to read-only memoryFix the signature instead.
Returning a pointer to a localDangling pointerCaller-supplied buffer, static data, or malloc with documented ownership.
A destination buffer with no size parameterCannot be used safely — the strcpy defectAlways pass the size alongside.
Using atoi for user inputCannot distinguish 0 from failurestrtol with endptr and errno checked.
Guessing at a declaration's meaningWrong type, confusing errorsApply the right-left rule, or introduce a typedef.

8Check yourself

When is a status return plus an output parameter better than returning the value directly?

Whenever every possible value is a legitimate result, so no value is available as a failure sentinel. Returning a temperature, a parsed integer, or a count that may legitimately be zero all fall into this category. Separating "did it succeed" from "what is the answer" also forces the caller to handle failure before touching the result.

What is the difference between const char *p and char * const p?

The first is a pointer to constant characters: you may repoint p but not write through it. The second is a constant pointer to writable characters: you may write through it but not repoint it. Read const as qualifying whatever is immediately to its left, or to its right when there is nothing to the left.

Why is const better than a comment saying the function does not modify its argument?

Because it is checked. An accidental write becomes a compile error rather than a bug, and the guarantee propagates to callers, who can pass const data without casting. A comment is unverified and drifts out of date; const is re-verified on every build.

Which of the four sources of a returned pointer is always wrong, and why?

A pointer to a local variable. The local lives in the function's stack frame, which is discarded on return, so the caller receives an address into memory that now belongs to whatever is called next. It frequently appears to work, because the bytes have not yet been overwritten, which makes it harder to find rather than easier.

Read char *(*f[3])(int).

Start at f, go right to [3] — an array of 3. The parenthesis closes, so go left to * — of pointers to. Outside the parentheses, right to (int) — functions taking an int. Then left to char * — returning a pointer to char. So: an array of three pointers to functions taking an int and returning char *. A typedef would express this far more kindly.

9Where this leads

Week 17 turns from parameters to storage: where variables actually live, how long they last, and what the program's memory map looks like as a single picture. That picture makes week 18's recursion visible in a debugger and is the prerequisite for week 19, where you take memory from the heap and become responsible for giving it back.