Lesson 9 of 9
Time to build something real. This project uses only what Part I has covered: a plain struct, a
std::vector, functions, file I/O, and std::format. No pointers, no classes with methods, no
inheritance.
What you're building
A task manager that:
- adds a task with a title
- lists all tasks, showing which are done
- marks a task complete by id
- deletes a task by id
- saves to a text file and loads it on startup
Step 1: the data
One struct and one vector. That's the whole data model.
#include <format>
#include <iostream>
#include <string>
#include <vector>
struct Task {
int id = 0;
std::string title;
bool done = false;
};
int next_id(const std::vector<Task>& tasks) {
int highest = 0;
for (const Task& t : tasks) {
if (t.id > highest) {
highest = t.id;
}
}
return highest + 1;
}
void add_task(std::vector<Task>& tasks, const std::string& title) {
if (title.empty()) {
std::cout << "Title cannot be empty.\n";
return;
}
tasks.push_back({next_id(tasks), title, false});
std::cout << std::format("Added #{}: {}\n", tasks.back().id, title);
}
int main() {
std::vector<Task> tasks;
add_task(tasks, "write the lesson");
add_task(tasks, "verify every sample");
add_task(tasks, "");
std::cout << std::format("{} task(s)\n", tasks.size());
return 0;
}
Added #1: write the lesson
Added #2: verify every sample
Title cannot be empty.
2 task(s)
A struct is just a bundle of named values — Module 9 covers them properly. The = 0 and
= false are default member initializers, so a Task is never born uninitialized
(lesson 3.1).
Two parameter choices worth noting, both straight from lesson 5.4: tasks is std::vector<Task>&
because add_task must modify the caller's vector, and title is const std::string& because
it's only read.
next_id scans for the highest id rather than using size() + 1. That matters — after deleting
a task, size() + 1 would reuse an id that's still in use.
Step 2: complete and delete
#include <format>
#include <iostream>
#include <string>
#include <vector>
struct Task {
int id = 0;
std::string title;
bool done = false;
};
void list_tasks(const std::vector<Task>& tasks) {
if (tasks.empty()) {
std::cout << "No tasks yet.\n";
return;
}
for (const Task& t : tasks) {
std::cout << std::format("{:>3} [{}] {}\n", t.id, t.done ? 'x' : ' ', t.title);
}
int remaining = 0;
for (const Task& t : tasks) {
if (!t.done) ++remaining;
}
std::cout << std::format("{} of {} remaining\n", remaining, tasks.size());
}
bool complete_task(std::vector<Task>& tasks, int id) {
for (Task& t : tasks) {
if (t.id == id) {
t.done = true;
return true;
}
}
return false;
}
bool delete_task(std::vector<Task>& tasks, int id) {
for (std::size_t i = 0; i < tasks.size(); ++i) {
if (tasks[i].id == id) {
tasks.erase(tasks.begin() + static_cast<long>(i));
return true;
}
}
return false;
}
int main() {
std::vector<Task> tasks = {{1, "alpha", false}, {2, "beta", false}, {3, "gamma", false}};
list_tasks(tasks);
std::cout << (complete_task(tasks, 2) ? "Completed #2\n" : "No task #2\n");
std::cout << (complete_task(tasks, 99) ? "Completed #99\n" : "No task #99\n");
std::cout << (delete_task(tasks, 1) ? "Deleted #1\n" : "No task #1\n");
list_tasks(tasks);
return 0;
}
1 [ ] alpha
2 [ ] beta
3 [ ] gamma
3 of 3 remaining
Completed #2
No task #99
Deleted #1
2 [x] beta
3 [ ] gamma
1 of 2 remaining
complete_task loops with Task& — a reference, so t.done = true modifies the real task,
not a copy (lesson 4.4's & rule, and the reason it mattered).
Both return bool rather than printing "not found" themselves. That keeps the decision in the
function and the message at the call site, which is what makes them testable later.
Step 3: saving and loading
#include <fstream>
#include <iostream>
#include <string>
#include <vector>
struct Task {
int id = 0;
std::string title;
bool done = false;
};
bool save_tasks(const std::vector<Task>& tasks, const std::string& path) {
std::ofstream out(path);
if (!out) {
return false;
}
for (const Task& t : tasks) {
out << t.id << '\t' << (t.done ? 1 : 0) << '\t' << t.title << '\n';
}
return true;
}
std::vector<Task> load_tasks(const std::string& path) {
std::vector<Task> tasks;
std::ifstream in(path);
if (!in) {
return tasks;
}
std::string line;
while (std::getline(in, line)) {
if (line.empty()) continue;
std::size_t first = line.find('\t');
std::size_t second = line.find('\t', first + 1);
if (first == std::string::npos || second == std::string::npos) continue;
Task t;
try {
t.id = std::stoi(line.substr(0, first));
} catch (...) {
continue;
}
t.done = line.substr(first + 1, second - first - 1) == "1";
t.title = line.substr(second + 1);
tasks.push_back(t);
}
return tasks;
}
int main() {
std::vector<Task> tasks = {{1, "write a lesson", true}, {2, "with a\ttab? no", false}};
save_tasks(tasks, "demo.txt");
std::vector<Task> loaded = load_tasks("demo.txt");
std::cout << loaded.size() << "\n";
for (const Task& t : loaded) {
std::cout << t.id << " " << t.done << " " << t.title << "\n";
}
return 0;
}
2
1 1 write a lesson
2 0 with a tab? no
The file format is one task per line: id<TAB>done<TAB>title. Tabs rather than commas because a
title is far more likely to contain a comma.
Design decisions in that loader, all deliberate:
std::ifstream in(path); if (!in)— a missing file isn't an error here. First run, no tasks. Return an empty vector.- Title last. Everything after the second tab is the title, so a title containing a tab still survives — as the second test case shows.
- Malformed lines are skipped, not fatal. A truncated file loses one line rather than everything.
std::stoiin atry. Lesson 6.4 warned it throws on garbage; without this, one corrupt line crashes the program on startup.
Note we never explicitly close the files. ofstream and ifstream close themselves when they go
out of scope — that's RAII, the lifetime rule from lesson 4.6 doing real work. It's the
subject of lesson 8.2.
Step 4: the menu
The interface layer. This one needs a keyboard, so it's shown rather than run:
void run_menu(std::vector<Task>& tasks) {
while (true) {
std::cout << "\n1) list 2) add 3) complete 4) delete 5) save & quit\n> ";
int choice = 0;
if (!(std::cin >> choice)) {
if (std::cin.eof()) return;
std::cin.clear();
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
std::cout << "Please enter a number.\n";
continue;
}
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
if (choice == 1) {
list_tasks(tasks);
} else if (choice == 2) {
std::cout << "Title: ";
std::string title;
std::getline(std::cin, title);
add_task(tasks, title);
} else if (choice == 3 || choice == 4) {
std::cout << "Id: ";
int id = 0;
if (!(std::cin >> id)) {
std::cin.clear();
std::cout << "Not a number.\n";
} else if (choice == 3) {
std::cout << (complete_task(tasks, id) ? "Done.\n" : "No such task.\n");
} else {
std::cout << (delete_task(tasks, id) ? "Deleted.\n" : "No such task.\n");
}
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
} else if (choice == 5) {
return;
} else {
std::cout << "Pick 1-5.\n";
}
}
}
Every input trap from lesson 6.7 is handled here: checking std::cin >> choice as a boolean,
clear() and ignore() after a failure, the eof() guard so a piped file can't spin
forever, and ignore() before getline so the leftover newline doesn't produce an empty title.
The complete program
Here it is in full — every piece above, assembled. main itself is four lines, because all the
work lives in named functions.
#include <format>
#include <fstream>
#include <iostream>
#include <limits>
#include <string>
#include <vector>
struct Task {
int id = 0;
std::string title;
bool done = false;
};
const std::string DATA_FILE = "tasks.txt";
int next_id(const std::vector<Task>& tasks) {
int highest = 0;
for (const Task& t : tasks) {
if (t.id > highest) highest = t.id;
}
return highest + 1;
}
void add_task(std::vector<Task>& tasks, const std::string& title) {
if (title.empty()) {
std::cout << "Title cannot be empty.\n";
return;
}
tasks.push_back({next_id(tasks), title, false});
std::cout << std::format("Added #{}: {}\n", tasks.back().id, title);
}
void list_tasks(const std::vector<Task>& tasks) {
if (tasks.empty()) {
std::cout << "No tasks yet.\n";
return;
}
for (const Task& t : tasks) {
std::cout << std::format("{:>3} [{}] {}\n", t.id, t.done ? 'x' : ' ', t.title);
}
int remaining = 0;
for (const Task& t : tasks) {
if (!t.done) ++remaining;
}
std::cout << std::format("{} of {} remaining\n", remaining, tasks.size());
}
bool complete_task(std::vector<Task>& tasks, int id) {
for (Task& t : tasks) {
if (t.id == id) {
t.done = true;
return true;
}
}
return false;
}
bool delete_task(std::vector<Task>& tasks, int id) {
for (std::size_t i = 0; i < tasks.size(); ++i) {
if (tasks[i].id == id) {
tasks.erase(tasks.begin() + static_cast<long>(i));
return true;
}
}
return false;
}
bool save_tasks(const std::vector<Task>& tasks, const std::string& path) {
std::ofstream out(path);
if (!out) return false;
for (const Task& t : tasks) {
out << t.id << '\t' << (t.done ? 1 : 0) << '\t' << t.title << '\n';
}
return true;
}
std::vector<Task> load_tasks(const std::string& path) {
std::vector<Task> tasks;
std::ifstream in(path);
if (!in) return tasks;
std::string line;
while (std::getline(in, line)) {
if (line.empty()) continue;
std::size_t first = line.find('\t');
std::size_t second = line.find('\t', first + 1);
if (first == std::string::npos || second == std::string::npos) continue;
Task t;
try {
t.id = std::stoi(line.substr(0, first));
} catch (...) {
continue;
}
t.done = line.substr(first + 1, second - first - 1) == "1";
t.title = line.substr(second + 1);
tasks.push_back(t);
}
return tasks;
}
void run_menu(std::vector<Task>& tasks) {
while (true) {
std::cout << "\n1) list 2) add 3) complete 4) delete 5) save & quit\n> ";
int choice = 0;
if (!(std::cin >> choice)) {
if (std::cin.eof()) return;
std::cin.clear();
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
std::cout << "Please enter a number.\n";
continue;
}
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
if (choice == 1) {
list_tasks(tasks);
} else if (choice == 2) {
std::cout << "Title: ";
std::string title;
std::getline(std::cin, title);
add_task(tasks, title);
} else if (choice == 3 || choice == 4) {
std::cout << "Id: ";
int id = 0;
if (!(std::cin >> id)) {
std::cin.clear();
std::cout << "Not a number.\n";
} else if (choice == 3) {
std::cout << (complete_task(tasks, id) ? "Done.\n" : "No such task.\n");
} else {
std::cout << (delete_task(tasks, id) ? "Deleted.\n" : "No such task.\n");
}
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
} else if (choice == 5) {
return;
} else {
std::cout << "Pick 1-5.\n";
}
}
}
int main() {
std::vector<Task> tasks = load_tasks(DATA_FILE);
std::cout << std::format("Loaded {} task(s)\n", tasks.size());
run_menu(tasks);
if (!save_tasks(tasks, DATA_FILE)) {
std::cout << "Warning: could not save.\n";
return 1;
}
return 0;
}
Build and run it:
g++ -std=c++20 -Wall -Wextra -g todo.cpp -o todo
./todo
Note main returns 1 when saving fails — the exit status from lesson 2.3, so a script calling
your program can tell it went wrong.
Verify it works
Run through this by hand:
- Start with no
tasks.txt. It should say "Loaded 0 task(s)", not crash. - Add three tasks, list them — ids 1, 2, 3.
- Complete #2, list —
[x]on the middle one, "2 of 3 remaining". - Delete #1, list — 1 and 3 gone/renumbered? No: ids stay 2 and 3. Deleting must not renumber, or the id someone just read off the screen would point elsewhere.
- Save and quit, then restart. The same tasks come back, and a new task gets id 4 — not 3.
- Add a task titled
a, b, cand one with an apostrophe. Both should survive a round trip. - At the menu, type
banana. It should say "Please enter a number", not loop forever.
Point 5 is the one people get wrong. If next_id used tasks.size() + 1, deleting a task would
make the next id collide with an existing one.
Extend it
Each of these is a real exercise in something Part I taught:
- Priorities. Add a
char prioritytoTaskand show it in the listing. You'll need to handle old files that don't have the field. - Search.
std::vector<Task> find_tasks(const std::vector<Task>&, std::string_view)usingfind(lessons 6.4 and 6.5). - Sort.
std::sortwith a comparator overtasks.begin(), tasks.end()(lesson 6.6). - Undo. Keep a copy of the vector before each change.
- Atomic saves. Write to
tasks.txt.tmp, then rename over the original, so a crash mid-write can't destroy your data.
Part I complete
You've written a real program. It has a data model, functions with deliberate parameter choices, input validation, file persistence that survives corruption, and formatted output — and every piece of it is something you can now explain.
More importantly, you can read every character of it. That was the goal of Part I.
Next: Part II — Memory and the Machine, starting with Module 7 on pointers and references. This is where C++ stops being "Python with semicolons," and it's the hardest part of the curriculum. Take it slowly.
Questions about this lesson
Log in to ask a question.