臥槽,好強大的魔法,竟能讓Python支援方法過載
1. 你真的瞭解方法過載嗎?
方法過載是面向物件中一個非常重要的概念,在類中包含了成員方法和構造方法。如果類中存在多個同名,且引數(個數和型別)不同的成員方法或構造方法,那麼這些成員方法或構造方法就被過載了。下面先給出一個Java的案例。
class MyOverload { public MyOverload() { System.out.println("MyOverload"); } public MyOverload(int x) { System.out.println("MyOverload_int:" + x); } public MyOverload(long x) { System.out.println("MyOverload_long:" + x); } public MyOverload(String s, int x, float y, boolean flag) { System.out.println("MyOverload_String_int_float_boolean:" + s + x + y + flag); } }
這是一個Java類,有4個構造方法,很明顯,這4個構造方法的引數個數和型別都不同。其中第2個構造方法和第3個構造方法儘管都有一個引數,但型別分別是int和long。而在Java中,整數預設被識別為int型別,如果要輸入long型別的整數,需要後面加L,如20表示int型別的整數,而20L則表示long型別的整數。
如果要呼叫這4個構造方法,可以使用下面的程式碼:
new MyOverload(); new MyOverload(20); new MyOverload(20L); new MyOverload("hello",1,20.4f,false);
編譯器會根據傳入構造方法的引數值確定呼叫哪一個構造方法,例如,在分析new MyOverload(20)時,20被解析為int型別,所以會呼叫 public MyOverload(int x) {...}構造方法。
以上是Java語言中構造方法過載的定義和處理過程。Java之所以支援方法過載,是因為可以通過3個維度來確認到底使用哪一個過載形式,這3個維度是:
(1)方法名
(2)資料型別
(3)引數個數
如果這3個維度都相同,那麼就會認為存在相同的構造方法,在編譯時就會丟擲異常。
方法的引數還有一種特殊形式,就是預設引數,也就是在定義引數時指定一個預設值,如果在呼叫該方法時不指定引數值,就會使用預設的引數值。
class MyClass { public test(int x, String s = "hello") { ... ... } }
如果執行下面的程式碼,仍然是呼叫test方法。
new MyClass().test(20);
不過可惜的是,Java並不支援預設引數值,所以上面的形式並不能在Java中使用,如果要實現預設引數這種效果,唯一的選擇就是方法過載。從另一個角度看,預設引數其實與方法過載是異曲同工的,也就是過程不同,但結果相同。所以Java並沒有同時提供兩種形式。
2. Python為什麼在語法上不支援方法過載
首先下一個結論,Python不支援方法過載,至少在語法層次上不支援。但可以通過變通的方式來實現類似方法過載的效果。也就是說,按正常的方式不支援,但你想讓他支援,那就支援。要知詳情,繼續看下面的內容。
我們先來看一下Python為什麼不支援方法過載,前面說過,方法過載需要3個維度:方法名、資料型別和引數個數。但Python只有2個維度,那就是引數名和引數個數。所以下面的程式碼是沒辦法實現過載的。
class MyClass: def method(self, x,y): pass def method(self, a, b): pass
在這段程式碼中,儘管兩個method方法的形參名不同,但這些引數名在呼叫上無法區分,也就是說,如果使用下面的程式碼,Python編譯器根本不清楚到底應該呼叫哪一個method方法。
MyClass().method(20, "hello")
由於Python是動態語言,所以變數的型別隨時可能改變,因此,x、y、a、b可能是任何型別,所以就不能確定,20到底是x或a了。
不過Python有引數註解,也就是說,可以在引數後面標註資料型別,那麼是不是可以利用這個註解實現方法過載呢?看下面的程式碼:
class MyClass: def method(self, x: int): print('int:', x) def method(self, x: str): print('str:',x) MyClass().method(20) MyClass().method("hello")
在這段程式碼中,兩個method方法的x引數分別使用了int註解和str註解標註為整數型別和字串型別。並且在呼叫時分別傳入了20和hello。不過輸出的卻是如下內容:
str: 20 str: hello
這很顯然都是呼叫了第2個method方法。那麼這是怎麼回事呢?
其實Python的類,就相當於一個字典,key是類的成員標識,value就是成員本身。不過可惜的是,在預設情況下,Python只會用成員名作為key,這樣以來,兩個method方法的key是相同的,都是method。Python會從頭掃描所有的方法,遇到一個方法,就會將這個方法新增到類維護的字典中。這就會導致後一個方法會覆蓋前一個同名的方法,所以MyClass類最後就剩下一個method方法了,也就是最後定義的method方法。所以就會輸出前面的結果。也就是說,引數註解並不能實現方法的過載。
另外,要注意一點,引數註解也只是一個標註而已,與註釋差不多。並不會影響傳入引數的值。也就是說,將一個引數標註為int,也可以傳入其他型別的值,如字串型別。這個標註一般用作元資料,也就是給程式進行二次加工用的。
3. 用黑魔法讓Python支援方法過載
既然Python預設不支援方法過載,那麼有沒有什麼機制讓Python支援方法過載呢?答案是:yes。
Python中有一種機制,叫魔法(magic)方法,也就是方法名前後各有兩個下劃線(_)的方法。如__setitem__、__call__等。通過這些方法,可以干預類的整個生命週期。
先說一下實現原理。在前面提到,類預設會以方法名作為key,將方法本身作為value,儲存在類維護的字典中。其實這裡可以做一個變通,只要利用魔法方法,將key改成方法名與型別的融合體,那麼就可以區分具體的方法了。
這裡的核心魔法方法是__setitem__,該方法在Python解析器沒掃描到一個方法時呼叫,用於將方法儲存在字典中。該方法有兩個引數:key和value。key預設就是方法名,value是方法物件。我們只要改變這個key,將其變成方法名和型別的組合,就能達到我們的要求。
我們採用的方案是建立一個MultiMethod類,用於儲存同名方法的所有例項,而key不變,仍然是方法名,只是value不再是方法物件,而是MultiMethod物件。然後MultiMethod內部維護一個字典,key是同名方法的型別組成的元組,value是對應的方法物件。
另外一個核心魔法方法是__call__,該方法在呼叫物件方法時被呼叫,可以在該方法中掃描呼叫時傳入的值參的型別,然後將引數型別轉換成元組,再到MultiMethod類維護的字典中搜索具體的方法例項,並在__call__方法中呼叫該方法例項,最後返回執行結果。
現在給出完整的實現程式碼:
import inspect import types class MultiMethod: def __init__(self, name): self._methods = {} self.__name__ = name def register(self, meth): ''' 根據方法引數型別註冊一個新方法 ''' sig = inspect.signature(meth) # 用於儲存方法引數的型別 types = [] for name, parm in sig.parameters.items(): # 忽略self if name == 'self': continue if parm.annotation is inspect.Parameter.empty: raise TypeError( '引數 {} 必須使用型別註釋'.format(name) ) if not isinstance(parm.annotation, type): raise TypeError( '引數 {} 的註解必須是資料型別'.format(name) ) if parm.default is not inspect.Parameter.empty: self._methods[tuple(types)] = meth types.append(parm.annotation) self._methods[tuple(types)] = meth # 當呼叫MyOverload類中的某個方法時,會執行__call__方法,在該方法中通過引數型別註解檢測具體的方法例項,然後呼叫並返回執行結果 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: raise TypeError('No matching method for types {}'.format(types)) def __get__(self, instance, cls): if instance is not None: return types.MethodType(self, instance) else: return self class MultiDict(dict): def __setitem__(self, key, value): if key in self: # 如果key存在, 一定是MultiMethod型別或可呼叫的方法 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): def __new__(cls, clsname, bases, clsdict): return type.__new__(cls, clsname, bases, dict(clsdict)) @classmethod def __prepare__(cls, clsname, bases): return MultiDict() # 任何類只要使用MultileMeta,就可以支援方法過載 class MyOverload(metaclass=MultipleMeta): def __init__(self): print("MyOverload") def __init__(self, x: int): print("MyOverload_int:", x) def bar(self, x: int, y:int): print('Bar 1:', x,y) def bar(self, s:str, n:int): print('Bar 2:', s, n) def foo(self, s:int, n:int): print('foo:', s, n) def foo(self, s: str, n: int): print('foo:', s, n) def foo(self, s: str, n: int, xx:float): print('foo:', s, n) def foo(self, s: str, n: int, xx:float,hy:float): print('foo:', s, n) my = MyOverload(20) # 呼叫的是第2個構造方法 my.bar(2, 3) my.bar('hello',20) my.foo(2, 3) my.foo('hello',20)
執行程式,會輸出如下的執行結果:
MyOverload_int: 20 Bar 1: 2 3 Bar 2: hello 20 foo: 2 3 foo: hello 20
很顯然,構造方法、Bar方法和foo方法都成功過載了。以後如果要讓一個類可以過載方法,可以直接使用MultipleMeta類(通過metaclass指定)。