lazy debugging of lazy functional programs

23
New Generation Computing, 8 (1990) 139-161 OHMSHA, LTD. and Springer-Verlag OHMSHA, LTD. 1990 Lazy Debugging of Lazy Functional Programs Robin M. SNYDER* Computer Science Department, Whitmore Laboratory, The Pennsylvania State University, University Park, PA 16802, USA. Received 11 July 1989 Revised manuscript received 4 April 1990 Abstract The debugging of fully lazy functional programs can require searching a very large reduction-history space containing many delayed computations. A debugger should provide a means to obtain a source level representation of the computation, which can be large, and a means to select the appropriate part of the computation to investigate, which can be difficult. A method is presented to compile functional programs to combinator code such that a source-like representation of any part of a computation graph can be efficiently reconstructed at run-time. Other less efficient methods require excessive compile-time guidance as to the specific part of the computation to be investigated. Reconstruction, forward reduc- tion, and a history-rollback mechanism combine to make the entire source- like reduction-history space dynamically available at run-time. The deferring of debugging decisions until run-time is called lazy dubugging. Once the computation-sequence is meaningfully and efficiently available, the problem of debugging becomes that of localizing the search for the error. Some searching issues are discussed with respect to graph browsing and user- interface design. The method shows promise as a programmer tool to debug programs anti to informally reason about the time and space behavior of fully lazy functional programs, a nonintuitive process due to the subtleness of sharing and delayed computations. Keywords: Functional Programming, Debugging, Lazy Evaluation, Combinators. w Introduction The goal of program debugging is to locate in a minimum amount of Currently with : Department of Math and Computer Science, Wilkes University, Wilkes-Barre, PA 18766, USA.

Upload: robin-m-snyder

Post on 25-Aug-2016

216 views

Category:

Documents


1 download

TRANSCRIPT

New Generation Computing, 8 (1990) 139-161 OHMSHA, LTD. and Springer-Verlag

�9 OHMSHA, LTD. 1990

Lazy Debugging of Lazy Functional Programs

Robin M. SNYDER* Computer Science Department, Whitmore Laboratory, The Pennsylvania State University, University Park, PA 16802, USA.

Received 11 July 1989 Revised manuscript received 4 April 1990

Abstract The debugging of fully lazy functional programs can require searching a very large reduction-history space containing many delayed computations. A debugger should provide a means to obtain a source level representation of the computation, which can be large, and a means to select the appropriate part of the computation to investigate, which can be difficult. A method is presented to compile functional programs to combinator code such that a source-like representation of any part of a computation graph can be efficiently reconstructed at run-time. Other less efficient methods require excessive compile-time guidance as to the specific part of the computation to be investigated. Reconstruction, forward reduc- tion, and a history-rollback mechanism combine to make the entire source- like reduction-history space dynamically available at run-time. The deferring of debugging decisions until run-time is called lazy dubugging. Once the computation-sequence is meaningfully and efficiently available, the problem of debugging becomes that of localizing the search for the error. Some searching issues are discussed with respect to graph browsing and user- interface design. The method shows promise as a programmer tool to debug programs anti to informally reason about the time and space behavior of fully lazy functional programs, a nonintuitive process due to the subtleness of sharing and delayed computations.

Keywords: Functional Programming, Debugging, Lazy Evaluation, Combinators.

w Introduction The goal of program debugging is to locate in a min imum amount of

Currently with : Department of Math and Computer Science, Wilkes University, Wilkes-Barre, PA 18766, USA.

140 R.M. Snyder

time the source of a conflict between actual and expected program behavior. In functional programming this can involve searching the entire reduction-history space of an incorrect program. The analysis and debugging of fully lazy func- tional programs is a nontrivial process for which useful tools are needed, z) A debugger should provide a means to obtain a source level representation of the computation, which can be large, and a means to select the appropriate part of the computation to investigate, which can be difficult. A method is presented to compile functional programs to combinator code such that a meaningful source-like representation of any part of the computation graph can be efficiently reconstructed at run-time. Other methods incur a large run-time overhead and thus require excessive compile-time guidance as to the specific part of the computation-sequence graph to be investigated. Reconstruction, forward reduction, and a history-rollback mechanism combine to make the entire source-like reduction-history space dynamically available. The deferring of debugging decisions until run-time is called lazy debugging. Once the computa- tion space is meaningfully and efficiently available, the primary problem of debugging becomes one of localizing the search for the error. Some searching issues are discussed with respect to graph browsing and user-interface design. The method shows promise as a programmer tool to debug programs and to informally reason about the time and space behavior of fully lazy functional programs, a nonintuit ive process due to the subtleness of sharing and delayed computations.

w Debugging The goal of program debugging is to locate in a minimum amount of

time the source of conflict, commonly called a bug, between actual and desired program behavior. The desired program behavior takes the form of a specification, the manner of which may be formal, informal, written, or in the programmer's mind (preferably all o f these are used, informal being considered the intuitive basis of formal). In a declarative language, the specification is the implementation so that debugging the specification results in an implementa- tion. At some level of sophistication, however, the complexity o f notation allows bugs to creep into a program such that the program does not meet specifications. The trend for program language design to shift dynamic run-time errors into static compile'-time errors (through type-checking, for example) provides some assistance, but run-time errors still occur and are an indication of logic errors.

After correctness is established, efficiency is the main concern. The meaning of efficiency depends on the priorities and costs placed on the finite resources available. This paper will be concerned on the one hand with minimizing the time and space used by a program, and on the other with minimizing the effort o f the programmer in obtaining a correct and (space and time) efficient program. Program improvements range from implementation

Lazy Debugging of Lazy Functional Programs 141

constant-factor improvements to algorithmic rate-of-growth improvements (an excellent discussion of efficiency tradeoffs is in Ref. 1)).

When a program contains a bug, the programmer seeks the minimum change to the source program to remove the bug and meet the specification. The correct program is not available. If it were, it could be substituted for the incorrect one. The programmer must be satisfied with locating a part of the program not meeting the specification, preferably the first occurrence of a specification violation, and take appropriate action.

