The Last Program

Everything in this lesson has already been taught. Structures came from chapter 10, the pointer that walks them from chapter 11, malloc and realloc and free from chapter 13, fopen and fscanf from chapter 12, the guarded scanf habit from chapter 4, and the shape that holds it together from the lesson two back. What is new is only the scale: one program with six commands, a growing array of records, a file it can reload next time, and a check on every call that can fail. Build it the way the previous lesson said to build things — outline first — and it stays a set of small problems.

Stage One: Analyse the Problem

Before naming a single function, answer three questions in writing. Skipping this stage is what turns program design into guesswork, and the answers decide the data structure before any code exists.

What goes in? Lines on standard input, one command each: add 101 Asha 78, find 101, delete 101, list, stats, quit. Records may also arrive from a file saved by an earlier run.

What comes out? One line of confirmation per successful command on standard output, the listing and the statistics likewise, and every complaint on standard error — a habit worth keeping because ./records > report.txt should capture the report and not the complaints.

What are the constraints? The number of records is not known when the program starts, so a fixed array is either too small or mostly waste: the store has to grow, which means realloc and a capacity. A name has a maximum length, because a char array must be declared with one. A roll number identifies a student, so it must be unique and positive. Marks are 0 to 100. And the program must give back every byte it took, because this platform's AddressSanitizer reports what it does not.

Stage Two: The Algorithm Before the Code

Two of these functions have logic worth writing out before writing C, because a mistake in prose costs a minute and a mistake in code costs an afternoon. Pseudocode is enough; a flowchart says the same thing in boxes if your exam paper asks for one.

addRecord(db, roll, name, marks):
    if roll is not positive        -> complain, return failure
    if marks outside 0..100       -> complain, return failure
    if roll already in db         -> complain, return failure
    if db is full                 -> grow it; if that fails, return failure
    write the three fields at index count
    count = count + 1
    return success

deleteRecord(db, roll):
    found = findRecord(db, roll)
    if found is NULL              -> return failure
    shift every record after found one place left
    count = count - 1
    return success

Read those again and notice what the order buys. Every one of addRecord's tests happens before anything is written, so a rejected record cannot leave the store half-modified. And deleteRecord shifts before it decrements, because the loop needs the old count to know where the records end.

Stage Three: Design the Data

Two structures. struct student is one record. struct records is the store, and it is chapter 13's pattern: a pointer, the number of records in it, and the number it could hold.

struct student {
    int roll;
    char name[NAME_LEN];
    int marks;
};

struct records {
    struct student *items;
    int count;
    int capacity;
};

count and capacity are different numbers and confusing them is the classic bug here: count is how many records exist, capacity is how many fit before the next realloc. The growth policy is to double, starting at 2, so filling the store to n records costs about log2(n) reallocations rather than n of them. And the realloc result lands in a temporary first:

grown = realloc(db->items, (size_t)capacity * sizeof *grown);
if (grown == NULL) {
    fprintf(stderr, "out of memory\n");
    return 0;
}
db->items = grown;

Writing db->items = realloc(db->items, ...) instead would destroy the only pointer to the existing records on the day the allocator returns NULL — the records would still be allocated and no longer reachable. Note also sizeof *grown rather than sizeof(struct student): it asks the compiler for the size of whatever grown points at, so changing the element type cannot leave a stale size behind.

The Program

Here is the whole thing. Read the prototype block, then main, and you know the program before reading a single helper.

Given the input add 101 Asha 78, add 102 Ravi 65, add 103 Meera 91, list, find 102, delete 102, list, stats, quit, one line each, standard output is:

commands: add <roll> <name> <marks> | find <roll> | delete <roll> | list | stats | quit
added 101 Asha 78
added 102 Ravi 65
added 103 Meera 91
101 Asha 78
102 Ravi 65
103 Meera 91
102 Ravi 65
deleted 102
101 Asha 78
103 Meera 91
2 records, average 84.50

Standard error is empty, the exit status is 0, and AddressSanitizer reports nothing.

Four details in there are worth naming. The whole line is read with fgets and then picked apart with sscanf on the buffer, rather than reading fields straight from stdin, because a malformed line then costs one line rather than desynchronizing every read after it. The %15s and %23s field widths are what make those %s conversions safe: each is one less than its array, leaving the null terminator its byte, and it is exactly the width that lets addRecord use strcpy honestly — the source cannot be longer than the destination. Every sscanf is compared against the number of conversions expected, never assumed. And main owns db as a local, lending it to helpers by address; nothing here is a global.

Persistence: Save and Load

Two functions carry the store to disk and back. This pair is one to compile on your own machine rather than in the sandbox, which gives a running program no writable directory:

int saveRecords(const struct records *db, const char *path)
{
    FILE *fp;
    int i;

    fp = fopen(path, "w");
    if (fp == NULL) {
        fprintf(stderr, "cannot open %s for writing\n", path);
        return 0;
    }
    fprintf(fp, "%d\n", db->count);
    for (i = 0; i < db->count; i++) {
        fprintf(fp, "%d %s %d\n", db->items[i].roll, db->items[i].name,
                db->items[i].marks);
    }
    if (fclose(fp) != 0) {
        fprintf(stderr, "writing %s failed\n", path);
        return 0;
    }
    printf("saved %d records to %s\n", db->count, path);
    return 1;
}

int loadRecords(struct records *db, const char *path)
{
    FILE *fp;
    struct student entry;
    int expected;
    int i;

    fp = fopen(path, "r");
    if (fp == NULL) {
        printf("no saved records in %s yet\n", path);
        return 0;
    }
    if (fscanf(fp, "%d", &expected) != 1) {
        fprintf(stderr, "%s does not start with a record count\n", path);
        fclose(fp);
        return 0;
    }
    for (i = 0; i < expected; i++) {
        if (fscanf(fp, "%d %23s %d", &entry.roll, entry.name, &entry.marks) != 3) {
            fprintf(stderr, "%s ended after %d of %d records\n", path, i, expected);
            break;
        }
        if (addRecord(db, entry.roll, entry.name, entry.marks) == 0) {
            break;
        }
    }
    fclose(fp);
    printf("loaded %d records\n", db->count);
    return 1;
}

The count is written first so the loader knows how many records to expect, and the loader then treats that count as a claim rather than a fact — a file that ends early says so and stops, and it reuses addRecord so a corrupt roll number is rejected by the same three tests that reject a typed one. Two checks here are easy to skip and both matter. A missing file is not an error: on the first run there is nothing saved yet, so fopen returning NULL prints a note on standard output and the program carries on with an empty store. And fclose is checked on the writing path, which is the check almost nobody writes: fprintf only copies into a buffer, and the flush that actually reaches the disk happens inside fclose, so a full disk shows up there and nowhere earlier. Two real sessions, the second run started after the first had exited:

no saved records in marks.dat yet
added 101 Asha 78
added 102 Ravi 65
saved 2 records to marks.dat
loaded 2 records
101 Asha 78
102 Ravi 65
2 records, average 71.50

Test Each Function Alone, Then Together

A program this size is not tested by running it once and reading the output. Test in two stages, the way the discipline has been taught since long before either of us: each function on its own first, then the whole program.

Testing one function alone means feeding it the inputs that reach each of its exits and checking each result. addRecord has five exits, so it needs five cases: a bad roll, bad marks, a duplicate, a successful add, and a failed growth. deleteRecord has three: absent, present-and-not-last, present-and-last. findRecord has two. Reading the function and counting its return statements is how you know when the list of cases is finished, and a function small enough to have three or four exits is a function you can genuinely finish testing — which is the practical argument for one job per function, on top of the readability one.

Only then run the assembled program, because whole-program testing finds a different class of bug: the ones that live in the joins between functions rather than inside any of them. Loading a file into a store that already holds records is one such join. A delete followed by an add is another, since the add writes at count, which the delete just moved. Neither bug is visible while testing either function alone.

Test Data That Reaches Every Path

Deciding what to feed the program is most of the work, and the useful criterion is coverage of conditions and paths, not the number of runs. Four kinds of case are always worth writing, and this program's answers to all four are real runs:

Nothing at all. The empty store is where the off-by-one bugs live, because a loop over zero records and an average over zero records both have to do the right thing. Here list, stats, find 101, delete 101 on an empty store print no records, no records, and nothing at all for the two failures, which report on standard error. Note also what the empty run proves at exit: freeRecords is handed a NULL items, and free(NULL) is defined to do nothing, which is why the teardown needs no special case.

Full, and one past full. The growth boundary is the other end of the same problem. Five adds force the capacity from 2 to 4 to 8, so the realloc path runs twice and the records that already existed have to survive being moved:

added 1 a 10
added 2 b 20
added 3 c 30
added 4 d 40
added 5 e 50
5 records, average 30.00

There is a sharper reason to include a store that is exactly full, and it is worth knowing because it decides whether a whole class of bug is visible at all. Suppose deleteRecord's shifting loop were bounded one too far, so it read the record after the last one. Whenever capacity is larger than count — which is most of the time, since capacity doubles — that read lands on memory the store already owns, so nothing detects it and the wrong value is then hidden by the decrement. Only when count equals capacity does the same read leave the allocated block, and then AddressSanitizer stops the program with heap-buffer-overflow. The bug is in the program either way; the test data decides whether the run reports it.

A missing or malformed file. Covered above: the missing file is a normal first run, and a file that ends early is reported and partially loaded rather than trusted.

Input that is wrong in every way you can think of. Duplicate roll, marks above 100, a negative roll, an add with a field missing, and a command that does not exist. The point of this run is that standard output stays clean while standard error carries all five complaints, and the program is still running afterwards:

added 101 Asha 78
101 Asha 78
roll 101 already exists
marks must be 0 to 100
roll must be positive
usage: add <roll> <name> <marks>
unknown command sort

One warning about reading those two streams together. Standard output is fully buffered when it is redirected to a file or a pipe, while standard error is not, so combining them with 2>&1 shows the complaints ahead of output that was printed before them. The interleaving is an artefact of buffering, not evidence of the order things happened.

Key Takeaways

  • Analyse before designing: write down what goes in, what comes out, and what the constraints are — here it is the unknown number of records that forces a growing array, and that decision comes from the constraints rather than from taste.
  • Write the algorithm before the code for anything with real logic; addRecord validating everything before writing anything, and deleteRecord shifting before decrementing, are both decisions made in pseudocode and merely typed in C.
  • The store is a pointer, a count, and a capacity; capacity doubles, and the realloc result goes into a temporary that is checked before the old pointer is overwritten.
  • Read whole lines with fgets and parse them with sscanf, give every %s a field width one less than its array, and compare every sscanf against the number of conversions you expected.
  • A missing file on the first run is a normal case, not a failure; and fclose is checked on a writing path because that is where the buffer is flushed and where a write actually fails.
  • Test each function alone by counting its exits and reaching every one, then test the assembled program to catch the bugs that live in the joins between functions.
  • Choose test data for coverage of paths: nothing at all, full and one past full, a missing or truncated file, and input that is wrong in every way you can think of.