Lecture 2: OOP: everything about inheritance, magic methods + metaclasses

Hello everyone! Let's talk about Object-Oriented Programming (OOP), but we're not going to delve into theoretical concepts, as some familiar concepts are not fully realized in Python.

Instead, we'll study only what you need to know specifically in the context of the Python language.

Inheritance

Let's start with inheritance. Class inheritance is used to change the behavior of a specific class, as well as to extend its functionality.

For example, let's say we have a ready-made class for a pet.

class Pet:
     def __init__(self, name=None):
        self.name = name

Let's imagine that we need to model the process of populating a planet with pets. But we're not interested in populating the planet with just any pets; we specifically want to populate it with dogs without changing the original Pet class.

To inherit the Pet class, we declare the Dog class and specify the parent class Pet in parentheses. The new class, created through inheritance, inherits all the attributes and methods of the parent class. In this case, the Pet class is the parent class, also known as the base class or superclass. The Dog class, on the other hand, is called a child class or subclass.

To change the behavior of the Dog class, we will override the __init__ method and add a new attribute breed, where we will store the dog's breed. In the new __init__ method, we will also call the parent class's initializer using the super() function. Additionally, we will add a new method say that will simply print a string.

class Dog(Pet):
    def __init__(self, name, breed=None):
        super().__init__(name)
        self.breed = breed
    def say(self):
        print(f'{self.name}: woof-woof!')

dog = Dog( 'Rex', 'Doberman ')
dog.say()

 # Output: Rex: woof-woof!

Multiple Inheritance

In Python, inheritance from multiple parent classes is allowed, which is also known as multiple inheritance. This technique is often used to implement mixin classes. Suppose we need to export data about our objects (dogs) in JSON format, either to store this data on a hard drive or to transfer it over a network. We can solve this problem using mixin classes and multiple inheritance.

Let's declare an ExportJSON class and implement a method there that exports data in JSON format. Then we'll create a new class called ExDog, which will inherit from the Dog class and the new mixin class ExportJSON.

import json

class ExportJSON:
    def to_json(self):
        return json.dumps({
            "name": self.name,
            "breed": self.breed
              })
class ExDog (Dog, ExportJSON):
    pass

dog = ExDog("Rex", "Doberman")
print(dog.to_json())

""" Output: {"name":"\u0428\u0430\u0440\u0438\u043a", "breed":"\u0414\u043e
 \u0431\u0435\u0440\u043c\u0430\u043d"}
 """

On one hand, this may seem convenient and flexible, but multiple inheritance and the use of a large number of mixins can reduce code readability. Therefore, it's important not to overuse this approach and avoid creating too many mixin classes.

Every class in Python is a descendant of the object class. We can easily verify this by using the issubclass function.

>>> issubclass(int, object)
True
>>> issubclass (Dog, object)
True
>>> issubclass (Dog, Pet)
True
>>> issubclass (Dog, int)
False

We can also use the `isinstance` function to check if a specific object is an instance of a certain class.

>>> isinstance(dog, Dog)
True
>>> isinstance(dog, Pet)
True
>>> isinstance(dog, object)
True

Python allows us to build fairly complex class hierarchies through inheritance. We've constructed a somewhat complex hierarchy: there's a class `ExDog`, which we created using multiple inheritance from the `Dog` class and the `ExportJSON` mixin class. In turn, the `Dog` class inherits from the `Pet` class, and all other classes inherit from the `object` class. If we try to create an instance of the `ExDog` class and access the `name` attribute, how does Python search for this attribute within the existing class hierarchy?

For this purpose, Python has something called the Method Resolution Order (MRO), which is a separate topic for study. However, all you need to know is the order in which Python searches for the necessary attribute or method. This order can be obtained using the `__mro__` attribute. It shows that if we try to access the `name` attribute, Python will first look in the `ExDog` class, then in the `Dog` class, and after that, it will check the `Pet` class, where it will find the `name` attribute. This list is also called the linearization of the class, meaning Python sequentially searches for any attributes and methods in this list. If it goes through the entire list and doesn't find the desired attribute or method, an `AttributeError` exception will be raised.

Try to follow the attribute search chain here. Python first searches entirely through the left branch, then through the second branch, and so on, in case a class inherits from multiple others.

# Method Resolution Order
>>> ExDog.__mro_
 (<class '__main__.ExDog'>, <class '__main__. Dog'>, <class
'__main__.Pet'>, <class '__main__.ExportJSON'>, <class 'object'>) 

Class Composition

In addition to single and multiple inheritance, Python offers an alternative approach called class composition. Let's recall the previous example where we had a Pet class, and we derived the Dog class from it. To enable objects of the Dog class to export data, we introduced a mixin class called ExportJSON.

The final class, ExDog, used multiple inheritance and derived from both the Dog class and ExportJSON. If we suddenly needed to export data not only in JSON format but also in another format (for example, XML), we would need another mixin class, ExportXML:

class ExportJSON:
    def to_json(self):
        pass
class ExportXML:
    def to_xml (self):
        pass

class ExDog (Dog, ExportJSON, ExportXML):
    pass

dog = ExDog('Fox', 'Mops')
dog.to_xml()
dog.to_json()

Let's imagine that we need to add several more methods for exporting data. In such a case, we would constantly have to modify the code of our `ExDog` class, adding new mixin classes, which could overly complicate the code. To avoid this, class composition is used.

We'll create a new class, `PetExport`, which will not be used for creating instances but only for inheritance:

Let's also recall the classes we already had before.

Now let's recreate the ExDog class without using multiple inheritance.

class ExDog (Dog):
    def __init__(self, name, breed=None, exporter=None):
        super().__init__(name, breed)
        self._exporter = exporter

    def export(self):
        return self._exporter.export(self)

Now let's try creating an instance of the `ExDog` class. Suppose we want the object of this class to be able to export its data in XML format. For this, we need to pass the appropriate exporter.

By the way, note that when using composition, the required object is created at the moment the specific program is executed.

dog = ('Rex', 'Mutt', exporter=ExportXML())
dog.export()

There’s just a little bit left. Let's try to implement the methods for export in the initial class hierarchy.

With JSON, it's simple – we use the json module and the dumps method.

class ExportJSON(PetExport):
    def export(self, dog):
        return json.dumps({
                "name": dog.name,
                "breed": dog.breed,
        })

Now let's implement the export method in the ExportXML

class ExportXML(PetExport):
    def export(self, dog):
        return f"""<?xml version="1.0" encoding="utf-8"?>
<dog>
    <name>{dog.name}</name>
    <breed>{dog.breed}</breed>
</dog>"""

However, it is generally inconvenient to define the export method every time. Let's slightly modify the ExDog class and set a default export method.

We'll also add a check to see if the passed object is an instance of the PetExport class and whether it can perform data export at all. For this, we can use the isinstance function.

What should we do if the object passed to the function cannot perform the export? In this case, we'll raise an exception (specifically, ValueError) – this will indicate that the program cannot continue its work and will be stopped.

class ExDog(Dog):
    def __init__(self, name, breed=None, exporter=None):
        super().__init__(name, breed)
        
        self._exporter = exporter or ExportJSON()
        
        if not isinstance(self._exporter, PetExport):
            raise ValueError('bad export instance value', exporter)

    def export(self):
        return self._exporter.export(self)

Now, if we ever need to add a new export method to the code, we can easily do so. We just need to declare a new class, add it to the existing export hierarchy, and we won't need to change the ExDog class. We will be able to export to various formats easily and conveniently in the final program by simply substituting the desired exporter or creating one.

Magic methods

Magic methods" are special methods defined within a class that begin and end with double underscores.

For example, the __init__ method is a magic method responsible for initializing a newly created object.

Let's define a User class that overrides the magic method __init__. In this method, we will store the provided name and email in the class attributes. We will also define a method that returns the class attributes as a dictionary

class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email
    def get_email_data(self):
        return {
            'name': self.name,
             'email': self.email
          }

adrian = User('Alexander', 'alexander@example.com')
print(adrian.get_email_data())
 # Output: {'name': 'Alexander', 'email': 'alexander@example.com'}

Another magic method is the __new__ method, which defines what happens at the moment of creating an object of a class. The __new__ method returns the newly created object of the class.

For example, let's create a Singleton class that guarantees that no more than one object of this class can be created.

We can even try to create two objects, a and b, which will end up being the same object.

class Singleton:
    instance = None

    def __new__(cls):
        if cls.instance is None:
            cls.instance = super().__new__(cls)
        return cls.instance

a = Singleton()
b = Singleton()

print(a is b)  # Output: True

There is also the __del__ method, which defines behavior when an object is deleted. However, it doesn't always work in an obvious way. This method is not called when we delete an object using the del operator, but when the reference count of the object reaches zero and the garbage collector is triggered.

Of course, this doesn't always happen when we expect it to, which is why overriding the __del__ method is undesirable, as it can lead to memory leaks or unpredictable behavior.

Another magic method is the __str__ method. This method defines what happens when the print function is called on an object of the class.

