Search

인터페이스, typing.Protocol 을 곁들인.

인터페이스

Caller와 Callee 사이의 인터페이스(상호 계약)가 깨지면(method , param , type 등의 변경) 프로그램에서 에러가 발생합니다. 프로그램의 안정성을 높이기 위해선 인터페이스가 유지되는지 확인이 필요합니다. 컴파일 랭귀지라면, 컴파일 단계에서 타입 추론을 통해 방지합니다. 파이썬은 스크립트 언어이고 실행 시점에 코드를 읽기 때문에 사전 예방이 어렵습니다. 그래도 사고를 방지하기 위한 방법들이 있습니다.
크게 2가지 방법이 있는데, 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'
Python
복사
xvoice() 를 가지고 있을 것이라는 인터페이스를 cat 은 지켰지만, duck 은 지키지 않았습니다. x 의 변경은 make_noisex 의 인터페이스에 영향을 줍니다. 이를 인지 하기 위해서는 코드를 실행해 봐야 합니다.
Python은 Duck 클래스가 voice 메소드를 구현하지 않아도 실행 되게 할 수 있습니다. duck 인스턴스에 메소드를 동적으로 할당 하더라도, voice 를 동작 하도록 할 수 있습니다.
... duck = Duck() duck.voice = lambda : print("duck kwak") make_noise(duck) >>> duck kwak
Python
복사
동작하는 인터페이스(voice)만 있다면, Cat과 Duck은 make_noise 스코프에서 사실상 같은 걸로 취급하는 것입니다. (오리처럼 걷고, 오리처럼 소리지른다면 이는 오리와 같다.)
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
Python
복사
이 방식으로 모든 인터페이스를 안전하게 유지 할 수 있을까요?
프로그램 규모가 커짐에 따라 make_noise 함수에 들어올 x 타입이 늘어 갑니다.
class Lion: ... class Tiger: ... class Gorilla: ... ...
Python
복사
def make_noise(x): if isinstance(x, (Cat, Lion, Tiger, Gorilla, ...)): x.voice() else: print("x is not Cat")
Python
복사
클래스의 종류가 늘어갈 수록, make_noise 를 계속해서 변경해야 합니다. 그리고 isinstance 로는 class 종류만 확인이 가능합니다. classvoice 를 가지고 있는 여부는 동작중에 에러로만 확인이 가능합니다.

Goose-typing

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() animal = Animal() >>> TypeError: Can't instantiate abstract class Animal with abstract method voice goose = Goose() >>> TypeError: Can't instantiate abstract class Goose with abstract method moew
Python
복사
abstractmethod 를 구현한 Animal 은 인스턴스화가 되지 않습니다.
Animal 을 상속받은 클래스는 추상 메소드 voice 를 구현해야 합니다. 그렇지 않으면 instantiate 에서 에러가 발생 합니다.
Goose 클래스에 voice를 구현하면 정상 동작을 확인 할 수 있습니다.
class Goose(Animal): def voice(self): print("kwak") goose = Goose() make_noise(goose) >>> kwak
Python
복사
make_noise에 들어올 모든 클래스들은 Animal을 상속받도록 하고, voice를 구현하도록 합니다.
class Lion(Animal): def voice(self): ... class Tiger(Animal): def voice(self): ... class Gorilla(Animal): def voice(self): ...
Python
복사
make_noise 에서 xAnimal 의 구체 클래스라면 voice 가 구현 되어 있는지에 대해서는 이제 걱정하지 않아도 됩니다. 이를 검증하는 빌트인메소드 isinstance를 파이썬에서 제공합니다.
def make_noise(x): if isinstance(x, Animal): x.voice() else: print("x is not subclass of Animal")
Python
복사
DuckTyping 에서 나열하던 class 들이 사라졌습니다. 이처럼 추상클래스로 인터페이스를 구현하고 구상클래스들이 인터페이스를 따르도록 하는 것을 Goose-typing이라고 합니다.
우리는 아직 파이썬 런타임에서만 make_noise의 안정성을 검증할 수 있습니다.
대규모 파이썬 프로젝트의 아주 깊숙한 곳에 있는 부지불식 간에 예상하지 못한 코드에서 이런 런타임에러가 난다면 프로젝트 관련 주체들에게 큰 고통이 될 수 있습니다.

Static Type Check

python 3.5 부터 추가된 type hints를 활용 합니다. x에 type annotation Animal을 추가합니다. xAnimal 구상 클래스만 들어오도록 강제하지는 못합니다. isinstance 를 통해서 추상클래스와 일치 여부를 검증 할 수 있습니다.
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 isinstance(x, Animal): x.voice() else: print("x is not subclass of Animal") goose = Goose() make_noise(goose)
Python
복사
Animal 의 서브클래스가 아닌것으로 make_noise 에 전달 할 경우, 동작에는 영향이 없습니다.
class Dog: def voice(self): print("bark") dog = Dog() make_noise(dog) >>> bark
Python
복사
하지만 코드를 실행하지 않더라도 IDE 에서 경고를 날립니다.
Expected type 'Animal', got 'Dog' instead
Python
복사
정적 타입 검사기 Python lib mypy를 실행하면 에러가 발생합니다.
error: Argument 1 to "make_noise" has incompatible type "Dog"; expected "Animal"
Python
복사
mypy, pyright 등의 type checker를 개발 조직 내에서 강제한다면, isinstance 의 사용 없이도, type hints 만으로 프로그램의 안정성을 지킬 수 있게 됩니다.

