Tech Wiki

TOPICSSERIES

[Rust Zero to Production 18] Choose Box, Rc, Arc, and Weak by the Ownership Graph

Memorizing a list of smart pointers does not tell you whether a design needs Box, Rc, or Arc. Draw the owners as nodes and every lifetime-preserving relationship as a strong edge instead. The choice becomes narrower once you know whether there is one owner, whether several parts must keep the same value alive, and whether those owners cross a thread boundary.

A smart pointer is not the default. Store T directly when one place owns the value. Borrow it as &T or &mut T when other code only needs temporary access. Add indirection or shared ownership only when the relationship requires it.

1. Ownership Graphs

For the Endpoint Monitor, work through the relationships in this order.

Relationship Prefer Meaning in the ownership graph
One place clearly owns the value T Direct ownership
Other code needs temporary access &T, &mut T A borrow that adds no ownership
One owner needs fixed-size indirection Box<T> One strong ownership edge
Several parts on one thread share ownership Rc<T> Several strong ownership edges
Several threads share ownership Arc<T> Several atomically counted strong ownership edges
Code must reach a value without keeping it alive std::rc::Weak<T>, std::sync::Weak<T> A non-owning edge that can expire

Two functions reading the same value do not automatically require Rc<T>. If both can borrow from one stable owner, &T states the relationship more accurately. Rc and Arc answer “who keeps this value alive?”, not “who may access it?”

2. Box and Indirection

Box<T> uniquely owns a heap allocation, although Box::new may perform no allocation when T is a zero-sized type. It has no reference count and does not create shared ownership. The Endpoint Monitor example uses it to give a recursive check plan a finite size.

#[derive(Debug, PartialEq, Eq)]
pub enum CheckPlan {
    Check(&'static str),
    Then {
        check: &'static str,
        next: Box<Self>,
    },
}

impl CheckPlan {
    #[must_use]
    pub const fn check(name: &'static str) -> Self {
        Self::Check(name)
    }

    #[must_use]
    pub fn then(name: &'static str, next: Self) -> Self {
        Self::Then {
            check: name,
            next: Box::new(next),
        }
    }

    #[must_use]
    pub fn checks(&self) -> Vec<&'static str> {
        let mut checks = Vec::new();
        let mut current = self;
        loop {
            match current {
                Self::Check(name) => {
                    checks.push(*name);
                    return checks;
                }
                Self::Then { check, next } => {
                    checks.push(*check);
                    current = next;
                }
            }
        }
    }
}

If CheckPlan::Then stored the next CheckPlan directly, calculating the type's size would never terminate. Box<Self> puts a pointer of known size inside the enum and reaches the next node indirectly. Every node still has one owner.

There is no such reason to box ordinary fields such as a u8 retry count or a u16 port. Box has no reference-count bookkeeping, but heap indirection remains. Require a concrete reason such as a recursive type, a trait object, or an API ownership boundary rather than boxing a value merely because it seems large.

3. Rc and Shared Ownership

Rc<T> fits when a dashboard and scheduler on the same thread must each own the same immutable policy, but the compiler cannot know which one will outlive the other. Rc::clone does not copy the policy into a new snapshot. It adds one strong owner of the same allocation.

    #[test]
    fn rc_shares_one_immutable_policy_until_the_last_owner_drops() {
        use std::rc::Rc;

        let policy = Rc::new(EndpointPolicy::new("office", 3));
        let dashboard = Rc::clone(&policy);

        assert!(Rc::ptr_eq(&policy, &dashboard));
        assert_eq!(dashboard.name(), "office");
        assert_eq!(Rc::strong_count(&policy), 2);

        drop(dashboard);
        assert_eq!(Rc::strong_count(&policy), 1);
    }

The inner value is dropped when the last strong Rc disappears. The test uses strong_count to make that behavior visible, not as a recommendation to query the count and decide application ownership at runtime. Types and field directions should show the ownership relationship.

Rc<T> does not normally provide mutable access to its inner value. Reference counting settles lifetime, not the rules for shared mutation. Article 19 covers shared mutation with RefCell, Mutex, and RwLock.

4. Arc Across Threads

Choose Arc<T> when owners of the same immutable policy must cross thread boundaries. Arc updates its strong reference count with atomic operations, which makes the count suitable for cross-thread shared ownership. If all owners stay on one thread, Rc expresses the requirement more directly.

The example's run_workers gives each worker one owned clone of the same Arc<EndpointPolicy>. It sorts the reports by endpoint name so the output order is deterministic.

