Introduction to Prolog

Consider the following code:

student(gigel).
student(ionel).
student(john).
student(mary).
 
male(gigel).
male(ionel).
male(john).
female(mary).
 
lecture(pp).
lecture(aa).
 
studies(gigel,aa).
studies(mary,pp).
studies(mary,aa).
studies(john,pp).

Atoms

gigel, ionel, john, mary, pp, aa are atoms. Atoms are basic Prolog constructs. Prolog is a weakly-typed language, and primitive types such as String or Integer are part of the language. The afore-mentioned elements have type atom (which is different from String).

Atoms are specific to Prolog, and they are inherited from First-Order Logic, the formalism on which Prolog is based.

Relations

studies, male, female and lecture are relations. In First-Order Logic, a relation over sets $ A_1, \ldots, A_n$ , having arity n, is a subset $ R\subseteq A_1 \times \ldots \times A_n$ . In Prolog, we make no distinction between the types ($ A_i$ ) of each element from a relation. Thus, in Prolog, a relation of arity n is a subset $ R\subseteq Prim$ where $ Prim$ is the set of primitive values from the language. Strings, integers and atoms are such values.

The above program is simply a definition of 4 relations. Mathematically, :$ studies=\{(gigel,aa),(mary,pp),(mary,aa), (john,pp)\}$

Queries

After loading the previous program, the interpreter (with prompt ?-) expects a query from the programmer. Formallly, a query is a conjuction of literals, where each literal is of the form: $ R(t_1,\ldots, t_n)$ and $ t_i$ are terms. Instead of pursuing a formal approach, we illustrate examples of queries, for our above program (in what follows, we prepend ?- to Prolog code to remind the reader we are performing a query):

  • ?- student(gigel)
  • ?- student(gigel),studies(gigel,aa) (, should be read as logical and)
  • ?- student(X):
    • X is a variable. All tokens starting with a capital letter are (treated as) variables.
    • Variables can be bound to any value from the program. Variables do not have a type.
    • When a variable is present in a query, Prolog will try to find all values for that variable which satisfy the query. By typing ; we can iterate over all such values.
  • ?- student(X), lecture(X,aa)

Clauses

As shown so far, programs behave as databases (relations are essentially tables) and via the interpreter, we can use queries to interrogate the database.

However, a Prolog program is far more than just a database. We can also define new relations based on existing ones, without exhaustively enumerating all members. For instance, let us add to our initial program, the relation all male students which share a lecture with a female student:

hasFemaleColleague(M) :- student(M), male(M), studies(M,L), studies(F,L), female(F).

The above clause or rule can be translated to First-Order Logic (FOL) where it can be read easier:

$ student(M) \wedge male(M) \wedge studies(M,L) \wedge studies(F,L) \wedge female(F) \implies hasFemaleColleague(M)$

Notice the direction of the implication, as well as the usage of , (logical conjunction), :- (implication) and .(the end of a clause).

Re-satisfaction

Let us define a new property (1-ary relation) which describes those lectures which have at least two students. The first attempt:

lectureOfTwo(L) :- lecture(L), studies(X,L), studies(Y,L).

is incorrect, as X and Y may be bound to the same student. Another attempt is:

lectureOfTwo(L) :- lecture(L), studies(X,L), studies(Y,L), X \= Y.

however following the interrogation -? lectureOfTwo(L), pp and aa are prompted twice. To understand this, let us write our relation in FOL:

$ lecture(L) \wedge studies(X,L) \wedge studies(Y,L) \wedge X \neq Y \implies lectureofTwo(L)$

Formally, FOL sentences require that all variables are within the scope of a quantifier. Thus, to be more exact, we should write:

$ \forall L( lecture(L) \wedge \exists X \exists Y (studies(X,L) \wedge studies(Y,L) \wedge X \neq Y) \implies lectureofTwo(L))$

Notice the scope of each quantification, as a change in the parentheses may modify the meaning of the sentence.

