TEMA 5 : Asignación dinámica de memoria

5.0 Introducción

        En este tema estudiaremos  las posibilidades que ofrece el
lenguaje  C a  la hora  de trabajar  dinámicamente con  la memoria
dentro de nuestros programas, esto  es, reservar y liberar bloques
de memoria dentro de un programa.

        Además  en este  tema se  introducirá el  concepto de tipo
abstracto de dato y la forma  de dividir un gran programa en otros
más pequeños.


5.1  Asignación dinámica y estática de memoria.

        Hasta este momento  solamente hemos realizado asignaciones
estáticas  del programa,  y más  concretamente estas  asignaciones
estáticas  no eran  otras que  las declaraciones  de variables  en
nuestro  programa. Cuando  declaramos una  variable se  reserva la
memoria   suficiente  para   contener  la   información  que  debe
almacenar. Esta memoria permanece asignada a la variable hasta que
termine la  ejecución del programa  (función main). Realmente  las
variables  locales  de  las  funciones  se  crean cuando éstas son
llamadas pero  nosotros no tenemos  control sobre esa  memoria, el
compilador genera  el código para  esta operación automáticamente.
En  este   sentido  las  variables   locales  están  asociadas   a
asignaciones de memoria dinámicas, puesto que se crean y destruyen
durante la ejecución del programa.

        Así  entendemos  por  asignaciones  de  memoria  dinámica,
aquellas que  son creadas por  nuestro programa mientras  se están
ejecutando y que por tanto, cuya gestión debe ser realizada por el
programador.

5.2 ¿Cómo se reserva memoria dinámicamente?

        El lenguaje C dispone, como ya indicamos con anterioridad,
de  una serie  de librerías  de funciones  estándar. El fichero de
cabeceras stdlib.h contiene las declaraciones de dos funciones que
nos  permiten  reservar  memoria,  así  como  otra función que nos
permite liberarla.

5.2.1 Reserva de memoria

        Las dos funciones que nos permiten reservar memoria son:

        malloc (cantidad_de_memoria);
        calloc (número_de_elementos, tamaño_de_cada_elemento);

        Estas dos funciones reservan la memoria especificada y nos
devuelven un  puntero a la  zona en cuestión.  Si no se  ha podido
reservar el tamaño de la  memoria especificado devuelve un puntero
con el valor 0 o NULL. El  tipo del puntero es, en principio void,
es decir,  un puntero a  cualquier cosa. Por  tanto, a la  hora de
ejecutar  estás funciones  es aconsejable  realizar una  operación
cast  (de conversión  de tipo)  de  cara  a la  utilización de  la
aritmética  de  punteros  a  la  que  aludíamos anteriormente. Los
compiladores    modernos   suelen    realizar   esta    conversión
automáticamente.

        Antes  de  indicar  como  deben  utilizarse las susodichas
funciones tenemos  que comentar el operador  sizeof. Este operador
es imprescindible  a la hora  de realizar programas  portables, es
decir, programas  que puedan ejecutarse  en cualquier máquina  que
disponga de un compilador de C.

        El operador  sizeof(tipo_de_dato), nos devuelve  el tamaño
que  ocupa en  memoria un  cierto tipo  de dato,  de esta  manera,
podemos escribir programas independientes  del tamaño de los datos
y de  la longitud  de palabra  de  la  máquina. En  resumen si  no
utilizamos  este operador  en conjunción  con las  conversiones de
tipo  cast probablemente  nuestro  programa  sólo funciones  en el
ordenador sobre el que lo hemos programado.

        Por ejemplo, el los sistemas PC, la memoria está orientada
a bytes  y un  entero ocupa  2 posiciones  de memoria, sin embargo
puede  que en  otro sistema  la máquina  esté orientada a palabras
(conjuntos de 2  bytes, aunque en general una  máquina orientada a
palabras también puede  acceder a bytes) y por  tanto el tamaño de
un  entero sería  de 1  posición de  memoria, suponiendo que ambas
máquinas definan la misma precisión para este tipo.

        Con todo lo mencionado  anteriormente veamos un ejemplo de
