はじめに
最近デコレータを使ってフレームワークを作ってみる趣旨の記事を書いたのだが、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には引数が必須であることについて怒られている。
これを解決する方法こそジェネリクスだった。
そもそもジェネリクスとは?
ジェネリクスとは主に静的型付け言語で必要になる概念で、静的型付け言語では任意のデータ型を返却させるだとか、任意のデータ型を戻り値にするだとかといった関数を書く場合に利用することを前提とした概念だ。
有名な例だと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
を呼び出している状態となっている。
そのため、明示的にこれはラップされた関数であることを示すためにfunctools
のwraps
関数デコレータを利用する。
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)
が今現在の関数の型)
これでも利用は可能だが、静的型言語出身の私にとっては結構気持ち悪い感じになっている。
なんせ、引数はゼロなのに何かが入る余地がありそうだし、返却される型は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返却されることを推論できている。
3.12からのジェネリクス
他の言語のジェネリクスのように関数名の前に[T]
のように明記することでジェネリクスを実装することが可能になった。
# 引数の型で返却する。
def hoge[T](obj: T) -> T:
return obj
こちらでも同様の型推論が行われる。
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):
... # 省略
このようにジェネリクスを指定することで明示的に何が返ってくるか?がわかるようになる。
しかし、他のソースでは追加の引数を用意して、その引数のみで呼び出すような使われ方もしていたはずだ。
可変長なパラメータを許容するために必要なジェネリクス
追加の引数が必要なコードを確認する。
Pylanceで指摘されているのはuser
引数がデコレータの引数として存在しないからだ。
def decorator[T](
func: Callable[
# このアノテーションがConnectionとCursor しか存在しないため。
[sqlite3.Connection, sqlite3.Cursor],
T
]):
そのため、既定のConnection
, Cursor
以外に任意のパラメーターを許容できる必要がある。
それを実現するためにtyping
モジュールのConcatenate
とParamSpec
を利用する。
ParamSpec
に関して言えば先程のジェネリクスの話と同様にPython3.12からimportしなくても利用できるようになっている
今までのジェネリクス
ParamSpec
もTypeVar
同様省略可能になっており、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関数で利用されるよう認識してくれる。
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
の型のみを受け取ることが可能だ。
さいごに
前回書いた記事にこの情報も突っ込むべきかと思ったが、思った以上に書くことが多かったため、別の記事とした。
なお、同じOrganizationのメンバーのこの記事を見て「そういえばTypingモジュールあたり読み直すかー」となってなければ補足としてこの記事を書くことはなかった気がするので宣伝しておこうと思う。
参考