Object-Oriented Programming in C++: Classes, Encapsulation, Inheritance and Polymorphism

How C++ does OOP differently from Java and Python, with one program that uses all four pillars and the slicing and override mistakes that still compile.

A dashed outline of a cup followed by three solid cups of the same shape in decreasing sizes, representing an abstract base class and the concrete classes derived from it in C++.

Pass a Savings object to a C++ function that takes an Account by value, and the function receives an Account. The derived part is copied off, the overridden function is never called, and neither GCC nor Clang warns. In Java or Python the same call would just work. C++ supports object-oriented programming fully, but on its own terms: objects are values, polymorphism is something you opt into, and the language assumes you know which one you are using.

This guide explains object-oriented programming as C++ does it: the four core ideas (abstraction, encapsulation, inheritance and polymorphism), how C++ differs from Java and Python, one small program that uses all four, and the mistakes that compile. Each idea links to a deeper guide. Every program was compiled and run for this article on Ubuntu 24.04 with GCC 13.3 and Clang 18.1.3 under -std=c++17 and -std=c++20 with -Wall -Wextra -pedantic, and runs clean under AddressSanitizer and UndefinedBehaviorSanitizer; the examples that must not compile are shown with the errors both compilers gave. All output is captured verbatim, and the code is in a GitHub repository whose build repeats these checks on each commit.

The Short Answer

Is C++ an object-oriented language? C++ supports object-oriented programming, but it does not require it. It is a multi-paradigm language: the same program can use classes and virtual functions, plain functions and structs, and templates. Unlike Java, it has no common base class that every object inherits from.

What are the four pillars of OOP in C++? Abstraction (abstract classes and pure virtual functions), encapsulation (classes with private members), inheritance (deriving one class from another) and polymorphism (virtual functions called through a base-class reference or pointer).

Does C++ support multiple inheritance? Yes. A class can have several base classes, unlike in Java or C#, where a class extends one class and implements interfaces.

What Is Object-Oriented Programming in C++?

Object-oriented programming (OOP) in C++ is a way of structuring a program around classes: types that bundle data with the functions that operate on it and control who can change that data. Classes can be derived from other classes, and a virtual function called through a base-class pointer or reference runs the version that belongs to the object’s actual type.

C++ began in 1979 as Bjarne Stroustrup’s “C with Classes”, which added Simula-style classes to C; it was renamed C++ in 1983 and first standardized in 1998. The four ideas map onto specific language features:

IdeaWhat it meansHow C++ expresses itDeeper guide
AbstractionExpose what an object does, hide howAbstract classes with pure virtual functions (= 0)Virtual functions
EncapsulationKeep data valid by controlling access to itprivate and protected members, member functionsEncapsulation
InheritanceDefine a new type as a refinement of an existing oneclass EBook : public DocumentCovered below; inheritance in C++
PolymorphismOne call, behavior chosen by the object’s typevirtual functions called through Base& or Base*; templates at compile timePolymorphism

Classes themselves, with constructors, access specifiers and members, are covered in C++ classes explained.

How C++ Differs from Java and Python

Most OOP tutorials are written for languages that manage objects for you. Several of the rules people carry over do not hold in C++:

AspectC++JavaPython
Variables of class typeHold the object itself (value semantics)Hold a referenceHold a reference
OverridingOnly virtual functions can be overriddenMethods are overridable unless finalAll methods are overridable
Common base classNonejava.lang.Objectobject
Multiple inheritance of classesYesNo (interfaces only)Yes
Object destructionDeterministic: at end of scope or deleteGarbage collectedReference counting plus a cycle collector (CPython)
Default member accessprivate in a class, public in a structPackage-privatePublic (by convention, _name signals private)
Copying an objectCopies the whole value, unless you pass a referenceCopies the referenceCopies the reference

The first row explains the opening example. Because a C++ variable of type Account is an Account, assigning a Savings to it can only keep the Account part. Polymorphism in C++ works through references and pointers, and the rest of this guide uses them deliberately.

The Four Ideas in One Program

This program models a small digital library. It is short, and each part of it illustrates one of the four ideas:

// library.cpp - object-oriented C++ in one small program: an abstract base
// class, two derived classes, encapsulated state, virtual dispatch, and
// ownership through std::unique_ptr.
#include <algorithm>
#include <iostream>
#include <memory>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>

// Abstraction: what every item in the library can do, with no data about how.
class Document {
public:
    explicit Document(std::string title) : title_(std::move(title)) {}
    virtual ~Document() = default;              // deleted through a Document*

