Fundamentos y Estructuras de Programación Gerardo M. Sarria M. Borrador de 21 de julio de 2011 Índice general 1. Introducción 9 2. Noción de Problema 2.1. Comienzos . . . . . . . . . . . . . . 2.1.1. Cálculo Lambda . . . . . . 2.1.2. Máquina de Turing . . . . . 2.2. Problemas Tratables e Intratables . 2.3. Solución de Problemas . . . . . . . 2.4. Estrategias de Implementación . . 3. Noción de Lenguaje 3.1. Historia . . . . . . . . . . . . 3.2. Estructura . . . . . . . . . . . 3.3. Compiladores . . . . . . . . . 3.4. Máquinas Virtuales . . . . . . 3.5. Depuración . . . . . . . . . . 3.6. Excepciones . . . . . . . . . . 3.7. Interfaces Gráficas de Usuario 3.8. Referencias y Apuntadores . . 3.9. Declaraciones y Tipos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11 11 12 14 16 26 31 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35 35 38 41 44 46 52 57 60 66 4. Noción de Tipo Abstracto de Datos 4.1. Tipos Abstractos de Datos . . . . . . . . . . . . . . . . . 4.2. Listas . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.2.1. Diseño . . . . . . . . . . . . . . . . . . . . . . . . 4.2.2. Implementaciones . . . . . . . . . . . . . . . . . 4.2.3. Análisis de Complejidad de las Implementaciones 4.2.4. Utilización . . . . . . . . . . . . . . . . . . . . . 4.2.5. Variantes . . . . . . . . . . . . . . . . . . . . . . 4.3. Pilas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.3.1. Diseño . . . . . . . . . . . . . . . . . . . . . . . . 4.3.2. Implementaciones . . . . . . . . . . . . . . . . . 4.3.3. Utilización . . . . . . . . . . . . . . . . . . . . . 4.4. Colas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.5. Tablas Hash . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 73 73 81 82 83 96 96 99 100 102 104 104 104 104 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . (Eventos) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3 Índice general 4.6. Árboles Binarios . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 104 4.7. Árboles N-arios . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 104 4.8. Grafos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 104 Índice de nociones 4 105 Índice de figuras 2.1. Máquina de Turing . . . . . . . . . 2.2. Estado inicial del sudoku . . . . . 2.3. Estado final del sudoku . . . . . . 2.4. Búsqueda lineal . . . . . . . . . . . 2.5. Búsqueda lineal bidireccional . . . 2.6. Un ejemplo de búsqueda binaria . 2.7. Búsqueda con tabla hash . . . . . . 2.8. Ventana de una Calculadora . . . . 2.9. Creación de funciones en el modelo 2.10. Creación de funciones en el modelo . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . top-down . bottom-up . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14 26 27 28 28 29 30 32 33 33 37 40 41 42 43 45 46 49 50 69 70 3.1. Evolución de los lenguajes de alto nivel . . 3.2. Análisis para la asignación . . . . . . . . . . 3.3. Componentes superficiales de un compilador 3.4. Componentes intermedios de un compilador 3.5. Ejemplo de las fases de compilación . . . . . 3.6. Jerarquı́a de las máquinas virtuales . . . . . 3.7. Primer bug encontrado . . . . . . . . . . . . 3.8. Data Display Debugger . . . . . . . . . . . 3.9. Editor Eclipse . . . . . . . . . . . . . . . . . 3.10. Diagrama de contorno . . . . . . . . . . . . 3.11. Diagrama de contorno . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.1. 4.2. 4.3. 4.4. 4.5. 4.6. 4.7. 4.8. 4.9. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 83 . 84 . 88 . 88 . 89 . 90 . 91 . 93 . 101 Nodo con encadenamiento simple . . . Lista con encadenamiento simple . . . Nodo con doble encadenamiento . . . Lista con doble encadenamiento . . . . Lista circular encadenada simple . . . Lista circular doblemente encadenada Lista implementada con un vector . . Lista implementada con cursores . . . Ejemplo real de una pila . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5 Índice de algoritmos 1. 2. 3. 4. 5. 6. 7. 8. 9. 10. 11. 12. 13. 14. 15. 16. 17. 18. 19. 20. 21. 22. 23. 24. Imprime los números del 1 al 100 . . . . . . . . . . . . . . . . . . . . . . . . Imprime los números del 1 al n . . . . . . . . . . . . . . . . . . . . . . . . . . Imprime los números del n al 1 dividiendo por dos cada vez . . . . . . . . . . Suma los elementos impares de un vector de enteros . . . . . . . . . . . . . . Ordenamiento por el método burbuja . . . . . . . . . . . . . . . . . . . . . . Descubre si el procesador tiene error . . . . . . . . . . . . . . . . . . . . . . . Divide el número 10 entre un número dado por el usuario en C++ . . . . . . Divide el número 10 entre un número dado por el usuario, usando un condicional en C++ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Divide el número 10 entre un número dado por el usuario, usando una aserción en C++ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Divide el número 10 entre un número dado por el usuario, usando un manejador de excepciones en C++ . . . . . . . . . . . . . . . . . . . . . . . . . . . Divide el número 10 entre un número dado por el usuario, usando dos manejadores de excepciones en C++ . . . . . . . . . . . . . . . . . . . . . . . . . . Divide el número 10 entre un número dado por el usuario, garantizando la continuación, alternativa 1 en C++ . . . . . . . . . . . . . . . . . . . . . . . Divide el número 10 entre un número dado por el usuario, garantizando la continuación, alternativa 2 en C++ . . . . . . . . . . . . . . . . . . . . . . . Imprime ’HolaMundo!’ en una ventana usando Gtk+ . . . . . . . . . . . . . Imprime ’HolaMundo!’ por lı́nea de comandos usando curses . . . . . . . . . Asigna cuatro variables en Python (análisis de referencias) . . . . . . . . . . Declara, asigna e imprime un entero y un apuntador a entero en C++ . . . . Asigna tres variables en Python (análisis de tipos) . . . . . . . . . . . . . . . Asigna tres variables en C++ (análisis de tipos) . . . . . . . . . . . . . . . . Cambio de tipo de una variable en Python . . . . . . . . . . . . . . . . . . . Definición de dos bloques de ejecución en Pascal . . . . . . . . . . . . . . . . Definición de dos funciones que comparten una variable en Python . . . . . . Asignación simple en Python . . . . . . . . . . . . . . . . . . . . . . . . . . . Dos identificadores con la misma referencia en una función en Python . . . . 17 18 19 21 22 48 53 53 54 54 55 56 57 59 59 60 66 67 67 68 70 71 72 72 7 1 Introducción 9 2 Noción de Problema Un problema existe cuando el estado en el que se encuentran las cosas difiere del estado en que se desea que estén. La solución al problema es una serie de pasos que llevan del estado en que están al estado que se desean. Existen muchos problemas en el mundo (más de los que el hombre puede resolver). Una gran cantidad de dichos problemas pueden resolverse usando el computador como herramienta. En este capı́tulo se mostrará cómo identificar problemas que son solucionables por medio del computador y estrategias para resolverlos. 2.1. Comienzos El estudio de los problemas que se pueden resolver por medios computacionales tiene sus comienzos en la década de 1930, cuando D. Hilbert pretendı́a crear un sistema matemático formal completo y consistente, en el que todos los problemas pudieran plantearse con precisión. Además deseaba encontrar un algoritmo para determinar si una proposición, dentro del sistema, era verdadera o falsa. Con este sistema cualquier problema bien definido se resolverı́a aplicando dicho algoritmo. Después de varias investigaciones K. Gödel demostró que el sistema planteado por Hilbert no era posible construirlo. Para ello publicó el famoso Teorema de Incompletitud1 . Unos años después se mostró que existı́an problemas que eran indecidibles , es decir, no hay algoritmos que resuelvan dichos problemas (A. Church y A. Turing probaron que el problema de Hilbert era indecidible). Aquı́ los problemas se dividieron en dos tipos: tratables e intratables. Los estudios teóricos de los problemas siguieron cuando Church introdujo a las matemáticas una notación formal para las funciones calculables, que denominó cálculo lambda. La idea era transformar todas las fórmulas matemáticas a una forma estándar, de tal manera que la demostración de teoremas se convertirı́a en la transformación de cadenas de sı́mbolos siguiendo un conjunto de reglas como en un sistema lógico (véase [21]). Por otro lado, Turing argumentó que el problema de Hilbert podı́a ser atacado con la ayuda de una máquina. La Máquina de Turing podı́a ser usada por una persona para ejecutar un procedimiento bien definido, mediante el cambio del contenido de una cinta 1 Problema Teorema de incompletitud : Ningún sistema deductivo que contenga los teoremas de la aritmética, y con los axiomas recursivamente enumerables puede ser consistente y completo a la vez. 11 Indecidibilidad 2 Noción de Problema ilimitada, dividida en cuadros que pueden contener un solo sı́mbolo de un conjunto dado (el alfabeto). A continuación se mostrará un poco más en detalle los dos estudios anteriores. 2.1.1. Cálculo λ Cálculo Lambda El cálculo lambda es un formalismo para especificar funciones que calculan valores a partir de sus argumentos. Las funciones se definen con el sı́mbolo λ seguido de su argumento (funciones con múltiples argumentos pueden verse como la aplicación de funciones a funciones). La notación λx.P muestra una función cuyo cuerpo es P y cuyo argumento es x, de manera que la aplicación de esta función con un argumento n se reduce a sustituir x por n en P . Lo anterior quiere decir que si se tiene λx.f x y se aplica con el argumento n, se tendrá (λx.f x)n = f n La sintaxis abstracta del cálculo puede verse en el cuadro 2.1. Se tienen solo variables, términos lambda aplicados y abstracciones de términos. M ::= x | M1 M2 | λx.M (variables) (aplicación) (abstracción) Cuadro 2.1: Sintaxis del cálculo lambda Con el cálculo lambda, las funciones calculables (i.e. la idea de computabilidad) pueden ser expresadas [16]. En el ejemplo 2.1.1 se muestra la función suma de números naturales en el cálculo lambda. Ejemplo 2.1.1 Supongamos que queremos usar el cálculo lambda para sumar números naturales, es decir, saber si la suma de números naturales es una función computable (i.e. puede ser implementada en un computador). Lo primero que debemos hacer es representar los números en este cálculo ya que como se vió en el cuadro 2.1 no hay números, pero ¿cómo crear una representación, dentro de un sistema que solo soporta sı́mbolos (y no números), que permita contar, sumar, multiplicar y hacer todo lo que se puede hacer con números? 12 2.1 Comienzos La idea es crear una representación funcional lo más cercana posible a los números naturales. Representar el número cero y crear una función sucesor para encontrar los demás números, es básico. Los números naturales en el cálculo lambda pueden ser representados como una función con dos parámetros (Números de Church): λf.λx.halgoi El primer parámetro f , es la función sucesor. El segundo parámetro, x, es el valor que representa el cero. De allı́ que el 0 sea representado como: 0 ≡ λf.λx.x Cuando la función anterior es aplicada, siempre retornará el valor que representa el cero. El número de Church para el uno aplica la función sucesor al valor que representa el cero, exactamente una vez: 1 ≡ λf.λx.f x Los números de Church siguentes se encuentran aplicando la función sucesor más veces: 2 3 4 5 ≡ ≡ ≡ ≡ .. . λf.λx.f (f x) λf.λx.f (f (f x)) λf.λx.f (f (f (f x))) λf.λx.f (f (f (f (f x)))) n ≡ λf.λx.f n x Para representar la suma se tomará la aproximación más sencilla: sumar dos números n y m, es tomar m y sumarle uno (1) n veces, es decir, encontrar el n-avo sucesor de m; ası́, 3 + 5 es hallar el 3er sucesor de 5. Si el conjunto de sucesores de 5 es {6, 7, 8, 9, . . .}, el tercer sucesor es 8. De manera más formal, y siguiendo con la idea de que solo se puede representar el cero, y los demás números se trabajan como sucesores del cero. La función sucesor podrı́a definirse ası́: S ≡ λn.λf.λx.f (nf x) De esta manera el número 2 surge de aplicar la función sucesor al número 1. S 1 ≡ → → → ≡ (λn.λf.λx.f (nf x)) (λf.λx.f x) λf.λx.f ((λz.λw.zw)f x) λf.λx.f ((λw.f w)x) λf.λx.f (f x) 2 13 2 Noción de Problema Entonces la suma se representarı́a de esta manera: + ≡ λn.λmλf.λx.f n (f m x) + n m ≡ λf.λx.nf (mf x) Ası́, la suma de 2 + 1 será: + 2 1 ≡ → → → λf.λx.(λf2 .λx2 .f2 (f2 x)) f ((λf1 .λx1 .f1 x) f x) λf.λx.f (f ((λf1 .λx1 .f1 x) f x) λf.λx.f (f (f x) 3 ? ? ? 2.1.2. Máquina de Turing Máquina de Turing Una máquina de Turing es un concepto abstracto creado para demostrar las limitaciones de la computación. Ella trabaja por medio de estados, en el que cada estado es un paso donde se realiza una acción. Aunque la máquina de Turing es un concepto abstracto, en la figura 2.12 puede verse una representación de ella. Máquina Cabeza Símbolos Cinta Figura 2.1: Máquina de Turing 2 Para comprender mejor el concepto de la máquina de Turing se ha creado un software llamado JFLAP que puede descargarse gratis de http://www.jflap.org/ 14 2.1 Comienzos Una máquina de Turing está conformada por: 1. Un cinta infinita donde serán escritos o leı́dos los sı́mbolos del alfabeto. 2. Una cabeza para realizar la lectura y la escritura. 3. Un tabla de acciones que muestra las posibles transiciones que se pueden realizar. 4. Un registro de estados que almacena lo que ha pasado en la máquina. En el ejemplo 2.1.2 se muestra una máquina de Turing. Ejemplo 2.1.2 Se tiene un alfabeto {0, 1 y #}, siendo # el sı́mbolo de espacio. La cinta de la máquina arranca ası́ (la cabeza está ubicada en el elemento subrayado): # # 1 1 1 # es decir, arranca con el número 7 en binario, y se espera que se forme el número 10 en binario, es decir, el número 1010. El conjunto de estados es {s1, s2}, y el estado inicial es s1. La tabla de acciones es la siguiente: Estado Actual -----s1 s1 s1 s2 s2 s2 Simbolo Leido ------# 0 1 # 0 1 -> -> -> -> -> -> Simbolo a Escribir ----------1 1 0 # 0 1 Movimiento ---------Der. Der. Izq. Izq. Der. Der. Nuevo Estado -----s2 s2 s1 s1 s2 s2 El cómputo de esta máquina de Turing podrı́a resumirse en el siguiente registro de estados (la cabeza está ubicada en la posición del sı́mbolo subrayado): 15 2 Noción de Problema Paso ---1 2 3 4 5 6 7 8 9 10 11 12 13 Estado Cinta ------ ----s1 ##111# s1 ##110# s1 ##100# s1 ##000# s2 #1000# s2 #1000# s2 #1000# s2 #1000# s1 #1000# s2 #1001# s1 #1001# s1 #1000# s2 #1010# -- para -? ? ? Turing usó su máquina para demostrar que existen funciones que no son calculables por medio de métodos definidos y en particular, que el problema de Hilbert era uno de esos problemas. Además, demostró la equivalencia entre lo que se podı́a calcular mediante una máquina de Turing y lo que se podı́a calcular con un sistema formal en general. El resultado de las investigaciones de Church-Turing arrojó la existencia de algoritmos que con determinadas entradas nunca terminan (funciones totales) y ha servido como punto de partida para la investigación de los problemas que se pueden resolver mediante un algoritmo. 2.2. Tratabilidad Complejidad Problemas Tratables e Intratables Los problemas denominados intratables son aquellos que, con entradas grandes, no pueden ser resueltos por ningún computador, no importa lo rápido que sea, cuanta memoria tenga o cuanto tiempo se le de para que lo resuelva. Lo anterior sucede debido a que los algoritmos que existen para solucionar estos problemas tienen una complejidad muy grande. La complejidad de un algoritmo mide el grado u orden de crecimiento que tiene el tiempo de ejecución del algoritmo dado el tamaño de la entrada que tenga. Existen dos maneras rápidas de hallar la complejidad de un algoritmo (métodos más profundos, formales y detallados pueden verse en [9]): 1. por conteo, ó 16 2.2 Problemas Tratables e Intratables 2. por inspección o tanteo. Para encontrar la complejidad de un algoritmo por conteo se debe tomar cada lı́nea de código y determinar cuántas veces se ejecuta. Luego se suman las cantidades encontradas y la complejidad será del orden del resultado dado. Esta complejidad es una aproximación de cuánto se demorarı́a todo el algoritmo en ejecutarse. Ejemplo 2.2.1 Un primer ejemplo sencillo es el algoritmo 1 para imprimir los 100 primeros números naturales. void imprime100() { int i = 1; while(i <= 100) { printf("%d",i); i++; } } Algoritmo 1: Imprime los números del 1 al 100 El conteo de lı́neas se puede realizar utilizando una tabla donde se numeren las lı́neas de código y se determine el número de veces que se ejecuta cada una: Número de lı́nea 1 2 3 4 5 6 7 8 9 Lı́nea de código void imprime100() { int i = 1; while(i <= 100) { printf("%d",i); i++; } } Número de ejecuciones 1 101 100 100 La lı́nea 3 se ejecuta una sola vez. La guarda del while (lı́nea 4) se ejecuta 101 veces debido a que verifica las 100 veces que se imprime el número más una vez adicional donde se determina que el ciclo ha terminado. Las lı́neas internas del ciclo se ejecutan 100 veces. La suma de las cantidades encontradas es3 : 3 La lı́nea 1 no se tiene en cuenta ya que corresponde a los datos de referencia de la función (tipo de 17 2 Noción de Problema Numero Total de Ejecuciones = 1 + 101 + 100 + 100 = 302 De allı́ que la complejidad del algoritmo es del orden de O(302). Por ser 302 una constante, la complejidad se puede aproximar a O(1), esto es, afirmar que es una complejidad constante. ? ? ? Ejemplo 2.2.2 El ejemplo anterior se puede generalizar modificando la función de manera que tenga como parámetro la cantidad de números naturales a imprimir, es decir, hasta qué número natural se quiere escribir. Este nueva función se puede ver en el algoritmo 2. void imprimeN(int n) { int i = 1; while(i <= n) { printf("%d",i); i++; } } Algoritmo 2: Imprime los números del 1 al n Se numeran las lı́neas y se procede a contabilizar. Número de lı́nea 1 2 3 4 5 6 7 8 9 Lı́nea de código void imprimeN(int n) { int i = 1; while(i <= n) { printf("%d",i); i++; } } Número de ejecuciones 1 n+1 n n Al igual que el ejemplo 2.2.1, la lı́nea 3 se ejecuta una sola vez, la guarda del while (lı́nea 4) se ejecuta n + 1 veces y las lı́neas internas del ciclo (lı́neas 6 y 7) se ejecutan n veces. retorno, nombre y parámetros formales). Las lı́neas 2, 5, 8 y 9 tampoco se tienen en cuenta ya que son simplmente delimitadores de bloque. 18 2.2 Problemas Tratables e Intratables Ahora la suma de las cantidades encontradas es: Numero Total de Ejecuciones = 1 + (n + 1) + n + n = 3n + 2 En el caso en que n fuera un número extremadamente grande, se puede ver que 3n + 2 ≈ n De esta manera la complejidad del algoritmo anterior es del orden de n, es decir, es O(n). ? ? ? Ejemplo 2.2.3 Otro ejemplo cuyo código es muy simple pero su análisis es de cuidado es el algoritmo 3. void imprime_mitad(int n) { int i = n; while(i >= 0) { printf("%d",i); i = i / 2; } } Algoritmo 3: Imprime los números del n al 1 dividiendo por dos cada vez En primera instancia se podrı́a decir que el programa deberı́a imprimir los números desde n hasta 0, pero dentro del ciclo el contador i se divide entre 2. Entonces en realidad se imprimirán los números n, n/2, n/4, n/8, . . .. De allı́ que el número de veces que se ejecutan las instrucciones dentro del ciclo van disminuyendo exponencialmente: En la iteración 1 se disminuye en 2. En la iteración 2 se disminuye en 4, es decir, 22 . En la iteración 3 se disminuye en 8, es decir, 23 . En la iteración 4 se disminuye en 16, es decir, 24 . .. . En la iteración k se disminuye en 2k . Como se necesita saber cuántas veces se repite el ciclo y el contador cambia su valor desde 0 a n, entonces se requiere llegar al punto donde n = 2k , siendo k el número que indica en qué iteración está la ejecución del algoritmo. 19 2 Noción de Problema Para hallar el valor de k, se utilizan las propiedades de los logaritmos: n = 2k log2 n = log2 2k log2 n = k Luego el número de iteraciones que se realizan es log2 n. Número de lı́nea 1 2 3 4 5 6 7 8 9 Lı́nea de código void imprime_mitad(int n): { int i = n; while(i >= 0) { cout << i; i = i / 2; } } Número de ejecuciones 1 log2 n + 1 log2 n log2 n La suma de las cantidades encontradas es: Numero Total de Ejecuciones = 1 + (log2 n + 1) + log2 n + log2 n = 2 + 3log2 n ≈ log2 n Por lo tanto, la complejidad del algoritmo es O(log2 n), que normalmente se expresa como O(log n). ? ? ? Ejemplo 2.2.4 Cuando analizamos algoritmos con condicionales hay que tener en cuenta que el conteo se hace considerando si las guardas de los condicionales se cumplen o no. La complejidad en estos algoritmos se halla en el peor de los casos (cuando se asume que las guardas de los condicionales siempre se cumplen), el caso promedio (cuando se asume que las guardas algunas veces se cumplen y otras veces no) y el mejor de los casos (cuando se asume que las guardas no se cumplen). 20 2.2 Problemas Tratables e Intratables El algoritmo 4 suma los elementos impares de un vector de enteros. int sumaVector(int *v, int n) { int i = 0; int sum = 0; while(i < n) { if(v[i] % 2 != 0) sum = sum + v[i]; i++; } return sum; } Algoritmo 4: Suma los elementos impares de un vector de enteros Se numeran las lı́neas y se procede a contabilizar. Número de lı́nea 1 2 3 4 5 6 7 8 9 10 11 12 Lı́nea de código int sumaVector(int *v, int n) { int i = 0; int sum = 0; while(i < n) { if(v[i] % 2 != 0) sum = sum + v[i]; i++; } return sum; } Número de ejecuciones 1 1 n+1 n ? n La cantidad de veces que se ejecuta la lı́nea 8 es indefinida debido a que depende de si la guarda del condicional es verdadera o falsa. Por esta razón tenemos que analizar esta situación desde los tres casos: En el mejor de los casos ningún elemento del vector es impar por lo que la lı́nea 8 no se ejecutarı́a nunca. En el caso promedio, aproximadamente la mitad de los elementos será impar y la otra mitad par. En este caso la lı́nea se ejecutarı́a n/2 veces. 21 2 Noción de Problema En el peor de los casos todos los elementos del vector son impares por lo que siempre que se ejecute la lı́nea 7 se ejecutará la lı́nea 8. Luego esta lı́nea se ejecutará n veces. La suma de las cantidades encontradas es entonces: En el mejor de los casos: Numero Total de Ejecuciones = 1 + 1 + (n + 1) + n + 0 + n = 3 + 3n En el caso promedio: Numero Total de Ejecuciones = 1 + 1 + (n + 1) + n + n/2 + n = 3 + 7(n/2) En el peor de los casos: Numero Total de Ejecuciones = 1 + 1 + (n + 1) + n + n + n = 3 + 4n Por lo tanto, la complejidad del algoritmo es, en este algoritmo particular, O(n). ? ? ? Ejemplo 2.2.5 Otro ejemplo, un poco más complejo, es un algoritmo de ordenamiento de un vector de enteros: void burbuja(int *v, int n) { int i = 0; while(i < n) { int j = i + 1; while(j < n) { if(v[i] > v[j]) { int temp = v[i]; v[i] = v[j]; v[j] = temp; } j++; } i++; } } Algoritmo 5: Ordenamiento por el método burbuja 22 2.2 Problemas Tratables e Intratables Se numeran las lı́neas y se procede a contabilizar. Número de lı́nea 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 Lı́nea de código void burbuja(int *v, int n) { int i = 0; while(i < n) { int j = i+1; while(j < n) { if(v[i] > v[j]) { int temp = v[i]; v[i] = v[j]; v[j] = temp; } j++; } i++; } } Número de ejecuciones 1 n+1 Pnn k=1 k P ( nk=1 k) − n P (Pnk=1 k) − n (Pnk=1 k) − n ( nk=1 k) − n P ( nk=1 k) − n n La lı́nea 3 se ejecutará una sola vez. Solamente asigna el valor 0 a la variable i. Si el tamaño del vector es n, entonces la guarda del ciclo externo (lı́nea 4) va ejecutarse n + 1 veces, ya que la variable i comienza el ciclo con el valor 0 y se incrementa en 1 cada iteración hasta que llegue al valor n, cuando se termina el ciclo. Sin embargo las lı́neas 6 y 17 se ejecutan n veces, es decir, uno menos que la lı́nea 4. Lo anterior debido a que se debe verificar la guarda del ciclo una vez adicional para saber que ya no debe entrar más al ciclo. La guarda del ciclo interno (lı́nea 7) se ejecutará un número de veces que depende de la variable i, que en el último ciclo tendrá el valor del tamaño del vector menos uno. Más en detalle, en la primera iteración del ciclo externo la variable i es igual a 0, por lo que la guarda del ciclo interno se ejecuta n veces; en la segunda iteración del ciclo externo la variable i es igual a 1, por lo que la lı́nea 7 se ejecuta n − 1 veces; ası́ sucesı́vamente hasta la última iteración del ciclo externo, donde la variable i es igual a n − 1 y la guarda del ciclo interno se ejecuta 1 vez. Todo esto da como resultado una sumatoria del número de ejecuciones de la lı́nea 7, desde 1 hasta n. Si consideramos el peor de los casos, al igual que pasó con la lı́nea 6, la guarda del if y las asignaciones internas (lı́neas 11–13) se ejecutan una vez menos que la guarda el ciclo 23 2 Noción de Problema interno, es decir, la sumatoria del número de ejecuciones desde 1 hasta n menos n (en cada iteración dichas lı́neas se ejecutan 1 vez menos y en total son n iteraciones). Por teorı́a matemática, se tiene que n X i=1 i= n × (n + 1) 2 De allı́ que es posible expresar el número de ejecuciones sólo en términos de n: Numero Total de Ejecuciones = (1) + (n + 1) + (n) + n×(n+1) + ... 2 − n + (n) . . . + 5 n×(n+1) 2 = 2 + n + 3n2 ≈ n2 Y ası́ se puede decir que la complejidad del algoritmo burbuja es del orden de n2 , es decir, es O(n2 ). ? ? ? Por otro lado, hallar la complejidad por inspección o tanteo, es más rápida pero imprecisa y, si no se cuenta con la suficiente experiencia, poco confiable. Simplemente se mira la estructura del algoritmo y se siguen las tres siguientes reglas: 1. La complejidad de una asignación es O(1). 2. La complejidad de un condicional es 1 más el máximo entre la complejidad del cuerpo del condicional cuando la guarda es positiva y el cuerpo del condicional cuando la guarda es negativa. 3. La complejidad de un ciclo es el número de veces que se ejecuta el ciclo multiplicado por la complejidad del cuerpo del ciclo. En el algoritmo 5 (burbuja) se puede observar que el ciclo externo tiene n iteraciones (siendo n el tamaño del vector), el ciclo interno, en la primera iteración del ciclo externo, tiene n iteraciones, y el condicional tiene como cuerpo tres asignaciones. Por todo esto, la complejidad del algoritmo serı́a O(1 + n × (2 + n × ((1 + 3) + 1))) = O(1 + 2n + 5n2 ) ≈ O(n2 ). Ahora, si las asignaciones internas y la condición del if (las lı́neas 5-8 juntas) tomaran un segundo en ejecutarse, en el peor de los casos (cuando siempre se ejecute lo que está dentro del if) se tendrı́a: 24 2.2 Problemas Tratables e Intratables Tamaño del vector 10 20 50 100 1000 Tiempo de ejecución (seg.) 100 400 2500 10000 1000000 El cuadro anterior muestra que un algoritmo con complejidad O(n2 ) puede ser rápido para tamaños de entrada pequeños, pero a medida que crece la entrada, se va volviendo ineficiente. En el cuadro 2.2 se muestran algunos ejemplos de complejidades que pueden encontrarse en los análisis de algoritmos, para un problema de tamaño n, desde la más rápida hasta la más ineficiente. Se dice que un problema es tratable si su complejidad es polinomial o menor. Complejidad O(1) O(log n) O(n) O(n log n) O(n2 ) O(n3 ) O(nc ), c > 3 O(2n ) O(3n ) O(cn ), c > 3 O(n!) O(nn ) Nombre Constante Logarı́tmica Lineal Cuadrática Cúbica Polinomial Exponencial Factorial Cuadro 2.2: Complejidades A partir de una complejidad O(2n ), los problemas para los cuales hay un algoritmo con dicha complejidad son intratables. Existen otros problemas llamados “NP-Completos”, cuya complejidad es desconocida. Se dice que son “NP” ya que se presume que los algoritmos que los solucionen son NoPolinomiales; sin embargo, no existe ninguna prueba que demuestre lo contrario, es decir, que sean “P” (Polinomiales)4 . Los cientı́ficos creen que los problemas NP-Completos son intratables, debido a que si existiera un algoritmo que resolviera alguno en un tiempo polinomial, entonces todos los problemas NP-Completos podrı́an ser resueltos en un tiempo 4 Aunque Vinay Deolalikar de los laboratorios de Hewlett Packart en el segundo semestre del 2010 compartió una versión preliminar de una prueba [10]. 25 NP-Completitud 2 Noción de Problema polinomial. Lo anterior llevó a los investigadores a plantear una de las más importantes preguntas en las ciencias de la computación [8]: ¿P = NP? Es decir, ¿las clases de complejidades Polinomiales y las No-Polinomiales son equivalentes? El instituto de Matemáticas Clay, en Cambridge, está ofreciendo 1 millón de dólares a quien de una demostración formal de que P = NP ó que P 6= NP. 2.3. Solución de Problemas Un problema en ciencias de la computación se puede definir como un vacı́o, que no ha sido llenado, entre un estado inicial y un estado objetivo. Es decir, se está en una situación y se quiere llegar a otra, pero no se conoce el camino a ella. El ejemplo 2.3.1 muestra un problema cotidiano. Ejemplo 2.3.1 Se tiene un estado inicial del juego Sudoku 5 , como en la figura 2.2. 6 8 1 4 3 5 5 6 2 1 8 4 7 6 7 6 3 9 1 2 6 5 8 4 5 2 7 4 9 7 Figura 2.2: Estado inicial del sudoku El sudoku tiene una sola regla: Llenar la grilla de tal manera que cada fila, columna y cuadro de 3 × 3 contenga los dı́gitos del 1 al 9. Aunque hay números en la grilla, no hay matemática envuelta. El problema se podrı́a resolver con razonamiento y lógica. El estado final del problema serı́a el mostrado en la figura 2.3. ? ? ? Entonces surge la pregunta ¿cómo solucionar un problema?. La primera aproximación es: “¡ como se pueda !”. Aunque esta respuesta es un poco rápida y brusca, es muy usada 5 http://www.sudoku.com/ 26 2.3 Solución de Problemas 9 6 3 1 7 4 2 5 8 1 7 8 3 2 5 6 4 9 2 5 4 6 8 9 7 3 1 8 2 1 4 3 7 5 9 6 4 9 6 8 5 2 3 1 7 7 3 5 9 6 1 8 2 4 5 8 9 7 1 3 4 6 2 3 1 7 2 4 6 9 8 5 6 4 2 5 9 8 1 7 3 Figura 2.3: Estado final del sudoku en estudiantes principiantes en la programación quienes apenas reciben el problema lo primero que hacen es prender el computador y empezar a programar. Lo malo de esta aproximación es que puede resultar en algoritmos muy ineficientes (con una complejidad de O(2n ) o mayor). G. Polya en [17] introdujo cuatro fases para solucionar un problema, y aunque fueron concebidos para resolver problemas matemáticos, no hay duda en la relación directa que hay entre las matemáticas y la computación y su interés común en la resolución de muchos problemas. Las cuatro fases son: 1. Entender el problema 2. Diseñar un plan 3. Ejecutar el plan 4. Recapitular Para entender el problema se deben hacer preguntas, pensar e investigar. Se debe preguntar por ejemplo quién propuso el problema, por qué, de donde salió, a donde se quiere llegar, cuál es el objetivo. Se debe pensar para tener una idea mejor del problema como un todo y empezar a divisar la solución. Por último se debe investigar para saber quien más ha trabajado en ese problema o quien está trabajado en algo parecido, hay que leer libros, revistas y artı́culos, pero en la solución hay que referenciar todo lo que se investigó. Ya entendido el problema se debe abordar el problema y diseñar un plan para solucionarlo. Si no es evidente un plan, se pueden seguir las siguientes estrategias: Dividir el problema en subproblemas y atacar cada subproblema. Estrategia llamada dividir y conquistar, original de los romanos. Si el problema es muy abstracto, tratar de examinar un ejemplo concreto (e.g. si no se sabe cuantas veces se ejecuta una lı́nea de código para una entrada n, se le puede dar un valor: n = 10). 27 2 Noción de Problema Tratar de resolver un problema más general. Estrategia llamada la paradoja del inventor : entre más ambicioso sea el plan más opciones de éxito. Relajar un poco el problema de manera que se pueda encontrar una solución fácilmente aunque no lleve a un plan correcto. El plan resultante será una heurı́stica, es decir, una aproximación que puede dar ideas o puede dar una solución muy cercana a la correcta. Una vez tenga un plan para resolver el problema, es necesario buscar alternativas, es decir, otros planes para llegar a la solución. Usualmente existen varias alternativas de solución, solo que es necesario analizar el problema de diferentes maneras (véase el ejemplo 2.3.2). Muchas veces la alternativa más ingenua puede ser la más rápida y consisa. Después de tener una baraja de planes se puede escoger el más acertado (la mejor solución). Ejemplo 2.3.2 Se desea encontrar un número e en una lista ordenada de números. ¿Cómo lograrlo? La aproximación más ingenua es recorrer toda la lista, desde el primer elemento hasta el final, buscando el número e dado (véase la figura 2.4). Si el tamaño de la lista es n, se puede ver que la implementación de este método tomarı́a un tiempo de O(n) en el peor de los casos (cuando el número dado sea el último elemento de la lista o no esté en ella). 3 6 1 2 ... 13 16 ... 45 57 n-1 n Figura 2.4: Búsqueda lineal Se puede optimizar la idea anterior haciendo dos recorridos, uno desde el principio y otro desde el final de la lista (véase la figura 2.5). De esta manera, el peor de los casos serı́a cuando el número estuviera en la mitad de la lista. La búsqueda entonces no mejorarı́a mucho, serı́a O(n/2), que en el caso en que n fuera extremadamente grande serı́a equivalente a O(n). 3 6 1 2 ... 13 16 ... 45 57 n-1 n Figura 2.5: Búsqueda lineal bidireccional 28 2.3 Solución de Problemas Una idea mucho más eficiente es la de hacer una búsqueda binaria. Se parte la lista en dos (por la mitad), quedando una sublista con los números menores o iguales a un k y otra sublista con los números mayores a k. Entonces se hace la pregunta ¿e < k? Si la respuesta es positiva se procede a partir la sublista con los menores a k; de lo contrario se hace lo mismo pero con la sublista con los mayores a k. De esta forma se tiene una lista con una logitud igual a la mitad de la lista original y un nuevo número k correspondiente al elemento de la mitad de la nueva lista. Se realiza el proceso anterior hasta que quede una lista con un solo elemento, el número e que estaba buscando. Un ejemplo de una búsqueda binaria puede verse gráficamente como en la figura 2.6. Se búsca el número 8. 1 1 1 3 3 5 3 6 5 8 5 6 6 9 10 14 16 20 30 45 9 6 6 8 10 14 16 20 30 45 8 8 9 9 8 Figura 2.6: Un ejemplo de búsqueda binaria Al partir la lista a la mitad cada vez, se reduce la búsqueda primero en 2, luego en 4, en 8, en 16, y ası́ sucesivamente. Es decir que la complejidad del algoritmo termina siendo O(log n). Un último método (de los muchos métodos posibles que hay y que no se verán aquı́) para hacer la búsqueda es convirtiendo la lista en una estructura de datos un poco más compleja donde se manejen ı́ndices para que, por medio de una llave, se llegue a un valor. Esta estructura de datos es llamada tabla hash y se verá más a fondo en el 29 2 Noción de Problema capı́tulo 4. Cada valor va a tener un ı́ndice asociado. Dicho ı́ndice es encontrado mediante una llave. Lo anterior indica que teniendo una función (denominada función hash) y aplicándola con la llave como argumento se devuelve el valor correspondiente, como se muestra en la figura 2.7. Llave 64 5 80 Índice Valor 0 30 1 5 . . . . . . 19 64 20 45 . . . . . . 56 80 57 16 Figura 2.7: Búsqueda con tabla hash Para hacer más fácil el entendimiento de este método, es bueno pensar en cómo se busca una empresa en las páginas amarillas del directorio telefónico. Teniendo la llave (que en este caso es el nombre de la empresa) se búsca en el ı́ndice la página correspondiente al negocio de la empresa (aseguradoras de riesgos profesionales, por ejemplo) y llendo a dicha página, allı́ se encuentra rápidamente los datos de la empresa. La complejidad de los algoritmos de búsqueda por medio de tablas hash es de O(1), es decir, es constante. Esto es debido a que la operación para hallar el dato solamente aplica la función hash, esta halla el ı́ndice, quien apunta directamente al dato requerido. ? ? ? Finalmente se debe transformar la solución potencial en un resultado mediante la ejecución del plan y se debe recapitular para saber, entre otras cosas, cómo se puede mejorar la solución descrita, si esta solución puede usarse en otro problema, ó para conocer las debilidades de la solución. 30 2.4 Estrategias de Implementación Dentro de la teorı́a de ingenierı́a de software, cada una de las cuatro fases tiene una etapa asociada en el ciclo de vida del software (véase [20]): 1. Análisis – Donde se levantan los requerimientos que debe satisfacer el sistema, se debe estudiar la viabilidad del proyecto, y formalizar un acuerdo con el cliente. 2. Diseño – Donde se divide el problema en subproblemas o funciones, se identifican las soluciones tecnológicas para cada una de las funciones, se asignan recursos ellas, y se ajustan las especificaciones. 3. Implementación – Donde se genera la solución, se integran las funciones y se valida la solución mediante unas pruebas. 4. Mantenimiento – Donde se asegura el uso del sistema y se realiza la conservación del software. 2.4. Estrategias de Implementación Una vez se hace el análisis y el diseño de los algoritmos, la implementación de los mismos puede ser abordado de dos maneras distintas: Top-Down Bottom-Up En el modelo top-down se hace un manejo completo del sistema sin entrar en detalles. Se comienza sistema como un todo y cada una de sus partes son “cajas negras” que deben ser abiertas poco a poco, a medida que se vaya internando en los detalles especı́ficos del proyecto. La gran ventaja de este modelo es que se divide el proyecto en subproyectos desde el inicio de la implementación. La desventaja es que no se pueden hacer pruebas a ninguna parte del proyecto casi que hasta el final. En contraste, el modelo bottom-up las partes más especiı́ficas del sistema son abordadas desde el comienzo, y ellas, al irse enlazando, van forman el sistema completo. Al contrario del modelo anterior, la ventaja de bottom-up es que puede hacerse un plan de pruebas desde el inicio de la implementación. La desventaja es que puede perderse de vista el objetivo final del proceso. El ejemplo 2.4.1 muestra el trabajo de realizar una aplicación para la enseñanza del teorema del binomio desde los dos modelos. Ejemplo 2.4.1 Se desea crear una calculadora básica, es decir, con las operaciones aritméticas fundamentales. A continuación se verá el proceso de implementación de la aplicación desde los dos modelos: 31 2 Noción de Problema Top-Down Se comienza mirando la aplicación como un todo. Si se piensa hacer una GUI (interfaz gráfica de usuario), ésta será el paso inicial a realizar. Una posible interfaz gráfica puede verse en la figura 2.8. X Calculadora Archivo Ayuda 12345.67890 C 7 8 9 / 4 5 6 * 1 2 3 - . + = 0 Figura 2.8: Ventana de una Calculadora Se debe pensar en la especificación del comportamiento esperado del programa: qué tipo de interfaz va a tener (gráfica o texto), cuáles serán las entradas al problema, cuáles menús habrán en la ventana, si se hace click en algún lado de la ventana qué va a pasar, cómo va a estar dividida la ventana (si se piensa en dividirla como en la figura), qué tipos de mensajes sacará la aplicación (para comunicarse con el usuario), cómo realizará las operaciones matemáticas, etc. La estructura de los módulos de la aplicación, a grosso modo, podrı́a ser como en la figura 2.9. Puede verse que el módulo principal de la aplicación contiene la función para crear la ventana. La ventana tiene las funciones de creación de una barra de tı́tulo, una barra de menús y un área de trabajo. Y continua profundizando hasta llegar a la función más básica: MostrarTexto. Bottom-Up Se comienza mirando cuales son las funciones más sencillas que ayudarán a construir funciones más complejas. Se debe pensar en las primitivas de bajo nivel y posibles operaciones a nivel de hardware que sean importantes y relevantes al problema: la función MostrarTexto es la operación más básica en este problema (dejando a un lado las operaciones gráficas básicas), las operaciones aritméticas, la creación de botones, la visualización de los mismos, los componentes de la ventana, las interrupciones de hardware y software, etc. 32 2.4 Estrategias de Implementación Principal CrearVentana CrearBarradeTitulo CrearBarradeMenu CrearEspaciodeTrabajo CrearMenuArchivo CrearMenuAyuda CrearMenuItemSalir MostrarAyuda CrearCampodeTexto CrearBotones CrearBoton1 Salir ... CrearBoton+ ... OperacionSuma CrearBoton= CalcularResultado MostrarTexto Figura 2.9: Creación de funciones en el modelo top-down La estructura de los módulos de la aplicación, sin mucho detalle, podrı́a ser como en la figura 2.10 MostrarTexto CrearCampodeTexto Mostrar1 ... Mostrar9 CrearBoton1 ... CrearBoton9 OperacionSuma CalcularResultado CrearBoton+ Salir OperacionDivisión ... ... ... CrearBoton+ CrearBoton= CrearMenuItemSalir MostrarAyuda CrearBotones CrearMenuArchivo CrearBarradeTitulo CrearMenuAyuda CrearBarradeMenu CrearEspaciodeTrabajo CrearVentana Principal Figura 2.10: Creación de funciones en el modelo bottom-up ? ? ? 33 3 Noción de Lenguaje “Un computador es un conjunto integrado de algoritmos y estructuras de datos capaz de almacenar y ejecutar programas.” En la definición anterior (tomada de [18]), la cual es un poco diferente a la definición de computador que todos conocen, se destacan tres conceptos que forman la famosa ecuación de Wirth: programas = algoritmos + estructuras de datos Un programa es la implementación de un algoritmo dados unos datos de entrada estructurados. Dicho algoritmo es desarrollado en un lenguaje de programación. Un lenguaje de programación , al igual que cualquier lenguaje, es un mecanismo de comunicación compuesto por un vocabulario y un conjunto de reglas gramaticales. Su propósito es ordenarle al computador que realice una tarea especı́fica. Los lenguajes de programación pueden hacer el desarrollo de programas más fácil o más difı́cil, dependiendo del nivel de abstracción que requiera y la cantidad de conocimiento del trabajo interno del computador que sea necesario para escribir los programas. Entre más familiar sea el lenguaje que se use para resolver los problemas, su nivel será más alto. En este capı́tulo se presentará una descripción de la teorı́a de los lenguajes que permiten la especificación de la solución de los problemas en términos relativamente más cercanos a los usados por las personas, y en particular el lenguaje C++, el cual será usado en el resto del documento. 3.1. Historia La historia de los lenguajes de programación se ha dividido en cuatro generaciones1 : Primera Generación Lenguajes de máquina, que se reduce a secuencias de números binarios. Segunda Generación Lenguajes ensambladores, que tienen instrucciones de bajo nivel, bastante básicos pero menos abstracto que el lenguaje de máquina. (en la figura 3.5 se muestra un ejemplo de código en lenguaje ensamblador). 1 Algunos autores hablan de una quinta generación de lenguajes, los cuales son usados para resolver problemas mediante la especificación de programas con restricciones o fórmulas lógicas en vez de algoritmos. Ejemplos de estos lenguajes son: MoZArt, Prolog y Mercury. 35 Programa Lenguaje de Programación 3 Noción de Lenguaje Tercera Generación Lenguajes de alto nivel, con sintaxis más cercana al lenguaje natural. Algunos de estos lenguajes pueden verse en la figura 3.1. Cuarta Generación Lenguajes diseñados para desarrollar aplicaciones requeridas en ambientes empresariales y de negocios. SQL, Oracle Reports, Mathematica, MATLAB, son algunos de estos lenguajes. En los años 50’s, cuando la tecnologı́a permitió el desarrollo de computadores más familiares, J. Backus creó el lenguaje de programación FORTRAN (FORmula TRANslation). Este lenguaje es considerado el primer lenguaje de alto nivel y aún es usado por matemáticos y cientı́ficos. La evolución de los lenguajes de programación se ha debido a cinco influencias principales [18]: 1. El hardware y los sistemas operativos – El hardware es cada vez más fácil de usar (PC’s y Tablets) y los sistemas operativos más gráficos y amigables (basados en ventanas). 2. Las aplicaciones – Desde las militares, cientı́ficas, de negocios e industriales, hasta los juegos, personales y de todo tipo de actividades humanas. 3. Las metodologı́as – Para desarrollar programas más complejos y con nuevos diseños. 4. Los estudios teóricos – Nuevos métodos formales matemáticos que soporten las caracterı́sticas de los lenguajes. 5. Las estandarizaciones – La posibilidad de implementar los lenguajes en cualquier sistema y permitir transportar los programas de un computador a otro. En la figura 3.12 se puede apreciar la evolución de los más conocidos lenguajes de programación de alto nivel, desde FORTRAN hasta C#. B. Kinnersley ha hecho un listado de más de 25003 lenguajes de programación, que han sido desarrollados a lo largo de la historia. Muchos de estos lenguajes ya no se usan mientras que otros como FORTRAN y LISP siguen siendo utilizados constantemente. Terrece Pratt en [18] describe las caracterı́sticas que enmarcan los lenguajes en usados y no usados: Claridad – La sintaxis del lenguaje debe ser fácil para leer, escribir, probar, entender y modificar los programas que se escriban en él. Aplicación – El lenguaje debe proveer estructuras de datos, operaciones y estructuras de control para resolver uno o varios tipos especı́ficos de problemas. 2 Una versión más detallada de la evolución de los lenguajes ha sido creada por Éric Lévénez y puede verse en http://www.levenez.com/lang/history.html. 3 El listado puede consultarse en http://people.ku.edu/~nkinners/LangList/Extras/langlist.htm. 36 3.1 Historia FORTRAN LISP 1954 ALGOL 1958 COBOL 1959 SNOBOL 1962 SIMULA BASIC 1964 LOGO 1968 FORTH SMALLTALK SH 1969 PASCAL PROLOG 1971 C 1973 ML SCHEME MS BASIC 1975 MODULA ICON 1977 1978 AWK ADA 1979 POSTSCRIPT MIRANDA 1982 C++ 1983 COMMON LISP 1984 1986 EIFFEL HASKELL 1970 CAML 1987 PERL TURBO PASCAL 1988 TCL/TK CLOS OZ VISUAL BASIC 1991 PYTHON 1993 RUBY DELPHI 1989 JAVA JAVASCRIPT 1995 PHP 1996 OCAML MOZART C# 1999 2000 Figura 3.1: Evolución de los lenguajes de alto nivel 37 3 Noción de Lenguaje Soporte – Se debe ayudar al programador a solucionar problemas mediante el lenguaje con API’s (interfaces para programar aplicaciones) y grupos de desarrollo. Verificación – La posibilidad de verificar la correctitud de un programa mediante varias técnicas. Ambiente – Un ambiente de programación (con editor y paquetes de depuración) puede acelerar la creación de programas. Costo – De uso, ejecución, traducción, creación, pruebas y mantenimiento de los programas. Portabilidad – El transporte de los programas, del computador donde fue creado a otros sistemas. 3.2. Estructura Los lenguajes de programación de alto nivel tienen seis caracterı́sticas: 1. Datos - Tipos de datos 2. Operaciones primitivas 3. Secuencias de control 4. Datos de control 5. Almacenamiento 6. Interacción con el ambiente Al igual que cualquier lenguaje (como el español), los lenguajes de programación tienen una sintaxis y una semántica. La sintaxis es la forma en que los programas son escritos mientras que la semántica es el significado dado a las construcciones sintácticas. Cada lenguaje de programación tiene una sintaxis y una semántica particular. Por ejemplo, mientras en Pascal la defición de una variable de tipo lista de reales es var V: array [1..10] of real; en C es float V[10]; Sintaxis La sintaxis de un lenguaje, está definida entonces como la escongencia y organización de varios elementos sintácticos básicos. Dichos elementos pueden ser caracteres, identificadores, operadores, palabras reservadas, comentarios, espacios en blanco y delimitadores. 38 3.2 Estructura Los elementos forman expresiones, declaraciones y, en general, la estructura de un programa. Formalmente, la organización de esos elementos construyen la gramática del lenguaje, que consta de un conjunto de definiciones (llamadas reglas o producciones) que especifican el orden particular en que deben estar ubicados los elementos para que un programa esté bien escrito. La forma más usada para especificar la gramática de un lenguaje es la BNF (BackusNaur Form), desarrollada por J. Backus en 1960. La BNF define un lenguaje de una manera directa. Por ejemplo para describir una variable o identificador en C++, se lista la estructura4 : identificador ::= (letra | "_") | identificador (letra | "_") | identificador digito letra ::= mayusculas | minusculas minusculas ::= "a"..."z" mayusculas ::= "A"..."Z" digito ::= "0"..."9" Un identificador en C++ es la composición de una letra o una raya (el sı́mbolo ” ”, también llamado underscore), con cero o varias letras, digitos o rayas. Una letra puede ser mayúscula o minúscula. Las minúsculas, mayúsculas y los dı́gitos están allı́. Si se fuera a definir una asignación simple (solo con enteros y expresiones aritméticas básicas), se tendrı́a: asignacion ::= identificador "=" exp_aritmetica ";" exp_aritmetica ::= | | | | entero ::= digitos_sin_cero digito* | "0" entero | identificador exp_aritmetica "+" exp_aritmetica exp_aritmetica "-" exp_aritmetica exp_aritmetica "*" exp_aritmetica exp_aritmetica "/" exp_aritmetica digitos_sin_cero ::= "1"..."9" 4 La descripción completa de la BNF de C++ puede encontrarse en http://www.nongnu.org/hcb/ 39 3 Noción de Lenguaje Al igual que en cualquier lenguaje, para asegurar que una expresión está bien escrita, lo que se debe hacer es seguir la BNF de forma estricta. El ejemplo 3.2.1 muestra una asignación y su análisis correspondiente. Ejemplo 3.2.1 ¿La expresión W = Y * 10 + V; es sintácticamente correcta? La figura 3.2 muestra el análisis dada la gramática anterior. asignacion exp_aritmetica exp_aritmetica exp_aritmetica exp_aritmetica identificador W entero identificador = Y digito_sin_cero * exp_aritmetica digito 10 identificador + V ; Figura 3.2: Análisis para la asignación Por lo tanto, sı́ es correcta la expresión. ? ? ? Semántica Por otro lado, la semántica del lenguaje hace otro tipo de verificación. Ella chequea principalmente: 1. Los tipos – Se verifica que los operadores sean aplicados a los operandos correctos; por ejemplo, la división entre una lista y un entero no tiene sentido. 40 3.3 Compiladores 2. El control de flujo – Se asegura que los comandos que causan un rompimiento en el flujo de control (como el comando break) transfieran el flujo a otro lugar. 3. La unicidad – No deben haber variables y etiquetas diferentes con el mismo identificador. 3.3. Compiladores Los lenguajes de programación se implementan mediante traductores ó compiladores que convierten el código fuente en un código objetivo (que puede ser un código intermedio o código de máquina). En la figura 3.3 (figura tomada de [1]) se puede ver la forma superficial de un compilador. código fuente compilador código objetivo mensajes de error Figura 3.3: Componentes superficiales de un compilador Para hacer la traducción del programa, varias etapas deben ser superadas, las cuales pueden verse como los componentes intermedios del compilador. Dichas etapas son el preprocesamiento, el compilador como tal, el ensamblador y los enlazadores y cargadores. Como el código fuente puede estar dividido en varios archivos diferentes o módulos, y pueden haber macros y extensiones necesarias para el programa, todos estos componentes deben ser recolectados para tener un único programa fuente. Esta etapa es denominada preprocesamiento. El código ensamblador es una versión menos abstracta del código de máquina, que es el entendido por el procesador del computador. Este código trabaja directamente con direcciones de memoria de la RAM, los registros del procesador, la pila del programa y las interrupciones del sistema operativo. Cada procesador tiene un conjunto de instrucciones que constituyen el lenguaje ensamblador. En [4] puede verse las instrucciones para los procesadores Intel desde el 8086 hasta el 80486 (las generaciones antes de los Pentium). Los enlazadores y cargadores realizan las funciones de cargar y enlazar código intermedio y librerı́as ya creadas (e.g. archivos *.dll en windows, o *.so y *.a en linux) al programa que se está traduciendo. 41 Compilador 3 Noción de Lenguaje El sistema de procesamiento de compilación (los componentes intermedios del compilador) puede verse en la figura 3.4 (figura adaptada de [1]). módulos de programa fuente preprocesador programa fuente compilador programa ensamblador ensamblador código objeto enlazador cargador librerías código objeto código de máquina Figura 3.4: Componentes intermedios de un compilador Una vez pasado el ensamblador, el código objeto generado puede usarse como librerı́a para otros programas. Esto es posible debido a que los componentes intermedios del compilador son separables, es decir, el proceso de compilación puede hacerse paso a paso, tomando cada uno de los componentes como aplicaciones separadas y trabajandolos manualmente. Conceptualmente, el proceso de transformar el programa fuente en un programa ensamblador se puede descomponer en seis fases: análisis léxico, análisis sintáctico, análisis semántico, generación de código intermedio, optimización y generador de código. 42 3.3 Compiladores W = Y * 10 + V; Analizador Léxico id1 = id2 * 10 + id3 Analizador Sintáctico = id1 + * id2 id3 10 Analizador Semántico = id1 + * id2 id3 10 Generador de Código Intermedio temp1 = 10 temp2 = id2 * temp1 temp3 = id3 + temp2 id1 = temp3 Optimizador temp = id2 * 10 id1 = id3 + temp Generador de Código MOV AL, id2 MOV BL, 10 MUL BL MOV CL, id3 ADD AL, CL MOV id1, AL Figura 3.5: Ejemplo de las fases de compilación 43 3 Noción de Lenguaje En el análisis léxico se hace un escaneo de los caracteres que tiene el programa en búsca de sı́mbolos que no hacen parte del alfabeto del lenguaje. Una vez que un grupo de caracteres que conforman un elemento sintáctico básico es escaneado y aprobado, dicho elemento es pasado como un token al analizador sintáctico. Los espacios en blanco que separan los tokens son eliminados en esta fase. El analizador sintáctico se encarga de crear un árbol con cada uno de los tokens para comparar las expresiones con la BNF del lenguaje. Un ejemplo de árbol es el de la figura 3.2. Como se vio anteriormente la fase de análisis semántico chequea el programa fuente para encontrar errores de tipos de datos, de control de flujo y de unicidad. Una vez las fases de análisis son superadas, algunos compiladores generan un código intermedio. Este código sirve para que el compilador decida el orden de operación de las lı́neas de código y para generar nombres temporales que mantengan su valor calculado por cada instrucción. La fase de optimización intenta mejorar el código intermedio para obtener mejores resultados en rendimiento. La última fase es la generación del código objetivo, que usualmente consiste en código ensamblador. La figura 3.5 (adaptada de [1]) muestra un ejemplo de una compilación de la expresión del ejemplo 3.2.1. Comienza el analizador léxico transformando la expresión inicial en una expresión basada en tokens, luego el analizador sintáctico construye el árbol de sintaxis, en seguida el analizador semántico hace la verificación (en este caso no hay errores semánticos por lo que queda igual), entonces el generador de código intermedio pone los nombres temporales y ordena las operaciones, acto seguido el optimizador reduce el número de operaciones, y por último el generador de código retorna el código ensamblador correspondiente. 3.4. Máquina Virtual Máquinas Virtuales El código objetivo de un compilador es ejecutado en interpretadores los cuales pueden ser hardware o máquinas reales, ó software en cuyo caso son llamadas máquinas virtuales. Las máquinas virtuales son ambientes de ejecución que emulan o actúan como interfaz de un computador o programa. Ellas proveen las instrucciones para comunicarse directamente con el computador o programa que estén emulando. Los interpretadores tienen una máquina virtual del computador local donde ejecutan los programas sin necesidad de crear archivos ejecutables. De esta manera, la máquina virtual se encarga de tomar el código intermedio y hacer la traducción al código de máquina del computador fı́sico y devolver el resultado. Ası́ las máquinas virtuales podrı́an verse como wrappers o funciones envolventes que encapsulan las instrucciones reales de los computadores o programas que emulan. 44 3.4 Máquinas Virtuales Algunos lenguajes de programación interpretados son Java, Forth, Fortran, Perl, Lisp, Scheme, Smalltalk y Python. Por otro lado, lenguajes de programación como Java poseen una máquina abstracta pero al nivel del sistema operativo5 . Esto da pie al concepto de portabilidad. Una de las grandes ventajas de Java es que el código fuente que se escribe en él puede ser compilado y ejecutado en cualquier sistema operativo, ya sea Linux, Windows, MacOS, Solaris, o algunos otros. La desventaja es que el hecho de que ya sean dos capas que tienen que emularse (sistema operativo y hardware) tiene repercusiones en el rendimiento de los programas. De allı́ que muchos programadores evitan programar en Java, ya que los programas les corren más lento que en lenguajes compilados como C. Datos de Entrada Datos de Salida Programa Lenguaje de Programación Sistemas Operativos Computador Físico (Hardware) Figura 3.6: Jerarquı́a de las máquinas virtuales La figura 3.6 (adaptada de [18]) muestra la jerarquı́a de máquinas virtuales. Además de las máquinas virtuales al nivel del hardware y el sistema operativo, los lenguaje de progra5 Existen programas que emulan todo el sistema operativo. En Linux, por ejemplo, programas como wine (http://www.winehq.org/) y crossover (http://www.codeweavers.com/) pueden correr programas hechos en Windows, mientras que vmware (http://www.vmware.com/) puede lanzar todo el sistema operativo Windows encima de Linux. 45 3 Noción de Lenguaje mación y los programas que se escriben pueden verse también como máquinas virtuales. El lenguaje de programación funciona como interfaz entre lo que el programador quiere hacer y el sistema operativo, mientras que el programa funciona como interfaz entre el usuario y el lenguaje de programación. 3.5. Depuración Depuración Depuración es el proceso de encontrar y reducir el número de bugs, es decir, errores, faltas, fallas o equivocaciones, en un programa. El término bug tiene su origen en la marina de Estados Unidos en 1945, cuando se encontró una polilla que causaba un corto circuito en las pruebas que se le hacı́an al panel de un computador electromecánico. Los operadores incluyeron el insecto en su bitácora de pruebas que puede verse en la figura 3.7. Figura 3.7: Primer bug encontrado 46 3.5 Depuración B. Beizer en [3] categoriza los bugs en: 1. Suaves – Solo ofenden estéticamente. Mala indentación, mala ortografı́a, entre otros. 2. Moderados – Salidas redundantes que generan un impacto leve en el rendimiento del sistema. 3. Molestos – El comportamiento del sistema, por culpa del bug, es desagradable. 4. Perturbantes – Se rechazan operaciones normales. 5. Serios – Se pierde el rastro de las operaciones. 6. Muy serios – El bug causa que el sistema haga operaciones erradas. 7. Extremos – Los problemas anteriores ocurren frecuentemente y arbitrariamente, y no solo en casos aislados. 8. Intolerables – La base de datos empieza a tener datos corruptos e irreparables. Se considera seriamente bajar el sistema. 9. Catastróficos – El sistema falla completamente. 10. Infecciosos – La falla del sistema tiene repercusiones en otros sistemas. El proceso de depuración puede dividirse en cinco etapas: Reconocer que el bug existe: Si el error causa que el programa termine de forma abrupta, entonces es obvia la existencia del bug. Sin embargo, a medida que el error sea menos serio, la dificultad de detectarlo es mucho mayor, llegando al punto de pasar desapercibido. Por ejemplo, en 1994, T. Nicely de la Universidad de Lynchburg descubrió un error en los procesadores Intel. Él se dió cuenta que algunas divisiones siempre devolvı́an un valor errado. Inicialmente Intel negó el error, pero otras personas confirmaron el problema rápidamente y más tarde Intel tuvo que sustituir todos los procesadores defectuosos (algunos modelos del Pentium con una frecuencia 47 3 Noción de Lenguaje de menos de 100 MHz). Para comprobar el error se puede ejecutar el algoritmo 6. #include <stdio.h> int main(void) { float x = 8391667.0; float y = 1572863.0; if(x - (x / y) * y != 0) printf("Procesador con el error de division del Pentium."); else printf("Procesador sin el error de division del Pentium."); return 0; } Algoritmo 6: Descubre si el procesador tiene error Se deben identificar entonces, los sı́ntomas del bug, observar el problema y bajo qué condiciones es detectado. Aislar la fuente del bug : Se debe identificar qué parte del código genera el bug. Esto puede resultar muy difı́cil de hacer debido a que, por ejemplo, creyendo que una lı́nea de código tiene el problema, puede que dicha lı́nea genera el problema como resultado de errores en una función que está en otro módulo del programa. Los programadores menos experimentados deben seguir la ejecución del programa paso a paso, lı́nea a lı́nea, reconociendo en el flujo del programa una discontinuidad, comportamiento errado, o discrepancia. Los programadores hábiles pueden reconocer a priori en qué área del código puede estar el problema (basado en previas situaciones similares). Para lograr aislar el bug se debe mirar el código como algo nuevo. Un error común que cometen los programadores es pasar por alto secuencias, asignaciones, etc. ya que conocen muy bien el código y asumen la correctitud de ciertas partes. Además, el uso de instrucciones como print, assert y el cambiar pequeños detalles del programa pueden ayudar bastante (hay que tener en cuenta que se debe cambiar una cosa a la vez y volver atrás los cambios que no tengan efecto). Identificar la causa del bug : Si ya se sabe dónde está el bug, se debe investigar la causa del mismo. El buen conocimiento del programa, tanto es su funcionamiento como en su estructura interna es muy importante para descubrir la causa del bug. Un programador que no esté familiarizado con el código puede gastar muchas horas inútilmente mientras el creador del programa podrı́a decidir rápidamente que la causa es externa al código. Existen herramientas para ayudar a descubrir las causas de un bug. Una de ellas es DDD6 , 6 http://www.gnu.org/software/ddd/ 48 3.5 Depuración un software que actúa como front-end para depuradores de diferentes lenguajes como C, Perl, Python, y Java. La idea de este tipo de debuggers es usar breakpoints o puntos clave del programa donde se necesite hacer un seguimiento detallado de variables, funciones o estructuras (este seguimiento se hace mediante watchers). La figura 3.8 (figura tomada de http://www.gnu.org/software/ddd/) muestra la ventana de DDD con un programa para manejar listas hecho en C. Figura 3.8: Data Display Debugger Idealmente se debe prevenir cualquier posibilidad de bugs, es decir, diagnosticar y deter- 49 3 Noción de Lenguaje minar los errores pre-mortem. Para ello es bueno trabajar con bitácoras (archivos log), de manera que se tenga un rastro de los cambios realizados. También es recomendable usar un sistema de control de versiones (CVS7 o Subversion8 , por ejemplo) para que ası́ sea posible regresar a versiones anteriores estables cuando se requiera. Adicionalmente, para la mayorı́a de los compiladores actuales existen editores que ayudan a la prevención de errores sintácticos y semánticos, mediante el coloreo de palabras reservadas, variables, funciones y demas estructuras, el chequeo automático e incluso la opción de depuración. Un editor muy usado para Java y C/C++ es Eclipse9 (que puede verse en la figura 3.9). Figura 3.9: Editor Eclipse Determinar una corrección para el bug : La tarea de encontrar cómo corregir un bug no es sencilla por varias razones: 7 http://www.nongnu.org/cvs/ http://subversion.tigris.org/ 9 http://www.eclipse.org/ 8 50 3.5 Depuración Se puede alterar significativamente el sistema, tanto en su funcionamiento como en su rendimiento. Se pueden destapar errores mucho más profundos o complicados. Se pueden crear nuevos errores. Los errores lógicos son los más sencillos de corregir debido a que son equivocaciones en la implementación. Los errores que son resultado de un mal diseño del software pueden acarrear no una corrección sino una reimplementación parcial o completa del programa. En programas que son hechos de forma modular o con carga dinámica, es posible crear los denominados patches. Gracias a ellos no es necesario recompilar todo el programa sino simplemente un módulo o librerı́a. De esta manera, lo que se hace es reemplazar los archivos necesarios sin alterar todo el sistema. Aplicar la corrección y hacer pruebas: Cuando se aplica la corrección al problema es necesario crear un plan de pruebas riguroso y llevarlo a cabo para asegurarse que dicha corrección ha tratado el bug de forma correcta. Se pueden usar tres diferentes aproximaciones para demostrar que un programa está libre de bugs [3]: Prueba Funcional - Se debe pensar en todas las entradas posibles y blindar el programa para que soporte las entradas y produzca la salida correcta para cada una de ellas (un resultado o un mensaje de texto). Desafortunadamente, incluso teóricamente, es imposible conocer todas las entradas posibles ya que son infinitas, de allı́ que no es posible realizar una prueba funcional completa, por lo que se debe minimizar el número de entradas que se dejen por fuera de la prueba. La prueba funcional es también llamada prueba de caja negra, ya que el ingeniero de pruebas solo tiene acceso al software mediante las mismas interfaces que el usuario normal. Prueba Estructural - Se miran los detalles de implementación, es decir, el estilo de programación, los métodos de control, el código fuente, el diseño de la base de datos, y la estructura general. La prueba estructural es denominada prueba de caja blanca. El desarrollador tiene acceso al código fuente y puede modificar el código para realizar dichas pruebas. Prueba de Correctitud - Los requerimientos del programa son declarados en un lenguaje formal (matemático) de manera que puedan hacerse demostraciones inductivas para producir los resultados de todas las posibles entradas. Cada función en el programa tiene una precondición y un postcondición, expresadas en términos lógicos. Adicionalmente, en los ciclos se tiene una invariante (un conjunto de caracterı́sticas que nunca cambia mientras se desarrolla el ciclo). Lo que se hace 51 3 Noción de Lenguaje es que se toma la precondición y, mediante una serie de pasos, la entrada dada y las invariantes que puedan existir, se debe llegar a la postcondición (para más detalles se puede ver [7]). Las caracterı́sticas de las pruebas y la madurez del programa llevan a dos fases o versiones en el ciclo de vida del software antes de ser liberado: alpha y beta. Mientras los desarrolladores están creando el programa y hacen pruebas solo ellos, el programa está en su versión alpha. Las pruebas que se realizan aquı́ normalmente son de caja blanca, aunque por inspección también se hacen pruebas de caja negra. En esta etapa, el software es peligroso para usuarios finales. Una vez se tenga cierta estabilidad en el programa, el desarrollo entra en la fase beta. Se llaman a los ingenieros de prueba a que realicen pruebas de caja negra. Adicionalmente, un grupo de personas es escogido (usuarios finales pero con un nivel un poco más alto) para que usen el programa y reporten bugs. Algunas personas han considerado una tercera fase denominada gamma. En esta fase el software tiene la madurez para ser liberado pero puede contener errores (aún está en pruebas). De hecho muchas aplicaciones son lanzadas en etapa gamma ya sea porque se cree que está libre de bugs o porque los mismos programadores han escogido a sus compradores y clientes para que prueben el software mientras lo usan. Algunas de las grandes empresas de software han usado esta última estrategia para sus productos. 3.6. Excepción Excepciones Los manejadores de excepciones son construcciones diseñadas para tratar la ocurrencia de situaciones que cambian el flujo normal de la ejecución de un programa. Las excepciones son la analogı́a en software de lo que son las interrupciones10 en hardware. Ellas se dividen en sı́ncronas y ası́ncronas. Las excepciones sı́ncronas son aquellas que son planeadas, mientras las ası́ncronas son aquellas inesperadas. Las excepciones más comunes en programación son la división por cero, los nombres no definidos, la incompatibilidad de tipos y la ausencia de archivos, directorios o páginas web. El algoritmo 7 en C++ muestra un pequeño programa que divide el número 10 entre un número dado por el usuario. 10 Las interrupciones son señales que emiten algunos dispositivos y que causan que el procesador haga una pausa en la ejecución, salve el estado y comience una nueva ejecución. 52 3.6 Excepciones #include <iostream> using namespace std; int main(){ int numero,resultado; cout << "Entre el divisor: "; cin >> numero; resultado = 10/numero; cout << resultado << endl; } return 0; Algoritmo 7: Divide el número 10 entre un número dado por el usuario en C++ Si el usuario ingresa el cero, entonces la división se vuelve imposible y una excepción surgirá. La primera forma posible para evitar este inconveniente es usar un condicional. El algoritmo 8 muestra como quedarı́a. #include <iostream> using namespace std; int main(){ int numero,resultado; cout << "Entre el divisor: "; cin >> numero; if(numero != 0) { resultado = 10/numero; cout << resultado << endl; } else { cerr << "Division por cero" << endl; return 1; } } return 0; Algoritmo 8: Divide el número 10 entre un número dado por el usuario, usando un condicional en C++ También podemos hacer uso de las aserciones. El algoritmo 9 muestra su uso. La macro assert no retorna nada pero tiene un argumento de tipo entero que representa la prueba que se realizará. Si la prueba falla entonces un mensaje de error surge y el programa 53 3 Noción de Lenguaje termina, de lo contrario el flujo de ejecución continúa normalmente. #include <iostream> #include <cassert> using namespace std; int main(){ int numero,resultado; cout << "Entre el divisor; "; cin >> numero; assert(numero != 0); resultado = 10/numero; cout << resultado << endl; } return 0; Algoritmo 9: Divide el número 10 entre un número dado por el usuario, usando una aserción en C++ #include <iostream> using namespace std; int main(){ int numero,resultado; cout << "Entre el divisor: "; cin >> numero; try { if(numero == 0) throw 1; resultado = 10/numero; cout << resultado << endl; } catch(int) { cerr << "Division por cero" << endl; } } return 0; Algoritmo 10: Divide el número 10 entre un número dado por el usuario, usando un manejador de excepciones en C++ Sin embargo contemplar todos los posibles errores que puedan suceder (no solo de este tipo) es una tarea dispendiosa que atrasa la programación y desvı́a al programador de su objetivo. Además, los métodos anteriores no permiten una gestión apropiada de los errores generados. Ası́ que el código también podrı́a ser modificado con mecanismos de manejo de 54 3.6 Excepciones excepciones, como en el algoritmo 10. El bloque try ... catch ... define un espacio de prueba y una alternativa de salida a una falla. Si el código que está en el bloque try no lanza una excepción (por medio del comando throw) entonces el programa continua saltándose el catch; En caso contrario, se desecha lo que se hizo dentro del try, se ejecuta el bloque del catch y continúa el programa. La pregunta natural que surge entonces es ¿cuándo se usan las excepciones? Aunque los manejadores de excepciones pueden ser usados para tratar con errores normales e incluso para depuración, muchos programadores están deacuerdo en que a menos que no se tenga una muy buena razón para atrapar una excepción, no se haga. Esto es debido a dos razones, primero se supone que las excepciones son “excepcionales”, lo que implica que no se debe llenar el código de excepciones, y segundo, cuando se ejecuta el código, cada vez que se implementa un manejador de excepciones, el procesador guarda el estado del programa en ese momento para poder continuar la ejecución de cualquier manera. #include <iostream> using namespace std; int main(){ int numero,resultado; cout << "Entre el divisor: "; cin >> numero; try { if(numero == 0) throw 1; if(numero == 134514992) // No es un numero throw string("letra"); resultado = 10/numero; cout << resultado << endl; } catch(int) { cerr << "Division por cero" << endl; } catch(string) { cerr << "No entro un numero" << endl; } } return 0; Algoritmo 11: Divide el número 10 entre un número dado por el usuario, usando dos manejadores de excepciones en C++ Otras razones para tener en cuenta cuando se usen las excepciones son: Muchas veces los errores que atrapan los manejadores de excepciones pueden ser corregidos cuando se está escribiendo el programa. Por ejemplo, si un archivo va a ser modificado pero éste es de solo lectura, una excepción ocurrirá en el programa. 55 3 Noción de Lenguaje Este error es fácilmente corregible simplemente cambiando los permisos del archivo. Se debe proveer la mayor información posible cuando una excepción ocurre. Por ejemplo, cuando se intenta acceder a una página web y falla, se puede dar detalles acerca de por qué falló: DNS inválido, time out, usuario no autorizado. Es bueno implementar manejadores de excepciones especı́ficos. De esta manera el compilador está optimizado para tratar con la excepción dada: de valor, de ejecución, de tipo, de nombre, de entrada/salida, etc. Si en el ejemplo de la división por cero se entra una cadena o un letra (representando un identificador) en vez de un número habrán otros tipos de excepciones que pueden ser manejados separadamente como en el algoritmo 11. Hay que asegurarse de que el código siga ejecutandose aún si ocurren errores. En el código de la división entre cero puede asegurarse la continuación de dos maneras: creando un ciclo que pida el número de nuevo cada vez que se entre un cero, ó sustituyendo el cero por un número por defecto. Los algoritmos 12 y 13 muestran las dos alternativas. // Alternativa 1: #include <iostream> using namespace std; void division() { int numero,resultado; } cout << "Entre el divisor: "; cin >> numero; try { if(numero == 0) throw 1; resultado = 10/numero; cout << resultado << endl; } catch(int) { cerr << "Division por cero" << endl; division(); } int main() { division(); return 0; } Algoritmo 12: Divide el número 10 entre un número dado por el usuario, garantizando la continuación, alternativa 1 en C++ 56 3.7 Interfaces Gráficas de Usuario (Eventos) // Alternativa 2: #include <iostream> using namespace std; int main() { int numero,resultado; cout << "Entre el divisor: "; cin >> numero; try { if(numero == 0) throw 1; } catch(int) { cout << "Division por cero. Se reemplazo el 0 por 1." << endl; numero = 1; } resultado = 10/numero; cout << resultado << endl; } return 0; Algoritmo 13: Divide el número 10 entre un número dado por el usuario, garantizando la continuación, alternativa 2 en C++ 3.7. Interfaces Gráficas de Usuario (Eventos) Para trabajar con un computador es necesario tener control y poder realizar operaciones sobre los estados del sistema computacional. Lo anterior es logrado mediante una interfaz , es decir, un espacio donde ocurre la interacción entre el sistema y el usuario. Desde los años 50’s, cuando se diseñaron los primeros teclados para computador, hasta finales de los años 80’s el método de interacción más usado eran los comandos (antes de los 50’s se usaban las tarjetas perforadas). Ellos son ejecutados escribiendo en el shell 11 el nombre del comando y pulsando la tecla enter. Los ejemplos de código que se han visto hasta ahora han sido creados con interfaces por lı́nea de comandos (CLI). Cada vez que se ejecutan estos programas se pide al usuario que se entre un dato, éste debe escribirlo y pulsar enter. El flujo del programa en la programación por comandos es único y solo es cambiado de curso en algunos puntos. Desde los años 80’s los sistemas operativos gráficos (como Windows y MacOS) y los entornos como el X Window System hicieron que muchos programadores cambiaran su pa11 Un shell puede verse como la interfaz entre un programa y el usuario. Este programa puede ser un sistema operativo, un interpretador o una aplicación. Ejemplos de shells incluyen los Unix (sh, csh, bash, zsh, tcsh, etc.) el de DOS (command.com), el de Python y el de Tcl/Tk (wish). 57 Interfaz 3 Noción de Lenguaje radigma para crear interfaces gráficas de usuario (GUI). Una GUI representa la información y acciones que están disponibles al usuario. Los componentes de una GUI son comúnmente agrupados en el llamado WIMP (siglas en inglés de Ventana, Icono, Menú, Dispositivo apuntador). Existen muchas librerı́as conocidas para crear GUIs. Algunas de ellas están diseñadas para un sistema operativo especı́fico, por ejemplo el Windows API se usa en Windows, Cocoa se usa en MacOS y Motif se usa en Unix. Otras librerı́as para varias plataformas incluyen: GTK+ Página oficial: http://www.gtk.org/ Algunas aplicaciones hechas con Gtk+: Google Chrome, Gimp, Abiword, Gnumeric. wxWidgets (antes llamado wxWindows) Página oficial: http://wxwidgets.org/ Algunas aplicaciones hechas con wxWidgets: BitTorrent, Audacity. Qt Página Oficial: http://www.qtsoftware.com/products Algunas aplicaciones hechas con Qt: Adobe Photoshop, Google Earth, KDE, Mathematica, VLC. Swing: Página oficial: http://java.sun.com/javase/6/docs/technotes/guides/swing/ Algunas aplicaciones hechas con Swing: Limewire, Netbeans, Morpheus, JM Studio. Un ejemplo del uso de estas librerı́as puede verse en el algoritmo 14, el cual crea el famoso “Hola Mundo” con Gtk+ en el lenguaje C. Algunas CLIs proveen funcionalidades que en las GUIs son muy difı́ciles de expresar. Por ejemplo, en los shells de DOS y Unix, los resultados de la ejecución de un comando pueden ser usados por otro comando (utilizando el caracter pipe ’|’). Lo que hacen los programadores que solo utilizan CLIs es usar una librerı́a para consola (llamada ncurses en Linux y PDCurses en Windows), con la cual pueden embellecer la interfaz texto mediante el uso de caracteres especiales. El algoritmo 15 muestra el “Hola Mundo” con curses. 58 3.7 Interfaces Gráficas de Usuario (Eventos) #include <gtk/gtk.h> int main(int argc, char *argv[]) { GtkWidget *window; GtkWidget *label; gtk_init(&argc, &argv); window = gtk_window_new (GTK_WINDOW_TOPLEVEL); gtk_window_set_title (GTK_WINDOW (window), " "); g_signal_connect (G_OBJECT (window), "delete-event", gtk_main_quit, NULL); label = gtk_label_new ("Hola Mundo!"); gtk_container_add (GTK_CONTAINER (window), label); gtk_widget_show_all (window); gtk_main(); } return 0; Algoritmo 14: Imprime ’HolaMundo!’ en una ventana usando Gtk+ #include <ncurses.h> int main() { initscr(); printw("Hola Mundo!"); refresh(); getch(); endwin(); } return 0; Algoritmo 15: Imprime ’HolaMundo!’ por lı́nea de comandos usando curses Como los programas con GUI no pueden esperar a que el usuario pulse enter para ejecutar operaciones, ya que las operaciones pueden llamarse por medio de otros dispositivos de entrada como el mouse, la interacción se realiza por medio de eventos . Los eventos son hechos que suceden y son generados por interrupciones tanto de software como de hardware. Ejemplos de eventos son: tarjetas de red requiriendo un servicio, la presión de un botón del mouse, el cron del sistema cuando llega a algún momento especı́fico, un drag-anddrop en el sistema operativo, el pulso de un botón del teclado. El funcionamiento de un programa creado por eventos está basado en un proceso llamado el event-loop, quien 59 Evento 3 Noción de Lenguaje es el encargado de estar pendiente de los eventos que sucedan y llamar al manejador de eventos respectivo (despacha el evento). En el código de arriba el event-loop se llama con la función gtk-main(). En la actualidad los dispositivos móviles como PDAs, tablets y celulares están cambiando las GUI, debido a que las interfaces WIMP no son óptimas para trabajar con programas interactivos que tengan un continuo flujo de señales de entrada o con programas interactivos 3D. Las nuevas GUIs son llamadas post-WIMP. Ejemplos de dispositivos que usan post-WIMPs son los iPods, iPads, las nuevos cajeros automáticos y los celulares que corren sistemas como Android. 3.8. Referencia Referencias y Apuntadores Implı́citamente cuando se hace una asignación a una variable, en realidad lo que se realiza por debajo es tomar un identificador y denotar en él la dirección de una ubicación mutable en la memoria. Dicha dirección es llamada una referencia , y es el contenido de dicha referencia el que es modificado por la asignación de la variable [12]. a b c b = = = = 5 a 7 3 Algoritmo 16: Asigna cuatro variables en Python (análisis de referencias) Suponga que se tiene el algoritmo 16. La primera lı́nea crea una referencia a una ubicación donde estará contenido el número 5. La dirección de dicha ubicación la tiene la variable a. En la segunda lı́nea, una nueva referencia a la ubicación que contiene el 5 es creada con la variable b. En la tercera lı́nea se crea otra referencia con la asignación de c. Por último, al hacer la asignación de b se cambia la referencia que tenı́a dicha variable a otra ubicación cuyo contenido será 3. En el ejemplo 3.9.1 se ve gráficamente los cambios en las referencias de otro código en Python. Al lado izquierdo de cada figura puede verse la evolución del programa mientras que en el lado derecho se muestra las referencias que se van creando o modificando y sus respectivas ubicaciones con los valores a los cuales hacen referencia. Ejemplo 3.9.1 Primero se crea la variable s y se le asigna el valor "MURCIELAGO". >>> s = "MURCIELAGO" >>> Luego se crea la variable t y se le asigna el valor "LAGO". 60 s "MURCIELAGO" 3.8 Referencias y Apuntadores >>> s = "MURCIELAGO" >>> t = "LAGO" >>> s "MURCIELAGO" t "LAGO" Después se crea la variable i y se le asigna el resultado de aplicar la función find a la variable s con t como parámetro. Inmediatamente después se imprime el valor de i. Esta última instrucción (print i) no altera las referencias ni los valores de las ubicaciones. >>> >>> >>> >>> 6 >>> s = "MURCIELAGO" t = "LAGO" i = s.find(t) print i s "MURCIELAGO" t "LAGO" i 6 Acto seguido se asigna t a s. Esto significa que la referencia que tenı́a t a la segunda ubicación de nuestra memoria se pierde. t ahora hace referencia a la primera ubicación, al igual que s. >>> >>> >>> >>> 6 >>> >>> s = "MURCIELAGO" t = "LAGO" i = s.find(t) print i s "MURCIELAGO" t "LAGO" t = s i 6 En el momento en que se pierde la referencia a la segunda ubicación, el garbage collector (proceso que elimina la información que no se volverá a usar) libera ese espacio para un posterior uso. >>> >>> >>> >>> 6 >>> >>> s = "MURCIELAGO" t = "LAGO" i = s.find(t) print i s t = s i "MURCIELAGO" t 6 Si en este momento se quiere conocer el valor asociado a una variable que no ha sido asignada previamente, sale un error. Sin embargo, la memoria del ejemplo no cambia, no se hace una reserva anterior, es decir, no se tiene en cuenta que dicha variable puede ser declarada posteriormente. 61 Garbage Collector 3 Noción de Lenguaje >>> s = "MURCIELAGO" >>> t = "LAGO" >>> i = s.find(t) >>> print i 6 >>> t = s >>> a Traceback (most recent call last): File "<pyshell#16>", line 1 , in -toplevela NameError: name 'a' is not defined >>> s "MURCIELAGO" t i 6 Ahora se asigna la variable s al dato "CIELO". Esto hace que se cree una nueva referencia a la segunda ubicación de la memoria del ejemplo. No obstante, la referencia de t no se pierde, y al hacer un print a dicha variable, el resultado será, efectivamente, "MURCIELAGO". >>> s = "MURCIELAGO" >>> t = "LAGO" >>> i = s.find(t) >>> print i 6 >>> t = s >>> a Traceback (most recent call last): File "<pyshell#16>", line 1 , in -toplevela NameError: name 'a' is not defined >>> s = "CIELO" >>> print t "MURCIELAGO" >>> s "MURCIELAGO" t "CIELO" i 6 En el momento en que se borre la variable t, la referencia y el dato en la memoria también son eliminados. >>> s = "MURCIELAGO" >>> t = "LAGO" >>> i = s.find(t) >>> print i 6 >>> t = s >>> a Traceback (most recent call last): File "<pyshell#16>", line 1 , in -toplevela NameError: name 'a' is not defined >>> s = "CIELO" >>> print t "MURCIELAGO" >>> del t >>> 62 s "CIELO" i 6 3.8 Referencias y Apuntadores Por último, en el momento en que se intente acceder a la variable t de nuevo, un error surgirá, tal y como pasó con la variable a. >>> s = "MURCIELAGO" >>> t = "LAGO" >>> i = s.find(t) >>> print i 6 >>> t = s >>> a Traceback (most recent File "<pyshell#16>", a NameError: name 'a' is >>> s = "CIELO" >>> print t "MURCIELAGO" >>> del t >>> print t Traceback (most recent File "<pyshell#16>", t NameError: name 't' is >>> s "CIELO" i 6 call last): line 1 , in -toplevelnot defined call last): line 1 , in -toplevelnot defined ? ? ? El manejo de variables con listas y otras estructuras de datos (que se verán en el próximo capı́tulo) es un poco más complicado. Lo anterior debido a que las referencias llevan toda la información sobre lo que se referencian y el conjunto de operaciones que son posibles con ella. Esto hace que si se igualan dos variables que hacen referencia a la misma estructura de datos, el cambio al dato de una de ellas afecta al dato de la otra. En el ejemplo 3.9.2 se muestra un manejo de listas en Python y los cambios que van sucediendo. Ejemplo 3.9.2 Primero se crea la variable L1 y se le asigna el valor [2,4]. >>> L1 = [2, 4] >>> L1 [2, 4] Luego se agrega un nuevo elemento a la lista L1. 63 3 Noción de Lenguaje >>> L1 = [2, 4] >>> L1.append(5) >>> L1 [2, 4, 5] Después se crea una nueva variable L2 y se le asigna la misma referencia de L1. >>> L1 = [2, 4] >>> L1.append(5) >>> L2 = L1 >>> L1 [2, 4, 5] L2 Si se agrega un elemento nuevo a la lista L2, se ve afectada también la lista L1, ya que ambas variables son referencias a la misma lista. La impresión de ambas variables será idéntica. >>> >>> >>> >>> >>> [2, >>> [2, >>> L1 = [2, 4] L1.append(5) L2 = L1 L2.append(7) print L1 4, 5, 7] print L2 4, 5, 7] L1 [2, 4, 5, 7] L2 La eliminación de un elemento en una de las variables afecta ambas. >>> >>> >>> >>> >>> [2, >>> [2, >>> >>> L1 = [2, 4] L1.append(5) L2 = L1 L2.append(7) print L1 4, 5, 7] print L2 4, 5, 7] del L2[1] L1 [2, 5, 7] L2 Sin embargo, es posible hacer copias de la lista para que las dos variables L1 y L2 no se afecten mutuamente cuando se realice un cambio a alguna de ellas. 64 3.8 Referencias y Apuntadores >>> L1 = [2, 4] >>> L1.append(5) >>> L2 = L1 >>> L2.append(7) >>> print L1 [2, 4, 5, 7] >>> print L2 [2, 4, 5, 7] >>> del L2[1] >>> L2 = L1[:] >>> L1 == L2 True >>> L1 [2, 5, 7] L2 [2, 5, 7] Se cambia la lista L1, no se afecta la otra lista (no son la misma lista). >>> L1 = [2, 4] >>> L1.append(5) >>> L2 = L1 >>> L2.append(7) >>> print L1 [2, 4, 5, 7] >>> print L2 [2, 4, 5, 7] >>> del L2[1] >>> L2 = L1[:] >>> L1 == L2 True >>> L1.reverse() >>> L1 == L2 False >>> L1 [7, 5, 2] L2 [2, 5, 7] ? ? ? Los apuntadores , al igual que las referencias, son variables que contienen la dirección de memoria de un dato. Sin embargo, ellos se diferencian de las referencias en que son más flexibles y más generales. Adicionalmente, los apuntadores no permiten un proceso automático de garbage collection. El manejo de apuntadores y referencias está ligado al lenguaje de programación. Por ejemplo, Python y Java manejan referencias, mientras que C y C++ manejan apuntadores. El algoritmo 17 muestra un ejemplo de cómo se manejan apuntadores en C++. Si se compilara y ejecutara el programa anterior se mostrarı́a por pantalla los valores de las variables algunNumero y ptrAlgunNumero. El primero es 12345 mientras que el segundo es un número hexadecimal12 que es, en efecto, la dirección de memoria donde se encuentra el valor de algunNumero. 12 No se puede decir exactamente el valor porque éste depende de muchos factores entre los cuales se encuentra momento en que se compile el programa, el estado de la memoria y el compilador que se use. 65 Apuntadores 3 Noción de Lenguaje #include <iostream> using namespace std; int main(){ int algunNumero = 12345; int *ptrAlgunNumero = &algunNumero; cout << "algunNumero = " << algunNumero << endl; cout << "ptrAlgunNumero = " << ptrAlgunNumero << endl; } return 0; Algoritmo 17: Declara, asigna e imprime un entero y un apuntador a entero en C++ Muchos programadores afirman que el manejo de referencias es mucho más seguro que el manejo de apuntadores (en cuanto a errores y uso malicioso). Sobre esto hay que hacer notar que la seguridad depende mucho de la implementación de los tipos de datos y del compilador del lenguaje de programación. 3.9. Declaraciones y Tipos Los lenguajes de programación de alto nivel permiten crear y usar identificadores para los nombres de funciones y variables, es decir, permiten introducir las funciones y variables como nombres para algún valor. En el caso especial de las variables, ellas tienen cuatro caracterı́sticas: Tipo de dato Tipos de datos: Las variables que pueden declararse en un algoritmo son asignadas a valores. Dichos valores están dentro de un dominio de valores y los dominios son llamados los tipos de datos . Por ejemplo, cuando una variable x es de tipo entero, esto quiere decir que el dominio de valores que puede asignarsele a x es el de los números enteros. Además, las funciones a las cuales puede aplicarse la variable x deben tener una semántica sensible a los enteros (e.g. función abs(), que devuelve el valor absoluto de un número, tiene significado al aplicarse con la variable x, sin embargo la función strlen(), que devuelve el tamaño de una cadena no puede tener como argumento a x). Los lenguajes que no restringen los dominios de las variables son denominados lenguajes no tipados. En estos lenguajes las variables no tienen tipo, lo que quiere decir que se les puede asignar cualquier valor. Los lenguajes tipados pueden ser explı́citos, cuando los tipos son parte de la sintaxis del lenguaje, ó implı́citos. Python, por ejemplo, es un lenguaje de programación tipado implı́cito. El sistema de tipos de un lenguaje determina, en gran parte, el comportamiento de un 66 3.9 Declaraciones y Tipos programa, es decir, la presencia o ausencia de errores. Existen dos clases de errores de tipos: Atrapados. Los que causan que la ejecución pare inmediatamente, por ejemplo la división por cero. No atrapados. Los que permiten que siga la ejecución pero puede llevar a comportamientos inesperados, por ejemplo un salto a una dirección de memoria desconocida. Un programa es seguro si no presenta errores no atrapados [6]. Un lenguaje es seguro si no permite crear programas con un mal comportamiento, es decir, solo permite crear programas seguros. La seguridad tiene un costo en tiempo debido a que tiene que hacer varios chequeos y análisis que pueden ser complejos. El ejemplo 3.8.1 muestra la diferencia entre lenguajes seguros e inseguros. Ejemplo 3.8.1 El algoritmo 18 muestra un programa hecho en Python. x = 5 y = "37" z = x + y Algoritmo 18: Asigna tres variables en Python (análisis de tipos) El algoritmo 18 sacará un error en Python ya que en realidad se está tratando de sumar un entero con una cadena de caracteres. Tal vez fue una equivocación del programador y por eso lenguajes como Visual Basic tienen definidas funciones para hacer un casting de datos, es decir, un cambio de tipos para asegurar que el flujo de ejecución continue. Ahora, el algoritmo 19 muestra un programa hecho en C++. #include <iostream> using namespace std; int main(void){ int x = 5; char y[] = "37"; char *z = x + y; } return 1; Algoritmo 19: Asigna tres variables en C++ (análisis de tipos) El algoritmo 19 no suma las variables x y y. Por el contrario asigna a z la dirección de memoria 5 ubicaciones después de la dirección de memoria de y. Como no se sabe que 67 3 Noción de Lenguaje datos hay en esa dirección, el uso de la variable z es peligroso y puede llevar un terminación brusca del programa. ? ? ? Los lenguajes de programación que no permiten la ejecución de una operación con tipos errados son llamados lenguajes fuertemente tipados. En el ejemplo 3.8.1 se mostró que Python es un lenguaje fuertemente tipado mientras que Visual Basic no lo es. El chequeo de tipos puede ser estático o dinámico. En el chequeo estático, el análisis es realizado en tiempo de compilación mientras que en el chequeo dinámico, se realiza en tiempo de ejecución. En el chequeo dinámico las variables pueden tener un tipo dependiendo de la dirección del flujo de ejecución En Python es posible un programa como el del algoritmo 20, donde primero se asigna a la variable m un valor de tipo entero y luego se le asigna un valor de tipo lista de enteros. m = 5 m = [1, 2, 3] Algoritmo 20: Cambio de tipo de una variable en Python Sin embargo, lenguajes como C/C++ no permitirı́an un programa ası́ ya que en tiempo de compilación se tendrı́a que declarar la variable m como entero, asignarle el valor 5 y en la siguiente lı́nea se le asignarı́a un valor diferente de tipo lista. El cuadro 3.1 muestra algunos de los lenguajes de programación que se vieron en la figura 3.1 y su caracterización: si son de chequeo estático o dinámico, fuerte o débilmente tipados, y seguros o inseguros. Alcance Alcance: El alcance de una variable es la región dentro de la cual las referencias a la variable se asocian con el identificador de la variable. La región excluye cualquier otra región interior que contengan declaraciones que usan el mismo identificador. El concepto de alcance tiene sentido cuando se piensa en la cantidad reducida de identificadores que se usan para las variables (e.g. x es el nombre de variable por excelencia, mientras que i es el identificador tı́pico para los ı́ndices de una lista y para los contadores). La manera de manejar varias ocurrencias de variables se determina de dos maneras: Alcance Estático – La variable siempre se refiere a su contorno más cercano. Los contornos son cuadros que delimitan las regiones. La figura 3.10 muestra un programa en Python donde la variable x que se encuentra en el contorno más interior se refiere a la x que se pasa como argumento a la función f3, mientras que la x de la última lı́nea se refiere a la x que se pasa como argumento a la función f1. La figura 3.11 muestra un programa en Python un poco más complejo. Se definen dos contornos al interior del contorno exterior que son independientes entre ellos. Puede 68 3.9 Declaraciones y Tipos Lenguaje ADA BASIC C C++ C# FORTRAN HASKELL JAVA JAVASCRIPT LISP ML PASCAL PERL PHP PYTHON RUBY SCHEME SMALLTALK VISUAL BASIC Estático/Dinámico Estático Estático Estático Estático Estático Estático Estático Estático Dinámico Dinámico Estático Estático Dinámico Dinámico Dinámico Dinámico Dinámico Dinámico Hı́brido Fuerte/Débil Fuerte Débil Débil Fuerte Fuerte Fuerte Fuerte Fuerte Débil Fuerte Fuerte Fuerte Débil Débil Fuerte Fuerte Débil Fuerte Hı́brido Seguro/Inseguro Seguro Seguro Inseguro Inseguro Ambos13 Seguro Seguro Seguro Seguro Seguro Seguro Seguro Seguro Seguro Seguro Seguro Seguro Seguro Seguro Cuadro 3.1: Lenguajes de programación y tipos de datos def f1(x): def f2(y): def f3(x): print x+y return x Figura 3.10: Diagrama de contorno verse también que los nombres de las funciones, por ser identificadores, pueden pasarse como parámetros de otras funciones. Alcance Dinámico – Se tiene una pila14 para cada identificador en la que se introducen las variables con un nombre especı́fico cada vez que se encuentra una. De esta manera 14 El concepto de pila se puede ver en el siguiente capı́tulo. 69 3 Noción de Lenguaje def f5(z): def f6(a, b, c): def f7(a): return a+c a(f7, b) def f8(f, x): f(z, x) Figura 3.11: Diagrama de contorno cuando el flujo de ejecución sale de los alcances se van eliminando los elementos de la pila (la variable que se evalúa siempre es la que está en el tope de la pila). Para crear un alcance dinámico usualmente se definen bloques que permiten delimitar claramente un alcance. Cada lenguaje de programación tiene su forma de definir bloques: en Python los bloques se identifican por la indentación que tengan las expresiones, en C se tienen las llaves { } para los bloques, mientras que en Pascal se tienen las palabras reservadas begin y end. procedure Principal(); var x : integer; begin x := 1; while x <= 10 do begin writeln(x); x := x + 1; end; end; Algoritmo 21: Definición de dos bloques de ejecución en Pascal El algoritmo 21, escrito en Pascal, define dos bloques: el bloque de la definición de la función y el bloque de la estructura while. Ligadura Ligadura: Ligadura se refiere a la asociación de valores con identificadores. Mientras que una asignación cambia el valor asociado a un identificador, la ligadura crea dicha asociación. 70 3.9 Declaraciones y Tipos Cuando un identificador está ligado a un valor se dice que es una referencia a ese valor. Dicha referencia puede ser considerada como la dirección de memoria donde se encuentra alojado el valor asociado al identificador, es decir, es un dato que contiene la llave para llegar a otro dato. Al crear una variable, se reserva un espacio en la memoria donde se alojará el valor asociado al identificador de la variable. El tamaño del espacio depende del tipo de dato de la variable. Cuando una ligadura es compartida por varias funciones, un cambio que realice una función puede ser vista por todas las demás. En el algoritmo 22 se muestran dos funciones par e impar, escritas en Python, que comparten la variable x. Puede verse que la comunicación entre las dos funciones no se realiza pasando datos explı́citamente sino cambiando el estado de la variable que comparten. def par(impar): if x == 0: return True else: return impar(x-1) def impar(x): if x == 0: return False else: return par(impar, x-1) Algoritmo 22: Definición de dos funciones que comparten una variable en Python El tiempo durante el cual se caracteriza la ligadura de una variable es llamado el tiempo de ligadura. Existen cuatro diferentes tiempos de ligadura [18]: 1. Tiempo de ejecución 2. Tiempo de compilación 3. Tiempo de implementación del lenguaje 4. Tiempo de definición del lenguaje Cuando se hace una simple asignación en Python como en el algoritmo 23, se pueden identificar los diferentes tiempos de ligadura para las caracterı́sticas de la variable. El conjunto de posibles tipos para la variable x (e.g. entero, flotante, binario, caracter) se fijan en el tiempo de definición del lenguaje. El tipo de la variable x se fija en el tiempo de compilación o ejecución dependiendo del lenguaje (para Python es en ejecución). El conjunto de posibles valores para x en el tiempo de implementación del lenguaje. El valor de x se define en el tiempo de ejecución. La representación de la constante 10 se hace en el 71 3 Noción de Lenguaje tiempo de definición del lenguaje. Por último, la propiedades para el operador + se escogen en el tiempo de definición del lenguaje. x = x + 10 Algoritmo 23: Asignación simple en Python Visibilidad Visibilidad: Una variable es visible en un subprograma si el identificador asociado a ella es parte del ambiente de ese subprograma. Si el identificador existe pero no es parte del ambiente del subprograma que se encuentra actualmente en ejecución, se dice entonces que la variable asociada está escondida de ese subprograma. Durante su “tiempo de vida” una variable puede tener más de un nombre o identificador; esto es, existen muchas asociaciones en diferentes ambientes, cada una con un diferente nombre para la variable. El algoritmo 24 es un programa que muestra un caso en el que en una misma función (fun1) se tienen dos identificadores que hacen referencia a la misma variable (j por ser parámetro e i por ser variable global). i = 0 def fun1(j): print j def fun2(): fun1(i) fun2() Algoritmo 24: Dos identificadores con la misma referencia en una función en Python El paso de una variable como parámetro a una función se puede realizar de dos formas: por valor o por referencia. En el paso de parámetros por valor se crea un nueva referencia para cada parámetro formal de la función, es decir, las asignaciones dentro de la función son locales y no afectan la variable fuera del alcance de la función. Por el contrario, el paso de parámetros por referencia implica que la variable con la que se hace la llamada a la función pueda cambiar su contenido. Lo anterior significa que en los llamados por referencia se envı́a la dirección de memoria de la variable mientras que en los llamados por valor, como su nombre lo indica, se envı́a el contenido de la dirección de memoria. En el programa anterior, la variable i dentro de la función fun2 la variable i es pasada por valor a fun1. 72 4 Noción de Tipo Abstracto de Datos 4.1. Tipos Abstractos de Datos Un tipo abstracto de datos (TAD), es la conjunción de variables, operaciones y aserciones (además de documentación) que modela un dominio de datos. Un TAD se diferencia de un tipo de dato en que es especificado de forma precisa y diseñado independiente de cualquier implementación (en algunos casos los TADs no pueden ser implementados en un hardware o software especı́fico). Los lenguajes de programación traen de forma nativa un conjunto de tipos que son útiles pero desafortunadamente insuficientes para resolver todo tipo problemas. Por ejemplo, si se quiere tomar los datos de todos los empleados de una empresa y realizar consultas y reportes, resulta muy ineficiente (y dispendioso) crear una variable para cada dato de cada empleado (e.g. nombre, cédula, código, cargo, etc.) La definición de un TAD debe ser independiente de un lenguaje, no obstante ella es muy descriptiva y se ajusta bastante a las necesidades del diseñador. Por ello no es raro encontrar diferentes definiciones de listas, colas, árboles, etc. Todas las definiciones deben tener tres componentes comunes: la estructura del TAD (la representación), una colección de operadores y un conjunto de axiomas (para el TAD y cada una de las operaciones). Un tipo abstracto de datos se especifica formalmente de la siguiente manera1 : TAD hnombrei hObjeto Abstractoi {inv : hInvariante del T ADi} Operaciones Primitivas: • hOperacion 1i : • ... • hOperacion ni : hentradasi → hsalidai hentradasi → hsalidai El TAD debe llamarse con un nombre único que lo identifique plenamente; debe expresarse el objeto que se está modelando de una manera matemática o gráfica (entre más formal mejor), pero que muestre claramente el objeto y que pueda ser usado para referenciarlo en las notaciones y formalismos de las operaciones; debe establecerse una serie de condiciones que no varı́an nunca al interior del TAD; y deben listarse las operaciones que pueden 1 Esta es la misma aproximación de J. Villalobos en [23]. 73 4 Noción de Tipo Abstracto de Datos realizarse con los objetos del tipo del TAD. Las operaciones se especifican con las entradas a ellas y la salida que retornará el proceso (se escribe el tipo de dato de cada entrada y de la salida, tal como el contrato que se da como paso inicial de la receta para diseñar programas en [11]). Adicionalmente para cada una de las operaciones se debe escribir su comportamiento a manera de aserciones: una para mostrar qué se debe cumplir antes de ejecutar la operación (precondición) y otra para decir cómo queda el mundo después de terminar el proceso (postcondición). hprototipo de la operacioni ”hbreve descripcion de la operacioni” {pre : . . .} {post : . . .} La precondición y la postcondición deben ser lo más formal posible por dos razones: 1. El formalismo describe el propósito de la operación sin lugar a ambigüedades y con mucha exactitud. 2. La formalidad acerca el diseño a la implementación, es decir, entre más formal sea el diseño del TAD más fácil será concretizarlo en algún lenguaje de programación. Los siguientes ejemplos muestran diferentes TADs y sus especificaciones. Ejemplo 4.1.1 Suponga que una compañı́a tiene la información de Nombre, Foto, Documento de identidad, Cargo, y Sueldo por cada empleado. Si se quisiera almacenar estos datos serı́a desastroso usar una variable por cada dato o empleado (imagine una empresa con 1500 empleados). Una forma eficiente es crear un tipo de dato Empleado para guardar la información: TAD Empleado Gráficamente: Foto Nombre Cédula Cargo Sueldo Textualmente: Empleado = {N ombre : hnombrei, Cedula : hcedulai, Cargo : hcargoi, Sueldo : hsueldoi, F oto : hf otoi} 74 4.1 Tipos Abstractos de Datos {inv : Empleado.Sueldo ≥ 535600} Operaciones Primitivas: • • • • • • • • • CrearEmpleado: AgregarNombre: AgregarCedula: CambiarSalario: CambiarCargo: CambiarFoto: InfoSalario: InfoCargo: TieneFoto: → Empleado → Empleado → Empleado → Empleado → Empleado → Empleado → Entero → T exto → Booleano Empleado × T exto Empleado × T exto Empleado × Entero Empleado × T exto Empleado × Imagen Empleado Empleado Empleado CrearEmpleado() “Crea un nuevo empleado con los datos vacı́os” {pre : TRUE} {post : emp = {N ombre : ””, Cedula : ””, Cargo : ””, Sueldo : 535600, F oto = 2}} AgregarNombre(emp, n) “Asigna un nombre a un empleado sin nombre” {pre : emp = {N ombre : ””, . . .}, {post : emp.N ombre = n} n ∈ T exto} AgregarCedula(emp, c) “Asigna una cédula a un empleado sin cédula” {pre : emp = {. . . , Cedula : ””, . . .}, {post : Emp.Cedula = c} c ∈ T exto} CambiarSalario(emp, s) “Cambia el salario de un empleado” {pre : emp = {. . . , Salario : hsalarioi, . . .}, 535600} {post : emp.Salario = s} s ∈ Entero, s ≥ CambiarCargo(emp, c) “Cambia el cargo de un empleado” {pre : emp = {. . . , Cargo : hcargoi, . . .}, {post : emp.Cargo = c} c ∈ T exto} 75 4 Noción de Tipo Abstracto de Datos CambiarFoto(emp, f ) “Cambia la foto de un empleado” {pre : emp = {. . . , F oto : hf otoi}, {post : emp.F oto = f } f ∈ Imagen} InfoSalario(emp) “Retorna el salario de un empleado” {pre : emp = {. . . , Salario : hsalarioi, . . .}} {post : hsalarioi} InfoCargo(emp) “Retorna el cargo de un empleado” {pre : emp = {. . . , Cargo : hcargoi, . . .}} {post : hcargoi} TieneFoto(emp) “Informa si un empleado tiene foto” {pre : emp = {. . . , F oto : hf otoi} ó emp = {. . . , F oto : 2}} {post : True si emp.F oto = hf otoi ∨ False si emp.F oto = 2} ? ? ? Ejemplo 4.1.2 Se quiere diseñar un tipo abstracto de datos para modelar un conjunto de valores binarios y sus operaciones principales. TAD Binario b1 b2 . . . bn {inv : bi ∈ {1, 0}, 76 n ≥ 1} 4.1 Tipos Abstractos de Datos Operaciones Primitivas: • • • • • • • • • • CrearBin: CorrimientoDer: CorrimientoIzq: Not: And: Or: SumarBin: Complementoa2: bin2Dec: esBin: Entero Binario Binario Binario Binario × Binario Binario × Binario Binario × Binario Binario Binario Binario → Binario → Binario → Binario → Binario → Binario → Binario → Binario → Binario → Entero → Booleano CrearBin(e) “Construye un número binario a partir de un número entero” {pre : e ∈ Entero+ } {post : b1 b2 . . . bn−1 bn | (b1 × 2n ) + (b2 × 2n−1 ) + . . . + (bn−1 × 21 ) + (bn × 20 ) = e} CorrimientoDer(bin) “Realiza un corrimiento a la derecha (un solo bit) de un número binario” {pre : bin = b1 b2 . . . bn−1 bn } {post : bin = b1 b2 . . . bn−1 } CorrimientoIzq(bin) “Realiza un corrimiento a la izquierda (un solo bit) de un número binario” {pre : bin = b1 b2 . . . bn−1 bn } {post : bin = b1 b2 . . . bn−1 bn x | x = 0} Not(bin) “Modifica un número binario con su negación” {pre : bin = b1 b2 . . . bn } {post : bin = b01 b02 . . . b0n | ∀i b0i = 0 si bi = 1 1 si bi = 0 1 ≤ i ≤ n} 77 4 Noción de Tipo Abstracto de Datos And(bin1, bin2) “Retorna la conjunción de dos números binarios” {pre : bin1 = b1 b2 . . . bn , bin2 = c1 c2 . . . cn } {post : bin1 and bin2 = d1 d2 . . . dn | 1 si bi = ci = 1 ∀i di = 1 ≤ i ≤ n} 0 en otro caso Or(bin1, bin2) “Retorna la disjunción de dos números binarios” {pre : bin1 = b1 b2 . . . bn , bin2 = c1 c2 . . . cn } {post : bin1 or bin2 = d1 d2 . . . dn | 0 si bi = ci = 0 ∀i di = 1 ≤ i ≤ n} 1 en otro caso SumarBin(bin1, bin2) “Retorna la suma de dos números binarios” {pre : bin1 = b1 b2 . . . bn , bin2 = c1 c2 . . . cn } {post : bin1 + bin2 = d0 d1 d2 . . . dn | ∀i di = bi + ci + carryi+1 donde se cumple que 0 (carryi = 0) si bn 1 (carryi = 0) si bn bi + ci = 1 (carryi = 0) si bn 0 (carryi = 1) si bn = cn = 0 = 1 ∧ cn = 0 = 0 ∧ cn = 1 = cn = 1 Complementoa2(bin) “Retorna el complemento a 2 (la suma de su negación con el número 1) de un número binario” {pre : bin = b1 b2 . . . bn−1 bn } {post : Not(bin) + 1} bin2Dec(bin) “Retorna el número entero correspondiente un número binario” {pre : bin = b1 b2 . . . bn−1 bn } {post : e | e = (b1 × 2n ) + (b2 × 2n−1 ) + . . . + (bn−1 × 21 ) + (bn × 20 )} 78 4.1 Tipos Abstractos de Datos esBin(bin) “Informa si un número es binario” {pre : bin} {post : True si bin = b1 b2 . . . bn ∧ ∀i bi = {0, 1} 1 ≤ i ≤ n False de lo contrario} ? ? ? Las operaciones primitivas se dividen en dos grupos: principales y secundarios. El grupo principal está compuesto por las operaciones: Constructoras Encargadas de crear las estructuras internas del tipo abstracto de datos. En los ejemplos anteriores las operaciones CrearEmpleado y CrearBin son constructoras. Modificadoras Son aquellas operaciones que alteran el estado de los elementos del TAD. Las operaciones modificadoras en los ejemplos son: AgregarNombre, AgregarCedula, CambiarSalario, CambiarCargo, CambiarFoto, para el TAD Empleado CorrimientoDer, CorrimientoIzq y Not, para el TAD Binario Analizadoras Operaciones que consultan el estado de los elementos y retornan información (no cambian los estados). Las operaciones analizadoras en los ejemplos son: InfoSalario, InfoCargo y TieneFoto, para el TAD Empleado And, Or, SumarBin, Complementoa2, bin2Dec y esBin, para el TAD Binario El grupo secundario está compuesto por las operaciones: Destructoras Son operaciones que eliminan por completo los elementos del objeto del tipo del TAD. Luego de ejecutar operaciones destructoras los objetos no pueden volver a utilizarse. Persistencia Con ellas se puede guardar en un dispositivo de memoria secundaria (disco duro, CD/DVD, USB, entre otros) la información de los objetos. En los ejemplos 4.1.1 y 4.1.2 no hay operaciones destructoras ni de persistencia. Cualquier operación que no sea primitiva es considerada una operación adicional y no pertenece al TAD. Las operaciones adicionales deben construirse a partir de las operaciones primitivas. A partir de la siguiente sección se mostrarán los diseños, implementaciones en lenguaje C y ejemplos de uso de los principales tipos abstractos de datos. El diseño de estos TADs son modificaciones de los diseños propuestos por J. Villalobos en [23]. La implementación de los TADs utilizará dos archivos diferentes, uno llamado el archivo de encabezado (header 79 4 Noción de Tipo Abstracto de Datos file que comunmente tiene extensión .h), y otro llamado el archivo fuente (source file con extensión .c). Los archivos de encabezado proveen una interfaz de las funciones y estructuras de datos especı́ficas que la librerı́a incluye. El siguiente es un ejemplo de un archivo de encabezado para la librerı́a libreria.h (se excluyó la mayor parte de la documentación, es decir, aquella que muestra el autor de la librerı́a, la versión, forma de uso, changelog, etc): #i f n d e f #define LIBRERIA H LIBRERIA H /∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ LIBRERIAS NECESARIAS ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗/ #include <s t d i o . h> /∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ESTRUCTURAS DE DATOS ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗/ struct a l g o { i n t uno ; f l o a t dos ; }; /∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ OPERACIONES DEL TAD ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗/ /∗ CONSTRUCTORAS ∗/ void o p e r a c i o n 1 ( ) ; /∗ MODIFICADORAS ∗/ int operacion2 ( int ) ; /∗ ANALIZADORAS ∗/ float operacion3 ( float ) ; #endif Las rutinas de preprocesamiento #ifndef LIBRERIA H #define LIBRERIA H #endif son necesarias para evitar las múltiples declaraciones y errores potenciales con diferentes archivos fuente. En cada librerı́a se debe definir (si no está definido) el nombre único de ella. Las librerı́as necesarias son aquellas requeridas por algunas funciones o que van a ser usadas; se incluyen por medio de la rutina de preprocesamiento #include. Las estructuras de datos declaran la armadura o configuración de los datos, tı́picamente con la noción de registro llamada struct. Por último las operaciones del TAD son las funciones que manejan las estructuras de datos. En este archivo solo se especificará las declaraciones de las funciones 80 4.2 Listas y no la definición de ellas, esto quiere decir que solo se coloca el prototipo de la operación como una expresión (finaliza con punto y coma “;”). El archivo fuente contiene las definiciones de las funciones declaradas en el archivo de encabezado. En este archivo solo se debe incluir el archivo de encabezado. El siguiente es un ejemplo de un archivo fuente libreria.c para libreria.h: #include ” l i b r e r i a . h” void o p e r a c i o n 1 ( ) { p r i n t f ( ” s e c o n s t r u y o e l TAD” ) ; } int operacion2 ( int x ) { return x∗x ; } float operacion3 ( float y) { return y / 2 ; } 4.2. Listas Una lista es una colección lineal de elementos del mismo tipo. Cada elemento se encuentra almacenado en una ubicación llamada nodo. Las ubicaciones están numeradas de 1 hasta n, siendo n la longitud de la lista, es decir, el número de elementos que tiene. Muchos lenguajes de programación como Python, Ruby y Lisp, tienen incorporado el tipo de dato Lista de forma nativa. Sin embargo, en esta sección se mostrará cómo diseñar el TAD Lista de manera que se acomode a las necesidades del programador. 81 4 Noción de Tipo Abstracto de Datos 4.2.1. Diseño TAD Lista he1 , . . . , en i ∀i ei ∈ Elemento, n ≥ 0 Operaciones Primitivas: • • • • • • • crearLista: anxLista: insLista: elimLista: infoLista: longLista: vaciaLista: Lista × Elemento Lista × Elemento × Entero Lista × Entero Lista × Entero Lista Lista → Lista → Lista → Lista → Lista → Elemento → Entero → Booleano crearLista() “Construye una nueva lista, inicialmente vacı́a” {pre : TRUE} {post : hi} anxLista(lst, elem) “Inserta un elemento al final de la lista” {pre : lst = hi ó lst = he1 , . . . , en i, elem ∈ Elemento} {post : lst = helemi ó lst = he1 , . . . , en , elemi, respectivamente } insLista(lst, elem, pos) “Inserta un elemento en la posición dada” {pre : lst = he1 , . . . , epos−1 , epos , . . . , en i, elem ∈ Elemento, 1 ≤ pos ≤ n} {post : lst = he1 , . . . , epos−1 , elem, epos , . . . , en i} elimLista(lst, pos) “Elimina el elemento de la posición dada” {pre : lst = he1 , . . . , epos−1 , epos , epos+1 , . . . , en i, {post : lst = he1 , . . . , epos−1 , epos+1 , . . . , en i} 82 1 ≤ pos ≤ n} 4.2 Listas infoLista(lst, pos) “Retorna el dato correspondiente a la posición dada” {pre : lst = he1 , . . . , epos , . . . , en i, {post : epos } 1 ≤ pos ≤ n} vaciaLista(lst) “Informa si la lista está vacı́a (i.e. no contiene elementos)” {pre : lst = hi ó lst = he1 , . . . , en i} {post : True si lst = hi False si lst = he1 , . . . , en i} longLista(lst) “Retorna la longitud de la lista (i.e. número de elementos)” {pre : lst = hi ó lst = he1 , . . . , en i} {post : 0 si lst = hi n si lst = he1 , . . . , en i} 4.2.2. Implementaciones Estructuras Encadenadas Simples La manera más común de implementar una lista es con estructuras encadenadas. En esta aproximación una lista es una colección de nodos encadenados de manera simple por medio de referencias. Cada nodo consta de la información del elemento (de cualquier tipo) y una referencia (o apuntador para algunos lenguajes de programación) al siguiente nodo. Gráficamente un nodo puede verse como la figura 4.1. Información Figura 4.1: Nodo con encadenamiento simple El código para definir un nodo cuya información es un dato tipo entero es el siguiente : struct nodo { i n t dato = 0 ; struct nodo ∗ s i g = NULL; }; 83 4 Noción de Tipo Abstracto de Datos La lista tiene un apuntador al primer nodo, y cada nodo “apunta” al siguiente hasta que el último nodo apunta a un elemento nulo (NULL). Un ejemplo de una lista de enteros lst representada con estructuras encadenadas simples puede verse en la figura 4.2. lst 12 24 36 48 Figura 4.2: Lista con encadenamiento simple Como resulta incómodo referirse a la lista como un struct nodo*, es recomendado (mas no es un requerimiento) realizar una definición de un nuevo apuntador al tipo de dato struct nodo*. Lo anterior se logra mediante el comando typedef de la siguiente manera: typedef struct nodo ∗ L i s t a ; De esta forma siempre podremos referirnos a un dato de tipo struct nodo* como una Lista. Para crear las listas se desarrolla una función constructura que solamente creará la variable lst que apunta al primer nodo de la lista. Esta variable será inicializada en NULL, ya que la lista está vacı́a cuando se crea. La función será ası́: Lista crearLista () { Lista l s t ; l s t = NULL; return l s t ; } La operación para anexar un elemento al final de la lista se logra creando un nodo nuevo donde está contenido el nuevo dato y si la lista está vacı́a se actualiza la variable lst apuntando a nuevo, de lo contrario se crea un apuntador temporal que se moverá hasta el último nodo de la lista y se modifica haciendo el siguiente de este último igual al nuevo nodo. L i s t a a n x L i s t a ( L i s t a l s t , i n t elem ) { L i s t a nuevo , tmp ; nuevo = ( L i s t a ) m a l l o c ( s i z e o f ( struct nodo ) ) ; nuevo −> dato = elem ; nuevo −> s i g = NULL; 84 4.2 Listas i f ( l s t == NULL) l s t = nuevo ; else { tmp = l s t ; while ( tmp −> s i g != NULL) tmp = tmp −> s i g ; tmp −> s i g = nuevo ; } return l s t ; } Para crear un nuevo nodo se debe reservar la memoria necesaria para que todos los elementos de la estructura tengan un espacio. La función malloc permite realizar esta operación pasándole como parámetro el tamaño de la memoria que se va a reservar (en este caso es el tamaño de la estructura nodo). Dicha función retorna un apuntador al espacio de memoria. Como este apuntador que retorna es de tipo void* es deseable que se realice un casting, es decir, un cambio al tipo de dato que se hará referencia, de allı́ que antes del llamado a la función malloc se escriba “(Lista)”. Insertar un elemento en una lista es posible creando un nodo nuevo donde esté contenido el nuevo dato y dependiendo de la posición a insertar se realiza la inserción. Si la posición a insertar es la primera entonces simplemente el nodo nuevo apuntará al primer nodo de la lista y luego se actualiza la variable lst para apuntar al nodo nuevo. Si la posición es diferente a la primera posición de la lista, entonces se usa un apuntador temporal para llegar hasta la posición y actualizar los apuntadores de los nodos entre dicha posición. L i s t a i n s L i s t a ( L i s t a l s t , i n t pos , i n t elem ) { L i s t a nuevo , tmp ; nuevo = ( l i s t a ) m a l l o c ( s i z e o f ( struct nodo ) ) ; nuevo −> dato = elem ; nuevo −> s i g = NULL; i f ( pos >= 1 && pos <= l o n g L i s t a ( l s t ) ) { i f ( pos == 1 ) { nuevo −> s i g = l s t ; l s t = nuevo ; } else { tmp = l s t ; f o r ( i n t i = 0 ; i < pos − 2 ; i ++) tmp = tmp −> s i g ; nuevo −> s i g = tmp −> s i g ; 85 4 Noción de Tipo Abstracto de Datos tmp −> s i g = nuevo ; } } return l s t ; } Es de notar que si se quiere insertar un elemento en la última posición de una lista con elementos, ó en una lista vacı́a, debe usarse la operación anxLista ya que usando la operación insLista es necesario tener una posición válida (e.g. en listas vacı́as no hay posiciones válidas, en listas con elementos la última posición hará que se inserte como penúltimo elemento). Eliminar un elemento es la operación contraria a insertar. Solo se verifica si la posición es la primera u otra distinta y se actualizan los apuntadores de manera similar a insLista. L i s t a e l i m L i s t a ( L i s t a l s t , i n t pos ) { L i s t a tmp ; i f ( pos >= 1 && pos <= l o n g L i s t a ( l s t ) ) { i f ( pos == 1 ) { l s t = l s t −> s i g ; } else { tmp = l s t ; f o r ( i n t i = 0 ; i < pos − 2 ; i ++) tmp = tmp −> s i g ; tmp −> s i g = tmp −> s i g −> s i g ; } } return l s t ; } Para encontrar la información de un elemento dada su posición en la lista primero se garantiza que la posición dada sea una posición correcta y luego con un apuntador temporal, se llega a la posición y se retorna el dato. i n t i n f o L i s t a ( L i s t a l s t , i n t pos ) { L i s t a tmp ; tmp = l s t ; f o r ( i n t i =1; i <pos ; i ++) tmp = tmp −> s i g ; return tmp −> dato ; 86 4.2 Listas } Saber si una lista está vacı́a es tan sencillo como preguntar si la variable lst es igual a NULL, es decir, tiene su valor por defecto. int v a c i a L i s t a ( L i s t a l s t ) { return l s t == NULL; } Es importante conocer la longitud de una lista para realizar ciertas operaciones adicionales. La longitud depende de si la lista está vacı́a o no. Si está vacı́a su longitud es cero, de lo contrario se debe realizar un ciclo con un apuntador temporal hasta que el nodo correspondiente a dicho apuntador no tenga siguiente, es decir, su variable sig sea igual a NULL. int l o n g L i s t a ( L i s t a l s t ) { L i s t a tmp ; int cont ; tmp = l s t ; cont = 0 ; while ( tmp −> s i g != NULL) { tmp = tmp −> s i g ; c o n t ++; } return c o n t ; } Estructuras Doblemente Encadenadas Otra forma de implementar listas es con estructuras doblemente encadenadas. A diferencia de las listas encadenadas simples, en esta aproximación cada nodo consta de la información del elemento y dos referencias: una al siguiente nodo y otra al nodo anterior. Gráficamente puede verse como la figura 4.3. Al igual que las listas encadenadas simples, las listas doblemente encadenadas tienen un apuntador al primer nodo. Lo diferente está en que cada nodo “apunta” al siguiente y al anterior de la lista. El primer nodo en su apuntador al anterior tendrá un elemento nulo (NULL), al igual que último nodo en su apuntador al siguiente. Un ejemplo de una lista doblemente encadenada puede verse en la figura 4.4. El código para definir un nodo cambia. Se le agrega la variable apuntador al anterior. 87 4 Noción de Tipo Abstracto de Datos Información Figura 4.3: Nodo con doble encadenamiento lst 1 1 2 3 5 8 Figura 4.4: Lista con doble encadenamiento struct nodo { i n t dato = 0 ; struct nodo ∗ s i g = NULL; struct nodo ∗ ant = NULL; }; Ni la función constructora de las listas, ni las operaciones analizadoras cambian en esta aproximación. Solo las operaciones modificadoras tienen unos pequeños ajustes para asegurar que los apuntadores a los nodos anteriores se mantengan. A continuación se muestra la operación para insertar un elemento en una posición dada, ya que es la operación más representativa (las otras dos operaciones se dejan como ejercicio para el estudiante): L i s t a i n s L i s t a ( L i s t a l s t , i n t pos , i n t elem ) { L i s t a nuevo , tmp ; nuevo nuevo nuevo nuevo = ( l i s t a ) m a l l o c ( s i z e o f ( struct nodo ) ) ; −> dato = elem ; −> s i g = NULL; −> ant = NULL; i f ( pos >= 1 && pos <= l o n g L i s t a ( l s t ) ) { i f ( pos == 1 ) { nuevo −> s i g = l s t ; l s t −> ant = nuevo ; l s t = nuevo ; } else { 88 4.2 Listas tmp = l s t ; f o r ( i n t i = 0 ; i < pos − 2 ; i ++) tmp = tmp −> s i g ; nuevo −> s i g = tmp −> s i g ; nuevo −> ant = tmp ; tmp −> s i g −> ant = nuevo ; tmp −> s i g = nuevo ; } } return l s t ; } Es de notar que la secuencia de pasos para insertar un elemento (las últimas 4 lı́neas del anterior algoritmo) no puede ser cualquiera. Si se realizan los pasos equivocados puede resultar en la pérdida de elementos de la lista. Por ejemplo, si primero se realiza la asignación tmp ->sig = nuevo, entonces no habrá forma de llegar a los elementos siguientes a tmp y el garbage collector eventualmente los eliminará. Por esta razón primero se deben hacer las asignaciones del nodo nuevo y luego las de tmp. Estructuras Circulares En esta representación, las listas (ya sean encadenadas simples o doblemente encadenadas) tienen la particularidad de no tener nodos con apuntadores a NULL. En las listas circulares encadenadas simples el último elemento de la lista en su variable sig tiene un apuntador al primer elemento de la lista. La figura 4.5 muestra un ejemplo de una lista circular encadenada simple. lst 2 4 8 16 Figura 4.5: Lista circular encadenada simple Adicionalmente al apuntador del último elemento al primero de las listas circulares encadenadas simples, en las listas circulares doblemente encadenadas el primer elemento de la lista en su variable ant tiene un apuntador al último elemento. La figura 4.6 muestra un ejemplo de una lista circular doblemente encadenada. Cuando las listas son circulares, la definición de los nodos y las operaciones constructoras no cambian. No obstante, la mayor parte de las demás funciones cambian de manera que 89 4 Noción de Tipo Abstracto de Datos lst 5 4 3 2 1 0 Figura 4.6: Lista circular doblemente encadenada no se alteren los apuntadores en los casos de inserción y eliminación de los extremos de la lista, y para que no se quede en un ciclo infinito en el caso de anexar un elemento a la lista o saber cuántos elementos tiene la lista. A continuación se mostrará la operación para eliminar un elemento en una lista circular doblemente encadenada. L i s t a e l i m L i s t a ( L i s t a l s t , i n t pos ) { L i s t a tmp ; i f ( pos >= 1 && pos <= l o n g L i s t a ( l s t ) ) { i f ( pos == 1 ) { tmp = l s t ; l s t = l s t −> s i g ; } else { i f ( pos == l o n g L i s t a ( l s t ) ) { tmp = l s t −> ant ; } else { tmp = l s t ; f o r ( i n t i = 0 ; i < pos − 2 ; i ++) tmp = tmp −> s i g ; } } tmp tmp tmp tmp −> −> −> −> sig ant sig ant −> ant = tmp −> ant ; −> s i g = tmp −> s i g ; = NULL; = NULL; } return l s t ; } Para eliminar un elemento en una lista circular doblemente encadenada se debe realizar lo mismo que en una lista no circular doblemente encadenada con modificaciones cuando 90 4.2 Listas se elimine el primero ó el último elemento. Las dos últimas lı́neas del anterior algoritmo en teorı́a no son necesarias ya que se el nodo que se elimina queda inaccesible por la lista entonces eventualmente el garbage collector la borrará de la memoria. Sin embargo por motivos de seguridad de la lista es mejor colocarlas. Vectores Un vector es un arreglo unidimensional de elementos con una longitud constante. Los elementos de un vector pueden ser accesados directamente mediante un ı́ndice. Un ejemplo de una lista implementada con vectores puede verse en la figura 4.7. 0 1 2 'b' 'a' 'p' 3 4 MAX-1 ... Figura 4.7: Lista implementada con un vector MAX es una constante cuyo valor es el número máximo de elementos que puede contener la lista. Los elementos en el vector están indexados por medio de un número entre 0 y MAX-1. En el caso del ejemplo anterior puede verse que a partir de la posición 3 no hay elementos. Esta es una caracterı́stica importante en la implementación de listas con vectores, los elementos siempre deben estar agrupados en las primeras posiciones (i.e. no deben haber posiciones vacı́as entre dos elementos). Debido a que el vector de por sı́ es una estructura completa, en esta aproximación no se tienen nodos. La lista consta del vector y la constante MAX. Un vector es en realidad un apuntador a un espacio de memoria con un tipo de dato particular, de allı́ que podemos usar el operador typedef para seguir usando el tipo de dato Lista, por ejemplo para una lista de enteros se tendrı́a: typedef i n t ∗ L i s t a ; La constante MAX se puede definir mediante la rutina de preprocesamiento #define. Por ejemplo se podrı́a definir MAX con una valor de 1000 ası́: #define MAX 1000 La operación constructora creará el vector y definirá la constante2 : 2 Algunos lectores se preguntarán de qué tamaño debe crearse el vector ó si se debe pedir al usuario dicho tamaño. Las respuestas a esas preguntas son sencillas: el tamaño depende del problema que se piense 91 4 Noción de Tipo Abstracto de Datos Lista crearLista () { Lista l s t ; l s t = ( L i s t a ) m a l l o c (MAX∗ s i z e o f ( i n t ) ) ; f o r ( i n t i = 0 ; i < MAX; i ++) l s t [ i ] = NULL; return l s t ; } En la implementación anterior, se definió el tamaño máximo de la lista como 1000. Nótese que los elementos vacı́os son aquellos en los cuales las ubicaciones en el vector contienen un NULL. De esta manera resulta muy fácil la implementación de varias operaciones: en la operación vaciaLista, sólo hay que verificar si la primera posición contiene un elemento nulo, en las operaciones para anexar un elemento y para saber la longitud de la lista, se debe recorrer el vector hasta encontrar un NULL, y realizar las acciones respectivas. Para insertar un elemento se deben correr todos los elementos desde el final hasta llegar hasta la posición donde se va a insertar y luego copiar elemento. A continuación se muestra el código: L i s t a i n s L i s t a ( L i s t a l s t , i n t pos , i n t elem ) { i f ( l o n g L i s t a ( l s t ) != MAX) { int i = l o n g L i s t a ( l s t ) − 1 ; while ( i > pos −2) { l s t [ i +1] = l s t [ i ] ; i −−; } l s t [ i +1] = elem ; } return l s t ; } Es de notar que el ciclo para correr todos los elementos arranca en longLista(lst)-1 y termina en pos-2. Lo anterior es debido a que los vectores a diferencia de las listas que se han diseñado cuentan sus elementos de 0 a n − 1 y por lo tanto hay que hacer la diferencia. La operación para eliminar un elemento puede verse como el opuesto de la operación anterior, es decir, se deben correr los elementos hacia la posición del elemento que se debe borrar. El lector debe estar en capacidad de desarrollarla. resolver con la lista y no se le debe preguntar al usuario porque no se diseñó la operación constructora con un parámetro más y por lo tanto hacer dicho pedido irı́a en contra de la precondición de la operación. 92 4.2 Listas Cursores Para evitar el desplazamiento de los elementos en la implementación con vectores se puede separar la información en dos vectores: uno con los datos y otro con los ı́ndices o cursores que muestran la ubicación de los siguientes elementos. La gran ventaja está en que insertar o borrar elementos se reduce a cambiar algunos cursores, sin necesidad de mover elementos. La figura 4.8 muestra una lista representada con cursores. DATOS CURSORES 0 1 2 'z' 'k' 'p' 0 1 2 2 6 1 3 4 5 'q' 3 4 3 6 7 'x' 5 6 MAX-1 ... 7 4 MAX-1 ... Figura 4.8: Lista implementada con cursores El vector de cursores contiene el número ı́ndice de los siguientes elementos. Por ejemplo, para la figura 4.8, si el primer elemento está en la posición 0, entonces el segundo elemento está en la posición 2, el tercero está en la posición 1, el cuarto está en la posición 6 y el quinto está en la posición 4. Lo anterior quiere decir que la lista representada en dicha figura es la siguiente: h ’z’, ’p’, ’k’, ’x’, ’q’ i Nótese que el útimo elemento de la lista (’q’), quien se encuentra en la posición 4, tiene en el vector de cursores el número 3. Esto significa que el siguiente elemento que se anexará a la lista quedará en la posición 3 del vector de datos. Al igual que la aproximación con vectores, la constante MAX contiene el máximo número de elementos que puede contener la lista. De esta manera, las listas con cursores tendrán los dos vectores, la constante MAX, y la posición del primer elemento. Adicionalmente, para hacer más eficiente la implementación se tendrá la posición del último elemento y la longitud de la lista como caracterı́ticas almacenadas. Por lo tanto, una lista de enteros será una estructura como la siguiente: struct l i s t a { i n t d a t o s [MAX] , c u r s o r e s [MAX] ; i n t primero , u l t i m o , l o n g i t u d ; }; typedef struct l i s t a L i s t a ; 93 4 Noción de Tipo Abstracto de Datos La operación constructora inicializará todos los elementos de la estructura y las operaciones vaciaLista y longLista son evidentes. Lista crearLista () { Lista l s t ; f o r ( i n t i = 0 ; i < MAX; i ++) { l s t . d a t o s [ i ] = NULL; l s t . c u r s o r e s [ i ] = −1; } l s t . primero = 0 ; l s t . ultimo = 0; l s t . longitud = 0; return l s t ; } Una función interesante es la inserción de un elemento en la posición p. Lo que se debe hacer es buscar una posición vacı́a diferente a la que hace referencia el último elemento, copiar allı́ el nuevo elemento, colocar como su ı́ndice la posición del elemento de la lista donde se va a insertar (pos) y actualizar el ı́ndice del elemento anterior (pos − 1). Por ejemplo, si a la lista de la figura 4.8 se le va a insertar el elemento ’w’ en la posición 4, el proceso se puede ver gráficamente en el siguiente ejemplo: Ejemplo 4.2.1 Tenemos la siguiente lista: DATOS CURSORES 0 1 2 'z' 'k' 'p' 0 1 2 2 6 1 3 4 5 'q' 3 4 6 7 'x' 5 3 6 MAX-1 ... 7 4 MAX-1 ... Para insertar el elemento ’w’ en la posición 4 primero se inserta el elemento en una posición vacı́a. DATOS CURSORES 94 0 1 2 'z' 'k' 'p' 0 1 2 2 6 1 3 3 4 5 6 'q' 'w' 'x' 4 5 6 3 4 7 MAX-1 ... 7 MAX-1 ... 4.2 Listas Luego se busca el cursor del elemento cuya posición es 3 (marcado en la gráfica con color rojo). DATOS CURSORES 0 1 2 'z' 'k' 'p' 0 1 2 2 6 1 3 3 4 5 6 'q' 'w' 'x' 4 5 6 3 7 MAX-1 ... 7 4 MAX-1 ... Luego se coloca como ı́ndice el mismo número del cursor del elemento 3. DATOS CURSORES 0 1 2 'z' 'k' 'p' 0 1 2 2 6 1 3 3 4 5 6 'q' 'w' 'x' 4 5 6 3 6 4 7 MAX-1 ... 7 MAX-1 ... Por último se actualiza el ı́ndice de la posición 3. DATOS CURSORES 0 1 2 'z' 'k' 'p' 0 1 2 2 5 1 3 3 4 5 6 'q' 'w' 'x' 4 5 6 3 6 4 7 MAX-1 ... 7 MAX-1 ... ? ? ? El código de la función es el siguiente: L i s t a i n s L i s t a ( L i s t a l s t , i n t pos , i n t elem ) { i f ( pos >= 1 && pos <= l o n g L i s t a ( l s t ) ) { f o r ( i n t i = 0 ; i < l o n g L i s t a ( l s t ) − 1 ; i ++) { i f ( l s t . d a t o s [ i ] == NULL && l s t . c u r s o r e s [ l s t . u l t i m o ] != i ) { l s t . d a t o s [ i ] = elem ; break ; } 95 4 Noción de Tipo Abstracto de Datos } i n t postemp = l s t . p r i m e r o ; f o r ( i n t j = 0 ; j < pos − 2 ) postemp = l s t . c u r s o r e s [ postemp ] ; l s t . c u r s o r e s [ i ] = l s t . c u r s o r e s [ postemp ] ; l s t . c u r s o r e s [ postemp ] = i ; } return l s t ; } Esta función puede hacerse más eficiente si se tiene una estructura de datos donde se encuentren las posiciones vacı́as de la lista y, por lo tanto, no se necesite buscar una. Las operaciones de anxLista y elimLista se dejan como ejercicio para el estudiante. 4.2.3. Análisis de Complejidad de las Implementaciones Al observar las diferentes aproximaciones que existen (las de las sección anterior y muchas otras) la pregunta que surge es: ¿cuál usar? La respuesta a esta pregunta depende mucho del computador donde se usará el TAD, el lenguaje de programación en el que se implementará y el uso que se le dará (su aplicación). Si se asume que se tiene una máquina con mucha memoria, es decir, no es importante esta condición, y el lenguaje de programación es C, o sea que disponemos de las herramientas para desarrollar cualquier aproximación, entonces la solución solo depende del uso que se le de al TAD. De lo anterior se puede ver que el problema se reduce a analizar cada una de las operaciones. Es claro que las diferentes implementaciones del TAD Lista poseen diferentes algoritmos, lo que conlleva a diferentes complejidades. Encadenadas Simple Doblemente Encadenadas Circulares Simple Circulares Dobles Vectores Cursores crearL O(1) O(1) O(1) O(1) O(n) O(n) anxL O(n) O(n) O(n) O(1) O(n) O(1) insL O(n) O(n) O(n) O(n/2) O(n) O(n) elimL O(n) O(n) O(n) O(n/2) O(n) O(n) infoL O(n) O(n) O(n) O(n/2) O(1) O(n) longL O(n) O(n) O(n) O(n) O(n) O(1) vaciaL O(1) O(1) O(1) O(1) O(1) O(1) Cuadro 4.1: Comparación de complejidades en las implementaciones del TAD Lista 4.2.4. Utilización En esta sección se mostrarán varios ejemplos de desarrollo de funciones adicionales haciendo uso de las operaciones primitivas del TAD Lista. Estas operaciones adicionales resuelven problemas utilizando las operaciones de listas (no importa qué implementación ya que como se vió, todas las funciones tienen el mismo prototipo) y, aunque no hacen parte del TAD, enriquecen el uso de las listas. 96 4.2 Listas Ejemplo 4.2.2 (Buscar un elemento) Se desea buscar un elemento en una lista y retornar su posición. Una forma de resolver este problema es realizando un recorrido por la lista desde el primer elemento hasta que se encuentre o se termine la lista (en este último caso se retornará -1 haciendo entender que no se encuentra en la lista). En caso de haber elementos repetidos, se retorna la posición del primer elemento que se encuentre. La complejidad de este algoritmo es O(n) donde n es el tamaño de la lista. {pre : lst = he1 , . . . , ei , . . . , en i, elem ∈ Elemento} {post : i si ∃i | ei = elem, −1 de lo contrario} i n t b u s c a r L i s t a ( L i s t a l s t , i n t elem ) { i n t pos = −1; f o r ( i n t i = 1 ; i <= l o n g L i s t a ( l s t ) ; i ++) { i f ( i n f o L i s t a ( l s t , i ) == elem ) { pos = i ; break ; } } return pos ; } ? ? ? Ejemplo 4.2.3 (Invertir una lista) El problema de invertir una lista requiere recorrer la lista de entrada desde el último elemento hasta el primero anexando cada elemento en otra lista. Su complejidad es O(n), donde n es el tamaño de la lista. {pre : lst = he1 , e2 , . . . , en i} {post : hen , . . . , e2 , e1 i} Lista invertirLista ( Lista l s t ) { L i s t a tmp = c r e a r L i s t a ( ) ; f o r ( i n t i = l o n g L i s t a ( l s t ) ; i > 0 ; i −−) tmp = a n x L i s t a ( tmp , i n f o L i s t a ( l s t , i ) ) return tmp ; } ? ? ? Ejemplo 4.2.4 (Equivalencia de listas) 97 4 Noción de Tipo Abstracto de Datos Saber si dos listas son equivalentes requiere hacer un ciclo verificando si cada par de elementos de las dos listas, en la misma posición, son iguales. Esto trae consigo la condición de que las listas deben tener el mismo tamaño. Su complejidad es O(n), donde n es el tamaño de la lista. {pre : lst1 = he1 , . . . , ei , . . . , en i ∧ lst2 = hf1 , . . . , fi , . . . , fm i} {post : True si n = m ∧ ∀i ei = fi , 1 ≤ i ≤ n} int i g u a l e s L i s t a s ( L i s t a l s t 1 , L i s t a l s t 2 ) { i f ( l o n g L i s t a ( l s t 1 ) == l o n g L i s t a ( l s t 2 ) ) { f o r ( i n t i = 1 ; i <= l o n g L i s t a ( l s t 1 ) ; i ++) { i f ( i n f o L i s t a ( l s t 1 , i ) != i n f o L i s t a ( l s t 2 , i ) ) return 0 ; } return 1 ; } else return 0 ; } ? ? ? Ejemplo 4.2.5 (Palı́ndromes) Una lista es palı́ndrome si puede leerse igual de derecha a izquierda o de izquierda a derecha. Existen diversas formas de conocer si una lista es palı́ndrome. En este ejemplo se usarán las operaciones que se han desarrollado anteriormente, es decir, primero invirtiendo la lista y luego preguntando si la inversa es igual a la original. Su complejidad es O(2n), donde n es el tamaño de la lista. {pre : lst1 = he1 , . . . , ei , . . . , en i} {post : True si ∀i ei = e(n+1)−i , 1 ≤ i ≤ n} int palindrome ( L i s t a l s t ) { L i s t a tmp = i n v e r t i r L i s t a ( l s t ) ; i f ( i g u a l e s L i s t a s ( tmp , l s t ) ) return 1 ; else return 0 ; } ? ? ? 98 4.2 Listas 4.2.5. Variantes En esta sección se presentará una variante del TAD Lista más especializada llamada TAD Lista Ordenada. Lista Ordenada Una lista ordenada es una lista que cumple con la condición de que para cada elemento de la lista, el siguiente es mayor y el anterior es menor. Adicionalmente no existen elementos repetidos. A continuación se muestra el diseño del TAD (la implementación queda como ejercicio al estudiante). TAD Lista Ordenada e1 , . . . , e n ∀i ei < ei+1 , ei ∈ Elemento Operaciones Primitivas: • • • • • • • crearListaOrd: anxListaOrd: elimListaOrd: infoListaOrd: estaListaOrd: longListaOrd: vaciaListaOrd: 1 ≤ i < n, n≥0 Lista × Elemento Lista × Elemento Lista × Entero Lista × Elemento Lista Lista → Lista → Lista → Lista → Elemento → Booleano → Entero → Booleano crearListaOrd() “Construye una nueva lista ordenada, inicialmente vacı́a” {pre : TRUE} {post :} anxListaOrd(lst, elem) “Inserta un elemento en la lista ordenada” {pre : lst = ó lst = e1 , . . . , en , elem ∈ Elemento} {post : lst = elem ó lst = e1 , . . . , ei , elem, ei+1 , . . . , en ei < elem < ei+1 , respectivamente } 99 4 Noción de Tipo Abstracto de Datos elimListaOrd(lst, elem) “Elimina el elemento dado de la lista ordenada” {pre : lst = e1 , . . . , ei−1 , ei , ei+1 , . . . , en } {post : lst = e1 , . . . , ei−1 , ei+1 , . . . , en si ei = elem} infoListaOrd(lst, pos) “Retorna el dato correspondiente a la posición dada” {pre : lst = e1 , . . . , epos , . . . , en , {post : epos } 1 ≤ pos ≤ n} estaListaOrd(lst, elem) “Informa si un elemento está en la lista ordenada” {pre : lst = e1 , . . . , ei , . . . , en , {post : True si ∃i | ei = elem False de lo contrario } 1 ≤ i ≤ n} vaciaListaOrd(lst) “Informa si la lista está vacı́a (i.e. no contiene elementos)” {pre : lst = ó lst = e1 , . . . , en } {post : True si lst = False si lst = e1 , . . . , en } longListaOrd(lst) “Retorna la longitud de la lista (i.e. número de elementos)” {pre : lst = ó lst = e1 , . . . , en } {post : 0 si lst = n si lst = e1 , . . . , en } 4.3. Pilas Una pila es una estructura de datos lineal tipo LIFO (Last In, First Out) ya que el orden de la secuencia de sus elementos es análogo a una pila de platos en una cafeterı́a donde el último plato en ser colocado en la pila es usualmente el primero en ser usado. Entonces en una pila, a diferencia de las listas, los elementos son adicionados y eliminados en un extremo, el cual es llamado el tope de la pila. Este tope es el único elemento accesible de la pila. 100 4.3 Pilas Las pilas son bastante usadas en computación. Entre sus usos tenemos: para mantener un registro de acciones en muchos programas y poder “deshacer” algunas o todas ellas (usualmente con el comando Ctrl-Z), en navegadores de internet con las direcciones recientemente visitadas, para implementar recursión y backtracking, para evaluar de expresiones aritméticas y en la ejecución de algoritmos para mantener un rastro de los llamados a funciones y retornos y conocer el alcance de las variables. Para visualizar mejor esta estructura de datos, se puede pensar en un recipiente como R (ver figura 4.9). En dicho recipiente la única papa visible es la el de las papas Pringles que está en el tope y ella es la única que puede sacarse de la pila. Si se introduce una papa al recipiente ella se convertirá en el tope. De lo anterior se deduce que no es posible conocer la cantidad de elementos que contiene una pila, para saberlo hay que sacar todos los elementos y contarlos. Figura 4.9: Ejemplo real de una pila A continuación se muestra el diseño formal del TAD Pila. 101 4 Noción de Tipo Abstracto de Datos 4.3.1. Diseño TAD Pila en .. . e1 ∀i ei ∈ Elemento, n ≥ 0 Operaciones Primitivas: • • • • • crearPila: Push: Pop: Peek: vaciaPila: P ila × Elemento P ila P ila P ila crearPila() “Construye una nueva pila, inicialmente vacı́a” {pre : TRUE} {post : pil = } Push(pil, elem) “Inserta un elemento en el tope de la pila” en {pre : pil = ... , e1 elem en {post : pil = } .. . e1 102 elem ∈ Elemento} → P ila → P ila → P ila → Elemento → Booleano 4.3 Pilas Pop(pil) “Elimina el elemento tope de la pila” en en−1 {pre : pil = } .. . e1 en−1 .. {post : pil = } . e1 Peek(pil) “Retorna el dato correspondiente al tope de la pila” en {pre : pil = ... } e1 {post : en } vaciaPila(pil) “Informa si la pila está vacı́a (i.e. no contiene elementos)” {pre : pil = en ó pil = ... } e1 {post : True si pil = False de lo contrario } 103 4 Noción de Tipo Abstracto de Datos 4.3.2. Implementaciones 4.3.3. Utilización 4.4. Colas 4.5. Tablas Hash 4.6. Árboles Binarios 4.7. Árboles N-arios 4.8. Grafos 104 Índice alfabético Alcance, 68 Apuntadores, 65 Cálculo λ, 12 Compilador, 41 Complejidad, 16 Depuración, 46 Evento, 59 Excepción, 52 Garbage Collector, 61 Indecidibilidad, 11 Interfaz, 57 Lenguaje de Programación, 35 Ligadura, 70 Máquina de Turing, 14 Máquina Virtual, 44 NP-Completitud, 25 Problema, 11 Programa, 35 Referencia, 60 Semántica, 40 Sintaxis, 38 Tipo de dato, 66 Tratabilidad, 16 Visibilidad, 72 105 Bibliografı́a [1] Alfred V. Aho, Ravi Sethi, and Jeffrey D. Ullman. Compilers: Principles, Techniques, and Tools. Addison Wesley, 1986. [2] David M. Beazley. Python Essential Reference. Sams, 3rd edition, 2006. [3] Boris Beizer. Software Testing Techniques. Van Nostrand Reinhold Company, 1983. [4] Barry B. Brey. The Intel Microprocessors 8086/8088, 80186, 80286, 80386 and 80486. Architecture, Programming and Interfacing. Prentice Hall, 3rd edition, 1994. [5] Osvaldo Cairó and Silvia Guardati. Estructuras de Datos. McGraw-Hill, 3rd edition, 2006. [6] Luca Cardelli. Type Systems, chapter 103. Handbook of Computer Science and Engineering. CRC Press, 1997. [7] Rodrigo Cardoso. Verificación y Desarrollo de Programas. Ediciones Uniandes, 1991. [8] Stephen A. Cook. The complexity of theorem-proving procedures. In 3rd Annual ACM Symposium on the Theory of Computing, pages 151–158, 1971. [9] Thomas H. Cormen, Charles E. Leiserson, and Ronald L. Rivest. Introduction to Algorithms. Mc Graw Hill, 1990. [10] Vinay Deolalikar. P 6= NP. HP Research Labs, Palo Alto, August 2010. [11] Matthias Felleisen, Robert Bruce Findler, Matthew Flatt, and Shriram Krishnamurthi. How to Design Programs: An Introduction to Programming and Computing. The MIT Press, 2001. [12] Daniel P. Friedman, Mitchell Wand, and Christopher T. Haynes. Essentials of Programming Languages. The MIT Press, second edition, 2001. [13] Yedidyah Langsam, Moshe J. Augenstein, and Aaron M. Tenenbaum. Data Structures Using C and C++. Prentice Hall, 2nd edition, 1995. [14] Zbigniew Michalewicz and David B. Fogel. How To Solve It: Modern Heuristics. Springer, 2000. 107 Bibliografı́a [15] Bradley N. Miller and David L. Ranum. Problem Solving With Algorithms And Data Structures Using Python. Franklin, Beedle & Associates, Inc., 2006. [16] Roger Penrose. The Emperor’s New Mind. Oxford University Press, 1989. [17] George Polya. How to Solve It: A New Aspect of Mathematical Method. Princeton University Press, 1945. [18] Terrence W. Pratt and Marvin V. Zelkowitz. Programming Languages: Design and Implementation. Prentice Hall, 1984. [19] Bruno R. Preiss. Data Structures and Algorithms with Object-Oriented Design Patterns in Java. Wiley, 1999. [20] Roger S Pressman. Software Engineering: A Practitioner’s Approach. McGraw-Hill, 2004. [21] Stuart M. Shieber. Course notes for cs152 principles of programming languages, November 1995. [22] Guido van Rossum. Python Tutorial. Python Software Foundation, Release 2.5, 19th September 2006. [23] Jorge A. Villalobos. Diseño y Manejo de Estructuras de Datos en C. Mc Graw Hill, 1996. 108