Building a Calculator Application
A calculator is a perfect first project - it combines user input, mathematical operations, error handling, and can be extended to support expression parsing. Let's build one step by step!
Project Overview
We'll build a calculator that evolves from simple operations to parsing mathematical expressions. This project demonstrates operator overloading, class design, and the strategy pattern.
Level 1: Basic Ops
Simple arithmetic with +, -, *, / operations and input validation.
Level 2: Expression Parser
Parse and evaluate expressions like "3 + 4 * 2" with operator precedence.
Level 3: History
Track calculation history and support variables for storing results.
Level 1: Basic Calculator
Let's start with a simple calculator class that performs basic operations. We'll use good OOP practices from the beginning.
// calculator.hpp
#ifndef CALCULATOR_HPP
#define CALCULATOR_HPP
#include <string>
#include <stdexcept>
class Calculator {
public:
// Basic arithmetic operations
double add(double a, double b) const { return a + b; }
double subtract(double a, double b) const { return a - b; }
double multiply(double a, double b) const { return a * b; }
double divide(double a, double b) const {
if (b == 0) {
throw std::invalid_argument("Division by zero!");
}
return a / b;
}
Calculator Class Foundation: We start with a clean header file using include guards (#ifndef). The basic operations are marked const since they don't modify the object's state. Notice how divide() checks for division by zero and throws an exception - always validate inputs! This is defensive programming in action.
// Perform operation based on operator symbol
double calculate(double a, char op, double b) const {
switch (op) {
case '+': return add(a, b);
case '-': return subtract(a, b);
case '*': return multiply(a, b);
case '/': return divide(a, b);
default:
throw std::invalid_argument("Unknown operator: " + std::string(1, op));
}
}
};
#endif // CALCULATOR_HPP
Unified Calculate Method: The calculate() method is a "dispatcher" - it takes an operator character and routes to the appropriate operation. The switch statement handles all operators, and the default case catches invalid operators. Note how we convert the char to a string for the error message using std::string(1, op).
// main.cpp
#include <iostream>
#include "calculator.hpp"
int main() {
Calculator calc;
double num1, num2;
char op;
std::cout << "=== Simple Calculator ===" << std::endl;
std::cout << "Enter expression (e.g., 5 + 3): ";
while (std::cin >> num1 >> op >> num2) {
try {
double result = calc.calculate(num1, op, num2);
std::cout << "Result: " << result << std::endl;
} catch (const std::exception& e) {
std::cout << "Error: " << e.what() << std::endl;
}
std::cout << "\nEnter expression (or Ctrl+C to exit): ";
}
return 0;
}
User Interface: The main program creates a Calculator object and runs a read-eval-print loop (REPL). The std::cin >> num1 >> op >> num2 reads space-separated input. The try-catch block handles any errors gracefully, showing the user what went wrong instead of crashing. This pattern is essential for user-facing applications!
Level 2: Expression Parser
Now let's add the ability to parse full mathematical expressions with proper operator precedence. We'll implement a simple recursive descent parser.
// expression_parser.hpp
#ifndef EXPRESSION_PARSER_HPP
#define EXPRESSION_PARSER_HPP
#include <string>
#include <sstream>
#include <cctype>
#include <stdexcept>
class ExpressionParser {
private:
std::string expr;
size_t pos = 0;
char peek() const {
while (pos < expr.size() && isspace(expr[pos])) {
// Skip but don't modify pos in const method
}
return pos < expr.size() ? expr[pos] : '\0';
}
char get() {
while (pos < expr.size() && isspace(expr[pos])) pos++;
return pos < expr.size() ? expr[pos++] : '\0';
}
Parser Foundation: The expression parser tracks its position in the input string. peek() looks at the next character without consuming it (useful for lookahead), while get() returns the next character and advances the position. Both skip whitespace automatically. The '\0' return signals end-of-input.
double parseNumber() {
while (pos < expr.size() && isspace(expr[pos])) pos++;
size_t start = pos;
bool hasDecimal = false;
// Handle negative numbers
if (expr[pos] == '-') pos++;
while (pos < expr.size() && (isdigit(expr[pos]) || expr[pos] == '.')) {
if (expr[pos] == '.') {
if (hasDecimal) throw std::runtime_error("Invalid number format");
hasDecimal = true;
}
pos++;
}
if (pos == start || (pos == start + 1 && expr[start] == '-')) {
throw std::runtime_error("Expected a number");
}
return std::stod(expr.substr(start, pos - start));
}
Number Parsing: This method extracts a number from the expression. It handles negative numbers, decimal points (checking for duplicates), and uses std::stod() for conversion. The error checking ensures we don't accept invalid inputs like ".." or just "-". Building robust parsers requires handling edge cases!
// Handle + and - (lowest precedence)
double parseExpression() {
double left = parseTerm();
while (true) {
char op = peek();
if (op == '+' || op == '-') {
get(); // consume operator
double right = parseTerm();
left = (op == '+') ? left + right : left - right;
} else {
break;
}
}
return left;
}
// Handle * and / (higher precedence)
double parseTerm() {
double left = parseFactor();
while (true) {
char op = peek();
if (op == '*' || op == '/') {
get(); // consume operator
double right = parseFactor();
if (op == '/') {
if (right == 0) throw std::runtime_error("Division by zero");
left = left / right;
} else {
left = left * right;
}
} else {
break;
}
}
return left;
}
Operator Precedence via Recursion: This is the heart of the recursive descent parser! parseExpression() handles + and -, while parseTerm() handles * and /. By calling parseTerm() from parseExpression(), multiplication and division are evaluated FIRST (higher precedence). The while loops handle chains of same-precedence operators left-to-right.
// Handle numbers and parentheses (highest precedence)
double parseFactor() {
char c = peek();
// Handle parentheses
if (c == '(') {
get(); // consume '('
double result = parseExpression(); // recursive!
if (get() != ')') {
throw std::runtime_error("Missing closing parenthesis");
}
return result;
}
// It's a number
return parseNumber();
}
public:
double evaluate(const std::string& expression) {
expr = expression;
pos = 0;
double result = parseExpression();
// Check for leftover characters
while (pos < expr.size() && isspace(expr[pos])) pos++;
if (pos != expr.size()) {
throw std::runtime_error("Unexpected character: " + std::string(1, expr[pos]));
}
return result;
}
};
#endif // EXPRESSION_PARSER_HPP
Parentheses and Recursion: parseFactor() handles the highest-precedence elements: numbers and parentheses. When we see '(', we recursively call parseExpression() - this allows nested expressions of any depth! The evaluate() method is the public interface, initializing state and checking for complete consumption of input.
Level 3: Adding History
Let's add calculation history using STL containers. Users can view past calculations and even use the "ans" variable to reference the last result.
// calculator_with_history.hpp
#include <vector>
#include <string>
#include <map>
#include "expression_parser.hpp"
class AdvancedCalculator {
private:
ExpressionParser parser;
std::vector<std::pair<std::string, double>> history;
std::map<std::string, double> variables;
double lastResult = 0;
public:
double calculate(const std::string& expression) {
// Replace 'ans' with last result
std::string processedExpr = expression;
size_t pos;
while ((pos = processedExpr.find("ans")) != std::string::npos) {
processedExpr.replace(pos, 3, std::to_string(lastResult));
}
// Evaluate and store
double result = parser.evaluate(processedExpr);
history.push_back({expression, result});
lastResult = result;
return result;
}
void showHistory() const {
std::cout << "\n=== Calculation History ===" << std::endl;
for (size_t i = 0; i < history.size(); i++) {
std::cout << i + 1 << ". " << history[i].first
<< " = " << history[i].second << std::endl;
}
}
void clearHistory() {
history.clear();
std::cout << "History cleared." << std::endl;
}
};
History with STL Containers: We use std::vector to store history as expression-result pairs, and std::map for future variable support. The "ans" keyword is replaced with the last result before evaluation, allowing chains like "ans * 2". This demonstrates practical use of STL containers in real applications!
Creating a Simple Game Engine
Game engines are fascinating applications of OOP principles. We'll build a simple text-based game engine that demonstrates the game loop, entity system, and event handling.
Game Engine Architecture
Our game engine follows the Entity-Component pattern and includes a proper game loop that separates update logic from rendering.
Game Loop
The game loop is the heartbeat of every game. It continuously cycles through three phases: processing input, updating game state, and rendering the result. This loop runs until the game ends.
The cycle: Input → Update → Render → Repeat
// entity.hpp
#ifndef ENTITY_HPP
#define ENTITY_HPP
#include <string>
#include <iostream>
class Entity {
protected:
std::string name;
int x, y; // Position
char symbol; // Visual representation
bool active = true;
public:
Entity(const std::string& name, int x, int y, char symbol)
: name(name), x(x), y(y), symbol(symbol) {}
virtual ~Entity() = default;
// Pure virtual - must be implemented by subclasses
virtual void update() = 0;
virtual void render() const {
std::cout << symbol << " " << name << " at (" << x << ", " << y << ")";
}
// Getters
int getX() const { return x; }
int getY() const { return y; }
bool isActive() const { return active; }
const std::string& getName() const { return name; }
void deactivate() { active = false; }
};
#endif // ENTITY_HPP
Entity Base Class: Every object in our game (player, enemies, items) inherits from Entity. It has position (x, y), a visual symbol, and an active state. The update() method is pure virtual, forcing subclasses to define their behavior. Notice virtual ~Entity() = default - this ensures proper cleanup when deleting through base pointers.
// player.hpp
#include "entity.hpp"
class Player : public Entity {
private:
int health = 100;
int score = 0;
public:
Player(const std::string& name, int x, int y)
: Entity(name, x, y, '@') {} // '@' is classic roguelike player symbol
void update() override {
// Player update logic (health regen, etc.)
if (health < 100) health++;
}
void render() const override {
Entity::render();
std::cout << " | HP: " << health << " | Score: " << score;
}
void move(int dx, int dy) {
x += dx;
y += dy;
}
void takeDamage(int amount) {
health -= amount;
if (health <= 0) {
health = 0;
deactivate();
}
}
void addScore(int points) { score += points; }
int getHealth() const { return health; }
int getScore() const { return score; }
};
Player Entity: The Player class extends Entity with health and score. It overrides both update() (slow health regeneration) and render() (adds stats display). The move() method takes deltas (changes) rather than absolute positions - this makes input handling cleaner. When health reaches 0, the player is deactivated.
// enemy.hpp
#include "entity.hpp"
#include <cstdlib>
class Enemy : public Entity {
private:
int damage = 10;
int moveCounter = 0;
public:
Enemy(const std::string& name, int x, int y)
: Entity(name, x, y, 'E') {}
void update() override {
// Simple AI: move randomly every 3 updates
moveCounter++;
if (moveCounter >= 3) {
moveCounter = 0;
int dx = (rand() % 3) - 1; // -1, 0, or 1
int dy = (rand() % 3) - 1;
x += dx;
y += dy;
}
}
void render() const override {
Entity::render();
std::cout << " [Hostile]";
}
int getDamage() const { return damage; }
};
Enemy with Simple AI: Enemies have their own update logic - here, a simple random movement every 3 frames. This shows how different entities can have completely different behaviors while sharing the same interface. The moveCounter creates a delay, making enemies slower than the player. Real games would have more sophisticated AI!
// game.hpp
#include <vector>
#include <memory>
#include <algorithm>
#include "player.hpp"
#include "enemy.hpp"
class Game {
private:
std::vector<std::unique_ptr<Entity>> entities;
Player* player = nullptr; // Non-owning pointer for quick access
bool running = true;
int mapWidth = 20;
int mapHeight = 10;
public:
void init() {
// Create player
auto playerPtr = std::make_unique<Player>("Hero", 10, 5);
player = playerPtr.get(); // Store raw pointer before moving
entities.push_back(std::move(playerPtr));
// Spawn some enemies
entities.push_back(std::make_unique<Enemy>("Goblin", 5, 3));
entities.push_back(std::make_unique<Enemy>("Orc", 15, 7));
entities.push_back(std::make_unique<Enemy>("Troll", 8, 8));
std::cout << "Game initialized with " << entities.size()
<< " entities.\n";
}
Game Manager with Smart Pointers: The Game class owns all entities via unique_ptr. We store a raw pointer to the player for quick access (safe because Game owns the player's lifetime). Using std::make_unique and std::move demonstrates modern C++ memory management. No manual delete needed!
void processInput() {
char input;
std::cout << "\nMove (WASD) or Q to quit: ";
std::cin >> input;
switch (tolower(input)) {
case 'w': player->move(0, -1); break; // Up
case 's': player->move(0, 1); break; // Down
case 'a': player->move(-1, 0); break; // Left
case 'd': player->move(1, 0); break; // Right
case 'q': running = false; break;
}
}
void update() {
// Update all entities
for (auto& entity : entities) {
if (entity->isActive()) {
entity->update();
}
}
// Check collisions (simple version)
checkCollisions();
// Remove inactive entities
entities.erase(
std::remove_if(entities.begin(), entities.end(),
[](const auto& e) { return !e->isActive(); }),
entities.end()
);
// Check game over
if (!player->isActive()) {
std::cout << "\n*** GAME OVER ***\n";
std::cout << "Final Score: " << player->getScore() << std::endl;
running = false;
}
}
Input Processing and Update: processInput() handles WASD movement. update() iterates through all entities, calling their update methods polymorphically. The remove-erase idiom with std::remove_if and a lambda cleans up dead entities. This shows C++ STL algorithms working with smart pointers beautifully!
void checkCollisions() {
for (auto& entity : entities) {
if (entity.get() == player || !entity->isActive()) continue;
// Simple collision: same position
if (entity->getX() == player->getX() &&
entity->getY() == player->getY()) {
// It's an enemy
Enemy* enemy = dynamic_cast<Enemy*>(entity.get());
if (enemy) {
player->takeDamage(enemy->getDamage());
player->addScore(50); // Score for defeating
enemy->deactivate();
std::cout << "Combat! " << enemy->getName()
<< " defeated! (-" << enemy->getDamage() << " HP)\n";
}
}
}
}
void render() const {
std::cout << "\n=== Game State ===" << std::endl;
for (const auto& entity : entities) {
if (entity->isActive()) {
entity->render();
std::cout << std::endl;
}
}
}
void run() {
init();
while (running) {
render();
processInput();
update();
}
}
};
Collision, Render, and Run: checkCollisions() uses dynamic_cast to safely check if an entity is an Enemy - this is runtime type checking. render() calls each entity's render method polymorphically. Finally, run() implements the classic game loop: render → input → update → repeat. This is the same structure used by professional game engines!
Implementing a Mini Database System
Build a simple database that supports Create, Read, Update, Delete (CRUD) operations with file persistence. This project showcases file I/O, serialization, and data structures.
Database Design
Our mini database stores records with multiple fields, supports basic queries, and persists data to disk. It's like a simplified version of SQLite!
// database.hpp
#ifndef DATABASE_HPP
#define DATABASE_HPP
#include <string>
#include <vector>
#include <map>
#include <fstream>
#include <sstream>
#include <iostream>
#include <algorithm>
#include <iomanip>
// A Record is a collection of field-value pairs
using Record = std::map<std::string, std::string>;
class Database {
private:
std::string name;
std::vector<std::string> columns; // Column names
std::vector<Record> records; // All records
int nextId = 1;
static const char DELIMITER = '|';
static const char FIELD_SEP = ':';
public:
Database(const std::string& name, const std::vector<std::string>& cols)
: name(name), columns(cols) {
// Ensure 'id' is always first column
if (columns.empty() || columns[0] != "id") {
columns.insert(columns.begin(), "id");
}
}
Database Structure: We use std::map<string, string> for flexible records where each record stores field-value pairs. The columns vector defines the schema. Every record automatically gets an 'id' field. The delimiter constants will be used for file serialization - using constants makes the code easier to maintain.
// CREATE - Insert a new record
int insert(const Record& data) {
Record record = data;
record["id"] = std::to_string(nextId++);
// Validate: only allow defined columns
for (const auto& [key, value] : record) {
if (key != "id" &&
std::find(columns.begin(), columns.end(), key) == columns.end()) {
std::cerr << "Warning: Unknown column '" << key << "' ignored.\n";
}
}
records.push_back(record);
return std::stoi(record["id"]);
}
// READ - Find records matching criteria
std::vector<Record> select(const std::string& field = "",
const std::string& value = "") const {
if (field.empty()) {
return records; // Return all
}
std::vector<Record> results;
for (const auto& record : records) {
auto it = record.find(field);
if (it != record.end() && it->second == value) {
results.push_back(record);
}
}
return results;
}
Create and Read Operations: insert() automatically assigns an ID and validates columns. The structured binding [key, value] (C++17) makes iteration clean. select() returns all records if no filter is given, or filters by field-value match. Real databases use indexes for speed, but this linear search is fine for small datasets.
// UPDATE - Modify existing record
bool update(int id, const Record& newData) {
std::string idStr = std::to_string(id);
for (auto& record : records) {
if (record["id"] == idStr) {
for (const auto& [key, value] : newData) {
if (key != "id") { // Don't allow changing ID
record[key] = value;
}
}
return true;
}
}
return false; // Record not found
}
// DELETE - Remove a record
bool remove(int id) {
std::string idStr = std::to_string(id);
auto it = std::remove_if(records.begin(), records.end(),
[&idStr](const Record& r) {
auto found = r.find("id");
return found != r.end() && found->second == idStr;
});
if (it != records.end()) {
records.erase(it, records.end());
return true;
}
return false;
}
Update and Delete: update() finds the record by ID and modifies fields (but protects the ID from being changed). remove() uses the erase-remove idiom with a lambda that captures idStr by reference. The lambda checks if each record's ID matches the target. Both return bool to indicate success.
// Save database to file
bool save(const std::string& filename) const {
std::ofstream file(filename);
if (!file) {
std::cerr << "Error: Cannot open file for writing.\n";
return false;
}
// Write header (column names)
for (size_t i = 0; i < columns.size(); i++) {
file << columns[i];
if (i < columns.size() - 1) file << DELIMITER;
}
file << "\n";
// Write records
for (const auto& record : records) {
for (size_t i = 0; i < columns.size(); i++) {
auto it = record.find(columns[i]);
if (it != record.end()) {
file << it->second;
}
if (i < columns.size() - 1) file << DELIMITER;
}
file << "\n";
}
file.close();
std::cout << "Database saved to " << filename << std::endl;
return true;
}
Saving to File: We serialize the database in CSV-like format. First line is the header (column names), followed by data rows. Using a delimiter character (|) separates fields. We write values in column order to ensure consistent format. Error checking with if (!file) handles file opening failures gracefully.
// Load database from file
bool load(const std::string& filename) {
std::ifstream file(filename);
if (!file) {
std::cerr << "Error: Cannot open file for reading.\n";
return false;
}
records.clear();
std::string line;
// Read header
if (std::getline(file, line)) {
columns.clear();
std::stringstream ss(line);
std::string col;
while (std::getline(ss, col, DELIMITER)) {
columns.push_back(col);
}
}
// Read records
while (std::getline(file, line)) {
if (line.empty()) continue;
Record record;
std::stringstream ss(line);
std::string value;
size_t colIndex = 0;
while (std::getline(ss, value, DELIMITER) && colIndex < columns.size()) {
record[columns[colIndex++]] = value;
}
records.push_back(record);
// Update nextId
if (record.count("id")) {
int id = std::stoi(record["id"]);
if (id >= nextId) nextId = id + 1;
}
}
std::cout << "Loaded " << records.size() << " records from " << filename << std::endl;
return true;
}
Loading from File: The load function reverses the save process. We read the header first to restore column names, then parse each line into records. std::getline with the delimiter parameter splits fields. Critically, we update nextId to be higher than any loaded ID, preventing ID collisions for new inserts.
// Pretty print the database
void display() const {
if (records.empty()) {
std::cout << "Database is empty.\n";
return;
}
// Calculate column widths
std::map<std::string, size_t> widths;
for (const auto& col : columns) {
widths[col] = col.length();
}
for (const auto& record : records) {
for (const auto& col : columns) {
auto it = record.find(col);
if (it != record.end()) {
widths[col] = std::max(widths[col], it->second.length());
}
}
}
// Print header
std::cout << "\n";
for (const auto& col : columns) {
std::cout << std::left << std::setw(widths[col] + 2) << col;
}
std::cout << "\n" << std::string(60, '-') << "\n";
// Print records
for (const auto& record : records) {
for (const auto& col : columns) {
auto it = record.find(col);
std::string value = (it != record.end()) ? it->second : "";
std::cout << std::left << std::setw(widths[col] + 2) << value;
}
std::cout << "\n";
}
std::cout << "\nTotal: " << records.size() << " records\n";
}
size_t count() const { return records.size(); }
};
#endif // DATABASE_HPP
Pretty Printing: Good UX matters even for console apps! We first calculate the maximum width needed for each column (checking both header and data), then use std::setw and std::left for aligned output. This creates a clean, readable table format. The <iomanip> header provides these formatting manipulators.
Example Usage
// main.cpp - Database Example
#include "database.hpp"
int main() {
// Create a student database
Database db("students", {"name", "age", "grade", "major"});
// Insert some records
db.insert({{"name", "Alice"}, {"age", "20"}, {"grade", "A"}, {"major", "CS"}});
db.insert({{"name", "Bob"}, {"age", "22"}, {"grade", "B"}, {"major", "Math"}});
db.insert({{"name", "Carol"}, {"age", "21"}, {"grade", "A"}, {"major", "CS"}});
std::cout << "=== Initial Database ===" << std::endl;
db.display();
// Query: Find all CS majors
std::cout << "\n=== CS Majors ===" << std::endl;
auto csStudents = db.select("major", "CS");
for (const auto& student : csStudents) {
std::cout << student.at("name") << " - Grade: " << student.at("grade") << std::endl;
}
// Update Bob's grade
db.update(2, {{"grade", "A"}});
// Save to file
db.save("students.db");
// Create new database and load
Database db2("students_copy", {"name", "age", "grade", "major"});
db2.load("students.db");
std::cout << "\n=== Loaded Database ===" << std::endl;
db2.display();
return 0;
}
Key Takeaways
Modular Design
Split projects into separate header files (.hpp) and implementation files. Use include guards to prevent multiple inclusion.
Error Handling
Use exceptions for error handling, validate all inputs, and provide meaningful error messages to users.
Smart Pointers
Use unique_ptr for ownership and raw pointers for non-owning access. Let RAII manage memory automatically.
OOP Patterns
Use inheritance and polymorphism for extensibility. Virtual functions enable runtime behavior selection.
File Persistence
Use fstream for file I/O. Define clear serialization formats and always check for file operation errors.
STL Mastery
Leverage STL containers (vector, map) and algorithms (remove_if, find). They're tested, optimized, and reliable.
Knowledge Check
Quick Quiz
Test what you've learned about C++ project development