Académique Documents
Professionnel Documents
Culture Documents
“Si la forma en que nos comunicamos influye en la forma que pensamos y viceversa, entonces la forma en que
programamos influye en lo que pensamos sobre los programas y viceversa”.
Antes de la década de los 40, se programaba cableando, y es en dicha década cuando Von Neumann plantea el uso
de códigos para determinar las acciones de los ordenadores, naciendo asi el lenguaje ensamblador. Pero dependía de
cada ordenador y era difícil de entender.
Al principio los lenguajes seguían reflejando la arquitectura Von Neumann: un área de memoria donde se
almacenaban tanto a los programas como a los datos de los mismo, y por separado había una unidad de
procesamiento.
Los lenguajes modernos, al 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.
“Un lenguaje de programación es un sistema notacional para describir computaciones en una forma legible tanto
para el ordenador como para el 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, siendo los lenguajes más
interesantes, desde el punto de paradigma, son los lenguajes de propósito general.
- Primera: Se incluyen lenguajes máquina, en los que los datos y las operaciones se describen mediante ceros y
unos.
- Segunda: Incluye a los lenguajes ensambladores, cuya traducción a lenguaje maquina es muy sencilla.
- Tercera: Incluye a los lenguajes de alto nivel como Pascal, Fortran, C o Java. Para su traducción a lenguaje
maquina se necesitan compiladores o intérpretes.
- Quinta: Se incluyen lenguajes que se utilizan en primer lugar, en el área de la Inteligencia Artificial, como
Prolog y Haskell.
- Básicas.
Se refieren a la representación internas de los datos de tipo atómico que ofrece el lenguaje, junto a sus
operaciones estándar. 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 (variable).
- Estructuradas.
Son el mecanismo de abstracción para colecciones de datos. Una estructura típica es el array, que reúne
datos como una secuencia de elementos.
- Unitarias.
Se refieren a la agrupación, como única unidad, de datos y operaciones sobre ellos. Introducen el concepto
de encapsulado de datos, mecanismo muy útil para reunir códigos relacionados entre sí en localizaciones
específicas dentro del programa.
Estas abstracciones se asocian con los tipos abstractos de datos, lo que se conoce como interfaz.
Las clases de los lenguajes orientados a objetos son un mecanismo conceptualmente más cercano a las
abstracciones unitarias.
Dos de las características más importantes de estas abstracciones de datos son:
- Capacidad de reutilización.
- Interoperabilidad.
- Básicas.
Son sentencias individuales que permiten modificar el control del flujo de la ejecución de un programa,
como la sentencia de asignación o el goto de Fortran.
- Estructuradas.
Agrupan sentencias más simples para crear una estructura con un propósito común, como los bucles o las
sentencias condicionales.
Otro mecanismo para estructurar el control es el subprograma, que necesita una declaración con un nombre
y un conjunto de acciones a realizar que se abstraen bajo dicho nombre. 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 que requiere el almacenamiento del estado del programa en el punto de
llamada en el entorno de ejecución.
- Unitarias.
Permiten agrupar una colección de subprogramas como una unidad en si misma e independiente del
programa. Son idénticas a las abstracciones de datos unitarias, simplemente varía el enfoque, que en esta
ocasión se orienta más a las operaciones que a los datos.
Si un lenguaje de programación solo necesita describir computaciones, entonces solo necesita mecanismos
suficientes para describir todos los cálculos que puede llevar a cabo una máquina de Turing.
Como resumen:
- Paradigma imperativo: La computación puede ser paralela o ser no determinista e independiente del orden.
- Paradigma funcional: Basado en abstracciones matemáticas, usa la noción de función según se plantea en el
lambda calculo.
Este paradigma se basa en la idea de que un objeto se puede describir como una colección de posiciones de
memoria junto con todas las operaciones que pueden cambiar los valores de dichas posiciones.
En la mayoría de lenguajes orientados a objetos, los objetos se agrupan en clases que representan a todos los
que tienen las mismas propiedades. Tras la declaración de una clase, se pueden crear objetos concretos a partir
de la misma, mediante la instanciación de la clase.
Un programa está formado por un conjunto de sentencias que describen lo que es verdad o conocido con respecto
a un problema, en vez de indicar la secuencia de pasos que llevan al resultado.
En Prolog, un programa es un conjunto de sentencias, denominadas clausulas, 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”.
TEMA 2: PRINCIPIOS DE DISEÑO DE LOS LENGUAJES
2.1 DESCRIPCIÓN DE LOS LENGUAJES DE PROGRAMACIÓN
Los lenguajes de programación deben describirse de manera formal, completa, precisa e independiente de la máquina
y de la implementación.
Los elementos fundamentales para la definición de un lenguaje de programación son los siguientes:
- Léxico: Es el 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 identificadores, operadores y
símbolos de puntuación.
- Sintaxis: Conlleva la descripción de los diferentes componentes del lenguaje y de sus combinaciones posibles.
Para ello se utilizan las gramáticas libres de contexto.
- Semántica: Expresa los efectos de la ejecución en un contexto determinado. La semántica es la parte más difícil
en la definición de un lenguaje. Entre los sistemas de notación para definiciones semánticas formales se
encuentran:
- Semántica axiomática: Modela el significado con un conjunto de axiomas que describen a sus
componentes junto con algún tipo de inferencia del significado.
En el caso del interprete, la ejecución de un programa se realiza en un paso. El intérprete produce la ejecución
del programa sobre los datos de entrada. La compilación es un proceso de dos pasos: el código fuente de la
entrada se convierte en un nuevo programa o código objeto que es el que puede ser ejecutado.
El lenguaje del código objeto debe ser a su vez traducido por un ensamblador que será linkado con otros códigos
objeto. Otro caso posible es aquel en el que un pseudo-interprete no produce un programa objetivo, sino que
traduce el programa fuente a un lenguaje intermedio que posteriormente es interpretado.
Las fases que tanto un intérprete como un compilador deben llevar a cabo son:
1. Un analizador léxico debe identificar los tokens del programa, ya que inicialmente el programa se
entiende como una secuencia de caracteres.
2. Un analizador sintáctico identifica las estructuras correctas que definen las secuencias de tokens.
3. Un analizador semántico asigna el significado de forma suficiente para su ejecución o la obtención del
programa objetivo.
Estas fases exigen el mantenimiento de un entorno o ambiente de ejecución, que administra el espacio de
memoria para los datos del programa y registra el avance de la ejecución.
Los interpretes son menos eficientes que los compiladores ya que estos permiten la optimización del código en
análisis previos a la ejecución del programa.
Hay otro aspecto que influye en la selección de un intérprete o un compilador: las propiedades del lenguaje que
pueden ser determinadas antes de su ejecución (estáticas) y las que no (dinámicas). En un lenguaje que solo
tenga asignación estática, se puede utilizar un ambiente totalmente estático, y en caso contrario usar uno
totalmente dinámico. Una posición intermedia es el ambiente basado en pilas.
Un último aspecto por considerar con respecto a la traducción es el relacionado con la recuperación de errores,
que favorece la fiabilidad. Los errores se clasifican de acuerdo con la fase de traducción en que se encuentran:
- Errores sintácticos: Se refieren a tokens que faltan en expresiones o expresiones mal formadas.
- Errores lógicos: Son también cometidos por el programador y producen un comportamiento erróneo o no
deseable del programa.
Este principio se refiere a que el diseño debe permitir al traductor la generación de código ejecutable eficiente.
La eficiencia se organiza en tres principios:
- Eficiencia de traducción: Estipula que el diseño del lenguaje debe permitir el desarrollo de un traductor
eficiente y de tamaño razonable. Hay traductores que se diseñan para que los programadores omitan la
verificación de errores con reglas muy difíciles de verificar en tiempo de traducción, lo que influye
negativamente en otro principio de diseño: la fiabilidad que aparece ligada a la verificación de errores.
- Eficiencia de implementación: Es la eficiencia con que se puede escribir un traductor, que a su vez
depende de la complejidad del lenguaje.
- Eficiencia de la programación: Relacionada con la rapidez y facilidad para escribir programas o capacidad
expresiva del lenguaje, que se refiere a la facilidad para escribir procesos complejos de forma que el
programador relacione de manera sencilla su idea con el código.
Relacionado con este concepto, está el de Azúcar Sintáctico, para descubrir aquellas estructuras sintácticas
que no añaden expresividad al lenguaje, pero que facilitan la escritura de los programas.
La fiabilidad exige de tiempo adicional para pruebas, recuperación de errores o agregación de nuevas
características y depende tanto de los recursos disponibles como de su consumo.
2.2.2 La regularidad
Se refiere al comportamiento de las características del lenguaje. Las irregularidades durante el diseño de un
lenguaje son por causa de las prioridades de diseño. Sin embargo, si una irregularidad no puede justificarse
entonces probablemente sea un error de diseño.
La regularidad se divide en tres propiedades, y si viola alguna de ellas, el lenguaje se puede clasificar como
irregular.
- Generalidad: Se consigue cuando el uso y la disponibilidad de los constructores no están sujetas a casos
especiales y cuando el lenguaje incluye solo a los constructores necesarios y el resto se obtienen por
combinaciones de constructores relacionados.
- Ortogonalidad: Ocurre cuando los constructores del lenguaje pueden admitir combinaciones significativas
y en ellas, la interacción entre los constructores o con el contexto, no provocan restricciones ni
comportamientos inesperados.
- Simplicidad.
Se refiere sintácticamente a que cada concepto del lenguaje se presente de forma única y legible y
semánticamente que contiene el menor número posible de conceptos y estructuras con reglas sencillas de
combinación.
- Expresividad.
Es la facilidad con la que un lenguaje de programación permite expresar procesaos y estructuras complejas,
siendo uno de los mecanismos más expresivos, la recursividad. Una expresividad muy alta entra en
conflicto con la simplicidad ya que el ambiente de ejecución es muy complejo.
- Extensibilidad.
- Capacidad de restricción.
Un lenguaje debe incorporar notaciones y cualquier otra característica que se hayan convertido en
estandares, como lo son el concepto de programa, funciones y variable.
- Precisión.
Es la propiedad que exige una definición precisa del lenguaje, de forma que el comportamiento de los
programas sea predecible. La precisión ayuda a la confianza en los traductores, porque el programa se
comporta igual en cualquier máquina.
- Portabilidad.
- Seguridad.
Pretende evitar los errores de programación y permitir su descubrimiento. Por lo tanto, está muy relacionada
con la fiabilidad y la precisión. Sin embargo, compromete a la capacidad expresiva del lenguaje y a su
concisión, pues se apoya en que el programador especifique todo lo que sea posible en el código.
- Interoperabilidad.
Se refiere a la facilidad que tienen diferentes tipos de sistemas en general para trabajar conjuntamente de
manera efectiva, sin comunicaciones previas, para intercambiar información útil y con sentido. Kay tres
tipos de interoperabilidad:
- Semántica: Cuando los sistemas intercambian mensajes entre sí, interpretando el significado y el
contexto de los datos.
- Sintáctica: Cuando un sistema lee datos de otro, mediante una representación compatible.
En ocasiones se puede escribir dentro de un programa, código en otro lenguaje de programación, con objeto
de simplificar la programación.
- x → x (dado x, devuelve x)
- x, y → x + y (dados x e y, devuelve x + y)
- Toda función que requiera dos argumentos puede ser reinterpretada como una función que requiera un único
argumento y que devuelva una función que aceptará un único argumento. Proceso que se conoce como
currificación.
Se pueden escribir ecuaciones en las que se utilice la función doble aplicada a una variable x que representa un valor
real:
sea x tal que doble(x) = 4
El concepto de variable sirve para hacer referencia al valor de uno de los parámetros de la función, no una localización
en memoria que pueda ser modificada. Esto se conoce como concepto de programación pura.
No desaparece el concepto de constante, ya que una constante puede ser vista como una función que siempre devuelve
un mismo valor.
Dado que en programación funcional no existe el concepto de variable, no existe el concepto de asignación y en
consecuencia desaparece el concepto de bucle, que deben ser resueltos mediante recursión.
Otra consecuencia de la ausencia del concepto de variable es la ausencia de un estado del cómputo. De esta forma,
el resultado de una función depende únicamente de los valores de sus argumentos, siendo independientes de cualquier
calculo que se haya realizado con anterioridad a la llamada a la función. Esta propiedad se conoce como transparencia
referencial.
En programación funcional, todas las funciones son transparentes referencialmente, lo que simplifica la semántica
de los programas. El entorno en el que los programas se ejecutan asocia nombres a valores que nunca van a variar a
lo largo del cómputo, lo que se denomina semántica de valor.
Dado que los resultados no dependen del orden en el que se evalúe el resto del programa, la paralelización del
cómputo se facilita bastante.
Las funciones pueden devolver funciones y, para mantener la coherencia, debería poder recibir funciones como
parámetros de entrada. Esto se expresa diciendo que las funciones son ciudadanos, valores, objetos o entidades de
primera clase.
Ejemplo: Se suponen las siguientes dos funciones:
| suma3 x = x + 3
| funCuadrado f x = f x * f x
suma3 toma un número y devuelve el resultado de sumar 3 a dicho número. funCuadrado toma una función f y un
valor x y devuelve el resultado de elevar al cuadrado el resultado de aplicar f a x. Por tanto, se puede hacer:
funCuadrado suma3 5
El tipo de funciones que reciben otra función como parámetro de entrada se denominan funciones de orden superior.
y se realiza la llamada:
decide (true, 1/0)
Si la evaluación de los parámetros se realiza de forma estricta, la ejecución se detendrá con error. La función decide
no necesita conocer el valor del parámetro y para devolver el resultado, ya que como x es true, la función podría
devolver el valor 12 con independencia del valor que se haya pasado al parámetro y.
Esta función es no estricta, ya que no necesita conocer el valor de todos sus parámetros para devolver el resultado.
Todos los lenguajes modernos presentan una forma de evaluación no estricta para algunas de sus operaciones
conocida como evaluación en cortocircuito.
Gracias a la evaluación no estricta, se puede retrasar la evolución de alguna expresión hasta que su valor concreto
sea necesario para continuar el computo, lo que es útil si la evaluación de la expresión conlleva un coste importante.
Se acuñó el término memoización para referirse al proceso de almacenar el valor de una expresión la primera vez
que se evalúa, lo que se conoce como paso por necesidad.
Para evaluar un parámetro compuesto, como una lista, sería necesario retrasar todos aquellos pasos necesarios para
realizar esa evaluación al completo. De esta forma, la evaluación ira paso a paso y se podrá detener en el momento
en el que no sea necesario continuar.
La capacidad de retrasar la evaluación de los parámetros de las funciones junto con la memoización se conoce con
el nombre de evaluación perezosa, ya que se evalúa lo justo y necesario para seguir con el cálculo y solo lo hace una
vez.
La evaluación perezosa permite en muchas ocasiones realizar una programación separando las funciones:
Haskell es un lenguaje sensible a mayúsculas y minúsculas (case sensitive). En concreto, los identificadores de
tipo siempre han de comenzar con una letra mayúscula, mientras que los identificadores de funciones y los de
variables comienzan con letras minúsculas.
- Tipos atómicos: Son aquellos tipos cuyos valores no pueden descomponerse en otros.
- Enteros: Hay dos tipos diferentes de enteros, que pertenecen a una misma clase denominada Integral.
- Int: Representa a los enteros ligados a la arquitectura del computador. Son muy eficientes, pero están
acotados.
- Integer: Representa el conjunto de los numero enteros sin ningún tipo de limite. Es menos eficiente
que Int, pero son muy útiles cuando se trabaja con algoritmos que maneje números muy grandes.
- Booleanos: Representan los valores True y False del algebra de Boole, siendo Bool su identificador.
- Tipos estructurados: Son aquellos tipos cuyos valores pueden descomponerse en otros más simples.
- Tupla: Es una secuencia finita de valores de tipos no necesariamente iguales. La forma de construir una
tupla es encerrar estos valores entre paréntesis y separarlos mediante comas.
- Lista: Es una colección de valores de un mismo tipo. El tipo se expresa encerrando entre corchetes
cuadrados el tipo de sus elementos. Las listas de tipo [Char] representan cadenas de caracteres, que pueden
escribirse tanto encerradas entre comillas dobles (“lista”) como en forma de lista con todos sus caracteres
separados por comas ([‘l’, ‘i’, ‘s’, ‘t’, ‘a’]).
Las listas deben ser entendidas como una estructura recursiva con dos constructores básicos: la lista vacía
representada como [] y el constructor infijo (:) que añade un elemento a la cabeza de la lista y asocia a la
derecha.
[‘l’, ‘i’, ‘s’, ‘t’, ‘a’] es una forma abreviada de escribir ‘l’ : ‘i’ : ‘s’ : ‘t’ : ‘a’ : []
- Listas definidas por extensión: Se escriben todos sus componentes entre corchetes cuadrados.
Ejemplo:
- Mediante un identificador de función: Debe comenzar obligatoriamente con una letra minúscula y a
continuación una secuencia de caracteres que pueden ser letras, dígitos, apostrofes o subrayados. Se utilizan
de forma prefija.
Al trabajar con funciones definidas mediante operadores hay que tener en cuenta dos factores:
Haskell permite definir las reglas de precedencia y asociatividad de los operadores mediante una
declaración utilizando la siguiente sintaxis:
donde:
- asociatividad: Ha de ser:
La definición de una función se realiza mediante una serie de ecuaciones con el siguiente formato:
La forma de selecciona que definición se va aplicar se denomina encaje de patrones, donde 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. 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 textual en el que se hayan escrito en el programa.
En caso contrario se detendría la ejecución del programa.
Los patrones básicos que pueden ser utilizados en la definición de las funciones son:
- Patrones constantes: Representa un dato de cualquier tipo de datos y solo encaja con un argumento
de entrada que coincida exactamente con dicha constante.
Ejemplo:
| f 1 = True
| f 2 = False
f toma un valor entero y devuelve True si dicho valor es 1 y False si el 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.
- 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 y puede ser referenciado dentro de la
definición de función.
Ejemplo:
| suma x y = x + y
donde x e y son patrones variable que encajarían con cualquier valor que se pase a la función siempre
que sean de los tipos adecuados.
Ejemplo:
| siempre2 x = 2
|siempre2 _ = 2
- Patrones para listas: Cuando se quiere trabajar con listas, se pueden utilizar los siguientes tipos de
patrones:
| suma [] = 0
| suma (x:xs) = x + suma xs
- Patrones para tuplas: La forma de definir patrones es referenciar todos los elementos de la tupla
encerrados entre paréntesis.
(x, y, z)
representa una tupla formada por tres elementos los cuales se referencian como x, y, y z
respectivamente, mientras que:
(a, _ )
representa una tupla de dos elementos, el primero de los cuales se referencia como a y cuyo
segundo elemento encajara con cualquier valor de cualquier tipo.
- Patrones con nombre: Todo patrón no anónimo puede ser nombrado para referenciar el argumento
completo
Ejemplo: Se quiere definir una función que reciba una lista y devuelva la misma lista, pero con el
primer elemento duplicado.
sin embargo, es posible renombrar todo el patrón x:xs con un nombre y referenciarlo con ese nombre.
(n + k)
donde k es una constante natural. Este patrón solo encajara con un número entero mayor o igual que
k, asociando a la variable n el valor de dicho número menos k.
- Expresiones case.
Es posible utilizar patrones en cualquier punto de una expresión utilizando una expresión case, que emplea
una sintaxis muy similar a las sentencias case presentes en múltiples lenguajes de programación:
case expresión of
patron1 -> resultado1
patron2 -> resultado2
…
patronn -> resultadon
- Funciones definidas a trozos: guardas.
Si solo se pudiera definir una función a base de ecuaciones mediante encaje de patrones, no sería posible
definir una función como el valor absoluto:
x si x >= 0
|x|
-x si x < 0
Para distinguir si el valor de dicha variable es positivo o negativo, se pueden definir guardas dentro de una
función de la siguiente forma:
identificador <lista_de_patrones>
| guarda1 -> expresion1
…
| guardan -> expresión
Cada línea conteniendo una guarda debe presentar una indentación inicial con respecto a la línea de
definición de la función, para que no se confundan con la definición de otra función.
Cada guarda es una expresión de tipo Bool y las expresiones han de ser del mismo tipo, que será el tipo del
calor devuelto por la función. a la hora de evaluar la función, se evalúan las guardas por orden textual y la
primera que devuelva True será la que se aplique.
abs x
| x >= 0 = x
| x < 0 = -x
Es posible utilizar la constante otherwise como una guarda para indicar que esa condición deberá ser
utilizada si no se hubiera cumplido ninguna otra guarda previa.
abs x
| x >= 0 = x
| otherwise = -x
- Definiciones locales.
Es posible definir subfunciones dentro de una función. Al igual que con las guardas, deben estar indentadas
y si se definen varias subfunciones, todas ellas han de conservar la misma indentación. la forma de definir
subfunciones es la siguiente:
definición_de_la_funcion
where
subfuncion1
…
subfuncionn
Las subfunciones solo pueden aparecer al final de una definición de función y solo pueden ser utilizadas
dentro del cuerpo de esa función.
Ejemplo:
f x y = (a + 1) * (c – 1)
where
a = div (x + y) 2
c = mod (x + y) 2
al igual que las expresiones case permiten utilizar patrones en cualquier punto de una expresión, es posible
utilizar definiciones locales dentro de cualquier expresión utilizando la sintaxis:
let a x = x + 1 in a 100
devuelve 101.
Ejemplo: La función:
| esPar x = mod x 2 == 0
- La función error.
Al ser invocada, esta función detiene completamente la evaluación y muestra por pantalla la cadena de
caracteres que se le ha pasado como parámetro.
Ejemplo:
Las funciones también tienen un tipo que depende de los tipos de sus parámetros de entrada y de salida. El tipo
de las funciones puede definirse usando la siguiente sintaxis:
identificador :: expresionDeTipo
donde expresionDeTipo es una lista de los tipos de todos los parámetros de entrada y el tipo devuelto por la
función separados todos ellos por operadores ->.
Aunque no es necesario definir explícitamente el tipo de una función, si se desea incluir la definición de tipo,
esta deberá situarse siempre antes de cualquier definición ecuacional de la función.
Para cualquier función, el intérprete de Haskell permite averiguar el tipo inferido para una función de la siguiente
forma:
:t identificador
- Polimorfismo.
En algunas ocasiones las funciones tienen sentido para varios tipos de datos. Las funciones que para
cualquier parámetro de entrada x, devuelve, precisamente x, se denominan funciones polimórficas. Para
representar su tipo, Haskell introduce las variables de tipo, que denotan cualquier tipo de datos. Asi, la
función identidad, id, se escribe como:
id :: a -> a
Si alguno de los tipos solo admitiera a una clase de tipos, se incluyen esas restricciones al comienzo de la
expresión de tipo separándolas de la lista de tipos de los parámetros por el símbolo =>.
Para cualquier tipo a perteneciente a la clase Num, + recibe dos parámetros de dicho tipo y devuelve un
resultado del mismo tipo.
Las funciones en Haskell se pueden currificar 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.
Si se tienen más de dos parámetros de entrada, es posible generalizar la currificación de las funciones
entendiendo que toda función con n parámetros de entrada es una función con m parámetros de entrada
(siendo m < n), cuya definición es la misma que la función original, pero sus n – m parámetros se tratan
como constantes
Ejemplo:
Las funciones definidas como operadores también pueden beneficiarse de la aplicación parcia, en cuyo
caso se denominan secciones de un operador. Para usar un operador con uno de sus argumentos es necesario
recordar que todo operador binario definido de forma infija puede ser utilizado de forma prefija sin más
que encerrar su símbolo de operador entre paréntesis. Así las secciones del operador / serian:
Es una función que tiene alguna función entre sus parámetros de entrada.
Ejemplo: La función:
aplica f x y = f x y
es una función que recoge tres parámetros de entrada y devuelve el resultado de aplicar el primero de ellos
al segundo y al tercero.
Como hasta ahora no se tiene ninguna información sobre qué tipos han de tener dichos parámetros, la única
información que se tiene es que el tipo más genérico para la función f es:
f :: a -> b -> c
Por último, se sustituye 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:
que se debe leer como que aplica recibe una función f, la cual necesita dos parámetros de entrada de los
tipos a y b y devuelve y valor del tipo c, y dos valores a y b para devolver finalmente un valor del tipo c.
Se denota como ⊥ y representa el valor que tienen aquellas expresiones cuya evaluación produce un error
o no termina.
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 representarán el mismo tipo de datos y a todos los efectos serán
totalmente equivalentes.
Ejemplo:
En la primera definición se asigna el nombre Carácter al tipo de datos predefinido Char, mientras que en la
definición de la línea 2 se indica que el tipo String es una lista de elementos tipo Char. Asi se puede
reescribir como:
- Tipos enumerados: Consisten en una enumeración de todos los valores que pertenecen a dicho tipo, por
tanto, han de tener una cantidad finita de elementos.
Mediante el uso de la palabra reservada deriving, 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 en el nuevo tipo de datos. Las clases de tipos más usadas son:
- Eq: Si un tipo deriva de esta clase es posible comparar valores de dicho tipo mediante los operadores
de igualdad.
- Ord: Que un tipo derive de esta clase implica que se induce un orden en los valores del tipo.
- Show: Permite que el intérprete pueda mostrar un valor de ese tipo. Si un tipo deriva de esta clase,
el nombre del valor será la representación textual que el intérprete mostrará por pantalla.
- Read: Permite el paso inverso al de la clase Show. Convierte cadenas de caracteres en elementos del
tipo.
A la hora de utilizar un tipo de datos enumerado en una función, se pueden emplear tanto, patrones
constantes:
Ejemplo:
Asi, sería posible definir una lista de elementos del tipo BooleanoEntero, salvando la restricción de que las listas
solo pueden tener elementos del mismo tipo.
| listaMixta :: [BooleanoEntero]
| listaMixta = [Entero 0, Booleano True, Booleano False, Entero -1]
Cuando se quiera utilizar un tipo de datos que sea unión de tipos, en el patrón se deberá incluir el identificador
de tipo de datos que se pretenda utilizar en cada momento.
- Producto cartesiano: Los elementos de un tipo construido mediante el producto cartesiano están formados por
tantas componentes como tipos se estén incluyendo en el producto.
Ejemplo: Es posible definir un tipo de datos Asignatura 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:
El constructor de tipo (Asig) puede considerarse como una función que recibe los valores de cada uno de los
tipos que intervienen en el producto cartesiano y devuelve un valor del tipo producto.
Cuando se utiliza un tipo de datos producto en un patrón, será necesario encerrar todo el patrón entre paréntesis
y se deberá incluir el constructor de tipo.
Ejemplo:
| esDePrimero (Asig _ x _) = x == 1
Recibe un valor de tipo Asignatura y devuelve un valor de tipo Bool que indica si dicha asignatura es de primer
curso o no.
data también ofrece la posibilidad de nombrar cada una de las componentes del tipo utilizando la siguiente
notación:
Esta notación es más cercana a los tipos de datos registro comúnmente presentes en lenguajes imperativos,
obteniendo una serie de ventajas.
Ejemplo:
Ejemplo:
| esDePrimero x = curso x == 1
- Es posible crear nuevos datos a partir de otros ya existentes tan solo modificando algunas de sus
componentes:
Ejemplo:
- Tipos de datos recursivos: Un tipo de datos será recursivo siempre que el tipo que está siendo definido aparece
en su propia definición. El típico ejemplo es la definición de los naturales mediante inducción.
Ejemplo:
El cero es natural.
Si n es natural, entonces su sucesor también lo es.
Cero :: Natural
Sucesor :: Natural -> Natural
| data Natural = Cero | Sucesor Natural
- Tipos polimórficos: Son aquellos que pueden contener valores de cualquier tipo. El tipo genérico es:
En Haskell esta función viene predefinida como length. La forma de programar esta función sería:
| length [ ] = 0
| length (x:xs) = 1 + length xs
- Concatenar listas.
Dadas dos listas xs e ys, se desea obtener una lista formada por la concatenación de ambas de forma que
primero aparezcan los elementos de xs y después los de ys. Esto se realiza mediante la función (++).
- Si la primera de las listas es vacía, entonces el resultado de la concatenación será 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 concatenación será la concatenación de la cola de la primera lista
con la segunda lista.
| [ ] ++ ys = ys
| (x:xs) ++ ys = x : (xs ++ ys)
Una extensión es la concatenación de una lista de listas, y se realiza mediante la función concat. La forma
de realizar esta función es:
- El caso en el que la lista de listas es vacía, la concatenación de todas sus listas también será vacía.
- 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.
| concat [ ] = [ ]
| concat (xs:yss) = xs ++ (concat yss)
- Combinar listas.
- Si las dos listas tienen un primer elemento, el primer elemento de la lista resultante seria la tupla
formada por ambos elementos, mientras que la cola de la lista resultante será el resultado de aplicar
zip a las colas de ambas listas.
- Si alguna de las listas no tuviera primer elemento, entonces el resultado debería ser la lista vacía.
Ahora es posible hacer justo lo contrario: partiendo de una lista de tuplas, separarla en dos listas, la primera
con los primeros elementos de las tuplas y la segunda con los segundos. De esto se encarga la función
unzip, que tiene el siguiente funcionamiento:
- Si la lista de tuplas fuese vacía, entonces se deberá devolver la tupla compuesta por dos listas vacías.
- Si la lista de tuplas no es vacía, se tendrá una cabeza de lista formada por la tupla (a,b) y un resto de
lista xs, pero no se tiene acceso a las listas que forman los primeros y segundos elementos de las tuplas
de xs. Para ello se introducen parámetros acumuladores que actuaran de forma similar a como lo harían
las variables en un programa imperativo. Teniendo esto en cuenta, el proceso será el siguiente:
1. Se crea una función unzipAux que reciba dos parámetros: la lista de entrada y un parámetro
acumulador en el que se ira construyendo la salida.
2. Si la lista de entrada no es vacía, entonces tiene una cabeza (a,b) y un resto xs. Se tiene una
tupla cuyo primer elemento es la lista (as) de los primeros elementos de salida y cuyo segundo
elemento (bs) es 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].
3. La función unzip debe llamar a unzipAux dando el acumulador un valor inicial que permita ir
construyendo este parámetro de forma adecuada.
El código es el siguiente:
| unzip xs = unzipAux xs ([ ], [ ])
| where
| unzipAux [ ] acumulador = acumulador
| unzipAux [ ] ((a,b) : xs) (as, bs) = unzipAux xs (as ++ [a], bs ++ [b])
Esto lo realiza la función map devolviendo una nueva lista de la misma longitud que xs, pero conteniendo
los resultados de aplicar f a los respectivos elementos de xs. La construcción se justifica como:
- Aplicar map sobre una lista vacía produce una lista vacía.
- 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.
| map _ [ ] = [ ]
| map f (x:xs) = (f x) : (map f xs)
Una extensión de map seria aplicar una función con dos parámetros de entrada a dos listas, para producir
una tercera lista cuyo primer elemento sería el resultado de aplicar la función a los primeros elementos de
ambas listas, el segundo sería el resultado de aplicar la función a los segundos elementos de ambas listas y
asi sucesivamente. Esta función se llama zipWith y su construcción es la siguiente:
- Si se quiere aplicar la función f a dos listas cuyos primeros elementos son a y b respectivamente (y
sus colas son as y bs), entonces el primer elemento del resultado será f a b y la cola será el resultado
de aplicar zipWith a f y a las colas de las listas de entrada.
- Si alguna de las listas no tuviera al menos un elemento, entonces el resultado debería ser la lista
vacía.
Una variante seria generar una lista de forma que cada elemento sea la aplicación de una determinada
función sobre el elemento precedente. De esto se encarga la función iterate, cuya construcción es:
| iterate f x = x : (iterate f (f x ))
Filtrar sirve para producir una nueva lista cuyos elementos pertenezcan a la lista original y que además
cumplan una cierta propiedad. Haskell ofrece las funciones take y drop que permiten coger o descartar los
n primeros elementos de una lista. Su funcionamiento se basa en:
- Si se quieren coger o descartar 0 o menos elementos de cualquier lista, se presenta otro caso base.
En el caso de take, el resultado deberá ser una lista vacía. En el caso de drop, el resultado será
precisamente la lista de entrada.
- Si la lista tiene al menos un elemento y el número de elementos que se desea coger o descartar es 1
o más, el resultado será coger o 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.
| take _ [ ] = [ ]
| take n (x:xs)
| | n <= 0 = [ ]
| | otherwise = x : take (n – 1) xs
| drop _ [ ] = [ ]
| drop n (x:xs)
| | n <= 0 = (x:xs)
| | otherwise = x : drop (n – 1) xs
También se puede considerar como es posible filtrar una lista atendiendo a alguna propiedad de sus
elementos. Esto lo hace la función filter:
| filter _ [ ] = [ ]
| filter p (x:xs)
| | p x = x : (filter p xs)
| | otherwise = filter p xs
Otras dos funciones bastante comunes permiten coger o descartar los primeros elementos de una lista que
cumplan cierta propiedad. Estas son takeWhile y dropWhile y su funcionamiento es:
- Si la lista tiene un primer elemento, las funciones tendrán primero que comprobar si el elemento
cumple la propiedad para cogerlo o descartarlo.
| takeWhile _ [ ] = [ ]
| takeWhile p (x:xs)
| | p x = x : takeWhile p xs
| | otherwise = [ ]
| dropWhile _ [ ] = [ ]
| dropWhile p (x:xs)
| | p x = x : dropWhile p xs
| | otherwise = (x:xs)
Donde:
- cualificador: Define propiedades de los elementos y están separados por comas. Pueden ser de 3
tipos:
- filtros: Son expresiones booleanas utilizadas para filtrar aquellos elementos de un generador que
cumplen una determinada condición.
- Plegado de listas.
| función [ ] = caso_base
| función (x:xs) = combinar x (función xs)
Por lo tanto, es posible escribir una función de orden superior que aplique este esquema a una función
combinar y a un valor trivial caso_base dados. Esta función se denomina foldr y su definición es:
| foldr f z [ ] = z
| foldr f z (x:xs) = f x (foldr f z xs)
Esta función pliega una lista de derecha a izquierda, ya que el valor trivial se devuelve al ser aplicada a la
lista vacía. En algunas ocasiones es posible que se necesite plegar la lista del revés, de izquierda a derecha.
Para ello existe la función foldl, que define como:
| foldl f z [ ] = z
| foldl f z (x:xs) = foldl f (f z x) xs
El resultado devuelto por ambas funciones al aplicarse sobre los mismos parámetros de entrada coincidirá
siempre que se cumplan las siguientes condiciones:
- La función f es asociativa, por lo que será posible cambiar el orden de aplicación de los paréntesis.
- El valor z es un elemento neutro de f, por lo que el orden en el que sea evaluado no influye.
- La lista de entrada es finita.
- Su semántica declarativa permite al programador orientar sus trabajos hacia “cuál es el problema” y su
semántica procedimental permite utilizar características de control de la ejecución para conseguir mayor
eficiencia.
- Es un lenguaje de muy alto nivel ya que es un lenguaje de especificación lógica que es directamente
computable. Una comparación entre la programación declarativa y la programación imperativa es la siguiente:
Programación
Característica Programación declarativa
imperativa
Como resolver un
Idea básica Qué se conoce del problema
problema
Nivel de abstracción Limitado Elevado
Semántica Básica Bien formalizada
Variables Asignación Unificación
Variables locales Si Si, todas
Variables globales Si No, aunque simulables
Estructura de datos Arrays Listas
No en programación lógica (PL)
Tipado de datos Si
Si en programación funcional (PF)
Secuencial, Bifurcación Sin orden, Bifurcación por unificación,
Estructuras de control
Iteración, Recursión Recursión
Combinación con otros
Si Si
paradigmas
Si. En PL es el modelo de Horn.
Semántica declarativa Difícil de construir
En PF es el modelo inicial (algebra)
PL: SLD-resolución y unificación
Semántica operacional Menos difícil de aportar
PF: Reescritura y emparejamiento
Las características más importantes propias a Prolog que no aparecen en otros lenguajes son:
El proceso de paso de parámetros en Prolog es general, en dos direcciones y se basa en la unificación como operación
de comparación.
La gran ventaja que supone Prolog en la programación se debe a su semántica declarativa. Actualmente los entornos
Prolog disponibles ya incorporan mecanismos basados en la compilación o técnicas de paralelismo, para conseguir
una mayor eficiencia computacional, permitiendo la programación de problemas interesantes con una programación
declarativa y poco convencional.
La combinación de relaciones entre objetos y sus propiedades mediante los símbolos lógicos da lugar a las formulas
o clausulas.
Se llaman marco conceptual a la descripción de los símbolos no lógicos necesarios para la formulación de un
problema. Los elementos que aparecen en un problema se formulan mediante los términos definidos por:
Las propiedades y relaciones entre objetos se formulan con los literales, símbolo no lógico de predicado con n
argumentos que son términos. Hay tres tipos:
Ejemplo: Supóngase que se llega a un restaurante y se revisa la carta. En ella se encuentra la información de la
siguiente tabla:
Paella
Entrantes Gazpacho
Potaje
Besugo
Pescados
Bacalao
Filete
Carnes
Pollo
Tarta Helada
Postres Nueces con miel
Naranja
Para formular este conocimiento se utilizan los siguientes símbolos no lógicos que forman el marco conceptual:
- Constantes: Representan los objetos concretos y bien conocidos del dominio y son secuencias de letras sin el
carácter blanco y que comienzan por una letra minúscula.
| paella, gazpacho, potaje, besugo, bacalao, filete, pollo, tarta_helada, nueces_con_miel, naranja
- Predicados: Representan relaciones o propiedades que permiten clasificar y discriminar los diferentes tipos
de objetos del problema y deben comenzar con una letra minúscula y las variables con mayúscula.
A partir de la representación formal de los elementos y sus propiedades y relaciones, es necesario disponer de
estructuras de representación más complejas. Para ello se utilizan los símbolos lógicos del lenguaje que permiten
combinaciones de literales, denominadas reglas o Clausulas de Horn. Las reglas Prolog están formadas por una
cabeza, parte izquierda o conclusión de la regla, y un cuerpo, parte derecha o condiciones de la regla, separadas
ambas partes por el símbolo :- y acabando en un punto.
Q :- P1, P2, …, PN
en la siguiente:
También se denominan clausulas definidas, porque solo contienen un literal afirmado en su representación con
conectivas logicas. también puede asociarse un significado procedimental que se entiende por:
El objetivo Q se puede resolver si se puede resolver primero el objetivo P1 y a continuación el objetivo P2,
… y en último lugar el objetivo PN.
En cuanto al tipo de los argumentos objetivo, este se reconoce en el programa por su sintaxis:
Ejemplo: Siguiendo con el de la carta de un restaurante se formaliza el conocimiento relativo a los platos
ofrecidos y clasificados como se ha indicado, con las siguientes reglas Prolog.
% La carta
entrante (paella).
entrante (gazpacho).
entrante (potaje).
plato_de_carne (filete).
plato_de_carne (pollo).
plato_de_pescado (bacalao).
plato_de_pescado (besugo).
postre (tarta_helada).
postre (nueces_con_miel).
postre (naranja).
Además, se sabe que “un plato de carne es un plato fuerte” y “un plato de pescado es un plato fuerte” lo cual se
expresa mediante:
Cuya lectura es: “Para todo X si X es un plato de carne (o pescado) entonces X es un plato fuerte”.
A continuación, se define un menú como la composición de un entrante, un plato fuerte y un postre:
Lo cual debe ser entendido como “para cualquier tipo de valores X, Y, Z tal que el primero es un entrante, el
segundo un plato fuerte y el tercero es un postre, los tres forman un menu”.
% La carta
entrante (paella).
entrante (gazpacho).
entrante (potaje).
plato_de_carne (filete).
plato_de_carne (pollo).
plato_de_pescado (bacalao).
plato_de_pescado (besugo).
postre (tarta_helada).
postre (nueces_con_miel).
postre (naranja).
% Plato principal
Las variables que aparecen en las reglas se han de asumir cuantificadas universalmente, es decir, representan a
cualquier termino u objeto y no es necesario que se declaren previamente. Las variables que aparecen en una
regla Prolog son locales a esa regla, es decir, son independientes de las de otras reglas y por lo tanto pueden
representar elementos diferentes.
Además de la conectiva lógica de conjunción, también es posible utilizar la disyunción en la parte derecha de la
regla, para hacer más concisa la programación. La parte de condiciones puede contener entre paréntesis la
disyunción de dos o más literales u objetivos separados por ; . Asi la regla p :- q ; r. se lee “p es cierto si q o r lo
son”, representando por lo tanto la formula lógica q v r → p.
| menu (X, Y, Z) :- entrante (X), (plato_de_carne (Y) ; plato_de_pescado (Y)), postre (Z).
En Prolog la coma es más prioritaria que el punto y coma por lo que la regla:
| p :- q , r ; s.
La obtención o extracción de conocimiento expresados en el programa se realiza a partir de una consulta, que
es una secuencia de literales u objetivos acabada en un punto y comienza con el símbolo ?-. Supone la ejecución
o petición de prueba en el entorno Prolog de la existencia de objetos que verifican la conjunción de los objetivos
de la consulta. Una consulta se puede resolver si se puede resolver cada objetivo de la misma.
Cuando se hace una consulta, Prolog responderá con todos los valores posibles.
El significado declarativo de los programas determina cuando un literal es verdadero o para que valores es
verdadero. Como las variables de una regla se entienden cuantificadas universalmente, cualquier especificación
de una variable de una regla en un objeto del dominio del problema da lugar a una formula llamada instancia de
la regla. Por lo tanto, una instancia de una regla es la regla resultado de sustituir todas las apariciones de una
variable X por un término cualquiera t.
Las variables Prolog son logicas y representan un elemento u objeto genérico en vez de una posición de
memoria.
De esta forma, una consulta es cierta en un programa cuando existe un hecho en el programa que es una instancia
de la consulta, o cuando existe una instancia de una regla del programa cuya cabeza es sintácticamente idéntica
a la consulta y son ciertos cada uno de los literales de las condiciones de la instancia.
Y sea un caso concreto de familia la formada por el matrimonio Miguel y Estefanía que tienen dos hijos
Francisco y Gloria. Estos están casados con Teresa y Manuel respectivamente. Aránzazu y Bruno son hijos de
Teresa y Francisco, y Diego y Paula son hijos de Manuel y Gloria.
1. Descripción del Marco Conceptual: En esta fase hay que realizar lo siguiente:
- Dominio: {miguel, estefania, teresa, francisco, gloria, manuel, aranzazu, bruno, diego, paula}.
- Propiedades:
- Relaciones básicas:
- Afirmaciones.
- Formulas que representan la interacción entre literales: conclusión si conjunción de objetivos.
Ejemplo:
% Afirmaciones
hombre (miguel).
hombre (francisco).
hombre (manuel).
hombre (bruno).
hombre (diego).
mujer (estefania).
mujer (teresa).
mujer (gloria).
mujer (aranzazu).
mujer (paula).
% Relaciones básicas
% Relaciones causales
padres (X, Y, Z) :- hijo_ja (X, Z), hijo_ja (Y, Z), dif (X, Y).
padre (X, Y) :- hombre (X), padres (X, Z, Y).
madre (X, Y) :- mujer (X), padres (X, Z, Y).
abuelos (X, Y, Z) :- padres (U, V, Z), (padres (X, Y, U) ; padres (X, Y, V)).
abuelo (X, Y) :- hombre (X), abuelos (X, Z, Y).
abuela (X, Y) :- mujer (X), abuelos (X, Z, Y).
La programación recursiva es uno de los pilares de la programación Prolog, por lo que una definición recursiva
es una definición correcta.
1 𝑠𝑖 𝑛 = 0
𝑓𝑎𝑐𝑡(𝑛) = { }
𝑛 ∗ 𝑓𝑎𝑐𝑡(𝑛 − 1) 𝑠𝑖 𝑛 > 0
Toda función puede ser representada en términos predicativos utilizando un nuevo símbolo de predicado con
un argumento más que la función, en el que se representara el resultado. Asi, la función fact se puede representar
con el predicado factorial X, Y) que indica que el número Y es factorial del número X.
En Prolog, la aritmética disponible se expresa mediante el predicado predefinido is, con notación infija, esto es
Z is X Operado_con Y, de forma que Operado_con representa a una función aritmética.
La aritmética se puede entender también en forma predicativa, es decir, Prolog “conoce” las tablas de las
operaciones sobre los números.
Ejemplo: El de los planetas. En él se conoce que la distancia entre el sol y algunos planetas es la siguiente, cuya
información se formaliza con las reglas distancia (Planeta, Distancia).
Un conocimiento adicional es que un planeta puede tener vida si está al menos a 93 Mm del sol y tiene al menos
un satélite, lo que se formaliza en Prolog como:
| tiene_vida (Planeta) :- distancia (Planeta, D), D >= 93, satélite (Planeta, S), S > 0.
Responder una consulta en un programa es determinar si la consulta es una consecuencia lógica del programa.
La programación lógica pura, si tiene propiedades matemáticas como la corrección, completitud, transparencia y no
existencia de efectos colaterales. La resolución SLD que utiliza Prolog es completa y correcta teóricamente, pero en
la practica la introducción de algunos aspectos de control hacen necesario 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 que se obtienen todas las
soluciones de una consulta en su árbol de computación lineal.
4.2.1 Unificación
- Unificación simple.
Dos objetivos (átomos o literales positivos) se unifican si existe un unificador, formado por un conjunto de
pares <Variable, 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 estos son unificables uno a uno.
- Una variable se unifica con cualquier otro termino pasando a tomar ese valor.
- Una constante solo se unifica con cualquier otra constante idéntica sintácticamente.
- Dos términos compuestos se unifican si tienen igual símbolo de función, igual número de argumentos
y estos son unificables uno a uno.
- Algoritmo:
Se dice que una regla o clausula permite deducir un objetivo si existe una unificación que identifica este objetivo
con la conclusión de la regla o si existe una instancia de la regla del programa que infiere exactamente al
objetivo. Entonces el objetivo será resoluble si se pueden resolver cada una de las condiciones de la instancia
de la regla.
4.2.2 Algoritmo de resolución
- Selección…
- El modelo de computación recuerda todas las reglas que podrían haber sido seleccionadas en este
momento.
- Se aplica a la lista de objetivos resultante el unificador que hace posible unificar el objetivo resuelto
con la parte izquierda de la regla.
- Repetición: Se continua el proceso de selección hasta que se cumple una de las siguientes condiciones:
En ambos casos hay vuelta atrás al punto inmediatamente anterior en el que quedan reglas para seleccionar
y resolver el objeto.
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 permiten unificar su parte
izquierda con el primer objetivo de la lista. La lista que etiquetará a estos nodos será la del nodo antecesor,
en la que se ha sustituido el primer objetivo por la parte derecha de la regla correspondiente. Prolog
construye el árbol y recorre todas sus ramas de izquierda a derecha y cada vez que acabe de recorrer una
rama volverá al nodo anterior con una rama alternativa sin haber sido visitada aún.
3. A cada conexión entre nodos del árbol, se asocia una etiqueta con la identificación de la regla
seleccionada para resolver el primer objetivo del nodo anterior y la unificación realizada.
El espacio de búsqueda de soluciones es completo, es decir, en el aparecen todas las opciones a seleccionar en
cada momento del proceso y Prolog lo recorre en su totalidad, luego una consulta puede responder con varias
soluciones.
El control automático de la computación se realiza en los puntos de selección:
El control es interno y no modificable. El programador solo puede cambiar el orden de las reglas o el orden de
los objetivos en la parte derecha de las reglas, pero nunca en el momento de ejecución. Sin embargo, hay algunos
mecanismos no lógicos que permiten la variación de la forma de ejecución.
El predicado predefino corte (!) permite reducir el espacio de búsqueda de un objetivo haciendo más eficaz la
ejecución. Permite suprimir el número de elecciones en las fases de selección facilitando asi la recuperación de
la memoria o bien eliminando ramas infinitas o alternativas que se sabe que no llevan a ninguna solución.
El corte elimina todas las opciones de la rama en que se encuentra a partir del nodo anterior a su aparición. Con
este predicado, pues, es posible implementar, por ejemplo, como “obtener la primera solución de…”.
Otro punto en el que es muy útil la utilización del corte aparece cuando los problemas son de solución única, y
se sabe a priori, por lo que no hace falta buscar más soluciones una vez que se ha obtenido la primera de ellas.
Sin embargo, el corte puede producir problemas no esperados y es conveniente utilizarlo con cuidado, aunque
es una de las mejores formas de conseguir programas efectivos y eficientes, al eliminar opciones en la ejecución
que desde el principio son conocidas como inútiles.
El predicado corte también permite escribir el programa general que define la negación por defecto: algo no es
cierto cuando no es posible demostrarlo vía resolución Prolog. O lo que es lo mismo, “no (X) es falso cuando
es posible resolver “X” y cierto en caso contrario”.
call (X) es un predicado predefinido en algunas implementaciones que permite parametrizar la resolución Prolog
del objetivo X (la variable “X” ha de estar unificada con un objetivo en el momento de su ejecución, daría error
en otro caso).
fallo es un predicado al que nunca se define en el programa y, por lo tanto, siempre es falso lógicamente. En
algunas implementaciones ya se encuentra implementada la negación con el predicado not (X), e incluso el
predicado fallo como fail por su utilidad en la programación.
La lista se comporta como un término funcional especial ya que dispone de un procedimiento específico para
unificación entre listas.
Una lista de elementos u objetos se representa con símbolos de inicio y fin de lista [ y ] y todos sus elementos
separados por comas. Recursivamente se define una lista como:
Dos listas son unificables si es posible unificar sus primeros elementos (cabezas) y sus listas restantes (colas o
restos) son, a su vez, listas unificables. El tratamiento predefinido de las listas genéricas se realiza usando una
notación especial:
[Cabeza_lista | Cola_lista]
Ejemplo:
Según la definición recursiva de lista, acceder a los elementos de una lista es un proceso dividido en cómo
acceder al primer elemento y como acceder a los elementos del resto de la lista. Por tanto, el programa de cómo
acceder a un elemento es:
pertenece_a (X, [X | R]).
pertenece_a (X, [Y | R]) :- dif (X, Y), pertenece_a (X, R).
Otra operación útil entre listas es concatenar dos listas: dadas las listas L1 y L2 obtener la lista L3 que contenga
los elementos de L1 y L2 concatenados.
concatenar ([ ], L, L).
concatenar ([X | R1], L2, [X | R3]) :- concatenar (R1, L2, R3).
Otro problema relativo a las listas es como invertir una lista. Como este problema es de solución única, puede
optimizarse con el corte para evitar el desarrollo de nodos que no llevan a una solución en el espacio de
búsqueda. Para resolver este problema de forma distinta, se utiliza una lista auxiliar L2 donde se guardan la
parte de lista invertida hasta ese momento. Cuando se ha acabado de recorrer la lista, en L2 se tiene el resultado.
El programa completo es:
invertir ([ ], L, L).
invertir ([X | R], L2, L3) :- invertir (R, [X | L2], L3)
El indeterminismo depende del tipo de problema y de la forma de sus soluciones, y exige un análisis complejo
del programa y de sus refinamientos sucesivos. El primer aspecto a tener en cuenta es el relativo a la corrección
del programa.
También se ha de estudiar la forma de ejecución del programa, ya que la inferencia incorpora aspectos
procedimentales a la semántica declarativa del programa, al entender los programas como una secuencia
ordenada de reglas y cada parte derecha de una regla como una secuencia ordenada de objetivos, y por lo tanto
diferentes secuencias de objetivos pueden tener efectos beneficiosos o nocivos para la eficiencia e incluso para
la corrección del programa.
En general, en la relación a la poda de alternativas en el árbol o espacio de búsqueda, pueden identificarse tres
tipos de soluciones:
La eficiencia eliminando alternativas exige un primer análisis para el refinamiento del programa, comprobando
la correspondencia del programa con el conocimiento disponible del problema y su forma de uso y
posteriormente un análisis de la solución más adecuada.
Hay que tener en cuenta que la operación sobre los argumentos de los predicados u objetivos consiste en el uso
de términos funcionales extendidos lógicamente con la sintaxis especifica del entorno Prolog.
Las funciones en los argumentos de objetivos con un número determinado de elementos permiten aglutinar
entidades e incluso facilitan la metaprogramación. Un ejemplo del uso de términos funcionales es la solución
de Kowalski para el problema de los bloques, consiguiendo una elevada sintonía con la especificación
conceptual del problema en el que se describen entidades de diferente naturaleza.
Ejemplo: El problema de los bloques consiste en encontrar una planificación o secuencia de movimientos de un
brazo de robot en un escenario formado por tres bloques y tres posiciones en el escenario. Los bloques se pueden
apilar. El brazo del robot solo puede cambiar la posición de los bloques de uno en uno. Los datos del problema
consisten en la configuración inicial del escenario y la configuración final que se quiere alcanzar.
En el marco conceptual se identifican tres tipos de objetos o elementos: bloques, posiciones y estados:
- Un estado denota la situación de los bloques en el escenario con los predicados u objetivos en (X, Y) y
libre (X, Y), que indican que el objeto X esta sobre el Y, y que X no tiene nada encima.
- Se utiliza la función llevar (X, Y, Z) que representa el movimiento del robot de llevar X desde Y hasta Z.
- La función resultado (llevar (X, Y, Z), E), denota el estado resultante de llevar un bloque X de Y a Z, en
el estado E.
- Los meta-predicados necesarios son cierto (P, E), que indica que P ocurre en el estado E y el que indica
que un cierto estado E es posible, posible (E).
El marco conceptual anterior permite formular el problema de los bloques como sigue:
% Condición de invarianza
cierto (U, resultado (llevar (X, Y, Z), W)) :- cierto (U, W), dif (U, en (X, Y)), dif (U, libre (Z)).
posible (0).
posible (resultado (llevar (X, Y, Z), W)) :- posible (W), manipulable (X), dif (X, Z), cierto (libre (X), W),
cierto (libre (Z), W), cierto (en (X, Y), W).
manipulable (bloque_A).
manipulable (bloque_B).
manipulable (bloque_C).
La consulta es la descripción del estado final deseado, y que ha de ser posible de alcanzar desde el inicial.
?- cierto (en (bloque_A, bloque_B), W), cierto (en (bloque_B, bloque_C), W), cierto (en (bloque_C, pos3),
W), posible (W).
Las listas en Prolog están implementadas de forma muy eficiente, pero son exigentes en tiempo y memoria, por
lo que deben usarse solo cuando son necesarias: siempre que a priori no se conozca el número de elementos que
tendrá la lista de elementos en tiempo de ejecución.
En caso de que se conozca que el número de elementos de una lista va a ser fijo, es mejor utilizar un predicado
aglutinante. Debería utilizarse una función, pues de este modo se gastará menos memoria para el mismo número
de elementos y se dispondrá de un acceso a los elementos en tiempo constante.
4.3.3 Gestión dinámica
La memoria de trabajo es dinámica, es decir, puede ser modificada en tiempo de ejecución utilizando predicados
predefinidos, que en general aparecen para añadir una clausula (assert), eliminar clausulas (retract), listar las
cláusulas de memoria y otros.
Ejemplo: Para calcular los numero de Fibonacci sería útil recordar resultados intermedios o soluciones ya
encontradas o calculadas. En:
se realiza un número muy elevado de llamadas recursivas, pero si se programa guardando en la memoria de
trabajo los resultados intermedios:
entonces el programa “recuerda” los cálculos realizados para las sucesivas llamadas.
En términos de la programación lógica, se tiene una forma de iteración cuando no hay que recordar resultados
intermedios para finalmente componer el resultado por unificación. Esto ocurre cuando la llamada recursiva es
la última de la cláusula o regla y no hay alternativas pendientes anteriores a la llamada del objetivo a resolver.
En cualquier caso, retrasar la llamada recursiva, siempre que sea posible, es útil para evitar alternativas que no
llevan a soluciones.
4.3.5 Meta-programación
Se entiende como metaprograma, al programa que describe relaciones entre entidades del programa subyacente
o programa objeto. Un programa objeto podrá ser ejecutado directamente o ejecutado a partir de llamadas del
programa meta, por lo tanto, con control externo.
Para ello Prolog aporta un conjunto de predicados predefinidos que permiten acceder al estado de la
computación en un momento dado:
que almacena en una lista todos los valores, que, asignados a una variable, hacen cierto un objetivo.
En el caso de los identificadores, su longitud puede estar predefinida en algunos lenguajes, ser arbitraria o solo ser
significativos los primeros caracteres. Para evitar esta ambigüedad, se utiliza el principio de subcadena de mayor
longitud que determina que un token es la cadena más larga posible de caracteres hasta que haya un delimitador
(símbolo especial que indique sin ambigüedad su final).
La forma habitual de describir formalmente los tokens de un lenguaje es mediante expresiones regulares.
El objetivo de las expresiones regulares es representar a los lenguajes sobre un alfabeto , basándose en
lenguajes primitivos y operadores de composición. Dado un alfabeto , las expresiones regulares sobre él se
construyen mediante las siguientes reglas:
- (lenguaje vacío).
- (palabra vacía).
- a, con a .
- α + (union).
- α . o α (concatenación).
- α* (cierre).
- (α) (expresión regular entre paréntesis).
La precedencia, de mayor a menor, de los operadores es la siguiente:
1. () paréntesis.
2. * cierre.
3. . concatenación.
4. + union.
Sea exp una expresión regular sobre y sean α y dos expresiones regulares también sobre . El lenguaje
descrito por exp, denominado L (exp), se define recursivamente como:
Dos expresiones regulares son equivalentes si describen el mismo lenguaje. Se pueden establecer las siguientes
equivalencias y propiedades:
α + ( + ) = (α + ) + α* = + α . α*
α . ( . ) = (α . ) . * =
α+=+α * =
α . ( + ) = α . + α . α* α* = α*
(α + ) . = α . + . αα* = α*α
α.=αyα+=α (α*)* = *α
α.= (α* + *)* = (α**)* = (α + )* = (α*)*α*
Si L (α) entonces α + = α (α)*α = α (α)*
La clase de lenguajes que se pueden representar mediante una expresión regular es equivalente a la clase de
lenguajes regulares.
Las expresiones regulares son patrones que permiten presentar y encontrar combinaciones de caracteres dentro
de cadenas de texto. Están compuestas por una secuencia de caracteres que permiten especificar características
determinadas de los ejemplos del patrón.
Sim. Significado
\ Indica que el siguiente carácter es especial
\n Encuentra una nueva línea
\d Representa un digito del 0 al 9
\D Representa cualquier carácter que no sea un digito del 0 al 9
\w Cualquier carácter alfanumérico.
\W Cualquier carácter no alfanumérico
\s Representa un espacio en blanco
\S Representa cualquier carácter que no sea un espacio en blanco
\b Marca el inicio y el final de una palabra
^ Encuentra el comienzo de una línea
$ Posición de final de una cadena o una línea
* Encuentra 0 o más coincidencias del carácter que le precede
+ Encuentra 1 o más coincidencias del carácter que le precede
? Encuentra 0 o 1 coincidencias del carácter que le precede
. Encuentra una coincidencia de cualquier carácter salvo del principio de línea
| Indica varias opciones
(x) Encuentra coincidencias con x y recuerda el patrón para su uso posterior
{n} Encuentra coincidencia si el carácter que le precede aparece exactamente n veces
{n, } Encuentra coincidencia si el carácter que le precede aparece al menos n veces
{n, m} Encuentra coincidencia si el carácter que le precede aparece entre n y m veces
[xyz] Agrupa caracteres en grupos o en clases.
[n – m] Rango entre n y m
[^xyz] Encuentra coincidencias con caracteres que NO aparecen en el grupo
Las reglas están formadas por un único símbolo no terminal que forma la parte izquierda de la regla, a continuación,
el metasímbolo →, y finalmente la parte derecha de la regla. El lenguaje que define una gramática es el conjunto de
todas las cadenas de símbolos terminales para las cuales existe una derivación aplicado las reglas de la gramática.
El metasímbolo de disyunción | sirve para agrupar reglas con la misma parte izquierda.
Una derivación es una secuencia que comienza con el símbolo inicial y en la que en cada paso se sustituye un símbolo
no terminal de la cadena en curso por la parte derecha de una regla. El metasímbolo que indica cada paso de
derivación es =>.
En la notación BNF la representación de las reglas se hace con símbolos y metasímbolos que son:
Sim. Significado
: := Separación entre la parte izquierda y derecha de una regla
| Alternativa (solo se puede elegir uno de los elementos)
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, que puede definirse de forma independiente a la gramática o
revisándola añadiendo o modificando reglas.
Para diferenciar ( ) del metasímbolo de agrupación, la convención es encerrarlos entre comillas simples. La notación
EBNF no permite la recursión, es decir, un no terminar no puede aparecer al mismo tiempo en la parte izquierda y
derecha de una misma regla.
Para representar las reglas EBNF se usan los diagramas sintácticos en los que 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 valida debe
definir un cambio en el diagrama.
- Localizadores: Lugares donde se almacenan los valores y pueden entenderse como direcciones en la memoria
o de forma más abstracta.
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. Hay dos tipos de vínculos:
- Vínculos estáticos: Son aquellos que vinculan un atributo a un identificador en tiempo de traducción.
Los lenguajes funcionales tienen más vínculos dinámicos que los imperativos.
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 más corto de los que las reglas del lenguaje permiten al atributo
estar vinculado.
El traductor debe hacer posible que se mantenga la consistencia de los vínculos durante la traducción y durante la
ejecución. Para mantener esta información se utiliza una tabla de símbolos, en la que se almacena la forma de vincular
los atributos a los identificadores. La tabla de símbolos de un traductor se gestiona de forma diferente según se trate
de un intérprete o un compilador:
- Compilador: Solo puede procesar atributos estáticos, ya que la ejecución del programa es posterior a la
compilación.
- Interprete: Se procesan tanto los atributos estáticos como los dinámicos, por lo que se combinan la tabla de
símbolos y el ambiente. Luego en un intérprete, el ambiente produce y almacena la asignación de los
identificadores a sus atributos estáticos (labor de la tabla de símbolos) asi como a la localización de sus valores.
En el caso de C y C++ se distingue entre declaraciones que vinculan a todos los atributos potenciales, que se
denominan definiciones, o las que vinculan solo a algunos atributos, que son las declaraciones propiamente dichas.
Las declaraciones suelen estar asociadas a constructores específicos del lenguaje. A cada constructor especifico o
estándar, se le denomina bloque, que es una secuencia de declaraciones seguidas por una secuencia de sentencias y
rodeado por marcadores. 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.
Se pueden escribir declaraciones externas y 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.
En los lenguajes orientados a objetos, la clase es un constructor básico. En estos lenguajes a las funciones que no
devuelven valores se las denomina métodos.
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 especifican el alcance o ámbito.
Es posible referirse al alcance de una declaración siempre que el alcance de todas las ligaduras establecidas en dicha
declaración sea el mismo, pero debe evitarse el uso del concepto de alcance referido a los identificadores, porque un
mismo identificador puede estar involucrado en varias declaraciones diferentes.
En la mayoría de los lenguajes actuales estructurados en bloques, 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.
En 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 uso) con lo que el alcance de una declaración se extiende desde que dicha declaración
aparece hasta el final del bloque en el que se encuentra.
Otra característica de la estructura de bloques es que las declaraciones en los bloques anidados toman precedencia
sobre las declaraciones anteriores. La visibilidad de un vínculo es la region del programa en el que el vínculo es
aplicable. Es una subregión de la zona de alcance (si hay bloques anidados, pueden existir declaraciones que tapen
otras anteriores temporalmente, dentro del bloque anidado, lo que se denomina ocultamiento de alcance).
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.
Tras procesar el bloque anidado (línea 9) se restaura el vínculo de y, y se vuelve al estado de la primera figura. Una
vez terminado el proceso de p (línea 11) se restaura el vínculo de x, quedando la situación representada como:
Tras procesar las declaraciones de la función q (línea 15), la tabla de símbolos queda como:
Finalmente, la tabla de símbolos al procesar el main es:
Las tablas de símbolos también pueden procesar las declaraciones según aparecen durante la ejecución del programa,
lo que se conoce como regla de alcance dinámico.
La tabla de símbolos dinámica 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:
Al seguir con la ejecución, desde main se llama a q y hay que procesar su cuerpo. Tras procesar las declaraciones de
q (línea 13), la tabla de símbolos es:
Con la regla de alcance dinámico, 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 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.
- 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.
El alcance dinámico es una opción aceptable para los lenguajes muy dinámicos, los interpretados y en los que los
que los programas no suelen ser muy grandes como APL, Snobol y Perl.
La tabla de símbolos como una tabla única para todo el programa, con inserciones de un alcance a la entrada y
eliminaciones a la salida, es solo apropiada para lenguajes simples. Si el lenguaje permite definir estructuras más
complejas, como clases en Java o C++, o estructuras en C, entonces cada estructura tendrá asociada sui propia tabla
de símbolos. Se sigue utilizando la pila, pero para anidar las tablas, en lugar de los vínculos.
- 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.
En el ambiente también se utiliza una pila para vincular las localizaciones a las variables.
En la línea 7, a la salida del bloque B, el ambiente es el mismo que después de la entrada en A, ya que se han
eliminado las asignaciones de localizaciones del bloque B. Cuando el proceso entra en el bloque C (líneas 8 y 9),
asignan sus variables a las localizaciones de memoria liberadas por las variables del bloque B. Finalmente, a la
entrada del bloque D (líneas 11 y 12), el ambiente es:
La gestión de las localizaciones para las llamadas a subprogramas es más compleja pues debe resolverse
dinámicamente. Se hace mediante los registros de activación.
Se denomina objeto al área de almacenamiento asignada en el ambiente tras el procesamiento de una declaración.
Las constantes globales y los tipos de datos no son objetos, porque sus declaraciones no dan como resultado una
asignación de almacenamiento. Las variables y los parámetros si lo son.
El tiempo de vida o extensión de un objeto es la duración de su asignación en el ambiente. No necesariamente debe
coincidir con su accesibilidad (la variable x del bloque A se extiende a lo largo del bloque B, aunque en B no es
accesible) ni con su alcance (podría mantenerse la ligadura con la localización mas allá de su alcance en lenguajes
con ambientes totalmente dinámicos).
Cuando en un lenguaje de programación existen los punteros, cuyo valor almacenado es una referencia a un área de
almacenamiento asignada en el ambiente, es necesaria una extensión del ambiente.
Cada tipo de asignación (también llamado clase de almacenamiento) tiene su propia zona de memoria en el ambiente
Hay tres tipos de asignaciones:
- Asignación automática: Es la que se lleva a cabo con las variables y los parámetros.
- Asignación dinámica: Hay lenguajes que permiten al programador reservar memoria y por tanto realizar
vinculaciones de localización. Esto implica que el ambiente debe tener una zona de memoria específica para
permitirlo llamada montículo.
Algunos lenguajes permiten modificar la clase de almacenamiento, mediante palabras reservadas. Una variable static
en C, pasa a ser de asignación estática, en lugar de automática.
- Identificador.
- Localización.
- Valor.
- Otros atributos como el tipo de datos o el tamaño.
La variable puede cambiar de valor almacenado durante la ejecución a traces de la sentencia de asignación x = e,
donde x es un identificador de variable y e, una expresión que se evalúa a un cierto valor, que a su vez se copia en la
localización de x. Si la expresión e es a su vez una variable (caso de x = y), entonces la parte izquierda representa a
la localización de x y la parte derecha al valor contenido en la localización de y.
En algunos lenguajes, se usa la asignación por compartición, copiándose las localizaciones en vez de los valores.
Otra alternativa es la asignación por clonación, que consiste en utilizar una localización nueva, copiar en ella el valor
de y, y vincular a x con la nueva localización. En ambos casos, esta interpretación de la asignación se conoce por
semántica de puntero, para distinguirla de la más habitual, denominada semántica de almacenamiento.
Una constante es una entidad del lenguaje que tiene un valor fijo mientras existe en un programa. Se especifica
mediante un identificador, un valor y otros atributos, entre los que no se encuentra la localización. Las constantes
por tanto tienen semántica de valor.
Las constantes admiten la siguiente clasificación:
- Constantes estáticas: Son aquellas cuyo valor se puede calcular antes de la ejecución, que a su vez se clasifican
en constantes que:
- Constantes dinámicas: Son aquellas cuyo valor solo se puede computar en tiempo de ejecución.
Las constantes que se pueden calcular en tiempo de compilación no necesitan ocupar memoria y mejoran la eficiencia
de un programa.
- 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 que ofrece el lenguaje.
- Tipo de datos: Es el resultado de dotar a una estructura de datos de las operaciones definidas en el tipo abstracto de
datos.
Cualquier estructura del lenguaje susceptible de ser tipada suele estar acompañada de una declaración de tipo, que
es la sentencia por la que se define el tipo de una variable, contante, función, parámetro, … etc. Una vez declarado
el tipo, quedan fijadas las operaciones permitidas con ese elemento.
Gracias a las declaraciones de tipos de los diferentes elementos del programa, es posible realizar la llamada
verificación de tipos, que es el proceso por el cual el intérprete o compilador comprueba que los programas utilicen
de forma correcta los datos.
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 (tipificados). A
favor de este tipo de lenguajes hay varias razones:
Algunos lenguajes como C permiten que se realicen operaciones sobre variables que no pertenecen a su tipo abstracto
de datos, lo que dificulta la legibilidad de los programas. Este tipo lenguajes se conocen como lenguajes débilmente
tipados.
Otros lenguajes como Prolog no poseen ninguna información sobre los tipos, por lo que se admiten cualquier
operación entre tipos. Este tipo de lenguajes se conoce como lenguajes no tipados.
La inferencia de tipos es el proceso por el cual el intérprete o el compilar decide el tipo de una expresión, a la vista
de los tipos de los operandos y la semántica de los operadores utilizados.
Los lenguajes ofrecen 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. Esto se realiza mediante los constructores de tipos. Por ejemplo, el
constructor de arrays de Java:
- Si el nuevo tipo se usa directamente para declarar una variable, se habla de un tipo anónimo.
- Si se le da un nombre al nuevo tipo se habla de definición de tipo.
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.
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. Asi, los
lenguajes fuertemente tipados poseen un sistema de tipos estático (que puede ser aplicado en tiempo de compilación
sin que el programa se esté ejecutando) y garantiza que los programas peligrosos 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, pero este tipo de lenguajes también pueden rechazar los programas seguros.
Asi, los programas correctos son un subconjunto de los programas seguros. Por lo tanto, el principal reto de un
sistema de tipos estático es minimizar el conjunto de programas seguros incorrectos.
Un tipo de datos se considera atómico si no es posible separarlo en diferentes elementos más sencillo.
Salvo los números reales, los tipos atómicos son tipos discretos, ya que dado un valor es posible definir cuál es
su sucesor y/o su predecesor. Comparando entre Java y Haskell, los tipos atómicos son:
- Enteros.
Java ofrece los tipos byte (1 Byte), short (2 Bytes), int (4 Bytes) y long (8 Bytes) para almacenar
números enteros.
En Haskell hay dos tipos de enteros: Int (acotados por el compilador y la maquina) e Integer (sin
acotar).
Las operaciones que se definen son las aritméticas básicas: suma, resta, multiplicación, división entera
y modulo.
- Reales.
Tanto Java como Haskell ofrecen dos tipos de datos que almacenan reales, los cuales pueden ser:
Además de las operaciones aritméticas básicas, se suelen implementar la raíz cuadrada, el logaritmo
neperiano y la exponenciación.
- Caracteres.
En Java existe el tipo char, que esta almacenado en forma de un entero de dos bytes de longitud. Los
datos se almacenan siguiendo el estándar UNICODE. En Haskell está el tipo Char, que sigue el
estándar ASCII-7.
Las operaciones que suelen implementarse para trabajar con caracteres son:
- Booleanos.
En Java se denominan boolean, mientras que en Haskell Bool y permiten almacenar y operar con
valores lógicos.
Las operaciones entre booleanos son las comunes del Algebra de Boole.
- Tipos enumerados.
A pesar de ser tipos atómicos, son tipos que pueden ser definidos por el usuario. Se definen mediante
una enumeración de los elementos que los componen, junto a las operaciones que se pueden realizar
sobre dichos elementos. Por tanto, una de sus características más importantes es que el conjunto de
posibles valores de los datos ha de ser finito.
- Rangos.
Son tipos definidos por el usuario. Toman los valores 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.
Ni Java ni Haskell permiten de forma directa la creación de rangos, sino que se debe utilizar un tipo
de datos predefinido y controlar directamente que los datos no se salgan del rango seleccionado.
Pascal y Ada si permiten la definición de este tipo de datos.
- Arrays.
Representan estructuras matriciales de una o varias dimensiones, en las que es posible acceder
directamente a cualquiera de sus elementos sin más que indicar los índices del elemento deseado en
todas las dimensiones del array.
En Java, conjunto de índices ha de ser siempre un rango de enteros positivos que comienzan en 0. No
tienen tamaño prefijado, ya que el tamaño del array se define en el momento de su creación y se
conserva como una propiedad del mismo.
Para conocer el tamaño de un array en Java se usa la propiedad .length.
Los arrays no tienen por qué ser unidimensionales, sino que pueden tener cualquier número de
dimensiones.
En los lenguajes funcionales puros como Haskell, el concepto de array como tabla que permite un
acceso directo a cualquier de sus componentes son existe. En su lugar, se pueden utilizar listas para
simular arrays, aunque ello implica que los accesos a los componentes no se realizan en tiempo
constante.
- Funciones.
Se consideran tipos de datos en Haskell, no en Java. El tipo de una función viene dado por la tupla
formada por los tipos de los parámetros y el tipo de devolución.
- Producto cartesiano.
El producto cartesiano de dos conjuntos U y V se define como los pares ordenados (u, v) en los que
u U y v V.
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 Haskell es posible construir este tipo de datos de forma pura mediante el uso de tuplas.
En otros lenguajes como C o Pascal, este tipo de datos se construye mediante registros.
En lenguajes orientados a objetos como Java, es posible crear tipos producto cartesiano mediante el
uso de clases.
- Tipos recursivos.
Son aquellos en el que el tipo que está siendo definido aparece en su propia definición.
En Java, los tipos recursivos deben implementarse mediante clases, mientras que en Haskell es posible
realizarlo de una forma más sencilla en una línea de código.
- Union de tipos.
No es posible clasificar una union de tipos como un tipo atómico o estructurado sin tener en cuenta
los tipos que intervienen. Si uno de ellos es estructurado, entonces el tipo resultante también lo será,
mientras que, si todos los tipos son atómicos, el resultado será atómico.
Las uniones pueden ser discriminadas si cada elemento del tipo debe ser identificado con el tipo
concreto al que pertenece o indiscriminadas en caso contrario.
En lenguaje Java no permite la creación de tipos mediante la union de otros, aunque existe un
mecanismo similar a la union discriminada, aunque mucho más potente, que es la herencia de clases
propia de los lenguajes orientados a objetos.
La equivalencia estructural decide que dos tipos son equivalentes si se han construido exactamente de la misma
forma, es decir, aplicando los mismos constructores de datos a los mismos tipos sobre los que se han construido.
La forma de comprobar la equivalencia estructural consiste en sustituir en la declaración de una variable el nombre
del tipo por su definición.
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 esta la conversión explicita (forzada) en la que la conversión de tipos se delega en el 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)
Mientras que la conversión implícita puede dar lugar a comportamientos inesperados, la conversión explicita 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 solo permite este tipo de conversiones de un tipo a otro que lo
extienda.
Aquellos operadores que solo pueden aplicarse a un único operando se denominan operadores unarios, mientras los
que se aplican a dos operandos se denominan binarios.
Las funciones reciben una serie de parámetros de entrada y devuelven un único valor de salida.
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 se conoce como notación infija.
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).
También puede escribirse en notación postfija si los dos operandos a los que se aplica cada operador preceden
a dicho operador (6 2 4 * +)
Las notaciones prefija y postfija 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 utilizar paréntesis.
La notación postfija se puede realizar mediante una maquina pila, en la que los operandos se van apilando, los
operadores desapilan los operandos necesarios y apilan el resultado.
Los lenguajes ofrecen una serie de operadores predefinidos que han de escribirse mediante notación infija,
mientras que las funciones se escriben en notación prefija.
Antes de definir las distintas evaluaciones hay que tener claros dos conceptos:
- Efecto colateral: Al invocar una función, se producen modificaciones en variables que no son pasados
como argumentos.
- Transparencia referencial: La misma invocación a una función con los mismos valores en los argumentos
siempre produce el mismo resultado. Este principio se cumple si no existen efectos colaterales.
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 como evaluación impaciente o estricta. 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 los parámetros es indiferente. Sin embargo, si hay efectos colaterales, el orden sí que tiene
importancia.
En contraposición a la evaluación estricta esta la evaluación no estricta o diferida, en la que la evaluación de los
parámetros de una función se difiere hasta que dicha evaluación sea necesaria.
Que se cumpla la transparencia referencial 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 impaciente de una misma expresión han
de devolver el mismo resultado.
Si, además de la evaluación normal, 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, la evaluación pasa a denominarse evaluación
perezosa.
La evaluación en cortocircuito es aplicable en algunos operadores, implica que no todos los operandos son
evaluados, solo los estrictamente necesarios, como AND (u OR), que se sabe si es cierta o falsa sin tener que
evaluar todos lo operandos, ya que si un lado es falso (verdadero) no hace falta evaluar el otro. Los operadores
que no apliquen este, pudiendo hacerlo, son conocidos como operadores ansiosos.
Su semántica es la siguiente:
- Tanto s1 como s2 pueden ser una única sentencia o una secuencia de sentencias.
- En primer lugar, se evalúa la expresión e, que ha de ser booleana.
- Si el valor de la expresión es verdades se ejecutará s1.
- Si el valor de la expresión es falso y está presente la parte opcional, se ejecutará s2. Si no está presente,
no se hace nada.
En muchas ocasiones, cuando se anidan dos sentencias if-then-else se presenta un problema denominado el
problema del else antiguo. Algunos lenguajes como C o Pascal optan por aplicar la llamada regla del
anidamiento más cercano como criterio para eliminar ambigüedades, que establece que un else se debe asociar
al if más cercano.
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 bloqueo de otras sentencias que tengan un terminador explícito.
Por tanto:
se trata de una sentencia if-then sin else en la que se ejecuta una sentencia if-then-else si la expresión es cierta.
La forma de forzar que se interprete de la otra forma seria encerrar la sentencia if-then-else sin parte else dentro
de un bloque:
Se usa una expresión de algún tipo discreto (entero, carácter), y los bloques de instrucciones se asocian a los
distintos valores de la expresión.
switch (e) {
case valor1: s1; break;
case valor2: s2; break;
case valor3a:
case valor3b:
case valor3c: s3; break;
default sd; break;
}
Esta construcción es azúcar sintáctico pues se puede crear una construcción similar utilizando sentencias if-then
else:
if (e = = valor1) s1
else if (e = = valor2) s2
else if (e = = valor3a || e = = valor3b || e = = valor 3c) s3
else sd;
8.3 BUCLES
Permiten la ejecución repetida de un bloque de instrucciones, mientras una expresión lógica se evalúa a cierto.
La forma más comúnmente ofrecida por los lenguajes de programación es el bucle while y su forma general en Java
es:
while (e) s
do s while (e)
Otra versión muy común de bucle son los bucles for de Java, cuya sintaxis es:
que es equivalente a:
si;
while (e) {
s;
sa;
}
donde:
- si es la sentencia de inicialización.
- e es la condición de terminación del bucle.
- sa es la sentencia de actualización del bucle.
- s es el cuerpo del bucle.
Y la semántica es:
Algunos lenguajes ofrecen la posibilidad de interrumpir la ejecución del cuerpo del bucle dentro del interior del
mismo utilizando la sentencia break, pero complica la semántica de los bucles y otros lenguajes como Pascal no lo
permiten.
8.4 EXCEPCIONES
Son instrucciones de control no explicitas (al contrario que los bucles que si lo son), pues su cometido es controlar
aquellos eventos que tengan lugar durante la ejecución del programa que violen las restricciones semánticas del
lenguaje. Asi, cuando ocurre una excepción, se dice que esta se lanza y en ese momento una parte del código del
programa llamada manejador de excepciones entrara 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.
- Excepciones asíncronas: Pueden suceder en cualquier punto de la ejecución del programa con independencia
del código del mismo.
- Excepciones síncronas: Errores que pueden surgir como respuesta a la ejecución de los programas.
Si un lenguaje incluye manejadores de excepciones, la tarea de prever todas las posibles excepciones en todos los
posibles puntos donde estas pueden surgir se simplifica y pasa por considerar los siguientes puntos:
- Excepciones comprobadas: Representan un error del cual el programa puede recuperarse. Se denominan
comprobadas porque el compilador de Java comprueba si existe un manejador que las trate. Si el
programador quiere definir las suyas propias, las hace heredar de la clase Exception.
Los manejadores de excepciones de Java se realizan mediante bloques try-catch-finally. Las sentencias que
puedan provocar una excepción deben ser encerradas dentro de un bloque try {…} al que le deben seguir tantos
bloques catch (excepción) {…} como excepciones diferentes se quieran capturar. Por último, se puede incluir
un bloque finally {…} que encerrara una serie de sentencias que se ejecutaran, en cualquier caso, incluso si no
se produce ninguna excepción.
Si una excepción comprobada se puede lanzar en un método y no es capturada en dicho método, debe indicarse
en la cabecera del método con throws. Se podría añadir esta sentencia a la cabecera del main, en cuyo caso la
excepción abortaría la ejecución del programa.
Las sentencias en el bloque try posteriores al lanzamiento de la ejecución no se ejecutarán. Las sentencias catch
se evalúan por orden, y se aplican las reglas de tipo-subtipo de las relaciones de herencia.
Si tras finalizar la ejecución se devuelve un valor se denominan funciones y su invocación es una expresión y no una
sentencia. Si no se devuelve un valor se suelen denominar procedimientos (Pascal) o métodos (Java) y su invocación
es una sentencia. Una función no debería producir ningún efecto colateral, mientras que un procedimiento si podría
hacerlo.
Muchos lenguajes no hacen distinción explicita en este sentido entre funciones y procedimientos y permiten la
creación de funciones que producen efectos colaterales.
- Interfaz: Contiene la definición del identificador del subprograma y la lista de parámetros de entrada y el tipo
de valor devuelto (si el subprograma devuelve uno).
- Cuerpo: Consiste en el bloque de sentencias que se ejecutarán cuando el subprograma sea invocado.
En Java no existe una gran diferencia entre declarar métodos o funciones, solo en el tipo del valor devuelto, que en
el caso de los métodos es void y no requieren de un return. 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 y
Function para la creación de funciones. En los lenguajes funcionales solo se permite la declaración de funciones que
siempre han de devolver un resultado.
9.1.1 Semántica
El significado de los identificadores 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.
Los subprogramas pueden contener identificadores locales, por lo que necesitan que se asigne memoria para
ellos. Esta memoria se denomina registro de activación y se dice que el subprograma está activado si se está
ejecutando bajo las pautas dictadas por su registro de activación.
Cuando se invoca un subprograma, se activa su registro de activación. Cuando hay varios subprogramas activos,
hay varios registros de activación en memoria, uno por cada subprograma de ejecución. Para resolver referencias
no locales, los registros de activación suelen tener un enlace al ambiente local.
1 static int x;
2 static void incrementa () {
x++;
4}
5 static void suma (int i) {
incrementa ();
int z = x + y;
System.out.println (z);
9}
10 public static void main (String [] args) {
x = 1;
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) es:
En la línea 12 se invoca al 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 es:
Al comenzar el método incrementa, ya que este no añade ningún identificador adicional, se añadiría un registro
de activación que no incluyen nuevos identificadores.
Dado que incrementa se llama desde suma, la activación de incrementa debe guardar información sobre la
activación de suma, para que al terminar la ejecución de incrementa (línea 4) se devuelva el control de la
ejecución a la siguiente instrucción tras la llamada (línea 7).
Sin embargo, 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.
Bajo la regla del alcance léxico 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.
Asi, las referencias no locales se refieren siempre al ambiente de definición, por lo que un subprograma nunca
podrá comunicarse con su ambiente de invocación. Esto se resuelve mediante el paso de parámetros.
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 a los
segundos, parámetros actuales.
Es el mecanismo más utilizado. Las expresiones utilizadas al definir los parámetros actuales son evaluadas
previamente a 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.
Este mecanismo requiere que los parámetros actuales sean variables que tengan asignada una dirección
dentro de la memoria. Asi, 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.
- Paso por resultado, por copia-restauración o por copia de entrada y salida.
Este mecanismo requiere que los parámetros actuales sean variables. En el cuerpo del programa 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.
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 solo realizar la restauración del valor final. Esta variante se conoce
con el nombre de paso por resultado.
La ventaja que ofrece este mecanismo es que, si un parámetro del subprograma no se utiliza, no se pierde
tiempo en realizar su evaluación, pero si se utiliza varias veces, deberá ser evaluado en múltiples ocasiones.
Una forma de solucionar esto es recurrir a la memoización (proceso de almacenar el valor de una expresión
la primera vez que se evalúa) para evitar repetir cálculos ya realizados. El paso de parámetros por nombre
más la memoización dan lugar al paso por necesidad.
Este mecanismo es el usado para el paso de parámetros en los lenguajes puramente funcionales.
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, por tanto, el registro de activación de un subprograma será siempre el mismo.
Asi, cada registro de activación y el ambiente de un programa en Fortran77 con diferentes subprogramas tendrán
la siguiente estructura:
La forma de resolver la recursión es estructurar los registros de activación en una pila de registros de activación.
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.
Sin embargo, también es necesario almacenar la información sobre donde 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
(ep).
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 se conoce como enlace de control.
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 como enlace de acceso.
Asi, 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. 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.
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 solucionar este problema es no eliminar del ambiente el registro de activación de un subprograma
mientras existan referencias a objetos definidos localmente en él.
Los ambientes dinámicos 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.