← thecodex.expert · The Codex Family of Knowledge
Tier 0 · Absolute Beginner · C++ Project

To-Do List

A to-do list that remembers your tasks between runs by saving them to a file. Add, list, mark done, remove — four operations on a growing collection, backed by std::vector instead of a capped C array.

🧠 Teaches how to think spoonfed, every age Last verified:

1 The Problem

We want a to-do list you can actually use: add tasks, see them numbered, remove the ones you finish, and — crucially — have them saved to a file so they survive after you close the program. It teaches lists, a menu loop, and saving data to disk.

Where this shows up: every app that stores your stuff — notes, reminders, shopping lists, saved games, settings. The pattern of “keep a list in memory, save it to a file, load it back next time” is the simplest form of a database.

2 How to Think About It

Every operation works on a collection of tasks. The design questions are how that collection grows, how a task is found (by id, never by position), and how it survives between runs.

The plan — in plain English
1. Load tasks from a file at startup (an empty list if the file does not exist yet). → 2. Loop: add, list, mark done, or remove, by id. → 3. Save the current list back to the file before quitting.

Add

View

Remove

Quit

Load tasks from file

Show menu

Choice?

Add task

Show tasks

Remove task

Stop

Save to file

3 The Build — explained part by part

Here is the complete to-do list. The header/source split mirrors the earlier projects; what is new here is a struct with its own operator==, and file I/O done entirely through RAII streams.

C++TodoList.hpp / TodoList.cpp / main.cpp
#pragma once
#include <string>
#include <vector>

struct Task {
    int id;
    bool done;
    std::string text;

    bool operator==(const Task &other) const {
        return id == other.id && done == other.done && text == other.text;
    }
};

// One more than the highest id ever assigned -- never reused, even after a
// task is removed, the same guarantee a real database's auto-increment
// primary key gives you.
int next_id(const std::vector<Task> &tasks);

// Appends a new, not-done task with the next available id.
void add_task(std::vector<Task> &tasks, const std::string &text);

// Marks the task with the given id done, searching by id (not vector
// position). Returns true if found.
bool mark_done(std::vector<Task> &tasks, int id);

// Removes the task with the given id. Returns true if found.
bool remove_task(std::vector<Task> &tasks, int id);

// Hand-rolled "id|done|text" line format, one task per line.
std::string serialize(const std::vector<Task> &tasks);
std::vector<Task> deserialize(const std::string &data);

// Loads/saves tasks from/to a real file using the format above. load_tasks
// returns an empty list if the file does not exist yet.
std::vector<Task> load_tasks(const std::string &path);
void save_tasks(const std::string &path, const std::vector<Task> &tasks);

#include "TodoList.hpp"
#include <algorithm>
#include <fstream>
#include <sstream>

int next_id(const std::vector<Task> &tasks) {
    int max_id = 0;
    for (const auto &t : tasks) max_id = std::max(max_id, t.id);
    return max_id + 1;
}

void add_task(std::vector<Task> &tasks, const std::string &text) {
    tasks.push_back(Task{next_id(tasks), false, text});
}

bool mark_done(std::vector<Task> &tasks, int id) {
    for (auto &t : tasks) {
        if (t.id == id) {
            t.done = true;
            return true;
        }
    }
    return false;
}

bool remove_task(std::vector<Task> &tasks, int id) {
    auto it = std::find_if(tasks.begin(), tasks.end(),
                            [id](const Task &t) { return t.id == id; });
    if (it == tasks.end()) return false;
    tasks.erase(it);
    return true;
}

std::string serialize(const std::vector<Task> &tasks) {
    std::ostringstream out;
    for (const auto &t : tasks) {
        out << t.id << '|' << (t.done ? 1 : 0) << '|' << t.text << '\n';
    }
    return out.str();
}

std::vector<Task> deserialize(const std::string &data) {
    std::vector<Task> tasks;
    std::istringstream in(data);
    std::string line;
    while (std::getline(in, line)) {
        if (line.empty()) continue;
        auto p1 = line.find('|');
        if (p1 == std::string::npos) continue;
        auto p2 = line.find('|', p1 + 1);
        if (p2 == std::string::npos) continue;
        int id = std::stoi(line.substr(0, p1));
        bool done = line.substr(p1 + 1, p2 - p1 - 1) == "1";
        std::string text = line.substr(p2 + 1);
        tasks.push_back(Task{id, done, text});
    }
    return tasks;
}

std::vector<Task> load_tasks(const std::string &path) {
    std::ifstream file(path);
    if (!file) return {};
    std::ostringstream buffer;
    buffer << file.rdbuf();
    return deserialize(buffer.str());
}

