In YARV, frames don’t exist in isolation - they form parent-child relationships that determine variable visibility, scope access, and closure behavior. The type of frame critically affects how child frames interact with their parents.
Understanding Frame Hierarchies
When code executes, frames are pushed onto the call stack in a parent-child relationship:
def outer # Creates method frame A (parent)
x = 10
inner # Creates method frame B (child of A)
end
def inner # Executes in method frame B
# Can this see 'x' from outer?
end
The answer depends on the frame type and the relationship type.
The Frame Stack
Frames stack on top of each other as execution deepens:
Execution Timeline:
─────────────────────────────────────────────>
1. Program starts
┌──────────────┐
│ main frame │
└──────────────┘
2. Define class MyClass
┌──────────────┐
│ class frame │ ← Child of main
├──────────────┤
│ main frame │
└──────────────┘
3. Call method in MyClass
┌──────────────┐
│ method frame │ ← Child of class
├──────────────┤
│ class frame │
├──────────────┤
│ main frame │
└──────────────┘
4. Block execution in method
┌──────────────┐
│ block frame │ ← Child of method
├──────────────┤
│ method frame │
├──────────────┤
│ class frame │
├──────────────┤
│ main frame │
└──────────────┘
Two Types of Relationships
YARV frames have two fundamentally different parent-child relationship patterns:
Type 1: Isolated Relationships (Method → Method)
When a method frame calls another method, frames are isolated - the child cannot access the parent’s local variables:
def parent_method
secret = "hidden"
child_method # Creates new method frame
end
def child_method
# Cannot access 'secret' - isolated from parent
puts secret # NameError: undefined local variable
end
Why Isolation?
- Methods are independent units of code
- Each method has its own scope
- Parameters are the interface - explicit data passing
- Prevents accidental coupling
Stack Visualization:
┌────────────────────────────┐
│ child_method frame │
│ Local vars: (none) │
│ EP: points to own base │
│ ✗ Cannot see 'secret' │
├────────────────────────────┤
│ parent_method frame │
│ Local vars: secret │
│ EP: points to own base │
└────────────────────────────┘
↑
└─ Frames isolated by EP boundaries
Type 2: Shared Relationships (Method → Block)
When a method frame creates a block, the block frame shares access to the parent’s variables:
def parent_method
secret = "visible"
lambda do
# Block CAN access 'secret' - shared scope
puts secret # => "visible"
secret = "modified" # Can even modify it!
end.call
puts secret # => "modified"
end
Why Sharing?
- Blocks are closures - they capture surrounding context
- Enables powerful functional patterns
- Block is “part of” the method, not independent
Stack Visualization:
┌────────────────────────────┐
│ block frame │
│ Local vars: (block-local) │
│ EP: points to parent! │ ← Key difference
│ ✓ Can see 'secret' │
├────────────────────────────┤
│ parent_method frame │
│ Local vars: secret │
│ EP: points to own base │
└────────────────────────────┘
↑
└─ Block's EP allows parent access
The Role of Environment Pointer
The environment pointer (EP) determines variable visibility:
def method_a
x = 1
method_b # method_b gets new EP
end
def method_b
y = 2
lambda { y } # lambda shares method_b's EP
end
EP Configuration:
method_a frame:
EP → [x, ...]
method_b frame (isolated):
EP → [y, ...] (new base, cannot see x)
lambda frame (shared):
EP → [y, ...] (points to method_b's base, can see y)
Frame Relationship Patterns
Pattern 1: Method Chain (Isolated)
def level_1
a = 1
level_2
end
def level_2
b = 2
level_3
end
def level_3
c = 3
# Only 'c' visible here
end
Frame Stack:
┌─────────────┐
│ level_3 │ Local: c ✗ Cannot access b or a
├─────────────┤
│ level_2 │ Local: b ✗ Cannot access a
├─────────────┤
│ level_1 │ Local: a
└─────────────┘
Each frame is isolated - vertical lines represent EP boundaries.
Pattern 2: Nested Blocks (Shared)
def method
a = 1
lambda do
b = 2
lambda do
c = 3
# Can access a, b, and c!
end
end
end
Frame Stack:
┌─────────────┐
│ inner block │ Local: c ✓ Accesses: a, b, c
├─────────────┤
│ outer block │ Local: b ✓ Accesses: a, b
├─────────────┤
│ method │ Local: a ✓ Accesses: a
└─────────────┘
Blocks form a scope chain - each can see parent variables.
Pattern 3: Mixed (Method + Block)
def method_a
x = 1
lambda do
y = 2
method_b
end.call
end
def method_b
z = 3
# Only 'z' visible
end
Frame Stack:
┌─────────────┐
│ method_b │ Local: z ✗ Isolated (new method)
├─────────────┤
│ block │ Local: y ✓ Accesses: x, y
├─────────────┤
│ method_a │ Local: x ✓ Accesses: x
└─────────────┘
Block shares with method_a, but method_b is isolated from both.
Closure Capture and Frame Escape
When blocks outlive their parent method, frames must escape to the heap:
def create_counter
count = 0 # Local variable in method frame
lambda { count += 1 } # Block captures 'count'
end
counter = create_counter # method returns, but frame can't be destroyed!
counter.call # => 1 # Block still needs 'count'
counter.call # => 2
Frame Escape Process:
graph TD A[Method frame created] --> B[Block frame created] B --> C[Block captures variable 'count'] C --> D{Method returns} D -->|Normally| E[Would destroy frame] E --> F{Block escapes?} F -->|Yes| G[Reify frame to heap] F -->|No| H[Destroy frame normally] G --> I[Block keeps reference to heap frame] I --> J[Method frame persists as heap object] style A fill:#e1f5ff style C fill:#fff4e1 style G fill:#ffebee style J fill:#e8f5e9
This process is called reification - making the frame concrete/permanent.
Before Reification (Stack):
Stack (temporary):
┌──────────────────┐
│ method frame │ count = 0
│ EP: stack base │
└──────────────────┘
After Reification (Heap):
Heap (permanent):
┌──────────────────┐
│ method frame │ count = 0
│ EP: heap object │ ← Persists after method returns
└──────────────────┘
↑
└── Lambda holds reference
Class Frames and Scope
Class frames create a special scope:
class MyClass
x = 10 # Local to class frame
def method_a
# Cannot access x - isolated method frame
end
define_method(:method_b) do
# CAN access x - block shares class frame scope!
puts x
end
end
Why the difference?
def
creates a new instruction sequence - isolateddefine_method
with block - shares parent scope (class frame)
Frame Relationship:
Class Frame: x = 10
├── method_a frame (isolated) ✗
└── method_b block (shared) ✓
Exception Frames and Access
Rescue and ensure frames share parent scope:
def example
x = 10
begin
risky_code
rescue => e
puts x # ✓ Can access x (rescue shares scope)
ensure
puts x # ✓ Can access x (ensure shares scope)
end
end
Frame Stack:
┌─────────────┐
│ ensure │ ✓ Accesses parent variables
├─────────────┤
│ rescue │ ✓ Accesses parent variables
├─────────────┤
│ method │ Local: x
└─────────────┘
Exception frames need parent access for error handling and cleanup.
Eval Frames and Binding
Eval frames access the caller’s scope through binding:
def example
x = 10
eval("x + 5") # => 15 (eval sees x)
end
Eval creates a frame that shares the caller’s scope:
┌─────────────┐
│ eval frame │ ✓ Accesses caller's binding
├─────────────┤
│ method │ Local: x
└─────────────┘
Security implication: Eval can access and modify parent variables!
Performance Implications
Fast Path: Isolated Frames
def a
x = 1
b(x) # Pass explicitly - b's frame is isolated
end
def b(param)
param * 2 # Fast: local access only
end
- No scope chain traversal
- Local variable access by offset from EP
- Frame destroyed immediately on return
Slow Path: Capturing Blocks
def a
x = 1
lambda { x * 2 } # Captures x - may require frame escape
end
- Must check if frame escapes
- May reify to heap
- Slower variable access (indirection)
Relationship Summary Table
Parent Frame | Child Frame | Relationship | Variable Access | Example |
---|---|---|---|---|
method | method | Isolated | ✗ No access | def a; def b |
method | block | Shared | ✓ Full access | def a; lambda {} |
block | block | Shared | ✓ Full access | lambda { lambda {} } |
class | method (def ) | Isolated | ✗ No access | class C; def m |
class | block | Shared | ✓ Full access | class C; lambda {} |
method | rescue | Shared | ✓ Full access | def a; rescue |
method | ensure | Shared | ✓ Full access | def a; ensure |
any | eval | Shared (binding) | ✓ Full access | eval("code") |
Frame Navigation
YARV maintains pointers to navigate the frame stack:
def a
b
end
def b
c { }
end
def c
yield
end
# Call stack at yield:
# block → c (isolated) → b (isolated) → a (isolated)
# ↑ ↑ ↑ ↑
# EP EP EP EP
# └─────→ can access b's variables (shared)
Navigation Pointers:
- Current frame: Where execution is now
- Parent frame: Previous frame on call stack
- Lexical parent: Frame where block was defined (for shared access)
Key Insights
- Frame Type Matters: Frame type determines relationship pattern
- Two Patterns: Isolated (method→method) vs Shared (method→block)
- EP is Key: Environment pointer controls variable visibility
- Closures Complicate: Blocks that escape require heap allocation
- Performance Variance: Isolated is fast, shared is slower
- Scope Chain: Blocks form a traversable scope chain
- Security Boundaries: Frame isolation provides security and encapsulation