wandres.dev
LA BIBLIOTECA ESTÁNDAR · pequeña a propósito

io: el modelo de ficheros

El manejador de fichero como objeto con metatabla, los ficheros por defecto y su estado global, los modos de apertura, los formatos de lectura, la aritmética del búfer y el iterador io.lines con su contrato de cierre automático.

⏱ 18 min

La biblioteca io es la primera puerta real al mundo exterior y, como todo lo que Lua toma prestado del sistema anfitrión, es una capa finísima sobre la biblioteca estándar de C. No hay abstracción de rutas, ni de directorios, ni de permisos, ni de codificaciones: hay un objeto manejador de fichero, un puñado de métodos sobre él y dos variables globales ocultas que deciden a dónde va lo que escribes cuando no dices a dónde. Entender io es sobre todo entender esas dos variables globales, la aritmética del búfer que hay detrás de cada escritura y por qué el iterador io.lines es la única función de la biblioteca que se ocupa de cerrar por ti.

🎯 Al terminar esta lección sabrás
  • Distinguir el manejador de fichero como objeto de los ficheros por defecto y su estado global.
  • Elegir el modo de apertura correcto y tratar el fallo de apertura sin recurrir a suposiciones.
  • Leer con los cuatro formatos y decidir entre lectura por líneas y lectura por bloques con criterio de coste.
  • Explicar el comportamiento del búfer y el contrato de cierre de io.lines frente al de un manejador propio.

El modelo: manejadores y ficheros por defecto

Un manejador de fichero en Lua es un dato de usuario con metatabla propia. Esa metatabla le da un __index con los métodos, un __tostring que produce una descripción legible, un __gc que lo cierra si el recolector lo alcanza abierto y, desde Lua 5.4, un __close que permite atarlo a un ámbito léxico. La función io.type interroga ese objeto y devuelve una de tres respuestas: fichero abierto, fichero cerrado o nada en absoluto si el valor no es un manejador.

Junto a los manejadores explícitos existen dos ranuras globales, el fichero de entrada por defecto y el de salida por defecto, inicializadas a la entrada y la salida estándar del proceso. Las funciones sueltas de la biblioteca operan sobre ellas: io.read lee de la entrada por defecto y io.write escribe en la salida por defecto. Las mismas funciones invocadas con un argumento de fichero cambian la ranura y devuelven el manejador nuevo.

print(io.type(io.stdout))          --> file
print(io.type(42))                 --> nil

local anterior = io.output()       -- leer la ranura sin tocarla
io.output("salida.txt")            -- redirigir toda escritura posterior
io.write("esto va al fichero\n")
io.output(anterior)                -- restaurar
⚠️
Las ranuras por defecto son estado global compartido

Redirigir la salida por defecto dentro de una función y no restaurarla afecta a todo el programa, incluidas las bibliotecas de terceros y los mensajes de diagnóstico. Si necesitas redirigir, hazlo dentro de un pcall y restaura en ambos caminos, o simplemente no lo hagas: pasar un manejador explícito a cada escritura cuesta ocho caracteres y elimina la clase entera de fallos.

Conviene recordar que print no escribe en la salida por defecto de io, sino directamente en la salida estándar del proceso. Redirigir con io.output no captura lo que imprima print, y esa discrepancia sorprende siempre la primera vez.

Abrir, cerrar y los modos

io.open recibe una ruta y una cadena de modo, y devuelve el manejador o bien un trío formado por nada, un mensaje de error y un código numérico del sistema. No lanza error: el fallo es un valor de retorno, y el idioma canónico consiste en envolverla en assert, que convierte ese trío en excepción con el mensaje intacto.

local f = assert(io.open("datos.bin", "rb"))
-- ... trabajo ...
f:close()

-- Lua 5.4: cierre atado al ambito lexico, incluso si hay error
do
  local g <close> = assert(io.open("datos.bin", "rb"))
  local cabecera = g:read(8)
