[Final] PEP 673 - Self Type
원문 링크: PEP 673 - Self Type
상태: Final 유형: Standards Track 작성일: 10-Nov-2021
PEP 673 – Self
타입
요약 (Abstract)
이 PEP는 메서드가 자신의 클래스 인스턴스를 반환할 때 이를 어노테이션(annotation)하는 간단하고 직관적인 방법을 제안합니다. 이 방식은 기존의 TypeVar
기반 접근 방식(PEP 484에 명시됨)과 동일하게 작동하지만, 더 간결하고 이해하기 쉽습니다.
동기 (Motivation)
자신의 클래스와 동일한 인스턴스를 반환하는 메서드를 작성하는 것은 흔한 사용 사례입니다. 예를 들어, 일반적으로 self
를 반환하는 경우입니다.
class Shape:
def set_scale(self, scale: float):
self.scale = scale
return self
Shape().set_scale(0.5) # => Shape 타입이어야 합니다.
반환 타입을 명시하는 한 가지 방법은 현재 클래스(예: Shape
)로 지정하는 것입니다. 이 메서드를 사용하면 타입 검사기가 예상대로 Shape
타입을 추론합니다.
class Shape:
def set_scale(self, scale: float) -> Shape:
self.scale = scale
return self
Shape().set_scale(0.5) # => Shape
그러나 Shape
의 서브클래스에서 set_scale
을 호출하면, 타입 검사기는 여전히 반환 타입을 Shape
으로 추론합니다. 이는 아래와 같은 상황에서 문제가 될 수 있습니다. 예를 들어, 기본 클래스에는 없는 속성이나 메서드를 사용하려고 할 때 타입 검사기가 오류를 발생시킵니다.
class Circle(Shape):
def set_radius(self, r: float) -> Circle:
self.radius = r
return self
Circle().set_scale(0.5) # *Shape* 타입으로 추론, Circle이 아님
Circle().set_scale(0.5).set_radius(2.7) # => 에러: Shape에는 set_radius 속성이 없습니다.
이러한 경우에 대한 현재의 해결책은 TypeVar
를 정의하고, 기본 클래스를 바운드(bound)로 지정한 후, 이를 self
파라미터와 반환 타입의 어노테이션으로 사용하는 것입니다.
from typing import TypeVar
TShape = TypeVar("TShape", bound="Shape")
class Shape:
def set_scale(self: TShape, scale: float) -> TShape:
self.scale = scale
return self
class Circle(Shape):
def set_radius(self, radius: float) -> Circle:
self.radius = radius
return self
Circle().set_scale(0.5).set_radius(2.7) # => Circle
하지만 이 방법은 장황하고 직관적이지 않습니다. self
는 일반적으로 명시적으로 어노테이션되지 않기 때문에, 위의 해결책은 쉽게 떠오르지 않을 뿐더러, TypeVar
의 바운드(bound="Shape"
)나 self
에 대한 어노테이션을 잊어버리기 쉽습니다.
이러한 어려움 때문에 사용자들은 종종 포기하고 Any
와 같은 대체 타입을 사용하거나, 아예 타입 어노테이션을 생략하게 됩니다. 이 두 가지 모두 코드의 타입 안전성(type safety)을 떨어뜨립니다.
이 PEP는 위의 의도를 표현하는 더 직관적이고 간결한 방법을 제안합니다. 우리는 감싸고 있는(encapsulating) 클래스에 바운드된 타입 변수를 나타내는 특별한 형태인 Self
를 도입합니다. 위와 같은 상황에서, 사용자는 단순히 반환 타입을 Self
로 어노테이션하면 됩니다.
from typing import Self
class Shape:
def set_scale(self, scale: float) -> Self:
self.scale = scale
return self
class Circle(Shape):
def set_radius(self, radius: float) -> Self:
self.radius = radius
return self
반환 타입을 Self
로 어노테이션함으로써, 우리는 더 이상 기본 클래스에 명시적인 바운드를 가진 TypeVar
를 선언할 필요가 없습니다. 반환 타입 Self
는 함수가 self
를 반환한다는 사실을 반영하며 이해하기 쉽습니다.
위 예제에서처럼, 타입 검사기는 Circle().set_scale(0.5)
의 타입을 예상대로 Circle
로 정확하게 추론할 것입니다.
사용 통계 (Usage statistics)
우리는 인기 있는 오픈 소스 프로젝트들을 분석했으며, 위와 같은 패턴이 dict
또는 Callable
과 같은 인기 있는 타입만큼 자주 사용된다는 것을 발견했습니다 (약 40%). 예를 들어, 2021년 10월 기준으로 typeshed에서만 이러한 “Self” 타입은 523회 사용되었으며, dict
는 1286회, Callable
은 1314회 사용되었습니다. 이는 Self
타입이 상당히 자주 사용될 것이며, 사용자들은 이 더 간단한 접근 방식으로부터 많은 이점을 얻을 수 있음을 시사합니다.
Python 타입 사용자들도 제안 문서와 GitHub에서 이 기능을 자주 요청했습니다.
명세 (Specification)
메서드 시그니처에서의 사용 (Use in Method Signatures)
메서드 시그니처에서 사용되는 Self
는 해당 클래스에 바운드된 TypeVar
처럼 처리됩니다.
from typing import Self
class Shape:
def set_scale(self, scale: float) -> Self:
self.scale = scale
return self
위 코드는 다음 코드와 동일하게 처리됩니다.
from typing import TypeVar
SelfShape = TypeVar("SelfShape", bound="Shape")
class Shape:
def set_scale(self: SelfShape, scale: float) -> SelfShape:
self.scale = scale
return self
이것은 서브클래스에서도 동일하게 작동합니다.
class Circle(Shape):
def set_radius(self, radius: float) -> Self:
self.radius = radius
return self
위 코드는 다음 코드와 동일하게 처리됩니다.
SelfCircle = TypeVar("SelfCircle", bound="Circle")
class Circle(Shape):
def set_radius(self: SelfCircle, radius: float) -> SelfCircle:
self.radius = radius
return self
한 가지 구현 전략은 전처리 단계에서 전자를 후자로 단순히 “desugar”(구문 설탕 제거)하는 것입니다. 메서드가 시그니처에 Self
를 사용하면, 메서드 내 self
의 타입은 Self
가 됩니다. 다른 경우에는 self
의 타입은 감싸는(enclosing) 클래스로 유지됩니다.
클래스메서드 시그니처에서의 사용 (Use in Classmethod Signatures)
Self
타입 어노테이션은 자신이 작동하는 클래스의 인스턴스를 반환하는 클래스메서드(classmethod)에서도 유용합니다. 예를 들어, 다음 스니펫의 from_config
는 주어진 config
로부터 Shape
객체를 만듭니다.
class Shape:
def __init__(self, scale: float) -> None: ...
@classmethod
def from_config(cls, config: dict[str, float]) -> Shape:
return cls(config["scale"])
그러나 이렇게 하면 Circle.from_config(...)
가 Shape
타입의 값을 반환하는 것으로 추론되는데, 실제로는 Circle
이어야 합니다.
class Circle(Shape):
def circumference(self) -> float: ...
shape = Shape.from_config({"scale": 7.0}) # => Shape
circle = Circle.from_config({"scale": 7.0}) # => *Shape* 타입으로 추론, Circle이 아님
circle.circumference() # 에러: `Shape`에는 `circumference` 속성이 없습니다.
이에 대한 현재의 해결책은 직관적이지 않고 오류 발생 가능성이 높습니다.
Self = TypeVar("Self", bound="Shape") # TypeVar 이름 충돌을 피하기 위해 이름을 다르게 할 수도 있습니다.
class Shape:
@classmethod
def from_config(
cls: type[Self], # 복잡한 어노테이션
config: dict[str, float]
) -> Self:
return cls(config["scale"])
우리는 Self
를 직접 사용하는 것을 제안합니다.
from typing import Self
class Shape:
@classmethod
def from_config(cls, config: dict[str, float]) -> Self:
return cls(config["scale"])
이는 복잡한 cls: type[Self]
어노테이션과 바운드가 있는 TypeVar
선언을 피하게 해줍니다. 다시 말하지만, 후자의 코드는 전자의 코드와 동일하게 동작합니다.
파라미터 타입에서의 사용 (Use in Parameter Types)
Self
의 또 다른 용도는 현재 클래스의 인스턴스를 기대하는 파라미터를 어노테이션하는 것입니다.
Self = TypeVar("Self", bound="Shape")
class Shape:
def difference(self: Self, other: Self) -> float: ...
def apply(self: Self, f: Callable[[Self], None]) -> None: ...
우리는 동일한 동작을 위해 Self
를 직접 사용하는 것을 제안합니다.
from typing import Self
class Shape:
def difference(self, other: Self) -> float: ...
def apply(self, f: Callable[[Self], None]) -> None: ...
self: Self
를 명시하는 것이 무해하기 때문에, 일부 사용자들은 위 코드를 다음과 같이 작성하는 것이 더 읽기 쉽다고 생각할 수도 있습니다.
class Shape:
def difference(self: Self, other: Self) -> float: ...
속성 어노테이션에서의 사용 (Use in Attribute Annotations)
Self
의 또 다른 용도는 속성(attribute)을 어노테이션하는 것입니다. 한 가지 예시는 요소가 현재 클래스의 서브클래스여야 하는 LinkedList
를 가질 때입니다.
from dataclasses import dataclass
from typing import Generic, TypeVar
T = TypeVar("T")
@dataclass
class LinkedList(Generic[T]):
value: T
next: LinkedList[T] | None = None # OK
# LinkedList[int](value=1, next=LinkedList[int](value=2)) # OK
# LinkedList[int](value=1, next=LinkedList[str](value="hello")) # Not OK
그러나 next
속성을 LinkedList[T]
로 어노테이션하면 서브클래스와 함께 유효하지 않은 구성이 허용됩니다.
@dataclass
class OrdinalLinkedList(LinkedList[int]):
def ordinal_value(self) -> str:
return as_ordinal(self.value)
# LinkedList[int]는 OrdinalLinkedList의 서브클래스가 아니므로
# 허용되어서는 안 되지만, 타입 검사기는 이를 허용합니다.
xs = OrdinalLinkedList(value=1, next=LinkedList[int](value=2))
if xs.next:
print(xs.next.ordinal_value()) # 런타임 에러.
우리는 이 제약 조건을 next: Self | None
을 사용하여 표현하는 것을 제안합니다.
from typing import Self
from dataclasses import dataclass
from typing import Generic, TypeVar
T = TypeVar("T")
@dataclass
class LinkedList(Generic[T]):
value: T
next: Self | None = None
@dataclass
class OrdinalLinkedList(LinkedList[int]):
def ordinal_value(self) -> str:
return str(self.value) # 예제에 맞게 변경
xs = OrdinalLinkedList(value=1, next=LinkedList[int](value=2))
# 타입 에러: OrdinalLinkedList가 예상되었지만, LinkedList[int]를 받았습니다.
if xs.next is not None:
xs.next = OrdinalLinkedList(value=3, next=None) # OK
xs.next = LinkedList[int](value=3, next=None) # Not OK (타입 에러)
위 코드는 Self
타입을 포함하는 각 속성을 해당 타입을 반환하는 @property
처럼 취급하는 것과 의미론적으로 동일합니다.
from dataclasses import dataclass
from typing import Any, Generic, TypeVar
T = TypeVar("T")
Self = TypeVar("Self", bound="LinkedList") # 내부적으로 이렇게 처리될 수 있음
class LinkedList(Generic[T]):
value: T
_next: Any # 실제 내부 저장소
@property
def next(self: Self) -> Self | None:
return self._next
@next.setter
def next(self: Self, next: Self | None) -> None:
self._next = next
class OrdinalLinkedList(LinkedList[int]):
def ordinal_value(self) -> str:
return str(self.value)
제네릭 클래스에서의 사용 (Use in Generic Classes)
Self
는 제네릭(Generic) 클래스 메서드에서도 사용할 수 있습니다.
from typing import Generic, TypeVar
T = TypeVar("T")
class Container(Generic[T]):
value: T
def set_value(self, value: T) -> Self: ...
이는 다음 코드와 동일합니다.
from typing import Any, Generic, TypeVar
T = TypeVar("T")
Self = TypeVar("Self", bound="Container[Any]") # 내부적으로 이렇게 처리될 수 있음
class Container(Generic[T]):
value: T
def set_value(self: Self, value: T) -> Self: ...
동작은 메서드가 호출된 객체의 타입 인자(type argument)를 보존하는 것입니다. 구체적인 타입 Container[int]
를 가진 객체에서 호출될 때, Self
는 Container[int]
에 바인딩됩니다. 제네릭 타입 Container[T]
를 가진 객체에서 호출될 때, Self
는 Container[T]
에 바인딩됩니다.
def object_with_concrete_type() -> None:
int_container: Container[int] = Container() # 실제 인스턴스화
str_container: Container[str] = Container() # 실제 인스턴스화
# reveal_type(int_container.set_value(42)) # => Container[int] (타입 검사기에서)
# reveal_type(str_container.set_value("hello")) # => Container[str] (타입 검사기에서)
def object_with_generic_type(
container: Container[T],
value: T,
) -> Container[T]:
return container.set_value(value) # => Container[T]
이 PEP는 메서드 set_value
내 self.value
의 정확한 타입을 명시하지 않습니다. 일부 타입 검사기는 Self = TypeVar("Self", bound=Container[T])
와 같은 클래스-로컬 타입 변수를 사용하여 Self
타입을 구현하여 정확한 타입 T
를 추론할 수 있습니다. 그러나 클래스-로컬 타입 변수가 표준화된 타입 시스템 기능이 아니라는 점을 감안할 때, self.value
에 대해 Any
를 추론하는 것도 허용됩니다. 이 부분은 타입 검사기에게 맡겨집니다.
Self[int]
와 같이 타입 인자와 함께 Self
를 사용하는 것은 거부됩니다. 이는 self
파라미터의 타입에 대한 모호성을 유발하고 불필요한 복잡성을 초래하기 때문입니다.
class Container(Generic[T]):
def foo(
self,
other: Self[int], # 거부됨
other2: Self,
) -> Self[str]: # 거부됨
# ...
이러한 경우, self
에 대해 명시적인 타입을 사용하는 것을 권장합니다.
class Container(Generic[T]):
def foo(
self: Container[T],
other: Container[int],
other2: Container[T]
) -> Container[str]: ...
프로토콜에서의 사용 (Use in Protocols)
Self
는 클래스에서의 사용과 유사하게 프로토콜(Protocol) 내에서도 유효합니다.
from typing import Protocol, Self
class ShapeProtocol(Protocol):
scale: float
def set_scale(self, scale: float) -> Self:
self.scale = scale
return self
위 코드는 다음 코드와 동일하게 처리됩니다.
from typing import TypeVar, Protocol
SelfShape = TypeVar("SelfShape", bound="ShapeProtocol")
class ShapeProtocol(Protocol):
scale: float
def set_scale(self: SelfShape, scale: float) -> SelfShape:
self.scale = scale
return self
프로토콜에 바운드된 TypeVar
의 동작에 대한 자세한 내용은 PEP 544를 참조하세요.
클래스의 프로토콜 호환성 검사: 프로토콜이 메서드 또는 속성 어노테이션에 Self
를 사용하는 경우, 클래스 Foo
는 해당 메서드 및 속성 어노테이션에 Self
또는 Foo
또는 Foo
의 서브클래스 중 하나를 사용하는 경우 프로토콜과 호환되는 것으로 간주됩니다. 다음 예시를 참조하세요.
from typing import Protocol
class ShapeProtocol(Protocol):
def set_scale(self, scale: float) -> Self: ...
class ReturnSelf:
scale: float = 1.0
def set_scale(self, scale: float) -> Self:
self.scale = scale
return self
class ReturnConcreteShape:
scale: float = 1.0
def set_scale(self, scale: float) -> ReturnConcreteShape:
self.scale = scale
return self
class BadReturnType:
scale: float = 1.0
def set_scale(self, scale: float) -> int:
self.scale = scale
return 42
class ReturnDifferentClass:
scale: float = 1.0
def set_scale(self, scale: float) -> ReturnConcreteShape:
return ReturnConcreteShape() # 인스턴스 생성
def accepts_shape(shape: ShapeProtocol) -> None:
y = shape.set_scale(0.5)
# reveal_type(y) # 타입 검사기에서 타입 확인
def main() -> None:
return_self_shape: ReturnSelf
return_concrete_shape: ReturnConcreteShape
bad_return_type: BadReturnType
return_different_class: ReturnDifferentClass
accepts_shape(return_self_shape) # OK
accepts_shape(return_concrete_shape) # OK
accepts_shape(bad_return_type) # Not OK (반환 타입이 int이므로)
# Not OK (서브클래스가 아닌 다른 클래스를 반환하므로)
accepts_shape(return_different_class)
Self
의 유효한 위치 (Valid Locations for Self)
Self
어노테이션은 클래스 컨텍스트 내에서만 유효하며, 항상 감싸는(encapsulating) 클래스를 참조합니다. 중첩된 클래스(nested classes)를 포함하는 컨텍스트에서는 Self
가 항상 가장 안쪽 클래스를 참조합니다.
다음과 같은 Self
사용은 허용됩니다.
class ReturnsSelf:
def foo(self) -> Self: ... # 허용됨
@classmethod
def bar(cls) -> Self: # 허용됨
return cls()
def __new__(cls, value: int) -> Self: ... # 허용됨
def explicitly_use_self(self: Self) -> Self: ... # 허용됨
# 허용됨 (Self는 다른 타입 내에 중첩될 수 있습니다)
def returns_list(self) -> list[Self]: ... # 허용됨 (Self는 다른 타입 내에 중첩될 수 있습니다)
@classmethod
def return_cls(cls) -> type[Self]: return cls
class Child(ReturnsSelf): # 허용됨 (Self 어노테이션을 사용하는 메서드를 오버라이드할 수 있습니다)
def foo(self) -> Self: ...
class TakesSelf:
def foo(self, other: Self) -> bool: ... # 허용됨
class Recursive: # 허용됨 (@property가 Self | None을 반환하는 것으로 처리됩니다)
next: Self | None
class CallableAttribute:
def foo(self) -> int: ...
# 허용됨 (@property가 Callable 타입을 반환하는 것으로 처리됩니다)
bar: Callable[[Self], int] = foo
class HasNestedFunction:
x: int = 42
def foo(self) -> None:
# 허용됨 (Self는 HasNestedFunction에 바인딩됩니다).
def nested(z: int, inner_self: Self) -> Self:
print(z)
print(inner_self.x)
return inner_self
nested(42, self) # OK
class Outer:
class Inner:
def foo(self) -> Self: ... # 허용됨 (Self는 Inner에 바인딩됩니다)
다음과 같은 Self
사용은 거부됩니다.
def foo(bar: Self) -> Self: ... # 거부됨 (클래스 내부에 있지 않음)
bar: Self # 거부됨 (클래스 내부에 있지 않음)
class Foo:
# 거부됨 (Self는 unknown으로 처리됩니다).
def has_existing_self_annotation(self: T) -> Self: ...
class Foo:
def return_concrete_type(self) -> Self:
return Foo() # 거부됨 (아래 FooChild 참조)
class FooChild(Foo):
child_value: int = 42
def child_method(self) -> None:
# 런타임 시, 이것은 Foo이며 FooChild가 아닙니다.
y = self.return_concrete_type()
# y.child_value # 런타임 에러: Foo에는 child_value 속성이 없습니다.
# 위 시나리오가 문제가 되므로, Self를 반환할 때는 해당 서브클래스의 인스턴스를
# 생성하여 반환해야 합니다 (예: return self.__class__())
class Bar(Generic[T]):
def bar(self) -> T: ...
class Baz(Bar[Self]): ... # 거부됨
Self
를 포함하는 타입 별칭(type aliases)은 거부됩니다. 클래스 정의 외부에서 Self
를 지원하려면 타입 검사기에서 많은 특별 처리가 필요할 수 있습니다. 또한 Self
를 클래스 정의 외부에서 사용하는 것이 이 PEP의 나머지 부분과도 상반된다는 점을 고려할 때, 별칭의 추가적인 편리함이 그만한 가치가 있다고 보지 않습니다.
TupleSelf = Tuple[Self, Self] # 거부됨
class Alias:
def return_tuple(self) -> TupleSelf: # 거부됨
return (self, self)
staticmethod
에서는 Self
가 거부됩니다. self
나 cls
를 반환할 필요가 없으므로 Self
는 큰 가치를 추가하지 않습니다. 유일하게 가능한 사용 사례는 파라미터 자체를 반환하거나 파라미터로 전달된 컨테이너의 요소를 반환하는 것일 것입니다. 이러한 것들은 추가적인 복잡성만큼의 가치가 없는 것으로 판단됩니다.
class Base:
@staticmethod
def make() -> Self: # 거부됨
...
@staticmethod
def return_parameter(foo: Self) -> Self: # 거부됨
...
마찬가지로, 메타클래스(metaclasses)에서는 Self
가 거부됩니다. 이 PEP의 Self
는 일관되게 동일한 타입(self
의 타입)을 참조합니다. 하지만 메타클래스에서는 서로 다른 메서드 시그니처에서 다른 타입을 참조해야 할 것입니다. 예를 들어, __mul__
에서 반환 타입의 Self
는 감싸는 클래스 MyMetaclass
가 아닌 구현 클래스 Foo
를 참조할 것입니다. 그러나 __new__
에서는 반환 타입의 Self
가 감싸는 클래스 MyMetaclass
를 참조할 것입니다. 혼동을 피하기 위해 이 예외적인 경우는 거부됩니다.
class MyMetaclass(type):
def __new__(cls, *args: Any) -> Self: # 거부됨
return super().__new__(cls, *args)
def __mul__(cls, count: int) -> list[Self]: # 거부됨
return [cls()] * count
class Foo(metaclass=MyMetaclass): ...
런타임 동작 (Runtime behavior)
Self
는 subscriptable(첨자 사용 가능)하지 않으므로, typing.NoReturn
과 유사한 구현을 제안합니다.
@_SpecialForm
def Self(self, params):
"""클래스에서 "self"의 타입을 나타내는 데 사용됩니다.
예시::
from typing import Self
class ReturnsSelf:
def parse(self, data: bytes) -> Self:
...
return self
"""
raise TypeError(f"{self} is not subscriptable")
거부된 대안 (Rejected Alternatives)
타입 검사기가 반환 타입을 추론하도록 허용 (Allow the Type Checker to Infer the Return Type)
한 가지 제안은 Self
타입을 암묵적으로 두고, 타입 검사기가 메서드 본문을 분석하여 반환 타입이 self
파라미터의 타입과 동일해야 한다고 추론하도록 하는 것입니다.
class Shape:
def set_scale(self, scale: float):
self.scale = scale
return self # 타입 검사기가 self를 반환한다는 것을 추론합니다.
우리는 “명시적인 것이 암묵적인 것보다 낫다(Explicit Is Better Than Implicit)”는 이유로 이를 거부합니다. 또한, 위 접근 방식은 분석할 메서드 본문이 없는 타입 스텁(type stubs)에서는 실패할 것입니다.
참고 구현 (Reference Implementations)
- Mypy: Mypy의 개념 증명(Proof of concept) 구현.
- Pyright: v1.1.184
- Self의 런타임 구현: PR.
자료 (Resources)
Python의 Self
타입에 대한 유사한 논의는 2016년경 Mypy에서 시작되었습니다. Mypy issue #1212 - SelfType or another way to spell “type of self”를 참조하세요. 그러나 최종적으로 채택된 접근 방식은 “before” 예시에서 보여진 바운드된 TypeVar
접근 방식이었습니다. 이와 관련된 다른 이슈로는 Mypy issue #2354 - Self types in generic classes가 있습니다.
Pradeep은 PyCon Typing Summit 2021에서 구체적인 제안을 발표했습니다. (녹화된 발표, 슬라이드 참조). James는 typing-sig에서 독립적으로 이 제안을 제기했습니다. (Typing-sig 스레드 참조).
다른 언어들도 감싸는 클래스의 타입을 표현하는 유사한 방법을 가지고 있습니다.
- TypeScript는
this
타입을 가지고 있습니다 (TypeScript docs). - Rust는
Self
타입을 가지고 있습니다 (Rust docs).
이 PEP에 대한 피드백을 주신 다음 분들께 감사드립니다. Jia Chen, Rebecca Chen, Sergei Lebedev, Kaylynn Morgan, Tuomas Suutari, Eric Traut, Alex Waygood, Shannon Zhu, and Никита Соболев.
저작권 (Copyright)
이 문서는 퍼블릭 도메인 또는 CC0-1.0-Universal 라이선스 중 더 관대한 조건으로 배포됩니다.
⚠️ 알림: 이 문서는 AI를 활용하여 번역되었으며, 기술적 정확성을 보장하지 않습니다. 정확한 내용은 반드시 원문을 확인하시기 바랍니다.
Comments