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

Python 개선 제안 한국어 번역

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

번역·라이선스 안내

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

Important

This PEP is a historical document: see Protocols and typing.Protocol for up-to-date specs and documentation. Canonical typing specs are maintained at the typing specs site; runtime typing behaviour is described in the CPython documentation.

×

See the typing specification update process for how to propose changes to the typing spec.

초록

PEP 484에서 도입된 타입 힌트는 정적 타입 검사기와 기타 서드파티 도구가 사용할 타입 메타데이터를 지정하는 데 쓸 수 있습니다. 그러나 PEP 484는 명목적 서브타이핑의 의미만을 규정합니다. 이 PEP에서는 구조적 서브타이핑(정적 덕 타이핑)을 지원하는 프로토콜 클래스의 정적 의미와 런타임 의미를 규정합니다.

근거와 목표

현재 PEP 484와 typing 모듈 [typing]은 Iterable과 Sized 같은 여러 일반적인 Python 프로토콜에 대한 추상 베이스 클래스를 정의합니다. 문제는 클래스가 이를 지원하려면 명시적으로 표시되어야 한다는 점인데, 이는 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)
    

    근거에서 언급했듯이, 특히 정적 컨텍스트에서는 이러한 필요성을 피하고자 합니다. 그러나 런타임 컨텍스트에서는 ABC가 프로토콜 클래스의 좋은 후보이며, 이미 typing 모듈에서 폭넓게 사용되고 있습니다.

  • collections.abc 모듈 [collections-abc]에 정의된 추상 클래스는 명시적인 등록 없이 런타임 구조 검사를 가능하게 하는 사용자 정의 __subclasshook__() 메서드를 구현하므로 조금 더 발전된 형태입니다:
    from collections.abc import Iterable
    
    class MyIterable:
        def __iter__(self):
            return []
    
    assert isinstance(MyIterable(), Iterable)
    

    이러한 동작은 프로토콜의 런타임 동작과 정적 동작 모두에 완벽하게 들어맞는 것으로 보입니다. 근거에서 논의한 바와 같이, 이러한 동작에 대한 정적 지원을 추가할 것을 제안합니다. 또한 사용자가 사용자 정의 프로토콜에 대해서도 이러한 런타임 동작을 구현할 수 있도록 특수한 @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는 구현에서 불필요한 멤버를 금지합니다. 선택적 멤버라는 아이디어는 흥미로워 보이지만, 이 제안을 복잡하게 만들 것이며 얼마나 유용할지도 분명하지 않습니다. 따라서 이를 연기할 것을 제안합니다. 거부된 아이디어를 참조하십시오. 전반적으로 런타임에 영향을 주지 않는 정적 프로토콜 검사라는 아이디어는 합리적으로 보이며, 기본적으로 이 제안도 같은 노선을 따릅니다.

  • 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()의 반환 타입)은 모두 호환되는 타입 시그니처를 가진 close() 메서드를 제공하므로 SupportsClose의 서브타입으로 간주된다는 점에 유의하십시오.

프로토콜 멤버

프로토콜 클래스 본문에 정의된 모든 메서드는 일반 메서드이든 @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이 포함되어 있으면 다른 모든 베이스 클래스도 프로토콜이어야 합니다. 프로토콜은 일반 클래스를 확장할 수 없습니다. 그 근거는 거부된 아이디어를 참조하십시오. 명시적 서브클래싱에 관한 규칙은 일반 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]'.

명목적 클래스와 달리, 사실상 공변인 프로토콜은 불변 조건(invariant)으로 선언할 수 없다는 점에 유의하십시오. 이는 서브타이핑의 추이성을 깨뜨릴 수 있기 때문입니다(자세한 내용은 거부된 아이디어를 참조하십시오). 예를 들면:

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는 X가 P의 모든 프로토콜 멤버를 호환되는 타입으로 구현하는 경우에만, 그리고 그 경우에 한해 프로토콜 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에 명시된 대로 특별한 교집합 타입 구문이 추가될 수 있습니다. 자세한 내용은 기각된 아이디어를 참조하십시오.

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()가 실패합니다. 이는 덕 타이핑의 취지에 따른 것입니다. 프로토콜은 기본적으로 런타임에 명시적으로가 아니라 정적으로 덕 타이핑을 모델링하는 데 사용됩니다.

