6.2.1
15 Lecture 15: Abstracting over types
Working with generic types, abstracting over types
15.1 The need for more abstraction
These methods were neatly structurally recursive, and were the translation to Java of functions
we had written in Fundies I before.
But they weren’t particularly reusable: if we wanted to sort a list of Books by their prices, or produce a list of Books
written by a particular author, we’d have to implement these methods a second time, with slightly
different names and slightly different signatures and slightly different code,
that nevertheless did nearly exactly the same thing as the original methods.
In the
past two lectures, we’ve seen how to use function objects to generalize these two questions:
Now we only have to implement these methods once, and we can create as many classes as we’d like
that implement IBookPredicate or IBookComparator—one class for each particular predicate
or sorting order we’d like—to use with these methods.
But again these methods and interfaces aren’t reusable enough: if we’d like to sort a list of Runners,
or a list of Circles, or a list of ancestry trees, we’d have to re-implement these methods in each
ILoWhatever interface, and define new predicate and comparator interfaces (IRunnerPredicate,
ICircleComparator, etc.) that have slightly different names and slightly different
signatures, but nearly identical purposes.
Write out explicitly the predicate and comparator interfaces for Runners and Authors.
What to do? Whenever we encounter such duplication,
we know that the design recipe for abstraction suggests we find the parts that stay the same and find the parts
that vary, and abstract away the parts that vary.
What varies between the interfaces you just defined?
15.2 Introducing generics
The only differences between an IRunnerPredicate and an IBookPredicate are their names,
and the type of the argument supplied to the apply methods:
interface IBookPredicate { |
boolean apply(Book b); |
} |
interface IRunnerPredicate { |
boolean apply(Runner r); |
} |
We need a mechanism to express abstraction over this type, to define a general-purpose
IPred interface that describes predicates over any particular type. In Java,
such abstractions are known as generics, because they generalize away from specific types.
We define a simple generic interface as follows:
interface IPred<T> { |
boolean apply(T t); |
} |
Typical Java convention is to use T to be the name of an arbitrary type,
and if additional type parameters are needed, they are often named U, V, or S
(simply because those letters are near T in the alphabet.
We read this declaration in words as “the interface IPred of T”. The syntax <T> states that
this interface is parameterized by a type, which we will name T within the definition of this interface.
Said another way, T is bound as a type parameter within the scope of this interface (much like
regular parameters to methods are bound within the scope of the method).
Note that the particular name T is meaningless: we could replace it with any other name we wanted,
as long as we replaced it consistently throughout the interface definition. In other words, the definition
above is exactly the same as
interface IPred<WhateverNameIWant> { |
boolean apply(WhateverNameIWant t); |
} |
Suppose we forgot to write the <T> syntax. What would Java report as the error if we defined
interface IPred { |
boolean apply(T t); |
} |
instead? (Read it carefully!)
The definition above is not parameterized, so Java thinks that T must be the actual name of some actual
class or interface. If no such class or interface is defined, Java will complain that it can’t find any such
definition.
15.3 Implementing generic interfaces: specialization
When we want to use this interface in our code, we must specialize the generic interface to some specific type.
For example, we could revise our BookByAuthor as follows:
class BookByAuthor implements IPred<Book> { |
public boolean apply(Book b) { ... } |
... |
} |
Notice that we do not say implements IPred — such a statement is simply meaningless.
Try defining this in Eclipse, and see what error message is generated.
What is an IPred — what kind of data is it a predicate about? Merely writing “IPred”
doesn’t provide enough information; we must specialize the interface when using it.
Notice also that the argument to apply is a Book, and not a T — when we specialized the interface,
we consistently substituted Book for T everywhere that it was mentioned in the interface.
15.4 Instantiating generic interfaces
In our Examples class, we now need to update our definitions: we can no longer write
IBookPredicate byAuthor = new BookByAuthor(...); |
We must instead write
IPred<Book> byAuthor = new BookByAuthor(...); |
since we have upgraded our code from the original interface to this new generic one.
Revise our definition of the GoodStart predicate over Runners, and
revise the examples that use it.
IPred<Runner> goodStart = new GoodStart(...); |
The parameters to a generic interface are part of the type, and getting them wrong will result in a type error.
For example, writing
IPred<Runner> oops = new BookByAuthor(...); |
will result in a type error, because BookByAuthor implements IPred<Book>, not IPred<Runner>.
But there’s still more duplication we can eliminate...
15.5 Generic classes: implementing lists
We still have ILoString and ILoRunner (and ILoBook and ILoShape and many others)
lying around. We’ve had to implement methods like sort and filter and length on all of them.
Now with generics, we can finally resolve all that duplication.
The common features of all of these interfaces are pretty clear: they all represent lists of something.
So we’ll define a generic interface IList<T> as follows:
interface IList<T> { |
IList<T> filter(IPred<T> pred); |
IList<T> sort(IComparator<T> comp); |
int length(); |
... |
} |
How can we implement the classes? If we just write
class MtList implements IList<T> { ... } |
Unless, of course, you’ve defined a class or interface named T. But why would
you give a class such a meaningless name?
Java will complain that it does not know what T is.
The solution is that we need to make the class itself be parameterized:
class MtList<T> implements IList<T> { |
public IList<T> filter(IPred<T> pred) { return this; } |
public IList<T> sort(IComparator<T> comp) { return this; } |
public int length() { return 0; } |
... |
} |
This definition says “An MtList of T is a list of T.” And now we can implement our methods
once and for all on this class, and not worry about implementing them ever again. Except for ConsList, of course.
Define a generic ConsList class.
class ConsList<T> implements IList<T> { |
T first; |
IList<T> rest; |
ConsList(T first, IList<T> rest) { |
this.first = first; |
this.rest = rest; |
} |
... |
} |
What type should the fields be? A ConsList of T items ought to have a T
value as its first field, and another list of T items as its rest.
When we construct a new list now, we need to specify the type of the items in it:
IList<String> abc = new ConsList<String>("a", |
new ConsList<String>("b", |
new ConsList<String>("c", new MtList<String>()))); |
Defining methods such as filter for ConsList<T> is straightforward, as long as we remember to specify the type
of the new ConsList being constructed:
public IList<T> filter(IPred<T> pred) { |
if (pred.apply(this.first)) { |
return new ConsList<T>(this.first, this.rest.filter(pred)); |
} else { |
return this.rest.filter(pred); |
} |
} |
Implement sort for ConsList<T>. Implement whatever
helper methods you need, as well.
15.6 Generic interfaces with more that one parameter
Suppose we wanted to produce the list of all Runners’ names. Without generics, we might
come up with an interface definition like this:
interface IRunner2String { |
String apply(Runner r); |
} |
The name is reasonably suggestive: it takes a Runner and produces a String. But we can see
that this way lies the same code duplication problems that we had before: soon we’ll want a
IBook2String to get titles, or IBook2Author to get Authors, and again we have nearly
identical definitions that differ only in their types.
Instead, let’s define the following interface, that’s generic in two type parameters:
interface IFunc<A, R> { |
R apply(A a); |
} |
This interface describes function objects that take an argument of type A and return a value of type R.
In Fundies I notation, this describes functions with the signature A -> R. Now we can define a class that implements
this interface:
class RunnerName implements IFunc<Runner, String> { |
public String apply(Runner r) { return r.name; } |
} |
And we could use it to get the list of Strings of the names from a list of Runners: this is just
one specialization of a generic map method on IList<T>.
Writing the signature for map is a bit tricky:
??? map(IFunc<T, ???> f); |
We’d like to put some other type parameter in for the ???, but the only type parameter we have so far is T.
We need some additional syntax to define a type parameter just for this method:
<U> IList<U> map(IFunc<T, U> f); |
All the angle brackets can be a bit hard to read. Read this line as “In IList<T>, map is a method parameterized by U,
that takes a function from T values to U values, and produces an IList<U> as a result.”
Now we can implement this for ConsList<T>:
public <U> IList<U> map(IFunc<T, U> f) { |
return new ConsList<U>(f.apply(this.first), this.rest.map(f)); |
} |
What should we do for the empty case? Can’t we just use our usual implementation?
public <U> IList<U> map(IFunc<T, U> f) { return this; } |
No!
Just because we have an empty list of T does not mean that we have an empty list of U — the types
don’t match, and we get a compile error. Instead we need to write:
public <U> IList<U> map(IFunc<T, U> f) { return new MtList<U>(); } |
Much better.
15.7 Digression: lists of numbers and booleans
Java will not let us write the following definitions:
IList<int> ints = new ConsList<int>(1, |
new ConsList<int>(4, new MtList<int>()); |
IList<doubles> dbls = new ConsList<double>(1.5, |
new ConsList<double>(4.3, new MtList<double>()); |
IList<boolean> bools = new ConsList<boolean>(true, new MtList<boolean>()); |
The technical reasons for this limitation are beyond the scope of this course, or even
of object-oriented design. But if you are interested, take a compilers course
or a programming languages course, which will explain the subtle details of
what’s actually happening here.
This is because int and double and boolean are all primitive types,
and Java only permits class or interface types as the type parameters to generics.
Instead, Java defines classes Integer, Boolean and Double, that essentially
are just “wrappers” for the primitive values. So instead we can write
IList<Integer> ints = new ConsList<Integer>(1, |
new ConsList<Integer>(4, new MtList<Integer>()); |
IList<Double> dbls = new ConsList<Double>(1.5, |
new ConsList<Double>(4.3, new MtList<Double>()); |
IList<Boelean> bools = new ConsList<Boolean>(true, new MtList<Boolean>()); |
Define a function object to compute the area of a Circle, and use it
to compute a list of the areas of a list of Circles. (Ignore IShape for now.)
class CircleArea implements IFunc<Circle, Double> { |
public Double apply(Circle c) { return c.area(); } |
} |
IList<Circle> circs = new ConsList<Circle>(new Circle(3, 4, 5), |
new MtList<Circle>()); |
IList<Double> circAreas = circs.map(new CircleArea()); |
15.8 Summary
Generic types let us describe families of related types that define nearly the same thing,
but differing slightly in the types inside them. We can use generic types to define data,
like IList<T>, and to define function object interfaces like IFunc<T, U>. This lets
us remove much of the “boilerplate” repetitive code we have had to deal with up until now.
Next time, we’ll combine the material of these past four lectures to answer a seemingly simple
question: How can I take an IList<IShape> and get a list of the areas of the shapes?
Try to design a solution to this problem. Where does the pattern above get stuck.
How might the techniques from
the last few lectures
help?