Lecture 11: Classes that Share Fields and Code
Overview
Identifying and sharing code between classes that have the same implementation of one or more methods (not just the same method header)
Identifying and sharing code between classes that have (some of) the same fields
9.1 Combining Searches, Part II
In the last section we saw how we could combine search queries together using combiner classes like AndQuery and OrQuery. This let us write queries like:
class ExamplesSearch { ImageData i1 = new ImageData("ucsd cse computer science", "png", 600, 400); ImageData i2 = new ImageData("data science ai artificial intelligence", "png", 500, 400); ImageQuery lg1 = new LargerThan(600, 400); ImageQuery me1 = new MatchesExtension("png"); ImageQuery ck1 = new ContainsKeyword("ucsd"); ImageQuery all3 = new AndQuery(new AndQuery(this.lg1, this.me1), this.ck1); boolean testAnd(Tester t) { return t.checkExpect(this.all3.matches(i1), true) && t.checkExpect(this.all3.matches(i2), false); } }
One thing that gets a bit verbose here is the construction of the combiners. If we make large queries, we end up repeating new AndQuery quite a few times, which is a lot of typing:
class ExamplesSearch { ImageQuery lg1 = new LargerThan(600, 400); ImageQuery me1 = new MatchesExtension("png"); ImageQuery ck1 = new ContainsKeyword("ucsd"); ImageQuery ck2 = new ContainsKeyword("data"); ImageQuery ck3 = new ContainsKeyword("science"); ImageQuery all5 = new AndQuery(new AndQuery(this.lg1, this.me1), new AndQuery(this.ck1, new AndQuery(this.ck2, this.ck3))); }
While this isn’t unusable, it’s often nice to find ways to shorten uses like these. One idea is to have a method, called and, defined on the various ImageQuery classes that takes another ImageQuery and produces a new AndQuery of the two. If that were defined, we could shorten the long line above to:
ImageQuery all5 = this.lg1.and(this.me1).and(this.ck1).and(this.ck2).and(this.ck3);
Subjectively, I find this quite a bit more readable than the example above, as well. So in LargerThan, we’d write:
class LargerThan implements ImageQuery { ... public ImageQuery and(ImageQuery other) { return new AndQuery(this, other); } }
Of course, just implementing this in one place isn’t sufficient. We need to add this to all the classes that we want to use and on;
class MatchesExtension implements ImageQuery { ... public ImageQuery and(ImageQuery other) { return new AndQuery(this, other); } } class ContainsKeyword implements ImageQuery { ... public ImageQuery and(ImageQuery other) { return new AndQuery(this, other); } } class AndQuery implements ImageQuery { ... public ImageQuery and(ImageQuery other) { return new AndQuery(this, other); } }
This still isn’t quite enough to make the above example run.
Do Now!
What error will we get if we put the add method in all of these classes and try to run the example using and above?
Since all the names in the example above have type ImageQuery—the interface type—Java won’t let us use the and method on them. We have to first declare that method as part of the interface:
interface ImageQuery { boolean matches(ImageData id); ImageQuery and(ImageQuery other); }
Now the example above will run, and we can use this idea of the and method to shrink our code that uses the query combiners. To do so, we paid a particular cost – we had to put the and method in each of the ImageQuery-implementing classes. This is a bunch of repeated work. It might be worth it if we have hundreds of lines of code that make use of this to be more readable and usable, but it’s still annoying to repeat code when it is exactly the same across classes.
9.2 abstracting Common Method Implementations
Java, and many other languages that support objects and classes, has a feature for precisely this situation, where multiple classes could share an implementation of a method. Note that this situation is more specific than the situation we identified with interfaces, where multiple classes shared a common method header with different implementations. Here, the method header and implementation—the entire method definition—is the same across these classes.
When this situation comes up, we can declare an abstract class that contains the shared implementations. An abstract class is written like a class with abstract at the beginning, and it lists fields and methods (we’ll just use methods for now). Methods can also be started with abstract, which means they don’t have a shared implementation, or they can be written out normally. Here’s an abstract class that defines the and method as before, and has an abstract definition of the matches method:
Note that we declared that the ImageQuery abstract class implements the ImageQuery interface.
abstract class AQuery implements ImageQuery { public ImageQuery and(ImageQuery other) { return new AndQuery(this, other); } }
We can now use a new keyword, extends, to make each of the individual query classes get access to the single implementation of and in AQuery, and remove their individual definitions of it. So LargerThan becomes:
class LargerThan extends AQuery { ... just the fields, constructors, and match, no and method ... }
We’d add the same extends AImageQuery clause to each query class, which allows us to remove the identical copies of the and method, and just use the one in AImageQuery. This lets us write the add method only once, but get its benefits for all the classes that extend the AImageQuery class.
9.3 abstracting Common Fields
Consider the class AndQuery and the related class OrQuery:
class AndQuery extends AQuery { ImageQuery q1, q2; AndQuery(ImageQuery q1, ImageQuery q2) { this.q1 = q1; this.q2 = q2; } public boolean matches(ImageData id) { return this.q1.matches(id) && this.q2.matches(id); } } class OrQuery extends AQuery { ImageQuery q1, q2; OrQuery(ImageQuery q1, ImageQuery q2) { this.q1 = q1; this.q2 = q2; } public boolean matches(ImageData id) { return this.q1.matches(id) || this.q2.matches(id); } }
These two classes share a lot of code. For example, they have the exact same two field definitions (q1 and q2), and the constructors are identical aside from their names. Similar to the case above, where we identified completely shared method definitions, here we’ve idenfieid completely shared field and constructor definitions. In this case again, we can “abstract out” the shared behavior into an abstract class:
abstract class AComboQuery extends AQuery { ImageQuery q1, q2; AComboQuery(ImageQuery q1, ImageQuery q2) { this.q1 = q1; this.q2 = q2; } } class AndQuery extends AComboQuery { AndQuery(ImageQuery q1, ImageQuery q2) { super(q1, q2); } public boolean matches(ImageData id) { return this.q1.matches(id) && this.q2.matches(id); } } class OrQuery extends AComboQuery { OrQuery(ImageQuery q1, ImageQuery q2) { super(q1, q2); } public boolean matches(ImageData id) { return this.q1.matches(id) || this.q2.matches(id); } }
We may see other uses of super outside of constructors in the future.
9.4 Talking About abstract Classes
Some definitions are useful at this point:
The general space of features where one class extends another and re-uses some methods and/or fields is called inheritance. We say that AndQuery inherits the and method from the AQuery class and the q1 field from the AComboQuery class, for example.
We call the relationship between classes in a particular program the class hierarchy of the program.
The relationship between a class that extends another has a few names. One of the most common is to call AComboQuery the parent class of AndQuery, and AndQuery the child class of AComboQuery. This is commonly used when one class directly extends another. Another term that’s used is subclass and superclass, corresponding to AndQuery and AComboQuery in this instance. To talk about the relationship between AndQuery and AQuery, which have AComboQuery “between” them in the class hierarchy, we’d say that AndQuery is a subclass of AComboQuery, and also that AComboQuery is a subclass of AQuery. So the subclass/superclass terms are often used for these relationships including more than one step of extension.
It’s useful to use pictures to describe the class hierarchy. These pictures are distinct from the memory diagrams that include the stack and heap – they talk about the relationship between classes. For this example, we’d use the following class hierarchy diagram to describe it:
This captures the relationships between these classes by drawing an extends arrow between each pair of classes where one extends the other, and an implements arrow when a class implements another.
This picture helps us understand what methods and fields are available on a particular class. For example, if we see a field access like this.q1, and know that this is a reference to an AndQuery object, we can use the diagram to trace the extends relationships to find that the q1 field is defined on its parent class. And in a method call like this.and(someOtherQuery), where this is a reference to an object of type LongerThan, we can trace the extends relationship to find the and method on AQuery.
9.5 Summary
We saw that:
abstract classes and inheritance allow us to share implementations of methods and definitions of fields and constructors across multiple classes.
The super keyword, when used in a constructor, calls the superclass’s constructor, which can help share initialization.
We can draw diagrams of class relationships to show the class hierarchy of the program.
We can use the words parent/child and superclass/subclass to describe the relationship between classes when speaking and writing.