Mayo 2008

You are currently browsing the monthly archive for Mayo 2008.

(Este artículo forma parte del Curso de Programación en C)

Incluso en las aplicaciones de consola, que tienen un interfaz mucho más simple que las gráficas, es habitual que deseemos utilizar distintos colores para las fuentes y el fondo, o decidir la posición exacta de la pantalla en la que se tienen que mostrar los caracteres, o dibujar ventanas, o, en definitiva, cualquier otra cosa propia de las interfaces de texto.

El estándar ANSI C no dispone de funciones para realizar estas tareas, pero existen muchas librerías para ello. En Windows, la más utilizada es la librería conio (CONsole Input/Output) de Borland, cuyos fundamentos se han descrito en otro artículo. Fue una librería muy difundida en su momento y aún hay mucha gente que la utiliza, hasta el extremo que existe una emulación para GNU/Linux.

Pero en entornos Linux, la librería más utilizada y, posiblemente, más versátil es Ncurses que, de hecho, se incluye con la mayoría de las distribuciones, ya que forma parte del proyecto GNU. Existe una versión para Windows (y otras plataformas) llamada PDcurses.

Por cierto: aunque lo he debido de decir en otros sitios, lo repito ahora porque me parece importante. Hablamos generalmente de “Librería ncurses” o “Librería conio”, cuando lo correcto sería decir “Biblioteca ncurses” o “Biblioteca conio”, ya que “Biblioteca” es la traducción correcta del “Library” original en inglés. Pero, en la jerga de programación, se ha extendido como una plaga el uso de “Librería” en este contexto, así que, para no confundir al personal, usaremos también esa palabra.

Qué es Ncurses

Ncurses es una librería de funciones para el manejo de interfaces basadas en texto. Es decir, se trata de un conjunto de funciones, ya programadas, que podemos utilizar en nuestros programas para mejorar su presentación.

Como Ncurses no es una librería estándar de C, es necesario ordenar al compilador que la enlace con nuestro programa. Esto se hace añadiendo la opción –lncurses al comando gcc. Así pues, esta línea de comando compila el programa “holamundo.c” sin enlazarlo con la librería Ncurses:

$ gcc holamundo.c

En cambio, esta otra línea fuerza el enlace del programa con la librería Ncurses:

$ gcc –lncurses holamundo.c

No hace falta decir que la librería debe estar instalada en nuestro sistema, ¿a que no?. Además, debemos hacer un #include <ncurses.h> en el programa que vaya a utilizar estas funciones.

Ncurses tiene muchísimas funciones, pero nosotros sólo nos referiremos a las que necesitamos para empezar a funcionar con ella.

Inicialización de Ncurses

Para utilizar las funciones de Ncurses en nuestro programa, basta con que incluyamos la siguiente llamada:

initscr();

Esta función crea una ventana de texto. La ventana se llama stdscr (que significa “standard screen”, es decir, “pantalla estándar”). A partir de aquí podremos utilizar cualquier función de Ncurses, pues todas actúan sobre esa ventana . Por ejemplo, una función que suele ir justo después es:

keypad (stdscr, 1);

Esto sirve para activar la recepción de teclas especiales (como F1, F2, ESC, etc). Si no llamamos a keypad(), no podremos utilizar ese tipo de teclas en nuestro programa. El primer parámetro se refiere a la ventana sobre la que queremos actuar (stdscr es la consola en su totalidad; no vamos a entrar, en este artículo de introducción, en los detalles sobre cómo crear ventanas dentro de la consola). El segundo parámetro es el que nos interesa: sirve para activar (1) o desactivar (0) la recepción de teclas especiales.

A continuación se enumeran las principales funciones de inicialización de Ncurses:

  • initscr(): Inicializa Ncurses y crea la pantalla estándar. Debe ser invocada antes que cualquier otra función de la librería.
  • keypad(stdscr, activar): Activa / desactiva la recepción de teclas especiales, como F1, ESC, Intro, etc. Si activar = 1, se activa la recepción. Si activar = 0, se desactiva.
  • echo() / noecho(): Activa / desactiva el eco de caracteres. Si el eco está activo, lo que se escriba en el teclado aparece en la pantalla. Si está inactivo, no.
  • cbreak() / nocbreak(): Activa / desactiva el envío inmediato de teclas. Normalmente, cuando se teclea algo no es enviado al programa hasta que no se pulsa “intro”. La función cbreak() hace que todo cuanto se teclee sea enviado al programa sin necesidad de “intro”. La función nocbreak() desactiva este comportamiento
  • nodelay(stdscr, activar): Activa / desactiva la espera para lectura de teclado. Las funciones para leer un solo carácter, como getch(), detienen la ejecución del programa hasta que se pulsa alguna tecla. Llamando a esta función con el parámetro activar = 1, conseguiremos que el programa no se detenga en getch() aunque no se pulse tecla alguna. Para desactivarlo, llamaremos a la función con activar = 0.
  • endwin(): Finaliza Ncurses. Hay que llamar a esta función antes de terminar el programa para liberar la memoria ocupada y restaurar la consola al estado inicial.

Escribir y leer

Cuando utilicemos Ncurses debemos olvidarnos de las funciones de entrada/salida estándar, como scanf(), printf(), gets() o puts(). En su lugar usaremos estas otras funciones:

  • printw() y putstr(): Para escribir usaremos la función printw(), que funciona igual que printf() pero sobre una ventana de Ncurses. También podemos usar putstr(), que es como puts(), es decir, sirve para imprimir cadenas
  • getstr() y getch(): Para leer disponemos de getstr(), que es como gets(), es decir, sirve para leer cadenas por teclado. De modo que, si queremos leer un número, debemos leerlo como cadena y luego convertirlo a número (con las funciones estándar atoi(), atof(), etc). También podemos usar getch(), que lee un único carácter.
  • move(): Para colocar el cursor usaremos move(y,x). Esto ubica el cursor en la columna “x” y la fila “y” de la pantalla. Funciona como la función gotoxy() de Borland, pero, ¡cuidado!, porque en move() se indica primero la fila y luego la columna, es decir, justo al revés que en la función de Borland.
  • refresh(): Actualiza la pantalla. Es el único modo de asegurarnos de que los cambios realizados se muestren instantáneamente.

Colores

Antes de utilizar los colores hay que inicializarlos llamando a la función start_color() sin argumentos, así:

if (has_colors())
  start_color();

La llamada previa a has_colors() se realiza para asegurarnos de que nuestra consola soporta el uso de colores. Es raro encontrar una consola que no permita colores, pero existen, así que no está de más hacer la comprobación.

Una vez hecho esto, podemos utilizar los colores básicos definidos en ncurses.h, cuyas constantes son COLOR_BLACK, COLOR_WHITE, COLOR_YELLOW, etc.

Para utilizar esos colores se deben agrupar en parejas: un color para el texto junto con un color para el fondo. A cada pareja se le asigna un número a través de la función init_pair(), así:

init_pair(1, COLOR_YELLOW, COLOR_BLUE);

Esto define a la pareja nº 1 como texto amarillo sobre fondo azul. De este modo podemos definir, por lo general, hasta 64 parejas.

Después, para activar una pareja, haremos esta llamada:

attron(COLOR_PAIR(1));

Esto activa la pareja de colores nº 1, de manera que todo el texto que se envíe a la pantalla a partir de este momento se verá amarillo con el fondo azul.

La función attron(), además de para activar parejas de colores, sirve para cambiar otros atributos del texto. Por ejemplo, lo siguiente se utiliza para escribir en negrita:

attron(A_BOLD);

Puedes obtener más información sobre otras cosas que se pueden hacer con attron() en las páginas de manual (escribiendo $man attron)

Ejemplo de uso de Ncurses

Para terminar esta breve introducción a la librería Ncurses mostraremos un ejemplo ilustrativo del uso de algunas de las funciones que aquí se han visto.

El siguiente programa utiliza Ncurses para escribir el texto HOLA en color rojo sobre fondo azul y el texto MUNDO en color amarillo sobre fondo verde. El texto HOLA aparece en la línea 11, y MUNDO en la 12. Luego, el programa espera hasta que se pulsa la tecla “flecha arriba”, y entonces termina.

#include <ncurses.h>

int main(void)
{
char carácter;
// Inicializa Ncurses
initscr();
// Activa teclas especiales (como las flechas)
keypad(stdscr, 1);
// Para no tener que pulsar Intro tras cada carácter
cbreak();

// Inicializa los colores
if (has_colors()) start_color();
// Pareja 1 = Texto rojo sobre fondo azul
init_pair(1, COLOR_RED, COLOR_BLUE);
// Pareja 2 = Texto amarillo sobre fondo verde
init_pair(2, COLOR_YELLOW, COLOR_GREEN);

// Activa la pareja 1
attron(COLOR_PAIR(1));
move(11, 1);
printw(“HOLA”);

// Activa la pareja 2
attron(COLOR_PAIR(2));
move(12, 1);
printw(“MUNDO”);

do
{
// Lee un carácter desde el teclado
carácter = getch();
}
while (carácter != KEY_UP);

// Finaliza Ncurses
endwin();
return 0;
}

Más información

Esto sólo ha sido una pequeña introducción a Ncurses, pero con lo que hemos visto y un poco de práctica es suficiente para conseguir efectos visuales más que dignos en nuestros programas para GNU/Linux.

Si, aún así, le ha sabido a poco, puede obtener mucha más información aquí.

En la asignatura de “Análisis y diseño de aplicaciones” suelo encontrarme con la resistencia de los alumnos (bastante comprensible, por otra parte) a planificar adecuadamente sus proyectos antes de comenzar a programar. Es un mal que nos aqueja a todos los que nos hemos dedicado a programar en alguna ocasión: uno tiene una idea y siente casi la necesidad física de lanzarse sobre el teclado y comenzar a picar código furiosamente.

Sin embargo, como repito una y otra vez en clase, eso es impracticable si el proyecto es demasiado complejo o demasiado grande (o las dos cosas).

David Asorey, en su blog Informática para tod@s, explica muy bien y con sentido del humor los problemas que causan estas prácticas, y que terminan con todo el mundo mosqueado por culpa de un programa que nunca funciona como debe y que sólo provoca dolores de cabeza y pérdidas económicas. Escribe David:

Al final nos encontramos con un dinosaurio software, lento, grande y arcaico. Los usuarios lo odian porque nunca acaba de ir bien, los responsables de área lo odian porque se lleva muchos recursos presupuestarios, los desarrolladores lo odian porque es asqueroso trabajar con el código enmarañado y mil veces parcheado, …

Una lectura muy recomendable para los estudiantes de análisis, y, en general, para cualquiera interesado en el desarrollo de programas informáticos.

(Este artículo forma parte del Curso de Programación en C)

Ya hemos visto en otros artículos del Curso de Programación en C las diferentes organizaciones de archivos (secuenciales, directos, etc.), así como las funciones de C para escribir y leer información de los mismos.

En el presente artículo realizaremos la implementación en C de los algoritmos que habitualmente se utilizan para procesar los archivos secuenciales. Así podremos ver en acción a todas las funciones de las que hablábamos anteriormente.

Escribir en un archivo secuencial

Como ya sabemos, los registros, en un archivo secuencial, se añaden siempre al final. Es necesario abrir el archivo para escritura, ya sea en el modo “w” si queremos borrar lo que contuviera anteriormente, o en el modo “a” si deseamos conservar su información anterior.

Una vez hecho esto, usaremos sucesivas instrucciones de escritura para insertar los registros (si el archivo es binario) o los caracteres (si es de texto). Ten en cuenta que los datos se grabarán en el archivo exactamente en el mismo orden en el que los escribas.

Las funciones de escritura que se deben usar dependen de la naturaleza del problema y de las preferencias del programador, pero recuerde que, en general, fwrite() suele reservarse para archivos binarios y el resto (fputc(), fprintf(), fputs()…) para archivos de texto.

En el siguiente fragmento de código tiene un ejemplo. Un archivo de texto llamado “ejemplo.txt” se abre para añadir datos al mismo (modo “at”). Luego se escriben en el archivo diez números enteros elegidos al azar. Cada vez que se ejecute el programa, se añadirán otros diez números al azar al final del archivo. Observe cómo se usa fprintf() para enviar el número entero N (seguido de un retorno de carro) al archivo de texto gracias a la cadena de formato. Esta cadena de formato es idéntica a la de la función printf() que tantas veces hemos utilizado.

   FILE *fich;
   int i, N;
   fich = fopen("ejemplo.txt", "at");
   if (fich == NULL)
      printf("Error al abrir el archivo");
   else
   {
      for (i = 0; N < 10; i++)
      {
         N = random(1000)+1;
         fprintf(fich, "%i\n", N);
      }
      fclose(fich);
   }

Lectura de datos de un archivo secuencial

Al abrir un archivo secuencial para lectura (en modo “r”), el indicador de posición se sitúa en el primer byte del archivo. Cada vez que se lea un dato, el indicador de posición se desplaza automáticamente tantos bytes adelante como se hayan leído. Las lecturas se pueden continuar haciendo hasta que se alcance el final del archivo.

En el siguiente ejemplo, abriremos el archivo del ejemplo anterior y escribiremos en la pantalla todos los números que contenga. Observe como usamos la funcion fscanf() para leer un número e introducirlo directamente en una variable de tipo entero. Si usásemos otra función de lectura (como, por ejemplo, fgets()), el número sería leído en forma de cadena de caracteres, y luego habría que convertirlo a entero.

Fíjate también en cómo se usa la función feof() para comprobar si se ha alcanzado el final del archivo.

   int N;
   FILE *fich;
   fich = fopen("ejemplo.txt", "rt");
   if (fich == NULL)
      printf("Error al abrir el archivo");
   else
   {
      while (!feof(fich))    // Mientras no se llegue al final del archivo...
      {
         fscanf(fich, "%i\n", &N);    // Leemos un número entero del archivo
         printf("%i\n", N);        // Escribimos el número en la pantalla
      }
      fclose(fich);
   }

Búsqueda de información en un archivo secuencial

En un archivo secuencial el único método de búsqueda posible es el secuencial, es decir, que hay que leer todos los registros, partiendo del primero, hasta encontrar el que buscamos.

En el siguiente ejemplo volvemos a utilizar el archivo generado en los ejemplos anteriores para tratar de localizar un número introducido por el usuario. Ese número se guarda en la variable n_busq. Después se van leyendo los números contenidos en el archivo en la variable n_leido, comparando cada número con el que estamos buscando.

Si el número se encuentra, el programa dice en qué línea del archivo está. Si no se encuentra, se da un mensaje de error. Observe que, cuando el número no se encuentra, es necesario recorrer todo el archivo antes de determinar que el número no está en el mismo.

Si el archivo estuviera ordenado podríamos mejorar el mecanismo de búsqueda, ya que no sería necesario recorrer todo el archivo para determinar que un elemento no está: bastaría con encontrar un elemento mayor para poder detener la búsqueda en ese instante.

   FILE *fich;
   int n_busq, n_leido, linea;
   int encontrado;
   fich = fopen("ejemplo.txt", "rt");
   if (fich == NULL)
      printf("Error al abrir el archivo");
   else
   {
      printf("¿Qué número desea buscar?");
      scanf("%i", &n_busq);
      linea = 0;
      encontrado = 0;
      while (!feof(fich))
      {
         linea++;
         fscanf(fich, "%i\n", &n_leido);
         if (n_leido == n_busq) {    // ¡Hemos encontrado el número!
           encontrado = 1;
           printf("He encontrado ese número en la línea %i\n", linea);
           break;
         }
      }
      if (encontrado == 0)
         printf("Ese número no está en el archivo");
      fclose(fich);
   }

Eliminación de información de un archivo secuencial

El borrado es una operación problemática. Existen dos formas de hacer el borrado en un archivo secuencial:

  1. Crear un segundo archivo secuencial y copiar en él todos los registros del archivo original excepto el que se pretende borrar. Después, se borra el archivo original y se renombra el archivo nuevo con el nombre que tenía el original. Como puede imaginar, este método, aunque funciona, es muy lento, sobre todo si el archivo es largo.
  2. Marcar el registro que se prentende borrar como “no válido” y, aunque siga existiendo, ignorarlo a la hora de procesar el archivo. Este segundo método requiere utilizar registros de estructura compleja (no simples archivos de texto). Además, sólo tiene ventajas si el archivo es directo.

La conclusión es que en los archivos secuenciales no se debe usar la operación de borrado. Si sobre un archivo se van a hacer borrados frecuentemente, entonces la organización secuencial no es adecuada, y deberíamos recurrir a los archivos directos, algo más difíciles de manejar, pero también más flexibles.

En el siguiente fragmento de código se utiliza el primer método de borrado para eliminar la quinta línea del archivo “ejemplo.txt” usado en los ejemplos anteriores. Se van leyendo números del archivo original y escribiendo en otro archivo llamado “temporal”, excepto la quinta línea, que es la que pretendemos borrar. Cuando el proceso acaba, cerramos los dos archivos, borramos “ejemplo.txt” y renombramos el archivo “temporal” para que a partir de ese momento se llame “ejemplo.txt”

   FILE *f_orig, *f_nuevo;
   int N, linea;
   f_orig = fopen("ejemplo.txt", "rt");
   f_nuevo = fopen("temporal", "wt");
   if ((f_orig == NULL) || (f_nuevo == NULL))
      printf("Error al abrir los archivos");
   else
   {
      linea = 0;
      while (!feof(f_orig))
      {
         linea++;
         fscanf(f_orig, "%i\n", &N);
         if (linea != 5)        // La 5ª línea no se escribe
           fprintf(f_nuevo, "%i\n", N);
      }
      fclose(f_orig);
      fclose(f_nuevo);
      remove("ejemplo.txt");
      rename("temporal", "ejemplo.txt");
   }

Modificación de datos en un archivo secuencial

En los archivos secuenciales sólo puede escribirse al final del archivo. Por lo tanto, para modificar un registro hay que actuar de forma similar al primer método de borrado: creando un segundo archivo en el que se copiarán todos los registros exactamente igual que en el archivo original, excepto el que se pretende cambiar.

Procesamiento de archivos con registros complejos

Hasta ahora todos los ejemplos han tratado con archivos de texto muy simples, en los que sólo había un número entero en cada línea.

Estas técnicas pueden extenderse a los archivos cuyos registros sean más complejos: sólo hay que modificar la función de lectura o escritura para adaptarla al formato de los datos del archivo.

Por ejemplo, supongamos un archivo en el que, en vez de sencillos números enteros, tengamos almacenada la lista de alumnos del instituto. Cada registro del archivo contendrá el nombre, el número de matrícula y la edad de un alumno/a. Para tratar cada registro definiremos una estructura:

struct s_alumno {
  int matricula;
  char nombre[30];
  int edad;
};

Cada registro del archivo se corresponderá exactamente con una estructura. Así, para añadir un alumno al archivo podemos usar el siguiente algoritmo:

   FILE *fich;
   struct s_alumno a;
   fich = fopen("alumnos.dat", "wb");
   if ((fich == NULL))
      printf("Error al abrir los archivos");
   else
   {
      printf("Introduzca los datos del alumno/a que desea añadir\n");
      printf("Nombre: "); scanf("%s", a.nombre);
      printf("Nº de matrícula: "); scanf("%i", &a.matricula);
      printf("Edad: "); scanf("%i", &a.edad);
      fwrite(&a, sizeof(struct s_alumno),1,fich);
      fclose(fich);
   }

Observe que el procedimiento es el mismo que en el caso de sencillos número enteros, salvo que, al tratase de una estructura compleja, es preferible usar archivos binarios y la función fwrite() en lugar de archivos de texto y la función fprintf(). Pero podría usarse perfectamente fprintf() de este modo (entre otros):

fprintf(fich, "%i %s %i ", a.matricula, a.nombre, a.edad);

Lógicamente, para hacer la lectura de este archivo será necesario usar fread() si se escribió con fwrite(), o fscanf() si se escribió con fprintf().

Los procedimientos de lectura, búsqueda, borrado, etc también son fácilmente extensibles a este tipo de archivos más complejos.

Un ejemplo: archivos secuenciales de texto

El siguiente programa trata de ilustrar cómo se utilizan los archivos de texto con C. Se trata de un programa que se divide en dos funciones. Por un lado, escribir_archivo() sirve para escribir un texto en la pantalla y volcarlo a un archivo llamado “prueba.txt”. Todo lo que se va tecleando va apareciendo en la pantalla y, al mismo tiempo, se va enviando, carácter a carácter, al archivo de disco, hasta que se introduce el carácter “#”. Por otro lado, leer_archivo() hace lo contrario: lee todo lo que haya grabado en “prueba.txt” y lo muestra por la pantalla.

Fíjese en cómo se usa feof() para saber cuándo se ha llegado al final del archivo. Además, observa que se han preferido las funciones fgetc() y fputc() en lugar de fscanf() y fprintf(), por ser más adecuadas a la naturaleza de este problema.

#include <stdio.h>
int main(void)
{
  int opción;
  puts("¿Qué desea hacer? 1 = escribir, 2 = leer");
  puts("Teclee 1 ó 2: ");
  scanf("%i", opcion);
  if (opcion == 1) escribir_archivo();
  if (opcion == 2) leer_archivo();
  return 0;
}
void escribir_archivo()
{
  FILE* f;
  char car;
  f = fopen("prueba.txt", "w");
  if (f == NULL)
    printf("Error al abrir el archivo\n");
  else
  {
    do
    {
       car = getchar();    // Lee un carácter desde el teclado
       fputc(car, f);    // Escribe el carácter en el archivo
    }
    while (car != '#');
    fclose(f);
  }
}
void leer_archivo()
{
  FILE* f;
  char car;
  f = fopen("prueba.txt", "r");
  if (f == NULL)
    printf("Error al abrir el archivo\n");
  else
  {
    do
    {
      car = fgetc(f);    // Lee un carácter del archivo
      printf("%c",car);    // Lo muestra en la pantalla
    }
    while (!feof(f));    // Repite hasta alcanzar el fin de fichero
    fclose(f);
  }
}

Ejemplo:  archivos secuenciales binarios

El siguiente ejemplo utiliza archivos binarios para escribir o leer un array de 30 estructuras. En el programa principal se pregunta al usuario qué desea hacer y dependiendo de su respuesta se invoca a una de estos dos funciones:

leer_archivo()

Abre el archivo “alumnos.dat” para lectura y recupera los datos que haya en él, mostrándolos en la pantalla. Observe que es un archivo binario y fíjese sobre todo en el uso de fread():

fread(&alumno[i],sizeof(struct s_alumno),1,archivo);

El argumento &alumno[i] es la dirección de memoria donde está guardado el elemento i-ésimo del array. El segundo argumento es sizeof(struct s_alumno), es decir, el tamaño de cada elemento del array. El tercer agumento es 1, porque es el número de elementos que vamos a escribir. El último argumento es el nombre del flujo.

Observe que esa instrucción se repite NUM_ALUMNOS veces, ya que esa es la cantidad de elementos que tiene el array. Podríamos haber sustituido todo el bucle por una sola instrucción de escritura como esta:

fread(alumno,sizeof(struct s_alumno),NUM_ALUMNOS,archivo);

Aquí sólo pasamos la dirección del primer elemento del array y luego le decimos que escriba NUM_ALUMNOS elementos en lugar de sólo 1.

escribir_archivo()

Primero se le pide al usuario que introduzca los datos por teclado y luego se guardan todos esos datos en “alumnos.dat”. Observa el uso de la función fwrite(), que es similar al que antes hacíamos de fread().

#include <stdio.h>
#define NUM_ALUMNOS 30
struct s_alumno {
  int matricula;
  char nombre[30];
  int edad;
};
void leer_archivo();        // Prototipos
void escribir_archivo();
int main()
{
  int opcion;
  puts("¿Qué desea hacer? 1 = escribir, 2 = leer");
  puts("Teclee 1 ó 2: ");
  scanf("%i", &opcion);
  if (opcion == 1) escribir_archivo();
  if (opcion == 2) leer_archivo();
  return 0;
}
void leer_archivo()
{
  int i;
  FILE *archivo;
  struct s_alumno alumno[NUM_ALUMNOS];
  // Lectura de datos desde el archivo
  archivo = fopen("alumnos.dat","rb");
  if (archivo == NULL) printf("Error al abrir el archivo");
  else
  {
  for (i=0; i<NUM_ALUMNOS; i++)
    {
       fread(&alumno[i],sizeof(struct s_alumno),1,archivo);
    }
    fclose(archivo);
    // Escritura de los datos en la pantala
    for (i=0; i<NUM_ALUMNOS; i++)
    {
       printf("Nº matrícula: %i\n", alumno[i].matricula);
       printf("Nombre: %s\n ", alumno[i].nombre);
       printf("Edad: %i\n", alumno[i].edad);
    }
  }
}
void escribir_archivo()
{
  int i;
  FILE *archivo;
  struct s_alumno alumno[NUM_ALUMNOS];
  // Lectura de datos por teclado
  for (i=0; i<NUM_ALUMNOS; i++)
  {
    printf("Introduzca nº de matricula :");
    scanf("%d",&alumno[i].matricula);
    printf("Introduzca nombre :");
    gets(alumno[i].nombre);
    printf("Introduzca edad :");
    scanf("%d",&alumno[i].edad);
  }
  // Grabación del archivo
  archivo = fopen("alumnos.dat","ab+");
  if (archivo == NULL) printf("Error al abrir el archivo");
  else
  {
    for (i=0; i<NUM_ALUMNOS; i++)
    {
       fwrite(&alumno[i],sizeof(struct s_alumno),1,archivo);
    }
  fclose(archivo);
  }
}

