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?

お題は不問!Qiita Engineer Festa 2024で記事投稿!
Qiita Engineer Festa20242024年7月17日まで開催中!

Pythonってジェネリクス必要なの?→静的型解析するなら使ったほうがいい

Last updated at Posted at 2024-07-16

はじめに

最近デコレータを使ってフレームワークを作ってみる趣旨の記事を書いたのだが、Pylanceを利用して静的型チェックをするとうまく型推論できないことに気づいた。

import sqlite3
from typing import Callable, Any

DB_PATH = 'test.db'


def db_connect(auto_commit: bool = False):
    # デコレータ
    def decorator(func: Callable[[sqlite3.Connection, sqlite3.Cursor], Any]):
        # ラッパー
        def wrapper(*args, **kwargs):
            connection = sqlite3.Connection(DB_PATH)
            cursor = connection.cursor()
            try:
                print('open')
                result = func(connection, cursor, *args, **kwargs)
            except Exception as e:
                connection.rollback()
                raise e
            else:
                # デコレータ生成時のauto_commitによってコミットするか決定する
                if auto_commit:
                    connection.commit()
                return result
            finally:
                print('close')
                connection.close()

        return wrapper
    return decorator


@db_connect(auto_commit=True)
def create_table(connection: sqlite3.Connection, cursor: sqlite3.Cursor):
    cursor.execute(
        'CREATE TABLE test_table (id INTEGER PRIMARY KEY, name TEXT)')


create_table() # ここが型推論できない状態になる。

以下のようにcreate_tableには引数が必須であることについて怒られている。

image.png

これを解決する方法こそジェネリクスだった。

そもそもジェネリクスとは?

ジェネリクスとは主に静的型付け言語で必要になる概念で、静的型付け言語では任意のデータ型を返却させるだとか、任意のデータ型を戻り値にするだとかといった関数を書く場合に利用することを前提とした概念だ。

有名な例だとJavaやC#のコレクションの型を決定するはジェネリクスだ。

// <> の中に任意の型を入れる事で任意の配列(List)が生成できる。
List<String> hogeList = new ArrayList<>();
// 受け取った型を確認し、そのまま返却する関数(型は何でもよい)
T Hoge<T>(T fuga)
{
    var typeOfFuga = typeof(T);
    Console.WriteLine($"fugaのタイプは {typeOfFuga}");
    return fuga;    
}

これらはジェネリクスがない場合は全ての型を列挙し実装していかなければならないので、非常に便利だ。

今回はPythonなのでそもそもAny、つまり「何が返ってくるかわからない。」という状態から、「どの型が返ってくるか?」がわかるようになるので毎回どんな型が返ってきてるかをデバッグコンソールなどで確かめる手間が減り見通しの良いコードとなる。

このコードの問題点

Pylanceの解析ではこのcreate_table関数を呼び出した際、デコレータでラッピングしたwrapper関数を呼ぶのではなく、create_tableを呼び出している状態となっている。

そのため、明示的にこれはラップされた関数であることを示すためにfunctoolswraps関数デコレータを利用する。

def db_connect(auto_commit: bool = False):
    # デコレータ
    def decorator(func: Callable[[sqlite3.Connection, sqlite3.Cursor], Any]):
        # ラッパー
        @functools.wraps(func) # このデコレータを追加
        def wrapper(*args, **kwargs):
            ... # 省略

それによりにCallable(..., Any)の型アノテーションがついた状態と同一の関数として扱われるようになる。
(前半の(Connection, Cursor), Anyまでがデコレートされる前の関数の型で、後半のCallable(..., Any)が今現在の関数の型)

image.png

これでも利用は可能だが、静的型言語出身の私にとっては結構気持ち悪い感じになっている。

なんせ、引数はゼロなのに何かが入る余地がありそうだし、返却される型はNoneなのにAnyとなにかを返す余地がありそうになっている。

@functools.wraps(func)デコレータは引数となる関数の属性を引き継ぐ事ができます。

from functools import wraps

def my_decorator(f):
    @wraps(f)
    def wrapper(*args, **kwargs):
        print("Before the function call")
        result = f(*args, **kwargs)
        print("After the function call")
        return result
    return wrapper

@my_decorator
def say_hello():
    """This function says hello"""
    print("Hello!")

say_hello()

print(say_hello.__name__)  # 出力: say_hello
print(say_hello.__doc__)  # 出力: This function says hello

