1 ポイント 投稿者 GN⁺ 1 시간 전 | 1件のコメント | WhatsAppで共有
  • PEP 661 は、None が有効な値である状況で別個に識別できるセンチネル値を作るために、Python 組み込みの呼び出し可能オブジェクト sentinel() と C API PySentinel_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つ特別な概念として負担にならないべき
  • 標準ライブラリは sentinelssentinel のような 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 化できない
  • センチネルオブジェクトの reprsentinel() に渡した 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 sentinelNameError を発生させると仮定しているコードでは、今後は同じ結果にならない
  • これは新しい組み込み名の追加で一般的に生じる互換性上の考慮事項である
  • 既存のローカル名、グローバル名、インポート名 sentinel は影響を受けない
  • すでに sentinel という名前を使っているコードは、新しい組み込みオブジェクトを使うよう調整が必要になる場合があり、組み込み名との衝突を警告するリンターから新たな警告を受ける可能性がある
  • 新しい組み込み機能については、一般的な文書化手段である docstring、ライブラリドキュメント、「What’s New」セクションで十分だと考えられている
  • この提案にセキュリティ上の影響はないと考えられている

参照実装とバックポート

  • 参照実装は CPython のプルリクエスト [10] として提供されている
  • 以前の参照実装は別の GitHub リポジトリ [7] にある
  • 意図された動作のスケッチは次のとおり
    class sentinel:  
        &quot;&quot;&quot;Unique sentinel values.&quot;&quot;&quot;  
    
        __slots__ = (&quot;__name__&quot;, &quot;_module_name&quot;)  
    
        def __init_subclass__(cls):  
            raise TypeError(&quot;type &#039;sentinel&#039; is not an acceptable base type&quot;)  
    
        def __init__(self, name, /):  
            if not isinstance(name, str):  
                raise TypeError(&quot;sentinel name must be a string&quot;)  
            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]  
    

