Source Level Debugging in Poly/ML

Poly/ML includes a source-level debugger.   You can use it to set breakpoints in the program and print the values of local variables.  To turn on debugging for a particular piece of code set the compiler variable PolyML.Compiler.debug to true.  You can freely mix functions compiled with debugging on with functions compiled with debugging off, you simply can't set a breakpoint in a non-debuggable function or print its internal state.   The debugging control functions are all in the PolyML.Debug structure. It is often convenient to open this structure before debugging a program.

An Example Session.

To see how debugging works we'll follow through an example session.  We have a small function that is supposed to add together a list of pairs of integers but it has an error in it which causes it not to terminate.  We turn on debugging and compile in the program.

> PolyML.Compiler.debug := true;
val it = () : unit
> fun addList a l =
    let
        fun f (b,c) = a+b+c
    in
        case l of
              [] => a
            | (x::y) =>
                let
                    val v = f x
                    val l' = y
                in
                    addList v l
                end
    end;


val addList = fn : int -> (int * int) list -> int
> open PolyML.Debug;

We set a breakpoint in the function f using the breakIn function and apply the function to some arguments.

> breakIn "f";
val it = () : unit
> addList 0 [(1,2), (3, 4)];

The function prints the line number and stops at the breakpoint.

line:3 function:addList()f
debug>

The name addList()f is the full name of the function and we could have used this in place of f when setting the breakpoint.  The "debug>" prompt is almost identical to the normal Poly/ML ">" prompt.   The only difference is that variables which are in scope at the breakpoint are available as though they were declared globally.  So we can print the values of a, b, c and l.

debug> a;
val it = 0 : int
debug> b;
val it = 1 : int
debug> c;
val it = 2 : int
debug> l;
val it = [(1, 2), (3, 4)] : (int * int) list
debug>

In addition anything in the original top level is also available, provided it is not masked by a local declaration, so we can continue the program by calling the continue function which we opened from PolyML.Debug.

debug> continue();
val it = () : unit
line:3 function:addList()f
debug>

This continues and we find ourselves back in f at the breakpoint again.   We expect that and check the value of a.

debug> a;
val it = 3 : int
debug>

However when we check b something seems to be wrong and printing l confirms it.

debug> b;
val it = 1 : int
debug> l;
val it = [(1, 2), (3, 4)] : (int * int) list
debug>

We don't seem to be making any progress.  We go up the stack to the recursive call of addList in order to check that the value of l' is correct.  We have to go up twice because l' is not local to f nor is it available at the point where f was called.  It is only available at the point where addList was called recursively.

debug> up();
line:9 function:addList
val it = () : unit
debug> up();
line:12 function:addList
val it = () : unit
debug> l';
val it = [(3, 4)] : (int * int) list
debug>

This looks fine so the problem was not that l' had the wrong value.  We print the values of everything using the dump function to see if that helps.

debug> dump();
Function addList()f: c = 2 b = 1 l = [(1, 2), (3, 4)] a = 3
Function addList: x = (1, 2) y = [(3, 4)] f = fn l = [(1, 2), (3, 4)] a = 3
Function addList: l' = [(3, 4)] v = 3 x = (1, 2) y = [(3, 4)] f = fn
l = [(1, 2), (3, 4)] a = 0

val it = () : unit

At this stage it is clear that we are passing the original value of l rather than what we intended, l'.  We abort the function by typing control-C and f.

debug> ^C
=>f
Compilation interrupted
Pass exception to function being debugged (y/n)?y
Exception- Interrupt raised
>

This returns us to the top level.  We now fix the error and clear the breakpoint.

> fun addList a l =
    let
        fun f (b,c) = a+b+c
    in
        case l of
              [] => a
            | (x::y) =>
                let
                    val v = f x
                    val l' = y
                in
                    addList v l'
                end
    end;

val addList = fn : int -> (int * int) list -> int
> clearIn "f";
val it = () : unit

As a final check we turn on tracing to check that the values are as we expect and run the program with the same input as before.

