Encapsulation in C++: Private Data, Invariants and Interfaces That Survive Change

Encapsulation in C++ with tested code: invariants, access specifiers, getter pitfalls, safe resource ownership, and how Python's approach compares.

A thermostat dial on a wall with a cutaway showing the wiring behind it, illustrating encapsulation: a simple public interface in front of hidden internal state

I changed the one private data member of a C++ Temperature class from Celsius to kelvin, and the code that used the class needed zero edits. Making the same change to a struct with a public field produced seven compile errors, plus one line that kept compiling and quietly started meaning kelvin. That difference is what encapsulation buys you, and it is a more useful way to understand the idea than the usual “wrap your data in getters and setters”.

This guide covers what encapsulation is, how C++ enforces it, what it protects (invariants and the freedom to change a representation), and the mistakes that quietly undo it: setters that ignore bad input, getters that leak references, and classes that own memory without owning their copies. A short section compares Python’s approach. Every C++ program was compiled and run for this article on Ubuntu 24.04 with g++ 13.3 and clang++ 18.1.3 under both -std=c++17 and -std=c++20, with -Wall -Wextra -pedantic. The working examples produce zero warnings and run clean under AddressSanitizer and UndefinedBehaviorSanitizer; the deliberately broken ones are shown with the warnings and sanitizer reports they actually produced. The Python example ran on Python 3.12.3. Every output block and compiler message is captured verbatim.

What Is Encapsulation in C++?

Encapsulation in C++ is the practice of bundling an object’s data with the functions that operate on it inside a class, and restricting direct access to that data so it can only change through the class’s own member functions. It lets a class guarantee rules about its state, called invariants, and change how it stores that state without breaking the code that uses it.

Two related terms are often used interchangeably. Data hiding is the access-control part: marking members private so outside code cannot name them. Abstraction is the design goal: presenting a simple interface that describes what an object does rather than how. Encapsulation is the mechanism that delivers both, and in C++ the mechanism is the class plus its access specifiers.

One interface, two representations The same client.cpp compiled, unchanged, against both versions of Temperature. client.cpp room.set_celsius(-40.0); room.celsius(); room.fahrenheit(); Version 1 public interface (unchanged) celsius() fahrenheit() set_celsius(double) private representation double celsius_; ≥ −273.15 °C Version 2 public interface (unchanged) celsius() fahrenheit() set_celsius(double) private representation double kelvin_; ≥ 0 K What the two builds printed 4 of 5 output lines identical 21.5 C = 70.7 F, rejected -300, … 1 line changed: body == 36.6 true in v1, false in v2 (36.600000000000023) Same change with a public struct 7 compile errors, and 1 line that still compiles
Callers see only the green layer. Because client code names nothing below it, the stored unit could switch from Celsius to kelvin without a single edit to the caller. The amber row is the caveat: the round trip through kelvin moved one value in its seventeenth significant digit, and an exact equality test noticed.

Access Specifiers in One Minute

C++ has three access levels, and the default depends on which keyword introduced the class:

Access specifierWho can use the memberTypical use
publicAny codeThe interface: the operations other code calls
privateThe class’s own member functions and its friendsData members and internal helpers
protectedThe class, its friends, and classes derived from itHooks meant for subclasses

Members of a class are private until you say otherwise; members of a struct are public. The defaults are the only language difference between the two keywords, and they apply to base classes too: class Derived : Base inherits privately, struct Derived : Base publicly. Both are covered in more depth, together with constructors and member functions, in our guide to C++ classes.

The rule is enforced when the program is compiled. Given the Temperature class shown in the next section, this line:

room.celsius_ = -300.0;

stops the build with both compilers:

error: 'double Temperature::celsius_' is private within this context      (g++ 13.3)
error: 'celsius_' is a private member of 'Temperature'                     (clang++ 18.1.3)

Invariants: What Encapsulation Is For

An invariant is a condition that must hold for every object of a class, at every moment outside its own member functions. A temperature cannot be below absolute zero, a date cannot be 31 February, and a sorted list must stay sorted. The point of making data private is that only the class’s member functions can then break an invariant, so only they need to be checked.

Here is the class used throughout this article:

// temperature.h (version 1) - stores degrees Celsius
#pragma once
#include <stdexcept>

class Temperature {
public:
    static constexpr double absolute_zero_c = -273.15;

    explicit Temperature(double celsius) { set_celsius(celsius); }

    double celsius() const { return celsius_; }
    double fahrenheit() const { return celsius_ * 9.0 / 5.0 + 32.0; }

