March 26, 20266 min read

Python Classes and OOP: The Practical Parts

Classes, inheritance, dunder methods, properties, and when object-oriented Python actually helps versus when plain functions are the better call.

python oop classes fundamentals
Ad 336x280

Python's object-oriented features are powerful, but they're also optional. Unlike Java, where everything must live inside a class, Python lets you mix and match. A module full of functions is perfectly fine. So the real question isn't "how do classes work?" -- it's "when should I reach for them?"

Short answer: when you have data and behavior that belong together, and you'll have multiple instances of that thing. If you just need a namespace for related functions, a module works fine.

The Basics: Classes, __init__, and self

class Dog:
    def __init__(self, name, breed):
        self.name = name      # instance variable
        self.breed = breed    # instance variable

def bark(self):
return f"{self.name} says woof!"

rex = Dog("Rex", "German Shepherd")
print(rex.bark()) # Rex says woof!

__init__ is the initializer (not technically a constructor, but close enough). self is the instance -- Python passes it automatically when you call a method on an object. It's explicit because Python doesn't believe in hiding things. The philosophy is "we're all consenting adults here" -- there are no truly private attributes, just conventions.

The self.name = name lines create instance variables. Each Dog gets its own copy.

Instance vs Class Variables

This distinction bites people regularly:

class Counter:
    total = 0           # class variable -- shared by ALL instances

def __init__(self, name):
self.name = name # instance variable -- unique per instance
Counter.total += 1

a = Counter("a")
b = Counter("b")
print(Counter.total) # 2
print(a.name) # "a"

Class variables live on the class itself. Instance variables live on each object. If you accidentally do self.total = 5, you create an instance variable that shadows the class variable for that one object, and now you have a confusing bug.

Dunder Methods (Magic Methods)

These are the __double_underscore__ methods that let your objects work with Python's built-in operations:

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

def __repr__(self):
return f"Vector({self.x}, {self.y})"

def __str__(self):
return f"({self.x}, {self.y})"

def __len__(self):
return int((self.x 2 + self.y 2) ** 0.5)

def __eq__(self, other):
return self.x == other.x and self.y == other.y

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

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

print(repr(v1)) # Vector(3, 4)
print(str(v1)) # (3, 4)
print(len(v1)) # 5
print(v1 == Vector(3, 4)) # True
print(v1 + v2) # (4, 6)

__repr__ is for developers (unambiguous, ideally copy-pasteable). __str__ is for users (pretty). If you only implement one, do __repr__ -- Python falls back to it when __str__ isn't defined. __eq__ lets == work. Without it, Python compares by identity (memory address), which is almost never what you want for value objects.

Properties: Managed Attributes

Instead of writing Java-style getters and setters, Python uses @property:

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

@property
def radius(self):
return self._radius

@radius.setter
def radius(self, value):
if value < 0:
raise ValueError("Radius can't be negative")
self._radius = value

@property
def area(self):
import math
return math.pi self._radius * 2

c = Circle(5)
print(c.radius) # 5
print(c.area) # 78.54...
c.radius = 10 # uses the setter
c.radius = -1 # raises ValueError

The beauty here: the caller uses c.radius like a normal attribute. The validation happens behind the scenes. You can start with a plain attribute and add a property later without changing any calling code.

Note the _radius convention -- the single underscore means "this is internal, don't touch it directly." Python won't stop you from accessing it, because again, consenting adults.

@classmethod and @staticmethod

class Date:
    def __init__(self, year, month, day):
        self.year = year
        self.month = month
        self.day = day

@classmethod
def from_string(cls, date_str):
"""Alternative constructor from 'YYYY-MM-DD' string."""
year, month, day = map(int, date_str.split("-"))
return cls(year, month, day)

@staticmethod
def is_valid(date_str):
"""Check format without needing an instance."""
parts = date_str.split("-")
return len(parts) == 3 and all(p.isdigit() for p in parts)

d = Date.from_string("2026-03-26")
print(Date.is_valid("2026-13-99")) # True (format-wise, not calendar-wise)

@classmethod receives the class as its first argument (cls). It's commonly used for alternative constructors -- dict.fromkeys(), datetime.fromtimestamp(), etc. @staticmethod doesn't receive the class or instance. It's basically a regular function that lives in the class namespace because it's conceptually related. You could put it outside the class and nothing would change. Use it sparingly.

Inheritance and super()

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

def speak(self):
raise NotImplementedError

class Cat(Animal):
def __init__(self, name, indoor):
super().__init__(name) # call parent's __init__
self.indoor = indoor

def speak(self):
return f"{self.name} says meow"

class Kitten(Cat):
def speak(self):
return f"{self.name} says mew"

k = Kitten("Tiny", indoor=True)
print(k.speak()) # Tiny says mew
print(k.name) # Tiny (inherited from Animal)
print(k.indoor) # True (inherited from Cat)

super() calls the parent class's method. Always use it instead of hardcoding the parent name -- it handles multiple inheritance correctly via the Method Resolution Order (MRO).

Speaking of multiple inheritance: Python supports it, but you should use it cautiously. Mixins (small classes that add specific behavior) work well. Deep diamond hierarchies do not.

When OOP Helps vs When Functions Are Fine

Use classes when:


  • You have state that multiple methods need to operate on

  • You'll create multiple instances of the same thing

  • You need polymorphism (different objects responding to the same interface)

  • The stdlib or framework expects it (Django models, dataclasses for structured data)


Stick with functions when:

  • You're transforming data in a pipeline (input goes in, output comes out)

  • There's no shared state between operations

  • A module gives you enough namespace organization

  • You're writing utility/helper code


Python's dataclasses module is a great middle ground -- you get structured data without writing boilerplate:

from dataclasses import dataclass

@dataclass
class Point:
x: float
y: float

# Automatically generates __init__, __repr__, __eq__ p = Point(3.0, 4.0) print(p) # Point(x=3.0, y=4.0)

If you want to practice building classes with real exercises -- implementing your own data structures, writing proper dunder methods, building class hierarchies -- CodeUp has interactive Python challenges that go from basic class definitions through advanced OOP patterns.

Ad 728x90