Data Abstraction (Generics)
Let’s look at another situation in which similar code can arise across different classes in Java.
Credits: The material in the first part of this lecture is adapted from Felleisen et al’s How to Design Classes.
1 Abstracting Over Classes with Similar Structure But Different Types
Assume that you have the following two classes. The first associates names with phone numbers (as in a phone book); the second associates prices with the names of menu items. Each has a method for updating a value if the name matches a given string:
// class for storing names and phone numbers |
class PhoneBookEntry { |
String name; |
PhoneNumber phnum; |
|
PhoneBookEntry(String name, PhoneNumber phnum) { |
this.name = name; |
this.phnum = phnum; |
} |
|
void updateNumForName (String forname, PhoneNumber newNum) { |
if (this.name.equals(forname)) { |
this.phnum = newNum; |
} |
} |
} |
|
// class for storing menu item names and prices |
class MenuItem { |
String name; |
int price; |
|
MenuItem(String name, int price) { |
this.name = name; |
this.price = price; |
} |
|
void updatePriceForName (String forname, int newPrice) { |
if (this.name.equals(forname)) { |
this.price = newPrice; |
} |
} |
} |
These two classes look very similar. We would like a way to share the common code here. Creating an abstract class as we have done before is challenging, however, because the types of the phone number and price differ across the two classes.
To be concrete, we want to create a general class for associating names with data, along the lines of the following:
class NameAssoc { |
String name; |
??? data; |
|
NameAssoc(String name, ??? data) { |
this.name = name; |
this.data = data; |
} |
|
void updateValForName (String forname, ??? newData) { |
if (this.name.equals(forname)) { |
this.data = newData; |
} |
} |
} |
Until now, we would have abstracted over the different types using an interface (something like IDataForName or (abstract) class, putting that in place of ???). Neither works in this case because we cannot control what integers (built in) implement or extend. Even if we could, an abstract class wouldn’t even make sense, since integers and PhoneEntries have nothing in common conceptually.
What about the type Object, which we’ve mentioned in passing is a type that all classes extend (it is the "top" of every class hierarchy). Unfortuately, this approach creates several challenges (there’s an optional section on it further down in these notes). We’re going to explore a different option.
2 Abstracting Using Generics
Really, all we want to be able to do is treat the type of data as a parameter, so that we can tailor the NameAssoc class to different types. Classes that are parameterized over types are called generic in Java.
Here’s what a generic class looks like in Java, using NameAssoc as an example. The type of data is given as a parameter (in the angle brackets):
class NameAssoc<TDATA> { |
String name; |
TDATA data; |
|
NameAssoc(String name, TDATA data) { |
this.name = name; |
this.data = data; |
} |
|
void updateDataForName (String forname, TDATA newData) { |
if (this.name.equals(forname)) { |
this.data = newData; |
} |
} |
} |
The following code shows how to create the PhoneBookEntry and MenuItem classes via type parameters:
class PhoneBookEntry extends NameAssoc<PhoneNumber> { |
PhoneBookEntry(String name, PhoneNumber phnum) { |
super(name, phnum); |
} |
} |
|
class MenuItem extends NameAssoc<Integer> { |
MenuItem(String name, int price) { |
super(name, price); |
} |
} |
3 (Optional) Using Object for the Share NameAssoc Field Type
Java helps handle this problem by providing a special class called Object that is the superclass of all other classes. Every time you write a class definition, Java includes an implicit extends Object. This means that Object is a type that represents objects of any class. We could therefore rewrite the code as follows:
class NameAssoc { |
String name; |
Object data; |
|
NameAssoc(String name, Object data) { |
this.name = name; |
this.data = data; |
} |
|
updateDataForName (String forname, Object newData) { |
if (this.name.equals(forname)) { |
this.data = newData; |
} |
} |
} |
|
class PhoneBookEntry extends NameAssoc { |
PhoneBookEntry(String name, PhoneNumber phnum) { |
super(name, phnum); |
} |
} |
|
class MenuItem extends NameAssoc { |
MenuItem(String name, int price) { |
super(name, price); |
} |
} |
Now, let’s imagine that we wanted to create lists of phone-book entries or menu items, and we wanted a way to sort those lists. To write a sorting method, each class would need a method to determine whether one entry or menu item was smaller than another. Let’s look at a reasonable such method for MenuItems, one that compares based on the price:
class MenuItem extends NameAssoc { |
MenuItem(String name, int price) { |
super(name, price); |
} |
|
boolean lessThan(MenuItem thanItem) { |
return this.data < thanItem.data; |
} |
} |
boolean lessThan(MenuItem thanItem) { |
return (Integer)this.data < (Integer)thanItem.data; |
} |
The Object approach to abstracting over classes puts more responsibility on the programmer to use the classes correctly. For example, nothing in the current code prevents a MenuItem from holding a non-numeric piece of data. For example, the following code compiles:
class MenuItem extends NameAssoc { |
MenuItem(String name, int price) { |
super(name, "gotcha"); |
} |
} |
This illustrates that using Object is too general. The Object type is useful when you want to allow any datum and you don’t intend to use it in computation. Here, the above code would raise an error if we tried to use the lessThan method on a MenuItem object. In this case, we would prefer a way to parameterize NameAssoc over specific types in a way that preserves type checking.
Generics also offer much better type checking than abstractions created through Object. The casts in lessThan in the object version are not needed here, because the version of NameAssoc that MenuItem extends has fixed the data to be Integer.
4 Generic Data Structures
Generics are a very useful technique when you create your own classes for data structures. Last week, we defined classes for binary search trees over integers. Conceptually, couldn’t we have made binary search trees over Strings, or security alerts, or library items? Sure, as long as we have a way to decide when one item is smaller than other, it could make sense to put objects of that class into a BST.
Let’s now make a generic version of our BST class. We’ll first adapt the existing BST code we have to work with Strings, then make the classes generic from there.
4.1 BSTs over Strings
Here’s our previous code for BSTS containing ints. We include only the hasElt method for sake of space.
interface IBST { |
// determines whether given element is in the BST |
boolean hasElt (int elt); |
} |
|
class EmptyBST implements IBST { |
EmptyBST() {} |
|
// determines whether the given element is in the BST |
public boolean hasElt (int elt) { return false; } |
} |
|
class DataBST implements IBST { |
int data; |
IBST left; |
IBST right; |
|
DataBST(int data, IBST left, IBST right) { |
this.data = data; |
this.left = left; |
this.right = right; |
} |
|
// determines whether the given element is in the BST |
public boolean hasElt (int elt) { |
if (elt == this.data) |
return true; |
else if (elt < this.data) |
return this.left.hasElt(elt); |
else // elt > this.data |
return this.right.hasElt(elt); |
} |
} |
What if we had instead wanted a BST that contained Strings? Where would this code have to change?
Clearly, all uses of int would have to become String. We would also have to change how we do hasElt, since right now we compare items using operations that are only valid on integers.
What is a meaningful notion of "less than" on Strings? It could mean the length, but the default is usually alphabetical order. The current homework introduced you to the idea of the compareTo method, which Java allows each class to provide a way to order elements. For Strings, compareTo indicates alphabetical order. Here are some examples
> "Kathi".compareTo("Kathi") |
0 |
> "Kathi".compareTo("Kathie") |
-1 |
> "Kathi".compareTo("Ruthie") |
-7 |
> "Kathi".compareTo("Bobby") |
9 |
The magnitude of the number indicates the distance or difference between the strings. For purpose of creating a BST, all we care about is the sign of the number: a negative result means the second string is earlier in alphabetical order and a positive result means the second string is later in alphabetical order.
Here’s hasElt rewritten to work with a type of data that has a compareTo method:
// determines whether the given element is in the BST |
public boolean hasElt (String elt) { |
int compResult = this.data.compareTo(elt); |
if (compResult == 0) |
return true; |
else if (compResult <= -1) |
return this.left.hasElt(elt); |
else // compResult >= 1 |
return this.right.hasElt(elt); |
} |
Notice now that nothing in this code depends on the elt being a String (other than the type we gave to elt). Now we just need to make the classes generic to accept data of any type.
4.2 Generics with Comparable (or other interface constraints)
As a first attempt, let’s just replace all uses of String in our current BST classes with a generic type parameter:
interface IBST<T> { |
// determines whether given element is in the BST |
boolean hasElt (T elt); |
} |
|
class EmptyBST<T> implements IBST<T> { |
EmptyBST() {} |
|
// determines whether the given element is in the BST |
public boolean hasElt (T elt) { return false; } |
} |
|
class DataBST<T> implements IBST<T> { |
T data; |
IBST<T> left; |
IBST<T> right; |
|
DataBST(T data, IBST<T> left, IBST<T> right) { |
this.data = data; |
this.left = left; |
this.right = right; |
} |
|
// determines whether the given element is in the BST |
public boolean hasElt (T elt) { |
int compResult = this.data.compareTo(elt); |
if (compResult == 0) |
return true; |
else if (compResult <= -1) |
return this.left.hasElt(elt); |
else // compResult >= 1 |
return this.right.hasElt(elt); |
} |
} |
This is the right idea, but when we try to compile, Java complains that it can’t be sure that the this.data will have the method compareTo in hasElt. Right now, the generic type T could be anything, so it is possible that we pass a type (like our old friend Dillo) that does not have a compareTo method.
We know from our work with the PriorityQueue class that compareTo is required by the interface Comparable. So to make our generic BST compile we need to restrict the type supplied as T to something that implements Comparable. We put this constraint on DataBST as follows:
class DataBST<T extends Comparable<T>> implements IBST<T> { |
T data; |
IBST<T> left; |
IBST<T> right; |
} |
The use of extends (instead of implements) here is correct, even though it seems inconsistent with what we have done before. The type T could be either a class or an interface. If it is an interface, it wouldn’t implement the constraints of Comparable, but merely add onto them. Thus, extends gets used when interfaces build on one another (making extends more general in this case than implements).
4.3 Using Generic Data Types
You already have a lot of experience using generics, since that’s what LinkedLists are. When we specify the type of contents of a list, we are supplying a type for a generic type parameter.
words LinkedList<String> = new LinkedList<String>(); |
In similar vein, we would create BSTs over strings by writing:
IBST b = new DataBST<String>("hi", |
new EmptyBST<String>(), |
new EmptyBST<String>()); |