wandres.dev
NÚMEROS · enteros y flotantes

Bits, conversión y formato: el número como patrón

Los seis operadores bit a bit sobre enteros de 64 bits, el desplazamiento lógico que no conserva el signo, la conversión que se niega cuando no es exacta, math.tointeger frente a math.floor, y el formateo que decide qué ves.

⏱ 16 min

Los operadores bit a bit fueron la razón práctica que empujó el cisma de 5.3: sin un entero de verdad no hay máscaras, ni banderas, ni desplazamientos, ni interoperabilidad con las estructuras binarias del mundo de C. Al añadirlos, Lua tuvo que responder también a la pregunta inversa —qué hacer cuando alguien pide una operación de bits sobre un flotante— y la respuesta que eligió, negarse salvo cuando la conversión es exacta, define el estilo de todo el sistema numérico. Este nivel cierra con la otra cara del mismo problema: cómo se convierte un número en texto y qué se pierde por el camino.

🎯 Al terminar esta lección sabrás
  • Usar los seis operadores bit a bit sabiendo que operan siempre sobre 64 bits con signo.
  • Anticipar cuándo un flotante es aceptado en un contexto entero y cuándo produce error.
  • Elegir con criterio entre math.tointeger, math.floor y la división entera.
  • Formatear y leer números con string.format y tonumber sin pérdidas silenciosas.

Los seis operadores bit a bit

Lua 5.3 añadió & para la conjunción, | para la disyunción, ~ para la disyunción exclusiva en forma binaria y para la negación en forma unaria, y << y >> para los desplazamientos. Todos operan sobre los 64 bits del entero y todos devuelven un entero.

print(0xF0 & 0x3C)        --> 48        0x30
print(0xF0 | 0x0F)        --> 255       0xFF
print(0xF0 ~ 0xFF)        --> 15        xor binario
print(~0)                 --> -1        negacion unaria de todos los bits
print(1 << 8)             --> 256
print(256 >> 4)           --> 16

Hay dos rarezas que conviene fijar de inmediato. La primera: los desplazamientos son lógicos, no aritméticos. Al desplazar a la derecha se rellena con ceros, no con el bit de signo, de modo que un número negativo se convierte en un positivo enorme.

