Worked example project — a command-line contact manager in C
This page walks through one complete, compilable C program from an empty file to a working tool. It is the kind of moderately-complex program the syllabus asks each group to build for the Group Project (20% of the grade): a command-line contact / inventory manager that stores records of people, lets you add, list, search and delete them, and saves everything to a CSV file on disk so the data survives between runs.
Although small, it deliberately exercises the heart of the course: struct records,
typedef, pointers, dynamic memory (malloc / realloc /
free) backed by a growable array, file I/O for save & load, careful input validation, and a
menu-driven main loop. Every snippet below is real C — copy them in order into one file and it compiles and
runs. The companion interactive demos animate the underlying ideas; the
course outline maps each one to a session.
build & run (one translation unit, no external libraries)
# compile with all warnings on — treat them as errors while learning gcc -std=c11 -Wall -Wextra -O2 -o contacts contacts.c # run it ./contacts # (optional) check for leaks & invalid memory use under valgrind valgrind --leak-check=full ./contacts
Sessions / modules this project exercises:
1 · Design — the data model
Before writing code we decide what a record is and where records live.
A contact is a small bundle of related fields, which is exactly what a struct is for
(see the structs & memory layout demo). The whole collection lives in a
dynamic array: a single malloc'd block of Contact values plus two
integers — how many slots are used and how many are allocated. When it fills up we realloc to a
bigger block. This gives us O(1) append and contiguous, cache-friendly storage, in contrast to the pointer
chasing of a linked list.
the record and the store (top of contacts.c)
#include <stdio.h> #include <stdlib.h> #include <string.h> #define NAME_LEN 64 #define EMAIL_LEN 96 #define LINE_LEN 256 /* One contact record. Fixed-size char arrays keep each record a flat, copyable value — no inner pointers to free, which simplifies memory. */ typedef struct { int id; /* unique, assigned on insert */ char name[NAME_LEN]; /* "Ada Lovelace" */ char email[EMAIL_LEN]; /* "ada@analytical.engine" */ int age; } Contact; /* The dynamic store: a growable array of Contact values. `count` is how many are in use; `capacity` is how many fit before we must grow. `items` is the malloc'd block (NULL when empty). */ typedef struct { Contact *items; size_t count; size_t capacity; int next_id; /* monotonically increasing id source */ } Book;
Module breakdown. The program is organised into small single-purpose functions, grouped by responsibility — the same decomposition the syllabus asks groups to plan and divide among members:
2 · Step-by-step implementation
We build bottom-up: first the store and its memory management, then queries, then validated input, then file I/O, and finally the menu loop that calls everything.
The dynamic array: init, grow, append, free
The store starts empty (items == NULL). book_reserve is the one place that
calls realloc: it doubles the capacity (starting at 4) only when the array is full, so a run
of n appends costs O(n) total. We always assign realloc's result to a
temporary first — if it returns NULL the original block is still valid and we have not
leaked it. This is the dynamic-memory pattern from
Module 2, Session 6.
contacts.c — store
void book_init(Book *b) { b->items = NULL; b->count = 0; b->capacity = 0; b->next_id = 1; } /* Ensure room for at least `need` items. Returns 1 on success, 0 on allocation failure (leaving the existing buffer untouched). */ int book_reserve(Book *b, size_t need) { if (need <= b->capacity) return 1; /* already big enough */ size_t new_cap = b->capacity ? b->capacity : 4; while (new_cap < need) new_cap *= 2; /* grow geometrically */ /* realloc into a TEMP so we don't lose the old pointer on failure */ Contact *tmp = realloc(b->items, new_cap * sizeof(Contact)); if (tmp == NULL) { fprintf(stderr, "out of memory (wanted %zu slots)\n", new_cap); return 0; } b->items = tmp; b->capacity = new_cap; return 1; } /* Append a copy of `c`, assigning it the next free id. Returns the new id, or -1 if the store could not grow. */ int book_add(Book *b, Contact c) { if (!book_reserve(b, b->count + 1)) return -1; c.id = b->next_id++; b->items[b->count++] = c; /* struct copy into the slot */ return c.id; } /* Release the whole block and reset to the empty state. Calling book_free twice is safe because we NULL the pointer afterwards. */ void book_free(Book *b) { free(b->items); b->items = NULL; b->count = 0; b->capacity = 0; }
Queries: find, list, delete
book_find returns a pointer to the matching record (or NULL),
so the caller can read or edit it in place — a direct use of the
pointers & memory idea that a pointer is just an address into the array.
book_delete removes by id while keeping the array contiguous: it shifts the tail down with
memmove rather than leaving a hole. Order is not preserved guarantees here — we keep it simple
and stable by shifting.
contacts.c — queries
/* Linear scan for an id. Returns a pointer INTO the array (do not free it) or NULL if absent. See the linear-vs-binary search demo. */ Contact *book_find(Book *b, int id) { for (size_t i = 0; i < b->count; i++) if (b->items[i].id == id) return &b->items[i]; return NULL; } void book_list(const Book *b) { if (b->count == 0) { puts(" (no contacts yet)"); return; } printf(" %-4s %-22s %-28s %s\n", "ID", "NAME", "EMAIL", "AGE"); for (size_t i = 0; i < b->count; i++) { const Contact *c = &b->items[i]; printf(" %-4d %-22s %-28s %d\n", c->id, c->name, c->email, c->age); } } /* Delete by id. Returns 1 if a record was removed, else 0. Closes the gap with memmove so the array stays contiguous (no tombstones). */ int book_delete(Book *b, int id) { for (size_t i = 0; i < b->count; i++) { if (b->items[i].id == id) { size_t tail = b->count - i - 1; /* items after i */ memmove(&b->items[i], &b->items[i + 1], tail * sizeof(Contact)); b->count--; return 1; } } return 0; }
Safe console input (validation)
The classic beginner bug is mixing scanf("%d") with fgets and leaving a stray
newline in the buffer. We avoid it by reading whole lines with fgets and parsing them
ourselves. read_line also strips the trailing '\n' and rejects empty input;
read_int uses strtol so we can detect non-numeric junk and out-of-range values
instead of silently accepting them.
contacts.c — input
/* Read a trimmed, non-empty line into `buf` (size `cap`). Returns 1 on success, 0 on EOF. Extra characters beyond the buffer are discarded. */ int read_line(const char *prompt, char *buf, size_t cap) { printf("%s", prompt); if (fgets(buf, (int)cap, stdin) == NULL) return 0; /* EOF / error */ size_t len = strlen(buf); if (len > 0 && buf[len - 1] == '\n') { buf[len - 1] = '\0'; /* chop the newline */ } else { int ch; /* line too long: flush rest */ while ((ch = getchar()) != '\n' && ch != EOF) { } } return buf[0] != '\0'; /* reject empty input */ } /* Read an integer in [lo, hi]. Re-prompts until valid or EOF (-> def). */ int read_int(const char *prompt, int lo, int hi, int def) { char buf[LINE_LEN]; while (read_line(prompt, buf, sizeof buf)) { char *end; long v = strtol(buf, &end, 10); if (end != buf && *end == '\0' && v >= lo && v <= hi) return (int)v; printf(" please enter a whole number %d..%d\n", lo, hi); } return def; /* EOF: fall back to default */ }
Persistence: save & load a CSV file
This is Module 3, Session 8 in action — opening files, checking the
FILE* for NULL, and always fclose-ing. We pick a simple line
format: one record per line, fields separated by commas. Saving is a straightforward loop; loading rebuilds
the store with the same book_add path so growth and ids are handled for free. Note we re-derive
next_id so saved ids are not reused.
contacts.c — persistence
/* Write the whole book to `path` as CSV. Returns 1 on success. NOTE: this simple format assumes names/emails contain no commas. */ int book_save(const Book *b, const char *path) { FILE *f = fopen(path, "w"); if (f == NULL) { perror(path); return 0; } /* check the handle! */ for (size_t i = 0; i < b->count; i++) { const Contact *c = &b->items[i]; fprintf(f, "%d,%s,%s,%d\n", c->id, c->name, c->email, c->age); } fclose(f); /* release the OS resource */ return 1; } /* Load records from `path` into an already-initialised book. A missing file is not an error (first run) — returns 1 with an empty book. */ int book_load(Book *b, const char *path) { FILE *f = fopen(path, "r"); if (f == NULL) return 1; /* no file yet: fine */ char line[LINE_LEN]; int max_id = 0; while (fgets(line, sizeof line, f)) { Contact c = {0}; /* %[^,] reads up to the next comma; widths guard the buffers */ int n = sscanf(line, "%d,%63[^,],%95[^,],%d", &c.id, c.name, c.email, &c.age); if (n == 4) { book_reserve(b, b->count + 1); b->items[b->count++] = c; if (c.id > max_id) max_id = c.id; } } fclose(f); b->next_id = max_id + 1; /* don't reuse loaded ids */ return 1; }
The menu loop & main
Finally the menu loop: print options, read a choice with the validated
read_int, dispatch with a switch, and repeat until the user quits. main
owns the single Book on the stack, loads any saved data at start, and — crucially —
book_save + book_free on the way out so there are no leaks and the data persists.
contacts.c — ui + main
#define DB_PATH "contacts.csv" void do_add(Book *b) { Contact c = {0}; if (!read_line(" name : ", c.name, sizeof c.name)) return; if (!read_line(" email: ", c.email, sizeof c.email)) return; c.age = read_int(" age : ", 0, 150, 0); int id = book_add(b, c); if (id < 0) puts(" could not add (out of memory)"); else printf(" added contact #%d\n", id); } void do_find(Book *b) { int id = read_int(" id to find: ", 1, 1000000, -1); Contact *c = book_find(b, id); if (c) printf(" #%d %s <%s> age %d\n", c->id, c->name, c->email, c->age); else puts(" no contact with that id"); } void menu(void) { puts("\n=== contact manager ==="); puts(" 1) add 2) list 3) find"); puts(" 4) delete 5) save 0) quit"); } int main(void) { Book book; book_init(&book); book_load(&book, DB_PATH); printf("loaded %zu contact(s) from %s\n", book.count, DB_PATH); int running = 1; while (running) { menu(); int choice = read_int(" > ", 0, 5, 0); switch (choice) { case 1: do_add(&book); break; case 2: book_list(&book); break; case 3: do_find(&book); break; case 4: { int id = read_int(" id to delete: ", 1, 1000000, -1); puts(book_delete(&book, id) ? " deleted" : " not found"); break; } case 5: if (book_save(&book, DB_PATH)) printf(" saved %zu contact(s)\n", book.count); break; case 0: running = 0; break; } } book_save(&book, DB_PATH); /* persist on exit */ book_free(&book); /* free the buffer */ puts("bye."); return 0; }
gcc -std=c11 -Wall -Wextra the five steps
above form a complete program with no warnings. Paste them into contacts.c in the order shown
(store → queries → input → persistence → ui/main).3 · Results — a sample run
A full terminal session: we start with no data file, add two contacts, list them, look one up, delete one, save, and quit. On the second run the saved contact is loaded back automatically — proving persistence works.
$ gcc -std=c11 -Wall -Wextra -O2 -o contacts contacts.c $ ./contacts loaded 0 contact(s) from contacts.csv === contact manager === 1) add 2) list 3) find 4) delete 5) save 0) quit > 1 name : Ada Lovelace email: ada@analytical.engine age : 36 added contact #1 === contact manager === ... > 1 name : Alan Turing email: alan@bombe.uk age : 41 added contact #2 === contact manager === ... > 2 ID NAME EMAIL AGE 1 Ada Lovelace ada@analytical.engine 36 2 Alan Turing alan@bombe.uk 41 > 3 id to find: 2 #2 Alan Turing <alan@bombe.uk> age 41 > 4 id to delete: 1 deleted > 5 saved 1 contact(s) > 0 bye. # second run — data survived on disk: $ ./contacts loaded 1 contact(s) from contacts.csv > 2 ID NAME EMAIL AGE 2 Alan Turing alan@bombe.uk 41 # the CSV file on disk: $ cat contacts.csv 2,Alan Turing,alan@bombe.uk,41
4 · Memory-safety notes
Most "mysterious" C bugs are memory bugs. This project follows a handful of rules that, applied consistently, eliminate the common ones — the discipline emphasised across Module 2.
- Every
malloc/reallocis paired with afree. The store owns exactly one block;book_freereleases it andmainalways calls it before returning. Run undervalgrind --leak-check=full→ "All heap blocks were freed". reallocinto a temporary.book_reservenever overwritesb->itemswithrealloc's result directly — if it returnedNULLthe old block would leak. We assign totmpfirst, check it, then commit.- Check every resource handle.
fopencan returnNULL; we test it (and useperror) before reading or writing, and a missing file on load is treated as an empty book rather than a crash. - Bounded string input.
fgetstakes the buffer size;sscanfuses field-width limits (%63[^,],%95[^,]) that match the array sizes, so a long token can never overrunnameoremail. - No dangling pointers.
book_findreturns a pointer into the array; callers use it immediately and never hold it across anadd(which mayreallocand move the block).book_freeNULLs the pointer so a stray double-free is harmless. - Zero-initialise records with
Contact c = {0};so unset fields are never read as garbage.
5 · Mapping to learning outcomes
How each part of the project lands on the syllabus modules and the matching interactive demo:
| Project feature | Syllabus module / session | Demo |
|---|---|---|
| menu loop, printf/scanf, control flow | Module 0 — C basics & I/O (S1–2) | variable tracing, loops |
| book_find returns a pointer into the array | Module 2 — Pointers (S5) | pointers & memory |
| malloc / realloc / free, growable buffer | Module 2 — Dynamic Memory (S6) | pointers & memory |
| Contact & Book structs, typedef | Module 2 — Structures & Unions (S7) | structs & memory layout |
| book_save / book_load CSV, fopen/fclose | Module 3 — File I/O (S8) | — |
| contiguous store vs. node-based store | Module 4 — Linked Lists (S13) | linked lists |
| linear search in book_find | algorithms / Module 0 | linear vs binary search |
6 · Extensions
Natural next steps that reuse the same data model and reinforce later course topics — good candidates for the group project's "moderate complexity":
- Sorting. Add
book_sort_by_nameusingqsortwith a comparatorint cmp(const void *a, const void *b)— a clean demonstration of function pointers and comparison-based sorting. - Binary search. Once sorted by id (which insertion order already gives us), replace the
linear
book_findwithbsearchfor O(log n) lookup — compare against the search demo. - Hashing. Index contacts by email in a hash table for O(1) average lookup; revisit the bit-mixing ideas from bit manipulation for the hash function.
- Linked-list backend. Swap the dynamic array for a singly linked list to get O(1) deletion without shifting — and feel the loss of random access. A nice A/B exercise on data-structure trade-offs.
- Robust CSV. Quote and escape fields so names with commas round-trip correctly; add a header row and a schema version.
- Unit tests. Cover
book_add/delete/findwith GTest and CMake, as introduced in Module 1, Session 4.
7 · References
-
The C Programming Language, 2nd ed.The definitive reference. §6 (structures), §7.8.5 (malloc/free), §7 (file I/O via
fopen/fgets/fprintf) directly underpin this project. -
Practical C Programming, 3rd ed.Hands-on coverage of dynamic memory, defensive input handling and debugging — the practical habits used throughout the memory-safety section.
-
cp1-lab — interactive demos & course outlineInteractive demos animate pointers, structs, search, sorting and linked lists; the course outline maps every session.