Lua with Neovim: Closures — Upvalues, Counter Factory & Functions That Remember | Episode 16
Video: Lua with Neovim: Closures — Upvalues, Counter Factory & Functions That Remember | Episode 16 by Taught by Celeste AI - AI Coding Coach
Lua Closures: Functions That Remember
A function that captures variables from its enclosing scope. The captured variables (called upvalues) live as long as the closure does. Build a counter factory and a multiplier factory.
A closure is a function plus the environment it was created in. When you return a function from another function, the inner function "closes over" the outer function's locals — they keep existing after the outer function returns.
This is what lets us build factories, callbacks with state, and the foundation for OOP later.
The counter factory
function make_counter()
local count = 0
return function()
count = count + 1
return count
end
end
local counter_a = make_counter()
local counter_b = make_counter()
print("A: " .. counter_a()) -- 1
print("A: " .. counter_a()) -- 2
print("A: " .. counter_a()) -- 3
print("B: " .. counter_b()) -- 1
print("B: " .. counter_b()) -- 2
print("A: " .. counter_a()) -- 4
make_counter returns an anonymous function that reads and increments count. Even after make_counter returns, the returned function keeps count alive.
Each call to make_counter creates a new count. counter_a and counter_b are independent — each has its own private count.
What just happened
When you call make_counter(), three things happen:
- A new local variable
countis created on the stack. - An anonymous function is created that references
count. - The anonymous function is returned.
Normally, when a function returns, its locals are reaped. But because the returned function still references count, Lua keeps count alive for as long as the returned function exists. That captured variable is called an upvalue.
counter_a has its own count upvalue. counter_b has its own, independent count upvalue. They don't share state.
The multiplier factory
function make_multiplier(factor)
return function(x)
return x * factor
end
end
local double = make_multiplier(2)
local triple = make_multiplier(3)
print("double(5): " .. double(5)) -- 10
print("triple(5): " .. triple(5)) -- 15
print("double(10): " .. double(10)) -- 20
Same pattern: make_multiplier returns a function that captures factor. Each call to make_multiplier creates a new closure with a new captured factor.
double and triple look like different functions, but they share their body — the difference is the captured upvalue.
Closures are by reference, not by value
local x = 5
local fn = function() return x end
x = 10
print(fn()) -- 10
The closure captures the variable, not the value. Reassign x outside, the captured reference sees the new value.
This is mostly what you want, but it bites in loops:
local fns = {}
for i = 1, 3 do
fns[i] = function() return i end
end
print(fns[1]()) -- 1 (in Lua 5.0 this would be 4!)
print(fns[2]()) -- 2
print(fns[3]()) -- 3
In Lua 5.1+, each for iteration creates a fresh local i, so each closure captures a distinct value. Pre-5.0 (and many older languages) shared one i, so all closures returned the same final value. Modern Lua does the right thing.
If you ever need to manually capture-by-value:
local fns = {}
for i = 1, 3 do
local copy = i
fns[i] = function() return copy end
end
Each iteration creates its own copy.
Two closures sharing an upvalue
function make_account(balance)
local function deposit(n)
balance = balance + n
return balance
end
local function withdraw(n)
balance = balance - n
return balance
end
return deposit, withdraw
end
local d, w = make_account(100)
print(d(50)) -- 150
print(w(30)) -- 120
print(d(10)) -- 130
Two functions can share the same upvalue. Both deposit and withdraw see and mutate the same balance. They're closures over a shared environment.
This is the foundation of "objects" in Lua — a table of methods sharing state, just expressed as separate functions.
Returning a table of closures (proto-OOP)
function make_account(balance)
return {
deposit = function(n) balance = balance + n; return balance end,
withdraw = function(n) balance = balance - n; return balance end,
get_balance = function() return balance end,
}
end
local acc = make_account(100)
acc.deposit(50)
acc.deposit(25)
print(acc.get_balance()) -- 175
The table holds three closures. They all share the upvalue balance. From outside, balance is hidden — only the methods can touch it. That's encapsulation, no class or private keywords needed.
This is a real pattern in Lua. We'll see it again with metatables in episode 29 — but closures alone get you far.
Closures and memory
Each closure holds references to its upvalues. An upvalue stays alive as long as any closure references it. So:
function make_pair()
local big_array = {}
for i = 1, 1_000_000 do big_array[i] = i end
return function() return big_array[1] end
end
The returned closure keeps big_array alive even though we only need [1]. To release the rest, capture only what's needed:
function make_pair()
local big_array = {}
for i = 1, 1_000_000 do big_array[i] = i end
local first = big_array[1]
return function() return first end
end
Now big_array is released after make_pair returns; the closure only keeps first.
Common stumbles
Expecting "by value" capture. Closures capture references; mutations outside affect the captured variable.
Sharing state across closures unintentionally. If two closures should be independent, return them from separate factory calls — not the same one.
Memory leaks via closures. A closure holds onto everything it references. Captured-but-unused locals stay alive.
Recursion through closures. A local function self-references work; assigning to a local first then a function literal does not. Use local function f(...) ... end or the two-step local f; f = function(...) ... end.
Confusing closures with classes. Closures give you data + behaviour, but they're not proper classes. They're a lighter-weight alternative for many cases.
What's next
Episode 17: tables as arrays. Lua's universal data structure. 1-based indexing, table.insert, table.remove, # length, and the basic patterns for working with sequences.
Recap
A closure = function + the environment it was created in. Captured variables are upvalues — they live as long as the closure does. Each call to the outer function creates new upvalues, so each closure has its own state. Closures capture by reference, not by value. Two closures from the same factory share state; two from separate factories don't. The factory + closures pattern gets you encapsulation without explicit classes.
Next episode: tables as arrays.