Note: phiên bản Tiếng Việt của bài này ở link dưới.

https://duongnt.com/init_subclass-metaclass-vie

Use init_subclass to replace metaclass

Metaclasses are interesting features of the Python language. They allow us to dynamically inquire about a class and modify its behavior. But in many situations, metaclasses are overkilled and can actually complicate a class hierarchy. From version 3.6, Python provides a new method called __init_subclass__ to replace metaclasses in such cases.

Overview of metaclasses

A metaclass is a special class that inherits directly from type. It defines a __new__ method that is run when a class is imported, but before any instance of that class is initialized.

class DummyMeta(type):
    def __new__(cls, name, bases, class_dict):
        # Code to run when classes that use this metaclass are imported

class DummyClass(metaclass=DummyClass):
    # Code to define this class

Below is the meaning of each parameter in __new__ method.

  • cls: the current metaclass, which is DummyMeta in this case.
  • name: the name of the class that uses this metaclass. It is DummyClass in this case.
  • bases: all parent classes of DummyClass.
  • class_dict: class members of DummyClass. They include both class attributes and methods.

Use a metaclass to enforce snake case in other classes

By convention, method names and attributes in Python use snake case. We will create a metaclass to raise exceptions when a class defines class attributes or methods using camel case or pascal case. This check will be performed right after we import that class, before any of its instances are created.

First, we define a SnakeCaseMeta metaclass.

class SnakeCaseMeta(type):
    def __new__(cls, name, bases, class_dict):
        not_camel_case = set()

        for ele in class_dict:
            if cls._not_snake_case(ele) and ele not in not_camel_case:
                not_camel_case.add(ele)

        if not_camel_case:
            raise ValueError(f'The following members are not using snake case: {", ".join(not_camel_case)}')

        return type.__new__(cls, name, bases, class_dict)

    @classmethod
    def _not_snake_case(cls, txt):
        return txt.lower() != txt

Inside __new__, we access all class attributes and methods, and verify that their names are using snake case. All names not using snake case are stored in a list, so that we can display them in the error message.

Note that the _not_snake_case method is a very simple implementation, we only check if the text contains any uppercase characters. This is enough for demonstration purposes, but is not ready for production.

Using SnakeCaseMeta is trivial.

class Animal(metaclass=SnakeCaseMeta):
    def EatMethod(self):
        print('This animal can eat.')

    def sleepMethod(self):
        print('This animal can sleep.')

When we try to import Animal‘s module, the interpreter will throw the following error.

ValueError: The following members are not using snake case: EatMethod, sleepMethod

Not only that, the same verification is also applied to all child class of Animal.

The limitation of metaclasses

We need to define a metaclass

Let’s say we have a hierarchy of animal classes, all inherited from Animal.

Animal classes with metaclass

Because Animal uses SnakeCaseMeta as its metaclass, all its children are also verified. This is a good thing. But it would be even better if we could get rid of the metaclass and move all verifications into the base class itself.

A class can only have one metaclass

Now, let’s say we want to log every time an animal class is imported. We can do this by defining a new metaclass.

class LogImportMeta(type):
    def __new__(cls, name, bases, class_dict):
        print(f'Import class {name}')

    return type.__new__(cls, name, bases, class_dict)

But there is a problem: a class cannot use two different metaclasses. Animal cannot use LogImportMeta because it has already used SnakeCaseMeta. Here, we have a few solutions, but none of them is clean.

  • Move the code inside LogImportMeta into SnakeCaseMeta. This violates the single-responsibility principle, because now SnakeCaseMeta is doing two unrelated things.
  • Create a metaclasses’ hierarchy as below. This increases the complexity of our code. And such hierarchy is sketchy anyway. Metaclasses hierarchy

Replace metaclasses with init_subclass

From version 3.6, Python introduced a new feature called __init_subclass__. In most cases, we can replace Metaclasses by implementing the __init__subclass__ method. Below is how to use it to verify that a class is only using snake case.

class VerifySnakeCase:
    def __init_subclass__(cls):
        super().__init_subclass__()

        not_camel_case = set()
        for ele in cls.__dict__:
            if cls._not_snake_case(ele) and ele not in not_camel_case:
                not_camel_case.add(ele)

        if not_camel_case:
            raise ValueError(f'The following members are not in snake case: {", ".join(not_camel_case)}')

    @classmethod
    def _not_snake_case(cls, txt):
        return txt.lower() != txt

Now, we can apply VerifySnakeCase to other classes by using inheritance.

class Animal(VerifySnakeCase):
    # omitted

Likewise, we can define a new LogImport class.

class LogImport:
    def __init_subclass__(cls):
        super().__init_subclass__()
        print(f'Import {cls}')

Then we can use both VerifySnakeCase and LogImport at the same time.

class Animal(VerifySnakeCase, LogImport):
    # omitted

Conclusion

Metaprogramming is one of the most interesting features of Python. It makes Python an extremely powerful and flexible language. And with the new __init__subclass__ method, metaprogramming becomes a little easier and simpler.

A software developer from Vietnam and is currently living in Japan.

Leave a Reply