The __str__ method should define a readable description of our class, which the user can later display in the interface.

In the following example, we use the previously written User class, but now if we call the print function on an object of the class, a clear and readable name will be displayed:

class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email

    def __str__(self):
        return f'{self.name} <{self.email}>'

adrian = User('Alexander', 'alexander@example.com')
print(adrian)  # Output: Alexander <alexander@example.com>

Two other useful magic methods are __hash__ and __eq__, which define what happens when the hash function is called and how objects are compared.

The __hash__ magic method overrides the hashing function, which is used, for example, when retrieving keys in a dictionary.

In the following example, we will specify in the User class that the hash function will use the user's e-mail for hashing, and users will be compared based on their e-mail addresses.

Thus, if we create two users with different names but the same e-mail addresses, Python will indicate that they are the same object when comparison is performed, because the overridden __eq__ method compares users by their e-mail addresses:

class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email
    def __hash__(self):
        return hash(self.email)
    def __eq__(self, obj):
         return self.email == obj.email
alex = User('Alex', 'alex@example.com')
alexander= User( 'Alexander', 'alex@example.com') 
print(alex == alexander)

 # Output: True

The hash() function now returns the same value because only the e-mail addresses are compared, and in this case, they are identical.

print(hash(alex))
# Output: -3795776245241034452

print(hash(alexander))
# Output: -3795776245241034452

Very important magic methods are those that define access to attributes. These methods are __getattr__ and __getattribute__.

The __getattr__ method defines the behavior when an attribute we are trying to access is not found. The __getattribute__ method is called every time we access any attribute of an object.

Next, we will define a class and override the __getattribute__ method to always return the same string. As a result, no matter which attribute we access, the same string will be returned.

class Researcher:
    def __getattr__(self, name):
        return 'Nothing found :('
    def __getattribute__(self, name):
        return 'nope'
obj = Researcher()
print(obj.attr)
# Output: nope
print(obj.method)
# Output: nope
print(obj.DFG2H3J00KLL)
# Output: nope

The __getattr__ method is called when an attribute is not found. In the following example, within __getattribute__, which is always called, we log that we are trying to access the corresponding attribute and then proceed using the object class. If the attribute is still not found, the __getattr__ method is invoked:

class Researcher:
    def __getattr__(self, name):
        return 'Nothing found :()\n'
    def __getattribute__(self, name):
        print(f'Looking for {name}')
        return object.__getattribute__(self, name)
obj = Researcher()
print(obj.attr)
# Output: Looking for attr
# Nothing found :()

print(obj.method)
# Output: Looking for attr
# Nothing found :()

print(obj.DFG2H3J00KLL)
# Output: Looking for attr 21 
# Nothing found :()

The magic method __setattr__, as you might guess, defines the behavior when assigning a value to an attribute.

In this case, if we attempt to assign a value to an attribute, nothing will happen—the attribute will not be created.

class Ignorant:
    def __setattr__(self, name, value): 
        print(f'Not gonna set {name}!')

obj = Ignorant()
obj.math = True
# Output: Not gonna set math!

Finally, the __delattr__ method manages the behavior when deleting an attribute of an object. For example, it makes sense to use it if we want to cascade the deletion of objects related to the class.

In this case, we simply continue the deletion using the object class and log the fact that deletion is occurring.

class Polite:
    def __delattr__(self, name):
        value = getattr(self, name)
        print(f'Goodbye {name}, you were {value}!') 
        object.__delattr__(self, name)

obj = Polite()
obj.attr = 10
del obj.attr

 # Output: Goodbye attr, you were 10!

Another magic method is __call__, which defines the behavior when the class is called as a function. For example, with the __call__ method, we can define a logger that we can then use as a decorator (yes, a decorator can be not only a function but also a class!).

In the example below, when initializing the Logger class, the object of this class remembers the filename provided to it. Each time the class is called, it returns a new function according to the decorator protocol and logs a line in the log file about the function call.

In this case, we define an empty function, and the decorator logs all its calls.

class Logger:
    def __init__(self, filename):
        self.filename filename
    def __call__(self, func):
        def wrapped (*args, **kwargs):
            with open(self.filename, 'a') as f:
                f.write('Test text...')
            return func(*args, *kwargs)
        return wrapped

logger = Logger('log.txt')

@logger
def completely_useless_function():
    pass
completely_useless_function()
with open('log.txt') as f:
    print(f.read())
# Output: Test text...

In Python, the addition operation is handled by the __add__ operator (which is essentially a magic method). Similarly, subtraction can be overloaded using the __sub__ method.