un programa que reserva dinámicamente memoria para algún dato.

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

        main()
        {
        int     *p_int;
        float   *mat;

        p_int = (int *)  malloc(sizeof(int));
        mat   = (float *)calloc(20,sizeof(float));

        if ((p_int==NULL)||(mat==NULL))
                {
                        printf ("\nNo hay memoria");
                        exit(1);
                }

        /* Aquí irían las operaciones sobre los datos */
        /* Aquí iría el código que libera la memoria */

        }

        Este programa declara dos variables  que son punteros a un
entero y  a un float.  A estos punteros  se le asigna  una zona de
memoria,  para el  primero se  reserva memoria  para almacenar una
variable  entera y  en el  segundo se  crea una  matriz de  veinte
elementos  cada uno  de ellos  un float.  Obsérvese el  uso de los
operadores cast  para modificar el  tipo del puntero  devuelto por
malloc y calloc, así como la utilización del operador sizeof.

        Como se puede observar  no resulta rentable la declaración
de  una  variable  simple  (un  entero,  por  ejemplo,  como en el
programa anterior)  dinámicamente, en primer lugar  por que aunque
la  variable sólo  se utilice  en una  pequeña parte del programa,
compensa tener menos memoria (2  bytes para un entero) que incluir
todo  el código  de llamada  a  malloc  y comprobación  de que  la
asignación fue correcta (ésto seguro  que ocupa más de dos bytes).
En segundo lugar  tenemos que trabajar con un  puntero con lo cual
el programa ya  aparece un poco más engorroso  puesto que para las
lecturas y  asignaciones de las variables  tenemos que utilizar el
operador *.

        Para  termina  un  breve  comentario  sobre  las funciones
anteriormente descritas. Básicamente da lo mismo utilizar malloc y
calloc para reservar memoria es equivalente:

         mat = (float *)calloc (20,sizeof(float));
         mat = (float *)malloc (20*sizeof(float));

        La  diferencia fundamental  es que,  a la  hora de definir
matrices dinámicas  calloc es mucho más  claro y además inicializa
todos los elementos de la matriz a cero. Nótese también que puesto
que  las matrices  se referencian  como un  puntero la  asignación
dinámica  de una  matriz nos  permite acceder  a sus elementos con
instrucciones de la forma:

      NOTA:  En realidad  existen algunas  diferencias al trabajar
sobre máquinas con alineamiento de palabras.

        mat[0] = 5;
        mat[2] = mat[1]*mat[6]/67;

        Con lo cual el comentario sobre lo engorroso que resultaba
trabajar con un  puntero a una variable simple, en  el caso de las
matrices dinámicas no existe diferencia alguna con una declaración
normal de matrices.


5.2.2 Liberación de la memoria.

        La función que nos permite liberar la memoria asignada con
malloc  y calloc  es free(puntero),  donde puntero  es el  puntero
devuelto por malloc o calloc.

        En  nuestro ejemplo  anterior, podemos  ahora escribir  el
código etiquetado  como : /*  Ahora iría el  código que libera  la
memoria */

        free (p_int);
        free(mat);

        Hay  que tener  cuidado a  la hora  de liberar la memoria.
Tenemos que  liberar todos los  bloque que hemos  asignado, con lo
cual siempre  debemos tener almacenados los  punteros al principio
de la  zona que reservamos.  Si mientras actuamos  sobre los datos
modificamos el valor  del puntero al inicio de  la zona reservada,
la  función  free  probablemente  no  podrá  liberar  el bloque de
memoria.


5.2.3 Ventajas de la asignación dinámica.

        Vamos  a  exponer  un  ejemplos   en  el  que  se  aprecie
claramente la utilidad de la asignación dinámica de memoria.

        Supongamos que  deseamos programar una  serie de funciones
para trabajar con matrices. Una primera solución sería definir una
estructura  de  datos  matriz,  compuesta  por  una  matriz  y sus
dimensiones  puesto  que  nos   interesa  que  nuestras  funciones
trabajen  con matrices  de cualquier  tamaño. Por  tanto la matriz
dentro  de la  estructura tendrá   el tamaño  máximo de  todas las
matrices con las que queramos  trabajar y como tenemos almacenadas
las dimensiones  trabajaremos con una  matriz de cualquier  tamaño
pero tendremos reservada memoria para una matriz de tamaño máximo.
Estamos desperdiciando memoria. Una definición de este tipo sería:

        typedef struct
                {
                        float   mat[1000][1000];
                        int     ancho,alto;
                } MATRIZ;

        En  principio esta  es la  única forma  de definir nuestro
