Structs
Qué es un struct
Un struct es un registro de datos ligero: agrupa varios valores bajo nombres de campo, igual que las propiedades de una clase, pero con el conjunto de campos fijado en la declaración. A cambio de esa restricción, internamente los campos se almacenan por índice en un bloque compacto de memoria, sin tablas hash, lo que hace que un struct ocupe varias veces menos memoria que una instancia de clase y se cree mucho más rápido.
Los structs no sustituyen a las clases: las complementan. JARU ofrece tres formas de agrupar datos, cada una con su propósito:
| Contenedor | Campos | Uso ideal |
|---|---|---|
map | Dinámicos, cualquier clave en tiempo de ejecución | Datos cuya estructura no se conoce de antemano (JSON, configuraciones) |
class | Dinámicos, con métodos, herencia y encapsulación | Objetos con comportamiento: datos + lógica |
struct | Fijos, solo datos | Registros puros: puntos, entidades de juego, lecturas de sensores |
Si necesitas métodos, herencia o añadir propiedades sobre la marcha, usa una clase. Si solo necesitas agrupar datos —sobre todo si vas a crear muchos objetos en un ESP32— usa un struct.
Declaración
Un struct se declara con la palabra clave struct, seguida del nombre y de las declaraciones var de sus campos:
struct Punto
var x = 0
var y = 0
end
Se pueden declarar varios campos en una misma línea, y un campo sin valor inicial vale 0 (igual que cualquier var en JARU):
struct Enemigo
var x = 0, y = 0 // varios campos por línea
var vida = 100
var nombre = "orco"
var vivo = true
var objetivo // sin valor inicial: vale 0
end
El orden de declaración de los campos es parte del contrato del struct: determina el orden de los argumentos al crear instancias. Reordenar los campos de un struct cambia el significado de las llamadas existentes.
Un struct puede tener como mucho 127 campos: con más, el compilador da el error «A struct can't have more than 127 fields.».
Valores por defecto: solo literales constantes
Los valores por defecto de los campos deben ser literales constantes: números (con signo opcional), cadenas, true/false o nil. Cualquier expresión produce un error de compilación:
struct Config
var reintentos = 3 // ok: número
var velocidad = -1.5 // ok: número con signo
var modo = "auto" // ok: cadena
var depurar = false // ok: booleano
var dueno = nil // ok: nil
var pos = Punto() // ERROR: no es un literal
var cosas = [] // ERROR: no es un literal
var semilla = clock() // ERROR: no es un literal
end
Esta restricción tiene dos motivos. El primero es de rendimiento: al ser constantes, crear una instancia es una simple copia de memoria. El segundo es de seguridad: si un campo pudiera valer [] por defecto, todas las instancias compartirían la misma lista — una fuente clásica de errores difíciles de encontrar en otros lenguajes.
Cuando un campo necesita contener una lista, un mapa u otro struct, el patrón habitual es asignarlo después de crear la instancia, normalmente en una función fábrica:
func nuevoEnemigo(x, y)
var e = Enemigo()
e.x = x
e.y = y
e.objetivo = Punto(x, y) // el campo ESTÁ declarado; se rellena aquí
return e
end
Crear instancias
Un struct se instancia llamándolo como si fuera una función, igual que una clase. Los argumentos son posicionales: rellenan los campos en su orden de declaración, y los que falten toman su valor por defecto:
var a = Punto() // {x: 0, y: 0} todo por defecto
var b = Punto(3, 4) // {x: 3, y: 4} posicional
var c = Punto(7) // {x: 7, y: 0} parcial: el resto, por defecto
var d = Punto(1, 2, 3) // EXCEPCIÓN: Punto solo tiene 2 campos
Acceso a los campos y sellado
Los campos se leen y escriben con la notación de punto, como las propiedades de una clase:
var p = Punto(1, 2)
p.x = 10
p.y = p.x + 5
println(p.x, " ", p.y) // 10 15
La gran diferencia con las clases es que un struct está sellado: el conjunto de campos es fijo. Acceder a un campo que no existe lanza una excepción, también al escribir:
var e = Enemigo()
e.vdia = 50 // errata de 'vida': EXCEPCIÓN al instante
// Struct 'Enemigo' has no field 'vdia'
// (fields: x, y, vida, nombre, vivo, objetivo).
En una clase, e.vdia = 50 crea silenciosamente una propiedad nueva llamada vdia, y el error no se manifiesta hasta mucho más tarde y en otro lugar del programa. En un struct, la errata se detecta en la línea exacta donde ocurre, y el mensaje incluye la lista de campos válidos.
Semántica de referencia, copy y clone
Como el resto de objetos de JARU, asignar un struct copia la referencia, no el contenido:
var a = Punto(1, 2)
var b = a
b.x = 99
println(a.x) // 99: a y b son el mismo objeto
Para duplicar un struct están los métodos copy() (copia superficial: los contenedores anidados se comparten) y clone() (copia profunda: también se duplican listas, mapas y structs anidados), con el mismo contrato que en listas, mapas e instancias:
var original = nuevoEnemigo(5, 5)
var copia = original.copy() // campos duplicados, objetivo compartido
var clon = original.clone() // todo duplicado, también objetivo
clone() duplica las listas, los mapas, los arrays, las instancias y los structs que haya dentro; lo demás se comparte con el original.
Los bitmaps no se duplican: el clon usa el mismo bitmap que el original, también dentro de listas, mapas e instancias. Los píxeles de un bitmap no cambian después de Bitmap.load(), así que no se nota; lo único compartido que se puede cambiar es el color transparente, y clon.img.tcolor(c) también lo cambia en el original.
Los sprites tampoco se duplican, y ahí sí se nota: si mueves el sprite del clon, también se mueve el del original. Para tener otro sprite, créalo con Sprite.new(sprite) (ver Sprite).
clone() con structs que se apuntan entre síSi dentro de lo que clonas hay un ciclo, como un nodo que guarda a su padre en un campo, clone() no termina nunca de copiar: la VM se cierra sin ningún mensaje, y try no lo captura. copy() no tiene este problema.
Todos los problemas conocidos de JARU, juntos, están en Problemas conocidos.
La igualdad == compara referencias: dos structs distintos con los mismos valores no son iguales, igual que ocurre con [1, 2] == [1, 2].
Structs anidados y en contenedores
Un campo puede contener cualquier valor: otro struct, una lista, un mapa... Solo los valores por defecto están restringidos a literales; el contenido en tiempo de ejecución es libre:
struct Segmento
var a = nil
var b = nil
end
var s = Segmento(Punto(1, 2), Punto(3, 4))
println(s.a.x) // 1: acceso encadenado
var enemigos = [nuevoEnemigo(0, 0), nuevoEnemigo(10, 5)]
println(enemigos[1].x) // 10: structs dentro de listas
Al imprimir un struct se muestran sus campos con sus nombres:
println(Punto(3, 4)) // Punto{x: 3, y: 4}
Funciones en campos (callbacks)
Un struct no tiene métodos propios, pero como un campo admite cualquier valor, puede contener una función. Si guardas una función en un campo, puedes llamarla con la notación de punto, igual que harías con una propiedad de instancia:
func alDisparar(x, y)
println("disparo en ", x, ", ", y)
end
struct Boton
var etiqueta = ""
var alPulsar = nil // aquí guardaremos una función
end
var b = Boton()
b.etiqueta = "Fuego"
b.alPulsar = alDisparar // asignar la función al campo
b.alPulsar(100, 50) // llamarla: imprime disparo en 100, 50
La función guardada en el campo se invoca tal cual: no recibe el struct como this, porque no es un método del struct sino una función normal a la que da la casualidad de que se llega a través de un campo. Es exactamente el mismo mecanismo que ya existe para las propiedades de una instancia de clase.
Esto convierte a los structs en una forma cómoda y ligera de montar tablas de despacho o manejadores de eventos: cada entidad de un juego puede llevar su propia función de comportamiento en un campo, sin el coste de una clase.
Si el campo no contiene un valor invocable (por ejemplo un número), llamarlo lanza una excepción, igual que intentar llamar a cualquier otro valor no invocable. Y si el nombre no corresponde a ningún campo (ni a copy/clone), la llamada lanza has no field or method.
Memoria y rendimiento
La razón de ser de los structs es la eficiencia, especialmente en el ESP32, donde la memoria RAM es el recurso más escaso. Medido en Windows con memUse(), creando 10.000 objetos:
| Objeto | Como clase | Como struct | Diferencia |
|---|---|---|---|
| Punto (2 campos) | 184 bytes | 36 bytes | 5,1x menos |
| Entidad de juego (8 campos) | 440 bytes | 84 bytes | 5,2x menos |
Para un juego con 500 entidades de 8 campos, eso significa pasar de ~215 KB a ~41 KB: en una placa sin PSRAM puede ser la diferencia entre caber o no caber. Lo que queda libre en cada placa está en Memoria y límites.
La creación de instancias también es más rápida (en torno a 1,4x frente a una clase con constructor), porque no hay que ejecutar ningún método init ni construir tablas hash. La velocidad de acceso a los campos, en cambio, es prácticamente la misma que en una clase: la ventaja de los structs está en la memoria y en la creación, no en la lectura.
Lo que un struct no tiene
Deliberadamente, un struct no admite:
- Métodos, herencia ni sobrecarga de operadores — un struct con comportamiento es una clase: usa
class. (Sí puedes guardar una función en un campo y llamarla; ver Funciones en campos.) - Campos privados — todos los campos son públicos.
- Añadir o quitar campos en tiempo de ejecución — para claves dinámicas, usa un
map. - Recorrido con
foreach— un struct no es iterable: daERR_TYPE_MISMATCH(Object is not iterable.). - Constante de tipo — no hay una constante
t*para los structs:type()da23con un struct y24con su declaración (ver Saber el tipo).
Ejemplo completo
struct Bala
var x = 0, y = 0
var vx = 0, vy = -4
var activa = true
end
func mueveBalas(balas)
foreach (var b in balas)
if (b.activa) then
b.x = b.x + b.vx
b.y = b.y + b.vy
if (b.y < 0) then
b.activa = false
end
end
end
end
var balas = []
for (var i = 0; i < 100; i++)
balas.append(Bala(i * 3, 240))
end
mueveBalas(balas)
println(balas[0]) // Bala{x: 0, y: 236, vx: 0, vy: -4, activa: true}
Cien balas como structs ocupan una fracción de lo que ocuparían como instancias de clase, se crean casi al instante, y una errata en cualquier campo se detecta en el momento.