Word count
How often does each word appear in "How much wood would a woodchuck chuck"? This program counts every word of the tongue twister, prints the counts in alphabetical order with a little bar chart, and names the favourite word.
Counting is what a hash map is for, and this project is a checkpoint for Part 17: Collections. It also shows two habits that make text programs cheap: normalising the text once, up front, and using views into it as keys instead of copying every word into a new string.
How it is put together
The program is a pipeline of four steps, each feeding the next:
flowchart LR
lines["Five lines<br/>of the twister"] --> lower["One lower-case copy<br/>in a StringBuilder"]
lower --> count["HashMap<br/>word → count"]
count --> sorted["TreeMap<br/>same entries, sorted"]
sorted --> report["Bars and<br/>the favourite"]| Step | Lessons it uses |
|---|---|
| Lower-case copy | String builder, Encoding (c8'a'), Propagate |
| Splitting into words | Slice, Continue, Range |
| Counting | Hash map, Coalesce (?? 0) |
| Sorting | Tree map |
| The report | Format ({:10}), For |
| Failures | Error sum, Fallible main |
Normalise once, up front
"How" and "how" should be one word. Rather than compare words case-insensitively everywhere, the program makes one lower-case copy of the whole text, with a space where each line ended:
func AppendLowercase(builder: &var StringBuilder, text: char8[..]) -> ! TextError {
for c in text {
if c >= c8'A' && c <= c8'Z' {
builder.AppendAscii(c - c8'A' + c8'a')?;
} else {
builder.AppendAscii(c)?;
}
}
}
c - c8'A' + c8'a' moves a capital letter to its small twin: the letters A to Z and a to z each sit in a row in ASCII, so the distance from A is the same as the distance from a. Appending can fail — the builder may need more memory — so each call ends in ?, and the function's ! TextError passes the failure on.
Words are views, not copies
The text is walked once. start marks where the current word began; any byte that is not a letter ends it, and the slice between the two is the word:
for i in 0..=text.length {
let inWord = i < text.length && IsLetter(text[i]);
if inWord {
continue;
}
if i > start {
let word = text[start..i];
counts.Insert(word, (counts.Get(word) ?? 0) + 1)?;
words += 1;
}
start = i + 1;
}
Three details make this loop correct:
- The range is
0..=text.length, one step past the last byte. At that stepinWordis false, so a word that runs to the very end of the text is still counted. Thei < text.length &&test comes first, and&&short-circuits, sotext[i]is never read out of range. i > startskips empty words, which appear wherever two separators sit side by side, such as", ".text[start..i]is a view into the lower-case copy: making it costs nothing. That is also why nothing may be appended to the builder after this point — the keys all point into it.
Counting with a hash map
The counting line reads like a sentence: look the word up, add one, store it back. Get returns an int32? — none for a word not seen before — and ?? 0 turns that absence into a count of zero:
counts.Insert(word, (counts.Get(word) ?? 0) + 1)?;
The map is built with three things: an allocator, a function that hashes a slice, and one that compares two slices. A slice's == would compare the views, not the text they show, so the map is told how to compare the contents:
var counts = HashMap<char8[..], int32>(allocator, HashSlice, EqualsSlice);
Sorting by copying into a tree map
A hash map keeps no order — print it straight out and the words come in whatever order the hashing scattered them. A tree map always walks its keys in order, so copying the finished counts into one sorts them for free:
var sorted = TreeMap<char8[..], int32>(allocator, CompareSlice);
for entry in counts {
sorted.Insert(entry.key, entry.value)?;
}
The report then walks sorted. {:10} pads each word to ten columns so the counts line up, and a strict > when looking for the favourite means a tie goes to the word earlier in the alphabet.
Two kinds of failure
Main is declared -> ! (CollectionError | TextError): the maps can fail with a CollectionError and the builder with a TextError. With a sum of both as Main's failure, every ? in the program passes its error straight up, and a failure ends the program with exit status 1.
The program
The whole lesson is one package in the Examples repository. Its comments explain every step.
// WordCount: how often does each word appear in a tongue twister?
//
// Counting is what a hash map is for. Each word is a key and its count the value: look the word
// up, add one, store it back. A word not seen before is simply absent, and `?? 0` turns that
// absence into a count of zero to add to.
//
// A hash map keeps no order, though, so printing it straight out would list the words however the
// hashing scattered them. Copying the finished counts into a `TreeMap` sorts them by word for
// free, because a tree map walks its keys in order.
//
// Two details make the words come out right. "How" and "how" should count as one word, so the
// text is first copied in lower case. And the map's keys are slices of that copy, not new strings:
// each word is a view of the bytes it occupies, which costs nothing to make.
import Allocator::{ Allocator, SystemAllocator };
import Collections::{ CollectionError, CompareSlice, EqualsSlice, HashMap, HashSlice, TreeMap };
import Io::{ Print, PrintLine };
import Text::{ StringBuilder, TextError };
func IsLetter(c: char8) -> bool {
return c >= c8'a' && c <= c8'z';
}
// Adds the text to the builder with every capital letter A to Z made small, and the rest as it is.
func AppendLowercase(builder: &var StringBuilder, text: char8[..]) -> ! TextError {
for c in text {
if c >= c8'A' && c <= c8'Z' {
builder.AppendAscii(c - c8'A' + c8'a')?;
} else {
builder.AppendAscii(c)?;
}
}
}
func Main() -> ! (CollectionError | TextError) {
var system = SystemAllocator();
let allocator: Allocator = system;
let twister = [
"How much wood would a woodchuck chuck",
"if a woodchuck could chuck wood?",
"He would chuck, he would, as much as he could,",
"and chuck as much wood as a woodchuck would",
"if a woodchuck could chuck wood."
];
// One lower-case copy of the whole text, with a space where each line ended. Every key in the
// maps below is a view into it, so nothing is appended to it after this loop.
var lower = StringBuilder(allocator);
for line in twister {
AppendLowercase(lower, line)?;
lower.AppendAscii(c8' ')?;
}
let text = lower.Bytes();
// Walk the text once. `start` marks where the current word began; a byte that is not a
// letter ends the word, and the slice between them is the key.
var counts = HashMap<char8[..], int32>(allocator, HashSlice, EqualsSlice);
var words = 0;
var start: uint = 0;
for i in 0..=text.length {
let inWord = i < text.length && IsLetter(text[i]);
if inWord {
continue;
}
if i > start {
let word = text[start..i];
counts.Insert(word, (counts.Get(word) ?? 0) + 1)?;
words += 1;
}
start = i + 1;
}
PrintLine("{} words, {} different", words, counts.Length());
PrintLine();
// Same entries, now in alphabetical order.
var sorted = TreeMap<char8[..], int32>(allocator, CompareSlice);
for entry in counts {
sorted.Insert(entry.key, entry.value)?;
}
var top: char8[..] = "";
var topCount = 0;
for entry in sorted {
Print("{:10} {:2} ", entry.key, entry.value);
for i in 0..entry.value {
Print("*");
}
PrintLine();
// Strictly greater, so a tie goes to the word earlier in the alphabet.
if entry.value > topCount {
top = entry.key;
topCount = entry.value;
}
}
PrintLine();
PrintLine("the favourite word is \"{}\", {} times", top, topCount);
}
Besides Io, its Rux.toml lists Allocator, Collections and Text under [Dependencies].
Run it
cd Examples/Projects/WordCount
rux run
38 words, 12 different
a 4 ****
and 1 *
as 4 ****
chuck 5 *****
could 3 ***
he 3 ***
how 1 *
if 2 **
much 3 ***
wood 4 ****
woodchuck 4 ****
would 4 ****
the favourite word is "chuck", 5 times
Common mistakes
counts.Get(word) + 1 is refused, because Get may find nothing: error: operator '+' cannot combine left operand 'int32?' with right operand 'int'. Decide what absence means first — here ?? 0.? from an insertion.Insert can fail when the map needs memory, so a bare counts.Insert(…); fails with error: fallible result of type '! CollectionError' is discarded.IsLetter accepts only a to z. Skip the lower-casing and the capitals become separators: "How" splits into a lost H and a word ow, "He" into e, and the report says 13 different words instead of 12. Normalise once, and then the rest of the program can assume it.Try it yourself
- Print the words sorted by count, highest first, instead of alphabetically. One way: copy the entries into a Vector of structs and order it with
SortByfrom Sort, in the next part. - Count letters as well as words. No map is needed: an array of 26 counters,
[0; 26], indexed byc - c8'a', does the job. - Ignore the short words "a", "as", "he" and "if" by keeping them in a Hash set and skipping any word it contains.
- Report the longest word as well as the most frequent one. When two are equally long, which one does your loop keep?
Learn more
25.7 Quadratic
Read three coefficients and solve a x² + b x + c = 0, telling apart two real roots, a repeated root, a complex pair, a linear equation, an inconsistent one and an identity.
25.9 Inventory
Keep a shop's stock as structs in a vector and apply a day of orders, refusing the ones that cannot be carried out.