Skip to content

The Rust Engineering Handbook / Chapter 45

Ownership Choices in Function and Type Signatures

Choose borrowing, transfer, conversion, sharing, and return contracts that expose cost and preserve caller freedom.

Part VII made compatibility measurable: a release is safe only relative to promises that downstream programs can exercise. Public API design begins one level earlier, before a release has anything to classify. A Rust signature decides who stores a value, who may mutate it, how long access remains coupled, whether a call can allocate, and whether synchronization is part of the abstraction. Those decisions become the downstream program’s source code.

Consider a cache library reviewing five lookup signatures:

fn get(&self, id: RecordId) -> Option<Record>;
fn get(&self, id: &RecordId) -> Option<&Record>;
fn get(&self, id: &str) -> Option<&Record>;
fn get(&self, id: impl AsRef<str>) -> Option<&Record>;
fn get(&self, id: Arc<RecordId>) -> Option<Arc<Record>>;

All can be implemented. They are not interchangeable conveniences. The first consumes an identifier and returns an independent record, normally by moving or cloning. The second observes a typed identifier and lends a record whose lifetime is coupled to the cache. The third broadens the lookup boundary to text. The fourth adds a generic conversion-shaped surface and monomorphized call sites. The fifth requires shared ownership and atomic reference counting on both sides of the call.

The review question is therefore not “which signature is most idiomatic?” It is: what ownership event is intrinsic to this operation, and which costs and restrictions must every caller accept? Start with the narrowest truthful event. Add conversion, allocation, reference counting, or lifetime coupling only when it belongs to the contract.

Three questions organize the choices that follow: what authority enters the call, whether the result must outlive its source, and which cost or topology every caller is forced to accept. Borrowing, conversion traits, iterators, smart pointers, and callback bounds are answers to those questions, not independent style preferences.

Read a signature as an exchange of capabilities

A parameter grants the callee some combination of observation, mutation, retention, and destruction:

Form Callee can Caller gives up Typical hidden risk
T own, mutate, retain, move, or drop all further use unless T: Copy unnecessary transfer or caller clone
&T observe for the borrow mutation for that interval return lifetime coupled accidentally
&mut T observe and mutate exclusively all other access for that interval oversized transactional authority
impl Into<T> create an owned T input-dependent; conversion may allocate convenience hides ownership and code-size cost
Arc<T> retain shared ownership cheap handle clone, not unique control atomic counts and shared-lifetime architecture

Return types make the reverse exchange. T gives the caller an independent owner. &T lends a view and constrains what may happen to the owner while the view lives. impl Iterator<Item = &T> + '_ lends a lazy traversal with the same coupling. A smart pointer does not merely return “something heap allocated”; it declares an ownership topology.

A caller-to-callee decision map separates observation, exclusive mutation, ownership transfer, intentional conversion, and shared ownership, then distinguishes owned, borrowed, and lazy borrowed returns.

Use the map from top to bottom. First decide the capability the operation needs. Then decide whether the result must survive independently of the receiver. Do not begin with a favorite abstraction and retrofit a story around it.

Borrow inputs when the call only needs a view

If a function reads a value during the call and does not retain it, a shared borrow is the direct contract:

pub fn get(&self, id: &RecordId) -> Option<&Record> {
    self.records.get(id)
}

The caller may use the identifier again immediately. The implementation cannot store the borrowed identifier beyond the permitted lifetime. No clone or allocation is implied. The type RecordId, rather than str, also preserves a validation boundary: callers must present an identifier already known to satisfy the domain invariant.

For contiguous collections, prefer a view type when ownership of the container is irrelevant. &[Record] accepts a vector, array, boxed slice, or subslice without promising that the callee can resize it. &str accepts validated UTF-8 text without requiring a String. A signature that takes &Vec<Record> exposes an unnecessary container choice; one that takes &String does the same for text.

Borrowing is not synonymous with “fast.” Hashing a long borrowed key still costs time; traversing a borrowed slice still touches memory. It means the ownership exchange itself requires no transfer. Measure the rest separately.

Mutability should name the operation’s real authority

