We first finished our discussion on "super lazy" streams (streams that only evaluate their head upon request). We then talked about the implementation of streams with non-shared state (or "memory," as we called it informally). You can find the relevant notes here.
From our previous discussions we know that we can only declare recursive functions using the fun, or val rec statements. We also know that we can use val statements to declare simple (non-recursive) functions. We show the power of references by implementing recursive functions using val statements.
We illustrate our method using the canonical example of recursive functions, the factorial:
fun fact(n: int): int = if n = 0 then 1 else n * (fact(n - 1))
If we were to use a val statement to declare fact, the declaration would fail because of the recursive call fact(n - 1). To avoid this problem, we replace the recursive call with a call to a function specified using a reference:
val x: (int -> int) ref = ref (fn _: int => 1) val fact: int -> int = (fn (n: int) => if n = 0 then 1 else n * (!x)(n - 1))
Function fact does not really implement the factorial yet:
/
| 1, if n = 0,
fact(n) = <
| n, otherwise.
\
We can "convince" fact to implement the factorial by resetting reference x to fact itself:
val () = x := fact
With this transformation, we now have a correct implementation of the factorial function:
- fact 0; val it = 1 : int - fact 1; val it = 1 : int - fact 3; val it = 6 : int - fact 10; val it = 3628800 : int
On its face, this example is just a clever trick that allows us to circumvent a language limitation. The trick is not really needed, however, since we have language constructs specifically provided for defining recursive functions (fun and val rec).
A bit of analysis, however, reveals that this example also has a deeper significance; it shows that using references it is possible to change a computation (function, in our case) on the fly, after the computation has been defined. Here we changed the meaning of fact from an almost-identity function to the factorial. This is a powerful feature, which we can exploit in certain applications.
As in other areas of life, power brings with it the need for responsibility. If we have functions whose semantics can be modified from the outside, by changing references, then we must be careful to manage the sequence and timing of these reference changes correctly. It is harder to write such programs, and it is harder to understand them, compared to purely functional programs. Maybe now you understand better why it is easier to work in a purely functional context...
With references we can create datastructures that are not feasible in a purely functional environment. Note: Any datastructure can be emulated in a functional environment, for example, by simulating a collection of memory cells and mapping the respective datastructure to this "memory." We refer here to direct, low-overhead implementations of the datastructures of interest.
A simple example of datastructure that can not be built in a purely functional context is a circular list:
lst --> 1 --> 2 --> 3 -+
^ |
| |
+--------------+
While such circular lists contain a finite number of elements, in some sense they never end - each element has a successor element. Such circular lists arise naturally in many applications.
Let us define a custom datastructure that we can use to implement circular lists:
datatype 'a rlist = Nil | Cons of 'a * 'a rlist ref;
Note that this definition is almost identical to that of a regular custom list; the only - but crucial - difference is that the "next" element is not provided directly, as for a custom list, but through a reference.
val dummy:int rlist ref = ref (Nil: int rlist) val lst:int rlist = Cons(1, ref(Cons(2, ref(Cons(3, dummy))))) val () = dummy := lst
The three lines above show how to create our circular list with three elements. The key step consists in defining a reference to the successor element of 3. The value that dummy initially refers to is indifferent, all we need is to be able to refer to the successor element of 3 using a name. Once we have a reference to the successor of 3, we can build the whole logical list 1-->2-->3. In the final step we reset dummy to refer to the head of the entire list, and we are done.
We can use box-diagrams to precisely represent that datastructure that emerged:
After step 2:
+---+---+ +---+ +---+---+ +---+ +---+---+ +---+ +---+
lst -->| 1 | --+-->| R |--->| 2 | --+-->| R |--->| 3 | --+-->| R |--->|Nil|
+---+---+ +---+ +---+---+ +---+ +---+---+ +---+ +---+
^
|
|
dummy
After step 3:
dummy
|
|
V
+---+---+ +---+ +---+---+ +---+ +---+---+ +---+ +---+
lst -->| 1 | --+-->| R |--->| 2 | --+-->| R |--->| 3 | --+-->| R | |Nil|
+->+---+---+ +---+ +---+---+ +---+ +---+---+ +---+ +---+
| |
| |
+----------------------------------------------------------+
Note that in the diagram above there is no reference pointing to Nil anymore. In effect, this piece of data got "lost;" it can not be reached from any part of our SML program. We call such data garbage. Garbage is useless, but it still consumes memory; the actions that the system takes to recover this memory are known collectively as garbage collection. We will address this topic after we introduce the environment model.
Remember:
+---+ +---+
val v = ref 1 v --->| R |--->| 1 |
+---+ +---+
v --->+---+ +---+
val w = v | R |--->| 1 |
w --->+---+ +---+
+---+ +---+
val v = ref 2 v --->| R |--->| 2 |
+---+ +---+
+---+ +---+
w --->| R |--->| 1 |
+---+ +---+
+---+ +---+
val v = ref 1 v --->| R |--->| 1 |
+---+ +---+
v --->+---+ +---+
val w = v | R |--->| 1 |
w --->+---+ +---+
v --->+---+ +---+
v := 2 | R |--->| 2 |
w --->+---+ +---+
+---+
| 1 |<---- garbage
+---+
Here is a function which, given a regular list, will create a corresponding circular list:
fun makeCircList(l: 'a list): 'a rlist =
case l of
[] => Nil
| _ => let
val dummy: 'a rlist ref = ref (Nil: 'a rlist)
val lst: 'a rlist = !(foldr (fn (e, rl) => ref(Cons(e, rl))) dummy l)
val (): unit = dummy := lst
in
lst
end
And now, to more complex examples. What if we wanted to create lists that have loops, but are not simply circular? For example, let us create the following logical structure, in which the list has a "handle" consisting of element 1, and a circular part, consisting of elements 2 and 3, like so:
lst --> 1 --> 2 --> 3 -+
^ |
| |
+--------+
This problem is more interesting than it appears at first sight, because the unique logical structure that we want to create can be mapped to two box diagrams:
Case 1:
r1 r3
+---+---+ +---+ +---+---+ +---+ +---+---+
lst -->| 1 | --+-->| R |--->| 2 | --+-->| R |--->| 3 | --+-----+
+---+---+ +---+ +---+---+ +---+ +---+---+ |
^ |
| |
+-----------------------------------------+
Case 2:
r1 r3
+---+---+ +---+ +---+---+ +---+ +---+---+ +---+
lst -->| 1 | --+-->| R |--->| 2 | --+-->| R |--->| 3 | --+-->| R |
+---+---+ +---+ +---+---+ +---+ +---+---+ +---+
^ |
| |
+--------------------------------+
Besides having a different physical layout, the two datastructures are subtly different in their semantics as well. To see this, consider references r1, the reference to the successor of element 1, and r3, the reference to the successor element of 3. In case 1, r1 is equal to r3 (these are the same reference; both point to the same R-box). In case 2, the two references are not equal.
Let us now create the two datastructures:
Case 1: val dummy: int rlist ref = ref (Nil: int rlist) val (): unit = dummy := Cons(2, ref(Cons(3, dummy))) val lst: int rlist = Cons(1, dummy) Case 2: val dummy: int rlist ref = ref (Nil: int rlist) val lst2: int rlist = Cons(2, ref(Cons(3, dummy))) val (): unit = dummy := lst2 val lst: int rlist = Cons(1, ref lst2)
Note: other solutions are also possible.
At this point we can create data structures that resemble lists, but which contain loops. What is somebody gives you a value of type 'a rlist - can you tell whether the respective list contains a loop or whether it is in fact equivalent to a regular SML list?
One of the first ideas that comes to mind is to start at the head of the 'a rlist, somehow tag each visited cell, then move to the next list cell. If we ever get to a Nil element, we know that we reached the end of the list, and that the list does not contain loops. If, however, we ever reach an already tagged cell, then the list contains loops.
The problem with the previous approach is that there is no obvious way for us to tag a cell. In particular, if we know nothing about the base type of the list 'a we can not know whether 'a has any field that we could somehow modify to reflect the fact that we visited a cell.
Alternatively, we could create a collection of references that we have already seen, and wait for a reference to show up again in order to detect a loop. This latter approach will work, but it requires additional memory.
What if we dispense with tagging, and we are not willing to allocate significant additional memory to hold references that we have already seen? Can we solve the problem? Yes.
exception Empty
fun tl(l: 'a rlist): 'a rlist ref =
case l of
Nil => raise Empty
| Cons(_, t) => t
fun hasLoop (l: 'a rlist): bool =
let
fun move(one: 'a rlist ref, two: 'a rlist ref): bool =
let
val one = tl(!one)
val two' = tl(!two)
val two = tl(!two')
in
(one = two) orelse (one = two') orelse (move(one, two))
end
in
move (ref l, tl l)
handle Empty => false
end
The idea underlying hasLoop is that we can detect loops by cleverly moving two references along the list. The first reference starts at the head of the list, and always advances by one list cell. The second reference starts at the second list cell, and it always advances by two list cells.
If we ever reach the end of the list, no next cell exists, so function tl will raise an exception. The exception indicates that the list is linear - it contains no loops.
If, however, there is a loop in the list, then the faster-moving reference (say, rf) will enter the loop before the slower-moving reference (say, rs). Once in the loop, neither reference can get out of it. Because rf moves faster around the loop than rs, rf will sooner or later overtake rs. When this happens, we have detected a loop.
We illustrate the behavior of function hasLoop below:
- val dummy = ref (Nil: int rlist); val dummy = ref Nil : int rlist ref - hasLoop(!dummy); val it = false : bool - val v = Cons(5, dummy); val v = Cons (5,ref Nil) : int rlist - dummy:=v; val it = () : unit - hasLoop(v); val it = true : bool - val dummy: int rlist ref = ref (Nil: int rlist) val dummy = ref Nil : int rlist ref - val lst2: int rlist = Cons(2, ref(Cons(3, dummy))) val lst2 = Cons (2,ref (Cons (#,#))) : int rlist - val lst: int rlist = Cons(1, ref lst2); val lst = Cons (1,ref (Cons (#,#))) : int rlist - hasLoop lst; val it = false : bool - val (): unit = dummy := lst2; - hasLoop lst; val it = true : bool