Object-Oriented Programming: Concepts, Pillars and Techniques in C++, Java and Python

How OOP differs from procedural code, what its four pillars mean, and the techniques that keep object-oriented code easy to change, in three languages.

Four different freight cars, a box car, a tank car, a flat car and a hopper, coupled on one track, representing different objects working together through a shared interface.

A shop calculates shipping for standard, express and pickup orders. Written procedurally, that is one function with an if for each option. It works until someone adds a courier option, and every function that checks the shipping type has to be found and edited. Written in an object-oriented style, each option is an object that knows its own cost, and adding a courier means adding one class. Nothing that already works has to change. That difference, in where a change lands, is most of what object-oriented programming (OOP) is for.

This guide is the overview of the object-oriented programming series. It explains what OOP is, how it differs from procedural programming, and what its core concepts mean, with one example in C++, Java and Python that uses all of them. It then covers the techniques that make object-oriented code easy to change and the cases where OOP is the wrong tool. Every program was run for this article on Ubuntu 24.04 with GCC 13.3 and Clang 18.1.3 (-std=c++17 -Wall -Wextra -pedantic -Werror), OpenJDK 21 (javac -Xlint:all -Werror) and Python 3.11, 3.12 and 3.13 (-W error), and every output below is captured verbatim. The code is in a GitHub repository whose build repeats these checks on each commit.

The Short Answer

What is object-oriented programming? A way of structuring programs around objects, each combining data with the operations that act on it. Objects are created from classes and work together by calling each other’s methods, instead of passing shared data through a sequence of functions.

What are the four pillars of OOP? Encapsulation (an object controls changes to its own data), abstraction (code depends on what an object does, not how), inheritance (a class builds on an existing one) and polymorphism (one call runs different code for different types of object).

Are C++, Java and Python object-oriented? All three support OOP fully, in different ways. Java builds every program from classes. Python makes every value an object but doesn’t require classes. C++ supports OOP alongside procedural and generic programming, and uses it only where you choose to.

What Is Object-Oriented Programming?

Object-oriented programming is a programming paradigm that organizes software as a collection of objects. Each object bundles state (its data) with behavior (the methods that read and change that data), and is created from a class that defines both. Programs work by objects calling each other’s methods, and each object is responsible for keeping its own data valid.

The idea is older than any language in this guide. Ole-Johan Dahl and Kristen Nygaard introduced classes, objects, subclasses and virtual procedures in Simula 67, a language for simulation. Alan Kay’s group at Xerox PARC developed Smalltalk through the 1970s, and Kay is generally credited with coining the term “object-oriented”. Bjarne Stroustrup brought classes to C as “C with Classes” from 1979, released as C++ in 1985. Python followed in 1991 and Java in 1995, and OOP became the default way to structure large programs.

Two basic building blocks come first:

  • A class is a definition: the data each object will hold and the methods that operate on it.
  • An object is an instance of a class, created at run time, with its own state and identity.

Procedural vs Object-Oriented Programming

Procedural programming, the dominant style of C, Pascal and Fortran, organizes a program as a sequence of steps grouped into functions. The functions are the main unit, and the data they work on is passed between them or shared. This works well for small programs. As a program grows, more functions come to depend on the same data and the same decisions, and a change to either has to be made in many places.

Here is the shipping calculation written procedurally:

"""shipping_procedural.py - the procedural version: one function decides
the cost by checking which kind of shipping was chosen."""


def shipping_cost_cents(kind: str, kg: int) -> int:
    if kind == "standard":
        return 400 + 50 * kg
    if kind == "express":
        return 1000 + 100 * kg
    if kind == "pickup":
        return 0
    raise ValueError(f"unknown shipping kind: {kind}")


def money(cents: int) -> str:
    return f"{cents // 100}.{cents % 100:02d}"


for kind in ("standard", "express", "pickup"):
    print(f"{kind:<8} 2 kg: {money(shipping_cost_cents(kind, 2))}")

