Skip to main content
Back to module
Python Fundamentals · Files, Modules, and Packages

Objects and classes (intro)

Reading42mLesson 3 of 3 in this module

Define a small class with an __init__ method, attributes and methods that work on the object's own data.

Why it matters

So far you have kept data (lists, dictionaries) and the functions that work on it separately. As programs grow, it helps to bundle them together: a sensor has a name, a unit and readings, and you can ask it for its average. A class describes that bundle, and each object made from it carries its own data. Everything in Python is already an object (a string has .upper(), a list has .append()), so learning classes also explains how the tools you already use work.

Defining a class

class Sensor:
    def __init__(self, name, unit):
        self.name = name
        self.unit = unit
        self.readings = []

    def add(self, value):
        self.readings.append(value)

    def average(self):
        if not self.readings:
            return 0.0
        return sum(self.readings) / len(self.readings)
  • class Sensor: starts the definition. Class names use CapWords style.
  • Functions defined inside a class are methods.
  • __init__ (two underscores on each side) is the initialiser. Python calls it automatically when you create a new object, to set up the object's starting data.
  • self is the object the method is working on. Every method takes it as its first parameter. self.name = name stores the argument as an attribute of this particular object.

Creating and using objects

tank = Sensor("tank-a", "C")
pump = Sensor("pump-1", "bar")

tank.add(20.5)
tank.add(21.5)
pump.add(3.2)

print(tank.name, tank.readings)     # tank-a [20.5, 21.5]
print(tank.average())               # 21.0
print(pump.average())               # 3.2

Calling the class like a function, Sensor("tank-a", "C"), creates a new object (an instance) and runs __init__ with self set to it. When you call tank.add(20.5), Python passes tank as self for you; you never pass self yourself.

Each instance has its own attributes. Adding a reading to tank does not touch pump.

Methods that use other methods

Methods can call each other through self:

class Sensor:
    def __init__(self, name, unit):
        self.name = name
        self.unit = unit
        self.readings = []

    def add(self, value):
        self.readings.append(value)

    def average(self):
        if not self.readings:
            return 0.0
        return sum(self.readings) / len(self.readings)

    def summary(self):
        return f"{self.name}: {len(self.readings)} readings, mean {self.average():.1f} {self.unit}"

tank = Sensor("tank-a", "C")
for v in [20.5, 21.5, 22.0]:
    tank.add(v)
print(tank.summary())     # tank-a: 3 readings, mean 21.3 C

A readable printout with __str__

By default, print(tank) shows something unhelpful like <__main__.Sensor object at 0x10a...>. Define __str__ to choose what print and str() show:

    def __str__(self):
        return f"Sensor({self.name}, {len(self.readings)} readings)"

Methods with double underscores are called special methods (or "dunder" methods). Python calls them for you in particular situations: __init__ on creation, __str__ when printing, __len__ for len(obj), and so on.

Validating data

A class is a good place to enforce rules, so bad data is rejected at the door:

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance

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

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

acct = BankAccount("Ada", 100)
acct.deposit(50)
acct.withdraw(30)
print(acct.balance)     # 120
acct.withdraw(500)      # ValueError: Insufficient funds

raise signals an error deliberately. The calling code can catch it with try / except ValueError (see the debugging lesson) or let the program stop with a clear message.

When to write a class

Classes are a tool, not a requirement. Plain functions and dictionaries are fine for small scripts. Consider a class when:

  • several functions all take the same group of data as their first argument;
  • you have many similar things (sensors, accounts, students), each with its own state;
  • you want to enforce rules about how that data may change.

There is more to object-oriented programming, such as inheritance (a class that extends another) and the @dataclass shortcut from the dataclasses module that writes __init__ for you. Those build on exactly what you have seen here.

Common mistakes

  • Forgetting self in the method definition. def add(value): fails with TypeError: add() takes 1 positional argument but 2 were given, because Python passes the object automatically.
  • Forgetting self. when using an attribute. Inside a method, readings is a local name; the attribute is self.readings.
  • Misspelling __init__. _init_ or __int__ will not be called when the object is created.
  • Creating mutable data at class level. readings = [] written directly under class Sensor: (outside __init__) is shared by all sensors. Create it in __init__ with self.readings = [].
  • Calling the method without parentheses. tank.average is the method itself; tank.average() runs it.

Quick recap

  • class Name: defines a class; calling Name(...) creates an instance.
  • __init__(self, ...) sets each new object's starting attributes.
  • Methods take self first and use self.attribute to reach the object's data.
  • __str__ controls what print(obj) shows.
  • Use classes to bundle related data with the behaviour that belongs to it.

Try it

In the Do tab you will finish a Sensor class: store readings, report the latest and the average, and print a readable summary.

Also in this lesson: