Chapter 19
Constructors, and Types That Can't Be Broken
Initialization, invariants, and types that enforce their own rules.
Chapter 18’s Deck had a flaw and the stretch pointed at it:
Deck d;
std::cout << d.size(); // 0
A brand-new deck has no cards in it. It only becomes a real deck once someone
calls fill(), and nothing makes them. The class carefully prevents outsiders
adding a 53rd card while permitting a deck of zero.
Every rule a type has is worth nothing if the type can be born breaking it.
A function that runs on creation
class Deck {
public:
Deck() {
std::vector<std::string> suits{
"Clubs", "Diamonds", "Hearts", "Spades"};
for (const std::string& s : suits) {
for (int r = 2; r <= 14; ++r) {
cards.push_back(Card(r, s));
}
}
}
int size() const { return cards.size(); }
private:
std::vector<Card> cards;
};
Deck d;
std::cout << "fresh deck: " << d.size() << '\n';
fresh deck: 52
Deck() is a constructor. Two things mark it out: it has the same name as the
class, and it has no return type: not void, nothing at all. It isn’t a function
you call. It’s the code that runs when a Deck comes into existence.
fill() is gone, and so is the possibility of forgetting it. There is no longer
any point in the deck’s life at which it holds the wrong number of cards.
That is the whole idea of this chapter, and it’s worth stating plainly: move the work from the user of the type to the type itself. Anything a caller must remember to do is something they will eventually forget.
Constructors that take arguments
Most types need to be told something when they’re made:
class Player {
public:
Player(std::string n, int s) : name(n), score(s) {}
std::string describe() const {
return name + ": " + std::to_string(score);
}
private:
std::string name;
int score;
};
Player p("Ada", 3);
std::cout << p.describe() << '\n';
Ada: 3
The odd part is : name(n), score(s) between the brackets and the body. That’s a
member initializer list, and it means “start name off as n, and score as
s”.
You could write it in the body instead, as name = n;, and for these types it
would work identically. Prefer the list anyway, for two reasons that will matter
later: it initialises the fields rather than creating them empty and then
assigning over the top, and some types can only be set up this way. It also puts
all the setting-up in one glanceable place above the body.
Read the syntax as punctuation and don’t overthink it. A colon, then each field with its starting value in brackets, comma-separated.
Enforcing a rule at the door
Here’s where constructors stop being convenience and start being correctness:
class Temperature {
public:
Temperature(double c) : celsius(c) {
if (celsius < -273.15) {
celsius = -273.15;
}
}
double value() const { return celsius; }
private:
double celsius;
};
Temperature t(-400);
std::cout << t.value() << '\n';
-273.15
Nothing colder than absolute zero can exist, so no Temperature object holding
less than -273.15 can exist either. The field is private, so the constructor is
the only way in, and the constructor checks.
The thing that must stay true, celsius is never below -273.15, is called an invariant. Naming it is most of the work; once you can state it in a sentence, where to enforce it is usually obvious.
Clamping is one response. Refusing is another, and chapter 17’s tools apply: a
function that returns std::optional<Temperature> can decline to make one at all.
Which is right depends on whether a nonsensical input is a small mistake or a
serious one. What isn’t right is storing it and hoping.
More than one way to build something
A class can have several constructors, as long as each takes different arguments:
class Fraction {
public:
Fraction() : num(0), den(1) {}
Fraction(int n) : num(n), den(1) {}
Fraction(int n, int d) : num(n), den(d) {}
std::string text() const;
private:
int num;
int den;
};
Fraction(); // 0/1
Fraction(3); // 3/1
Fraction(3, 4); // 3/4
0/1 3/1 3/4
C++ picks by counting and matching the arguments, the same way it decided which
function you meant back in chapter 9’s “requires 2 arguments, but 1 was provided”
error. Writing the no-argument one yourself is how you get Fraction f; back
after the callout above took it away.
Don’t get carried away. Each constructor is another way the type can be built and another thing to keep correct; three is plenty and one is often right.
When the answer is “you can’t have one”
Clamping works for temperatures because -400 has an obvious nearest legal value. Some invariants have no such fallback. A fraction with a denominator of zero isn’t a slightly-wrong fraction. It isn’t a fraction.
A constructor is badly placed to refuse. It has no return value to say no with, and the object is already being created by the time it runs.
So don’t use one. Put a function in front of it:
std::optional<Fraction> make_fraction(int n, int d) {
if (d == 0) {
return std::nullopt;
}
return Fraction(n, d);
}
make_fraction(1, 2); // 1/2
make_fraction(1, 0); // refused
1/2
refused
Chapter 17’s std::optional again, in a new place: the caller cannot get at a
Fraction without first dealing with the possibility that there isn’t one. The
constructor stays simple, the check happens once, and no zero-denominator
Fraction is ever built.
This pairing, a plain constructor plus a function that decides whether to call it, is worth remembering. Use the constructor alone when every input can be made sensible; put a function in front when some inputs are simply refusals.
What not to do: const members
You may wonder whether a field that never changes should be marked const:
private:
const std::string suit;
It compiles and it does guarantee the value never changes. It also quietly makes
the whole type non-assignable, and since std::sort works by moving elements
around, a std::vector of them can no longer be sorted. On my machine that
mistake produced 375 lines of errors from inside the standard library, with
barely a mention of the class that caused it.
A private field with no setter is already unchangeable from outside, which is the
protection you actually wanted. Don’t use const members, and if you meet one
in someone else’s code and their vector won’t sort, you now know why.
Exercise 1 · Three constructors
Rewrite chapter 18’s Thermometer so the constructor takes the starting
temperature and clamps it, and there is no setter at all. Confirm that
Thermometer t; no longer compiles, and read the error.
Write a class Rectangle taking width and height, with area() const and
perimeter() const. Make the constructor refuse negative sides by clamping them
to zero.
Then write a class Fraction taking a numerator and denominator. A denominator
of zero is the invariant to defend, and deciding what to do about it is the
interesting part. Clamping to 1 is a lie, so consider what else you could do.
Check yourself
Project
War
Roughly 60 minutes
The card game. Two players, one card each per round, higher card wins. It is a game of no skill whatsoever, which makes it perfect for this. All the interesting decisions are in the types.
Round 1: Q of Hearts vs 9 of Hearts -- you win
Round 2: 3 of Clubs vs Q of Clubs -- they win
Round 3: A of Clubs vs K of Hearts -- you win
Round 4: Q of Diamonds vs 2 of Spades -- you win
Round 5: 2 of Diamonds vs 8 of Spades -- they win
Final: you 3, them 2
42 cards leftChange Card first, and this is the design decision the whole project turns
on. Chapter 18 stored the rank as a string, which is fine for printing and
useless for comparing: "10" < "9" is true, because strings compare
alphabetically.
So store the rank as an int from 2 to 14, and convert for display:
class Card {
public:
Card(int r, std::string s) : rank(r), suit(s) {}
int value() const { return rank; }
std::string name() const { return rank_name() + " of " + suit; }
private:
int rank;
std::string suit;
std::string rank_name() const;
};Note rank_name() is private. It’s a helper for name() and nobody outside
needs it. Member functions can be private too, and it’s worth doing, because it keeps
the public section down to what the type is for.
rank_name is an if chain: 11 is J, 12 is Q, 13 is K, 14 is A, and anything
else is std::to_string(rank).
Deck gets a constructor that builds all 52, replacing chapter 18’s
fill(). The rank loop becomes for (int r = 2; r <= 14; ++r), which is neater
than the vector of strings it replaces.
The game itself is a loop you could have written in chapter 8: deal two,
compare with value(), count the winner, and remember the tie case.
Stretch one, play the whole deck. Twenty-six rounds instead of five. The
loop condition becomes deck.size() >= 2, which is the honest test.
Stretch two, real War. A tie means each player deals three face-down and one
face-up, and the winner takes everything on the table. You’ll need a
std::vector<Card> for the pile and to think about what happens when someone
runs out mid-war. This is a genuine step up in difficulty and the first problem
in this book where the rules are the hard part rather than the C++.