🧬 Phase 5 · Templates & Advanced 🟠 Project MODULE 23

Final Project — Student Record System

⏱️ 120 min
📖 Hands-on Build
🧩 5 Quiz Questions
🏗️ 1 Extension Task
Your progress in Phase 5100%
🎯 The Project: Build a genuine Student Record System that combines everything you've learned across Phase 4 and Phase 5 — classes, STL containers, function templates, custom exceptions, and file I/O. You'll create a Student class holding an ID, name, and a list of grades, and a StudentManager class that can add, remove, and search for students, validate every grade, and save or load the entire roster from a CSV file. We'll build it step by step, then see the complete, working program.

Project Brief

This project is deliberately the biggest one in the course, because it's meant to prove you can combine ideas from every earlier phase into one working system. Student represents a single record. StudentManager owns a collection of them and exposes every operation a real application would need — including handling the ways things can go wrong.

🎓
Student
A single record: id, name, and a vector of grades. Validates every grade it's given and can compute its own average.
🗂️
StudentManager
Owns a vector<Student>. Adds, removes, searches, lists, and saves/loads the whole roster to a file — throwing clear exceptions when something's wrong.
🧩
Every phase, one program
Classes and encapsulation come from Phase 3. vector<Student> comes from Phase 4's STL. A small function template comes from earlier in Phase 5. Custom exceptions and try/catch come from Lesson 21. Reading and writing a CSV file comes from Lesson 22. Nothing here is new syntax — it's assembly.

The Build Plan

We'll build this in five steps, each one adding a working piece to the last.

1
Custom exceptions & a template helper
Two small exception classes for "student not found" and "invalid grade," plus a generic template<typename T> range-check function reused throughout the project.
2
Define the Student class
Private data (id, name, grades), a constructor, an addGrade() that validates through the template helper, and an average().
3
Define the StudentManager class
A class holding vector<Student> with addStudent(), removeStudent(), getStudent() (throws if missing), and listAll().
4
Add file I/O
saveToFile() and loadFromFile(), converting each Student to and from one CSV line using fstream and stringstream.
5
Put it together in main()
Create a roster, trigger both exception cases on purpose, save it, reload it into a fresh manager, and prove the round trip worked.

Custom Exceptions & a Template Helper

Deriving your own exception types from std::runtime_error is the standard C++ pattern — you inherit a working what() and a constructor that takes a message, and your exception still works with any code that catches const std::exception&. Alongside that, inRange<T> is a tiny function template: the exact same range-check logic works whether T is double, int, or anything else that supports < and >.

Step 1 — exceptions_and_template.cpp
C++
class StudentNotFoundException : public runtime_error {
public:
    StudentNotFoundException(int id)
        : runtime_error("Student with ID " + to_string(id) + " was not found.") {}
};

class InvalidGradeException : public runtime_error {
public:
    InvalidGradeException(double grade)
        : runtime_error("Invalid grade: " + to_string(grade) + " (must be between 0 and 100).") {}
};

// works for double grades, int ids, or any other comparable type
template <typename T>
bool inRange(T value, T low, T high) {
    return value >= low && value <= high;
}
Why not just throw a plain string?
A named exception type lets calling code catch specific problems separately — catch (const InvalidGradeException&) versus catch (const StudentNotFoundException&) — instead of parsing an error message to figure out what went wrong. It also self-documents: the type name tells you exactly what the failure was.

The Student Class

Each Student owns its own vector<double> of grades. addGrade() is the only way to add one, and it always validates through inRange() first — so an out-of-range grade can never enter the object's state.

Step 2 — student.cpp
C++
class Student {
private:
    int id;
    string name;
    vector<double> grades;

public:
    Student() : id(0), name("") {}
    Student(int studentId, string studentName)
        : id(studentId), name(studentName) {}

    int getId() const { return id; }
    string getName() const { return name; }
    const vector<double>& getGrades() const { return grades; }

    void addGrade(double grade) {
        if (!inRange(grade, 0.0, 100.0)) {
            throw InvalidGradeException(grade);
        }
        grades.push_back(grade);
    }

