高度なPython ドット演算子

Pythonドット演算子の高度な活用法

Pythonでオブジェクト指向のパラダイムを可能にする演算子

Dot operator is one of the pillars of object oriented paradigm in Python. Photo by Madeline Pere on Unsplash

今回は、ささいなことについて書きます。それは「ドット演算子」です。おそらく、多くの人がこの演算子を何度も使ってきたでしょうが、何が裏で起こっているかを知らずに使ってきたり、疑問に思ったりしたことはないかもしれません。そして、前回話したメタクラスの概念と比べると、これは日常のタスクでより使いやすいものです。冗談ですよ、実際には、Pythonを「Hello World」以上のことに使うたびに使っています。だからこそ、もう少し深く掘り下げたいと思い、私がガイドになります。旅を始めましょう!

まず、ちょっとした疑問から始めましょう。「ドット演算子」とは何ですか?

以下に例を示します:

hello = 'Hello world!'print(hello.upper())# HELLO WORLD!

これは確かに「Hello World」の例ですが、誰かがまさにこれと同じようにPythonを教え始めるとは思えません。とにかく、「ドット演算子」とはhello.upper()の「.」の部分です。もう少し詳しい例を示してみましょう。

class Person:    num_of_persons = 0    def __init__(self, name):        self.name = name    def shout(self):        print(f"Hey! I'm {self.name}")p = Person('John')p.shout()# Hey I'm John.p.num_of_persons# 0p.name# 'John'

「ドット演算子」を使用する場所はいくつかあります。全体像をより見やすくするために、次の2つのケースで使用する方法をまとめましょう:

  • オブジェクトやクラスの属性にアクセスするために使用する場合
  • クラス定義で定義された関数にアクセスするために使用する場合

明らかに、私たちの例にはこれらすべてが含まれており、直感的で予想通りです。しかし、目に見える以上のものがあります!この例をよく見てみてください:

p.shout# <bound method Person.shout of <__main__.Person object at 0x1037d3a60>>id(p.shout)# 4363645248Person.shout# <function __main__.Person.shout(self)>id(Person.shout)# 4364388816

なぜかp.shoutは、予想どおりには同じ関数を参照していないようです。少なくともそれが期待されるはずですよね?さらにp.shoutは関数ではありません!それでは、議論を始める前に次の例を見てみましょう:

class Person:    num_of_persons = 0    def __init__(self, name):        self.name = name    def shout(self):        print(f"Hey! I'm {self.name}.")p = Person('John')vars(p)# {'name': 'John'}def shout_v2(self):    print("Hey, what's up?")p.shout_v2 = shout_v2vars(p)# {'name': 'John', 'shout_v2': <function __main__.shout_v2(self)>}p.shout()# Hey, I'm John.p.shout_v2()# TypeError: shout_v2() missing 1 required positional argument: 'self'

vars関数を知らない人のために説明すると、これはインスタンスの属性を保持する辞書を返します。vars(Person)を実行すると、若干異なる応答が返ってきますが、イメージがわかるはずです。値とともに属性やクラス関数定義を保持する変数があります。明らかに、クラスのインスタンスとクラスオブジェクト自体には違いがあります。したがって、これら2つに対してvars関数の応答が異なることになります。

今、オブジェクトが作成された後に関数を追加で定義することも完全に有効です。これは p.shout_v2 = shout_v2 という行です。これにより、インスタンスの辞書に別のキーと値のペアが追加されます。すべて問題ないように見えますし、実行もできるでしょう。まるで shout_v2 がクラス定義で指定されたかのようにです。しかし実は、何かが本当におかしいのです。 shout メソッドと同じ方法で呼び出すことができません。

注意深い読者は、私が関数とメソッドという用語をどれほど慎重に使っているかに気づいているはずです。なぜなら、Pythonがこれらをどのように表示するかにも違いがあるからです。前の例を見てください。 shout はメソッドであり、shout_v2 は関数です。少なくとも、オブジェクト p の観点から見ればそうです。一方、Person クラスの観点から見れば、shout は関数であり、shout_v2 は存在しません。オブジェクトの辞書(名前空間)でのみ定義されています。ですので、もしオブジェクト指向のパラダイムやカプセル化、継承、抽象化、ポリモーフィズムなどのメカニズムを本当に依存するのであれば、私たちの例のようにオブジェクトに関数を定義しないでしょう。クラス定義(本体)で関数を定義するようにするでしょう。

