A Declarative Debugger for a Logical-Functional Language

Lee Naish^{1} Tim Barbour^{2}

Department of Computer Science University of Melbourne Parkville, Victoria 3052 Australia

Abstract

Logic and functional programming languages have many advantages and there is a growing trend to develop languages which incorporate both these paradigms. One of the disadvantages of such languages is that the execution mechanisms are so complex that traditional debugging methods are difficult to use. Declarative debugging techniques have been successful applied to Prolog and more recently functional languages. They allow programs to be debugged using only knowledge of the declarative semantics, hiding the details of the execution from the programmer. In this paper we describe a declarative debugger for a combined logic and functional programming language, NUE-Prolog. The debugger combines both existing and new algorithms. The main new algorithm is for declarative debugging of functions which for some inputs are not well-defined (typically resulting in a runtime error). The algorithms can easily be adapted to other logic and functional languages.

Keywords: logic programming, functional programming, NUE-Prolog, declarative debugging

1 Introduction

An important contribution of artificial intelligence research has been the design of new paradigms for programming languages. Declarative languages based on the functional and logic programming paradigms are widely used for symbolic computation. Features such as pattern matching, higher order functions and lazy evaluation in functional languages and unification, multiple input-output modes, backtracking and coroutining in logical languages allow very high level “symbol crunching” programs to be written. Unsurprisingly, there has been a great deal of work on combining these two closely related paradigms. An extensive survey of this area can be found in [Han94]. In this paper we use one such language, NUE-Prolog [Nai91], but it should be possible to adapt our work to other languages in this class.

The complex execution models necessary to implement the high level features of these languages can make the traditional style of debugging programs very difficult. Mechanisms such as break points and tracing are very low level and produce output which can be very difficult to follow. However, one of the great advantages of these languages is they have declarative as well as procedural semantics. Programs in functional and logic programming languages can be thought of as defining functions and predicates in a declarative way. In a sense they specify what problem is to be solved rather than how it is solved (though a program together with the execution mechanism of the language implicitly defines how the problem can be solved).

Declarative debuggers use the declarative semantics of programs to find bugs while hiding the execution details from the programmer. Algorithms for declarative debugging were first devised for Prolog. More recently they have been adapted to diagnose some bugs in functional languages. In this paper we describe a debugger for NUE-Prolog, a combined logic and functional programming language. We

  1lee@cs.mu.OZ.AU, http://www.cs.mu.oz.au/~lee
  2trb@cs.mu.OZ.AU, http://www.cs.mu.oz.au/~trb

believe this is the first declarative debugger for this class of languages. As part of this research we have developed algorithms to diagnose a wider variety of bugs in functional programs. Specifically, we give a declarative diagnosis algorithm for debugging functions which are not well-defined for some inputs (the typical symptom is a runtime error). We show how these bugs in functional code can be related to bugs in Prolog code.

The structure of the rest of this paper is as follows. We first give a brief overview of the language we are debugging, NUE-Prolog, and declarative debugging in general. Next we discuss the details of diagnosing wrong answers in Prolog, functional code and NUE-Prolog. Diagnosis of missing answers is then discussed. This leads to discussion of analogous bugs in functional programs, functions which are not always well-defined, and algorithms for diagnosing these bugs. We then describe how the diagnosis algorithms for the different components of NUE-Prolog are combined into a single debugger and conclude.

2 NUE-Prolog

NUE-Prolog[Nai91] extends NU-Prolog to include evaluable functions. The extensions allow particular function symbols to be defined as evaluable functions and provide a mechanism for their evaluation; otherwise the language is identical to NU-Prolog. The following example shows the main features of NUE-Prolog code:

        % append two lists
concat([], A) = A.
concat(A.B, C) = A.concat(B, C).
        % head of a list
hd(A.As) = A.
        % tail of a list
tl(A.As) = As.
        % take the first N items of a list