却下された代替案

  • NotGiven = object() の使用

    • この方式は、PEP の設計基準で扱われた欠点をすべて抱えている
    • repr が長くて明確ではなく、型シグネチャを明確にしにくいうえ、コピーや pickle に関する問題が生じる可能性がある
  • MISSING または Sentinel のような単一の新しいセンチネル値の追加

    • 1 つの値が複数の場所で複数の用途に使われると、あるユースケースではその値自体が常に有効な値ではないと確信するのが難しい
    • 専用の異なるセンチネル値であれば、潜在的なエッジケースを考慮しなくても、より安心して使える
    • センチネル値には、使用文脈に合った意味のある名前と repr を与えられるべきである
    • この選択肢は投票で 12% しか選ばれず、非常に不人気だった
  • 既存の Ellipsis センチネル値の使用

    • Ellipsis はもともとこのような用途を意図した値ではない
    • pass の代わりに空のクラスや関数ブロックを定義するために使われることは増えたが、専用の異なるセンチネル値ほど、あらゆる場合に安心して使えるわけではない
  • 単一値の Enum の使用

    • 提案されたイディオムは次のとおり
    class NotGivenType(Enum):  
      NotGiven = &#039;NotGiven&#039;  
      NotGiven = NotGivenType.NotGiven  
    
  • 冗長すぎて、repr&lt;NotGivenType.NotGiven: &#039;NotGiven&#039;&gt; のように長すぎる
  • より短い 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)reprname である新しい一意なオブジェクトを作る、というルールだけが残る
  • モジュール名の自動検出または受け渡し

    • 初期草案では、レジストリベースの設計を支えるために、任意の 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 のほうが明確な場合には、望む修飾名を明示的に渡すべきである
    &gt;&gt;&gt; class MyClass:  
    ...    NotGiven = sentinel(&#039;MyClass.NotGiven&#039;)  
    &gt;&gt;&gt; MyClass.NotGiven  
    MyClass.NotGiven  
    
  • 関数やメソッドの中でセンチネルを作ることは許可される
  • sentinel() は呼び出すたびに異なるオブジェクトを生成するため、ローカルスコープで作られたセンチネルは、そのスコープで object() を呼び出して作った値のように動作する
  • NotImplemented の真偽値は True だが、これを使うことは Python 3.9 以降で廃止予定となっており、deprecation warning を発生させる
  • この廃止は、bpo-35712 [8] で説明されている NotImplemented 固有の問題によるものである
  • 複数の関連するセンチネル値を定義する必要がある場合や、それらの間に順序を定義する必要がある場合は、Enum または類似の方式を使うべきである
  • これらのセンチネルの型指定については、typing-sig メーリングリスト [9] で複数の選択肢が議論されている

1件のコメント

 
GN⁺ 1 시간 전
Lobste.rs の意見
  • 選ばれた名前は意味が狭すぎるようで違和感がある
    名前だけを見ると、一意なシンボルのようなもののほうが、より柔軟な基本要素だった気がする。実際ほぼシンボルのように振る舞うのだろうからそう使えはするだろうが、名前を “Sentinels” にしたのはしっくりこない。Lisp に慣れているからそう感じるのかもしれない

    • 目的は SENTINEL_ASENTINEL_B異なる型にして、ある値が is_a SENTINEL_A かどうかを尋ねられるようにすることのようだ
      Ruby のシンボルはそうは動かない: :beef.is_a? :droog.class #=> true
    • Lisp 的な考え方は理解できる。広い用途で使うのが望ましく、それが解決すべき問題だという前提に立っているが、Python にはすでに Lisp のシンボルの大半のユースケース向けに Literal とリテラル文字列がある
      これらが名前付きセンチネルである理由は、sentinel values が Python で一般的な概念でありパターンでもあり、センチネルはそのパターン利用で生じるいくつかの問題を限定的に解決しようとしているからだ。"Motivation" と "Rationale" の節で説明されているとおりである
      また、センチネルは値セマンティクスを持たないので、同じ名前のセンチネルが 2 つあっても互いに別の値であり等しくもない。したがってシンボルのようには動作せず、そのように使うべきでもない
  • 名前付き引数のデフォルト値の問題では、Typst に noneautoだけを追加すれば、望ましい名前付き引数インターフェースのほぼすべてを表現できる
    none だけでは、ほとんどの名前付き引数のデフォルト値として意味がうまく合わない。none はデフォルトの戻り値としてはよいが、関数引数に入ると名詞として適切な意味を持てないことが多い。matrix(axes=None) は軸を削除するという意味なのか、通常どおり維持するという意味なのか曖昧である。none を渡すことと何も渡さないことが違うのかも不明確だ。パラメータの有無を区別するために多重ディスパッチに進むと、そのパラメータの動作を文書化する中心的な場所を失ってしまう
    auto は「与えられた情報で適切に処理せよ」という意味をそのまま表せる優れたデフォルト値である。auto | none シグネチャは、より明示的なブール値のように使え、T | auto | none は関数が値をどう使うかについてかなり多くの情報を与える。たとえば Tcolor なら、auto は白/黒のようなデフォルト値を選ぶか親から継承する可能性が高く、T は色を明示的に設定し、none は文脈に応じて色をまったく設定しないか透明として扱うことができる

  • 興味深いし、一部のパッケージのセマンティクスがどう変わるのか気になる。たとえば Item | None を返す代わりに、次のように書ける

    NOT_FOUND = sentinel("NOT_FOUND")  
    def get_item(iid: str) -> Item | NOT_FOUND: ...  
    

    もちろん複数のセンチネルで追加の意味を持たせることもできる。もともと可能ではあったが、ドキュメント上で「正式に推奨される」やり方はなかった。これによってパッケージ作者が別の方向へ導かれるかもしれない

    MISSING_ID = sentinel("MISSING_ID")  
    MISSING_VALUE = sentinel("MISSING_VALUE")
    
    def get_item(iid: str) -> Item | MISSING_ID | MISSING_VALUE: ...  
    

    やや作為的な例だが、この場合は既存の ID はあるが関連する値がない状況と、そのような ID 自体が存在せず失敗した状況を区別できる。おそらく「Python らしい」やり方は例外を使うことだろうが、普段 Python を書くときよりも 関数型アプローチに近く見える

    • 以前はダミークラスを作ってモジュールごとにインスタンス化していた シングルトンを、よりきれいに書く方法のように見える
      class _MissingId: ...
      
      MISSING_ID = _MissingId()
      
      # elsewhere  
      from ... import MISSING_ID  
      
      Symbols を思い出す
    • PEP では、関連する複数のセンチネル値を定義したり、それらの間に順序まで持たせたいなら、代わりに Enum かそれに類するものを使うべきだとしている
  • 単に JavaScript の Symbol API を導入したほうがよかった気がする。一般用途でも有用で、ここで解決しようとしている問題も一緒に解決できる