ところで、なぜこれら2つは異なり、なぜエラーが発生するのでしょうか?それは「ドット演算子」がどのように動作するかによります。より長い回答は、裏で行われる名前解決のメカニズムがあるからです。このメカニズムは、__getattribute____getattr__ のダンダーメソッドで構成されています。

属性を取得する

最初はおそらく直感に反していて不必要に複雑に聞こえるでしょうが、我慢してください。Pythonでオブジェクトの属性にアクセスしようとする場合、2つのシナリオが発生する可能性があります。属性が存在するか存在しないか、単純にそうです。いずれの場合も、__getattribute__ が呼び出されます。具体的に言うと、これはいつも「呼び出される」のです。このメソッドは次のように動作します。

  • 計算された属性の値を返します。
  • __getattr__ を明示的に呼び出します。
  • 属性名にアクセスできない場合は AttributeError を発生させ、デフォルトでは __getattr__ が呼び出されます。

属性名の解決メカニズムをインターセプトしたい場合は、ここが攻略する場所です。ただし、無限ループに陥ったり、名前解決のメカニズム全体をめちゃくちゃにしたりすることは非常に簡単です。特に、オブジェクト指向の継承のシナリオでは、それはそう簡単ではありません。

オブジェクトの辞書に属性が存在しない場合を処理する場合は、__getattr__ メソッドを直接実装することができます。これは、__getattribute__ が属性名にアクセスできない場合に呼び出されます。このメソッドが属性を見つけられない場合や取り扱えない場合は、AttributeError 例外も発生させます。以下はこれをどのように操作するかの例です。

class Person:    num_of_persons = 0    def __init__(self, name):        self.name = name    def shout(self):        print(f"Hey! I'm {self.name}.")            def __getattribute__(self, name):        print(f'getting the attribute name: {name}')        return super().__getattribute__(name)        def __getattr__(self, name):        print(f'this attribute doesn\'t exist: {name}')        raise AttributeError()p = Person('John')p.name# getting the attribute name: name# 'John'p.name1# getting the attribute name: name1# this attribute doesn't exist: name1## ... exception stack trace# AttributeError:

__getattribute__ の実装では super().__getattribute__(...) を呼び出すことが非常に重要であり、前述のように、Pythonのデフォルト実装ではたくさんのことが行われています。そして、「ドット演算子」がその魔法を得る場所でもあるのです。まあ、少なくともその魔法の半分がそこにあります。もう半分は、クラスオブジェクトがクラス定義を解釈した後にどのように作成されるかにあります。

クラスの関数

ここで使っている用語は意図的です。クラスに含まれるのは関数のみであり、最初の例のうちの1つでそれを確認しました:

p.shout# <bound 0x1037d3a60="" >Person.shout# 

オブジェクトの視点から見ると、これらはメソッドと呼ばれます。クラスの関数をオブジェクトのメソッドに変換するプロセスを束縛と呼びます。その結果、以前の例で見たように、束縛されたメソッドが作成されます。それが何に束縛されるのか?まあ、クラスのインスタンスを持っていて、そのメソッドを呼び出し始めると、そのオブジェクト参照をメソッドに渡すことになります。 self 引数を覚えていますか?では、これがどのように実現され、誰がそれを行うのでしょうか?

まあ、最初の部分はクラスの本体を解釈している時に行われます。このプロセスでは、クラスの名前空間を定義し、属性値を追加し、(クラス)関数を定義し、それらを名前に束縛していくなど、いくつかのことが起こります。さて、これらの関数が定義されているとき、物理的にはラップされています。概念的にディスクリプタと呼ばれるオブジェクトにラップされています。このディスクリプタは、以前に見たクラス関数の識別と動作の変化を可能にするものです。私はディスクリプタについて別のブログ記事を書くつもりなので、後で紹介しますが、今はこのオブジェクトが、予め定義されている一連のダンダーメソッドを実装したクラスのインスタンスであることを知っておいてください。これはプロトコルとも呼ばれます。これらが実装されると、このクラスのオブジェクトが特定のプロトコルに従い、期待どおりの方法で振る舞うと言われています。データディスクリプタと非データディスクリプタの違いがあります。前者は __get__, __set__, および/または __delete__ ダンダーメソッドを実装します。後者は単に __get__ メソッドを実装します。とにかく、クラスの各関数はいわゆる非データディスクリプタにラップされることになります。