As an example, let's define a class NoisyInt that behaves almost like an integer but adds noise during addition. To create random noise, we'll use the built-in random module.

import random 
class NoisyInt:
    def __init__(self, value): 
        self .value = value 
    def __add__(self, obj) 
        noise = random.uniform(-1, 1) 
        return self.value + obj.value + noise 

a = NoisyInt(10) 
b = Notsylnt(20) 

for _ in range(3):
    print(a + b) 
# Output: 
# 30.605646527205856 
# 30.170967742734117 
# 29.071231797981817

In Python, much of the magic when working with objects, classes, and methods is implemented using descriptors. To define your own descriptor, you need to implement the __get__, __set__, or __delete__ methods in a class.

After that, you can create a new class and assign an object of the descriptor type to an attribute of that class. This object will override the behavior for accessing the attribute (__get__), assigning values (__set__), or deleting the attribute (__delete__).

Let's create an object of the `Class` and see what happens when accessing an attribute:

class Descriptor: 
    def _get_ (self, obj, obj_type): 
        print('get') 

    def _set_ (self, obj, value): 
        print('set') 

    def _delete (self, obj): 
        print('delete') 
class Class: 
    attr = Descriptor() 

instance = Class()

That's how the __get__ method works:

instance.attr

#Output: get

And this is how the __delete__ method works:

del instance.attr

# Output: delete

And this is how the __set__ method works:

instance.attr = 10

# Output: set

Descriptors are a powerful mechanism that allows you to override attribute behavior in classes transparently for the user (or another programmer using your code). For example, we can define a Value descriptor that overrides the behavior when assigning it a value.

Let’s define a class with an attribute that will be a descriptor, and we will observe the modified behavior (multiplying the value by 10) when assigning a value

class Value:
    def __init__(self):
        self.value = None

    @staticmethod
    def _prepare_value(value):
        return value * 10

    def __get__(self, obj, obj_type):
        return self.value

    def __set__(self, obj, value):
        self.value = self._prepare_value(value)


class Class:
    attr = Value()

You can use this descriptor as follows:

obj = Class()
obj.attr = 5
print(obj.attr)  
# Output: 50

Even though you've been using functions and methods for a long time, you might not have realized that functions and methods are actually implemented using descriptors.

To understand that this is true, you can try accessing the same method through both an instance of the class and the class itself. It turns out that when you access a method via obj.method, it returns a bound method—a method tied to a specific object. But when you access the method from the class, Class.method, you get an unbound method—which is just a function.

This distinction highlights the role of descriptors in Python, where functions and methods are actually managed by the descriptor protocol to decide whether a method should be bound to an instance or left unbound.

As you can see, the same method returns different objects depending on how it is accessed. This is the behavior of a descriptor:

class Class:
  def method(self):
    pass

obj = Class()

print(obj.method)
# Output: <bound method Class.method of <_main_.Class object at 0x10ee77278>>

print(Class.method)
# Output: <function Class.method at 0x10ee3bea0>

You might already be familiar with the @property decorator, which allows a function to be used as a class attribute. In this case, we define a full_name method, which, although it is a function returning a string, can be used just like a regular attribute—without parentheses when called.

When full_name is accessed from an instance of the class, it calls the full_name function. However, if you try to access full_name from the class itself, what you get is a property object.

In fact, property is implemented using descriptors, which explains the different behavior depending on how the object is accessed. Here is an example to illustrate this:

class User:
  def __init__(self, first_name, last_name):
    self.first_name = first_name
    self.last_name = last_name

  @property
  def full_name(self):
    return f'{self.first_name} {self.last_name}'

user= User('Alex', 'Misyuk')

print(user.full_name)
# Output: Alex Misyuk

print(User.full_name)
# Output: <property object at ....>

To better understand how the property works, let's write our own class that mimics its behavior. We need to store the function that the property would normally receive.

When the attribute is accessed from the class, we'll just return self, and when the attribute is accessed from an instance, we'll call the stored function.

class Property:
    def __init__(self, getter):
        self.getter = getter

    def __get__(self, obj, obj_type=None):
        if obj is None:
            return self
        return self.getter(obj)

Let's test our newly created decorator alongside the standard @property. To do this, we'll create another class Class and use the custom @MyProperty decorator. Here's how you can implement it:

class Class:
  @property
  def original(self):
    return 'original'

  @Property
  def custom_sugar(self):
    return 'custom sugar'

  def custom_pure(self):
    return 'custom pure'

  custom_pure = Property(custom_pure)

Here's what we ended up with