    double average() const {
        if (grades.empty()) return 0.0;
        double sum = 0.0;
        for (double g : grades) sum += g;
        return sum / grades.size();
    }

    void display() const {
        cout << "[" << id << "] " << name << " — Average: ";
        if (grades.empty()) cout << "N/A";
        else cout << average();
        cout << " (" << grades.size() << " grade(s))" << endl;
    }

    string toCSV() const {
        ostringstream oss;
        oss << id << "," << name << ",";
        for (size_t i = 0; i < grades.size(); i++) {
            oss << grades[i];
            if (i + 1 < grades.size()) oss << ";";
        }
        return oss.str();
    }
};

The StudentManager Class — Core Methods

StudentManager owns a vector<Student> and provides the operations any code using it actually needs. getStudent() is the one to notice: instead of returning something that might silently be wrong, it throws when the id doesn't exist.

Step 3 — student_manager_core.cpp
C++
class StudentManager {
private:
    vector<Student> students;

    int findIndexById(int id) const {
        for (size_t i = 0; i < students.size(); i++) {
            if (students[i].getId() == id) return static_cast<int>(i);
        }
        return -1;
    }

public:
    void addStudent(int id, const string& name) {
        students.push_back(Student(id, name));
    }

    Student& getStudent(int id) {
        int idx = findIndexById(id);
        if (idx == -1) throw StudentNotFoundException(id);
        return students[idx];
    }

    bool removeStudent(int id) {
        int idx = findIndexById(id);
        if (idx == -1) return false;
        students.erase(students.begin() + idx);
        return true;
    }

    void listAll() const {
        cout << "--- Student Records (" << students.size() << ") ---" << endl;
        for (const Student& s : students) s.display();
    }
};
⚠️
Why throw instead of returning a default Student?
If getStudent(999) quietly returned an empty, default-constructed Student, calling code could easily use it by mistake — printing a fake record with id 0 as if it were real. Throwing makes the "not found" case impossible to ignore: the caller must handle it in a try/catch, or the exception propagates and stops the program.

Saving and Loading — File I/O

Each student is written as one CSV line: id,name,grade1;grade2;grade3. Commas separate the three top-level fields; semicolons separate multiple grades within the grades field. Loading reverses the process with getline() and stringstream, splitting on each delimiter in turn.

Step 4 — student_manager_fileio.cpp
C++
void saveToFile(const string& filename) const {
    ofstream outFile(filename);
    if (!outFile.is_open()) {
        throw runtime_error("Could not open file for writing: " + filename);
    }
    for (const Student& s : students) {
        outFile << s.toCSV() << "\n";
    }
    outFile.close();
}

void loadFromFile(const string& filename) {
    ifstream inFile(filename);
    if (!inFile.is_open()) {
        throw runtime_error("Could not open file for reading: " + filename);
    }
    students.clear();
    string line;
    while (getline(inFile, line)) {
        if (line.empty()) continue;
        stringstream ss(line);
        string idStr, name, gradesStr;
        getline(ss, idStr, ',');
        getline(ss, name, ',');
        getline(ss, gradesStr, ',');

        Student s(stoi(idStr), name);
        stringstream gs(gradesStr);
        string gradeToken;
        while (getline(gs, gradeToken, ';')) {
            if (!gradeToken.empty()) {
                s.addGrade(stod(gradeToken));   // re-validated on the way back in
            }
        }
        students.push_back(s);
    }
    inFile.close();
}
Reusing addGrade() on the way back in
loadFromFile() doesn't set grades directly — it calls s.addGrade(stod(gradeToken)) for each value, the exact same method used when adding grades in memory. That means every grade loaded from disk passes through the same inRange() validation as any other grade, with no separate code path to keep in sync.

Putting It Together in main()

Here's the complete, correct program — exceptions, the template helper, Student, StudentManager with file I/O, and a main() that deliberately triggers both exception cases before proving the save/load round trip works.

