Type Annotationsのすすめ

Robust Python Programming 3/N

公開日: 2024-03-14
更新日: 2024-03-14

  Table of Contents

この記事のスコープ

Python 3.5以降で導入された type annotationsについて解説した記事です.

Guido van Rossumより引用

I’ve learned a painful lesson that for small programs dynamic typing is great. For large programs you have to have a more disciplined approach and it helps if the language actually gives you that discipline, rather than telling you “Well, you can do whatever you want.

Type annotationsとは, 動的型付け言語であるPythonで書かれたcodeに規律(discipline)を与える Pythonの仕組みの一つです. なお留意点としては, 上の引用でもあるように

  • ちょっとしたスクリプトを書くときは厳密にやらなくても良い
  • あくまで長くメンテされることを予定した大きいコードを書くときに有用な仕組み

であることを忘れないでください. あくまで他人と非同期的なコミュニケーションをする際に有用なツールであり, 探索的に自分だけでプログラミングするときなどは別に使わなくて良いと思います.

Type Annotationsとはなんなのか?

Type Annotationsとは, 開発者が取り扱っている変数について期待されているデータ型を示すsyntaxのことです.

1
2
3
4
def close_kitchen_if_past_close(point_in_time: datetime.datetime): 
    if point_in_time >= closing_time():
        close_kitchen()
        log_time_closed(point_in_time)

この例では, point_in_timeのデータ型として datetime.datetime型が期待されていることがType Annotations によって示されています. このclose_kitchen_if_past_close()関数を使用するときのみならず, 変更するときにどのようなデータ型の範囲で変更してよいのかを示唆してくれるというメリットがあります.

Type Annotationsによるcognitive overheadの低減

1
2
3
4
5
6
7
8
9
import datetime
import random

def schedule_restaurant_open(open_time, workers_needed):
    workers = find_workers_available_for_time(open_time)
    # Use random.sample to pick X available workers
    # where X is the number of workers needed.
    for worker in random.sample(workers, workers_needed):
        worker.schedule(open_time)

という他の人が書いたコードが与えられ, schedule_restaurant_open()関数を使いたい状況を想定します. このとき,

  • open_timeはdatetime型を入れればいいのか?それともYYYY-MM-DDのような文字列を入れればいいのか?
  • workers_needed: 必要なthe number of workersという意味でintを入れればよいのか?

が一見ではわかりません. random.sampleが使われていることからworkers_neededint型を予定していることが推察できますが, line-by-lineで読んでわかることであり, cognitive overheadが発生します. また, 実装を読んでもopentimeのデータ型は推察することができず, worker classのコードを見に行かなくてはなりません.

1
2
3
4
5
6
7
8
9
10
import datetime
import random

def schedule_restaurant_open(open_time: datetime.datetime,
                             number_of_workers_needed: int):
    workers = find_workers_available_for_time(open_time)
    # Use random.sample to pick X available workers
    # where X is the number of workers needed.
    for worker in random.sample(workers, number_of_workers_needed):
        worker.schedule(open_time)

とType Annotationsが活用されていれば,

  • open_timeはdatetime
  • workers_neededはint

random.sampleにたどり着く前に判断することができます. number_of_workers_neededという変数名もType Annotationsの内容と整合的であり, より理解しやすくなったコードになったと言えます.

Annotating return types

上記のschedule_restaurant_open()関数の中にfind_workers_available_for_time()が呼ばれています.

  • workersを返している
  • workersfor文で呼ばれているのでイタラブル型

ということは推察できますが, list型なのか, tuple型なのか, それ以外なのか?よくわかりません. そこで実装を見てみると次のようなコードでした.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def find_workers_available_for_time(open_time: datetime.datetime):
    workers = worker_database.get_all_workers()
    available_workers = [worker for worker in workers
                           if is_available(worker)]
    if available_workers:
        return available_workers

    # fall back to workers who listed they are available
    # in an emergency
    emergency_workers = [worker for worker in get_emergency_workers()
                           if is_available(worker)]

    if emergency_workers:
        return emergency_workers

    # Schedule the owner to open, they will find someone else
    return [OWNER]

Returnのパターンが

  • available_workers
  • emergency_workers
  • [OWNER]

と3つ存在しており, 一貫性があるのかすらよくわかりません. worker_database.get_all_workers()関数やget_emergency_workers()関数を調査し, 簡易なテストの実行しなくてはならないような状況です. ここで役に立つのがreturn typeです.

1
def find_workers_available_for_time(open_time: datetime.datetime) -> list[str]:

のように関数宣言の最後に -> <type> を付与することで返り値に期待されるデータ型をコミュニケーションすることができます. worker_database.get_all_workers()関数やget_emergency_workers()関数を調査し, 簡易なテストの実行という工数をちょっとした文字列を付与するだけで解決することができました.

変数の定義とType Annotations

変数の定義時にも以下のようにType Annotationsを利用することができます.

1
2
3
4
5
6
7
workers: list[str] = find_workers_available_for_time(open_time)
numbers: list[int] = []
ratio: float = get_ratio(5,3)
number: int = 0
text: str = "useless"
values: list[float] = [1.2, 3.4, 6.0]
worker: Worker = Worker()

ただし, 上記の例のいくつかはType Annotationsを用いなくても良いです. "useless"0はそれぞれstrintと一目でわかります. あくまで他人と非同期的なコミュニケーションをする際に有用なツールだからType Annotationsを使うのであって, Type Annotationsを使うためにType Annotationsを使うわけではありません.

Type Annotationsの機能面でのメリット

上での説明はsemanticな意味でのメリットを用いながらType Annotationsのメリットを紹介しましたが,

  • Autocomplete
  • Typecheckers

などPythonの機能面でのメリットをここでは紹介します.

Autocomplete

VSCodeを使っている人の多くは Pylance を拡張機能に入れているかもしれません. Pylance は, その背後にMicrosoftの静的型チェックツールであるPyrightが動いており, その機能を利用することで豊富な型情報を用いたPythonのIntelliSenseを提供してくれます.

open_time変数に対してType Annotationsを利用することで, open_time変数を利用するとき, methodやattributesの候補提示を利用することができます.

Typecheckers

Typecheckersとは静的解析の1つです. PylanceでもTypecheker機能を使うことができますが, Defaultでは offになっているので

1
2
3
4
{
    // Typecheker
    "python.analysis.typeCheckingMode": "standard",
}

settings.jsonで設定する必要があります. 設定後, Pythonファイルを適当に開き,

1
2
a: int = 5
a = 'string'

と記載すると, 解析時に以下のようなWarningが出てきます.

1
2
Expression of type "Literal['string']" cannot be assigned to declared type "int"
"Literal['string']" is incompatible with "int"

Noneも許容したいときに警告が吐かれてしまう場合もありますが, その場合は

1
2
a: int | None = 5
a = None

とすればTypechecker解析を警告なしでやり過ごすことができます.

いつTypechekerをつかうべきか?

すべてのエンティティに対して, Type Annotationsを実施することはすごく労力を有します. プライベートでちょっとした関数を試したいときなどは必要は無いですし, 大人数で開発するときもすべての変数に必要なわけではありません.

基本的には以下に該当するときにtypecheckerを利用することを検討する方針が良いとされます:

  • public APIs, library entry pointsなど他のモジュールや開発者が利用すると思われる関数
  • データ型を強調したいときや, counter-intuitiveなロジック展開をする場合

BUG発見EXERCISE

Exercise 1

1
2
3
4
def read_file_and_reverse_it(filename: str) -> str:
    with open(filename) as f:
        # Convert bytes back into str
        return f.read().encode("utf-8")[::-1]


解答

` -> str`とreturnについてType Annotationされているが, UTF-8にエンコードされたものを返している = byte型を返しているので

1
2
Incompatible return value type
(got "bytes", expected "str")

のバグがある


Exercise 2

1
2
3
4
5
6
7
# takes a list and adds the doubled values
# to the end
# [1,2,3] => [1,2,3,2,4,6]
def add_doubled_values(my_list: list[int]):
    my_list.update([x*2 for x in my_list])

add_doubled_values([1,2,3])


解答

my_list: list[int]と引数についてType Annotationされているが, List型にupdate methodは存在しない. つまり,

1
"list[int]" has no attribute "update"


Exercise 3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# The restaurant is named differently
# in different parts of the world
def get_restaurant_name(city: str) -> str:
    if city in ITALY_CITIES:
            return "Trattoria Viafore"
    if city in GERMANY_CITIES:
            return "Pat's Kantine"
    if city in US_CITIES:
            return "Pat's Place"
    return None


if get_restaurant_name('Boston'):
    print("Location Found")


解答

` -> strとreturnについてType Annotationされているが, None`を返すパターンがある. つまり,

1
2
Incompatible return value type
(got "None", expected "str")

これを許容したい場合は

1
2
def get_restaurant_name(city: str) -> str | None:
    ...

と宣言すべき

References



Share Buttons
Share on:

Feature Tags
Leave a Comment
(注意:GitHub Accountが必要となります)