wandres.dev
NÚMEROS · enteros y flotantes

Los límites: 64 bits que dan la vuelta sin avisar

El entero de Lua es de 64 bits en complemento a dos y desborda envolviendo, no fallando. math.maxinteger, math.mininteger, los enteros que un flotante no puede representar y las conversiones que se niegan a ocurrir.

⏱ 16 min

Un entero de Lua tiene exactamente 64 bits y no crece. Cuando se pasa del máximo no lanza un error, no promociona a flotante y no aborta el programa: da la vuelta al contador y sigue como si nada, con un resultado matemáticamente absurdo pero perfectamente definido. Al otro lado del cisma, un flotante cubre un rango astronómicamente mayor pero solo representa con exactitud los enteros de hasta 53 bits. Los dos subtipos son finitos, se solapan solo en parte, y la zona donde no se solapan es donde viven los errores más difíciles de diagnosticar de todo el lenguaje.

🎯 Al terminar esta lección sabrás
  • Manejar math.maxinteger y math.mininteger y la aritmética modular que los une.
  • Detectar y prevenir el desbordamiento entero antes de que ocurra, no después.
  • Entender por qué 53 bits marcan el límite de exactitud de un flotante.
  • Sobrevivir a los casos patológicos del extremo negativo y a math.ult.

El rango entero y la vuelta al contador

Los dos extremos están en la biblioteca math como constantes, no como funciones, y valen lo que cabe esperar de un entero con signo de 64 bits en complemento a dos:

print(math.maxinteger)                 --> 9223372036854775807
print(math.mininteger)                 --> -9223372036854775808
print(math.type(math.maxinteger))      --> integer

print(math.maxinteger + 1 == math.mininteger)   --> true
print(math.mininteger - 1 == math.maxinteger)   --> true

Esas dos últimas igualdades no son un accidente de implementación ni un comportamiento indefinido que dependa del compilador: el manual de Lua define que la aritmética entera envuelve, es decir, que se calcula módulo dos elevado a 64. Es una garantía, y esa es una diferencia enorme con C, donde el desbordamiento de un entero con signo es comportamiento indefinido y el optimizador tiene permiso para asumir que jamás ocurre. En Lua puedes razonar sobre el resultado; en C no puedes ni razonar.

Que esté definido no lo hace inofensivo. El desbordamiento sigue siendo silencioso, y silencioso significa que un cálculo de tamaños, de sumas acumuladas o de factoriales pasa de golpe a devolver números negativos sin que nada se queje.

local function factorial(n)
  local r = 1
  for i = 2, n do r = r * i end
  return r
end

print(factorial(20))     --> 2432902008176640000     correcto
print(factorial(21))     --> -4249290049419214848    envuelto y negativo
print(factorial(25))     --> 7034535277573963776     envuelto y positivo, peor aun

El caso de 21 al menos deja una pista visible, el signo negativo. El de 25 es el verdaderamente peligroso: un número grande, positivo y plausible que es basura. Ninguna comprobación superficial lo detecta.

Detectar el desbordamiento antes de sufrirlo

Como Lua no avisa, la comprobación la escribes tú, y la forma correcta es anticipar en lugar de comprobar después. Comprobar después es tentador y a menudo incorrecto, porque el propio resultado ya está corrompido.

-- suma segura: comprueba antes, no despues
local function suma_segura(a, b)
  if b > 0 and a > math.maxinteger - b then return nil, "desbordamiento por arriba" end
  if b < 0 and a < math.mininteger - b then return nil, "desbordamiento por abajo" end
  return a + b
end

-- multiplicacion: la division entera es el arbitro fiable
local function mul_segura(a, b)
  if a ~= 0 and (a * b) // a ~= b then return nil, "desbordamiento" end
  return a * b
end

La técnica de la suma es exacta porque math.maxinteger - b nunca desborda cuando b es positivo. La de la multiplicación es una comprobación a posteriori, aceptable porque la envoltura es determinista y la división entera deshace la operación solo si no hubo pérdida.

La alternativa estructural es no usar enteros donde el rango no cabe. Si tu magnitud puede superar los 64 bits, la respuesta no es una comprobación más ingeniosa: es una biblioteca de precisión arbitraria, o representar el valor como cadena y no operar con él.

Hay un caso, sin embargo, en el que la envoltura no es un fallo sino la especificación: las funciones de dispersión. Un hash multiplicativo quiere que los bits altos se descarten, y el hecho de que Lua defina la envoltura en vez de dejarla indefinida convierte un idioma que en C sería peligroso en un idioma perfectamente portátil.

-- hash FNV de 64 bits: la envoltura forma parte del algoritmo
local function fnv1a(s)
  local h = 0xcbf29ce484222325
  for i = 1, #s do
    h = h ~ s:byte(i)
    h = h * 0x100000001b3          -- desborda a proposito, y esta definido
  end
  return h
end

La diferencia entre este código y el factorial de arriba no está en la aritmética, que es idéntica, sino en la intención. Documenta siempre cuál de los dos casos es el tuyo: una envoltura deliberada sin un comentario que lo diga es indistinguible de un error, y el siguiente que lea el código intentará “arreglarla”.

Los 53 bits del flotante

