Chapter 9: Practical Projects

Theory is nothing without practice. This chapter provides progressively challenging projects to cement your C++ skills.

Project 1: Command-Line Task Manager

A simple but complete CLI application demonstrating core concepts.

Requirements

  • Add, list, complete, and delete tasks
  • Persist tasks to file
  • Use modern C++ features

Implementation

task_manager.cpp:

#include <iostream>
#include <fstream>
#include <vector>
#include <string>
#include <algorithm>
#include <optional>
#include <sstream>

struct Task {
    int id;
    std::string description;
    bool completed;

    std::string serialize() const {
        return std::to_string(id) + "|" +
               description + "|" +
               (completed ? "1" : "0");
    }

    static std::optional<Task> deserialize(const std::string& line) {
        std::istringstream ss(line);
        std::string token;
        Task task;

        if (!std::getline(ss, token, '|')) return std::nullopt;
        task.id = std::stoi(token);

        if (!std::getline(ss, task.description, '|')) return std::nullopt;

        if (!std::getline(ss, token, '|')) return std::nullopt;
        task.completed = (token == "1");

        return task;
    }
};

class TaskManager {
public:
    explicit TaskManager(std::string filename)
        : filename_(std::move(filename)) {
        load();
    }

    ~TaskManager() {
        save();
    }

    void add(const std::string& description) {
        int newId = tasks_.empty() ? 1 : tasks_.back().id + 1;
        tasks_.push_back({newId, description, false});
        std::cout << "Added task #" << newId << "\n";
    }

    void list() const {
        if (tasks_.empty()) {
            std::cout << "No tasks.\n";
            return;
        }

        for (const auto& task : tasks_) {
            std::cout << "[" << (task.completed ? "x" : " ") << "] "
                      << "#" << task.id << ": "
                      << task.description << "\n";
        }
    }

    void complete(int id) {
        if (auto* task = findTask(id)) {
            task->completed = true;
            std::cout << "Completed task #" << id << "\n";
        } else {
            std::cout << "Task not found.\n";
        }
    }

    void remove(int id) {
        auto it = std::find_if(tasks_.begin(), tasks_.end(),
            [id](const Task& t) { return t.id == id; });

        if (it != tasks_.end()) {
            tasks_.erase(it);
            std::cout << "Removed task #" << id << "\n";
        } else {
            std::cout << "Task not found.\n";
        }
    }

private:
    std::vector<Task> tasks_;
    std::string filename_;

    Task* findTask(int id) {
        auto it = std::find_if(tasks_.begin(), tasks_.end(),
            [id](const Task& t) { return t.id == id; });
        return it != tasks_.end() ? &*it : nullptr;
    }

    void load() {
        std::ifstream file(filename_);
        std::string line;
        while (std::getline(file, line)) {
            if (auto task = Task::deserialize(line)) {
                tasks_.push_back(*task);
            }
        }
    }

    void save() {
        std::ofstream file(filename_);
        for (const auto& task : tasks_) {
            file << task.serialize() << "\n";
        }
    }
};

void printUsage() {
    std::cout << "Usage:\n"
              << "  tasks add <description>\n"
              << "  tasks list\n"
              << "  tasks done <id>\n"
              << "  tasks remove <id>\n";
}

int main(int argc, char* argv[]) {
    if (argc < 2) {
        printUsage();
        return 1;
    }

    TaskManager manager("tasks.txt");
    std::string command = argv[1];

    if (command == "add" && argc >= 3) {
        std::string desc;
        for (int i = 2; i < argc; ++i) {
            if (i > 2) desc += " ";
            desc += argv[i];
        }
        manager.add(desc);
    } else if (command == "list") {
        manager.list();
    } else if (command == "done" && argc >= 3) {
        manager.complete(std::stoi(argv[2]));
    } else if (command == "remove" && argc >= 3) {
        manager.remove(std::stoi(argv[2]));
    } else {
        printUsage();
        return 1;
    }

    return 0;
}

Skills Practiced

  • File I/O
  • Command-line argument parsing
  • STL containers and algorithms
  • RAII (TaskManager saves on destruction)
  • std::optional

Project 2: Generic Container Library

Build your own simplified versions of STL containers.

Requirements

  • Implement Vector<T> with dynamic resizing
  • Implement a simple HashMap<K, V>
  • Follow RAII principles

Vector Implementation

#include <algorithm>
#include <memory>
#include <stdexcept>
#include <initializer_list>

template<typename T>
class Vector {
public:
    using value_type = T;
    using iterator = T*;
    using const_iterator = const T*;

    Vector() : data_(nullptr), size_(0), capacity_(0) {}

    explicit Vector(size_t count, const T& value = T())
        : data_(allocate(count)), size_(count), capacity_(count) {
        std::uninitialized_fill_n(data_, count, value);
    }

    Vector(std::initializer_list<T> init)
        : data_(allocate(init.size())),
          size_(init.size()),
          capacity_(init.size()) {
        std::uninitialized_copy(init.begin(), init.end(), data_);
    }

    // Copy constructor
    Vector(const Vector& other)
        : data_(allocate(other.size_)),
          size_(other.size_),
          capacity_(other.size_) {
        std::uninitialized_copy(other.begin(), other.end(), data_);
    }

    // Move constructor
    Vector(Vector&& other) noexcept
        : data_(other.data_),
          size_(other.size_),
          capacity_(other.capacity_) {
        other.data_ = nullptr;
        other.size_ = other.capacity_ = 0;
    }

    // Copy assignment
    Vector& operator=(const Vector& other) {
        if (this != &other) {
            Vector temp(other);
            swap(temp);
        }
        return *this;
    }

    // Move assignment
    Vector& operator=(Vector&& other) noexcept {
        if (this != &other) {
            clear();
            deallocate(data_);
            data_ = other.data_;
            size_ = other.size_;
            capacity_ = other.capacity_;
            other.data_ = nullptr;
            other.size_ = other.capacity_ = 0;
        }
        return *this;
    }

    ~Vector() {
        clear();
        deallocate(data_);
    }

    // Element access
    T& operator[](size_t index) { return data_[index]; }
    const T& operator[](size_t index) const { return data_[index]; }

    T& at(size_t index) {
        if (index >= size_) throw std::out_of_range("Index out of range");
        return data_[index];
    }

    T& front() { return data_[0]; }
    T& back() { return data_[size_ - 1]; }
    T* data() { return data_; }

    // Iterators
    iterator begin() { return data_; }
    iterator end() { return data_ + size_; }
    const_iterator begin() const { return data_; }
    const_iterator end() const { return data_ + size_; }

    // Capacity
    bool empty() const { return size_ == 0; }
    size_t size() const { return size_; }
    size_t capacity() const { return capacity_; }

    void reserve(size_t newCap) {
        if (newCap > capacity_) {
            reallocate(newCap);
        }
    }

    // Modifiers
    void push_back(const T& value) {
        if (size_ == capacity_) {
            reallocate(capacity_ == 0 ? 1 : capacity_ * 2);
        }
        new (data_ + size_) T(value);
        ++size_;
    }

    void push_back(T&& value) {
        if (size_ == capacity_) {
            reallocate(capacity_ == 0 ? 1 : capacity_ * 2);
        }
        new (data_ + size_) T(std::move(value));
        ++size_;
    }

    template<typename... Args>
    T& emplace_back(Args&&... args) {
        if (size_ == capacity_) {
            reallocate(capacity_ == 0 ? 1 : capacity_ * 2);
        }
        new (data_ + size_) T(std::forward<Args>(args)...);
        return data_[size_++];
    }

    void pop_back() {
        if (size_ > 0) {
            data_[--size_].~T();
        }
    }

    void clear() {
        for (size_t i = 0; i < size_; ++i) {
            data_[i].~T();
        }
        size_ = 0;
    }

    void swap(Vector& other) noexcept {
        std::swap(data_, other.data_);
        std::swap(size_, other.size_);
        std::swap(capacity_, other.capacity_);
    }

private:
    T* data_;
    size_t size_;
    size_t capacity_;

    static T* allocate(size_t n) {
        return n > 0 ? static_cast<T*>(::operator new(n * sizeof(T))) : nullptr;
    }

    static void deallocate(T* ptr) {
        ::operator delete(ptr);
    }

    void reallocate(size_t newCap) {
        T* newData = allocate(newCap);
        for (size_t i = 0; i < size_; ++i) {
            new (newData + i) T(std::move(data_[i]));
            data_[i].~T();
        }
        deallocate(data_);
        data_ = newData;
        capacity_ = newCap;
    }
};

