Skip to content
academia.sh

Lesson 10 / 12

Collections

The same 'mutate' work is measured across three separate collections but falls into one of two classes: the growable array's push gives () (statement), the hash map's insert gives the old value as an Option (expression). The limiting measurement shows that old data is silently lost if this return value is not captured.

Published Updated

In this lesson

  • Shows that the growable array's push method is a statement and its pop method is an expression
  • Measures why a string's byte count and character count diverge
  • Explains that the hash map's insert method is an expression and returns the old value
  • Shows with an example when an uncaptured return value leads to silent data loss
Contents

The Pattern Matching lesson measured how a single Shape value is taken apart; every lesson so far has treated a single value bound to a single variable. But a program’s data is almost never made up of a single value: many fields are held at once, and they grow and shrink. This lesson lifts that limit and carries the same question — is a piece of writing an expression or a statement — to three structures that hold many values at once: the growable array, the string, the hash map.

What these three structures share is that they carry methods that mutate themselves: adding an item, removing an item. The question this lesson measures is this: does the same work — “add something” — fall into three separate classes across the three structures, or do they all behave the same? The core of the shared definition already gave a short answer to this question: HashSet::insert both mutated the set and returned a bool, while Vec::push gave only (). This lesson widens that one comparison to three structures and four methods.

Growable Array: Adding Is a Statement, Removing Is an Expression

Vec is Rust’s growable array type; unlike a fixed-length array, its element count can grow and shrink at runtime. It has two basic mutating methods: adding an item to the end and removing an item from the end. The return types of these two methods are entirely different from each other — and this difference is the first instance of a pattern that will keep recurring throughout the rest of this lesson: a mutating method’s class is read not from what the method is for, but from which type it returns.

// growable_array.rs — push gives () (statement), pop gives Option (expression)
fn main() {
    let mut areas: Vec<f64> = Vec::new();

    let push_result = areas.push(12.57); // Circle(2.0) area
    println!("{:<24}: {:?}", "push return value", push_result);

    areas.push(12.0); // Rectangle(3.0, 4.0) area
    areas.push(6.0); // Triangle(3.0, 4.0, 5.0) area
    println!("{:<24}: {:?}", "areas (after push)", areas);

    let popped = areas.pop();
    println!("{:<24}: {:?}", "pop return value", popped);
    println!("{:<24}: {:?}", "areas (after pop)", areas);
}
push return value       : ()
areas (after push)      : [12.57, 12.0, 6.0]
pop return value        : Some(6.0)
areas (after pop)       : [12.57, 12.0]

The return value of the append-to-end method is () — push return value: () in the output shows this. This is a statement: areas changes, but no value representing that change comes back. The remove-from-end method, however, returns Option<f64> — Some(6.0), because the list’s last item was 6.0. This is an expression: the value returned is the removed item itself, and it can be used in the next computation.

DM12. — The claim that “two methods in the same ‘work’ class can fall into separate classes” is measured here with the push/pop pair: both mutate the areas list, both concern a single item, but one gives () and the other gives Option<f64>. This measurement mirrors the same contrast established in the shared definition’s core (expression.rs) between HashSet::insert and Vec::push: class is read not from whether the work is “adding” or “removing,” but from the type it returns.

Why does this difference exist? Adding to the end always succeeds — whether the list is empty or full does not change the result, there is always room to add. This is why there is no informative result to return. Removing from the end, however, may not always succeed: if the list is empty, there is no item to remove. Option carries these two possibilities — an item was there (Some) or it was not (None) — in a single return type. push being “always successful” exempts it from returning a result, while pop’s “being able to fail” forces it to return one.

The line areas (after push): [12.57, 12.0, 6.0] in the output shows the whole list; the square brackets and comma-separated numbers are Vec‘s own debug formatting. After three push calls the list has three elements; after the pop call the last element (6.0) is removed, leaving two elements ([12.57, 12.0]). How many elements the list can hold has no upper bound written anywhere in the source — Vec grows as needed, and the implementation details of that growth are not this lesson’s subject.

A String Is a Sequence of Bytes

In Rust, a string is a sequence not of characters but of bytes; text is held in UTF-8 encoding, and not every character occupies the same number of bytes. A letter in the ASCII range occupies a single byte, while letters specific to Turkish such as ç, ş, ğ, ı, ö, ü occupy two bytes.