(Este artículo forma parte del Curso de Programación en C)

Como hicimos con las listas abiertas, presentamos ahora una posible implementación en C de las operaciones de inserción y lectura en una cola que tienen en cuenta todos los casos vistos anteriormente. Recuerde que no dispondrá en realidad del tipo de datos “cola” hasta que no implemente estas funciones. Una vez desarrolladas, puede utilizarlas para manipular la cola, que a su vez le servirá para resolver otros problemas.

Las operaciones están escritas como funciones para que así puedan ser utilizadas desde cualquier programa.

Supondremos que ya está declarado el tipo t_nodo (como vimos en este artículo introductorio) y que se trata de una cola de números enteros (ya sabe que para hacer una cola con otros datos basta con modificar la definición de la estructura). Las funciones deben recibir como parámetros los punteros a la cabeza y a la cola. Además, la función de inserción debe recibir el dato que se desea insertar, y la de lectura debe devolverlo en un return.

Como nos ha ocurrido en otras implementaciones anteriores, al convertir cada operación en una función sucede algo que puede confundir un poco: el puntero al primer elemento (y al último) de la cola debe ser pasado a la función por variable, es decir, en forma de puntero. Pero como ya es en sí mismo un puntero, el parámetro se convierte en un puntero a puntero (**primero) o doble puntero. Observe con detenimiento las implicaciones que ello tiene en la sintaxis de la función:

void insertar(t_nodo **primero, t_nodo **ultimo, int v) {
   t_nodo* nuevo;
 
   nuevo = (t_nodo*)malloc(sizeof(t_nodo));    // Creamos el nuevo nodo
   nuevo->dato = v;    // Le asignamos el dato
   nuevo->siguiente = NULL;   // El nuevo nodo apuntará a NULL
   if (*ultimo != NULL)       // Si la cola no estaba vacía...
       (*ultimo)->siguiente = nuevo;    // ...enganchamos el nuevo al final
   *ultimo = nuevo;      // A partir de ahora, el nuevo será el último
   if (*primero == NULL) // Si la cola estaba vacía...
       *primero = nuevo; // ...el último también será el primero
}
int leer(t_nodo **primero, t_nodo **ultimo) {
   t_nodo *aux;  // Puntero auxiliar
   int v;        // Para almacenar el valor del dato y devolverlo
 
   aux = *primero;   // El auxiliar apunta a la cabeza
   if(aux == NULL)   // La cola está vacía: devolver valor especial
      return -1;
   *primero = aux->siguiente;    // El primero apunta al segundo
   v = aux->dato;      // Recoger valor del primero
   free(aux);          // Eliminar el nodo primero
   if (*primero==NULL) // Si la cola se ha quedado vacía...
      *ultimo = NULL;  // ...hacer que el último también apunte a NULL
   return v;           // Devolver el dato que había en el primero
}

(Este artículo forma parte del Curso de Programación en C)

Como vimos al introducir las colas, la lectura de los datos siempre se hace por la cabeza y siempre implica la eliminación automática del nodo.

Distinguiremos dos casos: cuando la cola contiene más de un elemento y cuando tiene sólo uno. Podríamos añadir un tercero: cuando la cola no tiene elementos, pero, en ese caso, la operación de lectura no tiene sentido.

Leer un elemento en una cola con más de un elemento

Necesitaremos un puntero auxiliar que apunte al primer elemento de la cola (es decir, a la cabeza):

Disponiendo de esos punteros, la operación de lectura se realiza así:

  1. Hacemos que aux apunte a primero.
  2. Hacemos que primero apunte al segundo elemento de la cola, es decir, a primero->siguiente.
  3. Guardamos el dato contenido en aux para devolverlo como valor del elemento
  4. Eliminamos el nodo apuntado por aux (mediante la función free() o similar)

El resultado de estas acciones será el siguiente:

En la implementación en C, observe como se salva el dato contenido en el nodo antes de eliminarlo, para así poder usarlo en el programa como más convenga:

t_nodo* aux;             
int valor;
aux = primero;        // Hacemos que aux apunte a la cabeza
primero = primero->siguiente;   // Hacemos que primero apunte al segundo
valor = aux->dato;    // Guardamos en una variable el dato contenido en el nodo
free(aux);            // Eliminamos el primer nodo

Leer un elemento en una cola con un sólo elemento

La forma de proceder es la misma, pero ahora hay que añadir una cosa más: hay que hacer que el puntero “último” pase a apuntar a NULL, ya que la cola se quedará vacía. Por lo tanto, partimos de esta situación:

Y, después de ejecutar todos los pasos, debemos llegar a esta:

Fíjese en que no es necesario hacer que primero apunte a NULL, sino que basta con hacer que apunte a primero->siguiente, según establece el algoritmo general. Por lo tanto, la implementación en C puede ser ésta:

t_nodo* aux;             
int valor;
aux = primero;        // Hacemos que aux apunte a la cabeza
primero = primero->siguiente;     // Hacemos que primero apunte al segundo
valor = aux->dato;    // Guardamos en una variable el dato contenido en el nodo
free(aux);            // Eliminamos el primer nodo
if (primero == NULL)  // ¡La cola se ha quedado vacía!
    ultimo = NULL;    // Hacemos que el último también apunte a NULL

(Este artículo forma parte del Curso de Programación en C)

Ya hemos visto en qué consiste la estructura de cola. Nos centraremos ahora en una de sus dos operaciones elementales: la de inserción de datos.

La inserción de elementos siempre se hace al final de la cola, es decir, a continuación de elemento apuntado por el puntero “último”.

Insertar un elemento en una cola vacía

Supondremos que disponemos de un nuevo nodo que vamos a insertar en la cola (con un puntero, que llamaremos “nuevo”, apuntando a él) y, por supuesto, los punteros “primero” y “último” que definen la cola.
Si la cola está vacía, ambos deben estar apuntando a NULL.

El proceso de inserción consiste en:

  1. Hacer que nuevo->siguiente apunte a NULL.
  2. Hacer que primero apunte a nuevo.
  3. Hacer que último apunte a nuevo.

El estado final de la cola debe ser este:

Una posible implementación en C de este algoritmo de inserción puede ser:

t_nodo* nuevo;             
nuevo = (t_nodo*) malloc(sizeof(t_nodo));   // Se reserva memoria para el nuevo nodo
nuevo->dato = 5;            // Insertamos un dato en el nuevo nodo
nuevo->siguiente = NULL;    // Hacemos que el nuevo apunte a NULL
primero = nuevo;            // Hacemos que el primero y el último apunten al nuevo
ultimo = nuevo;

El tipo t_nodo y los punteros primero y último han debido ser declarados con anterioridad. Supondremos que la cola almacena números enteros. En este ejemplo, el dato que se inserta en el nodo nuevo es el número 5.

Insertar un elemento en una cola no vacía

En esta ocasión partiremos de una cola no vacía (en la siguiente figura dispone de 3 elementos), y de un nuevo nodo al que podemos acceder a través de un puntero llamado “nuevo”:

Para insertar un nodo en estas condiciones hay que seguir estos pasos:

  1. Hacer quee nuevo->siguiente apunte a NULL.
  2. Hacer que ultimo->siguiente apunte a nuevo.

El resultado debe ser esta otra cola, en la que el nuevo elemento se ha insertado al final.

Como hacemos siempre, vamos a proponer una posible implementación de este algoritmo en C. El dato insertado en este ejemplo será el número 25:

t_nodo* nuevo;             
nuevo = (t_nodo*) malloc(sizeof(t_nodo));  // Se reserva memoria para el nuevo nodo
nuevo->dato = 25;           // Insertamos un dato en el nuevo nodo
nuevo->siguiente = NULL;    // Hacemos que el nuevo apunte a NULL
ultimo->siguiente = nuevo;  // Enganchamos el nuevo al final de la cola
ultimo = nuevo;             // A partir de ahora, el nuevo será el último

(Este artículo forma parte del Curso de Programación en C)

Las colas, esas estructuras de datos que se prestan a chistes tan fáciles en castellano, son un tipo especial y simplificado de lista abierta. A uno de los extremos de la lista se le denomina cabeza, y al otro, cola. Sólo se pueden insertar nodos en la cola, y sólo se pueden leer nodos en la cabeza. Además, como sucede con las pilas, la lectura de un dato siempre implica la eliminación del nodo que contiene ese dato.

Este tipo de lista es conocido como lista FIFO (First In First Out, es decir, el primer elemento en entrar es el primero en salir). El nombre de “cola” proviene de la analogía con las colas de la vida real; por ejemplo, la cola para pagar en un supermercado. El primero que llega a la cola es el primero que sale de ella, mientras que los que van llegando después se tienen que ir colocando detrás, y serán atendidos por orden estricto de llegada.

Las colas, como las pilas, son listas abiertas simplificadas que, sin embargo, se adaptan a la perfección a determinados problemas, por lo que, para resolver esos problemas, es preferible usar una cola en lugar de una lista.

Hemos dicho que las colas son listas abiertas simplificadas. Por lo tanto, la representación interna será exactamente la misma, con la salvedad de que ahora necesitaremos dos punteros: uno al primer nodo (cabeza) y otro al último nodo de la lista (cola)

Tipos de datos para implementar colas

Vamos a trabajar con colas de números enteros (como siempre, para simplificar), pero se podría cambiar fácilmente con sólo modificar el tipo del campo “dato” en la siguiente estructura de nodo:

struct s_nodo
{
   int dato;
   struct s_nodo *siguiente;
};
typedef struct s_nodo t_nodo;
t_nodo *primero;
t_nodo *ultimo;

Observe que los tipos necesarios con los mismos que en las listas abiertas, con la excepción de que ahora necesitamos dos punteros en lugar de uno: el que apunta al primer elemento (cabeza) y el que apunta al último (cola).

Operaciones con colas

Las colas, volvemos a repetirlo, son listas abiertas simplificadas. Lo único que cambia respecto de las listas abiertas es el conjunto de operaciones, que en las colas es mucho más reducido (precisamente eso es lo que las hace más simples).

Así, las únicas operaciones permitidas con colas son:

  • Insertar: Añade un elemento al final de la cola.
  • Leer: Lee y elimina un elemento del principio de la cola.

En los siguientes posts veremos cómo se implementan en C esas operaciones. Antes, sin embargo, hacemos el mismo aviso que hicimos al hablar de pilas: también puede implementarse una cola basada en otra estructura de datos distinta de la lista abierta (por ejemplo, basándonos en un vector; en este caso, sería una cola estática, no dinámica).

(Este artículo forma parte del Curso de Programación en C

Ya hemos hablado aquí sobre las pilas y cómo debe ser el nodo que necesitamos para implementarlas. Nos detendremos ahora en la operación de extracción (o pop).

La operación pop consiste en leer el dato que hay en la cima de la pila (es decir, el que está apuntado por el puntero primero) y eliminarlo. La operación de eliminación es exactamente igual que la que vimos referida a listas abiertas (borrado del primer elemento), así que puedes repasarla allí.

Esta es una posible implementación en C de la operación pop en forma de función. La función recibe como parámetro un puntero a la cima de la pila y devuelve el valor del dato que está en la cima, eliminando el nodo que lo contiene:

int pop(t_nodo **primero)
{
   t_nodo *aux;      // Variable auxiliar para manipular el nodo
   int v;            // Variable auxiliar para devolver el valor del dato
 
   aux = *primero;
   if(aux == NULL)   // Si no hay elementos en la pila devolvemos algún valor especial
      return -1;
 
   *primero = aux->siguiente; // La pila empezará ahora a partir del siguiente elemento
   v = aux->dato;    // Este es el dato que ocupaba la cima hasta ahora
   free(aux);        // Liberamos la memoria ocupada por el nodo que estaba en la cima
   return v;         // Devolvemos el dato
}

(Este artículo forma parte del Curso de Programación en C

Ya hemos hablado aquí sobre las pilas y cómo debe ser el nodo que necesitamos para implementarlas. Nos detendremos ahora en la operación de inserción (o push).

La operación push consiste en insertar un dato en la cima de la pila. Las operaciones con pilas son muy simples, como vamos a ver: no hay casos especiales, salvo que la pila esté vacía.

Push en una pila vacía

Debemos disponer de un nodo del tipo t_nodo y del puntero primero, que debe apuntar a NULL si la pila está vacía, la operación push será exactamente igual que la inserción en una lista abierta vacía:

  1. Hacer que nodo->siguiente apunte a NULL
  2. Hacer que primero apunte a nodo.

Revise la operación de inserción en una lista abierta vacía para obtener más información.

Push en una pila no vacía

Si la pila ya contiene al menos un nodo, la operación de inserción es igual que la de insertar un elemento al principio de una lista abierta, de modo que puede repasar aquella operación.

A continuación presentamos una posible implementación en C de la operación push en forma de función. Esta implementación contempla las dos posibilidades (inserción en pila vacía y en pila no vacía). La función recibe dos parámetros: un puntero al primer elemento de la pila (cima) y un número entero, que es el dato que se pretende insertar. Observe que, como el puntero al primer elemento ya es un puntero y hay que pasarlo por variable a la función, se trata en realidad de un doble puntero (**). Fíjese bien en las diferencias sintácticas que eso representa:

void push(t_nodo **primero, int v)
{
   t_nodo *nuevo;
 
   nuevo = (t_nodo*)malloc(sizeof(t_nodo)); // Creamos nodo nuevo
   nuevo->dato = v;                         // Insertamos el dato en el nodo
 
   nuevo->siguiente = *primero;             // La cima a partir de ahora será "nuevo"
   *primero = nuevo;
}

(Este artículo forma parte del Curso de Programación en C)

Una pila es un tipo especial y simplificado de lista abierta con la que sólo está permitido realizar dos operaciones: insertar y eliminar nodos en uno de los extremos de la lista. Estas operaciones se conocen como push y pop (respectivamente, “empujar” y “tirar”). Además, al leer un dato (pop), el nodo se elimina automáticamente de la lista.

Estas características implican un comportamiento de lista LIFO (Last In First Out), que significa que el último elemento en entrar es el primero en salir.

La estructura en pila puede parecer un poco extraña, pero en realidad se ajusta como un guante a determinados problemas. Esto, unido a la extrema simplicidad de uso (ya que sólo permite dos operaciones) hace que sea una estructura muy recomendable en ciertas ocasiones.

El mismo nombre “pila” da una idea bastante cabal de su funcionamiento: piense el lector en una pila de platos cuidadosamente dispuesta en el fregadero. No se puede añadir un nuevo plato en la parte de abajo sin grandes dificultades y alguna pequeña tragedia doméstica: hay que añadirlos en la parte superior. Tampoco se puede extraer un plato del fondo, sólo de la cima. El plato que se toma de la cima es, precisamente, el último que fue añadido en la pila (o el que menos tiempo lleva en ella, si lo prefiere). Así funcionan las pilas.

La representación interna de una pila es exactamente igual que la de una lista abierta: sólo cambiarán las operaciones que se pueden realizar con ella. Así pues, el nodo de una pila de números enteros (ya sabemos que, para construir pilas de otro tipo, bastaría con cambiar el tipo de “dato”) tendrá este aspecto:

struct s_nodo
{
   int dato;
   struct s_nodo *siguiente;
};
typedef struct s_nodo t_nodo;
t_nodo *primero;

Fíjese en que es exactamente igual que una lista abierta y, como sucedía con ellas, es fundamental no perder nunca el puntero al primer elemento de la pila, porque es a través de él como podemos acceder a los demás.

En los siguientes artículos discutiremos las operaciones push y pop. Pero antes de acabar con esta introducción, un aviso importante: no todas las pilas tienen por qué ser dinámicas. O, dicho de otra forma, no todas las pilas tienen por qué construirse con listas abiertas. De hecho, es posible construir una pila sobre un array, por ejemplo. Cuestión aparte sería la utilidad de esa construcción (aunque seguro que alguna puede encontrársele).

(Este artículo forma parte del Curso de Programación en C)

Tras nuestro recorrido por las listas dinámicas (abiertas, circulares y doblemente enlazadas), no podemos dejar de mencionar, aunque sea por encima, que es habitual (y bastante útil) combinar la lista doblemente enlazada con la lista circular, obteniendo así listas circulares doblemente enlazadas, en las que el nodo siguiente al último es el primero, y el anterior del primero es el último. En realidad, el concepto “primero” y “último” se diluye:

Estas son, sin duda, las listas más versátiles, porque permiten recorrer los nodos hacia delante o hacia atrás partiendo de cualquier punto.

Puede el lector conseguir una implementación de las mismas sin más que combinar el código de las listas circulares con las listas doblemente enlazadas, como es lógico.

(Este artículo forma parte del Curso de Programación en C)

Una lista doblemente enlazada es una variedad de lista abierta en la que cada nodo tiene dos enlaces: uno al nodo siguiente y otro al anterior.

Las listas doblemente enlazadas pueden recorrerse en ambos sentidos (de atrás hacia delante y al revés) a partir de cualquier nodo. Necesitaremos, como en las otras listas, de, como mínimo, un puntero a alguno de los nodos de la lista, para a partir de él poder acceder al resto. Es habitual, sin embargo, mantener dos punteros: uno al primer elemento y otro al último.

El tipo de dato básico para construir los nodos de la lista es diferente al de las listas abiertas, ya que ahora necesitamos dos punteros en cada nodo. Así, para construir, por ejemplo, una lista doblemente enlazada de números enteros necesitaremos esta estructura:

struct s_nodo
{
   int dato;
   struct nodo *siguiente;
   struct nodo *anterior;
};
typedef struct s_nodo t_nodo;
t_nodo *primero, *ultimo;

El repertorio de operaciones básicas es el mismo que en las , de modo que puede modificar su código para adaptarlo a las listas doblemente enlazadas:

  • Insertar elementos.
  • Buscar elementos.
  • Borrar elementos.

Estas operaciones, como siempre, pueden complementarse con las funciones secundarias que sean necesarias.

(Este artículo forma parte del Curso de Programación en C)

Una lista circular es una variedad de lista abierta en la que el último nodo a punta al primero en lugar de apuntar a NULL.

En estas listas el concepto de “nodo primero” es una convención, porque en realidad no existe: todos los nodos son anteriores a otro y siguientes de otro. No hay principio ni fin de la lista, aunque debemos mantener un puntero a al menos uno de los nodos para poder iniciar desde él las operaciones sobre la lista.

En las listas circulares, por lo tanto, no hay casos especiales, salvo que la lista este vacía.

Los tipos de datos que se emplean son los mismos que en el caso de las listas abiertas. Así, para construir una lista de números enteros necesitaremos una estructura de este tipo:

struct s_nodo
{
   int dato;
   struct s_nodo *siguiente;
};
typedef struct s_nodo t_nodo;
t_nodo* nodo;

Fíjese en que el puntero a un nodo de la lista lo hemos llamado “nodo” en lugar de “primero”. Esto se debe a que, como hemos dicho, en una lista circular no hay “primero” ni “último”. Recuerde que para construir una lista con otros datos que no sean de tipo entero, bastaría con cambiar la definición del campo “dato” en la estructura s_nodo.

En cuanto a las operaciones básicas que se pueden realizar con listas circulares, son las mismas que con listas abiertas, es decir:

  • Insertar elementos.
  • Buscar elementos.
  • Borrar elementos.

De modo que puede el lector dirigirse a los artículos sobre listas abiertas y modificar ligeramente el código para tener implementada una lista circular. Y, claro, a estas operaciones básicas le puede añadir cuantas operaciones secundarias le sean necesarias.

(Este artículo forma parte del Curso de Programación en C)

Hemos visto en los artículos anteriores el fundamento de las operaciones básicas sobre listas abiertas, enlazadas o simples (que de las tres formas se llaman). Esas operaciones son la inserción, la búsqueda y la eliminación de nodos o elementos de la lista.

Es cierto que con éstas no se agotan las posibles operaciones sobre una lista. Ya mencionamos otras posibilidades: eliminar todos los nodos, comprobar si una lista está vacía, ordenar una lista, etc. Pero sí que constituyen el núcleo central de la lista. Sin ellas, la estructura de datos no sería funcional, no serviría para nada. Con ellas, podemos utilizar la lista como utilizamos un array (usando las funciones programadas en lugar de las operaciones típicas de arrays, claro)

A continuación, como resumen, presentamos una posible implementación C de las operaciones básicas sobre listas, para que el lector pueda estudiar en conjunto muchos de los casos particulares que hemos estado viendo por separado hasta ahora.

Supondremos que ya se ha definido la estructura del nodo y que la lista sirve para almacenar números enteros (para que almacene otro tipo de información basta con cambiar la estructura del nodo)

Implementaremos una función diferente para cada operación sobre la lista, de manera que estas mismas funciones puedan utilizarse en otros programas:

  • Función insertar(): servirá para añadir un dato a la lista. Recibirá como parámetros el puntero al primer elemento de la lista y el dato (número entero en nuestro ejemplo) que se quiere insertar. Insertaremos el dato siempre en la primera posición de la lista, pero esta función se puede modificar para insertar el dato al final o en cualquier otra ubicación (por ejemplo, se puede mantener la lista ordenada insertando el dato en la posición que le corresponda)
  • Función borrar(): servirá para borrar un dato de la lista. Recibirá como parámetros el puntero al primer elemento y el dato que se quiere borrar (un número entero). Buscará en la lista ese dato y, si lo encuentra, lo eliminará. Devolverá 1 si el borrado se ha hecho con éxito, o –1 si ha fallado.
  • Función buscar(): servirá para buscar un dato en la lista. Recibirá como parámetros el puntero al primer elemento y la posición del dato que se quiere buscar. Luego recorrerá la lista hasta la posición indicada y devolverá el número almacenado en ella, o bien –1 si esa posición no existe. Fíjese en que esto difiere de la operación “buscar” que vimos antes. Allí buscábamos un nodo a través del dato que contenía, y aquí vamos a buscarlo a partir de la posición que ocupa en la lista.

Tenga en cuenta que ésta es sólo una posible implementación de una lista. Dependiendo de la naturaleza del problema, puede ser necesario modificar las funciones para que actúen de otro modo ligeramente distinto.

Por último, y antes de pasar a ver el código, observe que, al utilizar funciones para cada operación, tenemos que pasar por variable o referencia el puntero al primer elemento de la lista. Y como el puntero al primer elemento ya es un puntero, hay que pasar como parámetro un puntero a puntero. Eso plantea algunos problemas sintácticos que debe usted observar con detalle (en el caso de la función buscar() eso no ocurre porque el parámetro se puede pasar por valor)

void insertar(t_nodo **primero, int v)
{
   t_nodo* nuevo;
   nuevo = (t_nodo*)malloc(sizeof(t_nodo));        // Creamos nodo nuevo
   nuevo->dato = v;               // Le asignamos el dato
   nuevo->siguiente = *primero;   // El primero pasará a ser el segundo
   *primero = nuevo;              // Y el nuevo pasará a ser el primero
}
int borrar(t_nodo **primero, int v)
{
   t_nodo *anterior, *aux;
   int borrado = -1;    // Marca de "no borrado"
   // Primera parte: buscar el nodo anterior al que vamos a borrar
   // El que vamos a borrar se distingue porque contiene el dato "v"
   anterior = *primero;
   while (anterior != NULL)
   {
      aux = anterior->siguiente;
      if ((aux != NULL) && (aux->dato == v))
         break;     // aux es el nodo que queremos eliminar
      anterior = anterior->siguiente;
   }
   // Segunda parte: borrar el nodo siguiente y reasignar los punteros
   // Comprobamos que hemos encontrado el nodo que deseamos eliminar (aux)
   if ((anterior != NULL) && (aux != NULL))
   {
      anterior->siguiente = aux->siguiente;  // Reasignamos los enlaces
      free(aux);                             // Eliminamos el nodo
      borrado = 1;                           // Marca de "borrado realizado"   
   }
   return borrado;
}
int buscar (t_nodo* primero, int pos)
{
   int cont, valor;
   t_nodo* nodo;
   nodo = primero; // Nos situamos en el primer elemento
   cont = 1;       // Ponemos el contador a su valor inicial
   while ((cont<pos) && (nodo != NULL))    // Repetir hasta encontrar nodo o terminar lista
   {
      nodo = nodo->siguiente; // Pasamos al nodo siguiente
      cont++;                 // Actualizamos el contador de nodos
   }
   if (cont == pos)    // Hemos encontrado el elemento buscado
       valor = nodo->dato;
   else                // No hemos encontrado el elemento
       valor = -1;
   return valor;
}

Desde el programa principal se usarán estas funciones en el orden adecuado para resolver el problema que se nos haya planteado. Por ejemplo, estas son algunas llamadas válidas:

insertar(primero, 5);
insertar(primero, n);
insertar(primero, 2);
borrar(primero, 5);
n = buscar(primero, 1);
...etc...

(Este artículo forma parte del Curso de Programación en C)

Igual que al insertar datos en una lista abierta, a la hora de eliminar nodos debemos considerar por separado diferentes casos, ya que su implementación es ligeramente distinta: eliminar el primer nodo, eliminar el último, etc.

Eliminar el primer nodo de una lista abierta

Para eliminar el primer nodo de una lista usaremos un puntero auxiliar que apunte al segundo, de esta manera:

  1. Hacer que el puntero auxiliar apunte a primero->siguiente (es decir, al segundo nodo)
  2. Eliminar el elemento primero, liberando la memoria con free() o una función similar
  3. Reasignar el puntero primero para que pase a apuntar al que antes era el segundo nodo, y que ahora se habrá convertido en el primero.

Partimos, por tanto, de esta situación:

Y, después del proceso de borrado, debemos obtener este resultado:

Observe que, si no guardásemos el puntero al segundo nodo antes de actualizar la lista, después nos resultaría imposible acceder al nuevo primer elemento, y toda la lista sería inaccesible.

La implementación en C de todo esto podría ser algo así:

t_nodo *segundo;
if (primero != NULL) {                      // Comprobamos que la lista no esté vacía
   segundo = primero->siguiente;        // Guardamos la referencia al segundo elemento
   free(primero);                           // Eliminamos el primero (es importante liberar la memoria)
   primero = segundo;                       // El que era segundo se convierte en primero
}

Eliminar un nodo cualquiera de una lista abierta

En todos los demás casos, eliminar un nodo se hace siempre del mismo modo. Únicamente necesitamos disponer de un puntero al nodo anterior al que queremos eliminar, y un nodo auxiliar que apunte al siguiente, es decir, al que vamos a eliminar:

El proceso es muy parecido al del caso anterior:

  1. Hacemos que el puntero auxiliar apunte al nodo que queremos borrar (anterior->siguiente)
  2. Asignamos como nodo siguiente del nodo anterior, el siguiente al que queremos eliminar. Es decir, anterior->siguiente = aux->siguiente.
  3. Eliminamos el nodo apuntado por aux, liberando la memoria.

Como hacemos siempre, presentamos una implementación de este algoritmo en C. Para ello, supondremos que queremos eliminar el nodo siguiente a aquél que contiene en dato 7:

t_nodo *anterior, *aux;

// Primera parte: buscar el nodo anterior al que vamos a borrar (contendrá el dato 7)
anterior = primero;
while ((anterior->dato != 7) && (anterior != NULL))
   anterior = anterior->siguiente;
// Segunda parte: borrar el nodo siguiente y reasignar los punteros
if (anterior != NULL) {                   // Comprobamos que hemos encontrado el punto de eliminación   aux = anterior->siguiente;                    // aux es el nodo que queremos eliminar
   anterior->siguiente = aux->siguiente;  // Reasignamos los enlaces
   free(aux);                             // Eliminamos el nodo
}

Eliminar todos los nodos de una lista

Para eliminar una lista completa hay que recorrer todos los nodos e ir liberando la memoria de cada uno, hasta que alcancemos el último nodo (que reconoceremos porque estará apuntando a NULL).

Otra manera de hacerlo es eliminar el primer elemento de la lista repetidamente, según el algoritmo que hemos visto antes, hasta que el primer elemento sea NULL. Eso significará que la lista se ha quedado vacía.

(Este artículo forma parte del Curso de Programación en C)

Muy a menudo necesitaremos recorrer una lista abierta, ya sea buscando un valor particular o un nodo concreto. De hecho, es algo que ya hemos necesitado hacer en algunos de los algoritmos de inserción que hemos presentado en el artículo anterior.

Las listas abiertas sólo pueden recorrerse en un sentido, ya que cada nodo apunta al siguiente, de modo que no se puede obtener un puntero al nodo anterior desde un nodo cualquiera.

Para recorrer una lista procederemos siempre del mismo modo:

  1. Usaremos un puntero auxiliar (a modo del contador que se usa para recorrer un array)
  2. El valor inicial del puntero auxiliar será igual al primer elemento de la lista
  3. Iniciamos un bucle que, al menos, debe tener una condición: que el puntero auxiliar no sea NULL. Cuando el puntero auxiliar tome el valor NULL significará que hemos llegado al final de la lista.
  4. Dentro del bucle asignaremos al puntero auxiliar el valor del nodo siguiente al actual.

Por ejemplo, este fragmento de código muestra los valores de los nodos de la lista de los ejemplos anteriores:

t_nodo *aux;
aux = primero;
while (aux != NULL)
{
   printf("%d\n", aux->dato);
   aux = aux->siguiente;
}

La condición de salida del bucle puede complicarse si queremos añadir algún criterio de búsqueda, pero siempre debemos conservar la comparación (aux != NULL) para terminar el bucle en caso de llegar al final de la lista. Si no, el programa fallará.

Por ejemplo, el siguiente código busca el dato 50 en una lista de números enteros. Si existe, se mostrará en la pantalla, y, si no, se dará un mensaje de error:

t_nodo *aux;
aux = primero;
while ((aux != NULL) && (aux->dato != 50))
{
   aux = aux->siguiente;
}
if (aux->dato == 50)
   printf("El dato 50 está en la lista");
else
   printf("El dato 50 NO se encuentra en la lista");

(Este artículo forma parte del Curso de Programación en C)

Nos referíamos en un artículo anterior a las listas abiertas como la estructura dinámica más sencilla. Veremos ahora cómo realizar con ella la operación más básica: insertar elementos.

Como comprenderemos enseguida, no es lo mismo insertar un dato en una lista vacía que en una lista que ya tiene elementos, del mismo modo que no es lo mismo insertar un elemento al principio de la lista que insertarlo al final. De modo que trataremos cada situación por separado.

Insertar un elemento en una lista vacía

Si una lista está vacía significa que no contiene ningún nodo y, por lo tanto, el puntero primero estará apuntando a NULL. Esto lo representaremos así:

El proceso para insertar un nodo en la lista vacía consiste en:

  1. Crear ese nodo reservando memoria para el mismo (con malloc() o una función similar). Tras la creación, dispondremos de un puntero apuntando al nodo (llamaremos nodo a la variable puntero a nodo).
  2. Hacer que nodo->siguiente apunte a NULL
  3. Hacer que primero apunte a nodo.

El resultado de la ejecución de estos tres pasos debe ser:

Veamos como se implementa esto en C. Dispondremos de una variable primero, que apunta al primer elemento de la lista, y de una variable nodo, que será el elemento que pretendemos insertar en la lista. El valor del dato de este nodo será, por ejemplo, 5.

t_nodo *primero, *nodo;
primero = NULL;                       // Cuando la lista está vacía, su primer elemento es NULL
nodo = (t_nodo*) malloc(sizeof(t_nodo));      // Nuevo elemento
nodo->dato = 5;                          // El dato guardado en el nuevo elemento es 5
nodo->siguiente = NULL;                  // El elemento siguiente a este será NULL
primero = nodo;                          // El primer elemento deja de ser NULL y pasa a ser "nodo"

La lista resultante de la ejecución de este fragmento de código es esta:

Insertar un elemento en la primera posición de una lista

En este caso dispondremos de una lista no vacía y de un nuevo nodo que queremos insertar al principio de la lista:

Para hacer la inserción, basta con seguir esta secuencia de acciones:

  1. El puntero primero debe apuntar al nuevo nodo
  2. El nuevo nodo debe apuntar al que hasta ahora era el primero

Si lo escribimos en C:

t_nodo *nuevo;
nuevo = (t_nodo*) malloc(sizeof(t_nodo));      // Nuevo elemento
nuevo->dato = 7;                        // El nuevo dato guardado en el nuevo elemento será 7
nuevo->siguiente = primero;               // El elemento siguiente a este será el que antes era primero
primero = nuevo;                         // El nuevo elemento pasa a ser el primero

Si aplicamos este código sobre la lista anterior tendremos este resultado:

Insertar un elemento en la última posición de una lista

Razonando del mismo modo podemos insertar un nuevo nodo al final de una lista no vacía, sólo que en este caso necesitamos un puntero que nos señale al último elemento de la lista. La forma de conseguir este puntero es muy sencilla: basta con recorrer uno a uno todos los elementos de la lista hasta llegar al último. Podemos reconocer el último porque es el único cuyo elemento siguiente valdrá NULL.

Cuando tengamos todos estos elementos, el proceso de inserción se resume en:

  1. Hacer que el último elemento deje de apuntar a NULL y pase a apuntar al nuevo nodo.
  2. Hacer que el nuevo nodo apunte a NULL

Observe detenidamente la implementación en C, prestando atención a cómo se obtiene el puntero al último elemento de la lista. Recuerde que el último se identifica porque su puntero a su siguiente elemento vale NULL:

t_nodo *ultimo, *nuevo;
// Primera parte: buscar el último nodo de la lista (para eso, la recorremos desde el principio)
ultimo = primero;
while (ultimo->siguiente != NULL)
     ultimo = ultimo->siguiente;
// Segunda parte: crear el nodo nuevo e insertarlo en la lista
nuevo = (t_nodo*) malloc(sizeof(t_nodo));      // Creamos nodo nuevo
nuevo->dato = 18;                              // Le asignamos un valor al dato
ultimo->siguiente = nuevo;            // Lo enlazamos al (hasta ahora) último de la lista
nuevo->siguiente = NULL;              // Hacemos que el siguiente del nodo nuevo sea NULL

Si aplicamos este código a la lista de ejemplo del apartado anterior obtendremos esta otra lista:

Insertar un elemento a continuación de un nodo cualquiera de una lista

Para insertar un nodo nuevo en cualquier posición de una lista, es decir, entre otros dos nodos cualesquiera, el procedimiento es similar al anterior, sólo que ahora, en lugar de un puntero al último elemento, necesitaremos disponer de un puntero al nodo exacto a partir del cual pretendemos hacer la inserción.

Supongamos que queremos insertar el nuevo nodo entre los elementos 2 y 3 de la lista; entonces necesitaremos un puntero al elemento 2, así:

Con todos esos elementos, basta con reasignar los punteros para obtener la nueva lista:

  1. El nodo 2 dejará de apuntar al 3 y pasará a apuntar al nuevo nodo (4)
  2. El nuevo nodo pasará a apuntar al nodo 3

Como hemos hecho en los otros casos, vamos la implementación en C de este tipo de inserción.

Supondremos que estamos trabajando con la misma lista que en los ejemplos de los anteriores epígrafes, y que se desea insertar un nuevo nodo entre los datos 5 y 18. Necesitamos obtener un puntero al nodo que contiene el dato 5, y para ello debemos ir mirando los datos contenidos en todos los nodos desde el primero.

t_nodo *elemento, *nuevo;
// Primera parte: buscar el nodo en el que queremos insertar el nuevo (contendrá el dato 5)
elemento = primero;
while ((elemento->dato != 5) && (elemento != NULL))
    elemento = elemento->siguiente;
// Segunda parte: crear el nodo nuevo e insertarlo en la lista
if (elemento != NULL) {                         // Comprobamos que hemos encontrado el punto de inserción
    nuevo = (t_nodo*) malloc(sizeof(t_nodo));      // Creamos nodo nuevo
    nuevo->dato = 2;                               // Le asignamos un valor al dato
    nuevo->siguiente = elemento->siguiente;        // Lo enlazamos al siguiente de la lista
    elemento->siguiente = nuevo;                   // Hacemos que el anterior apunte al nodo nuevo
}

La lista resultante, siguiendo con el ejemplo anterior, será ésta:

(Este artículo forma parte del Curso de Programación en C)

La forma más simple de estructura dinámica es la lista abierta, también conocidas como listas simples o listas generales. Se trata de una especie de vector dinámico en el que el número de elementos puede crecer y decrecer a voluntad del programador en tiempo de ejecución.

En esta estructura, los nodos se organizan de modo que cada uno apunta al siguiente, y el último no apunta a nada, es decir, el puntero al nodo siguiente vale NULL.

En las listas abiertas existe un nodo especial: el primero. Para manejar la lista es necesario mantener un puntero a ese primer nodo, que llamaremos cabeza de la lista. Mediante ese único puntero-cabeza podemos acceder a toda la lista. Cuando el puntero-cabeza vale NULL, diremos que la lista está vacía.

Podemos representar gráficamente una lista de esta manera:

Esta lista contiene 4 datos. Observe cómo cada dato está enlazado con el nodo que contiene el siguiente dato y, además, el puntero primero apunta a la cabeza de la lista, es decir, al primer elemento. Es muy importante no perder nunca el valor de ese puntero, ya que en tal caso sería imposible acceder al primer nodo y, desde él, a todos los demás.

Tipos de datos para implementar listas abiertas

De aquí en adelante supondremos, por simplicidad, que estamos manejando una lista abierta de números enteros, pero el lector debe tener en cuenta que el tipo de dato con el que se construye la lista puede ser cualquiera, sin más que modificar la estructura del nodo.

Para construir una lista abierta de números enteros debemos definir los siguientes tipos de datos y variables:

struct s_nodo {
   int dato;
   struct nodo *siguiente;
};

typedef struct s_nodo t_nodo;
t_nodo *primero;

Observe que la estructura s_nodo contiene un dato (en nuestro caso, de tipo entero) seguido de un puntero a otro nodo. Después, se define una variable llamada primero, que será el puntero al primer nodo de la lista.

Operaciones con listas abiertas

Con las definiciones anteriores aún no tendremos disponible una lista abierta. Es importante darse cuenta de que el tipo de dato “lista abierta dinámica” no existe en C estándar. Para crearlo, debemos declarar los tipos de datos anteriores y, además, construir funciones en C que nos sirvan para utilizar esos datos. Entonces sí que tendremos disponible un nuevo tipo de dato para utilizar en nuestros programas y, además, podremos reutilizarlo en todos los programas en los que nos haga falta.

Las operaciones básicas que debemos programar para obtener el nuevo tipo “lista abierta” son:

  • Añadir o insertar elementos.
  • Buscar elementos.
  • Borrar elementos.

Estas son las operaciones fundamentales, pero podemos añadirles otras muchas operaciones secundarias que pueden llegar a sernos muy útiles, como:

  • Contar el número de elementos que hay en la lista.
  • Comprobar si la lista está vacía.
  • Borrar todos los elementos de la lista.
  • Etc.

En general, procuraremos programar cada operación con una función independiente. Esto facilitará la reusabilidad del código que escribamos. Hay que tener siempre presente que las funciones con listas abiertas, una vez programas, deben poder ser reutilizadas en otros programas con el mínimo número de cambios posible.

Con esa idea en mente, vamos a ver en los próximos artículos cómo podemos implementar las operaciones básicas para manejar listas abiertas.

(Este artículo forma parte del Curso de Programación en C)

Las estructuras estáticas tienen una importante limitación: no pueden cambiar de tamaño durante la ejecución. Por ejemplo, los arrays están compuestos por un determinado número de elementos y ese número se decide durante la codificación del programa, no pudiendo cambiarse en tiempo de ejecución.

En muchas ocasiones se necesitan estructuras que puedan cambiar de tamaño durante la ejecución del programa. Esas son las estructuras dinámicas.

C no dispone de estructuras dinámicas predefinidas, por lo que es tarea del programador construirlas basándose en estructuras estáticas y gestión dinámica de memoria. Además, habrá que programar una colección de funciones que manipulen esas estructuras. En la teoría de la computación, al conjunto de definición de los datos junto con las funciones que los manipulan se le denomina tipo abstracto de datos (TAD). Nosotros nos dejaremos de abstracciones, al menos por ahora, e iremos a lo práctico.

Ya que el programador se toma la molestia de implementar las estructuras y sus funciones, lo más habitual es que se asegure de que todo sea reutilizable, de manera que pueda usarlo en otros programas. A lo largo de todos nuestros artículos sobre estructuras dinámicas seguiremos este principio.

Como veremos, para desarrollar las estructuras dinámicas es imprescindible usar punteros y asignación dinámica de memoria, así que debería tener bastante claros esos conceptos antes de continuar.

Nodos

El fundamento de las estructuras de datos dinámicas es una estructura estática a la que llamaremos nodo o elemento. Éste incluye los datos con los que trabajará nuestro programa y uno o más punteros al mismo tipo nodo.

Por ejemplo, si la estructura dinámica va a guardar números enteros, el nodo puede tener esta forma:

struct s_nodo
{
    int dato;
    struct nodo *otro_nodo;
};

El campo otro_nodo apuntará a otro objeto del tipo nodo. De este modo, cada nodo puede usarse como un ladrillo para construir estructuras más complejas, y cada uno mantendrá una relación con otro u otros nodos (esto dependerá del tipo de estructura dinámica, como veremos).

A lo largo del tema usaremos una representación gráfica para mostrar las estructuras de datos dinámicas. El nodo anterior se representará así:

En el rectángulo de la izquierda se representa el dato contenido en el nodo (en nuestro ejemplo, un número entero). En el rectángulo de la derecha se representa el puntero, que apuntará a otro nodo.

Tipos de estructuras dinámicas

Dependiendo del número de punteros que haya en cada nodo y de las relaciones entre ellos, podemos distinguir varios tipos de estructuras dinámicas. A lo largo de los siguientes artículos iremos viendo algunas de estas estructuras, pero aquí las vamos a enumerar todas:

  • Listas abiertas: cada elemento sólo dispone de un puntero, que apuntará al siguiente elemento de la lista.

  • Pilas: son un tipo especial de lista, conocidas como listas LIFO (Last In, First Out: el último en entrar es el primero en salir). Los elementos se “amontonan” o apilan, de modo que sólo el elemento que está encima de la pila puede ser leído, y sólo pueden añadirse elementos encima de la pila.

  • Colas: otro tipo de listas, conocidas como listas FIFO (First In, First Out: El primero en entrar es el primero en salir). Los elementos se almacenan en una lista, pero sólo pueden añadirse por un extremo y leerse por el otro.

  • Listas circulares: o listas cerradas, son parecidas a las listas abiertas, pero el último elemento apunta al primero. De hecho, en las listas circulares no puede hablarse de “primero” ni de “último”.

  • Listas doblemente enlazadas: cada elemento dispone de dos punteros, uno apunta al siguiente elemento y el otro al elemento anterior. Al contrario que las listas abiertas, estas listas pueden recorrerse en los dos sentidos.

  • Árboles: cada elemento dispone de dos o más punteros, pero las referencias nunca son a elementos anteriores, de modo que la estructura se ramifica y crece de modo jerárquico.

  • Árboles binarios: son árboles donde cada nodo sólo puede apuntar a dos nodos.

  • Árboles binarios de búsqueda (ABB): son árboles binarios ordenados, por lo que la búsqueda de información en ellos es menos costosa. Desde cada nodo todos los nodos de una rama serán mayores, según la norma que se haya seguido para ordenar el árbol, y los de la otra rama serán menores.

  • Árboles AVL: son también árboles de búsqueda, pero su estructura está más optimizada para reducir los tiempos de búsqueda.

  • Árboles B: son otro tipo de árboles de búsqueda más complejos y optimizados que los anteriores.

  • Tablas HASH: son estructuras auxiliares para ordenar listas de gran tamaño.

  • Grafos: son árboles no jerarquizados, es decir, en los que cada nodo puede apuntar a nodos de nivel inferior o de nivel superior. De hecho, no se puede hablar de nivel “superior” e “inferior”. Son las estructuras dinámicas más complejas.

Para terminar con esta introducción, señalar que también pueden existir estructuras dinámicas aún más complejas, en las que los nodos pueden ser de distinto tipo.

El mundo real es continuo, al menos a nuestra escala macroscópica (dejemos la física cuántica de lado en esta discusión). Por lo tanto, las señales procedentes del mundo real son contínuas.

Calma, que no cunda el pánico. Vamos a tratar de explicarlo.

Pensemos en un sonido cualquiera; por ejemplo, el sonido producido por nuestra propia voz al pronunciar la vocal “A”. Este sonido es, básicamente, una vibración que se propaga en forma de ondas a través de algún medio, generalmente el aire. Cuando esa onda llega a un oído humano, produce una vibración del tímpano que el cerebro del oyente interpreta.

Suponga que pronuncia la vocal “A” ininterrumpidamente durante 5 segundos. La onda, o señal, que estará usted transmitiendo por el aire tendrá más o menos este aspecto:

digit_img1.png

El eje X representa el tiempo (de 0 a 5 segundos) y el eje Y la amplitud de la onda sonora. Cuando más fuerte sea el sonido, más amplitud; cuanto más débil, menos amplitud. También podríamos representar la variación de la frecuencia del sonido con el tiempo: los sonidos agudos tienen frecuencias altas, y los graves, frecuencas bajas. En cualquier caso, tendríamos una representación gráfica absolutamente fiel del sonido.

Ahora llega el momento de pensar: si, de esa señal, nos quedamos sólo con los dos primeros segundos, ¿se corresponderá la onda con la de la vocal “A”?

digit_img2.png

La respuesta es sí. Sería como si usted hubiera dicho “A” durante sólo 2 segundos.

Si nos quedamos sólo con el primer segundo, la señal sigue siendo una “A”. Y si reducimos aún más el tiempo, y nos quedamos sólo con medio segundo, o con una décima o con una mil millonésima de segundo, seguiremos teniendo una pequeñísima parte de la señal de la vocal “A”. Podemos tomar una parte infinitamente pequeña de la señal, y seguirá siendo una “A” (infinitamente corta).

Cuando decimos que una señal es continua, nos referimos precisamente a eso: entre dos instantes de tiempo cualesquiera, por muy cercanos que sean, la señal no se interrumpe, sino que se extiende de manera continua en el tiempo.

Señales digitales

Cuando se pretende grabar una señal continua, como el sonido, en un ordenador, nos enfrentamos con un problema sin posibilidad de solución. Lo exponemos a continuación.

El ordenador sólo entiende números (binarios, por cierto). Luego la señal debe ser convertida a números. Así, para grabar en el ordenador la señal de 5 segundos de la vocal “A”, lo único que puede hacerse es transmitirle a la máquina esta información:

  • Al principio de la señal, tiene una amplitud de, por ejemplo, 50.
  • Un instante después, la señal tiene una amplitud de 52.
  • Un instante después, la ampitud es de 54.

Y así sucesivamente, hasta que, instante tras instante, recorramos la señal desde el segundo 0 hasta el segundo 5. Al final, el ordenador entenderá que la vocal “A” tiene esta señal:

digit_img3.png

Cada punto de la señal se denomina muestra. El resultado depende de lo próximas entre sí que estén las muestras. Si le pasamos al ordenador el valor de la señal con las muestras separadas 0,01 segundos, obtendremos puntos más juntos. Si separamos las muestras 1 segundo, obtendremos puntos más dispersos, como aquí:

digit_img4.png

Pues bien, lo que están viendo en estos gráficos son señales digitales, también llamadas señales discontínuas (el evidente por qué, ¿no?). Como ven, la señal digital se parece a la señal analógica, pero no es igual. Por lo tanto, cualquier sonido grabado en un ordenador no es igual que el sonido real que produjo la grabación. Y esto es aplicable a cualquier otra señal del mundo real, como, por ejemplo, las imágenes.

Observe que no importa lo próximas que estén entre sí las muestras: nunca conseguiremos que la señal sea continua. Siempre se compondrá de puntos dispersos. Para conseguir una réplica exacta de la señal continua, necesitaríamos tomar muestras infinitamente próximas entre sí o, lo que es lo mismo, necesitaríamos tomar un número infinito de muestras. Pero eso es imposible, porque los ordenadores son máquinas finitas: tienen una cantidad limitada de memoria y una capacidad limitada de proceso.

Así que, ¿cómo lo hacemos? ¿Existe alguna posibilidad de transformar señales continuas en digitales sin perder gran parte del contenido por el camino?

Digitalización

Se denomina digitalización al proceso de conversión de una señal analógica (contínua) en digital (discontínua). Como ya hemos visto, las señales digitales no son exactamente iguales a las analógicas, sólo parecidas. Así, por ejemplo, el sonido digital grabado en un CD de música no es exactamente el mismo sonido que produjo el cantante durante la grabación.

Sin embargo, los CDs de música se oyen estupendamente, ¿no es cierto? ¿Cómo es posible, si lo que hay grabado en ellos no es más que una mala copia discontínua de la señal de sonido original, que era contínua?

La respuesta está en la frecuencia de muestreo, es decir, la cantidad de muestras (o puntos) de la señal que se toman por unidad de tiempo. Cuantas más muestras cojamos por segundo, más próximos estarán los puntos entre sí, y más se parecerá la señal digital a la analógica.
La frecuencia de muestreo se mide en Hertzios (Hz). Por ejemplo, una frecuencia de muestreo de 100 Hz equivale a tomar 100 muestras en cada segundo.

Existe un teorema físico denominado teorema del muestreo (o teorema de Nyquist – Shannon, si quiere usted hacerse el interesante), que no vamos a discutir ahora porque este post saldría kilométrico. Lo que nos interesa el teorema es que establece cuál es la frecuencia de muestreo necesaria para cada señal de manera que no se note la digitalización. Establece que esa frecuencia crítica es exactamente el doble de la frecuencia máxima que queremos reproducir con fidelidad.

¿Que no se ha entendido? A ver así: el oido humano puede percibir sonidos de una frecuencia entre 50 y 20.000 Hz, más o menos. Si usamos una frecuencia de muestreo de 40.000 Hz, se conservarán con absoluta precisión todos los sonidos de hasta 20.000 Hz y, por lo tanto, el oído humano será totalmente incapaz de percibir la diferencia entre la señal digital y la analógica. En cambio, si muestreamos con una frecuencia de 20.000 Hz, sólo se conservarán los sonidos por debajo de 10.000 Hz. Las frecuencias por encima de ésa (las componentes más agudas del sonido) se deformarán al digitalizarse, y el sonido digital perderá calidad con respecto al analógico.

¿Cómo se oye un sonido digitalizado a 40.000 Hz? Bueno, los CDs de música contienen el sonido muestreado a 44.000 Hz. Por eso la música digital grabada en ese soporte se oye tan limpia, tan perfecta, aunque la señal es discontínua.

El sonido digitalizado a 20.000 Hz se oye, aproximadamente, como una emisora de radio en FM. El sonido digitalizado a 10.000 Hz, en cambio, se empieza a oir francamente mal (sólo reproducirá con fidelidad las frecuencias por debajo de 5.000 Hz), con un sonido metálico parecido al de la línea telefónica.

Cuantificación

Al digitalizar una señal continua, además de “recortar” la señal en el tiempo, cogiendo sólo unas cuantas muestras de las infinitas disponibles, es necesario darle un valor a esas muestras. A esto se le llama cuantificar la señal.

Expliquémoslo más despacio con un ejemplo. Volviendo a las señales de sonido: si muestreamos una señal que dura 5 segundos con una frecuencia de 10 Hz, tomaremos 10 muestras por segundo, es decir, una muestra cada 0,1 segundos. Al ordenador, por lo tanto, se le transmite esta información:

  • En el segundo 0.0, la energía de la señal es 82 (por ejemplo)
  • En el segundo 0.1, la energía de la señal es 67
  • El en segundo 0.2, la energía de la señal es 75
  • Y así sucesivamente,  hasta llegar al segundo 5.

Pues bien, la cuantificación consiste en dar un valor numérico a la energía de la señal en cada muestra (en el ejemplo, 82, 67, 75, etc.)
Esos números, en realidad, deben ser binarios, que son los únicos que entiende el ordenador. La cantidad de números diferentes disponibles dependerá del número de bits que usemos.

  • Si cuantificamos con 2 bits, tendremos sólo 4 valores posibles para la energía de la señal: 00, 01, 10 y 10 (es decir, 0, 1, 2 y 3).
  • Si cuantificamos con 8 bits, tendremos 256 valores posibles: 00000000, 00000001, 00000010, hasta 1111111.
  • Si cuantificamos con 16 bits, tendremos 65.536 valores posibles.

Lógicamente, cuantos más bits utilicemos en la cuantificación, más “fino” y aproximado a la realidad será el resultado final. Por ejemplo, en los CDs de música se utiliza una cuantificación de 16 bits.

¿Cuánta memoria ocupa la señal digitalizada?

Para guardar una señal digital en el ordenador, se almacenan en un archivo todas las muestras con su valor numérico. Por lo tanto, cuantas más muestras tomemos, es decir, cuanto mayor sea la frecuencia de muestreo, más espacio ocupará el archivo. Como para cada muestra se guarda su valor numérico, cuantos más bits empleemos en la cuantificación, más espacio ocupará el archivo.

De aquí se deduce que, conforme crece la calidad de una señal digitalizada, más memoria ocupa y, por lo tanto, más recursos del ordenador consume.

Además, si el archivo de sonido es estéreo, contiene realmente dos señales diferentes, una para ser reproducida por el altavoz izquierdo y otra para el derecho, con lo que ocupa el doble de espacio que un archivo no estéreo. Por eso, los ordenadores multimedia, además del hardware específico del que ya hemos hablado, deben ser en general equipos muy potentes, con gran cantidad de espacio en el disco duro y gran cantidad de memoria RAM. Éste también es el motivo por el que son tan populares los formatos de archivo donde el sonido se almacena de forma comprimida, como el MP3: la misma señal digitalizada puede ocupar mucho menos espacio se se la comprime, a costa de perder calidad.

¿Y qué pasa con las imágenes?

Bien, con las imágenes no pasa nada en particular. Son señales continuas, como el sonido, pero más complejas. Se digitalizan del mismo modo, es decir, por aproximación a las imágenes reales: nunca son reproducciones exactas. En los sonidos, tomábamos una muestra de la señal sonora cada cierto tiempo, componiendo una señal discontínua que se parecía a la original. En las imágenes, tomaremos una muestra cada cierto espacio (no cada cierto tiempo) de la señal luminosa original, componiendo una señal luminosa discontínua parecida a la original.

Por lo tanto, lo que hacemos al digitalizar imágenes reales es pasarle al ordenador el color de puntos luminosos muy próximos entre sí. A esos puntos los llamamos píxeles. Cuanto más próximos estén los píxeles, menos se notarán las discontinuidades.

Las imágenes digitales también necesitan mucha memoria (sobre todo si son imágenes en movimiento, es decir, vídeos). Por ese motivo los formatos de archivo más populares utilizan técnicas de compresión, ya sea con pérdida de calidad (como el formato JPEG) o sin pérdida (como PNG o GIF). Otro día, si les parece, hablaremos de los formatos de archivo y de los algoritmos de compresión.

(Este artículo forma parte del Curso de Programación en C)

Aunque los que empiezan con el lenguaje C creen a menudo que los punteros han sido inventados para amargar la vida del estudiante, la verdad es que son tremendamente útiles. Entre sus utilidades, las dos más notables son:

Vamos a centrarnos ahora en la segunda utilidad. Emplearemos para esta discusión el ejemplo de los arrays por ser la estructura de datos más simple y fácil de entender, pero lo que digamos aquí es extensible a otras estructuras de datos diferentes. De hecho, dedicaremos muchos artículos posteriores a estudiar otras estructuras de datos dinámicas más complejas.

Ya que un nombre de array es en realidad un puntero a su primer elemento, es posible definir un array como una variable puntero en vez de como un array convencional. Así, estas dos definiciones sirven para un vector de números enteros:

int vector1[100];
int* vector2;
  • El vector1 se define del modo convencional de un array. Esto produce la reserva de un bloque fijo de memoria al empezar la ejecución del programa lo suficientemente grande como para almacenar 100 números enteros.
  • El vector2 se define como puntero a entero. En este caso, no se reserva ninguna cantidad de memoria para almacenar los números enteros.

Si intentamos acceder a los elementos de los vectores obtendremos resultados diferentes:

vector1[5] = 83;
vector2[5] = 27;    /* Esto es un error */

La primera asignación funcionará correctamente, ya que el quinto elemento del vector1 tiene un espacio de memoria asignado. La segunda asignación producirá un efecto impredecible, ya que vector2 no tiene ningún espacio de memoria asignado y, por lo tanto, el dato 27 se escribirá en una posición de memoria correspondiente a otro dato u otro programa. La consecuencia puede llegar a ser bastante desagradable.

Reservar memoria dinámicamente

Se necesita, pues, reservar un fragmento de memoria antes de que los elementos del array sean procesados. Tales tipos de reserva se realizan mediante la función malloc() o alguna de sus variedades.

Observe bien su uso en este ejemplo:

int *x;
x = (int *) malloc (100 * sizeof(int));

La función malloc() reserva un especio de memoria consistente en 100 veces el tamaño de un número entero. Fíjese bien en el uso del sizeof(int): se trata de un operador unario que devuelve el tamaño de un tipo de dato cualquiera, tanto simple como complejo.

Suponiendo que sizeof(int) fuera 2 (es decir, que cada número de tipo int ocupase 2 bytes), lo que se le está pidiendo a malloc() es que reserve 100 * 2 bytes, es decir, 200 bytes de memoria.

Además, es necesario usar el molde (int *), ya que malloc() devuelve un puntero sin tipo (es decir, un puntero a void), así que hay que convertirlo a puntero a entero antes de asignarlo a la variable x, que efectivamente es un puntero a entero.

De esta manera, la variable vector2 pasa a ser lo que podemos denominar un array dinámico, en el sentido de que se comporta como un array y puede usarse como tal, pero su tamaño ha sido definido durante la ejecución del programa (más adelante, en el mismo programa, podemos redefinir el tamaño del array para acortarlo o alargarlo)

Si la función malloc() falla devolverá un puntero a NULL. Utilizar un puntero a NULL es la forma más segura de estrellar el programa, así que siempre debemos comprobar que el puntero devuelto es correcto. Una vez hecho esto, podemos utilizar x con toda tranquilidad como si fuera un array de 100 números enteros. Por ejemplo:

int *x, i;
x = (int *) malloc (100 * sizeof(int));
if (x == NULL)
  printf("Error en la asignación de memoria");
else
{
  printf("Se ha reservado con éxito espacio para 100 números");
  for (i=0; i<100; i++)
  {
    printf("Introduzca un número:");
    scanf("%i", &x[i]);
  }
}

Liberación de memoria

El programador debe tener dos precauciones básicas a la hora de manejar la memoria dinámicamente:

  1. Asignar memoria a un puntero antes de usarlo con malloc() u otra función similar
  2. Liberar la memoria asignada, cuando ya no va a usarse más, con free() u otra función similar.

Si no se libera la memoria asignada a un puntero, teóricamente no ocurre nada grave, salvo que podemos terminar por agotar la memoria disponible si reservamos continuamente y nunca liberamos. Es, en cualquier caso, una costumbre muy saludable.

Para liberar la memoria reservada previamente con malloc() u otra función de su misma familia, se utiliza la función free(). Observe su uso en este ejemplo:

int *x, i;
x = (int *) malloc (100 * sizeof(int));
... instrucciones de manipulación de x ...
free(x);

Toda la memoria reservada con malloc() quedará liberada después de hacer free() y se podrá utilizar para guardar otros datos o programas. El puntero x quedará apuntado a NULL y no debe ser utilizado hasta que se le asigne alguna otra dirección válida.

Funciones básicas para la gestión dinámica de la memoria

Además de malloc() y free() existen otras funciones similares pero con pequeñas diferencias. A continuación resumimos las más usuales y mostramos un ejemplo de su uso.

Pero antes haremos una advertencia: todas las funciones de reserva de memoria devuelven un puntero a NULL si no tienen éxito. Por lo tanto, deben ir seguidas de un condicional que compruebe si el puntero apunta a NULL antes de utilizarlo: no nos cansaremos de repetir que utilizar un puntero a NULL es una manera segura de estrellar el programa.

calloc()

Reserva un bloque de memoria para almacenar num elementos de tam bytes y devuelve un puntero void al comienzo del bloque. La sintaxis es:

void* calloc(num, tam);

El siguiente ejemplo reserva espacio para 35 números enteros:

int* p;
p = (int*) calloc(35, sizeof(int));

free()

Libera el bloque de memoria apuntado por un puntero y que previamente había sido reservado.

free(puntero);

malloc()

Reserva un bloque de memoria de tam bytes y devuelve un puntero void al comienzo del mismo, según esta sintaxis:

void* malloc(tam);

Por ejemplo, para reservar espacio para una cadena de 100 caracteres:

char* texto;
texto = (char*) malloc(100 * sizeof(char));

realloc()

Cambia el tamaño de un bloque de memoria apuntado por puntero. Dicho bloque ha debido ser previamente asignado con malloc() u otra función similar. El nuevo tamaño será de tam bytes. Devuelve un puntero void al comienzo del bloque, y la sintaxis es:

void* realloc(puntero, tam);

En el siguiente ejemplo, se reserva espacio para 100 caracteres, pero luego se modifica el tamaño del bloque para dar cabida hasta 500 caracteres:

char* texto;
texto = (char*) malloc(100 * sizeof(char));
/* Aquí irían las instrucciones que utilicen el puntero texto
   con un tamaño de 100 caracteres */
texto = (char*) realloc(texto, 500 * sizeof(char));
/* A partir de aquí, el mismo puntero texto puede usarse para
   manejar hasta 500 caracteres */

(Este artículo forma parte del Curso de Programación en C)

Las funciones de C, como todo el código de todos los programas que se ejecutan en el ordenador, también ocupan unas posiciones concretas de la memoria principal. Por lo tanto, es posible disponer de un puntero a una función, es decir, de una variable que contenga la dirección de memoria en la que comienza el código de una función.

La declaración de un puntero a función se realiza así:

tipo_de_dato (*nombre_puntero) (lista_de_parámetros);

No debe confundirse con la declaración de una función que devuelve un puntero:

tipo_de_dato* nombre_función (lista_de_parámetros);

Posteriormente, la dirección de la función puede asignarse al puntero para luego ser invocada a través del puntero, en lugar de usar una llamada convencional:

nombre_puntero = nombre_función;    /* Asignación al puntero de la dirección de la función */
(*nombre_puntero)(lista_de_parámetros);    /* Invocación de la función */

Puede preguntarse usted, con toda razón, qué ventajas proporciona este método rebuscado de invocar.  funciones. Bien, hay algunas (si no, no se habrían inventado los punteros a funciones). Una es la eficiencia: es más rápido invocar a una función a través de un puntero. Otra es que, aunque no lo crea, hay determinados casos en los que es más fácil tener punteros a funciones que llamadas convencionales. Podemos, por ejemplo, construir un array cuyos elementos sean punteros a funciones, para poder invocar a cada función sencillamente indexando el array.

(Este artículo forma parte del Curso de Programación en C)

En general, la memoria reservada para cada variable se define en el momento de escribir el código del programa. Por ejemplo, si declaramos una variable de tipo int, ésta tendrá asignados 2 bytes de memoria (aunque esa cantidad puede variar dependiendo del compilador y del sistema operativo). Entonces, si declaramos un array de 100 números enteros, el array tendrá reservados 200 bytes de memoria.

¿Pero qué ocurre si no sabemos de antemano cuántos elementos puede llegar a tener el array?

Por ejemplo, imaginemos un problema consistente en leer por teclado (u otro dispositivo de entrada) una cantidad indefinida de números para almacenarlos en un array y luego hacer ciertas operaciones con ellos. ¿De qué tamaño podemos definir el array? ¿De 100 elementos? ¿Y si el usuario introduce 101 elementos?

Podemos pensar, entonces, que será suficiente con definir el array muy grande: de 1000 elementos, o de 5000, o de 10000… pero siempre existe la posibilidad de que el programa no funcione correctamente por desbordamiento del espacio reservado a las variables. Y, por otra parte, si definimos un array de enormes dimensiones y luego la mayoría de sus posiciones no se utilizan, estaremos desperdiciando los recursos de la máquina.

Para evitar esto existe la asignación dinámica de memoria, que consiste en reservar memoria para las variables en tiempo de ejecución, es decir, mientras el programa está funcionando. Así, es posible “estirar” o “encoger” sobre la marcha el espacio reservado para el array, dependiendo de las necesidades de cada momento, y no limitarse a los 100, 1000 ó 10000 elementos que definió el programador al escribir el código.

Veremos en los siguientes posts que, para manejar la memoria dinámicamente, es imprescindible el uso de punteros. De hecho, este es el mejor fruto que vamos a obtener de ellos.

(Este artículo forma parte del Curso de Programación en C)

Un último aspecto (a la vez confuso y potente) de los punteros es la posibilidad de definir punteros que, a su vez, apunten a otros punteros. A esto se le llama indirección múltiple.

La declaración de un puntero a puntero se hace así:

tipo_de_dato **nombre_puntero;

Por ejemplo, el resultado del siguiente fragmento de código en C debe ser que se imprima el número 15 en la pantalla:

int n;
int* p1;
int** p2;
p1 = &n;    /* p1 contiene la dirección de n */
p2 = &p1;    /* p2 contiene la dirección de p1 */
**p2 = 15;
printf("%i", n);

Mediante este procedimiento pueden crearse indirecciones aún más “indirectas”: punteros que apuntan a punteros que a su vez apuntan a punteros, y así hasta el infinito. Sin embargo, la doble indirección suele ser el límite práctico que habitualmente se maneja. Además, las indirecciones triples, cuádruples o más, pueden ser condenadamente difíciles de comprender.

(Este artículo forma parte del Curso de Programación en C)

Paso de parámetros que son punteros

A menudo los punteros son pasados a las funciones como argumentos. Esto permite que datos de la porción de programa desde el que se llama a la función sean accedidos por la función, alterados dentro de ella y devueltos de forma alterada. Este uso de los punteros se conoce como paso de parámetros por variable o referencia y lo hemos estado utilizando hasta ahora sin saber muy bien lo que hacíamos.

Cuando los punteros son usados como argumento de una función, es necesario tener cuidado con la declaración y uso de los parámetros dentro de la función. Los argumentos formales que sean punteros deben ir precedidos por un asterisco. Observe detenidamente el siguiente ejemplo:

#include <stdio.h>
void funcion1(int, int);
void funcion2(int*, int*);
int main(void)
{
  int u, v;
  u = 1;
  v = 3;
  funcion1(u,v);
  printf("Después de la llamada a funcion1:  u=%d v=%d\n", u, v);
  funcion2(&u,&v);
  printf("Después de la llamada a funcion2:  u=%d v=%d\n", u, v);
}
void funcion1(int u, int v)   
{
   u=0;
   v=0;
}
void funcion2(int *pu, int *pv)
{
   *pu=0;
   *pv=0;
}

La función de nombre funcion1 utiliza paso de parámetros por valor. Cuando es invocada, los valores de las variables u y v del programa principal son copiados en los parámetros u y v de la función. Al modificar estos parámetros dentro de la función, el valor de u y v en el programa principal no cambia.

En cambio, funcion2 utiliza paso de parámetros por variable (también llamado paso de parámetros por referencia o por dirección). Lo que se pasa a la función no es el valor de las variables u y v, sino su dirección de memoria, es decir, un puntero a las celdas de memoria donde u y v están almacenadas. Dentro de la función, se utiliza el operador asterisco para acceder al contenido de pu y pv y, en consecuencia, se altera el contenido de las posiciones de memoria apuntadas por pu y pv. El resultado es que las variables u y v del programa principal quedan modificadas.

Por lo tanto, la salida del programa debe ser:

Después de la llamada a funcion1:  u=1 v=3
Después de la llamada a funcion2:  u=0 v=0

Recuerde que la función scanf() requiere que sus argumentos vayan precedidos por &, mientras que printf() no lo necesitaba. Hasta ahora no podíamos comprender por qué, pero ahora podemos dar una razón: scanf() necesita que sus argumentos vayan precedidos del símbolo & porque necesita las direcciones de los datos que van a ser leídos, para poder colocar en esas posiciones de memoria los datos introducidos por teclado. En cambio, printf() no necesita las direcciones, sino únicamente los valores de los datos para poder mostrarlos en la pantalla.

Al estudiar los arrays y las estructuras ya vimos en detalle cómo se deben pasar como parámetros a las funciones. Recuerda que los arrays siempre se pasan por variable y no es necesario usar el símbolo & en la llamada, ya que el propio nombre del array se refiere, en realidad, a la dirección del primer elemento.

Devolución de punteros

Una función también puede devolver un puntero. Para hacer esto, la declaración de la función debe indicar que devolverá un puntero. Esto se realiza precediendo el nombre de la función con un asterisco. Por ejemplo:

double *funcion(argumentos);

Cuando esta función sea invocada, devolverá un puntero a un dato de tipo double, y por lo tanto debe ser asignada a una variable de ese tipo. Por ejemplo, así:

double *pf;
pf = funcion(argumentos);
printf("%lf", *pf);

(Este artículo forma parte del Curso de Programación en C)

Punteros y vectores

Los punteros y los arrays tienen una relación muy estrecha en C, ya que el nombre de un array es en realidad un puntero al primer elemento de ese array. Si x es un array undimensional, la dirección del primer elemento puede ser expresada como &x[0] o simplemente como x. La dirección del elemento i-ésimo se puede expresar como &x[i] o como (x+i).

(En este caso, la expresión (x+i) no es una operación aritmética convencional, sino una operación con punteros, de cuyas peculiaridades ya hemos hablado en un epígrafe anterior)

Si &x[i] y (x+i) representan la dirección del i-ésimo elemento de x, podemos decir que x[i] y *(x+i) representan el contenido de esa dirección, es decir, el valor del i-ésimo elemento de x. Observe que la forma x[i] es la que hemos estado utilizando hasta ahora para acceder a los elementos de un vector.

Los arrays, por lo tanto, pueden utilizarse con índices o con punteros. Al programador suele resultarle mucho más cómodo utilizar la forma x[i] para acceder al elemento i-ésimo de un array. Sin embargo, hay que tener en cuenta que la forma *(x+i) es mucho más eficiente que x[i], por lo que suele preferirse cuando la velocidad del ejecución es un factor determinante.

Punteros y arrays multidimensionales

Un array multidimensional es en realidad una colección de varios arrays unidimensionales (vectores). Por tanto, se puede definir un array multidimensional como un puntero a un grupo contiguo de arrays unidimensionales.

El caso más simple de array de varias dimensiones es el bidimiensional. La declaración de un array bidimensional la hemos escrito hasta ahora como:

tipo_dato variable [expresión1][expresión2];

Pero también puede expresarse así:

tipo_dato (*variable) [expresión2];

Los paréntesis que rodean al puntero deben estar presentes para que la sintaxis sea correcta.

Por ejemplo, supongamos que x es un array bidimensional de enteros con 10 filas y 20 columnas. Podemos declarar x como:

int x[10][20];

Y también como:

int (*x)[20];

En la segunda declaración, x se define como un puntero a un grupo de array unidimensionales de 20 elementos enteros. Así x apunta al primero de los arrays de 20 elementos, que es en realidad la primera fila (fila 0) del array bidimensional original. Del mismo modo (x+1) apunta al segundo array de 20 elementos, y así sucesivamente.

Por ejemplo, el elemento de la fila 2 y la columna 5 puede ser accedido así:

x[2][5];

Pero también así:

*(*(x+2)+5);

Esta instrucción parece muy complicada pero es fácil de desentrañar:

  • (x+2) es un puntero a la fila 2
  • *(x+2) es el objeto de ese puntero y refiere a toda la fila. Como la fila 2 es un array unidimensional, *(x+2) es realmente un puntero al primer elemento de la fila 2.
  • (*(x+2)+5) es un puntero al elemento 5 de la fila 2.
  • El objeto de este puntero *(*(x+2)+5) refiere al elemento 5 de la fila 2.

Arrays de punteros

Un array multidimensional puede ser expresado como un array de punteros en vez de como un puntero a un grupo contiguo de arrays. En términos generales un array bidimensional puede ser definido como un array unidimensional de punteros escribiendo:

tipo_dato *variable[expresión1];

…en lugar de la definición habitual, que sería:

tipo_dato variable[expresión1][expresión2];

Observe que el nombre del array precedido por un asterisco no está cerrado entre paréntesis. Ese asterisco que precede al nombre de la variable establece que el array contendrá punteros.

Por ejemplo, supongamos que x es un array bidimensional de 10 filas y 25 columnas. Se puede definir x como un array unidimensional de punteros escribiendo:

int *x[25];

Aquí x[0] apunta al primer elemento de la primera columna, x[1] al primer elemento de la segunda columna, y así sucesivamente. Observe que el número de elementos dentro de cada columna no está especificado explícitamente. Un elemento individual del array, tal com x[2][5] puede ser accedido escribiendo:

*(x[2]+5)

En esta expresión, x[2] es un puntero al primer elemento en la fila 2, de modo que (x[2]+5) apunta al elemento 5 de la fila 2. El objeto de este puntero, *(x[2]+5), refiere, por tanto, a x[2][5].

Los arrays de punteros ofrecen un método conveniente para almacenar cadenas. En esta situación cada elemento del array es un puntero que indica dónde empieza cada cadena.

(Este artículo forma parte del Curso de Programación en C)

Con las variables de tipo puntero sólo se pueden hacer dos operaciones aritméticas: sumar o restar a un puntero un número entero, y restar dos punteros. Pero el resultado de esas operaciones no es tan trivial como puede parecer. Por ejemplo, si sumamos un 1 a un puntero cuyo valor sea 1200, el resultado puede ser 1201… ¡pero también puede ser 1202 ó 1204! Esto se debe a que el resultado depende del tipo de dato al que apunte el puntero.

Sumar o restar un valor entero a un puntero

Al sumar un número entero a un puntero se incrementa la dirección de memoria a la que apunta. Ese incremento depende del tamaño del tipo de dato apuntado.

Si tenemos un puntero p y lo incrementamos en una cantidad entera N, la dirección a la que apuntará será:

dirección_original + N * tamaño_del_tipo_de_dato

Por ejemplo, imaginemos un puntero p a carácter que se incrementa en 5 unidades, así:

char* p;
p = p + 5;

Supongamos que p apunta a la dirección de memoria 800. Como cada carácter ocupa 1 byte, al incrementarlo en 5 unidades, p apuntará a la dirección 805.

Veamos ahora que pasa si, por ejemplo, el puntero p apunta a un número entero:

int* p;
p = p + 5;

Si la dirección inicial de p es también la 800, al incrementarlo en 5 unidades pasará a apuntar a la dirección 810 (suponiendo que cada entero ocupe 2 bytes).

Todo esto también explica qué ocurre cuando se resta un número entero de un puntero, sólo que entonces las direcciones se decrementan en lugar de incrementarse.

A los punteros también se les puede aplicar las operaciones de incremento (++) y decremento (–) de C, debiendo tener el programador en cuenta que, según lo dicho hasta ahora, la dirección apuntada por el puntero se incrementará o decrementará más o menos dependiendo del tipo de dato apuntado.

Por ejemplo, si los datos de tipo int ocupan 2 bytes y el puntero p apunta a la dirección 800, tras la ejecución de este fragmento de código, el puntero p quedará apuntado a la dirección 802:

int *p;
p++;

Resta de dos punteros

La resta de punteros se usa para saber cuantos elementos del tipo de dato apuntado caben entre dos direcciones diferentes.

Por ejemplo, si tenemos un vector de números reales llamado serie podemos hacer algo así:

float serie[15];
int d;
float *p1, *p2;
p1 = &tabla[4];
p2 = &tabla[12];
d = p1 – p2;

El puntero p1 apunta al quinto elemento del vector, y el puntero p2, al decimotercero. La restar los dos punteros obtendremos el valor 8, que es el número de elementos de tipo float que pueden almacenarse entre las direcciones p1 y p2.

(Este artículo forma parte del Curso de Programación en C)

Se puede asignar una variable puntero a otra siempre que ambas apunten al mismo tipo de dato. Al realizar la asignación, ambos punteros quedarán apuntando a la misma dirección de memoria.

Observe este ejemplo y juegue un rato a tratar de determinar qué resultado se obtiene en la pantalla (antes de leer la solución que aparece más abajo; si no, no tiene gracia):

int a, b, c;
int *p1, *p2;
a = 5;
p1 = &a;    /* p1 apunta a la dirección de memoria de la variable a */
p2 = p1;    /* a p2 se le asigna la misma dirección que tenga p1 */
b = *p1;
c = *p1 + 5;    /* Suma 5 a lo que contenga la dirección apuntada por p1 */
printf("%i %i %i %p %p", a, b, c, p1, p2);

.

.

.

.

.

Solución: En la pantalla se imprimirá “5 5 10”, que es el contenido de las variables a, b y c al terminar la ejecución de este bloque de instrucciones, y la dirección a la que apuntan p1 y p2, que debe ser la misma. Observa que con printf y la cadena de formato “%p” se puede mostrar la dirección de memoria de cualquier variable.

(Este artículo forma parte del Curso de Programación en C)

Las variables de tipo puntero, como cualquier otra variable, deben ser declaradas antes de ser usadas.

Cuando una variable puntero es definida, el nombre de la variable debe ir precedido por un *. El tipo de dato que aparece en la declaración se refiere al tipo de dato que se almacena en la dirección representada por el puntero, en vez del puntero mismo. Así, una declaración de puntero general es:

tipo_dato *puntero;

donde puntero es la variable puntero y tipo_dato el tipo de dato apuntado por el puntero. Por ejemplo:

int *numero;
char *letra;

La variable numero no contiene un número entero, sino la dirección de memoria donde se almacenará un número entero. La variable letra tampoco contiene un carácter, sino una dirección de memoria donde se almacenará un carácter.

Cuando un puntero ha sido declarado pero no inicializado, apunta a una dirección de memoria indeterminada. Si tratamos de usarlo en esas condiciones obtendremos resultados impredecibles (y casi siempre desagradables). Antes de usar cualquier puntero hay que asegurarse de que está apuntando a una dirección válida, es decir, a la dirección de alguna variable del tipo adecuado. Por ejemplo, así:

int *numero;
int a;
numero = &a;

El puntero numero ahora sí está en condiciones de ser usado, porque está apuntado a la dirección de la variable a, que es de tipo int, como el puntero.

Otra posibilidad es hacer que un puntero apunte a NULL. El identificador NULL es una constante definida en el lenguaje que indica que un puntero no está apuntando a ninguna dirección válida y que, por lo tanto, no se debe utilizar.

(Este artículo forma parte del Curso de Programación en C)

Comprender y usar correctamente los punteros es probablemente lo más complicado del lenguaje C, pero también se trata de una herramienta muy poderosa. Tan poderosa que un simple puntero descontrolado (hay quien acertadamente los llama “punteros locos“) puede provocar que falle todo el sistema y haya que reiniciar la máquina.

El escurridizo concepto de “puntero”

Dentro de la memoria del ordenador, cada dato almacenado ocupa una o más celdas contiguas de memoria. El número de celdas de memoria requeridas para almacenar un dato depende de su tipo. Por ejemplo, un dato de tipo entero puede ocupar 16 bits (es decir, 2 bytes) o 32 bits (4 bytes), mientras que un dato de tipo carácter ocupa 8 bits (es decir, 1 byte).

Pues bien, un puntero no es más que una variable cuyo contenido no es un dato, sino la dirección de memoria donde está almacenado un dato.

Veámoslo a través de un ejemplo. Imaginemos que v es una variable de tipo carácter y que, por tanto, necesita 1 byte para ser almacenada. La declaración e inicialización de la variable será como la siguiente:

char v;
v = 'A';

Al ejecutar este código, el sistema operativo asigna automáticamente una celda de memoria para el dato. Supongamos que la celda asignada tiene la dirección 1200. Al hacer la asignación v = ‘A’, el sistema almacena en la celda 1200 el valor 65, que es el código ASCII de la letra ‘A’:

                       ----+----+----+----+----+----+----
Dirección de memoria    ...|1198|1199|1200|1201|1202|...
                       ----+----+----+----+----+----+----
Contenido                  |    |    | 65 |    |    |
                       ----+----+----+----+----+----+----

Cuando usamos la variable v a lo largo del programa, el sistema consulta el dato contenido en la celda de memoria asignada a la variable. Esa celda será siempre la misma a lo largo de la ejecución: la 1200. Por ejemplo, al encontrar esta instrucción:

printf("%c", v);

.. la CPU acude a la celda 1200 de la memoria, consulta el dato almacenado en ella en ese momento lo muestra en la pantalla con formato de carácter. Así vemos en la pantalla una letra A.

El programador no tiene modo de saber en qué posición de memoria se almacena cada dato, a menos que utilice punteros. Los punteros sirven, entonces, para conocer la dirección de memoria donde se almacena el dato, y no el dato en sí.

Operadores para punteros: & y *

La dirección ocupada por una variable v se determina escribiendo &v. Por lo tanto, & es un operador unario, llamado operador dirección, que proporciona la dirección de una variable.

La dirección de v se le puede asignar a otra variable mediante esta instrucción:

char* p;
p = &v;

Resultará que esta nueva variable es un puntero a v, es decir, una variable cuyo contenido es la dirección de memoria ocupada por la variable v. Representa la dirección de v y no su valor. Por lo tanto, el contenido de p será 1200, mientras que el contenido de v será 65.

El dato almacenado en la celda apuntada por la variable puntero puede ser accedido mediante el operador asterisco aplicado al puntero. Así pues, la expresión *p devuelve el valor 65, que es el contenido de la celda apuntada por p. El operador * es un operador unario, llamado operador indirección, que opera sólo sobre una variable puntero.

Resumiendo: podemos tener variables “normales” y utilizar el operador & para conocer su dirección de memoria. O podemos tener variables puntero, que ya son en sí mismas direcciones de memoria, y utilizar el operador * para acceder al dato que contienen. Así pues:

  • El operador dirección (&) sólo puede actuar sobre variables que no sean punteros. En el ejemplo anterior, la variable v vale 65 y la expresión &v vale 1200.
  • El operador indirección (*) sólo puede actuar sobre variables que sean punteros. En el ejemplo anterior, la expresión *p vale 65 y la variable p vale 1200.

Las variables puntero pueden apuntar a direcciones donde se almacene cualquier tipo de dato: enteros, flotantes, caracteres, cadenas, arrays, estructuras, etc. Esto es tremendamente útil y proporciona una enorme potencia al lenguaje C, pero también es una fuente inagotable de errores de programación difíciles de detectar y corregir, como iremos viendo en los siguientes temas

(Este artículo forma parte del Curso de Programación en C)

Existe un grupo de funciones de ANSI C que sirven para manipulación de directorios (o carpetas, en terminología de Windows). Estas funciones no actúan sobre flujos, sino sobre archivos y directorios directamente, por lo que hay que pasarles el nombre del archivo o del directorio en una cadena de texto.

A este respecto hay que destacar que la barra invertida (“\”) que separa los directorios en Windows no puede utilizarse directamente en una cadena, ya que en C la barra invertida se usa para los caracteres especiales (por ejemplo, el retorno de carro se representa “\n”). Para usar la barra invertida en una constante de cadena debemos usar la secuencia de escape “\\”. Por ejemplo, para borrar el archivo C:\PRUEBAS\DATOS.TXT debemos escribir:

remove("C:\\PRUEBAS\\DATOS.TXT");

Aclarado esto, enumeramos a continuación las funciones de directorio más útiles:

remove()

Borra un archivo del directorio. Devuelve 0 si el borrado se realiza con éxito, u otro valor en caso de error. Si el archivo está abierto no podrá borrarse hasta que se cierre.

int remove(char* nombre);

rename()

Cambia el nombre de un archivo. Devuelve 0 si el cambio se ha realizado u otro valor si ocurre un error.
int remove(char* nombre_antiguo, char* nombre_nuevo);

chdir()

Cambia el directorio activo. Normalmente se trabaja en el mismo directorio donde está el archivo ejecutable, llamado directorio activo. Todos los archivos que se escriban y lean se localizarán en ese directorio, a menos que lo cambiemos.

int chdir(char* nombre_dir);

La función devuelve 0 si el cambio se produce con éxito u otro valor en caso contrario

mkdir()

Crea un directorio dentro del directorio activo. Devuelve 0 si la operación tiene éxito.

int mkdir(char* nombre_dir);

rmdir()

Borra un directorio. Para que el borrado tenga éxito, el directorio debe de estar vacío. Devuelve 0 si el borrado se completa correctamente.

int rmdir(char* nombre_dir);

Además, existen otras funciones para leer el contenido de un directorio (es decir, la lista de archivos que lo componen) y procesar dicho contenido. Dichas funciones escapan a la extensión de este artículo, pero el lector interesado puede buscar información sobre ellas: son opendir(), closedir(), readdir(), etc.

(Este artículo forma parte del Curso de Programación en C)

Como hemos dicho anteriormente, en los archivos de texto todos los datos se almacenan en forma de texto ASCII. Esto hace que podamos abrirlos, consultarlos y modificarlos con cualquier editor de texto, mientras que con los binarios no es posible.

En los archivos de texto, y dependiendo del compilador y del sistema operativo empleado, pueden producirse ciertas transformaciones automáticas de caracteres. En particular, es frecuente que el carácter invisible de fin de línea (representado habitualmente como LF) se convierta en dos caracteres al escribirlo en un archivo (fin de línea – LF – más retorno de carro – CR – ). También pueden ocurrir conversiones a la inversa, es decir, durante la lectura del archivo. Esto no sucede con los archivos binarios.

Todas las funciones de E/S sirven para ambos tipos de archivo, pero algunas pueden dar problemas con según qué tipos. Por ejemplo, fseek() no funciona bien con archivos de texto debido a las conversiones automáticas que antes mencionábamos. Desde cierto punto de vista, puede considerarse que un archivo de texto no es más que un archivo secuencial en el que cada registro es un carácter, por lo que tiene sentido que las funciones de acceso directo tengan problemas para tratar este tipo de archivos.

Como normas generales (que nos podemos saltar si la situación lo requiere, ya que C es lo bastante flexible como para permitirlo) propondremos las siguientes:

  • Cuando se trate de manipular datos simples, usaremos archivos de texto. Esto implica convertir los números a texto ASCII (lo cual es muy fácil de hacer usando fprintf() y fscanf() junto con las cadenas de formato). Sólo en el caso de que estas conversiones representen un inconveniente grave recurriremos a los archivos binarios.
  • Cuando tratemos con estructuras de datos más complejas, es mejor usar archivos binarios, a menos que nos interese abrir esos archivos con un editor de texto, en cuyo caso seguiremos usando archivos de texto.
  • Si necesitamos acceso directo, usaremos archivos binarios.
  • Las funciones fread() y fwrite() se usarán preferentemente con achivos binarios, y el resto de funciones de lectura y escritura se reservarán para archivos de texto.

(Este artículo forma parte del Curso de Programación en C)

Las funciones de lectura tienen el mismo comportamiento tanto si el archivo es secuencial como directo: empiezan leyendo desde el primer registro del archivo (si éste está recién abierto), y a partir de ahí van desplazando el punto de lectura hacia el final del archivo.

Las funciones de escritura, sin embargo, tienen un comportamiento diferente:

  • Si estamos manejando un archivo en modo secuencial, todas las funciones de escritura que hagamos van a escribir la información al final del archivo. Cada vez que se escribe un registro, el cursor o punto de escritura avanza automáticamente al final del archivo, donde se escribirá el siguiente registro.
  • Si el archivo es directo y se abre en modor+” o “w+“, la escritura se realizará, por defecto, al principio del archivo (eliminando los registros que existieran), o bien en cualquier otra posición indicada por el programador (ver función fseek() ). Cada vez que se escribe un registro, el cursor o punto de escritura no avanza automáticamente, sino que es el programador el que debe situarlo (nuevamente con la función fseek()) en el sitio adecuado antes de la siguiente lectura o escritura. Por el contrario, si el archivo se abre en modo “a+“, la escritura siempre se realizará al final del archivo, y las llamadas a fseek() no tendrán efecto (en las escrituras, porque las lecturas si serán afectadas)

Otra diferencia importante es que los archivos secuenciales sólo pueden abrirse para lectura o para escritura, de modo que no pueden combinarse ambas operaciones sin antes cerrar el archivo y volver a abrirlo. Los archivos directos, en cambio, pueden abrirse para lectura/escritura simultánea, compartiendo ambas operaciones el cursor o indicador de posición del archivo.

Por lo demás, y teniendo siempre presentes estas diferencias, las funciones de lectura y escritura son las mismas y se comportan de modo similar con los archivos directos y con los secuenciales. Todo lo que sigue es aplicable, además, tanto a archivos binarios como de texto, aunque luego veremos que algunas funciones se usan más con un tipo de archivos y otras con el otro tipo.

Leer y escribir caracteres: fgetc() y fputc()

Para escribir un carácter en un archivo de texto se pueden utilizar las funciones putc() o fputc(), que son idénticas:

int fputc(int carácter, FILE* puntero_a_archivo);

Observe que putc() recibe un entero, no un carácter. Esto obedece a razones históricas, pero, en realidad, putc() sólo se fija en los 8 bits de menos peso del entero, por lo que, a todos los efectos, es como si fuera un carácter.

La función fputc() devuelve el código EOF si no ha podido escribir el carácter.

Por ejemplo, de este modo se escribiría el carácter “s” al final del archivo “datos.txt”:

FILE* archivo;
archivo = fopen("datos.txt", "a");
if (archivo != NULL) fputc('s', archivo);

Para leer un carácter de un archivo de texto se utilizan las funciones getc() o fgetc(), que también son iguales y tienen esta forma:

int fgetc(FILE* puntero_a_archivo)

Observa que fgetc() devuelve un entero, no un carácter, por las mismas razones que putc(); si lo asignamos a una variable de tipo carácter el resultado será correcto.

Leer y escribir cadenas de caracteres: fgets() y fputs()

Para escribir en un archivo de texto una cadena de caracteres completa se utiliza la función fputs(), que es igual que fputc() pero con cadenas:

int fputs(char* cadena, FILE* puntero_a_archivo);

Del mismo modo, existe una función fgets() para leer cadenas de caracteres de un archivo de texto. En este caso, hay que indicar cuántos caracteres se quieren leer. La función irá leyendo caracteres del archivo hasta que encuentre un fin de línea o hasta que haya leído longitud – 1 caracteres. Comenzará leyendo desde el primer carácter del archivo y a partir de ahí irá avanzando secuencialmente:

char* fgets(char* cadena, int longitud, FILE* puntero_a_archivo);

Lectura y escritura con formato: fscanf() y fprintf()

También se puede escribir en un archivo de texto como estamos acostumbrados a hacerlo en la pantalla usando printf(), ya que existe un función similar, llamada fprintf(), que envía los datos al archivo especificado. Su sintaxis es muy parecida, salvo que hay que indicar a qué flujo se desean enviar los datos:

int fprintf(FILE* puntero_a_archivo, char* cadena_de_formato, lista_de_parámetros);

Por ejemplo, este fragmento de código escribe los caracteres “15 más 5 son 20″ en el archivo “datos.txt”:

FILE* archivo;
int a, b;
a = 15;
b = 5;
archivo = fopen("datos.txt", "a");
if (archivo != NULL)
  fprintf(archivo, "%i más %i son %i", a, b, a+b);

También existe una hermana gemela de scanf(); se llama fscanf() y lee los datos de un archivo, en lugar de hacerlo del flujo stdin (es decir, del teclado). Su prototipo es:

int fscanf(FILE* puntero_a_archivo, char* cadena_de_formato, lista_de_parámetros);

Lectura y escritura de bloques de datos: fread() y fwrite()

También se puede enviar un bloque de memoria completo a un archivo. Para eso utilizaremos la función fwrite(), cuyo prototipo es:

int fwrite(void* puntero_a_memoria, int tam_bytes, int num, FILE* puntero_a_archivo);

Esta función escribe en el archivo especificado un número (num) de elementos del tamaño indicado en bytes (tam_bytes). Los elementos se cogen de la memoria principal, a partir de la dirección apuntada por puntero_a_memoria.

Por ejemplo, la siguiente instrucción escribe en el archivo apuntado por el flujo fich 16 números de tipo float. Los números se leen de la memoria a partir de la dirección apuntada por ptr. Observe el uso que se hace de sizeof() para saber cuánto ocupa cada elemento (en este caso, cada número de tipo float):

fwrite(ptr, sizeof(float), 16, fich);

La función fwrite() devuelve el número de bytes escritos correctamente, por lo que el programador puede comprobar si se han escrito tantos datos como se pretendía o si ha surgido algún problema.

La función complementaria de fwrite() es fread(), que sirve para leer un bloque de datos de un archivo y colocarlo en la memoria, a partir de determinada dirección apuntada por un puntero. El prototipo es:

int fread(void* puntero_a_memoria, int tam_bytes, int num, FILE* puntero_a_archivo);

En este caso, se leen num elementos de tamaño tam_bytes del archivo. Todos los bytes se colocan en la memoria principal, en las direcciones situadas a partir de puntero_a_memoria. La función fread() devuelve el número de bytes leídos correctamente.

Lógicamente, el puntero_a_memoria debe estar apuntando a una zona de memoria que previamente haya sido reservada con una declaración estática, o bien con malloc() u otra función similar.

Estas dos funciones (fread() y fwrite()) suelen utilizarse con archivos binarios, mientras que el resto de funciones de lectura y escritura (fgets(), fgetc(), fscanf(), etc) es más frecuente usarlas con archivos de texto.

Fin de fichero: feof()

EOF es una constante definida en stdio.h. Se utiliza en el tratamiento de ficheros para localizar el final de los mismos. EOF es un carácter especial no imprimible, cuyo código ASCII es 26, que casi todos los editores de texto insertan al final de los archivos de texto para marcar el último registro o carácter.
Por lo tanto, si estamos leyendo caracteres secuencialmente de un archivo de texto, podemos ir comparándolos con EOF para comprobar cuándo hemos alcanzado el final del archivo.

Esto no funciona con archivos binarios, porque el valor de EOF puede ser confundido con una parte del último registro del archivo.

Para evitar este problema existe la función feof(), que nos dice si hemos alcanzado el final de un archivo, tanto de texto como binario. Devuelve un 0 (falso) si aún no se ha llegado al final, y otro valor cuando se ha alcanzado. Es muy útil para saber si podemos seguir leyendo caracteres o ya los hemos leído todos. Su prototipo es:

int feof(FILE* puntero_a_archivo);

Vuelta al principio del archivo: rewind()

Otra función del ANSI C muy útil es rewind(). Sirve para situar el indicador de posición al comienzo del archivo; es como si hubiéramos abierto el archivo en ese momento. Su prototipo es:

void rewind(FILE* puntero_a_archivo);

Vaciado del buffer: fflush()

Como ya comentamos, la salida de datos hacia archivos suele hacerse a través de un buffer por motivos de rendimiento. Así, cuando vamos escribiendo datos en un archivo, éstos pueden no escribirse inmediatamente en el dispositivo de almacenamiento, sino que pemanecen durante un tiempo en un espacio intermedio de memoria llamado buffer, donde se van acumulando. Sólo cuando el buffer está lleno se realiza físicamente la operación de escritura.

Podemos forzar un vaciado del buffer con la función fflush(), que tiene este prototipo:

int fflush(FILE* puntero_a_archivo);

Al llamar a fflush(), todos los datos que estuvieran pendientes de ser escritos en el dispositivo de memoria secundaria se escribirán, y el buffer quedará vacío.

Si el puntero_a_archivo es NULL, se realizará un vaciado de buffer de todos los archivos que hubiera abiertos en ese momento.

La función fflush() devuelve 0 si el vaciado se ha realizado con éxito, o EOF si se produce algún error.
Cuando se cierra un archivo con fclose(), se realiza automáticamente un vaciado del buffer de ese archivo para no perder los datos que estuvieran aún pendientes de escritura.

(Este artículo forma parte del Curso de Programación en C)

Abrir archivos

Para usar un archivo desde un programa en C, tanto secuencial como directo, lo primero que hay que hacer es abrirlo. Esto crea un flujo que conecta nuestro programa con el archivo.

La función para abrir archivos es fopen(), que tiene esta sintaxis:

FILE *fopen(char *nombre_archivo, char *modo);

El argumento nombre_archivo es el identificador del archivo que se pretende abrir, es decir, su nombre en el dispositivo de memoria secundaria. El argumento modo define qué se va a hacer con el archivo: leer datos de él, escribir datos en su interior, o ambas cosas. Además, el modo también sirve para especificar si el archivo se va a manejar como un archivo secuencial o como un archivo directo. Los valores que puede tomar el argumento se muestran en una tabla más abajo.

La función fopen() devuelve un puntero a archivo. El tipo FILE está definido en stdio.h, por lo que se puede utilizar en cualquier programa que incluya dicha cabecera. El puntero devuelto por fopen() será fundamental para escribir y leer datos del archivo más adelante. Si fopen(), por la razón que sea, no puede abrir el archivo, devolverá un puntero a NULL.

Los modos de apertura válidos son:

  • Modo “r”: Abre el archivo existente para lectura en modo secuencial. El archivo debe existir previamente.
  • Modo “w”: Crea un archivo nuevo para escritura en modo secuencial. ¡Cuidado! Si el archivo ya existe, se borrará y se creará uno nuevo.
  • Modo “a”: Abre un archivo existente para escritura en modo secuencial, añadiendo los datos al final de los que haya. Si el archivo no existe, se crea.
  • Modo “r+”: Abre el archivo para lectura/escritura en modo directo. El archivo debe existir previamente. Se puede leer y escribir en cualquier posición del archivo.
  • Modo “w+”: Crea un archivo para lectura/escritura en modo directo. Si el archivo ya existe, se elimina y se crea de nuevo. Se puede leer y escribir en cualquier posición del archivo.
  • Modo “a+”: Abre un archivo existente para lectura/escritura en modo directo. Si el archivo no existe, lo crea. La escritura sólo se podrá realizar al final del archivo (modo “añadir”), aunque se puede leer en cualquier posición.

A todos estos modos se les puede añadir la letra “b” si el archivo es binario, o “t” si es de texto. Por ejemplo: “rb”, “w+t”, “a+b”, etc. Si no se añade “b” ni “t”, se supone que el archivo es de texto. Los archivos de texto deben usarse para almacenar texto ASCII. Los archivos binarios suelen utilizarse para guardar información más compleja, aunque también pueden guardar texto. De esto hablamos más detenidamente en este otro artículo.

Por ejemplo:

FILE* archivo;
archivo = fopen("datos.txt", "rt");
if (archivo == NULL) puts("Error al abrir el archivo");

El archivo “datos.txt” es de texto y se abre para lectura. No se podrán escribir datos en él, sólo leerlos. La variable puntero archivo será imprescindible para actuar más adelante sobre el archivo. Fíjese en cómo se comprueba si el archivo ha sido abierto comparando el puntero con NULL.

Cerrar archivos

Cuando un archivo no va a usarse más, su flujo debe ser cerrado para liberar memoria. Aunque teóricamente todos los archivos abiertos por un programa se cierran automáticamente al finalizar dicho programa, el programador, por precaución, debe ocuparse de hacerlo dentro del código.

Para cerrar un archivo se usa la función fclose(), con esta sintaxis:

int fclose(FILE* puntero_file);

Por ejemplo:

FILE *archivo;
archivo = fopen("datos.txt", "rt");
... instrucciones de manipulación del archivo "datos.txt" ...
fclose(archivo);

Una anotación al margen: fclose() devuelve un número entero. Este número se puede utilizar para averiguar si ha ocurrido un error al cerrar el archivo, ya que tomará el valor EOF si ha sido así (EOF es otra constante definida en stdio.h)

(Este artículo forma parte del Curso de Programación en C)

Aunque, como dijimos, el lenguaje C maneja con las mismas funciones los archivos secuenciales y los directos, dispone de algunas funciones exclusivas para archivos de organización relativa directa. Estas funciones, que, por lo tanto, no tienen sentido con los archivos secuenciales, son fseek() y ftell().

Las funciones de acceso directo de C no permiten hacer referencia a direcciones de memoria secundaria absolutas, pero sí relativas al comienzo del archivo. Es decir: asignan la dirección 0 al primer byte del archivo, de modo que cada registro tenga una dirección relativa a ese primer byte. Cuando, por ejemplo, enviamos el indicador de posición a la dirección 500, no lo estamos enviando a la dirección 500 de la memoria secundaria, sino a la dirección 500 desde el comienzo del archivo.

La función fseek() sirve para situarnos directamente en cualquier posición del fichero, de manera que el resto de lecturas se hagan a partir de esa posición. Su prototipo es:

int fseek(FILE* puntero_a_archivo, long int num_bytes, int origen);

El argumento origen debe ser una de estas tres constantes definidas en stdio.h:

  • SEEK_SET: principio del fichero
  • SEEK_CUR: posición actual
  • SEEK_END: final del fichero

El argumento num_bytes especifica en qué posición desde el origen queremos situarnos. Por ejemplo, con esta llamada nos colocamos en el byte número 500 contando desde el principio del archivo:

fseek(archivo, 500, SEEK_SET);

Y con esta otra nos desplazamos 2 bytes más allá de la posición actual:

fseek(archivo, 2, SEEK_CUR);

Esta función devuelve 0 si se ejecuta correctamente o cualquier otro valor si ocurre algún error.

La función ftell(), por su parte, devuelve el indicador de posición del archivo, es decir, cuántos bytes hay desde el principio del archivo hasta el lugar donde estamos situados en ese momento. Su prototipo es:

long int ftell(FILE* puntero_a_archivo);

Devuelve -1 si se produce un error.

Un uso habitual de la función ftell() es averiguar el tamaño de un archivo, de este modo:

fseek(archivo, 0, SEEK_END);
tam_archivo = ftell(archivo);

Es decir, primero nos colocamos al final del archivo con fseek(), y luego preguntamos a ftell() el byte en el que nos hemos situado (contando desde el comienzo del archivo). Como resultado, ftell() nos proporcionará en tamaño (en bytes) del archivo.

(Este artículo forma parte del Curso de Programación en C)

Para acceder a los archivos, por tanto, es necesario crear flujos nuevos a parte de stdin y stdout. Crear un flujo con un archivo se denomina comúnmente “abrir el archivo”. Cuando ya no va a ser necesario escribir ni leer más datos del archivo, el flujo debe destruirse en la operación denominada “cerrar el archivo”.

El acceso a los archivos se hace a través de un buffer. Se puede pensar en un buffer como si fuera un array donde se van almacenando los datos dirigidos al archivo, o los datos que el archivo envía hacia el programa. Esos datos se van colocando en el buffer hasta que éste se llena, y sólo entonces pasan efectivamente a su destinatario. También es posible forzar el vaciado del buffer antes de que se llene invocando a determinadas funciones que luego veremos.

Desde un punto de vista práctico, lo que hay que recordar es lo siguiente: cuando se envían datos a través de un flujo, éstos no se escriben inmediatamente en el archivo, sino que se van acumulando en el buffer, y sólo cuando el buffer está lleno los datos se graban realmente en el archivo. En ese momento el buffer queda vacío y listo para seguir recibiendo datos. Al cerrar el archivo, se terminan de escribir los últimos datos que pudieran quedar en el buffer.

(Este artículo forma parte del Curso de Programación en C)

Un flujo (o stream en inglés) es una corriente de datos que fluyen desde un origen, llamado productor, y son recibidos por un destinatario, llamado consumidor. Entre el origen y el destino debe existir una conexión de tal naturaleza que permita la transmisión de datos.

En C, para recibir datos desde cualquier dispositivo de entrada (productor) o para enviar datos a cualquier dispositivo de salida (consumidor), es necesario establecer un canal que permita recibir y enviar esos datos. Este canal es lo que llamamos flujo.

En todos los programas escritos en C existen tres flujos o canales abiertos automáticamente:

  • stdin: es el flujo de entrada estándar, es decir, el canal de comunicación con el teclado.
  • stdout: es el flujo de salida estándar, es decir, el canal de comunicación con la pantalla.
  • stderr: es el flujo por el que se envían los mensajes de error; como éstos aparecen por defecto en la pantalla, se trata de un segundo canal de comunicación con la pantalla.

Estos flujos no hay que abrirlos, cerrarlos, definirlos ni nada parecido, porque existen de manera automática en todos los programas. Así, cuando invocamos a la función printf(), estamos enviando datos a través del flujo stdout, del mismo modo que cuando llamamos a scanf() estamos leyendo datos a través del flujo stdin.

Cada vez que utilicemos un archivo en memoria secundaria será necesario crear un flujo nuevo, es decir, un canal a través del cual podamos leer o escribir datos del archivo. En todas las funciones de lectura y escritura deberemos especificar, además de los datos que queremos leer o escribir, el flujo a través del cual deseamos hacerlo.

(Este artículo forma parte del Curso de Programación en C)

Los archivos con organización indexada tienen una mezcla entre las organizaciones secuencial y relativa, que ya hemos visto en otros artículos. Se pretende aprovechar las ventajas de las dos organizaciones, evitando al mismo tiempo sus inconvenientes.

Los archivos indexados están divididos en tres zonas o áreas:

1) El área primaria. En esta área se encuentran almacenados los registros del archivo secuencial. Es decir, el área primaria es, en realidad, un archivo secuencial corriente . Los registros deben estar ordenados (normalmente, se hará en orden creciente según sus claves). El área primaria suele estar segmentada, es decir, dividida en trozos o segmentos. En cada segmento se almacenan N registros en posiciones de memoria consecutivas. Para acceder a un registro individual, primero hay que acceder a su segmento y, una vez localizado el segmento, buscar secuencialmente el registro concreto.

2) El área de índices. Se trata, en realidad, de un segundo archivo secuencial agregado al primero. Pero es un archivo especial, cuyos registros solo tienen dos campos: uno contiene la clave del último registro de cada segmento, y otro contiene la dirección física de comienzo de cada segmento.