Skills Practiced

  • Template programming
  • Rule of Five
  • Move semantics
  • Memory management with placement new
  • Exception safety

Project 3: Multi-Threaded Work Queue

A thread-safe producer-consumer queue.

#include <queue>
#include <mutex>
#include <condition_variable>
#include <optional>
#include <thread>
#include <vector>
#include <functional>
#include <atomic>
#include <iostream>

template<typename T>
class ThreadSafeQueue {
public:
    void push(T value) {
        std::lock_guard lock(mutex_);
        queue_.push(std::move(value));
        cv_.notify_one();
    }

    std::optional<T> pop() {
        std::unique_lock lock(mutex_);
        cv_.wait(lock, [this] { return !queue_.empty() || stopped_; });

        if (queue_.empty()) return std::nullopt;

        T value = std::move(queue_.front());
        queue_.pop();
        return value;
    }

    std::optional<T> try_pop() {
        std::lock_guard lock(mutex_);
        if (queue_.empty()) return std::nullopt;

        T value = std::move(queue_.front());
        queue_.pop();
        return value;
    }

    void stop() {
        std::lock_guard lock(mutex_);
        stopped_ = true;
        cv_.notify_all();
    }

    bool empty() const {
        std::lock_guard lock(mutex_);
        return queue_.empty();
    }

private:
    std::queue<T> queue_;
    mutable std::mutex mutex_;
    std::condition_variable cv_;
    bool stopped_ = false;
};

class ThreadPool {
public:
    explicit ThreadPool(size_t numThreads) : stop_(false) {
        for (size_t i = 0; i < numThreads; ++i) {
            workers_.emplace_back([this] { workerLoop(); });
        }
    }

    ~ThreadPool() {
        stop_ = true;
        tasks_.stop();
        for (auto& worker : workers_) {
            if (worker.joinable()) {
                worker.join();
            }
        }
    }

    template<typename F>
    void submit(F&& task) {
        tasks_.push(std::function<void()>(std::forward<F>(task)));
    }

private:
    void workerLoop() {
        while (!stop_) {
            if (auto task = tasks_.pop()) {
                (*task)();
            }
        }
    }

    std::vector<std::thread> workers_;
    ThreadSafeQueue<std::function<void()>> tasks_;
    std::atomic<bool> stop_;
};

// Usage example
int main() {
    ThreadPool pool(4);

    std::atomic<int> counter{0};

    for (int i = 0; i < 100; ++i) {
        pool.submit([&counter, i] {
            std::this_thread::sleep_for(std::chrono::milliseconds(10));
            counter++;
            std::cout << "Task " << i << " completed\n";
        });
    }

    // Wait for tasks to complete
    std::this_thread::sleep_for(std::chrono::seconds(2));
    std::cout << "Completed tasks: " << counter << "\n";

    return 0;
}

Skills Practiced

  • Multi-threading with std::thread
  • Synchronization with mutex and condition_variable
  • Thread-safe design patterns
  • RAII for thread management
  • Atomic operations

Project 4: Simple Expression Parser

A recursive descent parser for mathematical expressions.

#include <string>
#include <string_view>
#include <optional>
#include <stdexcept>
#include <cctype>
#include <cmath>
#include <variant>
#include <memory>
#include <iostream>

// Abstract Syntax Tree nodes
struct Number;
struct BinaryOp;
struct UnaryOp;

using Expr = std::variant<
    Number,
    std::unique_ptr<BinaryOp>,
    std::unique_ptr<UnaryOp>
>;

struct Number {
    double value;
};

struct BinaryOp {
    char op;
    Expr left;
    Expr right;
};

struct UnaryOp {
    char op;
    Expr operand;
};

class Parser {
public:
    explicit Parser(std::string_view input) : input_(input), pos_(0) {}

    Expr parse() {
        auto result = parseExpression();
        skipWhitespace();
        if (pos_ < input_.size()) {
            throw std::runtime_error("Unexpected character at position " +
                                     std::to_string(pos_));
        }
        return result;
    }

private:
    std::string_view input_;
    size_t pos_;

    char peek() const {
        return pos_ < input_.size() ? input_[pos_] : '\0';
    }

    char consume() {
        return input_[pos_++];
    }

    void skipWhitespace() {
        while (pos_ < input_.size() && std::isspace(input_[pos_])) {
            ++pos_;
        }
    }

