Putting It Together: A Small C Program

October 13, 2026 • 8 min read

Putting It Together: A Small C Program
Table of contents

Part of the series:C Explained

The series covered each part of C on its own. This last post uses all of it at once to build a small, complete command-line program: it reads numbers from a file, keeps them in a dynamically growing array, and prints the count, sum, average, minimum, and maximum. It is the wc of numbers, and along the way you will see every earlier topic earn its place.

What the program does

$ ./stats numbers.txt
count:   5
sum:     150.00
average: 30.00
min:     10.00
max:     50.00

The design is split into a library (stats.c with a header stats.h) and a thin main (main.c). The library holds all the logic and knows nothing about the terminal; main handles the command line. That split is deliberate: it is what the testing post called keeping the pure middle away from the I/O edges, and it lets us test the logic without running the program.

The header: the contract

The header is where the functions post’s “prototype” idea lives for a whole file. It also defines the struct that holds the numbers:

#ifndef STATS_H
#define STATS_H

#include <stddef.h>

typedef struct {
    double *values;      // heap array of the numbers
    size_t count;        // how many are stored
    size_t capacity;     // how many fit before growing
} Numbers;

int    numbers_init(Numbers *n, size_t capacity);
int    numbers_push(Numbers *n, double value);
void   numbers_free(Numbers *n);
int    numbers_read(Numbers *n, const char *path);

double numbers_average(const Numbers *n);
double numbers_min(const Numbers *n);
double numbers_max(const Numbers *n);

#endif

Two things to notice. The #ifndef/#define/#endif guard prevents the header from being included twice, which would otherwise re-declare the struct and fail to compile. And every function that can fail returns int, with 0 for success and -1 for failure, the convention from the error-handling post. The Numbers struct is exactly the “group different fields under one name” idea from the structs post: a pointer to the array, plus how many slots are used and how many exist.

Growing memory

init, push, and free manage the heap array. Growth doubles capacity when full, so the total work stays linear instead of copying on every insert:

#include "stats.h"
#include <stdio.h>
#include <stdlib.h>

int numbers_init(Numbers *n, size_t capacity) {
    n->values = malloc(capacity * sizeof(double));
    if (n->values == NULL) {
        n->count = 0;
        n->capacity = 0;
        return -1;
    }
    n->count = 0;
    n->capacity = capacity;
    return 0;
}

int numbers_push(Numbers *n, double value) {
    if (n->count == n->capacity) {
        size_t next = n->capacity == 0 ? 4 : n->capacity * 2;
        double *grown = realloc(n->values, next * sizeof(double));
        if (grown == NULL) {
            return -1;              // original block is still valid
        }
        n->values = grown;
        n->capacity = next;
    }
    n->values[n->count] = value;
    n->count++;
    return 0;
}

void numbers_free(Numbers *n) {
    free(n->values);
    n->values = NULL;
    n->count = 0;
    n->capacity = 0;
}

Every memory rule from the pointers post is here. malloc counts bytes, so we multiply by sizeof(double). Every allocation is checked against NULL. realloc returns the new block (which may have moved) and must not overwrite the old pointer until it succeeds; that is why we store into grown first. And free sets the pointer back to NULL so a later accidental use is a clean crash, not silent corruption. The struct is always passed as a pointer (Numbers *n), because the functions must modify its fields and a by-value copy would edit a doomed duplicate.

Reading the file

Reading numbers from a file shows pointers, strings, loops, and error handling together:

int numbers_read(Numbers *n, const char *path) {
    FILE *f = fopen(path, "r");
    if (f == NULL) {
        return -1;                  // caller decides how to report it
    }

    double value;
    while (fscanf(f, "%lf", &value) == 1) {
        if (numbers_push(n, value) != 0) {
            fclose(f);              // free the resource before leaving
            return -1;
        }
    }

    fclose(f);
    return 0;
}

fopen returns NULL on failure; the &value passed to fscanf is the address-of operator, because the function writes through it. The loop condition is a fscanf call: it returns 1 for each successfully read number and stops at end of file, which is a compact “read until there is nothing left” pattern. The const char *path parameter means “I read this string, I never modify it”, matching the string post. On the one failure path, the open file is closed before returning, so nothing leaks.

The statistics functions

These are the pure functions, taking const Numbers * and returning a double:

double numbers_average(const Numbers *n) {
    if (n->count == 0) {
        return 0.0;
    }
    double total = 0.0;
    for (size_t i = 0; i < n->count; i++) {
        total += n->values[i];
    }
    return total / (double)n->count;
}

double numbers_min(const Numbers *n) {
    if (n->count == 0) return 0.0;
    double best = n->values[0];
    for (size_t i = 1; i < n->count; i++) {
        if (n->values[i] < best) best = n->values[i];
    }
    return best;
}

