🎉

【型好きなあなたへ】Python-Type-Challengesのすゝめ

2023/12/25に公開

はじめに

先日、こんな素敵な記事を目にしました
https://zenn.dev/kakekakemiya/articles/2d7a3384a5faf0

さすがTypeScriptさん。型定義の自由度が非常に高く、色んな表現が出来ます。
この問題一通りやれば、TypeScriptの型定義もマスター出来るんですかね。

ところで、実は型なし言語のPythonにも型定義が存在します。
https://docs.python.org/3/library/typing.html

TypeScriptやJavaのように、コンパイルエラーを発生させるような強制力はないのですが、VSCodeを始めとしたエディタと協力することで、十分素敵な開発体験を送れます。

def foo(x: int):
    pass
# VSCodeの拡張機能Pylanceを使用した場合、以下コードで赤波線が引かれます
foo("1")

ちなみに、mypyというライブラリを使用すれば、コマンドライン上で型チェックを実現することが出来ますので、皆様のCI環境に組み込むことで、型付言語とほとんど同様の強制力をもたせることができます

さて、前置きが長くなりましたが、冒頭で紹介したTypeScriptのtype-challengesには、Python版があります。
https://github.com/laike9m/Python-Type-Challenges

今回は、この問題をすべて解いてみたので、実践的に役立つもの(+個人的に好きな問題)をいくつか紹介してみたいと思います。

TypeChallengesの問題紹介

Basic

Basic問題は基本的にすべて活用するので割愛します!
Basic問題を網羅すれば大抵のTypehintには困らないのでぜひ全問解いてみてください。

Intermediate

generic

"""
TODO:

The function `add` accepts two arguments and returns a value, they all have the same type.
"""


def add(a, b):
    return a

################################以下テストコード##################################
from typing import List, assert_type

assert_type(add(1, 2), int)
assert_type(add("1", "2"), str)
assert_type(add(["1"], ["2"]), List[str])
assert_type(add(1, "2"), int)  # expect-type-error
解答例
def add[T](a: T, b: T):
    return a

まずはgenericについての問題です。
Java等の型付言語だと当たり前の様に使用すると思いますが、つい最近まではPythonでの実装は非常にイケてない実装になっていたのです・・・。

解答例(Python3.11以前)
from typing import TypeVar

T = TypeVar("T")


def add(a: T, b: T) -> T:
    return a

最新のPython3.12より大きく変わったので是非覚えてほしいです!

literalstring

"""
TODO:

You're writing a web backend.
Annotate a function `execute_query` which runs SQL, but also can prevent SQL injection attacks.
"""

from typing import Iterable


def execute_query(sql, parameters: Iterable[str] = ...):
    """No need to implement it"""

################################以下テストコード##################################
def query_user(user_id: str):
    query = f"SELECT * FROM data WHERE user_id = {user_id}"
    execute_query(query)  # expect-type-error


def query_data(user_id: str, limit: bool) -> None:
    query = """
        SELECT
            user.name,
            user.age
        FROM data
        WHERE user_id = ?
    """

    if limit:
        query += " LIMIT 1"

    execute_query(query, (user_id,))

解答例
from typing import LiteralString, Iterable

def execute_query(sql: LiteralString, parameters: Iterable[str] = ...):
    ...

続いてはPython3.11から追加されたliteralstringです。
元々literal型という、予め指定した定数のみを受け付ける型を定義することが出来たのですが、新たに「任意の文字列定数の型」を定義することが出来るようになりました。
これによって、変数が絡む文字列を型レベルで弾くことが出来ますので、テストコードの様にSQLインジェクションの可能性のある脆弱コードを未然に防ぐことが出来ます。

typed-dict2

"""
TODO:

Define a class `Student` that represents a dictionary with three keys:
- name, a string
- age, an integer
- school, a string

Note: school can be optional
"""

################################以下テストコード##################################
a: Student = {"name": "Tom", "age": 15}
a: Student = {"name": "Tom", "age": 15, "school": "Hogwarts"}
a: Student = {"name": 1, "age": 15, "school": "Hogwarts"}  # expect-type-error
a: Student = {(1,): "Tom", "age": 2, "school": "Hogwarts"}  # expect-type-error
a: Student = {"name": "Tom", "age": "2", "school": "Hogwarts"}  # expect-type-error
a: Student = {"z": "Tom", "age": 2}  # expect-type-error
assert Student(name="Tom", age=15) == dict(name="Tom", age=15)
assert Student(name="Tom", age=15, school="Hogwarts") == dict(
    name="Tom", age=15, school="Hogwarts"
)
解答例
from typing import TypedDict, NotRequired

class Student(TypedDict):
    name: str
    age: int
    school: NotRequired[str]

Python文化としては、よく辞書型(Dict)が使われます。他言語でも外部I/Oが走る際には、そこで受け取るオブジェクトが辞書型であることが多いですが、Pythonではそれ以降の処理もそのままDictで実装されることが多いです。
Dictは使い易い上、キーによるhash化が行われるため高速なインデックスアクセスが実現出来るなど、メリットも多いのですが、以下のようなデメリットもあります。

  1. その辞書型オブジェクトの内部構造がわからない
    • どんなKeyが存在するか不明
    • Value(厳密にはKeyも)の型が不明
  2. ビジネスロジックは組み込めない
    • Dictオブジェクトに対して何かしらのロジックを組み込みたい場合は、ラッパーオブジェクトが必要