    // expression = term (('+' | '-') term)*
    Expr parseExpression() {
        auto left = parseTerm();

        while (true) {
            skipWhitespace();
            char op = peek();
            if (op != '+' && op != '-') break;

            consume();
            auto right = parseTerm();
            left = std::make_unique<BinaryOp>(BinaryOp{
                op, std::move(left), std::move(right)
            });
        }

        return left;
    }

    // term = factor (('*' | '/') factor)*
    Expr parseTerm() {
        auto left = parseFactor();

        while (true) {
            skipWhitespace();
            char op = peek();
            if (op != '*' && op != '/') break;

            consume();
            auto right = parseFactor();
            left = std::make_unique<BinaryOp>(BinaryOp{
                op, std::move(left), std::move(right)
            });
        }

        return left;
    }

    // factor = ('+' | '-')? (number | '(' expression ')')
    Expr parseFactor() {
        skipWhitespace();

        // Unary operators
        if (peek() == '+' || peek() == '-') {
            char op = consume();
            auto operand = parseFactor();
            return std::make_unique<UnaryOp>(UnaryOp{
                op, std::move(operand)
            });
        }

        // Parentheses
        if (peek() == '(') {
            consume();
            auto expr = parseExpression();
            skipWhitespace();
            if (peek() != ')') {
                throw std::runtime_error("Expected ')'");
            }
            consume();
            return expr;
        }

        // Number
        return parseNumber();
    }

    Expr parseNumber() {
        skipWhitespace();
        size_t start = pos_;

        if (!std::isdigit(peek()) && peek() != '.') {
            throw std::runtime_error("Expected number");
        }

        while (std::isdigit(peek())) consume();
        if (peek() == '.') {
            consume();
            while (std::isdigit(peek())) consume();
        }

        std::string numStr(input_.substr(start, pos_ - start));
        return Number{std::stod(numStr)};
    }
};

// Evaluator using visitor pattern
struct Evaluator {
    double operator()(const Number& n) const {
        return n.value;
    }

    double operator()(const std::unique_ptr<BinaryOp>& op) const {
        double left = std::visit(*this, op->left);
        double right = std::visit(*this, op->right);

        switch (op->op) {
            case '+': return left + right;
            case '-': return left - right;
            case '*': return left * right;
            case '/': return left / right;
            default: throw std::runtime_error("Unknown operator");
        }
    }

    double operator()(const std::unique_ptr<UnaryOp>& op) const {
        double operand = std::visit(*this, op->operand);
        return op->op == '-' ? -operand : operand;
    }
};

double evaluate(std::string_view expression) {
    Parser parser(expression);
    Expr ast = parser.parse();
    return std::visit(Evaluator{}, ast);
}

int main() {
    std::vector<std::string> tests = {
        "2 + 3",
        "2 + 3 * 4",
        "(2 + 3) * 4",
        "-5 + 3",
        "3.14 * 2",
        "10 / 2 / 5",
    };

    for (const auto& expr : tests) {
        try {
            double result = evaluate(expr);
            std::cout << expr << " = " << result << "\n";
        } catch (const std::exception& e) {
            std::cout << expr << " -> Error: " << e.what() << "\n";
        }
    }

    return 0;
}

Skills Practiced

  • Recursive descent parsing
  • std::variant and std::visit (type-safe unions)
  • Visitor pattern
  • Smart pointers with recursive structures
  • Error handling

Project 5: Memory Pool Allocator

A custom allocator for performance-critical applications.

#include <cstddef>
#include <memory>
#include <vector>
#include <new>
#include <iostream>

template<size_t BlockSize, size_t PoolSize = 1024>
class MemoryPool {
public:
    MemoryPool() {
        // Allocate pool
        pool_ = static_cast<char*>(::operator new(BlockSize * PoolSize));

        // Initialize free list
        for (size_t i = 0; i < PoolSize - 1; ++i) {
            *reinterpret_cast<char**>(pool_ + i * BlockSize) =
                pool_ + (i + 1) * BlockSize;
        }
        *reinterpret_cast<char**>(pool_ + (PoolSize - 1) * BlockSize) = nullptr;
        freeList_ = pool_;
    }

    ~MemoryPool() {
        ::operator delete(pool_);
    }

