- PEP 661은 None이 유효한 값인 상황에서 별도로 구별할 수 있는 센티널 값을 만들기 위해 Python 내장 호출 가능 객체 sentinel()과 C API PySentinel_New()를 제안함
- 기존 _sentinel = object() 관용구는 함수 시그니처에서 repr이 길고 불명확하며, 명확한 타입 시그니처·복사·피클링에서 문제가 생길 수 있음
- sentinel('MISSING') 호출은 짧은 repr을 가진 새 고유 객체를 만들며, 같은 센티널을 공유하려면 MISSING = sentinel('MISSING')처럼 변수에 할당해 명시적으로 재사용해야 함
- 센티널은 is로 비교하는 방식이 권장되고 참값으로 평가되며, copy.copy()와 copy.deepcopy()는 같은 객체를 반환하고, 모듈에서 이름으로 임포트 가능한 경우 피클링 뒤에도 항등성을 보존함
- 타입 시스템은 int | MISSING처럼 센티널 자체를 타입 표현식에 사용할 수 있게 하며, 최신 공식 문서는 Python 3.15의 sentinel") 문서에 있음
도입 배경
- 고유한 자리표시자 값인 센티널 값(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 비교가 실패하는 등 예상과 다르게 동작함
- 일부 흔한 관용구는 피클링 후 언피클링했을 때도 비슷한 문제가 있음
- Victor Stinner가 Python 표준 라이브러리에서 쓰이는 센티널 값 목록을 제공했고, 표준 라이브러리 안에서도 여러 구현 방식이 쓰이며 많은 구현이 위 문제 중 하나 이상을 갖는다는 점이 확인됨
- discuss.python.org의 투표는 39표 기준으로 명확한 결론을 내지 못함
- 40%는 “현 상태가 괜찮고 일관성이 필요 없다”를 선택함
- 다수는 하나 이상의 표준화된 해법을 선택함
- 37%는 “새로운 전용 센티널 팩토리/클래스/메타클래스를 일관되게 사용하고, 표준 라이브러리에서 공개 제공”하는 선택지를 골랐음
- 엇갈린 결과 때문에 PEP가 작성됐고, 단순하고 좋은 표준 라이브러리 구현이 표준 라이브러리 내부와 외부 모두에 유용하다는 결론으로 이어짐
- 표준 라이브러리의 기존 센티널을 모두 이 방식으로 바꾸는 것은 필수 사항이 아니며, 해당 유지보수자의 재량에 맡겨짐
- PEP 문서는 역사적 문서이며, 최신 공식 문서는 Python 3.15의 sentinel") 문서에 있음
설계 기준
- 센티널 객체는 is 연산자로 비교했을 때 자기 자신과는 항상 동일하고, 다른 어떤 객체와도 동일하지 않아야 함
- 센티널 객체 생성은 단순하고 직관적인 한 줄 코드여야 함
- 필요한 만큼 여러 개의 서로 다른 센티널 값을 쉽게 정의할 수 있어야 함
- 센티널 객체는 짧고 명확한 repr을 가져야 함
- 센티널에 대해 명확한 타입 시그니처를 사용할 수 있어야 함
- 복사 후에도 올바르게 동작해야 하며, 피클링과 언피클링 시 예측 가능한 동작을 가져야 함
- CPython 3.x와 PyPy3에서 동작해야 하며, 가능하면 다른 Python 구현에서도 동작해야 함
- 구현과 사용 모두 최대한 단순하고 직관적이어야 하며, Python을 배울 때 또 하나의 특수한 개념으로 부담이 되지 않아야 함
- 표준 라이브러리는 sentinels나 sentinel 같은 PyPI 패키지 구현에 의존할 수 없기 때문에, 표준 라이브러리 안에서 사용할 수 있는 구현이 필요함
sentinel() 사양
- 새 내장 호출 가능 객체 sentinel 이 추가됨 >>> MISSING = sentinel('MISSING') >>> MISSING MISSING
- sentinel()은 위치 전용 인자 name 하나를 받으며, name은 반드시 str이어야 함
- 문자열이 아닌 값을 전달하면 TypeError가 발생함
- name은 센티널의 이름과 repr로 쓰임
- 센티널 객체는 두 개의 공개 속성을 가짐
- __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()로 센티널 객체를 복사하면 같은 객체가 반환됨
- 정의된 모듈에서 이름으로 임포트 가능한 센티널은 표준 피클 메커니즘에 따라 피클링과 언피클링 후에도 항등성을 보존함 MISSING = sentinel('MISSING') assert pickle.loads(pickle.dumps(MISSING)) is MISSING
- sentinel()은 센티널 생성 시 호출 모듈을 __module__ 속성으로 기록함
- 피클링은 센티널을 모듈과 이름으로 기록하고, 언피클링은 모듈을 임포트한 뒤 이름으로 센티널을 가져옴
- 지역 스코프에서 생성되고 모듈 전역 또는 클래스 속성의 일치하는 이름에 할당되지 않은 센티널처럼, 모듈과 이름으로 임포트할 수 없는 센티널은 피클링할 수 없음
- 센티널 객체의 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 확장에서도 센티널이 유용할 수 있어, 두 개의 새 C API 함수가 제안됨
- PyObject *PySentinel_New(const char *name, const char *module_name)은 새 센티널 객체를 생성함
- bool PySentinel_Check(PyObject *obj)는 객체가 센티널인지 확인함
- C 코드는 특정 센티널인지 확인할 때 == 연산자를 사용할 수 있음
호환성과 보안
- 새 내장 이름을 추가하면, 현재 bare name sentinel이 NameError를 발생시킨다고 가정하는 코드는 더 이상 같은 결과를 보지 않음
- 이는 새 내장 이름 추가에서 일반적으로 생기는 호환성 고려사항임
- 이미 존재하는 로컬, 전역, 임포트 이름 sentinel은 영향을 받지 않음
- 이미 sentinel이라는 이름을 쓰는 코드는 새 내장 객체를 쓰도록 조정해야 할 수 있으며, 내장 이름과의 충돌을 경고하는 린터에서 새 경고를 받을 수 있음
- 새 내장 기능에 대한 일반 문서화 방식인 독스트링, 라이브러리 문서, “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이 길고 명확하지 않으며, 타입 시그니처를 명확히 하기 어렵고, 복사 또는 피클링 관련 문제가 생길 수 있음
-
MISSING 또는 Sentinel 같은 단일 새 센티널 값 추가
- 하나의 값이 여러 곳에서 여러 용도로 쓰이면, 어떤 사용 사례에서는 그 값 자체가 유효한 값이 아닐 것이라고 항상 확신하기 어려움
- 전용의 서로 다른 센티널 값은 잠재적 엣지 케이스를 고려하지 않고 더 자신 있게 사용할 수 있음
- 센티널 값에는 사용 문맥에 맞는 의미 있는 이름과 repr을 제공할 수 있어야 함
- 이 선택지는 투표에서 12%만 선택해 인기가 매우 낮았음
-
기존 Ellipsis 센티널 값 사용
- Ellipsis는 원래 이런 용도로 의도된 값이 아님
- pass 대신 빈 클래스나 함수 블록을 정의하는 데 쓰이는 일이 늘었지만, 전용의 서로 다른 센티널 값만큼 모든 경우에 자신 있게 사용할 수 없음
-
단일 값 Enum 사용
- 제안된 관용구는 다음과 같음
- 반복이 지나치고, repr이 <NotGivenType.NotGiven: 'NotGiven'>처럼 너무 김
- 더 짧은 repr을 정의할 수는 있지만, 코드와 반복이 더 늘어남
- 투표의 9개 선택지 중 유일하게 표를 받지 못해 가장 인기가 낮았음
-
센티널 클래스 데코레이터
- 제안된 관용구는 다음과 같음 @sentinel class NotGivenType: pass NotGiven = NotGivenType()
- 데코레이터 구현 자체는 단순하고 명확할 수 있지만, 관용구가 너무 장황하고 반복적이며 기억하기 어려움
-
클래스 객체 사용
- 클래스는 본질적으로 싱글턴이므로 센티널 값으로 쓰는 발상은 가능함
- 가장 단순한 형태는 다음과 같음
class NotGiven: pass
- 명확한 repr을 얻으려면 메타클래스나 클래스 데코레이터가 필요함
- 클래스를 이런 방식으로 쓰는 것은 이례적이라 혼란스러울 수 있음
- 주석 없이는 코드 의도를 이해하기 어렵고, 센티널이 호출 가능해지는 등 예상 밖의 바람직하지 않은 동작이 생김
-
구현 없이 권장 표준 관용구만 정의
- 흔한 기존 관용구 대부분은 중요한 단점을 가짐
- 지금까지 이런 단점을 피하면서 명확하고 간결한 관용구는 발견되지 않았음
- 관련 투표에서 관용구 권장 선택지는 인기가 낮았고, 가장 많은 표를 받은 선택지도 25%에 그쳤음
-
새 표준 라이브러리 모듈 사용
- 초기 초안은 새 sentinels 또는 sentinellib 모듈에 Sentinel 클래스를 추가하는 방식을 제안함
- 공개 호출 가능 객체 하나를 위해 새 모듈을 추가하는 것은 불필요함
- 모듈을 사용하면 기존 object() 관용구보다 기능 사용이 불편해짐
- Steering Council도 object()만큼 쉽게 쓰이도록 내장 기능으로 만들 것을 구체적으로 권장함
- sentinels라는 이름은 이미 활발히 쓰이는 PyPI 패키지와 충돌하며, 내장 기능으로 만들면 이름 문제를 피할 수 있음
-
모듈별 센티널 이름 레지스트리 사용
- 초기 초안은 센티널 이름을 모듈 안에서 고유하게 만들도록 제안함
- 이 설계에서는 같은 모듈에서 sentinel("MISSING")을 반복 호출하면, 모듈 이름과 센티널 이름을 키로 하는 프로세스 전역 레지스트리를 통해 같은 객체를 반환함
- 이 동작은 지나치게 암묵적이어서 거절됨
- 공유 센티널이 필요하면 기존 MISSING = object()처럼 하나를 명시적으로 정의하고 이름으로 재사용하면 됨
- 지역 스코프에서는 호출이나 반복마다 새 센티널을 원할 수도 있으므로, sentinel(name) 반복 호출은 object() 반복 호출처럼 서로 다른 객체를 만들어야 함
- 레지스트리를 제거하면 구현과 사고 모델이 더 단순해지고, sentinel(name)은 repr이 name인 새 고유 객체를 만든다는 규칙만 남음
-
모듈 이름 자동 발견 또는 전달
- 초기 초안은 레지스트리 기반 설계를 지원하기 위해 선택적 module_name 인자를 제안함
- 레지스트리가 제거되면서 공개 module_name 인자는 핵심 제안에 더 이상 필요하지 않음
- 구현은 TypeVar와 유사하게 피클이 임포트 가능한 센티널을 모듈과 이름으로 직렬화할 수 있도록 호출 모듈을 내부적으로 기록함
- 내부 모듈 이름은 센티널의 repr에 영향을 주지 않음
- 모듈명이나 클래스명이 포함된 repr을 원하면 sentinel("mymodule.MISSING")처럼 단일 name 인자에 명시적으로 포함하면 됨
-
repr 사용자 지정 허용
- 기존 센티널 값을 repr 변경 없이 이 방식으로 옮길 수 있다는 장점이 있었음
- 하지만 추가 복잡도를 감수할 가치가 없다고 보고 제외됨
-
불리언 평가 사용자 지정 허용
- 논의에서는 센티널을 명시적으로 참값, 거짓값, 또는 bool 변환 불가로 만들 수 있게 하는 방안이 검토됨
- 일부 서드파티 센티널은 거짓값 동작을 공개 API의 일부로 제공함
- 여러 참여자는 불리언 문맥에서 예외를 발생시키는 편이 항등성 검사를 더 잘 강제한다고 보았음
- PEP는 일반 객체의 기본 참값 동작을 유지하고 항등성 검사를 권장하는 방식으로 초기 제안을 단순하게 유지함
- 사용자 지정 불리언 동작은 추가 API와 타입 지정 복잡도를 감수할 만하다고 판단될 때 나중에 검토될 수 있음
-
타입 애너테이션에서 typing.Literal 사용
- 논의에서 여러 사람이 제안했고, PEP도 처음에는 이 방식을 채택했음
- 그러나 Literal["MISSING"]이 센티널 값 MISSING에 대한 전방 참조가 아니라 문자열 값 "MISSING"을 가리키기 때문에 혼란을 일으킬 수 있음
- bare name 사용도 논의에서 자주 제안됨
- bare name 방식은 None이 만든 선례와 잘 알려진 패턴을 따르며, 임포트가 필요 없고 훨씬 짧음
추가 사용 지침
- 클래스 스코프에서 센티널을 정의하거나, 이름 충돌을 피하거나, 한정된 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]에서 여러 옵션이 논의됨

4 hours ago
1








English (US) ·