简体   繁体   中英

Python: mark method as implementing/overriding

Given a 'contract' of sorts that I want to implement, I want the code to

  1. tell the reader what the intent is
  2. allow the type checker to correct me (fragile base class problem)

Eg in C++, you can

class X: public Somethingable {
  int get_something() const override
  { return 10; }
};

Now when I rename Somethingable::get_something (to plain something for instance), the compiler will error on my X::get_something because it is not an override (anymore).

In C# the reader gets even more information:

class X : Somethingable {
  int GetSomething() implements Somethingable.GetSomething { return 10; }
}

In Python, we can use abc.ABC and @abstractmethod to annotate that subclasses have to define this and that member, but is there a standardised way to annotate this relation on the implementation site?

class X(Somethingable):
  @typing.implements(Somethingable.get_something) # does not exist
  def get_something(self):
     return 10

I was overestimating the complexity of such solution, it is shorter:

import warnings

def override(func):
    if hasattr(func, 'fget'):  # We see a property, go to actual callable
        func.fget.__overrides__ = True
    else:
        func.__overrides__ = True
    return func


class InterfaceMeta(type):
    def __new__(mcs, name, bases, attrs):
        for name, a in attrs.items():
            f = getattr(a, 'fget', a)
            if not getattr(f, '__overrides__', None): continue
            f = getattr(f, '__wrapped__', f)
            try:
                base_class = next(b for b in bases if hasattr(b, name))
                ref = getattr(base_class, name)
                if type(ref) is not type(a):
                    warnings.warn(f'Overriding method {name} messes with class/static methods or properties')
                    continue
                if _check_lsp(f, ref):
                    warnings.warn(f'LSP violation for method {name}')
                    continue
            except StopIteration:
                warnings.warn(f'Overriding method {name} does not have parent implementation')
        return super().__new__(mcs, name, bases, attrs)

override decorator can mark overriding methods, and InterfaceMeta confirms that these methods do exist in superclass. _check_lsp is the most complex part of this, I'll explain it below.

What is actually going on? First, we take a callable and add an attribute to it from the decorator. Then metaclass looks for methods with this marker and:

  • confirms, that at least one of base classes implements it
  • checks, that property remains property, classmethod remains classmethod and staticmethod remains staticmethod
  • checks, that implementation does not break Liskov substitution principle.

Usage

def stupid_decorator(func):
    """Stupid, because doesn't use `wrapt` or `functools.wraps`."""
    def inner(*args, **kwargs):
        return func(*args, **kwargs)
    return inner

class IFoo(metaclass=InterfaceMeta):
    def foo(self): return 'foo'
    @property
    def bar(self): return 'bar'
    @classmethod
    def cmethod(cls): return 'classmethod'
    @staticmethod
    def smethod(): return 'staticmethod'
    def some_1(self): return 1
    def some_2(self): return 2

    def single_arg(self, arg): return arg
    def two_args_default(self, arg1, arg2): return arg1
    def pos_only(self, arg1, /, arg2, arg3=1): return arg1
    def kwonly(self, *, arg1=1): return arg1

class Foo(IFoo):
    @override
    @stupid_decorator  # Wrong signature now: "self" not mentioned. With "self" in decorator won't fail.
    def foo(self): return 'foo2'
 
    @override
    @property
    def baz(self): return 'baz'

    @override
    def quak(self): return 'quak'

    @override
    @staticmethod
    def cmethod(): return 'Dead'

    @override
    @classmethod
    def some_1(cls): return None

    @override
    def single_arg(self, another_arg): return 1

    @override
    def pos_only(self, another_arg, / , arg2, arg3=1): return 1

    @override
    def two_args_default(self, arg1, arg2=1): return 1

    @override
    def kwonly(self, *, arg2=1): return 1

This warns:

LSP violation for method foo
Overriding method baz does not have parent implementation
Overriding method quak does not have parent implementation
Overriding method cmethod messes with class/static methods or properties
Overriding method some_1 messes with class/static methods or properties
LSP violation for method single_arg
LSP violation for method kwonly

You can set the metaclass on Foo as well with the same result.

LSP

LSP (Liskov substitution principle) is a very important concept that, in particular, postulates that any parent class can be substituted with any child class without interface incompatibilities. _check_lsp performs only the very simple checking, ignoring type annotations (it is mypy area, I won't touch it!). It confirms that

  • *args and **kwargs do not disappear
  • positional-only args count is same
  • all parent's regular (positional-or-keyword) args are present with the same name, do not lose default values (but may change) and all added have defaults
  • same for keyword-only args

Implementation follows:

from inspect import signature, Parameter
from itertools import zip_longest, chain

def _check_lsp(child, parent):
    child = signature(child).parameters
    parent = signature(parent).parameters

    def rearrange(params):
        return {
            'posonly': sum(p.kind == Parameter.POSITIONAL_ONLY for p in params.values()),
            'regular': [(name, p.default is Parameter.empty) 
                        for name, p in params.items() 
                        if p.kind == Parameter.POSITIONAL_OR_KEYWORD],
            'args': next((p for p in params.values() 
                          if p.kind == Parameter.VAR_POSITIONAL), 
                         None) is not None,
            'kwonly': [(name, p.default is Parameter.empty) 
                       for name, p in params.items() 
                       if p.kind == Parameter.KEYWORD_ONLY],
            'kwargs': next((p for p in params.values() 
                            if p.kind == Parameter.VAR_KEYWORD), 
                           None) is not None,
        }
    
    child, parent = rearrange(child), rearrange(parent)
    if (
        child['posonly'] != parent['posonly'] 
        or not child['args'] and parent['args'] 
        or not child['kwargs'] and parent['kwargs']
    ):
        return True


    for new, orig in chain(zip_longest(child['regular'], parent['regular']), 
                           zip_longest(child['kwonly'], parent['kwonly'])):
        if new is None and orig is not None:
            return True
        elif orig is None and new[1]:
            return True
        elif orig[0] != new[0] or not orig[1] and new[1]:
            return True

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