On this page:
14.1 Finding the final standings
14.2 Three-valued comparisons
14.3 Finding the winner of the race, two ways
The easy way
14.3.1 The smarter way
14.4 Computing the registration roster:   sorting alphabetically
8.12

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.

Do Now!

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.

Do Now!

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);
}
// in MtLoRunner public ILoRunner sortByTime() { return this; }
public ILoRunner insertByTime(Runner r) { return new ConsLoRunner(r, this); }
// in ConsLoRunner 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);
}
}
// in Runner 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.

Do Now!

What signature should this interface define?

interface ICompareRunners {
// Returns true if r1 comes before r2 according to this ordering 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.

Do Now!

Design this class.

class CompareByTime implements ICompareRunners {
public boolean comesBefore(Runner r1, Runner r2) {
return r1.time < r2.time;
}
}

Do Now!

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);
}
// in MtLoRunner public ILoRunner sortBy(ICompareRunners comp) { return this; }
public ILoRunner insertBy(ICompareRunners comp, Runner r) {
return new ConsLoRunner(r, this);
}
// in ConsLoRunner public ILoRunner sortBy(ICompareRunners comp) {
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);
}
}
// in Runner // No more method finishesBefore!

Now, instead of saying
marathon.sortByTime()
, 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:
// To compute a three-way comparison between two Runners interface IRunnerComparator {
// Returns a negative number if r1 comes before r2 in this order // Returns zero if r1 is tied with r2 in this order // Returns a positive number if r1 comes after r2 in this order 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:
// In ConsLoRunner public ILoRunner insertBy(IRunnerComparator comp, Runner r) {
// comp.compare will return a negative number if its first argument comes first 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.

Do Now!

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.

Do Now!

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:

// In ILoRunner // Finds the fastest Runner in this list of Runners Runner findWinner();
// Finds the first Runner in this list of Runners Runner getFirst();

Do Now!

Try implementing these two methods for MtLoRunner and ConsLoRunner. Pay attention to the differences in the purpose statements.

// In MtLoRunner 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");
}
// In ConsLoRunner 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.
// In ILoRunner 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:
// In ConsLoRunner 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:
// In MtLoRunner public Runner findMin(IRunnerComparator comp) {
throw new RuntimeException("No minimum runner available in this list!");
}

The non-empty case is a bit subtle.

Do Now!

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
// In ConsLoRunner public Runner findMin(IRunnerComparator comp) {
/* Fields: * this.first * this.rest * * Methods on fields: * this.rest.findMin(ICompareRunner) * * Methods on parameters: * comp.comesBefore(Runner, Runner) */
...
}
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:
// In ILoRunner // Returns the minimum Runner of the given accumulator and every Runner // in this list, according to the given comparator Runner findMinHelp(IRunnerComparator comp, Runner acc);

Do Now!

Implement findMinHelp for MtLoRunner and ConsLoRunner.

In the empty case, there are no Runners in the list, so the minimum must be the accumulator:
// In MtLoRunner public Runner findMinHelp(IRunnerComparator comp, Runner acc) { return acc; }
In the non-empty case, we’re now unstuck:
// In ConsLoRunner public Runner findMinHelp(IRunnerComparator comp, Runner acc) {
if (comp.compare(acc, this.first) < 0) {
// The accumulator is still the minimum return this.rest.findMinHelp(comp, acc);
}
else {
// this.first comes before the accumulator 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!

Exercise

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.

Exercise

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.

Do Now!

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!