Python Classes and Object-Oriented Basics

AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.

Python 3.13

Reach for a class when data and the operations on that data belong together. Reach for something simpler when they do not — a module of functions is often enough.

Environment

Examples are verified on Python 3.13 with no third-party dependencies.

Defining a class

class User:
    def __init__(self, name: str, age: int = 0) -> None:
        self.name = name        # instance attribute
        self.age = age

    def greet(self) -> str:
        return f"Hi, I'm {self.name}"

u = User("Ada", 36)
print(u.greet())     # Hi, I'm Ada

Details worth knowing:

  • __init__ initializes an already-created object; the object itself comes from __new__.
  • self is the first parameter of instance methods. The name is a convention, not syntax.
  • u.greet() is User.greet(u) in disguise; the bound method supplies self.

Instance attributes versus class attributes

class Counter:
    total = 0                 # class attribute: shared

    def __init__(self) -> None:
        self.count = 0        # instance attribute: per object
        Counter.total += 1

Class attributes suit constants and cross-instance counters. Never put a mutable object there as a default — see the pitfalls below.

Three kinds of methods

class User:
    role = "member"           # class attribute

    def __init__(self, name: str) -> None:
        self.name = name

    def greet(self) -> str:                       # instance method
        return f"I'm {self.name}"

    @classmethod
    def admin(cls, name: str) -> "User":          # class method
        return cls(name)

    @staticmethod
    def is_valid_name(name: str) -> bool:         # static method
        return len(name.strip()) > 0
  • Instance methods operate on one object’s state.
  • @classmethod receives the class, not the instance. Use it for alternative constructors; cls binds to the subclass when inherited.
  • @staticmethod is a plain function namespaced under the class. Use it when the logic belongs there conceptually but needs no state.

@property

class User:
    def __init__(self, first: str, last: str) -> None:
        self.first = first
        self.last = last

    @property
    def full_name(self) -> str:
        return f"{self.first} {self.last}"

    @full_name.setter
    def full_name(self, value: str) -> None:
        self.first, self.last = value.split(" ", 1)

u = User("Ada", "Lovelace")
print(u.full_name)          # Ada Lovelace
u.full_name = "Grace Hopper"

The point is future-proofing: you can start with a plain attribute and later add validation or computation without changing call sites.

Properties are not free; if the getter does I/O or heavy computation, callers will assume it is cheap.

Inheritance

class Animal:
    def __init__(self, name: str) -> None:
        self.name = name

    def speak(self) -> str:
        raise NotImplementedError

class Dog(Animal):
    def __init__(self, name: str, breed: str) -> None:
        super().__init__(name)     # initialize the parent part
        self.breed = breed

    def speak(self) -> str:
        return "woof"

    def fetch(self) -> str:
        return f"{self.name} fetches the ball"

super() follows the MRO (method resolution order) rather than hard-coding the parent class, which matters with multiple inheritance:

print(Dog.__mro__)

Prefer composition when the relationship is “has a” rather than “is a”:

class Order:
    def __init__(self, user: User, items: list[str]) -> None:
        self.user = user          # holds a User instead of being one
        self.items = items

Deep hierarchies are hard to change; a shallow tree plus composition stays flexible.

Dunder methods worth implementing

class Point:
    def __init__(self, x: float, y: float) -> None:
        self.x, self.y = x, y

    def __repr__(self) -> str:              # for developers, in logs and debuggers
        return f"Point({self.x}, {self.y})"

    def __str__(self) -> str:               # for users, print() and f-strings
        return f"({self.x}, {self.y})"

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Point):
            return NotImplemented
        return (self.x, self.y) == (other.x, other.y)

    def __hash__(self) -> int:              # defining __eq__ removes the default hash
        return hash((self.x, self.y))

Others that show up in real code:

Method Enables
__len__ len(obj)
__iter__ for x in obj
__getitem__ obj[key]
__enter__ / __exit__ with statements
__lt__ sorting and comparisons

Write __repr__ first; without it, every log line says <__main__.Point object at 0x...>.

__slots__ removes the per-instance __dict__, saving memory and turning typo’d attribute assignments into errors:

class Point:
    __slots__ = ("x", "y")

Common mistakes

1. Mutable class attributes

class Bag:
    items = []            # shared by every instance

b1, b2 = Bag(), Bag()
b1.items.append("x")
print(b2.items)           # ['x']

Create mutable state in __init__.

2. Forgetting self

def greet(): inside a class raises TypeError: greet() takes 0 positional arguments but 1 was given on call.

3. __eq__ without a type check

Accessing other.x blindly raises AttributeError when comparing to a different class. Return NotImplemented for unknown types and let Python try the reflected operation.

4. __eq__ without __hash__

The class becomes unhashable, so it cannot go into a set or serve as a dict key. Define __hash__ from the same fields used in __eq__.

5. Overusing inheritance

Inheriting to reuse one method makes the hierarchy brittle. Module-level functions and composition are usually cheaper.

6. Doing work in __init__ that can fail

Constructors that open sockets or read files are hard to test. Pass in the dependency, or use a classmethod factory that returns a ready object.

7. Relying on __del__ for cleanup

Its timing is unspecified. Use context managers.

Summary

  • A class binds state and behavior; __init__ initializes, self is the instance.
  • @property keeps the public API stable; @classmethod builds alternative constructors.
  • Use super() and the MRO; prefer composition over deep hierarchies.
  • Implement __repr__; define __eq__ and __hash__ as a pair.

Next: dataclasses and type hints, which remove most of this boilerplate.