All Articles

파이썬 인터페이스 정리하기 from Duck-typing to typing.Protocol

Caller와 Callee 사이의 인터페이스(상호 계약)가 깨지면(method , param … 등의 변경) 프로그램에서 에러가 발생한다. 프로그램의 안정성을 높이기 위해선 인터페이스 유지되는지 확인이 필요하다. 컴파일 랭귀지라면, 컴파일 단계에서 타입 추론을 통해 확인한다. 파이썬은 스크립트 언어라 실행 시점에 코드를 읽기 때문에 사전 확인이 어렵다.

그래서 Runtime 에서 확인하거나 Static 한 방식으로 사전에 코드를 읽어서 검사한다. 인터페이스를 완벽하게 강제하는 방법은 없다. 프로그램의 상황과 구성원간의 합의에 따라 적절한 방법을 선택해야한다. 어느 방법이 적합할지 한번 살펴보겠다.

Ducktyping

class Duck:
    def shoute(self):
        print("kwak")

class Cat:
    def voice(self):
        print("moew")

def make_noise(x):
    x.voice()

cat = Cat()
make_noise(cat)

duck = Duck()
make_noise(duck)
>>> moew
>>> ...
>>> AttributeError: 'Duck' object has no attribute 'voice'

xvoice() 를 가지고 있을 것이라는 인터페이스를 cat 은 지켰지만, duck 은 지키지 않았다. make_noisex 의 인터페이스는 x 의 변경에 취약하다. 이를 인지 하기 위해서는 코드를 실행해 봐야 한다.

Duck 클래스가 voice 메소드를 구현하지 않아도 실행 되게 할 수 있다. duck 인스턴스에 동적으로 할당 하더라도, voice 를 동작 하도록 할 수 있다.

...
duck = Duck()
duck.voice = lambda : print("duck kwak")
make_noise(duck)

동작하는 인터페이스(voice)만 있다면, Cat과 Duck은 make_noise 스코프 안에서 사실상 같은 걸로 취급하는 것이다. 오리처럼 걷고, 오리처럼 소리지른다면 이는 오리와 같다. 여기서 Duck typing 이란 이름이 나왔다.

moew 가 구현된 Cat 의 인스턴스 만을 받고자 한다면, 다음과 같이 검증 로직을 추가 할 수 있다.

def make_noise(x):
    if isinstance(x, Cat):
        x.voice()
    else:
        print("x has no voice")

cat = Cat()
make_noise(cat)
duck = Duck()
make_noise(duck)
moew
x has no voice

이러면 인터페이스를 안전하게 유지할 수 있는 걸까?

프로그램 규모가 커짐에 따라 make_noise 함수가 수용해야 할 x 타입이 늘어간다.

class Lion:
	...

class Tiger:
	...

class Gorilla:
	...
def make_noise(x):
    if isinstance(x, Cat):
        x.voice()
		elif isinstance(x, Lion):
        x.voice()
    elif isinstance(x, Tiger):
        x.voice()
    elif isinstance(x, Gorilla):
        x.voice()
		...
    else:
        print("x is not Cat")

이렇게 종류가 늘어갈 수록, make_noise 의 변경사항이 계속해서 생긴다. ’Open Close Priciple’ 이 깨진다.

isinstance 로는 class 종류 만 확인이 가능하다. classvoice 를 가지고 있는 여부는 런타임에서 발생한 에러로만 확인이 가능하다.

Goose-typing

make_noise가 수용할 class 들이 같은 voice 를 갖도록 하기 위한 방법으로 추상클래스를 이용한 상속을 한다. (PEP-484)

OOP SOLID 원칙의 ‘Dependency Inversion Principle’ 과도 연결된다.

from abc import ABC, abstractmethod

# 추상클래스 임을 명시하는 ABC 상속
class Animal(ABC):
		# 추상메소드를 만들기 위한 데코레이터
    @abstractmethod
    def voice(self):
        raise NotImplementedError

# abc 없이 이렇게 Base Class를 선언하기도함
class Animal:
    def voice(self):
        raise NotImplementedError

class Goose(Animal):
		...

def make_noise(x):
    x.voice()

