On this page:
Overview
8.1 Searching for Images
8.2 Queries as Data
8.3 Identifying Common Behavior
8.4 Combining Queries
8.5 Summary
6.8

Lecture 10: Classes that Share Behavior

Related files:
  ExamplesSearch.java  

Overview
8.1 Searching for Images

Many search engines support an advanced search mode, where the user can type in a specific query based on combining many parameters. Here’s Google’s interface for searching for images:

Let’s try to model a simple version of this to see what queries like this might look like. Let’s start with a class for representing the images themselves; we’ll pick just a few fields that will serve as good examples:

class ImageData {
String keywords; // All the keywords, separated by spaces String filetype; // gif, png, jpg, and so on int width; // the width in pixels int height; // the height in pixels ImageData(String keywords, String filetype, int width, int height) {
this.keywords = keywords;
this.filetype = filetype;
this.width = width;
this.height = height;
}
}

As a first try, we could add some methods to this class for the various search types. One way to think about a “search” method is that it takes in some parameters to test for, and uses boolean operations to check if they hold for this object.

For example, we could add a method that takes a String representing a file type and checks if the image has that file extension:

class ImageData {
...
/* matchesExtension   @param ext A string representing the extension (the part after the . in the filename) @return true If this image's filetype matches the extension */
boolean matchesExtension(String ext) {
return this.filetype.equals(ext);
}
}
class ExamplesSearch {
ImageData i1 = new ImageData("ucsd cse computer science", "png", 600, 400);
boolean testMatchesExtension(Tester t) {
return t.checkExpect(i1.matchesExtension("png"), true) &&
t.checkExpect(i1.matchesExtension("jpg"), false);
}
}

We could also check that the size is greater than a certain width and height:

class ImageData {
/* largerThan   @param minWidth The width in pixels the image is checked against @param minHeight The height in pixels the image is checked against @return true If this image has a width and height greater than or equal to those supplied */
boolean largerThan(int minWidth, int minHeight) {
return this.width >= minWidth && this.height >= this.minHeight;
}
}
class ExamplesSearch {
ImageData i1 = new ImageData("ucsd cse computer science", "png", 600, 400);
boolean testLargerThan(Tester t) {
return t.checkExpect(i1.largerThan(600, 400), true) &&
t.checkExpect(i1.largerThan(599, 400), false) &&
t.checkExpect(i1.largerThan(600, 399), false);
}
}

This is pretty good so far. One of the things menus like this often give is the ability to combine multiple queries together. For example, we might want all the png images greater than a certain size. To write that method, we’d need something like:

class ImageData {
/* largerThanAndMatchesExtension   @param minWidth The width in pixels the image is checked against @param minHeight The height in pixels the image is checked against @param ext The file extension to check against @return true If this image has a width and height greater than or equal to those supplied and has the given file extension */
boolean largerThanAndMatchesExtension(int minWidth, int minHeight, String ext) {
return this.largerThan(minWidth, minHeight) && this.matchesExtension(ext);
}
}

This works, but is starting to hint at an issue we might have. Will we really write a method for every combination of possible questions? And what if we care about size and several file extensions, like 400x600 images with filetype equal to either png or gif? Writing largerThanAndMatchesOneOfTwoExtensions and largerThanAndMatchesOneOfThreeExtensions and so on doesn’t seem like much fun.

8.2 Queries as Data

To solve this, let’s step back a bit. We created a class to represent the ImageData, and then started representing queries with methods. What if instead of thinking of each possible query type as a method, we thought of them as classes? After all, we could easily use the data a user enters for the various search terms to construct an object, and then use methods to manipulate those query objects.

Let’s start simple. A query for size larger than a certain amount can be represented as a class that stores the width and the height:

class LargerThan {
int minWidth, minHeight;
LargerThan(int minWidth, int minHeight) {
this.minWidth = minWidth;
this.minHeight = minHeight;
}
}

Here, we took the parameters of the largerThan method—the information in the query—and made them be fields of the class we created to represent it. To do the actual work of the query, we can define a method matches that takes some ImageData object and returns true if it matches the query:

class LargerThan {
...
boolean matches(ImageData id) {
return id.width >= this.minWidth && id.height >= this.minHeight;
}
}
class ExamplesSearch {
ImageData i1 = new ImageData("ucsd cse computer science", "png", 600, 400);
...
LargerThan lg1 = new LargerThan(600, 400);
LargerThan lg2 = new LargerThan(599, 400);
LargerThan lg3 = new LargerThan(600, 399);
boolean testLargerThanClass(Tester t) {
return t.checkExpect(this.lg1.matches(i1), true) &&
t.checkExpect(this.lg2.matches(i1), false) &&
t.checkExpect(this.lg3.matches(i1), false);
}
}

We haven’t changed the actual calculation that’s happening. We just moved data around so it is first stored in fields, and then compared in the matches method, rather than being done in the largerThan method of ImageData.

We can repeat this process for the extension query:

class MatchesExtension {
String ext;
MatchesExtension(String ext) { this.ext = ext; }
boolean matches(ImageData id) {
return id.filetype.equals(this.ext);
}
}
class ExamplesSearch {
ImageData i1 = new ImageData("ucsd cse computer science", "png", 600, 400);
...
MatchesExtension png = new MatchesExtension("png");
MatchesExtension jpg = new MatchesExtension("jpg");
boolean testMatchesExtensionClass(Tester t) {
return t.checkExpect(this.png.matches(i1), true) &&
t.checkExpect(this.jpg.matches(i1), false);
}
}

Note that we’re violating a principle from lecture here – that we should only use fields within the class in which they’re defined. There are a few ways around this, but for simplicity, we’re going to directly use the fields of the ImageData reference here.

Again, we took the parameter of the matchesExtension method, made it a field of the class, and did the same calculation as before in the matches method.

We’re actually much closer to a solution to our problem of combining queries than we were before, though it’s not quite clear why yet.

8.3 Identifying Common Behavior

The key insight is that we’ve defined two different methods with exactly the same signature:

class MatchesExtension {
...
boolean matches(ImageData id) { ... }
}
class LargerThan {
...
boolean matches(ImageData id) { ... }
}

Whenever we see this situation come up, we have the opportunity to create an interface that captures this shared functionality. In this case, the interface should describe the matches method. To write an interface, we use the interface keyword, followed by the name of the interface, and then write a series of method headers between curly braces:

interface ImageQuery {
boolean matches(ImageData id);
}

We’ll have a dedicated lecture later on about public and private. For now, public doesn’t change anything from our point of view, since it would only change visibility of fields in projects that used lots of files organized across different directories.

This definition gives us the ability we can use ImageQuery as a new type that describes objects that implement the matches method with this signature. Or rather, it almost does – we have to explicitly mark on all the classes we want to use in this way that they implement the interface. We also need to mark the method itself as public, which, in contrast to private, makes the method available in any context. To mark the classes as such, we use the implements keyword:

class MatchesExtension implements ImageQuery {
...
public boolean matches(ImageData id) { ... }
}
class LargerThan implements ImageQuery {
...
public boolean matches(ImageData id) { ... }
}

This tells Java that we want to be able to use MatchesExtension objects and LargerThan objects uniformly as ImageQuery objects. So we could use that type as the type of fields that store these objects, for example:

class ExamplesSearch {
ImageData i1 = new ImageData("ucsd cse computer science", "png", 600, 400);
ImageQuery lg1 = new LargerThan(600, 400);
ImageQuery me1 = new MatchesExtension("jpg");
boolean testQuery(Tester t) {
return t.checkExpect(this.lg1.matches(i1), true) &&
t.checkExpect(this.me1.matches(i1), false);
}
}

Note that we cannot look up fields, like minWidth, using the field lg1. Java will only let us access the methods listed in the interface definition when a name has the interface type.

Do Now!

What error message do you get when you try to use this.lg1.minWidth?

8.4 Combining Queries

So far, we’ve represented queries themselves as data, and identified their common behavior – the matches method. We expressed this in Java by defining the interface named ImageQuery, and marking that both LargerThan and MatchesExtension use it by using implements. Next, we’ll use this idea to build a new class that represents combining two queries to make a new query that returns true only if both subqueries return true.

We’ll call it AndQuery:

class AndQuery {
ImageQuery iq1, iq2;
AndQuery(ImageQuery iq1, ImageQuery iq2) {
this.iq1 = iq1;
this.iq2 = iq2;
}
boolean matches(ImageData id) {
return this.iq1.matches(id) && this.iq2.matches(id);
}
}

Let’s try it in an example:

class ExamplesSearch {
ImageData i1 = new ImageData("ucsd cse computer science", "png", 600, 400);
ImageData i2 = new ImageData("data science ai artificial intelligence", "jpg", 500, 400);
ImageQuery lg1 = new LargerThan(600, 400);
ImageQuery me1 = new MatchesExtension("png");
AndQuery aq = new AndQuery(this.lg1, this.me1);
boolean testAnd(Tester t) {
return t.checkExpect(this.aq.matches(i1), true) &&
t.checkExpect(this.aq.matches(i2), false);
}
}

This means any time we want to make a query on an ImageData object, we can construct a new AndQuery and use it to make the query. This moves the work from writing a custom method every time we want to make a query to constructing one of these query objects, and using it to do the job. We can add new kinds of queries, like searching based on a particular keyword, without changing any existing code – we just write a new ImageQuery-implementing class.

Do Now!

Write a class called ContainsKeyword that implements the ImageQuery interface. It should have one String field that represents the keyword to search for, and its matches method should return true when the given ImageData has the string appearing somewhere in the keywords field.

There’s a slight annoyance remaining, though. Let’s say we wanted to combine three, rather than two, queries together in this way. It seems we would have to write another class, AndQuery3, with three ImageQuery fields, and a new matches method. Since a user may want, in general, many conditions, we would need a AndQuery4, AndQuery5, and so on.

There’s a useful observation we can make about the AndQuery class that will help us here: it has the same method signature for the matches method as the ImageQuery interface! That immediately suggests that we could make AndQuery implement that interface. Let’s do it:

Note that we also made the method public.

class AndQuery implements ImageQuery {
ImageQuery iq1, iq2;
AndQuery(ImageQuery iq1, ImageQuery iq2) {
this.iq1 = iq1;
this.iq2 = iq2;
}
public boolean matches(ImageData id) {
return this.iq1.matches(id) && this.iq2.matches(id);
}
}

Now we have three different classes that implement ImageQuery: the original two queries LargerThan and MatchesExtension, and the one we just noticed, AndQuery. All of these are candidates that we could use when combining queries together – including AndQuery itself! This suggests a way forward to combining three queries together, using the pieces we’ve seen.

Do Now!

Can you think of a ImageQuery you can write that would represent "larger than 600 by 400" AND "has file extension png" AND "has the keyword ucsd"?

The key idea is to think of it as two queries, one of which is an AndQuery itself. That is, we’ll first use an AndQuery to combine the first two conditions, and then use another AndQuery to combine that result with the third. In code, that looks 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);
}
}

Later lectures will show us how to use this idea to process an entire list of images to find the ones that match a query.

This idea isn’t limited to just three queries – we can combine an arbitrary number of queries together with this strategy. This gives us the ability to create quite complex queries by creating new query classes, and combining them together. There’s a wealth of query types we could create to build on this idea and build quite a sophisticated set of matching checks for single images.

Exercise

Write a class OrQuery that implements the ImageQuery interface, with two ImageQuery fields, which matches an ImageData if either or both of the two match.

Exercise

Write a class NotQuery that implemenets the ImageQuery interface, with a single ImageQuery field, which matches an ImageData if the given query does not match. Write an example of using this class to match images that have a file extension that isn’t "png".

Exercise

Make up some of your own queries based on combining the ones above.

8.5 Summary