🌅

Python で実行時型チェックをしよう

2023/01/07に公開

私の彼氏、靴磨きクリームを「シュークリームには違いないだろ」って食べちゃうのよ
                               —— 暗黙の型変換 ——

はじめに

最近 Python を書いていて少しでも規模が大きくなってくると、事あるごとに型アノテーションと型チェックを仕込むようになってきた。そんなに気になるなら型のある言語を使えばいいじゃないという話だが、データ分析などで書きながら試行錯誤がしやすいという意味では Python のガバさはむしろ有用だと思っている。そうはいうたかてガバすぎるのでちょうどよく引き締めましょうね、という現状の私の局所解を紹介する。

Python は静的型チェックを持たない言語だが、暗黙の型変換は極力行わず、実行時ではあるが型的に明らかにヤバいコードは割とエラーを吐いてくれるので Lightweight Language の中では比較的良心がある[1]。たとえば Python では以下のようなコードは実行できない。

Python には良心または良識があることを証明するコード
# 以下のコードがエラーを吐かずに動作するプログラミング言語には人の心がない
1 + '0'

Python にはバージョン 3.5 以降、型アノテーションという機能が搭載されており、変数や関数の引数などにそれがどんな型であるか型注釈をつけることができる。だからといって型チェックをしてくれるわけでもなく現状はお飾りに過ぎないのだが、おそらく Python 4 が登場すればそのときには何らかのチェック機構が搭載されるものと踏んでいる。

サードパーティの mypy というライブラリを使えば静的型チェックをしてくれるのだが、私はあまり利便性を感じておらず使っていない[2]ため、この記事では解説しない。

想定していない入力に対してプログラムは異常停止すべきである

少なくともデータ分析において、想定していない入力が与えられたとき、プログラムは異常停止すべきである。想定していない入力が与えられたということは、そのときのプログラムの挙動は想定していなかったものになる。もしそれでプログラムが停止しなければ、分析者はデータから誤った結論を導いて相手に報告することになる

プログラムが異常停止していれば失うのは修正のためのちょっとした時間で済むが、異常停止せずにバグったまま報告した場合に失うのは信用である。時間は交渉によって確保できるが、信用を失えば交渉の機会すら与えられない。よって Python でデータ分析をする場合にはバグを検知するためのコードを随所に仕込むことになる[3][4]

そもそもプログラムのバグとは「プログラムが想定通りの挙動をしていないのにプログラムが停止しなかった」という状態を指すものである。

プログラムの挙動は想定通り プログラムは停止 結果
である しない ヨシ!!
である した ヨシ!![5]
ではない しない バグ(死)
ではない した 修正すればヨシ

プログラムのバグの原因は主に以下の4パターンに大別できる。

  1. プログラムにタイプミスや変数・関数の意図しない上書きなどがあった
  2. プログラマの組んだロジックに誤り(条件の網羅抜けなど)があった
  3. プログラマが関数の動作を知らなかった/誤解していた
  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))
output
<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))
output
[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))
output
[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 を返す。

小技:明示的な型変換

データ分析において分析者自身がいじくらねばならないのはほぼデータの読み書きと加工の部分であり、扱うデータの型としても

  • numpyndarray
  • pandasSeriesDataFrame
  • pathlibPath
  • それらのリスト、辞書、集合など

がほとんどである。それ以外の機械学習など高度な分析手法に関しては誰かが作ってくれたクラスや関数が用意されているので、それを呼び出すだけであってバグの原因にはならない。

小技として、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

実際に書くコードはもっと複雑な処理になるが、本質としては strPath と それ以外 の場合分けになる。また、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行を見た時点で

  • ああ、この関数の引数は strPath のどちらで与えてもよいのだな
  • そして最初に Path に型を強制してしまうから、この関数内では Path 以外のケースを想定しなくてよいのだな

と分かるので、ソースコードを読む人にとっても大分読みやすくなるし、join 関数を書き換える際にもかなり安心して編集できる。

このテクニックは numpypandas などでも使える。変換が一筋縄ではいかない組み合わせもあるので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_dirstr または Path で与えてもよい。
    • どちらで与えても必ず Path 型で保持される。
    • 保存機能を使わない場合は与えなくてもよく、その場合は None を保持する。
  • 初期化時に data_dir を与えなかった場合、self.data_dir を参照しようとすると RuntimeError となる。
  • save メソッドを呼び出すときに data_dirstr または Path で与えてもよい。
    • 与えた場合は初期化時に与えた data_dir よりも優先され、とりあえず他の場所に一時保存しておきたい場合やデータをコピーしたい場合に便利である。
    • 与えなかった場合には初期化時に与えた data_dir が使用される。初期化時にも与えていなかった場合には RuntimeError が発動する。
    • 与えようと与えまいと、save メソッドの処理が続行する場合、data_dir には Path 型の値が入っている。

つまりユーザは好きなタイミングで data_dir を指定することができるし、与えるときは strPath のどちらにすべきかも悩まなくてよくなるそして間違った使い方をすればエラーを出してくれる。かなり堅牢で利便性の高いプログラムになる。

堅牢で利便性の高い引数の受け取り方ができるクラス
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]

