On this page

Classes and Objects

15 min read TextCh. 4 — OOP and Files

Classes and Objects

Object-Oriented Programming (OOP) is a paradigm that organizes code around objects — bundles of state (data) and behavior (methods). Python is a fully object-oriented language — even integers, strings, and functions are objects. In this lesson you will learn how to define your own classes to model complex domains cleanly and maintain data integrity.

Defining a Class

The class keyword defines a class. By convention, class names use PascalCase:

class Dog:
    """Represents a dog with a name, breed, and age."""

    # Class attribute — shared by ALL instances
    species = "Canis lupus familiaris"

    def __init__(self, name: str, breed: str, age: int) -> None:
        # Instance attributes — unique to each instance
        self.name = name
        self.breed = breed
        self.age = age

    def bark(self) -> str:
        return f"{self.name} says: Woof!"

    def birthday(self) -> None:
        self.age += 1
        print(f"Happy birthday, {self.name}! Now {self.age} years old.")

    def __str__(self) -> str:
        """Human-readable string representation."""
        return f"{self.name} ({self.breed}, {self.age}y)"

    def __repr__(self) -> str:
        """Unambiguous representation — should allow recreation of the object."""
        return f"Dog(name={self.name!r}, breed={self.breed!r}, age={self.age})"

# Creating instances
rex = Dog("Rex", "German Shepherd", 3)
buddy = Dog("Buddy", "Labrador", 5)

print(rex)              # Rex (German Shepherd, 3y) — uses __str__
print(repr(rex))        # Dog(name='Rex', breed='German Shepherd', age=3)
print(rex.bark())       # Rex says: Woof!
rex.birthday()          # Happy birthday, Rex! Now 4 years old.

# Accessing class attribute via instance
print(rex.species)      # Canis lupus familiaris
print(Dog.species)      # same thing

The `__init__` Method

__init__ is the initializer (often called the constructor). It runs automatically when you create a new instance. The first parameter is always self — a reference to the newly created instance.

class Rectangle:
    def __init__(self, width: float, height: float) -> None:
        if width <= 0 or height <= 0:
            raise ValueError("Width and height must be positive")
        self.width = width
        self.height = height

    def area(self) -> float:
        return self.width * self.height

    def perimeter(self) -> float:
        return 2 * (self.width + self.height)

    def is_square(self) -> bool:
        return self.width == self.height

    def scale(self, factor: float) -> "Rectangle":
        """Return a new scaled rectangle."""
        return Rectangle(self.width * factor, self.height * factor)

    def __str__(self) -> str:
        return f"Rectangle({self.width} x {self.height})"

r1 = Rectangle(5, 3)
print(r1)                # Rectangle(5 x 3)
print(r1.area())         # 15
print(r1.perimeter())    # 16
print(r1.is_square())    # False

r2 = r1.scale(2)
print(r2)                # Rectangle(10 x 6)

Properties — Controlled Attribute Access

Properties let you add validation and computation logic to attribute access without breaking the public interface:

class Temperature:
    """Temperature that enforces a minimum of absolute zero."""

    ABSOLUTE_ZERO_CELSIUS = -273.15

    def __init__(self, celsius: float) -> None:
        self.celsius = celsius   # goes through the setter

    @property
    def celsius(self) -> float:
        return self._celsius

    @celsius.setter
    def celsius(self, value: float) -> None:
        if value < self.ABSOLUTE_ZERO_CELSIUS:
            raise ValueError(
                f"Temperature cannot be below absolute zero ({self.ABSOLUTE_ZERO_CELSIUS}°C)"
            )
        self._celsius = value

    @property
    def fahrenheit(self) -> float:
        """Computed property — read-only."""
        return self._celsius * 9 / 5 + 32

    @property
    def kelvin(self) -> float:
        return self._celsius - self.ABSOLUTE_ZERO_CELSIUS

    def __str__(self) -> str:
        return f"{self._celsius:.1f}°C / {self.fahrenheit:.1f}°F / {self.kelvin:.1f}K"

t = Temperature(100)
print(t)             # 100.0°C / 212.0°F / 373.2K

t.celsius = 0
print(t)             # 0.0°C / 32.0°F / 273.1K

try:
    t.celsius = -300
except ValueError as e:
    print(f"Error: {e}")

Special (Dunder) Methods

Special methods (surrounded by double underscores) allow your objects to integrate with Python's built-in operators and functions:

class Vector:
    """2D vector with operator overloading."""

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

    def __str__(self) -> str:
        return f"Vector({self.x}, {self.y})"

    def __repr__(self) -> str:
        return f"Vector({self.x!r}, {self.y!r})"

    def __add__(self, other: "Vector") -> "Vector":
        return Vector(self.x + other.x, self.y + other.y)

    def __sub__(self, other: "Vector") -> "Vector":
        return Vector(self.x - other.x, self.y - other.y)

    def __mul__(self, scalar: float) -> "Vector":
        return Vector(self.x * scalar, self.y * scalar)

    def __rmul__(self, scalar: float) -> "Vector":
        return self.__mul__(scalar)

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

    def __abs__(self) -> float:
        """Magnitude of the vector."""
        return (self.x ** 2 + self.y ** 2) ** 0.5

    def __len__(self) -> int:
        return 2   # a 2D vector always has 2 components

    def __iter__(self):
        yield self.x
        yield self.y

v1 = Vector(3, 4)
v2 = Vector(1, 2)

print(v1 + v2)       # Vector(4, 6)
print(v1 - v2)       # Vector(2, 2)
print(v1 * 3)        # Vector(9, 12)
print(2 * v1)        # Vector(6, 8)
print(v1 == v2)      # False
print(abs(v1))       # 5.0 — classic 3-4-5 triangle
print(list(v1))      # [3, 4]
x, y = v1            # unpacking via __iter__
print(x, y)          # 3 4

tip type: info title: "Common dunder methods"

Beyond __init__, __str__, and __repr__, the most useful dunder methods are: __len__ (supports len()), __iter__ (supports for loops), __contains__ (supports in), __getitem__ (supports obj[key]), __eq__ and __hash__ (equality and hashing), and __enter__/__exit__ (context manager protocol).

Class Methods and Static Methods

import json
from datetime import date

class User:
    _next_id = 1   # class-level counter

    def __init__(self, username: str, email: str, birthdate: date) -> None:
        self.id = User._next_id
        User._next_id += 1
        self.username = username
        self.email = email
        self.birthdate = birthdate

    @classmethod
    def from_dict(cls, data: dict) -> "User":
        """Alternative constructor — create a User from a dictionary."""
        return cls(
            username=data["username"],
            email=data["email"],
            birthdate=date.fromisoformat(data["birthdate"]),
        )

    @classmethod
    def from_json(cls, json_str: str) -> "User":
        """Alternative constructor — create a User from a JSON string."""
        return cls.from_dict(json.loads(json_str))

    @staticmethod
    def validate_email(email: str) -> bool:
        """Check if an email looks valid. No `self` or `cls` needed."""
        return "@" in email and "." in email.split("@")[-1]

    @property
    def age(self) -> int:
        today = date.today()
        years = today.year - self.birthdate.year
        if (today.month, today.day) < (self.birthdate.month, self.birthdate.day):
            years -= 1
        return years

    def __str__(self) -> str:
        return f"User #{self.id}: {self.username} ({self.email}), age {self.age}"

# Regular constructor
u1 = User("alice", "[email protected]", date(1995, 6, 15))

# Alternative constructor via classmethod
u2 = User.from_dict({"username": "bob", "email": "[email protected]", "birthdate": "1990-03-20"})

u3 = User.from_json('{"username": "carol", "email": "[email protected]", "birthdate": "2000-11-10"}')

# Static method — no instance or class needed
print(User.validate_email("[email protected]"))  # True
print(User.validate_email("not-an-email"))       # False

print(u1)
print(u2)
print(u3)

Dataclasses

For classes that primarily hold data, @dataclass generates __init__, __repr__, __eq__, and more automatically:

from dataclasses import dataclass, field
from typing import ClassVar

@dataclass
class Point:
    x: float
    y: float
    z: float = 0.0   # default value

@dataclass(frozen=True)   # immutable — raises FrozenInstanceError on assignment
class Color:
    r: int
    g: int
    b: int

    def to_hex(self) -> str:
        return f"#{self.r:02X}{self.g:02X}{self.b:02X}"

@dataclass
class Inventory:
    name: str
    items: list[str] = field(default_factory=list)   # mutable default!
    item_count: ClassVar[int] = 0                    # class variable, not instance field

    def add_item(self, item: str) -> None:
        self.items.append(item)
        Inventory.item_count += 1

p1 = Point(1.0, 2.5)
p2 = Point(1.0, 2.5, 3.0)
p3 = Point(1.0, 2.5)

print(p1)          # Point(x=1.0, y=2.5, z=0.0)
print(p1 == p3)    # True — __eq__ compares all fields

red = Color(255, 0, 0)
print(red.to_hex())   # #FF0000

inv = Inventory("Warehouse A")
inv.add_item("Widget")
inv.add_item("Gadget")
print(inv)
print(f"Total items added: {Inventory.item_count}")

tip type: tip title: "Use @dataclass for data-centric classes"

If your class is primarily a container for data — like a configuration object, a database record, or a DTO (Data Transfer Object) — use @dataclass. It eliminates boilerplate __init__, __repr__, and __eq__ code. Use frozen=True for immutable value objects and field(default_factory=list) for mutable defaults.

nextSteps

  • inheritance-and-polymorphism