Crabrix blog
Rust HashMap: count with entry and produce stable reports
Build a reliable Rust word counter with HashMap entry. Handle missing keys, choose borrowed or owned keys, and sort output for repeatable reports.
· 9 min read
Crabrix blog
Build a reliable Rust word counter with HashMap entry. Handle missing keys, choose borrowed or owned keys, and sort output for repeatable reports.
· 9 min read
A word counter looks like a tiny exercise, yet it contains decisions that appear in inventories, event summaries, logs and analytics. What makes two keys equal? Who owns their text? What happens when a key is absent? Does the final report need a predictable order? Answering these questions gives the collection a clear job. Merely choosing a map because it offers fast lookup does not define the behavior of the report.
We will count the tokens in a short string and produce a stable alphabetical result. The scope is intentionally precise: split on whitespace and preserve each token’s exact spelling. Punctuation and letter case remain significant. That makes the example useful without pretending it implements a complete natural-language tokenizer. Every program uses the standard library and can be run in Crabrix; no external hashing or text-processing package is required.

Our input is rust crab rust. It contains three tokens but only two distinct keys. The expected counts are rust twice and crab once. Naming the input in the program matters: a copied loop referring to an undeclared text variable is an incomplete example, even if the loop itself illustrates a useful technique. A first runnable sample should establish every binding its explanation depends on.
The program uses borrowed string slices as keys because the source text remains alive for the entire calculation. The count type is usize, which is convenient for this small in-memory token count. A production counter with an externally specified numeric range should choose and validate that range deliberately. Assertions check both known keys and a missing key, so absence remains an ordinary result rather than a panic hidden in indexing syntax.
use std::collections::HashMap;
fn main() {
let text = "rust crab rust";
let mut counts: HashMap<&str, usize> = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word).or_insert(0) += 1;
}
assert_eq!(counts.get("rust"), Some(&2));
assert_eq!(counts.get("crab"), Some(&1));
assert_eq!(counts.get("missing"), None);
assert_eq!(counts.len(), 2);
println!("Two distinct keys, three tokens");
}entry gives access to the slot associated with a key. Calling or_insert(0) returns a mutable reference to the existing value, or inserts zero and returns a mutable reference to that new value. The leading dereference in *counts.entry(word).or_insert(0) += 1 changes the stored count. Without understanding that reference, the expression can look like punctuation to memorize rather than an operation you can explain.
The sequence is easy to trace. The first rust token inserts zero and increments it to one. Crab gets its own zero and becomes one. The second rust token finds the existing count and changes it to two. There is no need to remove the key, rebuild its pair and insert it again. The infographic shows those exact updates, followed by a separate sort for presentation. Each step is independently checkable against the original three-token input.