「ドット演算子」を使用して属性検索を開始すると、__getattribute__ メソッドが呼び出され、名前解決の全体的なプロセスが始まります。このプロセスは、解決が成功するまで続き、次のような手順を踏みます:

  1. 望ましい名前を持つデータディスクリプタを返す(クラスレベル)。
  2. 望ましい名前を持つインスタンス属性を返す(インスタンスレベル)。
  3. 望ましい名前を持つ非データディスクリプタを返す(クラスレベル)。
  4. 望ましい名前を持つクラス属性を返す(クラスレベル)。
  5. AttributeError を発生させ、実質的には __getattr__ メソッドを呼び出す。

最初のアイデアは、このメカニズムがどのように実装されるかについて、公式のドキュメンテーションへの参照を残しておくことでしたが、学習目的のために、少なくとも Python のモックアップを提供することにしました。ただし、公式のドキュメンテーションのページ全体を読むことを強くお勧めします。

それでは、次のコードスニペットでは、コメントにいくつかの説明を追加して、コードがより読みやすく理解しやすくなるようにします。以下に記載します:

def object_getattribute(obj, name):    "Emulate PyObject_GenericGetAttr() in Objects/object.c"    # Create vanilla object for later use.    null = object()    """    obj はカスタムクラスからインスタンス化されたオブジェクトです。ここで、それがインスタンス化されたクラスの名前を見つけようとします。    """    objtype = type(obj)     """    name はクラス関数、インスタンス属性、または任意のクラス属性の名前を表します。ここでは、それを見つけて参照を保持しようとします。MRO は Method Resolution Order の略であり、クラスの継承に関連していますが、この時点ではそれほど重要ではありません。親クラス全体で最適に name を見つける仕組みと言えます。    """    cls_var = find_name_in_mro(objtype, name, null)    """    ここでは、このクラス属性が __get__ メソッドを実装したオブジェクトであるかどうかをチェックします。もし実装していたら、それは非データディスクリプタです。これは後続の手順で重要になります。    """    descr_get = getattr(type(cls_var), '__get__', null)    """    これで、クラス属性がディスクリプタを参照している場合、それがデータディスクリプタであるかどうかをテストし、ディスクリプタの __get__ メソッドへの参照を返すか、次の if コードブロックに移動します。    """    if descr_get is not null:        if (hasattr(type(cls_var), '__set__')            or hasattr(type(cls_var), '__delete__')):            return descr_get(cls_var, obj, objtype)  # データディスクリプタ    """    name がデータディスクリプタを参照していない場合、オブジェクトのディクショナリ内の変数を参照するかどうかをチェックし、参照できる場合はその値を返します。    """    if hasattr(obj, '__dict__') and name in vars(obj):        return vars(obj)[name]  # インスタンス変数    """    name が上記のいずれからも参照されない場合、name が非データディスクリプタを参照しているかどうかを確認し、それへの参照を返します。    """    if descr_get is not null:        return descr_get(cls_var, obj, objtype)  # 非データディスクリプタ    """    上記から何も参照しない場合、name がクラス属性を参照しているかどうかを確認し、その値を返します。    """    if cls_var is not null:        return cls_var                                  # クラス変数    """    名前の解決が失敗した場合、AttributeError 例外が発生し、__getattr__ が呼び出されます。    """    raise AttributeError(name)

この実装はPythonで行われており、__getattribute__メソッドで実装されたロジックを記録し説明するために用意されています。

実際のところ、これはCで実装されています。これを見るだけでも、全体を再実装することは避けた方が良いことが想像できます。最良の方法は、一部の解決を自分で試してみて、上記の例のようにreturn super().__getattribute__(name)を使ってCPythonの実装に回帰することです。

