🥽

Python のクラスをハックして型付き immutable / protected 属性を作った

2023/04/23に公開

はじめに

Python のクラスの属性(attribute: メンバ、メソッド、インスタンス変数なども指す)は基本的に const にしたり private にしたりすることができませんし、型もありません。

  • でもこの属性は一度定義したら書き換えたくないなー、とか
  • この属性はユーザに勝手に書き換えられたくないなー、とか
  • この属性は自動的にこの型に制約してほしいなー、とか

そんなときありませんか? ありますよね? そう、あるんです。

あったので作りました。

https://github.com/wsuzume/nobus

$ pip install nobus

で使えます。

参考文献

Example

自分でクラスを定義するときに nobus.safeattrSafeAttrABC を継承し、

  • immutable: 一度定義したら書き換えられない(型チェック可能)
  • protected: 通常アクセスでは書き換えられない、隠蔽アクセスで書き換え可能(型チェック可能)
  • typed: アクセス方法については制御せず型チェックのみ実施

の3つから選んで使うだけです。

定義方法
from nobus.safeattr import immutable, protected, typed, SafeAttrABC

class Person(SafeAttrABC):
    def __init__(self, name, age):
        # 通常通りの定義も可能
        self.species = 'human'

        fst, lst = name.split(' ')

        # アクセス制御や型チェックが可能
        self.first_name = immutable(fst, str)

        self.last_name = protected(lst, str)

        self.age = typed(age, int)


person = Person('Josh Nobus', 27)
immutable 属性: 一度定義したら書き換え不可能
immutable 属性に対するアクセス
# 通常アクセス
print(person.first_name)

# 隠蔽アクセス
print(person._first_name)

# 隠蔽された実体に対するアクセス
print(person._safeattr_first_name)

# 通常アクセスと隠蔽アクセスのどちらから書き換えてもエラー
person.first_name = 'Jane'
person._first_name = 'Jane'
output
Josh
Josh
Immutable(value='Josh', type_=<class 'str'>, optional=False)
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-5-5888de4903e3> in <cell line: 11>()
      9 
     10 # 通常アクセスと隠蔽アクセスのどちらから書き換えてもエラー
---> 11 person.first_name = 'Jane'
     12 person._first_name = 'Jane'

...

/usr/local/lib/python3.9/dist-packages/nobus/safeattr.py in setter(self, value)
    241                 if isinstance(attr, Immutable):
    242                     # immutable を書き換えようとしているのでエラー
--> 243                     raise AttributeError(f"immutable attribute '{name}' cannot be rewritten.")
    244                 elif isinstance(attr, Protected):
    245                     # protected に通常の名前でアクセスして書き換えようとしていたのでエラー

AttributeError: immutable attribute 'first_name' cannot be rewritten.
protected 属性: 隠蔽アクセスで書き換え可能
protected 属性に対するアクセス
# 通常アクセス
print(person.last_name)

# 隠蔽アクセス
print(person._last_name)

# 隠蔽された実体に対するアクセス
print(person._safeattr_last_name)

# 隠蔽アクセスによる書き換えはエラーにならない
person._last_name = 'Washington'
print(person.last_name)

# 通常アクセスによる書き換えはエラー
person.last_name = 'Washington'
output
Nobus
Nobus
Protected(value='Nobus', type_=<class 'str'>, optional=False)
Washington
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-6-b55af4fef567> in <cell line: 15>()
     13 
     14 # 通常アクセスによる書き換えはエラー
---> 15 person.last_name = 'Washington'

...

/usr/local/lib/python3.9/dist-packages/nobus/safeattr.py in setter(self, value)
    244                 elif isinstance(attr, Protected):
    245                     # protected に通常の名前でアクセスして書き換えようとしていたのでエラー
--> 246                     raise AttributeError(f"protected attribute '{name}' cannot be rewritten.")
    247 
    248                 if not isinstance(attr, Typed):

AttributeError: protected attribute 'last_name' cannot be rewritten.
typed 属性: 代入時に自動で型チェック
typed 属性に対するアクセス
# 通常アクセス
print(person.age)

