9.20 利用函數(shù)注解實(shí)現(xiàn)方法重載

2018-02-24 15:27 更新

問(wèn)題

你已經(jīng)學(xué)過(guò)怎樣使用函數(shù)參數(shù)注解,那么你可能會(huì)想利用它來(lái)實(shí)現(xiàn)基于類(lèi)型的方法重載。但是你不確定應(yīng)該怎樣去實(shí)現(xiàn)(或者到底行得通不)。

解決方案

本小節(jié)的技術(shù)是基于一個(gè)簡(jiǎn)單的技術(shù),那就是Python允許參數(shù)注解,代碼可以像下面這樣寫(xiě):

class Spam:
    def bar(self, x:int, y:int):
        print('Bar 1:', x, y)

    def bar(self, s:str, n:int = 0):
        print('Bar 2:', s, n)

s = Spam()
s.bar(2, 3) # Prints Bar 1: 2 3
s.bar('hello') # Prints Bar 2: hello 0

下面是我們第一步的嘗試,使用到了一個(gè)元類(lèi)和描述器:

# multiple.py
import inspect
import types

class MultiMethod:
    '''
    Represents a single multimethod.
    '''
    def __init__(self, name):
        self._methods = {}
        self.__name__ = name

    def register(self, meth):
        '''
        Register a new method as a multimethod
        '''
        sig = inspect.signature(meth)

        # Build a type signature from the method's annotations
        types = []
        for name, parm in sig.parameters.items():
            if name == 'self':
                continue
            if parm.annotation is inspect.Parameter.empty:
                raise TypeError(
                    'Argument {} must be annotated with a type'.format(name)
                )
            if not isinstance(parm.annotation, type):
                raise TypeError(
                    'Argument {} annotation must be a type'.format(name)
                )
            if parm.default is not inspect.Parameter.empty:
                self._methods[tuple(types)] = meth
            types.append(parm.annotation)

        self._methods[tuple(types)] = meth

    def __call__(self, *args):
        '''
        Call a method based on type signature of the arguments
        '''
        types = tuple(type(arg) for arg in args[1:])
        meth = self._methods.get(types, None)
        if meth:
            return meth(*args)
        else:
            raise TypeError('No matching method for types {}'.format(types))

    def __get__(self, instance, cls):
        '''
        Descriptor method needed to make calls work in a class
        '''
        if instance is not None:
            return types.MethodType(self, instance)
        else:
            return self

class MultiDict(dict):
    '''
    Special dictionary to build multimethods in a metaclass
    '''
    def __setitem__(self, key, value):
        if key in self:
            # If key already exists, it must be a multimethod or callable
            current_value = self[key]
            if isinstance(current_value, MultiMethod):
                current_value.register(value)
            else:
                mvalue = MultiMethod(key)
                mvalue.register(current_value)
                mvalue.register(value)
                super().__setitem__(key, mvalue)
        else:
            super().__setitem__(key, value)

class MultipleMeta(type):
    '''
    Metaclass that allows multiple dispatch of methods
    '''
    def __new__(cls, clsname, bases, clsdict):
        return type.__new__(cls, clsname, bases, dict(clsdict))

    @classmethod
    def __prepare__(cls, clsname, bases):
        return MultiDict()

為了使用這個(gè)類(lèi),你可以像下面這樣寫(xiě):

class Spam(metaclass=MultipleMeta):
    def bar(self, x:int, y:int):
        print('Bar 1:', x, y)

    def bar(self, s:str, n:int = 0):
        print('Bar 2:', s, n)

# Example: overloaded __init__
import time

class Date(metaclass=MultipleMeta):
    def __init__(self, year: int, month:int, day:int):
        self.year = year
        self.month = month
        self.day = day

    def __init__(self):
        t = time.localtime()
        self.__init__(t.tm_year, t.tm_mon, t.tm_mday)

下面是一個(gè)交互示例來(lái)驗(yàn)證它能正確的工作:

>>> s = Spam()
>>> s.bar(2, 3)
Bar 1: 2 3
>>> s.bar('hello')
Bar 2: hello 0
>>> s.bar('hello', 5)
Bar 2: hello 5
>>> s.bar(2, 'hello')
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
    File "multiple.py", line 42, in __call__
        raise TypeError('No matching method for types {}'.format(types))
TypeError: No matching method for types (<class 'int'>, <class 'str'>)
>>> # Overloaded __init__
>>> d = Date(2012, 12, 21)
>>> # Get today's date
>>> e = Date()
>>> e.year
2012
>>> e.month
12
>>> e.day
3
>>>

討論

坦白來(lái)講,相對(duì)于通常的代碼而已本節(jié)使用到了很多的魔法代碼。但是,它卻能讓我們深入理解元類(lèi)和描述器的底層工作原理,并能加深對(duì)這些概念的印象。因此,就算你并不會(huì)立即去應(yīng)用本節(jié)的技術(shù),它的一些底層思想?yún)s會(huì)影響到其它涉及到元類(lèi)、描述器和函數(shù)注解的編程技術(shù)。

本節(jié)的實(shí)現(xiàn)中的主要思路其實(shí)是很簡(jiǎn)單的。MutipleMeta 元類(lèi)使用它的 __prepare__() 方法來(lái)提供一個(gè)作為 MultiDict 實(shí)例的自定義字典。這個(gè)跟普通字典不一樣的是,MultiDict 會(huì)在元素被設(shè)置的時(shí)候檢查是否已經(jīng)存在,如果存在的話,重復(fù)的元素會(huì)在 MultiMethod實(shí)例中合并。

