🥳

Python/構造体から再帰的にインスタンス化するファクトリに関するメモ

2022/06/27に公開

はじめに

ここで対象としているのは Python 3.9 となります。TypeScript の経験があるため、それと比較しながら理解を進めています。Python を触り始めてまだ 2 週間も経っていないので、誤っているところや最適化されていない箇所があるかもしれません。初学者としての備忘録を兼ねているため、細かいところからメモをしています。

やりたいこと

再帰的な構造をもった Dict 型の値を、再帰的な構造を持ったユーザ定義のクラスとしてインスタンス化することを目標とします。

ユーザ定義のクラスは from models import xxx で import できるとします。
またユーザ定義クラスのメンバ変数は、以下のいずれかの型、ないしその Optional 型であるとします。

  • ユーザ定義クラス
  • プリミティブ型 ( Union[int, float, str, bool] )
  • プリミティブ型の List 型 ( List[Union[int, float, str, bool]] )

models
class Foo:
    def __init__(
            self,
            *,
            Bar: str,
    ):
        self.Bar = Bar

class Baz:
    def __init__(
            self,
            *,
            Foo: Foo,
            Qux: Optional[int] = None,
    ):
        self.Foo = Foo
        if Qux is not None:
            self.Qux = Qux
material = {
    "Baz": {
        "Foo": {
            "Bar": "bar",
        },
        "Qux": 100,
    },
}

created: Baz = DynamicFactory[Baz].create(material)

Python では PEP 8 で変数は snake_case で命名すべきとされており、
クラスのメンバ変数も同様であることが一般的ですが、ここでは便宜的に PascalCase で定義しています。

型に対する操作について

👉 Optional[T]T に変換する

■ TypeScript でいうと

やりたいことは typescript でいう NonNullable<T> のような操作です。
https://github.com/microsoft/TypeScript/blob/b24b6a1125321fb62d53dd544a6c59f03a6eac07/lib/lib.es5.d.ts#L1518-1521

■ 実装例

def non_nullable(arg: Optional[T]) -> T:
    arg_tuple = get_args(arg)

    if get_origin(arg) is Union and type(None) in arg_tuple:
        return arg_tuple[0]
    return arg

cf. https://stackoverflow.com/a/58841311

冗長に書けば引数は Union[T, Optional[T]] ですが T ⊂ Optional[T] なので Optional[T] のみで十分です。

typing.Union

https://docs.python.org/3/library/stdtypes.html#union-type

Union[T, None]T または None いずれかの型であることを表します。
( python 3.10 からは Union[T, None]T | None 書くこともできます )

typing.Optional

https://docs.python.org/3/library/typing.html#typing.Optional

ドキュメントにもあるように Optional[T]Union[T, None] と同じ評価をされます。

typing.get_args(tp), typing.get_origin(tp)

https://docs.python.org/3/library/typing.html#typing.get_args
https://docs.python.org/3/library/typing.html#typing.get_origin

get_origin(Union[T, None])Union を返し、
get_args(Union[T, None])Tuple[T, None] を返します。

上の実装例では get_origin(arg) is UnionTrue ならば、
get_args(arg)Tuple[T, None] であることは自明なため [0] を返しています。

typing.Tuple, typing.List

https://docs.python.org/ja/3/library/typing.html#typing.Tuple
https://docs.python.org/ja/3/library/typing.html#typing.List

Tuple は実装例には出てきませんが、本文中には登場しているため、ここで触れます。

Tuple[T] は T 型の要素が 1 つのいわば固定長で、
Tuple[T, K] は 0 番目の要素が T型, 1 番目の要素が K 型の固定長です。
Tuple[T, K, ...] 0 番目の要素が T型, 1 番目以降の要素が K 型の可変長です。

List[T] は T 型の要素からなる可変長となります。

test_list: List[str] = ['one', 'two', 'three']
test_tuple: Tuple[str, ...] = ('one', 'two', 'three')

👉 object[T]T に変換する

■ TypeScript でいうと

TypeScript ではできないみたいです。。。?
cf. https://stackoverflow.com/q/67114094

■ 実装例

def infer_class_generic(arg):
    arg_tuple = get_args(arg)

    if len(arg_tuple) == 1:
        return arg_tuple[0]
    raise ValueError

👉 Optional[List[T]]T に変換する

■ TypeScript でいうと

typescript でいうと以下のような操作です。

type InferListElement<T> = NonNullable<T> extends Array<infer K> ? K : never
type Example = InferListElement<Array<number> | null> // number

