Classes and Objects in Object-Oriented Programming

Week 3: Monday Morning Session

Lesson Overview

Welcome to this deep dive into classes and objects - the fundamental building blocks of Object-Oriented Programming. Today we'll explore what classes and objects are, how they relate to each other, and how to create and use them effectively in Python. By the end of this session, you'll have a strong understanding of these core OOP concepts and be ready to apply them in your own code.

Understanding Classes and Objects

The Conceptual Foundation

To truly understand classes and objects, let's start with a conceptual foundation:

Real-world analogy: Think about the relationship between a blueprint for a house and the actual houses built from that blueprint:

  • The blueprint (class) defines what all houses of that type will have: the layout, number of rooms, dimensions, etc.
  • Each actual house (object) is a physical instance built according to that blueprint
  • You can build many houses (objects) from the same blueprint (class)
  • Each house (object) will have the same structure but can have different colors, furniture, and residents

Similarly, in programming:

Defining Classes in Python

In Python, we use the class keyword to define a class. Here's the basic syntax:

class ClassName:
    # Class attributes
    class_attribute = value
    
    # Constructor method
    def __init__(self, param1, param2):
        # Instance attributes
        self.attribute1 = param1
        self.attribute2 = param2
    
    # Instance methods
    def method_name(self, parameters):
        # Method body
        pass

Let's break down the components of a class:

Class Name

By convention, class names in Python use CamelCase notation (each word starts with a capital letter, with no underscores). For example: Person, BankAccount, ShoppingCart.

Class Attributes

Class attributes are variables that belong to the class itself. They're shared by all instances of the class. They're defined directly in the class body, outside of any methods.

The Constructor Method (__init__)

The __init__ method is a special method that gets called when you create a new object from the class. It's used to initialize the object with specific values. This is where you typically define instance attributes.

The self Parameter

