RizTech Academy logo
RizTech Academy
Object-Oriented PythonLesson 2 of 725 min

Defining a class, __init__ and self

Two pieces of syntax confuse nearly everybody at first: __init__ and self. Neither is complicated once you see what they are doing.

The smallest class

class Expense:
    pass

class, a name, a colon, an indented body. pass from module 3 fills an empty block.

Class names use CapWords — Expense, BankAccount, HttpClient. Not expense, not bank_account. This is the one place Python does not use snake_case, and following it means a reader can tell a class from a function at a glance.

Creating an object:

e = Expense()
print(e)
<__main__.Expense object at 0x7f3c8a1b2d50>

An object exists. It has nothing in it, and prints unhelpfully — both fixable.

init

class Expense:
    def __init__(self, amount, category):
        self.amount = amount
        self.category = category


lunch = Expense(250, "food")
print(lunch.amount)      # 250

__init__ runs automatically when you create an object. Its job is to set up the object's starting state.

Note what you did not write: Expense.__init__(...). Writing Expense(250, "food") creates the object and calls __init__ on it for you.

It is not a constructor in the sense other languages mean — the object already exists by the time __init__ runs. It initialises rather than creates, which is what the name says.

The double underscores mark it as special to Python. There are many such methods, covered two lessons from now.

self

self is the object the method was called on.

lunch = Expense(250, "food")

Python calls __init__ with lunch as the first argument. Inside, self is lunch, so self.amount = amount sets the amount on that particular object.

train = Expense(1200, "transport")

Now self is train. Same code, different object, separate data.

Every method takes self as its first parameter, and you never pass it — Python supplies it. This:

lunch.with_gst()

is really:

Expense.with_gst(lunch)

Both work, and the first is what anybody writes. The second occasionally shows up in a traceback and is worth recognising.

Forget self and you get a characteristic error:

class Expense:
    def describe():
        return "..."

Expense().describe()
TypeError: describe() takes 0 positional arguments but 1 was given

"But I passed none!" You did; Python passed the object. A TypeError about one extra argument on a method almost always means a missing self.

self is not a keyword. It is a convention so strong that breaking it will get your code rejected in review, but the language does not enforce it.

Attributes

Values on an object are attributes, set with self.name = value:

class Expense:
    def __init__(self, amount, category, note=""):
        self.amount = amount
        self.category = category
        self.note = note
        self.is_settled = False        # not every attribute is a parameter

is_settled is derived rather than passed. A sensible starting state belongs in __init__ just as much as the arguments do.

Reading and writing from outside:

lunch = Expense(250, "food")
print(lunch.amount)
lunch.amount = 300

Python lets you do that freely. Whether you should is the next lesson.

Methods

Functions defined in a class:

class Expense:
    def __init__(self, amount: float, category: str, note: str = ""):
        self.amount = amount
        self.category = category
        self.note = note

    def with_gst(self) -> float:
        """Return the amount including 18% GST."""
        return round(self.amount * 1.18, 2)

    def describe(self) -> str:
        """Return a one-line summary."""
        return f"{self.category}: ₹{self.amount:,.2f}"

    def apply_discount(self, percent: float) -> None:
        """Reduce the amount by a percentage."""
        if not 0 <= percent <= 100:
            raise ValueError(f"Percent must be 0-100, got {percent}")
        self.amount = round(self.amount * (1 - percent / 100), 2)

Everything from module 5 applies — type hints, docstrings, guard clauses, returning rather than printing. A method is a function that happens to live in a class and receive self.

Note the difference between with_gst, which computes and returns without changing anything, and apply_discount, which modifies the object and returns None. That is the same in-place-versus-new distinction from module 4, and it is worth being deliberate about.

Class attributes

A value on the class rather than on each object:

class Expense:
    GST_RATE = 0.18
    VALID_CATEGORIES = {"food", "transport", "rent", "other"}

    def __init__(self, amount, category):
        if category not in self.VALID_CATEGORIES:
            raise ValueError(f"Unknown category: {category!r}")
        self.amount = amount
        self.category = category

    def with_gst(self):
        return round(self.amount * (1 + self.GST_RATE), 2)

GST_RATE is shared by every instance. Accessible as self.GST_RATE or Expense.GST_RATE. Ideal for constants that belong to the concept rather than to one object.

The trap

A mutable class attribute is shared, and this is module 5's default argument problem wearing new clothes:

class Basket:
    items = []              # one list, shared by every basket

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


a = Basket()
b = Basket()
a.add("apple")
print(b.items)
['apple']

Two baskets, one list. Put mutable state in __init__ instead:

class Basket:
    def __init__(self):
        self.items = []        # a new list per object

Class attributes for constants. Instance attributes for anything that changes. The same rule as basket=None in module 5, and the same underlying cause.

Putting it together

class BankAccount:
    """A bank account that cannot go overdrawn."""

    def __init__(self, owner: str, balance: float = 0.0):
        if balance < 0:
            raise ValueError("Opening balance cannot be negative")
        self.owner = owner
        self.balance = balance
        self.transactions: list[str] = []

    def deposit(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("Deposit must be positive")
        self.balance += amount
        self.transactions.append(f"Deposited {amount}")

    def withdraw(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("Withdrawal must be positive")
        if amount > self.balance:
            raise ValueError(f"Insufficient funds: balance is {self.balance}")
        self.balance -= amount
        self.transactions.append(f"Withdrew {amount}")

Every rule from the invariants exercise in the last lesson is enforced in one place, and no sequence of valid method calls can produce a negative balance.

Check your work

The missing self.

TypeError: describe() takes 0 positional arguments but 1 was given

"But I passed none!" You did — Python passed the object. A TypeError about one extra argument on a method almost always means a missing self.

Class attributes are shared. Changing Expense.GST_RATE changes it for every instance, because there is one value on the class rather than one per object.

The Basket trap.

class Basket:
    items = []           # one list, shared by every basket

a, b = Basket(), Basket()
a.add("apple")
print(b.items)           # ['apple']

This is module 5's mutable default in new clothes. Put mutable state in __init__:

def __init__(self):
    self.items = []      # a new list per object

Class attributes for constants. Instance attributes for anything that changes.

Making BankAccount go negative. You cannot, through its methods — every route is guarded. You can by assigning account.balance = -999 directly, which is the next lesson's subject.

A derived attribute.

def __init__(self, amount, category):
    ...
    self.created_at = datetime.now()

Not every attribute is a parameter. A sensible starting state belongs in __init__ just as much as the arguments do.

Practice

  1. Write an Expense class with __init__ taking amount, category and an optional note. Create two and print their attributes.
  2. Leave self out of a method. Read the TypeError and explain it.
  3. Add with_gst and describe. Call them on both objects.
  4. Add apply_discount with validation. Call it with 20 and with 150.
  5. Add GST_RATE as a class attribute. Change it on the class and confirm both objects see the change.
  6. Build the Basket class with items = [] at class level. Create two, add to one, and print the other. Then fix it.
  7. Write BankAccount and try every way you can think of to make the balance negative. Confirm you cannot.
  8. Add an attribute in __init__ that is not a parameter — a created-at timestamp using datetime.now().

Next: controlling what the outside world can do to your objects.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship