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는 클래스 및 프로토콜 어트리뷰트를 읽기 전용으로 표시하는 단일하고 간결한 방법으로, 해당 어노테이션에 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가 있다고 가정해 봅시다:

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

런타임에는 다음이 모두 이 요구 사항들을 충족할 수 있습니다:

  • name: T 속성을 가진 객체,
  • name: ClassVar[T] 클래스 변수를 가진 클래스,
  • 위 클래스의 인스턴스,
  • @property def name(self) -> T를 가진 객체,
  • functools.cached_property()와 같은 사용자 정의 디스크립터를 가진 객체.

현재 typing 명세는 (추상) 프로퍼티를 사용하여 이러한 프로토콜 멤버를 만들 수 있도록 허용합니다:

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

이 문법에는 몇 가지 단점이 있습니다:

  • 다소 장황합니다.
  • 여기서 전달하려는 성질이 프로퍼티의 읽기 전용 특성이라는 점이 분명하게 드러나지 않습니다.
  • 타입 한정자와 조합할 수 없습니다.
  • 현재 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를 쓰기 가능한 속성이나 디스크립터로 재정의할 수 있습니다. 또한 그 타입을 좁힐 수도 있습니다.
  • HasName 프로토콜은 더 간결하게 정의되며, 쓰기 가능한 인스턴스/클래스 속성이나 사용자 정의 디스크립터로 구현할 수 있습니다.

명세

사용법

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[...]에는 항상 타입을 요구하기로 결정했습니다.

각주