PEP 800 – 타입 시스템의 분리 베이스
- Author:
- Jelle Zijlstra <jelle.zijlstra at gmail.com>
- Discussions-To:
- Discourse thread
- Status:
- Final
- Type:
- Standards Track
- Topic:
- Typing
- Created:
- 21-Jul-2025
- Python-Version:
- 3.15
- Post-History:
- 18-Jul-2025, 23-Jul-2025
- Resolution:
- 15-Apr-2026
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
Python 프로그램을 정밀하게 분석하려면 타입 검사기가 두 클래스가 공통 자식 클래스를 가질 수 있는 경우와 가질 수 없는 경우를 알아야 합니다. 그러나 이를 판단하는 데 필요한 정보는 현재 타입 시스템에 포함되어 있지 않습니다. 이 PEP는 클래스가 “분리 베이스”임을 나타내는 새 데코레이터 @typing.disjoint_base를 추가합니다. 서로 관련이 없는 별개의 분리 베이스를 가진 두 클래스는 공통 자식 클래스를 가질 수 없습니다.
대부분의 사용자는 이 데코레이터를 직접 사용하지 않을 것으로 예상합니다. 이 데코레이터는 주로 표준 라이브러리 클래스와 확장 모듈 클래스의 스텁 파일에서 사용하기 위한 것이며, 이러한 파일에서 타입 검사기가 런타임 제약을 일관되게 반영하도록 돕습니다.
동기
Python 타입 검사에서 중요한 개념 중 하나는 도달 가능성(reachability)입니다. Python 타입 검사기는 일반적으로 코드의 브랜치가 결코 도달할 수 없는 경우를 감지하고, 그러한 코드에 대해 사용자에게 경고합니다. 도달할 수 없는 코드는 프로그램을 불필요하게 복잡하게 만들고, 그 존재 자체가 버그의 징후일 수 있으므로 이 기능은 유용합니다.
예를 들어, 다음 프로그램에서:
def f(x: bool) -> None:
if isinstance(x, str):
print("It's both!")
널리 쓰이는 두 타입 검사기인 pyright와 mypy(--warn-unreachable 사용 시)는 모두 if 블록의 본문에 도달할 수 없다고 경고합니다. x가 bool이라면 동시에 str일 수는 없기 때문입니다.
Python에서는 다중 상속이 존재하기 때문에 도달 가능성 문제가 복잡해집니다. bool과 str 대신 사용자 정의 클래스 두 개를 사용하면 mypy와 pyright는 아무런 경고도 표시하지 않습니다:
class A: pass
class B: pass
def f(x: A):
if isinstance(x, B):
print("It's both!")
A와 B를 모두 상속하는 클래스가 존재할 수 있으므로 이는 올바른 동작입니다.
int와 str을 사용하는 또 다른 경우에는 타입 검사기 간에 결과가 엇갈립니다:
def f(x: int):
if isinstance(x, str):
print("It's both!")
이 코드에 대해 pyright는 아무 오류도 보이지 않지만, mypy는 해당 브랜치가 도달 불가능하다고 주장합니다. 여기서는 Mypy가 기술적으로 옳습니다. CPython은 클래스가 int와 str을 모두 상속하는 것을 허용하지 않으므로, 해당 브랜치는 도달 불가능합니다. 그러나 이 베이스 클래스들이 서로 호환되지 않는다는 것을 판단하는 데 필요한 정보는 현재 타입 시스템에서 사용할 수 없습니다. 사실 mypy는 호환되지 않는 메서드가 있는지를 기준으로 하는 휴리스틱을 사용합니다. 이 휴리스틱은 실제로, 특히 내장 타입에 대해서는 상당히 잘 동작하지만, 아래에서 더 자세히 논의하듯이 일반적으로는 올바르지 않습니다.
실험적인 ty 타입 검사기는 파이썬의 런타임 동작에 더 가깝게 부합하는 세 번째 접근 방식을 사용합니다. 이 검사기는 특정 클래스를 다중 상속을 제한하는 “솔리드 베이스”로 인식합니다. 대략적으로 말하면, 모든 클래스는 고유한 솔리드 베이스를 최대 하나만 상속해야 하며, 고유한 솔리드 베이스가 없다면 그 클래스는 존재할 수 없습니다. 더 정확한 정의는 아래에서 제시합니다. 그러나 ty의 현재 접근 방식은 특정 내장 타입에 대해 하드코딩된 지식에 의존합니다. “솔리드 베이스”라는 용어는 CPython 구현에서 유래했으며, 이 PEP에서는 그 대신 새로 제안하는 용어인 “분리 베이스”를 사용합니다.
이 PEP는 런타임에 다중 상속이 허용되지 않는 경우를 표현할 수 있도록 하는 타입 시스템 확장을 제안합니다. 바로 클래스를 분리 베이스로 표시하는 @disjoint_base 데코레이터입니다. 이를 통해 타입 검사기는 도달 가능성을 더 정밀하게 이해할 수 있으며, 이는 여러 구체적인 영역에서 도움이 됩니다.
잘못된 클래스 정의
다음 클래스 정의는 int와 str이 서로 다른 분리 베이스이므로 런타임에 오류를 발생시킵니다:
class C(int, str): pass
분리 베이스에 대한 지식이 없으면, 타입 검사기는 이 클래스가 존재한다고 가정할 때 일부 메서드가 호환되지 않는다는 점은 감지할 수 있더라도 이 클래스 정의가 잘못된 이유는 현재 감지할 수 없습니다. (mypy는 이 클래스 정의를 보면 __add__ 및 여러 다른 메서드의 호환되지 않는 정의를 지적합니다.)
이 문제는 그 자체로는 특별히 중요하지 않습니다. 보통 코드를 처음 임포트할 때 오류가 발견되기 때문입니다. 다만 완전성을 위해 여기에서 언급합니다.
도달 가능성
isinstance()를 사용하는 코드의 도달 가능성은 이미 언급했습니다. match 문과 같은 다른 타입 좁히기 구문에서도 비슷한 문제가 발생합니다. 도달 가능성을 올바르게 추론하려면 서로소 베이스를 이해해야 합니다.
class A: pass
class B: pass
def f(x: A):
match x:
case B(): # reachable
print("It's both!")
def g(x: int):
match x:
case str(): # unreachable
print("It's both!")
오버로드
일부 오버로드의 매개변수 타입은 겹치지만 반환 타입은 겹치지 않는 경우, @overload로 데코레이트된 함수는 안전하지 않을 수 있습니다. 예를 들어, 다음 오버로드 집합은 불건전한 동작을 일으키는 데 악용될 수 있습니다:
from typing import overload
class A: pass
class B: pass
@overload
def f(x: A) -> str: ...
@overload
def f(x: B) -> int: ...
A와 B를 모두 상속하는 클래스가 존재한다면, 타입 검사기는 f()를 호출할 때 잘못된 오버로드를 선택할 수 있습니다.
타입 검사기는 이러한 불안전성의 원인을 감지하여 경고할 수 있지만, 올바르게 구현하려면 서로소 베이스를 이해해야 합니다. A와 B 모두의 인스턴스인 값이 존재할 수 있는지를 알아야 하기 때문입니다. 많은 타입 검사기가 이미 겹치는 오버로드에 대해 이러한 검사를 어떤 형태로든 수행하고 있지만, 현재 타이핑 명세는 이 검사가 어떻게 동작해야 하는지 규정하지 않습니다. 이 PEP는 이를 바꾸자고 제안하지 않지만, 겹치는 오버로드를 건전하게 검사하기 위한 구성 요소를 제공하는 데 도움이 됩니다.
교차 타입
주어진 모든 타입의 인스턴스인 값을 포함하는 타입을 나타내는 명시적 교차 타입은 현재 타입 시스템에 포함되어 있지 않습니다. 하지만 교차 타입은 Python과 같은 집합론적 타입 시스템에서 타입 좁히기의 결과로 자연스럽게 발생하며, 향후 타입 시스템 확장에서 명시적 교차 타입 지원이 추가될 수도 있습니다.
교차 타입에서는 특정 교차가 비어 있지 않은지(inhabited), 즉 그 교차의 멤버가 될 수 있는 값이 존재하는지를 아는 것이 중요한 경우가 많습니다. 이를 통해 타입 검사기는 도달 가능성을 이해하고 사용자에게 더 정확한 타입 정보를 제공할 수 있습니다.
구체적인 예로, 교차 타입에 대한 할당 가능성은 다음과 같이 구현할 수 있습니다. 교차 타입 A & B가 주어졌을 때, 타입 C가 A와 B 중 적어도 하나에 할당 가능하고 A와 B 모두와 겹친다면 C는 A & B에 할당 가능합니다. (여기서 “겹친다”는 것은 두 타입 모두의 멤버가 되는 런타임 값이 적어도 하나 존재할 수 있다는 뜻입니다. 즉, A & B가 비어 있지 않으면 A와 B는 겹칩니다.) 규칙의 두 번째 부분은 str이 int & Any와 같은 타입에 할당 가능하지 않도록 보장합니다. str은 Any에 할당 가능하지만 int와는 겹치지 않기 때문입니다. 하지만 물론 str과 int가 겹치지 않는다는 것은 두 클래스가 모두 서로소 베이스라는 사실을 알 때만 알 수 있습니다.
개요
서로소 베이스는 타입 시스템의 여러 영역에서 유용할 수 있습니다. 이러한 영역 중 일부는 명세가 불충분하거나, 추측에 기반하거나, 중요도가 크지 않지만, 어느 경우든 서로소 베이스라는 개념 덕분에 타입 검사기는 현재 타입 시스템이 허용하는 것보다 더 정확하게 이해할 수 있습니다. 따라서 서로소 베이스는 Python 타입 시스템을 개선하기 위한 확고한 토대(말하자면 견고한 베이스(solid base))를 제공합니다.
근거
“서로소 베이스”라는 개념을 통해 타입 검사기는 두 클래스의 공통 자식 클래스가 존재할 수 있는 경우와 존재할 수 없는 경우를 이해할 수 있습니다. 이 개념을 타입 검사기에 전달하기 위해, 클래스를 서로소 베이스로 표시하는 @disjoint_base 데코레이터를 타입 시스템에 추가합니다. 그 의미는 대략 한 클래스가 서로 관련 없는 두 서로소 베이스를 가질 수 없다는 것입니다.
명명
이 PEP의 초기 버전은 CPython 구현에서 사용하는 용어를 따라 “솔리드 베이스(solid base)”라는 이름을 사용했습니다. 그러나 이 용어는 다소 모호합니다. 대안 용어인 “서로소 베이스(disjoint base)”는 이 데코레이터가 붙은 클래스가 다른 베이스와 서로소임을 암시하며, 이는 개념을 1차적으로 잘 설명합니다. (정확한 의미는 더 미묘하며 아래에서 설명합니다.)
다중 상속에 대한 런타임 제약
Python은 일반적으로 다중 상속을 허용하지만, CPython에 문서화된 대로 런타임은 여러 가지 제약을 부과합니다. 일관된 MRO에 관한 제약과 일관된 메타클래스에 관한 제약, 이 두 가지 제약 집합은 타입 검사기가 타입 시스템에서 이용할 수 있는 정보를 사용하여 이미 구현할 수 있습니다. 세 번째 제약인 인스턴스 레이아웃에 관한 제약은 분리 베이스에 대한 지식이 필요한 제약입니다. 비어 있지 않은 __slots__ 정의를 포함하는 클래스는 자동으로 분리 베이스가 되며, C로 구현된 많은 내장 클래스도 마찬가지입니다.
PyPy와 같은 Python의 대체 구현은 CPython과 비슷하게 동작하는 경향이 있지만, 정확히 어떤 표준 라이브러리 클래스가 분리 베이스인지와 같은 세부 사항에서는 다를 수 있습니다. 현재 타입 시스템은 대체 Python 구현을 명시적으로 지원하지 않으므로, 이 PEP는 typeshed와 같은 스텁 라이브러리가 @disjoint_base 데코레이터를 언제 사용할지 결정할 때 CPython의 동작을 따를 것을 권장합니다. 향후 타입 시스템 확장이 대체 구현에 대한 지원을 추가한다면(예를 들어 sys.implementation.name의 값에 따른 분기), 스텁은 필요한 경우 구현에 따라 @disjoint_base 데코레이터의 존재 여부를 조건부로 지정할 수 있습니다.
CPython 구현에서 “분리 베이스”(“솔리드 베이스(solid base)”라고 불림)라는 개념은 수십 년 동안 존재해 왔지만, 어떤 클래스가 분리 베이스인지 결정하는 규칙은 가끔 변경되었습니다. Python 3.12 이전에는 베이스 클래스와 비교하여 __dict__나 약한 참조(weakref) 지원을 추가하면 클래스가 분리 베이스가 될 수 있었습니다. 실제로 이는 namedtuple() 클래스처럼 C로 구현된 클래스를 상속하는 Python 구현 클래스가 그 자체로 분리 베이스가 되는 경우가 많았다는 것을 의미했습니다. 이 동작은 Python 3.12에서 python/cpython#96028에 의해 변경되었습니다. 이 PEP는 더 단순하고 이해하기 쉬운 Python 3.12 이상의 동작을 지원하는 데 중점을 둡니다. 타입 검사기는 원한다면 3.12 이전 동작의 한 형태를 구현할 수도 있지만, 이를 올바르게 수행하려면 현재 타입 시스템에서 이용할 수 없는 정보가 필요합니다.
런타임에 분리 베이스인 클래스의 정확한 집합은 향후 Python 버전에서 다시 변경될 수 있습니다. 그런 일이 생긴다면, 타입 검사기가 사용하는 타입 스텁을 갱신하여 이 새로운 현실을 반영할 수 있습니다. 다시 말해, 이 PEP는 타입 시스템에 분리 베이스라는 개념을 추가하지만, 정확히 어떤 클래스가 분리 베이스인지는 규정하지 않습니다.
구현 파일에서의 @disjoint_base
@disjoint_base 데코레이터의 가장 명확한 사용 사례는 표준 라이브러리와 같은 C 라이브러리의 스텁 파일에서 C로 구현된 분리 베이스를 표시하는 것입니다.
하지만 구현 파일에서 분리 베이스를 표시하는 사용 사례도 있으며, 이 경우 데코레이트된 클래스와 다른 분리 베이스(예를 들어 표준 라이브러리 클래스나 @disjoint_base로 데코레이트된 다른 사용자 클래스)를 함께 상속하는 자식 클래스의 존재를 허용하지 않는 효과가 있습니다. 예를 들어, 이를 통해 타입 검사기는 사용자 클래스와 int나 str 같은 표준 라이브러리 클래스를 모두 상속하는 클래스가 존재해야만 도달할 수 있는 코드에 플래그를 지정할 수 있습니다. 그러한 클래스는 기술적으로는 가능할 수 있지만 실제로는 있을 법하지 않습니다.
@disjoint_base
class BaseModel:
# ... General logic for model classes
pass
class Species(BaseModel):
name: str
# ... more fields
def process_species(species: Species):
if isinstance(species, str): # oops, forgot `.name`
pass # type checker should warn about this branch being unreachable
# BaseModel and str are disjoint bases, so a class that inherits from both cannot exist
이는 원리상 기존의 @final 데코레이터와 비슷한데, 이 데코레이터 역시 서브클래싱을 제한하는 역할을 합니다. 스텁에서는 프로그램적으로 서브클래싱을 허용하지 않는 클래스를 표시하는 데 사용되지만, 구현 파일에서는 런타임 강제 없이 클래스가 서브클래싱되도록 의도되지 않았음을 나타내는 데 자주 사용됩니다.
특수 클래스에 대한 @disjoint_base
@disjoint_base 데코레이터는 주로 명목적 클래스를 위한 것이지만, 타입 시스템에는 구문상 클래스 정의를 사용하는 다른 구성 요소도 있으므로, 이들에도 데코레이터를 허용해야 하는지, 그리고 허용한다면 그것이 무엇을 의미하는지 검토해야 합니다.
Protocol 정의의 경우, 가장 일관된 해석은 해당 프로토콜을 구현할 수 있는 클래스가 그 프로토콜을 명목적으로 상속하는 클래스나 그 프로토콜을 구현하는 @final 클래스뿐이라는 것입니다. 그 밖의 클래스는 해당 프로토콜이 아닌 분리 베이스를 가지고 있거나 잠재적으로 가질 수 있습니다. 이는 복잡하고 유용하지 않으므로, Protocol 정의에는 @disjoint_base를 허용하지 않습니다.
마찬가지로, TypedDict는 순수한 구조적 타입이므로 TypedDict 정의에서는 “분리 베이스”라는 개념이 의미가 없습니다.
NamedTuple 정의는 타입 시스템에서 몇 가지 특별한 취급을 받지만, 자식 클래스를 가질 수 있는 실제 명목적 클래스를 생성하므로, 여기에 @disjoint_base를 허용하고 분리 베이스 메커니즘에 있어서는 일반 클래스처럼 취급하는 것이 타당합니다. 모든 NamedTuple 클래스는 MRO에 분리 베이스인 tuple을 가지고 있으므로, 다른 분리 베이스와 다중 상속을 할 수 없습니다.
명세
@typing.disjoint_base 데코레이터가 타입 시스템에 추가됩니다. 이 데코레이터는 NamedTuple 정의를 포함한 명목적 클래스에만 사용할 수 있으며, 함수, TypedDict 정의 또는 Protocol 정의에 이 데코레이터를 사용하는 것은 타입 검사기 오류입니다.
(명목적) 클래스에 대해 두 가지 속성을 정의합니다. 클래스는 서로소 베이스일 수도 있고 아닐 수도 있으며, 모든 클래스는 유효한 서로소 베이스를 가져야 합니다.
클래스가 @typing.disjoint_base로 데코레이트되어 있거나 비어 있지 않은 __slots__ 정의를 포함하면 그 클래스는 서로소 베이스입니다. 여기에는 @dataclass(slots=True) 데코레이터 때문에 __slots__를 갖는 클래스나, 슬롯을 추가하는 dataclass_transform 메커니즘을 사용하여 __slots__를 갖는 클래스도 포함됩니다. 범용 베이스 클래스인 object도 서로소 베이스입니다.
클래스의 서로소 베이스를 결정하려면 해당 클래스의 모든 베이스 클래스를 살펴서 서로소 베이스 후보 집합을 정합니다. 각 베이스에 대해, 그 베이스 자체가 서로소 베이스이면 후보는 그 베이스 자신이고, 그렇지 않으면 그 베이스의 서로소 베이스가 후보입니다. 후보 집합에 서로소 베이스가 하나만 있으면 그것이 해당 클래스의 서로소 베이스입니다. 후보가 여러 개이더라도 그중 하나가 나머지 모든 후보의 서브클래스이면 그 클래스가 서로소 베이스입니다. 그러한 후보가 없으면 해당 클래스는 유효한 서로소 베이스를 갖지 않으므로 존재할 수 없습니다.
타입 검사기는 클래스 정의를 검사할 때 유효한 서로소 베이스가 있는지 확인해야 하며, 유효한 서로소 베이스가 없는 클래스 정의를 발견하면 진단 메시지를 내보내야 합니다. 타입 검사기는 서로소 베이스(disjoint base) 메커니즘을 사용하여 타입들이 서로소인지 판단할 수도 있습니다. 예를 들어 isinstance() 같은 타입 좁히기 구문이 도달 불가능한 브랜치를 만드는지 검사할 때 이를 사용할 수 있습니다.
예시:
from typing import disjoint_base, assert_never
@disjoint_base
class Disjoint1:
pass
@disjoint_base
class Disjoint2:
pass
@disjoint_base
class DisjointChild(Disjoint1):
pass
class C1: # disjoint base is `object`
pass
# OK: candidate disjoint bases are `Disjoint1` and `object`, and `Disjoint1` is a subclass of `object`.
class C2(Disjoint1, C1): # disjoint base is `Disjoint1`
pass
# OK: candidate disjoint bases are `DisjointChild` and `Disjoint1`, and `DisjointChild` is a subclass of `Disjoint1`.
class C3(DisjointChild, Disjoint1): # disjoint base is `DisjointChild`
pass
# error: candidate disjoint bases are `Disjoint1` and `Disjoint2`, but neither is a subclass of the other
class C4(Disjoint1, Disjoint2):
pass
def narrower(obj: Disjoint1) -> None:
if isinstance(obj, Disjoint2):
assert_never(obj) # OK: child class of `Disjoint1` and `Disjoint2` cannot exist
if isinstance(obj, C1):
reveal_type(obj) # Shows a non-empty type, e.g. `Disjoint1 & C1`
런타임 구현
새로운 데코레이터인 @disjoint_base가 typing 모듈에 추가될 것입니다. 이 데코레이터의 런타임 동작은 (@final 등 유사한 데코레이터와 일관되게) 데코레이트된 객체에 .__disjoint_base__ = True 속성을 설정한 다음 인자를 그대로 반환하는 것입니다:
def disjoint_base(cls):
cls.__disjoint_base__ = True
return cls
__disjoint_base__ 속성은 런타임 인트로스펙션에 사용할 수 있습니다. 그러나 사용자 정의 클래스에 대해 이 데코레이터를 런타임에 강제하지는 않습니다.
스텁에서 @disjoint_base 데코레이터를 적용해야 하는지 검증할 수 있다면 유용할 것입니다. CPython은 어떤 클래스가 서로소 베이스인지 정확히 문서화하고 있지 않지만, 런타임 인트로스펙션을 사용하여 인터프리터의 동작을 재현할 수 있습니다(예시 구현). mypy의 stubtest와 같은 스텁 검증 도구는 이 로직을 사용하여 스텁에서 @disjoint_base 데코레이터가 올바른 클래스에 적용되었는지 검사할 수 있습니다.
하위 호환성
이전 버전의 Python과의 호환성을 위해 @disjoint_base 데코레이터는 typing_extensions 백포트 패키지에도 추가될 것입니다.
런타임에서 새 데코레이터는 호환성 문제를 일으키지 않습니다.
스텁에서는 아직 모든 타입 검사기가 이 데코레이터를 이해하지 못하더라도 서로소 베이스 클래스에 데코레이터를 추가할 수 있습니다. 그러한 타입 검사기는 이 데코레이터를 아무 동작도 하지 않는 것으로 취급하면 됩니다.
타입 검사기가 이 PEP를 지원하게 되면 사용자는 도달 가능성과 교집합에 관련된 타입 검사 동작에서 일부 변화를 겪을 수 있습니다. 이러한 변화는 런타임 동작을 더 잘 반영하게 되므로 긍정적일 것이며, 사용자에게 보이는 변화의 규모는 타입 검사기 버전 간의 일반적인 변화량과 비슷하게 제한적일 가능성이 높습니다. 이 변경의 영향을 우려하는 타입 검사기는 옵트인 플래그와 같은 전환 메커니즘을 사용할 수 있습니다.
보안 영향
알려진 바 없습니다.
교육 방법
대부분의 사용자는 @disjoint_base 데코레이터를 직접 사용하거나 이해할 필요가 없을 것입니다. 이 데코레이터는 주로 저수준 라이브러리의 라이브러리 스텁에서 사용될 것으로 예상되기 때문입니다. 이 데코레이터를 사용자가 클래스에 일상적으로 추가해야 하는 데코레이터로 가르쳐서는 안 됩니다. Python을 가르치는 사람은 특정 경우에 다중 상속이 허용되지 않는 이유를 설명하기 위해 “분리 베이스(disjoint bases)” 개념을 소개할 수 있습니다. Python 타이핑을 가르치는 사람은 isinstance() 같은 타입 좁히기 구문을 가르칠 때 이 데코레이터를 소개하여, 타입 검사기가 특정 브랜치를 도달 불가능한 것으로 취급하는 이유를 사용자에게 설명할 수 있습니다.
참조 구현
@disjoint_base 데코레이터의 런타임 구현은 typing-extensions 4.15.0에서 사용할 수 있습니다. Mypy와 그 stubtest 도구는 버전 1.18.1부터 이 데코레이터를 지원합니다. 이는 python/mypy#19678에서 구현되었습니다. ty 타입 검사기에는 astral-sh/ruff#20084에서, pycroscope에는 JelleZijlstra/pycroscope#431에서 지원이 추가되었습니다.
부록
이 부록에서는 타입 시스템과 CPython 런타임에서 다중 상속을 둘러싼 현재 상황을 더 자세히 다룹니다.
CPython의 솔리드 베이스
“솔리드 베이스(solid bases)” 개념은 오랫동안 CPython 구현의 일부였습니다. 이 개념은 2001년의 한 커밋까지 거슬러 올라갑니다. 그럼에도 불구하고 이 개념은 문서에서 거의 주목받지 못했습니다. 이 메커니즘의 세부 사항은 CPython의 내부 객체 표현과 밀접하게 연관되어 있지만, CPython이 어떻게, 그리고 왜 이런 방식으로 동작하는지를 높은 수준에서 설명하는 것은 유용합니다.
CPython의 모든 객체는 본질적으로 C 구조체, 즉 객체에 대한 정보를 담고 있는 연속된 메모리 조각을 가리키는 포인터입니다. 일부 정보는 인터프리터가 관리하며 많은 객체 또는 모든 객체가 공유합니다. 객체의 타입에 대한 참조나 사용자 정의 객체의 __dict__ 속성이 그 예입니다. 일부 클래스는 해당 클래스에 고유한 추가 정보를 담고 있습니다. 예를 들어, __slots__가 있는 사용자 정의 클래스는 각 슬롯을 위한 메모리 공간을 담고 있으며, 내장 float 클래스는 float의 값을 저장하는 C double 값을 담고 있습니다. 이 메모리 레이아웃은 해당 클래스의 모든 인스턴스에서 유지되어야 합니다. float와 상호작용하는 C 코드는 객체 메모리의 특정 오프셋에서 값을 찾을 것으로 기대하기 때문입니다.
자식 클래스가 생성될 때, CPython은 모든 부모 클래스와 호환되는 메모리 레이아웃을 새 클래스에 대해 만들어야 합니다. 예를 들어, float의 자식 클래스가 생성될 때, 그 자식 클래스의 인스턴스를 float 클래스의 기반 구조체와 직접 상호작용하는 C 코드에 전달할 수 있어야 합니다. 따라서 이러한 서브클래스는 double 값을 부모 float 클래스와 같은 오프셋에 저장해야 합니다. 그러나 구조체의 끝에 추가 필드를 더할 수는 있습니다. CPython은 __dict__ 속성으로 이를 처리하는 방법을 알고 있으며, 이 때문에 __dict__를 추가하는 float의 자식 클래스를 만들 수 있습니다.
그러나 구조체에 double 값이 있어야 하는 float를 같은 위치에 다른 데이터를 저장하는 int 같은 다른 C 타입과 결합할 방법은 없습니다. 따라서 float와 int의 공통 서브클래스는 존재할 수 없습니다. 이를 두고 float와 int는 솔리드 베이스라고 합니다.
C로 구현된 클래스는 고정된 오프셋에 데이터를 저장하는 기반 구조체를 가지고 있고, 그 구조체가 부모 클래스의 구조체와 다르면 솔리드 베이스입니다. C 클래스는 (문자열의 내용과 같은) 가변 크기의 데이터 배열을 저장할 수도 있습니다. 이것이 부모 클래스와 다르면 그 클래스 역시 솔리드 베이스가 됩니다. CPython 구현은 이를 타입 객체의 tp_itemsize 필드와 tp_basicsize 필드로부터 추론합니다. 이 필드들은 Python 코드에서도 타입 객체의 문서화되지 않은 속성인 __itemsize__와 __basicsize__로 접근할 수 있습니다.
마찬가지로, Python으로 구현된 클래스는 __slots__가 있으면 솔리드 베이스입니다. 슬롯은 특정 메모리 레이아웃을 강제하기 때문입니다.
Mypy의 비호환성 검사
mypy 타입 검사기는 두 클래스에 서로 호환되지 않는 메서드가 있으면 두 클래스가 호환되지 않는다고 간주합니다. 예를 들어, mypy는 int와 str 클래스가 여러 메서드를 서로 호환되지 않게 정의하므로 두 클래스가 호환되지 않는다고 간주합니다. 다음과 같은 클래스 정의가 주어지면:
class C(int, str):
pass
Mypy는 Definition of "__add__" in base class "int" is incompatible with definition in base class "str"라는 오류를 출력하고, 다른 여러 메서드에 대해서도 비슷한 오류를 출력합니다. 두 클래스의 __add__ 정의는 실제로 호환되지 않으므로 이 오류는 올바릅니다. int.__add__는 int 인자를 기대하는 반면, str.__add__는 str을 기대합니다. 이 클래스가 존재한다면, 런타임에 __add__는 int.__add__로 결정됩니다. C의 인스턴스는 str 타입의 멤버이기도 하지만, 다른 str과의 연결처럼 str이 지원하는 일부 연산은 지원하지 않습니다.
여기까지는 문제가 없습니다. 하지만 mypy는 매우 비슷한 논리로 어떤 클래스도 int와 str을 동시에 상속할 수 없다고 결론짓기도 합니다. 그런데도 mypy는 다음 클래스 정의를 오류 없이 받아들입니다:
from typing import Never
class C(int, str):
def __add__(self, other: object) -> Never:
raise TypeError
def __mod__(self, other: object) -> Never:
raise TypeError
def __mul__(self, other: object) -> Never:
raise TypeError
def __rmul__(self, other: object) -> Never:
raise TypeError
def __ge__(self, other: int | str) -> bool:
return int(self) >= other if isinstance(other, int) else str(self) >= other
def __gt__(self, other: int | str) -> bool:
return int(self) > other if isinstance(other, int) else str(self) > other
def __lt__(self, other: int | str) -> bool:
return int(self) < other if isinstance(other, int) else str(self) < other
def __le__(self, other: int | str) -> bool:
return int(self) <= other if isinstance(other, int) else str(self) <= other
def __getnewargs__(self) -> Never:
raise TypeError
속성에서도 비슷한 상황이 있습니다. 호환되지 않는 속성을 가진 두 클래스가 주어지면, mypy는 공통 서브클래스가 존재할 수 없다고 주장하지만, 이 속성들을 호환되도록 오버라이드하는 서브클래스는 받아들입니다:
from typing import Never
class X:
a: int
class Y:
a: str
class Z(X, Y):
@property
def a(self) -> Never:
raise RuntimeError("no luck")
@a.setter
def a(self, value: int | str) -> None:
pass
지금까지의 예제는 Never를 반환하는 오버라이드에 의존하지만, mypy의 규칙은 실용적으로 더 유용한 구현을 가진 클래스도 거부할 수 있습니다:
from typing import Literal
class Carnivore:
def eat(self, food: Literal["meat"]) -> None:
print("devouring meat")
class Herbivore:
def eat(self, food: Literal["plants"]) -> None:
print("nibbling on plants")
class Omnivore(Carnivore, Herbivore):
def eat(self, food: str) -> None:
print(f"eating {food}")
def is_it_both(obj: Carnivore):
# mypy --warn-unreachable:
# Subclass of "Carnivore" and "Herbivore" cannot exist: would have incompatible method signatures
if isinstance(obj, Herbivore):
pass
실제로 mypy의 규칙은 두 클래스의 교집합에 속하는 값이 존재하는지 추론하는 데 상당히 잘 작동합니다. 서로소 베이스(disjoint base)인 대부분의 내장 클래스는 공교롭게도 __add__나 __iter__ 같은 흔한 던더 메서드를 서로 호환되지 않는 방식으로 구현하므로, mypy는 이들이 호환되지 않는다고 간주합니다. 몇 가지 예외가 있습니다. mypy는 class C(BaseException, int): ...를 허용하지만, 이 두 클래스는 모두 서로소 베이스이며 이 클래스 정의는 런타임에 거부됩니다. 반대로, 실제로 다중 상속을 사용할 때는 대개 부모 클래스들에 호환되지 않는 메서드가 없습니다.
따라서 두 클래스가 교차할 수 없다고 판단하는 mypy의 방식은 너무 넓기도 하고(일부 교집합에 속하는 값이 없다고 잘못 간주합니다) 너무 좁기도 합니다(서로소 베이스 때문에 속하는 값이 없는 일부 교집합을 놓칩니다). 이 내용은 mypy 트래커의 이슈에서 논의되고 있습니다.
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.