wandres.dev
VALORES Y TIPOS · ocho tipos y ya

Valor frente a referencia: qué se copia y qué se comparte

La frontera exacta entre los cuatro tipos que se copian y los cuatro que se comparten, cómo funciona la identidad de tablas y funciones, por qué las cadenas parecen objetos y se comportan como valores, y qué ocurre al comparar con igualdad dos valores de tipos distintos.

⏱ 18 min

La pregunta «qué pasa exactamente cuando asigno una variable a otra» tiene en Lua dos respuestas distintas según el tipo del valor, y la línea que las separa atraviesa el inventario de ocho tipos por la mitad. Cuatro tipos se copian: nil, boolean, number y string. Los otros cuatro no se copian nunca, ni al asignar, ni al pasar como argumento, ni al devolver: table, function, userdata y thread son objetos, y las variables solo guardan referencias a ellos. Esta lección traza esa frontera con precisión, examina qué significa la identidad para cada lado y termina en la regla de igualdad entre tipos distintos, que es más severa de lo que casi nadie espera.

🎯 Al terminar esta lección sabrás
  • Clasificar los ocho tipos según se copien o se compartan al asignarse.
  • Predecir los efectos del aliasing y distinguir copia superficial de copia profunda.
  • Razonar sobre la identidad de tablas y funciones, y sobre lo que el metamétodo de igualdad puede y no puede alterar.
  • Aplicar la regla de igualdad entre tipos distintos, incluida la excepción de los subtipos numéricos.

Los que se copian y los que se comparten

El manual de Lua lo formula así: tablas, funciones, hilos y userdata completo son objetos, y las variables no contienen esos valores sino referencias a ellos. Los cuatro tipos restantes son valores y viven, conceptualmente, dentro de la variable.

local a = 10
local b = a
b = b + 1
print(a, b)        --> 10   11    (a no se enteró de nada)

local t = { n = 10 }
local u = t
u.n = u.n + 1
print(t.n, u.n)    --> 11   11    (t y u son la misma tabla)

La asignación u = t no duplicó nada: copió una referencia de ocho bytes. La tabla existe una sola vez en el montón y ahora tiene dos nombres. Esto se llama aliasing y es el mecanismo por el que un cambio hecho dentro de una función se ve fuera de ella.

flowchart LR
A[Variable a] --> T[Tabla unica en el monton]
B[Variable b] --> T
C[Variable c] --> U[Otra tabla distinta aunque vacia igual]
style T fill:#89b4fa,color:#11111b
style U fill:#a6e3a1,color:#11111b

Lua no tiene paso por referencia ni paso por valor en el sentido clásico: siempre pasa el valor del argumento, solo que para los objetos ese valor es una referencia. La distinción práctica es que dentro de una función puedes mutar la tabla que te pasaron y el efecto se ve fuera, pero reasignar el parámetro no cambia nada afuera.

local function mutar(t) t.x = 1 end      -- se ve desde fuera
local function reemplazar(t) t = {} end  -- no se ve: solo cambia la ranura local

Copiar una tabla, cuando de verdad quieres una copia, hay que hacerlo explícitamente, y aquí aparece la diferencia entre copia superficial y profunda. La superficial duplica el primer nivel y sigue compartiendo todo lo anidado.

local function copiaSuperficial(t)
  local r = {}
  for k, v in pairs(t) do r[k] = v end
  return r
end

local orig = { nombre = "ada", tags = { "a", "b" } }
local copia = copiaSuperficial(orig)
copia.nombre = "grace"          -- solo afecta a la copia
copia.tags[1] = "z"             -- afecta a las dos: tags se comparte
print(orig.tags[1])             --> z

Una copia profunda debe recorrer recursivamente los valores de tipo tabla y, si el grafo puede tener ciclos, llevar un registro de lo ya copiado para no entrar en recursión infinita. Conviene decidir además qué hacer con la metatabla: compartirla suele ser lo correcto, duplicarla casi nunca.

La identidad de tablas y funciones

Para los cuatro tipos objeto, == compara identidad, no contenido. Dos tablas con exactamente las mismas claves y los mismos valores son distintas si fueron creadas por separado.

print({} == {})                 --> false
print({1,2} == {1,2})           --> false

local t = {}
print(t == t)                   --> true, misma referencia

Ese comportamiento se puede reescribir con el metamétodo de igualdad, pero con dos restricciones que conviene tener presentes. La primera es que solo se consulta cuando ambos operandos son tablas, o ambos son userdata completo, y no son ya idénticos. La segunda es que el resultado se convierte a booleano, así que devolver una tabla desde el metamétodo produce true.

local Punto = {}
Punto.__eq = function(a, b) return a.x == b.x and a.y == b.y end

local p = setmetatable({ x = 1, y = 2 }, Punto)
local q = setmetatable({ x = 1, y = 2 }, Punto)
print(p == q)          --> true, lo decidio el metametodo
print(rawequal(p, q))  --> false, la identidad real no cambio

rawequal es la vía para preguntar por identidad sin que ningún metamétodo intervenga, y es la comprobación que necesitas cuando implementas un centinela o un registro de objetos ya visitados.