?- lazy take/2.
take(N, As) = (N > 0 ? hd(As).take(N - 1, tl(As)) : []).
        % addition and subtraction functions
        % quote/1 stops its argument being treated as an evaluable function
A + B = C :-
        C is quote(A + B).
A - B = C :-
        C is quote(A - B).
        % succeeds if F is a factor of N
factor(F, N) :-
        (if N > F then
                factor(F, N - F)
        else
                N = F).
        % map (apply function to each element of list)
map(F, []) = [].
map(F, A.B) = fapply(F, A).map(F, B).

A function is defined by a set of mutually exclusive equations. An equation is a clause that has =/2 as the top-level functor in its head; it has the form Lhs = Rhs :- Body. Body is an optional goal that must be true for the equation to hold, thus providing a means for functions to call logical code.

An expression of the form (Cond ? A : B), where Cond is a logic-goal, is a conditional expression. If Cond is true, then the value of the expression is equal to A, otherwise it is equal to B. This provides another way for functions to call logical code.

Predicates may contain function symbols in their arguments. If one of these is an evaluable function then it is replaced by the result of its evaluation, prior to the predicate being called. This is the means by which predicates call functional code.

NUE-Prolog is implemented by translation into NU-Prolog. Functions are flattened into Prolog code which performs the same computation. A function call f(A_{1}, A_{2}, ...A_{n}) is flattened to a predicate call f′(A_{1}, A_{2}, ...A_{n}, Y), where Y is a new variable introduced to take the result of the function. An equation f(A_{1}, A_{2}, ...A_{n}) = Rhs : −B is flattened to the Prolog clause f′(A_{1}, A_{2}, ...A_{n}, Y) : −B, E where E is a conjunction of extra goals created by flattening the function calls in Rhs.

Functions may be declared to be lazy, and when this is done the translation is more complicated. Lazy functions initially return ‘lazy closures’, which contain the information necessary for evaluation. Any function needing the result of a lazy function has extra clauses inserted to cause evaluation. Expressions that are used by logical code are completely evaluated first. For the purpose of this paper it is not necessary to go into the details.

3 Declarative debugging

Declarative debugging (also called algorithmic debugging) was first introduced by Shapiro [Sha83] for debugging Prolog programs. The idea is to use the declarative semantics of Prolog programs to diagnose errors. When a Prolog program is written the programmer intends certain calls to succeed and others to fail. The intended declarative semantics is a formalisation of this. The program is treated as a formula in logic and an intended interpretation assigns the value true or false to each atom of the program [Llo84].

For example, app([a,b],[c,d],[a,b,c,d]) would be assigned true whereas app([a,b],[c,d],[a,b])

would be assigned false, assuming app is interpreted as the predicate for appending lists. Declarative diagnosis algorithms analyse a computation which produced an incorrect result and isolate the bug to a single clause or procedure in the program. The search for the bug is guided by asking an oracle (we will assume this is the programmer in this paper) about the intended declarative semantics of the program.

There are two ways in which Prolog computations can have an incorrect result. First, a goal may return a wrong answer. Second, a goal may have a missing answer (for example, it may incorrectly fail). Diagnosis algorithms for these two cases are normally discussed separately. Wrong answer diagnosis (usually) isolates the bug to a single incorrect clause instance. This is an instance of a clause which was used to derive the wrong answer (the head of the clause was a call to the procedure). Each call in the body of the clause is correct (valid in the intended interpretation) but the head is not. If the clause is treated as a logical implication it is false in the intended interpretation. Missing answer diagnosis (usually) isolates the the bug to a single procedure. There is a call to the procedure which is missing an answer but the execution of all the calls in the bodies of all matching clauses in the procedure was correct. The call to the procedure is known as an uncovered atom.

Wrong and missing answer diagnosis algorithms are not completely independent. If the program contains negation, for example, a wrong answer in a sub-computation may lead to a missing answer at the top level and vice versa. The algorithms call each other recursively to deal with such cases. Also, many program errors can result in both uncovered atoms and incorrect clause instances. Typically if a program returns a wrong answer it also misses the correct answer and a wrong answer in a subcomputation often results in a missing answer at the top level. Since incorrect clauses instances are more specific than uncovered atoms (they refer to a single clause rather than a whole procedure) they are the preferred kind of bug to report. The better missing answer diagnosis algorithms perform wrong answer diagnosis for sub-computations.

Recently the algorithms for wrong answer diagnosis have been adapted to debug functional programs [NF92][Nai93]. Although the functional programming community does not stress the importance of declarative semantics (formal semantics is normally based on rewriting rather than model theory), functional programs do have an intended interpretation. For example, concat([a,b],[c,d])=[a,b,c,d] is true whereas concat([a,b],[c,d])=[a,b] is false, assuming concat is interpreted as the function for concatenating lists. Lazy evaluation is the most difficult feature to deal with.

4 Wrong answer diagnosis

We use the wrong answer diagnosis algorithm described by Naish[Nai93], which is summarized in the next three sections. Finally, we briefly describe how it is used to debug mixed logical and functional code.

4.1 Prolog

The algorithm below is called with a successful atom that is not valid in the intended interpretation, and returns an incorrect clause instance. It makes use of three main predicates: successful_clause(H, B) is true if H:-B is the representation of a clause instance whose body has succeeded; c_member(A, B) is true if B is the representation of the body of a successful clause instance (or goal) and A is the representation of an atom in it; valid(A) is true if the atom A is true in the intended interpretation. This is implemented by querying an oracle.

        % from representation of incorrectly successful atom
        % return incorrect clause instance
  wrong_atom(A, Bug):-
        not valid(A),
        successful_clause(A, G),
        (if some [Bug1] wrong_rhs(G, Bug1) then
                Bug = Bug1
        else
                Bug = (A :- G)
        ).
        % as above, but for an arbitrary goal (eg, RHS of a clause)
  wrong_rhs(G, Bug):-
        c_member(A, G),
        wrong_atom(A, Bug).

The algorithm may be understood as follows. Let A be a wrong atom, and let it be derived from G by a successful clause instance A:-G. Either the atoms in G are all valid or there is an invalid atom among them. If there is an invalid atom, then it is debugged by a recursive call to wrong_atom. Otherwise A has been derived from atoms which are all valid, which implies the clause instance leading from G to A is incorrect.

Diagnosing a wrong answer can be thought of as searching the proof tree. Given an atom A derived by a clause instance A:-G, its proof tree consists of a node containing A and having one subtree for each of the atoms in G. To find an incorrect clause instance, we search the tree for an invalid atom whose children are all valid. The example below shows a buggy version of the standard append predicate:

% should be: app([], As, As).
app([], As, []).
app(A.As, Bs, A.Cs):-
        app(As, Bs, Cs).

When used to append two lists, the code produces the proof tree represented below, where A-Cs represents a node A and a list Cs of its children:

app([a, b], [c, d], [a, b]) -
        [app([b], [c, d], [b]) -

                [app([], [c, d], []) -
                        []
                ]
        ]

This is the resulting debugging session:

Please enter the goal you wish to debug: app([a, b], [c, d], [a, b]).
Is this atom valid: app([a, b], [c, d], [a, b])  ? (y/n) n
Is this atom valid: app([b], [c, d], [b])  ? (y/n) n
Is this atom valid: app([], [c, d], [])  ? (y/n) n
Incorrect clause instance:
app([], [c, d], []).
Matching clauses:
app([], A, []).

4.2 Strict functions

The same algorithm is used for debugging both logical and functional code. The only difference is the representation used for the computations. The different representations require different implementations

of successful_clause, c_member and valid.

For consistency we will also refer to the representation of a functional computation as a proof tree. In fact a functional computation can be thought of as a proof in an equational theory.

Each function call (and result) in a functional computation is associated with a node in the proof tree of that computation. The node has one child for each function call in the right hand side of the equation matching the original function call. For further details of the data structure used see Naish[Nai93].