ここで重要なことは、各クラス関数(オブジェクト)が非データディスクリプタ(functionクラスオブジェクト)でラップされるということです。これは、このラッパーオブジェクトに__get__ダンダーメソッドが定義されていることを意味します。このダンダーメソッドは、新しい呼び出し可能(新しい関数と考えてください)を返します。最初の引数は「ドットオペレータ」を実行しているオブジェクトへの参照です。呼び出し可能なので、新しい関数のように考えてください。本質的には、MethodTypeという別のオブジェクトです。以下をご覧ください:

type(p.shout)# 属性名の取得: shout# methodtype(Person.shout)# 関数

確かに興味深いのは、このfunctionクラスです。これは、__get__メソッドが定義されているラッパーオブジェクトそのものです。ただし、「ドットオペレータ」としてメソッドshoutにアクセスしようとすると、__getattribute__はリストを反復処理し、3番目のケース(非データデスクリプタを返す)で停止します。この__get__メソッドには、オブジェクトへの参照を取り、functionとオブジェクトへの参照を持つMethodTypeを作成する追加のロジックが含まれています。

以下は公式ドキュメントのモックアップです:

class Function:    ...    def __get__(self, obj, objtype=None):        if obj is None:            return self        return MethodType(self, obj)

クラス名の違いは無視してください。理解しやすくするためにfunctionを使用していましたが、公式ドキュメントの説明に従ってFunctionという名前を使用するようにします。

とにかく、このモックアップを見るだけで、functionクラスが全体の構成にどのようにフィットするか理解するのに十分な場合があります。しかし、もっと明確になるように、あるいは不足しているコードのいくつかを追加するために、いくつかの行を追加します。この例では、2つの追加のクラス関数(つまり)を追加します:

class Function:    ...    def __init__(self, fun, *args, **kwargs):        ...        self.fun = fun    def __get__(self, obj, objtype=None):        if obj is None:            return self        return MethodType(self, obj)    def __call__(self, *args, **kwargs):        ...        return self.fun(*args, **kwargs)

なぜこれらの関数を追加したのでしょうか?これにより、Functionオブジェクトがメソッドバインディングのシナリオ全体でどのような役割を果たしているかを簡単に想像できるようになりました。この新しいFunctionオブジェクトは、元の関数を属性として格納します。また、呼び出し可能なので、関数として呼び出すことができます。その場合、それはラップする関数と同じように機能します。覚えておいてください、Pythonのすべてはオブジェクトであり、関数もそうです。そしてMethodTypeは、メソッド(この場合はshout)を呼び出すオブジェクトへの参照と一緒にFunctionオブジェクトを「ラップ」します。

MethodTypeはこれをどのように行うのでしょうか?これらの参照を保持し、呼び出し可能なプロトコルを実装します。以下は、MethodTypeクラスの公式ドキュメントのモックアップです:

class MethodType:    def __init__(self, func, obj):        self.__func__ = func        self.__self__ = obj    def __call__(self, *args, **kwargs):        func = self.__func__        obj = self.__self__        return func(obj, *args, **kwargs)

同様に、簡潔さのために、funcは最初のクラス関数(shout)を参照し、objはインスタンス(p)を参照し、引数とキーワード引数が渡されます。 shout宣言のselfは、実際にはこの`obj`(この例ではp)を参照するようになります。

最後に、なぜ関数とメソッドを区別し、関数が「ドット演算子」を使用してオブジェクト経由でアクセスされるとバインドされるのかが明確になるはずです。考えてみれば、次のようにクラスの関数を呼び出すのは完璧に問題ありません:

class Person:    num_of_persons = 0    def __init__(self, name):        self.name = name    def shout(self):        print(f"Hey! I'm {self.name}.")        p = Person('John')Person.shout(p)# Hey! I'm John.

しかし、これはお勧めの方法ではなく、非常に見苦しいです。通常、コードでこれを行う必要はありません。

したがって、最後に、属性の解決のいくつかの例を見て、この点を理解しやすくしましょう。前の例を使用して、ドット演算子がどのように機能するかを調べましょう。

