PEP 661 – 센티널 값, 5년 뒤 승인됨
(peps.python.org)- PEP 661은
None이 유효한 값인 상황에서 별도로 구별할 수 있는 센티널 값을 만들기 위해 Python 내장 호출 가능 객체sentinel()과 C APIPySentinel_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사용- 제안된 관용구는 다음과 같음
class NotGivenType(Enum): NotGiven = 'NotGiven' NotGiven = NotGivenType.NotGiven - 반복이 지나치고,
repr이<NotGivenType.NotGiven: 'NotGiven'>처럼 너무 김 - 더 짧은
repr을 정의할 수는 있지만, 코드와 반복이 더 늘어남 - 투표의 9개 선택지 중 유일하게 표를 받지 못해 가장 인기가 낮았음
-
센티널 클래스 데코레이터
- 제안된 관용구는 다음과 같음
@sentinel class NotGivenType: pass NotGiven = NotGivenType() - 데코레이터 구현 자체는 단순하고 명확할 수 있지만, 관용구가 너무 장황하고 반복적이며 기억하기 어려움
- 제안된 관용구는 다음과 같음
-
클래스 객체 사용
- 클래스는 본질적으로 싱글턴이므로 센티널 값으로 쓰는 발상은 가능함
- 가장 단순한 형태는 다음과 같음
class NotGiven: pass- 명확한
repr을 얻으려면 메타클래스나 클래스 데코레이터가 필요함
class NotGiven(metaclass=SentinelMeta): pass@Sentinel class NotGiven: pass - 명확한
- 클래스를 이런 방식으로 쓰는 것은 이례적이라 혼란스러울 수 있음
- 주석 없이는 코드 의도를 이해하기 어렵고, 센티널이 호출 가능해지는 등 예상 밖의 바람직하지 않은 동작이 생김
-
구현 없이 권장 표준 관용구만 정의
- 흔한 기존 관용구 대부분은 중요한 단점을 가짐
- 지금까지 이런 단점을 피하면서 명확하고 간결한 관용구는 발견되지 않았음
- 관련 투표에서 관용구 권장 선택지는 인기가 낮았고, 가장 많은 표를 받은 선택지도 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]에서 여러 옵션이 논의됨
Lobste.rs 의견들
-
선택한 이름이 의미가 너무 좁은 것 같아 이상함
이름만 보면 고유 심볼 같은 쪽이 더 유연한 기본 요소였을 듯함. 실제로는 거의 심볼처럼 동작할 테니 그렇게 쓸 수는 있겠지만, 이름을 “Sentinels”로 붙인 건 어색함. Lisp에 익숙해서 그렇게 느끼는 걸 수도 있음- 목표는
SENTINEL_A가SENTINEL_B와 다른 타입이 되게 해서, 어떤 값이is_a SENTINEL_A인지 물을 수 있게 하는 것 같음
Ruby의 심볼은 그렇게 동작하지 않음::beef.is_a? :droog.class #=> true - Lisp식 사고가 맞음. 넓은 용도로 쓰는 게 바람직하고 해결해야 할 문제라고 전제하고 있지만, Python에는 이미 Lisp 심볼의 대부분 사용 사례를 위한
Literal과 리터럴 문자열이 있음
이것들이 이름 있는 센티널인 이유는 sentinel values가 Python에서 흔한 개념이자 패턴이고, 센티널은 그 패턴 사용에서 생기는 일부 문제를 좁게 해결하려는 것이기 때문임. “Motivation”과 “Rationale” 절에서 설명한 그대로임
또한 센티널은 값 의미론을 갖지 않으므로, 같은 이름의 센티널 두 개도 서로 다른 값이고 서로 같지 않음. 그래서 심볼처럼 동작하지도 않고 그렇게 쓰면 안 됨
- 목표는
-
명명 인자의 기본값 문제에서는 Typst에
none과 함께auto값만 추가해도 원하는 거의 모든 명명 인자 인터페이스를 표현할 수 있음
none만으로는 대부분의 명명 인자 기본값으로 의미가 잘 맞지 않음.none은 기본 반환값으로는 좋지만 함수 인자로 들어가면 명사로서 올바른 의미를 담지 못하는 경우가 많음.matrix(axes=None)은 축을 제거한다는 뜻인지, 평소처럼 유지한다는 뜻인지 애매함.none을 넘기는 것과 아무것도 넘기지 않는 것이 다른지도 불명확함. 매개변수 포함 여부를 구분하려고 다중 디스패치로 가면, 그 매개변수의 동작을 문서화할 중심 위치를 잃게 됨
auto는 “가진 정보로 적절히 처리하라”는 뜻을 그대로 담는 훌륭한 기본값임.auto | none시그니처는 더 명시적인 불리언처럼 쓸 수 있고,T | auto | none은 함수가 값을 어떻게 쓸지 꽤 많은 정보를 줌. 예를 들어T가color라면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을 작성할 때보다 더 함수형 접근처럼 보임
- 예전에는 더미 클래스를 만들고 모듈별로 인스턴스화하던 싱글턴을 더 깔끔하게 쓰는 방식처럼 보임
Symbols가 떠오름class _MissingId: ... MISSING_ID = _MissingId() # elsewhere from ... import MISSING_ID - PEP에서는 관련된 여러 센티널 값을 정의하거나 그 사이에 정렬 순서까지 둘 수 있다면, 대신 Enum이나 비슷한 것을 쓰라고 함
- 예전에는 더미 클래스를 만들고 모듈별로 인스턴스화하던 싱글턴을 더 깔끔하게 쓰는 방식처럼 보임
-
그냥 JavaScript의
SymbolAPI를 도입하는 편이 더 나았을 것 같음. 일반적으로도 쓸모가 있고, 여기서 해결하려는 문제도 같이 해결됨