Creating and Using Custom Classes in Python

Week 3: Monday Afternoon Session

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:

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:

2. Define Attributes

Identify the data your class needs to store:

3. Define Methods

Determine what behaviors your class should have:

4. Establish Relationships

Consider how your class relates to other classes:

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:

  1. Purpose: Represent a book in a library management system
  2. Attributes: title, author, ISBN, publication year, number of pages, current status (available, checked out, etc.)
  3. Methods: check_out(), return_book(), get_status(), display_info()
  4. 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:

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:

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

Naming and Documentation

Implementation

# 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:

Conclusion

Congratulations! You've learned the essentials of creating and using custom classes in Python. To summarize what we've covered:

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:

  1. Store account number, owner name, and balance
  2. Implement methods for deposit, withdrawal, and balance inquiry
  3. Use properties to ensure the balance can't be directly modified
  4. Keep a transaction history
  5. 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