野声

Hey, 野声!

谁有天大力气可以拎着自己飞呀
twitter
github

[譯] 在 Python 中實現函數重載

譯自:https://arpitbhayani.me/blogs/function-overloading
作者:@arpit_bhayani
翻譯已獲原作者授權


函數重載是指可以創建多個同名函數,但是每個函數的形參以及實現可以不一樣。當一個重載函數 fn 被調用時,運行時會根據傳入的參數 / 對象,來判斷並調用相應的實現函數。

int area(int length, int breadth) {
  return length * breadth;
}

float area(int radius) {
  return 3.14 * radius * radius;
}

上面這個例子中 (C++ 實現),函數 area 被兩個實現重載,第一個函數允許傳入兩個參數 (且都是整數型),根據傳入的長方形的寬高,返回長方形的面積;第二個函數需要傳入一個整形參數:圓的半徑。當我們調用函數 area 時,如調用 area(7) 時會使用第二個函數,而調用 area(3, 4) 時則會使用第一個。

為什麼 Python 中沒有函數重載?#

Python 默認不支持函數重載。當我們定義多個同名函數時,後一個總會覆蓋掉前一個函數,因此在同一個命名空間內,每個函數名總是只有一個函數執行入口。我們可以調用函數 locals()globals() 來查看命名空間中有什麼,這兩個函數分別返回本地命名空間和全局命名空間。

def area(radius):
    return 3.14 * radius ** 2

>>> locals()
{
  ...
  'area': <function __main__.area(radius)>,
  ...
}

定義好函數之後,執行 locals() 函數,這個函數會返回一個包含目前命名空間中定義的所有變數的字典。該字典的鍵是變數名稱,字典的值是變數的引用或值。當運行時遇到另一個同名函數時,它會更新本地命名空間中的函數入口,從而消除兩個函數共存的可能性。因此 Python 不支持函數重載。這是在創建語言時做出的設計決策,但這並不妨礙我們實現它。來,讓我們重載一些函數。

在 Python 中實現函數重載#

我們已經知道了 Python 怎樣管理命名空間,如果我們想實現函數重載,我們需要:

  • 維護一個虛擬命名空間,在其中管理函數定義
  • 根據傳入的參數找到調用相應函數的方法

簡單起見,我們實現的函數重載將由函數接受的參數數量來區分。

包裝函數#

我們創建一個名為 Function 的類,這個類可以傳入任意的函數,通過重寫類的 __call__ 方法使得該類可被調用,最後,我們實現一個 key 方法,該方法返回一個元組,可以在全局中唯一標識傳入的函數。

from inspect import getfullargspec


class Function(object):
    """包裝標準的 Python 函數.
    """

    def __init__(self, fn):
        self.fn = fn

    def __call__(self, *args, **kwargs):
        """該方法使得整個類能像函數一樣被調用,然後返回傳入函數的
        調用結果。
        """
        return self.fn(*args, **kwargs)

    def key(self, args=None):
        """返回唯一標識函數的 key
        """
        # 如果沒有手動傳入參數,從函數定義中獲取參數
        if args is None:
            args = getfullargspec(self.fn).args

        return tuple(
            [self.fn.__module__, self.fn.__class__, self.fn.__name__, len(args or [])]
        )

在上面的這個代碼片段中,Function 類的 key 方法返回一個元組,該元組可以在整個代碼庫中唯一的標識被包裝的函數,元組中還保存了以下內容:

  • 函數所屬的模塊
  • 函數所屬的類
  • 函數名稱
  • 函數接受的參數個數

覆蓋的 __call__ 方法調用被包裝的函數並返回計算出的值(目前這裡還只是常規操作,值原樣返回)。這使得這個類的實例可以像函數一樣被調用,並且其行為與被包裝的函數完全一樣。

def area(l, b):
  return l * b

>>> func = Function(area)
>>> func.key()
('__main__', <class 'function'>, 'area', 2)
>>> func(3, 4)
12

