Before You Begin
Pull the skeleton code from GitHub as usual. Open IntelliJ and remember to Import Project and add javalib
to the Project Structure.
If something isn’t working, usually re-importing the project (choosing to overwrite any existing configurations) and re-adding
javalib
to the Project Structure will fix things.
Linked Lists
In today’s lab, we’re going to continue our discussion of linked lists by introducing a new way of organizing programs through encapsulation.
We will first define a few terms.
- Module
- A set of methods that work together as a whole to perform some task or set of related tasks.
- Encapsulated
- A module is said to be encapsulated if its implementation is completely hidden, and it can be accessed only through a documented interface.
Encapsulation
The previous iteration of IntList
that we learned is impractical to use at scale.
In our current IntList
, the notion of a node in the list and the list itself are not separate. This means that someone who wants to use the IntList
class would need to spend a lot of time adding null
checks and handling errors that might come as a result of accidentally or intentionally modifying the first
or rest
of a list. By giving users direct control to the IntList
data structure’s internals, in some ways, we’ve actually made it harder to reason about the correctness of a program.
We want to separate the idea of the list as an entity from an entry in the list, much like the abstraction barrier you might remember from CS 61A. We’ll see that this design philosophy of encapsulation will come up over and over again for each of the data structures we study in the coming weeks.
Read the entirety of SLLists, Chapter 2.2.
Then, discuss how the idea of invariants at the end of the chapter relates to how someone might reason about the correctness of code that uses
IntList
vs.SLList
.
Discussion: addFirst
- Exercise 2.2.1
- The curious reader might object and say that the
IntList
would be just as easy to use if we simply wrote anaddFirst
method. Try to write anaddFirst
method to theIntList
class. You’ll find that the resulting method is tricky as well as inefficient.
In the textbook, we implemented SLList.addFirst
.
public class SLList {
public IntNode first;
public SLList(int x) {
first = new IntNode(x, null);
}
/** Adds an item to the front of the list. */
public void addFirst(int x) {
first = new IntNode(x, first);
}
}
Discuss with your partner how you might implement IntList.addFirst
.
Consider the case below, and how you might make it so that the IntList L
in main
can see the changes. It’s not as straightforward as you might think!
public class IntList {
public int first;
public IntList rest;
public void addFirst(int x) {
// Describe in your own words what you might put here.
}
public static void main(String[] args) {
IntList L = IntList.of(2, 3, 4, 5);
L.addFirst(1);
System.out.println(L.first);
}
}
Printing out L.first
should display the number 1 even without reassigning L
in the main
method.
Hint: If we know that the reference, L
, cannot be changed, what are our options for making the changes in addFirst
visible to main
? We know that, due to Golden Rule of Equals, there’s no way to change what L
points to from the addFirst
method. Given these constraints, try drawing a box-and-pointer diagram that shows what it should look like before and after calling addFirst
.
Exercise: toString
and equals
Normally, we would ask you to manually write out these methods to convert a list into its String representation and to write an equality checker. However, since we’re using IntelliJ, let’s utilize one of its features: automatic code generation.
Move your blinking cursor to somewhere inside the IntNode
class and press the Code tab at the top. Then, click the Generate
button and this should bring up a Generate menu at your cursor. Go ahead and select toString()
, take a look at the dialog, and press OK.
You’ll notice a toString()
method is generated for the IntNode
class, using the fields you specified. (Both fields should have been highlighted.) Now pull up the menu again, and this time select equals()
and hashCode()
. Look at the dialog boxes, press OK & next as needed, and you’ll see the equals()
and hashCode()
methods appear automatically. You can feel free to delete the hashCode()
method as we won’t need it and will be learning about it later in the course.
Repeat the process with your cursor inside the SLList
class, and now you’ll have equals()
and toString()
methods automatically generated and working. This doesn’t work 100% of the time, but it’s a great way to get a framework going quickly. You can even generate constructors and several other kinds of useful methods!
Make sure to generate
toString
andequals
for bothIntNode
andSLList
or it won’t pass the tests later.If the IntelliJ-generated
equals
method includes anif
case mentioning the keywordsuper
, delete that test case.Note that you’ll still need to clean-up the code a bit to pass the style checker.
Exercise: add
As a bit of practice working with such lists, implement the add
method in SLList.java
, which adds the int x
to the list at the specified int index
.
public void add(int index, int x) {
// TODO
}
Check your solution with the tests provided in SLListTest.java
.
Exercise: reverse
Over the next few steps, we’re going to be working on a method to reverse an SLList
using our current SLList
implementation.
We will assume the restriction that the reversal is to be done in place, destructively, without creating any new nodes.
public void reverse() {
// TODO
}
However, don’t implement the method just yet!
Testing reverse
Make sure that you’re familiar with how to test with JUnit from the section on Testing in last lab.
We’re going to write a test before we write reverse
, as part of a methodology called test-driven development (TDD).
Thinking about test cases before writing code can help us write better code more quickly compared to rushing ahead and trying to solve the problem without first having considered all the different scenarios.
We’ve included a very basic test, testReverse
, in SLListTest.java
. Add a couple more test cases in the same style as the first few to test larger lists of length 2, 3, 4, 5, and more until you’re satisfied that the tests cover all the bases.
In the end, your tests should test at least the following three situations:
- That the function returns a reversed list.
- That the function is destructive, i.e. when it is done running, the list pointed to by
A
has been tampered with. You can useassertNotEquals
. This is sort of a silly test. - That the method handles a
null
input properly.
Once you’ve written your test, discuss with your partner to make sure you’ve both written tests for at least all three situations above.
Implementing reverse
public void reverse() {
// TODO
}
Try running the test we just wrote; it should fail. This is a good thing because it means our tests are working as expected.
Implement reverse
, and rerun the tests until all of them pass. A static
helper method will be helpful.
Why? Discuss with your partner why a
static
helper method might be useful here, and what its method signature would be.The method signature consists of the method name and parameter types.
Hint: This is a challenging problem. If you’re stuck, try drawing things out on a whiteboard, talking it through with your partner, and solving each test one-by-one, even if the solution doesn’t look elegant. For example, it is possible to implement reverse
using a huge if
statement that performs different operations for different-length lists.
public void reverse() {
if (size() == 0) {
// Do nothing: reverse of an empty list requires no change.
} else if (size() == 1) {
// Do nothing: reverse of a single item list requires no change.
} else if (size() == 2) {
IntNode first = sentinel.next;
IntNode second = sentinel.next.next;
second.next = first;
first.next = null;
sentinel.next = second;
} else if (size() == 3) {
...
}
}
Even though this might feel icky, we can use this approach to help find patterns in the recursive logic and help us get a sense for what operations are needed to solve the problem.
Some people find the rush of TDD addictive. You basically set up a little game for yourself to solve. Some people hate it. Your mileage may vary. Whether you personally enjoy the TDD flow or not, writing tests will be one of the most important skills you learn here at Berkeley, and getting test-infected will save you and your future colleagues an enormous amount of time and misery.
If you find the concept of test-driven development interesting, or just want to learn more about testing philosophy and the conversation around TDD, here are a couple recommended optional readings:
Doubly-Linked
There are some major issues, both efficiency-wise and code-simplicity-wise with the linked list implementations we’ve been working with so far:
- It’s easy to insert into the front of the list, but requires a lot more work to insert into the back of the list.
- If we want to remove a node in our linked list, even if we have a reference to the node we want to remove, we have to traverse until we find the node before it in order to remove it.
The solution to these problems is to use a doubly-linked list.
Read the entirety of DLLists, Chapter 2.3. It’s not too long.
Exercise: DLList
Methods
Implement add
and remove
in DLList.java
.
Read through the existing code—some of it can be used as a reference and example for how you can structure your methods. You should never need to type the word null
or do any edge case checking, thanks to the sentinel design.
Recap
- Naked Data Structures
IntLists
are hard to use. In order to use anIntList
correctly, the programmer must understand and utilize recursion even for simple list-related tasks.- Adding Clothes
- First, we will turn the
IntList
class into anIntNode
class. Then, we will delete any methods in theIntNode
class. Next, we will create a new class calledSLList
, which contains the instance variablefirst
, and this variable should be of typeIntNode
. In essence, we have “wrapped” ourIntNode
with anSLList
. - Using SLList
- As a user, to create a list, I call the constructor for
SLList
, and pass in the number I wish to fill my list with. TheSLList
constructor will then call theIntList
constructor with that number, and setfirst
to point to theIntList
it just created. - Improvement
- Notice that when creating a list with one value, we wrote
SLList list = new SLList(1)
. We did not have to worry about passing in a null value like we did with ourIntList
. Essentially, the SLList class acts as a middleman between the list user and the nakedIntList
. - Public vs. Private
- We want users to modify our list via
SLList
methods only, and not by directly modifyingfirst
. We can prevent other users from doing so by setting our variable access toprivate
. Writingprivate IntNode first;
prevents code in other classes from accessing and modifyingfirst
(while the code inside the class can still do so). - Nested Classes
- We can also move classes into classes to make nested classes! You can also declare the nested classes to be private as well; this way, other classes can never use this nested class.
- Static Nested Classes
- If the
IntNode
class never uses any variable or method of theSLList
class, we can turn this class static by adding the “static” keyword. - Recursive Helper Methods
- If we want to write a recursive method in
SLList
, how would we go about doing that? After all, theSLList
is not a naturally recursive data structure like theIntNode
. A common idea is to write an outer method that users can call. This method calls a private helper method that takesIntNode
as a parameter. This helper method will then perform the recursion, and return the answer back to the outer method. - Caching
- Previously, we calculated the size of our
IntList
recursively by returning 1 + the size of the rest of our list. This becomes really slow if our list becomes really big, and we repeatedly call our size method. Now that we have anSLList
, lets simply cache the size of our list as an instance variable! Note that we could not do this before with outIntList
. - Empty Lists
- With an
SLList
, we can now represent an empty list. We simply setfirst
tonull
andsize
to0
. However, we have introduced some bugs; namely, becausefirst
is nownull
, any method that tries to access a property offirst
(likefirst.item
) will return aNullPointerException
. Of course, we can fix this bug by writing code that handles this special case. But there may be many special cases. Is there a better solution? - Sentinel Nodes
- Lets make all
SLList
objects, even empty lists, the same. To do this, lets give each SLList a sentinel node, a node that is always there. Actual elements go after the sentinel node, and all of our methods should respect the idea that sentinel is always the first element in our list. - Invariants
- An invariant is a fact about a data structure that is guaranteed to be true (assuming there are no bugs in your code). This gives us a convenient checklist every time we add a feature to our data structure. Users are also guaranteed certain properties that they trust will be maintained. For example, an
SLList
with a sentinel node has at least the following invariants:
- The sentinel reference always points to a sentinel node.
- The front item (if it exists), is always at sentinel.next.item.
- The size variable is always the total number of items that have been added.
- SLList Drawbacks
addLast()
is slow! We can’t add to the middle of our list. In addition, if our list is really large, we have to start at the front, and loop all the way to the back of our list before adding our element.- A Naive Solution
- Recall that we cached the size of our list as an instance variable of
SLList
. What if we cached thelast
element in our list as well? All of a sudden,addLast()
is fast again; we access the last element immediately, then add our element in. ButremoveLast()
is still slow. InremoveLast()
, we have to know what our second-to-last element is, so we can point our cachedlast
variable to it. We could then cache asecond-to-last
variable, but now if I ever want to remove the second-to-last element, I need to know where our third-to-last element is. How to solve this problem? - DLList
- The solution is to give each
IntNode
aprev
pointer, pointing to the previous item. This creates a doubly-linked list, orDLList
. With this modification, adding and removing from the front and back of our list becomes fast (although adding/removing from the middle remains slow). - Incorporating the Sentinel
- Recall that we added a sentinel node to our
SLList
. ForDLList
, we can either have two sentinels (one for the front, and one for the back), or we can use a circular sentinel. ADLList
using a circular sentinel has one sentinel. The sentinel points to the first element of the list withnext
and the last element of the list withprev
. In addition, the last element of the list’snext
points to the sentinel and the first element of the list’sprev
points to the sentinel. For an empty list, the sentinel points to itself in both directions. - Generic DLList
- How can we modify our
DLList
so that it can be a list of whatever objects we choose? Recall that our class definition looks like this:public class DLList { ... }
We will change this to:
public class DLList<T> { ... }
where
T
is a placeholder object type. Notice the angle bracket syntax. Also note that we don’t have to useT
; any variable name is fine. In ourDLList
, our item is now of typeT
, and our methods now takeT
instances as parameters. We can also rename ourIntNode
class toTNode
for accuracy. - Using Generic DLList
- Recall that to create a
DLList
, we typed:DLList list = new DLList(10);
If we now want to create a
DLList
holdingString
objects, then we must say:DLList<String> list = new DLList<>("bone");
On list creation, the compiler replaces all instances of
T
withString
! We will cover generic typing in more detail in later lectures.
Deliverables
Here’s a short summary on what you need to do to complete this lab.
- Identify the differences between today’s implementation of
IntList
and yesterday’s implementation. - Use the Intellij code generation tool to auto-generate a
toString
andequals
method for theIntNode
andSLList
classes. - Implement
add
for today’s implementation ofSLList
. - Implement
reverse
with a recursive helper method. - Understand the benefits and trade-offs between using a singly-linked and double-linked list.
- Implement
add
andremove
methods inDLList.java
.
There is no assessment for this lab, so you can either work ahead, spend time on the project, or ask questions about the upcoming exam.