C++ / CAPSTONE PROJECTS
Project: a gradebook that reads files and reports statistics
Read a CSV gradebook with ifstream and getline, skip malformed rows with line numbers, and report count, mean, median, spread and range.
What you will learn
- Split CSV rows with getline(ss, field, ','), not >>, which breaks on spaces and commas
- Drive the read loop with while (getline(in, line)), never with while (!in.eof())
- Reject a score field that still has characters left over after the number
- Accumulate mean and stddev by iteration, but keep every value to get a median
Understanding Project: a gradebook that reads files and reports statistics
A report program has three layers that must not blur together: lines of text from disk, validated records in memory, and numbers derived from those records. The parsing layer is the only code that knows about commas, stray spaces and typos; once a line becomes a Record its average is trusted, and the statistics layer never has to ask whether a score was really a number. That separation is what turns a broken line into a reported skipped row instead of a silent zero that quietly drags the class mean down.
The read loop condition is while (std::getline(in, line)), not while (!in.eof()), because eofbit is only set after a read has already failed, so testing it first always buys one extra iteration over a stale buffer. Inside a line, operator>> is the wrong tool since it splits on whitespace and knows nothing about commas, so the row is cut up by a second getline that uses ',' as its delimiter. Each field then goes through its own istringstream, and a row is accepted only if the number consumed the entire field: checking for one leftover character is what catches "8O" typed with a letter O, or a trailing carriage return.
Which statistics you can stream and which need the whole sample decides your data structure. Count, sum and sum of squared deviations need only running totals, so they could be computed while reading; the median, quartiles or "who is in the bottom quarter" need every value present at once, which is why the roster is a vector. It also matters what each number hides: one missing assignment recorded as 0 moves the mean hard and the median barely at all, so a report that prints only the mean will mislead whoever reads it.
<algorithm>
<cmath>
<cstddef>
<fstream>
<iomanip>
<iostream>
<sstream>
<string>
<vector>
struct Record {
std::string name;
double average;
};
int main() {
{ // write the input file; the destructor closes and flushes it here
std::ofstream out("grades.csv");
out << "Ada,90,86,100\n"
<< "Grace,72,80,79\n"
<< "Alan,,55\n"
<< "Linus,60,68,73\n"
<< "Barbara,84,88,95\n";
}
std::ifstream in("grades.csv");
if (!in) {
std::cerr << "cannot open grades.csv\n";
return 1;
}
std::vector<Record> roster;
std::vector<std::string> skipped;
std::string line;
int lineNo = 0;
while (std::getline(in, line)) {
++lineNo;
if (line.empty()) continue;
std::istringstream fields(line);
std::string name;
std::getline(fields, name, ',');
std::string token;
double sum = 0.0;
int scores = 0;
bool ok = !name.empty();
while (ok && std::getline(fields, token, ',')) {
std::istringstream text(token);
double value = 0.0;
char leftover = '\0';
if (!(text >> value) || (text >> leftover)) ok = false;
else { sum += value; ++scores; }
}
if (ok && scores > 0) roster.push_back({name, sum / scores});
else skipped.push_back("line " + std::to_string(lineNo) + ": " + line);
}
if (roster.empty()) {
std::cerr << "no usable rows\n";
return 1;
}
std::sort(roster.begin(), roster.end(),
[](const Record& a, const Record& b) { return a.average > b.average; });
std::cout << std::fixed << std::setprecision(2);
for (const Record& r : roster)
std::cout << std::left << std::setw(10) << r.name
<< std::right << std::setw(6) << r.average << '\n';
const std::size_t k = roster.size();
double total = 0.0;
for (const Record& r : roster) total += r.average;
const double mean = total / k;
double ss = 0.0;
for (const Record& r : roster) {
const double d = r.average - mean;
ss += d * d;
}
const double sd = k > 1 ? std::sqrt(ss / (k - 1)) : 0.0;
const double median = (roster[k / 2].average + roster[(k - 1) / 2].average) / 2;
std::cout << '\n'
<< "students " << k << '\n'
<< "mean " << mean << '\n'
<< "median " << median << '\n'
<< "std dev " << sd << '\n'
<< "range " << roster.back().average << " to "
<< roster.front().average << '\n'
<< "\nskipped " << skipped.size() << ":\n";
for (const std::string& s : skipped) std::cout << " " << s << '\n';
}
Text read from a file is untrusted input, so parsing must either produce a validated record or a reported skipped line, and only validated records reach the statistics.
Worked examples
What splitting a row really produces
Shows that getline with a delimiter keeps embedded spaces and empty fields, but never produces the field after a trailing comma.
<iostream>
<sstream>
<string>
<vector>
std::vector<std::string> split(const std::string& row, char sep) {
std::vector<std::string> out;
std::istringstream s(row);
std::string field;
while (std::getline(s, field, sep)) out.push_back(field);
return out;
}
int main() {
std::vector<std::string> f = split("Ada Lovelace,90,,86,", ',');
std::cout << "fields: " << f.size() << '\n';
for (std::size_t i = 0; i < f.size(); ++i)
std::cout << i << ": [" << f[i] << "]\n";
}
Example explained
Line 1getline stops only at ',', so the space inside "Ada Lovelace" stays inside one field, whereas s >> field would have produced two tokens.
Line 2The gap between the two adjacent commas becomes an empty string, so a missing grade is something you can detect instead of something that vanishes.
Line 3Nothing follows the final comma, so the next getline call extracts no characters, sets failbit and ends the loop: 4 fields for 5 comma positions.
Line 4Splitting into strings first and converting afterwards keeps the decision "is this row usable" in a single place.
Telling bad data apart from end of file
Diagnoses why a >> loop stopped early and recovers the remaining scores instead of losing them.
<fstream>
<iostream>
<string>
int main() {
{
std::ofstream out("scores.txt");
out << "88\n91\nninety\n70\n";
}
std::ifstream in("scores.txt");
if (!in) {
std::cout << "cannot open scores.txt\n";
return 1;
}
double sum = 0.0, x = 0.0;
int n = 0;
while (in >> x) { sum += x; ++n; }
std::cout << "read " << n << " values, sum " << sum << '\n';
std::cout << "eof=" << in.eof() << " fail=" << in.fail() << '\n';
if (!in.eof()) {
in.clear();
std::string junk;
in >> junk;
std::cout << "skipping \"" << junk << "\"\n";
while (in >> x) { sum += x; ++n; }
}
std::cout << "read " << n << " values, sum " << sum << '\n';
}
Example explained
Line 1The first loop ends at "ninety" because no number can be built from 'n': failbit is set and the offending characters stay in the buffer.
Line 2eof=0 together with fail=1 is the signature of bad data, not of a finished file; eofbit appears only when a read runs off the end.
Line 3in.clear() resets the flags, and reading the token into a std::string consumes the characters that blocked the numeric extraction.
Line 4Without that clear-and-skip pair the last score, 70, is never read and the report understates the total by 70.
Median without a full sort, plus a letter histogram
Uses nth_element to find the middle of a sample and a map to count grade bands in key order.
<algorithm>
<iostream>
<map>
<vector>
double median(std::vector<double> v) { // by value: nth_element reorders it
if (v.empty()) return 0.0;
std::size_t mid = v.size() / 2;
std::nth_element(v.begin(), v.begin() + mid, v.end());
double hi = v[mid];
if (v.size() % 2 == 1) return hi;
double lo = *std::max_element(v.begin(), v.begin() + mid);
return (lo + hi) / 2;
}
char letter(double s) {
if (s >= 90) return 'A';
if (s >= 80) return 'B';
if (s >= 70) return 'C';
if (s >= 60) return 'D';
return 'F';
}
int main() {
std::vector<double> scores;
scores.push_back(92); scores.push_back(89); scores.push_back(77);
scores.push_back(67); scores.push_back(95); scores.push_back(83);
std::cout << "median " << median(scores) << '\n';
std::map<char, int> hist;
for (std::size_t i = 0; i < scores.size(); ++i) ++hist[letter(scores[i])];
for (std::map<char, int>::const_iterator it = hist.begin(); it != hist.end(); ++it)
std::cout << it->first << ' ' << it->second << '\n';
}
Example explained
Line 1median takes its vector by value because nth_element rearranges elements; the caller's roster order is left untouched.
Line 2nth_element only guarantees that position mid holds the element a full sort would put there, which is all a median needs, in linear time.
Line 3For an even count the other middle value is the largest element of the left partition, which nth_element has already pushed before mid.
Line 4std::map keeps its char keys ordered, so the histogram comes out A, B, C, D with no extra sorting step.
Important notes
Dividing the squared deviations by k - 1 makes this a sample standard deviation; with a single accepted row that is 0/0 and prints nan, which is why the code guards on k > 1. Divide by k instead if the class is treated as the whole population.
Reading the file back inside the same program works only because the writer lives in its own block: the ofstream destructor closes and flushes it there. Keep the writer open and the reader may see an empty file.
Common mistakes
Writing while (!in.eof()) { std::getline(in, line); ... }: eofbit is set only after a read fails, so the last iteration parses a stale or empty line and the report gains a phantom student or divides by zero.
Reading rows with in >> name >> s1 >> s2: >> splits on whitespace, not commas, so "Ada,90,86" all lands in name, the score extractions fail, and the program reports zero students from a perfectly good file.
Editing grades.csv on Windows leaves a \r at the end of each line; it sticks to the last score token, so "100\r" fails the leftover-character check and every row ends up in the skipped list.
Try it yourself
Change, predict, then run
Extend the parser to keep each row's raw scores instead of just the average, then print a per-assignment line giving the mean and the highest score for assignment 1, 2 and 3. Make any row whose score count differs from the first accepted row land in the skipped list with its line number.
Open the C++ workspaceCheck your understanding
The outer loop reads lines with std::getline(in, line) and the inner loop splits with std::getline(fields, token, ','). For the row Ada,90,,86, how many score tokens does the inner loop see?
- 3, because the position after the final comma never becomes a token
- 4, because the trailing empty field is produced like any other
- 2, because getline skips fields that contain no characters
- 5, one token per comma-separated position, including the name
Show answer
After the name is taken by the outer split, getline yields "90", then the empty field between the two commas, then "86" while consuming the final comma as its terminator. The next call finds the stream already at the end, extracts nothing, sets failbit and produces no token, so 3. Option 2 is the tempting one: getline does not skip an empty field, it hands you an empty string, and treating that as "nothing was there" is exactly how a missing grade becomes an invisible one.