A Word Counter
The first program reads a text file and prints the words that occur most often in it, with their counts. It is a classic small program because it touches files, strings, a hash table and sorting, and in a language with a garbage collector it is about ten lines long. In C it is several pages, mostly memory management. The Goose version is about sixty lines, and none of them manage memory.
#The program24.1
Given this file, geese.txt:
The goose that lays the golden eggs is not a goose you should cook.
A goose is a goose, said the farmer, and the farmer's wife agreed:
"Don't cook the goose." So the goose stayed, and the eggs kept coming,
and the farmer kept the eggs, and the goose kept the farmer honest.
the program, run as goose words.goose -- geese.txt 6, prints the six
most common words:
import std;
import os;
import dictionary;
struct Count { word: const u8[:], n: i64 }
type Counts = dictionary<const u8[:], i64>;
fn is_word_byte(c: u8) -> bool {
(c >= 'a' && c <= 'z') || c == '\''
}
// Counts the words of text into counts, and returns how many
// there were. A word is a run of letters and apostrophes, and
// text must be lower case.
fn count_words(text: const u8[:], counts: Counts&) -> i64 {
var total = 0;
var i = 0;
while i < text.len {
while i < text.len && !is_word_byte(text[i]) { i += 1; }
let start = i;
while i < text.len && is_word_byte(text[i]) { i += 1; }
if i > start {
counts.update(text[start..i], 0) { it += 1; };
total += 1;
}
}
return total;
}
// The entries of counts, most frequent first, and
// alphabetically on a tie.
fn ranked(counts: Counts&) -> Count[>..] {
var out: Count[>..] = [];
counts.each() { w, n => out.push(Count { word: w, n: n }); };
sort(out) { a, b =>
a.n > b.n || (a.n == b.n && compare(a.word, b.word) < 0)
};
return out;
}
fn main() {
let a = args();
guard a.len >= 2 else {
print("usage: words file [how-many]");
exit(2);
}
var top = 10;
if a.len >= 3 {
let n, ok = parse_int(a[2]);
guard ok && n > 0 else {
print("not a count: ", a[2]);
exit(2);
}
top = n;
}
var text: u8[>..] = [];
guard read_file(a[1], text) else {
print("cannot read ", a[1]);
exit(1);
}
to_lower(text);
var counts = Counts {};
let total = count_words(text, counts);
let list = ranked(counts);
print(total, " words, ", counts.count, " different");
for c in list[..min(top, list.len)] {
var line: u8[>..] = [];
format_int(line, c.n, 10, 5, ' ');
format(line, " ", c.word);
print(line);
}
}
53 words, 24 different
11 the
7 goose
4 and
3 a
3 eggs
3 farmer
The rest of the chapter goes through the decisions in it.
#One buffer, no copies24.2
The program reads the whole file into one grow-only array, text, and
lowers its case in place. After that, no byte of the input is copied
again. A word is a slice of text, a dictionary key is a slice of
text, and so is the word field of every Count:
This is the shape that Goose favours: one large owner, and many small views
into it. The views are cheap (a pointer and a length each), and the
compiler knows what each one points into. If you tried to keep list after
text was gone, for example by returning it from a function that owned
text, the program would not compile.
count_words takes text as const u8[:]. It only reads the text, and the
dictionary's key type is const u8[:] too, so the slices it stores are
read-only views. Chapter 18 explains why a dictionary keyed by string
slices uses the const form.
#Counting with update24.3
counts.update(text[start..i], 0) { it += 1; };
update looks the key up once, inserts 0 if the key is missing, and runs
the block on a reference to the value. The obvious alternative, a get
followed by an insert when the key is missing, hashes the word twice for
every new word. More importantly, the block form is the one that the
borrow rules make easy: the reference it cannot outlive the block, so it
cannot be held across the next insertion, which might move the table.
#Ranking24.4
ranked copies the entries into a Count[>..] and sorts it with a block
comparing two entries. Each Count is a slice and a count, three machine
words, so sorting moves small fixed-size values around. The words themselves stay
where they are in text.
The function returns an array whose elements refer into text, which it
never sees: it only has counts. The compiler treats the result as rooted
in its argument, counts, and so in everything that counts may refer
into. That is the "inferred roots" rule of Chapter 8, and it is what keeps
list from outliving text. It is also conservative, as the next section
shows.
sort is unstable, so the comparator breaks ties itself, alphabetically.
Without that, the order of a, eggs and farmer above, which all occur
three times, would depend on the hash table's layout.
#The output loop24.5
Each line of output is built in a fresh builder, with format_int padding
the count to five columns. It is tempting to make one builder before the
loop and clear it each time round, as you would in C. The compiler
refuses:
let list = ranked(counts);
var line: u8[>..] = [];
for c in list[..min(top, list.len)] {
line.clear();
format_int(line, c.n, 10, 5, ' ');
format(line, " ", c.word);
print(line);
}
error: cannot clear line: the for loop at words.goose:67 iterates list[..min(top, list.len)], whose elements may refer into line (§5.1)
The words in list came out of a dictionary, by way of a function. The
compiler knows they are views of byte arrays that belong to main, but no
longer which ones, so it assumes they might be views of any of them,
line included. Clearing line while the loop could still be reading
such a word would leave the word pointing at discarded bytes, so the
clear is rejected. In this program no word points into line, but the
check errs on the side of safety. The fix costs nothing to speak of: a
builder made inside the loop is a stack pointer moved forward, and the end
of each iteration moves it back.
#Errors and exit codes24.6
The program handles three failures: no file name, a count that is not a
positive number, and a file it cannot read. In each case it prints a
message and calls exit with a non-zero code, which ends the program
immediately. Since exit never returns, the compiler treats the rest of a
guard's else as unreachable, which is what lets the guard stand in
front of code that uses text or n.
A library function would not exit. It would return a bool or use
return … from, as the calculator in the next chapter does. In main, of
a program this small, exiting is the right thing.
#How fast is it?24.7
Built with goose build, on the machine this book was tested on, the
program counted a 51 MB file of 8 million words, about 150,000 of them
different, in under 0.4 seconds. Nearly all of that is the scanning and
the hashing. The allocations, of which there are only a handful in the
whole run, do not show up at all.
#Exercises24.8
-
Make the counter ignore words of one or two letters.
-
Print, after the table, the number of words that occur exactly once.
-
Change
count_wordsso that it takesu8[:]instead ofconst u8[:]. What else has to change? -
Make the program read from standard input when it is given no file name, using
read_stdinfromos.