6.5
Lecture 14: Abstractions over more than one argument
Comparison function objects
Let’s continue with our example of Runners from Lecture 13. Today we want to help
the race organizers find the final standings, find the winner of the race, and handle check-in
registration, which requires knowing the names of all the runners.
What kinds of questions will we need to ask about Runners to solve these
problems?
14.1 Finding the final standings
The final standings of the race are determined by how fast each Runner completed the race: in other words,
it is the list of Runners as sorted by the times. We have seen several times how
to implement a sort() method to sort a list; but every time we did so, we narrowly hard-coded
the method to use a particular sort order. Let’s revisit those methods, and abstract away
the sorting order so we have a more general-purpose sorting algorithm.
Reconstruct the insertion-sort algorithm, for a list of Runners as sorted by their times in increasing order.
interface ILoRunner { |
ILoRunner sortByTime(); |
ILoRunner insertByTime(Runner r); |
} |
public ILoRunner sortByTime() { return this; } |
public ILoRunner insertByTime(Runner r) { return new ConsLoRunner(r, this); } |
public ILoRunner sortByTime() { |
return this.rest.sortByTime().insertByTime(this.first); |
} |
public ILoRunner insertByTime(Runner r) { |
if (this.first.finishesBefore(r)) { |
return new ConsLoRunner(this.first, this.rest.insertByTime(r)); |
} |
else { |
return new ConsLoRunner(r, this); |
} |
} |
boolean finishesBefore(Runner r) { return this.time < r.time; } |
But hard-coding insertByTime and sortByTime and even finishesBefore is inflexible;
we can see that this is likely to lead to duplication, just like our find methods in Lecture 13
did. We should abstract away these details, and to do that,
we need to be able to compare two Runners. Our predicates from the previous lecture
won’t help, as they let us answer a boolean question about one Runner: they have the
wrong signature.
We therefore need to design a new interface for a new kind of function object: an ICompareRunners.
What signature should this interface define?
interface ICompareRunners { |
boolean comesBefore(Runner r1, Runner r2); |
} |
We can then define a CompareByTime class that returns true when the first provided Runner
ran a faster (i.e. smaller) time than the second.
class CompareByTime implements ICompareRunners { |
public boolean comesBefore(Runner r1, Runner r2) { |
return r1.time < r2.time; |
} |
} |
Revise the sorting methods above to use this new abstraction.
We add to our methods a parameter of type ICompareRunners, and remove the parts of
the code that explicitly refer to times:
interface ILoRunner { |
ILoRunner sortBy(ICompareRuners comp); |
ILoRunner insertBy(ICompareRunners comp, Runner r); |
} |
public ILoRunner sortBy(ICompareRunners comp) { return this; } |
public ILoRunner insertBy(ICompareRunners comp, Runner r) { |
return new ConsLoRunner(r, this); |
} |
public ILoRunner sortBy(ICompareRunners) { |
return this.rest.sortBy(comp).insertBy(comp, this.first); |
} |
public ILoRunner insertBy(ICompareRunners comp, Runner r) { |
if (comp.comesBefore(this.first, r)) { |
return new ConsLoRunner(this.first, this.rest.insertBy(comp, r)); |
} |
else { |
return new ConsLoRunner(r, this); |
} |
} |
Now, instead of saying
, we would instead write
marathon.sortBy(new CompareByTime()) |
Read the original code as “take this marathon and sort it by time”. Read the new
code as “take this marathon and sort it, comparing the Runners by time”.
The latter sentence emphasizes the abstraction that we just achieved, separating the general-purpose
sorting operation from the particular ordering we are using.
14.2 Three-valued comparisons
The ICompareRunners interface has one method, comesBefore,
that returns a boolean value: either the first Runner comes before
the second or it doesn’t. But that doesn’t quite give us enough information
to distinguish whether two Runners tied, or whether the first Runner
comes after the second.
Said another way, we might want to produce a three-valued comparison: either the first Runner
comes before the second, is tied with the second, or comes after the second. There
are only two distinct boolean values, so to produce a three-way result, we need a different type of
data. Java picks a particularly convenient convention: it uses integers instead.
To distinguish this new convention from our existing one, we’ll define a new interface:
interface IRunnerComparator { |
int compare(Runner r1, Runner r2); |
} |
(A mnemonic for the name: A comparator is something that can compare. A IRunnerComparator
is therefore something that can compare two Runners.)
To adapt our sortBy method to use this new interface, we just need to change the use of comesBefore:
public ILoRunner insertBy(IRunnerComparator comp, Runner r) { |
if (comp.compare(this.first, r) < 0) { |
return new ConsLoRunner(this.first, this.rest.insertBy(comp, r)); |
} |
else { |
return new ConsLoRunner(r, this); |
} |
} |
Modifying our CompareByTime class is also straightforward. The purpose statement
for the compare method suggests we might want a three-way if-statement:
class CompareByTime implements IRunnerComparator { |
public int compare(Runner r1, Runner r2) { |
if (r1.time < r2.time) { return -1; } |
else if (r1.time == r2.time) { return 0; } |
else { return 1; } |
} |
} |
This works just fine. It’s worth noting, however, that the purpose statement of the compare
method offers a greater flexibility that we can take advantage of: we don’t have to return
specifically -1, 0, or 1; we can return any negative number, 0, or any
positive number.
Can you simplify the method above, given this flexibility and given that here we’re essentially
comparing two numbers?
class CompareByTime implements IRunnerComparator { |
public int compare(Runner r1, Runner r2) { |
return r1.time - r2.time; |
} |
} |
If r1.time is less than r2.time, the difference above is negative;
if r1.time is greater, the difference is positive; and if the two times are equal, the difference is zero — exactly
as the purpose statement requires. (This is why Java allows such flexibility in the return values: precisely to
allow this simple “trick” for implementing comparators.)
14.3 Finding the winner of the race, two ways
The easy way
The winner of a marathon is the Runner who finishes with the fastest time.
How much of our code above can we reuse to solve this new question?
If we try sorting the list of Runners by time, then the winner should simply
be the Runner at the front of the resulting sorted list. So as an intuitive first
guess, we might try marathon.sortBy(new CompareByTime()).first ...but this
will fail to typecheck, and in fact is problematic for two related reasons. First,
the result of sorting an ILoRunner is always another ILoRunner, and the ILoRunner
interface does not have a first field. Second, finding “the winner” of a marathon
is only well-defined if the marathon is not an empty list. So we might try writing the following:
Runner findWinner(); |
Runner getFirst(); |
Try implementing these two methods for MtLoRunner and ConsLoRunner.
Pay attention to the differences in the purpose statements.
Runner findWinner() { |
throw new RuntimeException("No winner of an empty list of Runners"); |
} |
Runner getFirst() { |
throw new RuntimeException("No first of an empty list of Runners"); |
} |
Runner findWinner() { |
return this.sortBy(new CompareByTime()).getFirst(); |
} |
Runner getFirst() { |
return this.first; |
} |
Note that the use of getFirst in ConsLoRunner will never fail, because
sorting a non-empty list will result in a non-empty list, so getFirst will be
invoked on some ConsLoRunner object, which indeed has a first field.
14.3.1 The smarter way
In Lecture 27, we’ll learn a bit more precisely what is “extravagant” about this approach.
It seems a bit extravagant that to find the winner of a marathon, we sort an entire list of Runners
and produce an entirely new sorted list, only to take the first item and throw the rest of the list away!
If most of that information is unneeded, could we be cleverer and avoid constructing the entire list?
The key observation here is that to find the fastest runner, we simply need to keep track of the fastest runner seen so far
as we recur over the list; once we’ve processed the entire list, the fastest runner seen so far is therefore the fastest
runner overall — i.e., the winner.
More generally, we might want to find the minimum runner according to any comparison, by keeping track of the minimum seen so far.
Runner findMin(IRunnerComparator comp); |
In the empty case, as above, we just throw exceptions since there is no minimum of an empty list.
In the non-empty case, finding the winner is just a special case of finding a minimum, as compared by time:
public Runner findWinner() { return this.findMin(new CompareByTime()); } |
Now we just need to finish designing the findMin method.
We know that findMin only makes sense for non-empty lists. It cannot possibly succeed for empty lists, since
in the empty case, there is no minimum runner, so we have no good answer to return. In that
situation, the only thing we can do is throw an exception:
public Runner findMin(IRunnerComparator comp) { |
throw new RuntimeException("No minimum runner available in this list!"); |
} |
The non-empty case is a bit subtle.
Try to implement this method on the ConsLoRunner class. Where does it get stuck?
If we look at the template for this method, we see
public Runner findMin(IRunnerComparator comp) { |
|
... |
} |
We have two problems: first, if we simply call this.rest.findMin(...), eventually we
will reach the MtLoRunner case, and our code above will inevitably throw an exception.
Second, in order to use comp.comesBefore, we need two Runners — but a ConsLoRunner
only has one available.
But wait! Remember our description above: we want to keep track of the minimum Runner seen so far. This
sounds like we need an accumulator parameter, which means we need a helper method:
Runner findMinHelp(IRunnerComparator comp, Runner acc); |
Implement findMinHelp for MtLoRunner and ConsLoRunner.
In the empty case, there are no Runners in the list, so the minimum must be the accumulator:
public Runner findMinHelp(IRunnerComparator comp, Runner acc) { return acc; } |
In the non-empty case, we’re now unstuck:
public Runner findMinHelp(IRunnerComparator comp, Runner acc) { |
if (comp.compare(acc, this.first) < 0) { |
return this.rest.findMinHelp(comp, acc); |
} |
else { |
return this.rest.findMinHelp(comp, this.first); |
} |
} |
public Runner findMin(IRunnerComparator comp) { |
return this.rest.findMinHelp(comp, this.first); |
} |
Notice that this code has only one potential exception (if we call findMin on an empty list),
and every other code path cannot possibly throw an exception. Much better!
Design a comparator class ReverseComparator that takes an IRunnerComparator
as a parameter, and whose behavior is the reverse of that parameter. I.e., if the given
comparator says one Runner is less than the other, then the ReverseComparator
will say that it is greater.
Design a findMax method. (Hint: There is a very short solution, using the answer to the previous exercise.)
14.4 Computing the registration roster: sorting alphabetically
Often when handling registration at big events like marathons, the organizers have a list of attendees,
sorted alphabetically by name. We already have a sortBy() method; all we need is a IRunnerComparator
that sorts by name.
Design a CompareByName comparator that compares two Runners by their names.
Conveniently, the method to compare two Strings in Java, compareTo, has exactly the same purpose
statement as for our compare method in IRunnerComparator.
class CompareByName implements IRunnerComparator { |
public int compare(Runner r1, Runner r2) { |
return r1.name.compareTo(r2.name); |
} |
} |
And we’re done!