rcmdnk's blog

型で作るやきもの―型成形の基本と実際 (新技法シリーズ)

Pythonでかならず使うわけではないライブラリをimportする際、globalにしてしまうと そのファイルを読む際に必ず呼ばれて遅延が起こるので、使う関数内でimportしたい、という場合。

この時にその関数の返り値がimportしたライブラリの中のクラスのオブジェクトになるとすると 型付け(アノテーション)するために関数の外でimportされてないと型がつけられません。

そういった場合にちゃんと型付けする方法について。

環境

Python 3.11.3 で以下のパッケージをインストールします。

requirements.txt
1
2
3
4
pandas==2.0.1
pandas-stubs==2.0.1.230501
flake8==6.0.0
mypy==1.3.0

重いライブラリとしてpandasを使い、 型付けチェックとしてflake8とmypyでチェックします。

globalにimportする場合

普通にファイルの先頭でimportする場合。

test0.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pathlib import Path
import pandas as pd


def get_df(file: str) -> pd.DataFrame | None:
    if Path(file).exists():
        return pd.read_csv(file)
    return None


def print_df(file: str) -> None:
    df: pd.DataFrame | None = get_df(file)
    if df is not None:
        print(df)

こんなファイルを考えます。 このファイルでは全ての関数でpdを使ってるのでimportするとすればPandasを読み込まないと何も意味ないレベルのものですが、 実際には他にも色々とあってこの部分が使われないこともあるとして。

このファイルを型チェックをしてみたり、実際にimportしてみたりすると

1
2
3
4
5
$ flake8 test0.py
$ mypy test0.py
Success: no issues found in 1 source file
$ python  -c 'import test0'
$

といった感じでなんのエラーも起きずにうまくいきます。

importを関数内で行う

先頭で行うとこのファイルを読み込む際に必ず読み込むことになるので 関数内で必要なところだけで呼んでみます。

test1.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pathlib import Path


def get_df(file: str) -> pd.DataFrame | None:
    if Path(file).exists():
        import pandas as pd
        return pd.read_csv(file)
    return None


def print_df(file: str) -> None:
    import pandas as pd
    df: pd.DataFrame | None = get_df(file)
    if df is not None:
        print(df)

これで関数内ではうまくいきますが、 返り値の値として型をつけてる部分が問題になります。

1
2
3
4
5
6
7
8
9
10
11
12
13
$ flake8 test1.py
test1.py:4:26: F821 undefined name 'pd'
$ mypy test1.py
test1.py:4: error: Name "pd" is not defined  [name-defined]
Found 1 error in 1 file (checked 1 source file)
$ python -c 'import test1'
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File ".../test1.py", line 4, in <module>
    def get_df(file: str) -> pd.DataFrame | None:
                             ^^
NameError: name 'pd' is not defined. Did you mean: 'id'?
$

型チェックも失敗しますが実際に実行する際にもエラーになるのでこれは絶対に使えません。

もちろんここで型チェックに妥協してそもそも返り値の型を書かないようにしたり typing.Anyを使えば型チェッカーを通せますしエラーも出ません。

ただ、ちゃんと型をつけようとしてmypyに--disallow-untyped-defsをつけている場合などは error: Function is missing a return type annotation [no-untyped-def]のエラーが出ます。

今回はこれも回避出来るようにちゃんと型を付けて見たいと思います。

TYPE_CHECKINGを使う

関数の返り値の部分は関数外になってしまうので、ここのところでクラスを使いたい場合には どうしてもglobalな部分でimportするしかありません。

この場合、typing.TYPE_CHEKING (Python 3.5.2で導入) という値を使うことが出来ます。

typing.TYPE_CHECKINGは型をチェックする際にだけTrueになる値で、通常の実行時にはFalseになります。

実際実行時には

1
2
3
4
5
6
7
from typing import TYPE_CHECKING


print(f'TYPE_CHECKING: {TYPE_CHECKING}')

if TYPE_CHECKING:
    print('type checking is true')

みたいなものを実行すれば

1
TYPE_CHECKING: False

とだけ出て、とりあえず実行時にはFalseになっていることはわかります。

これを使って

test2.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
    import pandas as pd


def get_df(file: str) -> pd.DataFrame | None:
    if Path(file).exists():
        import pandas as pd
        return pd.read_csv(file)
    return None


def print_df(file: str) -> None:
    df: pd.DataFrame | None = get_df(file)
    if df is not None:
        print(df)

このようにすると、flake8やmypyでチェックする時には最初の importは呼ばれますが、 実際に実行する際には呼ばれません。

これで返り値のチェックはクリア出来ます。

ただ、これをやってみると

