cover_effective_python

【 Effective Python, 2nd Edition 】__set_name__ デスクリプタ専用特殊関数 ( special method for descriptor ) を利用してデスクリプタインスタンスを割り当てたクラス変数名を取得し、コードの冗長性を排除しよう! 投稿一覧へ戻る

Tags: Python , Effective , descriptor , metaclass , __set_name__

Published 2020年7月28日21:30 by T.Tsuyoshi

メタクラス ( metaclass ) を利用することで、クラスの「型」が完全に作られる前にクラス属性を操作することができます。


さらに、デスクリプタ ( descriptor ) を利用すると、デスクリプタインスタンスを含むクラスとその属性のよりきめ細かい操作が可能になるんです。


例えば、顧客データベースの各フィールドに相当するクラスを作成するとします。


今回は、データベースのフィールド名とクラスの属性名を結びつけるためにデスクリプタを定義します。

class Field:
def __init__(self, name: str):
self.name = name
self.internal_name = '_' + self.name

def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.internal_name, None)

def __set__(self, instance, value):
setattr(instance, self.internal_name, value)



Field デスクリプタは、受け取ったフィールド名の先頭に '_' を付け加えた protected 属性名を作成し、setattr 組み込み関数を利用してオーナークラスのインスタンスの属性辞書に直接書き込みを行います。


また、属性値の取得時には、フィールド名から作成した protected 属性名を使用して、getattr 組み込み関数によりインスタンスの属性辞書から値を取り出し供給します。


( このデスクリプタの利用方法が曖昧な方は こちらの記事 をどうぞ )


この実装方法は、オーナークラスのインスタンス毎に属性値を保存、取得するために、弱参照 ( weak reference ) を利用してメモリリーク ( memory leak ) を防ぎつつデスクリプタクラス内に定義したクラス辞書変数を利用する方法と比較すると、より直接的で機能的である、ような気がしませんか?。


( クラス辞書変数と弱参照を利用した上記の方法に興味がある方は この記事 をどうぞ )


さて、データベースの各フィールドに対応するクラス変数を定義したクラスで Filed デスクリプタを利用するには、各フィールド名を引数として渡す必要があります。


class Customer:
first_name = Field('first_name')
last_name = Field('last_name')
age = Field('age')



使い方はいたって普通です。


cust = Customer()


print(f"Before(cust.first_name): {cust.first_name!r} {cust.__dict__!r}")

# Before(cust.first_name): None {}


cust.first_name = 'Nana'
cust.age = 26


print(f"After(cust.first_name): {cust.first_name!r} {cust.__dict__!r}")

# After(cust.first_name): 'Nana' {'_first_name': 'Nana', '_age': 26}



Field デスクリプタによって、フィールド名が protected 属性名に変換された上でインスタンス属性辞書に追加されています。狙い通りです!


ただし、Customer クラスの定義を見て、どうですか?


first_name = Field('first_name')



クラス属性名としてフィールド名 ( 'first_name' ) を利用しているにもかかわらず、Field デスクリプタにも同じ情報を渡している ( Field('first_name') ) ために異様に目にうるさいコードになってしまっています。


もしフィールドが 10 も 20 もあったら目がチカチカします、多分。


しかし、これはある意味で致し方の無いことです。


代入式は右辺から左辺の順番に評価されますから、最初に Field('first_name') が実行されて、その結果が first_name クラス変数に代入されますね。


ですから、Field クラスインスタンスが作られる段階で「引数はクラス属性と同じ名前の文字列だからそれを使ってね」とお願いするのは無理なんですね。


でもやっぱり冗長で気になります。よしっ、メタクラスを定義して解決することにしましょう。


class MyMeta(type):
def __new__(cls, name, bases, attrs):
for k, v in attrs.items():
if isinstance(v, Field):
v.name = k
v.internal_name = '_' + k
return type.__new__(cls, name, bases, attrs)



このメタクラスは、Custome クラス定義の読み込み終了直後に実行されます。


クラスの属性辞書の中で属性値が Field デスクリプタインスタンスのものを探し出し、そのインスタンスの name 属性にはクラス属性名 ( = フィールド名 ) を、interna_name 属性には クラス属性名に '_' を付け加えたものをセットしています。


つまり、元々の Field デスクリプタクラスの __init__ で行っていたことですね。


続いて、MyMeta クラスを利用するベースクラスを定義します。


