3004597 – Estructura de Datos – Modulo 06 Universidad Nacional de Colombia – Sede Medellín MODULO 06 OBJETIVOS Con el desarrollo de este módulo se pretende: - Introducir el uso de los punteros como una potente herramienta para el manejo de estructuras dinámicas en memoria y hacer ver los peligros que puede traer un manejo inadecuado de estos - Introducir conceptos afines como la Indirección y accesos complejos. - Definir e ilustrar el funcionamiento del Heap. - Profundizar en el concepto de recursión, dar elementos para comprender su funcionamiento. Mostrar los casos en los que resulta ventajosa e ilustrar como hace el sistema uso del stack para implementarla. - Presentar ejemplos que ayuden a la comprensión del tema, dando elementos al estudiante para seguir algoritmos recursivos. CONTENIDO 1 1.1 1.1.1 1.2 1.2.1 1.2.2 1.3 1.3.1 Punteros Direcciones de variables Operador de referencia Uso de los punteros Operador de Indirección Paso de argumentos por referencia Aritmética de punteros Arreglos 2 2.1 2.2 Memoria dinámica Heap Uso de la memoria dinámica 3 Recursión A Referencias B Taller 02-2003 1 3004597 – Estructura de Datos – Modulo 06 1 Universidad Nacional de Colombia – Sede Medellín PUNTEROS Los punteros son variables muy especiales: toman como valores direcciones a otras variables. La importancia de los punteros radica en su estrecha relación con el manejo de arreglos y memoria dinámica, sin embargo su utilidad va mucho más allá, lo que los convierte en una de las más potentes características de C/C++. Un uso adecuado de los punteros permite al programador codificar programas extremadamente eficientes y potentes, sin embargo un uso equivocado de estos conduce casi siempre a errores de manejo de memoria muy difíciles de encontrar y que pueden traer consecuencias catastróficas. 1.1 DIRECCIONES DE VARIABLES En el módulo anterior se estudió como la memoria está dividida en un conjunto de posiciones de memoria, cada una de las cuales puede guardar un byte (8 bits). Cada variable que es declarada, tiene asociado un espacio de memoria, es decir, un conjunto de posiciones de memoria en las cuales se puede guardar un valor correspondiente al tipo de la variable. Cada espacio de memoria tiene una dirección de memoria que permite hacer referencia a él. A la dirección de la primera posición de memoria asociada a una variable se le llamará dirección de la variable. Así, al usar el nombre de una variable, realmente nos estamos refiriendo al valor almacenado en el espacio de memoria asociado a dicha variable. 1.1.1 Operador de referencia C/C++ posee un mecanismo para obtener la dirección de una variable, conocido como el operador de referencia. Para usarlo se debe seguir la sintaxis: dest = &nombre_variable; // dest es una variable que almacena // direcciones, es decir, un puntero. El operador de referencia devuelve la dirección de la variable que le precede, así que su resultado debe ser asignado a alguna variable en la cual pueda guardarse una dirección de memoria (es decir, a un puntero). Figura 1.1: Direcciones de variables Dirección Nombre de la variable [0001] A 50 Espacio de memoria asociado a la variable entera A. La dirección de la variable A inicia en 0001, el contenido de la variable A es 50. ¡Cuidado! no se debe confundir el valor de una variable con su dirección. Ejemplo 1.1: valor y dirección 02-2003 2 3004597 – Estructura de Datos – Modulo 06 Universidad Nacional de Colombia – Sede Medellín El siguiente fragmento de código muestra la diferencia entre el valor y la dirección de una variable usando el operador de referencia. Primero se crea la variable entera A, se le asigna un valor de 50 y se muestra en pantalla su dirección y a continuación su valor (50). int A; A=50; cout<<"La dirección de A es: "<< &A<<" El valor de A es: "<<A; //Esto imprimirá algo como: // La dirección de A es: 0x0012FF7C El valor de A es: 50 1.2 USO DE LOS PUNTEROS Un puntero es una variable en la cual se almacenan direcciones de memoria, es decir en un puntero se pueden guardar direcciones de variables. Cuando a un puntero se le asigna la dirección de una variable se dice que el puntero apunta a dicha variable. Para declarar un puntero a una variable de un tipo dado se usa la siguiente sintaxis: tipo_var *nombre_puntero; Que ordena al compilador: “declare una variable de tipo puntero llamada nombre_puntero que guardará direcciones de variables de tipo tipo_var”. Es muy importante tener en cuenta que al declarar un puntero este quedará apuntando a algún lugar indeterminado de la memoria hasta que se le asigne la dirección de alguna variable válida del programa, por tanto, nunca debe utilizarse un puntero antes de inicializarlo. Figura 1.2: Punteros int A=50; int *P; [0001] P=&A; A 50 [0002] P 0001 Se declara un puntero (a entero) P, se le asigna la dirección del entero A, por tanto P queda apuntando a A. 1.2.1 Operador de indirección 02-2003 3 3004597 – Estructura de Datos – Modulo 06 Universidad Nacional de Colombia – Sede Medellín El operador de indirección permite acceder a la variable cuya dirección se encuentra guardada en un puntero. Es decir, nos permite manipular la variable a la que apunta el puntero (ya sea para acceder al valor almacenado en la variable o para modificarlo). El operador de Indirección usa la siguiente sintaxis: *nombre_puntero Que puede usarse en cualquier lugar del código en el que sea pertinente usar un nombre de variable. Ejemplo 1.2: uso de punteros En el siguiente código se crea la variable entera A con un valor inicial de 50, luego se declara el puntero P y se le asigna la dirección de A (P queda apuntando a A). A continuación se usa el operador de Indirección para acceder al valor de A y luego para cambiar el valor de A a 100. Nótese que la expresión *P es completamente equivalente a usar el nombre de variable A. int A=50; int *P; P=&A; 1.2.2 cout<< *P; *P=100; // Imprime 50 cout<< *P; cout<< A; // Imprime 100 //Imprime 100 Paso de argumentos por referencia En el módulo anterior se estudió el paso de argumentos a funciones por valor y como devolver resultados, allí se vio que las funciones operan sobre una copia local de los argumentos y en consecuencia al terminar la función no se habrá modificado ninguna de las variables pasadas como argumentos (a menos que se le haya asignado el valor de retorno de la función a alguna de ellas). En algunas funciones se necesita retornar más de un valor; para hacer esto existe otro método de paso de argumentos llamado paso de argumentos por referencia, con este método la función recibe las direcciones de las variables sobre las cuales se va a operar. Cuando una variable se pasa por referencia a una función, será modificada a medida que la función opere sobre ella. Esto se debe a que la función utiliza la dirección de sus argumentos para escribir y leer directamente sobre ellos en vez de crear copias locales con sus valores. El siguiente ejemplo ilustra este método. Ejemplo 1.3: paso de argumentos por referencia La función definida a continuación recibe la dirección de dos enteros y recibe un entero por valor, los dos primeros enteros se suman y el resultado se asigna a cada uno de ellos y al tercer entero, 02-2003 4 3004597 – Estructura de Datos – Modulo 06 Universidad Nacional de Colombia – Sede Medellín que posteriormente es retornado. Al declarar la función, deben indicarse que argumentos se pasarán por referencia, esto se hace escribiendo * justo antes del nombre del argumento y después de su tipo. Ya que la función recibió las direcciones de los dos primeros argumentos (a y b), es necesario usar el operador de Indirección (*) dentro de la función para acceder a su contenido. int sumap(int *a, int *b, int c) { c = *a + *b; *a = c; *b = c; /*la función recibe 2 direcciones de enteros y el valor de un entero*/ /*Dentro de la función, a y b son dos variables locales de tipo puntero a int*/ return c; } Ahora, veamos como se invoca la función y como se le asignan los argumentos. Al invocar la función sumap deben entregársele como parámetros dos direcciones a variables entero y un entero. Para entregar las direcciones de las variables a y b se usa el operador de referencia &, c se pasa por valor. void main() { int a=3; int b=2; int c=0; int r=0; r=sumap( &a, &b, c ); cout<<”Valores cout<<”a=”<<a; cout<<”b=”<<b; cout<<”c=”<<c; cout<<”r=”<<r; finales: \n“; // Imprime 5 // Imprime 5 // Imprime 0 // Imprime 5 } Al final, la función imprimirá a = 5, b = 5, c = 0, r = 5. Esto indica que a y b fueron modificados directamente por la función (ya que se pasaron por referencia), mientras que c no fue modificado (ya que se pasó por valor), finalmente r fue modificado al asignársele el valor retornado por la función. 1.3 ARITMÉTICA DE PUNTEROS En C/C++ pueden llevarse a cabo dos operaciones aritméticas sobre un puntero: adición y sustracción (sólo de números enteros), estas operaciones se utilizan para cambiar el lugar de la memoria al que apunta el puntero. Cada vez que se le suma/resta un número n a un puntero la dirección contenida en este se aumenta/disminuye en n*(tamaño) donde tamaño es el número de 02-2003 5 3004597 – Estructura de Datos – Modulo 06 Universidad Nacional de Colombia – Sede Medellín bytes que ocupa una variable del tipo asociado al puntero, esto significa que la aritmética de punteros tiene en cuenta el tamaño de la variable apuntada. En C/C++ puede obtenerse el tamaño en bytes de las variables de un tipo dado usando la función sizeof(tipo) que recibe como argumento el nombre del tipo de dato y devuelve un entero (el tamaño en bytes que ocupan las variables de dicho tipo). Las operaciones sobre punteros deben usarse con mucha precaución, ya que al cambiar indiscriminadamente los lugares de la memoria a los que se apunta se pueden sobrescribir accidentalmente otros datos importantes del programa ocasionando errores al momento de la ejecución. Ejemplo 1.4: aritmética de punteros Supongamos que se crea una variable entera a cuya dirección es 0001 y un puntero a entero llamado p. Si se asigna a p la dirección de a, entonces p contendrá la dirección 0001 (apunta a a). Ahora, si se suma 1 a p, la dirección que contenía se aumentará en 2, (debido a que el tamaño en bytes de un entero es 2) es decir, apuntará a la posición de memoria 0003. Esto es peligroso, ya que p quedó apuntando a un lugar de memoria cuyo contenido es desconocido. void main() { int a=654; int *p; /*Luego de sumar 1 a p, este apuntará dos posiciones de memoria adelante de la dirección de a*/ p=&a; p++; cout<< *p << "y" << *(p-1); // Imprimió: 1245120 y 654 } Figura 1.3: aritmética de punteros a Dirección de a (a ocupa 2 bytes) p [0001] [0002] [0003] [0004] 654 ? ? [0045] ... ? Antes de ejecutar p++ Después de ejecutar p++ 1.2.3 Arreglos Como se vio en módulos anteriores, los arreglos son un conjunto de espacios de memoria de igual tamaño ubicados uno al lado del otro, al crear un arreglo de n posiciones en realidad se están declarando n variables del tipo dado localizadas en posiciones de memoria contiguas. ¿Pero cómo 02-2003 6 3004597 – Estructura de Datos – Modulo 06 Universidad Nacional de Colombia – Sede Medellín se accede a esas variables? en C/C++ esto se hace usando un puntero al primer elemento del arreglo y el operador [n]. El identificador (o nombre) de un arreglo siempre se refiere a la dirección del primer elemento, el operador [n] precedido del nombre de un arreglo es equivalente a sumar n al puntero a la primera posición del arreglo. Supongamos que hemos creado un arreglo de 10 enteros llamado A con la instrucción int A[10], siempre que se utilice el identificador A, se estará haciendo referencia a la dirección del primer elemento del arreglo. Para acceder al elemento 5 del arreglo se escribe A[5] que significa exactamente lo mismo que *(A+5). Para crear un puntero a un arreglo, simplemente se declara una variable puntero al tipo de los elementos del arreglo y se le asigna la dirección al primer elemento (es decir, el nombre del arreglo). Este puntero puede ser acompañado de + i para acceder al elemento i del arreglo (ver figura 1.4) Siempre se debe tener precaución de no sobrepasar los límites del arreglo, ya que al hacerlo pueden modificarse datos del programa alterando su funcionamiento, C/C++ no reportan este tipo de errores al compilar y por lo general son muy difíciles de encontrar una vez se han cometido. Figura 1.4: arreglos y punteros 0 A Dirección de p1 p1 1 2 3 [0001] [0003] [0005] [0007] 50 50 50 50 [0023] 0001 [0345] p2 0003 int *p1; int *p2; int *p3; p1=A; p2=&p1[1]; p3=p1+3; //Dir. ele 1 //Dir. ele 2 //Dir. ele 4 [0090] p3 0007 Con respecto a la figura 1.4, nótese que las siguientes expresiones son equivalentes: (p1 +3) , (A + 3) y (&p1[3]); todas ellas proporcionan la dirección del cuarto elemento del arreglo. Ejemplo 1.5: la verdad acerca de [n] 02-2003 7 3004597 – Estructura de Datos – Modulo 06 Universidad Nacional de Colombia – Sede Medellín En el siguiente código se recorre un arreglo sin usar el operador [n], para acceder a los elementos del arreglo se usa aritmética de punteros, que es la manera como se implementa este operador. void main() { int A[]={2, 5, 87, 21, 34}; //No es necesario especificar el //tamaño del arreglo int *p; p=A; for(int i=0; i<5; i++) { cout<<"Elemento "<<i<<"="<<*p<<"\n"; p++; } } 2 MEMORIA DINÁMICA Hasta ahora todos los programas que se han desarrollado tienen un conjunto de variables conocido que se declaran al escribir el programa. Pero existen programas en los que no es posible conocer de antemano (antes de la ejecución) qué cantidad de memoria será necesaria, incluso puede ser necesario crear y destruir variables en plena ejecución, esto es muy común en los programas de estructuras de datos que necesitan insertar y eliminar elementos continuamente. La memoria dinámica permite a los programas pedir y liberar espacio de memoria durante su ejecución a medida que lo necesiten. 2.1 HEAP Al igual que el stack y el segmento de datos, el Heap o montículo es una zona de memoria con una función muy especial. También se conoce como pila de variables dinámicas, ya que se encarga de almacenar las variables dinámicas (espacios de memoria creados durante la ejecución de un programa). Es importante comprender que las variables asignadas dinámicamente (que residen en el Heap) no están sujetas a las reglas de duración vistas en el módulo anterior. Estas variables nunca salen de ámbito, por lo que una vez que se asigna memoria sobre el Heap se es responsable de liberarla cuando ya no se necesite. 2.2 USO DE LA MEMORIA DINÁMICA En C, la gestión de memoria dinámica se hace mediante las funciones malloc() y free(), que crean y liberan espacio de memoria en el Heap. Estas dos funciones se encuentran en la librería stdlib.h, por tanto, todo programa que las use debe tener esta librería en uno de sus includes. 02-2003 8 3004597 – Estructura de Datos – Modulo 06 Universidad Nacional de Colombia – Sede Medellín La función malloc() reserva un espacio de memoria de tamaño dado en el Heap y retorna un puntero a dicho espacio, si no había memoria suficiente, retorna NULL; malloc() utiliza la siguiente sintaxis: puntero = ( tipo_de_variable * ) malloc ( numero_de_bytes ); malloc() no es consciente del tipo de variable que se almacenará en el espacio que reservó, retorna una dirección de memoria sin tipo. La expresión ( tipo_de_variable * ) se conoce como typecast y se utiliza para asignarle tipo a la dirección retornada por malloc(), así, a puntero se le asignará la dirección de una variable de tipo tipo_de_variable (este tipo debe coincidir con el tipo del puntero). Ejemplo 1.6: creación de un arreglo usando malloc() La función crear_arreglo reserva espacio en memoria para n variables enteras, es decir, crea un arreglo de n enteros, inicializa en 1 todas sus posiciones y retorna su dirección. Para indicarle a malloc() el número de bytes que debe reservar, se usa sizeof(int) que retorna el tamaño en bytes de una variable entera; como se quiere crear un arreglo de n enteros, debemos indicar a malloc() que reserve n*sizeof(int) bytes. int *crear_arreglo(int n) { int *p; p=(int *)malloc( n*sizeof(int) ); for(int i=0; i<n; i++){ p[i]=1; } return p; } Figura 2.1: Mapa del Heap ejemplo 1.6 Heap void main(void) { int *a; a=crear_arreglo(4); } [0089] a 02-2003 0001 Dirección de a [0001] [0003] [0005] [0007] 0 0 0 0 9 3004597 – Estructura de Datos – Modulo 06 Universidad Nacional de Colombia – Sede Medellín Si no se libera el espacio reservado que ya no es necesario, se corre el riesgo de agotar la zona de memoria heap. La función free() libera un espacio de memoria reservado mediante malloc(), no retorna nada. Utiliza la siguiente sintaxis: free( direccion ); direccion, el único argumento de free(), es precisamente la dirección de memoria del espacio que se quiere liberar. Esta dirección debe ser alguna retornada anteriormente en un llamado a malloc(). C++ incluye la función new() que reemplaza a malloc() y es más fácil de usar. Al igual que malloc(), new() reserva un espacio de memoria determinado en el Heap: puntero= new tipo_de_dato [n]; new() retorna la dirección del espacio de memoria reservado, nótese que ya no es necesario usar el typecast, new() lo ejecuta automáticamente. tipo_de_dato es el tipo de las variables que se desean almacenar en el espacio reservado y [n] indica que se reserven n de estas variables. Ejemplo 1.7: creación de un arreglo usando new() La función crear_arreglo_new() hace exactamente lo mismo que la función crear_arreglo() del ejemplo 1.6, pero usa new() en vez de malloc(). int *crear_arreglo_new(int n) { int *p; p= new int[n]; for(int i=0; i<n; i++) p[i]=1; return p; } 3 RECURSIÓN Un algoritmo es recursivo si se encuentra definido en términos de sí mismo. Es posible que una función se llame a sí misma, basta para ello con invocar la función y pasarle los argumentos correspondientes. A este tipo de función la denominamos “recursiva”. Ejemplo 3.1: Función recursiva La función factorial es uno de los ejemplos clásicos de función recursiva, ya que el factorial de un número puede definirse como el producto entre el número y el factorial del número anterior: 02-2003 10 3004597 – Estructura de Datos – Modulo 06 Universidad Nacional de Colombia – Sede Medellín N!=N*(N-1)! La recursión resulta muy útil en ciertos problemas, en especial estructuras de datos recursivas como los árboles, de hecho, la recursión simplifica enormemente algunos algoritmos que por naturaleza son recursivos y es esta su mayor virtud. La desventaja de la recursión es la gran cantidad de espacio que se ocupa en memoria debido al llamado continuo de funciones que, como se vio, se acumulan en el stack. En una función recursiva se deben considerar dos casos. En el caso trivial se da la respuesta sin necesidad de hacer más llamadas recursivas, a este caso se le denomina salida de la recursión y es necesario que exista por lo menos uno de estos casos dentro de toda función recursiva (de no existir el programa podría no terminar) En el segundo caso, para poder dar una respuesta, es necesario esperar hasta obtener el resultado de una o varias llamadas recursivas. A este caso se le conoce como avance de la recursión y su presencia también es propia de toda función recursiva. Ejemplo 3.2: función factorial recursiva En este ejemplo se presenta la implementación recursiva de la función factorial de un número entero n. int factorial(int n) { Caso de salida de la recursión if(n==0) return 1; Avance de recursión else return (n*factorial(n-1)); } Figura 3.1: función factorial recursiva void main(void) { int a; a=factorial(3); } a 6 Factorial(3) 2 Factorial(2) 1 Valor retornado Factorial(1) Llamadas recursivas a la función factorial 1 02-2003 Factorial(0) 11 3004597 – Estructura de Datos – Modulo 06 Universidad Nacional de Colombia – Sede Medellín Para seguir la ejecución de una función recursiva, resulta interesante diagramar un mapa del stack a medida que la función se va llamando a sí misma. Como se vio en el módulo anterior, cada vez que se invoca una función se apila una entrada en el stack que contiene copias de los argumentos y las variables locales de la función. Las funciones recursivas crean una serie de entradas en el stack correspondientes a cada invocación que hacen de sí mismas, cuando se llega a la función que ejecuta la salida de recursión comienzan a retornar y desapilarse una tras otra las funciones invocadas recursivamente en secuencia. Este proceso se muestra en el ejemplo 1.8. Ejemplo 1.8: mapa de memoria para la función factorial A continuación se muestra paso a paso el mapa del stack para el programa listado, que invoca la función factorial(3). void main() { int a; a=factorial(3); } - Paso 1: Función main() El programa inicia en la función main(), para la cual se crea una entrada en el stack, dentro de esta función, se declara una variable entera llamada a. - Paso 2: Primer llamado a factorial() Dentro de la función main() se invoca a factorial(3), por tanto se crea una entrada en el stack para el primer llamado a la función factorial con su respectivo argumento, llamado n. 02-2003 12 3004597 – Estructura de Datos – Modulo 06 Universidad Nacional de Colombia – Sede Medellín Figura 3.2: pasos 1 y 2 Paso 1 S T A C K - Paso 2 S T A C K factorial(3) n: 3 main() main() a: ¿ a: ¿ Paso 3: Llamada recursiva a factorial() Dentro de factorial(3) se invoca recursivamente a la función factorial(), esta vez con argumento n-1, cuyo valor en este paso es 2. Se apila una nueva entrada en el stack para esta función. Nótese que esta segunda función factorial() que se apila tiene su propio espacio en la pila al igual que las variables n's son independientes la una de la otra. - Paso 4: Segunda llamada recursiva a factorial() Similar al paso anterior, pero esta vez el argumento dado a factorial(), n-1, vale 1. 02-2003 13 3004597 – Estructura de Datos – Modulo 06 Universidad Nacional de Colombia – Sede Medellín Figura 3.3: pasos 3 y 4 Paso 3 Paso 4 factorial(1) n: 1 S T A C K - factorial(2) n: 2 S T A C K factorial(2) n: 2 factorial(3) factorial(3) n: 3 n: 3 main() main() a: ¿ a: ¿ Paso 5: Última llamada recursiva a factorial() Dentro de factorial(1) se llama de nuevo a factorial() recursivamente, se le pasa como argumento n-1 que en este paso vale 0. Figura 3.4: paso 5 factorial(0) n: 0 S T A C K Como se cumple la condición de salida de recursión (n=0), factorial(0) retorna 1 a la función que la invocó (factorial(1)). factorial(1) n: 1 factorial(2) n: 2 factorial(3) n: 3 main() a: ¿ 02-2003 14 3004597 – Estructura de Datos – Modulo 06 Universidad Nacional de Colombia – Sede Medellín Paso 6: salida de recursión - En la función factorial(0), se cumple la condición de la salida de recursión: n=0, por tanto, factorial(0) retorna 1 a la función factorial(1). Figura 3.5: paso 6 factorial(1) n: 1 S T A C K factorial(2) n: 2 La función factorial(1) recibe el valor retornado por factorial(0) y lo multiplica por su valor de n, es decir 1*1, posteriormente retorna este producto. El proceso se repite para cada llamado de la función factorial hasta llegar a factorial(3), que retorna el resultado final de la operación a la variable a de la función main(). factorial(3) n: 3 main() a: ¿ - Paso 7: retorno de todas las funciones factorial A su vez, factorial(1) retorna el valor que le fue entregado por factorial(0) multiplicado por el valor de su variable n (que en este caso es 1). Este proceso se repite para cada uno de los llamados hechos a factorial, hasta que por último, factorial(3) retorna el resultado final del proceso recursivo a la variable a de la función main(). 02-2003 15 3004597 – Estructura de Datos – Modulo 06 Universidad Nacional de Colombia – Sede Medellín Figura 3.6: paso 7 S T A C K main() a: 6 02-2003 La variable a contiene el valor de 3!, es decir, 6. Las demás funciones factorial() han sido desapiladas 16 3004597 – Estructura de Datos – Modulo 06 Universidad Nacional de Colombia – Sede Medellín ANEXOS A REFERENCIAS Villalobos Jorge, Quintero Alejandro, Otero Mario. Estructuras de datos. Ediciones Uniandes. Stubbs & Webre. Data structures. B TALLER (Basado en Villalobos, Quintero, Otero. Estructuras de datos, Capítulo 0) 1. Escriba una función que ordene los elementos de un arreglo de menor a mayor, haga que la función reciba como argumento un puntero con la dirección del primer elemento del arreglo y el tamaño de este. Se prohíbe el uso del operador [n]. 2. Cree una clase sencilla, y luego declare una función que reserve memoria para un arreglo de n elementos de dicha clase, la función debe retornar la dirección del espacio de memoria reservado. Use malloc(). 3. Cree una función no recursiva que calcule el factorial de un número. Compárela con la función factorial presentada en la sección 3. ¿Cuál considera mejor?. ¿Cuál es más fácil de escribir?. ¿Cuál ocupa menos espacio en memoria durante su ejecución?. 4. Desarrolle un procedimiento recursivo que imprima. En orden inverso, una cadena de caracteres sin alterarla. C CREDITOS Editor: PhD. Fernando Arango Colaboradores: Ing. Edwin Hincapié, Francisco Moreno MS.C, Santiago Londoño, Alberto Jiménez, Juan Carlos Hernández, Carlos Andrés Giraldo, Diego Figueroa. 02-2003 17