Skip to content

Chapter 13: Memory Management

The Problem with Manual Memory Management

In C, you allocate memory with malloc and free it with free. Forget to free? Memory leak. Free twice? Crash. Use after free? Undefined behavior, probably a security vulnerability.

In garbage-collected languages like Python or Java, the runtime handles memory. But garbage collection has costs: pauses, unpredictable performance, memory overhead.

Wyn uses Automatic Reference Counting (ARC). It's deterministic like manual management but automatic like garbage collection.

How ARC Works

Every value has a reference count-how many variables point to it. When the count reaches zero, the memory is freed immediately.

wyn
fn main() -> int {
    var x = "hello"  // Reference count: 1
    var y = x        // Reference count: 2
    
    // y goes out of scope
    // Reference count: 1
    
    // x goes out of scope
    // Reference count: 0, memory freed
    
    return 0
}

You don't see this happening. The compiler inserts increment and decrement operations automatically.

Value Semantics

Simple types are copied:

wyn
fn main() -> int {
    var a = 42
    var b = a  // Copies the value
    
    b = 100
    
    print(a)  // 42 - unchanged
    print(b)  // 100
    
    return 0
}

Integers, floats, booleans-these are small and cheap to copy.

Structs are also copied:

wyn
struct Point {
    x: int,
    y: int
}

fn main() -> int {
    var p1 = Point { x: 5, y: 10 }
    var p2 = p1  // Copies the struct
    
    p2.x = 20
    
    print(p1.x)  // 5 - unchanged
    print(p2.x)  // 20
    
    return 0
}

This is safe but can be expensive for large structs. Most of the time, it's fine.

Reference Semantics

Collections use reference semantics:

wyn
fn main() -> int {
    var arr1 = [1, 2, 3]
    var arr2 = arr1  // Shares the reference
    
    arr2[0] = 10
    
    print(arr1[0])  // 10 - changed!
    print(arr2[0])  // 10
    
    return 0
}

Arrays, HashMaps, and HashSets are references. When you assign them, you're copying the reference, not the data.

This is efficient but means mutations are visible through all references.

When Memory Is Freed

Memory is freed when the last reference goes out of scope:

wyn
fn process() -> int {
    var data = [1, 2, 3, 4, 5]  // Allocated
    
    // Use data
    
    return 0
    // data goes out of scope, memory freed
}

This is deterministic. You know exactly when memory is freed-when the variable goes out of scope.

No Dangling Pointers

ARC prevents use-after-free:

wyn
fn get_data() -> [int] {
    var data = [1, 2, 3]
    return data  // Reference count stays > 0
}

fn main() -> int {
    var arr = get_data()
    print(arr[0])  // Safe - data still alive
    return 0
}

The array doesn't get freed when get_data returns because arr still references it.

No Memory Leaks (Mostly)

ARC prevents most memory leaks. When nothing references a value, it's freed:

wyn
fn main() -> int {
    for (var i = 0; i < 1000; i = i + 1) {
        var temp = [1, 2, 3, 4, 5]
        // temp freed at end of each iteration
    }
    return 0
}

No accumulation of temporary allocations.

Cycles Can Leak

ARC has one weakness: reference cycles:

wyn
struct Node {
    value: int,
    next: Option<Node>
}

fn main() -> int {
    var node1 = Node { value: 1, next: none }
    var node2 = Node { value: 2, next: some(node1) }
    node1.next = some(node2)  // Cycle!
    
    // node1 references node2
    // node2 references node1
    // Neither can be freed
    
    return 0
}

This is rare in practice. Most data structures are trees or DAGs, not cycles. If you need cycles, you'll need to break them manually.

Stack vs Heap

Small values live on the stack:

wyn
fn main() -> int {
    var x = 42        // Stack
    var y = 3.14      // Stack
    var flag = true   // Stack
    return 0
}

Stack allocation is fast-just move a pointer. Deallocation is automatic when the function returns.

Large values and collections live on the heap:

wyn
fn main() -> int {
    var arr = [1, 2, 3, 4, 5]  // Heap
    var map = {"a": 1, "b": 2}  // Heap
    return 0
}

Heap allocation is slower but necessary for dynamic sizes.

You don't control this. The compiler decides based on size and lifetime.

Performance Implications

Copying Costs