データベースのテーブルの各フィールドを象徴するクラスは必ずこのクラスから派生するようにします。


class DatabaseRow(metaclass=MyMeta):
pass



そして Field デスクリプタクラスですが、__init__ で引数を受け取る必要もなくなり、処理はメタクラスが代行してくれるようになりましたから、ただインスタンス変数を定義するだけになります。そのほかの変化はありません。


class Field:
def __init__(self):
self.name = None
self.internal_name = None

def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.internal_name, '')

def __set__(self, instance, value):
setattr(instance, self.internal_name, value)



メタクラス、ベースクラス、新しい Field デスクリプタのおかげで、Customer クラス定義時の冗長性は一切なくなります。


class Customer(DatabaseRow):
first_name = Field()
last_name = Field()
age = Field()


cust = Customer()


print(f"Before(cust.first_name): {cust.first_name!r} {cust.__dict__!r}")

# Before(cust.first_name): '' {}


cust.first_name = 'Saki'
cust.age = 28


print(f"After(cust.first_name): {cust.first_name!r} {cust.__dict__!r}")

# After(cust.first_name): 'Saki' {'_first_name': 'Saki', '_age': 28}



結果も以前と同じ、問題なしですね。


ただ、この方法の問題点は、Field デスクリプタクラスを単独では使用できなくなってしまい、使用したい場合は必ず DatabaseRow ベースクラスから派生させなければいけなくなってしまうことです。


もし DatabaseRow クラスを継承することを忘れてしまったり、設計上の都合からそうしたくない場合、コード全体が機能しなくなってしまいます。


class BrokenCustomer:
first_name = Field()


cust = BrokenCustomer()


cust.first_name = 'Hana'

# Traceback (most recent call last):
# TypeError: attribute name must be string, not 'NoneType'



この問題の解決方法は、Python 3.6 で導入されたデスクリプタ専用特殊関数 __set_name__ の利用です。


この特殊関数をデスクリプタ内に定義しておくと、このデスクリプタのインスタンスを含むオーナークラスの定義直後に全てのデスクリプタインスタンスについてこの関数が呼び出され、その際、2つのパラメータを受け取るようになります。


第1パラメータ: デスクリプタインスタンスを含むオーナークラス


第2パラメータ: デスクリプタインスタンスが割り当てられたクラス属性名


この第2パラメータを使うことで、前例の MyMeta クラスの __new__ 特殊関数内で行っていた操作を __set_name__ 内で行えるようになり、メタクラス定義自体を全て省くことができるようになっちゃうんですね。


class Field:
def __init__(self):
self.name = None
self.internal_name = None

def __set_name__(self, owner, name):
self.name = name
self.internal_name = '_' + name

def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.internal_name, '')

def __set__(self, instance, value):
setattr(instance, self.internal_name, value)



これによって Field デスクリプタクラスを単独で使用できるようになりましたし、メタクラスを定義する必要もなくなりました。


そして当初の目的である「属性名記述の重複をなくす」という目的ももちろん達成できます。


class Customer:
first_name = Field()
last_name = Field()
age = Field()


cust = Customer()


print(f"Before(cust.first_name): {cust.first_name!r} {cust.__dict__!r}")

# Before(cust.first_name): '' {}


cust.first_name = 'Yuka'
cust.age = 32


print(f"After(cust.first_name): {cust.first_name!r} {cust.__dict__!r}")

# After(cust.first_name): 'Yuka' {'_first_name': 'Yuka', '_age': 32}



まとめ:

1: メタクラス ( metaclass ) を利用することでクラスの「型」が完全に作られる前にクラス属性を操作することが可能です。

2: メタクラスとデスクリプタを併用することで、クラス定義時ならびにクラス実行時における属性アクセス操作を連携させることができるようになります。

3: デスクリプタクラスに __set_name__ 特殊関数を実装することで、自身のインスタンスを保有するオーナークラスと割り当てられるクラス属性名を参照可能になります。

4: オーナークラスのインスタンス毎に属性値を保持しておきたい場合、weakref 組み込みモジュールを利用してメモリリークを防ぐ方法よりは、今回紹介した、それぞれのインスタンスの属性辞書を直接操作する方法がおススメです。

この投稿をメールでシェアする

0 comments

コメントはまだありません。

コメントを追加する(不適切と思われるコメントは削除する場合があります)