0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AliasPropertyで値を特定の範囲に縛る仕組みを実装してみて気付いたこと

Posted at

AliasPropetyを選んだ理由

次のように機能する物をPythonの通常のプロパティではなくKivyのプロパティで実装したかった。

vc = ValueClapmer(value=200, min=0, max=100)
print(vc.value)  # => 100
vc.value = -100
print(vc.value)  # => 0
vc.min = 50
print(vc.value)  # => 50
vc.value = 200
print(vc.value)  # => 100
vc.max = 70
print(vc.value)  # => 70

KivyにはBoundedNumericPropertyという正に今欲しい物のように聞こえる機能がありますがこれだと

  • 閾値自体はKivyのプロパティではないので監視できない。
  • 閾値を越えた時に例外をあげてしまう。
  • 閾値自体に変化が起きた時には値が閾値を越えているかの確認をしない。

という点が不都合だったのでAliasPropertyを選びました。

セッター内で縛ってみる

値を特定の範囲に縛る事をこれから正規化と呼ぶことにします。この正規化をどのタイミングでするかなんですが、普通に考えるとmaxminvalueに値が書き込まれたときじゃないでしょうか?なので私がこのValueClamperをPythonのプロパティで実装するとしたら以下のようになります。

# minとmaxの関係が min <= max である事を確かめる処理が抜けていますが目を瞑って下さい。
class ValueClapmer:
    def __init__(self, *, value=0, min=0, max=0):
        self._value = value
        self._min = min
        self._max = max
        self._clamp()

    def _clamp(self):
        self._value = max(self._min, min(self._max, self._value))

    @property
    def value(self):
        return self._value

    @value.setter
    def value(self, v):
        self._value = v
        self._clamp()

    @property
    def min(self):
        return self._min

    @min.setter
    def min(self, v):
        self._min = v
        self._clamp()

    @property
    def max(self):
        return self._max

    @max.setter
    def max(self, v):
        self._max = v
        self._clamp()

このように各セッターが呼ばれた時に正規化(_clamp)し、内部変数_valueには常に正規化後の値をしまうというのが最初に思い浮かぶ実装です。そしてこれに倣ってKivy版を実装するとこうなります。

from kivy.event import EventDispatcher
from kivy.properties import NumericProperty, AliasProperty


class ValueClapmer(EventDispatcher):
    min = NumericProperty()
    max = NumericProperty()

    def _get_value(self):
        return self._value

    def _set_value(self, value):
        value = max(self.min, min(self.max, value))
        if self._value != value:
            self._value = value
            return True
 
    value = AliasProperty(_get_value, _set_value, bind=("min", "max"))

    def __init__(self, **kwargs):
        self._value = 0
        super().__init__(**kwargs)

しかし実際に使ってみると

vc = ValueClapmer(min=0, max=100)
vc.bind(value=lambda __, value: print("value:", value))
vc.value = 200
vc.value = -100
vc.min = 50
print(vc.value)
value: 100  # 期待通り
value: 0    # 期待通り
value: 0    # ??? (期待していたのは50)
0           # ??? (期待していたのは50)

見ての通り下限値を50に引き上げたのにvalueは0のままでした。そしておかしいのはそれだけではありません。上のコードではKivyプロパティの通知の仕組みを利用してprintしているわけですが、0から0、つまり変化が無いにも拘らず通知が来ています。

Kivyのプロパティは値に変化が起きた時に通知してくれる仕組みを持っています。詳しくは以下の記事を。

ゲッター内で縛ってみる

上での試みがうまくいかなかったのはvalueminmaxのいずれに書き込んでも_set_value()が呼び出されると思い込んでいたからでした。実際にはvalueに書き込んだ時のみであり、セッターは正規化には不適切(あるいは不十分)な場所であると言えます。

セッターが駄目ならゲッターしかありません。

class ValueClapmer(EventDispatcher):
    min = NumericProperty()
    max = NumericProperty()

    def _get_value(self):
        self._value = v = max(self.min, min(self.max, self._value))
        return v

    def _set_value(self, value):
        self._value = value
        return True
 
    value = AliasProperty(_get_value, _set_value, bind=("min", "max"))

    def __init__(self, **kwargs):
        self._value = 0
        super().__init__(**kwargs)


vc = ValueClapmer(min=0, max=100)
vc.bind(value=lambda __, value: print("value:", value))
vc.value = 200
vc.value = -100
vc.value = -100
vc.min = 50
print(vc.value)
value: 100  # 期待通り
value: 0    # 期待通り
value: 0    # 過剰な通知
value: 50   # 期待通り
50          # 期待通り

するとvalueは期待通り正規化されましたが、過剰な通知が来ている点は変わりありません。加えて今度は別の問題も生じています。

ゲッターにprint("getter")を仕込んだ状態で実行
vc = ValueClapmer(min=0, max=100)
print("--------")
vc.value
vc.value
getter
getter
--------
getter
getter

正規化は本来maxminvalueに値が書き込まれた時のみ行えば十分なはずですが、それに加えてvalueを読み出す際にも行われてしまっています。

cache

幸い今出てきた無駄に正規化の処理が走る問題はcacheで解決できました。

抜粋
class ValueClapmer(EventDispatcher):
    value = AliasProperty(_get_value, _set_value, bind=("min", "max"), cache=True)
ゲッターにprint("getter")を仕込んだ状態で実行
vc = ValueClapmer(min=0, max=100)
print("--------")
vc.value
vc.value
getter
--------

このcacheは説明を読む限り、ゲッターが返した値を記憶しておいてゲッターが再び呼ばれたら記憶しておいた値と比較、結果異なっていれば通知を行うものだと思うのですが、実際に動かしてみるとセッターがTrueを返した場合は比較の結果に拘らず通知が発生してしまうようです。なので通知を正しく機能させるためにはセッター側でも正規化された値を算出し、それを元の値と比較する必要がありそうです。

最終的な実装

class ValueClapmer(EventDispatcher):
    min = NumericProperty()
    max = NumericProperty()

    def _clamp(self, value):
        return max(self.min, min(self.max, value))

    def _get_value(self):
        self._value = v = self._clamp(self._value)
        return v

    def _set_value(self, value):
        value = self._clamp(value)
        if self._value != value:
            self._value = value
            return True

    value = AliasProperty(_get_value, _set_value, bind=("min", "max"), cache=True)

    def __init__(self, **kwargs):
        self._value = 0
        super().__init__(**kwargs)

というわけでセッターとゲッターの両方で正規化された値を計算するという醜い実装となってしまいました。もしセッターのreturn Trueが通知を行うか否かを決めるものではなくゲッターを呼ぶか否かを決める物であったなら、通知を行うか否かは依然としてキャッシュされた値との比較で決まるためセッター内でその判断を下す必要はなく、もっと綺麗に実装できていたところです。

感想

とにかく分かりにくかった。もしかするとAliasPropertyは今回のような用途の為にあるのでは無いのかもしれない。

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?