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 self parameter 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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.