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:
- Class: A blueprint or template that defines the structure and behavior for a category of objects
- Object: A specific instance created from a class
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:
- A class defines a data structure with attributes (variables) and methods (functions)
- An object is a concrete instance of the class with its own unique data
- You can create many objects from the same class
- Each object has the same structure but can contain different data values
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:
- We define a
Personclass with one class attribute (species) and two instance attributes (nameandage) - We create two Person objects:
aliceandbob - We access their attributes using dot notation
- We call methods on the objects, which can both return values and modify the object's state
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:
- Initialization with parameters: The
__init__method sets up the account with initial values, including a default value forbalance - Data validation: The methods check for valid inputs before performing operations
- State management: The object maintains its state (balance, transaction history) between method calls
- Encapsulation: The account's data and the operations on that data are bundled together in a single unit
- Multiple instances: We can create multiple accounts, each with its own unique data
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:
- Operations that involve class attributes rather than instance attributes
- Creating alternative constructors
- Factory methods that return instances of the class
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:
list,dict,set,str, etc. are all classes- When you call
my_list = [1, 2, 3], you're creating an instance of thelistclass - Methods like
append(),pop(), etc. are instance methods of these 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:
- Flask:
app = Flask(__name__)creates an instance of theFlaskclass - Django: Models are classes that inherit from
django.db.models.Model - Pandas:
DataFrameis a class with methods likehead(),describe(), etc. - Pygame:
Surface,Rect, etc. are classes with methods for game development
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
- Class names should use CamelCase:
BankAccount,Person,ShoppingCart - Method and attribute names should use snake_case:
get_balance(),account_number - Private attributes and methods (not meant to be accessed directly) should be prefixed with an underscore:
_balance,_calculate_interest()
Design Principles
- Single Responsibility Principle: Each class should have one reason to change. A class should do one thing and do it well.
- Encapsulation: Keep internal details hidden. Provide a clean public interface.
- DRY (Don't Repeat Yourself): Avoid duplicating code across different methods or classes.
- YAGNI (You Aren't Gonna Need It): Don't add features until they're actually needed.
Common Mistakes to Avoid
- Too many responsibilities: Classes should have a single, well-defined purpose.
- Overexposing internals: Don't make all attributes public if they don't need to be.
- Ignoring class relationships: Think about how your classes relate to each other.
- Reinventing the wheel: Use Python's built-in classes and libraries when appropriate.
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:
- It should have
widthandheightattributes - It should have methods to calculate the area and perimeter
- It should be able to determine if it's a square
- It should have a method to return a scaled version of itself
- 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:
- What classes and objects are and how they relate to each other
- How to define classes with attributes and methods
- How to create and use objects
- The difference between class and instance attributes and methods
- How to work with special methods
- Best practices for designing and implementing classes
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
Bookclass with title, author, and methods to track reading progress - A
Studentclass with name, ID, and methods to track grades - A
ShoppingCartclass 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
- Python Official Documentation on Classes
- Real Python: OOP in Python 3
- Real Python: String Representations
- Python Special Method Names
- Recommended Book: "Fluent Python" by Luciano Ramalho (Chapters on Classes and Objects)