と書いた場合、xint または 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))
output
True
True

以下のようにして Union 型の新しい型を作ることができる。

Numeric = Union[int, float]

print(isinstance(3, Numeric))
output
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)))
output
(<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

Iterableisinstance 関数の引数に与えた場合、特殊メソッド __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]

戻り値がない場合は ReturnTypeNone となる。また、引数の型がどうでもよい場合、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

つまり Iterablestr にも 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})
output
x は文字列のリストです
x は文字列のリストです
x は文字列のリストです

上記のコードは xIterable であり、その個々の要素が 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})
output
x は文字列です
x は文字列のリストです
x は辞書です

おしまい

Happy Python Life!!

脚注
  1. 暗黙の型変換はヤベェ。問題が発覚するのは大抵人が死んでからだ。 ↩︎

  2. そこまでするならそれこそ型付きの言語使えばいいじゃんという話になるし、頻繁に書き換えが発生するデータ分析においてはどうでもいい型エラーが頻発して検知したいエラーが埋もれてしまう。 ↩︎

  3. プログラムの異常停止がそのまま信用の失墜に繋がる Web サービスでは、むしろ「プログラムが停止せず正常に動いているように見える」ことが重要である。その意味では Ruby, PHP, Javascript などの言語が暗黙の型変換によってよしなに動き続けることにはそれなりの理がある。 ↩︎

  4. 他にも必要に応じて静的型チェックやテストなどで品質を担保する。 ↩︎

  5. プログラムがプログラマの想定した通りに停止するならばそれは正常な停止である。 ↩︎

  6. 昨今のプログラミング言語なら静的型付けの言語でも auto で型推論してくれてるレベルだろう。 ↩︎

  7. 型チェック用の関数をたくさん作ってしまうと、型チェック用の関数が正しく動いていることを担保する必要が出てきて本末転倒な気がするし、頻繁に使っている型チェック用の関数に変更を加えたときはそれがバグの原因になってしまいかねない。よって比較的短い単純な if 文くらいなら毎回書いたほうがよく、いちいち関数の中身を見に行かなくてもひと目見れば正しいと思える状態にしておくのがよいと思っている。 ↩︎

  8. バグの発生ポイントとしてよく知られているのは内部状態を持つプログラム、要するに変数の書き換えを頻繁に行なうプログラムである。つまり while 文で手書きされた有限オートマトンは最悪そのものであり、独自フォーマットの設定ファイルに対して独自のパーサーを手書きするなど素人はやりがちだがまぁほぼ 100% 目も当てられない状態になる。よい子のみんなは設定ファイルの仕様を json など有名なものに合わせるか、せめて正規表現を使ってパースするなど工夫しよう。 ↩︎

  9. 一時的に使用するために与えたはずの値がそれ以降も継続して使用される。さらに同メソッド内の以降のコードでも data_dir ではなく self.data_dir を参照するコードになっていた場合、プログラムはこのバグを原因とするエラーを吐くことはないので、まぁ気づかない。ご愁傷様です。 ↩︎

  10. Python の for 文による反復は __iter__() が実装されていなければ __getitem__() を使ってイテレートするため、Iterable ですべての iterable なオブジェクトを検出することはできない。x がイテレート可能ならば iter(x) を実行することでイテレータが返るのでこれによって iterable なオブジェクトをすべて検出することができる。しかし x がイテレート可能でない場合は例外が送出されるので、例外処理を用いて型チェックをすることになり、これを許容するくらいなら Iterable を使っておいてすり抜けたら別途対処するほうがまだよい気がする。 ↩︎

  11. Python ではピリオド3つ ... は省略を意味する ellipsis という特別な値になる。ここまでのソースコード中にもソースコードを省略する意味で ... を記述していたが、そのままコピペしても動くのは ... が ellipsis という値として解釈されて文法として正しくなるからである。 ↩︎

Discussion