In the following example concat is the functional version of append, and append can be thought of as the flattened version of concat (as in append the first clause should evaluate to As instead of []):

% should be: concat([], As) = As.
concat([], As) = [].
concat(A.As, B) = A.concat(As, B).
Please enter the goal you wish to debug: concat([a, b], [c, d]) = [a, b].
concat([a, b], [c, d]) = [a, b].
Is this atom valid: concat([a, b], [c, d]) = [a, b]  ? (y/n) n
Is this atom valid: concat([b], [c, d]) = [b]  ? (y/n) n
Is this atom valid: concat([], [c, d]) = []  ? (y/n) n
Incorrect clause instance:
concat([], [c, d]) = [].
Matching clauses:
concat([], A) = [].

4.3 Lazy functions

In lazy functional programs, if the value of a particular sub-expression is not needed it will not be evaluated. When this happens the proof tree of the computation contains lazy closures. A closure in an argument of a function call in the proof tree represents an expression whose value was not needed in the computation. Since the value was not needed it follows that if the function result is correct then it must be correct for all possible values of that expression. When questioning the user, this can be expressed by replacing the closure with a universally quantified variable. A closure in the right hand side of an equation in the proof tree i.e. in a function result, represents a function that was not fully evaluated. This can be expressed by replacing the closure with an existentially quantified variable.

The following buggy code (take takes one element too few) and debugging session illustrate both existential and universal quantification (+,-, hd and tl are as defined earlier):

?- lazy take/2.
% should be: take(N, As) = (N > 0 ? hd(As).take(N - 1, tl(As)) : []).
take(N, As) = (N > 1 ? hd(As).take(N - 1, tl(As)) : []).
        % returns the list of all integers greater than or equal to N
?- lazy ints_from/1.
ints_from(N) = N.ints_from(N + 1).

In the questions below, variables quantified by some are existentially quantified, and all other variables are implicitly universally quantified (questions about +, -, hd, tl and > have been omitted):

Please enter the goal you wish to debug: take(3, ints_from(0)) = [0, 1].
Is this atom valid: some [A] ints_from(0) = [0, 1|A]  ? (y/n) y
Is this atom valid: take(3, [0, 1|A]) = [0, 1]  ? (y/n) n
Is this atom valid: take(2, [1|A]) = [1]  ? (y/n) n
Is this atom valid: take(1, A) = []  ? (y/n) n
Incorrect clause instance:
take(1, A) = (1 > 1 ? [hd(B)|take(1 - 1, tl(B))] : []).
Matching clauses:
take(A, B) = (A > 1 ? [hd(B)|take(A - 1, tl(B))] : []).

4.4 NUE-Prolog

We have described debuggers for diagnosing wrong answers in logical code, and in functional code. It is straightforward to combine them to produce a debugger that can diagnose wrong answers in mixed logical and functional code. The proof trees used are produced by augmenting the generated NU-Prolog code, so that each computation returns a representation of its proof. The representations we use for proof trees allow mixed logical and functional code to generate a combined proof tree. A logical atom may have function calls among its children and conversely. When a function calls logical code, the proof tree node for that function has a logical sub-tree. Similarly when a predicate calls functional code, the proof tree node for that predicate has a functional sub-tree. We apply the same algorithm as before, using versions of successful_clause, c_member and valid that can operate on the combined proof tree. More details and examples can be found in [BN94].

5 Missing answer diagnosis

5.1 Prolog

Diagnosing missing answers in Prolog programs is significantly more difficult than diagnosing wrong answers. Diagnosing a wrong answer involves searching an incorrect proof tree. For missing answers there is no proof tree; there is a complex failed attempt to build a proof tree. An alternative way to compare wrong and missing answer diagnosis is to consider SLD trees [Llo84]. A wrong answer corresponds to a single incorrect branch in an SLD tree whereas a missing answer corresponds to a missing branch in the tree (so the diagnosis algorithm may have to examine the whole tree rather than a single branch). Treatments of missing answer diagnosis normally do not refer to either kind of tree. They rely on intuition about the computation rather than more abstract notions.