student_records.cpp — COMPLETE PROGRAM
C++
#include <iostream>
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
#include <stdexcept>
using namespace std;

// ── Custom exceptions ──
class StudentNotFoundException : public runtime_error {
public:
    StudentNotFoundException(int id)
        : runtime_error("Student with ID " + to_string(id) + " was not found.") {}
};

class InvalidGradeException : public runtime_error {
public:
    InvalidGradeException(double grade)
        : runtime_error("Invalid grade: " + to_string(grade) + " (must be between 0 and 100).") {}
};

// ── Template utility ──
template <typename T>
bool inRange(T value, T low, T high) {
    return value >= low && value <= high;
}

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

public:
    Student() : id(0), name("") {}
    Student(int studentId, string studentName)
        : id(studentId), name(studentName) {}

    int getId() const { return id; }
    string getName() const { return name; }
    const vector<double>& getGrades() const { return grades; }

    void addGrade(double grade) {
        if (!inRange(grade, 0.0, 100.0)) {
            throw InvalidGradeException(grade);
        }
        grades.push_back(grade);
    }

    double average() const {
        if (grades.empty()) return 0.0;
        double sum = 0.0;
        for (double g : grades) sum += g;
        return sum / grades.size();
    }

    void display() const {
        cout << "[" << id << "] " << name << " — Average: ";
        if (grades.empty()) cout << "N/A";
        else cout << average();
        cout << " (" << grades.size() << " grade(s))" << endl;
    }

    string toCSV() const {
        ostringstream oss;
        oss << id << "," << name << ",";
        for (size_t i = 0; i < grades.size(); i++) {
            oss << grades[i];
            if (i + 1 < grades.size()) oss << ";";
        }
        return oss.str();
    }
};

// ── StudentManager class ──
class StudentManager {
private:
    vector<Student> students;

    int findIndexById(int id) const {
        for (size_t i = 0; i < students.size(); i++) {
            if (students[i].getId() == id) return static_cast<int>(i);
        }
        return -1;
    }

public:
    void addStudent(int id, const string& name) {
        students.push_back(Student(id, name));
    }

    Student& getStudent(int id) {
        int idx = findIndexById(id);
        if (idx == -1) throw StudentNotFoundException(id);
        return students[idx];
    }

    bool removeStudent(int id) {
        int idx = findIndexById(id);
        if (idx == -1) return false;
        students.erase(students.begin() + idx);
        return true;
    }

    void listAll() const {
        cout << "--- Student Records (" << students.size() << ") ---" << endl;
        for (const Student& s : students) s.display();
    }

    void saveToFile(const string& filename) const {
        ofstream outFile(filename);
        if (!outFile.is_open()) {
            throw runtime_error("Could not open file for writing: " + filename);
        }
        for (const Student& s : students) {
            outFile << s.toCSV() << "\n";
        }
        outFile.close();
    }

    void loadFromFile(const string& filename) {
        ifstream inFile(filename);
        if (!inFile.is_open()) {
            throw runtime_error("Could not open file for reading: " + filename);
        }
        students.clear();
        string line;
        while (getline(inFile, line)) {
            if (line.empty()) continue;
            stringstream ss(line);
            string idStr, name, gradesStr;
            getline(ss, idStr, ',');
            getline(ss, name, ',');
            getline(ss, gradesStr, ',');

            Student s(stoi(idStr), name);
            stringstream gs(gradesStr);
            string gradeToken;
            while (getline(gs, gradeToken, ';')) {
                if (!gradeToken.empty()) {
                    s.addGrade(stod(gradeToken));
                }
            }
            students.push_back(s);
        }
        inFile.close();
    }
};