wyn
struct LargeStruct {
    data: [int]  // imagine 1000 elements
}

fn process(s: LargeStruct) -> int {
    // s is copied - expensive!
    return 0
}

Passing large structs by value copies them. For small structs, this is fine. For large ones, it's slow.

Solution: use references (future feature) or redesign to avoid large copies.

Reference Counting Costs

Every assignment increments/decrements reference counts:

wyn
fn main() -> int {
    var arr = [1, 2, 3]
    for (var i = 0; i < 1000; i = i + 1) {
        var temp = arr  // Increment count
        // Decrement count at end of iteration
    }
    return 0
}

This has overhead. It's usually negligible, but it's not zero.

Allocation Costs

Creating collections allocates memory:

wyn
fn main() -> int {
    for (var i = 0; i < 1000000; i = i + 1) {
        var temp = [1, 2, 3]  // Allocates every iteration
    }
    return 0
}

Allocations are expensive. Reuse collections when possible:

wyn
fn main() -> int {
    var temp = []
    for (var i = 0; i < 1000000; i = i + 1) {
        temp.clear()
        temp.push(1)
        temp.push(2)
        temp.push(3)
    }
    return 0
}

Memory Safety Guarantees

Wyn guarantees:

  1. No use-after-free: ARC ensures values live as long as they're referenced
  2. No double-free: The compiler handles deallocation
  3. No null pointer dereferences: Option forces you to check
  4. No buffer overflows: Array bounds are checked

These guarantees prevent entire classes of bugs. You can't accidentally corrupt memory.

Profiling Memory Usage

To optimize memory, you need to measure. Wyn binaries are ordinary native executables, so the standard system tools work directly:

bash
wyn build program.wyn

# Peak memory (macOS/Linux):
/usr/bin/time -l ./program     # macOS: "maximum resident set size"
/usr/bin/time -v ./program     # Linux: "Maximum resident set size"

# Allocation hotspots (macOS):
leaks --atExit -- ./program

# Allocation hotspots (Linux):
valgrind --tool=massif ./program

Profile before optimizing. Don't guess where memory is used.

Best Practices

Prefer Small Structs

wyn
// Good: small, cheap to copy
struct Point {
    x: int,
    y: int
}

// Bad: large, expensive to copy
struct HugeStruct {
    data: [int]  // imagine 10,000 elements
}

Reuse Collections

wyn
// Bad: allocates every iteration
for (var i = 0; i < n; i = i + 1) {
    var temp = []
    // Use temp
}

// Good: reuse allocation
var temp = []
for (var i = 0; i < n; i = i + 1) {
    temp.clear()
    // Use temp
}

Avoid Unnecessary Copies

wyn
// Bad: copies array
fn process(arr: [int]) -> int {
    // ...
}

// Better: document that it copies
fn process_copy(arr: [int]) -> int {
    // ...
}

Break Cycles

wyn
// If you create cycles, break them manually
fn cleanup(node: Node) -> int {
    node.next = none  // Break cycle
    return 0
}

What You've Learned

Wyn uses Automatic Reference Counting for memory management. Values are freed when their reference count reaches zero. This is deterministic and predictable.

Simple types and structs have value semantics-they're copied. Collections have reference semantics-they're shared.

ARC prevents use-after-free, double-free, and most memory leaks. The one weakness is reference cycles, which are rare in practice.

Memory is allocated on the stack or heap. The compiler decides based on size and lifetime. You don't control this directly.

Profile before optimizing. Reuse collections. Prefer small structs. Break cycles if you create them.

Exercises

  1. Value vs reference: Create examples showing value semantics (structs) vs reference semantics (arrays). Observe the difference.

  2. Lifetime tracking: Write a program that creates and destroys many objects. Add print statements to see when memory is freed.

  3. Memory profiling: Write a program that allocates lots of memory. Profile it to see where allocations happen.

  4. Reuse optimization: Write a program that creates many temporary arrays. Optimize it by reusing a single array.

  5. Cycle detection: Create a reference cycle. Verify it leaks memory. Then break the cycle and verify the leak is fixed.

  6. Large struct: Create a large struct and pass it to functions. Measure the performance impact of copying.

  7. Collection growth: Create an array and add elements one at a time. Observe how it grows and reallocates.


Next: Chapter 14: Standard Library

MIT License - v1.19.1