> trace true;
val it = () : unit
> addList 0 [(1,2), (3,4)];
addList entered l = [(1, 2), (3, 4)] a = 0
  addList()f entered c = 2 b = 1 l = [(1, 2), (3, 4)] a = 0
  addList()f returned 3
  addList entered l = [(3, 4)] a = 3
   addList()f entered c = 4 b = 3 l = [(3, 4)] a = 3
   addList()f returned 10
   addList entered l = [] a = 10
   addList returned 10
  addList returned 10
addList returned 10
val it = 10 : int
>

This seems to have worked fine so we can now turn off PolyML.Compiler.debug and compile the function without debugging.

 

Reference

Tracing program execution

    val trace = fn : bool -> unit
The trace function turns on and off function tracing.  Function tracing prints the arguments and results of every debuggable function.

Breakpoints

    val breakAt = fn : string * int -> unit
    val breakIn = fn : string -> unit
    val breakEx = fn : exn -> unit
    val clearAt = fn : string * int -> unit
    val clearIn = fn : string -> unit
    val clearEx = fn : exn -> unit

Breakpoints can be set with the breakAt, breakIn or breakEx functions and cleared with clearAt, clearIn or clearExbreakAt takes a file name and line number and breakIn a function name.  The file name and line have to given exactly otherwise the breakpoint will not work.  breakIn is more flexible about the function name.  It can be the function name used in the declaration (e.g. f) or the full "path name".  The latter is useful when the program contains several functions with the same name since setting a breakpoint in f stops in any function called fbreakIn and breakAt simply record that you want a breakpoint.  There is no check when they are called that the appropriate location actually exists and you can set a breakpoint for a function and then compile it later. breakEx sets a breakpoint for a particular exception and causes the program to stop at the end of the function that raises the exception unless it is also handled there. The argument is the exception to trap. It is possible to trap exceptions that take a parameter. Just provide it with a dummy parameter to create a value of type exn that can be passed to breakEx. The actual parameter value will be ignored and the debugger will be entered whenever the exception is raised with any parameter value.

When a breakpoint is reached the program stops with a debug> prompt.   This is a normal Poly/ML top-level and you can type any ML expression.  The only difference is that local variables in the function being debugged are available as though they had been declared at the top-level.  You can print them or use them in any way that you could with a normal variable.  This includes local functions which can be applied to local values, constants or globals. You cannot change the value of a variable unless it is a reference.  At a breakpoint you can continue or single-step the program or you can raise an exception.  This is usually the most convenient way of aborting a program and getting back to the normal top-level unless the program has a handler for the exception you raise.

Single Stepping and Continuing

val continue = fn : unit -> unit
val step = fn : unit -> unit
val stepOver = fn : unit -> unit
val stepOut = fn : unit -> unit

continue runs the program to the next breakpoint.  stepstepOver and stepOut are different ways of single-stepping through a function.    step runs the program up to the next breakable point which is often the next source line.  If evaluation involves calling a function then it may stop at the beginning of the function.  By contrast stepOver stops at the next line within the current function only.  stepOut goes further and stops only within the function which called the current function.  If a called function includes a breakpoint they always stop there.

Examining and Traversing the Stack

val up = fn : unit -> unit
val down = fn : unit -> unit
val dump = fn : unit -> unit
val variables = fn : unit -> unit

up and down move the focus between called and calling functions allowing you to view local variables at different levels.  This is particularly useful for recursive functions.  The variables function prints all the variables in scope at the current point.  dump gives a complete stack trace.

Notes

The current implementation includes most values but not types or structures.     A recursive function is not available within the function itself.

The compiler adds debugging information which considerably increases the run time of the program.  It is probably best to turn debugging on only when it is needed.

The example shows debugging when all the variables have monomorphic types.  The debugger does not have access to any run-time type information so it cannot always know how to print a value inside a polymorphic type.  For example

> fun map f [] = [] | map f (a::b) = f a :: map f b;
val map = fn : ('a -> 'b) -> 'a list -> 'b list
> breakIn "map";
val it = () : unit
> map (fn i => i+1) [1,2,3];
line:1 function:map
debug> a;
val it = ? : 'a

If you know the type is int you can add a type constraint.

debug> a:int;
val it = 1 : int
debug>

It is though equally possible to use the wrong constraint and crash Poly/ML.   Future versions of Poly/ML may treat polymorphic variables as opaque which would prevent this but also prevent "safe" coercions.