在上面的這個例子裡,我們把 area 函數傳入 Function 類,並把實例賦給 func,調用 func.key() 返回了一個元組,該元組的第一個元素就是當前模塊名 __main__,第二個元素是類型 <class 'function'>,第三個是函數的名字 area,第四個是函數 area 能接受的參數個數:2

這個例子同時也說明了如何調用 func 實例,就像我們平常調用 area 函數那樣調用就可以,傳入兩個參數:34,然後得到結果 12。這和我們調用 area(3, 4) 的表現是一模一樣的。

當我們在後面的階段使用裝飾器時,這種表現能派上大用場。

創建虛擬命名空間#

在這一步,我們會構建一個虛擬命名空間(函數註冊表),這個函數註冊表會存儲所有在函數定義階段要重載的函數。

由於全局只需要一個命名空間(把所有的要重載的函數保存到一起),我們創建了一個單例類,內部使用字典來保存不同的函數,字典的鍵就是我們從 key 方法獲得的元組,該元組可以唯一標識整個代碼中的函數。這樣一來,即使傳入的函數名稱相同(但參數不同),我們也可以把它們保存在字典中,從而實現函數重載。

class Namespace(object):
  """Namespace 這個單例類保存了所有要重載的的函數
  """
  __instance = None

  def __init__(self):
    if self.__instance is None:
      self.function_map = dict()
      Namespace.__instance = self
    else:
      raise Exception("已存在實例,無法再進行實例化")

  @staticmethod
  def get_instance():
    if Namespace.__instance is None:
      Namespace()
    return Namespace.__instance

  def register(self, fn):
    """在虛擬命名空間中註冊函數,並且返回一個包裝好
    的 `Function` 實例。
    """
    func = Function(fn)
    self.function_map[func.key()] = fn
    return func

Namespace 有一個 register 方法,該方法接收一個 fn 參數,根據 fn 創建一個唯一的鍵值然後保存到字典中,最後返回一個包裝好的 Function 實例。

這意味著 register 方法的返回值也是可調用的,並且(到目前為止)其行為與要包裝的函數 fn 完全相同。

def area(l, b):
  return l * b

>>> namespace = Namespace.get_instance()
>>> func = namespace.register(area)
>>> func(3, 4)
12

使用裝飾器#

現在我們已經定義了一個能夠註冊函數的虛擬命名空間,我們需要一個在函數定義期間被調用的鉤子(注:原文為 hook),這裡使用裝飾器來實現。

在 Python 中,我們可以用裝飾器包裝函數,這樣我們就能在不修改函數原結構的情況下向函數添加新功能。裝飾器接受要被包裝的函數 fn 作為參數,並返回一個可調用的新函數。新函數也可以接受你在函數調用時傳遞的 argskwargs 並返回值。

下面展示了一個裝飾器示例,它可以計算函數的執行時間。

import time


def my_decorator(fn):
  """my_decorator 是我們自定義的一個裝飾器
  它能包裝傳入的函數並且輸出該函數的執行時間。
  """
  def wrapper_function(*args, **kwargs):
    start_time = time.time()

    # 調用原函數獲得原函數的返回值
    value = fn(*args, **kwargs)
    print("函數執行花費了:", time.time() - start_time, "秒")

    # 返回原函數的調用值
    return value

  return wrapper_function


@my_decorator
def area(l, b):
  return l * b


>>> area(3, 4)
函數執行花費了: 9.5367431640625e-07
12

上面這個例子裡我們定義了一個名為 my_decorator 的裝飾器,我們使用這個裝飾器包裝了定義的 area 函數,包裝後的函數在執行後可以打印執行花費的時間。

當 Python 解釋器遇到被裝飾的函數定義的時候,都会執行一遍裝飾器函數 my_decorator(這樣它就完成了對被裝飾函數的包裝,並將裝飾器返回的函數存儲在 Python 的本地或全局命名空間中)。對我們來說,這就是一個能在虛擬命名空間中註冊函數的理想的鉤子。因此,我們創建一個名為 overload 的裝飾器,這個裝飾器會在虛擬命名空間中註冊被裝飾的函數,並返回一個能被調用的 Function 實例。