1
2
3
4
5
6
7
8
9
10
11
$ flake8 test2.py
$ mypy test2.py
Success: no issues found in 1 source file
$ python -c 'import test2'
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File ".../test2.py", line 7, in <module>
    def get_df(file: str) -> pd.DataFrame | None:
                             ^^
NameError: name 'pd' is not defined. Did you mean: 'id'?
$

のように、型チェッカーのチェックは通りますが、実際に実行する際にも 型のチェックはしませんがその部分は見えるためpdというものがない、ということだけはわかり エラーになってしまいます。

また、print_dfの関数内で使っている値への型付けの部分でもimportを外していますが、 ここに関しては型チェック用のTYPE_CHECKINGの中でimportされていれば 文句は言われないようになります。 (これは後で実証します。)

文字列にする

これを対処するために、型の部分を文字列にする方法があります。

test3.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
    import pandas as pd


def get_df(file: str) -> 'pd.DataFrame' | None:
    if Path(file).exists():
        import pandas as pd
        return pd.read_csv(file)
    return None


def print_df(file: str) -> None:
    df: pd.DataFrame | None = get_df(file)
    if df is not None:
        print(df)

こうすることにより、flake8やmypyではpd.DataFrameの部分をチェックしますが 実際に実行する際にはアノテーションとしてなにか文字列があるな、 と思うだけで特にエラーを出しません。

ただ、また別の問題が出ます。

1
2
3
4
5
6
7
8
9
10
11
$ flake8 test3.py
$ mypy test3.py
Success: no issues found in 1 source file
$ python -c 'import test3'
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File ".../test3.py", line 7, in <module>
    def get_df(file: str) -> 'pd.DataFrame' | None:
                             ~~~~~~~~~~~~~~~^~~~~~
TypeError: unsupported operand type(s) for |: 'str' and 'NoneType'
$

問題は|を使って複数の型を指定する方法がこのような型を文字列で書いた場合 うまくいかない、という点。

Optionalを使う

そこでこの|を使えるようになるPython 3.10より前まで使われていたtyping.Unionを使うかtyping.Optionalを使います。 下ではNoneとの組み合わせですが|と同じように書いて見るためにUnionで書いてみます。

test4.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pathlib import Path
from typing import TYPE_CHECKING, Union
if TYPE_CHECKING:
    import pandas as pd


def get_df(file: str) -> Union['pd.DataFrame', None]:
    if Path(file).exists():
        import pandas as pd
        return pd.read_csv(file)
    return None


def print_df(file: str) -> None:
    df: pd.DataFrame | None = get_df(file)
    if df is not None:
        print(df)
1
2
3
4
5
6
$ flake8 test4.py
$ flake8 test4.py
$ mypy test4.py
Success: no issues found in 1 source file
$ python -c 'import test4'
$

これで型チェックも実際に実行する際にもうまくいきました。

文字列でちゃんとチェックできているのか?

文字列にすると実行時には関係なくなるという状態ですが、 型チェックする際にも単に無視されているとしたら意味がありません。

これを試すために関数の受け取り側で違う型を指定してみると、

test5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pathlib import Path
from typing import TYPE_CHECKING, Union
if TYPE_CHECKING:
    import pandas as pd


def get_df(file: str) -> Union['str', None]:
    if Path(file).exists():
        import pandas as pd
        return pd.read_csv(file)
    return None


def print_df(file: str) -> None:
    df: str | None = get_df(file)
    if df is not None:
        print(df)
1
2
3
4
5
6
$ flake8 test5.py
$ mypy test5.py
test5.py:15: error: Incompatible types in assignment (expression has type "Optional[DataFrame]", variable has type "Optional[str]")  [assignment]
Found 1 error in 1 file (checked 1 source file)
$ python -c 'import test5'
$

といった感じにflake8は検出出来てませんが mypyではきちんとOptional[str]であるべき所にOptional[DataFrame]が来ている、ということで 間違いを検出していますし、きちんと関数の返り値の部分がpd.DataFrameであることも認識しています。

文字列にせず実行時には見ないようにする

型チェックの部分を実行時に見てもそこで型のチェックをしているわけでは無いので意味がありません。

現状ではこれをquoteして文字列にすることで回避していますが、これをquoteなしでそのまま 実行時には見ず、型チェックのときだけ見るようにするようにする方向がPEP 563で定義されています。

これは現在はfrom __future__ import annotationsを使うと有効になります。

また、これによりUnionを使わずに|で書くことも可能になります。

test6
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Union
if TYPE_CHECKING:
    import pandas as pd


def get_df(file: str) -> pd.DataFrame | None:
    if Path(file).exists():
        import pandas as pd
        return pd.read_csv(file)
    return None


