Encapsulation Is About Invariants, Not Secrecy
Encapsulation is often explained as "hiding data", which makes it sound like a security measure. It is not — anyone reading your source can see the private members, and access control is a compile-time rule, not a lock. The real purpose is narrower and far more useful: encapsulation shrinks the number of places where a rule can be broken.
Take a temperature. A physical rule says it cannot go below −273.15 °C. If the value is a public field, every line in your program is a place where that rule might be violated, and when a nonsensical reading turns up you have the whole codebase to search. Make the field private and provide a constructor and a setter that both check, and there are exactly two places. When the bug appears, you read two functions.
This is why validation belongs inside the class rather than in the code that calls it. Callers forget. New callers written next year do not know about the rule. A check inside the constructor cannot be bypassed, because there is no way to create the object without going through it. The class becomes the single authority on what a valid temperature is.
The second benefit is freedom to change. Because nobody outside touches the private data, you can change how it is stored without touching a single caller. Store the temperature in Celsius today and in Kelvin tomorrow; as long as celsius() and kelvin() keep returning the right numbers, no other file needs to change. Every public field, by contrast, is a permanent promise about your internal representation.
So when you design a class, the question to ask is: what must always be true about this object? That statement is your invariant. The private data is whatever holds it, the constructor establishes it, and every public method must leave it true when it returns.
#include <iostream>
#include <stdexcept>
class Temperature {
private:
double celsius_; // invariant: >= -273.15
static void check(double c) {
if (c < -273.15) throw std::invalid_argument("below absolute zero");
}
public:
explicit Temperature(double celsius) : celsius_(celsius) { check(celsius); }
double celsius() const { return celsius_; }
double fahrenheit() const { return celsius_ * 9.0 / 5.0 + 32.0; }
double kelvin() const { return celsius_ + 273.15; }
void set(double celsius) { // the only other way in
check(celsius);
celsius_ = celsius;
}
};
int main() {
Temperature t(100.0);
std::cout << t.fahrenheit() << '\n'; // 212
std::cout << t.kelvin() << '\n'; // 373.15
try {
Temperature impossible(-500.0);
} catch (const std::invalid_argument& e) {
std::cout << "rejected: " << e.what() << '\n';
}
} - A constructor that cannot establish the invariant should throw, not quietly correct the value. An object that exists is then guaranteed to be valid, and no caller ever has to ask "did that actually work?". Silently clamping
-500to-273.15hides a bug in the caller's code.
Getters and Setters Are Not Encapsulation
Here is a habit worth unlearning early. Many introductory courses teach that encapsulation means making every field private and adding a getX and setX for each one. Follow that literally and you have written twice as much code to achieve exactly what public fields already did: anybody can read any value and set it to anything.
A setter earns its place when it does something — validates, converts units, keeps two fields consistent, notifies something. A setter whose entire body is field_ = value; is a public field wearing a disguise, and it is worse than a public field because it is longer and implies a promise it does not keep.
The better instinct is to expose operations rather than state. A bank account should offer deposit and withdraw, not setBalance. A shopping cart should offer addItem and applyCoupon, not setTotal. Named after what the caller wants to achieve, these methods can enforce every rule that applies, and the calling code reads like a description of the business rather than a sequence of assignments.
This principle is sometimes phrased as "tell, don't ask": rather than asking an object for its data, doing a calculation outside, and pushing the answer back in, tell the object what you want done and let it do the work. Code written that way tends to have far fewer places where an invariant can slip.
Getters are more often justified than setters — printing, reporting and testing all genuinely need to read state. Even so, return const std::string& rather than std::string for large members to avoid a copy, and think about whether the caller needs the raw value or a derived one. And if a class is genuinely nothing but data with no rules at all, be honest: make it a struct with public fields and skip the ceremony.
#include <iostream>
#include <string>
#include <vector>
// Anti-pattern: private fields, but the class enforces nothing
class CartBad {
double total_ = 0.0;
public:
double getTotal() const { return total_; }
void setTotal(double t) { total_ = t; } // anyone can set anything
};
// Operations, not state
class Cart {
std::vector<double> prices_;
double discount_ = 0.0; // invariant: 0.0 to 0.5
public:
void addItem(double price) {
if (price > 0) prices_.push_back(price);
}
bool applyCoupon(const std::string& code) {
if (code == "FIRST10") { discount_ = 0.10; return true; }
if (code == "BIG25") { discount_ = 0.25; return true; }
return false; // unknown codes change nothing
}
double payable() const { // derived, never stored
double sum = 0.0;
for (double p : prices_) sum += p;
return sum * (1.0 - discount_);
}
std::size_t itemCount() const { return prices_.size(); }
};
int main() {
Cart c;
c.addItem(499.0);
c.addItem(1000.0);
c.applyCoupon("FIRST10");
std::cout << c.itemCount() << " items, pay " << c.payable() << '\n';
CartBad b;
b.setTotal(-9999.0); // nothing stopped this
std::cout << b.getTotal() << '\n';
} - Notice that
Carthas nototal_field at all — the total is computed from the items whenever it is asked for. Storing a value that can be derived from other values creates a second thing that must be kept in step, which is an invariant you did not have to have.
friend: A Deliberate, Narrow Exception
Occasionally a function that is not a member genuinely needs access to a class's private data. The commonest case by far is stream output: to write std::cout << temperature, you have to overload operator<<, and that operator cannot be a member function because its left-hand operand is the stream, not your class. It has to be a free function — and a free function cannot see private members.
The friend keyword is the answer. Declaring friend std::ostream& operator<<(std::ostream&, const Temperature&); inside the class grants that one specific function access to the private members. Note the direction: friendship is granted by the class, from inside its own definition. Nobody can declare themselves your friend from outside, which is what keeps the mechanism honest.
Two properties are worth knowing. Friendship is not inherited — a friend of a base class is not a friend of a derived one. And it is not transitive — your friend's friends are not your friends. Both of these limit the damage, and both occasionally surprise people who expected otherwise.
Use it sparingly. Every friend declaration adds a function to the list of places your invariant can be broken, which is exactly the number encapsulation exists to reduce. The good cases are narrow: stream operators, operators that need symmetric access to two objects of the same class, and occasionally a closely-coupled helper class. If you find yourself adding friends to make ordinary code work, the class's public interface is probably missing something.
There is a tidy alternative worth knowing: if the class already exposes enough public accessors, operator<< can be an ordinary free function with no friendship at all. A non-member, non-friend function is the least privileged way to write it, and less privilege is generally better design.
#include <iostream>
#include <stdexcept>
class Temperature {
double celsius_;
public:
explicit Temperature(double c) : celsius_(c) {
if (c < -273.15) throw std::invalid_argument("below absolute zero");
}
double celsius() const { return celsius_; }
// Granted from inside the class. Can see celsius_ directly.
friend std::ostream& operator<<(std::ostream& os, const Temperature& t) {
os << t.celsius_ << "°C";
return os;
}
// Symmetric comparison: a natural use of friendship
friend bool operator==(const Temperature& a, const Temperature& b) {
return a.celsius_ == b.celsius_;
}
};
// No friendship needed — this only uses the public interface
std::ostream& printKelvin(std::ostream& os, const Temperature& t) {
return os << (t.celsius() + 273.15) << "K";
}
int main() {
Temperature boiling(100.0);
std::cout << boiling << '\n'; // 100°C
printKelvin(std::cout, boiling) << '\n'; // 373.15K
std::cout << (boiling == Temperature(100.0)) << '\n'; // 1
} operator<<should return the stream by reference. That is what allows chaining —std::cout << a << bworks because the first<<hands the stream back for the second one to use. Returningvoidcompiles until somebody chains, at which point the error is confusing.
Operator Overloading, Used Responsibly
Operator overloading lets your type be used with the built-in operators: +, ==, <, [], << and more. It is what makes std::string concatenate with + and std::vector index with [], and it is a genuine part of designing a class's interface rather than a party trick.
The governing rule is that an overloaded operator must mean the obvious thing. + on two Money values should add them. + on two Employee objects means nothing, so it should not exist. When an operator's meaning is not immediately clear from the types involved, a named function is better — merge(a, b) tells the reader far more than a + b ever could.
Which operators are worth defining depends on how the type is used. == and != if the type has a natural notion of equality. < if it has a natural ordering, which also makes it usable as a std::map key and sortable by std::sort without a lambda. << for printing and debugging, which pays for itself the first time you need to log one. Arithmetic operators only for types that are genuinely numeric, such as money, vectors or matrices.
As a member or as a free function? The rule of thumb: operators that modify the left operand (+=, ++) are members; symmetric binary operators (+, ==, <) are better as free functions, because that way both operands convert the same way; and stream operators must be free because the stream is on the left.
If you are compiling as C++20, comparison gets much simpler: you can write bool operator==(const T&) const = default; and get a correct member-by-member comparison, with != derived from it automatically. Before C++20, write == and define != as its negation so the two can never disagree.
==and!=— when the type has a clear notion of equality; define!=as!(a == b)<— when there is one natural ordering; unlocksstd::sort,std::setandstd::map<<— for printing and logging; always a free function returning the stream+ - * /— only for genuinely numeric types such as money, vectors, matrices[]— for container-like types; usually two versions, oneconstand one not- Anything whose meaning is not obvious — do not overload it; write a named function
#include <iostream>
class Money {
long long paise_ = 0; // integer paise: never floating point
public:
explicit Money(long long paise) : paise_(paise) {}
long long paise() const { return paise_; }
double rupees() const { return paise_ / 100.0; }
Money& operator+=(const Money& other) { // modifies left operand: member
paise_ += other.paise_;
return *this;
}
};
// Symmetric operators: free functions, built on the public interface
Money operator+(Money a, const Money& b) { a += b; return a; }
bool operator==(const Money& a, const Money& b) { return a.paise() == b.paise(); }
bool operator!=(const Money& a, const Money& b) { return !(a == b); }
bool operator<(const Money& a, const Money& b) { return a.paise() < b.paise(); }
std::ostream& operator<<(std::ostream& os, const Money& m) {
return os << "Rs " << m.rupees();
}
int main() {
Money cart(149900); // Rs 1499.00
Money shipping(4900); // Rs 49.00
Money total = cart + shipping;
std::cout << total << '\n'; // Rs 1548
std::cout << (cart < total) << '\n'; // 1
std::cout << (cart == total) << '\n'; // 0
// std::cout << (total > cart); // will NOT compile:
// operator> was never defined. Overloading one operator does not
// give you the others.
} 