1 An example of similar methods
2 Abstracting Over Methods with Similar Structure
3 Parameterizing Methods Passed as Arguments
4 Summary

Method Abstraction

Kathi Fisler

Credits: This lecture derives from Felleisen et al’s How to Design Classes.

Writing clean, reusable code is a key component of software design. Reusable code arises from abstracting over common patterns between code fragments, so that later fragments can be expressed in terms of more general constructs. So far, we’ve studied abstracting over shared fields and methods with abstract classes, and abstracting over similar code sans types via generics. Today, we turn to a different problem: abstracting over similar code sans functions. An example will make this clearer.

1 An example of similar methods

Last week, we were talking about menu items. Imagine that we had a list of menu items (i.e., a menu) and two different methods for extracting a subset of those items: one that selected inexpensive items and one that selected items featuring garlic. We could write these functions as follows (source code file with examples class and some Java tips):

  class MenuItem {

    String name;

    int price;

  

    MenuItem(String name, int price) {

      this.name = name;

      this.price = price;

    }

  

    boolean hasLowPrice() {

      return this.price < 10;

    }

  

    boolean hasGarlic() {

      return this.name.startsWith("garlic");

    }

  }

  

  interface IMenu {

    // count items under $10

    int countLowPrice();

    // count items whose name starts with "garlic"

    int countHasGarlic();

  }

  

  class MtMenu implements IMenu {

    MtMenu(){}

  

    // returns 0 since an empty menu has no items

    public int countLowPrice() {

      return 0;

    }

  

    // returns 0 since an empty menu has no items

    public int countHasGarlic() {

      return 0;

    }

  }

  

  class DataMenu implements IMenu {

    MenuItem item;

    IMenu left;

    IMenu right;

  

    DataMenu(MenuItem item, IMenu left, IMenu right) {

      this.item = item;

      this.left = left;

      this.right = right;

    }

  

    // adds count of low price items in subtrees, adding 1 if item is low priced

    public int countLowPrice() {

      int resultsLR = this.left.countLowPrice() + this.right.countLowPrice();

      if (this.item.hasLowPrice())

        return 1 + resultsLR;

      else

        return resultsLR;

    }

  

    // adds count of garlicy items in subtrees, adding 1 if item has garlic

    public int countHasGarlic() {

      int resultsLR = this.left.countHasGarlic() + this.right.countHasGarlic();

      if (this.item.hasGarlic())

        return 1 + resultsLR;

      else

        return resultsLR;

    }

  }

Notice how countLowPrice and countHasGarlic are identical except for the method called on the first element of the list? We would like to write the common code once, configuring the different methods via a parameter.

Had we been in Racket, this abstraction would have been easy. We would write a single countMatch method and provide the function to determine whether to count each item as a parameter, something like:

  public int countMatch(??? checkMatch) {

    int resultsLR = this.left.countMatch() + this.right.countMatch();

    if (this.item.checkMatch())

      return 1 + resultsLR;

    else

      return resultsLR;

  }

Unfortunately, this isn’t valid Java code, because Java does not support passing methods as arguments (in technical terms, methods are not first-class in Java). This is the right spirit, however, so the question is how we achieve this result within Java’s capabilities.

2 Abstracting Over Methods with Similar Structure

What are we allowed to pass an arguments in Java? We have said repeatedly that in Java, everything is an object. We can pass objects. So if we can create objects that act like functions, we can implement this design.

How do we make an object "act like a function"? Objects contain methods, which are functions. We can certainly make the countMatch parameter be an object that contains the method that we want to call. To avoid confusion with the flawed approach we outlined above, let’s rename the object to matcher. We’ve also added ellipses around this.item to indicate that we have to figure out how to process this.item with matcher.

  public int countMatch(??? matcher) {

    int resultsLR = this.left.countMatch() + this.right.countMatch();

    if (... this.item ...)

      return 1 + resultsLR;

    else

      return resultsLR;

  }

In the if statement, we cannot write this.item.matcher. Why? Because matcher is an object, not a field or method inside this.item. Go back to our desired code for a moment. There, we wanted to write if (this.item.checkMatch()). What is the type of checkMatch implicit in this statement?

So matcher needs a method that consumes a MenuItem and returns a boolean. Furthermore, this method needs a fixed name in every object passed as matcher (so that we can access it consistently). Let’s call the method isMatch. This lets us fill in the if statement in countMatch as follows:

  public int countMatch(??? matcher) {

    int resultsLR = this.left.countMatch() + this.right.countMatch();

    if (matcher.isMatch(this.item))

      return 1 + resultsLR;

    else

      return resultsLR;

  }

What can we use as the type for matcher? Well, all we really care about is that it provide a isMatch method that consumes a MenuItem and produces a boolean (note that this is the same contract on each of hasLowPrice and hasGarlic). This is an ideal job for an interface:

  interface IMatch {

    // method to determine whether to count a given menu item

    boolean isMatch(MenuItem m);

  }

  

  public int countMatch(IMatch matcher) {

    int resultsLR = this.left.countMatch(matcher) + this.right.countMatch(matcher);

    if (matcher.isMatch(this.item))

      return 1 + resultsLR;

    else

      return resultsLR;

  }

Now that we have a generalized countMatch method, we need to understand how to use it. Think about what a test case to count low-priced items would have looked like in the original code (this assumes a DataMenu named M):

  boolean testPrice1(Tester t) {

    return t.checkExpect(M.countLowPrice(),3);

  }

Let’s rewrite this test case using countMatch instead:

  boolean testPrice1(Tester t) {

    return t.checkExpect(M.countMatch(_____________),3);

  }

The argument to countMatch must be an object that satisfies the IMatch interface. Objects come from classes. But we don’t have any classes that implement IMatch. We need to create one. The class must provide an isMatch method (as required by the interface) that checks whether its menu item has a low price (as required by the test case we are trying to replicate):

  // class providing isMatch for counting low priced items

  class MatchLowPrice implements IMatch {

    MatchLowPrice(){}

  

    public boolean isMatch(MenuItem m) {

      return m.hasLowPrice();

    }

  }

  

  boolean testPrice1(Tester t) {

    return t.checkExpect(M.countMatch(new MatchLowPrice()),3);

  }

We would do something similar to count items that have garlic. The final code file shows this (along with some Java tidbits).

3 Parameterizing Methods Passed as Arguments

What if we wanted to allow a user of our menu library to customize the limit on the price? In other words, we want to replace the constant 10 in HasLowPrice (in the MenuItem class) with a parameter. Here’s the desired new hasLowPrice code:

  boolean hasLowPrice(int limit) {

    return this.price < limit;

  }

How do we get the value for this parameter using the MatchLowPrice class?

  class MatchLowPrice implements IMatch {

    MatchLowPrice(){}

  

    public boolean isMatch(MenuItem m) {

      return m.hasLowPrice(???);

    }

  }

We can’t change the type on isMatch (that’s required by IMatch). We can, however, have the constructor for the MatchLowPrice class take the parameterized limit as an argument and store it in a field:

  class MatchLowPrice implements IMatch {

    int limit;

  

    MatchLowPrice(int limit){

      this.limit = limit;

    }

  

    public boolean isMatch(MenuItem m) {

      return m.hasLowPrice(this.limit);

    }

  }

For those of you who had 1102, this shows how to mimic closures in Java: whereas Racket automatically saves closed-over values in lambdas, in Java we create the closure manually by passing in free variables as constructor arguments.

4 Summary

To summarize, we get the effect of functions as arguments in Java by:
  1. creating a class for each function we want to pass as an argument,

  2. picking a consistent name for the method that will provide the actual function,

  3. creating an interface that requires that consistent name (and its input/output types, and

  4. having the class for each function implement the interface that specifies the naming convention.

You may wonder whether all of this work is worth it to abstract over only two functions. If you are writing code that is part of a larger software infrastructure (a gaming platform, a data structure library, the core of an applications platform, a general-purpose robotics controller, etc), you could have many people interacting with your data structures, trying to run similar kinds of computations on them. This is an ideal case in which to provide some custom methods for working with the data. Why?

  • Your users can write code that more succiently captures what they want to do (i.e., countMatches, search, indicate when to move a player or robotm etc)

  • It gives you the freedom to change your implementation of the framework, without your users having to edit their code! Imagine that you decided to switch menus from being implemented with lists rather than trees. If your users wrote their programs assuming trees, they have to change their code. If your users wrote their programs against only the IMenu interface (as they should have done all along), you can simply rewrite countMatch and your users will never notice the difference.

Maintainability of code. Once again, we see a programming pattern designed to help us create code that enables long-term flexibility (for both the authors and users of your code). Once again, we see that interfaces are a key part to this story.