New to Java? We'll help you get started with our revised beginner's tutorial, or our free online textbook.
|
Get the latest Java books |
|
h t t p : / /w w w . j a v a c o f f e e b r e a k . c
o m /
|
Section 5.3
Programming with Objects
THERE ARE SEVERAL WAYS in which object-oriented concepts can be applied to the process of designing and writing programs. The broadest of these is object-oriented analysis and design which applies an object-oriented methodology to the earliest stages of program development, during which the overall design of a program is created. Here, the idea is to identify things in the problem domain that can be modeled as objects. On another level, object-oriented programming encourages programmers to produce generalized software components that can be used in a wide variety of programming projects.
Generalized Software Components
Every programmer builds up a stock of techniques and expertise expressed as snippets of code that can be reused in new programs using the tried-and-true method of cut-and-paste: The old code is physically copied into the new program and then edited to customize it as necessary. The problem is that the editing is error-prone and time-consuming, and the whole enterprise is dependent on the programmer's ability to pull out that particular piece of code from last year's project that looks like it might be made to fit. (On the level of a corporation that wants to save money by not reinventing the wheel for each new project, just keeping track of all the old wheels becomes a major task.)
Well-designed classes are software components that can be reused without editing. A well-designed class is not carefully crafted to do a particular job in a particular program. Instead, it is crafted to model some particular type of object or a single coherent concept. Since objects and concepts can recur in many problems, a well-designed class is likely to be reusable without modification in a variety of projects.
Furthermore, in an object-oriented programming language, it is possible to make subclasses of an existing class. This makes classes even more reusable. If a class needs to be customized, a subclass can be created, and additions or modifications can be made in the subclass without making any changes to the original class. This can be done even if the programmer doesn't have access to the source code of the class and doesn't know any details of its internal, hidden implementation. We will discuss subclasses in the next section.
Object-oriented Analysis and Design
A large programming project goes through a number of stages, starting with specification of the problem to be solved, followed by analysis of the problem and design of a program to solve it. Then comes coding, in which the program's design is expressed in some actual programming language. This is followed by testing and debugging of the program. After that comes a long period of maintenance, which means fixing any new problems that are found in the program and modifying it to adapt it to changing requirements. Together, these stages form what is called the software life cycle. (In the real world, the ideal of consecutive stages is seldom if ever achieved. During the analysis stage, it might turn out that the specifications are incomplete or inconsistent. A problem found during testing requires at least a brief return to the coding stage. If the problem is serious enough, it might even require a new design. Maintenance usually involves redoing some of the work from previous stages....)
Large, complex programming projects are only likely to succeed if a careful, systematic approach is adopted during all stages of the software life cycle. The systematic approach to programming, using accepted principles of good design, is called software engineering. The software engineer tries to efficiently construct programs that verifyably meet their specifications and that are easy to modify if necessary. There is a wide range of "methodologies" that can be applied to help in the systematic design of programs. (Most of these methodologies seem to involve drawing little boxes to represent program components, with labeled arrows to represent relationships among the boxes.)
We have been discussing object orientation in programming languages, which is relevant to the coding stage of program development. But there are also object-oriented methodologies for analysis and design. The question in this stage of the software life cycle is, How can one discover or invent the overall structure of a program? As an example of a rather simple object-oriented approach to analysis and design, consider this advice: Write down a description of the problem. Underline all the nouns in that description. The nouns should be considered as candidates for becoming classes or objects in the program design. Similarly, underline all the verbs. These are candidates for methods. This is your starting point. Further analysis might uncover the need for more classes and methods, and it might reveal that subclassing can be used to take advantage of similarities among classes.
This is perhaps a bit simple-minded, but the idea is clear and the general approach can be effective: Analyze the problem to discover the concepts that are involved, and create classes to represent those concepts. The design should arise from the problem itself, and you should end up with a program whose structure reflects the structure of the problem in a natural way.
Programming Examples
The PairOfDice class in the previous section is already an example of a generalized software component, although one that could certainly be improved. The class represents a single, coherent concept, "a pair of dice." The instance variables hold the data relevant to the state of the dice, that is, the number showing on each of the dice. The instance method represents the behaviour of a pair of dice, that is, the ability to be rolled. This class would be reusable in many different programming projects.
On the other hand, the Student class from the previous section is not very reusable. It seems to be crafted to represent students in a particular course where the grade will be based on three tests. If there are more tests or quizzes or papers, it's useless. If there are two people in the class who have the same name, we are in trouble (one reason why numerical student ID's are often used). Admittedly, it's much more difficult to develop a general-purpose student class than a general-purpose pair-of-dice class. But this particular Student class is good mostly as an example in a programming textbook.
Let's do another example in a domain that is simple enough that we have a chance of coming up with something reasonably reusable. Consider card games that are played with a standard deck of playing cards (a so-called "poker" deck, since it is used in the game of poker). In a typical card game, each player gets a hand of cards. The deck is shuffled and cards are dealt one at a time from the deck and added to the players' hands. In some games, cards can be removed from a hand, and new cards can be added. The game is won or lost depending on the value (ace, 2, ..., king) and suit (spades, diamonds, clubs, hearts) of the cards that a player receives. If we look for nouns in this description, there are several candidates for objects: game, player, hand, card, deck, value, and suit. Of these, the value and the suit of a card are simple values, and they will just be represented as instance variables in a Card object. In a complete program, the other five nouns might be represented by classes. But let's work on the ones that are most obviously reusable: card, hand, and deck.
If we look for verbs in the description of a card game, we see that we can shuffle a deck and deal a card from a deck. This gives use us two candidates for instance methods in a Deck class. Cards can be added to and removed from hands. This gives two candidates for instance methods in a Hand class. Cards are relatively passive things, but we need to be able to determine their suits and values. We will discover more instance methods as we go along.
First, we'll design the deck class in detail. When a deck of cards is first created, it contains 52 cards in some standard order. The Deck class will need a constructor to create a new deck. The constructor needs no parameters because any new deck is the same as any other. There will be an instance method called shuffle() that will rearrange the 52 cards into a random order. The dealCard() instance method will get the next card from the deck. This will be a function with a return type of Card, since the caller needs to know what card is being dealt. It has no parameters. What will happen if there are no more cards in the deck when its dealCard() method is called? It should probably be considered an error to try to deal a card from an empty deck. But this raises another question: How will the rest of the program know whether the deck is empty? Of course, the program could keep track of how many cards it has used. But the deck itself should know how many cards it has left, so the program should just be able to ask the deck object. We can make this possible by specifying another instance method, cardsLeft(), that returns the number of cards remaining in the deck. This leads to a full specification of all the subroutines in the Deck class:
Constructor and instance methods in class Deck: public Deck() // Constructor. Create an unshuffled deck of cards. public void shuffle() // Put all the used cards back into the deck, // and shuffle it into a random order. public int cardsLeft() // As cards are dealt from the deck, the number of // cards left decreases. This function returns the // number of cards that are still left in the deck. public Card dealCard() // Deals one card from the deck and returns it.This is everything you need to know in order to use the Deck class. Of course, it doesn't tell us how to write the class. This has been an exercise in design, not in programming. In fact, writing the class involves a programming technique, arrays, which will not be covered until Chapter 8. Nevertheless, you can look at the source code, Deck.java, if you want. And given the source code, you can use the class in your programs without understanding the implementation.
We can do a similar analysis for the Hand class. When a hand object is first created, it has no cards in it. An addCard() instance method will add a card to the hand. This method needs a parameter of type Card to specify which card is being added. For the removeCard() method, a parameter is needed to specify which card to remove. But should we specify the card itself ("Remove the ace of spades"), or should we specify the card by its position in the hand ("Remove the third card in the hand")? Actually, we don't have to decide, since we can allow for both options. We'll have two removeCard() instance methods, one with a parameter of type Card specifying the card to be removed and one with a parameter of type int specifying the position of the card in the hand. (Remember that you can have two methods in a class with the same name, provided they have different types of parameters.) Since a hand can contain a variable number of cards, it's convenient to be able to ask a hand object how many cards it contains. So, we need an instance method getCardCount() that returns the number of cards in the hand. When I play cards, I like to arrange the cards in my hand so that cards of the same value are next to each other. Since this is a generally useful thing to be able to do, we can provide instance methods for sorting the cards in the hand. Here is a full specification for a reusable Hand class:
Constructor and instance methods in class Hand: public Hand() { // Create a Hand object that is initially empty. public void clear() { // Discard all cards from the hand, making the hand empty. public void addCard(Card c) { // Add the card c to the hand. c should be non-null. // (If c is null, nothing is added to the hand.) public void removeCard(Card c) { // If the specified card is in the hand, it is removed. public void removeCard(int position) { // If the specified position is a valid position in the // hand, then the card in that position is removed. public int getCardCount() { // Return the number of cards in the hand. public Card getCard(int position) { // Get the card from the hand in given position, where // positions are numbered starting from 0. If the // specified position is not the position number of // a card in the hand, then null is returned. public void sortBySuit() { // Sorts the cards in the hand so that cards of the same // suit are grouped together, and within a suit the cards // are sorted by value. Note that aces are considered // to have the lowest value, 1. public void sortByValue() { // Sorts the cards in the hand so that cards of the same // value are grouped together. Cards with the same value // are sorted by suit. Note that aces are considered // to have the lowest value, 1.Again, you don't yet know enough to implement this class. But given the source code, Hand.java, you can use the class in your own programming projects.
We have covered enough material to write a Card class. The class will have a constructor that specifies the value and suit of the card that is being created. There are four suits, which can be represented by the integers 0, 1, 2, and 3. It would be tough to remember which number represents which suit, so I've defined named constants in the Card class to represent the four possibilities. For example, Card.SPADES is a constant that represents the suit, spades. (These constants are declared to be public final static ints. This is one case in which it makes sense to have static members in a class that otherwise has only instance variables and instance methods.) The possible values of a card are the numbers 1, 2, ..., 13, with 1 standing for an ace, 11 for a jack, 12 for a queen, and 13 for a king. Again, I've defined some named constants to represent the values of aces and face cards. So, cards can be constructed by statements such as:
card1 = new Card( Card.ACE, Card.SPADES ); // Construct ace of spades. card2 = new Card( 10, Card.DIAMONDS ); // Construct 10 of diamonds. card3 = new Card( v, s ); // This is OK, as long as v and s // are integer expressions.A Card object needs instance variables to represent its value and suit. I've made these private so that they cannot be changed from outside the class, and I've provided instance methods getSuit() and getValue() so that it will be possible to discover the suit and value from outside the class. The instance variables are initialized in the constructor, and are never changed after that. In fact, I've declared the instance variables suit and value to be final, since they are never changed after they are initialized. (An instance variable can be declared final provided it is either given an initial value in its declaration or is initialized in every constructor in the class.)
Finally, I've added a few convenience methods to the class to make it easier to print out cards in a human-readable form. For example, I want to be able to print out the suit of a card as the word "Diamonds", rather than as the meaningless code number 2, which is used in the class to represent diamonds. Since this is something that I'll probably have to do in many programs, it makes sense to include support for it in the class. So, I've provided instance methods getSuitAsString() and getValueAsString() to return string representations of the suit and value of a card. Finally, there is an instance method toString() that returns a string with both the value and suit, such as "Queen of Hearts". There is a good reason for calling this method toString(). When any object is output with System.out.print(), the object's toString() method is called to produce the string representation of the object. For example, if card refers to an object of type Card, then System.out.println(card) is equivalent to System.out.println(card.toString()). Similarly, if an object is appended to a string using the + operator, the object's toSring() method is used. Thus,
System.out.println( "Your card is " + card );is equivalent to
System.out.println( "Your card is " + card.toString() );If the card is the queen of hearts, either of these will print out "Your card is the Queen of Hearts".
Here is the complete Card class. It is general enough to be highly reusable, so the work that went into designing, writing, and testing it pays off handsomely in the long run.
/* An object of class card represents one of the 52 cards in a standard deck of playing cards. Each card has a suit and a value. */ public class Card { public final static int SPADES = 0, // Codes for the 4 suits. HEARTS = 1, DIAMONDS = 2, CLUBS = 3; public final static int ACE = 1, // Codes for non-numeric cards. JACK = 11, // Cards 2 through 10 have QUEEN = 12, // their numerical values KING = 13; // for their codes. private final int suit; // The suit of this card, one of the // four constants: SPADES, HEARTS, // DIAMONDS, CLUBS. private final int value; // The value of this card, from 1 to 13. public Card(int theValue, int theSuit) { // Construct a card with the specified value and suit. // Value must be between 1 and 13. Suit must be between // 0 and 3. If the parameters are outside these ranges, // the constructed card object will be invalid. value = theValue; suit = theSuit; } public int getSuit() { // Return the int that codes for this card's suit. return suit; } public int getValue() { // Return the int that codes for this card's value. return value; } public String getSuitAsString() { // Return a String representing the card's suit. // (If the card's suit is invalid, "??" is returned.) switch ( suit ) { case SPADES: return "Spades"; case HEARTS: return "Hearts"; case DIAMONDS: return "Diamonds"; case CLUBS: return "Clubs"; default: return "??"; } } public String getValueAsString() { // Return a String representing the card's value. // If the card's value is invalid, "??" is returned. switch ( value ) { case 1: return "Ace"; case 2: return "2"; case 3: return "3"; case 4: return "4"; case 5: return "5"; case 6: return "6"; case 7: return "7"; case 8: return "8"; case 9: return "9"; case 10: return "10"; case 11: return "Jack"; case 12: return "Queen"; case 13: return "King"; default: return "??"; } } public String toString() { // Return a String representation of this card, such as // "10 of Hearts" or "Queen of Spades". return getValueAsString() + " of " + getSuitAsString(); } } // end class Card
I will finish this section by presenting a complete program that uses the Card and Deck classes. The program lets the user play a very simple card game called HighLow. A deck of cards is shuffled, and one card is dealt from the deck and shown to the user. The user predicts whether the next card from the deck will be higher or lower than the current card. If the user predicts correctly, then the next card from the deck becomes the current card, and the user makes another prediction. This continues until the user makes an incorrect prediction. The number of correct predictions is the user's score.
My program has a subroutine that plays one game of HighLow. This subroutine has a return value that represents the user's score in the game. The main() routine lets the user play several games of HighLow. At the end, it reports the user's average score.
I won't go through the development of the algorithms used in this program, but I encourage you to read it carefully and make sure that you understand how it works. Here is the program:
/* This program lets the user play HighLow, a simple card game that is described in the output statements at the beginning of the main() routine. After the user plays several games, the user's average score is reported. */ public class HighLow { public static void main(String[] args) { TextIO.putln("This program lets you play the simple card game,"); TextIO.putln("HighLow. A card is dealt from a deck of cards."); TextIO.putln("You have to predict whether the next card will be"); TextIO.putln("higher or lower. Your score in the game is the"); TextIO.putln("number of correct predictions you make before"); TextIO.putln("you guess wrong."); TextIO.putln(); int gamesPlayed = 0; // Number of games user has played. int sumOfScores = 0; // The sum of all the scores from // all the games played. double averageScore; // Average score, computed by dividing // sumOfScores by gamesPlayed. boolean playAgain; // Record user's response when user is // asked whether he wants to play // another game. do { int scoreThisGame; // Score for one game. scoreThisGame = play(); // Play the game and get the score. sumOfScores += scoreThisGame; gamesPlayed++; TextIO.put("Play again? "); playAgain = TextIO.getlnBoolean(); } while (playAgain); averageScore = ((double)sumOfScores) / gamesPlayed; TextIO.putln(); TextIO.putln("You played " + gamesPlayed + " games."); TextIO.putln("Your average score was " + averageScore); } // end main() static int play() { // Lets the user play one game of HighLow, and returns the // user's score in the game. Deck deck = new Deck(); // Get a new deck of cards, and // store a reference to it in // the variable, Deck. Card currentCard; // The current card, which the user sees. Card nextCard; // The next card in the deck. The user tries // to predict whether this is higher or low // than the current card. int correctGuesses ; // The number of correct predictions the // user has made. At the end of the game, // this will be the user's score. char guess; // The user's guess. 'H' if the user predicts that // the next card will be higher, 'L' if the user // predicts that it will be lower. deck.shuffle(); correctGuesses = 0; currentCard = deck.dealCard(); TextIO.putln("The first card is the " + currentCard); while (true) { // Loop ends when user's prediction is wrong. /* Get the user's prediction, 'H' or 'L'. */ TextIO.put("Will the next card be higher (H) or lower (L)? "); do { guess = TextIO.getlnChar(); guess = Character.toUpperCase(guess); if (guess != 'H' && guess != 'L') TextIO.put("Please respond with H or L: "); } while (guess != 'H' && guess != 'L'); /* Get the next card and show it to the user. */ nextCard = deck.dealCard(); TextIO.putln("The next card is " + nextCard); /* Check the user's prediction. */ if (nextCard.getValue() == currentCard.getValue()) { TextIO.putln("The value is the same as the previous card."); TextIO.putln("You lose on ties. Sorry!"); break; // End the game. } else if (nextCard.getValue() > currentCard.getValue()) { if (guess == 'H') { TextIO.putln("Your prediction was correct."); correctGuesses++; } else { TextIO.putln("Your prediction was incorrect."); break; // End the game. } } else { // nextCard is lower if (guess == 'L') { TextIO.putln("Your prediction was correct."); correctGuesses++; } else { TextIO.putln("Your prediction was incorrect."); break; // End the game. } } /* To set up for the next iteration of the loop, the nextCard becomes the currentCard, since the currentCard has to be the card that the user sees, and the nextCard will be set to the next card in the deck after the user makes his prediction. */ currentCard = nextCard; TextIO.putln(); TextIO.putln("The card is " + currentCard); } // end of while loop TextIO.putln(); TextIO.putln("The game is over."); TextIO.putln("You made " + correctGuesses + " correct predictions."); TextIO.putln(); return correctGuesses; } // end play() } // end class HighLowHere is an applet that simulates the program:
[ Next Section | Previous Section | Chapter Index | Main Index ]