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, allowing us to manage complexity by hiding complicated details behind simpler concepts. The resulting layers of abstraction have been fundamental since the early days of electronics. For example, early electrical engineers abstracted electronic components into circuit diagrams to represent complex electronic systems, allowing them to design and troubleshoot without delving into the physics of each component. Early assembler and programming languages represented the next levels of abstraction on top of the electronic machinery. High-level languages like Python allow us to instruct computers without worrying about the underlying machine code. Similarly, the creation of libraries and frameworks, such as for graphical user interfaces (GUIs), means that developers can build sophisticated applications without starting from scratch. Classes are powerful tools for abstraction in programming. A class defines a blueprint for creating objects by encapsulating data and the methods that manipulate that data. This encapsulation provides a clear interface for interacting with objects while hiding their internal state. Think of it like driving a car: you only need to know how to use the steering wheel and pedals, without understanding the complex mechanics under the hood. Classes allow programmers to build complex software systems in a modular and manageable way. Each class represents a building block that can be combined with others to create sophisticated functionality. This modularity not only makes code easier to understand and maintain, but also promotes reusability. As a result, abstraction through classes enables the development of scalable, maintainable, and robust software.

Defining Our Own Data Types

There are many ways to introduce classes, the most elegant in my opinion was chosen by Stanley B. Lippman in his best-selling book on the C++ programming language since 1986: "C++ Primer". In Python, everything is an object, so behind every data structure we use is a class that defines that data type. In many cases, however, it makes sense to create your own data types. Stanley B. Lippman chose the example of inventory management for a bookstore. If we write software to manage an inventory of books, sooner or later we will have to think about our data type Book. For example, how would we add two books? Obviously, a simple addition is not very practical. If the bookstore sells one copy of "Harry Potter", the quantity of all books may have decreased by 1, but the bookstore manager does not get any meaningful information about the stock of the book sold and thus the need to reorder it. Using classes, we can define a new data type for books with its own methods. In our example, adding and subtracting two books only makes sense if they are the same book, i.e. two copies with the same ISBN.

Magic Methods

Magic methods, also known as dunder (double underscore) methods, are special methods in Python that allow developers to define the behavior of objects for built-in operations. There are about 83 magic methods in Python, covering a wide range of operations. In the Book class example, magic methods are used to provide meaningful implementations for comparison, string representation, and arithmetic operations. The __init__ method initializes a new instance of the class with attributes such as title, author, ISBN, and number of copies. The __str__ method defines how the Book object is represented as a string, making it easier to print readable information about the book. The __eq__ method allows you to compare two Book objects, ensuring that they are equal based on the ISBN. The __add__ and __sub__ methods handle the addition and subtraction of Book objects, respectively, but only if the books have the same ISBN. These magic methods allow custom data types to behave intuitively and integrate seamlessly with Python's syntax and built-in operations.


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

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

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

    def __add__(self, other):
        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):
        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

Now let's see how we can use the Book class and the implemented 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(book1)

try:
    new_book = book1 + book2
    print(new_book)
except ValueError as e:
    print(e)

try:
    new_book = book1 + book3  # This will raise a ValueError
except ValueError as e:
    print(e)

try:
    new_book = book1 - book2
    print(new_book)
except ValueError as e:
    print(e)
        

Understanding Classes and Objects

Object-Oriented Programming (OOP) is a programming paradigm based on the concept of "objects," which can contain data and methods that manipulate that data. The goal of OOP is to organize program code into modular parts. If we think of a computer program as an organism, different functions are divided into organs. Staying with the example, the heart has the function of pumping blood, but how it does this is not relevant to the liver (oversimplified). This roughly corresponds to the basic ideas of object-oriented programming, so it is not surprising that the first object-oriented programming language, Simula, was developed in the 1960s to simulate (natural) systems. A class is a blueprint for creating objects, and an object is an instance of a class.


class Ant:
    def __init__(self, type):
        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 is a way to create new classes using classes that have already been defined. The new class is a derived class (or child class); the class it inherits from is the base class (or parent class).


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()
        

Understanding Polymorphism

Polymorphism allows a single interface to represent a variety of different underlying forms or data types. It's a powerful feature that increases flexibility and code reusability. In essence, polymorphism allows us to use one interface to handle numerous data types. This can greatly simplify code, making it easier to read, write, and maintain.


def ant_activity(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 is the practice of bundling data together with the methods that operate on that data into a single entity. This practice increases data integrity by keeping the information and the methods that manipulate it together. Abstraction, on the other hand, is the concept of hiding the intricate details of how a system works, while exposing only the necessary (and at best, user-friendly) parts. This approach simplifies complex systems, making them easier to understand and use without needing to know details of the underlying implementations.


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

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

hidden_ant = HiddenAnt()
hidden_ant.reveal()
hidden_ant.__secret
        

Function Decorators

Function decorators are a powerful feature in Python that allow you to modify the behavior of a function or a method. Decorators are often used for logging, access control, instrumentation, caching, and more. They are denoted by the @decorator_name syntax placed 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 in Python is used to define a method that belongs to the class rather than to a particular instance. This means that the method can be called on the class itself without having to create an instance. Static methods do not have access to the instance (self) or class (cls) variables, but can be used to perform utility functions related to the class.


class Utility:
    @staticmethod
    def add(a, b):
        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 we design classes, our goal should be to provide access to complex functionality through the simplest possible interface. Computer scientist and Stanford professor John Ousterhout calls these classes "deep" in his brilliant book “A Philosophy of Software Design”. In contrast, he calls classes with complicated interfaces but simple implementations "shallow". A simple interface requires that as much functionality as possible be hidden inside the class, that is, inaccessible from the outside. In principle, any information hidden within classes reduces the complexity of the overall system. It is often difficult to decide what information is accessible from the outside. However, any unnecessary information adds unnecessary complexity to the overall system. When constructing classes, it is often easier to cram a lot of information into the interface. John Ousterhout calls this "information leakage," which leads to what is known in the software industry as technical debt. Let's say we want to add a new feature to a program. We package this information in a class, but we don't have time to construct the class as deep as possible. To achieve our short-term goal of adding a new feature, we have accepted a much shallower class than necessary. Wherever this class is instantiated or its methods called, the unnecessary complexity is multiplied and the entire system is damaged. If we do this more often, we will inevitably reach the point where our software is no longer extensible and maintainable. We then have to spend a lot of time reducing the complexity of our software - in other words, paying off the technical debt we have accumulated. An important principle in building deep classes is generalization. It leads to better information hiding. General-purpose modules are deeper - they have more complex internal logic, but simpler interfaces.

Prompting AI for Implementing Object-Oriented Programming

Start by defining the functionality you need and the context in which it will be used. For example, instead of asking "How do I use OOP?", ask "Can you help me create a Python class to manage an inventory system with methods to add and retrieve items?" Provide detailed requirements, such as "I need to manage a collection of items with attributes such as name, quantity, and price, and I want methods to update these attributes and calculate the total value." Ask for examples to illustrate the solution, such as "Can you provide a Python class definition with these methods and explain how to instantiate objects and use these methods?"

Example Prompt:
Write an object-oriented program for a Morse code translator, including methods to encode and decode messages.

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):
        return ' '.join(MorseCodeTranslator.MORSE_CODE_DICT.get(char.upper(), '') for char in message)

    @staticmethod
    def decode(morse_code):
        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