|
Comp201: Principles of Object-Oriented Programming I
|
In this lab, we will build immutable lists (lists that can't be changed once they are made) and explore the algorithms we can write to process the data that they hold.
First, recall our definition of a list:
A List is an abstract container of objects:
- An Empty list is a List and has no data,
- A Non-Empty list is a List, and has a data element, called first, and has another List, called rest.
This definition is independent of whatever programming language we might use to implement it. It is strictly our abstract definition of a "list". The golden rule here is
Your code should always match your abstractions as closely as possible.
Looking at the above definition of a list, we see two things:
These two observations map directly to our OO modeling notions of inheritance and composition. Thus we can immediately draw a UML diagram corresponding to our definition:
.
Notice that we have used an interface to represent the abstract list, as interfaces are commonly used to represent the pure abstract behaviors such as in a list.
This lab uses Full Java language level.
Let's go through a series of exercises to build our list system and to endow it with some processing capabilities. Note that the completion of these exercises is required for HW05:
Before we make rabbit stew, we must first catch the rabbit....
Next, we need to test what we have so we can be confident that the basic structure is set up correctly:
The code for this point of the lab can be downloaded here (no peeking!!).
OK, so far so good.
Now let's try to build a list up.
Why didn't our tests ever print the elements in our list?
The code for this point of the lab can be downloaded here (no peeking!!).
To get a look at what inside our list, let's override the toString method inherited from Object. This method returns a string representation of the object. We'll write something simple first and then later, when we learn a some better techniques, we'll fix it up to be much nicer.
The toString method has the following signature (the description of the name of the method, its return value and the types of its input parameters):
public String toString()
Note: There is more than one "solution" to this problem--it all depends on what you consider is the "right" string representation.
Run your tests again. Did you get the results you wanted?
The toString method is an example of a recursive algorithm. If you look at the code in the NEList, you see that the result of toString is determined (partially) by the result of toString being called on rest. It almost looks like toString calls itself since it calls a method of the same name, but it does not because the toString being called is that of a different object. That is, the recursive call is to _rest.toString() not to this.toString(). Recursive algorithms in OOP are actually examples of delegation.
How does the algorithm ever end you ask? Well, at some point, the list is an empty list. The toString of the empty list does not need to call anything else, as the empty list knows the answer unequivocally. The algorithm thus progresses no further and each call starts to return to its caller.
The key issue here is that an abstract list "knows" how to calculate the product of its elements. The concrete implementations of the abstract list each knows how to calculate the product for themselves, without worrying about the other. Notice that the MTList code has no references to NEList and vice versa. The algorithm never has to check whether the list is empty or not because each kind of list knows exactly what to do.
All recursive algorithms are characterized by having two parts:
Notice that the code written is exactly the same as the play-acted process! This is what we mean by "expressing your abstraction directly in code".
Modify your test code such that it actually test that toString returns the correct result. You will want to remove the System.out.println statements.
First, a bit of play-acting:
Now, implement the above process in code. Proceed along the lines that your wrote the toString method above.
Notice how the algorithm follows this process (assume a non-empty list here):
What we see is that the inductive case is characterized by waiting for its rest to return its result so that it can finish its own calculation. We refer to this as "pending operations". As the recursion unrolls towards the base case, each inductive case encountered enters a pending operation state. By the time the base case is reached, every non-empty list object involved has a pending operation, waiting for a result to be returned to them. When the base case finally returns the result, the pending calls resume one by one and the recursion rolls back out to the beginning of the list.
The result is first returned by the base case, and then the inductive case takes that result and calculates its result. If one were to take a bird's eye view of the process on the whole list, one would see the result accumulating from the back of the list (the empty list) towards the front of the list (non-empty list). We like to call this "reverse accumulation" since the result is accumulated as the algorithm "reverses" its way back out of the list.
But the key to understanding recursion is not to try to see the whole list at once. Recursion is a very locally defined process, that is, the base case is defined a doing something and the inductive case is defined as doing something else. All that is required is that the inductive case call the next element in the chain of objects (and the base case does not) and the process will work. One does not need to know what kind of object the next (i.e rest) is, nor how long the total chain is, just that the next object has the necessary abstract behavior. One never needs and quite frankly, never should, think about the process over the whole data structure. The beauty of recursion is that it reduces the processing of very complex data structures down to very simple, unrelated operations.
Here are some hints on how to deal with int when your system wants Object:
Add a test method to your Test_IList test class to test that your algorithm produces the correct result.
Write the reverse accumulation algorithm that will return the sum of all the elements of a list.
Add a test method to your Test_IList test class to test that your algorithm produces the correct result.
This is a different twist on a reverse accumulation algorithm. Write an algorithm that will return a copy of the list. That is, the result is a new list with the same values in first if the list is non-empty. The empty list is a singleton, so the issue is moot there.
If you're not sure what to do, read the statement of the problem above very carefully and then write down, in Java, exactly what the algorithm is. Don't try to "figure out how to do it"--just write down what the problem says it is.
Add a test method to your Test_IList test class to test that your algorithm produces the correct result.
Reverse accumulation algorithms work very nicely but are admittedly not always the most intuitive algorithm. When one is faced with a list of numbers, do we naturally add the numbers from the bottom to the top? Or do we add them from the top to the bottom, accumulating an answer as we move forward through the list? If that's the way that we view the problem, then we should write exactly that in code.
First, let's try some play-acting to see what exactly is going on:
Things to note about this forward accumulation algorithm:
What does this tell us? It tells us that an IList has two methods used to add using a forward accumulation algorithm.
Add a test method to your Test_IList test class to test that your algorithm produces the correct result.
Write a forward accumulation algorithm to multiply all the elements of a list together and return the result.
Add a test method to your Test_IList test class to test that your algorithm produces the correct result.
Write a forward accumulation algorithm to return a new list with all the same elements as the original list, but in reverse order.
Compare this algorithm tot he reverse accumulation copy algorithm.
Can reverse even be written using reverse accumulation?
Add a test method to your Test_IList test class to test that your algorithm produces the correct result.
We wrote a simple toString method above, but the output, while "correct" wasn't exactly pretty or intuitive. For instance, what if we had this list, ("ab", "c", "de")? The output would be a rather confusing "abcde". What we want is an output that looks like we expect it to look, that is, something more like "(ab, c, de)" with commas and parentheses.
In order to be able to express the problem, we must first understand it. We must learn to express the obviousness about it. Let's see how we can look at empty and non-empty lists respectively:
()
(1, 2, 3, 4, 5)
What we see is
Write this algorithm using forward accumulation. Simply replace the old toString code. Remember, just express what is already written down above.
Can you generate this same output using reverse accumulation for the recursive part of the above algorithm? Is there more than one way to do this using reverse accumulation? (Here's a start on a reverse accumulation algorithm)
Add a test method to your Test_IList test class to test that your algorithm produces the correct result.
Last Revised Thursday, 03-Jun-2010 09:50:18 CDT
©2007 Stephen Wong and Dung Nguyen