Python | テスト・設計・品質:Literal

Python Python
スポンサーリンク

Literalって何?一言でいうと「値そのものを型にする」

Literal は、型ヒントの世界で
「この引数(または値)は、この“決まった値”しか受け付けません」
と表現するための道具です。

普通の型ヒントは「int」「str」など“型”を指定しますが、
Literal は「1」「”DEBUG”」「”INFO”」のように“具体的な値”を指定します。

つまり、

「int のどれでもいい」ではなく
「1 か 2 だけ」「’DEBUG’ か ‘INFO’ か ‘ERROR’ だけ」

という制約を、型として書けるようになります。

ここが、Union や Optional と比べたときの一番の特徴です。


まずは超シンプルな例でLiteralの感覚をつかむ

ログレベルを表すLiteralの例

例えば、ログレベルを文字列で指定する関数を考えます。

def set_log_level(level: str) -> None:
    ...
Python

こう書いてあると、「どんな文字列でも渡せる」ように見えます。
でも、本当は "DEBUG", "INFO", "ERROR" だけを許したいとします。

そこで Literal の出番です。

from typing import Literal

LogLevel = Literal["DEBUG", "INFO", "ERROR"]

def set_log_level(level: LogLevel) -> None:
    ...
Python

こうすると、「この関数に渡してよいのは "DEBUG", "INFO", "ERROR" のどれかだけ」ということが、型としてはっきりします。

Python 3.11 以降なら、typing ではなく typing からそのまま Literal を使えますが、使い方は同じです。

何が嬉しいのか(エディタと静的解析が賢くなる)

例えば、こう書いたとします。

set_log_level("DEBUG")   # OK
set_log_level("INFO")    # OK
set_log_level("WARN")    # 本当はダメにしたい
Python

level: str だと、どれも「型的には OK」です。
でも Literal["DEBUG", "INFO", "ERROR"] にしておくと、
エディタや静的解析ツールが「WARN は許されていない値だよ」と教えてくれます。

実行前に「ありえない値」を弾けるようになる、というのが Literal の強さです。


LiteralとUnionの違いをちゃんと整理する

Unionは「型のどれか」、Literalは「値のどれか」

Union はこうでした。

from typing import Union

value: Union[int, str]  # int か str のどちらか
Python

これは「型レベルの“どれか”」です。

一方 Literal はこうです。

from typing import Literal

value: Literal[1, 2, 3]  # 1 か 2 か 3 のどれか
Python

これは「値レベルの“どれか”」です。

もう少し混ぜて書くと、こういうこともできます。

value: Literal[0, 1]  # 0 か 1 だけ
mode: Literal["r", "w", "a"]  # ファイルモード
Python

Union は「型のバリエーション」、
Literal は「値のバリエーション」を表現するものだ、と覚えておくと整理しやすいです。


実践的な例1:モードやフラグをLiteralで縛る

ファイルモードをLiteralで表現する

Python の open 関数の mode には、 "r", "w", "a" など、決まった文字列しか渡しません。

自分でラッパー関数を書くときに、こう書けます。

from typing import Literal

FileMode = Literal["r", "w", "a"]

def open_file(path: str, mode: FileMode) -> str:
    return f"open {path} with {mode}"
Python

この関数に対して、

open_file("data.txt", "r")    # OK
open_file("data.txt", "x")    # 型的には NG(Literal に含まれていない)
Python

という区別がつきます。

実行時にエラーになる前に、
「そのモードはサポートしていないよ」とツールが教えてくれるわけです。

フラグ的な引数をLiteralで表す

例えば、「並び順」を指定する関数を考えます。

def sort_items(order: str) -> None:
    ...
Python

本当は "asc""desc" だけを許したいなら、こう書けます。

from typing import Literal

Order = Literal["asc", "desc"]

def sort_items(order: Order) -> None:
    ...
Python

これで、 "ascending" みたいな微妙なスペルミスも、
静的解析で検出できるようになります。


実践的な例2:戻り値にLiteralを使って「状態」を表す

処理結果をLiteralで返す

例えば、「処理が成功したかどうか」を文字列で返す関数を考えます。

def process() -> str:
    ...
Python

これだと、「何が返ってくるのか」が曖昧です。

本当は "ok""error" のどちらかだけを返す設計なら、
Literal を使ってこう書けます。

from typing import Literal

Result = Literal["ok", "error"]

def process() -> Result:
    ...
Python

これで、「この関数は "ok""error" しか返さない」という仕様が、型として固定されます。

呼び出し側も、こういうコードを書きやすくなります。

result = process()
if result == "ok":
    ...
elif result == "error":
    ...
Python

