v4 Bytecode: lowering control flow

Work through examples, try the reasoning yourself, then put it into practice.

View Slides

Before you start

  • Analyze loop/function boundaries and trace absolute v3 jumps.

What you will practice

  • Derive loop targets and scope cleanup for every exit path.
  • Preserve exactly one boolean result across short-circuit joins.
  • Distinguish emitted RHS code from runtime evaluation.
In this lesson

Lesson

3 min read

v4 Bytecode: lowering control flow

Control-flow compilation is a graph construction problem. Give each loop a context containing unresolved break/continue jumps and its enclosing scope depth. Nested loops push distinct contexts. A function compiler starts with a fresh loop stack; it cannot target an enclosing function's loops.

Worked reconstruction

Use this labeled shape for a for loop:

ENTER_SCOPE              # initializer scope
init
condition: cond-or-true
JUMP_IF_FALSE cleanup
ENTER_SCOPE              # body block
body
EXIT_SCOPE
post: post-statement
JUMP condition
cleanup: EXIT_SCOPE      # initializer scope

Suppose a continue occurs in an extra nested block inside the body. At that point the runtime has initializer, body, and nested scopes. Emit two EXIT_SCOPE instructions to discard nested/body scopes, then jump to post. Preserve the initializer scope so post can still read i. Break emits the same local cleanup but jumps to cleanup, which also exits the initializer scope. Jumping after that last cleanup instruction would leak it.

The normal lexical EXIT_SCOPE instructions remain in emitted code, but a taken jump skips them. Do not decrement the compiler's syntactic scope-depth counter when emitting cleanup for one runtime edge: later source statements are still compiled in the same lexical nesting.

Short-circuit join invariant

For a && b, branch on a; a falsey path pushes false. Otherwise evaluate b, normalize its truthiness to a boolean, and join. For a || b, a truthy path pushes true and skips b. Every executed path leaves exactly one boolean. nil || 7 yields true; 7 && nil yields false; zero is truthy in v4.

The compiler emits both operands, with RHS instructions behind jumps. It does not implement dead-code elimination. Analysis still rejects an undefined name in false && missing(), while a valid false && (1/0) emits division code that the VM skips.

Patching discipline

Jumps are absolute instruction indexes in the current code slice. Record a placeholder index when emitting a forward jump, then fill it when the target label's position is known. A for continue targets post when present, otherwise condition. A while continue always targets its condition. Each break uses the innermost loop's cleanup target.

Debugging lab

A compiler changes its own scopeDepth while emitting cleanup for a break in one branch. Subsequent code emits too few exits on a continue in the other branch. Why is source nesting different from one runtime path?

Compare your diagnosis after attempting the lab

The compiler is traversing both branches within the same lexical scopes, while only one executes. Emit the required runtime exits without changing the lexical bookkeeping used to compile sibling paths.

Transfer task

Draw a two-level loop with an inner break and an outer continue after the inner loop. Label each jump target and count runtime scope exits. Then trace both paths of false || true && false to verify one boolean at each join.

Carry forward

Carry a path-by-path stack/scope trace into the v4 capstone. An output-only test should be supplemented by a case that repeats continue many times to expose leaked scopes.

Build compile-bytecode-v4. Keep a before/after trace of one failing test. Use the checkpoint to diagnose specific skills; passing it does not replace implementing and testing the artifact.


Practice sequence

Use the order below for a guided path, or choose an item to revisit. A pass records a successful attempt; try a fresh example later to check what you can reconstruct.

Join Discord