LoginSignup
13
6

More than 3 years have passed since last update.

[Python]Unionで不便なケースと、それを解決するジェネリックのTypeVarについて

Posted at

ここ数か月、Pythonで型付きの状態でお仕事やプライベートでのコーディングをしてみて、UnionやTypeVarなどのジェネリックのものなどの使い分けに少し慣れてきたので備忘録として軽く備忘録としてアウトプットしておきます。ジェネリックについてもある程度深堀りします。

使う環境

複数の型の引数と返却値を受け付ける関数でAnyを使う時の問題点

複数の型を受け付ける関数を作る場合、Any型を使うという選択肢があります。例えば以下のように引数の値を2倍するという関数を考えてみます。整数などを指定した場合には2倍された値、文字列などが指定されればその文字列を2回分繰り返した値が返却されます。

from typing import Any


def multiply_two(x: Any) -> Any:
    return x * 2

返却値部分で以下のように整数として型アノテーションしようが、文字列として型アノテーションしようがこれならエラーにはなりませんし、プログラムは正常に結果を返してくれます。

>>> multiplied_int: int = multiply_two(10)
>>> print(multiplied_int)
20
>>> multiplied_str: str = multiply_two('Hello!')
>>> print(multiplied_str)
Hello!Hello!

しかし、値によっては2倍できない型のものも存在します。例えば辞書などを指定してしまうと以下のようにランタイムエラーになってしまいます。

>>> multiplied_dict: dict = multiply_two({'x': 100})
>>> print(multiplied_dict)

TypeError: unsupported operand type(s) for *: 'dict' and 'int'

単体テストなどをきっちり書いておけばもちろん多くは防げる問題ではありますが、デプロイ前などに型チェックなどが走ってチェックがされているとより一層安心できます。Anyだとこの辺のチェックがうまくいきません。

複数の型を受け付けるUnion型がうまく使えるケースと不便に感じるケース

先ほどの関数で、引数の値をintもしくはstrのみに制限する形にしようと思います。まずは利用頻度の高いUnionを使っていってみます。

from typing import Any, Union


def multiply_two(x: Union[int, str]) -> Any:
    return x * 2

こうすると引数に整数や文字列を指定した場合は型チェックは通過しますが、辞書などを指定した際にはエラーにすることができます。

multiplied_dict: dict = multiply_two({'x': 100})
print(multiplied_dict)

image.png

しかし返却値に関してはAnyのままなので、例えば引数だけ整数などにして返却値は辞書として型アノテーションがしてあってもそのままチェックを通過してしまいます。

multiplied_dict: dict = multiply_two(10)

そのため返却値もUnionでintもしくはstrを返却するように調整してみます。

def multiply_two(x: Union[int, str]) -> Union[int, str]:
    return x * 2

これであれば返却値が辞書になっていたらエラーで弾いてくれます。

image.png

しかしながら、これではintやstr単体で返却値に対して型アノテーションをしている場合もエラーになります。

multiplied_int: int = multiply_two(10)
print(multiplied_int)

image.png

整数の値が返ってくる・・・と分かっている場合でも、関数の型アノテーション内容に準じてUnion[int, str]といったように返却値に型アノテーションをする必要があります。

以下のように返却値に型アノテーションを書いた場合にはエラーにはなりません。

multiplied_int: Union[int, str] = multiply_two(10)
print(multiplied_int)

一見良さそうに見えますし、実際これで特に問題にならないケースも多くあります。ただし、以下のように整数用のメソッドや属性値などにアクセスした際にエラーになってしまうという不便な点があります。Union[int, str]の指定なので、もし文字列が返却された場合にエラーになってしまうという型の判定なのでチェックとしては安全で正しい挙動になります。

multiplied_int.bit_length

image.png

isinstanceなどで型チェックしつつ分岐させてあげればエラー自体は出なくなります。

if isinstance(multiplied_int, int):
    multiplied_int.bit_length

ただし毎回分岐などさせるのは手間です。返却値のUnionのの型アノテーションもそうですが、記述が煩雑且つ複雑になってしまいます。

TypeVarによるジェネリック型で解決する

