본문 바로가기
프로그래밍/Python

[Python] Interfaces, Protocols, and ABCs

by 별준 2022. 3. 23.

References

  • Fluent Python

Contents

  • Dynamic/Static Protocols
  • Goose Typing
  • ABCs in the Standard Library
  • The numbers ABCs and Numeric Protocols

부족한 영어 실력과 생소한 내용으로 부족할 수 있습니다.. ㅠ.ㅠ
지적은 언제나 환영이므로, 덧붙이고 싶거나 잘못된 내용이 있다면 언제든지 댓글로 남겨주세요.. !

 

객체지향 프로그래밍는 인터페이스에 관한 것이라고 할 수 있습니다. 파이썬의 타입을 이해하는데 가장 좋은 방법은 그 타입이 제공하는 메소드(인터페이스)를 아는 것입니다.

 

프로그래밍 언어에 따라서, 인터페이스를 정의하고 사용하는 여러 방법이 있습니다. Python 3.8부터는 4가지 방법이 있는데, 아래 그림은 4가지 방법들을 묘사하고 있습니다.

간략하여 요약하면 다음과 같습니다.

  • duck typing:
    파이썬 초창기부터의 기본 타이핑 접근방법
  • goose typing:
    파이썬 2.6부터 추상 베이스 클래스(Abstract Base Claases; ABCs)에 의해 지원되는 접근방법. 런타임에 ABCs에 대한 객체 검사에 의존
  • static typing:
    C와 자바와 같은 정적 타이핑 언어의 기본 접근 방법. 파이썬 3.5부터 typing 모듈에 의해 지원되며 PEP 484와 외부 type check에 의해 강제됨
  • static duck typing:
    Go 언어로 인해 대중화된 접근방식. 파이썬3.8에 추가된 typing.Protocol의 서브클래스에 의해 지원됨

 


Two Kinds of Protocols

프로토콜이라는 단어는 컴퓨터 공학에서 문맥에 따라 다른 의미를 가지고 있습니다. HTTP와 같은 네트워크 프로토콜에서는 GET, PUT, HEAD와 같은 클라이언트가 서버로 보낼 수 있는 명령어를 가리킵니다.

import collections

Card = collections.namedtuple('Card', ['rank', 'suit'])

class FrenchDeck:
    ranks = [str(n) for n in range(2, 11)] + list('JQKA')
    suits = 'spades diamonds clubs hearts'.split()

    def __init__(self):
        self._cards = [Card(rank, suit) for suit in self.suits
                                        for rank in self.ranks]
    
    def __len__(self):
        return len(self._cards)
    
    def __getitem__(self, position):
        return self._cards[position]

위 예제 코드는 파이썬 관련 포스팅에서 자주 사용하던 예제 코드인데, 이처럼 객체 프로토콜은 객체가 반드시 역할을 수행하기 위해 제공해야하는 메소드를 가리킵니다. FrecnDeck의 경우에는 시퀀스 프로토콜을 따른다고 볼 수 있으며, 정의된 메소드들은 파이썬 객체가 시퀀스처럼 행동할 수 있도록 해줍니다.

 

완전한 프로토콜을 구현하기 위해서는 여러 메소드들이 필요하지만, 이 메소드들 중의 일부만 구현해도 괜찮습니다. 아래 예제 코드의 Vowels 클래스를 살펴보겠습니다.

class Vowels:
    def __getitem__(self, i):
        return 'AEIOU'[i]

__getitem__() 메소드를 구현하는 것만으로 인덱스를 통해 항목들을 탐색할 수 있으며, 반복과 in 연산자를 지원합니다. __getitem__() 스페셜 메소드는 실제로 시퀀스 프로토콜의 중요한 역할을 합니다. Python/C API Reference Manual의 Sequence Protocol에서 PySequence_Check 함수를 살펴보면, 다음과 같이 설명하고 있습니다.

 

또한, __len__ 메소드를 구현하여 시퀀스가 len()을 지원한다고 기대할 수 있습니다. 위에서 정의한 Vowels에는 __len__ 메소드가 없지만, Vowels은 일부 문맥에서는 시퀀스처럼 동작합니다. 그렇기 때문에 프로토콜을 "informal interface"라고 부릅니다. 이것이 객체지향 프로그래밍 환경에서 사용하는 프로토콜이며, 이는 프로토콜을 이해하는 방법이라고 할 수 있습니다.

 

파이썬 3.8에서는 PEP 544와 함께 프로토콜이라는 단어는 다른 의미를 가집니다. PEP 544는 typing.Protocol의 서브클래스를 생성하여, 클래스가 반드시 구현(또는 상속)해야 하는 하나 이상의 메소드를 정의하도록 합니다.

구체적으로 표현하자면, 아래의 두 프로토콜로 나눌 수 있습니다.

  • dynamic protocol:
    파이썬의 informal protocol. Dynamic protocol은 암묵적으로, 컨벤션(convention)에 따라 정의되며, 문서에 설명되어 있습니다. 파이썬의 가장 중요한 dynamic protocol은 인터프리터 그 자체에 의해 지원되며, 파이썬 레퍼런스 문서의 Data Model 챕터(link)에 설명되어 있습니다.
  • static protocol:
    PEP 544에 정의된 프로토콜이며, 파이썬 3.8부터 적용됩니다. Static protocol은 typing.Protocol의 서브클래스로 명시적으로 정의됩니다.

이들 간에는 2가지 주요한 차이점이 있습니다.

  1. 객체는 dynamic protocol의 일부분만 구현될 수 있으며, 그렇더라도 매우 유용하다. 하지만 static protocol을 따른다면 객체는 비록 프로그램이 모든 메소드를 필요로 하지 않더라도, 프로토콜 클래스에 선언된 모든 메소드를 제공해야만 한다.
  2. Static protocol은 static type checker에 의해 검증되며, dynamic protocol은 그렇지 않다.

 

두 프로토콜은 클래스가 프로토콜을 지원한다고 프로토콜 이름을 상속과 같은 방식으로 선언할 필요가 없다는 본질적인 특징을 공유합니다.

추가로, static protocols에서, 파이썬은 코드안에 명시적인 인터페이스를 정의하는 다른 방법(ABC)을 제공합니다. 이 내용은 아래쪽에서 다루도록 하겠습니다.

 


Programming Ducks

이번에는 파이썬에서 가장 중요한 sequence / iterable 프로토콜을 가지고 dynamic protocol에 대해 이야기해보도록 하겠습니다.

 

Python Digs Sequences

파이썬 데이터 모델은 가능한 한 많이 핵심 dynamic 프로토콜과 협업하겠다는 철학을 가지고 있습니다. 시퀀스의 경우, 가장 단순한 구현만 가지고 있더라도 파이썬은 최선을 다합니다.

 

아래 그림은 ABC로 정의된 공식적인 Sequence 인터페이스를 보여줍니다.

파이썬 인터프리터와 list, str과 같은 내장된 시퀀스들은 ABC에 전혀 의존하지 않습니다. 

 

위에서 살펴봤던 Vowels 클래스를 다시 살펴보겠습니다.

class Vowels:
    def __getitem__(self, i):
        return 'AEIOU'[i]

이 클래스는 abc.Sequence를 상속받지 않으며 오직 __getitem__()만 구현하고 있습니다.

