source

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 - isolated
  • define_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 FrameChild FrameRelationshipVariable AccessExample
methodmethodIsolated✗ No accessdef a; def b
methodblockShared✓ Full accessdef a; lambda {}
blockblockShared✓ Full accesslambda { lambda {} }
classmethod (def)Isolated✗ No accessclass C; def m
classblockShared✓ Full accessclass C; lambda {}
methodrescueShared✓ Full accessdef a; rescue
methodensureShared✓ Full accessdef a; ensure
anyevalShared (binding)✓ Full accesseval("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

  1. Frame Type Matters: Frame type determines relationship pattern
  2. Two Patterns: Isolated (method→method) vs Shared (method→block)
  3. EP is Key: Environment pointer controls variable visibility
  4. Closures Complicate: Blocks that escape require heap allocation
  5. Performance Variance: Isolated is fast, shared is slower
  6. Scope Chain: Blocks form a traversable scope chain
  7. Security Boundaries: Frame isolation provides security and encapsulation