하지만 Iterable을 비롯한 collections.abc 및 typing의 다른 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% 신뢰할 수 없다는 점에 유의하십시오. 이 때문에 이 동작은 옵트인 방식이며, 예시는 거부된 아이디어에 관한 절을 참고하십시오. 타입 검사기가 할 수 있는 최선은 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에서 제공되는 백포트를 통해 이전 버전에도 백포트될 것입니다.

프로토콜 클래스의 런타임 구현

구현 세부 사항

런타임 구현은 typing 모듈과 collections.abc에 대한 사소한 업데이트를 제외하면 핵심 인터프리터와 표준 라이브러리에 아무런 영향을 주지 않고 순수 Python으로 할 수 있습니다:

  • typing.Generic와 유사하게 typing.Protocol 클래스를 정의합니다.
  • 클래스가 프로토콜인지 아닌지 감지하는 기능을 구현합니다. 그렇다면 클래스 속성 _is_protocol = True를 추가합니다. 프로토콜 클래스의 MRO에 (객체 object를 제외하고) 프로토콜 베이스 클래스만 있는지 확인하십시오.
  • collections.abc 클래스에서처럼 __subclasshook__()가 구조적 인스턴스 검사와 서브클래스 검사를 수행할 수 있게 하는 @runtime_checkable을 구현합니다.
  • 모든 구조적 서브타이핑 검사는 mypy [mypy] 같은 정적 타입 검사기가 수행합니다. 런타임에는 프로토콜 검증을 위한 추가 지원을 제공하지 않습니다.

typing 모듈의 변경 사항

typing 모듈의 다음 클래스는 프로토콜이 됩니다:

  • Callable
  • Awaitable
  • Iterable, Iterator
  • AsyncIterable, AsyncIterator
  • Hashable
  • Sized
  • Container
  • Collection
  • Reversible
  • ContextManager, AsyncContextManager
  • SupportsAbs (및 기타 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의 구체적인 인스턴스(및 서브클래스 인스턴스)만 받으며, 다른 내장 클래스에 대해서도 마찬가지입니다. Python 런타임을 크게 변경하지 않고서는 int를 구조적 타입으로 만드는 것이 안전하지 않으며, 그러한 변경은 일어나지 않을 것입니다.

일반 클래스를 서브클래싱하는 프로토콜

이를 금지하는 주된 근거는 서브타이핑의 추이성을 보존하기 위해서입니다. 다음 예를 살펴보십시오:

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

처음에는 실용적인 이유로 이를 허용하자는 제안이 있었으나, 발견하기 어려운 버그를 가릴 수 있으므로 이후 기각되었습니다.

프로토콜 클래스의 추론된 변성 재정의하기

프로토콜이 실제로는 공변이거나 반공변인 경우에도 불변 조건(invariant)으로 선언할 수 있도록 허용하자는 제안이 있었습니다(명목적 클래스에서는 가능한 것처럼, 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[int]를 구현하고 P는 불변 조건(invariant)이므로, D는 P[float]의 서브타입이 아닙니다. MRO에서 프로토콜 구현을 찾는 방식으로 이를 “치료”할 가능성이 있지만, 일반적인 경우에는 너무 복잡해지며, 이 “치료”를 위해서는 프로토콜에 대한 순수한 구조적 서브타이핑이라는 단순한 개념을 포기해야 합니다.

  • 서브타이핑 검사에는 항상 프로토콜에 대한 타입 추론이 필요하게 됩니다. 위의 예에서 사용자는 이렇게 불평할 수 있습니다. “왜 내 D를 P[int]로 추론했습니까? 이 클래스는 P[float]를 구현합니다!”. 보통은 명시적 어노테이션으로 추론을 무효화할 수 있지만, 여기서는 명시적 서브클래싱이 필요하게 되어 프로토콜을 사용하는 목적이 무색해집니다.
  • 변성 재정의를 허용하면 타입 검사기가 멤버 타입 시그니처의 구체적인 충돌을 짚어 주는 더 자세한 오류 메시지를 제공할 수 없게 됩니다.
  • 끝으로, 이 경우에는 명시적인 것이 암시적인 것보다 낫습니다. 사용자에게 올바른 변성을 선언하도록 요구하면 코드를 이해하기가 쉬워지고, 사용 시점에 예상치 못한 오류가 발생하는 것을 피할 수 있습니다.

어댑터와 어댑테이션 지원하기

적응(adaptation)은 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 모듈에서 사용할 수 있습니다.

참고 문헌