This section gives a brief overview of the three main approaches to missing answer diagnosis. For further details we refer the reader to [Nai92]. We will illustrate the three different styles of missing answer diagnosis using the following clause as an example:

p(X,Y):-q(X,Z),r(Z,Y).

The first style of diagnosis uses a valid failing atom at each stage of the search (contrast this with wrong answer diagnosis which uses a successful atom which is not valid). There is often an implicit assumption that the atom is ground. Suppose it is known that p(a,b) is valid but fails. The debugger must determine if the bug is in the computation of q or r or in the definition of p. By matching with the head of the clause we obtain the atom q(a,Y). If the bug is in q then there is some instance of this atom which is valid but fails. In order to find such an instance, the user is asked for valid instances of the atom and the debugger checks each instance to see if it fails. If a failing instance is found the debugging algorithm can be invoked recursively to find the bug in q, thus narrowing down the search. These instance questions can be a considerable burden on the user. Determining the correct answers to calls can be difficult and typing them in can be time consuming.

Conjunctions in the bodies of clauses are dealt with by asking the user about the different conjuncts individually. Ultimately the algorithm narrows down the search to a single uncovered atom. This is a valid failing atom that is not the head of any clause instance with a valid body. Either one of the matching clauses is wrong or the procedure needs an extra clause to deal with this call. This kind of bug can be defined as follows:

bug(atom(A)) :-
    valid(A),
    all B not (is_clause(A, B), valid(B)).

The second style of diagnosis uses a satisfiable failing atom at each stage of the search (a valid failing atom is a special case of this). Suppose it is known that p(a,V) is satisfiable but fails. If the bug is in q then q(a,Y) must be satisfiable. Furthermore, it is very likely that q(a,Y) also fails (some debuggers have assumed that this is the case). The user need only be asked if q(a,Y) is satisfiable for the search to be narrowed. These satisfiability questions require less thought and less typing (just y or n) than instance questions. Unfortunately, satisfiability questions alone are not sufficient if the computation can be nondeterministic. It may be that q(a,Y) actually succeeds (even with multiple answers) but still misses the answer which leads to r and therefore p succeeding. Nevertheless, it is possible to weaken the definition above: an uncovered atom is a satisfiable failing atom which does not subsume the head of any clause instance with a valid body.

bug(atom(A)) :-
    satisfiable(A),
    all [H,B] not (subsumes(A, H), is_clause(H, B), valid(B)).

The third style of diagnosis uses an atom which returns an incomplete set of answers at each stage of the search (a satisfiable failing atom is a special case of this). This is the most general class of missing answers. Suppose it is known that p(a,V) is “incomplete”. If the bug is in q then q(a,Y) must be incomplete. The debugger can print the set of computed answers to this call and ask the user if it is incomplete. Like satisfiability questions, incompleteness questions only require a yes/no answer. Nondeterminism is supported; it just results in questions which are more complicated. Unfortunately, incompleteness questions break down in the presence of coroutining. It may be that p failed due to a coroutining execution of q and r without either one being completely evaluated. If q(a,Y) is evaluated in isolation it may have an infinite number of answers, making an incompleteness question impossible. Diagnosing missing answers in coroutining Prolog programs is still an area for research.

All three styles of diagnosis can be enhanced with wrong answer diagnosis. First, if a negated atom is missing an answer then the atom has a wrong answer and should be diagnosed accordingly. Second, since wrong answer diagnosis is simpler and results in more specific and natural bugs being reported, it is often advantageous to check for wrong answers within missing answer diagnosis.

5.2 Strict functions

The idea of missing answers does not fit well with the functional programming mind-set and declarative debugging of the functional analogue of missing answer diagnosis has not previously been explored to our knowledge. The analogue of a predicate which can fail is a function which is not total (that is, welldefined for all possible inputs). An example is the function hd, which returns the head of a nonempty list. Its value is not defined for the empty list and the call hd([]) would simply fail in NUE-Prolog. A more conventional functional language would report a runtime error such as "program error: hd []". We will refer to this kind or error as an undefined call.