■ 実装例

def infer_list_element(arg: Optional[List[T]]) -> T:
    return infer_class_generic(non_nullable(arg))

cf. https://stackoverflow.com/a/58841311

non_nullable(arg) すれば arg が List[T] であり、
すなわち get_args(arg)Tuple[T] であることは自明なため infer_class_generic(...) をコールしています。

reflection のような操作について

👉 動的に、インスタンス化せずに、クラスの情報を取得する

importlib.import_module(name, package=None)

https://docs.python.org/3/library/importlib.html#importlib.import_module
モジュールを指定して動的に import できます。

getattr(object, name[, default])

https://docs.python.org/3/library/functions.html#getattr
import されたモジュールからクラスの情報を取得できます。

import importlib

imported_class = getattr(
    importlib.import_module("models"),
    "Baz"
)

上記は、下記のコードを動的に実行していると考えることができます。

from models import Baz

imported_class = Baz

なお同じ module に定義されているクラスの情報を、同様に取得するには globals() を使用する方法があります。
https://docs.python.org/3/library/functions.html#globals

imported_class = globals()["Baz"]

👉 クラスの情報から、指定したメソッドの指定した引数の型を取得する

■ 実装例

def inspect_method_param_type(target_method, key: str):
    parameters = inspect.signature(target_method).parameters
    return parameters.get(key).annotation

inspect

inspect.Signature(...) の引数にメソッドを渡せば、
インスタンス化された Signature クラスのメンバー変数 parameters から、
そのメソッドの引数に関する情報である Parameter クラスのコレクションが取得できます。

引数の名前をキーにしてコレクションから要素を取り出し、 Parameter.annotation からその引数の型アノテーションが取得できます。

import importlib
import inspect

imported_class = getattr(
    importlib.import_module("models"),
    "Baz"
)

inspected_parameters = inspect.signature(imported_class.__init__).parameters
annotated_type = inspected_parameters.get("Foo").annotation # Foo

object.__init__(self[, ...])

https://docs.python.org/3/reference/datamodel.html#object.__init__

実装例

import importlib
from typing import Any, Dict, Generic, List, Optional, TypeVar

from util.typings import (
    infer_class_generic,
    infer_list_element,
    inspect_method_param_type,
    non_nullable,
)

T = TypeVar("T")
K = TypeVar("K")


class DynamicFactory(Generic[T]):
    def create(self, convert_from: Dict[str, Any]) -> T:
        """
        Dict 型の convert_from を, models で定義された型として再帰的に instantiate する
        :param convert_from: 変換元となるデータ
        :return: instantiate された T
        """
        # T として指定されたクラスの名前を取得する
        generic_class_name = infer_class_generic(self.__orig_class__).__name__
        return self.__instantiate_recursively(
            # 名前空間 model から該当するクラスを import する
            getattr(importlib.import_module("models"), generic_class_name),
            convert_from,
        )

    def __instantiate_recursively(
        self, target_class: Optional[K], dict_from: Dict[str, Any]
    ) -> K:
        # Optional[K] 型だと後続の処理が面倒になるので、初めに K 型にしておく
        required_target_class = non_nullable(target_class)

        dict_to: Dict[str, Any] = {}
        for key, value in dict_from.items():

            # target_class における __init__ の引数のうち、key にマッチする名称の型を取得しておく
            target_type = inspect_method_param_type(required_target_class.__init__, key)

            if isinstance(value, list):
                # List[V] から V の型を取得しておく
                element_type = infer_list_element(target_type)
                # value が List 型であるときは各要素について再帰的に処理する
                carry: List[Any] = []
                for element in value:
                    carry.append(
                        self.__internal(
                            element_type,
                            element,
                        )
                    )

                dict_to[key] = carry
                continue

            dict_to[key] = self.__internal(target_type, value)
        return required_target_class(**dict_to)

    def __internal(self, target_class, value):
        if isinstance(value, dict):
            # value が Dict 型であるときは型が定義されているとして instantiate して返す
            return self.__instantiate_recursively(target_class, value)

        # List, Dict いずれの型でないならば primitive な値であるとして instantiate せず、そのまま返す
        return value

definition.__name__

https://docs.python.org/3/library/stdtypes.html#definition.__name__

__orig_class__

https://stackoverflow.com/a/60984681 を参考にしたのですが、公式ドキュメントには記載がなさそう。。。?

Discussion