Home/Unsafe & FFI/Safe Abstractions Over Unsafe

Safe Abstractions Over Unsafe

Encapsulation patterns for unsafe code

expert
unsafeencapsulationsafety
🎮 Interactive Playground

What are Safe Abstractions?

Safe abstractions encapsulate unsafe code behind safe interfaces, ensuring that invariants are maintained and undefined behavior cannot be triggered through the public API. This pattern allows leveraging low-level operations while maintaining Rust's safety guarantees at the interface boundary.

The key principle: unsafe code is an implementation detail, not part of the API contract.

The Problem

Sometimes you need unsafe operations for:

  1. Raw pointer manipulation
  2. Calling FFI functions
  3. Implementing low-level data structures
  4. Performance-critical code bypassing checks

However, exposing unsafe directly leads to:

  • Burden on API users to maintain invariants
  • Potential for undefined behavior throughout codebase
  • Loss of Rust's safety guarantees

Example Code

// Example 1: Safe wrapper around raw pointer
pub struct Buffer {
    ptr: *mut u8,
    len: usize,
    capacity: usize,
}

impl Buffer {
    /// Safe: Allocates with proper alignment and capacity
    pub fn new(capacity: usize) -> Self {
        let layout = std::alloc::Layout::array::<u8>(capacity).unwrap();
        let ptr = unsafe { std::alloc::alloc(layout) };

        if ptr.is_null() {
            std::alloc::handle_alloc_error(layout);
        }

        Buffer {
            ptr,
            len: 0,
            capacity,
        }
    }

    /// Safe: Bounds checking prevents invalid access
    pub fn push(&mut self, value: u8) {
        if self.len >= self.capacity {
            panic!("Buffer is full");
        }

        unsafe {
            // SAFETY: We checked bounds above, so this is safe
            *self.ptr.add(self.len) = value;
        }
        self.len += 1;
    }

    /// Safe: Returns Option instead of raw pointer
    pub fn get(&self, index: usize) -> Option<u8> {
        if index < self.len {
            unsafe {
                // SAFETY: index < len, so this is valid
                Some(*self.ptr.add(index))
            }
        } else {
            None
        }
    }

    /// Safe: Returns slice with valid lifetime
    pub fn as_slice(&self) -> &[u8] {
        unsafe {
            // SAFETY: ptr is valid for len bytes
            std::slice::from_raw_parts(self.ptr, self.len)
        }
    }
}

impl Drop for Buffer {
    fn drop(&mut self) {
        unsafe {
            // SAFETY: ptr was allocated with same layout
            let layout = std::alloc::Layout::array::<u8>(self.capacity).unwrap();
            std::alloc::dealloc(self.ptr, layout);
        }
    }
}

// Buffer is safe to use without unsafe blocks!
fn example_buffer() {
    let mut buf = Buffer::new(10);
    buf.push(42);
    buf.push(100);

    println!("Value at 0: {:?}", buf.get(0));
    println!("Slice: {:?}", buf.as_slice());
}

// Example 2: Safe abstraction over uninitialized memory
pub struct UninitVec<T> {
    ptr: *mut T,
    len: usize,
    capacity: usize,
}

impl<T> UninitVec<T> {
    pub fn with_capacity(capacity: usize) -> Self {
        let layout = std::alloc::Layout::array::<T>(capacity).unwrap();
        let ptr = unsafe { std::alloc::alloc(layout) as *mut T };

        if ptr.is_null() {
            std::alloc::handle_alloc_error(layout);
        }

        UninitVec {
            ptr,
            len: 0,
            capacity,
        }
    }

    /// Safe: Takes ownership and initializes memory
    pub fn push(&mut self, value: T) {
        assert!(self.len < self.capacity, "Vec is full");

        unsafe {
            // SAFETY: Capacity checked, writing to uninitialized memory
            std::ptr::write(self.ptr.add(self.len), value);
        }
        self.len += 1;
    }

    /// Safe: Only returns initialized elements
    pub fn get(&self, index: usize) -> Option<&T> {
        if index < self.len {
            unsafe {
                // SAFETY: index < len, so element is initialized
                Some(&*self.ptr.add(index))
            }
        } else {
            None
        }
    }

    pub fn len(&self) -> usize {
        self.len
    }
}

