The Iterator trait

Most loops in Rust do not count an index from 0 to the length. They ask a value for its elements one at a time. An iterator in Rust is any type that implements the standard trait Iterator, whose one required method is fn next(&mut self) -> Option<Self::Item>: each call returns Some(item) with the next element, or None when there are no more. The type of the elements, Item, is part of the trait. (Traits in general are on Traits and Generics; Option is on Error Handling.)

fn main() {
    let readings = vec![350, 412, 120];
    let mut it = readings.iter();

    println!("{:?}", it.next());
    println!("{:?}", it.next());
    println!("{:?}", it.next());
    println!("{:?}", it.next());
}
Some(350)
Some(412)
Some(120)
None

The iterator itself keeps track of where it is, which is why next takes &mut self and why it has to be declared mut. You rarely call next yourself: a for loop does it for you, and stops at the first None. On top of next, the trait provides dozens of ready-made methods (map, filter, sum, max, zip, position …) that work on every iterator, including ones you write yourself.

iter, iter_mut and into_iter

A collection such as Vec can hand out its elements in three ways, and the difference is exactly the difference between borrowing and moving from Ownership and Borrowing and References:

Method Items for a Vec<u32> Afterward In a for loop
iter() &u32 (shared references) the vector is unchanged and still usable for x in &v
iter_mut() &mut u32 (mutable references) the vector is still yours, possibly changed for x in &mut v
into_iter() u32 (the values themselves) the vector is gone: it was moved into the iterator for x in v
fn main() {
    let mut readings = vec![350, 412, 120];

    // iter(): shared references, the vector stays usable
    let total: u32 = readings.iter().sum();
    println!("total {total} kWh");

    // iter_mut(): mutable references, change the elements in place
    for kwh in readings.iter_mut() {
        *kwh += 10;
    }
    println!("corrected {readings:?}");

    // into_iter(): the elements themselves; the vector is consumed
    let labels: Vec<String> = readings
        .into_iter()
        .map(|kwh| format!("{kwh} kWh"))
        .collect();
    println!("{labels:?}");
}
total 882 kWh
corrected [360, 422, 130]
["360 kWh", "422 kWh", "130 kWh"]

In the iter_mut loop, kwh is a &mut u32, so *kwh += 10 changes the element inside the vector. In the last statement the strings are built from the values themselves, and the vector cannot be used any more. That last point surprises nearly everyone once, because a plain for loop over a vector calls into_iter():

fn main() {
    let readings = vec![350, 412, 120];
    for kwh in readings {
        println!("{kwh}");
    }
    println!("{} readings", readings.len());
}
example.rs:6:29: error[E0382]: borrow of moved value: `readings`: value borrowed here after move

The full message explains what happened and what to write:

$ rustc --edition 2024 example.rs
error[E0382]: borrow of moved value: `readings`
 --> example.rs:6:29
  |
2 |     let readings = vec![350, 412, 120];
  |         -------- move occurs because `readings` has type `Vec<i32>`, which does not implement the `Copy` trait
