LoginSignup
5
4

【Python】ボイラープレートなメソッドを、呼び出し可能オブジェクトを返すディスクリプタで型安全にDRY化しよう

Last updated at Posted at 2024-03-12

はじめに

私が開発に参画しているプロダクトではpywinautoを使用して業務チームの作業効率化を行なっています。
そのプロダクトでは原則1つの画面コンポーネントを操作するオブジェクトを1つのクラスとして実装する規約があります。

そのため、操作対象の画面ごとにpywinauto.controls.uiawrapper.UIAWrapperを属性として持つコンポジションクラスを無数に定義しています。

そうすると、下記のように実装内容が共通しているメソッドがクラスに定義されていきます。

from dataclasses import dataclass

from pywinauto.controls.uiawrapper import UIAWrapper


@dataclass
class WindowA:
    """画面Aを操作する"""
    wrp: UIAWrapper

    def OpenWindowB(self) -> "WindowB":
        ...

    def Move(self, x: float, y: float) -> None:
        """ウィンドウを指定した絶対画面座標(左上側)に移動する"""
        self.wrp.iface_transform.Move(x, y)

    def Resize(self, width: float, height: float) -> None:
        """ウィンドウのサイズを変更する。
        `(0, 0)`などウィンドウが小さくできる物理的な下限より小さな値を指定した場合は、物理的な下限まで小さくする。
        """
        self.wrp.iface_transform.Resize(width, height)

@dataclass
class _Pane:
    """画面の子要素ペイン。
    トップレベルウィンドウではないため、「移動する」という機能を持てない
    """
    wrp: UIAWrapper


@dataclass
class WindowB:
    """画面Bを操作する
    
    業務上、ウィンドウを移動させる必要がない。
    """
    wrp: UIAWrapper

    def OpenWindowC(self) -> "WindowC":
        ...


@dataclass
class WindowC:
    """画面Cを操作する"""
    wrp: UIAWrapper

    def Move(self, x: float, y: float) -> None:
        """ウィンドウを指定した絶対画面座標(左上側)に移動する"""
        self.wrp.iface_transform.Move(x, y)

    def Resize(self, width: float, height: float) -> None:
        """ウィンドウのサイズを変更する。
        `(0, 0)`などウィンドウが小さくできる物理的な下限より小さな値を指定した場合は、物理的な下限まで小さくする。
        """
        self.wrp.iface_transform.Resize(width, height)

このようなコードは、開発者の性としてDRY化したくなります。
しかし、よくある共通化の手段には下記の問題があります。

継承の問題点

真っ先に思いつくのが、基底クラス側に共通で使うメソッドを定義して、継承してコードを再利用するという方法です。

...


@dataclass
class _Base:
    wrp: UIAWrapper

    def Move(self, x: float, y: float) -> None:
        """ウィンドウを指定した絶対画面座標(左上側)に移動する"""
        self.wrp.iface_transform.Move(x, y)

    def Resize(self, width: float, height: float) -> None:
        """ウィンドウのサイズを変更する。
        `(0, 0)`などウィンドウが小さくできる物理的な下限より小さな値を指定した場合は、物理的な下限まで小さくする。
        """
        self.wrp.iface_transform.Resize(width, height)


class WindowA(_Base):
    ...


...

しかし、継承は『ロバストPython』にもあるように、「上位型の代わりに部分型が使えるという関係をモデリングすること」 にしか使うべきではなく、コードの再利用は副次的な効果としてのみ利用した方が良いです。
この方法は、将来的に現状の実装では対応できなくなる事態が発生した時に、問題が発覚した派生クラスの実装内容を変更するのではなく、基底クラス側で対応してしまい、今までその基底クラスを使っていた他の派生クラスに影響してしまうことを招きかねない、継承のアンチパターンです。
かと言って基底クラス側の実装の詳細を変更せずにサブクラスの特化実装メソッドで基底クラス側にあるメソッドをオーバーライドしていくと、そのサブクラスと基底クラスはis-Aの関係を保てなくなるかもしれません
また、仮に基底クラスにMoveメソッドを定義すると、WindowB_Paneのように Moveメソッドを実装することが適切ではないオブジェクトにもMoveが実装されてしまい不適切です。

ミックスインの問題点

次に考えられるのが、単一メソッドのみを注入するミックスインクラスを定義し、注入先に継承させることです。

...


class _MoveMixin:
    def Move(self, x: float, y: float) -> None:
        """ウィンドウを指定した絶対画面座標(左上側)に移動する"""
        self.wrp.iface_transform.Move(x, y)


class _ResizeMixin:
    def Resize(self, width: float, height: float) -> None:
        """ウィンドウのサイズを変更する。
        `(0, 0)`などウィンドウが小さくできる物理的な下限より小さな値を指定した場合は、物理的な下限まで小さくする。
        """
        self.wrp.iface_transform.Resize(width, height)


@dataclass
class WindowA(_MoveMixin, _ResizeMixin):
    wrp: UIAWrapper


...

しかし、今回ミックスインさせて共通させたいMoveのようなメソッドは、ミックスイン先にwrp: UIAWrapperのような属性があることを前提とします。
ミックスイン先の属性に依存していることを静的型付けで表現する方法は現在のところ存在しません
ミックスインで定義したメソッド内部の実装は静的型安全でなく、モダンなスタイルでの動作担保ができない状態となります。

内部でユティリティ関数を呼び出す際の問題点

次に考えられるのが、ユティリティ関数を作りそれを内部で呼び出すという方法です。

def _Move(wrp: UIAWrapper, x: float, y: float) -> None:
    """ウィンドウを指定した絶対画面座標(左上側)に移動する"""
    wrp.iface_transform.Move(x, y)


def _Resize(wrp: UIAWrapper, width: float, height: float) -> None:
    """ウィンドウのサイズを変更する。
    `(0, 0)`などウィンドウが小さくできる物理的な下限より小さな値を指定した場合は、物理的な下限まで小さくする。
    """
    wrp.iface_transform.Resize(width, height)


@dataclass
class WindowA:
    wrp: UIAWrapper

    def Move(self, x: float, y: float) -> None:
        """ウィンドウを指定した絶対画面座標(左上側)に移動する"""
        _Move(self.wrp, x, y)

    def Resize(self, width: float, height: float) -> None:
        """ウィンドウのサイズを変更する。
        `(0, 0)`などウィンドウが小さくできる物理的な下限より小さな値を指定した場合は、物理的な下限まで小さくする。
        """
        _Resize(self.wrp, width, height)

しかしこれはメソッド内部の実装をまるまるもとにしたクラスからコピペすることを招き、調整すべき項目を忘れてバグが起こりやすいです。
また、そのユティリティ関数の呼び出し元となるメソッドにはdocstringを都度書かなければならず、内部の実装はDRY化できても結局行数はそれほど圧縮できず、かえって認知負荷を増やしてしまう かもしれません。

ディスクリプタを使うDRY化

ミックスインや継承のようなis-Aモデリングやユティリティ関数での共通化は難しいことがわかりました。

その次に考えたのが、ディスクリプタです。
あるオブジェクト(オーナーオブジェクト/オーナークラス/オーナーインスタンス)が属性としてディスクリプタを保持している場合、その名前を参照して属性を読み出す振る舞いはディスクリプタの__get__メソッドがオーバーライドします。

ディスクリプタについては下記記事が詳しいです。

https://qiita.com/koshigoe/items/848ddc0272b3cee92134

ディスクリプタは普通のオブジェクトだけでなく、コールバック関数やメソッド内部で定義された内部のクロージャ関数のような呼び出し可能オブジェクトを返すことも可能です。ディスクリプタが呼び出し可能オブジェクトを返すことでその属性をあたかもメソッドのように振る舞わせることができます
呼び出し可能オブジェクトを返すディスクリプタを実装することで、注入したいメソッドの実装と注入先のクラスの実装を出来るだけ疎結合にできます。

「オーナーオブジェクトがwrp: UIAWrapper属性を持っている必要がある」ような、そのディスクリプタを使用する場合オーナーにあるべきインターフェースを表現する(静的型制約をつける)にはProtocolを使うことができます
__get__メソッドの(selfを第一として)第ニ引数へProtocolをアノテートすることで、静的な型安全性を保つことができます。

また、ディスクリプタであれば、関数ではなくクラスなので、コンストラクタ引数でオーナークラス特有の設定を注入することもできるようになります。
これによって他の手段を使うよりもオーナーにある名前空間を汚さずに設定を注入できます。

型アノテーションを工夫すれば関数のシグネチャやdocstringなどの静的型情報も失われずに実装できそうでした。

そして、下記をコーディングしてみたところ、やりたいことを実現する機能を実装することができました。

実装

これ以降にある、IDEのスクリーンショットはVSCode+Pylance(Pyright)のものです。

from collections.abc import Callable
import functools
from typing import Protocol, TypeVar

from pywinauto.controls.uiawrapper import UIAWrapper


_W = TypeVar("_W", bound=UIAWrapper)
_R = TypeVar("_R")
_ThisT = TypeVar("_ThisT")
_InstT = TypeVar("_InstT")
_OwnerT = TypeVar("_OwnerT")


class _Component(Protocol[_W]):
    """UIAラッパー1つのみを保持する、もっとも単純な画面操作クラスを想定したプロトコル"""

    wrp: _W


def add_cls_attr_behavior_to_dunder_get(
    f: Callable[[_ThisT, _InstT, _OwnerT], _R]
) -> Callable[[_ThisT, _InstT, _OwnerT], _R]:
    """呼び出し可能オブジェクト(関数)を返すディスクリプタの`__get__`につける想定のデコレータ。

    ランタイムの振る舞いとしては、インスタンス属性として参照することしか想定していない実装がされた
    `__get__`に対して、クラス属性として参照したときディスクリプタインスタンスそのものを返す
    振る舞いを追加する。

    `__get__`の返り値への型付けは型チェッカーの推論に任せ、型ヒントをつけない。
    - `) -> Callable[..., Any]`や`) -> Callable[[str], int]`のように型付けしてしまうと、
      内部で定義した関数の引数名やdocstringなどの静的情報が参照先へ伝達しなくなってしまう。

    Note:
        ディスクリプタについては下記参照。
        https://docs.python.org/ja/3/howto/descriptor.html

    Examples:
        >>> # オーナーインスタンスの`a`属性の値と掛け算して返す振る舞いをする、単純なディスクリプタで動きを比較する
        >>> class UndecoratedDescriptor:
        ...     def __get__(self, instance: 'Foo', owner: 'type[Foo] | None' = None):
        ...         def f(b: int) -> int:
        ...             return instance.a * b
        ...         return f
        ...
        >>> class DecoratedDescriptor:
        ...     @add_cls_attr_behavior_to_dunder_get  # デコレートされる関数に返り値のヒント(`Callable[[int], int]`のような)は不要
        ...     def __get__(self, instance: 'Foo', owner: 'type[Foo] | None' = None):
        ...         def f(b: int) -> int:  # クロージャ(コールバック)の型ヒントが不十分だと型推論がうまく効かないので注意
        ...             return instance.a * b
        ...         return f
        ...
        >>> class Foo:
        ...     def __init__(self, a: int) -> None:
        ...         self.a = a
        ...     C = UndecoratedDescriptor()
        ...     D = DecoratedDescriptor()
        ...
        >>> #
        >>> # ===============================
        >>> #
        >>> # デコレートされていないディスクリプタ
        >>> Foo(4).C  # doctest: +ELLIPSIS
        <function UndecoratedDescriptor.__get__.<locals>.f at ...>
        >>> Foo(4).C(2)
        8
        >>> Foo.C  # ディスクリプタの`__get__`が返しているクロージャが返ることに注目  # doctest: +ELLIPSIS
        <function UndecoratedDescriptor.__get__.<locals>.f at ...>
        >>> #
        >>> # ===============================
        >>> #
        >>> # デコレートされているディスクリプタ
        >>> Foo(3).D  # doctest: +ELLIPSIS
        <function DecoratedDescriptor.__get__.<locals>.f at ...>
        >>> Foo(3).D(3)
        9
        >>> Foo.D  # ディスクリプタインスタンスが返ることに注目  # doctest: +ELLIPSIS
        <descriptor.DecoratedDescriptor object at ...>
    """

    @functools.wraps(f)
    def wrapper(self, instance, owner):
        if instance is None:
            return self
        return f(self, instance, owner)

    return wrapper


class MoveMethod:
    """ウィンドウを指定した絶対画面座標(左上側)に移動するメソッドを追加するディスクリプタ"""

    @add_cls_attr_behavior_to_dunder_get
    def __get__(self, instance: _Component[UIAWrapper], owner: type[_Component[UIAWrapper]] | None = None, /):
        def func(x: float, y: float, /) -> None:
            """ウィンドウを指定した絶対画面座標(左上側)に移動する

            Args:
                x (float): 垂直方向の座標
                y (float): 水平方向の座標

            Note:
                UIAのCOMメソッドを間接的に呼び出している。
                仕様は下記参照のこと
                https://learn.microsoft.com/ja-jp/windows/win32/api/uiautomationclient/nf-uiautomationclient-iuiautomationtransformpattern-move
            """
            instance.wrp.iface_transform.Move(x, y)

        return func


class FindChildrenMethod:
    """指定したコントロール名を持つ子世代の画面要素を返すメソッドを追加するディスクリプタ"""

    def __init__(self, control_name: str) -> None:  # コンストラクタでクラス特有の設定を注入できる
        self.control_name = control_name

    @add_cls_attr_behavior_to_dunder_get
    def __get__(self, instance: _Component[UIAWrapper], owner: type[_Component[UIAWrapper]] | None = None, /):
        def func() -> list[UIAWrapper]:
            """ウィンドウの子世代の画面要素を返す

            Returns:
                list[UIAWrapper]: 該当する子世代の画面要素
            """
            return instance.wrp.children(control_name=self.control_name)

        return func


if __name__ == "__main__":
    class Sample:
        wrp: UIAWrapper

        def __init__(self, wrp) -> None:
            self.wrp = wrp

        Move = MoveMethod()
        GetChildren = FindChildrenMethod("Button")

    wrp = ...
    sample = Sample(wrp)
    sample.Move(1, 2)
    children = sample.GetChildren()

本来__get__メソッドは、ディスクリプタがインスタンス属性として参照されるだけではなく、クラス属性として参照される想定をした実装にしなければなりません。
しかし、その分岐をつけてoverloadなどの型ヒントもつけてしまうと、返される呼び出し可能オブジェクトの静的型情報の一部が失われ、かつ冗長になってしまいます。
また、ディスクリプタをクラス属性として参照することはメタプログラミングの場面以外ではほとんどありません。
実装や型ヒントを簡略化し、できるだけ型チェッカーによる型推論を活用するため、add_cls_attr_behavior_to_dunder_getのようなデコレータを実装し、新しいディスクリプタを実装する開発者はインスタンス属性としての振る舞いのみを__get__に実装すればいいようにしています。

MoveMethodのようなディスクリプタは呼び出し可能オブジェクトを返すため、あたかもメソッドのように機能します。
そしてディスクリプタが返す関数のシグネチャやdocstringもツールチップや型チェッカーに反映されているため、型情報を握りつぶすこともオーナークラスの属性にdocstringをつける必要もなくなっています。
image.png
image.png

__get__メソッドのコードベースについても、instance引数にアノテートされたプロトコルによって、型安全に機能を実装することができています。

image.png

image.png

そして、将来このディスクリプタが提供する呼び出し可能オブジェクトで対応できないケースに出くわしたら、そのクラスではディスクリプタを使わずに、特化実装したメソッドを定義すれば良いのです

現状で対応できないケースが発生した時、ディスクリプタ側の分岐を増やしていくことは、コードベースが複雑化し開発者にとって認知負荷が高くなっていく可能性があるため悪手です。

ディスクリプタを使わず、特化実装となっても、継承とは違い基底クラスのメソッドをオーバーライドするわけではないので、基底クラスの実装の詳細(名前の競合問題など)やメソッド解決順(MRO)を気にする必要はありません。
また、特化実装となることで、「このクラスには実装を共通化できない特殊な事情がある」 ということを将来のメンテナーに伝えることもできます。

「なぜ共通化できなかったか」のコメントを特化実装メソッド内に残せば、さらに意図は伝わりやすくなります。

ディスクリプタとオーナーが持つ依存関係について

「ディスクリプタが特定のプロトコル(オーナー)に依存するのはどうかと思う」かもしれません。
しかし、最も一般的なディスクリプタであるpropertyでさえ、ほとんどの場合オーナーインスタンスから取得できるものに基づいた値を返す実装をしている、つまりディスクリプタがオーナーとデータのやり取りをしています。

# オーナーが持つデータを`property`が参照する最も簡単な例
class Foo:
    def __init__(self, a):
        self._a = a

    @property
    def a(self):
        self._a  # オーナーインスタンスにアクセスし、データを返している


# オーナーが持つデータをカスタムディスクリプタが参照する最も簡単な例
class UnderAValueDescriptor:
    """オーナーインスタンスの`_a`属性の値を返すディスクリプタ

    おそらくここまで単純なプロパティだとわざわざディスクリプタを定義してDRY化する必要はない。
    あくまでも説明用に用意した。
    """
    def __get__(self, instance, owner=None):
        if instance is None:
            return self
        return instance._a  # オーナーインスタンスにアクセスし、データを返している


class Bar:
    def __init__(self, a):
        self._a = a

    # `Foo.a`プロパティとほとんどやることは同じ。データディスクリプタではないなどの違いはある
    a = UnderAValueDescriptor()

Djangoのような有名ライブラリにもオーナーインスタンスの名前空間を参照する___get__メソッドが定義されているディスクリプタがあります。
またDjangoモデルの属性として定義されているフィールドは(クラスに直接__get__が定義されている限りではなくメタプログラミングによって間接的に定義されているケースもあるためわかりにくいですが)ディスクリプタでもあります。
なので、「ディスクリプタが特定のプロトコル(オーナー)に依存する」設計思想自体に問題があるとは考えていません。

この手法が有効である場合とそうでないケース

ディスクリプタによる呼び出し可能オブジェクトの注入は各オブジェクトの依存関係を小さくして、変更容易性を高めています。
ならばどのプロダクトのコードベースにもこれを導入して、これまでの継承やミックスインは廃止していくべきなのかというと、私はそう思いません。
この手法が有効なのは、コンポーネント同士の共通部分が見出せないかできたとしても「〇〇という属性がある」程度の小さすぎるものであるため、抽象が定義しにくいドメインに限られると考えています。

抽象が見出せるものに対して、変更容易性があるからと言ってディスクリプタでの注入を選ぶと、クラス定義のたびに毎回ディスクリプタをアサインする属性を定義しなければならず 、かえって煩雑になります。
抽象化すべきものがされていないことによって、開発者が機能の概要を見出せず認知負荷が上がる かもしれません。
共通化するケースが少ないにもかかわらずディスクリプタが実装されてしまい数が膨大になり 、開発者が既存のディスクリプタからどれを選んで使ったらいいかわからなくなるかもしれません。

開発者は各オブジェクトが持つ責任を軽くしていくような設計を常に心がけるべきですが、改修容易性が下がることを恐れるあまりにデータの集合にあるべき処理が付いていないという「責任逃れ」はやめるべきです。

下記のような場合に、継承の代替としてディスクリプタが有効だと考えています。

  • コードの共通化のために〇〇メソッドを定義した基底クラスを継承している(したい)が、基底クラスやそのサブクラス同士でis-A関係が築けているとは誤解して欲しくない
  • このドメインを扱うクラスはたいてい共通して〇〇メソッドを持つ(はずだ)が、一部にそれが適切ではない(らしい)ものがある
  • あるクラスは特殊な事情で基底クラスの〇〇メソッドを使用できない場合があるが、基底クラスにある別の□□メソッドは使えるので、〇〇メソッドのみ別実装にオーバーライドして使用したい

付録

ボツ案

上記のコードでは、クラス属性としてアクセスしたときに、うまく型推論が働きません。
image.png

下記のように、Callableを使う代わりに、__get__overload付きCallable Protocolにすれば型推論が可能かもしれないと思って試してみました。
しかし現時点では型推論してくれず、さらにインスタンス属性としてアクセスした際にも型推論が効かなくなってしまったのでボツにしました。

from collections.abc import Callable
import functools
from typing import Any, overload, Protocol, Self, TYPE_CHECKING

if TYPE_CHECKING:
    from typing_extensions import TypeVar
else:
    from typing import TypeVar


_W = TypeVar("_W", bound=UIAWrapper)

if TYPE_CHECKING:
    _R = TypeVar("_R", infer_variance=True)
    _ThisT = TypeVar("_ThisT", infer_variance=True)
    _InstT = TypeVar("_InstT", infer_variance=True)
else:
    _R = TypeVar("_R")
    _ThisT = TypeVar("_ThisT")
    _InstT = TypeVar("_InstT")

class _DunderGetAsInstAttrOnly(Protocol[_ThisT, _InstT, _R]):
    def __call__(self, this: _ThisT, instance: _InstT, owner: None | type[_InstT] = None, /) -> _R:
        ...


class _DunderGet(Protocol[_ThisT, _InstT, _R]):
    @overload
    def __call__(self, this: _ThisT, instance: None, owner: None | type[Any] = None, /) -> _ThisT:
        ...
    @overload
    def __call__(self, this: _ThisT, instance: _InstT, owner: None | type[_InstT] = None, /) -> _R:
        ...
    def __call__(self, this, instance, owner=None) -> Any:
        ...


def add_cls_attr_behavior_to_dunder_get(f: _DunderGetAsInstAttrOnly[_ThisT, _InstT, _R]) -> _DunderGet[_ThisT, _InstT, _R]:
    @functools.wraps(f)
    def wrapper(self, instance, owner=None):
        if instance is None:
            return self
        return f(self, instance, owner)

    return wrapper  # type: ignore

型システムになかった仕組み

オーナーがディスクリプタの__set_name__で型付けされたプロトコルと不整合を起こしていた場合、型チェッカーエラーにする仕組みは現在のところ存在していません。
これについてはPython discussionsにスレッドを立てて、Pythonコミュニティの意見をうかがっています。

関連情報

5
4
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
5
4