An Introduction to the Art of Computer Programming Using Python in the Age of Generative AI

XIV. Classes and Object-Oriented Programming

Introduction

Abstraction is a fundamental principle of computer science, helping us manage complexity by concealing details behind simpler concepts. Layers of abstraction have been essential since the early days of electronics. For instance, circuit diagrams allow engineers to design and troubleshoot complex systems without delving into the physics of every component. High-level languages like Python also exemplify this: they let us instruct computers without writing machine code.

Classes are powerful tools for abstraction in programming. A class defines a blueprint for creating objects, bundling data and methods that manipulate that data. This encapsulation provides a clear interface for interacting with objects while hiding internal details. It’s like driving a car: you only need to know how to use the steering wheel and pedals, without understanding the intricate mechanics under the hood. Classes let programmers build complex software in a modular and manageable way, with each class acting as a building block.

Defining Our Own Data Types

There are multiple ways to introduce classes. One elegant approach, inspired by Stanley B. Lippman’s "C++ Primer," highlights that in Python, everything is an object. Each data structure is backed by a class. Sometimes, you’ll want to create your own data types. For instance, if we manage an inventory of books, eventually we’ll define a custom Book type. By default, adding two books doesn't make obvious sense—unless they share the same ISBN. Classes let you define logic for such operations, making them meaningful in the context of your specific problem domain.

Magic Methods

Magic methods (or dunder methods, short for "double underscore") in Python let you define an object’s behavior for built-in operations. They include __init__ for initialization (the constructor), __str__ for string representation, __eq__ for equality, __add__ and __sub__ for arithmetic, and so forth. Implementing these methods helps custom objects integrate seamlessly with Python’s syntax.


class Book:
    def __init__(self, title: str, author: str, isbn: str, copies: int):
        self.title = title
        self.author = author
        self.isbn = isbn
        self.copies = copies

    def __str__(self) -> str:
        return f"{self.title} by {self.author}, ISBN: {self.isbn}, Copies: {self.copies}"

    def __eq__(self, other) -> bool:
        if not isinstance(other, Book):
            return False
        return self.isbn == other.isbn

    def __add__(self, other):
        # Defines behavior for the '+' operator
        if self == other:
            return Book(self.title, self.author, self.isbn, self.copies + other.copies)
        raise ValueError("Books must have the same ISBN to be added together")

    def __sub__(self, other):
        # Defines behavior for the '-' operator
        if self == other:
            if self.copies >= other.copies:
                return Book(self.title, self.author, self.isbn, self.copies - other.copies)
            raise ValueError("Cannot subtract more copies than are available")
        raise ValueError("Books must have the same ISBN to be subtracted")
        

Utilizing the Book Class

Below is an example of how to use the Book class and its magic methods:


book1 = Book("Harry Potter", "J.K. Rowling", "1234", 10)
book2 = Book("Harry Potter", "J.K. Rowling", "1234", 5)
book3 = Book("Lord of the Rings", "J.R.R. Tolkien", "5678", 7)

print(f"Book Details: {book1}")

try:
    # Uses the __add__ method
    new_book = book1 + book2
    print(f"Combined Stock: {new_book}")
except ValueError as e:
    print(f"Error: {e}")

try:
    new_book = book1 + book3  # This will raise a ValueError
except ValueError as e:
    print(f"Error: {e}")

try:
    # Uses the __sub__ method
    new_book = book1 - book2
    print(f"Remaining Stock: {new_book}")
except ValueError as e:
    print(f"Error: {e}")
        

Modern Python: Data Classes

Writing standard methods like __init__ and __str__ can be tedious. Python 3.7 introduced Data Classes to automate this. The @dataclass decorator automatically generates these magic methods for you, resulting in cleaner code.


from dataclasses import dataclass

@dataclass
class SimpleBook:
    title: str
    author: str
    isbn: str
    copies: int

# __init__ and __str__ are automatically created!
b = SimpleBook("1984", "Orwell", "999", 5)
print(b)
        

Understanding Classes and Objects

Object-Oriented Programming (OOP) is a paradigm that structures code around objects containing data and methods. A class is like a blueprint; an object is an instance of that blueprint. Classes allow us to divide a program into distinct parts that interact with one another.

Note on `self`: In Python, self represents the specific object instance calling the method. It allows the code to distinguish between this specific ant and that specific ant, even though they are created from the same class blueprint.


class Ant:
    def __init__(self, type: str):
        self.type = type

    def work(self):
        print(f"The {self.type} ant is working.")

# Creating objects of Ant class
worker_ant = Ant("worker")
soldier_ant = Ant("soldier")
scout_ant = Ant("scout")

worker_ant.work()
        

Implementing Inheritance

Inheritance lets you create new classes from existing ones. The new (child) class inherits attributes and methods from the base (parent) class. This is useful for code reusability and hierarchical designs.


class SoldierAnt(Ant):
    def fight(self):
        print("The soldier ant is fighting!")

class WorkerAnt(Ant):
    def build(self):
        print("The worker ant is building!")

class ScoutAnt(Ant):
    def explore(self):
        print("The scout ant is exploring!")

# Creating objects of derived classes
soldier = SoldierAnt("soldier")
worker = WorkerAnt("worker")
scout = ScoutAnt("scout")

soldier.fight()
worker.build()
scout.explore()
        
Generative AI Insight: Neural Networks as Classes
When you use deep learning libraries like PyTorch or TensorFlow, you are using OOP. A Neural Network is defined as a class that inherits from a parent class (e.g., nn.Module). You define the layers (data) in __init__ and the logic in a method called forward(). This allows AI researchers to swap out complex layers just by changing the class definition.

Understanding Polymorphism

Polymorphism enables a single interface to handle multiple data types. It allows different classes to implement the same method in various ways while still sharing the same interface, increasing flexibility and reusability.


def ant_activity(ant: Ant):
    # Accepts any object that is an Ant (or subclass of Ant)
    ant.work()

ant_activity(soldier)  # Output: The soldier ant is working.
ant_activity(worker)   # Output: The worker ant is working.
ant_activity(scout)    # Output: The scout ant is working.
        

Encapsulation and Abstraction

Encapsulation bundles data with the methods that operate on it, promoting data integrity. Abstraction hides complex details while exposing only what's necessary. Both concepts simplify code by reducing complexity and making the internal workings of classes less visible to external code.

In Python, we use double underscores (__) to indicate that an attribute is "private" and should not be accessed directly from outside the class.


class HiddenAnt:
    def __init__(self):
        self.__secret = "I have a hidden message."

    def reveal(self):
        print(self.__secret)

hidden_ant = HiddenAnt()
hidden_ant.reveal()

try:
    # Direct access raises an error
    print(hidden_ant.__secret)
except AttributeError as e:
    print(f"Access Denied: {e}")
        

Pythonic Encapsulation: Properties

While double underscores enforce privacy, the "Pythonic" way to control access to a variable is using the @property decorator. This allows you to add logic (like validation) when getting or setting a value, without changing how the class is used.


class SecureAnt:
    def __init__(self):
        self._energy = 100  # Single underscore implies "internal use"

    @property
    def energy(self):
        return self._energy

    @energy.setter
    def energy(self, value):
        if value < 0:
            print("Energy cannot be negative!")
            self._energy = 0
        else:
            self._energy = value

ant = SecureAnt()
ant.energy = -50  # Triggers the setter logic automatically
print(f"Current Energy: {ant.energy}")
        

Function Decorators

Function decorators in Python allow you to modify or extend the behavior of functions or methods. Common uses include logging, access control, instrumentation, and caching. Decorators are declared with @decorator_name above the function definition.


def simple_decorator(func):
    def wrapper():
        print("Something is happening before the function is called.")
        func()
        print("Something is happening after the function is called.")
    return wrapper

@simple_decorator
def say_hello():
    print("Hello!")

say_hello()
        

For example, the @staticmethod decorator creates methods tied to a class rather than a particular instance. These methods cannot access instance (self) or class (cls) variables, but can perform utility functions related to the class.


class Utility:
    @staticmethod
    def add(a: int, b: int) -> int:
        return a + b

# Calling the static method
result = Utility.add(5, 3)
print(f"The result of adding 5 and 3 is: {result}")

Classes and Software Design

When designing classes, aim for a simple interface that hides internal complexities—a concept John Ousterhout terms “deep” classes in his book “A Philosophy of Software Design.” Shallow classes have complicated interfaces but simple implementations, leading to “information leakage” and technical debt. Generalization is key to building deeper classes with more intricate internal logic yet simpler external interfaces. Such designs enhance maintainability and readability, reducing complexity in the broader system.

Prompting AI for Implementing Object-Oriented Programming

Provide detailed requirements and context when asking AI to help with OOP. Instead of "How do I use OOP?", say, "Create a Python class for managing an inventory system with methods for adding and retrieving items." You can specify attributes like name, quantity, and price, and ask for code examples illustrating how to instantiate and use the class.

Example Prompt:
Write an object-oriented program for a Morse code translator, including methods to encode and decode messages. Use a dictionary for mapping and include static methods. Use type hints.

Resulting AI-generated code:


class MorseCodeTranslator:
    MORSE_CODE_DICT = {
        'A': '.-', 'B': '-...', 'C': '-.-.', 'D': '-..', 'E': '.', 'F': '..-.',
        'G': '--.', 'H': '....', 'I': '..', 'J': '.---', 'K': '-.-', 'L': '.-..',
        'M': '--', 'N': '-.', 'O': '---', 'P': '.--.', 'Q': '--.-', 'R': '.-.',
        'S': '...', 'T': '-', 'U': '..-', 'V': '...-', 'W': '.--', 'X': '-..-',
        'Y': '-.--', 'Z': '--..',
        '0': '-----', '1': '.----', '2': '..---', '3': '...--', '4': '....-',
        '5': '.....', '6': '-....', '7': '--...', '8': '---..', '9': '----.',
        ',': '--..--', '.': '.-.-.-', '?': '..--..', '/': '-..-.', '-': '-....-',
        '(': '-.--.', ')': '-.--.-', ' ': '/'
    }

    @staticmethod
    def encode(message: str) -> str:
        return ' '.join(MorseCodeTranslator.MORSE_CODE_DICT.get(char.upper(), '') for char in message)

    @staticmethod
    def decode(morse_code: str) -> str:
        # Invert the dictionary to map Morse to Letters
        reverse_dict = {value: key for key, value in MorseCodeTranslator.MORSE_CODE_DICT.items()}
        return ''.join(reverse_dict.get(code, '') for code in morse_code.split(' '))

# Creating a MorseCodeTranslator instance
translator = MorseCodeTranslator()

# Encoding a message to Morse code
message = "HELLO WORLD"
encoded_message = translator.encode(message)
print(f"Encoded: {encoded_message}")

# Decoding a Morse code message
morse_code = ".... . .-.. .-.. --- / .-- --- .-. .-.. -.."
decoded_message = translator.decode(morse_code)
print(f"Decoded: {decoded_message}")
Back to Home