MultiMethod 實(shí)例通過(guò)構(gòu)建從類(lèi)型簽名到函數(shù)的映射來(lái)收集方法。在這個(gè)構(gòu)建過(guò)程中,函數(shù)注解被用來(lái)收集這些簽名然后構(gòu)建這個(gè)映射。這個(gè)過(guò)程在 MultiMethod.register() 方法中實(shí)現(xiàn)。這種映射的一個(gè)關(guān)鍵特點(diǎn)是對(duì)于多個(gè)方法,所有參數(shù)類(lèi)型都必須要指定,否則就會(huì)報(bào)錯(cuò)。

為了讓 MultiMethod 實(shí)例模擬一個(gè)調(diào)用,它的 __call__() 方法被實(shí)現(xiàn)了。這個(gè)方法從所有排除 slef 的參數(shù)中構(gòu)建一個(gè)類(lèi)型元組,在內(nèi)部map中查找這個(gè)方法,然后調(diào)用相應(yīng)的方法。為了能讓 MultiMethod 實(shí)例在類(lèi)定義時(shí)正確操作,__get__() 是必須得實(shí)現(xiàn)的。它被用來(lái)構(gòu)建正確的綁定方法。比如:

>>> b = s.bar
>>> b
<bound method Spam.bar of <__main__.Spam object at 0x1006a46d0>>
>>> b.__self__
<__main__.Spam object at 0x1006a46d0>
>>> b.__func__
<__main__.MultiMethod object at 0x1006a4d50>
>>> b(2, 3)
Bar 1: 2 3
>>> b('hello')
Bar 2: hello 0
>>>

不過(guò)本節(jié)的實(shí)現(xiàn)還有一些限制,其中一個(gè)是它不能使用關(guān)鍵字參數(shù)。例如:

>>> s.bar(x=2, y=3)
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
TypeError: __call__() got an unexpected keyword argument 'y'

>>> s.bar(s='hello')
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
TypeError: __call__() got an unexpected keyword argument 's'
>>>

也許有其他的方法能添加這種支持,但是它需要一個(gè)完全不同的方法映射方式。問(wèn)題在于關(guān)鍵字參數(shù)的出現(xiàn)是沒(méi)有順序的。當(dāng)它跟位置參數(shù)混合使用時(shí),那你的參數(shù)就會(huì)變得比較混亂了,這時(shí)候你不得不在 __call__() 方法中先去做個(gè)排序。

同樣對(duì)于繼承也是有限制的,例如,類(lèi)似下面這種代碼就不能正常工作:

class A:
    pass

class B(A):
    pass

class C:
    pass

class Spam(metaclass=MultipleMeta):
    def foo(self, x:A):
        print('Foo 1:', x)

    def foo(self, x:C):
        print('Foo 2:', x)

原因是因?yàn)?x:A 注解不能成功匹配子類(lèi)實(shí)例(比如B的實(shí)例),如下:

>>> s = Spam()
>>> a = A()
>>> s.foo(a)
Foo 1: <__main__.A object at 0x1006a5310>
>>> c = C()
>>> s.foo(c)
Foo 2: <__main__.C object at 0x1007a1910>
>>> b = B()
>>> s.foo(b)
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
    File "multiple.py", line 44, in __call__
        raise TypeError('No matching method for types {}'.format(types))
TypeError: No matching method for types (<class '__main__.B'>,)
>>>

作為使用元類(lèi)和注解的一種替代方案,可以通過(guò)描述器來(lái)實(shí)現(xiàn)類(lèi)似的效果。例如:

import types

class multimethod:
    def __init__(self, func):
        self._methods = {}
        self.__name__ = func.__name__
        self._default = func

    def match(self, *types):
        def register(func):
            ndefaults = len(func.__defaults__) if func.__defaults__ else 0
            for n in range(ndefaults+1):
                self._methods[types[:len(types) - n]] = func
            return self
        return register

    def __call__(self, *args):
        types = tuple(type(arg) for arg in args[1:])
        meth = self._methods.get(types, None)
        if meth:
            return meth(*args)
        else:
            return self._default(*args)

    def __get__(self, instance, cls):
        if instance is not None:
            return types.MethodType(self, instance)
        else:
            return self

為了使用描述器版本,你需要像下面這樣寫(xiě):

class Spam:
    @multimethod
    def bar(self, *args):
        # Default method called if no match
        raise TypeError('No matching method for bar')

    @bar.match(int, int)
    def bar(self, x, y):
        print('Bar 1:', x, y)

    @bar.match(str, int)
    def bar(self, s, n = 0):
        print('Bar 2:', s, n)

描述器方案同樣也有前面提到的限制(不支持關(guān)鍵字參數(shù)和繼承)。

所有事物都是平等的,有好有壞,也許最好的辦法就是在普通代碼中避免使用方法重載。不過(guò)有些特殊情況下還是有意義的,比如基于模式匹配的方法重載程序中。舉個(gè)例子,8.21小節(jié)中的訪問(wèn)者模式可以修改為一個(gè)使用方法重載的類(lèi)。但是,除了這個(gè)以外,通常不應(yīng)該使用方法重載(就簡(jiǎn)單的使用不同名稱的方法就行了)。

在Python社區(qū)對(duì)于實(shí)現(xiàn)方法重載的討論已經(jīng)由來(lái)已久。對(duì)于引發(fā)這個(gè)爭(zhēng)論的原因,可以參考下Guido van Rossum的這篇博客:Five-Minute Multimethods in Python

以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)