3) El área de excedentes. Puede ocurrir que los segmentos del área primaria se llenen y no puedan contener algún registro. Esos registros van a parar a un área de excedentes u overflow.

Para acceder a un registro concreto en un archivo indexado, el procedimiento es el siguiente:

  • Primero, buscamos secuencialmente en el área de índices la dirección de comienzo del segmento donde está el registro que queremos buscar.
  • Segundo, hacemos un acceso directo al primer registro del segmento.
  • Después hacemos un recorrido secuencial dentro del segmento hasta localizar el registro.
  • Si el registro no se encuentra, acudimos al área de excedentes y hacemos un nuevo recorrido secuencial en ella para intentar localizarlo allí.

Observa que los archivos indexados mezclan los accesos secuenciales con los accesos directos.

Mejor con un ejemplo…

Vamos a mostrar un ejemplo para tratar de entender correctamente esta organización de archivo.
Supongamos un archivo de datos personales de los alumnos que conste de estos 10 registros:

DNI (clave)    Nombre         Teléfono
1111        Arturo Pérez       348734
1232        Miguel Ruiz        349342
2100        Antonia Camacho    209832
2503        Silvia Ortiz       349843
3330        Sonia del Pino     987349
5362        José Anguita       978438
6300        Ana Zamora         476362
6705        Susana Hernández   473239
7020        Rodrigo Sánchez    634838
9000        Natalia Vázquez    362653

