vak: (Знайка)
[personal profile] vak
Продолжим разбираться с умными указателями в 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).

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.
Here’s the code:
#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)...);
}
This Rc<T> captures Rust’s essence - shared, immutable, single-threaded ownership.

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).
2. Allocation
  • 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.
3. Shared Ownership
  • 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).
4. Deallocation
  • 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.
5. Move Semantics
  • 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.
6. Access
  • 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.
2. Deleter
  • Rc<T>: Fixed deallocation (global allocator).
  • std::shared_ptr<T>: Custom deleters via constructor.
  • Our Rc<T>: Fixed delete, no customization.
3. Weak Pointers
  • 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.
4. Allocation
  • 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.
5. Nullability
  • Rc<T>: Always points to a valid T.
  • std::shared_ptr<T>: Can be null.
  • Our Rc<T>: Non-null by design, like Rust.
6. Overhead
  • 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:
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));
}
}
}
}
Our C++ version mirrors this single-allocation, non-atomic design.