Archivos - AlgoritmosyEstructurasDeDatos/Laboratorio_JSL GitHub Wiki

(Adaptado de Prinz y Crawford (2016), C in a Nutshell, 2da edición)

En esta sección, hablaremos del manejo de archivos realizado con C. Partiremos hablando del concepto de flujo (stream), que es el fundamento de los mecanismos de acceso a archivos y la representación interna que hace C de estos, para luego pasar a las operaciones específicas que maneja C y su modo de utilizarlas.

Flujos

Para un programa en C, todo "dispositivo" de entrada y salida es tratado como un flujo (stream) de datos lógicos. Esto incluye salidas por pantalla (flujo de salida estándar); entradas por teclado (flujo de entrada estándar); archivos, tanto de entrada como de salida; etc.

Se suele hacer una distinción entre flujos de texto y binarios. Si bien algunos sistemas hacen alguna diferencia en la forma que los tratan, muchos otros no hacen ninguna, pues ambos corresponden en un nivel muy básico a secuencias de bytes que recibe o envía el programa. Una forma general en que se diferencian estos dos tipos de flujo es que uno binario no se interpreta, mientras que uno de texto, sí (por ejemplo, los flujos de texto suelen ser divididos en líneas, las que corresponden a secuencias de caracteres terminadas en el caracter de salto de línea, '\n').

Si bien la representación interna de los flujos de texto es independiente de la plataforma en C, hay que resaltar que algunas diferencias deben ser hechas entre sistemas, especialmente entre basados en Unix (Linux, Mac OSX) y Windows. Algunos caracteres podrían ser modificados entre ellas, debido a que representan internamente de modo distinto algunas ideas. Un ejemplo típico es que, para Windows (codificación normal latin1, también llamada ISO-8859-1), un salto de línea se representa con dos caracteres (a diferencia de Linux, por ejemplo, que usa UTF8 y solo requiere el carácter de salto de línea): el carácter de salto de línea propiamente tal, '\n', y el carácter de retorno de carro (carriage return), '\r'. Normalmente un programador no tiene que preocuparse de dicha transformación, ya que es manejada por la librería utilizada, pero es bueno conocer la diferencia.

Archivos

Un archivo es, en términos muy simples, una secuencia de bytes. Normalmente, se entiende específicamente como las secuencias de bytes que se manejan en algún dispositivo de almacenamiento permanente, como un disco duro o una memoria flash, pero para algunos sistemas, es la forma de representar una secuencia de bytes en general.

En el caso de C, el manejo de archivos corresponde a operaciones de entrada y salida (I/O) estándar, por lo que vienen declaradas en la biblioteca stdio.h. Se utiliza la función fopen para asociar un archivo (en su sentido amplio de secuencia de bytes) con un flujo determinado, el que se controla mediante el objeto de tipo FILE que retorna por la función. En este objeto, hay información como un puntero al buffer usado para acceder al flujo, un indicador de posición y algunas banderas (flags) para señalar condiciones.

Posición

Todo carácter (o byte, si lo interpretamos así) en un archivo tiene una posición definida en él y el indicador de posición del archivo señala cuál es el próximo carácter a leer o escribir en él. Los archivos son, por defecto, leídos de manera secuencial, es decir, desde donde parte el indicador de posición del archivo, solo puede avanzar en el flujo y no devolverse y cada operación de lectura o escritura avanza el indicador en la cantidad de caracteres leídos o escritos, respectivamente, haciendo que el acceso secuencial al archivo sea simple.

Hay muchas razones para preferir este modo de acceso a un archivo, algunas de ellas relacionadas con la eficiencia de acceso (por ejemplo, acceder a un disco duro es enormemente más lento que acceder a la memoria RAM, por lo que se tratan de minimizar las operaciones sobre el disco duro) o la propia naturaleza secuencial del dispositivo (como una impresora, pues solo tienen una dirección en que avanza la página, lo que causa que devolverse sea inviable). Una terminal es otro ejemplo de dispositivo secuencial.

Para los casos donde sí sea posible el acceso a cualquier posición del flujo, existen funciones para volver atrás, como fseek y fsetpos.

Buffers

Debido a lo ineficiente que resulta leer o escribir caracteres de a uno, cuando se trata de flujos (debido a que sus fuentes pueden ser discos duros de lento acceso, conexiones de red u otros similares), estos se almacenan en buffers de datos.

Un buffer es un espacio de memoria donde se almacena información de modo temporal y para un único uso (que, además, suele funcionar como una cola), para poner una "capa" intermedia entre el programa que escribe o lee datos y el sistema que los recibirá o entregará. Esta capa tiene el fin de que el programa no se quede sin datos durante procesos de transferencia irregulares. El mecanismo utilizado normalmente es que se agrupan los datos en el buffer hasta que se llena y luego se transfiere como bloque al o desde el archivo.

Existen tres mecanismos de buffer, principalmente:

  • Buffer completo (fully buffered), que transfiere los datos cuando se llena el buffer.
  • Buffer de línea (line buffered), que transfiere los datos cuando cuando se recibe un salto de línea (la entrada estándar funciona así, por ejemplo).
  • Sin buffer (unbuffered), que transfiere los caracteres a medida que llegan o lo antes posible.

El proceso de transferir manualmente la información de un buffer se conoce como flushing ("descarga", lo que uno hace al tirar la cadena del baño) y se realiza con la función fflush en C. Los buffer se descargan también cuando se cierra un flujo o al terminar un programa, donde todos los flujos abiertos se descargan (lo que puede causar efectos indeseados, si el flujo no se cerró adecuadamente antes).

Flujos estándar

Existen tres flujos estándar en todo programa computacional y estos tres están disponibles para todo programa escrito en C desde su inicio, por lo que no necesitan ser abiertos de modo explícito. Estos son:

  • Entrada estándar, stdin, con buffer lineal, se asocia normalmente con el teclado y es la principal forma de comunicación entre usuario y programa.
  • Salida estándar, stdout, con buffer lineal, se suele asociar con la consola y es la principal forma de mostrar información al usuario, usando el monitor (display) de la consola.
  • Salida estándar de error, stderr, sin buffer, se suele asociar a la consola, también, y se utiliza para mostrar mensajes de error.

Dividir la salida estándar de la salida de error estándar permite que los errores sean capturados por un dispositivo y los mensajes, por otro, particularmente útil al redireccionar la salida estándar, por ejemplo, a un archivo.

La redirección se puede hacer dentro del mismo programa en C mediante la función freopen o mediante las herramientas del ambiente de ejecución (por ejemplo, con el uso de "pipas" o tuberías [pipes, pipelines]).

Secuencia de manejo de archivos

El manejo de archivos en un programa se compone básicamente de tres pasos:

  1. Abrir el archivo.
  2. Procesar los contenidos (lectura o escritura).
  3. Cerrar el archivo.

El cierre del archivo es muy importante, pues los sistemas operativos mantienen un registro de cuáles archivos han sido abiertos y ponen restricciones a su acceso según esto (un ejemplo clásico es tratar de escribir un archivo con un programa, cuando está abierto en otro: Windows, en particular, no permite realizar la operación), por lo que partiremos con esa parte.

Abrir archivos y cerrar archivos

Abrir archivos

El primer paso para acceder a un archivo es abrirlo y, para esto, necesitamos especificar un modo de acceso. La función principal para esto es:

FILE *fopen(const char* filename, const char* mode);

Esta función toma como parámetros un par de strings, uno con el nombre del archivo (filename) y otro con el modo de acceso (mode). El nombre del archivo puede ser su ruta absoluta o relativa. Su valor de retorno es el puntero a un flujo de tipo FILE, que será el que nos permita acceder al archivo en sí. Si la operación de apertura falla, el retorno será un puntero nulo.

Alternativamente, existen dos funciones más para abrir un archivo:

/* Abre el archivo en el modo pedido, igual que open, pero además,
 * cierra el archivo abierto previamente en `stream` y asocia el archivo
 * con este.
 */
FILE *freopen(const char* filename, const char* mode, FILE* stream);

// Crea un archivo temporal para lectura y escritura en modo binario.
// Si el programa se cierra normalmente, el archivo se borra.
FILE *tmpfile(void);

Modos de acceso

El modo de acceso dado a las funciones de apertura determinan qué podemos hacer con el flujo. El string puede tener varias opciones, pero veremos acá las principales:

  • "r" es para modo de lectura (falla si no existe el archivo).
  • "w" es para modo de escritura (si existe previamente, lo borra; lo crea en otro caso).
  • "a" es para modo de agregado (comienza a escribir al final del archivo, si existe, o lo crea, como "w", si no).

Además de estos modos, se pueden añadir al string de modo los caracteres "b", para decir que el flujo se tratará como binario, y "+", para abrir en modo de lectoescritura (v. Prinz & Crawford, 2016, para los detalles).

Cierre de archivos

El cierre de archivo se realiza con la función fclose:

int fclose(FILE* fp);

En breves palabras, descarga el buffer al archivo en disco, cierra el archivo y libera la memoria usada por los buffer y demás elementos utilizados.

Su valor de retorno es 0, indicando que todo salió bien, o la constante EOF, si hubo un problema. Esta constante está definida en stdio.h y es un número entero que se utiliza para representar que se llegó al final de un archivo (EOF son las iniciales de end of file, fin de archivo) o algunos errores, como en el caso de esta función.

En caso de que un puntero a archivo sea nulo (porque no se ha inicializado o porque no se pudo abrir el archivo), la función fclose genera una violación de segmento.

Ejemplo

Supongamos que queremos probar si un archivo es posible de leer por un programa. Podemos utilizar el siguiente código:

#include <stdio.h>
#include <stdlib.h>

int main(){
    FILE *fp = fopen("input_test", "r");
    if (fp == NULL )
        puts("No se puede abrir el archivo para lectura");
    else {
        puts("El archivo se puede abrir para lectura");
        fclose(fp);
    }
    
    return 0;
}

Lectura sin formato

Caracteres

Usaremos acá principalmente las siguientes funciones de lectura de caracteres desde archivo. Todas ellas leen un único carácter a la vez:

