Computer Engineering/Fluent Python 정리

Fluent Python Chapter 11. 인터페이스: 프로토콜에서 ABC까지

jordan.bae 2022. 2. 7. 20:23
책 정리를 시작한 이유

Chapter1의 Introduction 부분에서 이야기 한 것처럼 지난 5년간 다양한 언어나 프레임워크 및 프로그램을 공부하고 이용하여 소프트웨어를 개발했는데 이것저것 하다 보니 자주 사용하는 언어임에도 불구하고 파이썬을 잘 활용하고 있느냐에 대한 답변을 자신 있게 하기 어렵다고 느껴서 Fluent Python이라는 책을 공부하며 정리하고 있습니다. 올해에는 새로운 기술들도 좋지만 기존에 활용하던 언어나 프레임워크 그리고 소프트웨어를 더 잘 사용할 수 있도록 깊게 공부할 수 있는 한 해를 만들고 싶은 소망을 가지고 있습니다. 21장까지 정리를 성공하고 맛있는 걸 먹으면서 스스로 축하할 날이 어서 왔으면 좋겠네요! 혹시, 제가 정리한 글이 괜찮으시면 구독 하시면 다음편이 나오면 바로 알림을 받으실 수 있습니다.
 

지난 chapter 정리한 포스팅

Fluent Python Chapter 1. 파이썬 데이터 모델 (Feat. 일관성 있는 언어)

Fluent Python Chapter 2-1. Sequence (Sequence의 분류, listcomp, generator, tuple, slicing

Fluent Python Chapter 2-2. Sequence (bisect, deque, memoryview)

Fluent Python Chapter 3. Dictionary (feat. hashtable)

Fluent Python Chapter 4. 텍스트와 바이트 (feat. 깨지지마라..)

Fluent Python Chapter 5. 일급 함수

Fluent Python Chapter 6. 일급 함수 디자인 패턴

Fluent Python Chapter 7. 함수 데커레이터와 클로저 (feat. 메타프로그래밍)

Fluent Python Chapter 8. 객체 잠조, 가변성, 재활용

Fluent Python Chapter 9. 파이썬스러운 객체

Fluent Python Chapter 10. 시퀀스 해킹, 해시, 슬라이스

 

 

Chapter11 - Introduction

11장은 객체지향에서 빠질 수 없는 인터페이스에 대한 이야기입니다. 파이썬에서 인터페이스를 어떻게 구현하는지에 대한 이야기를 설명하는 Chapter입니다. 오래전 부터 사용해오던 조금은 느슨한 프로토콜(덕 타이핑)과 비교적 근래에 도입된 ABC(Abstract Base Class)를 순서대로 다루면서 각자의 장점과 왜 ABC가 도입됐는지 그리고 어떻게 활용해야 하는지에 대해서 소개합니다.

 

 

덕타이핑과 프로토콜

Chapter1에서 부터 저자는 프로토콜을 파이썬과 같은 동적 자료형을 제공하는 언어에서 다형성을 제공하는 비공식 인터페이스라고 정의 했다. (예를 들면, list객체는 sequence 프로토콜을 지원하므로 sequence 객체라고 볼 수 있다. (이 부분은 개인적인 의견입니다.))프로토콜의 기원은 스몰토크에서 어떤 역할을 완수하기 위한 메서드 집합으로서의 인터페이스를 프로토콜이라고 불렀고, 이 정의는 여러 커뮤니티가 퍼져나갔다. 프로토콜은 강제 하지 않기 때문에 비공식적이고, 문서와 관례에 따라 정의된다.

 

예를 들면 bytes같은 객체, bytes 프로토콜, bytes 인터페이스는 파이썬에서 동의어 이다.

 

 

파이썬의 데이터 모델 철학

파이썬의 데이터 모델은 가능한 한 많이 핵심 프로토콜과 협업하겠다는 철학을 가지고 있다. 예를 들면 아래 코드에서 보는 것 처럼__getitem__() 매직 메서드 하나만 구현했음에도 여러 프로토콜을 사용할 수 있다. 

class Foo:    
	def __getitem__(self, position):        
    	return range(0,30,10)[position]
        
f = Foo()

for i in f: print(i)
0
10
20

20 in f
True

이 부분과 관련해서는 예전에 작성했던 글(파이썬은 얼마나 똑똑한가?)이 있는데 한 번 읽어보셔도 좋을 것 같습니다.

 

예전에 작성했던 글에서 가져온 일부 부분

파이썬 데이터 모델은 가능한 한 많이 핵심 프로토콜과 협업하겠다는 철학을 가지고 있습니다. 
(정말 똑똑하고 부지런한 녀석입니다. 과장을 많이 하면 하나를 알려주면 나머지 알아서 한다는 말과 비슷합니다. 가끔 여러분 곁에 훌륭한 동료들이 대충 말해도 모두 처리하는 것처럼 말이죠! 저도 그런 훌륭한 동료가 되고 싶네요!)
파이썬은 __iter__()와 __contains__() 메서드가 구현되어 있지 않았지만 __getitem__() 메서드를 호출해서 객체를 반복하고 in 연산자를 사용할 수 있게 해 줍니다.
 
저는 이러한 파이썬의 데이터 모델에 대한 철학이 깐깐하게 객체의 인터페이스를 정의하고 너 a,b,c 함수 모두 구현했어?라는 체크 없이 프로그래밍을 해도 많은 부분을 보완해준다고 생각합니다.
즉, 여기서 Foo class는 abc.Sequence를 상속받지 않았지만 abc.Sequence가 지원하는 일부분 protocol을 수행할 수 있습니다,

 

 

런타임에 프로토콜을 구현하는 멍키패칭

멍키패칭은 런타임에 프로그램을 바꾸는 것을 의미한다. 멍키패칭의 어원과 관련하여 잘 정리된 블로그 글이 있어 첨부해봅니다.

https://kangmin517.tistory.com/55

 

몽키패치(Monkey patch)란?

*몽키패치(Monkey Patch) : 몽키패치란 일반적으로 런타임 중인 프로그램 메모리의 소스 내용을 직접 바꾸는 것이다. 몽키패치의 어원을 찾아보았는데 이게 상당히 재밌다. 원래 "게릴라 패치" 였는

kangmin517.tistory.com

말 그래로 runtime에 protocol을 구현할 수 있다는 것을 의미합니다. 코드로 보면 이해가 쉽습니다.

import collections
from random import shuffle


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]
        

deck = FrenchDeck()
shuffle(deck)

....
TypeError: 'FrenchDeck' object does not support item assignment


# runtime에 protocol 구현
def set_card(deck, position, card):
    deck._cards[position] = card

FrenchDeck.__setitem__ = set_card
shuffle(deck)
deck[:5]
[Card(rank='9', suit='diamonds'),
 Card(rank='2', suit='clubs'),
 Card(rank='6', suit='clubs'),
 Card(rank='8', suit='clubs'),
 Card(rank='5', suit='hearts')]

위의 코드에서는 멍키 패칭의 동작외에도 프로토콜이 동적이라는 것을 잘 보여주는 부분이 있다. random.shuffle 함수는 자신이 받는 자료형에 대해서는 신경 쓰지 않고, 받은 객체가 일부 시퀀스 프로토콜(로직에 필요한)을 구현하고 있는지만 체크한다.

위에서 확인한 것 처럼 덕타이핑은 어떤 프로토콜을 구현하는 한 자료형에 상관없이 객체를 작동시킨다.

 

구스 타이핑

해당 chapters는 알렉스 마르텔리가 작성한 장이다. 알렉스 마르텔리는 덕 타이핑이라는 용어를 퍼뜨리는데 일조한 유명한 엔지니어다. 위에서 살펴본 것처럼 덕 타이핑은 타입을 체크하지 않고 해당 프로토콜을 지원하지만 동적으로 체크한다. 즉, 파이썬에는 자료형 검사를 위한 isinstance() 함수 사용을 회피합니다. 하지만, 덕 타이핑의 유용하지 않은 경우도 있다.

 

아래 코드를 보자.

class Artist:
	def draw(self) ... # 그림 그리기
    
class Gunslinger:
	def draw(self): ... # 총을 뽑는 행위
    
class Lottery:
	def draw(self): ...# 복권 추첨

위에 코드를 보면 느끼는 것은 같은 함수의 이름을 가지고 있더라도 항상 같은 의미로 쓰이지 않는다는 것을 알 수 있다.

즉, 동일한 이름의 메서드를 호출한다고 해서 의미가 비슷하다고 생각할 수 없다. 예를 들면 draw 프로토콜을 지원하는 프로토콜인지 알고 draw 함수를 호출했는데 상관없는 메서드라서 이상하게 동작될 수 있다는 것이다. 책에서는 새들의 행동을 분기학적(생물학적)으로 접근하는것과 구스타이핑을 비유했는데 개인적으로는 이해가 어려웠다...

 

구스 타이핑은 이런 케이스를 보완하기 위해서 부분적으로 타입 체크를 허용하는 방법론이다.

cls가 추상 베이스 클래스인 경우, 즉 cls의 메타 클래스가 ab.ABCMeta인 경우에는 isinstance(obj, cls)를 써도 좋다는 것을 의미한다. 여기서 유의할 부분은 구스 타이핑이 덕 타이핑을 대신하려고 하는 것이 일부 상황에서 보완하려고 하는 것이라는 것이다.

여기서 구상 클래스가 아닌 추상 베이스 클래스인 경우만 type check에 사용하라고 권장하는 이유 중 하나는 register()라는 class method를 사용해서 조금 더 유연하게 인터페이스를 구현할 수 있기 때무이다.( register()의 사용 방법은 뒤에서 살펴본다.) 또한, ABC는 특별 메서드들을 사용해서 굉장히 유연하게 동작한다. (아래 코드를 보자.) 파이썬의 데이터 모델의 철학이 반영된 것으로 보인다!

class Struggle:
    def __len__(self):
        return 23

from collections import abc
isinstance(Struggle(), abc.Sized)

알렉스는 isinstance를 통해 type checking하는 것보다 ABC를 상속하는 것이 낫다고 강조한다고 한다. 상속은 보다 개발자의 의도를 명확히 나타내기 때문이다. 

 

구스 타이핑에서 중요한 부분 중 하나는 프레임워크를 구현하는 것을 제외하고는 일반적으로 덕 타이핑이 자료형 검사보다 간단하고 융퉁성이 높다는 것이다.

 

 

ABC 상속하기

알렉스는 isinstance를 사용하는 것보다 ABC를 상속하는 것을 추천했다.

아래 코드를 보면 상속에 따라서 카드를 섞기 위해서는 __setitem__만 구현하면 되지만 MutableSequence의 추상 메서드는 __delitem__(), insert() 도구현해야 한다. 만약에 구현하지 않으면 예상대로 TypeError 에러가 발생한다.

   
import collections

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

class FrenchDeck2(collections.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]

    def __setitem__(self, position, value):  # <1>
        self._cards[position] = value

    def __delitem__(self, position):  # <2>
        del self._cards[position]

    def insert(self, position, value):  # <3>
        self._cards.insert(position, value)

 

위에서 살펴 본 것 처럼 ABC를 잘 활용하려면 각 ABC가 어떤 추상 메서드를 가지고 있는지 파악해야한다.

 

 

ABC의 정의와 사용

위에서 ABC를 직접 생성하려면 프레임워크를 만들 때와 같은 경우라고 했기 때문에 책에서는 ABC를 생성하는 일을 정당화하기 위해 광고 관리 프레임워크를 만든다고 가정한다.

 

요구 조건

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

위의 요구 조건을 충족하기 위해 무반복 무작위 선택하는 클래스(ADAM)를 지원해야 한다. ADAM 사용자에게 '무반복 무작위 선택' 요소가 갖추어야 하는 성질을 명시하기 위해서 ABC를 정의한다.

import abc

class Tombola(abc.ABC):  # <1>

    @abc.abstractmethod
    def load(self, iterable):  # <2>
        """Add items from an iterable."""

    @abc.abstractmethod
    def pick(self):  # <3>
        """Remove item at random, returning it.
        This method should raise `LookupError` when the instance is empty.
        """

    def loaded(self):  # <4>
        """Return `True` if there's at least 1 item, `False` otherwise."""
        return bool(self.inspect())  # <5>


    def inspect(self):
        """Return a sorted tuple with the items currently inside."""
        items = []
        while True:  # <6>
            try:
                items.append(self.pick())
            except LookupError:
                break
        self.load(items)  # <7>
        return tuple(sorted(items))

ABC를 정의하기 위해서 abc.ABC를 상속한다. 또, load(), pick() 추상 메서드와 loaded(), inspect() 두 개의 구상 메서드를 구현했다.

ABC임에도 구상 메서드를 가지고 있다. 이 부분에서 비효율적인 inspect()메서드를 구현한 이유는 ABC도 인터페이스에 정의된 메서드만 이용하는 경우 구상 메서드를 제공하는 것이 가능하다는 것을 보여주기 위해서이다.

 

한 번 해당 ABC를 상속해서 다 구현하지 않으면 위에 이야기한 것 처럼 TypeError가 발생하는지 확인해보자.

class FakeTambola(Tombola):
    def pick(self):
        return 23
        
f = FakeTambola()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Input In [24], in <module>
----> 1 f = FakeTambola()

TypeError: Can't instantiate abstract class FakeTambola with abstract method load

덕 타이핑였다면 해당 메서드가 실행되기 전까지는 몰랐을 것이다.

 

이제 제대로 상속받아 구현한 class를 만들어보자. BingoCage Class는 단순히 인터페이스를 잘 따라 구현했고, LotteryBlower Class는 몇가지 부분을 보완했다. LotteryBlower의 경우 초기화 메서드에서 list 생성자를 사용해서 어떠한 반복형이더라도 LotteryBlower 클래스를 초기화할 수 있으므로 융퉁성이 향상되었다.  또한, loaded 함수를 override해서 성능을 향상시켰다.

