En este capítulo se revisa los aspectos teoricos y prácticos que necesitan ser considerados cuando son escritos programas grandes.
Cuando se escriben programas grandes se deberá programar en módulos. Estos serán archivos fuentes separados. La función main() deberá estar en un archivo, por ejemplo en main.c, y los otros archivos tendrán otras funciones.
Se puede crear una biblioteca propia de funciones escribiendo una suite de subrutinas en uno o más módulos. De hecho los módulos pueden ser compartidos entre muchos programas simplemente incluyendo los módulos al compilar como se verá a continuación.
Se tiene varias ventajas si los programas son escritos de esta forma:
- los módulos de forma natural se dividirán en grupos comunes de funciones.
- se puede compilar cada módulos separadamente y ligarlo con los módulos ya compilados.
- las utilerías tales como
make nos ayudan a mantener sistemas grandes.
Si se adopta el modelo modular entonces se querrá tener para cada módulo las definiciones de las variables, los prototipos de las funciones, etc. Sin embargo, ¿qué sucede si varios módulos necesitan compartir tales definiciones? En tal caso, lo mejor es centralizar las definiciones en un archivo, y compartir el archivo entre los módulos. Tal archivo es usualmente llamado un archivo cabecera.
Por convención estos archivos tienen el sufijo .h
Se han revisado ya algunos archivos cabecera de la biblioteca estándar, por ejemplo:
#include <stdio.h>
Se pueden definir los propios archivos cabecera y se pueden incluir en el programa como se muestra enseguida:
#include "mi_cabecera.h"
Los archivos cabecera por lo general sólo contienen definiciones de tipos de datos, prototipos de funciones y comandos del preprocesador de C.
Considerar el siguiente programa de ejemplo:
main.c
/*
* main.c
*/
#include "cabecera.h"
#include <stdio.h>
char *Otra_cadena = "Hola a Todos";
main()
{
printf("Ejecutando...\n");
/*
* Llamar a EscribirMiCadena() - definida en otro archivo
*/
EscribirMiCadena(MI_CADENA);
printf("Terminado.\n");
}
EscribirMiCadena.c
/*
* EscribirMiCadena.c
*/
extern char *Otra_cadena;
void EscribirMiCadena(EstaCadena)
char *EstaCadena;
{
printf("%s\n", EstaCadena);
printf("Variable Global = %s\n", Otra_cadena);
}
cabecera.h
/*
* cabecera.h
*/
#define MI_CADENA "Hola Mundo"
void EscribirMiCadena();
Cada módulo será compilado separadamente como se verá más adelante.
Algunos módulos tienen la directiva de preprocesamiento #include "cabecera.h" ya que comparten definiciones comunes. Algunos como main.c también incluyen archivos cabecera estándar. La función main.c llama a la función EscribirMiCadena() la cual esta en el módulo (archivo) EscribirMiCadena.c
El prototipo void de la función EscribirMiCadena esta definida en cabecera.h.
Observar que en general se debe decidir entre tener un módulo .c que tenga acceso solamente a la información que necesita para su trabajo, con la consecuencia de mantener muchos archivos cabecera y tener programas de tamaño moderado con uno o dos archivos cabecera (probablemente lo mejor) que compartan más definiciones de módulos.
Un problema que se tiene al emplear módulos son el compartir variables. Si se tienen variables globales declaradas y son instanciadas en un módulo, ¿cómo pueden ser pasadas a otros módulos para que sean conocidas?
Se podrían pasar los valores como parámetros a las funciones, pero:
- puede ser esto laborioso si se pasan los mismos parámetros a muchas funciones o si la lista de argumentos es muy larga.
- arreglos y estructuras muy grandes son difíciles de guardar localmente -- problemas de memoria con el stack.
Las variables y argumentos definidos dentro de las funciones son ``internas'', es decir, locales.
Las variables ``externas'' están definidas fuera de las funciones -- se encuentran potencialmente disponibles a todo el programa (globales) pero NO necesariamente. Las variables externas son siempre permanentes.
En el lenguaje C, todas las definiciones de funciones son externas, NO se pueden tener declaraciones de funciones anidadas como en PASCAL.
Una variable externa (o función) no es siempre totalmente global. En el lenguaje C se aplica la siguiente regla:
El alcance de una variable externa (o función) inicia en el punto de declaración hasta el fin del archivo (módulo) donde fue declarada.
Considerar el siguiente código:
main()
{ ... }
int que_alcance;
float fin_de_alcance[10];
void que_global()
{ ... }
char solitaria;
float fn()
{ ... }
La función main() no puede ver a las variables que_alcance o fin_de_alcance, pero las funciones que_global() y fn() si pueden. Solamente la función fn() puede ver a solitaria.
Esta es también una de las razones por las que se deben poner los prototipos de las funciones antes del cuerpo del código.
Por lo que en el ejemplo la función main no conocerá nada acerca de las funciones que_global() y fn(). La función que_global() no sabe nada acerca de la función fn(), pero fn() si sabe acerca de la función que_global(), ya que esta aparece declarada previamente.
La otra razón por la cual se usan los prototipos de las funciones es para revisar los parámetros que serán pasados a las funciones.
Si se requiere hacer referencia a una variable externa antes de que sea declarada o que esta definida en otro módulo, la variable debe ser declarada como una variable externa, por ejemplo:
extern int que_global;
Regresando al ejemplo de programación modular, se tiene una arreglo de caracteres tipo global Otra_cadena declarado en main.c y que esta compartido con EscribirMiCadena donde esta declarada como externa.
Se debe tener cuidado con el especificador de almacenamiento de clase ya que el prefijo es una declaración, NO una definición, esto es, no se da almacenamiento en la memoria para una variable externa -- solamente le dice al compilador la propiedad de la variable. La variable actual sólo deberá estar definida una vez en todo el programa -- se pueden tener tantas declaraciones externas como se requieran.
Los tamaños de los arreglos deberán ser dados dentro de la declaración, pero no son necesarios en las declaraciones externas, por ejemplo:
main.c int arr[100];
arch.c extern int arr[];
Las ventajas principales de dispersar un programa en varios archivos son:
- Equipos de programadores pueden trabajar en el programa, cada programador trabaja en un archivo diferente.
- Puede ser usado un estilo orientado a objetos. Cada archivo define un tipo particular de objeto como un tipo de dato y las operaciones en ese objeto como funciones. La implementación del objeto puede mantenerse privado al resto del programa. Con lo anterior se logran programas bien estructurados los cuales son fáciles de mantener.
- Los archivos pueden contener todas las funciones de un grupo relacionado, por ejemplo todas las operaciones con matrices. Estas pueden ser accesadas como una función de una biblioteca.
- Objetos bien implementados o definiciones de funciones pueden ser reusadas en otros programas, con lo que se reduce el tiempo de desarrollo.
- En programas muy grandes cada función principal puede ocupar un propio archivo. Cualquier otra función de bajo nivel usada en la implemantación puede ser guardada en el mismo archivo, por lo que los programadores que llamen a la función principal no se distraerán por el trabajo de bajo nivel.
- Cuando los cambios son hechos a un archivo, solamente ese archivo necesita ser recompilado para reconstruir el programa. La utilería
make de UNIX es muy útil para reconstruir programas con varios archivos.
Cuando un programa es separado en varios archivos, cada archivo contendrá una o más funciones. Un archivo incluirá la función main() mientras los otros contendrán funciones que serán llamados por otros. Estos otros archivos pueden ser tratados como funciones de una biblioteca.
Los programadores usualmente inician diseñando un programa dividiendo el problema en secciones más fácilmente manejables. Cada una de estas secciones podrán ser implementaddas como una o más funciones. Todas las funciones de cada sección por lo general estarán en un sólo archivo.
Cuando se hace una implementación tipo objeto de las estructuras de datos, es usual tener todas las funciones que accesan ése objeto en el mismo archivo. Las ventajas de lo anterior son:
- El objeto puede ser fácilmente reusado en otros programas.
- Todas las funciones relacionadas estan guardadas juntas.
- Los últimos cambios al objeto requieren solamente la modificación de un archivo.
El archivo contiene la definición de un objeto, o funciones que regresasan valores, hay una restricción en la llamada de estas funciones desde otro archivo, al menos que la definición de las funciones estén en el archivo, no será posible compilar correctamente.
La mejor solución a este problema es escribir un archivo cabecera para cada archivo de C, estos tendrán el mismo nombre que el archivo de C, pero terminarán en .h. El archivo cabecera contiene las definiciones de todas las funciones usadas en el archivo de C.
Cuando una función en otro archivo llame una función de nuestro archivo de C, se puede definir la función usando la directiva #include con el archivo apropiado .h
Cualquier archivo deberá tener sus datos organizados en un cierto orden, tipícamente podrá ser la siguiente:
- Un preámbulo consistente de las definiciones de constantes (
#define), cabeceras de archivos (#include) y los tipos de datos importantes (typedef).
- Declaración de variables globales y externas. Las variables globales podrían estar inicializadas aquí.
- Una o más funciones.
El orden anterior es importante ya que cada objeto deberá estar definido antes de que pueda ser usado. Las funciones que regresan valores deberán estar definidos antes de que sean llamados. Esta definición podría ser una de las siguientes:
- El lugar en que la función esta definida y llamada en el mismo archivo, una declaración completa de la función puede ser colocada delante de cualquier llamada de la función.
- Si la función es llamada desde un archivo donde no esta definida, un prototipo deberá aparecer antes que llamada de la función.
Una función definida como:
float enc_max(float a, float b, float c)
{ ... }
podrá tener el siguiente prototipo:
float enc_max(float a, float b, float c);
El prototipo puede aparecer entre las variables globales en el inicio del archivo fuente. Alternativamente puede ser declarado en el archivo cabecera el cual es leído usando la directiva #include.
Es importante recordar que todos los objetos en C deberán estar declarados antes de ser usados.
La utilería Make es un manejador inteligente de programas que mantiene la integridad de una colección de módulos de un programa, una colección de programas o un sistema completo -- no tienen que ser programas, en la práctica puede ser cualquier sistema de archivos (por ejemplo, capítulos de texto de un libro que esta siendo tipografiado). Su uso principal ha sido en la asistencia del desarrollo de sistemas de software.
Esta utilería fue inicialmente desarrollada para UNIX, pero actualmente esta disponible en muchos sistemas.
Observar que make es una herramienta del programador, y no es parte del lenguaje C o de alguno otro.
Supongamos el siguiente problema de mantenimiento de una colección grande de archivos fuente:
main.c f1.c ...... fn.c
Normalmente se compilarán los archivos de la siguiente manera:
gcc -o main main.c f1.c ....... fn.c
Sin embargo si se sabe que algunos archivos han sido compilados previamente y sus archivos fuente no han sido cambiados desde entonces, entonces se puede ahorrar tiempo de compilación ligando los códigos objetos de estos archivos, es decir:
gcc -o main main.c f1.c ... fi.o ... fj.o ... fn.c
Se puede usar la opción -c del compilador de C para crear un código objeto (.o) para un módulo dado. Por ejemplo:
gcc -c main.c
que creará un archivo main.o. No se requiere proporcionar ninguna liga de alguna biblioteca ya que será resuelta en la etapa de ligamiento.
Se tiene el problema en la compilación del programa de ser muy larga, sin embargo:
- Se consume tiempo al compilar un módulo
.c -- si el módulo ha sido compilado antes y no ha sido modificado el archivo fuente, por lo tanto no hay necesidad de recompilarlo. Se puede solamente ligar los archivos objeto. Sin embargo, no será fácil recordar cuales archivos han sido actualizados, por lo que si ligamos un archivo objeto no actualizado, el programa ejecutable final estará incorrecto.
- Cuando se teclea una secuencia larga de compilación en la línea de comandos se cometen errores de tecleo, o bien, se teclea en forma incompleta. Existirán varios de nuestros archivos que serán ligados al igual que archivos de bibliotecas del sistema. Puede ser difícil recordar la secuencia correcta.
Si se usa la utilería make todo este control es hecho con cuidado. En general sólo los módulos que sean más viejos que los archivos fuente serán recompilados.
La programación de make es directa, basicamente se escribe una secuencia de comandos que describe como nuestro programa (o sistema de programas) será construído a partir de los archivos fuentes.
La secuencia de construción es descrita en los archivos makefile, los cuales contienen reglas de dependencia y reglas de construcción.
Una regla de dependencia tiene dos partes -- un lado izquierdo y un lado derecho separados por :
lado izquierdo : lado derecho
El lado izquierdo da el nombre del destino (los nombres del programa o archivos del sistema) que será construído (target), mientras el lado derecho da los nombres de los archivos de los cuales depende el destino (por ejemplo, archivos fuente, archivos cabecera, archivos de datos).
Si el destino esta fuera de fecha con respecto a las partes que le constituyen, las reglas de construcción siguiendo las reglas de dependencia son usadas.
Por lo tanto para un programa típico de C cuando un archivo make es ejecutado las siguientes tareas son hechas:
- El archivo
make es leído. El Makefile indica cuales objetos y archivos de biblioteca se requieren para ser ligados y cuales archivos cabecera y fuente necesitan ser compilados para crear cada archivo objeto.
- La hora y la fecha de cada archivo objeto son revisados contra el código fuente y los archivos cabecera de los cuales dependen. Si cualquier fuente o archivo cabecera son más recientes que el archivo objeto, entonces los archivos han sido modificados desde la última modificación y por lo tanto los archivos objeto necesitan ser recompilados.
- Una vez que todos los archivos objetos han sido revisados, el tiempo y la fecha de todos los archivos objeto son revisados contra los archivos ejecutables. Si existe archivos ejecutables viejos serán recompilados.
Observar que los archivos make pueden obedecer cualquier comando que sea tecleado en la línea de comandos, por consiguiente se pueden usar los archivos make para no solamente compilar archivos, sino también para hacer respaldos, ejecutar programas si los archivos de datos han sido cambiados o limpieza de directorios.
La creación del archivo es bastante simple, se crea un archivo de texto usando algún editor de textos. El archivo Makefile contiene solamente una lista de dependencias de archivos y comandos que son necesarios para satisfacerlos.
Se muestra a continuación un ejemplo de un archivo make:
prog: prog.o f1.o f2.o
gcc -o prog prog.o f1.o f2.o -lm ...
prog.o: cabecera.h prog.c
gcc -c prog.c
f1.o: cabecera.h f1.c
gcc -c f1.c
f2.o: ....
...
La utilería make lo interpretará de la siguiente forma:
prog depende de tres archivos: prog.o, f1.o y f2.o. Si cualquiera de los archivos objeto ha cambiado desde la última compilación los archivos deben ser religados.
prog.o depende de 2 archivos, si estos han cambiado prog.o deberá ser recompilado. Lo mismo sucede con f1.o y f2.o.
Los últimos 3 comandos en makefile son llamados reglas explícitas -- ya que los archivos en los comandos son listados por nombre.
Se pueden usar reglas implícitas en makefile para generalizar reglas y hacer más compacta la escritura.
Si se tiene:
f1.o: f1.c
gcc -c f1.c
f2.o: f2.c
gcc -c f2.c
se puede generalizar a:
.c.o: gcc -c $<
Lo cual se lee como .ext_fuente.ext_destino: comando donde $< es una forma breve para indicar los archivos que tienen la extensión .c
Se pueden insertar comentarios en un Makefile usando el símbolo #, en donde todos los caracteres que siguen a # son ignorados.
Se pueden definir macros para que sean usadas por make, las cuales son tipícamente usadas para guardar nombres de archivos fuente, nombres de archivos objeto, opciones del compilador o ligas de bibliotecas.
Se definen en una forma simple, por ejemplo:
FUENTES = main.c f1.c f2.c
CFLAGS = -ggdb -C
LIBS = -lm
PROGRAMA = main
OBJETOS = (FUENTES: .c = .o)
en donde (FUENTES: .c = .o) cambia la extensión .c de los fuentes por la extensión .o
Para referirse o usar una macro con make se debe hacer $(nomb_macro), por ejemplo:
$(PROGRAMA) : $(OBJETOS)
$(LINK.C) -o $@ $(OBJETOS) $(LIBS)
En el ejemplo mostrado se observa que:
- La línea que contiene
$(PROGRAMA) : $(OBJETOS) genera una lista de dependencias y el destino.
- Se emplean macros internas como
$@.
Existen varias macros internas a continuación se muestran algunas de ellas:
- $*
- Parte del nombre del archivo de la dependencia actual sin el sufijo.
- $@
- Nombre completo del destino actual.
- $
- Archivo
.c del destino
Un ejemplo de un makefile para el programa modular discutido previamente se muestra a continuación:
#
# Makefile
#
FUENTES.c=main.c EscribirMiCadena.c
INCLUDES=
CFLAGS=
SLIBS=
PROGRAMA=main
OBJETOS=$(FUENTES.c:.c=.o)
# Destino (target) especial (inicia con .)
.KEEP_STATE:
debug := CFLAGS=-ggdb
all debug: $(PROGRAMA)
$(PROGRAMA): $(INCLUDES) $(OBJETOS)
$(LINK.c) -o $@ $(OBJETOS) $(SLIBS)
clean:
rm -f $(PROGRAMA) $(OBJETOS)
Para ver más información de esta utilería dentro de Linux usar info make
Para usar make solamente se deberá teclear en la línea de comandos. El sistema operativo automáticamente busca un archivo con el nombre Makefile (observar que la primera letra es mayúscula y el resto minúsculas), por lo tanto si se tiene un archivo con el nombre Makefile y se teclea make en la línea de comandos, el archivo Makefile del directorio actual será ejecutado.
Se puede anular esta búsqueda de este archivo tecleando make -f makefile.
Existen algunas otras opciones para make las cuales pueden ser consultadas usando man. |