1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Python で C# っぽいイベント機構を使うためのクラス

Last updated at Posted at 2023-02-28

基本部分

何番煎じか知らんけど、python で C# っぽいイベント機構を使うためのクラス。

ChatGPT に「Django の Signal を使うのもいいんじゃない?」とか言われたけど、なんか手軽さがないので自作。(てか、Python 標準ライブラリで用意されていてもいいレベルだと思うが、もっと pythonic なやり方があるんか?)

そして、なんか改善しようと頑張った人がいるっぽいが、私の欲しい機能としては以下で必要十分。てか、C# でも sender 渡すし。

event.py
"""A module for implementing event-driven programming in Python."""
from typing import Any
from collections.abc import Callable


class Event(Callable):
    """Event class"""

    def __init__(self) -> None:
        self._handlers = []

    def __iadd__(self, other: Any) -> "Event":
        if not isinstance(other, Callable):
            raise TypeError(f"'{type(other)}' is not callable.")

        if other in self._handlers:
            raise ValueError(
                f"Handler (object id: '{str(id(other))}') has already been added."
            )

        self._handlers.append(other)
        return self

    def __isub__(self, other: Any) -> "Event":
        if other in self._handlers:
            self._handlers.remove(other)
        return self

    def __call__(self, sender: object, *args, **kwargs) -> None:
        for handler in self._handlers:
            handler(sender, *args, **kwargs)
使ってみる
from . import event

class EventTestClass:
    def __init__(self) -> None:
        self._test_event = event.Event()

    @property
    def test_event(self) -> Event:
        return self._test_event

    @test_event.setter
    def test_event(self, new_value) -> None:
        if not new_value is self._test_event:
            raise ValueError("New value must be the same as the current value.")

    def test_event_happen(self) -> None:
        self._test_event(self, msg="test event has happened.")


def test_handler(sender: object, msg: str) -> None:
    print(msg)


if __name__ == '__main__':
    obj = EventTestClass()
    obj.test_event += my_handler
    obj.test_event_happen()
出力
test event has happened.

改善できたらいいなぁと思ってるところ

イベントごとにゲッター/セッターを書くのが面倒すぎる。デコレーターとかで何とかできないか…。案募集中。

案 1) デコレーターで何とかする

event_property というデコレーター (として使う関数) を追加した。

event.py (追加)
def event_property(fget):
    """A decorator for event properties.

    This decorator can be applied to a method in a class that represents an event.

    Note:
        This decorator automatically generates an attribute with a name starting with "_"\
        to store the Event object. The name is derived from the name of the decorated method.
    """
    event_attr_name = "_" + fget.__name__

    def getter(obj):
        if not hasattr(obj, event_attr_name):
            setattr(obj.__class__, event_attr_name, Event())
        return getattr(obj, event_attr_name)

    def setter(obj, value):
        if not value is getattr(obj, event_attr_name):
            raise ValueError("New value must be the same as the current value.")

    return property(getter, setter)
再度、使ってみる
from . import event

class EventTestClass:
    @event.event_property
    def test_event(self) -> Event:
        pass

    def test_event_happen(self) -> None:
        self.test_event(self, msg="test event has happened.")


def test_handler(sender: object, msg: str) -> None:
    print(msg)


if __name__ == '__main__':
    obj = EventTestClass()
    obj.test_event += my_handler
    obj.test_event_happen()

あとは、test_event()にあるpassがなくせたら完璧だな (まぁ docstring 書いたら消せるw)。それと、プライベートなインスタンス フィールドをデコレーター内で名前決め打ちで作ってるけどいいのかな、みたいな?

いや、もう十分使いやすいけど。

案 2) __init__()内で呼び出す関数でなんとかする

デコレーターで何とかする案は、結構複雑なうえに利用者が理解しづらそうなので、もっと単純な理屈のほうがよさげかなぁ。ということで作ってみた。

event.py (追加)
def add_event(obj: object, event_name: str) -> Event:
    """Add an event to an object.

    This function is intended to be used inside the initializer of obj's class.

    Args:
        obj (object): The object to which the event is to be added.
        event_name (str): The name of the event to be added.

    Returns:
        Event: The event object that was added to the object.

    Note:
        This function generates a property with a getter and a setter method, as well\
        as an attribute with a name starting with "_" to store the Event object.
    """
    event_obj = Event()
    event_attr_name = "_" + event_name

    setattr(obj, event_attr_name, event_obj)

    def getter(obj):
        return getattr(obj, event_attr_name)

    def setter(obj, value):
        if not value is getattr(obj, event_attr_name):
            raise ValueError("New value must be the same as the current value.")

    setattr(obj.__class__, event_name, property(getter, setter))
    return event_obj
再々度、使ってみる
from . import event

class EventTestClass:
    def __init__(self) -> None:
        self.test_event = event.add_event(self, "test_event")

    def test_event_happen(self) -> None:
        self.test_event(self, msg="test event has happened.")


def test_handler(sender: object, msg: str) -> None:
    print(msg)


if __name__ == '__main__':
    obj = EventTestClass()
    obj.test_event += my_handler
    obj.test_event_happen()

うーん、解りやすくはなった気がする。が、test_event ってのが文字列リテラルで書かれてるせいで、プロパティ名とのズレが発生しそう。どうすりゃええんや…。

ていうのと、本当はself.test_event = event.add_event(self, "test_event")じゃなく、event.add_event(self, "test_event")だけでも動くんだけど、Pylint が文句言うんだよなぁ。冗長だけど、こうしないと…。

案 3) __setattr__()をオーバーライドして何とかする

EventSource というイベントを発生する派生クラス群の基底クラスを定義し、__setattr__()をオーバーライドすることで魔法をかける。

event.py (追加)
class EventSource:
    """Provides a mechanism for defining events in a class.

    An event is a signal that something has happened, and any interested object can be notified of the event.

    Note:
        This implementation uses overriding the `__setattr__` method to detect the\
        creation of event objects and turn them into properties with getters and\
        setters that enforce the event contract.
    """

    def __setattr__(self, name: str, value: Any) -> None:
        if not isinstance(value, Event):
            super().__setattr__(name, value)
            return

        event_attr_name = "_" + name

        if (
            hasattr(self, event_attr_name)
            and not value is getattr(self, event_attr_name)
        ):
            raise ValueError("New value must be the same as the current value.")

        def getter(obj):
            return getattr(obj, event_attr_name)

        def setter(obj, new_value):
            if not new_value is getattr(obj, event_attr_name):
                raise ValueError("New value must be the same as the current value.")

        setattr(self.__class__, name, property(getter, setter))

        super().__setattr__(event_attr_name, value)
再々々度、使ってみる
from . import event

class EventTestClass(event.EventSource):
    def __init__(self) -> None:
        self.test_event = event.Event()

    def test_event_happen(self) -> None:
        self.test_event(self, msg="test event has happened.")


def test_handler(sender: object, msg: str) -> None:
    print(msg)


if __name__ == '__main__':
    obj = EventTestClass()
    obj.test_event += my_handler
    obj.test_event_happen()

ちゃんと動くし、一見素直。ある程度問題は解決されてるんだけど、よく考えた時の「どう動いてるかわからん」感は、デコレーターの時よりひどい気がする。

どの方法がいいのでしょうか。

もしくは、もっといい方法があるのでしょうか。コメント待ってます。

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?