end   -- aqui g ya esta cerrado, pase lo que pase dentro del bloque

Los modos son los de C, sin añadidos. r abre para lectura y falla si el fichero no existe. w crea o trunca. a abre para añadir y sitúa cada escritura al final sin importar dónde esté el puntero. Los tres admiten el sufijo de actualización, que habilita lectura y escritura simultáneas: r+ mantiene el contenido, w+ lo trunca, a+ permite leer en cualquier posición pero escribe siempre al final. El sufijo b solicita modo binario y solo tiene efecto en sistemas que traducen finales de línea; en sistemas de tipo Unix es inocuo, y ponerlo siempre en ficheros no textuales es una disciplina barata que evita fallos al portar.

📖

Modo r

Lectura pura. El fallo más común no es de permisos, sino una ruta relativa interpretada desde un directorio de trabajo distinto del esperado.

✍️

Modo w

Trunca sin preguntar en el instante de la apertura, no en la primera escritura. Abrir con w y abandonar el programa deja el fichero vacío.

Modo a

Cada escritura se sitúa al final de forma atómica a nivel del sistema. Es el modo correcto para registros a los que escriben varios procesos.

🔁

Sufijo de actualización

Con lectura y escritura mezcladas hay que intercalar una llamada a seek o a flush al cambiar de sentido; omitirlo es comportamiento indefinido heredado de C.

Leer: formatos, líneas y bloques

Los formatos de lectura son cuatro y uno numérico. La letra l devuelve una línea sin su salto final y es el valor por defecto. La letra L devuelve la línea con el salto incluido, lo que permite reconstruir el fichero exactamente. La letra n intenta leer un número respetando la sintaxis numérica de Lua y devuelve nada si no lo consigue. La letra a devuelve todo lo que quede, y la cadena vacía si ya no queda nada. Un número entero pide esa cantidad exacta de bytes y devuelve menos si el fichero se acaba, o nada si ya estaba agotado.

local f = assert(io.open("registro.txt", "r"))

local todo   = f:read("a")     -- el fichero entero en una cadena
f:seek("set", 0)               -- volver al principio
local primera = f:read("l")    -- una linea sin el salto
local dos, tres = f:read("l", "l")   -- varios formatos en una llamada

print(f:seek("end"))           -- devuelve el tamano en bytes
f:close()

Entre las tres estrategias de lectura hay un compromiso claro. Leer el fichero entero con a es lo más rápido en llamadas al sistema y lo peor en memoria: un fichero de dos gigabytes se convierte en una cadena de dos gigabytes. Leer línea a línea es lo más cómodo y lo más costoso por unidad de datos, porque cada línea es una cadena nueva que el recolector tendrá que atender. Leer bloques de tamaño fijo es el punto medio de todo procesamiento serio de ficheros grandes.

local f = assert(io.open("enorme.log", "rb"))
local total = 0
while true do
  local bloque = f:read(64 * 1024)
  if not bloque then break end
  total = total + #bloque
end
f:close()
print("bytes leidos:", total)
flowchart TD
A[Necesito leer un fichero] --> B[Cabe comodamente en memoria]
B -->|si| C[Leer todo con el formato a]
B -->|no| D[El formato es de lineas]
D -->|si| E[Iterar con io.lines]
D -->|no| F[Leer bloques de tamano fijo]
E --> G[Cierre automatico al agotar]
F --> H[Cierre manual o variable de ambito]
C --> H

El búfer y io.lines como iterador

Toda escritura pasa por un búfer intermedio de C antes de llegar al disco. El método setvbuf fija su política: sin búfer, por líneas o completo, con un tamaño opcional. La salida estándar suele estar en modo de líneas cuando apunta a un terminal y en modo completo cuando se redirige a un fichero, y ese cambio silencioso explica el desconcierto clásico de ver los mensajes en orden en pantalla y desordenados en el fichero de registro.