여기에 __iter__() 메소드는 아직 구현되지 않았지만, Vowels 인스턴스에는 대체 수단인 __getitem__() 메소드가 구현되어 있으므로 반복 가능(iterable)합니다. 파이썬 인터프리터는 0부터 시작하는 정수 인덱스로 __getitem__() 메소드를 호출하여 객체 반복을 시도하기 때문입니다. 파이썬은 Vowels 인스턴스를 반복할 수 있을 만큼 충분히 똑똑하기 때문에 __contains__() 메소드가 구현되어 있지 않더라도, 객체 전체를 조사해서 항목을 찾아냄으로써 in 연산자도 동작시킬 수 있습니다.

 

정리하면, 시퀀스와 같은 데이터 구조의 중요성 때문에, __iter__()와 __contains__() 메소드가 구현되어 있지 않더라도 파이썬은 __getitem__() 메소드를 호출해서 객체를 반복하고 in 연산자를 사용할 수 있게 해줍니다.

 

위에서 살펴본 FrenchDeck 클래스도 abc.Sequence를 상속하지 않지만, 시퀀스 프로토콜의 __getitem__()과 __len__() 메소드를 구현합니다. 파이썬은 약간이라도 시퀀스를 닮은 객체는 모두 특별하게 처리하므로 시퀀스에서 처리할 수 있는동작들 대부분을 수행할 수 있습니다. 파이썬 인터프리터는 객체를 반복하기 위해서 2개의 다른 메소드를 시도하므로, 반복(iterable) 프로토콜은 덕 타이핑의 극단적인 예를 보여줍니다.

 

프로토콜의 동적인 특징을 잘 보여주는 다른 예를 살펴보겠습니다.

Monkey-Patching: Implementing a Protocol at Runtime

위에서 살펴본 FrenchDeck 클래스는 카드를 섞을 수 없다는 치명적인 단점이 있습니다. 저자의 경우, FrechDeck 클래스를 처음 구현했을 때는 shuffle() 메소드를 구현했었는데, 나중에 파이썬에 대해 어느 정도 눈을 뜬 후에는, 시퀀스처럼 동작하는 FrechDeck 클래스라면 shuffle() 메소드를 직접 구현할 필요가 없다는 것을 깨달았다고 합니다. 이는 random.shuffle() 함수 문서(link)에서 설명하는 것처럼 random.shuffle() 함수가 시퀀스 객체 안의 항목들을 섞어주기 때문입니다.

 

표준 random.shuffle() 함수는 다음과 같이 사용할 수 있습니다.

그러나, FrenchDeck 인스턴스를 섞으려하면 다음과 같이 예외가 발생합니다.

에러 메세지를 보면 원인을 알 수 있는데, 원인은 'FrenchDeck 객체가 할당을 지원하지 않기' 때문입니다. shuffle() 함수는 컬렉션 안의 항목들을 교환시킴으로써 동작하는데, FrenchDeck 클래스는 불변(immutable) 시퀀스 프로토콜만 구현하고 있습니다. 가변(mutable) 시퀀스는 __setitem__() 메소드도 지원해야 합니다.

 

파이썬은 동적 언어이므로 코드를 대화형 콘솔에서 실행하는 동안에도 이 문제를 해결할 수 있습니다. 다음 코드를 통해서 어떻게 이 문제를 수정할 수 있는지 살펴보겠습니다.

__setitem__() 스페셜 메소드의 시그니처는 파이썬 레퍼런스 문서의 Emulating container types(link)에 정의되어 있습니다. 문서에 있는 self, key, value 대신 여기서는 deck, position, card를 매개변수로 사용했고, 이는 파이썬 메소드는 단지 평범한 함수며, 첫 번째 매개변수로 self를 사용하는 것은 관례일 뿐이라는 것을 보여주기 위함입니다. 콘솔 세션에서는 이렇게 작성해도 되지만, 파이썬 소스 파일에서는 문서에서 설명한 대로 self, key, value를 매개변수 이름으로 사용하는 것이 좋습니다.

 

deck 객체에는 _cards라는 이름의 속성이 있고, _cards가 가변 시퀀스임을 set_card() 함수가 알고 있다는 것이 핵심입니다. 그리고 나서 set_card() 함수가 FrenchDeck 클래스의 __setitem__() 스페셜 메소드에 연결됩니다. 이 방법은 멍치 패칭(monkey patching)의 한 예입니다. 멍키 패칭은 소스 코드를 건드리지 않고 런타임에 클래스나 모듈을 변경하는 행위를 말합니다. 멍키 패칭은 강력하지만, private 속성이나 문서화되지 않은 부분을 다루는 경우가 많기 때문에 패치하는 코드와 패치될 프로그램이 아주 밀접하게 연결되어 있습니다.

 

위의 예제 코드는 멍키 패칭의 예시를 보여주는 것 외에도 프로토콜이 동적이라는 것을 잘 보여줍니다. random.shuffle() 함수는 자신이 받는 인수의 자료형에 대해서는 신경쓰지 않습니다. 단지 받은 객체가 일부 가변 시퀀스 프로토콜을 구현하고 있으면 될 뿐입니다. 심지어 해당 객체가 필요한 메소드를 '원래부터' 가지고 있었는지, 아니면 나중에 얻었는지는 전혀 문제가 되지 않습니다.

 


Goose Typing (Dynamic Protocols)

파이썬에는 interface 키워드가 없으며, 추상 베이스 클래스(Abstract Base Classes; ABCs)를 사용해서 명시적인 인터페이스를 정의합니다.

 

파이썬 용어사전에서 abstract base class는 다음과 같이 설명하고 있습니다.

 

Goose Typing(구스 타이핑)은 ABCs를 사용하는 런타임 type checking approach입니다.

간략하게 정리하면, 구스 타이핑은 ABCs를 서브클래싱하여 이전에 정의한 인터페이스를 구현하고 있음을 명시합니다. 그리고 구스 타이핑은 isinstance와 issubclass의 두 번째 인수로 구체적인 클래스 대신 ABCs를 사용하여 런타임 타입 검사를 수반합니다.

 

구스 타이핑에 대한 내용을 텍스트로 자세히 설명하기에 제 능력이 부족하여, 간단하게만 정리하였습니다. 아래에서 구스 타이핑을 응용하는 예제들을 통해서 자세히 살펴보겠습니다.

 

Subclassing an ABC

우선 직접 ABC를 만드는 작업을 시도해보기 전에 collections.MutableSequence라는 ABC를 활용해보겠습니다. 아래 예제 코드는 위에서 정의한 FrenchDeck을 collections.MutableSequence의 서브클래스로 선언합니다.

import collections
from collections.abc import MutableSequence

Card = collections.namedtuple('Card', ['rank', 'suit'])

class FrenchDeck(MutableSequence):
    ranks = [str(n) for n in range(2, 11)] + list('JQKA')
    suits = 'spades diamonds clubs hearts'.split()

    def __init__(self):
        self._cards = [Card(rank, suit) for suit in self.suits
                                        for rank in self.ranks]
    
    def __len__(self):
        return len(self._cards)
    
    def __getitem__(self, position):
        return self._cards[position]

    # 카드를 섞기 위해서는 __setitem__() 메소드만 있으면 가능
    def __setitem__(self, position, value):
        self._cards[position] = value
    
    # MutableSequence를 상속했으므로, 이 클래스의 추상 메소드인 __delitem__()도 구현해야함
    def __delitem__(self, position):
        del self._cards[position]
    
    # MutableSequence의 세 번째 추상 메소드인 insert()도 구현해야함
    def insert(self, position, value):
        self._cards.insert(position, value)

