英文:https://arpitbhayani.me/blogs/function-overloading

作者:arprit

譯者:豌豆花下貓(“Python貓”公眾號作者)

宣告:本翻譯是出於交流學習的目的,基於 CC BY-NC-SA 4.0 授權協議。為便於閱讀,內容略有改動。

函式過載指的是有多個同名的函式,但是它們的簽名或實現卻不同。當呼叫一個過載函式 fn 時,程式會檢驗傳遞給函式的實參/形參,並據此而呼叫相應的實現。

  1. int area(int length, int breadth) {
  2. return length * breadth;
  3. }
  4. float area(int radius) {
  5. return 3.14 * radius * radius;
  6. }

在以上例子中(用 c++ 編寫),函式 area 被過載了兩個實現。第一個函式接收兩個引數(都是整數),表示矩形的長度和寬度,並返回矩形的面積。另一個函式只接收一個整型引數,表示圓的半徑。

當我們像 area(7) 這樣呼叫函式 area 時,它會呼叫第二個函式,而 area(3,4) 則會呼叫第一個函式。

為什麼 Python 中沒有函式過載?

Python 不支援函式過載。當我們定義了多個同名的函式時,後面的函式總是會覆蓋前面的函式,因此,在一個名稱空間中,每個函式名僅會有一個登記項(entry)。

Python貓注:這裡說 Python 不支援函式過載,指的是在不用語法糖的情況下。使用 functools 庫的 singledispatch 裝飾器,Python 也可以實現函式過載。原文作者在文末的註釋中專門提到了這一點。

通過呼叫 locals() 和 globals() 函式,我們可以看到 Python 的名稱空間中有什麼,它們分別返回區域性和全域性名稱空間。

  1. def area(radius):
  2. return 3.14 * radius ** 2
  3. >>> locals()
  4. {
  5. ...
  6. 'area': <function area at 0x10476a440>,
  7. ...
  8. }

在定義一個函式後,接著呼叫 locals() 函式,我們會看到它返回了一個字典,包含了定義在區域性名稱空間中的所有變數。字典的鍵是變數的名稱,值是該變數的引用/值。

當程式在執行時,若遇到另一個同名函式,它就會更新區域性名稱空間中的登記項,從而消除兩個函式共存的可能性。因此 Python 不支援函式過載。這是在創造語言時做出的設計決策,但這並不妨礙我們實現它,所以,讓我們來過載一些函式吧。

在 Python 中實現函式過載

我們已經知道 Python 是如何管理名稱空間的,如果想要實現函式過載,就需要這樣做:

  • 維護一個虛擬的名稱空間,在其中管理函式定義
  • 根據每次傳遞的引數,設法呼叫適當的函式

為了簡單起見,我們在實現函式過載時,通過不同的引數數量來區分同名函式。

把函式封裝起來

我們建立了一個名為Function的類,它可以封裝任何函式,並通過重寫的__call__方法來呼叫該函式,還提供了一個名為key的方法,該方法返回一個元組,使該函式在整個程式碼庫中是唯一的。

  1. from inspect import getfullargspec
  2. class Function(object):
  3. """Function類是對標準的Python函式的封裝"""
  4. def __init__(self, fn):
  5. self.fn = fn
  6. def __call__(self, *args, **kwargs):
  7. """當像函式一樣被呼叫時,它就會呼叫被封裝的函式,並返回該函式的返回值"""
  8. return self.fn(*args, **kwargs)
  9. def key(self, args=None):
  10. """返回一個key,能唯一標識出一個函式(即便是被過載的)"""
  11. # 如果不指定args,則從函式的定義中提取引數
  12. if args is None:
  13. args = getfullargspec(self.fn).args
  14. return tuple([
  15. self.fn.__module__,
  16. self.fn.__class__,
  17. self.fn.__name__,
  18. len(args or []),
  19. ])

在上面的程式碼片段中,key函式返回一個元組,該元組唯一標識了程式碼庫中的函式,並且記錄了:

  • 函式所屬的模組
  • 函式所屬的類
  • 函式名
  • 函式接收的引數量

