Skip to content

The Rust Engineering Handbook

Appendix F — Standard-Library Type Selection Guide

Choose Rust standard-library collections, pointers, cells, locks, channels, text and path types, clocks, I/O adapters, synchronization, and initialization primitives by operational contract.

After a relay service replaced a linked work list, shared mutable registry, unbounded channel, wall-clock timeout, and global Mutex<Option<Config>>, its core state looked less “architectural” and more explicit:

struct RelayState {
    config_path: PathBuf,
    routes: BTreeMap<String, PathBuf>,
    counters: HashMap<String, u64>,
    pending: VecDeque<String>,
    shared_snapshot: Arc<[String]>,
    mutable_health: Arc<Mutex<HashMap<String, bool>>>,
    epoch: Instant,
}

static PROCESS_LABEL: OnceLock<String> = OnceLock::new();

This is not a prescription to copy those types. It is the result of answering separate questions. Routes need deterministic key order in snapshots; counters need general keyed lookup; pending jobs use both ends; an immutable snapshot is shared across threads; health data has genuine concurrent mutation; elapsed time must not depend on wall-clock adjustment; configuration paths are operating-system paths; the process label initializes once.

Choose a standard-library type by the behavior it must make visible: shape, ownership, mutation, concurrency, blocking, ordering, encoding, timing, buffering, and initialization. Combining those axes into “what container should I use?” is how incidental mechanisms become permanent API contracts.

A selection sequence

Use these questions in order:

  1. What shape is the data? Sequence, map, set, priority queue, stream, scalar state, or one-time value.
  2. Who owns it? One owner, borrowed view, shared single-thread ownership, shared cross-thread ownership, or transferred messages.
  3. Who mutates it? One &mut owner, aliased one-thread code, multiple threads, or nobody after construction.
  4. What must callers observe? Order, duplicate policy, stable key lookup, path semantics, Unicode, deadlines, blocking, wakeups, or initialization failure.
  5. What resource is bounded? Capacity, retained memory, lock hold time, queued work, wait time, file descriptors, or initialization retries.
  6. What failure policy applies? Result, disconnection, timeout, poison recovery, borrow panic, initialization panic, partial I/O, or process-level invariant.

The first type that compiles may answer only the first question.

Before accepting the choice, mark whether the concrete type crosses a public boundary. A private HashMap can later become a BTreeMap after measurement; a public field, parameter, return type, or serialized order may turn that choice into a compatibility promise. Selection and API exposure are separate decisions.

Collections: start with access shape

Need Start with Contract to record Common wrong reason to switch
contiguous ordered sequence, stack, batch Vec<T> append/removal pattern, capacity, index stability “linked lists insert faster” without considering traversal and allocator cost
queue or deque VecDeque<T> which ends are used, capacity, wraparound visibility using Vec::remove(0) repeatedly
keyed lookup, order not promised HashMap<K, V> hash/equality laws, duplicate replacement, randomized iteration relying on observed iteration order
ordered keys or range queries BTreeMap<K, V> sort order, range semantics, comparison cost choosing it only for deterministic tests when sorting output would suffice
membership without values HashSet<T> / BTreeSet<T> same hash/order distinction as maps storing meaningless () map values
repeatedly take greatest item BinaryHeap<T> priority order and tie policy expecting arbitrary removal or sorted iteration
node-stable splicing under a measured need LinkedList<T> why contiguous or deque storage fails assuming theoretical insertion dominates end-to-end cost

Vec and HashMap are good defaults for broad classes of work, not universal answers. Capacity growth is amortized and can occasionally move elements. If a pointer or index escapes, specify whether reallocation, removal, and reordering invalidate it. Use reserve when a credible bound is known; do not reserve a worst-case capacity that turns a rare burst into permanently retained memory.

Map entry APIs can combine lookup and insertion without a second search. They do not solve contention if the map sits under a lock. For externally visible output, either choose an ordered collection because range/order is part of the domain, or sort at the boundary. Do not accidentally make randomized hash iteration a protocol.

Collection complexity is a model, not the whole workload. Hashing, comparison, allocation, cache locality, destructor cost, and adversarial inputs can dominate. Measure with domain-shaped keys and sizes.

Smart pointers: ownership before indirection

