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_needed
はint
型を予定していることが推察できますが, 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
を返しているworkers
はfor
文で呼ばれているのでイタラブル型
ということは推察できますが, 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
はそれぞれstr
やint
と一目でわかります. あくまで他人と非同期的なコミュニケーションをする際に有用なツールだから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
- Robust Python > Chapter 3. Type Annotations
- Ryo’s Tech Blog > Coding Style Guide Part 1
- Ryo’s Tech Blog > Robust Codeを書くためのプログラミング姿勢について
- Ryo’s Tech Blog > Python Typesのすすめ
(注意:GitHub Accountが必要となります)