Что такое Rc<T>
2025-02-27 15:17![[personal profile]](https://www.dreamwidth.org/img/silk/identity/user.png)
Продолжим разбираться с умными указателями в Rust. Глянем на тип Rc<T>. Название расшифровывается как Reference Counted, что намекает. Спросим grok.com. Выходит почти тот же shared_ptr, с двумя отличиями: он не может оказаться NULL, и счетчик простой, а не std::atomic. Сгенерённый ним исходник смотрите в файле: rc.cpp
Question: How Rc<T> would look if implemented in C++?
Let’s re-implement Rust’s Rc<T> in C++ - a fascinating exercise since Rc<T> introduces reference-counted shared ownership, contrasting with the unique ownership of Box<T>. In Rust, Rc<T> (short for "Reference Counted") allows multiple owners of a heap-allocated T, tracking the number of references via a counter. When the last Rc is dropped, the T is deallocated. This is akin to C++’s std::shared_ptr<T>, but we’ll craft a custom version to mirror Rust’s Rc<T> behavior, focusing on its single-threaded nature (unlike Arc<T> for multi-threading).
Question: How Rc<T> would look if implemented in C++?
Let’s re-implement Rust’s Rc<T> in C++ - a fascinating exercise since Rc<T> introduces reference-counted shared ownership, contrasting with the unique ownership of Box<T>. In Rust, Rc<T> (short for "Reference Counted") allows multiple owners of a heap-allocated T, tracking the number of references via a counter. When the last Rc is dropped, the T is deallocated. This is akin to C++’s std::shared_ptr<T>, but we’ll craft a custom version to mirror Rust’s Rc<T> behavior, focusing on its single-threaded nature (unlike Arc<T> for multi-threading).
What Rc<T> Does in Rust
- Allocation: Places a T on the heap with a reference count.
- Shared Ownership: Multiple Rc<T> instances can point to the same T, incrementing the count.
- Cloning: Rc::clone creates a new reference, bumping the count.
- Deallocation: When the last Rc is dropped (count hits 0), T is destroyed and memory freed.
- No Mutability: Rc<T> alone doesn’t allow mutation (use RefCell<T> or Cell<T> for that).
- Non-Atomic: Single-threaded, unlike Arc<T>.
C++ Implementation
We’ll create an Rc<T> class in C++ that:- Uses a control block to store the reference count and T together.
- Supports copying (to increment the count) and RAII cleanup.
- Mimics Rust’s immutability by providing const access (mutation would need a separate mechanism).
- Avoids atomic operations, matching Rc’s single-threaded design.
This Rc<T> captures Rust’s essence - shared, immutable, single-threaded ownership.#include <cassert>
#include <utility>
#include <iostream>
// Control block to hold the reference count and the value
template <typename T>
struct RcControlBlock {
size_t count;
T value;
template <typename... Args>
RcControlBlock(Args&&... args) : count(1), value(std::forward<Args>(args)...) {}
};
template <typename T>
class Rc {
private:
RcControlBlock<T>* block;
// Private constructor from raw block (for internal use)
explicit Rc(RcControlBlock<T>* b) : block(b) {}
void increment() {
if (block) {
++block->count;
}
}
void decrement() {
if (block && --block->count == 0) {
delete block;
block = nullptr;
}
}
public:
// Constructor: Allocate and construct T
template <typename... Args>
explicit Rc(Args&&... args) : block(new RcControlBlock<T>(std::forward<Args>(args)...)) {}
// Destructor: Decrease count, cleanup if last reference
~Rc() {
decrement();
}
// Copy constructor: Share ownership
Rc(const Rc& other) : block(other.block) {
increment();
}
// Copy assignment: Share ownership
Rc& operator=(const Rc& other) {
if (this != &other) {
decrement(); // Drop old reference
block = other.block; // Take new reference
increment();
}
return *this;
}
// Move constructor: Transfer pointer, no count change
Rc(Rc&& other) noexcept : block(other.block) {
other.block = nullptr; // Leave moved-from empty
}
// Move assignment: Transfer pointer, no count change
Rc& operator=(Rc&& other) noexcept {
if (this != &other) {
decrement(); // Drop old reference
block = other.block; // Take new reference
other.block = nullptr; // Leave moved-from empty
}
return *this;
}
// Accessors (const only, mimicking Rust's immutability)
const T& operator*() const {
assert(block != nullptr);
return block->value;
}
const T* operator->() const {
assert(block != nullptr);
return &block->value;
}
// Clone method (like Rc::clone)
Rc clone() const {
return Rc(*this); // Uses copy constructor
}
// Get reference count (for debugging)
size_t use_count() const {
return block ? block->count : 0;
}
};
// Factory function (like Rc::new)
template <typename T, typename... Args>
Rc<T> make_rc(Args&&... args) {
return Rc<T>(std::forward<Args>(args)...);
}
Breaking It Down
1. Control Block- Rust: Rc<T> internally uses a struct like RcBox { ref_count: NonZeroUsize, value: T }.
- C++: RcControlBlock<T> holds count and value. Allocated once, shared by all Rc instances.
- Why: Keeps the count and data together, ensuring atomic-like management (though non-atomic here).
- Rust: Rc::new allocates via the global allocator, initializes T, sets count to 1.
- C++: Constructor uses new RcControlBlock<T>(args...) with perfect forwarding.
- Why: C++’s new handles both allocation and construction, like Rust.
- Rust: Cloning via Rc::clone increments the count.
- C++: Copy constructor and clone() call increment(), sharing the block.
- Why: Mimics Rust’s shared ownership without C++’s atomic ops (unlike shared_ptr).
- Rust: Drop decrements the count; if 0, deallocates.
- C++: Destructor calls decrement(); if count hits 0, deletes the block.
- Why: RAII ensures cleanup, matching Rust’s scope-based freeing.
- Rust: Moves are cheap pointer transfers.
- C++: Move constructor/assignment transfers block, nulls the source, no count change.
- Why: Optimizes transfer without affecting reference count.
- Rust: Deref to &T (immutable).
- C++: Const operator* and operator-> enforce immutability.
- Why: Reflects Rc<T>’s read-only nature (mutation needs RefCell).
Differences from std::shared_ptr<T>
1. Thread Safety- Rc<T>: Non-atomic, single-threaded (uses usize for count).
- std::shared_ptr<T>: Atomic ref counting, thread-safe (uses std::atomic).
- Our Rc<T>: Non-atomic, like Rust’s Rc, cheaper but unsafe across threads.
- Rc<T>: Fixed deallocation (global allocator).
- std::shared_ptr<T>: Custom deleters via constructor.
- Our Rc<T>: Fixed delete, no customization.
- Rc<T>: Pairs with Weak<T> for non-owning refs (separate count).
- std::shared_ptr<T>: Pairs with std::weak_ptr<T> (separate weak count).
- Our Rc<T>: No Weak equivalent; we’d need another class and a weak count.
- Rc<T>: Single allocation for count and T.
- std::shared_ptr<T>: Often two allocations (control block separate from T), unless make_shared is used.
- Our Rc<T>: Single allocation in RcControlBlock, like Rust.
- Rc<T>: Always points to a valid T.
- std::shared_ptr<T>: Can be null.
- Our Rc<T>: Non-null by design, like Rust.
- Rc<T>: sizeof(*mut T) (pointer size).
- std::shared_ptr<T>: Often larger (pointer + control block pointer or atomic overhead).
- Our Rc<T>: sizeof(T*), minimal like Rust.
Rust Internals Peek
Rust’s Rc<T> is roughly:Our C++ version mirrors this single-allocation, non-atomic design.struct RcBox<T: ?Sized> {
strong: usize, // Reference count
value: T,
}
pub struct Rc<T: ?Sized> {
ptr: *mut RcBox<T>,
}
impl<T> Rc<T> {
pub fn new(value: T) -> Rc<T> {
let ptr = unsafe { alloc::alloc(Layout::for_value(&RcBox { strong: 1, value })) };
// Initialize...
Rc { ptr }
}
}
impl<T: ?Sized> Clone for Rc<T> {
fn clone(&self) -> Rc<T> {
unsafe { (*self.ptr).strong += 1; }
Rc { ptr: self.ptr }
}
}
impl<T: ?Sized> Drop for Rc<T> {
fn drop(&mut self) {
unsafe {
(*self.ptr).strong -= 1;
if (*self.ptr).strong == 0 {
alloc::dealloc(self.ptr as *mut u8, Layout::for_value(&*self.ptr));
}
}
}
}