Diagnosing such errors is nontrivial. The bug is typically not in the “failing” function (hd in this case). It is obvious that any function which (indirectly) called the failing function may be the cause of the error. Furthermore, any function which produced any input to any function which (indirectly) called the failing function may be the cause of the error. For example, if a function incorrectly returned the empty list this may ultimately be passed to hd. We adapt the declarative debugging techniques for Prolog to diagnose such bugs.

Diagnosing undefined calls (failure) in strict NUE-Prolog functions is actually simpler than diagnosing failure in Prolog, for several reasons:

• Functions return at most one answer.

• Input-output modes and dataflow are simple; execution is like left to right Prolog execution rather than coroutining.

• A function call can match at most one equation

• Undefined calls always indicate a bug; failure is not a normal part of execution.

The first two points mean the algorithms based on satisfiability or incompleteness questions and enhanced with wrong answer diagnosis can be adapted to diagnose strict functional programs. The third point means that more specific error messages can often be given. If we have an “uncovered atom” which matches with an equation then that equation must be wrong. The fourth point can be used as a heuristic to change the search strategy of the debugger, often significantly reducing the number of questions asked. A debugger based on these observations is described in [BN94]. We will not discuss this debugger further as it can not always diagnose bugs in lazy code, the topic we discuss next.

5.3 Lazy functions

The reason that the missing answer diagnosis algorithms for prolog cannot easily be extended to deal with undefined calls of lazy functions is that there is an additional cause of errors in lazy code. We illustrate this with the following example, assuming all functions are lazy.

        % check if list is empty:
        % returns 1 for empty, 0 for non-empty
e([]) = 1.
e(x.Xs) = 0.  % x.Xs should be X.Xs
                 % f should return 0 but
f = e([hd([])]). % results in an undefined call

Suppose we need to evaluate f. This requires e([hd([])]) to be evaluated. The first equation for e does not match and when matched with the second equation hd([]) matches with x. Thus we must evaluate hd([]), which is undefined. Neither of the two causes of errors mentioned previously apply. The only function which recursively called hd is f, which is correct, and no function returned a wrong result.

The cause of the undefined call is the second equation for e forcing the element of the list to be evaluated. The intended behaviour of the program is that e should not need the elements of the list and hence, due to laziness, hd([]) should never be evaluated. In [BN94] we refer to this class of bugs as unreasonable demands. In the next section we show how unreasonable demand bugs can be related to a generalised definition of uncovered atoms. Given this definition it may be possible to modify the missing answer diagnosis algorithms so as to diagnose such bugs. However, the debugging algorithm we have implemented is actually based on the wrong answer diagnosis algorithm. We describe this next.

The theory of lazy functional programming introduces a special value ⊥ (called “bottom”) to represent the value of expressions which are otherwise undefined (see [BW88]). For example, the value of hd([]) is ⊥. In fact, any functional expression whose evaluation has an undefined call has value ⊥. For strict functions, any expression containing ⊥ equals ⊥. For lazy functions this is not the case. For example, e([⊥]) equals 0 in the intended interpretation (though it evaluates to ⊥ due to the bug). Expressions which incorrectly result in undefined calls can be thought of as incorrectly evaluating to ⊥. Thus we can adapt the wrong answer diagnosis algorithm.

There are four main extensions we need to make to the previous lazy functional wrong answer diagnosis algorithm. The first is representing proof trees containing ⊥. Conceptually this is no problem — ⊥ is just another value. The implementation is somewhat more difficult. Our initial implementation uses a combination of interpretation and re-execution of compiled code to redo the computation up to the point of failure. It may be more efficient to “catch” the failure as soon as it occurs but there is no mechanism in Prolog to do so. One possibility would be to modify the way functions are transformed so failure can be detected immediately. This could make successful execution significantly less efficient however. Another complication is that a function may partially construct a result before failing. Our implementation keeps the partially constructed result in the computation tree but when asking questions this information is not displayed.