Prolog does not interpret the sentence as in FOL. Instead, it finds all combinations of values for L, X and Y that satisfy the left-hand side (LHS) of the implication. In our example, these are:

pp, mary, john
pp, john, mary
aa, gigel, mary
aa, mary, gigel

and then reports the values of L for each such combination. For this reason, we observe that both aa and pp are reported twice.

We can verify this by querying: -? lecture(L), studies(X,L), studies(Y,L), X \= Y.

Proof trees

We briefly discuss the concept of proof trees which will be approached in more detail later on, when discussing resolution. To introduce it, consider the queries:

-? female(X), studies(X,L).

and

-? studies(X,L), female(X).

Viewed as FOL sentences (with appropriate quantification), the queries are the same. However, in answering those queries, Prolog searches its knowledge-base (database) differently.

For the first query:

Find X which satisfies female(X):
  X = mary,
  Find L which satisfies studies(mary,L):
  L = pp,
    report X = mary, L = pp.
  L = aa
    report X = mary, L = aa.

And for the second:

Find X,L which satisfy studies(X,L):
  X = gigel, L = aa
    female(gigel) could not be established, continue search
  X = mary, L = pp
    female(mary) is true, report X = mary, L = pp.
  X = mary, L = aa
    female(mary) is true, report X = mary, L = aa.
  X = john, L = pp
    female(john) could not be established, search finishes.

This simplistic example shows that the complexity of a Prolog program (execution), may be affected by the order in which literals appear in a clause. We will later see that even the output may be affected by this order.

Instantiating variables

The question does Prolog permit side-effects is difficult to answer. On one hand:

  • side-effects are present: variables can be instantiated. For instance, when resolving -? female(X), studies(X,L), initially, the variable X is unbound. However, once the part female(X) has been satisfied, X is bound to mary.
  • however, side-effects are inherently limited: during the satisfaction of a query/clause, a bound variable cannot be unbound. In our example, X remains permanently bound to Mary. X can only become free upon resatisfaction of a query/clause.

Variables can be (partially) bound several ways:

  • via unification: e.g. X = 2 is not the conventional assignment - = designates unification. We illustrate this via several examples:
    • -? X = 2, Y = X.
    • -? X = Y + 2, Z = 2 + Y, X = Z. - here note that expressions are treated as such - no computation (of 2+2) is performed.
  • conventional assignment is performed using the is operator:
    • -? X is 2+2.
  • comparison is performed using the operator =:=:
    • -? X is 2+2, X =:= 4.

Representations - lists

The idea

One distinguishing feature of FOL is that it allows representing objects which programmers use, such as lists, trees, graphs, matrices, etc. Prolog inherits this feature. In Prolog, we can enroll any term in a relation, including other - composite terms. At the same time, variables can be bound to terms. We illustrate this via an example:

-? X = f(Y,Z), Y = g(W), W = Z, Z = 2.

Here, X is bound to f(g(2),2). Note that f is a binary relation and g(2) (a term) is enrolled with 2 in this relation.

We can exploit this feature to represent lists as follows:

-? L = cons(1, cons(2, cons(3, void))).

Note here that:

  • cons is a binary relation. A list is an instance of such a relation.
  • it enrols an element (the head) together with a relation instance describing the tail.

We can define helpful relations over lists:

head(L,H) :- L = cons(H,_).
tail(L,T) :- L = cons(_,T).
 
empty(L) :- L = void.
nonempty(L) :- L = cons(_,_).

_ is used much in the same way as in Haskell - to designate an arbitrary term whose name is unimportant for the programmer.

The basic observation here is that:

  • we use queries over relations in order to perform computations on lists
  • we assume variable L is bound and stands for the input
  • we assume variables H and T are unbound, and stand for the output. They will be bound to the result, or results, if there are several.
  • example: -? L = cons(1,cons(0,void)), head(L,H).
  • essentially, this is a programmer convention. For instance, the query: -? head(L,1). is valid, and will produce: L = cons(1, _G766). Here, _G766 is an anonymous variable which is not bound - it can be anything. Basically, what Prolog is telling us is that L must be cons of 1 into something.