El flotante parece la salida fácil porque llega hasta diez elevado a 308, pero su rango grande está hecho de precisión pequeña. La mantisa aporta 53 bits significativos, así que a partir de dos elevado a 53 los enteros consecutivos dejan de ser representables uno a uno: primero se pierden los impares, luego tres de cada cuatro, y así sucesivamente.

local dos53 = 2^53
print(dos53 == dos53 + 1)              --> true      dos enteros distintos, un flotante
print(string.format("%.0f", 2^63))     --> 9223372036854775808
print(math.maxinteger + 0.0)           --> 9.2233720368548e+18

La tercera línea encierra el detalle más traicionero del nivel. math.maxinteger vale dos elevado a 63 menos uno, un número impar que necesita 63 bits significativos. Al convertirlo a flotante se redondea al valor representable más cercano, que resulta ser dos elevado a 63 exacto: un número mayor que el propio máximo entero. Es decir, la conversión de entero a flotante puede producir un valor que ya no cabe de vuelta en un entero.

print(math.maxinteger + 0.0 == math.maxinteger)   --> false
print(math.tointeger(math.maxinteger + 0.0))      --> nil o fail
flowchart LR
A[Enteros de 64 bits] -->|conversion siempre posible| B[Flotantes de 64 bits]
B -->|conversion solo si es exacta| A
A --> C[Rango pequeno y precision total]
B --> D[Rango enorme y precision de 53 bits]
C --> E[Zona segura comun hasta dos elevado a 53]
D --> E
style E fill:#a6e3a1,color:#11111b
style A fill:#89b4fa,color:#11111b
style B fill:#f9e2af,color:#11111b

La zona verde del diagrama es la única región donde los dos subtipos coinciden valor a valor. Por debajo de dos elevado a 53 puedes moverte entre subtipos sin pensar; por encima, cada conversión es una decisión que hay que justificar.

El extremo negativo y la comparación sin signo

El complemento a dos es asimétrico: hay un valor negativo más que positivos, y eso produce una familia de casos patológicos concentrados en math.mininteger.

print(-math.mininteger == math.mininteger)       --> true    no tiene opuesto
print(math.mininteger // -1 == math.mininteger)  --> true    tampoco se puede negar dividiendo
print(math.abs(math.mininteger))                 --> -9223372036854775808

math.abs devolviendo un negativo parece un error de biblioteca y no lo es: el valor absoluto de math.mininteger sencillamente no existe dentro del tipo, y Lua prefiere la envoltura definida a inventarse un error. Cualquier código que calcule distancias, magnitudes o normas con enteros debe tratar ese valor como un caso especial, igual que se hace en C.

Por último, hay una operación que existe precisamente para trabajar con patrones de bits que representan enteros sin signo: math.ult, que compara dos enteros interpretando sus 64 bits como magnitud sin signo.

print(math.mininteger < 0)                --> true    lectura con signo
print(math.ult(math.mininteger, 0))       --> false   lectura sin signo: es enorme
print(math.ult(-1, 1))                    --> false   -1 sin signo es el maximo

Es la herramienta correcta cuando lo que tienes en la variable no es una cantidad sino un identificador, un hash o una máscara que vino de C.

La finitud no es un defecto del tipo, es el tipo

Hay una tentación muy extendida al aprender un lenguaje de guion: creer que sus números son números, en el sentido matemático, y que los límites son detalles de implementación que solo importan en C. Lua desmiente eso de la forma más didáctica posible, porque te obliga a convivir con dos finitudes distintas y ortogonales. El entero es finito en rango pero infinitamente preciso dentro de él: todo valor entre math.mininteger y math.maxinteger está exactamente representado, sin excepción ni redondeo, y al salirse no se degrada, se envuelve. El flotante es casi ilimitado en rango pero solo tiene 53 bits de precisión: fuera de la zona común representa cantidades que no son las que escribiste, y al salirse no envuelve, se degrada a infinito. Elegir subtipo no es, por tanto, elegir entre exacto y aproximado, sino elegir en qué dimensión estás dispuesto a fallar —y esa elección la hace tu programa cada vez que escribe un punto decimal en un literal, aunque el programador no se dé cuenta. La consecuencia práctica es que ninguna de las dos representaciones es la opción por defecto sensata para todo: los identificadores, los contadores, los índices, los tamaños y los desplazamientos quieren enteros, porque su corrección es la exactitud bit a bit; las medidas, las proporciones, las probabilidades y las coordenadas quieren flotantes, porque su corrección es el orden de magnitud. Cuando un programa mezcla las dos sin criterio, no obtiene lo mejor de ambas: obtiene el rango del entero con la precisión del flotante, y ese es el peor cuadrante posible.

⚔️ Toca los bordes a propósito
  1. Imprime math.maxinteger, math.mininteger, su suma y su diferencia, y verifica a mano que todo cuadra módulo dos elevado a 64.
  2. Escribe un bucle que calcule factoriales de uno a veinticinco y marque con una advertencia la primera iteración en la que mul_segura detecte el desbordamiento.
  3. Busca el primer entero n para el que n + 0.0 == n + 1.0 sea verdadero y comprueba que coincide con dos elevado a 53.
  4. Averigua cuántos enteros distintos hay entre math.maxinteger y el flotante math.maxinteger + 0.0 que no son representables en ninguno de los dos subtipos a la vez.
  5. Implementa abs_segura(x) que devuelva nil para math.mininteger y documenta en un comentario por qué ese caso no puede resolverse.