The C++ Textbook

Part 3 · Types you make

Structs and classes

Grouping data, and deciding what the outside world may touch.

By the end of this chapter you can

  • Define a class with a clear public interface
  • Explain the only difference between struct and class
  • Choose what belongs in the interface and what does not

Everything so far has used types the language gave you. This part is about making your own — types that behave as well as int does: they can be copied, compared, passed around, and cleaned up, and they cannot easily be misused.

The starting point is grouping related data under one name.

An aggregate

A struct is a group of members
#include <iostream>
#include <string>

struct Point {
    double x;
    double y;
};

int main() {
    Point origin{};              // both members zero-initialised
    Point p{3.0, 4.0};           // aggregate initialisation, in declaration order
    Point named{.x = 1.0, .y = 2.0};   // designated initialisers (C++20)

    std::cout << "p = (" << p.x << ", " << p.y << ")\n";
    std::cout << "origin = (" << origin.x << ", " << origin.y << ")\n";
    std::cout << "named.x = " << named.x << '\n';

    p.x = 10.0;                  // members are public: anyone may write them
    std::cout << "after p.x = 10: " << p.x << '\n';
}

Point is an aggregate: no private members, no user-declared constructors, so you can initialise it with braces in declaration order. Designated initialisers (.x = 1.0) arrived in C++20 and are worth using — they survive someone reordering the members, and they document what each value means at the call site.

You can give members default values, which fixes that trap at the source:

Default member initialisers
#include <iostream>

struct Settings {
    int  retries = 3;
    bool verbose = false;
    double timeout = 1.5;
};

int main() {
    Settings a;                       // uses every default
    Settings b{5};                    // retries = 5, the rest defaulted
    Settings c{.verbose = true};      // only verbose overridden

    std::cout << a.retries << ' ' << b.retries << ' ' << c.verbose << '\n';
    std::cout << "c.timeout still " << c.timeout << '\n';
}

The only difference between struct and class

struct members are public by default. class members are private by default. That is the entire difference — everything else is identical.

class defaults to private
class Hidden {
    int value = 1;       // private, because this is a class
};

int main() {
    Hidden h;
    return h.value;      // error: 'int Hidden::value' is private
}

The convention almost everyone follows: use struct when the type is a plain bundle of data with no invariant to maintain, and class when it has an interface to protect. The compiler does not care; readers do.

Why hide anything?

Because some combinations of member values are nonsense, and a type that can be put into a nonsensical state will eventually be put into one.

An invariant that nothing enforces
#include <iostream>

struct Fraction {
    int numerator;
    int denominator;

    double value() const { return static_cast<double>(numerator) / denominator; }
};

int main() {
    Fraction half{1, 2};
    std::cout << "1/2 = " << half.value() << '\n';

    half.denominator = 0;                    // nothing stops this
    std::cout << "1/0 = " << half.value() << '\n';
}

Dividing by zero in floating point gives inf rather than crashing, so the program carries on with a nonsense value and fails somewhere far from the cause.

Make the invariant the type’s responsibility:

An invariant the type maintains
#include <iostream>
#include <numeric>
#include <stdexcept>

class Fraction {
public:
    Fraction(int numerator, int denominator)
        : numerator_(numerator), denominator_(denominator) {
        if (denominator_ == 0) {
            throw std::invalid_argument("a fraction cannot have a zero denominator");
        }
        normalise();
    }

    int numerator() const { return numerator_; }
    int denominator() const { return denominator_; }
    double value() const { return static_cast<double>(numerator_) / denominator_; }

private:
    void normalise() {
        if (denominator_ < 0) {          // keep the sign on the numerator
            numerator_ = -numerator_;
            denominator_ = -denominator_;
        }
        const int divisor = std::gcd(numerator_, denominator_);
        if (divisor > 1) {
            numerator_ /= divisor;
            denominator_ /= divisor;
        }
    }

    int numerator_;
    int denominator_;
};

int main() {
    Fraction half{2, 4};
    std::cout << half.numerator() << '/' << half.denominator()
              << " = " << half.value() << '\n';

    Fraction negative{1, -2};
    std::cout << negative.numerator() << '/' << negative.denominator() << '\n';

    try {
        Fraction bad{1, 0};
    } catch (const std::invalid_argument& e) {
        std::cout << "rejected: " << e.what() << '\n';
    }
}

Now there is no way to obtain a Fraction with a zero denominator, and every Fraction is in lowest terms with the sign on the numerator. Those are the class’s invariants: statements true of every instance, from construction until destruction.

That is what private is for. Not secrecy — there is nothing secret about denominator_ — but the guarantee that the only code able to break the invariant is the small, reviewable amount inside the class.

Constructors

A constructor establishes the invariant. The member initialiser list — the part after the colon — initialises members directly, rather than default-constructing them and then assigning:

Initialise, do not assign
#include <iostream>
#include <string>

struct Loud {
    Loud() { std::cout << "  default-constructed\n"; }
    Loud(const std::string&) { std::cout << "  constructed from a string\n"; }
    Loud& operator=(const std::string&) { std::cout << "  assigned\n"; return *this; }
};

class Bad {
public:
    Bad(const std::string& text) { member_ = text; }   // constructs, then assigns
private:
    Loud member_;
};

class Good {
public:
    Good(const std::string& text) : member_(text) {}   // constructs once
private:
    Loud member_;
};

int main() {
    std::cout << "Bad:\n";  Bad  b{"x"};
    std::cout << "Good:\n"; Good g{"x"};
}

Bad does twice the work. For a Loud that is a wasted line of output; for a std::string or a std::vector it is a wasted allocation. And some members — references, const members, and types with no default constructor — cannot be assigned after the fact, so the initialiser list is the only option.

explicit

A single-argument constructor defines an implicit conversion unless you say otherwise:

An implicit conversion you did not intend
#include <iostream>

class Meters {
public:
    Meters(double value) : value_(value) {}     // not explicit
    double value() const { return value_; }
private:
    double value_;
};

void report(Meters distance) {
    std::cout << "distance: " << distance.value() << " m\n";
}

int main() {
    report(Meters{5.0});
    report(5.0);              // compiles — 5.0 is silently converted
    report(true);             // compiles — bool to double to Meters!
}

report(true) is almost certainly a bug, and the language accepted it without a word. Adding explicit requires callers to name the type:

explicit closes the hole
class Meters {
public:
    explicit Meters(double value) : value_(value) {}
    double value() const { return value_; }
private:
    double value_;
};

void report(Meters) {}

int main() {
    report(Meters{5.0});      // fine
    report(5.0);              // error: no implicit conversion
}

Make single-argument constructors explicit by default. Leave it off only when the conversion is genuinely something you want at every call site — as with std::string from a string literal.

What belongs in the interface

A useful test: would a caller notice if this changed? If not, it should be private.

  • Data members are almost always private, because their names, types, and representation are implementation choices you will want to change.
  • A getter is not automatically good design. balance() on an account is meaningful; a getter and setter for every member is a struct with extra typing, and it gives you the maintenance cost of a class with none of the protection.
  • Prefer members that do something over members that expose state. account.deposit(50) maintains the invariant; account.set_balance(x) hands it to the caller.

Check yourself

Practice