Type Ownership contract Thread property Principal cost or risk
Box<T> one owner of heap-allocated T; useful for recursive size or stable indirection needs follows T allocation and pointer indirection
Rc<T> multiple owners in one thread not Send or Sync non-atomic reference counts; cycles can leak
Arc<T> multiple owners across threads when bounds permit atomic counts; contained access still follows T atomic refcount traffic; cycles can leak
Weak<T> non-owning link to Rc/Arc allocation follows pointer family upgrade can fail after owners disappear
Pin<P> restricts moves through a pointer under pinning contract follows P and target subtle projection and unsafe obligations; not “heap stability” by itself

Use Box because ownership and representation require indirection, not because a value is “large” in the abstract. Use Rc or Arc only when multiple owners are real. A borrowed &T is clearer when one owner outlives all users. Arc does not make T internally synchronized, does not prevent logical races, and does not define shutdown. Arc<Mutex<T>> is an explicit shared-mutable design with atomic ownership and lock behavior; it should be the conclusion of those requirements, not a compiler-error reflex.

Cycles of strong Rc or Arc references do not deallocate automatically. Model back-references with Weak, identifiers, or an owning arena when a graph has a clear root.

Cells and locks: where is aliasing allowed?

Need Type Enforcement and failure
replace/copy small state through shared reference, one thread Cell<T> no references to inner value; move/copy operations
dynamically borrow arbitrary state, one thread RefCell<T> borrow rules checked at runtime; conflicting borrow calls panic, try_borrow returns errors
initialize once, one thread OnceCell<T> / LazyCell<T> not Sync; later reads need no dynamic borrow guard
exclusive mutable state across threads Mutex<T> blocking acquisition; one guard; poisoning policy must be handled
concurrent readers or one writer RwLock<T> blocking; OS scheduling/fairness policy is not a portable guarantee
initialize once across threads OnceLock<T> / LazyLock<T> synchronization occurs around initialization
integer/boolean/pointer state with atomic protocol atomic types explicit memory ordering; no automatic multi-field invariant

Interior mutability moves enforcement; it does not remove the aliasing contract. RefCell makes invalid overlapping borrows a runtime event. Prefer try_borrow at boundaries where a panic is unacceptable, but also ask whether the dynamic ownership graph is needlessly tangled.

Choose Mutex before RwLock unless a reader/writer workload and critical-section measurements justify the extra state and policy. A read-heavy label is insufficient: short mutex holds can outperform a more complex lock, writers can suffer, and the operating system may decide scheduling. Never hold a blocking standard-library lock across an .await in async code; use task-owned state, message passing, or an async-aware primitive from the selected runtime, with cancellation and fairness reviewed separately.

Keep lock scope visible and avoid calling unknown code, I/O, logging pipelines, or callbacks while holding a guard. Poisoning reports that a thread panicked while holding a lock; it is not memory unsafety and not proof the data is unusable. Decide whether to propagate, recover after validating an invariant, or terminate the operation.

Atomics are appropriate when the invariant itself fits an atomic protocol. A pair of atomic counters does not create an atomic snapshot of the pair. Memory ordering is part of the algorithm’s proof, not a tuning knob.

Channels: transfer, capacity, and shutdown

The standard std::sync::mpsc module provides multi-producer, single-consumer channels. channel() is unbounded from the application’s perspective: sends do not block for capacity, so producers can outrun the receiver and grow retained memory. sync_channel(n) supplies a bounded buffer and makes senders wait when it is full; capacity zero creates a rendezvous.

Requirement Standard choice Review obligation
one receiver, load proven bounded elsewhere mpsc::channel state the external bound and disconnection behavior
one receiver, explicit backpressure mpsc::sync_channel(n) derive n; account for blocked senders and shutdown
many consumers another design or ecosystem channel pin behavior/version; do not pretend mpsc::Receiver is cloneable
async task communication runtime-aware bounded channel cancellation, wakeups, capacity, and runtime coupling
shared latest state rather than every event lock, atomic, watch-style external primitive define whether intermediate updates may be dropped

Channel disconnection is part of normal lifecycle: receive operations fail after all senders are dropped; send fails after the receiver is dropped. Preserve those results instead of unwrapping them in long-lived workers. Define who owns the last sender, how shutdown wakes blocked operations, whether queued work drains, and whether a timeout abandons or retains a message.

