...para principiantes

You are currently browsing the archive for the ...para principiantes category.

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)

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)

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)

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.

(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 más habituales en este artículo.

Las cadenas y el control de los datos de entrada

Una de las principales fuentes de error de los programas son los datos de entrada incorrectos. Por ejemplo, si un programa está preparado para leer un número entero pero el usuario, por error o por mala fe, introduce un carácter, la función scanf() fallará y el programa, probablemente, se detendrá.

El programador tiene, por suerte, un modo de prevenir estos errores: leyendo todos los datos de entrada como cadenas y, luego, convirtiéndolos al tipo de dato adecuado.

Observe el siguiente ejemplo. Sirve para leer un número entero por teclado, pero previniendo los errores provocados por el usuario que antes mencionábamos. Se utiliza la función atoi(), que convierte una cadena a un número entero, y cuya sintaxis puedes encontrar en el tema 2 (en el apéndice dedicado a las funciones de uso frecuente de ANSI C).

int n;        // El número entero que se pretende leer por teclado
char cad[50];    // La cadena que se usará para prevenir errores de lectura
printf("Introduzca un número entero");
gets(cad);    // No se lee un número entero, sino una cadena
n = atoi(cad);    // Se convierte la cadena a entero

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

En la memoria del ordenador, todos los elementos de los vectores declarados en C se almacenan en posiciones de memoria consecutivas. El programador que tiene esto en mente puede resolver algunos de los errores típicos de ejecución en los programas que usan vectores (o, en general, arrays), así que es conveniente pensar en ello durante un rato y tratar de interiorizarlo.

Por ejemplo, si v1 es un vector de 10 números de tipo short int (suponiendo que cada número de dicho tipo ocupa 1 byte de memoria), el compilador asignará un espacio de memoria al elemento 0. Imaginemos que dicho espacio de memoria se ubica en la dirección 2000. Entonces, el resto de elementos del vector ocuparán la posición 2001, la 2002, la 2003, … hasta la 2009.

Por otro lado, si un vector v2 consta de 50 números de tipo int, y suponemos que los datos de este tipo ocupan 2 bytes, si el primer elemento tiene asignada la posición 2000, el siguiente estará en la posición 2002, el siguiente en la 2004, etc.

¿Qué ocurre si se intenta acceder a un elemento del vector más allá de su límite? Dicho de otro modo, si tenemos un vector de 10 elementos, ¿qué pasa si intentamos utilizar el elemento undécimo? Lógicamente, que estaremos invadiendo el espacio de direcciones que hay más allá del límite del vector: la dirección 2010 y siguientes en el caso del vector v1, y la 2020 y siguientes en el caso del vector v2. Esas direcciones pertenecerán a otras variables o, lo que es peor, a algún fragmento de código.

Si leemos información de ese espacio de direcciones, lo peor que puede ocurrir es que obtengamos basura.

Pero si escribimos información en ese espacio de direcciones, el efecto es impredecible: puede que alguna otra variable cambie misteriosamente de valor, puede que el programa se detenga en un error de ejecución o, directamente, se “cuelgue”, o, en el peor de los casos, puede que el sistema entero falle y haya que reiniciar la máquina. He visto cosas tan curiosas como que una función no regrese exactamente al punto desde el que se llamó, sino unas cuantas líneas más arriba o más abajo, todo por culpa de un array desbordado. Y el compilador de C, como es su obligación, no dice ni pío.

Moraleja: recuerde que el lenguaje C, a diferencia de otros, no comprueba los desbordamientos de los índices de los vectores y arrays. Eso es responsabilidad del programador. El motivo es fácil de entender: C está orientado a obtener un código ejecutable rápido y eficiente, y las comprobaciones de los índices de los arrays consumen tiempo, ya que hay que realizarlas en cada acceso al array.

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

Para pasar un vector como argumento a una función, en la llamada a la función se escribe simplemente el nombre del vector, sin índices. Esto sirve para pasar a la función la dirección de memoria donde se almacena el primer elemento del vector. Como C guarda todos los elementos de los vectores en posiciones de memoria consecutivas, conociendo la dirección del primer elemento es posible acceder a todas las demás.

El hecho de que a la función se le pase la dirección del vector y no sus valores provoca un efecto importante: que los arrays siempre se pasan por referencia, nunca por valor (si no sabe de lo que estamos hablando, puede repasar este post). Esto incluye a los vectores, que son arrays unidimensionales. Por lo tanto, si algún elemento del vector se modifica en una función, también será modificado en la función desde la que fue pasado.

Como siempre se pasan por referencia, no es necesario utilizar el símbolo & delante del parámetro. Por ejemplo, supongamos que serie es un vector de 15 números enteros. Para pasarlo como parámetro a una función llamada funcion1 escribiríamos simplemente esto:

int serie[15];
funcion1(serie);

En cuanto a la definición de la función, la declaración de un parámetro que en realidad es un vector se puede hacer de tres maneras diferentes:

void funcion1 (int sere[15]);  /* Array delimitado */
void funcion1 (int serie[]);   /* Array no delimitado */
void funcion1 (int *serie);    /* Puntero */

El resultado de las tres declaraciones es, en principio, idéntico, porque todas indican al compilador que se va a recibir la dirección de un vector de números enteros. En la práctica, sin embargo, las dos últimas pueden darnos problemas en algunos compiladores, así que preferiremos la primera declaración (la que utiliza un array delimitado)

Dentro de la función, el vector puede usarse del mismo modo que en el programa que la llama, es decir, no es preciso utilizar el operador asterisco.

Por ejemplo: Un programa que sirve para leer 50 números por teclado, y calcular la suma, la media y la desviación típica de todos los valores. La desviación es una magnitud estadística que se calcula restando cada valor del valor medio, y calculando la media de todas esas diferencias.

Observe el siguiente programa de ejemplo detenidamente, prestando sobre todo atención al uso de los vectores y a cómo se pasan como parámetros.

Los números de la serie se almacenarán en un vector float de 50 posiciones llamado valores. La introducción de datos en el vector se hace en la función introducir_valores(). No es necesario usar el símbolo & al llamar a la función, porque los vectores siempre se pasan por variable. Por lo tanto, al modificar el vector dentro de la función, también se modificará en el algoritmo principal.

Después, se invoca a tres funciones que calculan las tres magnitudes. El vector también se pasa por variable a estas funciones, ya que en C no hay modo de pasar un vector por valor.

#include <stdio.h>
#include <math.h>
int main(void)
{
    float valores[50];
    float suma, media, desviacion;
    introducir_valores(valores);
    suma = calcular_suma(valores);
    media = calcular_media(valores, suma);
    desviacion = calcular_desviacion(valores, media);
    printf("La suma es %f, la media es %f y la desviación es %f", suma, media, desviacion);
    return 0;
}
/* Lee 50 números y los almacena en el vector N pasado por variable */
void introducir_valores(float N[50])
{
    int i;
    for (i=1; i<=49; i++)
    {
        printf("Introduzca el valor nº %d: ", i);
        scanf("%f", &N[i]);
    }
}
/* Devuelve la suma todos los elementos del vector N */
float calcular_suma(float N[50])
{
    int i;
    float suma;
    suma = 0;
    for (i=1; i<=49; i++)
        suma = suma + N[i];
    return suma;
}
/* Devuelve el valor medio de los elementos del vector N. Necesita conocer la suma de los elementos para calcular la media */
float calcular_media(float N[50], float suma)
{
    int i;
    float media;
    media = suma / 50;
    return media;
}
/* Calcula la desviación típica de los elementos del vector N. Necesita conocer la media para hacer los cálculos */
float calcular_desviacion(float N[50], float media)
{
    int i;
    float diferencias;
    diferencias = 0;
    for (i=1; i<=49; i++)
        diferencias = diferencias + abs(N[i] – media) ;
    diferencias = diferencias / 50;
    return diferencias;
}

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

Ya tiene más de 30 años, lo cual es una eternidad en el universo informático, pero sigue tan lozano como el primer día. GNU/Linux y, de hecho, casi todos los sistemas operativos están programados en gran parte con él. Cientos de miles, tal vez millones, de programadores en todo el mundo lo conocen. Es el lenguaje C. ¿Pero quién es este tipo? ¿A qué se debe su longevidad?

Breve historia de C

Empecemos por el principio. En 1972, los laboratorios Bell necesitaban un nuevo sistema operativo. Hasta ese momento, la mayoría de los sistemas operativos estaban escritos en un lenguaje ensamblador ya que los lenguajes de alto nivel no generaban programas lo suficientemente rápidos. Pero los programas escritos en ensamblador son difíciles de mantener y Bell quería que su nuevo sistema operativo se pudiera mantener y modificar con facilidad. Por lo tanto, se decidieron a inventar un lenguaje de alto nivel nuevo con el que programar su sistema operativo. Este lenguaje debía cumplir dos requisitos: ser tan manejable como cualquier otro lenguaje de alto nivel (para que los programas fueran fáciles de mantener) y generar un código binario tan rápido como el escrito directamente en ensamblador.

Dennis Ritchie, un ingeniero de laboratorios Bell, diseñó el lenguaje C usando un ordenador DEC PDP-11. Se denominó C porque evolucionó de un lenguaje anterior llamado B, que se denominó B porque provenía de otro lenguaje llamado BCPL, que a su vez… Bueno, oigan, al principio de todo hubo una gran explosión, luego la materia se condensó y surgieron las galaxias y todo eso… El caso es que el lenguaje C pasó a convertirse y conocerse como “un lenguaje de programación de alto‑bajo nivel”, significando con esa frase que soporta todas las construcciones de programación de cualquier lenguaje de alto nivel, incluyendo construcciones de programación estructurada, pero también se compila en un código eficiente que corre casi tan rápidamente como un lenguaje ensamblador.

Los laboratorios Bell terminaron de construir su sistema operativo Unix y su lenguaje de programación por excelencia, C. El tándem C – Unix ha sido la referencia fundamental en el mundo de la programación desde entonces, y C se ha convertido en uno de los lenguajes de programación más populares y longevos de la historia de la informática. C creció en popularidad muy rápidamente y sigue siendo uno de los lenguajes fundamentales tanto en el mundo educativo como en el mundo profesional.

El lenguaje C como tal aparece descrito por primera vez en el libro “The C Programming Language” (Prentice-Hall, 1978), aunténtica biblia de la programación escrita por Brian Kerninghan y el propio Dennis Ritchie. El lenguaje se extendió rápidamente y surgieron diferentes implementaciones con ligeras diferencias entre sí hasta que el instituto de estándares americano (ANSI) formó un comité en 1983 para definir un estándar del lenguaje. El primer estándar ANSI C apareció en 1989 (C89) y fue revisado en 1999 (C99).

Una evolución de C fue el lenguaje C++, diseñado por Bjarne Stroustrup en los años 80. Además de todas las características del ANSI C, C++ incluye soporte para la orientación a objetos, una técnica de programación ligeramente diferente de la programación estructurada de la que ya hablaremos otro día. Y en el año 2000, Microsoft presentó el lenguaje C#, otra evolución de C++ orientada al desarrollo de aplicaciones para la plataforma .NET de esta compañía. Los dos lenguajes (C++ y C#) cuentan en la actualidad con sus respectivos estándares ISO.

En la actualidad son muchos los fabricantes de compiladores C, y todos cumplen con la norma ANSI C89 como mínimo, por lo que el código escrito para un compilador es altamente portable a otros.

Un lenguaje para programadores

Hace tiempo leí (en un libro sobre C de Herbert Schildt) la siguiente reflexión, que al principio puede resultar sorprendente:

Pero… ¿no son todos los lenguajes para programadores? La respuesta es sencillamente: no.

Analizando un poco más las razones del autor para tan rotunda negativa, se llega a la conclusión de que existen determinados lenguajes (algunos clásicos, como Basic, Cobol o Fortran, y otros más actuales, como Visual Basic) que han sido diseñados para permitir que los no programadores puedan leer y comprender los programas y, presumiblemente, aprender a escribir los suyos propios para resolver problemas sencillos.

Por el contrario, C fue creado, influenciado y probado en vivo por programadores profesionales. El resultado es que “C da al programador profesional lo que el programador profesional pide” (parafraseo de nuevo a Schildt). Es decir: C tiene pocas restricciones, pocas pegas, bloques de código independientes y un reducido (pero suficiente) conjunto de palabras clave. Si a esto unimos que el código objeto generado por C es casi tan eficiente como el ensamblador, se entenderá por qué lleva 30 años siendo el lenguaje más popular entre los programadores profesionales.

Ahora bien, C también tiene sus detractores que lo acusan de ser confuso, críptico y demasiado flexible. En efecto, con C se pueden desarrollar las técnicas de programación estructurada, pero también se puede programar “código espagueti”. Otro ejemplo: C++ es un lenguaje orientado a objetos que, sin embargo, permite saltarse a la torera todas las reglas de la orientación a objetos.

Un lenguaje estructurado y modular

C es un lenguaje estructurado porque contiene las estructuras de control básicas de las que hemos hablado con anterioridad. También permite romper las estructuras y escribir programas no estructurados, pero en este blog huiremos de ello como de la peste.

C es un lenguaje estrictamente modular. Todos los algoritmos se escriben en forma de funciones, incluido el algoritmo principal (cuya función siembre recibe el mismo nombre: main() ). En C no existen los procedimientos, pero se pueden escribir funciones que no devuelvan ningún valor, es decir, funciones que en realidad son procedimientos.

Características de C

Como resumen de esta introducción al lenguaje C, les propongo la siguiente lista de caracterísitcas definitorias de nuestro viejo amigo:

  • Es un lenguaje con muy pocas palabras clave.
  • Los operadores de C son más numerosos que en la mayoría de los lenguajes de programación anteriores y contemporáneos suyos.
  • Muchas de las sentencias de decisión y de bucles han servido de referencia para el diseño de todos los lenguajes creados en estos últimos años, de modo especial los populares Java, PHP y Visual Basic (no confundir Java con JavaScript: son dos lenguajes diferentes. Tampoco se debe confundir Visual Basic con el antiguo Basic)
  • C es un lenguaje muy eficiente, casi tanto como el ensamblador, por lo que es adecuado para desarrollar software en el que la velocidad de ejecución sea importante: sistemas operativos, sistemas en tiempo real, compiladores, software de comunicaciones, etc.
  • C es un lenguaje de nivel intermedio: soporta todas las estructuras de programación típicas de los lenguajes de alto nivel, pero también permite realizar tareas de bajo nivel (como manipulación de datos a nivel de bit, acceso directo a los registros del procesador, a los puertos de entrada/salida o a la memoria, etc.). Por eso es tan adecuado para la programación de sistemas.
  • C y Unix (o GNU/Linux) forman una pareja estable y fuertemente compenetrada.
  • C es altamente portable, más que otros lenguajes de alto nivel, ya que existen compiladores para lenguaje C estándar en todas las plataformas imaginables.
  • Es un lenguaje muy popular y, por lo tanto, existen multitud de librerías de funciones ya programadas que se pueden reutilizar, así como documentación abundante y muchos programadores profesionales que conocen el lenguaje.
  • C es más críptico que la mayoría de los otros lenguajes de programación de alto nivel. Su naturaleza críptica proviene de la enorme cantidad de operadores y un número pequeño de palabras clave o palabras reservadas. ¡El lenguaje C estándar tiene solamente 32 palabras reservadas!

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

Ya vimos en este artículo la definición y declaración de vectores. Ahora vamos a detenernos un momento en las operaciones básicas que pueden hacerse con estas estructuras de datos.

Manipulación de elementos individuales

Los vectores en C deben manipularse elemento a elemento. No se pueden modificar todos los elementos a la vez.

Para asignar valores a los elementos de un vector, por lo tanto, el mecanismo es este:

int serie[5];
serie[0] = 5;
serie[1] = 3;
serie[2] = 7;
...etc...

La inicialización de los valores de un vector también puede hacerse conjuntamente en el momento de declararlo, así:

int serie[5] = {5, 3, 7, 9, 14};

El resultado de esta declaración será un vector de 5 elementos de tipo entero a los que se les asigna estos valores:

  0   1   2   3    4
+---+---+---+---+----+
| 5 | 3 | 7 | 9 | 14 |
+---+---+---+---+----+

Cada elemento del vector es, a todos los efectos, una variable que puede usarse independientemente de los demás elementos. Así, por ejemplo, un elemento del vector serie puede usarse en una instrucción de salida igual que cualquier variable simple de tipo int:

int serie[5];
serie[0] = 21;
printf("%i", serie[0]);

Del mismo modo, pueden usarse elementos de vector en una instrucción de entrada. Por ejemplo:

int serie[5];
scanf("%i", &serie[0]);
serie[1] = serie[0] + 15;
printf("%i", serie[1]);

Recorrido de un vector

Una forma habitual de manipular un vector es accediendo secuencialmente a todos sus elementos, uno tras otro. Para ello, se utiliza un bucle con contador, de modo que la variable contador nos sirve como índice para acceder a cada uno de los elementos del vector.

Supongamos, por ejemplo, que tenemos un vector de 10 números enteros declarado como int v[10]; y una variable entera declarada como int i;. Por medio de un bucle, con ligeras modificaciones, podemos realizar todas estas operaciones:


a) Inicializar todos los elementos a un valor cualquiera
(por ejemplo, 0):

for (i = 0; i <= 9; i++)
{
   v[i] = 0;
}


b) Inicializar todos los elementos con valores introducidos por teclado:

for (i = 0; i <= 9; i++)
{
   printf("Escriba el valor del elemento nº %i: ", i);
   scanf("%i", &v[i]);
}


c) Mostrar todos los elementos en la pantalla:

for (i = 0; i <= 9; i++)
{
   printf("El elemento nº %i vale %i\n", i, v[i]);
}


d) Realizar alguna operación que implique a todos los elementos. Por ejemplo, sumarlos:

suma = 0;
for (i = 0; i <= 9; i++)
{
   suma = suma + v[i];
}

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

Un array (también llamado arreglo, sobre todo en latinoamérica) es una agrupación de muchos datos individuales del mismo tipo bajo el mismo nombre. Cada dato individual de un array es accesible mediante un índice.

El caso más simple de array es el array unidimensional, también llamado vector. Por ejemplo, un vector de números enteros es una colección de muchos números enteros a los que les adjudicamos un único identificador.

La declaración de un vector en C se hace así:

tipo_de_datos nombre_vector[número_de_elementos];

Por ejemplo:

int serie[5];

La variable serie será un vector que contendrá 5 números enteros. Los 5 números reciben el mismo nombre, es decir, serie. Se puede acceder a cada uno de los números que forman el vector escribiendo a continuación del nombre un número entre corchetes. Ese número se denomina índice. Observe el siguiente ejemplo:

int serie[5];
serie[2] = 20;
serie[3] = 15;
serie[4] = serie[2] + serie[3];
printf("%i", serie[4]);

El vector serie puede almacenar hasta 5 números enteros. En su posición 2 se almacena el número 20, y en su posición 3, el 15. Luego se suman ambos valores, y el resultado se almacena en la posición 4. Finalmente, se imprime en la pantalla el resultado de la suma, es decir, 35.

Es muy útil representar los vectores de forma gráfica para entenderlos mejor. El vector serie del ejemplo anterior se puede representar así:

Posiciones    0   1    2    3    4
            +---+---+----+----+----+
Valores     | ? | ? | 20 | 15 | 35 |
            +---+---+----+----+----+

Observe algo muy importante: el primer elemento del vector tiene el índice 0, es decir, el primer elemento es serie[0]. Como este vector tiene 5 elementos, el último será serie[4], no serie[5]. Observe también que los elementos 0 y 1 no han sido utilizados y, por lo tanto, tienen un valor desconocido, exactamente lo mismo que ocurre con cualquier variable de tipo simple que no se inicialice.

C no realiza comprobación de los índices de los arrays, por lo que es perfectamente posible utilizar un índice fuera del rango válido (por ejemplo, serie[7]). Es responsabilidad del programador evitar que esto ocurra, porque los efectos pueden ser desastrosos.

Como es lógico, se pueden construir vectores cuyos elementos sean de cualquier otro tipo simple, como float o double, con la única restricción de que todos los elementos sean del mismo tipo. Los vectores de caracteres se denominan cadenas de caracteres, y por sus especiales características los estudiaremos en un epígrafe posterior.

También es posible construir vectores cuyos elementos sean de un tipo complejo. Así, podemos tener vectores de vectores o de cualquier otro tipo.

En sucesivos posts iremos viendo las operaciones típicas con vectores:

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

Los tipos de datos vistos hasta ahora (enteros, reales, caracteres y lógicos) se denominan simples porque no pueden descomponerse en otros datos más simples aún.

Los tipos de datos complejos son aquellos que se componen de varios datos simples y, por lo tanto, pueden dividirse en partes más sencillas. A los tipos de datos complejos se les llama también estructuras de datos.

Las estructuras de datos pueden ser de dos tipos:

  • Estáticas: son aquéllas que ocupan un espacio determinado en la memoria del ordenador. Este espacio es invariable y lo especifica el programador durante la escritura del código fuente.

  • Dinámicas: sin aquéllas cuyo espacio ocupado en la memoria puede modificarse durante la ejecución del programa.

Las estructuras estáticas son mucho más sencillas de manipular que las dinámicas, y son suficientes para resolver la mayoría de los problemas. Las estructuras dinámicas, de manejo más difícil, permiten aprovechar mejor el espacio en memoria y tienen aplicaciones más específicas.

Además, se pueden mencionar como una clase de estructura de datos diferente las estructuras externas, entendiendo como tales aquéllas que no se almacenan en la memoria principal (RAM) del ordenador, sino en alguna memoria secundaria (típicamente, un disco duro). Las estructuras externas, que también podemos denominar archivos o ficheros, son en realidad estructuras dinámicas almacenadas en memoria secundaria.

En diferentes puntos del Curso de Programación en C nos dedicamos a estudiar con detalle los tres tipos de estructuras.

Cuando se acerca el final del curso de fundamentos de programación, siempre hacemos un escueto recorrido por las estructuras de datos complejas y dinámicas. Los alumnos asisten con gesto impávido a las abstrusas explicaciones sobre pilas, colas, árboles y otras especies vegetales, y en el fondo supongo que están pensando: ¿y esto para qué demonios sirve?

Sólo hay una razón, pero es una razón importante: la elección de una estructura de datos adecuada puede hacer que la solución de un problema difícil sea fácil. Y al revés. Así que seleccionar la estructura de datos más adecuada a cada problema es una de las primeras cosas (¡y de las más importantes!) que tiene que hacer un programador al enfrentarse a un nuevo programa.

Vamos a intentar explicarlo mejor.

Datos simples, estructuras simples

Estamos acostumbrados a usar en nuestra vida cotidiana estructuras de datos sin darnos cuenta. Nuestro cerebro es un complejo sistema de procesamiento de información, y la información se almacena y manipula en estructuras.

Los datos más simples necesitan estructuras simples. “Tengo 58 años” es un enunciado que transmite una determinada información que todos ustedes han comprendido sin esfuerzo. La información “viaja” transportada en un dato: 58. Es un dato simple de tipo número entero.

Puedo sustituir la frase anterior por “Tengo X años”, donde X es cualquier número entero. Entonces decimos que X es una variable de tipo entero, porque puede ser asignada a cualquier número entero. Así no tengo un solo enunciado, sino toda una colección de enunciados diferentes y válidos que responden al patrón genérico de “Tengo X años”

La campeona de las estructuras de datos: el vector

Cuando manipulamos conjuntos mayores de datos, disponemos de otras estructuras más complejas, como los vectores o las matrices. Un vector es una colección de elementos del mismo tipo. Cada elemento se identifica con un número llamado índice.

He aquí un vector de números enteros:

    +---+---+---+----+----+----+---+---+---+----+
v = | 5 | 7 | 2 | 23 | 18 | 19 | 7 | 5 | 3 | 19 |
    +---+---+---+----+----+----+---+---+---+----+

Una sola variable (v) es capaz de almacenar y manipular muchos números. v[1] será el primero de ellos (o v[0], dependiendo del lenguaje de programación que empleemos). v[2] será el segundo, etc.

“Tengo v[4] años” es un enunciado tan válido como “Tengo X años”. v[4] es una variable entera simple. v es una variable compleja: un vector de enteros.

Resolver determinados problemas es muchísimo más sencillo utilizando vectores que utilizando variables simples. Un ejemplo: un programa que genere una combinación válida para jugar a la lotería primitiva. Es decir, que genere seis números diferentes entre 1 y 49. He aquí un algoritmo:

1. i = 1
2. v[i] = un número al azar entre 1 y 49
3. Comprobar que ese número no se haya elegido ya,
   es decir, que v[i] no sea igual a ningún v[j],
   para cualquier valor de j menor que i
4. i = i +1
5. Repetir los pasos 2, 3 y 4 hasta que i > 6

Trate de imaginar cómo sería el algoritmo anterior utilizando seis variables independientes para los seis números, en lugar de un vector. Chungo, ¿a qué sí? En este caso, utilizar una estructura más simple provoca que el programa sea mucho más complejo.

Estructuras para todo

La elección de una estructura de datos adecuada es, como vemos, importantísima para resolver con éxito un problema. Por eso hay tantos tipos de estructuras. Los vectores y las matrices se adaptan muy bien a muchísimos problemas, pero para otros son completamente inadecuados. Y ahí es donde entran en juego las estructuras de datos exóticas: pilas, colas, árboles, grafos, etc.

¿Recuerdan el algoritmo para jugar a las tres en raya? La solución por fuerza bruta generaba el espacio de estados completo, que tenía estructura de árbol. Los árboles son imprescindibles en muchos problemas de inteligencia artificial o, en general, de búsqueda exhaustiva de soluciones.

Del mismo modo, las pilas, las colas y las otras estructuras se adaptan como guantes a determinados problemas tan dispares como, por ejemplo, los analizadores sintácticos o el reparto de recursos en el sistema operativo.

Cómo se programan y manejan estas estructuras es otra historia. Suelen usar memoria dinámica, para crecer y decrecer conforme cambien las necesidades del programa, lo cual es una complicación añadida. Pero de eso hablaremos en otro artículo.

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

