Parte II Javier Ibá˜nez (Despacho D

Anuncio
Ingenierı́a de Requerimientos (IDR)
(prácticas)
Facultad de Informática - 4A
El lenguaje de programación Visual Prolog
Parte II
Javier Ibáñez (Despacho D-109)
Germán Vidal (Despacho D-242)
Edificio DSIC
Curso 2005/2006
Parte V
Datos simples y compuestos
1
1.- Tipos de datos simples
Un objeto simple es una variable o bien una constante, entendiendo por “constante”:
un carácter (char), un número (entero o real), o una tira de caracteres (symbol o
string).
1.1. Variables
Las variables deben comenzar por una letra mayúscula o por subrayado ( ). Una
variable anónima se representa con un sı́mbolo de subrayado y se interpreta como “no
importa su valor”. Las variables se pueden instanciar a cualquier argumento o dato
legal. Las distintas ocurrencias de la variable anónima se pueden instanciar a datos
diferentes, incluso dentro de una misma cláusula o átomo.
Las variables en Prolog son locales, es decir, si dos cláusulas contienen una misma
variable X, estas variables se consideran distintas. Por supuesto, ambas pueden convertirse en la misma variable debido al mecanismo de unificación, pero en general no
tienen porqué tener ninguna relación.
1.2. Constantes
Las constantes incluyen números, caracteres y tiras de caracteres. Atención, no las
confundáis con las constantes simbólicas definidas en la sección constants. Aquı́, el
valor de una constante es su nombre. Es decir, el valor de la constante 2 es 2, y el valor
de la constante pedro es pedro.
Caracteres
Los caracteres son de tipo char. Los caracteres se escriben entre comillas simples:
’a’
’*’
’{’
’3’
’A’
Si queremos usar el carácter \ o la comilla simple, lo escribiremos ası́: ’\\’, ’\’’.
Disponemos también de una serie de caracteres que realizan funciones especiales (cuando se preceden por el carácter de escape):
’\n’
’\r’
’\t’
Newline
Return
Tab (horizontal)
Los caracteres se pueden escribir también usando el carácter de escape, seguido del
número ASCII del carácter (por ejemplo, ’\65’).
Números
Los números permitidos son enteros (ver la tabla de dominios enteros en la Parte
III) o reales (dominio real).
2
Tiras de caracteres
Pueden ser de tipo symbol o string. La diferencia entre ambos es más bien una
cuestión de implementación (tal como se comentó en la Parte III).
Visual Prolog realiza una conversión automática entre datos del dominio symbol
y datos del dominio string. En principio, la sintaxis para cada tipo es la siguiente:
symbol: nombres que comiencen por un carácter en minúscula y que contengan
sólo letras (minúsculas o mayúsculas) y dı́gitos;
string: entre comillas dobles y pueden contener cualquier cosa.
Aquı́ podéis ver algunos ejemplos:
symbol
jesseJames
a
pdc Prolog
string
"Jesse James"
"a"
"Visual Prolog, by PDC"
Dado que ambos tipos son intercambiables, la distinción no resulta importante. Sin
embargo, cosas tales como los nombres de predicado y los functores para los tipos de
datos compuestos, deben seguir obligatoriamente la sintaxis para symbol.
2.- Tipos de datos compuestos
Los datos compuestos nos permiten tratar varios “bloques” de información como
un objeto único. Por ejemplo, la fecha “2 de Abril de 1988” consta de tres partes (dı́a,
mes y año), pero a menudo resulta útil tratarla como un objeto único:
fecha
aaa
aa
aa
a
2
Abril
Podemos hacer esto declarando un dominio:
1988
domains
fecha = fecha(unsigned,string,unsigned)
y entonces escribir simplemente:
..., D = fecha(2, "Abril", 1988), ...
Fijaos en que tiene el mismo aspecto que un hecho Prolog, pero se trata en realidad
de un objeto compuesto de datos, que podemos manejar como si fuera un entero o una
cadena de caracteres. Los datos compuestos comienzan por un nombre, usualmente
llamado functor, seguidos por sus argumentos entre paréntesis.
Es importante destacar que, aunque un functor de Prolog es comparable a una
función en otros lenguajes, los functores no tienen asociada una interpretación. Es
decir, dado un objetivo:
3
write(suma(3,2))
aparecerá por pantalla suma(3,2) y, en ningún caso, 5.
Los argumentos de un dato compuesto pueden ser, a su vez, datos compuestos. Por
ejemplo, la información sobre el cumpleaños de una persona se puede representar con
una estructura como ésta:
cumplea~
nos
PPP
PP
PP
PP
persona
ZZ
Z
Z
Pepe
Perez
fecha
anac
aaa
aa
aa
14
Abril
1960
En Prolog, escribimos esta estructura ası́:
cumplea~
nos(persona("Pepe", "Perez"), fecha(14, .Abril", 1960))
2.1. Unificación de datos compuestos
Un dato compuesto puede unificar con una variable, o bien con otro dato compuesto
(que contenga posiblemente variables como argumentos). Esto signifca que podemos
usar datos compuestos para pasar varios datos a la vez como un dato único, usando la
unificación para descomponerlo. Por ejemplo,
fecha(2, "Abril", 1988)
unifica con una variable X, instanciando la variable X a fecha(2, .Abril", 1988). Por
otro lado,
fecha(2, "Abril", 1988)
también unifica con fecha(D, M, A), instanciando D a 2, M a Abril y A a 1988.
Uso del sı́mbolo = para unificar datos compuestos
Visual Prolog realiza unificación en dos situaciones: (1) cuando un subobjetivo se
resuelve contra la cabeza de una cláusula, y (2) cuando aparece el sı́mbolo ‘=’ en un
subobjetivo (‘=’ es un predicado predefinido en Prolog, con la particularidad de que se
escribe en notación infija).
Ante un objetivo de la forma izq = der , Prolog lo resuelve realizando la unificación entre izq y der. Podéis encontrar un ejemplo que usa el predicado ‘=’ para
realizar comparaciones entre dos datos compuestos en el programa ch05e01.pro.
4
2.2. Agrupando datos simples para formar datos compuestos
Los datos compuestos se pueden considerar y tratar en Prolog como si fueran un
dato simple, lo que simplifica bastante la programación. Por ejemplo, el hecho:
tiene(juan, libro("De aqui a la eternidad", "James Jones")).
establece que Juan tiene el libro “De aquı́ a la eternidad”, escrito por James Jones. De
la misma forma, podrı́amos escribir el hecho:
tiene(juan, perro(toby)).
que se podrı́a interpretar como “Juan tiene un perro llamado Toby”. En estos ejemplos
hemos usado datos compuestos, que son:
libro("De aqui a la eternidad", "James Jones")
y
perro(toby)
Sin embargo, si hubieramos escrito (usando datos simples):
tiene(juan, "De aqui a la eternidad").
tiene(juan, toby).
no serı́amos capaces de decidir si toby es el nombre de un libro o el nombre de un perro.
Ası́, podemos usar el functor de un dato compuesto para distinguir entre distintos tipos
de datos (en este caso libro y perro).
En resumen, los datos compuestos tienen siempre la forma:
functor(dato1, dato2, ..., datoN)
donde dato1, . . . , datoN pueden ser datos simples o bien datos compuestos.
Un ejemplo del uso de datos compuestos
Como hemos comentado, una de las ventajas del uso de datos compuestos es que
nos permite pasar un conjunto de datos simples como un sólo argumento. Supongamos
que queremos mantener un directorio de teléfonos con las fechas de nacimiento de la
gente (para recordar su fecha de cumpleaños). De entrada, podrı́amos usar algo como
esto:
predicates
phone_list(symbol, symbol, symbol, symbol, integer, integer)
/*
(First , Last , Phone , Month ,
Day ,
Year )
clauses
phone_list(ed, willis, "422-0208", aug, 3, 1955).
phone_list(chris, grahm, "433-9906", may, 12, 1962).
5
Si examinamos los 6 argumentos de phone list, parece más adecuado agrupar 5 de
dichos argumentos ası́:
person
birthday
HH
HH
HH
!P
!! E PPPP
!
PP
E
!!
PP
!!
E
First name
Month
Last name
Day
Year
De esta forma, reescribimos el fragmento de programa anterior como sigue:
domains
name = person(symbol,symbol)
birthday = b_date(symbol,integer,integer)
ph_num = symbol
/* (First, Last) */
/* (Month, Day, Year) */
/* Phone_number */
predicates
phone_list(name,symbol,birthday)
clauses
phone_list(person(ed, willis), "422-0208", b_date(aug, 3, 1955)).
phone_list(person(chris, grahm), "433-9906", b_date(may, 12, 1962)).
Ahora el predicado phone list sólo tiene 3 argumentos, lo que hace el programa
más legible y fácil de usar.
Supongamos ahora que queremos generar una lista de personas cuyo cumpleaños
sea en el mes actual. El programa ch05e03.pro muestra cómo se puede hacer. Cargad
el programa y ejecutarlo. Fijaos en el uso del predicado predefinido date, que nos devuelve el año, mes y dı́a del reloj del sistema. Podéis encontrar información sobre los
predicados predefinidos en la Parte IX.
Ejercicio. Modificad el programa para que también liste las fechas de nacimiento de la
gente y sus números de teléfono.
2.3. Declaración de dominios para datos compuestos
Tal como vimos anteriormente, podemos definir en un programa las cláusulas:
tiene(juan, libro("De aqui a la eternidad", "James Jones")).
tiene(juan, perro(toby)).
y realizar consultas del tipo:
tiene(juan, X).
La variable X se podrá instanciar a distintos tipos de datos: un libro, un perro, o
cualquier otra cosa más que definamos. Ası́, una declaración del tipo:
domains
tiene(symbol, symbol)
6
ya no es válida, puesto que el segundo argumento debe ser un dato compuesto. La
declaración correcta serı́a:
domains
articulos = libro(titulo, autor); perro(nombre)
titulo, autor, nombre = symbol
El punto y coma (‘;’) en la declaración de articulos se lee “o”, es decir, los articulos
pueden ser libros o perros. En el programa ch05e04.pro podéis ver un ejemplo más
completo del uso de datos compuestos.
Resumiendo, los datos compuestos se declaran ası́:
dominio = alternativa1(D, D, ...);
alternativa2(D, D, ...);
...
donde alternativa1, alternativa2, etc, son functores arbitrarios (pero diferentes).
La notación (D, D, ...) representa una secuencia de nombres de dominios, que pueden
ser estándar o bien estar declarados en algún otro sitio. Cuando una de las alternativas sea un functor sin argumentos, podemos escribir tanto functor como functor(),
ambas opciones son válidas.
Es importante destacar que los functores deben seguir la sintaxis que hemos dado
para los datos simples de tipo symbol. Por ejemplo, una declaración del tipo:
domains
num_natural = 0 ; succ(num_natural)
para disponer de un dominio que represente los números naturales (usando la notación
del sucesor), no es válida, ya que el número 0 no es un functor válido. La forma
correcta de declararlo es:
domains
num_natural = cero ; succ(num_natural)
Datos compuestos “multi-nivel”
Visual Prolog permite construir datos compuestos con varios niveles. Por ejemplo,
en:
libro("El patito feo", "Andersen")
en lugar de usar el apellido del autor como segundo argumento, podemos construir una
nueva estructura que describa al autor con más detalle:
libro("El patito feo", autor("Hans Christian", "Andersen"))
De esta forma, la declaración que tenı́amos antes:
7
domains
articulos = libro(titulo, autor); perro(nombre)
titulo, autor, nombre = symbol
se convierte ahora en:
domains
articulos = libro(titulo, autor); perro(nombre)
autor = autor(nombre_autor, apellido_autor)
titulo, nombre_autor, apellido_autor, nombre = symbol
A menudo resulta más claro representar los distintos niveles de un objeto compuesto
con un árbol:
libro
b
b
b
b
b
titulo
autor
!!HH
HH
!!
!
HH
!
!
!
H
nombre autor
apellido autor
Sin embargo, hay que tener en cuenta que en una declaración de dominios sólo se puede
describir un nivel cada vez. Es decir, una declaración como esta:
articulo = libro(titulo, autor(nombre_autor, apellido_autor))
en la que hay functores anidados, es incorrecta.
2.4. Declaración de dominios mixtos
En este último punto, vamos a ver cómo declarar dominios de forma que podamos
usar predicados:
1.
con un argumento de varios tipos posibles,
2.
con un número indeterminado de argumentos, cada uno de un tipo especı́fico, y
3.
con un número indeterminado de argumentos, alguno de los cuales puede tener
varios tipos posibles.
Argumentos de tipos múltiples
Para permitir que un predicado acepte argumentos de varios tipos distintos, debemos añadir un functor a cada posibilidad. Por ejemplo, dado el programa:
8
domains
edad = i(integer); r(real); s(string)
predicates
tu_edad(edad)
clauses
tu_edad(i(Edad)) :- write(Edad).
tu_edad(r(Edad)) :- write(Edad).
tu_edad(s(Edad)) :- write(Edad).
tenemos un procedimiento tu edad que acepta como argumento un valor entero, real
o un string. Fijaos en que la presencia del functor para las distintas alternativas es
necesaria. Es decir, una declaración como esta:
domains
edad = integer; real; string
no es válida en Visual Prolog.
Listas
Supongamos que queremos almacenar las asignaturas que debe impartir cada profesor. En principio, podrı́amos generar el siguiente código:
predicates
profesor(symbol, symbol, symbol)
/*
(nombre, apellido, asig) */
clauses
profesor(juan, perez, matematicas)
profesor(juan, perez, fisica)
profesor(juan, perez, algebra)
profesor(ana, alonso, historia)
profesor(ana, alonso, quimica)
En este ejemplo, tenemos que repetir el nombre del profesor por cada asignatura que
imparte. Si tuvieramos un volumen más grande de asignaturas, la tarea serı́a realmente
costosa. En esta situación, resulta útil disponer de una declaración que nos permita
asignar un número indeterminado de argumentos. Esto se puede conseguir con el uso
de listas. En la siguiente versión del código anterior, introducimos un nuevo argumento
asignatura que es del tipo lista:
domains
asignatura = symbol*
predicates
profesor(symbol, symbol, asignatura)
9
clauses
profesor(juan, perez, [matematicas, fisica, algebra])
profesor(ana, alonso, [historia, quimica])
En esta versión el código resulta más compacto y legible. En la declaración:
asignatura = symbol*
le estamos diciendo a Prolog que el tipo asignatura estará compuesto por una lista de
elementos del tipo symbol. Por ejemplo, si queremos declarar un dominio que consista
en una lista de números enteros, lo harı́amos ası́:
lista_enteros = integer*
Podéis ver el uso de listas con más detalle en la Parte VII.
10
Parte VI
Repetición y recursión
11
Una buena parte de la utilidad de los ordenadores consiste en que son capaces de realizar un mismo proceso una y otra vez. Prolog puede expresar repetición tanto a nivel
de procedimientos como de estructuras de datos. La idea de una estructura de datos
recursiva puede parecer extraña, pero en Prolog se usa de forma generalizada cuando
el tamaño definitivo de la estructura no es conocido en el momento de su definición
(estructuras dinámicas). En esta parte, presentamos primero los procedimientos repetitivos (iteración y recursión), y después abordamos las estructuras de datos recursivas.
1.- Procesos repetitivos
A primera vista puede parecer extraño que Prolog no disponga de intrucciones del
estilo de FOR, WHILE o REPEAT, lo que significa que no hay una forma directa de expresar la iteración. Sin embargo, es perfectamente posible implementar procedimientos
repetitivos usando backtracking y recursión.
Es importante destacar que esta ausencia no disminuye en absoluto la potencia
expresiva del lenguaje. De hecho, Prolog reconoce un tipo especial de recursión, llamado
tail-recursion, que se compila a código máquina exactamente igual que un bucle iterativo
(consiguiendo ası́ la misma eficiencia que un bucle de Pascal o C). Por otro lado,
la recursión es, en muchos casos, la forma más clara y natural de expresar procesos
repetitivos.
1.1. Backtracking
El mecanismo de backtracking nos permite buscar soluciones alternativas para un
subobjetivo. Concretamente, un paso de backtracking consiste en reconsiderar el último
punto de la computación en el que disponı́amos de más de una alternativa para resolver
un subobjetivo (es decir, éste unificaba con más de una cláusula del programa), eligiendo a continuación la primera de las alternativas aún no consideradas y continuando
la computación de la forma usual. El siguiente ejemplo nos muestra como explotar el
backtracking para realizar procesos repetitivos.
Cargad el programa ch06e01.pro:
PREDICATES
nondeterm country(symbol)
print_countries
CLAUSES
country("England").
country("France").
country("Germany").
country("Denmark").
print_countries :12
country(X),
write(X),
nl,
fail.
print_countries.
/* write the value of X */
/* start a new line */
GOAL
print_countries.
El predicado country simplemente lista los nombres de varios paı́ses, de forma que un
objetivo como:
country(X).
tiene múltiples soluciones. El predicado print countries se encarga de recoger todas
las soluciones e imprimirlas por pantalla. Su definición es como sigue:
print_countries :country(X), write(X), nl, fail.
print_countries.
La primera cláusula de print countries dice:
“Para imprimir los paı́ses, debemos encontrar una solución a country(X),
imprimir X, hacer un salto de lı́nea y provocar un fallo.”
En este caso, “provocar un fallo” significa:
“realizar un paso de backtracking, para buscar una alternativa a country(X).”
El predicado predefinido fail siempre falla (y provoca el backtracking), pero podrı́amos
conseguir el mismo efecto escribiendo un subobjetivo como 2 = 3.
El efecto de la definición de print countries consiste, por tanto, en:
1.
obtener la primera solución para country(X) (instanciando X a "England"),
2.
escribir England por pantalla,
3.
hacer un salto de lı́nea,
4.
desinstanciar la variable X (debido al backtracking provocado por fail), y
5.
repetir el proceso anterior mientras existan más alternativas para country(X).
De esta forma, al ejecutar el objetivo, aparece por pantalla:
England
France
Germany
Denmark
yes
13
Si no incluyésemos la segunda cláusula para print countries, la única diferencia serı́a
que el objetivo terminarı́a con fallo (después de encontrar e imprimir todos los paı́ses),
mostrando por pantalla:
England
France
Germany
Denmark
no
Ejercicio. Modifica el programa ch06e01.pro de manera que el predicado country tenga
dos argumentos: nombre y poblacion. Modifica después los hechos para que se ajusten
al nuevo formato, asignando poblaciones entre 5 y 15 millones a cada paı́s. Finalmente,
modifica print countries para que sólo imprima los paı́ses con más de 10 millones de
habitantes.
Pre- y post-procesos
En general, un programa que compute toda las soluciones a un objetivo, requerirá realizar alguna acción extra antes y después. Por ejemplo, nuestro programa podrı́a:
1.
imprimir primero “Algunos paises del mundo son:”,
2.
imprimir después todas las soluciones a country(X), e
3.
imprimir finalmente “y pueden haber mas”.
En este momento, la primera cláusula de print countries realiza el paso (2) y, además,
podemos modificar fácilmente la segunda cláusula para que realice el paso (3). Concretamente, podemos redefinirla ası́:
print_countries :- write("y pueden haber mas"), nl.
¿Y respecto al paso (1)? Bastarı́a con añadir una cláusula más al principio, quedando
la definición completa como sigue:
print_countries :write("Algunos paises del mundo son:"),
nl, fail.
print_countries :country(X),
write(X),
nl, fail.
print_countries :write("y pueden haber mas"), nl.
14
Fijaos en que el fail en la primera cláusula es importante ya que nos asegura que,
tras ejecutar dicha cláusula, el backtracking nos llevará a ejecutar la segunda cláusula.
Sin embargo, algún programador avispado podrı́a pensar que no son necesarias las
tres cláusulas, y escribir el programa anterior de esta forma:
new_print_countries :write("Algunos paises del mundo son:"),
nl,
print_countries,
write("y pueden haber mas"),
nl.
print_countries :country(X),
write(X),
nl, fail.
En principio, puede parecer que no hay ningun error, y que ante un objetivo de la
forma:
new_print_countries.
el programa funcionará correctamente. Sin embargo, no es ası́!
Ejercicio. Averiguar por qué funciona mal el programa anterior y resolverlo.
1.2. Implementando el backtracking con bucles
El backtracking es una buena forma de conseguir todas las soluciones alternativas
para un objetivo. Además, incluso aunque un objetivo no tenga soluciones alternativas,
aún es posible usar backtracking para introducir repetición. Para ello, basta con definir
el siguiente predicado:
repeat.
repeat :- repeat.
Este procedimiento sirve para “engañar” al control de Prolog, haciéndole pensar que
existe un número infinito de soluciones. Es decir, nos permite introducir un número infinito de pasos de backtracking. Podemos ver un ejemplo en el programa ch06e02.pro:
PREDICATES
nondeterm repeat
nondeterm typewriter
CLAUSES
repeat.
repeat :- repeat.
15
typewriter :repeat,
readchar(C),
write(C),
C = ’\r’.
/* Leer un caracter y asignarlo a C */
/* Es un CR? Si no, fallo */
GOAL
typewriter,nl.
El procedimiento typewriter funciona de la siguiente forma:
1.
Ejecuta repeat (que no hace nada).
2.
Lee un carácter y se lo asigna a la variable C.
3.
Escribe el valor de C.
4.
Comprueba si el valor de C es un retorno de carro.
5.
Si lo es, el programa termina. Si no, hace backtracking en busca de alternativas.
Dado que ni write, ni readchar generan soluciones alternativas, llegamos hasta
el repeat (desinstanciando la C), quien siempre tiene soluciones alternativas (ver
su definición).
6.
Ahora el proceso vuelve a comenzar, leyendo otro carácter, imprimiendólo y verificando si es un retorno de carro.
Notad que la desinstanciación de la variable C al realizar el backtracking resulta fundamental para el buen funcionamiento del ejemplo anterior. En general:
Todas las variables pierden sus valores cuando la ejecución realiza backtracking más allá del paso en el que dichas variables tomaron sus valores.
Desgraciadamente, esto significa que no es posible “recordar” nada de una iteración a
la siguiente, lo que impide hacer uso de un contador o cualquier otro registro sobre el
progreso de las iteraciones. En la siguiente sección veremos cómo resolver este problema.
1.3. Procedimientos recursivos
En Visual Prolog la recursión resulta el mecanismo más natural de expresar procesos repetitivos y, además, sı́ permite el uso de contadores (o cualquier otro resultado
intermedio). Por ejemplo, el procedimiento para calcular el factorial de un número se
puede expresar ası́:
Para encontrar el factorial de un numero N:
- Si N es 1, el factorial es 1.
- Si no, encontrar el factorial de N-1 y multiplicarlo por N.
16
En Prolog, podemos expresarlo mediante dos cláusulas:
factorial(1,1) :- !.
factorial(N, FactN) :M = N-1,
factorial(M, FactM),
FactN = N*FactM.
Como funciona la recursión internamente
Como en cualquier otro lenguaje, las sucesivas llamadas recursivas a un mismo
procedimiento se tratan como si fueran llamadas a procedimientos distintos. Es decir,
los argumentos y las variables internas del procedimiento son locales a cada llamada.
Esta información se almacena en un “entorno” de activación de procedimiento en el
área de memoria llamada STACK. Con cada nueva llamada recursiva, se crea un nuevo
entorno que contiene las variables locales del procedimiento invocado. Cada vez que la
ejecución de una regla termina, el entorno es eliminado del STACK (excepto que existan
soluciones alternativas pendientes).
Fijaos en que esto significa que un procedimiento recursivo puede tener, en principio,
un coste espacial muy superior al de un procedimiento iterativo equivalente! Pese a
todo, en el siguiente punto veremos que existe un tipo especial de recursión, cuyo coste
asociado es equivalente al de una iteración.
Además, no debemos olvidar que los procedimientos recursivos nos permiten expresar fácilmente algoritmos cuya versión iterativa puede dar lugar a algoritmos mucho
más complejos (por ejemplo, el caso de las torres de Hanoi ). En general, la recursión
es la forma más apropiada de describir un problema que contiene otro problema del
mismo tipo en su definición.
1.4. Optimización de última llamada (LCO)
Como ya hemos comentado, la recursión plantea un problema: consume mucha
memoria. Cada vez que un procedimiento llama a otro (sea o no el mismo), debe salvarse el estado del procedimiento actual en un entorno del STACK, de forma que su
ejecución pueda continuar al terminar la llamada. Esto significa que, si un procedimiento recursivo se llama a si mismo 100 veces, debemos almacenar en el STACK 100
entornos de activación de procedimiento. . .
¿Cómo se puede evitar este uso excesivo de memoria? La solución pasa por considerar lo siguiente: si el procedimiento que realiza la llamada no debe realizar ninguna otra
acción después de dicha llamada, no es necesario almacenar su estado en el STACK!
Por ejemplo, supongamos que tenemos un procedimiento procA que llame a un
procedimiento procB, de forma que, a su vez, procB llame a procC en el último paso.
Es decir, tenemos una situación como ésta:
procC :- ...
17
procB :- ..., procC.
procA :- ..., procB, ...
Cuando comienza la ejecución de procA y llegamos a la ejecución de la llamada a
procB, se almacena el estado del procedimiento procA en el STACK y se pasa a ejecutar
procB. Ahora, procB comienza su ejecución y se encuentra una llamada a procC, con
lo que debe salvar su estado en el STACK y pasar a ejecutar procC. La optimización que
planteamos consiste en lo siguiente: no hace falta almacenar el estado del procedimiento procB ya que, al terminar la ejecución de procC, debe seguir ejecutando
procA (y no procB), puesto que procB ya habı́a terminado.
Consideremos una situación ligeramente distinta: tenemos ahora un procedimiento
recursivo que, como última acción, realiza una llamada a sı́ mismo:
proc :- ..., proc.
En este caso, como hemos visto arriba, no es necesario almacenar el estado de proc
en el STACK, ya que la llamada aparece en último lugar. Es decir, una llamada a proc
procede a ejecutar el cuerpo del procedimiento y luego realiza la llamada recursiva,
la cual simplemente repite el mismo proceso, sin almacenarse en ningun momento el
estado de proc en el STACK. ¿Qué hemos conseguido? Un procedimiento recursivo que
se comporta como un procedimiento iterativo!
Este tipo de recursión se conoce como recursión de cola (“tail-recursion”) y la
optimización que hemos descrito (es decir, no almacenar en el STACK el entorno del
procedimiento con este tipo de recursión) se conoce como “optimización de última
llamada” (last call optimization, LCO).
Uso de la recursión de cola
Hemos dicho que para aplicar la optimización LCO, el procedimiento debe realizar la
llamada recursiva como última acción de su definición. ¿Qué significa esto exactamente
en Prolog? Significa que debe cumplirse que:
1.
la llamada aparece como último subobjetivo en el cuerpo de la cláusula, y que
2.
no hay puntos de backtracking previos pendientes.
Aquı́ podemos ver un ejemplo que cumple las dos condiciones:
contar(N) :write(N), nl,
NewN = N+1,
contar(NewN).
Este procedimiento puede ejecutarse con un objetivo de la forma:
contar(0).
18
lo que producirá que se escriba una secuencia infinita de números naturales por pantalla,
sin agotar nunca la memoria (este ejemplo se encuentra en el programa ch06e04.pro).
Ejercicio. Prueba a modificar el ejemplo anterior, de forma que la ejecución se aborte
debido a que se agota la memoria del STACK. (Nota: sólo funcionará en plataformas de
16 bits.)
Uso erroneo de la recursión de cola
Cargad el programa ch06e05.pro. En él se muestran distintas formas de implementar de forma errónea la recursión de cola.
1.
Cuando la llamada no es realmente la última acción del procedimiento:
badcount1(X) :write(’\r’,X),
NewX = X+1,
badcount1(NewX),
nl.
Aquı́, con cada llamada recursiva sı́ se debe salvar el estado del procedimiento,
ya que al terminar la llamada aún quedará pendiente de ejecutar nl.
2.
Otra forma de perder la recursión de cola consiste en que existan alternativas
pendientes en el momento de la llamada. Por ejemplo,
badcount2(X) :write(’\r’,X),
NewX = X+1,
badcount2(NewX).
badcount2(X) :X < 0,
write("X is negative.").
Aquı́, la llamada recursiva se encuentra al final del cuerpo de la primera cláusula.
Sin embargo, en el momento de la llamada aún está pendiente de ejecutar la
segunda cláusula, lo que obliga a salvar el estado del procedimiento.
3.
De forma similar al caso anterior, puede ocurrir que hayan soluciones alternativas
incluso aunque no sean para el propio procedimiento recursivo. Por ejemplo,
badcount3(X) :write(’\r’,X),
NewX = X+1,
19
check(NewX),
badcount3(NewX).
check(Z) :- Z >= 0.
check(Z) :- Z < 0.
Supongamos que X es positivo. Entonces, en el momento de la llamada recursiva a
badcount3(NewX), la primera cláusula de check se ha ejecutado con éxito, pero la
segunda alternativa está aún pendiente. Al igual que antes, no tenemos recursión
de cola y es necesario almacenar el estado del procedimiento.
Recuperando la recursión de cola mediante cortes
En este momento, puede parecer que es realmente difı́cil garantizar que un procedimiento cumpla las condiciones de la recursión de cola. Por un lado, resulta sencillo
escribir la llamada recursiva al final de la última cláusula del procedimiento. Pero,
¿cómo podemos asegurarnos de que no hayan quedado alternativas pendientes?
La forma más sencilla consiste en emplear el corte. Por ejemplo, el procedimiento
badcount3 que veı́amos, se podrı́a escribir ası́:
cutcount3(X) :write(’\r’,X),
NewX = X+1,
check(NewX),
!,
cutcount3(NewX).
(dejando check como estaba). Con esto conseguimos que, justo antes de ejecutar la
llamada recursiva cutcount3(NewX), todas las alternativas pendientes para cutcount3
(si las hay) sean eliminadas. Esto es justamente lo que necesitabamos. Ahora el procedimiento cumple las condiciones de la recursión de cola, con lo que no será necesario
almacenar el estado del procedimiento y la recursión se comportará como una iteración,
sin consumir espacio del STACK.
De forma similar, podemos usar el corte para evitar el problema de badcount2.
Ahora, tendrı́amos esto:
cutcount2(X) :X >= 0, !,
write(’\r’,X),
NewX = X+1,
cutcount2(NewX).
cutcount2(X) :write("X is negative.").
20
Hemos movido el test X <0 de la segunda cláusula a la primera. Ahora, si se cumple
X >= 0, el corte elimina la posibilidad de hacer backtracking a la segunda cláusula,
con lo que la llamada recursiva a cutcount2(NewX) es realmente la última llamada del
procedimiento. Si el test no se cumple, se ejecuta la segunda cláusula (no hace falta
comprobar que X <0, porque la única forma de llegar a la segunda cláusula es que el
test X >= 0 falle) y el procedimiento termina.
Fijaos en que, en ocasiones, no resulta tan simple conseguir un procedimiento con
recursión de cola mediante el uso de cortes. Por ejemplo, el procedimiento badcount1
no puede arreglarse introduciendo un corte. La única posibilidad serı́a modificar la
computación para que la llamada recursiva estuviera al final.
1.5. Bucles y contadores
Vamos a ver ahora como implementar un programa recursivo que se comporte como
un bucle con contadores. Para ello, veremos de forma intuitiva cómo se traduce un
programa Pascal a Prolog. Partimos del siguiente programa en estilo Pascal:
P := 1;
FOR I := 1 TO N DO P := P*I;
FactN := P;
Este fragmento de código calcula (en FactN) el valor del factorial de N. En primer lugar,
debemos escribirlo con un bucle WHILE, de forma que quede patente qué operaciones
estamos haciendo sobre las variables:
P := 1;
I := 1;
WHILE I <= N DO
begin
P := P*I;
I := I+1
end;
FactN := P;
A partir de este bucle, tenemos que obtener su versión recursiva (recordad que en
Prolog es la forma natural de expresar procedimientos repetitivos):
factorial(N, FactN);
begin
factorial_aux(N, FactN, 1, 1)
end;
factorial_aux(N, FactN, I, P);
begin
IF I <= N THEN
begin
21
P := P*I;
I := I+1;
factorial_aux(N, FactN, I, P)
end;
ELSE
FactN := P
end;
Fijaos en la necesidad de introducir una función auxiliar con dos argumentos extra para
poder pasarle los valores iniciales de las variables P e I. Ahora, el programa equivalente
en Prolog resulta muy sencillo de generar:
factorial(N, FactN) :factorial_aux(N, FactN, 1, 1).
factorial_aux(N, FactN, I, P) :I <= N, !,
NewP = P*I,
NewI = I+1,
factorial_aux(N, FactN, NewI, NewP).
factorial_aux(N, FactN, I, P) :FactN = P.
Observad que en Prolog no es posible escribir algo de la forma:
I = I+1
ya que, desde el punto de vista lógico, un número I nunca puede ser igual a I+1. Por
ello, usamos una variable auxiliar NewI, y escribimos NewI = I+1.
Podéis encontrar una versión algo más compacta del ejemplo anterior en el programa ch06e08.pro.
Ejercicio. Escribir un programa con recursión de cola que imprima una tabla con las 10
primeras potencias de 2:
22
N
--1
2
3
...
10
2^N
----2
4
8
...
1024
2.- Estructuras de datos recursivas
A diferencia de los lenguajes imperativos, Prolog permite la definición de estructuras
de datos recursivas. Por estructura de datos recursiva entendemos una estructura de
datos que contiene como argumentos estructuras de su mismo tipo.
La estructura de datos recursiva más habitual en Prolog es la lista. Dada la importancia que las listas tienen en Prolog, éstas se ven con detalle en la Parte VII.
Ahora vamos a definir una estructura recursiva que represente un árbol. La recursividad aparece porque un árbol (binario) se puede ver como formado por un elemento
raı́z, un subárbol izquierdo y un subárbol derecho, de manera que cada subárbol es a
su vez una estructura de tipo árbol.
2.1. El tipo de datos árbol
Pese a que la idea de los tipos de datos recursivos la introdujo Niklaus Wirth en el libro “Algoritmos + Estructuras de datos = Programas”, éstos no fueron implementados
en Pascal. De haber existido, podrı́amos definir un árbol de esta forma:
arbol = record
raiz: string[80];
izq, der: arbol
end;
/* Pascal incorrecto! */
Sin embargo, la única aproximación para una estructura de este tipo en Pascal consiste
en utilizar punteros:
arbolptr = ^arbol;
arbol = record
raiz: string[80];
izq, der: arbolptr
end;
Notad que existe una diferencia sutil: este código maneja la representación en memoria
de un árbol, y no la propia estructura árbol. Es decir, vemos un árbol como formado
por celdas de memoria, cada una conteniendo un dato y un puntero a otras celdas.
En Visual Prolog, por el contrario, sı́ que es posible definir auténticos tipos de datos
recursivos. Por ejemplo, podemos definir la estructura árbol simplemente como sigue:
23
domains
tipoarbol = arbol(string, tipoarbol, tipoarbol)
Con esta declaración establecemos que el tipo árbol está compuesto por un functor
arbol, cuyos argumentos son: un string (la raı́z) y dos estructuras del mismo tipo
árbol.
Sin embargo, esto no es aún correcto. Tal como lo hemos definido, un árbol serı́a
siempre una estructura infinita. Para que puedan existir árboles finitos, es necesario
permitir que en algún momento los argumentos no sean a su vez árboles. Para ello,
introducimos el functor vacio para denotar un árbol vacı́o, y modificamos la declaración
como sigue:
domains
tipoarbol = arbol(string, tipoarbol, tipoarbol) ; vacio
Por ejemplo, el siguiente árbol:
Ana
PP
PP
PP
PP
P
Miguel
Carmen
bb
b
b
b
ZZ
Z
Z
Carlos
Juan
Mabel
Elena
se representa mediante la estructura Prolog:
arbol("Ana",
arbol("Miguel",
arbol("Carlos", vacio, vacio),
arbol("Mabel", vacio, vacio)),
arbol("Carmen",
arbol("Juan", vacio, vacio),
arbol("Elena", vacio, vacio)))
Tened en cuenta dos cosas: (1) el indentado no es necesario, lo escribimos ası́ por
legibilidad, y (2) la estructura no es una cláusula de Prolog, es sólo un dato complejo
(que se podrá usar como argumento de un predicado).
Recorrido de un árbol
Antes de abordar el tema de cómo crear árboles, vamos a ver primero qué se puede
hacer con un árbol ya creado. Una de las operaciones más habituales consiste en recorrer
todos los nodos del árbol, realizando algún proceso con cada nodo. El algoritmo básico
para realizar este recorrido es:
1.
Si el árbol está vacio, no hacer nada.
24
2.
En otro caso, procesar el nodo actual, recorrer el subárbol izquierdo y recorrer el
subárbol derecho.
Al igual que la estructura árbol, el algoritmo es recursivo. En Prolog se puede implementar ası́:
recorrer(vacio).
recorrer(arbol(Raiz, Izq, Der)) :procesar(Raiz),
recorrer(Izq),
recorrer(Der).
añadiendo la definición adecuada para procesar. Concretamente, el algoritmo realiza
un recorrido en profundidad del árbol. La siguiente numeración nos indica el orden del
recorrido para el árbol anterior:
Ana (1)
XXX
XXX
XXX
XXX
Miguel (2)
Carmen (5)
HHH
HH
H
bb
bb
bb
Carlos (3)
Mabel (4)
Juan (6)
Elena (7)
Notad que la forma en que se recorre el árbol es la misma en que Prolog recorre un árbol
de búsqueda mediante backtracking. En el programa ch06e09.pro podéis encontrar un
ejemplo que imprime por pantalla el valor de todos los nodos del árbol, usando el
algoritmo que hemos visto. Por supuesto, podéis modificar fácilmente el programa para
que realice algún proceso más complejo que la simple impresión de los nodos.
Creación de un árbol
En primer lugar, la forma más directa de crear un árbol consiste en escribir la
estructura donde sea necesaria, tal y como hemos hecho en el punto anterior. Sin
embargo, a menudo resulta útil poder construir la estructura en tiempo de ejecución.
Para ello, el método consiste en crear inicialmente un primer árbol conteniendo un sólo
nodo, y cuyos subárboles izquierdo y derecho estén vacı́os. Este simple hecho:
crear_arbol(N, arbol(N, vacio, vacio)).
nos permite crear un nuevo árbol con un sólo nodo N en la raı́z. Por ejemplo, la ejecución
del subobjetivo:
crear_arbol("Ana", Arbol).
25
usando el hecho anterior, tiene el efecto de instanciar la variable Arbol a la estructura
arbol(.Ana", vacio, vacio).
De forma similar, podemos definir procedimientos que se encarguen de insertar un
nuevo subárbol izquierdo, o bien un nuevo subárbol derecho:
insertar_izq(Izq, arbol(Raiz,_,Der), arbol(Raiz,Izq,Der)).
insertar_der(Der, arbol(Raiz,Izq,_), arbol(Raiz,Izq,Der)).
Haciendo uso de las tres cláusulas crear arbol, insertar izq e insertar der resulta
sencillo construir una estructura de árbol paso a paso en tiempo de ejecución. En el
programa ch06e10.pro podéis ver un ejemplo concreto.
2.2. Árboles de búsqueda binarios
Una de las principales aplicaciones de los árboles consiste en almacenar datos en
ellos, de forma que luego la búsqueda de un determinado elemento resulte muy eficiente.
En general, para buscar un elemento en un árbol con N nodos, debemos recorrerlos
todos (en el peor de los casos), con lo que tendrı́amos un algoritmo de búsqueda cuyo
coste es de orden N.
Cuando se usan árboles binarios (ordenados), la búsqueda se puede realizar de forma
mucho más eficiente, ya que cada vez que se alcanza un determinado nodo, solamente
es necesario inspeccionar uno de los subárboles. Por ejemplo, consideremos el siguiente
árbol binario (ordenado alfabéticamente):
Gracia
P
PP
PP
PP
PP
P
Beatriz
Sara
PPP
PP
PP
PP
bb
b
b
b
Antonio
Cesar
Tadeo
Mauricio
Q
Q
Q
Q
Q
Laura
Olga
Ramon
Pese a que el árbol tiene 10 nodos, nunca es necesario inspeccionar más de 5 nodos para
encontrar cualquier elemento del árbol (concretamente, en el peor de los casos hay que
inspeccionar tantos nodos como niveles de profundidad tenga el árbol). En general, si
un árbol binario con N nodos está equilibrado, el coste de búscar un elemento es del
orden de log2 N .
Por supuesto, ya que ahora necesitamos que los elementos del árbol estén ordenados,
los procedimientos del punto anterior para la creación de un árbol no son adecuados.
26
El procedimiento para la inserción de un nuevo elemento en un árbol binario ordenado
es:
insert(NewItem, vacio, arbol(NewItem, vacio, vacio)) :-!.
insert(NewItem, arbol(Raiz,Izq,Der), arbol(Raiz,NewIzq,Der)) :NewItem < Raiz,
!,
insert(NewItem, Izq, NewIzq).
insert(NewItem, arbol(Raiz,Izq,Der), arbol(Raiz,Izq,NewDer)) :insert(NewItem, Der, NewDer).
Ordenación de árboles
Una vez que hemos construido un árbol binario ordenado, podemos recorrer sus
elementos en orden mediante este sencillo programa:
recorrer_todo(vacio).
recorrer_todo(arbol(Raiz, Izq, Der)) :recorrer_todo(Izq),
procesar(Raiz),
recorer_todo(Der).
De esta forma, dada una secuencia de N elementos, podemos ordenarla a base de
insertar todos los elementos en un árbol binario (ordenado), y después recorrer todo el
árbol con el procedimiento anterior. De esta forma, tenemos un algoritmo de ordenación
cuyo coste es N log2 N (no existen algoritmos de ordenación más eficientes!).
En el programa ch06e11.pro podéis encontrar un ejemplo completo de manejo de
árboles (declaración, creación e impresión) para ordenar alfabéticamente una secuencia
de caracteres.
En el programa ch06e12.pro tenéis una versión algo más compleja del mismo
ejemplo (toma como entrada un fichero). Su ejecución es unas 5 veces más eficiente que
el programa SORT.EXE que proporciona el sistema DOS (aunque ligeramente menor que
el sort optimizado de UNIX).
Observad que en estos dos últimos ejemplos aparecen algunos predicados predefinidos que no hemos introducido aún (openread, writedevice, etc.). Podéis consultar la Parte IX para encontrar más información sobre ellos.
Ejercicio. Usar estructuras de datos recursivas para implementar hipertexto. El hipertexto consiste básicamente en que alguna de las palabras de un texto pueden contener
una referencia a un nuevo texto, el cual, a su vez, puede contener palabras que lleven
asociadas nuevamente referencias a otro texto, etc. Para simplificarlo, podemos considerar únicamente strings que llevan asociados un único string (conteniendo posiblemente
una nueva referencia). Es decir, partimos de una declaración como esta:
27
domains
link = vacio; entrada(string, link)
Definid ahora un pequeño hipertexto como este:
entrada("Prolog es un lenguaje de programacion...",
entrada("Prolog: Lenguaje de programacion declarativo...",
entrada("Declarativo: ...", vacio).
y realizar un programa que, dada esta estructura, muestre el primer string por pantalla
y espere a que se pulse Enter, muestre el segundo string y espere de nuevo a que se
pulse Enter, y ası́ hasta alcanzar el fin de la estructura, momento en el cual mostrará el
mensaje "No hay mas informacion".
28
Parte VII
Listas y recursión
29
El objetivo de esta parte es introducir el procesamiento de listas en Prolog. Concretamente, veremos cómo declarar listas, algunos ejemplos de su uso, y la definición de los
tı́picos predicados de Prolog member y append. Por último, se introduce el predicado
predefinido findall, que nos permitirá recoger todas las soluciones para un objetivo
dado.
1.- ¿Qué es una lista?
Una lista es una estructura que contiene un número indeterminado de objetos. Se
corresponde básicamente con los vectores de otros lenguajes, pero no requiere que se
conozca el número máximo de elementos de antemano. Usando estructuras recursivas
también es posible definir estructuras dinámicas, pero a menudo resulta más sencillo el
uso de listas.
Una lista que contenga los elementos 1, 2 y 3 se escribe como:
[ 1, 2, 3 ]
Los elementos de la lista se separan por comas y aparecen encerrados entre corchetes.
Algunos ejemplos más son:
[ ana, pepe, juan]
[ "Ana Perez", "Juan Garcia"]
1.1. Declaración de listas
Para declarar un tipo lista enteros que consista de una lista de números enteros,
escribimos:
domains
lista_enteros = integer*
Los elementos de una lista pueden ser a su vez listas (dando lugar a algo similar a los
vectores multidimensionales de Pascal). La única restricción es que todos los elementos
de la lista deben ser del mismo tipo. Por ejemplo, esta declaración:
domains
matriz = lista_enteros*
lista_enteros = integer*
es perfectamente válida, y sirve para disponer de un tipo matriz que consiste de una
lista de listas de enteros (una matriz de enteros). Sin embargo, esta declaración:
domains
lista_mixta = integer* ; symbol*
no es válida, incluso aunque la escribamos ası́:
30
lista_mixta = elemento*
elemento = integer ; symbol
(ahora el problema estarı́a en la declaración de elemento, que ya vimos en la Parte V
que no era válida). La forma correcta de declarar una lista que pueda contener números
enteros y secuencias de caracteres es:
domains
lista_mixta = elemento*
elemento = i(integer); s(symbol)
Una lista de este tipo podrı́a ser esta: [i(3), s(pepe), i(6)].
Cabeza y cola
La forma habitual de entender las listas en Prolog consiste en verlas como un operador binario, cuyo primer argumento es la cabeza de la lista, y cuyo segundo argumento
es la cola de la lista. Es decir, dada una lista:
[ a, b, c, d ]
decimos que a es la cabeza de la lista, y [b, c, d] es la cola. Este proceso se puede
repetir, de manera que la lista [b, c, d] se descompone a su vez en cabeza (b) y
cola ([c, d]), y ası́ hasta llegar a la lista vacı́a ([ ]). Podemos representarla con una
estructura de árbol como sigue:
lista
!
!! T
!
!
T
!
!!
T
a
lista
TT
T
b
lista
TT
T
c
lista
T
T
T
d
[ ]
De hecho, incluso una lista conteniendo un sólo elemento [a] se puede ver como una
estructura compuesta de la forma:
lista
T
T
T
a
[ ]
En este caso, a es la cabeza y [ ] es la cola de la lista.
31
1.2. Procesamiento de listas
Prolog dispone de una forma para hacer explı́cita la separación entre la cabeza y la
cola de una lista. Concretamente, usamos una barra vertical (|) en lugar de la coma.
Por ejemplo, la lista:
[ a, b, c, d ]
es equivalente a:
[ a | [ b, c, d ] ]
y, continuando el proceso, tenemos:
[ a | [ b | [ c | [ d | [] ] ] ] ]
Por otra parte, se puede combinar el operador “,” con “|”, de forma que la lista [a,
b, c, d] se escriba:
[ a, b | [ c, d ] ]
Veamos algunos ejemplos de cómo se puede partir una lista en cabeza y cola:
Lista
[’a’, ’b’, ’c’]
[ ’a’ ]
[ ]
[[1, 2, 3], [2, 1], [ ]]
Cabeza
’a’
’a’
indefinido
[1, 2, 3]
Cola
[’b’, ’c’]
[ ]
indefinido
[[2, 1], [ ]]
La siguiente tabla muestra algunos ejemplos de cómo se unifican las listas:
Lista 1
[X, Y, Z]
[7]
[1, 2, 3, 4]
[1, 2]
Lista 2
[pepe, juan, ana]
[X | Y]
[X, Y | Z]
[2 | X]
Instaciación de variables
X=pepe, Y=juan, Z=ana
X=7, Y=[ ]
X=1, Y=2, Z=[3,4]
fallo
2.- Uso de listas
Puesto que las listas son estructuras de datos recursivas, vamos a necesitar algoritmos recursivos para procesarlas. Este tipo de algoritmos suelen contener dos cláusulas:
una para procesar una lista ordinaria (es decir, que pueda subdividirse en cabeza y
cola) y otra para procesar la lista vacı́a.
2.1. Escritura de listas
El procedimiento para escribir una lista es muy simple:
32
escribir_lista([]).
escribir_lista([H|T]) :write(H), nl,
escribir_lista(T).
Ahora, dado un objetivo:
escribir_lista([1, 2, 3]).
aparecerá por pantalla:
1
2
3
yes
Ejercicio. ¿El programa para escribir listas cumple las condiciones de la recursión de
cola? ¿Las cumplirı́a si invirtiésemos el orden de las cláusulas?
2.2. Contando los elementos de una lista
Para contar los elementos de la lista, podemos definir un algoritmo recursivo que:
si la lista está vacı́a, devuelve 0;
en otro caso, el resultado es 1 más el número de elementos de la cola (llamada
recursiva).
Concretamente, es suficiente con las dos cláusulas siguientes:
nelem([], 0).
nelem([_|T], L) :nelem(T, TL),
L = TL+1.
Por ejemplo, el objetivo:
nelem([a, b, c, d], L).
tiene éxito mostrando por pantalla:
L = 4
1 Solution
Ejercicio. ¿Cuál es el resultado del siguiente objetivo?
nelem(X,3), !.
33
Ejercicio. Escribir un programa sumlist que sume los elementos de una lista. (Nota: es
básicamente similar al procedimiento nelem.)
Ejercicio. ¿Qué ocurre si lanzamos el siguiente objetivo al programa anterior?
sumlist(Lista, 10).
¿Por qué ha ocurrido esto?
2.3. Listas y recursión de cola
Como resulta sencillo de comprobar, la definición del procedimiento nelem para
calcular el número de elementos de una lista no cumple las condiciones de la recursión
de cola. Convertirlo en uno que sı́ las cumpla no es una tarea sencilla, aunque es posible
hacerlo.
Para ello, hay que crear una versión recursiva que use un contador, similar al programa factorial que vimos en la parte anterior:
nelem(L, N) :- nelem_aux(L, N, 0).
nelem_aux([], N, N).
nelem_aux([_|T], N, Contador) :NuevoContador = Contador+1,
nelem_aux(T, N, NuevoContador).
Como podéis ver, esta versión es algo más compleja y menos legible que la versión
anterior. Sólo la hemos presentado para mostrar que, aunque el proceso no suele ser
sencillo, es posible obtener una versión con recursión de cola a partir de prácticamente
cualquier algoritmo recursivo.
Ejercicio. Reescribe el programa sumlist para que cumpla las condiciones de la recursión de cola.
Veamos algunos ejemplos más. El siguiente fragmento de programa sirve para sumar 1
a todos los elementos de una lista:
sum1([], []).
/* caso base
sum1([H | T], [NewH | NewT]) :/* caso recursivo
NewH = H+1,
/* sumar 1 al primer elemento
sum1(T, NewT). /* sumar 1 al resto de elementos
*/
*/
*/
*/
(La versión completa del programa está en ch07e04.pro.) Fijaos en que esta versión
ya cumple las condiciones de la ‘recursión de cola.
El siguiente ejemplo muestra como podemos obtener una sublista con los números
positivos de una lista:
34
eliminar_negativos([], [])
eliminar_negativos([H|T], ListaPos) :H < 0,
/* si H es negativo, no se considera */
!,
eliminar_negativos(T, ListaPos).
eliminar_negativos([H|T], [H|ListaPos]) :eliminar_negativos(T, ListaPos).
(La versión completa del programa está en ch07e05.pro.)
Por último, el siguiente programa duplica los elementos de una lista:
duplicar([], []).
duplicar([H | T], [H, H | DobleT]) :duplicar(T, DobleT).
2.4. El predicado member
Supongamos que tenemos una lista con nombres: Juan, Pedro, Ana, etc. y queremos
saber si un determinado nombre se encuentra en la lista. Para ello, podemos usar el
siguiente programa (ch07e06.pro):
DOMAINS
namelist = name*
name = symbol
PREDICATES
nondeterm member(name, namelist)
CLAUSES
member(Name, [Name|_]).
member(Name, [_|Tail]) :- member(Name,Tail).
Podemos probar con el objetivo:
member(juan, [pedro, susana, juan]).
y Prolog contestará yes.
Ejercicio. Supongamos que cambiamos de orden las dos cláusulas de member. ¿Hay
alguna diferencia con la versión anterior? (Nota: probad el objetivo member(X, [ana,
juan, pedro]) con las dos versiones.)
35
2.5. Concatenación de listas: el predicado append
En primer lugar, si revisamos la definición del predicado member:
member(Name, [Name|_]).
member(Name, [_|Tail]) :- member(Name, Tail).
ésta se puede interpretar de dos formas:
Desde el punto de vista declarativo: “Name es miembro de una lista si la cabeza
de la lista es igual a Name; si no, Name es miembro de la lista si es miembro de la
cola”.
Desde un punto de vista procedural: “Para obtener un elemento que sea miembro
de una lista, podemos devolver la cabeza de la lista, o bien cualquier elemento
que sea miembro de la cola de la lista”.
Estos dos puntos de vista se corresponden, respectivamente, con los objetivos:
member(2, [1, 2, 3]).
member(X, [1, 2, 3]).
Como podéis ver, la definición de member es la misma, pero sirve para dos propósitos
distintos: (1) comprobar si un determinado elemento es miembro de una lista (por
ejemplo, member(2,[1,2,3])), y (2) obtener todos los elementos de una lista (por
ejemplo, member(X,[1,2,3])).
Diferentes usos del mismo procedimiento
Una de las ventajas de Prolog es que, a menudo, uno puede implementar un procedimiento teniendo en mente sólo una de las posibles interpretaciones del mismo, y que
éste sea igualmente útil para el resto de interpretaciones. Por ejemplo, si nos planteamos
implementar un procedimiento append(List1, List2, List3) que, dadas las listas
List1 y List2, nos devuelva en List3 la concatenación de List1 y List2, podrı́amos
escribir esto:
append([], List2, List2).
append([H|L1], List2, [H|L3]) :- append(L1, List2, L3).
cuya interpretación es:
1.
La concatenación de una lista vacı́a [ ] con List2 es List2.
2.
La concatenación de una lista no vacı́a [H|L1] con List2 es una lista cuyo elemento en cabeza es H y cuya cola es la concatenación de la cola de la primera
lista (L1) y List2.
Por ejemplo, dado el objetivo:
append([1, 2, 3], [8, 9], L).
36
obtenemos la siguiente solución:
L = [1, 2, 3, 8, 9]
1 Solution
(En ch07e07.pro tenéis una versión completa del programa.)
Sin embargo, desde el punto de vista declarativo, hemos definido (sin habérnoslo
propuesto!) una relación que tiene éxito siempre que la concatenación de las dos
primeras listas sea igual a la tercera. Estos nos permite lanzar objetivos como este:
“encontrar una sublista L1 tal que la concatenación de L1 con [2,3] sea la
lista [1,2,3]”
(es decir, queremos calcular la diferencia de [1,2,3] menos [2,3]). En Prolog, escribirı́amos el objetivo:
append(L1, [2,3], [1,2,3]).
cuya solución es:
L1 = [1]
1 Solution
De forma similar, podemos lanzar un objetivo:
append([1], L2, [1,2,3]).
y averiguar qué lista hay que concatenar a [1] para obtener la lista [1,2,3]. La
solución de Prolog es:
L2 = [2, 3]
1 Solution
Más aún, podrı́amos usar el predicado append para generar todas las posibles sublistas
cuya concatenación es [1,2,3]. Concretamente, dado el objetivo:
append(L1, L2, [1,2,3]).
Prolog nos devuelve:
L1 = [], L2 = [1,2,3]
L1 = [1], L2 = [2,3]
L1 = [1,2], L2 = [3]
L1 = [1,2,3], L2 = []
4 Solutions
En resumen, disponemos de un procedimiento en el que los patrones de flujo de información no son fijos. Podemos preguntar al sistema cuál es la salida del procedimiento
dada una cierta entrada de datos, o bien preguntar cuál debe ser la entrada para obtener
una cierta salida. Ambas formas de uso son válidas con una misma definición.
Como ya comentamos en la Parte II, el comportamiento en la entrada/salida de
datos a un procedimiento se establece mediante sus patrones de flujo de datos. Por
ejemplo, cuando usamos append para obtener la concatenación de dos listas, como en
el objetivo:
37
append([1,2], [3], L):
tenemos un patron de flujo de datos (i,i,o), es decir, los dos primeros argumentos
son de entrada (i) y el tercero de salida (o). La definición del predicado append tiene
la particularidad de aceptar cualquier patrón de flujo de datos:
(i,i,i)
(i,o,o)
(i,i,o)
(o,i,o)
(i,o,i)
(o,o,i)
(o,i,i)
(o,o,o)
Ejercicio. Escribir la definición de un predicado miembro par(E, L) que tenga éxito
si el elemento E es un miembro de la lista L y, además, es par. Por ejemplo, dado el
objetivo:
miembro_par(2, [1,2,3,4,5,6]).
la respuesta debe ser:
yes
mientras que, dado el objetivo:
miembro_par(X, [1,2,3,4,5,6]).
la respuesta debe ser:
X
X
X
3
= 2
= 4
= 6
Solutions
3.- Encontrando todas las soluciones
En la Parte VI discutimos las dos formas de realizar procesos repetitivos: backtracking y recursión. Quedaron claras las ventajas de la recursión frente al backtracking, ya
que con un procedimiento recursivo es posible pasar información (a través de los argumentos) de una iteración a la siguiente. Sin embargo, hay cosas que la recursión no
puede hacer: generar todas las soluciones a un objetivo.
Supongamos ahora que necesitamos generar todas las soluciones para un objetivo
dado y, además, disponer de ellas agrupadas dentro de una estructura. Para esto, Prolog
dispone del predicado predefinido findall. Dado un objetivo de la forma:
findall(Var, Goal, ListVar).
Prolog nos devuelve en ListVar una lista con todos los valores de Var tales que Goal
tiene éxito.
Por ejemplo, dada la definición de append:
append([], List2, List2).
append([H|L1], List2, [H|L3]) :- append(L1, List2, L3).
38
podemos lanzar el objetivo:
findall(L1, append(L1, L2, [1,2,3]), ListL1).
cuya solución será:
ListL1 = [ [], [1], [1,2], [1,2,3] ]
1 Solution
Es decir, nos ha construı́do una lista ListL1 conteniendo todos los valores de L1 tales
que el objetivo append(L1,L2,[1,2,3]) tiene éxito.
En el programa ch07e08.pro podéis ver otro ejemplo del uso de findall para calcular la media de las edades de varias personas:
DOMAINS
name,address = string
age = integer
list = age*
PREDICATES
nondeterm person(name, address, age)
sumlist(list, age, integer)
run
CLAUSES
sumlist([],0,0).
sumlist([H|T],Sum,N) :sumlist(T,S1,N1),
Sum=H+S1, N=1+N1.
person("Sherlock Holmes", "22B Baker Street", 42).
person("Pete Spiers", "Apt. 22, 21st Street", 36).
person("Mary Darrow", "Suite 2, Omega Home", 51).
GOAL
findall(Age,person(_, _, Age),L),
sumlist(L,Sum,N),
Ave = Sum/N,
write("Average=", Ave),nl.
Por otro lado, si quisieramos obtener un listado de las personas que tienen 42 años,
podrı́amos lanzar simplemente el objetivo:
findall(Who, person(Who,_,42), List).
Sin embargo, fijaos en que nos da un error de tipos. El problema consiste en que la
lista List que debe construir findall debe estar declarada. Es decir, debemos añadir
la declaración:
slist = string*
(el nombre de la lista no importa). Aunque pueda parecer un inconveniente, este tipo
de situaciones se da rara vez en aplicaciones reales.
39
Parte VIII
La base de datos interna
40
Como ya comentamos brevemente en la Parte III, Visual Prolog nos permite definir
una base de datos interna compuesta de hechos. Los predicados de estos hechos deben
declararse en la sección database, y se pueden actualizar en tiempo de ejecución mediante ciertos predicados predefinidos.
La secuencia de hechos para un predicado dado se comporta como una tabla de una
base de datos y, en realidad, Visual Prolog los trata en compilación como si se tratase
de una base de datos real.
1.- Declaración de la base de datos interna
La definición de un programa que contenga una base de datos interna tiene un
aspecto como éste:
domains
nombre, direccion = string
edad = integer
genero = hombre ; mujer
database
persona(nombre, direccion, edad, genero)
predicates
hombre(nombre, direccion, edad)
mujer(nombre, direccion, edad)
clauses
persona("Rosa", "Madrid", 35, hombre).
persona("Miguel", "Valencia", 24, mujer).
hombre(Nombre, Direccion, Edad) :persona(Nombre, Direccion, Edad, hombre).
mujer(Nombre, Direccion, Edad) :persona(Nombre, Direccion, Edad, mujer).
En este ejemplo usamos el predicado persona de la misma forma que el resto de
predicados (hombre y mujer). La única diferencia es que, en tiempo de ejecución, se
pueden insertar y borrar hechos del tipo persona(...).
Existen dos restricciones al uso de la base de datos:
1.
sólo se pueden añadir hechos, no reglas;
2.
los hechos no pueden contener variables desinstanciadas.
Es posible, sin embargo, definir más de una base de datos. Para hacer esto, debemos
darle un nombre explı́cito a cada una:
41
database - personas
persona(nombre, direccion, edad, genero)
database - estudiantes
estudiante(nombre, direccion, edad)
Esta declaración crea dos bases de datos con los nombres personas y estudiantes. Si
sólo usamos una base de datos, no es necesario darle un nombre, aunque internamente
se le asignará el nombre dbasedom.
2.- Uso de la base de datos interna
En primer lugar, fijaos en que mediante una secuencia de hechos Prolog resulta
inmediato definir una base de datos relacional haciendo uso de la base de datos interna.
Ası́, los objetivos Prolog se pueden utilizar para lanzar consultas a la base de datos,
la unificación se encarga de computar los valores para las variables de la consulta, y el
backtracking se encarga de obtener todas las soluciones para dichas variables.
2.1. Acceso a la base de datos interna
El acceso a los predicatos pertenecientes a la base de datos interna es exactamente
el mismo que al resto de los predicados. Es decir, dado el siguiente programa:
domains
nombre = string
sexo = char
database
persona(nombre, sexo)
clauses
persona("Elena", ’M’).
persona("Maria", ’M’).
persona("Juan", ’H’).
podemos lanzar un objetivo del tipo:
persona(Nombre, ’M’).
para encontrar los nombres de todas las mujeres de la base de datos, o bien:
persona("Elena", _).
para comprobar si Elena pertenece a nuestra base de datos.
42
2.2. Actualización de la base de datos interna
Los hechos de la base de datos interna se pueden escribir directamente en el programa, tal como hemos hecho en los ejemplos anteriores, o bien se pueden insertar o
eliminar en tiempo de ejecución.
Disponemos de los predicados predefinidos: assert, asserta, assertz, retract, retractall, consult y save, cuya definición veremos en los siguientes apartados. Estos
predicados pueden tomar uno o dos argumentos. Indicaremos entre comentarios el patrón de flujo de datos asociado a cada predicado.
Inserción de hechos
Disponemos de 3 predicados predefinidos (de uno o dos argumentos cada uno):
asserta(hecho)
asserta(hecho, nombre_database)
/*
/*
assertz(hecho)
assertz(hecho, nombre_database)
/*
assert(hecho)
assert(hecho, nombre_database)
/*
/*
/*
(i)
(i,i)
*/
*/
(i)
(i,i)
*/
*/
(i)
(i,i)
*/
*/
El primero, asserta, añade el nuevo hecho al principio de los hechos ya existentes para
el predicado dado; assertz lo añade al final (y assert se comporta igual).
El segundo argumento es siempre opcional, ya que los nombres de los predicados
deben ser únicos (aunque existan varias bases de datos definidas en el programa), con
lo que Prolog sabe perfectamente dónde debe insertarlo. Sin embargo, si queréis comprobar que lo estáis insertando en el lugar correcto, podéis usar las versiones de dos
argumentos.
Por ejemplo, si tenemos un programa que contiene los hechos:
persona("Rosa", "Madrid", 35).
persona("Miguel", "Valencia", 24).
y lanzamos el objetivo:
assertz(persona("Pepe", "Madrid", 36)),
asserta(persona("Ana", "Valencia", 22)).
el nuevo conjunto de hechos del programa es:
persona("Ana", "Valencia", 22).
persona("Rosa", "Madrid", 35).
persona("Miguel", "Valencia", 24).
persona("Pepe", "Madrid", 36).
43
Prolog no comprueba si un hecho existe o no en la base de datos antes de insertarlo.
Si deseamos hacer dicha comprobación, podemos definir un procedimiento del tipo:
mi_assert(persona(Nombre,Direccion)) :persona(Nombre, Direcccion), ! ;
/* disyuncion */
assert(persona(Nombre, Direccion)).
Este procedimiento comprueba primero si existe y, en caso negativo, lo inserta.
Consulta de un fichero de hechos
El predicado predefinido consult se utiliza para cargar en la base de datos interna
una colección de hechos almacenada en un fichero. consult puede tener uno o dos
argumentos:
consult(nombreFichero)
consult(nombreFichero, nombreDatabase)
/* (i) */
/* (i,i) */
consult siempre almacena los nuevos hechos al final de la base de datos. Sin embargo,
a diferencia de assertz, si no se le especifica un nombre de base de datos, únicamente
lee los hechos de la base de datos por defecto (dbasedom).
En general, si llamamos a consult con un nombre concreto de base de datos, sólo
leerá los hechos declarados en dicha base de datos. Si el fichero contiene algún hecho
no declarado en la base de datos especificada, se producirá un error. Sin embargo,
tened en cuenta que la lectura se realiza de forma secuencial. Es decir, si el fichero que
consultamos contiene 10 hechos, y el séptimo que aparece en el fichero contiene algún
error de sintaxis (o no pertenece a la base de datos especificada), los seis primeros
hechos sı́ se leerán y después mostrará el mensaje de error.
Para que consult pueda leer un fichero sin problemas, éste debe tener el mismo
formato que los ficheros generados por save, es decir, no debe contener:
caracteres en mayúscula (excepto en strings entre dobles comillas)
espacios en blanco (excepto en strings entre dobles comillas)
comentarios
lı́neas en blanco
Eliminación de hechos
El predicado predefinido retract elimina hechos de la base de datos interna. Su
formato es:
retract(hecho)
retract(hecho, nombreDatabase)
/* (i) */
/* (i,i) */
44
retract(hecho) elimina de la base de datos interna el primer hecho que unifique con
hecho, instanciado en el proceso las variables que aparezcan en hecho. El predicado
retract es indeterminista y, mediante backtracking, puede eliminar no sólo el primero,
sino todos los hechos de la base de datos que unifiquen con su argumento. Por ejemplo,
dado el programa:
database
persona(string, string, integer)
database - mibasededatos
tiene(string, string)
no_tiene(string, string)
clauses
persona("Ana", "Valencia", 22).
persona("Rosa", "Madrid", 35).
persona("Miguel", "Valencia", 24).
persona("Pepe", "Madrid", 36).
tiene("Miguel", "casa").
tiene("Ana", "coche").
tiene("Miguel", "perro").
no_tiene("Ana", "coche").
no_tiene("Pepe", "perro").
El objetivo:
retract(persona(_, "Madrid", _)).
eliminará de la base de datos dbasedom (puesto que no le hemos dado un nombre
explı́cito) el hecho:
persona("Rosa", "Madrid", 35).
Por el contrario, si hubieramos lanzado el objetivo:
retract(persona(Nombre, "Madrid", _)),
write(Nombre), nl,
fail.
entonces se hubieran eliminado los hechos:
persona("Rosa", "Madrid", 35).
persona("Pepe", "Madrid", 36).
(gracias al backtracking provocado por fail), mostrando por pantalla lo siguiente:
Nombre = "Rosa"
Nombre = "Pepe"
no
45
El motivo de que el último mensaje sea no es que, tras el último fail, intenta buscar
más hechos que unifiquen con persona(Nombre,"Madrid", ) y, al no encontrarlos, se
produce un fallo que aborta la computación.
Como ocurrı́a en el caso de assert, el segundo argumento de retract es opcional.
Es decir, el objetivo:
retract(tiene("Ana",_)).
y el objetivo:
retract(tiene("Ana",_), mibasededatos).
tienen exactamente el mismo efecto, es decir, eliminan el hecho tiene(.Ana",çoche").
Sin embargo, si lanzamos el objetivo:
retract(persona("Ana",_,_), mibasededatos).
obtendremos un mensaje de error, ya que el predicado persona no está declarado en
la sección database - mibasededatos.
Por último, tened en cuenta lo siguiente. Si no usamos el nombre explı́cito de la
base de datos, Prolog no aceptará un objetivo del tipo:
retract(X).
Sin embargo, si indicamos el nombre de la base de datos, es posible lanzar un objetivo
de la forma:
retract(X, mibasededatos),
write(X),
fail.
cuyo efecto serı́a eliminar todos los hechos (con cualquier predicado) que aparezcan en
la base de datos interna mibasededatos (y escribirlos por pantalla).
Eliminación de varios hechos a la vez
Disponemos también de una variante de retract que nos permite eliminar varios
hechos a la vez, sin necesidad de usar el backtracking: retractall. Su sintaxis es:
retractall(hecho)
retractall(hecho, nombreDatabase)
/* (i) */
/* (i,i) */
Dado un objetivo retractall(hecho), su efecto es eliminar todos los hechos de la base
de datos que unifiquen con hecho. Es decir, se comporta como si lo hubieramos definido
ası́:
retractall(X) :- retract(X), fail.
retractall(_).
46
aunque es considerablemente más rápido. Como podéis imaginar, retractall sólo puede
tener éxito una vez y, por tanto, no es posible obtener valores de salida para sus variables
desinstanciadas. Por ello, al igual que con not, las variables desinstanciadas deben
aparecer como variables anónimas (“ ”). Por ejemplo, el objetivo:
retractall(persona(_,_,36)).
elimina todas las personas de la base de datos cuya edad sea 36, mientras que el objetivo:
retractall(_,mibasededatos).
elimina todos los hechos (con cualquier predicado) que aparezcan en la base de datos
mibasededatos.
2.3. Cómo salvar la base de datos interna en un fichero
El predicado predefinido save nos permite salvar el estado de una base de datos en
un fichero. Su sintaxis es:
save(nombreFichero)
save(nombreFichero, nombreDatabase)
/* (i) */
/* (i,i) */
Si no indicamos el nombre de la base de datos, en el fichero nombreFichero se almacenarán los hechos de la base de datos por defecto (dbasedom).
3.- Un ejemplo
Para terminar esta parte, podéis cargar el programa ch08e01.pro que contiene un
pequeño sistema experto que hace uso de la base de datos interna. Lanzad el objetivo:
run(tool).
y contestad a las preguntas como si desearais encontrar una herramienta para comunicaros con un ordenador. Probad luego con el objetivo:
update, run(tool).
y contestad a las preguntas del mismo modo. Observad el código del programa e intentad compreder su funcionamiento.
47
Parte IX
Predicados predefinidos
48
Visual Prolog dispone de un gran número de predicados predefinidos, es decir, predicados cuya definición es ya conocida por el sistema y que, por tanto, podemos usar sin
incluir su definición en los programas.
A continuación podéis ver un listado de los principales grupos de predicados predefinidos en Visual Prolog, ası́ como dónde podéis encontrar más información sobre
ellos:
Aritmética y comparación. Consultar:
Help/Visual Prolog Language Fundamentals/Arithmetic and Comparison
Manejo de errores y control de ejecución. Consultar:
Help/Contents/Predefined Predicates/Function Groups/Error and Break control
Entrada, salida y manejo de ficheros. Consultar:
Help/Visual Prolog Language Fundamentals/Writing, Reading, and Files
Manejo de strings. Consultar:
Help/Visual Prolog Language Fundamentals/String Handling
Llamadas al sistema. Consultar:
Help/Visual Prolog Language Fundamentals/System Level Programming
Además, podéis consultar una serie de ejemplos sencillos en Visual Prolog. Estos
ejemplos presentan diversas aplicaciones tı́picas de Prolog y, aunque son versiones simples, se pueden extender fácilmente. Los ejemplos son:
1. ch15e01.pro: un pequeño sistema experto para encontrar un animal a partir de
sus caracterı́sticas.
2. ch15e02.pro: un programa que permite averiguar si existe una ruta posible entre
dos ciudades.
3. ch15e03.pro: un pequeño ejemplo sobre cómo encontrar la salida a un laberinto
definido mediante galerı́as.
4. ch15e04.pro: la implementación de un circuito lógico mediante un programa
Prolog.
5. ch15e05.pro: las torres de Hanoi.
6. ch15e06.pro: un programa que divide las palabras en sı́labas.
49
Descargar