&mut T grants exclusive access to the entire T for the borrow. That is appropriate when mutation of that exact object is the purpose:

pub fn normalize(record: &mut Record) {
    // mutate record while the caller is excluded
}

It is too broad when the function needs only one capability. Passing &mut Cache to a callback that should update metrics also lets it remove records, alter policy, or hold an exclusive borrow longer than expected. A smaller handle, a method on the relevant component, or a returned change description can preserve reviewable authority.

Interior mutability changes when exclusivity is checked, not whether mutation exists. Replacing &mut State with &State containing a Mutex<StateInner> may enable shared access, but it also introduces blocking, poisoning policy, contention, and possible deadlock. The public signature should not disguise that architectural change as a harmless relaxation.

Consume when transfer is the operation

Insertion is a natural ownership transfer. The cache must retain the record after the call, so accepting Record states that fact:

pub fn insert(&mut self, record: Record) -> Option<Record> {
    self.records.insert(record.id().clone(), record)
}

The identifier clone inside this teaching implementation is a property of its chosen index layout, not a requirement imposed on the caller. A different internal representation could store the key once. The important public fact is that the cache becomes responsible for the record and returns ownership of any displaced value.

Taking &Record instead would force the implementation either to clone, to retain a borrow and infect the cache type with a lifetime, or to finish using the record before returning. If retention is the job, by-value input is honest. Let callers choose whether to move an existing value or explicitly clone it:

cache.insert(record);          // transfer
cache.insert(record.clone());  // caller chooses duplication

Do not put a hidden clone in the API merely to keep the caller’s variable usable. A clone may duplicate an allocation, a file descriptor wrapper, a reference count, a large buffer, or domain state with non-obvious semantics. The caller has the context needed to approve it.

Removal is the matching return-side transfer:

pub fn remove(&mut self, id: &RecordId) -> Option<Record>;

An owned return lets the removed record outlive the mutable borrow of the cache. Returning Option<&Record> from a destructive removal would be incoherent because the cache no longer stores the referent. Ownership models the state transition rather than decorating it.

Conversion traits are policy, not punctuation

impl Into<T> is valuable when accepting several representations and producing an owned T is explicitly part of the operation. A constructor that stores payload text can reasonably say:

pub fn new(id: RecordId, payload: impl Into<Arc<str>>) -> Record;

The call always constructs a record that retains its payload. A &'static str and an existing Arc<str> can enter cheaply; a String can be converted into the retained representation. The conversion is not incidental.

Using impl Into<String> on every textual parameter is less defensible. It can hide allocation behind a friendly call, make type inference ambiguous around .into(), enlarge generated code because the function is generic, and prevent a simple function-pointer type. A function that only inspects text should normally take &str. A function that stores text should take an owned representation or an intentional conversion boundary.

AsRef<T> is a borrowing conversion: it promises access to a reference, not ownership of a new T. It can be appropriate for path-like or byte-like inputs with a well-established ecosystem convention. On domain types it may erase useful validation. If both raw strings and RecordId are accepted, decide whether raw input is validated, normalized, or rejected, and make that behavior visible in the result type and documentation.

Generic convenience also affects compatibility. Changing fn open(path: &Path) to fn open(path: impl AsRef<Path>) appears more permissive, but it changes symbol generation, inference, trait selection, documentation, and how the function can be named as a value. Chapter 44’s witness-program discipline applies: broader syntax is not proof of an invisible change.

Return ownership according to independence

The fixture exposes both borrowed and cloned lookup:

pub fn get(&self, id: &RecordId) -> Option<&Record>;
pub fn get_cloned(&self, id: &RecordId) -> Option<Record>;

get is ideal for immediate inspection. It avoids duplicating the record, but the returned reference keeps the cache borrowed. A caller cannot mutate the cache while that reference remains in use. get_cloned pays the record’s clone cost and returns an independent value, which can cross a task boundary or survive subsequent cache mutation.

