Programación

You are currently browsing the archive for the Programación category.

Nos quedamos en la fase 9 con un sabor de boca agridulce: aunque nuestro programa ya era capaz de seleccionar una pieza y realizar un movimiento (es decir, ya podía jugar al ajedrez contra nosotros), era tan mal jugador como el más bisoño de los principiantes. ¿Qué estaba ocurriendo?

Ocurría que habíamos transmitido poca “inteligencia” (y las c0millas son por lo indefinido del término) al programa. Apenas la suficiente para que seleccionase el mejor movimiento mirando únicamente el estado del tablero tal y como quedaría al hacer ese movimiento. Ese trocito de inteligencia lo denominamos “función de evaluación estática”.

Ahora llega el momento de hacer germinar ese granito de inteligencia que dejamos plantado en la fase 9.

Introducción a las técnicas heurísticas

Se denominan técnicas heurísticas a aquellas que no nos aseguran encontrar una solución perfecta a un problema (en nuestro caso, una solución perfecta sería encontrar un movimiento que nos condujera directa e inevitablemente a ganar la partida de ajedrez), pero sí hallar una solución de la cual se puede esperar que esté entre las mejores soluciones posibles.

Con la solución adoptada en la fase 9, y siempre que la función de evaluación sea lo bastante buena, conseguiremos que el ordenador “piense” sus jugadas siguiendo criterios objetivos, pero cometerá continuamente torpezas como, por ejemplo, no dudar en sacrificar una reina para comer un peón. Esto se debe a que, a diferencia de los jugadores humanos, la máquina no ve más allá del siguiente movimiento, mientras que un humano intentará predecir la reacción de su adversario a su movimiento. Un buen jugador humano, antes de realizar un movimiento, estará pensando en los siguientes dos, tres, cuatro o más movimientos.

Por supuesto, ningún ser humano puede calcular todos los posibles movimientos de las siguientes dos, tres o cuatro jugadas. Ni mucho menos cómo acabará la partida (entonces, el ajedrez no tendría ninguna gracia). Ello se debe a la enorme cantidad de posibilidades que se abren desde cada posición del tablero. Son tantas que ni siquiera un ordenador potente puede evaluarlas TODAS más allá de tres o cuatro movimientos.

Para que se haga una idea, se calcula que el número de posibles combinaciones de piezas después de los diez primeros movimientos es de alrededor de 165.518.829.100.544.000.000.000.000. Un ordenador rapidísimo, capaz de evaluar, por ejemplo, un millón de posiciones por segundo , necesitaría más de 7 billones de años (¡mucho más que la edad del universo!) para generar todas las combinaciones posibles y decidir cual es la mejor.

Señoras y señores… ¡un fuerte aplauso para Minimax!

Ante la imposibilidad de una evaluación exhaustiva de todas las combinaciones posibles de jugadas a partir de un estado concreto, tenemos que conformarnos con un examen parcial. Una buena estrategia para lograr una buena heurística es la técnica minimax, que se puede usar en otros muchos juegos de dos jugadores para hacer que el ordenador “piense”, y que describimos a continuación.

Supongamos que el ordenador maneja a las piezas negras. Lógicamente, su siguiente movimiento debería ser aquél que haga máxima la función de evaluación. Pero debemos tener en cuenta que el contrincante (piezas blancas) responderá con la jugada que haga máxima la función de evaluación para las blancas, es decir, que la convierta en mínima para las negras.

maximo = -99999
para cada movimiento posible del jugador actual sobre "tablero" hacer
inicio
    // Vamos a tratar de hacer este movimiento, a ver qué pasaría
    copiar tablero en tablero2
    realizar el movimiento sobre tablero2
    // Vamos a ver con qué movimiento respondería el contrario. Supondremos que
    // preferirá el que haga la función de evaluación mínima para nosotros
    minimo = 99999
    para cada movimiento_contrario posible del jugador contrario sobre "tablero2" hacer
    inicio
       copiar tablero2 en tablero3
       realizar movimiento_contrario sobre tablero 3
       puntos = evaluar_estado(tablero3)
       si (puntos < minimo)
         minimo = puntos
       fin
    fin (para)

    // Ya hemos encontrado la mejor respuesta del contrario a nuestro movimiento
    // Después de ella, ¿quedamos en una buena situación? Vamos a comprobarlo
    si (minimo > maximo) // Es el mejor (para nosotros) encontrado hasta ahora
    inicio
       maximo = minimo
       mejor_movimiento = movimiento
    fin (si)
fin (para)

Al final del algoritmo, la variable “mejor_movimiento” debe contener el movimiento seleccionado como el más adecuado (en realidad, debe ser un conjunto de cuatro coordenadas, dos de origen y dos de destino). Así ya no aparece el problema de sacrificar una reina para comer un peón. Aunque eso haga máxima la función de evaluación en el siguiente movimiento, la hará mínima dentro de dos movimientos, porque podemos prever que el contrario preferirá comerse nuestra reina, y, por lo tanto, no elegiremos ese camino.

Naturalmente, pudiera ocurrir que el movimiento elegido de esta forma sea equivocado, en el sentido de que no conduzca a una jugada ganadora. Al fin y al cabo, sólo estamos comprobando la respuesta del contrario a cada posible movimiento nuestro, es decir, sólo estamos prediciendo dos movimientos en el futuro. Quizá mirando cinco o seis movimientos más adelante nos diéramos cuenta de que no es rentable, pero eso es imposible por la enorme cantidad de estados del tablero que habría que evaluar. Por eso esta estrategia no nos asegura la victoria ni mucho menos.

Con el algoritmo anterior conseguimos mirar dos movimientos por anticipado: el nuestro y la respuesta previsible del contrario. Esto se puede generalizar para examinar la próximas n jugadas:

función buscar_mejor_movimiento(tablero)
inicio
   maximo = -9999
   para cada movimiento posible del jugado actual
   inicio
       copiar tablero en tablero2
       realizar movimiento sobre tablero2
       puntos = min(tablero2)
       si (puntos > maximo)
       inicio
         maximo = puntos
         mejor_movimiento = movimiento
       fin (si)
   fin (para)
fin (función)

Esta función iniciaría el proceso de búsqueda, eligiendo el movimiento que hace máximo el valor devuelto por el jugador contrario, cuyo “razonamiento” trata de simularse en la función min(),así:

función min(tablero)
inicio
  si (se ha alcanzado la profundidad deseada)
    minimo = evaluar_estado(tablero)
  si_no
  inicio
    minimo = 99999
    para cada movimiento posible del jugador contrario sobre el tablero
    inicio
       copiar tablero en tablero 2
       realizar ese movimiento en tablero2
       puntos = max(tablero2)
       si (puntos < minimo)
          minimo = puntos
    fin (para)
  fin (si_no)
  devolver (minimo)
fin (funcion)

A su vez, la función max() simulará la forma de pensar del judador actual, que tratará de hacer máxima la evaluación, de este modo:

función max(tablero)
inicio
  si (se ha alcanzado la profundidad deseada)
     maximo = evaluar_estado(tablero)
  si_no
  inicio
     maximo = -99999
     para cada movimiento posible del jugador contrario sobre el tablero
     inicio
       copiar tablero en tablero 2
       realizar ese movimiento en tablero2
       puntos = min(tablero2)
       si (puntos > maximo)
         maximo = puntos
    fin (para )
  fin (si_no)
  devolver (maximo)
fin (funcion)

Como vemos, el jugado actual (que maneja el ordenador) trata de hacer máxima la ventaja que obtiene en cada jugada, al tiempo que trata de hacer mínima la ventaja del jugador contrario. Por esa razón esta técnica se denomina minimax.

En la siguiente figura lo expresamos gráficamente. Supongamos que el juego se encuentra en el estado representado en la raiz del árbol, y que le toca mover al ordenador (negras). Cada  posible movimiento conduce al tablero a un estado diferente en el siguiente nivel del árbol. A su vez, cada uno de estos tableros tiene una colección de posibles movimientos que generan otros estados del tablero.

minimax(clic para agrandar)

Los valores asignados a los estados más bajos (hojas del árbol) se obtienen aplicando la función de evaluación estática. El resto de valores se obtienen mediante la regla minimax. El jugador negro elegirá el primer movimiento (el de la rama izquierda), porque le asegura un valor mínimo de 5. Es decir, es el máximo de los valores mínimos del siguiente nivel.

Aplicación del minimax al juego

El algoritmo minimax puede implementarse de diversas formas. Aquí propondremos una, pero usted puede desarrollar otra si lo prefiere.

La función de evaluación estática no es necesario modificarla; es más, se usará en repetidas ocasiones.

Necesitaremos definir la profundidad máxima de búsqueda (que, como dijimos, no debe ser mayor de 3, 4 o, como mucho, 5 movimientos). También necesitamos dos funciones nuevas:

  • valorar_posicion_contrario(): dada una posición del tablero (pasada como parámetro) realizará la evaluación estática si ya se ha alcanzado la profundidad máxima de búsqueda (que, por tanto, también se pasará como parámetro) y devolverá esa valoración. Si no se ha alcanzado esa profundidad, se generarán todos los posibles movimientos del jugador contrario y se llamará a la función valorar_posición_propia(), quedándonos con el valor mínimo de todos los que nos devuelva esta función.
  • valorar_posicion_propia(): dada una posición del tablero (pasada como parámetro) realizará la evaluación estática si ya se ha alcanzado la profundidad máxima de búsqueda (que, por tanto, también se pasará como parámetro) y devolverá esa valoración. Si no se ha alcanzado esa profundidad, se generarán todos los posibles movimientos del jugador que tiene el turno y se llamará a la función valorar_posición_contrario(), quedándonos con el valor máximo de todos los que nos devuelva esta función.

En definitiva, la función valorar_posición_contrario() se ejecutará en los niveles del árbol correspondientes al jugador contrario (MIN) y valorar_posición_propia() se ejecutará en los niveles del árbol correspondientes al jugador actual (MAX)

Observa que la primera función llama a la segunda y la segunda a la primera, por lo que se produce una recursión doble. El caso base es aquél en el que se alcanza la profundidad máxima preestablecida: entonces se invoca la función de evaluación estática y la recursión termina.

Debe existir una tercera función llamada buscar_mejor_movimiento() o algo similar que se encargue de iniciar el proceso recursivo. Esta función calculará todos los posibles movimientos del jugador actual y, para cada uno de ellos, llamará a valorar_posicion_contrario(), ya que el siguiente movimiento corresponderá al jugador contrario. De todos los valores que nos devuelve esa función, debemos quedarnos con el mayor, considerando que es el mejor movimiento que podemos hacer, ya que maximiza nuestra ventaja y minimiza la del contrario.

Expresado en pseudocódigo:

función buscar_mejor_movimiento(tablero, estado)
inicio
  maximo = -9999
  para todos los movimientos que el jugador actual pueda hacer en “tablero”
  inicio
     copiar tablero en tablero2
     realizar el movimiento en tablero2
     valoracion = valorar_posicion_contrario(tablero2, 1)
     si (valoracion > maximo)
     inicio
       maximo = valoracion
       mejor_movimiento = movimiento_actual
     fin (si)
  fin (para)
fin (función)

función valorar_posicion_contario(tablero, profundidad)
inicio
  si (profundidad == PROFUNDIDAD_MAXIMA)
  inicio
    valoracion = evaluacion_estatica (tablero)
    devolver (valoracion)
  fin (si)
  minimo = 9999
  para todos los movimientos que pueda hacer el jugador contrario en “tablero”
  inicio
     copiar tablero en tablero2
     realizar el movimiento sobre tablero2
     valoracion = valorar_posicion_propia(tablero2, profundidad + 1)
     si (valoracion < minimo)
        minimo = valoracion
  fin (para)
  devolver (minimo)
fin (función)

función valorar_posicion_propia(tablero, profundidad)
inicio
   si (profundidad == PROFUNDIDAD_MAXIMA)
   inicio
      valoracion = evaluacion_estatica (tablero)
      devolver (valoracion)
   fin (si)
   maximo = -9999
   para todos los movimientos que pueda hacer el jugador actual en “tablero”
   inicio
      copiar tablero en tablero2
      realizar el movimiento sobre tablero2
      valoracion = valorar_posicion_contraria(tablero2, profundidad + 1)
      si (valoracion > maximo)
         maximo = valoracion
   fin (para)
   devolver (maximo)
fin (función)

Una mejora: Minimax con poda alfa-beta

La técnica minimax, como hemos dicho, no tiene por qué proporcionarnos el mejor movimiento posible. De hecho, puede provocar un movimiento manifiestamente malo y ante el que cualquier jugador avanzado de ajedrez sonreiría con suficiencia. Aunque, la mayoría de las veces, minimax proporcionará un movimiento razonablemente bueno.

El movimiento será tanto más bueno cuanto más podamos profundizar en el árbol de movimientos. De hecho, se considera que un buen jugador de ajedrez suele prever, aproximadamente, una media de 8 jugadas. Pero, como hemos visto, este árbol se ramifica demasiado y se hace muy pronto incalculable, incluso para los ordenadores más potentes. A este tipo de problemas se les denomina “no computables”, ya que no pueden ser procesados y resueltos en un tiempo razonable.

Pero hay una forma de lograr profundizar más en el árbol, llegando hasta seis, siete o más jugadas en el futuro: utilizando una variación de la técnica minimax denominada minimax con poda alfa-beta.

El minimax con poda se basa en la misma idea que un jugador humano de ajedrez: no es necesario mirar TODOS los estados porque hay algunos que, ya desde el principio y de forma evidente, son malos para el jugador que mueve. Por lo tanto, esas ramas del árbol no es necesario explorarlas porque, por más que descendamos, todos los estados resultarán muy negativos.

Para aplicar este principio a nuestro algoritmo debemos ejecutar la función de evaluación estática en algún nivel intermedio, antes de llegar a las hojas del árbol. Por ejemplo, si estamos explorando 5 jugadas, podemos aplicar la función de evaluación en el nivel 3. En esa jugada, le toca mover al jugador actual (es un nivel de tipo MAX en el algoritmo minimax). Evaluaremos todos los estados del nivel 3 como si fueran los últimos del árbol, y llamaremos al mejor de ellos ALFA.

A partir de entonces sólo seguiremos explorando las ramas del árbol cuyo valor estático en el nivel 3 sea igual ALFA (o, al menos, muy próximo a ALFA). Esto eliminará la gran mayoría de ramas del árbol, por lo que nos será fácil descender hasta el nivel 7 u 8. Si hacemos una segunda poda, podemos explorar niveles muy profundos en un tiempo razonable.

También podemos elegir hacer la poda en un nivel correspondiente al jugador contrario (por ejemplo, el nivel 4, que es de tipo MIN). En ese caso, aplicaremos la función de evaluación estática a todos los estados y elegiremos la menor de todas ellas, llamándola BETA. Podaremos todas las ramas cuyo valor estático sea mayor que ese valor BETA, profundizando en las demás.

Evidentemente, este método tampoco es infalible pues, aunque permite profundizar mucho más en el árbol, es muy posible que en alguna de las podas desechemos una rama que, aunque en ese momento proporciona una valoración pobre, en el futuro nos hubiera conducido a la victoria. A pesar de este riesgo, inherente a todas las técnicas heurísticas (que, recordemos, no pretenden encontrar soluciones infalibles, ya que se usan en problemas para los que es imposible encontrar dichas soluciones), el minimax con poda alfa-beta suele proporcionar mejores resultados que el minimax simple.

Cómo hacer que la máquina “piense” una jugada

Para que el ordenador pueda jugar al ajedrez razonablemente bien, debe empezar por ser capaz de distinguir las situaciones más beneficiosas (para él) de las que no lo son tanto. Por ejemplo, debe saber que tener un peón en el centro del tablero es mucho mejor que tenerlo en un lateral, o que la reina es mucho más valiosa que un caballo.

Para lograrlo existen varios métodos, pero el más simple es la función de evaluación estática. Se trata de una función matemática que, partiendo de las piezas que hay en el tablero y su ubicación, devuelve un valor numérico que determina la calidad de la situación para un jugador dado.

Supongamos que el ordenador juega con las negras. Si, cuando le toca su turno, la función devuelve, por ejemplo, el número 525, quiere decir que la situación actual del tablero es muy beneficiosa para las negras. Si la función devuelve, en cambio, un número más pequeño (próximo a cero), la situación es más o menos igual de buena para las negras y para las blancas. Si devuelve un número muy negativo (por ejemplo, -650), quiere decir que la situación es muy negativa para las negras, es decir, muy positiva para las blancas.

Obviamente, la función de evaluación debe funcionar exactamente al revés para el jugador blanco.

Gracias a la función de evaluación estática, el ordenador puede saber qué movimiento le resultará más beneficioso y, de esta manera, decidirse por un movimiento concreto de entre los muchos posibles.

Una función de evaluación sencilla

A continuación se muestra una función de evaluación sencilla y fácil de implementar. Con una función así, ningún programa de ajedrez se proclamará campeón del mundo, desde luego, pero sí que conseguiremos resultados bastante aceptables.

  • PEÓN
    • Por cada peón propio, sumar 100 puntos.
    • Si el peón está en el centro del tablero, añadir 12 puntos más
    • Añadir 2 puntos por cada casilla que haya avanzado el peón desde su punto de partida
  • CABALLO
    • Cada caballo propio suma 315 puntos
    • Añadir entre 0 y 15 puntos si está cerca del centro del tablero (más cuanto más cerca del centro)
    • Quitar entre 0 y 15 puntos si está lejos del centro (quitar más cuanto más lejos)
  • ALFIL
    • Cada alfil suma 330 puntos
    • Añadir un punto más por cada casilla a la que pueda moverse libremente (es decir, sin que se lo impida otra pieza que esté en medio)
  • TORRE
    • Cada torre suma 500 puntos
    • Añadir un punto más por cada casilla a la que pueda moverse libremente (es decir, sin que se lo impida otra pieza que esté en medio)
  • DAMA
    • La dama representa 940 puntos
    • Como en el caballo, añadir o quitar puntos (de 0 a 10) dependiendo de lo cerca o lejos que esté del centro del tablero

Todos estos puntos se calculan según las piezas del jugador al que le toca mover. Depués, se calculan del mismo modo para las piezas del jugador contrario, y ambas cantidades se restan. El resultado es lo que debe devolver la función de evaluación.

Un posible algoritmo para implementar esta función sería algo así:

total = 0;
para x desde 1 hasta 8
inicio
  para y desde 1 hasta 8
  inicio
    puntos_pieza = 0;
    según (tablero[x][y].pieza)
    inicio
       caso PEON: puntos_pieza = <<calcular puntos de este peón>>
       caso TORRE: puntos_pieza = <<calcular puntos de esta torre>>
       <<añadir el resto de piezas>>
    fin

    si (tablero[x][y].color_pieza == turno) // La pieza es nuestra
      total = total + puntos_pieza; // Sumamos los puntos al total
    si_no // La pieza NO es nuestra
      total = total - puntos_pieza; // Restamos los puntos al total
    fin (para)
  fin (para)
devolver(total);

La función se puede complicar tanto como queramos. En Internet y en muchos libros especializados sobre ajedrez puedes encontrar otras funciones de evaluación que pueden llegar a ser realmente complicadas. Sin embargo, hay que encontrar un compromiso entre exactitud y complejidad, porque si la función es excesivamente compleja tardará mucho tiempo en calcularse, y, por tanto, el programa tardará demasiado tiempo en “pensar” los movimientos (sobre todo cuando, en la fase 10, añadamos búsqueda recursiva a las rutinas de inteligencia)

Aplicación de la función al programa del ajedrez

Veamos ahora cómo puede el ordenador decidir su próximo movimiento.

