MULTIPLE INHERITANCE

In addressing the advanced topics in Python I would say to speak immediately of multiple inheritance, a very important feature. When we talked about inheritance, we saw how a base class can be derived from a superclass. Now we will address the topic of multiple inheritance fully supported in Python, which allows a base class to inherit from two or more classes.

Multiple inheritance

The derived class inherits from the two superclasses BClass and CClass. Two integer type attributes have been defined in them. Both attributes are inherited from AClass. Obviously the superclass methods are also inherited, let’s see an example.

Multiple inheritance

Now we have to ask ourselves what happens if two or more superclasses from which it inherits a base class have defined a method with the same signature and same name? Let’s see how Python handles method resolution.

Multiple inheritance

METHOD RESOLUTION ORDER (MRO)

The answer to the previous question is that the method present in BClass will be invoked, this is because Python for the resolution of the methods uses a certain rule that is called Method Resolution Order (MRO).

OPERATION OF THE MRO

At first, according to this algorithm, the xFunc() method is searched in the subclass AClass, if found it is immediately used and the search stops. Otherwise, the inheritance hierarchy is moved up one level and the superclasses are examined in the order in which they are defined in the subclass declaration, first BClass then CClass. Since the xFunc() function is defined in BClass, the method of this superclass will be executed. If this method is not defined in BClass then the CClass method will be executed if defined.

MRO

SEARCH MECHANISM

The search mechanism of the MRO can spread over the entire chain of hierarchies involving AClass, in the figure it can be seen that if the method is not defined in either BClass or CClass, the search continues. The method is defined in DClass, CClass being a subclass of it inherits the attribute which is automatically inherited also in AClass as a subclass of CClass. If the method was not defined even in DClass, the MRO would look for the parent object class of the entire inheritance chain in Python, so the last class from which it would look for the attribute is precisely the object class. If it is not found even in the parent class, an AttributeError exception is obtained.

MRO

THE OBJECT AND TYPE CLASSES

object and type they are at the top of the generalization chain in Python.

class MyClass:

       pass

myObj = MyClass()

Let’s start with the object class, all objects in Python are instances of this class. Python classes are also objects so MyClass() is an object instance. myObj is an instance of both MyClass() and object, being an object anyway. So both MyClass() and myObj are object instancestype is a base class also at the top of the generalization hierarchy that contains all instances of classes, both the default classes of the language, but also of the classes we define.

Object e type

RELATIONS BETWEEN TYPE AND OBJECT

object is a class and an object (everything in Python is an object) so object is an instance of itself. Being a class, object is also an instance of type. type is also a class and an object so it is an object instance and an instance of itself as a class.

Object e type
Object e type

THE BUILDER __NEW__

The __init__ method we used as a constructor is actually not a real constructor but an initializer to be more precise. So __init__ is not used to build the classes but to initialize them after the instance has been built with the __new__ statement.

new e init

Python first invokes the __new__ method which is a static method and does not need the decorator as it is a default method of the language. It creates an instance of a class, then invokes __init__ which finds the instance already created and initializes its attributes. __init__ receives in input an instance already manufactured with the __new__ method.

__new__(cls [, ….])

cls is the class of which we intend to produce an instance followed by optional arguments. The instance created and all the arguments of the __new__ constructor are in turn passed to __init__.

new e init

Generally an instance is built and returned by invoking the __new__ method in the superclass of MyClass() which in this case corresponds to object. __new__ is implemented by default in the object class. By invoking the superclass object with the super method, we leave it to create and return the instance of MyClass (). Once the instance has been created and returned, the program flow proceeds correctly by invoking __init__ to which the newly created instance is passed in the self argument and initializing the instance of MyClass().

Constructor new
Constructor new
class animale():
    def __new__(cls):
        istanza1 = super().__new__(cls)
        print(f'Istanza animale creata')
        return istanza1
    def __init__(self):
        print(f'Istanza animale inizializzata')
    def mangia(self):
        pass
    def respira(self):
        pass
class mammifero(animale):
    def __new__(cls,nome):
        istanza2 = super().__new__(cls)
        print(f'Istanza {nome} creata')
        return istanza2
    def __init__(self,nome):
         super().__init__()
         self.nome=nome
         print(f'Istanza {nome} inizializzata')
    def mangia(self):
        print(f'Classe mammifero istanza {self.nome} sta mangiando')
    def respira(self):
        print(f'Classe mammifero istanza {self.nome} sta respirando')
class persona(mammifero):
    def __new__(cls,nome,nomepersona,cognome):
        print(nomepersona)
        print(cognome)
        istanza3 = super().__new__(cls,nome)
        return istanza3
    def __init__(self,nome,nomepersona,cognome):
        super().__init__(nome)
        self.nomepersona=nomepersona
        self.cognome=cognome

    def denominazione(self):
        print(f'Classe persona denominazione : {self.nomepersona} {self.cognome}')
        
p = persona('persona','Luigi','Rossi')
p.denominazione()
p.mangia()
p.respira()
import math
#THIS IS A SMALL EDUCATIONAL PROGRAM THAT ILLUSTRATES
#THE MULTIPLE INHERITANCE. THREE IS NOT NEEDED IN A REAL PROGRAM
#CLASSES TO MANAGE SIMPLE MATHEMATICAL OPERATIONS.
class a(object):
    def __init__(self,numero):
           self.numero = numero
    def seno(self):
        s=f'Sono nella classe a. Il seno di {self.numero} è {math.sin(self.numero)}'
        print(s)
    def coseno(self):
        s=f'Il coseno di {self.numero} è {math.cos(self.numero)}'
        print(s)
class b:
    def __init__(self,numero):
        self.numero = numero
    def tangente(self):
        s=f'La tangente di {self.numero} è {math.tan(self.numero)}'
        print(s)
    def arcotangente(self):
        s=f'L\'arco tangente di {self.numero} è {math.atan(self.numero)}'
        print(s)
    def seno(self):
        s=f'Sono nella classe b. Il seno di {self.numero} è {math.sin(self.numero)}'
        print(s)
    def radice(self):
        s=f'Sono nella classe b. La radice di {self.numero} è {math.sqrt(self.numero)}'
        print(s)
class calcolatrice(a,b):
    def __init__(self,numero,valore):
        super().__init__(numero)
        s=f'Sono nella classe derivata valore = {valore}'
        print(s)
c = calcolatrice(144,90)
c.seno()
c.radice()
s=f'MRO: {calcolatrice.__mro__}'
print(s)
#LA CLASSE TYPE E OBJECT
class MyClass(object):
    pass
myObj = MyClass()
print(isinstance(myObj,MyClass))
print(isinstance(myObj,object))
print(isinstance(myObj,type))
print(isinstance(MyClass,object))
print(isinstance(MyClass,type))
print(isinstance(object,object))
print(isinstance(object,type))
print(isinstance(type,type))
print(isinstance(type,object))

ITERABLE AND ITERATORY

A container is a data structure containing a certain number of elements and which admits a membership test of the type True or False. The containers we have already encountered are List, Dictionary, Set, Tuple and String. In general, a container is also an Iterable object. The opposite is not true, that is, all iterables are containers, for example a file opened in Python is iterable but it is not a container that is still a good example of an iterable object. An iterable is an object that returns an iterator in order to be able to iterate through its elements. An iterable object must implement the __iter () __ method which returns an iterator. The iterator is an object that produces the next element of an iterable through the __next () __ method.

Iterable and Iterator

An object that implements both __iter() __ and __next() __ is both an Iterable and an Iterator. __iter() __ returns an instance of itself. Let’s see an example with a list that is an object that satisfies these two characteristics.

Iterable and Iterator
Iterable and Iterator

Using a for in loop to scroll through the list we’re doing the same thing, plus for in handles the StopIteration error.

Iterable and Iterator

IMPLEMENT AN ITERATOR

Example iterator
Example iterator
#The algorithm iterates between 0 and an integer value passed as a parameter
class MyIterator():
    def __iter__(self):
        self.myAttr = 0
        return self
    def __next__(self):
        if self.myAttr <= self.number:
            n=self.myAttr
            self.myAttr+=1
            return n
        else:
            raise StopIteration
    @property
    def number(self):
        return self.__number__
    @number.setter
    def number(self,number):
        self.__number__ = number

m = MyIterator()
m.number=300
myIter = iter(m)
for x in myIter:
    print(x)

GENERATOR FUNCTION

It is a function that behaves like a normal Python function except for one very important aspect, the yield keyword. If this keyword appears within a function then the entire function is transformed into an iterator. Where yield appears, the function stops, passes the current value of the iteration to the caller and if it is invoked again it restarts from the statement following yield.

Generator Function
keyword yield

RETURN INSTRUCTION