// string_byte_char.rs — a string is a sequence of bytes, character count is measured separately
fn main() {
    let shape_name = "üçgen";
    println!("{:<24}: {}", "byte count (len)", shape_name.len());
    println!("{:<24}: {}", "character count (chars)", shape_name.chars().count());
}
byte count (len)        : 7
character count (chars) : 5

"üçgen" (the Turkish word for triangle) is made up of five characters: ü, ç, g, e, n. But the len() method (which gives the byte length) gives the result 7, while chars().count() (which gives the character count) gives 5. The two-byte gap comes from each of the letters ü and ç occupying two bytes in UTF-8; g, e, n are each one byte. This is another instance of the distinction seen in earlier lessons between “a number that can only be found by reading the source” and “a number that can only be found by running it”: how many characters the writing "üçgen" has can be counted by reading the source, but how many bytes it takes up, and which letters are multi-byte, cannot be worked out from the source without knowing that.

DM13. — The claim that “byte count and character count diverge” is measured here by two different methods giving different numbers on the same string: len() gives 7, chars().count() gives 5. The difference is exactly 2, and this is explained by each of the string’s two multi-byte characters (ü, ç) contributing one extra byte to the byte length — of the five characters, two occupy two bytes each and three occupy one byte each: 2×2 + 3×1 = 7.

This distinction is a reminder that a string is also a collection: the len() method carries the same name as Vec‘s len() and does the same thing — it gives the count of items the string carries. The difference is that a string’s items are bytes, not characters. The chars() method takes on the job of splitting these bytes into actual characters and counting them; this requires a second pass, because how many bytes make up one character can change from character to character.

This is why a string cannot be accessed directly by position (like shape_name[0]), the way a growable array can. In a growable array, position 0 is always one whole item; in a string, byte position 0 can land in the middle of a multi-byte character — "üçgen"’s first byte is not the whole ü character, it is only the first of its two bytes. Position-based access not being defined for strings removes this half-a-character problem at the source: code that wants to take individual characters from a string is forced to go through chars(), and that pass follows character boundaries, not byte boundaries.

Hash Map: Adding Is an Expression

A hash map (HashMap) is a collection that holds key-value pairs. Where a growable array accesses an item by position (0, 1, 2, …), a hash map accesses an item by a chosen key — a number, a string, any comparable type. This lesson uses &str keys: a small table where each shape name corresponds to its own area value. The insertion method has a return type different from the growable array’s: not (), but Option<V>.

// hash_map_insert.rs — insert returns Option (expression), not printed directly
use std::collections::HashMap;

fn main() {
    let mut area_map: HashMap<&str, f64> = HashMap::new();

    let first_result = area_map.insert("circle", 12.57);
    println!("{:<24}: {:?}", "insert return (first)", first_result);

    area_map.insert("rectangle", 12.0);
    area_map.insert("triangle", 6.0);

    let mut keys: Vec<&&str> = area_map.keys().collect();
    keys.sort();
    for key in keys {
        println!("{:<24}: {}", format!("area_map[{}]", key), area_map[key]);
    }
}
insert return (first)   : None
area_map[circle]        : 12.57
area_map[rectangle]     : 12
area_map[triangle]      : 6

When the key "circle" is added for the first time, the returned value is None — this key had no previous value. This differs from Vec::push giving (): insert is an expression that carries information about what happened before the addition. If a second addition were made to the same key, the returned value would not be None, but a Some carrying the previous value — this is the subject of the limiting measurement.

The hash map itself is not printed directly; the keys are first gathered into a list and sorted, then accessed one by one. A hash structure’s traversal order can change from run to run; since every number printed in this lesson has to give the same output five years from now as well, the hash map’s raw traversal order is never relied upon anywhere. The full reason for this rule, and its counterpart — why the ordered map’s traversal is the same on every run — is the subject of the next lesson.

This step does not block the access area_map[key] itself: if a key is already known, reaching its value with that key is direct and deterministic — what changes is only which order the map’s entirety gets walked in. This is why the loop above first gets all the keys with keys(), sorts them, and only then goes back to the map while walking the sorted list to read each key’s value — never looking at the map’s own internal order at any point.

DM14. — The claim that “insert is an expression and carries the previous value” is measured here by the first insertion call’s return value: it can be bound to a variable named first_result and printed with {:?} — this binding would be meaningless for a (), since () carries no information at all. The None output directly says that the key “circle” never existed in this map before.

The double & sign in the line keys: Vec<&&str> used to sort the keys is not arbitrary: the area_map.keys() method already gives a reference to each key in the map (&str), and gathering these references into a list makes the list a reference to a reference (&&str). This detail lets the map itself be read without ever being copied; the full meaning of copying and references is the subject of the Ownership and the Memory Model course — here it is only visible which type the sorting step operates on.

