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:
- What shape is the data? Sequence, map, set, priority queue, stream, scalar state, or one-time value.
- Who owns it? One owner, borrowed view, shared single-thread ownership, shared cross-thread ownership, or transferred messages.
- Who mutates it? One
&mutowner, aliased one-thread code, multiple threads, or nobody after construction. - What must callers observe? Order, duplicate policy, stable key lookup, path semantics, Unicode, deadlines, blocking, wakeups, or initialization failure.
- What resource is bounded? Capacity, retained memory, lock hold time, queued work, wait time, file descriptors, or initialization retries.
- 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
Barriercoordinates a fixed number of participants reaching a phase. A missing or failed participant can strand the rest; it is not a general completion service.Condvarwaits 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.Onceruns an initialization closure once but does not itself store a typed result; preferOnceLock<T>orLazyLock<T>when the value is the real artifact.- Thread
JoinHandlerepresents completion and possible panic payload. Dropping it detaches rather than joins; own a join policy. - Scoped threads can borrow from a scope, avoiding unnecessary
'staticownership 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
Instantfor elapsed process-local time andSystemTimefor system-clock timestamps; neither is a calendar library. - Treat partial reads, partial writes, buffering, flushing, and decoding as separate I/O contracts.
- Pick
MutexorRwLockonly 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::collectionsgives official collection selection and operation-cost guidance; individual type pages remain authoritative for exact behavior.Box,Rc,Arc, andPindefine ownership and pinning contracts.std::celldistinguishesCell,RefCell,OnceCell, and lazy single-thread cells;std::synccovers cross-thread locks, atomics, barriers, condition variables, and initialization primitives.std::sync::mpscdocuments the standard multi-producer, single-consumer channel, bounded synchronous variant, and disconnection.std::pathandstd::ffidefine platform path, OS-string, and C-string domains.Stringdocuments owned UTF-8 text.Instant,SystemTime, andDurationdefine the standard time model.std::iodefines 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.
Continue reading
Full table of contents