Lesson Overview
Welcome to our exploration of creating and using custom classes in Python! Today, we'll learn how to design, implement, and use your own classes to model real-world entities and solve practical problems. By the end of this session, you'll be able to create well-designed classes that encapsulate data and behavior in a clean, reusable way.
Why Create Custom Classes?
Before diving into the technical details, let's understand why we create custom classes in the first place:
- Organization: Classes help organize related data and functions together
- Encapsulation: They allow you to hide complex implementation details
- Reusability: Well-designed classes can be reused across different projects
- Modeling: They help model real-world entities and relationships
- Maintainability: Classes make code easier to update and maintain
Real-world analogy: Think of a class like a blueprint for a house. The blueprint defines what all houses of that type will have (rooms, doors, windows, etc.), but it's not a house itself. From a single blueprint, you can build many actual houses, each with its own address, occupants, and unique characteristics. Similarly, a class defines a template, and objects are the actual instances created from that template.
Class Design Process
Designing good classes is both an art and a science. Here's a structured approach to class design:
1. Identify the Purpose
Start by clearly defining what your class represents and what problem it solves:
- What real-world entity or concept does it model?
- What responsibilities will it have?
- How will it be used in your program?
2. Define Attributes
Identify the data your class needs to store:
- What properties describe the entity?
- Which attributes are essential vs. optional?
- What data types are appropriate for each attribute?
3. Define Methods
Determine what behaviors your class should have:
- What operations can be performed on the entity?
- What actions can the entity perform?
- What information needs to be computed or retrieved?
4. Establish Relationships
Consider how your class relates to other classes:
- "Has-a" relationships (composition)
- "Is-a" relationships (inheritance)
- "Uses-a" relationships (dependencies)
Design principle: Follow the Single Responsibility Principle (SRP): a class should have only one reason to change. If you find your class doing too many different things, consider splitting it into multiple classes.
Example: Designing a Book Class
Let's apply this process to design a Book class:
- Purpose: Represent a book in a library management system
- Attributes: title, author, ISBN, publication year, number of pages, current status (available, checked out, etc.)
- Methods: check_out(), return_book(), get_status(), display_info()
- Relationships: A Book belongs to a Library (composition), may have a current Borrower (association)
Creating Your First Class
Let's implement our Book class in Python:
class Book:
"""A class representing a book in a library system."""
def __init__(self, title, author, isbn, pub_year, pages):
"""Initialize a new Book with the provided attributes."""
self.title = title
self.author = author
self.isbn = isbn
self.publication_year = pub_year
self.pages = pages
self.status = "available"
self.borrower = None
def display_info(self):
"""Return a formatted string with book information."""
return f"'{self.title}' by {self.author} ({self.publication_year}), ISBN: {self.isbn}"
def check_out(self, borrower_name):
"""Check out the book to a borrower if it's available."""
if self.status == "available":
self.status = "checked out"
self.borrower = borrower_name
return f"'{self.title}' has been checked out to {borrower_name}"
else:
return f"'{self.title}' is not available for checkout"
def return_book(self):
"""Return the book to the library."""
if self.status == "checked out":
self.status = "available"
previous_borrower = self.borrower
self.borrower = None
return f"'{self.title}' has been returned by {previous_borrower}"
else:
return f"'{self.title}' was not checked out"
def get_status(self):
"""Return the current status of the book."""
if self.status == "checked out":
return f"'{self.title}' is currently checked out to {self.borrower}"
else:
return f"'{self.title}' is available"
Let's break down the key components of our class:
Class Docstring
The triple-quoted string right after the class definition provides documentation about the class's purpose and usage. This is a best practice for making your code self-documenting.
Constructor Method (__init__)
The __init__ method is a special method that's called when you create a new instance of the class. It initializes the object's attributes with the values provided as arguments. The self parameter refers to the instance being created.
Instance Attributes
These are the variables prefixed with self. in the constructor. Each instance of the class will have its own copy of these attributes with potentially different values.
Methods
These are functions defined inside the class that operate on the instance's attributes. Each method takes self as its first parameter, allowing it to access and modify the instance's attributes.
Naming conventions: In Python, class names typically use CamelCase (e.g., Book, LibraryMember), while method and attribute names use snake_case (e.g., check_out, publication_year).
Using Your Custom Class
Now that we've created our Book class, let's see how to use it in code:
# Creating instances (objects) of the Book class
book1 = Book("The Great Gatsby", "F. Scott Fitzgerald", "9780743273565", 1925, 180)
book2 = Book("To Kill a Mockingbird", "Harper Lee", "9780061120084", 1960, 281)
# Accessing attributes
print(book1.title) # Output: The Great Gatsby
print(book2.author) # Output: Harper Lee
# Calling methods
print(book1.display_info()) # Output: 'The Great Gatsby' by F. Scott Fitzgerald (1925), ISBN: 9780743273565
print(book2.get_status()) # Output: 'To Kill a Mockingbird' is available
# Modifying state through methods
print(book1.check_out("Alice")) # Output: 'The Great Gatsby' has been checked out to Alice
print(book1.get_status()) # Output: 'The Great Gatsby' is currently checked out to Alice
print(book1.return_book()) # Output: 'The Great Gatsby' has been returned by Alice
print(book1.get_status()) # Output: 'The Great Gatsby' is available
# Trying to return a book that wasn't checked out
print(book2.return_book()) # Output: 'To Kill a Mockingbird' was not checked out
This example demonstrates the key operations when working with custom classes:
Creating Instances
To create a new object from your class, you call the class name like a function, passing any required arguments to the constructor. This process is called "instantiation."
Accessing Attributes
You can access an object's attributes using dot notation: object.attribute.
Calling Methods
Similarly, you can call an object's methods using dot notation: object.method(arguments). Note that you don't need to pass anything for the self parameter - Python handles that automatically.
Maintaining State
Objects maintain their state (the values of their attributes) between method calls. This allows them to model real-world entities that have persistent state over time.
Adding Special Methods
Python provides special methods (also called "dunder" methods for their double underscore naming pattern) that allow your classes to integrate with Python's built-in functionality. Let's enhance our Book class with some of these:
class Book:
"""A class representing a book in a library system."""
def __init__(self, title, author, isbn, pub_year, pages):
"""Initialize a new Book with the provided attributes."""
self.title = title
self.author = author
self.isbn = isbn
self.publication_year = pub_year
self.pages = pages
self.status = "available"
self.borrower = None
def __str__(self):
"""Return a string representation of the book for end users."""
return f"'{self.title}' by {self.author} ({self.publication_year})"
def __repr__(self):
"""Return a string representation of the book for developers."""
return f"Book('{self.title}', '{self.author}', '{self.isbn}', {self.publication_year}, {self.pages})"
def __eq__(self, other):
"""Define equality comparison based on ISBN (two books with the same ISBN are considered equal)."""
if not isinstance(other, Book):
return False
return self.isbn == other.isbn
def __lt__(self, other):
"""Define less-than comparison based on title (for sorting books alphabetically)."""
if not isinstance(other, Book):
return NotImplemented
return self.title < other.title
def __len__(self):
"""Return the number of pages in the book."""
return self.pages
# ... other methods from the previous example ...
These special methods enable the following functionality:
# Using the enhanced Book class
book1 = Book("The Great Gatsby", "F. Scott Fitzgerald", "9780743273565", 1925, 180)
book2 = Book("To Kill a Mockingbird", "Harper Lee", "9780061120084", 1960, 281)
book3 = Book("The Great Gatsby", "F. Scott Fitzgerald", "9780743273565", 2003, 190) # Different edition
# __str__ for user-friendly string representation
print(book1) # Output: 'The Great Gatsby' by F. Scott Fitzgerald (1925)
# __repr__ for developer-friendly string representation
print(repr(book1)) # Output: Book('The Great Gatsby', 'F. Scott Fitzgerald', '9780743273565', 1925, 180)
# __eq__ for equality comparison based on ISBN
print(book1 == book3) # Output: True (same ISBN)
print(book1 == book2) # Output: False (different ISBN)
# __lt__ for comparisons (allows sorting)
books = [book2, book1, book3]
for book in sorted(books):
print(book)
# Output:
# 'The Great Gatsby' by F. Scott Fitzgerald (1925)
# 'The Great Gatsby' by F. Scott Fitzgerald (2003)
# 'To Kill a Mockingbird' by Harper Lee (1960)
# __len__ for getting the number of pages
print(len(book2)) # Output: 281
Design principle: Special methods make your classes behave like Python's built-in types, creating a more intuitive interface for users of your class.
Class Composition: Building Complex Classes
Real-world programs often require more complex classes that interact with each other. Let's see how we can build a library system using composition:
class Book:
"""A class representing a book in a library system."""
def __init__(self, title, author, isbn, pub_year, pages):
self.title = title
self.author = author
self.isbn = isbn
self.publication_year = pub_year
self.pages = pages
self.status = "available"
self.borrower = None
# ... other Book methods ...
class Member:
"""A class representing a library member."""
def __init__(self, name, member_id):
self.name = name
self.member_id = member_id
self.borrowed_books = [] # List to store borrowed books
def borrow_book(self, book):
"""Borrow a book if it's available."""
result = book.check_out(self.name)
if book.status == "checked out":
self.borrowed_books.append(book)
return result
def return_book(self, book):
"""Return a borrowed book."""
if book in self.borrowed_books:
result = book.return_book()
self.borrowed_books.remove(book)
return result
else:
return f"{self.name} did not borrow '{book.title}'"
def get_borrowed_books(self):
"""Return a list of books currently borrowed by this member."""
if not self.borrowed_books:
return f"{self.name} has not borrowed any books"
books_list = "\n".join(f"- {book.title}" for book in self.borrowed_books)
return f"{self.name}'s borrowed books:\n{books_list}"
class Library:
"""A class representing a library with books and members."""
def __init__(self, name, location):
self.name = name
self.location = location
self.books = {} # Dictionary to store books by ISBN
self.members = {} # Dictionary to store members by ID
def add_book(self, book):
"""Add a book to the library collection."""
self.books[book.isbn] = book
return f"Added '{book.title}' to {self.name} Library"
def add_member(self, member):
"""Register a new member with the library."""
self.members[member.member_id] = member
return f"Added {member.name} as a member of {self.name} Library"
def find_book_by_title(self, title):
"""Find books that contain the given title."""
matching_books = [book for book in self.books.values()
if title.lower() in book.title.lower()]
return matching_books
def checkout_book(self, isbn, member_id):
"""Process a book checkout from one central method."""
if isbn not in self.books:
return "Book not found"
if member_id not in self.members:
return "Member not found"
book = self.books[isbn]
member = self.members[member_id]
return member.borrow_book(book)
def return_book(self, isbn, member_id):
"""Process a book return from one central method."""
if isbn not in self.books:
return "Book not found"
if member_id not in self.members:
return "Member not found"
book = self.books[isbn]
member = self.members[member_id]
return member.return_book(book)
def get_available_books(self):
"""Return a list of all available books."""
available_books = [book for book in self.books.values()
if book.status == "available"]
return available_books
def get_checked_out_books(self):
"""Return a list of all checked out books."""
checked_out_books = [book for book in self.books.values()
if book.status == "checked out"]
return checked_out_books
Now let's see how these classes work together:
# Create a library
city_library = Library("City Public Library", "123 Main St.")
# Create some books
book1 = Book("The Great Gatsby", "F. Scott Fitzgerald", "9780743273565", 1925, 180)
book2 = Book("To Kill a Mockingbird", "Harper Lee", "9780061120084", 1960, 281)
book3 = Book("1984", "George Orwell", "9780451524935", 1949, 328)
# Add books to the library
city_library.add_book(book1)
city_library.add_book(book2)
city_library.add_book(book3)
# Create some members
alice = Member("Alice Smith", "A123")
bob = Member("Bob Johnson", "B456")
# Register members with the library
city_library.add_member(alice)
city_library.add_member(bob)
# Check out books
print(city_library.checkout_book("9780743273565", "A123")) # Alice borrows The Great Gatsby
print(city_library.checkout_book("9780061120084", "B456")) # Bob borrows To Kill a Mockingbird
# See what books each member has borrowed
print(alice.get_borrowed_books())
print(bob.get_borrowed_books())
# Try to check out a book that's already checked out
print(city_library.checkout_book("9780743273565", "B456")) # Should fail
# Return books
print(city_library.return_book("9780743273565", "A123")) # Alice returns The Great Gatsby
# See what books are available
available_books = city_library.get_available_books()
print(f"Available books: {len(available_books)}")
for book in available_books:
print(f"- {book.title}")
This example demonstrates several important concepts:
Composition
The Library class contains collections of Book and Member objects. This is an example of composition - building complex classes by combining simpler ones.
Object Relationships
These classes form a network of relationships that model a real-world library system:
- A
Libraryhas manyBooks andMembers (one-to-many relationship) - A
Membercan borrow multipleBooks (many-to-many relationship) - A
Bookcan be borrowed by at most oneMemberat a time (one-to-one relationship)
Delegation
The Library class delegates certain operations to the appropriate objects. For example, when checking out a book, it delegates to the Member's borrow_book method, which in turn calls the Book's check_out method.
Design principle: Each class should have a single responsibility, and complex operations should be broken down into simpler ones that are delegated to the appropriate objects.
Private Attributes and Encapsulation
In object-oriented programming, encapsulation means hiding the internal details of an object and providing a controlled interface for interacting with it. Python uses naming conventions rather than strict access control for this purpose:
class BankAccount:
"""A class representing a bank account."""
def __init__(self, account_number, owner_name, balance=0):
self.account_number = account_number
self.owner_name = owner_name
self._balance = balance # Protected attribute (convention)
self.__transaction_log = [] # Private attribute (name mangling)
self.__log_transaction("Account created", balance)
def deposit(self, amount):
"""Deposit money into the account."""
if amount <= 0:
return "Deposit amount must be positive"
self._balance += amount
self.__log_transaction("Deposit", amount)
return f"Deposited ${amount}. New balance: ${self._balance}"
def withdraw(self, amount):
"""Withdraw money from the account if sufficient funds exist."""
if amount <= 0:
return "Withdrawal amount must be positive"
if amount > self._balance:
return "Insufficient funds"
self._balance -= amount
self.__log_transaction("Withdrawal", -amount)
return f"Withdrew ${amount}. New balance: ${self._balance}"
def get_balance(self):
"""Get the current account balance."""
return self._balance
def __log_transaction(self, transaction_type, amount):
"""Private method to log transactions."""
import datetime
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self.__transaction_log.append(f"{timestamp}: {transaction_type} ${abs(amount)}")
def get_transaction_history(self):
"""Get a copy of the transaction history."""
return self.__transaction_log.copy() # Return a copy to prevent modification
This example demonstrates Python's approach to encapsulation:
Protected Attributes (Single Underscore)
Attributes prefixed with a single underscore (e.g., _balance) are considered "protected" by convention. This signals to other developers that the attribute is intended for internal use, but it doesn't actually prevent access.
Private Attributes (Double Underscore)
Attributes prefixed with a double underscore (e.g., __transaction_log) undergo "name mangling" - Python renames them internally to prevent accidental access from subclasses. However, they're still accessible if you know the mangled name (_BankAccount__transaction_log).
Public Interface
The class provides methods like deposit(), withdraw(), and get_balance() as its public interface. These methods enforce rules (like positive deposit amounts) and maintain the internal state consistently.
Python philosophy: "We're all consenting adults here." Python relies on conventions and documentation rather than strict access control, trusting developers to respect the intended usage patterns.
Using the BankAccount class:
# Create a bank account
account = BankAccount("12345", "Alice Smith", 1000)
# Use the public interface
print(account.deposit(500))
print(account.withdraw(200))
print(f"Current balance: ${account.get_balance()}")
# View transaction history
for transaction in account.get_transaction_history():
print(transaction)
# Direct access to protected attribute is possible but discouraged
print(account._balance) # Works, but not recommended
# Trying to access private attribute directly will fail
try:
print(account.__transaction_log)
except AttributeError as e:
print(f"Error: {e}")
# But can still access via mangled name (not recommended)
print(account._BankAccount__transaction_log)
Properties for Controlled Attribute Access
Python's property decorators provide a more elegant way to control attribute access while maintaining a clean interface:
class Circle:
"""A class representing a circle."""
def __init__(self, radius):
self._radius = None # Initialize with None
self.radius = radius # Use the setter
@property
def radius(self):
"""Getter for radius."""
return self._radius
@radius.setter
def radius(self, value):
"""Setter for radius with validation."""
if not isinstance(value, (int, float)):
raise TypeError("Radius must be a number")
if value <= 0:
raise ValueError("Radius must be positive")
self._radius = value
@property
def diameter(self):
"""Calculate diameter on-the-fly."""
return self.radius * 2
@property
def area(self):
"""Calculate area on-the-fly."""
import math
return math.pi * self.radius ** 2
@property
def circumference(self):
"""Calculate circumference on-the-fly."""
import math
return 2 * math.pi * self.radius
Using the Circle class with properties:
# Create a circle
circle = Circle(5)
# Access properties like regular attributes
print(f"Radius: {circle.radius}")
print(f"Diameter: {circle.diameter}")
print(f"Area: {circle.area:.2f}")
print(f"Circumference: {circle.circumference:.2f}")
# Use the setter with validation
try:
circle.radius = -10 # This will raise an exception
except ValueError as e:
print(f"Error: {e}")
# Update with a valid value
circle.radius = 7.5
print(f"New radius: {circle.radius}")
print(f"New area: {circle.area:.2f}")
# Can't set computed properties directly
try:
circle.diameter = 20 # This will raise an AttributeError
except AttributeError as e:
print(f"Error: {e}")
Properties offer several advantages:
- Clean Interface: Users access properties like normal attributes, without calling methods
- Validation: Setters can validate inputs before updating attributes
- Computed Values: Getters can calculate values on-the-fly instead of storing them
- Backward Compatibility: You can start with public attributes and later add properties without changing the interface
Best practice: Use properties when you need to control attribute access, perform validation, or compute values dynamically, while maintaining a clean attribute-like interface.
Class Methods and Static Methods
In addition to regular instance methods, Python classes can have class methods and static methods:
class Temperature:
"""A class for working with temperatures in different scales."""
# Class variables
scales = {"celsius", "fahrenheit", "kelvin"}
conversion_formulas = {
"c_to_f": lambda c: c * 9/5 + 32,
"f_to_c": lambda f: (f - 32) * 5/9,
"c_to_k": lambda c: c + 273.15,
"k_to_c": lambda k: k - 273.15
}
def __init__(self, value, scale="celsius"):
if scale.lower() not in self.scales:
raise ValueError(f"Unknown scale '{scale}'. Must be one of: {', '.join(self.scales)}")
self.value = value
self.scale = scale.lower()
def convert_to(self, target_scale):
"""Convert temperature to another scale."""
target_scale = target_scale.lower()
if target_scale not in self.scales:
raise ValueError(f"Unknown scale '{target_scale}'. Must be one of: {', '.join(self.scales)}")
if self.scale == target_scale:
return Temperature(self.value, self.scale)
# Convert to celsius as an intermediate step if needed
celsius_value = self.value
if self.scale == "fahrenheit":
celsius_value = self.conversion_formulas["f_to_c"](self.value)
elif self.scale == "kelvin":
celsius_value = self.conversion_formulas["k_to_c"](self.value)
# Convert from celsius to target scale
if target_scale == "celsius":
return Temperature(celsius_value, "celsius")
elif target_scale == "fahrenheit":
return Temperature(self.conversion_formulas["c_to_f"](celsius_value), "fahrenheit")
elif target_scale == "kelvin":
return Temperature(self.conversion_formulas["c_to_k"](celsius_value), "kelvin")
def __str__(self):
"""Return string representation of the temperature."""
symbols = {"celsius": "°C", "fahrenheit": "°F", "kelvin": "K"}
return f"{self.value}{symbols[self.scale]}"
@classmethod
def add_scale(cls, scale_name, c_to_scale_func, scale_to_c_func):
"""Add a new temperature scale to the class."""
scale_name = scale_name.lower()
cls.scales.add(scale_name)
cls.conversion_formulas[f"c_to_{scale_name}"] = c_to_scale_func
cls.conversion_formulas[f"{scale_name}_to_c"] = scale_to_c_func
return f"Added {scale_name} scale"
@staticmethod
def is_freezing(temp_value, scale="celsius"):
"""Check if a temperature is at or below freezing point of water."""
freezing_points = {"celsius": 0, "fahrenheit": 32, "kelvin": 273.15}
if scale.lower() not in freezing_points:
raise ValueError(f"Unknown scale '{scale}'")
return temp_value <= freezing_points[scale.lower()]
This example demonstrates:
Instance Methods
Regular methods like convert_to() that operate on an instance through the self parameter.
Class Methods
Methods decorated with @classmethod that operate on the class itself through the cls parameter. They can access and modify class variables but not instance attributes.
Static Methods
Methods decorated with @staticmethod that don't operate on either the instance or the class. They're included in the class because they're related to its purpose but don't need access to its data.
Using the Temperature class with these different methods:
# Using instance methods
temp = Temperature(25, "celsius")
print(f"Original temperature: {temp}")
fahrenheit = temp.convert_to("fahrenheit")
print(f"In Fahrenheit: {fahrenheit}")
kelvin = temp.convert_to("kelvin")
print(f"In Kelvin: {kelvin}")
# Using class methods to add a new scale (Rankine)
Temperature.add_scale(
"rankine",
lambda c: (c + 273.15) * 9/5, # Celsius to Rankine
lambda r: (r * 5/9) - 273.15 # Rankine to Celsius
)
# Now we can convert to the new scale
rankine = temp.convert_to("rankine")
print(f"In Rankine: {rankine}")
# Using static methods
print(f"Is 0°C freezing? {Temperature.is_freezing(0, 'celsius')}")
print(f"Is 40°F freezing? {Temperature.is_freezing(40, 'fahrenheit')}")
print(f"Is 260K freezing? {Temperature.is_freezing(260, 'kelvin')}")
When to use each type:
- Use instance methods when you need to operate on an instance's data
- Use class methods when you need to operate on class variables or create alternative constructors
- Use static methods for utility functions related to the class but not needing access to its data
Advanced Class Creation Patterns
As you become more experienced with OOP, you'll encounter more advanced class creation patterns. Here are a few examples:
Factory Methods
Factory methods are class methods that create and return instances of the class, often with special initialization logic:
class Person:
"""A class representing a person."""
def __init__(self, first_name, last_name, age):
self.first_name = first_name
self.last_name = last_name
self.age = age
@property
def full_name(self):
"""Get the person's full name."""
return f"{self.first_name} {self.last_name}"
@classmethod
def from_full_name(cls, full_name, age):
"""Create a Person from a full name string."""
first_name, last_name = full_name.split(" ", 1)
return cls(first_name, last_name, age)
@classmethod
def from_dict(cls, data):
"""Create a Person from a dictionary."""
return cls(
data.get("first_name"),
data.get("last_name"),
data.get("age")
)
@classmethod
def from_csv_row(cls, row):
"""Create a Person from a CSV row (comma-separated string)."""
first_name, last_name, age = row.split(",")
return cls(first_name, last_name, int(age))
Using factory methods:
# Regular initialization
p1 = Person("Alice", "Smith", 30)
# Using factory methods
p2 = Person.from_full_name("Bob Johnson", 25)
p3 = Person.from_dict({"first_name": "Charlie", "last_name": "Brown", "age": 35})
p4 = Person.from_csv_row("Diana,Davis,28")
print(p1.full_name) # Output: Alice Smith
print(p2.full_name) # Output: Bob Johnson
print(p3.full_name) # Output: Charlie Brown
print(p4.full_name) # Output: Diana Davis
Singleton Pattern
The Singleton pattern ensures that a class has only one instance and provides a global point of access to it:
class Singleton:
"""A singleton class that can have only one instance."""
_instance = None
def __new__(cls, *args, **kwargs):
"""Override __new__ to ensure only one instance exists."""
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, name="DefaultName"):
"""Initialize the singleton (called every time __new__ is called)."""
# Only set name if it hasn't been set before
if not hasattr(self, "name"):
self.name = name
Using the Singleton pattern:
# Create an instance
singleton1 = Singleton("FirstInstance")
print(singleton1.name) # Output: FirstInstance
# Create another "instance" (actually gets the same instance)
singleton2 = Singleton("SecondInstance")
print(singleton2.name) # Output: FirstInstance (name wasn't changed)
# Prove it's the same object
print(singleton1 is singleton2) # Output: True
print(id(singleton1) == id(singleton2)) # Output: True
Builder Pattern
The Builder pattern separates the construction of a complex object from its representation:
class Computer:
"""A class representing a computer with many configuration options."""
def __init__(self):
# Default configuration
self.cpu = None
self.memory = None
self.storage = None
self.gpu = None
self.os = None
self.extras = []
def __str__(self):
"""Return a string description of the computer."""
components = [
f"CPU: {self.cpu}" if self.cpu else "CPU: Not specified",
f"Memory: {self.memory} GB" if self.memory else "Memory: Not specified",
f"Storage: {self.storage}" if self.storage else "Storage: Not specified",
f"GPU: {self.gpu}" if self.gpu else "GPU: Not specified",
f"OS: {self.os}" if self.os else "OS: Not specified"
]
if self.extras:
components.append(f"Extras: {', '.join(self.extras)}")
return "\n".join(components)
class ComputerBuilder:
"""A builder class for creating computers."""
def __init__(self):
"""Initialize a new builder, starting with an empty computer."""
self.computer = Computer()
def with_cpu(self, cpu):
"""Set the CPU."""
self.computer.cpu = cpu
return self # Return self for method chaining
def with_memory(self, memory_gb):
"""Set the memory size in GB."""
self.computer.memory = memory_gb
return self
def with_storage(self, storage_desc):
"""Set the storage description."""
self.computer.storage = storage_desc
return self
def with_gpu(self, gpu):
"""Set the GPU."""
self.computer.gpu = gpu
return self
def with_os(self, os_name):
"""Set the operating system."""
self.computer.os = os_name
return self
def add_extra(self, extra):
"""Add an extra component."""
self.computer.extras.append(extra)
return self
def build(self):
"""Return the configured computer."""
return self.computer
Using the Builder pattern:
# Build a gaming PC
gaming_pc = ComputerBuilder() \
.with_cpu("Intel Core i9-12900K") \
.with_memory(32) \
.with_storage("2TB NVMe SSD") \
.with_gpu("NVIDIA RTX 3080") \
.with_os("Windows 11") \
.add_extra("RGB Lighting") \
.add_extra("Liquid Cooling") \
.build()
print("Gaming PC Configuration:")
print(gaming_pc)
# Build a simple office PC
office_pc = ComputerBuilder() \
.with_cpu("Intel Core i5-11400") \
.with_memory(16) \
.with_storage("512GB SSD") \
.with_os("Windows 10") \
.build()
print("\nOffice PC Configuration:")
print(office_pc)
Design principle: These patterns are reusable solutions to common problems in software design. Learning them can help you create more elegant, flexible, and maintainable classes.
Best Practices for Custom Classes
Here are some best practices to follow when creating and using custom classes:
Design and Structure
- Single Responsibility Principle: Each class should have only one reason to change
- Keep Classes Focused: If a class is doing too many things, split it into multiple classes
- Respect Encapsulation: Hide implementation details and provide a clean interface
- Design for Inheritance: Consider whether your class might be extended by subclasses
- Prefer Composition Over Inheritance: Use has-a relationships more often than is-a relationships
Naming and Documentation
- Meaningful Names: Class and method names should clearly indicate their purpose
- Follow Conventions: Use CamelCase for class names and snake_case for methods and attributes
- Write Docstrings: Document the purpose, parameters, and return values of your classes and methods
- Include Examples: Where appropriate, include usage examples in docstrings
Implementation
- Validate Inputs: Check parameters for validity, especially in constructors
- Use Properties: For controlled attribute access with validation
- Immutable When Possible: Make objects immutable if their state shouldn't change
- Implement Special Methods: Use dunder methods to integrate with Python's built-in functionality
- Avoid Mutable Default Arguments: Never use mutable objects as default arguments
# Example of a well-designed class following best practices
class Rectangle:
"""
A class representing a rectangle.
Attributes:
width (float): The width of the rectangle.
height (float): The height of the rectangle.
Examples:
>>> rect = Rectangle(5, 10)
>>> rect.area()
50
>>> rect.perimeter()
30
"""
def __init__(self, width, height):
"""
Initialize a new Rectangle.
Args:
width (float): The width of the rectangle. Must be positive.
height (float): The height of the rectangle. Must be positive.
Raises:
ValueError: If width or height is not positive.
TypeError: If width or height is not a number.
"""
# Validate inputs
if not isinstance(width, (int, float)) or not isinstance(height, (int, float)):
raise TypeError("Width and height must be numbers")
if width <= 0 or height <= 0:
raise ValueError("Width and height must be positive")
self._width = width
self._height = height
@property
def width(self):
"""Get the rectangle's width."""
return self._width
@width.setter
def width(self, value):
"""Set the rectangle's width with validation."""
if not isinstance(value, (int, float)):
raise TypeError("Width must be a number")
if value <= 0:
raise ValueError("Width must be positive")
self._width = value
@property
def height(self):
"""Get the rectangle's height."""
return self._height
@height.setter
def height(self, value):
"""Set the rectangle's height with validation."""
if not isinstance(value, (int, float)):
raise TypeError("Height must be a number")
if value <= 0:
raise ValueError("Height must be positive")
self._height = value
def area(self):
"""
Calculate the rectangle's area.
Returns:
float: The area of the rectangle.
"""
return self.width * self.height
def perimeter(self):
"""
Calculate the rectangle's perimeter.
Returns:
float: The perimeter of the rectangle.
"""
return 2 * (self.width + self.height)
def is_square(self):
"""
Check if the rectangle is a square.
Returns:
bool: True if the rectangle is a square, False otherwise.
"""
return self.width == self.height
def __str__(self):
"""Return a string representation of the rectangle."""
return f"Rectangle(width={self.width}, height={self.height})"
def __repr__(self):
"""Return a developer-friendly string representation of the rectangle."""
return f"Rectangle({self.width}, {self.height})"
def __eq__(self, other):
"""Check if two rectangles have the same dimensions."""
if not isinstance(other, Rectangle):
return NotImplemented
return self.width == other.width and self.height == other.height
Practical Example: Creating a Task Management System
Let's apply everything we've learned to create a simple task management system:
class Task:
"""A class representing a task in a task management system."""
# Class variable to track all tasks
all_tasks = []
# Status constants
STATUS_TODO = "To Do"
STATUS_IN_PROGRESS = "In Progress"
STATUS_DONE = "Done"
# Priority constants
PRIORITY_LOW = "Low"
PRIORITY_MEDIUM = "Medium"
PRIORITY_HIGH = "High"
def __init__(self, title, description="", priority=None, due_date=None):
"""Initialize a new task."""
self.title = title
self.description = description
self.priority = priority or self.PRIORITY_MEDIUM
self.due_date = due_date
self.status = self.STATUS_TODO
self.created_date = self._get_current_date()
self.completed_date = None
# Add to all_tasks list
Task.all_tasks.append(self)
@staticmethod
def _get_current_date():
"""Get the current date and time."""
import datetime
return datetime.datetime.now()
def mark_in_progress(self):
"""Mark the task as in progress."""
self.status = self.STATUS_IN_PROGRESS
return f"Task '{self.title}' marked as In Progress"
def mark_done(self):
"""Mark the task as done."""
self.status = self.STATUS_DONE
self.completed_date = self._get_current_date()
return f"Task '{self.title}' marked as Done"
def update_priority(self, new_priority):
"""Update the task's priority."""
valid_priorities = [self.PRIORITY_LOW, self.PRIORITY_MEDIUM, self.PRIORITY_HIGH]
if new_priority not in valid_priorities:
return f"Invalid priority. Must be one of: {', '.join(valid_priorities)}"
self.priority = new_priority
return f"Priority updated to {new_priority}"
def set_due_date(self, due_date):
"""Set the task's due date."""
self.due_date = due_date
return f"Due date set to {due_date}"
def __str__(self):
"""Return a string representation of the task."""
result = f"{self.title} ({self.status})"
if self.priority:
result += f" - {self.priority} priority"
if self.due_date:
result += f" - Due: {self.due_date}"
return result
@classmethod
def get_all_tasks(cls):
"""Get all tasks."""
return cls.all_tasks
@classmethod
def get_tasks_by_status(cls, status):
"""Get tasks filtered by status."""
return [task for task in cls.all_tasks if task.status == status]
@classmethod
def get_tasks_by_priority(cls, priority):
"""Get tasks filtered by priority."""
return [task for task in cls.all_tasks if task.priority == priority]
@classmethod
def create_from_dict(cls, task_dict):
"""Create a task from a dictionary."""
return cls(
title=task_dict.get("title"),
description=task_dict.get("description", ""),
priority=task_dict.get("priority"),
due_date=task_dict.get("due_date")
)
class Project:
"""A class representing a project containing multiple tasks."""
def __init__(self, name, description=""):
"""Initialize a new project."""
self.name = name
self.description = description
self.tasks = []
self.created_date = self._get_current_date()
@staticmethod
def _get_current_date():
"""Get the current date and time."""
import datetime
return datetime.datetime.now()
def add_task(self, task):
"""Add a task to the project."""
self.tasks.append(task)
return f"Task '{task.title}' added to project '{self.name}'"
def remove_task(self, task):
"""Remove a task from the project."""
if task in self.tasks:
self.tasks.remove(task)
return f"Task '{task.title}' removed from project '{self.name}'"
return f"Task not found in project '{self.name}'"
def get_task_count(self):
"""Get the number of tasks in the project."""
return len(self.tasks)
def get_completed_task_count(self):
"""Get the number of completed tasks in the project."""
return len([task for task in self.tasks if task.status == Task.STATUS_DONE])
def get_completion_percentage(self):
"""Get the percentage of completed tasks."""
if not self.tasks:
return 0
return (self.get_completed_task_count() / self.get_task_count()) * 100
def get_tasks_by_status(self, status):
"""Get tasks in the project filtered by status."""
return [task for task in self.tasks if task.status == status]
def get_tasks_by_priority(self, priority):
"""Get tasks in the project filtered by priority."""
return [task for task in self.tasks if task.priority == priority]
def __str__(self):
"""Return a string representation of the project."""
return f"{self.name}: {self.get_completion_percentage():.1f}% complete ({self.get_completed_task_count()}/{self.get_task_count()} tasks)"
class TaskManager:
"""A class for managing projects and tasks."""
def __init__(self):
"""Initialize a new task manager."""
self.projects = {}
def create_project(self, name, description=""):
"""Create a new project."""
if name in self.projects:
return f"Project '{name}' already exists"
project = Project(name, description)
self.projects[name] = project
return f"Project '{name}' created"
def delete_project(self, name):
"""Delete a project."""
if name not in self.projects:
return f"Project '{name}' not found"
del self.projects[name]
return f"Project '{name}' deleted"
def get_project(self, name):
"""Get a project by name."""
return self.projects.get(name)
def create_task(self, title, description="", priority=None, due_date=None, project_name=None):
"""Create a new task, optionally adding it to a project."""
task = Task(title, description, priority, due_date)
if project_name:
project = self.get_project(project_name)
if project:
project.add_task(task)
return f"Task '{title}' created and added to project '{project_name}'"
else:
return f"Task '{title}' created, but project '{project_name}' not found"
return f"Task '{title}' created"
def get_all_projects(self):
"""Get all projects."""
return list(self.projects.values())
def get_all_tasks(self):
"""Get all tasks across all projects."""
return Task.get_all_tasks()
def get_tasks_by_status(self, status):
"""Get all tasks with the given status across all projects."""
return Task.get_tasks_by_status(status)
def get_tasks_by_priority(self, priority):
"""Get all tasks with the given priority across all projects."""
return Task.get_tasks_by_priority(priority)
def generate_status_report(self):
"""Generate a status report for all projects."""
report = "Task Manager Status Report\n"
report += "==========================\n\n"
# Overall statistics
total_tasks = len(self.get_all_tasks())
completed_tasks = len(self.get_tasks_by_status(Task.STATUS_DONE))
if total_tasks > 0:
completion_percentage = (completed_tasks / total_tasks) * 100
else:
completion_percentage = 0
report += f"Overall Progress: {completion_percentage:.1f}% complete ({completed_tasks}/{total_tasks} tasks)\n\n"
# Project breakdown
report += "Projects:\n"
for project in self.get_all_projects():
report += f"- {project}\n"
return report
Using the task management system:
# Create a task manager
manager = TaskManager()
# Create projects
manager.create_project("Website Redesign", "Redesign the company website")
manager.create_project("Mobile App", "Develop a mobile app for customers")
# Create tasks and add them to projects
manager.create_task(
"Design homepage",
"Create a mockup of the new homepage",
Task.PRIORITY_HIGH,
"2023-12-15",
"Website Redesign"
)
manager.create_task(
"Implement responsive CSS",
"Make the website work well on mobile devices",
Task.PRIORITY_MEDIUM,
"2023-12-20",
"Website Redesign"
)
manager.create_task(
"App wireframes",
"Create wireframes for the mobile app",
Task.PRIORITY_HIGH,
"2023-12-10",
"Mobile App"
)
manager.create_task(
"Backend API",
"Develop the backend API for the mobile app",
Task.PRIORITY_MEDIUM,
"2024-01-15",
"Mobile App"
)
# Update task status
website_project = manager.get_project("Website Redesign")
design_task = website_project.tasks[0]
design_task.mark_in_progress()
app_project = manager.get_project("Mobile App")
wireframe_task = app_project.tasks[0]
wireframe_task.mark_done()
# Generate and print status report
print(manager.generate_status_report())
# Print detailed task information for a project
print("\nWebsite Redesign Tasks:")
for task in website_project.tasks:
print(f"- {task}")
This example demonstrates:
- Multiple Interacting Classes:
Task,Project, andTaskManagerwork together - Class and Instance Variables:
Task.all_taskstracks all tasks globally - Static and Class Methods: Various utility methods for filtering and creating tasks
- Encapsulation: Each class has a clear responsibility and provides a clean interface
- String Representation:
__str__methods provide readable output
Conclusion
Congratulations! You've learned the essentials of creating and using custom classes in Python. To summarize what we've covered:
- Understanding the purpose and benefits of classes
- Designing classes with appropriate attributes and methods
- Creating instances (objects) and using their functionality
- Implementing special methods to integrate with Python
- Using composition to build complex systems
- Enforcing encapsulation with private attributes and properties
- Creating class methods and static methods
- Applying design patterns for common problems
- Following best practices for clean, maintainable code
With these skills, you can now model real-world entities and build sophisticated software systems using object-oriented programming principles. Remember that practice is essential for mastering OOP, so keep creating and refining your custom classes as you work on different projects.
Practice Exercise
Design and implement a BankAccount class with the following features:
- Store account number, owner name, and balance
- Implement methods for deposit, withdrawal, and balance inquiry
- Use properties to ensure the balance can't be directly modified
- Keep a transaction history
- Add special methods for string representation
Then extend your solution by creating related classes for different account types (Checking, Savings) and a Bank class to manage multiple accounts.
Additional Resources
- Python Official Documentation on Classes
- Real Python: Object-Oriented Programming in Python 3
- Python OOP Tutorial
- Design Patterns in Python
- Recommended Book: "Python 3 Object-Oriented Programming" by Dusty Phillips