    void set_celsius(double c) {
        if (c < absolute_zero_c)
            throw std::invalid_argument("below absolute zero");
        celsius_ = c;
    }

private:
    double celsius_;          // invariant: celsius_ >= -273.15
};

Three choices in this class do the enforcing:

  • The constructor establishes the invariant. There is no default constructor, so no Temperature can exist without a checked value.
  • Every write goes through one function. set_celsius() is the only code that assigns celsius_, so the check lives in exactly one place.
  • Bad input fails loudly. An impossible value throws std::invalid_argument instead of being silently ignored.

Two Ways Common Advice Gets This Wrong

A common “best practice” setter looks like this: void setRadius(double r) { if (r > 0) radius = r; }. It rejects bad input, but silently. The caller passes -5, gets no error, and carries on believing the radius changed. A setter that validates should report the failure by throwing, returning a bool, or returning an error type. Otherwise it only hides the bug.

The second gap is an object that can exist before it is valid. This class compiles cleanly, but nothing sets value_ until someone calls set():

// uninit.cpp - a class whose object can exist before it is valid
#include <iostream>

class Counter {
public:
    void set(int v) { value_ = v; }
    int get() const { return value_; }
private:
    int value_;                  // no constructor sets this
};

int main()
{
    Counter c;
    std::cout << "value: " << c.get() << '\n';   // set() never called
}

The same source gave a different answer in almost every build:

BuildOutputDiagnostic
g++ 13.3 -O0value: 32765None
g++ 13.3 -O2value: 0warning: 'c.Counter::value_' is used uninitialized [-Wuninitialized]
clang++ 18.1.3 -O0value: 32765None
clang++ 18.1.3 -O2value: -791039996, then value: 1586409476 on the next runNone
clang++ -fsanitize=memoryAbortedWARNING: MemorySanitizer: use-of-uninitialized-value

Only one of the four ordinary builds warned, and the one that warned printed a plausible zero. A constructor that sets the member removes the question entirely.

Changing the Representation Without Breaking Callers

The second thing encapsulation protects is your freedom to change how a class stores its data. To measure that, the same client program was compiled against two versions of Temperature. The client only calls the public member functions:

// client.cpp - uses only the public interface of Temperature
#include <iomanip>
#include <iostream>
#include <stdexcept>
#include "temperature.h"

int main()
{
    Temperature room(21.5);
    std::cout << room.celsius() << " C = " << room.fahrenheit() << " F\n";

    room.set_celsius(-40.0);
    std::cout << room.celsius() << " C = " << room.fahrenheit() << " F\n";

    try {
        room.set_celsius(-300.0);
    } catch (const std::invalid_argument& e) {
        std::cout << "rejected -300: " << e.what() << '\n';
    }
    std::cout << "still " << room.celsius() << " C\n";

    Temperature body(36.6);
    std::cout << std::boolalpha << "body == 36.6: " << (body.celsius() == 36.6)
              << std::setprecision(17) << ", stored as " << body.celsius() << '\n';
}

Version 2 stores kelvin instead of Celsius. The public section is character-for-character the same; only the private member and the bodies that use it changed:

// temperature.h (version 2) - stores kelvin; same public interface
#pragma once
#include <stdexcept>

class Temperature {
public:
    static constexpr double absolute_zero_c = -273.15;

    explicit Temperature(double celsius) { set_celsius(celsius); }

    double celsius() const { return kelvin_ + absolute_zero_c; }
    double fahrenheit() const { return celsius() * 9.0 / 5.0 + 32.0; }

    void set_celsius(double c) {
        if (c < absolute_zero_c)
            throw std::invalid_argument("below absolute zero");
        kelvin_ = c - absolute_zero_c;
    }

private:
    double kelvin_;           // invariant: kelvin_ >= 0
};

Output with version 1:

21.5 C = 70.7 F
-40 C = -40 F
rejected -300: below absolute zero
still -40 C
body == 36.6: true, stored as 36.600000000000001

Output with version 2:

21.5 C = 70.7 F
-40 C = -40 F
rejected -300: below absolute zero
still -40 C
body == 36.6: false, stored as 36.600000000000023

client.cpp compiled against both headers without a single edit, and four of the five output lines are identical. The fifth line is the one worth studying. Converting 36.6 to kelvin and back does not return exactly the same double, so a caller that compared temperatures with == changed behavior even though its code still compiled. Encapsulation protects callers’ code from a representation change; it does not protect their assumptions about floating-point results. If a class promises exact round trips, that promise belongs in its tests.

One boundary is worth stating plainly: “zero edits” means zero source edits. client.cpp still had to be recompiled against version 2, because private members are declared in the header, and every file that includes it builds its objects from that layout. Here both versions hold a single double, so sizeof(Temperature) stayed at 8 bytes. Add or resize a private member, and code compiled against the old header, such as a plugin or a user of a shared library, would disagree with the new code about the object’s size. The Pimpl idiom removes the data from the header entirely: the class holds only a std::unique_ptr to a struct defined in the .cpp file. Its one requirement is easy to miss. The class’s destructor must be declared in the header and defined in the .cpp file, where that struct is complete. Leave it to the compiler and the build fails with invalid application of 'sizeof' to incomplete type 'Widget::Impl' from both g++ and clang++.

Now the same change made to a struct with a public field. Version 1 is struct Temperature { double celsius; };, and its client reads and writes room.celsius directly. Renaming the field to kelvin gave:

sclient.cpp:8:23: error: 'struct Temperature' has no member named 'celsius'
sclient.cpp:8:50: error: 'struct Temperature' has no member named 'celsius'

That was 7 errors in total from both g++ and clang++, one for every use of .celsius. The client depended on the field in eight places, though: seven uses of .celsius and the brace initializer Temperature room{21.5};, which kept compiling because aggregate initialization does not name the member. After the change it quietly creates a temperature of 21.5 kelvin. The seven errors are the easy part; the line that still compiles is the dangerous one.

The struct version also accepted room.celsius = -300.0; without complaint and printed now -300 C. A struct is the right tool when its members really can hold any combination of values. The C++ Core Guidelines state it as rule C.2: use class if the type has an invariant, struct if the data members can vary independently.

Getters and Setters: When They Help and When They Leak

A class with a private member plus a get and set for it, each doing nothing but copying the value, has the same flexibility as a public field with extra typing. Core Guidelines rule C.131 advises against such trivial accessors. The useful test is whether the function protects something: an invariant, a conversion, a lazy computation, or a place to add one later.

The more serious leak is a getter that returns a non-const reference to a private member. This leaderboard keeps its scores sorted, highest first:

// leaderboard.cpp - a getter that returns a non-const reference
#include <algorithm>
#include <functional>
#include <iostream>
#include <vector>

class Leaderboard {
public:
    void add(int score) {
        // invariant: scores_ stays sorted, highest first
        auto pos = std::lower_bound(scores_.begin(), scores_.end(), score,
                                    std::greater<>{});
        scores_.insert(pos, score);
    }

    int top() const { return scores_.empty() ? 0 : scores_.front(); }

    std::vector<int>& scores() { return scores_; }   // the leak

private:
    std::vector<int> scores_;
};

int main()
{
    Leaderboard board;
    board.add(420);
    board.add(980);
    board.add(615);
    std::cout << "top after add():       " << board.top() << '\n';

    board.scores().push_back(9999);   // bypasses add(), breaks the order
    std::cout << "top after push_back(): " << board.top() << '\n';

    std::cout << "scores actually held:";
    for (int s : board.scores()) std::cout << ' ' << s;
    std::cout << '\n';
}

Output:

top after add():       980
top after push_back(): 980
scores actually held: 980 615 420 9999

scores_ is declared private, yet outside code appended to it directly. The board now holds 9999 and still reports 980 as the top score: the invariant is broken, and nothing crashed to say so. Changing the getter to const std::vector<int>& scores() const restores the guarantee, and the same push_back line becomes a compile error:

error: passing 'const std::vector<int>' as 'this' argument discards qualifiers [-fpermissive]   (g++)
error: no matching member function for call to 'push_back'                                      (clang++)

The same applies to returning non-const pointers, iterators, or std::spans into private data. Each one hands the caller a way around the class’s own rules.

Encapsulating a Resource: The Box That Crashed When Copied

Encapsulation also means a class owns the lifetime of what it manages. This Box allocates an int in its constructor and deletes it in its destructor, and it compiles with no warnings from either compiler:

// box_raw.cpp - a class that owns heap memory through a raw pointer
#include <iostream>

class Box {
public:
    Box() : value_(new int(112)) {}
    ~Box() { delete value_; }

    void set(int v) { *value_ = v; }
    int value() const { return *value_; }

private:
    int* value_;
};

int main()
{
    Box small;
    small.set(177);
    std::cout << "small holds " << small.value() << '\n';

    Box copy = small;              // one ordinary line
    std::cout << "copy holds  " << copy.value() << '\n';
}

Output (g++ and clang++ -O2, identical):

small holds 177
copy holds  177
free(): double free detected in tcache 2
Aborted