もし "unknown" みたいな値を返すように実装を変えたら、
静的解析ツールが「型と合っていない」と教えてくれます。


Literalがテストと品質にどう効いてくるか

テストケースの「列挙」がしやすくなる

Literal を使うと、「取りうる値」が型として列挙されます。

例えば、さっきの LogLevel を思い出してください。

LogLevel = Literal["DEBUG", "INFO", "ERROR"]

def set_log_level(level: LogLevel) -> None:
    ...
Python

この型を見た時点で、テストの観点がはっきりします。

“DEBUG” のとき
“INFO” のとき
“ERROR” のとき

少なくともこの 3 パターンはテストしたくなりますよね。

型に Literal がないと、「たまたま ‘DEBUG’ だけテストして終わり」になりがちです。
Literal は、「仕様上の全パターン」を型として書き出すことで、
テストケースの漏れを減らしてくれます。

「ありえない値」を早期に潰せる

Literal を使っていると、
「仕様上ありえない値」がコードに紛れ込んだときに、
静的解析で検出できます。

例えば、誰かがこう書いたとします。

set_log_level("debug")  # 小文字
Python

Python 的には普通に動きますが、
仕様としては「大文字だけ」を許したいかもしれません。

Literal で "DEBUG" だけを許しておけば、
この時点で「それは許されていない値だよ」と教えてもらえます。

これは、バグを「実行前」に潰すための強力な仕組みです。


Literalを使うときの注意点と「やりすぎ防止」

なんでもLiteralにすると窮屈になる

Literal は強力ですが、
何でもかんでも Literal で縛ると、コードが窮屈になります。

例えば、こういうのはやりすぎです。

from typing import Literal

Age = Literal[18, 19, 20, 21, 22, 23, 24, 25]
Python

年齢を「18〜25 のどれか」に限定したい気持ちは分かりますが、
これは型でやる話ではありません。

こういうのは、普通に int にしておいて、
バリデーションでチェックする方が自然です。

Literal が向いているのは、

モード
フラグ
状態
コマンド名

のように、「取りうる値が少なく、列挙できるもの」です。

仕様が頻繁に変わるところには向かない

Literal は、「値の集合」を型として固定します。

ということは、
仕様が変わるたびに型も変えないといけません。

例えば、ログレベルに "TRACE" を追加したくなったら、

LogLevel = Literal["DEBUG", "INFO", "ERROR", "TRACE"]
Python

と書き換える必要があります。

これは悪いことではありませんが、
「毎週のように増減する値」を Literal で縛るのはしんどくなります。

仕様が安定しているところ、
あるいは「変わるときはちゃんと型も直したいところ」に使うのが向いています。


初心者がLiteralとどう付き合うといいか

まずは「文字列のモード・フラグ」から使ってみる

最初の一歩としては、

ログレベル
並び順(”asc” / “desc”)
ファイルモード(”r” / “w” / “a”)
ステータス(”ok” / “error”)

のような「少数の文字列の選択肢」を Literal で表現してみるのがオススメです。

そこから、

この値のバリエーションは型で縛るべきか?
それとも、ただの文字列+バリデーションで十分か?

という感覚を少しずつ育てていくと、
Literal の「ちょうどいい使いどころ」が見えてきます。

「仕様を列挙するための道具」として意識する

Literal を付けるということは、

「この引数(または戻り値)は、この値たちのどれかに“必ず”なる」

と仕様レベルで宣言することです。

それはそのまま、

テストで全部の値を試す
呼び出し側で間違った値を渡さない
仕様変更時に型も一緒に見直す

という行動につながります。

Literal は、「仕様を列挙して、コードとテストに落とし込むための道具」だと意識して使うと、
設計と品質の両方が一段上がります。


まとめ(Literalは「値のバリエーションを型で固定する」ための道具)

Literal を初心者目線で整理すると、こうなります。

Literal は、「この値はこの“決まった値のどれか”だけを取る」ということを型で表現する仕組みで、Literal["DEBUG", "INFO", "ERROR"] のように具体的な値を列挙できる。
Union が「型のどれか」を表すのに対して、Literal は「値のどれか」を表し、モード・フラグ・状態など、取りうる値が少ないものを縛るのに向いている。
Literal を使うと、「仕様上の全パターン」が型として見えるようになり、テストケースの漏れを減らしつつ、ありえない値を静的解析で早期に検出できる。
ただし、何でも Literal で縛ると窮屈になるので、仕様が安定していて、値のバリエーションを列挙したい場面(ログレベル、ステータス、モードなど)に絞って使うのが現実的で、設計・品質のバランスも取りやすい。

タイトルとURLをコピーしました