close this bookHow to Think Like a Computer Scientist
source ref: ebookit.html
View the documentMetadata
View the documentChapter 1:The way of the program.
View the documentchapter 2:Variables and types
View the documentchapter 3:Methods
View the documentChapter 4:Conditionals, graphics, and recursion
View the documentChapter 5:Methods that return things
View the documentChapter 6:Iteration
View the documentChapter 7:Strings and things
View the documentChapter 8:Interesting objects
View the documentChapter 9:Create your own objects
View the documentChapter 10:Arrays
View the documentChapter 11:Object methods
View the documentChapter 12:Arrays of Objects
View the documentChapter 13:Object-oriented programming

Chapter 12:Arrays of Objects

Chapter 12

Arrays of Objects



12.1 Composition

By now we have seen several examples of composition: the ability to combine language features in a variety of arrangements. One example, in Section 3.7 is the possibility of using method invocations as part of an expression. Another example is the nested structure of statements; you can put an if statement within a while loop, or within another if statement, etc.

Having seen this pattern, and having learned about arrays and objects, you should not be surprised to learn that you can have arrays of objects. In fact, you can also have objects that contain arrays (as instance variables); you can have arrays that contain arrays; you can have objects that contain objects, and so on.

In the next two chapters we will look at some examples of these various combinations, using Card objects as an example.



12.2 Card objects

If you are not familiar with common playing cards, now would be a good time to get a deck, or else this chapter might not make much sense. There are 52 cards in a deck, each of which belongs to one of four suits and 13 ranks. The suits are Spades, Hearts, Diamonds and Clubs (in descending order in Bridge). The ranks are Ace, 2, 3, 4, 5, 6, 7, 8, 9, 10, Jack, Queen and King. Depending on what game you are playing, the rank of the Ace may be higher than King or lower than 2.

If we create a new object to represent a playing card, it is pretty obvious what the instance variables should be: rank and suit. It is not as obvious what type the instance variables should be. One possibility is Strings, containing things like "Spade" for suits and "Queen" for ranks. One problem with this implementation is that it would not be easy to compare cards to see which had higher rank or suit.

An alternative is to use integers to encode the ranks and suits. By "encode," I do not mean what some people think, which is to encrypt, or translate into a secret code. What a computer scientist means by "encode" is something like "define a mapping between a sequence of numbers and the things I want to represent." For example,

Spades -> 3
Hearts -> 2
Diamonds -> 1
Clubs -> 0

The symbol -> is mathematical notation for "maps to." The obvious feature of this mapping is that the suits map to integers in order, so we can compare suits by comparing integers. The mapping for ranks is fairly obvious; each of the numerical ranks maps to the corresponding integer, and for face cards:

Jack -> 11
Queen -> 12
King -> 13

The reason I am using mathematical notation for these mappings is that they are not part of the Java program. They are part of the program design, but they never appear explicitly in the code. The class definition for the Card type looks like this:


public class Card
{
  public int suit, rank;

  public Card () {
    this.suit = 0;  this.rank = 0;
  }

  public Card (int suit, int rank) {
    this.suit = suit;  this.rank = rank;
  }
}

As usual, I am providing two constructors, one of which takes a parameter for each instance variable and the other of which takes no parameters.

To create an object that represents the 3 of Clubs, we would use the new command:


   Card threeOfClubs = new Card (0, 3);

The first argument, 0 represents the suit Clubs.



12.3 The toString method

When you create a new class, the first step is usually to declare the instance variables and write constructors. The second step is often to write the standard methods that every object should have, like toString, equals, and compareTo. I will start with toString.

In order to print Card objects in a way that humans can read easily, we want to map the integer codes onto words. A natural way to do that is with an array of Strings. You can create an array of Strings the same way you create an array of primitive types:


    String[] suits = new String [4];

Then we can set the values of the elements of the array.


    suits[0] = "Clubs";
    suits[1] = "Diamonds";
    suits[2] = "Hearts";
    suits[3] = "Spades";

Creating an array and initializing the elements is such a common operation that Java provides a special syntax for it:


    String[] suits = { "Clubs", "Diamonds", "Hearts", "Spades" };

The effect of this statement is identical to that of the separate declaration, allocation, and assignment. A state diagram of this array might look like:

StringArray Image

The elements of the array are references to the Strings, rather than Strings themselves. This is true of all arrays of objects, as I will discuss in more detail later. For now, all we need is another array of Strings to decode the ranks:


  String[] ranks = { "narf", "Ace", "2", "3", "4", "5", "6",
    "7", "8", "9", "10", "Jack", "Queen", "King" };

The reason for the "narf" is to act as a placekeeper for the zeroeth element of the array, which will never be used. The only valid ranks are 1--13. This wasted entry is not necessary, of course. We could have started at 0, as usual, but it is best to encode 2 as 2, and 3 as 3, etc.

