简体   繁体   中英

Is there any design pattern for implementing decoration of methods of child classes of an abstract class?

The case is such that I have an abstract class and a few child classes implementing it.

class Parent(metaclass=ABCMeta):

    @abstract_method
    def first_method(self, *args, **kwargs):
        raise NotImplementedError()

    @abstract_method
    def second_method(self, *args, **kwargs):
        raise NotImplementedError()


class Child(Parent):

    def first_method(self, *args, **kwargs):
        print('First method of the child class called!')

    def second_method(self, *args, **kwargs):
        print('Second method of the child class called!')

My goal is to make some kind of decorator, which will be used on methods of any child of the Parent class. I need this because every method make some kind of preparation before actually doing something, and this preparation is absolutely the same in all methods of all childs of the Parent class. Like:

class Child(Parent):

    def first_method(self, *args, **kwargs):
        print('Preparation!')
        print('First method of the child class called!')

    def second_method(self, *args, **kwargs):
        print('Preparation!')
        print('Second method of the child class called!')

The first thing came to my mind is to use Parent class method implementation: just remove "raise NotImplementedError()" and put some functionality, and then in child classes I would call, for example, super().first_method(self, *args, **kwargs) in the beginning of each method. It is good, but I also would want to return some data from the Parent method, and it would look weird when parent method and child method return something different in declaration. Not to mention that I would probably want to do some post-processing work after the method, so then I would need 2 different functions: for the beginning and after the performing the script.

The next thing I came up with is making MetaClass. Just implement all the decoration of methods in the new MetaClass during creating a class, and pass the newly generated data which is used in child methods to them in kwargs.

This is the closest solution to my goal, but it feels wrong anyway. Because it is not explicit that some kwargs will be passed to child methods, and if you are new to this code, then you need to do some researches to understand how it works. I feel like I overengineering or so.

So the question: is there any pattern or something along these lines to implement this functionality? Probably you can advise something better for my case? Thank you a lot in advance!

So, existing patterns apart: I won't know if this has an specific name, what you need, that would be a "pattern" is the use of "slots": that is - you document special named methods that will be called as part of the execution of another method. This other method then performs its setup code, checks if the slotted method (usually identifiable by name) exists, call them, with a plain simple method call, which will run the most specialized version of it, even if the special method that calls the slots is in the base class, and you are on a big class-inheritance hierarchy.

One plain example of this pattern is the way Python instantiates objects: what one actually invokes calling the class with the same syntax that is used for function calls ( MyClass() ) is that class's class (its metaclass) __call__ method. (Usally type.__call__ ). In Python's code for type.__call__ the class' __new__ method is called, then the class' __init__ method is called and finally the value returned by the first call, to __new__ is returned. A custom metaclass can modify __call__ to run whatever code it wants before, between, or after these two calls.

So, if this was not Python, all you'd need is to spec down this, and document that these methods should not be called directly, but rather through an "entry point" method - which could simply feature an "ep_" prefix. These would have to be fixed and hardcoded on a baseclass, and you'd need one for each of the methods you want to prefix/postfix code to.


class Base(ABC):
    def ep_first_method(self, *args, **kw);
        # prefix code...
        ret_val = self.first_method(*args, **kw)
        # postfix code...
        return ret_val

    @abstractmethod
    def first_method(self):
        pass
    
class Child(Base):
    def first_method(self, ...):
        ...

This being Python, it is easier to add some more magic to avoid code repetition and keep things concise.

One possible thing is to have a special class that, when detecting a method in a child class that should be called as a slot of a wrapper method, like above, to automatically rename that method: this way the entry point methods can feature the same name as the child methods - and better yet, a simple decorator can mark the methods that are meant to be "entrypoints", and inheritance would even work for them.

Basically, when building a new class we check all methods: if any of them has a correspondent part in the calling hierarchy which is marked as an entrypoint, the renaming takes place.

It is more practical if any entrypoint method will take as second parameter (the first being self ), a reference for the slotted method to be called.

After some fiddling: the good news is that a custommetaclass is not needed - the __init_subclass__ special method in a baseclass is enough to enable the decorator.

The bad news: due to re-entry iterations in the entry-point triggered by potential calls to "super()" on the final methods, a somewhat intricate heuristic to call the original method in the intermediate classes is needed. I also took care to put some multi-threading protections - although this is not 100% bullet-proof.

import sys
import threading
from functools import wraps


def entrypoint(func):
    name = func.__name__
    slotted_name = f"_slotted_{name}"
    recursion_control = threading.local()
    recursion_control.depth = 0
    lock = threading.Lock()
    @wraps(func)
    def wrapper(self, *args, **kw):
        slotted_method = getattr(self, slotted_name, None)
        if slotted_method is None:
            # this check in place of abstractmethod errors. It is only raised when the method is called, though
            raise TypeError("Child class {type(self).__name__} did not implement mandatory method {func.__name__}")

        # recursion control logic: also handle when the slotted method calls "super",
        # not just straightforward recursion
        with lock:
            recursion_control.depth += 1
            if recursion_control.depth == 1:
                normal_course = True
            else:
                normal_course = False
        try:
            if normal_course:
                # runs through entrypoint
                result = func(self, slotted_method, *args, **kw)
            else:
                # we are within a "super()" call - the only way to get the renamed method
                # in the correct subclass is to recreate the callee's super, by fetching its
                # implicit "__class__" variable.
                try:
                    callee_super = super(sys._getframe(1).f_locals["__class__"], self)
                except KeyError:
                    # callee did not make a "super" call, rather it likely is a recursive function "for real"
                    callee_super = type(self)
                slotted_method = getattr(callee_super, slotted_name)
                result = slotted_method(*args, **kw)

        finally:
            recursion_control.depth -= 1
        return result

    wrapper.__entrypoint__ = True
    return wrapper


class SlottedBase:
    def __init_subclass__(cls, *args, **kw):
        super().__init_subclass__(*args, **kw)
        for name, child_method in tuple(cls.__dict__.items()):
            #breakpoint()
            if not callable(child_method) or getattr(child_method, "__entrypoint__", None):
                continue
            for ancestor_cls in cls.__mro__[1:]:
                parent_method = getattr(ancestor_cls, name, None)
                if parent_method is None:
                    break
                if not getattr(parent_method, "__entrypoint__", False):
                    continue
                # if the code reaches here, this is a method that
                # at some point up has been marked as having an entrypoint method: we rename it.
                delattr (cls, name)
                setattr(cls, f"_slotted_{name}", child_method)
                break
        # the chaeegs above are inplace, no need to return anything


class Parent(SlottedBase):
    @entrypoint
    def meth1(self, slotted, a, b):
        print(f"at meth 1 entry, with {a=} and {b=}")
        result = slotted(a, b)
        print("exiting meth1\n")
        return result

class Child(Parent):
    def meth1(self, a, b):
        print(f"at meth 1 on Child, with {a=} and {b=}")

class GrandChild(Child):
    def meth1(self, a, b):
        print(f"at meth 1 on grandchild, with {a=} and {b=}")
        super().meth1(a,b)

class GrandGrandChild(GrandChild):
    def meth1(self, a, b):
        print(f"at meth 1 on grandgrandchild, with {a=} and {b=}")
        super().meth1(a,b)

c = Child()
c.meth1(2, 3)


d = GrandChild()
d.meth1(2, 3)

e = GrandGrandChild()
e.meth1(2, 3)

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM