Foundations · Module 4 · Lesson 9 of 9
Project: A Contact Book Across Several Files
Build a multi-file contact book: structs, file I/O, line parsing, and a simple build script that compiles everything into one program.
By the end you can
- Organize a program into header, implementation, and main files
- Combine structs, file I/O, and line parsing into a working application
- Write a build script that compiles and links multiple source files
Builds on Parsing Lines: Splitting Text and Converting Fields.
01 · A question first
You have structs for contacts, you can read and write files, and you can parse CSV lines into typed fields. Now put it all together: a program that loads contacts from a text file, lets you add and search them, saves on exit, and builds from three source files with one command.
Make a guess, then step through the trace below to check it.
02 · Watch it run
std::vector<Contact> book = load("contacts.txt");book.push_back(parse("99,Ada,30"));save("contacts.txt", book);- step 0load:3(finished)
- step 1add:?
- step 2save:?
Cells are the program phases: load reads the file line by line, parses each into a Contact, and fills the vector. The file has three lines, so three Contacts exist after load.
Machine work: load: open, read lines, parse, push_back
03 · The idea
Start with the problem
You have learned the pieces: structs that group data and enforce invariants, file streams that read and write text, and a parser that turns CSV lines into typed values. The capstone assembles them into a complete, buildable program — the contact book promised in the track overview. It reads contacts.txt at startup, lets you search and add contacts, writes the updated list on exit, and compiles from three source files with a single script.
How the idea works
The program has four files:
contact.h — the shared interface
Define
Contact, its validation rule, and the declarations shared by the parser, persistence layer, and user interface. The complete implementation appears at the end of this lesson.
parse.cpp — line parsing (from the previous lesson)
Convert one comma-separated line into a validated
Contact, returningstd::optionalwhen the input is malformed instead of throwing from the low-level parser.
io.cpp — file load and save
Keep file ownership and stream errors in the persistence layer. Loading filters invalid records, while saving serializes the vector in one predictable format.
main.cpp — the interactive loop
Orchestrate loading, searching, adding, and saving without duplicating parsing or file-format rules in the user-facing code.
build.sh — one script to build it all
Compile each translation unit with warnings enabled and link the resulting object files into one executable. The full script and source are provided at the end.
Run ./build.sh then ./contact_book. The trace above shows the three phases: load (3 contacts), add (1 more), save (4 written).
Why it is correct
Each file has one job. contact.h owns the type and the function declarations — the contract. parse.cpp and io.cpp implement the contract. main.cpp drives the user interaction. The build script compiles each .cpp to an object file (translation unit) and links them. The linker resolves load_contacts from io.o, parse_contact from parse.o, and main from main.o. No duplicate definitions, no missing symbols.
The std::optional return from parse_contact makes failure explicit: the caller must check has_value() before using the contact. Invalid lines are skipped silently during load; the interactive add command reports the failure to the user. The valid() method enforces the same invariants the struct promised in lesson 1.
What it costs
- Build time: three compiles + one link. On a modern laptop, under a second. Changing one
.cpprecompiles only that file (the build script is naive but correct;makeorninjawould track dependencies automatically). - Run time: load and save are O(n) lines. The vector holds all contacts in contiguous memory — cache-friendly for
listandfind. Search is linear; a real system would index by name, but for a few thousand contacts linear is instant. - Memory: one
Contactper entry (id + string + age). The string owns its characters. No leaks: vector cleans up on exit, ofstream flushes on close.
Pitfalls and variations
Header guards. #ifndef CONTACT_H prevents double inclusion if a file includes contact.h twice (directly and through another header). C++20 modules replace this, but guards work everywhere.
Error handling. load_contacts skips bad lines. A stricter version would abort or collect errors. The choice depends on whether the file is trusted (generated by your own save) or hostile (user-edited).
Concurrency. This program is single-threaded. A multi-user server would need file locking or a database. The struct and parser stay the same; only the I/O layer changes.
Build systems. The shell script is explicit and portable. For larger projects, CMake or Meson generates the same compile/link steps with dependency tracking and cross-platform support.
Making the small program durable
The example is intentionally small, but a production version should make its failure policy explicit. Opening the input file can fail because the path does not exist, permissions changed, or another process has the file open. load_contacts should distinguish an absent first-run file from a file that exists but cannot be read. One useful policy is to treat a missing file as an empty book, while reporting permission and format failures with the filename and line number. Skipping malformed records can keep the application usable, but silently losing data is dangerous; collect rejected lines and show a summary before the user exits.
Saving also deserves more care than simply opening the destination with std::ofstream. If the process crashes halfway through the write, the original contact file can be truncated. A safer sequence writes the complete book to a temporary file in the same directory, flushes and closes it, then replaces the destination with a rename operation. The exact replacement guarantees vary by platform, so a real application should document the durability promise and test it on every supported operating system. If two processes can edit the book, file replacement alone does not provide conflict detection; use a lock or move the data to a database.
The parser currently accepts three comma-separated fields and does not support commas or newlines inside a name. That is a deliberate teaching boundary, not a complete CSV implementation. A production format must either escape quoted fields according to a documented CSV grammar or choose a serialization format with an existing library. It should also reject duplicate ids, trim or preserve whitespace consistently, and define whether names are compared case-sensitively. These rules belong in the parsing and validation layer so the interactive loop does not invent a second interpretation.
Tests can exercise each layer without driving the whole terminal session. Test valid and malformed lines for parse_contact, including missing fields, trailing characters and ages outside the allowed range. Use a temporary directory to test loading and saving, then reload the saved file and compare contacts rather than comparing incidental formatting. Finally, compile the three translation units with warnings enabled in CI. The small build script teaches the translation-unit boundary; automated tests and reproducible compiler flags make that boundary dependable for a team.
Complete project solution
contact.h
#ifndef CONTACT_H
#define CONTACT_H
#include <cstddef>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
struct Contact {
int id;
std::string name;
int age;
bool valid() const { return age >= 0 && age <= 150 && !name.empty(); }
};
std::optional<Contact> parse_contact(std::string_view line);
std::string to_csv(const Contact& c);
std::vector<Contact> load_contacts(const std::string& path);
void save_contacts(const std::string& path, const std::vector<Contact>& book);
#endifparse.cpp
#include "contact.h"
#include <charconv>
#include <optional>
std::optional<Contact> parse_contact(std::string_view sv) {
size_t pos = 0;
int id = 0, age = 0;
std::string name;
auto take = [&](char delim) -> std::string_view {
size_t end = sv.find(delim, pos);
std::string_view field = sv.substr(pos, end - pos);
pos = (end == std::string_view::npos) ? sv.size() : end + 1;
return field;
};
auto f_id = take(',');
auto [p1, ec1] = std::from_chars(f_id.data(), f_id.data() + f_id.size(), id);
if (ec1 != std::errc{} || p1 != f_id.data() + f_id.size()) return std::nullopt;
name = std::string(take(','));
auto f_age = take(',');
auto [p2, ec2] = std::from_chars(f_age.data(), f_age.data() + f_age.size(), age);
if (ec2 != std::errc{} || p2 != f_age.data() + f_age.size()) return std::nullopt;
Contact c{id, name, age};
if (!c.valid()) return std::nullopt;
return c;
}
std::string to_csv(const Contact& c) {
return std::to_string(c.id) + "," + c.name + "," + std::to_string(c.age);
}io.cpp
#include "contact.h"
#include <fstream>
#include <vector>
std::vector<Contact> load_contacts(const std::string& path) {
std::vector<Contact> book;
std::ifstream in(path);
std::string line;
while (std::getline(in, line)) {
if (auto c = parse_contact(line); c.has_value()) {
book.push_back(*c);
}
}
return book;
}
void save_contacts(const std::string& path, const std::vector<Contact>& book) {
std::ofstream out(path);
for (const auto& c : book) {
out << to_csv(c) << '\n';
}
}main.cpp
#include "contact.h"
#include <iostream>
#include <string>
int main() {
const std::string file = "contacts.txt";
auto book = load_contacts(file);
std::cout << "Loaded " << book.size() << " contacts.\n";
std::cout << "Commands: list | add <id,name,age> | find <name> | quit\n";
std::string cmd;
while (std::cout << "> " && std::getline(std::cin, cmd)) {
if (cmd == "quit") break;
if (cmd == "list") {
for (const auto& c : book) std::cout << to_csv(c) << '\n';
} else if (cmd.rfind("add ", 0) == 0) {
if (auto c = parse_contact(cmd.substr(4)); c.has_value() && c->valid()) {
book.push_back(*c);
std::cout << "Added.\n";
} else {
std::cout << "Invalid contact.\n";
}
} else if (cmd.rfind("find ", 0) == 0) {
std::string q = cmd.substr(5);
for (const auto& c : book) {
if (c.name.find(q) != std::string::npos) std::cout << to_csv(c) << '\n';
}
} else {
std::cout << "Unknown command.\n";
}
}
save_contacts(file, book);
std::cout << "Saved " << book.size() << " contacts.\n";
}build.sh
#!/usr/bin/env bash
set -euo pipefail
g++ -std=c++17 -Wall -Wextra -O2 -c parse.cpp -o parse.o
g++ -std=c++17 -Wall -Wextra -O2 -c io.cpp -o io.o
g++ -std=c++17 -Wall -Wextra -O2 -c main.cpp -o main.o
g++ parse.o io.o main.o -o contact_book
echo "Built contact_book"Where it is used
This is the foundations project: a command-line contact book that stores data in a text file, built from several source files with a simple build script. Every later track project (library management, thread pool, allocator benchmark) follows the same architecture: types in headers, implementations in .cpp, build script or CMake, tests alongside. You now have the complete foundation to move into Core, where classes add polymorphism, STL replaces hand-written containers, and exceptions replace error codes.
04 · Prove it
Question 1 of 3 · answer from memory
Why put the Contact struct definition in a header file instead of main.cpp?
05 · Practice
Asked in interviews
Something wrong with this lesson? Report a problem