被重寫的__call__方法會呼叫被封裝的函式,並返回計算的值(這沒有啥特別的)。這使得Function的例項可以像函式一樣被呼叫,並且它的行為與被封裝的函式完全一樣。

  1. def area(l, b):
  2. return l * b
  3. >>> func = Function(area)
  4. >>> func.key()
  5. ('__main__', <class 'function'>, 'area', 2)
  6. >>> func(3, 4)
  7. 12

在上面的例子中,函式area被封裝在Function中,並被例項化成func。key() 返回一個元組,其第一個元素是模組名__main__,第二個是類<class 'function'>,第三個是函式名area,而第四個則是該函式接收的引數數量,即 2。

這個示例還顯示出,我們可以像呼叫普通的 area函式一樣,去呼叫例項 func,當傳入引數 3 和 4時,得到的結果是 12,這正是呼叫 area(3,4) 時會得到的結果。當我們接下來運用裝飾器時,這種行為將會派上用場。

構建虛擬的名稱空間

我們要建立一個虛擬的名稱空間,用於儲存在定義階段收集的所有函式。

由於只有一個名稱空間/登錄檔,我們建立了一個單例類,並把函式儲存在字典中。該字典的鍵不是函式名,而是我們從 key 函式中得到的元組,該元組包含的元素能唯一標識出一個函式。

通過這樣,我們就能在登錄檔中儲存所有的函式,即使它們有相同的名稱(但不同的引數),從而實現函式過載。

  1. class Namespace(object):
  2. """Namespace是一個單例類,負責儲存所有的函式"""
  3. __instance = None
  4. def __init__(self):
  5. if self.__instance is None:
  6. self.function_map = dict()
  7. Namespace.__instance = self
  8. else:
  9. raise Exception("cannot instantiate a virtual Namespace again")
  10. @staticmethod
  11. def get_instance():
  12. if Namespace.__instance is None:
  13. Namespace()
  14. return Namespace.__instance
  15. def register(self, fn):
  16. """在虛擬的名稱空間中註冊函式,並返回Function類的可呼叫例項"""
  17. func = Function(fn)
  18. self.function_map[func.key()] = fn
  19. return func

Namespace類有一個register方法,該方法將函式 fn 作為引數,為其建立一個唯一的鍵,並將函式儲存在字典中,最後返回封裝了 fn 的Function的例項。這意味著 register 函式的返回值也是可呼叫的,並且(到目前為止)它的行為與被封裝的函式 fn 完全相同。

  1. def area(l, b):
  2. return l * b
  3. >>> namespace = Namespace.get_instance()
  4. >>> func = namespace.register(area)
  5. >>> func(3, 4)
  6. 12

使用裝飾器作為鉤子

既然已經定義了一個能夠註冊函式的虛擬名稱空間,那麼,我們還需要一個鉤子來在函式定義期間呼叫它。在這裡,我們會使用 Python 裝飾器。

在 Python 中,裝飾器用於封裝一個函式,並允許我們在不修改該函式的結構的情況下,向其新增新功能。裝飾器把被裝飾的函式 fn 作為引數,並返回一個新的函式,用於實際的呼叫。新的函式會接收原始函式的 args 和 kwargs,並返回最終的值。

以下是一個裝飾器的示例,演示瞭如何給函式新增計時功能。

  1. import time
  2. def my_decorator(fn):
  3. """這是一個自定義的函式,可以裝飾任何函式,並列印其執行過程的耗時"""
  4. def wrapper_function(*args, **kwargs):
  5. start_time = time.time()
  6. # 呼叫被裝飾的函式,並獲取其返回值
  7. value = fn(*args, **kwargs)
  8. print("the function execution took:", time.time() - start_time, "seconds")
  9. # 返回被裝飾的函式的呼叫結果
  10. return value
  11. return wrapper_function
  12. @my_decorator
  13. def area(l, b):
  14. return l * b
  15. >>> area(3, 4)
  16. the function execution took: 9.5367431640625e-07 seconds
  17. 12

在上面的例子中,我們定義了一個名為 my_decorator 的裝飾器,它封裝了函式 area,並在標準輸出上打印出執行 area 所需的時間。

