Lecture 15: Abstracting over types
Working with generic types, abstracting over types
15.1 The need for more abstraction
Produce the list of all Books published before a given year
Sort this list of Books by their authors’ names
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.
Produce the list of all Books that satisfy the given IBookPredicate predicate
Sort this list of Books by the given IBookComparator comparison function
Do Now!
Write out explicitly the predicate and comparator interfaces for Runners and Authors.
Do Now!
What varies between the interfaces you just defined?
15.2 Introducing generics
interface IBookPredicate { boolean apply(Book b); } interface IRunnerPredicate { boolean apply(Runner r); }
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).
interface IPred<WhateverNameIWant> { boolean apply(WhateverNameIWant t); }
Do Now!
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!)
15.3 Implementing generic interfaces: specialization
class BookByAuthor implements IPred<Book> { public boolean apply(Book b) { ... } ... }
Do Now!
Try defining this in IntelliJ, and see what error message is generated.
Notice also that the argument to apply is a Book, and not a T —
15.4 Instantiating generic interfaces
IBookPredicate byAuthor = new BookByAuthor(...);
IPred<Book> byAuthor = new BookByAuthor(...);
Do Now!
Revise our definition of the RunnerIsInFirst50 predicate over Runners, and revise the examples that use it.
IPred<Runner> inFirst50 = new RunnerIsInFirst50(...);
IPred<Runner> oops = new BookByAuthor(...);
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.
interface IList<T> { IList<T> filter(IPred<T> pred); IList<T> sort(IComparator<T> comp); int length(); ... }
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?
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; } ... }
Do Now!
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; } ... }
// In Examples IList<String> abc = new ConsList<String>("a", new ConsList<String>("b", new ConsList<String>("c", new MtList<String>())));
// In ConsList<T> 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); } }
Do Now!
Implement sort for ConsList<T>. Implement whatever helper methods you need, as well.
15.6 Generic interfaces with more than one parameter
interface IRunner2String { String apply(Runner r); }
interface IFunc<A, R> { R apply(A arg); }
class RunnerName implements IFunc<Runner, String> { public String apply(Runner r) { return r.name; } }
// In IList<T>: ??? map(IFunc<T, ???> f);
// In IList<T>: <U> IList<U> map(IFunc<T, U> f);
Do Now!
I claim that we need this parameter “just for this method”. But another possibility seems to be to add U as a type parameter to IList itself, like this:
interface IList<T, U> { ... IList<U> map(IFunc<T, U> f); ... } What goes wrong with that approach? (There are at least two big problems.)
First, having two type parameters for IList doesn’t make sense: we want a “list of Strings”, or a “list of Runners”, not a “list of Runners/Strings” (whatever that even means)!
Second, it’s too restrictive. We want to be able to map a IList<Book> into an IList<String> to get all the titles, but also to map it to an IList<Author> to get all the authors. There’s just one list of books involved; we shouldn’t need two separate types (i.e., IList<Book, String> and IList<Book, Author>) to get the two different mapping behaviors.
// In ConsList<T> public <U> IList<U> map(IFunc<T, U> f) { return new ConsList<U>(f.apply(this.first), this.rest.map(f)); }
// In MtList<T> public <U> IList<U> map(IFunc<T, U> f) { return this; }
Do Now!
Why not?
// In MtList<T> public <U> IList<U> map(IFunc<T, U> f) { return new MtList<U>(); }
15.7 Digression: lists of numbers and booleans
// Will not work IList<int> ints = new ConsList<int>(1, new ConsList<int>(4, new MtList<int>())); IList<double> 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.
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<Boolean> bools = new ConsList<Boolean>(true, new MtList<Boolean>());
Do Now!
Define a function object to compute the perimeter of a Circle, and use it to compute a list of the perimeters of a list of Circles. (Ignore IShape for now.)
class CirclePerimeter implements IFunc<Circle, Double> { public Double apply(Circle c) { return 2.0 * Math.PI * c.radius; } } // In Examples IList<Circle> circs = new ConsList<Circle>(new Circle(3, 4, 5), new MtList<Circle>()); IList<Double> circPerims = circs.map(new CirclePerimeter());
15.8 Subtleties and challenges with generic types
Generic data types like IList<T> are very useful: they let us implement once-and-for-all a whole suite of functionality for “lists of anything”, and we’ll never have to implement such functionality again. But they come with some down-sides: the only methods we can implement are the ones that make sense for all possible types T. Let’s see what happens in more detail.
15.8.1 Flawed attempt 1
For example, in Lecture 5 we implemented a method totalPrice() for ILoBook. How could we do that for IList<Book>? If we naively write:
interface IList<T> { ... int totalPrice(); // Oh by-the-way, this method only makes sense when T is Book! ... } class MtList<T> { ... public int totalPrice() { return 0; // because an empty list always has zero price } ... } class ConsList<T> { ... public int totalPrice() { ... ??? ... } ... }
Do Now!
Why? What precisely is in our template? (Specifically, what are we allowed to do with this.first?)
15.8.2 Flawed attempt 2
class ConsLoBook extends ConsList<Book> { ... public int totalPrice() { // Now we know that this.first is a Book, so we can call its price() method return this.first.price + this.rest.totalPrice(); } }
Do Now!
What goes wrong now?
class ConsLoBook extends ConsList<Book> { ConsLoBook(Book first, IList<Book> rest) { super(first, rest); } }
15.8.3 Successful attempt
Do Now!
Pretend for a moment that we had a [List-of Book] in Racket. Can you express total-price as a function in Racket, using foldr?
Do Now!
Translate your answer above from Racket to Java.
(define (total-price lob) (foldr (λ(book total) (+ (book-price book) total)) 0 lob))
// Interface for two-argument function-objects with signature [A1, A2 -> R] interface IFunc2<A1, A2, R> { R apply(A1 arg1, A2 arg2); } // In IList<T> <U> U foldr(IFunc2<T, U, U> func, U base); // In MtList<T> public <U> U foldr(IFunc2<T, U, U> func, U base) { return base; } // In ConsList<T> public <U> U foldr(IFunc2<T, U, U> func, U base) { return func.apply(this.first, this.rest.foldr(func, base)); } class SumPricesOfBooks implements IFunc2<Book, Integer, Integer> { public Integer apply(Book b, Integer sum) { return b.price() + sum; } } // Example of using foldr and the function object to obtain the total price class Utils { Integer totalPrice(IList<Book> books) { return books.foldr(new SumPricesOfBooks(), 0); } }
The IFunc2<A1, A2, R> interface is much like the IFunc<A, R> interface, except it represents functions with two arguments, of potentially (but not necessarily) different types. The signature of the foldr method is analogous to its signature in Racket: it takes a function(-object) from the element type (T) and the result type (U), to the result type; and it takes an initial value of the result type; and it produces something of the result type.
The SumPricesOfBooks class dodges the two problems we had earlier: it is what specializes to work with Books, and it avoids any problems of trying to access this.first or this.rest (because on its own, it has nothing to do with ILists at all). In a real sense, the problem with our original IList<T> interface wasn’t that it was too general: it wasn’t general enough, and didn’t have the useful foldr method!
15.9 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.