Output:

standard 2 kg: 5.00
express  2 kg: 12.00
pickup   2 kg: 0.00

The function is short and correct. The problem is what happens next. The decision “which kind of shipping is this?” is made by string comparison inside the function, and any other function that depends on the kind (an estimated delivery date, a label format, a tracking link) has to make the same decision again. Adding a courier option means finding and editing every one of them.

AspectProceduralObject-oriented
Main unitFunctionsObjects (data plus methods)
DataPassed between functions, or sharedOwned by the object, changed through its methods
Choosing behavior by typeif or switch on a type codeThe object’s own method runs
Adding a new typeEdit every function that checks the typeAdd a class
Adding a new operationAdd one functionAdd a method to every class

The last two rows are the real trade-off, sometimes called the expression problem (Philip Wadler’s name for it, from 1998). Object-oriented design makes new types cheap and new operations expensive; procedural design does the opposite. OOP fits a program whose set of types grows more often than its set of operations.

One Example, Four Pillars

Here is the same calculation written with objects. Each shipping method is an object with a name() and a cost_cents() method; quote() works with any of them; and a courier option has been added as a new class:

C++:

// shipping.cpp - the object-oriented version: each shipping method is an
// object that knows its own cost, and quote() works with any of them.
#include <iomanip>
#include <iostream>
#include <memory>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>

class ShippingMethod {  // abstraction: what every shipping method offers
public:
    virtual ~ShippingMethod() = default;
    virtual std::string name() const = 0;
    virtual long cost_cents(int kg) const = 0;
};

class RateShipping : public ShippingMethod {  // inheritance: one implementation
public:
    RateShipping(std::string name, long base_cents, long per_kg_cents)
        : name_(std::move(name)), base_(base_cents), per_kg_(per_kg_cents) {
        if (base_ < 0 || per_kg_ < 0) throw std::invalid_argument("rates must not be negative");
    }
    std::string name() const override { return name_; }
    long cost_cents(int kg) const override { return base_ + per_kg_ * kg; }

private:  // encapsulation: the rates are checked once and can't change afterwards
    std::string name_;
    long base_;
    long per_kg_;
};

class Pickup : public ShippingMethod {
public:
    std::string name() const override { return "pickup"; }
    long cost_cents(int) const override { return 0; }
};

class Courier : public ShippingMethod {  // added later: quote() did not change
public:
    std::string name() const override { return "courier"; }
    long cost_cents(int) const override { return 750; }
};

std::string money(long cents) {
    std::string fraction = std::to_string(cents % 100);
    return std::to_string(cents / 100) + "." + (fraction.size() < 2 ? "0" : "") + fraction;
}

void quote(const ShippingMethod& method, int kg) {  // polymorphism: any method works
    std::cout << std::left << std::setw(8) << method.name() << ' ' << kg
              << " kg: " << money(method.cost_cents(kg)) << '\n';
}

int main() {
    std::vector<std::unique_ptr<ShippingMethod>> methods;
    methods.push_back(std::make_unique<RateShipping>("standard", 400, 50));
    methods.push_back(std::make_unique<RateShipping>("express", 1000, 100));
    methods.push_back(std::make_unique<Pickup>());
    methods.push_back(std::make_unique<Courier>());

    for (const auto& method : methods) quote(*method, 2);
}

Java:

// Shipping.java - the object-oriented version: each shipping method is an
// object that knows its own cost, and quote() works with any of them.
import java.util.List;

public final class Shipping {
    interface ShippingMethod {  // abstraction: what every shipping method offers
        String name();
        long costCents(int kg);
    }

    static final class RateShipping implements ShippingMethod {  // inheritance: one implementation
        private final String name;  // encapsulation: the rates are checked once and can't change afterwards
        private final long base;
        private final long perKg;