Keeping both methods is justified when both jobs are frequent and the cost difference matters. Naming the allocating or reference-counting variant makes review easier. A single method returning owned values may be simpler for a remote or synchronized cache whose internal guard cannot safely escape, but its documentation should say whether the value is copied, cloned, decoded, or fetched.

Borrowed returns need intentional lifetime coupling. This signature couples the output only to self through lifetime elision:

pub fn get(&self, id: &RecordId) -> Option<&Record>;

An overexplicit version can accidentally couple unrelated inputs:

pub fn get<'a>(&'a self, id: &'a RecordId) -> Option<&'a Record>;

Now the returned record appears limited by the shorter of the cache and identifier borrows even though it does not refer to the identifier. It may compile for simple calls and still reject useful code. Lifetimes express relationships, not endurance. Give two borrows the same named lifetime only when the returned value can actually derive from either one.

Conversely, a function choosing between two borrowed inputs must state the relationship because the result may come from either:

pub fn longer_payload<'a>(left: &'a Record, right: &'a Record) -> &'a str {
    if left.payload().len() >= right.payload().len() {
        left.payload()
    } else {
        right.payload()
    }
}

The shared lifetime is a conservative lower bound accepted for both alternatives. It is not a promise that the references live equally long.

Lazy returns preserve work and preserve coupling

An iterator return can avoid an intermediate allocation and allow the caller to stop early:

pub fn matching<'cache>(
    &'cache self,
    prefix: &'cache str,
) -> impl Iterator<Item = &'cache Record> + 'cache;

This iterator borrows both the cache and prefix because its filter closure uses both while iteration proceeds. The caller gains laziness and loses the ability to mutate the cache until the iterator is consumed or dropped. That is a real API contract, not compiler noise.

Possible alternatives serve different operations:

  • Vec<&Record> performs the search eagerly and allocates the vector, but releases the prefix borrow after return while still borrowing the cache through the records.
  • Vec<Record> allocates and clones, then releases all input borrows.
  • impl Iterator<Item = Record> may clone lazily, spreading cost and possible failure assumptions across iteration.
  • a callback such as visit_matching(&self, prefix, visitor) keeps the concrete traversal private but introduces reentrancy and control-flow questions.

Returning impl Iterator hides the concrete adaptor type and permits internal refactoring, but its declared item, lifetime, and auto-trait bounds remain promises. If documentation or bounds promise Send, a later implementation cannot silently return a thread-confined iterator. Hidden concrete types preserve representation freedom; they do not erase observable capabilities.

Smart pointers belong in signatures only when topology belongs there

Box<T>, Rc<T>, and Arc<T> answer different ownership questions:

  • Box<T> is one owner of an allocation and can stabilize size or address; passing it transfers that allocation and ownership.
  • Rc<T> is non-atomic shared ownership within a thread; it is neither Send nor Sync.
  • Arc<T> is atomically counted shared ownership that may cross threads when T permits; it does not make T internally synchronized or immutable.

Accepting Arc<Record> says the callee may retain a share of this exact allocation. If the callee merely reads during the call, &Record is less restrictive: callers with a stack value, Box, Rc, Arc, arena allocation, or embedded field can all lend a reference. Accept an Arc when identity and shared lifetime are the API—subscription registration, shared immutable configuration installed into workers, or a handle deliberately cloned into tasks—not as a reflex for avoiding lifetime design.

Likewise, returning Arc<Record> couples users to reference-counted identity. It may be the correct cache contract when eviction must not invalidate outstanding handles. It also means destruction occurs when the last unknown owner drops, memory can outlive cache accounting, and every clone/drop performs atomic count operations. An owned snapshot or callback-scoped borrow may give the service a more bounded resource model.

Callbacks expose capture, repetition, and reentrancy

The Fn trait family lets a signature state how the callee invokes captured state. The fixture uses:

pub fn retain(&mut self, mut keep: impl FnMut(&Record) -> bool);

FnMut is correct because the predicate may update captured counters and will be called repeatedly. FnOnce would allow consuming a capture but could only guarantee one invocation. Fn would forbid mutation through ordinary captured mutable access and promise concurrent-style repeatability more strongly than needed.

The signature still needs prose. Does the cache call the predicate once per record? In a stable order? While holding a lock? May the predicate panic? Can it call back into the cache? Which records remain if it does? The language trait bounds describe capture capability, not the entire execution contract.

Prefer a generic callback when static dispatch and inlining matter and the operation is scoped. Use &mut dyn FnMut(...) when code-size control, storage, or a non-generic boundary matters. Use Box<dyn FnMut(...) + Send + 'static> only when the callee truly retains the callback and may move it across threads. Adding 'static because a spawned task wants it transfers a scheduling choice into the public API and excludes borrowed captures.

Five signatures, one cache decision

Review the opening candidates against a concrete requirement: lookup must not allocate, keys must already be validated, the result is used while the cache is immutably borrowed, and callers may request an explicit independent snapshot.

  1. get(RecordId) -> Option<Record> transfers and probably clones for an observational operation. Reject it as the default.
  2. get(&RecordId) -> Option<&Record> matches the validated, allocation-free borrowed job. Choose it.
  3. get(&str) -> Option<&Record> avoids key construction but bypasses or duplicates the domain validation policy. Add a separately named raw lookup only if that is intentional.
  4. get(impl AsRef<str>) -> Option<&Record> adds generic surface without improving the core ownership event. Reject convenience without a demonstrated caller job.
  5. get(Arc<RecordId>) -> Option<Arc<Record>> imposes shared topology. Reserve it for a cache explicitly designed to issue eviction-independent handles.

Then add get_cloned(&RecordId) -> Option<Record> if independent snapshots are common enough to deserve a named operation. The pair makes cost and lifetime visible at the call site.

Repairs that compile can still weaken the boundary

Ownership diagnostics often appear while integrating an API, so a local repair can accidentally redesign the public contract. Review these moves as architecture changes:

Clone before every call. This ends a move error by manufacturing a second owner. It may be legitimate when two independent values are required, but repeated caller-side clones often reveal that the callee consumes a value it only observes. For Arc, cloning is smaller than cloning the record but still changes destruction timing and adds atomic count traffic. For a deep domain value, the cost may scale with input. Fix the signature when transfer is false; keep the explicit clone when duplication is the intended business event.

Add 'static to make a task spawn. A 'static bound means the supplied value contains no non-'static borrowed data; it does not mean the value is immortal. Adding the bound may exclude request-scoped references, arena-backed values, and test adapters because one implementation chose an unscoped task. Alternatives include scoped concurrency, performing the work before returning, transferring an owned projection, or accepting a service-owned handle at a higher-level registration boundary. If detachment and retention are the operation, 'static is truthful. If not, it leaks a scheduler choice.

Wrap the receiver in Arc<Mutex<_>>. This can satisfy shared ownership and mutation requirements, but it exports lock identity, blocking, poisoning, contention, and deadlock risk. It also makes every operation participate in one coarse synchronization domain. A command channel, split state, immutable snapshot, or method that completes mutation within the library may preserve a smaller contract. Use Arc<Mutex<T>> when callers genuinely coordinate through that shared lock, not as a universal answer to aliasing pressure.

Box a return to erase a difficult type. Box<dyn Iterator> or Box<dyn FnMut> can simplify a signature and permit heterogeneous implementations. It also implies allocation, indirection, object-safety constraints, and a chosen lifetime. impl Trait, an enum, or a named concrete adapter may better match the variation. Type erasure is a design tool, not an error-message silencer.

Return an owned collection to escape lifetime errors. Collecting into Vec<Record> can be exactly right when results must cross mutation, lock, thread, or process boundaries. It can also duplicate every record and discard laziness because the lifetime relationship was modeled incorrectly. First determine which input the output actually borrows and whether that coupling is acceptable. Choose eager ownership only after its independence is part of the reader-visible job.

Accept both owned and borrowed values through a clever generic. Traits such as Cow, Borrow, or a custom conversion abstraction can unify call syntax. They can also make allocation conditional, diagnostics harder, and implementation choices part of inference. Separate named operations are often clearer when borrowed observation and owned retention have different failure, latency, or storage behavior.

