A report from records, sorting and grouping
One program that uses records, a sort with a comparison, a fold for totals and Printf for a report: four books, their pages, and a total per genre.
Two questions about one shelf
The program keeps a shelf as a list of records, then answers two questions: the books from the longest down, and the pages held by each genre. The first is a sort with the comparison written out — compare b.pages a.pages is the ordinary ordering with the arguments the other way round — and the second is a filter and a fold for each genre in a short list.
Nothing here is new. Records, List.sort with a comparison, List.filter, List.fold_left, List.iter and Printf.printf have all appeared, and the capstone is the argument that they compose: a little data, a few small functions, and a printed report.
Widths are what make it a report
%-20s writes a string left-aligned in a field twenty characters wide, %3d writes an int in a field three wide, and %4d does the same for four. The fields are what line the numbers up in a column, so the output reads as a table even though it is only a sequence of printed lines. A width is a minimum: a longer value pushes the rest of the line along, which is why a title column is sized for the longest title you expect.
The genre section is a small grouping: for each genre in the list, keep the books that belong to it, then fold their page counts. Sorting first would be the general answer when the genres are not known in advance; a fixed list of two is enough here, and it keeps the shape of the program visible.
From rows to a report
A report is a pipeline. Start with the rows, keep the ones that belong in this section with List.filter, order them with List.sort, reduce them with List.fold_left, and only then format each line. Keeping the stages separate means each one can be read and checked on its own, and the printed output is the last thing that happens rather than the place where the logic lives.
Grouping by a fixed key is a filter per key. That is clear for two or three known genres, and it is the right shape while the set is small. When the keys are not known in advance, sort by the key first and walk the sorted list once, starting a new total whenever the key changes; that is the general group-by, and it avoids scanning the whole list for every key.
Averages need care because the division is float division. total shelf / List.length shelf is an int division and throws away the remainder; float_of_int total /. float_of_int (List.length shelf) keeps it. Printf then decides how many places to show, and the format is the last place where a rounding decision should be made.
What the pipeline buys you
Each stage has a type that is easy to state: a list of books, a filtered list of books, a sorted list, a number. Because three of those steps share a type, the compiler would accept them in almost any order — which is why the order has to be argued for in the code rather than discovered by the checker.
The pipeline is also where a report becomes testable. total and by_pages are functions from data to data; only the last line knows about a printer. If a number is wrong, the stage that produced it can be called on its own, which is not true of a loop that prints as it goes.
What goes wrong
The int division is the first: total shelf / List.length shelf looks like an average and answers a whole number, because both sides are ints. Convert before dividing, and convert both sides — float_of_int total /. float_of_int (List.length shelf) — or the compiler will refuse the mixed operator.
The second is a sort comparison with the arguments the wrong way round, which produces a report that is correct and backwards. compare a.pages b.pages is ascending; compare b.pages a.pages is descending. Neither is wrong in the type system, so check the first two rows against the heading.
Three report mistakes: dividing two ints and expecting a decimal, which needs float_of_int on both sides and /.; passing a float to %d or an int to %f, which the format string refuses at compile time; and making a column narrower than its longest value, which does not truncate it but pushes the rest of the line out of alignment. A width is a minimum, not a maximum.
Try these next
- Add a count column and a total row to the genre table.
- Change the report to sort by title as well as by pages, and check the tie-break.
- Compute the average pages per book and print it with one decimal place.
A total with a padded format
type entry = { name: string; amount: int }
let entries =
[ { name = "rent"; amount = 900 }; { name = "food"; amount = 250 } ]
let () =
Printf.printf "%-8s %4d\n" "total"
(List.fold_left (fun sum e -> sum + e.amount) 0 entries)The shelf report
Run it and read the whole report. Then add a fifth book, change the genre list to three genres, and make the title column narrower so the lines get shorter.
A spending report by category
Run it and read the report. Then add a fourth expense, include its category in the list, and print the average expense with two decimals.
Read the output, then break it: change %-20s to %s and the numbers stop lining up, or swap the arguments of compare in by_pages and the report runs the other way. A report is a program you can check by eye, which makes it a good place to experiment.
Check what you learned
One question at a time. A wrong answer explains itself — take another go.
Part 1 of 5
What does List.sort (fun a b -> compare b.pages a.pages) shelf put first?