Existen muchos compiladores de C/C++ en entorno Windows, siendo los más populares los de Microsoft (y sus diferentes versiones del compilador Visual C++) y Borland (tanto el Builder C++ como el Borland C/C++, bastante más antiguo). Estos compiladores suelen estar integrados en un IDE (Entorno Integrado de Desarrollo), de manera que bajo el mismo interfaz se puede controlar el editor, el compilador, el enlazador y el depurador, entre otros.

Los compiladores de C libres (como djgpp o gcc) suelen ser compiladores independientes, es decir, caracen de IDE. El programador debe encargarse de buscar un editor para escribir su código fuente y un depurador para corregir errores de ejecución. Esta es la forma clásica de trabajar en entornos Unix.

¿Existe algo parecido a los IDEs de Borland para Windows, pero con licencia de software libre? La respuesta es Dev-C++, un IDE desarrollado por Bloodshed Software bajo licencia GNU. Se trata de un entorno integrado para Windows que proporciona un compilador de C/C++ (Mingw, basado en gcc), un completo editor de código fuente y un depurador. A continuación proporcionamos un resumen de las opciones más útiles del IDE. Todo lo que se explique es fácilmente extensible a otros IDEs, incluidos los que funcionan bajo GNU/Linux.

Ya sé que alguien puede estar pensando: ¿y qué pasa con Eclipse? Bueno, pues es otro IDE perfectamente válido que ustedes pueden utilizar para desarrollar programas en C, entre otros lenguajes. Eclipse está orientado a la creación de otros IDEs y programas cliente del tipo BitTorrent o Azureus, pero es perfectamente posible escribir programas más convencionales. Eso sí, es un entorno más complejo que el de Dev-C++ y, quizás por eso, menos indicado para empezar a programar.

El IDE de Dev-C++

El Entorno Integrado de Desarrollo (IDE) de Dev-C++ tiene, a primera vista, es aspecto un editor de texto con una serie de menús adicionales. Efectivamente, el IDE incluye un editor de texto específico para programas en C, pero no se debe olvidar que el editor es sólo una de las aplicaciones integradas dentro del IDE.

Para acceder al compilador y al depurador existe un menú en la parte superior al estilo de cualquier programa habitual. Además, existen otros menús que permiten manejar los programas y proyectos con facilidad y rapidez.

Menú Archivo

Contiene las opciones para abrir y guardar los archivos fuente. Generalmente, los editores de C manejan archivos con las siguientes extensiones:

  • .C — Archivos fuente escritos en C
  • .CPP — Archivos fuente escritos en C++
  • .H — Archivos de cabecera (con prototipos de funciones y otras definiciones)
  • .HPP — Archivos de cabecera para programas en C++

También se pueden abrir y cerrar proyectos (ver más abajo el Menú “Proyecto”)

Menú Edición

Tiene las opciones típicas para facilitar la edición de programas, incluyendo las utilísimas funciones de Cortar, Copiar y Pegar que cualquier programador utilizará con frecuencia (pero, si se sorprende usted utilizándolas con demasiada frecuencia, debería encenderse su luz de alarma). Es muy recomendable que te aprenda los atajos de teclado de estas funciones si aún no los domina.

Menú Buscar

Contiene las opciones para buscar textos en el programa, reemplazarlos por otros, ir a cierta línea, etc.
Menú Ver: Tiene opciones para acceder a las distintas ventanas de información del depurador y del compilador.

Menú Proyecto

Con este menú se pueden manejar aplicaciones distribuidas en varios archivos fuente. A estas aplicaciones se les denomina proyectos. Desde el menú se pueden crear proyectos y agregarles los archivos implicados en el mismo, así como cambiar las propiedades del proyecto.

Menú Ejecutar

Desde aquí se accede al compilador. La opción Compilar produce la compilación y el enlace del código fuente activo en ese momento. Si se producen errores, se muestran en una ventana específica en la parte inferior de la ventana.

La opción Reconstruir todo recompila todos los archivos que formen parte del proyecto (lo cual puede llevar mucho tiempo si el proyecto es grande) y los vuelve a enlazar, mientras que la opción Compilar sólo compila el archivo activo y los que tengan dependencias con él

La opción Compilar y ejecutar es la más útil y permite ejecutar el programa tras la compilación. Si surgen errores, se muestran (sin ejecutar el programa, obviamente) en la ventana inferior.

Menú Debug (Depurar)

Desde aquí también se accede al depurador, que por su importancia explicaremos más abajo.

Menú Herramientas

Contiene multitud de opciones de configuración del compilador y del entorno de desarrollo. No vamos a explicarlas en este momento: las opciones del editor se aprenden mejor trasteando con ellas, y las del compilador son demasiado complejas para detenernos en ellas ahora.

Menús Ventana y Ayuda

Son similares a los de otras aplicaciones Windows. La mayor crítica que se le puede hacer a este IDE es que el sistema de ayuda en línea es bastante pobre, pero, teniendo un buen manual de referencia de C a mano, o una conexión a Internet, es un detalle de importancia menor.

El Depurador o Debugger

El acceso al depurador desde el IDE es tan sencillo como la invocación del compilador, ya que basta con activar la opción de menú correspondiente, o bien su atajo por teclado. Respecto a esto, debe acostumbrarse a utilizar los atajos de teclado del compilador porque así agilizará mucho el trabajo con las distintas herramientas.

Manejar el depurador es bastante simple y todas sus opciones están en el menú Depurar. Veamos las opciones más importantes:

  • Depurar: inicia la ejecución del programa en “modo de depuración”, de manera que se activan el resto de opciones de depuración.
  • Puntos de ruptura: Un punto de ruptura es un lugar donde la ejecución debe detenerse para iniciar la ejecución paso a paso. Podemos establecer puntos de ruptura en cualquier lugar del código fuente, y tantos como queramos. Luego ejecutaremos el programa con normalidad, y éste se detendrá cada vez que encuentre un Punto de Ruptura, pudiendo hacer a partir de ahí la depuración paso a paso.
  • Avanzar Paso a Paso: Sirve para ejecutar el programa instrucción a instrucción, comenzando por la primera línea de la función main(). Cada vez se ejecuta una única instrucción, que se va señalando en la pantalla, y luego la ejecución se detiene momentáneamente, permitiéndonos comprobar el estado de la entrada/salida, el contenido de las variables, etc. Pulsando sucesivamente la opción “Siguiente paso”, el programa se va ejecutando línea a línea hasta que termine. Al llegar a una llamada a una función, podemos optar por introducirnos dentro del código de la misma y ejecutarla también paso a paso (opción “Siguiente Paso”), o bien saltarla para no depurarla (opción “Saltar Paso”). Si optamos por lo primero, cuando la función termina, regresaremos al algoritmo que la invocó y continuaremos la ejecución paso a paso a partir de la siguiente instrucción.
  • Parar: Finaliza la depuración. Esto se puede usar en cualquier momento durante la depuración. Es muy útil si queremos volver a ejecutar el programa con el depurador desde el principio.
  • Ir a cursor: A veces, deseamos depurar una parte del programa muy específica y queremos evitar tener que ejecutar paso a paso todo el programa desde el principio hasta llegar a esa parte. Para esto podemos situar primero el cursor al principio del fragmento que deseamos depurar y luego usar “Ir a cursor”, con lo que conseguiremos que el programa se ejecute hasta la instrucción en la que se encuentre el cursor. Tras esto podemos usar la depuración paso a paso a partir de este punto. “Ir a cursor” puede ser usado en cualquier momento, incluso aunque la depuración ya haya comenzado.
  • Watches (Visualizaciones): Permite mostrar el valor de una variable o una expresión, para así ir comprobando cómo evoluciona tras la ejecución de cada instrucción. Al activar la ventana de Watches, podemos insertar en ella variables y expresiones (o eliminarlas). También podemos cambiar el valor de una variable en tiempo de ejecución.

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

La documentación no es exactamente una fase del desarrollo del software, sino una actividad que debe practicarse a lo largo de todo el desarrollo.

La documentación que debe haberse generado al terminar un producto software es de dos tipos:

  • La documentación externa la forman todos los documentos ajenos al programa: guías de instalación, guías de usuario, etc.
  • La documentación interna es la que acompaña al programa; básicamente, los comentarios.

La que más nos afecta a nosotros, como programadores, es la documentación interna, que debe elaborarse al mismo tiempo que el programa. Ya hablamos sobre los comentarios internos aquí. Pero también debemos conocer, aunque sea por encima, la documentación externa; a veces, porque el programador debe consultarla para realizar su trabajo; otras veces, porque debe colaborar en su elaboración o modificación. A ella dedicaremos este artículo.

El manual técnico

El manual técnico es un documento donde queda reflejado el diseño de la aplicación, la codificación de los módulos y las pruebas realizadas. Está destinado al personal técnico (analistas y programadores) y tiene el objeto de facilitar el desarrollo y el mantenimiento del software.
El manual técnico se compone de tres grupos de documentos:

  • El cuaderno de carga: es el conjunto de documentos donde se refleja el diseño de la aplicación a partir de la fase de análisis. Entronca, pues, con la fase de diseño del ciclo de vida. Está destinado a los programadores de la aplicación, que lo utilizarán para saber qué módulos tienen que codificar, qué función realiza cada uno y cómo se comunican con los otros módulos. Es un documento fundamental para permitir que varios programadores puedan trabajar en el mismo proyecto sin pisarse el trabajo unos a otros. Suele estar dividido en varias partes:
    • Tratamiento general: consiste en una descripción de las tareas que la aplicación tiene que llevar a cabo, una descripción del hardware y del software de las máquinas donde va a funcionar y una planificación del trabajo (tiempo de desarrollo, distribución de tareas, etc)
    • Diseño de datos: se trata de una especificación de los datos utilizados en la aplicación: descripciones detalladas de archivos, de tablas y relaciones (si se maneja una base de datos), etc.
    • Diseño de la entrada/salida: es una descripción del interfaz con el usuario. Se detallan las pantallas, los formularios, los impresos, los controles que se deben realizar sobre las entradas de datos, etc.
    • Diseño modular: consiste en una descripción de los módulos que conforman el programa y las relaciones entre ellos (quién llama a quién, en qué orden, y qué datos se pasan unos a otros). Se utilizan diagramas de estructura, que vimos en el tema 3, y descripciones de los módulos. También se debe indicar en qué archivo se almacenará cada módulo.
    • Diseño de programas: es una descripción detallada de cada uno de los programas y subprogramas de la aplicación. Puede hacerse, por ejemplo, con pseudocódigo.
  • El programa fuente: el código fuente completo también suele incluirse en la guía técnica, y debe ir autodocumentado, es decir, con comentarios dentro del código realizados por el programador.
  • Juego de pruebas: se trata de un documento en el que se detallan las pruebas que se han realizado a la aplicación o a partes de la misma. Las pruebas pueden ser de tres tipos: unitarias (se prueba un módulo por separado), de integración (se prueban varios módulos que se llaman unos a otros) y de sistema (pruebas de toda la aplicación). Se debe detallar en qué ha consistido la prueba, cuáles han sido los datos de entrada y qué resultado ha producido el programa.

El manual de usuario

Este es un documento destinado al usuario de la aplicación. La información del manual de usuario proviene del manual técnico, pero se presenta de forma comprensible para el usuario, centrándose sobre todo en los procesos de entrada/salida.

Debe estar redactado en un estilo claro, evitando en lo posible el uso de terminología técnica. En general, todo manual de usuario debe contar con estos apartados:

  1. Índice de los temas
  2. Forma de uso de la guía
  3. Especificaciones hardware y software del sistema donde se vaya a usar la aplicación
  4. Descripción general de la aplicación
  5. Forma de ejecutar la aplicación
  6. Orden en el que se desarrollan los procesos
  7. Descripción de las pantallas de entrada de datos
  8. Descripción de todas las pantallas y de la forma en que se pasa de una a otra
  9. Controles que se realizan sobre los datos y posibles mensajes de error
  10. Descripción de los informes impresos
  11. Ejemplos de uso
  12. Solución de problemas frecuentes durante el uso del programa
  13. Ayuda en línea
  14. Realización de copias de seguridad de los datos

La guía de instalación

Es un documento destinado a informar al usuario o al administrador del sistema sobre cómo poner en marcha la aplicación y cuáles son las normas de explotación. Debe contemplar todos los aspectos técnicos y logísticos de la instalación: requerimientos hardware, espacio necesario en el disco duro, configuraciones especiales de archivos o dispositivos, etc.

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

Depuración del código fuente

La depuración del programa consiste en localizar y corregir los errores que se hayan podido producir durante el desarrollo. El objetivo es conseguir un programa que funcione lo más correctamente posible, aunque hay que tener presente que ningún programa complejo está libre de errores al 100%

Los errores pueden ser de tres tipos: de compilación, de enlace y de ejecución.

Errores en tiempo de compilación

Se producen al traducir el código fuente a código objeto. El compilador los detecta y marca en qué línea se han producido, y de qué tipo son, por lo que son relativamente fáciles de corregir. Los errores de compilación más frecuentes son:

  • Errores sintácticos: escribir mal alguna instrucción o algún identificador, u olvidarnos del punto y coma que debe terminar cada instrucción.
  • Errores de tipos: intentar asignar a una variable de cierto tipo un valor de otro tipo incompatible, o invocar a una función con argumentos de tipo equivocado. Recuerde que C puede hacer conversiones de tipo automáticas, por lo que estos errores pueden quedar enmascarados.
  • Errores de identificadores no reconocidos: ocurren cuando se intenta utilizar una variable o una constante que no ha sido declarada, o cuyo ámbito no llega al lugar donde se intenta utilizar.

Además de los errores, el compilador puede dar avisos (warnings) en lugares donde potencialmente puede existir un error de compilación. Es conveniente revisar todos los avisos y tratar de corregirlos antes de continuar con la ejecución.

Errores en tiempo de enlace

Cuando el compilador termina la traducción se produce el enlace de todos los archivos objeto. En este momento se resuelven todas las llamadas a funciones, de modo que si alguna función no está presente en el conjunto de archivos objeto, el enlazador fallará y explicará la causa del error.

La causa más habitual de fallo del enlazador es la inconsistencia entre la definición de una función, la llamada a esa función y su prototipo. Por ejemplo, si tenemos una función cuyo prototipo es:

int prueba(char a, float x);

…y en la definición de la misma aparece definida como:

int prueba(char a)
{
    ... código de la función ...
}

… el enlazador fallará porque no encontrará el prototipo adecuado de la función prueba, ya que las listas de parámetros son diferentes. También puede fallar si en la invocación de la función cometemos un error semejante.

Otra causa de fallo del enlazador es el intento de uso de una función sin enlazar adecuadamente la librería. En la sección de librerías no estándar (SDL, ncurses, etc) del curso de programación en C puede encontrar información sobre cómo enlazar ésas u otras librerías adicionales.

Errores de tiempo de ejecución

Si la compilación y el enlace terminan sin novedad, se genera un archivo ejecutable. Es el momento de comprobar que el programa realmente hace lo que se espera que haga. Para ello hay que probarlo con diversos conjuntos de datos de entrada; la elaboración de estos juegos de pruebas es una técnica que excede nuestras pretensiones.

Los errores que surgen en tiempo de ejecución son los más complicados de corregir, ya que muchas veces no está clara la causa del error. En el peor de los casos, puede ser necesario rediseñar la aplicación por completo. Simplificando mucho, podemos encontrarnos con estos errores en tiempo de ejecución:

  • Errores lógicos. Se producen cuando alguna condición lógica está mal planteada. Entonces, el flujo del programa puede ir por la rama “si_no” cuando debería ir por la rama “si”, o puede salirse de un bucle cuando debería repetir una vez más, o entrar en un bucle infinito, etc.
  • Errores aritméticos. Ocurren cuando una variable se desborda (overflow), o se intenta una operación de división entre cero, o alguna operación aritmética está mal planteada.
  • Errores de punteros. Los punteros son herramientas muy potentes que permiten la manipulación dinámica de la memoria, pero también conllevan grandes riesgos porque un puntero “descontrolado” puede hacer auténticas locuras en la memoria del ordenador, hasta el punto de colgar sistemas poco fiables (como los sistemas Windows anteriores a XP)
  • Errores de conversión automática de tipos. Se producen cuando C realiza una conversión automática que no teníamos prevista. Entonces el dato puede cambiar y dar al traste con la lógica del programa.
  • Errores de diseño. Ocurren cuando el programa no está bien diseñado y realiza tareas diferentes de las que se pretendían. Son los peores errores, porque obligarán a modificar una parte (o la totalidad) del trabajo realizado, debiendo, en ocasiones, volver a las primeras fases del ciclo de vida para repetir todo el proceso.

Estos y otros errores en tiempo de ejecución pueden manifestarse con distintas frecuencias:

  • Siempre que se ejecuta el programa: son los más fáciles de localizar y corregir.
  • Solo cuando se introducen determinados datos de entrada: puede ser complicado dar con la secuencia de datos de entrada que provocan el error, pero una vez que la encontramos, puede localizarse con facilidad.
  • Al azar: algunas veces, los programas fallan sin motivo aparente, cuando han estado funcionando en el pasado con el mismo conjunto de datos. Son los errores más difíciles de localizar, porque ni siquiera se sabe bajo qué circunstancias ocurren.

El depurador

El depurador es un programa independiente del editor, el compilador y el enlazador. Suele estar integrado con los otros tres, de modo que desde el entorno de programación se puede lanzar cualquiera de los programas, pero también se puede usar por separado.

El depurador es una herramienta fundamental para localizar y corregir los errores en tiempo de ejecución de los que hablábamos más arriba. Cada depurador tiene sus propias opciones y características, pero todos suelen coincidir en varios aspectos:

  • Permiten ejecutar paso a paso cada instrucción del programa, deteniéndose antes de ejecutar la siguiente para permitirnos ver el estado de las variables o de los dispositivos de E/S.
  • Permiten ver y manipular el contenido de las variables en cualquier punto del programa.
  • Permiten ver y manipular la estructura de la memoria asignada al programa y de los registros del microprocesador.
  • Permiten insertar puntos de ruptura (breakpoints), es decir, puntos donde la ejecución se detendrá momentáneamente para que hagamos alguna comprobación de las anteriormente expuestas.
  • Haciendo correcto uso de estas posibilidades, podemos localizar rápidamente cualquier error en tiempo de ejecución y afrontar la tarea de corregirlo.

En este post existe información más detallada sobre el funcionamiento de los depuradores.

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

Compilación

El proceso de compilación consiste en que un programa, llamado compilador, traduzca el código fuente (en lenguaje C o cualquier otro lenguaje de alto nivel) a código binario. La compilación, por lo tanto, no es más que una traducción, como vimos con más detalle en este post.

El resultado de la compilación es el mismo programa traducido a código binario. Como el programa fuente estaba almacenado en un archivo con extensión .C, el compilador suele guardar el programa objeto en otro archivo con el mismo nombre y extensión .OBJ o, quizás, .O

Los programas cortos se guardan en un único archivo fuente que se traducirá a un único archivo objeto. Pero cuando los programas crecen, es habitual distribuir el código fuente en varios archivos con el objetivo de manipularlo mejor.

Los compiladores de C usan compilación separada. Esto significa que, si un programa largo está escrito en varios archivos fuente, no es necesario compilarlos todos cada vez que se modifica algo. Basta con volver a compilar el archivo modificado. Por eso, dividir un programa fuente largo en varios archivos más cortos también sirve para mejorar los tiempos de compilación.

Cuando tenemos varios archivos fuente es normal que existan dependencias entre ellos. Por ejemplo, supongamos que en un archivo A1 se utiliza (con la directiva #include) un archivo de cabecera A2. Si modificamos el archivo A2 es necesario volver a compilar el archivo A1, aunque A1 no haya sido modificado en absoluto. Se dice entonces que existe una dependencia entre los archivos A1 y A2.

Controlar las dependencias es un trabajo tedioso y propenso a errores. Por fortuna, los compiladores se encargan de controlarlas por sí mismos o con ayuda de alguna herramienta adicional. Así que no se extrañe si, al volver a compilar un archivo fuente después de modificarlo, se compilan automáticamente algunos otros archivos, aunque se hayan modificado. El control de las dependencias lo puede realizar el compilador de manera automática o semiautomática (mediante archivos de dependencias o makefiles escritos por el programador), como veremos en otro momento.

El funcionamiento de Dev-C++ se trata más detenidamente en este post, y, en este otro, el de gcc.

Enlace (link)

Cuando existen varios programas objeto es necesario combinarlos todos para dar lugar al programa ejecutable definitivo. Este proceso se denomina enlace.

El código objeto de las funciones de la librería estándar de C se encuentra almacenado en varios archivos situados en ubicaciones conocidas por el enlazador. De este modo, el código objeto de las funciones de librería que hayamos utilizado en nuestro programa puede unirse con el código objeto del programa durante en enlace, generándose el programa ejecutable.

Por lo tanto, es necesario hacer el enlace cuando el programa se encuentra distribuido en varios archivos, o cuando dentro del programa se utilizan funciones de librería. Esto quiere decir que, en la práctica, el enlace hay que hacerlo siempre.

El enlazador o linker, es decir, el programa encargado de hacer el enlace, es en realidad diferente del compilador, que sólo hace la traducción. Sin embargo, la mayoría de los compiladores de C lanzan automáticamente el enlazador al finalizar la compilación para que el programador no tenga que hacerlo.

Si tenemos que enlazar otros archivos, bien porque nuestro programa esté repartido en varios archivos fuentes, o bien porque estemos utilizando librerías no estándar, habrá que indicar al enlazador cómo enlazar esos archivos adicionales y dónde se encuentran las librerías. El funcionamiento de Dev-C++ se trata más detenidamente en este post, y, el de gcc, en este otro.

El enlace de nuestro código objeto con las funciones de librería puede hacerse de dos maneras:

  • Enlace estático. Consiste en unir durante el enlace el código objeto de las librerías con el código del programa, generando así el ejecutable. El programa ejecutable crece notablemente de tamaño respecto de los archivos objeto, ya que incorpora el código de todas las funciones de las librerías. El enlace estático es el que normalmente se utiliza a menos que indiquemos otra cosa.
  • Enlace dinámico. El código de las librerías no se une al del programa, sino que se busca durante la ejecución, únicamente cuando es requerido. El enlace dinámico produce, por lo tanto, ejecuciones más lentas, ya que cada vez que se use una función de librería dinámica es necesario buscar el archivo en el que se encuentra y ejecutar su código. Además, pueden producirse errores de enlace durante la ejecución del programa. Sin embargo, el enlace dinámico tiene las ventajas de reducir el tamaño del archivo ejecutable y permitir la compartición de librerías entre diferentes aplicaciones.

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

Técnicamente, con printf() y scanf() es posible escribir y leer cualquier tipo de datos desde cualquier dispositivo de salida o entrada, no solo la pantalla y el teclado, como de hecho comprobaremos cuando estudiemos los ficheros.

En la práctica, aunque printf() resulta bastante efectiva y versátil, scanf() suele dar muchos problemas en la mayoría de los entornos de desarrollo. Existe otro grupo de funciones en ANSI C específicamente diseñadas para hacer la E/S por consola, es decir, por teclado y pantalla, de manera más simple. Las resumimos a continuación.

Los prototipos de estas funciones se encuentran en el archivo de cabecera stdio.h (de “std” = standard e “io” = input/output, es decir, “entrada/salida”)

Función getchar()

Espera a que se pulse una tecla seguida de INTRO y devuelve su valor en código ASCII, es decir, en formato carácter. Muestra el eco en la pantalla, es decir, la tecla pulsada aparece en la pantalla.

char car;
car = getchar();
printf("La tecla pulsada ha sido:\n");
putchar(car);

Función putchar()

Escribe un carácter en la pantalla.

char c;
c = 'A';
putchar(c);

Función gets()

Lee del teclado una cadena de caracteres seguida de INTRO.

char cadena[50];  /* Cadena de caract.*/
gets(cadena);
printf("La cadena introducida es:\n");
puts(cadena);

Función puts()

Escribe una cadena de caracteres en la pantalla (véase el ejemplo anterior).

Problemas con scanf()

scanf() suele causar problemas con el buffer del teclado. Si hacemos un scanf() para leer, por ejemplo, un número entero y, a continuación, intentamos un scanf() con un carácter o una cadena, es segundo scanf() fallará.

int i;
char c[50];
scanf("%i", &i);
scanf("%s", c);    // Este scanf() fallará

El motivo del fallo se debe a que el primer scanf() asigna un número a la variable entera, pero el carácter de retorno de carro queda sin consumir en el buffer de entrada del teclado. Al llegar al segundo scanf(), el carácter de salto de línea, que aún estaba en el buffer, se asigna automáticamente a la variable de tipo cadena. Al ejecutar el programa, tendremos la desagradable sorpresa de que el segundo scanf() es ignorador.

Para evitar este tipo de problemas con scanf(), podemos recurrir a gets() para leer las cadenas de caracteres. Si necesitamos leer un número, podemos usar gets() y luego convertir la cadena a un tipo de dato numérico con las funciones de conversión atoi() y atof(), como se muestra en el siguiente ejemplo:

char cadena[50];
int a;
float x;
gets(cadena);     // Leemos una cadena de caracteres
a = atoi(cadena); // Convertimos la cadena en un número entero
x = atof(cadena); // Convertimos la cadena en un número real

Las funciones de conversión atoi() y atof() tratarán de convertir la cadena en un número, si ello es posible (es decir, si la cadena realmente contiene números).

Usar la combinación de gets() con atoi() o atof() es más costoso que utilizar scanf(). Primero, porque necesitamos una variable auxiliar de tipo cadena. Y, segundo, porque gets() es una función peligrosa: si se teclean más caracteres de los que caben en la cadena, el resultado es imprevisible (a menudo el programa se cuelga). Esto también tiene solución utilizando en su lugar la función fgets(), de la que hablaremos cuando nos refiramos a los ficheros. ¡Es debido a este tipo de complicaciones en las cosas más triviales por lo que C tiene exaltados detractores!

Por último, mencionaremos que los compiladores de Borland tienen dos variaciones de la función getchar() llamadas getche() y getch(). Estas funciones, no definidas en el estándar ANSI de C, son como getchar() pero sin necesidad de pulsar INTRO detrás del carácter. La primera muestra el eco, es decir, escribe en la pantalla la tecla pulsada, y la segunda no. Los prototipos de estas funciones se encuentran en conio.h (de “con” = consola e “io” = input/output)

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

La entrada y salida de datos en C, es decir, la traducción de las instrucciones leer() y escribir() de pseudocódigo, es uno de los aspectos más difíciles para el programador que comienza a utilizar C.

El estándar ANSI C dispone de muchas funciones estándar para hacer las entradas y salidas de datos. En concreto, dispone de un subconjunto de ellas para hacer la entrada y salida por consola, es decir, por teclado y pantalla.

Podemos clasificar estas funciones de E/S en dos grupos:

  • Funciones de E/S simples: getchar(), putchar(), gets(), puts()
  • Funciones de E/S con formato: printf(), scanf()

Las más versátiles son sin duda las segundas, así que nos detendremos ahora en ellas.

Salida de datos con formato: la función printf()

La función printf() (de “print” = imprimir y “f” = formato) sirve para escribir datos en el dispositivo de salida estándar (generalmente la pantalla) con un formato determinado por el programador. La forma general de utilizarla es la siguiente:

printf(cadena_de_formato, datos);

El prototipo de printf() se encuentra en el archivo de cabecera stdio.h (de “std” = standard e “io” = input/output, es decir, entrada/salida; por lo tanto, “stdio” es un acrónimo de “entrada/salida estándar”)

El primer argumento, la cadena_de_formato, especifica el modo en el que se deben mostrar los datos que aparecen a continuación. Esta cadena se compone de una serie de códigos de formato que indican a C qué tipo de datos son los que se desean imprimir. Todos los códigos están precedidos del símbolo de porcentaje (”%”). Por ejemplo, el código “%i” indica a la función que se desea escribir un número de tipo int, y el código “%f”, que se desea escribir un número real de tipo float.

La forma más simple de utilizar printf() es:

int a;
a = 5;
printf("%i", a);

Esto escribirá el valor de la variable entera a en la pantalla, es decir, 5. Fíjese en que el primer argumento de printf() es una cadena (y, por lo tanto, se escribe entre comillas) cuyo contenido es el código del tipo de dato que se pretende escribir. El segundo argumento es el dato mismo.

En una sola instrucción printf() pueden escribirse varios datos. Por ejemplo:

int a;
float x;
a = 5;
x = 10.33;
printf("%i%f", a, x);

Observe detenidamente la cadena de formato: primero aparece “%i” y luego “%f”. Esto indica que el primer dato que debe imprimirse es un entero, y el segundo, un real. Después, aparecen esos datos separados por comas y exactamente en el mismo orden que en la cadena de formato: primero a (la variable entera) y luego x (la variable real). El resultado será que en la pantalla se escribirán los números 5 y 10.33.

Los códigos de formato que se pueden utilizar en printf() son:

  • %c – carácter
  • %d – número entero
  • %i – número entero
  • %e – número real con notación científica
  • %f – número real
  • %g – usar %e o %f, el más corto
  • %o – número octal
  • %s – cadena de caracteres
  • %u – untero sin signo
  • %x – número hexadecimal
  • %p – puntero

Algunos de estos códigos sirven para imprimir tipos de datos que aún no conocemos, pero que iremos viendo en próximos artículos.

Hay códigos que admiten modificadores. Por ejemplo:

  • Los códigos numéricos “%i”, “%d”, “%u” (para números enteros) y “%f”, “%e” y “%g” (para números reales), permiten insertar modificadores de longitud “l” (longitud doble) y “h” (longitud corta). Así, por ejemplo, “%ld” indica que se va a imprimir un entero de longitud doble (long int); “%hu” sirve para enteros cortos sin signo (unsigned short int); “%lf” indica que se imprimirá un número real de longitud doble (double), etc.
  • El código “%f” (números reales) se pueden usar con un modificador de posiciones decimales que se desean mostrar. Por ejemplo, con “%10.4f” obligamos a que se impriman diez dígitos a la izquierda de la coma decimal y cuatro a la derecha. La escritura se ajusta a la derecha. Para ajustarla a la izquierda se utiliza el modificador “-”, de esta forma: “%-10.4f”
  • El código “%s” (cadenas de caracteres) se puede combinar con un especificador de longitud máxima y mínima de la cadena. Por ejemplo, “%4.8s” escribe una cadena de al menos cuatro caracteres y no más de ocho. Si la cadena tiene más, se pierden los que excedan de ocho. También se puede utilizar el modificador “-” para alinear el texto a la izquierda.

Además de los códigos de formato, en la cadena de formato puede aparecer cualquier texto entremezclado con los códigos. A la hora de escribir en la pantalla, los códigos serán sustituidos por los datos correspondientes, pero el resto del texto aparecerá de forma literal. Por ejemplo:

int a;
float x;
a = 5;
x = 10.33;
printf("El número entero es %i y el real es %f", a, x);

Lo que aparecerá en la pantalla al ejecutar este fragmento de código será:

El número entero es 5 y el real es 10.33

Una última observación sobre printf(). Hay ciertos caracteres que no son directamente imprimibles desde el teclado. Uno de ellos es el salto de línea. Para poder ordenar a printf() que escriba un salto de línea (o cualquier otro carácter no imprimible) se utilizan los códigos de barra invertida, que con códigos especiales precedidos del carácter “\”.

En concreto, el carácter “salto de línea” se indica con el código “\n”. Observe las diferencias entre estos dos bloques de instrucciones para intentar comprender la importancia del salto de línea:

int a;
a = 5;
printf("La variable a vale %i", a);
a = 14;
printf("La variable a vale %i", a);

El resultado en la pantalla de la ejecución de estas instrucciones es:

La variable a vale 5La variable a vale 14

Veamos el mismo ejemplo usando el código del salto de línea (\n):

int a;
a = 5;
printf("La variable a vale %i\n", a);
a = 14;
printf("La variable a vale %i", a);

El resultado en la pantalla será:

La variable a vale 5
La variable a vale 14

Entrada de datos con formato: la función scanf()

La función scanf() es, en muchos sentidos, la inversa de printf(). Puede leer desde el dispositivo de entrada estándar (normalmente el teclado) datos de cualquier tipo de los manejados por el compilador, convirtiéndolos al formato interno apropiado. Funciona de manera análoga a printf(), por lo que su sintaxis es:

scanf(cadena_de_formato, datos);

El prototipo de scanf() se encuentra en el archivo de cabecera stdio.h (de “std” = standard e “io” = input/output, es decir, entrada/salida)

La cadena_de_formato tiene la misma composición que la de printf(). Los datos son las variables donde se desea almacenar el dato o datos leidos desde el teclado. ¡Cuidado! Con los tipos simples, es necesario utilizar el operador & delante del nombre de la variable, porque esa variable se pasa por referencia a scanf() para que ésta pueda modificarla.

Por ejemplo:

int a, b;
float x;
scanf("%d", &a);
scanf("%d%f", &b, &x);

La primera llamada a scanf() sirve para leer un número entero desde teclado y almacenarlo en la variable a. La segunda llamada lee dos números: el primero, entero, que se almacena en b; y, el segundo, real, que se almacena en x.

La función scanf() tiene alguna otra funcionalidad añadida para el manejo de cadenas de caracteres que por ahora no vamos a discutir, para no agobiar.

Ejemplo de uso de scanf() y printf()

Debido a la relativa complejidad de estas funciones de entrada y salida, vamos a presentar un pequeño ejemplo de traducción de pseudocódigo a C. Se trata de un algoritmo que lee dos números enteros, A y B. Si A es mayor que B los resta, y en otro caso los suma.

En pseudocódigo:

algoritmo suma_y_resta
variables
  a y b son enteros
inicio
  escribir ("Introduzca dos números enteros")
  leer(a, b)
  si (a < b) entonces
    escribir("La suma de a y b es:", a+b)
  si_no
    escribir("La resta de a menos b es:", a–b)
fin

En lenguaje C:

/* Programa suma y resta */
#include <stdio.h>
int main()
{
  int a, b;
  printf ("Introduzca dos números enteros\n");
  scanf("%d%d", &a, &b);
  if (a < b)
    printf("La suma de %d y %d es: %d", a, b, a+b);
  else
    printf("La resta de %d menos %d es: %d", a, b, a–b);
  return 0;
}

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

Suele resultar útil, sobre todo cuando se empieza a trabajar con un nuevo lenguaje, disponer de una plantilla con la estructura habitual de un programa en dicho lenguaje. De eso trata este artículo.

Todo programa en C, desde el más pequeño hasta el más complejo, tiene una función principal denominada main(). Además, por encima de main() deben aparecer los prototipos de funciones (y esto implica a los archivos de cabecera, si se utilizan funciones de librería) y las variables y constantes globales, si las hay. Por debajo de main() encontraremos el código del resto de funciones.

Por lo tanto, la estructura habitual de nuestros programas en C debería ser esta:

/* Comentario inicial: nombre del programa,
   del programador, fecha, etc */

/* Archivos de cabecera (prototipos de funciones de librería) */

#include <archivo_cabecera.h>
#include <archivo_cabecera.h>

/* Prototipos de funciones escritas por nosotros */
float función1 (argumentos);
float función2 (argumentos);

/* Variables y constantes globales */
int variable_global;
const char constante_global;
#define PI 3.14

/* Algoritmo principal */
int main(void)
{
   /* Variables locales del algoritmo principal */
   int a, b;
   float x, y;
   ...
   ...
   /* Instrucciones del algoritmo principal */
   ...
   función1(argumentos);
   ...
   función2(argumentos);
   ...
   return 0;
}

/* Código completo de las funciones escritas por nosotros */
float función1 (argumentos)
{
   /* Variables locales e instrucciones de este subalgoritmo */
}

float función2 (argumentos)
{
   /* Variables locales e instrucciones de este subalgoritmo */
}

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

En C no es necesario escribir las funciones (subalgoritmos) antes de su primera invocación. El mecanismo de compilación y enlace de C permite, de hecho, que las funciones puedan estar físicamente en un archivo distinto del lugar desde el que se invocan.

En la práctica, esto plantea un problema: C no tiene forma de saber si la llamada a una función se hace correctamente, es decir, si se le pasan los argumentos debidos y con el tipo correcto, ni si el resutado devuelto es asignado a una variable del tipo adecuado.

Para conseguir que C realice esas comprobaciones se utilizan los prototipos de función. Un prototipo de función es, en pocas palabras, la declaración de una función. Consiste, simplemente, en la primera línea del código la función. El prototipo debe aparecer antes de que la función se invoque por primera vez, aunque el código completo de la función esté en otra parte. Los prototipos permiten al compilador comprobar que los argumentos de la función coinciden en tipo y número con los de la invocación de la misma, y que el tipo devuelto es el correcto.

Los prototipos suelen aparecer al principio del programa, antes de la función main(). Observe, en el siguiente ejemplo, que el prototipo de la función calcular_area() se coloca delante de main(). Sin embargo, el código concreto de esta función no aparece hasta después (incluso podría estar situado en otro archivo diferente):

float calcular_area (float base, float altura);    // Prototipo de la función
int main()                // Algoritmo principal
{
   ...instrucciones...
   area = calcular_area (x,y);
   ...más instrucciones...
   return 0;
}
float calcular_area(float base, float altura)        // Código de la función
{
   ... instrucciones...
}

Archivos de cabecera

Cuando se vayan a usar funciones de una librería ya programada, como fabs() (valor absoluto), sqrt() (raíz cuadrada) o cualquier otra, hay que escribir sus prototipos antes de la función main(). Sin embargo, como estas funciones no las hemos escrito nosotros, desconocemos cuales son sus prototipos.

En C se soluciona este problema con los archivos de cabecera, que son archivos que incluyen en su interior, entre otras cosas, los prototipos de las funciones de librería. Como funciones de librería hay muchas, también hay muchos archivos de cabecera. Por ejemplo, el archivo math.h tiene los prototipos de todas las funciones matemáticas estándar. Todos los archivos de cabecera tienen la extensión “.h” en su nombre (h de “header”).

Para incluir un archivo de cabecera en nuestro programa se utiliza #include, que no es exactamente una instrucción de C, sino una directiva de compilación. Más adelante, en el apartado de “Aspectos avanzados de C”, veremos qué significa eso.

Por ejemplo, esta línea de código sirve para incluir todos los prototipos de las funciones de librería matemática en nuestro programa:

#include <math.h>

Cada vez que necesite usar una de las funciones estándar en un programa, debe escribir al principio del mismo el #include del archivo de cabecera donde esa función se encuentra definida para disponer así del prototipo. Esto también es aplicable a las funciones no estándar, es decir, a las funciones de librerías escritas por terceros, salvo que, en este caso, el proceso de enlace no es automático y hay que indicarle al compilador dónde puede encontrar la librería y de qué librería se trata.

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

Como hemos visto anteriormente, C es un lenguaje modular hasta el extremo de que todas las líneas de código deben pertenecer a alguna función, incluyendo las instrucciones del algoritmo principal, que se escriben en una función llamada principal (main en inglés).

Si no tiene claros los conceptos de función y procedimiento, o en qué consiste el paso de parámetros, es conveniente que revise los artículos correspondientes. Aquí nos limitaremos a exponer cómo se manejan las funciones y los parámetros en el lenguaje C.

Funciones

La declaración de funciones se hace de forma similar a la que empleamos en pseudocódigo:

tipo_devuelto nombre_función (parámetros_formales)
{
   ...instrucciones...
   return expresión;
}

Observe que las únicas diferencias con el pseudocódigo son que no se usa la palabra “función”, que las llaves { y } sustituyen a inicio y fin, y que se emplea la palabra return en lugar de devolver.

Procedimientos

Si el tipo_devuelto es void, se considera que la función no devuelve ningún valor y que, por lo tanto, es un procedimiento. Entonces, un procedimiento se declara así:

void nombre_procedimiento (parámetros_formales)
{
   ...instrucciones...
}

Ahora bien, en la jerga de C siempre se habla de “funciones” y rara vez de “procedimientos”. Pero, siendo estrictos, una función que no devuelve ningún valor (o que lo devuelve a través de sus parámetros) es, de hecho, un procedimiento.

Paso de parámetros

Los parámetros formales son, como en pseudocódigo, una lista de tipos e identificadores que se sustituirán por los parámetros actuales y se usarán como variables dentro de la función.

Los parámetros se pasan normalmente por valor, pero también se pueden pasar por referencia. El paso de parámetros por referencia admite dos sitaxis ligeramente diferentes en C: anteponiendo el operador * (asterisco) al nombre del parámetro (como hemos hecho en pseudocódigo1) o anteponiendo el operador &. Veamos ambos casos más despacio.

Paso de parámetros por valor

Por ejemplo, en esta función el paso de parámetros es por valor:

int funcion1 (int x, int y)

Esto quiere decir que la función1 recibirá únicamente el valor de los dos parámetros, x e y. Podrá utilizar esos valores a lo largo de su código, e incluso podrá cambiarlos. Pero cualquier cambio en x e y no afectará a los parámetros actuales, es decir, a los parámetros del programa que llamó a función1.

Paso de parámetros por referencia con el operador *

En la siguiente función, el paso del parámetro “x” es por valor y el del parámetro “y”, por referencia:

int funcion2 (int x, int *y)

¡OJO! En esto difiere C del pseudocódigo: cada vez que se vaya a usar el parámetro “y” dentro del código de la función, será necesario acompañarlo del asterisco. Por ejemplo:

*y = 5;
x = 17 + *y;

Por último, también en la llamada a la función hay que indicar explícitamente si alguno de los parámetros se está pasando por referencia, utilizando el operador &, como en pseudocódigo. Por lo tanto, para llamar a la funcion2 del ejemplo anterior con los parámetros A y B habrá que escribir:

resultado = funcion2 (A, &B);

Observe que el segundo parámetro (el que se pasa por referencia), lleva delante el operador &.

Paso de parámetros por referencia con el operador &

Otra forma de pasar un argumento por referencia es usar el operador & en los parámetros formales, así:

int funcion3 (int x, int &y)

En esta función, el parámetro “x” se pasa por valor y el parámetro “y” se pasa por referencia. Utilizando esta sintaxis no es necesario añadir asteriscos cada vez que se usa la “y” en el cuerpo de la función, ni tampoco usar “&” en la llamada a la función.

Pero ¡cuidado!. Esta sintaxis es propia de C++ y no está definida en C. La mayoría de los compiladores de C compilan también C++, así que se tragarán esta sintaxis sin quejarse demasiado. Pero, si decide usarla, debe ser consciente de que está introduciendo un fragmento de código C++ dentro de un programa en C, y que puede causarle problemas dependiendo de su compilador y de las opciones de compilación del mismo.

Un bonito ejemplo

En el siguiente ejemplo se ilustran los dos tipos de paso de parámetros y, en el paso por referencia, las dos sintaxis alternativas de que dispone C.

El ejemplo muestra tres funciones muy similares que reciben dos parámetros, a y b. Las tres intentan intercambiar el valor de a y b mediante una tercera variable, tmp. Sin embargo, en la primera de ellas el intercambio no tiene ningún efecto en el programa main(), ya que los parámetros están pasados por valor. En las otras dos funciones sí que se consigue el intercambio, ya que los parámetros está pasados por referencia.

El objetivo de este ejemplo es mostrar cuál es la sintaxis correcta en cada tipo de paso de parámetros.

#include <stdio.h>

// Paso de parámetros por valor.
// En este ejemplo, esta función no tendrá el efecto deseado, porque las variables
// del programa principal no se verán afectadas.
void intercambiar1(int a, int b)
{
     int tmp = a;
     a = b;
     b = tmp;
}

// Paso de parámetros por referencia, sintaxis 1.
// Esta función sí que consigue intercambiar los valores de las variables
// del programa principal.
void intercambiar2(int *a, int *b)
{
     int tmp = *a;
     *a = *b;
     *b = tmp;
}

// Paso de parámetros por referencia, sintaxis 2.
// Esta función también consigue su objetivo. A todos los efectos,
// es idéntica a la función anterior, ¡pero es código en C++!
void intercambiar3(int &a, int &b)
{
     int tmp = a;
     a = b;
     b = tmp;
}

// Programa principal
int main()
{
    int dato1 = 30, dato2 = 90;
    printf("Antes de la llamada a las funcioens: dato1 = %i, dato2 = %i\n", dato1, dato2);
    intercambiar1(dato1, dato2);
    printf("Después de intercambiar1: dato1 = %i, dato2 = %i\n", dato1, dato2);
    intercambiar2(&dato1, &dato2);
    printf("Después de intercambiar2: dato1 = %i, dato2 = %i\n", dato1, dato2);
    intercambiar3(dato1, dato2);
    printf("Después de intercambiar3: dato1 = %i, dato2 = %i\n", dato1, dato2);
    return 0;
}

Una última observación antes de terminar por hoy: existe una poderosa razón por la que se utilizan los operadores * y & en el paso de parámetros por referencia. Sin embargo, es demasiado pronto para explicarla. Volveremos sobre ello cuando veamos los punteros. Es una amenaza.

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

Como hicimos con las instrucciones condicionales, presentaremos ahora la sintaxis de las estructuras iterativas en C. Para más detalles sobre cómo funciona cada estructura, pueden consultar el artículo donde se explicaron usando pseudocódigo.

Bucle mientras

while (condición)
{
  acciones
}

Un ejemplo de uso:

int n, i;
scanf("%i", &n);
i = 1;
while (i <= 10)
{
  printf("%i x %i = %i\n", n, i, n*i);
  i++;
}

Observe que no difiere en absoluto de la estructura vista en pseudocódigo.

Bucle repetir

do
{
   acciones
}
while (condición);

Un ejemplo de uso:

int n, i;
scanf("%i", &n);
i = 1;
do
{
  printf("%i x %i = %i\n", n, i, n*i);
  i++;
}
while (i <= 10);

La diferencia entre el bucle “mientras” y el bucle “repetir”, como ya vimos, es que la condición del último se evalúa al final, por lo que su cuerpo se ejecuta al menos una vez. El bucle “mientras” puede no ejecutarse nunca.

Cuidado con el while del final. NO es un while de un bucle “mientras”, claro. Pero, al usarse la misma palabra reservada, puede mover a error. El while de un bucle “repetir” se distingue porque lleva un punto y coma (;) detrás, ya que la instrucción “repetir” termina ahí. Un bucle “mientras” no lleva punto y coma tras el while, porque aún no termina.

Bucle para

for (inicialización; condición; incremento)
{
   acciones
}

Cuesta acostumbrarse a este bucle al principio, sobre todo si usted tiene experiencia con otros lenguajes estructurados. Su sintaxis es algo diferente a la que hemos visto en pseudocódigo. Ya se ha dicho en varias ocasiones que C es a veces un poco críptico. El bucle para (o bucle for) es un ejemplo típico de ello ya que:

  • La variable contador debe ser inicializada con una asignación dentro de la instrucción for.
  • El valor final debe ser expresado en forma de condición, como haríamos en un bucle mientras.
  • El incremento del contador hay que indicarlo explícitamente.

Por ejemplo, el siguiente bucle en pseudocódigo:

para cont desde 1 hasta 100 inc 2 hacer
inicio
    acciones
fin

Tendría esta traducción en C:

for (cont = 1; cont <= 100; cont = cont + 2)
{
    acciones
}

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

Las estructuras de control en C son muy similares a las condiciones y bucles que hemos visto en pseudocódigo, cambiando ligeramente la notación empleada. Vamos a limitarnos a presentar su sintaxis en C: si desea información más detallada sobre su comportamiento, vea los artículos correspondientes a pseudocódigo (aquí).

Condicional simple

if (condición)
{
   acciones
}

He aquí un pequeño ejemplo:

int a;
scanf("%i", &a);
if (a % 2 == 0)
{
   printf("El número introducido es par");
}

Observe que, en C, la condición debe escribirse entre paréntesis y que no se emplea la palabra “entonces”.

Condicional doble

if (condición)
{
   acciones-1
}
else
{
   acciones-2
}

Ejemplo:

int a;
scanf("%i", &a);
if (a > 0)
{
   printf("El número es positivo");
}
else
{
   printf("El número es negativo o cero");
}

Vea cómo cada bloque tiene su propio inicio (símbolo { ) y su propio fin (símbolo } ). No hay nada equivalente al “end-if” de otros lenguajes (por eso tampoco lo usábamos en pseudocódigo)

Condicional múltiple

switch (expresión)
{
  case valor1: acciones-1;
               break;
  case valor2: acciones-2;
               break;
  case valor3: acciones-3;
               break;
    ...
  case valorN: acciones-N;
               break;
  default: acciones-si_no;
}

Ahí va un ejemplo:

int a;
scanf("%i", &a);
switch (a)
{
  case 1: printf("Enero");
          break;
  case 2: pritnf("Febrero");
          break;
  case 3: printf("Marzo");
          break;
  ...etc...
  case 12:printf("Diciembre");
          break;
  default:printf("Número incorrecto");
}

Esta estructura presenta algunas peculiaridades, a saber:

  • La expresión discriminante debe escribirse entre paréntesis y ser de un tipo ordinal (int, char o similar). No pueden usarse cadenas ni números reales.
  • Los valores no pueden ser expresiones, sino constantes, es decir, números o caracteres fijos.
  • ¡Cuidado! Las acciones no son bloques de instrucciones, es decir, no van encerradas entre { y }. Eso quiere decir que, si se ejecutan las acciones-2, después se ejecutarán automáticamente las acciones-3, luego las acciones-4, etc. La forma de evitar esto es insertar la instrucción break al final de cada bloque de instrucciones.

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

En otros artículos hemos dicho que C es un lenguaje estrictamente modular: todo el código debe estar ubicado en el interior de funciones.

La función llamada main() es sólo eso, una función. Pero una función especial. Existe en todos los programas, porque contiene el algoritmo o módulo principal del programa. La ejecución de un programa siempre empieza por la primera línea de la función main().

La función main(), como todas las funciones de C, puede devolver un valor. El valor devuelto por main() debe ser de tipo entero. Esto se utiliza para pasar algún valor al programa que haya llamado al nuestro, que suele ser el sistema operativo. Si main() no devuelve un número entero al sistema operativo mediante una sentencia return, entonces nuestro programa devolverá un número desconocido. Moraleja: es una buena idea incluir un return al final de la función main(). Generalmente, la devolución de un 0 indica al sistema operativo que el programa a finalizado sin problemas, mientras que cualquier otro valor señala que se ha producido algún error.

Por lo tanto, la forma habitual de la función main() será:

int main(void)
{
   ...instrucciones del algoritmo principal...
   return 0;
}

Observa que main() no tiene argumentos, por lo que aparece el identificador void entre paréntesis en la declaración.

Es posible que vea por esos mundos una definición de main() con argumentos, algo así:

int main(int argc, char* argv[])
{
   ...instrucciones del algoritmo principal...
   return 0;
}

Esos dos argumentos sirven para capturar parámetros de entrada desde la línea de comandos. Bien, por ahora, olvidémonos de esos parámetros. Utilizaremos main() sin parámetros y, en el apartado de “Aspectos avanzados” de este mismo curso, volveremos sobre ellos para aprender a usarlos.

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

Todas las variables deben declararse antes de ser usadas. La sintaxis de la declaración incluye su tipo y su nombre (identificador):

 tipo_de_datos lista_de_variables;

Por ejemplo:

int cont;
char respuesta;
float x, y, resultado;

En C99 no está delimitado el lugar del algoritmo donde deben declararse las variables, siendo la única condición que se declaren antes de ser usadas por primera vez. Sin embargo, nosotros haremos siempre la declaración al principio del algoritmo, antes de la primera instrucción. Esto no cuesta ningún trabajo y nos asegurará la compatibilidad con estándares de C más antiguos.

Todas las variables son locales a la función donde estén definidas, dejando de existir al finalizar la función. Las variables globales se declaran fuera del cuerpo de todas las funciones y antes de la función main(), que es el algoritmo principal. Recuerda que debes evitar el uso de variables globales a menos que sea estrictamente necesario.

Para asignar un valor a una variable se utiliza la sentencia de asignación, exactamente igual que en pseudocódigo. Por ejemplo:

cont = cont + 1;
respuesta = 'S';
x = 5.33;

Constantes

Recuerde que también se pueden usar identificadores para asociarlos a valores constantes, es decir, valores que no cambiarán nunca durante la ejecución del programa.

Para declarar una constante y asignarle un valor se utiliza el modificador const delante de la declaración:

const tipo_de_datos nombre_constante = valor;

Por ejemplo:

const float pi = 3.141592;

El valor de la constante pi no podrá ser modificado a lo largo del programa.

Otra forma de definir constantes es mediante una directiva del compilador:

#define PI = 3.141592

Las directivas no son instrucciones de C, sino consignas comunicadas al compilador para que sepa que, si encuentra el símbolo PI en el código fuente, debe sustituirlo por 3.141592. Estudiaremos las directivas con más detalle en posts posteriores. Por ahora nos basta saber que existen estas dos formas de declarar constantes.

Conversiones de tipo

C es un lenguaje débilmente tipado, es decir, no hace comprobaciones estrictas de tipos a la hora de asignar un valor a una variable o de comparar dos expresiones.

Por ejemplo, estas instrucciones son correctas:

float a;
int b;
b = 5;
a = b;

Se ha asignado un valor entero a la variable “a”, que es de tipo float. En otros lenguajes esto no está permitido, pero en C se realizan conversiones automáticas de tipo cuando en una misma expresión aparecen datos de tipos diferentes. Esto, que en principio es una ventaja, pues elimina algunas limitaciones engorrosas, otras veces es peligroso porque algunos datos pueden cambiar extrañamente de valor al hacerse esa conversión automática.

La conversión puede ser de dos clases:

  • Asignación de un valor a una variable que permita más precisión. Por ejemplo, asignar un número entero a una variable float. En este caso, el número se convierte a real añadiendo “.0″ a la parte decimal. No hay pérdida de información.
  • Asignación de un valor a una variable que permita menos precisión. Por ejemplo, asignar un número long int a una variable de tipo int. En este caso, el número se recorta, perdiendo sus bits más significativos, es decir, los que están a la izquierda, y por lo tanto hay pérdida de información. Hay que tener mucho cuidado con este tipo de conversiones porque pueden producir resultados imprevisibles

Además de las conversiones automáticas de tipo, el programador puede forzar la conversión de tipos a voluntad utilizando moldes. Un molde es una expresión de un tipo de datos entre paréntesis que aparece delante de un dato. Entonces, antes de evaluar la expresión, el dato es convertido al tipo especificado en el molde. Por ejemplo:

float a;
int b;
a = 5;
b = (float)a/2;

Sin el molde (float), la división a/2 sería entera, ya que a es una variable de tipo int, y se perdería la parte decimal. Al aplicar el molde, se convierte momentáneamente el valor entero 5 al valor real 5.0 y se evalúa la expresión, que ahora sí se realiza como división real, conservando sus decimales.

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

Los tipos simples de datos admitidos por C son los siguientes:

Denominación Tipo de datos Tamaño en bits Rango de valores
char Carácter 8 de 0 a 255
int Número entero 16 de –32768 a 32767
float Número real de precisión simple 32 de 3.4 x 10-38 a 3.4 x 1038
double Número real de precisión doble 64 de 1.7 x 10-308 a 1.7 x 10308
void Tipo vacío 0 sin valor

Los tamaños en bits pueden variar dependiendo del compilador empleado. Por ejemplo, gcc intepreta que el entero es de 32 bits, y para usar enteros de 16 bits hay que indicarlo expresamente. Por tanto, no debe usted presuponer ningún tamaño concreto para los tipos si quiere escribir programas portables.

El tipo char se usa normalmente para variables que guardan un único carácter, aunque lo que en realidad guardan es un código ASCII, es decir, un número entero de 8 bits sin signo (de 0 a 255). Los caracteres se escriben siempre entre comillas simples ( ‘…’ ). Por lo tanto, si suponemos que x es una variable de tipo char, estas dos asignaciones tienen exactamente el mismo efecto, ya que 65 es el código ASCII de la letra A:

x = 'A';
x = 65;

Mucho cuidado con esto, porque las cadenas de caracteres se escriben con comillas dobles (”…”) a diferencia de las comillas simples de los caracteres sueltos.

El tipo int se usa para números enteros, mientras que los tipos float y double sirven para números reales. El segundo permite representar números mayores, a costa de consumir más espacio en memoria.

El tipo void tiene tres usos. El primero es para declarar funciones que no devuelven ningún valor (procedimientos); el segundo, para declarar funciones sin argumentos; el tercero, para crear punteros genéricos. En otros posts se discutirán los tres usos.

Observe que en C no existe el tipo de dato lógico. Se utiliza en su lugar el tipo int, representando el 0 el valor falso y cualquier otra cantidad (normalmente 1) el valor verdadero.

Modificadores de tipo

Existen, además, unos modificadores de tipo que pueden preceder a los tipos de datos char e int. Dichos modificadores son:

  • signed: obliga a que los datos se almacenen con signo
  • unsigned: los datos se almacenan sin signo
  • long: los datos ocuparán el doble de espacio en bits del habitual, y, por lo tanto, aumentará su rango de valores
  • short: los datos ocuparán la mitad del espacio habitual, y, por lo tanto, disminuirá su rango de valores

De este modo, nos podemos encontrar, por ejemplo, con estos tipos de datos:

  • unsigned int: Número entero de 16 bits sin signo. Rango: de 0 a 65535.
  • signed int: Número entero de 16 bits con signo. No tiene sentido, porque el tipo int ya es con signo por definición, pero es sintácticamente correcto.
  • signed char: Carácter (8 bits) con signo. Rango: de –128 a 127
  • long int: Número entero de 32 bits. Rango: de –2147483648 a 2147483647

Incluso podemos encontrar combinaciones de varios modificadores. Por ejemplo:

  • unsigned long int: Número entero de 32 bits sin signo. Rango: de 0 a 4294967295

(Recuerde nuevamente lo que decíamos al principio sobre la cantidad de bits asociada a cada tipo: puede variar dependiendo del compilador y del sistema con el que trabajemos)

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

Cuando programamos en un lenguaje distinto del lenguaje máquina, nuestro código debe ser traducido a binario para que el ordenador pueda entenderlo y ejecutarlo. Existe un programa específico encargado de hacer esa traducción y que, dependiendo del lenguaje en el que hayamos escrito nuestro programa, puede ser un ensamblador, un compilador o un intérprete.

Ensambladores

Se llaman ensambladores los programas encargados de traducir los programas escritos en ensamblador a código binario.
Fíjese en que tanto el programa traductor como el lenguaje se llaman del mismo modo: ensamblador.

Como el lenguaje ensamblador es muy próximo al binario, estos traductores son programas relativamente sencillos.

Compiladores

El compilador es un programa que traduce el código de alto nivel a código binario. Es, por tanto, parecido al ensamblador, pero mucho más complejo, ya que las diferencias entre los lenguajes de alto nivel y el código binario son muy grandes.

El programa escrito en lenguaje de alto nivel se denomina programa fuente o código fuente. El programa traducido a código binario se llama programa objeto o código objeto. Por lo tanto, el compilador se encarga de convertir el programa fuente en un programa objeto.

Una vez que se ha obtenido el programa objeto ya no es necesario volver a realizar la traducción (o compilación), a menos que se haga alguna modificación en el programa fuente, en cuyo caso habría que volver a compilarlo.

El programa objeto, una vez generado, puede ejecutarse en la máquina en la que fue compilado, o en otra de similares características (procesador, sistema operativo, etc.).

Cuando el programa objeto se haya disperso en diferentes archivos (lo que ocurre con frecuencia cuando el programa es grande o, sencillamente, cuando usa funciones escritas por terceras personas), puede ser necesario un proceso previo de enlace de los diferentes módulos. De eso se encarga un programa llamado enlazador o linker, ya ven qué original, que suele actuar inmediatamente después del compilador.

Intérpretes

El intérprete es un programa que traduce el código de alto nivel a código binario pero, a diferencia del compilador, lo hace en tiempo de ejecución. Es decir, no se hace un proceso previo de traducción de todo el programa fuente a binario, sino que se va traduciendo y ejecutando instrucción por instrucción.

Compiladores frente a intérpretes

El intérprete es notablemente más lento que el compilador, ya que realiza la traducción al mismo tiempo que la ejecución. Además, esa traducción se lleva a cabo siempre que se ejecuta el programa, mientras que el compilador sólo la hace una vez. Por estos motivos, un mismo programa interpretado y compilado se ejecuta mucho más despacio en el primer caso.

La ventaja de los intérpretes es que hacen que los programas sean más portables. Así, un programa compilado en una máquina PC bajo Windows no funcionará en un Macintosh, o en un PC bajo Linux, a menos que se vuelva a compilar el programa fuente en el nuevo sistema. En cambio, un programa interpretado funcionará en todas las plataformas, siempre que dispongamos del intérprete en cada una de ellas.

JavaScript es un ejemplo de lenguaje interpretado. Esto permite que los scripts puedan funcionar en cualquier máquina que disponga de un navegador de Internet capaz de interpretarlos, algo común en todos los sistemas actuales. En cambio, C o C++ son lenguajes compilados, lo que hace que los programas desarrollados con estos lenguajes se ejecuten más rápido que sus equivalentes en JavaScript, aunque obliga a volver a compilarlos si se desea ejecutarlos en una máquina con diferente hardware o diferente sistema operativo (de hecho, muchos programas en C no podrían escribirse en JavaScript, pero esa es otra historia)

Es decir: los lenguajes compilados no son mejores que los interpretados, ni al revés. Optar por uno u otro depende de la función para la que vayamos a escribir el programa y del entorno donde deba ejecutarse.

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

Un lenguaje de programación es, ya lo saben, un conjunto de símbolos que se combinan de acuerdo con una sintaxis bien definida para posibilitar la transmisión de instrucciones a la CPU.

Lenguajes de programación hay muchos, cada uno con sus ventajas e inconvenientes. Conviene, por tanto, clasificarlos en categorías. Suelen hacerse dos clasificaciones:

  • La primera, atendiendo al nivel de abstracción del lenguaje, distinguirá entre lenguajes de bajo nivel y de alto nivel.
  • La segunda, según el proceso de traducción a código máquina, distinguirá entre lenguajes interpretados, compilados y ensamblados.

En este artículo hablaremos de la primera clasificación.

Niveles de abstracción

El ordenador, como es sabido, solo puede manejar ceros y unos, es decir, código o lenguaje binario. Los seres humanos, por el contrario, utilizamos un lenguaje mucho más complejo, con montones de símbolos y reglas sintácticas y semánticas, que denominaremos lenguaje natural.

Entre estos dos extremos (lenguaje binario y lenguaje natural) se encuentran los lenguajes de programación. Tienen cierto parecido con el lenguaje natural, pero son mucho más reducidos y estrictos en su sintaxis y semántica, para acercarse a las limitaciones del lenguaje binario.

Hay lenguajes de programación muy próximos al lenguaje binario: a éstos los llamamos lenguajes de bajo nivel de abstracción. Y los hay más próximos al lenguaje natural: son los lenguajes de alto nivel de abstracción.

Lenguajes de bajo nivel

Son los lenguajes más cercanos a la máquina. Los programas directamente escritos en código binario se dice que están en lenguaje máquina que, por lo tanto, es el lenguaje de más bajo nivel que existe.

Las instrucciones del lenguaje máquina realizan tareas muy sencillas, como, por ejemplo, sumar dos números, detectar qué tecla se ha pulsado en el teclado o escribir algo en la pantalla del ordenador. Cuando se combinan adecuadamente muchas de estas instrucciones sencillas se obtiene un programa de ordenador que puede realizar tareas muy complejas.

A pesar de la simplicidad de las instrucciones del lenguaje máquina, la forma de escribirlas es muy complicada, ya que hay que hacerlo en binario. En los primeros años de la informática los ordenadores se programaban directamente en lenguaje máquina, lo cual convertía la tarea de programar en una verdadera pesadilla. Por ejemplo, una instrucción para sumar dos números en lenguaje máquina puede tener este aspecto:

110100100101110010100010001001111010010110110

Cuando los ordenadores fueron haciéndose más potentes, pronto se vio que con el lenguaje máquina no se podrían crear programas que aprovechasen esa potencia por la sencilla razón de que era demasiado difícil programar así: no se podía hacer nada demasiado complicado porque el cerebro humano no está “diseñado” para pensar en binario.

Surgió entonces la idea de utilizar el propio ordenador como traductor: ¿por qué no escribir una instrucción como la anterior, que suma dos números, de una forma más parecida al lenguaje humano y que luego un pequeño programa de ordenador se encargue de traducir esa instrucción a su correspondiente ristra de ceros y unos? Así apareció el lenguaje ensamblador, cuyas instrucciones son equivalentes a las del lenguaje máquina, pero se escriben con palabras similares a las del lenguaje humano. Por ejemplo, para sumar dos números, la instrucción en ensamblador puede ser algo como:

ADD  D1, D2

Los lenguajes de bajo nivel se caracterizan por ser dependientes del hardware de la máquina. Es decir: un programa escrito en lenguaje máquina o en ensamblador para una máquina Pentium IV no funcionará, por ejemplo, en un Apple Macintosh a menos que sea modificado sustancialmente. Incluso puede tener serios problemas para funcionar en máquinas de la misma familia pero con el resto del hardware diferente, o con un sistema operativo distinto.

Lenguajes de alto nivel

Siguiendo el razonamiento anterior (utilizar el propio ordenador como traductor), en los años sesenta se empezaron a desarrollar lenguajes cada vez más complejos, en los que cada instrucción ya no se correspondía exactamente con una instrucción del lenguaje máquina, sino con varias. Estos son los lenguajes de alto nivel o, simplemente, L.A.N. (no confundir con “red de área local”)

Lógicamente, la traducción desde un lenguaje de alto nivel a lenguaje máquina es mucho más compleja que desde lenguaje ensamblador, por lo que los traductores se han hecho cada vez más complicados.
Una característica muy importante de los lenguajes de alto nivel es que son independientes del hardware, lo que implica que los programas desarrollados con estos lenguajes pueden ser ejecutados en ordenadores con hardware totalmente distinto. A esto se le llama portabilidad.

Los programas encargados de traducir el código de alto nivel a código máquina se llaman compiladores e intérpretes. Son programas muy complejos que generan el código binario equivalente al código de alto nivel para una máquina concreta. Por lo tanto, el programa de alto nivel, que es portable de un hardware a otro, debe ser traducido a código máquina en cada tipo de máquina en la que se pretenda ejecutar.

Ejemplos de lenguajes de alto nivel son: Cobol, C, Fortran, Basic, Pascal, Ada, etc.

Comparación entre los lenguajes de alto y bajo nivel

Lenguajes de alto nivel Lenguajes de bajo nivel
Ventajas Inconvenientes
  • Son comprensibles directamente por la máquina (aunque el ensamblador necesita una pequeña traducción)
  • Los programas se ejecutan muy rápidamente (si están bien escritos, claro)
  • Ocupan menos espacio en memoria
  • Permiten controlar directamente el hardware, por lo que son apropiados para la programación de sistemas
  • Necesitan ser traducidos por medio de complicados programas (compiladores e intérpretes)
  • La traducción automática del código de alto nivel al código máquina siempre genera programas menos eficientes que si se escribieran directamente en binario
  • Ocupan más espacio en memoria
  • En general, solo pueden acceder al hardware utilizando al sistema operativo como intermediario. Pero, entonces, ¿cómo programar el sistema operativo, que necesita controlar directamente el hardware?
Inconvenientes Ventajas
  • Son completamente dependientes del hardware. Un programa escrito para determinado tipo de máquina no funcionará en un ordenador con diferente arquitectura.
  • Incluso los programas más sencillos son largos y farragosos
  • Los programas son difíciles de escribir, depurar y mantener
  • Es imposible resolver problemas muy complejos
  • Son portables, es decir, independientes del hardware. Un programa escrito en una máquina puede funcionar en otra con hardware distinto, siempre que se vuelva a traducir a binario en la máquina nueva.
  • Los programas son más sencillos, ya que una sola instrucción puede equivaler a varias instrucciones binarias.
  • Los programas son más fáciles de escribir, depurar y mantener
  • Es posible, aunque difícil, enfrentarse a problemas muy complejos

Enfrentando las ventajas e inconvenientes de unos y otros, se concluye que, en general, se prefiere usar lenguajes de alto nivel para el desarrollo de aplicaciones, reservando los de bajo nivel para casos muy concretos en los que la velocidad de ejecución o el control del hardware sean vitales. Por ejemplo, los sistemas operativos más conocidos, como Windows o GNU/Linux, están programados casi en su totalidad con lenguajes de alto nivel, reservando un pequeño porcentaje del código a rutinas en ensamblador.

También hay que destacar que no todos los lenguajes de alto nivel son iguales. Los hay de “más alto nivel” que otros. C tiene sin duda menor nivel de abstracción que, por ejemplo, Visual Basic; pero, por eso mismo, los programas en C son más rápidos y eficientes que los escritos en Visual Basic, aunque también pueden llegar a ser más difíciles de escribir y depurar.

Categorías dentro de los lenguajes de alto nivel

Para terminar con esta vista preliminar sobre el mundo de los lenguajes de programación, mencionaremos que los lenguajes de alto nivel se suelen subdividir en categorías tales como:

  • Lenguajes de tercera generación (o imperativos), en los que el programador escribe una secuencia de instrucciones que el ordenador debe ejecutar en un orden preestablecido. Son los lenguajes que nosotros vamos a manejar. Todos los lenguajes “clásicos” pertenecen a esta categoría: C, Basic, Cobol, Fortran, etc.
  • Lenguajes de cuarta generación (o 4GL), dirigidos a facilitar la creación de interfaces con el usuario y con otras aplicaciones, como las bases de datos.Un ejemplo de estos lenguajes es SQL.
  • Lenguajes orientados a objetos, que son una evolucuión de los lenguajes de tercera generación y que permiten construir con mayor facilidad y robustez programas modulares complejos. Ejemplos de lenguajes orientados a objetos son C++ o Java.
  • Lenguajes declarativos y funcionales, propios de la inteligencia artificial, como Prolog o Lisp.
  • Otos tipos más específicos: lenguajes concurrentes, paralelos, distribuidos, etc.

En general, podemos decir que un programador acostumbrado a trabajar con un lenguaje de tercera generación puede aprender con poco esfuerzo cualquier otro lenguaje de tercera generación, y, con algo más de trabajo, un lenguaje orientado a objetos. Sin embargo, el “salto” a otros tipos de lenguajes, como los declarativos, cuesta más porque la raíz misma de estos lenguajes es diferente.

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

Las variables locales

Se llama ámbito de una variable a la parte de un programa donde dicha variable puede utilizarse.

En principio, todas las variables declaradas en un algoritmo son locales a ese algoritmo, es decir, no existen fuera del algoritmo, y, por tanto, no pueden utilizarse más allá de las fronteras marcadas por inicio y fin. El ámbito de una variable es local al algoritmo donde se declara.

Cuando el algoritmo comienza, las variables se crean, reservándose un espacio en la memoria RAM del ordenador para almacenar su valor. Cuando el algoritmo termina, todas sus variables se destruyen, liberándose el espacio en la memoria RAM. Todos los resultados que un algoritmo obtenga durante su ejecución, por lo tanto, se perderán al finalizar, salvo que sean devueltos al algoritmo que lo invocó o sean dirigidos a algún dispositivo de salida (como la pantalla). Esta forma de funcionar ayuda a que los algoritmos sean módulos independientes entre sí, que únicamente se comunican los resultados de sus procesos unos a otros.

Por ejemplo, calculemos el cuadrado de un valor X introducido por teclado utilizando diseño modular.

algoritmo cuadrado
variables
   N, result son reales
inicio
   leer(N)
   calcular_cuadrado()
   escribir("El cuadrado es ", result)
fin

procedimiento cacular_cuadrado ()    // Calcula el cuadrado de un número
inicio
   result = N ^ 2
fin

En este algoritmo hay un grave error, ya que se han intentado utilizar las variables result y N, que son locales al algoritmo principal, en el subalgoritmo cuadrado(), desde donde no son accesibles.

Es importante señalar que en algunos lenguajes de programación, y bajo determinadas circunstancias, cuando un algoritmo invoca a un subalgoritmo, puede que todas las variables locales del algoritmo estén disponibles en el subalgoritmo. Así, el ejemplo anterior podría llegar a ser correcto. Esto no ocurre en C, debido a que no se pueden anidar funciones dentro de funciones, pero debe ser tenido en cuenta por el alumno/a si en algún momento debe programar en otro lenguaje. El problema que surge en esas situaciones es similar al de las variables globales que tratamos a continuación.

Las variables globales

En ocasiones es conveniente utilizar variables cuyo ámbito exceda el del algoritmo donde se definen y puedan utilizarse en varios algoritmos y subalgoritmos. Las variables globales implican una serie de riesgos, como veremos más adelante, por lo que no deben utilizarse a menos que sea estrictamente necesario. A pesar de los riesgos, la mayoría de los lenguajes de programación disponen de algún mecanismo para manejar variables globales.

Aunque ese mecanismo varía mucho de un lenguaje a otro, diremos como regla general que las variables globales deben declararse en el algoritmo principal, anteponiendo el identificador global al nombre de la variable, siendo entonces accesibles a todos los algoritmos y subalgoritmos que conformen el programa.

Por ejemplo, vamos a volver a calcular el cuadrado de un valor X introducido por teclado utilizando diseño modular.

algoritmo cuadrado
variables
   global N es real
   global result es reales
inicio
   leer(N)
   calcular_cuadrado()
   escribir("El cuadrado es ", result)
fin

procedimiento cacular_cuadrado ()    // Calcula el cuadrado de un número
inicio
   result = N ^ 2
fin

El error que existía antes ya no ocurre, porque ahora las variables result y N han sido declaradas como globales en el algoritmo principal, y por lo tanto pueden utilizarse en cualquier subalgoritmo, como cuadrado().

Pudiera ocurrir que una variable global tenga el mismo nombre que una variable local. En ese caso, el comportamiento depende del lenguaje de programación (los hay que ni siquiera lo permiten), pero lo habitual es que la variable local sustituya a la global, haciendo que ésta última sea inaccesible desde el interior del subalgoritmo. Al terminar la ejecución del subalgoritmo y destruirse la variable local, volverá a estar accesible la variable global que, además, habrá conservado su valor, pues no ha podido ser modificada desde el subalgoritmo.

De todas formas, y puestos a evitar la utilización de variables globales (a menos que no quede otro remedio), con más razón aún evitaremos usar variables locales que tengan el mismo nombre que las globales.

Los efectos laterales

Al utilizar variables globales, muchas de las ventajas de la programación modular desaparecen.

Efectivamente, la filosofía de la programación modular consiste en diseñar soluciones sencillas e independientes (llamadas módulos) para problemas sencillos, haciendo que los módulos se comuniquen entre sí sólo mediante el paso de parámetros y la devolución de resultados.

Cuando empleamos variables globales como en el ejemplo anterior, se crea una comunicación alternativa entre módulos a través de la variable global. Ahora un módulo puede influir por completo en otro modificando el valor de una variable global. Los módulos dejan de ser “cajas negras” y pasan a tener fuertes dependencias mutuas que es necesario controlar. Cuando el programa es complejo y consta de muchos módulos, ese control de las dependencias es cada vez más difícil de hacer.

Cualquier comunicación de datos entre un algoritmo y un subalgoritmo al margen de los parámetros y la devolución de resultados se denomina efecto lateral. Los efectos laterales, como el ilustrado en el ejemplo anterior, son peligrosísimos y fuente habitual de malfuncionamiento de los programas. Por esa razón, debemos tomar como norma:

  • Primero, evitar la utilización de variables globales.
  • Segundo, si no quedara más remedio que emplear variables globales, no hacer uso de ellas en el interior de los procedimientos y las funciones, siendo preferible pasar el valor de la variable global como un parámetro más al subalgoritmo.

La reutilización de módulos

El diseño modular tiene, entre otras ventajas, la posibilidad de reutilizar módulos previamente escritos. Es habitual que, una vez resuelto un problema sencillo mediante una función o un procedimiento, ese mismo problema, o uno muy parecido, se nos presente más adelante, durante la realización de otro programa. Entonces nos bastará con volver a utilizar esa función o procedimiento, sin necesidad de volver a escribirlo.

Es por esto, entre otras razones, que los módulos deben ser independientes entre sí, comunicándose con otros módulos únicamente mediante los datos de entrada (paso de parámetros por valor) y los de salida (devolución de resultados – en las funciones – y paso de parámetros por referencia). Los módulos que escribamos de este modo nos servirán probablemente para otros programas, pero no así los módulos que padezcan efectos laterales, pues sus relaciones con el resto del programa del que eran originarios serán diferentes y difíciles de precisar.

Es habitual agrupar varios algoritmos relacionados (por ejemplo: varios algoritmos que realicen diferentes operaciones matemáticas) en un mismo archivo, formando lo que se denomina una biblioteca de funciones. Cada lenguaje trata las librerías de manera distinta, de modo que volveremos sobre este asunto en los posts dedicados al lenguaje C.

Por último, señalemos que, para reutilizar con éxito el código, es importante que esté bien documentado. En concreto, en cada algoritmo deberíamos documentar claramente:

  • la función del algoritmo, es decir, explicar qué hace
  • los parámetros de entrada
  • los datos de salida, es decir, el resultado que devuelve o la forma de utilizar los parámetros por referencia

Como ejemplo, documentaremos la función potencia() que hemos utilizado como ejemplo en otras partes de esta unidad didáctica. Es un caso exagerado, pues la función es muy sencilla y se entiende sin necesidad de tantos comentarios, pero ejemplifica cómo se puede hacer la documentación de una función.

{ Función: potencia() --> Calcula una potencia de números enteros
  Entrada: base       --> Base de la potencia
           exponente  --> Exponente de la potencia
  Salida:  base elevado a exponente }

real función potencia(base es real, exponente es real)
inicio
   devolver (base ^ exponente)
fin

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

El paso de parámetros, o comunicación de datos del algoritmo invocante al subalgoritmo invocado, suele causar un montón de dudas y problemas cuando uno empieza a estudiar programación. Vamos a tratar de aclarar los conceptos en este post.

El paso de parámetros puede hacerse mediante, al menos, dos métodos:

  • Paso de parámetros por valor, que es la forma más sencilla pero no permite al subalgoritmo devolver resultados en los parámetros.
  • Paso de parámetros por referencia, que es más complejo pero permite a los subalgoritmos devolver resultados en los parámetros.

Veamos cada método detenidamente.

Paso de parámetros por valor

Los subalgoritmos/subprogramas, como hemos visto, pueden tener una serie de parámetros en su declaración. Estos parámetros se denominan parámetros formales.

Por ejemplo, una función que calcula la potencia de un número elevado a otro podría ser así:

real función potencia(base es real, exponente es real)
inicio
   devolver (base ^ exponente)
fin

En esta función, base y exponente son los parámetros formales.

Cuando el subalgoritmo es invocado, se le pasan entre paréntesis los valores de los parámetros. A éstos se les denomina parámetros actuales; por ejemplo:

A = 5
B = 3
C = potencia(A,B)

En esta invocación de la función potencia(), los parámetros actuales son A y B, es decir, 5 y 3.

Al invocar un subalgoritmo, los parámetros actuales son asignados a los parámetros formales en el mismo orden en el que fueron escritos. Dentro del subalgoritmo, los parámetros se pueden utilizar como si fueran variables. Así, en el ejemplo anterior, dentro de la función potencia(), el parámetro base puede usarse como una variable a la que se hubiera asignado el valor 5, mientras que exponente es como una variable a la que se hubiera asignado el valor 3.

Cuando el subalgoritmo termina de ejecutarse, sus parámetros formales base y exponente dejan de existir y se devuelve el resultado (en nuestro ejemoplo, 53), que se asigna a la variable C.

Paso de parámetros por referencia

En el paso de parámetros por referencia se produce una ligadura entre el parámetro actual y el parámetro formal, de modo que si el parámetro formal se modifica dentro del subalgoritmo, el parámetro actual, propio del algoritmo principal, también será modificado.

Los argumentos pasan sus parámetros por valor excepto cuando indiquemos que el paso es por referencia colocando el símbolo * (asterisco) delante del nombre del argumento.

Un ejemplo. Escribiremos el mismo subalgoritmo de antes, pero utilizando un procedimiento (que, en principio, no devuelve resultados) en lugar de una función.

procedimiento potencia(base es real, exponente es real, *resultado es real)
inicio
   resultado = base ^ exponente
fin

Observe el símbolo * delante del nombre del argumento resultado: esa es la señal de que el paso de parámetros será por referencia para ese argumento. Si no aparece el símbolo *, el paso será por valor, como es el caso de los argumentos base y exponente.

La invocación del subalgoritmo se hace del mismo modo que hasta ahora, pero delante del parámetro que se pasa por referencia debe colocarse el símbolo &:

A = 5
B = 3
C = 0
potencia(A, B, &C)

En este caso, pasamos tres parámetros actuales, ya que el subalgoritmo tiene tres parámetros formales. El tercero de ellos, C, se pasa por referencia (para señalar esta circunstancia, se antepone el símbolo &), y por lo tanto queda ligado al parámetro formal resultado.

El parámetro formal es modificado en la instrucción resutado = base ^ exponente, y como está ligado con el parámetro actual C, el valor de la variable C también se modifica. Por lo tanto, C toma el valor 53.

Cuando el subalgoritmo termina de ejecutarse, dejan de existir todos sus parámetros formales (base, exponente y resultado), pero la ligadura de resultado con la variable C hace que esta variable conserve el valor 53 incluso cuando el parámetro resultado ya no exista.

Diferencias entre los métodos de paso de parámetros

La utilidad del método de paso de parámetros por referencia es evidente: un subalgoritmo puede devolver tantos resultados como argumentos tenga, y no tiene que limitarse a un único resultado, como en el caso de las funciones.

El paso de parámetros por referencia suele, por lo tanto, usarse en procedimientos que tienen que devolver muchos resultados al algoritmo que los invoca. Cuando el resultado es sólo uno, lo mejor es emplear una función. Esto no quiere decir que las funciones no puedan tener argumentos pasados por referencia: al contrario, a veces es muy útil.

Expresado de otro modo:

  • el paso por valor es unidireccional, es decir, sólo permite transmitir datos del algoritmo al subalgoritmo a través de los argumentos.
  • el paso por referencia es bidireccional, es decir, permite transmitir datos del algoritmo al subalgoritmo, pero también permite al subalgoritmo transmitir resultados al algoritmo.

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

Funciones

Las funciones son subalgoritmos (o módulos) que resuelven un problema sencillo y devuelven un resultado al algoritmo que las invoca.

Las funciones pueden tener argumentos, aunque no es obligatorio. Los argumentos son los datos que se proporcionan a la función en la invocación, y que la función utilizará para sus cálculos.

Además, las funciones tienen, obligatoriamente, que devolver un resultado. Este resultado suele almacenarse en una variable para usarlo posteriormente.

Por ejemplo, cuando utilizamos las funciones matemáticas de biblioteca (es decir, predefinidas en el lenguaje), siempre escribimos algún dato entre paréntesis para que la función realice sus cálculos con ese dato. Pues bien, ese dato es el argumento o parámetro de entrada:

A = raiz(X)
B = redondeo(7.8)
N = aleatorio(100)

En estas tres instrucciones de asignación, se invoca a las funciones raiz(), redondeo()y aleatorio(), pasándoles los argumentos X y 7.8. Éstas son funciones que los lenguajes de programación incorporan por defecto, junto con muchas otras que iremos descubriendo con el uso.

Ambas funciones devuelven un resultado; el resultado de la función raiz() se almacena en la variable A, el de redondeo() en la variable B y el de la función aleatorio() en la variable N.

Declaración de funciones

No sólo de funciones de biblioteca vive el programador. Como es lógico, también podemos crear nuestras propias funciones para invocarlas cuando nos sea necesario.

Recuerde que una función no es más que un módulo, es decir, un subalgoritmo que depende, directamente o a través de otro subalgoritmo, del algoritmo principal. Por tanto, su estructura debe ser similar a la de cualquier otro algoritmo.

La sintaxis en pseudocódigo de una función es:

tipo_resultado función nombre_función(lista_de_argumentos)
constantes
   lista_de_constantes
variables
   lista_de_variables
inicio
   acciones
   devolver (expresión)
fin

Observe que es exactamente igual que cualquier otro algoritmo, excepto por la primera línea, que ya no contiene la palabra “algoritmo” e incluye algunos elementos nuevos:

  • El tipo_resultado es el tipo de datos del resultado que devuelve la función
  • El nombre_función es el identificador de la función
  • La lista_de_argumentos es una lista con los parámetros que se le pasan a la función

También aparece una nueva sentencia, devolver(expresión), justo al final de la función. La expresión se evalúa y su resultado es devuelto al algoritmo que invocó a la función. El tipo de la expresión debe coincidir con el de tipo_resultado.

De todos estos elementos nuevos, el más complejo con diferencia es la lista de argumentos, ya que pueden existir argumentos de entrada, de salida y de entrada/salida. El problema de los argumentos lo trataremos en profundidad en otro post. Por ahora, diremos que es una lista de esta forma:

parámetro_1 es tipo_de_datos_1, parámetro_2 es tipo_de_datos_2, etc.

Ahí va un ejemplo: una función que calcula el área de un círculo. El radio se pasa como argumento de tipo real.

real función área_círculo (radio es real)
variables
   área es real
inicio
   área = 3.14 * radio ^ 2
   devolver (área)
fin

Fíjese en que la función no es más que un algoritmo normal y corriente, salvo por dos detalles:

  • La primera línea. En ella aparece más información: el tipo de valor devuelto por la función (real, puesto que calcula el área del círculo), el nombre de la función (área_círculo) y la lista de argumentos. En esta función sólo hay un argumento, llamado radio. Es de tipo real.
  • La penúltima línea (antes de fin). Contiene el valor que la función devuelve. Debe ser una expresión del mismo tipo que se indicó en la primera línea (en este ejemplo, real).

Invocación de funciones

Para que las instrucciones escritas en una función sean ejecutadas es necesario que la función se llame o invoque desde otro algoritmo.

La invocación consiste en una mención al nombre de la función seguida, entre paréntesis, de los valores que se desan asignar a los argumentos. Deben aparecer tantos valores como argumentos tenga la función, y además coincidir en tipo. Estos valores, que los teóricos llaman parámetros actuales, se asignarán a los argumentos (que los teóricos llaman parámetros formales) y se podrán utilizar, dentro de la función, como si de variables se tratase.

Como las funciones devuelven valores, es habitual que la invocación aparezca junto con una asignación a variable para guardar el resultado y utilizarlo más adelante.

Ejemplo 1: Un algoritmo que calcula el área de un círculo mediante el empleo de la función vista en el ejemplo anterior. La función área_círculo() que acabamos de ver puede ser invocada desde otro módulo, igual que invocamos las funciones de biblioteca como raiz() o redondeo()

algoritmo círculo
variables
   A, B, R son reales
inicio
   leer(R)
   A = área_círculo(R)
   escribir(A)
fin

Este fragmento de código invocará la función área_círculo() con el argumento R. La función se ejecutará con el valor de R asociado al identificador radio, exactamente igual que si éste fuera una variable y hubiéramos hecho la asignación radio = R. Una vez calculado el resultado, la función lo devuelve al módulo que la invocó, y por tanto el valor del área se asigna a la variable A. Por último, el valor de A se escribe en la pantalla.

Ejemplo 2: Un algoritmo que calcula el cuadrado y el cubo de un valor X introducido por teclado, utilizando funciones. Aunque el algoritmo es simple y podría resolverse sin modularidad, forzaremos la situación construyendo dos funciones, cuadrado() y cubo():

algoritmo cuadrado_cubo
variables
   N, A, B son reales
inicio
   leer(N)
   A = cuadrado(N)
   B = cubo(N)
   escribir("El cuadrado es ", A)
   escribir("El cubo es ", B)
fin

real función cuadrado (número es real)    // Devuelve el cuadrado de un número
inicio
   devolver (número ^ 2)
fin

real función cubo (número es real)    // Devuelve el cubo de un número
inicio
   devolver (número ^ 3)
fin

Fíjese en que hemos escrito las funciones después del algoritmo principal. Esto puede variar dependiendo del lenguaje utilizado.

Procedimientos

Las funciones son muy útiles como herramientas de programación, pero tienen una seria limitación: sólo pueden devolver un resultado al algoritmo que las invoca. Y en muchas ocasiones es necesario devolver más de un resultado.

Para eso existen los procedimientos, también llamados subrutinas, que son, en esencia, iguales a las funciones, es decir:

  • son algoritmos independientes que resuelven algún problema sencillo
  • pueden recibir datos de entrada del algoritmo que los invoca
  • el algoritmo que los invoca queda momentáneamente en suspenso mientras se ejecuta el procedimiento y, cuando éste termina, el algoritmo principal continúa ejecutándose

Pero existe una diferencia fundamental entre las funciones y los procedimientos: los procedimientos pueden devolver 0, 1 o más resultados, mientras que las funciones siempre devuelven uno.

Los procedimientos son, por lo tanto, módulos más generales que las funciones. La declaración de un procedimiento es similar a la de una función, pero sustituyendo la palabra función por procedimiento y sin indicar el tipo de datos del resultado; tampoco tienen sentencia devolver al final del código:

procedimiento nombre_procedimiento(lista_de_argumentos)
constantes
   lista_de_constantes
variables
   lista_de_variables
inicio
   acciones
fin

Pero, si no tienen sentencia devolver, ¿cómo devuelve un procedimiento los resultados al algoritmo que lo invoca? La única posibilidad es utilizar los parámetros como puerta de dos direcciones, es decir, que no solo sirvan para que el algoritmo comunique datos al subalgoritmo, sino también para comunicar datos desde el subalgoritmo hacia el algoritmo.

Para ello necesitamos saber más cosas sobre el paso de parámetros, que es lo que tratamos en este otro post.

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

Hay otra confusión que me he encontrado muchas veces entre los estudiantes que empiezan a hacer sus pinitos con la programación de ordenadores. Consiste en mezclar dos conceptos relacionados pero no sinónimos (ni mucho menos excluyentes): programación estructurada y programación modular.

De la programación estructurada ya hablamos aquí y en unas cuantas entradas posteriores que están enlazadas en ese mismo artículo. Hoy nos dedicaremos a aclarar el concepto de programación modular y a relacionarlo con el de programación estructurada.

Una definición de programación modular

Podemos definir la programación modular como aquélla que afronta la solución de un problema descomponiéndolo en subproblemas más simples, cada uno de los cuales se resuelve mediante un algoritmo o módulo más o menos independiente del resto (de ahí su nombre: “programación modular”).

Las ventajas de la programación modular son varias:

  • Facilita la comprensión del problema y su resolución escalonada
  • Aumenta la claridad y legibilidad de los programas
  • Permite que varios programadores trabajen en el mismo problema a la vez, puesto que cada uno puede trabajar en uno o varios módulos de manera bastante independiente
  • Reduce el tiempo de desarrollo, reutilizando módulos previamente desarrollados
  • Mejora la fiabilidad de los programas, porque es más sencillo diseñar y depurar módulos pequeños que programas enormes
  • Facilita el mantenimiento de los programas

Resumiendo, podemos afirmar sin temor a equivocarnos que es virtualmente imposible escribir un programa de grandes dimensiones si no procedemos a dividirlo en fragmentos más pequeños, abarcables por nuestro pobre intelecto humano.

Insisto en que la programación modular y la estructurada no son técnicas incompatibles, sino más bien complementarias. La mayoría de los programas que se desarrollan con lenguajes estructurados son, de hecho, estructurados y modulares al mismo tiempo.

Pero expliquemos más despacio que es eso de “descomponer un problema en subproblemas simples”…

Descomposición modular: ¡divide y vencerás!

La forma más habitual de diseñar algoritmos para resolver problemas de cierta envergadura se suele denominar, muy certeramente, divide y vencerás (en inglés, divide and conquer o simplemente DAC). Fíjese en que hemos dicho “diseñar” algoritmos: estamos adentrándonos, al menos en parte, en la fase de diseño del ciclo de vida del software.

El método DAC consiste en dividir un problema complejo en subproblemas, y tratar cada subproblema del mismo modo, es decir, dividiéndolo a su vez en subproblemas. Así sucesivamente hasta que obtengamos problemas lo suficientemente sencillos como para escribir algoritmos que los resuelvan. Llamaremos módulo a cada uno de estos algoritmos que resuelven los problemas sencillos.

Una vez resueltos todos los subproblemas, es decir, escritos todos los módulos, es necesario combinar de algún modo las soluciones para generar la solución global del problema.

Esta forma de diseñar una solución se denomina diseño descendente o top-down. No es la única técnica de diseño que existe, pero sí la más utilizada.

Resumiendo lo dicho hasta ahora, el diseño descendente debe tener dos fases:

  1. La identificación de los subproblemas más simples y la construcción de algoritmos que los resuelvan (módulos)
  2. La combinación de las soluciones de esos algoritmos para dar lugar a la solución global

La mayoría de lenguajes de programación estructurada permiten aplicar técnicas de diseño descendente mediante un proceso muy simple: independizando fragmentos de código en subprogramas o módulos denominados procedimientos y funciones, que en otro post analizaremos en profundidad.

Algoritmo principal y subalgoritmos

En general, el problema principal se resuelve en un algoritmo que denominaremos algoritmo o módulo principal, mientras que los subproblemas sencillos se resolverán en subalgoritmos, también llamados módulos a secas. Los subalgoritmos están subordinados al algoritmo principal, de manera que éste es el que decide en qué orden deben ejecutarse los subalgoritmo y con qué conjunto de datos.

El algoritmo principal realiza llamadas o invocaciones a los subalgoritmos, mientras que éstos devuelven resultados a aquél. Así, el algoritmo principal va recogiendo todos los resultados y puede generar la solución al problema global.

progmodular1.png

Cuando el algoritmo principal hace una llamada al subalgoritmo (es decir, lo invoca), se empiezan a ejecutar las instrucciones del subalgoritmo. Cuando éste termina, devuelve los datos de salida al algoritmo principal, y la ejecución continúa por la instrucción siguiente a la de invocación. También se dice que el subalgoritmo devuelve el control al algoritmo principal, ya que éste toma de nuevo el control del flujo de instrucciones después de habérselo cedido temporalmente al subalgoritmo.

El programa principal puede invocar a cada subalgoritmo el número de veces que sea necesario. A su vez, cada subalgoritmo puede invocar a otros subalgoritmos, y éstos a otros, etc. Cada subalgoritmo devolverá los datos y el control al algoritmo que lo invocó.

progmodular2.png

Los subalgoritmos pueden hacer las mismas operaciones que los algoritmos, es decir: entrada de datos, proceso de datos y salida de datos. La diferencia es que los datos de entrada se los proporciona el algoritmo que lo invoca, y los datos de salida son devueltos también a él para que haga con ellos lo que considere oportuno. No obstante, un subalgoritmo también puede, si lo necesita, tomar datos de entrada desde el teclado (o desde cualquier otro dispositivo de entrada) y enviar datos de salida a la pantalla (o a cualquier otro dispositivo de salida).

Un ejemplo

Vamos a diseñar un algoritmo que calcule el área y la circunferencia de un círculo cuyo radio se lea por teclado. Se trata de un problema muy simple que puede resolverse sin aplicar el método divide y vencerás, pero lo utilizaremos como ilustración.

Dividiremos el problema en dos subproblemas más simples: por un lado, el cálculo del área, y, por otro, el cálculo de la circinferencia. Cada subproblema será resuelto en un subalgoritmo, que se invocará desde el algoritmo principal. La descomposición en algoritmos y subalgoritmos sería la siguiente (se indican sobre las flechas los datos que son interrcambiados entre los módulos):

progmodular5.png

Lógicamente, los subalgoritmos deben tener asignado un nombre para que puedan ser invocados desde el algoritmo principal, y también existe un mecanismo concreto de invocación/devolución.

Nivel de descomposición modular

Los problema complejos, como venimos diciendo, se descomponen sucesivamente en subproblemas más simples cuya solución combinada dé lugar a la solución general. Pero, ¿hasta dónde es necesario descomponer? O, dicho de otro modo, ¿qué se puede considerar un “problema simple” y qué no?

La respuesta se deja al sentido común y a la experiencia del diseñador del programa. Como regla general, digamos que un módulo no debería constar de más de 30 ó 40 líneas de código. Si obtenemos un módulo que necesita más código para resolver un problema, probablemente podamos dividirlo en dos o más subproblemas. Por supuesto, esto no es una regla matemática aplicable a todos los casos. En muchas ocasiones no estaremos seguros de qué debe incluirse y qué no debe incluirse en un módulo.

Tampoco es conveniente que los módulos sean excesivamente sencillos. Programar módulos de 2 ó 3 líneas daría lugar a una descomposición excesiva del problema, aunque habrá ocasiones en las que sea útil emplear módulos de este tamaño.

Diagramas de estructura modular

La estructura modular, es decir, el conjunto de módulos de un programa y la forma en que se invocan unos a otros, se puede representar gráficamente mediante un diagrama de estructura modular. Esto es particularmente útil si el programa es complejo y consta de muchos módulos con relaciones complicadas entre sí.

En el diagrama se representan los módulos mediante cajas, en cuyo interior figura el nombre del módulo, unidos por líneas, que representan las interconexiones entre ellos. En cada línea se pueden escribir los parámetros de invocación y los datos devueltos por el módulo invocado.

El diagrama de estructura siempre tiene forma de árbol invertido. En la raíz figura el módulo principal, y de él “cuelgan” el resto de módulos en uno o varios niveles.

En el diagrama también se puede representar el tipo de relación entre los módulos. Las relaciones posibles se corresponden exactamente con los tres tipos de estructuras básicas de la programación estructurada:

  • Estructura secuencial: cuando un módulo llama a otro, después a otro, después a otro, etc.
  • Estructura selectiva: cuando un módulo llama a uno o a otro dependiendo de alguna condición
  • Estructura iterativa: cuando un módulo llama a otro (o a otros) en repetidas ocasiones

Las tres estructuras de llamadas entre módulos se representan con tres símbolos diferentes:

progmodular3.png

A modo de ejemplo, veamos el diagrama de estructura del algoritmo que calcula el área y la circunferencia de un círculo, que pusimos como ejemplo un poco más arriba. La descomposición modular que hicimos entonces consistía en un algoritmo principal que llamaba a dos subalgoritmos: uno para calcular el área y otro para calcular la circunferencia.

progmodular4.png

Los dos subalgoritmos (o módulos) son llamados en secuencia, es decir, uno tras otro, por lo que lo representamos con la estructura secuencial. El módulo principal pasará a los dos subalgoritmos el radio (R) del círculo, y cada subalgoritmo devolverá al módulo principal el resultado de sus cálculos.

Escritura del programa

Una vez diseñada la estructura modular, llega el momento de escribir los algoritmos y subalgoritmos mediante pseudocódigo, diagramas de flujo o cualquier otra herramienta. Lo más habitual es comenzar por los módulos (subalgoritmos) de nivel inferior e ir ascendiendo por cada rama del diagrama de estructura.

Lo dicho hasta ahora respecto de algoritmos y subalgoritmos se puede traducir en programas y subprogramas cuando pasemos del pseudocódigo a lenguajes de programación concretos, como C. Los subprogramas, en general, se pueden dividir en dos tipos, muy similares pero con alguna sutil diferencia: las funciones y los procedimientos, que estudiamos en este otro post.

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

La escritura de un programa debe ser siempre lo más clara posible, ya se esté escribiendo en pseudocódigo o en un lenguaje de programación real. La razón es evidente: los algoritmos pueden llegar a ser muy complejos, y, si a su complejidad le añadimos una escritura sucia y desordenada, se volverán ininteligibles.

Esto es un aviso para navegantes. Todos los programadores han experimentado la frustración que se siente al ir a revisar un algoritmo redactado pocos días antes y no entender ni una palabra de lo que uno mismo escribió. El problema es aún más devastador en el caso de revisión de algoritmos escritos por otras personas.

Por esta razón, y ya desde el principio, debemos acostumbrarnos a respetar ciertas reglas básicas de estilo. Cierto que cada programador puede luego desarrollar su estilo propio, pero siempre dentro de un marco aceptado por la mayoría.

Partes de un algoritmo

Los algoritmos deberían tener siempre una estructura en tres partes:

1. Cabecera

2. Declaraciones

3. Acciones

Algunos lenguajes, C entre ellos, son lo bastante flexibles como para permitir saltarse a la torera esta estructura, pero es una buena costumbre respetarla siempre:

  • La cabecera: contiene el nombre del programa o algoritmo.
  • Las declaraciones: contiene las declaraciones de variables y constantes que se usan en el algoritmo
  • Las acciones: son el cuerpo en sí del algoritmo, es decir, las instrucciones

Documentación

La documentación del programa comprende el conjunto de información interna y externa que facilita su posterior mantenimiento.

  • La documentación externa la forman todos los documentos ajenos al programa: guías de instalación, guías de usuario, etc.
  • La documentación interna es la que acompaña al programa. Nosotros sólo nos ocuparemos, por ahora, de esta documentación.

La forma más habitual de plasmar la documentación interna es por medio de comentarios significativos que acompañen a las instrucciones del algoritmo o programa. Los comentarios son líneas de texto insertadas entre las instrucciones, o bien al lado, que se ignoran durante la ejecución del programa y aclaran el funcionamiento del algoritmo a cualquier programador que pueda leerlo en el futuro.
Para que el compilador o el intérprete sepa qué debe ignorar y qué debe ejecutar, los comentarios se escriben precedidos de determinados símbolos que la máquina interpreta como “principio de comentario” o “fin de comentario”.

Los símbolos que marcan las zonas de comentario dependen del lenguaje de programación, como es lógico. Así, por ejemplo, en Pascal se escriben encerrados entre los símbolos (* y *):

(* Esto es un comentario en Pascal *)

El lenguaje C, sin embargo, utiliza los símbolos /* y */ para marcar los comentarios. Además, C++ permite emplear la doble barra ( / / ) para comentarios que ocupen sólo una línea. Nosotros usaremos indistintamente estos dos métodos:

/* Esto es un comentario en C */
// Esto es un comentario en C++

He aquí un ejemplo de algoritmo comentado: un algoritmo que suma todos los números naturales de 1 hasta 1000

algoritmo sumar1000

/* Función: Sumar los números naturales entre 1 y 1000

Autor: Jaime Tralleta

Fecha: 12-12-04 */
variables

  cont es entero /* variable contador */

  suma es entero /* variable acumulador */

  N es entero

inicio

  suma = 0 /* se pone el acumulador a 0 */

  para cont desde 1 hasta 1000 hacer /* repetir 1000 veces */

  inicio

    suma = suma + cont /* los números se suman al acumulador */

  fin

  escribir (suma)

fin

Observe que los comentarios aparecen a la derecha de las instrucciones, encerrados entre llaves. A efectos de ejecución, se ignora todo lo que haya escrito entre los símbolos /* y */, pero a efectos de documentación y mantenimiento, lo que haya escrito en los comentarios puede ser importantísimo.

Una buena e interesante costumbre es incluir un comentario al principio de cada algoritmo que explique bien la función del mismo y, si se considera necesario, el autor, la fecha de modificación y cualquier otra información que se considere interesante.

Pero ¡cuidado! Comentar un programa en exceso no sólo es tedioso para el programador, sino contraproducente, porque un exceso de documentación lo puede hacer más ilegible. Sólo hay que insertar comentarios en los puntos que se considere que necesitan una explicación. En este sentido, el algoritmo del ejemplo está demasiado comentado.

Estilo de escritura

En este blog encontrará muchos algoritmos. Si se fija en ellos, verá que todos siguen ciertas convenciones en el uso de la tipografía, las sangrías, los espacios, etc. Escribir los algoritmos cumpliendo estas reglas es una sana costumbre.

Sangrías

Las instrucciones que aparezcan debajo de “inicio” deben tener una sangría mayor que dicha instrucción. Ésta sangría se mantendrá hasta la aparición del “fin” correspondiente. Esto es particularmente importante cumplirlo si existen varios bloques inicio–fin anidados. Asimismo, un algoritmo es más fácil de leer si los comentarios tienen todos la misma sangría.

Ejemplo: Escribir un algoritmo que determine, entre dos números A y B, cuál es el mayor o si son iguales. Observe bien las sangrías de cada bloque de instrucciones, así como la posición alineada de los comentarios.

algoritmo comparar

// Función: Comparar dos números A y B

variables

  A,B son enteros

inicio

  leer (A) // leemos los dos números del teclado

  leer (B)

  si (A == B) entonces // los números son iguales

  inicio

    escribir ('Los dos números son iguales')

  fin

  si_no // los números son distintos, así que

  inicio // vamos a compararlos entre sí

    si (A > B) entonces

    inicio // A es mayor

      escribir ('A es mayor que B')

    fin

    si_no

    inicio // B es mayor

      escribir ('B es mayor que A')

    fin

  fin

fin
Prescindir de “inicio” y “fin”

Cuando un bloque de instrucciones sólo contiene una instrucción, podemos escribirla directamente, sin necesidad de encerrarla entre un “inicio” y un “fin”. Esto suele redundar en una mayor facilidad de lectura.

Ejemplo: Repetiremos el mismo ejemplo anterior, prescindiendo de los “inicio” y “fin” que no sean necesarios. Fíjese en que el algoritmo es más corto y, por lo tanto, más fácil de leer y entender.

algoritmo comparar

// Función: Comparar dos números A y B

variables

  A,B son enteros

inicio

  leer (A) // leemos los dos números del teclado

  leer (B)

  si (A == B) entonces // los números son iguales

    escribir ('Los dos números son iguales')

  si_no // los números son distintos, así que

  inicio // vamos a compararlos entre sí

    si (A > B) entonces

      escribir ('A es mayor que B')

    si_no

      escribir ('B es mayor que A')

  fin

fin
Tipografía

En muchos textos, se resaltan las palabras clave del lenguaje de programación en negrita, para distinguirlas de identificadores de variable, símbolos, etc. Muchos editores de texto pensados para escribir programas con ellos también lo hacen, utilizando diversos colores para distinguir los elementos entre sí. Esto aumenta la legibilidad del algoritmo, aunque tiene sus detractores.

Para escribir algoritmos con un procesador de texto convencional o usando pseudocódigo, es conveniente que usar una fuente de tamaño fijo (el tipo Courier va bastante bien).

Espacios

Otro elemento que aumenta la legibilidad es espaciar suficientemente (pero no demasiado) los distintos elementos de cada instrucción. Por ejemplo, esta instrucción ya es bastante complicada y difícil de leer:

si (a > b) y (c > d * raiz(k) ) entonces a = k + 5.7 * b

Pero se lee mucho mejor que esta otra, en la que se han suprimido los espacios (excepto los imprescindibles):

si(a>b)y(c>d*raiz(k))entonces a=k+5.7*b

Al ordenador le dará igual si escribimos (a > b) o (a>b), pero a cualquier programador que deba leer nuestro código le resultará mucho más cómoda la primera forma.

Por la misma razón, también es conveniente dejar líneas en blanco entre determinadas instrucciones del algoritmo cuando se considere que mejora la legibilidad.

Identificadores

A la hora de elegir identificadores de variables (o de constantes) es muy importante utilizar nombres que sean significativos, es decir, que den una idea de la información que almacena esa variable. Por ejemplo, si en un programa de nóminas vamos a guardar en una variable la edad de los empleados, es una buena ocurrencia llamar a esa variable “edad”, pero no llamarla “X”, “A” o “cosa”.

Ahora bien, dentro de esta política de elegir identificadores significativos, es conveniente optar por aquellos que sean lo más cortos posible, siempre que sean descifrables. Así, un identificador llamado “edad_de_los_empleados” es engorroso de escribir y leer, sobre todo si aparece muchas veces en el algoritmo, cuando probablemente “edad_empl” proporciona la misma información. Sin embargo, si lo acortamos demasiado (por ejemplo “ed_em”) llegará un momento en el quede claro lo que significa.

Toda esta idea de significación de los identificadores es extensible a los nombres de los algoritmos, de las funciones, de los procedimientos, de los archivos y, en general, de todos los objetos relacionados con un programa.

En ciertos lenguajes existen convenciones más o menos rígidas para formar identificadores largos. También pueden existir acuerdos (escritos o tácitos) en empresas y organizaciones, así que uno debe amoldarse al sitio y al lenguaje. El objetivo es evitar que unos programadores usen identificadores del tipo edad_de_los_empleados, mientras que otros bauticen a esta variable edadDeLosEmpleados.
Por último, señalar que muchos lenguajes de programación distinguen entre mayúsculas y minúsculas, es decir, que para ellos no es lo mismo el identificador “edad” que “Edad” o “EDAD”. Es conveniente, por tanto, ir acostumbrándose a esta limitación. Nosotros preferiremos usar identificadores en minúscula, por ser lo más habitual entre los programadores de lenguaje C.

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

Asociadas a los bucles se encuentran a menudo algunas variables auxiliares. Como siempre se utilizan de la misma manera, las llamamos con un nombre propio (contador, acumulador, etc.), pero hay que dejar claro que no son más que variables comunes, aunque se usan de un modo especial.

Contadores

Un contador es una variable (casi siempre de tipo entero) cuyo valor se incrementa o decrementa en cada repetición de un bucle. Es habitual llamar a esta variable “cont” (de contador) o “i” (de índice).

El contador suele usarse de este modo:

Primero se inicializa antes de que comience el bucle. Es decir, se le da un valor inicial. Por ejemplo:

 cont = 5

Segundo, se modifica dentro del cuerpo del bucle. Lo más habitual es que se incremente su valor en una unidad. Por ejemplo:

 cont = cont + 1

Esto quiere decir que el valor de la variable “cont” se incrementa en una unidad y es asignado de nuevo a la variable contador. Es decir, si cont valía 5 antes de esta instrucción, cont valdrá 6 después.

Otra forma típica del contador es:

 cont = cont – 1

En este caso, la variable se decrementa en una unidad; si cont valía 5 antes de la instrucción, tendremos que cont valdrá 4 después de su ejecución.

El incremento o decremento no tiene por qué ser de una unidad. La cantidad que haya que incrementar o decrementar vendrá dada por la naturaleza del problema.

Tercero, se utiliza en la condición de salida del bucle. Normalmente, se compara con el valor máximo (o mínimo) que debe alcanzar el contador para dejar de repetir las instrucciones del bucle.

Ejemplo: Escribir un algoritmo que escriba la tabla de multiplicar hasta el 100 de un número N introducido por el usuario

 algoritmo tabla_multiplicar
 variables
   cont es entero
   N es entero
 inicio
   leer (N)
   cont = 1
   mientras (cont <= 100) hacer
   inicio
     escribir (N * cont)
     cont = cont + 1
   fin
 fin

El uso de contadores es casi obligado en bucles “mientras” y “repetir” que deben ejecutarse un determinado número de veces. Recuerde que siempre hay que asignar al contador un valor inicial para la primera ejecución del bucle (cont = 1 en nuestro ejemplo) e ir incrementándolo (o decrementándolo, según el algoritmo) en cada repetición con una instrucción del tipo cont = cont + 1 en el cuerpo del bucle. De lo contrario habremos escrito un bucle infinito.

Por último, hay que prestar atención a la condición de salida, que debe estar asociada al valor del contador en la última repetición del bucle (en nuestro caso, 100). Mucho cuidado con el operador relacional (<, >, <=, >=, etc) que usemos, porque el bucle se puede ejecutar más o menos veces de lo previsto

Acumuladores

Las variables acumuladoras tienen la misión de almacenar resultados sucesivos, es decir, de acumular resultados, de ahí su nombre.

Las variables acumuladores también debe ser inicializadas. Si llamamos “acum” a un acumulador, escribiremos antes de iniciar el bucle algo como esto:

 acum = 0

Por supuesto, el valor inicial puede cambiar, dependiendo de la naturaleza del problema. Más tarde, en el cuerpo del bucle, la forma en la que nos la solemos encontrar es:

 acum = acum + N

…siendo N otra variable. Si esta instrucción va seguida de otras:

 acum = acum + M
 acum = acum + P

… estaremos acumulando en la variable “acum” los valores de las variables M, N, P, etc, lo cual resulta a veces muy útil para resolver ciertos problemas repetitivos.

Ejemplo: Escribir un algoritmo que pida 10 números por el teclado y los sume, escribiendo el resultado

algoritmo sumar10
variables
   cont es entero
   suma es entero
   N es entero
 inicio
   suma = 0
   para cont desde 1 hasta 10 hacer
   inicio
     leer (N)
     suma = suma + N
   fin
   escribir (suma)
 fin

En este algoritmo, cont es una variable contador típica de bucle. Se ha usado un bucle “para”, que es lo más sencillo cuando conocemos previamente el número de repeticiones (10 en este caso). La variable Nsuma es el acumulador, donde se van sumando los diferentes valores que toma N en cada repetición. se usa para cada uno de los números introducidos por el teclado, y la variable

Observe como, al principio del algoritmo, se le asigna al acumulador el valor 0. Esta es una precaución importante que se debe tomar siempre porque el valor que tenga una variable que no haya sido usada antes es desconocido (no tiene por qué ser 0)

Conmutadores

Un conmutador (o interruptor) es una variable que sólo puede tomar dos valores. Pueden ser, por tanto, de tipo booleano, aunque también pueden usarse variables enteras o de tipo carácter.

La variable conmutador recibirá uno de los dos valores posibles antes de entrar en el bucle. Dentro del cuerpo del bucle, debe cambiarse ese valor bajo ciertas condiciones. Utilizando el conmuntador en la condición de salida del bucle, puede controlarse el número de repeticiones.

Ejemplo: Escribir un algoritmo que sume todos los números positivos introducidos por el usuario a través del teclado. Para terminar de introducir números, el usuario tecleará un número negativo.

 algoritmo sumar
 variables
   suma es entero
   N es entero
   terminar es lógico
 inicio
   suma = 0
   terminar = falso
   mientras (terminar == falso)
     inicio
       escribir ('Introduce un número (negativo para terminar)')
       leer (N)
       si (N >= 0) entonces
          suma = suma + N
       si_no
          terminar = verdadero
     fin
   fin
   escribir (suma)
 fin

Con este programa, el usuario puede ir introduciendo números indefinidamente, hasta que se canse. Para indicar al ordenador que ha terminado de introducir números, debe teclear un número negativo.

El bucle se controla por medio de la variable “terminar”: es el conmutador. Sólo puede tomar dos valores: “verdadero”, cuando el bucle debe terminar, y “falso”, cuando el bucle debe repetirse una vez más. Por lo tanto, “terminar” valdrá “falso” al principio, y sólo cambiará a “verdadero” cuando el usuario introduzca un número negativo.

A veces, el conmutador puede tomar más de dos valores. Entonces ya no se le debe llamar, estrictamente hablando, conmutador. Cuando la variable toma un determinado valor especial, el bucle termina. A ese “valor especial” se le suele denominar valor centinela.

Otro ejemplo: Escribir un algoritmo que sume todos los números positivos introducidos por el usuario a través del teclado. Para terminar de introducir números, el usuario tecleará un número negativo.

 algoritmo sumar
 variables
   suma es entero
   N es entero
 inicio
   suma = 0
   repetir
     inicio
       escribir ('Introduce un número (negativo para terminar)')
       leer (N)
       si (N >= 0) entonces
          suma = suma + N
     fin
   mientras que (N >= 0)
   escribir (suma)
 fin

Tenemos aquí un ejemplo de cómo no siempre es necesario usar contadores para terminar un bucle “mientras” (o “repetir”). Las repeticiones se controlan con la variable N, de modo que el bucle termina cuando N < 0. Ese es el valor centinela.

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

Los ordenadores se diseñaron inicialmente para realizar tareas sencillas y repetitivas. El ser humano es de lo más torpe acometiendo tareas repetitivas: pronto le falla la concentración y comienza a tener descuidos. Los ordenadores programables, en cambio, pueden realizar la misma tarea muchas veces por segundo durante años y nunca se aburren (o, al menos, hasta hoy no se ha tenido constancia de ello)

La estructura repetitiva, por tanto, reside en la naturaleza misma de los ordenadores y consiste, simplemente, en repetir varias veces un conjunto de instrucciones. Las estructuras repetitivas también se llaman bucles, lazos o iteraciones. Nosotros preferiremos la denominación bucle.

(Recuerde que la estructura repetitiva o bucle es una de las estructuras permitidas en la programación estructurada)

Los bucles tienen que repetir un conjunto de instrucciones un número finito de veces. Si no, nos encontraremos con un bucle infinito y el algoritmo no funcionará. En rigor, ni siquiera será un algoritmo, ya que no cumplirá la condición de finitud.

El bucle infinito es un peligro que acecha constantemente a los programadores y nos toparemos con él muchas veces a lo largo de este curso. Para conseguir que el bucle se repita sólo un número finito de veces, tiene que existir una condición de salida del mismo, es decir, una situación en la que ya no sea necesario seguir repitiendo las instrucciones.

Por tanto, los bucles se componen, básicamente, de dos elementos:

  • un cuerpo del bucle o conjunto de instrucciones que se ejecutan repetidamente
  • una condición de salida para dejar de repetir las instrucciones y continuar con el resto del algoritmo

Dependiendo de dónde se coloque la condición de salida (al principio o al final del conjunto de instrucciones repetidas), y de la forma de realizarla, existen tres tipos de bucles, aunque hay que resaltar que, con el primer tipo, se puede programar cualquier estructura iterativa. Pero con los otros dos, a veces el programa resulta más claro y legible. Los tres tipos de bucle se denominan:

  • Bucle “mientras“: la condición de salida está al principio del bucle.
  • Bucle “repetir“: la condición de salida está al final del bucle.
  • Bucle “para“: la condición de salida está al principio y se realiza con un contador automático.

El bucle “mientras”

El bucle “mientras” es una estructura que se repite mientras una condición sea verdadera. La condición, en forma de expresión lógica, se escribe en la cabecera del bucle, y a continuación aparecen las acciones que se repiten (cuerpo del bucle):

mientras (condición) hacer
inicio
   acciones (cuerpo del bucle)
fin

Cuando se llega a una instrucción mientras, se evalúa la condición. Si es verdadera, se realizan las acciones y, al terminar el bloque de acciones, se regresa a la instrucción mientras (he aquí el bucle o lazo). Se vuelve a evaluar la condición y, si sigue siendo verdadera, vuelve a repetirse el bloque de acciones. Y así, sin parar, hasta que la condición se haga falsa.

Por ejemplo, vamos a escribir un algoritmo que muestre en la pantalla todos los números enteros entre 1 y 100

algoritmo contar
variables
  cont es entero
inicio
  cont = 0
  mientras (cont <= 100) hacer
  inicio
    cont = cont + 1
    escribir (cont)
  fin
fin

Aquí observamos el uso de un contador en la condición de salida de un bucle, un elemento muy común en estas estructuras. Observe la evolución del algoritmo:

  • cont = 0. Se le asigna el valor 0 a la variable cont (contador)
  • mientras (cont <= 100) hacer. Condición de salida del bucle. Es verdadera porque cont vale 0, y por lo tanto es menor o igual que 100.
  • cont = cont + 1. Se incrementa el valor de cont en una unidad. Como valía 0, ahora vale 1.
  • escribir(cont). Se escribe el valor de cont, que será 1.

Después, el flujo del programa regresa a la instrucción mientras, ya que estamos en un bucle, y se vuelve a evaluar la condición. Ahora cont vale 1, luego sigue siendo verdadera. Se repiten las intrucciones del bucle, y cont se incrementa de nuevo, pasando a valer 2. Luego valdrá 3, luego 4, y así sucesivamente.

La condición de salida del bucle hace que éste se repita mientras cont valga menos de 101. De este modo nos aseguramos de escribir todos los números hasta el 100.

Lo más problemático a la hora de diseñar un bucle es, por lo tanto, pensar bien su condición de salida, porque si la condición de salida nunca se hiciera falsa, caeríamos en un bucle infinito. Por lo tanto, la variable implicada en la condición de salida debe sufrir alguna modificación en el interior del bucle; si no, la condición siempre sería verdadera. En nuestro ejemplo, la variable cont se modifica en el interior del bucle: por eso llega un momento, después de 100 repeticiones, en el que la condición se hace falsa y el bucle termina.

El bucle “repetir”

El bucle de tipo “repetir” es muy similar al bucle “mientras”, con la salvedad de que la condición de salida se evalúa al final del bucle, y no al principio, como a continuación veremos. Todo bucle “repetir” puede escribirse como un bucle “mientras”, pero al revés no siempre sucede.

La forma de la estructura “repetir” es la que sigue:

repetir
inicio
   acciones
fin

Cuando el ordenador encuentra un bucle de este tipo, ejecuta las acciones escritas entre inicio y fin y, después, evalúa la condición, que debe ser de tipo lógico. Si el resultado es falso, se vuelven a repetir las acciones. Si el resultado es verdadero, el bucle se repite. Si es falso, se sale del bucle y se continúa ejecutando la siguiente instrucción.Existe, pues, una diferencia fundamental con respecto al bucle “mientras”: la condición se evalúa al final. Por lo tanto, las acciones del cuerpo de un bucle “repetir” se ejecutan al menos una vez, cuando en un bucle “mientras” es posible que no se ejecuten ninguna (si la condición de salida es falsa desde el principio)

Ejemplo: Escribir un algoritmo que escriba todos los números enteros entre 1 y 100, pero esta vez utilizando un bucle “repetir” en lugar de un bucle “mientras”

algoritmo contar
variables
  cont es entero
inicio
  cont = 0
  repetir
  inicio
    cont = cont + 1
    escribir (cont)
  fin
  mientras que (cont <= 100)
fin

Observa que el algoritmo es básicamente el mismo que en el ejemplo anterior, pero hemos cambiado el lugar de la condición de salida.

El bucle “para”

En muchas ocasiones se conoce de antemano el número de veces que se desean ejecutar las acciones del cuerpo del bucle. Cuando el número de repeticiones es fijo, lo más cómodo es usar un bucle “para”, aunque sería perfectamente posible sustituirlo por uno “mientras”.

La estructura “para” repite las acciones del bucle un número prefijado de veces e incrementa automáticamente una variable contador en cada repetición. Su forma general es:

para cont desde valor_inicial hasta valor_final hacer
inicio
   acciones
fin

cont es la variable contador. La primera vez que se ejecutan las acciones situadas entre inicio y fin, la variable cont tiene el valor especificado en la expresión valor_inicial. En la siguiente repetición, cont se incrementa en una unidad, y así sucesivamente, hasta alcanzar el valor_final. Cuando esto ocurre, el bucle se ejecuta por última vez y después el programa continúa por la instrucción que haya a continuación.El incremento de la variable cont siempre es de 1 en cada repetición del bucle, salvo que se indique otra cosa. Por esta razón, la estructura “para ” tiene una sintaxis alternativa:

para cont desde valor_inicial hasta valor_final inc|dec paso hacer
inicio
   acciones
fin

De esta forma, se puede especificar si la variable cont debe incrementarse (inc) o decrementarse (dec) en cada repetición, y en qué cantidad (paso).

Ejemplo 1: Escribir un algoritmo que escriba todos los números enteros entre 1 y 100, utilizando un bucle “para”

algoritmo contar
variables
  cont es entero
inicio
  para cont desde 1 hasta 100 hacer
  inicio
    escribir (cont)
  fin
fin

De nuevo, lo más interesante es observar las diferencias de este algoritmo con los dos ejemplos anteriores. Advierta que ahora no es necesario asignar un valor inicial de 0 a cont, ya que se hace implícitamente en el mismo bucle; y tampoco es necesario incrementar el valor de cont en el cuerpo del bucle (cont = cont + 1), ya que de eso se encarga el propio bucle “para”. Por último, no hay que escribir condición de salida, ya que el bucle “para” se repite hasta que cont vale 100 (inclusive)

Ejemplo 2: Escribir un algoritmo que escriba todos los números enteros impares entre 1 y 100, utilizando un bucle “para”:

algoritmo contar
variables
  cont es entero
inicio
  para cont desde 1 hasta 100 inc 2 hacer
  inicio
    escribir (cont)
  fin
fin

Este ejemplo, similar al anterior, sirve para ver el uso de la sintaxis anternativa del bucle “para”. La variable cont se incrementará en 2 unidades en cada repetición del bucle.

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

Vimos en el post anterior que los programas estructurados utilizan únicamente tres estructuras: secuencial, condicional y repetitiva. También vimos en qué consiste la estructura secuencial.

Los algoritmos que usan únicamente estructuras secuenciales están muy limitados y no tienen ninguna utilidad real. Esa utilidad aparece cuando existe la posibilidad de ejecutar una de entre varias secuencias de instrucciones dependiendo de alguna condición asociada a los datos del programa.

Las estructuras selectivas pueden ser de tres tipos:

  • simples
  • dobles
  • múltiples

Condicional simple

La estructura condicional simple tiene esta representación:

si condición entonces
inicio
  acciones
fin

La condición que aparece entre “si” y “entonces” es siempre una expresión lógica, es decir, una expresión cuyo resultado es “verdadero” o “falso”. Si el resultado es verdadero, entonces se ejecutan las acciones situadas entre “inicio” y “fin”. Si es falso, se saltan las acciones y se prosigue por la siguiente instrucción (lo que haya debajo de “fin”)

Por ejemplo: recuperemos algoritmo del área y el perímetro del rectángulo para mostrar la condicional simple en pseudocódigo:

algoritmo rectángulo
variables
  base, altura, área, perímetro son reales
inicio
  leer (base)
  leer (altura)
  si (área > 0) y (altura > 0) entonces
  inicio
    área = base * altura
    perímetro = 2 * base + 2 * altura
    escribir (área)
    escribir (perímetro)
  fin
  si (área <= 0) o (altura <=0) entonces
  inicio
    escribir ('Los datos son incorrectos')
  fin
fin

Observe que, en la primera instrucción condicional (si (área > 0) y (altura > 0) entonces) se comprueba que los dos datos sean positivos; en caso de serlo, se procede al cálculo del área y el perímetro mediante las acciones situadas entre inicio y fin. Más abajo hay otra condicional (si (área <= 0) o (altura <=0) entonces) para el caso de que alguno de los datos sea negativo o cero: en esta ocasión, se imprime en la pantalla un mensaje de error.

Condicional doble

La forma doble de la instrucción condicional es:

si condición entonces
inicio
  acciones-1
fin
si_no
inicio
  acciones-2
fin

En esta forma, la instruccción funciona del siguiente modo: si el resultado de la condición es verdadero, entonces se ejecutan las acciones de la primera parte, es decir, las acciones-1. Si es falso, se ejecutan las acciones de la parte “si_no”, es decir, las acciones-2.

Por ejemplo, podemos reescribir nuestro algoritmo del rectángulo usando una alternativa doble:

algoritmo rectángulo
variables
  base, altura, área, perímetro son reales
inicio
  leer (base)
  leer (altura)
  si (área > 0) y (altura > 0) entonces
  inicio
    área = base * altura
    perímetro = 2 * base + 2 * altura
    escribir (área)
    escribir (perímetro)
  fin
  si_no
  inicio
    escribir ('Los datos de entrada son incorrectos')
  fin
fin

Lo más interesante de este algoritmo es compararlo con el anterior, ya que hace exactamente lo mismo. ¡Siempre hay varias maneras de resolver el mismo problema! Pero esta solución es un poco más sencilla, al ahorrarse la segunda condición, que va implícita en el si_no.

Condicional múltiple

En algunas ocasiones nos encontraremos con selecciones en las que hay más de dos alternativas (es decir, en las que no basta con los valores “verdadero” y “falso”). Siempre es posible plasmar estas selecciones complejas usando varias estructuras si-entonces-si_no anidadas, es decir, unas dentro de otras, pero, cuando el número de alternativas es grande, esta solución puede plantear grandes problemas de escritura y legibilidad del algoritmo.

Sin embargo, hay que dejar clara una cosa: cualquier instrucción condicional múltiple puede ser sustituida por un conjunto de instrucciones condicionales simples y dobles totalmente equivalente.

La estructura condicional múltiple sirve, por tanto, para simplificar estos casos de condiciones con muchas alternativas. Su sintaxis general es:

según expresión hacer
    inicio
      valor1: acciones-1
      valor2: acciones-2
      valor3: acciones-3
      ...
      valor4: acciones-N
      si_no: acciones-si_no
    fin

Su funcionamiento es el siguiente: se evalúa expresión, que en esta ocasión no puede ser de tipo lógico, sino entero, carácter, etc. El resultado de expresión se compara con cada uno de los valores valor1, valor2… valorN. Si coincide con alguno de ellas, se ejecutan únicamente las acciones situadas a la derecha del valor coincidente (acciones-1, acciones-2… acciones-N). Si se diera el caso de que ningún valor fuera coincidente, entonces se ejecutan las acciones-si_no ubicadas al final de la estructura. Esta última parte de la estructura no es obligatorio que aparezca.

Por ejemplo, construyamos un algoritmo que escriba los nombres de los días de la semana en función del valor de una variable entera llamada “día”. Su valor se introducirá por teclado. Los valores posibles de la variable “día” serán del 1 al 7: cualquier otro valor debe producir un error.

algoritmo día_semana
variables
  día es entero
inicio
  leer (día)
  según (día) hacer
  inicio
    1: escribir('lunes')
    2: escribir('martes')
    3: escribir('miécoles')
    4: escribir('jueves')
    5: escribir('viernes')
    6: escribir('sábado')
    7: escribir('domingo')
    si_no: escribir('Error: el día introducido no existe')
  fin
fin

Hay dos cosas interesantes en este algoritmo. Primera, el uso de la instrucción selectiva múltiple: la variable día, una vez leída, se compara con los siete valores posibles. Si vale 1, se realizará la acción escribir(’lunes’); si vale 2, se realiza escribir(’martes’); y así sucesivamente. Por último, si no coincide con ninguno de los siete valores, se ejecuta la parte si_no. Es un buen ejercicio mental pensar en cómo se podría resolver el mismo problema sin recurrir a la alternativa múltiple, es decir, utilizando sólo alternativas simples y dobles.

El otro aspecto digno de destacarse no tiene nada que ver con la alternativa múltiple, sino con la sintaxis general de pseudocódigo: no hemos empleado inicio y fin para marcar cada bloque de instrucciones. Lo más correcto hubiera sido escribirlo así:

según día hacer
inicio
  1:
    inicio
      escribir('lunes')
    fin
  2:
    inicio
      escribir('martes')
    fin
..etc..

Sin embargo, cuando el bloque de instrucciones consta sólo de UNA instrucción, podemos prescindir de las marcas de inicio y fin y escribir directamente la instrucción.

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

He comprobado que el término programación estructurada se malinterpreta a menudo, incluso entre algunos programadores experimentados. No significa que utilicemos en el programa estructuras de datos (aunque podemos hacerlo), ni tampoco que usemos un lenguaje de programación estructurado (la mayoría permiten programar de forma desestructurada), ni mucho menos que seamos muy organizaditos a la hora de desarrollar nuestro programa (aunque es una cualidad deseable, sin duda).

El término programación estructurada se refiere, en realidad, a un conjunto de técnicas que han ido evolucionando desde los primeros trabajos del holandés E. Dijkstra. Estas técnicas aumentan la productividad del programador, reduciendo el tiempo requerido para escribir, verificar, depurar y mantener los programas.

Allá por mayo de 1966, Böhm y Jacopin demostraron que se puede escribir cualquier programa propio utilizando solo tres tipos de estructuras de control: la secuencial, la selectiva (o condicional) y la repetitiva. A esto se le llama Teorema de la programación estructurada, y define un programa propio como un programa que cumple tres características:

  • Posee un sólo punto de inicio y un sólo punto de fin
  • Existe al menos un camino que parte del inicio y llega hasta el fin pasando por todas las partes del programa
  • No existen bucles infinitos

Realmente, el trabajo de Dijkstra basado en este teorema fue revolucionario, porque lo que venía a decir es que, para construir programas más potentes y en menos tiempo, lo que había que hacer era simplificar las herramientas que se utilizaban para hacerlos, en lugar de complicarlas más. Este regreso a la simplicidad, unido a las técnicas de ingeniería del software, acabó con la crisis del software de los años 70.

Por lo tanto, los programas estructurados deben limitarse a usar tres estructuras:

  • Secuencial
  • Selectiva (o condicional)
  • Repetitiva

Vamos a estudiar cada estructura detenidamente y veremos cómo se representan mediante diagramas de flujo y pseudocódigo.

La estructura secuencial

La estructura secuencial no es ni más ni menos que aquélla en la que una acción sigue a otra (en secuencia). Esta es la estructura algorítmica básica, en la que las instrucciones se ejecutan una tras otra, en el mismo orden en el que fueron escritas.

La estructura secuencial, por lo tanto, es la más simple de las tres estructuras permitidas. A continuación vemos su representación mediante pseudocódigo:

inicio
   acción 1
   acción 2
   ...
   acción N
fin

Un ejemplo inofensivo

Vamos a escribir, como ejemplo, un algoritmo completamente secuencial que calcule la suma de dos números, A y B. Recuerde que, generalmente, los algoritmos se dividen en tres partes: entrada de datos, procesamiento de esos datos y salida de resultados.

algoritmo suma
variables
  A, B, suma son enteros
inicio
  leer (A)
  leer (B)
  suma = A + B
  escribir (suma)
fin

¿Y las otras estructuras?

Bien, no es bueno darse un empacho, sobre todo al principio. De las estructuras condicionales y repetitivas hablaremos en los próximos posts.

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

Los programas de ordenador son productos realmente complejos (y caros) de diseñar y construir. Al principio, con los primeros ordenadores de la historia, esto no era así. Aquellos ordenadores eran tan elementales que sus programas no podían ser demasiado complicados, y podían ser desarrollados por cualquiera con algunos conocimientos del funcionamiento del ordenador.

Pero, a lo largo de los años 70, el avance de la tecnología provocó que los ordenadores tuvieran cada vez más capacidad de cálculo y, por lo tanto, que los programas fueran cada vez más complejos. Llegó un momento en el que se hizo evidente que ningún ser humano era capaz de hacer un programa tan complejo que aprovechase todas las posibilidades de hardware de los ordenadores de esa época. A esto se le llamó la crisis del software, y estancó la industria informática durante varios años.

El problema era que, hasta entonces, se programaba sin método ni planificación. A nadie se le ocurriría, por ejemplo, construir un avión sin haber hecho antes, cuidadosamente, multitud de cálculos, estudios, planos, diseños, esquemas, etc. Pues bien, un programa de ordenador puede ser tan complejo, o más, que un avión o cualquier otro artefacto industrial, y, por lo tanto, es necesario construirlo con los mismos procesos de ingeniería.

Surgió así el concepto de ingeniería del software. No me gustan las definiciones, pero a veces son inevitables. Ahí va una:

La ingeniería del software es el conjunto de procedimientos y técnicas encaminadas a diseñar y desarrollar – con economía, prontitud, elegancia y cumpliendo los estándares de calidad – programas informáticos, documentación y procedimientos operativos mediante los cuales los ordenadores puedan ser útiles al ser humano.

Actualmente, los procesos de la ingeniería del software (que son muchos y variados) se aplican en todas las empresas y organismos en los que se desarrolla software de forma profesional y rigurosa, porque no hay otro modo de asegurar que el producto se va a terminar dentro de los plazos y costes previstos, y que éste va a funcionar correctamente y se va a ajustar a los niveles de calidad que el mercado exige.

El ciclo de vida clásico

Una de las primeras enseñanzas de la ingeniería del software fue que, al ser el proceso de producción de software tan complicado, debía descomponerse en varias etapas para poder abordarlo.

El conjunto de estas etapas, o fases, junto con las reglas para pasar de una a otra, constituyen lo que se denomina el ciclo de vida del software.

Dependiendo de diversos factores (como el tipo de software que se va a desarrollar, el sistema en el que va a funcionar, o las propias preferencias de los ingenieros o de la empresa desarrolladora), se puede elegir entre varios tipos de ciclos de vida que han demostrado su eficacia a lo largo de los años. Pero la mayoría de ellos, con ligeras variaciones, constan de las siguiente fases:

  • Análisis del problema
  • Diseño de una solución
  • Especificación de los módulos
  • Codificación
  • Pruebas
  • Mantenimiento

Análisis del problema

La fase de análisis consiste en averiguar QUÉ problema vamos a resolver. Parece una obviedad, pero la experiencia demuestra que no sólo no es así, sino que el análisis suele ser la etapa que más problemas causa y a la que más tiempo se le debería dedicar.

Es imprescindible partir de una especificación de requisitos lo más exacta y detallada posible. El resultado debe ser un modelo preciso del entorno del problema, de los datos y del objetivo que se pretende alcanzar. Pero expliquémoslo todo con más detenimiento:

El mundo real, por definición, es muy complejo. Cuando pretendemos traspasar una parte de ese mundo a un ordenador es necesario extraer sólo los aspectos esenciales del problema, es decir, lo que realmente afecta a esa parte del mundo, desechando todo lo demás. El proceso de comprensión y simplificación del mundo real se denomina análisis del problema, y la simplificación obtenida como resultado del análisis se llama modelo.

Por ejemplo, si lo que prentedemos es realizar un programa que calcule la trayectoria de un proyectil lanzado por un cañón de artillería (el clásico problema del tiro oblicuo, ¿recuerdan sus clases de física?), lo lógico es que simplifiquemos el problema suponiendo que el proyectil es lanzado en el vacío (por lo que no hay resistencia del aire) y que la fuerza de la gravedad es constante. El resultado será muy aproximado al real, aunque no exacto. Esto es así porque nos hemos quedado con los aspectos esenciales del problema (la masa del proyectil, su velocidad, etc), desechando los menos importantes (la resistencia del aire, la variación de la gravedad). Es decir, hemos realizado un modelo del mundo real.

En este ejemplo, el modelo del tiro oblícuo es muy fácil de construir ya que se basa en fórmulas matemáticas perfectamente conocidas. Necesitamos conocer algunos datos previos para que el modelo funcione: la velocidad del proyectil, su masa y su ángulo de salida. Con eso, nuestro programa podría calcular fácilmente la altura y la distancia que el proyectil alcanzará. Sin embargo, las áreas de aplicación de la Informática van más allá de la Física, por lo que la modelización suele ser bastante más difícil de hacer que en este problema.

Por ejemplo, en el programa de facturación de una empresa: ¿qué datos previos necesitamos conocer? ¿Qué fórmulas o cáculos matemáticos debemos realizar con ellos? ¿Qué resultado se espera del programa? Estas cuestiones deben quedar muy claras antes de la modelización porque, de lo contrario, el modelo no será adecuado para resolver el problema y todo el proceso de programación posterior dará como fruto un programa que no funciona o no hace lo que se esperaba de él.

Para que el modelo sea acertado, por lo tanto, es necesario tener muy clara la naturaleza del problema y de los datos que le afectan. A este respecto, es imprescindible establecer lo que se denomina una especificación de requisitos, que no es más que una definición lo más exacta posible del problema y su entorno. Sin una especificación detallada, es imposible comprender adecuadamente el problema y, por lo tanto, también es imposible hacer bien el análisis y construir un modelo que sea válido.

Diseño de soluciones

Una vez establecido el modelo del mundo real, y suponiendo que el problema sea computable, es necesario decidir CÓMO se va a resolver el problema, es decir, crear una estructura de hardware y software que lo resuelva.

Diseñar una solución para un modelo no es una tarea sencilla y sólo se aprende a hacerlo con la práctica. Típicamente, el diseño se resuelve mediante la técnica del diseño descendente (top-down), que consiste en dividir el problema en subproblemas más simples, y estos a su vez en otros más simples, y así sucesivamente hasta llegar a problemas lo bastante sencillos como para ser resueltos con facilidad.

Especificación y codificación de módulos

Para cada subproblema planteado en el diseño hay que inventarse una solución lo más eficiente posible, es decir, crear un algoritmo. Cada algoritmo que resuelve un subproblema se llama módulo.

Posteriormente, cada módulo debe ser traducido a un lenguaje comprensible por el ordenador, tecleado y almacenado. Estos lenguajes se llaman lenguajes de programación.

Los lenguajes de programación son conjuntos de símbolos y de reglas sintácticas especialmente diseñados para transmitir órdenes al ordenador. Existen multitud de lenguajes para hacer esto: C/C++, Pascal, Cobol, Fortran, Visual Basic, Java, PHP, etc.

Pruebas

Una vez que el programa está introducido en la memoria del ordenador, es necesario depurar posibles errores. La experiencia demuestra que hasta el programa más sencillo contiene errores y, por lo tanto, este es un paso de vital importancia.

Los errores más frecuentes son los sintácticos o de escritura, por habernos equivocado durante la codificación. Para corregirlos, basta con localizar el error (que generalmente nos marcará el propio ordenador) y subsanarlo.

Más peliagudos son los errores de análisis o diseño. Un error en fases tan tempranas dará lugar a un programa que, aunque corre en la máquina, no hace lo que se esperaba de él y, por lo tanto, no funciona. Estos errores obligan a revisar el análisis y el diseño y, en consecuencia, a rehacer todo el trabajo de especificación, codificación y pruebas. La mejor forma de evitarlos es realizar un análisis y un diseño concienzudos antes de lanzarnos a teclear código como posesos.

Existen varias técnicas, relacionadas con los controles de calidad, para generar software libre de errores y diseñar baterías de prueba que revisen los programas hasta el límite de lo posible, pero que quede claro: ningún programa complejo está libre de errores al 100% por más esfuerzos que se hayan invertido en ello.

Mantenimiento

Cuando el programa está en uso, y sobre todo si se trata de software comercial, suele ser preciso realizar un mantenimiento. El mantenimiento puede ser de varios tipos: correctivo (para enmendar errores que no se hubieran detectado en la fase de pruebas), perfectivo (para mejorar el rendimiento o añadir más funciones) o adaptativo (para adaptar el programa a otros entornos).

El coste de la fase de mantenimiento ha experimentado un fuerte incremento en los últimos años. Así, se estima que la mayoría de las empresas de software que dedican alrededor del 60% de sus recursos exclusivamente a mantener el software que ya tienen funcionando, empleando el 40% restante en otras tareas, entre las que se incluye el desarrollo de programas nuevos. Esto es una consecuencia lógica del elevado coste de desarrollo del software.

¿Y cuál es el papel del programador en todo esto?

La figura del programador artesanal que, poseído por una idea feliz repentina se lanza a teclear como un poseso y, tras algunas horas de pura inspiración, consigue componer un programa para acceder, digamos, a las bases de datos de la CIA, es, digámoslo claro, pura fantasía romántica. El programador de ordenadores es una pieza más, junto con los analistas, diseñadores, jefes de proyecto, usuarios, etc., del complejo engranaje de la ingeniería del software.

Como es lógico, toda la maquinaria de esta ingeniería es excesiva si lo que pretendemos es realizar programas pequeños y sencillos, del mismo modo que no tomamos un avión para ir a comprar el pan a la esquina.

El programador, pues, debe estar capacitado para elaborar programas relativamente sencillos basándose en las especificaciones de los analistas y diseñadores. Esto no quiere decir que un programador no pueda ser, a la vez, analista, diseñador o implantador. En realidad, a menudo ejerce varias de estas funciones, dependiendo de su experiencia y capacidad y de la organización de la empresa en la que trabaje.

En un post anterior hicimos un rápido recorrido por los tipos de datos simples.

Como dijimos entonces, los tipos de datos se caracterizan por la clase de objeto que representan y por las operaciones que se pueden hacer con ellos. Los datos que participan en una operación se llaman operandos, y el símbolo de la operación se denomina operador. Por ejemplo, en la operación entera 5 + 3, los datos 5 y 3 son los operandos y “+” es el operador.

Podemos clasificar las operaciones básicas con datos simples en dos grandes grupos: las operaciones aritméticas y las operaciones lógicas.

Operaciones aritméticas

Son análogas a las operaciones matemáticas convencionales, aunque cambian los símbolos. Sólo se emplean con datos de tipo entero o real (aunque puede haber alguna excepción):

operaciones1.png

No todos los operadores existen en todos los lenguajes de programación. Por ejemplo, en lenguaje Fortran no existe la división entera, en C no existe la exponenciación, y, en Pascal, el operador “%” se escribe “mod”.

Señalemos que la división entera (div) se utiliza para dividir números enteros, proporcionando a su vez como resultado otro número entero, es decir, sin decimales. La operación módulo (%) sirve para calcular el resto de estas divisiones enteras.

El tipo del resultado de cada operación dependerá del tipo de los operandos. Por ejemplo, si sumamos dos números enteros, el resultado será otro número entero. En cambio, si sumamos dos números reales, el resultado será un número real. La suma de un número entero con otro real no está permitida en muchos lenguajes, así que intentaremos evitarla.

Por último, decir que las operaciones “div” y “%” sólo se pueden hacer con números enteros, no con reales, y que la operación “/” sólo se puede realizar con reales, no con enteros.

Aquí tenemos algunos ejemplos de operaciones aritméticas con números enteros y reales:

operaciones2.png

Nótese que el operador “–” también se usa para preceder a los números negativos, como en el álgebra convencional.

Operaciones lógicas (o booleanas)

Estas operaciones sólo pueden dar como resultado verdadero o falso, es decir, su resultado debe ser un valor lógico.

Hay dos tipos de operadores que se utilizan en estas operaciones: los operadores de relación y los operadores lógicos

A) Operadores de relación. Son los siguientes:

    operaciones3.png

    Muchos lenguajes prefieren el símbolo “< >” para “distinto de”. En realidad, es un asunto de notación que no tiene mayor importancia.

    Los operadores de relación se pueden usar con todos los tipos de datos simples: entero, real, carácter o lógico. El resultado será verdadero si la relación es cierta, o falso en caso contrario.

    Ahí van algunos ejemplos:

    operaciones4.png

    En cuanto a los datos lógicos, se considera que “falso” es menor que “verdadero”. Por lo tanto:

    operaciones5.png

    B) Operadores lógicos. Los operadores lógicos son and (y), or (o) y not (no). Sólo se pueden emplear con tipos de datos lógicos.

      El operador and, que también podemos llamar y, da como resultado verdadero sólo si los dos operandos son verdaderos:

      operaciones6.png

      El operador or (también nos vale o) da como resultado verdadero cuando al menos uno de los dos operandos es verdadero:

      operaciones7.png

      El operador not (o no) es uno de los escasos operadores que sólo afectan a un operando (operador monario), no a dos (operador binario). El resultado es la negación del valor del operando, es decir, que le cambia el valor de verdadero a falso y viceversa:

      operaciones8.png

      Prioridad de los operadores

      Es habitual encontrar varias operaciones juntas en una misma línea. En estos casos es imprescindible asignar una prioridad de acción a los operadores, porque las operaciones se calcularán en el orden de prioridad y el resultado puede ser muy distinto del esperado. Por ejemplo, en la operación 6 + 4 / 2, no es lo mismo calcular primero la operación 6 + 4 que calcular primero la operación 4 / 2. conocer la

      La prioridad de cálculo respeta las reglas generales del álgebra. Así, por ejemplo, la división y la multiplicación tienen más prioridad que la suma o la resta. Pero el resto de prioridades pueden diferir de manera imporante de un lenguaje de programación a otro. Por ejemplo, las siguientes son las prioridades en el lenguaje C:

      operaciones9.png

      La prioridad del cálculo se puede alterar usando paréntesis, como en álgebra. Los paréntesis se pueden anidar tantos niveles como sean necesarios. Por supuesto, a igualdad de prioridad entre dos operadores, la operación se calcula de izquierda a derecha, en el sentido de la lectura de los operandos.

      Aquí tenemos algunos ejemplos de operaciones conjuntas y su resultado según el orden de prioridad que hemos visto:

      operaciones10.png

      Cuando digo que “Tengo 78 años”, transmito una información que todo el mundo puede comprender. La información viaja a bordo de los datos. El dato más crucial de la afirmación anterior es el número entero “78″. Podemos sustituir el enunciado por uno más general, tal como “Tengo X años”, donde X es cualquier número entero entre 1 y 120. En ese caso, diremos que X es una variable de tipo entero que puede tomar valores entre 1 y 120.

      Un tipo de datos es exactamente eso: una clase concreta de variables. Cada tipo de datos exige que las variables que pertenecen a esa clase tomen sólo un conjunto de valores posibles y, además, tiene asociado un conjunto de operaciones para válidas manipularlos.

      Cada tipo de datos dispone de una representación interna diferente en el ordenador; por eso es importante distinguir entre tipos de datos a la hora de programar.

      Existen tipos de datos simples y tipos complejos. Entre los simples tenemos:

      • Números enteros
      • Números reales
      • Caracteres
      • Lógicos

      Así, por ejemplo, en el caso de un programa de gestión de nóminas, la edad de los empleados será un dato de tipo número entero, mientras que el dinero que gana al mes será un dato de tipo número real.

      De los tipos de datos complejos, también llamados estructuras de datos, hablaremos en otros posts. Ahora nos centraremos en los tipos simples.

      Números enteros

      Es probablemente el tipo más sencillo de entender. Los datos de tipo entero sólo pueden tomar como valores:

      …, -4, -3, -2, -1, 0, 1, 2, 3, 4, …

      Como el ordenador tiene una memoria finita, la cantidad de valores enteros que puede manejar también es finita y depende del número de bits que emplee para ello (recuerda que el ordenador, internamente, representa todos los datos en binario).

      Además, los enteros pueden ser con signo y sin signo. Si tienen signo, se admiten los números negativos; si no lo tienen, los números sólo pueden ser positivos (sería más correcto llamarlos números naturales).

      (Los enteros con signo se almacenan en binario en complemento a uno o en complemento a dos. No describiremos estas representaciones internas por ahora)

      Por lo tanto:

      • Si se utilizan 8 bits para codificar los números enteros, el rango de valores permitido irá de 0 a 255 (sin signo) o de -128 a +127 (con signo).
      • Si se utilizan 16 bits para codificar los números enteros, el rango será de 0 a 65535 (sin signo) o de -32768 a 32767 (sin signo).
      • Si se utilizan 32, 64, 128 bits o más, se pueden manejar números enteros mayores.

      Números reales

      El tipo de dato número real permite representar números con decimales. La cantidad de decimales de un número real puede ser infinita, pero al ser el ordenador una máquina finita es necesario establecer un número máximo de dígitos decimales significativos.

      La representación interna de los números reales se denomina coma flotante (también existe la representación en coma fija, pero no es habitual). La coma flotante es una generalización de la notación científica convencional, consistente en definir cada número con una mantisa y un exponente.

      La notación científica es muy útil para representar números muy grandes economizando esfuerzos. Por ejemplo, el número 129439000000000000000 tiene la siguiente representación científica:

      1,29439 x 1020

      Pero el ordenador representaría este número siempre con un 0 a la izquierda de la coma, así:

      0,129439 x 1021

      La mantisa es el número situado en la posición decimal (129439) y el exponente es 21.

      La notación científica es igualmente útil para números decimales muy pequeños. Por ejemplo, el número 0,0000000000000000000259 tiene esta notación científica:

      2,59 x 10-23

      Pero el ordenador lo representará así:

      0,259 x 10-22

      Siendo 259 la mantisa y -22 el exponente.

      Internamente, el ordenador reserva varios bits para la mantisa y otros más para el exponente. Como en el caso de los números reales, la magnitud de los números que el ordenador pueda manejar estará directamente relacionada con el número de bits reservados para su almacenamiento.

      Overflow

      Cuando se realizan operaciones con números (tanto enteros como reales), es posible que el resultado de una de ellas dé lugar a un número fuera del rango máximo permitido. Por ejemplo, si tenemos un dato de tipo entero sin signo de 8 bits cuyo valor sea 250 y le sumamos 10, el resultado es 260, que sobrepasa el valor máximo (255).

      En estos casos, estamos ante un caso extremo denominado overflow o desbordamiento. Los ordenadores pueden reaccionar de forma diferente ante este problema, dependiendo del sistema operativo y del lenguaje utilizado. Algunos lo detectan como un error de ejecución del programa, mientras que otros lo ignoran, convirtiendo el número desbordado a un número dentro del rango permitido pero que, obviamente, no será el resultado correcto de la operación, por lo que el programa probablemente fallará.

      Caracteres y cadenas

      El tipo de dato carácter sirve para representar datos alfanuméricos. El conjunto de elementos que puede representar está estandarizado según el código ASCII, que, como ya vimos, consiste en una combinación de 8 bits asociada a un carácter alfanumérico concreto.

      Las combinaciones de 8 bits dan lugar a un total de 255 valores distintos (desde 0000 0000 hasta 1111 1111), por lo que esa es la cantidad de caracteres diferentes que se pueden utilizar. Entre los datos de tipo carácter válidos están:

      • Las letras minúsculas: ‘a’, ‘b’, ‘c’ … ‘z’
      • Las letras mayúsculas: ‘A’, ‘B’, ‘C’ … ‘Z’
      • Los dígitos: ‘1′, ‘2′, ‘3′ …
      • Caracteres especiales: ‘$’, ‘%’, ‘¿’, ‘!’ …

      Nótese que no es lo mismo el valor entero 3 que el carácter ‘3′. Para distinguirlos, usaremos siempre comillas para escribir los caracteres.

      Los datos tipo carácter sólo pueden contener UN carácter. Una generalización del tipo carácter es el tipo cadena de caracteres, utilizado para representar series de varios caracteres. Éste, sin embargo, es un tipo de datos complejo y será estudiado más adelante (en el tema 3). Sin embargo, las cadenas se utilizan tan a menudo que no podremos evitar usarlas en algunos ejercicios incluso antes de estudiarlas a fondo.

      Para distinguir una cadena de caracteres de los caracteres individuales, usaremos la misma convención que en el lenguaje C: rodearemos las cadenas con comillas dobles (”) y a los caracteres individuales con comillas simples (’). Esta sintaxis, como el lógico, depende del lenguaje de programación utilizado.

      Así, consideraremos que las siguientes expresiones son cadenas de caracteres: “andrómeda”, “dRoMeDaRiO”, “jsdk”, “a”. Obsérvese que, sin usar las comillas dobles, sería imposible distinguir la última cadena del carácter individual ‘a’.

      Datos lógicos

      El tipo dato lógico, también llamado booleano, es un dato que sólo puede tomar un valor entre dos posibles. Esos dos valores son:

      • Verdadero (en inglés, true)
      • Falso (en inglés, false)

      Este tipo de datos se utiliza para representar alternativas del tipo sí/no. En algunos lenguajes, el valor truefalse con el número 0. Es decir, los datos lógicos contienen información binaria. Esto ya los hace bastante importantes, pero la mayor utilidad de los datos lógicos viene por otro lado: son el resultado de todas las operaciones lógicas y relacionales que se emplean continuamente en las instrucciones condicionales y en los bucles. se representa con el número 1 y el valor

      Como ya dijimos en otra ocasión, el pseudocódigo es un lenguaje simplificado de descripción de algoritmos. El paso desde el pseudocódigo hasta el lenguaje de programación real (por ejemplo, C), es casi inmediato. Además, la descripción de algoritmos en pseudocódigo ocupa mucho menos espacio que su equivalente con un diagrama de flujo, por lo que lo que, en este blog, lo preferiremos a la hora de diseñar algoritmos complejos.

      El pseudocódigo es bastante parecido a la mayoría de los lenguajes de programación reales, pero no tiene unas reglas tan estrictas, por lo que el programador puede trabajar en la estructura del algoritmo sin preocuparse de las limitaciones del lenguaje final, que son muchas y variopintas.

      El pseudocódigo utiliza ciertas palabras reservadas para representar las acciones del programa. Estas palabras originalmente están en inglés (y se parecen mucho a las que luego emplean los lenguajes de programación), pero por suerte para nosotros su traducción española está muy extendida entre la comunidad hispanohablante.

      Las palabras reservadas del pseudocódigo son relativamente pocas, pero, como irá comprobando el aprendiz de programador con la práctica, con un conjunto bastante reducido de instrucciones, correctamente combinadas, podemos construir programas muy complejos.

      A continuación presentamos una tabla-resumen con todas las palabras reservadas del pseudocódigo. A partir de ahora, en sucesivos posts haremos uso de estas instrucciones.

      Instrucción

      Significado

      algoritmo nombre

      Marca el comienzo de un algoritmo y le adjudica un nombre

      inicio

      Marca el comienzo de un bloque de instrucciones

      fin

      Marca el final de un bloque de instrucciones

      variables

      nombre_var es tipo_de_datos

      Declaración de variables. Indica el identificador y el tipo de las variables que se van a usar en el algoritmo

      constantes

      nombre_const = expresión

      Declaración de constantes. La expresión se evalúa y su resultado se asigna a la constante. Este valor no puede modificarse a lo largo del programa.

      leer (variable)

      Entrada de datos. El programa lee un dato desde un dispositivo de entrada (si no se indica otra cosa, el teclado), asignando ese dato a la variable

      escribir (variable)

      Salida de datos. Sirve para que el programa escriba un dato en un dispositivo de salida (si no se indica otra cosa, la pantalla).

      variable = expresión

      Asignación. La expresión se evalúa y su resultado es asignado a la variable

      si (condición) entonces

      inicio

      acciones-1

      fin

      si_no

      inicio

      acciones-2

      fin

      Instrucción condicional doble. El ordenador evaluará la condición, que debe ser una expresión lógica. Si es verdadera, realiza las acciones-1, y, si es falsa, las acciones-2.Instrucción condicional simple. Es igual pero carece de la rama “si_no”, de modo que, si la expresión de falsa, no se realiza ninguna acción y la ejecución continúa por la siguiente instrucción

      según (expresión) hacer

      inicio

      valor1: acciones-1

      valor2: acciones-2

      valor3: acciones-N

      si_no: acciones-si_no

      fin

      Instrucción condicional múltiple. Se utiliza cuando hay más de dos condiciones posibles (verdadero o falso) . Se evalúa la expresión, que suele ser de tipo entero, y se busca un valor en la lista valor1, valor2,… valorN que coincida con ella, realizándose las acciones asociadas al valor coincidente.Si ningún valor de la lista coincide con la expresión del “según”, se realizan las acciones de la parte “si_no”.

      mientras (condición) hacer

      inicio

      acciones

      fin

      Bucle mientras. Las acciones se repiten en tanto la condición, que debe ser una expresión lógica, sea verdadera. La condición se evalúa antes de entrar al bloque de acciones, de modo que pueden no ejecutarse ninguna vez.

      repetir

      inicio

      acciones

      fin

      mientras que (condición)

      Bucle repetir. Las acciones se repiten en tanto que la condición, que debe ser una expresión lógica, sea verdadera. Se parece mucho al anterior, pero la condición se evalúa al final del bucle, por lo que éste se ejecuta, como mínimo, una vez.

      para variable desde expr-ini hasta expr-fin hacer

      inicio

      acciones

      fin

      Bucle para. Se evalúa la expresión expr-ini, que debe ser de tipo entero, y se asigna ese valor a la variable. Dicha variable se incrementa en una unidad en cada repetición de las acciones. Las acciones se repiten hasta que la variable alcanza el valor expr-fin.

      Dentro de un programa, se llama dato constante (o, simplemente, “una constante”) a un dato cuyo valor no cambia durante la ejecución. Por el contrario, un dato variable (o, simplemente, “una variable”) es un dato cuyo valor sí cambia en el transcurso del programa.

      Es la misma diferencia que hay entre decir “Tengo 50 años” y decir “Tengo X años”. El primero es un enunciado con un dato constante (50), que proporciona una determinada información. El segundo es un enunciado con un dato variable (X), que puede tomar un valor concreto dentro de un gran número de valores posibles y que, por tanto, puede dar lugar a un montón de enunciados diferentes.

      Identificadores

      A los datos variables se les asigna un identificador alfanumérico, es decir, un nombre. Por lo tanto, es necesario disinguir entre el identificador de una variable y su valor. Por ejemplo, en la frase “Tengo X años”, la variable X puede contener el valor 50. En este caso, X es el identificador y 50 el valor de la variable.

      Los identificadores o nombres de variable deben cumplir ciertas reglas que, aunque varían de un lenguaje a otro, podemos resumir en que:

      • Deben empezar por una letra y, en general, no contener símbolos especiales excepto el subrayado (”_”)
      • No deben coincidir con alguna palabra reservada del lenguaje

      He aquí algunos ejemplos de identificadores:

      • x: Es válido
      • 5x: No es válido, porque no empieza por una letra
      • x5: Es válido
      • pepe: Es válido
      • -pepe: No es válido, porque no empieza por una letra
      • pepe_luis: Es válido
      • pepe!luis: No es válido, porque contiene caracteres especiales (!)

      Las constantes también pueden tener un identificador, aunque no es estrictamente obligatorio. En caso de tenerlo, ha de cumplir las mismas reglas que los identificadores de variable.Declaración y asignación

      Declaración de variables

      Las variables tienen que pertenecer a un tipo de dato determinado, es decir, debemos indicar explícitamente qué tipo de datos va a almacenar a lo largo del programa. Esto implica que, en algún punto del programa (ya veremos dónde) hay que señalar cuál va a ser el identificador de la variable, y qué tipo de datos va a almacernar. A esto se le llama declarar la variable.

      Una declaración de variables será algo así:

      X es entero
      Y es real
      letra es carácter

      X, Y y letra son los identificadores de variable. Es necesario declararlas porque, como vimos, el ordenador maneja internamente cada variable de una forma diferente: en efecto, no es lo mismo una variable entera de 8 bits sin signo que otra real en coma flotante. El ordenador debe saber de antemano qué variables va a usar el programa y de qué tipo son para poder asignarles la memoria necesaria.

      Para adjudicar un valor a una variable, se emplea una sentencia de asignación, que tienen esta forma:

      X = 5
      Y = 7.445
      letra = 'J'

      A partir de la asignación, pueden hacerse operaciones con las variables exactamente igual que se harían con datos. Por ejemplo, la operación X + X daría como resultado 10. A lo largo del programa, la misma variable X puede contener otros valores (siempre de tipo entero) y utilizarse para otras operaciones. Por ejemplo:

      X es entero
      Y es entero
      Z es entero
      X = 8
      Y = 2
      Z = X div Y
      X = 5
      Y = X + Z

      Después de esta serie de operaciones, realizadas de arriba a abajo, la variable X contendrá el valor 5, la variable Y contendrá el valor 9 y, la variable Z, el valor 4.

      En cambio, las constantes no necesitan identificador, ya que son valores que nunca cambian. Esto no significa que no se les pueda asociar un identificador para hacer el programa más legible. En ese caso, sólo se les puede asignar valor una vez, ya que, por su propia naturaleza, son invariables a lo largo del programa.

      Expresiones

      Una expresión es una combinación de constantes, variables, operadores y funciones. Es decir, se trata de operaciones aritméticas o lógicas como las que vimos en el apartado 2.2 (página 12), pero en las que, además, pueden aparecer variables.

      Por ejemplo:

      (5 + X) div 2

      En esta expresión, aparecen dos constantes (5 y 2), una variable (X) y dos operadores (+ y div), además de los paréntesis, que sirven para alterar la prioridad de las operaciones. Lógicamente, para resolver la expresión, es decir, para averiguar su resultado, debemos conocer cuál es el valor de la variable X. Supongamos que la variable X tuviera el valor 7. Entonces, el resultado de la expresión es 6. El cálculo del resultado de una expresión se suele denominar evaluación de la expresión.

      Otro ejemplo:

      ( – b + raiz(b^2 – 4 * a * c)) / (2 * a)

      Esta expresión, más compleja, tiene tres variables (a, b y c), 4 operadores (–, +, ^ y *, aunque algunos aparecen varias veces), 2 constantes (2 y 4, apareciendo el 2 dos veces) y una función (raiz, que calcula la raiz cuadrada). Si el valor de las variables fuera a = 2, c = 3 y b = 4, al evaluar la expresión el resultado sería –0.5

      La forma más habitual de encontrar una expresión es combinada con una sentencia de asignación a una variable. Por ejemplo:

      Y = (5 + X) div 2

      En estos casos, la expresión (lo que hay a la derecha del signo “=”) se evalúa y su resultado es asignado a la variable situada a la izquierda del “=”. En el ejemplo anterior, suponiendo que la variable X valiera 7, la expresión (5 + X) div 2 tendría el valor 6, y, por lo tanto, ese es el valor que se asignaría a la variable Y.

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

      Un algoritmo es un conjunto finito de pasos que conducen a la resolución de un problema. Cuando usted cruza la calle en un semáforo, cuando fríe un par de huevos o cuando resuelve con lápiz y papel una integral definida, está ejecutando algoritmos con su cerebro y su cuerpo.

      Para escribir cualquier programa que resuelva un problema es necesario idear previamente un algoritmo que resuelva ese problema. Sin algoritmo, no existiría el programa, porque un programa no es más que un algoritmo traducido a un lenguaje comprensible por el ordenador, esto es, un lenguaje de programación.

      Definición más o menos formal

      Podemos definir un algoritmo como una secuencia ordenada de pasos que conducen a la solución de un problema. Los algoritmos tienen tres características fundamentales:

      1. Son precisos, es decir, deben indicar el orden de realización de los pasos.
      2. Están bien definidos, es decir, si se sigue el algoritmo dos veces usando los mismos datos, debe proporcionar la misma solución.
      3. Son finitos, esto es, deben completarse en un número determinado de pasos.

      Por ejemplo, vamos a diseñar un algoritmo simple que determine si un número N es par o impar:

       1. Inicio
       2. Si N es divisible entre 2, entonces ES PAR
       3. Si N no es divisible entre 2, entonces NO ES PAR
       4. Fin

      Si se fija usted bien, este algoritmo cumple las tres condiciones enumeradas anteriormente (precisión, definición y finitud) y resuelve el problema planteado. Lógicamente, al ordenador no le podemos dar estas instrucciones tal y como las hemos escrito, sino que habrá que expresarlo en un lenguaje de programación, pero esto es algo que trataremos en otros posts.

      Notación de algoritmos

      Los algoritmos deben representarse con algún método que permita independizarlos del lenguaje de programación que luego se vaya a utilizar. Así se podrán traducir más tarde a cualquier lenguaje. En el ejemplo que acabamos de ver hemos especificado el algoritmo en lenguaje español, pero existen otras formas de representar los algoritmos. Entre todas ellas, destacaremos las siguientes:

      1. Lenguaje español
      2. Diagramas de flujo
      3. Diagramas de Nassi-Schneiderman (NS)
      4. Pseudocódigo

      En este blog utilizaremos, principalmente, el pseudocódigo, que es un lenguaje de especificación de algoritmos basado en la lengua española. Tiene dos propiedades que nos interesarán: facilita considerablemente el aprendizaje de las técnicas de programación y logra que la traducción a un lenguaje de programación real sea casi instantánea.

      Los diagramas de flujo son representaciones gráficas de los algoritmos que ayudan a comprender su funcionamiento, pero enseguida se vuelven demasiado voluminosos e incómodos para trabajar con ellos.

      Dedicaremos muchos otros post a discutir las técnicas básicas de programación usando pseudocódigo y, a veces, diagramas de flujo; pero, como adelanto, ahí va el algoritmo que determina si un número N es par o impar, escrito en pseudocódigo. Es recomendable que le eche un vistazo para intentar entenderlo y para familiarizarse con la notación en pseudocódigo:

       algoritmo par_impar
       variables
         N es entero
         solución es cadena
       inicio
         leer (N)
         si (N div 2 == 0) entonces solución = "N es par"
         si_no solución = "N es impar"
         escribir (solución)
       fin

      Escritura inicial del algoritmo

      Una vez superadas las fases de análisis y diseño, es decir, entendido bien el problema y sus datos y descompuesto en problemas más sencillos, llega el momento de resolver cada problema sencillo mediante un algoritmo.

      Muchos autores recomiendan escribir una primera versión del algoritmo en lenguaje natural (en nuestro caso, en castellano), siempre que dicha primera versión cumpla dos condiciones:

      • que la solución se exprese como una serie de instrucciones o pasos a seguir para obtener una solución al problema
      • que las instrucciones haya que ejecutarlas de una en una, es decir, una instrucción cada vez

      Consideremos un problema sencillo: el cálculo del área y del perímetro de un rectángulo. Evidentemente, tenemos que conocer su base y su altura, que designaremos con dos variables de tipo real. Una primera aproximación, en lenguaje natural, podría ser:

      1. Inicio
      2. Preguntar al usuario los valores de base y altura
      3. Calcular el área como área = base * altura
      4. Calcular el perímetro como perímetro = 2 * base + 2 * altura
      5. Fin

      Describir un algoritmo de esta forma puede ser útil si el problema es complicado, ya que puede ayudarnos a entenderlo mejor y a diseñar una solución adecuada. Pero esto sólo es una primera versión que puede refinarse añadiendo cosas. Por ejemplo, ¿qué pasa si la base o la altura son negativas o cero? En tal caso, no tiene sentido averiguar el área o el perímetro. Podríamos considerar esta posibilidad en nuestro algoritmo para hacerlo más completo:

      1. Inicio
      2. Preguntar al usuario los valores de base y altura
      3. Si base es mayor que cero y altura también, entonces:
         3.1. Calcular el área como área = base * altura
         3.2. Calcular el perímetro como perímetro = 2 * base + 2 * altura
      4. Si no:
         4.1. No tiene sentido calcular el área ni el perímetro
      5. Fin

      Estos refinamientos son habituales en todos los algoritmos y tienen la finalidad de conseguir una solución lo más general posible, es decir, que pueda funcionar con cualquier valor de “base” y “altura”.

      Diagramas de flujo

      El diagrama de flujo es una de las técnicas de representación de algoritmos más antigua y también más utilizada, al menos entre principiantes y para algoritmos sencillos. Con la práctica comprobará que, cuando se trata de problemas complejos, los diagramas de flujo se hacen demasiado grandes: es como conducir un camión por la zona antigua de su ciudad.

      Un diagrama de flujo o flowchart es un gráfico en el que se utilizan símbolos (o cajas) para representar los pasos del algoritmo. Las cajas están unidas entre sí mediante flechas, llamadas líneas de flujo, que indican el orden en el que se deben ejecutar para alcanzar la solución.

      Los símbolos de las cajas están estandarizados y son muy variados. En la tabla siguiente tiene los que utilizaremos en este blog. Ciertamente, alguien podrá objetar que me he dejado algunos símbolos en el tintero, y llevará razón si lo hace. Pero cuando se trata de introducir herramientas prefiero la simplicidad.

      diagrama_flujo_simbolos.png
      Clic para ampliar

      Veamos un ejemplo: la representación del algoritmo que calcula el área y el perímetro de un rectángulo mediante un diagrama de flujo. Antes, tengamos en cuenta que:

      • los valores de “base” y “altura” los introducirá el usuario del programa a través del teclado; así, el programa servirá para cualquier rectángulo
      • después se realizarán los cálculos necesarios
      • los resultados, “área” y “perímetro”, deben mostrarse en un dispositivo de salida (por defecto, la pantalla) para que el usuario del programa vea cuál es la solución

      Esta estructura en 3 pasos es muy típica de todos los algoritmos: primero hay una entrada de datos, luego se hacen cálculos con esos datos, y por último se sacan los resultados.

      El diagrama de flujo será más o menos así (clic para ampliar):

      diagrama_flujo.png

      Pseudocódigo

      El pseudocódigo es un lenguaje simplificado de descripción de algoritmos. El paso desde el pseudocódigo hasta el lenguaje de programación real (por ejemplo, C), es relativamente fácil. Además, la descripción de algoritmos en pseudocódigo ocupa mucho menos espacio que su equivalente con un diagrama de flujo, por lo que lo preferiremos a la hora de diseñar algoritmos complejos.

      El pseudocódigo es bastante parecido a la mayoría de los lenguajes de programación reales, pero no tiene unas reglas tan estrictas, por lo que el programador puede trabajar en la estructura del algoritmo sin preocuparse de las limitaciones del lenguaje final que, como veremos al estudiar C, son muchas y variopintas.

      El pseudocódigo utiliza ciertas palabras reservadas para representar las acciones del programa. Estas palabras originalmente están en inglés (y se parecen mucho a las que luego emplean los lenguajes de programación), pero por suerte para nosotros su traducción española está muy extendida entre la comunidad hispanohablante.

      En próximos posts aprenderemos a utilizar el pseudocódigo aplicándolo a algoritmos sencillos que, progresivamente, iremos complicando. Por ahora, dejamos expuesta la solución en pseudocódigo del algoritmo de la base y la altura, para solaz del lector/a. Échele un vistazo y comprobará con alegría que no contiene nada que no pueda entender:

      algoritmo rectángulo
      inicio
        leer (base)
        leer (altura)
        área = base * altura
        perímetro = 2 * base + 2 * altura
        escribir (área)
        escribir (perímetro)
      fin

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

      Resumimos aquí las generalidades sintácticas básicas del lenguaje C:

      • Los bloques de código se marcan con las llaves {…}. Son equivalentes al inicio y fin del pseudocódigo.
      • Todas las instrucciones terminan con un punto y coma ( ; )
      • Los identificadores de variables, funciones, etc., no pueden empezar con un número ni contener espacios o símbolos especiales, salvo el de subrayado ( _ )
      • Los caracteres se encierran entre comillas simples ( ‘…’ )
      • Las cadenas de caracteres se encierran entre comillas dobles ( “…” )
      • El lenguaje es sensitivo a las mayúsculas. Es decir, no es lo mismo escribir main() que MAIN() o Main()

      En las entradas anteriores dedicadas a programadores/as principiantes vimos cómo la metáfora del ordenador visto como un extraterrestre casi idiota nos podía resultar útil para aprender los rudimentos de la programación.

      Siguiendo con el mismo razonamiento, intentaremos ir un poco más allá. Pero, primero, permítanme que formalicemos un poco lo que sabemos hasta ahora.

      1. El extraterrestre (es decir, el ordenador) entiende y sabe resolver las operaciones aritméticas (suma, multiplicación, división…) y lógicas (mayor que, menor que, comparación…) básicas. También maneja sin problemas el sistema de numeración decimal. Supongo que tendrá diez dedos en sus manos, como nosotros.
      2. El extraterrestre también entiende algunas instrucciones muy simples y es capaz de manejar datos de forma abstracta (por ejemplo, datos del tipo: “llamemos N a un número entero cualquiera”)
      3. El extraterrestre puede recibir una lista de instrucciones y luego seguirlas en el mismo orden en el que le fueron dadas.
      4. También podemos hacerle repetir un conjunto de pasos unas cuantas veces si se cumple determinada condición, como ya vimos con un ejemplo.

      Pues bien, a menudo este tercer punto es demasiado limitativo: no siempre hay que hacer las cosas en el mismo orden. Casi siempre, el orden depende de los datos que el extraterrestre maneje.

      Por ejemplo, supongamos que queremos darle las instrucciones para que nos pida dos números y nos diga cuál de los dos es más pequeño:

      • Paso 1: Pregunta al terrícola un número (llamémosle A)
      • Paso 2: Pregunta al terrícola otro número (llamémosle B).
      • Paso 3: Si A es mayor que B, entonces ir a Paso 4. Si no, ir a Paso 5.
        • Paso 4: Informar al terrícola de que A es mayor que B y terminar.
        • Paso 5: Informar al terrícola de que B es mayor que A

      Si tratamos de escribir para el extraterrestre una lista de instrucciones lineal, es decir, que siempre tenga que seguir de arriba a abajo, no podremos conseguir que resuelva este problema. En cambio, con el paso 3 logramos romper esa linealidad, de forma que en algunas ocasiones el extraterrestre salte al paso 4, y en otras al 5. Ambas opciones son excluyentes y, además, elegir una u otra es algo que no se puede hacer de antemano, sino que hay que esperar a ver cuáles son los valores de A y B.

      Este tipo de construcción, que se denomina condicional, es fundamental en programación.

      A menudo, estas listas de instrucciones que conocemos como algoritmos se entienden mejor si se representan gráficamente con un ordinograma, ya saben, el viejo juego de “siga la flecha”:

      ordin_mayor_menor.png
      (clic para ampliar)

      La correspondencia entre el ordinograma o la lista de instrucciones para el extraterrestre y un programa escrito para un ordenador es mínima, y puede salvarse con un pequeño esfuerzo. A modo de ejemplo, observe el programa anterior codificado en algunos lenguajes de programación muy extendidos, y verá como puede entenderlo con muy poco esfuerzo (aunque habrá cosas que, inevitablemente, no sabrá para qué sirven). Recuerde que el ordenador es el extraterrestre.

      En Basic:

      INPUT A
      INPUT B
      IF A > B THEN
         PRINT "El mayor es A"
      ELSE
         PRINT "El mayor es B"

      En C:

      scanf("%i", &A);
      scanf("%i", &B);
      if  (A > B)
         printf("El mayor es A");
      else
         printf("El mayor es B");

      En Pascal:

      readln(A);
      readln(B);
      if (A > B) then
         writeln('El mayor es A')
      else
         writeln('El mayor es B');

      Algo que agobia mucho a los principiantes es que un programa nunca está del todo acabado. Incluso este programa tan sencillo está incompleto. Y si no me creen, digan: ¿qué pasa si los dos números son iguales?

      En la entrada anterior asegurábamos que programar un ordenador es en esencia como tratar de comunicarse con un extraterrestre que sólo conoce los rudimentos de nuestro lenguaje y las nociones matemáticas básicas. Él obedecerá nuestras órdenes ciegamente, aunque no entienda para qué narices sirven.

      En aquella entrada dábamos instrucciones al extraterrestre para que calculase el valor medio de diez números que previamente nos debía solicitar a nosotros. Ahora tratemos de pedirle que haga algo un poco más complicado: que, primero, me pregunte cuántos números le voy a dar, y que luego me vaya pidiendo los números, para después calcular el valor medio de todos ellos. Tendríamos así un programa básicamente igual al anterior, pero más general, ya que no tiene por qué funcionar siempre con 10 números, sino que lo hará con cualquier cantidad de ellos.

      Piénselo un momento antes de ver la solución. Al leerla, póngase en el lugar del extraterrestre que, al fin y al cabo, es quien recibirá las instrucciones:

      • Paso 1: Preguntar al terrícola cuántos números piensa introducir (llamar a esta cantidad A)
      • Paso 2: Usar la letra S para referirme a la suma de los números que voy a empezar a pedir al terrícola enseguida. S valdrá 0 inicialmente.
      • Repetir A veces los pasos 3 y 4:
        • Paso 3: Pedir al terrícola un número (lo llamaré N)
        • Paso 4: Sumar N a los números que ya había sumado antes (S = S + N).
      • Paso 5: Calcular media = S / A
      • Paso 6: Comunicar al terrícola el resultado de mis cálculos (es decir, el valor de media)

      Pasémosle al extraterrestre este conjunto ordenado de instrucciones y pidámosle que las siga: estaremos ejecutando el programa.

      Observe que ocurre algo curioso en los pasos 3 y 4: se repiten varias veces antes de continuar en el paso 5. Esta estructura repetitiva es fundamental en programación y se denomina muy imaginativamente bucle, lazo o iteración.

      Esta lista de pasos (o programa) a veces se representa, para mejor comprensión, de manera gráfica. Cada paso se dibuja dentro de un rectángulo y los rectángulos se unen entre sí con flechas, que indican el orden de ejecución. Las condiciones o preguntas (como “¿ya hemos repetido el número suficiente de veces?”) se representan con un rombo. El conjunto se denomina ordinograma y verá que es muy fácil de interpretar si le dedica un momento de atención:

       

      ordin_media.png
      (click para ampliar)

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

      Imagínese que un buen día se tropieza con un extraterrestre (y no es una metáfora: me refiero a un extraterrestre de verdad, o sea, de los de las películas). El extraterrestre se ha perdido de algún modo en nuestro pequeño planeta y no sabe absolutamente nada sobre costumbres locales. ¿Cómo podría usted comunicarse con él?

      Parece, en principio, una tarea imposible. Pero existe un lenguaje que sin duda compartimos: el de las matemáticas. Según todos los indicios, los principios matemáticos son universales y, por lo tanto, verdaderos tanto aquí como en la galaxia de Andrómeda. Así que nuestro extraterrestre debe saber que dos y dos son cuatro, aunque a las ideas de “dos”, “cuatro” y “más” las llame de otra forma.

      Supongamos, para simplificar, que el extraterrestre conoce algunos rudimentos de la lengua terrícola (castellana, para más señas), lo suficiente para entender esos términos aritméticos simples (”dos”, “cuatro”, “más”, “por”, “mayor que”, “igual que”, y esas cosas). ¿Cómo podríamos, basándonos en esos términos, pedirle al extraterrestre que hiciese una tarea más complicada, como, digamos, averiguar cuál es el valor medio de una serie de diez números?

      Primero, le tendríamos que decir que necesita conocer cuáles son esos diez números, ¿no? Eso ya se lo diré yo, que para eso soy el autor del hallazgo del extraterrestre. Cuando tenga los diez números, deberá sumarlos y, el resultado de esa suma, dividirlo entre diez. Así que esto es lo que le diríamos al extraterrestre, gritando mucho como cuando hablamos con un extranjero en nuestro idioma:

      • “Primero, me tienes que pedir los diez números”
      • “Luego, cuando te los haya dado, los sumas”
      • “Cuando tengas la suma, lo divides entre diez y me dices el resultado”

      Formalícemos un poco lo que tendríamos que pedir al extraterrestre que hiciera:

      • Paso 1: Pedir diez números (N1, N2, N3, … N10) al terrícola.
      • Paso 2: Calcular suma = N1 + N2 + N3 + … + N10
      • Paso 3: Calcular media = suma / 10
      • Paso 4: Comunicar el valor de media al terrícola

      Pues bien, esto es un programa. Si nuestro extraterrestre es un ordenador y si los cuatro pasos anteriores los escribimos en un lenguaje ligeramente más formal que el que hemos empleado, tendremos un programa comprensible por el ordenador (recuerde: él es el extraterrestre, y usted el terrícola) .

      Podemos complicar el conjunto de órdenes que le transmitimos indefinidamente para pedirle al extraterrestre que haga cosas mucho más complicadas, como resolver ecuaciones diferenciales o calcular el tiempo previsto para mañana, pero en esencia esta es la idea.

      Categorías

      Licencia

      ClustrMaps