# 隠蔽アクセス
print(person._age)

# 隠蔽された実体に対するアクセス
print(person._safeattr_age)

# 通常アクセス、隠蔽アクセスどちらで書き換えてもエラーにならない
person.age = 28
print(person.age)

person._age = 29
print(person.age)

# ただし型チェックが入っており、
# 指定した型と異なるとエラーになる
person.age = 'dog'
output
27
27
Typed(value=27, type_=<class 'int'>, optional=False)
28
29
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-12-8b8aa9b18d2f> in <cell line: 19>()
     17 # ただし型チェックが入っており、
     18 # 指定した型と異なるとエラーになる
---> 19 person.age = 'dog'

...

/usr/local/lib/python3.9/dist-packages/nobus/safeattr.py in typecheck(value, type_, optional)
     35             if isinstance(value, type_):
     36                 return True
---> 37             raise TypeError(f"value must be instance of {type_} but actual type {type(value)}.")
     38 
     39         return

TypeError: value must be instance of <class 'int'> but actual type <class 'str'>.

仕組み

SafeAttrABC クラスが、継承された子クラスの __setattr__() メソッドと __getattribute__() メソッドをジャックして、インスタンスの属性に対するアクセスを監視しています。

immutable, protected, typed などの関数で作られる変数はそれぞれ Immutable, Protected, Typed というクラスのインスタンスになっていて、ImmutableProtectedTyped の子クラスです。ImmutableProtected も型チェック可能です。

SafeAttrABC は通常の属性、たとえば person.x に対して Typed クラスのインスタンス(ImmutableProtected も含む)が割り当てられそうになると、その実体を person._safeattr_x に隠蔽して管理下に置きます[1]

属性が管理下に置かれると、アクセスポイントとして person.x, person._x というプロパティが作成され、person._safeattr_x から情報を読み取って型チェック&アクセス制御を行います。このおかげでユーザはあたかも person.x に読み書きを行ったかのように感じます。

Python ではユーザから見える名前は person.x のようにしておき、開発者がいじり回す実体には person._x という名前をつける風習があります。型チェックやアクセス制御をしたくなるようなメンバにはどうせ ._x を作るので、SafeAttrABC は制御対象とするメンバに自動的に ._x のアクセスポイントも作成します。

まとめると、アクセス方法は以下の3種類です。

アクセス方法 属性名 型チェック 読取り可能 書込み可能
通常アクセス .x 無制限 Typed のみ
隠蔽アクセス ._x 無制限 Typed, Protected のみ
実体アクセス ._safeattr_x 不可 無制限 無制限だけど書き換えてはダメ(管理できなくなるから)

Usage

ImmutableProtectedTyped から派生したクラスであり、アクセス制御が異なるだけで型チェックの機構は Typed と同様です。したがって使い方の解説ではどれかひとつについてのみ解説し、他をハショることがあります。

インストール

$ pip install nobus

インポート

from nobus.safeattr import SafeAttrABC

アクセス制御済み属性の定義

名前が '_' で始まらない通常の属性を定義するときに Typed クラス、およびその派生クラスである Immutable, Protected のインスタンスが与えられると、その属性を管理対象とします。

視覚的ノイズを軽減するために immutable, protected, typed という関数を用意しているのでそれを使うのがオススメです。また、名前空間を汚染したくないときは SafeAttrABC.immutable(), .protected(), .typed() という staticmethod を定義してあるのでそれを使うとよいです。機能に違いはないので好きなものを使ってください。

from nobus.safeattr import SafeAttrABC
from nobus.safeattr import immutable, protected, typed
from nobus.safeattr import Immutable, Protected, Typed

class MyClass(SafeAttrABC):
    def __init__(self):
        # 管理対象とならない定義方法
        self.species = 'human'

        # 管理対象となる定義方法1(オススメ)
        self.first_name = immutable('Josh')
        self.last_name = protected('Nobus')
        self.age = typed(27)

        # 管理対象となる定義方法2(お好みで)
        self.first_name = Immutable('Josh')
        self.last_name = Protected('Nobus')
        self.age = Typed(27)

        # 管理対象となる定義方法3(名前空間を汚したくないとき)
        self.first_name = self.immutable('Josh')
        self.last_name = self.protected('Nobus')
        self.age = self.typed(27)

