LoginSignup
4
0

More than 3 years have passed since last update.

Pythonの__getattr__()を使ってObserverデザインパターンを実装

Last updated at Posted at 2020-12-11

Abstruct

PythonでPush型の非同期的デザインであるObserverパターン(Subscribe-Publish)を__getattr__()を使って実装してみた.
(改良を追記しました.Githubのコードはこの記事の範囲の最終版になります)
https://github.com/Philosophistoria/Python_ObserverDesign

PythonでObserberパターンについては以下の記事でも:
https://qiita.com/ttsubo/items/34690c8e9351f5b2e989
https://qiita.com/Algos/items/ba9c6ed52380b57aecac

ただ__getattr__を用いた実装はなかったので今回記事を書いてみた.
もしかしたらあまりよくない方法かも?
まあたぶんオーバーヘッドは大きいです.

が,もともとPythonで速さを求めてないし,実装が簡単だと思います.

Background

PythonでCallbackを実現するのは結構容易いが,
既存のクラスにCallbackを登録するような変更を加えるのがちょっと嫌だったのと,
すべてのメソッドに対して通知が欲しいが,メソッドが多すぎて面倒な場合,
これらの変更ができない場合(かなり面倒くさい場合)があった.

そこでObservableにしたいオブジェクトをラップすることでその実現ができないかと模索したところ
__getattr__を使うことでそれが可能っぽかったので,実験してみた.

__getattr__の説明は以下参考:
- 【Python中級者への道】クラスにgetattr関数を定義する
- ドットによるオブジェクトの属性の取得をカスタマイズするためのgetattrgetattribute
- 公式ドキュメント

Details

Neko

class Neko:
    def nyan(self):
        print("nyaan")

    def meow(self):
        print("meow")

    def default_response(self):
        print ("wagahai am neko")
input
neko = Neko()
neko.nyan()
neko.meow()
# neko.neee()           # <-- AttributeError: 'Neko' object has no attribute 'neee'
# neko.nyan("nekoneko") # <-- TypeError: nyan() takes 1 positional argument but 2 were given
output
nyaan
meow

以上のようなNekoクラスがあったとき,このNekoがなく瞬間に別のオブジェクトが反応してほしい.
いま猫がどこ出身かを診断するNekoObserverがいたとする.

NekoObserver
class NekoObserver:
    def listen(self, nakigoe):
        if (nakigoe == "meow"):
            print("this nekosama meows!")
        else:
            print("this nekosama is from Japan")

このNekoObserverlistenメソッドをNekoオブジェクトであるnekoにアタッチし
nekoが鳴くたびにその猫がどこ出身かを判別してほしい.

しかし,今のところNekoクラスにはそういった機能は載っていない.
そこで以下のようなクラスを用意しラップ(引数notifierに代入)する.

ObservableWrapper
class HeroicNotifier:
    def __init__(self, notifier, callback=None):
        self.callbacks = [] 
        self.notifier = notifier
        self.attatch(callback)

    def attatch(self, callback):
        if (callable(callback)):
            self.callbacks.append(callback)

    def __getattr__(self, name):
        for callback in self.callbacks:
            callback(name)
        if hasattr(self.notifier, name):
            return getattr(self.notifier, name)
        elif hasattr(self.notifier, "default_response"):
            return getattr(self.notifier, "default_response")
        else:
            return self.__default_action
            # same as:
            # return getattr(self, "__default_action")

    def __default_action(self, *args):
        print ('no such attribute')
input
neko = Neko()                                # <--- neko 用意
nekodignosis = NekoObserver()                # <--- 猫出身地診断Observer用意
heroic_neko = HeroicNotifier(neko)           # <--- Observableなnekoになっていただく
heroic_neko.attatch(nekodignosis.listen)     # <--- nekoの鳴き声に耳を傾ける

# neko が鳴いてみる
heroic_neko.nyan()
heroic_neko.meow()
heroic_neko.neee()
# heroic_neko.nyan("nyaaaan") # <-- TypeError: nyan() takes 2 positional argument but 2 were given
output
this nekosama is from Japan
nyaan
this nekosama meows!
meow
this nekosama is from Japan
wagahai am neko

となって,猫が鳴く直前にステレオタイプに基づいて猫を日本出身認定している.きもい.

今のところCallbackを実行してから,もとのメソッドを実行しかできない.

これはある猫メソッドheroic_neko.nyan()がよばれたとき,
まず()の直前のheroic_neko.nyanまでの読み込みにおいてheroic_neko.__attribute__()がよばれ,
heroic_nekonyanというattributeを持っていない場合heroic_neko.__getattr__()が呼ばれ,
でこの中でCallbackを実行してから,もとのneko.nyan()の参照neko.nyanを返し,
最後にneko.nyan()を実行しているためである.

すなわち,{heroic_neko.nayn --> neko.nyan}()という形になっている.伝わってほしい.

どうにかreturnしたあとに少し処理を続けられるようにするか,
左辺値の関数ポインタを変更するかなどできればできそうかもだけど,
たぶんPythonだともっといい方法があるのかな.

これ書いた後にちょっと調べたら以下のスレッドに似たようなコードがあった(気がする)
https://stackoverflow.com/questions/13528213/observer-observable-classes-in-python

P.S.

元のメソッドを読んだ後にCallbackを呼ぶのは元のメソッドをラップしたメソッドの参照(の値)を返せばできた.
すなわち,以下のようにObsarvableWrapperを変更すると,

ObservableWrapper
class HeroicNotifier:
    def __init__(self, notifier, callback=None):
        self.callbacks = [] 
        self.notifier = notifier
        self.attatch(callback)

    def attatch(self, callback):
        if (callable(callback)):
            self.callbacks.append(callback)

    def __getattr__(self, name):
-       for callback in self.callbacks:
-           callback(name)
-       if hasattr(self.notifier, name):
-           return getattr(self.notifier, name)
-       elif hasattr(self.notifier, "default_response"):
-           return getattr(self.notifier, "default_response")
-       else:
-           return self.__default_action
-           # same as:
-           # return getattr(self, "__default_action")
+       self.attr_name
+       return __wrapper

    def __default_action(self, *args):
        print ('no such attribute')
+
+   def __wrapper(self, *args):
+       if hasattr(self.notifier, self.attr_name):
+           self.callable_attr = getattr(self.notifier, self.attr_name)
+       elif hasattr(self.notifier, "default_response"):
+           self.callable_attr = getattr(self.notifier, "default_response")
+       else:
+           self.callable_attr = self.__default_action
+           # same as:
+           # getattr(self, "__default_action")
+
+       retval = None
+
+       for callback in self.callbacks:    #<--- pre method callback
+           callback(self.attr_name)
+
+       retval = self.callable_attr(*args)
+
+       for callback in self.callbacks:    #<--- post method callback
+           callback(self.attr_name)
+
+       return retval

以下のようなInputとOutputになる

input
    print("\n! Ordinal neko:\n")
    neko = Neko()
    neko.nyan()
    neko.meow()
    # neko.neee()           # <-- AttributeError: 'Neko' object has no attribute 'neee'
    # neko.nyan("nekoneko") # <-- TypeError: nyan() takes 1 positional argument but 2 were given

    print("\n! noko dignosis:\n")
    nekodignosis = NekoObserver()
    heroic_neko = HeroicNotifier(neko)
    heroic_neko.attatch(nekodignosis.listen)
    heroic_neko.nyan()
    heroic_neko.meow()

    print("\n! neko dont have neee voice:\n")
    heroic_neko.neee()
output
! Ordinal neko:

nyaan
meow

! noko dignosis:

this nekosama is from Japan
nyaan
this nekosama is from Japan
this nekosama meows!
meow
this nekosama meows!

! neko dont have neee voice:

this nekosama is from Japan
wagahai am neko
this nekosama is from Japan

nekoが鳴いたあとにも出身地診断をしている.

heroic_neko.nyan()では

  1. 前置callback
  2. retval = neko.nyan()
  3. 後置callback
  4. return retval

の順で実行されている.

したがって後置Callbackそれ自体,あるいはCallbackによってフラグが立って再開された別スレッド上の手続きなどが
返されたretvalを受け取る左辺値を監視している場合,実行時その左辺値が変更されていない状態で動いてしまうことになる.

例えば,今Nekoクラスが以下のようなとき

Neko

    def nyan(self):
-       print("nyaan")
+       return "nyaan"

    def meow(self):
-       print("meow")
+       return "meow"

    def default_response(self):
-       print("wagahai am neko")
+       return "wagahai am neko"

でObserverがあるミュータブル変数を監視してる

NekoObserver
class NekoObserver:
+   def __init__(self, buf=[None]):
+       self.buf = buf

    def listen(self, nakigoe):
        if (nakigoe == "meow"):
            print("this nekosama meows!")
+           print(self.buf)
        else:
            print("this nekosama is from Japan")
+           print(self.buf)

そしてメイン手続きの中でnekoの各メソッドからの返り値(各鳴き声)
NekoObserverが監視しているバッファに格納しようとする.

input
if __name__ == "__main__":
    print("\n! Ordinal neko:\n")
    neko = Neko()
-   neko.nyan()
+   print(neko.nyan())
-   neko.meow()
+   print(neko.meow())

    print("\n! noko dignosis:\n")
+   retval = [None]   #<--- NekoObserverの監視するミュータブルオブジェクト
    nekodignosis = NekoObserver(retval) #<--- 監視させる
    heroic_neko = HeroicNotifier(neko)
    heroic_neko.attatch(nekodignosis.listen)
-   heroic_neko.nyan()
+   retval[0] = heroic_neko.nyan()      #<--- 返り値をリストに入れる
-   heroic_neko.meow()
+   retval[0] = heroic_neko.meow()      #<--- 返り値をリストに入れる

    print("\n! neko dont have neee voice:\n")
-   heroic_neko.neee()
+   retval[0] = heroic_neko.neee()      #<--- 返り値をリストに入れる

これに対するoutputは以下のようになり,後置Callbackでは元メソッドの返り値を含むリストが返ってきてほしいのに
そうではない状態になっている.

output
! Ordinal neko:

nyaan
meow

! noko dignosis:

this nekosama is from Japan
[None]
this nekosama is from Japan
[None]                       #<--- ここが'nyaan'になってほしい
this nekosama meows!
['nyaan']
this nekosama meows!
['nyaan']                    #<--- ここが'meow'になってほしい

! neko dont have neee voice:

this nekosama is from Japan
['meow']
this nekosama is from Japan
['meow']                     #<--- ここが'wagahai am neko'になってほしい

P.S.2

そこでconstlib.constmanegerを使ったラッピングでwithステイトメントの中で代入操作をすることでこれを解決してみる.

ObservableWrapper
+import contextlib
 class HeroicNotifier:
     def __init__(self, notifier, callback=None):
         self.callbacks = [] 
         self.notifier = notifier
         self.attatch(callback)

     def attatch(self, callback):
         if (callable(callback)):
             self.callbacks.append(callback)

     def __getattr__(self, name):
         self.attr_name = name 
         return self.__wrapper

     def __default_action(self, *args):
         print ('no such attribute')
+
+    @contextlib.contextmanager
     def __wrapper(self, *args):
         if hasattr(self.notifier, self.attr_name):
             self.callable_attr = getattr(self.notifier, self.attr_name)
         elif hasattr(self.notifier, "default_response"):
             self.callable_attr = getattr(self.notifier, "default_response")
         else:
             self.callable_attr = self.__default_action
             # same as:
             # getattr(self, "__default_action")
-
-        retval = None

         for callback in self.callbacks:
             callback(self.attr_name)
-
-        retval = self.callable_attr(*args)
+
+        yield self.callable_attr(*args)   #<--- ここでいったんwith stamentのas以降に返り値を格納

         for callback in self.callbacks:
             callback(self.attr_name)
-
-        return retval
input
 if __name__ == "__main__":
     print("\n! Ordinal neko:\n")
     neko = Neko()
     print(neko.nyan())
     print(neko.meow())

     print("\n! noko dignosis:\n")
     retval = [None]
     nekodignosis = NekoObserver(retval)
     heroic_neko = HeroicNotifier(neko)
     heroic_neko.attatch(nekodignosis.listen)
-    retval[0] = heroic_neko.nyan()
+    with heroic_neko.nyan() as whatnekosays:
+        retval[0] = whatnekosays
-    retval[0] = heroic_neko.meow()
+    with heroic_neko.meow() as whatnekosays:
+        retval[0] = whatnekosays

     print("\n! neko dont have neee voice:\n")
-    retval[0] = heroic_neko.neee()
+    with heroic_neko.neee() as whatnekosays:
+        retval[0] = whatnekosays

とするとoutputは

output
! Ordinal neko:

nyaan
meow

! noko dignosis:

this nekosama is from Japan
[None]
this nekosama is from Japan
['nyaan']                   #<--- 後置Callbackでちゃんと所望の鳴き声が反映されている
this nekosama meows!
['nyaan']
this nekosama meows!
['meow']                    #<--- 後置Callbackでちゃんと所望の鳴き声が反映されている

! neko dont have neee voice:

this nekosama is from Japan
['meow']
this nekosama is from Japan
['wagahai am neko']          #<--- 後置Callbackでちゃんと所望の鳴き声が反映されている

Conclusion

どちらかといえば犬派です.

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0