Skip to content

← Collections, Text and First Iterators step 20 of 21

Medium Primitives

Reusing buffers instead of allocating per iteration

Count how many input values have each decimal digit length, while keeping heap allocations within the exercise's budget.

pub fn digit_histogram(values: &[u64]) -> [u32; 20]

Slot i counts values with i + 1 digits. Zero has one digit, and u64::MAX has twenty. Count repeated values separately. An empty input returns twenty zeros. Inputs have at most u32::MAX elements, so every counter fits in the output type.

For [0, 5, 42, 999], the first three slots are [2, 1, 1] and the rest are zero. The fixed-size output array needs no heap allocation of its own.

What the allocation check measures

The large fixture supplies the 200,000 values from 0 through 199_999. It allows at most four allocator calls during digit_histogram: both allocation and reallocation calls count, while deallocation calls do not. Input construction and JSON output formatting are outside that interval.

Other fixtures check histogram values directly. The large fixture also expects allocs_over_budget to be zero, meaning no calls beyond the four allowed. This is an allocation check, not a required timing speedup.

The starter creates a new decimal String for each value. Its histogram logic is correct, but repeated string creation exceeds the budget on the exercise toolchain. The task is to avoid that repeated allocation while preserving the output.

Reuse one string

Create a buffer before the loop, then clear and refill it for each value. Twenty bytes are enough for any decimal u64:

use std::fmt::Write as _;

let mut buf = String::with_capacity(20);
for &v in values {
    buf.clear();
    write!(buf, "{v}").expect("formatting into a String");
    let digits = buf.len();
    // Increment the corresponding histogram slot.
}

clear() removes the contents while retaining capacity. Replacing buf with String::new() would drop the existing buffer instead. Without an initial capacity reservation, longer values can cause later growth.

Decimal output uses ASCII digits, so its byte length is also its digit count. That relationship does not hold for arbitrary Unicode text. write! uses the std::fmt::Write trait here. Writing to a String does not return a formatting error for these integers; allocation failure is not a recoverable fmt::Error.

This reuse pattern is also useful for a scratch vector or a line-reading buffer. Collection clear methods retain capacity, but removing owned elements may still drop resources inside them. See capacity and buffer reuse for more on length, capacity and shrinking.

Count digits without building text

Only the digit count is needed, so integer arithmetic is another valid approach:

let digits = if v == 0 { 1 } else { v.ilog10() as usize + 1 };

ilog10() gives the integer part of the base-ten logarithm. For example, 999.ilog10() is 2, which gives three digits after adding one. ilog10() panics on zero, so handle zero separately. Its checked variant returns None for zero.

Both buffer reuse and integer digit counting can meet the budget. Their relative speed depends on the workload, compiler and machine; allocation counts alone do not establish which is faster.

Finish using a slice before changing its buffer

This example fails with E0502 because last still refers to the previous contents when the next iteration mutates buf:

use std::fmt::Write as _;

let mut buf = String::new();
let mut last: &str = "";
for &v in values {
    buf.clear();
    write!(buf, "{v}").unwrap();
    println!("Previous: {last}");
    last = &buf;
}

Finish reading the old slice before clearing the buffer. If the old and new text must coexist, use separate storage, such as two reusable buffers or an owned copy. If only a count or comparison result is needed, retain that value instead of the slice.

Keep temporary owners alive

This also fails, with E0716:

let text = format!("{value}").as_str();
println!("{text}");

The temporary String is dropped at the first statement's end. Bind the owner before borrowing it:

let rendered = format!("{value}");
let text = rendered.as_str();
println!("{text}");

Temporary lifetime rules depend on syntax. A direct borrowing initializer such as let text = &format!("{value}")[..]; extends the temporary's lifetime in this case; the method-call initializer above does not.

Check your understanding When is buffer reuse worth adding? Show answer

Use it when profiling identifies repeated allocation as a cost, or when an explicit budget requires it. Reuse introduces mutable state and can retain a large capacity after a one-off large input. Keep the buffer's lifetime and ownership clear, and measure the complete operation when performance is the reason for the change.

Passing requires compilation, correct histograms, the large fixture's allocation budget and Clippy with warnings treated as errors.