Lesson 25 of 25

Final Project: Build a C++ App

What You Are Building

The project is a Student Result Management System: a command-line program that stores students and their marks, calculates averages and grades, ranks a class, and saves everything to a file so the data survives between runs. It is deliberately unglamorous, because it is the shape of an enormous amount of real software — hold some records, enforce some rules about them, search and sort them, and persist them.

It is a good final project because every part of it needs a different piece of this course, and none of the pieces can be faked. Marks have to be validated, which means encapsulation and exceptions. Students have to be found by roll number and ranked by average, which means containers and algorithms. Data has to survive, which means file handling and parsing input you do not control. And the whole thing has to not leak, which comes free if you followed the Rule of Zero.

Work through it in the order below rather than trying to write it all at once. Get each stage compiling and running before starting the next — a program that works and does one thing is far more useful than four files that nearly work. The code in this lesson is a complete, working reference; type it rather than pasting it, and change the details as you go.

The one design decision worth making before you write anything is where the rules live. Marks must be between 0 and 100. A roll number must be unique. If those rules are enforced inside the classes, then any student object that exists is valid and the rest of the program never has to check. If they are enforced in the menu code, you will be re-checking them in five places by the end of the evening.

  • Add a student with a name and a unique roll number
  • Record marks for a student, rejecting anything outside 0 to 100
  • Compute each student's average and letter grade
  • Look a student up by roll number in logarithmic time
  • List the class ranked by average, highest first
  • Report the class average and the highest and lowest scorers
  • Save to a CSV file and reload on the next run, skipping bad rows instead of crashing
Notes
  • Build it with g++ -std=c++17 -Wall -Wextra -g -fsanitize=address,undefined -o results main.cpp from the very first stage. A memory or bounds bug caught on day one takes a minute to fix; the same bug found after four hundred lines takes an evening.

Stage 1: The Student Class

Start with the thing the whole program is about: one student. Everything else — the container, the menu, the file format — exists to serve this class, so getting it right first means the rest has something solid to stand on.

Notice that the data members are private. That is not ceremony. It is what lets addGrade be the only way a mark can enter a student, which in turn is what makes the 0–100 rule impossible to bypass. If grades were public, every future piece of code that touches a student would have to remember the rule, and one of them eventually would not.

average() and letterGrade() are marked const because they only read. That single keyword is what allows you to call them on a const Student& later — and you will, the moment you start passing students to functions without copying them. Leave it off now and you will be adding it under compiler pressure in an hour.

The friend ostream& operator<< is what makes cout << student work. It has to be a free function rather than a member because the left operand is the stream, not the student; friend is simply how it gets access to the private members while still being a free function.

Example
#include <iostream>
#include <string>      // needed for std::string
#include <vector>
#include <numeric>     // std::accumulate
#include <stdexcept>   // std::invalid_argument

class Student {
private:
    std::string name;
    int id;
    std::vector<double> grades;

public:
    Student(const std::string& n, int i) : name(n), id(i) {}

    void addGrade(double g) {
        if (g < 0 || g > 100)
            throw std::invalid_argument("Marks must be between 0 and 100");
        grades.push_back(g);
    }

    double average() const {
        if (grades.empty()) return 0.0;
        // 0.0 — NOT 0 — see the note below. This one character matters.
        return std::accumulate(grades.begin(), grades.end(), 0.0) / grades.size();
    }

    char letterGrade() const {
        double avg = average();
        if (avg >= 90) return 'A';
        if (avg >= 80) return 'B';
        if (avg >= 70) return 'C';
        if (avg >= 60) return 'D';
        return 'F';
    }

    int getId() const { return id; }
    const std::string& getName() const { return name; }
    bool hasMarks() const { return !grades.empty(); }

    friend std::ostream& operator<<(std::ostream& os, const Student& s) {
        os << s.name << " (ID: " << s.id << ") - Avg: "
           << s.average() << " (" << s.letterGrade() << ')';
        return os;
    }
};
Notes
  • The 0.0 is load-bearing. std::accumulate takes the type of its initial value and uses that as the accumulator. Write accumulate(begin, end, 0) and the accumulator is an int: every mark is truncated as it is added, so 88.5 and 91.5 total 179 instead of 180. The averages come out slightly wrong, no warning is issued, and the bug is nearly invisible because the numbers still look plausible.
  • Student(const std::string& n, ...) takes the name by const reference to avoid copying the string on every construction. Taking it by value would work and is sometimes preferred with std::move, but const-reference is the safe default while you are learning.
  • Include <string> and <stdexcept> explicitly. Code that uses std::string often compiles anyway because another header happened to pull it in — until you change a header and it suddenly does not.

Stage 2: Storing and Finding Students

The brief asks you to look a student up by roll number in logarithmic time. That single phrase rules out the obvious choice. A std::vector<Student> means every lookup walks the list from the start — fine for ten students, wasteful for ten thousand, and it silently allows two students to share a roll number.

std::map<int, Student> keyed on roll number solves both problems at once. Lookup is O(log n), and the key is unique by construction — the container itself now enforces the rule you would otherwise have to check by hand every time.

Be deliberate about which insert you use. map::insert and emplace do nothing if the key already exists and tell you so through the bool in their return value. operator[] will happily overwrite. For a roll number that must be unique, silently replacing an existing student with a new one is exactly the bug you are trying to prevent, so check the return value and report the duplicate to the user.

Example
#include <map>