double numbers_max(const Numbers *n) {
    if (n->count == 0) return 0.0;
    double best = n->values[0];
    for (size_t i = 1; i < n->count; i++) {
        if (n->values[i] > best) best = n->values[i];
    }
    return best;
}

They take a pointer so the whole array is not copied, and const so a bug inside cannot modify it. The empty-input guard is the defensive habit from the error-handling post: handle the boundary case instead of dividing by zero.

The main function

main owns the command-line arguments, wires the pieces, and reports errors to stderr:

#include "stats.h"
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char **argv) {
    if (argc != 2) {
        fprintf(stderr, "usage: %s <file-with-numbers>\n", argv[0]);
        return EXIT_FAILURE;
    }

    Numbers numbers;
    if (numbers_init(&numbers, 8) != 0) {
        fprintf(stderr, "out of memory\n");
        return EXIT_FAILURE;
    }

    if (numbers_read(&numbers, argv[1]) != 0) {
        fprintf(stderr, "could not read %s\n", argv[1]);
        numbers_free(&numbers);
        return EXIT_FAILURE;
    }

    printf("count:   %zu\n", numbers.count);
    printf("sum:     %.2f\n", numbers_average(&numbers) * numbers.count);
    printf("average: %.2f\n", numbers_average(&numbers));
    printf("min:     %.2f\n", numbers_min(&numbers));
    printf("max:     %.2f\n", numbers_max(&numbers));

    numbers_free(&numbers);
    return EXIT_SUCCESS;
}

argc/argv are how every C program reads its command line: argc is the count, and argv is an array of strings, with argv[0] the program name. The first check rejects the wrong number of arguments. Then memory is allocated, the file is read, results are printed, and the memory is freed on the way out. If reading fails, the program frees before returning, the same cleanup-first habit from the error post.

Build it

Compile all the .c files together and link the math library:

gcc -Wall -Wextra -g -fsanitize=address,undefined -o stats stats.c main.c -lm

With a numbers.txt containing 10 20 30 40 50, running ./stats numbers.txt prints the block at the top of this post. -Wall -Wextra raise the warnings, and the sanitizers turn any memory mistake into a named error.

Test it

The header from the library is what makes testing possible: the test file includes stats.h and calls the same functions, no main needed from the program:

#include "stats.h"
#include <math.h>
#include <stdio.h>
#include <stdlib.h>

static int failures = 0;

#define CHECK(expr)                                                        \
    do {                                                                   \
        if (!(expr)) {                                                     \
            printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #expr);         \
            failures++;                                                    \
        }                                                                  \
    } while (0)

static int approx(double a, double b) {
    return fabs(a - b) < 1e-9;
}

int main(void) {
    Numbers n;
    CHECK(numbers_init(&n, 2) == 0);

    CHECK(numbers_push(&n, 10.0) == 0);
    CHECK(numbers_push(&n, 20.0) == 0);
    CHECK(numbers_push(&n, 30.0) == 0);        // forces a realloc
    CHECK(n.count == 3);

    CHECK(approx(numbers_average(&n), 20.0));
    CHECK(approx(numbers_min(&n), 10.0));
    CHECK(approx(numbers_max(&n), 30.0));

    numbers_free(&n);
    CHECK(n.values == NULL);

    Numbers empty;
    CHECK(numbers_init(&empty, 4) == 0);
    CHECK(approx(numbers_average(&empty), 0.0));   // boundary case
    numbers_free(&empty);

    if (failures > 0) {
        printf("%d test(s) failed\n", failures);
        return EXIT_FAILURE;
    }
    printf("all tests passed\n");
    return EXIT_SUCCESS;
}
gcc -Wall -Wextra -g -fsanitize=address,undefined -o test_stats stats.c test_stats.c -lm
./test_stats

Because the test runs under AddressSanitizer, the realloc path is verified for leaks and invalid access at the same time the results are checked. Every memory rule from the whole series is enforced by the tooling.

The big picture

This tiny program is the series in miniature. The Numbers struct groups fields and is passed by pointer so it can change. malloc/realloc/free manage a runtime-sized array. Strings reach the file functions as const char *. Loops and conditionals do the work; functions package it into named, testable units; error codes and if (f == NULL) handle the failures; sanitizers and a CHECK harness prove it correct. Nothing here is larger than the parts you already learned.

That is C1: a small language whose pieces connect directly to the machine. You now have the whole toolkit, from int main(void) to manual memory and tests. The reward is the one property no other language hands you for free: you always know what your program is doing.

Footnotes

  1. C (programming language) - Wikipedia