Skip to content

Classes and objects

Every type you have worked with so far (strings, lists, dictionaries) is actually a class. When you call "hello".upper(), you are calling a method on a string object. Classes let you define your own types with their own data and behaviour. A Player class can hold a name, a score, and a level, and know how to display itself.

Classes are the mechanism for user-defined types. A class defines a template: the data each instance holds (attributes) and the operations it supports (methods). Instead of tracking values in parallel variables and passing them everywhere, you bundle them into one cohesive object with a clear interface.

A class statement creates a new type object in the current namespace, using a metaclass (default type) to construct it. Instances are created by calling the class, which invokes __new__ to allocate the object and __init__ to initialise it. Python's object model is uniform: everything is an object, including classes, functions, and types themselves.

Blueprint and instances

A class is a blueprint. An instance is a specific thing made from that blueprint. You can make as many instances as you need, each with its own data but sharing the same methods defined in the class.

A class defines structure and behaviour. Instances are objects created from that class; each has its own attribute namespace but shares the class's method objects. Creating an instance calls the class like a function: Dog() creates a new Dog instance.

Calling a class invokes type.__call__, which calls cls.__new__(cls) to allocate the instance and cls.__init__(instance, ...) to initialise it. The resulting object's __class__ points back to the class. Method lookup follows the MRO: instance __dict__, class __dict__, and then the MRO chain.

python
class Dog:
    def bark(self):
        print("Woof!")

rex  = Dog()
luna = Dog()

rex.bark()    # "Woof!"
luna.bark()   # "Woof!"

Dog is the class. rex and luna are instances: two different dogs, each sharing the same behaviour defined in the class.

__init__ and self

__init__ is the method Python calls automatically when you create a new instance. It is where you set up the starting data for the object. self is how a method refers to the specific instance it is operating on, and it is always the first parameter.

__init__ initialises a freshly allocated instance. self is a conventional name for the first parameter of every instance method; Python passes the instance automatically when you call alice.display(). Attributes set on self inside __init__ are instance attributes: each instance has its own copy.

__init__ receives the already-allocated instance from __new__ and sets it up. self is not a keyword; it is the first positional parameter, passed implicitly by the descriptor protocol when you call instance.method(). Setting self.attr = value calls object.__setattr__, which writes to the instance's __dict__. Attributes not set in __init__ can still be set later on any instance, since Python dicts are dynamic.

python
class Player:
    def __init__(self, name, score=0):
        self.name  = name
        self.score = score

    def add_points(self, points):
        self.score += points

    def display(self):
        print(f"{self.name}: {self.score} points")

alice = Player("Alice")
bob   = Player("Bob", score=50)

alice.add_points(30)
alice.display()   # "Alice: 30 points"
bob.display()     # "Bob: 50 points"

self.name and self.score are instance attributes: they belong to the specific object, not the class itself. Each Player instance has its own name and score.

Methods

Any function defined inside a class is a method. Instance methods always have self as the first parameter; Python passes it automatically. Methods can read and change the instance's data via self.

Instance methods are regular functions stored in the class namespace. The descriptor protocol transforms instance.method into a bound method, binding self automatically. Returning self from a method enables method chaining: obj.scale(2).rotate(90).

instance.method triggers descriptor lookup: type.__getattribute__ finds method in the class __dict__, recognises it as a function (which implements the descriptor protocol), and returns a bound method object that wraps the function with the instance pre-applied. This is how self is automatically passed. Returning self enables fluent interfaces.

python
class Circle:
    def __init__(self, radius):
        self.radius = radius

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

    def scale(self, factor):
        self.radius *= factor
        return self    # returning self allows chaining: c.scale(2).scale(0.5)

c = Circle(5)
print(c.area())    # 78.53975
c.scale(2)
print(c.area())    # 314.159

Class variables vs instance variables

Variables defined directly on the class (not inside __init__) are class variables. All instances share the same class variable. Variables set on self inside __init__ are instance variables, unique to each object.

Class variables live in the class __dict__ and are shared by all instances. Instance variables live in each instance's __dict__. When you read self.attr, Python checks the instance first, then the class. When you write self.attr = value, it always creates or updates the instance attribute, shadowing the class variable if one exists.

Attribute lookup on an instance follows this order: instance __dict__, class __dict__, and then the MRO. Writing to self.attr always targets the instance __dict__ (unless __setattr__ is overridden). A class variable is shared until a write to self.attr creates an instance shadow. Mutable class variables (like lists) are particularly tricky: all instances share the same object, so mutation via any instance is visible to all.

python
class Player:
    max_lives = 3    # class variable, same for every Player

    def __init__(self, name):
        self.name  = name   # instance variable, unique to each Player
        self.lives = Player.max_lives

    def die(self):
        self.lives -= 1

alice = Player("Alice")
bob   = Player("Bob")

Player.max_lives = 5    # change for all current and future instances

Use class variables for values shared across all instances: constants, counters, defaults. Use instance variables for data that differs per object.