Message passing can clarify ownership, but it does not guarantee bounded work, fairness, delivery, or processing success. Queue capacity should follow a latency and memory budget, not a round number.

Strings and paths: retain the domain encoding

Domain Borrowed Owned Key point
valid UTF-8 text str String byte indexing is not character indexing; slicing must respect UTF-8 boundaries
operating-system strings OsStr OsString may not be valid Unicode; use lossy display only as an explicit presentation policy
filesystem paths Path PathBuf wraps OS-string semantics and platform path rules
C-compatible nul-terminated strings CStr CString interior nul and FFI ownership/lifetime require explicit handling
raw bytes [u8] Vec<u8> / Box<[u8]> do not claim text encoding until validated

Do not accept String for a path because the command line happened to contain UTF-8 in testing. Accept &Path or impl AsRef<Path> for observation; retain PathBuf when ownership is needed. Path comparison is not a universal filesystem identity check: case sensitivity, links, normalization, current directory, mount behavior, and races are platform/filesystem concerns.

Convert bytes to str or String with explicit UTF-8 validation. from_utf8_lossy is valuable for display or diagnostics when replacement is acceptable; it must not quietly define a protocol parser or authentication identifier.

Time: elapsed duration is not civil time

Use Instant for elapsed time, deadlines within a running process, and timeout arithmetic. It is monotonic in the sense documented for the platform abstraction, but it cannot be serialized as a durable cross-process timestamp and its range/platform behavior still deserves checked arithmetic.

Use SystemTime for timestamps related to the system clock, filesystem metadata, or conversion relative to UNIX_EPOCH. The system clock can be adjusted; duration_since can therefore fail when the ordering assumption is false. Neither type supplies calendar dates, time zones, or recurrence rules.

Duration is a nonnegative span. Use checked, saturating, or explicitly handled arithmetic when values can come from configuration or external input. A timeout needs a lifecycle policy: which operation observes it, what is cancelled, who cleans up, and whether retry consumes the remaining budget.

I/O: traits, buffering, and partial progress

Read and Write are synchronous byte-stream traits. A call may process fewer bytes than requested without reaching a terminal error. Use read_exact only when the protocol requires the entire buffer and its EOF behavior is acceptable; use write_all when partial writes must be retried until completion or error. Preserve Result and define retry behavior for Interrupted where the chosen helper does not already do so.

Add BufReader<R> when many small reads from R would otherwise cross an expensive boundary. Add BufWriter<W> for many small writes, but handle flush: errors can occur after earlier write calls appeared successful, and dropping a buffered writer is not a robust acknowledgement strategy. Do not stack redundant buffering without measuring.

BufRead adds access to an internal buffer and delimiter-oriented operations. Cursor<T> gives in-memory data position-based Read/Write behavior useful in tests and format code. Seek exposes repositioning only where the underlying object supports it; a stream socket is not a file.

Separate bytes, decoding, framing, and domain parsing. This keeps partial I/O and invalid input from becoming the same error and lets limits apply before untrusted length fields allocate memory.

Synchronization beyond locks

  • Barrier coordinates a fixed number of participants reaching a phase. A missing or failed participant can strand the rest; it is not a general completion service.
  • Condvar waits for a predicate associated with a mutex. Always re-check the predicate in a loop because wakeups and competing consumers do not promise that the condition remains true.
  • Once runs an initialization closure once but does not itself store a typed result; prefer OnceLock<T> or LazyLock<T> when the value is the real artifact.
  • Thread JoinHandle represents completion and possible panic payload. Dropping it detaches rather than joins; own a join policy.
  • Scoped threads can borrow from a scope, avoiding unnecessary 'static ownership when all work joins before scope exit.

Synchronization chooses coordination, not liveness. Every blocking design needs a wait-for graph, shutdown path, panic policy, and observability story appropriate to its risk.

Initialization: eager, caller-selected, or lazy

Requirement Type or pattern Failure/panic question
ordinary construction with inputs constructor returning T or Result<T, E> can callers retry or provide context?
set once from one of several callers, one thread OnceCell<T> what happens to the losing value?
same, cross-thread OnceLock<T> can initialization block or re-enter?
fixed lazy initializer, one thread LazyCell<T> if it panics, what should later access do?
fixed lazy initializer, cross-thread/global LazyLock<T> is hidden first-access latency acceptable?

Prefer eager initialization when failure is expected and belongs at startup or request construction. Lazy initialization moves cost and failure to first access, which may occur on a latency-sensitive path or under partial service load. One-time cells are safe state machines, not configuration reload mechanisms.

Avoid Mutex<Option<T>> when the only transition is uninitialized to initialized and readers never replace the value. Conversely, do not force OnceLock onto data that must be refreshed. For fallible initialization, check the exact stable surface of the baseline and MSRV; some get_or_try_init variants have different stabilization status across cell types and releases. A plain constructor plus explicit set can state the policy without relying on a newer API.

Integrated design exercise

Design the state of a local build coordinator with these constraints:

  • paths may contain non-Unicode platform data;
  • ten thousand jobs arrive in bursts, with one consumer and a 64 MiB queue budget;
  • active jobs are keyed by opaque IDs; completed jobs must be emitted in finish-time order;
  • a configuration snapshot is immutable and shared with worker threads;
  • metrics are updated concurrently but exported as a consistent snapshot;
  • initialization can fail because the configuration file is invalid;
  • deadlines are process-local, while audit records need system timestamps;
  • logs are written in small fragments to a file.

Produce a type ledger. For each field, name the data shape, owner, mutation authority, ordering/encoding/timing contract, bound, and failure path. Compare at least these alternatives: Vec versus VecDeque, HashMap versus BTreeMap, Arc<T> versus Arc<Mutex<T>>, unbounded versus bounded channel, Instant versus SystemTime, eager Result construction versus OnceLock, and raw file writes versus BufWriter<File>. Calculate queue capacity from measured average and worst-case message sizes; do not assume the number of messages alone bounds memory.

Then run two failure rehearsals: the consumer exits while producers are blocked, and buffered log flushing fails during shutdown. The design is complete only when ownership makes both outcomes observable and cleanup has a named owner.

The companion crate at examples/rust-engineering-handbook/appendices/conversion-type-selection-lab/ exercises ordered and hashed maps, a deque, Arc, Mutex, a bounded standard channel, PathBuf, Instant, and OnceLock in a dependency-free Rust 2024 package.

Field card

  • Choose collection by access shape and observable order; state duplicate, capacity, and invalidation policy.
  • Choose Box, Rc, Arc, borrowing, or message transfer from ownership—not from a desire to silence lifetime errors.
  • Use cells for deliberate one-thread interior mutability; locks or atomics require a cross-thread protocol.
  • Bound queues from latency and memory budgets and specify disconnection and shutdown.
  • Keep Unicode text, OS strings, paths, C strings, and raw bytes as distinct domains.
  • Use Instant for elapsed process-local time and SystemTime for system-clock timestamps; neither is a calendar library.
  • Treat partial reads, partial writes, buffering, flushing, and decoding as separate I/O contracts.
  • Pick Mutex or RwLock only with measured critical sections and a lock-order/liveness plan.
  • Prefer typed one-time initialization primitives over Mutex<Option<T>> when the state transition is truly one-way.
  • Keep initialization failure at an explicit boundary; hidden first-access work changes latency and incident behavior.

Appendix G turns from type selection to numeric and bitwise correctness, where width, overflow, representation, and conversion policy must be equally explicit.

Sources and version notes

  • std::collections gives official collection selection and operation-cost guidance; individual type pages remain authoritative for exact behavior.
  • Box, Rc, Arc, and Pin define ownership and pinning contracts.
  • std::cell distinguishes Cell, RefCell, OnceCell, and lazy single-thread cells; std::sync covers cross-thread locks, atomics, barriers, condition variables, and initialization primitives.
  • std::sync::mpsc documents the standard multi-producer, single-consumer channel, bounded synchronous variant, and disconnection.
  • std::path and std::ffi define platform path, OS-string, and C-string domains. String documents owned UTF-8 text.
  • Instant, SystemTime, and Duration define the standard time model.
  • std::io defines synchronous byte I/O, partial progress, buffering, seeking, and helper methods.
  • Examples target Rust 2024. The fixture declares Rust 1.85 as its MSRV. Newer initialization helpers and other stabilization-sensitive APIs must be checked against both the publication stable toolchain and MSRV before independent acceptance.