Skip to main content

Structs

What is a struct​

A struct is a lightweight data record: it groups several values under field names, just like the properties of a class, but with the set of fields fixed at declaration time. In exchange for that restriction, fields are stored internally by index in a compact memory block, with no hash tables, which makes a struct several times smaller than a class instance and much faster to create.

Structs do not replace classes: they complement them. JARU offers three ways to group data, each with its own purpose:

ContainerFieldsIdeal use
mapDynamic, any key at runtimeData whose structure is not known in advance (JSON, configuration)
classDynamic, with methods, inheritance and encapsulationObjects with behavior: data + logic
structFixed, data onlyPure records: points, game entities, sensor readings
Class or struct?

If you need methods, inheritance, or adding properties on the fly, use a class. If you just need to group data — especially if you are going to create many objects on an ESP32 — use a struct.

Declaration​

A struct is declared with the struct keyword, followed by its name and the var declarations of its fields:

struct Point
var x = 0
var y = 0
end

Several fields can be declared on the same line, and a field without an initial value is 0 (just like any var in JARU):

struct Enemy
var x = 0, y = 0 // several fields per line
var hp = 100
var name = "orc"
var alive = true
var target // no initial value: it is 0
end

The declaration order of the fields is part of the struct's contract: it determines the order of the arguments when creating instances. Reordering the fields of a struct changes the meaning of existing calls.

Default values: constant literals only​

The default values of the fields must be constant literals: numbers (with optional sign), strings, true/false or nil. Any expression produces a compile-time error:

struct Config
var retries = 3 // ok: number
var speed = -1.5 // ok: signed number
var mode = "auto" // ok: string
var debug = false // ok: boolean
var owner = nil // ok: nil

var pos = Point() // ERROR: not a literal
var items = [] // ERROR: not a literal
var seed = clock() // ERROR: not a literal
end

This restriction has two reasons. The first is performance: since defaults are constants, creating an instance is a simple memory copy. The second is safety: if a field could default to [], all instances would share the same list — a classic source of hard-to-find bugs in other languages.

When a field needs to hold a list, a map or another struct, the idiomatic pattern is to assign it after creating the instance, usually in a factory function:

func newEnemy(x, y)
var e = Enemy()
e.x = x
e.y = y
e.target = Point(x, y) // the field IS declared; it is filled here
return e
end

Creating instances​

A struct is instantiated by calling it like a function, just like a class. Arguments are positional: they fill the fields in declaration order, and any missing ones take their default value:

var a = Point()          // {x: 0, y: 0}   all defaults
var b = Point(3, 4) // {x: 3, y: 4} positional
var c = Point(7) // {x: 7, y: 0} partial: the rest to defaults

var d = Point(1, 2, 3) // EXCEPTION: Point only has 2 fields

Field access and sealing​

Fields are read and written with dot notation, like the properties of a class:

var p = Point(1, 2)
p.x = 10
p.y = p.x + 5
println(p.x, " ", p.y) // 10 15

The big difference from classes is that a struct is sealed: the set of fields is fixed. Accessing a field that does not exist throws an exception, also when writing:

var e = Enemy()
e.helth = 50 // typo of 'hp': EXCEPTION right away
// Struct 'Enemy' has no field 'helth'
// (fields: x, y, hp, name, alive, target).
Typo protection

In a class, e.helth = 50 silently creates a new property called helth, and the bug does not show up until much later, somewhere else in the program. In a struct, the typo is caught on the exact line where it happens, and the message includes the list of valid fields.

Reference semantics, copy and clone​

Like every other object in JARU, assigning a struct copies the reference, not the contents:

var a = Point(1, 2)
var b = a
b.x = 99
println(a.x) // 99: a and b are the same object

To duplicate a struct there are the copy() method (shallow copy: nested containers are shared) and clone() (deep copy: lists, maps and nested structs are duplicated too), with the same contract as lists, maps and instances:

var original = newEnemy(5, 5)
var copied = original.copy() // fields duplicated, target shared
var cloned = original.clone() // everything duplicated, target too

Equality == compares references: two different structs with the same values are not equal, just as [1, 2] == [1, 2] is false.

Nested structs and structs in containers​

A field can hold any value: another struct, a list, a map... Only the default values are restricted to literals; runtime contents are free:

struct Segment
var a = nil
var b = nil
end

var s = Segment(Point(1, 2), Point(3, 4))
println(s.a.x) // 1: chained access

var enemies = [newEnemy(0, 0), newEnemy(10, 5)]
println(enemies[1].x) // 10: structs inside lists

Printing a struct shows its fields with their names:

println(Point(3, 4))     // Point{x: 3, y: 4}

Functions in fields (callbacks)​

A struct has no methods of its own, but since a field can hold any value, it can hold a function. If you store a function in a field, you can call it with dot notation, just as you would with an instance property:

func onFire(x, y)
println("shot at ", x, ", ", y)
end

struct Button
var label = ""
var onClick = nil // we will store a function here
end

var b = Button()
b.label = "Fire"
b.onClick = onFire // assign the function to the field

b.onClick(100, 50) // call it: prints "shot at 100, 50"

The function stored in the field is invoked as-is: it does not receive the struct as this, because it is not a method of the struct but a regular function that just happens to be reached through a field. This is exactly the same mechanism that already exists for the properties of a class instance.

This makes structs a convenient, lightweight way to build dispatch tables or event handlers: each game entity can carry its own behavior function in a field, without the cost of a class.

note

If the field does not hold a callable value (a number, for example), calling it throws an exception, just like calling any other non-callable value. And if the name matches no field (nor copy/clone), the call throws has no field or method.

Memory and performance​

The reason structs exist is efficiency, especially on the ESP32, where RAM is the scarcest resource. Measured with memUse() creating 10,000 objects:

ObjectAs a classAs a structDifference
Point (2 fields)184 bytes36 bytes5.1x less
Game entity (8 fields)440 bytes84 bytes5.2x less

For a game with 500 entities of 8 fields, that means going from ~215 KB to ~41 KB: on a board with ~230 KB free, it is the difference between fitting or not fitting.

Instance creation is also faster (around 1.4x compared to a class with a constructor), because no init method runs and no hash tables are built. Field access speed, on the other hand, is practically the same as in a class: the advantage of structs is in memory and creation, not in reads.

What a struct does not have​

Deliberately, a struct does not support:

  • Methods or inheritance — a struct with behavior is a class: use class. (You can store a function in a field and call it; see Functions in fields.)
  • Private fields — all fields are public.
  • Adding or removing fields at runtime — for dynamic keys, use a map.
  • Iteration with foreach — a struct is not iterable.

Complete example​

struct Bullet
var x = 0, y = 0
var vx = 0, vy = -4
var active = true
end

func updateBullets(bullets)
foreach (var b in bullets)
if (b.active) then
b.x = b.x + b.vx
b.y = b.y + b.vy
if (b.y < 0) then
b.active = false
end
end
end
end

var bullets = []
for (var i = 0; i < 100; i++)
bullets.append(Bullet(i * 3, 240))
end

updateBullets(bullets)
println(bullets[0]) // Bullet{x: 0, y: 236, vx: 0, vy: -4, active: true}

One hundred bullets as structs take a fraction of what they would take as class instances, they are created almost instantly, and a typo in any field is caught on the spot.