Using these arrays, we can select the appropriate Strings by using the suit and rank as indices. In the method toString,


  public String toString () {
    String[] suits = { "Clubs", "Diamonds", "Hearts", "Spades" };
    String[] ranks = { "narf", "Ace", "2", "3", "4", "5", "6",
        "7", "8", "9", "10", "Jack", "Queen", "King" };

    return ranks[rank] + " of " + suits[suit];
  }

the expression suits[suit] means "use the instance variable suit as an index into the array named suits, and select the appropriate string." The output of this code


    Card card = new Card (1, 11);
    System.out.println (card);

is Jack of Diamonds.

12.4 The equals method

It is conventional for objects to have methods named equal and compareTo, where both are object methods that get invoked on one of the operands, and passed the other operand as a parameter.

I like to use the name that for the parameter, since it makes it clear that we are comparing the current object, this to another object, that.

The equals method gives you the opportunity to define what "equality" means for the type you are defining. In this case, two cards are equal if their ranks and suits are equal:


  public boolean equals (Card that) {
    return (this.suit == that.suit && this.rank == that.rank);
  }

Now if we create two different objects that contain the same data, we can use equals to see if they represent the same card:


    Card card1 = new Card (1, 11);
    Card card2 = new Card (1, 11);

    if (card1.equals (card2)) {
      System.out.println ("card1 and card2 are the same card.");
    }

    if (card1 == card2) {
      System.out.println ("card1 and card2 are the same object.");
    }

The operator == checks whether two variables refer to the same object. In this case, card1 and card2 are two different objects that contain the same data

Card Image

so the output of this program is card1 and card are the same card. What would the state diagram have to look like in order for card1 == card2 to be true?

The kind of equality tested by == is sometimes called shallow equality, because it only compares the references, not the contents of the objects. Comparing the contents of the object is called deep equality.



12.5 The compareTo method

In Section 7.12 we looked at a built-in method in the String class that compares String objects. Many objects have a similar method, so let's write one for Cards. Later, we will use this method for sorting a deck of cards.

Like equals, compareTo is an object method that compares the current object (this) to another object passed as a parameter (that).

The ordering of cards in the deck is two-dimensional, meaning that it depends on both the rank and the suit. In order to determine a total ordering--in other words, in order to be able to compare any two cards--we need to decide which is more important, rank or suit. For example, is the King of Clubs greater than the Two of Spades, because it has a higher rank, or lower because it has a lower suit?

To be honest, the choice is completely arbitrary. Just for the sake of choosing, I will say that rank is more important, because when you buy a new deck of cards, it comes sorted with all the Clubs together, followed by all the Diamonds, and so on.

With that decided, the first step is to compare the suits. If this has a higher suit than that, we return 1, which indicates that the current object wins. If that wins, we return -1.


    if (this.suit > that.suit) return 1;
    if (this.suit < that.suit) return -1;

If neither of these is true, then the suits must be equal, and we have to compare ranks:


    if (this.rank > that.rank) return 1;
    if (this.rank < that.rank) return -1;
    return 0;

If neither of these is true, the ranks must be equal, so we return 0. In this ordering, aces will appear lower than deuces (2s). As an exercise, fix it so that aces are ranked higher than Kings, and encapsulate this code in a method.



12.6 Arrays of cards

The reason I chose Cards as the objects for this chapter is that there is an obvious use for an array of cards--a deck. Here is some code that creates a new deck of 52 cards:


    Card[] deck = new Card [52];

Here is the state diagram for this object:

CardArray Image

The important thing to see here is that the array contains only references to objects; it does not contain any Card objects. The value of the array elements is initialized to null. You can access the elements of the array without causing an error:


    if (deck[3] == null) {
      System.out.println ("No cards yet!");
    }

But if you try to access the instance variables of the non-existent Cards, you will get a NullPointerException.


    deck[2].rank;             // NullPointerException

Nevertheless, that is the correct syntax for accessing the rank of the "twoeth" card in the deck (really the third--we started at zero, remember?). This is another example of composition, the combination of the syntax for accessing an element of an array and an instance variable of an object.

The easiest way to populate the deck with Card objects is to write a nested loop:


    int index = 0;
    for (int suit = 0; suit <= 3; suit++) {
      for (int rank = 1; rank <= 13; rank++) {
        deck[index] = new Card (suit, rank);
        index++;
      }
    }

The outer loop enumerates the suits, from 0 to 3. For each suit, the inner loop enumerates the ranks, from 1 to 13. Since the outer loop iterates 4 times, and the inner loop iterates 13 times, the total number of times the body is executed is 52 (13 times 4).

I used the variable index to keep track of where in the deck the next card should go. The following state diagram shows what the deck looks like after the first two cards have been allocated:

CardArray2 Image

As an exercise, encapsulate this deck-building code in a method called buildDeck that takes no parameters and that returns a fully-populated array of Cards.



12.7 Searching

The next method I want to write is findCard, which searches through an array of Cards to see whether it contains a certain card. It may not be obvious why this method would be useful, but it gives me a chance to demonstrate two ways to go searching for things, a linear search and a bisection search.

Linear search is the more obvious of the two; it involves traversing the deck and comparing each card to the one we are looking for. If we find it we return the index where the card appears. If it is not in the deck, we return -1.


  public static int findCard (Card[] deck, Card card) {
    for (int i = 0; i< deck.length; i++) {
      if (deck[i].equals (card)) return i;
    }
    return -1;
  }

As you can tell by the keyword static, I chose to make this a class method. The arguments are named card and deck. It might seem a little odd to have a variable with the same name as a type (with a lower case letter). This is legal and common, although it can sometimes make code hard to read. In this case, though, I think it works.

Notice the syntax for invoking a method on one of the objects in the array: deck[i].equals. Once again, you can compose the syntax for accessing the elements of an array and invoking a method.

The method returns as soon as it discovers the card, which means that we do not have to traverse the entire deck if we find the card we are looking for. If the loop terminates without finding the card, we know the card is not in the deck and return -1.

If the cards in the deck are not in order, there is no way to search that is faster than this. We have to look at every card, since otherwise there is no way to be certain the card we want is not there.

But when you look for a word in a dictionary, you don't search linearly through every word. The reason is that the words are in alphabetical order. As a result, you probably use an algorithm that is similar to a bisection search:

  1. Start in the middle somewhere.
  2. Choose a word on the page and compare it to the word you are looking for.
  3. If you found the word you are looking for, stop.
  4. If the word you are looking for comes after the word on the page, flip to somewhere later in the dictionary and go to step 2.
  5. If the word you are looking for comes before the word on the page, flip to somewhere earlier in the dictionary and go to step 2.

If you ever get to the point where there are two adjacent words on the page and your word comes between them, you can conclude that your word is not in the dictionary. The only alternative is that your word has been misfiled somewhere, but that contradicts our assumption that the words are in alphabetical order.

In the case of a deck of cards, if we know that the cards are in order, we can write a version of findCard that is much faster. The best way to write a bisection search is with a recursive method. That's because bisection is naturally recursive.

The trick is to write a method called findBisect that takes two indices as parameters, low and high, indicating the segment of the array that should be searched (including both low and high).

  1. To search the array, choose an index bewteen low and high (call it mid) and compare it to the card you are looking for.
  2. If you found it, stop.
  3. If the card at mid is higher than your card, search in the range from low to mid-1.
  4. If the card at mid is lower than your card, search in the range from mid+1 to high.

Steps 3 and 4 look suspiciously like recursive invocations. Here's what this all looks like translated into Java code:


  public static int findBisect (Card[] deck, Card card, int low, int high) {
    int mid = (high + low) / 2;
    int comp = deck[mid].compareTo (card);

    if (comp == 0) {
      return mid;
    } else if (comp > 0) {
      return findBisect (deck, card, low, mid-1);
    } else {
      return findBisect (deck, card, mid+1, high);
    }
  }

Rather than call compareTo three times, I called it once and stored the result.

Although this code contains the kernel of a bisection search, it is still missing a piece. As it is currently written, if the card is not in the deck, it will recurse forever. We need a way to detect this condition and deal with it properly (by returning -1).

The easiest way to tell that your card is not in the deck is if there are {\em no} cards in the deck, which is the case if high is less than low. Well, there are still cards in the deck, of course, but what I mean is that there are no cards in the segment of the deck indicated by low and high.

With that line added, the method works correctly:


  public static int findBisect (Card[] deck, Card card, int low, int high) {
    System.out.println (low + ", " + high);

    if (high < low) return -1;

    int mid = (high + low) / 2;
    int comp = deck[mid].compareTo (card);

    if (comp == 0) {
      return mid;
    } else if (comp > 0) {
      return findBisect (deck, card, low, mid-1);
    } else {
      return findBisect (deck, card, mid+1, high);
    }
  }

I added a print statement at the beginning so I could watch the sequence of recursive calls and convince myself that it would eventually reach the base case. I tried out the following code:


    Card card1 = new Card (1, 11);
    System.out.println (findBisect (deck, card1, 0, 51));

And got the following output:


0, 51
0, 24
13, 24
19, 24
22, 24
23

Then I made up a card that is not in the deck (the 15 of Diamonds), and tried to find it. I got the following:


0, 51
0, 24
13, 24
13, 17
13, 14
13, 12
-1

These tests don't prove that this program is correct. In fact, no amount of testing can prove that a program is correct. On the other hand, by looking at a few cases, you might be able to convince yourself.