tipo  de  datos.  Con  esta  definición  una  matriz  3x3  ocupará
1000x1000 floats al igual que una matriz 50x50.

        Sin  embargo podemos  asignar memoria  dinámicamente a  la
matriz y reservar sólo el tamaño que nos hace falta. La estructura
sería ésta.

        struct mat
                {
                        float   *datos;
                        int     ancho,alto;
                };

        typedef struct mat *MATRIZ;

        El  tipo  MATRIZ  ahora  debe  ser  un  puntero puesto que
tenemos que  reservar memoria para  la estructura que  contiene la
matriz en  sí y las  dimensiones. Una función  que nos crease  una
matriz sería algo así:

        MATRIZ  inic_matriz (int x,int y)
        {
        MATRIZ  temp;

        temp = (MATRIZ) malloc (sizeof(struct mat));
        temp->ancho = x;
        temp->alto  = y;
        temp->datos = (float *) malloc (sizeof(float)*x*y);

        return temp;
        }

        En esta función  se ha obviado el código  que comprueba si
la asignación ha sido correcta. Veamos como se organizan los datos
con estas estructuras.

        temp------->datos---------->x*x elementos
                    ancho,alto

        Esta  estructura  puede  parecer  en  principio  demasiado
compleja, pero veremos en el siguiente capítulo que es muy útil en
el encapsulado de los datos.

        En  nuestro programa  principal, para  utilizar la  matriz
declararíamos algo así:

        main()
        {
        MATRIZ  a;

        a = inic_matriz (3,3);
        ...
        borrar_matriz(a);
        }

        Dónde  borrar_matriz(a)  libera  la  memoria  reservada en
inic_matriz,   teniendo   en   cuenta   que   se   realizaron  dos
asignaciones, una para la estructura mat  y otra para la matriz en
sí.

        Otra definición posible del problema podría ser así.

        typedef struct mat MATRIZ;

        void inic_matriz (MATRIZ *p,int x,int y)
        {

        p->ancho = x;
        p->alto  = y;
        p->datos = (float *)malloc(sizeof(float)*x*y);
        }

        Con  este esquema  el programa  principal sería  algo como
ésto:

        main()
        {
        MATRIZ  a;

        inic_matriz (&a,3,3);

        .....

        borrar_matriz (&a);
        }

        Con   este   esquema   el   acceso   a   la  matriz  sería
*(a.datos+x+y*a.ancho),  idéntico  al  anterior  sustituyendo  los
puntos por flechas ->. En  el siguiente capítulo se justificará la
utilización de  la primera forma  de implementación frente  a esta
segunda. En realidad se trata de la misma implementación salvo que
en la primera  el tipo MATRIZ es un puntero  a una estructura, por
tanto es necesario primero reservar memoria para poder utilizar la
estructura,  mientras que  en la  segunda implementación,  el tipo
MATRIZ es  ya en si  la estructura, con  lo cual el  compilador ya
reserva  la  memoria  necesaria.  En  el  primer ejemplo MATRIZ a;
define a como un puntero a una estructura struct mat, mientras que
en la  segunda MATRIZ a; define  a como una variable  cuyo tipo es
struct mat.


5.3 Tipos de datos abstractos y encapsulado.

5.3.0 Introducción

        En  el  apartado  anterior  se  mostraba  un ejemplo de la
definición de un nuevo tipo de  datos. En general un nuevo tipo de
datos es  simplemente una estructura que  contiene información, la
cual es  organizada de una  forma determinada dependiendo  de cual
será su aplicación.

        Podemos además de crear la  estructura de datos asociar al
nuevo tipo  una serie de  funciones que nos  permitan actuar sobre
esos  datos, de  forma que  el usuario  final que  utilice nuestra
estructura  de  datos  no  necesite  saber  como  está  organizada
internamente.  A grandes  rasgos ésto  es lo  que se  entiende por
encapsulamiento de  la información. El usuario  dispone de un tipo
de  datos abstracto,  puesto que   no tiene  acceso directo  a esa
información  y  por  ello  se  hace  necesaria  la  utilización de
funciones que nos permitan procesar esa información.

        Esta  organización  tiene  numerosas  ventajas.  En primer
lugar la depuración de programas  se hace más sencilla, puesto que
si  se  produce  un  error  mientras  trabajamos  con nuestro tipo
abstracto,  necesariamente  ese  error  se  produce  dentro de las
funciones  asociadas  a  él  y  no  en  otro lugar. Además resulta
sencilla la  modificación de la implementación  de las funciones o
incluso  de la  estructura de  datos asociada  a nuestro tipo, sin
necesidad  de modificar  los programas  que utilizan  nuestro tipo
abstracto.   Estamos   aumentando   la   modularidad   de  nuestra
programación.

        La  definición  de  tipos  abstractos  es  la  base  de la
programación  orientada  a  objetos.  Este  tipo  de  programación
denomina  los  tipos  abstractos  como  clases,  una  clase es una
estructura  de datos  y una   serie de  funciones asociadas  a esa
estructura de datos. Además en  general disponen de otras opciones
tales como sobrecarga de operadores, herencia de clases, etc... En
la actualidad  el estándar en programación  orientada a objetos es
el  C++.  Otros  lenguajes  como  el  C  o  el  Módula II no están
orientados  a  objetos  pero   disponen  de  facilidades  para  la
definición de tipos abstractos de datos.


5.3.1 Código fuente, código objeto. Librerías y enlazadores

        Los programas  que hemos esta realizando  hasta el momento
se conocen como código fuente,  son ficheros de texto cuyas líneas
contienen   instrucciones   de    un   determinado   lenguaje   de
programación.   El  ordenador   sólo  puede   ejecutar  un  número
determinado de  instrucciones, en general muy  sencillas, por ello
necesitamos  utilizar un  compilador para  poder ejecutar nuestros
programas.  El  compilador  traduce   nuestro  código  fuente  (un
programa  en C,  por ejemplo)  a un  código objeto  que la máquina
puede reconocer. En  principio este código tal y  como es generado
por el compilador no puede ser ejecutado por el sistema operativo,
puesto que en nuestro código fuente hacemos referencia a funciones
como  printf  o  malloc  que  nosotros  no  hemos  definido. Estas
funciones se encuentran ya compiladas  en forma de código objeto y
agrupadas en lo que se conoce como una librería.

        Así  se hace  necesaria  la  utilización de  otro programa
conocido como  enlazador o linker. El  enlazador toma como entrada
una serie de módulos objeto o librerías y enlaza todos los módulos
utilizados  por el  programa en   un sólo  programa que  pueda ser
ejecutado por el sistema operativo.

        De  todo  esto  se  deduce  que  resulta  muy  interesante
disponer de  librerías para distintas  operaciones que pueden  ser
utilizadas en  cualquiera de nuestros  programas. Además a  medida
que  los  programas  se  van  haciendo  cada  vez  más  grandes es
recomendable dividir el programa en varios ficheros que contengan,
cada  uno de  ellos, funciones  que guarden  cierta relación o que
actúen  sobre  un  determinado  tipo  de  datos,  siendo  ésta  la
finalidad que perseguimos en este capítulo.


5.3.2 Los ficheros de proyectos de Borland

        Los  compiladores  de  lenguaje  C  de  Borland ofrecen un
entorno integrado  para el desarrollo de  programas. Nos ofrece un
editor  para escribir  nuestro código  fuente, un  compilador para
obtener el código objeto y un enlazador para construir el programa
ejecutable final.

        NOTA: esto es sólo aplicable a compiladores C con entornos
integrados.  Por  consiguiente,  no  es  aplicable  al  GCC,  pero
conviene saber  algo del tema para  posteriores trabajos con otros
compiladores. Aquí se hace referencia al compilador Borland C.

        Hasta  ahora  nuestros  programas  eran  muy  sencillos  y
ocupaban un sólo  fichero, con lo que el  enlazador sólo tenía que
vérselas con las librerías del  sistema. En este capítulo nuestros
programas  van a  estar formados  por varios  ficheros y  por ello
tendremos que utilizar los ficheros de proyectos.

        Estos  ficheros simplemente  contienen los  nombres de los