A first approach to debugging is to view a representation of the sequence of computations. The deriving of such representations should be machine efficient and the necessary conditions for constructing such representations should have minimal time and space impact on run-time performance. The programmer, however, needs to locate bugs quickly and conveniently by the selective investigation of appropriate parts of the computation sequence. The debugging of nontrivial programs is difficult precisely because an enormous amount of information is available. A minimum of programmer direction should be needed to convert a program with a bug into a smaller program (containing the bug) such that the bug can be identified, eradicated, and the omitted part of the original program (not containing the bug) restored. An identified bug is usually simple to correct unless a design flaw requiring substan- tial modification of the entire specification and resulting implementation is uncovered (thus the benefit of specification debugging). The difficult problem of the selective investigation of the computation sequence can be assisted by an appropriate user-interface. Some general methods to assist in localizing bugs in functional and logic programs are found in Refs. 19), 13), and 6). The mechani- cal transformation to a representation of the computation, however, should efficiently support, and not restrict, any higher-level debugging considerations. To get diagnostic information after a run-time error, in analogy to a flight recorder from a crashed airliner, the method of deriving useful debugging information (reconstruction) must not cause excessive run-time overhead. Otherwise the method will not be used unless an error is certain to occur. For example, the SECD method is appropriate for applicative-order reduction but incurs a large overhead when modified to perform (fully lazy) normal, order reduction. 24) If excessive overhead is to be permitted, one might as well interpret- fully lazy reduction at the source level without compilation.

Summarizing, a program debugger should provide a way to obtain a source-level representation of the computation, which can be large, and a means to select the appropriate part of the computation to investigate, which can be difficult. With respect to fully lazy functional languages, a machine efficient run-time method will be presented to accomplish the former while some issues relating to the latter will be discussed.

1,12 R.M. Snyder

w Imperative and Functional Languages Imperative languages such as Pascal and F O R T R A N are based on the

destructive assignment statement, having a clear separation of pure code and changing data (a debugging system for imperative languages is described in Ref. 18). The state of the computation may be represented by mapping every instruc- tion in the generated code to a line in the source file, although code optimiza- tions complicate matters) ) The current value of the program counter permits the appropriate source line to be identified and displayed. A method to display data values completes the representation, al though data structures such as general graphs present problems (similar to that of displaying functional language source graphs).

Fully lazy functional programs are based on the/l-calculus, 4) represented as graphs, and evaluated by rewriting expressions in the graph with equivalent, but simpler, expressions until weak head normal form is reached. These rewrit- ings result in a mixture of code and data whose graphs are difficult to interpret and display (they do not fit on a screen as easily as a source file). As there are no side effects, the correctness of a (sequential or parallel) functional program is more easily established than for an imperative counterpart. On the other hand, functional programs tend not to be as time and space efficient. Even reasoning about the time and space behavior of fully lazy functional programs can be very nonintuitive due to the subtleness of sharing and delayed computations. In large part this is due to the removal of the burden of storage allocation and reclama- tion from the programmer. Parallel processing provides hope of narrowing the gap.

w Source Language The source functional language is lexically scoped, supports higher-order

functions (including lazy lists), and is evaluated by fully lazy normal order graph reduction driven by the need to print a top-level expression. Fully lazy evaluation m requires that any subexpression be evaluated at most once after,the variables in it are bound and then only when needed. There are no side effects. Graph reduction z6) allows an efficient implementation of lazy normal order reduction by representing each expression by a node containing a value or pointers to (possibly shared) subexpressions. Programs are assumed to be well-typed according to some form of polymorphic type-checking. 17)

Modeling the/t-calculus, a user-defined function f has the form

f 7 1 7 2 . . , v . = e

where expression e is the body (definition) of function f and vl are variables bound in e. An expression e has the abstract syntax

e = v, l f, loP, I (ee)

Lazy Debugging of Lazy Functional Programs 143

consisting of variables v~, user-defined functions (constants) fi, built-in operators (constants) ool, and applications of the form

(e, e2)

that apply argument e2 to function 01 (parentheses are left-associative). Built-in operators will be written using mnemonic symbols where appropriate, such as (+) for op~ where operator i is the (strict) binary operator for addition. Values such as numbers are represented as nullary operators.

Some translations are performed when converting from concrete to abstract syntax. The conditional selects e2 or e3 based on Boolean value of ex.

trans~if e] then e2 else e3 fi]]= (if ( transl~ e] ]]) ( trans[[ e2 ]]) ( trans[~ e3 3) )

Lazy lists borrow the notation of Prolog (constructor pr, selectors hd and tl)

trans[[ [e~le2] ]] = (pr (translFel])(transl~e2~l)) trans[[ [el ]] = (pr (trans[[e~]) nil) transl[ [el, e2] ~1 = (pr (transEel]]) (transl~ [e2] ]]))

Binary operators are written infix for concrete syntax expressions (such as for pretty-printing) and prefix for abstract syntax expressions (such as for compila- tion and reconstruction).

trans~el opi e2]] -- (opi (trans[~el~) (trans[~e2]]))

Any function definition may be qualified with a where clause terminated by an end enclosing a list of (possibly mutually recursive) statically scoped function definitions.

fvl----e where

gj Vjk z e3 end

Variables v~ are bound in e and free in ej. Variables Vjk are bound in e~. The concrete syntax uses a layout rule, using indentation instead of semicolons to separate function definitions. Debugging and program documentation are supported by requiring all user-defined functions to be named. An unnamed top-level expression, optionally qualified with a where clause, provides a top- level demand.

w Compilation and Reconstruction Ref. 24) showed that a fixed set of combinators could form the basis for

implementing fully lazy normal order evaluation of functional programs. A function definition is compiled by abstracting all bound variables to produce a code expression consisting of applications and constants. Constants include

144 R.M. Snyder

