On this page
Classes and Objects
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 thingThe `__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 4tip type: info title: "Common dunder methods"
Beyond
__init__,__str__, and__repr__, the most useful dunder methods are:__len__(supportslen()),__iter__(supportsforloops),__contains__(supportsin),__getitem__(supportsobj[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. Usefrozen=Truefor immutable value objects andfield(default_factory=list)for mutable defaults.
nextSteps
- inheritance-and-polymorphism
Sign in to track your progress