import random

from tombola import Tombola


class BingoCage(Tombola):  # <1>

    def __init__(self, items):
        self._randomizer = random.SystemRandom()  # <2>
        self._items = []
        self.load(items)  # <3>

    def load(self, items):
        self._items.extend(items)
        self._randomizer.shuffle(self._items)  # <4>

    def pick(self):  # <5>
        try:
            return self._items.pop()
        except IndexError:
            raise LookupError('pick from empty BingoCage')

    def __call__(self):  # <7>
        self.pick()
        
import random

from tombola import Tombola


class LotteryBlower(Tombola):

    def __init__(self, iterable):
        self._balls = list(iterable)  # <1>

    def load(self, iterable):
        self._balls.extend(iterable)

    def pick(self):
        try:
            position = random.randrange(len(self._balls))  # <2>
        except ValueError:
            raise LookupError('pick from empty LotteryBlower')
        return self._balls.pop(position)  # <3>

    def loaded(self):  # <4>
        return bool(self._balls)

    def inspect(self):  # <5>
        return tuple(sorted(self._balls))

 

이번에는 위에서 언급했던 register() 클래스 메서드를 활용해보자. register()를 사용하면 어떤 클래스가 ABC를 상속하지 않더라도 그 클래스의 가상 서브클래스로 등록할 수 있다. Python은 register로 등록하면 따로 검사하지 않고 해당 인터페이스를 구현했다고 믿는다. (일부로 이를 구현하지 않는 개발자는 없을 것이다. 대부분이 실수로 빠트린 것일 것이기 때문에 파이썬은 유연하게 행동한다.)

 

register를 이용해 가상 서브 클래스로 등록.

from random import randrange


@Tombola.register  # <1>
class TomboList(list):  # <2>

    def pick(self):
        if self:  # <3>
            position = randrange(len(self))
            return self.pop(position)  # <4>
        else:
            raise LookupError('pop from empty TomboList')

    load = list.extend  # <5>

    def loaded(self):
        return bool(self)  # <6>

    def inspect(self):
        return tuple(sorted(self))

issubclass(TomboList, Tombola)
True

t = TomboList(range(100))
isinstance(t, Tombola)
True

 

 

서브클래스 테스트 방법

클래스 계층 구조를 조사할 수 있게 해주는 다음과 같으 두가지 클래스 속성이 있다.

- __subclasses__(): 클래스의 바로 아래 서브 클래스의 리스트를 반환하는 메서드. 리스트에 가상 서브클래스는 들어가지 않는다.

- _abc_registry: ABC에서만 사용할 수 있는 데이터 속성으로, 추상 클래스의 등록된 가상 서브클래스에 대한 약한 참조를 담고 있는 WeakSet

 

 

__subclasshook__ 매직 메서드

위에서 우리는 아래와 같은 코드를 봤다.

class Struggle:
    def __len__(self):
        return 23

from collections import abc
isinstance(Struggle(), abc.Sized)

어떻게 이렇게 가능할까? 이 마법의 비밀이 __subclasshook__ 매직메서드이다.

from abc import ABCMeta, abstractmethod


class Sized(metaclasss=ABCMeta):
    __slots__ = ()
    
    @abstractmethod
    def __len__(self):
        return 0
    
    @classmethod
    def __subclasshook__(cls, C):
        if cls is Sized:
            if any("__len__" in B.__dict__ for B in C.__mro__):
                return True
        return NotImplementedError

__subclasshook__()은 구스 타이핑에 약간의 덕 타이핑 유전자를 추가한 것이라고 한다. 너무 찰떡 같은 표현이다.. ABC 중 일부는 __subclasshook__() 매직 메서드가 구현되어 있다.

 

 

정리

파이썬에서는 유연하게 덕 타이핑같은 동적 프로토콜과 구스 타이핑으로 조금은 정적인 프로토콜을 통해 인터페이스를 지원한다. 정말 유연한 언어라는 생각이 든다. 책 summary 부분에 나오는 부분을 인용하면서 마무리한다.

 

파이썬은 상당히 많은 융통성을 부여하는 동적 언어라는 철학을 기반으로 하고 있다.
모든 곳에서 자료형을 통제하려고 하면 필요 이상으로 복잡한 코드가 나온다.
파이썬의 융통성을 받아들이기 바란다.

- 데이비드 비즐리, 브라이언 K.존스 (Python Cookbook의 저자.)

 

 

Reference

- https://github.com/fluentpython/example-code/tree/master/11-iface-abc

- Fluent Python Chapter 11

반응형