Chapter 18
Classes: Types You Design Yourself
Member functions, and why data goes private.
Chapter 14 gave you a Contact and admitted its weakness in a callout: nothing
stops anyone writing ada.age = -4. The struct holds three values and has no
opinion about them.
For a contact book that’s fine. It stops being fine the moment your type has rules: a deck that must contain 52 cards, a date that can’t be the 40th of March, a bank balance nobody should be able to set directly.
This chapter is about types that hold their own rules.
Putting the function inside
Here is a Card, and a function that describes it:
struct Card {
std::string rank;
std::string suit;
};
std::string name(const Card& c) {
return c.rank + " of " + c.suit;
}
std::cout << name(ace) << '\n';
Now the same thing with the function moved inside the braces:
class Card {
public:
std::string rank;
std::string suit;
std::string name() const {
return rank + " of " + suit;
}
};
std::cout << ace.name() << '\n';
Ace of Spades
That’s a member function, and it closes a loop that has been open since
chapter 9. Back then you were told some functions are called thing.action() and
others action(thing), and that the dotted ones belong to the value. Now you can
make them.
Two things changed and both matter.
The parameter is gone. Inside a member function, the fields are just there.
rank means “the rank of whichever card this was called on”. You don’t pass the
card in, because the call already said which one.
There’s a const after the brackets. That is a promise that this function
doesn’t change the card, and it is the same promise as chapter 12’s const&,
written in a new place. Leave it off and the function still works, but then you
can’t call it on a const Card&, which is what every function in chapter 14 was
taking. Get in the habit: a member function that only reads gets const.
Shutting the door
So far this is rearrangement. Here’s the part that changes what’s possible:
class Counter {
public:
void bump() { ++count; }
int value() const { return count; }
private:
int count = 0;
};
public: and private: are labels, and everything after one obeys it until the
next. Public members are reachable from outside; private ones are reachable only
from inside the class’s own functions.
Try to reach in from outside and the compiler stops you:
n.count = 100;
error: 'count' is a private member of 'Counter'
note: declared private here
Short, clear, and pointing at both ends of the problem, a pleasant change from chapter 12’s wall of library noise.
Note int count = 0; while you’re here. A field can be given a starting value
right where it’s declared, and every Counter then begins at zero without anyone
remembering to do it. That is chapter 14’s uninitialised-garbage trap, closed for
good.
class and struct
class Card { ... };
struct Card { ... };
The difference is one thing: struct members are public unless you say
otherwise, class members are private unless you say otherwise. That’s it.
There is no deeper distinction, no separate feature set, and anything you can do
with one you can do with the other.
Convention is worth following anyway. Use struct for a plain bag of fields with
no rules, such as chapter 14’s Contact, and class when the type is meant to look
after itself. A reader seeing class expects a private: section somewhere, and
you may as well meet the expectation.
From here on this book uses class when there’s something to protect.
What to make private
The instinct after learning this is to make every field private and add a get_
and set_ for each. Resist it. A private field with a setter that changes it to
anything is a public field wearing a hat, and you’ve written six lines to achieve
nothing.
The useful question is: could an outsider put this object into a state that makes no sense?
- A
Card’s rank and suit are set once and read forever. Public fields are honest, and astructis the right choice. - A
Deck’s vector of cards must never gain a 53rd card or a second Ace of Spades. That is a rule, so the vector goes private and the only ways in areshuffleanddeal. - A
Counter’s number should only ever go up by one.bump()is the entire public interface, and there is deliberately no way to set it directly.
Privacy is worth its cost when there’s an invariant, a thing that must stay true. No invariant, no need.
Bodies outside the class
So far every member function has had its body written inside the braces. That’s fine while they’re one-liners and gets unreadable fast. A class with six ten-line functions in it is a wall you have to scroll past to see what the type even offers.
The alternative is to declare inside and define outside:
class Card {
public:
std::string name() const;
void flip();
bool face_up() const { return up; }
std::string rank;
std::string suit;
private:
bool up = false;
};
std::string Card::name() const {
if (!up) { return "(face down)"; }
return rank + " of " + suit;
}
void Card::flip() {
up = !up;
}
(face down)
Ace of Spades
Card::name is the new punctuation. The :: says this name is the one
belonging to Card. Without it you’d be defining a free function that happens
to share the name and knows nothing about ranks or suits.
Three details worth having:
- The
consthas to appear in both places. Declaration and definition must match exactly, or you’ve written two different functions and the compiler will tell you it can’t find the one you declared. - The class name isn’t repeated in the return type, only before the function
name.
std::string Card::name(), notCard::std::string name(). - Tiny functions can stay inside.
face_up()above is one line and reads better where it is. The rule of thumb is whether someone skimming the class would want to see it.
This is exactly what chapter 20 needs. When a class moves into a header, the
declarations stay there and the bodies go to a .cpp, and this is the split that
makes it possible.
Reading a class from the outside in
One practical habit. When you meet a class in somebody’s code, read the public:
section first and stop there.
That section is what the type can do. The private part is how, and you can
usually avoid caring. This is the same reason chapter 9’s functions were worth
writing: you can use name() without holding its four lines in your head, and now
you can do that for a whole type.
It’s also a decent test of your own design. If your public section reads like a
sensible list of things one might want, the class is probably right. If it reads
like a list of your fields with get_ in front of them, it probably isn’t.
Exercise 1 · Make one look after itself
Write a class Thermometer with a private double celsius, a set(double), a
celsius_value() const and a fahrenheit() const that converts.
Make set refuse anything below -273.15, printing a complaint and leaving the old
value alone. Then try to break it from main by assigning to the field directly,
and read the error.
Now notice something: fahrenheit() is computed, not stored. There is no
double fahrenheit field and there shouldn’t be, because two fields that must
agree with each other are two chances to disagree.
Check yourself
Project
A deck of cards
Roughly 60 minutes
A deck that cannot be wrong. Chapter 19 deals it into a game; this chapter builds it.
deck has 52 cards
Q of Diamonds
J of Hearts
9 of Spades
7 of Spades
A of Clubs
deck has 47 cardsCard is a struct. Rank and suit, public, plus a name() const that joins
them. No rules, no privacy.
Deck is a class, and its std::vector<Card> cards is private. That is the
whole design: outsiders can shuffle and deal, and cannot add a 53rd card.
class Deck {
public:
void fill();
void shuffle();
Card deal();
int size() const;
private:
std::vector<Card> cards;
};Write the public section first and look at it before writing any bodies. Four things a deck can do, and nothing about how.
Filling it is two vectors of strings, thirteen ranks and four suits, and a
loop inside a loop. The outer over suits, the inner over ranks, pushing a Card
each time. 4 × 13 is where the 52 comes from, and you should check that it does.
Shuffling is std::shuffle, which is std::sort’s cousin and takes the
random generator from chapter 9:
std::random_device seed;
std::mt19937 generator(seed());
std::shuffle(cards.begin(), cards.end(), generator);Dealing takes from the end, because taking from the end of a vector is cheap and taking from the front is not:
Card top = cards.back();
cards.pop_back();
return top;back() gives you the last element and pop_back() removes it: two functions
chapter 10 didn’t need and this does.
Stretch one, deal from an empty deck. As written, deal() on an empty deck
reads off the end, which is chapter 10’s undefined behaviour with a nicer name.
Decide what should happen and make it happen. std::optional<Card> from chapter
17 is one honest answer; refusing to compile is not available to you here.
Stretch two, the deck that fills itself. Right now a Deck starts empty and
is only correct once somebody remembers fill(). That is exactly the kind of
rule a type should enforce rather than hope for, and chapter 19 is about the
mechanism that fixes it. Leave the flaw in place and notice it.