Dada una posición cualquiera del tablero, el ordenador puede efectuar muchos movimientos diferentes. Se trata de que busque todos los movimientos que puede hacer, y evalúe, para cada uno de ellos, en qué situación le deja. Para la evaluación usará la función de evaluación estática que acabamos de ver, u otra parecida. Al final, escogerá el movimiento que le conduce a una situación en la que el valor devuelto por la función de evaluación es máximo.

Para buscar todos los movimientos, la máquina debe recorrer todas las casillas del tablero buscando piezas de su color. Para cada pieza que encuentre, volverá a recorrer el tablero, y, para cada casilla, probará a mover la pieza a dicha casilla. Usará las funciones de comprobación de movimientos (las hicimos en la fase 4) para comprobar si el movimiento es posible. Si el movimiento es posible, lo evaluará con la función de evaluación, quedándose con el máximo

Expresado algorítmicamente:

maximo = -infinito;
para ox desde 1 hasta 8
  inicio
  para oy desde 1 hasta 8
  inicio
    si (tablero[ox][oy].color_pieza == turno) // Hemos encontrado una pieza nuestra
    inicio
    para dx desde 1 hasta 8
    inicio
      para dy desde 1 hasta 8
      inicio
        si (es posible el movimiento de (ox,oy) a (dx,dy))
        inicio // Hemos encontrado un destino válido
          tablero2 = copiar_tablero(tablero); // Hacemos el movimiento
          realizar_movimiento (tablero2, ox, oy, dx, dy);
          puntos = evaluar_posicion (tablero2); // Evaluamos el resultado
          si (puntos > maximo)  // Es el mejor encontrado hasta ahora
          inicio
             maximo = puntos; // Guardamos el mejor movimiento
             mejor_ox = ox; mejor_oy = oy;
             mejor_dx = dx; mejor_dy = dy;
          fin
         fin (si)
       fin (para dy)
     fin (para dx)
   fin (si)
 fin (para oy)
fin (para ox)

Observe que el algoritmo realiza los movimientos sobre una copia del tablero (llamada aquí “tablero2″). Esto se hace para no modificar el tablero real, ya que estos movimientos no se están haciendo realmente, sino que sólo se están probando para ver cual es el mejor de todos ellos.

Al final del proceso, en el par (mejor_ox, mejor_oy) tendremos el origen del mejor movimiento posible, y en (mejor_dx, mejor_dy), el destino. El ordenador deberá realizar ese movimiento para llevar al tablero a la situación más conveniente para sus intereses.

Pero recuerde que esto no es suficiente para lograr un programa que juegue razonablemente bien al ajedrez. Procediendo de este modo, el ordenador sólo está mirando un movimiento más allá del estado actual del trablero y se comportará exactamente como lo que es: como un jugador con una lamentable cortedad de miras. En la siguiente (y última) fase de desarrollo daremos el salto definitivo para hacer de nuestro programa un jugador de ajedrez competente usando una técnica clásica de Inteligencia Artificia: Minimax.

Eligiendo una biblioteca gráfica

Para crear un interfaz gráfico para nuestro programa existen muchas posibles bibliotecas a las que podemos recurrir, como Allegro, OpenGLSDLGTK+, etc. Cada una tiene sus ventajas e inconvenientes, así como un nivel de aplicación (por ejemplo, GTK+ está más orientada a la programación de aplicaciones en entornos gráficos en general, no de juegos en particular, mientras que Allegro está orientada precisamente a estos últimos)

Ninguna de estas bibliotecas pertenece al estándar ANSI C, como es lógico, así que en todos los casos debemos instalarlas por separado y a continuación enlazarlas con nuestro proyecto.

Nos hemos decantado por la biblioteca SDL, que es multiplataforma (el programa compilará perfectamente en Windows, Linux o MacOS) y está orientada al manejo de gráficos 2D a bajo nivel, precisamente lo que necesitamos en este proyecto.

Sobre la biblioteca SDL ya publicamos un extenso artículo explicando los fundamentos de su utilización (incluyendo su instalación). Es recomendable revisarlo antes de enfrentarse a esta fase del desarrollo.

Añadiendo gráficos al juego

El proceso de adaptación del juego al formato gráfico con SDL va a ser largo, como se puede imaginar (por eso le asignamos a esta tarea dos fases de duración). Se acabaron los printw(), move(), INIT_PAIR() y demás recursos de ncurses.

Si ha tenido la precaución de agrupar todas las entradas y salidas en un único archivo lo tendrá un poco más fácil.

Aquí se propone una guía de actuaciones para afrontar el reto de la conversión de la aplicación a un programa gráfico:

  1. Haga una copia de todos los archivos del programa y guárdela a buen recaudo, por si las moscas. Prepárese para pasar bastante tiempo sin disponer de una versión que funcione.
  2. Prepare todas las imágenes que va a necesitar: como mínimo, una imagen del tablero y, además, una imagen para cada tipo y color de pieza (6 imágenes de las piezas blancas – peón, torre, alfil, caballo, dama y rey – y otras 6 de las negras). Compruebe que los tamaños de todas ellas concuerdan y que el tipo de los archivos es BMP sin compresión. Ponga todos los nombres de los archivos en minúsculas para evitar problemas, y cópielos a su directorio de trabajo. Búsquese también uno o dos archivos de fuentes true type que le gusten y cópielos junto con las imágenes.
  3. Defina una variable GLOBAL de tipo SDL_Surface* para cada sprite que vaya a utilizar en su programa (el tablero, el peón blanco, el peón negro, la torre blanca, la torre negra, etc.). Defina otra variable global del tipo TTF_Font* para cada una de las fuentes de caracteres que vaya a utilizar.
  4. Escriba una función llamada inicializar_SDL() que inicie el sistema gráfico. Invóquela al comienzo de su función main(), antes que cualquier otra cosa. En esta función, y justo después de iniciar la pantalla gráfica, también debe cargar todas las imágenes asignándolas a sus respectivas variables globales de tipo SDL_Surface* que ya debe tener definidas. No se olvide de iniciar también el subsistema SDL_ttf (si toda esta jerga le suena como si le hablase un marciano, es que no ha revisado artículo sobre SDL que hemos mencionado al principio)
  5. Escriba una función finalizar_SDL() que finalice el sistema gráfico. Llámela al final de la función main(), justo antes de terminar. Esta función hará (en estricto orden) las llamadas a TTF_CloseFont() para liberar las fuentes, TTF_Quit(), SDL_FreeSurface() para cada imagen cargada y, por último, SDL_Quit().
  6. Programe una función escribir() que sirva para mostrar un texto en una posición concreta de la pantalla. Esta función tendrá cuatro parámetros: el texto que se desea escribir, la posición en la que se va a escribir (fila y columna) y el color del texto. Compruebe que funciona y proceda entonces a sustituir todos los attron(), move() y printw() de su programa por llamadas a la función escribir(), excepto las llamadas a printw() que servían para dibujar el tablero o las piezas.
  7. Programe una función leer() similar a la anterior, que sirva para leer un texto por teclado y, al mismo tiempo, ir mostrando el eco en la pantalla. Sustituya todas las llamadas a getstr() por llamadas a leer()
  8. Modifique la función de dibujar_tablero() por otra que muestre la imagen BMP del tablero y luego, recorriendo la estructura de datos del tablero, dibuje en las posiciones correctas los sprites de las piezas.
  9. Modifique la función en la que el usuario selecciona la casilla de origen y la de destino de un movimiento, adaptándola a las funciones de lectura del teclado de SDL e inventándose algún modo de marcar las casillas (por ejemplo, puede añadir un nuevo sprite llamado “recuadro”, que sea simplemente un rectángulo que se puede mover sobre el tablero para señalar una casilla)
  10. Revise todos los puntos “vulnerables” de su programa (por ejemplo, el menú), para ver si necesitan algún cambio adicional a los que ya ha realizado.

¿Conservar la versión de texto?

Cuando la versión gráfica del programa esté marchando no querrá ni ver la primitiva versión “ncursera”. Pero es una lástima perder ese trabajo que ya estaba hecho y, oiga, quizás alguien prefiera jugar en modo texto. Hay gente para todo.

No tiene por qué borrar el código de entrada/salida en modo texto, sino que ambas versiones (la de texto y la gráfica) pueden coexistir pacíficamente, y usted puede elegir si prefiere compilar el programa para que funcione en modo texto o en modo gráfico.

Para ello, puede optar por la siguiente solución:

1. Defina una constante llamada, por ejemplo, INTERFAZ_TEXTO para compilar en modo texto, y otra llamada INTEFAZ_GRAFICO para compilar en modo gráfico. Por ejemplo:

#define INTERFAZ_TEXTO

2. Luego, en cada llamada a una función de entrada/salida, compruebe qué constante hay definida antes de invocar la función de texto o la gráfica. Por ejemplo:

#ifdef INTERFAZ_TEXTO
   getstr(txt);
#ifdef INTERFAZ_GRAFICO
   leer(txt, 1, 1);

De este modo, se compilará una u otra instrucción dependiendo de qué constante esté definida, y conseguirá hacer funcionar ambas versiones del programa. Eso sí, su código se complicará un poco más todavía.

En esta fase vamos a llevar a cabo varias tareas pequeñas pero importantes:

  • Reproducir partidas
  • Mostrar una lista con los últimos movimientos
  • Ayuda en línea
  • Detectar los jaques
  • Detectar el final de la partida

Reproducción de partidas

El objetivo de esta funcionalidad es ver en la pantalla el desarrollo de las partidas que tuviéramos guardadas en archivos de disco, o bien reproducir desde el principio la partida que estamos jugando.

Poner en marcha esta opción va a resultar muy sencillo, como veremos.

Se supone que, después de la fase anterior, debemos tener construida una estructura de datos dinámica en la que tendremos guardada la lista de movimientos de la partida actual, tanto si ha sido cargada desde un archivo de disco como si ha sido jugada “en directo” desde su inicio. Esta estructura será una lista enlazada simple o alguna variedad similar.

Para reproducir la partida actual desde el inicio, debemos añadir una opción al menú de opciones general. La selección de esta opción provocará una llamada a una función nueva que podemos llamar reproducir_partida() o algo parecido. Esa función tiene que realizar lo siguiente:

  • Colocar el tablero en su posición inicial (llamando a la función de inicializar tablero que programamos en la fase 2)
  • Leer el primer movimiento de la lista (que corresponderá al jugador blanco). Reflejar ese movimiento en las estructuras de datos del juego (tablero, estado, etc) y redibujar el tablero en la pantalla. Esperar un instante antes de continuar.
  • Leer el segundo movimiento de la lista (que corresponderá al jugador negro) y hacer lo mismo.
  • Repetir los dos pasos anteriores con los movimientos tercero, cuarto, quinto, etc, hasta que se haya recorrido toda la lista de movimientos.

Lista de movimientos

Al jugador de ajedrez suele serle útil repasar los últimos movimientos realizados en la partida. Para eso puede optar por reproducir la partida desde el inicio, como acabamos de ver, pero otra ayuda adicional puede ser mostrar en todo momento los últimos movimientos realizados.

Para ello, dedicaremos una sección del panel lateral de la pantalla, que hasta ahora hemos usado para mostrar los mensajes de usuario.

En la lista de movimientos aparecerán los 7 u 8 últimos movimientos (el número lo decide usted, según el diseño de su pantalla y el espacio de que disponga). Los movimientos aparecen en notación algebraica, es decir, solo hay que extraerlos de la lista de movimientos que has debido construir en la fase 5 para guardar y recuperar partidas de disco.

Lo más conveniente es que escriba una función llamada mostrar_lista_movimientos() o algo similar. Esta función debe ser llamada desde dibujar_pantalla() o sus proximidades (lo importante es que sea llamada antes de que cada jugador vaya a hacer un nuevo movimiento; así la lista siempre estará actualizada). La función borrará la lista anterior y escribirá encima la actual.

Ayuda en línea

En esta fase también debe implementar una sencilla función de ayuda que sea accesible desde cualquier momento del juego pulsando alguna tecla especial (lo más conveniente es F1). Por lo tanto, tendrá que añadir el control de la tecla F1 en las rutinas de lectura de teclado que haya escrito hasta ahora. Como mínimo, debe tener una rutina de este tipo: el lugar donde el usuario seleccione las casillas de origen y de destino del movimiento.

La ayuda puede aparecer a toda pantalla o sólo en el panel derecho, como prefiera. Será un texto en el que se explique brevemente cómo se usa el programa (teclas, opciones del menú y poco más). Lo mejor es que lo codifique todo en una función llamada ayuda() que sea invocada al pulsar la tecla F1.

Al salir de la ayuda (lo que ocurrirá pulsando ESC o alguna otra tecla) regresaremos al mismo punto donde nos habíamos quedado, pero el panel derecho de la pantalla habrá desaparecido. Por lo tanto, debe tener la precaución de volver a dibujar todo lo que hubiera en él (por ejemplo, la lista de movimientos llamado a mostrar_lista_movimientos()) antes de salir de la función ayuda(). Si ha preferido hacer la ayuda a pantalla completa, tendrá que volver a dibujar también el tablero llamando a la función correspondiente.

Como ve, tener el código distribuido en funciones independientes simplifica mucho las cosas. Si no, ¿cómo podría volver a dibujarlo todo antes de salir de la ayuda?

Detección del jaque

Cuando un rey está amenazado por una pieza enemiga se dice que está en jaque. El próximo movimiento de ese jugador tiene que estar orientado obligatoriamente a deshacer esa situación, algo que se puede conseguir de tres modos:

  • moviendo el rey para alejarlo del peligro
  • colocando alguna pieza entre en el rey y el enemigo que lo amenaza
  • tomando la pieza que realiza la amenaza

Sea cual sea la opción escogida por el jugador, el juego debe obligar al jugador a deshacer la situación de jaque. Además, un jugador no puede poner a su rey en jaque voluntariamente, y, por lo tanto, el programa debe ser capaz de detectar esa situación para impedir que ocurra.

Cómo detectar si un rey está en situación de jaque

Supongamos que queremos comprobar si el rey blanco está en jaque (para el negro se haría igual, pero al revés). Supongamos también que dicho rey está en la casilla (rx, ry) del tablero. Habrá que recorrer todo el tablero y, para cada pieza negra encontrada, comprobar si puede hacer un movimiento a (rx, ry)

Esto es muy fácil de comprobar, ya que tenemos las funciones de comprobación del movimiento programadas desde la fase 4. Para comprobar, por ejemplo, si la torre negra situada en (tx, ty) puede moverse a (rx, ry), basta con que llamemos a la función comprobar_movimiento_torre() (o como tú la hayas llamado en tu programa), pasándole como origen del movimiento (tx, ty) y como destino (rx, ry). Si la función determina que ese movimiento es posible, entonces el rey está en situación de jaque.

Debemos programar una función llamada detectar_jaque() o algo similar que realice la detección descrita. Recibirá como parámetros el tablero, el estado y el turno actual (es decir, a qué jugador le toca mover). Devolverá BLANCO si se ha puesto en jaque al rey blanco, NEGRO si se ha puesto en jaque al rey negro o NINGUNO en otro caso.

Cuándo debemos realizar la detección

El momento para invocar la función detectar_jaque() y hacer la detección es siempre después de comprobar el movimiento de un jugador. Supongamos que el movimiento actual pertenece al jugador blanco y que, habiéndolo comprobado con las funciones correspondientes (que programamos en la fase 4) ha resultado ser un movimiento correcto. Antes de darlo definitivamente por bueno debemos llamar a detectar_jaque(), y, dependiendo de lo que nos devuelva, hacer lo siguiente:

  1. Si nos devuelve “BLANCO”, al estar en el turno de las blancas el movimiento debe ser considerado incorrecto, ya que pone en jaque voluntariamente a su propio rey.
  2. Si nos devuelve “NEGRO” quiere decir que nuestro movimiento ha puesto en jaque al rey enemigo, lo cuál debe ser tenido en cuenta a la hora de elaborar la lista de movimientos, ya que el jaque tiene un símbolo especial (ver fase 5). Además, sería conveniente mostrar en la pantalla algún mensaje llamativo que diga algo así como “JAQUE AL REY NEGRO”

Si el turno fuera de las negras, el proceso de comprobación sería igual pero al revés.

Detección del fin de la partida

Una partida puede terminar por tres causas:

  • Si el rey blanco está en situación de jaque mate, es decir, está en jaque y ningún movimiento del jugador blanco puede sacar al rey de esa situación. Entonces, la partida termina y ganan las negras.
  • Si el rey negro está en situación de jaque mate, es decir, está en jaque y ningún movimiento del jugador negro puede sacar al rey de esa situación. Entonces, la partida termina y ganan las blancas.
  • Si un jugador cualquiera, en su turno, no puede mover ninguna pieza y su rey no está en jaque. Entonces, el juego termina en situación de tablas.

Debemos, por lo tanto, programar dos funciones: detectar_jaque_mate() y detectar_tablas(). Estas funciones deben ser invocadas después de cada movimiento de cada jugador, para comprobar si el último movimiento ha producido alguna de estas situaciones y, en tal caso, terminar el juego inmediatamente, mostrando al usuario los mensajes que consideres convenientes.

Cómo detectar el jaque mate

Programaremos una nueva función llamada detectar_jaque_mate(), que usará la función detectar_jaque() que hemos programado antes.

Todo lo que sigue es aplicable al turno del jugador blanco. Para el negro se haría igual, pero dándole la vuelta a los colores (el algoritmo en pseudocódigo de más abajo debería funcionar para ambos jugadores, ya que la variable “turno” puede valer tanto BLANCO como NEGRO)

Para que se produzca jaque mate primero debe haberse producido un jaque. Es decir, la función detectar_jaque_mate() sólo debe ser llamada si antes la función detectar_jaque() ha dado como resultado “BLANCO”.

Dentro de detectar_jaque_mate() haremos una copia del tablero, es decir, copiaremos toda la matriz en otra matriz local. Lo mejor es que escribas una función copiar_tablero(), porque luego la necesitarás en la fase 10.

Usando la copia del tablero, recorreremos todas las casillas buscando las piezas blancas. Para cada pieza blanca que encontremos, volveremos a recorrer todo el tablero buscando posibles casillas de destino, invocando a las funciones de comprobación del movimiento para ver si ese movimiento es posible.

Cuando encontremos un movimiento posible, lo realizaremos sobre la copia del tablero (no sobre el tablero real) y llamaremos a detectar_jaque() con este nuevo tablero. Si detectar_jaque() nos da como resultado algo distinto a “BLANCO”, quiere decir que existe al menos un movimiento que deshace la situación de jaque y, por lo tanto, no es un jaque mate.

En cambio, si probamos todos los movimientos posibles del jugador blanco y todos siguen produciendo una situación de jaque, debemos concluir que el rey blanco está en jaque mate.

Una versión simplificada de este algoritmo en pseudocódigo podría ser la siguiente:

entero función detectar_jaque_mate(t_casilla tablero[9][9], char turno)
{
   t_casilla tablero2[9][9];
   int jaque_mate = 1; // Supondremos que hay jaque mate
   int ox, oy, dx, dy;

   copiar_tablero(tablero, tablero2);

   para ox desde 1 hasta 8 // Recorrer todos los posibles orígenes
   inicio
      para oy desde 1 hasta 8
      inicio
         si (tablero2[ox][oy].color_pieza == turno)
         inicio
            // Recorrer posibles destinos
            para dx desde 1 hasta 8
            inicio
               para dy desde 1 hasta 8
               inicio
                  si (es posible el movimiento desde (ox,oy) hasta (dx, dy) en tablero2)
                  inicio
                     // Realizar el movimiento en tablero2
                     Realizar el movimiento desde (ox,oy) hasta (dx, dy) en tablero2
                     si (detectar_jaque(tablero2, turno) != turno)
                        jaque_mate = 0;   // Este movimiento deshace el jaque
                     copiar_tablero(tablero, tablero2);      // Restaurar tablero2
                  fin (si)
               fin (para)
            fin (para)
         fin (si)
      fin (para)
   fin (para)
   devolver (jaque_mate);
}