Imaginemos que cada segmento tiene 4 registros. Por lo tanto, el archivo se dividirá en 3 segmentos. Si suponemos que cada registro ocupa 50 bytes en memoria secundaria, y que el principio del archivo está en la dirección 100 de dicha memoria, el archivo físico tendrá este aspecto:

Área primaria:

Dirección   Clave (DNI)    Contenido del registro   
 física
  100          1111         Arturo Pérez     348734
  150          1232         Miguel Ruiz      349342
  200          2100         Antonia Camacho  209832
  250          2503         Silvia Ortiz     349843
  300          3330         Sonia del Pino   987349
  350          5362         José Anguita     978438
  400          6300         Ana Zamora       476362
  450          6705         Susana Hernández 473239
  500          7020         Rodrigo Sánchez  634838
  550          9000         Natalia Vázquez  362653
  600        Sin usar       
  650        Sin usar

Área de índices:

Segmento    Dirección    Clave del útimo
           de comienzo       registro
  1            100            2503
  2            300            6705
  3            500            9000

Observe primero el área primaria: los registros están dispuestos en orden creciente según la clave (que, en este caso, es el campo NIF). A la izquierda aparece la dirección física donde comienza cada registro. Fíjate también en que los registros están agrupados en tres segmentos.

Luego fíjese en el área de índices: contienen una lista de segmentos, guardando la dirección de comienzo del segmento y la clave del último registro de ese segmento.