Limiting Measurement: an Uncaptured Return Value Is Silently Lost

If the old value insert returns is not bound to a variable, the program keeps running — Rust does not count this as an error. But in that case, the old value is lost without ever appearing anywhere.

// insert_data_loss.rs — insert's old value is silently dropped if not captured
use std::collections::HashMap;

fn main() {
    let mut stock: HashMap<&str, i32> = HashMap::new();
    stock.insert("circle", 5);
    stock.insert("circle", 9); // return value not captured -- old value 5 is lost here
    println!("{:<22}: {}", "uncaptured (current)", stock["circle"]);

    let old_value = stock.insert("circle", 3);
    println!("{:<22}: {:?}", "captured (old)", old_value);
    println!("{:<22}: {}", "captured (current)", stock["circle"]);
}
uncaptured (current)  : 9
captured (old)        : Some(9)
captured (current)    : 3

In the second line, 9 is written to the key "circle"; this line’s return value (the Some(5) carrying 5) is not bound to any variable, and the result is computed and immediately discarded. The rest of the program does not notice this loss: the query stock["circle"] gives back 9 directly, because the map has already been updated. Only the old value is lost — the number 5 can never be retrieved anywhere in the program after this line.

The third call repeats the same operation, but this time the return value is bound to the name old_value. The result is Some(9) — this says that stock["circle"] was 9 before this call. The same line both updates the map and returns the value it had before the update; the only difference is whether the caller captures this value or not.

DM15. — The claim that “an uncaptured return value leads to data loss” is measured here by running two different call forms side by side: the second insert call (the one writing 9) discards its return value, and 5 is directly observed to never appear in the program again; the third call (the one writing 3) uses the same structure but binds its return value to old_value and gets it back as Some(9). Both calls invoke the same method with the same signature; the only difference is whether the caller binds the result to a name.

This is a direct consequence of insert being an expression: an expression’s value is always computed, but using that value is not mandatory. This question never even arises for a statement — push has no value to return in the first place, and nothing to lose either. The risk of loss appears exactly in methods that carry a return value; this risk can remain invisible to someone reading the code without knowing what that return value means.

If the "circle" key in the stock example carried a count meaning — say the number of parts in stock — the second call silently replacing (not adding to) the 5 with 9 would be a genuine data loss: the previous count would be lost without ever being summed anywhere. In this example insert was deliberately used in an “overwrite” sense; code that wants to preserve the previous value for the purpose of summing it is obligated to capture the return value. In the source, these two intentions — overwriting and summing — are expressed with the same insert call; what decides the difference between them is what the caller does with the return value.

This lesson measured the mutating methods of three separate collections and showed that all of them fall into one of the same two patterns: a method either gives () (a statement, like push) or gives back information about the result of the operation (an expression, like pop and insert). The shared trait of these two collections — the growable array and the hash map — is that even though both answer to “at which position” or “under which key” questions, they do not give the same guarantee about which order the answer will come in. In a growable array, order is the insertion order in the source, and it never changes. In a hash map, as this lesson measured, the raw traversal order was never relied on at all — keys were always sorted first.

Summary

  • The growable array’s append method (push) returns () and is a statement; its remove-from-end method (pop) returns Option and is an expression.
  • A string’s byte length and its character count diverge for text containing multi-byte characters.
  • The hash map’s insertion method (insert) is an expression; its return value carries either the key’s previous value (Some) or the fact that the key is new (None).
  • A hash map is not printed directly; its keys are gathered and sorted first.
  • If insert’s return value is not bound to a variable, the old value it overwrites is silently lost; this risk exists only for methods that carry a return value.

A collection itself can also be passed through the same expression/statement/item axis: Vec::new() and HashMap::new() calls are expressions, because they produce an empty collection value; binding this value to a name with let is again a binding statement. This lesson’s real contribution comes not from the collection itself, but from the methods that mutate it — because mutation is the one moment a single call can carry both a side effect (the collection’s contents change) and an optional result (the return value) at once.

Next Step

This lesson measured that a hash map is not printed directly, that its keys are sorted first — but why is this necessary, and why does its counterpart, the ordered structure, not need this sorting step at all? The next lesson opens this question: what is the guarantee gap between an ordered map and an ordered set versus a hash map and a hash set, and what condition does this guarantee ask to be met?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close