The phrasing of questions is the second extension, and there is some flexibility here. The simplest way to phrase questions is with explicit mention of ⊥ (we mention an alternative in the next section). Our implementation uses the constant $failed to repesent ⊥, and we will use this in the questions. Debugging f results in the following dialog:

Is this atom valid: f = $failed  ? (y/n) n
Is this atom valid: hd([]) = $failed  ? (y/n) y
Is this atom valid: e([$failed]) = $failed  ? (y/n) n

At this point the debugger can conclude that e([$failed]) is the left hand side of an incorrect equation instance. The third extension is the displaying of an “incorrect equation instance”. For undefined calls there may be no matching equation so an alternative error message should be printed. Ideally, some unreasonable demand bugs should be treated specially. If the expression failed to match an equation because of a $failed, that equation must be the cause of the unreasonable demand. For example, e([$failed]) fails to match because $failed does not match with x. It would be helpful to display the equation with x highlighted as the bug (we have not yet implemented this enhancement).

The final extension is to handle expressions which are not evaluated because the computation failed (or aborted with an error). These can be treated in the same way as expressions which were not evaluated due to laziness. They can appear in the arguments to functions but not in the result (as the result is simply ⊥). Thus they can lead to universally quantified variables in questions. For example, consider the evaluation of e(hd([]).g), where the undefined call occurs before g is evaluated. The following questions would be asked:

Is this atom valid: hd([]) = $failed  ? (y/n) y
Is this atom valid: e($failed.X) = $failed  ? (y/n) n

The algorithm for diagnosing undefined call in lazy functional computations can be summarised as follows. It is the same as the algorithm for diagnosing wrong answers in lazy functional computations [Nai93] with the following modifications.

1. For computations which result in undefined calls, the computation tree should contain a representation of ⊥ for the result.

2. Expressions which are never evaluated due to an undefined call are treated in the same way as expresions which are never evaluated due to laziness.

3. Questions involving ⊥ may be rephrased as long as their meaning is preserved.

4. After the algorithm has isolated the bug to a single call, the way the bug is displayed may be different.

5.4 Prolog revisited

It is possible to rephrase the questions in the functional debugger in a way which avoids explicit mention of ⊥ or $failed and makes it easier to compare with Prolog. For the simple case above (without the unevaluated call to g) the questions would be as follows.

Is  f  well-defined? y
Is  hd([])  well-defined? n
Is  all [Y] e([Y])  well-defined? y

By asking if an expression is well-defined we are asking if it does not equal ⊥. The last question asks if e([Y]) is well-defined for all Y. If e([⊥]) is well-defined then e([Y]) must be well-defined for all Y. The converse is also true if we assume Y can range over all terms (in an untyped language this is reasonable; in a strongly typed language this style of question should probably be avoided).

In the example with the unevaluated call to g the following question would be asked:

Is  some [X] all [Y] e(Y.X)  well-defined? y

Note that X is now existentially quantified because the question has been negated (to understand the question it may be helpful to replace Y with ⊥).

The flattening transformation can clarify how this method of debugging functional programs is related to debugging logic programs. Asking if a functional expression is well-defined is essentially the same as asking if the flattened version of the expression (an atom with one more argument, R, say) should succeed. That is, for some R, the atom is valid. Consider the flattened version of e:

e([], 1).
e(x.Xs, 0).  % x.Xs should be X.Xs

Given a query such as e([A],R) the programmer would expect an answer which did not instantiate A. Instead, the binding A=x is returned. This conflicts with the intended interpretation in which the answer R=0 is true for all A. One could imagine a debugger which diagnosed this unexpected behaviour with the following question:

Is  some [R] all [A] e([A], R)  valid? y