inst.x = immutable(3) のように属性が定義されると、SafeAttr は自動的に以下の属性を作成します。

self._safeattr_x

# self._x は作成されませんが存在しているように見えます

@property
def x(self):
    ...

@x.setter
def x(self, value):
    ...
    
def arg_x(self, value):
    ...

._safeattr_x は SafeAttr が管理しているデータの実体であり、ユーザからの書き換えは(可能ですが)想定されていません。

._x はクラスの開発者からのアクセスを想定したインターフェースになります。._x という名前のメンバやプロパティは作成されませんが、SafeAttr によってアクセスをインターセプトされ、._safeattr_x へリダイレクトされるようになります。この時点でユーザからは本物の ._x が unreachable になります。もともと ._x という名前の属性が存在していた場合は自動的に削除されます[2]

.x という名前の getter と setter が作成され、ユーザはあたかも定義した属性がそのまま存在しているように感じます。実際のアクセスは ._safeattr_x へリダイレクトされます。

.arg_x() の機能は型チェックの章で説明します。

  • .x でアクセスしたとき、Immutable, Protected であれば書き換えが禁止されます。
  • ._x でアクセスしたとき、Immutable であれば書き換えが禁止されます。
  • ._safeattr_x でアクセスしたとき、任意に書き換えが可能ですがユーザによる書き換えは想定されていません。
  • .arg_x() は属性に対して書き換えを行いませんので任意に使用していただいて構いません。

型チェック

型チェックは以下のタイミングで自動的に実行されます。

  • Immutable, Protected, Typed などの初期化時
  • SafeAttr の管理下にある属性に代入が実行されたとき

型チェックには Python の isinstance 関数が用いられますが、ユーザ定義の TypeChecker を用いることも可能です。

型を指定する場合はイニシャライザの第二引数に与えます。複数の型を許容する場合は、複数の型をタプルで与えます(isinstance の仕様上、リストなどではダメなので注意)。属性が None であることを許容する場合は第三引数に optional=True を指定します。

あるいはユーザ定義の TypeChecker を定義することも可能です。TypeChecker はひとつの引数を取り、許容される型なら True、それ以外は False を返します。SafeAttr は TypeCheckerFalse を返したという情報しか知らないため、エラーメッセージがわかりにくい場合は TypeChecker の中で自前の TypeError を吐くように実装してください。

from nobus.safeattr import SafeAttrABC
from nobus.safeattr import typed
from nobus.safeattr import typechecker

@typechecker
def even(x):
    if isinstance(x, int) and x % 2 == 0:
        return True
    return False

class MyClass(SafeAttrABC):
    def __init__(self):
        self.x = typed(1, (int, str))
        self.y = typed(None, int, optional=True)
        self.z = typed(6, even)
inst = MyClass()

# 型が合うので OK
inst.x = 5
inst.x = 'hoge'

inst.y = None
inst.y = 6

inst.z = 4

# 型が合わないので NG
inst.x = 3.0
inst.x = None

inst.y = '7'

inst.z = '9'
inst.z = 11
output
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-71-09c246687ae7> in <cell line: 13>()
     11 
     12 # 型が合わないので NG
---> 13 inst.x = 3.0
     14 inst.x = None
     15 

...

/usr/local/lib/python3.9/dist-packages/nobus/safeattr.py in typecheck(value, type_, optional)
     35             if isinstance(value, type_):
     36                 return True
---> 37             raise TypeError(f"value must be instance of {type_} but actual type {type(value)}.")
     38 
     39         return

TypeError: value must be instance of (<class 'int'>, <class 'str'>) but actual type <class 'float'>.

メソッドを呼び出すときに以下のような挙動になってほしいことがよくあります。

  1. 引数に None が与えられたら自身の属性で上書きする
  2. 引数に None 以外が与えられたらそのメソッド内では与えられた値を使う
    • このとき型チェックが実行される

