Smart pointers
Lecture 5 combined bounds and lifetime metadata to enforce memory safety dynamically, under stated assumptions. Its benchmark results show that instrumentation can be expensive. This lecture examines how ownership and borrowing rules let a compiler establish many of the same lifetime properties before execution.
A smart pointer is an object that manages a resource through a pointer. It can carry ownership metadata and release the resource automatically. C++ supplies both exclusive and shared ownership types. These manage destruction, but do not by themselves check all accesses through other pointers {{ cite.cppsmart }} {{ cite.cppshared }}.
Two important smart pointer types, available since C++11 and present in the slides' C++17 setting, illustrate the difference:
Can be moved, not copied. A move transfers ownership and leaves the source empty. The type system rejects copying a unique_ptr.
One owner is responsible for destroying the resource. Non-owning aliases obtained through get() or a reference can still become dangling when that owner releases it.
No reference-counting overhead. Ownership transfer is enforced by the type system; destruction still executes at runtime.
Multiple owners can keep a resource alive. Each owning handle participates in a reference count maintained at runtime.
Copying an owning shared_ptr increases the strong count. Releasing an owner decreases it; reaching zero destroys the managed object. Raw aliases do not contribute to the count. Cycles of owning pointers can leak.
Runtime bookkeeping. Updating the ownership count has a cost, and the count does not synchronize access to the object itself.
The missing piece in C++ is checked borrowing. A single owner can coexist with non-owning references, but those references must stop being used before the resource is destroyed or invalidated. Safe Rust enforces that relationship.
#include <memory>
int main() {
auto owner = std::make_unique<int>(7);
int *alias = owner.get(); // non-owning access
owner.reset(); // destroys the integer
// *alias = 9; // would access freed memory
}
Bounds checked statically or at runtime
Rust protects safe indexing in two ways: the compiler can prove an access in bounds, or the generated program checks it before accessing memory.
For an array whose length and index are known, the compiler can often establish the result during compilation. Optimizers can also eliminate redundant checks, and iterator-based loops often avoid separate indexing checks. The amount eliminated depends on the code and build settings.
A reference to a slice, &[T], carries an address and a runtime length: a fat pointer {{ cite.pointee }}. The length is metadata, not part of the slice's type. By contrast, an array type [T; N] includes its length N. An index that has not been proved in range is checked against the length before the access.
Both slice references and SoftBound associate bounds information with a pointer, but their representations and access rules differ. A slice reference carries its length directly, while SoftBound propagates separate base-and-bound metadata for C pointers. Safe Rust APIs preserve valid slice metadata; unsafe code constructing a slice must establish those invariants.
Out-of-range indexing panics before performing the invalid access. A panic may unwind the current thread's stack and be caught, or abort the process, depending on configuration {{ cite.panic }}. Memory safety does not guarantee successful execution; it prevents invalid memory accesses.
Exclusive ownership
For values such as String and Box<T>, ownership transfers through a move. After the move, the previous binding cannot be used until reinitialized. An owned value is normally dropped when its local owner goes out of scope, or earlier through an operation that takes ownership. Types implementing Copy, such as integers, instead produce independent copies of their values.
In this C example, two pointers can each be passed to free. Rust prevents duplicating the ownership of a Box through assignment. Borrowed aliases can still exist, but borrowing does not give them a second ownership right over the same allocation.
/* C fragment: assume allocation succeeds. */ void buggy_temporal() { int *p = (int*) malloc(sizeof(int)); int *q = p; // an unchecked alias free(p); free(q); // double free }
Why a compiler can do this at all
The compiler need not reconstruct all pointer aliases during execution. For ordinary exclusive ownership, the type system records which value owns a resource and which operations transfer that responsibility:
Rc or Arc uses additional runtime bookkeeping.The compiler checks ownership transfers and inserts destruction along the appropriate control-flow paths. It does not need to know the entire runtime heap graph. Ordinary moves and borrows need no runtime reference counter; allocation and destruction still have costs. Values can also be deliberately leaked, so memory safety is not a guarantee that every allocation is reclaimed.
Borrowing and lifetimes
Passing an owned value to a function can transfer it. Borrowing provides temporary access without that transfer {{ cite.borrowing }}. The owner remains responsible for the value, while the compiler checks how references are used.
You cannot use or borrow a moved value through its old binding. References can themselves be borrowed, but a binding from which a non-Copy value has moved is unavailable until it is initialized again.
A reference cannot remain in use after its referent becomes invalid. The compiler infers the region over which each borrow is needed. This can end at its last use, before the enclosing lexical scope ends:
A use-after-free accesses an object after its lifetime has ended. Safe Rust rejects a reference use that could do this. This lifetime relation works together with ownership and borrowing restrictions: keeping a reference in use also prevents a conflicting operation that would invalidate its referent.
A reborrow derives access through an existing reference. A mutable reborrow temporarily restricts conflicting uses of the original reference. Once the reborrow's last use is over, the original reference can be used again. The examples below show both a valid reborrow and an attempt to reuse the original reference too early.
Aliasing xor mutability
The referent's storage can become invalid even while its container remains alive. In this C++ example, increasing a vector's capacity can invalidate a reference to one of its elements:
void test_vector() {
std::vector<int> v(300, 10);
int &first = v[0];
std::cout << first << std::endl; // 10, as expected
v.resize(4000); // may move the whole buffer
std::cout << first << std::endl; // undefined behavior if reallocated; no promised output
}
If resize exceeds the vector's capacity, it allocates a larger buffer and releases the old one. The first reference then becomes dangling. Using it after that invalidation is the error. The reference is used only for reading here, although C++ declares it as an ordinary mutable int&.
For ordinary references to the same data, Rust permits shared access or exclusive mutable access. This is often called aliasing xor mutability (AXM): multiple &T references may be in use, or one exclusive &mut T. Interior-mutability types such as RefCell and Mutex provide controlled ways to mutate through shared references, discussed in section 06.
{{ axmCode }}
The restrictions concern overlapping access to the same data, not the number of reference variables in a scope. A borrow that is no longer used can end before its variable goes out of scope. A mutable reborrow can also end before the original reference is used again. Neither allows a vector to reallocate while a reference into its old buffer will still be used.
Concurrency
Concurrency adds a requirement: a lifetime check and the access it permits must remain valid even if another thread runs between them. Consider this C-like pseudocode, assuming allocation succeeds and SIZE > 10:
int main() {
char *p = (char*) malloc(SIZE);
Thread_create(Thread1, p); // shares access to p
p[10] = 0;
free(p);
}
int Thread1(char *p) {
...
p[10] = 1; // may run after main freed it
}
The worker may write after the main thread frees the buffer. Even a lock-and-key monitor can miss that violation if the worker first passes the check, then main frees the object, then the worker accesses it. The original CETS prototype does not support multithreading and explicitly discusses this check-to-access race {{ cite.cets }}.
Safe Rust combines ownership and borrowing with the thread API's type requirements {{ cite.concurrency }}. Send permits a value to be transferred between threads; Sync permits shared references to it to cross threads. These rules exclude unsynchronized conflicting access through ordinary references. Synchronized mutation is still possible, and data-race freedom does not rule out logical races or deadlocks.
Shared ownership and interior mutability
Shared ownership is useful when several parts of a program must keep the same allocation alive. It is one design option; graphs can also be represented by indices into an exclusively owned collection. Rust provides separate types for sharing ownership and controlling mutation:
Shared ownership within one thread. Cloning an Rc increases its strong count; dropping a handle decreases it. The value is dropped at zero. Strong ownership cycles can leak, and Weak can represent links that do not keep the value alive {{ cite.rc }}.
Borrow rules checked at runtime. RefCell permits many shared borrows or one exclusive mutable borrow of its contents. Conflicting borrow() or borrow_mut() calls panic; their try_ variants return errors. The borrow guards' lifetimes are still checked statically {{ cite.refcell }}.
These types solve different problems. Rc counts owners to decide when to drop a value. RefCell tracks active borrows to decide which access is allowed. Neither supplies synchronized sharing across threads. In particular, Rc is neither Send nor Sync, and RefCell is not Sync.
For shared ownership across threads, Arc uses an atomic reference count {{ cite.arc }}. It does not make arbitrary mutation of its contents safe. Arc<Mutex<T>> combines shared ownership with exclusive access under a lock; RwLock and atomic types provide other access patterns. The runtime cost is explicit in the types and operations selected.
Ownership and access are separate
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let count = Arc::new(Mutex::new(0));
let worker_count = Arc::clone(&count);
let worker = thread::spawn(move || {
let mut value = worker_count.lock().unwrap();
*value += 1;
}); // the lock guard is dropped when the closure returns
worker.join().unwrap();
assert_eq!(*count.lock().unwrap(), 1);
}
The cloned Arc keeps the allocation alive while the worker owns it. The mutex guard provides exclusive access and releases the lock when dropped. The thread can run concurrently because both the lifetime and access requirements are satisfied.
What the guarantee actually covers
Rust's unsafe keyword permits operations whose memory-safety requirements the compiler cannot fully verify, such as dereferencing raw pointers. It does not disable the borrow checker. Ordinary references are still checked inside an unsafe block {{ cite.unsafedocs }}.
Low-level implementations, including allocators and parts of smart pointer libraries, use unsafe operations behind safe interfaces. Their authors must ensure that every permitted use of the safe interface preserves the required invariants. RustBelt gives a machine-checked proof for a model of a realistic subset of Rust and verified library contracts, rather than a proof of the entire compiler and ecosystem {{ cite.rustbelt }}.
A study collected 186 bug reports, including all Rust memory-safety CVEs known by 31 December 2020. Every memory-safety bug in that dataset involved unsafe code {{ cite.rustcves }}. A separate 2020 ecosystem study found that unsafe code was often reached through dependencies, even when a crate did not contain it directly {{ cite.rustunsafe }}. These are findings about those datasets, not guarantees about every future vulnerability.
Safe Rust relies on sound unsafe implementations, including dependencies and foreign-code interfaces. Review must cover the invariants of the whole abstraction: surrounding safe code can violate an assumption later relied on by an unsafe operation {{ cite.unsafecontracts }}. There is no fixed number of lines in which every memory-safety bug must occur.
Rust, in brief
- Ownership controls destruction; borrowing permits access. Moving a non-Copy value transfers ownership. Non-owning references do not duplicate it.
- For ordinary exclusive ownership, the compiler checks transfers and borrows through known bindings and types. Shared ownership adds runtime bookkeeping.
- A reference cannot outlive its referent. Borrows can end at their last use, before the enclosing scope ends.
- Ordinary references enforce shared or exclusive access. Reborrowing temporarily restricts conflicting uses of the original reference.
- Bounds are proved or checked before access. Slice references carry a runtime length; indexing outside it panics.
- Rc counts owners; RefCell checks borrows. Across threads, Send and Sync constrain sharing; Arc and synchronization types provide controlled shared access.
- Unsafe code must uphold the safe interface's contract. Borrow checking remains active, and the guarantee depends on sound implementations of unsafe operations.
{{ course.notes }} · Computer Security