Python で実行時型チェックをしよう
私の彼氏、靴磨きクリームを「シュークリームには違いないだろ」って食べちゃうのよ
—— 暗黙の型変換 ——
はじめに
最近 Python を書いていて少しでも規模が大きくなってくると、事あるごとに型アノテーションと型チェックを仕込むようになってきた。そんなに気になるなら型のある言語を使えばいいじゃないという話だが、データ分析などで書きながら試行錯誤がしやすいという意味では Python のガバさはむしろ有用だと思っている。そうはいうたかてガバすぎるのでちょうどよく引き締めましょうね、という現状の私の局所解を紹介する。
Python は静的型チェックを持たない言語だが、暗黙の型変換は極力行わず、実行時ではあるが型的に明らかにヤバいコードは割とエラーを吐いてくれるので Lightweight Language の中では比較的良心がある[1]。たとえば Python では以下のようなコードは実行できない。
# 以下のコードがエラーを吐かずに動作するプログラミング言語には人の心がない
1 + '0'
Python にはバージョン 3.5 以降、型アノテーションという機能が搭載されており、変数や関数の引数などにそれがどんな型であるか型注釈をつけることができる。だからといって型チェックをしてくれるわけでもなく現状はお飾りに過ぎないのだが、おそらく Python 4 が登場すればそのときには何らかのチェック機構が搭載されるものと踏んでいる。
サードパーティの mypy
というライブラリを使えば静的型チェックをしてくれるのだが、私はあまり利便性を感じておらず使っていない[2]ため、この記事では解説しない。
想定していない入力に対してプログラムは異常停止すべきである
少なくともデータ分析において、想定していない入力が与えられたとき、プログラムは異常停止すべきである。想定していない入力が与えられたということは、そのときのプログラムの挙動は想定していなかったものになる。もしそれでプログラムが停止しなければ、分析者はデータから誤った結論を導いて相手に報告することになる。
プログラムが異常停止していれば失うのは修正のためのちょっとした時間で済むが、異常停止せずにバグったまま報告した場合に失うのは信用である。時間は交渉によって確保できるが、信用を失えば交渉の機会すら与えられない。よって Python でデータ分析をする場合にはバグを検知するためのコードを随所に仕込むことになる[3][4]。
そもそもプログラムのバグとは「プログラムが想定通りの挙動をしていないのにプログラムが停止しなかった」という状態を指すものである。
プログラムの挙動は想定通り | プログラムは停止 | 結果 |
---|---|---|
である | しない | ヨシ!! |
である | した | ヨシ!![5] |
ではない | しない | バグ(死) |
ではない | した | 修正すればヨシ |
プログラムのバグの原因は主に以下の4パターンに大別できる。
- プログラムにタイプミスや変数・関数の意図しない上書きなどがあった
- プログラマの組んだロジックに誤り(条件の網羅抜けなど)があった
- プログラマが関数の動作を知らなかった/誤解していた
- プログラマの想定していない入力がプログラムに与えられたが偶然それっぽく動作した
1はエディタやコンパイラといったツールの支援である程度防げる。2と3は誰にでもあるし防ぎようがないのでしょうがない。そして4は「想定していない入力が与えられた時点でプログラムを停止させる」という方法で防げるのである。入力の型チェックの時点でプログラムを停止させてしまえば、その後の挙動が実際に動かしてみたときにどうなるものであろうが関係ない。
入力は想定通り | プログラムの挙動は想定通り | プログラムは停止 | 結果 |
---|---|---|---|
である | である | しない | ヨシ!! |
である | である | した | ヨシ!! |
である | ではない | しない | バグ(仕方ない) |
である | ではない | した | 修正すればヨシ |
入力は想定通り | プログラムの挙動は想定通り | プログラムは停止 | 結果 |
---|---|---|---|
ではない | である | させる | 停止 |
ではない | である | させる | 停止 |
ではない | ではない | させる | 停止(バグを抑止) |
ではない | ではない | させる | 停止 |
Python の型アノテーション
先にも述べた通り、Python の型アノテーションはあくまでも注釈に過ぎず、コードの動作自体に何ら影響を与えるものではない。私も最初は書いていて悲しい気持ちになったが
- 少なくともこの関数を書いた人はこの型の入力を想定している
というヒントがあるだけでもその関数にどんな機能があるのか想像したり思い出したりしやすくなるものである。
特にコードの規模が大きくなってくると、過去に書いた自分のコードを読むときに助けられることが多い。型アノテーションがないと、私のように記憶力の悪い人間はその関数になにが入力されているのかを確かめるためにそこに繋がるコードを無限に辿る作業が頻発して開発が進まなくなる。
Python では他人にコードを共有するときにどうせ docstring やコメントに引数の型を書くことになるので、そのときのための覚書きとしても型アノテーションとしてメモしておくのが視覚的にスマートである。また、sphinx
などの自動ドキュメント生成においても型アノテーションはドキュメントに反映されるので、型アノテーションさえ書いておけば、一応は最低限のドキュメントとして機能する。
これらのメリットに対してデメリットは「ちょっと面倒くさい」くらいしかないので書いた方がよいというのが私の暫定的な結論である。
そんな Python の型アノテーションの記法は以下である。変数や引数の右に :
を書き、さらにその右に型を書く。関数の戻り値は関数定義の )
と :
の間に ->
と戻り値の型を書く。
# 変数に型アノテーションする記法
x: str = 'this is string'
# 関数に型アノテーションする記法
def myfunc(x: float, y: float=5.) -> float:
return x * y
変数の型は代入するときにほぼ自明(だしそうなるようにコードを書くべき)なので、私は変数の型アノテーションはむしろ目障りに感じて使っていない[6]。一方で関数の型アノテーションはその関数を呼び出すたびに「型合ってるかな」と確認する機会になり安全性が高くなるので多用している。
Python の実行時型チェック
Python で着目している対象の型は type
関数で取得することができる。
print(type(3.14))
<class 'float'>
Python で実行時に型チェックをするには isinstance
関数を使う。プリミティブな型、たとえば int
, float
, bool
, str
, list
, tuple
, dict
, set
あたりであれば特に考えることなく使える。
x = 'this is string'
if isinstance(x, str):
print('x は str 型のインスタンスです')
第2引数に型のタプルを与えた場合、そのうちのいずれかの型であれば True
を返す。
x = 'this is string'
if isinstance(x, (int, str)):
print('x は int 型 または str 型のインスタンスです')
また、自分で定義したクラスでも使うことができる。
class MyClass:
pass
x = MyClass()
if isinstance(x, MyClass):
print('x は MyClass のインスタンスです')
isinstance
関数を利用した実行時型チェックのコンボは以下である。
if not isinstance(x, str):
raise TypeError(f"x must be instance of {str} but actual type {type(x)}.")
私はあまりやらない[7]が、もし頻繁に上記のコードを使うのであれば以下のような関数を用意してもよいだろう。
def type_checked(x, type_):
if not isinstance(x, type_):
raise TypeError(f"x must be instance of {type_} but actual type {type(x)}.")
return x
# こちらはエラーにならない
x = type_checked(3, int)
print(x)
# こちらはエラーになる
x = type_checked('3', int)
print(x)
def myfunc(x: int, y: str):
x = type_checked(x, int)
y = type_checked(y, str)
...
特にバグりやすいポイント
究極のバグ製造機として手書きの有限オートマトンがある[8]が、そのようなトチ狂ったケースを除けば Python でもっとも書く頻度が高くバグりやすいポイントとして真っ先に思いつくのは、引数として受け取ったイテラブルな値を for
文でイテレートするような関数である。以下の関数はディレクトリのパス path
と文字列のリスト xs
を引数に取り、パスに個々の文字列を結合したリストを返す関数である。
def joined_path_list(path, xs):
ys = []
for x in xs:
ys.append(path / x)
return ys
from pathlib import Path
path = Path('../data')
xs = ['raw', 'interim', 'result']
print(joined_path_list(path, xs))
[PosixPath('../data/raw'), PosixPath('../data/interim'), PosixPath('../data/result')]
この関数は xs
に文字列を与えても動作してしまう。なぜなら文字列はイテレート可能でイテレートすると文字をひとつずつ取り出し、Python には文字と文字列の区別がないので取り出された個々の要素もまた文字列となるためである。他にも文字列をキーに持つ辞書などでも動作してしまう。
from pathlib import Path
path = Path('../data')
xs = 'result'
print(joined_path_list(path, xs))
[PosixPath('../data/r'), PosixPath('../data/e'), PosixPath('../data/s'), PosixPath('../data/u'), PosixPath('../data/l'), PosixPath('../data/t')]
Python においては利便性を考慮してかなり多くの型がイテラブルにされることが多いため、このようなコードは xs
にイテラブルな値を与えたとき動作してしまい、結果としてバグとなることが多い。
このバグは以下のひと工夫で防ぐことができる。
def is_instance_list(xs, type_):
if not isinstance(xs, list):
return False
return all([ isinstance(x, type_) for x in xs ])
def joined_path_list(path, xs):
if not is_instance_list(xs, str):
raise TypeError("xs must be instance of list[str].")
ys = []
for x in xs:
ys.append(path / x)
return ys
is_instance_list
関数自体がけっこう大きいためかなり手間がかかるように思えるが、「引数が特定の型のリストになっていてほしい」という状況は非常に多く発生するので作っておいて損はない。リストが特定の長さになってほしいことがよくあるので、私は以下のような関数を適当な場所に置いて import
していることが多い。
def is_instance_list(xs, type_, n=None):
if not isinstance(xs, list):
return False
if (n is not None) and (len(xs) != n):
return False
return all([ isinstance(x, type_) for x in xs ])
上記の関数は引数 n
を与えた場合、リストの型チェックに加えて、リストの長さが n
になるときのみ True
を返す。
小技:明示的な型変換
データ分析において分析者自身がいじくらねばならないのはほぼデータの読み書きと加工の部分であり、扱うデータの型としても
-
numpy
のndarray
-
pandas
のSeries
とDataFrame
-
pathlib
のPath
- それらのリスト、辞書、集合など
がほとんどである。それ以外の機械学習など高度な分析手法に関しては誰かが作ってくれたクラスや関数が用意されているので、それを呼び出すだけであってバグの原因にはならない。
小技として、numpy
, pandas
, pathlib
のようによく作り込まれたライブラリのクラスのイニシャライザは、内部で型チェックをしているので型チェックに使うことができる。
実践的な例を挙げておこう。扱うデータのディレクトリ構造や命名規則が複雑になってくると、データのパス名を加工するための関数をたくさん作ることになる。このとき陥りがちなのが、関数の入力が Path
なのか str
なのかわからなくなって、入力の型がどちらでも動くように関数をいじくり始めるという状況だ。
慣れないうちは以下のように書いてしまうかもしれない。
from pathlib import Path
def join(dirname, filename):
if isinstance(dirname, str):
path = Path(dirname) / filename
elif isinstance(dirname, Path):
path = dirname / filename
else:
TypeError("dirname must be instance of 'Path'.")
return path
実際に書くコードはもっと複雑な処理になるが、本質としては str
と Path
と それ以外 の場合分けになる。また、filename
の型についても気を払うならばもっと複雑になるかもしれない。
上記の処理は 実は以下のように書ける。
from pathlib import Path
def join(dirname, filename):
dirname = Path(dirname)
path = dirname / filename
return path
要点は「関数の入口部分でこれから使いたい型に変換してしまう」ということだ。dirname
にいろいろな値を与えてみると分かるが、Path
のイニシャライザは内部で型チェック(およびパスとして有効な文字列かどうかのチェック)を行っているので、ファイルのパスを記述するのにふさわしくない入力を与えた場合にはすべてエラーになる。
上記のコードに型アノテーションを加えてより実践的な見た目にしておこう(Union
についてはあとで解説する)。
from pathlib import Path
from typing import Union
def join(dirname: Union[str, Path], filename: Union[str, Path]) -> Path:
dirname = Path(dirname)
filename = Path(filename)
path = dirname / filename
return path
これならば join
関数の定義とその下の2行を見た時点で
- ああ、この関数の引数は
str
とPath
のどちらで与えてもよいのだな - そして最初に
Path
に型を強制してしまうから、この関数内ではPath
以外のケースを想定しなくてよいのだな
と分かるので、ソースコードを読む人にとっても大分読みやすくなるし、join
関数を書き換える際にもかなり安心して編集できる。
このテクニックは numpy
や pandas
などでも使える。変換が一筋縄ではいかない組み合わせもあるので1行で済むとは限らないが、冒頭部分で型を統一してしまえばその後の型別の条件分岐を減らすことができるという点は共通している。関数内で後になってからやたらと型による条件分岐を記述するのはスマートではない。状況によるので一概には言えないが、冒頭以外で型による条件分岐が発生するならば、想定する型ごとに関数を分けることを検討したほうがよいかもしれない。
import numpy as np
import pandas as pd
# リストが与えられるかもしれないが、numpy.ndarray に変換してしまう
def myfunc(xs):
xs = np.array(xs)
...
# リスト, numpy.ndarray, pandas.Series などが与えられるかもしれないが、
# pandas.DataFrame に変換してしまう
def myfunc(X):
X = pd.DataFrame(X)
...
注意点として、関数の最初で str
に変換するのはよくない。
# これはアンチパターンです
def myfunc(x):
x = str(x)
...
なぜならば str
はどんな値を与えてもまずエラーにならず何らかの文字列に変換するからである。
小技の応用例
たとえばデータの保存先を保持するクラスのテンプレは以下のようになる(Optional
, Union
についてはあとで解説する)。以下のテンプレで与える MyClass
は次のような動作を保証する。
- 初期化時に
data_dir
をstr
またはPath
で与えてもよい。- どちらで与えても必ず
Path
型で保持される。 - 保存機能を使わない場合は与えなくてもよく、その場合は
None
を保持する。
- どちらで与えても必ず
- 初期化時に
data_dir
を与えなかった場合、self.data_dir
を参照しようとするとRuntimeError
となる。 -
save
メソッドを呼び出すときにdata_dir
をstr
またはPath
で与えてもよい。- 与えた場合は初期化時に与えた
data_dir
よりも優先され、とりあえず他の場所に一時保存しておきたい場合やデータをコピーしたい場合に便利である。 - 与えなかった場合には初期化時に与えた
data_dir
が使用される。初期化時にも与えていなかった場合にはRuntimeError
が発動する。 - 与えようと与えまいと、
save
メソッドの処理が続行する場合、data_dir
にはPath
型の値が入っている。
- 与えた場合は初期化時に与えた
つまりユーザは好きなタイミングで data_dir
を指定することができるし、与えるときは str
と Path
のどちらにすべきかも悩まなくてよくなる。そして間違った使い方をすればエラーを出してくれる。かなり堅牢で利便性の高いプログラムになる。
from pathlib import Path
from typing import Optional, Union
class MyClass:
def __init__(self, data_dir: Optional[Union[str, Path]]=None):
self._data_dir = Path(data_dir) if data_dir is not None else None
@property
def data_dir(self):
if self._data_dir is None:
raise RuntimeError(f"data_dir is not defined.")
return self._data_dir
def save(self, data_dir: Optional[Union[str, Path]]=None):
data_dir = Path(data_dir) if data_dir is not None else self.data_dir
...
上記のテンプレは初期化時に与えた data_dir
を上書きすることは想定していない。誰かがどこかで上書きしたことに気づかなかったり、うっかり save
メソッドやその他のメソッドの中で書き換えてしまったときにはバグとなるため、あえて上書きできないようにしている。
上書きを許すと、たとえば上記テンプレの save
メソッド定義直下の1行を self.data_dir = Path( ...
とうっかり書いただけでプログラムは非常に検知しづらいバグり方をするのである[9]。
どうしても上書きしたい場合は以下のメソッドを追記すればよい。
@data_dir.setter
def data_dir(self, data_dir: Optional[Union[str, Path]]):
self._data_dir = Path(data_dir)
上記の setter を追加すれば self.data_dir = '../data'
のように普通のメンバを上書きするように更新でき、そのとき与えた値は Path
のイニシャライザを通過するので型チェックが入ることになる。
より高度な実行時型チェック
Python ではより高度な型アノテーション、型チェックのために typing
モジュールや collections
モジュールが提供されている。大抵は静的型チェックのために用意されているものだが、実行時にも役立つものがあるのでよく使うものを紹介しておく。
Union
Union
型は複数の型のうちいずれかを表す型(直和型)である。たとえば
from typing import Union
x: Union[int, float]
と書いた場合、x
は int
または float
であることを表す。Python 3.10 以降では |
によって Union
型を略記できるようになった。
x: int | float
また、Python 3.10 以降では Union
型を isinstance
に与えたときも意図通りに動作するようになった。
x = 3.14
print(isinstance(x, Union[int, float]))
print(isinstance(x, int | float))
True
True
以下のようにして Union
型の新しい型を作ることができる。
Numeric = Union[int, float]
print(isinstance(3, Numeric))
True
Python 3.10 以前の場合
Python 3.10 以前の場合、isinstance
関数の第2引数に Union
型を与えても意図通りには動作しない。その場合は typing
から get_args
をインポートして使う。
from typing import Union, get_args
Numeric = Union[int, float]
print(get_args(Numeric))
print(isinstance(3, get_args(Numeric)))
(<class 'int'>, <class 'float'>)
True
Optional
引数がある型、または None
となるときは Optional
型を使う。与えても与えなくてもよいオプショナル引数の型アノテーションに使われる。
from typing import Optional
def myfunc(x: Optional[str]=None):
if x is None:
print('Hello, world!')
else:
print(x)
Optional[T]
は T | None
または Union[T, None]
と等価である。
Any
任意の型を表す。
from typing import Any
x: Any
解釈としては
- どんな型の値であっても構わないよ
- どんな型の値が入ってくるかわからないよ
であるが、前者と後者は似て非なるものであり、Any
と書くくらいなら「どんな型の値が入ってきても動く」ようなプログラムになっているべきである。
Any
は静的型チェックのための型アノテーションであり、isinstance
関数の引数に使うことはできない。isinstance(x, Any)
はエラーになる(エラーにならなかったとして、常に True
になるので意味のあるプログラムではない)。
list, tuple, set, dict
Python 3.9 以降では標準の list
, tuple
, set
, dict
といった型に list[T]
のような記法がサポートされ、これで T
型のリストであることを表すことができる。
表記 | 意味 |
---|---|
List[T] |
T 型のリスト |
Tuple[S, T] |
ひとつ目の要素の型が S でふたつ目の要素の型が T となるタプル |
Set[T] |
T 型の集合 |
Dict[KT, VT] |
KT 型のキーと VT 型の値を持つ辞書 |
である。
これも静的型チェックのための型アノテーションであり、isinstance
関数の引数に使うことはできない。実行時にチェックしたければ最初のほうで作った is_instance_list
のような補助関数を作るしかない。
# int 型のリスト
x: list[int] = [1, 2, 3, 4, 5]
# str 型と int 型のタプル
y: tuple[str, int] = ('a', 1)
# 文字列型の集合
z: set[str] = {'a', 'b', 'c'}
# str 型のキーと int 型の値を持つ辞書
w: dict[str, int] = { 'a': 1, 'b': 2, 'c': 3 }
# 単にリストであるかどうかのチェックはできる
print(isinstance(x, list))
# int 型のリストであるかどうかといったチェックはできない
# 以下のコードはエラーになります
# print(isinstance(x, list[int]))
Iterable
Iterable
は isinstance
関数の引数に与えた場合、特殊メソッド __iter__()
が実装されているクラスにマッチする。簡単に言えば for
文でイテレート可能な大抵のオブジェクトは Iterable
で検出できる[10]。
静的型チェックにおいては Iterable[T]
とすることで、for
文によってイテレートしたときに取り出される個々の要素が T
型であることを表すことができる。isinstance
関数の引数に Iterable[T]
のように T
を特定する与え方はできない。
from collections.abc import Iterable
# 以下はすべて False になります
print(isinstance(1, Iterable)) # False
print(isinstance(3.14, Iterable)) # False
print(isinstance(True, Iterable)) # False
# 以下はすべて True になります
print(isinstance('abcde', Iterable)) # True
print(isinstance([1, 2, 3, 4, 5], Iterable)) # True
print(isinstance((1, 2, 3), Iterable)) # True
print(isinstance({1, 2, 3, 4, 5}, Iterable)) # True
print(isinstance({ 'a': 1, 'b': 2, 'c': 3 }, Iterable)) # True
# 以下はエラーになります
# print(isinstance([1, 2, 3, 4, 5], Iterable[int]))
Mapping
Mapping
は特殊メソッド __getitem__()
, __iter__()
, __len__()
が実装されているクラスにマッチする。簡単に言えば、辞書のように xs[key]
と書いたときに key
に対応する値が取り出されるような構造にマッチする(ただしリストのようなインデックスによるアクセスは除く)。
イメージとしては Iterable
がリストを抽象化したもので、Mapping
が辞書を抽象化したものである。注意点として Mapping
にマッチするクラスは必ず Iterable
にもマッチするので、入力がリストなのか辞書なのかを区別したい場合は必ず Mapping
かどうかを先に判定しなければならない。
例のごとく Mapping[KT, VT]
のようにキーと値の型を指定することができるが、これは静的型チェックのための表記で isinstance
関数の引数として使うことはできない。
from collections.abc import Mapping
# 以下はすべて False になります
print(isinstance(1, Mapping)) # False
print(isinstance(3.14, Mapping)) # False
print(isinstance(True, Mapping)) # False
print(isinstance('abcde', Mapping)) # False
print(isinstance([1, 2, 3, 4, 5], Mapping)) # False
print(isinstance((1, 2, 3), Mapping)) # False
print(isinstance({1, 2, 3, 4, 5}, Mapping)) # False
# 以下のみ True になります
print(isinstance({ 'a': 1, 'b': 2, 'c': 3 }, Mapping)) # True
Callable
Callable
は特殊メソッド __call__()
が実装されているクラスにマッチする。つまり関数のように呼び出せる対象が Callable
にマッチする。typing
モジュールと collections.abc
モジュールの両方に実装されているが、typing
のほうが非推奨となっているような記述はない(使い分けについて何か知っていたら教えてくださると幸いです)。
from typing import Callable
# from collections.abc import Callable
x = 3
def f(x):
return 3 * x
print(isinstance(x, Callable)) # False
print(isinstance(f, Callable)) # True
静的型チェックにおいて関数の型を指定するときには Callable[[ArgTypes], ReturnType]
の形式で記述する。
# たとえばこのような関数の型は
def f(x: int, y: str) -> float:
z = float(x) * float(y)
return z
# 以下のように書けます
Callable[[int, str], float]
戻り値がない場合は ReturnType
は None
となる。また、引数の型がどうでもよい場合、ellipsis[11] によって Callable[..., ReturnType]
のように省略できる。
クラスの継承と型チェック
ある基底クラス BaseClass
を継承した派生クラス DerivedClass1
, DerivedClass2
, ... が存在するとき、BaseClass
さえ継承していれば許容するという型チェックをしたい場合がある。
このとき実行時の型チェックにおいては単に isinstance
関数の引数に BaseClass
を与えてやればよい。多段階継承していても継承元を遡って判定が行われる。
class User:
pass
class BasicUser(User):
pass
class ProUser(BasicUser):
pass
x = BasicUser()
y = ProUser()
print(isinstance(x, User)) # True
print(isinstance(x, BasicUser)) # True
print(isinstance(x, ProUser)) # False
print(isinstance(y, User)) # True
print(isinstance(y, BasicUser)) # True
print(isinstance(y, ProUser)) # True
静的型チェックに記述するときは typing
モジュールの Type
を使う。
from typing import Type
class User:
pass
class BasicUser(User):
pass
class ProUser(BasicUser):
pass
# x は User クラスを継承してさえいればよい
def show_profile(x: Type[User]):
...
str, Iterable, Mapping を併用するときの注意点
ここまででもちょくちょく注意書きをしてきたが、str
, Iterable
, Mapping
の間には以下のような包含関係があるので注意する。
包含関係 |
---|
str < Iterable
|
Mapping < Iterable
|
つまり Iterable
は str
にも Mapping
にもマッチしてしまう。たとえば「与えられたのが文字列なのか、文字列のリストなのか、文字列をキーに持つ辞書なのかを判定したい」というとき、以下のコードはどこもおかしくないように見えるしエラーも吐かないが、意図した通りには動作しない。
from typing import Any, Union
from collections.abc import Iterable, Mapping
def myfunc(x: Union[str, Iterable[str], Mapping[str, Any]]):
if isinstance(x, Iterable):
if all([ isinstance(s, str) for s in x ]):
print('x は文字列のリストです')
else:
raise TypeError("...")
elif isinstance(x, Mapping):
if all([ isinstance(s, str) for s in x.keys() ]):
print('x は文字列をキーに持つ辞書です')
else:
raise TypeError("...")
elif isinstance(x, str):
print('x は文字列です')
myfunc('abc')
myfunc(['abc', 'def', 'ghi'])
myfunc({'abc': 1, 'def': 2, 'ghi': 3})
x は文字列のリストです
x は文字列のリストです
x は文字列のリストです
上記のコードは x
が Iterable
であり、その個々の要素が str
型であることまで確かめているが、文字列を与えても辞書を与えても一番最初の条件分岐に引っかかっている。これは
- 文字列は
Iterable
であり、イテレートしたときには個々の文字が文字列として取り出される - 辞書は
Iterable
であり、イテレートしたときにはキーが順番に取り出される
という Python の仕様による。したがって大抵の場合、Iterable
は条件分岐の最後に持ってこなければならない。
from typing import Any, Union
from collections.abc import Iterable, Mapping
def myfunc(x: Union[str, Iterable[str], Mapping[str, Any]]):
if isinstance(x, str):
print('x は文字列です')
elif isinstance(x, Mapping):
if all([ isinstance(s, str) for s in x.keys() ]):
print('x は文字列をキーに持つ辞書です')
else:
raise TypeError("...")
elif isinstance(x, Iterable):
if all([ isinstance(s, str) for s in x ]):
print('x は文字列のリストです')
else:
raise TypeError("...")
myfunc('abc')
myfunc(['abc', 'def', 'ghi'])
myfunc({'abc': 1, 'def': 2, 'ghi': 3})
x は文字列です
x は文字列のリストです
x は辞書です
おしまい
Happy Python Life!!
-
暗黙の型変換はヤベェ。問題が発覚するのは大抵人が死んでからだ。 ↩︎
-
そこまでするならそれこそ型付きの言語使えばいいじゃんという話になるし、頻繁に書き換えが発生するデータ分析においてはどうでもいい型エラーが頻発して検知したいエラーが埋もれてしまう。 ↩︎
-
プログラムの異常停止がそのまま信用の失墜に繋がる Web サービスでは、むしろ「プログラムが停止せず正常に動いているように見える」ことが重要である。その意味では Ruby, PHP, Javascript などの言語が暗黙の型変換によってよしなに動き続けることにはそれなりの理がある。 ↩︎
-
他にも必要に応じて静的型チェックやテストなどで品質を担保する。 ↩︎
-
プログラムがプログラマの想定した通りに停止するならばそれは正常な停止である。 ↩︎
-
昨今のプログラミング言語なら静的型付けの言語でも auto で型推論してくれてるレベルだろう。 ↩︎
-
型チェック用の関数をたくさん作ってしまうと、型チェック用の関数が正しく動いていることを担保する必要が出てきて本末転倒な気がするし、頻繁に使っている型チェック用の関数に変更を加えたときはそれがバグの原因になってしまいかねない。よって比較的短い単純な
if
文くらいなら毎回書いたほうがよく、いちいち関数の中身を見に行かなくてもひと目見れば正しいと思える状態にしておくのがよいと思っている。 ↩︎ -
バグの発生ポイントとしてよく知られているのは内部状態を持つプログラム、要するに変数の書き換えを頻繁に行なうプログラムである。つまり
while
文で手書きされた有限オートマトンは最悪そのものであり、独自フォーマットの設定ファイルに対して独自のパーサーを手書きするなど素人はやりがちだがまぁほぼ 100% 目も当てられない状態になる。よい子のみんなは設定ファイルの仕様をjson
など有名なものに合わせるか、せめて正規表現を使ってパースするなど工夫しよう。 ↩︎ -
一時的に使用するために与えたはずの値がそれ以降も継続して使用される。さらに同メソッド内の以降のコードでも
data_dir
ではなくself.data_dir
を参照するコードになっていた場合、プログラムはこのバグを原因とするエラーを吐くことはないので、まぁ気づかない。ご愁傷様です。 ↩︎ -
Python の
for
文による反復は__iter__()
が実装されていなければ__getitem__()
を使ってイテレートするため、Iterable
ですべての iterable なオブジェクトを検出することはできない。x
がイテレート可能ならばiter(x)
を実行することでイテレータが返るのでこれによって iterable なオブジェクトをすべて検出することができる。しかしx
がイテレート可能でない場合は例外が送出されるので、例外処理を用いて型チェックをすることになり、これを許容するくらいならIterable
を使っておいてすり抜けたら別途対処するほうがまだよい気がする。 ↩︎ -
Python ではピリオド3つ
...
は省略を意味する ellipsis という特別な値になる。ここまでのソースコード中にもソースコードを省略する意味で...
を記述していたが、そのままコピペしても動くのは...
が ellipsis という値として解釈されて文法として正しくなるからである。 ↩︎
Discussion