2つ目の理由があるため、基本的にはDictではなく自身で定義するオブジェクトに入れ直すことを推奨しますが、そうは言っても現行コードが十分に大きくなってしまっていると、今更そんな改修は現実的ではなくなりますよね。
そんな時に便利なのがTypedDictです。TypedDictを継承したClassを定義することで、Dictに対して存在するKeyやValueの型を定義できます。TypedDictの型を付与してあげるだけで、少なくとも上記のデメリットの1番は解消されますね。
また、本問題の様に、RequiredやNotRequiredの付与も可能なため、比較的柔軟な型定義が出来ます。

Advanced

recursive

"""
TODO:

Define a `Tree` type. `Tree` is a dictionary, whose keys are string, values are also `Tree`.
"""

Tree = ...

################################以下テストコード##################################
def f(t: Tree):
    pass


f({})
f({"foo": {}})
f({"foo": {"bar": {}}})
f({"foo": {"bar": {"baz": {}}}})


f(1)  # expect-type-error
f({"foo": []})  # expect-type-error
f({"foo": {1: {}}})  # expect-type-error

解答例
Tree = dict[str, "Tree"]

Tree構造のような再帰的な構造体を定義する問題です。
Pythonではインタープリター言語である都合上、自分自身を戻り値や引数等の型に定義することが通常出来ませんので、そのような時の対応方法となりますので、覚えていることを推奨します。

typeguard

"""
TODO:

is_string is a function that takes an argument value of arbitrary type, and returns a boolean.
You should make is_string be able to narrow the type of the argument based on its return value:
when it's true, narrow value's type to str.
Basically, it should work like `isinstance(value, str)` from the perspective of a type checker.
"""
from typing import Any


def is_string(value: Any):
    ...

################################以下テストコード##################################

from typing import assert_type

a: str | None = "hello"
if is_string(a):
    assert_type(a, str)
else:
    assert_type(a, type(None))  # expect-type-error

解答例
from typing import Any, TypeGuard

def is_string(value: Any) -> TypeGuard[str]:
    ...

下記例のように、isinstance関数などを使用した場合、そのif文下では論理的に型がはっきりするのですが、カスタム関数ではそれを判断することが出来ません。

if isinstance(str_val, str):
    # 通る  
    str_val.join(literal_obj)

# カスタム関数
if is_string(str_val, str):
    # エラー  
    str_val.join(literal_obj)

そんな時に使用するのがTypeGuardです。
bool型の戻り値の型にTypeGuardを定義すれば、その関数がTrueであるブロックではTypeGuardのgenericで定義した型であると判断されます。

overload-generic

"""
TODO:

foo is a function that returns an interger when called with Foo[int], returns a string when called with Foo[str], otherwise returns a Foo[T].
"""
class Foo[T]:
    a: T


def foo(value: Foo):
    ...

################################以下テストコード##################################
foo(Foo[int]()).bit_length()
foo(Foo[str]()).upper()
foo(Foo[list]()).a.append(1)

foo(Foo[int]()).upper()  # expect-type-error
foo(Foo[str]()).bit_length()  # expect-type-error
foo(Foo[list]()).bit_length()  # expect-type-error

解答例
from typing import overload, Any

class Foo[T]:
    a: T

@overload
def foo(value: Foo[str])-> str:
    ...

@overload
def foo(value: Foo[int]) -> int:
    ...

@overload
def foo[T](value: Foo[T]) -> Foo[T]:
    ...

def foo(value: Foo) -> Any:
    ...

Pythonには本来、Overloadという概念がありません。Overloadをしたい場合は、対象の関数にデフォルト引数を定義することで、複数のインターフェースに対応します。
しかし、そのOverloadの振る舞いが、渡された引数によって戻り値の型が異なる仕様であった場合、その関数の使用者側からすると、戻り値の型が曖昧になってしまいます。
そこで、型定義のみを行うOverloadがPython3.11から登場しました。
ただし、あくまで実体はPythonが最後に読み込んだ関数のみなので、overloadデコレータを実装した関数に何かしら実装をしてもそこは動かないことに注意です。

Extreme

constructor

"""
TODO:

Define a decorator `constructor_parameter` that accepts the type of Foo.
and return a wrapper function with the same signature as the constructor of Foo,
and function decorated by `constructor_parameter` can be called with an instance of Foo.
"""


def constructor_parameter():
    ...

################################以下テストコード##################################
class Foo:
    a: int
    b: str

    def __init__(self, a: int, b: str) -> None:
        ...


@constructor_parameter(Foo)
def func_pass(foo: Foo) -> list[Foo]:
    ...


res = func_pass(1, "2")
res[0].a.bit_length()
res[0].b.upper()


@constructor_parameter(Foo)
def func_fail(foo: Foo) -> list[Any]:
    ...


func_fail("1", "2")  # expect-type-error
func_fail([1, 2, 3])  # expect-type-error

解答例
from typing import Callable

def constructor_parameter[T, U, **P](cls: Callable[P, T]) -> Callable[[Callable[[T], U]], Callable[P, U]]:
    ...

Extreme問題は中々難しいです。本記事では1問だけご紹介します。
constructor_parameterは、引数として受け取ったオブジェクトから、あるオブジェクトを受取る関数に対してデコレータとして使用し、引数を当該オブジェクトから、そのオブジェクトのコンストラクタの引数に変更するデコレータを作成する関数となります。
・・・文章で書くとかえって複雑かもしれませんね笑
これをしっかり整理出来ていればCallableとGenericは完璧ですね!

まとめ

今回は、PythonのTypeChallengesの紹介をしてみました。
TypeScriptのTypeChallengesと比較すると全体的に簡単かと思いますが、Python実装の実践的にはこの問題が全て解ければほとんど困ることはないと思いますので、是非チャレンジしてみてください!

Discussion