Para acceder, por ejemplo, al registro cuya clave es 5362, el proceso es el siguiente:

  1. Buscar en el área de índices secuencialmente, es decir, desde la primera fila, hasta localizar un registro mayor que el que estamos buscando. Eso ocurre en la segunda fila, pues la clave del último registro es 6705. Por lo tanto, sabemos que el registro buscado debe de estar en el segmento 2.
  2. Acceder de forma directa a la dirección 300 del área primaria, que es de comienzo del segmento 2. Esa dirección la conocemos gracias a que está guardada en el área de índices.
  3. Buscar en el área primaria secuencialmente a partir de la dirección 300, hasta localizar el registro buscado, que ocupa la segunda posición dentro de ese segmento.

Fíjese en que han sido necesarios, en total, 4 accesos secuenciales y 1 directo. Si hubiésemos hecho una búsqueda secuencial, hubiéramos necesitado 6 accesos secuenciales desde el principio del archivo. Esto puede no parecer una gran ventaja, pero ahora piensa qué pasaría si el archivo tuviera más segmentos y el registro buscado estuviera muy lejos del principio del archivo. Cuanto mayor es el tamaño del archivo y más lejos del principio está el registro, más ventajosa resulta la organización indexada frente a la secuencial.

(Este artículo forma parte del Curso de Programación en C)

Hasta ahora hemos visto las formas de organización de archivos (secuenciales, directos, indexados…. ). En este artículo y los siguientes del Curso de Programación en C, vamos a estudiar las funciones de C para acceder a los archivos.

En principio, quédese con esta idea: el lenguaje C sólo puede manejar archivos secuenciales y directos. La mayoría de sus funciones sirven para ambos tipos de organización, comportándose de forma ligeramente distinta con una y con otra. Y, luego, existen algunas funciones exclusivamente para archivos secuenciales, y otras para archivos directos, como iremos viendo. Por último, combinando adecuadamente los accesos directos con los secuenciales, se puede lograr en C un acceso indexado, aunque es tarea del programador manejar los índices y todas las complicaciones de este método de organización.

Además de los tipos de archivos que ya hemos visto (según su organización: secuenciales y relativos con todas sus variedades), en C podemos hacer otras dos clasificaciones de los archivos:

1) Según la dirección del flujo de datos:

  • De entrada: los datos se leen por el programa desde el archivo.
  • De salida: los datos se escriben por el programa hacia el archivo.
  • De entrada/salida: los datos pueden se escritos o leídos.

2) Según el tipo de valores permitidos a cada byte:

  • De texto: sólo permiten guardar caracteres o, mejor dicho, su código ASCII. Para guardar información numérica en un archivo de texto es necesario convertirla a caracteres.
  • Binarios: guardan información binaria de cualquier tipo..

Cuando conozcamos el manejo que de los archivos se puede hacer con C, discutiremos con más detenimiento las diferencias entre archivos binarios y de texto.

(Este artículo forma parte del Curso de Programación en C)

Sigamos hablando de la organización interna de archivos. Nos encontramos ahora con la organización relativa, que es más compleja que la secuencial.

La idea básica de la organización relativa consiste en guardar físicamente los registros en lugares de la memoria secundaria no consecutivos. Pero, entonces, ¿cómo podemos encontrar dónde está cada registro?

La única solución es utilizar un campo clave de entre todos los del registro. Ese campo clave, que suele ser numérico, permite averiguar la dirección física donde está almacenado el registro en la memoria secundaria mediante un algoritmo de transformación. Por eso, la clave suele denominarse dirección de memoria lógica, para distinguirlo de la dirección de memoria física donde efectivamente se encuentra guardado el registro.

Esta transformación de claves para obtener direcciones físicas se denomina hashing. Más abajo encontrará un ejemplo muy sencillo de hashing que le ayudará a entender todo esto.

Los archivos relativos son más versátiles que los secuenciales porque permiten acceder a cualquier parte del fichero en cualquier momento, como si fueran arrays. Las operaciones de lectura y escritura pueden hacerse en cualquier punto del archivo.

Los archivos con organización relativa tienen dos variantes: los archivos directos y los archivos aleatorios (o indirectos). A lo largo de este artículo estudiaremos cada tipo por separado. Pero antes veamos un…

Ejemplo de hashing

Vamos a tratar de entender bien la técnica de hashing con un sencillo ejemplo.

Supongamos que un archivo almacenado en una memoria secundaria contiene 5 registros, que llamaremos R1, R2, R3, R4 y R5. En un archivo secuencial, los cinco registros estarán almacenados en posiciones consecutivas de la memoria. Si R1 se guarda, por ejemplo, en la dirección 1000 de la memoria secundaria y cada registro lógico ocupa exactamente un registro físico, tendremos que los registros estarán guardados en estas direcciones:

                     +------+------+------+------+------+
Dirección            | 1000 | 1001 | 1002 | 1003 | 1004 |
                     +------+------+------+------+------+
Registro almacenado  |  R1  |  R2  |  R3  |  R4  |  R5  |
en esa posición      |      |      |      |      |      |
                     +------+------+------+------+------+

En cambio, si el archivo es relativo, cada registro estará almacenado en posiciones no consecutivas de la memoria secundaria. Por ejemplo, podrían estar en estas direcciones:

                     +-----+--+----+--+----+--+----+--+----+
Dirección            |1000 |..|1200|..|5720|..|6304|..|6318|
                     +-----+--+----+--+----+--+----+--+----+
Registro almacenado  | R1  |  | R2 |  | R3 |  | R4 |  | R5 |
en esa posición      |     |  |    |  |    |  |    |  |    |
                     +-----+--+----+--+----+--+----+--+----+

El problema con este sistema de almacenamiento es cómo localizar los registros en la memoria secundaria. Para eso se utiliza el hashing. Cada registro debe tener un campo clave (que denominaremos R1.clave, R2.clave, etc). El hashing consiste en aplicar una función de transformación a cada clave. Esa función se denomina función hash.

Supongamos que las claves de los registros de este ejemplo son:

  • R1.clave = 500
  • R2.clave = 600
  • R3.clave = 2860
  • R4.clave = 3152
  • R5.clave = 3159

Entonces, la función hash aplicada a este archivo para averiguar la dirección de cada registro ha sido

  • f(clave) = clave x 2

Probemos a aplicar la función hash al primer registro (R1):

  • f(R1.clave) = 500 x 2 = 1000

Efectivamente, aplicando la función hash a la clave de R1 (500), hemos obtenido su dirección de almacenamiento en memoria secundaria (1000).

Si probamos con otros registros, esta función hash también nos devuelve la dirección. Por ejemplo, con R3:

  • f(R3.clave) = 2860 x 2 = 5720

Si lo comprueba, verá que 5720 es la dirección donde está guardado el registro R3.

Archivos de organización relativa directa

Entre los archivos con organización relativa los más sencillos son los directos.

En ellos, el campo clave de cada registro debe ser de tipo numérico, e identifica directamente el registro físico donde está almacenado. La función hash, en este caso, es la más simple posible, ya que no transforma la clave:

  • f(clave) = clave

En el ejemplo anterior, el registro R1 se almacenaría en la dirección 500, el R2 en la 600, el R3 en la 2860, etc, ya que:

  • f(R1.clave) = clave = 500
  • f(R2.clave) = clave = 600
  • f(R3.clave) = clave = 2860

El valor de la clave está en relación con la capacidad máxima del dispositivo de almacenamiento, no pudiendo almacenar registros cuya clave esté por encima de este límite.

En estos archivos no puede haber dos registros con la misma clave, porque ambos ocuparían la misma posición física, solapándose. Esto es lo que se llama una colisión y debe ser evitada.

Las ventajas de los archivos directos son:

  1. Permite acceder al archivo de dos maneras: directamente (a través de la clave de cada registro) y secuencialmente.
  2. Permite realizar operaciones de lectura y escritura simultáneamente.
  3. Son muy rápidos al tratar registros individuales.

Los inconvenientes principales son:

  1. El acceso secuencial, del principio al fin del fichero, puede ser muy lento porque podemos encontrarnos con muchos huecos, es decir, posiciones que no están siendo usadas. Existen técnicas de programación avanzadas para el acceso secuencial eficiente a ficheros directos.
  2. Relacionado con la anterior, pueden quedar muchos huecos libres en el dispositivo de memoria secundaria, desaprovechándose el espacio.

Archivos de organización relativa aleatoria (o indirecta)

Se denominan así a los archivos relativos que empleen alguna función hash para transformar la clave y conseguir así la dirección física.

La función hash puede ser muy sencilla, como la del ejemplo que vimos en el apartado 2.2 (que consistía en multiplicar la clave por 2 para obtener la dirección física) o más complicada, pero el principio es el mismo: transformar la clave para obtener la dirección física.

He aquí, por ejemplo, una posible función hash más realista:

  • f(clave) = clave * num_primo + clave

…donde “num_primo“ es el número primo más cercano que exista a 2n, siendo n el número de bits de la clave.

Dependiendo de la función hash empleada pueden surgir colisiones, es decir, claves que proporcionan la misma dirección física.

Por ejemplo, si la función hash es f(clave) = clave / 2 (división entera), tendremos que los registros con clave 500 y 501 intentarán ocupar la misma dirección física: la 250. Es responsabilidad del programador evitar estas colisiones y, en caso de que lleguen a producirse, detectarlas y programar algún mecanismo que las resuelva.

Otras funciones hash, como la ya vista f(clave) = clave x 2, no producen colisiones, pero en cambio provocan que muchas direcciones físicas no sean utilizadas, con lo que se desaprovecha el espacio de almacenamiento.

Por lo tanto, la elección de una función hash adecuada es crucial para el correcto rendimiento y funcionamiento de este tipo de archivos. Existen multitud de funciones hash adaptadas a los más diversos problemas que ofrecen un máximo aprovechamiento del espacio y un mínimo número de colisiones, pero su estudio excede los propósitos de este blog… ¡al menos por ahora!

Las ventajas de los archivos aleatorios son similares a las de los directos, y entre los inconvenientes podemos quitar el de dejar muchos huecos libres, siempre que, como hemos visto, la función hash elegida sea adecuada.

(Este artículo forma parte del Curso de Programación en C)

La organización de los archivos es la forma en que los datos son estructurados y almacenados en el dispositivo de almacenamiento. El tipo de organización se establece durante la fase de creación del archivo y es invariable durante toda su vida. La organización puede ser secuencial o relativa (o una combinación de ambas), como enseguida veremos.

El tipo de acceso al archivo es el procedimiento que se sigue para situarnos sobre un registro concreto para hacer alguna operación con él. Esto es lo que realmente le interesa al programador: cómo acceder a los registros de archivo. El tipo de acceso está condicionado por el tipo de organización física del archivo.

A lo largo de este artículo y los siguientes (véase el índice del Curso de Programación en C), estudiaremos los tipos de organización. Un poco más adelante, nos detendremos en las funciones de C para acceder a archivos y, por último, nos centraremos en la implementación de los distintos tipos de acceso a archivos que se pueden realizar desde C.

Archivos de organización secuencial

La forma más simple de estructura de archivo es el archivo secuencial. En este tipo de archivo, los registros se sitúan físicamente en el dispositivo en el orden en el que se van escribiendo, uno tras otro y sin dejar huecos entre sí. El acceso a los registros también debe hacerse en orden, de modo que para acceder al registro N es necesario pasar primero por el registro 1, luego por el 2, luego por el 3, y así hasta llegar al registo N.

Los archivos secuenciales se utilizaban mucho cuando el soporte de almacenamiento masivo más usual era la cinta magnética. Hoy día, con nuestros flamantes discos duros y memorias flash, no es habitual encontrarse con archivos de organización interna secuencial. Pero sí que se utiliza el acceso secuencial (aunque físicamente el archivo no lo sea), porque su simplicidad y porque es suficientemente útil en muchas ocasiones (por ejemplo, en aplicaciones de proceso de lotes). Arhoa bien, si el programa necesita acceder a registros individuales y no consecutivos, el acceso secuencial ofrece un rendimiento pobre y es preferible el acceso directo, que luego veremos.

Los archivos secuenciales (sobreentiéndase “archivos de acceso secuencial”) tienen un indicador de posición (o cursor) que señala qué registro fue el último que se accedió. Al abrir el archivo, el indicador se sitúa en el primer campo del primer registro. Cada acceso sobre el archivo desplazará el indicador de posición hacia el siguiente registro, hasta que ya no haya más registros que leer.

Cuando un archivo secuencial se abre para escribir datos en él, el indicador de posición se sitúa justo después del último byte del mismo, de manera que los datos sólo se pueden añadir al final.

Ventajas e inconvenientes de los archivos secuenciales

La organización secuencial cuenta con varias ventajas:

  1. Es la más sencilla de manejar para el programador.
  2. Si hay que acceder a un conjunto de registros consecutivos, o a todo el archivo, es el método más rápido.
  3. No deja espacios entre registro y registro, por lo que se optimiza el uso del espacio en la memoria secundaria.

Pero también tiene algunos inconvenientes serios, como:

  1. Para consultar datos individuales, hay que recorrer todo el archivo desde el principio. Es decir, el acceso a registros individuales es, en general, lento.
  2. Las operaciones de inserción y eliminación de registros solo pueden hacerse al final del archivo. Hacerlas con registros intermedios representa mover grandes bloques de información y, por lo tanto, consumir mucho tiempo.

(Este artículo forma parte del Curso de Programación en C)

En un fichero o archivo se puede realizar operaciones sobre cada registro individual o bien sobre todo el archivo, es decir, sobre todos los registros a la vez.

A) Operaciones con registros individuales

  • Inserción (alta): consiste en añadir un registro al fichero. El registro puede añadirse al final del fichero o entre dos registros que ya existieran previamente.
  • Borrado (baja): consiste en eliminar un registro existente.
  • Modificación: consiste en cambiar el dato almacenado en uno o varios de los campos del registro
  • Consulta: consiste en leer el dato almacenado en uno o varios de los campos del registro.

B) Operaciones sobre el archivo completo