built-in operators, user-defined functions, and combinators. A combinator is a A-expression with no free variables; all arguments are required for reduction. During evaluation, combinators direct applied, and possibly unevaluated, values to their proper place in the expression being reduced, effectively, though not efficiently, performing /3-substitution of values for variables. The com- binators S, K, and I with abstraction rules

Ex] • - - I

Ix] y : K y , y ~ x Ix] el e2 = S (I-x] el) ([x] e2)

and reduction rules

[ X = X

K x y - - x S x y z - - - - x z ( y z )

are sufficient for abstraction/reduction (only S and K are necessary as I can be expressed as SKK). Efficiency considerations TM lead to the introduction of the additional combinators B and C with the reduction rules

B x y z - - x ( y z ) C x y z : x z y

and optimization rules (the first rule that applies is to be used)

optics (K e,) (K e2)]] ---- K (e, ez) optES (K e,) I ]] : e, optES (K ex) e2 ~ = B e, ez optics e, (K e~)] = C e, ez optES el e2 ~ = S el ez

The addi t ion o f B and C produces code that is fu l ly lazy when evaluated by graph reduction. More efficient sets of fixed combinators will not be discussed, although the method to be presented extends if variables can be uniquely associated with each combinator.

The compilat ion of

f v [ v z . . . v n : e

proceeds by applying the abstraction rules in an innermost to outermost manner

compEf]] ---- Iv,] ([v23 (... ( I v , ] e) . . . ))

so that lexical scoping is enforced and a-conversion (renaming) problems are avoided. Consider the source function in concrete syntax

f x y z - - - - z + x * y

and corresponding abstract syntax

Lazy Debugging of Lazy Functional Programs 145

f x y z : ( + z (8 xy))

Arguments are successively abstracted to form combinator code.

f = Ex] ([y] ([z] + z ( , x y)) = [x] ( [y ] c + ( , x y)) :- [-x~ B (C +) ( * x) = B (B ( C + ) ) *

Consider an interpretation whose reduction rules are augmented to include constants representing source variables vi. When combinator code for function f is supplied with the source variables • y, and z, a source representation (abstract syntax) of the original function is constructed, suitable for conversion into concrete syntax for purposes such as pretty printing.

f x y z : B (B (C+) ) 8 x y z : B (C+) (8 x) yz : ( C + ) ( * x y ) z : + Z ( * x y )

No further simplification can take place in this interpretation and the recon- structed parse tree is considered in source normal form.

In operat ional terms, a source graph represents the source expression while a code graph represents the combinator code. Compi la t ion is from a source graph to a code graph and reconstruction is from a code graph to a source graph. A source graph is in a programmer efficient form while a code graph is in a machine efficient form. This is the essence of compilat ion.

w Reconstruction During Reduction During evaluation, however, the function boundaries lose meaning as

/3-substitution and built-in operator reductions create a code and data mixture. Evaluating

f 2 3 5 by substituting the combinator code definition for the user-defined function f and applying the reduction rules results in the evaluation sequence

f235-- - - B (B ( C + ) ) * 2 3 5 = B ( C + ) ( * 2) 3 5 = (C+) (* 23) 5 = + 5 ( * 2 3 ) = + 5 6 =11

To reconstruct a source representation of the graph after each reduction, make each introduced combinator a source annotated combinator by associating with it the variable it abstracts. This can be done as each combina tor abstracts only

146 R.M. Snyder

one variable (an inefficiency of fixed set combinators). Let the source annotated combinator Sx refer to combinator S introduced when variable x is abstracted. Handle the other combinators similarly. The annotated combinator code for function f is

f ---- Bx (By (Cz 4-)) *

Annotat ions are maintained at (almost) no run-time cost. Most implementat ions of graph reduction contain equal sized nodes that contain either values or pointers. A bit o f a pointer often indicates whether the value of the pointer is to be interpreted as a pointer to a node or as a constant (function, operator, or combinator). The small number of (combinator) constants leave unused bits free to store source-level annotations indexing into an available symbol table of identifiers with appropriate mappings into the source file. The run-time cost is negligible as the annotat ion bits in pointers representing constants are not being used and the bits can be ignored during reduction. The storing of a symbol table is an additional, but reasonable, cost (most compilers create one and it can be stored somewhere until needed at run-time).

The reconstruction transformation 91 takes a combinator code expression and returns a source expression that is the (abstract syntax) representation of the reconstructed source for the code graph. Due to the mixture of code and data during reduction, the result of reconstruction is similar to, but not exactly like, definitions in the source file. This is especially bothersome to programmers used to the imperative programming model of pure code and changing data. The following mappings to the source file can be maintained to help relate the source-like information to the source file definitions.

Every function definition can be reconstructed. Every function use can be uniquely identified within the source file. Every variable can be uniquely identified with its source function head- ing. Each operator can be uniquely identified within its source definition body.

During reconstruction, all combinators are removed and, correspond- ingly, A-abstractions and variables are introduced. In essence, a reverse compila- tion is performed. User-defined functions fl, built-in operators opi, and variables vi reconstruct to themselves.

[If, I] = f, c~ ~'op,] = op,

"~ l l v , ] ] = v,

An applicat ion applies an argument (right part) to a function (left part). When the left part represents a user-defined or built-in (perhaps higher order) function, the right part represents an argument and the application may be reconstructed

Lazy Debugging of Lazy Functional Programs 147

by reconstructing the left part (function), reconstructing the right part (argu- ment), and forming an application of the reconstructed left and right parts.

9~[~(e, e2)]] = (9~Ee,]} ~ l le2] ] )

On the other hand, the application may represent a complete or incomplete combinator redex. In a well-typed program graph, there are three cases that can occur for the I, K, and S combinators in the leftmost-outermost (normal order) position during reconstruction (B and C are handled similarly to S).

(1) The combinator redex is incomplete by exactly one argument. A ~- abstraction is formed binding the annotated source variable of the combinator to the expression.

II-I [1 = x

~l~Kx e]]] = Ax. ( ~ e ~ ] ) 9~Sx el e=]] = ,~x. (9~-e, x (e2 x)]])