AddressSanitizer names the cause:

==839==ERROR: AddressSanitizer: attempting double-free on 0x502000000010 in thread T0:
    #0 0x7fea122ff5e8 in operator delete(void*, unsigned long) ../../../../src/libsanitizer/asan/asan_new_delete.cpp:164
    #1 0x55e0fe0b06bd in Box::~Box() box_raw.cpp:7
    #2 0x55e0fe0b04e4 in main box_raw.cpp:24

The compiler-generated copy constructor copied the pointer, not the int, so two objects each believed they owned the same allocation, and the second destructor freed it again. The class hid the pointer but did not control its ownership. Letting a standard type own the memory fixes that without writing any special member functions:

// box_fixed.cpp - the same Box, with ownership held by std::unique_ptr
#include <iostream>
#include <memory>
#include <utility>

class Box {
public:
    Box() : value_(std::make_unique<int>(112)) {}   // no destructor needed

    void set(int v) { *value_ = v; }
    int value() const { return *value_; }

private:
    std::unique_ptr<int> value_;
};

int main()
{
    Box small;
    small.set(177);
    std::cout << "small holds " << small.value() << '\n';

    Box moved = std::move(small);  // ownership transfers; small is left empty
    std::cout << "moved holds " << moved.value() << '\n';
}

Output:

small holds 177
moved holds 177

Now Box copy = small; is rejected at compile time instead of crashing at run time:

error: use of deleted function 'Box::Box(const Box&)'            (g++)
error: call to implicitly-deleted copy constructor of 'Box'      (clang++)

If copies are genuinely needed, store the int by value; the pointer never served a purpose here. Ownership and smart pointers are covered in detail in pointers in C++.

Does private Cost Anything, and What Does It Protect Against?

Access control exists only in the compiler. To check what it costs and what it stops, this program compares a struct with a public int against a class with a private int behind a getter:

// pin.cpp - what private costs at run time, and what it protects against
#include <cstring>
#include <iostream>

struct OpenPin {                  // public data
    int pin;
};

class SealedPin {                 // private data behind a getter
public:
    explicit SealedPin(int p) : pin_(p) {}
    int pin() const { return pin_; }
private:
    int pin_;
};

int read_open(const OpenPin& p)     { return p.pin; }
int read_sealed(const SealedPin& p) { return p.pin(); }

int main()
{
    std::cout << "sizeof(OpenPin)   = " << sizeof(OpenPin) << '\n'
              << "sizeof(SealedPin) = " << sizeof(SealedPin) << '\n';

    SealedPin secret(4071);
    int stolen = 0;
    static_assert(sizeof secret == sizeof stolen, "one int, no padding");
    std::memcpy(&stolen, &secret, sizeof stolen);   // no access check on bytes
    std::cout << "copied out of a private member: " << stolen << '\n';

    OpenPin open{1234};
    std::cout << read_open(open) + read_sealed(secret) << '\n';
}

Output:

sizeof(OpenPin)   = 4
sizeof(SealedPin) = 4
copied out of a private member: 4071
5305

At -O2, both compilers generated the same instructions for the direct read and the getter:

Functiong++ 13.3 -O2clang++ 18.1.3 -O2
read_open (public field)endbr64 / movl (%rdi), %eax / retmovl (%rdi), %eax / retq
read_sealed (private + getter)endbr64 / movl (%rdi), %eax / retmovl (%rdi), %eax / retq

(endbr64 is a control-flow-protection marker Ubuntu’s GCC adds to every function; it is not related to the getter.) At -O0 the getter is a real call, so debug builds do pay for it, but an optimized build pays nothing.

The memcpy line shows the other side: private is a rule about which names code may use, checked at compile time. It does not stop code in the same process from reading the object’s bytes, and it is not a security boundary. Encapsulation protects a program from its own mistakes, not from hostile code.

Encapsulation Across Inheritance and Friends

protected data members are the most common way a class hierarchy leaks its invariants. Every derived class, including ones written years later by someone else, can assign a protected member directly, so the base class can no longer guarantee anything about it. The usual fix is to keep data private in the base class and give derived classes protected member functions that perform checked operations. See inheritance in C++ for how access specifiers combine with public, protected and private inheritance.

A friend declaration grants one named function or class access to private members, most often operator<< for printing. A friend is best treated as part of the class’s interface: it is declared inside the class and changes whenever the representation does. One or two well-chosen friends are normal; a class with many friends has effectively made its data public.

Encapsulation in Python vs C++

