Python: Class / OOP

OOP stands for object oriented programming.

  • we can store data (attributes) and methods inside an object

  • we can create numerous instances of the same object, but all are independent

Creating a class

An object is an instance of a class, which is something like a blueprint for that object.
A class is defined with the keyword class followed by a name of your choice (like names for variables and methods).
A class consists of at least one method called __init__. It’s executed when a new object of that class is created.
Typically a class holds data, which can be inserted when an instance is created.

class custom_class:
    def __init__(self, param1, param2):
        # self refers to the object itself, the parameters to additional data
        self.param1 = param1 # create an internal variable param1 and assign the value param1 (from the outside) to it
        self.param2 = param2
        local_variable = (119, 201, 170) # without self it's a local variable, which can't be accessed from outside

Create instances of that class

A new object is created through calling the class like a function and assign this to a variable.

a = custom_class(1, 4)
# Another instance of that class. This does not affect the object a.
b = custom_class('hello', ' world!')

It’s possible to access data inside the class with the . notation:

a.param1
1
b.param2
' world!'

It’s possible to change the values with the same notation:

a.param1 = -1 # override param1
a.param1
-1
# Local variables can't be accessed from outside:
a.local_variable
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
/tmp/ipykernel_2832/2981977348.py in <module>
      1 # Local variables can't be accessed from outside:
----> 2 a.local_variable

AttributeError: 'custom_class' object has no attribute 'local_variable'

Additional internal methods

Classes become powerful through internal methods:

class custom_class:
    def __init__(self, param1, param2):
        self.param1 = param1
        self.param2 = param2
        
    def sum_(self):
        '''Return the sum of the parameters.'''
        return self.param1 + self.param2
# Create an instance:
a = custom_class(1, 4)

# Call the method sum_
a.sum_()
5
b = custom_class('hello', ' world!')
b.sum_()
'hello world!'

__str__(self)

A well defined class should return meaningful output when we call the print function on it:

print(b)
<__main__.custom_class object at 0x7f159c7b4940>

This is not yet meaningful. We have to define the output through a internal method called __str__, which is called when the object is inserted into the print() function.

class custom_class:
    def __init__(self, param1, param2):
        self.param1 = param1
        self.param2 = param2
        
    def __str__(self):
        ''' __str__ is called when you convert an object into
        a string (through print(), str()).'''
        return f'{self.param1}, {self.param2}'
        
    def sum_(self):
        '''Return the sum of the parameters.'''
        return self.param1 + self.param2
b = custom_class('hello', ' world!')
print(b)
hello,  world!
str(b)
'hello,  world!'

__repr__(self)

Optional we could define a method called __repr__:

class custom_class:
    '''A custom class for ...'''
    
    def __init__(self, param1, param2):
        self.param1 = param1
        self.param2 = param2
        
    def __str__(self):
        ''' __str__ is called when you convert an object into
        a string (through print(), str()).'''
        return f'{self.param1}, {self.param2}'
    
    def __repr__(self):
        '''Used for a more elaborate output (for debugging).'''
        return (f'{self.__class__.__name__}('
                f'{self.param1!r}, {self.param2!r})')
        
    def sum_(self):
        '''Return the sum of the parameters.'''
        return self.param1 + self.param2
b = custom_class('hello', ' world!')
print(b)
print(repr(b))
hello,  world!
custom_class('hello', ' world!')

__doc__

The docstring of functions is accessible through the method __doc__:

custom_class.__doc__
'A custom class for ...'
custom_class.sum_.__doc__
'Return the sum of the parameters.'
# It's possible to call the methods through instances of the class as well:
b.sum_.__doc__
'Return the sum of the parameters.'

You can inspect more methods through dir():

dir(custom_class)
['__class__',
 '__delattr__',
 '__dict__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__getattribute__',
 '__gt__',
 '__hash__',
 '__init__',
 '__init_subclass__',
 '__le__',
 '__lt__',
 '__module__',
 '__ne__',
 '__new__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__setattr__',
 '__sizeof__',
 '__str__',
 '__subclasshook__',
 '__weakref__',
 'sum_']

When are classes useful?

If you need to use the same object (data) multiple times or if you need multiple instances of the same object

For example fpdf makes use of classes and when we create a FPDF(), we create one instance of the class FPDF(). Then this instance is used through the whole programm. It has a lot of own functions like add_page(), set_font() etc. and it stores all the data that we put into it.

Markov class

In the following use case we’ll define a very simple (and not fully elaborated) class for generating text with a markov chain. First we’ll insert a parameter to insert a text corpora into the class:

class Markov():
    '''Generate a text with a simple one-word based markov chain.'''
    def __init__(self, txt):
        self.txt = txt # Holds the text corpora
txt = '''The quick brown fox jumps over the lazy dog. The lazy programmer jumps over the fire fox.'''

m = Markov(txt)
# Check that the text is available:
print(m.txt)
The quick brown fox jumps over the lazy dog. The lazy programmer jumps over the fire fox.

Next the class needs a dictionary to hold the probabilities. This results in a clean and organized program, because we don’t have to deal with the dictionary outside of the functionality of the Markov chain.
The dictionary will be created internally, so we don’t need to insert a parameter for that into __init__:

class Markov():
    '''Generate a text with a simple one-word based markov chain.'''
    def __init__(self, txt):
        self.txt = txt # Holds the text corpora.
        self.dictionary = {} # Holds the dictionary for probabilities.

Then we need a method to create that dictionary based on the text corpora:

class Markov():
    '''Generate a text with a simple one-token word markov chain.'''
    def __init__(self, txt, txt_lower=False):
        self.txt = txt.lower() if txt_lower else txt # Holds the text corpora.
        self.dictionary = {} # Holds the dictionary for probabilities.
        
    def create_dictionary(self):
        # Split txt into a list:
        txt = self.txt.lower().split()
        
        self.dictionary = {}
        
        for i in range(len(txt)-1):
            
            # The current token (i) and the next tokens (i+n) are key.
            key = txt[i]

            # The next token after the last token of key is the corresponding value.
            value = txt[i+1]
            
            # First check if the key exists in the dictionary already.
            if key in self.dictionary.keys():
                # If yes, append the value to the list.
                self.dictionary[key].append(value)

            # Else insert the new key + the value in form of a [list].
            else:
                self.dictionary[key] = [value]
m = Markov(txt)
m.create_dictionary()
m.dictionary
{'the': ['quick', 'lazy', 'lazy', 'fire'],
 'quick': ['brown'],
 'brown': ['fox'],
 'fox': ['jumps'],
 'jumps': ['over', 'over'],
 'over': ['the', 'the'],
 'lazy': ['dog.', 'programmer'],
 'dog.': ['the'],
 'programmer': ['jumps'],
 'fire': ['fox.']}

The last part is a method to generate a sentence:

class Markov():
    '''Generate a text with a simple one-word based markov chain.'''
        
    def __init__(self, txt, txt_lower=False):    
        self.txt = txt.lower() if txt_lower else txt # Holds the text corpora.
        self.dictionary = {} # Holds the dictionary for probabilities.
        
    def create_dictionary(self):
        # Split txt into a list:
        txt = self.txt.lower().split()
        
        self.dictionary = {}
        
        for i in range(len(txt)-1):
            
            # The current token (i) and the next tokens (i+n) are key.
            key = txt[i]

            # The next token after the last token of key is the corresponding value.
            value = txt[i+1]
            
            # First check if the key exists in the dictionary already.
            if key in self.dictionary.keys():
                # If yes, append the value to the list.
                self.dictionary[key].append(value)

            # Else insert the new key + the value in form of a [list].
            else:
                self.dictionary[key] = [value]
                
    def generate_sentence(self, inp_):
        import random
        # Transform input into a list
        gen_txt = inp_.split()
        
        while not gen_txt[-1].endswith('.'):
            new_token = random.choice(self.dictionary[gen_txt[-1].lower()])
            gen_txt.append(new_token)
            
        # Return generated text as string:
        return ' '.join(gen_txt)
m = Markov(txt)
m.create_dictionary()
new_text = m.generate_sentence('The')
print(new_text)
The quick brown fox jumps over the quick brown fox jumps over the lazy dog.
for i in range(3):
    print(m.generate_sentence('The'))
The fire fox.
The quick brown fox jumps over the fire fox.
The quick brown fox jumps over the quick brown fox jumps over the lazy dog.

Inheritance

The second powerful quality of OOP is inheritance. With that it’s possible to create a new class based on an existing class, which will inherite all the functionality of the parent class and can modify it or add new functionality to it.

Extend the custom class

First we’ll use the custom class from above, create a child class from that and add a new method.

class custom_class:
    def __init__(self, param1, param2):
        self.param1 = param1
        self.param2 = param2
        
    def sum_(self):
        '''Return the sum of the parameters.'''
        return self.param1 + self.param2

Syntax for that: The parent class is inserted into the child class:

class child_custom_class(custom_class): # Parent class is inserted.
    # The child class inherits all methods (like __init__()).
    
    # Additional (new) method:
    def division(self):
        '''Return a / b.'''
        return self.param1 / self.param2

The parent class is inserted as an argument into the child class. The child class inherits everything from the parent class, so it’s not necessary to write a __init__() function again.