void save_tasks(const std::string &path, const std::vector<Task> &tasks) {
    std::ofstream file(path);
    file << serialize(tasks);
}

#include "TodoList.hpp"
#include <iostream>

static const std::string FILE_PATH = "tasks.db";

static void print_list(const std::vector<Task> &tasks) {
    for (const auto &t : tasks) {
        std::cout << "#" << t.id << " [" << (t.done ? "x" : " ") << "] " << t.text << "\n";
    }
}

int main() {
    auto tasks = load_tasks(FILE_PATH);
    std::cout << "1) Add  2) List  3) Done  4) Remove  5) Quit\nChoice: ";
    int choice;
    while (std::cin >> choice) {
        std::cin.ignore();
        if (choice == 1) {
            std::cout << "Task: ";
            std::string text;
            std::getline(std::cin, text);
            add_task(tasks, text);
            std::cout << "Added.\n";
        } else if (choice == 2) {
            print_list(tasks);
        } else if (choice == 3) {
            std::cout << "ID: ";
            int id;
            std::cin >> id;
            std::cout << (mark_done(tasks, id) ? "Marked done.\n" : "No such task.\n");
        } else if (choice == 4) {
            std::cout << "ID: ";
            int id;
            std::cin >> id;
            std::cout << (remove_task(tasks, id) ? "Removed.\n" : "No such task.\n");
        } else if (choice == 5) {
            break;
        }
        std::cout << "1) Add  2) List  3) Done  4) Remove  5) Quit\nChoice: ";
    }
    save_tasks(FILE_PATH, tasks);
    return 0;
}
⚠ No in-browser playground here
C++ compiles to a real, native binary, so unlike the Python version of this project there is no editor above you can run in the browser. Copy the code below and run it on your own machine — it takes seconds once a C++17-or-newer compiler like g++ or clang++ is installed.
What each part does — in plain words
std::vector<Task> — a growable array, replacing the fixed-size Task tasks[MAX_TODOS] plus a separate count variable the C version needed. There is no capacity to overflow and no count to keep in sync by hand; push_back and erase manage the size for you.

bool operator==(const Task &other) const — defined right inside the struct, this is what lets the round-trip test below write assert(back == tasks) and compare two whole vectors of tasks with plain ==, instead of writing a manual field-by-field comparison loop for every test that needs one.

