Aller au contenu

Comprenez comment Prolog cherche

Jusqu’ici, Prolog a répondu à nos questions comme par magie. Mais pour écrire des programmes qui terminent, qui ne répètent pas leurs réponses et qui ne s’égarent pas, il faut savoir exactement ce que fait le moteur. Bonne nouvelle : il n’utilise que deux mécanismes, l’unification et la résolution avec retour arrière, et vous pouvez les dérouler à la main.

Dans ce chapitre, vous allez apprendre à calculer une unification, à suivre la résolution d’une question pas à pas, à dessiner l’arbre de recherche et à lire une trace. Notre fil rouge reste la crêperie, avec la question : « quels plats coûtent moins de 10 euros ? ».

Quand Prolog cherche à démontrer le but prix(cotriade, E) avec le fait prix(cotriade, 16), il se demande : existe-t-il des valeurs pour les variables qui rendent ces deux termes identiques ? Oui, E = 16. Cette opération s’appelle l’unification, et c’est le cœur du langage : c’est elle qui fait à la fois le passage de paramètres, le filtrage et la construction des résultats.

Deux termes s’unifient selon trois règles.

  1. Deux constantes (atomes ou nombres) s’unifient si elles sont identiques : cotriade et cotriade oui, cotriade et far_breton non, 16 et 16.0 non plus (un entier n’est pas un flottant).
  2. Une variable libre s’unifie avec n’importe quel terme : elle est alors liée à ce terme. Deux variables libres s’unifient en devenant la même variable.
  3. Deux termes composés s’unifient s’ils ont le même foncteur, la même arité, et si leurs arguments s’unifient deux à deux, de gauche à droite, en tenant compte des liaisons déjà faites.

Le résultat est une substitution, un ensemble de liaisons comme { E = 16 }. On peut demander une unification explicitement avec le prédicat = :

?- prix(cotriade, E) = prix(P, 16).
E = 16, P = cotriade
?- rep(entree(E), P) = rep(entree(salade_de_chevre), plat(cotriade)).
E = salade_de_chevre, P = plat(cotriade)
?- f(X, X) = f(far_breton, kouign_amann).
No

Le dernier cas échoue : le premier argument lie X à far_breton, et le second demande alors d’unifier far_breton avec kouign_amann, deux constantes différentes. Le = de Prolog n’est pas une affectation : il ne calcule rien, il cherche seulement à rendre deux termes égaux.

Considérons f(X, g(Y)) = f(g(Z), X). Le premier argument impose X = g(Z) ; le second devient alors g(Y) = g(Z), d’où Y = Z. La substitution obtenue est { X = g(Z), Y = Z }.

D’autres substitutions rendraient aussi les deux termes égaux, par exemple { X = g(far), Y = far, Z = far }. Mais celle-là est plus contraignante que nécessaire : elle fixe une valeur que rien n’impose. Prolog calcule toujours le plus général unificateur (pgu, en anglais most general unifier ou mgu), celui qui lie le moins possible ; toutes les autres solutions s’en déduisent en précisant les variables restantes. Ce pgu est unique, au renommage des variables près.

fXgYfgZX=même foncteur f/2 : on unifieles arguments deux à deux1 X = g(Z)2 g(Y) = X, c’est-à-dire g(Y) = g(Z) d’où Y = Zpgu : { X = g(Z), Y = Z }fXXffar_bretonkouign_amann=1 X = far_breton2 X vaut déjà far_breton : far_breton = kouign_amann ?échec : deux constantes différentes1212
L'unification compare les arbres argument par argument, en tenant compte des liaisons déjà faites. En haut elle donne le pgu { X = g(Z), Y = Z } ; en bas, la seconde paire échoue.

