Final Project — Student Record System
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.
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.
template<typename T> range-check function reused throughout the project.addGrade() that validates through the template helper, and an average().vector<Student> with addStudent(), removeStudent(), getStudent() (throws if missing), and listAll().saveToFile() and loadFromFile(), converting each Student to and from one CSV line using fstream and stringstream.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 >.
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; }
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.
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.
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(); } };
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.
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(); }
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.
#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:
-- 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.
StudentNotFoundException and InvalidGradeException derive from std::runtime_error instead of being written completely from scratch?template <typename T> bool inRange(T value, T low, T high) allow?StudentManager::getStudent(int id), why does it throw StudentNotFoundException instead of returning some default, empty Student?loadFromFile() reads a saved CSV line and calls s.addGrade(stod(gradeToken)) for each grade, what does reusing addGrade() guarantee?103,Grace Hopper,78.5;84 demonstrate about this project's file format?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.
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();