(2) A complete redex is formed and the last argument (supplied from an outer level during reconstruction) matches the source variable of the annotated combinator. The variable is passed along using the reduction rule for the combinator.

xql = m - - x

9 ~ K x el x ] = 9~e , ] ] 9IES, e, ez x]] = 9~[Fe, X (e2 x)]]

(3) A complete redex is formed and the last argument does not match the source variable of the annotated combinator. The last argument is an actual argument being directed (fl-substituted) into position by the combinator. The matching formal argument is determined by the annota- tion of the combinator. There are at least three ways of handling this case.

(3a) The actual argument (making a complete redex) is passed along using the reduction rule for the combinator, generating information that may be repeated often within an expression (the S combinator duplicates an expression).

9~EIx e,]] = 9~e l ] ] ~-Kx e l ez]] = ~ - e , ] ] 9~ [VSx e, e2 e3]] = 9~[[e, e3 (e= ea)]]

(3b) The source variable of the annotated combinator is passed along while saving the binding of actual to formal argument.

9~lx eli] = x where x = 9~ [~ el ]]

9~ [[Kx el e2] = 9~[~el] ,where x = ~[[e2]] ~ S x el ez e3]] = 9~ [~e~ x (e2 x)]]where x : ~ e a l l

148 R.M. Snyder

(3C) The reconstruction is represented as a ,~-abstraction, accurately depict- ing fully lazy normal order graph reduction but generating a lot of information without providing additional insight into the reduction process.

~EIx e,[] : (Ax. (~[]-x'[]) ~Ee,~ : nee,]] ~[]-K, e, e2[] = (/Ix. (9~ []e,~])) ~[]-e2[I ~-Sx e, e2 ea'j] : (,~x. (~[]-e, x (e2 x)'[])) ~[]-ea~]

Reconstruction of each step of the reduction of

f 2 3 5

using rule (3b) produces a fine-grained reduction reconstruction sequence.

r215 (By (Cz -{-)) * 235[ ] : + z ( * xy) w h e r e x = 2 , y : 3 , z = 5

9~ [l-By (C, + ) ( * 2) 35[] = + z ( * 2 y) where y : 3, z : 5

~- (C , + ) ( * 23) 5~] : + z ( * 23) where z = 5

~[ ] -+5 ( * 23)~] = + 5 ( * 23) 5 sl] : + 5 6

l]-Jll] : I I

Rule (3a) produces the following.

9~[[Bx (By (Cz +)) * 235"[] : + 5 ( * 23) 9~EBy (Cz + ) ( * 2) 35[] : + 5 ( * 23) 9~[](Cz + ) ( * 23) 5~ = + 5 ( * 23) 9~[]-+ 5 ( * 23)]] : + 5 ( * 23 )

5 6 ] : + 5 6 [ll] : II

After the first reconstruction, the next three steps are the same as/~-substitution by the reconstruction rules is performed in the first step. A reasonable (default) user-interface consideration is to use rule (3a) within the current redex (high- lighting the current redex), use rule (3b) outside the current redex, not use rule (3c), and when stepping through a sequence of reductions (forward or back- ward), to group adjacent combinator reductions appropriately.

During reconstruction, the code graph must not be modified. Consequent- ly, application nodes in the part of the code graph being reconstructed must be copied (nodes in source-normal form may be shared). Run-time reduction is lazy, but reconstruction is strict in that every argument is reconstructed in order to remove all combinators, although reconstruction of variable bindings (expres- sed following where) can be delayed until demanded.

Reconstruction does not have the same termination problems as reduc- tion. Consider nonterminating evaluation of a code expression. In a side-effect free functional language, this can only occur due to nonterminating recursive invocations of functions. If every pointer to an unevaluated function body can

Lazy Debugging of Lazy Functional Programs 149

be identified, each can be replaced with the name of the function it is pointing to, terminating that part of the reconstruction. This can be done as all functions are named and function pointers can be identified (there are many ways to do this; one method will be mentioned later). Thus every reconstruction will terminate. Even if this were not so, efficiency considerations for large programs dictate that the reconstruction be halted when a parse tree that fills the available screen area is obtained. As reconstruction is a reverse compilat ion, the time complexity of reconstruction is the same as compilat ion and this varies depend- ing on the compi la t ion method used.

w Optimizations Source-level reconstruction can become difficult in the presence of opti-

mizations as source information can be lost or obscured) ) Optimizations of combinator code are considered compile-t ime reductions. These reductions are useful if the resulting code size is reduced or if opportunit ies for further optimizations are introduced. Uncontrol led reduction (expansion) of recursive definitions is avoided due to code size increase and the resulting termination proflems.

The second optimization rule for combinator abstraction

optiCS (K e l ) I ] = e,

is the z/-optimization rule and causes source reconstruction information to be lost. The other optimizat ion rules cause no problems. For example, if 77- optimization is performed for the definition

p l u s x y = x + y

the result is

plus -- +

and source information is lost that must be supplied by the programmer. I f the optimization is not performed, source information is retained, but the code size increases. An intermediate solution to insure that reconstruction information is not lost is to introduce the apply combinator A with optimizat ion rule

optics (K e,)17] : A e,

replacing the 77-optimization rule. The reduction rule for A is

A x y = x y

The definition for plus becomes

plus = Bx Ay (Ax + )

The purpose of A is to supply an abstracted variable name during source-level reconstruction (the code size increase is unavoidable).

150 R.M. Snyder

In the second example, the condit ional

f x = if t rue then x else - - x fi

with a literal constant compiles to

f - - Sx (Ax ( i f t r ue ) ) (Ax - - )

if the optimization is ignored, but to the more compact but less informative

f - - Ix

if the optimization is performed. As compile-time optimization is compile-time reduction, the techniques for investigating run-time execution can be used to investigate compile-time optimizations. A requirement of fully lazy evaluation is that such reductions occur only once and the results are shared.

w Nested Definitions A nested source definition of the form

e where

fi vj = esl

end

