Teoria de Los Lenguajes de Progarmacion -Digit
January 7, 2017 | Author: Carmelo Pozo Gomez | Category: N/A
Short Description
Download Teoria de Los Lenguajes de Progarmacion -Digit...
Description
Fernando López Ostenero Ana María García Serrano
TEORIA DE LOS LENGUAJES DE PROGRAMACIÓN
T
Editorial Universitaria Ramón Areces
UflED
•
Indice índice de
figuras
xiii
índice de tablas
xv
Prólogo
1
1
Paradigmas de la computación
5
1.1
Abstracción en los lenguajes de programación
7
1.1.1
Abstracciones de Datos
7
1.1.2
Abstracciones de Control
8
1.2
1.3 1.4
Introducción a los paradigmas de computación
11
1.2.1
Programación orientada a objetos
13
1.2.2
Programación funcional
14
1.2.3
Programación lógica
15
Descripción de los lenguajes de programación
16
1.3.1
17
Traducción de los programas para su ejecución
Diseño de los lenguajes de programación
20
1.4.1
La eficiencia
1.4.2
La regularidad
23
1.4.3
Principios adicionales
24
v
22
1.5
Ejercicios resueltos
30
1.6
Ejercicios propuestos
30
1.7
Notas bibliográficas
31 ix
ÍNDICE
X
2
3
Programación Funcional
33
2.1
Programas como Funciones
35
2.2
Evaluación perezosa
38
2.3
Introducción al lenguaje Haskell
41
2.3.1
Tipos de datos predefinidos en Haskell
42
2.3.2
Definición de Funciones
45
2.3.3
El tipo de las Funciones
54
2.3.4
Tipos de Datos Avanzados
58
2.3.5
Ejemplos de Funciones: trabajando con listas
66
2.4
Ejercicios resueltos
77
2.5
Ejercicios propuestos
82
2.6
Notas bibliográficas
84 85
Programación Lógica 3.1
3.2
3.3
3.4
Especificación de programas 3.1.1
Reglas y programas
3.1.2
Extracción de respuestas
3.1.3
Guía metodológica para la especificación
• • **"7 ^9 96
Computación lógica 3.2.1
Unificación
3.2.2
Resolución
3.2.3
Control de la ejecución
1
3.2.4
Estructura de datos: la lista
119
Técnicas avanzadas de programación lógica
125
3.3.1
Indeterminismo
126
3.3.2
Eficiencia con estructuras de datos
129
3.3.3
Gestión dinámica
134
3.3.4
Uso eficiente de la recursión
136
3.3.5
Meta-programación
138
Ejercicios resueltos
1^2
139
ÍNDICE
XÍ
3.5
Ejercicios propuestos
156
3.6
Notas bibliográficas
160
Sintaxis y Semántica Básica
161
4.1
Sintaxis de los Lenguajes de Programación
161
4.1.1
Estructura léxica de los Lenguajes de Programación
161
4.1.2
Gramáticas libres de contexto
169
4.1.3
Notación BNF
171
4.1.4
Estructura sintáctica: árboles sintácticos
171
4.1.5
Ambigüedad, asociatividad y precedencia
173
4.1.6
Diagramas sintácticos
175
4.2
Semántica de los Lenguajes de Programación
177
4.2.1
Atributos, vínculos y funciones semánticas
178
4.2.2
Declaraciones, bloques y alcance
181
4.2.3
La tabla de símbolos
188
4.2.4
Asignación, tiempo de vida y ambiente
196
4.2.5
Variables y constantes
201
4.3
Ejercicios resueltos
204
4.4
Ejercicios propuestos
212
4.5
Notas bibliográficas
213
Tipos de Datos
215
5.1
Tipos de Datos
216
5.1.1
Tipos de datos atómicos
221
5.1.2
Tipos de datos estructurados
225
5.2
Equivalencia de Tipos de Datos
230
5.3
Conversión de Tipos de Datos
232
5.4
Ejercicios resueltos
234
5.5
Ejercicios propuestos
238
5.6
Notas bibliográficas
239
xii 6
ÍNDICE
Control de la Ejecución 6.1
6.2
Evaluación de expresiones 6.1.1
Notaciones infija, prefija y postfija
6.1.2
Evaluación de una expresión
Sentencias Condicionales 6.2.1
wSentencias if-then-else
6.2.2
Sentencias case
6.3
Bucles
6.4
Excepciones
6.5
6.4.1
Definición de excepciones
6.4.2
Definición de manejadores de excepciones y control del flujo . . . .
Subprogramas 6.5.1
Semántica
6.5.2
Paso de Parámetros
6.5.3
Ambientes de Ejecución
6.6
Ejercicios resueltos .
6.7
Ejercicios propuestos
6.8
Notas bibliográficas
Bibliografía índice analítico
.
índice de figuras 3.1
Árbol de búsqueda para la consulta p (a, a)
107
3.2
Árbol de búsqueda para la consulta p (X, Y)
108
3.3
Árbol de búsqueda para la consulta p (X, b)
108
3.4
Árbol de búsqueda para la primera versión del factorial
109
3.5
Árbol de búsqueda para la segunda versión del factorial
110
3.6
Árbol de búsqueda para la tercera versión del factorial
111
3.7
Árbol de búsqueda de menor (4, 5, S) para la \a versión de menor (X, Y, Z).
117
3.8
Árbol de búsqueda de menor (4, 5, S) para la 2a versión de menor (X, Y, Z).
117
3.9
Árbol de búsqueda de menor (5, 2, S) para la 2a versión de menor (X, Y, Z).
117
3.10 Árbol de búsqueda de menor (5, 2, S) para la 3a versión de menor (X, Y, Z).
118
3.11 Árbol de búsqueda de menor (2, 5, 5) para la 3a versión de menor (X, Y, Z).
118
3.12 Árbol de búsqueda de i n v e r t i r ([1, 2, 3 ] , S)
123
3.13 Árbol de búsqueda de i n v e r t i r ([1, 2, 3 ] , S) , !
124
3.14 Árbol de búsqueda de i n v e r t i r ( [ 1 , 2, 3 ] , [ ] , S)
125
3.15 Ejercicio de analogía
132
3.16 Laberinto de Tólkien
152
4.1
Ejemplo de ambigüedad
173
4.2
Ejemplos de derivaciones
174
4.3
Diagramas sintácticos
176
4.4
Diagrama sintáctico para el metasímbolo de opción
177
4.5
Tabla de símbolos en la línea 6
189 Xlll
xiv
ÍNDICE DE FIGURAS
4.6
Tabla de símbolos en la línea 8
4.7
Tabla de símbolos en la línea 11
4.8
Tabla de símbolos en la línea 15
4.9
Tabla de símbolos en la línea 16
-
4.10 Tabla de símbolos en la línea 20 4.11 Tabla de símbolos con alcance dinámico en la línea 19 4.12 Tabla de símbolos con alcance dinámico en la línea 13 4.13 Tabla de símbolos con alcance dinámico en la línea 8 4.14 Tabla de símbolos con subtablas 6.1
Árbol de sintaxis abstracta para la expresión 6 + 2 * 4
6.2
Forma general de una sentencia condicional
6.3
Forma general de un bucle
6.4
Estado de los registros de activación al comienzo del programa
6.5
Estado de los registros de activación durante la ejecución de suma
6.6
Estado de los registros de activación durante la ejecución de incrementa. . .
6.7
Ambiente estático de un programa en Fortran77
6.8
Estructura de un registro de activación en Fortran77
6.9
Registros de activación durante la ejecución del programa principal
6.10 Registros de activación durante la ejecución de Pj 6.1 1 Registros de activación durante la primera ejecución de P2 6.12 Registros de activación durante sucesivas ejecuciones de P2 6.13 Estructura de un registro de activación para un lenguaje con recursión sin anidamiento 6.14 Registros de activación durante las ejecuciones de P2 anidado en P| 6.15 Estructura de un registro de activación para un lenguaje con recursión y anidamiento
A
Indice de tablas 1.1
Abstracciones en los Lenguajes de Programación
11
3.1
Comparativa entre programación imperativa y declarativa
86
3.2
Ejemplos de predicados lógicos
88
3.3
Carta de un restaurante
89
3.4
Distancias del sol a los planetas
100
4.1
Caracteres especiales (1/2)
166
4.2
Caracteres especiales (2/2)
167
4.3
Metasímbolos BNF.
171
4.4
Metasímbolos EBNF.
175
4.5
Ambiente después de la entrada en A
198
4.6
Ambiente después de la entrada a B
198
4.7
Ambiente después de la entrada a D
198
5.1
Comparativa de tipos reales entre Java y Haskell
223
5.2
Operaciones sobre caracteres en Java y Haskell
223
xv
apítulo 1
I
aradigmas de la computación este tema se presentan conceptos relacionados con los diferentes paradigmas de progralón existentes. Según Louden [15]: k\S7 la forma en que nos comunicamos influye en la i en que pensamos y viceversa, entonces la forma en que programamos influye en lo pensamos sobre los programas y viceversa". Con lo que el estudio de los paradigmas de lenguajes de programación es tan importante para el programador, como lo es dominar 5 lenguajes concretos, ya que este conocimiento va a permitir saber qué lenguaje es el adecuado para cada tipo de escenario y cada problema a resolver. s de la década de los 40 (del siglo XX), se programaba cableando, y es en dicha década do Von Neumann 1241 plantea el uso de códigos para determinar las acciones de los nadores, evitando el cableado. A continuación se asignaron símbolos a los códigos de instrucciones y a las localizaciones de memoria, naciendo el lenguaje ensamblador.
el lenguaje ensamblador, de bajo nivel de abstracción, dependía de cada ordenador . era difícil de entender. Se fueron añadiendo al lenguaje construcciones con mayor nivel abstracción como la asignación, los bucles (también llamados ciclos) o las sentencias condicionales y opciones, que ya son instrucciones independientes del ordenador, mas concisas y fáciles de comprender. Pero al principio los lenguajes seguían reflejando la arquitectura Von Newman: un área de memoria donde se almacenaban tanto a los programas como a los datos de los mismos, y por separado había una unidad de procesamiento que ejecutaba secuencialmente las instrucciones de la memoria. Los lenguajes estaban muy lejos de lo que ahora se entiende por un lenguaje de programación de alto nivel. Los lenguajes modernos, aunque siguen conservando en esencia ese tipo de procesamiento, .J aumentar el nivel de abstracción y utilizar nuevas arquitecturas en paralelo, se hacen independientes de la máquina y solo describen el procesamiento en general, en lugar de detallar todas las instrucciones que debe ejecutar la unidad de procesamiento. Así, siguiendo a Louden [15]: "Un lenguaje de programación es un sistema notacional para describir computaciones en una forma legible tanto para el ordenador como para el
6
P A R A D I G M A S DH LA C O M P U T A C I Ó N
programador'. Un lenguaje de programación es una notación especial para comunicarse con el ordenador y la computación incluye todo tipo de operaciones como por ejemplo la manipulación de datos, el procesamiento de texto o el almacenamiento y la recuperación de información. A veces los lenguajes se diseñan con un propósito concreto (como SQL para el mantenimiento de una base de datos), pero los mas interesantes desde el punto de vista de paradigma, son los lenguajes de propósito general. Y estos han de ser legibles por un ordenador, requisito que exige la existencia de una estructura del lenguaje que permita a un programa su traducción no ambigua y finita. En general, se restringe la notación de un lenguaje de programación a la notación formal de un lenguaje de contexto libre (lenguajes descritos por unas formas especiales de reglas en su gramática). La evolución de los lenguajes de programación se ha organizado en cinco generaciones: 1. En la primera generación se incluyen los lenguajes máquina, en los que los datos y las operaciones sobre ellos se describen mediante ceros y unos. Son códigos o notaciones muy difíciles de entender por los programadores y cada procesador tiene el suyo propio . Por ejemplo, el byte 01111000 le dice al procesador Z80 de Zilog que copie en el registro A el contenido del registro B. 2. La segunda generación es la que incluye a los lenguajes ensambladores, cuya traducción a lenguaje máquina es muy sencilla, y aún hoy se utilizan para tareas muy específicas, como puede ser para programar d r i v e r s para dispositivos. Siguiendo con el ejemplo anterior, el byte 01111000 se representa mediante el mneniónico "LD A, B'\ que es más sencillo de recordar 1 . 3. La tercera generación es la que incluye a los lenguajes de alto nivel como Pascal, Fortran, C o Java. Se denominan de alto nivel porque están muy alejados de la máquina pero muy cercanos al los programadores. Para su traducción a lenguaje máquina se necesitan compiladores o intérpretes. Surgen alrededor de los años 60 (del siglo XX), siendo los primeros Fortran, Lisp, Algol y Cobol. 4. La cuarta generación agrupa a lenguajes de propósito específico, como SQL, Natural. o el del paquete estadístico SPSS que permite manipular grandes cantidades de datos con fines estadísticos. 5. Por último, en la quinta generación se incluyen lenguajes que se utilizan, en primer lugar, en el área de la Inteligencia Artificial, con los que se especifica mas qué problema hay que resolver que cómo se resuelve dicho problema con una secuencia de acciones. El primero que se incluye en este grupo es el lenguaje Prolog, aunque otros lenguajes funcionales como Haskell, también se clasifican como de quinta generación. 1
Ya que LD es una abreviatura de Leal;.
ABSTRACCIÓN EN LOS LENGUAJES DE PROGRAMACIÓN
7
mente, indicar que para grandes desarrollos en los que intervienen varios programes, un lenguaje de programación se convierte en una parte de un entorno de desarrollo software, que obliga a utilizar una metodología de desarrollo que permita comprender ama como un todo e identificar fácilmente qué efecto produciría un cambio local, entornos, por lo tanto, se convierten en un conjunto de herramientas para la escritura y ción de los programas, para manipular los archivos del programa, registrar cambios y pruebas y análisis. Los entornos de programación son el objeto de la ingeniería del are, que queda fuera del objetivo de este tema: los lenguajes de propósito general.
.1
Abstracción en los lenguajes de programación
abstracción en los lenguajes de programación se refiere a la abstracción de los datos, resume sus propiedades y la abstracción del control que resume las propiedades de la ferencia de control, esto es, de la modificación de la estrategia de ejecución de un pro»a en una situación determinada (por ejemplo, los bucles, las sentencias condicionales ¡a> llamadas a subprogramas). A su vez las abstracciones se clasifican en básicas, estrucs y unitarias 2 . m
.1.1
Abstracciones de Datos
continuación se presentan las abstracciones de datos, las cuales se trataran con mayor ndidad en el capítulo 5. • Las abstracciones de datos básicas se refieren a la representación interna de los datos de tipo atómico 3 que ofrece el lenguaje, junto a sus operaciones estándar (como las aritméticas para los datos numéricos o las del Algebra de Boole para los valores booleanos). Otro tipo de abstracción básica es el uso de nombres simbólicos para referenciar las localizaciones de memoria que contienen los datos del programa. Esto se conoce con el nombre de variable. Las variables abstraen estas direcciones por medio de un nombre y un tipo de datos establecidos mediante una declaración, por ejemplo el siguiente código en Pascal: var x :
integer;
está declarando una variable x de tipo entero, mientras que su equivalente en C sería: int x ; - Aunque en este libro no se tratarán en profundidad las abstracciones unitarias. Que son aquellos que no pueden dividirse en elementos más sencillos.
8
PARADIGMAS DE LA COMPUTACIÓN
• Las abstracciones de datos estructuradas son el mecanismo de abstracción para colecciones de datos. Una estructura típica es el arrav (también llamado vector o, en una desafortunada traducción, arreglo) que reúne datos como una secuencia de elementos. Por ejemplo la declaración en C: int tabla [ 7];
establece que la variable t a b l a es un array de 7 valores enteros. En muchos lenguajes se puede dar nombre también a los tipos de datos, mediante una definición de tipo como la siguiente en C: typedef int M i t a b l a [ 7 ] ;
que define un nuevo tipo Mitabla que es un array de 7 enteros. A estos tipos se les denomina tipos estructurados. • Las abstracciones de datos unitarias se refieren a la agrupación, como una única unidad, de datos y operaciones sobre ellos. Introducen el concepto de encapsulado de datos u ocultación de información, mecanismo muy útil para reunir códigos relacionados entre sí en localizaciones específicas dentro del programa, ya sea en forma de archivos por separado o como estructuras del lenguaje separadas dentro de un archivo. Estas abstracciones unitarias se asocian a menudo con los tipos abstractos de datos, separando las operaciones que se pueden realizar con los valores de dicho tipo de datos (lo que se conoce como interfaz) de su implementación interna. Ejemplos son los módulos en Haskell (o ML) y los paquetes en Java (o Ada). Las clases de los lenguajes orientados a objetos son un mecanismo conceptual mente mas cercano a las abstracciones unitarias, pero también a las abstracciones estructuradas, ya que ofrecen un encapsulamiento de datos y tienen algunas características de los módulos o paquetes. Dos de las características mas importantes de las abstracciones de datos unitarias son la capacidad de reutilización de la misma abstracción en programas diferentes, a través de bibliotecas y su interoperabilidad o facilidad de combinación de abstracciones al proporcionar convenciones estándar para sus interfaces como CORBA (Common Object Request Broker Architecture), que es un estándar de interface independiente del lenguaje de programación aplicado a la estructura de clase. 1.1.2
Abstracciones de Control
• Las abstracciones básicas de control son sentencias individuales que permiten modificar (directa o indirectamente) el control del flujo de la ejecución de un programa. Como ejemplos de abstracciones de control básicas están la sentencia de asignación
ABSTRACCIÓN EN LOS LENGUAJES DE PROGRAMACIÓN
9
0 el goto de Fortran, que se encarga del proceso de cambiar la transferencia de control de una sentencia a otra parte dentro del programa. Por ejemplo en el programa en Fortran: GOTO 10 estas ...y
líneas la
se
saltan...
ejecución
continúa
aquí
10 CONTINUE el control salta de la línea 1 a la 5. Todas las sentencias que haya las líneas intermedias no se ejecutan. Actualmente las sentencias g o t o se consideran de muy baja abstracción y en los lenguajes modernos se encuentran solo de torma muy limitada, por su escasa Habilidad. Las abstracciones de control estructuradas agrupan sentencias más simples para crear una estructura con un propósito común que permite gobernar la ejecución del programa. Ejemplos típicos son los bucles o las sentencias condicionales, como i f , la sentencia c a s e de Pascal o el s w i t c h de C. Por ejemplo en C: i if (x >= 0) { : n u m S o l u c i o n e s = 2; 3 rl = ^ s q r t ( x ) ; 4 r2 = - r1 ; ? } else { 6 numSoluciones = 0 ;
7} el grupo de sentencias de las líneas 2 a la 4 (encerradas entre llaves) se ejecutan como un único bloque si se cumple que x>=0 y en otro caso se ejecuta la sentencia 6. Otros lenguajes como Haskell, utilizan la sangría como sustitución de llaves para indicar anidamiento, como en la siguiente función: i raices x : | numSoluciones 3 | numSoluciones
x == 0 -> x == 2 ->
[] [sqrt(x),
-sqrt(x)]
Además las abstracciones de control estructuradas se pueden anidar unas dentro de otras. Por ejemplo en C: 1 if (x > 0) { 2 n u m S o l u c i o n e s = 2; 3 rl = sqrt (x) ;
PARADIGMAS DE LA COMPUTACIÓN é
4 r2 = - r l ; 5 } else if (x
«. == 0)
{
6
numSoluciones
7
rl
= 1 ;
= 0.0;
s
}
9
else n u m S o l u c i o n e s
= 0;
Otro mecanismo muy útil para estructurar el control es el subprograma. Necesita una declaración, con un nombre y un conjunto de acciones a realizar que se abstraen bajo dicho nombre. Esta declaración es similar a la declaración de variable y de tipo. En segundo lugar es necesario que el subprograma sea llamado o invocado en el punto en que las acciones deben ejecutarse. Una llamada a un subprograma es un mecanismo mas complejo que las sentencias condicionales o los bucles, puesto que requiere el almacenamiento del estado del programa en el punto de llamada en el entorno de ejecución. Típicos ejemplos de subprogramas son los procedimientos de Pascal o los métodos de Java. Un mecanismo de abstracción muy cercano al de subprograma es el de función, que es un subprograma que devuelve un resultado tras ser invocado. De hecho en algunos lenguajes como C, los procedimientos se consideran como funciones nulas (que no devuelven un valor). La diferencia mas importante entre procedimientos y funciones es que las funciones se corresponden con la abstracción matemática de función, por lo que pueden entenderse independientemente del estado del entorno de ejecución. Las funciones constituyen la base de la programación funcional, que se estudiará en el capítulo 2. A continuación se incluye un ejemplo en Ada que calcula el máximo común divisor de los enteros u y v (sus parámetros): 1 function g c d ( u , v : i n i n t e g e r ) 2 y, t, z: i n t e g e r ; 3begin 4 z : = u ; y := v ; 5 lOOp 6 exit when y = 0; 7 t := y ; 8 y := z mod y; 9 z := t; 10 end loop; u return z; 12 end g c d ;
return i n t e g e r
is
En el capítulo 6 se estudiarán las abstracciones de control estructuradas. • Las abstracciones de control de tipo unitario permiten agrupar una colección de subprogramas como una unidad en sí misma e independiente del programa. De esta
I N T R O D U C C I Ó N A LOS P A R A D I G M A S DE C O M P U T A C I Ó N
11
forma, aislando partes del programa cuyo funcionamiento no es necesario conocer en detalle, se mejora la comprensión del mismo. Esencialmente son idénticas a las abstracciones de datos unitarias (y generalmente se implementan con módulos y paquetes al igual que aquellas). Simplemente varía el enfoque, que en esta ocasión se orienta más a las operaciones que a los datos. No obstante mantienen las propiedades de las abstracciones de datos unitarias como la reutilización mediante la creación de bibliotecas. upo de abstracción de control difícil de clasificar en alguno de los niveles anteriores es los mecanismos de programación en paralelo, que los lenguajes modernos suelen r. Java por ejemplo, contiene los mecanismos de hilos (trayectorias de control ejecu- por separado dentro del entorno Java). Ada contiene el mecanismo de tarea para lo o aunque se puede clasificar como una abstracción unitaria, mientras que los hilos y procesos de Java son clases y por lo tanto son abstracciones estructuradas. Abstracción Básica Estructurada Unitaria
de Control asignación goto bucles condicionales tipos estructurados subprogramas módulos paquetes de Datos tipos atómicos variables
Tabla 1.1: Abstracciones en los Lenguajes de Programación En la tabla 1.1 se pueden ver, a modo de resumen, las diferentes abstracciones de los lenguaíes de programación. Finalmente, indicar que si un lenguaje de programación sólo necesita describir computaciones, entonces sólo necesita mecanismos suficientes para describir todos los cálculos que puede llevar a cabo una máquina de Türing, puesto que cualquier máquina de Turing puede ejecutar cualquier cálculo conocido en un ordenador. Un lenguaje de este tipo se conoce como lenguaje completo en Türing, debe incluir variables enteras y aritméticas, así como la ejecución de sentencias de forma secuencial, incluyendo sentencias de asignación, condicionales ( i f ) y bucles (while).
1.2
Introducción a los paradigmas de computación
4 Inicialmente los lenguajes de programación se basaron en el modelo de computación Von Neumann, que propuso que el programa se almacenara en la máquina antes de ejecutarse y a su vez en:
12
P A R A D I G M A S DE LA COMPUTACIÓN
1. La ejecución secuencial de instrucciones. 2. El uso de variables para la representación de las posiciones de memoria. 3. El uso de la asignación para cambiar el valor de las variables.
Estos lenguajes se conocen como lenguajes imperativos, porque sus instrucciones representan órdenes. También se les ha denominado procedurales, aunque no tengan nada que ver con el concepto de abstracción de procedimiento. La mayoría de los lenguajes de programación son imperativos, pero no es requisito que la computación sea una secuencia de instrucciones donde cada una opere sobre un dato (esto se conoce como cuello de botella de Von Neumann), sino que la computación puede ser paralela, actuar sobre diferentes datos simultáneamente, o ser no determinista e independiente del orden. Hay otras formas de describir la computación de forma independiente al modelo Von Neumann, por lo que los lenguajes imperativos se consideran un paradigma o patrón (conocido como paradigma imperativo) para otros lenguajes de programación. Dos paradigmas diferentes al anterior, basados en abstracciones matemáticas, son el paradigma funcional, que usa la noción de función según se plantea en el lambda cálculo, y el paradigma lógico que se basa en la lógica simbólica. Permiten que tareas muy complejas se describan precisa y concisamente, facilitando la verificación de los programas (comprobar si el programa se ejecutará correctamente). En alguna bibliografía se denomina programación declarativa al grupo formado por la programación funcional y la lógica, por la gran diferencia de sus modelos de computación con los del resto de lenguajes de programación. En estos, las propiedades se declaran y no se especifica la secuencia de su ejecución. También se les denomina lenguajes de muy alto nivel o de quinta generación. Un cuarto paradigma es el de la programación orientada a objetos, que facilita la reutilización de programas y su ampliación, siendo mas natural la elaboración de código que se quiere ejecutar. Sin embargo de alguna manera este paradigma es también imperativo pues se basa en una ejecución secuencial sobre un conjunto cambiante de posiciones de memoria. La diferencia es que los programas están formados por pequeñas piezas de código, cuyas interacciones están controladas y se cambian fácilmente. En la práctica este tipo de programación tiene dificultad en predecir con precisión el comportamiento y determinar la corrección de los programas. Actualmente es un estándar ampliamente utilizado. A continuación se introducen con algo mas de detalle los paradigmas de orientación a objetos, funcional y lógico, utilizando un mismo ejemplo (calcular el máximo común divisor de 18 y 8) para iniciar el conocimiento de sus similitudes y diferencias, aspecto muy interesante ya que, en general, los lenguajes de programación actuales no se pueden clasificar únicamente en un paradigma, pues suelen contener características de diferentes paradigmas.
I N T R O D U C C I Ó N A LOS P A R A D I G M A S DE C O M P U T A C I Ó N
1
13
Programación orientada a objetos
paradigma se basa en la idea de que un objeto se puede describir como una colección posiciones de memoria junto con todas las operaciones que pueden cambiar los valores ¿ichas posiciones. Un ejemplo muy básico de objeto es una variable con operaciones de ^znación de valor y de recogida de su valor. la mayoría de los lenguajes orientados a objetos, los objetos se agrupan en clases que rsentan a todos los que tienen las mismas propiedades. Estas clases se definen mediante laraciones parecidas a las de los tipos estructurados en C o Pascal. Tras la declaración de clase, se pueden crear objetos concretos a partir de la misma, mediante la instanciación la clase. implementar el ejemplo del máximo común divisor en orientación a objetos se necesita operación sobre objetos de tipo entero (gcd) y como los enteros ordinarios en Java no >on objetos reales (por cuestiones de eficiencia), hay que incluir a los enteros en una nueva dase 4 que defina el objeto entero con la operación de máximo común divisor: public class I n t W i t h G c d { public class I n t W i t h G c d ( int v a l ) { v a l u é public int i n t V a l () { return v a l u é ; } public int g c d ( int v ) { int z = v a l u é ; int y v; while ( y • - 0 ) { int t = y; y = z % y; z = t;
val;
}
return z;
}
private int v a l u é ;
En este ejemplo se define la nueva clase mediante: 1. Un constructor en la línea 2 (con el mismo nombre que la clase, pero sin tipo de salida). Los constructores asignan memoria y aportan los valores iniciales para los datos del objeto. En este caso el constructor necesita un entero, que es el valor del objeto. 2. Un método de acceso a este valor ( i n t V a l en la línea 3). 4
Dado que no es posible añadir métodos a clase I n t e g e r de Java. Por eso es necesario crear una clase completamente nueva.
14
P A R A D I G M A S DE LA C O M P U T A C I Ó N
3. El método gcd (definido en las líneas de la 4 a la 13), con un único valor entero, ya que el primer parámetro es el valor del objeto sobre el que se llama a gcd. 4. El entero v a l u é queda definido en la línea 14. El constructor y los métodos se definen con acceso público, para que puedan ser llamados por los usuarios, mientras que los datos de la línea 14 son privados para que no sean accesibles desde el exterior. La clase IntWithGcd se utiliza definiendo un nombre de variable para contener un objeto de la clase: IntWithGcd x ; . Al principio la variable x no contiene la referencia a un objeto, por lo que hay que instanciarla con la sentencia: x = new I n t W i t h G c d ( 8 ) ; A continuación se llamaría al método gcd mediante: int y = x . g c d ( 1 8 ) ;
Y tras la ejecución de esta sentencia, la variable y contendrá el valor 2, que es el máximo común divisor de 18 y 8. En este ejemplo, el objeto de datos contenido en x, está enfatizado al colocarlo en primer término de la llamada (en vez de utilizar gcd (x, 18)) y al darle solo un parámetro a gcd.
1.2.2
Programación funcional
La computación en el paradigma funcional se fundamenta en la evaluación de funciones o en la aplicación de funciones a valores conocidos, por lo que también se denominan lenguajes aplicativos. El mecanismo básico es la evaluación de funciones, con las siguientes características: • La transferencia de valores como parámetros de las funciones que se evalúan. • La generación de resultados en forma de valores devueltos por las funciones. Este proceso no involucra de ningún modo a la asignación de una variable a una posición de memoria, aspecto que le aleja de la programación orientada a objetos. Tampoco las operaciones repetitivas se representan por ciclos (que requieren de variables de control para su terminación), sino mediante las funciones recursivas, un mecanismo muy potente. Que un lenguaje de programación funcional prescinda de las variables y de los ciclos, ofrece ventajas relacionadas con la/verificación de los programas. Volviendo al ejemplo de calcular el máximo común divisor (gcd), dicha función en un lenguaje funcional como Haskell sería:
I N T R O D U C C I Ó N A LOS PARADIGMAS DE COMPUTACIÓN
U V
v == 0 -> otherwise
15
u -> g c d v
(mod u v)
la linea 1 se define la cabecera de la función gcd y sus dos parámetros formales u y v. la línea 2 se comprueba si v es igual a 0, en cuyo caso se devuelve directamente el valor en ido en el parámetro u. otro caso, la línea 3 establece la recursión, llamando nuevamente a la función gcd con parámetros v y el resto de dividir u entre v (mod u v). . para calcular el máximo común divisor entre 18 y 8, se deberá evaluar la expresión: gcd 18 8 nos devolverá 2. programación funcional se estudiará con más detalle en el capítulo 2.
Programación lógica un lenguaje de programación lógica, un programa está formado por un conjunto de tencias que describen lo que es verdad o conocido con respecto a un problema, en vez indicar la secuencia de pasos que llevan al resultado. No necesita de abstracciones de trol condicionales ni de ciclos ya que el control lo aporta el modelo de inferencia lógica subyace. Li definición de máximo común divisor (gcd) es la siguiente: • El gcd de u y v es u si v es 0. • El gcd de u y v es el gcd de v y de u mod v, si v no es 0. - puede programarse directamente en un lenguaje de PROgramación LOGica como es Prolog, con el predicado (que podrá ser verdad o falso) gcd (U, V, X), que se entiende como ~ es verdad que el gcd de U y V es X ': red (U, 0 , U) . red (U, V, X) : - n o t (V = 0) , Y is U m o d V, g c d ( V , Y, X) .
\sí. para calcular el máximo común divisor entre 18 y 8, se deberá escribir la consulta PROLOG:
16
P A R A D I G M A S DE LA COMPUTACIÓN
?-
gcd(18,8,X) .
que nos indicará que para ser cierto es necesario que X valga 2. En Prolog un programa es un conjunto de sentencias, denominadas cláusulas, de la forma: a : - b, c, d. que es una afirmación que se entiende como "a es cierto, o resoluble, si b, a continuación c y finalmente d son ciertos o resolubles en este orden \ A diferencia de las funciones en la programación funcional, Prolog requiere de variables para representar los valores de las funciones, aunque no representan tampoco posiciones de memoria. En Prolog las variables se distinguen sintácticamente de otros elementos del lenguaje (por ejemplo, empezando por mayúsculas). El lenguaje Prolog ha mostrado su interés para la programación de problemas complejos, cuando el uso de la recursividad sea necesaria y cuando no se conozca cómo o cuáles son los pasos para calcular o alcanzar un resultado. Además, casi todos los entornos de programación Prolog disponen de formas de comunicarse con otros lenguajes de programación, lo que permite escoger el paradigma de programación mas adecuado para cada parte del programa (cálculos complejos, en lenguaje C o Java, usando los hilos de Java para interfaces, etc). Ha mostrado, junto con la programación funcional, toda su capacidad en problemas clásicos en el área de la Inteligencia Artificial. El estudio de la Programación Lógica se realizará en mayor profundidad en el capítulo 3.
1.3
Descripción de los lenguajes de programación
Los lenguajes de programación deben describirse de manera formal, completa y precisa. Esta descripción ha de ser, además, independiente de la máquina y de la implementación. Para ello se utilizan habitualmente estándares aceptados universalmente, ya que de esta formalización dependen tanto el diseño del propio lenguaje de programación como la comprensión del comportamiento del programa escrito por los programadores. Sin embargo no todos los niveles de descripción de un lenguaje disponen de un estándar para ello. Los elementos fundamentales para la definición de un lenguaje de programación son los siguientes: • El léxico o conjunto de las "palabras" o unidades léxicas que son las cadenas de caracteres significativas del lenguaje, también denominados tokens. También son unidades léxicas los identifícadores, los símbolos especiales de operadores, como o 44 87 (ambos inclusive). • [1, 3. . 10] se debe interpretar como la lista que comienza con el 1. a continuación tiene el 3 y a partir de ahí, cada elemento se calcula sumando al elemento anterior la diferencia entre el segundo y el primer elementos, siempre y cuando el resultado sea menor o igual que el último número. Es decir, la lista del ejemplo es [ 1 , 3 , 5 , 7 , 9 ] . • [10. .] representa la lista (potencialmente infinita) formada por I números enteros mayores o iguales que 10. • [ 1 , 3 . . ] representa la lista (potencialmente infinita) que comienza c el 1, le sigue el 3 y cada elemento a partir de ahí se calcula suman al elemento anterior la diferencia entre el segundo y primer element Es decir, esa lista representa la lista de los números impares mayo que 0. * Listas definidas por comprensión (o intensión), es decir, describien los elementos que la componen en lugar de tener que detallar todos ell Más adelante (en la página 74) se tratará más profundamente este tipo
45
INTRODUCCIÓN AL LENGUAJE HASKELL
listas, aunque se deja como ejemplo adelantado la definición de la lista de todos los números impares (que también se puede definir como f 1, 3 . . ] ): [
p
I
p
?
0
\
salvo las siguientes combinaciones que tienen un uso especial: =>
Las funciones definidas mediante un símbolo de operador se utilizan de forma infija. es decir, entre sus parámetros de entrada. Esto significa que sólo es posible definir funciones mediante símbolos de operador si éstas tienen exactamente dos parámetros de entrada. Por ejemplo: x
+
y
46
PROGRAMACIÓN FUNCIONAL
Toda función definida mediante un identificador de función puede ser utilizada de forma infija (como un operador) sin más que encerrar su identificador entre comillas inversas, como x s u m a \ Así suma 3 5 puede reescribirse como 3 ' s u m a ' 5. Análogamente, todo operador puede ser utilizado de forma prefija (como si fuera un identificador) si se encierra entre paréntesis. Por ejemplo, x * y puede reescribirse como (*) x y. Dado que es posible escribir expresiones como: 2 * 3
+ 1
al trabajar con funciones definidas mediante operadores hay que tener en cuenta dos factores
importantes: la precedencia y la asociatividad. El primero de ellos permite desambiguar expresiones como la anterior, que se puede interpretar como "sumar 1 al resultado de multiplicar 2 por 3" (lo que daría 7) o bien como "multiplicar por 2 el resultado de sumar 3 más 1" (lo que daría 8). Esto se resuelve en Haskell asignando una precedencia a cada operador, mediante un entero entre 0 y 9. Así, un operador con más precedencia se aplicará primero. En el ejemplo que se ha visto, la multiplicación tiene más precedencia (un valor de 7) que la suma (con un valor de precedencia de 6), por lo que esa expresión se interpreta correctamente desde el punto de vista de las matemáticas y su resultado es 7. La aplicación de funciones definidas con un identificador tiene más precedencia que cualquier símbolo de operador. Así pues: f
x + 1
debe entenderse como sumar 1 al resultado de aplicar la función f a x, no como el resultado de aplicar la función f a x+1, lo que se escribiría: f
( x + 1 )
Por otro lado, la asociatividad permite desambiguar expresiones como: 3 - 5 - 2
que puede interpretarse como "restar 3 menos 5, para luego restar 2" (obteniendo —4). o bien como "restar 3 menos el resultado de restar 5 menos T" (lo que daría 0). Esto se resuelve asignando una regla de asociatividad a cada operador de forma que se elimine la ambigüedad de ese tipo de expresiones. Así, se dice que un operador cualquiera representado mediante el símbolo (#):
47
INTRODUCCIÓN AL LENGUAJE HASKELL
\socia a la derecha: si x # y # z se debe interpretar como x # ( y # z ). Ejem:ian a la derecha son && y I I (conjunción y disyunción p
\ M K i i i a i a i £ M u i c i u a . ma # y # z se debe interpretar como (x # y ) # z. Ejemplos de operadores que asocian a la izquierda son +. - o * (suma, resta y producto respectivamente).
Í
• No asocia: si no se admite una expresión como x # y # z. Por ejemplo == (operador de igualdad) y, en general, todos los de comparación. 1 permite definir las reglas de precedencia y asociatividad de los operadores mediante laración (que debe ser única para cada operador en concreto) utilizando la siguiente is: asociatividad
precedencia
lista_de_operadores
(y comenzando por el final): • l i s t a _ d e _ o p e r a d o r e s es una lista de símbolos de operador separados por comas. • p r e c e d e n c i a es un número entre 0 y 9 representando la precedencia de los operadores de la lista. • a s o c i a t i v i d a d ha de ser i n f i x r para indicar que los operadores de la lista asocian a la derecha, i n f i x l para indicar que asocian a la izquierda e i n f ix para indicar que no asocian. ejemplo, los operadores de suma y resta están definidos como: infixl
6 +,
lo que el valor correcto de 3 - 5 - 2 es —k al ser la resta un operador que asocia a la rda. ición ecuacional y Encaje de Patrones definición de una función se realiza mediante una serie de ecuaciones con el siguiente ato: identificador
.
.
.
=
48
PROGRAMACIÓN FUNCIONAL
donde cada expresión a representa uno de los n (número que se denomina aridad de la función) argumentos de entrada de la función y se denomina patrón. Si una función se debe definir con más de una ecuación, hay que recordar lo siguiente: • Todas las ecuaciones deben definirse juntas. • Todas las ecuaciones deben tener la misma aridad. • Sólo se aplicará la definición de una de las ecuaciones. La forma de seleccionar qué definición se va a aplicar se denomina encaje de patrones (pattern matching). En este proceso se evalúan los argumentos de la función que sean necesarios para comprobar si los valores de dichos argumentos pueden encajar con los patrones de las diferentes ecuaciones. La evaluación perezosa es la encargada de evaluar los argumentos hasta que es posible determinar si encaja con el patrón de una de las ecuaciones, en cuyo caso se aplicará dicha ecuación. Si un argumento encajase con el patrón de dos o más ecuaciones, se aplicará siempre la primera de ellas según el orden textua-1 en el que se hayan escrito en el programa. En caso contrario, si ninguna de las ecuaciones pudiera encajar todos los patrones, se generaría un error y se detendría la ejecución del programa. A continuación se presentan algunos patrones básicos que pueden ser utilizados en la definición de las funciones: • Patrones constantes: un patrón constante representa un dato de cualquier tipo de datos (número, carácter, una lista...) y sólo encaja con un argumento de entrada que coincida exactamente con dicha constante. Por ejemplo: 1 f 2 f
1 = True 2 = False
La función f toma un valor entero y devuelve T r u e si dicho valor es 1 y F a l s e si e l valor es 2. Para cualquier otro valor de entrada, se generaría un error indicando que no se puede calcular el valor de la función. Otro ejemplo tomado del Standard Prelude sería la definición de la función not: 1 not True = False 2 not False = True
• Patrones variable: es el tipo más básico de patrón que encaja con cualquier argumento de entrada. Una variable se representa mediante un identificador de variable
INTRODUCCIÓN AL LENGUAJE HASKELL
49
ido igual que un identificador de función) y puede ser referenciado dentro de nición de la función. Por ejemplo: x
y
=
x
+
y
x e y son patrones variable que encajarán con cualesquiera valores que se pase Li función (siempre que sean de los tipos adecuados). nes anónimos: se representan mediante el símbolo _ y encajan con cualquier mentó de entrada con independencia de su tipo, aunque no permiten referenciar argumento dentro de la definición de la función. Su uso es útil cuando no es irio conocer el valor de uno de los argumentos de la función para devolver el ltado. Por ejemplo, la función: : e m p r e 2
x
=
2
necesita conocer el valor de x, por lo que podría ser reescrita así: =iempre2
_=
2
Patrones para listas: cuando se quiere trabajar con listas, se pueden utilizar los siguientes tipos de patrones: -
[ ], que es una lista vacía. Por lo tanto, se puede considerar como un patrón constante que sólo encajará con una lista vacía.
-
[x], que encajará con una lista de un único elemento al cual se referenciacomo x. Este patrón puede extenderse con el número de elementos que se necesiten. Así el patrón [x, y ] encajará con una lista de exactamente dos elementos, siendo el primero referenciado como x y el segundo como y.
-
( x : x s ) , encajará con una lista de al menos un elemento referenciado como x y una cola (que puede ser vacía) referenciada como xs. Al igual que el anterior, este patrón puede extenderse a cualquier número de elementos, por lo que ( x : y : zs) encajará con una lista de, al menos, dos elementos (x e y) y una cola (zs).
Por ejemplo, una función que sume todos los elementos de una lista se puede definir como sigue: suma
[ ]
=
suma
( x : x s )
(3 =
x
+
suma
xs
PROGRAMACIÓN FUNCIONAL
Es decir, si la lista a sumar tiene, al menos, un elemento x, se suma el valor de dicho elemento al resultado de sumar todos los elementos de la lista xs (según la ecuación de la línea 2). Por el contrario, si la lista a sumar es vacía, el resultado de la suma será directamente 0 (según la ecuación de la línea 1). Patrones para tupias: al igual que con las listas, es posible definir patrones para trabajar con tupias. La forma de hacerlo será referenciar todos los elementos de la tupia encerrándolos entre paréntesis. Entonces: (x, y, z) representa una tupia formada por tres elementos los cuales se referencian como x, y y z respectivamente. Mientras que: (a,_) representa una tupia de dos elementos, el primero de los cuales se referencia como 5 y cuyo segundo elemento encajará con cualquier valor de cualquier tipo, al haberle representado mediante un patrón anónimo, pero no podrá ser referenciado. Patrones con nombre: todo patrón no anónimo puede ser nombrado para referencia] el argumento completo. Esto es especialmente útil en patrones para listas o tupias Por ejemplo, si se quiere definir una función que reciba una lista y devuelva la misrru lista, pero con el primer elemento duplicado, se podría hacer así: duplicaCabeza
(x:xs)
= x:x:xs
En el código de la función es necesario referenciar la lista al completo, para lo cua se necesita utilizar nuevamente todo el patrón x : x s . Sin embargo es posible darle \ ese patrón el nombre 1 y referendario con ese nombre: duplicaCabeza
l@(x:xs)
= x:l
lo cual, si el patrón es complejo, ayuda a evitar confusiones. Patrones aritméticos: este patrón se utiliza para valores enteros y tiene la forma: ( n + k ) donde k es una constante natural. Este patrón sólo encajará con un número entemayor o igual que k, asociando a la variable n el valor de dicho número menos k. Aunque HUGS acepta el uso de este tipo de patrones, no es aconsejable utilizarle* ya que han sido eliminados de la última revisión del lenguaje (Haskell 2010).
INTRODUCCIÓN AL LENGUAJE HASKELL
51
lenguaje Haskell permite definir nuevos tipos de datos, también permite utilizar para dichos tipos de datos. En la sección 2.3.4, dedicada a la construcción de ~rs de datos, se mostrará la forma de definir tipos y cómo definir y utilizar patrones jar con ellos. * case utilizar patrones en cualquier punto de una expresión utilizando una expresión emplea una sintaxis muy similar a las sentencias case presente en múltiples de programación: case
e x p r e s i ó n
of
p a t r ó n i
->
r e s u l t a d o i
patrón2
->
r e s u l t a d 0 2
patrón,,
->
resultado,,
los patrones deberán ser del mismo tipo, que habrá de coincidir (obviamente) con el expresión. También será necesario que los resultados sean también del mismo tipo, el tipo devuelto por la expresión case. ^ definidas a trozos: guardas se pudiera definir una función a base de ecuaciones mediante encaje de patrones, no risible definir una función como el valor absoluto. Esta es su definición matemática: x si x >= 0 —x si x < 0 de patrones sólo permitiría encajar con una variable x, pero no distinguir si el valor a variable es positivo o negativo. Para ello se pueden definir guardas dentro de una de la siguiente forma: i d e n t i f i c a d o r
< l i s t a _ d e _ p a t r o n e s >
|
guardai
=
expresión]
|
guarda,,
=
expresión,,
línea conteniendo una guarda debe presentar una indentación inicial (y si hay varias s, todas deben presentar la misma indentación) con respecto a la línea de definición la función, para que no se confundan con la definición de otra función.
52
PROGRAMACIÓN
FUNCIONAL
Cada guarda es una expresión de tipo B o o l y las expresiones han de ser del mismo tipo, que será el tipo del valor devuelto por la función. A la hora de evaluar la función, se evalúan la? guardas por orden textual (es decir, de la 1 a la /*) y la primera que devuelva un valor True será la que se aplique, devolviéndose la expresión correspondiente. Así pues, la función valor absoluto puede definirse como sigue: 1 abs
x
2
I
X
>=
3
|
X
<
0 0
= =
X - X
Es posible utilizar la constante o t h e r w i s e como una guarda para indicar que esa condición deberá ser utilizada si no se hubiera cumplido ninguna otra guarda previa. En Haskell. o t h e r w i s e es una constante cuyo valor está siempre definido como True, por lo que una guarda o t h e r w i s e siempre se va a cumplir. De esta forma se puede reescribir la línea 3 de la función a b s , quedando así su definición: 1 abs
x
2
i
X
3
| otherwise
>=
0
=
X
-x
=
Definiciones locales Es posible definir subfunciones dentro de una función. Al igual que con las guardas, ladefiniciones locales deben estar indentadas y si se definen varias subfunciones, todas ellahan de conservar la misma indentación. La forma de definir subfunciones es la siguiente: de f i n i c i o n _ d e _ l a _ f u n c i ó n w h e r e s u b f u n c i o n ¡ •
•
•
s u b f u n c i o n „
Las subfunciones sólo pueden aparecer al final de una definición de función y sólo pueden ser utilizadas dentro del cuerpo de esa función, lo que incluye las propias subfunciones: i 2
f
x
y
=
(a
+
1)
*
(
where
3
a = div
(x + y)
2
4
c
(x
2
=
mod
+
y)
c
-
1
)
INTRODUCCIÓN AL LENGUAJE HASKELL
53
que las expresiones case permiten utilizar patrones en cualquier punto de una exes posible utilizar definiciones locales dentro de cualquier expresión utilizando la sintaxis: definición
let
in expresión
lo. la siguiente expresión: l e t
a
x
=
x
+
l
i
n
a
100
a 101.
anónimas !
ones anónimas (también conocidas como expresiones lambda) permiten introJefiniciones de funciones dentro de cualquier expresión. Considérese, por ejemplo, la
i r
x
mod
=
x
2
==
0
indica si un número x es par o no. quiere utilizar esta función dentro de una expresión, pero sin tener que definirla se 10 siguiente: (\x
->
mod
x
2
==
0)
4
ndo como resultado True. función error 11 ofrece una función predefinida que permite detener la evaluación de la expresión trar un mensaje informativo: la función error. Al ser invocada, esta función detiene letamente la evaluación y muestra por pantalla la cadena de caracteres que se le ha b como parámetro. Por ejemplo, si se quiere definir una función d i v i d e que reciba numerador y un denominador y devuelva el resultado de la división, se puede hacer lo nte: -de
vide
a
0
a b
=
error
= a
/
b
"No
se
p u e d e
d i v i d i r
por
0"
54
PROGRAMACIÓN FUNCIONAL
De esta forma, la ecuación de la línea 1 encajaría cuando el denominador fuese 0, deteniendo la ejecución del programa al tiempo que muestra el mensaje "No se puede dividir por 0".
2.3.3
El tipo de las Funciones
Las funciones, como ciudadanos de primera clase, también tienen un tipo que depende de los tipos de sus parámetros de entrada y de salida. A diferencia de otros lenguajes, en Haskell no es obligatorio definir el tipo de las funciones, ya que posee un algoritmo de inferencia automática de tipos que es capaz de deducir el tipo más genérico de las funciones, siempre que éstas estén bien definidas. En matemáticas, cuando se define una función como: doble(x)
=
2*x
se suele acompañar de su signatura, que indica cuales son su dominio (conjunto del que toman valores los parámetros de entrada) y su codominio (conjunto en el que toman los valores los datos de salida de la función. En el caso de la función d o b l e se puede considerar que dichos conjuntos son los reales, lo cual se escribiría como: d o b l e
:
—• SK
que indica que la función toma un elemento del conjunto de los números reales y devuelve, también, un elemento del mismo conjunto. El tipo de las funciones puede definirse utilizando la siguiente sintaxis: i d e n t i f i c a d o r
: :
e x p r e s i o n D e T i p o
donde e x p r e s i o n D e T i p o es una lista de los tipos de todos los parámetros de entrada tipo devuelto por la función separados todos ellos por operadores ->. Siguiendo con el ejemplo de la función d o b l e
::
doble,
y
su definición de tipo en Haskell sería:
F l o a t
->
ya que dicha función toma un parámetro real (de tipo tipo.
F l o a t
F l o a t )
y devuelve un valor del mis
Aunque (como ya se ha indicado) no es necesario definir explícitamente el tipo de u función, ya que Haskell es capaz de inferir el tipo más genérico de una función, si se de: incluir la definición de tipo de una función, ésta deberá situarse siempre antes de cualqu definición ecuacional de la función.
e
INTRODUCCIÓN AL LENGUAJE HASKELL
55
tante, para cualquier función (ya se haya explicitado su tipo o no) el intérprete de ii permite averiguar el tipo inferido para una función sin más que teclear: : t
i d e n t i f i c a d o r
intérprete. De esta forma, si se teclea: :t
doble
rete respondería: d o b l e
::
F l o a t
->
F l o a t
rfismo todos los ejemplos vistos hasta ahora, los tipos de datos de los parámetros de las funi han sido siempre concretos (enteros, reales, caracteres...). Sin embargo, en algunas _nes las funciones tienen sentido para varios tipos de datos. El ejemplo más sencillo la función id, cuya definición es: x
=
x
para cualquier parámetro de entrada x devuelve, precisamente, x, con independencia del del parámetro de entrada. Las funciones que tienen este comportamiento se denominan iones polimórficas En tal caso, la pregunta que surge es: ¿cómo se puede representar ipo de las funciones polimórficas? ello Haskell introduce las denominadas variables de tipo (las cuales también deben zar por una letra minúscula). Una variable de tipo denota cualquier tipo de datos. Así . el tipo de la función id es: id
::
a
->
a
decir, cualquier tipo de datos que se substituya por la variable de tipo a. En la ecuación Dpo de una función pueden utilizarse tantas variables de tipo como sean necesarias para tificar aquellos parámetros que acepten valores de diferentes tipos de datos. ionalmente, si alguno de los tipos sólo admitiera pertenecer a una clase de tipos (por pío, cualquier tipo numérico), se incluyen esas restricciones al comienzo de la exprede tipo separándolas de la lista de tipos de los parámetros por el símbolo =>. Por pío, el operador + tiene el siguiente tipo:
56
PROGRAMACIÓN
(+)
:¡
Num
a
=>
FUNCIONAL
a
->
a
->
a
Es decir, para cualquier tipo a perteneciente a la clase Num (que es la clase de todos los tipos numéricos), + recibe dos parámetros de dicho tipo y devuelve un resultado del mismo tipo. Currificación de funciones y aplicación parcial Considérese el siguiente ejemplo de la función F l o a t y devuelve un valor de tipo F l o a t : suma
::
suma
x
Float y
=
x
-> +
Float
->
suma,
que recibe dos parámetros de tipo
Float
y
Dado que el operador -> asocia a la derecha, la declaración de tipo de suma
::
f l o a t
->
(
f l o a t
->
f l o a t
suma
en realidad es:
)
Así pues, s u m a también es una función que toma un parámetro de tipo F l o a t y devuelve una función de tipo F l o a t - > F l o a t . De esta forma, las funciones en Haskell pueden currificarse (recuérdese que ese concepto proviene del lambda cálculo) ya que toda función f con n parámetros de entrada es, en realidad, una función f' con un único parámetro de entrada que devuelve una función con n — 1 parámetros de entrada. La definición de U función f' es la misma que la de la función f, salvo que el primer parámetro de f es tratado como una constante dentro de f ' . Si se tienen más de dos parámetros de entrada, es posible generalizar la currificación de lad funciones entendiendo que toda función con n parámetros de entrada es una función con rd parámetros de entrada (siendo m < n), cuya definición es la misma que la función originail pero sus m — n primeros parámetros se tratan como constantes. Gracias a la posibilidad de currificar las funciones, se puede definir por ejemplo la función! suma3
::
suma3
x
Float =
suma
-> 3
Float x
lo cual puede abreviarse como: suma3
::
suma3
=
Float suma
->
Float
3
La función s u m a 3 dado un valor de tipo F l o a t , devolverá el resultado de sumar 3 a dk J valor. Se dice que s u m a 3 es una función definida por la aplicación parcial de la funcKfl s u m a a su primer parámetro de entrada.
INTRODUCCIÓN AL LENGUAJE HASKELL
57
ones definidas como operadores también pueden beneficiarse de la aplicación parcuyo caso se denominan secciones de un operador. Es posible usar un operador con uno de sus argumentos, bien sea el primero o el segundo o incluso ninguno, es necesario recordar que todo operador binario definido de forma infija puede do de forma prefi ja sin más que encerrar su símbolo de operador entre paréntesis, secciones del operador / (división real) serían: •
), que toma dos valores y los divide.
•
5) que toma un valor y lo divide por 5.
•
5/) que toma un valor y devuelve el resultado de dividirlo 5 entre dicho valor. que sólo es posible crear secciones de operadores binarios, ya que los operadores - de negación (tanto booleana como numérica) no permiten la creación de secciones. "
es de orden superior
ejemplos anteriores se puede ver que al aplicar una función parcialmente a sus pas de entrada se obtiene una nueva función. Es decir, una función puede devolver resultado otra función. Por lo tanto, no es de extrañar que una función también pueda como parámetro de entrada otra función. que una función que tiene alguna función entre sus parámetros de entrada es una :~n de orden superior. Por ejemplo, la función: a p l i c a
f
x
y
=
f
x
y
función que recoge tres parámetros de entrada y devuelve el resultado de aplicar el o de ellos al segundo y al tercero. A continuación se deduce razonadamente el tipo la función a p l i c a : primer lugar, por el código de aplica, el parámetro de entrada ^3 dos parámetros de entrada. Como hasta ahora no se tiene tipos han de tener dichos parámetros, se utilizarán variables como el tipo del valor devuelto por la función. Por lo tanto, ahora es que el tipo más genérico para la función f sería: f
::
a
->
b
->
f ha de ser una función que ninguna información sobre de tipo para representarlos, la información que se tiene
c
continuación, se puede ver que el primer parámetro de la función f es el segundo de _ i c a , al igual que el segundo de f es el tercero de a p l i c a . Por lo tanto, llamando x al de la función f, se tiene que:
58
PROGRAMACIÓN FUNCIONAL a p l i c a : :
x ->
a
->
b ->
c
puesto que el resultado de a p l i c a es el resultado de f. Por último, se substituye x por el tipo de f, pero (por ser una función y dado que el operador -> asocia por la derecha) se encierra entre paréntesis para indicar que es un único parámetro de entrada y no tres. Por lo tanto, el tipo de a p l i c a será: aplica
::
(a ->
b ->
c)
->
a
->
b ->
c
que se debe leer como que a p l i c a recibe una función f, la cual necesita dos parámetros de entrada de tipos a y b y devuelve un valor de tipo c, y dos valores de tipos a y b para devolver finalmente un valor de tipo c.
2.3.4
Tipos de Datos Avanzados
Ya se ha visto cómo Haskell permite la creación de datos estructurados como son las listas y las tupias. A continuación se verá cómo se puede dar nombre a nuevos tipos de datos y qué mecanismo proporciona el lenguaje para la creación de tipos de datos más complejos. Los conceptos sobre tipos de datos que se van a encontrar a continuación aplicados a Haskell serán estudiados con mayor profundidad y generalidad en el capítulo 5 del libro. El valor indefinido: Bottom La función constante undef i n e d siempre produce un error al ser evaluada y su tipo es a. por lo que representa un valor indefinido de cualquier tipo. De esta forma, todos los tipos de datos en Haskell (los predefinidos y los definidos por el programador) pueden contener un valor indefinido. Semánticamente, este valor se denota como _L (se lee bottom) y representa el valor que tienen aquellas expresiones cuya evaluación produce un error o no termina. Sinónimos de tipos: type
Un sinónimo de tipo consiste en la definición de un nuevo nombre para un tipo ya existente sin que en realidad se cree un nuevo tipo de datos, ya que ambos nombres (el nuevo ja el original) representarán el mismo tipo de datos y a todos los efectos serán totalmente equivalentes.
Para definir sinónimos de tipos se utiliza la palabra reservada t y p e : 1 type 2 type
Caracter String =
= Char [Charj
INTRODUCCIÓN AL LENGUAJE HASKELL
59
definición de la línea 1 se asigna el nombre C a r a c t e r al tipo de datos predefinido . mientras que en la definición de la línea 2 se indica que el tipo String es una lista de tos de tipo Char. Así pues, las definiciones anteriores pueden reescribirse como: C a r a c t e r String
=
=
Char
[ C a r a c t e r ]
que Char y Caracter son tipos sinónimos. ejemplo clásico de uso de t y p e es la definición de un tipo de datos para almacenar s complejos en forma de tupia de dos números reales: Complex
=
(Double,Double)
Complex (3.4,2.5)
ición de nuevos tipos de datos: data >ók) se pudiese utilizar t y p e , la creación de nuevos tipos de datos en Haskell estarí lia a los tipos predefinidos y sus combinaciones utilizando listas y tupias. Sin embargo, ell ofrece la posibilidad de crear nuevos tipos de datos utilizando para ello la palabra ada data:
• Tipos enumerados: estos tipos de datos consisten en una enumeración (de ahí su nombre) de todos los valores que pertenecen a dicho tipo. Por consiguiente, los tipos enumerados han de tener una cantidad finita de elementos. Si se quiere crear un tipo enumerado para almacenar las luces de un semáforo, se podría hacer así: i data
S e m á f o r o
=
Rojo
|
A m a r i l l o
deriving
I
V e r d e
(Eq,Ord,Show,Read}
Es decir, se utiliza la palabra reservada d a t a seguida del identificador del nuevo tipo de datos (el cual ha de comenzar por una letra mayúscula), a continuación se encuentra el símbolo = y una enumeración de todos los elementos del nuevo tipo S e m á f o r o (los cuales también han de comenzar con una letra mayúscula), separados por el símbolo |. Mediante el uso de la palabra reservada d e r i v i n g , Haskell permite indicar que el nuevo tipo deriva de alguna de las clases de tipos ya existentes y, de este modo, sobrecargar operaciones ya definidas en esas clases de forma que puedan ser utilizadas
60
PROGRAMACIÓN FUNCIONAL
en el nuevo tipo de datos sin necesidad de volverlas a definir. Las clases de tipos más utilizadas son: - Eq: si un tipo deriva de esta clase significa que es posible comparar valores de dicho tipo mediante los operadores de igualdad, con la propiedad de que un valor sólo es igual a sí mismo. De tal forma R o j o = = R o j o y V e r d e = = V e r d e , pero R o j o / = V e r d e . - Ord: que un tipo derive de esta clase implica que se induce un orden en los valores del tipo. Dicho orden es en el que los valores aparecen en la definición del tipo. Por lo tanto R o j o < A m a r i l l o < V e r d e . -
clase que permite que el intérprete pueda mostrar un valor de este tipo. Si un tipo deriva de esta clase, el nombre del valor será la representación textual que el intérprete mostrará por pantalla. Así. el valor R o j o se imprimirá como la cadena " R o j o " .
-
el pertenecer a esta clase permite el paso inverso al de la clase S h o w , es decir, convertir cadenas de caracteres en elementos del tipo. Para ello será necesario indicar a qué tipo se pretende convertir la cadena. Por ejemplo la expresión
Show:
Read:
r e a d
devuelve el elemento
Rojo
" R o j o " : : S e m á f o r o
del tipo
Semáforo.
En el P R E L U D E se puede encontrar que el tipo de datos de datos enumerado: data
Bool
= False
Bool
se define como un tipo
| True
A la hora de utilizar un tipo de datos enumerado en una función, se pueden emplear tanto patrones constantes: 1 not
:: B o o l
->
2 not
True
=
False
Bool
3not
False
=
True
como variables o indefinidos: 1 (&&), 2 False
(II) && _
:: B o o l = False
3 True
&&
x
=
x
4 False
||
x
=
x
5 True
I I __ =
True
->
Bool
->
Bool
61
INTRODUCCIÓN AL LENGUAJE HASKELL
iiión de tipos: la unión de tipos permite agrupar en un único tipo elementos de dos diferentes. Por ejemplo: W
ta B o o 1 e a n o E n t e r o
= Booleano
Bool | E n t e r o
Integer
forma que los elementos de este nuevo tipo de datos tendrán el siguiente aspecto: Booleano E n t e r o
True
0
sería posible definir una lista de elementos del tipo B o o l e a n o E n t e r o , salvando je esta forma la restricción de que las listas sólo pueden tener elementos del mismo tipo: listaMixta :: [BooleanoEntero] l . s t a M i x t a = [ Entero 0 , Booleano E n t e r o -1 ]
True
,
Booleano
False
,
Cuando se quiera utilizar un tipo de datos que sea una unión de tipos, en el patrón deberá incluir el identificador del tipo de datos que se pretenda utilizar en cada momento. Por ejemplo: « negación : negación ?i n e g a c i ó n
:: B o o l e a n o E n t e r o E n t e r o x = -x B o o l e a n o x = not x
> BooleanoEntero
» Producto cartesiano: los elementos de un tipo construido mediante el producto cartesiano de otros tipos están formados por tantas componentes como tipos se estén incluyendo en el producto. A todos los efectos, sería posible utilizar tupias para definir este tipo de datos, aunque de esta forma no se aprovecha todo el potencial que ofrece Haskell. Por ejemplo, sería posible definir un tipo de datos A s i g n a t u r a para almacenar la información sobre el nombre de una asignatura, el curso en el que se imparte y la dirección de su página web: data
Asignatura
= Asig
String
Integer
String
El constructor de tipo (en nuestro caso Asig) puede considerarse como una "función" 12 que recibe los valores de cada uno de los tipos que intervienen en el producto cartesiano y devuelve un valor del tipo producto: Aunque no es propiamente una función, pues éstas han de comenzar obligatoriamente con una letra minús-
62
PROGRAMACIÓN FUNCIONAL Asig
::
S t r i n g
->
I n t e g e r
->
S t r i n g
->
A s i g n a t u r a
Es decir, la forma de un elemento de este tipo es el constructor A s i g 1 3 seguido de tres valores, el primero de los cuales será una cadena de caracteres (que se utilizará para almacenar el nombre de la asignatura) el segundo un valor entero (que representará el curso en el que se imparte) y el tercero otra cadena de caracteres (que guardará la dirección de la página web de la asignatura). Por ejemplo: t l p
=
Asig
" T e o r i a
de
l o s
L e n g u a j e s
: / / w w w . l s i . u n e d . e s / t l p /
de
P r o g r a m a c i ó n "
2
" h t t p
n
Cuando se desee utiliza un tipo de datos producto en un patrón, será necesario encerrar todo el patrón entre paréntesis (para así indicar que se trata de un único parámetro y no de varios) y, al igual que en Los tipos enumerados, también se deberá incluir e. constructor del tipo: e s D e P r i m e r o
(Asig
x
_)
=
x
==
1
La función e s D e P r i m e r o recibe un valor de tipo A s i g n a t u r a y devuelve un valor ck tipo B o o l que indicará si dicha asignatura es de primer curso o no. Dado que pa ello sólo se necesita conocer el valor del curso en el que se imparte, es posible utiliz patrones anónimos para referenciar el nombre y la dirección web. Hasta aquí, el uso de d a t a para construir un tipo producto no difiere del resulta que se obtendría utilizando tupias. Sin embargo, al utilizar d a t a Haskell ofrece posibilidad de nombrar cada una de las componentes del tipo utilizando la siguien notación: A s i g n a t u r a
data
,
web
::
=
Asig
String
{
nombre
::
String
,
c u r s o
::
Integer
}
La definición de un tipo de datos producto de esta forma (en lugar de con una pía), es más cercana a los tipos de datos registro comúnmente presentes en lenguaj imperativos obteniendo una serie de ventajas como: — Los datos pueden ser definidos nombrando los campos. La definición anteri de la función constante t l p podría reescribirse, por tanto, como: tlp = A s i g
13
{ nombre
P r o g r a m a c i ó n "
,
u n e d . e s / t l p / "
}
=
"Teoria
c u r s o
=
2
de ,
los web
Lenguajes =
de
" h t t p : / / w w w . l s i .
No es obligatorio que el constructor de elementos del tipo sea diferente al constructor del tipo en sí. i pues, se podría utilizar d a t a A s i g n a t u r a - A s i g n a t u r a S t r i n g I n t e g e r S t r i n g sin ningún problema.
INTRODUCCIÓN AL LENGUAJE HASKELL
63
- Haskell crea automáticamente una serie de funciones que permiten acceder a cada una de las componentes directamente. En este ejemplo serían: nombre
::
c u r s o web
A s i g n a t u r a
::
::
A s i g n a t u r a A s i g n a t u r a
lo que permite reescribir la función e s D e P r i m e r o
x
=
c u r s o
x
==
-
-
>
S t r i n g
-
>
>
S t r i n g
e s D e P r i m e r o
I n t e g e r
de la siguiente forma:
1
- Es posible crear nuevos 1 4 datos a partir de otros ya existentes tan sólo modificando algunas de sus componentes. Por ejemplo, para crear otra asignatura de segundo curso se podría hacer: l p p
=
t l p
{
nombre
P r o c e s a d o r e s "
= ,
" L e n g u a j e s
web
=
de
P r o g r a m a c i ó n
y
" h t t p : / / w w w . l s i . u n e d . e s / l p p / "
}
- Tipos de datos recursivos: un tipo de datos será recursivo siempre que el tipo que está siendo definido aparece en su propia definición. De esta manera es posible definir tipos de datos con infinitos elementos. El típico ejemplo de este tipo de datos es la definición de los naturales mediante inducción: * El cero es natural. * Si n es natural, entonces su sucesor también es natural. En Haskell esa definición puede ser escrita directamente como sigue: data
N a t u r a l
=
C e r o
|
S u c e s o r
N a t u r a l
Al igual que en los tipos definidos mediante producto cartesiano, los constructores de tipos de datos recursivos pueden ser considerados como "funciones". En nuestro ejemplo, los tipos de dichas " f u n c i o n e s " serían: Cero
::
S u c e s o r
N a t u r a l : :
N a t u r a l
- >
N a t u r a l
Por lo tanto, se puede escribir: 1 uno
=
S u c e s o r
Cero
2 dos
=
S u c e s o r
uno
3 t r e s
=
S u c e s o r
dos
Recuérdese que en Programación Funcional las variables no representan una posición de memoria que ser modificada, por lo que al crear un nuevo dato mediante esta opción, el valor del dato original no se
PROGRAMACIÓN FUNCIONAL
A continuación se presentan diversos ejemplos de uso de patrones sobre este nuevo tipo de datos para implementar algunas funciones sobre él, aunque todas esas funciones podrían ser realizadas de forma más sencilla si el tipo de datos N a t u r a l derivase de las clases E q y O r d . Considérese una función que indique si un elemento del tipo N a t u r a l es mayor que dos. Esta función podría programarse como sigue: mayorQue2
(
mayorQue2
_
S u c e s o r =
(
S u c e s o r
(
S u c e s o r
x
)
)
)
=
True
False
Es decir, si el valor de entrada es el resultado de aplicar un mínimo de tres veces el constructor S u c e s o r a un dato x de tipo N a t u r a l , sea cual sea dicho x , se aplicaría la ecuación definida en la línea 1 devolviéndose T r u e . Si el dato de entrada no encajase con el patrón definido por la ecuación de la línea 1, significaría que en la construcción de dicho dato sólo se habría empleado el constructor S u c e s o r dos veces o menos, lo que implica que el natural representado no es mayor que d o s . De responder F a l s e en tal caso se encarga la ecuación de la línea 2. Para crear una función que indicase si un N a t u r a l es mayor que otro, se puede hacer lo siguiente: mayor
( S u c e s o r
mayor
Cero
=
mayor
( S u c e s o r
_)
Cero
=
True
False x)
( S u c e s o r
y)
=
mayor
x
y
La ecuación de la línea 1 dice que el S u c e s o r de cualquier N a t u r a l es, lógicamente, mayor que C e r o . La de la línea 2 dice que C e r o no es mayor que cualquier otro N a t u r a l . Por último, la ecuación de la línea 3 dice que el resultado de comparar dos elementos construidos utilizando el constructor S u c e s o r es el mismo que el resultado de comparar ambos elementos eliminando el constructor S u c e s o r más externo. Incluso es posible definir un valor infinito diciendo que es S u c e s o r de sí mismo n a t u r a l I n f i n i t o
=
S u c e s o r
n a t u r a l l n f i n i t o
Hay que notar que la evaluación de n a t u r a l l n f i n i t o nunca se detendría sí sola. Sin embargo, es posible utilizarlo en expresiones gracias a la evaluaci perezosa. Así, las expresiones: mayorQue2 mayor
devolverían
True,
n a t u r a l l n f i n i t o
n a t u r a l l n f i n i t o
n a t u r a l l n f i n i t o
Cero
puesto que no se necesita evaluar completamente el val para responder.
65
I N T R O D U C C I Ó N AL LENGUAJE H A S K E L L
Incluso la expresión: mayor
(Sucesor
undefir.ed)
Cero
también devolvería T r u e , debido a que el encaje de patrones aplicaría la primera ecuación de la función m a y o r . Los tipos de datos recursivos no tienen por qué limitarse a una reeursión lineal 13 . Por ejemplo, es posible definir árboles binarios de enteros de la siguiente forma: data
ArbBinEnt
=
A r b o l V a c i o
|
Nodo
Integer
ArbBinEnt
ArbBinEnt
Es decir, se tiene un constructor de datos A r b o l V a c i o que proporciona un árbol vacío (que no contiene ningún tipo de datos) y un constructor N o d o que (nuevamente) puede ser visto como una "función * cuyo tipo es: Nodo
::
I n t e g e r
->
ArbBinEnt
->
ArbBinEnt
->
ArbBinEnt
que recibe un entero y dos árboles binarios de enteros para producir un árbol binario de enteros cuya raíz contiene el entero que se le pasa como primer parámetro y de la que cuelgan los dos árboles binarios de enteros que se le pasan como segundo y tercer parámetros. • Tipos polimórficos: un tipo de datos polimórfico es aquel que puede contener valores de cualquier tipo. El ejemplo más sencillo es el de las listas, ya que en Haskell es posible construir listas de cualquier tipo. Volviendo al ejemplo que vio al hablar de los tipos de datos unión de varios tipos, es posible generalizarlo haciendo: data
TipoMixto
a
b
=
Tipol
a
|
Tipo2
b
de forma que el tipo BooleanoEntero se podría definir como un sinónimo de tipo: type
B o o l e a n o E n t e r o
=
TipoMixto
Bool
Integer
Así, para cualquier tipo a , el valor T i p o l T r u e sería un elemento del tipo T i p o M i x t o B o o l a , puesto que se ha utilizado el primer constructor de tipos, mientras que T i p o 2 ' a ' pertenecería al tipo T i p o M i x t o a C h a r . Volviendo al ejemplo de los árboles binarios de enteros, es posible generalizar esa definición a árboles binarios genéricos sin más que substituir el tipo concreto I n t e g e r por una variable de tipo, creando así un tipo de datos recursivo y polimórfico: 15
En un tipo recursivo lineal los constructores del tipo sólo pueden recibir, como máximo, un elemento del construido.
66
PROGRAMACIÓN
data
ArbBin
a
=
A r b o l V a c i o
|
FUNCIONAL
Nodo
a
ArbBin
ArbBin
cuyos nodos podrán contener valores de cualquier tipo, pero todos ellos del mismo.
2.3.5
Ejemplos de Funciones: trabajando con listas
Para finalizar con esta introducción al lenguaje Haskell, a continuación se van a presentar una serie de funciones que trabajan sobre listas. Todas estas funciones se encuentran ya definidas en el P R E L U D E de Haskell y su uso es muy frecuente en los programas. se presenta una posible implementación de estas funciones con intenciones didácticas. Posiblemente en el P R E L U D E se utilicen otras implementaciones equivalentes empleando funciones de orden superior en lugar de realizar una función específica para cada uno de los casos. Aquí
Contar los elementos de una lista Una función típica sobre listas es aquella que devuelve la cantidad de elementos de una lista dada. En Haskell esta función viene predefinida como l e n g t h . La forma de programar esta función sería considerar que: • Una lista vacía no tiene elementos, por lo tanto la función debe devolver 0. • Una lista que tiene, al menos, un elemento, tiene un elemento más que su propia cola Así pues, el código de la función l e n g t h será: length
[] =0
length
(x:xs)
=
1
+
length
xs
Esta función contará los elementos de la lista, pero no se detendrá hasta que haya tern nado de recorrer la lista entera, ya que la evaluación perezosa no puede predecir cuál es resultado de la evaluación hasta que no se haya evaluado por completo. Concatenar listas El objetivo de esta función es sencillo, dadas dos listas xs e ys, se desea obtener una li formada por la concatenación de ambas de forma que primero aparezcan los elementos xs y después los de ys. Obviamente, un requisito imprescindible es que ambas listas teng
INTRODUCCIÓN AL LENGUAJE H A S K E L L
67
tipo. En Haskell esto se realiza mediante la función (++), que se define entre is por tratarse de un operador infijo. de la función va a ser ir recorriendo la primera de las listas hasta agotar todos sus tos, de manera que cuando esta primera lista sea vacía, se llegue al caso base de la
• Si la primera de las listas es vacía, entonces el resultado de la concatenación será, directamente, la segunda lista. • Si la primera lista tiene un primer elemento, entonces ese elemento será el primero de la lista concatenación, mientras que la cola de la lista concatenación será la concatenación de la cola de la primera lista con la segunda lista. lo tanto, el código de la función (++) será: ys xs)
++
=
ys ys
=
x
:
(xs
++
ys)
extensión a esta función de concatenación de dos listas es la concatenación de una de listas, todas ellas del mismo tipo. Esto también se conoce como aplanar una lista listas y se realiza mediante la función c o n c a t . La forma de realizar esta función es iderar los siguientes casos: • Como siempre, en el caso básico en el que la lista de listas es vacía, la concatenación de todas sus listas será también vacía. • Ahora, si la lista de listas no es vacía, entonces tiene una primera lista xs y una lista de listas yss como cola. Por lo tanto, el resultado de la concatenación de xs y las listas que forman parte de la lista de listas yss será concatenar xs con el resultado de concatenar todas las listas de yss. ndo el código de concat
[] =
concat
( x s : y s s )
concat
el siguiente:
[] =
xs
++
(concat2
yss)
Combinar listas En ocasiones puede resultar útil combinar dos listas de elementos en una única lista de rupias. Para ello existe la función zip. Su funcionamiento es el que a continuación se describe:
68
PROGRAMACIÓN FUNCIONAL
• Si las dos listas tienen un primer elemento, entonces el primer elemento de la lista resultante sería la tupia formada por ambos elementos, mientras que la cola de la lista resultante será el resultado de aplicar z i p a las colas de ambas listas. • En caso contrario, si alguna de las listas no tuviera un primer elemento, entonces el resultado debería ser la lista vacía. Por lo que el código de z i p es: 1 zip
2 zip
(x:xs)
(y:ys)
= (x,y):(zip
xs ys)
= []
Estas ecuaciones deberán escribirse en ese orden, ya que si se invirtiera el orden la función resultante siempre devolvería la lista vacía. También hay que notar que la longitud de la lista resultado será siempre la longitud de la más pequeña de las listas de entrada. Análogamente es posible crear la función z i p 3 , que toma tres listas y produce tupias de tres elementos o incluso z i p 4 , z i p 5 . . . En el P R E L U D E vienen definidas z i p y z i p 3 . Ahora es posible hacer justo lo contrario: partiendo de una lista de tupias separarla en dos listas, la primera con los primeros elementos de las tupias y la segunda con los segundos Sin embargo, dado que una función no puede devolver dos valores diferentes, será necesario convertir la lista de tupias en una tupia de dos listas. De esto se encarga la función un zip. Si se intenta construir esta función siguiendo la misma estrategia que se ha venido utilizandc a lo largo de esta sección: • Si la lista de tupias fuese vacía, entonces se deberá devolver la tupia compuesta dos listas vacías. • Pero si la lista de tupias no es vacía, entonces se tendrá una cabeza de lista forma por la tupia (a,b) y un resto de lista xs, pero no se tiene acceso a las listas q forman los primeros y segundos elementos de las tupias de xs. ¿Cómo es posi resolver este problema? No es posible construir esta función del mismo modo que las anteriores, así que se abord el problema desde otro punto de vista muy utilizado en programación funcional: introd parámetros acumuladores que actuarán de forma similar a como lo harían las variables un programa imperativo. Teniendo esto en cuenta, el proceso será el siguiente: • Se creará una función u n z i p A u x que reciba dos parámetros: la lista de entrada y parámetro acumulador en el que se irá construyendo la salida. Obviamente, cu la lista de entrada se haya consumido (esté vacía) significará que en el pará acumulador está ya el resultado buscado.
INTRODUCCIÓN AL LENGUAJE H A S K E L L
k I I I I
1
69
Si la lista de entrada no es vacía, entonces tiene una cabeza (a,b) y un resto xs. Se llega a la misma situación que antes, pero ahora en el acumulador se está construyendo el resultado, es decir, se tiene una tupia cuyo primer elemento es la lista lamérnosla as) de los primeros elementos de la salida y cuyo segundo elemento "bs) será la lista de los segundos elementos de la salida. Para construir el resultado será necesario volver a aplicar unzipAux a la cola de la lista de entrada (xs) y al resultado de modificar adecuadamente el acumulador concatenando la lista as con [a] y la lista bs con [b].
• Por último, nuestra función unzip deberá llamar a unzipAux dando al acumulador un valor inicial que permita ir construyendo este parámetro de forma adecuada. I
go resultante es el siguiente: p xs
= unzipAux
xs
([],[])
^re unzipAux unzipAux
[] a c u m u l a d o r = a c u m u l a d o r ((a,b):xs) ( a s , b s ) = unzipAux
xs
(as++[a],bs+ +[b])
técnica de programación produce una función auxiliar recursiva finallb que va acuo en uno de sus parámetros el resultado, para devolverlo cuando se alcanza el caso Aunque no es obligatorio, es una buena práctica declarar la función auxiliar como a la función principal utilizando para ello where como se ha hecho aquí. gual que se pueden crear zip3 o zip4, es posible crear unzip3, unzip4 y similares. En -*ELUDE vienen definidas unzip (para dos listas) y unzip3 (para tres). r una función a los elementos de una lista ejemplo típico es el de la función map, que permite aplicar una función f a todos los ntos de una lista xs, devolviendo una nueva lista de la misma longitud que xs, pero niendo los resultados de aplicar f a los respectivos elementos de xs. íngase que se desea generar la lista de los diez primeros cuadrados perfectos, desde hasta 100. Con la función map es posible aplicar la función "elevar al cuadrado" a los lentos de una lista formada por los números del 1 al 10 y ya tendría el resultado buscado. construcción de la función map se justifica como sigue: • Aplicar map sobre una lista vacía (con independencia de la función que se considere) producirá una lista vacía, ya que no tiene elementos sobre los que aplicar la función. 6
Una función es recursiva final si tras obtener el resultado de la llamada rccursiva no se realiza ninguna operación, en contraposición a las funciones recursivas no finales que realizan operaciones tras obtener el tado de la llamada recursiva.
70
PROGRAMACIÓN
FUNCIONAL
• En segundo lugar, si la lista tiene un primer elemento x, se aplicará la función f a x y el resultado será el primer elemento de la lista resultado, mientras que su cola será el resultado de volver a aplicar map a f y a la cola de la lista de entrada. Por lo tanto, el código de map es: 1 map
_
[]
=
[]
2 map
f
(x:xs)
=
(f
x ) : ( map
f
x s)
Así, para obtener la lista de los diez primeros cuadrados perfectos, se escribe la expresión map
(\x
->
x*x)
[1..10]
cuyo valor será: [1, 4, 9, 16, 2 5 , 36, 49, 64, 8 1 , 1 0 0 ]
Una extensión de map sería aplicar una función con dos parámetros de entrada a dos lis para producir una tercera lista cuyo primer elemento sería el resultado de aplicar la función los primeros elementos de ambas listas, el segundo sería el resultado de aplicar la función los segundos elementos de ambas listas y así sucesivamente. Esta función se llama z i p W i (que ya se había visto visto en la página 41 en el ejemplo de los números de Fibonacci). construcción se realiza considerando los siguientes supuestos: • Si se quiere aplicar la función f a dos listas cuyos primeros elementos son a > respectivamente (y sus colas as y bs), entonces el primer elemento del resultado s f a b y la cola será el resultado de aplicar z i p W i t h a f y a las colas de las listas entrada. • Por el contrario (y al igual que con la función zip), si alguna de las listas no tuv al menos un elemento, entonces el resultado debería ser la lista vacía. Por lo que el código de 1 zipWith
f
2 zipWith
_
(aras) _
_
=
zipWith
queda como sigue:
(b:bs)
=
(f
a
b):(zipWith
f
as
bs)
[]
Los comentarios sobre la función z i p son también aplicables a z i p W i t h : las dos ecuaci tienen que escribirse en ese orden (porque si no se devolvería siempre la lista vac también se pueden hacer funciones z i p W i t h 3 o z i p W i t h 4 (por ejemplo) que reciban
INTRODUCCIÓN
AL LENGUAJE
HASKELL
71
listas de entrada respectivamente y una función del número adecuado de parámetros. P R E L U D E vienen definidas z i p W i t h y z i p W i t h 3 . | .ariante sería generar una lista de forma que cada elemento (salvo el primero) sea la Tión de una determinada función sobre el elemento precedente. De esto se encarga la n i t e r a t e . Su construcción se realiza como sigue: • La función necesitará de una función f y un primer elemento x. • La cabeza de la lista resultado será x y la cola de dicha lista será una lista cuyo primer elemento deberá ser f x y cada siguiente elemento se obtiene aplicando f al anterior, es decir, el resultado de llamar recursivamente a i t e r a t e con f como función y f x como primer elemento. lo tanto, el código de la función ate
f
x
=
x:(iterate
i t e r a t e
f
(f
es:
x))
ejemplo, es posible generar la lista (infinita) de las potencias de 2 sin más que hacer: :encias2
=
iterate
(2*)
1
w sobre listas los elementos de una lista sirve para producir una nueva lista cuyos elementos pertean a la lista original y que además cumplan una cierta propiedad. Esa propiedad puede intrínseca a los elementos, por ejemplo tomar sólo los números pares de una lista de ros, o relativa a la posición de los elementos en la lista, como coger o descartar los 3 eros elementos de la lista. enzando por el filtrado relativo a la posición de los elementos en la lista, Haskell ofrece funciones t a k e y d r o p que permiten, respectivamente, coger o descartar los n primeros mentos de una lista. Su funcionamiento se basa en las siguientes consideraciones: • En primer lugar, si la lista es vacía, con independencia del número de elementos que se desee coger o descartar, la lista resultante va a ser vacía. • En segundo lugar, si se quieren coger o descartar 0 o menos elementos de cualquier lista, se presenta otro caso base. En el caso de t a k e , el resultado deberá ser una lista vacía, con independencia de la lista de entrada, ya que no se va a coger ningún elemento. En el caso de d r o p , al no querer descartar ninguno de los primeros elementos de la lista de entrada, el resultado será precisamente la lista de entrada.
72
PROGRAMACIÓN
FUNCIONAL
• Por último, si la lista tiene al menos un elemento y el número de elementos que desea coger (take) o descartar (drop) es 1 o más, entonces el resultado será coger (respectivamente descartar) ese primer elemento y aplicar recursivamente la función correspondiente, decrementando en 1 el número de elementos a coger (o descartar sobre la cola de la lista. El código de estas funciones es el siguiente: 1 take
_ [ ]
2 take
n n
=
[]
(x:xs)
3
|
4
| otherwise
5 drop
(\x
filter
x
mod
->
x
>
2
==
0)
10)
s dos funciones bastante comunes permiten coger o descartar los primeros elementos una lista que cumplan cierta propiedad. Estas funciones son takeWhile y dropWhile y funcionamiento es muy similar a t a k e y d r o p respectivamente:
• Si la lista sobre la que operan es vacía, no se puede ni coger ni descartar ningún elemento, por lo tanto ambas devolverán la lista vacía, con independencia de la propiedad a comprobar. • Ahora bien, si la lista tiene un primer elemento, entonces las funciones tendrán primero que comprobar si el elemento cumple la propiedad para cogerlo (takeWhile) o descartarlo (dropWhile) en cada caso. • Si el elemento no cumpliera la propiedad, takeWhile devolvería directamente la lista vacía, pues ya no habría más elementos al comienzo de la lista de entrada que cumplan la propiedad, mientras que dropWhile devolvería la lista completa, porque ya no quedan elementos al principio de la lista que haya que descartar por cumplir la propiedad. Así pues, el código de ambas funciones es el siguiente: takeWhile
_ p
takeWhile |
p
x
=
[] =
x
[]
(x:xs) :
t otherwise
takeWhile =
[]
dropWhile
_
[] =
dropWhile
p
ys@(x:xs)
|
p
x
[]
= dropWhile
| otherwise
=
ys
p
xs
p
xs
74
PROGRAMACIÓN F U N C I O N A L
Listas por comprensión Al presentar el tipo de datos lista, se mencionaron dos formas principales de definir una lista, bien por extensión (enumerando todos sus elementos), bien por comprensión (también llamada por intensión), dando las propiedades de los elementos que forman la lista. Como ejemplo se presentó la lista de los números impares: [ p
I p 93, sat-elite (Planeta , N) , N > 0.
. algunas consultas y respuestas con este programa son: distancia(venus,X) . = 67. - distancia(X,Y). = m e r c u r i o Y = 36 ; ' = venus Y = 67 ; = tierra Y = 93 ;
tiene-vida(X).
= tierra
3.2
;
Computación lógica
El proceso de computación Prolog es el que resuelve la consulta, o lista de objetivos, a partir del programa y se basa en la manipulación de las reglas del programa de acuerdo con una estrategia predefinida y en la comparación de objetivos vía la unificación para obtener las instancias que se identifiquen sintácticamente con las conclusiones o parte izquierda de las reglas del programa. Este apartado, por lo tanto, es clave en la comprensión de este tipo diferente de ejecución de programas y, además, muestra a través de ejemplos sencillos, la potencia computacional del lenguaje Prolog. Los problemas propuestos deben resolverse por el lector "con lápiz y papel" y de la forma más detallada posible de acuerdo a la explicación. Desde el punto de vista declarativo, la ejecución Prolog es un proceso complejo en el que interviene la inferencia lógica, la exploración de alternativas y la vuelta atrás. Responder una consulta en un programa es determinar si la consulta es una consecuencia lógica del
102
PROGRAMACIÓN LÓGICA
programa. El cálculo de la respuesta consiste en la manipulación lógica del programa. Una respuesta negativa significa que la consulta no se puede deducir de las reglas y 1 hechos que forman ese programa, sin embargo no implica la falsedad lógica (esto es, no . demuestra lógicamente que NO se deduce la respuesta). La programación lógica pura, sí tiene propiedades matemáticas como la corrección, co pletitud, transparencia y no existencia de efectos colaterales, pero Prolog como imple mentación específica de este tipo de lenguajes, pierde parcialmente alguna de estas propie dades. En particular la Resolución S L D 2 , que utiliza Prolog es completa y correcta teó~ camente, pero en la práctica la introducción de algunos aspectos de control hacen necesari definir la corrección de una forma independiente. El procedimiento de resolución de Prolog es en profundidad y con vuelta atrás, por lo q se obtienen todas las soluciones de una consulta en su árbol de computación lineal, per cuando el espacio de búsqueda es infinito presenta problemas de completitud (encontrar respuesta si ésta existe), aunque la corrección esté asegurada. En la sección anterior se ha indicado que Prolog ante un objetivo a resolver busca Ínstelas de hechos del programa o de conclusiones (parte izquierda) de las reglas que se sintácticamente iguales al objetivo. La instancia común se obtiene a partir de un conjun de sustituciones de variables por términos tanto en el objetivo a resolver como en el q proviene del programa. Estas sustituciones son el resultado del proceso de unificación. P ejemplo para los objetivos siguientes:
O b j e t i v o 1: m e n ú (paella, X, Z) . O b j e t i v o 2: m e n ú (Y, filete, V) .
existe una instancia común menú (paella, filete, Z) si a ambos objetivos se aplica conjunto de sustituciones o unificación: , , . A con nuación se muestra el algoritmo de unificación con más detalle.
3.2.1
Unificación
Se define a continuación la unificación en dos versiones, la primera de ellas es una si plificación de la segunda y suficiente para la mayor parte de las unificaciones que el lee necesitará realizar en la resolución de los problemas propuestos. 2
Del inglés "Linear resolution with Selection function with Definite clauseses decir "resolución Li con función de Selección para cláusulas DefinidasNombre dado por Maarten van Emden a la regla inferencia definida por Roben Kowalski en 111 ].
103
COMPUTACIÓN LÓGICA
ación Simple #
3objetivos (átomos o literales positivos) se unifican si existe un unificador. formado por conjunto de pares cVariable, Término>, que los identifica sintácticamente: . Dos literales u objetivos se unifican si tienen igual símbolo de predicado, igual número de argumentos y éstos son unificables uno a uno. 2. Una variable se unifica con cualquier otro término pasando a tomar ese valor. 3. Una constante sólo se unifica con otra constante idéntica sintácticamente. 4. Dos términos compuestos se unifican si tienen igual símbolo de función, igual número de argumentos y éstos son unificables uno a uno. ejemplo: • 3 se unifica con 3 (constante). • padre (juan, manuel) no se u n i f i c a c o n padre (luis, manuel). • menú (ensalada, x, tarta_helada) se unifica c o n menú (y, filete, z) m e d i a n t e
el siguiente unificador o conjunto de sustituciones: {, , }
nifícación General algoritmo de unificación general más conocido es el propuesto por Robinson en [ 18]: • Entrada: A, B literales a unificar (con todas las variables diferentes entre sí). • Algoritmo: 1 Sea S = {} la s u s t i t u c i ó n vacia 2 M I E N T R A S S(A) no sea S(B) HACER 3 D e t e r m i n a r s i m b o l o más a la i z q u i e r d a de S(A) que sea d i s t i n t o del s i m b o l o en igual p o s i c i ó n en S ( B ) . 4 C a l c u l a r a p a r t i r de los s í m b o l o s a n t e r i o r e s , los t é r m i n o s o fórmulas ui y U2 m í n i m o s que c o m i e n z a n por e l l o s . 5 SI ni uj ni U2 son v a r i a b l e s o uno es una v a r i a b l e c o n t e n i d a en el otro 6 ENTONCES 7 a c a b a r , pues A y B no son u n i f i c a b l e s
104
PROGRAMACIÓN LÓGICA
8 9
SI NO D e t e r m i n a r una v a r i a b l e x en (o en U2) y el s u b t é r m i n o b en la misma p o s i c i ó n en 112 ( r e s p . en u j ) y hacer S = S U {}. 10 FIN SI 11 FIN MIENTRAS • Salida: S es el unificador de máxima generalidad (umg) de los literales A y B. A continuación se aplica el algoritmo de forma detallada en dos ejemplos: 1. Sean A = P (a, X, f (g (Y))) y B = P (Z, f ( Z ) , f (U)) con X, Y, Z, U variables, a u constante y f, g nombres de funciones lógicas. Se comprobará que su unificador máxima generalidad es S = {, , }: • Se inicializa So= {} y se aplica a A y B, que no se ven modificados: S 0 = { } , S0 (A)=P (a, X, f (g (Y))) y S0 (B) =P (Z, f (Z), f (U)) Se identifican uj y U2: uj=Z, U2=a Por lo tanto: S| = So U {} • AI aplicar Sj a los literales A y B se tiene: S i ( A ) = P ( a , X , f ( g ( Y ) ) ) y Si(B)=P (a, f (a), f (U)) Se identifican ui y U2: U|=X,U2=f(a) Por lo tanto: 5 2 = Si U {} • Nuevamente, se aplica S2 a A y B: S 2 ( A ) = P ( a , f ( a ) , f (g(Y))) y S 2 (B) =P (a, f (a), f (U)) Se identifican uj y u2: ui=U, u 2 =g(Y) Con lo que: 5 3 = S 2 U {} • Finalmente, al aplicar S3 a A y 3: S3 (A) =P (a, f ( a ) , f (g(Y))) y S3 (B) =P (a, f (a), f (g (Y))) Por lo tanto S3 (A) =S3 (B) y el algoritmo termina. 2. Si ahora A = P (X, f (X), X) y B = P (U, W, W) siendo X, U, W variables y f símbolo función lógica, se puede comprobar que no existe sustitución que los unifique: • So={}, S0 (A) =P (X, f (X), X) y S 0 (B)=P(Ü / W / W) U|=U, u2=X S, = So u {}
COMPUTACIÓN LÓGICA
105
• Sj (A) =P (X, f (X) , X) y Si (B) =P (X, W, W) uj=W,U2=f(X) S 2 = Si U {) • S 2 (A) =P (X, f (X) , X) y S 2 (B) =P (X, f(X) , f (X) ) ui=X y u 2 =f(X) Como ui (X) es una variable contenida en u2 (f (X)), no existe umg de A y B. proceso de computación Prolog se basa en la resolución de literales o conjunción de ales que forman la consulta (lista de objetivos a resolver), por lo tanto es un proceso elección de un objetivo a resolver de entre los que forman la lista de objetivos en curso resolución (el primero de la lista, de izquierda a derecha) y de seleccionar la regla del ¿rama que se puede aplicar a ese objetivo (la primera que tiene una instancia común el objetivo). Se dice que una regla o cláusula permite deducir un objetivo si existe unificación (umg) que identifica este objetivo con la conclusión (parte izquierda) de la a o si existe una instancia de la regla del programa que infiere exactamente al objetivo, nces el objetivo será resoluble si se pueden resolver cada una de las condiciones de la cia de la regla. En la siguiente sección se presenta con detalle la forma de ejecución programas o resolución Prolog.
^.2
Resolución
continuación se presenta el algoritmo de resolución controlada que utiliza Prolog: • Inicio: La lista de objetivos es la que forma la consulta. • Selección . . . 1. . . . de un objetivo a resolver: el primero más a la izquierda de la lista de objetivos actual. 2. . . . d e una regla o cláusula para resolver el objetivo seleccionado: la primera del programa cuya conclusión o parte izquierda se unifica con el objetivo. Una vez realizada la selección: - El modelo de computación recuerda todas las reglas que podrían haber sido seleccionadas en este momento. - El objetivo es reemplazado en la lista de objetivos por la parte derecha de la regla seleccionada. - Se aplica a la lista de objetivos resultante el unificador que hace posible unificar el objetivo resuelto con la parte izquierda de la regla.
106
PROGRAMACIÓN LÓGICA
• Repetición: se continúa el proceso de selección hasta que se cumple una de las s: guientes condiciones: 1. Se vacía la lista de objetivos (ÉXITO) y se devuelve el unificador como re puesta. 2. No puede ser resuelto un objetivo (FRACASO LÓGICO).
t
En ambos casos hay vuelta atrás al punto inmediatamente anterior en el que quedar reglas para seleccionar y resolver el objetivo.
Mas adelante se verán ejemplos de terminaciones de la ejecución Prolog con ERROR, que es diferente al FRACASO LÓGICO. Es muy útil simular (a mano) el proceso de ejecución o resolución Prolog mediante un árbo de búsqueda donde: 1. Cada nodo tiene asociada una etiqueta que indica la lista de objetivos a resolver. 2. Cada nodo tiene un nodo descendiente para cada una de las reglas que permitec unificar su parte izquierda con el primer objetivo de la lista. La lista que etiquetará estos nodos será la del nodo antecesor, en la que se ha sustituido el primer objetivo por la parte derecha de la regla correspondiente (con la sustitución aplicada a la nueva lista de objetivos). Prolog construirá el árbol y recorrerá todas sus ramas de izquierda a derecha (se llama recorrido con estrategia en profundidad) y cada vez que acabe de recorrer una rama volverá al nodo anterior con una rama alternativa (vuelta atrás) si haber sido visitada aún. 3. A cada conexión entre nodos del árbol, se asocia una etiqueta con la i d e n t i f i c a d de la r e g l a seleccionada para resolver el primer objetivo del nodo anterior y l a j unificación realizada. Por ejemplo, sea el programa: p(X, Y) p (X, Y ) q (a) . r(a,b) . r (b, b) .
y la consulta: ?- p(a,a)
q(X) q(X)
, r(Y,Z) . , q(Y) .
107
COMPUTACIÓN LÓGICA
espacio de búsqueda que computará Prolog se muestra en la figura 3.1 mediante un árbol istruido como se explicó previamente. Los nombres de las reglas ( r í a r5) identifican la a del programa donde se encuentra la regla aplicada. p(a,a).
(rl){, ) q(a), r(a,Z|). I
(r3) r(a,Zi). (r4) { EXITO
(r 5) FRACASO
(r2){, } q(a), q(a). I
(r3) q(a). (r 3) EXITO
Figura 3.1: Árbol de búsqueda para la consulta p (a, a). go la primera respuesta es ÉXITO (ha encontrado una solución). Como se construye y rre el espacio de búsqueda totalmente, se siguen buscando soluciones. Y se encuentra nueva derivación con las reglas (distinta de la de la primera solución). También se uentra un FRACASO, o error lógico, que indica que no se puede resolver a partir del rama dado, y solo indica que por ese camino no se llega a ninguna solución, hay vuelta s y se encuentra la segunda solución. De hecho, la respuesta de Prolog a la consulta es: p (a, a) . e ; e. que encuentra dos soluciones (ambas con éxito y, dado que no hay variables en la conta, verdaderas). i la consulta fuera p (X, Y), se computarán todas las soluciones y por lo tanto se calcularán s los valores que asignados a las variables X e Y, hacen cierto o resoluble al predicado X, Y). Se puede ver el árbol de búsqueda para esta consulta en la figura 3.2, que muestra se encuentran dos formas de encontrar la solución {, } y una forma de encontrar la solución (, }. Si la consulta es p (X, b), el espacio de búsqueda construido es el mostrado en la figura 3.3. Como se ha visto, el espacio de búsqueda de soluciones es completo, es decir, en él aparecen :odas las opciones a seleccionar en cada momento del proceso y Prolog lo recorre en su totalidad. Luego una consulta puede ser respondida con varias soluciones (tantas como elementos asignados a las variables hagan deducible o resoluble la consulta a partir del programa). En algunas versiones Prolog una vez obtenida una solución, exige escribir un
108
PROGRAMACIÓN LÓGICA p(X.Y).
(rl){, } q(X), tíYZi). I (r3) {} r(Y.Z,).
(r2){, } q(X), q(Y). I
(r3){} q(Y). I
(r4){, } EXITO I ,
(r3){} EXITO
(r5){, } EXITO
I
,
I
,
Figura 3.2: Árbol de búsqueda para la consulta p (X, Y). piX.b).
(rl){, } q(X), r(b,Z¡). I
(r2){, } q(X), q(b). I
(r3){} q(b).
(r3) {}
r{bJZ\).
I
(r4) FRACASO
(r5)1} EXITO
(r3) FRACASO
I
Figura 3.3: Árbol de búsqueda para la consulta p (X, b)
punto y coma ; (o pulsar espacio) si se quiere continuar la búsqueda de más soluciones. El control automático (no lógico) de la computación se realiza en los puntos de selecci al seleccionar un objetivo en una lista y al seleccionar una regla que resuelva o unifique parte izquierda con el objetivo. En las implementaciones Prolog se selecciona el objeti más a la izquierda en la lista de objetivos y la primera cláusula que aparece en el progra Por lo tanto, es importante a la hora de escribir un programa tener en cuenta el orden de cláusulas y el orden de los objetivos en cada cláusula o en la consulta, porque el result de la computación puede ser diferente (aspecto provocado por el control, que aleja a implementaciones de la teoría del denominado Prolog puro).
109
COMPUTACIÓN LÓGICA
ver el efecto de este control, en lo que sigue se simula con el árbol de búsqueda la putación Prolog para la consulta f a c t o r i a l (3,S) en varias versiones del programa, s ellas iguales desde el punto de vista lógico, pero que dejan de serlo en la ejecución. era versión: : : o r i a l (0,1) . ctorial(N,M)
I
is
N-l,
f a c t o r i a l ( I , X ) ,
M is
X
*
N.
la ejecución a la consulta "¿cuál es el factorial de 3?" produce el árbol de búsqueda de figura 3.4. faetorial(3,S).
(rl) FRACASO
(r2){, } 1| is 3-1. factorial(I|,X|). S is X|*3.
{} factorial(2,X|), S is Xi *3
(rl) FRACASO
( r 2 H < N , 2 > , } I 2 is 2-1. factorial(l2.X 2 ), X, is X 2 *2, S is X,*3.
I
() factorial(l,X 2 ), X, is X 2 *2. S is X,*3.
(ri) FRACASO
(r2)1, } h i s 1-1. factorial*I3.X3). X 2 i s X | » l . X , is X 2 *2. S is X, *3.
1
factoriaKO.X^). X 2 is X3 *I.Xi is X 2 *2, S is X|*3.
( r l ) {} X^ is 1*1, X, i s X o * 2 , S i s X , * 3 .
(r2) ERROR
I
(,, | EXITO
I
Figura 3.4: Árbol de búsqueda para la primera versión del factorial.
/
La rama que acaba en ERROR significa que se produce un error no lógico, que puede haber sido provocado porque la aritmética permita computar factoriales negativos hasta el infinito o porque acabe con un error no lógico al intentar restar de cero.
110
PROGRAMACIÓN LÓGICA
Nótese que cada vez que se utiliza una regla en una rama del árbol, se renombran todas sus variables, porque lógicamente se utiliza una nueva instancia de la regla para resolver cada consulta intermedia (factorial (3, W), factorial (2,X\), factorial (1, X 2 ) ,etc). En este ejemplo, una vez obtenida una solución, se continúa buscando más soluciones, pero si se cambia el orden textual de las reglas en el programa, se obtiene una ejecución Prolog completamente diferente (mostrada en la figura 3.5), debida al control de la ejecución que aporta la implementación de Prolog. Segunda versión ("desordenada 1 '): factorial(N,M) factorial (0,1).
I is N - l ,
f a c t o r i a l (I,X),
M is X * N.
factoriaI(3,S).
( r l ) { < N , 3 > , } I] is 3-1, factorial(I|.X|). S is Xi*3.
(r 2) FRACASO
{} factorial(2,X,),SisXj*3
X 2 is 1*1, X, is X 2 *2, S is Xi *3. {,f } EXITO I
Figura 3.5: Árbol de búsqueda para la segunda versión del factorial. Este árbol no obtiene la solución (que quedaría sin visitar a la derecha), pues la computac se queda recorriendo una rama infinita o acaba con un ERROR.
111
C O M P U T A C I Ó N LÓGICA
era versión (o segunda "desordenada"): se cambia el orden de las condiciones en la regla general, se obtiene la siguiente versión: c i o r i a l (0,1) . ctorial(N,M)
factorial(I,X),
I is
N-l,
M
is
X
*
N.
o árbol de búsqueda se muestra en la figura 3.6. factorial(3,S).
( r l M < I | , 0 > , } 0 is 3-1. S is 1*3.
(r2) { < N 2 , I I > , } factorialdj.X 2 ). b is I i - l , X| is X?*I| , li is 3-1. S is X[*3.
FRACASO ( r l ) { < I 2 , 0 > , ) O is Ii-l . X , is 1*1,, I, is 3-1. S is X| *3.
F.RROR
Figura 3.6: Árbol de búsqueda para la tercera versión del factorial. En este último caso ni siquiera se llega a recorrer la rama pues, en la aritmética de Prolog, los argumentos de toda operación tienen que tener valor cuando ese objetivo va a ser resuelto. En este caso, además, el comportamiento de Prolog no es bueno (el programa acaba con ERROR que no es el FRACASO de resolución lógica) y la ejecución de los programas sólo debe acabar en EXITO (se resolvió por esta rama la consulta) o con FRACASO (no se puede resolver por esta rama esa consulta) y nunca en ERROR de computación. Como el espacio de búsqueda de soluciones es completo, es necesario asegurarse de que no sea infinito o, si lo es, controlar la ejecución para que, además de obtener la solución a la consulta (caso de que así ocurra), acabe el programa en un tiempo finito. Además los programas pueden ser complejos si resuelven problemas complejos o bien la computación ser poco eficiente. Para conseguir una mayor eficiencia existen las llamadas características no lógicas de control de la ejecución que son predicados predefinidos o códigos incorporados en el programa por el programador que, al seL interpretados por el mecanismo de computación Prolog, modifican la lógica establecida cíe computación. A pesar de que estas características no lógicas no gozan de gran aceptación (su introducción en un programa puede llegar incluso a la modificación de su significado), a veces es necesaria su utilización.
112
3.2.3
PROGRAMACIÓN LÓGICA
Control de la ejecución
En Prolog el control durante la ejecución de prfbgramas es interno y no modificable (se lección del objetivo a resolver y selección de la regla para resolver el objetivo predefinido El programador sólo puede cambiar el orden de las reglas o el orden de los objetivos e la parte derecha de las reglas pero nunca en el momento de ejecución. Sin embargo ha algunos mecanismos no lógicos que permiten la variación de la forma de ejecución, comc se muestra a continuación. El predicado predefinido corte, denotado por la admiración !, permite reducir el esp¿tcic de búsqueda de un objetivo haciendo más eficaz la ejecución. Permite suprimir el núme de elecciones en las fases de selección facilitando así la recuperación de la memoria o bie eliminando ramas infinitas o alternativas que se sabe que no llevan a ninguna solución, corte es un predicado que siempre es verdad y que produce un efecto colateral sobre el de búsqueda de soluciones: elimina todas las opciones de la rama en que se encuent a partir del nodo anterior al de su aparición. Puede aparecer únicamente en la pa derecha de una regla Prolog o en una consulta. Por ejemplo sea el programa: 1p 2 p :3p 4q 5q
q,t. m, ! . r. s, ! . t .
6 S .
7 s :- m. 8 t.
9 r .
El espacio de búsqueda generado para resolver la consulta p hasta el momento inmedi mente anterior a resolver el corte es: P-
(rl) q, t. (r4) s,!, t. (r6) !, t.
(r2) m, !. (r5) t, t.
(r7) m, !, t.
Y al ejecutarse el corte, se poda el espacio de búsqueda:
(r3) r.
COMPUTACIÓN LÓGICA
113
k) que las alternativas marcadas con X dejan de serlo y el espacio de búsqueda que se -a es:
( r 9 )
EXITO
(r 8) EXITO el corte es posible implementar, por ejemplo, cómo "obtener la primera solución En el programa de la carta de un restaurante si se consulta menú (X, Y, Z), !, se ;ne una única solución y es la primera terna de valores asignados a las variables X, Y y e verifican el predicado menú (X, Y, Z). ados a este punto, se sugiere al lector construir el espacio de búsqueda de las consultas: plato_fuerte(P), dif(P, filete),!. p l a t o _ f u e r t e ( P), !, dif{P, f i l e t e ) .
responder a la pregunta: ¿cuál de los dos se corresponde con la consulta en castellano tener un plato fuerte diferente de filete"? punto en el que es muy útil la utilización del corte aparece cuando los problemas son solución única, y se sabe a priori, por lo que no hace falta buscar mas soluciones una vez se ha obtenido la primera de ellas, como ocurre en el programa que calcula el factorial
PROGRAMACIÓN LÓGICA
114
de un número. Nótese la mejora en términos de eficiencia de la computación (observan el árbol de búsqueda), si se realiza la consulta: ?-
factorial(3,S),!.
en vez de sin corte, o reescribiendo de nuevo el programa del factorial de la siguiente ñera: factorial (0,1)
factorial(N,M)
:- ! .
I is N - l ,
factorial(I,X),
M is X * N.
Sin embargo, el corte puede producir problemas no esperados y es conveniente utilizarlo c cuidado, aunque es una de las mejores formas de conseguir programas efectivos y eficient al eliminar opciones en la ejecución que desde el principio son conocidas como inútil Supóngase que se tienen los datos de 100 ó 1000 alumnos representados mediante reg Prolog con el siguiente significado: persona(Nombre, Numero_de_Matricula,Curso).
persona(manuel,223344,2). persona ( francisco, 334455 , 1) . persona(aranzazu, 442244 , 2) .
Y que se desea optimizar la ejecución de la consulta persona (francisco, N, C). Pe intentarse modificar las reglas introduciendo el corte para que, una vez que se estudia solución (que es única) de cuál es el número de matricula y el curso de un alumno dado, se continúe la búsqueda de soluciones, pues no las habrá. Esto es: persona(manuel,223344,2) !. persona ( francisco, 334455 , 1) !. persona(aranzazu,442244,2) !.
Pero si ahora se desea consultar "¿cuáles son los alumnos de segundo curso T, responderá que sólo manuel, pues una vez que encuentra esta solución elimina el de alternativas. Luego, la modificación genera posteriormente efectos no acordes cor semántica del programa, por lo que la solución acertada en este caso, es incorporar er consulta el corte cuando se conozca que la solución es única. Sea el siguiente enunciado de otro problema:
COMPUTACIÓN LÓGICA
115
• Juan no tiene hermanos. • Pedro tiene un hermano. • Cualquiera tiene dos hermanos. rste caso se elige un predicado numero_de_hermanos (X, Y) que significa que la persona e un número Y de hermanos. La primera aproximación al programa puede ser: ro_de_hermanos ( juan,0) . ero_de_hermanos(pedro,1). e r o _ d e _ h e r m a n o s (X,2) .
se consulta cuántos hermanos tiene Juanse obtienen las respuestas, 0 y 2, de5 a las reglas de las líneas 1 y 3 respectivamente. Luego una segunda aproximación o miento del programa, se obtiene incorporando el hecho de que si uno tiene un número nermanos ya no hay mas soluciones (parece muy adecuado el uso del corte): e r o _ d e _ h e r m a n o s ( juan,0) . e r o _ d e _ h e r m a n o s (pedro,1) e r o _ d e _ h e r m a n o s (X,2) .
!. !.
ya se responderá que Juan tiene 0 hermanos y nada más. Pero si se consulta ahora n tiene dos hermanos¡la respuesta es afirmativa! (pruébese construyendo el árbol acio de búsqueda correspondiente a la consulta). La solución correcta es indicar que z los que no sean Juan y Pedro tienen dos hermanos, información no explícita en el ciado, pero conocida en el contexto del problema: e r o _ d e _ h e r m a n o s ( juan,0) !. ero_de_hermanos(pedro,1) !. ero_de_hermanos(X,2) dif{X,juan),
dif(X,pedro).
predicado predefinido corte también permite escribir el programa general que define la ación por defecto: algo no es cierto cuando no es posible demostrarlo vía resolución og. O, lo que es lo mismo, "no(X)" es falso cuando es posible resolver "X" y cierto en contrario: X) : - cali (X)
,
! , fallo .
(X) . ' 1 (X) es un predicado predefinido en algunas implementaciones o entornos Prolog que ite parametrizar la resolución Prolog del objetivo X (la variable "X" ha de estar unificon un objetivo en el momento de su ejecución, daría error en otro caso).
116
PROGRAMACIÓN LÓGICA
Por otro lado, "fallo" es un predicado al que nunca se define en el programa y, por tanto, es siempre falso lógicamente. Si en el programa de la carta del restaurante se cónsul no (plato-fuerte (ensalada)) se obtendrá como respuesta que no lo es (es convenien que el lector escriba el árbol de búsqueda de soluciones asociado). En algunas implement ciones o entornos Prolog ya se encuentra implementada la negación con el predicado p~ definido not (X), e incluso el predicado fallo como f ail por su utilidad en la programaci' Obsérvese, que la negación como fallo de Prolog no se corresponde con la negación lógi cuando en el objetivo negado aparecen variables. La respuesta a la consulta Objetiv no coincide con la de no (no (Objetivo)) como ocurre en lógica clásica, ya que en segunda las variables siguen siendo libres y en la primera pueden haber tomado el v' correspondiente. Por ejemplo, si al programa que contiene una única afirmación p (a) se consulta p (X) entonces responde que sí se puede deducir, con el valor a instanciado a X. ~ embargo, ante la consulta no (no (p (X))), la respuesta es que sí se puede deducir, pero asigna valor a la variable X. Este comportamiento no lógico, se puede utilizar, por ejemp para verificar una condición intermedia sin asignar valor a las variables del objetivo. En la bibliografía pueden encontrarse los términos corte verde y corte rojo para disting entre los cortes que no afectan al significado del programa, esto es al conjunto de solucio o a la semántica declarativa del programa y los que sí lo hacen. Por ejemplo el corte de negación anterior, es rojo y aquellos que sólo suponen la eliminación de alternativas que llevan a ninguna solución son cortes verdes como el que aparece, por ejemplo, al reescri el problema del factorial asumiendo que la solución es única: 1 factorial(0,1) 2 factorial(N,M)
:-
!. I is
N-l,
factorial(I,X),
M is
X * N.
A continuación se muestra, a partir de un ejemplo, cómo la búsqueda de la eficiencia vía corte puede, de nuevo, provocar cambios no deseados en el significado del programa, menor (X, Y, Z), significando que "el menor de los números X e Y es Z \ programado c sigue: 1 menor (X, Y, X)
X num dígito => num dígito dígito =>• dígito dígito dígito => 1 dígito dígito => 12 dígito =• 123
num => num dígito =>• num 3 => num dígito 3 => num 23 => dígito 23 =¡> 123
Figura 4.2: Ejemplos de derivaciones.
Las gramáticas ambiguas no expresan claramente las estructuras sintácticas de un lenguaje, por ello es necesario aportar un criterio para eliminar ambigüedades. Éste puede definirse de forma independiente a la gramática o revisándola añadiendo o modificando las reglas. En el ejemplo de las expresiones aritméticas, hay dos árboles diferentes para una misma expresión (figura 4.1), y una solución es añadir reglas para incluir un no terminal f a c t o r : e x p —> e x p + e x p | e x p - e x p | f a c t o r f a c t o r —> f a c t o r * f a c t o r | f a c t o r / f a c t o r | ( e x p ) num —> num d í g i t o | d í g i t o dígito — > 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
| num
Pero con esta solución se sigue pudiendo interpretar 1 + 2 + 3 bien como (1 + 2) 3 o como 1 + (2 + 3). Aunque esto representa correctamente que la adición puede ser asociativa tanto por la izquierda como por la derecha, en el caso de la sustracción habría un problema al poder interpretar 1 - 2 - 3 como ( 1 - 2 ) - 3 o 1 - ( 2 - 3 ) (cuyos resultados son, respectivamente, —4 y 2). Para solucionarlo se debe reemplazar la regla de expresión por una de las siguientes alternativas: e x p —• exp + t é r m i n o | exp - t é r m i n o | t é r m i n o e x p —> t é r m i n o + e x p | t é r m i n o - e x p | t é r m i n o
Con la primera regla, recursiva por la izquierda, se realiza la asociatividad a la izquierda > con la segunda a la derecha. Si se mantiene la precedencia numérica, la gramática revisada y no ambigua es: e x p —> e x p + t é r m i n o | e x p - t é r m i n o | t é r m i n o t é r m i n o —• t é r m i n o * f a c t o r | t é r m i n o / f a c t o r | f a c t o r f a c t o r —• ( e x p ) | num num —¥ num d í g i t o I d í g i t o d í g i t o -> 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
Cuando la revisión de una gramática es extremadamente compleja, es mejor exigir un terio de no ambigüedad no dependiente de la gramática. Esto se realiza ya fuera del ámbil sintáctico y formaría parte de la semántica del lenguaje. Un ejemplo de esto se verá en capítulo 6 (página 249) al tratar sobre el problema del else ambiguo.
S I N T A X I S D E LOS L E N G U A J E S D E P R O G R A M A C I Ó N
1.6
175
Diagramas sintácticos
facilitar la comprensión de reglas BNF complejas como alguna de las presentadas anormente, se utilizan las facilidades de los metasímbolos de las expresiones regulares o los corchetes que indican cero o mas repeticiones y los corchetes cuadrados que inn al menos una repetición), en lo que se denominó la Forma Bakus-Naur Extendida EBNF. Esta notación sirve para simplificar la notación BNF, pero no añade más expreidad, ya que todo lenguaje representado mediante notación EBNF se puede representar bién mediante notación BNF (y viceversa). la tabla 4.4 se puede ver el significado de los metasímbolos que admite la notación NF 3 . Si se compara con la tabla 4.3, se pueden ver las diferencias entre los metasímbolos ambas notaciones. de de de de de
separación entre la parte izquierda y derecha de una regla alternativa (se puede elegir únicamente uno de los elementos que separa) repetición (los elementos que incluyen pueden repetirse cero o más veces) opción (los elementos que incluyen pueden utilizarse o no) agrupación (sirven para agrupar los elementos que incluyen)
Tabla 4.4: Metasímbolos EBNF. La gramática anterior representada en notación EBNF es: : : = < t é r m i n o > { ( + | - ) < t é r m i n o > } ::= < f a c t o r > { ( * | / ) < f a c t o r > } < f a c t o r > : : = ' ( ' ' ) ' I : : = < d í g i t o > { < d í g i t o > } : : = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
Se puede ver que una de las producciones de la regla del no terminal f a c t o r representa una expresión entre paréntesis. Para diferenciar estos símbolos terminales del metasímbolo de agrupación, la convención es encerrarlos entre comillas simples. De igual manera se haría con cualquier otro metasímbolo que se desee utilizar como símbolo de la gramática. Además, la notación EBNF no permite la recursión, es decir, un no terminal no puede aparecer al mismo tiempo en la parte izquierda y derecha de una misma regla. Por el contrario, la notación BNF sí que lo permite, ya que al no haber metasímbolos de repetición, es la única forma de denotar repeticiones de no terminales de longitud arbitraria. Se ha estandarizado una representación gráfica para representar las reglas EBNF (nunca las reglas en BNF 4 ), denominada diagrama sintáctico. El uso de diagramas sintácticos para 3
Realmente aquí se presenta una variante simplificada, ya que la notación estándar (definida por el ISO14977) es más compleja. 4 Las razones para ello se encuentran en los problemas que plantea la recursividad en la gramática, permitida
176
SINTAXIS Y SEMÁNTICA BÁSICA
representar la gramática de un lenguaje se ha venido utilizando desde la década de los 70 (del siglo XX) y uno de los primeros lenguajes en ser así representados es Pascal [27]. En los diagramas sintácticos, los símbolos terminales están encerrados en círculos y los no terminales en rectángulos. Estos símbolos se unen mediante flechas dirigidas que indican el orden en el que se pueden presentar en una regla. Para una regla determinada, una cadena válida debe definir un camino en el diagrama. Con diagramas sintácticos, las reglas EBNF anteriores se representan gráficamente como se muestra en la figura 4.3.
{dígito)
Figura 4.3: Diagramas sintácticos. En dicha figura se pueden ver ejemplos de uso de los metasímbolos de repetición, agru- | pación y alternativa por ejemplo en la regla exp. La forma de leer el diagrama sintáctico del esta regla es la siguiente: 1. El primer símbolo que debe aparecer es el no terminal término. 2. Tras aparecer dicho símbolo hay dos opciones: (a) Se termina el uso de la regla y se llega al final del diagrama sintáctico. (b) Se produce una repetición, en cuyo caso deberá aparecer uno de los terminí + ó - seguido de otro no terminal t é r m i n o . Llegados aquí, se vuelve al punto por la notación BNF, al realizar un análisis sintáctico. Un análisis más profundo de estos problemas queda fi. de los propósitos de este libro.
S E M Á N T I C A D E LOS L E N G U A J E S D E P R O G R A M A C I Ó N
177
representación gráfica del metasímbolo de alternativa también permite representar el ímbolo de opción, simplemente dando una alternativa vacía. Por ejemplo, si se modila regla num como sigue: rum> : : = [ - ]
{
}
permite que los números vengan precedidos de un signo negativo opcional. Esta regla se esentaría gráficamente en un diagrama sintáctico como se muestra en la figura 4.4.
Figura 4.4: Diagrama sintáctico para el metasímbolo de opción. s diagramas sintácticos (y, por tanto, la notación EBNF) son de especial utilidad a la ra de escribir un analizador sintáctico que emplea un análisis sintáctico descendente 5 nominado analizador sintáctico descendente recursivo), ya que la propia notación se ede traducir muy fácilmente al código de dicho analizador. a forma de análisis sintáctico es el análisis sintáctico ascendente 6 , que es el utilizado r los g e n e r a d o r e s de a n a l i z a d o r e s s i n t á c t i c o s como YACC7 y sus sucesores. Dado que la construcción y funcionamiento de los analizadores sintácticos están fuera de los propósitos de este libro, no se tratará con más profundidad este tema.
4.2
Semántica de los Lenguajes de Programación
La especificación de la semántica de un lenguaje de programación no está estandarizada ni consensuada como en el caso de la sintaxis. A falta de una definición formal, hay varias formas posibles de especificar informalmente un lenguaje: 1. Mediante un manual de referencia del lenguaje, que puede ser impreciso, sufrir omisiones y contener ambigüedades. 2. Mediante el uso de un traductor para definir la semántica. Así a cada pregunta relacionada con el lenguaje el traductor la responde, aunque habrá preguntas que no puedan ser contestadas hasta la ejecución de un programa. s 6
7
Que realiza el análisis partiendo de la raíz del árbol sintáctico y yendo hacia las hojas. Este tipo de análisis parte de las hojas del árbol sintáctico y asciende hacia la raíz del mismo.
"Yet Another Compiler Compiler", es decir. "Otro compilador de compiladores
SINTAXIS Y SEMÁNTICA BÁSICA
178
Otras desventajas de estas aproximaciones son que es difícil depurar los errores del traductor y que éste suele ser dependiente de la máquina y no portable (por lo que la segunda opción se utiliza poco). Lo mejor es una especificación formal, esto es, una forma precisa de especificar, aunque su nivel de abstracción sea elevado y su comprensión mas difícil que una descripción informal. Entre las aproximaciones existentes, la semántica denotacional que describe la semántica de un lenguaje mediante funciones, es la mas utilizada.
4.2.1
Atributos, vínculos y funciones semánticas
En la descripción semántica de un lenguaje de programación, tienen que estar incluidalas reglas que determinan el significado de cada uno de los nombres o identificadores uti fizados para las variables, los procedimientos y constantes (o cualquier otra entidad d lenguaje). El significado de un identificador queda determinado por las propiedades o atributos asociados al mismo. Unos atributos especialmente importantes son los valores, que representan cualquier c ti dad almacenada (enteros, reales, matrices etc). y las localizaciones, que son los luga donde se almacenan los valores y pueden entenderse como direcciones en la memoria o forma mas abstracta. Por ejemplo la declaración o definición C: i const int n = 5 ; 3 int
x;
s double f ( i n t 6
n) {
. . .
7}
asocia al identificador n el atributo de tipo de datos constante entera (según indican 1 palabras clave c o n s t e i n t ) y el atributo de valor 5. A continuación se asocia el atri variable y el tipo de datos entero (según indica la palabra clave i n t ) al identificador x. siguiente declaración asocia el atributo función al identificador f y, adicional mente: 1. La cantidad, identificadores y tipos de datos de sus parámetros (en el ejemplo, parámetro n con tipo de datos entero). 2. El tipo de datos del valor devuelto (en este caso, double). 3. El cuerpo del código que se ejecuta cuando se llama a la función (entre llaves). Al proceso de asignación de un atributo a un identificador, se denomina vínculo.
S E M Á N T I C A DE LOS L E N G U A J E S DE PROGRAMACIÓN
179
Las declaraciones no son las únicas construcciones de un lenguaje que pueden asociar atributos a los identificadores. También lo hace la asignación de valor a una variable como: x = 2;
que asocia el atributo valor 2 a la variable x. O, en el caso de una variable puntero, el -iguiente código en C++: int * y ; y = new int;
asigna memoria para una variable entera (esto es, asocia un atributo de localización a la misma) y asigna esta localización a *y, como nuevo atributo de v a l o r de la variable puntero En algunos lenguajes, las construcciones que hacen que los valores sean vinculados con identificadores pueden no ser declaraciones. Por ejemplo en el código Haskell siguiente aparece una expresión l e t : tres
= let
x = 2 in
x + 1
que es una expresión que vincula el valor 2 con x, pero sólo dentro de la expresión x + 1, dando como resultado el valor 3. Un atributo puede clasificarse según el tiempo en que se está calculando o bien vinculando | a un identificador, lo que se conoce como tiempo de vinculación. Un vínculo estático es aquel que vincula un atributo a un identificador en tiempo de traducción, esto es, estáticamente. Dicho atributo se denomina atributo estático. Por el contrario, si la vinculación se realiza durante la ejecución del programa, se denomina vínculo dinámico y, consecuentemente, se tratará de un atributo dinámico. En general, los lenguajes funcionales tienen mas vínculos dinámicos que los imperativos. Los tiempos de vinculación también dependen del traductor. En el caso de un intérprete, se traduce y ejecuta el código de forma simultánea, por lo que la mayoría de los vínculos serán dinámicos, mientras que un compilador genera muchos más vínculos estáticos. Para hacer que el análisis de los atributos y sus vínculos sea independiente del traductor, se toma por convenio que el tiempo de vinculación de un atributo es el tiempo mas corto de los que las reglas del lenguaje permiten al atributo estar vinculado. Por ejemplo, la declaración c o n s t i n t n = 2; vincula estáticamente el valor 2 con el identificador n y en la declaración i n t x; el tipo de datos entero está también vinculado estáticamente al identificador x. Por otra parte, la asignación x = 2;, vincula dinámicamente a 2 con x (en el momento de ejecutar la sentencia de asignación).
180
SINTAXIS Y SEMÁNTICA BÁSICA
La clasificación de los tiempos de vinculación puede refinarse mas. Un atributo estático puede vincularse: 1. En tiempo de traducción (o compilación), durante el análisis gramatical o el semántico. 2. En tiempo de linkado, durante el encadenamiento del programa con las bibliotecas. 3. En tiempo de carga, durante la carga del programa para su ejecución. 4. En tiempo de definición del lenguaje. 5. En tiempo de implementación del lenguaje. Por ejemplo, el cuerpo de una función definida externamente se vinculará en el tiempo de linkado, y la localización de una variable global se vinculará en tiempo de carga, ya que no se modifica durante la ejecución del programa. Los identificadores pueden vincularse con los atributos aún antes del tiempo de traducción. I como ocurre con identificadores predefinidos como los tipos de datos boolean (dos valores) y char de Pascal, que tienen especificados sus atributos en el tiempo de definición de; lenguaje. Otros identificadores predefinidos como el tipo de datos i n t e g e r o la constante maxint, tienen especificados parte de sus atributos en tiempo de definición del lenguaje (subconjunto de enteros y constante, respectivamente) y otra parte en tiempo de la implementación concreta del lenguaje, en la que ya se indica exactamente el rango del tipo de datos i n t e g e r y el valor exacto de maxint (que representa el valor máximo de dicho conjunto). Por otro lado, un atributo dinámico puede vincularse en tiempo de ejecución, en la entra o en la salida de un procedimiento o bien en la entrada o salida de todo el programa. El traductor debe hacer posible que se mantenga la consistencia de los vínculos a los ide tificadores, tanto durante la traducción como durante la ejecución. Para mantener esta i formación, los traductores utilizan una tabla de símbolos, en la que se almacena la fo de vincular los atributos a los identificadores. La tabla de símbolos define una función identificadores en atributos, que cambia según avanza la traducción y/o la ejecución programa, reflejando adiciones o eliminaciones de vínculos dentro del programa. La tabla de símbolos de un traductor se gestiona de forma diferente según se trate de intérprete o de un compilador. Un compilador sólo puede, por definición, procesar atribuí estáticos, ya que la ejecución del programa es posterior a la compilación. El compilad genera un código para conservar durante la ejecución del programa ciertos atributos ( ejemplo localizaciones y valores): • La asignación de memoria para almacenar el vínculo de los identificadores con 1 lizaciones de almacenamiento, se realiza por separado, en el ambiente o entorno.
SEMÁNTICA DE LOS L E N G U A J E S DE PROGRAMACIÓN
181
• Los vínculos entre las localizaciones de almacenamiento y los valores forman lo que se denomina estado del cómputo (almacén o memoria), que supone una abstracción de la memoria de un ordenador real. En resumen, en un compilador se encuentran las siguientes funciones semánticas que permiten relacionar los identificadores con sus atributos: • La tabla de símbolos almacena la asignación de identificadores a atributos estáticos (aunque el compilador no pueda gestionarlos todos). • El ambiente asigna identificadores a localizaciones de almacenamiento. • El estado del cómputo asigna localizaciones de almacenamiento a valores. En un intérprete se procesan tanto los atributos estáticos como los dinámicos, por lo que >e combinan la tabla de símbolos, el ambiente y el estado del cómputo. Luego en el intérprete, el ambiente produce y almacena la asignación de los identificadores a los atributos incluyendo localizaciones y valores).
4.2.2
Declaraciones, bloques y alcance
Las declaraciones pueden establecer vínculos de forma implícita o explícita. Por ejemplo la declaración en C i n t x; establece explícitamente que el tipo de datos de x es i n t e g e r , aunque la localización exacta de x durante la ejecución está vinculada de manera implícita y podría ser un atributo estático o dinámico, según la posición de la declaración en el programa. El atributo valor será cero o indefinido también dependiendo de la posición de la declaración en el programa. Sin embargo, la declaración i n t x = 0; vincula o liga de manera explícita a 0 como valor inicial de x. En algunos lenguajes, la declaración completa de una variable puede ser implícita. Por ejemplo Fortran no requiere declaraciones para variables simples, porque todas las no explícitamente declaradas, son e n t e r a s si sus identificadores empiezan por la letra, I, J, K, L, M o N y r e a l e s en otro caso. En algunas implementaciones de BASIC, también por convenio, se entiende que las variables que acaban con el símbolo % son enteras, si acaban con $ son caracteres y en cualquier otro caso son reales. En el caso de C y C++ (y otros lenguajes) se distingue entre declaraciones que vinculan a todos los atributos potenciales, que se denominan definiciones, o las que vinculan sólo algunos atributos, que son las declaraciones propiamente dichas. Por ejemplo double f ( i n t x ) ; es una declaración pero no una definición, porque sólo especifica el tipo de su parámetro y el del valor de retorno, pero no especifica el código de la función f. En el caso de la declaración s t r u c t x; que especifica un tipo incompleto en C o C++, tampoco se tiene una definición.
SINTAXIS Y SEMÁNTICA BÁSICA
182
Las declaraciones suelen estar asociadas a constructores específicos del lenguaje. A cada constructor específico o estándar, se le denomina bloque. Un bloque es una secuencia de declaraciones seguidas por una secuencia de sentencias y rodeado por marcadores, como las llaves o pares de inicio y fin. En C los bloques se denominan sentencias compuestas y aparecen en el cuerpo de las definiciones de una función y en cualquier otra parte de un programa en que podría aparecer una sentencia ordinaria. En el siguiente ejemplo en C: 1 void p (void) { 2 double r , z ; / * b l o q u e
de p h a s t a
3
...
4 5 6
{ int x , y ; /* b l o q u e a n i d a d o x= 2 ; y = 0 ; x += 1 ; }
7
...
la última
entre
llaves
llave
*/
*/
81
hay un bloque anidado en el cuerpo del bloque que define a p. En el lenguaje C, por ejemplo, además de dentro de un bloque se pueden escribir declaraciones externas y declaraciones globales fuera de cualquier sentencia compuesta. Las que se encuentran dentro de un bloque, se denominan declaraciones locales al bloque y al resto no locales al bloque. En el ejemplo anterior, las variables r y z son no locales en el bloque anidado, pero locales en el bloque que define a p. En el ejemplo siguiente, las primeras líneas de declaración de x e y son externas para todas las funciones y también son globales: 1 int x; 2 double
y;
4 main () { s int i , j ; 6
/* v a r i a b l e s
asociadas
al
bloque
main*/
. . .
7}
Los lugares donde pueden aparecer declaraciones dependen del lenguaje. En Pascal sólo los bloques b e g i n - e n d asociados con el programa principal, con un procedimiento o con un¿ función pueden contener declaraciones 8 . Sin embargo, los bloques b e g i n - e n d de bucles | o sentencias condicionales no admiten declaraciones. También existen otros constructores del lenguaje diferentes al bloque, como son las definiciones de registros, que contiene* declaraciones de variables locales (denominadas campos). Por ejemplo, en C, un registre se definiría como sigue: 8
Las cuales se deben escribir antes de la palabra reservada becin, aunque semánticamente pertenecen bloque.
S E M Á N T I C A DE LOS L E N G U A J E S DE P R O G R A M A C I Ó N
struct A int x; double y ; struct { int* x ; char y ; } z;
/*
declaración
local
/*
declaraciones
183
a A */
de v a r i a b l e s
locales
anidadas
*/
t En los lenguajes orientados a objetos, la clase es un constructor básico. Por ejemplo, en Java la clase es la única declaración que no necesita estar en el interior de otra declaración. En estos lenguajes a las funciones que no devuelven valores se las denomina métodos. Por ejemplo, en el listado en Java que se vio en la página 13, se declaran como locales los métodos i n t V a l y gcd (en las líneas 3 y 4 respectivamente) y el campo de valores v a l u é en la línea 14). En algunos lenguajes, las declaraciones pueden agruparse en grupos más grandes para organizar los programas, en paquetes como en Java, en módulos como en ML y Haskell o en espacios de nombres como en C++. Como se ha visto, dependiendo del tipo de declaración se vinculan varios atributos a los identificadores. Cada uno de estos vínculos tienen una determinada posición en el programa que, dependiendo del lenguaje, especifican el alcance (o ámbito) del vínculo, que es la región del programa en la que se conserva dicho vínculo. Es posible referirse al alcance de una declaración siempre que el alcance de todas las ligaduras (que nos interesen) establecidas en dicha declaración sea el mismo. Sin embargo, debe evitarse el uso del concepto alcance referido a los identificadores, porque un mismo identificador puede estar involucrado en varias declaraciones diferentes, cada una con un alcance distinto. En el ejemplo siguiente de código C, las dos declaraciones del identificador x tienen significados y alcances diferentes: void p(void) int x;
{
>
void q(void) char x;
{
} En la mayoría de los lenguajes actuales estructurados en bloques (como C), en los que los bloques pueden anidarse, el alcance de un vínculo queda limitado al bloque en que aparece su declaración. Esta regla se conoce por alcance léxico.
184
SINTAXIS Y SEMÁNTICA BÁSICA
En el siguiente ejemplo de código C: i int x; 3 void p(void) 4 int y; 5 . . . 6 } /* fin
de p */
s void q(void) 9 double z; 10 . . . n } /* fin 13 m a i n ()
{
{
de q * /
{
14
char w;
15
. . .
16 }
se tiene que: • Las declaraciones de la variable x, de los procedimientos p, q y main son globales. • Las declaraciones de y, z y w son locales y sólo válidas en los bloques donde aparecen p, q y main respectivamente. En el lenguaje C hay una regla para indicar que el alcance de una declaración comienza en la propia declaración (regla conocida como declaración antes de usoc;) con lo que ei alcance una declaración se extiende desde que dicha declaración aparece, hasta el final del bloque en el que se encuentra. En el ejemplo anterior es posible, por tanto, afirmar que: • El alcance de la declaración de x, va desde la línea 1 hasta la 16. • El alcance de la declaración de P. va desde la línea 3 hasta la 16. • El alcance de la declaración de y> va desde la línea 4 hasta la 6. • El alcance de la declaración de q» va desde la línea 8 hasta la 16. • El alcance de la declaración de z, va desde la línea 9 hasta la 11. • El alcance de la declaración de main, va desde la línea 13 hasta la 16. • El alcance de la declaración de w, va desde la línea 14 hasta la 16. y
Esto es, un identificado! - debe declararse antes de poder ser utilizado.
SEMÁNTICA DE LOS LENGUAJES DE PROGRAMACIÓN
185
to significa que, por ejemplo, desde la función q es posible llamar a p, puesto que el nierpo de la función q está dentro del alcance de p. Sin embargo, al revés no es cierto, por que desde p no se puede llamar a la función q. El lenguaje C solventa esto permitiendo e las funciones sean declaradas de forma previa a su definición, por lo que el alcance de la declaración se vería modificado. Si se introduce la siguiente línea: void q(void);
el alcance de la declaración de q iría desde la línea 2 hasta la línea 16, cubriendo el cuerpo de p y permitiendo así una llamada a q desde p. Otra característica de la estructura de bloques es que las declaraciones en los bloques anidados toman precedencia sobre las declaraciones anteriores. En el ejemplo siguiente, en el que hay varias variables diferentes con identificador x, la regla de precedencia anterior permite identificar en cada caso la visibilidad de una declaración, es decir, la región del programa en la que los vínculos son aplicables. Es un concepto diferente al de alcance, porque éste incluye a aquellos puntos en los que el vínculo (aunque sigue existiendo) está oculto a la vista: los llamados ocultamientos del alcance (también llamados agujeros o huecos). int x ; void p(void) char x; x = 'a' ;
{
} / * f i n d e p *f n a i n () { x = 2;
} En el ejemplo anterior, la declaración de x en el cuerpo de la declaración de p (línea 4) tiene precedencia sobre la declaración global de x (línea 1), por lo que el entero x no es accesible dentro del bloque que define a p. En este caso se dice que la declaración global de x tiene un ocultamiento en el alcance dentro de p. Para acceder a variables globales no visibles, en algunos lenguajes como en C++, existe un operador de resolución de alcance ( : : ) que puede utilizarse para alterar la visibilidad. En el siguiente ejemplo: int x ; void p(void) char x;
{
186
SINTAXIS Y SEMÁNTICA BÁSICA
6
:: x = 7;
7
. . .
8 } / * f i n de p */ 10 main () { u x = 2; 12
. . .
13 }
se accede a la x global entera en la declaración de p y se le asigna un entero (el 7) y en mair. se le asignará el 2. En otros lenguajes pueden encontrarse diferentes operadores y modificadores de declaraciones que alteren la visibilidad y el alcance de las declaraciones. En C++ además se utiliza el operador de alcance para introducir el alcance de una clase desde el exterior, por ejemplo para proporcionar definiciones que falten, ya que C++ sólo exige declaraciones y no definiciones completas dentro de la declaración de una clase. Se muestra este caso en el ejemplo siguiente: 1 class 2
IntWithGcd
{
. . .
3 public: 4 int i n t V a l { ) /* d e c l a r a c i ó n l o c a l de la f u n c i ó n i n t V a l */ 5 int g c d ( i n t v) /* d e c l a r a c i ó n l o c a l de gcd */ 6 private : 7 int v a l u é ; / * d e c l a r a c i ó n l o c a l d e l campo d e v a l o r e s * / s }; io int u 12 }
I n t W i t h G c d : : i n t Val () { /* el o p e r a d o r de a l c a n c e da v i s i b i l i d a d o a c c e s o a la d e f i n i c i ó n de i n t V a l */ return v a l u é ;
Otro ejemplo de alteración de la visibilidad y el alcance de las declaraciones, es el caso de una palabra clave en una declaración, que modifique el alcance de la misma. Por ejemplo en C, las declaraciones de variables globales pueden ser visibles o accesibles a través de | archivos utilizando la palabra clave e x t e r n : Archivol : extern int x ; / * u t i l i z a
una x de o t r o
archivo
Archivo2: int x ; / * x e s una v a r i a b l e g l o b a l que p u e d e a c c e s i b l e desde o t r o a r c h i v o */
*/
ser
visible
o
187
SEMÁNTICA DE LOS L E N G U A J E S DE PROGRAMACIÓN
uando A r c h i v o l y Archivo2 se compilan por separado y posteriormente se vinculan tre sí, la variable x externa de A r c h i v o l , será identificada en tiempo de linkado con la proporcionada por Archivo2. También puede restringirse que una variable sea visible de el exterior con la palabra clave s t a t i c : rchivo2 : s t a t i c int x ; / * x e s a r c h i v o */
una v a r i a b l e
que s ó l o
es
visible
en e s t e
Ahora no es posible vincular la x A r c h i v o l con la x de Archivo2, con lo que habrá un error de una referencia no definida para x. Las reglas de alcance tienen que tener en cuenta la recursividad, porque la declaración de un identificador de función tiene un alcance que empieza antes de que se introduzca el bloque del cuerpo de la función: int
f a c t o r i a l (int . . . /* se puede
n) { /* el a l c a n c e de f a c t o r i a l llamar aqui a f a c t o r i a l */
empieza
aqui
*/
Pero sólo en los subprogramas, ya que una declaración recursiva de variable como: int
x = x + 1;
no tendría sentido en general, porque la variable x no habría sido inicializada y, por tanto, no se conocería su valor. Sin embargo, en C o C++ no es un error si la variable es local, incrementándose en uno el valor (inicializado aleatoriamente) de x. Otro caso especial relacionado con la recursión se presenta en las declaraciones de clase de los lenguajes orientados a objetos. Las declaraciones locales, en el interior de una declaración de clase, generalmente tienen un alcance que incluye toda la clase, con lo que deja de aplicarse la regla de declaración antes de uso explicada anteriormente. De esta forma el orden de las declaraciones dentro de una clase no es relevante. Por ejemplo, en el código Java de InWithGcd (ya mostrado anteriormente), el dato local v a l u é se referencia en el método i n t V a l , antes de que sea definido. Los vínculos establecidos por las declaraciones se conservan en la tabla de símbolos, la cual se estudia en la siguiente sección. La forma en la que la tabla de símbolos procesa y almacena los vínculos de las declaraciones en un lenguaje estructurado en bloques permite conocer, en todo momento, el alcance y visibilidad de cada declaración.
188
SINTAXIS Y SEMÁNTICA BÁSICA
4.2.3
La tabla de símbolos
Como se observó en la sección anterior, la gestión de los vínculos entre identificadores y sus atributos es una tarea semántica relativamente compleja que exige utilizar una estructura de almacenamiento. En el caso de los lenguajes de programación que incorporan alcance léxico y estructura de bloques, se requiere que la gestión de las declaraciones se realice con una estructura de pila: a la entrada del bloque se procesan todas las declaraciones y se agregan los vínculos correspondientes en la tabla de símbolos, para eliminarlos a la salida del bloque, restaurando así cualquier vínculo anterior que pudiera existir. Por lo tanto la tabla de símbolos contendrá un conjunto de identificadores, y para cada uno de ellos, una pila con las declaraciones asociadas a él, de manera que la declaración en la cima de la pila será la que es visible en cada momento. 1 int x; 2 char y; 4 void p(void) { 5 double x; 6 ... 7 { int y [ 1 0 ] ; 8
. . .
9
)
10
...
ii } / * f i n d e p * / 13 void q (void) { 14 int y; is ... 16 } / * f i n d e q * / 18 main () { 19 char x ; 20
. . .
21 }
En el programa anterior en C los identificadores son x, y, p, q y main, pero x e y están en tres declaraciones diferentes cada uno y con alcances distintos. Si la tabla de símbolos se construye procesando el programa en el orden en el que ha sido escrito, entonces tras procesar la declaración de variable en el cuerpo de p (línea 6), la tabla de símbolos se representa gráficamente en la figura 4.5. Esta representación gráfica indica que, en este punto del programa el vínculo visible de x es el definido por la declaración local de p. Aunque aún se está dentro del alcance de la declaración global de x (porque está en la tabla de símbolos), la cual ha sido ocultada por la local.
S E M Á N T I C A D E LOS L E N G U A J E S D E P R O G R A M A C I Ó N
Identificador
189
Vínculos double \ í int local de p y V global char global
void función
Figura 4.5: Tabla de símbolos en la línea 6. tabla de símbolos al procesar el bloque anidado en p (línea 8) se muestra en la tabla 4.6. Identificador
Vínculos double A local de pj
í int l global
int array local del bloque anidado en p
char global
void función
Figura 4.6: Tabla de símbolos en la línea 8. Tras procesar el bloque anidado (línea 9) se restaura (eliminando la cima de su pila) el vínculo de y, volviéndose al estado representado en la tabla 4.5. Y una vez terminado el proceso de p (línea 11) se restaura el vínculo de x, quedando la situación representada en la tabla 4.7. Identificador
Vínculos '
int
>
^global y ^ char ^
global^ void
función Figura 4.7: Tabla de símbolos en la línea 11.
190
SINTAXIS Y SEMÁNTICA BÁSICA
Y tras procesar las declaraciones de la función q (línea 15), la tabla de símbolos queda com se ve en la tabla 4.8. Identifícador
Vínculos
int
local de q void
función void
función Figura 4.8: Tabla de símbolos en la línea 15. La tabla 4.9 muestra el estado de la tabla de símbolos al acabar el proceso de q (línea 16). Identifícador
Vínculos
void
función Figura 4.9: Tabla de símbolos en la línea 16. Finalmente, la tabla 4.10 representa la tabla de símbolos al procesar main (línea 20). A lo largo de toda la explicación se puede ver cómo se conserva la información del alcance de cada identifícador, incluyendo los ocultamientos del alcance de las declaraciones globales de x (dentro de p y main) e y (dentro de q). Hasta este momento se ha visto la gestión de la tabla de símbolos para el caso de que le> vínculos de las declaraciones sean todos estáticos y se procese el código de un programa Cr manera secuencial con un compilador. En este caso la tabla procesa todas las declaraciones de forma estática, aplicando la regla de alcance léxico (o de alcance estático).
S E M Á N T I C A DE LOS L E N G U A J E S DE P R O G R A M A C I Ó N
Identificador
191
Vínculos char
int
local de main
global
r
char ^
.global y void
función void
función int
función Figura 4.10: Tabla de símbolos en la línea 20.
La tabla de símbolos también puede procesar las declaraciones según aparecen durante la ejecución del programa, lo que se conoce como regla de alcance dinámico. En el siguiente ejemplo, se comparan los efectos de las reglas de alcance dinámico y estático: #include < s t d i o . h > int x = 1 ; char y = ' a ' ; void p(void) { double x = 2 . 5 ; printf("%c\n",y); } / * f i n de p */ void q ( v o i d ) { int y = 4 2 ; p r i n t f ( " %d\n",x); PO ; } / * f i n de q */ int
}
main () { char x = ' b ' ; q () ; return 0; -i
192
SINTAXIS Y SEMÁNTICA BÁSICA
La salida de este programa en C, utilizando el alcance léxico o estático es: 1 a
dado que la primera sentencia p r i n t f de la línea 8, usa la y global, y el segundo p r i n t f de la línea 13 usa el vínculo global de la x, en ambos casos independientemente de la trayectoria de la ejecución. A continuación se va a construir la tabla de símbolos dinámicamente durante la ejecución del programa. Ésta se inicia con main, pero las declaraciones globales deben ser procesadas antes de dicha ejecución, ya que main debe conocer todas las declaraciones anteriores. El aspecto de la tabla de símbolos antes de la llamada a q (línea 19) es el representado en la figura 4.11 l 0 . Identifícador
Vínculos char='b'
local de main fchar='a'
\
global void
función " void "
función ^main^-
int
función
Figura 4.11: Tabla de símbolos con alcance dinámico en la línea 19. Esta tabla es similar a la tabla de símbolos (en el mismo punto) si la función main es procesada con la regla de alcance léxico, salvo que aún no se han procesado los cuerpos de las funciones p ni q. Al seguir con la ejecución, desde main se llama a q y hay que procesar su cuerpo. Tras procesar las declaraciones de q, en la línea 13, la tabla de símbolos se puede ver en la figura 4.12. Esta tabla de símbolos es muy diferente de la que se obtendría con la regla de alcance estático en la misma posición de proceso del programa. Con la regla de alcance dinámico. 10
Se incluyen los atributos de valor únicamente para poder ilustrar la diferencia de funcionamiento de ambas reglas de alcance. Hay que recordar que dichos atributos no se guardan en la tabla de símbolos, sino en la memoria.
S E M Á N T I C A DE LOS L E N G U A J E S DE P R O G R A M A C I Ó N
193
Vínculos
Identificador
char='b'
int=l \
local de main
global)
int=42
char='a'
local de q
global
void
función void
función int
función Figura 4.12: Tabla de símbolos con alcance dinámico en la línea 13.
cada una de las llamadas a una función puede tener una tabla de símbolos diferente en su entrada, dependiendo de la trayectoria de ejecución de dicha llamada. En el caso del alcance estático, sólo habría una posible tabla. Siguiendo con el ejemplo, desde q se llama a p y la figura 4.13 muestra la tabla de símbolos con alcance dinámico al llegar a la línea 8.
Identificador
Vínculos double=2.5
char='b'
int=l
local de p
local de main
global
int=42
char='a'
local de <
global
void "
función void
función int
función Figura 4.13: Tabla de símbolos con alcance dinámico en la línea 8
194
SINTAXIS Y SEMÁNTICA BÁSICA
Si el lenguaje C utilizara (que no lo hace) la regla de alcance dinámico, la salida de este programa sería: 98 • porque el p r i n t f de la línea 13 se refiere a la x ligada al valor b según lo establecido en main. Como la sentencia del p r i n t f imprime el carácter b interpretado como un entero (lo indica la cadena de formato %d\n) que es el valor ASCII 98. El p r i n t f en el cuerpo de p (línea 8) se refiere a la y con valor 42 definido dentro de q. Este valor 42 se debe interpretar como un carácter ASCII (el *), porque lo indica la cadena de formato (%c\n). En general, la regla del alcance dinámico es compleja de implementar y produce diferentes problemas: • La semántica de una función puede cambiar según avanza la ejecución bajo el alcance dinámico. Cuando un identificador no local es utilizado en una expresión o sentencia (dentro de esa función), la declaración que se aplica a ese identificador sólo puede encontrarse a lo largo de la ejecución del programa, por lo que diferentes ejecuciones pueden llevar a diferentes resultados. Un ejemplo en el programa anterior es el caso de la referencia al identificador y en la sentencia p r i n t f de la línea 8, que no se conoce hasta el tiempo de ejecución. Se dice que la semántica de la función p cambia durante la ejecución. • Dado que las referencias a variables no locales no pueden predecirse antes de la ejecución, tampoco pueden definirse los tipos de datos de esas variables. En el ejemplo, la referencia a y en la sentencia p r i n t f de la línea 8, se espera que sea un carácter y por eso se exige imprimir un carácter (%c) en el programa. Sin embargo, con la regla de alcance dinámico, la variable llega ligada a un entero. Como el lenguaje C tiene reglas de conversión entre los enteros y los caracteres, no se produce un error y se imprime lo indicado anteriormente. Pero si la variable llegase ligada a un double o a otra estructura cualquiera definida por el usuario, dependería de la máquina producir o no un error en tiempo de ejecución, algo no deseable en absoluto. Este caso demuestra que el vínculo estático de los tipos de datos y el alcance dinámico son inherentemente incompatibles. Aunque algunos lenguajes no admiten la regla del alcance dinámico (como Pascal o Ada es una opción aceptable para los lenguajes muy dinámicos, los interpretados y en los qut los programas no suelen ser muy grandes (como es el caso de APL, Snobol y Perl). útil porque el ambiente en tiempo de ejecución es considerablemente mas simple que c la regla de alcance léxico (estático). Sin embargo hay que indicar que esto no quiere di que no se puedan escribir interpretes con alcance léxico, en los que la tabla de símbol se mantenga de forma dinámica por definición. El mantenimiento dinámico del ale
S E M Á N T I C A DE LOS L E N G U A J E S DE P R O G R A M A C I Ó N
195
tico, requiere disponer de estructuras extras y una contabilidad esmerada. Lisp (1958) tenido tradicionalmente alcance dinámico, su dialecto Scheme ha usado el alcance léxico el Common Lisp (1982) ofrece el alcance léxico como opción. tabla de símbolos como una tabla única para todo el programa, con inserciones de un anee a la entrada y eliminaciones a la salida, es sólo apropiada para lenguajes simples mo C y Pascal, con el requisito de declaración estricta antes de la ejecución del programa donde los alcances no pueden ser insertados de nuevo tras haber sido eliminados durante proceso de compilación. Pero incluso en estos lenguajes simples, no cubre todas las situaciones posibles. Se muestra en el siguiente ejemplo: Jinclude < s t d i o . h > struct { int a; char b; double c; x = {1, ' a ' , 2 . 5 } ; void p(void) { struct { double a; int b ; char c; } y = { 1 . 2 , 2 , ' b ' }; p r i n t f ("%d, %c, % g \ n " , x . a , x . b , x . c ) ; p r i n t f { "% f, % d, % c \ n " , y . a , y . b , y . c ) ; } / * f i n de p */ main () { p() ; return 0;
} Cada una de las declaraciones s t r u c t en el código anterior (líneas 3-7 y 10-14) debe contener declaraciones adicionales de los campos de datos dentro de cada s t r u c t (líneas 4-6 y 11-13) y deben ser accesibles (utilizando la notación de punto de la selección de miembros) siempre que las variables s t r u c t (x e y) estén en el alcance. Se recuerda que en C las definiciones de registros son constructores que se definen con declaraciones de variables locales (denominadas campos). Luego: 1. Una declaración s t r u c t , contiene en realidad una tabla de símbolos locales, que es en sí misma un atributo (conteniendo las declaraciones miembros). 2. Esta tabla de símbolos local no puede eliminarse hasta que la variable s t r u c t sea eliminada de la tabla de símbolos global del programa.
196
SINTAXIS Y SEMÁNTICA BÁSICA
Cuando se llega a la línea 15, la tabla de símbolos gráficamente es la mostrada en la figura 4.14. Identificador
Vínculos Identificador Vínculos
s t r u c t ^ subtabla global double)
void función Identificador Vínculos
struct subtabla local de p
©
©©-
-(double)
—(¡ñt) —(char)
Figura 4.14: Tabla de símbolos con subtablas. Cualquier estructura que pueda ser referenciada directamente en un lenguaje, debe tener su propia tabla de símbolos. Ejemplos son: todos los alcances nombrados en Ada; las clases, las estructuras y los espacios de nombres en C++; o las clases y los paquetes en Java. Lo mas común en estos lenguajes es tener una tabla para cada uno de los alcances, que a su vez tienen que estar anidados con las tablas que las encierran. De nuevo la pila es una estructura útil con la que gestionar estas tablas de símbolos. En Java, las tablas de símbolos se conocen como espacios de nombre y no deben confundirse con los espacios de nombre de C++. En C++ un espacio de nombre es una zona separada (de la global) donde se pueden declarar y definir objetos, funciones y en general, cualquier identificador de tipo, clase, estructura, etc. al que se asigna un nombre o identificador propio. Ayudan a evitar problemas con identificadores en grandes proyectos o cuando se usan bibliotecas externas. Permite, por ejemplo, que existan objetos o funciones con ei mismo nombre, declarados en diferentes ficheros fuente, siempre y cuando se declaren er distintos espacios de nombre.
4.2.4
Asignación, tiempo de vida y ambiente
El ambiente se encarga de mantener los vínculos de los identificadores con las localizaciones de memoria, y se puede construir estáticamente (como en Fortran), dinámicamente en tiempo de ejecución (como en LISP), o una mezcla de ambos (como en C, C++, Ada | Algol o Java). En los lenguajes compilados no todos los identificadores en un programa están vinculados a localizaciones. Por ejemplo las constantes y los tipos de datos pueden representar directa-
S E M Á N T I C A D E LOS L E N G U A J E S D E P R O G R A M A C I Ó N
197
te cantidades en tiempo de compilación, con lo que no se tienen en cuenta en tiempo de a o de ejecución. Por ejemplo en la declaración de C, c o n s t i n t MAX = 10; el com'or reemplaza todas las apariciones de MAX en el código por el valor 10, con lo que el tificador MAX nunca tiene asignada una localización, ni existe en el programa en tiempo ejecución. o ya se ha indicado, las declaraciones permiten construir el ambiente y la tabla de bolos: en el caso de un compilador, se genera el código de asignación según se procesa la laración y en el caso de un intérprete, que combina la tabla de símbolos con el ambiente, vínculo de atributos de la declaración incluye el vínculo de localizaciones (pero no los res). general, en un lenguaje con estructura de bloques: • Los identificadores de las variables globales se asignan estáticamente porque su significado es fijo en el programa. • Los identificadores de las variables locales se asignan dinámicamente cuando la ejecución llega al bloque. manera análoga a como se utiliza una estructura de pila para mantener las vinculaciones las variables en la tabla de símbolos (en un lenguaje estructurado), en el ambiente tamén se utiliza una pila para vincular las localizaciones a las variables. :{ i n t x; c h a r y; B: { d o u b l e x; int a ; } /*
fin
de B * /
C: { c h a r y; int b; D:
{ int
x;
d o u b l e y; } /* } /* } /*
fin
fin
fin
de D * /
de C * /
de A */
Durante Ja ejecución del código anterior, el ambiente gestiona las asignaciones de las variables a localizaciones. Si se representa gráficamente este proceso con una pila (cada posición
198
SINTAXIS Y SEMÁNTICA BÁSICA
de la pila es una localización concreta de memoria), entonces el ambiente después de la trada en A en la línea 3, será como muestra la tabla 4.5. Dirección de memoria 1 Dirección de memoria 2
X
y
Vínculos de localización de A
Tabla 4.5: Ambiente después de la entrada en A el ambiente después de la entrada en B se ve en la tabla 4.6. Dirección Dirección Dirección Dirección
de memoria 1 de memoria 2 de memoria 3 de memoria 4
X
y X
a
Vínculos de localización de A Vínculos de localización de B
Tabla 4.6: Ambiente después de la entrada a B En la línea 7, a la salida del bloque B, el ambiente es el mismo que después de la entrada en ya que se han eliminado las asignaciones de localizaciones del bloque B. Cuando el proce entra en el bloque C (líneas 8 y 9), se asignan sus variables (y y b) a las localizaciones memoria liberadas por las variables x y a del bloque B. Finalmente, a la entrada del bloq D (líneas 11 y 12), el ambiente se muestra en la tabla 4.7. Dirección Dirección Dirección Dirección Dirección Dirección
de memoria 1 de memoria 2 de memoria 3 de memoria 4 de memoria 5 de memoria 6
X
y y
b X
y
Vínculos de localización de A Vínculos de localización de C Vínculos de localización de D
Tabla 4.7: Ambiente después de la entrada a D Con la estructura de bloques y el alcance léxico, se puede asociar el mismo identific a varias localizaciones diferentes, aunque sólo una de ellas es accesible en cada mome En esta última representación gráfica del ambiente, el identiíicador x está vinculado a localizaciones diferentes durante la ejecución del bloque D y el identiftcador y a tres, au sólo sean accesibles en ese momento la x y la y del bloque D.
SEMÁNTICA DE LOS LENGUAJES DE PROGRAMACIÓN
199
proceso de vinculación de localizaciones en bloques es mas sencillo que el proceso para edimientos o funciones. Esta declaración de función: "d p ( v o i d ) int x ; double y; /*
fin
de p * /
se activa cuando es llamada en tiempo de ejecución. La activación supone que se le gna una memoria denominada registro de activación para asignar localizaciones a sus ables locales (ver capítulo 6, página 262). denomina objeto al área de almacenamiento asignada en el ambiente tras el proceiento de una declaración (no confundirlo con el concepto de objeto de la programación entada a objetos). Las constantes globales y los tipos de datos no son objetos, porque declaraciones no dan como resultado una asignación de almacenamiento. El tiempo de * *a o extensión de un objeto es la duración de su asignación en el ambiente. Las vidas los objetos se pueden extender mas allá de la parte del programa donde son accesibles . viceversa, un objeto puede ser accesible mas allá de su tiempo de vida. En el ejemplo nmediatamente anterior, la declaración del entero x en el bloque A define un objeto cuya xtensión se extiende a lo largo del bloque B, aunque la declaración tiene un ocultamiento ¿e alcance en B, en cuyo interior no es accesible. uando en un lenguaje de programación existen los punteros, cuyo valor almacenado es jna referencia a un área de almacenamiento asignada en el ambiente (objeto), es necesaria una extensión del ambiente. Por ejemplo en C, la declaración i n t * x; al ser procesada por el ambiente no genera la asignación de un objeto al puntero x. Para su inicialización existe, en la mayoría de los lenguajes, el convenio de que el entero 0 es una dirección que no puede ser usada por otro objeto asignado. Es común que los lenguajes definan un literal especial para referirse a esa dirección de memoria inexistente (como n u i l en Java o n i l en Pascal). En general es necesario que haya un proceso de asignación específico incorporado en el lenguaje, aunque algunos lenguajes lo incluyan en una biblioteca, como en C con m a l l o c
X
*
X
y para definir una constante de función con esa expresión: alCuadrado
=
( \x
->
x * x )
De igual manera, un literal de función sin identificador se puede utilizar directamente expresiones sin darles nombre. Por ejemplo: ( \x
-> x * x ) 2
que al calcular la función alCuadrado (pero sin identificador), pasa el valor 2 y devuelve
4.3
Ejercicios resueltos
1. Sea la siguiente gramática expresada en notación EBNF:
205
EJERCICIOS RESUELTOS
1 < e x p r e s i o n > : : = < t e r m i n o > { 0R < t e r m i n o > } 2 < t e r m i n o > : : = < f a c t o r > { AND < f a c t o r > } 3 < f a c t o r > ::= ' ( ' ' ) ' I 4 ::= verdadero | falso | a I b I c
siendo el símbolo inicial. Para cada una de las siguientes cadenas de la gramática, dibujar su árbol sintáctico. (a) verdadero AND ( falso OR verdadero ) Solución: expresión I.
termino
valor I verdadero
expresión termino
(b) verdadero AND falso OR verdadero
OR
termino
factor
factor
i
I
valor
valor
falso
verdadero
Solución: expresión
206
SINTAXIS Y SEMÁNTICA BÁSICA
(c) ( ( a AND b ) OR ( c AND a ) ) Solución: expresión I
termino i
factor
expresión
OR
termino
termino
I
factor
(
factor
exprexion
)
(
expresión
termino factor I
valor i a
AND
)
termino factor I
valor
factor valor
AND
factor valor i a
EJERCICIOS RESUELTOS
a
z<
207
208
SINTAXIS Y SEMÁNTICA BÁSICA
2. Dibujar las reglas de la gramática EBNF del ejercicio anterior como diagramas sintácticos. Solución: (termino)
(expresión)
OR
{valor)
(factor)
(itermino)
AND
(exPresion)
(factor)
* (valor)
»* '
3. Dado el siguiente código estructurado en bloques: 1 A:
{ double x = 0.453;
2
B:
3 4
{ i n t j = 0; char c = ' x ' ;
5
C:
6 7 8
9
{ float float int a int b
x = 3.141592; y = 2.718281; = -15;
= 0;
10
11 12
13 14 15
D:
double c = 0.0; f l o a t x = -15.75;
16
17
IX 19
E:
{ i n t xx = 1; i n t y = 2; f l o a t c = 3.4;
20 21
22
dibujar el estado de la tabla de símbolos (siguiendo la regla de alcance léxico) en las líneas indicadas:
EJERCICIOS RESUELTOS
(a) Línea 2. Solución: Identificador
Vínculos double
local de A (b) Línea 5. Solución. Identificador
Vínculos
(c) Línea 10. Solución: Identificador
Vínculos
209
210
SINTAXIS Y SEMÁNTICA BÁSICA
(d) Línea 16. Solución: Identificador
Vínculos
(e) Línea 20. Solución: Identificador
Vínculos
Es necesario recordar que los valores asociados a cada identificador no se almacén en la tabla de símbolos, sino en la memoria, por lo cual dichos valores no aparee en las tablas de símbolos. 4. Para el listado del ejercicio anterior, indicar el alcance y visibilidad (siguiendo reglaa de alcance léxico y de definición antes de uso) de cada declaración. (a) d o u b l e x = 0 . 4 5 3 ; (lineal). Solución: Alcance: líneas 1 a 13. Visibilidad: líneas 1 a 5 y 12 a 13. (b) i n t
j = 0; (línea 3).
Solución: Alcance: líneas 3 a 12. Visibilidad: la misma, dado que no hay ningún tamiento del alcance debido a otra declaración con el mismo identificador.
EJERCICIOS RESUELTOS
211
(c) c h a r c = ' x ' ; (línea 4). Solución: Alcance: líneas 4 a 12. Visibilidad: la misma. (d) f l o a t x = 3.141592; (línea6). Solución: Alcance: líneas 6 a 11. Visibilidad: la misma. Esta declaración produce un ocultamiento del alcance de la declaración de la línea 1. (e) f l o a t y = 2 . 7 1 8 2 8 1 ; (línea7). Solución: Alcance: líneas 7 a 12. Visibilidad: la misma. (f) i n t a = - 1 5 ; (línea 8). Solución: Alcance: líneas 8 a 11. Visibilidad: la misma. (g) i n t b = 0; (línea 9). Solución: Alcance: líneas 9 a 11. Visibilidad: la misma. (h) d o u b l e c = 0 . 0 ; (línea 14). Solución: Alcance: líneas 14 a 22. Visibilidad: líneas 14 a 18 y línea 22. (i) f l o a t x = - 1 5 . 7 5 ; (línea 15). Solución: Alcance: líneas 15 a 22. Visibilidad: la misma. (j) i n t xx = 1; (línea 17). Solución: Alcance: líneas 17 a 21. Visibilidad: la misma. (k) i n t y = 2; (línea 18). Solución: Alcance: líneas 18 a 21. Visibilidad: la misma. (1) f l o a t c = 3 . 4 ; (línea 19). Solución: Alcance: líneas 19 a 21. Visibilidad: la misma. Esta declaración produce un ocultamiento del alcance de la declaración de la línea 14.
SINTAXIS Y SEMÁNTICA BÁSICA
.4
Ejercicios propuestos
1. Comprobar que con esta expresión regular: A
( h t | f ) t p ( s ? ) \ : \ / \ / [ 0 - 9 a - z A - Z ] ( [ - . \ w ] * [ 0 - 9 a - z A - Z ] ) * ( : (0-9) * ) * ( \ / ? )
( [ a - z A - Z 0 - 9 \ - \ . \ ? \ , \ ' \ / \ \ \ + & % \ $ # _ ] *) ?$
se describe o valida una URL en dos protocolos, el http y el ftp. 2. Reescribir los siguientes diagramas sintácticos (que describen diferentes no terminales de la gramática del lenguaje Pascal [27]) en forma de reglas EBNF:
(c) (ujnt)
(d) (s_exp)
3. Para el listado en C de la página 191, dibujar el estado de las tablas de símbolos en líneas 8, 13 y 19 siguiendo la regla de alcance léxico. Comparar las tablas dibuj las tablas correspondientes que se generaron siguiendo la regla de alcance dinámi 4. Para el mismo listado en C del ejercicio anterior, dibujar el estado del ambiente < guiendo la regla de alcance léxico) en las líneas 8, 13 y 19. 5. Para el mismo listado en C de los ejercicios anteriores, indicar el alcance y visibili de cada declaración (siguiendo la regla de alcance léxico).
NOTAS BIBLIOGRÁFICAS
.5
213
Notas bibliográficas
a la elaboración de este capítulo, la fuente bibliográfica principal ha sido el libro [15], la que se han tomado algunos ejemplos paradigmáticos. También se han consultado los bajos [6], [10] y [27].
Capítulo 5
Tipos de Datos Las computadoras han sido diseñadas para almacenar información y procesarla. Dicha inbrmación puede ser de muy diferentes tipos: numérica, textual, valores de verdad o agrupaciones de pequeños trozos de información para formar estructuras más complejas. Todas estas diferentes clases de información que se almacena en las computadoras es lo que en el ámbito de los lenguajes de programación se denomina tipos de datos. Este capítulo trata sobre los tipos de datos que ofrecen los lenguajes de programación. ¿Qué >on? ¿Cómo se definen? ¿Qué restricciones presentan? En definitiva, qué elecciones deben lomarse a la hora de diseñar un lenguaje de programación para que éste pueda trabajar adecuadamente con diferentes tipos de información. A un nivel muy básico y cercano al hardware, la información almacenada en una computadora se encuentra en forma de unos y ceros, algo que no es muy manejable desde el punto de vista de un programador. Resulta mucho más complicado pensar en el dato binario 01000010 formado por 8 bits1 que en su equivalente decimal 66. Incluso ese dato binario puede estar representando una letra almacenada en código ASCII, en este caso la B (mayúscula). Subiendo el nivel de abstracción, Wirth [28] indica que la información que va a manejar un programa representa, de alguna forma, una abstracción de la realidad, por lo que es deseable que el programador pueda utilizar dicha información de una forma lo más cercana posible a la realidad representada. La información, por tanto, no puede estar almacenada de cualquier manera, sino que se debe estructurar de manera adecuada para que el acceso a la misma y, lo que es igual de importante, la comprensión de la semántica de su contenido, no supongan un gran esfuerzo al programador.
1
Un bit es un dígito binario (Binar}' digit cuyo valor puede ser 0 o 1. El término hit fue empleado por primera vez con este propósito por Claude E. Shannon en [21], donde atribuye su concepción a J. W. Tukey. 215
T I P O S DE DATOS
216
No existe un consenso generalizado entre los diseñadores de lenguajes sobre cuanta información sobre los tipos de datos utilizados debe indicarse en un programa para poder realizar una verificación de tipos en tiempo de traducción. Aquellos lenguajes en los que se hace necesario expresar los tipos de datos de forma explícita y que realizan una estricta verificación de tipos en tiempo de traducción se denominan lenguajes fuertemente tipados (o tipificados). A favor de este tipo de lenguajes se pueden citar una serie de razones: 1. Los compiladores pueden optimizar la gestión de la memoria reservada para almacenar los datos y generar código que los maneje eficientemente si conocen la estructura de los tipos en tiempo de compilación. Esto mejora la eficiencia de la ejecución. 2. Relacionado con lo anterior, al conocer la estructura de los tipos de datos se puede reducir el código que necesita ser compilado, mejorando la eficiencia de la traducción. 3. Muchos errores de programación debidos a un mal uso de los tipos (que se conocen como errores de tipo 2 ), pueden ser evitados, mejorándose la capacidad de escritura. También se mejora, por tanto, la seguridad y la contabilidad de un programa y permite eliminar posibles ambigüedades (por ejemplo se puede resolver la sobrecarga de la suma si se conocen los tipos de datos de los números a sumar). 4. Al incluir de forma explícita la información sobre los tipos de datos en los programas, mejora su legibilidad. Sin embargo, en las décadas de 1960 y 1970 algunos autores consideraron que los lenguajes fuertemente tipados imponían una disciplina demasiado rígida a los programadores, reduciendo la capacidad de escritura de los programas. Desde entonces, los lenguajes han evolucionado en este sentido, ofreciendo una mayor flexibilidad en el uso de los tipos de datos.
5.1
Tipos de Datos
Para realizar el estudio de los tipos de datos es necesario introducir una serie de conceptos 1. Un tipo abstracto de datos representa una abstracción de la realidad con independencia de la implementación concreta que se realice del mismo. Se representa mediante el comportamiento semántico de las operaciones que se pueden realizar a los» elementos de dicho tipo. 2
Por ejemplo sumar valores booleanos.
T I P O S DE DATOS
217
Por ejemplo, se puede considerar el tipo abstracto de datos que representa las pilas. Para caracterizar dicho tipo abstracto de datos hay que definir primeramente una función que devuelva un elemento p i l a V a c i a que represente una pila que no contiene ningún elemento y a continuación la operación push, que apila un nuevo elemento en una pila ya existente. Añadiendo las operaciones e s V a c i a (que dice si una pila es vacía o no), cima (devuelve el elemento que está en la cima de una pila no vacía) y pop (que desapila el elemento situado en la cima de una pila no vacía) y las propiedades que cumplen estas operaciones, ya estaría completamente definido este tipo abstracto de datos. 2. Una estructura de datos es la representación computacional concreta de la organización de los datos del tipo abstracto en términos de los tipos de datos primitivos 3 que ofrece el lenguaje. Siguiendo con el ejemplo anterior, en Java se puede almacenar una pila de datos mediante un array y un valor entero que indique la posición del último elemento apilado. En Haskell se puede hacer mediante una lista, considerando que la cabeza de la lista contiene la cima de la pila. 3. Finalmente, un tipo de datos es el resultado de dotar a una estructura de datos de las operaciones definidas en el tipo abstracto de datos, lo que permite su uso efectivo en un programa. En nuestro ejemplo, el tipo de datos pila en Java necesitaría de la representación de las pilas mediante un array (o una lista en Haskell) y un entero y de la implementación de las funciones p i l a V a c i a , push, e s V a c i a , cima y pop para trabajar sobre esa estructura de datos. De este modo, un programa podrá utilizar las pilas sin necesidad de conocer la representación interna de los datos. Cualquier estructura del lenguaje susceptible de ser tipada (una variable, una constante, una función...) suele estar acompañada de una declaración de tipo. En java, estas declaraciones tienen la siguiente forma: int x ;
Según esta declaración, la variable x va a contener datos de tipo i n t (enteros). La palabra reservada i n t es el identificador del tipo de la variable x. De esta forma se está indicando cómo se ha de almacenar internamente un dato referenciado por dicha variable (es decir, se indica la estructura de datos a utilizar) y qué operaciones pueden realizarse sobre dicho dato (lo que es una referencia al tipo abstracto de datos). Opcionalmente, las declaraciones pueden asociar un valor inicial a la variable, como en: 3
Que son aquellos tipos de datos que el lenguaje ofrece de serie.
218
TIPOS DE DATOS
i n t x = 6 6; Así, además se indica que la variable x va a referenciar inicialmente un valor que representa al número 66. Otro ejemplo sería: char c = ' B ' ; De esta forma, el valor 01000010 almacenado internamente en su forma binaria podrá representar al número entero 66 (referenciado por la variable x) o a la letra B (referenciado por la variable c), pues la estructura de datos para ambos tipos puede coincidir (si el lenguaje los representa mediante un byte 4 ). Lo que permite discernir cuál es su significado real es el tipo abstracto de datos, es decir, las posibles operaciones que se pueden realizar sobre dicho dato. Por ejemplo: los números pueden ser sumados, pero los caracteres no. Así, gracias a las declaraciones de tipos de los diferentes elementos del programa, es posible realizar la llamada v e r i f i c a c i ó n de t i p o s , que es el proceso por el cual el intérprete o compilador comprueba que los programas utilicen de forma correcta los datos. Por ejemplo la sentencia: c = c + 1;
aunque es sintácticamente correcta, carece de una semántica adecuada si anteriormente se había declarado la variable c como de tipo c h a r , pues el tipo abstracto de datos que representa los caracteres no incluye la suma como una de sus operaciones, aunque la estructura de datos utilizada por la implementación concreta del tipo de datos sí permita que se realice internamente dicha operación. Sin embargo, algunos lenguajes como C permiten que se realicen operaciones sobre variables que no pertenecen a su tipo abstracto de datos. Esto dificulta bastante la legibilidad de I sus programas pues permite que los programas realicen operaciones de bajo nivel sobre la i estructura de datos, lo cual obliga al programador a conocer cómo ésta se implementa. Este tipo de lenguajes se conoce a veces como lenguajes débilmente tipados. Otros lenguajes como Prolog no poseen ninguna información sobre los tipos. Por ejemplo I en Prolog es perfectamente posible realizar la comparación: 3 = True
sin que se produzca un error de tipos. El resultado de dicha comparación, como 3 no unifi con True, será falso. Por este motivo, se dice que Prolog es un lenguaje no tipado. 4
Un byte es una unidad de información en informática y telecomunicaciones que normalmente consiste í t | un grupo de 8 bits. El término fue acuñado por Werner Buchholz en [5] (publicado en 1962 aunque el te original data de 1956).
TIPOS DE DATOS
219
La verificación de tipos no sólo permite comprobar que no haya errores de tipos, sino que también permite resolver la sobrecarga. Ante una sentencia como: z = 5 /
2;
dado que el compilador conoce el tipo de la variable z puede deducir qué tipo de división se debe realizar. Si z fuese de tipo i n t se debe aplicar la división entera por defecto, asignando a z el valor 2. Por el contrario, si z fuese de tipo r e a l , se aplicaría la división real y en z se almacenaría el valor 2.5. Así pues la misma expresión 5 / 2 puede ser de tipo i n t o de tipo r e a l y es el lenguaje el responsable de deducir su tipo en base a la información de tipos que se le haya indicado. Este proceso se conoce como inferencia de tipos y puede realizarse conjunta o independientemente de la verificación de tipos. Como ya se ha visto, algunos lenguajes como Haskell presentan un complejo sistema de inferencia de tipos y no necesitan que se declaren de forma explícita los tipos de las funciones y los datos que éstas manejan. Los lenguajes ofrecen, además, la posibilidad de definir nuevos tipos de datos más complejos basándose en los tipos básicos, que se denominan tipos definidos por el usuario (o definidos por el programador). Esto se realiza mediante los constructores de tipos. En esencia, dado que los tipos de datos son conjuntos de valores, los constructores de tipo se corresponden con operaciones sobre conjuntos. El constructor de tipos más común de todos el array, cuya declaración en Java sería: int [ ] x ; ; x = new i n t [ 1 0 ] ;
Mediante esta declaración se define una variable x que va a contener conjuntos de enteros (línea 1) para a continuación indicar que dichos conjuntos contendrán 10 enteros (línea 2) que se referencian mediante dicha variable y serán almacenados de forma interna en una estructura que permita acceder directamente a cualquiera de ellos mediante un índice que va de 0 a 9. La declaración anterior no está dando un nombre a ese nuevo tipo de datos, lo cual se conoce como tipo anónimo. Si bien dar un nombre al nuevo tipo no es estrictamente necesario para utilizarlo, sí que es deseable tanto para aportar documentación extra a los programas como para facilitar la verificación de tipos. Esto se realiza mediante una definición de tipo. En Java se ha de realizar mediante la definición de una clase que encapsule el tipo: public class ArraysEnteros : p r i v a t e i n t [ ] x; 3
//
Constructor
de la
{
clase
que
crea
el
array
T I P O S DE DATOS
220
public ArrayEnteros(int x = new i n t [ t a m ] ;
tam)
{
}
//
Método
para
devolver
el
public int getElemento(int return x[idx];
elemento
idx)
idx
{
}
} con lo cual ahora es posible declarar una nueva variable y como perteneciente al tipo de datos A r r a y E n t e r o s : ArrayEnteros y; y = ArrayEnteros(10);
Ambas líneas realizan la misma labor que las anteriores, pero ahora con un tipo definido por el usuario. Al trabajar con tipos definidos por el usuario, el proceso de verificación de tipos se complica, pues en muchas ocasiones se deben comparar dos tipos de datos para comprobar si son el mismo. Por ejemplo, con las declaraciones anteriores de x e y se podría presentar la sentencia: x = y;
Si bien tanto x como y representan datos del mismo tipo abstracto de datos y, de hecho, están almacenadas utilizando la misma estructura de datos, la variable x tiene un tipo anónimo, mientras que y es de un tipo de datos con nombre propio. Así, cada lenguaje debe incluir una serie de reglas para comprobar si dos tipos posiblemente provenientes de diferentes declaraciones de tipo son o no equivalentes. Este problema se conoce como equivalencia de tipos y se tratará con más detalle en la sección 5.2. Los mecanismos ofrecidos por un lenguaje para la creación de nuevos tipos, más los algoritmos de verificación, inferencia y equivalencia de tipos se conocen de forma conjunta como sistema de tipos del lenguaje. Así, los lenguajes fuertemente tipados poseen un sistema de tipos estático^ y garantiza que los programas peligrosos (aquellos programas que contienen errores de tipos que pueden corromper los datos como el acceso a un componente inexistente de un array o una división por cero) o bien son rechazados en tiempo de compilación o bien son capaces de generar un error de ejecución antes de que se produzca una corrupción de los datos. 5
Esto es, que puede ser aplicado en tiempo de compilación, sin que el programa se esté ejecutando.
T I P O S DE D A T O S
221
Sin embargo, este tipo de lenguajes pueden rechazar los programas seguros (aquellos programas que no son peligrosos). Por ejemplo si los algoritmos de equivalencia de tipos no *)n capaces de deducir que las anteriores variables x e y representan datos del mismo tipo abstracto de datos, la sentencia: *
= y;
sería rechazada por el compilador o intérprete (de hecho, Java lo rechazaría), aunque su ejecución no provoque una corrupción en los datos. Así pues, los programas correctos (aquellos que pueden ser aceptados por un intérprete o compilador) son un subconjunto de tos programas seguros. Por lo tanto, el principal reto de un sistema de tipos estático es minimizar el conjunto de programas seguros incorrectos. No todos los lenguajes tienen un sistema de tipos estático susceptible de ser aplicado en tiempo de compilación. Se conocen como lenguajes con tipado dinámico. Un ejemplo de este tipo de lenguajes sería Scheme, en el cual las variables son tipadas de forma dinámica 6 . Por ejemplo: define x valor) permite definir en Scheme una variable global x cuyo tipo dependerá del tipo de la expresión v a l o r , con independencia de si dicho tipo puede (o no) ser determinado en tiempo de compilación. De esta forma, ningún programa seguro es incorrecto, a costa de sacrificar la eficiencia de ejecución de los programas. El resto de esta sección se dedica a realizar un recorrido por los tipos de datos más comunes presentes en los lenguajes de programación.
5.1.1
Tipos de datos atómicos
Un tipo de datos se considera un tipo atómico si no es posible separarlo en diferentes elementos más sencillos. Tipos atómicos serían, por ejemplo, los números o los caracteres. Todos los lenguajes de programación ofrecen estos tipos atómicos ya predefinidos, sin embargo no todos los denominan de igual manera. Salvo los números reales, los tipos atómicos son tipos discretos, ya que dado un valor es posible definir (con independencia de la implementación) cuál es su sucesor y/o su predecesor (en caso de que cualquiera de ellos exista). En los números reales esto no es posible, ya que esas definiciones dependerían de la implementación concreta del tipo. A continuación se muestran los tipos atómicos más comunmente utilizados en los lenguajes de programación, comparándolos principalmente en los lenguajes Java y Haskell: 6
Es decir, el tipo de una variable dependerá del flujo de la ejecución del programa.
222
TIPOS DE DATOS
Enteros Java ofrece los tipos b y t e , s h o r t , i n t y l o n g para almacenar números enteros. La diferencia entre ellos es el tamaño que ocupan en memoria (y, por tanto, el rango de enteros que pueden representar): el tipo b y t e ocupa (lógicamente) un byte, el s h o r t dos, i n t cuatro y l o n g ocho bytes. En Haskell, como ya se vió en el capítulo 2, hay dos tipos de enteros: I n t (acotados con un tamaño prefijado que depende del compilador y la máquina) e I n t e g e r (sin acotar, a costa de una menor eficiencia en su uso). Prolog dispone del predicado predefinido i n t e g e r () que será cierto siempre que sea aplicado a un número entero. Las operaciones que comúnmente se definen sobre este tipo de números son las operaciones aritméticas básicas, esto es: suma, resta, producto, división entera y el módulo (resto de la | división entera). Keales Los números reales se suelen representar utilizando el estándar IEEE-754 7 , que establece una serie de formatos de almacenamiento de números mediante una mantisa y un exponente Un número así representado se corresponde con: mantisa* \0exponente Tanto Java como Haskell ofrecen dos tipos de datos que almacenan reales, los cuales pueden verse en la tabla 5.1. En Prolog el predicado f l o a t será cierto siempre que se aplique a un número real. E2| tratamiento de estos números depende, al igual que en el caso de los enteros, de la implementación concreta de Prolog. Además de las operaciones aritméticas básicas (con la salvedad de que la división pasa a sodivisión real y la operación módulo carece de sentido en este tipo de datos), se suelen implementar la raíz cuadrada, una función para obtener el logaritmo neperiano de un número] y la exponenciación. Caracteres En Java existe el tipo c h a r , que está almacenado en forma de un entero de dos bytes longitud (equivalente en tamaño a un entero s h o r t ) . Los datos se almacenan siguiendo 7
IEEE 754 es un estándar establecido en 1985 para computación con números almacenados en c flotante. Su uso ha trascendido hasta el propio hardware y es común que los procesadores incluyan ins ciones nativas de manipulación de números en coma flotante siguiendo este estándar.
TIPOS DE DATOS
Lenguaje Simple precisión (32 bits) Mantisa: 23 bits Exponente: 8 bits Signo: 1 bit Doble precisión (64 bits) Mantisa: 52 bits Exponente: 11 bits Signo: 1 bit
223
Java
Haskell
float
Float
double
Double
Tabla 5.1: Comparativa de tipos reales entre Java y Haskell Operación ¿Es un espacio? ¿Es un dígito? ¿Es una letra? ¿Está en mayúsculas? ¿Está en minúsculas? Transformar a mayúsculas Transformar a minúsculas
Java
Haskell
isWhiteSpace
isSpace
isDigit isLetter
isAlpha
isUpperCase
isUpper
isLowerCase
isLower
toUpperCase
toUpper
toLowerCase
toLower
Tabla 5.2: Operaciones sobre caracteres en Java y Haskell estándar UNICODE 8 . En Haskell está el tipo Char, que sigue el estándar ASCII-7. Las operaciones que suelen implementarse para trabajar con caracteres incluyen funciones que sólo tienen sentido dentro de este tipo de datos, por ejemplo Java y Haskell ofrecen (entre otras) las operaciones mostradas en la tabla 5.2.
Booleanos En Java se denominan b o o l e a n , mientras que en Haskell Bool. Este tipo de datos permite almacenar y operar con valores lógicos. En Prolog, al tratarse de un lenguaje basado en la lógica, todos los predicados tienen un valor de verdad, por lo que los valores booleanos son algo común en este lenguaje. Las operaciones entre booleanos son las comunes del Álgebra de Boole 9 . n
UNICODE es un estándar de codificación de caracteres que facilita el tratamiento informático de textos en múltiples idiomas, incluyendo lenguas muertas. Es compatible con estándares más antiguos como ASCII-7. 9 Denominada así en honor a George Boole, que fue el primero en definirla en su obra "£/ análisis matemático de la lógica" de 1847 [3].
224
T I P O S DE DATOS
Tipos Enumerados Los tipos de datos enumerados, a pesar de ser tipos atómicos, son tipos que pueden ser definidos por el usuario. Se definen mediante una enumeración (de ahí su nombre) de los elementos que los componen, junto a las operaciones que se pueden realizar sobre dichos elementos. Por lo tanto, una de sus características más importantes es que el conjunto de posibles valores de los datos ha de ser finito. Como primer ejemplo de tipo enumerado están los valores booleanos, que deben considerarse como un tipo enumerado ya predefinido cuyos elementos son False y True, sobre los cuales se definen las operaciones lógicas habituales 10 . Otro ejemplo típico de un tipo de datos enumerado es el de los días de la semana, cuya definición en Java podría hacerse como sigue: 1 enum D i a S e m a n a { 2 LUNES, MARTES, MIERCOLES, JUEVES, 3 // Método que i n d i c a si el d í a es
4 5 6
public boolean esFinde() { if ( t h i s == SABADO || t h i s e l s e return f a l s e ;
VIERNES, SABADO, DOMINGO; f i n de semana o no
== DOMINGO ) r e t u r n t r u e ;
7
con lo que se puede utilizar este nuevo tipo de datos en una definición: DiaSemana
DI = D i a S e m a n a . L U N E S ;
y la siguiente expresión permitiría consultar si DI es fin de semana DI.esFinde()
que devolvería false. Mientras que en Haskell se haría con data: i d a t a DiaSemana na = LUNES 2
3 esFinde
| MARTES | MIERCOLES | JUEVES VIERNES | SABADO | DOMINGO x = ( x == SABADO ) || ( x == DOMINGO )
siendo el tipo de la función e s F i n d e : esFinde 10
::
DiaSemana - > Bool
De hecho, en Haskell se definen así (página 60).
|
TIPOS DE DATOS
225
Rangos Los rangos son, al igual que los enumerados, tipos definidos por el usuario. Toman los • alores de un subconjunto de elementos contiguos de un tipo discreto, que se definen dando el menor y el mayor de los elementos que conforman el rango. Las operaciones se heredan de las operaciones del tipo discreto sobre el que se ha definido el rango. Ni Java ni Haskell permiten de forma directa la creación de rangos como nuevos tipos de datos. En ambos lenguajes se debe utilizar un tipo de datos predefinido y controlar directamente que los datos no se salgan del rango seleccionado. Otros lenguajes, como Pascal o Ada sí que permiten la definición de este tipo de datos, delegando en el propio lenguaje la comprobación de la pertenencia de los valores a los rangos definidos. Por ejemplo: type Mayúsculas Minúsculas
= = 'a'..'z';
En el caso de Ada es posible, además, definir rangos sobre un tipo no discreto como es el de los números reales.
5.1.2
Tipos de datos estructurados
Los tipos de datos estructurados, en contraposición a los tipos atómicos, son aquellos cuyos valores pueden descomponerse en otros más simples. Por ejemplo, una pila de enteros almacena múltiples números enteros. A continuación se presentan los constructores de tipos estructurados más habituales en los lenguajes de programación.
Array s Los arrays (en ocasiones horriblemente traducidos como arreglos) representan estructuras matriciales de una o varias dimensiones, en las que es posible acceder directamente a uno cualquiera de sus elementos sin más que indicar los índices del elemento deseado en todas las dimensiones del array. En Java (al igual que en otros lenguajes como C) el conjunto de índices ha de ser siempre un rango de enteros positivos que comienzan en cero. En otros lenguajes como Pascal es posible definir arrays cuyos índices pertenezcan a cualquier tipo discreto ordenado, incluso a un tipo enumerado definido por el usuario: type //
Los
dias
de la
semana
como
tipo
enumerado
226
TIPOS DE DATOS
DiaSemana = ( Lunes , M a r t e s , M i e r c o l e s , J u e v e s , V i e r n e s , Sabado , Domingo); 4 var 5 S a l a r i o : array [ L u n e s . . V i e r n e s ] o f Real; 3
Una declaración de array en Java se realiza de la siguiente forma: 1 int[] x1; 2 x l = new int [ 1 0 ] ; 3 int[] x2; 4 x 2 = new i n t [ 1 0 0 ] ;
//
Array
unidimensional
de
10 e n t e r o s
//
Array
unidimensional
de
100
enteros
A diferencia de Pascal, que requiere que la definición del tipo del array incluya su tamaño, en Java los arrays (como tipo) no tienen un tamaño prefijado, ya que el tamaño de un array se define en el momento de su creación y se conserva como una propiedad del mismo, sin que sea posible modificarlo posteriormente. En el ejemplo anterior, las variables xl y x2 tienen el mismo tipo "array de enteros*\ aunque su tamaño sea diferente en el momento de ser instanciadas. Para conocer el tamaño de un array en Java se utiliza su propiedad . l e n g t h . Así: xl.length vale
10
x2.length vale
100
De esta forma, si se desea crear una función que reciba un array de enteros como parámetro de entrada, bastará con definir una función que acepte cualquier array de enteros con independencia de su tamaño. Por el contrario, en Pascal sería necesario escribir tantas funciones diferentes como tamaños de array deban ser considerados. Los arrays no tienen por qué ser unidimensionales, sino que pueden tener cualquier número de dimensiones. Para declarar arrays multidimensionales en Java se utiliza la siguiente sintaxis: 1 int[][] y; 2 y = new int[3][4];
que representa que y es un array unidimensional de arrays unidimensionales (línea 1). Después se concretan los tamaños (línea 2) indicando que y es un array unidimensional que contiene tres elementos, cada uno de los cuales es, a su vez, un array unidimensional de cuatro enteros. Así y. l e n g t h vale 3, mientras que y [2] . l e n g t h vale 4. Por otro lado, en los lenguajes funcionales puros como Haskell, dado que no existe una zona de memoria que se modifica a medida que se realiza el cómputo, el concepto de array como tabla que permite un acceso directo a uno cualquiera de sus componentes no existe. En SL
TIPOS DE DATOS
227
lugar, se pueden utilizar listas para simular arrays, aunque ello implica que los accesos a las componentes no se realizan en tiempo constante.
Funciones Considerar las funciones como un tipo de datos puede resultar un poco chocante al principio. Sin embargo, dado que los tipos de datos son conjuntos de valores, nada impide considerar el conjunto de todas las funciones con un determinado tipo como un tipo de datos con todas sus consecuencias. Esto es particularmente importante en aquellos lenguajes en los que las funciones son ciudadanos de primera clase como en Haskell. Por ejemplo, las funciones i s S p a c e , i s D i g i t , i s A l p h a , i s U p p e r e i s L o w e r que se mencionaron al hablar de los caracteres
(ver tabla 5.2) pertenecen al tipo de datos Char -> Bool, que representa al conjunto de todas las funciones que tienen por dominio el conjunto representado por el tipo de datos Char y por codominio el conjunto de los valores del tipo de datos Bool.
Producto Cartesiano El producto cartesiano de dos conjuntos U y V se define como los pares ordenados (m, v) en los que ueü y ve V. Análogamente es posible definir el producto cartesiano de cualquier número de conjuntos, siempre que se respete el orden de aparición de los elementos. Sobre este tipo se define una serie de funciones de proyección, que dado un elemento del producto cartesiano permiten obtener cada uno de los elementos de los conjuntos originales que lo forman. En el caso anterior con sólo dos conjuntos, dichas funciones serían: pu
((u, v))
= u
pv
((u, v))
= v
En Haskell es posible construir este tipo de datos de forma pura mediante el uso de tupias. Así, para definir el tipo I n t x Char se puede usar el tipo ( I n t , C h a r ) . En otros lenguajes, como C o Pascal, este tipo de datos se construye mediante registros. Por ejemplo en C se utiliza la construcción s t r u c t : typedef struct int i; char c;
{
} I n t Char ;
mientras que en Pascal se utiliza r e c o r d :
228
TIPOS DE DATOS
1 type IntChar 2 i:integer; 3 c:char; 4 end;
= record
Los registros contienen más información que un mero producto cartesiano, ya que en los dos ejemplos anteriores cada una de las componentes que forman el tipo producto tiene un nombre, mientras que los productos cartesianos puros (como las tupias de Haskell) sólo diferencian los componentes según la posición. Aunque es necesario recordar que Haskell también permite la definición de tipos de datos producto cartesiano en los que cada una de las componentes reciben un nombre (ver página 61). En los lenguajes orientados a objetos como Java, es posible crear tipos producto cartesiano mediante el uso de clases, como aquí: 1 public class int i; 3 c h a r c;
IntChar
{
2
4
//
5
public
Constructor
del
6
//
7 8
p u b l i c i n t g e t l n t O { r e t u r n i; } p u b l i c c h a r g e t C h a r ( ) { r e t u r n c; }
IntChar(int
Funciones
tipo
u,
c h a r v)
{ i = u;
c = v;
}
de p r o y e c c i ó n
9 }
Esta definición de clase incluye, además, las definiciones de las funciones proyección (líneas 7 y 8).
Tipos Recursivos Un tipo de datos recursivo es aquel en el que el tipo que está siendo definido aparece en su propia definición. Por ejemplo, es posible definir los árboles binarios de enteros de forma recursiva como sigue: • Un elemento que representará un árbol que no contiene ningún entero. • Un constructor que recibe un entero i y dos árboles binarios, 1 y r, de enteros para producir un nuevo árbol binario de enteros cuya raíz es un nodo que contiene el entero i y del que cuelgan los árboles binarios 1 y r. En Java, los tipos recursivos deben implementarse mediante clases. Por ejemplo, los árboles binarios de enteros se podrían definir como sigue:
TIPOS DE DATOS
229
ass N o d o A r b B i n E n t { int d a t o ; // Dato d e l nodo NodoArbBinEnt a i ; / / Arbol i z q u i e r d o NodoArbBinEnt ad; // Arbol d e r e c h o // Constructor del tipo public N o d o A r b o l (int d a t o ) { this.dato = dato; ai=ad=null;
}
class A r b B i n E n t { private N o d o A r b B i n E n t r a i z ; // Y aqui se d e f i n i r í a n las
funciones
sobre
el
tipo
de datos
mientras que en Haskell, como ya se vió, es posible realizarlo de una forma mucho más sencilla en una única línea de código: data A r b B i n E n t
= ArbolVacio
|
Nodo Integer A r b B i n E n t
ArbBinEnt
Unión de tipos Una de las operaciones más comunes con conjuntos es la unión, que también puede aplicarse a los tipos. Así, el conjunto de posibles valores del tipo unión se forma mediante la unión de los conjuntos de los tipos que se están uniendo. Por ejemplo, el conjunto unión de enteros y caracteres, I n t O r C h a r , estaría formado por todos los enteros y los caracteres. Por lo tanto, no es posible clasificar una unión de tipos como un tipo atómico o estructurado sin tener en cuenta los tipos que intervienen en dicha unión. Si uno de ellos es estructurado, entonces el tipo resultado también lo será, mientras que si todos los tipos que participan en la unión son atómicos, el tipo resultado será atómico. Las uniones pueden ser discriminadas si cada elemento del tipo debe ser identificado con el tipo concreto al que pertenece (es decir, mediante un discriminante de tipo) o indiscriminadas en caso contrario. Un ejemplo de lenguaje con uniones indiscriminadas es C con la construcción u n i ó n : typedef unión int i ; char c; } IntOrChar; IntOrChar x;
{
230
T I P O S DE DATOS
De esa forma el tipo I n t O r C h a r es definido (líneas I a 4) y la variable x se declara como perteneciente a dicho tipo (línea 5), con lo que x . i es de tipo i n t y x . c es de tipo char. pero no constituyen un discriminante de su tipo, ya que representan los posibles valores de x. Podría parecer que no hay diferencia entre las uniones y los productos cartesianos, ya que el acceso a las componentes es idéntico (recuérdese el tipo I n t C h a r definido en la página 227 como producto cartesiano). Sin embargo, mientras que en el producto cartesiano x. i y x. c coexisten al mismo tiempo, en las uniones no es así, ya que ambos identificadores están referenciando la misma posición de memoria y es cometido del programador utilizar el identificador adecuado. Por ejemplo.! con la declaración anterior de la variable x de tipo I n t O r C h a r , el siguiente código: x . i = 65; printf(x.c) ; asigna el valor 65 a x. i (línea 1) e imprime A (línea 2) porque es el valor que se interpreta ¡ al considerar el 65 como un carácter11. En Haskell es posible crear uniones discriminadas mediante el uso de la construcción data (ver página 61): data
IntOrChar
= Entero
Int
|
Caracter
Char
de forma que los elementos de este tipo de datos Entero 7 Char ' B '
tienen un discriminante que indica el tipo de datos concreto al cual pertenecen. El lenguaje Java no permite la creación de tipos mediante la unión de otros tipos, aunque existe un mecanismo similar a la unión discriminada, aunque mucho más potente, que es LA herencia de clases propia de los lenguajes orientados a objeto.
5.2
Equivalencia de Tipos de Datos
Uno de los componentes cruciales en un sistema de tipos es el mecanismo de equivalen de tipos, que es el responsable de decidir si dos tipos son o no iguales (o compatibles óe ] alguna forma). Una primera solución a este problema es considerar que los tipos representa 11
El código ASCII de la letra A es, precisamente, 65.
EQUIVALENCIA DE TIPOS DE DATOS
231
conjunto de valores y, por tanto, comparar si los conjuntos de valores representados por dos tipos que se desean comparar son iguales o no. ta visión de la equivalencia de tipos se denomina equivalencia estructural y decide que s tipos son equivalentes si se han construido exactamente de la misma forma, es decir, licando los mismos constructores de datos (y en el mismo orden) a los mismos tipos sobre que se han construido. forma de comprobar la equivalencia estructural consiste en substituir en la declaración una variable el nombre del tipo por su definición. De esta forma se puede comprobar fácilmente si dos variables tienen tipos equivalentes aunque éstos sean distintos. Por ejemplo, el lenguaje C implementa una equivalencia estructural para comprobar la gualdad de dos tipos de datos, por lo que la definición en C del tipo I n t Char vista anteriormente: typedef s t r u c t int i ; c h a r c;
{
IntChar;
NO es equivalente a la siguiente: typedef s t r u c t char c; int i ;
{
} IntChar2;
ya que se han intercambiado las líneas donde se definen los tipos c h a r e i n t dentro del s t r u c t . Sin embargo, a pesar de que los tipos I n t C h a r e I n t C h a r 2 no son equivalentes estructuralmente, en abstracto sí que son capaces de almacenar los mismos elementos y el acceso a los mismos es idéntico en ambos casos. Este modelo de equivalencia de tipos no da una respuesta única a determinadas preguntas que pueden surgir, de forma que en cada lenguaje concreto el comportamiento puede ser diferente. Si ahora se considera la siguiente definición de tipo: typedef struct i n t x; char y;
{
} IntChar3;
se puede ver que los tipos I n t C h a r e I n t C h a r 3 se construyen aplicando los mismos constructores y en el mismo orden. Es posible preguntarse ahora si los nombres de las componentes influyen en la equivalencia estructural. En general la respuesta es que estos dos tipos
232
TIPOS DE DATOS
no van a ser estructuralmente equivalentes, pues el acceso a los datos se realiza mediante diferentes nombres de variable. Aunque nuevamente I n t C h a r e I n t C h a r 3 son capaces de almacenar los mismos elementos. Otra de las preguntas que implica el uso de equivalencia estructural de tipos está relacionada con el uso de tipos anónimos, aunque se puede solventar si el compilador o intérprete asigna un nombre de tipo a ese tipo anónimo de forma interna. Así, se puede comprobar la equivalencia estructural como si el tipo hubiera sido declarado en una definición de tipo, aunque ésta sea interna al compilador y no conocida por el programador. Sin embargo, ante tipos recursivos, si se desea comprobar la equivalencia estructural substituyendo el nombre del tipo por su definición, como dentro de la definición vuelve a estar el nombre del tipo, se llegaría a un proceso infinito. Para evitar este problema, en lugar de eliminar los nombres de los tipos como se hace en la equivalencia estructural, se puede ser más estricto y decir que dos tipos son equivalentes sólo si tienen el mismo nombre. Esto se conoce como equivalencia en nombre. Esta forma de equivalencia de tipos es mucho más fácil de implementar: dos tipos son equivalentes sólo si tienen el mismo nombre. Consecuentemente, dos variables son equivalentes en nombre si en sus declaraciones se usa el mismo nombre de tipo. Sin embargo, la equivalencia de nombres también deja cuestiones abiertas, como los alia< de tipos. Si se tiene: 1 typedef 2 Letra
char L e t r a ;
c;
3 c h a r d; se puede considerar que c y d no son equivalentes en nombre, pues en sus declaraciones se utilizan nombres de tipo diferentes. Pero dado que los tipos c h a r y L e t r a son idénticos, se podría también considerar lo contrario y admitir la equivalencia en nombre de ambas variables, como hacen Pascal y C. Nuevamente surge un problema con los tipos anónimos. En este caso, si las dos variables comparten la misma declaración de tipo, se puede considerar que ambas son equivalentes en nombre, pues el nombre interno asignado por el compilador o intérprete al tipo de ambas variables será el mismo. Por otro lado, si se declaran de forma independiente, no podrán ser equivalentes en nombre.
5.3
Conversión de Tipos de Datos
Como ya se ha visto, los lenguajes de programación dividen los tipos numéricos en enteros y reales, debido a las diferencias a la hora de representar (y, por tanto, de trabajar con> ambos tipos de datos. Sin embargo, en muchas ocasiones es necesario trabajar de forma
CONVERSIÓN DE TIPOS DE DATOS
233
¡ conjunta con enteros y reales, por lo que dentro del sistema de tipos se hace necesario algún mecanismo de conversión de tipos de datos. Por ejemplo, Java tiene cuatro tipos de datos enteros: b y t e , s h o r t , i n t y l o n g , cada uno de ellos con el doble de capacidad que el anterior. Esta declaración de variables: byte b; short s; int i ; long 1;
permitiría las siguientes asignaciones: s = b; i = s; 1 = i;
i = b; 1 = s;
1 = b;
En todos los casos la variable asignada es de un tipo con menor capacidad que la variable donde se asigna, por lo que la operación es posible. Sin embargo, una asignación del tipo: b = i;
no sería posible, puesto que en una variable de tipo b y t e (que ocupa un byte en memoria) no se puede almacenar un entero que se almacena en una variable de tipo i n t (que ocupa cuatro bytes en memoria). En caso de encontrar una asignación así, surgiría un error de tipos. Así pues, Java realiza una conversión automática entre los tipos de enteros cuando dicha conversión es posible. Esto se conoce como conversión implícita de tipos. En contraposición a la conversión implícita está la conversión explícita (o conversión forzada), en la que la conversión de los tipos se delega en el propio programador. Este tipo de conversión puede realizarse precediendo la expresión con el tipo deseado para el resultado: x = (double)
(3 + 2 . 5 )
como hacen C y Java, o bien utilizando el propio tipo como si de una función se tratase: x = double
(3 + 2 . 5 )
Una variante de esta sintaxis es la utilización de funciones específicas, tal y como realiza Haskell. Así, la expresión:
234
TIPOS DE DATOS
trúncate
3.5
devolvería 3, en lugar de utilizar la sintaxis I n t 3 . 5 o I n t e g e r
3.5.
Otros ejemplos de este tipo de conversiones explícitas son las que relacionan un carácter con su código ASCII. Por ejemplo, la expresión en Haskell: fromEnum ' a '
devuelve 97 (el código ASCII de ' a ' ) . También son comunes las funciones que permiten convertir una cadena de caracteres conteniendo un número en un número que represente ese valor. En Java existen las siguientes funciones: S t r i n g s = I n t e g e r . t o S t r i n g (34) ; I n t e g e r i = I n t e g e r . p a r s e I n t ("34 " ) ;
// //
s contendrá i contendrá
la el
cadena entero
"34" 34
Mientras que la conversión implícita puede dar lugar a comportamientos inesperados, la conversión explícita carga la tarea en el programador. Existe un punto intermedio en el que el lenguaje realiza una conversión implícita siempre que los datos no puedan corromperse, como Java, que sólo permite este tipo de conversiones de un tipo a otro que lo extiendacorno en el ejemplo anterior de los tipos enteros o de f l o a t a d o u b l e . El resto de conversiones deberán hacerse de forma explícita por parte del programador.
5.4
Ejercicios resueltos
1. Dadas las siguientes definiciones de tipos en Haskell, identificar qué clase de constructores se están empleando para su definición. (a) El tipo R e a l I n t definido como sigue: data R e a l I n t
= R Double
| I Int
Solución: Se trata de un tipo de datos atómico construido mediante una unión discriminada de los tipos Double, identificado mediante el discriminante R, e I n t , mediante el discriminante I. (b) El tipo Complex definido como sigue: type Complex = {Double,Double)
235
EJERCICIOS RESUELTOS
Solución: Se trata de un tipo de datos estructurado que representa una tupia o producto cartesiano de los tipos Double y Double. (c) El tipo Arbol definido como sigue: data
Arbol
a = ArbolVacio
| Nodo a
[Arbol
a]
Solución: Se trata de un tipo de datos estructurado que representa un árbol general capaz de almacenar datos de un tipo a. Así pues, se trata de un tipo de datos recursivo y además polimórfico, ya que el tipo de los datos almacenados es una variable de tipo. 2. Ante las siguientes declaraciones de variables siguiendo la sintaxis de C: 1 typedef struct 2 int i; 3 c h a r c;
{
4 } IntChar; 6 IntChar
x;
8typedef IntChar2 9 I n t C h a r 2 xx;
IntChar;
11 s t r u c t { 12 int i; 13 c h a r c ; '4 } y ;
16 t y p e d e f s t r u c t i? int i i ; is char c ;
{
i9 } I n t C h a r 3 ; 2i I n t C h a r 3
z;
¿Qué mecanismo de equivalencia de tipos debería tener un lenguaje para que se cumplan las siguientes afirmaciones? ¿Por qué? (a) Las variables x e y tienen tipos equivalentes. Solución: Dado que los tipos de x e y son I n t C h a r y un tipo anónimo respectivamente, si el lenguaje implementase una equivalencia en nombre, dichas variables no
236
TIPOS DE DATOS
tendrían tipos equivalentes. Sin embargo, si el lenguaje implementase una equivalencia estructural, ambas variables sí que serían equivalentes, puesto que la estructura de sus respectivos tipos es la misma. (b) Las variables x y xx tienen tipos equivalentes. Solución: El tipo de xx es sinónimo del tipo de x, por lo que para que ambas variables tengan tipos equivalentes, el lenguaje debe admitir que dos tipos sinónimos lo sean. Esto puede hacerse bien implementando una equivalencia estructural (ya que lógicamente ambos tipos estarán construidos de la misma forma), o bien implementando una equivalencia en nombre permitiendo que los tipos sinónimos sean también equivalentes. (c) Las variables x y z tienen tipos equivalentes. Solución: Una equivalencia en nombre no permitiría que ambas variables tuvieran tipos equivalentes, porque los nombres de sus respectivos tipos son diferentes. Por otro lado, una equivalencia estructural tampoco haría que las dos variables tuvieran tipos equivalentes, ya que el acceso a la componente que almacena el dato de tipo i n t se realiza con un identificador diferente ( x . i y z. ii respectivamente). Así pues, las variables x y z no pueden ser equivalentes bajo ninguno de los mecanismos de equivalencia de tipos estudiados. 3. Implementar un tipo de datos P i l a en Haskell, incluyendo la definición de las funciones que realicen las operaciones descritas al comienzo de la sección 5.1 Solución: 1 2 3 4 5
p i l a V a c i a = [] push x p i l a = x : p i l a e s V a c i a p i l a = p i l a == p i l a V a c i a cima x : x s = x pop x : x s = xs
Tal y como se apuntó en la sección 5.1, es posible la implementación del tipo de datos P i l a mediante una lista en la que la cabeza contiene la cima de la pila. Hay que resaltar que las operaciones cima y pop requieren ser aplicadas a una pila no vacía, por lo que el patrón utilizado es x : x s , que asume que la lista que está almacenando la pila tiene, al menos, un elemento. 4. Comprobar si el sistema de tipos de Haskel:
E J E R C I C I O S RESUELTOS
237
(a) ¿Permite la equivalencia entre tipos sinónimos? Solución: Sí, la permite. Se puede comprobar con el siguiente listado: i type C a r a c t e r
= Char
a : : Caracter a = '1' b
: :
b
=
f
: :
f
X
Char '2' Char -> Char =
X
En la linea 1 se declara el tipo C a r a c t e r como sinónimo del tipo predefinido Char. A continuación se definen dos funciones constantes de tipos C a r a c t e r y Char. Se puede comprobar que Haskell no pone ningún impedimento para comparar el valor de ambas funciones con a == b, que devuelve F a l s e . Más aún, la función f recibe un parámetro de tipo Char y devuelve dicho parámetro. Haskell tampoco impide que se llame a f a, aunque a : : C a r a c t e r . Por lo tanto, en Haskell dos tipos sinónimos son equivalentes. (b) ¿Permite una equivalencia estructural? Solución: En este caso no se puede utilizar t y p e para definir nuevos tipos de datos, ya que sólo produce tipos sinónimos, por lo que será necesario utilizar d a t a . Sea, pues, el siguiente código: 1 d a t a TI = T ( C h a r , C h a r ) 2 d a t a T2 = T ( C h a r , C h a r ) 4 ti 5 ti
: : TI = T ('a','b')
7 12 : : T2 8 12 = T ( ' a ' , ' b ' )
Los elementos de TI y T2 se construyen exactamente de la misma manera, por lo tanto, si Haskell permite una equivalencia estructural de datos, las funciones constantes ti y t2 serian iguales. Sin embargo, el código anterior no funciona, ya que Haskell no permite que se utilice el mismo constructor de datos T en múltiples declaraciones. Eso significa
T I P O S DE DATOS
238
que se deberían utilizar constructores distintos para tipos distintos, por lo que jamás serían equivalentes estructuralmente. Por lo tanto, en este caso la respuesta es negativa.
5.5
Ejercicios propuestos
1. Implementar un tipo de datos P i l a en Java, incluyendo todas las funciones y métodos que implementen las operaciones propias de este tipo (ver comienzo de la sección 5.1. Comparar la implementación con la vista en el ejercicio propuesto 3 de este mismo capítulo. 2. Dadas las siguientes declaraciones de variables en un lenguaje: 1 int j; 2 integer
i;
3 d o u b l e d; 4 5 6 7
f l o a t f; b y t e b; c h a r c; bool bb;
Indicar cuales de las siguientes asignaciones serían posibles y qué condiciones debería cumplir el sistema de tipos del lenguaje para ello: (a) j : = i ; (b) i : = j ; (c) d : = f ; (d) f : = i ; (e) i : = d ; (f) b : = c ; (g) b b : = i ; (h) i : = b b ; 3. Sea un tipo de datos s t r i n g cuyos elementos son cadenas de caracteres. (a) ¿Qué operaciones son necesarias para este tipo abstracto de datos? (b) ¿Qué estructura de datos es adecuada para su representación en base a las operaciones anteriormente definidas? (c) ¿En qué estructuras de datos se apoyan lenguajes como Java y Haskell par. implementar el tipo de datos de las cadenas de caracteres?
NOTAS BIBLIOGRÁFICAS
239
4. En Haskell, el tipo de datos I n t e g e r representa los enteros sin acotar. Es decir, puede representar enteros de longitud arbitraria (siempre dependiendo de la memoria de la máquina), pero a costa de una menor eficiencia en su uso. Implementar en Java un tipo similar que pueda almacenar enteros de longitud arbitraria y dotar dicho tipo de datos de una operación de suma. ¿Qué estructura de datos sería necesaria para realizar esta implementación? ¿Cuál es la diferencia de coste entre una suma en este nuevo tipo de datos y una suma en los tipos de datos de enteros que Java tiene predefinidos?
5.6
Notas bibliográficas
Para la elaboración de este capitulo se han consultado diferentes fuentes. Las principales referencias bibliográficas han sido los libros 115] (del que se han tomado algunos ejemplos), [28] y 117]. También se ha consultado información de [21), [5] y [3].
Capítulo 6
Control de la Ejecución Este capítulo trata sobre las estructuras de control de la ejecución de los programas presentes en los lenguajes de programación. En primer lugar se verán los problemas que pueden plantearse a la hora de evaluar una expresión en un lenguaje de programación, pues el resultado de la misma puede depender del orden en el que se evalúen sus diferentes partes. A continuación se tratarán las estructuras de control explícitas, como son las sentencias condicionales y los bucles, para continuar con las excepciones, que son estructuras preventivas de control que permiten continuar ejecutando un programa cuando se haya producido un error durante su ejecución. La última parte del capítulo está dedicada a los subprogramas, que se utilizan para agrupar una serie de operaciones y tratarlas, de forma abstracta, como si fueran una única operación. Su uso en un lenguaje de programación está ligado al concepto de ambiente de ejecución, que es una estructura de control que deben manejar los compiladores e intérpretes de forma interna, no siendo accesible por el programador.
6.1
Evaluación de expresiones
En un lenguaje de programación, una expresión es una estructura que representa un valor concreto de uno de los tipos de datos que dicho lenguaje contempla. Las expresiones básicas las constituyen las constantes (que representan un valor concreto) y los identificadores (que referencian un valor). Las expresiones más complejas se elaboran mediante la aplicación de funciones y operadores a otras expresiones más simples (que se denominan subexpresiones). Aquellos operadores que sólo pueden aplicarse a un único operando (como la negación aritmética) se denominan operadores unarios. Los que se aplican a dos operandos (suma, resta, multiplicación...) se denominan operadores binarios. 241
C O N T R O L DE LA E J E C U C I Ó N
242
Las funciones reciben una serie de parámetros (o argumentos) de entrada y devuelven un único valor de salida. No tienen la restricción que presentan los operadores con respecto al número de parámetros de entrada.
6.1.1
Notaciones infija, prefija y postfija
En la expresión: 6 + 2 * 4
se encuentran los operadores binarios + y * que están situados entre los dos operandos a los que se aplican. Esta forma de escribir una expresión (situando los operadores binarios entre sus dos operandos) se conoce como notación infija. Si se asume que la expresión anterior quiere representar la expresión matemática equivalente, la multiplicación debe tener más prioridad que la suma. Por lo tanto, el operador * se aplica a los operandos 2 y 4, mientras que el operador + se aplica a los operandos y al resultado de la subexpresión 2 * 4. Es decir, el árbol de sintaxis abstracta para esta expresión es el mostrado en la figura 6.1. +
6
"* 2
4\
Figura 6.1: Árbol de sintaxis abstracta para la expresión 6-1-2*4 Esta misma expresión puede escribirse en notación prefija sin más que anteponer el operador binario a sus dos operandos + 6 * 2 4 . Y en notación postfija, si los dos operandos a los que se aplica cada operador preceden jj dicho operador 6 2 4 * + . Estas dos notaciones presentan una ventaja sobre la notación infija: no existe ambigüedad en el orden en el que se deben aplicar los operadores, por lo que no es necesario utili paréntesis para agrupar subexpresiones. Por ejemplo, si se quiere que en la expresión an rior la suma tenga más prioridad que la multiplicación, debería escribirse como: ( 6 + 2 ) * 4
cuya notación postfija sería 6 2 + 4 * , que es distinta a la notación postfija de la expresi original. La notación postfija es muy útil a la hora de evaluar una expresión, ya que
E V A L U A C I Ó N DE E X P R E S I O N E S
243
puede realizar mediante una máquina pila 1 . Por ejemplo, la evaluación de esta última presión se realizaría de la siguiente forma siguiendo el orden postfijo de los operadores y randos: 1. Operando 6: Apilar 6 en la pila de operandos. 2. Operando 2: Apilar 2 en la pila de operandos. 3. Operador +: Desapilar los dos últimos operandos de la pila, sumarlos y apilar el resultado (8) en la pila de operandos. 4. Operando 4: Apilar 4 en la pila de operandos. 5. Operador *: Desapilar los dos últimos operandos de la pila, multiplicarlos y apilar el resultado (32) en la pila de operandos. Al finalizar este recorrido, en la cima de la pila de operandos se encontraría el valor 32, que es el resultado de evaluar dicha expresión. Normalmente los lenguajes ofrecen una serie de operadores predefinidos que han de escribirse mediante notación infija, mientras que las funciones se escriben utilizando notación prefija y el programador puede definir funciones adicionales a las que ofrece el lenguaje de forma predefinida. Así, aunque se podría hacer una distinción entre los operadores y las funciones, como hacen muchos lenguajes de programación, ambos conceptos son equivalentes y algunos lenguajes, por ejemplo Haskell como ya se vió en el capítulo 2, permiten que un operador binario se utilice como una función con dos parámetros y viceversa, o la definición de nuevos operadores infijos.
6.1.2
Evaluación de una expresión
A la hora de evaluar una expresión es necesario establecer una serie de reglas que controlen la forma en la que dicha expresión ha de ser evaluada. La mayoría de los lenguajes de programación establecen que los operandos o parámetros han de ser evaluados antes de aplicar los operadores o funciones. Esto se conoce con el nombre de evaluación impaciente o evaluación estricta. Si se considera la expresión: 6 + 2 * 4
la evaluación estricta corresponde a una evaluación desde abajo hacia arriba del árbol sintáctico de la expresión (ver figura 6.1). Esto se ve más claramente en la notación postfija de la expresión (la cual es 6 2 4 * +). 1
Una máquina pila es una máquina virtual que realiza cálculos utilizando una pila para obtener los operandos y dejar los resultados.
C O N T R O L DE LA EJECUCIÓN
244
Si se substituyen los operadores + y * por dos funciones mas y por, cada una con dos parámetros de entrada en la notación prefija de la expresión, se obtiene: mas
( 6 , por
(2,4)
)
Se puede ver que para evaluar la expresión completa primero se ha de llamar a la función por con los parámetros 2 y 4, para luego llamar a la función mas con los parámetros 6 y 8 (que es el resultado devuelto por la función por). Si ahora se considera la expresión: (3 + 5) * (8 + 2)
escribiéndola en notación prefija utilizando las funciones mas y por: por
( mas
(3,5)
, mas
(8,2)
)
surge la pregunta sobre el orden en el que se han de evaluar las dos llamadas a la función mas. Si la evaluación de los parámetros no produce ningún tipo de efecto colateral, el orden en el que se realice la evaluación de ambos parámetros es indiferente, obteniéndose el mismo resultado en ambos casos. Sin embargo, si hay efectos colaterales, el orden sí que tiene importancia. Considérese el siguiente programa en Java: i static int x; 3 static int colateral 4 x++; 5 return x; 6
() {
}
8public static void main(String[] args) { 9 x = 1; 10 int y = x + colateral (); ii System.out.println (y); 12 x = 1; 13 int z = colateral () + x; 14 System.out.println ( z);
15 } Al ejecutar el método main, las variables y y z deberían tener el mismo valor según sus definiciones en las líneas 10 y 13 respectivamente, ya que la suma cumple la propiedad
E V A L U A C I Ó N DE E X P R E S I O N E S
245
conmutativa. Sin embargo, basta con ejecutar el programa para comprobar que y vale 3, mientras que z vale 4. Si el lenguaje establece explícitamente el orden de evaluación de las subexpresiones, el programador puede tener en cuenta los efectos colaterales y se pueden escribir programas como el anterior. Sin embargo, si el orden de evaluación no se establece de forma explícita y las subexpresiones pueden ser evaluadas en diferente orden dependiendo de factores externos a la propia expresión (como pudiera ser una implementación concreta del compilador), el anterior programa se debería considerar incorrecto. En contraposición a la evaluación estricta está la evaluación no estricta o evaluación diferida, en la que la evaluación de los parámetros de una función (o, más generalmente, de las subexpresiones de una expresión) se difiere hasta que dicha evaluación sea necesaria. Como ya se vió en el capítulo 2, en ausencia de efectos colaterales la evaluación de una expresión no depende del cómputo realizado antes de dicha evaluación, por lo que el valor de dicha expresión será el mismo en cualquier parte del programa, a lo que se denomina transparencia referencial. Que se cumpla esta propiedad permite el uso de una estrategia de evaluación denominada evaluación normal, en la que las funciones comienzan a ser evaluadas antes de que se realice la evaluación de sus parámetros. Si no hay efectos colaterales, la evaluación normal y la evaluación impaciente de una misma expresión han de devolver el mismo resultado. Un ejemplo de este tipo de evaluación está en el lenguaje Haskell, en el cual, además, la evaluación de los parámetros compuestos de las funciones se retrasa hasta tener toda la información suficiente para evaluar la función, lo que se conoce con el nombre de evaluación perezosa. Sin embargo, aún utilizando evaluación impaciente, en determinadas ocasiones no es necesario evaluar todas las subexpresiones para realizar la evaluación de la expresión completa. Por ejemplo, las expresiones: f a l s e AND x x AND f a l s e
tienen claramente el valor false, con independencia del valor de x. Mientras que: 0 * x x * 0
tienen valor 0 con independencia del valor de x. Si un lenguaje de programación tiene en cuenta estas expresiones, se dice que está implementando una evaluación en cortocircuito o evaluación McCarthy 2 . Este tipo de evalua2
En honor a John McCarthy, creador del lenguaje L1SP.
C O N T R O L DE LA EJECUCIÓN
246
ción permite, por ejemplo, proteger accesos a componentes inexistentes de un array como en el siguiente programa en Java: if
{ (i
> = 0 && i
0 )
{ ...
}
Si el índice i está fuera del rango de definición del array a y no hay evaluación en cortocircuito se producirá un error de acceso a la hora de evaluar a [ i ] . Sin embargo, si la expresión interna del if se evalúa en cortocircuito, la expresión completa tendrá el valor false sin tener en cuenta el valor de la subexpresión a [ i ] > 0, por lo que dicha subexpresión no se evaluará y no se producirá un error de acceso. Hay que tener en cuenta, sin embargo, que esto sólo es posible si la subexpresión (i >= 0 && i 0. Algunos lenguajes, como Fortran, no contemplan que la evaluación de los operadores de conjunción y disyunción sea en cortocircuito. Ese tipo de operadores se denominan operadores ansiosos y fuerzan la evaluación de sus dos operandos. Otros lenguajes (como C. JavaScript, Modula-2...) sólo ofrecen operadores en cortocircuito. En aquellos lenguajes, como Haskell, en los que la evaluación perezosa se haga por defecto, todas las expresiones se evalúan en cortocircuito y no se ofrecen operadores especiales para forzar esa forma de evaluación. Los operadores de conjunción y disyunción (así como la multiplicación por cero) no son los únicos ejemplos de expresiones que pueden ser evaluadas en cortocircuito. Muchos lenguajes ofrecen expresiones que imitan estructuras de control condicionales para devolver valores. Por ejemplo, están las expresiones if (o expresiones if-then-else), que pueden ser consideradas como un operador ternario que recibe tres expresiones: • c: denominada condición, debe ser una expresión de tipo booleano. • t y e: expresiones then y else respectivamente. Ambas deben ser del mismo tipo, que será el tipo de la expresión if. La semántica de una expresión if es similar a la de una sentencia if-then-else (las cuales se tratan en la siguiente sección): en primer lugar se evalúa la expresión c. Si ésta resulta ser verdadera, se evalúa la expresión t y se devuelve dicho valor. Si el valor de c es falso, entonces se evalúa la expresión e y ese será el valor devuelto por la expresión if. En Java (por herencia de C) la sintaxis de este operador es la siguiente: c ?
t
:
e
Por ejemplo, si se quiere asignar a la variable mayor el valor más alto contenido en la> variables x e y, se puede hacer lo siguiente:
E V A L U A C I Ó N DE E X P R E S I O N E S
247
y
i V
mayor = ( x > y ) ? x : y ;
en lugar de: if ( X > y ) mayor = x; ) else { mayor = y;
{
}
Para realizar la evaluación de este tipo de expresiones, nunca se evalúan las tres subexpresiones, sino que sólo se evalúa la expresión then si la condición es verdadera, no evaluándose en tal caso la expresión else. En caso de que la condición sea falsa, la expresión then no se evaluará. A diferencia de las sentencias if-then-else, las expresiones if deben obligatoriamente tener una expresión else, pues si la condición se evalúa a falso no se tendría un valor que pudiera ser devuelto. Por último, algunos lenguajes (principalmente funcionales) ofrecen las llamadas expresiones case. Estas expresiones son una extensión de las expresiones if, en las que la condición no ha de ser necesariamente de tipo booleano y, en consecuencia, no se restringe a dos el número de posibles valores a devolver. Ya se vió en el capítulo 2 (página 51) que la forma de las expresiones case en Haskell es la siguiente:
case e x p r e s i ó n o f patrónj -> resultado] patrón2 -> resultado2 patrón,, -> resultado,,
Al evaluar estas expresiones, se evalúa en primer lugar la expresión de control y si ésta encaja con alguno de los patrones, entonces se evalúa la expresión resultado correspondiente, mientras que el resto de expresiones nunca son evaluadas. Al igual que con las expresiones if, en caso de que la expresión de control no encaje con ninguno de los patrones, se produciría un error, pues no se tendría un valor para la expresión case.
C O N T R O L DE LA EJECUCIÓN
248
6.2
Sentencias Condicionales
Una de las formas más básicas para controlar la ejecución de un programa es la posibilidad de restringir la ejecución de una serie de sentencias sólo si se cumple una serie de condiciones. Esto da lugar a las estructuras de control denominadas sentencias condicionales. En 1975 Dijkstra [8] describió por primera vez la forma más general de una sentencia condicional, la cual se puede ver en la figura 6.2: Gi
- >
Si
g
2
->
S2
G„
- >
S/i
fi
Figura 6.2: Forma general de una sentencia condicional. cuya semántica es la siguiente: • Las expresiones G, son expresiones booleanas conocidas como guardas, mientras que las S, son secuencias de sentencias. • Todas las guardas son evaluadas. • Si la i-ésima guarda se evalúa a verdadero, entonces se ejecuta la secuencia de sentencias S,. • Si más de una guarda resultase ser verdadera, se elegirá sólo una de las secuencias asociadas y se ejecutará. • Si ninguna de las guardas fuese verdadera, se producirá un error. En su forma más pura, esta sentencia condicional introduce el no determinismo en e' lenguaje, aunque la mayoría de implementaciones de una sentencia condicional evalúan las guardas siguiendo su orden de aparición hasta encontrar una Gi que sea verdadera, ejecutando a continuación la secuencia de sentencias Si. Los lenguajes de programación suelen implementar dos formas de sentencias condicionales las sentencias if-then-else (también conocidas como sentencias if) y las sentencias case.
6.2.1
Sentencias if-then-else
La forma básica de una sentencia if-then-else es la siguiente:
SENTENCIAS CONDICIONALES
if
( e ) then si
249
[ else s2 ]
aunque en muchos lenguajes (como C o Java) se omite la palabra clave then. Su semántica es la siguiente: • Tanto si como s2 pueden ser una única sentencia o una secuencia de sentencias. Es común el uso de los corchetes { y } como delimitadores para agrupar sentencias (C y Java son ejemplos de ello), aunque algunos lenguajes emplean palabras reservadas para ello, como Pascal que utiliza Begin y End respectivamente. • En primer lugar se evalúa la expresión e, que ha de ser booleana. • Si el valor de la expresión es verdadero, se ejecutará la sentencia o secuencia de sentencias s i . • Si el valor de la expresión es falso y está presente la parte else opcional, entonces se ejecutará la sentencia o secuencia de sentencias s2. Si la parte else opcional no se encuentra presente, entonces no se ejecutará nada. En muchas ocasiones, cuando se anidan dos sentencias if-then-else se presenta un problema denominado el problema del else ambiguo, el cual se puede ejemplificar con el siguiente código: if ( el
) then if ( e2
) si
else s2
El problema es saber si la parte e l s e se ha de ejecutar si el es falsa o si e2 es falsa. Algunos lenguajes como C y Pascal optan por aplicar la llamada regla del anidamiento más cercano como criterio para eliminar ambigüedades, que establece que un e l s e se debe asociar al if más cercano. En tal caso, la secuencia de sentencias s2 se ejecutará siempre que e2 sea falsa (y, consecuentemente, el habrá de ser verdadera). El problema del else ambiguo es inherente al diseño sintáctico del lenguaje y dificulta la lectura de un programa. Algunos lenguajes, como Java, incluyen las reglas necesarias en su gramática para evitar que se presente este problema. La regla BNF que define la sentencia if-then-else en Java es la siguiente: ::= if short if> else
(
) | |
|
se puede ver que la única diferencia es que en < s t a t e m e n t no s h o r t i f > no se incluye una producción hacia la regla ( ) , mientras que en si. Si ahora se acude a dicha regla: ::=
if
(
)
se comprueba que se trata de una sentencia if-then-else sin la parte else (que, recuérdese, es opcional). Por lo tanto, en Java la parte then de una sentencia if-then-else no puede acabar en una sentencia if-then-else sin su parte else. Sí puede incluirse si se mete dentro de un bloque o de otras sentencias que tengan un terminador explícito. Por lo tanto, la única posible interpretación en Java del código anterior: if ( el
) if ( e2
) si
else s2
es que se trata de una sentencia if-then (sin else) en la que se ejecuta una sentencia if-thenelse si la expresión es cierta. Así pues, el problema del else ambiguo desaparece porque la propia gramática de Java impide su aparición. La forma de forzar que se interprete de la otra forma sería encerrar la sentencia if-then-else sin parte else dentro de un bloque: if { el
) { if ( e2
) si
} else s2
Otros lenguajes emplean palabras clave que marcan el final de la sentencia if-then-else. como Ada, que utiliza end if ; para marcar el final de una sentencia de este tipo. Algunos lenguajes, como Perl, ofrecen la palabra clave e l s i f (una contracción de e l s e i f ) que permite trabajar fácilmente con múltiples alternativas al mismo nivel:
SENTENCIAS CONDICIONALES
1 if ( e l
2
)
251
{
Si
3 }
( e2
4 elsif s s2
)
{
* J¡
7 . . .
< else { 9 seise }
Las diferentes expresiones booleanas se encuentran al mismo nivel, es decir, no están anidadas unas dentro de otras. La semántica de esta construcción es la siguiente: • Se evalúa e l , si resulta verdadera se ejecuta s i . • Si el es falsa, se evalúa e2, ejecutándose s2 si es verdadera. • Si ninguna de las expresiones booleanas resultara ser verdadera, se ejecutaría s e i s e en caso de que ésta estuviera presente. Si no lo estuviera, no se ejecutaría nada. Por último, hay que hacer notar nuevamente la diferencia entre una sentencia y una expresión if-then-else: la parte else es opcional en las sentencias, pero no lo es en las expresiones if, ya que una expresión siempre ha de devolver un valor, mientras que una sentencia puede permitirse el lujo de no hacer nada.
6.2.2
Sentencias case
Las sentencias case son una variación de la sentencia condicional genérica descrita por Dijkstra (ver figura 6.2) en la que las guardas no son necesariamente expresiones booleanas. Tómese como ejemplo la siguiente sentencia, en Java: 1 switch ( e ) { 2 case v a l o r l : si; break; case v a l o r 2 : s 2 ; break; 3 case v a l o r 3 a 4 case v a l o r 3 b 5 case v a l o r 3 c s 3 ; break; 6 7 default s d ; break; 8 }
La semántica de esta sentencia es la que sigue:
252
C O N T R O L DE LA EJECUCIÓN
• Se evalúa la expresión e, que no tiene por qué ser booleana. • Se compara el resultado de la evaluación con v a l o r 1. Si coincide se ejecuta s i . • Si no coincide, se compara con v a l o r 2 . Si coincide, se ejecuta s2. • Si no coincide, se compara con v a l c r 3 a , v a l o r 3 b y v a l o r 3 c . Si coincide con alguno de ellos, se ejecutará s3. • Si no coincide con ninguno de esos valores, se ejecutará sd. Realmente, esta construcción es azúcar sintáctico pues se puede crear una construcción similar utilizando sentencias if-then-else: 1 if ( 2 else 3 else 4 else
6.3
e == v a l o r l ) si if ( e == v a l o r 2 ) s2 if ( e == v a l o r 3 a | | e == v a l o r sd;
3b
| | e == v a l o r 3 c
) s3
Bucles
Fue el propio Dijkstra [8] quién definió la forma general de un bucle (de forma similar a la de una sentencia condicional), tal y como se muestra en la figura 6.3: do Gj - > Sj
G2 -> S2 Gn -> S„ od
Figura 6.3: Forma general de un bucle. cuya semántica es la siguiente: • Se evalúan las guardas Gi y se selecciona (de forma no determinista) una de las Si asociada a una guarda que se haya evaluado a verdadero. • El proceso se repite hasta que todas las guardas se evalúan a falso. La forma de bucle más comúnmente ofrecida por los lenguajes de programación es una versión de esta forma general en la que sólo hay una guarda (con lo que se elimina el no determinismo). Esto se conoce como bucle while y su forma general en Java seria la siguiente:
253
BUCLES
while
(e) s
que ejecutará la secuencia de sentencias s (a la que se denomina cuerpo del bucle) mientras la expresión e (condición de terminación o condición de salida del bucle) sea verdadera. Nótese que en primer lugar se evalúa la expresión e y si ésta resulta ser falsa, no se ejecutará el cuerpo del bucle. Muchos lenguajes ofrecen diferentes variaciones sobre esta construcción, como pudiera ser el bucle do-while, que fuerza la ejecución del cuerpo del bucle al menos una vez: do s w h i l e
(e);
Aunque esta forma de expresar un bucle puede simularse con un bucle while de la siguiente forma: s while
(e)
s
A continuación se presenta una versión muy común de bucle: los bucles for, cuya sintaxis en Java sería la siguiente: for
( s i ; e ; sa ) s;
que es equivalente a: si; while
(e)
{
Sisa; }
Donde: • La sentencia si es la sentencia de inicialización del bucle. • La expresión e es la condición de terminación del bucle. • La sentencia sa es la sentencia de actualización del bucle. • La sentencia (o secuencia de sentencias) s es el cuerpo del bucle. La semántica de este tipo de bucles se puede deducir de la equivalencia con los bucles while:
254
CONTROL DE LA EJECUCIÓN
• En primer lugar se ejecuta la sentencia de inicialización del bucle. Típicamente se inicializa una variable de control. • Se evalúa la condición de salida del bucle y si resulta ser falsa se termina la ejecución del mismo. • Si la condición de salida del bucle es verdadera, entonces se ejecuta el cuerpo del bucle y la sentencia de actualización del bucle, que consiste normalmente en una actualización de la variable de control inicializada en la sentencia de inicialización. Todas estas diferentes versiones de bucles no dejan de ser azúcar sintáctico que mejoran la eficiencia de la programación del lenguaje, ya que todas ellas son equivalentes entre sí. Por último, indicar que las versiones de bucle aquí consideradas evalúan la condición de terminación del bucle o al comienzo o al final de la ejecución de su cuerpo. Algunos lenguajes ofrecen la posibilidad de interrumpir la ejecución del cuerpo del bucle dentro del interior del mismo (como Java o C, utilizando la sentencia break), pero esto complica la semántica de los bucles y otros lenguajes (por ejemplo Pascal) no lo permiten.
6.4
Excepciones
Las estructuras de control vistas en la sección anterior se denominan estructuras de control explícitas, pues se definen en el punto exacto donde se han de ejecutar. Esto no ocurre con las excepciones, pues su cometido es controlar aquellos eventos que tengan lugar durante la ejecución del programa que violen las restricciones semánticas del lenguaje, generando, consiguientemente un error en tiempo de ejecución. Así, cuando ocurre una excepción, se dice que ésta se lanza (se pone de manifiesto o surge) y en ese momento una parte del código del programa denominada manejador de excepciones entrará en ejecución para capturar dicha excepción e intentar contener el error de forma que la ejecución normal del programa se vea afectada lo menos posible. El lenguaje PL/I fue el pionero en introducir el manejo de excepciones y a lo largo de los años este proceso ha sido mejorado hasta que, en la actualidad, los lenguajes más utilizados (como Java o C++) incorporan mecanismos para el control de las excepciones. Si un lenguaje no incorpora un mecanismo de control de excepciones, la ejecución de los programas se detendrá ante cualquier error que pudiera aparecer. Esto no es deseable desde el punto de vista del principio de diseño de confiabilidad (tal y como se vio en el capítulo 1), ya que un programa debería ser capaz de recuperarse ante determinados errores. Sin embargo, el hecho de que en un lenguaje existan mecanismos de control de excepciones y éstos sean utilizados por los programas, no garantiza que todos los errores que se produzcan puedan ser tratados y no provoquen que se detenga la ejecución del programa. Algunos
EXCEPCIONES
255
errores ocurren a niveles más bajos que la propia ejecución de un programa, por ejemplo la lectura de un fichero existente puede fallar si el dispositivo hardware donde dicho fichero está almacenado está dañado. Si esto ocurre, el programa no puede hacer nada al respecto por muy potente que sea el mecanismo de control de excepciones ofrecido por el lenguaje. Ese tipo de excepciones se conoce como excepciones asincronas, pues pueden suceder en cualquier punto de la ejecución del programa con independencia del código del mismo. Por otro lado, aquellos errores que pueden surgir como respuesta a la ejecución de los programas se denominan excepciones síncronas. Esta división tampoco implica que todas las excepciones asincronas no puedan ser tratadas por un programa, ya que en algunas ocasiones sí es posible recuperar la ejecución normal del programa. Al hablar de la evaluación de expresiones en cortocircuito se mostró un ejemplo simple de control de errores al tratar con la sentencia condicional en Java: if
( (i
>=0 && i
0 )
{ ...
}
que no provocará un error si el índice i está fuera del rango de definición del array a. Sin embargo, esto complica en exceso la tarea de escribir un programa (atentando contra el principio de eficiencia de la programación), ya que obliga al programador a prever todas las posibles excepciones en todos los posibles puntos donde éstas pueden surgir, indicando, además, qué debería hacer el programa en cada punto para cada excepción posible. Afortunadamente, si un lenguaje incluye manejadores de excepciones, la tarea se simplifica y pasa por considerar los siguientes puntos: 1. Definir las posibles excepciones que pueden surgir. 2. Definir los manejadores de excepciones para controlar dichas excepciones y los mecanismos para que se pase el control de la ejecución al manejador de excepciones y éste, al finalizar su cometido, se lo devuelva al programa. A continuación se desarrollan estos puntos tomando como ejemplo el tratamiento de las excepciones que hace Java.
6.4.1
Definición de excepciones
Java diferencia entre dos tipos de excepciones: • Excepciones comprobadas (o comprobables o checked), que representan un error del cual el programa puede recuperarse. Por ejemplo, si se intenta escribir datos en un fichero que no existe, se provocaría un error. Dicho error se produce directamente por culpa del código del programa, a pesar de ser ajeno al mismo.
256
C O N T R O L DE LA E J E C U C I Ó N
Se denominan comprobadas porque el compilador de Java comprueba, durante el proceso de compilación, si existe un manejador que las trate, generándose un mensaje de error en caso contrario. Así pues, todas las excepciones de este tipo han de ser capturadas. Un ejemplo sería la excepción j a v a . i o . E O F E x c e p t i o n , que surgiría al intentar leer datos de un fichero del cual no quedan datos por leer. • Excepciones no comprobadas (o no comprobables o unchecked), que representan errores de programación. Por ejemplo, si se intenta acceder a un componente inexistente de un array. A diferencia de las anteriores, el compilador de Java no comprueba que exista un manejador para su tratamiento. Entre estas excepciones, están la clase E r r o r y sus subclases, que representan errores de gran magnitud, por ejemplo si la pila de la máquina virtual se llena por culpa de una cadena interminable de llamadas recursivas surgiría una excepción de tipo java.lang.StackOverflowError. El resto de excepciones no comprobables son la clase RuntimeException y sus subclases. Por ejemplo existe IndexOutOfBoundException, que es la excepción que surge cuando se intenta acceder a un índice inexistente de un array. Aparte de las excepciones ya predefinidas en el lenguaje, Java permite crear nuevas excepciones extendiendo la clase E x c e p t i o n (si se desea que la captura de la excepción sea comprobada por el compilador) o la clase RuntimeException (si no es necesario que su captura sea comprobada por el compilador): i class MyOutOfBound extends E x c e p t i o n
{}
Así se declara una nueva excepción comprobable por el compilador. Una versión más elaborada para declarar una excepción consiste en definir un método que reciba un mensaje que pueda ser pasado al manejador de excepciones: 1 class MyOutOfBound extends E x c e p t i o n { 2 public MyOutOfBound ( S t r i n g s) { super(s); 3 }
}
De esta forma, al lanzar la excepción se puede añadir un mensaje indicando qué es lo que ha fallado.
6.4.2
Definición de manejadores de excepciones y control del flujo
Los manejadores de excepciones en Java se realizan mediante bloques try-catch-finally. los cuales pueden aparecer en cualquier lugar donde pueda aparecer una sentencia.
EXCEPCIONES
257
Las sentencias que puedan provocar una excepción deben ser encerradas dentro de un bloque t r y { . . . } al que le deben seguir tantos bloques c a t c h ( e x c e p c i ó n ) { . . . } como excepciones diferentes se quieran capturar. Por último, se puede incluir un bloque f i n a l l y { . . . } que encerrará una serie de sentencias que se ejecutará en cualquier caso, incluso si no se produce ninguna excepción. A continuación se muestra un ejemplo que utiliza la excepción MyOutOfBound ya definida en la sección anterior: 1 class MyOutOfBound extends E x c e p t i o n { 2 public MyOutOfBound ( S t r i n g s) { super(s);
}
3 }
5 public class p r u e b a E x c e p c i o n e s 7
static int[]
9
public static void a s i g n a ( i n t
10
a = new int [ 1 0 ] ; i , i n t v)
throws MyOutOfBound
if ( i == 0 ) throw new M y O u t O f B o u n d ( " N o el Índice 0 del a r r a y " ) ; else a [ i ] = v ;
n -2
{
se
permite
{
modificar
}
14 15
16 17 18 19 20
public static void m a i n ( S t r i n g [ ] a r g s ) { try { asigna (0,1) ; asigna{9,3); a s i g n a {10, 1 1 ) ; } catch ( I n d e x O u t O f B o u n d s E x c e p t i o n e x ) { System.out.println ("Indice inexistente
21
}
22 23
S y s t e m . o u t . p r i n t l n (a [ 0 ] ) ; S y s t e m . o u t . p r i n t l n (a [9] ) ;
24
en el
array");
}
25 }
Al intentar compilar este código, el propio compilador indica que las sentencias de las líneas 16 a 18 pueden provocar excepciones de tipo MyOutOfBound y éstas no se están capturando. Esto es debido a que esa excepción se ha definido como una excepción que ha de ser comprobada por el compilador por haber sido declarada como heredera de la clase E x c e p t i o n (línea 1). Si se modifica la declaración de dicha excepción por: i class MyOutOfBound extends R u n t i m e E x c e p t i o n
{
258
CONTROL DE LA EJECUCIÓN
el compilador no dará error a la hora de compilar el programa, pero al ejecutar la sentencia de la línea 16, la línea 10 lanzará una excepción MyOutOfBound, deteniéndose la ejecución del programa. Para capturar las excepciones MyOutOfBound es necesario añadir el siguiente bloque c a t c h : catch (MyOutOfBound e x ) { System.out.println (ex); }
quedando el programa completo c o m o sigue: 1 class MyOutOfBound extends R u n t i m e E x c e p t i o n { 2 public MyOutOfBound ( S t r i n g s) { super(s); } 3 } s public class p r u e b a E x c e p c i o n e s 7
static i n t [ ]
9
public static void a s i g n a ( i n t
a = new i n t [ 1 0 ] ; i,int
v)
throws MyOutOfBound n
10 n 12
{
if ( i == 0 ) throw new M y O u t O f B o u n d ( N o el Índice 0 del a r r a y " ) ; else a [ i ] = v ;
se
permite
{
modificar
}
14 15
16 17 i» 19 20 21 22
public static void m a i n ( S t r i n g [ ] a r g s ) { try { asigna (0,1) ; asigna (9,3); asigna (10,11) ; } catch ( I n d e x O u t O f B o u n d s E x c e p t i o n e x ) { System.out.println ("Indice inexistente } catch (MyOutOfBound e x ) { S y s t e m . o u t . p r i n t ln ( e x ) ;
23
}
24 25
S y s t e m , o u t . p r i n t l n (a [0] ) ; S y s t e m . o u t . p r i n t l n (a [9] ) ;
26
en el
array");
}
27 }
De este m o d o se captura la excepción que se lanza en la línea 10 al ejecutar la sentencia de la línea 16. El resultado es que no se ejecuta ninguna de las sentencias del bloque t r y (líneas 16 a 18), ya que su ejecución se ha abortado debido a que se ha producido una excepción MyOoutOfBound.
EXCEPCIONES
259
Ahora, si se reordenan las sentencias dentro del bloque try se puede comprobar que, mientras no se produzca ninguna excepción, todas las sentencias serán ejecutadas secuencialmente. Sin embargo, tras producirse una excepción, se abortará la ejecución del resto de sentencias del bloque try. En concreto, si las sentencias del bloque try se reordenasen como sigue: 15
16 17 18 19
try { asigna (9,3) ; asigna(0,l); asigna (10,11) ; } catch . . .
se ve que a [ 9] se asigna correctamente con el valor 3. pero no se ejecutan las sentencias de las líneas 17 y 18. Si ahora la reordenación se realiza de la siguiente forma: 15 16 17 18 19
try { asigna asigna asigna ) catch
(10,11); (0 , 1 ) ; (9,3) ; ...
se lanzará una excepción IndexOutOfBoundsException al tratar de ejecutar la sentencia de la línea 16 y, de nuevo, no se ejecutarán las sentencias de las líneas 17 y 18. Sin embargo, en ninguno de estos casos se detiene la ejecución del programa, porque las sentencias de las linas 24 y 25 se ejecutan siempre que las excepciones se hayan capturado. Es decir, cuando el manejador de excepciones termina su tarea, se devuelve el control a la siguiente sentencia que sigue a los bloques t r y - c a t c h - f i n a l l y . Otra opción que ofrece Java a la hora de tratar una excepción es transmitir dicha excepción al método llamante. De esta forma la excepción va subiendo en la cadena de llamadas hasta llegar a un método que la trate o, en su defecto, a la máquina virtual, en cuyo caso la ejecución del programa se abortará. Para verlo, se puede modificar el ejemplo anterior como sigue: 14
public static void m a i n ( S t r i n g [ ]
args)
throws MyOutOfBound
{
con lo que se indica que las excepciones de tipo MyOutOfBound no las va a tratar el método main, sino que delega su tratamiento en el método llamante. Si se compila el ejemplo sin incluir el bloque c a t c h correspondiente al tratamiento de la excepción MyOutOfBound (líneas 21 y 22), se puede ver que el compilador no indica ningún error aunque la excepción hubiera sido declarada como comprobable en la primera línea:
260
CONTROL DE LA EJECUCIÓN
1 class MyOutOfBound extends E x c e p t i o n
{
2 ...
Sin embargo, dado que no hay ningún método que llame al método main, sino que éste es llamado directamente por la máquina virtual, la excepción llega a ésta, con lo que la ejecución del programa se ve abortada y las sentencias de las líneas 24 y 25 nunca se ejecutan. Por último hay que indicar que el manejo de excepciones conlleva un aumento en el tiempo de ejecución, atentando contra el principio de eficiencia con respecto a la ejecución. Por ello no se aconseja hacer un uso excesivo de este tipo de estructuras de control.
6.5
Subprogramas
En los años 50 Maurice V. Wilkes, David J. Wheeler y Stanley Gilí introdujeron el concepto de subrutina [26], descrita como una parte autocontenida de un programa que puede ser utilizada por diferentes programas [251En esencia, este concepto se refiere a bloques de sentencias que reciben una serie de parámetros de entrada y realizan una serie de operaciones sobre los mismos. Su ejecución se difiere hasta que en otro punto del programa son llamados (o invocados) de forma adecuada. Si tras finalizar la ejecución se devuelve un valor al punto donde se realizó la llamada, normalmente se denominan funciones y, a todos los efectos, su invocación es una expresión y no una sentencia. Si no se devuelve un valor se suelen denominar procedimientos (como en Pascal) o métodos (como en Java) y su invocación sería una sentencia. Idealmente una función no debería producir ningún tipo de efecto colateral, mientras que un procedimiento (al ser una sentencia) sí podría hacerlo. Sin embargo, muchos lenguajes no hacen una distinción explícita en este sentido entre funciones y procedimientos y permiten la creación de funciones que producen efectos colaterales, así como de procedimientos que devuelven valores por medio de parámetros de entrada/salida. Un ejemplo claro es C, lenguaje en el que únicamente se pueden definir funciones. En caso de que una función no deba devolver un resultado, se declara como función nula indicando que el tipo del valor devuelto por la función es void (vacío). Para definir un subprograma es necesario realizar su declaración, que consiste en lo siguiente: • Interfaz: contiene la definición del identificador del subprograma), así como la lista de sus parámetros de entrada (y sus tipos) y el tipo del valor devuelto (en el caso de que el subprograma deba devolver un valor).
261
SUBPROGRAMAS
• Cuerpo: consiste en el bloque de sentencias que se ejecutarán cuando el subprograma sea invocado. Por ejemplo, el siguiente subprograma en Java suma dos números e imprime el resultado: public static void Suma ( int x , int y ) int z = x + y ; System.out.println (z);
{
} public static void m a i n ( S t r i n g [ ] S y s t e m . o u t . p r i n t ( " V o y a sumar:
args) ");
{
Suma(2,3);
System.out.println("¡Hecho!"); }
El subprograma Suma recibe dos parámetros (según su interfaz, definida en la línea 1) llamados respectivamente x e y, siendo ambos de tipo entero. También se indica que este subprograma no devuelve ningún valor, pues el tipo devuelto es void, por lo tanto (y siguiendo la nomenclatura de Java) Suma es un método y no una función. El cuerpo del método Suma consiste en dos sentencias. En la línea 2 se crea una nueva variable entera z y se le asigna el resultado de sumar los parámetros de entrada, mientras que la sentencia de la línea 3 escribe por pantalla el valor de esta nueva variable z. Si se ejecuta el programa anterior, el resultado obtenido es el siguiente: Voy a s u m a r : ¡Hecho!
5
En Java, la ejecución del programa comienza con la ejecución del cuerpo del método main (líneas 5 a 9). La primera sentencia que se ejecuta es la encargada de imprimir el mensaje "Voy a sumar: " (línea 6), para a continuación invocar (o llamar) al método Suma (línea 7) escribiendo, en forma de sentencia, su identificador y los valores concretos que se quiere dar a los parámetros de entrada. En ese momento, el control de la ejecución se transfiere al subprograma llamado (líneas 1 a 4), los valores de los parámetros se transfieren de la forma adecuada (más adelante se tratarán diferentes formas de realizar esto) y se comienza a ejecutar el cuerpo del subprograma (líneas 2 y 3), lo que provoca que se imprima el valor 5. Una vez se termina la ejecución del cuerpo del subprograma, el control se devuelve a la sentencia que sigue a la llamada del mismo (línea 8), imprimiéndose así el mensaje "¡Hecho!". Si ahora se desea un programa que haga lo mismo, pero con un subprograma Suma que reciba los mismos parámetros de entrada, y devuelva el valor de la suma de ambos (es decir.
262
C O N T R O L DE LA E J E C U C I Ó N
una función), es necesario cambiar la definición del interfaz del subprograma para indicar que va a devolver un valor entero, e indicar en el cuerpo del subprograma cuál es el valor a devolver: 1 public static int Suma 2 return (x + y);
( int x , int y )
{
3 }
4public static void m a i n { S t r i n g [ ] 5 S y s t e m . o u t . p r i n t ( " V o y a sumar: 6 int z = Suma ( 2 , 3 ) ; 7 S y s t e m . o u t . p r i n t l n (z); » S y s t e m . o u t . p r i n t l n ( " ¡Hecho! " ) ;
args) ");
{
9 } Como se puede ver, ahora el subprograma Suma (líneas 1 a 3) se declara (línea 1) como de tipo entero, lo que indica que va a devolver un resultado (y que, por tanto, es una función). Para indicar qué valor se devuelve, se utiliza la sentencia r e t u r n ( v a l o r ); (línea 2). Si se omitiera dicha sentencia dentro del cuerpo de la función Suma, el compilador daría un mensaje de error, ya que se espera que una función siempre devuelva un resultado. Ahora, la invocación de la función Suma ya no es una sentencia, sino que es una expresión (pues tiene un valor) y, como tal, la llamada puede ser utilizada en cualquier sitio donde se pueda utilizar una expresión. En este ejemplo concreto se utiliza para asignar su valor a una nueva variable entera z (línea 6). En Java no existe una diferencia muy grande a la hora de declarar métodos o funciones, sólo en el tipo del valor devuelto, que en el caso de los métodos es v o i d (vacío) y, por tanto, no requieren de una sentencia r e t u r n para devolver un valor. En otros lenguajes sí que existe diferencia en las declaraciones, como en Pascal, que utiliza la palabra reservada Procedure para la creación de procedimientos (que no devuelven valores) y la palabra reservada F u n c t i o n para la creación de funciones. En los lenguajes funcionales, como Haskell, sólo se permite la declaración de funciones que siempre han de devolver un resultado. Hay que hacer notar que en el cuerpo de los subprogramas no sólo se tiene acceso a sus parámetros de entrada, sino que también se pueden utilizar variables, constantes o subprogramas declarados fuera de su cuerpo. En el capítulo 4 (páginas 183 y siguientes) se trataron las diferentes reglas de alcance que dotan de semántica a estas referencias no locales.
6.5.1
Semántica
El significado de los identificadores (variables, constantes, subprogramas...) viene determinado por el entorno (o ambiente, que se encarga de gestionar los vínculos de dichos identificadores con las posiciones de memoria donde se encuentran sus valores o código.
263
SUBPROGRAMAS
Los subprogramas pueden contener identificadores locales (de hecho los parámetros de un subprograma deberían ser considerados como tales), por lo que necesitan que se asigne memoria para ellos. Esta memoria se denomina registro de activación del subprograma y se dice que el subprograma está activado si se está ejecutando bajo las pautas dictadas por su registro de activación. Al ejecutar un programa que va realizando llamadas a diferentes subprogramas, cada vez que se empieza a ejecutar uno de ellos, el control pasa del registro de activación del subprograma que lo rodea 3 al suyo propio. Al terminar la ejecución, el registro de activación del subprograma se libera y se devuelve el control al registro de activación del subprograma que lo rodeaba. A modo de ejemplo, considérese el siguiente programa en Java: i static int x; 2static void i n c r e m e n t a 3 x + +;
{)
{
4} 5 static void suma(int y) { 6 incrementa (); 7 int z = x + y; 8 S y s t e m . o u t . p r i n t l n ( z) ;
9 } 10 public static void m a i n ( St r i n g [ ] a r g s ) ii x = 1; 12
{
suma (3) ;
13 }
En la línea 1 se declara la variable x, la cual se asigna en el registro de activación del ambiente global, por lo que el estado de los registros de activación al comenzar la ejecución del método main (línea 10) sería el mostrado en la figura 6.4. Registro de activación del ambiente global Figura 6.4: Estado de los registros de activación al comienzo del programa. En la línea 12 se invoca el método suma, por lo que el control pasa a la línea 5. En dicho método se utiliza el parámetro y y una variable local z (línea 7), por lo que durante su ejecución, el estado de los registros de activación sería el mostrado en la figura 6.5. Al comenzar el método incrementa, ya que éste no añade ningún identificador adicional, se añadiría un registro de activación que no incluye nuevos identificadores (figura 6.6). 3
Un subprograma A rodea a otro B. si la definición de fí se realiza dentro de la definición de A. Java no permite definir un método o función dentro de otro, pero otros lenguajes, como Pascal, sí.
264
C O N T R O L DE LA E J E C U C I Ó N
| x |
Registro de activación del ambiente global Registro de activación de suma
Figura 6.5: Estado de los registros de activación durante la ejecución de suma. | x |
Registro de activación del ambiente global Registro de activación de suma Registro de activación de incrementa
Figura 6.6: Estado de los registros de activación durante la ejecución de incrementa. Dado que incrementa se llama desde suma (línea 6), la activación de incrementa debe guardar información sobre la activación de suma, para que al terminar la ejecución de incrementa (en la línea 4) se devuelva el control de la ejecución a la siguiente instrucción tras la llamada (línea 7). Sin embargo, el método incrementa necesita acceder a la variable global x declarada en la línea 1, la cual se encuentra en el registro de activación del ambiente global. Esto se conoce con el nombre de referencia no local y requiere que durante la ejecución de un subprograma se tenga información sobre el registro de activación del subprograma que lo rodea. En este caso, durante la ejecución de incrementa se requiere tener acceso al registro de activación del ambiente global. Esto supone que, bajo la regla de alcance léxico (ver capítulo 4, página 183) y, dado que el método suma no rodea al método incrementa, toda referencia no local realizada en el cuerpo de incrementa se referirá al ambiente global, que es el que rodea a incrementa. Es decir, en el cuerpo de incrementa no es posible referenciar identificadores que estén en el registro de activación de suma. Esta distinción entre ambientes se realiza de la siguiente manera: el ambiente de definición, (que es posible determinarlo completamente en tiempo de compilación) de un subprograma es aquel donde se encuentra la definición del subprograma. Por otro lado, el ambiente desde el cual se llama a un subprograma se denomina ambiente de invocación (que sólo es posible determinarlo en tiempo de ejecución). En nuestro ejemplo, el ambiente de definición de incrementa es el ambiente global, mientras que su ambiente de invocación es el de suma. Así, las referencias no locales se refieren siempre al ambiente de definición. Esto supone un problema, ya que mediante el uso de referencias no locales, un subprograma nunca
SUBPROGRAMAS
265
podrá comunicarse con su ambiente de invocación. Esto se resuelve mediante el paso de parámetros. En nuestro ejemplo, el método suma recibe un parámetro cuyo valor no se conoce hasta que el método es llamado en la línea 12. Mediante esa llamada se declara que el valor que debe asumir el identificador y dentro del cuerpo de suma es 3. Para diferenciar entre los identificadores de los parámetros utilizados dentro del cuerpo del subprograma y los valores que se les asigna en las diferentes llamadas, a los primeros se les denomina parámetros formales (y en nuestro ejemplo) y a los segundos, parámetros actuales (en nuestro ejemplo, 3).
En teoría, es posible que los subprogramas se comuniquen con el resto del programa sólo mediante el uso de parámetros, es decir, que en el cuerpo de los subprogramas nunca existan referencias no locales. Este tipo de subprogramas se dice que están en forma cerrada, y sólo dependen de los valores de sus parámetros y de características fijas del lenguaje. Sin embargo, en la práctica esto resulta poco útil, ya que en muchas ocasiones se requiere el uso de diferentes constantes o variables globales, por lo que pasar el valor de dichas constantes a todos los subprogramas supondría manejar enormes listas de parámetros, atentando así contra el principio de eficiencia de la programación. En este momento se podría pensar que en un lenguaje funcional como Haskell, que no incluye el concepto de variable global (por lo que necesita que estas variables sean transmitidas a las funciones mediante parámetros), las funciones son subprogramas en forma cerrada. Sin embargo tampoco es así, porque para ello dentro de una función no se podría llamar a ninguna otra función definida globalmente, debiendo definirlas como subfunciones de todas aquellas funciones que las utilicen. Algo irrealizable en la práctica sin atentar contra el principio de eficiencia de la programación.
6.5.2
Paso de Parámetros
La forma en la que se vinculan los parámetros formales con los parámetros actuales para su tratamiento dentro del cuerpo de los subprogramas influye directamente en la semántica de los mismos. Ya se ha visto con anterioridad que el orden en el que se evalúen los parámetros de una función puede cambiar el resultado en presencia de efectos colaterales. A continuación se analizan cuatro diferentes mecanismos de paso de parámetros según se interprete la forma en la que se vincula el parámetro formal con el parámetro actual. En determinadas ocasiones, todos ellos presentan una serie de ventajas sobre los demás.
Paso por valor Es el mecanismo de paso de parámetros más utilizado. Bajo este mecanismo, las expresiones utilizadas al definir los parámetros actuales son evaluadas previamente a la ejecución
266
C O N T R O L DE LA EJECUCIÓN
del mismo y los valores resultado se asignan a los parámetros formales para que puedan ser referenciados dentro del cuerpo del subprograma. Idealmente, al hacer esta referencia, el valor de estos parámetros no debería poder modificarse, por lo que deberían ser tratados como constantes. Sin embargo muchos lenguajes (C, Pascal, Java) tratan estos parámetros como variables, por lo que es posible modificar su valor, aunque esta modificación tiene lugar únicamente dentro del cuerpo del subprograma, sin que el valor de la expresión se vea modificado en el ambiente donde se sitúa la llamada al mismo. Por ejemplo, el resultado de la ejecución del siguiente programa: 1 public static int Suma 2 x = x + 1; 3 return {x + y) ;
( int x , int y )
{
4 }
5public static void m a i n ( S t r i n g [ ] 6 int x = 2; 7 S y s t e m . o u t . p r i n t l n ( Suma ( x , 3 ) ); 8 System.out.println(x);
args)
{
9 } es que se imprimen por pantalla los valores 6 (correspondiente a la sentencia de la línea 7) y 2 (correspondiente a la sentencia de la línea 8). El valor del parámetro formal x se ve modificado dentro del cuerpo de la función Suma, por lo que ésta devuelve 6, pero no se modifica externamente, por lo que el valor de x dentro del método main sigue siendo 2. Paso por referencia Este mecanismo de paso de parámetros requiere, en principio, que los parámetros actuales sean variables que tengan asignada una dirección dentro de la memoria. Así, en lugar de pasar al cuerpo del subprograma el valor de la variable, lo que se le está pasando es la dirección de dicha variable, por lo que todas las modificaciones que se efectúen sobre el parámetro formal dentro del cuerpo del subprograma se estarán efectuando, en realidad, sobre la variable utilizada como parámetro actual. Una de las utilidades de este mecanismo de paso de parámetros es la simulación de funciones que devuelvan varios valores. Lenguajes como C++ y Pascal, que utilizan por defecto el paso de parámetros por valor, ofrecen la posibilidad de indicar que un parámetro se está pasando por referencia. En C++ esto se consigue añadiendo el símbolo & al tipo de datos del parámetro formal: void d o b l e
( int& i
)
{ i
= 2 * i;
}
Mientras que en Pascal se debe anteponer la palabra reservada var al nombre del parámetro
SUBPROGRAMAS
267
formal: procedure d o b l e begin i := i * 2; end;
( var i:integer );
En ambos casos, el resultado de una llamada como doble (w) es que el valor de la variable w se duplica. Es decir, ha ocurrido un efecto colateral. En Fortran, el paso por referencia es el único mecanismo disponible para el paso de parámetros y, además, se permite que dichos parámetros no sean variables. Por lo tanto, es posible realizar una llamada como: doble
( 4 )
pero en C++ o en Pascal, dicha llamada se considera un error. Paso por valor-resultado También conocido con el nombre de paso por copia-restauración o paso por copia de entrada y salida, este mecanismo de paso de parámetros requiere, al igual que el paso por referencia, que los parámetros actuales sean variables. En el cuerpo del subprograma los parámetros formales son tratados como variables y, al igual que en el mecanismo de paso por valor, el valor del parámetro actual es copiado a la variable que representa el parámetro formal. Dicha variable es utilizada en el interior del cuerpo del subprograma pudiendo ser modificada y, al terminar la ejecución del cuerpo, el valor final de la variable del parámetro formal es copiado nuevamente a la variable del parámetro actual. Esto puede suponer un problema si se utiliza la misma variable en más de un parámetro actual al llamar a un subprograma. Dentro del cuerpo del mismo se trabajaría con dos variables locales diferentes que habrán sido inicializadas con el valor que tuviera la variable utilizada como parámetro actual en la llamada, lo cual no supone ningún inconveniente. El problema está a la hora de volver a copiar el valor final del parámetro formal a la variable utilizada como parámetro actual, pues nada garantiza que el valor de ambas variables locales sea el mismo al finalizar la ejecución del cuerpo del subprograma. ¿Cuál de las dos habría de utilizarse? Ya que no existe ningún motivo para favorecer el uso de una sobre la otra, el resultado queda indefinido a expensas del orden en el que el compilador realice esta restauración. Esto puede provocar que dos compiladores diferentes del mismo lenguaje presenten comportamientos distintos al utilizar este mecanismo de paso de parámetros.
268
C O N T R O L DE LA EJECUCIÓN
A pesar de este problema, este mecanismo es muy útil en la programación en paralelo, donde la ejecución de un subprograma puede llevarse a cabo en un procesador diferente al que ejecuta el programa llamante. Ambos procesadores tendrían acceso a diferentes zonas de memoria, dificultando el uso de un mecanismo de paso de parámetros por referencia. Una variación de este mecanismo consiste en no inicializar la variable parámetro formal con el valor de la variable usada como parámetro actual y sólo realizar la restauración del valor final. Esta variante se conoce con el nombre de paso por resultado.
Paso por nombre A diferencia de los tres mecanismos anteriores, en los que el valor de los parámetros se evaluaba antes de la ejecución del cuerpo de los subprogramas (evaluación estricta), en el mecanismo de paso por nombre, la expresión utilizada como parámetro actual en la llamada al subprograma se substituye directamente en las apariciones del parámetro formal dentro del cuerpo del mismo. Este es un mecanismo de paso de parámetros utilizado en una evaluación no estricta. La ventaja que ofrece este mecanismo es que si un parámetro (o parte de un parámetro) del subprograma no se utiliza, no se pierde tiempo en realizar su evaluación. En contraposición, si se utiliza varias veces, deberá ser evaluado en múltiples ocasiones. Este mecanismo se utilizó por primera vez en el lenguaje Algol60, pero las dificultades que conllevan su implementación y sus interacciones con otras construcciones del lenguaje hicieron que se desechase en todos los descendientes del lenguaje (entre los que se incluyen Pascal y C). Sin embargo, es el mecanismo utilizado para el paso de parámetros en los lenguajes puramente funcionales como Haskell, ya que en estos lenguajes no cabe la posibilidad de que sucedan efectos colaterales. Sin embargo, si el subprograma realiza efectos colaterales se pueden producir comportamientos inesperados. Un ejemplo clásico de esto utilizando la sintaxis de Java sería el siguiente subprograma: 1
void
swap
2
int
temp
3
x
=
y;
4
y
=
temp;
( int =
x
, int
y
)
{
x;
5 } cuyo comportamiento esperado es el intercambio de los valores de sus dos parámetros mediante un efecto colateral. Si el lenguaje implementa un mecanismo de paso por nombre y se tiene un array de enteros a y una variable entera i respectivamente inicializados como: a[l]
= 2;
SUBPROGRAMAS
269
a [ 2 ] = 5; i = i;
entonces, cabría esperar que la llamada: swap
( i
,
a [i ] ) ;
provocase, mediante efectos colaterales, que el valor de i fuese 2 y que el de a [ 1 ] fuera 1. Sin embargo, dado que el mecanismo consiste en substituir la expresión en cada aparición del parámetro dentro del cuerpo del subprograma, en realidad se ejecutarían las siguientes sentencias: temp = i ; i = a[i]; a [ i ] = temp;
cuyo efecto es el siguiente: t e m p = 1; i = 2; a [2] = 1;
Es decir, no sólo no se han intercambiado los valores de i y a [ 1 ] como cabría esperar, sino que el valor de a [ 1 ] sigue siendo 1 y sorprendentemente el valor de a [ 2 ] se ha modificado, algo totalmente inesperado. Sin embargo, al realizar la llamada: swap
( a[i]
,
i
);
las instrucciones que se ejecutan en el cuerpo de swap son: temp = a [ i ] ; a[i] = i; i = temp;
obteniendo el efecto deseado. Es decir, el subprograma swap intercambia los valores de sus parámetros, pero debido al mecanismo de paso de parámetros utilizado, en ocasiones el orden en el que se utilicen los parámetros puede influir en el resultado obtenido. Esto contradice la idea abstracta del subprograma swap y por eso este mecanismo de paso de parámetros no se suele implementar en lenguajes que permitan efectos colaterales.
270
6.5.3
C O N T R O L DE LA EJECUCIÓN
Ambientes de Ejecución
La información sobre el ambiente en el que se están ejecutando los subprogramas en un lenguaje con la regla de alcance léxico puede ser gestionada mediante una pila (ver capítulo 4, página 188), creando registros de activación al inicio de los subprogramas y liberándolos al salir. Además, en la sección 6.5.1 se vió la necesidad de distinguir entre el ambiente de invocación y el ambiente de definición, por lo que para devolver el control al primero y poder acceder a las variables definidas en el segundo mediante referencias no locales, se hace necesario almacenar la información sobre dichos ambientes. Esta sección está dedicada a la forma de tratar dicha información.
Ambientes totalmente estáticos Como ejemplo de lenguaje en el que la gestión de la memoria se puede realizar mediante un ambiente totalmente estático se utilizará Fortran77. Este lenguaje no permite que las definiciones de subprogramas sean anidadas y tampoco admite recursión 4 , por lo que todos los subprogramas están declarados al mismo nivel. Esto significa que las referencias no locales serán siempre a variables globales y, en consecuencia, toda la gestión de la memoria se puede realizar de forma estática, de manera que la estructura de la memoria y la localización de las diferentes variables en ella no varía a lo largo de la ejecución del programa. En definitiva, el registro de activación de un subprograma será siempre el mismo. Así, el ambiente de un programa en Fortran77 con diferentes subprogramas tendrá la forma que se muestra en la figura 6.7. Area de definición de variables globales Registro de activación del programa principal Registro de activación del primer subprograma Registro de activación del segundo subprograma
Figura 6.7: Ambiente estático de un programa en Fortran77. Mientras que cada registro de activación tendrá el aspecto mostrado en la figura 6.8. Cuando se llama a un subprograma, se evalúan los parámetros y se almacenan sus direcciones en la zona de memoria de su registro de activación reservada a tal fin. Se guarda 4
Aunque en versiones posteriores de Fortran, como Fortran90, se cambia este modelo permitiendo la recursión y, consiguientemente, la asignación dinámica de variables.
SUBPROGRAMAS
271
Variables locales Parámetros del subprograma (pasados por referencia) Dirección de retorno Espacio temporal para evaluar la expresión Figura 6.8: Estructura de un registro de activación en Fortran77. la dirección de retorno a continuación y se salta a la primera instrucción del cuerpo del subprograma. Tras finalizar la ejecución, se devuelve el control a la instrucción almacenada en la dirección de retorno. Si se trata de una función, el valor devuelto a menudo se devuelve en un registro especial o en una zona de memoria del registro de activación del subprograma invocador o de la propia función. Como se puede ver, la gestión de la memoria en Fortran77 (y, en general, en cualquier lenguaje con ambiente estático) es muy simple y el propio compilador del lenguaje puede encargarse totalmente de ella, reservando la memoria necesaria en tiempo de compilación y resolviendo las referencias no locales a las variables globales.
Ambientes basados en pilas I: lenguajes con recursión sin anidamiento En el momento en el que un lenguaje admite recursión (aunque todos los subprogramas sigan definiéndose al mismo nivel, como en C), no es posible asignar memoria estáticamente para los registros de activación, ya que no es posible prever a priori cuántas llamadas recursivas se van a realizar. La forma de resolverlo es estructurar los registros de activación en una pila de registros de activación. Cada vez que se llama a un subprograma, se apila un nuevo registro de activación que será liberado cuando se termine la ejecución del subprograma. En cada uno de estos registros de activación será necesario almacenar la misma información ya almacenada en un registro de activación para un ambiente estático, esto es, variables locales, dirección de retorno del subprograma, y parámetros. Sin embargo esto no basta, ya que como ahora los registros de activación son creados de forma dinámica también es necesario almacenar la información sobre dónde comienza el registro de activación del subprograma que actualmente se está ejecutando. Esta información se debe almacenar externamente a la pila de registros de activación, normalmente en un registro que se conoce con el nombre de puntero de ambiente (o environment pointer, ep, en inglés).
272
C O N T R O L DE LA EJECUCIÓN
Además, cada registro de activación deberá almacenar adicionalmente la dirección de inicio del registro de activación del subprograma invocador, de forma que se pueda restaurar el valor del puntero de ambiente al salir del subprograma y volver al invocador. Esta información debe estar presente en todos los registros de activación y se conoce con el nombre de enlace de control (o enlace dinámico), pues se encarga de devolver el control al subprograma que llamó al actual. En el siguiente ejemplo en C, durante la ejecución de un programa se llama a un subprograma P\ que, a su vez, llama a otro subprograma P2, el cual realiza llamadas recursivas a sí mismo. Las líneas que contienen puntos suspensivos representan cualquier secuencia de sentencias que no sean llamadas a otros subprogramas: 1 void P1 2
. . .
3
P2 0 ;
4
. . .
5
(void) {
}
6 void P2 7
. . .
8
P2 0 ;
9
. . .
(void)
10 } 11 int main (void) . . . 12 13 P1 0 ;
{
{
. . .
14 15 }
Durante la ejecución del programa principal, y antes de llamar a P| (líneas 11 a 12), el estado de los registros de activación será el mostrado en la figura 6.9, con el puntero de ambiente (ep) apuntando al registro de activación del programa principal.
ep
Registro de activación del programa principal
Figura 6.9: Registros de activación durante la ejecución del programa principal. Mientras se ejecuta Pi y antes de llamar a P2, el estado de los registros de activación cambiará al mostrado en la figura 6.10, donde ep apunta al registro de activación de P¡ y desde éste se apunta al registro de activación del programa principal con el enlace de control. Ahora, al llamar al subprograma P2 (línea 3), se apila un nuevo registro de activación correspondiente a dicho subprograma y ep apuntará a él. Desde dicho registro se apuntará al registro de activación de Pi mediante el enlace de control. Esta situación se muestra en la
SUBPROGRAMAS
273
Figura 6.10: Registros de activación durante la ejecución de P\. figura 6.11, que se mantendrá mientras se ejecuten (por primera vez) las líneas 6 y 7).
Figura 6.11: Registros de activación durante la primera ejecución de P2. A continuación, al ser P2 un subprograma recursivo, a cada llamada a sí mismo (línea 8) se apilará un nuevo registro de activación del subprograma P2 que será convenientemente apuntado por ep. El enlace de control de cada uno de estos registros de activación apuntará al registro de activación del subprograma invocador, ya sea este P\ (si es la primera llamada) o P2 (en llamadas sucesivas). El estado de los registros de activación en esta situación es el representado en la figura 6.12. Cuando se termina cada una de las ejecuciones de P2 (línea 10), se desapila el registro de activación correspondiente y se restaura el valor de ep al registro de activación del subprograma invocador. Esto es posible hacerlo gracias al enlace de control presente en los registros de activación de cada una de las ejecuciones de P2. El control de la ejecución vuelve a la dirección de retorno, que podrá ser la línea 9 (si se trata de una ejecución invocada desde el propio P2) o bien la línea 4 (en caso de ser la primera llamada a P2 que se realizó desde P|). De igual manera, cuando se vuelve a P) y se termina su ejecución (línea 5), el proceso es
274
C O N T R O L DE LA E J E C U C I Ó N
enlace de control
enlace de control
Registro de activación del subprograma P2 Figura 6.12: Registros de activación durante sucesivas ejecuciones de P2. análogo: se desapila el registro de activación de Pi y ep vuelve a apuntar al registro de activación del programa principal (volviendo al estado de la figura 6.9). Este estado es el que se tiene en la ejecución de las líneas 14 y 15). Por último, es necesario considerar la forma en la que se ha de gestionar el acceso a las variables referenciadas en el cuerpo de un subprograma. Las referencias no locales siempre serán al registro de activación global y como éste se puede establecer en tiempo de compilación mediante la tabla de símbolos, las referencias no locales también, por lo que no plantean problemas. Sin embargo, las referencias locales sí deben ser tratadas de forma distinta, ya que en tiempo de compilación no se sabe en qué dirección de memoria se va a situar el registro de activación del subprograma. Un ejemplo de ello se encuentra en la figura 6.12, donde hay apilados varios registros de activación del mismo subprograma, cada uno en una dirección de memoria distinta. Sin embargo, sí que se conoce el número de variables locales que se declaran, lo que permite (conociendo el tipo de cada una) calcular cuál sería la posición de cada una de ellas dentro del registro de activación"1. Esta posición se conoce como desplazamiento de la variable local. De esta forma, para localizar una variable local en la memoria, habrá que sumar su 5
Siempre que el tamaño de las variables sea constante, claro está.
SUBPROGRAMAS
275
desplazamiento a la dirección de comienzo del registro de activación del subprograma que se encuentra en ejecución, el cual viene dado por el puntero de ambiente. Por lo tanto, la información que deberán contener los registros de activación para este tipo de lenguajes será la mostrada en la figura 6.13. enlace de control dirección de retorno parámetros desplazamientos a variables locales (siempre situados en el mismo orden) memoria temporal opcional Figura 6.13: Estructura de un registro de activación para un lenguaje con recursión sin anidamiento.
Ambientes basados en pilas II: lenguajes con recursión y anidamiento Aquellos lenguajes que, además de permitir recursión, admiten que los subprogramas se definan anidados unos dentro de otros, como Pascal o Modula-2 plantean una dificultad adicional. Salvo el tratamiento de las referencias no locales, todo lo dicho para los lenguajes con recursión sin anidamiento sigue siendo válido, pues el puntero de ambiente se puede seguir restaurando a la salida de cada subprograma gracias al enlace de control. Sin embargo, el enlace de control no permite resolver las referencias no locales siguiendo la regla de alcance léxico, puesto que se accedería a las variables definidas en el ambiente de invocación, no en el ambiente de definición. Si se utilizase el enlace de control para este cometido, se estaría utilizando una regla de alcance dinámico (ver capítulo 4, páginas 191 y siguientes). Así pues, se hace necesario que un subprograma también almacene la información de cuál es su ambiente de definición, lo que se conoce con el nombre de enlace de acceso (o enlace estático). Así, a la hora de resolver una referencia no local a una variable, se deberá seguir el enlace de acceso para buscar la variable dentro del ambiente de definición del subprograma en ejecución. Si no estuviera ahí, se deberán seguir los enlaces de acceso hasta encontrarla (o provocar un error en tiempo de compilación si la referencia no existe ni en el ambiente global). Este proceso se denomina encadenamiento de accesos y el número de enlaces de acceso que se deben seguir hasta encontrar la variable se denomina profundidad de anidamiento entre el ambiente de acceso y el ambiente de definición de la variable. Para ilustrarlo se va a modificar el ejemplo del apartado anterior, utilizando la sintaxis de Pascal y asumiendo que la definición de Pt está anidada dentro de la definición de Pi. Nue-
276
C O N T R O L DE LA EJECUCIÓN
vamente, las líneas con puntos suspensivos representan cualquier secuencia de sentencias excluyendo llamadas a subprogramas: 1 Program A n i d a m i e n t o ; 2 3 Procedure P1; 4 < d e c l a r a c i o n e s l o c a l e s de Pl> 5 Procedure P2; 6 < d e c l a r a c i o n e s l o c a l e s d e P2> 7 Begin 8
...
P2; ... End; Begin
9
10 n 12 13
. . .
14
P2;
15
. . .
16 End; 17 Begin 18 ... 19 Pl; 20
...
2i End.
La figura 6.14 muestra como se organizan los registros de activación durante la ejecución de una llamada recursiva al subprograma P2 del listado anterior: • El enlace de control del registro de activación de Pi apunta al registro de activación del programa principal, ya que Pi es invocado desde el programa principal. Además, como el subprograma P| está definido dentro del programa principal, el enlace de acceso de su registro de activación también apuntará al registro de activación del programa principal. • La misma situación se produce durante la primera ejecución de P2: el enlace de control apunta al registro de activación de Pj, porque es el subprograma invocador de P2, y el enlace de acceso también apunta al registro de activación de Pj, porque P2 está definido dentro de Pj • En sucesivas ejecuciones de P2, el enlace de control apuntará al registro de activación del subprograma invocador, que también será P2, mientras que el enlace de acceso apuntará al registro de activación de P¡, porque P2 está definido dentro de P|. Volviendo al ejemplo, para resolver las referencias no locales dentro de P2 se sigue el enlace de acceso hasta llegar al registro de activación de P\. Dentro del cual se buscará la referencia
SUBPROGRAMAS
277
Figura 6.14: Registros de activación durante las ejecuciones de P2 anidado en P\.
y, en caso de no encontrarse, se seguiría el enlace de acceso hasta llegar al registro de activación del programa principal. Si ahí tampoco se encuentra la referencia, significaría que es errónea y se produciría un error en tiempo de compilación. Ahora bien, dado que el lenguaje admite que los subprogramas se aniden unos dentro de otros, podría suceder que la referencia no local no fuese a una variable, sino a otro subprograma. Por lo tanto, cabe pensar que dentro de los registros de activación también se deberían almacenar las direcciones de inicio de todos los subprogramas definidos dentro del subprograma actual. Sin embargo eso no es necesario, ya que las direcciones de inicio de cada subprograma se establecen en tiempo de compilación y no varían a lo largo de la ejecución del programa. De esta forma, gracias a la tabla de símbolos es posible conocer, en tiempo de compilación, la dirección de ejecución de un subprograma en caso de que éste no sea local al subprograma actualmente en ejecución. Por lo tanto, la estructura de un registro de activación para este tipo de lenguajes es similar a la ya vista en la figura 6.13, simplemente añadiendo el enlace de acceso tal y como se puede ver en la figura 6.5.3.
278
C O N T R O L DE LA E J E C U C I Ó N
enlace de control enlace de acceso dirección de retorno parámetros desplazamientos a variables locales (siempre situados en el mismo orden) memoria temporal opcional Figura 6.15: Estructura de un registro de activación para un lenguaje con recursión y anidamiento.
Ambientes totalmente dinámicos La gestión mediante una pila del ambiente de ejecución tiene sus limitaciones si se permite que los subprogramas puedan ser creados de forma dinámica. Por ejemplo, en Haskell es posible devolver una función como resultado de otra función, ya que las funciones son ciudadanos de primera ciase (como ya se vio en el capítulo 2). En este tipo de lenguajes no es posible que la gestión del ambiente se base en pilas, porque el enlace de acceso de un subprograma apuntaría a su ambiente de definición, el cual podría haber desaparecido en el momento en el que el subprograma sea invocado. La forma de resolver este problema es no eliminar del ambiente el registro de activación de un subprograma mientras existan referencias a objetos definidos localmente en él. Este tipo de ambientes se denominan ambientes totalmente dinámicos y necesitan mecanismos para recuperar de forma automática la memoria que ya no es accesible, como los conteos de referencias o la recolección de basura. La gestión que realiza Haskell (y otros lenguajes funcionales) de su ambiente de ejecución está basada en este modelo, aunque su estudio queda fuera del propósito de este libro.
6.6
Ejercicios resueltos
1. Dadas las siguientes expresiones escritas en forma iníija, escribirlas en forma postfija: (a) 3 + 4 + 2 * 8 Solución: El primer paso para convertir una expresión escrita en forma infija a su forma postfija es dibujar su árbol de sintaxis abstracta, que en este caso (siguiendo la precedencia y asociatividad usuales) es:
279
EJERCICIOS RESUELTOS
+
3 4 2 A continuación hay que recorrer el árbol teponiendo los operandos al operador que presión en forma postfija es: 3 4 + 2 8 *
8 desde las hojas hacia la raíz, anlos relaciona. Por lo tanto, la ex+.
(b) (3 + 2) * (5 - 2) Solución: El árbol de sintaxis abstracta de la expresión es: *
3 2 5 2 Por lo que la forma postfija de la expresión es: 3 2 + 3 2 - * . (c) True AND False OR False
Solución: El operador de conjunción AND tiene mayor precedencia que el operador de disyunción OR, por lo que el árbol de sintaxis abstracta de la expresión es: OR AND True
False
False
Y su f o r m a postfija: True False AND False OR.
(d) a + b * c - d Solución: El árbol de sintaxis abstracta es: *
a b Y la forma postfija: a b + c d - *.
c
d
Si se utiliza una máquina pila para evaluar expresiones (utilizando para ello la forma postfija de dichas expresiones), ¿qué tipo de evaluación se realiza? Solución: El funcionamiento de una máquina pila consiste en apilar los operandos en una estructura de pila en el orden en el que aparecen en la notación postfija de una expresión
280
C O N T R O L DE LA EJECUCIÓN
y, cuando se llega a un operador, se desapilan los operandos que dicho operador necesita, se opera y se apila nuevamente el resultado. Esto significa que antes de que se realice la operación, los operandos deben haber sido completamente evaluados, por lo que se trata de una evaluación impaciente. 3. ¿Es posible implementar una evaluación en cortocircuito utilizando una máquina pila? Solución: Una de las operaciones que normalmente se benefician de la evaluación en cortocircuito es la multiplicación por 0, cuyo resultado será 0 con independencia del valor del segundo operando. Así, la evaluación en cortocircuito de la expresión 0 * (3 * (5 + 8 * 2)) no precisaría de la evaluación del segundo operando para saber que el resultado es, directamente, 0. Sin embargo, si se calcula la forma postfija de la expresión partiendo del árbol de sintaxis abstracta: *
3
+ *18
5 8
2
Que permite escribir la forma postfija de la expresión como 0 3 5 8 2 * + **, muestra que el segundo operando se evaluaría completamente, ya que el operando de multiplicación cuyo primer operador es 0 es el último. Por lo tanto, una evaluación en cortocircuito no puede implementarse con una máquina pila de forma directa. Normalmente se implementa haciendo que el compilador incluya código adicional para comprobar el valor del primer operando una vez ha sido evaluado y, en caso necesario, saltar el código de evaluación del segundo operando. 4. El lenguaje Pascal admite tres tipos de bucles: • Bucles while C do S;, que repiten la sentencia S mientras sea cierta la condición C. • Bucles repeat S until C;, que repiten la sentencia S hasta que la condición C sea cierta. • Bucles for v:=INI to FIN do S;, que repiten la sentencia S desde que la variable v tiene el valor INI hasta que dicha variable alcanza el valor FIN. El valor de v se incrementa en una unidad a cada ejecución del bucle.
EJERCICIOS RESUELTOS
281
Reescribir los bucles repeat y for mediante construcciones realizadas con el bucle while para demostrar que son azúcar sintáctico. Solución: • Bucles repeat: la condición de salida del bucle se evalúa tras la ejecución de la sentencia S, por lo que dicha sentencia se ejecuta, al menos, una vez. Además, la condición del bucle while debe cumplirse para que se ejecute el cuerpo del mismo, justo lo contrario que debe cumplirse para que se ejecute el cuerpo del bucle repeat. Por lo tanto, un bucle repeat puede reescribirse en términos de while como: S;
while
( not C ) do S;
• Bucles for: el cuerpo del bucle for se ejecuta mientras la variable v toma los valores entre INI y FIN (ambos inclusive). Dicha variable se incrementa en 1 a cada vuelta del bucle, por lo que el bucle finaliza cuando el valor de v es superior a FIN. En otras palabras, el cuerpo del bucle se ejecuta mientras el valor de v sea menor o igual que FIN. Por lo tanto, el bucle while equivalente sería: v : = INI; while ( v
View more...
Comments