        RateShipping(String name, long baseCents, long perKgCents) {
            if (baseCents < 0 || perKgCents < 0) throw new IllegalArgumentException("rates must not be negative");
            this.name = name;
            this.base = baseCents;
            this.perKg = perKgCents;
        }
        @Override public String name() { return name; }
        @Override public long costCents(int kg) { return base + perKg * kg; }
    }

    static final class Pickup implements ShippingMethod {
        @Override public String name() { return "pickup"; }
        @Override public long costCents(int kg) { return 0; }
    }

    static final class Courier implements ShippingMethod {  // added later: quote() did not change
        @Override public String name() { return "courier"; }
        @Override public long costCents(int kg) { return 750; }
    }

    static String money(long cents) {
        return String.format("%d.%02d", cents / 100, cents % 100);
    }

    static void quote(ShippingMethod method, int kg) {  // polymorphism: any method works
        System.out.printf("%-8s %d kg: %s%n", method.name(), kg, money(method.costCents(kg)));
    }

    public static void main(String[] args) {
        List<ShippingMethod> methods = List.of(
            new RateShipping("standard", 400, 50),
            new RateShipping("express", 1000, 100),
            new Pickup(),
            new Courier());

        for (ShippingMethod method : methods) quote(method, 2);
    }
}

Python:

"""shipping.py - the object-oriented version: each shipping method is an
object that knows its own cost, and quote() works with any of them."""
from abc import ABC, abstractmethod


class ShippingMethod(ABC):  # abstraction: what every shipping method offers
    @abstractmethod
    def name(self) -> str: ...

    @abstractmethod
    def cost_cents(self, kg: int) -> int: ...


class RateShipping(ShippingMethod):  # inheritance: one implementation
    def __init__(self, name: str, base_cents: int, per_kg_cents: int) -> None:
        if base_cents < 0 or per_kg_cents < 0:
            raise ValueError("rates must not be negative")
        self._name = name  # encapsulation: internal by convention, set once here
        self._base = base_cents
        self._per_kg = per_kg_cents

    def name(self) -> str:
        return self._name

    def cost_cents(self, kg: int) -> int:
        return self._base + self._per_kg * kg


class Pickup(ShippingMethod):
    def name(self) -> str:
        return "pickup"

    def cost_cents(self, kg: int) -> int:
        return 0


class Courier(ShippingMethod):  # added later: quote() did not change
    def name(self) -> str:
        return "courier"

    def cost_cents(self, kg: int) -> int:
        return 750


def money(cents: int) -> str:
    return f"{cents // 100}.{cents % 100:02d}"


def quote(method: ShippingMethod, kg: int) -> None:  # polymorphism: any method works
    print(f"{method.name():<8} {kg} kg: {money(method.cost_cents(kg))}")


methods: list[ShippingMethod] = [
    RateShipping("standard", 400, 50),
    RateShipping("express", 1000, 100),
    Pickup(),
    Courier(),
]
for method in methods:
    quote(method, 2)

Output (identical for all three):

standard 2 kg: 5.00
express  2 kg: 12.00
pickup   2 kg: 0.00
courier  2 kg: 7.50

The first three lines match the procedural version exactly. The fourth comes from Courier, which was added without changing quote() or any existing class. Costs are kept in integer cents, so no floating-point rounding can creep into a price.

Adding a courier option: what has to change shipping_procedural.py compared with shipping.cpp, Shipping.java and shipping.py Procedural: edit the function def shipping_cost_cents(kind, kg): if kind == "standard": ... if kind == "express": ... if kind == "pickup": ... if kind == "courier": return 750 raise ValueError(...) edited: a function that already worked Every other function that checks kind needs the same edit. Object-oriented: add a class ShippingMethod name(), cost_cents(kg) RateShipping standard, express Pickup Courier new class quote(method, kg) unchanged Existing classes and quote() are not touched; the new case is one new class. Both designs print the same three costs for 2 kg; the object-oriented one adds the courier: standard 5.00 express 12.00 pickup 0.00 + courier 7.50 The trade-off: adding an operation instead of a type is easier in the procedural design.
A new shipping option edits working code in one design and adds a class in the other. Both versions print the same three costs; the object-oriented one takes the courier without touching quote() or any existing class.