is compiled to the form

e where

fi ---- el

end

by compiling each source expression esl to the code expression el. There are no free variables in e or any el so that references to fi in the code graph may be resolved at compile-time by replacing them with pointers to the corresponding compiled function definition e~ in the code graph. The code is pure code (assuming combinator code optimization is performed) as described in Ref. 9). No explicit copying of functions is needed when applying arguments ad the combinators serve to copy incrementally in a lazy way the needed part of the function body. By creating a function lookup table as a known part of the graph node space, an efficient compile-time implementation of the TUPLE-n/INDEX-i approach to handling nested definitions is achieved. 16) In addition to being run-time efficient, it becomes simple to determine if a pointer points to an unevaluated function definition, a requirement for efficient reconstruction termi- nation.

The approach must be modified when free variables are involved. A (potentially) recursive function definition

f - - e

is compiled by abstracting f from e and applying the u combinator

Lazy Debugging of Lazy Functional Programs 151

Y ( [ f ] e)

where Y has the reduction rule

Y f ---- f (Y f)

The abstracted function name is the annotation for the Y combinator, as opposed to variable name annotations for the other combinators (for conve- nience, all functions are internally given variable names). If f is not recursive (f is not used in e) then

(Y ( I f ] e))--- (Y (K e))--- K e (Y (K e))--- e

This is an example of a compile time reduction that results in a code optimiza- tion. The overhead of the Y combinator can be lessened by use of the knot-tying u combinator, introducing an explicit cycle in the code graph but removing that instance of u Cycles complicate both reconstruction of the source and the use of reference counting for node allocation and reclamation (although there are ways of dealing with cycles when performing reference counting. H) The node where a cycle is introduced by a u combinator reduction remains at a fixed location in the graph. In this sense, such cycles are well behaved, unlike cycles created by (source level) side effects such as the use of RPLACA in LISP.

The approach to compiling nested function definitions of Ref. 10) can be extended to generate pure code for each function. The nested definition is first compiled to

( [ f , ] e) f, where

f2 = ( [ f , ] e2) f, fa = ( [ f , ] e3) f,

end

where the (shared) code definition of fl is

(Y (If1] e,))

All future variable abstractions on the shared code for function fl by variable v, will be done with no other intervening variables being abstracted on the shared occurrence of ft. The abstraction algorithm is modified to perform the abstrac- tion on the function code the first time, and record the variable abstraction to avoid multiple abstractions of the same variable (this can be easily done by looking at the top level combinator annotation if all variable have unique identities). This allows combinator optimizations to be performed over function code boundaries. The shared result will be considered the pure code for function ft. The above process is repeated with the new grouped definition until all function definitions have been removed and only the compiled expression

152 R.M. Snyder

results, along with pure code for each function (the generation of pure code for each function is an important starting point for parallel evaluation methods).

A function definition involving V is reconstructed to its source variable name

~ E Y , el] = f where f =- N E e f]]

unless the definition body with free variables in place is required. The Y combinator is needed only when a mutually recursive group of function definitions contains one or more free variables. Otherwise direct pointing to the function definition code can be used.

Consider the example program with nested definitions and free variables x and y.

f 2 where

f x = i f x = I then I e l s e w + g w f i where

w - - x - - 2 end

g y = f z where

z - - - y + l end

end

Compilation to combinator code results in an unnamed root expression and named function definitions (F represents a function constant).

(F, z) f = (Sx (Cx (Bx Bw (Cx (Bx if (Cx (Ax =) I)) ~))(Sw (Aw +) (Aw Fw))) Fw) w = (Cx (Ax - ) Z) g = (By (Az F,) Fz) z -- (c~ (A~ + ) I )

Reconstruction of each definition to concrete syntax (via abstract syntax) yields

f 2 f x = i f x = I then I e l s e w x + g ( w x ) fi WX-----X--2 g y = f (Z y) z y = y + l

The original where clause boundaries are not shown, although they are implicit- ly available in the symbol table. After reconstructing the source definitions, the compilation strategy for grouped definitions is seen to be a form of A-lifting lz) that achieves full laziness but lacks optimizations detectable from a global

Lazy Debugging of Lazy Functional Programs 153

dependency analysis. Such optimizations include floating groups of mutually recursive function definitions based on function dependency without free vari- ables to the top level where direct pointing can be used.

It is undesirable to see A-lifted variables as formal or actual arguments in source reconstructions. As free variables are abstracted after bound variables, they are applied before bound variables and are therefore innermost arguments to the function to which they are passed. By maintaining for each function a A-lifted redex-arity in the symbol table, A-lifted arguments can be carried along at the abstract syntax level (reconstruction) but not displayed at the concrete syntax level (pretty printing). Doing so yields the reconstruction

fZ f x = if x---- I then I e l s e w + g w fi

w - - x - - 2

g y = f z z = y + l

Reconstruction at run-time results in the following computat ion sequence (simple pretty printing is used to save space).

f 2 A x . ( i f x = I then I e l s e w + g w f i ) 2 i f 2 = t then t e l s e w + g w f i w h e r e w = 2 - 2

if false then I e l s e w + g w f i w h e r e w = 2 - 2

w + g w w h e r e w = 2 - 2

0 + g 0 0 + Ay. (f z) 0 O + f z w h e r e z = O + I

O + f l

0 + A x . ( i f x = I then I e l s e w + g w f i ) I

0 + i f I -- I then I e l s e w + g w f i where w = I - 2

0 + if true then I e l s e w I + g w f i w h e r e w = I - - 2

0 + 1

I

Explicit A-notatiOn is used when a function name is not available to juxtapose . with the bound variables (such as after a function name if replaced with its body but before/~-substitution is performed). W h e r e clause information is shown for completeness. Notice the delayed computations involving w.

w Higher Order Functions Two examples of higher order functions are provided. In the first, the foldr

function sums a list of numbers. The source definition