    const std::string& title() const { return title_; }
    virtual std::string kind() const = 0;       // pure virtual: no Document objects
    virtual std::string progress() const = 0;

private:
    std::string title_;
};

// Encapsulation: the page number can only move to a valid page.
class EBook : public Document {
public:
    EBook(std::string title, int pages) : Document(std::move(title)), pages_(pages)
    {
        if (pages < 1)
            throw std::invalid_argument("an e-book needs at least one page");
    }

    void next_page()     { if (current_ < pages_) ++current_; }
    void previous_page() { if (current_ > 1) --current_; }
    void go_to(int page)
    {
        if (page < 1 || page > pages_)
            throw std::out_of_range("page " + std::to_string(page) + " of " + std::to_string(pages_));
        current_ = page;
    }

    std::string kind() const override { return "e-book"; }
    std::string progress() const override
    {
        return "page " + std::to_string(current_) + " of " + std::to_string(pages_);
    }

private:
    int pages_;
    int current_ = 1;
};

// Inheritance: an audiobook is a Document with a different notion of progress.
class AudioBook : public Document {
public:
    AudioBook(std::string title, int minutes) : Document(std::move(title)), minutes_(minutes) {}

    void listen(int minutes) { heard_ = std::min(minutes_, heard_ + minutes); }

    std::string kind() const override { return "audiobook"; }
    std::string progress() const override
    {
        return std::to_string(heard_) + " of " + std::to_string(minutes_) + " minutes";
    }

private:
    int minutes_;
    int heard_ = 0;
};

// Polymorphism: one loop, and each object answers in its own way.
void print_shelf(const std::vector<std::unique_ptr<Document>>& shelf)
{
    for (const auto& doc : shelf)
        std::cout << "  " << doc->kind() << ": " << doc->title() << " (" << doc->progress() << ")\n";
}

int main()
{
    auto ebook = std::make_unique<EBook>("A Tour of C++", 320);
    auto audio = std::make_unique<AudioBook>("The Pragmatic Programmer", 540);
    EBook& book = *ebook;                       // the objects stay put when the
    AudioBook& talk = *audio;                   // unique_ptrs move into the shelf

    std::vector<std::unique_ptr<Document>> shelf;
    shelf.push_back(std::move(ebook));
    shelf.push_back(std::move(audio));

    book.go_to(41);
    book.next_page();
    talk.listen(95);

    std::cout << "shelf:\n";
    print_shelf(shelf);

    try {
        book.go_to(999);
    } catch (const std::out_of_range& e) {
        std::cout << "rejected: " << e.what() << '\n';
    }
    std::cout << "still on " << book.progress() << '\n';
}

Output:

shelf:
  e-book: A Tour of C++ (page 42 of 320)
  audiobook: The Pragmatic Programmer (95 of 540 minutes)
rejected: page 999 of 320
still on page 42 of 320
Four object-oriented ideas in one C++ program The classes in library.cpp; green members are private abstract base class Document – title_ : std::string + title() const + kind() const = 0 + progress() const = 0 Abstraction = 0 means no plain Document can be created EBook – pages_ : int – current_ : int = 1 + go_to(int page) + next_page(), previous_page() + kind(), progress() override AudioBook – minutes_ : int – heard_ : int = 0 + listen(int minutes) + kind(), progress() override Inheritance both are Documents Encapsulation go_to(999) throws; current_ stays 42 Polymorphism one loop over std::vector<std::unique_ptr<Document>> print_shelf(shelf) prints: e-book: A Tour of C++ (page 42 of 320) audiobook: The Pragmatic Programmer (95 of 540 minutes)
Each of the four ideas is a specific part of the code. The pure virtual functions make Document abstract, EBook’s private page number can only change through checked member functions, both derived classes inherit from Document, and one loop printed two different kinds of progress because the calls are virtual.
  • Abstraction. Document declares kind() and progress() as pure virtual (= 0). It says what every library item can report and nothing about how. A class with a pure virtual function is abstract: Document d("x"); does not compile, so the only documents that exist are documents of some concrete kind.
  • Encapsulation. EBook keeps pages_ and current_ private, and every way of changing the page checks the range. The program asked for page 999 of 320; go_to threw, and the book stayed on page 42. If current_ were public, nothing would stop a caller from setting it to 999 and the next progress() would report nonsense. The constructor also refuses a book with no pages, so an invalid EBook cannot exist at all.
  • Inheritance. EBook and AudioBook are both Documents: they inherit title() and must provide kind() and progress(). The virtual ~Document() matters here, because the std::unique_ptr<Document> objects in shelf delete EBook and AudioBook objects through a Document*; without it, that deletion is undefined behavior, and in practice the derived destructors are skipped.
  • Polymorphism. print_shelf knows only about Document. The same doc->progress() call printed page 42 of 320 for one object and 95 of 540 minutes for the other, because progress() is virtual and each call is dispatched to the object’s real type at run time.

