Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

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

Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

초록

PEP 705에서는 읽기 전용 typing.TypedDict항목을 정의할 수 있도록 typing.ReadOnly 타입 한정자를 도입했습니다.

이 PEP에서는 클래스 및 프로토콜의 attributes에 대한 annotationsReadOnly를 사용하여 읽기 전용임을 표시하는 간결한 단일 방법을 제안합니다.

이는 PEP 705와 마찬가지로 런타임에 속성을 설정하는 방식을 변경하지 않습니다. 읽기 전용 속성의 올바른 사용은 정적 타입 검사기를 통해서만 강제하도록 합니다.

용어

이 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를 가정해 보십시오.

  1. hasattr(obj, "name")
  2. isinstance(obj.name, T)

이러한 요구 사항은 다음의 모든 경우에 런타임에서 충족됩니다.

  • name: T특성을 가진 객체입니다.
  • name: ClassVar[T]클래스 변수를 가진 클래스입니다.
  • 위 클래스의 인스턴스입니다.
  • @propertydef name(self) -> T를 가진 객체입니다.
  • 예를 들어 functools.cached_property()와 같은 사용자 지정 디스크립터를 가진 객체입니다.

현재의 typing spec은 (추상) 프로퍼티를 사용하여 이러한 프로토콜 멤버를 생성할 수 있도록 허용합니다.:

class HasName(Protocol):
    @property
    def name(self) -> T: ...

이 구문에는 몇 가지 단점이 있습니다.

  • 다소 장황합니다.
  • 여기서 전달되는 특성이 프로퍼티의 읽기 전용 속성이라는 점이 명확하지 않습니다.
  • 이는 type qualifiers와 조합할 수 없습니다.
  • 현재 Pyright는 위의 다섯 객체 중 일부를 이 구조적 타입에 할당할 수 없다고 판단합니다. [Pyright][mypy]는 그렇습니다.

근거

이러한 문제는 특성 수준의 타입 한정자로 해결할 수 있습니다. 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]
  • Member의 서브클래스는 .id를 쓰기 가능한 특성 또는 descriptor로 재정의할 수 있습니다. 또한 narrow를 사용하여 해당 타입을 더 좁힐 수도 있습니다.
  • HasName 프로토콜은 더 간결하게 정의할 수 있으며, 쓰기 가능한 인스턴스/클래스 특성 또는 사용자 지정 디스크립터로 구현할 수 있습니다.

사양

사용법

이때 typing.ReadOnly type qualifier는 명목 클래스와 프로토콜의 attributes에 유효한 어노테이션이 됩니다. 클래스 수준과 __init__ 내부에서 이를 사용하여 개별 특성을 읽기 전용으로 표시할 수 있습니다.:

class Book:
    id: ReadOnly[int]

    def __init__(self, id: int, name: str) -> None:
        self.id = id
        self.name: ReadOnly[str] = name

단독 ReadOnly의 사용([<type>] 없이)은 허용되지 않습니다. 타입 검사기는 Initialization에 설명된 컨텍스트를 제외하고, ReadOnly로 어노테이션된 특성에 할당하거나 해당 특성을 삭제하려는 모든 시도에 오류를 표시해야 합니다.

Final로 어노테이션된 특성을 삭제하는 것 역시 오류여야 합니다. (현재는 명세에 정의되어 있지 않습니다.)

현재 의미가 없는 다른 위치(예: 지역/전역 변수 또는 함수 매개변수)의 어노테이션에서 ReadOnly를 사용하는 것은 이 PEP의 범위를 벗어나며, 계속 금지됩니다.

ReadOnly는 특성 값의 변경 가능성에 영향을 주지 않습니다. 불변 프로토콜과 ABCs (예: 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

고정 데이터클래스와 네임드 튜플의 모든 인스턴스 특성은 읽기 전용으로 간주되어야 합니다. 타입 검사기는 이러한 특성에 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__()를 호출하는 경우입니다.
    • __new__type[T]유형의 모든 객체에서 호출하는 경우이며, 여기서 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

클래스 수준 선언에 초기화 값이 있으면 인스턴스의 flyweight 기본값으로 사용할 수 있습니다.

class Patient:
    number: ReadOnly[int] = 0

    def __init__(self, number: int | None = None) -> None:
        if number is not None:
            self.number = number

Note

이는 __slots__가 없는 클래스에서만 가능합니다. 슬롯에 포함된 속성에는 클래스 수준 기본값을 지정할 수 없습니다.

클래스 속성

읽기 전용 클래스 속성은 ReadOnlyClassVar를 모두 어노테이션으로 지정한 속성입니다. 이러한 속성에 대한 할당은 다음 컨텍스트에서 허용되어야 합니다.

  • 클래스 스코프에서 선언할 때 수행할 수 있습니다.
  • __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]
    

다른 타입 한정자와의 상호작용

ReadOnlyClassVarAnnotated와 중첩 순서와 관계없이 사용할 수 있습니다.

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에서 정의된 ReadOnlytyping.TypedDict의 상호작용과 일치합니다.

FinalNamedTuple과 같은 메커니즘 때문이든, 부모 클래스가 해당 속성을 ReadOnly로 선언했기 때문이든, 이미 읽기 전용인 속성을 (재)선언하는 데 사용할 수 있습니다.

Final의 의미가 읽기 전용 속성의 의미보다 우선합니다. ReadOnlyFinal을 함께 사용하는 것은 중복이며, 타입 검사기는 이러한 중복에 대해 경고하거나 오류를 발생시킬 수 있습니다.

하위 호환성

이 PEP는 ReadOnly가 유효한 새로운 컨텍스트를 도입합니다. 해당 위치를 검사하는 프로그램은 이를 지원하도록 변경해야 합니다. 이는 주로 타입 검사기에 영향을 미칠 것으로 예상됩니다.

그러나 이전 버전의 Python에서 백포트된 typing_extensions.ReadOnly를 사용할 때는 주의해야 합니다. 어노테이션을 검사하는 메커니즘은 ReadOnly를 만날 때 잘못 동작할 수 있습니다. 특히, looks for ClassVar를 찾는 @dataclass 데코레이터가 ReadOnly[ClassVar[...]]를 인스턴스 속성으로 잘못 취급할 수 있습니다.

인트로스펙션 문제를 피하려면 ReadOnly[ClassVar[...]]대신 ClassVar[ReadOnly[...]]를 사용하십시오.

보안 영향

이 PEP로 인해 발생하는 알려진 보안상의 영향은 없습니다.

이 내용을 가르치는 방법

다음은 typing 모듈 문서에 제안하는 변경 사항으로, PEP 705를 따릅니다:

  • 나열된 다른 PEP에 이 PEP를 추가하십시오.
  • 이 PEP에 typing.ReadOnly를 연결하십시오.
  • typing.ReadOnly의 설명을 업데이트하십시오.
    클래스의 속성이나 TypedDict의 항목을 읽기 전용으로 표시하는 특수 타이핑 구성체입니다.
  • type qualifiers 섹션에 ReadOnly에 대한 독립 항목을 추가하십시오.
    클래스 속성 어노테이션의 ReadOnly타입 한정자는 해당 클래스의 속성을 읽을 수는 있지만 할당하거나 del하지는 못함을 나타냅니다. TypedDict의 사용법은 ReadOnly를 참조하십시오.

거부된 아이디어

@property와 프로토콜의 상호작용 명확히 하기

프로토콜의 프로퍼티 해석에서 타입 검사기 간의 불일치가 Protocols 섹션에서 언급됩니다. 타이핑 명세를 수정하여 이러한 속성의 읽기 전용 특성을 구현하는 요소를 명확히 하면 이 문제를 해결할 수 있습니다.

이 PEP는 프로토콜에서 읽기 전용 속성을 정의하는 더 나은 대안으로 ReadOnly를 제시하며, 이 목적에 속성을 사용하는 방식을 대체합니다.

__init__ 및 클래스 스코프에서만 할당

이 PEP의 이전 버전에서는 읽기 전용 속성에 __init__와 클래스 본문에서만 값을 할당할 수 있다고 명시했습니다. 이 결정은 C#의 readonly 명세에 기반했습니다.

이 PEP의 이후 개정판에서는 __new__, __init_subclass__@classmethod를 포함하도록 제한을 완화했습니다. 초기 버전에서는 일반적으로 __init__을 정의하지 않는 불변 클래스에서 ReadOnly의 사용성이 심각하게 제한된다는 사실이 밝혀졌기 때문입니다.

초기화 값과 함께 베어 ReadOnly 허용하기

이 PEP의 이전 버전에서는 주석이 달린 속성에 초기화 값이 있는 경우 베어 ReadOnly를 사용할 수 있도록 허용했습니다. 속성의 타입은 타입 검사기가 일반적인 타입 추론 규칙을 사용하여 결정하도록 했습니다.

This thread에서는 이 기능과 관련된 몇 가지 간단하지 않은 문제가 드러났습니다. 예를 들어 리터럴 값에서 Literal[...]을 바람직하지 않게 추론하는 문제, 타입 검사기별 추론 규칙의 차이, 클래스 수준 및 __init__ 수준의 할당으로 인한 구현의 복잡성 등이 있습니다. 명시적인 것이 암시적인 것보다 낫기 때문에 ReadOnly[...]에 항상 타입을 요구하기로 결정했습니다.

각주