Cómo detectar las tablas

La detección de las tablas es más fácil que la del jaque mate. Programaremos una función detectar_tablas() que puede ser invocada después de realizar un movimiento en el tablero, o bien justo antes de realizar un nuevo movimiento. En el primer caso hay que tener la precaución de pasarle a detectar_tablas() el color del jugador al que le va a tocar mover a continuación, y no el del jugador que acaba de mover.

La función detectar_tablas() recorrerá el tablero buscando las piezas del color del jugador al que le toca mover. Para cada pieza encontrada, intentará moverla a todas las demás casillas del tablero, usando para ello las funciones de control de movimiento de la fase 4. Si todos los intentos fracasan, quiere decir que el jugador no puede mover ninguna pieza y, por lo tanto, estamos en situación de tablas. Si al menos un intento tiene éxito, no hay situación de tablas.

Esbozada en forma de pseudocódigo, la función sería algo así:

entero función detectar_tablas(t_casilla tablero[9][9], char turno)
{
   int tablas = 1; // Supondremos que hay tablas
   int ox, oy, dx, dy;
   para ox desde 1 hasta 8 // Recorrer todos los posibles orígenes
   inicio
      para oy desde 1 hasta 8
      inicio
         si (tablero2[ox][oy].color_pieza == turno)
         inicio
            // Recorrer posibles destinos
            para dx desde 1 hasta 8
            inicio
               para dy desde 1 hasta 8
               inicio
                  si (es posible el movimiento desde (ox,oy) hasta (dx, dy))
                     // No hay tablas (se ha encontrado al menos un movimiento correcto)
                     tablas = 0;
               fin (para)
            fin (para)
         fin (si)
      fin (para)
   fin (para)
   devolver (tablas);
}

Por último, recordar que las tablas, el jaque y el jaque mate tienen un símbolo específico en la notación algebraica (ver fase 5), que debe ser añadido a la lista de movimientos si se produce la situación.

En esta fase desarrollaremos el menú de opciones del juego y la posibilidad de guardar las partidas en disco para continuar jugándolas más tarde.

Menú de opciones

El juego tiene dos menús de opciones:

  • El que aparece al inicio del juego, para seleccionar el tipo y el color de los jugadores (este ya lo programamos en la fase 2)
  • El que puede invocarse desde el tablero de juego, pulsando en cualquier momento alguna tecla especial (como ESC, F2, etc)

El que vamos a programar en esta fase es el segundo, que es más complejo.

Composición del menú

El menú debe aparecer al pulsar alguna tecla especial (ESC, F2, “m”, o la tecla que decida) durante el juego. Por lo tanto, hay que añadir el control de esa tecla en los procedimientos de selección de casilla, que es donde se lee el teclado.

El menú puede aparecer a toda pantalla (borrando momentáneamente el tablero) o bien en el lateral (en el espacio reservado a los mensajes del usuario)

Las opciones del menú deben ser las siguientes (el texto, el aspecto y el orden, que cada cual lo elija a su gusto):

  • Salir del programa. El programa terminará inmediatamente.
  • Empezar una partida nueva. Volveremos al principio del juego: elección de jugadores, dibujo del tablero con las piezas en su disposición inicial, etc.
  • Continuar la partida. Volveremos al tablero y continuaremos el juego tal y como lo habíamos dejado.
  • Guardar la partida. El programa nos preguntará un nombre de archivo. A ese nombre le añadiremos la extensión “.PGN” y lo guardaremos en un archivo de disco, con el formato que en el siguiente epígrafe se detalla. Después volveremos a este mismo menú de opciones, para que el usuario decida qué quiere hacer a continuación (salir, continuar, etc)
  • Cargar una partida guardada. El programa nos preguntará un nombre de archivo. Luego buscará un archivo con ese nombre y, si lo encuentra, cargará la partida almacenada en él. Después volverá a este mismo menú de opciones para que el usuario decida qué quiere hacer a continuación.

Selección de opciones

Para seleccionar una opción se puede optar por varios caminos:

  1. Lo más fácil es mostrar un número delante de cada opción y luego pedir al usuario que teclee el número de la opción que quiere seleccionar (ver figuras).
  2. Una versión más elaborada consiste en escribir una marca delante de la primera opción. El usuario podrá mover esa marca de una opción a otra con las teclas del cursor (flecha arriba y flecha abajo), seleccionando una opción al pulsar Enter o Espacio.
  3. Aún quedaría mejor si la opción seleccionada apareciese escrita en vídeo inverso.

Puede empezar programando la versión 1 del menú y, más adelante, cuando todo lo demás funcione, mejorar su aspecto.

Primera versión del menú (la opción se selecciona tecleando el número):

MIAJEDREZ 1.0

MENÚ DE OPCIONES
(1) Continuar partida
(2) Empezar otra partida
(3) Guardar partida
(4) Cargar partida
(5) Salir del programa

Introduzca opción (1-5): _

Versión mejorada del menú (la opción se selecciona moviendo una marca al lado de las opciones, o poniéndolas en video inverso, hasta que se pulse Intro):

   MIAJEDREZ 1.0

   MENÚ DE OPCIONES

>> Continuar partida
   Empezar otra partida
   Guardar partida
   Cargar partida
   Salir del programa

Guardar y cargar partidas

La otra función que vamos a añadir en esta fase es la posibilidad de guardar y cargar partidas en archivos de disco.

Para guardar una partida en un archivo podemos hacer dos cosas: una, guardar el estado actual del tablero; dos, guardar todos los movimientos que se hayan producido en la partida desde el comienzo.

  • Con el primer método, la recuperación de la partida es muy fácil: basta con recuperar el estado del tablero.
  • Con el segundo, tendremos que reproducir todos los movimientos efectuados desde el principio y reflejarlos en el tablero.

Aunque el primer método resulta, sin duda, más sencillo, nosotros vamos a optar por el segundo. La razón estriba en que en la fase 6 vamos a añadir una función (la reproducción de partidas guardadas) que necesitará conocer todos los movimientos de la partida.

Tenemos que decidir cómo vamos a guardar los movimientos para poder recuperarlos después y reproducirlos sin posibilidad de duda. En los siguientes apartados describiremos una forma de hacerlo.

Notación algebraica

Para guardar una partida, por lo tanto, necesitamos haber guardado en alguna estructura de datos todos los movimientos realizados desde el principio. Es el momento de elegir una estructura de datos adecuada para ello y añadirla al diseño de las estructuras que hicimos en la fase 1.

La estructura elegida debería ser dinámica porque, en principio, no sabemos cuantos movimientos va a tener la partida; aunque, para simplificar, también se puede utilizar una estructura estática, siempre que tenga espacio suficiente para almacenar un número elevado de movimientos.

Los movimientos, en ajedrez, suelen representarse con la llamada notación algebraica. Esta notación es muy simple y es conveniente que la use para almacenar en su estructura de datos los movimientos de la partida.

La notación algebraica consiste en lo siguiente:

  • Cada pieza se identifica con una letra: R = rey, D = dama, T = torre, A = alfil, C = caballo, P = peón.
  • Cada casilla se identifica con su letra (en minúscula) y su número. Por ejemplo: f3, d5, h1, etc.
  • Cada movimiento se identifica con la letra de la pieza que se mueve seguida de la casilla de origen y la casilla de destino, separadas por un guión (-). Por ejemplo: Af1-c4 quiere decir que el alfil se ha movido de la casilla f1 a la c4.
  • Cuando la pieza que se mueve es un peón, no se suele poner la P, sobreentendiéndose que, en ausencia de letra, la pieza movida es un peón. Por ejemplo: d2-d3 significa que se mueve el peón de d2 a d3.
  • Cuando una pieza toma a otra (se la “come”), el guión se sustituye por una “x”. Por ejemplo: Cf6xd5 quiere decir que el caballo que había en f6 se mueve a d5 y se come la pieza que allí hubiera.
  • El enroque se representa con O-O (enroque corto) o con O-O-O (enroque largo)
  • Cuando hay jaque se añade un “+” al movimiento. Por ejemplo: Cf6-d5+
  • Cada jugada se antepone del número de la misma. En cada jugada aparecerán dos movimientos: primero el del jugador blanco y luego el del negro. Estos son, por ejemplo, los 6 primeros turnos de una partida:

1. e2-e4    e7-e5
2. Cg1-f3    Cb8-c6
3. Af1-c4    Cg8-f6
4. Cf3-g5    d7-d5
5. e4xd5    Cf6xd5
6. Cg5xf7    …

La notación algebraica reducida es una variación de la notación convencional, consistente en omitir la casilla de origen (excepto cuando el peón come a otra pieza). Por ejemplo, si un movimiento se nota como Cf3, quiere decir que el caballo se ha movido a f3. Pero, ¿qué caballo? Lo normal es que sólo haya un caballo que se pueda mover a f3, pero a veces pueden producirse ambigüedades, que ya veremos como se resuelven. La partida anterior, en esta notación algebraica reducida, se representaría así:

1. e4    e5
2. Cf3    Cc6
3. Ac4    Cf6
4. Cg5    d5
5. e4xd5    Cxd5
6. Cxf7    …

De cualquiera de los modos se puede representar, con pocos símbolos, la partida completa. Elija una de las dos notaciones para almacenar en tu estructura de datos la partida conforme se vaya disputando, aunque es más recomendable la notación algebraica convencional, que carece de ambigüedades.

El formato PGN

PGN (Portable Game Notation) es el nombre de un formato de archivo para guardar partidas de ajedrez muy extendido entre la comunidad informático-ajedrecista. Muchos programas de ajedrez pueden leer y grabar partidas en este formato. En Internet puede encontrar muchos sitios donde descargarte partidas (famosas, históricas o simplemente curiosas) grabadas en archivos PGN.

Los archivos PGN son de texto ASCII, es decir, que pueden abrirse y leerse perfectamente con cualquier editor de texto. Utilizan notación algebraica reducida, así que, con un poco de práctica, pueden interpretarse a mano, es decir, sin necesidad de ordenador.

Debido a lo extendido que está, vamos a intentar que nuestro programa guarde las partidas en formato PGN. Si lo hace bien, no sólo podrá compartir las partidas guardadas por su programa con otros jugadores, sino que podrá descargarse partidas de Internet y cargarlas (y reproducirlas) en su programa.

Descripción del formato PGN

Los archivos PGN ofrecen muchas posibilidades. Aquí sólo nos referiremos a las imprescindibles para guardar y recuperar partidas. Puede encontrar descripciones completas del formato en Internet.
Aquí tiene un ejemplo de archivo PGN. Después comentaremos qué significa cada línea.

[Event "Badalona Open"]
[Site "?"]
[Date "1991.??.??"]
[Round "?"]
[White "J.Vila"]
[Black "Richard Guerrero"]
[Result "0-1"]
1. d4 d5 2. c4 e5 3. dxe5 d4 4. Nf3 Nc6 5. g3 Bg4
6. Nbd2 Bb4 7. Bg2 Qd7 8. a3 Bxd2+ 9. Qxd2 O-O-O
10. b3 f6 11. exf6 Nxf6 12. h3 Bf5 13. g4 Bg6
14. Nh4 Ne4 15. Bxe4 Bxe4 16. f3 Rhf8 17. fxe4 Qe7
18. Ng2 Qxe4 19. Rg1 Ne5 20. Nh4 Rf3
21. Kd1 Rf2 22. Qe1 d3 23. e3 d2
24. Qxf2 dxc1=Q+ 25. Kxc1 Nd3+ 0-1

Los archivos PGN tienen dos secciones: la cabecera, donde aparece información general (nombre del torneo, fecha, nombre de los jugadores, etc) y el cuerpo, donde se almacenan los movimientos de la partida usando notación algebraica reducida.

Cabecera del archivo PGN

En la cabecera encontramos varios campos, cada uno en una línea distinta y encerrados entre dos corchetes, “[" y "]“. Los campos más habituales son:

  • Event: nombre del torneo o evento donde se produjo la partida
  • Site: lugar de la partida. Observa que se puede escribir una “?” si no se conoce algún dato
  • Date: fecha de la partida, en formato AAAA:MM:DD
  • Round: ronda (si se trata de un torneo)
  • White: nombre del jugador blanco
  • Black: nombre del jugador negro
  • Result: resultado de la partida. Puede ser “1-0″ (ganaron las blancas), “0-1″ (ganaron las negras), “1/2-1/2″ (tablas) o ” * ” (partida sin terminar)

Es posible que en algunos archivos PGN aparezcan otros campos en la cabecera. Si es así, nuestro programa puede, simplemente, ignorarlos.

Movimientos

Si observa el ejemplo de notación PGN que hemos escrito más arriba, lo primero que llama la atención es que los nombres de las piezas son diferentes. La razón es que se usan los nombres en inglés, y no en castellano, para identificar las piezas. Así pues, la letra que corresponde a cada pieza es:

  • K = king (rey)
  • Q = queen (reina o dama)
  • R = rook (torre)
  • B = bishop (alfil)
  • N = knight (caballo)
  • P = pawn (peón)

La P, como en español, no se usa. Observe que los movimientos se representan con notación algebraica reducida, en donde no aparece la casilla de origen, salvo cuando el peón se come a otra pieza (en este caso, puede aparecer la casilla de origen o sólo su columna).

Si sigue observando, verá que los movimientos se escriben con su número seguido de un punto, y, a continuación, separados por espacios, cada uno de los movimientos de ese turno, primero el del jugador blanco y luego el del negro. Después hay otro espacio y, tras el, el siguiente movimiento.

El jaque mate, que no aparece en este ejemplo, se representa con el símbolo # en lugar de + (éste se reserva para el jaque normal). Después del último movimiento, si la partida está acabada, aparece el resultado (0-1 en el ejemplo)

Ambigüedades

La notación algebraica reducida, al contrario que la algebraica normal, puede interpretarse, en algunas ocasiones, de varias maneras. La razón es que, al no indicarse la casilla de origen del movimiento, puede ocurrir que la casilla de destino pueda ser ocupada por varias piezas. En caso de ambigüedad, ésta se resuelve utilizando estas tres reglas:

  1. Si la pieza que debe moverse puede distinguirse por la letra de la columna que ocupa, se inserta esa letra en el movimiento, justo después de la letra que identifica la pieza. Por ejemplo: Nbd2 significa que el caballo que se mueve a d2 es el que estaba en la columna b.
  2. Si lo anterior falla, se inserta el número de la fila en vez de la letra de la columna.
  3. Si ambas cosas siguen provocando ambigüedad, se añadirán las dos cosas, o sea, la letra de la columna y el número de la fila de origen, como en la notación algebraica convencional, sólo que después de la letra de la pieza, no antes.

Por ejemplo, imagine que los caballos blancos están ocupando las casillas c3 y g1, y que al jugador blanco, que le toca mover, decide mover un caballo a la casilla e2. Si el movimiento se especifica sólo con “Ne2″, es imposible saber cuál de los dos caballos se ha movido. En cambio, usando el primer criterio para eliminar ambigüedades, se usará la notación “Nce2″ o “Nge2″ para indicar si el caballo que se mueve es el que estaba en la columna c o el de la g.

Otros símbolos

En los archivos PGN pueden aparecer otros símbolos, como interrogaciones, admiraciones, etc. Nosotros no los vamos a usar y, por lo tanto, no los generaremos desde nuestro programa. Al leer archivos PGN bajados de internet, ignoraremos cualquier símbolo que no sea los que hasta aquí hemos expuesto.

Guardar y cargar partidas usando el formato PGN

Una vez conocido y comprendido el formato PGN, lo que hay que hacer para guardar partidas está muy claro:

  1. Almacenar en alguna estructura de datos, y en notación algebraica, todos los movimientos de la partida conforme se vayan produciendo
  2. Desde la opción “guardar partida” del menú de opciones, invocar a una función que pregunte un nombre de archivo y, a continuación, escriba un archivo con formato PGN a partir de los movimientos almacenados en memoria. Este archivo tiene que tener su cabecera y su cuerpo, tal y como hemos descrito, para cumplir con el estándar PGN.

Para cargar las partidas guardadas, el procedimiento será al contrario:

  1. Preguntar un nombre de archivo y comprobar si existe.
  2. Leer los datos del archivo PGN e ir interpretando los movimientos, modificando las estructuras de datos como si los movimientos se estuvieran realizando en realidad.

Evidentemente, lo más difícil de hacer es controlar las posibles ambigüedades. Podemos establecer un plan de acción para dividir este problema en subproblemas:

  • Primero, modificar el programa para que vaya almacenando en memoria los movimientos de la partida.
  • Segundo, escribir el módulo para guardar partidas sin preocuparnos de las ambigüedades.
  • Tercero, escribir el módulo que carga partidas, sin preocuparnos de las ambigüedades. Este paso lo podemos dividir en dos: la lectura del archivo propiamente dicha y la realización de los movimientos que están guardados en el archivo.
  • Cuarto, pensar un modo de controlar las ambigüedades tanto al escribir como al leer.

El siguiente paso lógico tras la fase 3 es controlar todos los movimientos de piezas para asegurarnos que cumplen con las reglas del ajedrez. Además, añadiremos el control del tiempo. Esto empieza a ser serio porque, una vez terminada esta fase, tendremos una primera versión completamente operativa del juego, en la que será posible que dos jugadores humanos jueguen al ajedrez.

Para lograrlo, dividiremos esta fase en 4 pasos:

  1. Controlar la casilla de origen
  2. Controlar la casilla de destino
  3. Controlar si se toma una pieza enemiga
  4. Controlar el tiempo

1º) Controlar la casilla de origen

Este control es muy fácil de realizar. Consiste, simplemente, en comprobar que, en la casilla de origen, existe una pieza propia, es decir, del color del jugador a quien le corresponde mover.

Si el jugador selecciona una casilla en la que hay una pieza enemiga, o bien no hay ninguna pieza, se mostrará un mensaje de error y se obligará al jugador a elegir otra casilla de origen.

2º) Controlar la casilla de destino

Este control es más complicado: consiste en verificar que la pieza que se ha elegido como origen puede efectivamente moverse a la casilla seleccionada como destino, sin infringir ninguna regla del juego. Como cada pieza tiene sus propios movimientos, el algoritmo de control dependerá de la pieza que se intenta mover. Dicho de otra forma: tendrá que escribir una función de control diferente para cada tipo de pieza.

Llevar a cabo este control va a ser de las cosas más difíciles del programa, así que debe pensarlo con mucha calma y, otra vez, dividir la tarea en subtareas:

  1. Primero pensaremos en los movimientos normales de cada tipo de pieza. Más abajo encontrará esbozado un algoritmo para controlar los movimientos de la torre.
  2. Después pensaremos en cómo controlar los movimientos especiales de aquéllas piezas que los tienen (el enroque, la promoción del peón, etc)
  3. Por último, pensaremos en cómo controlar las situaciones de jaque y de tablas, ya que en estos casos los movimientos correctos quedan muy reducidos.

Además, recuerde que las piezas no pueden pasar, al moverse, por encima de otras, excepto el caballo.

Como el asunto es complicado, es mejor que lo dividamos en los tres subproblemas antes mencionados. En primer lugar, por tanto, nos ocuparemos de los movimientos habituales de cada pieza y luego añadiremos los controles necesarios para los movimientos y situaciones excepcionales. Cada uno de los movimientos habituales de cada pieza lo trataremos como un problema individual. Como ve, se trata de usar otra vez la táctica de “divide y vencerás”

A modo de ejemplo, pensemos en cómo podríamos controlar los movimientos habituales de la torre.

Supongamos que el jugador ya ha elegido la casilla de origen del movimiento, que ésta ha sido comprobada y que en su interior hay una torre de su propiedad. Llamaremos a esta casilla (ox, oy), siendo ox la columna de origen y oy la fila de origen.

Supongamos también que el jugador ha elegido la casilla de destino, que identificaremos con las coordenadas (dx, dy). Nuestro objetivo ahora es comprobar si esta casilla de destino es o no correcta. He aquí un posible algoritmo para hacerlo.

