Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

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::stoi in a try. 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:

  1. Start with no tasks.txt. It should say "Loaded 0 task(s)", not crash.
  2. Add three tasks, list them — ids 1, 2, 3.
  3. Complete #2, list — [x] on the middle one, "2 of 3 remaining".
  4. 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.
  5. Save and quit, then restart. The same tasks come back, and a new task gets id 4 — not 3.
  6. Add a task titled a, b, c and one with an apostrophe. Both should survive a round trip.
  7. 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:

  1. Priorities. Add a char priority to Task and show it in the listing. You'll need to handle old files that don't have the field.
  2. Search. std::vector<Task> find_tasks(const std::vector<Task>&, std::string_view) using find (lessons 6.4 and 6.5).
  3. Sort. std::sort with a comparator over tasks.begin(), tasks.end() (lesson 6.6).
  4. Undo. Keep a copy of the vector before each change.
  5. 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.