파이썬은 모듈을 로딩하거나 컴파일할 때가 아니라, 실행 도중 실제로 FrenchDeck2 객체를 생성할 때 추상 메소드의 구현 여부를 확인합니다. 이때 추상 메소드 중 하나라도 구현되어 있지 않으면 'Can't instantiate abstract class FrenchDeck2 with abstract methods __delitem__, insert' 라는 메세지와 함께 TypeError 예외가 발생합니다. 그렇기 때문에 우리가 구현한 FrenchDeck2 예제에서 사용하지도 않는 __delitem__()과 insert() 메소드를 구현해야 합니다. 이는 MutableSequence ABC가 요구하는 사항이기 때문입니다.

 

아래 그림을 보면 Sequence와 MutableSequence ABC의 메소드 전부가 추상 메소드는 아니라는 것을 보여줍니다.

FrenchDeck2는 Sequence로부터 바로 사용할 수 있는 __contains__(), __iter__(), __reversed__(), index(), count()와 같은 메소드를 상속합니다. MutableSequence 클래스로부터는 append(), reverse(), extend(), pop(), remove(), __iadd__() 메소드를 상속합니다.

 

collections.abc ABC의 구상(concrete) 메소드는 클래스의 public 인터페이스만 이용해서 구현하므로, 클래스 내부 구조를 몰라도 제대로 동작합니다. 따라서 ABC를 잘 활용하려면 어떤 것들이 제공되는지 알아야 합니다. 아래에서는 collections에서 제공하는 ABC에 대해서 살펴보겠습니다.

 

ABCs in the Standard Library

파이썬 2.6이후부터 표준 라이브러리에 ABC가 포함되었습니다. numbers와 io 패키지에서도 볼 수 있지만, 대부분의 ABC는 collections.abc 모듈에 정의되어 있으며, 이 모듈에 정의된 ABC들이 가장 많이 사용됩니다.

 

아래 그림은 collections.abc에 정의된 17개의 ABCs에 대한 UML 클래스 다이어그램을 간략하게 보여줍니다.

collections.abc의 공식 문서는 ABC 클래스들 간의 관계, 추상(abstract) 및 구상(concrete) 메소드를 표(link) 형태로 요약해서 잘 설명해주고 있습니다. 다중 상속도 많이 볼 수 있지만, 여기에서는 다루지 않도록 하며 일반적으로 ABC에서는 다중 상속이 문제가 되지 않는다는 정도만 알아두면 좋습니다.

 

위 그림의 주요 부분들을 요약하면 다음과 같습니다.

  • Iterable, Container, Sized :
    모든 컬렉션은 이 ABC를 상속하거나, 적어도 호환되는 프로토콜을 구현해야 합니다. Iterable은 __iter__()를 통해 반복을, Container는 __contains__()를 통해 in 연산자를, Sized는 __len__()을 통해 len() 메소드를 지원합니다.
  • Collection :
    이 ABC는 자체 메소드를 가지지 않지만 Iterable, Container, Sized를 쉽게 서브클래싱하기 위해서 파이썬 3.6에서 추가되었습니다.
  • Sequence, Mapping, Set :
    주요 불변(immutable) 컬렉션 타입으로, 각각 가변형(mutable) 서브클래스가 있습니다. MutableSequence와 MutableMapping, MutableSet에 대한 다이어그램은 다음과 같습니다.

MutableSequence Diagram
MutableMapping Diagram
MutableSet

  • MappingView :
    파이썬 3에서 items(), keys(), values() 메소드에서 반환된 객체는 각각 ItemsView, KeysView, ValuesView를 상속합니다. ItemsView와 ValuesView는 Set을 상속하므로 집합 연산에 사용되는 연산자들이 포함됩니다.
  • Iterator :
    Iterator는 Iterable을 상속합니다.
  • Callable, Hashable :
    이 두 ABC는 컬렉션과 밀접한 연관이 있는 것은 아니지만, collections.abc가 파이썬 표준 라이브러리 안에 ABC를 정의한 최초의 패키지입니다. Callable이나 Hashable의 서브클래스는 거의 없으며, 주로 호출하거나 해시할 수 있는지 안전하게 체크하기 위해 사용됩니다.
    callable detection은 isinstance(obj, Callable) 또는 callable(obj) 내장 함수로 가능합니다.
    hashable detection은 isinstance(obj, Hashable)로 가능합니다.

 

Defining and Using an ABC

프레임워크를 확장해야 하는 상황이라고 간주하고, ABC를 생성해보도록 하겠습니다. 여기서는 다음과 같은 상황이라고 가정합니다.

웹사이트나 모바일 앱에서 광고를 무작위 순으로 보여주어야 하지만, 광고 목록에 들어 있는 광고를 모두 보여주기 전까지는 같은 광고를 반복하면 안됨

이제 ADAM이라는 광고 관리 프레임워크를 만든다고 가정해보겠습니다. 이 프레임워크는 사용자가 제공한 무반복 무작위 picking 클래스를 지원해야 합니다. ADAM 사용자에게 '무반복 무작위 picking' 컴포넌트가 갖추어야 할 것을 명확히 알려주기 위해서 ABC를 정의합니다.

 

이렇게 정의되는 ABC의 이름은 실세계에 존재하는 것을 비유해서 정합니다. 여기서는 집합이 소진될 때까지 반복하지 않고 유한 집합에서 무작위로 항목을 선택하도록 설계된 기계인 빙고 케이지(Bingo Cage)와 로터리 블로어(Lottery Blower)라고 부르도록 하겠습니다.

 

빙고의 이탈리아식 이름과 숫자를 혼합하는 통의 이름을 본떠 ABC의 이름은 Tombola로 합니다.

 

Tombola ABC는 4개의 메소드를 가지고 있습니다. 그중 두 개의 추상(abstract) 메소드는 다음과 같습니다.

  • .load(...) : 항목을 컨테이너 안에 넣는다.
  • .pick() : 컨테이너 안에서 무작위로 항목 하나를 꺼내서 반환한다.

나머지 두 개의 구상(concrete) 메소드는 다음과 같습니다.

  • .loaded() : 컨테이너 안에 항목이 하나 이상 들어 있으면 True를 반환
  • .inspect() : 내용을 변경하지 않고 현재 컨테이너 안에 들어 있는 항목으로부터 만든 정렬된 튜플을 반환

Tombola ABC와 3개의 구상 구현은 다음과 같습니다.

위 UML에서 점선 화살표는 인터페이스 구현을 나타내며, 여기서는 TomboList가 Tombola의 가상 서브클래스임을 보여줍니다. 뒤에 나오지만 TomboList가 Tombola에 등록되기 때문입니다.

 

Tombola ABC의 정의는 다음과 같습니다.

# tombola.py
import abc

