Skip to main content
PProfileMatch

Object-Oriented Programming: Classes, Objects and Encapsulation

ProgrammingLesson 1 of 45 min read

What you'll learn

  • Explain the difference between a class and an object, and what state and behaviour mean
  • Use encapsulation to make invalid states unreachable instead of hoping callers behave
  • Write a Python class with validation, a read-only property and a useful __repr__
  • Tell instance attributes, class attributes, class methods and static methods apart, and avoid the shared-state trap

On this page

  1. Classes bundle data with the rules for changing it
  2. Encapsulation: make the invalid state unreachable
  3. Things that belong to the class, not the object
  4. Common mistakes
  5. How this shows up in real projects and interviews

Here is a prepaid metro card with a fare system built on top of it. One line of code elsewhere in the app sets card.balance = -500, another subtracts a fare, and the card now reports a balance of -525. No error, no crash, just a number that makes no sense. Nothing in this class can stop the mistake, and that gap is exactly what object-oriented programming was invented to close.

metro_naive.py
class MetroCard:
    def __init__(self, card_id, balance):
        self.card_id = card_id
        self.balance = balance


card = MetroCard("MC-204", 40)
card.balance = -500          # nothing stops this assignment
card.balance -= 25           # and now the card is "overdrawn" by 525
print(card.card_id, card.balance)

Output

MC-204 -525

Classes bundle data with the rules for changing it

A class is a blueprint: it says which data every card has and which operations are allowed on it. An object (or instance) is one actual card built from that blueprint, with its own data. The data an object holds is its state; the operations it offers are its behaviour. The point of putting both in one place is that the rules about the data travel with the data.

The class above has state but no rules, so it is really just a dictionary with a nicer name. The useful question to ask of any class is: what must always be true about an object of this class? That statement is its invariant. For a metro card it is "the balance is never negative." A well-designed class makes it impossible for outside code to break the invariant, and that practice is called encapsulation.

Encapsulation: make the invalid state unreachable

The version below keeps the balance internal, exposes it read-only, and funnels every change through methods that check the rule. Python does not enforce access control with keywords. The single leading underscore in _balance is a convention meaning "internal to this class", and the @property decorator lets callers read card.balance as if it were an attribute while refusing assignment.

metro_card.py
class InsufficientBalance(Exception):
    pass


class MetroCard:
    """A prepaid card. Invariant: the balance is never negative."""

    def __init__(self, card_id, opening_balance=0):
        if opening_balance < 0:
            raise ValueError("opening balance cannot be negative")
        self.card_id = card_id
        self._balance = opening_balance     # leading underscore: "internal, please do not touch"
        self._trips = []                    # one fresh list per card

    @property
    def balance(self):                      # read-only view of the internal state
        return self._balance

    def top_up(self, amount):
        if amount <= 0:
            raise ValueError("top-up must be positive")
        self._balance += amount

    def tap_in(self, station, fare):
        if fare > self._balance:
            raise InsufficientBalance(
                f"{self.card_id} has {self._balance}, fare is {fare}")
        self._balance -= fare
        self._trips.append((station, fare))

    def trip_count(self):
        return len(self._trips)

    def __repr__(self):
        return f"MetroCard({self.card_id!r}, balance={self._balance})"


card = MetroCard("MC-204", 40)
card.tap_in("Central", 25)
card.top_up(100)
print(card, card.trip_count())

for action in (
    lambda: setattr(card, "balance", 1_000_000),
    lambda: card.top_up(-5),
    lambda: card.tap_in("Airport", 500),
):
    try:
        action()
    except (AttributeError, ValueError, InsufficientBalance) as err:
        print(type(err).__name__ + ":", err)

Output

MetroCard('MC-204', balance=115) 1
AttributeError: property 'balance' of 'MetroCard' object has no setter
ValueError: top-up must be positive
InsufficientBalance: MC-204 has 115, fare is 500

Look at the last three lines of output. Assigning to balance fails because the property has no setter, a negative top-up is rejected by the method that owns the rule, and an unaffordable tap raises a domain-specific exception that carries the card id and amounts. Every path that could produce a negative balance now ends in an exception at the point of the mistake, not in a wrong number discovered later.

Other languages draw the line differently, but the idea is identical.

LanguageHow you hide internalsEnforced by
Python_name by convention, @property for controlled access, __name triggers name manglingConvention and discipline (name mangling only avoids accidental clashes)
Javaprivate fields with public methodsThe compiler
C++private: and protected: sectionsThe compiler

Things that belong to the class, not the object

Not every piece of data belongs to a single card. A class attribute is shared by all instances, which suits constants such as a minimum top-up. A class method (@classmethod) receives the class itself, which makes it the natural home for alternative constructors such as building a card from a CSV row. A static method receives neither the instance nor the class and is just a function that lives in the class's namespace because it relates to it.