int main() {
    StudentManager manager;

    manager.addStudent(101, "Ada Lovelace");
    manager.addStudent(102, "Alan Turing");
    manager.addStudent(103, "Grace Hopper");

    try {
        manager.getStudent(101).addGrade(92.5);
        manager.getStudent(101).addGrade(88.0);
        manager.getStudent(102).addGrade(95.0);
        manager.getStudent(103).addGrade(78.5);
        manager.getStudent(103).addGrade(84.0);
    } catch (const InvalidGradeException& e) {
        cout << "Error adding grade: " << e.what() << endl;
    }

    cout << "\n-- Before saving --" << endl;
    manager.listAll();

    // deliberately trigger InvalidGradeException
    try {
        manager.getStudent(102).addGrade(150.0);
    } catch (const InvalidGradeException& e) {
        cout << "\nCaught expected error: " << e.what() << endl;
    }

    // deliberately trigger StudentNotFoundException
    try {
        Student& missing = manager.getStudent(999);
        missing.display();
    } catch (const StudentNotFoundException& e) {
        cout << "Caught expected error: " << e.what() << endl;
    }

    manager.saveToFile("students.csv");
    cout << "\nSaved records to students.csv" << endl;

    StudentManager reloaded;
    reloaded.loadFromFile("students.csv");
    cout << "\n-- After reloading from file --" << endl;
    reloaded.listAll();

    return 0;
}

Output:

Console Output
OUTPUT
-- Before saving --
--- Student Records (3) ---
[101] Ada Lovelace — Average: 90.25 (2 grade(s))
[102] Alan Turing — Average: 95 (1 grade(s))
[103] Grace Hopper — Average: 81.25 (2 grade(s))

Caught expected error: Invalid grade: 150.000000 (must be between 0 and 100).
Caught expected error: Student with ID 999 was not found.

Saved records to students.csv

-- After reloading from file --
--- Student Records (3) ---
[101] Ada Lovelace — Average: 90.25 (2 grade(s))
[102] Alan Turing — Average: 95 (1 grade(s))
[103] Grace Hopper — Average: 81.25 (2 grade(s))

Notice the message text: to_string(double) always prints six decimal places (150.000000), which is real, correct C++ behavior — a small reminder that even error messages deserve a second look. And notice what doesn't happen: the invalid grade never entered 102's data, and the missing id never produced a fake record — both exceptions did exactly the job they were written for.

🧩 Knowledge Check — Lesson 23
5 questions about the design decisions behind this project. Instant feedback on every answer.
1. Why do StudentNotFoundException and InvalidGradeException derive from std::runtime_error instead of being written completely from scratch?
2. What does the template function template <typename T> bool inRange(T value, T low, T high) allow?
3. In StudentManager::getStudent(int id), why does it throw StudentNotFoundException instead of returning some default, empty Student?
4. When loadFromFile() reads a saved CSV line and calls s.addGrade(stod(gradeToken)) for each grade, what does reusing addGrade() guarantee?
5. What does the CSV line 103,Grace Hopper,78.5;84 demonstrate about this project's file format?
💪
Extension Task — Lesson 23
Extend the project · Advanced Level

The base project works — now make it more useful by bringing back an STL algorithm from Phase 4. Complete this extension in any C++ compiler or online IDE, using the complete program from Section 7 as your starting point.

Extension: Class Ranking 🏆

Add a new method to StudentManager:

void printRanking() const

It should print every student ordered from highest average to lowest, with their rank ("1st", "2nd", "3rd", …) shown next to each name. Do this without changing the original order stored in students — sort a copy instead.

Call it from main() after loading the roster back from the file, and confirm the ranking matches the averages you already know from the console output above.
💡 Show hints if you're stuck
  • Copy first, sort the copy: vector<Student> ranked = students;
  • Comparator lambda: [](const Student& a, const Student& b) { return a.average() > b.average(); } — descending order
  • Call it with sort(ranked.begin(), ranked.end(), yourComparator); from <algorithm>
  • Loop with an index starting at 1, printing something like cout << (i+1) << ". "; ranked[i].display();
Finished this project?
Mark it complete to track your progress.
🎉

Phase 5 Complete!

You've built a complete, multi-class C++ program combining STL containers, function templates, custom exceptions, and file I/O. That's every major idea from this course, working together in one real system.

Module 23 of 26 Phase 5 — Templates & Advanced Concepts