If you're learning Python and you've reached classes, you may have had the same reaction I did:
“Okay... but why do I need all this?”
You already have variables, lists, dictionaries, and functions. They seem to do the job just fine.
So why introduce class, self, __init__(), inheritance, and all these other concepts?
The answer became much clearer to me when I stopped thinking about classes as complicated Python syntax and started thinking about them as a way to organize related data and behavior.
Let's break it down.
What Is a Class?
A class is essentially a blueprint for creating objects.
Imagine you're building an employee management system. Every employee might have:
- A name
- A salary
- A department
- A way to calculate their bonus
- A way to get promoted
Instead of keeping all this information separately, we can define an Employee class:
class Employee:
pass
We can then create objects from that class:
navas = Employee()
emmanuel = Employee()
Here, Employee is the class, while navas and emmanuel are objects (instances) of that class.
Think of it like this:
Class → Blueprint
Object → Something created from the blueprint
Adding Data with __init__()
We don't want to manually add every attribute after creating an employee.
That's where __init__() comes in.
class Employee:
def __init__(self, name, salary, department):
self.name = name
self.salary = salary
self.department = department
Now creating an employee is much cleaner:
navas = Employee("Navas", 60000, "Marketing")
emmanuel = Employee("Emmanuel", 80000, "Engineering")
The __init__() method runs automatically when an object is created.
One thing that confused me at first was the difference between name and self.name.
self.name = name
The name on the right is the value passed into the method.
self.name is the attribute stored on the object.
So... What Exactly Is self?
self refers to the current object.
For example:
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
def calculate_bonus(self):
return self.salary * 0.10
When we do:
navas.calculate_bonus()
Python knows that self refers to navas.
That's why the method can access:
self.salary
and use Navas's salary.
This is one of the biggest mental shifts when learning classes:
Methods operate on the object's current state.
Classes Can Have Behavior
Classes aren't just containers for data.
They can also contain methods that define what an object can do.
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
def calculate_bonus(self):
return self.salary * 0.10
def give_raise(self, amount):
self.salary += amount
Now:
navas = Employee("Navas", 60000)
navas.give_raise(5000)
print(navas.salary)
print(navas.calculate_bonus())
Navas's salary changes to 65000, and his bonus is calculated using his new salary.
This is where classes become really useful. Data and the operations that work on that data can live together.
Class Attributes vs Instance Attributes
There are two important types of attributes you'll encounter.
Instance attributes
These belong to a specific object:
self.name = name
self.salary = salary
Navas and Emannuel can have different values.
Class attributes
These belong to the class itself:
class Employee:
company = "LuxDev"
Every employee can access it:
navas.company
emmanuel.company
If you change the class attribute:
Employee.company = "Microsoft"
objects that don't have their own company attribute will see the new value.
A simple rule:
Instance attributes describe the individual object. Class attributes describe something shared by the class.
Encapsulation: Controlling How Data Changes
Classes can also help us control how an object's data is modified.
For example, a bank account shouldn't allow a negative balance.
class BankAccount:
def __init__(self, balance):
self._balance = balance
def deposit(self, amount):
if amount > 0:
self._balance += amount
def withdraw(self, amount):
if 0 < amount <= self._balance:
self._balance -= amount
Instead of allowing anything to modify the balance directly, we provide methods that enforce rules.
Python also provides @property when you need more controlled access to attributes.
The bigger idea is:
Encapsulation means keeping an object's state consistent and controlling how it changes.
Inheritance: Reusing Existing Classes
What if we have different types of employees?
A manager is an employee, but a manager might have additional behavior.
class Manager(Employee):
def manage_team(self):
return f"{self.name} is managing the team."
Manager inherits from Employee.
This means a manager can use the attributes and methods already defined in Employee.
We can also override behavior:
class Manager(Employee):
def calculate_bonus(self):
return self.salary * 0.20
Now managers receive a different bonus calculation.
When we want to reuse the parent's implementation, we can use super():
class Manager(Employee):
def __init__(self, name, salary, team_size):
super().__init__(name, salary)
self.team_size = team_size
super().__init__() lets us reuse the parent's initialization logic instead of duplicating it.
Composition: Objects Can Contain Other Objects
Inheritance isn't the only way objects can relate to each other.
An employee can have an address.
class Address:
def __init__(self, city):
self.city = city
class Employee:
def __init__(self, name, address):
self.name = name
self.address = address
Now:
address = Address("Nairobi")
navas = Employee("Navas", address)
print(navas.address.city)
This is called composition.
A useful way to remember the difference:
Inheritance: Manager is an Employee.
Composition: Employee has an Address.
A Few Python Class Features Worth Knowing
Python classes come with several useful features.
Class methods
Use @classmethod when the method needs to work with the class itself:
@classmethod
def change_company(cls, new_company):
cls.company = new_company
Here, cls refers to the class.
Static methods
Use @staticmethod when the method doesn't need either the object or class:
@staticmethod
def is_valid_salary(salary):
return salary > 0
Dunder methods
These are methods with double underscores, such as:
__init__()
__str__()
__repr__()
__eq__()
They allow your objects to interact naturally with Python.
For example:
def __str__(self):
return f"Employee: {self.name}"
Now:
print(navas)
can produce a useful description instead of Python's default object representation.
Don't Use Classes for Everything
This was another important lesson for me.
Classes aren't automatically better than functions.
If you're simply converting Celsius to Fahrenheit:
def celsius_to_fahrenheit(celsius):
return (celsius * 9/5) + 32
A function is perfectly fine.
Classes become useful when you have related data and behavior that belong together.
The Mental Model That Finally Clicked
After working through classes, this is the mental model I now use:
- Class → blueprint
- Object → instance created from the blueprint
- Attributes → object's data/state
- Methods → object's behavior
-
self→ the current object -
__init__()→ initializes the object - Inheritance → "is-a" relationship
- Composition → "has-a" relationship
- Encapsulation → controlling how state changes
- Polymorphism → different objects responding to the same method differently
And the most important idea:
A class brings related data and behavior together into an object that represents something meaningful.
For me, that made Python classes much less intimidating.
The goal isn't to memorize every class feature. It's to recognize when a problem naturally calls for an object and then use classes to model it.
Once that starts clicking, Object-Oriented Programming becomes much easier to reason about.
Top comments (0)