ficheros  que  son  necesarios  para  crear  el programa final. Se
pueden crear  utilizando la opción Proyect  del menú principal del
compilador  y  seleccionando  posteriormente  la  opción Open. Una
ventana aparecerá  en pantalla y en  ella tendremos que introducir
los nombres de los ficheros que compondrán nuestro programa.

        En la  línea inferior de  la pantalla siempre  aparece una
ayuda con  las teclas que se  pueden utilizar para trabajar  en la
ventana  que  se  encuentre  activa.  La  tecla INSERT nos permite
seleccionar  los  ficheros  a  incluir  mediante  un  selector  de
ficheros semejante al que aparece al cargar un programa.


5.3.4 Tipos abstractos de datos.

        Ya hemos explicado el concepto  de tipo abstracto de datos
en  la  introducción  de  este  capítulo,  y  en  esta sección nos
centraremos en el modo de implementación.

        Fundamentalmente vamos a tener tres ficheros:

        1.-El programa principal: Este  programa utilizará el tipo
de   datos   abstractos   que   implementaremos   como  un  módulo
independiente.  Además   debe  incluir  un  fichero   .H  con  las
definiciones de los tipos abstractos y de las funciones de gestión
de los datos.

        2.-El  fichero de  cabecera conteniendo  la definición del
tipo abstracto y de los prototipos de las funciones.

        3.-La  estructura   real  del  tipo  de   datos,  la  cual
permanecerá  oculta  al  programa  principal,  y  el código de las
funciones definidas para trabajar con el tipo.

        Nuestro tipo abstracto no es tal.  En el fichero 3, el que
implementa las funciones y tipos de datos, nos encontramos con una
definición de un  tipo de dato como podría ser  el utilizado en el
ejemplo de las  matrices. Sin embargo en el  programa principal no
tendremos  acceso  a  los  distintos  campos  de esa estructura de
datos,  ésta es  la razón  de que  se llamen  tipos abstractos. La
mejor forma para expresar un tipo  abstracto es el tipo void. void
representa  cualquier  cosa,  de  esta  manera  para  el  programa
principal  cualquier tipo  abstracto será  un puntero  a void.  La
necesidad de  que se trate con  punteros, deriva del hecho  de que
tenemos que mantener las estructuras  de datos ocultas, lo que nos
obliga a  asignar dinámicamente las variables  del tipo abstracto,
haciéndose necesario el uso de punteros.

        Veremos un ejemplo de  implementación de un tipo abstracto
de datos  y lo comentaremos.  El tipo que  implementaremos será el
tipo vector y  definiremos una serie de funciones  para el trabajo
con vectores.

        Comenzaremos con  el fichero que implementa  el nuevo tipo
de datos y las funciones asociadas a él.

        VECTOR.C

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

struct vector
{
        float   *componentes;
        int     dimension;
};

typedef struct vector *VECTOR;

#include "vector.h"

VECTOR crear_vector(int dimension)
{
VECTOR temp;

temp=(VECTOR)malloc(sizeof( struct vector));
if (temp==0)
                {
                   printf("\nno hay sitio en memoria");exit(1);
                }
temp->dimension=dimension;
temp->componentes=(float *)calloc(dimension,sizeof(float));
if (temp->componentes==0)
                {
                   printf("\nno hay sitio en memoria");exit(1);
                }
return temp;
}

void borrar_vector(VECTOR vec)
{
free(vec->componentes);
free(vec);
}

float leer_componente(VECTOR vec,int componente)
{
float x;
x=*(vec->componentes+componente);
return x ;
}

void escribir_componente(VECTOR vec,int componente,float valor)
{
*(vec->componentes+componente)=valor;
}

void suma(VECTOR uno,VECTOR dos,VECTOR tres)
{
int i;

if (uno->dimension!=dos->dimension) printf("\nerror");
else
 if (uno->dimension!=tres->dimension) printf("\nerror");
 else
  for(i=0;i<uno->dimension;i++)
   *(tres->componentes+i)=*(dos->componentes+i)+
     *(uno->componentes+i);
}

        En  primer  lugar  se  indican  las  librería que necesita
utilizar  el  fichero  en  sí.  En  este  caso  la stdlib para las
asignaciones  y liberaciones  de memoria,  y la  stdio para  sacar
mensajes de error por pantalla.

        A continuación  definimos un tipo  de datos para  trabajar
