Records and structs#
Two declarations share one syntax and compile to different things. A record is a Lua table with a nominal name. A struct is FFI cdata with a fixed C layout. Choosing between them is choosing a representation.
record struct
──────────────────── ───────────────────────── ─────────────────────────
Runtime Lua table + metatable FFI cdata
Field access Hash lookup Offset
Field types Anything C-representable only
Construction new R(x = 1) new S(x = 1) or S(1, 2)
Uninitialized Not nil-able; needs a value Zero-initialized
Garbage collected Yes Managed by the FFI
Array part {T} Allowed Rejected
Property capabilities `readonly` / `writeonly` Ordinary fields only
Nested declarations Allowed Rejected
Inline methods Yes Yes, via ffi.metatype
metamethod contracts Yes Rejected
`is` runtime test getmetatable(v)?.__index == R ffi.istype(S, v)Records#
That lowers to what you would have written:
const Point = {} Point.__index = Point
local p = setmetatable({ x = 3, y = 4 }, Point)The declaration's runtime table is the type's identity, which is what lets p is Point compile to a getmetatable comparison.
An instance is a value that came from the declaration, not only one the declaration stamped itself. A constructor may link back rather than stamping — giving instances their own metatable whose __index is the record, which is how a prototype-style registrar builds them — and those are instances too. The test reaches the record through __index so that both arrive at the same answer, which works because a record is its own prototype: R.__index = R is emitted with it.
A value with no metatable, another record's instance, and the declaration's own table all answer false.
The name holds the table#
Point is a type and also a value: the runtime table above. That table is the metatable its instances carry, so the value has the type of one.
Neither stands where the other is wanted. Point may be passed to setmetatable, held under a metatable<Point> annotation, and written to when a metamethod contract is installed; p may not. p may be read for its fields and passed where a Point is wanted; Point may not. Point is Point is answered without running, because a declaration's own table is never one of the values it stamps.
Reaching a member through the table reaches the record's, so Point.length, Point.make(...) and a nested Point.Inner all resolve as they always did. A function that takes a declaration's table rather than an instance says so:
Construction is by name. A record has no positional form, because field order in a table is not meaningful.
Record fields may expose independent read and write views, including distinct types for the two operations. See property capabilities.
Inline methods and static functions#
An inline function whose first parameter is named self is an instance method and is emitted on the ordinary method namespace. Without that parameter it is a static function, called through the declaration table with .. Inline signatures are hoisted before any body is checked, so methods may call each other in any order.
self is a type binder scoped to the declaration, so a method returning self returns the concrete receiver type rather than the declaring type.
A function-typed field is a declaration without a body, which supports late assignment:
Inline methods are not metamethod definitions, even when their names begin with __. Use metamethod for that; see interfaces.
Recursive and nested declarations#
Inside its own body a declaration answers to its simple name:
Records may nest other declarations, which reach through the table their owner sits on:
Structs#
becomes
const __nuppMt_Vec2 = {__index = {}}
const Vec2 = ffi.metatype(ffi.typeof("struct { float x; float y; }"), __nuppMt_Vec2)Real memory, real widths. A float field truncates the way a C float does.
Three construction forms:
A struct binding is never nil, so the third form is complete on its own.
What a struct field may hold#
The field type has to be reifiable — something with a C layout:
- the numeric primitives:
number,float,boolean,integer, and the sized integersint8throughuint64; - another struct, by value;
- any pointer
T*, and the nullableT*?; - a fixed C array
T[N].
Everything GC-managed is refused with NUPP2201: string, {T}, function types, and even number?, because an optional needs a representation the C layout does not have. cstring and voidptr are allowed in a cdef struct but not in a Nupp struct, since a GC-managed struct gives them no anchor.
Fixed arrays#
T[N] sits inline — N elements in the struct's own bytes, with no indirection, which is how a C struct carries a vector:
Vertex is 24 bytes: twelve for pos, eight for uv, four for id, and no padding. The elements are zero-based, like every C array.
The element may itself be a struct, and it is stored by value:
T[?] is not a field. A variable-length array has no size, so a struct holding one would have none either; that is NUPP2201. Use a pointer and hold the count yourself, which is what C does.
Pointing at itself#
A struct may hold a pointer to its own declaration, which is how a linked structure is written:
By value it cannot — next: Node would have to contain a copy of itself and so has no size. That is NUPP2201, and the repair it names is the pointer.
Value or reference#
A struct is a value type in memory, and a nested struct field is stored by value. But a struct held in a Lua variable is a reference to that cdata, so passing one to a function and mutating a field is visible to the caller. Copy explicitly when you want a copy.
Choosing#
Reach for a record when you want identity, dynamism, arbitrary field types, metamethod contracts, or ordinary GC. That is most application code.
Reach for a struct when the layout matters: interop with C, a large array of small values, or a hot field access you want to be an offset instead of a hash lookup.
layoutof(T) reports how one is laid out — see C interop. That is what lets a codec, a snapshot writer or a GPU vertex-attribute descriptor be derived from the declaration rather than maintained beside it, and it is worth knowing about before writing the second one by hand.
Both are nominal. Two records with identical fields are different types, and neither is assignable to the other. A record does erode into a structural shape with the same fields — width subtyping works one way only.
Diagnostics#
- NUPP2201 — a struct field is not reifiable, or a struct nests a declaration.
- NUPP2202 — a construction problem: an unknown field, a missing one, or a positional argument to a record.
- NUPP2204 / NUPP2205 — array-part problems.
- NUPP2118 — a duplicate member, or a metamethod contract on a struct.