Classes and objects

In Python, every value is an object: a number, a string, a list. Each object has a type that decides what data it holds and what you can do with it. Object-oriented programming (OOP) means defining your own types, so that data and the code that works on that data live together.

A class is the definition of such a type: a blueprint that describes which data each object has and which operations it supports. An instance is one concrete object created from a class. The words object and instance mean the same thing; "instance" stresses which class it came from.

class BankAccount:
    pass


savings = BankAccount()
checking = BankAccount()

print(type(savings).__name__)
print(savings is checking)
BankAccount
False

class BankAccount: defines the class; the body here is empty, so it holds pass. Calling the class like a function, BankAccount(), creates a new instance. The two instances are separate objects. By convention, class names use PascalCase.

__init__ and self

Most objects need data from the start: an account has an owner and a balance. The special method __init__ sets up a new instance. Python calls it automatically right after creating the object, passing along the arguments you gave to the class.

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


account = BankAccount("Ada", 100)
print(account.owner)
print(account.balance)

empty = BankAccount("Grace")
print(empty.balance)
Ada
100
0

The first parameter of __init__, and of every other method, is self — the instance the method is working on. When you write BankAccount("Ada", 100), Python creates the object and calls __init__ with that object as self, "Ada" as owner and 100 as balance. You never pass self yourself.

__init__ is often called the constructor. Strictly, it is an initializer: the object already exists when it runs, and it must return None.

Attributes

An attribute is a name attached to an object, reached with a dot: account.balance. There are two kinds, and the difference matters.

Instance attributes

Attributes assigned on self, usually in __init__, belong to one instance. Every account has its own owner and balance, and changing one account does not affect another.

Class attributes

Attributes assigned directly in the class body belong to the class and are shared by all its instances. They suit values that are the same for every object, such as a fixed interest rate.

class BankAccount:
    interest_rate = 0.02          # class attribute: shared

    def __init__(self, owner, balance=0):
        self.owner = owner        # instance attributes: per object
        self.balance = balance


ada = BankAccount("Ada", 100)
grace = BankAccount("Grace", 250)

print(ada.interest_rate, grace.interest_rate)

BankAccount.interest_rate = 0.03
print(ada.interest_rate, grace.interest_rate)

ada.balance = 150
print(ada.balance, grace.balance)
0.02 0.02
0.03 0.03
150 250

When you read ada.interest_rate, Python looks on the instance first and, if the name is not there, on the class. That is why a change to BankAccount.interest_rate is visible through every instance.

Methods

A method is a function defined inside a class. You call it on an instance with a dot, and Python passes that instance as self, so the method can read and change the object's own data.

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


account = BankAccount("Ada", 100)
account.deposit(50)
account.withdraw(30)
print(account.balance)

try:
    account.withdraw(500)
except ValueError as error:
    print("Refused:", error)
120
Refused: Insufficient funds

account.deposit(50) is shorthand for BankAccount.deposit(account, 50). Putting the checks inside the methods means every piece of code that changes a balance goes through the same rules. The page on error handling explains raise and try.

Dunder methods

A dunder method ("double underscore") is a method whose name starts and ends with two underscores, such as __init__. You rarely call them yourself: Python calls them for you when you use a built-in operation on the object. Defining them lets your objects work with print(), ==, len() and more.

__str__ and __repr__

By default, printing an object shows only its class and memory address, such as <__main__.Sensor object at 0x7f3a…>. Two methods change that:

  • __str__ returns a readable text for users. It is used by print() and str().
  • __repr__ returns an unambiguous text for developers, ideally looking like the code that recreates the object. It is used by repr(), in the REPL, and inside lists.
class Sensor:
    def __init__(self, location, celsius):
        self.location = location
        self.celsius = celsius

    def __str__(self):
        return f"{self.location}: {self.celsius:.1f} °C"

    def __repr__(self):
        return f"Sensor({self.location!r}, {self.celsius!r})"


kitchen = Sensor("Kitchen", 21.456)
print(kitchen)
print(repr(kitchen))
print([kitchen])
Kitchen: 21.5 °C
Sensor('Kitchen', 21.456)
[Sensor('Kitchen', 21.456)]

If a class defines only __repr__, Python uses it for str() as well, so __repr__ is the one to write first.

__eq__, __len__ and others

Without __eq__, == between two of your objects only checks whether they are the same object. Define it to compare by value. __len__ makes len() work.

class Playlist:
    def __init__(self, name, songs):
        self.name = name
        self.songs = list(songs)

    def __len__(self):
        return len(self.songs)

    def __eq__(self, other):
        if not isinstance(other, Playlist):
            return NotImplemented
        return self.songs == other.songs


morning = Playlist("Morning", ["Sunrise", "Coffee"])
copy = Playlist("Copy", ["Sunrise", "Coffee"])

print(len(morning))
print(morning == copy)
print(morning is copy)
2
True
False

Returning NotImplemented tells Python that this method cannot compare with that type, so it falls back to other options instead of guessing. Many more dunder methods exist: __lt__ for <, __add__ for +, __contains__ for in, and so on.

