Python classes stop by step. From simple to complex according to requirements.

Jose Ferro
8 min readSep 30, 2023

My story is the one of a software engineer self taught the last years. I would like to revisit the concepts behind classes in python.

I assume you code in python and you know what is Object Oriented Programming.

lets start with a simple class. name Lybrary, only one atribute. the books.

class Library:
def __init__(self, book_titles):
self.books = book_titles

Since book titles are strings and there might be many, its a list. Lets add typing. In this way anyone reading the code knows that book_titles is a list of strings. It will help also the linters to check your code.

from typing import List

class Library:
def __init__(self, book_titles: List[str]) -> None:
self.books = book_titles

Now we add a second attribute based on the first one. We need to know the amount of books that the library has.

from typing import List

class Library:
def __init__(self, book_titles: List[str]) -> None:
self.books = book_titles
self.num_books = len(book_titles)

Now imagine that there is an API that given the title of the book gives back the shelf number where the book is. I would like to add an atribute consisting on a dictionary with keys title, shelf.

from typing import List, Dict

class Library:
def __init__(self, book_titles: List[str], shelf_api) -> None:
self.books = book_titles
self.num_books = len(book_titles)
self.book_shelves = {title: shelf_api.get_shelf(title) for title in book_titles}

I would like now to add another atribute, most_demanded_book. Asume that this is calculated with multiple calls to multiple dabases. It needs several lines of code and is hence calculated ba a separate class method

from typing import List, Dict

class Library:
def __init__(self, book_titles: List[str], shelf_api) -> None:
self.books = book_titles
self.num_books = len(book_titles)
self.book_shelves = {title: shelf_api.get_shelf(title) for title in book_titles}
self.most_demanded_book = self.calculate_most_demanded_book()

def calculate_most_demanded_book(self) -> str:
# Placeholder for calls to multiple databases and complex calculations
# For example:
# demand_data = fetch_from_database(self.books)
# ...
# return highest_demand_book
return "SomeBookTitle" # Replace with actual calculation logic

@property decorator

So far all the components are the usual basic ones. Now starts the fun.

I would like to create an atribute that is not initialized at the beginning, only when it is called. The reason is that making the calculation is very costly in time and resources and not always is the atribute needed. For instance imagine that we built another dictionary books_content and we with keys title, summary where the summary is done applying some kind of machine learning algorith. Only when the user calls books_content is the atribute calculated.

from typing import List, Dict, Optional

class Library:
def __init__(self, book_titles: List[str], shelf_api) -> None:
self.books = book_titles
self.num_books = len(book_titles)
self.book_shelves = {title: shelf_api.get_shelf(title) for title in book_titles}
self.most_demanded_book = self.calculate_most_demanded_book()
self._books_content = None # Initialize as None

def calculate_most_demanded_book(self) -> str:
# Your complex calculation here
return "SomeBookTitle"

@property
def books_content(self) -> Optional[Dict[str, str]]:
if self._books_content is None:
self._books_content = {title: self.generate_summary(title) for title in self.books}
return self._books_content

def generate_summary(self, title: str) -> str:
# Placeholder for your machine learning algorithm to generate summary
# For example:
# summary = ml_algorithm.generate_summary(title)
# ...
return "SomeSummary" # Replace with actual logic

We observe two things, the decorator @property is used and the name of the method decorated is actually the name of the attribute.

When initialising the class:

mylibrary = Library(["first title", "second title"], shelf_api="http...")

The method books_content is not run. Only when the user does afterwards:

print(mylibrary.books_content)

The method called.

What if after the user calls books_content, there is a need to modify “manually” the content of the dictionary. That is, we dont want to run the whole calculation again, only modify the summary of one of the books because we have the data from another place or we good a correction in writing from one reader. In order to do that we need a dedicated method.

from typing import List, Dict, Optional

class Library:
# ... (same as before)

def update_book_summary(self, title: str, new_summary: str) -> None:
if self._books_content is None:
self.books_content # This will initialize the attribute
self._books_content[title] = new_summary

How would the situation be if two parameters, total_pages and average_pages_per_book are added and both are added with the property decorator.

from typing import List, Dict, Optional

class Library:
# ... (same as before)