io.stdout:setvbuf("line")        -- diagnostico legible aunque se redirija
local f = assert(io.open("salida.dat", "wb"))
f:setvbuf("full", 1024 * 1024)   -- una sola escritura por megabyte

Cerrar un fichero vacía su búfer; abandonar el proceso con os.exit sin argumento de cierre puede no hacerlo. Ese es el motivo por el que un programa que aborta deja a veces un fichero de salida truncado justo antes de la información interesante.

io.lines merece un tratamiento aparte porque es la única función de la biblioteca con una política de recursos propia. Invocada con un nombre de fichero, lo abre, devuelve un iterador y lo cierra sola cuando la iteración se agota. Invocada sin argumentos, lee de la entrada por defecto. El método homónimo sobre un manejador, en cambio, no cierra nada: el manejador es tuyo y sigue siéndolo.

for linea in io.lines("config.txt") do        -- se cierra solo al terminar
  if linea:match("^#") then goto continue end
  print(linea)
  ::continue::
end

local f = assert(io.open("config.txt"))
for linea in f:lines("L") do print(#linea) end  -- f sigue abierto
f:close()                                       -- cierre a tu cargo
💡
Salir del bucle antes de tiempo tiene consecuencias

El cierre automático de io.lines ocurre cuando el iterador llega al final del fichero. Si el bucle termina con un break, el fichero queda abierto hasta que el recolector lo alcance, lo que puede tardar. En Lua 5.4 el iterador se devuelve además como valor de cierre, de modo que for linea in io.lines(ruta) do cierra correctamente incluso con break; en versiones anteriores conviene abrir a mano cuando el bucle pueda abandonarse.

io no es una abstracción de ficheros, es una ventana a C

La tentación al aprender io es tratarla como el sistema de ficheros del lenguaje y sentirse decepcionado al descubrir que no puede listar un directorio, comprobar si una ruta existe, consultar un tamaño sin abrir, distinguir un fichero de un enlace ni crear una carpeta. Esa decepción nace de una expectativa equivocada. io no fue diseñada como una capa de sistema de ficheros, sino como el conjunto mínimo de operaciones sobre flujos que la biblioteca estándar de C garantiza en cualquier plataforma con un compilador conforme, y ni una sola función más. Todo lo que falta falta porque no está en esa garantía: los directorios son POSIX, los enlaces son POSIX, los permisos son POSIX, y Lua se ejecuta en sistemas que no lo son y en entornos empotrados que ni siquiera tienen sistema de ficheros. La consecuencia práctica es doble y hay que asumir las dos. La primera es que cualquier programa Lua que necesite algo más que abrir, leer, escribir y posicionar necesitará una biblioteca externa, y esa dependencia es inevitable por diseño. La segunda, más sutil, es que io hereda de C no solo sus capacidades sino también sus trampas: el búfer que no se vacía, la mezcla de lectura y escritura que exige un reposicionamiento intermedio, la diferencia entre modo texto y modo binario, el fichero abandonado que el recolector cerrará algún día. Quien programe io pensando en C acertará siempre; quien lo haga esperando la comodidad de una capa moderna se llevará una sorpresa por cada supuesto que dé por bueno.

⚔️ Medir la puerta al exterior
  1. Escribe la misma copia de un fichero de cien megabytes con el formato de todo el contenido, con io.lines y con bloques de sesenta y cuatro kilobytes; compara tiempo y memoria máxima.
  2. Redirige la salida por defecto a un fichero y comprueba qué ocurre con print frente a io.write.
  3. Abre un fichero, escribe sin cerrar y termina el proceso con os.exit; repite fijando el búfer sin almacenamiento y explica la diferencia.
  4. Recorre un fichero con io.lines y abandona el bucle con break; usa io.type sobre el manejador para verificar si quedó abierto en tu versión.
  5. Implementa una función que devuelva el tamaño de un fichero sin leer su contenido y razona qué ocurre si la ruta no existe.