The number of recursive calls is fairly small, typically 6 or 7. That means we only had to invoke compareTo 6 or 7 times, compared to up to 52 times if we did a linear search. In general, bisection is much faster than a linear search, especially for large arrays.



12.8 Shuffling

For most card games you need to be able to shuffle the deck; that is, put the cards in a random order. In Section~\ref{random} we saw how to generate random numbers, but it is not obvious how to use them to shuffle a deck.

One possibility is to model the way humans shuffle, which is usually by dividing the deck in two and then reassembling the deck by choosing alternately from each deck. Since humans usually don't shuffle perfectly, after about 7 iterations the order of the deck is pretty well randomized. But a computer program would have the annoying property of doing a perfect shuffle every time, which is not really very random. In fact, after some number of perfect shuffles which I forget, you would find the deck back in the same order you started in.

A better shuffling algorithm is to traverse the deck one card at a time, and at each iteration, choose two cards and swap them.

Here is an outline of how this algorithm works. To sketch the program, I am using a combination of Java statements and English words that is sometimes called pseudocode:


    for (int i=0; i<deck.length; i++) {
      // choose a random number between i and deck.length
      // swap the ith card and the randomly-chosen card
    }

The nice thing about using pseudocode is that it often makes it clear what methods you are going to need. In this case, we need something like randomInt, which chooses a random integer between the parameters low and high, and swapCards which takes two indices and switches the cards at the indicated positions.

You can probably figure out how to write randomInt by looking at Section~\ref{random}, although you will have to be careful about generating indices that are out of range.

You can also figure out swapCards yourself. The only tricky thing is to decide whether to swap just the references to the cards or the contents of the cards. Does it matter which one you choose? Which is faster?

I will leave the remaining implementation of these methods as an exercise to the reader.



12.9 Sorting

Now that we have messed up the deck, we need a way to put it back in order. Ironically, there is an algorithm for sorting that is very similar to the algorithm for shuffling.

Again, we are going to traverse the deck and at each location choose another card and swap. The only difference is that this time instead of choosing the other card at random, we are going to find the lowest card remaining in the deck.

By "remaining in the deck," I mean cards that are to the right of the index i.


    for (int i=0; i<deck.length; i++) {
      // find the lowest card to the right of i
      // swap the ith card and the lowest card to the right of i
    }

Again, the pseudocode helps with the design of the helper methods. In this case we can use swapCards again, wo only need one new one, called findLowestCard, that takes an array of cards and an index where it should start looking.

Once again, I am going to leave the implementation up to the reader.



12.10 Decks and subdecks

Looking at the interface to findBisect


  public static int findBisect (Card[] deck, Card card, int low, int high)

it might make sense to treat three of the parameters, deck, low and high, as a single parameter that specifies a subdeck. We took a similar view in Section 4.9 when we were talking about bounding boxes. In that case I referred to x, y, width and height as if they were a single parameter, a bounding box.

This kind of thing is quite common, and I sometimes think of it as an abstract parameter. What I mean by "abstract," is something that is not literaly part of the program text, but which describes the function of the program at a higher level.

For example, when you invoke a method and pass an array and the bounds low and high, there is nothing that prevents the invoked method from accessing parts of the array that are out of bounds. So you are not literally sending a subset of the deck; you are really sending the whole deck. But as long as the recipient plays by the rules, it makes sense to think of it, abstractly, as a subdeck.

There is one other example of this kind of abstraction that you might have noticed in Section~\ref{objectops}, when I referred to an "empty" data structure. The reason I put "empty" in quotation marks was to suggest that it is not literally accurate. All variables have values all the time. When you create them, they are given default values. So there is no such thing as an empty object.

But if the program guarantees that the current value of a variable is never read before it is written, then the current value is irrelevant. Abstractly, it makes sense to think of such a variable as "empty."

This kind of thinking, in which a program comes to take on meaning beyond what is literally encoded, is a very important part of thinking like a computer scientist. Sometimes, the word "abstract" gets used so often and in so many contexts that it comes to lose its meaning. Nevertheless, abstraction is a central idea in computer science (as well as many other fields).

A more general definition of "abstraction" is "The process of replacing a complex system with a simplified model in order to ignore unnecessary details while capturing important or relevant system behavior."



12.11 Glossary

encode:
To represent one set of values using another set of values, by constructing a mapping between them.
shallow equality:
Equality of references. Two references that point to the same object.
deep equality:
Equality of values. Two references that point to objects that have the same value.
pseudocode:
A way of designing programs by writing rough drafts in a combination of English and Java.
helper method:
Often a small method that does not do anything enormously useful by itself, but which helps another, more useful, method.
abstract parameter:
A set of parameters that act together as a single parameter.
abstraction:
The process of interpreting a program (or anything else) at a higher level than what is literally represented by the code.


  

 

to previous section to next section