ジェネリクスの実装

Python3.12から登場した構文らしいが、良い感じにジェネリクスを定義できるようになっている。

今までのジェネリクス

typingモジュールのTypeVarを利用する必要があり、他の言語と違いひと手間あり面倒な感じであった。

from typing import TypeVar

T = TypeVar('T')

# 引数の型で返却する。
def hoge(obj: T) -> T:
    return obj

上記の構文の通りにすることで、Pylanceは以下のようにint返却されることを推論できている。

image.png

3.12からのジェネリクス

他の言語のジェネリクスのように関数名の前に[T]のように明記することでジェネリクスを実装することが可能になった。

# 引数の型で返却する。
def hoge[T](obj: T) -> T:
    return obj

こちらでも同様の型推論が行われる。

image.png

DB接続でのジェネリクスを実装

先ほどのコードではCallableの戻り値がAnyとなってしまっていたことが原因なので、Callable[[sqlite3.Connection, sqlite3.Cursor], T]としてやることでラップ対象の関数と同じ戻り値が定義できる。

def db_connect(auto_commit: bool = False):
    # 受け取った関数の戻り値がラッパーの戻り値になるようにジェネリクスを指定
    def decorator[T](func: Callable[[sqlite3.Connection, sqlite3.Cursor], T]):
        # ラッパー
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            ... # 省略

このようにジェネリクスを指定することで明示的に何が返ってくるか?がわかるようになる。

image.png

しかし、他のソースでは追加の引数を用意して、その引数のみで呼び出すような使われ方もしていたはずだ。

可変長なパラメータを許容するために必要なジェネリクス

追加の引数が必要なコードを確認する。

image.png

Pylanceで指摘されているのはuser引数がデコレータの引数として存在しないからだ。

def decorator[T](
    func: Callable[
        # このアノテーションがConnectionとCursor しか存在しないため。
        [sqlite3.Connection, sqlite3.Cursor],
        T
    ]):

そのため、既定のConnection, Cursor以外に任意のパラメーターを許容できる必要がある。
それを実現するためにtypingモジュールのConcatenateParamSpecを利用する。

ParamSpecに関して言えば先程のジェネリクスの話と同様にPython3.12からimportしなくても利用できるようになっている

今までのジェネリクス

ParamSpecTypeVar同様省略可能になっており、3.12より前は以下のような構文だった。

import functools
from typing import ParamSpec, Callable

# パラメーターの型を定義
P = ParamSpec('P')


def hoge(func: Callable[P, int]):
    @functools.wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> int:
        print('hoge')
        return func(*args, **kwargs)
    return wrapper


@hoge
# ここでは任意の型の引数を受け取る関数を定義している
def fuga(a: int, b: int) -> int:
    return a + b


print(fuga(1, 2))

これでPylanceはfuga型の引数をそのままwrapper関数で利用されるよう認識してくれる。

image.png

3.12からのジェネリクス

ParamSpecの場合は**Pをつける必要があるが、これもほかの言語のように関数名と()の間にジェネリクスを記述するだけで記述可能となっている。

import functools
from typing import Callable


def hoge[**P](func: Callable[P, int]):
    @functools.wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> int:
        print('hoge')
        return func(*args, **kwargs)
    return wrapper

DB接続でのジェネリクスを実装

typingモジュールのConcatenateをimportし、ParamSpec以外の引数はラッパーが直接渡す事を明記するようにする。

from typing import Concatenate


def db_connect(auto_commit: bool = False):
    def decorator[T, **P](func: Callable[
            # Concatenateで既定引数と任意引数を結合する
            Concatenate[sqlite3.Connection, sqlite3.Cursor, P],
            T]):
        # ラッパー
        @functools.wraps(func)
        # ラッパーの引数の型をP.args, P.kwargsにすることで、任意の引数を受け取れるようにする
        def wrapper(*args: P.args, **kwargs: P.kwargs):
            ... # 省略

こうすることでPylanceは正しく型推論を行う事ができるのでcreate_user関数ではUserの型のみを受け取ることが可能だ。

image.png

さいごに

前回書いた記事にこの情報も突っ込むべきかと思ったが、思った以上に書くことが多かったため、別の記事とした。

なお、同じOrganizationのメンバーのこの記事を見て「そういえばTypingモジュールあたり読み直すかー」となってなければ補足としてこの記事を書くことはなかった気がするので宣伝しておこうと思う。

参考

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?