def __init__(self, book_titles: List[str], shelf_api) -> None:
# ... (same as before)
self._total_pages = None
self._average_pages_per_book = None

@property
def total_pages(self) -> Optional[int]:
if self._total_pages is None:
self._total_pages = self.calculate_total_pages()
return self._total_pages

def calculate_total_pages(self) -> int:
# Placeholder for the actual calculation
return 10000 # Replace with actual logic

@property
def average_pages_per_book(self) -> Optional[float]:
if self._average_pages_per_book is None:
self._average_pages_per_book = self.total_pages / self.num_books if self.num_books else 0
return self._average_pages_per_book

# ... (same as before)

Take away:

  • There are two internal attributes starting by underscore self._total_pages, self._average_pages_per_book that work as place holders.
  • The sequence might be like this.
  1. user calls average_pages_per_book
  2. since _average_pages_per_book is none _average_pages_per_book is calculated.
  3. since it depends on total_pages, total_pages is calculated since _total_pages is None at the begining.

Setters and getters

Now imagine the following situation. The user is free to manually set an atribute. If the user manually sets the total_pages atribute for whatever reason, two things happens. The first is that if the average pages per book was already calculated it would not be correct anymore, and the second is that there is a risk that the user makes a mistake and passes for instance a negative value as tota_pages. This is the point where the setter comes into place. A setter is a method that runs when an atribute is set.

@property
def total_pages(self) -> Optional[int]:
if self._total_pages is None:
self._total_pages = self.calculate_total_pages()
return self._total_pages

@total_pages.setter
def total_pages(self, value: int) -> None:
if value < 0:
raise ValueError("Total pages cannot be negative.")
self._total_pages = value
self._average_pages_per_book = self._total_pages / self.num_books if self.num_books else

If the user sets the total_pages value as such:

mylibrary.total_pages = -34

# an error is raised.

and if no error is raised the self._average_pages_per_book atribute is recalculated as well.

So the benefits and use cases for setters are:

  1. Validation: With setters, you can add checks to ensure that an attribute is set to a valid state.
  2. Derived Attributes: If an attribute is derived from other attributes, a setter can help update those parent attributes accordingly.
  3. Side Effects: If setting an attribute should trigger additional behaviors, such as logging or sending a notification, a setter can handle this.
  4. Dependency: For attributes that depend

What about getters

A getter is generally used when you need to control how an attribute’s value is accessed. While the property decorator effectively acts as a built-in getter in Python, sometimes you may still need a more traditional getter method. Here are a few scenarios:

  1. Computed Values: If the attribute’s value needs to be computed dynamically from other attributes or external data each time it’s accessed.
  2. Data Transformation: If you need to format or transform the data before returning it, like converting units.
  3. Access Control: If you want to implement fine-grained control over who can access the attribute, perhaps based on some condition.
  4. Side Effects: If accessing the attribute should trigger other actions, like logging or data fetching.

In many cases, the property decorator is enough for these purposes. But if your logic starts to get complex and you want to make it explicitly clear that a method is meant to act as a getter, then you might still define a getter method.

So, to sum up, the need for a getter arises when simply accessing an attribute’s value isn’t straightforward and requires additional logic or controls. Makes sense?

Let’s add a getter method for an attribute called oldest_book, which we'll say is determined by some algorithm or data source. Instead of directly accessing an attribute, we'll use a method to 'get' this information.

from typing import List, Dict, Optional

class Library:
# ... (existing code)

def __init__(self, book_titles: List[str], shelf_api) -> None:
# ... (existing attributes)
self._oldest_book = None # Initialize as None

# Our new 'getter' method for the oldest book
def get_oldest_book(self) -> Optional[str]:
if self._oldest_book is None:
self._oldest_book = self.determine_oldest_book()
return self._oldest_book

def determine_oldest_book(self) -> str:
# Replace with actual logic to determine the oldest book
return "SomeOldBookTitle"

# ... (rest of the existing code)

No that said, getters are implemented in python simply with the @property decorator, hence, take away about getters, use the @property decorator.

@staticmethod

Defines a static method, which belongs to the class rather than any particular object instance. Can be called on the class itself, rather than on instances of the class.

For example, let’s say you want to validate that a book title meets certain criteria (e.g., it must be a non-empty string). You could use a static method for this:

@staticmethod
def is_valid_book_title(title: str) -> bool:
return bool(title) and all(c.isalnum() or c.isspace() for c in title)

@classmethod

Class methods are great for alternative constructors. Imagine you have book data coming from different types of data sources, like a list, a JSON object, or even a CSV file. You can create multiple class methods to handle these different data types and return a new Library instance.

Here’s how you might use a @classmethod to create a Library instance from a JSON object:

import json
from typing import Dict

class Library:
# ... (existing code)

@classmethod
def from_json(cls, json_str: str, shelf_api):
book_data = json.loads(json_str)
book_titles = book_data.get('book_titles', [])
return cls(book_titles, shelf_api)
json_str = '{"book_titles": ["Book1", "Book2", "Book3"]}'
library_instance = Library.from_json(json_str, some_shelf_api)

@abtractmethod

In the context of your Library class, you might use @abstractmethod if you plan to have multiple types of libraries that share some common features but also have unique, specialized behavior. Abstract methods define a contract that all derived, non-abstract classes must adhere to.

For example, let’s say your application supports both physical libraries (with real books and shelves) and digital libraries (with e-books). While both can have a display_books method, the way they display the list of books could be fundamentally different.

Here’s how you could set it up:

from abc import ABC, abstractmethod

class AbstractLibrary(ABC):

@abstractmethod
def display_books(self):
"""Display the list of books in the library."""
pass

@abstractmethod
def find_book(self, title: str):
"""Find and return the book with the given title."""
pass

class PhysicalLibrary(AbstractLibrary):

def display_books(self):
print("Displaying books on physical shelves...")

def find_book(self, title: str):
return f"Book found on shelf {self.shelf_info.get(title, 'unknown')}."

class DigitalLibrary(AbstractLibrary):

def display_books(self):
print("Displaying e-books in the digital catalog...")

def find_book(self, title: str):
return f"Book found in digital catalog {self.shelf_info.get(title, 'unknown')}."

Here, AbstractLibrary serves as a base class that defines a common interface, which includes the abstract methods display_books and find_book. Then, PhysicalLibrary and DigitalLibrary are concrete implementations of this interface.

Any attempt to instantiate AbstractLibrary will raise an error, and any subclass that doesn't implement the abstract methods will also be considered abstract.

This ensures that all types of libraries in your program follow the same basic structure and contract, which can simplify code that uses these classes.

This “ensurance” takes place in the following way:
When a class inherits from an abstract base class that has methods decorated with @abstractmethod, Python will enforce that any non-abstract derived class must implement those methods. If you don't, Python will raise a TypeError when you try to instantiate the derived class.

Here’s what would happen if DigitalLibrary didn't implement find_book:

from abc import ABC, abstractmethod

class AbstractLibrary(ABC):
@abstractmethod
def find_book(self, title: str):
pass

class DigitalLibrary(AbstractLibrary):
def display_books(self):
print("Displaying e-books...")

# This will raise a TypeError because `find_book` is not implemented
library = DigitalLibrary()
TypeError: Can't instantiate abstract class DigitalLibrary with abstract methods find_book

This mechanism ensures that all concrete classes that inherit from AbstractLibrary are contractually obligated to implement all of its abstract methods, thus ensuring a consistent interface across all subclasses.

__str__ magic method

Also known as dunder methods (double underscore methods), these let you override default Python behavior or emulate built-in types. For example, __str__ for the string representation, or __eq__ to define equality between objects.

def __str__(self):
return f"Library with {len(self.books)} books"

__eq__ method

you can implement the __eq__ method in your Library class to compare the books attributes. Here's how you can do it:

def __eq__(self, other):
if not isinstance(other, Library):
return False
return sorted(self.books) == sorted(other.books)
lib1 = Library(["Book1", "Book2", "Book3"], some_shelf_api)
lib2 = Library(["Book3", "Book1", "Book2"], some_shelf_api)

# This will return True because they have the same book titles
print(lib1 == lib2)

That is all for the moment. I would come back here if eventually I think that some else belong to the classes fast primer.

--

--

Jose Ferro

Python coder. NLP. Pandas. Currently heavily involved with ipywidgets (ipysheet, ipyvuetify, ipycytoscape)