These repairs are not forbidden. Each can be the right public design. The failure is adopting one because it makes a local compiler error disappear while leaving callers unable to predict allocation, retention, blocking, or independence.

Carry ownership costs into operations and observability

The signature review should follow the value beyond compilation. If a cache returns owned snapshots, define whether clone latency is charged to lookup, whether large payloads are bounded, and which metric exposes bytes duplicated. If it returns Arc handles, distinguish logical eviction from physical memory reclamation and measure outstanding strong counts only when that observation is operationally meaningful. If a borrowed iterator holds a read guard internally, document that the apparent &self traversal can delay writers and ensure iterator lifetime appears in contention analysis.

Panic and cancellation behavior also follow ownership. A by-value input moved into an operation is dropped during unwinding unless transferred into durable state first. A callback that panics midway through retain may leave a valid but partially filtered collection; document the exception-safety guarantee. A future or task that owns a record may drop it on cancellation at any suspension point. A returned borrow cannot outlive its owner, but a returned shared handle can keep resources alive after the service considers the request complete.

Security review asks who can retain secrets and how many copies exist. Borrowed inspection can avoid another buffer, while to_owned, implicit conversion, logging, or callback capture can create copies with different zeroization and lifetime behavior. Do not claim that borrowing alone provides secrecy; the original owner, compiler optimizations, allocators, swap, and crash capture still matter. The useful API property is narrower: it does not require an additional owned copy for the operation.

Portability and FFI boundaries may require owned buffers, pinned storage, stable representation, or explicit length and encoding. Those constraints should appear in a boundary-specific type rather than distort every in-process caller. The same principle applies to async runtimes and distributed serialization: adapt at the architectural edge, and keep the core signature’s ownership event as narrow as its actual work.

Review what the signature makes inevitable

Before accepting a public function, ask:

  • Does the callee observe, mutate, retain, or destroy each input?
  • Is a slice or view more truthful than a concrete owning container?
  • Does by-value input express necessary transfer, or force a caller clone?
  • Can conversion allocate or fail, and is that obvious in the type and name?
  • Is a returned borrow coupled only to data it can actually reference?
  • Does a lazy return hold borrows or locks longer than an eager result?
  • Does a smart pointer expose intentional identity and lifetime topology?
  • Do callback bounds match capture, repetition, retention, thread movement, panic, and reentrancy behavior?
  • Are cloning, reference counting, indirection, hashing, and allocation costs inspectable?
  • What downstream witness would fail if this ownership contract changed?

Exercise: adjudicate a lookup surface

Design a cache API for records that are expensive to clone, indexed by validated RecordId, and occasionally needed by work that outlives the cache lock. Evaluate the five opening signatures plus any separately named alternative you propose.

Deliver:

  1. a caller/callee ownership map for identifier, cache, and record;
  2. the zero-allocation immediate-inspection signature;
  3. the independent-result signature and its exact cloning, allocation, or reference-counting cost;
  4. lifetime relationships for every borrowed return and lazy iterator;
  5. the eviction behavior of outstanding results;
  6. whether callbacks may be retained, repeated, reentrant, panicking, or thread-moving;
  7. witness programs for stack-owned, Box-owned, Arc-owned, and borrowed caller data;
  8. a compatibility note for changing from the chosen v1 signature to each rejected alternative.

The review succeeds when another engineer can predict ownership, aliasing, allocation, and retention from the API and its concise behavioral documentation—without reading the implementation.

Make the cheapest truthful contract the default

Borrow for scoped observation, use &mut for deliberate exclusive mutation, consume when retention or destruction is the operation, and return ownership when the result must be independent. Treat Into, AsRef, smart pointers, iterators, and callback bounds as architectural vocabulary, not ergonomic decoration. A good signature preserves caller choice while exposing unavoidable cost.

Those choices define more than individual functions. Once downstream users can construct, match, implement, or send the surrounding public types, the library has committed evolution space. The next design problem is deciding which parts of that type and trait surface must remain stable and which internals must remain replaceable.

Sources and verification notes