wandres.dev
NÚMEROS · enteros y flotantes

El cisma de 5.3: cuando el número se partió en dos

Durante quince años todo número en Lua fue un flotante de doble precisión. Desde 5.3 conviven dos subtipos bajo un mismo tipo: entero y flotante. Qué cambió exactamente, cómo interrogarlo con math.type, y por qué esa grieta se filtra a cada línea que escribes.

⏱ 14 min

Lua sostuvo durante quince años una de las simplificaciones más audaces de su diseño: solo existía un tipo numérico, respaldado por un double de C, y con él se contaban elementos, se indexaban tablas y se hacía trigonometría. Era una decisión coherente con la filosofía del lenguaje —una pieza menos que aprender, una menos que documentar— y funcionó hasta que dejó de funcionar. En 2015, Lua 5.3 partió el número en dos subtipos sin partir el tipo. Ese cisma es la fuente de casi todas las sorpresas numéricas que te encontrarás, y entenderlo no es aprender una curiosidad histórica: es aprender a leer lo que tu programa está haciendo de verdad.

🎯 Al terminar esta lección sabrás
  • Reconstruir por qué Lua 5.0 a 5.2 representaba todo número como un flotante de 64 bits.
  • Distinguir tipo de subtipo, y usar type frente a math.type con criterio.
  • Predecir qué subtipo produce cada literal, cada operador y cada bucle numérico.
  • Situar el coste de compatibilidad del cambio, incluido el caso de LuaJIT y Neovim.

Un solo double para todo el universo

Hasta Lua 5.2, la macro LUA_NUMBER del código fuente valía double, y ahí se acababa la historia numérica del lenguaje. Un flotante IEEE 754 de doble precisión dedica 52 bits explícitos a la mantisa, más el bit implícito: cincuenta y tres bits de precisión enteros. Eso significa que todo entero cuyo valor absoluto no supere 9007199254740992 —es decir, dos elevado a 53— se representaba de forma exacta, y la aritmética entera sobre ese rango era perfecta. Para contar elementos de una tabla, iterar bucles o indexar cadenas, sobraba.

Las ventajas de esa uniformidad eran reales y conviene no despreciarlas. No había división entera que sorprendiera al principiante: 7 / 2 daba 3.5 y punto. No había desbordamiento silencioso: al pasar del rango representable el resultado se degradaba a infinito, que al menos es un valor visible y contagioso. Y no había que decidir nunca de qué tipo declarar una variable, porque no había nada que decidir.

El precio, sin embargo, se fue haciendo insoportable a medida que Lua salía del nicho de los guiones de configuración. Un identificador de 64 bits —una marca de tiempo en nanosegundos, un hash, una clave primaria de base de datos, un descriptor devuelto por una biblioteca de C— sencillamente no cabía sin perder los bits bajos, y lo peor es que la pérdida era silenciosa. Los operadores bit a bit eran impensables sobre flotantes, así que las bibliotecas los emulaban con multiplicaciones y módulos, lentas y limitadas a 32 bits. Y la interoperabilidad con un int64_t de C obligaba a pasar por cadenas de texto o por userdata.

Existía una válvula de escape, poco conocida: recompilar el intérprete cambiando LUA_NUMBER. Los sistemas empotrados sin unidad de coma flotante compilaban un Lua puramente entero. Pero eso fragmentaba el ecosistema en dialectos incompatibles, que es justo lo que un lenguaje de guion embebido no puede permitirse.

Merece la pena ver el síntoma concreto que hizo insostenible la situación, porque explica la urgencia del cambio mejor que cualquier argumento abstracto:

-- semantica de Lua 5.2: todo es double
local id = 9007199254740993      -- 2^53 + 1, un identificador legitimo
print(id == 9007199254740992)    --> true en 5.2, false en 5.3

Dos identificadores distintos que el lenguaje declara iguales. No hay forma de programar alrededor de eso: no es un problema de estilo ni de cuidado, es una pérdida de información en la propia representación del dato. Cuando Lua empezó a usarse como capa de guion de servidores, bases de datos y protocolos binarios, ese defecto dejó de ser teórico y se convirtió en la razón por la que ciertos proyectos no podían usar Lua en absoluto.

Dos subtipos, un solo tipo

