PEP 767 – 읽기 전용 속성에 어노테이션 달기
- Author:
- Łukasz Modzelewski
- Sponsor:
- Carl Meyer <carl at oddbird.net>
- Discussions-To:
- Discourse thread
- Status:
- Draft
- Type:
- Standards Track
- Topic:
- Typing
- Created:
- 18-Nov-2024
- Python-Version:
- 3.15
- Post-History:
- 09-Oct-2024 05-Dec-2024
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
PEP 705는 읽기 전용 typing.TypedDict 항목을 정의할 수 있도록 typing.ReadOnly 타입 한정자를 도입했습니다.
이 PEP는 클래스 및 프로토콜 어트리뷰트를 읽기 전용으로 표시하는 단일하고 간결한 방법으로, 해당 어노테이션에 ReadOnly를 사용하는 것을 제안합니다.
PEP 705와 마찬가지로, 이 PEP는 런타임에 속성을 설정하는 방식에 아무런 변경도 가하지 않습니다. 읽기 전용 속성의 올바른 사용은 정적 타입 검사기에 의해서만 강제되도록 의도되었습니다.
용어
이 PEP는 읽을 수는 있지만 대입하거나(초기화를 지원하기 위한 제한된 경우는 예외) 삭제할 수는 없는 속성을 “읽기 전용”이라고 부릅니다.
동기
Python 타입 시스템에는 속성을 읽기 전용으로 표시하는 간결한 단일 방법이 없습니다. 이 기능은 다른 정적 타입 언어와 점진적 타입 언어(예: C# 또는 TypeScript)에 존재하며, 타입 검사기 수준에서 속성에 대입하거나 속성을 삭제하는 기능을 없애는 데 유용할 뿐만 아니라 구조적 서브타이핑을 위한 폭넓은 인터페이스를 정의하는 데도 유용합니다.
클래스
현재 타입 검사기가 인정하는, 읽기 전용 속성을 구현하는 주요 방법은 세 가지가 있습니다.
typing.Final로 속성에 어노테이션 달기:class Foo: number: Final[int] def __init__(self, number: int) -> None: self.number = number class Bar: def __init__(self, number: int) -> None: self.number: Final = number
dataclasses에서 지원됩니다(typing#1669 이후로는 타입 검사기에서도 지원됩니다).number를 재정의할 수 없습니다 -Final의 명세는 해당 이름을 서브클래스에서 재정의할 수 없도록 규정합니다.
- 속성을 “_internal”로 표시하고, 읽기 전용
@property를 통해 노출하기:class Foo: _number: int def __init__(self, number: int) -> None: self._number = number @property def number(self) -> int: return self._number
number를 재정의할 수 있지만,@property를 사용하는 것으로 제한됩니다. [1]- 런타임에 읽기 전용입니다. [2]
- 추가 보일러플레이트가 필요합니다.
dataclasses에서 지원되지만 잘 조합되지 않습니다 - 합성된__init__과__repr__는_number를 매개변수/속성 이름으로 사용합니다.
dataclasses.dataclass()또는typing.NamedTuple같은 “동결” 메커니즘 사용하기:@dataclass(frozen=True) class Foo: number: int # implicitly read-only class Bar(NamedTuple): number: int # implicitly read-only
@dataclass의 경우에는number를 재정의할 수 있습니다.- 런타임에 읽기 전용입니다. [2]
- 속성별 제어가 불가능합니다 - 이 메커니즘들은 클래스 전체에 적용됩니다.
- 동결된 데이터클래스는 약간의 런타임 오버헤드를 발생시킵니다.
- 대부분의 클래스에는
NamedTuple에서 상속되는 인덱싱, 이터레이션, 연결 기능이 필요하지 않습니다.
프로토콜
두 가지 요구 사항을 정의하는 Protocol 멤버 name: T가 있다고 가정해 봅시다:
hasattr(obj, "name")isinstance(obj.name, T)
런타임에는 다음이 모두 이 요구 사항들을 충족할 수 있습니다:
name: T속성을 가진 객체,name: ClassVar[T]클래스 변수를 가진 클래스,- 위 클래스의 인스턴스,
@propertydef name(self) -> T를 가진 객체,functools.cached_property()와 같은 사용자 정의 디스크립터를 가진 객체.
현재 typing 명세는 (추상) 프로퍼티를 사용하여 이러한 프로토콜 멤버를 만들 수 있도록 허용합니다:
class HasName(Protocol):
@property
def name(self) -> T: ...
이 문법에는 몇 가지 단점이 있습니다:
근거
이러한 문제는 속성 수준의 타입 한정자로 해결할 수 있습니다. 이 역할에는 ReadOnly가 선택되었는데, 그 이름이 의도를 잘 전달하고 새로 제안된 변경 사항이 PEP 705에서 정의된 의미를 보완하기 때문입니다.
이제 읽기 전용 인스턴스 속성을 가진 클래스를 다음과 같이 정의할 수 있습니다:
from typing import ReadOnly
class Member:
def __init__(self, id: int) -> None:
self.id: ReadOnly[int] = id
…그리고 Protocols에서 설명한 프로토콜은 이제 다음과 같이 간단해집니다:
from typing import Protocol, ReadOnly
class HasName(Protocol):
name: ReadOnly[str]
명세
사용법
typing.ReadOnly 타입 한정자는 명목적 클래스와 프로토콜의 속성에 대한 유효한 어노테이션이 됩니다. 클래스 수준과 __init__ 내부에서 개별 속성을 읽기 전용으로 표시하는 데 사용할 수 있습니다:
class Book:
id: ReadOnly[int]
def __init__(self, id: int, name: str) -> None:
self.id = id
self.name: ReadOnly[str] = name
[<type>] 없이 ReadOnly만 단독으로 사용하는 것은 허용되지 않습니다. 타입 검사기는 Initialization에서 설명하는 맥락을 제외하고, ReadOnly로 어노테이션된 속성에 할당하거나 그 속성을 삭제하려는 모든 시도에 대해 오류를 보고해야 합니다.
Final로 어노테이션된 속성을 삭제하는 것도 오류여야 합니다. (이는 현재 명세되어 있지 않습니다.)
현재 아무런 의미가 없는 다른 위치(지역/전역 변수나 함수 매개변수 등)의 어노테이션에서 ReadOnly를 사용하는 것은 이 PEP의 범위를 벗어나는 것으로 간주되며, 계속 금지됩니다.
ReadOnly는 속성 값의 가변성에 영향을 주지 않습니다. 불변 프로토콜과 ABC(collections.abc에 있는 것 등)를 ReadOnly와 함께 사용하여 타입 검사기 수준에서 해당 값의 변경을 금지할 수 있습니다:
from collections import abc
from dataclasses import dataclass
from typing import Protocol, ReadOnly
@dataclass
class Game:
name: str
class HasGames[T: abc.Collection[Game]](Protocol):
games: ReadOnly[T]
def add_games(shelf: HasGames[list[Game]]) -> None:
shelf.games.append(Game("Half-Life")) # ok: list is mutable
shelf.games[-1].name = "Black Mesa" # ok: "name" is not read-only
shelf.games = [] # error: "games" is read-only
del shelf.games # error: "games" is read-only and cannot be deleted
def read_games(shelf: HasGames[abc.Sequence[Game]]) -> None:
shelf.games.append(...) # error: "Sequence" has no attribute "append"
shelf.games[0].name = "Blue Shift" # ok: "name" is not read-only
shelf.games = [] # error: "games" is read-only
동결된(frozen) 데이터 클래스와 네임드 튜플의 모든 인스턴스 속성은 암묵적으로 읽기 전용인 것으로 간주되어야 합니다. 타입 검사기는 이러한 속성에 ReadOnly로 어노테이션하는 것이 불필요하다고 알릴 수 있지만, 이를 오류로 보아서는 안 됩니다:
from dataclasses import dataclass
from typing import Final, NewType, ReadOnly
@dataclass(frozen=True)
class Point:
x: int # implicitly read-only
y: ReadOnly[int] # ok, redundant
uint = NewType("uint", int)
@dataclass(frozen=True)
class UnsignedPoint(Point):
x: ReadOnly[uint] # ok, redundant; narrower type
y: Final[uint] # not redundant, Final imposes extra restrictions; narrower type
초기화
명목적 클래스의 읽기 전용 속성에는 그 속성을 선언한 클래스와 그 클래스의 명목적 서브클래스 안에서만, 그리고 아래에 설명하는 위치에서만 대입할 수 있습니다. 속성에 대입할 수 있는 횟수에는 제한이 없습니다.
타입 검사기는 인스턴스가 생성된 후에도 초기화되지 않은 채로 남을 수 있는 읽기 전용 속성에 대해 경고하도록 선택할 수 있습니다(stubs, 프로토콜 또는 ABC는 예외입니다):
class Patient:
id: ReadOnly[int] # error: "id" is not initialized on all code paths
name: ReadOnly[str] # error: "name" is never initialized
def __init__(self) -> None:
if random.random() > 0.5:
self.id = 123
class HasName(Protocol):
name: ReadOnly[str] # ok
인스턴스 속성
읽기 전용 인스턴스 속성에 대한 대입은 다음 문맥에서 허용되어야 합니다:
__init__에서, 첫 번째 매개변수(보통self)로 전달받은 선언 클래스의 인스턴스에 대한 대입.__new__와@classmethod에서, 다음을 통해 생성된 선언 클래스의 인스턴스에 대한 대입:super().__new__()호출,type[T]타입인 임의의 객체에 대한__new__호출. 여기서T는 선언 클래스의 명목적 상위 타입입니다.
- 클래스 스코프에서의 선언 시점.
또한 타입 검사기는 __new__와 @classmethod에서 선언 클래스의 인스턴스에 대한 대입을 인스턴스의 출처와 관계없이 허용하도록 선택할 수 있습니다. (인스턴스가 이미 초기화되었을 수 있으므로, 이 선택은 건전성을 내주고 구현의 단순함을 얻는 것입니다.)
from collections import abc
from typing import ReadOnly
class Band:
name: str
songs: ReadOnly[list[str]]
def __init__(self, name: str, songs: abc.Iterable[str] | None = None) -> None:
self.name = name
self.songs = []
if songs is not None:
self.songs = list(songs) # multiple assignments are fine
def clear(self) -> None:
# error: assignment to read-only "songs" outside initialization
self.songs = []
band = Band(name="Bôa", songs=["Duvet"])
band.name = "Python" # ok: "name" is not read-only
band.songs = [] # error: "songs" is read-only
band.songs.append("Twilight") # ok: list is mutable
# a simplified immutable Fraction class
class Fraction:
numerator: ReadOnly[int]
denominator: ReadOnly[int]
def __new__(
cls,
numerator: str | int | float | Decimal | Rational = 0,
denominator: int | Rational | None = None
) -> Self:
self = super().__new__(cls)
if denominator is None:
if type(numerator) is int:
self.numerator = numerator
self.denominator = 1
return self
elif isinstance(numerator, Rational): ...
else: ...
@classmethod
def from_float(cls, f: float, /) -> Self:
self = super().__new__(cls)
self.numerator, self.denominator = f.as_integer_ratio()
return self
클래스 수준 선언에 초기값이 있으면, 그 값은 인스턴스를 위한 플라이웨이트 기본값 역할을 할 수 있습니다:
class Patient:
number: ReadOnly[int] = 0
def __init__(self, number: int | None = None) -> None:
if number is not None:
self.number = number
Note
이는 __slots__가 없는 클래스에서만 가능합니다. 슬롯에 포함된 속성은 클래스 수준 기본값을 가질 수 없습니다.
클래스 속성
읽기 전용 클래스 속성은 ReadOnly와 ClassVar로 함께 어노테이트된 속성입니다. 이러한 속성에 대한 대입은 다음 문맥에서 허용되어야 합니다:
- 클래스 스코프에서의 선언 시점.
__init_subclass__에서, 첫 번째 매개변수(보통cls)로 전달받은 클래스 객체에 대한 대입.
class URI:
protocol: ReadOnly[ClassVar[str]] = ""
def __init_subclass__(cls, protocol: str = "") -> None:
cls.protocol = protocol
class File(URI, protocol="file"): ...
프로토콜
프로토콜 속성 선언에서 name: ReadOnly[T]는 프로토콜을 따르는 값이 .name 접근을 지원해야 하며, 반환된 값을 T에 대입할 수 있어야 함을 나타냅니다:
class HasName(Protocol):
name: ReadOnly[str]
class NamedAttr:
name: str
class NamedProp:
@property
def name(self) -> str: ...
class NamedClassVar:
name: ClassVar[str]
class NamedDescriptor:
@cached_property
def name(self) -> str: ...
# all of the following are ok
has_name: HasName
has_name = NamedAttr()
has_name = NamedProp()
has_name = NamedClassVar
has_name = NamedClassVar()
has_name = NamedDescriptor()
읽기 전용 프로토콜 속성은 어떤 문맥에서도 대입하거나 삭제할 수 없습니다.
프로토콜을 상속하여 그 구현을 명시적으로 선언하는 경우, (프로토콜이 정의할 수 있는) 읽기 전용 속성에 관한 규칙을 적용할 때는 해당 프로토콜을 명목적 클래스처럼 취급해야 한다는 점에 유의하십시오. 특히 이는 서브클래스가 프로토콜에서 정의한 읽기 전용 속성을 초기화할 수 있다는 의미입니다.
타입 검사기는 프로토콜의 타입(type[HasName])이 프로토콜의 읽기 전용 속성에 대한 접근을 지원한다고 가정해서는 안 됩니다. 프로토콜의 타입에 속성이 존재하더라도 그 속성의 타입에 대해서는 어떠한 가정도 해서는 안 됩니다.
type[HasName].name의 동작과 타입을 정확하게 모델링하기는 어려우므로, 복잡성을 줄이기 위해 이 PEP에서는 이를 다루지 않았습니다. 향후 타이핑 명세가 개선되면서 이 동작이 구체화될 수 있습니다.
서브타이핑
읽기 전용 속성은 대입하거나 삭제할 수 없으므로 공변적입니다. 이는 서브타이핑에 몇 가지 영향을 줍니다. PEP 705의 내용을 빌리면 다음과 같습니다:
- 서브클래스는 읽기 전용 속성을 쓰기 가능한 속성, 디스크립터 또는 클래스 변수로 재선언할 수 있습니다:
@dataclass class HasTitle: title: ReadOnly[str] @dataclass class Game(HasTitle): title: str year: int game = Game(title="DOOM", year=1993) game.year = 1994 game.title = "DOOM II" # ok: attribute is no longer read-only class TitleProxy(HasTitle): @functools.cached_property def title(self) -> str: ... class SharedTitle(HasTitle): title: ClassVar[str] = "Still Grey"
- 재선언되지 않은 읽기 전용 속성은 계속 읽기 전용으로 남습니다.:
class Game(HasTitle): year: int def __init__(self, title: str, year: int) -> None: super().__init__(title) # preferred self.title = title # ok self.year = year game = Game(title="Robot Wants Kitty", year=2010) game.title = "Robot Wants Puppy" # error: "title" is read-only
- 서브클래스는 읽기 전용 속성의 타입을 narrow할 수 있습니다:
from collections import abc class GameCollection(Protocol): games: ReadOnly[abc.Collection[Game]] @dataclass class GameSeries(GameCollection): name: str games: ReadOnly[list[Game]] # ok: list[Game] is assignable to Collection[Game]
다른 타입 한정자와의 상호 작용
ReadOnly는 ClassVar 및 Annotated와 중첩 순서에 관계없이 함께 사용할 수 있습니다:
class Foo:
foo: ClassVar[ReadOnly[str]] = "foo"
bar: Annotated[ReadOnly[int], Gt(0)]
class Foo:
foo: ReadOnly[ClassVar[str]] = "foo"
bar: ReadOnly[Annotated[int, Gt(0)]]
이는 PEP 705에 정의된 ReadOnly와 typing.TypedDict의 상호 작용과 일관됩니다.
Final은 NamedTuple 같은 메커니즘 때문이든, 부모 클래스가 ReadOnly로 선언했기 때문이든, 이미 읽기 전용인 속성을 (재)선언하는 데 사용할 수 있습니다.
Final의 의미가 읽기 전용 속성의 의미보다 우선합니다. ReadOnly와 Final을 함께 사용하는 것은 중복이며, 타입 검사기는 이 중복에 대해 경고하거나 오류를 낼 수 있습니다.
하위 호환성
이 PEP는 ReadOnly가 유효한 새로운 문맥을 도입합니다. 해당 위치를 검사하는 프로그램은 이를 지원하도록 변경되어야 합니다. 이는 주로 타입 검사기에 영향을 줄 것으로 예상됩니다.
다만 이전 버전의 Python에서 백포트된 typing_extensions.ReadOnly를 사용할 때는 주의해야 합니다. 어노테이션을 검사하는 메커니즘은 ReadOnly를 만났을 때 잘못 동작할 수 있습니다. 특히 ClassVar를 찾는 @dataclass 데코레이터는 ReadOnly[ClassVar[...]]를 인스턴스 속성으로 잘못 취급할 수 있습니다.
인트로스펙션 문제를 피하려면 ReadOnly[ClassVar[...]] 대신 ClassVar[ReadOnly[...]]를 사용하십시오.
보안 영향
이 PEP로 인해 발생하는 것으로 알려진 보안상의 영향은 없습니다.
교육 방법
PEP 705의 선례를 따라 제안하는 typing 모듈 문서 변경 사항은 다음과 같습니다:
- 나열된 다른 PEP들에 이 PEP를 추가합니다.
typing.ReadOnly를 이 PEP에 링크합니다.typing.ReadOnly의 설명을 갱신합니다:클래스의 속성이나TypedDict의 항목을 읽기 전용으로 표시하기 위한 특수한 타이핑 구성 요소입니다.- 타입 한정자 섹션 아래에
ReadOnly에 대한 독립 항목을 추가합니다:클래스 속성 어노테이션의ReadOnly타입 한정자는 해당 클래스의 속성을 읽을 수는 있지만 대입하거나del로 삭제할 수는 없음을 나타냅니다.TypedDict에서의 사용법은 ReadOnly를 참조하십시오.
기각된 아이디어
@property와 프로토콜의 상호 작용 명확화
Protocols 섹션은 프로토콜 안의 프로퍼티를 해석하는 방식에 관해 타입 검사기 간에 불일치가 있음을 언급합니다. 이 문제는 타이핑 명세를 수정하여 이러한 프로퍼티의 읽기 전용 특성을 무엇이 구현하는지 명확히 함으로써 해결할 수 있습니다.
이 PEP는 ReadOnly를 프로토콜에서 읽기 전용 속성을 정의하는 더 나은 대안으로 만들어, 이 목적으로 프로퍼티를 사용하던 방식을 대체합니다.
__init__과 클래스 스코프에서만 대입
이 PEP의 이전 버전에서는 읽기 전용 속성에 __init__과 클래스 본문에서만 대입할 수 있다고 명시했습니다. 이 결정은 C#의 readonly 명세를 바탕으로 한 것입니다.
이후 이 PEP를 개정하면서 __new__, __init_subclass__, @classmethod에서도 대입할 수 있도록 제한을 완화했습니다. 초기 버전대로라면 보통 __init__을 정의하지 않는 불변 클래스에서 ReadOnly를 사용하기가 매우 어려워진다는 점이 드러났기 때문입니다.
초기화 값이 있을 때 단독 ReadOnly 허용하기
이 PEP의 이전 버전에서는 어노테이션이 달린 속성에 초기화 값이 있을 때 ReadOnly를 단독으로 사용하는 것을 허용했습니다. 속성의 타입은 타입 검사기가 일반적인 타입 추론 규칙을 사용하여 결정하도록 되어 있었습니다.
이 스레드에서 이 기능에 관한 몇 가지 까다로운 문제가 드러났습니다. 리터럴 값에서 Literal[...]이 추론되는 바람직하지 않은 동작, 타입 검사기마다 다른 추론 규칙, 클래스 수준 대입과 __init__ 수준 대입으로 인한 구현의 복잡성 등이 그 예입니다. 명시적인 것이 암시적인 것보다 낫기 때문에 ReadOnly[...]에는 항상 타입을 요구하기로 결정했습니다.
각주
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.