1 Abstracting Over Classes with Similar Structure But Different Types
1.1 Abstracting Using a Shared Superclass
1.2 Abstracting Using Generics
2 Summary

Data Abstraction

Kathi Fisler

So far, we’ve covered basic program structure in Java, abstract data types, and data structures with invariants. These have raised several Java issues that we should address. The next few lectures discuss some of these issues.

Credits: Most of the material in this lecture is taken from Felleisen et al’s How to Design Classes.

1 Abstracting Over Classes with Similar Structure But Different Types

Assume that you have the following two classes. The first associates names with phone numbers (as in a phone book); the second associates prices with the names of menu items.

  class PhoneBookEntry {

    String name;

    PhoneNumber phnum;

  

    PhoneBookEntry(String name, PhoneNumber phnum) {

      this.name = name;

      this.phnum = phnum;

    }

  }

  

  class MenuItem {

    String name;

    int price;

  

    MenuItem(String name, int price) {

      this.name = name;

      this.price = price;

    }

  }

These two classes have similar structure, and we could imagine writing similar methods over them (i.e., looking up the number or price associated with a name). We would therefore like a way to share the commonalities here. This means that we want to be able to abstract over similar class definitions. This differs from the abstract class ideas we discussed previously because the types of the phone number and price differ across the two classes.

To be concrete, we want to create a general class for associating names with data, along the lines of the following:

the following code, figuring out what to fill in for the ???:

  class NameAssoc {

    String name;

    ??? data;

  

    NameAssoc(String name, ??? data) {

      this.name = name;

      this.data = data;

    }

  }

The question, however, is what type to use in place of ???.

Until now, we would have abstracted over the different types using an interface. However, that doesn’t work in this case because we cannot control what interfaces integers implement. In general, when you want to abstract over types that you may not control, an interface is not an option.

1.1 Abstracting Using a Shared Superclass

Java helps handle this problem by providing a special class called Object that is the superclass of all other classes. Every time you write a class definition, Java includes an implicit extends Object. This means that Object is a type that represents objects of any class. We could therefore rewrite the code as follows:

  class NameAssoc {

    String name;

    Object data;

  

    NameAssoc(String name, Object data) {

      this.name = name;

      this.data = data;

    }

  }

  

  class PhoneBookEntry extends NameAssoc {

    PhoneBookEntry(String name, PhoneNumber phnum) {

      super(name, phnum);

    }

  }

  

  class MenuItem extends NameAssoc {

    MenuItem(String name, int price) {

      super(name, price);

    }

  }

One could imagine creating lists of phone-book entries or menu items; one could also imagine sorting those lists. To do so, each class would need a lessThan method. Let’s look at a reasonable such method for MenuItems, one that compares based on the price:

  class MenuItem extends NameAssoc {

    MenuItem(String name, int price) {

      super(name, price);

    }

  

    boolean lessThan(MenuItem thanItem) {

      return this.data < thanItem.data;

    }

  }

Unfortunately, Java won’t accept this code, because it can’t guarantee that this.data and thanItem.data are numbers (and hence valid arguments to the < operator). We have to explicitly tell Java that these are numbers (which we know, since our constructor expects the price (data) to be an integer):

  boolean lessThan(MenuItem thanItem) {

    return (Integer)this.data < (Integer)thanItem.data;

  }

The (Integer) is another example of a cast. In general, we have said that you should only use a cast if you are confident that the object in question is of the class in the cast. In this case, we know that the data field in a MenuItem corresponds to the price input to the constructor, which we guaranteed to be an integer by the type on the constructor.

The Object approach to abstracting over classes puts more responsibility on the programmer to use the classes correctly. For example, nothing in the current code prevents a MenuItem from holding a non-numeric piece of data. For example, the following code compiles:

  class MenuItem extends NameAssoc {

    MenuItem(String name, int price) {

      super(name, "gotcha");

    }

  }

This illustrates that using Object is too general. The Object type is useful when you want to allow any datum and you don’t intend to use it in computation. Here, the above code would raise an error if we tried to use the lessThan method on a MenuItem object. In this case, we would prefer a way to parameterize NameAssoc over specific types in a way that preserves type checking.

1.2 Abstracting Using Generics

The following version of NameAssoc takes the type of data as a parameter:

  class NameAssoc<DATA> {

    String name;

    DATA data;

  

    NameAssoc(String name, DATA data) {

      this.name = name;

      this.data = data;

    }

  }

The angle brackets in the first line of the class definition denote a type parameter. We could use any alphabetic-character sequence as the type parameter name (in particular, it did not need to be DATA to match the name of the field).

The following code shows how to create the PhoneBookEntry and MenuItem classes via type parameters:

  class PhoneBookEntry extends NameAssoc<PhoneNumber> {

    PhoneBookEntry(String name, PhoneNumber phnum) {

      super(name, phnum);

    }

  }

  

  class MenuItem extends NameAssoc<Integer> {

    MenuItem(String name, int price) {

      super(name, price);

    }

  }

Here, each class provides the type for the data field as an argument when extending the NameAssoc class. Effectively, Java creates a new copy of NameAssoc for each type parameter; the type argument name is simply substituted for the parameter name throughout the class definition. Classes with type parameters, called generics, behave as if the original class had been written literally using the type argument in place of the parameter. You can think of generics as functions from types to classes.

Generics offer much better type checking than abstractions created through Object. The casts in lessThan in the object version are not needed here, because the version of NameAssoc that MenuItem extends has fixed the data to be Integer.

Finally, let’s look at the lessThan method in the context of generics. We can require that every NameAssoc provide a lessThan method as follows:

  abstract class NameAssoc<DATA> {

    String name;

    DATA data;

  

    NameAssoc(String name, DATA data) {

      this.name = name;

      this.data = data;

    }

  

    abstract boolean lessThan(NameAssoc<DATA> other);

  }

Note that NameAssoc cannot possibly require an implementation of lessThan since it is parameterized over a type. Just as we could annotate classes with abstract, we can similarly annotate methods. An abstract method must be overridden with a concrete definition in any subclass. Once a class contains an abstract method, it too must be marked as abstract.

The MenuItem class implements the lessThan method as follows:

  class MenuItem extends NameAssoc<Integer> {

    ...

  

    boolean lessThan(MenuItem thanItem) {

      return this.data < thanItem.data;

    }

  }

Note that the casts are no longer needed within the method, because the generic guarantees that the data in a MenuItem will be an integer.

2 Summary

In this lecture, you should have learned: