Stop writing classes (unless you really need them)

Stop writing classes (unless you really need them)

I still remember the day my code review made our senior developer spill coffee all over his mechanical keyboard. My crime? Adding a shiny new EmailValidator class with exactly two methods: __init__ and validate_email.

"A class? For THIS?" he asked, wiping coffee from his beard. "You just wrote 15 lines to do what a 3-line function could handle."

He was right. And I've spent years since unlearning my reflex to wrap everything in classes.

If you're nodding along because you've created a StringFormatter class just to format strings, this article is for you. Let's talk about Python's dirty little secret: you probably don't need most of those classes you're writing.

When you reach for a class but shouldn't

Picture this: You need to validate user input. Your object-oriented instincts kick in:

class InputValidator:
    def __init__(self, min_length=3, max_length=50):
        self.min_length = min_length
        self.max_length = max_length
    
    def validate(self, text):
        return self.min_length <= len(text) <= self.max_length

# Using it:
validator = InputValidator(min_length=5)
is_valid = validator.validate("hello world")
        

Seems reasonable, right? But look at all that ceremony for something that could be:

def validate_input(text, min_length=3, max_length=50):
    return min_length <= len(text) <= max_length

# Using it:
is_valid = validate_input("hello world", min_length=5)
        

The function is simpler, more direct, and doesn't force you to create an object just to perform a single operation.

Here's another classic example - the infamous "bag of functions" class:

class StringUtils:
    @staticmethod
    def reverse(s):
        return s[::-1]
    
    @staticmethod
    def capitalize_words(s):
        return ' '.join(word.capitalize() for word in s.split())
        

This isn't object-oriented programming. It's just functions wearing a trench coat pretending to be a class. Drop the disguise:

def reverse(s):
    return s[::-1]

def capitalize_words(s):
    return ' '.join(word.capitalize() for word in s.split())
        

When your class methods don't use self, that's Python whispering, "This should just be a function."

And if you're reaching for a class just to group related functions, that's what modules are for!

Functions: Python's secret weapon

Most Python newcomers don't realize just how powerful functions really are in this language. They're not just procedures or methods - they're full-fledged objects with superpowers.

Here's something that blows people's minds: in Python's C implementation, functions ARE classes. That's right - when you define a function in Python, you're actually creating an instance of a class (specifically, a PyFunction_Object in the C code).

This means Python functions can do things that make other languages jealous:

def greet(name):
    """Say hello to someone"""
    return f"Hello, {name}!"

# Functions have attributes
greet.description = "A friendly greeting function"
greet.default_name = "friend"

# Functions can be passed around like any other object
def run_twice(func, arg):
    return func(arg), func(arg)

# Functions can be stored in data structures
greeting_funcs = [
    greet,
    lambda name: f"Hey there, {name}!",
    lambda name: f"Howdy, {name}!"
]

# Functions can be returned from other functions
def make_greeter(prefix):
    def custom_greeter(name):
        return f"{prefix}, {name}!"
    return custom_greeter

spanish_greeter = make_greeter("Hola")
print(spanish_greeter("Carlos"))  # "Hola, Carlos!"
        

That last example shows one of Python's most powerful features: closures. The spanish_greeter function "remembers" the value of prefix from when it was created.

This often eliminates the need for a class. When you think you need a class to "remember" some initial configuration, a closure might be cleaner:

# Instead of this class:
class Multiplier:
    def __init__(self, factor):
        self.factor = factor
    
    def multiply(self, x):
        return x * self.factor

# Use this function factory:
def create_multiplier(factor):
    def multiply(x):
        return x * factor
    return multiply

# Usage:
double = create_multiplier(2)
triple = create_multiplier(3)
print(double(5))  # 10
print(triple(5))  # 15
        

And let's not forget decorators - Python's elegant way of modifying or enhancing functions:

def log_calls(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} with {args} and {kwargs}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result}")
        return result
    return wrapper

@log_calls
def add(a, b):
    return a + b

add(3, 5)  # Automatically logs the call and return value
        

With all these capabilities, functions can handle most of what you'd use simple classes for, with less code and cognitive overhead.

The hidden costs of unnecessary classes

When you create classes needlessly, you're not just writing extra code - you're creating future problems. Let's look at some real costs:

More mental overhead

Every class creates a mini-universe with its own rules. Each new class asks your brain to track:

  • What methods exist
  • How state changes between method calls
  • The inheritance hierarchy (if any)
  • Potential side effects

Compare the mental load of tracking a class like this:

class DataProcessor:
    def __init__(self, source_path, output_path):
        self.source_path = source_path
        self.output_path = output_path
        self.data = None
        self.processed = False
    
    def load(self):
        with open(self.source_path, 'r') as f:
            self.data = f.readlines()
    
    def process(self):
        if not self.data:
            self.load()
        self.data = [line.strip().lower() for line in self.data]
        self.processed = True
    
    def save(self):
        if not self.processed:
            self.process()
        with open(self.output_path, 'w') as f:
            f.writelines(self.data)
        

Versus this functional approach:

def load_data(source_path):
    with open(source_path, 'r') as f:
        return f.readlines()

def process_data(data):
    return [line.strip().lower() for line in data]

def save_data(data, output_path):
    with open(output_path, 'w') as f:
        f.writelines(data)
        

The functional version is easier to follow because each function has a clear input and output with no hidden state.

Testing becomes painful

Classes with internal state are harder to test. With the DataProcessor class, testing the process method properly means setting up the right state first.

But with pure functions, testing is straightforward:

def test_process_data():
    input_data = ["Line 1  ", "  LINE 2", "line 3"]
    expected = ["line 1", "line 2", "line 3"]
    assert process_data(input_data) == expected
        

No setup, no state, no surprises.

Flexibility suffers

When logic is locked in a class, it's harder to compose and reuse. What if you want to process data from a different source, not a file?

With the class approach, you'd need to modify the class or create a new one. With functions, just add another function:

def load_data_from_api(url):
    response = requests.get(url)
    return response.text.splitlines()

# Then use with the existing process_data function
data = load_data_from_api("https://example.com/data")
processed = process_data(data)
        

Classes tempt us to keep adding more methods and more state, leading to bloated "god objects" that do too much. Functions push us toward simplicity and composition.

When classes actually make sense

Look, I'm not a class-hating anarchist. Classes shine in specific situations:

When you need to maintain complex state

If you're tracking multiple related attributes that change together through well-defined operations, classes make sense:

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance
        self.transactions = []
    
    def deposit(self, amount):
        self.balance += amount
        self.transactions.append(("deposit", amount))
    
    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError("Insufficient funds")
        self.balance -= amount
        self.transactions.append(("withdraw", amount))
    
    def transfer(self, amount, target_account):
        self.withdraw(amount)
        target_account.deposit(amount)
        self.transactions.append(("transfer", amount, target_account.owner))
        

This works as a class because:

  • It manages interrelated state (balance and transactions)
  • Operations modify multiple attributes consistently
  • The class enforces rules (can't withdraw more than your balance)
  • The identity of each account matters

Trying to replace this with functions would be messy and error-prone.

When implementing interfaces or protocols

When you're working with frameworks or libraries expecting specific object interfaces, classes are appropriate:

class CustomWidget(tk.Frame):
    def __init__(self, master):
        super().__init__(master)
        # Set up widget...
    
    def update_display(self):
        # Update the widget
        

Many Python frameworks (Django, Flask, Tkinter, etc.) rely on class inheritance to hook into their systems.

When leveraging dunder methods

Python's special methods let you create objects that work seamlessly with language features:

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)
    
    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)
    
    def __repr__(self):
        return f"Vector({self.x}, {self.y})"

# Now you can use natural syntax
v1 = Vector(1, 2)
v2 = Vector(3, 4)
v3 = v1 + v2  # Uses __add__
v4 = v1 * 3   # Uses __mul__
        

This kind of operator overloading and custom behavior is a legitimate reason to use classes.

Building better code with both approaches

The real skill isn't picking functions or classes - it's knowing when to use each. This is where principles like SOLID come in handy.


Article content

SOLID principles (originally defined for OOP) can guide your thinking even when writing functional code, For a deep dive into these principles, check out Building Bulletproof Code: SOLID Principles to Level Up Your Skills.

The truth is that good Python code often mixes both approaches:

# Functions for core operations
def load_config(path):
    with open(path) as f:
        return json.load(f)

def validate_config(config):
    required = ["api_key", "endpoint", "timeout"]
    return all(key in config for key in required)

# A class where it makes sense
class APIClient:
    def __init__(self, config):
        if not validate_config(config):
            raise ValueError("Invalid configuration")
        self.api_key = config["api_key"]
        self.endpoint = config["endpoint"]
        self.timeout = config["timeout"]
        self.session = requests.Session()
    
    def get_data(self, resource_id):
        response = self.session.get(
            f"{self.endpoint}/{resource_id}",
            headers={"Authorization": f"Bearer {self.api_key}"},
            timeout=self.timeout
        )
        return response.json()
        

Here, the functions handle simple transformations while the class manages complex state (the HTTP session and configuration).

Python's own standard library follows this pattern. Look at the json module - it offers functions like dumps() and loads() for simple operations, but also provides the JSONEncoder class for more complex customization.

So the next time you start typing class Whatever:, pause and ask yourself:

  • Am I using a class just to group functions? (Use a module)
  • Does this class have only one real method? (Use a function)
  • Will this be simpler and more testable as a function? (Usually yes)

Classes aren't evil. They're just overused. Use them when they truly pull their weight - not just because you can.

Remember: in Python, simplicity is a feature, not a bug!

Sathvik Ayyasamy

Full Stack Developer | Intern at High On Swift | Coimbatore Institute of Technology | Decision and Computing Science Student.

2mo

Love this, Agni

To view or add a comment, sign in

Others also viewed

Explore topics