// Lee un único caracter del flujo *fp
int fgetc(FILE* fp);
// Equivalente a fgetc, pero es una macro, en vez de función
int getc(FILE *fp);
// Lee un único caracter del flujo de la entrada estándar
int getchar(void);

Recordemos que un carácter es básicamente un entero de un byte de largo, por lo que todas estas funciones retornan eso: un entero de un byte que represente al carácter leído.

La diferencia entre fgetc y getc es que la segunda es una macro, lo que puede acelerar la ejecución (las llamadas a funciones siempre involucran un cierto overhead que podría ser significativo en llamadas sucesivas), sin embargo, podría causar problemas cuando fp es una expresión que genere un puntero a archivo, en lugar de un puntero a archivo, ya que podría evaluarse varias veces (ver aquí para la explicación).

Por su parte, getchar es otra macro que sirve de wrapper a getc(stdin).

Todas estas funciones retornan el valor EOF cuando legan al final del flujo u ocurre algún error.

Strings

La principal función para leer un string es la siguiente:

char* fgets(char* buf, int n, FILE *fp);

Esta función lee hasta $n-1$ caracteres de fp y los pone en el string apuntado por buf, por lo que este debe ser un puntero a string con, a lo menos, espacio para n caracteres. Lee $n-1$, debido a que añade el caracter '\0' para finalizar el string. Si encuentra un salto de línea, lo añade al string y lo finaliza en ese punto (en otras palabras, lee hasta $n-1$ caracteres o hasta el salto de línea, lo que ocurra primero).

Su valor de retorno es el valor del puntero buf (es decir, dónde apunta) o un puntero nulo, en caso de haber un error (que no haya más caracteres para leer antes del final de archivo cuenta como error, en este caso).

En el siguiente ejemplo, se muestra un procedimiento sencillo para leer el contenido de un archivo e imprimirlo por pantalla:

...
#define LENGTH 256

// Resto del programa
    
char buff[LENGTH];

FILE *fp = fopen("input_test", "r");
    
while (fgets(buff, LENGTH, fp) != NULL)
    printf("%s", buff);
    
fclose(fp);
...

Hay algunas funciones adicionales, como gets, que está obsoleta debido a su incapacidad para asegurar su buen funcionamiento, y gets_s, que la reemplaza y funciona en algunos sistemas con soporte para interfaces con chequeo de límites.

Escritura sin formato

Caracteres

Así como hay funciones para leer caracter a caracter, hay para escribir caracter a caracter:

// Escribe el caracter c en el flujo apuntado por fp
int fputc(int c, FILE* fp);
// Equivalente a fputc, pero es una macro
int putc(int c, FILE *fp);
// Escribe el caracter específicamente a la salida estándar
int putchar(int c);

La diferencia entre fputc y putc es exactamente la misma que entre fgetc y getc y las mismas precauciones deben tomarse que con las segundas. Por su parte, putchar es equivalente a putc(c, stdout).

El valor de retorno de estas funciones es el carácter escrito o EOF, en caso de algún error.

Strings

Para escribir un string, hay principalmente dos funciones:

/* Esta función escribe el string s al flujo fp, excepto el caracter nulo 
 * al final del string.
 */
int fputs(const char* s, FILE* fp);
// Esta función el string en la salida estándar, seguido de un salto de línea
int puts(const char* s);

Ambas funciones retornan EOF en caso de error o un número no negativo, en caso de éxito.

Errores

Es normal que ocurran errores con el manejo de archivo y varias de las funciones presentadas acá retornan EOF o utilizan otros mecanismos para reportar los problemas, incluso el uso de la variable global errno.

Además de verificar el valor de retorno de la función utilizada para verificar el error, estas funciones colocan una bandera de error en el objeto FILE que controla el flujo. Podemos verificarlo con la función ferror:

int ferror(FILE* fp);

Esta función retorna un valor diferente de cero si el flujo ha tenido algún error.

Cuando la lectura de un flujo avanza, eventualmente se llegará al final de este (duh), momento en el que el flujo es marcado con la bandera de final de archivo, EOF. Para verificar si un flujo está al final (pues no podemos leer nada después de eso), podemos usar la función feof:

int feof(FILE* fp);

Si el archivo no ha llegado al final, su retorno es 0. Retorna un número diferente de cero, si lo ha hecho. Esta función es útil, debido a que la lectura retorna EOF en caso de error o de finalización de archivo, por lo que, para verificar errores, conviene usar alguna de las dos funciones mencionadas:

char c;
while ((c = fgetc(fp)) != EOF)
    do_something(c);
// Si no está al final del archivo, hubo un error
if(!feof(fp))
    puts("Error de lectura\n");
// Como alternativa
if(ferror(fp))
    puts("Error de lectura\n");

Notar que no tiene sentido hacer una condición del estilo !feof(fp) || ferror(fp), debido a que, si fgetc retorna EOF, feof retornará 0, a la vez que ferror retornaría diferente de 0, lo que sería una tautología.

⚠️ **GitHub.com Fallback** ⚠️