これを実現するのが SafeAttr の管理下にある属性 .x に対して自動的に実装される .arg_x() メソッドです。

from nobus.safeattr import SafeAttrABC
from nobus.safeattr import typed

class MyClass(SafeAttrABC):
    def __init__(self):
        self.x = typed('Bow!', str)
    
    def hello(self, x=None):
        x = self.arg_x(x)
        print(x)

inst = MyClass()

inst.hello()
inst.hello(x='Meow!')

# 型エラーになります
inst.hello(x=5)
output
Bow!
Meow!
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-7-9884fd282648> in <cell line: 18>()
     16 
     17 # 型エラーになります
---> 18 inst.hello(x=5)

...

/usr/local/lib/python3.9/dist-packages/nobus/safeattr.py in _typecheck(value, type_, optional)
     35             if isinstance(value, type_):
     36                 return True
---> 37             raise TypeError(f"value must be instance of {type_} but actual type {type(value)}.")
     38 
     39         return

TypeError: value must be instance of <class 'str'> but actual type <class 'int'>.

与えられた引数に型キャストを施すには第二引数にキャスト用の関数を与えます。

from nobus.safeattr import SafeAttrABC
from nobus.safeattr import typed

from pathlib import Path

class MyClass(SafeAttrABC):
    def __init__(self):
        self.x = typed(Path('Bow!'), (str, Path))
    
    def hello(self, x=None):
        x = self.arg_x(x, Path)
        print(x, type(x))

inst = MyClass()

inst.hello()
inst.hello(x='Meow!')
output
Bow! <class 'pathlib.PosixPath'>
Meow! <class 'pathlib.PosixPath'>

第三引数の typecheckFalse にすると、引数に対する型チェックは行いません。

from nobus.safeattr import SafeAttrABC
from nobus.safeattr import typed

class MyClass(SafeAttrABC):
    def __init__(self):
        self.x = typed('Bow!', str)
    
    def hello(self, x=None):
        x = self.arg_x(x, typecheck=False)
        print(x)

inst = MyClass()

inst.hello()
inst.hello(x='Meow!')

# 型エラーになりません
inst.hello(x=5)
output
Bow!
Meow!
5

以上で SafeAttrABC が持つ機能は全部になります。

解説

今回は以前作った kette ほどの黒魔術はありません。Python の仕様のせいで妙なやり方になっている部分はありますが、基本的には地道な条件分岐です。個々の条件分岐は全体の整合性を取るためのものであり特に解説するようなことはないので、今回は実装のときに困った Python の仕様とそれを回避するためのテクニックについて紹介します。

マングリング機構と継承

当初は隠蔽するメンバは、アンダースコア2つを名前の先頭に持つマングリング機構によって隠蔽されたメンバを使おうと思っていました。これを使えるならばプライベート変数も作れたかもしれません。

class MyClass:
    def __init__(self):
        self.__x = 3

    def show(self):
        # ここからは見える
        print(self.__x)

inst = MyClass()

# ここからは見えない
print(inst.__x)

表示してみるとわかりますが、.__x というメンバはクラス定義の外からは ._MyClass__x という名前で見えているので外で .__x にアクセスしても AttributeError になります(._MyClass__x に直アクセスすることは可能です)。このようにして擬似的にプライベート変数のような挙動を実現する仕組みを Python のマングリング機構といいます。