Las funciones también tienen identidad, y aquí hay un matiz que casi ningún tutorial menciona. Cada evaluación de una expresión de función crea normalmente un cierre nuevo, pero la especificación permite que la implementación reutilice un cierre cuando comparte prototipo y upvalues con otro que ya existe. La consecuencia es que no debes construir lógica sobre la suposición de que dos cierres creados en el mismo punto son necesariamente distintos.

local function fabricar(n)
  return function() return n end
end

print(fabricar(1) == fabricar(1))   -- false: upvalues distintos
print(print == print)               -- true: es el mismo objeto funcion

Lo que sí es garantía sólida y usarás a diario: registrar una función como manejador y luego querer desregistrarla exige conservar la referencia exacta, porque volver a escribir la misma definición produce otro objeto.

Cadenas: comparten memoria y se comportan como valores

Las cadenas son el caso interesante, porque internamente son objetos gestionados por el recolector de basura y sin embargo se comportan como valores puros. La razón es la inmutabilidad: como ninguna operación puede modificar una cadena existente, compartir memoria entre dos cadenas iguales es indetectable desde el programa.

local s1 = "hola"
local s2 = "ho" .. "la"
print(s1 == s2)          --> true, se comparan por contenido
print(#s1)               --> 4

La implementación aprovecha esto agresivamente: las cadenas cortas se internalizan en una tabla global, de modo que dos cadenas cortas iguales son literalmente el mismo objeto y compararlas cuesta una comparación de punteros. Las cadenas largas no se internalizan y se comparan por contenido, con la longitud y un hash calculado bajo demanda como filtros previos. Nada de esto es observable salvo en el perfil de rendimiento, y es la razón de que usar cadenas como claves de tabla sea barato en Lua.

Lo que sí es observable es que no puedes mutar una cadena. Toda operación textual construye una cadena nueva, así que concatenar dentro de un bucle largo tiene coste cuadrático y la solución idiomática es acumular en una tabla y unir al final.

local partes = {}
for i = 1, 1000 do partes[#partes + 1] = tostring(i) end
local resultado = table.concat(partes, ",")

Igualdad entre tipos distintos

La regla es tajante: si los operandos son de tipos diferentes, == devuelve false sin convertir nada y sin consultar ningún metamétodo. No hay coerción en la comparación, aunque sí la haya en la aritmética.

print("1" == 1)        --> false
print(nil == false)    --> false
print(0 == false)      --> false
print("" == nil)       --> false

La única excepción aparente son los números, y no es una excepción real: entero y flotante son subtipos del mismo tipo, así que compararlos es una comparación intratipo. Lua además la hace de forma matemáticamente exacta, sin convertir el entero a flotante, lo que evita los falsos positivos clásicos de otros lenguajes en el extremo del rango.

print(1 == 1.0)                            --> true
print(math.type(1), math.type(1.0))        --> integer   float
print(math.maxinteger + 0.0 == math.maxinteger)   --> false, comparacion exacta

Esa igualdad numérica se propaga a las claves de tabla: un flotante con valor entero exacto se normaliza a entero al usarse como clave, así que t[2] y t[2.0] son la misma entrada. Una cadena nunca se normaliza, así que t["2"] es una entrada diferente.

La frontera copia y referencia es en realidad una frontera sobre la mutabilidad

Merece la pena preguntarse por qué la línea cae exactamente donde cae, porque no es arbitraria y no responde al tamaño del valor. Una cadena de un megabyte se comporta como valor y una tabla vacía se comporta como objeto, de modo que el criterio no puede ser el coste de copiar. El criterio real es la mutabilidad: los cuatro tipos con semántica de valor son inmutables, y los cuatro con semántica de referencia son mutables o tienen estado interno. Y en cuanto un valor es inmutable, la distinción entre copiar y compartir deja de ser observable desde el programa: ningún experimento escrito en Lua puede distinguir si dos cadenas iguales ocupan la misma memoria o dos zonas distintas, porque no hay ninguna operación capaz de cambiar una sin producir otra. Eso libera al implementador para elegir la representación más eficiente en cada caso, y de hecho elige distinto según la longitud. Los tipos mutables no tienen esa libertad: si dos tablas iguales fueran la misma, escribir en una se vería en la otra, y eso sí es detectable, de modo que la identidad tiene que ser real y observable. De ahí se sigue todo lo demás casi mecánicamente. La igualdad estructural solo puede ser el valor por defecto para lo inmutable, porque para lo mutable la estructura cambia con el tiempo y una igualdad que hoy es cierta puede ser falsa dentro de una línea. Y por eso el metamétodo de igualdad existe pero se aplica únicamente a tablas y userdata: te deja declarar que tu tabla se comportará como un valor, asumiendo tú la responsabilidad de no mutarla, algo que el lenguaje no puede verificar por ti.

⚔️ Mide la frontera
  1. Escribe una función que reciba una tabla y otra que reciba un número, y demuestra con impresiones que la primera puede alterar al llamante y la segunda no.
  2. Implementa la copia superficial y comprueba experimentalmente que las tablas anidadas siguen compartidas.
  3. Amplía la copia a profunda con un registro de nodos ya visitados y verifica que funciona sobre una estructura con un ciclo.
  4. Define el metamétodo de igualdad para un tipo punto y comprueba que rawequal sigue diciendo la verdad sobre la identidad.
  5. Comprueba que t[2] y t[2.0] son la misma clave, que t["2"] no lo es, y explica en un comentario a qué regla obedece cada caso.