con vectores, se trata de un vector  y un entero que nos indica la
longitud, y  finalmente el tipo  de dato abstracto  VECTOR como un
puntero a esta estructura. Como  vemos dentro del fichero VECTOR.C
el dato VECTOR está perfectamente definido y la ocultación de éste
se realiza en el fichero VECTOR.H.

        En el  fichero VECTOR.C podemos  dividir las funciones  en
varios tipos.  En primer lugar están  las funciones crear_vector y
borrar_vector,  las cuales  se encargan  de reservar  y liberar la
memoria necesaria  para cada elemento. Por  otra parte tenemos las
funciones leer_componente  y escribir_componente que  nos permiten
acceder a  la estructura de  datos, dependiendo del  tipo de datos
puede que no  sea necesario que el programador  acceda a los datos
en la  estructura. Finalmente tenemos  las funciones que  realizan
operaciones con los vectores, sumar_vectores.

        Vemos que antes de que comience el código de las funciones
tenemos un include de el fichero  VECTOR.H, esta línea lo que hace
es incorporar los prototipos de  las funciones para poder llevar a
cabo la compilación, ya se explicó la función de los prototipos de
funciones.  Además al  principio de  el fichero  se ha definido un
constante VECTOR_C, en el fichero .H veremos su utilidad.

        VECTOR.H

#ifndef VECTOR_H

#define VECTOR_H

#ifndef VECTOR_C
        typedef void *VECTOR;
#endif

VECTOR crear_vector(int);
void borrar_vector(VECTOR);
float leer_componente(VECTOR,int);
void escribir_componente(VECTOR,int,float);
void suma(VECTOR,VECTOR,VECTOR);

#endif

        En  primer  lugar  se  comprueba  si  se  ha  definido  la
constante VECTOR_H, para evitar incluir el fichero más de una vez.
Si  está definida  se ignora  el fichero,  si no  está definida la
define y comprueba si el fichero  que llama a VECTOR.H es VECTOR.C
mediante  la constante  VECTOR_C que  se definía  al comienzo  del
fichero VECTOR.C. Si no está definida se declara el tipo abstracto
VECTOR como un puntero a  void, eliminando de esta forma cualquier
información  sobre  la  forma  de  el  tipo  asociado  a VECTOR en
VECTOR.C. Si está definida no se realiza la definición para evitar
una  redeclaración de  VECTOR  dentro  de VECTOR.C.  Finalmente se
encuentran  los  prototipos  de  las  funciones  asociadas al tipo
vector.

        Hasta  el   momento  sólo  conocíamos   la  directiva  del
preprocesador #include.  En este ejemplo se  incluye dos más, cuyo
significado es evidente. La  directiva #define permite definir una
constante en  general realiza una macro  sustitución, veámoslo con
unos ejemplos:

        #define PI      3.14159

        Esta  sentencia  simplemente   define  una  constante,  en
cualquier parte de el programa en la que aparezca PI, este símbolo
se sustituirá por 3.14159.

        #define COMP    vector->datos

        Con esta definición podríamos escribir instrucciones como:

        *(COMP+1) = 10;

        Las  directivas  #ifndef  y  #endif  van siempre asociadas
señalando   bloques  de   programa  y   permiten  la   compilación
condicional de bloques de programa. Significan algo como:

        Si no está definida tal cosa compila (#ifndef)
        esto
        Hasta aquí (#endif)

        Finalmente   veamos   como   utilizaríamos   nuestro  tipo
abstracto en un programa, para sumar dos vectores.

#include"stdio.h"
#include"vector.h"
main()
{
VECTOR x,y,z;
int i;

x=crear_vector(3);
y=crear_vector(3);
z=crear_vector(3);
printf ("\n");
for(i=0;i<3;i++)
        escribir_componente(x,i,i);
for(i=0;i<3;i++)
        escribir_componente(y,i,i+2);

suma(x,y,z);
for(i=0;i<3;i++)
{
printf("\n%f + %f = %f",leer_componente(x,i),
     leer_componente(y,i),leer_componente(z,i));

}
borrar_vector (x);
borrar_vector (y);
borrar_vector (z);

}