__str__ and __repr__

__str__ controls what print() and f-strings show for your object. __repr__ controls the developer view shown in the console and for debugging. Always define __repr__. Define __str__ when you want a clean user-facing display separate from the debug view.

__str__ is called by str(), print(), and f-strings. __repr__ is called by repr() and shown in the REPL. If only __repr__ is defined, Python uses it for both. The convention: __repr__ should return a string that could reconstruct the object; __str__ should return a human-readable summary.

str(obj) calls type(obj).__str__(obj), falling back to __repr__ if __str__ is not defined. repr(obj) calls type(obj).__repr__(obj). Inside f-strings, {obj} calls __format__, which by default calls __str__. {obj!r} applies repr() before formatting. The __repr__ convention: return a string of the form ClassName(arg1=..., arg2=...) that, when evaluated, would reproduce the object.

python
class Player:
    def __init__(self, name, score):
        self.name  = name
        self.score = score

    def __str__(self):
        return f"{self.name} ({self.score} pts)"

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

alice = Player("Alice", 87)
print(alice)        # "Alice (87 pts)"   (uses __str__)
repr(alice)         # "Player(name='Alice', score=87)"  (uses __repr__)

Always define __repr__. Define __str__ when you want a clean user-facing representation separate from the debug view. If only __repr__ is defined, Python uses it for both.

Private convention

Python has no truly private variables, but a single underscore at the start of a name (_balance) is a convention that signals "this is internal, do not use it directly from outside the class". It is not enforced by the language; it is a communication to other developers.

A single underscore (_attr) is a convention signalling internal use. Python does not enforce it, but all linters, IDEs, and developers respect it. A double underscore (__attr) triggers name mangling: Python rewrites it to _ClassName__attr, which prevents accidental collision in subclasses. It is not true privacy; it is a collision-avoidance mechanism.

Single underscore: convention only, no enforcement. Double underscore: name mangling transforms __attr to _ClassName__attr at compile time, not runtime. This is a subclass collision guard, not a privacy mechanism; the mangled name is still accessible externally. __slots__ is a more fundamental control: it replaces the instance __dict__ with a fixed set of slot descriptors, preventing arbitrary attribute creation and reducing memory overhead.

python
class BankAccount:
    def __init__(self, balance):
        self._balance = balance    # _ means "hands off"

    def deposit(self, amount):
        if amount > 0:
            self._balance += amount

    def balance(self):
        return self._balance

A double underscore (__name) triggers name mangling; Python renames the attribute to _ClassName__name to avoid conflicts in subclasses. It is rarely needed. Single underscore is the convention in most code.

Inheritance

A class can inherit from another class, getting all its attributes and methods automatically. You can then override specific methods in the subclass to change their behaviour. This lets you reuse a common base and specialise where needed.

Inheritance creates an "is-a" relationship. The subclass inherits all the parent's methods and attributes and can override any of them. The method resolution order (MRO) defines the lookup sequence for attributes. Python supports multiple inheritance; the MRO is computed with the C3 linearisation algorithm.

The MRO is computed by type.__mro_entries__ using C3 linearisation, accessible as ClassName.__mro__. Method resolution follows the MRO from left to right. isinstance(obj, cls) walks the MRO to check. Multiple inheritance is powerful but can create diamond inheritance; the MRO resolves it deterministically. super() follows the MRO, not just the direct parent class.

python
class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        return "..."

class Dog(Animal):
    def speak(self):
        return f"{self.name} says Woof!"

class Cat(Animal):
    def speak(self):
        return f"{self.name} says Meow!"

pets = [Dog("Rex"), Cat("Luna"), Dog("Max")]
for pet in pets:
    print(pet.speak())

Dog and Cat inherit __init__ from Animal, so they do not need their own. They override speak() with their specific behaviour.

super()

super() calls a method from the parent class. Use it when you want to extend the parent's behaviour rather than replace it entirely: call the parent's __init__ to run its setup, then add anything your subclass needs on top.

super() returns a proxy object that delegates method calls to the next class in the MRO. Always call super().__init__() from a subclass __init__ when the parent has one. Skipping it means the parent's setup code does not run, which can leave the object in a broken state.

super() with no arguments (Python 3) uses __class__ (a compiler-injected cell variable) and the first argument of the enclosing method to determine the MRO position. super(cls, self) is the explicit form. The proxy resolves method lookup starting from the next class in type(self).__mro__ after cls. This is essential for correct multiple inheritance: super() chains each class's __init__ through the full MRO.

python
class Animal:
    def __init__(self, name, sound):
        self.name  = name
        self.sound = sound

class Dog(Animal):
    def __init__(self, name):
        super().__init__(name, "Woof")   # call Animal.__init__
        self.tricks = []                  # add something extra

    def learn(self, trick):
        self.tricks.append(trick)

rex = Dog("Rex")
rex.learn("sit")
print(rex.tricks)   # ["sit"]

