野声

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 が 2 つの実装でオーバーロードされています。最初の関数は 2 つの引数(どちらも整数型)を受け取り、渡された長方形の幅と高さに基づいて長方形の面積を返します。2 つ目の関数は 1 つの整数型引数:円の半径を受け取ります。関数 area を呼び出すと、例えば area(7) と呼び出すと 2 つ目の関数が使用され、area(3, 4) と呼び出すと最初の関数が使用されます。

なぜ Python には関数オーバーロードがないのか?#

Python はデフォルトで関数オーバーロードをサポートしていません。同名の関数を複数定義すると、後の関数が前の関数を常に上書きするため、同じ名前空間内では各関数名は常に 1 つの関数の実行エントリしか持ちません。関数 locals()globals() を呼び出すことで、名前空間に何があるかを確認できます。この 2 つの関数はそれぞれローカル名前空間とグローバル名前空間を返します。

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

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

関数を定義した後、locals() 関数を実行すると、この関数は現在の名前空間に定義されているすべての変数を含む辞書を返します。この辞書のキーは変数名で、辞書の値は変数の参照または値です。実行時に別の同名の関数に遭遇すると、それはローカル名前空間内の関数エントリを更新し、2 つの関数が共存する可能性を排除します。したがって、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):
        """関数を一意に識別するキーを返します。
        """
        # 手動で引数が渡されていない場合、関数定義から引数を取得します。
        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__ であり、2 番目の要素は型 <class 'function'>、3 番目は関数の名前 area、4 番目は関数 area が受け取る引数の数:2 です。

この例は、func インスタンスを呼び出す方法も示しています。通常の area 関数を呼び出すように、2 つの引数 34 を渡すと、結果 12 が得られます。これは area(3, 4) を呼び出したときの動作と全く同じです。

後の段階でデコレーターを使用する際、この動作は大いに役立ちます。

仮想名前空間の作成#

このステップでは、仮想名前空間(関数登録表)を構築します。この関数登録表は、関数定義段階でオーバーロードするすべての関数を保存します。

グローバルには 1 つの名前空間だけが必要です(すべてのオーバーロードする関数を一緒に保存するため)、私たちはシングルトンクラスを作成し、内部で辞書を使用して異なる関数を保存します。辞書のキーは、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 デコレーターは、Namespace シングルトンクラスの .register() メソッドを呼び出すことで得られた Function のインスタンスを返します。これで、関数(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("一致する関数が見つかりません。")

  # 関数の呼び出し値を返します。
  return fn(*args, **kwargs)

このメソッドは、仮想名前空間から正しい関数を見つけ、関数が見つからない場合は Exception をスローし、関数が存在する場合はその関数を呼び出して値を返します。

実践#

すべてのコードを整理し、area という名前の 2 つの関数を定義します:1 つは長方形の面積を計算し、もう 1 つは円の面積を計算します。

私たちは 2 つの関数を 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 に 1 つの引数を渡すと、円の面積が返され、2 つの引数を渡すと、長方形の面積が返されることがわかります。

完全なコードは次のリンクで確認できます:https://repl.it/@arpitbbhayani/Python-Function-Overloading

結論#

Python はデフォルトで関数オーバーロードをサポートしていませんが、簡単な言語構造を使用して解決策を整えました。デコレーターとユーザーが維持する仮想名前空間を使用して、関数の引数の数を曖昧さ解消因子として関数をオーバーロードしました。引数のデータ型を使用して曖昧さ解消を行うこともでき、これにより引数の数は同じだが引数の型が異なる関数オーバーロードを実現できます。オーバーロードの粒度は、getfullargspec 関数と私たちの想像力によって制限されます。

上記の内容を参考にして、より整然とした、クリーンで効率的な実装を自分で整えることもできますので、ぜひ実装してみてください。そして、私にツイートしてください @arpit_bhayani、あなたの実装を学ぶことができれば嬉しいです。

Python 3.4 以降、functools.singledispatch を使用して関数のオーバーロードを実現できます。
Python 3.8 以降、functools.singledispatchmethod を使用してインスタンスメソッドのオーバーロードを実現できます。

Harry Percival の修正に感謝します。

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。