Python has no private keyword. It relies on a naming convention and one mechanism: a single leading underscore (_kelvin) marks a name as internal by agreement, and a double leading underscore (__kelvin) triggers name mangling, which rewrites the name to _ClassName__kelvin to avoid clashes in subclasses. Validation is done with @property, which lets attribute syntax run a checked function. Here is the same Temperature class:

# temperature.py - the same Temperature class in Python 3


class Temperature:
    ABSOLUTE_ZERO_C = -273.15

    def __init__(self, celsius):
        self.celsius = celsius          # goes through the property setter

    @property
    def celsius(self):
        return self.__kelvin + self.ABSOLUTE_ZERO_C

    @celsius.setter
    def celsius(self, c):
        if c < self.ABSOLUTE_ZERO_C:
            raise ValueError("below absolute zero")
        self.__kelvin = c - self.ABSOLUTE_ZERO_C

    @property
    def fahrenheit(self):
        return self.celsius * 9 / 5 + 32


room = Temperature(21.5)
print(f"{room.celsius} C = {room.fahrenheit} F")

try:
    room.celsius = -300
except ValueError as e:
    print("rejected -300:", e)

try:
    print(room.__kelvin)
except AttributeError as e:
    print("AttributeError:", e)

print("name-mangled:", room._Temperature__kelvin)
room._Temperature__kelvin = -1000        # nothing stops this
print("after bypass:", room.celsius, "C")

Output:

21.5 C = 70.7 F
rejected -300: below absolute zero
AttributeError: 'Temperature' object has no attribute '__kelvin'
name-mangled: 294.65
after bypass: -1273.15 C

The property gives Python the same interface stability C++ gets from member functions. room.celsius looks like a plain attribute to callers, and the class can change what sits behind it. What Python does not give is enforcement. The mangled name is one lookup away, and assigning it put the object 1,000 degrees below absolute zero.

AspectC++Python
How “private” is markedprivate: access specifier_name by convention; __name for name mangling
When it is checkedAt compile time, on every use of the nameNever; mangling only renames the attribute
Outside access to private dataCompile errorWorks, via obj._Class__name
Validated attribute-style accessMember functions: celsius(), set_celsius()@property with a setter
Representation change without breaking callersYes, if callers use only public membersYes, if callers use only public names and properties
Protects against hostile codeNo; bytes can be copied directlyNo

The design lesson carries across both languages: decide which names are the interface, route every change to state through code that checks the invariant, and treat everything else as free to change.

Common Encapsulation Mistakes

MistakeWhat goes wrongFix
Public data in a type with an invariantAny code can set an impossible value; renaming the field breaks every callerMake the data private; expose operations
A setter that silently ignores bad inputThe caller believes the change happenedThrow, or return a status the caller must check
No constructorObjects exist before they are valid; reads are uninitializedEstablish the invariant in the constructor
A getter returning a non-const referenceCallers modify private state directlyReturn by value or by const&
An owning raw pointer with default copyingA copy causes a double freeUse std::unique_ptr or a value member
protected data membersEvery derived class can break the base’s invariantPrivate data with protected member functions
Trivial get/set for every memberEncapsulation in name onlyExpose operations that mean something

Key Takeaways

  • Encapsulation bundles data with the functions that change it and restricts direct access, so a class can guarantee its own invariants.
  • Invariants are the point. A constructor establishes them, one member function per kind of change enforces them, and bad input should fail loudly rather than be ignored.
  • A private representation can change freely. Switching Temperature from Celsius to kelvin needed no client edits, while the same change to a public struct produced 7 compile errors and 1 silent change of meaning.
  • Encapsulation protects code, not numerics. The kelvin version turned one == 36.6 comparison from true to false.
  • Returning a non-const reference to private data removes the protection. Return by value or const&.
  • Classes that own resources must own their copies too. One added copy of the raw-pointer Box caused a double free; std::unique_ptr turned it into a compile error.
  • private is free at -O2 and is not security. The getter compiled to the same single instruction as a field read, and memcpy read the private bytes anyway.

Frequently Asked Questions

Conclusion

Encapsulation is often taught as a rule about keywords: make the data private and write accessors. The experiments here point at what the keywords are for. A class that owns its invariants cannot be put into an impossible state, and a class whose callers depend only on its interface can change its representation without breaking them. The same mechanism fails the moment a reference, a raw pointer, or a protected member lets the state escape.

The next step in the object-oriented toolkit is using those interfaces polymorphically, where one interface stands for many implementations. The object-oriented programming tutorials cover inheritance, virtual functions and polymorphism with the same tested approach.

Scroll to Top