This formula, ∃R∀A e([A], R), is precise indication of the bug. However, it is not covered by either of the definitions of uncovered atoms. The first definition used valid, implicitly universally quantifying all variables. The second definition used satisfiable, implicitly existentially quantifying all variables. To describe this bug (and unreasonable demand bugs) both existential and universal quantifiers are needed. The previous two definitions of uncovered atoms can be generalised as follows. A formula ∃X_{1}∃X_{2}...∀Y_{1}∀Y_{2}...A, where A is an atom, is uncovered if it is valid but there does not exist a substitution θ = {X_{1}/t_{1}, X_{2}/t_{2}...} and formula B such that Aθ : −B is a clause instance and B is valid.

The reson why this more general definition has not been needed is due to the lack of support for universal quantification in Prolog systems. If a query such as all [A] e([A],R) could result in the answer R=0 (or failure for the buggy version of the program) the extra generality would be needed. Very few logic programming systems are that powerful. NU-Prolog, which allows the query to be expressed at least, will “flounder” and neither compute a result or fail. The way the bug can be found with existing systems is as follows. The goal e([A],R) returns the binding A=x and the error is noticed. Another query which must fail, say e([y],R), is run and then diagnosed using existing algorithms. Lazy evaluation is in some sense a more powerful evaluation mechanism than Prolog’s, and this results in a need to identify bugs in this more general class.

5.5 NUE-Prolog

The full debugger for NUE-Prolog is a straightforward combination of the algorithms we have outlined already. Wrong answer diagnosis uses missing answer diagnosis when negation is encountered and vice versa. The algorithm we use for diagnosing missing answers in Prolog comes directly from [Nai92] with a simple modification to use functional diagnosis if the Prolog code uses evaluable functions. It would not be difficult to substitute a different algorithm for diagnosing missing answers in Prolog. The functional undefined call diagnosis algorithm calls the Prolog missing answer diagnosis algorithm if a failure may be caused by Prolog code in a conditional or the body of an equation.

6 Conclusion

The execution mechanism of NUE-Prolog supports lazy evaluation, higher order functions, unification, coroutining and backtracking of predicates. Traditional debugging techniques based on the procedural semantics of programs, such as tracing, would make debugging extremely difficult. By using declarative debugging, diagnosing both wrong answer and missing answer/undefined call bugs in logical and functional code can be made comparatively easy. We have implemented a declarative debugger for NUE-Prolog, which we believe is the first of its kind. As part of this work we have extended the class of bugs in functional programs which can be diagnosed by declarative debuggers. It should not be difficult to adapt the algorithms we have developed so they can be applied to other similar languages.

References

[BN94] Timothy Barbour and Lee Naish. Declarative debugging of a logical-functional language (honours thesis). Technical Report 94/28, Department of Computer Science, University of Melbourne, Melbourne, Australia, December 1994.

[BW88] Richard Bird and Philip Wadler. Introduction to Functional Programming. Prentice Hall, Hempel Hemsted, UK, 1988.

[Han94] M. Hanus. The integration of functions into logic programming: From theory to practice. Journal of Logic Programming, 19&20:583–628, 1994.

[Llo84] John W. Lloyd. Foundations of logic programming. Springer series in symbolic computation. Springer-Verlag, New York, 1984.

[Nai91] Lee Naish. Adding equations to NU-Prolog. Proceedings of The Third International Symposium on Programming Language Implementation and Logic Programming, pages 15–26, August, 1991.

[Nai92] Lee Naish. Declarative diagnosis of missing answers. New Generation Computing, 10(3):255– 285, 1992.

[Nai93] Lee Naish. Declarative debugging of lazy functional programs. Australian Computer Science Communications, 15(1):287–294, 1993.

[NF92] Henrik Nilsson and Peter Fritzson. Algorithmic debugging of lazy functional languages. Proceedings of The Fourth International Symposium on Programming Language Implementation and Logic Programming, August, 1992.

[Sha83] Ehud Y. Shapiro. Algorithmic program debugging. MIT Press, Cambridge, Massachusetts, 1983.