mark_done and remove_task search by id, not position — exactly like the C version, because the underlying bug that guards against (removing task #1 shifts every later task’s position, but never its id) is not a C-specific problem; it is true of any array-like collection in any language.

std::ifstream / std::ofstream — RAII in action: the file handle is a stack object whose destructor closes the file automatically, whichever way the function returns. There is no fclose to forget, and no leaked file descriptor if an exception were ever thrown mid-function.
Common mistakes — and how to avoid them
✗ Finding a task with tasks[id], treating the id as if it were a vector index — it is not, once any task has ever been removed.
✓ Search for the matching .id field with std::find_if, as remove_task does here, and never assume id equals position.
✗ Forgetting that next_id must look at the highest id ever assigned, not tasks.size() — after a removal, size shrinks but the next id must not go backwards and collide with a surviving task.
✓ Track the maximum id seen across all current tasks, as next_id does here, so a freed id is never reused.

4 Test & Prove Each Part

Six checks, including one that writes to and reads back from a real file in /tmp — exercising the actual save/load path, not just the in-memory logic. Same hand-written assert() harness as this project’s earlier pages.

IDs count up and are never reused, even after a removal
mark_done finds a task by id, not by its position in the vector
remove_task finds a task by id, not by its position in the vector
A vector of tasks round-trips correctly through serialize then deserialize
Tasks actually saved to and loaded from a real file on disk match what was saved
Loading a file that does not exist yet returns an empty list, not an error
C++test_TodoList.cpp
#include "TodoList.hpp"
#include <cassert>
#include <cstdio>
#include <iostream>

#define RUN(name) do { name(); std::cout << "PASS: " << #name << "\n"; } while (0)

static void ids_count_up_and_are_never_reused() {
    std::vector<Task> tasks;
    add_task(tasks, "first");
    add_task(tasks, "second");
    assert(tasks[0].id == 1);
    assert(tasks[1].id == 2);
    remove_task(tasks, 1); // simulate removing task 1
    add_task(tasks, "third");
    assert(tasks.back().id == 3); // not reused as 1
}

static void mark_done_finds_by_id_not_position() {
    std::vector<Task> tasks = { {5, false, "A"}, {9, false, "B"} };
    assert(mark_done(tasks, 9));
    assert(tasks[1].done);
    assert(!tasks[0].done);
    assert(!mark_done(tasks, 999));
}

static void remove_task_finds_by_id_not_position() {
    std::vector<Task> tasks = { {5, false, "A"}, {9, false, "B"}, {12, false, "C"} };
    assert(remove_task(tasks, 9));
    assert(tasks.size() == 2);
    assert(tasks[0].id == 5 && tasks[1].id == 12);
    assert(!remove_task(tasks, 999));
}

static void round_trips_through_serialize_and_deserialize() {
    std::vector<Task> tasks = { {1, true, "Ship the release"}, {2, false, "Write the docs"} };
    auto back = deserialize(serialize(tasks));
    assert(back == tasks);
}

static void saves_and_loads_through_a_real_file() {
    std::vector<Task> tasks = { {1, false, "Buy milk"}, {2, true, "Walk the dog"} };
    const std::string path = "/tmp/cpp_todo_test.db";
    save_tasks(path, tasks);
    auto loaded = load_tasks(path);
    assert(loaded == tasks);
    std::remove(path.c_str());
}

static void loading_a_missing_file_returns_an_empty_list() {
    auto loaded = load_tasks("/tmp/cpp_todo_definitely_does_not_exist.db");
    assert(loaded.empty());
}

int main() {
    RUN(ids_count_up_and_are_never_reused);
    RUN(mark_done_finds_by_id_not_position);
    RUN(remove_task_finds_by_id_not_position);
    RUN(round_trips_through_serialize_and_deserialize);
    RUN(saves_and_loads_through_a_real_file);
    RUN(loading_a_missing_file_returns_an_empty_list);
    std::cout << "All tests passed.\n";
    return 0;
}

Compile and run with g++ -std=c++20 -o test_run TodoList.cpp test_TodoList.cpp && ./test_run.

5 The Interface

INPUTINPUTa menu choice (1-5), then a task text or id
What it expects
1
Buy milk
OUTPUTOUTPUTthe updated list, printed with a done/not-done marker
What it returns
#1 [ ] Buy milk
#2 [x] Walk the dog

6 Run It & Automate It

Save the code as TodoList.hpp / TodoList.cpp / main.cpp and compile it with g++ — that turns your source directly into a native executable for your machine. No separate runtime needed: the compiled binary runs on its own.

Run it locally
g++ -std=c++20 -o todo main.cpp TodoList.cpp && ./todo
Tasks are saved to tasks.db in the current directory, so they are still there next time you run it.

A CI tool like Jenkins runs the same compile-then-test-then-check-for-leaks steps automatically whenever the code changes — every line below has a plain explanation.

What you should see when it works
Terminala real run
1) Add  2) List  3) Done  4) Remove  5) Quit
Choice: 1
Task: Buy milk
Added.
1) Add  2) List  3) Done  4) Remove  5) Quit
Choice: 2
#1 [ ] Buy milk
1) Add  2) List  3) Done  4) Remove  5) Quit
Choice: 5
If it breaks — how to fix it
🚨 No such task.
The id printed by 2) List (the number after #) is what 3) Done and 4) Remove expect — not the task’s position in the list.
🚨 Tasks vanish every time the program restarts.
Check the program reaches save_tasks before returning — quitting through choice 5 falls through to it, but an early return elsewhere would skip it.
GroovyJenkinsfile
// Jenkinsfile — compiles, tests, and checks for leaks on every change.
pipeline {
    agent any

    stages {
        stage('Get the code') {
            // download the latest code
            steps { checkout scm }
        }
        stage('Compile') {
            steps {
                // confirm a compiler is installed
                sh 'g++ --version'
                // compile with strict warnings on
                sh 'g++ -std=c++20 -Wall -Wextra -o app *.cpp'
            }
        }
        stage('Run the tests') {
            steps {
                // prints PASS/FAIL, exits non-zero on failure
                sh './app'
            }
        }
        stage('Check for memory leaks') {
            steps {
                // fails the build on any leak or invalid access
                sh 'valgrind --error-exitcode=1 --leak-check=full ./app'
            }
        }
    }

    post {
        success { echo 'All tests passed, no leaks found.' }
        failure { echo 'A test or Valgrind check failed — see above.' }
    }
}
🎯 Try this next — make it yours
  1. Add an “edit” option. Change a task’s text in place by id. (Teaches: mutating a vector element found by std::find_if.)
  2. Sort the list before printing. Not-done tasks first, then done ones. (Teaches: std::sort with a custom comparator, as top_n used elsewhere on this site.)
  3. Switch the file format to JSON. Replace the hand-rolled id|done|text line format. (Teaches: why a real format needs escaping the delimiter character if task text can ever contain it.)
What you learned
You learned to replace a fixed-size C array and a manual count with std::vector, how a struct’s own operator== makes whole-object comparisons trivial in tests, and how RAII file streams close themselves automatically. Related: Classes and RAII, STL Containers.