    // Non-copyable
    MemoryPool(const MemoryPool&) = delete;
    MemoryPool& operator=(const MemoryPool&) = delete;

    void* allocate() {
        if (!freeList_) {
            throw std::bad_alloc();
        }

        void* block = freeList_;
        freeList_ = *reinterpret_cast<char**>(freeList_);
        ++allocatedCount_;
        return block;
    }

    void deallocate(void* ptr) {
        if (!ptr) return;

        *reinterpret_cast<char**>(ptr) = freeList_;
        freeList_ = static_cast<char*>(ptr);
        --allocatedCount_;
    }

    size_t allocatedCount() const { return allocatedCount_; }
    size_t freeCount() const { return PoolSize - allocatedCount_; }

private:
    char* pool_;
    char* freeList_;
    size_t allocatedCount_ = 0;
};

// STL-compatible allocator wrapper
template<typename T, size_t PoolSize = 1024>
class PoolAllocator {
public:
    using value_type = T;

    PoolAllocator() : pool_(std::make_shared<MemoryPool<sizeof(T), PoolSize>>()) {}

    template<typename U>
    PoolAllocator(const PoolAllocator<U, PoolSize>& other)
        : pool_(reinterpret_cast<const PoolAllocator&>(other).pool_) {}

    T* allocate(size_t n) {
        if (n != 1) {
            return static_cast<T*>(::operator new(n * sizeof(T)));
        }
        return static_cast<T*>(pool_->allocate());
    }

    void deallocate(T* ptr, size_t n) {
        if (n != 1) {
            ::operator delete(ptr);
        } else {
            pool_->deallocate(ptr);
        }
    }

private:
    std::shared_ptr<MemoryPool<sizeof(T), PoolSize>> pool_;

    template<typename U, size_t PS>
    friend class PoolAllocator;
};

// Benchmark comparison
#include <chrono>
#include <list>

int main() {
    constexpr int NUM_ITERATIONS = 100000;

    // Standard allocator
    {
        auto start = std::chrono::high_resolution_clock::now();

        std::list<int> lst;
        for (int i = 0; i < NUM_ITERATIONS; ++i) {
            lst.push_back(i);
        }
        lst.clear();

        auto end = std::chrono::high_resolution_clock::now();
        auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
        std::cout << "Standard allocator: " << duration.count() << " us\n";
    }

    // Pool allocator
    {
        auto start = std::chrono::high_resolution_clock::now();

        std::list<int, PoolAllocator<int, 100000>> lst;
        for (int i = 0; i < NUM_ITERATIONS; ++i) {
            lst.push_back(i);
        }
        lst.clear();

        auto end = std::chrono::high_resolution_clock::now();
        auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
        std::cout << "Pool allocator: " << duration.count() << " us\n";
    }

    return 0;
}

Skills Practiced

  • Low-level memory management
  • Custom allocators for STL
  • Free list data structure
  • Performance optimization
  • Template metaprogramming

Progression Path

After completing these projects, continue your learning:

Intermediate Projects

  1. HTTP Client/Server - Network programming with sockets
  2. JSON Parser - Complete parser with object model
  3. Mini Database - B-tree, file storage, SQL-like queries
  4. Game Engine Core - Entity-component system, rendering loop

Advanced Projects

  1. Compiler Front-end - Lexer, parser, AST, semantic analysis
  2. Ray Tracer - 3D graphics, math, multithreading
  3. Async I/O Library - Event loops, coroutines
  4. Container Orchestrator - Process management, networking

Resources for Continued Learning

Books:

  • Effective Modern C++ by Scott Meyers
  • C++ Concurrency in Action by Anthony Williams
  • The C++ Programming Language by Bjarne Stroustrup

Online:

  • CppCon talks on YouTube
  • C++ Weekly by Jason Turner
  • cppreference.com (your best reference)

Practice:

  • LeetCode (with C++)
  • Advent of Code
  • Open source contributions

Previous: 08 - Best Practices


Congratulations!

You've completed this C++ tutorial. You now have:

  • Strong fundamentals in C++ syntax and semantics
  • Understanding of memory management and RAII
  • Knowledge of modern C++ features (C++11-23)
  • Familiarity with the STL
  • Experience with build systems
  • Best practices for professional code

The journey doesn't end here. C++ is a deep language that rewards continued study. Build projects, read code, and keep learning.

Happy coding!