We can add new relations such as len:

len(L,R) :- empty(L), R = 0.
len(L,R) :- nonempty(L), tail(L,T), len(T,R1), R is R1 + 1.

Note that, we have defined two clauses which treat terms of different types for L. We can rewrite len in several ways, which illustrate Prolog's flexibility:

len(void,R) :- R = 0.
len(cons(_,T),R) :- len(T,R1), R is R1 + 1.

We can perform unification implicitly in the left-hand side of the implication. We can also do it for R, which results in:

len(void,0).
len(cons(_,T),R) :- len(T,R1), R is R1 + 1.

Actual Prolog lists

In Prolog (as in Haskell), lists are an essential programming construct. In the previous section, we have illustrated the principle governing list representations (as relations). Lists are already implemented in Prolog and helpful relations (or predicates) are already defined for them.

Real Prolog lists are defined as follows:

[] % the empty list
[H|T] % 'cons' of H into T
[1,2,3] % a list with integers 1,2 and 3

Thus, in order to work on Prolog Lists, we could rewrite len as follows:

len2([],0).
len2([_|T],R) :- len2(T,R1), R is R1 + 1.

List membership

We start with the following implementation of the predicate contains which verifies if an element is part of a list:

contains(E,[E|_]).
contains(E,[H|T]) :- E \= H, contains(E,T).

Testing our predicate yields:

?- contains(1,[1,2,3]).
true 
false.

The second false seems puzzling and inconvenient. We can trace it down if we build the proof tree for contains(1,[1,2,3]):

contains(1,[1|_]) is satisfied. true is reported. Prolog continues re-satisfaction:
contains(1,[1|[2,3]]) may be satisfied:
  1 \= 1 is not satisfied, hence Prolog reports false. Re-satisfaction ends.

We shall examine a more efficient way of defining contains in a future lecture.

List reversing

We start with the following - accumulator-based implementation:

rev([],R,R).
rev([H|T],Acc,R) :- rev(T,[H|Acc],R). 
reverse(L,R) :- rev(L,[],R).

While -? reverse([1,2,3],L). works just as expected, reverse(L,[1,2,3]). reports the correct result and then loops. First of all, we note that this query violates our assumption the the first list is the input, while the second - the output. Again, to understand the behaviour, we build the proof tree:

to satisfy reverse(L,[1,2,3]), we must satisfy rev(L,[],[1,2,3]).
the first clause of rev cannot be satisfied since [] and [1,2,3] do not unify.
during the satisfaction of the second clause L = [H1|T1] (which is possible since L is unbound). 
  We attempt to satisfy rev(T1,[H1|[]],[1,2,3]):
    the first clause of rev cannot be satisfied ([H1] and [1,2,3] do not unify)
    while satisfying the second clause T1 = [H2|T2].
      We attempt to satisfy rev(T2,[H2|[H1|[]]],[1,2,3]).
         the first clause of rev cannot be satisfied ([H2,H1] and [1,2,3] do not unify)
         while satisfying the second clause T2 = [H3|T3].
            We attempt to satisfy rev(T3,[H3|[H2|[H1|[]]]],[1,2,3])
              the first clause of rev CAN be satisfied ([H3,H2,H1] unify with [1,2,3]). The result is reported
              we attempt re-satisfaction via the second clause: T3 = [H4|T4]
                the first clause of rev cannot be satisfied ([H4,H3,H2,H1] and [1,2,3]) do not unify).
                ....
                the process repeats until the stack becomes full.

Take-away

  • Building and understanding proof trees is an essential tool for learning to program safely in Prolog.
  • It is important to keep a convention regarding input and output variables when writing a Prolog program. Sometimes, such a convention can be more flexible (contains will be an example), however, the programmer must make sure this is indeed possible.