PEP 544 – 프로토콜: 구조적 서브타이핑(정적 덕 타이핑)
- Author:
- Ivan Levkivskyi <levkivskyi at gmail.com>, Jukka Lehtosalo <jukka.lehtosalo at iki.fi>, Łukasz Langa <lukasz at python.org>
- BDFL-Delegate:
- Guido van Rossum <guido at python.org>
- Discussions-To:
- Python-Dev list
- Status:
- Final
- Type:
- Standards Track
- Topic:
- Typing
- Created:
- 05-Mar-2017
- Python-Version:
- 3.8
- Resolution:
- Typing-SIG message
Table of Contents
- 초록
- 근거 및 목표
- 구조적 서브타이핑에 대한 기존 접근법
- 사양
- 프로토콜 사용
- Python 2.7 - 3.5에서 프로토콜 사용
- 프로토콜 클래스의 런타임 구현
- 거부되었거나 연기된 아이디어
- 기본적으로 모든 클래스를 프로토콜로 만들기
- 일반 클래스를 상속하는 프로토콜
- 선택적 프로토콜 멤버 지원
- 프로토콜 메서드만 허용하고 게터와 세터 사용 강제하기
- 비프로토콜 멤버 지원
- 프로토콜을 다른 접근 방식과 상호 운용 가능하게 하십시오.
- 할당을 사용하여 클래스가 프로토콜을 구현하는지 명시적으로 확인하십시오.
- 기본적으로
isinstance()검사를 지원하십시오. - 특수한 교집합 타입 구문을 제공하십시오.
- 비프로토콜의 프로토콜 명시적 서브클래싱을 금지합니다.
- 가변 속성의 공변 서브타이핑
- 프로토콜 클래스에서 추론된 변동성 재정의
- 어댑터 및 적응을 지원합니다.
- 구조적 베이스 타입을 “인터페이스”라고 부릅니다.
- 프로토콜을 일반 ABC가 아니라 런타임에서 특수한 객체로 만들기
- 하위 호환성
- 구현
- 참고 문헌
- Copyright
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
타입 힌트는 PEP 484에서 도입되었으며, 정적 타입 검사기와 기타 타사 도구에서 사용할 타입 메타데이터를 지정하는 데 사용할 수 있습니다. 그러나 PEP 484는 명목적 서브타이핑의 의미만 지정합니다. 이 PEP에서는 구조적 서브타이핑(정적 덕 타이핑)을 지원하는 프로토콜 클래스의 정적 및 런타임 의미를 지정합니다.
근거 및 목표
현재 PEP 484와 typing 모듈 [typing]은 Iterable 및 Sized와 같은 몇 가지 일반적인 Python 프로토콜에 대한 추상 베이스 클래스를 정의합니다. 이들의 문제는 클래스가 이를 지원하도록 명시적으로 표시되어야 한다는 점이며, 이는 비파이썬적이고 동적으로 타입이 지정되는 Python 코드에서 관용적으로 일반적으로 수행하는 방식과 다릅니다. 예를 들어, 다음은 PEP 484를 준수합니다.:
from typing import Sized, Iterable, Iterator
class Bucket(Sized, Iterable[int]):
...
def __len__(self) -> int: ...
def __iter__(self) -> Iterator[int]: ...
사용자 정의 ABC에서도 동일한 문제가 발생합니다. 즉, 명시적으로 서브클래싱하거나 등록해야 합니다. 라이브러리의 타입 객체가 라이브러리 구현 깊숙한 곳에 숨겨져 있을 수 있으므로 라이브러리 타입에서는 이를 수행하기가 특히 어렵습니다. 또한 ABC를 광범위하게 사용하면 추가적인 런타임 비용이 발생할 수 있습니다.
이 PEP의 목적은 클래스 정의에서 명시적인 베이스 클래스 없이 위의 코드를 작성할 수 있도록 하여 이러한 모든 문제를 해결하는 것입니다. 이를 통해 구조적 [wiki-structural] 서브타이핑을 사용하는 정적 타입 검사기가 Bucket을 Sized와 Iterable[int]의 하위 유형으로 암묵적으로 간주할 수 있습니다.:
from typing import Iterator, Iterable
class Bucket:
...
def __len__(self) -> int: ...
def __iter__(self) -> Iterator[int]: ...
def collect(items: Iterable[int]) -> int: ...
result: int = collect(Bucket()) # Passes type check
typing 모듈의 ABC는 이미 런타임에 구조적 동작을 제공한다는 점에 유의하십시오. isinstance(Bucket(), Iterable)은 True를 반환합니다. 이 제안의 주요 목표는 이러한 동작을 정적으로 지원하는 것입니다. 아래에 명시된 대로 사용자 정의 프로토콜에도 동일한 기능이 제공됩니다. 프로토콜 클래스를 사용한 위의 코드는 일반적인 Python 관례에 훨씬 더 잘 부합합니다. 또한 자동으로 확장 가능하며, 필요한 프로토콜을 우연히 구현하는 추가적인 무관한 클래스와도 함께 작동합니다.
명목적 서브타이핑과 구조적 서브타이핑
구조적 서브타이핑은 덕 타이핑의 런타임 의미와 일치하므로 Python 프로그래머에게 자연스럽습니다. 즉, 특정 속성을 가진 객체는 실제 런타임 클래스와 독립적으로 처리됩니다. 그러나 PEP 483에서 논의된 것처럼 명목적 서브타이핑과 구조적 서브타이핑은 모두 장단점이 있습니다. 따라서 이 PEP에서는 PEP 484에 설명된 명목적 서브타이핑을 구조적 서브타이핑으로 완전히 대체하자고 제안하지 않습니다. 대신 이 PEP에 명시된 프로토콜 클래스는 일반 클래스를 보완하며, 사용자는 특정 해결책을 적용할 위치를 자유롭게 선택할 수 있습니다. 추가적인 근거는 이 PEP의 끝부분에 있는 거부된 아이디어에 관한 절을 참조하십시오.
목표가 아닌 사항
런타임에서 프로토콜 클래스는 단순한 ABC가 됩니다. 프로토콜 클래스에 대해 정교한 런타임 인스턴스 및 클래스 검사를 제공할 의도는 없습니다. 이는 어렵고 오류가 발생하기 쉬우며 PEP 484의 논리와 모순되기 때문입니다. 또한 PEP 484와 PEP 526에 따라 프로토콜은 완전히 선택 사항임을 명시합니다.
- 프로토콜 클래스가 타입 어노테이션으로 지정된 변수나 매개변수에는 어떠한 런타임 의미도 부과되지 않습니다.
- 모든 검사는 서드파티 타입 검사기와 기타 도구에서만 수행됩니다.
- 프로그래머는 타입 어노테이션을 사용하더라도 이를 사용하지 않아도 됩니다.
- 앞으로 프로토콜을 선택 사항이 아니게 만들 의도는 없습니다.
다시 말해, 프로토콜 클래스에 복잡한 런타임 의미론을 제공하는 것은 이 PEP의 목표가 아니며, 주요 목표는 정적 구조적 서브타이핑을 지원하고 표준을 제공하는 것입니다. 런타임 컨텍스트에서 프로토콜을 ABC로 사용할 수 있다는 점은 이미 ABC를 사용하는 프로젝트에 원활한 전환을 제공하기 위해 주로 존재하는 비교적 사소한 이점입니다.
구조적 서브타이핑에 대한 기존 접근법
실제 사양을 설명하기 전에, Python 및 다른 언어에서 구조적 서브타이핑과 관련된 기존 접근법을 검토하고 논평합니다.
zope.interface[zope-interfaces]는 Python에서 구조적 서브타이핑에 널리 사용된 최초의 접근법 중 하나였습니다. 이는 인터페이스 클래스와 일반 클래스를 구분하고, 인터페이스 속성을 표시하며, 구현을 명시적으로 선언할 수 있도록 특수 클래스를 제공하여 구현됩니다. 예를 들어:from zope.interface import Interface, Attribute, implementer class IEmployee(Interface): name = Attribute("Name of employee") def do(work): """Do some work""" @implementer(IEmployee) class Employee: name = 'Anonymous' def do(self, work): return work.start()
Zope 인터페이스는 인터페이스 클래스에 대해 다양한 계약과 제약 조건을 지원합니다. 예를 들어:
from zope.interface import invariant def required_contact(obj): if not (obj.email or obj.phone): raise Exception("At least one contact info is required") class IPerson(Interface): name = Attribute("Name") email = Attribute("Email Address") phone = Attribute("Phone Number") invariant(required_contact)
더욱 상세한 불변 조건도 지원됩니다. 그러나 Zope 인터페이스는 전적으로 런타임 검증에 의존합니다. 이러한 런타임 속성에 대한 집중은 현재 제안의 범위를 벗어나며, 불변 조건에 대한 정적 지원은 구현하기 어려울 수 있습니다. 그러나 특수 베이스 클래스로 인터페이스 클래스를 표시한다는 아이디어는 합리적이며 정적으로나 런타임에나 쉽게 구현할 수 있습니다.
- Python 추상 베이스 클래스 [abstract-classes]는 구조적 서브타이핑과 유사한 일부 기능을 제공하는 표준 라이브러리 도구입니다. 이 접근법의 단점은 추상 클래스를 서브클래싱하거나 구현을 명시적으로 등록해야 한다는 점입니다:
from abc import ABC class MyTuple(ABC): pass MyTuple.register(tuple) assert issubclass(tuple, MyTuple) assert isinstance((), MyTuple)
rationale에서 언급했듯이, 특히 정적 컨텍스트에서는 이러한 필요성을 피하고자 합니다. 그러나 런타임 컨텍스트에서 ABC는 프로토콜 클래스의 좋은 후보이며,
typing모듈에서 이미 광범위하게 사용되고 있습니다. collections.abc모듈 [collections-abc]에 정의된 추상 클래스는 명시적인 등록 없이 런타임 구조 검사를 허용하는 사용자 지정__subclasshook__()메서드를 구현하므로 약간 더 고급입니다.:from collections.abc import Iterable class MyIterable: def __iter__(self): return [] assert isinstance(MyIterable(), Iterable)
이러한 동작은 프로토콜의 런타임 및 정적 동작 모두에 완벽하게 부합하는 것으로 보입니다. rationale에서 논의했듯이, 이러한 동작에 대한 정적 지원을 추가할 것을 제안합니다. 또한 사용자가 사용자 정의 프로토콜에서 이러한 런타임 동작을 구현할 수 있도록 특수한
@runtime_checkable데코레이터를 제공하며, 자세한 내용은 아래의 discussion을 참조하십시오.- TypeScript [typescript]는 사용자 정의 클래스와 인터페이스를 지원합니다. 명시적인 구현 선언은 필요하지 않으며, 구조적 서브타이핑은 정적으로 검증됩니다. 예를 들어:
interface LabeledItem { label: string; size?: int; } function printLabel(obj: LabeledItem) { console.log(obj.label); } let myObj = {size: 10, label: "Size 10 Object"}; printLabel(myObj);선택적 인터페이스 멤버가 지원된다는 점에 유의하십시오. 또한 TypeScript는 구현에서 중복 멤버를 금지합니다. 선택적 멤버라는 아이디어는 흥미로워 보이지만, 이 제안을 복잡하게 만들며 얼마나 유용할지는 분명하지 않습니다. 따라서 이는 연기할 것을 제안하며, rejected 아이디어를 참조하십시오. 일반적으로 런타임 영향 없이 프로토콜을 정적으로 검사한다는 아이디어는 합리적으로 보이며, 기본적으로 이 제안도 같은 방향을 따릅니다.
- Go [golang]은 더 급진적인 접근 방식을 사용하며 인터페이스를 타입 정보를 제공하는 주요 수단으로 만듭니다. 또한 할당은 구현을 명시적으로 보장하는 데 사용됩니다.:
type SomeInterface interface { SomeMethod() ([]byte, error) } if _, ok := someval.(SomeInterface); ok { fmt.Printf("value implements some interface") }
이 두 가지 아이디어는 모두 이 제안의 맥락에서 의문스럽습니다. 거부된 아이디어에 관한 절을 참조하십시오.
사양
용어
구조적 서브타이핑을 지원하는 타입에 대해 프로토콜이라는 용어를 사용할 것을 제안합니다. 그 이유는 예를 들어 이터레이터 프로토콜은 커뮤니티에서 널리 이해되는 용어이며, 정적으로 타입이 지정된 문맥에서 이 개념에 대한 새로운 용어를 만들어 내는 것은 혼란만 초래하기 때문입니다.
이로 인해 프로토콜이라는 용어가 미묘하게 다른 두 가지 의미로 과도하게 사용된다는 단점이 생깁니다. 첫 번째는 이터레이터와 같은 프로토콜이라는 전통적이고 잘 알려져 있지만 다소 모호한 개념이며, 두 번째는 정적으로 타입이 지정된 코드에서 더 명시적으로 정의된 프로토콜 개념입니다. 이러한 구분은 대부분의 경우 중요하지 않으며, 그 밖의 경우에는 정적 타입 개념을 가리킬 때 프로토콜 클래스와 같은 한정어를 추가할 것을 제안합니다.
클래스가 MRO에 프로토콜을 포함하면 해당 클래스는 프로토콜의 명시적 서브클래스라고 합니다. 클래스가 프로토콜의 구조적 서브타입이면 해당 프로토콜을 구현하며 프로토콜과 호환된다고 합니다. 클래스가 프로토콜과 호환되지만 프로토콜이 MRO에 포함되지 않으면 해당 클래스는 프로토콜의 암시적 서브타입입니다. (하위 클래스에서 프로토콜 속성이 None으로 설정된 경우에는 프로토콜을 명시적으로 서브클래싱하고도 여전히 이를 구현하지 않을 수 있다는 점에 유의하십시오. 자세한 내용은 Python [data-model]을 참조하십시오.)
다른 클래스가 구조적 서브타입으로 간주되기 위해 반드시 필요한 프로토콜의 속성(변수 및 메서드)을 프로토콜 멤버라고 합니다.
프로토콜 정의
프로토콜은 특수한 새 클래스 typing.Protocol(abc.ABCMeta의 인스턴스)을 기본 클래스 목록에 포함하여 정의하며, 일반적으로 목록의 끝에 배치합니다. 다음은 간단한 예입니다.:
from typing import Protocol
class SupportsClose(Protocol):
def close(self) -> None:
...
이제 호환되는 시그니처를 가진 close() 메서드가 있는 Resource클래스를 정의하면, 프로토콜 타입에 구조적 서브타이핑이 사용되므로 암시적으로 SupportsClose의 서브타입이 됩니다.:
class Resource:
...
def close(self) -> None:
self.file.close()
self.lock.release()
아래에서 명시적으로 언급하는 몇 가지 제한 사항을 제외하면, 일반 타입을 사용할 수 있는 모든 문맥에서 프로토콜 타입을 사용할 수 있습니다.:
def close_all(things: Iterable[SupportsClose]) -> None:
for t in things:
t.close()
f = open('foo.txt')
r = Resource()
close_all([f, r]) # OK!
close_all([1]) # Error: 'int' has no 'close' method
사용자 정의 클래스 Resource와 내장 IO 타입(open()의 반환 타입)이 모두 SupportsClose의 서브타입으로 간주된다는 점에 유의하십시오. 두 타입 모두 호환되는 타입 시그니처를 가진 close() 메서드를 제공하기 때문입니다.
프로토콜 멤버
프로토콜 클래스 본문에 정의된 모든 메서드는 일반 메서드인지 @abstractmethod로 데코레이터가 적용된 메서드인지에 관계없이 프로토콜 멤버입니다. 프로토콜 메서드의 매개변수에 어노테이션이 지정되지 않은 경우 해당 타입은 Any로 간주됩니다(PEP 484 참조). 프로토콜 메서드의 본문은 타입 검사를 받습니다. super()를 통해 호출해서는 안 되는 추상 메서드는 NotImplementedError를 발생시켜야 합니다. 예:
from typing import Protocol
from abc import abstractmethod
class Example(Protocol):
def first(self) -> int: # This is a protocol member
return 42
@abstractmethod
def second(self) -> int: # Method without a default implementation
raise NotImplementedError
정적 메서드, 클래스 메서드 및 프로퍼티도 프로토콜에서 동일하게 허용됩니다.
프로토콜 변수를 정의하려면 클래스 본문에서 PEP 526 변수 어노테이션을 사용할 수 있습니다. 메서드 본문에서 self를 통한 할당으로 오직 정의되는 추가 속성은 허용되지 않습니다. 그 근거는 프로토콜 클래스의 구현이 서브타입 간에 공유되지 않는 경우가 많으므로 인터페이스가 기본 구현에 의존해서는 안 되기 때문입니다. 예제:
from typing import Protocol, List
class Template(Protocol):
name: str # This is a protocol member
value: int = 0 # This one too (with default)
def method(self) -> None:
self.temp: List[int] = [] # Error in type checker
class Concrete:
def __init__(self, name: str, value: int) -> None:
self.name = name
self.value = value
def method(self) -> None:
return
var: Template = Concrete('value', 42) # OK
프로토콜 클래스 변수와 프로토콜 인스턴스 변수를 구별하려면 PEP 526에 지정된 특수한 ClassVar 어노테이션을 사용해야 합니다. 기본적으로 위에서 정의한 프로토콜 변수는 읽고 쓸 수 있는 것으로 간주됩니다. 읽기 전용 프로토콜 변수를 정의하려면 (추상) 프로퍼티를 사용할 수 있습니다.
명시적으로 구현 선언
특정 클래스가 주어진 프로토콜을 구현한다고 명시적으로 선언하려면 해당 클래스를 일반 베이스 클래스로 사용할 수 있습니다. 이 경우 클래스는 프로토콜 멤버의 기본 구현을 사용할 수 있습니다. 정적 분석 도구는 해당 클래스가 주어진 프로토콜을 구현한다는 것을 자동으로 감지할 것으로 예상됩니다. 따라서 프로토콜을 명시적으로 서브클래싱할 수는 있지만, 타입 검사를 위해 그렇게 할 필요는 없습니다.
서브타입 관계가 암시적이고 구조적 서브타이핑을 통해서만 성립하는 경우에는 기본 구현을 사용할 수 없습니다. 상속의 의미는 변경되지 않습니다. 예제:
class PColor(Protocol):
@abstractmethod
def draw(self) -> str:
...
def complex_method(self) -> int:
# some complex code here
...
class NiceColor(PColor):
def draw(self) -> str:
return "deep blue"
class BadColor(PColor):
def draw(self) -> str:
return super().draw() # Error, no default implementation
class ImplicitColor: # Note no 'PColor' base here
def draw(self) -> str:
return "probably gray"
def complex_method(self) -> int:
# class needs to implement this
...
nice: NiceColor
another: ImplicitColor
def represent(c: PColor) -> None:
print(c.draw(), c.complex_method())
represent(nice) # OK
represent(another) # Also OK
명시적 서브타입과 암시적 서브타입 사이에는 차이가 거의 없다는 점에 유의하십시오. 명시적 서브클래싱의 주요 이점은 일부 프로토콜 메서드를 “공짜로” 얻는 것입니다. 또한 타입 검사기는 해당 클래스가 실제로 프로토콜을 올바르게 구현하는지 정적으로 검증할 수 있습니다:
class RGB(Protocol):
rgb: Tuple[int, int, int]
@abstractmethod
def intensity(self) -> int:
return 0
class Point(RGB):
def __init__(self, red: int, green: int, blue: str) -> None:
self.rgb = red, green, blue # Error, 'blue' must be 'int'
# Type checker might warn that 'intensity' is not defined
클래스는 여러 프로토콜과 일반 클래스를 명시적으로 상속할 수 있습니다. 이 경우 메서드는 일반적인 MRO를 사용하여 해석되며, 타입 검사기는 모든 서브타이핑이 올바른지 검증합니다. @abstractmethod의 의미는 변경되지 않으며, 명시적 서브클래스를 인스턴스화하려면 해당 메서드를 모두 구현해야 합니다.
프로토콜 병합 및 확장
일반적인 철학은 프로토콜이 대체로 일반 ABC와 비슷하지만, 정적 타입 검사기가 이를 특별히 처리한다는 것입니다. 프로토콜 클래스를 서브클래싱하더라도 해당 서브클래스 자체가 typing.Protocol을 명시적 베이스 클래스로 함께 갖지 않는 한 프로토콜이 되지는 않습니다. 이 베이스 클래스가 없으면 해당 클래스는 구조적 서브타이핑에 사용할 수 없는 일반 ABC로 “격하됩니다”. 이 규칙의 근거는 베이스 클래스 중 하나가 우연히 프로토콜이라는 이유만으로 어떤 클래스가 실수로 프로토콜처럼 동작하는 상황을 방지하려는 것입니다. 정적 타입 지정 환경에서는 여전히 구조적 서브타이핑보다 명목적 서브타이핑을 약간 더 선호합니다.
서브프로토콜은 하나 이상의 프로토콜을 직접 베이스 클래스로 가지면서 동시에 typing.Protocol을 직접 베이스 클래스로 가지는 두 가지 조건 모두를 충족함으로써 정의할 수 있습니다.:
from typing import Sized, Protocol
class SizedAndClosable(Sized, Protocol):
def close(self) -> None:
...
이제 SizedAndClosable 프로토콜은 __len__ 및 close라는 두 메서드를 가진 프로토콜입니다. 베이스 클래스 목록에서 Protocol을 생략하면 이는 Sized를 구현해야 하는 일반적인(프로토콜이 아닌) 클래스가 됩니다. 또는 definition 섹션의 예제에 있는 SupportsClose 프로토콜을 typing.Sized와 병합하여 SizedAndClosable 프로토콜을 구현할 수 있습니다.:
from typing import Sized
class SupportsClose(Protocol):
def close(self) -> None:
...
class SizedAndClosable(Sized, SupportsClose, Protocol):
pass
SizedAndClosable의 두 정의는 동일합니다. 프로토콜 간의 서브클래스 관계는 서브타이핑을 고려할 때 의미가 없습니다. 기준은 MRO가 아니라 구조적 호환성이기 때문입니다.
베이스 클래스 목록에 Protocol이 포함된 경우 다른 모든 베이스 클래스도 프로토콜이어야 합니다. 프로토콜은 일반 클래스를 확장할 수 없습니다. 그 근거는 rejected 아이디어를 참조하십시오. 명시적 서브클래싱에 관한 규칙은 일반 ABC와 다르다는 점에 유의하십시오. 일반 ABC에서는 추상성이 구현되지 않은 추상 메서드가 하나 이상 있는 것으로 간단히 정의됩니다. 프로토콜 클래스는 명시적으로 표시해야 합니다.
제네릭 프로토콜
제네릭 프로토콜은 중요합니다. 예를 들어, SupportsAbs, Iterable 및 Iterator는 제네릭 프로토콜입니다. 이는 일반적인 비프로토콜 제네릭 타입과 유사하게 정의됩니다.:
class Iterable(Protocol[T]):
@abstractmethod
def __iter__(self) -> Iterator[T]:
...
Protocol[T, S, ...]은 Protocol, Generic[T, S, ...]의 약식 표기로 허용됩니다.
사용자 정의 제네릭 프로토콜은 명시적으로 선언된 변성을 지원합니다. 타입 검사기는 추론된 변성이 선언된 변성과 다르면 경고합니다. 예:
T = TypeVar('T')
T_co = TypeVar('T_co', covariant=True)
T_contra = TypeVar('T_contra', contravariant=True)
class Box(Protocol[T_co]):
def content(self) -> T_co:
...
box: Box[float]
second_box: Box[int]
box = second_box # This is OK due to the covariance of 'Box'.
class Sender(Protocol[T_contra]):
def send(self, data: T_contra) -> int:
...
sender: Sender[float]
new_sender: Sender[int]
new_sender = sender # OK, 'Sender' is contravariant.
class Proto(Protocol[T]):
attr: T # this class is invariant, since it has a mutable attribute
var: Proto[float]
another_var: Proto[int]
var = another_var # Error! 'Proto[float]' is incompatible with 'Proto[int]'.
명목 클래스와 달리 사실상 공변인 프로토콜은 불변으로 선언할 수 없습니다. 이는 서브타이핑의 추이성을 깨뜨릴 수 있기 때문입니다(자세한 내용은 rejected 아이디어를 참조하십시오). 예:
T = TypeVar('T')
class AnotherBox(Protocol[T]): # Error, this protocol is covariant in T,
def content(self) -> T: # not invariant.
...
재귀 프로토콜
재귀 프로토콜도 지원됩니다. 프로토콜 클래스 이름에 대한 전방 참조는 PEP 484에 지정된 대로 문자열로 제공할 수 있습니다. 재귀 프로토콜은 트리와 같은 자기 참조 데이터 구조를 추상적인 방식으로 나타내는 데 유용합니다.:
class Traversable(Protocol):
def leaves(self) -> Iterable['Traversable']:
...
재귀 프로토콜의 경우, 판정이 자기 자신에 의존하는 상황에서는 클래스가 해당 프로토콜의 서브타입으로 간주된다는 점에 유의하십시오. 앞의 예를 계속 살펴보면:
class SimpleTree:
def leaves(self) -> List['SimpleTree']:
...
root: Traversable = SimpleTree() # OK
class Tree(Generic[T]):
def leaves(self) -> List['Tree[T]']:
...
def walk(graph: Traversable) -> None:
...
tree: Tree[float] = Tree()
walk(tree) # OK, 'Tree[float]' is a subtype of 'Traversable'
프로토콜의 자기 타입
프로토콜의 자기 타입은 해당 사양의 PEP 484에 따릅니다. 예:
C = TypeVar('C', bound='Copyable')
class Copyable(Protocol):
def copy(self: C) -> C:
class One:
def copy(self) -> 'One':
...
T = TypeVar('T', bound='Other')
class Other:
def copy(self: T) -> T:
...
c: Copyable
c = One() # OK
c = Other() # Also OK
콜백 프로토콜
프로토콜은 PEP 484에 지정된 Callable[...] 구문으로 표현하기 어렵거나 불가능한 가변 인자, 오버로드된 콜백, 복잡한 제네릭 콜백과 같은 유연한 콜백 타입을 정의하는 데 사용할 수 있습니다. 이러한 타입은 __call__ 멤버를 포함하는 프로토콜로 정의할 수 있습니다.:
from typing import Optional, List, Protocol
class Combiner(Protocol):
def __call__(self, *vals: bytes,
maxlen: Optional[int] = None) -> List[bytes]: ...
def good_cb(*vals: bytes, maxlen: Optional[int] = None) -> List[bytes]:
...
def bad_cb(*vals: bytes, maxitems: Optional[int]) -> List[bytes]:
...
comb: Combiner = good_cb # OK
comb = bad_cb # Error! Argument 2 has incompatible type because of
# different name and kind in the callback
콜백 프로토콜과 Callable[...] 타입은 서로 바꾸어 사용할 수 있습니다.
프로토콜 사용
다른 타입과의 서브타이핑 관계
프로토콜은 인스턴스화할 수 없으므로 런타임 타입이 프로토콜인 값은 존재하지 않습니다. 프로토콜 타입의 변수와 매개변수에 대해서는 서브타이핑 관계에 다음 규칙이 적용됩니다.
- 프로토콜은 구체 타입의 서브타입이 될 수 없습니다.
- 구체 타입
X가 프로토콜P의 모든 프로토콜 멤버를 호환 가능한 타입으로 구현하는 경우에 한해서만X는 프로토콜P의 서브타입입니다. 즉, 프로토콜과 관련된 서브타이핑은 항상 구조적 서브타이핑입니다. - 프로토콜
P1이P2의 모든 프로토콜 멤버를 호환 가능한 타입으로 정의하는 경우, 프로토콜P1은 다른 프로토콜P2의 서브타입입니다.
제네릭 프로토콜 타입에는 비프로토콜 타입과 동일한 변성 규칙이 적용됩니다. 프로토콜 타입은 Union, ClassVar, 타입 변수의 상한 등 다른 모든 타입을 사용할 수 있는 모든 문맥에서 사용할 수 있습니다. 제네릭 프로토콜에는 제네릭 추상 클래스의 규칙이 적용되지만, 상속 관계로 정의되는 호환성 대신 구조적 호환성을 사용합니다.
정적 타입 검사기는 해당 프로토콜을 가져오지 않았더라도 프로토콜 구현을 인식합니다.:
# file lib.py
from typing import Sized
T = TypeVar('T', contravariant=True)
class ListLike(Sized, Protocol[T]):
def append(self, x: T) -> None:
pass
def populate(lst: ListLike[int]) -> None:
...
# file main.py
from lib import populate # Note that ListLike is NOT imported
class MockStack:
def __len__(self) -> int:
return 42
def append(self, x: int) -> None:
print(x)
populate([1, 2, 3]) # Passes type check
populate(MockStack()) # Also OK
프로토콜의 합집합과 교집합
프로토콜 클래스의 Union은 프로토콜이 아닌 클래스의 경우와 동일하게 동작합니다. 예를 들어:
from typing import Union, Optional, Protocol
class Exitable(Protocol):
def exit(self) -> int:
...
class Quittable(Protocol):
def quit(self) -> Optional[int]:
...
def finish(task: Union[Exitable, Quittable]) -> int:
...
class DefaultJob:
...
def quit(self) -> int:
return 0
finish(DefaultJob()) # OK
다중 상속을 사용하여 프로토콜의 교집합을 정의할 수 있습니다. 예:
from typing import Iterable, Hashable
class HashableFloats(Iterable[float], Hashable, Protocol):
pass
def cached_func(args: HashableFloats) -> float:
...
cached_func((1, 2, 3)) # OK, tuple is both hashable and iterable
이것이 널리 사용되는 시나리오로 판명되면, PEP 483에 명시된 대로 향후 특별한 교집합 타입 구문이 추가될 수 있습니다. 자세한 내용은 rejected 아이디어를 참조하십시오.
Type[] 및 클래스 객체와 프로토콜
Type[Proto]로 어노테이션된 변수와 매개변수는 Proto의 구체적인(프로토콜이 아닌) 서브타입만 허용합니다. 주된 이유는 이러한 타입의 매개변수를 인스턴스화할 수 있도록 하기 위해서입니다. 예를 들어:
class Proto(Protocol):
@abstractmethod
def meth(self) -> int:
...
class Concrete:
def meth(self) -> int:
return 42
def fun(cls: Type[Proto]) -> int:
return cls().meth() # OK
fun(Proto) # Error
fun(Concrete) # OK
변수에도 동일한 규칙이 적용됩니다.:
var: Type[Proto]
var = Proto # Error
var = Concrete # OK
var().meth() # OK
ABC 또는 프로토콜 클래스를 명시적으로 타입 지정하지 않은 변수에 할당할 수 있으며, 이러한 할당은 타입 별칭을 생성합니다. 일반적인 (추상적이지 않은) 클래스의 경우 Type[]의 동작은 변경되지 않습니다.
클래스 객체에서 모든 멤버에 접근한 결과가 프로토콜 멤버와 호환되는 타입이 되면 해당 클래스 객체는 프로토콜의 구현으로 간주됩니다. 예를 들어:
from typing import Any, Protocol
class ProtoA(Protocol):
def meth(self, x: int) -> int: ...
class ProtoB(Protocol):
def meth(self, obj: Any, x: int) -> int: ...
class C:
def meth(self, x: int) -> int: ...
a: ProtoA = C # Type check error, signatures don't match!
b: ProtoB = C # OK
NewType() 및 타입 별칭
프로토콜은 본질적으로 익명입니다. 이 점을 강조하기 위해 정적 타입 검사기는 서로 다른 타입이 제공된다는 착각을 피하도록 NewType()내부의 프로토콜 클래스를 거부할 수 있습니다.:
from typing import NewType, Protocol, Iterator
class Id(Protocol):
code: int
secrets: Iterator[bytes]
UserId = NewType('UserId', Id) # Error, can't provide distinct type
반면 타입 별칭은 제네릭 타입 별칭을 포함하여 완전히 지원됩니다.:
from typing import TypeVar, Reversible, Iterable, Sized
T = TypeVar('T')
class SizedIterable(Iterable[T], Sized, Protocol):
pass
CompatReversible = Union[Reversible[T], SizedIterable[T]]
프로토콜의 구현으로서의 모듈
주어진 모듈의 공개 인터페이스가 예상되는 프로토콜과 호환되면, 프로토콜이 예상되는 위치에 모듈 객체를 사용할 수 있습니다. 예를 들어:
# file default_config.py
timeout = 100
one_flag = True
other_flag = False
# file main.py
import default_config
from typing import Protocol
class Options(Protocol):
timeout: int
one_flag: bool
other_flag: bool
def setup(options: Options) -> None:
...
setup(default_config) # OK
모듈 수준 함수의 호환성을 판단할 때 해당 프로토콜 메서드의 self인자를 제거합니다. 예를 들어:
# callbacks.py
def on_error(x: int) -> None:
...
def on_success() -> None:
...
# main.py
import callbacks
from typing import Protocol
class Reporter(Protocol):
def on_error(self, x: int) -> None:
...
def on_success(self) -> None:
...
rp: Reporter = callbacks # Passes type check
@runtime_checkable 데코레이터와 isinstance()를 사용한 타입 좁히기
기본 의미 체계에서는 프로토콜 타입에 대해 isinstance()와 issubclass()가 실패합니다. 이는 덕 타이핑의 취지에 부합합니다. 즉, 프로토콜은 기본적으로 덕 타이핑을 런타임에 명시적으로 사용하는 것이 아니라 정적으로 모델링하는 데 사용됩니다.
그러나 이러한 방식이 타당한 경우 프로토콜 타입이 사용자 지정 인스턴스 및 클래스 검사를 구현할 수 있어야 합니다. 이는 collections.abc와 typing의 Iterable 및 기타 ABC가 이미 수행하는 방식과 유사하지만, 제네릭이 아닌 프로토콜과 첨자가 지정되지 않은 제네릭 프로토콜로 제한됩니다(Iterable은 정적으로 Iterable[Any]와 동일합니다). typing 모듈은 collections.abc 클래스와 동일한 클래스 및 인스턴스 검사 의미 체계를 제공하는 특별한 @runtime_checkable 클래스 데코레이터를 정의하며, 이를 통해 본질적으로 “런타임 프로토콜”을 만듭니다.:
from typing import runtime_checkable, Protocol
@runtime_checkable
class SupportsClose(Protocol):
def close(self):
...
assert isinstance(open('some/file'), SupportsClose)
인스턴스 검사는 정적으로 100% 신뢰할 수 없다는 점에 유의하십시오. 이것이 이 동작이 옵트인 방식인 이유이며, 예시는 rejected 아이디어에 대한 절을 참조하십시오. 대부분의 타입 검사기가 할 수 있는 일은 isinstance(obj, Iterator)를 대략 hasattr(x, '__iter__') and hasattr(x, '__next__')를 더 간단하게 작성하는 방법으로 취급하는 것입니다. 이 기능의 위험을 최소화하기 위해 다음 규칙을 적용합니다.
정의:
- 데이터 및 비데이터 프로토콜: 프로토콜이 멤버로 메서드만 포함하는 경우 이를 비데이터 프로토콜이라고 합니다(예:
Sized,Iterator등). 메서드가 아닌 멤버를 하나 이상 포함하는 프로토콜(예:x: int)을 데이터 프로토콜이라고 합니다. - 안전하지 않은 중첩: 타입
X이 프로토콜P의 서브타입은 아니지만, 모든 멤버의 타입이Any인P의 타입 삭제 버전의 서브타입인 경우, 타입X는 프로토콜P와 안전하지 않게 중첩된다고 합니다. 또한 유니온의 요소 중 하나 이상이 프로토콜P와 안전하지 않게 중첩되면 전체 유니온이P와 안전하지 않게 중첩된 것으로 간주합니다.
사양:
- 프로토콜은
@runtime_checkable데코레이터를 통해 명시적으로 옵트인된 경우에만isinstance()및issubclass()의 두 번째 인자로 사용할 수 있습니다. 동적으로 설정된 속성의 경우 프로토콜 검사가 타입 안전하지 않으며, 타입 검사기는 특정 클래스에 대해서만isinstance()검사가 안전하다는 것을 증명할 수 있고 해당 클래스의 모든 서브클래스에 대해서는 증명할 수 없기 때문에 이러한 요구 사항이 존재합니다. isinstance()는 데이터 및 비데이터 프로토콜 모두와 함께 사용할 수 있지만,issubclass()는 비데이터 프로토콜과만 사용할 수 있습니다. 일부 데이터 속성은 생성자에서 인스턴스에 설정할 수 있으며, 이 정보가 클래스 객체에서 항상 사용 가능하지는 않기 때문에 이러한 제한이 존재합니다.- 첫 번째 인자의 타입과 프로토콜 사이에 안전하지 않은 중첩이 있는 경우, 타입 검사기는
isinstance()또는issubclass()호출을 거부해야 합니다. - 타입 검사기는 안전한
isinstance()또는issubclass()호출 후 유니온에서 올바른 요소를 선택할 수 있어야 합니다. 비유니온 타입을 좁힐 때는 타입 검사기가 최선의 판단을 사용할 수 있습니다(정확한 사양에는 교집합 타입이 필요하므로 이는 의도적으로 지정하지 않습니다).
Python 2.7 - 3.5에서 프로토콜 사용
변수 어노테이션 구문은 Python 3.6에 추가되었으므로, 이전 버전에 대한 지원이 필요한 경우 Specification 절에서 제안된 프로토콜 변수 정의 구문을 사용할 수 없습니다. 이전 버전의 Python과 호환되는 방식으로 이를 정의하려면 속성을 사용할 수 있습니다. 필요한 경우 속성을 설정 가능하게 만들거나 추상 속성으로 만들 수 있습니다.:
class Foo(Protocol):
@property
def c(self) -> int:
return 42 # Default value can be provided for property...
@abstractproperty
def d(self) -> int: # ... or it can be abstract
return 0
또한 PEP 484에 따른 함수 타입 주석을 사용할 수 있습니다(예를 들어 Python 2와의 호환성을 제공할 수 있습니다). 이 PEP에서 제안된 typing 모듈 변경 사항은 현재 PyPI에서 제공되는 백포트를 통해 이전 버전에도 백포트됩니다.
프로토콜 클래스의 런타임 구현
구현 세부 사항
런타임 구현은 핵심 인터프리터와 표준 라이브러리에 영향을 주지 않고 순수 Python으로 수행할 수 있으며, typing 모듈과 collections.abc에 대한 사소한 업데이트만 필요합니다.
typing.Generic과 유사하게typing.Protocol클래스를 정의합니다.- 클래스가 프로토콜인지 여부를 감지하는 기능을 구현합니다. 프로토콜인 경우 클래스 속성
_is_protocol = True를 추가합니다. 프로토콜 클래스가 MRO에서 프로토콜 베이스 클래스만 가지는지 확인합니다(object는 제외합니다). collections.abc클래스와 마찬가지로 구조적 인스턴스 및 서브클래스 검사를 수행하는__subclasshook__()을 가능하게 하는@runtime_checkable을 구현합니다.- 모든 구조적 서브타이핑 검사는
mypy[mypy]와 같은 정적 타입 검사기가 수행합니다. 런타임에는 프로토콜 검증을 위한 추가 지원을 제공하지 않습니다.
typing 모듈의 변경 사항
typing 모듈의 다음 클래스는 프로토콜이 됩니다:
CallableAwaitableIterable,IteratorAsyncIterable,AsyncIteratorHashableSizedContainerCollectionReversibleContextManager,AsyncContextManagerSupportsAbs(및 기타Supports*클래스)
이러한 클래스 대부분은 작고 개념적으로 단순합니다. 이러한 프로토콜이 구현하는 메서드가 무엇인지 쉽게 확인할 수 있으며, 이에 대응하는 런타임 프로토콜을 즉시 인식할 수 있습니다. 이러한 클래스 중 일부는 런타임에서 이미 필요한 방식으로 동작하므로, 실제로 typing에는 약간의 변경만 필요합니다. 대부분은 해당 typeshed 스텁 [typeshed]에서만 업데이트하면 됩니다.
List, Set, IO, Deque 등과 같은 그 밖의 모든 구체적인 제네릭 클래스는 프로토콜이 아닌 클래스로 유지하는 것이 타당할 만큼 충분히 복잡합니다(즉, 코드에서 해당 클래스를 명시적으로 지정하도록 요구합니다). 또한 일부 메서드를 실수로 구현하지 않은 채 두기 너무 쉬우며, 서브클래스 관계를 명시적으로 표시하면 타입 검사기가 누락된 구현을 정확히 찾아낼 수 있습니다.
인트로스펙션
기존 클래스 인트로스펙션 메커니즘(dir, __annotations__ 등)을 프로토콜과 함께 사용할 수 있습니다. 또한 typing 모듈에 구현된 모든 인트로스펙션 도구가 프로토콜을 지원합니다. 이 제안에 따라 모든 속성을 클래스 본문에 정의해야 하므로, 프로토콜 클래스는 속성을 암시적으로 정의할 수 있는 일반 클래스보다 인트로스펙션에 대해 훨씬 더 잘 파악할 수 있습니다. 즉, 프로토콜 속성은 인트로스펙션에 보이지 않는 방식(setattr()를 사용하거나 self를 통한 할당 등)으로 초기화할 수 없습니다. 그럼에도 Python 3.5 이하에서는 속성의 타입과 같은 일부 사항이 런타임에 표시되지 않지만, 이는 합리적인 제한으로 보입니다.
위에서 설명한 것처럼 isinstance() 및 issubclass()는 제한적으로만 지원됩니다(이 경우 런타임에 신뢰할 수 있는 답을 제공할 수 없으므로, 첨자가 붙은 제네릭 프로토콜에 대해서는 TypeError와 함께 항상 실패합니다). 그러나 다른 인트로스펙션 도구와 함께 사용하면 런타임 타입 검사 도구에 합리적인 관점을 제공합니다.
거부되었거나 연기된 아이디어
이 절의 아이디어는 이전에 [several] [discussions] [elsewhere]에서 논의되었습니다.
기본적으로 모든 클래스를 프로토콜로 만들기
Go와 같은 일부 언어는 구조적 서브타이핑을 유일한 또는 주된 서브타이핑 형태로 사용합니다. 모든 클래스를 기본적으로(또는 항상) 프로토콜로 만들면 비슷한 결과를 얻을 수 있습니다. 그러나 다음과 같은 이유로 클래스가 프로토콜임을 명시적으로 표시하도록 요구하는 편이 더 낫다고 생각합니다:
- 프로토콜에는 일반 클래스의 일부 속성이 없습니다. 특히 일반 클래스에 대해 정의된
isinstance()는 명목 계층 구조를 기반으로 합니다. 기본적으로 모든 것을 프로토콜로 만들고isinstance()가 작동하도록 하려면 그 의미를 변경해야 하지만, 그렇게 되지는 않을 것입니다. - 프로토콜 클래스에는 일반적으로 많은 메서드 구현이 없어야 합니다. 프로토콜 클래스는 구현이 아니라 인터페이스를 설명하기 때문입니다. 대부분의 클래스에는 많은 메서드 구현이 있으므로 프로토콜 클래스로는 적합하지 않습니다.
- 경험에 따르면 많은 클래스는 어차피 프로토콜로 사용하는 것이 실용적이지 않습니다. 주로 인터페이스가 너무 크거나 복잡하거나 구현 지향적이기 때문입니다. 예를 들어 사실상 비공개인 속성과
__접두사가 없는 메서드를 포함할 수 있습니다. - 기존 Python 코드에서 실제로 유용한 대부분의 프로토콜은 암시적인 것으로 보입니다.
typing및collections.abc의 ABC는 상당히 예외적이지만, 이들조차 Python에 최근 추가된 것이며 대부분의 프로그래머는 아직 사용하지 않습니다. - 많은 내장 함수는
int의 구체적인 인스턴스(및 서브클래스 인스턴스)만 허용하며, 다른 내장 클래스도 마찬가지입니다.int를 구조적 타입으로 만드는 것은 Python 런타임을 크게 변경하지 않고는 안전하지 않으며, 그러한 변경은 이루어지지 않을 것입니다.
일반 클래스를 상속하는 프로토콜
이를 금지하는 주된 근거는 서브타이핑의 추이성을 보존하기 위해서입니다. 다음 예를 고려하십시오.:
from typing import Protocol
class Base:
attr: str
class Proto(Base, Protocol):
def meth(self) -> int:
...
class C:
attr: str
def meth(self) -> int:
return 0
이제 C는 Proto의 서브타입이고, Proto는 Base의 서브타입입니다. 그러나 C는 Base의 서브타입일 수 없습니다. 후자는 프로토콜이 아니기 때문입니다. 이러한 상황은 정말 이상할 것입니다. 또한 Base의 속성이 Proto의 프로토콜 멤버가 되어야 하는지에 대한 모호성도 있습니다.
선택적 프로토콜 멤버 지원
프로토콜을 구현하는 클래스에 메서드나 데이터 속성이 반드시 존재할 필요는 없지만, 존재한다면 특정 시그니처나 타입을 따라야 한다고 말할 수 있으면 편리한 사례를 생각해 낼 수 있습니다. 특정 인스턴스에서 해당 속성을 사용할 수 있는지 확인하기 위해 hasattr()검사를 사용할 수 있습니다.
TypeScript와 같은 언어에도 유사한 기능이 있으며, 이러한 기능은 상당히 일반적으로 사용되는 것으로 보입니다. 현재 Python에서 프로토콜을 사용할 현실적인 잠재적 사례에는 이러한 기능이 필요하지 않습니다. 단순성을 위해 선택적 메서드나 속성은 지원하지 않을 것을 제안합니다. 실제 필요성이 생기면 나중에 언제든지 이 결정을 재검토할 수 있습니다.
프로토콜 메서드만 허용하고 게터와 세터 사용 강제하기
프로토콜은 일반적으로 변수는 정의하지 않고 메서드만 정의한다고 주장할 수 있습니다. 그러나 단순한 변수 하나만 필요할 때 게터와 세터를 사용하는 것은 상당히 비파이썬적입니다. 더구나 대규모 코드베이스에서 프로퍼티(종종 타입 검사기 역할을 합니다)를 널리 사용하는 것은 부분적으로 Python용 정적 타입 검사기가 이전에 없었기 때문이며, 이는 PEP 484와 이 PEP가 해결하려는 문제입니다. 예를 들어:
# without static types
class MyClass:
@property
def my_attr(self):
return self._my_attr
@my_attr.setter
def my_attr(self, value):
if not isinstance(value, int):
raise ValidationError("An integer expected for my_attr")
self._my_attr = value
# with static types
class MyClass:
my_attr: int
비프로토콜 멤버 지원
일부 메서드를 “비프로토콜”로 만들자는 아이디어가 있었습니다. 즉, 구현할 필요가 없고 명시적 서브클래싱에서 상속되도록 하자는 것이었지만, 이는 복잡성을 높이므로 거부되었습니다. 예를 들어 다음 상황을 고려하십시오.:
class Proto(Protocol):
@abstractmethod
def first(self) -> int:
raise NotImplementedError
def second(self) -> int:
return self.first() + 1
def fun(arg: Proto) -> None:
arg.second()
이것을 오류로 처리해야 하는지가 문제입니다. 대부분의 사람들은 이것이 유효하다고 예상할 것이라고 생각합니다. 따라서 안전을 위해 암묵적 서브클래스에서 두 메서드를 모두 구현하도록 요구해야 합니다. 또한 collections.abc의 정의를 살펴보면 “비프로토콜”로 간주할 수 있는 메서드는 매우 적습니다. 따라서 “비프로토콜” 메서드는 도입하지 않기로 결정했습니다.
이 방식에는 한 가지 단점만 있습니다. “대규모” 프로토콜의 암묵적 서브타입에 일부 보일러플레이트가 필요하다는 점입니다. 그러나 모든 “내장” 프로토콜은 “소규모” 프로토콜, 즉 추상 메서드가 몇 개뿐인 프로토콜이므로 여기에는 적용되지 않습니다. 또한 이러한 스타일은 사용자 정의 프로토콜에는 권장되지 않습니다. 간결한 프로토콜을 만들고 이를 결합하는 것이 권장됩니다.
프로토콜을 다른 접근 방식과 상호 운용 가능하게 하십시오.
여기에서 설명한 프로토콜은 기본적으로 기존 ABC 개념의 최소한의 확장입니다. 예를 들어 프로토콜을 Zope 인터페이스를 대체하는 무언가로 이해하기보다는, 이러한 방식으로 이해해야 한다고 주장합니다. 이러한 상호 운용을 시도하면 개념과 구현 모두 상당히 복잡해집니다.
반면 Zope 인터페이스는 개념적으로 여기에서 정의한 프로토콜의 상위 집합이지만, 이를 정의하는 데 호환되지 않는 구문을 사용합니다. PEP 526 이전에는 속성에 어노테이션을 붙일 간단한 방법이 없었기 때문입니다. 3.6 이상 환경에서는 zope.interface가 잠재적으로 Protocol 구문을 채택할 수 있습니다. 이 경우 타입 검사기가 인터페이스를 프로토콜로 인식하고 이에 대해 간단한 구조적 검사를 수행하도록 가르칠 수 있습니다.
할당을 사용하여 클래스가 프로토콜을 구현하는지 명시적으로 확인하십시오.
Go 언어에서는 구현 여부를 명시적으로 확인할 때 더미 할당을 사용합니다 [golang]. 현재 제안에서도 이러한 방식이 가능합니다. 예시입니다.:
class A:
def __len__(self) -> float:
return ...
_: Sized = A() # Error: A.__len__ doesn't conform to 'Sized'
# (Incompatible return type 'float')
이 접근 방식은 검사를 클래스 정의에서 벗어나게 하며, 그렇지 않으면 평균적인 독자에게 코드가 아마도 전혀 의미가 없을 것이므로 거의 반드시 주석이 필요합니다 – 죽은 코드처럼 보이기 때문입니다. 게다가 가장 단순한 형태에서는 A의 인스턴스를 생성해야 하는데, 파일이나 소켓과 같은 일부 리소스에 접근하거나 이를 할당해야 한다면 문제가 될 수 있습니다. 예를 들어 캐스트를 사용하여 후자의 문제를 우회할 수 있지만, 그러면 코드가 보기 좋지 않게 됩니다. 따라서 이 패턴의 사용을 권장하지 않습니다.
기본적으로 isinstance() 검사를 지원하십시오.
이 방식의 문제는 Iterable과 같이 공통된 시그니처 규칙이 있는 상황을 제외하면 인스턴스 검사를 신뢰하기 어려울 수 있다는 점입니다. 예를 들어 다음과 같습니다.:
class P(Protocol):
def common_method_name(self, x: int) -> int: ...
class X:
<a bunch of methods>
def common_method_name(self) -> None: ... # Note different signature
def do_stuff(o: Union[P, X]) -> int:
if isinstance(o, P):
return o.common_method_name(1) # Results in TypeError not caught
# statically if o is an X instance.
또 다른 잠재적으로 문제가 될 수 있는 경우는 인스턴스화 후 속성을 할당하는 경우입니다.:
class P(Protocol):
x: int
class C:
def initialize(self) -> None:
self.x = 0
c = C()
isinstance(c, P) # False
c.initialize()
isinstance(c, P) # True
def f(x: Union[P, int]) -> None:
if isinstance(x, P):
# Static type of x is P here.
...
else:
# Static type of x is int, but can be other type at runtime...
print(x + 1)
f(C()) # ...causing a TypeError.
명시적인 클래스 데코레이터를 요구하는 편이 더 낫다고 주장합니다. 그러면 문서에서 이와 같은 문제에 대한 경고를 추가할 수 있기 때문입니다. 사용자는 각 프로토콜에 대해 이점이 혼란을 초래할 가능성보다 큰지 평가하고 명시적으로 선택할 수 있지만, 기본 동작이 더 안전할 것입니다. 마지막으로 필요한 경우 이 동작을 기본값으로 만들기는 쉽지만, 기본 동작으로 만든 후 선택 사항으로 바꾸는 것은 문제가 될 수 있습니다.
특수한 교집합 타입 구문을 제공하십시오.
Proto = All[Proto1, Proto2, ...]을 약칭으로 허용하자는 아이디어가 있었습니다.:
class Proto(Proto1, Proto2, ..., Protocol):
pass
그러나 이것이 얼마나 널리 사용되고 유용할지는 아직 명확하지 않으며, 비프로토콜 클래스에 대해 이를 타입 검사기에 구현하는 것은 어려울 수 있습니다. 마지막으로, 필요하다면 나중에 이를 추가하는 것은 매우 쉽습니다.
비프로토콜의 프로토콜 명시적 서브클래싱을 금지합니다.
이는 다음과 같은 이유로 거부되었습니다.
- 하위 호환성: 사람들은 이미
typing모듈의 제네릭 ABC를 비롯한 ABC를 사용하고 있습니다. 이러한 ABC의 명시적 서브클래싱을 금지하면 상당히 많은 코드가 중단됩니다. - 편의성: 이미 프로토콜과 유사한 ABC가 존재하며, 이들은 유용한 “믹스인”(비추상) 메서드를 많이 포함하고 있습니다. 예를 들어
Sequence의 경우 명시적 서브클래스에서__getitem__과__len__만 구현하면 되고,__iter__,__contains__,__reversed__,index,count를 무료로 사용할 수 있습니다. - 명시적 서브클래싱을 사용하면 클래스가 특정 프로토콜을 구현한다는 점이 명확해지므로 서브타이핑 관계를 더 쉽게 파악할 수 있습니다.
- 타입 검사기는 이 절 위에서 논의한 더미 할당과 같은 해킹을 사용하지 않고도, 누락된 프로토콜 멤버나 호환되지 않는 타입의 멤버에 대해 더 쉽게 경고할 수 있습니다.
- 명시적 서브클래싱을 사용하면 클래스가 엄밀하게 호환되지 않는 경우에도, 안전하지 않은 오버라이드가 있는 경우와 같이
# type: ignore를 명시적 베이스 클래스와 함께 사용하여 해당 클래스를 프로토콜의 서브타입으로 간주하도록 강제할 수 있습니다.
가변 속성의 공변 서브타이핑
가변 속성의 공변 서브타이핑은 안전하지 않기 때문에 거부되었습니다. 다음 예를 고려하십시오.:
class P(Protocol):
x: float
def f(arg: P) -> None:
arg.x = 0.42
class C:
x: int
c = C()
f(c) # Would typecheck if covariant subtyping
# of mutable attributes were allowed.
c.x >> 1 # But this fails at runtime
실용적인 이유로 처음에는 이를 허용하자는 제안이 있었지만, 발견하기 어려운 일부 버그를 가릴 수 있으므로 이후 거부되었습니다.
프로토콜 클래스에서 추론된 변동성 재정의
실제로 공변 또는 반공변인 프로토콜을 불변으로 선언할 수 있도록 하자는 제안이 있었습니다(명목 클래스에서 가능한 것과 같습니다. PEP 484를 참조하십시오). 그러나 여기에는 몇 가지 단점이 있으므로 그렇게 하지 않기로 결정했습니다.
- 선언된 프로토콜의 불변성은 서브타이핑의 추이성을 깨뜨립니다. 다음 상황을 고려하십시오.:
T = TypeVar('T') class P(Protocol[T]): # Protocol is declared as invariant. def meth(self) -> T: ... class C: def meth(self) -> float: ... class D(C): def meth(self) -> int: ...
이제
D는C의 서브타입이고,C는P[float]의 서브타입입니다. 그러나D는P[float]의 서브타입이 아니며,D가P[int]를 구현하고P가 불변이기 때문입니다. MRO에서 프로토콜 구현을 찾아 이를 “치유”할 가능성이 있지만, 일반적인 경우에는 너무 복잡하며, 이러한 “치유”를 위해서는 프로토콜에 대한 순수한 구조적 서브타이핑이라는 단순한 개념을 포기해야 합니다. - 서브타이핑 검사를 수행하려면 항상 프로토콜에 대한 타입 추론이 필요합니다. 위의 예에서 사용자는 다음과 같이 불평할 수 있습니다. “내
D에 대해 왜P[int]를 추론했습니까?P[float]를 구현합니다!”. 일반적으로 명시적 어노테이션으로 추론을 재정의할 수 있지만, 여기서는 명시적 서브클래싱이 필요하므로 프로토콜을 사용하는 목적이 무색해집니다. - 변동성 재정의를 허용하면 타입 검사기가 멤버 타입 시그니처의 특정 충돌을 인용하는 더 상세한 오류 메시지를 제공할 수 없게 됩니다.
- 마지막으로, 이 경우에는 암시적인 것보다 명시적인 것이 낫습니다. 사용자가 올바른 변동성을 선언하도록 요구하면 코드를 더 쉽게 이해할 수 있고 사용 시점의 예기치 않은 오류를 방지할 수 있습니다.
어댑터 및 적응을 지원합니다.
적응은 PEP 246(거부됨)에서 제안되었으며 zope.interface에서 지원됩니다. 어댑터 레지스트리에 관한 Zope 문서 를 참조하십시오. 어댑터는 상당히 고급 개념이며, PEP 484는 어댑터 대신 사용할 수 있는 유니언과 제네릭 별칭을 지원합니다. 이는 Iterable 프로토콜의 예로 설명할 수 있으며, __getitem__ 및 __len__ 을 제공하여 반복을 지원하는 또 다른 방법도 있습니다. 함수가 이 방법과 현재 표준인 __iter__ 메서드를 모두 지원한다면 유니언 타입으로 어노테이션을 지정할 수 있습니다.:
class OldIterable(Sized, Protocol[T]):
def __getitem__(self, item: int) -> T: ...
CompatIterable = Union[Iterable[T], OldIterable[T]]
class A:
def __iter__(self) -> Iterator[str]: ...
class B:
def __len__(self) -> int: ...
def __getitem__(self, item: int) -> str: ...
def iterate(it: CompatIterable[str]) -> None:
...
iterate(A()) # OK
iterate(B()) # OK
기존 도구를 사용하면 이러한 경우에 합리적인 대안이 있으므로, 이 PEP에서는 적응을 포함하지 않을 것을 제안합니다.
구조적 베이스 타입을 “인터페이스”라고 부릅니다.
“프로토콜”은 __iter__ 및 __next__를 제공하는 이터레이터 프로토콜과 __get__, __set__ 및 __delete__를 제공하는 디스크립터 프로토콜과 같은 덕 타이핑 계약을 설명하기 위해 Python에서 이미 널리 사용되는 용어입니다. 이 점과 Specification에 제시된 다른 이유 외에도, 프로토콜은 여러 측면에서 Java 인터페이스와 다릅니다. 프로토콜은 구현을 명시적으로 선언할 것을 요구하지 않으며(주로 덕 타이핑을 지향합니다), 멤버의 기본 구현을 포함하고 상태를 저장할 수 있습니다.
프로토콜을 일반 ABC가 아니라 런타임에서 특수한 객체로 만들기
프로토콜을 ABC가 아니게 만들면, 애초에 가능하다고 하더라도 하위 호환성에 문제가 생길 것입니다. 예를 들어 collections.abc.Iterable은 이미 ABC이며, 기존 코드 다수가 isinstance(obj, collections.abc.Iterable)과 같은 패턴이나 다른 ABC에 대한 유사한 검사(마찬가지로 구조적인 방식, 즉 __subclasshook__을 통한 검사)를 사용합니다. 이 동작을 비활성화하면 문제가 발생할 것입니다. collections.abc의 ABC에 대해서는 이 동작을 유지하면서 typing의 프로토콜에 대해서는 유사한 런타임 동작을 제공하지 않는다면, 프로토콜로의 원활한 전환은 불가능할 것입니다. 또한 두 개의 병렬적인 계층 구조를 갖는 것은 혼란을 야기할 수 있습니다.
하위 호환성
이 PEP는 완전히 하위 호환됩니다.
구현
mypy타입 검사기는 (알려진 몇 가지 버그를 제외하면) 프로토콜을 완전히 지원합니다. 여기에는 Iterable과 같은 모든 내장 프로토콜을 구조적으로 처리하는 것도 포함됩니다. 프로토콜의 런타임 구현은 PyPI의 typing_extensions 모듈에서 이용할 수 있습니다.
참고 문헌
Copyright
This document has been placed in the public domain.