print(-1 >> 1)            --> 9223372036854775807     no es -1
print(-8 // 2)            --> -4                       esto si divide con signo

Si lo que quieres es dividir entre potencias de dos conservando el signo, el operador es //, no >>. El desplazamiento sirve para manipular patrones de bits, no para hacer aritmética con signo.

La segunda: un desplazamiento mayor o igual que 64 da cero, no queda indefinido como en C, y un desplazamiento negativo invierte la dirección. Otra vez, Lua prefiere definir el caso raro antes que dejarlo en manos de la plataforma.

print(1 << 64)            --> 0
print(1 << 1000)          --> 0
print(1 << -1)            --> 0         equivale a 1 >> 1
print(256 >> -4)          --> 4096      equivale a 256 << 4

Con esas piezas, el idioma clásico de banderas se escribe solo:

local LEER, ESCRIBIR, EJECUTAR = 1 << 0, 1 << 1, 1 << 2

local permisos = LEER | ESCRIBIR
local function tiene(mascara, bandera) return mascara & bandera ~= 0 end

print(tiene(permisos, ESCRIBIR))     --> true
permisos = permisos & ~ESCRIBIR      -- quitar una bandera
print(tiene(permisos, ESCRIBIR))     --> false

La conversión implícita y sus negativas

Los operadores de bits exigen enteros, pero aceptan flotantes si y solo si su valor tiene representación entera exacta. No hay redondeo, no hay truncamiento silencioso: o cabe, o error.

print(3.0 & 1)            --> 1
-- print(3.5 & 1)         --> error: number has no integer representation
-- print("3" & 1)         --> error: attempt to perform bitwise operation on a string

La tercera línea marca una diferencia importante con la aritmética: la coerción automática de cadenas a números, que sí funciona con + o *, no se aplica a los operadores de bits. Es una restricción deliberada que evita que un dato textual mal validado acabe convertido en una máscara plausible.

🔢

Conversión que exige exactitud

Los operadores de bits, string.format con %d y los índices de biblioteca aceptan un flotante solo si su valor es entero exacto. Cualquier parte fraccionaria produce un error inmediato y localizado, en vez de un resultado plausible y equivocado.

✂️

Redondeo que siempre responde

math.floor y math.ceil no fallan nunca con un número finito: devuelven un entero si cabe y un flotante si no. Son redondeos, no conversiones, y usarlos para validar entrada acepta 3.9 donde solo valía 3.

🚫

Cadenas fuera del juego de bits

La coerción de cadena a número funciona en la aritmética pero está prohibida en los operadores bit a bit. Un dato de texto sin validar no puede colarse como máscara por accidente.

📐

El formato decide lo que ves

tostring muestra catorce dígitos significativos, no diecisiete. Dos flotantes distintos pueden imprimirse igual, así que depurar con print puede ocultarte exactamente la diferencia que estás buscando.

math.tointeger frente a math.floor

Estas dos funciones se confunden constantemente porque en el caso feliz devuelven lo mismo. Son operaciones distintas con contratos distintos.

print(math.tointeger(7.0))        --> 7        exacto
print(math.tointeger(7.9))        --> nil      no hay representacion entera
print(math.tointeger("7"))        --> nil      no acepta cadenas en 5.4
print(math.tointeger(2^63))       --> nil      fuera de rango entero

print(math.floor(7.9))            --> 7        redondea hacia abajo
print(math.floor(-7.1))           --> -8       hacia menos infinito
print(math.type(math.floor(2^70)))--> float    no cabe, devuelve flotante
print(7.9 // 1)                   --> 7.0      redondea y conserva flotante

La regla de uso es directa. Si lo que quieres es validar que un valor recibido es un entero disfrazado, math.tointeger es la herramienta correcta, porque su fallo es la señal que necesitas. Si lo que quieres es calcular una parte entera, math.floor es la herramienta correcta, porque siempre responde. Usar math.floor para validar es el error clásico que deja pasar 3.9 como si fuera 3.

flowchart TD
A[Tengo un flotante y necesito un entero] --> B[El valor deberia ser ya entero]
B -->|si, es un dato de entrada| C[math.tointeger y trata el nil como error]
B -->|no, quiero la parte entera| D[math.floor o math.ceil]
D --> E[Comprueba el subtipo si el valor puede ser enorme]
C --> F[Ahora tienes un entero garantizado]
style C fill:#a6e3a1,color:#11111b
style D fill:#89b4fa,color:#11111b

Existe además el idioma x | 0, que fuerza la conversión a entero usando un operador de bits y lanza error si no es exacta. Es más corto que math.tointeger pero también más críptico y produce una excepción en lugar de un valor de fallo: úsalo solo cuando el error sea realmente lo que quieres.

Formatear y leer números

La conversión a texto es el último sitio donde el subtipo importa, y donde más silenciosamente se pierde información. tostring, y por tanto print y la concatenación, usan por defecto catorce dígitos significativos para los flotantes.

print(1/3)                                  --> 0.33333333333333
print(string.format("%.17g", 1/3))          --> 0.33333333333333331
print(0.1 + 0.2)                            --> 0.3            enganoso
print(string.format("%.17g", 0.1 + 0.2))    --> 0.30000000000000004

Esa tercera línea explica por qué tantos programadores creen que su comparación falla “sin motivo”: el valor impreso ya venía redondeado. Para depuración numérica seria, %.17g garantiza el viaje de ida y vuelta, y %a da la representación hexadecimal exacta del flotante, sin ninguna interpretación decimal.

Los especificadores enteros exigen representación entera, con la misma severidad que los operadores de bits:

print(string.format("%d", 7.0))       --> 7
-- string.format("%d", 7.5)           --> error: number has no integer representation
print(string.format("%x", 255))       --> ff
print(string.format("%.2f", 1/3))     --> 0.33
print(string.format("%a", 0.5))       --> 0x1p-1

En el sentido contrario, tonumber es la puerta de entrada disciplinada, y acepta una base opcional que la convierte en un analizador completo.

print(tonumber("42"), math.type(tonumber("42")))         --> 42   integer
print(tonumber("42.0"), math.type(tonumber("42.0")))     --> 42.0 float
print(tonumber("0x2A"))                                  --> 42
print(tonumber("ff", 16))                                --> 255
print(tonumber("hola"))                                  --> nil
print(tonumber("  7  "))                                 --> 7    ignora espacios

Fíjate en que tonumber preserva el subtipo que el texto implica, igual que hace el analizador léxico con los literales. Y en que devuelve nil en vez de lanzar error, lo que la convierte en la forma correcta de validar entrada externa: comprueba el nil y decide tú qué hacer, en lugar de dejar que la coerción implícita adivine.

Un número tiene tres vidas: valor, patrón y texto

Al terminar este nivel deberías poder ver que en Lua un número es tres cosas a la vez, y que la mayor parte de los errores numéricos nacen de confundir una con otra. Es un valor matemático cuando lo sumas o lo comparas, y ahí el lenguaje se esfuerza en ser exacto incluso entre subtipos distintos. Es un patrón de bits cuando lo enmascaras o lo desplazas, y ahí el signo deja de significar magnitud para significar simplemente el bit más alto, razón por la cual -1 desplazado a la derecha se convierte en math.maxinteger sin que nada esté roto. Y es un texto cuando lo imprimes o lo lees, y ahí interviene un redondeo de presentación que puede mostrarte iguales dos valores distintos, o distintos dos valores que tu programa considera iguales. La razón profunda por la que Lua exige conversiones explícitas entre estos tres mundos —negándose a hacer bits sobre 3.5, negándose a formatear 7.5 con %d, devolviendo nil en math.tointeger en vez de redondear— no es purismo académico, es que cada conversión implícita que un lenguaje se permite es un sitio donde el programa puede seguir funcionando con un valor equivocado. Un error en la conversión es barato: para el programa en la línea exacta donde está el fallo. Un redondeo silencioso es caro: sobrevive a las pruebas, atraviesa capas, se guarda en disco y aparece semanas después convertido en un identificador que no coincide, un saldo que no cuadra o una máscara de permisos que concede lo que no debía.

⚔️ Cierra el nivel manipulando bits
  1. Implementa contar_bits(n) que devuelva cuántos bits a uno tiene un entero, usando & y >>, y comprueba que contar_bits(-1) devuelve 64.
  2. Escribe a_binario(n) que produzca la cadena de 64 caracteres del patrón de bits, y úsala para observar math.mininteger y -1.
  3. Construye un conjunto de banderas con nombre y funciones poner, quitar y tiene, todas con operadores de bits.
  4. Compara math.tointeger, math.floor y x // 1 sobre los valores 3.0, 3.5, -3.5, 2^70 y math.huge, y tabula subtipo y resultado.
  5. Escribe leer_entero(texto) que use tonumber más math.tointeger y devuelva nil con un mensaje para cualquier entrada que no sea un entero exacto.