Además de manipular cada componente del archivo (registros y campos), también se pueden llevar a cabo operaciones con la totalidad del archivo, como:

  • Creación: La creación del archivo consiste en crear una entrada en el soporte de memoria secundaria y asignarle un nombre para identificar en el futuro a los datos que contiene.
  • Apertura: Antes de trabajar con un archivo es necesario abrirlo, creándose así un canal de comunicación entre el programa y el archivo a través del cuál se pueden leer y escribir datos. Los archivos sólo deben permanecer abiertos el tiempo estrictamente necesario.
  • Cierre: Es importante cerrar el canal de comunicación con el archivo cuando no va a usarse en un futuro inmediato, porque todos los sistemas limitan el número máximo de archivos que pueden estar abiertos simultáneamente. También es importante porque evita un acceso accidental al archivo que pueda deteriorar la información almacenada en él.
  • Ordenación: Permite establecer un orden entre los registros del archivo.
  • Copiado: Crea un nuevo archivo con la misma estructura y contenido que el fichero original.
  • Concatenación: Consiste en crear un archivo nuevo que contenga los registros de otros dos archivos previamente existentes, de manera que primero aparezcan todos los registros de un archivo y, a continuación, todos los del otro.
  • Mezcla: Parecida a la concatenación, pero el archivo resultante contendrá todos los registros de los dos archivos originales mezclados y ordenados.
  • Compactación: Esta operación sólo se realiza sobre archivos en los cuales el borrado de registros se ha realizado sin eliminar físicamente el registro, sino únicamente marcándolo como borrado para no procesarlo. Después de la compactación, todos los registros marcados como borrados quedan borrados físicamente, con lo que se libera espacio en el dispositivo de almacenamiento.
  • Borrado: Es la operación contraria a la creación, ya que elimina la entrada en el dispositivo de almacenamiento, con lo que se pierde toda la información almacenada en el archivo.

(Este artículo forma parte del Curso de Programación en C)

En todo este curso, cuando hablemos de “registro” a secas, nos estaremos refiriendo al registro lógico, no al físico.

Pues bien, dependiendo de la longitud de los campos que forman cada registro podemos clasificar éstos en:

A) Registros de longitud fija

Son los que ocupan siempre el mismo espacio a lo largo de todo el archivo (en el ejemplo anterior, 128 bytes). Dentro de estos registros, podemos encontrar varias posibilidades:

  • Igual número de campos por registro e igual longitud de todos los campos
  • Igual número de campos por registro y distinta longitud de cada campo, aunque igual en todos los registros
  • Igual número de campos por registro y distinta longitud de cada campo, pudiendo ser diferente en cada registro
  • Distinto número de campos por registro y distinta longitud de cada campo en cada registro

B) Registros de longitud variable

Aunque es menos habitual, pudiera ocurrir que cada registro del archivo tuviera una longitud propia y diferente del resto. En este tipo de archivos es necesario programar algún mecanismo para averiguar cuál es el principio y el final de cada registro.

(Esta entrada forma parte del Curso de programación en C)

Un registro físico, también llamado bloque, es diferente de los registros que vimos en esta entrada (y que, para diferenciarlos, a veces se denominan registros lógicos). El registro físico es la cantidad de información que el sistema operativo puede enviar o recibir del soporte de memoria secundaria en una operación de escritura o lectura. Esta cantidad depende del hardware.

El registro físico puede ser mayor que el registro lógico, con lo cual, en una sola operación de lectura o escritura, se podrían transferir varios registros lógicos. También puede ocurrir lo contrario, es decir, que el registro físico sea de menor tamaño que el lógico, lo que haría que para transferir un registro lógico fueran necesarias varias operaciones de lectura o escritura.

Se llama factor de bloqueo al número de registros lógicos contenidos en un registro físico.

Como ejemplo vamos a calcular el factor de bloqueo del archivo del epígrafe anterior. Supongamos que el tamaño del registro físico es de 512 bytes (es decir, en una sola lectura o escritura del dispositivo de almacenamiento se pueden transferir 512 bytes) y el registro lógico ocupa 128 bytes, calculados de esta manera1.

  • Campo NIF (10 caracteres)     =     10 bytes
  • Campo Nombre (30 caracteres)     =     30 bytes
  • Campo Apellidos (40 caracteres)     =     40 bytes
  • Campo Teléfono (entero largo)     =     8 bytes
  • Campo Dirección (40 caracteres)     =     40 bytes
  • TOTAL  = 128 bytes

En estas condiciones, el factor de bloqueo es 4, que es el resultado de dividir 512 (tamaño del registro físico) entre 128 (tamaño del registro lógico). En cada registro físico caben exactamente 4 registros lógicos, sin que sobre ningún byte, porque la división de 512 entre 128 es exacta, pero puede ocurrir que no sea así.

Por ejemplo, si el registro lógico ocupase 126 bytes en lugar de 128, en cada registro físico cabrían 4 registros lógicos pero sobrarían 8 bytes. Esto tiene una gran importancia desde el punto de vista del rendimiento, ya que cada acceso a la memoria secundaria requiere bastante tiempo y, por tanto, éstos deben reducirse al máximo.


(Este artículo forma parte del Curso de Programación en C)

Hasta este momento, todas las operaciones de entrada y salida de datos de nuestros programas se han hecho a través del teclado (entrada) y la pantalla (salida). Estos son los dispositivos de entrada y salida por defecto, pero también se pueden enviar datos hacia un archivo, o recibirlos de él.

Además, todos los datos que hemos manejado, ya sea mediante tipos de datos simples o estructuras complejas, han estado alojados en la memoria principal del ordenador, de manera que al apagar éste, o antes, al terminar el programa, toda esa información se perdía. Como es natural, también es posible almacenar datos en memoria secundaria, es decir, en dispositivos tales como discos duros, discos flexibles, discos ópticos, memorias USB, etc. Estas memorias se caracterizan por ser más lentas que la memoria principal del ordenador, pero también disponen de más espacio de almacenamiento, y no son volátiles, es decir, no pierden su contenido al desconectar el ordenador.

Para almacenar datos en estas memorias secundarias es necesario agruparlos en estructuras que denominaremos archivos o ficheros (en inglés, files). En próximos artículos nos detendremos en cómo se organizan esos archivos internamente, y cómo pueden manipularse con C. Pero antes, debemos definir algunos conceptos fundamentales relativos a los ficheros, sin los cuales toda la discusión posterior carecería de sentido.

Primera definición importante: Un archivo o fichero es un conjunto de información relacionada entre sí y estructurada en unidades más pequeñas, llamadas registros.

Segunda definición importante: Un registro es, por tanto, cada una de las unidades individuales en las que se divide un fichero. Cada registro debe contener datos pertenecientes a una misma cosa. Además, cada registro es un estructura de datos, es decir, está compuesto de otros datos más simples, que llamaremos campos.

Eso nos lleva a la tercera definición importante: Un campo es cada uno de los elementos que constituyen un registro. Cada campo se caracteriza por un identificador que lo distingue de los otros campos del registro, y por el tipo de dato que tiene asociado, que, a su vez, puede ser simple (número entero, carácter, lógico, etc) o compuesto (cadena de caracteres, fecha, vector, etc).

Observe el siguiente ejemplo de fichero. Contiene información relacionada entre sí: los datos personales de un conjunto de personas. Toda esa información está distribuida en registros, que son cada una de las filas de la tabla. Cada registro, por tanto, contiene los datos pertenecientes a una sola persona. Los registros se dividen en campos, que son cada una de las unidades de información que contiene cada registro.

                          C  A  M  P  O  S

R      NIF       Nombre     Apellidos   Teléfono     Dirección
E   +--------+-----------+--------------+---------+---------------+
G   | 1111-H | Salvador  | Pérez Pérez  | 2309201 |Av. Del Mar 105|
I   +--------+-----------+--------------+---------+---------------+
S   | 3333-J | Margarita | Sánchez Flor | 2232111 | C/Juela 23    |
T   +--------+-----------+--------------+---------+---------------+
R   |  ....  |    ....   |     .....    |   ....  |   ........    |
O   +--------+-----------+--------------+---------+---------------+
S

Si el tipo de dato de un campo es complejo, el campo puede dividirse en subcampos. Por ejemplo, si un campo contiene una fecha, se puede dividir en tres subcampos que contengan, respectivamente, el día, el mes y el año.

Para diferenciar a un registro de otro es conveniente que alguno de los campos tenga un valor distinto en todos los registros del archivo. Este campo, que identifica unívocamente cada registro, se denomina campo clave o, simplemente, clave. En el ejemplo anterior, el campo clave puede ser NIF, ya que será diferente para cada una de las personas que forman el archivo.

(Esta entrada forma parte del Curso de Programación en C)

En C, se pueden definir nuevos tipos de datos con la palabra reservada typedef:

typedef tipo nombre_tipo;

Por ejemplo:

typedef int entero;

A partir de esta declaración, el compilador de C reconocerá el tipo entero, que será exactamente igual al tipo predefinido int.

La definición de tipos es más práctica si se aplica a tipos complejos, como las estructuras. Por ejemplo:

typedef struct
{
    int dia;
    int mes;
    int anno;
} t_fecha;

Tras esta definición habrá quedado definido un nuevo tipo de datos llamado t_fecha. Por lo tanto, se podrán declarar variables de ese tipo:

t_fecha fecha_hoy;
t_fecha fecha_nacimiento;

Los identificadores de los tipos deben cumplir todas las reglas habituales (nada de caracteres especiales ni espacios). Es una buena costumbre que el nombre de un tipo de datos empiece por la letra “t”, para diferenciar así los identificadores de tipo de los identificadores de variable.

Tipos supercomplejos

En otros lugares de este curso de programación en C hemos visto varios tipos de datos simples (entero, real, carácter…) y complejos (arrays, estructuras, uniones…). Los tipos complejos se refieren a datos compuestos por otros datos como, por ejemplo, un array de números enteros.

Sin embargo, es perfectamente posible que los datos que componen un tipo complejo sean, a su vez, de tipo complejo. Por ejemplo, es posible tener un array de estructuras, o una estructura cuyos miembros son arrays u otras estructuras.

En el siguiente ejemplo podemos ver un array unidimensional (vector) cuyos elementos son estructuras:

/* Array de estructuras */
struct fecha
{
    int dia;
    int mes;
    int anno;
};
struct fecha lista_de_fechas[100];

La variable lista_de_fechas es un vector de 100 elementos. Cada elemento no es un dato de tipo simple, sino una estructura fecha. Para acceder, por ejemplo, al miembro día del elemento nº 3 del array y asignarle el valor 5, tendríamos que hacer esto:

lista_de_fechas[3].día = 5;

Otro caso bastante habitual es el de estructuras que tienen como miembros a otras estructuras. Veamos un ejemplo:

/* Estructura de estructuras */
struct s_fecha
{
    int dia;
    int mes;
    int anno;
};

struct s_hora
{
    int hh;    // Horas
    int mm;    // Minutos
    int ss;    // Segundos
};

struct calendario
{
    fecha struct s_fecha;
    hora struct s_hora;
}
struct calendario fecha_hoy;

La variable fecha_hoy es de tipo struct calendario, que es un tipo que a su vez está compuesto de otras dos estructuras. El acceso a los miembros de fecha_hoy se hará del siguiente modo:

fecha_hoy.fecha.dia = 5;
fecha_hoy.fecha.mes = 12;
fecha_hoy.hora.hh = 23;

Estos datos de tipo supercomplejo pueden combinarse de la forma que más convenga al problema que tratamos de resolver.

(Este artículo forma parte del Curso de Programación en C)

Una enumeración es un conjunto de constantes enteras. A la enumeración se le asigna un nombre que, a todos los efectos, se comporta como un nuevo tipo de datos, de manera que las variables de ese tipo son variables enteras que solo pueden contener los valores especificados en la enumeración.

La definición de una enumeración suele hacerse así:

enum nombre_enumeración {constante1 = valor1, constante2 = valor2, ..., constanteN = valorN };

Por ejemplo:

enum dias_semana {LUNES=1, MARTES=2, MIERCOLES=3, JUEVES=4, VIERNES=5, SÁBADO=6, DOMINGO=7 };

Las variables que se declaren del tipo dias_semana serán, en realidad, variables enteras, pero sólo podrán recibir los valores del 1 al 7, así:

dias_semana dia;
dia = LUNES;
dia = 1;    /* Las dos asignaciones son equivalentes */

Si no se especifican los valores en la enumeración, C les asigna automáticamente números enteros a partir de 0. Por ejemplo, en la siguiente definición, la constante LUNES valdrá 0, MARTES, 1, etc:

enum dias_semana { LUNES, MARTES , MIÉRCOLES, JUEVES, VIERNES, SÁBADO, DOMINGO};

Por último, el programador debe tener en cuenta que los identificadores utilizados en una enumeración son constantes enteras y que, por lo tanto, lo siguiente imprime en la pantalla un 2, y no la palabra “MIÉRCOLES”:

dias_semana dia;
dia = MIERCOLES;
printf("%i", dia);

(Este artículo forma parte del Curso de Programación en C)

Existen muchas librerías para añadir gráficos a nuestros programas escritos en C. Vamos a usar una llamada SDL (iniciales de Single DirectMedia Layer), porque tiene muchos puntos a su favor: es multiplataforma, libre, eficiente y permite manejar cualquier componente multimedia (gráficos, sonido, joysticks, ratones, discos ópticos, etc). Teniendo en cuenta la complejidad intrínseca a estos dispositivos, la librería es razonablemente sencilla de usar. Se ha utilizado como base para algunos desarrollos conocidos en el terreno de los videojuegos, como Quake 4 o FreeCiv.

SDL puede descargarse gratuitamente desde http://www.libsdl.org u obtenerse de cualquier repositorio oficial GNU/Linux medianamente decente.

Nosotros sólo vamos a proporcionar una introducción a la parte de SDL dedicada a los gráficos, y aún así nos saldrá un artículo bastante voluminoso. Si quiere más información, en la página web reseñada antes encontrará una completa documentación.

Instalación de SDL

SDL no es una librería C estándar, es decir, no viene de serie con ningún compilador de C. Así que debe ser instalada antes de poder utilizarla. A continuación describimos el proceso de instalación en Linux y en Windows.

Instalación de SDL en GNU/Linux

  1. Bájese la última versión de la librería de la web de SDL. Necesitará el paquete de la librería propiamente dicho (denominado runtime) y el paquete de desarrollo. El paquete runtime tiene un nombre similar a este: SDL-1.2.8-1.i386.rpm, donde “1.2.8″ es la versión de la libería e “i386″ indica para qué tipo de procesador está compilado. El paquete de desarrollo debe llamarse SDL-devel-1.2.8-i386.rpm o algo similar.
  2. Instale ambos paquetes en su sistema. Con el paquete runtime es suficiente para ejecutar programas que usen la librería SDL, pero si además quiere escribir programas nuevos que usen esta librería (y es nuestro caso), también necesitará el paquete de desarrollo.

Instalación de SDL en Windows

  1. Bájese la última versión de la librería de la web de SDL. Necesitará la librería de vínculos dinámicos (denominada dll) y el paquete de desarrollo. La librería de vínculos dinámicos suele venir comprimida en un archivo cuyo nombre es similar a: SDL-1.2.8-win32.zip, donde “1.2.8″ es la versión de la libería. Existirán varios paquetes de desarrollo para varios compiladores. Mi consejo es que baje el que está preparado para el compilador de GNU, cuyo nombre es SDL-devel-1.2.8-mingw32.tar o algo similar. También encontrará paquetes para Visual C++ y otros compiladores.
  2. Descomprima la librería de vínculos dinámicos. Debe obtener un archivo llamado sdl.dll. Copie este archivo al directorio /windows/system32, o bien ubíquelo en la misma carpeta en la que vaya a estar el programa ejecutable del ajedrez.
  3. Descomprima el paquete de desarrollo. Encontrará varios directorios y, dentro de ellos, multitud de archivos. Copie los archivos en los directorios del mismo nombre de su compilador. Por ejemplo, copie el directorio “include” del paquete de desarrollo al directorio “include” de la carpeta donde esté instalado su compilador. Repita la operación para todos los directorios cuyo nombre coincida.

Compilación y enlace

Al no ser SDL una librería estándar, el enlace entre nuestro programa y las funciones de SDL no se produce automáticamente. Hay que indicarle al enlazador (o linker) lo que debe hacer.

Compilación y enlace en GNI/Linux

Si, por ejemplo, nuestro programa ejecutable se llama “ajedrez” y se construye a partir de 3 programas objeto, llamados “ajedrez.o”, “movs.o” e “interfaz.o”, debemos modificar la primera parte de nuestro Makefile de este modo:

ajedrez: ajedrez.o movs.o interfaz.o
	gcc -g `sdl-config --cflags` -o ajedrez ajedrez.o movs.o interfaz.o `sdl-config --libs`

Fíjese bien en que las comillas son en realidad acentos graves, es decir, invertidos e inclinados hacia atrás. Debe respetar la sintaxis para que funcione.

Eso es todo lo que tiene que hacer para compilar son SDL. Si te interesa saber POR QUÉ, siga leyendo. Si no, puede pasar al siguiente apartado.

En realidad, lo que hay escrito entre esas comillas invertidas son comandos de SDL que indican la configuración de la librería. Estos comandos los puede ejecutar desde la consola, obteniendo más o menos esto:

$ sdl-config --cflags
-I/usr/local/include -I/usr/local/include/SDL -D_REENTRANT
$ sdl-config --libs
-L/usr/local/lib -lSDL -lpthread

Al añadir estos comandos dentro del Makefile, enmarcados entre esas comillas invertidas, obligamos a la herramienta make a ejecutar los comandos y a sustituir el texto entrecomillado por el resultado del comando. Es decir, sería como si hubiéramos puesto esto en el Makefile:

ajedrez: ajedrez.o movs.o interfaz.o
	gcc -g -I/usr/local/include -I/usr/local/include/SDL -D_REENTRANT -o ajedrez ajedrez.o movs.o interfaz.o -L/usr/local/lib -lSDL -lpthread

Pero preferiremos la primera forma porque es más corta y, además, funcionará en todas las situaciones, mientras que esta segunda depende de dónde y cómo se haya instalado la librería SDL (fíjese que hace referencia a directorios concretos de nuestro sistema)

Compilación y enlace en Windows

Lo siguiente explica cómo compilar y enlazar con SDL desde el compilador Dev-C++, que tiene licencia GNU y es gratuito. Ya explicamos cómo se usaba en este artículo. Con otros compiladores el proceso debe ser similar, aunque es posible que necesite bajar otro paquete de desarrollo adaptado al compilador concreto.

Para poder compilar y enlazar la libería SDL tiene que abrir las opciones del proyecto (menú “Proyecto”) y activar la pestaña “Parámetros”. En el cuadro con el título “Linker” escriba lo siguiente:

-lmingw32 -lSDLmain -lSDL

Si ha instalado correctamente la librería SDL, con esto debería bastar. Recuerde que el archivo sdl.dll debe estar en la misma carpeta que el programa ejecutable (o, si no, instalado con las liberías del sistema de Windows)

Inicialización y terminación de la pantalla gráfica

Una vez instalada la libería y preparado el compilador, podemos usar las funciones de SDL como cualquier otra función estándar de C. Su uso es exactamente igual en Windows y en Linux, por lo que el programa que obtendremos debería compilar sin necesidad de hacerle ningún cambio en ambos sistemas.

Para usar los gráficos, hay que hacer un #include <SDL/SDL.h> en el archivo fuente, como es natural. Aparece dos veces el nombre “SDL” porque el archivo SDL.h está dentro de una carpeta llamada SDL.
Lo siguiente que hay que hacer es inicializar la pantalla gráfica. Para eso disponemos de dos funciones: SDL_Init() y SDL_SetVideoMode().

SDL_Init() debe ser la primera función en invocarse. No se puede usar ninguna otra función de SDL si antes no se ha llamado a ésta. Hay que pasarle un parámetro que indica qué tipo de sistema multimedia queremos manejar (la tarjeta de vídeo, la de sonido, el CD-ROM, etc). En nuestro caso será la tarjeta de vídeo, ya que sólo nos interesa manipular gráficos. La constante para ello es SDL_INIT_VIDEO:

SDL_Init(SDL_INIT_VIDEO);

La fución SDL_Init() devuelve –1 si ocurre algún error al iniciar el sistema de gráficos. En ese caso, el programa no podrá continuar, de modo que debemos comprobar el valor devuelto por SDL_Init().

SDL_SetVideoMode() debe ser la segunda función en invocarse, justo a continuación de SDL_Init(). Sirve para establecer el tipo de pantalla gráfica que queremos. Hay que indicarle el tamaño en píxels, el número de bits de color y los atributos de la pantalla. Por ejemplo:

SDL_SetVideoMode(800, 600, 16, SDL_ANYFORMAT | SDL_DOUBLEBUFFER);

Esto crea una ventana gráfica de 800×600 píxels, con 16 bits de profundidad de color. El último parámetro, SDL_ANYFORMAT, es una constante que indica a SDL que puede seleccionar otra profundidad de color si la elegida no está disponible. Este cuarto parámetro puede tomar otros muchos valores que no vamos a ver, pero sí señalaremos que es conveniente añadir la constante SDL_DOUBLEBUFFER por motivos de rendimiento (ver ejemplo más abajo).

SDL_SetVideoMode() devuelve un puntero a una estructura llamada SDL_Surface, definida en SDL.h, o NULL si ocurre algún error. Este puntero nos será imprescidible para manejar la pantalla gráfica, así que debe guardarlo en una variable de tipo puntero a SDL_Surface.

SDL_Quit(). Tan importante como inicializar la pantalla gráfica es finalizarla. Tenga en cuenta que la pantalla gráfica consume muchos recursos, y éstos deben ser liberados antes de que el programa termine su ejecución. Para eso tenemos la función SDL_Quit(), que se invoca sin argumentos.

Siempre se entiende mejor con un ejemplo. Aquí va uno dónde se ilustra la inicialización de la pantalla gráfica:

#include <SDL/SDL.h>
...
SDL_Surface *pantalla;    // Puntero a la pantalla. Lo necesitaremos más adelante
...
// Inicializamos el modo de vídeo de SDL
if (SDL_Init(SDL_INIT_VIDEO) == -1) {
  puts("Error en la inicialización del sistema de vídeo\n");
  SDL_Quit();
  exit(-1);
}
// Creamos una pantalla gráfica de 800x600
pantalla = SDL_SetVideoMode(800, 600, 16, SDL_ANYFORMAT|SDL_DOUBLEBUF);
if (pantalla == NULL) {
  puts("Fallo al establecer el modo de vídeo\n");
  SDL_Quit();
  exit(-1);
}
...
SDL_Quit();        // Esto se hace al final del programa

Mostrando imágenes en la pantalla

Ya tenemos nuestra pantalla gráfica inicializada y lista para empezar a dibujar en ella. Pero, ¿qué tipo de objetos se pueden dibujar?

Aunque las librerías gráficas permiten al programador pintar píxels individuales en cualquier punto de la pantalla, lo habitual es trabajar con imágenes previamente existentes llamadas sprites. Un sprite es una imagen guardada en un archivo que puede ser cargada por el programa y mostrada en cualquier parte de la pantalla gráfica y tantas veces como sea necesario.

Por lo tanto, lo primero que necesita es hacerse con una colección de sprites para su programa. Supongamos, por ejemplo, que estamos programando un juego de ajedrez. Necesitaremos los siguientes sprites (puede buscarlos en Internet, escanearlos, dibujarlos usted mismo/a, etc):

  • Una imagen del tablero, a ser posible de buen tamaño (por ejemplo, de 400×400 píxels como mínimo)
  • Una imagen de cada una de las piezas. En total son 12: peón, torre, caballo, alfil, dama y rey, cada uno en dos colores (blanco y negro). El tamaño de estas imágenes debe ser adecuado para reproducirlas dentro de cada uno de los recuadros del tablero. Si, por ejemplo, en el tablero cada casilla mide 45×45 píxels, las imágenes de las piezas deben ser de alrededor de 40×40 píxels (o incluso algo menos). Además, todas las piezas deben tener el mismo color de fondo (para simplificar, negro)
  • Opcionalmente, todas las imágenes adicionales que deseemos para mejorar la estética del programa.

Los archivos con las imágenes deben estar en formato BMP (SDL admite otros formatos, pero el BMP es con diferencia el más fácil de manipular)

Para dibujar una imagen en cualquier punto de la pantalla, hay que hacer dos cosas que más abajo describimos con detalle:

  1. Cargar la imagen en la memoria (procedente de un archivo BMP)
  2. Mostrar la imagen en la pantalla

1. Cargar imágenes en la memoria

Sólo es necesario cargar las imágenes una vez. Normalmente, se hará al principio del programa, justo después de la inicialización de SDL. Una vez cargadas en la memoria, podremos utilizarlas tantas veces como las necesitemos, a menos que liberemos el espacio de memoria que ocupan. La liberación de espacio, por tanto, debería hacerse al final del programa, justo antes de terminar.

Para cargar una imagen BMP se usa la función SDL_LoadBMP(), de esta forma:

SDL_Surface *tablero;
tablero = SDL_LoadBMP("tablero.bmp");
if (fondo == NULL) {
  printf("Error al cargar el archivo tablero.bmp");
  SDL_Quit();
  exit(-1);
}

Observa que SDL_LoadBMP() devuelve un puntero a SDL_Surface. Este puntero será necesario para luego mostrar la imagen en cualquier lugar de la pantalla. La variable “fondo” debe ser global si se va a usar en más de una función (si es local y la pasamos como parámetro a otra función, SDL fallará).