# ABC를 정의하려면 abc.ABC를 상속해야 한다.
class Tombola(abc.ABC):
    # 추상 메소드를 @abstractmethod 데코레이터로 표시한다.
    # 이 데코레이터에는 docstring만 들어 있는 경우가 종종 있다.
    @abc.abstractmethod
    def load(self, iterable):
        """Add items from an iterable."""

    # 골라낼 항목이 없는 경우 LookupError를 발생시키라고 doctstring을 통해 구현자에게 알려줌
    @abc.abstractmethod
    def pick(self):
        """Remove item at random, returning it.
        This method should raise `LookupError` when the instance is empty."""
    
    # ABC에도 구상 메소드가 들어갈 수 있음
    def loaded(self):
        """Return `True` if there's at least 1 item, `False` otherwise."""
        # ABC의 구상 메소드는 반드시 ABC에 정의된 인터페이스만 사용해야 함
        return bool(self.inspect())
    
    def inspect(self):
        """Return a sorted tuple with the items currently inside."""
        items = []
        # 구상 서브클래스가 항목을 저장하는 방법은 알 수 없지만, pick()을 계속 호출해서
        # Tombola 객체를 비움으로써 inspect()가 제공해야 하는 결과를 만들 수 있음
        while True:
            try:
                items.append(self.pick())
            except LookupError:
                break
        # load()를 호출해서 다시 넣어줌
        self.load(items)
        return tuple(items)

위 코드에서 inspect() 메소드는 다시 어리석은 코드처럼 보입니다. 하지만, 이 코드는 pick()과 load() 메소드를 이용해서 항목들을 모두 꺼낸 후 다시 넣어서 Tombola 내부를 조사할 수 있음을 보여줍니다. 이 코드의 핵심은 ABC 안에서 인터페이스에 정의된 다른 메소드만 이용하는 한 ABC에 구상 메소드를 제공하는 것도 가능하다는 것을 보여주는 것입니다. 내부 데이터 구조를 알고 있는 Tombola의 구상 서브클래스는 언제든지 더 똑똑한 방식으로 inspect()를 오버라이드할 수 있지만, 꼭 오버라이드할 필요는 없습니다.

 

위의 코드에서 loaded() 메소드는 단 한 줄이지만, 상당히 cost가 큰 연산을 수행합니다. 단지 bool() 연산을 적용하기 위해 inspect()를 호출해서 정렬된 튜플을 생성하기 때문입니다. 이 코드가 정상적으로 수행하긴 하지만, 뒤에서 보겠지만 구상 서브클래스에서 조금 더 잘 구현할 수 있습니다.

 

비효율적으로 구현한 inspect() 메소드는 self.pick()이 발생시키는 LookupError 예외를 잡아야 한다는 점에 주의해야 합니다. self.pick()이 LookupError를 발생시킨다는 것도 인터페이스의 일부분이지만, 파이썬에서는 문서(or docstring) 외에는 이 사실을 선언할 방법이 없습니다.

 

LookupError 예외를 선택한 이유는 파이썬 예외 계층구조에서 IndexError 및 KerError와 관련된 이 예외의 위치 때문입니다. IndexError와 KeyError는 Tombola 구상 서브클래스를 구현하기 위해 사용할 데이터 구조체에서 발생될 가능성이 높습니다. 따라서 Tombola 구상 서브클래스는 인터페이스에 따라 LookupError, IndexError, KeyError를 발생시킬 수 있습니다.

Part of the Exception class hierarchy

이렇게 정의한 Tombola ABC가 인터페이스 검사를 제대로 수행하는지 확인하기 위해, 다음과 같이 잘못된 구현을 이용하여 테스트해보도록 하겠습니다.

이제 처음으로 정의한 ABC가 완성되었고, 이를 사용하면서 클래스를 검증해보도록 하겠습니다.

Tombola ABC를 상속하기 전에, 먼저 ABC 코딩 규칙을 살펴보겠습니다.

 

ABC Syntax Details

ABC를 선언할 때는 abc.ABC나 다른 ABC를 상속하는 방법이 가장 좋습니다.

 

그러나 abc.ABC는 사실 abc.ABCMeta의 인스턴스이며, abc.ABCMeta는 "metaclass"라고 하는 특별한 클래스 팩토리입니다. 메타클래스에 관한 내용은 여기서 다루지는 않겠습니다. 일단 메타클래스와 ABC를 특별한 종류의 클래스라고 생각하면 됩니다. 예를 들어, 일반적인 클래스는 인터페이스를 준수하는지 서브클래스를 검사하지는 않기 때문에, 이는 ABC의 특별한 동작입니다.

 

@abstractmethod 외에도 abc 모듈은 @abstractclassmethod, @abstractstaticmethod, @abstractproperty 데코레이터를 정의합니다. 그러나 이 3개의 데코레이터는 파이썬 3.3 이후에는 사용 중단으로 안내되고 있습니다. 파이썬 3.3에서는 @abstractmethod 위에 데코레이터를 쌓아 올릴 수 있게 되어 이 3개의 메소드가 중복되기 때문입니다. 예를 들어 추상 클래스 메소드는 다음과 같이 선언합니다.

class MyABC(abc.ABC):
    @classmethod
    @abc.abstractmethod
    def an_abstract_classmethod(cls, ...):
        pass
일반적으로 누적된 함수 데코레이터의 순서는 중요합니다. @abstractmethod의 경우는 문서에서 다음과 같이 명확히 설명하고 있습니다.
-> abstractmethod()를 다른 메소드 descriptor와 함께 적용할 때는 이 데코레이터를 제일 안쪽에 위치시켜야 한다.
즉, @abstractmethod와 def문 사이에는 어떤 것도 올 수 없습니다.

 

Subclassing an ABC

위에서 Tombola ABC를 구현했으니, 이제 이 인터페이스를 만족시키는 구상 서브클래스를 구현해보도록 하겠습니다.

# bingo.py
import random

from tombola import Tombola

# BingoCage 클래스는 Tombola를 명시적으로 상속함
class BingoCage(Tombola):
    def __init__(self, items):
        # random.SystemRandom 클래스는 os.urandom() 함수를 기반으로 random API를 구현
        self._randomizer = random.SystemRandom()
        self._items = []
        # 초기화 작업을 load() 메소드에 위임
        self.load(items)
    
    def load(self, items):
        self._items.extend(items)
        # 평범한 random.shuffle() 대신 SystemRandom 객체의 shuffle() 메소드를 사용
        self._randomizer.shuffle(self._items)
    
    def pick(self):
        try:
            return self._items.pop()
        except IndexError:
            raise LookupError('pick from empty BingoCage')
    
    # Tombola 인터페이스를 만족시키기 위해 필요한 것은 아님
    def __call__(self):
        self.pick()

위의 코드처럼 구현된 BingoCage 클래스는 더 좋은 난수 생성기를 사용하고 있습니다. BingoCage는 필요한 추상 메소드 load()와 pick()을 구현하고, Tombola에서 loaded()를 상속하고, inspect()를 오버라이드하고, __call__() 메소드를 추가합니다.

 

BingoCage는 Tombola의 실행 부담이 큰 loaded()와 inspect() 메소드를 상속합니다.

이 두 메소드는 아래의 LotteryBlower 클래스에서 처럼 훨씬 더 빠른 한 줄의 코드로 오버라이드할 수 있습니다. Tombola에서 상속한 메소드들은 BingoCage에 최고의 성능을 발휘하지는 않지만, pick()과 load() 메소드를 제대로 구현하는 모든 Tombola 서브클래스에서 제대로 동작합니다.

 

아래의 LotteryBlower 클래스는 Tombola 인터페이스를 제대로 구현하지만, 꽤 다른 클래스를 보여줍니다. '공'을 섞고 마지막 공을 꺼내는 대신 LotteryBlower는 임의의 위치에 있는 공을 꺼냅니다.

# lotto.py
import random

from tombola import Tombola

