Skip to content

← Collections, Text and First Iterators step 6 of 21

Medium Primitives

Zero-copy parsing: borrow, don't allocate

Parse an INI-style config into (section, key, value) triples — where all three are borrowed slices of the input, not new Strings.

pub fn parse_ini(src: &str) -> Vec<(&str, &str, &str)>

The rules:

  • [name] on a line by itself starts a section. The name is trimmed.
  • key = value produces a triple with the current section. Key and value are trimmed; only the first = splits, so url = http://x?a=b keeps its second = in the value.
  • Lines starting with # or ; are comments. Blank lines are skipped. A line with no = and no brackets is ignored.
  • Pairs appearing before any section header get the section "".

The hidden case parses a 10 000-line config and asserts at most 20 heap allocations for the whole parse. That budget covers the output Vec growing by doubling and nothing else. One to_string() per field would be 27 000.

The signature is the whole idea

Look at the return type again: Vec<(&str, &str, &str)>. Not String. Those &strs point into src — they are a pointer and a length, sixteen bytes, no ownership, no copy. Rust lets you hand out interior views of data you do not own and still guarantees at compile time that nobody keeps one past the lifetime of the thing it points into.

Where does the lifetime come from? Elision. The function has exactly one input reference, so every elided output lifetime is tied to it. Written out in full, the signature is:

pub fn parse_ini<'a>(src: &'a str) -> Vec<(&'a str, &'a str, &'a str)>

Try to write a version with no src parameter at all and you get E0106, missing lifetime specifier — there is nothing for the output to borrow from, and the compiler asks you where the data is supposed to come from.

The error the starter gives you

The starter’s algorithm is already correct. Exactly one decision in it is wrong: it stores the current section in an owned String. The compiler produces two errors from that one mistake:

error[E0506]: cannot assign to `section` because it is borrowed
error[E0515]: cannot return value referencing local variable `section`

E0515 is the important one, and it is worth reading slowly. section is a String owned by the function. Its buffer is freed when the function returns. Pushing section.as_str() into out puts a pointer to that buffer into the return value — so the moment the caller looks at the result, it is reading freed memory. In C this is the classic returning-a-pointer-to-a-local bug and it compiles fine. Here it is a compile error with your name on it.

E0506 falls out of the same thing: once out holds a borrow of section, you are no longer allowed to reassign section, because that would drop the buffer the borrow points at.

The fix is not a clone and not a lifetime annotation. It is to stop owning the section at all — src.lines() yields &str slices that live as long as src, and a section name is a slice of one of those lines. Change let mut section = String::new() to let mut section = "" and the two errors and the 10 000 allocations all disappear at once.

::: question If the starter had written out.push((section.clone(), ...)) with an owned String in the tuple, would it compile? You would have to change the return type to Vec<(String, &str, &str)> first, and then yes, it compiles and it is correct. That is the escape hatch people take, and it is why this problem measures allocations: the borrow checker will not stop you from writing the slow version, it only stops you from writing the unsound one.

Cloning is the right answer when the parsed data must outlive the input — if you are going to drop(src) and keep the config, you need owned strings. The decision is about lifetime, not about performance, and you should be able to say which one applies before you type either version. :::

Three std methods that do most of the work

  • split_once(pat) returns Option<(&str, &str)> — everything before the first match and everything after. This replaces the splitn(2, '=') two-step dance, it is clearer, and a surprising number of Rust programmers have never noticed it exists. clippy has manual_split_once for the old spelling.
  • strip_prefix / strip_suffix return Option<&str> with the affix removed, so [section] is one chained call instead of starts_with plus slicing plus an off-by-one.
  • trim returns a sub-slice. It allocates nothing; it just moves the pointer forward and shortens the length.

The measurement that kills a reflex

From a 300 000-field CSV benchmark, three ways to parse the same input:

split(',').map(to_string).collect::<Vec<String>>() then parse   6.07 ms
split(',').filter_map(|s| s.parse().ok())                       2.01 ms   3x
a hand-written byte-scanner state machine                       2.70 ms

The middle row is the idiomatic iterator version. The bottom row is the version written by someone who “knows iterators are abstraction overhead” and hand-rolled a scanner — and it is slower. The iterator chain compiles down to a tight loop with bounds checks elided; the hand-written machine has a match on state that the optimiser has a harder time with.

Keep that number. The reflex that manual equals fast is wrong often enough that you should measure before believing it, and the idiomatic version is also the one you can still read next year.

One related fact: str::split reports size_hint == (0, None), so collecting it never pre-sizes the destination. If you are collecting a split of something huge, with_capacity genuinely helps there — unlike collecting a slice iterator, which already knows its length.

Remember the grade is compile + tests + clippy -D warnings.

Loading visualization…