每當直譯器遇到一個函式定義時,就會呼叫裝飾器函式 my_decorator(用它封裝被裝飾的函式,並將封裝後的函式儲存在 Python 的區域性或全域性名稱空間中),對於我們來說,它是在虛擬名稱空間中註冊函式的理想鉤子。

因此,我們建立了名為overload的裝飾器,它能在虛擬名稱空間中註冊函式,並返回一個可呼叫物件。

  1. def overload(fn):
  2. """用於封裝函式,並返回Function類的一個可呼叫物件"""
  3. return Namespace.get_instance().register(fn)

overload裝飾器藉助名稱空間的 .register() 函式,返回 Function 的一個例項。現在,無論何時呼叫函式(被 overload 裝飾的),它都會呼叫由 .register() 函式所返回的函式——Function 的一個例項,其 call 方法會在呼叫期間使用指定的 args 和 kwargs 執行。

現在剩下的就是在 Function 類中實現__call__方法,使得它能根據呼叫期間傳入的引數而呼叫相應的函式。

從名稱空間中找到正確的函式

想要區別出不同的函式,除了通常的模組、類和函式名以外,還可以依據函式的引數數量,因此,我們在虛擬的名稱空間中定義了一個 get 方法,它會從 Python 的名稱空間中讀取待區分的函式以及實參,最後依據引數的不同,返回出正確的函式。我們沒有更改 Python 的預設行為,因此在原生的名稱空間中,同名的函式只有一個。

這個 get 函式決定了會呼叫函式的哪個實現(如果過載了的話)。找到正確的函式的過程非常簡單——先使用 key 方法,它利用函式和引數來創建出唯一的鍵(正如註冊時所做的那樣),接著查詢這個鍵是否存在於函式登錄檔中;如果存在,則獲取其對映的實現。

  1. def get(self, fn, *args):
  2. """從虛擬名稱空間中返回匹配到的函式,如果沒找到匹配,則返回None"""
  3. func = Function(fn)
  4. return self.function_map.get(func.key(args=args))

get 函式建立了 Function 類的一個例項,這樣就可以複用類的 key 函式來獲得一個唯一的鍵,而不用再寫建立鍵的邏輯。然後,這個鍵將用於從函式登錄檔中獲取正確的函式。

實現函式的呼叫

前面說過,每次呼叫被 overload 裝飾的函式時,都會呼叫 Function 類中的__call__方法。我們需要讓__call__方法從名稱空間的 get 函式中,獲取出正確的函式,並呼叫之。

__call__方法的實現如下:

  1. def __call__(self, *args, **kwargs):
  2. """重寫能讓類的例項變可呼叫物件的__call__方法"""
  3. # 依據引數,從虛擬名稱空間中獲取將要呼叫的函式
  4. fn = Namespace.get_instance().get(self.fn, *args)
  5. if not fn:
  6. raise Exception("no matching function found.")
  7. # 呼叫被封裝的函式,並返回呼叫的結果
  8. return fn(*args, **kwargs)

該方法從虛擬名稱空間中獲取正確的函式,如果沒有找到任何函式,它就丟擲一個 Exception,如果找到了,就會呼叫該函式,並返回呼叫的結果。

運用函式過載

準備好所有程式碼後,我們定義了兩個名為 area 的函式:一個計算矩形的面積,另一個計算圓的面積。下面定義了兩個函式,並使用overload裝飾器進行裝飾。

  1. @overload
  2. def area(l, b):
  3. return l * b
  4. @overload
  5. def area(r):
  6. import math
  7. return math.pi * r ** 2
  8. >>> area(3, 4)
  9. 12
  10. >>> area(7)
  11. 153.93804002589985

當我們用一個引數呼叫 area 時,它返回了一個圓的面積,當我們傳遞兩個引數時,它會呼叫計算矩形面積的函式,從而實現了函式 area 的過載。

原作者注:從 Python 3.4 開始,Python 的 functools.singledispatch 支援函式過載。從 Python 3.8 開始,functools.singledispatchmethod 支援過載類和例項方法。感謝 Harry Percival 的指正。

總結

Python 不支援函式過載,但是通過使用它的基本結構,我們搗鼓了一個解決方案。