The self parameter refers to the instance of the class that's being created or operated on. It's a convention in Python to name this parameter "self", though technically you could use any name (but don't - it would confuse other programmers reading your code).

Instance Attributes

Instance attributes are variables that belong to individual objects. Each object has its own copy of these attributes, which can have different values. They're typically defined in the __init__ method.

Instance Methods

Instance methods are functions defined inside a class that operate on instance attributes. They always take self as their first parameter, which allows them to access and modify the object's attributes.

Important distinction: Class attributes are shared by all instances, while instance attributes are unique to each instance. Think of class attributes as traits shared by all members of a species, and instance attributes as characteristics unique to individual organisms.

Creating and Using Objects

Once you've defined a class, you can create objects (instances) from it. This is called instantiation. Here's how you create an object in Python:

# Creating an object
object_name = ClassName(arguments)

When you create an object, Python automatically calls the __init__ method with the arguments you provide. This initializes the object with specific attribute values.

Accessing Attributes and Methods

Once you have an object, you can access its attributes and methods using dot notation:

# Accessing an attribute
value = object_name.attribute_name

# Calling a method
result = object_name.method_name(arguments)

Let's see a complete example with a Person class:

class Person:
    # Class attribute
    species = "Homo sapiens"
    
    # Constructor
    def __init__(self, name, age):
        # Instance attributes
        self.name = name
        self.age = age
    
    # Instance method
    def greet(self):
        return f"Hello, my name is {self.name} and I am {self.age} years old."
    
    # Another instance method
    def celebrate_birthday(self):
        self.age += 1
        return f"Happy birthday! {self.name} is now {self.age} years old."

# Creating objects
alice = Person("Alice", 30)
bob = Person("Bob", 25)

# Accessing attributes
print(alice.name)            # Output: Alice
print(bob.age)               # Output: 25
print(Person.species)        # Output: Homo sapiens (class attribute)

# Calling methods
print(alice.greet())         # Output: Hello, my name is Alice and I am 30 years old.
print(bob.celebrate_birthday())  # Output: Happy birthday! Bob is now 26 years old.
print(bob.age)               # Output: 26 (the attribute was modified by the method)

In this example:

Understanding Object Identity and References

When you create an object in Python, what you really have is a reference to that object. This has important implications for how objects behave when copied or compared.

Object Identity

Each object has a unique identity, which you can check with the id() function. Two different objects will have different identities, even if they have the same attribute values:

alice1 = Person("Alice", 30)
alice2 = Person("Alice", 30)

print(alice1 == alice2)  # Output: False (by default, == compares identity)
print(id(alice1))        # Output: some number
print(id(alice2))        # Output: a different number

Object References

When you assign an object to a new variable, you're not creating a new object - you're creating a new reference to the same object:

original = Person("Charlie", 40)
reference = original  # 'reference' now points to the same object as 'original'

print(original.name)    # Output: Charlie
print(reference.name)   # Output: Charlie

# Modifying through one reference affects the object for all references
reference.name = "Charles"
print(original.name)    # Output: Charles (changed because it's the same object)
print(id(original) == id(reference))  # Output: True (same identity)

Analogy: Think of an object like a house, and a variable like an address card. If you give someone a copy of your address card, you're not giving them a new house - you're giving them directions to the same house. If they go to that house and paint it blue, when you go home, your house will also be blue, because it's the same house.

Practical Example: Building a BankAccount Class

Let's work through a practical example of a BankAccount class to see how classes and objects work in a real-world context:

class BankAccount:
    # Class attribute
    bank_name = "Python National Bank"
    
    def __init__(self, account_holder, account_number, balance=0):
        # Instance attributes
        self.account_holder = account_holder
        self.account_number = account_number
        self.balance = balance
        self.transactions = []  # List to store transaction history
    
    def deposit(self, amount):
        if amount > 0:
            self.balance += amount
            self.transactions.append(f"Deposit: +${amount}")
            return f"Deposited ${amount}. New balance: ${self.balance}"
        else:
            return "Deposit amount must be positive"
    
    def withdraw(self, amount):
        if amount <= 0:
            return "Withdrawal amount must be positive"
        
        if amount > self.balance:
            return "Insufficient funds"
            
        self.balance -= amount
        self.transactions.append(f"Withdrawal: -${amount}")
        return f"Withdrew ${amount}. New balance: ${self.balance}"
    
    def get_balance(self):
        return f"Current balance: ${self.balance}"
    
    def get_transaction_history(self):
        if not self.transactions:
            return "No transactions yet"
        
        history = "Transaction history:\n"
        for transaction in self.transactions:
            history += f"- {transaction}\n"
        return history

# Creating bank account objects
alice_account = BankAccount("Alice Smith", "12345", 1000)
bob_account = BankAccount("Bob Johnson", "67890")

# Using the bank accounts
print(alice_account.get_balance())  # Output: Current balance: $1000
print(bob_account.get_balance())    # Output: Current balance: $0

print(alice_account.deposit(500))   # Output: Deposited $500. New balance: $1500
print(bob_account.deposit(100))     # Output: Deposited $100. New balance: $100

print(alice_account.withdraw(200))  # Output: Withdrew $200. New balance: $1300
print(bob_account.withdraw(150))    # Output: Insufficient funds

print(alice_account.get_transaction_history())
# Output:
# Transaction history:
# - Deposit: +$500
# - Withdrawal: -$200

This example demonstrates several important aspects of classes and objects:

Class Methods vs. Instance Methods

So far, we've focused on instance methods, which operate on individual objects. Python also supports class methods, which operate on the class itself rather than on instances.

Class Methods

Class methods are defined using the @classmethod decorator. They take cls (the class) as their first parameter instead of self (an instance):

class BankAccount:
    # Class attributes
    bank_name = "Python National Bank"
    accounts = 0  # Counter for the number of accounts
    
    def __init__(self, account_holder, account_number, balance=0):
        self.account_holder = account_holder
        self.account_number = account_number
        self.balance = balance
        self.transactions = []
        
        # Increment the account counter
        BankAccount.accounts += 1
    
    # Regular instance methods (as before)...
    
    # Class method
    @classmethod
    def get_total_accounts(cls):
        return f"Total accounts at {cls.bank_name}: {cls.accounts}"
    
    # Another class method - an alternative constructor
    @classmethod
    def create_joint_account(cls, holder1, holder2, account_number, balance=0):
        joint_name = f"{holder1} & {holder2}"
        return cls(joint_name, account_number, balance)

# Using instance methods
alice_account = BankAccount("Alice Smith", "12345", 1000)
bob_account = BankAccount("Bob Johnson", "67890")

# Using a class method
print(BankAccount.get_total_accounts())  # Output: Total accounts at Python National Bank: 2

# Using an alternative constructor
joint_account = BankAccount.create_joint_account("Alice Smith", "Bob Johnson", "54321", 2000)
print(joint_account.account_holder)  # Output: Alice Smith & Bob Johnson
print(joint_account.balance)         # Output: 2000

Class methods are useful for:

Analogy: If a class is a factory that produces objects, then instance methods are instructions for operating individual products, while class methods are instructions for operating the factory itself.

Static Methods

Python also supports static methods, which are associated with a class but don't operate on either the class or its instances. They're defined using the @staticmethod decorator:

class BankAccount:
    # Class attributes, constructor, instance methods, class methods as before...
    
    # Static method
    @staticmethod
    def validate_account_number(account_number):
        # Check if the account number is a 5-digit string
        return len(account_number) == 5 and account_number.isdigit()

# Using a static method
print(BankAccount.validate_account_number("12345"))  # Output: True
print(BankAccount.validate_account_number("ABC"))    # Output: False

Static methods are useful for utility functions that are related to the class's purpose but don't need to access or modify class or instance data.

When to use each type of method:

  • Use instance methods when you need to access or modify instance attributes
  • Use class methods when you need to access or modify class attributes, or create alternative constructors
  • Use static methods when you want to associate a utility function with a class but don't need to access any class or instance data

Classes in the Python Ecosystem

Understanding classes and objects is essential because they're used extensively throughout the Python ecosystem:

Built-in Classes

Many of Python's built-in data types are implemented as classes:

# These are equivalent
my_list1 = [1, 2, 3]
my_list2 = list([1, 2, 3])

# These are also equivalent
my_dict1 = {"name": "Alice", "age": 30}
my_dict2 = dict(name="Alice", age=30)

# These are methods on class instances
my_list1.append(4)
value = my_dict1.get("name")

Python Libraries

Most Python libraries and frameworks use classes to organize their code:

Real-world application: When developing a web application with Django (which we'll cover in Week 10), you'll define model classes that represent database tables. For example:

from django.db import models

class Product(models.Model):
    name = models.CharField(max_length=100)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    in_stock = models.BooleanField(default=True)
    
    def discount(self, percentage):
        """Apply a discount to the product price."""
        self.price = self.price * (1 - percentage / 100)
        self.save()
        
# Django will automatically create a database table based on this class
# Each row in the table will be an instance of the Product class

Best Practices for Classes and Objects

Naming Conventions

Design Principles

Common Mistakes to Avoid

Tip: When designing a class, ask yourself: "What is this class responsible for?" If your answer includes the word "and," you might need to split it into multiple classes.

Advanced Topic: Dunder Methods

Python classes can implement special methods called "dunder methods" (short for "double underscore") that allow custom classes to work with Python's built-in operations and functions.

We've already seen __init__, but there are many others:

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    # String representation for developers (debugging)
    def __repr__(self):
        return f"Point({self.x}, {self.y})"
    
    # String representation for users
    def __str__(self):
        return f"({self.x}, {self.y})"
    
    # Enable + operator
    def __add__(self, other):
        return Point(self.x + other.x, self.y + other.y)
    
    # Enable == operator
    def __eq__(self, other):
        if not isinstance(other, Point):
            return False
        return self.x == other.x and self.y == other.y
    
    # Enable < operator (for sorting)
    def __lt__(self, other):
        return (self.x**2 + self.y**2) < (other.x**2 + other.y**2)
    
    # Make the object callable
    def __call__(self, z=0):
        return (self.x, self.y, z)

# Create points
p1 = Point(1, 2)
p2 = Point(3, 4)

# String representations
print(repr(p1))  # Output: Point(1, 2)
print(p1)        # Output: (1, 2)

# Operators
p3 = p1 + p2     # Uses __add__
print(p3)        # Output: (4, 6)

# Equality
print(p1 == p2)  # Output: False (uses __eq__)
print(p1 == Point(1, 2))  # Output: True

# Comparison
points = [Point(3, 4), Point(1, 2), Point(5, 1)]
sorted_points = sorted(points)  # Uses __lt__
print([str(p) for p in sorted_points])  # Output: ['(1, 2)', '(5, 1)', '(3, 4)']

# Callable
coordinates = p1()  # Uses __call__
print(coordinates)  # Output: (1, 2, 0)

These special methods let your custom classes integrate seamlessly with Python's syntax and built-in functions. We'll cover these in more detail later in the course.

Hands-on Exercise: Building a Simple Class

Let's consolidate our understanding with a hands-on exercise. Try implementing a Rectangle class with the following requirements:

  1. It should have width and height attributes
  2. It should have methods to calculate the area and perimeter
  3. It should be able to determine if it's a square
  4. It should have a method to return a scaled version of itself
  5. It should have appropriate string representation methods

Solution:

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height
    
    def area(self):
        return self.width * self.height
    
    def perimeter(self):
        return 2 * (self.width + self.height)
    
    def is_square(self):
        return self.width == self.height
    
    def scale(self, factor):
        return Rectangle(self.width * factor, self.height * factor)
    
    def __str__(self):
        return f"Rectangle(width={self.width}, height={self.height})"
    
    def __repr__(self):
        return f"Rectangle({self.width}, {self.height})"

# Testing the Rectangle class
rect1 = Rectangle(5, 3)
print(f"Area: {rect1.area()}")         # Output: Area: 15
print(f"Perimeter: {rect1.perimeter()}")  # Output: Perimeter: 16
print(f"Is square: {rect1.is_square()}")  # Output: Is square: False

rect2 = Rectangle(4, 4)
print(f"Is square: {rect2.is_square()}")  # Output: Is square: True

rect3 = rect1.scale(2)
print(rect3)  # Output: Rectangle(width=10, height=6)

Conclusion

In this session, we've covered the fundamentals of classes and objects in Python:

Classes and objects are powerful tools for organizing code and modeling real-world entities. As you continue your journey in Object-Oriented Programming, you'll build on these fundamentals to create more complex and powerful systems.

In the next session, we'll explore more advanced OOP concepts, including inheritance, polymorphism, and abstraction, which will allow you to create relationships between classes and build more sophisticated object-oriented designs.

Practice Exercise

Try designing and implementing a class for another real-world entity of your choice. Some ideas:

  • A Book class with title, author, and methods to track reading progress
  • A Student class with name, ID, and methods to track grades
  • A ShoppingCart class with methods to add/remove items and calculate totals

Remember to think about what attributes and methods would be appropriate, and consider whether any special methods would make your class more intuitive to use.

Additional Resources