Las imágenes son rectangulares. En muchas ocasiones, necesitamos mostrar una imagen encima de otra. Es el caso de las piezas, que se mostrarán encima del tablero. Cuando esto ocurre, el color de fondo de la pieza (que decidimos que fuera negro) aparecerá encima del tablero como un desagradable recuadro de color negro. En estas situaciones, hay que avisar a SDL de que, para este sprite en concreto, el color negro va a ser transparente, es decir, no debe ser mostrado. Esto se hace así:

SDL_Surface *peon_blanco;
Uint32 color;    // Para definir el color de transparencia (donde proceda)// Cargamos la imagen del peón blanco
peon_blanco = SDL_LoadBMP("peon_bl.bmp");
if (peon_blanco == NULL) {
  printf("Error al cargar el archivo peon_bl.bmp");
  SDL_Quit();
  exit(-1);
}
// Definimos la transparencia (color negro = (0,0,0) )
color = SDL_MapRGB(peon_blanco->format, 0, 0, 0);
SDL_SetColorKey(cuadro1, SDL_SRCCOLORKEY | SDL_RLEACCEL, color);

Las imágenes cargadas en memoria deben ser liberadas antes de finalizar el programa con una llamada a SDL_FreeSurface(). Por ejemplo, para liberar la memoria ocupada por la imagen “tablero.bmp” que hemos cargado antes usaremos el puntero que obtuvimos al cargarla, así:

SDL_FreeSurface(tablero);

2. Mostrar imágenes en la pantalla

Una vez cargada una imagen BMP en la memoria, podemos mostrarla en la pantalla a través del puntero SDL_Surface que obtuvimos al cargarla. Una imagen cargada puede ser mostrada todas las veces que queramos en cualquier posición de la pantalla.

Por ejemplo, para mostrar la imagen del tablero (que cargamos en un ejemplo del apartado anterior) haríamos lo siguiente (luego comentamos el código)

SDL_Rect rect;
rect = (SDL_Rect) {10, 10, 400, 400};
SDL_BlitSurface(tablero, NULL, pantalla, &rect);
SDL_Flip(pantalla);

La variable “rect” es de tipo SDL_Rect, y define un área rectangular de la pantalla. El área rectangular empieza en las coordenadas (10, 10) (esquina superior izquierda de la pantalla) y mide 400 píxels de ancho y 400 de alto, es decir, termina en (410, 410)

SDL_BlitSurface() es la función que se encarga de mostrar en la pantalla un sprite. La variable “tablero” es de tipo SDL_Surface*, y debe ser la que nos devolvió SDL_LoadBMP() al cargar la imagen del tablero. La variable “pantalla” también es una SDL_Surface*, y debe ser la que nos devolvió SDL_SetVideoMode() al inicializar la pantalla gráfica. Ya dijimos que los punteros que nos devuelven estas funciones son imprescidibles y que debíamos definirlos como variables globales. La variable “rect” es el área rectangular que acabamos de definir.

Observe que “rect” es la que indica en qué lugar de la pantalla va a aparecer el sprite. En este ejemplo, aparecerá en (10,10). Se le han reservado 400×400 píxels para dibujarse, es decir, hasta la posición (410, 410). Si el sprite es más pequeño, no pasará nada (ocupará lo que mida realmente). Si es más grande, se truncará.

Por último, SDL_Flip() hace que lo que acabamos de dibujar se muestre realmente en la pantalla.

Control del teclado

Para leer el teclado en una ventana gráfica creada con SDL no se pueden usar las funciones estándar (como getchar() o gets()), sino las propias de SDL. SDL solo permite leer los caracteres de uno en uno, y no muestra eco por la pantalla (si queremos eco, tenemos que mostrar los caracteres nosotros mismos después de leerlos)

La forma de capturar los caracteres tecleados se muestra un el siguiente ejemplo:

SDL_Event evento;                // Para leer el teclado
// Leer teclado
if (SDL_PollEvent(&evento))            // Comprobar si se ha pulsado una tecla
{
  if (evento.type == SDL_KEYDOWN)     // Efectivamente, se ha pulsado una tecla
  {
    switch (evento.key.keysym.sym)  // Vamos a mirar qué tecla es
    {
      case SDLK_UP:     ...acciones...; break;    // Flecha arriba
      case SDLK_DOWN:   ...acciones...; break;    // Flecha abajo
      case SDLK_LEFT:   ...acciones...; break;    // Felcha izquierda
      case SDLK_RIGHT:  ...acciones...; break;    // Flecha derecha
      case SDLK_RETURN: ...acciones...; break;    // Intro
      case SDLK_ESCAPE: ...acciones...; break;    // ESC
      case SDLK_m:      ...acciones...; break;    // Tecla "m" (menú)
    }
  }
}

Existen constantes para cualquiera de las otras teclas del teclado. Todas empiezan por “SDLK_”. Por ejemplo, la tecla “a” tendrá el código “SDLK_a”.

Definición de colores

Aunque en general trataremos con imágenes ya creadas (como la del tablero o las de las piezas), es posible que necesites definir algún color para usarlo directamente sobre la pantalla gráfica (por ejemplo, para usar transparencias o para escribir un texto)

En SDL no hay colores predefinidos, como en ncurses. Los colores debemos definirlos nosotros mezclando los colores básicos RGB (rojo, verde y azul)

Hay dos formas de definir un color: con una variable de tipo “SDL_Color” o con una variable de tipo “Uint32”. El uso de una u otra dependerá de para qué queramos usar ese color.

a) Con una variable de tipo SDL_Color

Se usaría así:

SDL_Color color;
color = (SDL_Color) {50, 150, 200, 255};

Los cuatro números definen el color. Deben ser números comprendidos entre 0 y 255. El primero es el nivel de rojo (R), el segundo el nivel de verde (G) y el tercero, el nivel de azul (B). El cuarto número es el brillo. El color definido en este ejemplo tiene mucho azul, bastante verde y poco rojo. El resultado debe ser un azul amarillento.

b) Con una variable de tipo Uint32

Uint32 color;
color = SDL_MapRGB(pantalla->format, 50, 150, 200);

En esta ocasión, “pantalla” debe ser un puntero a una imagen SDL_Surface que hayamos cargado previamente. Los tres valores siguientes son los niveles RGB. No hay nivel de brillo, porque éste se toma de la imagen apuntada por “pantalla”.

De las dos maneras se pueden definir colores para usarlos posteriormente. Si el color lo necesitamos para una transparencia, recurriremos al segundo método (de hecho, ya vimos un ejemplo de ello al estudiar cómo se cargaban y mostaban las imágenes en SDL; allí usamos el color negro como transparencia). Si el color lo necesitamos para escribir un texto en la pantalla gráfica, usaremos el primer método (como se podrá ver en el siguiente apartado)

Mostrar texto en la pantalla gráfica: la librería SDL_TTF

La librería SDL no permite directamente la escritura de texto en la pantalla gráfica. Esto se debe a que la pantalla gráfica, por definición, no admite caracteres, sino únicamente imágenes.

Por fortuna, a la sombra de SDL se han creado multitud de librerías adicionales que, partiendo de SDL, complementan y mejoran sus prestaciones. Una de ellas es SDL_TTF.

La libería SDL_TTF permite cargar fuentes true type que estén guardadas en archivos “.ttf” y manejarlas como si fueran imágenes BMP en la pantalla gráfica generada por SDL. Necesitamos SDL_TTF, por lo tanto, para escribir los mensajes de usuario, las opciones del menú, etc.

Instalación, compilación y enlace de SDL_TTF

La instalación de la librería SDL_TTF es similar a la de SDL, tanto en Linux como en Windows, de modo que puede remitirse al apartado correspondiente para recordar cómo se hacía.

En cuanto a la compilación y enlace, sólo tiene que añadir la opción “-lSDL_ttf” a la línea de compilación del Makefile:

gcc -g `opciones de SDL` -o ajedrez ajedrez.o movs.o... `más opciones de SDL` -lSDL_ttf

Si estamos compilando en Windows con Dev-C++, agregaremos “-lSDL_ttf” a Opciones del Proyecto / Parámetros / Linker

Inicialización de SDL_TTF

Igual que SDL, la librería SDL_TTF necesita ser inicializada antes de usarla, y finalizada antes de terminar el programa para liberar los recursos adquiridos.

Como SDL_TTF corre por debajo de SDL, debe ser inicializada después de SDL, y debe ser terminada antes que SDL.

La inicialización de SDL_TTF se hace simplemente así:

if (TTF_Init() == -1) {
  printf("Fallo al inicializar SDL_TTF");
  exit(-1);
}

Inmediatamente después podemos cargar una fuente true type de un archivo TTF, así:

TTF_Font* fuente;
....
fuente = TTF_OpenFont("arial.ttf", 14);
if(fuente == NULL) {
  printf("Fallo al abrir la fuente");
  exit(-1);
}

TTF_SetFontStyle(fuente, TTF_STYLE_BOLD);

La variable “fuente” es un puntero a la estructura TTF_Font. La función TTF_OpenFont() abre el archivo “arial.ttf” y carga el tipo de letra Arial en tamaño 14 para su uso en el programa. Después es conveniente comprobar que el puntero “fuente” contenga un valor válido y no NULL.

Por último, la función TTF_SetFontStyle() puede usarse para determinar el estilo de la fuente. Tenemos varias posibilidades: TTF_STYLE_BOLD (negrita), TTF_STYLE_ITALIC (cursiva), TTF_STYLE_UNDERLINE (subrayado) y TTF_STYLE_NORMAL. Si queremos combinar varios estilos, podemos separarlos por el operador “|”. Por ejemplo, para poner la fuente en negrita y cursiva escribiríamos esto:

TTF_SetFontStyle(fuente, TTF_STYLE_BOLD | TTF_STYLE_ITALIC);

Finalización de SDL_TTF

El proceso de finalización es inverso y complementario al de inicialización. Primero habrá que liberar todas las fuentes cargadas durante la inicialización, y luego hay que terminar el subsistema SDL_TTF.
Para liberar una fuente escribiremos sencillamente:

TTF_CloseFont(fuente);

La variable “fuente” será de tipo TTF_Font*, y debe coincidir con la que nos devolvió la función TTF_OpenFont(). Esta operación la repetiremos con cada una de las fuentes que hayamos cargado.
Después finalizaremos SDL_TTF escribiendo:

TTF_Quit();

Recuerda que esto debe hacerse ANTES de SDL_Quit(), ya que SDL_TTF depende de SDL.

Escribir texto con SDL_TTF

Todo esto lo hacemos con un objetivo: poder escribir texto en la pantalla gráfica y sustituir así todas las funciones printf() y similares.

Para escribir un texto hay que hacer dos cosas: primero, convertirlo en una imagen; segundo, mostrar la imagen en la pantalla.

La conversión de un texto en una imagen se hace con la función TTF_Render():

SDL_Color color;
SDL_Surface* txt_img;
color = (SDL_Color) {255,100,100,255};
txt_img = TTF_RenderText_Blended(fuente, "Hola mundo", color);
if(txt_img == NULL) {
  printf("Fallo al renderizar el texto");
  exit(-1);
}

Como ve, hay que hacer bastantes cosas para mostrar un texto en la pantalla gráfica, pero todo es acostumbrarse. Primero, hay que definir un color para el texto (cómo se definen los colores es algo que vimos en el epígrafe anterior). En este caso, hemos escogido un rojo brillante.

Después se invoca a TTF_RenderText(), pasándole como parámetros el puntero a la fuente que obtuvimos con TTF_OpenFont(), el texto que queremos mostrar y el color. La función nos devuelve un puntero de tipo SDL_Surface* que, si recuerdas, es exactamente el mismo que usábamos con las imágenes cargadas desde un archivo BMP.

En realidad, la función TTF_RenderText() tiene tres formas:

  • TTF_RenderText_Solid(): realiza una conversión del texto en imagen rápida pero de poca calidad.
  • TTF_RenderText_Shaded(): la imagen resultante es de gran calidad pero tiene un recuadro negro alrededor
  • TTF_RenderText_Blended(): la imagen resultante es de gran calidad y sin recuadro negro

En general preferiremos el modo “Blended”, que es el que proporciona mejores resultados. El modo “Shaded” se puede usar en determinados lugares (si no hay otra imagen debajo del texto). El modo “Solid” sólo debe usarse si hay que mostrar mucho texto y el modo “Blended” se revela demasiado lento.

Hasta aquí, sólo hemos convertido el texto “Hola mundo” en una imagen, pero aún no la hemos mostrado en la pantalla. Para hacerlo procederemos como con cualquier otra imagen:

// Mostramos el texto como si fuera una imagen
rect = (SDL_Rect) { 500, 280, 100, 30 };
SDL_BlitSurface(txt_img, NULL, pantalla, &rect);
SDL_Flip(scr);

Se supone que “rect” es de tipo SDL_Rect y que pantalla es el puntero a SDL_Surface* que nos devolvió SDL_SetVideoMode() al inicializar SDL. Así, el texto “Hola mundo” se mostrará en la posición (500, 280) de la pantalla gráfica, reservándose para él 100 píxels de ancho y 30 de alto.

(Esta entrada forma parte del Curso de Programación en C)

Las uniones son muy similares a las estructuras: se declaran de manera análoga (cambiando la palabra struct por union) y se utilizan exactamente igual. Por ejemplo:

union datos_carnet
{
  long int número;
  char letra;
  char nombre[50];
  char apellidos[100];
};
union datos_carnet dni;        /* Declaración de la variable */

La diferencia radica en que todos los miembros de la union comparten el mismo espacio en memoria, de manera que sólo se puede tener almacenado uno de los miembros en cada momento.

El tamaño de la union es igual al del miembro más largo (no hagan chistes con esta frase). Supongamos que, en el ejemplo anterior, la longitud de cada miembro es:

  • número: 4 bytes (32 bits)
  • letra: 1 byte (8 bits)
  • nombre: 50 bytes
  • apellidos: 100 bytes

Por lo tanto, la union ocupa un espacio en memoria de 100 bytes, mientras que si fuera una estructura ocuparía 155 bytes, ya que cada miembro se almacenaría en un espacio de memoria propio.

Al hacer en una unión una asignación como esta:

dni.número = 55340394;

…estamos asignando el número 55340394 a los primeros 4 bytes de la union. Si posteriormente se hace esta otra asignación:

strcpy(dni.nombre, "María");

…la cadena “María” ocupará los primeros 5 bytes de la unión y, por lo tanto, se habrá perdido el número almacenado anteriormente.

Al usar uniones, únicamente debemos acceder a los miembros que en ese momento tengan algún valor. El siguiente código, por ejemplo, funciona correctamente y escribe en la pantalla el texto “María”:

dni.número = 55340394;
strcpy(dni.nombre, "María");
printf("%s", dni.nombre);

En cambio, el siguiente fragmento no funciona bien y escribe en la pantalla un número impredecible, ya que el miembro dni.número ha perdido su valor con la segunda asignación:

dni.número = 55340394;
strcpy(dni.nombre, "María");
printf("%d", dni.número);

Por lo demás, las uniones se utilizan exactamente igual que las estructuras, con la ventaja de que ahorran espacio en memoria. Sin embargo, al compartir todos los miembros las mismas posiciones de memoria, la utilidad de las uniones queda reducida a determinados algoritmos en los que esta limitación no representa un problema.

(Esta entrada es parte del Curso de Programación en C)

En los arrays, todos los elementos deben ser del mismo tipo. Pero hay ocasiones en las que debemos agrupar elementos de diversa naturaleza: para eso existen las estructuras o registros. Una estructura, por tanto, es una agrupación bajo el mismo nombre de varios datos cuyos tipos pueden ser diferentes.

Declaración de estructuras

Las estructuras se declaran en la zona habitual de declaración de variables, utilizando esta sintaxis:

struct nombre_estructura
{
   tipo1 dato1;
   tipo2 dato2;
   ...
   tipoN datoN;
};

Cada dato que forma parte de la estructura se denomina miembro. Posteriormente a la definición, se pueden declarar variables cuyo tipo sea la estructura que hayamos definido. Cada una de esas variables contendrá, en realidad, todos los datos miembro de que conste la estructura. Por ejemplo:

struct datos_carnet
{
   long int numero;
   char letra;
   char nombre[50];
   char apellidos[100];
};
struct datos_carnet dni;

La variable dni que se declara en la última línea no es de un tipo simple, como int o float, sino de un tipo complejo que acabamos de definir, llamado struct datos_carnet. Por lo tanto, una única variable (dni) va a contener varios datos agrupados en su interior (el número del DNI, la letra, el nombre y los apellidos)

Manipulación de estructuras

Una vez que se tiene una variable compleja definida mediante una estructura surge la pregunta: ¿cómo se puede manipular cada uno de los elementos individuales (miembros) que forman parte de la estructura?
El acceso a los miembros se realiza con el nombre de la variable y el del miembro separados por un punto, así:

variable_estructura.miembro;

Continuando con el ejemplo anterior, podemos hacer lo siguiente:

dni.numero = 503202932;
dni.letra = 'K';
strcpy(dni.nombre, "Manuel");
strcpy(dni.apellidos, "García García");

Lógicamente, para escribir un miembro en la pantalla, leerlo por teclado o realizar con él cualquier otro proceso, se utiliza la misma sintaxis.

Paso de estructuras a funciones

Al manejar estructuras en un programa modular pueden darse dos situaciones:

  1. Que queramos pasar una estructura completa como parámetro a una función
  2. Que queramos pasar sólo un miembro de una estructura como parámetro a una función

Paso de estructuras completas como parámetros

Las variables basadas en estructuras se pueden pasar como parámetros por valor o por referencia, existiendo entre ambos métodos las mismas diferencias que en los tipos de datos simples.

Para pasar, por ejemplo, la variable dni del ejemplo anterior por valor a una función llamada escribir_dni(), procederíamos de este modo:

escribir_dni(dni);

Y también puede pasarse por referencia añadiendo el símbolo “&”, de esta otra manera:

escribir_dni(&dni);

A su vez, la función escribir_dni() debe especificar en su declaración si el argumento se pasa por valor o por variable. El paso por valor se indica así:

void escribir_dni(struct datos_carnet dni)

Mientras que el paso por variable tiene esta forma (usando el símbolo ” * “):

void escribir_dni(struct datos_carnet* dni)

Dentro de la función, el acceso a los miembros de la estructura es diferente si ésta ha sido pasada por valor o por variable. Así, por ejemplo, el acceso al miembro nombre de la estructura dni, si ésta ha sido pasada por valor, se hace a la manera habitual:

printf("%s", dni.nombre);

Pero si la estructura dni se ha pasado por variable, se sustituye el punto por la flecha “->”:

printf("%s", dni->nombre);

Paso de miembros de estructuras como parámetros

Los miembros de las estructuras se pueden manipular como cualquier otro dato del mismo tipo que el miembro. Por ejemplo, como dni.numero es de tipo entero largo (long int), puede realizarse con este miembro cualquier operación que también pueda realizarse con un número entero largo, incluido el paso como parámetro a una función.

Así, para pasar por valor únicamente el miembro dni.numero a una función llamada, por ejemplo, escribir_dni(), haríamos esto:

escribir_dni(dni.numero);

En la declaración de la función, el parámetro formal debe ser de tipo long int:

void escribir_dni(long int número)

Dentro del cuerpo de la función, la variable número puede usarse como cualquier otra variable de tipo entero.

Si lo que queremos es pasar el miembro dni.numero por variable, no por valor, lo haremos igual que con cualquier dato de tipo entero, es decir, agregando el símbolo & a la llamada:

escribir_dni(&dni.numero);

Y en la declaración de la función el parámetro debe llevar el símbolo ” * “:

void escribir_dni(long int *numero)

En este caso, cada vez que vaya a usarse el parámetro número dentro del código de la función, al estar pasado por variable debe ir precedido del símbolo ” * “; por ejemplo:

*numero = 5;

Un ejemplo de utilización de estructuras

El siguiente programa es un sencillo ejemplo de manejo de estructuras. Se encarga de almacenar los datos de un alumno en una estructura y luego mostrarlos por la pantalla. Los datos que se almacenan son, simplemente, su número de matrícula, su nombre y su edad, pero se podrían ampliar sin más que añadir otros miembros a la estructura.

La entrada de datos se hace en una función llamada leer_datos(), a la que se pasa como parámetro una variable del tipo de la estructura. Luego se hace una pequeña modificación en la edad del alumno, para convertirla de años a meses, y se muestran los datos en la pantalla llamando a otra función, escribir_datos().

Preste especial atención a cómo se pasan los parámetros de tipo complejo a las funciones. En la primera función, leer_datos(), se pasa la estructura por variable. En la segunda, escribir_datos(), se pasan los miembros de la estructura (no la estructura completa), y además se hace por valor.

Observe también que la estructura se define antes de la función main(). Esto la convierte en un tipo de datos global, es decir, utilizable desde cualquier punto del programa. Si la definiéramos dentro de la función main() sólo podría emplearse en esa función.

#include <stdio.h>
#include <string.h>

struct datos_alumno    /* Definición GLOBAL de la estructura */
{
    int matricula;
    char nombre[30];
    int edad;
};

/* Prototipos de las funciones */
void leer_datos(struct datos_alumno *alumno);
void escribir_datos(int matr, char* nombre, int edad);

int main(void)
{
    struct datos_alumno alumno;

    leer_datos(&alumno);
    alumno.edad = alumno.edad * 12;
    escribir_datos(alumno.matricula, alumno.nombre, alumno.edad);
}

void leer_datos(struct datos_alumno *alumno)
{
    printf("Introduzca el nº de matricula :");
    scanf("%d", &alumno->matricula);
    printf("Introduzca el nombre :");
    gets(alumno->nombre);
    printf("Introduzca la edad :");
    scanf("%d", &alumno->edad);
}
void escribir_datos(int matr, char* nombre, int edad)
{
    printf("MATRICULA = %d \n", matr);
    printf("NOMBRE = %s \n", nombre);
    printf("MESES = %d \n", edad);
}

(Esta entrada es parte del Curso de Programación en C)

Ya hemos visto cómo se trabaja con arrays unidimensionales o vectores en C. El concepto de vector puede extenderse a arrays de varias dimensiones. El ejemplo más fácil de entender es el del array bidimensional, también llamado matriz o tabla.

Arrays bidimiensionales (matrices o tablas)

Una matriz, tabla o array bidimiensional, como un vector, es una colección de elementos individuales, todos del mismo tipo, agrupados bajo el mismo identificador. La diferencia con el vector es que, en el momento de declararlo y de acceder a cada elemento individual, debemos utilizar dos índices en lugar de uno:

int matriz[4][4];

Tenemos aquí una variable compleja llamada matriz que no consta de 4 elementos enteros, sino de 16, es decir, 4×4. Podemos representar gráficamente la matriz como una tabla:

            Columnas
          0   1   2   3
        +---+---+---+---+
      0 |   |   |   |   |
 F      +---+---+---+---+
 i    1 |   |   |   |   |
 l      +---+---+---+---+
 a    2 |   |   |   |   |
 s      +---+---+---+---+
      3 |   |   |   |   |
        +---+---+---+---+

Cada casilla de la tabla o matriz es identificable mediante una pareja de índices. Normalmente, el primero de los índices se refiere a la fila, y el segundo, a la columna. Por ejemplo, si hacemos estas asignaciones:

matriz[0][0] = 5;
matriz[1][0] = 1;
matriz[3][2] = 13;

…el estado en el que quedará la matriz será el siguiente:

            Columnas
          0   1   2    3
        +---+---+----+---+
      0 | 5 |   |    |   |
 F      +---+---+----+---+
 i    1 | 1 |   |    |   |
 l      +---+---+----+---+
 a    2 |   |   |    |   |
 s      +---+---+----+---+
      3 |   |   | 13 |   |
        +---+---+----+---+

Por descontado, los dos índices de la matriz pueden ser diferentes, obteniéndose tablas que son más anchas que altas o más altas que anchas.

Por lo demás, las matrices se utilizan exactamente igual que los vectores. A modo de ejemplo, éste sería el código para inicializar una matriz de 5×10 enteros con todos sus elementos a 0. Observe cómo se usan los dos bucles anidados para acceder a todos los elementos:

int m[5][10];
int i, j;
for (i = 0; i <= 4; i++)
{
   for (j = 0; j <= 9; j++)
   {
       m[i][j] = 0;
   }
}

Arrays de múltiples dimensiones

Del mismo modo que a los vectores se les puede añadir un segundo índice, obteniendo las matrices, se puede generalizar esta práctica, dando lugar a arrays multidimensionales. Por ejemplo, el siguiente es un array de cinco dimensiones compuesto de números enteros:

int ejemplo[10][10][4][5][7];

Estos arrays no se pueden representar gráficamente (aunque con los de tres dimensiones se puede intentar dibujar un cubo), pero su utilización es idéntica a la de los arrays de una o dos dimensiones.

Categorías

Licencia

ClustrMaps