Perl from the ground up

Capstone: a report from a table

Everything at once: a small table kept in the file, cut into records, filtered, sorted by a field, and printed as a report whose columns line up.

Part 12 of 12

Advanced22 min readBack to Courses

The shape of the program

This program has four steps and it takes them in order: cut each line of the table into fields, build one record per line, keep the records worth reporting on, and print them in order. Each step is a couple of lines, and each one is something an earlier chapter taught.

The table is written into the file, because this sandbox has no files to read. A real version of the same program would pull lines from a file, and every line after that first one would be identical — reading the data is the only part this page cannot show you.

From a line to a record

split /,/, $line cuts a line on a comma and answers the fields as a list, so my ($name, $score) = split /,/, $line; names them in the same line as they are cut. Naming them right there keeps the rest of the program readable.

One hash reference per line is what makes the records uniform: push @rows, { 'name' => $name, 'score' => $score };. If a later line of the table has a field missing, the name for it is undef rather than a wrong value, and use warnings says so — which is the honest way for it to fail.

Filtering, sorting, printing

The filter is one grep over the records: my @kept = grep { $_->{'score'} >= 70 } @rows;. The sort is a comparator block on the same field: sort { $a->{'score'} <=> $b->{'score'} } @kept puts the weakest record first, and swapping $a with $b puts the strongest first.

The report itself is printf, once per record, with %-10s for a left-aligned name and %5d for a number in five columns. The columns line up because every row asks for the same widths, and that is most of what makes output look like a report rather than a log.

The step this sandbox cannot show

A real report reads its lines from a file or a pipe. The loop looks like while (my $line = <$fh>) { chomp $line; ... }: read one line, remove the trailing newline, use it, repeat. chomp removes one line ending and nothing else, which is why it is safer than chop, its older relative that removes the last character whatever it is.

Opening the file is the one step that can fail, and the usual form is open my $fh, '<', $path or die "cannot open $path: $!";. Everything after that — split, build, filter, sort, print — is identical to the version on this page. Keeping the reading step at the top is what makes the rest portable.

A report has to survive an empty result

The filter can remove every row. An average then divides by zero, and Perl stops the program with an error rather than printing a blank line. Check the count before dividing, or print a sentence that says no rows matched. A report that handles the empty case is a report you can run on a quiet day.

Numbers and text also need different comparisons on the way through. A score read from text is still text until an operator converts it, so >= and <=> are right for scores and eq is right for names. The widths in printf then make the answer readable: %-10s for a left-aligned name, %5d for a whole number, %.1f for an average.

A heading and a total

A report usually needs a heading before the first row and a total after the last. Print the heading once, outside the loop, with the same widths the rows use; then print the total after the loop, from a variable the loop filled. The three parts share the format strings, so the columns cannot drift apart.

The whole capstone, one page

use strict;
use warnings;

# The data, written here: nothing is read from a file.
my @lines = ('Ada,95,London', 'Grace,72,New York',
             'Alan,88,Cambridge');

my @rows;
foreach my $line (@lines) {
  my @cell = split /,/, $line;
  push @rows, {'name' => $cell[0], 'score' => $cell[1],
               'city' => $cell[2]};
}

my @kept = grep { $_->{'score'} >= 70 } @rows;
my @order = sort { $a->{'score'} <=> $b->{'score'} } @kept;

printf "%-10s %5s  %s\n", 'NAME', 'SCORE', 'CITY';
foreach my $row (@order) {
  printf "%-10s %5d  %s\n", $row->{'name'}, $row->{'score'},
                            $row->{'city'};
}
my $total = 0;
$total += $_->{'score'} foreach @order;
printf "%-10s %5.1f\n", 'average', $total / scalar @order;

Four scores become a report

Run it and check that the filter and the sort did what the comments claim. Then add a third field to every line of the table, put it in the report, and print one line for each field.

Output
Click "Run" to execute your code

Keep the steps apart: read, build, filter, sort, print. A program that takes them in that order can be read from the top, and the part that would change when the data moves somewhere else is the first few lines.

Three report mistakes are common. chomp removes a trailing newline; chop removes the last character even when it is part of the data. split /,/, 'a,b,' drops the trailing empty field unless you pass a limit of -1. And dividing a total by an empty list stops the program, so check the count before the average line.

Try these next

  • Add a city field to the table and put it in the report as a third column.
  • Group the rows by region and print one total for each region.
  • Add a row that the filter rejects and print a count of rejected rows beside the report.

Totals by region

Run it and check the totals. Then add a row for a new region and a row that should be rejected, and confirm both the totals and the rejected count.

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 does push @rows, { 'name' => $name }; add to the list?