A Class Is a Type That Protects a Rule
The previous lesson ended on the distinction: use a struct for a transparent bundle of data, and a class when the type has a rule to protect. That rule is called an invariant — something that must be true about the object at all times, before and after every operation.
A bank account is the standard example because its invariant is easy to state: the balance is never negative. If the balance were a public field, any line of code anywhere in the program could set it to −5000, and finding out which line did that means reading the entire codebase. Make it private and provide deposit and withdraw, and there are exactly two places where the balance can change. Both of them check. The invariant now holds by construction rather than by everybody remembering.
That is the whole argument for encapsulation, and it is worth stating in this practical form rather than as an abstract principle. Private data does not make your program more secure against an attacker; it makes it possible to reason about. Whenever you find a bug in a class's data, the number of places you have to read is the number of methods that write it.
The second benefit follows from the first. Because the outside world only touches your public methods, you are free to change how the class works internally. Store the balance as a double today and as an integer number of paise tomorrow; as long as the method signatures stay the same, no calling code needs to change. A public field is a promise about representation that you can never take back.
So the design question when you write a class is not "what fields does it have" but "what can be done to it". Write the public methods first, as the things a user of the class needs, and let the private data be whatever those methods require.
#include <iostream>
#include <string>
class BankAccount {
private:
std::string owner_;
double balance_ = 0.0; // invariant: never negative
public:
BankAccount(std::string owner, double opening)
: owner_(std::move(owner)), balance_(opening > 0 ? opening : 0.0) {}
void deposit(double amount) {
if (amount > 0) balance_ += amount;
}
bool withdraw(double amount) {
if (amount > 0 && amount <= balance_) {
balance_ -= amount;
return true;
}
return false; // refused: the invariant is protected
}
double balance() const { return balance_; }
const std::string& owner() const { return owner_; }
};
int main() {
BankAccount acc("Ananya", 1000.0);
acc.deposit(500.0);
acc.withdraw(200.0);
std::cout << acc.owner() << ": " << acc.balance() << '\n'; // Ananya: 1300
if (!acc.withdraw(999999.0)) std::cout << "insufficient funds\n";
// acc.balance_ = -5000; // error: balance_ is private
} - The trailing underscore on
owner_andbalance_is a common convention for marking private members. It is not required by the language; its value is that it lets a constructor parameter be calledownerwithout shadowing the member, and it makes it obvious at a glance which names are the object's own state.
public, private and protected
C++ has three access levels, and each one answers the question "who is allowed to touch this name?".
public members can be used by anyone. This is your class's interface — the set of operations you are promising to support and are obliged to keep working. Keep it as small as you can get away with; every public method is a commitment.
private members can only be used by the class's own member functions (and by anything declared a friend). This is the default for a class, which is the only real difference between class and struct. All data should normally live here.
protected sits between the two: accessible to the class itself and to classes that inherit from it, but not to the outside world. It exists for inheritance, and it is used less often than beginners expect. Making data protected gives every present and future derived class the ability to break your invariant, which is most of the cost of public with none of the honesty. The usual advice is to keep data private and expose protected functions when derived classes need controlled access.
You can write these labels as many times as you like and in any order. A very common layout is public first — so a reader meets the interface before the implementation details — then private. Whichever you choose, being consistent across a codebase is worth more than the specific choice.
public— anyone. The interface. Keep it small and stable.private— this class only. All data belongs here by default.protected— this class and its derived classes. Use for functions, rarely for data.friend— a named function or class granted access to private members; useful for operator overloads such asoperator<<classdefaults toprivate;structdefaults topublic. That is the entire language-level difference.
- Access control is a compile-time rule, not a runtime protection. It stops honest mistakes and communicates intent; it does not stop someone determined to reach the bytes. That is fine — the purpose is engineering discipline, not security.
Constructors and the Member Initialiser List
A constructor is a special member function with the same name as the class and no return type. It runs when an object is created, and its job is to leave the object satisfying its invariant. If a constructor cannot do that — someone asked for a negative opening balance — it should either correct the input or throw.
The colon-separated list between the parameters and the body is the member initialiser list, and it is not just a style preference. Members are constructed before the constructor body starts running. If you initialise them in the list, each member is constructed once, directly with the right value. If you assign them in the body instead, each member is first default-constructed and then assigned — two operations where one would do. For an int that is invisible; for a std::string or a std::vector it is a wasted construction on every object you create.
For some members the list is not optional at all. A const member and a reference member can only be initialised, never assigned, so they must appear in the initialiser list. The same is true of any member whose type has no default constructor. Trying to set these in the body produces an error that does not obviously say "use the initialiser list".
Now the gotcha that catches everybody at least once: members are initialised in the order they are declared in the class, not the order you write them in the list. If width_ is declared before area_, then width_ is initialised first no matter what your list says. Write : area_(width_ * height_), width_(w), height_(h) and area_ is computed from members that do not have values yet. GCC and Clang warn about this under -Wall, and the fix is to write the list in declaration order.
One more constructor detail worth knowing early: if you declare any constructor, the compiler stops generating the no-argument default constructor for you. Code that used to write BankAccount a; suddenly fails to compile. If you want both, say so explicitly with BankAccount() = default;.
#include <iostream>
#include <string>
#include <utility>
class Rectangle {
private:
double width_; // declared first -> initialised first
double height_;
double area_; // declared last -> initialised last
public:
// The list is written in DECLARATION order. This matters.
Rectangle(double w, double h)
: width_(w), height_(h), area_(w * h) {}
// Rectangle(double w, double h)
// : area_(width_ * height_), width_(w), height_(h) {} // BUG: garbage area
double area() const { return area_; }
};
class Session {
private:
const std::string id_; // const: MUST be in the initialiser list
std::string user_;
public:
Session(std::string id, std::string user)
: id_(std::move(id)), user_(std::move(user)) {}
Session() = delete; // no anonymous sessions
const std::string& id() const { return id_; }
};
int main() {
Rectangle r(4.0, 5.0);
std::cout << r.area() << '\n'; // 20
Session s("SESS-118", "Ananya");
std::cout << s.id() << '\n';
// Session anon; // error: explicitly deleted
} - If the compiler says
warning: 'Rectangle::area_' will be initialized after ..., it is telling you the initialiser list is out of declaration order. Reorder the list; do not silence the warning.
const Methods, this, and static Members
Inside a member function, this is a pointer to the object the function was called on. You rarely need to write it — balance_ already means this->balance_ — but it becomes necessary when a parameter shadows a member name, and it is how a method returns a reference to its own object for chaining.
Marking a method const makes this a pointer to a constant object, which is how the compiler enforces the promise not to modify. As with structs, this is not optional discipline: a const BankAccount& parameter can only call const methods, and since passing by const reference is the normal way to pass objects, a getter without const is unusable in half your codebase. Mark every non-modifying method const from the start.
Static members belong to the class rather than to any particular object. A static data member exists once, shared by every instance — useful for a counter of how many objects have been created, or a shared constant. A static member function has no this and therefore cannot touch non-static data; it is a function that is grouped with the class for naming purposes, and it is called as ClassName::function().
There is a wrinkle with static data. Declaring it inside the class is not defining it, so historically you also had to write a definition outside the class in one .cpp file, and forgetting produced an undefined reference at link time. Since C++17 you can write inline static to declare and define in one place, and static constexpr members have not needed a separate definition since C++17 either. Both are worth preferring in new code.
A word of caution on getters and setters. A class that is nothing but getX and setX for every field has gained nothing over a struct — the data is still fully exposed, just more verbosely. Write accessors where a genuine need exists, and prefer methods that express an operation (deposit, enrol, markPresent) over methods that expose state.
#include <iostream>
#include <string>
class Student {
private:
std::string name_;
int marks_ = 0;
inline static int count_ = 0; // C++17: one shared counter
public:
static constexpr int PASS_MARK = 33; // shared constant
explicit Student(std::string name) : name_(std::move(name)) {
++count_;
}
// const: promises not to modify the object
const std::string& name() const { return name_; }
bool hasPassed() const { return marks_ >= PASS_MARK; }
// `this` lets methods be chained
Student& setMarks(int marks) {
this->marks_ = marks;
return *this;
}
// static: no `this`, called on the class itself
static int totalCreated() { return count_; }
};
void report(const Student& s) { // const ref -> only const methods
std::cout << s.name() << (s.hasPassed() ? " passed\n" : " failed\n");
}
int main() {
Student a("Ananya");
a.setMarks(91);
report(a);
Student b("Rahul");
b.setMarks(20);
report(b);
std::cout << Student::totalCreated() << '\n'; // 2
std::cout << Student::PASS_MARK << '\n'; // 33
} expliciton a one-argument constructor stops the compiler using it for silent conversions. Without it, a function taking aStudentwould happily accept a barestd::stringand build one for you — convenient once, baffling the day it happens by accident. Make single-argument constructorsexplicitunless you have a specific reason not to.