Defining __eq__ makes instances unhashable unless you also define __hash__, so they can no longer be set items or dictionary keys:

class Coin:
    def __init__(self, value):
        self.value = value

    def __eq__(self, other):
        return isinstance(other, Coin) and self.value == other.value


print(Coin(2) == Coin(2))
print({Coin(2)})
True
TypeError: unhashable type: 'Coin'

Encapsulation by convention

Encapsulation means keeping an object's internal details behind its methods, so other code does not depend on them. Many languages enforce this with private keywords. Python does not: every attribute can be read and changed from outside. Instead, a leading underscore marks a name as internal, and other programmers are expected to leave it alone.

class Thermostat:
    def __init__(self, target):
        self._target = target        # internal: use the methods

    def set_target(self, celsius):
        if not 5 <= celsius <= 30:
            raise ValueError("Target must be between 5 and 30 °C")
        self._target = celsius

    def target(self):
        return self._target


living_room = Thermostat(20)
living_room.set_target(22)
print(living_room.target())
22

Two leading underscores (self.__pin) trigger name mangling: Python renames the attribute to _ClassName__pin to avoid clashes in subclasses. It does not make the attribute secret, and a single underscore is enough in almost all code.

Inheritance

Inheritance lets you define a new class based on an existing one. The new class, the subclass (or child class), inherits the methods and class attributes of the parent class (superclass or base class), and can add or change behavior. The parent's instance attributes are set only if its __init__ runs, which is why a subclass with its own __init__ calls super().__init__(). You name the parent in parentheses.

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

    def area(self):
        return 0

    def describe(self):
        return f"{self.name} with area {self.area():.2f}"


class Rectangle(Shape):
    def __init__(self, width, height):
        super().__init__("Rectangle")
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height


class Circle(Shape):
    def __init__(self, radius):
        super().__init__("Circle")
        self.radius = radius

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


for shape in [Rectangle(3, 4), Circle(1)]:
    print(shape.describe())
Rectangle with area 12.00
Circle with area 3.14

Overriding methods

Rectangle and Circle define their own area(), which overrides the parent's version. describe() is written only once, in Shape, but calls self.area() — and because self is a Rectangle or a Circle, Python uses the subclass's method. This is called polymorphism: the same call does the right thing for each type.

super()

super() gives access to the parent class's methods from inside a subclass. Its most common use is in __init__: super().__init__("Rectangle") lets Shape set up its part of the object before the subclass adds its own attributes. It is equally useful to extend, rather than replace, a parent method:

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

    def describe(self):
        return f"{self.owner}: {self.balance:.2f}"


class SavingsAccount(BankAccount):
    def __init__(self, owner, balance=0, rate=0.02):
        super().__init__(owner, balance)
        self.rate = rate

    def describe(self):
        return super().describe() + f" (interest {self.rate:.0%})"


print(SavingsAccount("Ada", 1000).describe())
Ada: 1000.00 (interest 2%)

isinstance() and issubclass()

isinstance(obj, cls) checks whether an object is an instance of a class or of one of its subclasses; issubclass(a, b) checks the relationship between two classes. Every class ultimately inherits from object.

class Shape:
    pass


class Circle(Shape):
    pass


wheel = Circle()
print(isinstance(wheel, Circle))
print(isinstance(wheel, Shape))
print(type(wheel) == Shape)
print(issubclass(Circle, Shape))
print(issubclass(Shape, Circle))
print(issubclass(Shape, object))
True
True
False
True
False
True

Prefer isinstance() over comparing type(): it respects inheritance, so a subclass is still accepted wherever its parent is.

Composition versus inheritance

Inheritance models an is-a relationship: a savings account is a bank account. Many relationships are has-a instead: a weather station has sensors. For those, use composition — store other objects as attributes — rather than inheriting.

class Sensor:
    def __init__(self, location, celsius):
        self.location = location
        self.celsius = celsius


class WeatherStation:
    def __init__(self, name):
        self.name = name
        self.sensors = []            # composition: a station has sensors

    def add(self, sensor):
        self.sensors.append(sensor)

    def average(self):
        return sum(s.celsius for s in self.sensors) / len(self.sensors)


station = WeatherStation("Rooftop")
station.add(Sensor("North", 18.0))
station.add(Sensor("South", 21.0))
print(station.name, station.average())
Rooftop 19.5

Dataclasses

Many classes mostly hold data, and writing __init__, __repr__ and __eq__ for each of them is repetitive. The @dataclass decorator from the standard module dataclasses generates these methods from a list of annotated fields.

from dataclasses import dataclass


@dataclass
class Product:
    name: str
    price: float
    quantity: int = 1

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


keyboard = Product("Keyboard", 49.99)
cables = Product("USB cable", 4.5, quantity=3)

print(keyboard)
print(cables.total())
print(keyboard == Product("Keyboard", 49.99))
Product(name='Keyboard', price=49.99, quantity=1)
13.5
True

The fields are written with type hints (name: str). Python does not check those types at run time, but a dataclass needs them to know which fields exist. Fields with a default value must come after fields without one. You can still add ordinary methods, like total().