Que donne X = f(X) ? Aucun terme fini n’est égal à un terme qui le contient strictement. Logiquement, l’unification devrait échouer. Le vérifier s’appelle le test d’occurrence (occur check) : avant de lier une variable à un terme, on vérifie qu’elle n’y apparaît pas. Ce test coûte cher, et la plupart des systèmes Prolog, dont ECLiPSe par défaut, ne le font pas : ils acceptent la liaison et fabriquent un terme cyclique. En pratique, cela n’arrive que dans des programmes erronés, mais il faut le savoir.

fXX = f(X) ?X apparaîtdans f(X)avec test d’occurrence :échec (le bon résultat logique)fXX désignef(X) lui-mêmesans test (ECLiPSe par défaut)X lié à f(f(f(…))) :un terme cyclique, infini
Sans test d'occurrence, X = f(X) réussit et fabrique un terme cyclique f(f(f(…))), que la logique interdit.

Posons la question du fil rouge :

?- plat(P), prix(P, E), E < 10.

Prolog maintient une liste de buts à démontrer, ici trois. À chaque étape, il applique la résolution SLD (pour « Sélection Linéaire, clauses Définies ») :

  1. il sélectionne le premier but de la liste, le plus à gauche ;
  2. il cherche, dans l’ordre du fichier, une clause dont la tête s’unifie avec ce but, après avoir renommé les variables de la clause (pour qu’elles ne se mélangent pas avec celles de la question) ;
  3. il remplace le but par le corps de la clause (rien, si c’est un fait), et applique la substitution trouvée à toute la liste.

Quand la liste devient vide, tout a été démontré : c’est un succès, et les liaisons des variables de la question forment la réponse. Si aucune clause ne convient au but sélectionné, c’est un échec sur cette voie.

1plat(P)prix(P, E)E < 10clause plat(P1) :- viande(P1) : le but devient le corps2viande(P)prix(P, E)E < 10fait viande(galette_saucisse) : P = galette_saucisse3prix(galette_saucisse, E)E < 10fait prix(galette_saucisse, 9) : E = 949 < 10prédicat prédéfini < : 9 < 10 réussit5liste videsuccès : P = galette_saucisse, E = 9en ambre : le but sélectionné (le premier)
À chaque étape, le premier but (en ambre) est remplacé par le corps de la clause choisie, et la substitution s'applique au reste. La liste vide signale le succès.

À l’étape 2, plusieurs clauses peuvent convenir. Pour plat(P), il y en a deux ; pour viande(P), deux faits. Chaque fois, Prolog prend la première et mémorise qu’il reste des alternatives : c’est un point de choix. Si la suite échoue, ou si vous demandez une autre solution avec ;, il revient au dernier point de choix et essaie la clause suivante, en défaisant les liaisons faites depuis. C’est le retour arrière (backtracking).

L’ensemble des chemins possibles forme l’arbre de recherche : la racine est la question, chaque nœud une liste de buts, chaque branche une clause utilisée, et chaque feuille un succès ou un échec. Voici celui de notre question.

plat(P), prix(P, E), E < 10viande(P), prix(P, E), E < 10poisson(P), prix(P, E), E < 109 < 1010 < 1016 < 1018 < 10prix(galette_saucisse, E), E < 10prix(galette_complete, E), E < 10prix(cotriade, E), E < 10prix(noix_de_st_jacques, E), E < 10clause 1clause 2P = galette_saucisseP = galette_completeP = cotriadeP = noix_…succèsP = galette_saucisse, E = 9échecéchecéchec

Prolog parcourt cet arbre en profondeur d’abord, de gauche à droite : il descend toujours dans la branche la plus à gauche, et ne remonte qu’en cas d’échec ou de demande d’une autre solution. Ici, il trouve le succès tout de suite ; si vous tapez ;, il revient au point de choix de viande, essaie la galette complète (échec, car 10 < 10 est faux), remonte au point de choix de plat, explore la branche des poissons (deux échecs), puis répond qu’il n’y a plus de solution.

L’ordre des branches est l’ordre des clauses dans le fichier, et l’ordre de traitement des buts est la lecture de gauche à droite. Voilà pourquoi, au chapitre 2, les viandes sortaient avant les poissons, et pourquoi ?- entree(E), dessert(D). énumérait les desserts pour chaque entrée : le but dessert(D) est plus profond dans l’arbre, il varie donc plus vite.

Pour voir le moteur au travail, ECLiPSe propose un mode trace, qui affiche chaque étape selon le modèle des boîtes : chaque appel de prédicat est une boîte avec quatre portes.

PorteSignification
CALLon entre dans la boîte : le but est appelé pour la première fois
EXITon sort avec un succès (ECLiPSe écrit *EXIT s’il reste des alternatives)
FAILon sort sur un échec : plus aucune clause ne convient
REDOon rentre dans la boîte pour chercher une autre solution (retour arrière)
viande(P)une boîte par appelCALLEXITFAILREDOon entreune solutionplus de solutionretour arrière :la suivante ?
Chaque appel est une boîte : on y entre par CALL, on en sort par EXIT (une solution) ou FAIL (plus rien), et le retour arrière y rentre par REDO.

Voici une trace simplifiée de notre question, jusqu’à la première réponse, puis après un ; :

CALL plat(P)
CALL viande(P)
*EXIT viande(galette_saucisse)
*EXIT plat(galette_saucisse)
CALL prix(galette_saucisse, E)
EXIT prix(galette_saucisse, 9)
CALL 9 < 10
EXIT 9 < 10
P = galette_saucisse, E = 9 ;
REDO plat(galette_saucisse)
REDO viande(galette_saucisse)
EXIT viande(galette_complete)
*EXIT plat(galette_complete)
CALL prix(galette_complete, E)
EXIT prix(galette_complete, 10)
CALL 10 < 10
FAIL 10 < 10
...

Dans la vraie trace, chaque ligne porte aussi deux numéros : l’identifiant de la boîte et sa profondeur dans l’arbre. Lire une trace demande un peu d’habitude, mais c’est le meilleur moyen de comprendre pourquoi un programme boucle ou répond deux fois la même chose.

Vous avez maintenant deux façons de lire un programme Prolog.

  • La lecture déclarative : chaque clause est un énoncé logique vrai (« P est un plat si P est une viande »). Elle dit quelles réponses sont correctes.
  • La lecture procédurale : chaque clause est une procédure (« pour démontrer plat(P), démontrer viande(P) »), exécutée dans un ordre précis. Elle dit comment, et dans quel ordre, les réponses sont trouvées, et si la recherche termine.

Un bon programme Prolog est juste pour les deux lectures. La lecture déclarative sert à écrire les clauses ; la lecture procédurale sert à les ordonner. Toute la suite du cours consiste à apprendre à concilier les deux.

  • L’unification rend deux termes identiques en liant des variables ; = la déclenche explicitement et n’a rien d’une affectation.
  • Prolog calcule le plus général unificateur, et ne fait pas de test d’occurrence par défaut (X = f(X) réussit à tort).
  • La résolution SLD sélectionne le premier but, l’unifie avec la première clause possible (variables renommées) et le remplace par le corps ; la liste vide signifie succès.
  • Chaque alternative laissée crée un point de choix ; en cas d’échec ou de ;, Prolog revient au dernier point de choix.
  • L’arbre de recherche est parcouru en profondeur d’abord, de gauche à droite ; une branche infinie peut cacher des solutions.
  • La trace montre les portes CALL, EXIT, FAIL et REDO de chaque appel.

Exercice · Unifier à la main

facile

Pour chaque paire, dites si les termes s’unifient et, si oui, donnez le plus général unificateur.

  1. commande(T, cotriade) et commande(table2, P)
  2. galette(X, 9) et galette(complete, 10)
  3. f(X, g(a)) et f(b, Y)
  4. f(X, Y, X) et f(Z, Z, far)
  5. plat(P) et prix(P, 16)
Voir la solution
  1. Oui : T = table2, P = cotriade.
  2. Non : X = complete convient, mais 9 et 10 sont deux constantes différentes.
  3. Oui : X = b, Y = g(a).
  4. Oui. Le premier argument lie X = Z, le second Y = Z, le troisième X = far, donc Z = far. Le pgu est X = far, Y = far, Z = far.
  5. Non : les foncteurs diffèrent (plat/1 contre prix/2, ni le nom ni l’arité ne correspondent).

En code

?- commande(T, cotriade) = commande(table2, P). % T = table2, P = cotriade
?- galette(X, 9) = galette(complete, 10). % No
?- f(X, g(a)) = f(b, Y). % X = b, Y = g(a)
?- f(X, Y, X) = f(Z, Z, far). % X = far, Y = far, Z = far
?- plat(P) = prix(P, 16). % No

Exercice · Dessiner un arbre de recherche

moyen

On considère le programme suivant :

aime(lea, crepes).
aime(lea, cidre).
aime(marc, cidre).
partage(X, Y) :- aime(X, Z), aime(Y, Z), X \= Y.

Dessinez l’arbre de recherche de ?- partage(lea, Y). et donnez les réponses dans l’ordre où Prolog les trouve.

Voir la solution

La question se réécrit en aime(lea, Z), aime(Y, Z), lea \= Y.

  • aime(lea, Z) a deux solutions, donc deux branches.
    • Branche Z = crepes : il reste aime(Y, crepes), lea \= Y. Seul aime(lea, crepes) convient, donc Y = lea, puis lea \= lea échoue.
    • Branche Z = cidre : il reste aime(Y, cidre), lea \= Y. Deux faits conviennent.
      • Y = lea : lea \= lea échoue.
      • Y = marc : lea \= marc réussit. Succès.

L’arbre a trois feuilles : deux échecs et un succès. Prolog répond une seule fois, Y = marc, puis « No » si l’on demande une autre solution. Le test X \= Y sert à éviter la réponse absurde « Léa partage un goût avec elle-même ».

En code

% partage/2 instrumenté : chaque feuille de l'arbre s'affiche
partage_trace(X, Y) :-
aime(X, Z),
aime(Y, Z),
( X \= Y
-> writeln(succes(Z, Y))
; writeln(echec(Z, Y)), fail
).
% ?- partage_trace(lea, Y).
% echec(crepes, lea)
% echec(cidre, lea)
% succes(cidre, marc)
% Y = marc

Exercice · Un unificateur piégé

difficile
  1. Calculez le plus général unificateur de f(X, g(X), Y) et f(h(Z), Y, W).
  2. Que devrait répondre ?- p(X, f(X)) = p(Y, Y). en logique ? Et que répond un système Prolog sans test d’occurrence ? Expliquez.
Voir la solution
  1. Premier argument : X = h(Z). Deuxième argument : g(X) = Y, soit Y = g(h(Z)). Troisième argument : Y = W, donc W = g(h(Z)). Le pgu est X = h(Z), Y = g(h(Z)), W = g(h(Z)). Z reste libre : c’est ce qui rend l’unificateur le plus général.
  2. Le premier argument impose X = Y. Le second demande alors f(X) = X : X devrait être égal à un terme qui le contient strictement, ce qui est impossible pour un terme fini. En logique, l’unification échoue. Sans test d’occurrence, Prolog lie quand même X à f(X) et crée un terme cyclique, f(f(f(…))) à l’infini ; la question réussit donc à tort, et l’affichage du résultat peut même boucler. Pour obtenir le comportement logique, on utilise le prédicat unify_with_occurs_check/2, disponible dans la plupart des systèmes.

En code

?- f(X, g(X), Y) = f(h(Z), Y, W).
% X = h(Z), Y = g(h(Z)), W = g(h(Z))
?- unify_with_occurs_check(p(X, f(X)), p(Y, Y)).
% No : le test d'occurrence refuse X = f(X)
?- p(X, f(X)) = p(Y, Y).
% réussit à tort : X = Y = f(f(f(...))), un terme cyclique