def overload(fn):
  """overload 是我們用來包裝函數的裝飾器,會返回一個
  能被調用的 `Function` 實例。
  """
  return Namespace.get_instance().register(fn)

overload 裝飾器返回了一個 Function 的實例,該實例是通過調用 Namespace 單例類中的 .register() 方法得到的。現在只要函數(被 overload 裝飾過的)被調用,實際上是這個 Function 的實例的 __call__ 方法被調用,該方法也會接收函數傳入相應的參數。

現在我們還需要做的就是完善 Function 這個類的 __call__ 方法,讓 __call__ 方法被調用的時候根據傳入的參數找到正確的函數定義。

從命名空間中找到正確的函數#

函數歧義消除的條件除了判斷模塊的類和名稱之外,還需要判斷函數接收的參數數量,因此我們在虛擬命名空間中定義了一個方法 get,這個方法接收一個 Python 命名空間中(區別於我們創建的虛擬命名空間,我們並沒有改變 Python 命名空間的默認行為)的函數,我們根據函數的參數數量(我們設置的消歧因子)返回一個可以被調用的消歧後的函數。

這個 get 方法的作用就是決定函數的哪個實現(如果被重載過)會被調用。找到正確函數的過程其實很簡單,根據傳入的函數和函數參數創建一個全局唯一鍵然後查找這個鍵是否在命名空間中註冊過;如果是,則讀取與鍵對應的實現。

def get(self, fn, *args):
  """get 返回在命名空間中匹配到的函數

  如果未匹配到任何函數則返回 None
  """
  func = Function(fn)
  return self.function_map.get(func.key(args=args))

get 方法在內部創建了一個 Function 的實例,然後通過該實例的 key 方法獲取函數的唯一鍵。然後使用該鍵從函數註冊表中獲取相應的函數。

函數調用#

如上所述,每次調用被 overload 裝飾過的函數時,都会調用 Function 類中的 __call__ 方法。我們要在這個方法裡通過虛擬命名空間的 get 方法找到並調用正確的重載函數實現。__call__ 方法的實現如下:

def __call__(self, *args, **kwargs):
  """覆蓋類的 `__call__` 方法可以讓一個實例可被調用
  """
  # 從虛擬命名空間中獲取真正的要被調用的函數
  fn = Namespace.get_instance().get(self.fn, *args)
  if not fn:
    raise Exception("no matching function found.")

  # 返回函數調用值
  return fn(*args, **kwargs)

該方法從虛擬命名空間中找到正確的函數,如果沒找到任何函數,則拋出 Exception 異常,如果函數存在,則調用該函數並返回值。

實戰#

將所有的代碼整理好,我們定義兩個名為 area 的函數:一個函數計算矩形的面積,另一個函數計算圓形的面積。

我們將兩個函數都使用 overload 裝飾器進行裝飾。

@overload
def area(l, b):
  return l * b

@overload
def area(r):
  import math
  return math.pi * r ** 2


>>> area(3, 4)
12
>>> area(7)
153.93804002589985

當我們調用 area 只傳一個參數時,可以看到它返回了圓的面積;當我們傳入兩個參數時,可以發現返回了矩形的面積。

完整代碼見:https://repl.it/@arpitbbhayani/Python-Function-Overloading

結論#

Python 默認不支持函數重載,但通過簡單的語言結構我們整出了一个解決方法。我們使用裝飾器和用戶維護的虛擬命名空間,將函數參數數量作為消歧因子來重載函數。我們也可以使用函數參數的數據類型來做消歧,這樣就能實現參數個數相同但參數類型不同的函數重載。重載的粒度僅受 getfullargspec 函數和我們的想像力限制。

你也可以通過上述內容自己整一個更整潔、更乾淨、更高效的實現,所以請隨意實現一個,並發推給我 @arpit_bhayani,我會很高興學習你的實現。

從 Python 3.4 開始,可以使用 functools.singledispatch 實現函數的重載。
從 Python 3.8 開始,可以使用 functools.singledispatchmethod 實現實例方法的重載。

感謝 Harry Percival 的更正

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。