The program also shows a C++-specific part of object design: ownership. The shelf owns its documents through std::unique_ptr, so they are destroyed when the shelf is, and the references book and talk remain valid because moving a unique_ptr moves the pointer, not the object. Ownership is covered in smart pointers in C++.

Classes and Objects

A class is a type; an object is a value of that type. class and struct declare the same kind of type in C++, and the only difference is the default access: members of a class are private unless an access specifier says otherwise, and members of a struct are public. This program declares a member without a specifier and then reads it from outside:

// default_private.cpp - must NOT compile: members of a class are private
// unless an access specifier says otherwise (a struct defaults to public).
class Counter {
    int count = 0;
};

int main()
{
    Counter c;
    return c.count;
}

Both compilers rejected it (GCC first, then Clang):

tests/compile-fail/default_private.cpp:10:14: error: 'int Counter::count' is private within this context
tests/compile-fail/default_private.cpp:10:14: error: 'count' is a private member of 'Counter'
tests/compile-fail/default_private.cpp:4:9: note: implicitly declared private here

Clang’s note says implicitly declared private: the class never wrote private:. The same program also shows that a class does not need to declare a constructor: neither compiler objected to Counter c;, because the compiler supplies a default constructor when a class declares none. Which special member functions the compiler generates, and when declaring one removes another, is covered in constructors and destructors in C++.

Encapsulation: Protecting an Invariant

An invariant is a statement about an object that must hold whenever code outside the class can see it: “the current page is between 1 and the page count”. Encapsulation is how a class makes an invariant enforceable. If the data can only change through member functions, and every member function preserves the invariant, then no caller can break it.

That is a stronger reason for private than “hiding data”. EBook::go_to is three lines, and those three lines are the only place a page number can enter the object. A public data member would spread that check across every caller, or more likely leave it out. The C++ access levels:

AccessVisible toTypical use
publicEveryoneThe interface: member functions callers rely on
protectedThe class and classes derived from itHooks for derived classes; use sparingly
privateThe class and its friendsData and helper functions that maintain the invariant

A friend declaration grants one specific function or class access to the private members, as the operator<< in the operator overloading section below does. It is part of the class’s interface, written by the class itself, so it does not weaken encapsulation the way a public data member does. Getters and setters for every field, on the other hand, do: a set_current(int) that assigns without checking is a public data member with extra steps. The encapsulation guide goes further.

Inheritance and Virtual Functions

Public inheritance models an is-a relationship: an EBook is a Document and can be used anywhere a Document& is expected. Three rules make that safe in C++:

  1. Make the functions you intend to override virtual. A non-virtual function in the base class is called according to the static type of the pointer or reference, not the object.
  2. Give a polymorphic base class a virtual destructor, or make its destructor protected and non-virtual so it cannot be deleted through a base pointer.
  3. Mark every overriding function override.

The third rule exists because an override that does not match exactly is not an override. This Circle::name() differs from Shape::name() only by a missing const:

// missing_override.cpp - a derived function that differs from the base only by
// a missing const. It compiles, and it does not override anything.
#include <iostream>
#include <memory>
#include <string>

struct Shape {
    virtual ~Shape() = default;
    virtual std::string name() const { return "shape"; }
};

struct Circle : Shape {
    std::string name() { return "circle"; }        // not const: a new function
};

int main()
{
    std::unique_ptr<Shape> s = std::make_unique<Circle>();
    Circle c;
    std::cout << "through Shape*: " << s->name() << '\n';
    std::cout << "on a Circle:    " << c.name() << '\n';
}

Output:

through Shape*: shape
on a Circle:    circle

Called through a Shape*, the object reported shape. Circle::name() is a new, unrelated function that happens to share the name; the virtual function it was meant to replace is still Shape::name() const. Both compilers did warn under -Wall, but the warnings describe hiding, not a failed override (GCC first, then Clang; excerpt):

src/missing_override.cpp:9:25: warning: 'virtual std::string Shape::name() const' was hidden [-Woverloaded-virtual=]
src/missing_override.cpp:13:17: note:   by 'std::string Circle::name()'
src/missing_override.cpp:13:17: warning: 'Circle::name' hides overloaded virtual function [-Woverloaded-virtual]

Adding override turns the mistake into an error that says exactly what went wrong:

tests/compile-fail/override_typo.cpp:11:17: error: 'std::string Circle::name()' marked 'override', but does not override
tests/compile-fail/override_typo.cpp:11:24: error: non-virtual member function marked 'override' hides virtual member function

override and final have been in the language since C++11. How the compiler implements virtual calls, abstract classes and pure virtual destructors is covered in the virtual functions guide. C++ also allows a class to have several base classes; it is useful for combining independent interfaces and rarely needed beyond that.

Polymorphism Needs a Reference or a Pointer

Virtual dispatch happens only when you call through a reference or a pointer. Copy a derived object into a base-class object and the copy is just a base-class object. This is called object slicing:

// slicing.cpp - copying a derived object into a base-class object keeps only
// the base part. Polymorphism needs a reference or a pointer.
#include <iostream>
#include <string>
#include <vector>

struct Account {
    virtual ~Account() = default;
    virtual std::string describe() const { return "account"; }
    double balance = 0;
};

struct Savings : Account {
    std::string describe() const override { return "savings account"; }
    double rate = 0.045;            // a member the base class does not have
};

void by_value(Account a)            { std::cout << "  by value:     " << a.describe() << '\n'; }
void by_reference(const Account& a) { std::cout << "  by reference: " << a.describe() << '\n'; }

int main()
{
    Savings s;
    by_value(s);                    // copies the Account part only
    by_reference(s);

    std::vector<Account> accounts;  // stores Account objects, not Savings
    accounts.push_back(s);
    std::cout << "  in vector<Account>: " << accounts[0].describe() << '\n';
    std::cout << "  sizeof(Account) = " << sizeof(Account)
              << ", sizeof(Savings) = " << sizeof(Savings) << '\n';
}

Output:

  by value:     account
by reference: savings account
in vector<Account>: account
sizeof(Account) = 16, sizeof(Savings) = 24

by_value(s) built a new Account from the Account part of s, so describe() ran Account‘s version. by_reference(s) referred to the caller’s Savings and ran the override. std::vector<Account> slices the same way: an Account element has room for 16 bytes, and a Savings needs 24, so push_back copied only the base part. All four builds compiled this without a warning under -Wall -Wextra. The same copy happens for any class type passed by value; the C++ functions guide counts the copies each kind of parameter makes.

The fixes follow from the cause. Pass polymorphic objects by const& or by pointer, and store them in a container of pointers, usually std::vector<std::unique_ptr<Base>> as in the library program. A base class that is not meant to be copied can delete its copy operations, which turns accidental slicing into a compile error. Compile-time and run-time polymorphism in more depth, with examples in other languages, are in polymorphism in object-oriented programming.

Operator Overloading

C++ lets a class define what operators mean for its objects, so user-defined types can be used like built-in ones. The guideline is to overload an operator only when its meaning is the obvious one: + for adding money, == for equality, << for printing.

// operators.cpp - operator overloading that behaves like the built-in
// operators, and one that cannot: an overloaded && evaluates both operands.
#include <iostream>
#include <ostream>

class Money {
public:
    explicit Money(long cents) : cents_(cents) {}
    Money operator+(Money other) const { return Money(cents_ + other.cents_); }
    bool operator==(Money other) const { return cents_ == other.cents_; }
    friend std::ostream& operator<<(std::ostream& os, Money m)
    {
        return os << '$' << m.cents_ / 100 << '.' << (m.cents_ % 100) / 10 << m.cents_ % 10;
    }
private:
    long cents_;
};

struct Check {
    bool ok;
    Check operator&&(Check other) const { return Check{ok && other.ok}; }
};

Check check(const char* what, bool result)
{
    std::cout << "  evaluated " << what << '\n';
    return Check{result};
}

int main()
{
    Money price(1999), tax(160);
    std::cout << "price + tax = " << price + tax << '\n';
    std::cout << "equal to $21.59: " << std::boolalpha << (price + tax == Money(2159)) << '\n';

    std::cout << "built-in &&:\n";
    bool b = check("left", false).ok && check("right", true).ok;
    std::cout << "overloaded &&:\n";
    Check c = check("left", false) && check("right", true);
    std::cout << "results: " << b << ' ' << c.ok << '\n';
}

Output:

price + tax = $21.59
equal to $21.59: true
built-in &&:
  evaluated left
overloaded &&:
  evaluated left
  evaluated right
results: false false

Money behaves the way a reader expects. Check shows why a few operators should be left alone. With the built-in &&, the right operand was never evaluated because the left one was already false. The overloaded && is an ordinary function call, so both operands were evaluated before it ran: short-circuit evaluation is lost, and an expression that relies on it, such as p != nullptr && p->ok(), would dereference a null pointer. The same applies to || and the comma operator.