Within a generator function we can use the return statement which raises the StopIteration exception.

Generator Function
Generator Function

GENERATOR EXPRESSION

A generator expression is effectively equivalent to a List Comprehension. The figure shows the code of a List Comprehension. Once we have produced the new list we can scroll through it as many times as we want. This is not true with a Generator Expression that stops at the first iteration.

Generator expression
Generator expression

Let’s do the same example with a Generator Expression that uses round brackets instead of using square brackets. At the second iteration the Generate Expression does not print anything, this is because the generator was exhausted during the first iteration and therefore cannot be iterated again.

Generator expression
Generator expression

WHY CHOOSE A GENERATOR EXPRESSION?

At this point we must ask ourselves the question. why use Generator Expressions? The answer is this: A List Comprehension produces a list and the generation of this list is done right away in one go. Instead a Generator Expression is executed in a lazy way, that is, it produces its values at each single iteration. If the number of elements to be produced is very high, a Generator Expression is much more performing.

lazy load

DEEPENING

THE OBJECT AND TYPE CLASSES

In Python, the object and type classes play a fundamental role in implementing the type system and class hierarchy. Their thorough understanding is crucial for those who wish to master object-oriented programming (OOP) in Python. Below I will provide a detailed description of each of these classes and their role within the language.

The object Class

The object class is the base class for all classes in Python. This means that every class in Python, directly or indirectly, inherits from object. The object class defines some basic functionality that is available to all Python objects.

Main features of the object class:

-Universal Inheritance: All classes in Python, even user-defined classes, inherit from object. If you create a class without specifying a superclass, Python will automatically make it a subclass of object.

-Basic Methods: object provides some basic methods common to all Python objects, such as:

-__new__(cls): This is a static method that is responsible for creating a new instance of the class. It is called before __init__.

-__init__(self): It is the constructor of the class, used to initialize the object after its creation.

-__str__(self): Represents the object as a string and is called by the str() function.

-__repr__(self): Provides a string representation of the object, useful for debugging.

-__eq__(self, other): Defines the behavior of the equality operator ==.

-OOP Pure: Python, as of version 3, is a “pure” object-oriented language, meaning that even primitive types such as integers, strings, and lists are derived from object.

The Type Class

The type class in Python is a special class that plays a dual role: it is both the base class for all metaclasses and the default metaclass for all classes. A metaclass in Python is a “class of classes”-a class whose instance is a class.

Main features of class type:

-Creation of Classes: When you define a new class in Python, you are actually creating an instance of the type class. For example, if you write class MyClass: pass, Python executes in the background MyClass = type(‘MyClass’, (object,), {}).

-Function type(): The built-in function type() has two distinct uses:

-If called with an argument, it returns the class (type) of the object passed. For example, type(10) returns <class ‘int’>.

-If called with three arguments, type(name, bases, dict) is used to dynamically create a new class. For example, type(‘MyClass’, (object,), {‘x’: 5}) creates a new class called MyClass with a class variable x initialized to 5.

-Custom Metaclasses: Using type as a metaclass, custom metaclasses can be created, allowing modification of class creation and behavior. A metaclass is specified using the syntax class MyClass(metaclass=MyMetaClass):.

-Circular Response: An interesting aspect of class type is its circular relationship with object. In fact:

-object is an instance of type.

-type is a subclass of object.

-At the same time, type is an instance of itself, which creates a self-referential structure that allows Python to manage the type system.

Relationship between object and type

The relationship between object and type is essential to understanding Python’s type model:

-object is the base class of all classes, including classes derived from type.

-type is the metaclass of all classes, including object.

This circular relationship between type and object is what makes possible the flexibility of Python’s type system, allowing dynamic creation of classes and customization of their metaclasses.

Conclusion

An understanding of the object class and the type class is essential for anyone wishing to learn more about programming in Python. object provides the common basis for all objects in Python, while type handles the creation and behavior of the classes themselves. These concepts are at the heart of object-oriented programming in Python and offer great power and flexibility for creating advanced, custom code structures.

THE __NEW__ CONSTRUCTOR

The __new__ constructor in Python is a special method that plays a crucial role in the process of creating an object. Unlike __init__, which is used to initialize an already created object, __new__ is responsible for the actual creation of the object. Understanding how this method works can be very useful, especially when working with complex classes or when implementing custom metaclasses.

Features and Operation of __new__

1. Responsibility of __new__:

– The __new__ method is responsible for creating and allocating memory for a new instance of the class.

– It is the first method to be called when a new object is created.

– It is called before __init__.

2. Syntax of __new__:

– The signature of the __new__ method is generally as follows:

def __new__(cls, *args, **kwargs):
         # logic for creating the object.

– The cls parameter represents the class itself, similar to how self represents the instance in the __init__ method.

3. Typical Use of __new__:

– In many cases, there is no need to override __new__, since its default implementation is sufficient for most classes.

-You override __new__ mainly when you need to control the creation of new instances of a class, for example, to implement design patterns such as Singleton or when working with metaclasses.

4. Return of an Object:

– __new__ must always return a new instance of the class, which can be created by calling super().__new__(cls), or directly using object.__new__(cls).

– If __new__ returns an instance of a different class, the initialization process (__init__) of the current class will not be executed.

5. Practical Example:

– A simple example of using __new__ is the creation of a Singleton:

class Singleton:
        _instance = None

       def __new__(cls, *args, **kwargs):
            if cls._instance is None:
                cls._instance = super().__new__(cls)
                return cls._instance

       def __init__(self, value):
              self.value = value

# Test del Singleton
s1 = Singleton(10)
s2 = Singleton(20)

print(s1 is s2) # True
print(s1.value) # 20
print(s2.value) # 20

– In this example, __new__ ensures that only one Singleton instance is created, regardless of how many times the constructor is called.

Difference between __new__ and __init__.

– __new__: It is called to create a new instance of a class. It is a class method and receives the cls parameter, which represents the class itself. It is used when it is necessary to check the creation of the object, and must return the created object.

– __init__: It is called to initialize an object that has already been created (by __new__). It is an instance method and receives the self parameter, which represents the newly created object. __init__ does not have to return anything.

Conclusion

The __new__ method is a powerful tool in Python that provides fine control over object creation. Although not necessary in most cases, understanding how it works is important for advanced situations, such as creating specific design patterns or when working with metaclasses.

ITERABLES AND ITERATORS

In Python, iterables and iterators are fundamental concepts when working with collections of data. Let’s look in detail at what they mean and how they work.

Iterables (Iterable).

An object is considered iterable if it can be used in a (for) loop. Examples of iterables include lists, tuples, strings, dictionaries, sets, and any other object that implements the special __iter__() method.

When using a for loop on an iterable, Python automatically creates an iterator for that object and then uses it to iterate through the elements.

Examples of iterables:

– Lists: [1, 2, 3]

– Tuples: (1, 2, 3)

– Strings: “abc”

An iterable is an object that can return an iterator.

Iterators (Iterator)

An iterator is an object that represents a stream of data, can be iterated (you can go to the next element), and keeps track of its state during iteration. Iterators implement two special methods:

– __iter__(): returns the iterator object itself.

– __next__(): returns the next element in the sequence. If there are no more elements, it raises the StopIteration exception.

Iterators are used to iterate through the elements of an iterable. Once an iterator is consumed, it cannot be reused without re-initializing it.

Example of an iterator:

# Creare un iteratore da una lista
numbers = [1, 2, 3]
iterator = iter(numbers)

# Utilizzare l’iteratore
print(next(iterator)) # Output: 1
print(next(iterator)) # Output: 2
print(next(iterator)) # Output: 3
print(next(iterator)) # Solleva StopIteration

Differences between Iterable and Iterator.

– An iterable is an object that has the ability to return an iterator, but does not keep track of the state of the iteration.

– An iterator is an object that keeps track of the current state of the iteration and can be used to get the elements of an iterable one at a time.

Creating a Custom Iterator

You can create a custom iterator by defining a class that implements the __iter__() and __next__() methods.

Here is an example of an iterator that returns the numbers 1 through 5:

class MyIterator:
       def __init__(self):
             self.current = 1

       def __iter__(self):
             return self

       def __next__(self):
             if self.current <= 5:
                  result = self.current
                  self.current += 1
                  return result
            else:
                  raise StopIteration

# Utilizzo dell’iteratore
iterator = MyIterator()
for number in iterator:
print(number)

Conclusion

Iterables: Objects that can be iterated (lists, strings, etc.).

Iterators: Objects that maintain state and generate elements one at a time when requested via next().

Understanding the difference between iterables and iterators is essential to mastering data flow and sequence management in Python.

LINK TO GITHUB CODE

GITHUB

LINKS TO PREVIOUS POST

PREVIOUS POST LINKS