Parte II. FORMALISMOS PARA PROGRAMACIÓN
3.4 El lenguaje PROLOG
3.4.1 Definición de predicados recursivos Reglas de computación y búsqueda
Comenzaremos por ver los sencillos cambios notacionales que introduce PROLOG con respecto a la notación matemática que hemos utilizado en los apartados anteriores. En un programa PROLOG, las variables empiezan con letras mayúsculas, los predicados y las constantes con letras minúsculas y en lugar del símbolo ← se utiliza : −. Las cláusulas, denominadas también en PROLOG ”reglas”, se terminan con un punto.
Ejemplo 3.34 El ejemplo de la sección 3.16 se escribe pues en PROLOG de la forma siguiente:
1. q(X ,Y ) : − p(X ,Y ).
3.4. El lenguaje PROLOG 131 3. p(b, a). 7. p( f , b).
4. p(c, a). 8. p(h, g). 5. p(d, b). 9. p(i, h). 6. p(e, b). 10. p( j, h).
De hecho, este programa ilustra una típica definición recursiva de predicado PROLOG. Los predi- cados más complejos (q(X ,Y )) se definen en términos de predicados más sencillos (p(X ,Y )), y las cláusulas más generales son recursivas, es decir, el predicado definido (cabeza de la cláusula) aparece en la definición (cuerpo de la cláusula). Así como en los lenguajes algorítmicos se utiliza la itera- ción (mediante estructuras de bucle tipo ”for”, ”while”, y ”repeat/until”), en los lenguajes lógicos la recursión es el recurso principal que el programador utiliza para diseñar los predicados.
Ejemplo 3.35 Efectivamente, es fácil imaginar diferentes programas que respondan al patrón ante-
rior. Considérese, por ejemplo, el problema de estudiar la conectividad en un grafo dirigido. Un grafo dirigido puede representarse en un programa lógico por medio de una colección de hechos. Un hecho arco(Nodo1, Nodo2) estará presente en el programa si existe un arco desde el Nodo1 al Nodo2 en el grafo. Dos nodos estarán conectados si existe una serie de arcos que pueden recorrerse para llegar desde el primer nodo hasta el segundo. Es decir, la relación que representa el predicado conectado(Nodo1, Nodo2), que es cierta si los nodos Nodo1 y Nodo2 están conectados, es el cierre transitivo de la relación arco. El predicado conectado se define mediante el siguiente programa:
1. conectado(Nodo1, Nodo2) : − arco(Nodo1, Nodo2).
2. conectado(Nodo1, Nodo2) : −arco(Nodo1, Enlace), conectado(Enlace, Nodo2). El siguiente conjunto de hechos representaría un grafo inconexo:
3. arco(b, a). 7. arco( f , b). 4. arco(c, a). 8. arco(h, g). 5. arco(d, b). 9. arco(i, h). 6. arco(e, b). 10. arco( j, h).
Ejercicio 3.36 Defina en PROLOG el predicado ancestro, cierre transitivo de la relación padre. Pro-
porcione asimismo una definición recursiva en PROLOG para el predicado f actorial, partiendo de su definición como sentencia lógica presentada en la sección 3.1.1.
Utilizando el programa anterior, un intérprete de PROLOG puede responder, por ejemplo, a la pregunta ”¿qué nodos Y y Z pueden conectarse a través de un camino que pase por el nodo b?”. Se denominan ”preguntas PROLOG” a los objetivos introducidos en un sistema PROLOG con el fin de comprobar su satisfacibilidad y/o computar las correspondientes respuestas. Normalmente, la línea de comandos en que se introducen los objetivos sometidos a prueba se identifica con el símbolo ?−.
En el sistema de cláusulas asociado, responder a la anterior pregunta supone computar una res- puesta para el objetivo ← conectado(y, b), conectado(b, z), que se introduce en la línea de comandos del intérprete como: ? − conectado(Y, b), conectado(b, Z). La respuesta correcta obtenida en el ejem- plo 3.16 de la demostración de validez de la fórmula ∃y∃z(q(y, b) ∧ q(b, z)),θ= {y ← d, z ← a}, es precisamente una de las respuestas que buscamos. El motor de inferencia de PROLOG computa esta respuesta aplicando el método de resolución en el sistema de cláusulas contenido en su base de datos. Como regla de computación utiliza la selección del literal situado más a la izquierda en la cláusula objetivo, y como regla de búsqueda la selección de la cláusula situada más arriba en la lista de cláusulas de cada procedimiento, de forma que realiza la computación en los siguientes pasos:
1. Escoge resolver conectado(Y, b) (el subobjetivo situado más a la izquierda) con la primera cláusula en que la equiparación es posible (la cláusula 1). La nueva cláusula objetivo es: ← arco(Y, b), conectado(b, Z).
2. Escoge resolver arco(Y, b) (el subobjetivo situado más a la izquierda del objetivo actual) con la primera cláusula en que la equiparación es posible (la cláusula 5), aplicando la sustitución {Y ← d}. La nueva cláusula objetivo es: ← conectado(b, Z)
3. Toma el único subobjetivo posible, conectado(b, Z), para resolver con la primera cláusula en que la equiparación es posible (la cláusula 1). La nueva cláusula objetivo es: ← arco(b, Z) 4. Toma el único subobjetivo posible, arco(b, Z) para resolver con la única cláusula en que la
equiparación es posible (la cláusula 3), aplicando la sustitución {Z ← a} , obteniéndo la cláusula vacía ¤.
El árbol SLD correspondiente se muestra en la figura 3.2.
a r c o (Y ,b ), c o n e c ta d o (b ,Z ) c o n e c ta d o (b ,Z ) a r c o (b ,Z ) Y ← d c o n e c ta d o (Y ,b ), c o n e c ta d o (b ,Z ) (1 ) (5 ) (1 ) Z ← a (3 )
Figura 3.2: Árbol SLD del objetivo conectado(Y, b), conectado(b, Z)
Es la aplicación de una estrategia de búsqueda en profundidad lo que hace que los intérpretes de PROLOG sean tan eficientes. Gracias a esta estrategia, en tiempo de ejecución basta con almacenar: a) la cláusula objetivo actual, con un puntero al subobjetivo actual, b) un puntero a la cláusula del procedimiento cuya cabeza se puede unificar con el citado suobjetivo y c) el conjunto de sustitucio- nes de variables realizadas hasta el momento. Adicionalmente, resulta útil señalar de algún modo los nodos previos del árbol SLD donde aún quedan ramas por explorar. De este modo, en caso de ocurrir un fallo, se puede retroceder directamente hasta el nodo más cercano cuyos subárboles puedan proporcionar soluciones adicionales. Tales nodos se registran en una lista y se denominan puntos de retroceso (” backtracking”). Debido a la regla de búsqueda de PROLOG, las ramas no exploradas están siempre situadas a la derecha de la rama actual. Esta estrategia es eficiente desde el punto de vista del almacenamiento, ya que no requiere guardar memoria de todos los caminos explorados, que están implícitos en los datos anteriores. Los puntos de retroceso no sólo son útiles en caso de fallo de
3.4. El lenguaje PROLOG 133 alguna rama, sino en los casos en que se desea computar más de una respuesta correcta, o todas las respuestas correctas para un objetivo dado.
Explorando nuevas ramas del árbol SLD, PROLOG puede computar respuestas adicionales, en particular las soluciones {Y ← e, Z ← a} y {Y ← f , Z ← a}, como se muestra en el árbol SLD de la figura 3.3, donde los puntos de retroceso se han señalado con un asterisco.
*a r c o (Y ,b ), c o n e c ta d o (b ,Z ) *c on e c tado (b ,Z ) a r c o (b ,Z ) Y ← d *c o n e c ta d o (Y ,b ), c o n e c ta d o (b ,Z ) (1 ) (5 ) (1 ) Z ← a (3 )
(6 ) (7 ) *c o n e c ta d o (b ,Z ) *c on e c tado (b ,Z ) a r c o (b ,Z ) (1 ) Z ← a (3 ) a r c o (b ,Z ) (1 ) Z ← a (3 )
Y ← e Y ← f a r c o (b ,X ), c o n e c ta d o (X ,Z ) (2 ) X ← a (3 ) *c o n e c ta d o (a ,Z ) F A L L O F A L L O a r c o (a ,Z ) (1 ) a r c o (a ,P ), c o n e c ta d o (P ,Z ) (2 ) F A L L O F A L L O F A L L O
Figura 3.3: Reevaluación del objetivo conectado(Y, b), conectado(b, Z)
En la figura 3.4 se muestra cómo PROLOG explora el árbol en busca de nuevas soluciones, retro- cediendo en cada paso al punto de retroceso más recientemente señalado.
Supongamos ahora que se formula a PROLOG la pregunta ? − conectado(X ,Y ), con el propósito de encontrar todos los pares de nodos conectados entre sí. La posibilidad de encontrar la solución ra- dica en dar con una rama finita. PROLOG encuentra las soluciones porque su estrategia de recorrido del árbol le conduce en este caso a evaluar primero las ramas finitas. En concreto, comenzará por sa- tisfacer repetidas veces el subobjetivo arco(X ,Y ) hasta agotar todas las respuestas. A continuación, el retroceso le llevará a intentar satisfacer conectado(X ,Y ) utilizando la segunda cláusula, encontrando primero un tercer nodo Z ligado mediante un arco a X , es decir, intentado satisfacer arco(X , Z), etc. Sin embargo, consideremos las cláusulas y sus literales ordenados en la siguiente forma:
1. conectado(Nodo1, Nodo2) : − conectado(Enlace, Nodo2), arco(Nodo1, Enlace). 2. conectado(Nodo1, Nodo2) : − arco(Nodo1, Nodo2).
Al desplegar el correspondiente árbol de búsqueda de respuestas para ? − conectado(X ,Y ), nos encontramos con que es infinito. Debido a la recursión, de las ramas situadas más a la izquierda cuelga siempre un subárbol que reproduce el árbol global (véase la figura 3.5). PROLOG no sería capaz de
*a r c o (Y ,b ), c o n e c ta d o (b ,Z ) *c on e c tado (b ,Z ) *c on e c tado (Y ,b ), c o n e c ta d o (b ,Z )
*c o n e c ta d o (b ,Z )
*c o n e c ta d o (a ,Z ) F A L L O F A L L O F A L L O F A L L O F A L L O *c on e c tado (b ,Z )
Figura 3.4: Exploración del árbol SLD del objetivo conectado(Y, b), conectado(b, Z) por un intérprete PROLOG
computar ni una sola respuesta, quedándose eternamente explorando la primera rama infinita.
c o n e c ta d o (X ,Y )
c on e c tado (Z ,Y ), a r c o (X ,Z ) (1 )
c o n e c ta d o (P ,Y ),a rc o (P ,Z ), a r c o (X ,Z ) (1 )
Figura 3.5: Rama infinita del árbol SLD del objetivo conectado(X ,Y )
Vemos pues, como se anticipó en la sección anterior, que las estrategias de búsqueda en profun- didad pueden conducir a computaciones no terminantes incluso en casos en que existe una respuesta correcta. Un programador de PROLOG debe ordenar cuidadosamente las cláusulas dentro de cada procedimiento, así como los literales dentro de las cláusulas, para evitar la no-terminación. Es por esta razón que es muy importante conocer las estrategias de computación y búsqueda de PROLOG y tenerlas presentes cuando se diseñan los programas. En realidad, ésta es una característica de la programación en PROLOG impropia de un paradigma puramente lógico, donde idealmente el progra- mador puede por completo despreocuparse del control de ejecución de sus programas. En la sección 3.4.2 veremos algunas otras características no-lógicas del lenguaje PROLOG.
Ejercicio 3.37 En el caso de la primera versión del programa anterior, muestre cómo PROLOG en-
3.4. El lenguaje PROLOG 135 ¿Qué ocurre si se intercambian las cláusulas 1 y 2, conservando el orden de los literales dentro de las cláusulas?
Finalmente supóngase que se desea conocer la lista de nodos conectados a un nodo dado, por ejemplo, el nodo h. Bastaría con formular la pregunta PROLOG ? − conectado(Nodo, h) y solicitar todas las respuestas posibles. La tarea puede facilitarse no obstante definiendo un nuevo predicado que proporcione en una sola respuesta la lista de tales nodos. Obsérvese que la pregunta anterior podría haberse formulado en la forma ? − conectados(ListaNodos, h), donde el predicado conectados se habría definido previamente como:
conectados(Nodo1|ListaNodos, Nodo2) : − conectado(Nodo1, Nodo2), conectados(ListaNodos, Nodo2). conectados([], Nodo).
En este caso el intérprete PROLOG proporcionaría todas las soluciones en la primera respuesta, con la notación característica de las listas PROLOG, donde los elementos aparecen separados por comas y encerrados entre corchetes. La notación Nodo1|ListaNodos, denota una lista cuyo primer elemento es Nodo1. Hemos introducido este último ejemplo para ilustrar la utilidad de las estructuras de tipo lista en los programas PROLOG, así como la forma en que se manipulan. La posibilidad que proporciona la recursión de definir estructuras de datos infinitas como la lista dota a los programas PROLOG de una gran potencia matemática.
Ejercicio 3.38 a) Defina un predicado PROLOG que descomponga un número en la suma de dos
números pares, utilizando los predicados predefinidos between(A, B,C) (pertenencia de un número a un intervalo), + (suma), =:=(igualdad), mod (módulo o resto de división entera). Muestre el árbol SLD correspondiente a la computación de descomponer(6, a, b).
b) Defina en PROLOG un predicado de ordenación utilizando el programa lógico del ejercicio 3.3 , haciendo uso de los predicados predefinidos PROLOG member(X , X s) (pertenecencia de un elemento a una lista), = / = (distinto) y < (menor). Muestre el árbol SLD correspondiente a la computación de ordenar([2, 5, 9]).