Every core concept of OOP appears in this one program:

ConceptWhat it meansIn the shipping exampleFull guide
ClassA definition of data and methodsRateShipping defines a name, two rates and how to compute a costClasses
ObjectAn instance of a class with its own statestandard and express are two objects of one class, with different ratesObjects
EncapsulationAn object controls changes to its own dataThe rates are checked in the constructor and can’t be changed from outside (in Python, by convention only)Encapsulation
AbstractionCode depends on what an object does, not howShippingMethod declares name() and cost_cents(), with no implementationAbstraction
InheritanceA class builds on an existing class or interfaceRateShipping, Pickup and Courier all implement ShippingMethod (an interface in Java, an abstract base class in C++ and Python)Inheritance
PolymorphismOne call runs different code for different typesquote() calls cost_cents(), and each class runs its own versionPolymorphism

The guides go in the order of the table, and each builds on the one before it, using C++, Java and Python side by side.

Object-Oriented Programming Techniques

Knowing the four pillars is not the same as writing object-oriented code that is easy to change. These techniques are what experienced developers mean by good OOP, and each one is visible in the shipping example.

  1. Program to an interface, not an implementation. quote() takes a ShippingMethod, not a RateShipping, so it works with classes that did not exist when it was written. The phrase comes from the “Gang of Four” book Design Patterns (Gamma, Helm, Johnson and Vlissides, 1994).
  2. Replace type checks with polymorphism. The procedural version’s chain of if kind == ... became one method call. Martin Fowler’s Refactoring lists this as “Replace Conditional with Polymorphism”. A long if/switch on a type code is often a sign that a class hierarchy is missing.
  3. Protect invariants in the class that owns the data. RateShipping rejects negative rates once, in its constructor, so no method has to check them again. That is the practical meaning of encapsulation.
  4. Favor composition over inheritance. Design Patterns states this one directly: “Favor object composition over class inheritance.” Standard and express shipping are two objects of one class with different rates, not two subclasses. Inheritance is reserved for real “is-a” relationships, where the code that uses the base type must work with every subclass.
  5. Keep classes small and focused. Each shipping class does one thing. A class that computes costs, prints receipts and talks to a database has three reasons to change, and changes to one put the others at risk.
  6. Pass dependencies in. quote() receives the shipping method instead of creating it, so a caller (or a test) can supply any implementation. The abstraction guide shows the same technique with a clock that a test can fix at 8:00.

SOLID Principles

Robert C. Martin collected five design principles for object-oriented code, known by the acronym SOLID. Each is a more precise statement of one of the techniques above:

PrincipleIn one sentenceTechnique above
Single responsibilityA class should have one reason to change5
Open/closedExtend behavior by adding code, not by editing working code1, 2 (adding Courier)
Liskov substitutionA subclass must work anywhere its base class is expected4
Interface segregationClients shouldn’t depend on methods they don’t use1
Dependency inversionDepend on abstractions, and pass concrete objects in1, 6

The principles are guidelines, not laws. Applied mechanically, they produce many small interfaces with a single implementation each, which is harder to read than the code they replaced.

Design Patterns

A design pattern is a named, reusable solution to a common design problem. Design Patterns catalogued 23; a handful cover most everyday object-oriented code:

PatternProblem it solvesWhere it appears in this series
StrategyChoose one of several interchangeable algorithms at run timeThe shipping methods on this page
Template methodFix the steps of an operation, let subclasses change one stepsend() and format() in the inheritance guide
FactoryCreate objects without the caller naming their concrete classA function that returns a ShippingMethod for a name read from a config file
ObserverNotify many objects when one object changesEvent listeners and GUI callbacks
AdapterMake an existing class fit an interface it wasn’t written forWrapping a third-party shipping API as a ShippingMethod