class ResultSystem {
private:
    std::map<int, Student> students;   // roll number -> student

public:
    // Returns false if that roll number is already taken.
    bool addStudent(const Student& s) {
        auto [it, inserted] = students.emplace(s.getId(), s);
        return inserted;                // false => duplicate roll number
    }

    // Returns nullptr if not found — the caller must check.
    const Student* find(int roll) const {
        auto it = students.find(roll);
        return it == students.end() ? nullptr : &it->second;
    }

    std::size_t size() const { return students.size(); }
};

// Using it:
ResultSystem sys;
if (!sys.addStudent(Student("Ananya", 101)))
    std::cout << "Roll number 101 already exists\n";

if (const Student* s = sys.find(101))
    std::cout << *s << '\n';
else
    std::cout << "No student with that roll number\n";
Notes
  • students[roll] on a std::map does something surprising when the key is missing: it creates a default-constructed entry and returns a reference to it. So a plain lookup written as students[999] quietly inserts an empty student at 999. Use find() to look things up and reserve operator[] for when you actually intend to create-or-update.
  • Because operator[] must be able to default-construct a value, it will not even compile for a type without a default constructor — which is one honest argument for giving Student no default constructor at all. A student with no name and no roll number is not a meaningful object, and the compiler can enforce that for you.

Stage 3: Saving and Loading Without Crashing

Persistence is where most student projects quietly fall apart. Writing the file is easy; reading it back is where the real work is, because the file will eventually contain something you did not expect — a blank line at the end, a half-written row from a run you interrupted, a name someone typed a comma into.

The brief is specific about this: skip bad rows instead of crashing. That is a deliberate design instruction, not a nicety. A results program that refuses to start because line 47 is malformed is worse than one that loads the other 46 and tells you which line it could not read.

Read line by line with std::getline, then parse each line with a std::istringstream. Wrap the parse of each row in its own try block so that a bad row throws, gets reported, and the loop simply moves on to the next one. This is the pattern that turns exception handling from an abstract chapter topic into something obviously useful.

Example
#include <fstream>
#include <sstream>

void save(const std::string& path) const {
    std::ofstream out(path);
    if (!out) throw std::runtime_error("Could not open " + path + " for writing");

    for (const auto& [roll, s] : students)
        out << roll << ',' << s.getName() << ',' << s.average() << '\n';
}

void load(const std::string& path) {
    std::ifstream in(path);
    if (!in) return;               // no file yet is normal on first run

    std::string line;
    int lineNo = 0, skipped = 0;

    while (std::getline(in, line)) {
        ++lineNo;
        if (line.empty()) continue;

        try {
            std::istringstream ss(line);
            std::string rollText, name, markText;

            if (!std::getline(ss, rollText, ',')) throw std::runtime_error("no roll");
            if (!std::getline(ss, name,     ',')) throw std::runtime_error("no name");
            if (!std::getline(ss, markText     )) throw std::runtime_error("no marks");

            Student s(name, std::stoi(rollText));   // throws if not a number
            s.addGrade(std::stod(markText));        // throws if out of range
            addStudent(s);
        }
        catch (const std::exception& e) {
            std::cerr << "Skipping line " << lineNo << ": " << e.what() << '\n';
            ++skipped;
        }
    }

    if (skipped) std::cout << "Loaded with " << skipped << " bad row(s) skipped\n";
}
Notes
  • std::stoi throws std::invalid_argument when the text is not a number and std::out_of_range when it will not fit. Both derive from std::exception, so the single catch (const std::exception&) above handles them together with the invalid_argument your own addGrade throws.
  • This simple CSV breaks if a name contains a comma — the parser would read the text after the comma as the marks column. Real CSV handles that with quoting. For this project, the honest fix is to reject commas in names at input time, and to know that is the limitation rather than discover it later.
  • Catch by const std::exception&, by reference. Catching by value slices the exception down to the base type and throws away the derived object's information — the same object-slicing problem from the inheritance lesson, showing up somewhere new.

Stage 4: Ranking, and Where to Take It Next

Ranking the class is the one place you need to step outside the map. A std::map is permanently sorted by its key — roll number — and there is no way to ask it to order itself by average instead. Copy what you need into a std::vector, sort that, and leave the map alone as the source of truth.

Sorting pairs of (average, name) with std::sort and a lambda comparator is a three-line job, and it is worth doing rather than reaching for anything cleverer. Once it works, printing the top scorer, the bottom scorer and the class average are all trivial reads off the same sorted vector.

When the four stages run, you have a complete program — and a much better position from which to extend it. Each of these is a genuine next step rather than busywork:

  • Let a student hold marks for several named subjects — a std::map<std::string, double> inside Student — instead of one flat list
  • Split the single file into student.h, student.cpp and main.cpp, so you meet header guards and separate compilation for real
  • Replace the raw menu loop with input validation that survives a user typing letters where a number belongs (cin.clear() and cin.ignore())
  • Add a unit test for average() and letterGrade() — the boundary cases at exactly 90, 80 and 0 are where grading bugs actually live
  • Store marks as integers if your institution never uses fractional marks; comparing doubles for equality is a trap you can simply design out
Notes
  • A grading boundary written as avg >= 90 behaves differently from avg > 89.999 once floating point is involved. Decide explicitly whether exactly 90.0 is an A, write a test for that case, and you will never have to argue about it with a user.
  • Keep building with -Wall -Wextra -fsanitize=address,undefined right through to the end. Every stage above adds pointers, references or indexing, and the sanitizer turns the class of bug that silently corrupts results into an immediate, located error message.
Ask AI