Pythonの型ヒントと共に進化するコード(#5: Union と Optional)
前回の 4 日目の記事ではTypedDict を導入し、辞書へのキーアクセスを安全にしました。
しかし、記事の終わりで関数の戻り値に関する新たな課題が浮き彫りになりました。
本日はこの課題に取り組みます。
今回の課題:None を返す可能性が型に表現されていない
fetch_and_format_address 関数を見てみましょう。
def fetch_and_format_address(zipcode: str, include_kana: bool): # ⚠️ 戻り値の型注釈がない
"""郵便番号から住所を取得し、整形して返す"""
# ...
try:
response = requests.post(api_url, json={"zipcode": zipcode})
if response.status_code != 200:
return None # 👈 エラー時は None を返す
# ...
return json.dumps(result, indent=2, ensure_ascii=False)
except requests.exceptions.RequestException as e:
return None # 👈 ここでも None を返す
この関数は正常時に JSON 文字列を、エラー時に None を返します。
しかし戻り値の型注釈がないため、呼び出し側(型チェッカー)はこの事実を知る術がありません。
result = fetch_and_format_address("9999999", include_kana=True)
print(result.upper()) # ☠️ None が返ってきたら AttributeError
型チェッカーは戻り値の型注釈がないことは警告してくれますが、.upper()(str 型のメソッド)を None に対して呼び出そうとしている本質的な危険性は検出できません。
処方箋:「ないかもしれない」を表現する Union と Optional
この問題を解決する鍵は「値が存在しないかもしれない」という可能性をコードと型の両方で明確に表現することです。そのためのツールが Union 型です。
Union 型は、複数の型のうちのいずれか 1 つであることを示す型です。Python 3.10 以降では、|(パイプ)演算子を使って直感的に表現できます。
# 'int' または 'str' のどちらかの型を持つ変数
value: int | str
value = 100 # OK
value = "hello" # OK
# value = [] # 型エラー
そして、この Union 型と「何もない」を表す None を組み合わせることで、「値が存在するか、あるいは存在しない(None)か」という状態を表現できます。これが一般に Optional 型として知られているものです。
str | None は、「文字列、あるいは None」を意味します。これは typing.Optional[str] と書くのと同じ意味ですが、| 記法の方がモダンで読みやすいです。
この仕組みを使って関数の戻り値に型注釈を追加してみます。
# 👉 関数の戻り値に str | None を定義する
def fetch_and_format_address(zipcode: str, include_kana: bool) -> str | None:
"""
郵便番号から住所を取得し、整形して返す。
取得に失敗した場合はNoneを返す。
"""
# ...
戻り値の型 str | None が呼び出し元に対して「この関数は None を返す可能性があるから、適切に処理してください」という契約を突きつけます。
もし呼び出し元が None チェックを怠れば型チェッカーが警告してくれます。
result = fetch_and_format_address("1000001", include_kana=True)
# print(result.upper()) # 型エラー! 'NoneType' には 'upper' 属性がない可能性がある
if result is not None:
# None の判定を入れることで、このブロック内では result は str型であることが保証される(型絞り込み)
print(result)
このようにして実行時エラーの可能性を開発中に排除できるのです。
コードの進化:スクリプトへの適用
それでは、この安全なアプローチを address_fetcher.py に適用します。
変更前のコード
def fetch_and_format_address(zipcode: str, include_kana: bool):
"""郵便番号から住所を取得し、整形して返す"""
# ...(戻り値の型注釈がない)
Union型の適用
戻り値の型を str | None に変更します。
# 👉 戻り値の型を str | None に変更
def fetch_and_format_address(zipcode: str, include_kana: bool) -> str | None:
"""郵便番号から住所を取得し、整形して返す"""
# ...
この変更によって関数の戻り値が str | None であることが型レベルで明示されました。
result = fetch_and_format_address("1000001", include_kana=True)
# これは型エラー! result は str | None なので、None の可能性がある
# print(result.upper())
# None チェックを入れることで、ブロック内では result は str 型として扱える
if result is not None:
print(result.upper()) # OK
if result is not None: で分岐した後、型チェッカーは result を str 型として認識してくれます。これを 型の絞り込み(Type Narrowing) と呼びます。
Appendix:「エラー発生時に `None` を返す設計ってどうなの?」という話
None を返す以外にも以下の選択肢があるかと思います。
- 例外を投げる
- 成功/失敗を明確に区別できる専用の型を使う
ちなみに、↑ の「成功と失敗を型で分ける」という考え方を具体的に実装したものの 1 つが Result 型 です。
Rust や Haskell などの言語では標準で用意されており、成功時の値と失敗時の理由を型レベルで安全に扱えます。
Python には標準の Result 型はありませんが、自作したり外部のライブラリを使うことで同様のパターンを実現できます(これは後の章で扱います)。
この記事では型として値の「有無」をどう表現するかというポイントに集中したいので、まずは最もシンプルなNoneを返す方式を採用しています。
得られたものと、次の課題
今回のリファクタリングで、このコードは値の「存在」に関する安全性を手に入れました。
関数が None を返す可能性を型で明示したことで、呼び出し元は None チェックを強制されるようになりました。
このコードは、前回導入したTypedDict でデータの 「形」 を、Union と None でデータの 「存在」 を保証できるようになりました。コードは少しずつ着実に堅牢になっています。
しかし、ここで一度コード全体を俯瞰してみましょう。特に関数のシグネチャ(引数や戻り値の定義)に注目してください。
def fetch_and_format_address(zipcode: str, include_kana: bool) -> str | None: ...
引数のzipcode: str という型注釈は本当に十分でしょうか?
もしこのスクリプトが成長し、以下のように prefecture_code(都道府県コード)がstrとして追加された場合どのようなリスクがありますか。
# 関数の引数に prefecture_code が追加された
def fetch_and_format_address(zipcode: str, prefecture_code: str, include_kana: bool) -> str | None: ...
開発者の不注意でfetch_and_format_address(prefecture_code, ...)のように誤った位置で引数の値を渡してしまうかもしれません。
型チェッカーはどちらもstrなので、この間違いを警告してはくれません。
また、辞書型の引数が必要になった場合はどうでしょうか。たとえば HTTP ヘッダーを渡す辞書型の引数が追加された場合、 headers: dict という型注釈では曖昧です。
# headers の中身は何を入れるべき?
def fetch_and_format_address(..., headers: dict) -> str | None: ...
fetch_and_format_address(..., headers={"User-Agent": "app/1.0"}) # OK?
fetch_and_format_address(..., headers={123: "value"}) # OK?
この辞書のキーと値はどのような型であるべきでしょうか? dict というだけでは、それが文字列の辞書なのか、それとも全く別の何かなのか、コードからは読み取れません。
コードの安全性は高まりましたが、今度はコードの「意図」と「意味」をどう型で表現するかという新しい課題が見えてきました。
次回予告
明日は NewType と TypeAlias を使い、str や dict といった汎用的な型にドメイン固有の意味を与え、より意図が明確で誤解の少ないコードへと進化させていきます。
処方後のコードはこちら
今回の主題は str | None ですが、せっかくなので前回紹介した TypedDict を出力結果のスキーマにも応用しています。
import requests
import json
from typing import TypedDict
# APIレスポンスのスキーマを定義
class AddressDict(TypedDict):
zipcode: str
prefecture: str
prefecture_kana: str
city: str
city_kana: str
town: str
town_kana: str
# 👉 出力結果のスキーマを定義
class FormattedAddressDict(TypedDict):
zipcode: str
full_address: str
prefecture: str
city: str
town: str
class FormattedAddressWithKanaDict(FormattedAddressDict):
full_address_kana: str
def _build_full_address(address_data: AddressDict) -> str:
"""
住所データからフル住所文字列を生成する。
"""
return address_data["prefecture"] + address_data["city"] + address_data["town"]
# 👉 戻り値の型を str | None に変更
def fetch_and_format_address(zipcode: str, include_kana: bool) -> str | None:
"""郵便番号から住所を取得し、整形して返す"""
api_url = "https://api.zipcode-jp.example/v1/address"
try:
response = requests.post(api_url, json={"zipcode": zipcode})
if response.status_code != 200:
print(f"Error: Failed to fetch address. Status: {response.status_code}")
return None
address_data: AddressDict = response.json()
full_address = _build_full_address(address_data)
result: FormattedAddressDict = {
"zipcode": zipcode,
"full_address": full_address,
"prefecture": address_data["prefecture"],
"city": address_data["city"],
"town": address_data["town"],
}
if include_kana:
result_with_kana: FormattedAddressWithKanaDict = {
**result,
"full_address_kana": (
address_data["prefecture_kana"]
+ address_data["city_kana"]
+ address_data["town_kana"]
),
}
return json.dumps(result_with_kana, indent=2, ensure_ascii=False)
return json.dumps(result, indent=2, ensure_ascii=False)
except requests.exceptions.RequestException as e:
print(f"An error occurred during API request: {e}")
return None
except KeyError as e:
print(f"Error processing data: Invalid data structure - {e}")
return None
# 実行例
if __name__ == "__main__":
result = fetch_and_format_address("1000001", include_kana=True)
if result is not None:
print(result)
Discussion