obj = Class()

print(obj.original)
# Output: original

print(obj.custom_sugar)
# Output: custom sugar

print(obj.custom_pure)
# Output: custom pure

Exactly the same way, @staticmethod and @classmethod are implemented using descriptors. Let's write our own implementation of these decorators.

For StaticMethod, we'll simply store the function and return it when accessed:

class StaticMethod:
  def __init__(self, func):
    self.func = func

  def __get__(self, obj, obj_type=None):
    return self.func

Unlike StaticMethod, ClassMethod returns a function where the first argument is obj_type, i.e., the class:

class ClassMethod:
  def __init__(self, func):
    self.func = func

  def __get__(self, obj, obj_type=None):
    if obj_type is None:
      obj_type = type(obj)

    def new_func(*args, **kwargs):
      return self.func(obj_type, *args, **kwargs)

    return new_func

Another example of descriptors in the Python standard library is the __slots__ construct, which allows you to define a class with a fixed set of attributes.

Typically, each class has a dictionary where all its attributes are stored. However, this can often be excessive. If your code has a large number of objects, you might not want to create a dictionary for each object. In such cases, the __slots__ construct allows you to specify a fixed number of attributes that the class can have.

In the following example, we stipulate that our class should only have the attribute anakin.

class Class:
  __slots__ = ['anakin']

  def __init__(self):
    self.anakin = 'the chosen one'

If we try to add another attribute to the class, it won't work. To demonstrate this more clearly, let's use the interactive mode:

>>> obj = Class()
>>> obj.luke = 'the chosen two'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Class' object has no attribute 'luke'

The __slots__ construct is implemented by defining descriptors for each of the attributes.

Metaclasses

As you already know, everything in Python is an object, and classes are no exception. This means that these classes are created by something. Let's define a class named Class and create an instance of it. The type of the object is Class because Class created it.

For clarity, let's use the interactive mode.

>>> class Class:
...     pass
...
>>> obj = Class()
>>> type(obj)
<class '__main__.Class'>

However, the class itself also has a type, specifically type, because type is the one that created the class. In this case, type is a metaclass, meaning it creates other classes.

>>> type(Class)
<class 'type'>

It is very important to understand the difference between creation and inheritance. In this case, the class is not a subclass of type. type creates it, but the class does not inherit from type; instead, it inherits from the object class.

>>> issubclass(Class, type)
False

>>> issubclass(Class, object)
True

To understand how classes are defined, you can write a simple function that returns a class. In the following example, we define a function that returns a class. Classes can be created on the fly, and in this case, we create two different objects and return them.

def dummy_factory():
  class Class:
    pass
  return Class

Dummy = dummy_factory()

Dummy() is Dummy()

However, Python actually works differently. Classes are created using the metaclass type, and you can dynamically create a class by calling type and passing it the class name, a tuple of base classes, and a dictionary of attributes. For example, let's create a class NewClass without any parents or attributes.

This is a real class; we created it on the fly without using the class literal.

>>> NewClass = type('NewClass', (), {})
>>> NewClass

<class '__main__.NewClass'>

>>> NewClass()

<__main__.NewClass object at 0x7f29936ea908>

Let's define our own metaclass Meta, which will manage the behavior during class creation. To be a metaclass, it must inherit from another metaclass (type). The metaclass method __new__ takes the class name, its parents, and attributes. Next, we can define a new class A and specify that its metaclass is Meta.

This metaclass will control the behavior when creating a new class. Thus, we can override the behavior of class creation (for example, add an attribute or do something else).

class Meta(type):
  def __new__(cls, name, parents, attrs):
    print(f'Creating {name}')
    return super().__new__(cls, name, parents, attrs)

class A(metaclass=Meta):
  pass

# Output: Creating A

For example, we can define a metaclass that overrides the __init__ method, so that every class created by this metaclass will keep track of all created subclasses. The new __init__ method records its own attribute, which will store a dictionary of created classes.

In the following example, we first create a class Base, with Meta as its metaclass. This class will have a class attribute registry, where we will store all its subclasses. Each time we create a class that inherits from Base, we record in registry the corresponding value, which includes the name of the created class and a reference to it.

class Meta(type):
  def __init__(cls, name, bases, attrs):
    print('Initializing {}'.format(name))
    if not hasattr(cls, 'registry'):
      cls.registry = {}
    else:
      cls.registry[name.lower()] = cls
    super().__init__(name, bases, attrs)

class Base(metaclass=Meta):
  pass

class A(Base):
  pass

class B(Base):
  pass

Comments (0)

Leave a comment