v5 Bytecode: compiling modules

Lesson, slides, and applied problem sets.

View Slides

Lesson

v5 Bytecode: compiling modules

Modules now lower into a per-module executable artifact with explicit import/export boundaries. This is the bridge between semantic module contracts and linker/runtime execution.


1) Module object shape and bytecode shape

A compiled module contains:

  • module name
  • import metadata
  • exported symbol list
  • function table fragment
  • module initialization bytecode (Main)

Why this shape:

  • import side effects and bindings are separated from value emission
  • linker can combine modules by explicit export objects
  • runtime does not need special-case module opcodes

2) Deterministic module init lowering

A canonical compile pattern:

ENTER_SCOPE
  emit import bindings
  emit module body declarations/expressions/statements
  collect exports in deterministic order
  for each exported name:
    PUSH_STR name
    LOAD name
  MAKE_MAP N
  DEFINE_CONST <moduleName>
EXIT_SCOPE

Important:

  • ENTER_SCOPE/EXIT_SCOPE isolate module locals.
  • exports are captured as a map value so module is a value object, not global namespace magic.
  • even empty export sets still emit MAKE_MAP 0 so module shape is consistent.

3) Import lowering and alias semantics

import math and import math as m become alias bindings in module scope.

  • if alias omitted, alias defaults to module identifier (math)
  • alias binding is generally immutable in this version (const-style)
  • analyzer already validates import existence/order; compiler translates that into direct alias definitions

Do not flatten imports. Keep import references as aliases that participate in existing identifier and indexing rules.


4) Export lowering and ordering

Exports must remain declaration-order deterministic. That means

  • collect exported names during semantic phase
  • emit map entries in stable order
  • emit PUSH_STR/LOAD pairs before MAKE_MAP

If the order changes, bytecode diffs become unstable and grader snapshots become noisy.


5) Member access as map read

No new instruction needed for m.name:

LOAD m
PUSH_STR "name"
GET_INDEX

This keeps module and map systems unified:

  • same opcode path for object-like access
  • same runtime contract for unknown keys
  • less VM surface area to debug and test

Function-call on member: m.name() lowers to member read + CALL.


DEFINE_CONST <moduleName> stores module object in local scope after body emission.

That module object contains at least:

  • exports map
  • function references
  • any captured runtime constants for module-local initialization behavior

Linker can then compose by module-import dependency without re-parsing source.


7) Invariants worth writing tests for

  • each module compiles exactly once, no re-parse in compiler path
  • import aliases are bound before use
  • module scope does not leak locals beyond scope end
  • empty export module still produces valid deterministic object
  • map construction count matches declared export count

8) Practice checkpoints (almost solved)

1) Empty export map + Module with no exports should still emit MAKE_MAP 0 and be loadable.

2) Import default alias + import math; and import math as m; both bind aliases and are available in member expressions.

3) Export ordering + export let a, export let b should emit map entries in a, then b.

4) Member lowering + m.x must become LOAD m, PUSH_STR "x", GET_INDEX in that order.

5) Module object binding + A compiled module should bind as immutable module constant in module init scope.

This module keeps module behavior explicit and stable before linker-level complexity increases.


Module Items

  • Compile to Bytecode v5

    Compile v5 modules into bytecode with export maps.

    hard Upgrade to Pro to access hard problems
Join Discord