Singleton, a class with exactly one global instance, is also in the catalog, but it behaves like a global variable: it hides dependencies and makes code hard to test. Passing the one instance in (technique 6) usually works better.

OOP in C++, Java and Python Compared

All three languages implement the same concepts, with different defaults:

AspectC++JavaPython
Is everything an object?No. int and free functions are notAlmost: primitives such as int are not, and all code lives in classesYes. Integers, functions and classes are all objects
Assignment b = aCopies the objectCopies a referenceBinds a second name to the object
Runtime polymorphismOpt-in with virtualDefault for instance methodsEvery method call
InterfacesAbstract classes with pure virtual functionsinterfaceAbstract base classes, or typing.Protocol
Multiple inheritanceSupported for classesClasses no; a class can implement many interfacesSupported, with a defined method resolution order
Access controlpublic, protected, private, checked at compile timeAdds package-private, checked at compile timeConventions (_name); not enforced
Object cleanupDeterministic: destructors run at end of scopeGarbage collected; try-with-resources for resourcesReference counting plus a cycle collector (CPython); with for resources

For C++ in depth, see object-oriented programming in C++, inheritance in C++, multiple inheritance in C++ and virtual functions in C++.

When Not to Use OOP

OOP is one way to organize a program, and it isn’t always the right one:

  • Small scripts and data pipelines. A fifty-line script that reads a file and prints a summary gains nothing from classes. Functions are clearer.
  • New operations more often than new types. A compiler adds passes over a fixed set of syntax-tree node types far more often than it adds node types. That is the procedural side of the trade-off in the table above. In C++, std::variant with std::visit, or a visitor, often fits better than virtual functions, as shown in object-oriented programming in C++.
  • Performance-critical data processing. Large arrays of small objects, each reached through a pointer and a virtual call, use the CPU cache poorly. Game engines and simulations often switch to data-oriented design, storing each field in its own contiguous array.
  • Deep inheritance hierarchies. If understanding a class means reading five base classes, composition would probably serve better.

Most real programs mix styles. The standard libraries of all three languages use objects for containers and resources, plain functions for algorithms, and generics or templates for code that works across types.

Key Takeaways

  • OOP organizes a program around objects that combine data with the methods that change it.
  • Its core concepts are classes and objects, plus four pillars: encapsulation, abstraction, inheritance and polymorphism. One short program, about 60 lines in each language, used all six.
  • The main trade-off: object-oriented design makes new types cheap (the courier was one class) and new operations expensive. Procedural design does the opposite.
  • Good OOP is about techniques, not features: program to interfaces, replace type checks with polymorphism, protect invariants, and prefer composition to inheritance.
  • C++, Java and Python share the concepts but not the defaults: copying versus sharing on assignment, opt-in versus automatic virtual dispatch, and enforced versus conventional access control.

Conclusion

Object-oriented programming is a way of deciding where code lives. Data goes with the methods that keep it valid, decisions about type go into the types themselves, and new behavior arrives as new classes instead of edits to working code. It doesn’t suit every program, and its trade-off is real, but for systems whose set of types keeps growing it keeps changes local.

The rest of this series takes each concept in turn, in C++, Java and Python. Start with classes and objects, then encapsulation, abstraction, inheritance and polymorphism. Everything in the series is collected in the OOP section.

Source Code and Tests

oop-overview

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

Build and test:

bash tests/run_tests.sh g++

What the build checks. On Linux, with GCC and with Clang, it compiles the C++ program with -Wall -Wextra -pedantic -Werror, compiles the Java program with javac -Xlint:all -Werror on Java 21, runs the Python programs with -W error on Python 3.12, and checks that every program prints exactly the output shown on this page. The object-oriented version prints the same in all three languages. Python 3.11 and 3.13 were checked locally and produced the same output.

Scroll to Top