goose = Goose()
make_noise(goose)

abstractmethod 를 구현한 Animal 은 인스턴스화가 되지 않는다.

>>> TypeError: Can't instantiate abstract class Animal with abstract method voice

Animal 을 상속받은 클래스는 추상 메소드 voice 를 구현해야 한다. 그렇지 않으면 instantiate 에서 에러가 발생한다.

>>> TypeError: Can't instantiate abstract class Goose with abstract method moew

Goose 클래스에 voice를 구현한다.

class Goose(Animal):
    def voice(self):
        print("kwak")

goose = Goose()
make_noise(goose)

>>> kwak

정상 동작을 확인 할 수 있다.

make_noise에 들어올 모든 클래스들은 Animal을 상속받도록 하고, voice를 구현하도록한다.

class Lion(Animal):
		def voice(self):
			...

class Tiger(Animal):
		def voice(self):
			...

class Gorilla(Animal):
		def voice(self):
			...

make_noise 에서 xAnimal 의 구체 클래스라면 voice 가 구현 되어 있는지에 대해서는 이제 걱정하지 않아도 된다. 이를 검증하는 빌트인메소드 issubclass를 파이썬에서 제공한다.

def make_noise(x):
		if issubclass(x, Animal):
				x.voice()
		else:
				print("x is not subclass of Animal")

Duck-typing 에서 보던 elif 블록들이 사라졌다.

이처럼 추상클래스로 인터페이스를 구현하고 구상클래스들이 인터페이스를 따르도록 하는 것을 Goose-typing이라고 한다.

우리는 아직 파이썬 런타임에서만 make_noise의 안정성을 검증할 수 있다. 소규모 프로젝트에서는 가능하다.

대규모 파이썬 프로젝트의 아주 깊숙이 있는 코드에서 이런 런타임에러가 난다면 프로젝트를 안정하게 유지하는건 여전히 힘든일이 될 수 있다.

Static Type Check

python 3.5 부터 추가된 type hints를 활용한다. x에 type annotation 으로 Animal을 추가한다. xAnimal 구상 클래스만 들어오도록 강제하지는 못한다. issubclass 를 통해서 구상클래스를 검증 할 수 있다.

from abc import ABC, abstractmethod

class Animal(ABC):
    @abstractmethod
    def voice(self):
        raise NotImplementedError

class Goose(Animal):
		def voice(self):
        print("kwak")

def make_noise(x: Animal):
		if issubclass(x, Animal):
		    x.voice()
		else:
				print("x is not subclass of Animal")

goose = Goose()
make_noise(goose)

>>> kwak

Animal 의 서브클래스가 아닌것으로 make_noise 에 전달 할 경우,

class Dog:
		def voice(self):
        print("bark")

dog = Dog()
make_noise(dog)

IDE 에서는 경고가 뜬다.

Expected type 'Animal', got 'Goose' instead

mypy를 실행하면 에러가 난다.

error: Argument 1 to "make_noise" has incompatible type "Goose"; expected "Animal"

mypy, pyright 등의 type checker를 개발조직내에서 강제한다면, issubclass 없이도, type hints 만으로 프로그램의 안정성을 지킬 수 있게 된다.

Static Duck-typing

ABC에 Type Annotation을 적용하면 완전해 지는가?

PEP-544에 따르자면 아직은 그렇지 않다.

PEP-484 에서 제안된 typing은 파이썬 일반 프로토콜인 len, iter 를 위한 추상 클래스(Iterable and Sized)들을 가지고 있습니다. 문제는 이 클래스를 상속하고 있음음 명시적으로 적어 줘야 한다는 것입니다. 이는 파이써닉 하지 않고, 관용 동적 타이핑 파이썬 코드와도 다릅니다. 사용자 정의 추상 클래스도 마찬가지 입니다. 라이브러리 타입과 할경우 타입이 어디 있는지 찾기 힘듭니다. 상속을 활용하기 위해서는 Base Class 되거나 가상클래스로 Base Class에 등록 되어야 합니다. 그리고 ABC 과도한 사용은 추가적인 자원을 소모 합니다.

참조 PEP-544

