The Warm-Up: An Evening With Lua

Before you implement a language, spend one evening being a user of the language you are imitating. This chapter is three hours of hands-on Lua 5.4 designed around a single question: which of these behaviors will be hard to implement, and why?

Every experiment here maps onto a specific lab later. When you meet the lab, you will already know what the behavior is supposed to be, which turns "what should this do?" into "why does it do that?" — a much better question.

# macOS
brew install lua
# Debian / Ubuntu
sudo apt install lua5.4 lua5.4-doc
# verify — this curriculum assumes 5.4.x
lua -v

Warning: Lua 5.1, 5.3, and 5.4 differ in ways that matter here. 5.3 introduced the integer/float split; 5.4 refined it and added new opcodes. LuaJIT is 5.1-compatible and does not have integers in the 5.4 sense. If lua -v says anything but 5.4, install 5.4 before continuing, or the experiments below will give different answers.


Predict First

Write these down — file, comment, paper, does not matter, but write them, with a confidence level of high / medium / guessing. You will check them as you go.

  1. In Lua 5.4, is 3 / 2 equal to 1 or 1.5?
  2. Is 1 == 1.0 true?
  3. What is #t when t = {1, 2, nil, 4}?
  4. Two closures made by the same call to a factory function — do they share the captured variable, or get a copy each?
  5. local a, b, c = f() where f returns two values. What is c?
  6. What does "10" + 5 evaluate to, and is that a good idea?
  7. Does pairs(t) visit keys in insertion order?

Experiment 1: Numbers Have Two Subtypes

