Projects · Lesson 25.8

Word count

Source
Count how often each word appears in a tongue twister, and print the counts in alphabetical order.
You'll need: Parts 1–17 — this project is a checkpoint for Collections, and leans on String builder, Hash map and Tree map.

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"]
StepLessons it uses
Lower-case copyString builder, Encoding (c8'a'), Propagate
Splitting into wordsSlice, Continue, Range
CountingHash map, Coalesce (?? 0)
SortingTree map
The reportFormat ({:10}), For
FailuresError 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 step inWord is false, so a word that runs to the very end of the text is still counted. The i < text.length && test comes first, and && short-circuits, so text[i] is never read out of range.
  • i > start skips 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.

Src/Main.rux
// 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

Using a lookup as if it were a count.
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.
Dropping the ? 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.
Normalising only half the program.
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

  1. 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 SortBy from Sort, in the next part.
  2. Count letters as well as words. No map is needed: an array of 26 counters, [0; 26], indexed by c - c8'a', does the job.
  3. Ignore the short words "a", "as", "he" and "if" by keeping them in a Hash set and skipping any word it contains.
  4. Report the longest word as well as the most frequent one. When two are equally long, which one does your loop keep?

Learn more