こういったケースではジェネリックという型を使うことで解決できます。ジェネリクス・テンプレートなどと色々な呼ばれ方をしますが、他のプログラミング言語だと一般的な機能ですね。

例えばRustのThe Rust Programming Languageとかでも入門本の時点で出てきます。

ただしPython界隈では型を使うケースが多くなってきたのがここ数年の話なので、私が読んだPython入門本にはもちろん出てきませんでしたしお恥ずかしながらここ数か月前まではPythonにもジェネリック型があることを把握していませんでした(Classic Computer Science Problems in Pythonの本を読んでいて知りました)。

また、利用に関してもPylance(Pyright)でのチェックが予期したジェネリックの挙動になってくれなかった?感じがあり(勘違いだったかもしれませんが、今は想定通りに動いています)、最近まで利用せずにいました。実際に使ってみると前述のUnionで不便だった点などが解決して快適です。

ジェネリック型を使うことで複数の型を受け付けつつ、引数と返却値で型を一致させたり・・・といった制御が可能になります。

使い方としては、まずはimportでTypeVarというクラスを読み込みます。

from typing import TypeVar

続いてそのクラスをインスタンス化します。慣習的にTypeのTとかそういった短い名前が使われることが多いです。第一引数には型の名前を指定します。特に理由がなければ変数名と同じ値を指定します。

T = TypeVar('T')

後は関数の引数や返却値に定義した変数(T)を指定していきます。引数と返却値が同じTの型なので、例えば引数に整数を指定すれば返却値も整数、文字列を指定すれば返却値も文字列という制約が付与されます。

def multiply_two(x: T) -> T:
    return x * 2

しかしこのままでは、TはAnyと同様に何の型でも受け付けるようになってしまいます。
型によっては乗算(*)の処理ができない型もあるため、この記述だと上記の関数はエラーになってしまいます。

image.png

そこで、乗算のインターフェイスがある型のみに制限する(今回はintとstr)ためには、TypeVarの第二引数以降に引数に型を指定していきます。複数の型を受け付ける場合には順番に各引数に指定してきます。

T = TypeVar('T', int, str)

これで返却値の型の指定がintやstr単体の指定でもエラーにならなくなりました。属性やメソッドへのアクセス時もisinstanceなどの分岐も不要になります。

image.png

引数に文字列、返却値に整数・・・といった記述を誤ってしてしまった場合でもエラーを検知してくれます。

multiplied_int: int = multiply_two('Hello!')

image.png

また、返却値側の型アノテーションを省略した場合も正しく値は整数だと認識してくれます。

multiplied_int = multiply_two(10)

image.png

関数側で返却値がAnyなどになっていると、返却値側の型アノテーションを省略しているとAnyとしか認識してくれません(入力補完などの面でAnyになっていると不便ですしチェックも甘くなります)。この辺りの面でもAnyを指定するよりも開発体験が良くなります。

def multiply_two(x: Any) -> Any:
    return x * 2


multiplied_int = multiply_two(10)

image.png

クラス内でもジェネリックを利用する

ジェネリックはクラスでも使うことができます。そのクラス内で特定の値の型を固定して使うことができます(各メソッドや属性をまたぐ形で指定することができます)。

使い方としてはまずはGenericクラスをimportします。

from typing import Generic

続いてGenericを継承する形でそのクラスを作ります。[]の括弧内に利用したいジェネリックの変数(今回はT)を指定します。その後は各メソッドなどでジェネリックで指定しておきます。

class Value(Generic[T]):

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

    def multiply_two(self) -> T:
        return self.value * 2

インスタンス化などの際の型アノテーション時には[]の括弧と共にTに設定したい型を指定します。以下の例では[int]とすることで、このインスタンスではTの値はintで固定するといった定義になります。

int_value: Value[int] = Value(100)

試しにコンストラクタの引数に文字列を指定してみるとエラーになることが確認できます。

int_value: Value[int] = Value('Hello!')

image.png

これで、例えば整数と文字列で同じインターフェイスを持つクラスでそれぞれ個別にクラスを定義したりといった煩雑なことをせずに記述を統一することができます。

13
6
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
13
6