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:
| Idea | What it means | How C++ expresses it | Deeper guide |
|---|---|---|---|
| Abstraction | Expose what an object does, hide how | Abstract classes with pure virtual functions (= 0) | Virtual functions |
| Encapsulation | Keep data valid by controlling access to it | private and protected members, member functions | Encapsulation |
| Inheritance | Define a new type as a refinement of an existing one | class EBook : public Document | Covered below; inheritance in C++ |
| Polymorphism | One call, behavior chosen by the object’s type | virtual functions called through Base& or Base*; templates at compile time | Polymorphism |
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++:
| Aspect | C++ | Java | Python |
|---|---|---|---|
| Variables of class type | Hold the object itself (value semantics) | Hold a reference | Hold a reference |
| Overriding | Only virtual functions can be overridden | Methods are overridable unless final | All methods are overridable |
| Common base class | None | java.lang.Object | object |
| Multiple inheritance of classes | Yes | No (interfaces only) | Yes |
| Object destruction | Deterministic: at end of scope or delete | Garbage collected | Reference counting plus a cycle collector (CPython) |
| Default member access | private in a class, public in a struct | Package-private | Public (by convention, _name signals private) |
| Copying an object | Copies the whole value, unless you pass a reference | Copies the reference | Copies 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
- Abstraction.
Documentdeclareskind()andprogress()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.
EBookkeepspages_andcurrent_private, and every way of changing the page checks the range. The program asked for page 999 of 320;go_tothrew, and the book stayed on page 42. Ifcurrent_were public, nothing would stop a caller from setting it to 999 and the nextprogress()would report nonsense. The constructor also refuses a book with no pages, so an invalidEBookcannot exist at all. - Inheritance.
EBookandAudioBookare bothDocuments: they inherittitle()and must providekind()andprogress(). Thevirtual ~Document()matters here, because thestd::unique_ptr<Document>objects inshelfdeleteEBookandAudioBookobjects through aDocument*; without it, that deletion is undefined behavior, and in practice the derived destructors are skipped. - Polymorphism.
print_shelfknows only aboutDocument. The samedoc->progress()call printedpage 42 of 320for one object and95 of 540 minutesfor the other, becauseprogress()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:
| Access | Visible to | Typical use |
|---|---|---|
public | Everyone | The interface: member functions callers rely on |
protected | The class and classes derived from it | Hooks for derived classes; use sparingly |
private | The class and its friends | Data 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++:
- 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. - Give a polymorphic base class a virtual destructor, or make its destructor
protectedand non-virtual so it cannot be deleted through a base pointer. - 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.
| Situation | Better fit |
|---|---|
| Open set of types; plugins or code you do not control add new ones | Class hierarchy with virtual functions |
| Closed set of types, many operations over them | std::variant and std::visit |
| Same algorithm for any type with the right operations | Templates: compile-time polymorphism; see C++ templates |
| Reusing behavior, not substituting one type for another | Composition: a member object instead of a base class |
| A function that needs no state | A 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
Savingsby value printedaccount, and neither compiler warned. - Mark overrides with
override. A missingconstproduced a function that overrode nothing;overridemade 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
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.


