C# from the ground up

Putting it together: a spending report

A capstone that uses the whole course once: records for the rows, LINQ to group them by category, and money formatted only on the way out.

Part 13 of 13

Advanced20 min readBack to Courses

Records: a name for a bundle of values

A record is a class whose whole job is to hold a few values: a row of data with names on it. Newer versions of C# write one in a single line — record Expense(string Kind, decimal Cost) — and the properties, the constructor and the printing are all supplied. The compiler in this sandbox is older than that keyword, so here the same idea is written out as a small class with two properties, exactly as the previous chapters taught.

The reason a record is worth its lines is the name. Two decimals and a string passed around loose can be swapped by mistake; Expense with a kind and a cost cannot. The shape of the value is written down once, and every method that takes one is checked against it.

GroupBy: one row per group

A report is almost always a question about groups: what was spent per category, per month, per person. GroupBy(r => r.Kind) answers it. It hands back one group per category, and each group is the rows that belong to it, so a count is g.Count() and a total is g.Sum(r => r.Cost).

Select then shapes each group into the line to print, and OrderBy(g => g.Key) settles the order so the report looks the same on every run — the habit from the dictionaries chapter, needed here for the same reason. Nothing about a group depends on the order the rows arrived in.

Money, and reading the report

Costs stay decimal from the first row to the last total, and the format F2 is applied only where a person reads the number. Adding up prices that were already turned into text would mean parsing them back, which is the kind of bug this one rule prevents.

The file has the shape worth keeping: the values in a small class, the questions as LINQ, and the reading of them at the very end. The same skeleton carries a real program — the rows come from a file or a form instead of a literal list, and the class becomes the place where each row is checked before it is trusted.

The record-shaped class, and the grouping

using System;
using System.Collections.Generic;
using System.Linq;

// A record is a class whose job is to hold a few values.
// Newer C# writes it in one line:
//     record Expense(string Kind, decimal Cost)
// This compiler is older, so here it is written out.
class Expense
{
    public string Kind { get; private set; }
    public decimal Cost { get; private set; }

    public Expense(string kind, decimal cost)
    {
        Kind = kind;
        Cost = cost;
    }

    public override string ToString()
    {
        return Kind + " " + Cost.ToString("F2");
    }
}

class Program
{
    static void Main()
    {
        var rows = new List<Expense>
        {
            new Expense("travel", 42.5m),
            new Expense("food", 14.25m),
            new Expense("travel", 180m),
        };

        // One group per category, in a settled order.
        var groups = rows.GroupBy(r => r.Kind);
        var ranked = groups.OrderBy(g => g.Key);

        foreach (var g in ranked)
        {
            var sum = g.Sum(r => r.Cost);
            var line = g.Key + " x" + g.Count() + " ";
            Console.WriteLine(line + sum.ToString("F2"));
        }

        var total = rows.Sum(r => r.Cost);
        Console.WriteLine(total.ToString("F2"));
    }
}

Three rows, one report

Run it and read the three lines. Then add a fourth row in a new category, and add a count per category to each line so the report reads like a summary.

Output
Click "Run" to execute your code

Keep a value as a number until the moment it is printed, then format it once. Every report that does this can be re-used for a different currency with one change.

What a group is

GroupBy returns a sequence of groups, and each group is both a key and a sequence of the rows that share it. g.Key is the value the grouping function produced once, and g.Count() or g.Sum(...) walks the rows in that group. The groups are produced lazily, like any other LINQ query.

The key comparison uses the same equality rules as a dictionary. Two strings with the same characters are the same key; two objects are the same key only if their equality says so. That is why grouping by a formatted string can split one category into two when the formatting differs.

Grouping is deferred like any other query, but when it runs it cannot stream the way Where does: it has to read every row before it can be sure a group is complete, so the first group appears only after the last row has been seen. For a report that is fine, because the whole list is already in memory. For a sequence that arrives slowly, it means a pause before the first line of output, which is worth knowing when the source is a file or a network call.

Totals come from rows, not from lines

A report usually needs a total for each group and a total for the whole set. The group total is g.Sum(r => r.Cost), and the grand total is rows.Sum(r => r.Cost). Adding the printed group totals back together would work only if they were still numbers; once formatted, they are text.

That is the shape to keep: collect the rows, group them, aggregate each group as a number, and format once per line. Every later change, a new category or a different currency, touches one stage instead of the whole report.

Three report mistakes: summing values that were already formatted into strings; grouping by a display label, so Travel and travel become two categories; and printing groups without an OrderBy, then expecting the same order on every run.

Try these next

  • Add a fourth row in an existing category and check that the group count and total both change.
  • Group the rows by cost rounded to whole units, and explain why that is usually the wrong key for money.
  • Add a grand total and an average per row, formatting each number only in the line that prints it.

Takings grouped by month

Run it and read the four lines. Then add a second sale in March, and make the report show the average sale in each month as well as the total.

Output
Click "Run" to execute your code

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

What is a record, in the words of this chapter?