Smart-pointers: C++ vs Rust

Hello,
I am trying to see the advantages of Rust smart-pointers ( Box<T>, Rc<T>, Arc<T>, and RefCell<T>) over C++ ones ( std::unique_ptr, std::shared_ptr, and std::weak_ptr) but it seems both more or less the same. Can someone enlighten me? Concrete examples are highly appreciated.

Thanks.

2 Likes

C++ doesn't have Rc, which is a non-threadsafe version of shared_ptr, possibly because it can't check you're not using it across threads like Rust can, and weak_ptr is Weak, but otherwise they're (near) identical, yes (I think I remember something about how the memory layout of Arc can't be identical to the equivalent C++? Been a while since I saw that)


I missed RefCell, which isn't a pointer - C++ also doesn't have this, but it's sort of halfway between a mutable field and a single threaded Mutex; the idea is you can be sure you're not accessing a value while it's being mutated using a runtime check, for use when Rust's lifetime system can't or is too complicated to use to enforce that.

It's close to Cell which doesn't need the runtime checking because you can only copy or move values in and out.

9 Likes

Addition to @simonbuchan 's reply:

Apart from layout difference of shared_ptr and Arc, there's some detailed difference like

  1. std::unique_ptr can have custom deleter as class template parameter but Rust Box does not have them.
  2. Box<T>, Rc<T>, Arc<T> can't be null while their counter part can be null.
11 Likes

Right! Though you can get back that null with eg Option<Box<T>>, which is still guaranteed to be the same size.

3 Likes

Yes, agree that never NULL is an advantage of Rust over C++ counter parts.

I thought Rc and shared_ptr are the same, they both provide shared ownership and reference counting. Of cause they are both not thread-safe. Is there any advantage of Rc over shared_ptr? A concrete example may be helpful. Thanks.

Semantically shared_ptr is equal to Arc (except the non-null part).

And shared_ptr<T> itself is thread-safe. It use atomic action for counters, while Rc use plain arith operations which is not thread safe (and much faster than Arc in highly contended cases).

The thread-unsafe part about shared_ptr<T> is that std::swap(ptr_a, ptr_b) is thread-unsafe. The equivlent Rust code would be std::mem::swap(&mut ptr_a, &mut ptr_b), which is "thread-unsafe" as well. But since you can't have multiple &mut ptr_a in different threads at the same time, so the unsafe case can never happen.

3 Likes

Another very subtle difference is that, Arc<T>/Rc<T> allow it handles the case when the internal reference count would exceed isize::MAX/usize::MAX, it will leak the object abort the program. IIRC in this case std::shared_ptr<T> is UB.

But since C++ does not have ZST, I doubt this will ever happen in real practice anyway.

3 Likes

Even in Rust it would be almost impossible to reach usize::MAX on normal hardware: if incrementing the counter inside the Arc/Rc takes a single CPU cycle, reaching usize::MAX on a CPU running at 10 GHz means something like 2^64 / 10^10 seconds, i.e., around 68 years.

2 Likes

If you use more reasonable values like 2^32 / 100e6 you will get one and a fifth of an hour on a 32-bit system. Or 2^16 / 100e3 is less than a second on some embedded targets. (I have used 100MHz and 100kHz to account for processor probably not actually incrementing in a single cycle.)

Note that Arc is not in std, it is in alloc::sync so it being available on 16-bit embedded target is reasonable assumption. And Arc is using AtomicUsize and not AtomicU64.

It probably still does not happen in practice because there is normally not enough memory to hold all those Arcs, but if one really wants to overflow one should be able to forget arcs, freeing stack without reducing counter, it just would be rather visible. I am not familiar enough with C++ to state whether there is similar option there and how visible it would be when reviewing code.

1 Like

Yep, on a 16/32 bit system a ZST will overflow. My comment was more on the line that given real-world constraints overflowing an Arc/Rc is really difficult, except if you really try.

Thank you, because I never thought about running Rust on 16 bit micros. Until now. :slight_smile:

1 Like

It's worth noting that Cell is specifically non-thread-safe (!Send) (!Sync)**. I had a run-in with someone who incorrectly impl Send on a Cell's container for a similar line of reasoning.

They alloc::sync::Arc* actually abort when the ref count goes beyond isize::MAX (~half usize::MAX), giving it a buffer between isize::MAX and usize::MAX to detect when ref count is rapidly increasing.

*see @ZyX-II reply below
**see @jameseb7 reply below

2 Likes

Sorry, what is ZST? Thanks.

1 Like

I have checked code and it looks like Arc<T> is aborting on exceeding isize::MAX and Rc<T> on exceeding usize::MAX, but in either case it is not leaking.

Note that this Arc does leak, but it is for linux kernel and its reference count is backed by kernel’s C code.

2 Likes

Zero-sized type.

2 Likes

Pedantic correction: Cell is Send (at least whenever what it contains is Send), because there is no problem sending an owned Cell across threads. What Cell isn't is Sync, because that is about references being safe to send and that wouldn't be safe since Cell allows mutation through a shared reference (and that mutation isn't guaranteed to be atomic).

3 Likes

Aye I must have mixed up linux kernel's Arc with the std one

I suspect cloning an Rc is typically faster than cloning an Arc even when there is no contention.

2 Likes

Ah right, the one I was thinking about was to Send and Arc containing Cell, which would require Cell: Sync, not (just) Send.

1 Like

You are not wrong, but the margin is considerably smaller (or nearly unobservable). In contrast, surprisingly, Arc::clone might become your bottleneck if the contention is high enough! So I just highlighted the significant case. :slight_smile:

1 Like