pub fn run_workers(
    policy: &std::sync::Arc<EndpointPolicy>,
    endpoints: &[&str],
) -> Result<Vec<WorkerReport>, WorkerPanicked> {
    let workers: Vec<_> = endpoints
        .iter()
        .map(|endpoint| {
            let endpoint = (*endpoint).to_owned();
            let policy = std::sync::Arc::clone(policy);
            std::thread::spawn(move || WorkerReport {
                endpoint,
                policy: policy.name().to_owned(),
                retries: policy.retries(),
                worker_id: std::thread::current().id(),
            })
        })
        .collect();

    let mut reports = Vec::with_capacity(workers.len());
    for worker in workers {
        reports.push(worker.join().map_err(|_| WorkerPanicked)?);
    }
    reports.sort_unstable_by(|left, right| left.endpoint.cmp(&right.endpoint));
    Ok(reports)
}

Be precise about what Arc makes thread-safe: updates to the reference count. It does not add thread safety to the inner T. For Arc<T> to be Send and Sync, T must satisfy the corresponding bounds. Wrapping RefCell<T> in Arc therefore does not make RefCell<T> thread-safe.

Here, Send means that ownership may move between threads, and Sync means that references may be shared safely across threads. Article 21 covers the ownership move in thread::spawn and the two traits in detail. Shared mutation and synchronization belong to Article 19.

The example that moves an Rc into a worker thread does not compile. Run this command from the crate directory to check the intentionally failing source.

rustc --edition=2024 --error-format=short fixtures/rc_across_thread.rs --out-dir target/compile-fail

This is the complete diagnostic shown in short format.

fixtures/rc_across_thread.rs:12:19: error[E0277]: `Rc<EndpointPolicy>` cannot be sent between threads safely: `Rc<EndpointPolicy>` cannot be sent between threads safely
error: aborting due to 1 previous error

5. Weak and Non-Owning Edges

Some edges must reach a value without extending its lifetime. Use the Weak type from the same pointer family as the strong owner. Rc::downgrade creates std::rc::Weak<T>, while Arc::downgrade creates std::sync::Weak<T>. The two types are not interchangeable.

The example's observer does not own the local policy.

#[derive(Debug, Clone)]
pub struct PolicyObserver {
    policy: std::rc::Weak<EndpointPolicy>,
}

impl PolicyObserver {
    #[must_use]
    pub fn new(policy: &std::rc::Rc<EndpointPolicy>) -> Self {
        Self {
            policy: std::rc::Rc::downgrade(policy),
        }
    }

    #[must_use]
    pub fn upgrade(&self) -> Option<std::rc::Rc<EndpointPolicy>> {
        self.policy.upgrade()
    }
}

upgrade() returns Option<Rc<T>>. If a strong owner remains, Some gives the caller temporary strong ownership. If the inner value has already been dropped, the result is None. The upgrade() method on std::sync::Weak<T> follows the same rule and returns Option<Arc<T>>. Expiration is a normal state for the caller to handle, not a dangling pointer.

There are two lifetimes to distinguish. A Weak pointer does not keep the inner value alive. The backing allocation that holds metadata such as reference counts may remain while weak pointers exist. Saying that “Weak retains no memory” erases this distinction.

6. Cycles and Needless Indirection

Reference counting cleans up a value when its strong count reaches 0. In a closed cycle where every edge is strong, each count stays above 0 after the external owners disappear, so the values are not dropped. This is a memory leak, not a memory-safety violation. Both Rc and Arc can form strong cycles.

A graph does not leak merely because it is a graph. A DAG (directed acyclic graph) in which parents strongly own children and children do not own their parents is cleaned up with strong edges alone. When the parent-to-child link represents ownership and the child's parent link exists only for traversal, use Rc or Arc in the first direction and the matching Weak in the other. Do not mechanically weaken an edge that must keep its target alive according to the domain. Redesign the ownership graph instead.

The complete Rust 2024 example has no external dependencies. Run these commands from the project directory. The separate rustc command above is expected to be the only failure, with E0277.

cd examples/article-18-smart-pointers
cargo fmt --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet

All five Cargo commands should exit with status 0 on Rust 1.98.1 and Cargo 1.98.1. The suite contains four library unit tests, one compile-fail integration test, and one output integration test. The program output is:

box: dns -> tcp -> http
rc: policy=office owners=2
weak: expired after owner drop
arc: endpoint=admin.internal policy=production retries=4
arc: endpoint=api.internal policy=production retries=4

Use the narrowest relationship first. Start with direct T or a borrowed &T. Choose Box for a size boundary, Rc for same-thread shared ownership, and a qualifying Arc for shared ownership across threads. Add the matching Weak only where an edge must not extend the target's lifetime. This order removes needless heap allocation and reference counting along with cycles.

Full source code

The complete runnable source for this article is available in the Chapter 18 project on GitHub.

Sources


Leave a Reply

Your email address will not be published. Required fields are marked *

Tech Wiki

Built with WordPress · Learn in public.