La solución de 5.3 fue quirúrgica: mantener un único tipo visible, number, y darle dos representaciones internas. El resultado es que type sigue diciendo lo mismo de siempre, mientras que una función nueva, math.type, revela la capa de debajo.

print(type(1), type(1.0))          --> number    number

print(math.type(1))                --> integer
print(math.type(1.0))              --> float
print(math.type(1e2))              --> float
print(math.type(0x10))             --> integer
print(math.type(0x1p4))            --> float
print(math.type("10"))             --> nil

La última línea merece atención: math.type no falla ante un argumento no numérico, devuelve nil. Es un predicado de tres estados que puedes usar directamente como condición, y ese diseño es deliberado.

Dentro del intérprete no hay dos tipos, hay un tipo con dos variantes de etiqueta: LUA_TNUMINT y LUA_TNUMFLT comparten el número de tipo básico LUA_TNUMBER. Por eso type no puede distinguirlas sin mentir sobre la compatibilidad, y por eso la distinción tuvo que llegar en la biblioteca math y no en el núcleo del lenguaje.

¿Qué decide el subtipo de un literal? La sintaxis, y solo la sintaxis:

flowchart TD
A[Literal numerico en el fuente] --> B[Lleva punto decimal]
B -->|si| F[Subtipo flotante]
B -->|no| C[Lleva exponente e o E]
C -->|si| F
C -->|no| D[Es hexadecimal con exponente p]
D -->|si| F
D -->|no| E[Subtipo entero]
E --> G[math.type devuelve integer]
F --> H[math.type devuelve float]
style E fill:#a6e3a1,color:#11111b
style F fill:#89b4fa,color:#11111b

Nota la asimetría: 1e2 es flotante aunque valga exactamente cien, porque la notación científica es notación de flotante. Y 0xFF es entero, mientras que 0x1p4 es flotante hexadecimal, una notación que existe precisamente para escribir constantes de coma flotante sin ambigüedad de redondeo.

Dónde el cisma se hace visible

El primer sitio donde lo notas es al imprimir. Desde 5.3, tostring añade un sufijo a los flotantes de valor entero para que no se confundan con enteros: print(1.0) escribe 1.0, mientras que en 5.2 escribía 1. Es un cambio incompatible que rompió pruebas de regresión en medio ecosistema, y se conservó porque la alternativa —no poder distinguir visualmente los dos subtipos— era peor.

El segundo es la indexación de tablas. Aquí Lua aplica una regla de normalización que evita un desastre: si usas como clave un flotante cuyo valor es exactamente entero, se convierte a entero antes de buscar o guardar.

local t = {}
t[1] = "uno"
print(t[1.0])                      --> uno         misma clave
print(math.type(next(t)))          --> integer     se guardo normalizada

t[1.5] = "medio"                   -- esta si permanece flotante

Sin esa normalización, una tabla podría contener dos entradas distintas para el mismo valor matemático, y la parte secuencia del array dejaría de funcionar en cuanto un índice llegara por una división.

El tercero es el bucle numérico. Desde 5.4, si los tres valores de control son enteros, el contador es entero y el bucle está protegido contra desbordamiento; si alguno es flotante, los tres se convierten a flotante y el contador acumula error de redondeo.

for i = 1, 3 do io.write(math.type(i), " ") end    --> integer integer integer
print()
for i = 1, 3, 1.0 do io.write(math.type(i), " ") end  --> float float float

El cuarto, y el que más quema en producción, es la frontera con el exterior: un serializador JSON tiene que decidir si escribe 1 o 1.0; una API de C recibe lua_Integer o lua_Number según el subtipo; y string.format con %d acepta 3.0 pero rechaza 3.5.

local n = 10 / 5                       -- flotante, aunque valga dos
print("hay " .. n .. " elementos")     --> hay 2.0 elementos
print(string.format("%d", n))          --> 2          aqui si se acepta
print(("x"):rep(n))                    --> xx         indice convertible

local m = 10 / 4                       -- 2.5
-- print(("x"):rep(m))                 --> error: number has no integer representation

La primera línea es un clásico de los mensajes de registro y de la generación de texto: un contador perfectamente entero que aparece en la salida con un punto decimal porque en algún punto del cálculo pasó por una división. Ese defecto no rompe nada, pero delata que el programa perdió el subtipo por el camino, y allí donde perder el subtipo sí importa —una clave, un índice, un identificador serializado— el mismo descuido produce un fallo real.

