Low-level designCore7 min
Classes, objects and state
A class describes a kind of thing; an object is one of them, with its own identity, its own state, and behavior that changes that state. The constructor makes sure every object starts valid, and every method keeps it that way.
A café's gift cards
The class GiftCard describes every card; each card sold is an object of it.
1 · Classes and objects
One description, many cards
A café sells gift cards. Every card has a number and a balance, and you can spend from it. The class GiftCard describes what every card has and can do; each card sold is an object, also called an instance, made from that description.
class GiftCard {
private final String number;
private int balanceCents;
GiftCard(String number, int balanceCents) {
this.number = number;
this.balanceCents = balanceCents;
}
String number() {
return number;
}
int balanceCents() {
return balanceCents;
}
void spend(int cents) {
balanceCents -= cents;
}
}Every object has | Meaning | For a gift card |
|---|---|---|
Identity | It is itself, distinct from every other object, even one that looks the same | Card A-100 is not card B-200 |
State | The values of its fields right now, which can change over time | Its balance: $50.00, then $30.00 |
Behavior | What it can do, through its methods, usually changing its state |
|
The class as a diagram: one box, whatever the number of cards. Each card sold is an object of this class.
2 · Identity
Two cards with the same balance are still two cards
Cards A-100 and B-200 both hold $50.00. Their state is equal, but they are different objects: spending from one must not touch the other. Each language has two different questions for this, and mixing them up is a classic bug:
Language | Same object? | Equal state? |
|---|---|---|
Java |
|
|
C++ |
|
|
JavaScript |
| No built-in test; compare the fields |
Python |
|
|
For cards, identity is what matters: a card is a particular piece of plastic, not a balance. Some objects are the other way round, such as an amount of money or a date, where two equal values are interchangeable. Those are value objects, with a page of their own.
3 · State and references
A second name is not a second card
Java, JavaScript and Python variables hold references to objects. gift = a doesn't copy card A; it gives the same card a second name, so spending through gift changes a:
class GiftCard {
private final String number;
private int balanceCents;
GiftCard(String number, int balanceCents) {
this.number = number;
this.balanceCents = balanceCents;
}
String number() {
return number;
}
int balanceCents() {
return balanceCents;
}
void spend(int cents) {
balanceCents -= cents;
}
}
public class Main {
static String dollars(int cents) {
int amount = Math.abs(cents);
return (cents < 0 ? "-$" : "$") + amount / 100 + "." + String.format("%02d", amount % 100);
}
static void show(GiftCard c) {
System.out.println(c.number() + ": " + dollars(c.balanceCents()));
}
public static void main(String[] args) {
GiftCard a = new GiftCard("A-100", 5000);
GiftCard b = new GiftCard("B-200", 5000);
System.out.println("Same balance: " + (a.balanceCents() == b.balanceCents()));
System.out.println("Same card: " + (a == b));
// A second name for card A, not a new card.
GiftCard gift = a;
gift.spend(2000);
show(a);
show(b);
a.spend(4500);
show(a);
GiftCard c = new GiftCard("C-300", -1000);
show(c);
}
}Three variables, two objects. a and gift are two names for card A; b names card B.
Same balance: true Same card: false A-100: $30.00 B-200: $50.00 A-100: -$15.00 C-300: -$10.00
Card A lost $20 through its second name, card B didn't move. Then card A went to -$15.00, and card C started at -$10.00.
C++ is different by default: GiftCard gift = a; would copy the card, and spending from the copy would leave A alone. The C++ version writes GiftCard& gift = a;, a reference, to get a second name for the same card. Knowing which one your language does is part of reading any design.
4 · The change
A card with less than nothing
The output shows two cards no café would accept: card A at -$15.00 after a purchase bigger than its balance, and card C created with -$10.00 on it. Nothing in GiftCard said these were wrong, so nothing stopped them.
The rule they break is an invariant: a statement that must be true of every object of the class whenever no method is running. For gift cards there are two:
- a new card holds between $1 and $500;
- a balance is never below zero.
An invariant is kept by two kinds of code: the constructor, which must create every object in a valid state, and every method that changes state, which must leave it valid.
5 · The fix
Check at the door, and at every change
The constructor refuses a starting balance outside $1 to $500, and spend() refuses a purchase bigger than the balance. Both throw an error instead of continuing, so a bad card can never exist and a good card can never go bad:
class GiftCard {
static final int MAX_CENTS = 50000;
private final String number;
private int balanceCents;
// The starting balance must be $1 to $500.
GiftCard(String number, int balanceCents) {
if (balanceCents < 100 || balanceCents > MAX_CENTS) {
throw new IllegalArgumentException("a card starts with $1 to $500");
}
this.number = number;
this.balanceCents = balanceCents;
}
String number() {
return number;
}
int balanceCents() {
return balanceCents;
}
// The balance never goes below zero.
void spend(int cents) {
if (cents <= 0) throw new IllegalArgumentException("spend a positive amount");
if (cents > balanceCents) throw new IllegalStateException("not enough left");
balanceCents -= cents;
}
}Same balance: true Same card: false A-100: $30.00 B-200: $50.00 A-100: refused, not enough left ($30.00) C-300: refused, a card starts with $1 to $500
The same day at the café: the oversized purchase and the bad card are both refused.
The card checks the purchase against its own balance and refuses it, so the balance stays at $30.00.
Notice what the check buys: code anywhere else can now trust any GiftCard it's handed. Nobody has to ask "is this balance negative?" again, because no such card can exist.
6 · Constructors
Start every object valid
- Take everything the object needs to be valid. A card can't exist without a number and a starting balance, so the constructor requires both.
- Check, then refuse loudly. Throwing from the constructor means the caller never gets a half-made object.
- Don't leave a gap to fill later. A constructor that creates an empty card and expects the caller to set the balance next has made an invalid object, even if only for a moment.
- Keep it light. Validating and storing is the constructor's job; slow work such as loading files or calling a server belongs elsewhere.
Language | The constructor | If it throws |
|---|---|---|
Java | A method named after the class, run by |
|
C++ | Named after the class; fields are set up before its body runs | The object never existed; its fields are cleaned up |
JavaScript |
|
|
Python |
| The caller gets the error, not the object |
7 · Behavior
Change state through methods with meaning
spend(cents) is named for something that happens to a card, and it carries the rule about that event. A setBalance(cents) method would let any caller set any number and skip every rule. Methods named for real events are where invariants live; the next page, Encapsulation, is about keeping every other path to the state closed.
8 · In practice
Spots, tickets and their identities
In the parking-lot problem, every spot and every ticket is an object with its own identity and state: two compact spots look alike, but only one has your car in it. Decide each class's invariants first, such as "a spot holds at most one vehicle", and make the constructor and methods keep them.
Design the parking lot and give its objects valid states.
Check yourself
4 questions
Takeaways
Remember this
- A class describes a kind of thing; an object is one of them, with identity, state and behavior.
- Equal state is not the same object; know your language's tests for each.
- In Java, JavaScript and Python, assignment shares an object; in C++ it copies unless you use a reference.
- An invariant is a rule every object keeps whenever no method is running.
- The constructor establishes the invariants, and every method that changes state keeps them.