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
- HTTP Client/Server - Network programming with sockets
- JSON Parser - Complete parser with object model
- Mini Database - B-tree, file storage, SQL-like queries
- Game Engine Core - Entity-component system, rendering loop
Advanced Projects
- Compiler Front-end - Lexer, parser, AST, semantic analysis
- Ray Tracer - 3D graphics, math, multithreading
- Async I/O Library - Event loops, coroutines
- 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!