class LotteryBlower(Tombola):
    def __init__(self, iterable):
        # 초기화 메소드는 어떠한 반복형도 받을 수 있음
        self._balls = list(iterable)
    
    def load(self, iterable):
        self._balls.extend(iterable)
    
    def pick(self):
        try:
            # random.randrange() 함수는 범위가 비어있을 때, ValueError를 발생시킴
            # Tombola 인터페이스를 따르기 위해 ValueError를 잡고 대신 LookupERror를 발생시킴
            position = random.randrang(len(self._balls))
        except ValueError:
            raise LookupError('pick from empty LottoBlower')
        # self._balls에서 무작위로 선택된 항목을 꺼냄
        return self._balls.pop(position)
    
    # inspect()를 호출하지 않도록 loaded() 메소드를 오버라이드함
    def loaded(self):
        return bool(self._balls)
    
    # inpect()를 오버라이드함
    def inspect(self):
        return tuple(self._balls);

위 코드에서 __init__() 메소드 안에 self._balls는 iterable이 아닌 list(iterable)을 저장합니다. 이렇게 하면 어떠한 반복형이라도 LotteryBlower 클래스를 초기화할 수 있으므로 더 flexible합니다. 이와 동시에 항목들을 리스트에 저장하므로 항목을 꺼낼 수 있도록 보장합니다. 그리고 늘 iterable 인수로 리스트를 만들지만 list(iterable)을 실행하면 인수의 사본이 생성됩니다. 이 클래스가 인수로 받은 반복형에서 항목을 제거하면 이 클래스의 사용자는 전달한 리스트가 변경된다는 사실을 모를 수 있다는 점을 고려하면, 괜찮은 방법입니다.

 

이제 구스 타이핑에서 가장 중요한 동적 기능인 register() 메소드를 이용해서 가상 서브클래스를 선언하는 방법에 대해 알아보겠습니다.

 

A Virtual Subclass of an ABC

구스 타이핑의 본질적인 기능은 어떤 클래스가 ABC를 상속하지 않더라도 그 클래스의 가상 서브클래스로 등록할 수 있다는 것입니다. 이렇게 함으로써 이 클래스가 ABC에 정의된 인터페이스를 충실히 구현한다고 약속하는 것입니다. 그리고 파이썬은 이를 검사하지 않고 믿고 넘어갑니다. 하지만 거짓말을 하게 되면 런타임 예외가 발생합니다.

 

ABC의 register() 메소드를 호출하면 클래스가 등록됩니다. 등록된 클래스는 ABC의 가상 서브클래스가 되어 issubclass()와 isinstance() 함수에 의해 인식되지만, ABC에서 상속한 메소드나 속성은 전혀 없습니다.

가상 서브클래스는 자신의 ABC에서 상속한 것이 아니며, 심지어 객체를 생성할 때도 ABC 인터페이스에 따르는지 검사받지 않습니다. 런타임 에러를 피하기 위해 필요한 메소드를 실제로 모두 구현하는 것은 전적으로 서브클래스에 달려 있습니다.

일반적으로 register() 메소드는 평범한 함수처럼 호출되지만, 데코레이터로 사용할 수도 있습니다. 아래 예제 코드에서는 데코레이터 구문을 이용해서 다음 그림의 Tombola의 가상 서브클래스인 TomboList를 구현합니다.

# tombolist.py
from random import randrange

from tombola import Tombola

# TomboList를 Tombola의 가상 서브클래스로 등록
@Tombola.register
class TomboList(list): # TomboList는 list를 상속
    def pick(self):
        # TomboList는 list에서 __bool__을 상속. 리스트가 비어 있지 않으면 True 반환
        if self:
            position = randrange(len(self))
            # pick() 메소드는 무작위 인덱스를 전달해서 list에서 상속한 self.pop()을 호출
            return self.pop(position)
        else:
            raise LookupError('pop from empty TomboList')
    
    # list.extend() 메소드를 TomboList.load에 할당
    load = list.extend

    def loaded(self):
        # loaded() 메소드를 bool() 함수에 위임
        return bool(self)
    
    def inspect(self):
        return tuple(self)

# 파이썬 3.3 및 이전 버전에서는 register() 클래스 데코레이터로 사용할 수 없고,
# 아래와 같이 표준 호출 구문을 사용해야 한다
# Tombola.register(TomboList)

 

TomboList를 Tomboa 클래스의 가상 서브클래스로 등록했기 때문에 이제 issubclass()와 isinstance() 함수는 TomboList가 Tombola의 서브클래스인 것처럼 판단합니다.

그러나, 상속은 메소드 결정 순서(Method Resolution Order; MRO)를 담은 __mro__라는 특별 클래스 속성에 의해 운영됩니다. 이 속성은 기본적으로 파이썬이 메소드를 검색할 순서대로 자신과 자신의 슈퍼클래스들을 나열합니다. TomboList의 __mro__를 조사해보면 이 클래스의 진짜 슈퍼클래스인 list와 object만 들어있습니다.

Tombola가 TomboList.__mro__에 들어 있지 않으므로 TomboList는 Tombola에서 아무런 메소드도 상속하지 않습니다.

 

Usage of register in Practice

TomboList 코드에서는 @Tombola.register를 클래스 데코레이터로 사용했습니다. 예제 마지막 부분의 주석에서 설명한 것처럼, 파이썬 3.3 이전에는 이런 형태로 register()를 사용할 수 ㅇ벗었고, 클래스를 정의한 후에는 평범한 함수처럼 호출해야 했습니다.

 

그렇지만 이제는 register()를 데코레이터로 사용할 수 있음에도 불구하고, 다른 곳에서 정의된 클래스를 등록하기 위해 함수 형태로 사용하는 경우가 더 많습니다. 예를 들어, collections.abc에 대한 소스 코드(link)에서는 tuple, str, range, memoryview 내장 자료형이 다음과 같이 Sequence의 가상 서브클래스로 등록되어 있습니다.

Sequence.register(tuple)
Sequence.register(str)
Sequence.register(range)
Sequence.register(memoryview)

그 외의 내장 자료형이 _collections_abc.py (link)에 있는 ABC에 등록되었습니다. 모듈이 임포트될 때만 등록되는데, ABC에 접근하려면 어쨋든 임포트해야 하므로 아무런 문제가 되지 않습니다. 예를 들어, isinstance(my_dict, MutableMapping) 코드를 실행하기 위해서는 collections.abc로부터 MutableMapping을 임포트를 해주어야 합니다.

 

ABC를 서브클래싱하거나 ABC에 등록하는 것은 클래스가 issubclass 검사를 통과하기 위한 명시적인 방법입니다. 또한 isinstance 검사는 issubclass에 의존합니다. 하지만 몇몇 ABC는 structural typing 또한 지원하는데, 아래에서 살펴보도록 하겠습니다.

 

Structural typing with ABCs

클래스를 등록하지 않고도 ABC의 가상 서브클래스로 인식시킬 수 있습니다. 다음 예제 코드를 살펴보겠습니다.

issubclass() 함수는 Struggle을 abc.Sized의 서브클래스라고 간주합니다. 그리고 isinstance()도 마찬가지 입니다. 이는 abc.Sized가 __subclasshook__()이라는 특별 클래스 메소드를 구현하기 때문입니다. 다음 예제 코드를 살펴보겠습니다.

이 코드는 Lib/_collections_abc.py 소스 코드에서의 Sized()의 정의 부분입니다 (link).

class Sized(metaclass=ABCMeta):

    __slots__ = ()

    @abstractmethod
    def __len__(self):
        return 0

    @classmethod
    def __subclasshook__(cls, C):
        if cls is Sized:
            return _check_methods(C, "__len__")
        return NotImplemented

line 12를 보면 _check_methods를 호출해서 __len__ 이라는 속성이 있는지 검사하는데, 이 함수 내에서 C.__mro__에 나열된 클래스 중 __dict__ 속성에 __len__ 이라는 속성이 있다면 True를 반환해서 C가 Sized의 가상 서브클래스임을 알려주고, 그렇지 않다면 NotImplemented를 반환합니다.

 

이것이 __subclasshook__()이 ABC가 structural typing을 지원하도록 하는 방법입니다. ABC를 이용해서 공식적으로 인터페이스를 정의할 수 있고, 어디에서든 isinstance() 검사를 할 수 있으며, 단지 어떤 메소드를 구현하기만 하면 전혀 상관없는 클래스들이 함께 어울리도록 만들 수 있습니다. 물론 이것은 __subclasshook__()을 제공하는 ABC에만 적용됩니다.

 

표준이 아닌 자체 정의한 ABC에서 __subclasshook__()을 구현하는 것이 좋을까요? 아마도 아니라고 생각됩니다. 파이썬 소스 코드에서 __subclasshook__()을 구현하는 클래스들은 모두 특별 메소드 하나만 선언한 Sized 같은 ABC이며, 그 클래스들은 그러한 특별 메소드명만 검사할 뿐입니다. 특별한 상태에 있으니 __len__이라는 이름을 가진 메소드는 우리가 기다하는 일을 할 것이라고 확신할 수 있습니다. 그렇지만 특별 메소드와 핵심 ABC 영역에서도 그런 가정을 하는 것은 위험합니다. 예를 들어 매핑은 __len__(), __getitem__(), __iter__() 메소드를 구현하지만, 정수 오프셋으로 항목을 검색할 수 없고 항목 순서를 보장하지 않으므로 Sequence의 서브타입이라고 간주하지 않습니다. 이러한 이유로 abc.Sequence 클래스는 __subclasshook__을 구현하지 않습니다.

 

우리가 작성하는 ABC의 경우에는 __subclasshook__()을 훨씬 더 믿을 수 없습니다. load(), pick(), inspect(), loaded()를 구현하거나 상속하는 Spam이라는 이름의 클래스가 Tombola로 작동할 것이라고 믿지 않는 것과 비슷합니다. Spam을 Tombola에서 상속하거나, 적어도 Tombola.register(Spam)으로 등록함으로써 프로그래머가 그 사실을 약속할 수 있게 해준다면 더 나을 것입니다. 물론 작성한 __subclasshook__() 메소드가 메소드 시그니처 및 다른 기능을 검사할 수도 있겠지만, 그럴 가치가 있지는 않다고 생각됩니다.

 


Static Protocols

이번에는 간단한 두 예제를 통해서 static protocol에 대해 알아보겠습니다.

 

The typed double function

정적 타입 언어에 더 익숙한 프로그래머에게 파이썬을 소개할 때, 자주 사용되는 예제 중의 하나는 바로 간단한 double 함수입니다.

static protocol이 도입되기 전에, 타입 힌트로 double을 추가하는 것은 실용적인 방법이 아니었습니다.

 

파이썬에서 타입 힌트의 초기 구현은 nominal type system이었습니다. 어노테이션에서 타입의 이름은 실제 인수의 타입과 매치되거나 이들의 수퍼클래스 중 하나의 이름과 매치되어야 합니다. 필요한 연산들을 지원하여 프로토콜을 구현하는 모든 타입의 이름을 지정할 수 없기 때문에 파이썬 3.8 이전에는 덕 타이핑은 타입 힌트로 묘사될 수 없었습니다.

 

이제는 typing.Protocol을 통해 Mypy에게 double 함수가 x * 2를 지원하는 인수 x를 받는다고 알려줄 수 있습니다.

그 방법은 다음과 같습니다.

from typing import TypeVar, Protocol

# T를 __mul__의 signature로 사용
T = TypeVar('T')

class Repeatable(Protocol):
    # __mul__은 Repeatable 프로토콜의 본질.
    # self 파라미터는 보통 어노테이션되지 않으며, 보통 클래스 타입이라고 가정
    # 여기서 리턴 타입이 self와 같다라는 것을 보장하기 위해서 T를 사용하며,
    # 이 프로토콜에서 repeat_count는 int로 제한됨
    def __mul__(self: T, repaet_count: int) -> T:
        ...

# RT 타입 변수는 Repeatable 프로토콜에 바운딩됨
# 이 type checker는 실제 타입이 Repeatable을 구현해야 한다는 것을 요구함    
RT = TypeVar('RT', bound=Repeatable)

# 이제 type checker는 x 파라미터가 정수로 곱할 수 있는 객체라는 것을 검증하고
# x와 같은 타입의 값을 반환함
def double(x: RT) -> RT:
    return x * 2

위 예제는 PEP 544의 제목이 왜 "Protocols: Structural subtyping(static duck typing)"으로 지어졌는지 보여줍니다. 

 

Runtime checkable static protocols

포스팅 처음에 살펴본 Typing Map에서, typing.Protocol은 static checking area에 나타납니다. 그러나 typing.Protocol 서브클래스를 정의할 때, @runtime_checkable 데코레이터를 사용할 수 있으며, 이는 프로토콜이 런타임에 isinstance/issubclass 검사를 지원하도록 합니다. 이는 typing.Protocol이 ABC이기 때문에 동작하며, 그러므로 __subclasshook__ 메소드도 지원합니다.

 

파이썬 3.9에서는 typing 모듈이 런타임에 검사가능한 7개의 ready-to-use 프로토콜을 포함합니다. 그 중 2개를 typing 문서에서 인용했습니다.

이 프로토콜은 수치 타입의 "convertibility"을 검사하도록 디자인되었습니다. 만약 o라는 객체가 __complex__()를 구현한다면, complex(o)를 호출하여 complex를 얻을 수 있습니다. __complex__() 스페셜 메소드는 complex() 내장 함수를 지원하기 때문입니다.

 

다음 코드는 typing.SupportsComplex 프로토콜의 소스 코드(link)입니다.

@runtime_checkable
class SupportsComplex(Protocol):
    """An ABC with one abstract method __complex__."""
    __slots__ = ()

    @abstractmethod
    def __complex__(self) -> complex:
        pass

여기서 핵심은 __complex__() 추상 메소드입니다. static type checking 동안, 객체가 오직 self만을 인수로 받고 complex를 리턴하는 __complex__() 메소드가 구현되어 있다면 이 객체는 SupportsComplex 프로토콜과 일치하는 것으로 간주됩니다.

 

SupportsComplex에 적용된 @runtime_checkable 클래스 데코레이터 덕분에, 이 프로토콜은 isinstance 검사에도 사용될 수 있습니다.

마지막 문장에서, 만약 객체 c가 complex 또는 SupportsComplex인지 아닌지 테스트하기를 원한다면, 다음과 같이 isinstance의 두 번째 인수에 타입의 튜플을 전달하면 됩니다.

 

다른 방법으로 number 모듈에 정의된 Complex ABC를 사용할 수도 있습니다. 내장 complex 타입과 Numpy의 complex64와 complex128 타입은 모두 numbers.Complex의 가상 서브클래스로 등록되어 있으므로, 다음과 같은 문장들이 잘 동작합니다.

 

Supporting a Static Protocol

다음과 같이 정의된 Vector2d 클래스를 살펴보겠습니다.

from array import array
import math

class Vector2d:
    typecode = 'd'

    def __init__(self, x, y):
        self.__x = float(x)
        self.__y = float(y)

    @property
    def x(self):
        return self.__x

    @property
    def y(self):
        return self.__y

    def __iter__(self):
        return (i for i in (self.x, self.y))

    def __repr__(self):
        class_name = type(self).__name__
        return '{}({!r}, {!r})'.format(class_name, *self)

    def __str__(self):
        return str(tuple(self))

    def __bytes__(self):
        return (bytes([ord(self.typecode)]) +
                bytes(array(self.typecode, self)))

    def __eq__(self, other):
        return tuple(self) == tuple(other)

    def __hash__(self):
        return hash((self.x, self.y))

    def __abs__(self):
        return math.hypot(self.x, self.y)

    def __bool__(self):
        return bool(abs(self))

    def angle(self):
        return math.atan2(self.y, self.x)

    def __format__(self, fmt_spec=''):
        if fmt_spec.endswith('p'):
            fmt_spec = fmt_spec[:-1]
            coords = (abs(self), self.angle())
            outer_fmt = '<{}, {}>'
        else:
            coords = self
            outer_fmt = '({}, {})'
        components = (format(c, fmt_spec) for c in coords)
        return outer_fmt.format(*components)

    @classmethod
    def frombytes(cls, octets):
        typecode = chr(octets[0])
        memv = memoryview(octets[1:]).cast(typecode)
        return cls(*memv)

complex와 Vector2d 인스턴스는 둘다 floats의 쌍으로 구성되어 있습니다. 따라서 Vector2d에서 complex로 변환하는 것을 지원할 수도 있습니다.

 

아래 코드는 __complex__() 메소드의 구현이며, 위의 Vector2d를 개선합니다. 완벽을 위해서 complex로부터 Vector2d를 생성하는 fromcomplex 클래스 메소드도 지원할 수 있습니다.

    def __complex__(self):
        return complex(self.x, self.y)
    
    @classmethod
    def fromcomplex(cls, datum):
        # datum이 .real과 .imag 속성을 가지고 있다고 가정
        return cls(datum.real, datum.imag)

위의 메소드들이 추가된 Vector2d 클래스는 다음과 같이 사용할 수 있습니다.

런타임 타입 체크가 잘 동작하지만, 더 좋은 정적 커버리지와 Mypy의 에러 리포팅을 위해서 __abs__(), __complex__(), fromcomplex() 메소드가 다음과 같이 타입 힌트를 얻을 수 있습니다.

    def __abs__(self) -> float: # float 리턴 어노테이션
        # 타입 힌트 덕분에 메소드 바디에서 체크할 필요가 없음
        return math.hypot(self.x, self.y)

    def __complex__(self) -> complex: # 어노테이션이 없더라도 Mypy는 complex 타입으로 추론 가능
        # 어노테이션은 경고(warning) 출력을 막아줌
        return complex(self.x, self.y)
    
    # datum이 convertible하다는 것을 보장
    @classmethod
    def fromcomplex(cls, datum: SupportsComplex) -> Vector2d:
        # SupportsComplex 타입은 .real과 .imag 속성을 선언하지 않기 때문에
        # 명시적인 변환이 필요
        c = complex(datum)
        return cls(c.real, c.imag)

fromcomplex의 리턴 타입은 from __future__ import annotations가 모듈의 상단에 있어야지 Vector2d가 될 수 있습니다. 이 import는 함수 정의가 평가될 때, import time에 평가하는 것 없이 타입 힌트를 문자열로 저장하게 합니다. __future__의 annotations import가 없으면 Vector2d는 해당 지점(클래스가 완전히 정의되지 않음)에서 유효하지 않은 참조입니다. 이 __future__ import는 PEP 563에서 도입되었으면 파이썬 3.7에서 구현되었습니다.

 

Designing a Static Protocol

구스 타이핑에 대해 알아보면서, Tombola ABC를 구현했습니다. 아래에서는 static protocol을 사용하여 유사한 인터페이스를 정의해보도록 하겠습니다.

 

Tombola ABC는 두 개의 메소드(pick, load)를 지정합니다. 우리는 이 두 메소드를 가진 static protocol을 정의할 수 있지만, 저자는 Go 커뮤니티를 통해 단일 메소드를 가진 protocol이 정적 덕 타이핑을 더 유용하고 유연하게 만든다는 것을 배웠다고 합니다. Go의 표준 라이브러리는 Reader와 같은 여러 인터페이스를 가지고 있는데, 이 Reader는 단지 read 메소드만을 요구하는 I/O를 위한 인터페이스입니다. 이후에 더 완전한 프로콜이 필요하다고 느껴지면, 둘 이상의 프로토콜을 결합하여 새로운 것을 정의할 수 있습니다.

 

무작위로 아이템을 뽑는 컨테이너를 사용하는 것이 컨테이너를 다시 읽어들이는 것을 필요로 하거나 필요로 하지 않을 수 있습니다. 하지만, 실제로 뽑는 동작을 수행하는 메소드는 확실히 필요하므로, 이는 최소한의 RandomPicker 메소드를 통해 선택할 메소드일 수 있습니다. 아래 코드는 이 프로토콜을 정의한 것입니다.

# randompick.py
from typing import Protocol, runtime_checkable, Any

@runtime_checkable
class RandomPicker(Protocol):
    def pick(self) -> Any:
        ...

이를 사용한 테스트 코드는 다음과 같습니다.

import random
from typing import Any, Iterable, TYPE_CHECKING

# 클래스를 구현할 때, static protocol을 import할 필요는 없다
# 여기서는 test_isinstance에서 사용하기 위해 import함
from randompick import RandomPicker

class SimplePicker:
    def __init__(self, items: Iterable) -> None:
        self._items = list(items)
        random.shuffle(self._items)
    
    # 기본 리턴 타입은 Any. 따라서 이 어노테이션은 반드시 필요로 하지 않음
    # 여기서는 RandomPicker 프로토콜을 구현한다는 것을 명확하게 보여주기 위해 사용
    def pick(self) -> Any:
        return self._items.pop()
    
def test_isinstance() -> None: # 테스트를 위해 리턴 타입 어노테이션 필요
    # 변수 popper가 SimplePicker와 일치한다는 것을 mypy에게 알려주기 위해 타입 힌트 추가
    popper: RandomPicker = SimplePicker([1])
    # SimplePicker가 RandomPicker의 인스턴스인지 체크
    # @runtime_checkable 데코레이터와 SimplePicker가 pick 메소드를 가지고 있기 때문에 동작함
    assert isinstance(popper, RandomPicker)

# 이 테스트는 SimplePicker로부터 pick 메소드를 호출하고, 반환된 아이템을 검증한다
def test_item_type() -> None:
    items = [1, 2]
    popper = SimplePicker(items)
    item = popper.pick()
    assert item in items
    if TYPE_CHECKING:
        reveal_type(item) # 이 line은 pypy의 output에서 note를 생성
    assert isinstance(item, int)

여기서 reveal_type은 Mypy로부터 인식되는 Magic 함수입니다. typing.TYPE_CHECKING는 정적 type checker에는 True로 보이지만, 런타임에서는 False입니다. 위 코드의 두 테스트는 모두 통과합니다. Mypy는 이 코드에서 어떠한 에러도 발생시키지 않으며, reveal_type의 결과를 보여줍니다.

 