def print_df(file: str) -> None:
    df: pd.DataFrame | None = get_df(file)
    if df is not None:
        print(df)
1
2
3
4
5
$ flake8 test6.py
$ mypy test6.py
Success: no issues found in 1 source file
$ python -c 'import test6'
$

この機能はいずれ from __future__ import annotationsなしでも有効になるはずです。

実行速度

実際に使えるtest0とtest4のバージョンをimportする時間について見てます。

手元の環境でpython -X importtimeを使って見てみると 先頭で必ずimportする場合は

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ time python -X importtime -c 'import test0' 2>&1 |tail -n10
import time:       146 |        383 |       pandas.io.sas
import time:       101 |        101 |       pandas.io.spss
import time:       705 |        705 |       pandas.io.sql
import time:      2518 |       2518 |       pandas.io.stata
import time:       654 |        654 |       pandas.io.xml
import time:       493 |      24304 |     pandas.io.api
import time:       103 |        103 |     pandas.util._tester
import time:        80 |         80 |     pandas._version
import time:       510 |     230275 |   pandas
import time:       195 |     233178 | test0

real    0m0.292s
user    0m0.359s
sys     0m0.457s

importtimeの各列は

1
import time: self [us] | cumulative | imported package

で、self [us]はそのパッケージをimportするのにかかった時間、cumulativeは パッケージ内でさらに他のパッケージをimportしている場合、それらも含めたトータルの時間です。

pandasのロードでトータル230,275 us(0.2秒)かかっていることがわかり、 それ以外は0.003秒ほどで100分の1ほどです。

test4の方をやってみると

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ time python -X importtime -c 'import test4' 2>&1 |tail -n10
import time:       210 |        210 |     warnings
import time:        53 |         53 |     errno
import time:        96 |         96 |       urllib
import time:       713 |        809 |     urllib.parse
import time:       667 |       2397 |   pathlib
import time:       122 |        122 |     collections.abc
import time:       410 |        410 |     contextlib
import time:       113 |        113 |     _typing
import time:      1805 |       2449 |   typing
import time:      1529 |       6373 | test4

real    0m0.025s
user    0m0.026s
sys     0m0.000s

こんな感じでpandasは呼ばれず、トータルで0.006秒ほどに抑えられています。 50分の1ほど。

実際の実行時間もuserを見ると0m0.359sから0m0.026sと10分の1以下になっています。

ここにはターミナル出力等も含まれるので純粋なpythonのimportの読み込み時間としては 50分の1ほどです。

test6のバージョンも見てみると

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ time python -X importtime -c 'import test6' 2>&1 |tail -n10
import time:       269 |        269 |     warnings
import time:        54 |         54 |     errno
import time:        93 |         93 |       urllib
import time:       712 |        805 |     urllib.parse
import time:       749 |       2509 |   pathlib
import time:       151 |        151 |     collections.abc
import time:       408 |        408 |     contextlib
import time:       123 |        123 |     _typing
import time:      1810 |       2491 |   typing
import time:       217 |       5331 | test6

real    0m0.025s
user    0m0.026s
sys     0m0.000s

ということでannotationsのimportはほとんど時間がかからないので同じような時間になっています。

関数中での型付けのチェック

途中で返り値ではなく実行文中での型付けの部分に関しては TYPE_CHECKINGでガードされて実行時にはimportされないクラスなどを 使っても問題ない、という話をしましたがそれをチェックしてみます。

test4のprint_dfの関数を実際に使ってみます。

test7.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from pathlib import Path
from typing import TYPE_CHECKING, Union
if TYPE_CHECKING:
    import pandas as pd


def get_df(file: str) -> Union['pd.DataFrame', None]:
    if Path(file).exists():
        import pandas as pd
        return pd.read_csv(file)
    return None


def print_df(file: str) -> None:
    df: pd.DataFrame | None = get_df(file)
    if df is not None:
        print(df)


if __name__ == '__main__':
    print_df('a.csv')
1
2
$ python test4.py
$

a.csvがない場合にはget_dfの返り値もNoneでpandasは一切呼ばれずに実行されますが エラーなく実行できています。

ある場合でも

1
2
3
4
5
6
7
8
9
10
$ cat << EOF > a.csv
> a,b,c
> 1,2,3
> 4,5,6
> EOF
$ python test4.py
   a  b  c
0  1  2  3
1  4  5  6
$

といった感じで問題なく実行できます。

Sponsored Links
Sponsored Links

« シェルスクリプトでgitとかみたいなサブコマンドを作る スイッチボット防水温湿度計を設置 »

}