Skip to content
elephantoo

Classes & objects

Lesson 19 of 38 13 min read

Define classes, __init__ and self, instance vs class attributes, __repr__/__str__ and encapsulation.


So far you've stored data in variables, lists and dicts, and logic in functions. Object-oriented programming (OOP) bundles the two: a class defines a new type with its own data (attributes) and behaviour (methods). You've been using objects all along — every string, list and file is one. Now you'll create your own.

Your first class#

Python
class Dog:
    def __init__(self, name, age):
        self.name = name      # instance attributes
        self.age = age

    def bark(self):
        return f"{self.name} says woof!"

    def birthday(self):
        self.age += 1


rex = Dog("Rex", 3)            # create an instance
luna = Dog("Luna", 5)

print(rex.bark())
rex.birthday()
print(rex.name, rex.age)
print(luna.name, luna.age)
print(type(rex), isinstance(rex, Dog))
Output
Rex says woof!
Rex 4
Luna 5
<class '__main__.Dog'> True

The vocabulary:

  • A class (Dog) is a blueprint. By convention its name is in PascalCase.
  • An instance or object (rex, luna) is a concrete thing built from the blueprint. Each has its own attribute values.
  • __init__ is the initialiser. Calling Dog("Rex", 3) creates a new object and then runs __init__ on it with those arguments.
  • A method is a function defined inside a class. Calling rex.bark() automatically passes rex as the first argument, conventionally named self.

rex.bark() is just a convenient spelling of Dog.bark(rex). That's all self is — the object the method was called on.

Why bother with classes?#

Compare a dict-based bank account:

Python
account = {"owner": "Ada", "balance": 0}
account["balance"] += 100
account["balanse"] = 50        # typo creates a new key — no error!
print(account)
Output
{'owner': 'Ada', 'balance': 100, 'balanse': 50}

Any code can change the data in any way, and the rules ("balance can't go negative") live nowhere in particular. A class keeps data and the rules that protect it together:

Python
class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance
        self.transactions = []

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("deposit must be positive")
        self.balance += amount
        self.transactions.append(("deposit", amount))

    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError("insufficient funds")
        self.balance -= amount
        self.transactions.append(("withdraw", amount))

    def statement(self):
        lines = [f"Statement for {self.owner}"]
        for kind, amount in self.transactions:
            lines.append(f"  {kind:<9}{amount:>8,}")
        lines.append(f"  {'balance':<9}{self.balance:>8,}")
        return "\n".join(lines)


acct = BankAccount("Ada")
acct.deposit(5000)
acct.withdraw(1200)
print(acct.statement())

try:
    acct.withdraw(10_000)
except ValueError as e:
    print("Refused:", e)
Output
Statement for Ada
  deposit     5,000
  withdraw    1,200
  balance     3,800
Refused: insufficient funds

This is encapsulation: the object owns its state, and the methods are the sanctioned way to change it.

Instance attributes vs class attributes#

Attributes set on self belong to one instance. Attributes defined directly in the class body are class attributes, shared by all instances:

Python
class Circle:
    pi = 3.14159              # class attribute: shared
    count = 0

    def __init__(self, radius):
        self.radius = radius  # instance attribute: per object
        Circle.count += 1

    def area(self):
        return self.pi * self.radius ** 2


a = Circle(1)
b = Circle(2)
print(a.area(), b.area())
print(Circle.count, a.count)
Output
3.14159 12.56636
2 2

When you read self.pi, Python looks on the instance first, then on the class. Class attributes are great for constants and counters — but never use a mutable class attribute for per-instance data:

Python
class Basket:
    items = []                 # shared by ALL baskets — a bug

    def add(self, item):
        self.items.append(item)


b1, b2 = Basket(), Basket()
b1.add("apple")
print(b2.items)               # b2 sees b1's apple!
Output
['apple']

The fix: create the list in __init__ with self.items = [].

Printing objects nicely#

By default, printing an object shows something unhelpful like <__main__.Dog object at 0x7f...>. Define __repr__ (for developers) and optionally __str__ (for end users):

Python
class Product:
    def __init__(self, name, price):
        self.name = name
        self.price = price

    def __repr__(self):
        return f"Product(name={self.name!r}, price={self.price!r})"

    def __str__(self):
        return f"{self.name} — ₹{self.price:,}"


p = Product("Laptop", 55000)
print(p)            # uses __str__
print(repr(p))      # uses __repr__
print([p])          # containers use __repr__
Output
Laptop — ₹55,000
Product(name='Laptop', price=55000)
[Product(name='Laptop', price=55000)]

A good __repr__ looks like the code that would recreate the object. These double-underscore "dunder" methods are the subject of a whole lesson soon.

"Private" attributes by convention#

Python has no private keyword. Instead, a leading underscore says "internal — don't touch from outside":

Python
class Thermostat:
    def __init__(self, target):
        self._target = target          # internal by convention

    def set_target(self, value):
        if not 10 <= value <= 30:
            raise ValueError("target must be between 10 and 30")
        self._target = value

    def describe(self):
        return f"heating to {self._target}°C"


t = Thermostat(21)
t.set_target(23)
print(t.describe())
Output
heating to 23°C

Python trusts developers — "we're all consenting adults". Code can still access t._target, but doing so is clearly at your own risk. (Two underscores, __target, trigger name mangling to avoid clashes in subclasses; it's rarely needed.) In the properties lesson you'll see the Pythonic way to add validation without getter/setter methods.

Objects interacting#

Real programs consist of objects that hold and use other objects — called composition:

Python
class Item:
    def __init__(self, name, price, qty=1):
        self.name, self.price, self.qty = name, price, qty

    def total(self):
        return self.price * self.qty


class Cart:
    def __init__(self):
        self.items = []

    def add(self, item):
        self.items.append(item)
        return self          # returning self allows chaining

    def total(self):
        return sum(item.total() for item in self.items)

    def __len__(self):
        return sum(item.qty for item in self.items)


cart = Cart().add(Item("Pen", 25, 4)).add(Item("Notebook", 120, 2))
print(len(cart), "items, total", cart.total())
Output
6 items, total 340

Common mistakes#

  • Forgetting self in a method definition: def bark(): raises TypeError: bark() takes 0 positional arguments but 1 was given.
  • Forgetting self. when using an attribute: balance += amount inside a method refers to a local variable, not the attribute.
  • Mutable class attributes used as per-instance state.
  • Calling the class without its arguments: Dog() raises TypeError if __init__ requires name and age.
  • Making everything a class. If it's just data, a dict or dataclass might do; if it's just behaviour, a function is fine.

What's next#

Classes can build on other classes. Next: inheritance and polymorphism — reusing and specialising behaviour.

Check your understanding

Quick quiz

0/3 answered
  1. 1.What is self in a method?

  2. 2.What does __init__ do?

  3. 3.A class attribute items = [] is defined directly in the class body. What happens when one instance appends to self.items?

Finished reading?

Mark this lesson complete to track your progress.