Static Duck-typing

ABC에 Type Annotation을 적용하면 인터페이스 변경 문제는 완전히 해결 될까요 ?
PEP-544에 따르자면 아직은 그렇지 않습니다.
PEP-484 에서 제안된 typing은 파이썬 일반 프로토콜인 __len__, __iter__ 를 위한 추상 클래스(Iterable  and Sized)들을 가지고 있습니다. 문제는 이 클래스를 상속하고 있음음 명시적으로 적어 줘야 한다는 것입니다. 이는 파이써닉 하지 않고, 관용 동적 타이핑 파이썬 코드와도 다릅니다. 사용자 정의 추상 클래스도 마찬가지 입니다. 라이브러리 타입과 할경우 타입이 어디 있는지 찾기 힘듭니다. 상속을 활용하기 위해서는 Base Class 되거나 가상클래스로 Base Class에 등록 되어야 합니다. 그리고 ABC 의 과도한 사용은 추가적인 자원을 소모 합니다.
엄격한 type checker 에서 파생된 구조적 복잡성 문제를 해결하기 위해 PEP-544가 제안 되었습니다. 이는 Static Duck-typing 으로 칭해집니다.
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) # type check: OK
Python
복사
또는 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: 'PColor' is not parent 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) # type check: OK represent(another) # type check: OK
Python
복사
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 데코레이터를 사용하면 isinstance, issubclass 지원한다. Protocol의 하위인지 검사가 아니라, method구현여부를 검증하기에 런타임에서도 검사할 수 있는 장치를 달 수 있습니다. 단 method , attribute 소유 여부만 조사하고, 실제 구현의 방식(parameter type, return type) 검증은 못합니다. 이는 static type checker로만 가능합니다.
from typing import runtime_checkable, Union, Protocol class NoiseMaker1(Protocol): def moew1(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(NoiseMaker1, 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: NoiseMakerProtocol ): if isinstance(x, NoiseMakerProtocol): x.moew1('master') else: print("no") cat = Cat() make_noise(cat) >>> moew
Python
복사
프로토콜 합성도 가능합니다.
class Animal(Protocol): def voice(self): ... class Animal2(Protocol): def shout(self): ... # 주의: 합성 클래스에도 Protocol 이 들어가야함 class AnimalProtocol(Animal, Animal2, Protocol): ... class Tiger: def voice(self): ... def shout(self): ... class Gorilla: def voice(self): ... def motion(self): ... def make_noise(x:AnimalProtocol): x.voice() x.shout() tiger = Tiger() make_noise(tiger) # type check: OK gorilla = Gorilla() make_noise(gorilla) # type check: ERROR
Python
복사
함수입장에서는 인터페이스가 강제된 특정 구상클래스로 구현하는것보다 파라미터가 유연해 집니다. 명시적 타입 ( 추상 클래스 or 구상 클래스) 이 암묵적 타입 ( 동작 스코프내 필요한 어트리뷰트와 메소드 검증) 으로 바뀌기 때문입니다.
인터페이스를 프로토콜로 지정해놓으면 상속을 받지 않은 작은 타입들로도 동작이 가능합니다.
추상 클래스에 집중되고 중앙화 되던 타입검증 책임이, 프로토콜들로 분산됩니다.
각각을 특징을 표로 비교해 봅니다.
Duck-typing
Goose-typing
Static Type Check
Static Duck-typing
runtime checkable
O
O
O
O (@runtime_checkable 필요함)
runtime check method
isinstance, issubclass
isinstance, issubclass
isinstance, issubclass
isinstance, issubclass (@runtime_checkable 필요함)
static checkable
X
X
O
O
static check metho
X
X
Type annotation: Abstract Base Class
type annotation: Protocol
파라미터 타입 검증 가능
X
X
O 타입체커가 구상 메소드 타입 어노테이션 검증
O 타입체커가 메소드 타입 어노테이션도 검증
인터페이스 집중
X
O
O
X
인터페이스 분산
O
X 추상 메소드 구현필요
X 추상 메소드 구현필요
△ 구상 메소드 + 추상 프로토콜 작성 필요
코드 난이도
메모리 효율성
구조 단순성
코드 안정성

유연하게, 중요한 것에 집중

초기 빠른 생산이 필요한 MVP 단계에서는, Interface를 강제하기 보다는 중요한것에 집중하고, 프로젝트가 비대해지거나, 중요성이 올라갈수록, 부분 또는 전체에 인터페이스를 강조해서 안정성을 유지하는것이 좋다는 생각이 듭니다. Protocol 은 Static Type Checking 에서 커버하기 어려운 부분을 # type ignore[...] 없이 유연하게 만들 수 있겠다는 생각이 들고, 적용해보고 있습니다.