El coste de la compatibilidad

Lua 5.3 ofreció una opción de compilación, LUA_COMPAT_FLOATSTRING, para que los flotantes se imprimieran al estilo antiguo. Es un parche de transición, no una solución: si tu código depende de él, lo que tienes es una deuda con fecha de vencimiento.

El caso realmente importante para quien vive dentro de un editor es LuaJIT. LuaJIT implementa la semántica de Lua 5.1 con extensiones selectivas, y no adoptó el cisma: en LuaJIT todo número sigue siendo un double, math.type no existe, y los enteros de 64 bits se manejan como cdata del FFI. Como Neovim embebe LuaJIT, el código que escribes para tu configuración vive todavía en el mundo anterior a 5.3.

-- deteccion defensiva, valida en 5.1, LuaJIT, 5.3 y 5.4
local function es_entero(x)
  if math.type then return math.type(x) == "integer" end
  return type(x) == "number" and x % 1 == 0
end

La segunda implementación no es equivalente: acepta como entero un flotante de valor entero, y en 5.3 el flotante 3.0 no es un entero. Ninguna función de compatibilidad puede unificar de verdad dos modelos numéricos distintos; lo único honesto es saber en cuál estás.

Queda una última pieza de configuración que conviene conocer porque afecta a lo que puedes asumir del intérprete ajeno. Lua 5.4 se puede compilar con LUA_32BITS, que hace que el entero sea de 32 bits y el flotante de precisión simple, pensado para microcontroladores. Los nombres de las constantes no cambian, así que math.maxinteger sigue existiendo pero vale 2147483647.

-- nunca escribas la constante a mano; preguntasela al interprete
local bits = math.maxinteger == 2147483647 and 32 or 64
print("enteros de " .. bits .. " bits")

Escribir 9223372036854775807 literalmente en el código es asumir una compilación concreta. La constante existe justamente para que no tengas que asumirla, y ese hábito —preguntar al entorno en lugar de suponerlo— es la actitud correcta ante todo el sistema numérico que verás en los cuatro capítulos siguientes.

La abstracción numérica perfecta no existe, solo se elige dónde se rompe

Todo lenguaje que quiera hablar a la vez de cantidades continuas y de cantidades discretas tiene que decidir dónde va a mentir, porque el hardware ofrece dos máquinas aritméticas distintas y ninguna sirve para las dos cosas. Lua eligió durante quince años mentir por el lado del entero: fingió que no existía, y aceptó que un identificador de 64 bits perdiera sus bits bajos con tal de que el programador nunca tuviera que declarar un tipo. Otros lenguajes eligen mentir por el lado del rendimiento —Python promociona a enteros de precisión arbitraria y paga con asignaciones de memoria en cada suma—, y otros mienten por el lado de la seguridad —C desborda con comportamiento indefinido y te deja la factura entera—. Lua 5.3 no eliminó la mentira, la movió a un sitio más útil: ahora el lenguaje mantiene la ficción de un solo tipo number en la superficie, para que el código sencillo siga siendo sencillo, y sitúa la verdad un nivel más abajo, accesible mediante math.type, para que el código que necesita precisión pueda exigirla. Ese es el patrón profundo que conviene extraer de este nivel y que reaparecerá en las metatablas, en las corrutinas y en la API C: Lua no te oculta la máquina, la envuelve en una capa fina y te deja una puerta para bajar. Los cinco niveles de sorpresas numéricas que vienen a continuación —división, límites, comparación, bits— no son defectos del diseño, son la forma que tiene la puerta.

⚔️ Interroga el subtipo de todo
  1. Escribe una función describir(x) que devuelva una cadena con type(x) y math.type(x) juntos, y pásale 1, 1.0, 2^31, 1e15, 0x7fffffffffffffff y "7".
  2. Comprueba en tu intérprete qué imprime print(3/1) y explica por qué el subtipo del resultado no depende de que la división sea exacta.
  3. Crea una tabla, guarda en ella la clave 2.0 y recórrela con pairs aplicando math.type a cada clave. Justifica lo que ves.
  4. Ejecuta el mismo guion en Neovim con :lua y anota cuáles de las tres pruebas anteriores fallan y por qué.
  5. Busca en el manual de referencia la sección de conversiones y localiza la frase exacta que define cuándo un literal es flotante.