class_methods.py
class MetroCard:
    MIN_TOP_UP = 50                              # shared constant: belongs to the class

    def __init__(self, card_id, balance=0):
        self.card_id = card_id
        self.balance = balance

    @classmethod
    def from_csv_row(cls, row):                  # alternative constructor
        card_id, balance = row.split(",")
        return cls(card_id.strip(), int(balance))

    @staticmethod
    def is_valid_id(card_id):                    # no self, no cls: just a namespaced helper
        return card_id.startswith("MC-") and card_id[3:].isdigit()


card = MetroCard.from_csv_row("MC-9001, 120")
print(card.card_id, card.balance, MetroCard.MIN_TOP_UP)
print(MetroCard.is_valid_id("MC-9001"), MetroCard.is_valid_id("XX-1"))

Output

MC-9001 120 50
True False

Using cls(...) inside the class method rather than hard-coding MetroCard(...) means a subclass calling from_csv_row gets an instance of the subclass. That detail becomes important in the next lesson on inheritance.

Common mistakes

shared_state.py
class Station:
    lines = []                       # class attribute: ONE list shared by every Station

    def __init__(self, name):
        self.name = name

    def add_line(self, line):
        self.lines.append(line)      # mutates the shared list


class FixedStation:
    def __init__(self, name, lines=None):
        self.name = name
        self.lines = [] if lines is None else list(lines)   # one list per instance

    def add_line(self, line):
        self.lines.append(line)


a, b = Station("Central"), Station("Airport")
a.add_line("Red")
print("Airport lines:", b.lines)

c, d = FixedStation("Central"), FixedStation("Airport")
c.add_line("Red")
print("Airport lines:", d.lines)

Output

Airport lines: ['Red']
Airport lines: []
  • A mutable class attribute used as per-object state. In Station, lines = [] sits on the class, so a.add_line("Red") shows up on b. The symptom is data from one object appearing in another. Create the list inside __init__.
  • A mutable default argument. def __init__(self, card_id, trips=[]) creates the list once, when the function is defined, so every card without an explicit trips shares it. Use None as the default and create the list inside, as FixedStation does.
  • Getters and setters that do nothing. Writing get_balance and set_balance(x) that merely read and write the field is encapsulation in name only; the invariant is still unprotected. Expose behaviour (top_up, tap_in) rather than raw setters.
  • Returning internal collections. If a method returns self._trips directly, callers can append to it and bypass your rules. Return a tuple or a copy.
  • Forgetting `self`. Defining def tap_in(station, fare) inside a class produces TypeError: tap_in() takes 2 positional arguments but 3 were given the first time you call it on an object.
  • Misspelling `__init__`. A class with no working constructor fails with TypeError: MetroCard() takes no arguments when you pass the card id.
  • Putting validation only in the UI. Another caller, such as an import script or a test, will skip it. The class that owns the data should own the rule.

How this shows up in real projects and interviews

At work, encapsulation is less about the private keyword and more about deciding where a rule lives. Money, stock levels, booking states and permissions all have invariants, and bugs cluster wherever those invariants are enforced in several scattered places instead of one. Code reviews often come down to "why is this field public" or "why does the caller need to know to call these three methods in order".

Interviewers probe the vocabulary and the reasoning: the difference between a class and an object, what encapsulation protects, why exposing a field might be a mistake, and when @staticmethod is just a function in disguise. A good answer uses a concrete invariant, as above, rather than a definition. If you want the same ideas with compiler-enforced private fields, see Java Classes and Objects, and for the Python language basics behind these examples see the Python Fundamentals course.

Try it yourself

Add a daily spending cap to MetroCard: a card created with daily_cap=200 must refuse any tap_in that would push the total fares of the current day above the cap, raising a new DailyCapExceeded exception. Keep the invariant that the balance is never negative, and make sure there is no public way to reset the day's total except a new_day() method.

Show a hint

Store the cap and a _spent_today counter as internal state, check both rules (balance and cap) inside tap_in before changing anything so a failed tap leaves the card untouched, and write a few try/except calls like the ones at the bottom of metro_card.py to prove each rule.

Key takeaways

  • A class bundles state and behaviour; an object is one instance with its own state.
  • Encapsulation means designing so the class's invariant cannot be broken from outside, not merely hiding fields.
  • In Python, _name and @property express intent; the guarantee comes from routing every change through methods that validate.
  • Class attributes are shared by all instances, so never use a mutable one as per-object state, and avoid mutable default arguments.
  • Prefer behaviour methods such as tap_in over getters and setters, and return copies of internal collections.

Test yourself

A trick from this lesson's ride: Don't just hide the field: make the class the only gatekeeper of its own rule. Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

What is the difference between a class and an object in OOP?

A class is a blueprint that defines what data and operations something has. An object is a specific instance created from that class, holding its own data. One MetroCard class can produce thousands of card objects, each with a different balance.

What is encapsulation in simple terms?

Encapsulation means keeping an object's data internal and allowing changes only through methods that enforce the rules, so the object can never be put into an invalid state. Hiding fields is a tool for that goal, not the goal itself.

How do you make a variable private in Python?

Python has no true private variables. A single leading underscore like _balance signals that it is internal, a double underscore triggers name mangling to avoid accidental clashes, and a @property without a setter exposes a read-only value. Other code can still reach these, so the protection is by convention.