PEP 661 – センチネル値、5年越しに承認
(peps.python.org)- PEP 661 は、
Noneが有効な値である状況で別個に識別できるセンチネル値を作るために、Python 組み込みの呼び出し可能オブジェクトsentinel()と C APIPySentinel_New()を提案している - 既存の
_sentinel = object()というイディオムは、関数シグネチャ内でreprが長く不明瞭であり、明確な型シグネチャ・コピー・pickle 化で問題が生じうる sentinel('MISSING')の呼び出しは、短いreprを持つ新しい一意のオブジェクトを作成し、同じセンチネルを共有したい場合はMISSING = sentinel('MISSING')のように変数へ代入して明示的に再利用する必要がある- センチネルは
isで比較する方法が推奨され、真値として評価され、copy.copy()とcopy.deepcopy()は同じオブジェクトを返し、モジュールから名前で import 可能な場合は pickle 化後も同一性を保持する - 型システムは
int | MISSINGのようにセンチネル自体を型式として使えるようにしており、最新の公式ドキュメントは Python 3.15 の [sentinel](<https://docs.python.org/3.15/library/functions.html#sentinel "(in Python v3.15>)") にある
導入の背景
- 固有のプレースホルダー値である センチネル値(sentinel value) は、関数引数が与えられなかったときのデフォルト値、探索失敗を示す戻り値、欠損データを表す値などに使われる
- Python には通常この用途で使う特別な値
Noneがあるが、None自体が有効な値である文脈では、Noneと区別される別のセンチネル値が必要になる - 2021年5月、python-dev メーリングリストで
traceback.print_exceptionに使われているセンチネル値をよりよく実装する方法が議論された - 既存実装は一般的なイディオム
_sentinel = object()を使っていたが、reprが長すぎて情報量も乏しく、関数シグネチャが読みにくくなっていた>>> help(traceback.print_exception) Help on function print_exception in module traceback: print_exception(exc, /, value=<object object at 0x000002825DF09650>, tb=<object object at 0x000002825DF09650>, limit=None, file=None, chain=True) - 議論の過程で、既存のセンチネル実装にほかの問題もあることが確認された
- 一部のセンチネルには固有の型がなく、センチネルをデフォルト値として使う関数の明確な型シグネチャを定義しにくい
- コピー後に別インスタンスが生成されて
is比較が失敗するなど、予想外の動作をする - 一般的なイディオムの一部には、pickle 化してから unpickle した後にも同様の問題がある
- Victor Stinner は Python 標準ライブラリで使われているセンチネル値の一覧を示し、標準ライブラリ内でも複数の実装方式が使われており、多くの実装が上記の問題の一つ以上を抱えていることが確認された
- discuss.python.org での投票は、39票の時点では明確な結論に至らなかった
- 40% は「現状で問題なく、一貫性は不要」を選択した
- 多数派は、1つ以上の標準化された解決策を選んだ
- 37% は「新しい専用のセンチネルファクトリ/クラス/メタクラスを一貫して使用し、標準ライブラリで公開提供する」という選択肢を選んだ
- 結果が割れたため PEP が作成され、シンプルで優れた標準ライブラリ実装は標準ライブラリの内外の両方で有用だ、という結論に至った
- 標準ライブラリの既存センチネルをすべてこの方式に置き換えることは必須ではなく、それぞれのメンテナーの裁量に委ねられている
- PEP 文書は歴史的文書であり、最新の公式ドキュメントは Python 3.15 の [
sentinel](<https://docs.python.org/3.15/library/functions.html#sentinel "(in Python v3.15>)") にある
設計基準
- センチネルオブジェクトは
is演算子で比較したとき、自分自身とは常に同一であり、ほかのいかなるオブジェクトとも同一であってはならない - センチネルオブジェクトの作成は、シンプルで直感的な 1行のコード であるべき
- 必要なだけ複数の異なるセンチネル値を簡単に定義できるべき
- センチネルオブジェクトは短く明確な
reprを持つべき - センチネルに対して明確な型シグネチャを使えるべき
- コピー後も正しく動作し、pickle 化と unpickle 時に予測可能な動作を持つべき
- CPython 3.x と PyPy3 で動作し、可能であればほかの Python 実装でも動作すべき
- 実装と利用の両方が可能な限りシンプルかつ直感的であり、Python を学ぶ際にまた1つ特別な概念として負担にならないべき
- 標準ライブラリは
sentinelsやsentinelのような PyPI パッケージ実装に依存できないため、標準ライブラリ内で利用できる実装が必要となる
sentinel() の仕様
- 新しい組み込みの呼び出し可能オブジェクト
sentinelが追加される>>> MISSING = sentinel('MISSING') >>> MISSING MISSING sentinel()は位置専用引数nameを1つ受け取り、nameは必ずstrでなければならない- 文字列以外の値を渡すと
TypeErrorが発生する nameはセンチネルの名前およびreprとして使われる- センチネルオブジェクトは2つの公開属性を持つ
__name__: センチネル名__module__:sentinel()が呼び出されたモジュール名
sentinelはサブクラス化できないsentinel(name)を呼び出すたびに新しいセンチネルオブジェクトが返される- 同じセンチネルを複数箇所で使う必要がある場合は、既存の
MISSING = object()イディオムと同様に、変数へ代入したうえで同じオブジェクトを明示的に再利用する必要があるMISSING = sentinel('MISSING') def read_value(default=MISSING): ... - 特定の値がセンチネルかどうかを確認する際は、
Noneと同様にis演算子 を使う方法が推奨される ==比較も、自分自身との比較のときにだけTrueを返すよう期待どおりに動作するif value is MISSING:のような同一性チェックは、通常if value:やif not value:のような真偽値チェックより適切である- センチネルオブジェクトは truthy であり、真偽値評価の結果は
Trueになる- これは任意のクラスのデフォルト動作および
Ellipsisの真偽値と同じである - falsy な
Noneとは異なる
- これは任意のクラスのデフォルト動作および
copy.copy()またはcopy.deepcopy()でセンチネルオブジェクトをコピーすると、同じオブジェクトが返される- 定義されたモジュールから名前でインポート可能なセンチネルは、標準の pickle メカニズムに従って、pickle 化と unpickle 後も同一性を保持する
MISSING = sentinel('MISSING') assert pickle.loads(pickle.dumps(MISSING)) is MISSING sentinel()はセンチネル作成時に、呼び出し元モジュールを__module__属性として記録する- pickle 化ではセンチネルをモジュール名と名前で記録し、unpickle 時にはモジュールをインポートしたうえで名前からセンチネルを取得する
- ローカルスコープで作成され、モジュールのグローバル変数またはクラス属性の対応する名前に代入されていないセンチネルのように、モジュール名と名前でインポートできないセンチネルは pickle 化できない
- センチネルオブジェクトの
reprはsentinel()に渡したnameであり、暗黙のモジュール修飾子は付かない - 修飾付き
reprが必要なら、名前に明示的に含める必要がある>>> MyClass_NotGiven = sentinel('MyClass.NotGiven') >>> MyClass_NotGiven MyClass.NotGiven - センチネルオブジェクトの順序比較は定義されていない
- センチネルは weakref をサポートしない
型付け
- 型付き Python コードでのセンチネル利用を明確かつ簡潔にするため、型システムにセンチネルオブジェクト用の特別扱いが追加される
- センチネルオブジェクトは、型式") の中で自分自身を表す値として使用できる
- これは既存の型システムで
Noneを扱う方法と似ているMISSING = sentinel('MISSING') def foo(value: int | MISSING = MISSING) -> int: ... - 型チェッカーは
NAME = sentinel('NAME')という形式のセンチネル生成を、新しいセンチネルオブジェクトの生成として認識しなければならない sentinel()に渡した名前が代入先の名前と一致しない場合、型チェッカーはエラーを出す必要がある- この構文で定義されたセンチネルは、型式") で使用できる
- そのセンチネル型は、センチネルオブジェクト自身だけを唯一のメンバーとして持つ 完全静的型") を表す
- 型チェッカーは
isおよびis not演算子を使った、センチネルを含むユニオン型の型の絞り込みをサポートしなければならないfrom typing import assert_type MISSING = sentinel('MISSING') def foo(value: int | MISSING) -> None: if value is MISSING: assert_type(value, MISSING) else: assert_type(value, int) - ランタイム実装は型式での使用をサポートするために
__or__と__ror__メソッドを持つ必要があり、これらのメソッドはtyping.Unionオブジェクトを返す - Typing Council はこの提案の型関連部分を支持している
C API
- C 拡張でもセンチネルが有用である可能性があるため、2つの新しい C API 関数が提案されている
PyObject *PySentinel_New(const char *name, const char *module_name)は新しいセンチネルオブジェクトを生成するbool PySentinel_Check(PyObject *obj)はオブジェクトがセンチネルかどうかを確認する- C コードでは、特定のセンチネルかどうかを確認する際に
==演算子を使用できる
互換性とセキュリティ
- 新しい組み込み名を追加すると、現在 bare name
sentinelがNameErrorを発生させると仮定しているコードでは、今後は同じ結果にならない - これは新しい組み込み名の追加で一般的に生じる互換性上の考慮事項である
- 既存のローカル名、グローバル名、インポート名
sentinelは影響を受けない - すでに
sentinelという名前を使っているコードは、新しい組み込みオブジェクトを使うよう調整が必要になる場合があり、組み込み名との衝突を警告するリンターから新たな警告を受ける可能性がある - 新しい組み込み機能については、一般的な文書化手段である docstring、ライブラリドキュメント、「What’s New」セクションで十分だと考えられている
- この提案にセキュリティ上の影響はないと考えられている
参照実装とバックポート
- 参照実装は CPython のプルリクエスト [10] として提供されている
- 以前の参照実装は別の GitHub リポジトリ [7] にある
- 意図された動作のスケッチは次のとおり
class sentinel: """Unique sentinel values.""" __slots__ = ("__name__", "_module_name") def __init_subclass__(cls): raise TypeError("type 'sentinel' is not an acceptable base type") def __init__(self, name, /): if not isinstance(name, str): raise TypeError("sentinel name must be a string") self.__name__ = name self._module_name = sys._getframemodulename(1) @property def __module__(self): return self._module_name def __repr__(self): return self.__name__ def __reduce__(self): return self.__name__ def __copy__(self): return self def __deepcopy__(self, memo): return self def __or__(self, other): return typing.Union[self, other] def __ror__(self, other): return typing.Union[other, self]- typing-extensions モジュールにはバックポートがあるが、現時点では PEP の反復版の動作と正確には一致していない
却下された代替案
-
NotGiven = object()の使用- この方式は、PEP の設計基準で扱われた欠点をすべて抱えている
reprが長くて明確ではなく、型シグネチャを明確にしにくいうえ、コピーや pickle に関する問題が生じる可能性がある
-
MISSINGまたはSentinelのような単一の新しいセンチネル値の追加- 1 つの値が複数の場所で複数の用途に使われると、あるユースケースではその値自体が常に有効な値ではないと確信するのが難しい
- 専用の異なるセンチネル値であれば、潜在的なエッジケースを考慮しなくても、より安心して使える
- センチネル値には、使用文脈に合った意味のある名前と
reprを与えられるべきである - この選択肢は投票で 12% しか選ばれず、非常に不人気だった
-
既存の
Ellipsisセンチネル値の使用Ellipsisはもともとこのような用途を意図した値ではないpassの代わりに空のクラスや関数ブロックを定義するために使われることは増えたが、専用の異なるセンチネル値ほど、あらゆる場合に安心して使えるわけではない
-
単一値の
Enumの使用- 提案されたイディオムは次のとおり
class NotGivenType(Enum): NotGiven = 'NotGiven' NotGiven = NotGivenType.NotGiven - 冗長すぎて、
reprも<NotGivenType.NotGiven: 'NotGiven'>のように長すぎる - より短い
reprを定義することはできるが、コードと重複はさらに増える - 投票の 9 つの選択肢の中で唯一 1 票も得られず、最も不人気だった
-
センチネルクラスデコレータ
- 提案されたイディオムは次のとおり
@sentinel class NotGivenType: pass NotGiven = NotGivenType() - デコレータの実装自体は単純で明確になり得るが、イディオムが冗長かつ重複的で、覚えにくい
- 提案されたイディオムは次のとおり
-
クラスオブジェクトの使用
- クラスは本質的にシングルトンなので、センチネル値として使うという発想は成り立つ
- 最も単純な形は次のとおり
class NotGiven: pass- 明確な
reprを得るには、メタクラスまたはクラスデコレータが必要になる
class NotGiven(metaclass=SentinelMeta): pass@Sentinel class NotGiven: pass - 明確な
- クラスをこのように使うのは異例であり、混乱を招く可能性がある
- 注釈なしではコードの意図を理解しにくく、センチネルが呼び出し可能になるなど、予想外で望ましくない動作が生じる
-
実装なしで推奨標準イディオムだけを定義
- よくある既存イディオムの大半には重要な欠点がある
- これまで、こうした欠点を避けつつ明確で簡潔なイディオムは見つかっていない
- 関連投票でもイディオム推奨の選択肢は不人気で、最も票を集めた選択肢でも 25% にとどまった
-
新しい標準ライブラリモジュールの使用
- 初期草案では、新しい
sentinelsまたはsentinellibモジュールにSentinelクラスを追加する方式が提案されていた - 1 つの公開 callable のために新しいモジュールを追加するのは不要である
- モジュールを使うと、既存の
object()イディオムより機能の利用が不便になる - Steering Council も、
object()と同じくらい簡単に使えるよう、組み込み機能にすることを具体的に推奨した sentinelsという名前はすでに活発に使われている PyPI パッケージと衝突するが、組み込み機能にすれば名前の問題を避けられる
- 初期草案では、新しい
-
モジュールごとのセンチネル名レジストリの使用
- 初期草案では、センチネル名をモジュール内で一意にすることが提案されていた
- この設計では、同じモジュールで
sentinel("MISSING")を繰り返し呼び出すと、モジュール名とセンチネル名をキーにしたプロセス全体のグローバルレジストリを通じて同じオブジェクトを返す - この挙動は暗黙的すぎるとして却下された
- 共有センチネルが必要なら、既存の
MISSING = object()のように 1 つを明示的に定義し、名前で再利用すればよい - ローカルスコープでは、呼び出しや繰り返しごとに新しいセンチネルが必要になることもあるため、
sentinel(name)の繰り返し呼び出しはobject()の繰り返し呼び出しと同様に、互いに異なるオブジェクトを作るべきである - レジストリを取り除くと、実装と考え方のモデルがより単純になり、
sentinel(name)はreprがnameである新しい一意なオブジェクトを作る、というルールだけが残る
-
モジュール名の自動検出または受け渡し
- 初期草案では、レジストリベースの設計を支えるために、任意の
module_name引数が提案されていた - レジストリが削除されたことで、公開された
module_name引数は中核提案にもはや不要になった - 実装は
TypeVarと同様に、pickle が import 可能なセンチネルをモジュールと名前でシリアライズできるよう、呼び出し元モジュールを内部的に記録する - 内部モジュール名はセンチネルの
reprに影響しない - モジュール名やクラス名を含む
reprが必要なら、sentinel("mymodule.MISSING")のように単一のname引数に明示的に含めればよい
- 初期草案では、レジストリベースの設計を支えるために、任意の
-
reprのカスタマイズを許可- 既存のセンチネル値を
reprを変更せずにこの方式へ移行できるという利点があった - しかし、追加の複雑さを受け入れる価値はないと判断され、除外された
- 既存のセンチネル値を
-
真偽値評価のカスタマイズを許可
- 議論では、センチネルを明示的に真値、偽値、または
bool変換不可にできるようにする案が検討された - 一部のサードパーティ製センチネルは、偽値としての動作を公開 API の一部として提供している
- 複数の参加者は、真偽値コンテキストで例外を発生させるほうが、同一性検査をより適切に強制できると考えた
- PEP は、一般オブジェクトのデフォルトの真値動作を維持し、同一性検査を推奨するという形で、初期提案を単純に保っている
- 真偽値動作のカスタマイズは、追加 API と型指定の複雑さを受け入れる価値があると判断された場合に、後で検討される可能性がある
- 議論では、センチネルを明示的に真値、偽値、または
-
型アノテーションで
typing.Literalを使用- 議論で複数の人が提案し、PEP も当初はこの方式を採用していた
- しかし、
Literal["MISSING"]はセンチネル値MISSINGへの前方参照ではなく、文字列値"MISSING"を指すため、混乱を招き得る - bare name の使用も議論でたびたび提案された
- bare name 方式は、
Noneが作った先例とよく知られたパターンに従い、import が不要で、はるかに短い
追加の使用指針
- クラススコープでセンチネルを定義する場合や、名前の衝突を避けたい場合、あるいは限定された
reprのほうが明確な場合には、望む修飾名を明示的に渡すべきである>>> class MyClass: ... NotGiven = sentinel('MyClass.NotGiven') >>> MyClass.NotGiven MyClass.NotGiven - 関数やメソッドの中でセンチネルを作ることは許可される
sentinel()は呼び出すたびに異なるオブジェクトを生成するため、ローカルスコープで作られたセンチネルは、そのスコープでobject()を呼び出して作った値のように動作するNotImplementedの真偽値はTrueだが、これを使うことは Python 3.9 以降で廃止予定となっており、deprecation warning を発生させる- この廃止は、bpo-35712 [8] で説明されている
NotImplemented固有の問題によるものである - 複数の関連するセンチネル値を定義する必要がある場合や、それらの間に順序を定義する必要がある場合は、
Enumまたは類似の方式を使うべきである - これらのセンチネルの型指定については、typing-sig メーリングリスト [9] で複数の選択肢が議論されている
1件のコメント
Lobste.rs の意見
選ばれた名前は意味が狭すぎるようで違和感がある
名前だけを見ると、一意なシンボルのようなもののほうが、より柔軟な基本要素だった気がする。実際ほぼシンボルのように振る舞うのだろうからそう使えはするだろうが、名前を “Sentinels” にしたのはしっくりこない。Lisp に慣れているからそう感じるのかもしれない
SENTINEL_AをSENTINEL_Bと異なる型にして、ある値がis_a SENTINEL_Aかどうかを尋ねられるようにすることのようだRuby のシンボルはそうは動かない:
:beef.is_a? :droog.class #=> trueLiteralとリテラル文字列があるこれらが名前付きセンチネルである理由は、sentinel values が Python で一般的な概念でありパターンでもあり、センチネルはそのパターン利用で生じるいくつかの問題を限定的に解決しようとしているからだ。"Motivation" と "Rationale" の節で説明されているとおりである
また、センチネルは値セマンティクスを持たないので、同じ名前のセンチネルが 2 つあっても互いに別の値であり等しくもない。したがってシンボルのようには動作せず、そのように使うべきでもない
名前付き引数のデフォルト値の問題では、Typst に
noneとauto値だけを追加すれば、望ましい名前付き引数インターフェースのほぼすべてを表現できるnoneだけでは、ほとんどの名前付き引数のデフォルト値として意味がうまく合わない。noneはデフォルトの戻り値としてはよいが、関数引数に入ると名詞として適切な意味を持てないことが多い。matrix(axes=None)は軸を削除するという意味なのか、通常どおり維持するという意味なのか曖昧である。noneを渡すことと何も渡さないことが違うのかも不明確だ。パラメータの有無を区別するために多重ディスパッチに進むと、そのパラメータの動作を文書化する中心的な場所を失ってしまうautoは「与えられた情報で適切に処理せよ」という意味をそのまま表せる優れたデフォルト値である。auto | noneシグネチャは、より明示的なブール値のように使え、T | auto | noneは関数が値をどう使うかについてかなり多くの情報を与える。たとえばTがcolorなら、autoは白/黒のようなデフォルト値を選ぶか親から継承する可能性が高く、Tは色を明示的に設定し、noneは文脈に応じて色をまったく設定しないか透明として扱うことができる興味深いし、一部のパッケージのセマンティクスがどう変わるのか気になる。たとえば
Item | Noneを返す代わりに、次のように書けるもちろん複数のセンチネルで追加の意味を持たせることもできる。もともと可能ではあったが、ドキュメント上で「正式に推奨される」やり方はなかった。これによってパッケージ作者が別の方向へ導かれるかもしれない
やや作為的な例だが、この場合は既存の ID はあるが関連する値がない状況と、そのような ID 自体が存在せず失敗した状況を区別できる。おそらく「Python らしい」やり方は例外を使うことだろうが、普段 Python を書くときよりも 関数型アプローチに近く見える
単に JavaScript の
SymbolAPI を導入したほうがよかった気がする。一般用途でも有用で、ここで解決しようとしている問題も一緒に解決できる