Most operators can be overloaded. ::, ., .* and ?: cannot: both compilers rejected a class declaring any of them. sizeof, typeid and the cast keywords are not overloadable either. The full set of rules and conventions is in the operator overloading guide.

When Not to Use a Class Hierarchy

Inheritance is one way to get “one call, many behaviors”, and in C++ it is not always the right fit. When the set of types is closed and known in one place, std::variant (C++17) and std::visit give run-time dispatch with no base class, no virtual functions and no allocation per object:

// variant_shapes.cpp - the same "one call, many behaviors" without a class
// hierarchy: a closed set of types in a std::variant, dispatched by std::visit.
#include <iostream>
#include <string>
#include <variant>
#include <vector>

struct Circle    { double r; };
struct Rectangle { double w, h; };

using Shape = std::variant<Circle, Rectangle>;

struct Area {
    double operator()(const Circle& c) const    { return 3.14159265358979 * c.r * c.r; }
    double operator()(const Rectangle& r) const { return r.w * r.h; }
};

int main()
{
    std::vector<Shape> shapes{Circle{1.0}, Rectangle{2.0, 3.0}};
    for (const Shape& s : shapes)
        std::cout << "area: " << std::visit(Area{}, s) << '\n';
    std::cout << "sizeof(Shape) = " << sizeof(Shape) << '\n';
}

Output:

area: 3.14159
area: 6
sizeof(Shape) = 24

The shapes are stored by value in the vector, with no std::unique_ptr and no slicing, because each element has room for the largest alternative. The two designs push back in opposite places as they grow. Add a type to the variant and any visitor without an overload for it stops compiling: both compilers rejected Area once a Triangle was added. Add an operation to a class hierarchy as a pure virtual function and any derived class without an implementation becomes abstract. So a variant makes new operations cheap (write another visitor) and new types expensive, and a hierarchy is the reverse.

SituationBetter fit
Open set of types; plugins or code you do not control add new onesClass hierarchy with virtual functions
Closed set of types, many operations over themstd::variant and std::visit
Same algorithm for any type with the right operationsTemplates: compile-time polymorphism; see C++ templates
Reusing behavior, not substituting one type for anotherComposition: a member object instead of a base class
A function that needs no stateA free function; see functions in C++

Key Takeaways

  • C++ supports OOP without requiring it. Classes, virtual functions and inheritance sit alongside free functions, templates and value types.
  • Encapsulation exists to protect invariants. EBook::go_to(999) threw and the book stayed on page 42, because the page can only change through checked member functions.
  • Polymorphism needs a reference or a pointer. Passing a Savings by value printed account, and neither compiler warned.
  • Mark overrides with override. A missing const produced a function that overrode nothing; override made it a compile error.
  • Polymorphic base classes need virtual destructors, especially when objects are owned through std::unique_ptr<Base>.
  • Overload operators only with their usual meaning. An overloaded && evaluated both operands where the built-in one evaluated one.
  • A class hierarchy is one option among several. std::variant, templates and composition fit many problems better.

Frequently Asked Questions

Conclusion

Object-oriented C++ is less about the four pillars as definitions and more about a handful of decisions each class makes: which data it protects and what invariant that data must keep, whether it is meant to be a base class, whether it is passed around by value or through references, and who owns it. Get those right and the language features follow; get them wrong and the program still compiles, as the slicing and override examples show.

The deeper guides linked above take each idea further, and the C++ programming tutorials are collected in the C++ section.

Source Code and Tests

object-orientation

All programs on this page are in the oop/object-orientation directory of the MYCPLUS C++ examples repository.

Build and test:

cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
bash tests/run_tests.sh g++ c++17
bash tests/sanitizers.sh g++

What the build checks. On Linux, with GCC and Clang under C++17 and C++20, it builds every program with -Wall -Wextra -pedantic -Werror, runs it and compares its output with this page; checks that missing_override.cpp still produces the -Woverloaded-virtual warning quoted here; and checks that the four compile-fail examples are rejected with the expected error (an abstract class instantiated, a private member read, a non-overriding override, and a visitor missing a case). A sanitizer job confirms that every program runs clean under AddressSanitizer and UndefinedBehaviorSanitizer. A macOS job runs the same tests with Apple Clang, and a Windows job builds every program with MSVC at /W4 /WX and compares the output. The statement that the four non-overloadable operators are rejected was checked locally and is not part of the build.

Scroll to Top