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