Ownership
Ownership is arguably one of Rust's most distinctive features. It allows Rust to make memory safety guarantees without needing a garbage collector.
In Rust, there can only be one owner of some memory, be that on the stack or heap, at any given time. Rust defines ownership rules that are enforced at compile time:
- Each value in Rust has a variable that's called it's owner.
- There can be only one owner at a time.
- When the owner goes out of scope, the value will be dropped.
The Rust compiler assigns lifetimes and tracks ownership. It is possible to pass or yield ownership, which is called moving in Rust.
See also:
-
Ownership in Rust.
Consider the following Java code that works without any errors:
record Point(int x, int y) {}
Point p1 = new Point(12, 10);
Point p2 = p1;
System.out.println(p1.x() + ", " + p1.y()); // prints: 12, 10
System.out.println(p2.x() + ", " + p2.y()); // prints: 12, 10
Now, let's look at the Rust version (fails with an error):
#![allow(dead_code, unused_variables)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let p1 = Point { x: 12, y: 10 }; // point owned by p1
let p2 = p1; // ownership of point moved to p2 here
println!("{}, {}", p1.x, p1.y); // doesn't work, compiler error!
println!("{}, {}", p2.x, p2.y); // works fine, prints: 12, 10
}
The first statement in main
will allocate Point
and that memory will be
owned by p1
. In the second statement, the ownership is moved from p1
to p2
and p1
can no longer be used because it no longer owns anything or represents valid memory. The statement that tries to print the fields of the point via p1
will fail compilation.
Borrowing
One way of making the code compile is by letting p2
borrow the value of p1
instead of taking ownership, as shown below. The ampersand (&
) indicates that p2
takes a
reference to the value of p1
.
fn main() {
let p1 = Point { x: 12, y: 10 }; // point owned by p1
let p2 = &p1; // p2 "borrows" point, doen't take ownership
println!("{}, {}", p1.x, p1.y); // works fine, prints: 12, 10
println!("{}, {}", p2.x, p2.y); // works fine, prints: 12, 10
}
Cloning
Another alternative would be to clone p1
:
#[derive(Clone)] // this is required for cloning to work
struct Point {
x: i32,
y: i32,
}
fn main() {
let p1 = Point { x: 12, y: 10 }; // point owned by p1
let p2 = p1.clone(); // clone point instead of taking ownership
println!("{}, {}", p1.x, p1.y); // prints: 12, 10
println!("{}, {}", p2.x, p2.y); // prints: 12, 10
}
Note that the
Point
struct needs to implement/derive theClone
trait in order for cloning to work.
Variable Scope
Let's look at this Rust code again:
fn main() {
let p1 = Point { x: 12, y: 10 }; // point owned by p1
let p2 = p1; // p2 owns the point now
println!("{}, {}", p2.x, p2.y); // ok, uses p2
} // point behind p2 is dropped
When main
exits, p1
and p2
will go out of scope. The memory
behind p2
will be released by virtue of the stack returning to its state
prior to main
being called. In Rust, one says that the point behind p2
was dropped. However, note that since p1
yielded its ownership of the point to p2
, there is nothing to drop when p1
goes out of scope.
A struct
in Rust can define code to execute when an instance is dropped by
implementing the Drop
trait.
The rough equivalent of dropping in Java would be an object finalizer: the finalize()
method1 provided by the root Object
class, that is called before the object is garbage collected. While a finalizer would be called automatically by the GC at some future point, dropping in Rust is always instantaneous and deterministic; that is, it happens at the point the compiler has determined that an instance has no owner
based on scopes and lifetimes.
In Java, the equivalent of the Drop
trait is the AutoCloseable
interface, and is implemented by types to release any unmanaged resources or memory they hold. Deterministic disposal is not enforced or guaranteed, but the try-with-resources
statement in Java is typically used to scope an instance of an auto-closeable type such that it gets disposed deterministically, at the end of the try-with-resources
statement's block.
In Java, references are shared freely without much thought so the idea
of a single owner and yielding/moving ownership may seem very limiting in
Rust, but it is possible to have shared ownership in Rust using the smart
pointer type Rc
; it adds reference-counting. Each time the smart pointer is cloned, the reference count is incremented. When the
clone drops, the reference count is decremented. The actual instance behind
the smart pointer is dropped when the reference count reaches zero.
These points are illustrated by the following example that builds on the previous:
#![allow(dead_code, unused_variables)]
use std::rc::Rc;
struct Point {
x: i32,
y: i32,
}
impl Drop for Point {
fn drop(&mut self) {
println!("Point dropped!");
}
}
fn main() {
let p1 = Rc::new(Point { x: 12, y: 10 });
let p2 = Rc::clone(&p1); // share with p2
println!("p1 = {}, {}", p1.x, p1.y); // okay to use p1
println!("p2 = {}, {}", p2.x, p2.y);
}
// prints:
// p1 = 12, 10
// p2 = 12, 10
// Point dropped!
Note that:
-
Point
implements thedrop
method of theDrop
trait and prints a message when an instance of aPoint
is dropped. -
The point created in
main
is wrapped behind the smart pointerRc
and so the smart pointer owns the point and notp1
. -
p2
gets a clone of the smart pointer that effectively increments the reference count to 2. Unlike the earlier example, wherep2
transferred its ownership of point top2
, bothp1
andp2
own their own distinct clones of the smart pointer, so it is okay to continue to usep1
andp2
. -
The compiler will have determined that
p1
andp2
go out of scope at the end ofmain
and therefore injected calls to drop each. TheDrop
implementation ofRc
will decrement the reference count and also drop what it owns if the reference count has reached zero. When that happens, theDrop
implementation ofPoint
will print the message, “Point dropped!” The fact that the message is printed once demonstrates that only one point was created, shared and dropped.
Rc
is not thread-safe. For shared ownership in a multi-threaded program, the
Rust standard library offers Arc
instead. The Rust language will
prevent the use of Rc
across threads.
In Java, primitive types (like int
and double
) live on the stack and
reference types (like class
, interface
, and record
) are heap-allocated. In Rust, the kind of type (basically enum
or struct
), does not determine where the backing memory will eventually live. By default, it is always on the stack, but just the way Java has the notion of autoboxing of primitive types, the way to allocate a type on the heap is to box it using Box
:
let stack_point = Point { x: 12, y: 10 };
let heap_point = Box::new(Point { x: 12, y: 10 });
Like Rc
and Arc
, Box
is a smart pointer, but unlike Rc
and Arc
, it
exclusively owns the instance behind it. All of these smart pointers allocate
an instance of their type argument T
on the heap.
The new
keyword in Java creates an instance of a type, and while members such
as Box::new
and Rc::new
that you see in the examples may seem to have a
similar purpose, new
has no special designation in Rust. It's merely a
conventional name that is meant to denote a factory. In fact they are called
associated functions of the type, which is Rust's way of saying static
methods.
The finalization
mechanism has been deprecated since Java 9. In modern Java, the preferred approach for resource management is by the use of cleaners, or try-with-resources statement.