Calling get returns an option containing a borrowed value. That is suitable for a lookup where the key may not exist. Indexing syntax can be concise when the key is guaranteed to exist, but it panics if the key is absent. A user-provided search key normally does not establish that guarantee. Match the option, provide a justified default, or propagate absence to a caller that knows what it means.
For a count report, a missing token can often be displayed as zero. For an inventory, an absent product record may require an “unknown product” result instead. Those are different contracts even if both use a map. Avoid turning every missing key into a default object just to simplify a type. An automatically created object can obscure the difference between “known with zero activity” and “never existed,” which matters for diagnostics, accounting and user interfaces.
It is common to explain entry by contrasting it with a separate get followed by insert. The entry form is usually clearer for an update, but be precise about the alternative. Modern Rust permits some nested method calls through its borrowing rules, including cases where a receiver’s mutable borrow is activated after an argument has been evaluated. You should not claim that every expression combining insert and get is rejected.
The reliable explanation focuses on the work and meaning: entry describes access to one keyed slot and supports updating it directly. Separate lookup and insertion can repeat lookup work and require more careful handling of ownership. If you are teaching a compiler error, compile the exact example and inspect the diagnostic. A plausible story about borrows is weaker evidence than the compiler’s actual behavior, and an incorrect story can leave learners distrusting an otherwise useful API.
With HashMap<&str, usize>, the map owns its reference values and counters, but the referenced token bytes remain in the original text. This avoids allocating a separate string for each key. It also means the map cannot outlive the source buffer. That relationship is usually convenient for a report computed and displayed while the input is available. It becomes a design constraint if the report is stored independently after the input is discarded.
Use owned keys when independent storage is required. A HashMap<String, usize> can retain keys assembled from temporary input, at the cost of allocating owned strings where needed. Decide at the storage boundary instead of sprinkling clones through a loop until the compiler stops objecting. Ownership is part of the API contract: a function returning borrowed keys is also promising a relationship with its input’s lifetime. An owned result makes a different, sometimes more useful promise.
Our whitespace tokenizer counts Rust, rust and rust, separately. If a product wants them treated as one word, it needs explicit normalization and tokenization rules. Lowercasing, punctuation removal and Unicode normalization each change information and have language-specific consequences. Do not silently add a few string operations and claim the result counts every human language correctly. Begin with the actual format your application accepts.
Normalization can also change ownership. Converting each token into a newly allocated lowercase string produces owned text, so borrowing keys directly from the unchanged original buffer is no longer the same operation. You may normalize into a separate owned representation or use owned map keys. Include this cost and lifetime in the design. A good report documents its token definition so a surprising count can be investigated as a rule or an implementation issue, rather than dismissed as random map behavior.
HashMap iteration order is arbitrary. It can vary between maps or runs, so tests and user-visible reports should not assume insertion or alphabetical order. Collecting borrowed entries into a vector and sorting them by key gives the output a deliberate stable order without changing the map’s lookup role. This collection serves a real purpose: the caller needs an ordered sequence for presentation.
The next program asserts the ordered rows independently of how the map happens to be laid out. A BTreeMap is another option when sorted key traversal is a core requirement throughout the program. Choose between the structures based on the operations and scale you need, not a blanket claim that one is universally faster. A map for accumulation plus one explicit sort is often easy to understand for a small final report.
use std::collections::HashMap;
fn main() {
let counts = HashMap::from([("rust", 2_usize), ("crab", 1)]);
let mut rows: Vec<_> = counts.iter().map(|(key, count)| (*key, *count)).collect();
rows.sort_unstable_by_key(|(key, _)| *key);
assert_eq!(rows, vec![("crab", 1), ("rust", 2)]);
for (key, count) in rows { println!("{key}: {count}"); }
}Hash-based lookup is commonly discussed in expected constant-time terms, but hashing a long string still requires work related to its contents, and collisions or allocation can affect actual cost. A report that sorts distinct keys also has sorting work. Keep the quantities separate: total input size, number of tokens, number of distinct keys and key lengths. Saying “the algorithm is O(1)” because it uses a HashMap ignores most of the program.
Memory also follows distinct keys and retained storage. A borrowed-key map can keep the original input alive; an owned-key map stores separate text. Reserving capacity may help a known workload, but guessing an enormous capacity can waste memory. Measure a real workload before publishing speed or storage claims. This article supplies exact count results, not invented benchmarks. The design can be justified by ownership and required operations even before performance becomes a demonstrated problem.
An initial count of zero is cheap, but other entry defaults may require allocating a buffer or constructing a larger record. An expression passed to or_insert is evaluated before the call, even when the key already has a value. or_insert_with accepts a closure whose construction work is performed when a vacant entry actually needs the default.
This distinction follows the same principle as choosing a consumer for an iterator: describe the work the operation really needs. Do not use a lazy closure merely to make a trivial constant look sophisticated. Use it when the default has meaningful cost or must be created at that specific boundary, and keep any side effects understandable.
A map associates one value with each key. Calling insert with an existing key replaces its previous value and returns the old value as an option. That is useful for deliberate replacement, but it is not the same rule as accumulating another occurrence. A counter that inserts one for every token will finish with one for each distinct key, silently losing repetition.
For an importer that must reject duplicate identifiers, examine whether the key already exists or use the entry variants to express occupied and vacant behavior. For an updater that permits replacement, decide whether callers need the previous value for history or rollback. The data structure supports these different policies; the application must select one and test duplicate input explicitly.
Check empty input, one token, repeated tokens, several distinct keys and a missing lookup. If case or punctuation is significant, include fixtures that prove it. Then test the sorted rows as data. A separate formatting assertion can verify headings, separators and final newlines when those are part of the product’s output contract. This separation makes failures easier to diagnose than one large text snapshot containing unrelated details.
When adapting the counter to inventories or events, define overflow behavior for counters and validate keys at the input boundary. Preserve errors instead of silently discarding malformed records. Finally, explain the result in terms a caller can rely on: how keys are identified, who owns them, how absence is handled and how output is ordered. Those four answers make a small HashMap loop into a dependable component rather than a copied idiom whose assumptions nobody has examined.