---
title: Collections
source: 'https://academia.sh/en/courses/rust-fundamentals/collections'
course: 'Rust Fundamentals'
language: en
updated: '2026-09-15T04:58:45+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - "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'
---

# 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.

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**.

```rust
// 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.

```rust
// 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>`.

```rust
// 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.

```rust
// 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?