-- numbers.lua
print(math.type(3), math.type(3.0), math.type("3"))
print(3 / 2)          -- division
print(3 // 2)         -- floor division
print(7 % 3, -7 % 3)  -- modulo follows the divisor's sign
print(1 == 1.0)
print(math.maxinteger, math.mininteger)
print(math.maxinteger + 1 == math.mininteger)
print(2^53 + 1.0 == 2^53)          -- float precision
print(string.format("%d", 3.0))    -- 3.0 has an exact integer representation
-- print(string.format("%d", 3.5)) -- uncomment: this is an error, not a truncation
lua numbers.lua

What to notice.

  • / always produces a float, even for 4 / 2. // is the integer-preserving one. This single decision removes an entire category of "why did my index become 2.0" bug.
  • 1 == 1.0 is true — equality compares mathematical values across subtypes — but math.type distinguishes them, and a table key of 1.0 is normalized to the integer 1. Hold onto that; it is a lab step in Section 4.
  • Integer arithmetic wraps on overflow. It does not promote to float, and it does not error.
  • Converting a float with a fractional part to an integer where an integer is required is an error, not a silent truncation.

Why it matters for you: Ember copies this model (ADR-005). Section 2 makes you implement the coercion table, and it is the first place where "dynamic typing" stops being a vague idea and becomes forty lines of decisions you must get exactly right.


Experiment 2: There Is Only One Data Structure

-- tables.lua
local t = {10, 20, 30, name = "alice", [100] = "sparse"}

print(#t)                      -- the "length" of the array part
print(t[1], t.name, t["name"]) -- t.name and t["name"] are identical
print(t[100], t[4])

-- The array part and the hash part in one object:
for i, v in ipairs(t) do print("ipairs", i, v) end
for k, v in pairs(t)  do print("pairs ", k, v) end

-- The famous one:
local holey = {1, 2, nil, 4}
print("#holey =", #holey)      -- run this. Then read the manual on the # operator.

-- Object-ish, using a table of functions:
local Account = {}
Account.__index = Account
function Account.new(balance) return setmetatable({balance = balance}, Account) end
function Account:deposit(n) self.balance = self.balance + n end

local a = Account.new(100)
a:deposit(50)                  -- sugar for a.deposit(a, 50)
print(a.balance)               -- 150 — but `deposit` is not IN `a`. Where is it?

What to notice.

  • t.name is exactly t["name"]. There are no fields, no properties, no attributes — there is one indexing operation on one data structure. This is the central simplification of Lua, and Ember copies it.
  • #holey is unspecified when the array has holes. The manual says # returns "a border". Run it and see what your build returns, then read §3.4.7 of the Lua 5.4 manual. A language spec that admits to nondeterminism in a common operation is telling you something about the implementation: the array part has a length, and the length is not the same as "how many things you put in".
  • a.deposit is not a key of a. The lookup missed, so Lua consulted a's metatable, found __index = Account, and looked there. That is the entire object system.

Why it matters for you: Lab 13 builds the hybrid table and Lab 18 builds __index. When you implement the lookup path you will implement exactly the four steps you just watched happen.


Experiment 3: Closures Share, They Do Not Copy

-- closures.lua
local function make_counter()
  local count = 0
  local function inc() count = count + 1; return count end
  local function get() return count end
  return inc, get
end

local inc, get = make_counter()
print(inc(), inc(), inc())   -- 1 2 3
print(get())                 -- 3   <- SHARED, not copied

local inc2, get2 = make_counter()
print(get2())                -- 0   <- a different call, a different `count`

-- The classic loop question. PREDICT before running.
local fs = {}
for i = 1, 3 do fs[i] = function() return i end end
print(fs[1](), fs[2](), fs[3]())

What to notice.

  • inc and get share one count. That variable was a local on the stack inside make_counter, and make_counter has returned — so the stack slot is gone. The value survived anyway. How?
  • In the loop, each iteration of a Lua numeric for creates a fresh i, so you get 1 2 3. Compare with JavaScript's var (one shared binding, famously surprising) versus let (fresh per iteration, like Lua). The difference is entirely about when a new binding is created, and it is a compiler decision.

Why it matters for you: this is the single hardest concept in the curriculum and it gets two chapters and a lab: closures, upvalues, and Lab 14. The answer to "how did the value survive?" is the upvalue was closed — the value was copied off the dying stack frame into a heap cell that both closures point at. Write down your guess now.


Experiment 4: Multiple Returns Are Not a Tuple

-- multret.lua
local function three() return 1, 2, 3 end

local a, b, c, d = three()
print(a, b, c, d)                -- 1 2 3 nil    -- adjusted UP with nil

local x, y = three()
print(x, y)                      -- 1 2          -- adjusted DOWN

print(three())                   -- 1 2 3        -- last in a list: ALL values
print(three(), "end")            -- 1 end        -- NOT last: truncated to one
print((three()))                 -- 1            -- parentheses truncate. Always.

local t = {three()}              print(#t)       -- 3
local u = {three(), "x"}         print(#u)       -- 2

local function sum(...)
  local n = select('#', ...)     -- the count, INCLUDING nils
  local total = 0
  for i = 1, n do total = total + (select(i, ...) or 0) end
  return total, n
end
print(sum(1, 2, 3, nil, 5))

What to notice.

  • A function call's result count is decided by where the call appears, not by the function. That is a syntactic rule with deep runtime consequences.
  • (f()) truncating to one value is not a quirk; it is the rule that "parentheses produce exactly one value" applied consistently.
  • select('#', ...) counts trailing nils, but #{...} does not. The distinction between "how many values were passed" and "how long is this table" is real and it will bite you.

Why it matters for you: multiple returns are the reason a call instruction cannot have a fixed stack effect, which is the reason the VM needs a notion of "the top of the current expression" that a simple stack machine does not. Lab 17 is where you feel it. Lua's own bytecode uses a sentinel value in the operand to mean "all of them", and you will end up doing something similar.


Experiment 5: Look at Real Bytecode

This is the best twenty minutes of the evening. luac ships with Lua.

cat > add.lua <<'EOF'
local x = 10 + 20
local function add(a, b) return a + b end
return add(x, 1)
EOF

luac -l  add.lua      # instructions
luac -l -l add.lua    # instructions + constants + locals + upvalues

You will see something close to this (exact opcodes and numbers vary by patch release — run it, do not trust this listing):

main <add.lua:0,0> (7 instructions at 0x…)
0+ params, 4 slots, 1 upvalue, 2 locals, 1 constant, 1 function
        1       [1]     VARARGPREP      0
        2       [1]     LOADI           0 30
        3       [2]     CLOSURE         1 0     ; 0x…
        4       [3]     MOVE            2 1
        5       [3]     MOVE            3 0
        6       [3]     LOADI           4 1
        7       [3]     CALL            2 3 0
        8       [3]     RETURN          2 0 1

Four things to notice, and each one is a chapter of this curriculum:

  1. LOADI 0 30. You wrote 10 + 20. There is no ADD. Lua's parser constant-folded it at compile time. Ember will not do this at first — Lab 10 emits LOAD_CONST, LOAD_CONST, ADD — and then Section 7 adds folding and you measure what it buys. Noticing this now means you will not be confused later when your disassembly looks "worse" than Lua's.
  2. MOVE 2 1, MOVE 3 0. Operands are numbered registers, not a stack. 0 is x, 1 is add. Lua is a register machine; Ember starts as a stack machine and the difference is visible right here, in the shape of the listing.
  3. The names are gone. x and add became 0 and 1. luac -l -l will show you a locals table that maps them back for the debugger, but the executing code never consults it. Names are a compile-time concept. This is the single most clarifying fact in Section 3.
  4. CALL 2 3 0. Three operands: the register holding the function, the number of arguments +1, and the number of results +1 — where 0 means "all of them, however many there are". That is the multiple-return machinery from Experiment 4, encoded.

Now try it on the closure from Experiment 3:

luac -l -l closures.lua | head -40

Look for GETUPVAL / SETUPVAL and the upvalues listing. That is the mechanism whose absence you were puzzling over. You have just seen the answer before the question.


Experiment 6: Errors Have Locations

-- errors.lua
local function inner(t) return t.x * 2 end
local function outer() return inner(nil) end
print(outer())
lua errors.lua
lua: errors.lua:1: attempt to index a nil value (local 't')
stack traceback:
        [C]: in ?
        errors.lua:1: in upvalue 'inner'
        errors.lua:2: in local 'outer'
        errors.lua:3: in main chunk
        [C]: in ?

What to notice. That message contains four separate pieces of engineering:

PieceWhat it required
errors.lua:1A line number stored per instruction, surviving compilation
attempt to index a nil valueA typed runtime error, not a panic
(local 't')Debug information mapping a register back to a source name
the tracebackA walk of the call frames, with each frame knowing its function and current line

Ember produces something better than this — with a source span and a caret — and Lab 24 is where you build it. But everything in that table has to be designed in from Section 1, because you cannot retrofit spans. That is why Ember's very first Token struct has a span field, in Lab 1, before there is anything to report an error about.

Now compare with a protected call:

local ok, err = pcall(function() return nil + 1 end)
print(ok, err)      -- false   errors.lua:N: attempt to perform arithmetic on a nil value

pcall turns an error into a value. In Ember, that is Result all the way down, and the boundary where a Result becomes a script-visible value is a design decision you will make in Section 5.


Experiment 7: Watch the Collector

-- gc.lua
local function kb() return collectgarbage("count") end

collectgarbage()               -- full collection, settle the baseline
print("baseline", kb())

local keep = {}
for i = 1, 100000 do keep[i] = {i} end     -- 100k live tables
print("after alloc", kb())

keep = nil
print("still held?", kb())     -- the memory is unreachable but not yet freed
collectgarbage()
print("after collect", kb())

-- Now the cycle that reference counting cannot handle:
local a, b = {}, {}
a.other, b.other = b, a
a, b = nil, nil
collectgarbage()
print("after cyclic collect", kb())

What to notice. Between keep = nil and collectgarbage(), the memory is garbage but not collected. Those are different states, and the gap between them is what a collector's scheduling policy is about. The cyclic pair is freed too — which reference counting alone could not do.

Why it matters for you: Lab 15 builds this, including collectgarbage("count") and a heap census that shows you object counts by type. Watching the number go down when you expect it to is one of the more satisfying moments in the curriculum.


Experiment 8: The Coercion You Will Have Opinions About

print("10" + 5)          -- 15     string coerced to number in arithmetic
print(10 .. 5)           -- "105"  numbers coerced to string in concatenation
print("10" == 10)        -- false  equality does NOT coerce
print(tostring(1/0), tostring(0/0))

Automatic string→number coercion in arithmetic is a 1993 decision that most modern languages have walked away from, and Lua keeps it for compatibility. Ember does not: "10" + 5 is a type error with a span pointing at the string.

That is a divergence, it is deliberate, and it goes in appendix/lua-differences.md with a one-line rationale. Get used to that pattern — every time you choose not to copy Lua, you write it down. A language with undocumented divergences from its inspiration is a language nobody can port code to.


Deliverables

  • All eight experiment scripts run, with your written predictions next to the actual results.
  • docs/learning/00-warmup.md created, listing every prediction you got wrong and the false belief behind each. Not "I forgot" — the actual belief.
  • luac -l -l output for add.lua and closures.lua saved into your notes, annotated with the four observations from Experiment 5 in your own words.
  • One paragraph: which of these eight behaviors do you expect to be hardest to implement, and why? Seal it. You will re-read it at the capstone.

Validation / Self-check

  1. Why does Lua have both / and //, and what bug class does that eliminate?
  2. t = {1, 2, nil, 4}. Why is #t unspecified, and what does that tell you about the implementation?
  3. Two closures returned from one call to a factory: do they share state? What must the implementation have done for that to be true after the factory returned?
  4. Give three contexts where a call returning three values yields a different number of values, and state the rule.
  5. In luac -l output, where did the variable names go? What still knows them, and who reads it?
  6. Name the four pieces of engineering visible in a single Lua error message.
  7. What is the difference between memory being garbage and being collected?
  8. Name one Lua behavior Ember deliberately does not copy, and where that gets documented.

Next: Milestone 0 — The Runtime Mental Model. That chapter is the one to read slowly.