sum [I, 31 where

154 R.M. Snyder

p lusxy = x 4-y sum ---- foldr plus 0 foldr op a = fn where

fn xs = if xs -- [-] then a

else op (hd xs) (fn (tl xs)) fi

end end

compiles to

(Fsu,. (pr 1 (pr 3 nil))) plus --- (Bx Ay (Ax 4-)) sum = (F~oldr Fp,u, 0) foldr ---- (Bop (Ba Pf) Fsu,,) fn = (Bop (Ba Y,) (Bop (Ca (Ba B~ (Ba Sxs (Aa

(Cxs (Bx, if (Cxs (Ax, = ) ni l)))))) (Cop (Bop Bf (Bf Sxs (Cap (Aop Bx~) (Axs hd)))) (C~ (A, Bx~) (Ax, tl)))))

The run-time reconstruction is

sum El, 3] foldr plus 0 [ I , 3] /lop. a.(fn) plus 0 [ I , 3] fn [ I , 3] 2xs. (if xs ---- [] then 0 else plus (hd xs) (fn (tl xs)) fi) [-I, 3] if J-I, 3] ---- [ ] then 0 else plus (hd [ I , 3]) (fn (tl [ I , 3] ) ) fi if false then 0 else plus (hd i-I, 3]) (fn (tl [-I, 3] ) ) fi plus (hd [ I , 3] ) (fn (tl [ I , 3 ] ) ) 2~x. y.(x + y) (hd [ I , 3]) (fn (tl [ I , 3 ] ) ) (hd [ I , 3]) + (fn (tl El, 3]) ) I + (fn (tl [ I , 3 ] ) ) . . . a n d so o n until . . .

14-3 4

Notice that function fn, once instantiated, is the definition of the function used in the reconstruction. Other function boundaries have disappeared due to the

effect of partial evaluation. The second example uses the map function to double a list o f numbers.

The source definition

double I-I, 3]

Lazy Debugging of Lazy Functional Programs 155

compiles to

where twice x - - 2 * x double xs = map twice xs map fn xs =

if xs : [ ] then []

else [fn (hd xs) lmap fn (tl xs)] fi

end

(F,~oub,e (pr I (pr 3 nil))) twice : (Ax ( * 2)) double : (A• (Fmap Ftw,ce)) map = (Bin (Sx,) Cx~ (g,:s if (Cx, (Ax~ = ) nil)) nil))

(Sfn (~}fn Sxs (Bf. (Bx. pr) (Cf. (A,. Bx.)(Ax. hd)))) (Cfn (B~. B• (At. Fro.p)) (Ax~ tl))))

and results in the reduction sequence

double [ I , 3] Axs. (map twice xs) [ I , 3] map twice [ I , 3] 2fn. xs. (if xs : [ ] then F] else [fn (hd xs)lmap fn (tl xs)] f i)twice [ I , 3'l i f [ I , 3] = I-] thenE,l else [twice (hd El, 3-1)lrnap twice (tl [-I, 3'l)'l fi if false then ['l else [twice (hd (hd El, 3])1 map twice (tl I-I, 3 ] ) ] fi [twice (hd (hd [ I , 3] ) lmap twice (tl I-I, 3 ] ) ] I-2x. (2 * x) (hd (hd F I, 3 ] ) lmap twice (tl [ I , 37 )7 [2 * (hd (hd El, 3 ] ) l map twice (tl El, 3 ] ) ] EZ * I I map twice (tl [ I , 3-])] [21 map twice (tl [I, 3]) . . . a n d so on unti l . . .

[2, 6]

w History Rollback Fully-lazy normal-order graph reduction is deterministic in that the next

redex to be reduced is the leftmost-outermost redex. The computa t ion order, however, is not always intuitively obvious from a scan of the source program, m Moving to a previous state is useful in order for a programmer to answer the question, "How did I get to the current state of the graph ?". Although history rollback mechanisms have been provided in various forms for various purposes, including I N T E R L I S P 28) and ParaTran, 2z) a discussion is provided to demon-

strate that an efficient rollback mechanism can be achieved. The graph space consists of a heap of equal-sized nodes. Each node

156 R.M. Snyder

contains a tag, left pointer, right pointer, operator, and count. The tag is for node access. A left or right pointer can point to a node or represent a constant. One bit of a pointer indicates if the pointer points to a node or represents a constant. Each node has an associated nullary, unary, or binary operator. The following are some typical examples.

A binary node has a left and right pointer. An apply node has a function (left pointer) and an argument (right pointer). A pair node has a head (left pointer) and a tail (right pointer). A lambda node (source graphs only) has a bound variable (left pointer) and expression (right pointer) which can be a lambda node. A unary node has a left pointer (the right pointer is undefined). An indirection node permits sharing resulting from the reduction of selectors (such as hd, tl, K, and I; if true can be represented as K and if false as KI). A nullary node has a value (such as an integer or real) that fits into the left and right pointers.

A node with no references to it is considered garbage and may be reused for other purposes. Reference counting does this by associating with each node a count of the number of references to the node. The count records the number of references to the node. The count is incremented when a reference is added and decremented when a reference is deleted. When no references remain, a node may be used for other purposes, after first decrementing the count of any nodes that node points to. This requires a significant amount of time if a large structure is to be reclaimed. Lazy garbage collection, using a stack of to-be-decremented pointers, can amortize the cost of freeing such large structures. 7)

A reduction is defined as the overwriting of a node with a (possibly shared) result. The result must be a pair, indirection, or value. Results are never overwritten. After a reduction, the result is returned (to the demanding node) and the count of the node is decremented, reclaiming the node if the last reference is removed. A history mechanism requires that reductions be undone in reverse order. To do this, the decrement is delayed in a lazy fashion 'by maintaining a doubly-linked queue of to-be-decremented nodes, augmented with the operator and left and right pointers of the to-be-decremented node. The previous state can be reached by removing a to-be-decremented node from the front of the queue and rewriting the node with the operator and left and right pointers (whose decrements were delayed) and then decrementing the counts of the previous left and right pointers of the node in the code graph as an atomic action. As already discussed, combinator reductions performing/~-substitution can be grouped together in both directions.