impl<T> Drop for UninitVec<T> {
    fn drop(&mut self) {
        // Drop all initialized elements
        for i in 0..self.len {
            unsafe {
                // SAFETY: Elements 0..len are initialized
                std::ptr::drop_in_place(self.ptr.add(i));
            }
        }

        // Deallocate memory
        unsafe {
            let layout = std::alloc::Layout::array::<T>(self.capacity).unwrap();
            std::alloc::dealloc(self.ptr as *mut u8, layout);
        }
    }
}

// Example 3: Safe abstraction for transmutation
pub struct TransmuteView<T, U> {
    data: T,
    _marker: std::marker::PhantomData<U>,
}

impl<T, U> TransmuteView<T, U> {
    /// Safe: Compile-time size and alignment checks
    pub fn new(data: T) -> Option<Self> {
        // Ensure sizes and alignments are compatible
        if std::mem::size_of::<T>() == std::mem::size_of::<U>()
            && std::mem::align_of::<T>() >= std::mem::align_of::<U>()
        {
            Some(TransmuteView {
                data,
                _marker: std::marker::PhantomData,
            })
        } else {
            None
        }
    }

    /// Safe: Returns reference with correct lifetime
    pub fn as_ref(&self) -> &U {
        unsafe {
            // SAFETY: Sizes and alignments checked in new()
            &*((&self.data) as *const T as *const U)
        }
    }
}

fn main() {
    println!("=== Buffer Example ===");
    example_buffer();

    println!("\n=== UninitVec Example ===");
    let mut vec = UninitVec::with_capacity(5);
    vec.push(10);
    vec.push(20);
    vec.push(30);

    for i in 0..vec.len() {
        println!("Element {}: {:?}", i, vec.get(i));
    }
}

Why It Works

Encapsulation Principles

  1. Invariant Maintenance: Unsafe code maintains all invariants internally
  2. Bounded Unsafety: Unsafe is contained, doesn't leak
  3. Safe Interface: Public API cannot trigger undefined behavior
  4. Clear Contracts: Preconditions enforced by type system

SAFETY Comments

unsafe {
    // SAFETY: Explain why this is safe
    // - What invariants are upheld
    // - What preconditions are met
    // - What could go wrong if violated
}

Compile-Time Guarantees

  • Type system enforces correct usage
  • Borrow checker prevents use-after-free
  • Lifetime parameters ensure validity

When to Use

Use safe abstractions when:

  • Implementing collections or data structures
  • Wrapping FFI libraries
  • Optimizing performance-critical code
  • Building low-level system interfaces

Unsafe is necessary for:

  • Dereferencing raw pointers
  • Calling unsafe functions/foreign functions
  • Accessing mutable static variables
  • Implementing unsafe trait methods
  • Inline assembly

Keep unsafe minimal:

  • Smallest possible unsafe blocks
  • Document safety requirements
  • Provide safe alternatives when possible
  • Test thoroughly

⚠️ Anti-patterns

⚠️ Mistake #1: Leaking Unsafety Through API

// ❌ DON'T: Expose raw pointers in public API
pub struct BadVec<T> {
    pub ptr: *mut T,  // Public! Users can violate invariants
    pub len: usize,
    pub capacity: usize,
}

// ✅ DO: Keep invariants private
pub struct GoodVec<T> {
    ptr: *mut T,  // Private
    len: usize,
    capacity: usize,
}

impl<T> GoodVec<T> {
    pub fn as_ptr(&self) -> *const T {
        self.ptr  // Const pointer is safer
    }
}

⚠️ Mistake #2: Missing Invariant Checks

// ❌ DON'T: Assume preconditions without checking
pub unsafe fn get_unchecked<T>(slice: &[T], index: usize) -> &T {
    // No bounds check! Caller must ensure index < len
    &*slice.as_ptr().add(index)
}

// ✅ DO: Provide safe alternative with checks
pub fn get<T>(slice: &[T], index: usize) -> Option<&T> {
    if index < slice.len() {
        Some(&slice[index])
    } else {
        None
    }
}

// Unsafe version for when performance is critical
pub unsafe fn get_unchecked<T>(slice: &[T], index: usize) -> &T {
    debug_assert!(index < slice.len(), "Index out of bounds");
    &*slice.as_ptr().add(index)
}

⚠️ Mistake #3: Inadequate SAFETY Documentation

// ❌ DON'T: Undocumented unsafe code
unsafe {
    *self.ptr.add(index) = value;
}

// ✅ DO: Explain safety invariants
unsafe {
    // SAFETY:
    // - index < self.len (checked above)
    // - self.ptr is valid for self.len elements (maintained by struct)
    // - No other mutable references exist (guaranteed by &mut self)
    *self.ptr.add(index) = value;
}

