Python Classes and Objects: OOP Fundamentals
Object-oriented programming organizes code around objects that combine data and the functions that operate on that data. Instead of writing procedural code where data and functions exist separately,...
Key Insights
- Classes are blueprints that define structure and behavior, while objects are concrete instances created from those blueprints—understanding this distinction is fundamental to writing maintainable Python code.
- The
selfparameter isn’t magic; it’s simply a reference to the instance calling the method, allowing you to access and modify that specific object’s data. - Encapsulation through properties and naming conventions prevents external code from breaking your class internals, making your codebase more resilient to change.
Introduction to Object-Oriented Programming
Object-oriented programming organizes code around objects that combine data and the functions that operate on that data. Instead of writing procedural code where data and functions exist separately, OOP bundles them together into cohesive units.
Python fully embraces OOP while remaining flexible enough for other paradigms. Every value in Python is actually an object—even integers and strings have methods you can call. When you write "hello".upper(), you’re calling a method on a string object.
The relationship between classes and objects is straightforward: a class is a blueprint, and objects are instances built from that blueprint. Think of a class as an architectural plan for a house. The plan defines what rooms exist, their layout, and how they connect. The actual houses built from those plans are the objects—each can have different paint colors, furniture, and occupants while sharing the same fundamental structure.
class Car:
def __init__(self, make, model, year):
self.make = make
self.model = model
self.year = year
self.odometer = 0
# Create two different car objects from the same blueprint
my_car = Car("Toyota", "Camry", 2020)
your_car = Car("Honda", "Civic", 2019)
print(f"{my_car.make} {my_car.model}") # Toyota Camry
print(f"{your_car.make} {your_car.model}") # Honda Civic
Creating Your First Class
Class definition starts with the class keyword followed by the class name. By convention, class names use PascalCase. The __init__ method is your constructor—it runs automatically when you create a new instance.
The self parameter appears in every instance method. It’s not a keyword; you could technically name it anything, but self is the universal convention. When you call my_car.some_method(), Python automatically passes my_car as the first argument to some_method(self). This is how methods know which instance they’re working with.
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
self.transaction_count = 0
# Create instances with different initial values
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob") # Uses default balance of 0
print(account1.owner) # Alice
print(account1.balance) # 1000
print(account2.balance) # 0
Each instance gets its own namespace for attributes. When you set self.owner = owner, you’re creating an attribute on that specific instance. Modifying account1.balance doesn’t affect account2.balance.
Instance Methods and Behavior
Methods define what objects can do. They’re functions defined inside a class that operate on instance data. The key difference between attributes and methods: attributes hold data, methods define behavior.
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
if amount > 0:
self.balance += amount
return True
return False
def withdraw(self, amount):
if 0 < amount <= self.balance:
self.balance -= amount
return True
return False
def get_balance(self):
return self.balance
account = BankAccount("Charlie", 500)
account.deposit(200)
print(account.get_balance()) # 700
if account.withdraw(100):
print("Withdrawal successful")
print(account.get_balance()) # 600
Methods can modify instance state (like deposit and withdraw) or simply return computed values without changing anything (like get_balance). Design your methods to do one thing well, and choose clear names that indicate whether they modify state or just retrieve information.
Class vs. Instance Attributes
Class attributes are shared across all instances. They’re defined directly in the class body, outside any method. Instance attributes are unique to each object and defined in __init__ or other methods using self.
class Dog:
species = "Canis familiaris" # Class attribute
total_dogs = 0 # Class attribute for counting
def __init__(self, name, age):
self.name = name # Instance attribute
self.age = age # Instance attribute
Dog.total_dogs += 1
dog1 = Dog("Buddy", 3)
dog2 = Dog("Max", 5)
print(dog1.species) # Canis familiaris
print(dog2.species) # Canis familiaris
print(Dog.total_dogs) # 2
print(dog1.name) # Buddy
print(dog2.name) # Max
Use class attributes for data that’s truly shared across all instances—constants, configuration values, or counters. Use instance attributes for data that varies per object. Be careful when modifying class attributes; changes affect all instances that haven’t overridden that attribute.
Special Methods (Magic Methods)
Special methods (dunder methods) let your objects integrate with Python’s built-in operations. They’re surrounded by double underscores and Python calls them automatically in specific situations.
The __str__ method defines how your object appears when printed or converted to a string. The __repr__ method provides an unambiguous representation, ideally something that could recreate the object.
class Book:
def __init__(self, title, author, pages):
self.title = title
self.author = author
self.pages = pages
def __str__(self):
return f'"{self.title}" by {self.author}'
def __repr__(self):
return f"Book('{self.title}', '{self.author}', {self.pages})"
def __len__(self):
return self.pages
def __eq__(self, other):
if not isinstance(other, Book):
return False
return (self.title == other.title and
self.author == other.author)
book1 = Book("1984", "George Orwell", 328)
book2 = Book("1984", "George Orwell", 328)
print(book1) # "1984" by George Orwell
print(repr(book1)) # Book('1984', 'George Orwell', 328)
print(len(book1)) # 328
print(book1 == book2) # True
Implementing these methods makes your objects feel like native Python types. Users can print them naturally, compare them intuitively, and use them with built-in functions.
Encapsulation and Private Attributes
Encapsulation means hiding internal implementation details and exposing only what’s necessary. Python uses naming conventions rather than strict access controls. A single leading underscore (_attribute) signals “internal use”—a convention telling other developers not to access it directly. Double underscores (__attribute) trigger name mangling, making the attribute harder to access from outside.
The @property decorator lets you add logic around attribute access without changing how users interact with your class.
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Temperature below absolute zero")
self._celsius = value
@property
def fahrenheit(self):
return self._celsius * 9/5 + 32
@fahrenheit.setter
def fahrenheit(self, value):
self.celsius = (value - 32) * 5/9
temp = Temperature(25)
print(temp.celsius) # 25
print(temp.fahrenheit) # 77.0
temp.fahrenheit = 86
print(temp.celsius) # 30.0
# This raises ValueError
# temp.celsius = -300
Properties let you start with simple attributes and add validation or computation later without breaking existing code. This is more Pythonic than writing explicit getter and setter methods.
Practical Example and Best Practices
Let’s build a complete Student class that incorporates everything we’ve covered:
class Student:
school_name = "Python Academy" # Class attribute
student_count = 0
def __init__(self, name, student_id):
self.name = name
self.student_id = student_id
self._grades = []
Student.student_count += 1
def add_grade(self, grade):
if 0 <= grade <= 100:
self._grades.append(grade)
return True
return False
@property
def gpa(self):
if not self._grades:
return 0.0
return sum(self._grades) / len(self._grades)
@property
def grade_count(self):
return len(self._grades)
def __str__(self):
return f"{self.name} (ID: {self.student_id})"
def __repr__(self):
return f"Student('{self.name}', '{self.student_id}')"
def __eq__(self, other):
if not isinstance(other, Student):
return False
return self.student_id == other.student_id
def __len__(self):
return len(self._grades)
# Usage
student1 = Student("Emma Watson", "S12345")
student2 = Student("Daniel Radcliffe", "S12346")
student1.add_grade(95)
student1.add_grade(87)
student1.add_grade(92)
print(student1) # Emma Watson (ID: S12345)
print(f"GPA: {student1.gpa:.2f}") # GPA: 91.33
print(f"Total grades: {len(student1)}") # Total grades: 3
print(f"Students enrolled: {Student.student_count}") # Students enrolled: 2
Key principles to follow: Keep classes focused on a single responsibility. Use meaningful names for methods and attributes. Prefer composition over complex inheritance hierarchies. Use properties when you need computed values or validation. Make your objects work naturally with Python’s built-in functions through magic methods.
Start simple and add complexity only when needed. A class with just __init__ and a few methods is often better than an over-engineered solution with unnecessary abstractions. Write classes that solve real problems in your codebase, not theoretical ones.