Go to Rust Series: ← The Rust Compiler | Series Overview | No Garbage Collector →
The Core Difference
Ownership is Rust’s most important concept-and the one Go completely lacks.
In Go, the garbage collector handles memory. In Rust, the ownership system handles memory at compile time, with zero runtime cost.
Go’s Approach: Share and GC
Go:
package main
import "fmt"
func main() {
data := []int{1, 2, 3}
// Pass to multiple functions
printData(data)
modifyData(data)
printData(data)
// Still usable
fmt.Println(data)
}
func printData(d []int) {
fmt.Println(d)
}
func modifyData(d []int) {
d[0] = 999 // Modifies original
}
Output:
[1 2 3]
[999 2 3]
[999 2 3]
Data is shared. The GC tracks references and cleans up when no longer needed.
Rust’s Approach: Ownership Rules
Rust has three fundamental ownership rules:
- Each value has one owner
- When the owner goes out of scope, the value is dropped
- Values can be borrowed (temporarily) but not owned by multiple places
Rust:
fn main() {
let data = vec![1, 2, 3];
print_data(&data); // Borrow (immutable)
modify_data(&mut data); // ERROR! data is not mutable
print_data(&data);
}
fn print_data(d: &Vec<i32>) {
println!("{:?}", d);
}
fn modify_data(d: &mut Vec<i32>) {
d[0] = 999;
}
Must make data mutable:
fn main() {
let mut data = vec![1, 2, 3]; // Now mutable
print_data(&data);
modify_data(&mut data);
print_data(&data);
println!("{:?}", data);
}
Move Semantics
Go: Copy Semantics (Usually)
Go:
func takeOwnership(s string) {
fmt.Println(s)
}
func main() {
text := "Hello"
takeOwnership(text)
fmt.Println(text) // Still works!
}
Output:
Hello
Hello
Strings in Go are immutable and cheap to copy (just a pointer and length).
With slices:
func takeOwnership(s []int) {
s[0] = 999 // Modifies original!
}
func main() {
nums := []int{1, 2, 3}
takeOwnership(nums)
fmt.Println(nums) // [999 2 3]
}
Slices are references under the hood, so modifications affect the original.
Rust: Move Semantics (By Default)
Rust:
fn take_ownership(s: String) {
println!("{}", s);
} // s is dropped here
fn main() {
let text = String::from("Hello");
take_ownership(text); // text is MOVED
println!("{}", text); // ERROR! text was moved
}
Error:
error[E0382]: borrow of moved value: `text`
--> src/main.rs:8:20
|
6 | let text = String::from("Hello");
| ---- move occurs because `text` has type `String`
7 | take_ownership(text);
| ---- value moved here
8 | println!("{}", text);
| ^^^^ value borrowed here after move
Solutions:
1. Clone (explicit copy):
fn main() {
let text = String::from("Hello");
take_ownership(text.clone()); // Clone before moving
println!("{}", text); // Works!
}
2. Borrow instead of move:
fn borrow_string(s: &String) {
println!("{}", s);
} // Borrow ends here
fn main() {
let text = String::from("Hello");
borrow_string(&text); // Borrow, don't move
println!("{}", text); // Works!
}
Borrowing: Immutable References
Go:
func read1(s *string) {
fmt.Println(*s)
}
func read2(s *string) {
fmt.Println(*s)
}
func main() {
text := "Hello"
read1(&text)
read2(&text) // Multiple references OK
}
Rust:
fn read1(s: &String) {
println!("{}", s);
}
fn read2(s: &String) {
println!("{}", s);
}
fn main() {
let text = String::from("Hello");
read1(&text);
read2(&text); // Multiple immutable borrows OK
}
Rule: You can have unlimited immutable borrows (&T) simultaneously.
Mutable Borrowing: One at a Time
Go:
func modify1(nums *[]int) {
(*nums)[0] = 1
}
func modify2(nums *[]int) {
(*nums)[0] = 2
}
func main() {
nums := []int{0, 0, 0}
ptr1 := &nums
ptr2 := &nums // Multiple mutable refs OK
modify1(ptr1)
modify2(ptr2)
fmt.Println(nums) // [2 0 0]
}
Works fine. But can lead to data races with goroutines.
Rust:
fn modify1(nums: &mut Vec<i32>) {
nums[0] = 1;
}
fn modify2(nums: &mut Vec<i32>) {
nums[0] = 2;
}
fn main() {
let mut nums = vec![0, 0, 0];
let r1 = &mut nums;
let r2 = &mut nums; // ERROR! Only one mutable borrow allowed
modify1(r1);
modify2(r2);
}
Error:
error[E0499]: cannot borrow `nums` as mutable more than once at a time
Rule: You can have only ONE mutable borrow (&mut T) at a time.
Correct version:
fn main() {
let mut nums = vec![0, 0, 0];
modify1(&mut nums); // Borrow ends after this line
modify2(&mut nums); // New borrow starts here
println!("{:?}", nums); // [2 0 0]
}
No Simultaneous Read/Write
Go (compiles, but dangerous):
func main() {
data := []int{1, 2, 3}
reader := &data
writer := &data
// Simultaneous read and write (data race!)
go func() {
for {
fmt.Println((*reader)[0])
}
}()
go func() {
for {
(*writer)[0] = 999
}
}()
time.Sleep(time.Second)
}
Compiles. Results in data race at runtime.
Rust (won’t compile):
fn main() {
let mut data = vec![1, 2, 3];
let reader = &data; // Immutable borrow
let writer = &mut data; // ERROR! Can't have both
println!("{}", reader[0]);
writer[0] = 999;
}
Error:
error[E0502]: cannot borrow `data` as mutable because it is also borrowed as immutable
Rule: You can have EITHER:
- Many immutable references (
&T), OR - One mutable reference (
&mut T)
But not both simultaneously.
Scope and Lifetimes
Go:
func dangling() *int {
x := 42
return &x // OK! GC keeps x alive
}
func main() {
ptr := dangling()
fmt.Println(*ptr) // 42
}
GC ensures x outlives the pointer.
Rust:
fn dangling() -> &i32 {
let x = 42;
&x // ERROR! x is dropped at end of function
}
Error:
error[E0106]: missing lifetime specifier
Rust prevents dangling references at compile time.
Correct version:
fn not_dangling() -> i32 {
42 // Return value, not reference
}
fn main() {
let val = not_dangling();
println!("{}", val);
}
Real-World Example: Building a Cache
Go: Simple but Requires Sync
Go:
package main
import (
"fmt"
"sync"
)
type Cache struct {
mu sync.RWMutex
data map[string]string
}
func NewCache() *Cache {
return &Cache{
data: make(map[string]string),
}
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.data[key]
return val, ok
}
func (c *Cache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
func main() {
cache := NewCache()
cache.Set("name", "Gopher")
val, _ := cache.Get("name")
fmt.Println(val)
}
Manual locking required. Easy to forget.
Rust: Safety Built-In
Rust:
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
struct Cache {
data: RwLock<HashMap<String, String>>,
}
impl Cache {
fn new() -> Self {
Cache {
data: RwLock::new(HashMap::new()),
}
}
fn get(&self, key: &str) -> Option<String> {
let data = self.data.read().unwrap();
data.get(key).cloned()
}
fn set(&self, key: String, value: String) {
let mut data = self.data.write().unwrap();
data.insert(key, value);
}
}
fn main() {
let cache = Arc::new(Cache::new());
cache.set("name".to_string(), "Rustacean".to_string());
if let Some(val) = cache.get("name") {
println!("{}", val);
}
}
RwLock ensures read/write safety. Type system prevents races.
Common Patterns
Pattern 1: Temporary Mutable Borrow
Rust:
fn main() {
let mut data = vec![1, 2, 3];
{
let temp = &mut data; // Mutable borrow
temp.push(4);
} // Borrow ends here
println!("{:?}", data); // Usable again
}
Pattern 2: Split Borrows
Rust:
fn main() {
let mut data = vec![1, 2, 3, 4];
// Can't borrow data twice mutably
// But can split it:
let (left, right) = data.split_at_mut(2);
left[0] = 10;
right[0] = 20;
println!("{:?}", data); // [10 2 20 4]
}
Pattern 3: Entry API (Avoiding Double Lookup)
Go:
if _, ok := cache[key]; !ok {
cache[key] = expensive_computation()
}
return cache[key]
Two lookups!
Rust:
cache.entry(key).or_insert_with(|| expensive_computation());
One lookup, borrowing rules ensure safety.
The Mental Shift
Coming from Go:
- Think: “Who owns this data?”
- Ask: “Do I need to modify it?”
- Plan: “How long does this reference need to live?”
In Go, you think:
- “Pass this data”
- “GC will handle cleanup”
In Rust, you think:
- “Should I move, borrow, or clone?”
- “Is this reference still valid?”
- “Can this be borrowed immutably?”
Conclusion
Go’s memory model:
- Simple: pass data around freely
- Garbage collector handles cleanup
- Runtime overhead for GC pauses
- Potential for data races
Rust’s ownership model:
- Complex: must track ownership and lifetimes
- Compile-time memory management
- Zero runtime overhead
- Data races impossible (in safe Rust)
Trade-off:
- Go: Easier to write, potential runtime issues
- Rust: Harder to write, guaranteed safety
Next: How Rust achieves this without a garbage collector.
Go to Rust Series: ← The Rust Compiler | Series Overview | No Garbage Collector →
Ownership verdict: Hardest Rust concept for Go developers. But once it clicks, you understand why Rust is so fast and safe.