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.
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:
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:
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:
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:
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:
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:
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:
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:
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:
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
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:
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:
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:
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:
- No use-after-free: ARC ensures values live as long as they're referenced
- No double-free: The compiler handles deallocation
- No null pointer dereferences: Option forces you to check
- 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:
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 ./programProfile before optimizing. Don't guess where memory is used.
Best Practices
Prefer Small Structs
// 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
// 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
// Bad: copies array
fn process(arr: [int]) -> int {
// ...
}
// Better: document that it copies
fn process_copy(arr: [int]) -> int {
// ...
}Break Cycles
// 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
Value vs reference: Create examples showing value semantics (structs) vs reference semantics (arrays). Observe the difference.
Lifetime tracking: Write a program that creates and destroys many objects. Add print statements to see when memory is freed.
Memory profiling: Write a program that allocates lots of memory. Profile it to see where allocations happen.
Reuse optimization: Write a program that creates many temporary arrays. Optimize it by reusing a single array.
Cycle detection: Create a reference cycle. Verify it leaks memory. Then break the cycle and verify the leak is fixed.
Large struct: Create a large struct and pass it to functions. Measure the performance impact of copying.
Collection growth: Create an array and add elements one at a time. Observe how it grows and reallocates.