Extending a protocol

Go 언어에서 많이 사용되는 인터페이스들은 단일 메소드를 가지고 있습니다. 만약 사용하다가 더 많은 메소드가 있는 프로토콜이 필요하다면, 원래 프로토콜에 메소드를 추가하는 대신 새 프로토콜을 파생하는 것이 좋습니다.

아래 코드에서 보여주듯이 파이썬에서 static protocol을 확장하는 데에는 몇 가지 주의 사항이 있습니다.

# randompickload.py
from typing import Protocol, runtime_checkable
from randompick import RandomPicker

# 만약 파생 프로토콜이 runtime checkable하길 원한다면,
# 이 데코레이터를 다시 적용해야함
@runtime_checkable
class LoadableRandomPicker(RandomPicker, Protocol): # 모든 프로토콜은 베이스 클래스 중 하나로
                                                    # 명시적으로 typing.Protocol을 가져야 한다.
    # 파생 클래스에서 필요한 메소드만 선언하면 된다
    def load(self, Iterable) -> None:
        ...

 

Protocol naming conventions

Contributing to typeshed 페이지(link)는 static protocol을 위해 다음과 같은 네이밍 컨벤션을 권장합니다.

  • Use plain names for protocols that represent a clear concept (e.g. Iterator, Container).
  • Use SupportsX for protocols that provide callable methods (e.g. SupportsInt, SupportsRead, SupportsReadSeek).
  • Use HasX for protocols that have readable and/or writable attributes or getter/setter methods (e.g. HasItems, HasFileno).

Go 표준 라이브러리 또한 유용한 네이밍 컨벤션을 가지고 있습니다. 만약 메소드의 이름이 동사라면 "-er"이나 "-or"을 붙여서 명사로 만듭니다. (ex, Formatter, Animator, Scanner). (참고: link)

 


The Numbers ABCs and Numeric Protocols

numbers 패키지는 소위 'numeric tower'라고 하는 것을 정의합니다(PEP 3141). 이 tower는 ABC들이 선형 계층구조를 이루고 있습니다. 다음과 같이 Number이 최상위 ABC이며, 그 밑에 Complex, 계속 내려가서 Integral까지 내려갑니다.

  • Number
  • Complex
  • Real
  • Rational
  • Integral

따라서 정수형인지 검사해야 하는 경우, isinstance(x, numbers.Integral)을 이용해서 bool형(int형의 서브클래스) 또는 numbers ABCs의 가상 서브클래스로 등록한 외부 라이브러리에서 제공되는 다른 정수형 타입을 받을 수 있습니다. 그리고 언제든 클래스를 numbers.Integral의 가상 서브클래스에 등록하면, 해당 클래스의 객ㅈ체가 isinstance(x, numbers.Integral) 검사를 통과할 수 있습니다. 예를 들어, Numpy에는 21가지 정수형 타입이 있으면 numbers.Real로 등록된 여러 실수형 타입도 있습니다. numbers.Complex에 등록된 복소수 타입도 있습니다.

 

아쉽지만, numeric tower는 static type checking을 위해 디자인되지 않았습니다. 루트 ABC인 numbers.Number는 어떠한 메소드도 없고, 따라서 만약 x: Number로 선언한다면 type checker는 x에 대해 산술 연산을 할 수 없고 어떠한 메소드도 호출할 수 없습니다.

 

솔직히 말해서 다양한 타입의 부동소수점 또는 다양한 비트 너비를 가진 정수형을 다룰 수 있는 type safe 함수를 구현할 필요가 없는 경우가 많습니다. 이런 것이 필요할 때 가능한 해결 방법은 typing 모듈에서 제공하는 numeric 프로토콜을 사용하는 것입니다.

 

불행히도, 런타임에 numeric 프로토콜은 우리를 실망시킬 수 있습니다. 파이썬의 complex 타입은 __float__를 구현하지만, 이 메소드는 오직 "can't convert complex to float"라는 명시적인 메세지와 함께 TypeError를 발생시키기 위해서만 존재합니다. __int__ 또한 같은 이유로 구현합니다. 이러한 메소드가 있으면 isinstance는 잘못된 결과를 반환하게 만듭니다. 그러나 Numpy의 complex 타입은 정상적으로 동작하는 __float__()와 __int__() 메소드를 구현하고, 오직 이들이 처음에 사용될 때만 경고를 발생시킵니다.

 

반대의 문제도 발생합니다. 내장 타입 complex, float, int, 그리고 numpy.float16, numpy.unit8 또한 __complex__ 메소드를 가지고 있지 않습니다. 따라서 isinstance(x, SupportsComplex)는 False를 반환합니다. np.complex64와 같은 Numpy complex 타입은 내장 타입 complex로 변환하기 위한 __complex__() 메소드를 구현합니다.

 

그러나, 실제로 complex() 내장 생성자는 이들 타입들의 인스턴스를 어떠한 에러나 경고없이 처리합니다.

이는 SupportsComplex에 대한 isinstance는 complex로의 변환이 실패할 것이라고 말하지만, 실제로는 성공한다는 것을 보여줍니다. typing-sig mailing 리스트에서 파이썬 창시자 귀도는 내장된 complex가 하나의 인수를 받고, 이것이 변환이 성공하는 이유라고 지적했습니다.

 

반면에, Mypy는 다음과 같이 정의된 to_complex() 함수에 대한 호출에서 6가지 타입을 모두 인수로 받습니다.

def to_complex(n: SupportsComplex) -> complex:
    return complex(n)

 

Numpy에는 타입 힌트가 없으므로, Numpy의 숫자 타입은 모두 Any입니다. 반면에 Mypy는 비록 typeshed에서 오직 내장된 complex 클래스만 __complex__ 메소드가 있음에도, 내장된 int와 float 타입이 complex로 변환될 수 있다는 것을 어떻게든 인식합니다.

 

정리하면 다음과 같습니다.

  • numbers ABCs는 구스 타이핑에 적합하지만, static typing에는 부적합하다
  • SupportsComplex나 SupportsFloat와 같은 numeric static protocols는 static typing에 잘 동작한다. 하지만 complex number가 포함될 때 구스 타이핑은 신뢰할만하지 않다

 

 


이번 포스팅의 내용은 저에게 생각보다 많이 생소하고 어려운 내용이었습니다... ㅠ 

사실 교재에서 이것에 대해 설명하는 목적을 제대로 이해하지 못했고, 이 프로토콜들이 정확히 어떠한 상황에서 사용되는지 잘 알지 못하기 때문에 더욱 이해가 어려웠던 것 같습니다. 만약 다른 프로젝트에서 이들을 사용하는 것들을 보게 된다면 조금 더 이해가 될 것 같습니다만.. 현재 제 수준으로는 이런 것이 있다라는 것만 알고 넘어가야 할 것 같습니다. 많이 어려운 내용이어서 내용이 어색한 부분이 많을 것 같습니다. 언제나 지적은 환영입니다.. !

'프로그래밍 > Python' 카테고리의 다른 글

[Python] Iterables, Iterators, and Generators  (0) 2022.03.25
[Python] 연산자 오버로딩  (0) 2022.03.24
[Python] Special Methods for Sequences  (0) 2022.03.21
[Python] Data Class Builders  (0) 2022.03.20
[Python] A Pythonic Object  (0) 2022.03.19

댓글