How to enforce a subclass to implement a parent cl

2019-07-17 05:59发布

问题:

I have the following code to compare a base class' current (empty) implementation of required functions to its sub-classes, which must implement them in some different way in order to be considered acceptable at runtime. Without using a metaclass=ABCMeta and implementing @abstractmethod decorators on these base class methods, how do I go about doing this? For now, I'm writing the following __init_subclass__ hook on my ad-hoc, metaclass-less abstract base classes in multiple places in my project, but it feels wrong.

import inspect

class AbstractThing:
    def __init__(self, topic: str, thing: Thing):
        thing.subscriptions[topic] = self.on_message
        thing.on_connected.append(self.on_connected)
        thing.on_disconnected.append(self.on_disconnected)

    def __init_subclass__(cls):
        required_methods = ['on_connected', 'on_disconnected', 'on_message']
        for f in required_methods:
            func_source = inspect.getsourcelines(getattr(cls, f))
            # if this class no longer inherits from `Object`, the method resolution order will have updated
            parent_func_source = inspect.getsourcelines(getattr(cls.__mro__[-2], f))
            if func_source == parent_func_source:
                raise NotImplementedError(f"You need to override method '{f}' in your class {cls.__name__}")

    def on_connected(self, config: dict):
        pass

    def on_disconnected(self):
        pass

    def on_message(self, msg: str):
        pass

Is there a better way to do this? Bonus points if I can get typechecking errors in my editor while defining sub classes of this AbstractThing.

回答1:

Indeed, you should not rely on inspect.getsourcelines for any code that should be used in serious contexts (i.e. outside experimentation realm, or tools to deal with the source-code itself)

The plain and simple is operator is enough to check if the method in a given class is the same as in the base class. (In Python 3. Python 2 users have to take care that methods are retrieved as unbound methods instead of the raw-functions)

Other than that, you are taking several unneeded turns to get to the base-class itself - the little documented and little used special variable __class__ can help you with that: it is an automatic reference to the class body where it is written (do not mistake with self.__class__ which is a reference to the sub-class instead).

From the documentation:

This class object is the one that will be referenced by the zero-argument form of super(). __class__ is an implicit closure reference created by the compiler if any methods in a class body refer to either __class__ or super. This allows the zero argument form of super() to correctly identify the class being defined based on lexical scoping, while the class or instance that was used to make the current call is identified based on the first argument passed to the method.

So, while keeping your main approach, the whole thing can be quite simpler:

def __init_subclass__(cls):
    required_methods = ['on_connected', 'on_disconnected', 'on_message']
    for f in required_methods:
         if getattr(cls, f) is getattr(__class__, f):
              raise NotImplementedError(...)

If you have a complex hierarchy, and will have parent classes with other mandatory methods that the subclasses of those will have to implement - and therefore, can't hard code the needed methods in the required_methods, you can still use the abstractmethod decorator from abc, without using the ABCMeta metaclass. All the decorator does is to create an attribute on the method that is checked on the metaclass. Just make the same check in a __init_subclass__ method:

from abc import abstractmethod

class Base:
   def __init_subclass__(cls, **kw):
        super().__init_subclass__(**kw)
        for attr_name in dir(cls):
            method = getattr(cls, attr_name)
            if (getattr(method, '__isabstractmethod__', False) and
                    not attr_name in cls.__dict__):
                # The second condition above allows 
                # abstractmethods to exist in the class where 
                # they are defined, but not on further subclasses
                raise NotImplementedError(...)

class NetworkMixin(Base):
    @abstractmethod
    def on_connect(self):
         pass

class FileMixin(Base):
    @abstractmethod
    def on_close(self):
         pass

class MyFileNetworkThing(NetworkMixin, FileMixin):
    # if any of the two abstract methods is not
    # implemented, Base.__init_subclass__ will fail

Keep in mind this just checks for methods that show up in a class' dir. But customizing __dir__ is used seldon enough for it to be reliable - just take care to document that.