print(dir(inst))
output
['_MyClass__x',
 '__class__',
 '__delattr__',
 '__dict__',
 ...

pathlibPath から派生した子クラスを作ろうとしたときに知ったのですが、このマングリング機構はクラスの継承の際にも有効になります。つまり他のプログラミング言語で言うところの private 変数に相当する機構になっていて、継承した子クラスからも隠蔽されてしまいます。

class MyClass:
    def __init__(self):
        self.__x = 3

    def show(self):
        # ここからは見える
        print(self.__x)

class MyChildClass:
    def __init__(self):
        super().__init__()

    def show(self):
        # ここからは見えない
        print(self.__x)

inst = MyChildClass()

# エラーになる
inst.show()

エラーを見てみればわかりますが、MyChildClass 内での .__x._MyChildClass__x に変換されてしまうので、MyClass 内で定義された ._MyClass__x とは名前が食い違ってしまいます。

この性質は SafeAttrABC という抽象クラスを継承することでメンバに対するアクセス制御を実現する機構とは相性が悪かったです。ユーザから隠蔽された実体にアクセスしにくくするためには .__x という名前にしたほうがよかったのですが、それだと継承先の子クラスで動作しなくなるので実体は ._safeattr_x のような名前にせざるを得ませんでした。

__setattr__() をオーバーライドするときの注意

__setattr__() は以下のようにクラスにメンバを追加するとき(代入時)に呼び出されるメソッドです。

self.x = 2
inst.x = 3
setattr(self, 'x', 5)
setattr(inst, 'x', 7)

__setattr__() メソッドをオーバーライドするときは、注意しないとクラスが機能不全を起こしたり無限再帰になってエラーになったりします。例を示します。

メンバの追加や上書きが機能不全になる例
class MyClass:
    def __setattr__(self, name, value):
        pass

inst = MyClass()

inst.x = 3

# メンバが追加されておらず AttributeError になる
print(inst.x)

これは inst.x = 3 のときに __setattr__() をインターセプトして何もしていないために、メンバの追加が不可能になっています。かといって以下のように、与えられた値を文字列に変換して自身にセットしようとすると __setattr__() が無限に再帰的に呼び出されてやはりエラーになります。

RecursionError になる例
class MyClass:
    def __setattr__(self, name, value):
        value = str(value)
        setattr(self, name, value)

inst = MyClass()

# maximum recursion error になる
inst.x = 3

これを回避するには、__setattr__() の中では親クラスの __setattr__() を呼び出すようにしないといけません。

うまくいく実装
class MyClass:
    def __setattr__(self, name, value):
        if isinstance(value, int):
            value = str(value)
        
        # 自分のではなく親クラスの __setattr__() を呼び出す
        super().__setattr__(name, value)

inst = MyClass()

inst.x = 3

print(inst.x)
print(type(inst.x))
output
3
<class 'str'>

意図した通りの動作になりました。基本的には監視対象としたいメンバに対するアクセスや、監視したい型の値が入ってこない限りは何もせずに親クラスの __setattr__() に渡すべきです。

https://github.com/wsuzume/nobus/blob/329cf0ff4ebf91c2c25e35ea77b85c3ebafecabf/nobus/safeattr.py#L157-L163

__getattribute__() をオーバーライドするときの注意

__getattribute__()__setattr__() と同様に、メンバへの属性アクセス(参照時)に呼び出されるメソッドです。__getattr__() という似たような名前の特殊メソッドも存在し、こちらはデフォルトの属性アクセスが失敗したときのみ呼び出されるという違いがあります。

__getattribute__()__setattr__() 同様に親クラスの __getattribute__() を呼び出さないとクラスが機能不全になります。一方で __getattr__() は検索している属性が存在するならば呼び出されないので、実装に失敗してもクラスが機能不全になることはありません。

いずれにせよインスタンスが保持する情報にアクセスしようとすると再帰の可能性が生じるため __setattr__() よりも無限再帰の回避が難しいです。

たとえば「インスタンスにその属性が存在するかどうかをチェック」しただけでも無限再帰になりえます。

__getattribute__() の実装に失敗し機能不全となる例
class MyClass:
    def __init__(self):
        self.x = 3
    
    def __getattribute__(self, name):
        pass

inst = MyClass()

# __getattribute__() 常に None を返すので機能不全になる
print(inst.x)
__getattribute__() が無限再帰になる例
class MyClass:
    def __init__(self):
        self.x = 3
    
    def __getattribute__(self, name):
        if hasattr(self, name):
            print(f"'{name}' という名前の属性を持っています")

inst = MyClass()

# hasattr が __getattribute__() を呼び出すので無限再帰する
print(inst.x)
__getattr__() が無限再帰になる例
class MyClass:
    def __init__(self):
        self.x = 3
    
    def __getattr__(self, name):
        if hasattr(self, name):
            print(f"'{name}' という名前の属性を持っています")

inst = MyClass()

# このメンバは発見できるので __getattr__() が呼び出されず機能不全にはならない
print(inst.x)

# hasattr が __getattr__() を呼び出すので無限再帰する
print(inst.y)

これは hasattr が「getattr を呼び出したときに AttributeError が発生するかどうかで属性を持っているかどうかを判断している」という仕様によるものです実装したやつを殴ったほうがいい[3]

したがって再帰させないように属性の存在確認を行うには、属性に対してアクセスしてみて AttributeError が発生しないかどうかチェックするという hasattr 相当のことを自分でやる必要があります。

うまくいく実装
class MyClass:
    def __init__(self):
        self.x = 1
        self._y = 2
    
    def __getattribute__(self, name):
        try:
            attr = super().__getattribute__(name)
            print(f"'{name}' という名前の属性を持っています")
            
            return attr
        except AttributeError:
            _name = '_' + name
            print(f"'{name}' という名前の属性はありません")
            print(f"代わりに '{_name}' という名前の属性を探してみます")
            return super().__getattribute__(_name)

inst = MyClass()

print(inst.x)
print(inst.y)
output
'x' という名前の属性を持っています
1
'y' という名前の属性はありません
代わりに '_y' という名前の属性を探してみます
2

__setattr__(), __getattribute__() は文字通りクラスの心臓部であり、ここがバグると継承したクラスはすべて心不全を起こして死ぬのでめちゃくちゃ気を遣います。

https://github.com/wsuzume/nobus/blob/329cf0ff4ebf91c2c25e35ea77b85c3ebafecabf/nobus/safeattr.py#L139-L155

インスタンスに動的にメソッドを追加する

.arg_x() メソッドは属性が SafeAttr の管理下に置かれるときに動的にインスタンスに追加されています。これにもちょっとしたコツが必要です。

というのも関数をインスタンスに追加してもそれは自動的にメソッドにはならないからです。以下で inst.morning はあくまでも関数であり、第1引数に自動的に self が渡されることはありません。

うまくいかない例
def morning(self):
    print('morning')

class MyClass:
    def hello(self):
        print('hello')

inst = MyClass()

# 単純に関数を追加しただけではメソッドにならない
inst.morning = morning

print(type(inst.morning))
print(type(inst.hello))
output
<class 'function'>
<class 'method'>

メソッドとして追加するには MethodType に変換してから追加する必要があります。

うまくいく例
from types import MethodType

def morning(self):
    print('morning')

class MyClass:
    def hello(self):
        print('hello')

inst = MyClass()

# inst のメソッドにする
inst.morning = MethodType(morning, inst)

print(type(inst.morning))
print(type(inst.hello))

inst.morning()
inst.hello()
output
<class 'method'>
<class 'method'>
morning
hello

https://github.com/wsuzume/nobus/blob/329cf0ff4ebf91c2c25e35ea77b85c3ebafecabf/nobus/safeattr.py#L262-L280

インスタンスに動的にプロパティを追加する

メソッドでさえちょっとした面倒がありましたが、プロパティの追加はもっと厄介です。まず Python のプロパティはクラスに結びついたものであるため、インスタンスを変更しただけでプロパティを追加することはできません

つまりプロパティを追加するにはもとのクラスを書き換える必要があるのですが、これをやってしまうとそのクラスのインスタンスすべてに影響が及んでしまいます。

以下はクラスに動的にプロパティを追加する例ですが、複数のインスタンスにプロパティの追加が反映されてしまうことがわかります。

クラスにプロパティを動的に追加するコード
class MyClass:
    def create_new_property(self, name):
        def getter_of(name):
            def getter(self):
                print(f"'{name}' の getter が呼び出されました")
                return getattr(self, '_' + name)
            return getter
        
        # getter をセットする
        setattr(self.__class__, name, property(getter_of(name)))

inst = MyClass()

# inst.x に対する getter をセット
inst.create_new_property('x')

# 隠蔽した属性をセット
inst._x = 3

# getter が呼び出される
print(inst.x)
output
'x' の getter が呼び出されました
3
ただしこの実装はすべてのインスタンスに影響を与えてしまう
class MyClass:
    def create_new_property(self, name):
        def getter_of(name):
            def getter(self):
                print(f"'{name}' の getter が呼び出されました")
                return getattr(self, '_' + name)
            return getter
        
        # getter をセットする
        setattr(self.__class__, name, property(getter_of(name)))

inst1 = MyClass()
inst2 = MyClass()

# inst1.x にのみ getter をセットしたつもり
inst1.create_new_property('x')

# 隠蔽した属性をセット
inst1._x = 1
inst2._x = 2

# inst2 にも getter がセットされてしまっている
print(inst1.x)
print(inst2.x)  # ← こっちはエラーになってほしいが、ならない
output
'x' の getter が呼び出されました
1
'x' の getter が呼び出されました
2

これを防ぐためにはインスタンスの親クラスの情報を、親クラスから動的に派生させた子クラスで上書きするという荒技を使わなければなりません。

型の情報を取得するために使われる type 関数ですが、実は3つ変数を与えることで子クラスを派生させることができます。第1引数には子クラスの名前、第2引数には親クラスのタプル、第3引数には追加する属性を辞書で与えます。

class MyClass:
    pass

new_class = type('MyChildClass', (MyClass, ), {})

print(new_class.__name__)
print(new_class)
output
MyChildClass
<class '__main__.MyChildClass'>

あとはこれを使って親クラスをすり替えれば完璧です。今回は結果をわかりやすくするために派生させた子クラスに 'Child' という名前を付加して結果をわかりやすくしていますが、親クラスとまったく同じ名前で派生させても正しく動作します(黒魔術みがある)。

意図した挙動になる動的なプロパティの追加
class MyClass:
    def __init__(self):
        # 複数回派生させないための制御フラグ
        self.is_derived_class = False

    def create_new_property(self, name):
        def getter_of(name):
            def getter(self):
                print(f"'{name}' の getter が呼び出されました")
                return getattr(self, '_' + name)
            return getter
        
        # 親クラスの情報を書き換える
        if not self.is_derived_class:
            cls = self.__class__
            self.__class__ = type(cls.__name__ + 'Child', (cls, ), {})

        # getter をセットする
        setattr(self.__class__, name, property(getter_of(name)))

inst1 = MyClass()
inst2 = MyClass()

# inst1.x にのみ getter をセットした
inst1.create_new_property('x')

# クラスが書きかわっていることを確認
print(inst1.__class__)
print(inst2.__class__)

# 隠蔽した属性をセット
inst1._x = 1
inst2._x = 2

# getter によるアクセス
print(inst1.x)
print(inst2.x)  # ← こっちがエラーになる!!
output
<class '__main__.MyClassChild'>
<class '__main__.MyClass'>
'x' の getter が呼び出されました
1
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-52-0706c4d9b2f3> in <cell line: 37>()
     35 # getter によるアクセス
     36 print(inst1.x)
---> 37 print(inst2.x)  # ← こっちがエラーになる!!

AttributeError: 'MyClass' object has no attribute 'x'

https://github.com/wsuzume/nobus/blob/329cf0ff4ebf91c2c25e35ea77b85c3ebafecabf/nobus/safeattr.py#L122-L128

https://github.com/wsuzume/nobus/blob/329cf0ff4ebf91c2c25e35ea77b85c3ebafecabf/nobus/safeattr.py#L259-L260

おしまい

そんなこんなで試行錯誤しながらできたのが safeattr モジュールになります。この記事がみなさんの Python 黒魔術ライフの充実に繋がれば幸いです。

safeattr モジュールもよかったら使ってね!!

脚注
  1. Typed クラスのインスタンスではない通常のオブジェクトが割り当てられるときは通常通りの動作になります。 ↩︎

  2. メモリリークが起こる可能性があるため削除しています。 ↩︎

  3. 単に辞書のキーを検索して存在確認するような実装にしていれば再帰しなかったのに。 ↩︎

Discussion