1) La primera comprobación consistirá en ver si (ox, oy) y (dx, dy) son dos casillas diferentes:

si (ox == dx) y (oy == dy) entonces MOVIMIENTO INCORRECTO

2) Después habrá que comprobar que la torre se está moviendo de acuerdo a sus posibilidades, es decir, a lo largo de su fila o a lo largo de su columna. Es decir, ox debe ser igual a dx o, si no, oy debe ser igual a dy. Si no se cumple ninguna de las igualdades, el movimiento es incorrecto:

si (ox != dx) y (oy != dy) entonces MOVIMIENTO INCORRECTO

3) Ya sabemos que la casilla de destino está en la misma fila o en la misma columna que la de origen. En principio, es un movimiento correcto, pero antes de darlo por bueno hay que comprobar que en la trayectoria del movimiento no haya ninguna pieza que intercepte a la torre. Para eso, haremos un bucle que recorra la columna (si la torre se mueve a lo largo de su columna) o la fila (si el movimiento es a lo largo de la fila):

si (ox != dx) entonces    // La torre se desplaza a lo largo de la columna
  para i desde ox hasta dx hacer
    si hay una pieza en (i, oy) --> MOVIMIENTO INCORRECTO
si (oy != dy) entonces    // La torre se desplaza a lo largo de la fila
  para i desde oy hasta dy hacer
    si hay una pieza en (ox, i) --> MOVIMIENTO INCORRECTO

En realidad, esto es un poco más complicado, porque hay que hacer la comprobación desde una casilla después del origen (ya que en el origen está situada la misma torre, y este algoritmo interpretaría que se intercepta a sí misma), y dejarla una casilla antes del destino (porque en la casilla destino puede haber otra pieza que va a ser “comida” por la torre)

4) Por último, hay que comprobar si en la casilla destino hay una pieza. Si es una pieza enemiga, va a ser tomada (o “comida”). Si es una pieza propia, el movimiento es incorrecto.

Con esto quedaría comprobada la corrección o incorrección del movimiento habitual de una torre. Si el movimiento resultara ilegal, se debe mostrar un mensaje de error y pedir al jugador que vuelva a elegir un origen y un destino. Algo similar hay que pensar para cada tipo de pieza, ya que cada una tiene su propio movimiento.

Una vez programados estos controles para los movimientos habituales de cada pieza, habría que ocuparse de los movimientos especiales (como el enroque o la salida del peón).

Y más aún: cuando haya programado eso, hay que controlar la situación de jaque y la de tablas: si el rey está amenazado con un jaque, hay que hacer obligatoriamente un movimiento que deshaga el mismo, y cualquier otro movimiento, aunque en circunstancias normales fuera legal, debe ser prohibido. Más complicado puede ser controlar las tablas: cuando no sea posible realizar ningún movimiento, el juego debe terminar. Esto debemos dejarlo para las últimas fases de desarrollo del juego, cuando introduzcamos la inteligencia artificial.

3º) Comprobar si se toma alguna pieza enemiga

Tomar o “comer” una pieza enemiga consiste en ubicar una pieza propia en el lugar del tablero que ocupaba la otra, y eliminar a la enemiga del tablero.

Es posible que tenga que programar algún código adicional para sustituir una pieza por otra en el tablero. También puede emitir algún mensaje informativo al respecto.

Por último, recuerde dos cosas sobre el peón: se mueve de forma diferente cuando se “come” a una pieza enemiga que cuando no lo hace, y tiene un movimiento especial llamado “toma al paso”. El peón, con su aparente insignificancia, le puede dar bastantes quebraderos de cabeza…

4º) Controlar el tiempo

Añadir el control del tiempo será bastante sencillo. Necesitaremos mantener dos contadores de tiempo, uno para el jugador blanco y otro para el negro. Utilizando las funciones estándar para obtener la hora del reloj interno (time(), localtime(), gmtime(), etc) podemos saber cuánto tiempo tarda un jugador en seleccionar su casilla de origen y su casilla de destino.

Una posible manera de hacerlo es mirar qué hora marca el reloj interno cuando un jugador recibe el turno. En el bucle de lectura del teclado (cuando el jugador está pulsando las flechas del cursor), volveremos a leer la hora del reloj interno, una vez en cada pasada. Cuando transcurra un segundo, lo reflejaremos en el reloj del jugador.

En esta fase debe tratar de programar las rutinas para poder mover las piezas.

Primero moverá una pieza el jugador blanco, luego el negro, luego el blanco, y así sucesivamente. Aún no entrarán en juego los relojes ni controlaremos si el movimiento que se realiza con cada pieza cumple con las reglas del ajedrez: eso lo dejaremos para la siguiente fase.

Descompondremos esta fase en dos pasos:

1º) Mover las piezas tecleando las coordenadas

En una primera aproximación, para que cada jugador indique sus movimientos, podemos preguntar en el área de la pantalla dedicada a los mensajes de usuario (a la derecha de la figura) cuál es la casilla de origen del movimiento y cuál la de destino. Para identificar una casilla será necesario que el jugador introduzca su fila y su columna.

El movimiento se debería reflejarse inmediatamente en el tablero invocando a la función encargada de repintar el tablero.

2º) Mover las piezas seleccionando la casilla con las teclas del cursor

Cuando haya conseguido realizar movimientos de este modo, es el momento de mejorar la jugabilidad. Para el jugador es muy incómodo tener que introducir a través del teclado la fila y la columna de origen y de destino. Es mucho mejor que pueda mover una marca a través del tablero para seleccionar las casillas de origen y de destino.

Esa marca puede ser, por ejemplo, una marca que señale la casilla, o un recuadro de color alrededor de la casilla, que se mueva con las flechas del cursor, de manera que al pulsar “Intro” o “Espacio” o algo parecido, la casilla quede seleccionada.

Así, el jugador puede cómodamente seleccionar dos casillas (primero, la de origen; luego, la de destino) para hacer sus movimientos. En la zona reservada al texto (en el panel de la derecha) pueden aparecer las coordenadas de la casilla que haya sido seleccionada.

Piense en ello. No es nada difícil y hará que el juego empiece a parecer algo serio. No pase a la siguiente fase hasta haber terminado ésta.

Si aprecias su estabilidad mental, no empiece esta fase hasta haber completado la primera y estar bastante seguro de lo que se trae entre manos. Sería la forma más fiable de pasar las próximas semanas programando algo que jamás funcionará.

Una vez diseñado el programa, vamos a empezar a implementar los primeros módulos. Lo haremos de manera que podamos ir probando lo que vayamos haciendo.

Inicialización

Los primeros módulos que hay que programar son los que se encarguen de dar el valor inicial a las estructuras de datos del programa (el tablero, los movimientos, etc; cada uno que se las apañe con las estructuras que haya elegido).  Por ejemplo: hay que colocar las piezas en el tablero en su posición inicial.

También programaremos la parte en la que el juego nos pregunta de qué tipo son los jugadores (humanos o máquinas) y qué color tendrá adjudicado cada uno (blanco o negro). Por ahora los dos jugadores tendrán que ser humanos: la posibilidad de que juegue la máquina se añadirá en la fase 8.

La función o funciones que inicialicen estas estructuras tienen que estar pensadas para que, en una fase posterior, la inicialización también se pueda hacer desde un archivo de disco en el que habrá guardadas otras partidas (fase 5) cambiando el menor número posible de cosas.

Interfaz de texto

En esta fase también programaremos los módulos que dibujen el tablero y las piezas en modo texto, diseñando la pantalla de tal modo que quede un área reservada para los mensajes que se necesiten dar al usuario y otra para el reloj.

El aspecto final de la pantalla puede ser algo parecido al de la siguiente figura; observe cómo las piezas se simbolizan con letras (el uso de gráficos no se contempla hasta la fase 7)

Para poder dibujar una pantalla como la anterior necesitamos funciones que nos permitan escribir en cualquier punto de la consola y manejar los colores libremente. ANSI C no dispone de tales funciones, pero existen muchas librerías para ello. Una de las más utilizadas en entornos Linux es Ncurses, que, de hecho, se incluye con la mayoría de las distribuciones.

Para más información sobre ncurses, consulte este artículo.

Antes de comenzar la codificación del programa es conveniente dedicar un tiempo a diseñarlo (lo ideal sería hacer un análisis y diseño completo, pero de ello ya hablaremos algún día). Nos vamos a dedicar a este diseño previo en este primer post dedicado al juego del ajedrez.

  1. Lo primero es, sin duda, leer y comprender detenidamente las reglas del juego si no estamos demasiado familiarizados con el ajedrez, así como lo que se espera que haga el programa. De ello ya hablamos en el artículo anterior.
  2. Diseñar las estructuras de datos que vamos a utilizar.
  3. Diseñar la estructura modular del programa.
  4. Diseñar la estructura de archivos del programa.

Veamos con más detalle los pasos 2, 3 y 4.

Eligiendo las estructuras de datos

La correcta elección de las estructuras de datos es de vital importancia para el posterior desarrollo del programa.

Debe usted elegir las estructuras que estime más convenientes para almacenar toda la información necesaria, y pensar detenidamente en ellas antes de darlas por buenas, porque una modificación de las estructuras de datos después de comenzada la codificación puede obligarle a reescribir una gran cantidad de código.

Para ayudarle en la correcta elección de estructuras, a continuación enumeramos la información más importante que tiene que manejar el programa:

  • El tablero. Debemos almacenar la información de todas las casillas del tablero: su color, su posición, si la ocupa alguna pieza o no.
  • Las piezas. El programa tiene que saber en qué lugar del tablero se encuentra cada pieza.
  • El estado del juego. Hay que almacenar diversa información sobre el estado en el que se encuentra el juego. Por ejemplo, a qué jugador le corresponde el turno, si se ha producido un jaque, si el rey o la torre han sido ya movidos (en cuyo caso se les prohibe hacer el enroque), etc.
  • Los movimientos. Hay que guardar todos los movimientos realizados desde el comienzo del juego por si se desea guardar la partida para continuarla en otro momento.
  • Además, es conveniente que definir constantes para los símbolos que se vayan a utilizar con más frecuencia en tus estructuras de datos. Por ejemplo, si en la estructura donde se almacene el tablero se va a simbolizar el peón con el carácter ‘P’ o el caballo un una ‘C’, es muy recomendable definir dos constantes PEON y CABALLO y asignarles, respectivamente, los valores ‘P’ y ‘C’, de manera que, en adelante, se pueda usar el identificador PEON en lugar de la constante ‘P’, o CABALLO en lugar de ‘C’, lo que hará que el programa sea mucho más legible y fácil de modificar.

Diseñando la estructura modular

Una vez elegidas las estructuras de datos llega el momento de que piense qué módulos va a hacer y cómo se van a llamar unos a otros.

Obviamente, debe existir un módulo principal, que será la función main(). Éste debe llamar a otros; por ejemplo, puede empezar llamando a un módulo inicializar(), que le asigne un valor inicial  a todas las variables.

Luego puede llamar al módulo pintar_tablero(), que dibuje el tablero en la pantalla, y que a su vez llame 64 veces al módulo pintar_casilla(), etc.

De este modo, trate de imaginar qué módulos va a necesitar y cómo se van a llamar unos a otros, y construya un diagrama de descomposición modular para tenerlo siempre presente.

Complete el diagrama describiendo brevemente qué hace cada módulo. Asegúrese de que cada módulo realice una labor sencilla y fácil de programar. Si no es así, descompóngalo en varios módulos más sencillos.

Asegúrese de que cada módulo tiene una y solo una función bien clara y definida. Esto se denomina cohesión interna del módulo y debe ser la mayor posible.

Asegúrese también de que cada módulo influya lo menos posible en el funcionamiento de los demás, es decir, que cada uno haga su labor sin afectar, en lo posible, a la de los otros. Esto se refleja, por ejemplo, en la cantidad de parámetros que los módulos se pasan entre sí cuando se invocan unos a otros. Esta cantidad debe ser la mínima imprescindible. Esto se denomina acoplamiento entre módulos, y hay que reducirlo todo lo posible.

No hay una sola forma correcta de hacer la descomposición. Cada cuál debe encontrar la suya. Pero recuerde: ¡divide y vencerás!

Eligiendo una estructura de archivos

Cuando un programa es largo, como el que nos ocupa, es poco recomendable escribir todas las funciones en el mismo archivo fuente, porque resulta muy complicado moverse dentro de un archivo muy largo y la compilación puede llegar a hacerse terriblemente lenta. Es mejor dividirlo en varios archivos más pequeños.

La división en archivos no debe hacerse a lo loco, sino que, partiendo de la descomposición modular, colocaremos las funciones más relacionadas entre sí juntas en el mismo archivo.

Por ejemplo, es muy, pero muy recomendable que las funciones de entrada / salida estén juntas en un archivo, y que ninguna otra función de otro archivo haga una entrada (es decir, un scanf() o similar) ni una salida (un printf() o similar). Esto facilitará mucho la tarea de localizar los errores de ejecución y la modificación posterior del programa.

Cada archivo fuente, de extensión .c, tendrá asociado un archivo de cabecera de extensión .h. En este archivo de cabecera se incluirán los prototipos de las funciones, de modo que puedan ser llamadas desde cualquier otro lugar del programa.

Por ejemplo, imagine que tenemos estos archivos:

  • ajedrez.c: contiene la función main()
  • interfaz.c  e  interfaz.h: contienen las funciones de entrada/salida
  • movimientos.c  y  movimientos.h: contienen las funciones para realizar los movimientos de las piezas

Imagine que el archivo interfaz.c contiene una función llamada pintar_pieza(), que muestra en la pantalla una pieza del ajedrez colocada en determinada posición del tablero. Suponga que deseamos llamar a esa función desde otra función situada en movimientos.c. Para eso, hay que colocar el prototipo de pintar_pieza() en interfaz.h, y hacer un #include “interfaz.h” en el archivo movimientos.c. Así, cualquier función de movimientos.c puede llamar a las funciones de interfaz.h

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

Pues sí, mire. Hace unos años, propuse a un grupo de alumnos que, como trabajo de fin de curso, realizasen un programa completo de cierta complejidad. Los juegos son, probablemente, las aplicaciones más completas que pueden desarrollarse durante el aprendizaje de las técnicas de programación, porque suelen requerir la utilización de muchos recursos distintos y, sobre todo, porque programar juegos es mucho más divertido que hacer programas de contabilidad, qué demonios.

Programar un juego de ajedrez presenta una dificultad elevada sin llegar a ser excesiva. Además, como otros juegos de mesa, tiene un conjunto de reglas muy bien definido que nos evitará ambigüedades, y permite dividir el desarrollo en diferentes fases pudiendo disponer casi desde el principio de un prototipo operativo.

En los próximos posts iremos describiremos cómo podemos desarrollar este juego en diez “sencillos” pasos asequibles para todo el que tenga nociones de programación. Al terminar, tendremos un juego de ajedrez en modo gráfico capaz de jugar contra nosotros con un nivel de competencia razonable.

¿Aún no se lo cree? Ya verá, ya. Aquí dejo una captura de pantalla del juego en cuestión para ir abriendo boca.

Plan de trabajo

Ante todo, un aviso: aquí no encontrará la implementación de un juego de ajedrez. Para eso ya tiene el GNU Chess, que es un juego extraordinario y cuyo código fuente está disponible (y no es el único software libre de ajedrez).

Lo que encontrará son una serie de pautas bastante detalladas sobre los pasos que debería seguir para conseguir por usted mismo programar un juego de ajedrez. Aquí venimos a jugar al fútbol, no a verlo por televisión. Si quería sentarse cómodamente a ver un partido, lo siento, este no es su sitio.

Aclarado esto, metámonos en harina.

Hay una frase tremendamente útil a la hora de enfrentarse al desarrollo de un programa de grandes dimensiones. Es tan importante que lo pondré en negritra: no intente resolverlo todo a la vez.

El método de trabajo “divide y vencerás” no se llama así casualmente. Dividir el problema en tareas más sencillas es la única forma de culminar la tarea con éxito. Incluso el viaje más largo empieza por el primer paso.

En realidad, los grandes proyectos de software se planifican cuidadosamente siguiendo los métodos propios de cualquier ingeniería, adaptados a las peculiaridades del desarrollo de software. La ingeniería del software es lo bastante interesante como para dedicarle otros posts, pero este no es el momento, así que optaremos por una aproximación menos formal.

Fases de desarrollo

Vamos a proponer un plan de trabajo para ir “montando” el programa poco a poco. Para ello, dividiremos las tareas en 10 fases o etapas. Sólo se debe pasar a la siguiente etapa si se ha resuelto satisfactoriamente la anterior. Esto es muy importante porque, si pasa a una nueva etapa demasiado pronto y después tiene que volver atrás a resolver cosas que dejó pendientes, es muy posible que tenga que tirar a la basura gran parte del trabajo realizado.

A continuación, resumimos a grandes rasgos las 10 etapas, que se irán explicando con mayor detalle en los siguientes posts (a razón de un post por etapa, excepto la 7 y la 8, que irán juntas en el mismo artículo). Iremos enlazando los posts con este artículo para poder usarlo como índice.

  • Fase 1: Diseño del programa. Las primeras fases del ciclo de vida clásico (especificación de requisitos, análisis y diseño) escapan al propósito de nuestra asignatura, pero es imprescindible un mínimo diseño previo antes de afrontar un programa relativamente complejo como es éste. En esta fase realizaremos este diseño, de vital importancia antes de lanzarnos a programar.
  • Fase 2: Inicialización. Interfaz de texto. En esta fase implementaremos las estructuras de datos diseñadas en la fase anterior, y les proporcionaremos sus valores iniciales. También diseñaremos una primera versión de la interfaz del programa, que será en modo texto.
  • Fase 3: Movimientos no controlados. Programaremos los algoritmos para mover las piezas por el tablero, pero, de momento, sin atenernos a las reglas del juego. Añadiremos algunos elementos al interfaz para facilitar los movimientos y la jugabilidad.
  • Fase 4: Movimientos controlados. Agregaremos las rutinas de control necesarias para asegurarnos de que los movimientos realizados por el jugador son legales, es decir, cumplen con las reglas del ajedrez. También añadiremos el control del tiempo que emplea cada jugador en realizar sus movimientos. Con esto ya tendremos una versión básica del juego, en la que dos jugadores humanos podrán enfrentarse utilizando nuestro programa como tablero de juego.
  • Fase 5. Guardar y recuperar partidas. Añadiremos diversas funciones para guardar en disco una partida y poder recuperarla después para continuar jugando. También añadiremos en esta fase el menú de opciones del programa.
  • Fase 6. Reproducir partidas guardadas. Esta funcionalidad nos permitirá ver en la pantalla el desarrollo de las partidas que tuviéramos guardadas, así como reproducir otras partidas que descarguemos de Internet. También añadiremos en esta fase el sistema de ayuda en línea y la posibilidad de que aparezcan escritos los movimientos que se hayan realizado hasta el momento.
  • Fases 7 y 8. Interfaz gráfica. Con el fin de hacer el juego visualmente más atractivo (y más fácil de utilizar), sustituiremos nuestro primitivo interfaz de texto por un interfaz gráfico.
  • Fases 9 y 10. Inteligencia artificial. Por último, introduciremos la posibilidad de que uno de los jugadores sea el ordenador, añadiendo las funciones necesarias para que éste “piense” cual es la jugada que más le conviene hacer en cada momento.

Qué debe hacer el programa

No vamos a reproducir aquí las reglas del juego del ajedrez. Encontrará montones de páginas en Internet donde las explican. Lo que haremos será especificar con claridad qué podrá hacer nuestro programa cuando esté terminado.

Respetará todas las reglas básicas del ajedrez, incluyendo los movimientos especiales (aunque la toma al paso del peón puede resultar complicada) y el control del tiempo.

