Encoding suits and ranks as integers so cards can be compared, decoding them back into words with an array of Strings, class variables shared by every object, a compareTo that imposes an order on a partially ordered set, and the decision to make cards immutable. Follows Think Java 2e, Chapter 12 (Arrays of Objects), Sections 12.1-12.5, pp. 201-208, cross-referenced against Java SE 21 API — java.lang.Comparable.
Subject: Java · 65 slides · code lesson
Open the interactive version of this deck
Title
Think Java 2e · Chapter 12 · Arrays of Objects
Sections 12.1-12.5 · pp. 201-208
Objectives
This lesson follows Think Java 2e, Chapter 12 (Arrays of Objects), Sections 12.1-12.5, pp. 201-208. Everything on these slides can be checked against those pages.
1. Explain what it means to encode a set of values, and why integers were chosen over Strings.
2. Use an array of Strings to decode an integer back into a word.
3. Distinguish a class variable from an instance variable, and say what static and final each mean.
4. Write a compareTo method returning a negative number, zero or a positive number.
5. Explain what totally ordered, partially ordered and unordered mean.
6. Make a class immutable by omitting setters and declaring the instance variables final.
Warm-up
Two techniques from earlier chapters are about to be combined.
Discussion prompt
From Lesson 7b: how did the doubloon program turn a letter into an array index? And from Lesson 11b: what does compareTo return, and what do you compare its result against?
Hint: One is subtraction; one is a sign.
Answer:
letter - 'a' mapped a letter to 0 through 25 so it could index an array of counters. compareTo returns a negative number, zero or a positive number, and you compare that result against zero rather than against a specific value.
This chapter uses both: an integer encoding so cards can be indexed and compared, and a compareTo of your own so they can be sorted. In this chapter we define a Card class, and the next two build a Deck and then a game of Crazy Eights on top of it.
Concept
A standard deck has 52 cards, each belonging to one of four suits and one of thirteen ranks. It is clear what the instance variables should be — rank and suit — and not obvious what types they should be. That choice decides everything else in the chapter.
Figure (svg): Two boxes comparing storing suits as Strings against storing them as integers, with the comparison problem noted
Downey & Mayfield, Think Java, 2nd edition (Green Tea Press / O'Reilly, 2020) — Think Java 2e, Chapter 12 (Arrays of Objects), Sections 12.1-12.5, pp. 201-208 — Chapter 12 opens on printed page 201.
Section
Section 12.1
Concept
One possibility is a String containing "Spade" for suits and "Queen" for ranks. A problem with that choice is that it would not be easy to compare cards to see which had a higher rank or suit. The alternative is to encode them as integers.
// suits // ranks
// Clubs -> 0 // Ace -> 1
// Diamonds -> 1 // 2-10 -> 2 to 10
// Hearts -> 2 // Jack -> 11
// Spades -> 3 // Queen -> 12
// // King -> 13| card | rank | suit |
|---|---|---|
| Ace of Clubs | 1 | 0 |
| 3 of Clubs | 3 | 0 |
| Jack of Diamonds | 11 | 1 |
| King of Spades | 13 | 3 |
encode — To represent one set of values using another set of values by constructing a mapping between them.
By encode we do not mean to encrypt or translate into a secret code. We mean to define a mapping between a sequence of numbers and the things we want to represent. The numeric ranks 2 through 10 map to themselves, which keeps the mapping easy to remember.
Notation
Think Java uses a mathematical arrow for the mapping deliberately, and the reason is worth noticing.
Annotate
An encoding is a design decision that the compiler cannot check. Choosing one that is easy to remember, and writing it down, is most of the work.
Worked example
The whole argument for the integer encoding is one thing Strings cannot do. Make it concrete.
// with Strings:
"Queen" < "Jack" // does not compile - < is for primitives
"Queen".compareTo("Jack") // positive - but that is ALPHABETICAL order
// with integers:
12 > 11 // true, and it means Queen beats Jack| comparison | with Strings | with integers |
|---|---|---|
| is Queen higher than Jack? | alphabetical order says J before Q — coincidentally right | 12 > 11 — right by design |
| is 10 higher than 9? | "10" < "9" alphabetically — wrong | 10 > 9 — right |
| is Ace higher than King? | depends on the game, and alphabetical says A first | 1 < 13 — a decision you make |
Notice that < does not work on objects at all.
Why: The comparison operators are for primitive types, as Lesson 9b showed for BigInteger.
Notice that String's compareTo gives alphabetical order.
Why: Which is not rank order — "10" sorts before "9" because '1' comes before '9'.
Notice the integers just work.
Why: this.rank < that.rank is an ordinary comparison of two ints, and it means exactly what you want.
Notice the cost.
Why: 12 does not read as Queen, so displaying a card now needs decoding — which is the next section.
Verify: Convince yourself that "10".compareTo("9") is negative, so alphabetically a 10 sorts before a 9.
Why: That single fact settles the design. Strings are easy to display and hard to compare; integers are easy to compare and need decoding to display — and comparison is the harder problem, so the encoding wins.
Prediction
Apply the two mappings.
// ranks: Ace 1, 2-10 themselves, Jack 11, Queen 12, King 13
// suits: Clubs 0, Diamonds 1, Hearts 2, Spades 3| value | position | means |
|---|---|---|
| 12 | rank | Queen |
| 2 | suit | Hearts |
Predict first
Which card is it?
Correct: Queen of Hearts
Why: The constructor takes rank first and suit second, so 12 is the rank — Queen — and 2 is the suit — Hearts. Note how much you had to know to answer: the parameter order and both mappings, none of which the call itself states. That is the encoding's cost.
Concept
With the encoding decided, the class definition is the Chapter 11 pattern applied to two integers.
public class Card {
private int rank;
private int suit;
public Card(int rank, int suit) {
this.rank = rank;
this.suit = suit;
}
}| part | decision it records |
|---|---|
| private int rank, suit | the data, encoded as integers, hidden from clients |
| Card(int rank, int suit) | how a Card is created |
| this.rank = rank | the shadowing pattern from Lesson 11a |
| no getters yet | Section 12.5 decides what to expose |
The instance variables are private: we can access them from inside this class, but not from other classes. new Card(3, 0) produces a reference to a Card representing the 3 of Clubs — and a reader who does not know the encoding cannot tell that from the call, which is a real weakness this chapter acknowledges.
Trap
new Card(3, 0) says nothing about what it means.
Card c1 = new Card(3, 0); // 3 of Clubs? or Clubs of 3?
Card c2 = new Card(0, 3); // and this?
Card c3 = new Card(1, 2); // Ace of Hearts, or 2 of... something?| problem | consequence |
|---|---|
| the parameter order is invisible at the call | rank and suit are easy to swap |
| both are ints | the compiler cannot catch a swap |
| the mapping is only a convention | a reader has to look it up |
Two int parameters of the same type means swapping them compiles perfectly. This is the cost of the encoding, and it is real.
Name the values, so the call site says what it means.
// with the class variables from Section 12.3:
Card c = new Card(3, Card.CLUBS);
// or at minimum, a comment recording the mapping:
// suits: 0 Clubs, 1 Diamonds, 2 Hearts, 3 Spades| improvement | what it buys |
|---|---|
| named constants | the call reads correctly and a swap becomes visible |
| the SUITS array | the order is written down in code, not just in comments |
| a comment | better than nothing, and cannot be checked |
This is Lesson 3a's magic-number argument in a new setting. A bare 0 in a call is exactly the kind of literal that should have a name — and the next section gives these ones names for a different reason that happens to help here too.
Definition probe
Strings display well; integers compare well.
Sort into buckets
Sort each task by the representation that makes it easy.
Prediction
Ace maps to 1, not 0.
// Ace -> 1, King -> 13
// so valid ranks are 1 to 13| rank value | card |
|---|---|
| 0 | none — unused |
| 1 | Ace |
| 13 | King |
Predict first
What consequence does starting at 1 have for a decoding array?
Correct: The array needs a placeholder at index 0, which is never used
Why: If rank 1 is to index directly into the array, index 0 has to exist and hold something — so the array has 14 elements with a placeholder first. The alternative would be subtracting 1 before every lookup, which is an off-by-one waiting to happen; the placeholder trades one wasted element for simpler code.
Socratic
Clubs could have been 3 and Spades 0.
Discussion prompt
The mapping Clubs to 0 through Spades to 3 is a choice. What makes this particular choice better than a random one — and what would break if you changed it later?
Hint: What order does a new deck come in?
Answer:
It matches the order a new deck comes in — Clubs, Diamonds, Hearts, Spades — so a sorted array of cards looks like a new deck, and the ordering feels natural rather than invented.
Changing it later would break anything that depends on the order: compareTo, any code that sorts, and the SUITS array whose element order encodes the same decision.
An encoding is a contract, even though nothing in the language enforces it. That is why writing it into a class variable — as the next section does — is worth more than leaving it in a comment: at least then there is one place it lives.
Section
Section 12.2
Concept
When you create a new class, the first step is to declare the instance variables and write constructors. A good next step is to write toString, which is useful for debugging and incremental development. To display a Card readably we have to decode the integers back into words.
String[] suits = {"Clubs", "Diamonds", "Hearts", "Spades"};
String[] ranks = {null, "Ace", "2", "3", "4", "5", "6",
"7", "8", "9", "10", "Jack", "Queen", "King"};
String s = ranks[this.rank] + " of " + suits[this.suit];| expression | for rank 11, suit 1 |
|---|---|
| ranks[this.rank] | ranks[11] is "Jack" |
| suits[this.suit] | suits[1] is "Diamonds" |
| the whole expression | "Jack of Diamonds" |
ranks[this.rank] means use the instance variable rank from this object as an index into the array ranks. That is Lesson 7b's value-as-index trick, used to decode rather than to count.
Picture it
Each element of the array is a reference to a String object — which is exactly what Lesson 9a's picture of an object variable predicted.
Figure (svg): An array of four elements each holding the name of a suit, with indexes 0 to 3 underneath
The array's index order encodes the mapping: Clubs is at 0 because Clubs maps to 0. So the array is not just a lookup table — it is the one place in the program where the design decision is written down in code.
Worked example
The ranks array has fourteen elements for thirteen ranks. The extra one is deliberate and its value is chosen carefully.
String[] ranks = {null, "Ace", "2", "3", "4", "5", "6",
"7", "8", "9", "10", "Jack", "Queen", "King"};| index | element | used? |
|---|---|---|
| 0 | null | never — rank 0 does not exist |
| 1 | "Ace" | yes |
| 11 | "Jack" | yes |
| 13 | "King" | yes |
| length | 14 | one more than the number of ranks |
Count the elements.
Why: Fourteen, for thirteen ranks — because valid ranks run 1 to 13 and index 0 must exist.
Notice why the placeholder is null.
Why: The zeroth element should never be used, and null indicates an unused element rather than pretending to be a rank.
Notice what null buys.
Why: If a bug ever produces rank 0, the output is the word null — visible and wrong — rather than a plausible-looking card.
Notice the alternative.
Why: Subtracting 1 before every lookup would avoid the wasted element and introduce an off-by-one everywhere.
Verify: Create new Card(11, 1) and print it; expect Jack of Diamonds.
Why: Then imagine a bug that produced rank 0. ranks[0] is null, so the output would be null of Clubs — obviously wrong at a glance. Choosing a placeholder that cannot be mistaken for real data is a small decision that pays off during debugging.
Prediction
Decode both integers.
Card card = new Card(11, 1);
System.out.println(card);| lookup | result |
|---|---|
| ranks[11] | "Jack" |
| suits[1] | "Diamonds" |
Predict first
What is displayed?
Correct: Jack of Diamonds
Why: println calls toString automatically, and toString uses the two integers as indexes into the decoding arrays. Without a toString the output would be the type and address instead — which is exactly why the book recommends writing one immediately after the constructor.
Concept
Wrapping the decoding in a toString gives the class the readable output Lesson 11b established, and it is the first thing to write after the constructor.
public String toString() {
String[] ranks = {null, "Ace", "2", "3", "4", "5", "6",
"7", "8", "9", "10", "Jack", "Queen", "King"};
String[] suits = {"Clubs", "Diamonds", "Hearts", "Spades"};
String s = ranks[this.rank] + " of " + suits[this.suit];
return s;
}| card | rank | suit | toString |
|---|---|---|---|
| new Card(11, 1) | 11 | 1 | Jack of Diamonds |
| new Card(1, 0) | 1 | 0 | Ace of Clubs |
| new Card(13, 3) | 13 | 3 | King of Spades |
When we display a card, println automatically calls toString — the mechanism from Lesson 11b. Writing it early is what makes the rest of the chapter debuggable: without it, every Card prints as Card@1a2b3c4d and you cannot see what your code is doing.
Trap
Two arrays created and discarded every time a card is printed.
public String toString() {
String[] ranks = { ... 14 strings ... };
String[] suits = { ... 4 strings ... };
return ranks[this.rank] + " of " + suits[this.suit];
}| printing | arrays created |
|---|---|
| one card | 2 |
| a 52-card deck | 104 |
| a deck a thousand times | 104,000 |
They are local variables, so each call allocates them and each return abandons them — which is Lesson 10b's garbage, generated for nothing. It works, and it is wasteful.
Make them class variables, allocated once for the whole program.
public static final String[] RANKS = { ... };
public static final String[] SUITS = { ... };
public String toString() {
return RANKS[this.rank] + " of " + SUITS[this.suit];
}| local arrays | class variables | |
|---|---|---|
| created | on every call | once, when the program begins |
| garbage produced | two arrays per call | none |
| usable from other methods | no | yes |
One advantage of defining SUITS and RANKS as class variables is that they do not need to be created and garbage-collected every time toString is called. They may also be needed in other methods and classes, so it helps to make them available everywhere — which is the next idea.
Prediction
Thirteen ranks, and a placeholder.
String[] ranks = {null, "Ace", "2", ..., "King"};| index | holds |
|---|---|
| 0 | null |
| 1 to 13 | the thirteen rank names |
Predict first
What is ranks.length?
Correct: 14
Why: Thirteen rank names plus a placeholder at index 0, because ranks are numbered from 1 and the array must have an element 0 for direct indexing to work. The wasted element buys code with no subtraction in it, which is a good trade.
Fill the middle
Use the instance variables as indexes.
Fill in the blanks
String s = ranks[this.rank] + " of " + suits[this.suit];
Why: this.rank is the object's own rank, used as an index into the ranks array — the value-as-index technique from Lesson 7b. Without this a bare rank would work here too, since there is no shadowing in this method, but writing it makes clear the value comes from the object rather than from a local.
Explain it to yourself
Before compareTo, before getters, before anything else.
Discussion prompt
Think Java says a good next step after the constructor is toString. Why before the methods that do the real work?
Hint: What can you see without it?
Answer:
Because without it you cannot see what your code is doing. Every Card prints as Card@1a2b3c4d, so any bug in a later method has to be diagnosed blind.
It is useful for debugging and incremental development — Lesson 4b's method, applied to a class. The first thing worth building is the thing that lets you check everything built afterwards.
The general habit: build the tool that makes the work visible before doing the work. It is the same reason the histogram in Lesson 7b was printed before being analysed.
Section
Section 12.3
Concept
You have seen local variables, declared inside a method, and instance variables, declared in a class definition. Now for class variables: they are also declared in the class, before the methods, but they are identified by the keyword static — and they are shared across all instances.
public class Card {
public static final String[] RANKS = {
null, "Ace", "2", "3", "4", "5", "6", "7",
"8", "9", "10", "Jack", "Queen", "King"};
public static final String[] SUITS = {
"Clubs", "Diamonds", "Hearts", "Spades"};
// instance variables and constructors go here
public String toString() {
return RANKS[this.rank] + " of " + SUITS[this.suit];
}
}| instance variable | class variable | |
|---|---|---|
| keyword | none | static |
| how many copies | one per object | one, ever |
| allocated | when an object is created | when the program begins |
| deleted | when the object is garbage-collected | when the program ends |
class variable — A variable declared within a class as static. There is only one copy, no matter how many objects there are.
Class variables are allocated when the program begins and persist until the program ends. In contrast, instance variables like rank and suit are allocated when the program creates new objects and deleted when the object is garbage-collected.
Notation
public static final String[] RANKS has three modifiers, and each one means something different.
Annotate
static means the variable is shared. One copy exists regardless of how many Card objects there are — and it exists even if there are none.final means the variable is constant. Note the careful wording: in this case it is the reference that is constant, so RANKS cannot be made to point at a different array.this and no class name, but the capitals tell you they are class variables.The second note is the subtle one. final on an array reference stops you reassigning the reference — it does not stop anyone changing an element. That is a real gap, and the next hazard is about it.
Worked example
You now have three places to declare something. The deciding question is how many copies should exist and how long each should live.
public class Card {
public static final String[] SUITS = { ... }; // one, for the class
private int rank; // one per Card
private int suit;
public String toString() {
String s = RANKS[this.rank]; // one per call
...
}
}| variable | how many copies | lifetime |
|---|---|---|
| SUITS | one, for the whole program | program start to end |
| rank | one per Card object | the object's lifetime |
| s | one per call to toString | the method call |
Ask whether every object needs its own.
Why: Each card has its own rank, so rank is an instance variable.
Ask whether one copy would do for all of them.
Why: Every card decodes with the same SUITS array, so one shared copy is right.
Ask whether it is needed only during one call.
Why: A temporary string is a local variable.
Then decide on final.
Why: SUITS should never be reassigned, so it is final as well as static.
Verify: Create fifty-two Cards and confirm the program has fifty-two ranks, fifty-two suits, and exactly one SUITS array.
Why: Class variables are often used to store constant values that are needed in several places — which is precisely what a decoding table is. The Deck class in Chapter 13 will use SUITS and RANKS too, and it can, because they are public.
Definition probe
How many copies should exist?
Sort into buckets
Sort each variable in the Card class.
Concept
Every constant and method you have called on a class rather than an object was static. Naming the concept explains several things at once.
| you wrote | which is | because |
|---|---|---|
| Integer.MAX_VALUE | a class variable | there is one, shared, not one per Integer |
| Math.PI | a class variable | pi does not vary per object |
| Math.sqrt(x) | a static method | no object owns square-rooting |
| Integer.parseInt(s) | a static method | it makes an int rather than using one |
| Card.SUITS | a class variable | one decoding table for all cards |
The pattern is consistent: static members are reached through the class name, because they do not belong to any object. That is also why main is static — Lesson 11b's point — and why you cannot use this inside one.
Trap
final freezes the reference, not the elements.
public static final String[] SUITS = {"Clubs", ...};
SUITS = new String[4]; // error - the reference is final
SUITS[0] = "Wands"; // legal! the elements are not| attempt | legal? | why |
|---|---|---|
| reassign SUITS | no | the reference is final |
| change SUITS[0] | yes | final says nothing about the elements |
| consequence | — | every card's suit name changes at once |
Because SUITS is public and static, any class can change an element, and every Card in the program immediately decodes differently. This is Lesson 10a's aliasing at the scale of a whole program.
Know what final promises, and decide whether public is worth the risk.
// final: this reference will always point at this array
public static final String[] SUITS = { ... };
// if the contents must be protected, do not expose the array:
private static final String[] SUITS = { ... };
public static String suitName(int suit) {
return SUITS[suit];
}| design | clients can read | clients can modify |
|---|---|---|
| public static final array | yes | yes — the elements |
| private array plus a method | yes | no |
The book makes SUITS public because Chapter 13's Deck class needs it, which is a reasonable trade for a textbook program. The point is knowing that it is a trade — final on an array is a weaker promise than it looks, and Lesson 11a's argument for private applies here too.
Prediction
Fifty-two cards are created.
public static final String[] SUITS = { ... };
// then 52 Card objects are created| what | how many |
|---|---|
| Card objects | 52 |
| rank variables | 52 |
| SUITS arrays | 1 |
Predict first
How many SUITS arrays are there?
Correct: One, shared by all of them
Why: A class variable is shared across all instances — there is only one copy, no matter how many objects exist. It is allocated when the program begins, so it exists even before the first Card is created, which is exactly why it can be used from a static context.
Matching
Three modifiers, three separate decisions.
Match the pairs
Why: The three are independent: a variable can be public without being static, static without being final, and so on. Think Java is explicit that static means shared and final means constant are two separate considerations that happen to be combined for a decoding table.
Edge cases
Push on what shared means.
Discussion prompt
SUITS is static and rank is not. Could a static method use rank directly? Reason from what static means before answering.
Hint: Which object's rank would it be?
Answer:
No. A static method belongs to the class and may be called when no objects exist at all — so there is no particular object whose rank it could mean.
That is exactly the error from Lesson 11b: non-static variable cannot be referenced from a static context. It is the same reason main cannot use instance variables directly.
The reverse is fine: an instance method can use a class variable, because a shared thing is available to everyone. toString uses SUITS freely — which is why the arrow only points one way.
Section
Section 12.4
Concept
For primitive types we can use < and > to compare values. But these operators do not work for object types. For Strings, Java provides compareTo; for classes we define, we can write our own — just as we did for equals.
public boolean equals(Card that) {
return this.rank == that.rank
&& this.suit == that.suit;
}| comparison | for primitives | for objects |
|---|---|---|
| are these equal? | == | equals |
| is this one bigger? | < and > | compareTo |
| who decides what bigger means? | the language | the class |
This is Lesson 11b's equals, now for Card — and both instance variables are ints, so both are compared with ==. There is no double here, so no tolerance is needed.
Notation
Before writing compareTo, it is worth asking whether cards can be ordered at all. The answer is not naturally — which is why the method has to make a decision.
Annotate
true < false is a compile error, because there is no sensible answer.That is worth noticing as a general point: writing compareTo for a partially ordered type means imposing an order rather than discovering one, and the class's author has to choose.
Worked example
With suit decided as more important, the method compares suits first and falls back on ranks.
public int compareTo(Card that) {
if (this.suit < that.suit) {
return -1;
}
if (this.suit > that.suit) {
return 1;
}
if (this.rank < that.rank) {
return -1;
}
if (this.rank > that.rank) {
return 1;
}
return 0;
}| this | that | compared on | returns |
|---|---|---|---|
| 3 of Clubs (3,0) | 2 of Diamonds (2,1) | suit: 0 < 1 | -1 |
| 3 of Clubs (3,0) | 2 of Clubs (2,0) | suits equal, rank: 3 > 2 | 1 |
| 3 of Clubs (3,0) | 3 of Clubs (3,0) | both equal | 0 |
Compare the suits first.
Why: If this suit is lower, return -1; if higher, return 1. That is the decision that suit matters more.
If the suits are the same, compare the ranks.
Why: The same two tests, on the other instance variable.
If the ranks are also the same, return 0.
Why: compareTo returns -1 if this is a lower card, +1 if higher, and 0 if this and that are equivalent.
Note that reaching the last line means everything matched.
Why: Which is the same shape as array11 returning 0 only after the loop finished, in Lesson 8b.
Verify: Compare the 3 of Clubs with the 2 of Diamonds and expect -1 — the Club is 'lower' because Clubs sort first.
Why: That answer is not a fact about cards; it is the consequence of a decision this method made. A different game could reasonably return the other answer, which is why compareTo belongs to the class rather than to the language.
Prediction
Suit is compared first.
Card a = new Card(2, 3); // 2 of Spades
Card b = new Card(13, 0); // King of Clubs
a.compareTo(b)| comparison | values | result |
|---|---|---|
| suits | 3 against 0 | this is higher |
| ranks | never reached | — |
Predict first
What is returned?
Correct: 1 — Spades outranks Clubs, and suit is compared first
Why: The method compares suits before ranks, and Spades (3) is higher than Clubs (0), so it returns 1 immediately and never looks at the ranks. That is the arbitrary decision the class made — a game where rank mattered more would return -1 for the same pair.
Concept
compareTo returns -1, 0 or 1 here, but callers should test the sign rather than the exact number — as Lesson 6b's String comparison already showed.
// correct
if (a.compareTo(b) < 0) { ... }
// fragile
if (a.compareTo(b) == -1) { ... }| class | what compareTo returns |
|---|---|
| Card | exactly -1, 0 or 1 |
| String | the difference between the first differing characters |
| Integer | -1, 0 or 1 |
| what callers should rely on | only the sign |
Lesson 6b's "Alan Turing".compareTo("Ada Lovelace") returned 8, not 1 — because String's version returns a character difference. A caller testing == 1 would work for Card and fail for String, which is why the contract is about the sign only.
Trap
The relational operators are for primitives.
Card a = new Card(3, 0);
Card b = new Card(2, 1);
if (a < b) { ... } // does not compile
if (a > b) { ... } // does not compile| operator | on ints | on objects |
|---|---|---|
| < | compares values | compile error |
| > | compares values | compile error |
| == | compares values | compiles — compares references |
The third row is the dangerous one and you have met it three times now: == does compile on objects and asks the wrong question. < and > at least fail loudly.
compareTo, compared against zero.
if (a.compareTo(b) < 0) {
System.out.println("a is lower");
} else if (a.compareTo(b) > 0) {
System.out.println("a is higher");
} else {
System.out.println("equivalent");
}| want | write |
|---|---|
| a < b | a.compareTo(b) < 0 |
| a > b | a.compareTo(b) > 0 |
| a equals b | a.equals(b), or compareTo(b) == 0 |
The pattern is identical to Lesson 6b's String comparison and Lesson 9b's BigInteger: objects compare with a method whose result you compare against zero. Three classes, one idiom.
Definition probe
Can any two values be compared?
Sort into buckets
Sort each type.
true < false at compile time.Fill the middle
Test whether a comes before b.
Fill in the blanks
if (a.compareTo(b) < 0) ___
Why: compareTo returns a negative number when the object it is invoked on comes first, so the test is against zero rather than against -1. Testing == -1 happens to work for Card and fails for String, whose compareTo returns a character difference — which is why the sign is the whole contract.
Counterexample
The encoding puts Ace at 1, the lowest rank.
Discussion prompt
In many games an Ace beats a King. With Ace encoded as 1 and King as 13, compareTo says the Ace is lower. Is the encoding wrong — and how would you handle a game where Aces are high?
Hint: Does the encoding have to match the game's ordering?
Answer:
The encoding is not wrong; it is just one mapping. The ordering is a separate decision from the encoding, and it lives in compareTo rather than in the numbers.
For Aces high you could change compareTo to treat rank 1 as if it were 14 — without touching the encoding, the constructor, or toString. Only the comparison changes.
That separation is the point. The encoding says what a card is; compareTo says how cards are ordered, and Think Java notes explicitly that the choice might be different for different games. Keeping the two apart is what makes the class reusable across Chapters 13 and 14.
Section
Section 12.5
Concept
The instance variables of Card are private, so they cannot be accessed from other classes. We provide getters to let other classes read the rank and suit — and then face a design decision about setters.
public int getRank() {
return this.rank;
}
public int getSuit() {
return this.suit;
}| provide | consequence |
|---|---|
| getters only | clients can read a card but not change it |
| getters and setters | cards become mutable — one card can be turned into another |
| neither | clients cannot see a card's rank at all |
Whether or not to provide setters is a design decision. If we did, cards would be mutable, so you could transform one card into another — that is probably not a feature we want, and in general mutable objects are more error-prone.
Picture it
Omitting setters is enough. Declaring the instance variables final is enough and enforced.
Figure (svg): Two panels comparing immutability by convention with immutability enforced by the final keyword
That is easy enough, but it is not foolproof, because a fool might come along later and add a modifier. Declaring the instance variables final prevents that possibility — and the fool in question is usually you, six months later.
Worked example
One keyword per instance variable, and the class becomes impossible to modify rather than merely unmodified.
public class Card {
private final int rank;
private final int suit;
public Card(int rank, int suit) {
this.rank = rank; // legal - initialising a final variable
this.suit = suit;
}
public void setRank(int rank) {
this.rank = rank; // compile error
}
}| assignment | where | legal? |
|---|---|---|
| this.rank = rank; | in the constructor | yes — the one permitted initialisation |
| this.rank = rank; | in a setter | no — cannot assign a final variable |
| reading this.rank | anywhere in the class | yes |
Add final to each instance variable.
Why: private final int rank;
Initialise them in the constructor.
Why: You can initialise these variables inside a constructor, which is the one place assignment is allowed.
Try to write a setter.
Why: If someone writes a method that tries to modify them, they will get a compiler error.
Notice what this buys.
Why: This kind of safeguard helps prevent future mistakes and hours of debugging.
Verify: Add a setRank method and confirm the class no longer compiles.
Why: That failure is the feature. It is Lesson 5a's argument for always writing braces, applied to class design: prefer a rule the compiler enforces over a convention you have to remember.
Prediction
It permits one assignment.
private final int rank;
public Card(int rank) {
this.rank = rank; // ?
}
public void setRank(int rank) {
this.rank = rank; // ?
}| assignment | legal? |
|---|---|
| in the constructor | yes |
| in a setter | no |
Predict first
Which assignment is a compile error?
Correct: The one in the setter — a final variable can only be initialised once
Why: A final instance variable may be initialised in the constructor and never assigned again, so the constructor's assignment is exactly the permitted one and the setter's is rejected. That is what makes the class impossible to modify rather than merely unmodified.
Concept
Chapter 10 argued that neither design is always better. For a Card, the arguments point one way.
| if cards were mutable | because they are immutable |
|---|---|
| a card in a hand could be silently changed | a card is a card, permanently |
| passing one to a method would be a risk | you can pass one anywhere safely |
| two references to one card would be a hazard | aliasing is harmless |
| a shuffled deck could be corrupted by a bug elsewhere | only the array of references can change |
The last row matters for Chapter 13: a Deck will shuffle by rearranging references, and the Cards themselves never change. That is only safe because Card is immutable — which is Lesson 9a's argument that immutability makes sharing free, now being relied on rather than merely described.
Trap
Immutable by convention — until someone adds a method.
public class Card {
private int rank; // not final
private int suit;
// no setters... today
}
// six months later, someone adds:
public void setRank(int rank) {
this.rank = rank; // compiles fine
}| when | is Card immutable? |
|---|---|
| today | yes, in practice |
| after the setter is added | no |
| does anything warn you? | no |
Nothing records the decision, so nothing defends it. Every piece of code that relied on cards being immutable — including Chapter 13's shuffling — is now built on an assumption that has quietly stopped being true.
final records the decision and enforces it.
public class Card {
private final int rank;
private final int suit;
...
}
// now the setter cannot be written at all| approach | records the intent? | enforced? |
|---|---|---|
| no setters | implicitly | no |
| a comment saying immutable | yes | no |
| final instance variables | yes | yes |
A safeguard that the compiler checks beats one that a reader has to notice. That is the same reasoning as final for constants in Lesson 3a — and the same reasoning as always writing braces.
Definition probe
Chapter 10's question, applied to specific types.
Sort into buckets
Sort each class by the design that fits.
Fill the middle
Prevent any future method from modifying the data.
Fill in the blanks
public class Card final} int rank;
private final int suit;
}
Why: final allows each variable to be initialised once — in the constructor — and rejects any later assignment at compile time. Simply omitting setters achieves the same thing today, but records nothing and stops working the moment someone adds one.
Real world
Alone, you could just remember not to add a setter.
Discussion prompt
Think Java's phrasing is that a fool might come along later and add a modifier. Why does that argument get stronger as a program grows, and who is the fool usually?
Hint: How long do you remember your own design decisions?
Answer:
The fool is usually you, some months later, with no memory of why the class had no setters. Nothing in the code said it was a decision rather than an omission.
In a team it is worse: the person adding the setter never made the decision at all, and has no way to discover it. A reasonable-looking change silently invalidates assumptions elsewhere.
final turns a decision into a fact the compiler checks. That is why it is worth two keywords — and it is the same argument as private instance variables in Lesson 11a: encode your intentions somewhere the language can defend them.
Comparison
Chapter 12 adds the last one. Fill the blanks.
Comparison matrix
| local | instance | class | |
|---|---|---|---|
| declared | inside a method | in the class, no static | in the class, with static |
| how many copies | one per call | one per object | one, ever |
| allocated | on invocation | when the object is created | when the program begins |
| usable in a static method? | yes, its own | no | yes |
The bottom row explains an error you have probably hit: main is static, so it cannot use instance variables — but it can use class variables freely, which is why Card.SUITS works anywhere.
Pattern
Encoding a set of values as integers, then decoding for display, is a technique you will reuse well beyond cards.
// 1. encode: choose a mapping, and write it down
// Clubs -> 0, Diamonds -> 1, Hearts -> 2, Spades -> 3
private final int suit;
// 2. decode: an array whose INDEX ORDER is the mapping
public static final String[] SUITS =
{"Clubs", "Diamonds", "Hearts", "Spades"};
// 3. display: use the value as an index
public String toString() {
return RANKS[this.rank] + " of " + SUITS[this.suit];
}
// 4. compare: the integers make an order possible
public int compareTo(Card that) { ... }| the encoding buys | the decoding array buys |
|---|---|
| comparison with < and > | readable output |
| use as an array index | the mapping written down in code |
| compact storage | one shared copy, if static |
| a sortable type | a place to change the names once |
Check
Work it out before you click.
public class Card {
public static final String[] SUITS = {"Clubs", ...};
private int suit;
}
// 52 Card objects are created| variable | copies |
|---|---|
| suit | 52 |
| SUITS | 1 |
Check your understanding
How many copies of each variable exist?
Answer: A
Why: An instance variable exists once per object, so 52 cards have 52 suit variables. A class variable is declared static and shared across all instances, so there is exactly one SUITS array however many cards exist — and it exists even before the first one is created.
Check
Work it out before you click.
// suit is compared first
Card a = new Card(13, 0); // King of Clubs
Card b = new Card(2, 1); // 2 of Diamonds
a.compareTo(b)| comparison | values |
|---|---|
| suits | 0 against 1 |
| result | this suit is lower |
Check your understanding
What does compareTo return?
Answer: A
Why: The method compares suits before ranks, so Clubs (0) against Diamonds (1) settles it and it returns -1 without ever looking at the ranks. The King's higher rank is irrelevant given the decision that suit matters more — a decision the class made and a different game could reverse.
Check
Work it out before you click.
public class Card {
private final int rank;
public Card(int rank) {
this.rank = rank;
}
public void setRank(int r) {
this.rank = r;
}
}| assignment | legal? |
|---|---|
| in the constructor | yes |
| in setRank | no |
Check your understanding
What happens when you compile this?
Answer: A
Why: A final instance variable may be initialised once, in the constructor, and never assigned again — so the constructor is fine and the setter is rejected. That is exactly the safeguard the book recommends: it makes adding a modifier impossible rather than merely discouraged.
Real world
Mapping a set of things onto integers so they can be compared, indexed and stored compactly is one of the oldest techniques in programming.
Discussion prompt
Cards became integers so they could be compared and used as indexes. Where else have you seen a set of non-numeric things represented as numbers — including earlier in this book?
Hint: Lesson 6a and Lesson 7b both used one.
Answer:
Characters are the biggest example: Unicode encodes every letter as a code point, which is what made letter - 'a' work in Lesson 7b and for (char c = 'a'; c <= 'z'; c++) work in Lesson 6a.
Beyond this book: colours as RGB numbers, days of the week as 0 to 6, error codes, database keys, and every file format ever designed. The reason is always the same — numbers compare, index and store in ways that names do not.
And the cost is always the same too: the mapping is a convention the compiler cannot check, so it has to be written down. The SUITS array is that write-down, which is why making it a class variable is worth more than a comment.
Commit first
Commit to an answer and to your confidence.
Predict first
In public static final String[] SUITS, what does final prevent?
Correct: Reassigning SUITS to a different array — but not changing its elements
Why: Think Java words this carefully: final means the variable — or in this case the reference — is constant. So SUITS = new String[4]; is rejected while SUITS[0] = "Wands"; compiles perfectly and changes how every Card in the program decodes. That gap matters because SUITS is also public. The static keyword is what ensures a single shared copy, and public is what allows other classes to read it — three modifiers, three separate effects.
Explain it
Two minutes, out loud.
Discussion prompt
A classmate asks why Card stores integers rather than Strings, since the integers have to be decoded for display anyway. Give them the argument, including one concrete comparison that Strings get wrong.
Hint: Compare a 10 and a 9 alphabetically.
Answer:
Strings display well and compare badly. < and > do not work on objects at all, and String's compareTo gives alphabetical order — so "10" sorts before "9", because the character '1' comes before '9'. That is exactly wrong for ranks.
Integers compare correctly with < and >, and they can be used as array indexes — which is how the decoding works. You pay for it by needing a toString, and that is six lines you write once.
The general form worth adding: choose the representation that makes the hard operation easy. Comparison and sorting are the hard part here, and displaying is the easy part, so the encoding wins.
Exit ticket
One question before you close the deck.
Predict first
What is the difference between an instance variable and a class variable?
Correct: An instance variable has one copy per object; a class variable is declared static and has one copy shared by all of them
Why: The static keyword is what makes the difference: a class variable is allocated when the program begins and persists until it ends, regardless of how many objects exist — while instance variables are created with each object and garbage-collected with it. That is why a decoding table like SUITS should be static (every card needs the same one) while suit should not (every card needs its own). Visibility and finality are separate decisions from this one.
Connect it up
One page, from memory.
Draw it
Write out both encodings — suits to 0-3 and ranks to 1-13 — and beside them draw the SUITS array with its indexes, showing how the index order IS the mapping. Then write the Card class skeleton with one class variable, two final instance variables and a constructor, labelling how many copies of each exist when 52 cards have been created. Finally, trace compareTo for the King of Clubs against the 2 of Diamonds and say which decision made the answer come out as it did.
Recap
Five sections that design a class carefully — and every decision in it is one Chapters 13 and 14 will rely on.
| if you remember one thing | it is this |
|---|---|
| about encoding | choose what makes the hard operation easy |
| about static | one copy for the class, not one per object |
| about immutability | final records the decision and defends it |
final on an array reference does not protect the elements.Want this taught 1-on-1? Alexander tutors Java — $55/session, free consultation.