c = child_custom_class(27, 9)
print(c.sum_())
print(c.division())
36
3.0

Customize FPDF

In the following we’ll create a child class of FPDF() and add a new method to it.

from fpdf import FPDF

class custom_FPDF(FPDF):
    
    def lorem(self):
        txt = '''
Dolorem rerum minima illum in rerum. Dolor totam qui id. Nemo voluptatem consectetur porro voluptatem quis ullam et. Totam pariatur nobis molestiae voluptas.

Sed optio aspernatur eius labore et quisquam est placeat. Deleniti incidunt amet dolor et illum doloremque consectetur quidem. Laborum est officia temporibus reiciendis dolores repudiandae. Quaerat est nostrum aspernatur laborum error. Pariatur doloribus ut molestiae quo expedita modi asperiores. Et est ut dicta suscipit sed doloremque rerum.

Suscipit aut saepe animi. Neque id doloremque harum ut voluptatibus. Optio et modi perspiciatis. Libero sint non dolor quia dignissimos. Et quae deserunt iusto pariatur est ipsum odit nemo. Alias sed expedita asperiores autem eaque autem.

Sed inventore illo non ducimus. Iure blanditiis maxime vitae quia et reiciendis iste. Minus provident aut vero facilis.

Esse magnam reiciendis est magni aut consequuntur. Earum mollitia harum minus. Fuga voluptatum ad voluptate incidunt officiis. Enim minus reprehenderit beatae deleniti adipisci.
'''

        return txt
pdf = custom_FPDF()
pdf.set_font('Courier')
pdf.set_font_size(14)

pdf.add_page()
pdf.multi_cell(0, None, txt=pdf.lorem())   
pdf.output('custom_FDPF_lorem.pdf')

We can override existing methods as well. The method page_no() has this code:

def page_no(self):
    """Get the current page number"""
    return self.page

We can insert and modify it inside the custom class. (Although it may be better to insert a new function for that or just calculate it when necessary.)

from fpdf import FPDF

class custom_FPDF(FPDF):
    
    def lorem(self):
        txt = '''
Dolorem rerum minima illum in rerum. Dolor totam qui id. Nemo voluptatem consectetur porro voluptatem quis ullam et. Totam pariatur nobis molestiae voluptas.

Sed optio aspernatur eius labore et quisquam est placeat. Deleniti incidunt amet dolor et illum doloremque consectetur quidem. Laborum est officia temporibus reiciendis dolores repudiandae. Quaerat est nostrum aspernatur laborum error. Pariatur doloribus ut molestiae quo expedita modi asperiores. Et est ut dicta suscipit sed doloremque rerum.

Suscipit aut saepe animi. Neque id doloremque harum ut voluptatibus. Optio et modi perspiciatis. Libero sint non dolor quia dignissimos. Et quae deserunt iusto pariatur est ipsum odit nemo. Alias sed expedita asperiores autem eaque autem.

Sed inventore illo non ducimus. Iure blanditiis maxime vitae quia et reiciendis iste. Minus provident aut vero facilis.

Esse magnam reiciendis est magni aut consequuntur. Earum mollitia harum minus. Fuga voluptatum ad voluptate incidunt officiis. Enim minus reprehenderit beatae deleniti adipisci.
'''

        return txt
    
    def page_no(self):
        """Get the current squared page number"""
        return pow(2, self.page)
pdf = custom_FPDF()
pdf.set_font('Courier')
pdf.set_font_size(14)

for i in range(4):
    pdf.add_page()
    pdf.multi_cell(0, None, txt=pdf.lorem())
    pdf.set_y(-30)
    pdf.cell(w=0, txt=str(pdf.page_no()), align='C')

pdf.output('custom_FDPF_page_no.pdf')

Multiple FPDF objects

# Create 3 instances and store them in a list.
pdfs = [custom_FPDF() for i in range(3)]
# Set font and font size for each.
from random import choice, randint

for pdf in pdfs:
    pdf.set_font(choice(['Helvetica', 'Times', 'Courier', 'Symbol', 'ZapfDingbats']))
    pdf.set_font_size(randint(10, 36))
# Outer loop:
index = 0
while len(pdfs) > 0:
    
    # Loop through pdf objects:
    for pdf in pdfs:
        pdf.add_page()
        pdf.multi_cell(0, None, txt=pdf.lorem())
        pdf.set_y(-30)
        pdf.cell(w=0, txt=str(pdf.page_no()), align='C')
        
    # Finish one pdf each outer loop:
    out = pdfs.pop()
    out.output(f'multiple_custom_FPDFs_{index}.pdf')
    index += 1