Advanced Example: Safe Reference-Counted Pointer

use std::ptr::NonNull;
use std::marker::PhantomData;
use std::ops::Deref;
use std::alloc::{alloc, dealloc, Layout};

struct RcBox<T> {
    ref_count: usize,
    value: T,
}

pub struct Rc<T> {
    ptr: NonNull<RcBox<T>>,
    _marker: PhantomData<RcBox<T>>,
}

impl<T> Rc<T> {
    pub fn new(value: T) -> Self {
        let layout = Layout::new::<RcBox<T>>();
        let ptr = unsafe { alloc(layout) as *mut RcBox<T> };

        if ptr.is_null() {
            std::alloc::handle_alloc_error(layout);
        }

        unsafe {
            // SAFETY: ptr is valid and aligned for RcBox<T>
            std::ptr::write(
                ptr,
                RcBox {
                    ref_count: 1,
                    value,
                },
            );
        }

        Rc {
            ptr: unsafe { NonNull::new_unchecked(ptr) },
            _marker: PhantomData,
        }
    }

    fn ref_count(&self) -> usize {
        unsafe {
            // SAFETY: ptr is always valid while Rc exists
            (*self.ptr.as_ptr()).ref_count
        }
    }

    fn inc_ref(&self) {
        unsafe {
            // SAFETY: We have a valid reference, so ptr is valid
            let rc = &mut (*self.ptr.as_ptr()).ref_count;
            *rc = rc.checked_add(1).expect("Reference count overflow");
        }
    }

    fn dec_ref(&mut self) -> bool {
        unsafe {
            // SAFETY: ptr is valid
            let rc = &mut (*self.ptr.as_ptr()).ref_count;
            *rc -= 1;
            *rc == 0
        }
    }
}

impl<T> Clone for Rc<T> {
    fn clone(&self) -> Self {
        self.inc_ref();
        Rc {
            ptr: self.ptr,
            _marker: PhantomData,
        }
    }
}

impl<T> Deref for Rc<T> {
    type Target = T;

    fn deref(&self) -> &T {
        unsafe {
            // SAFETY: ptr is valid for the lifetime of self
            &(*self.ptr.as_ptr()).value
        }
    }
}

impl<T> Drop for Rc<T> {
    fn drop(&mut self) {
        if self.dec_ref() {
            unsafe {
                // SAFETY: Last reference, safe to drop
                let layout = Layout::new::<RcBox<T>>();
                std::ptr::drop_in_place(self.ptr.as_ptr());
                dealloc(self.ptr.as_ptr() as *mut u8, layout);
            }
        }
    }
}

// Safe to use!
fn rc_example() {
    let rc1 = Rc::new(vec![1, 2, 3]);
    let rc2 = rc1.clone();
    let rc3 = rc1.clone();

    println!("rc1: {:?}", *rc1);
    println!("rc2: {:?}", *rc2);
    println!("rc3: {:?}", *rc3);
}

Performance Characteristics

Overhead

  • Minimal: Well-designed abstractions have near-zero cost
  • Bounds checking: Can be optimized away by compiler
  • Inlining: Small methods inline to eliminate calls

Trade-offs

  • Safety vs Speed: Sometimes need _unchecked variants
  • Ergonomics vs Control: Safe APIs may limit flexibility
  • Debug vs Release: Use debug assertions for development

Exercises

Beginner

  1. Wrap a raw pointer in a safe struct with bounds-checked access
  2. Implement safe get and set methods for an array
  3. Create a safe wrapper around libc::malloc and free

Intermediate

  1. Implement a safe fixed-size ring buffer
  2. Create a safe abstraction for memory-mapped files
  3. Build a safe wrapper around a C library

Advanced

  1. Implement a safe intrusive linked list
  2. Create a safe abstraction for lock-free data structures
  3. Build a safe arena allocator

Real-World Usage

Standard Library Vec

// Vec is a safe abstraction over raw pointers
let mut v = Vec::new();
v.push(1);  // No unsafe needed!

Bytes Crate

use bytes::Bytes;

// Safe abstraction over raw memory
let bytes = Bytes::from(&b"hello"[..]);

Tokio

// Safe wrappers around OS primitives
use tokio::net::TcpListener;

// No unsafe in user code
let listener = TcpListener::bind("127.0.0.1:8080").await?;

Further Reading

🎮 Try it Yourself

🎮

Safe Abstractions Over Unsafe - Playground

Run this code in the official Rust Playground