Module 5
Accumulators

 

 

This section first looks at how to write accumulators, re-working examples seen in earlier sections, and then examines why accumulators can be more efficient.

 
count_elems/2 revisited

We'll start with the simplest procedure that included Prolog arithmetic, although the original version seemed quite satisfactory:
     % 1 terminating condition
     count_elems([], 0).
     % 2 recursive
     count_elems([Head|Tail], Count) :-
          count_elems(Tail, Sum), 
          Count is Sum + 1.
This uses a variable called Count which acts as a counter of the number of elements in the list. In an accumulator, we add another argument to help the counting. This is the new terminating condition:
     % 1 terminating condition
     count_elems_acc([], Total, Total).
This simply says that when the list is empty, then the other two arguments are the same.

The recursive condition is a rearrangement of the old version:

     % 2 recursive
     count_elems_acc([Head|Tail], Sum, Total) :-
          Count is Sum + 1, 
          count_elems_acc(Tail, Count, Total).
This simply adds one to the counter, while recursing on the Tail of the list and passing the Total on. We can see how the variables are instantiated as recursion deepens by following the output of demo_count_elems_acc/3. Look especially at how Count is incremented and how Total is only instantiated when the terminating condition is reached:
     | ?- demo_count_elems_acc([a,b,c], 0, Total).
          Before recursion at depth 1
             List is: [a,b,c]
             Count is: 0
             Total is: _425
               Before recursion at depth 2
                  List is: [b,c]
                  Count is: 1
                  Total is: _425
                    Before recursion at depth 3
                       List is: [c]
                       Count is: 2
                       Total is: _425
                         At the termination condition
                            List is: []
                            Count is: 3
                            Total is: 3
                    After recursion at depth 3
                       List is: [c]
                       Count is: 2
                       Total is: 3
               After recursion at depth 2
                  List is: [b,c]
                  Count is: 1
                  Total is: 3
          After recursion at depth 1
             List is: [a,b,c]
             Count is: 0
             Total is: 3
     
     Total = 3 ;
     no
To call the procedure, we can try various versions:
     | ?- count_elems_acc([a,b,c], 0, Total).

     Total = 3 ? ;

     no
     | ?- count_elems_acc([a,b,c], 0, 4).

     no
     | ?- count_elems_acc([], 0, Total).

     Total = 0 ? ;

     no
     | ?- count_elems_acc(List, 0, 3).

     List = [_191,_197,_203] 

     yes
It is inconvenient to remember to add the literal '0' every time count_elems_acc/3 is called, so we write another rule, count_elems_acc/2, which calls count_elems_acc/3 with the literal:
     % 1
     count_elems_acc(List, Total) :-
          count_elems_acc(List, 0, Total).
                
          
 
memb/3 and position/3 revisited

We've already commented on the apparent similarity between memb/3 and position/3. In this section, we'll rewrite them in a single, accumulator procedure.
     % 1 terminating
     pos_memb_acc(Pos, Pos, Elem, [Elem|_]).
     % 2 recursive
     pos_memb_acc(Cnt, Pos, Elem, [_|Tail]) :-
          Cnt1 is Cnt + 1,
          pos_memb_acc(Cnt1, Pos, Elem, Tail).
Like count_elems_acc/3, this uses a counter (called Cnt and Cnt1) and a total (called Pos) to keep track on the position of the element. The recursive condition is true when the counter is incremented and the recursive call is true. The terminating condition is true when the head of the list is unifiable with Elem and the counter and Pos can be unified.

Again, we'll provide a more convenient calling rule:

     pos_memb_acc(Pos, Elem, List) :-
          pos_memb_acc(1, Pos, Elem, List).
Try these procedures with the following two queries:
     | ?- pos_memb_acc(3, Elem, [a,b,c,d,e]).
     | ?- pos_memb_acc(Pos, c, [a,b,c,d,e]). 
 
Using recursion efficiently