Always call super().__init__() when your subclass has its own __init__ and the parent does too.

Class methods and static methods

@classmethod creates a method that receives the class itself instead of an instance. It is useful for alternative constructors: creating an instance from a string, a file, or another format. @staticmethod is a plain function that lives inside the class for organisational reasons; it receives neither the instance nor the class.

@classmethod receives cls (the class) as its first argument, not an instance. The primary use is alternative constructors that create instances from different input formats. @staticmethod is a regular function namespaced under the class; it has no access to the class or instance. Use @classmethod for constructors, @staticmethod for utility functions logically tied to the class.

@classmethod uses the descriptor protocol: the classmethod descriptor returns a bound method where the first argument is the class, not the instance. cls is the actual class at call time, so subclass methods get the subclass. @staticmethod is a simpler descriptor that returns the underlying function unchanged, with no implicit first argument. Both decorators modify the descriptor behaviour; neither affects the function's code object.

python
class Player:
    def __init__(self, name, score):
        self.name  = name
        self.score = score

    @classmethod
    def from_string(cls, data):
        name, score = data.split(",")
        return cls(name, int(score))

alice = Player.from_string("Alice,87")
python
class Player:
    @staticmethod
    def is_valid_name(name):
        return name.isalpha() and len(name) >= 2

Player.is_valid_name("Alice")   # True
Player.is_valid_name("A1")      # False

Use @classmethod for alternative constructors. Use @staticmethod for utility functions that logically belong with the class but do not need instance or class data.

@property

@property lets you access a method like an attribute, with no parentheses needed. Use it for values that are computed from other attributes and feel natural to read as simple attribute access.

@property turns a method into a read-only attribute. The method runs when the attribute is accessed. This is useful for computed values derived from stored data, and for adding validation to attribute access without changing the public interface. A paired @name.setter makes the attribute writable.

@property is a descriptor: property(fget, fset, fdel, doc). Accessing instance.attr calls fget(instance). @name.setter sets fset. @name.deleter sets fdel. Properties are looked up on the class, not the instance, which is why instance.__dict__[name] does not shadow a property (data descriptors take priority over instance __dict__). Properties are the Pythonic alternative to Java-style getter/setter methods.

python
class Circle:
    def __init__(self, radius):
        self.radius = radius

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

    @property
    def diameter(self):
        return self.radius * 2

c = Circle(5)
print(c.area)      # 78.53975 (looks like an attribute, runs like a method)
print(c.diameter)  # 10

Properties are useful for computed values: things derived from other attributes that feel natural to access without ().

In practice

A Player class with instance attributes, methods, a @property, and __str__:

python
class Player:
    max_lives = 3

    def __init__(self, name: str):
        self.name  = name
        self.score = 0
        self.lives = Player.max_lives

    def earn_points(self, amount: int) -> None:
        self.score += amount

    def take_hit(self) -> bool:
        self.lives -= 1
        return self.lives > 0

    @property
    def is_alive(self) -> bool:
        return self.lives > 0

    def __str__(self) -> str:
        return f"{self.name} | Score: {self.score} | Lives: {self.lives}"

alice = Player("Alice")
alice.earn_points(50)
alice.take_hit()
print(alice)            # "Alice | Score: 50 | Lives: 2"
print(alice.is_alive)   # True

A User class that uses a private attribute with a @property getter, a deactivate method, and a to_dict serialiser:

python
class User:
    def __init__(self, user_id: int, username: str, email: str):
        self.id       = user_id
        self.username = username
        self.email    = email
        self._active  = True

    @property
    def active(self) -> bool:
        return self._active

    def deactivate(self) -> None:
        self._active = False

    def to_dict(self) -> dict:
        return {
            "id":       self.id,
            "username": self.username,
            "email":    self.email,
            "active":   self._active,
        }

    def __repr__(self) -> str:
        return f"User(id={self.id}, username={self.username!r})"

alice = User(1, "alice", "[email protected]")
print(alice.to_dict())
alice.deactivate()
print(alice.active)   # False

A DataSplit class that encapsulates train/validation slicing behind properties, with __repr__ for clean debug output:

python
class DataSplit:
    def __init__(self, data: list, train_ratio: float = 0.8):
        split       = int(len(data) * train_ratio)
        self._train = data[:split]
        self._val   = data[split:]

    @property
    def train(self) -> list:
        return self._train

    @property
    def val(self) -> list:
        return self._val

    @property
    def sizes(self) -> tuple[int, int]:
        return len(self._train), len(self._val)

    def __repr__(self) -> str:
        return f"DataSplit(train={len(self._train)}, val={len(self._val)})"

data  = list(range(100))
split = DataSplit(data, train_ratio=0.8)
print(split)         # DataSplit(train=80, val=20)
print(split.sizes)   # (80, 20)

The underscore prefix on _train and _val signals that callers should go through the properties rather than mutating the raw lists directly. Python will not enforce this, but it sets a clear contract.