When there are no available nodes, to-be-decremented nodes may be retrieved from the back of the queue, and the (delayed) decrement performed in an attempt to obtain free nodes (also decrementing the left and right pointers if the to-be-decremented node is to be reclaimed). The removal of history nodes

Lazy Debugging of Lazy Functional Programs 157

from the queue limits the amount of backtracking. The run-time overhead consists of maintaining a doubly-linked queue with the augmented information. Of a more serious nature are the common optimizations that delay writing results to the heap (if the result is not shared) 5) or immediately reuse (garbage collect) nodes when reducing combinators, z~ A history mechanism requires that such optimizations not be done.

w Traces and Breakpoints Once a representation of the computation can be (efficiently) obtained,

the primary problem of a practical debugger is to provide means for the programmer to localize a bug. To focus attention on a part of the execution sequence, the programmer can provide hints to the reducer in the form of traces and breakpoints, features provided in most languages, zT) A trace displays specified expressions in a meaningful form at some point in the computation sequence while a breakpoint, in addition, passes control to a user-interface for further action. The (unary uncondit ional) trace operator has the form

( t r e)

where e is the expression to be traced (breakpoints are handled with the obvious extensions). Trace operators are strict in that, once demanded, they demand their associated expressions and return the result, performing a controlled and trans- parent (to the reducer) form of side effect before and /o r after they receive control. The result (return) of a demand (call) in a lazy language may be an unevaluated part (higher order function) of the code/source graph and must be interpreted by the reducer/programmer accordingly.

Even when the programmer is aware of potential problem sites to investi- gate, the difficulty is conveniently specifying the sites and conditions. For example, condit ional tracing can be specified in terms of the tr (trace before and after), tb (trace before), and ta (trace after).

ctr b e = if b then t r e e lse e fi

ctb b e - - if b t h e n t b e e lse e fi

c ta b e = if b t h e n ta e e lse e fi

A recta-programming problem arises in that nontrivial specifications must be given to the programming environment to investigate the desired part of the computation. A common debugging action is to trace the invocation of selected functions. For a set of n functions, where only simple unconditional tracing of functions is used, there are 2 n possible subsets of functions to trace. The ease with which a programmer may make arbitrary trace decisions is difficult. Making the problem as painless as possible is a goal of a user-interface.

w User-Interface Considerations A good user-interface can provide the basis for a very productive pro-

158 R.M. Snyder

gramming environment. The method of reconstruction should assist and not restrict any such graph browsing and/or user-interface decisions.

Practical usage of a functional language dictates that a problem be structured in a concrete syntax form that is directly related to the problem being solved. User-defined operators with parsing priorities (similar to the op predi- cate of Prolog or as described in Ref. 15)) provide help toward this goal.

Some help can be provided to the programmer by allowing test cases and expected results to be associated with a function, the functional version of an assertion, as in the expression

e l = ) ez

which, when demanded, evaluates el and e2, aborts if they are not equal, and returns either result if they are equal. This permits functions (and expressions) to be automatically tested and provides documenting examples on the proper usage of such functions. Although not insuring correctness, minimum program- mer effort is expended in quickly locating obvious bugs, especially when modifying programs to meet similar specifications.

Pretty printing is intended to emphasize program graph structure by visual techniques using indentation and white space (sophisticated techniques are in Ref. 21)). Although reconstruction and (simple) pretty-printing can be done in one pass, it is convenient to first perform reconstruction from code to abstract syntax. In human terms, a graph browser must fill the screen with meaningful information within a fraction of a second. Though the user can be given the impression that the graph browser is paging up and down through a textual representation of the graph, each page can be computed "on the fly." Unneeded syntax trees may be discarded as they can always be recreated as needed. An interesting problem is to display the next, or previous, instance of a reconstructed graph in a form that minimizes the changes on the screen. The solution of this human-factors problem could help the programmer detect changes in the program graph by running the reduction in the manner of a motion picture. Otherwise, a useful diagnostic is to provide a reasoning of ~ach forward (or backward) reduction, such as

f2 - --> expanding 'definition of f -- = )

Ax. ( i f x - - I then I e l s e w + g w f i ) 2

o r

w + g w w h e r e w = 3 - 2 - - > substitute I for 3 -- 2 - - - - > I + g l

Some simple, but useful, graph-browser commands include the following.

Lazy Debugging of Lazy Functional Programs 159

Single or multiple step to the next/previous reduction (if any). Move up/down a level in the syntax tree (if possible). Display an accessible variable binding or function definition.

Profiling tools attempt to provide useful statistics relating to program execution. As such they provide a useful first step in identifying the time and space consuming parts of a computation. The following have been useful in providing diagnostic profiling information for determining time and space utilization:

Color coding of a visual representation of the node space to depict node utilization such as tags and reference counts, which tend to be small. Color coding of the displayed (reconstructed) parse trees to identify variable types (free, bound, or function definition) and sharing informa- tion. Statistics on the number of reductions, by category.

w Comparision with Other Work Other work on debugging of functional programs includes Refs. 23) and

14). These methods lack the flexibility of delaying until run-time decisions on which part of the reduction history at the source level to investigate. This is due to the large space overhead of maintaining complete derivations, requiring compile-time guidance for all but trivial programs. The technique presented in this paper can be efficiently implemented and allows the specification of the desired part of the derivation to investigate to be delayed until run-time. The combining of reconstruction, forward reduction, and a history rollback mecha- nism makes the entire source level reduction-history space dynamically available at run-time. The deferring of such decisions is here called lazy debugging.

w Current Implementation The reconstruction method presented in this paper has been implemented

as part of an integrated and evolving functional language programming environ- ment including an editor, compiler, linker, reducer, and debugger. The editor features compile-time error detection location. The compiler uses an enhanced fixed set combinator implementation and supports limited pattern matching. The architecture is designed to support modules and parallel evaluation methods. Debugging features include the ability to set traces and breakpoints, perform history rollback, and interrupt run-time execution. Graph browsing is primitive as the entire expression (or subexpression, depending on location in the graph) is reconstructed after each browsing command and passed (as an in-memory file) to an editor.

w Conclusions and Future Directions A method has been presented to reconstruct a source-level representation

160 R.M. Snyder

of a lazy functional program from compiled combinator code with a small space and time overhead. The reconstruction method provides the insight that although source variables are removed during compilation, they implicitly and anonymously, unless combinators are annotated, appear in the generated code in a form suitable for parallel evaluation methods. The user need never be aware that combinators are being used to implement lazy normal order evaluation of functional programs, opening the way for the integration of more efficient implementation methods (such as the G-machine 16)) when debugging is not required.

Applications of the presented debugging model include providing mean- ingful run-time error reports, enhancing the practical and intuitive understand- ing of space and time behavior of lazy normal order evaluation, and providing a basis for developing general debugging techniques for lazy functional pro- grams. The model (and implementation) shows promise of being a useful tool for the understanding and debugging of lazy functional programs. Such require- ments are often not initially obvious and development must go through a number of refinements, as the spelling checker described in Ref. 3). Further research is needed to determine what facilities are useful for debugging of functional programs, which are not, and how appropriately to adapt the tools.

The ease with which combinator code supports reconstruction is an indication of combinator efficiency (or inefficiency). That is, after one has paid the price, one might as well take advantage o f the information implicit in the structure of the code graph. This information, however, may be useful in other specialized applications.

References 1) Bentley, J., Writing Efficient Programs, Prentice-Hall, Englewood Cliffs, NJ, 1982. 2) Augustsson, L. and Johnsson, T., "The Chalmers Lazy-ML compiler," The Computer

Journal, Vol. 32, No. 2, pp. 127-141, 1989. 3) Bentley, J., Programming Pearls, AT&T Bell Laboratories, Murray Hill, NJ, 1986. 4) Burge, W. H., Recursive Programming Techniques, Addison-Wesley, Reading, MA,

1974. 5) Burn, G. L., Peyton Jones, S. L., and Robson, J. D., "The spineless G-machine,"

Proceedings of the 1988 ACM Symposium on Lisp and Functional Programming, Association for Computing Machinery, Snowbird, UT, pp. 244-258, 1988.

6) Eisenstadt, M. and Brayshaw, M., "The transparent PROLOG machine (TPM): An execution model and graphical debugger for logic programming," Journal of Logic Programming, 5, pp. 277-342, 1988.

7) Glaser, H. W. and Thompson, P., "Lazy garbage collection," Software--Practice and Experience, Vol. 17, No. 1, pp. 1-4, 1987.

8) Hennessy, J., "Symbolic debugging of optimized code," A CM TOPLAS, Vol. 4, No. 3, pp. 323-344, July, 1982.

9) Hudak, P. and Goldberg, B., "Experiments in diffused combinator reduction," Pro- ceedings o f the 1984 ACM Conference on LISP and Functional Programming, Association for Computing Machinery, pp. 167-176, August, 1984.

Lazy Debugging of Lazy Functional Programs 161

10) Hudak, P. and Kranz, D., "A combinator-based compiler for a functional language," Proceedings of the 11th Annual ACM Symposium on the Principles of Programming Languages, Association for Computing Machinery, New York, pp. 122-132, January, 1984.

11) Hughes, J., "The design and implementation of programming languages," Ph.D. Thesis, Oxford University, July, 1984.

12) Johnsson, T., "Lambda lifting: Transforming programs to recursive equations," in Functional Languages and Computer Architecture, Lecture Notes in Computer Science 201 (Jean-Pierre Jouannaud ed.), Springer-Verlag, Berlin, pp. 190-203, Septem- ber, 1985.

13) Lieberman, H., "Steps toward better debugging tools for LISP," ACM Symposium on LISP and Functional Programming, Association for Computing Machinery, pp. 247-255, August, 1984.

14) O'Donnell, John T. and Hall, C. V., "Debugging in applicative languages," Lisp and Symbolic Computation, 1, pp. 113-145, 1988.

15) Peyton Jones, S. L., "Parsing distfix operators", Communications of the A CM, Vol. 29, No. 2, pp. 118-122, February, 1986.

16) Peyton Jones, S. L., The Implementation of Functional Programming Languages, Prentice-Hall, New York, 1987.

17) Milner, R., "A theory of type polymorphism in programming," Journal of Computer and System Sciences, 17, pp. 348-375, 1978.

18) Satterthwaite, E., "Debugging tools for high level languages," Software--Practice and Experience, 2, pp. 197-217, 1972.

19) Shapiro, E. Y., Algorithmic Program Debugging, MIT Press, Cambridge, MA, 1983. 20) Stoye, W. R., Clarke, T. J. W., and Norman, A. C., "Some practical methods for rapid

combinator reduction," Conference Record of the 1984 ACM Symposium on LISP and Functional Programming, Association for Computing Machinery, August, 1984.

21) Shu, N. C., Visual Programming, Van Nostrand Reinhold, New York, 1988. 22) Tinker, P. and Katz, M., "Parallel execution of sequential scheme with ParaTran,"

ACM, pp. 28-39, 1988. 23) Toyn, I. and Runciman, C., "Adapting combinator and SECD machines to display

snapshots of functional computations," New Generation Computing, 4, pp. 339-363, 1986.

24) Turner, D. A., "A new implementation technique for applicative languages," Software --Practice and Experience, 9, pp. 31-49, 1979.

25) Turner, D. A., "Another algorithm for bracket abstraction," The Journal of Symbolic Logic, Vol. 44, No. 2, pp. 267-270, June, 1979.

26) Wadsworth, C. P., "Semantics and pragmatics of the lambda calculus," Ph.D. Thesis, Oxford University, 1971.

27) Wilensky, R., 'LISPcraft, W. W. Norton, New York, 1984. 28) Kaisler, S., INTERLISP: The Language and Its Usage, John Wiley & Sons, New

York, 1986.