p.name"""1. pと「name」の引数で__getattribute__が呼び出されます。2. objtypeはPersonです。3. descr_getはnullです。なぜならPersonクラスの辞書(名前空間)に「name」は存在しないためです。4. descr_getは存在しないため、最初のifブロックをスキップします。5. 「name」はオブジェクトの辞書に存在するため、値を取得します。"""p.shout('Hey')"""名前の解決手順に入る前に、Person.shoutは関数クラスのインスタンスであることを心に留めておいてください。基本的にそれに包まれます。そして、このオブジェクトは呼び出し可能なので、Person.shout(...)として呼び出すことができます。開発者の観点からは、すべてクラス本体に定義されたかのようにうまく機能します。ただし、背後ではそれは全く異なる方法で行われています。1. pと「shout」の引数で__getattribute__が呼び出されます。2. objtypeはPersonです。3. Person.shoutは実際にはラップされており、データではないデスクリプタです。そのため、ラッパーオブジェクトは__get__メソッドが実装されており、descr_getによって参照されます。4. ラッパーオブジェクトはデータではないデスクリプタなので、最初のifブロックはスキップされます。5. 「shout」はクラスの定義の一部であるため、オブジェクトの辞書に存在しないため、2番目のifブロックはスキップされます。6. 「shout」はデータではないデスクリプタであり、その__get__メソッドが3番目のifコードブロックから返されます。さて、ここでp.shout('Hey')にアクセスしようとしましたが、実際に取得できたのはp.shout.__get__メソッドです。これはMethodTypeオブジェクトを返します。したがって、p.shout(...)は動作しますが、呼び出されるのはMethodTypeクラスのインスタンスです。このオブジェクトは、`Function`ラッパーやオブジェクトpへの参照を保持する、実質的なラッパーです。最終的に、p.shout('Hey')を呼び出すと、呼び出されるのはpオブジェクトを持つ`Function`ラッパーであり、位置引数の1つとして 'Hey'が渡されます。"""Person.shout(p)"""Person.shoutは関数クラスのインスタンスであることを心に留めておいてください。基本的にそれに包まれます。そして、このオブジェクトは呼び出し可能なので、Person.shout(...)として呼び出すことができます。開発者の観点からは、すべてクラス本体に定義されたかのようにうまく機能します。ただし、背後ではそれは全く異なる方法で行われています。この部分は同じですが、以下の手順は異なります。確認してください。1. Personと「shout」の引数で__getattribute__が呼び出されます。2. objtypeはtypeです。このメカニズムはメタクラスについての私の記事で説明されています。3. Person.shoutは実際にはラップされており、データではないデスクリプタです。そのため、ラッパーオブジェクトは__get__メソッドが実装されており、descr_getによって参照されます。4. ラッパーオブジェクトはデータではないデスクリプタなので、最初のifブロックはスキップされます。5. 「shout」はPersonがオブジェクトであるため、オブジェクトの辞書に存在します。したがって、「shout」関数が返されます。Person.shoutが呼び出されると、実際にはクラス本体で定義された元の関数をラップした`Function`クラスのインスタンスが呼び出されます。この方法で、元の関数がすべての位置引数とキーワード引数とともに呼び出されます。

結論

この記事を一気に読み込むのが容易ではなかった場合は、心配しないでください!「ドット演算子」の背後にある全体的なメカニズムは簡単に理解できるものではありません。少なくとも2つの理由があります。1つは、__getattribute__が名前の解決を行う方法であり、もう1つはクラスの関数がクラス本体の解釈時にラップされる方法です。ですので、何度かこの記事を読み返し、例を試してみてください。実験することが私を「高度なPython」というシリーズを始める原動力になりました。

もう一つ!私の説明の仕方が好きであり、Pythonの世界で読みたいと思うような高度な内容がある場合は、教えてください!

「高度なPythonシリーズ」の以前の記事:

Advanced Python:関数

Advanced Python:メタクラス

Pythonクラスオブジェクトの簡単な紹介と作成方法

towardsdatascience.com

参考文献

We will continue to update VoAGI; if you have any questions or suggestions, please contact us!

Share:

Was this article helpful?

93 out of 132 found this helpful

Discover more