It would be incorrect to think that the only use of accumulators was to allow procedures to be "bi-directional". Recursion is potentially a large user of main memory, because each recursive call requires the state of the computation to be stored and a new state to be computed. In realistically large Prolog programs, recursion is frequently used and so memory consumption could be huge. However, there is a technique of implementing recursion that allows memory to be used very economically. Look at these two versions of a recursive rule:
     % 2 recursive
     count_elems([Head|Tail], Count) :-
          count_elems(Tail, Sum), 
          Count is Sum + 1.
     % 2 recursive
     count_elems_acc([Head|Tail], Sum, Total) :-
          Count is Sum + 1, 
          count_elems_acc(Tail, Count, Total).
In the first version, the first sub-goal (count_elems(Tail, Sum)) is non-deterministic: that is to say that there could be more than one alternative matching clause in the Prolog program; the second sub-goal is deterministic, because there is only one possible solution to Count is Sum + 1.

In the second version, the first sub-goal (Count is Sum + 1) is deterministic; the second sub-goal (count_elems(Tail, Sum, Total)) is non-deterministic.

In Prolog, the efficient use of memory hinges on determinism and non-determinism. Look again at the second version. As the first sub-goal (Count is Sum + 1) is deterministic, when the second, recursive sub-goal (count_elems(Tail, Sum, Total)) is reached, the Prolog interpreter/compiler has no need to "remember" anything about the previous sub-goals. This means that the recursive call can use the memory being used by the current call and so, as recursion deepens, there is no increase in memory used.

With the first version, it may be that some sub-goal following the recursive sub-goal may be non-deterministic, and so Prolog cannot over-write the memory used for the current recursive call and so requiring the consumption of more main memory.

This technique of economising on the use of memory during recursion is part of a technique called Tail Recursion Optimisation. Its name refers to the position of the recursive sub-goal at the tail-end of the recursive rule. Experienced Prolog programmers are identifiable by their wide-spread use of tail-recursion.
 

 
reverse/2: an example of efficiency

There is a version of reverse commonly known as "naïve reverse" and often quoted as a benchmark for measuring comparative speeds of Prolog interpreters and compilers. "Naïve reverse" in turn uses append (which we defined as app/3). A definition is:
     % 1 terminating condition
     reverse_naive([], []).
     % 2 recursive
     reverse_naive([Head|Tail1], Reversed) :-
          reverse_naive(Tail1, Tail2),
          app(Tail2, [Head], Reversed).
This seems a harmless piece of code, but look at the recursive rule. This recursively reverses Tail1 to obtain Tail2 and then appends Head to Tail2. This means that reverse_naive/2 recurses through to the empty list and, at each level of recursion, app/3 also recurses through to the end of the list. If you want an illustration of what this means, simply copy reverse_naive/2 and app/3 into a file and trace a query like:
     | ?- reverse_naive([a,b,c,d,e,f], Rev).
When I did this, I counted no less than twenty-eight separate calls to procedures, of which, seven were calls to reverse_naive/2 and twenty-one were to app/3. By rewriting reverse_naive/2 as an accumulator, we can drastically reduce the amount of processing:
     % 1
     reverse_acc(List, Tsil) :-
          reverse_acc(List, [], Tsil).

     % 1 terminating condition
     reverse_acc([], Reversed, Reversed).
     % 2 recursive
     reverse_acc([Head|Tail], Rest, Reversed) :-
          reverse_acc(Tail, [Head|Rest], Reversed).
This version starts the second argument of reverse_acc/3 as an empty list and then uses the list constructor to add Head to the second argument at each stage of recursion. The terminating condition specifies that the second and third arguments should be unified.

When I traced this version, there were only eight procedure calls. Obviously the accumulator version does far less work and it has the advantage of being tail-recursive, whereas the naïve version isn't, with a corresponding lack of efficiency.

Beginners at Prolog find it easy to write procedures that aren't tail recursive and don't use accumulators where they should. However, with practise and experience, better habits prevail.
 

Take time to work through the Self-Test 7