3 |     for kwh in readings {
  |                -------- `readings` moved due to this implicit call to `.into_iter()`
...
6 |     println!("{} readings", readings.len());
  |                             ^^^^^^^^ value borrowed here after move
  |
note: the `for` loop is desugared into a call to `std::iter::IntoIterator::into_iter`, which takes ownership of the receiver `self`, which moves `readings`
 --> /rustc/…/library/core/src/iter/traits/collect.rs:312:17
help: consider iterating over a slice of the `Vec<i32>`'s content to avoid moving into the `for` loop
  |
3 |     for kwh in &readings {
  |                +

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0382`.

Adapters and consumers

Iterator methods come in two kinds. An iterator adapter takes an iterator and returns a new iterator that changes the stream on its way through: map transforms each item, filter drops some, take(n) stops after n, skip, enumerate, zip, rev, chain and flat_map do what their names say. A consumer (the Book calls them consuming adapters) pulls the items through and returns a single result: sum, count, max, min, any, all, find, position, fold, last and collect.

fn main() {
    let quarter_hours = [102, 98, 131, 260, 245, 110];   // Wh per quarter hour

    for (i, wh) in quarter_hours.iter().enumerate().skip(2).take(2) {
        println!("slot {i}: {wh} Wh");
    }

    let jumps: Vec<i32> = quarter_hours
        .windows(2)
        .map(|pair| pair[1] - pair[0])
        .collect();
    println!("changes: {jumps:?}");

    let peak = quarter_hours.iter().max();
    let busy = quarter_hours.iter().filter(|&&wh| wh > 200).count();
    let any_zero = quarter_hours.iter().any(|&wh| wh == 0);
    let total: i32 = quarter_hours.iter().sum();
    println!("peak {peak:?}, {busy} busy slots, any zero: {any_zero}, total {total} Wh");

    let newest_first: Vec<_> = quarter_hours.iter().rev().take(3).collect();
    println!("newest first: {newest_first:?}");
}
slot 2: 131 Wh
slot 3: 260 Wh
changes: [-4, 33, 129, -15, -135]
peak Some(260), 2 busy slots, any zero: false, total 946 Wh
newest first: [110, 245, 260]

enumerate pairs each item with its position, skip(2).take(2) selects slots 2 and 3, and windows(2) (a method of slices, not of iterators) yields overlapping pairs, so the closure can compute the change from one quarter hour to the next. max returns an Option because an empty sequence has no maximum. The pattern |&&wh| in the filter looks odd: iter() gives &i32 items and filter lends each one to its closure as another reference, so the pattern strips two & to get the number.

Adapters are lazy

An adapter does no work when you call it; it only builds a new iterator value that remembers what to do. Rust iterators are lazy: the closures inside map and filter run only when a consumer asks for the next value, one item at a time, all the way through the chain. An adapter chain without a consumer therefore does nothing at all, and the compiler says so:

fn main() {
    let readings = vec![350, 412, 120];
    readings.iter().map(|kwh| kwh * 2700);
}
example.rs:3:5: error: unused `Map` that must be used
$ rustc --edition 2024 example.rs
warning: unused `Map` that must be used
 --> example.rs:3:5
  |
3 |     readings.iter().map(|kwh| kwh * 2700);
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: iterators are lazy and do nothing unless consumed
  = note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
help: use `let _ = ...` to ignore the resulting value
  |
3 |     let _ = readings.iter().map(|kwh| kwh * 2700);
  |     +++++++

warning: 1 warning emitted

Without -D warnings (which this site's examples use, to turn warnings into errors) it is a warning, and the program runs without ever multiplying anything. The order in which work happens shows when the closures print what they do:

fn main() {
    let readings = vec![350, 0, 412, 120];

    let total: u32 = readings
        .iter()
        .map(|&kwh| {
            println!("  map {kwh}");
            kwh
        })
        .filter(|&kwh| {
            println!("  filter {kwh}");
            kwh > 0
        })
        .take(2)
        .sum();
    println!("total {total}");
}
  map 350
  filter 350
  map 0
  filter 0
  map 412
  filter 412
total 762

Each value travels through map and filter before the next one starts. The 0 is dropped by the filter, and once take(2) has passed on two values (350 and 412) it stops asking, so the fourth reading, 120, is never even read. That is what makes chains on very long or even endless sequences possible: (1..).map(…).take(5) is fine, because nothing beyond the fifth item is ever computed.

A lazy iterator chain. Five boxes from left to right: readings.iter(), map, filter, take(2) and sum(). An arrow along the top, from right to left, shows sum calling next on the stage before it, which calls next on the stage before that, back to the source. An arrow along the bottom, from left to right, shows each value traveling forward one at a time, until None ends the chain. next(): each stage asks the one before it for one item iter() map filter take(2) sum() Some(value): one item at a time travels forward None (or take reaching its limit) ends the chain
The consumer at the end drives the chain. Until sum() asks, no closure in map or filter runs.

collect: into a Vec, a String, a map or a Result

collect() turns an iterator into a collection, and the type you ask for decides which one: anything that implements the trait FromIterator. You state it on the variable (let v: Vec<u32> = …) or with the "turbofish" syntax on the call (.collect::<Vec<_>>(), where _ lets the compiler fill in the element type). One target is especially useful: collecting an iterator of Result values into a Result<Vec<T>, E> gives Ok with all values, or stops at the first Err and returns it.

use std::collections::BTreeMap;

fn parse_kwh(field: &str) -> Result<u32, String> {
    field
        .trim()
        .parse::<u32>()
        .map_err(|e| format!("{field:?}: {e}"))
}

fn main() {
    let good = ["350", "412", " 120"];
    let bad = ["350", "41O", "120"];

    // collect into a Result: Ok with every value, or the first error
    let all: Result<Vec<u32>, String> = good.into_iter().map(parse_kwh).collect();
    let broken: Result<Vec<u32>, String> = bad.into_iter().map(parse_kwh).collect();
    println!("{all:?}");
    println!("{broken:?}");

    // the same values collected into a String and into a sorted map
    let kwh = all.unwrap_or_default();
    let sum: String = kwh.iter().map(|k| k.to_string()).collect::<Vec<_>>().join(" + ");
    let by_meter: BTreeMap<&str, u32> = ["M-30007", "M-30004", "M-30012"]
        .into_iter()
        .zip(kwh)
        .collect();
    println!("{sum}");
    println!("{by_meter:?}");
}
Ok([350, 412, 120])
Err("\"41O\": invalid digit found in string")
350 + 412 + 120
{"M-30004": 412, "M-30007": 350, "M-30012": 120}

.map(parse_kwh) passes a named function where a closure is expected; any function with the right signature works. The BTreeMap comes out sorted by key, which keeps the output the same on every run (a HashMap would print in a different order each time; see Collections and Strings).

A telegram pipeline

Iterator chains fit text processing well. The program below works on the example P1 telegram from Smart Meters and the P1 Port. In this example, a developer on Kestrel's platform team is trying out the meter-data parts of the BUG-1060 story in Rust: counting the data lines, finding the current power, and computing the CRC-16/ARC checksum. The pilot dongle's firmware itself is the manufacturer's C code; this is a prototype for comparison.

const TELEGRAM: &str = concat!(
    "/XYZ5\\2DEMO-P1-METER\r\n",
    "\r\n",
    "1-3:0.2.8(50)\r\n",
    "0-0:1.0.0(261007193015S)\r\n",
    "0-0:96.1.1(44454D4F303030303030303030303137)\r\n",
    "1-0:1.8.1(000412.338*kWh)\r\n",
    "1-0:1.8.2(000467.905*kWh)\r\n",
    "1-0:2.8.1(000061.214*kWh)\r\n",
    "1-0:2.8.2(000598.770*kWh)\r\n",
    "0-0:96.14.0(0002)\r\n",
    "1-0:1.7.0(01.234*kW)\r\n",
    "1-0:2.7.0(00.000*kW)\r\n",
    "0-0:96.7.21(00002)\r\n",
    "0-0:96.7.9(00001)\r\n",
    "1-0:99.97.0(1)(0-0:96.7.19)(260812021511S)(0000000397*s)\r\n",
    "1-0:32.32.0(00000)\r\n",
    "1-0:32.36.0(00000)\r\n",
    "0-0:96.13.0()\r\n",
    "1-0:32.7.0(231.4*V)\r\n",
    "1-0:31.7.0(005*A)\r\n",
    "1-0:21.7.0(01.234*kW)\r\n",
    "1-0:22.7.0(00.000*kW)\r\n",
    "0-1:24.1.0(003)\r\n",
    "0-1:96.1.0(44454D4F474153303030303030303137)\r\n",
    "0-1:24.2.1(261007192500S)(00092.418*m3)\r\n",
    "!F1F4\r\n",
);

/// "1-0:1.7.0(01.234*kW)" -> Some(1234) watts; any other line -> None
fn power_watts(line: &str) -> Option<u32> {
    let value = line.strip_prefix("1-0:1.7.0(")?.strip_suffix("*kW)")?;
    let (kw, frac) = value.split_once('.')?;
    if frac.len() != 3 {
        return None;
    }
    Some(kw.parse::<u32>().ok()? * 1000 + frac.parse::<u32>().ok()?)
}

/// CRC-16/ARC as a fold: start at 0, combine every byte into the running value.
fn crc16_arc(data: &[u8]) -> u16 {
    data.iter().fold(0u16, |crc, &byte| {
        (0..8).fold(crc ^ u16::from(byte), |crc, _| {
            if crc & 1 != 0 { (crc >> 1) ^ 0xA001 } else { crc >> 1 }
        })
    })
}

fn main() {
    let lines = TELEGRAM.lines().count();
    let obis_lines = TELEGRAM.lines().filter(|line| line.contains(':')).count();
    println!("{lines} lines, {obis_lines} with an OBIS code");

    println!("power: {:?} W", TELEGRAM.lines().find_map(power_watts));

    println!("check value: {:04X}", crc16_arc(b"123456789"));
    let end = TELEGRAM.find('!').expect("telegram has an end marker") + 1;
    println!("telegram CRC: {:04X}", crc16_arc(&TELEGRAM.as_bytes()[..end]));
}
26 lines, 23 with an OBIS code
power: Some(1234) W
check value: BB3D
telegram CRC: F1F4

Three iterator ideas are at work here:

  • lines() on a string slice is an iterator over its lines (it removes the \r\n endings), and filter(…).count() counts the matching ones without building a list.
  • find_map(power_watts) calls the function on each line and stops at the first one that returns Some: the power value 01.234 kW becomes 1234 W, the same result as on the IoT page. The ? inside power_watts turns every line that does not match into None.
  • fold carries a running value through the sequence: it starts with 0u16 and combines each byte into it. The CRC is a fold inside a fold (eight bit steps per byte). It gives BB3D for the standard check string and F1F4 for the telegram, the value on its last line. A plain for loop, as in the C version on the IoT page, is just as good; pick whichever reads better to you.

u16::from(byte) widens the byte without a cast, and the shifts and XORs stay in u16: there is no integer promotion as in C to keep in mind.

Closures

A closure is an anonymous function you can store in a variable or pass to another function, and that can use variables from the scope where it is written. Its parameters go between vertical bars, and the body follows: |kwh| kwh * price / 100. When the body is more than one expression it gets braces. Parameter and return types are usually inferred from how the closure is used, though you may write them.

fn main() {
    let price = 2700; // ten-thousandths of a euro per kWh: €0.2700

    let cost_cents = |kwh: u64| kwh * price / 100;
    let label = |cents: u64| format!("€{}.{:02}", cents / 100, cents % 100);

    println!("{}", label(cost_cents(350)));
    println!("{}", label(cost_cents(412)));
}
€94.50
€111.24

cost_cents uses price, a local variable of main: the closure captures it. Money is kept as whole numbers (ten-thousandths of a euro per kWh, cents in the result) for the reasons on Variables, Types and Mutability; €0.2700 × 350 kWh is €94.50. An ordinary fn cannot do this, even when it is written inside main:

fn main() {
    let price = 2700;

    fn cost_cents(kwh: u64) -> u64 {
        kwh * price / 100
    }

    println!("{}", cost_cents(350));
}
example.rs:5:15: error[E0434]: can't capture dynamic environment in a fn item

The compiler's own help reads "use the || { ... } closure form instead". (The second error, about the unused price, follows from the first.) A nested fn is a normal function that happens to be declared in a block; it only sees its own parameters and global items.

How closures capture: Fn, FnMut and FnOnce

A closure captures each variable in the least demanding way its body allows. If it only reads a variable, it borrows it with a shared reference. If it changes it, it borrows it mutably. If it moves the value out (returns it, or passes it to something that takes ownership), it takes the value itself. From that the compiler gives the closure one or more of three traits, the Fn traits:

Trait The closure … Can be called
Fn only reads what it captured (or captures nothing) any number of times, even through a shared reference
FnMut changes what it captured any number of times, but one call at a time; the variable holding it must be mut
FnOnce moves something it captured out of itself once

Every closure implements FnOnce, closures that do not move anything out also implement FnMut, and those that do not change anything also implement Fn. So an Fn closure can be passed wherever an FnMut or FnOnce is asked for, but not the other way round.

fn main() {
    let meter = String::from("M-30004");

    // 1. reads meter: borrows it (Fn)
    let show = || println!("meter {meter}");
    show();
    show();
    println!("main can still read {meter}");

    // 2. changes count: borrows it mutably (FnMut)
    let mut count = 0;
    let mut tick = || count += 1;
    tick();
    tick();
    tick();
    println!("count {count}");

    // 3. gives meter away: takes ownership (FnOnce)
    let hand_over = || meter;
    let owned = hand_over();
    println!("owned {owned}");
}
meter M-30004
meter M-30004
main can still read M-30004
count 3
owned M-30004

show borrows meter only while it exists, so main can still read the string. While tick holds its mutable borrow of count, main cannot touch count; the borrow ends after the last call, so the println! is fine. hand_over returns meter, which moves the string out of the closure, so it is FnOnce. Calling it twice is refused:

fn main() {
    let meter = String::from("M-30004");
    let hand_over = || meter;
    let first = hand_over();
    let second = hand_over();
    println!("{first} {second}");
}
example.rs:5:18: error[E0382]: use of moved value: `hand_over`: value used here after move
$ rustc --edition 2024 example.rs
error[E0382]: use of moved value: `hand_over`
 --> example.rs:5:18
  |
4 |     let first = hand_over();
  |                 ----------- `hand_over` moved due to this call
5 |     let second = hand_over();
  |                  ^^^^^^^^^ value used here after move
  |
note: closure cannot be invoked more than once because it moves the variable `meter` out of its environment
 --> example.rs:3:24
  |
3 |     let hand_over = || meter;
  |                        ^^^^^
note: this value implements `FnOnce`, which causes it to be moved when called
 --> example.rs:4:17
  |
4 |     let first = hand_over();
  |                 ^^^^^^^^^

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0382`.

The second note explains the rule in plain words. In the same way, forgetting the mut on an FnMut closure is caught, with the fix in the help:

fn main() {
    let mut count = 0;
    let tick = || count += 1;
    tick();
    println!("{count}");
}
example.rs:4:5: error[E0596]: cannot borrow `tick` as mutable, as it is not declared as mutable: cannot borrow as mutable

Taking closures as parameters

Each closure has its own unique, unnamed type, so a function that accepts a closure names the trait instead: f: impl Fn(&str), or the equivalent generic form fn run<F: Fn(&str)>(f: F) (see Traits and Generics). Ask for the least you need: FnOnce if you call it once, FnMut if you call it repeatedly, Fn only if you need to call it through a shared reference. The caller then has the most freedom.

// Fn: may be called many times, only reads what it captured
fn for_each_home(homes: &[&str], f: impl Fn(&str)) {
    for home in homes {
        f(home);
    }
}

// FnMut: may be called many times and may change what it captured
fn repeat(times: u32, mut f: impl FnMut()) {
    for _ in 0..times {
        f();
    }
}

// FnOnce: called at most once, may consume what it captured
fn finish(f: impl FnOnce() -> String) -> String {
    f()
}

fn main() {
    let prefix = "home";
    for_each_home(&["h0017", "h0042"], |id| println!("{prefix} {id}"));

    let mut retries = 0;
    repeat(3, || retries += 1);
    println!("retries {retries}");

    let report = String::from("2 homes checked");
    println!("{}", finish(move || report));
}
home h0017
home h0042
retries 3
2 homes checked

The standard library is full of such parameters: map and filter take FnMut, Option::unwrap_or_else and thread::spawn take FnOnce, and sort_by_key and retain take FnMut:

struct Reading {
    meter: &'static str,
    kwh: u32,
}

fn main() {
    let mut readings = vec![
        Reading { meter: "M-30007", kwh: 412 },
        Reading { meter: "M-30004", kwh: 350 },
        Reading { meter: "M-30012", kwh: 0 },
    ];

    readings.retain(|r| r.kwh > 0);              // keep only real readings
    readings.sort_by_key(|r| r.meter);           // sort by meter number
    for r in &readings {
        println!("{} {} kWh", r.meter, r.kwh);
    }

    let largest = readings.iter().max_by_key(|r| r.kwh).map(|r| r.meter);
    println!("largest: {}", largest.unwrap_or("none"));

    let missing = readings.iter().find(|r| r.meter == "M-30099");
    let kwh = missing.map_or(0, |r| r.kwh);
    println!("M-30099: {kwh} kWh");
}
M-30004 350 kWh
M-30007 412 kWh
largest: M-30007
M-30099: 0 kWh

move closures

Borrowing works as long as the closure is used while the borrowed variables still exist. A closure that is returned from a function, stored for later or handed to another thread can outlive them, and then a borrow would dangle. A move closure, written with the keyword move before the bars, takes ownership of everything it captures instead: values are moved into it, and Copy values such as numbers are copied. Without it, the compiler refuses the code:

fn cost_per_kwh(price: u64) -> impl Fn(u64) -> u64 {
    |kwh| kwh * price / 100
}

fn main() {
    let fixed = cost_per_kwh(2700);
    println!("{}", fixed(350));
}
example.rs:2:5: error[E0373]: closure may outlive the current function, but it borrows `price`, which is owned by the current function: may outlive borrowed value `price`
$ rustc --edition 2024 example.rs
error[E0373]: closure may outlive the current function, but it borrows `price`, which is owned by the current function
 --> example.rs:2:5
  |
2 |     |kwh| kwh * price / 100
  |     ^^^^^       ----- `price` is borrowed here
  |     |
  |     may outlive borrowed value `price`
  |
note: closure is returned here
 --> example.rs:2:5
  |
2 |     |kwh| kwh * price / 100
  |     ^^^^^^^^^^^^^^^^^^^^^^^
help: to force the closure to take ownership of `price` (and any other referenced variables), use the `move` keyword
  |
2 |     move |kwh| kwh * price / 100
  |     ++++

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0373`.

price is a parameter: it lives on the stack frame of cost_per_kwh, which is gone once the function returns. With move, the closure keeps its own copy. Because each closure has its own type, storing different closures together, for example one per tariff in a map, needs a Box<dyn Fn(u64) -> u64>: a pointer to a closure on the heap plus the information to call it (see Smart Pointers and trait objects on Traits and Generics).

use std::collections::BTreeMap;

// Returns a closure that owns its own copy of `price`.
fn cost_per_kwh(price: u64) -> impl Fn(u64) -> u64 {
    move |kwh| kwh * price / 100
}

fn main() {
    let fixed = cost_per_kwh(2700);
    println!("350 kWh fixed: {} cents", fixed(350));

    // Different closures have different types; Box<dyn Fn> stores them side by side.
    let mut tariffs: BTreeMap<&str, Box<dyn Fn(u64) -> u64>> = BTreeMap::new();
    tariffs.insert("fixed", Box::new(cost_per_kwh(2700)));
    tariffs.insert("variable", Box::new(|kwh| kwh * 2600 / 100 + 500));

    for (name, cost) in &tariffs {
        println!("{name}: {} cents", cost(350));
    }
}
350 kWh fixed: 9450 cents
fixed: 9450 cents
variable: 9600 cents

The prices of €0.2700 (fixed) and €0.2600 (variable) per kWh are Kestrel's tariffs; the second closure's monthly fee of 500 cents is an example value. move does not change the closure's Fn trait: a move closure that only reads its own copy is still Fn. It only changes how the variables get into the closure.

Writing your own iterator

Any type becomes an iterator by implementing next and naming its item type. All the adapters and consumers then come for free. This one yields the 96 quarter hours of a day, the unit in which meter readings are usually stored:

/// The quarter hours of one day: "00:00", "00:15", … "23:45".
struct QuarterHours {
    next_minute: u32,
}

impl Iterator for QuarterHours {
    type Item = String;

    fn next(&mut self) -> Option<String> {
        if self.next_minute >= 24 * 60 {
            return None;
        }
        let m = self.next_minute;
        self.next_minute += 15;
        Some(format!("{:02}:{:02}", m / 60, m % 60))
    }
}

fn day() -> QuarterHours {
    QuarterHours { next_minute: 0 }
}

fn main() {
    println!("{}", day().count());
    println!("{:?}", day().take(3).collect::<Vec<_>>());
    println!("{:?}", day().last());
    println!("{:?}", day().position(|t| t == "23:00"));
}
96
["00:00", "00:15", "00:30"]
Some("23:45")
Some(92)

count, take, collect, last and position are provided methods of the Iterator trait; the struct only says how to get the next item. (A real day has 92 or 100 quarter hours on the days daylight saving time starts or ends. Time zones are left out here.) Each call to day() creates a fresh iterator; once an iterator returns None, it is used up, and you create a new one to start over.

Loops, iterators and speed

Readers coming from C often write for i in 0..v.len() and index with v[i]. That works, but every v[i] is bounds-checked at run time, and an off-by-one error becomes a panic. An iterator cannot run past the end, and Clippy, Rust's linter, points the index loop out:

$ clippy-driver --edition 2024 example.rs
warning: the loop variable `i` is only used to index `readings`
 --> example.rs:3:14
  |
3 |     for i in 0..readings.len() {
  |              ^^^^^^^^^^^^^^^^^
  |
  = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.99.0/index.html#needless_range_loop
  = note: `#[warn(clippy::needless_range_loop)]` on by default
help: consider using an iterator
  |
3 -     for i in 0..readings.len() {
3 +     for <item> in &readings {
  |

warning: 1 warning emitted

Iterators are not slower for being higher level. The Rust Book calls them one of Rust's zero-cost abstractions: in an optimized build, a chain of iter().filter().map().sum() is typically compiled to the same kind of loop you would write by hand, often without the bounds checks that indexing needs. Choose between a loop and a chain for readability. A long chain with several closures can be harder to read than a plain for loop with an if; side effects such as printing or pushing to another list usually read better in a loop. Clippy and its other lints are on Testing, Docs and Tooling.