엄격한 type checker 에서 파생된 구조적 복잡성 문제를 해결하기 위해 PEP-544가 제안 되었다. 이는 Static Duck-typing 으로 부른다.

PEP-544에서 제안되고, python 3.8에서 도입된 Protocol 은 메소드, 어트리뷰트 검증을 한다.

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

ABC 처럼도 사용이 가능하다.

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

PEP-544의 제안자는 다음과 같이 적었다.

“Therefore, in this PEP we do not propose to replace the nominal subtyping described by PEP 484 with structural subtyping completely. Instead, protocol classes as specified in this PEP complement normal classes, and users are free to choose where to apply a particular solution.”

PEP-484를 대체 하고자 제안 하는 것이 아니며, 일반 클래스의 보완물로서 유저는 어느 곳에 적용할 지 자유롭게 선택할 수 있게 한다.

runtime_chekable 데코레이터를 사용하면 Procotolisinstance, issubclass 를 지원한다. Protocol의 하위인지 검사가 아니라, method구현여부를 검증하기에 런타임에서도 검사할 수 있는 장치를 달 수 있다. 단 method , attribute 소유 여부만 조사하고, 실제 구현의 방식(parameter type annotation, return type annotation) 까지 검사하지는 못한다. 이는 static type checker로만 가능하다.

from typing import runtime_checkable, Union

class NoiseMaker(Protocol):
    def moew(self, master:str) -> None:
        print(f"moew {master}")

class NoiseMaker2(Protocol):
    def moew2(self, master:str) -> None:
        print(f"moew {master}")

@runtime_checkable
class NoiseMakerProtocol(NoiseMaker, NoiseMaker2, Protocol):
    pass

class Cat:
    def moew1(self, master:str):
        print("moew")
    def moew2(self, master1:str) -> None:
        print(f"moew {master1}")

def make_noise(x: No ):
    if isinstance(x, No):
        x.moew1('master')
    else:
        print("no")

cat = Cat()
make_noise(cat)

>>> no

프로토콜 합성도 가능하다.

class Animal(Protocol):
  def voice(self):
    ...

class Animal2(Protocol):
  def shout(self):
    ...

# 주의: 합성 클래스에도 Protocol 이 들어가야함
class AnimalProtocol(Animal, Animal2, Protocol):
  ...

class Tiger:
	def voice():
		...
	def shout():
		...

class Gorilla:
	def voice():
		...
	def motion():
		...

def make_noise(x:AnimalProtocol):
  x.voice()
  x.shout()

tiger = Tiger()
make_noise(tiger) # OK

gorilla = Gorilla()
make_noise(gorilla) # ERROR

함수입장에서는 인터페이스가 강제된 특정 구상클래스로 구현하는것보다 파라미터가 유연해진 다. 명시적 타입 ( 추상 클래스 or 구상 클래스) 이 암묵적 타입 ( 동작 스코프내 필요한 어트리뷰트와 메소드 검증) 으로 바뀌기 때문이다.

인터페이스를 프로토콜로 지정 해놓으면 상속을 받지 않은 작은 타입들로도 동작이 가능하다.

추상 클래스에 집중되고 중앙화 되던 타입검증 책임이, 프로토콜들로 분산 된다.

항목별 비교표

인터페이스 안정을 위한 방법들을 표로 정리해 본다.

Duck-typing Goose-typing Static Type Check Static Duck-typing

runtime checkable
O O O O

runtime check method
isinstance issubclass issubclass isinstance, issubclass(@runtime_checkable 필요함)

static checkable
X X O O

static check way
X X Type annotation: Abstract Base Class type annotation: Protocol

파라미터 타입 검증 가능
X X
O
타입체커가 구상 메소드 타입 어노테이션 검증 런타임 체크(issubclass)는 추상 클래스만 확인

O
타입체커가 메소드 타입 어노테이션도 검증 런타임 체크(isinstance)는 소유 여부만 확인가능

인터페이스 집중
X O O X

인터페이스 분산
O X
추상 메소드 구현필요
X
추상 메소드 구현필요

구상 메소드 + 추상 프로토콜 작성 필요

코드 난이도

메모리 효율성

구조 단순성

코드 안정성