我們使用裝飾器和虛擬的名稱空間來過載函式,並使用引數的數量作為區別函式的因素。我們還可以根據引數的型別(在裝飾器中定義)來區別函式——即過載那些引數數量相同但引數型別不同的函式。

過載能做到什麼程度,這僅僅受限於getfullargspec函式和我們的想象。使用前文的思路,你可能會實現出一個更整潔、更乾淨、更高效的方法,所以,請嘗試實現一下吧。

正文到此結束。以下附上完整的程式碼:

  1. # 模組:overload.py
  2. from inspect import getfullargspec
  3. class Function(object):
  4.   """Function is a wrap over standard python function
  5.   An instance of this Function class is also callable
  6.   just like the python function that it wrapped.
  7.   When the instance is "called" like a function it fetches
  8.   the function to be invoked from the virtual namespace and then
  9.   invokes the same.
  10.   """
  11.   def __init__(self, fn):
  12.     self.fn = fn
  13.   
  14.   def __call__(self, *args, **kwargs):
  15.     """Overriding the __call__ function which makes the
  16.     instance callable.
  17.     """
  18.     # fetching the function to be invoked from the virtual namespace
  19.     # through the arguments.
  20.     fn = Namespace.get_instance().get(self.fn, *args)
  21.     if not fn:
  22.       raise Exception("no matching function found.")
  23.     # invoking the wrapped function and returning the value.
  24.     return fn(*args, **kwargs)
  25.   def key(self, args=None):
  26.     """Returns the key that will uniquely identifies
  27.     a function (even when it is overloaded).
  28.     """
  29.     if args is None:
  30.       args = getfullargspec(self.fn).args
  31.     return tuple([
  32.       self.fn.__module__,
  33.       self.fn.__class__,
  34.       self.fn.__name__,
  35.       len(args or []),
  36.     ])
  37. class Namespace(object):
  38.   """Namespace is the singleton class that is responsible
  39.   for holding all the functions.
  40.   """
  41.   __instance = None
  42.   def __init__(self):
  43.     if self.__instance is None:
  44.       self.function_map = dict()
  45.       Namespace.__instance = self
  46.     else:
  47.       raise Exception("cannot instantiate Namespace again.")
  48.   @staticmethod
  49.   def get_instance():
  50.     if Namespace.__instance is None:
  51.       Namespace()
  52.     return Namespace.__instance
  53.   def register(self, fn):
  54.     """registers the function in the virtual namespace and returns
  55.     an instance of callable Function that wraps the function fn.
  56.     """
  57.     func = Function(fn)
  58.     specs = getfullargspec(fn)
  59.     self.function_map[func.key()] = fn
  60.     return func
  61.   
  62.   def get(self, fn, *args):
  63.     """get returns the matching function from the virtual namespace.
  64.     return None if it did not fund any matching function.
  65.     """
  66.     func = Function(fn)
  67.     return self.function_map.get(func.key(args=args))
  68. def overload(fn):
  69.   """overload is the decorator that wraps the function
  70.   and returns a callable object of type Function.
  71.   """
  72.   return Namespace.get_instance().register(fn)

最後,演示程式碼如下:

  1. from overload import overload
  2. @overload
  3. def area(length, breadth):
  4.   return length * breadth
  5. @overload
  6. def area(radius):
  7.   import math
  8.   return math.pi * radius ** 2
  9. @overload
  10. def area(length, breadth, height):
  11.   return 2 * (length * breadth + breadth * height + height * length)
  12. @overload
  13. def volume(length, breadth, height):
  14.   return length * breadth * height
  15. @overload
  16. def area(length, breadth, height):
  17.   return length + breadth + height
  18. @overload
  19. def area():
  20.   return 0
  21. print(f"area of cuboid with dimension (4, 3, 6) is: {area(4, 3, 6)}")
  22. print(f"area of rectangle with dimension (7, 2) is: {area(7, 2)}")
  23. print(f"area of circle with radius 7 is: {area(7)}")
  24. print(f"area of nothing is: {area()}")
  25. print(f"volume of cuboid with dimension (4, 3, 6) is: {volume(4, 3, 6)}")