Metatables
Metatables enable operator overloading, custom behavior, and object-oriented programming in Lua.
Metatable Basics
What is a Metatable?
A metatable is a regular table that defines special behavior for another table through metamethods.
local t = {}
local mt = {}
-- Set metatable
setmetatable(t, mt)
-- Get metatable
local mt2 = getmetatable(t)
-- Inline
local t = setmetatable({}, {})
Metamethods
Metamethods are special keys in metatables that Lua looks for when performing operations:
| Metamethod | Triggered By |
|---|---|
|
Accessing missing key |
|
Setting new key |
|
Calling table as function |
|
tostring() conversion |
|
Arithmetic operators |
|
Comparison operators |
|
# operator |
|
.. operator |
__index (Key Lookup)
-- __index as table (fallback/defaults)
local defaults = {
port = 443,
timeout = 30
}
local config = setmetatable({
hostname = "ise-01"
}, {
__index = defaults
})
print(config.hostname) -- "ise-01" (own key)
print(config.port) -- 443 (from defaults)
print(config.missing) -- nil (not in either)
-- __index as function (computed values)
local cache = setmetatable({}, {
__index = function(t, key)
print("Computing " .. key)
local value = expensive_compute(key)
t[key] = value -- Cache it
return value
end
})
Chained __index
-- Multiple inheritance / prototype chain
local animal = {
speak = function() print("...") end
}
local dog = setmetatable({
bark = function() print("Woof!") end
}, { __index = animal })
local mydog = setmetatable({
name = "Rex"
}, { __index = dog })
mydog.bark() -- "Woof!" (from dog)
mydog.speak() -- "..." (from animal)
__newindex (Key Assignment)
-- Track assignments
local tracked = setmetatable({}, {
__newindex = function(t, key, value)
print(string.format("Setting %s = %s", key, tostring(value)))
rawset(t, key, value) -- Actually set it
end
})
tracked.foo = "bar" -- Prints: Setting foo = bar
-- Read-only table
local readonly = setmetatable({
constant = 42
}, {
__newindex = function()
error("Table is read-only")
end
})
-- readonly.x = 1 -- Error!
-- Validation on assignment
local validated = setmetatable({}, {
__newindex = function(t, key, value)
if key == "port" and (type(value) ~= "number" or value < 1 or value > 65535) then
error("Invalid port: " .. tostring(value))
end
rawset(t, key, value)
end
})
__call (Callable Tables)
-- Make table callable like a function
local Greeter = setmetatable({
prefix = "Hello"
}, {
__call = function(self, name)
return self.prefix .. ", " .. name .. "!"
end
})
print(Greeter("Evan")) -- "Hello, Evan!"
Greeter.prefix = "Hi"
print(Greeter("Evan")) -- "Hi, Evan!"
-- Factory pattern
local Endpoint = setmetatable({}, {
__call = function(_, mac, ip, vlan)
return {
mac = mac,
ip = ip,
vlan = vlan
}
end
})
local ep = Endpoint("00:11:22:33:44:55", "10.50.10.100", 10)
__tostring
local endpoint = setmetatable({
mac = "00:11:22:33:44:55",
ip = "10.50.10.100",
vlan = 10
}, {
__tostring = function(self)
return string.format("Endpoint<%s @ %s VLAN %d>",
self.mac, self.ip, self.vlan)
end
})
print(endpoint) -- "Endpoint<00:11:22:33:44:55 @ 10.50.10.100 VLAN 10>"
Arithmetic Metamethods
local Vector = {}
Vector.__index = Vector
function Vector.new(x, y)
return setmetatable({x = x, y = y}, Vector)
end
function Vector:__add(other)
return Vector.new(self.x + other.x, self.y + other.y)
end
function Vector:__sub(other)
return Vector.new(self.x - other.x, self.y - other.y)
end
function Vector:__mul(scalar)
return Vector.new(self.x * scalar, self.y * scalar)
end
function Vector:__eq(other)
return self.x == other.x and self.y == other.y
end
function Vector:__tostring()
return string.format("Vector(%d, %d)", self.x, self.y)
end
local v1 = Vector.new(1, 2)
local v2 = Vector.new(3, 4)
print(v1 + v2) -- Vector(4, 6)
print(v2 - v1) -- Vector(2, 2)
print(v1 * 3) -- Vector(3, 6)
print(v1 == v1) -- true
OOP with Metatables
Basic Class
local Endpoint = {}
Endpoint.__index = Endpoint
function Endpoint.new(mac, ip, vlan)
local self = setmetatable({}, Endpoint)
self.mac = mac
self.ip = ip
self.vlan = vlan
self.status = "unknown"
return self
end
function Endpoint:is_active()
return self.status == "active"
end
function Endpoint:activate()
self.status = "active"
end
function Endpoint:__tostring()
return string.format("Endpoint(%s)", self.mac)
end
-- Usage
local ep = Endpoint.new("00:11:22:33:44:55", "10.50.10.100", 10)
ep:activate()
print(ep:is_active()) -- true
print(ep) -- Endpoint(00:11:22:33:44:55)
Inheritance
-- Base class
local Device = {}
Device.__index = Device
function Device.new(hostname, ip)
local self = setmetatable({}, Device)
self.hostname = hostname
self.ip = ip
return self
end
function Device:connect()
print("Connecting to " .. self.hostname)
return true
end
function Device:get_info()
return {hostname = self.hostname, ip = self.ip}
end
-- Derived class
local ISENode = setmetatable({}, {__index = Device})
ISENode.__index = ISENode
function ISENode.new(hostname, ip, roles)
local self = Device.new(hostname, ip)
setmetatable(self, ISENode)
self.roles = roles or {}
return self
end
function ISENode:is_pan()
for _, role in ipairs(self.roles) do
if role == "PAN" then return true end
end
return false
end
function ISENode:get_info()
local info = Device.get_info(self) -- Call parent
info.roles = self.roles
return info
end
-- Usage
local ise = ISENode.new("ise-01", "10.50.1.20", {"PAN", "MNT"})
ise:connect() -- "Connecting to ise-01" (inherited)
print(ise:is_pan()) -- true (own method)
Class Helper Function
-- Reusable class creation
local function class(parent)
local cls = {}
cls.__index = cls
if parent then
setmetatable(cls, {__index = parent})
end
function cls.new(...)
local self = setmetatable({}, cls)
if self.init then
self:init(...)
end
return self
end
return cls
end
-- Usage
local Device = class()
function Device:init(hostname, ip)
self.hostname = hostname
self.ip = ip
end
function Device:connect()
print("Connecting to " .. self.hostname)
end
local ISENode = class(Device)
function ISENode:init(hostname, ip, roles)
Device.init(self, hostname, ip)
self.roles = roles
end
function ISENode:is_pan()
return vim.tbl_contains(self.roles, "PAN")
end
local ise = ISENode.new("ise-01", "10.50.1.20", {"PAN"})
Raw Access
-- Bypass metamethods
local t = setmetatable({}, {
__index = function() return "default" end,
__newindex = function() error("read-only") end
})
print(t.foo) -- "default" (via __index)
print(rawget(t, "foo")) -- nil (bypass __index)
-- t.foo = "bar" -- Error via __newindex
rawset(t, "foo", "bar") -- Works (bypass __newindex)
Proxy Tables
-- Create a proxy that logs all access
local function make_proxy(target)
return setmetatable({}, {
__index = function(_, key)
print("GET: " .. key)
return target[key]
end,
__newindex = function(_, key, value)
print("SET: " .. key .. " = " .. tostring(value))
target[key] = value
end,
__pairs = function()
return pairs(target)
end
})
end
local data = {a = 1, b = 2}
local proxy = make_proxy(data)
print(proxy.a) -- GET: a, then prints 1
proxy.c = 3 -- SET: c = 3
Neovim Usage
-- Plugin with class-like structure
local M = {}
M.__index = M
function M.new(opts)
local self = setmetatable({}, M)
self.opts = vim.tbl_deep_extend("force", {
enabled = true,
}, opts or {})
return self
end
function M:setup()
if not self.opts.enabled then return end
-- Setup logic
end
return M
-- Usage in config:
-- require("myplugin").new({ enabled = true }):setup()
Next Module
Modules - require, module patterns, package path.