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:

  1. Each value has one owner
  2. When the owner goes out of scope, the value is dropped
  3. 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.