Dispondrá de un tablero de juego en dos dimensiones que en principio será en modo texto y luego se cambiará a un formato gráfico. En modo texto, las piezas se representarán con su inicial (R = rey, D = dama, A = alfil, T = torre, C = caballo, P = peón). El programador elegirá los colores que considere más convenientes para facilitar la jugabilidad. En modo gráfico, las letras serán sustituidas por imágenes de las piezas, para hacer el interfaz más atractivo.

El ajedrez es un juego para dos jugadores. Nuestro programa debe permitir que juegen dos jugadores humanos, o bien un jugador humano contra la máquina. Esta última característica es la más avanzada y difícil de programar, por lo que la dejaremos para el final. El jugador humano debe poder elegir con qué piezas desea jugar, si las blancas o las negras.

Además, el juego debe proporcionar en todo momento al jugador información sobre el estado de la partida: tiempo transcurrido de cada jugador, si se ha tratado de hacer un movimiento ilegal, si un rey se encuentra en jaque, etc. Se reservará un área de la pantalla para proporcionar tales mensajes informativos a los jugadores.

Otra función del juego es que debe permitir grabar las partidas para recuperarlas después. Es decir, se grabarán todos los movimientos realizados hasta el momento en un archivo de disco para, más tarde, poder cargar la partida y reproducirla desde el inicio, o bien continuarla desde el punto en el que se quedó.

Por último, existirá un sencillo sistema de ayuda en línea accesible desde la pantalla principal del juego.

Un ejemplo de funcionamiento

Para dejar claro todo lo que pretendemos hacer, imaginemos una ejecución típica del juego:

  1. Al empezar, el programa da un mensaje de bienvenida
  2. El programa pregunta si se va a comenzar una partida nueva o se va a cargar una que ya esté grabada.
    1. Si la partida es nueva, pregunta qué tipo de jugadores van a jugar (dos humanos, humano contra máquina; podría ser interesante la posibilidad de máquina contra máquina…) y qué color escoge cada uno. También preguntará si se desea jugar con límite de tiempo o sin él, y cuál es el límite de tiempo deseado.
    2. Si la partida es grabada, esa selección de jugadores debe recuperarse del archivo de disco, junto con los movimientos.
  3. Aparece el tablero en su posición inicial (si la partida es nueva) o en el estado en el que se quedó (si la partida se ha recuperado del disco)
  4. Comienza el bucle principal del juego:
    1. Aparece un mensaje informando de qué jugador es el turno, y su reloj empieza a correr. El jugador hace su movimiento. Si el movimiento es ilegal, el ordenador informa de ello y vuelve a pedir que se haga el movimiento. Si el movimiento es legal, el reloj se detiene. Si se ha producido alguna situación especial (por ejemplo, un jaque), se mostrará un mensaje en la pantalla informando del hecho.
    2. Se cambia el turno al otro jugador, que realiza su movimiento en las mismas condiciones que su oponente.
    3. Este bucle se repite hasta que la partida termina por alguno de los hechos que se explicó al hablar de las reglas del juego, o hasta que el usuario decide ir al paso 5.
  5. En cualquier momento del punto 4, el jugador puede decidir interrumpir la partida pulsando alguna tecla (por ejemplo, ESC). Si es así, se mostrará un menú con varias opciones:
    1. Salir del programa
    2. Empezar otra partida
    3. Continuar la partida interrumpida
    4. Guardar la partida
    5. Recuperar otra partida distinta que tuviéramos guardada en disco
    6. Reproducir la partida en la pantalla desde el primer movimiento hasta la situación actual (como si rebobináramos la partida hasta el principio y la volviéramos a proyectar para verla como simples espectadores)
  6. Por último, se implementará sistema de ayuda en el que se resuma el funcionamiento del juego y las teclas con las que se utiliza. Esta pantalla será accesible desde el paso 4 pulsando alguna otra tecla especial (por ejemplo, F1)

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

A diferencia de la mayoría de lenguajes de programación, C incorpora métodos para acceder a los bits individuales dentro de un byte, lo cual puede ser útil por, al menos, tres razones:

  • Se pueden almacenar hasta ocho variables lógicas (booleanas) en solo byte, con el consiguiente ahorro de espacio.
  • Ciertos dispositivos codifican las información a nivel de bits, de modo que los programas que se comuniquen con esos dispositivos deben ser capaces de manejar esos bits
  • Asimismo, ciertas rutinas de cifrado y descifrado de información necesitan operar a nivel de bits.

Campos de bits

El método que se utiliza en C para operar con bits está basado en las estructuras (structs). Una estructura compuesta por bits se denomina campo de bits. La forma general de definición de un campo de bits es:

struct s_bits
{
   tipo nombre1: longitud;
   tipo nombre2: longitud;
   ...
   tipo nombre3: longitud;
};

Cada campo de bits debe declararse como unsigned int y su longitud puede variar entre 1 y 16. Por ejemplo:

struct s_bits
{
   unsigned int mostrar: 1;
   unsigned int rojo: 2;
   unsigned int azul: 2;
   unsigned int verde: 2;
   unsigned int transparencia: 1;
};

Esta estructura define cinco variables. Dos de ellas (“mostrar” y “transparencia”) son de 1 bit, mientras que el resto son de 2 bits. Se puede utilizar esta estructura u otra similar para codificar una imagen RGB de 16 colores, especificando para cada píxel su color, si es o no transparente y si debe o no mostrarse.
Para acceder a cada variable del campo de bits se emplea la misma sintaxis que en cualquier otra estructura de C, pero teniendo en cuenta que un bit sólo puede tomar dos valores, 0 y 1 (en el caso de los campos de dos bits, serían cuatro los valores posibles: 0, 1, 2 y 3). Por ejemplo:

struct s_bits pixel;
…
if (pixel.mostrar == 1)
{
   pixel.transparencia = 0;
}

Limitaciones de los campos de bits

Las variables de campos de bits tienen ciertas restricciones:

  • No se puede aplicar el operador & sobre una de estas variables para averiguar su dirección de memoria.
  • No se pueden construir arrays de campos de bits.
  • En algunos entornos, los bits se dispondrán de izquierda a derecha y, en otras, de derecha a izquierda, por lo que la portabilidad puede verse comprometida.

Operadores a nivel de bits

Otro modo de acceder a los bits individuales de un bit sin necesidad de recurrir a los campos de bits es utilizar los operadores específicos de C.

Las operaciones a nivel de bits, directamente heredadas del lenguaje ensamblador, permiten a C comprobar, asignar o desplazar los bits de un byte con toda libertad, algo que no suele ser posible en otros lenguajes.

Los operadores a nivel de bits de C son:

Operador     Acción
  &          Operación AND a nivel de bits
  |          Operación OR a nivel de bits
  ^          Operación XOR (OR exclusivo) a nivel de bits
  ~          Complemento a uno
  >>         Desplazamiento a la derecha
  <<         Desplazamiento a la izquierda

La operación & (AND) compara los bits uno a uno, dejando a 1 sólo aquéllos que valgan 1 en los dos operandos. Por ejemplo:

char c;
c = 255;       // 255 en binario es 1111 1111
c = c & 127;   // 128 en binario es 1000 0000

Se realizará la operación AND bit a bit entre 1111 1111 y 1000 0000, resultando el número 1000 0000, es decir, 127. Esto suele ser muy útil para averiguar si un byte tiene determinado bit a 1 o a 0. Por ejemplo, supongamos que tenemos un byte almacenado en la variable c y queremos saber si su bit número 3 (contando desde la derecha, es decir, el tercer bit menos significativo) vale 1 ó 0. Bastaría con hacer la operación AND a nivel de bits entre la variable c y el número binario 100, es decir, 4 en decimal:

c = c & 4;   // 4 en binario es 100
if (c == 4) printf(“El tercer bit valía 1”);

Efectivamente, si tras hacer la operación AND el resultado debe ser 4 si el bit buscado valía 1, o 0 si ese bit valía 0.

La operación | (OR) es la inversa de AND: pone a 1 todos los bits que valgan 1 en cualquiera de los dos operandos. Mientras que la operación ^ (XOR) pone a 1 los bits que sean diferentes en los dos operandos.
Podemos resumir el resultado estas tres operaciones en una tabla:

AND (&)   
0 & 0 = 0
1 & 0 = 0
0 & 1 = 0
1 & 1 = 1
OR ( | )
0 | 0 = 0
1 | 0 = 1
0 | 1 = 1
1 | 1 = 1
XOR ( ^ )    
0 ^ 0 = 0
1 ^ 0 = 1
0 ^ 1 = 1
1 ^ 1 = 0

Por ejemplo, tomemos dos números binarios cualesquiera, como 11110000 y 10101010. El resultado de aplicar las operaciones AND, OR y XOR a los bits uno a uno será:

     AND (&)          OR ( | )            XOR ( ^ )
1 1 1 1 0 0 0 0    1 1 1 1 0 0 0 0    1 1 1 1 0 0 0 0
1 0 1 0 1 0 1 0    1 0 1 0 1 0 1 0    1 0 1 0 1 0 1 0
---------------    ---------------    ---------------
1 0 1 0 0 0 0 0    1 1 1 1 1 0 1 0    0 1 0 1 1 0 1 0

Nótese la diferencia entre los operadores lógicos && y | |, que siempre producen como resultado verdadero o falso, y los operadores a nivel de bit & y |, que pueden producir cualquier número como resultado del cáculo bit a bit.

Los operadores de desplazamiento (<< y >>) mueven todos los bits de una variable hacia la izquierda o hacia la derecha. La forma habitual de usarlos es:
variable >> número de desplazamientos;

Por ejemplo:

char c;
c = 128;      // 128 en binario es 1000 0000
c = c >> 2;   // Desplazamos dos bits a la derecha

Tras estas instrucciones, c debe valer 0010 0000, es decir, 32.

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

La función main(), que normalmente no tiene argumentos, puede llevar dos argumentos especiales llamados argc y argv. La combinación de ambos permiten pasar al programa órdenes desde la línea de comandos (el símbolo de sistema de Windows, una consola de Linux, etc.)

Veamos la forma de usarlos a través de un ejemplo que más abajo comentaremos:

int main(int argc, char **argv)
{
    if (argc < 2)
        printf("Ha olvidado escribir su nombre\n");
    else if (argc > 2)
        printf("Error, demasiados parámetros\n");
    else
        printf("Hola, %s\n", argv[1]);
    return 0;
}

En este sencillo ejemplo observamos la forma en la que siempre se usan los parámetros de main():

  • El primer argumento, argc, es de tipo int y contiene el número de parámetros que se han pasado desde la línea de comandos. El propio nombre del programa cuenta como un parámetro, de modo que si deseamos leer un parámetro adicional desde la línea de comandos, el valor de argc debería ser 2. Observa que ese valor lo pasa el sistema operativo a nuestro programa cuando lo ejecuta. Nuestro programa sólo puede consultarlo y obrar en consecuencia (típicamente, emitiendo un mensaje de error si el número de argumentos es incorrecto, como en el ejemplo)
  • El segundo argumento, argv, es un array de cadenas. La longitud del array es indefinida, y de hecho tiene tantos elementos como parámetros se hayan pasado en la línea de comandos. El primer elemento, argv[0], siempre contiene el nombre del programa, de manera que los verdaderos parámetros se ubicarán a partir de esa posición, es decir, en argv[1], argv[2], etc.

Suponiendo que el programa anterior se haya compilado y que el ejecutable se denomine “saludo”, obtendríamos los siguientes resultados desde la línea de comandos:

$ saludo
Ha olvidado escribir su nombre
$ saludo Juan
Hola, Juan
$ saludo Juan Ana
Error, demasiados parámetros

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

Los espacios con nombre nos ayudan a evitar problemas con identificadores en grandes proyectos. Nos permite, por ejemplo, que existan variables o funciones con el mismo nombre, declaradas en diferentes ficheros fuente, siempre y cuando se declaren en distintos espacios.

La sintaxis para crear un espacio con nombre es:

namespace [<identificador>] {
...
<declaraciones y definiciones>
...
}

Por ejemplo:

// Inicio del fichero "puntos.h"
namespace 2D {
   struct Punto {
      int x;
      int y;
   };
}

namespace 3D {
   struct Punto {
      int x;
      int y;
      int z;
   };
}

Este ejemplo crea dos versiones diferentes de la estructura Punto, una para puntos en dos dimensiones, y otro para puntos en tres dimensiones.

Para activar un espacio durante la codificación del programa usaremos esta expresión:

using namespace <identificador>;

Por ejemplo:

#include "puntos.h"
using namespace 2D;    // Activa el espacio con nombre 2D
Punto p1;     // Define la variable p1 de tipo 2D::Punto
...
using namespace 3D;    // Activa el espacio con nombre 3D
Punto p2;     // Define la variable p2 de tipo 3D::Punto
...

Estando un espacio activado, se pueden usar definiciones propias de otro espacio utilizando esta forma de declaración de variables:

<nombre_de_espacio>::<identificador>;

Por ejemplo:

#include "puntos.h"
using namespace 2D;  // Activa el espacio con nombre 2D
Punto p1;            // Define la variable p1 de tipo 2D::Punto
Punto 3D::p2;        // Define la variable p2 de tipo 3D::Punto,
                     // aunque el espacio 3D no esté activado

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

En C existen ciertos modificadores de variables que se usan durante la definición de las mismas y que afectan al modo en que se almacenan las variables, así como a su ámbito, es decir, a la zona del programa desde donde las variables son accesibles. Estos son algunos de ellos.

auto

El modificador auto se usa para definir el ámbito temporal de una variable local. Es el modificador por defecto para las variables locales, y se usa muy raramente.

Por ejemplo:

void funcion1(void) {
   auto int n;     // La variable n será local a esta función
   ...
}

register

Indica al compilador que debe intentar que la variable se almacene en un registro de la CPU, cuando sea posible, con el fin de optimizar su tiempo de acceso.

Los datos declarados con el modificador register tienen siempre un ámbito global.
El compilador puede ignorar la petición de almacenamiento en registro, que sólo debe ser usado cuando una variable vaya a ser usada con mucha frecuencia y el rendimiento del programa sea un factor crucial.

Por ejemplo:

register int n;    // La variable n se almacenará en un registro de la CPU (o no)

static

Este modificador se usa con el fin de que las variables locales de una función conserven su valor entre distintas llamadas a la misma.

Por ejemplo:

void funcion1(void) {
   static int n;  // La variable n no perderá su valor entre dos llamadas a la función
   ...
}

extern

Este modificador se usa para indicar que el almacenamiento y valor de una variable o la definición de una función están definidos en otro fichero fuente. Las funciones declaradas con extern son visibles por todos los ficheros fuente del programa, salvo que se redefina la función como static.

Por ejemplo:

extern int n;    // La variable n está declarada en otro fichero, pero la vamos
                 // a usar también en éste

const

Cuando se aplica a una variable, indica que su valor no puede ser modificado. Cualquier intento de hacerlo durante el programa generará un error. Por eso es imprescindible inicializar las variables constantes cuando se declaran.

En C++ es preferible usar este tipo de constantes en lugar de constantes simbólicas (macros definidas con #define). El motivo es que estas constantes tienen un tipo declarado, y el compilador puede encontrar errores por el uso inapropiado de constantes que no podría encontrar si se usan constantes simbólicas. En C es indiferente.

Por ejemplo:

const int n = 3;    // Esta variable es, en realidad, una constante

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

A menudo, nos sorprendemos a nosotros mismos escribiendo una y otra vez funciones similares en distintos programas. Por ejemplo, cuando necesitamos manipular archivos o estructuras de datos dinámicas, operaciones como insertar, buscar, modificar o eliminar suelen dar lugar a funciones muy parecidas. Lo mismo ocurre con funciones como esperar(), pulsar_tecla(), borrar_pantalla(), menu() y otros muchos ejemplos.

Sería estupendo poder escribir una sola vez esas funciones y reutilizarlas en todos los programas que las necesitasen. Para eso existen las librerías.

Llamamos librería  a un archivo que sólo contiene una colección de funciones, pero no un punto de inicio de programa, es decir, no tiene una función main(). Por cierto, que la palabra “librería” es una mala traducción del inglés “library”, que, en realidad, significa “biblioteca”. La expresión “biblioteca de funciones” tiene mucho más sentido que “libería de funciones”, pero éste último es el que más se emplea, así que nosotros también lo haremos para no confundir al personal. Qué le vamos a hacer.

Las librerías no pueden compilarse hasta obtener un programa ejecutable, ya que carecen de función main(), pero sí hasta obtener un programa objeto. Más tarde, ese programa objeto puede enlazarse con otro programa que sí tenga función main() y que, además, haga uso de las funciones de la librería.

Las funciones agrupadas en una librería deberían tener algo en común (es decir, una fuerte cohesión entre sí), y no ser meras aglomeraciones al azar. El mejor ejemplo de esta idea lo proporcionan las librerías estándar. Así, por ejemplo, stdio contiene todas las funciones de entrada/salida; string, las funciones de cadena; y time, las relativas a la fecha y la hora.

Como vimos en este post, el enlace puede ser estático o dinámico. Nosotros sólo construiremos por ahora librerías estáticas, más sencillas. En todo caso, el enlace es reubicable, esto es, las direcciones de memoria del código y los datos de la librería no son absolutas, sino relativas, de modo que se pueden enlazar con el resto del código independientemente del estado de la memoria principal en cada momento.

El proceso de creación de una librería no puede ser más sencillo:

  1. Escribir el código fuente y depurarlo. No debe contener ninguna función main()
  2. Colocar los prototipos de funciones, así como cualquier otra definición de ámbito global (constantes, macros, etc.) en un archivo de cabecera. Es una buena costumbre que el archivo de cabecera tenga el mismo nombre que el archivo fuente, pero cambiando la terminación de .c a .h
  3. Compilar el código fuente indicando al compilador que lo que se pretende conseguir es una librería, no un programa ejecutable. El modo de hacerlo depende, lógicamente, del compilador. Concretando para los dos compiladores a los que hasta ahora hemos hecho referencia en este blog:

- Usando un IDE, como el de Dev-CPP u otros similares, basta con indicar, en las propiedades del proyecto, que deseamos crear una librería estática. Esto se hace en el momento de crear el proyecto o
bien posteriormente, buscando la opción “Propiedades del proyecto” entre los menús del IDE.
- Desde la línea de comandos del compilador (típicamente, usando el compilador gcc bajo Linux), hay que usar el comando “ar” para crear la librería. Dicho comando es similar a “tar” y sirve para empaquetar varios archivos en uno, que constituirá la librería.

A modo de ejemplo, supongamos que tenemos un archivo llamado prueba.c que contiene el código fuente de nuestra librería. Los pasos para crear la librería desde la línea de comandos serían:

1.    Crear el código objeto con gcc:

$ gcc –c –o prueba.o prueba.c

2.    Crear la librería con ar a partir del código objeto:

$ ar rcs libprueba.a prueba.o

Las opciones del comando “ar” pueden consultarse en las páginas de manual. Las utilizadas en este ejemplo son las más comunes y significan lo siguiente:

  • r: reemplazar el fichero destino si ya existía
  • c: crear el fichero destino si no existía
  • s: constuir un índice del contenido (hace que el enlace posterior con el programa principal sea más rápido)

Con esto, nuestra librería estará lista para ser enlazada con cualquier programa y reutilizarla tantas veces como sea necesario. Generalmente, el nombre del archivo binario que contiene la librería es (si ésta es estática) libxxx.a, donde “xxx” es el nombre del archivo fuente original. Esta librería debería ser copiada en el directorio de librerías del compilador, y su archivo de cabecera, en el directorio de archivos de cabecera.

Otra opción es copiar ambos archivos en el directorio de trabajo del programa que vaya a hacer uso de la librería, pero eso impedirá su enlace y reutilización desde otros programas.

Por ejemplo, supongamos que tenemos un programa principal (donde se encuentra la función main() ) cuyo nombre de archivo es principal.c, y una librería cuyo nombre es mi_libreria.c, que contiene tres funciones llamadas funcion1(), funcion2() y funcion3(). Debe existir un tercer archivo, mi_libreria.h, que contenga los prototipos de la funciones y cualquier otra declaración necesaria.

Una visión esquemática de estos tres archivos es:

mi_libreria.c

Debemos indicar al compilador que lo compile como librería. Se generará un archivo llamado libmi_libreria.a

#include "mi_libreria.h"
void funcion1(int n)
{
...código de la función...
}
int funcion2(void)
{
...código de la función...
}
float funcion3(int a)
{
...código de la función...
}

mi_libreria.h

No se compila porque es un archivo de cabecera (sólo contiene definiciones, no código ejecutable)

// Prototipos de funciones
void funcion1(int n);
int funcion2(void);
float funcion3(int a);
// Otras definiciones
#define ...
#define ...
const int x = 3;
...etc...

principal.c

Este es el archivo que hace uso de las funciones de la librería. Como este, cualquier otro programa puede usarlas, sin más que indicar al linker que debe enlazarlo con la librería libmi_libreria.a

#include "mi_libreria.h"
// Función main(). Puede hacer
// llamadas a funcion1(),
// funcion2() y funcion3()
int main(void)
{
   ...
   funcion1(n);
   ...
   r = funcion2();
   ...
   y = funcion3(n);
   ...
}
// Otras funciones. También
// pueden hacer llamadas a
// funcion1(), funcion2() y funcion3()

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

En los grandes proyectos (y en los no tan grandes) es imposible escribir todo el código fuente en un único archivo. Podemos dar, al menos, dos motivos para ello:

  • En un archivo fuente de gran tamaño, es incómodo y mareante para el programador encontrar el fragmento de código de busca (es algo así como soltar sobre la mesa de trabajo unos cuantos miles de lápices de colores y tratar de localizar entre ellos nuestro bolígrafo favorito)
  • Cualquier cambio en el código fuente, aunque sea sólo una coma, implica volver a compilar todo el programa, lo cual, en proyectos voluminosos, puede requerir un tiempo no despreciable (del orden de minutos).

Así pues, nuestros proyectos de programación constarán casi siempre de varios archivos fuente… Y pueden llegar incluso a centenares de ellos.

La compilación conjunta de varios archivos para generar un único ejecutable depende del compilador, y se discutió en otros post (en este en el caso de Dev-CPP, y en este otro en el caso de gcc)

Pero ocurre que, al usar archivos separados, éstos suelen estar estrechamente relacionados, en el sentido de que desde algunos de ellos se invocarán funciones de otros, e incluso se utilizarán variables o constantes simbólicas definidas en otros. Para que estas referencias cruzadas funcionen correctamente, se hace imprescindible usar archivos de cabecera.

En general, cuando vayamos a crear un proyecto que contenga varios archivos fuente, es recomendable seguir estas normas:

  1. Para cada archivo fuente crearemos un archivo de cabecera, con el mismo nombre pero con extensión .h, en el que colocaremos todas las declaraciones globales del archivo (prototipos de funciones, constantes simbólicas, variables globales, etc)
  2. En cada archivo fuente haremos un #include de su archivo de cabecera.
  3. En el resto de archivos fuente, si invocan a alguna función residente en otro archivo, haremos un #include del archivo de cabecera de dicho archivo.

Por ejemplo, supongamos que tenemos un proyecto consistente en tres archivos fuente, que llamaremos fich1.c (donde está la función main() ), fich2.c y fich3.c. Supongamos que desde fich1.c se invocan funciones situadas en fich2.c y fich3.c, y que desde fich2.c se invocan funciones de fich1.c y fich2.c, pero no de fich3.c. Por último, desde fich3.c no se invocará ninguna función de fich1.c, pero sí de fich2.c.

En esta situación, deberíamos crear los siguientes archivos (representados esquemáticamente). Obsérvense las directivas #include de cada archivo fuente:

fich1.c

#include "fich1.h"
#include "fich2.h"
#include "fich3.h"
int main(void)
{
  ...
  funcion1(); // Está en fich1.c
  ...
  funcion2(); // Está en fich2.c
  ...
  funcion3(); // Está en fich3.c
  ...
}
void funcion1()
{
...código de la función...
}

fich1.h

// Prototipos de funciones
void funcion1();
...otros prototipos...
// Otras definiciones
#define ...
#define ...
...etc...

fich2.c

#include "fich2.h"
#include "fich1.h"
void funcion2()
{
   ...
   funcion1(); // Está en fich1.c
   ...
   funcion2(); // Está en fich2.c
   ...
}
...código de otras funciones...

fich2.h

// Prototipos de funciones
void funcion2();
... otros prototipos ...
// Otras definiciones
#define ...
#define ...
...etc...

fich3.c

#include "fich3.h"
#include "fich2.h"
void funcion3()
{
   ...
   funcion2(); // Está en fich2.c
   ...
   funcion3(); // Está en fich3.c
   ...
}
...código de otras funciones...

fich3.h

// Prototipos de funciones
void funcion3();
... otros prototipos ...
// Otras definiciones
#define ...
#define ...
...etc...

Es posible que surjan, al actuar de este modo, problemas de redefinición de funciones, constantes o variables. Para resolverlo, existen al menos tres mecanismos que veremos a continuación:

  • Utilización de las directivas #ifndef… #endif
  • Utilización del modificador “extern” en la definición de variables.
  • Definición de espacios con nombre.

Solucionando el problema con las directivas #ifndef … #endif

Estas directivas permiten comprobar si un identificador está o no actualmente definido, es decir, si un #define ha sido previamente procesado para el identificador y si sigue definido.

La sintaxis es:

#ifdef identificador
#ifndef identificador

Todo lo que se encuentre entre #ifdef y la directiva #endif será procesado por el compilador si y sólo si el identificador está definido. Por ejemplo:

#ifdef MAX_ELEMENTOS
int a[MAX_ELEMENTOS];
printf("Se ha definido la variable a");
#endif

Las dos instrucciones se compilarán e incluirán en el programa ejecutable únicamente si el identificador MAX_ELEMENTOS ha sido previamente definido con la directiva #define. De este modo, se puede manipular al compilador para que compile un conjunto de instrucciones dependiendo de una condición.

La directiva #ifndef actúa del mismo modo, pero al contrario. Es decir, el código comprendido entre #ifndef y #endif se compilará si y sólo si el identificador no está definido.

Estas dos directivas, combinadas con #endif, son fundamentales cuando se escriben programas con múltiples ficheros fuente para evitar las múltiples definiciones de constantes, prototipos de funciones, o cualquier otra cosa que se encuentre en los archivos de cabecera incluidos desde varios archivos fuente. En el ejemplo anterior, las usaríamos así:

fich1.h

#ifndef _FICH1_H
#define _FICH1_H
// Prototipos de funciones
void funcion1();
...otros prototipos...
// Otras definiciones
#define ...
#define ...
...etc...
#endif

De este modo, conseguiremos que el código se compile sólo una vez. La primera vez que se haga un #include “fich1.h” desde cualquier archivo .c, el símbolo _FICH1_H no estará aún defindo, por lo que todo el código hasta el #endif se compilará. Eso incluye la definición del símbolo _FICH1_H. De ese modo, si se hace un segundo #include “fich1.h” desde algún otro fichero .c, el símbolo ya habrá quedado definido y el código no se volverá a compilar, evitando los errores por definición múltiple de identificadores.

Lo mismo, obviamente, hay que hacer en el resto de archivos de cabecera que puedan ser incluidos desde varios archivos .c. Como precaución básica, conviene hacerlo por sistema en todos los archivos de cabecera. Mostramos como ejemplo cómo quedaría el archivo fich2.h:

fich2.h

#ifndef _FICH2_H
#define _FICH2_H
// Prototipos de funciones
void funcion2();
... otros prototipos ...
// Otras definiciones
#define ...
#define ...
...etc...
#endif

Puede leer más sobre directivas de compilación en este otro post.

Solucionando el problema con los espacios con nombre (namespaces)

Los espacios con nombre también nos ayudan a evitar problemas con identificadores en grandes proyectos. Nos permite, por ejemplo, que existan variables o funciones con el mismo nombre, declaradas en diferentes ficheros fuente, siempre y cuando se declaren en distintos espacios.

La sintaxis para crear un espacio con nombre es:

namespace [<identificador>] {
...
<declaraciones y definiciones>
...
}

Por ejemplo:

// Inicio del fichero "puntos.h"
namespace 2D {
   struct Punto {
      int x;
      int y;
   };
}
namespace 3D {
   struct Punto {
      int x;
      int y;
      int z;
   };
}// Fin de fichero

Este ejemplo crea dos versiones diferentes de la estructura Punto, una para puntos en dos dimensiones, y otro para puntos en tres dimensiones.

Para activar un espacio durante la codificación del programa usaremos esta expresión:

using namespace <identificador>;

Por ejemplo:

#include "puntos.h"
using namespace 2D;    // Activa el espacio con nombre 2D
Punto p1;     // Define la variable p1 de tipo 2D::Punto
...
using namespace 3D;    // Activa el espacio con nombre 3D
Punto p2;     // Define la variable p2 de tipo 3D::Punto
...

Estando un espacio activado, se pueden usar definiciones propias de otro espacio utilizando esta forma de declaración de variables:

<nombre_de_espacio>::<identificador>;

Por ejemplo:

#include "puntos.h"
using namespace 2D;    // Activa el espacio con nombre 2D
Punto p1;              // Define la variable p1 de tipo 2D::Punto
Punto 3D::p2;          // Define la variable p2 de tipo 3D::Punto,
                       // aunque el espacio 3D no esté activado

Solucionando el problema con el modificador extern

Una última manera de evitar la redefinición de identificadores en proyectos grandes es usando extern. Este modificador se usa para indicar que el almacenamiento y valor de una variable o la definición de una función están definidos en otro fichero lugar. Las funciones declaradas con extern son visibles por todos los ficheros fuente del programa, salvo que se redefina la función como static.

Por ejemplo:

extern int n;    // La variable n está declarada en otro fichero, pero la vamos
                 // a usar también en éste

Retomamos el posteo periódico (o así lo espero) con uno de los temas avanzados del Curso de Programación en C: el preprocesador.

El preprocesador o precompilador es un programa  que analiza el fichero fuente antes de la compilación real, realizando las sustituciones de macros y procesando las directivas de compilación. El preprocesador también se encarga de eliminar los comentarios y las líneas adicionales que no contienen código compilable.

Una directiva de compilación es una línea cuyo primer carácter es un #. Puesto que actúa antes de la compilación, la directiva puede usarse para modificar determinados aspectos de la misma.

A continuación describimos algunas de las directivas de compilación de C. Observará que varias de ellas las hemos venido utilizando asiduamente en otros posts sin preguntarnos realmente qué es lo que hacían.

#include

La directiva “#include”, como imaginará, sirve para insertar ficheros externos dentro de nuestro código fuente. Estos ficheros son conocidos como ficheros incluidos, ficheros de cabecera o “headers”.

Su sintaxis es:

#include <nombre de fichero cabecera>
#include "nombre de fichero de cabecera"

Por ejemplo:

#include <stdio.h>
#include “conio.h”

El preprocesador elimina la línea “#include” y la sustituye por el fichero especificado.

El código fuente en sí no cambia, pero el compilador “ve” el fichero incluido. El emplazamiento del #include puede influir sobre el ámbito y la duración de cualquiera de los identificadores en el interior del fichero incluido.

La diferencia entre escribir el nombre del fichero entre ángulos (<…>) o comillas (“…”) estriba en la manera de buscar los ficheros que se pretenden incluir. En el primer caso, el preprocesador buscará en los directorios de “include” definidos en la configuración del compilador. En el segundo, se buscará primero en el directorio actual, es decir, en el que se encuentre el fichero fuente y, si no existe en ese directorio, se trabajará como el primer caso.

Si se proporciona una ruta como parte del nombre de fichero, sólo se buscará es el directorio especificado.

#define

La directiva “#define” sirve para definir macros. Esto suministra un sistema para la sustitución de palabras.

Su sintaxis es:

#define identificador_de_macro <secuencia>

Por ejemplo:

#define MAX_ELEMENTOS 100
#define LINUX

El preprocesador sustituirá cada ocurrencia del identificador_de_macro (MAX_ELEMENTOS) en el fichero fuente por la secuencia (100). Cada sustitución se conoce como una expansión de la macro. La secuencia es llamada a menudo cuerpo de la macro.

Si la secuencia no existe (como en el ejemplo LINUX), el identificador_de_macro será eliminado cada vez que aparezca en el fichero fuente. Esto tiene una utilidad importante que enseguida examinaremos (ver directiva #ifdef)

Después de cada expansión individual, se vuelve a examinar el texto expandido a la búsqueda de nuevas macros, que serán expandidas a su vez. Esto permite la posibilidad de hacer macros anidadas. Ahora bien: si la nueva expansión tiene la forma de una directiva de preprocesador, no será reconocida como tal.

Las macros pueden parametrizarse. Por ejemplo:

#define CUADRADO(x)   x * x
#define AREA_CIRCULO(r)   3.1416 * CUADRADO(r)

Cuando, durante el programa, se utilice cualquiera de estas dos macros, será necesario especificar un parámetro entre paréntesis. El preprocesador tomará ese parámetro durante la expansión de la macro y lo colocará en el lugar que le corresponda. Por ejemplo:

int r, x = 4;
r = AREA_CIRCULO(x);

…se expandirá como:

r = 3.1416 * CUADRADO(x);

…que a su vez se expandirá como:

r = 3.1416 * x * x;

Y eso será lo que el compilador reciba.

Usar macros para operaciones sencillas (como las de este último ejemplo) en lugar de funciones tiene la ventaja de una mayor velocidad de ejecución.

Por último, mencionemos que existen otras restricciones a la expansión de macros:

  • Las ocurrencias de macros dentro de literales, cadenas, constantes alfanuméricas o comentarios no serán expandidas (por ejemplo: printf(“Aquí la macro MAX_ELEMENTOS no será expandida”); )
  • Una macro no será expandida durante su propia expansión, así #define A A, no será expandida indefinidamente.
  • No es necesario añadir un punto y coma para terminar una directiva de preprocesador. Cualquier carácter que se encuentre en una secuencia de macro, incluido el punto y coma, aparecerá en la expansión de la macro. La secuencia termina en el primer retorno de línea encontrado. Las secuencias de espacios o comentarios en la secuencia se expandirán como un único espacio.

#undef

Sirve para eliminar definiciones de macros previamente definidas. La definición de la macro se olvida y el identificador queda indefinido.

Su sintaxis es:

#undef identificador_de_macro

Por ejemplo:

#undef MAX_ELEMENTOS

A partir de ese momento, la macro MAX_ELEMENTOS deja de estar definida.

La definición es una propiedad importante de un identificador. Las directivas condicionales #ifdef e #ifndef (ver más abajo) se basan precisamente en esta propiedad de los identificadores. Esto ofrece un mecanismo muy potente para controlar muchos aspectos de la compilación.

Después de que una macro quede indefinida puede ser definida de nuevo con #define, usando la misma u otra definición.

Si se intenta definir un identificador de macro que ya esté definido, se producirá un aviso (warning) si la definición no es exactamente la misma. Es preferible usar un mecanismo como este para detectar macros existentes:

#ifndef MAX_ELEMENTOS
  #define MAX_ELEMENTOS 100
#endif

De este modo, la línea del #define se ignorará si el símbolo MAX_ELEMENTOS ya está definido.

#ifdef e #ifndef

Estas directivas permiten comprobar si un identificador está o no actualmente definido, es decir, si un #define ha sido previamente procesado para el identificador y si sigue definido.

La sintaxis es:

#ifdef identificador
#ifndef identificador

Todo lo que se encuentre entre #ifdef y la directiva #endif será procesado por el compilador si y sólo si el identificador está definido. Por ejemplo:

#ifdef MAX_ELEMENTOS
int a[MAX_ELEMENTOS];
printf(“Se ha definido la variable a”);
#endif

Las dos instrucciones se compilarán e incluirán en el programa ejecutable únicamente si el identificador MAX_ELEMENTOS ha sido previamente definido con la directiva #define. De este modo, se puede manipular al compilador para que compile un conjunto de instrucciones dependiendo de una condición.

La directiva #ifndef actúa del mismo modo, pero al contrario. Es decir, el código comprendido entre #ifndef y #endif se compilará si y sólo si el identificador no está definido.

Estas dos directivas, combinadas con #endif, son fundamentales cuando se escriben programas con múltiples ficheros fuente, o que se pretenden compilar para diferentes entornos usando librerías no estándar.

Por ejemplo, supongamos que tenemos un programa que puede funcionar en Linux y en Windows. Supongamos que, para el modo consola de Linux hemos decidido usar la librería no estándar ncurses, mientras que para Windows nos hemos decantado por conio, que tampoco es estándar.

Todo el código del programa compilará en ambos sistemas, excepto las funciones de entrada/salida. Pues bien, podemos escribir un programa que compile perfectamente en ambos usando las directivas de este modo:

#ifdef LINUX
...llamadas a las funciones de E/S de la librería ncurses
#endif
#ifdef WINDOWS
...llamadas a las funciones de E/S de la librería conio
#endif

Previamente, habremos hecho un #define LINUX o un #define WINDOWS, dependiendo del sistema para el que estemos compilando. De este modo, sólo con cambiar una línea (la del #define) podemos compilar nuestro programa en cualquiera de los dos sistemas.

#if, #elif, #else y #endif

Estas directivas permiten hacer una compilación condicional de un conjunto de líneas de código.

La sintaxis es:

#if expresión-constante-1
<sección-1>
#elif <expresión-constante-2>
<sección-2>
.
.
.
#elif <expresión-constante-n>
<sección-n>
<#else>
<sección-final>
#endif

Todas las directivas condicionales deben completarse dentro del mismo fichero. Sólo se compilarán las líneas que estén dentro de las secciones que cumplan la condición de la expresión constante correspondiente.

Estas directivas funcionan de modo similar a los operadores condicionales de C. Si el resultado de evaluar la expresión-constante-1, que puede ser una macro, es distinto de cero (true), las líneas representadas por sección-1, ya sean líneas de comandos, macros o incluso nada, serán compiladas. En caso contrario, si el resultado de la evaluación de la expresión-constante-1, es cero (false), la sección-1 será ignorada: no se expandirán macros ni se compilará.

En el caso de ser distinto de cero, después de que la sección-1 sea preprocesada, el control pasa al #endif correspondiente, con lo que termina la secuencia condicional. En el caso de ser cero, el control pasa al siguiente línea #elif, si existe, donde se evaluará la expresión-constante-2. Si el resultado es distinto de cero, se procesará la sección-2, y después el control pasa al correspondiente #endif. Por el contrario, si el resultado de la expresión-constante-2 es cero, el control pasa al siguiente #elif, y así sucesivamente, hasta que se encuentre un #else o un #endif. El #else, que es opcional, se usa como una condición alternativa para el caso en que todas la condiciones anteriores resulten falsas. El #endif termina la secuencia condicional.

Cada sección procesada puede contener a su vez directivas condicionales, anidadas hasta cualquier nivel. Hay que tener en cuenta, en ese caso, que cada #if se corresponde con el #endif más cercano.

El objetivo de una red de este tipo es que sólo una sección, aunque se trate de una sección vacía, sea compilada. Las secciones ignoradas sólo son relevantes para evaluar las condiciones anidadas, es decir, para asociar cada #if con su #endif.

Las expresiones constantes deben poder ser evaluadas como valores enteros.

#error

Esta directiva se suele incluir en sentencias condicionales de preprocesador para detectar condiciones no deseadas durante la compilación. En un funcionamiento normal, estas condiciones serán falsas, pero cuando la condición es verdadera, es preferible que el compilador muestre un mensaje de error y detenga la fase de compilación. Para hacer esto se debe introducir esta directiva en una sentencia condicional que detecte el caso no deseado.

Su sintaxis es:

#error mensaje_de_error

Esta directiva genera el mensaje:

Error: nombre_de_fichero nº_línea : Error directive: mensaje_de_error

No ignoran ustedes que Π (o sea, pi) es la letra griega que representa el número 3,141592. Bueno, más exactamente representa el número:

3,1415926535 8979323846 2643383279 5028841971 6939937510
  5820974944 5923078164 0628620899 8628034825 3421170679
  8214808651 3282306647 0938446095 5058223172 5359408128
  4811174502 8410270193 8521105559 6446229489 5493038196
  4428810975 6659334461 2847564823 3786783165 2712019091
  4564856692 3460348610 4543266482 1339360726 0249141273
  7245870066 0631558817 4881520920 9628292540 9171536436
  7892590360 0113305305 4882046652 1384146951 9415116094
  3305727036 5759591953 0921861173 8193261179 3105118548
  0744623799 6274956735 1885752724 8912279381 8301194912
  9833673362 4406566430 8602139494 6395224737 1907021798
  6094370277 0539217176 2931767523 8467481846 7669405132

…aunque no nos hemos quedado ahora más cerca de la verdad que antes, ya que pi es un número irracional con infinitas cifras decimales. Si lo representamos con 600 decimales no nos hemos acercado más al infinito que al representarlo con 6, aunque, acaso, hemos sido un poco más exactos.

Lo más increíble de este absurdo número es que siempre se obtiene al dividir la longitud de la circunferencia estre su diámetro. Con cualquier circunferencia. En cualquier lugar del universo. No importa dónde ni cuándo. Coja una circunferencia (perfecta) cualquiera, divida su longitud entre su diámetro (perfectamente medidos), y obtendrá esa interminable sucesión de números, siempre la misma.

Pero eso es sólo el principio: debido precisamente a su íntima relación con las circunferencias, pi aparece como un factor inmutable en infinidad de relaciones matemáticas que describen fenómenos físicos donde las circunferencias y sus arcos tienen mucho que ver. Fenómenos físicos absolutamente reales con arcos de circunferencia absolutamente perfectos. Encontraremos a pi, como a una vieja conocida, incrustada en la distribución gaussiana de probabilidad, en el periodo de movimiento de un péndulo, en la cantidad de energía que la Tierra recibe procedente del Sol o en la ecuación de onda de un electrón, por poner algunos ejemplos. Es decir, que encontramos a pi entrelazada con el tejido mismo de la realidad.

Esto es lo que el físico Eugene Wigner denominaba la “irrazonable efectividad de las matemáticas” en la naturaleza. Y es que, vamos a ver: ¿por qué ese número concreto, ese número aleatorio en el sentido más absoluto del término, aparece una y otra vez en las descripciones matemáticas de la naturaleza?

Bueno, todo esto ha sido una forma de excusar la fascinación que ejercen algunos números. Muchos grandes cerebros han tratado de idear durante siglos el mejor método para calcular los decimales de pi con precisión. Las mediciones de circunferencias reales son muy limitadas a este respecto debido a los errores en las medidas y a las imperfecciones de cualquier circunferencia real. Pero es posible idear métodos matemáticos que, mediante aproximaciones sucesivas, vayan obteniendo las cifras de pi con absoluta precisión. Estos métodos requieren muchos cálculos repetitivos. Y ahí entran en juego los ordenadores.

Antes del advenimiento de los ordenadores digitales, lo más que se había logrado “a mano” era calcular las primeras 620 cifras decimales de pi. En la actualidad, se ha superado el billón de cifras, y podemos añadir que, por el momento, no se ha podido encontrar ninguna regularidad. Las cifras de pi, ese número tan ligado a la naturaleza, son, por lo que parece, completamente aleatorias.

Este es un blog sobre ciencia, tecnología y, bueno, cualquier cosa curiosa en general. Pero, más específicamente, es un blog sobre programación. Así que hablemos sobre algoritmos para calcular pi.
Un algoritmo (uno de los posibles) para calcular pi es el de Gauss-Legendre, que funciona así:

  1. Establecer los valores iniciales: a = 1, b = 1/raiz(2), t = 1/4, p = 1
  2. x = (a+b) / 2
  3. y = raiz(a * b)
  4. t = t – p * (a – x) ^ 2
  5. a = x
  6. b = y
  7. p = 2 * p
  8. repetir desde el paso 2 el número de iteraciones que se desee
  9. pi = ((a + b) ^ 2) / (4 * t)

No es el único algoritmo, ni el más eficiente. Si quieren saber de donde sale esa lista de instrucciones aparentemente sin sentido, aquí tienen una buena exposición de un algoritmo para calcular pi (es un algoritmo distinto, pero como ejemplo es perfecto). Los programas realmente rápidos para calcular decimales de pi, lo hacen en base 2 o en base 16. No se conoce ningún algoritmo eficiente en base 10.

Eso es en teoría. La pregunta es si funcionará una implementación en C del algoritmo de Gauss-Legendre. Ésta, por ejemplo, realizada con una función que recibe como parámetro el número de iteraciones que se desean realizar y devuelve el valor de pi aproximado:

double calcular_pi(int limit)
{
  int n;
  double a, b, t, p, x, y, pi;
  a = 1;
  b = 1 / sqrt(2);
  t = 0.25;
  p = 1;
  x = 0;
  y = 0;
  for (n = 1; n <= limit; n++)
  {
     x = (a + b) / 2;
     y = sqrt(a * b);
     t = t - p * ((a - x) * (a - x));
     a = x;
     b = y;
     p = 2 * p;
  }

  pi = ((a + b) * (a + b)) / (4 * t);
  return pi;
}

Si se han tomado la molestia de probarlo verán que sí. Funciona. Por supuesto, la precisión que se puede alcanzar con este algoritmo está directamente relacionada con la precisión del tipo de datos double. En concreto, calcula con exactitud los primeros 14 decimales en sólo tres iteraciones. Más allá de eso, el tipo double no es adecuado, por lo que se haría necesario implementar un tipo de datos completamente nuevo (por medio de vectores o alguna otra estructura subyacente) para alcanzar mayor precisión.

O, como dice un viejo chiste de informáticos, podemos implementar la función de forma mucho más simple así:

double calcular_pi()
{
   return 3.141592;
}

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

Nuestro recorrido por algunas librerías no estándar para mejorar la interfaz de nuestros programas en C nos ha llevado por Ncurses y SDL. Vamos a completar el viaje haciendo una breve visita a conio, un clásico entre las librerías para la consola de MS-DOS.

Las funciones de conio (CONsole Input Output) permiten, como las de Ncurses, cambiar el color del texto y del fondo, mostrar caracteres en cualquier posición de la consola, leer datos de entrada sin necesidad de pulsar intro, y un montón de cosas más. Eso sí, es bastante más restrictiva que Ncurses en otros aspectos, como la definición y manipulación de ventanas.

Conio es una librería no estándar. Estaba disponible en la mayor parte de los compiladores de C para entornos MS-DOS y Windows 3.x, pero había diferencias sustanciales entre unas implementaciones y otras, precisamente debido a que la librería no es estándar.

Una de las versiones que más éxito tuvo fue la de los compiladores de Borland (como Turbo C). De hecho, adquirió tanta popularidad que, en la actualidad, existen versiones que la emulan en otros entornos. Así, para el compilador Dev-C++ también existe una emulación (cortesía de C Con Clase) que funciona correctamente en el intérprete de comandos de Windows XP. Incluso existen algunas emulaciones para Linux, que facilitan la portabilidad de Windows a Linux, aunque lo más recomendable en estos sistemas es utilizar Ncurses.

Instalación y enlace de conio con Dev-C++

Como la librería no es estándar, tiene que instalarla para el compilador Dev-C++ siguiendo estos pasos:

  • Bájese los archivos conio.h y libconio.a
  • Busque la carpeta donde tenga instalado el compilador Dev-C++. Dentro de ella habrá varios subdirectorios:
    • En el subdirectorio “include”, copie el archivo conio.h
    • En el subdirectorio “lib”, copie el archivo libconio.a

Con esto, la librería queda instalada. Para utilizarla en sus programas, debe crear un proyecto en Dev-C++ siguiendo estos pasos:

  • Seleccione el menú “Archivo”, opcion “Nuevo proyecto”.
  • Elija “Consola” como tipo de proyecto.
  • Asígnele un nombre al proyecto y guárdelo en su carpeta de trabajo. Se creará automáticamente un archivo llamado main.c con una función main() vacía. Puede borrar su contenido y escribir su código en ese su lugar, o bien eliminar el archivo del proyecto y agregar el suyo.
  • Por último, abra el menú “Proyecto”, “Opciones de proyecto”, y busque la ficha “Parámetros”. Pulse el botón “Añadir biblioteca” y seleccione la librería “libconio.a” que acaba de copiar en tu disco duro.

Haciendo esto, ya podrá utilizar las funciones de “conio” en su programa, siempre que añada un #include <conio.h> al principio, claro.

Funciones importantes de conio

A continuación se resumen las funciones más relevantes de la librería conio. Puede encontrar una referencia completa en http://c.conclase.net/Borland.

gotoxy (columna, fila)

Sitúa el cursor en la columna y fila especificada. Por ejemplo, esta instrucción:

gotoxy (5, 2);

…sitúa el cursor en la fila 2, columna 5 de la pantalla. La siguiente instrucción de escritura en consola comenzará a escribir a partir de esas coordenadas.

cprintf() y cscanf()

Son las equivalentes a printf() y scanf(). Su sintaxis es la misma, y es recomendable usarlas en lugar de las funciones estándar para evitar funcionamientos extraños.

textcolor(color)

Cambia el color del texto. Los colores predefinidos son: BLACK, BLUE, RED, GREEN, CYAN, MAGENTA, BROWN, DARKGRAY. Además, existen las variedades “claras” de estos colores: LIGHTBLUE, LIGHTRED, LIGHTGREEN, etc.

Así, si ejecutamos:

textcolor (LIGHTRED);

…el texto que se escriba a continuación aparecerá el color rojo intenso.

textbackground (color)

Establece el color del fondo del texto. Los colores predefinidos son los mismos que para textcolor(). Así, este código:

textbackground (BLUE);

…hace que el texto que se escriba a continuación aparezca con el fondo en color azul oscuro.

getch ()

Lee un carácter desde el teclado, sin mostrar el eco y sin necesidad de pulsar Return. Devuelve el código ASCII del carácter tecleado. Ahí va un ejemplo:

char c;
c = getch();

clrscr ()

Borra la pantalla. No necesita argumentos.

En el post anterior hicimos una somera introducción al lenguaje Pascal. Ahora que conocemos lo básico, podemos empezar a trabajar con el entorno de programación de Delphi.

Delphi es un entorno de desarrollo visual para la programación de aplicaciones en entornos Windows y Linux (aunque la versión para Linux dejó de mantenerse hace algunos años). Creado inicialmente por Borland, famosa por la calidad de sus entornos de desarrollo, ahora lo mantiene y distribuye lsu filial CodeGear.

Procedimientos en Delphi

Delphi, como todos los compiladores de Pascal, permite definir funciones y procedimientos según la sintaxis convencional. Lo que distingue a Delphi de otros compiladores de Pascal es que, en general, todo el código se escribe dentro de los procedimientos.

Así, por ejemplo, para escribir las instrucciones que hay que ejecutar cuando se pulse un botón en la pantalla del programa, Delphi crea automáticamente un procedimiento de esta guisa:

procedure Button1Click;
begin
end;

…donde “button1″ es el nombre del botón. Todos los objetos de la ventana deben tener un nombre, y ese es el nombre por defecto de los botones. Es decir, Delphi crea un procedimiento vacío, preparado para que nosotros lo rellenemos con las instrucciones que corresponda, que se ejecutará cuando se haga clic en el botón llamado button1. Las instrucciones que hay que poner dentro de ese procedimiento, entre el “begin” y el “end”, son instrucciones convencionales en lenguaje Pascal: asignaciones, bucles, condiciones, etc.

Esto es lo que a veces llama programación dirigida por eventos: hasta que no ocurre un evento (como hacer clic en el button1), el código no se ejecuta. Es típico de los programas visuales programados para funcionar en entornos gráficos.

El diseño del interfaz

Cuando inicie Delphi, verá una ventana con varios paneles y montañas de botones (ver imagen más abajo). No se deje intimidar. Realmente, necesita saber muy poco para empezar.

Los elementos más importantes son:

  • El formulario o ventana del programa (etiquetado como Form1), donde colocaremos los componentes del interfaz del programa: botones, cuadros de texto, etc.
  • Los controles, situados en la barra de herramientas de la derecha (ver imagen). Aquí encontrará los elementos que puede ubicar en la ventana (Form1).
  • El botón Play (compilar y ejecutar)
  • La lista de propiedades del componente. Es un panel situado abajo y a la izquierda. Si selecciona un elemento de su Form1, en este panel se listarán todas sus propiedades: tamaño, color, ubicación, nombre y otro millón de cosas. Puede cambiar cualquier valor.

Ejemplo 1 – Cálculo del área de un cuadrado

¡Basta de teoría! Vamos a crear nuestros primeros programas con Delphi.

Supondremos que estamos utilizando un compilador de Delphi y que hemos diseñado una ventana como esta, con dos cuadros de texto y un botón (que encontrará en la barra de componentes), para escribir los datos de entrada y ver los resultados:

Seleccione el primer cuadro de texto y vaya al panel de propiedades. Allí, busque la propiedad Name y cámbiela, escribiendo entrada.

Haga lo mismo con el segundo cuadro de texto, y llámelo salida. Por último, repita la acción con el botón, y llámelo calcular.

Ahora haga doble clic sobre el botón y… ¡magia! Se abre un editor de texto con este aspecto:

procedure TForm1.CalcularClick(Sender: TObject);
begin
end;

Es un procedimiento de Pascal, ya lo habrá indentificado. Pero está vacío. Delphi lo ha preparado para usted, que sólo debe rellenarlo. Lo más importante que debe entender en este punto es que el código que escriba aquí será ejecutado cuando se haga clic sobre el botón Calcular.

El tamaño del lado del cuadrado lo tenemos en la propiedad text del objeto entrada. Eso se escribe simplificadamente así: entrada.text. Luego al pulsar el botón Calcular se debería ejecutar este código Pascal:

procedure TForm1.CalcularClick(Sender: TObject);
var
    area:integer;
begin
    area:= entrada.text * entrada.text;
    salida.text := area;
end;

Lamentablemente, si intentamos hacer eso Delphi nos mandará a tomar viento a la farola. La razón es sencilla: la propiedad text de los cuadros de texto es una cadena de caracteres, y, por lo tanto, no se puede multiplicar ni asignarse a una variable de tipo integer como es area.

Pascal dispone de varias funciones para conversión de cadenas de caracteres a números (función StrToInt) y viceversa (función IntToStr). Las utilizamos para arreglar nuestro programa, que quedará como sigue. Fíjese en que los comentarios, en Pascal, se escriben entre llaves.

procedure TForm1.CalcularClick(Sender: TObject);
var
   lado:integer;
   area:integer;
   texto:string;
begin
     lado := StrToInt(entrada.text);    { Convierte el texto en un número y lo guarda en “lado”}
     area :=lado*lado;            { Calcula el área }
     texto := IntToStr(area);        { Convierte el área en una cadena de caracteres }
     salida.text := texto;            { Muestra el área en el cuadro de texto “salida” }
end;

Es algo corriente tener que convertir el texto de un cuadro de texto en una variable de tipo numérico, así que asegúrese de entender el proceso antes de continuar.

Pruebe ahora a pulsar el botón “Play” (compilar y ejecutar). El programa debería ponerse en marcha, o puede que el compilador le señale algún error sintáctico. Corríjalo y vuelva a intentarlo. Ya tiene su primer programa con interfaz gráfico funcionando. ¡Y con sólo ocho líneas de código!

Y aún podemos resumir el programa anterior del siguiente modo (observe detenidamente las diferencias):

procedure TForm1.CalcularClick(Sender: TObject);
var
   lado:integer;
begin
     lado := StrToInt(entrada.text);
     salida.text := IntToStr(lado * lado);
end;

Podemos resumirlo aún más (tal vez se haya dado cuenta), aunque nos quedará bastante farragoso. Fíjese en que las tres soluciones son correctas y, en realidad, idénticas. Esta última ni siquiera necesita variables:

procedure TForm1.CalcularClick(Sender: TObject);
begin
    salida.text := IntToStr(StrToInt(entrada.text) * StrToInt(entrada.text));
end;

Ejemplo 2 – La tabla de multiplicar

En este ejemplo hay que susitutir el cuadro de texto salida por otro tipo de control, ya que ahora la salida va a tener varias líneas (en concreto, 10, una por cada número de la tabla de multiplicar), y los cuadros de texto solo admiten una línea de texto.

El control más sencillo que sirve a nuestro propósito es uno de tipo memo, que está situado, en la barra de controles, justo al lado del cuadro de texto. A estos controles se les puede activar la barra de desplazamiento vertical, quedando la ventana como la de la figura:

Observe en el código que sigue el mecanismo que se utiliza en los controles de tipo memo para añadir líneas al texto.

procedure TForm1.CalcularClick(Sender: TObject);
var
   numero:integer;
   valor:integer;
   error:integer;
   texto:string;
   paso:integer;
begin
     numero := StrToInt(entrada.text);        { Convierte el texto de entrada en un número }
     paso:=1;                        { Inicializa “paso” al valor 1 }
     while (paso <= 10) do                { Condición del bucle }
     begin
          valor := numero*paso;            { Calcula el producto }
          texto := IntToStr(valor);        { Convierte el producto a una cadena de caracters }
          salida.lines.add(texto);        { Añade esa cadena al final del control memo }
          paso:=paso+1;                { Incrementa “paso” en una unidad }
     end;
end;

Y ahora, ¿qué?

Si me ha seguido usted hasta aquí en los dos posts dedicados a Delphi (que, a pesar de su longitud, sólo han sido una breve introducción), habrá conseguido meter la cabeza en el mundo de la programación visual. Para ir más allá y terminar por dominar Delphi o cualquier otro entorno semejante necesitará aprender tres cosas:

  1. Manejar con la mayor soltura posible el lenguaje de programación (Pascal, en este caso)
  2. Aprender a manipular otras utilidades del entorno de programación. En particular, el depurador puede llegar a ser imprescindible.
  3. Conocer los distintos controles de los que dispone. La vida no se acaba en los botones, cuadros de texto y campos memo. Hay todo un mundo de controles, algunos muy elaborados, esperándole en su entorno Delphi: cuadros de diálogo de todo tipo, barras de progreso, temporizadores, controladores para acceso a bases de datos, y muchos más.

Ánimo. El límite es su imaginación.

Este artículo es, en realidad, una introducción a la programación en Delphi y otros entornos visuales. Pero, como me estaba quedando un poco voluminoso, he decidido dividirlo en dos entregas. Primero revisaremos el lenguaje de programación Pascal, y en el próximo artículo nos dedicaremos al entorno de programación Delphi.

Entornos de programación visuales

Delphi es un entorno de desarrollo de programas para Windows y GNU/Linux. Es lo que suele denominarse un entorno de desarrollo “visual”, donde el interfaz de usuario del programa se diseña con unos cuantos clicks de ratón. Aunque en estos artículos vamos a introducirnos en el manejo de Delphi, todo lo que digamos puede ser aplicado a otros entornos visuales, como los de Visual Basic o C++Builder, cambiando simplemente el lenguaje de programación subyacente.

Supondremos que usted está familiarizado con los fundamentos de la programación estructurada. Si no es así, puede empezar leyendo el primer capítulo del Curso de Programación en C, o bien cualquiera de las miles de intoducciones a la programación que encontrará en Internet.

El lenguaje Pascal

Delphi utiliza, como lenguaje de programación, una variedad de Pascal llamado Object Pascal. Pascal es un lenguaje de alto nivel creado en 1970 por el matemático suizo Niklaus Wirth. Su nombre se debe al matemático francés Blaise Pascal, que fue el inventor de la primera calculadora mecánica.

Pascal es un lenguaje muy utilizado para la enseñanza, ya que, debido a ciertas peculiaridades, es más fácil de aprender que otros lenguajes de alto nivel sin que esto le reste en absoluto potencia ni funcionalidad. Como cualquier otro lenguaje de programación, Pascal tiene una sintaxis muy estricta, que obliga a escribir las instrucciones de una forma muy determinada o, en caso contrario, el ordenador no las comprenderá.

Variables. Tipos de datos simples.

En Pascal es imprescindible declarar las variables antes de usarlas. La declaración consiste en comunicar al compilador cuál es el nombre de la variable y cuál el tipo de los datos que esa variable va a contener. Existen muchos tipos de datos, pero los principales tipos simples son:

  • integer: números enteros (p.ej: 1, 2, 3, -1, -2, -5, 0, 837, -2349)
  • real: números reales (p.ej: 1.23, 43.550, 0.1, 2.0, -4.54)
  • char: caracteres; se encierran entre comillas simples (ej: ‘a’, ‘b’, ‘z’, ‘A’, ‘J’, ‘%’, ‘$’, ‘1’, ‘5’)
  • bool: valores lógicos, es decir, verdadero (true) o falso (false)

La sintaxis para declarar las variables en Pascal es ésta:

var
   nombre_de_variable : tipo_de_datos;

Por ejemplo:

var
   contador:integer;
   respuesta:char;
   raiz:real;

(Observe que basta con escribir “var” una sola vez, al principio de la zona de declaraciones)

Asignaciones

Escribir un dato en la variable, es decir, asignar un valor a la variable, es una operación que en Pascal se escribe:

nombre_de_variable := expresión;

Una expresión es una combinación de números, variables, operadores matemáticos y otros elementos que veremos más adelante. Las expresiones se evalúan y su resultado es almacenado en la posición de memoria asociada a la variable. Por ejemplo, estas son algunas sentencias de asignación:

contador := 5;
contador := contador + 1;

Tipos de datos complejos: los arrays

Para ver qué es un array, puede consultar este post. En Pascal se declaran así:

<variable> = array[<límite_inferior>..<límite_superior>] of <tipo_base>;

Por ejemplo:

var
    lista: array [1..5] of integer;

Al declarar así una variable, se crean en realidad 5 variables de tipo entero, todas con el mismo nombre, lista. Para acceder a cada uno de las cinco elementos, hay que escribir entre corchetes el índice del elemento. Por ejemplo, para asignar el valor 80 al tercer elemento, escribiríamos esto:

lista[3] := 80;

Como es lógico, en Pascal también existen otros tipos complejos de datos, así como tipos definidos por el usuario. Pero recuerde: esta usted leyendo sólo una breve introducción al lenguaje.

Estructuras de control

Pascal cuenta con las instrucciones típicas de la programación estructurada, como no podía ser de otra manera. Las resumimos a continuación:

Inicio y fin de un bloque de instrucciones:

begin
    acciones;
end;

Asignación:

Variable : = valor;

Condicional:

if (condición) then
  begin
       acciones_sí;
  end;
else
  begin
       acciones_no;
  end;

Bucle “mientras”:

while condición do
  begin
    acciones;
  end;

Bucle “repetir”:

repeat
  begin
    acciones;
  end
until condición;

Bucle “para”:

for variable:=valor_inicial to|downto valor_final do
begin
     acciones
end;

Observe que hemos omitido las instrucciones de entrada y salida de datos (que sin readln y writeln, por si tiene usted curiosidad). Es porque no las vamos a utilizar. Cuando se utiliza un entorno de programación visual, la entrada y salida de datos se vuelve algo más complicada de lo habitual, ya que se efectúan a través de objetos de la ventana (cuadros de texto, botones, etc.), que tienen sus propios mecanismos de comunicación de datos. No obstante, al final del artículo verá usted a writeln en acción en un pequeño ejemplo.

Otras peculiaridades de Pascal

Hay que señalar varias características más de Pascal que son importantes:

  1. Todas las instrucciones acaban en un “;”. Hay varias excepciones. La principal: la instrucción que figura justo antes de un “end” no necesita llevar punto y coma.
  2. El último “end” del programa se escribe con un punto (“end.”)
  3. Cualquier bloque de más de una instrucción debe encerrarse entre un “begin” y un “end”. Por ejemplo, si un bucle while contiene dos ó más instrucciones en su bloque “acciones”, hay que colocar un “begin” antes de la primera instrucción y un “end” después de la última.
  4. Los operadores aritméticos básicos en Pascal son: + (suma), – (resta), * (producto) y / (cociente)
  5. Los operadores relacionales son los habituales: =, >, <, >=, <=, etc. El operador “distinto” se escribe <>.
  6. Los operadores lógicos son “and”, “or” y “not”, escritos así, como suenan. El resultado que devuelven es true o false.

Funciones y procedimientos

Una función, como bien sabrá, es un fragmento del código del programa que se encuentra separado del resto y que realiza una tarea concreta, pudiendo ser invocada en cualquier momento.

Por ejemplo: la función cos es una función predefinida en el lenguaje Pascal que permite calcular el coseno de un ángulo. Se utiliza escribiendo su nombre y, entre paréntesis, el valor del ángulo:

coseno := cos(15);

Con esta instrucción en Pascal estamos asignando el coseno del ángulo 15 a la variable “coseno”.

Muchas funciones están predefinidas en Pascal. Otras, en cambio, no lo están, sino que se encuentran en colecciones de funciones, llamadas librerías, suministradas por otros fabricantes de software. En realidad, nosotros mismos podemos crear nuestras librerías con funciones programadas por nosotros y que utilicemos con frecuencia.

Cada programador puede crear sus propias funciones. Las funciones más sencillas son las que realizan una serie de acciones y luego terminan, volviendo al punto del programa desde el que fueron llamadas. Estas funciones, que, en principio, no devuelven ningún valor, se llaman procedimientos.

Los procedimientos se definen así:

procedure <nombre_de_procedimiento>(<argumentos>);
begin
    <lista_de_instrucciones>
end;

Por ejemplo:

procedure resta(a:integer; b:integer);
var
  resultado: integer;
begin
     resultado:=a-b;
end;

Las funciones son similares, solo que devuelven un valor al lugar desde el que fueron invocadas; esto significa que una función puede (y debe) utilizarse en la parte derecha de una asignación (como, por ejemplo, la función cos que acabamos de ver en otro ejemplo)

Las funciones se declaran así:

function <nombre> (<argumentos>): <tipo_que_devuelve>;
begin
    <lista_de_instrucciones>
    <nombre_de_función> := <valor_que_devuelve>;
end;

Estructura de un programa en Pascal

Los programas en Pascal comienzan con la palabra program seguida del nombre del programa. Siguen las declaraciones de variables. A continuación, las funciones y, por último, entre su begin y su end correspondientes, el código del programa principal. Recuerde que ese último end termina con un punto (“.”), no con un punto y coma (“;”)

Se verá mejor con un ejemplo. Se trata del mismo ejemplo de antes, pero implementado con funciones. Observe cómo el código de la función se inserta dentro del programa principal. Observe también el uso de writeln, el equivalente en Pascal al printf() de C. La función para entrada de datos, por cierto, es readln. Perdone que no hagamos mucho énfasis en ellas, pero sucede que, en Delphi, no las vamos a utilizar, como ya hemos comentado antes.

program prueba_funcion;
var
    x,y,resultado:integer;
function resta(x:integer; y:integer): integer;
begin
    resta := x-y;
end;
begin
    x:=13;
    y:=5;
    resultado := resta(x,y);
    writeln(resultado);        { resultado vale 8 }
    resultado := resta(y,x);
    writeln(resultado);        { resultado vale –8 }
end.

Con esto debería ser suficiente para poder hacer nuestros primeros programas en Pascal y, por extensión, en Delphi. En el próximo post nos dedicaremos a crear programas gráficos; ya verán que sencillo resulta. Si quiere ir practicando con Pascal, busque un compilador libre (como Dev-Pascal, hermano de Dev-C++, o gpc, si es usted linuxero) y póngase manos a la obra.

(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.

(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.

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

La edición del código (o, como se dice a veces, “picar código”) es un acto cotidiano para el programador que no está exento de sus pequeños rituales (propios de cada uno) y de buenas costumbres (generales a todo el mundo). A continuación hablaremos de esas costumbres; los rituales, que se los busque cada cual. Pero antes, permítanme insistir una vez más: nunca empiecen a picar código sin haber planificado cuidadosamente su trabajo. Proceder de ese modo es la forma más segura de tener que tirar a la basura varios días de trabajo. Revisen este post si no saben de lo que hablo.

Escogiendo un editor de texto

A continuación nos referiremos al lenguaje C, pero todo lo que digamos puede aplicarse a la edición de código en cualquier otro lenguaje.

Para escribir el código nos puede servir cualquier procesador de textos que permita guardar el documento en forma de texto plano (sin códigos de control y formato propios de los procesadores avanzados, como Word o Writer). Existen multitud de procesadores de texto estupendos para programar en lenguaje C, pero el bloc de notas de Windows, definitivamente, no es uno de ellos (en realidad, no me imagino ninguna situación real en la que el bloc de notas pueda ser una herramienta estupenda).

La ventaja de estos procesadores es que resaltan, en diferentes colores y tipografías, las palabras clave, las funciones, las cadenas, los comentarios, etc, haciendo de este modo mucho más legible el código fuente. Algunos también revisan el equilibrado de los paréntesis y otros elementos emparejados. Ahí van algunos ejemplos de editores que son ligeros, útiles y soportan varios lenguajes: LopeEdit o UltraEdit (para Windows), gedit (para GNU/Linux con Gnome) o kate (para GNU/Linux con KDE). Y, por supuesto, el venerable emacs, que cuenta con una legión de incondicionales y anda por la versión 22 (!) cuando se escriben estas líneas.

Además, es habitual que los compiladores de C estén incrustados en un entorno de desarrollo más grande, que incluye también un editor. Por ejemplo, los compiladores de Borland (como Turbo C/C++, Borland C/C++ o C++ Builder) poseen un entorno integrado de desarrollo, que es un programa donde se unen el editor de texto, el compilador y el depurador en una sola aplicación controlada por un único interfaz, lo cual facilita mucho el trabajo. Otro entorno de desarrollo para Windows es Dev-CPP (éste, software libre). En GNU/Linux existen diferentes entornos integrados multilenguaje, todos con su correspondiente editor de texto, como Eclipse, KDevelop o Anjuta. En este artículo hablamos de cómo se usan este tipo de entornos. Ahora, sólo nos interesa el editor.

Recomendaciones para la edición del código

No están todas las que son, pero, como suele decirse, sí son todas las que están:

  • No empiece a teclear código sin haber entendido bien el problema que se le plantea. Si éste es complejo, es imprescindible elaborar antes una descomposición modular en papel, resolviendo los módulos con pseudocódigo o con diagramas de flujo.
  • Recuerde: comenzar a teclear a lo loco y sin pensar antes la solución detenidamente es la manera más segura de tardar el mayor tiempo posible en desarrollar un programa que, además, no funcione bien.
  • Realice un diseño modular previo del programa. Recuerde que un módulo de más de 30 ó 40 líneas (aproximadamente) empieza a ser demasiado largo.
  • Evite las variables globales. Evite las instrucciones GOTO. Realmente, no hay ninguna razón para usarlas, y más aún si ha diseñado correctamente el programa.
  • Elija bien el nombre de los identificadores (variables, constantes, funciones…), le ahorrará muchos quebraderos de cabeza. No llame a su variable e si puede llamarla edad, pero tampoco la llame edad_del_jugador_1 si no es absolutamente imprescindible. Los identificadores deben ser significativos pero no excesivamente largos.
  • Use la identación del texto, es decir, deje las sangrías necesarias para facilitar su lectura.
  • Use espacios y líneas en blanco siempre que considere que facilita la lectura. Es mucho más fácil de leer if (x > 5 + c) que if(x>5+c).
  • Desarrolle su propio estilo de escritura, siempre que sea razonable, y sígalo en todo el programa. Si está colaborando con otros programadores, use el estilo que hayan conveniado entre todos.
  • Sea generoso documentando el código fuente. Mejor que sobren comentarios que no que falten. Ante la duda, comente. En particular, es importante comentar cada función: qué hace, qué parámetros recibe, qué significan los valores de los parámetros y qué valores devuelve.
  • Si está programando en C, guarde el código fuente en archivos de texto cuya extensión sea “.c” (por ejemplo: “ejercicio.c”). Fíjese que “.c” no es lo mismo que “.cpp”

Cómo NO se debe programar

No me resisto a mencionar un desternillante documento de la Universidad de Oviedo, donde, con abundante sarcasmo y mala uva, el autor desgrana las malas costumbres más habituales de los estudiantes de programación a la hora de programar. El documento completo lo pueden encontrar aquí. No tiene desperdicio, y hasta los programadores más experimentados sacarán algo de él, o, al menos, esbozarán una sonrisa… aunque no le recomiendo su lectura si no tiene usted sentido del humor.

Les extracto algunas de sus desquiciadas recomendaciones:

  • Ignore los mensajes de error: los compiladores, los sistemas operativos, etc, emiten mensajes de error sólo para que los usen sus creadores, o para justificar sus sueldos.
  • Escriba el código directamente sin pensar: ¿Qué es lo que estamos construyendo? Un programa. ¿Qué es lo único imprescindible en un programa? El código. ¿Qué es lo que de verdad funciona? El código. No hay que perder ni un minuto en usar medios arcaicos como lápices, bolígrafos o papel.
  • Aunque el código no compile o no funcione, siga escribiendo: es sabido que los mensajes de error son una interrupción inadmisible, una traba estúpida a nuestro trabajo. ¿Qué puede hacer si tiene un error de compilación? Ya hemos visto que leérselo y comprenderlo no es una opción válida. Se puede intentar hacer algún cambio aleatorio en el código fuente, a ver si hay forma de engañar a ese estúpido compilador. Pero si eso no funciona, no pierda más tiempo. NO, no caiga en la tentación de leer el mensaje de error o intentar comprenderlo.
  • Construya enormes porciones de código sin compilar / ejecutar / probar: no compile con frecuencia; no dé pasos pequeñitos. Escriba miles de líneas de código, y ya después se compilará. Así será mucho más entretenido buscar los errores de compilación y arreglar el código, lo que constituye un excelente ejercicio.

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

Las funciones de librería ANSI C para manejar cadenas suelen empezar por las letras “str” (de “string”, que significa “cadena” en inglés) y utilizan el archivo de cabecera string.h. Hay multitud de funciones estándar, pero la mayor parte de las veces sólo se utilizan unas cuantas. Pasamos exponerlas esas funciones de uso frecuente a continuación.

gets() y puts()

Para leer por teclado una cadena de caracteres se puede utilizar también la función scanf() con la cadena de formato “%s”. Como las cadenas son vectores, no es preciso anteponer el símbolo & al nombre de la variable. Sin embargo, es preferible emplear la función gets() por estar específicamente diseñada para la lectura de cadenas. Por ejemplo:

char cadena[50];
printf("Introduzca su nombre ");
gets(cadena);

Tanto scanf() como gets() insertan automáticamente el carácter “” al final de la cadena.

De manera análoga podemos emplear la función printf() para escribir el contenido de una cadena en la pantalla, pero preferiremos la función puts(), específica de las cadenas. Por ejemplo:

char cadena[50] = "Hola, mundo";
puts(cadena);

strcpy()

Copia el contenido de una cadena en otra, incluyendo el carácter nulo. Su sintaxis es:

strcpy(cadena_origen, cadena_destino);

El siguiente ejemplo es otra versión (artificialmente enrevesada) del “hola, mundo”:

char cad1[50];
char cad2[50] = "Hola";
strcpy(cad1, cad2);
strcpy(cad2, "mundo");
printf("%s, %s", cad1, cad2);

strlen()

Devuelve la longitud de una cadena, es decir, el número de caracteres de que consta, sin contar el carácter nulo.

Por ejemplo, en este fragmento de código el resultado debe ser 11. Fíjate que la variable cadena tiene una longitud total de 50 caracteres, pero strlen() sólo cuenta los que efectivamente se están usando, es decir, los que hay hasta el carácter nulo:

char cadena[50] = "Hola, mundo";
int longitud;
longitud = strlen(cadena);
printf("La longitud es %i", longitud);

strcmp()

Compara dos cadenas. Devuelve el valor 0 si son iguales, un valor mayor que 0 si la primera es alfabéticamente mayor que la segunda, o un valor menor que 0 en caso contrario. Su sintaxis es general es:

strcmp(cadena1, cadena2);

Por ejemplo:

char cad1[50], cad2[50];
int comparacion;
printf("Introduzca dos cadenas");
scanf("%s %s", cad1, cad2);
comparacion = strcmp(cad1, cad2);
if (comparacion == 0)
   printf("Las dos cadenas son iguales");

strcat()

Concatena dos cadenas. Esta función añade la cadena2 al final de la cadena1, incluyendo el carácter nulo.

strcat(cadena1, cadena2);

El resultado de este ejemplo debe ser, otra vez, “hola, mundo”:

char cad1[50] = "Hola, ";
char cad2[50] = "mundo";
strcat(cad1, cad2);
prinft("%s", cad1);

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

Los vectores cuyos elementos son caracteres se denominan cadenas de caracteres o, simplemente, cadenas. Por lo tanto, una cadena de caracteres se declara así:

char cadena[50];        /* Cadena de 50 caracteres */

La cadenas son sin duda los vectores que más se utilizan y, por ese motivo, tienen ciertas peculiaridades que comentaremos en este apartado. Todo lo que hemos dicho hasta ahora sobre vectores es aplicable a las cadenas.

Declaración y manipulación de cadenas

Las cadenas pueden manipularse elemento por elemento, como cualquier vector. Por ejemplo:

char cadena[50];
cadena[0] = 'H';
cadena[1] = 'o';
cadena[2] = 'l';
cadena[3] = 'a';

Las cadenas deben tener, después de su último carácter válido, un carácter especial llamado nulo. Este carácter marca el final de la cadena. El carácter nulo se simboliza con el código . Por lo tanto, en el ejemplo anterior habría que agregar la siguiente línea para que la cadena estuviera completa:

cadena[4] = '';

Todas las cadenas deben terminar en un carácter nulo. De lo contrario, podemos tener problemas al imprimirlas en la pantalla o al realizar con ellas cualquier otro proceso. En consecuencia, en una cadena definida como la anterior, de 50 caracteres, en realidad sólo tienen cabida 49, ya que siempre hay que reservar una posición para el carácter nulo.

La declaración de una cadena puede ir acompañada de una inicialización mediante una constante. En este caso, la constante debe ir encerrada entre comillas dobles, al tratarse de una cadena y no de caracteres sueltos. Por ejemplo:

char cadena[50] = "Hola";

En inicializaciones de este tipo, el compilador se encarga de añadir el carácter nulo.

Por último, señalemos que no es necesario indicar el tamaño de la cadena si se inicializa al mismo tiempo que se declara. Por ejemplo, la declaración anterior puede sustituirse por esta otra:

char cadena[] = "Hola";

Esto se denomina array de longitud indeterminada. El compilador, al encontrar una declaración así, crea una cadena del tamaño suficiente para contener todos los caracteres. Esto vale no sólo para las cadenas, sino que también es aplicable a cualquier otro tipo de array que se inicialice al mismo tiempo que se declare.

Funciones estándar para manejar cadenas

Puede encontrar una referencia a las funciones