Source
전문가를 위한 파이썬(Fluent Python) 13장
4가지 타이핑 유형
이번 챕터를 그림 한장에 🦆🪿🤯
이 그림 보기 전에 우선 타입 검사에 대해서 한번 짚고 넘어가자. Python에서 가능한 타입 검사(Type Checking)은,
-
기본 인터프리터의 타입 검사 (= 동적 타입 검사, Dynamic Typing)
- Python은 원래 동적 타입 언어로 변수 타입을 미리 선언할 필요 없음
- 타입 지정 없이 실행이 가능하며 실행중(runtime)에 타입이 결정되고 문제가 있어도 실행중에 typeerror가 발생
- 사실상 이걸 타입 ‘검사’라고 볼 수 있나? 런타임에 잘못될 때까지 검사를 안하는 거임.. 어쨌든 Python의 기본 작동 방식이 이렇다
-
런타임 타입 검사
- 실행중(runtime)에 객체의 타입을 명시적으로 직접 확인
isinstance()
나hasattr()
를 통해 타입을 체크하는 것을 말함
-
정적 타입 검사 (Static Type Checking)
- type hints를 사용해서 타입을 미리 지정
- 하지만 Python 자체는 타입을 강제하지 않기 때문에 mypy 같은 모듈이나 IDE에서 제공하는 기능과 같은 별도의 외부 도구를 사용해서 검사
def add(x: int, y: int) -> int: #int라고 type hinting
return x + y
print(add(3, 5)) # 정상 작동
print(add("3", "5")) # mypy 검사 시 오류 발생
$ mypy script.py
error: Argument 1 to "add" has incompatible type "str"; expected "int"
다시 그림으로 돌아가서 축을 보면,
- 정적 🆚 런타임 검사
- 위의 검사 유형 중 런타임 검사이냐 정적 검사이냐를 의미
- 구조적 🆚 명목형 검사
- 구조적 검사: 객체의 구조에 기반해서 체크함. 클래스가 뭐든지 간에 이 객체가 어떤 메소드를 가지고 있다면 이런 타입이다 라는 방식 (그야말로 🦆 typing의 정의)
- 명목형 검사: 명시적으로 클래스명이 이거냐? 이 클래스를 상속했느냐? 를 검사
이 기준들로 볼 경우 4가지 타이핑 유형이 나눠진다는 것
- 덕타이핑
- 초기 파이썬부터 기본방식
- 구스타이핑
- 추상 베이스 클래스(ABC)로 지원
- 객체가 ABC형인지 런타임에 검사 -
isinstance()
- 정적 타이핑
- C, 자바처럼 정적으로 타입 검사
- 위의 코드 예시처럼 type hinting + 외부 타입 검사기로 검사
- 정적 덕 타이핑
- typing.Protocol의 서브클래스를 통해 지원
- 덕타이핑의 유연함을 살리면서 타입 검사도 할 수 있는 방법!
- 외부 타입 검사기로 검사
사실 정적 타이핑은 명확하기 때문에… 덕타이핑, 구스타이핑, 정적 덕 타이핑(정적 프로토콜)은 아래에서 더 자세히 다룬다!
프로토콜이란?
- 맥락에 따라 정말 많은 의미를 가질 수 있는 말이지만 Python 문서에서는 초기부터 비공식 인터페이스를 의미해왔음
- 예를 들어 어떤 클래스가
__getitem__()
이라는 스페셜 메서드를 구현하면, 시퀀스 프로토콜이 지원되어 항목을 가져오고 반복,in연산자 등 일반적인 시퀀스처럼 작동할 수 있게 됨 - 덕타이핑과 일치하는 맥락
- 예를 들어 어떤 클래스가
- 파이썬 3.8에서 Protocols - Structural subtyping(정적 덕 타이핑)이 등장하면서 프로토콜의 의미가 하나 더 추가됨
- 이 책에서는 두 가지 프로토콜을 다음 용어로 나누어 구분하려고 함
- 동적 프로토콜
- 처음에 언급한 전통적인(?) 의미의 프로토콜로 인터프리터 자체에서 기본으로 지원
- 타입 검사기 사용할 수 없고 런타임에만 타입이 맞는지 확인할 수 있음
- 정적 프로토콜
- typing.Protocol의 서브클래스
- 정적 타입 검사기로 검증할 수 있음
- 동적 프로토콜
- 둘 다 어떤 클래스를 상속할 필요는 없다는 점이 중요함! 동적 프로토콜의 경우에도 예를 들어
__getitem__()
을 구현만 하면 되고, 정적 프로토콜의 경우에도 typing.Protocol을 이용해 정의된 메소드를 구현하기만 하면 상속 없이 타이핑이 가능
덕 타이핑 (=동적 프로토콜)
멍키 패칭
기존 코드(클래스, 모듈, 라이브러리 등)를 수정하지 않고, 런타임에 동적으로 속성(메서드, 변수 등)을 변경
class Dog:
def speak(self):
return "멍멍!"
dog = Dog()
print(dog.speak()) # 멍멍
# 멍키 패칭
def new_speak(self):
return "월월!"
Dog.speak = new_speak
print(dog.speak()) # 월월
- 이런 방식으로 런타임에서 메서드를 추가하게 되면(예- 가변 시퀀스 프로토콜을 만족하는 메서드), 1) 이 클래스가 어떤 클래스이든 상관없이 2) 원래 코드에 있는 게 아니라 런타임에 추가되었더라도 프로토콜이 만족됨
방어적 프로그래밍/조기 실패
방어운전처럼, 부주의한 프로그래머가 있더라도 안정성을 높이는 관례
-
조기 실패: 함수 시작 부분에서 잘못된 인수를 거부하는 등 가능한 한 빨리 런타임 에러를 발생시킴
-
예를 들면,
def __init__(self, iterable):
self._balls = list(iterable)
-
너가 어떤 걸 넣든지 안전하게 list로 바꿔서 쓰겠다
- 만약 iterable이 아니어서 list로 변환이 안 되면 이때 실패할 것
- 이걸 미리 해두지 않으면 나중에 코드의 다른 부분에서 list형이 지원하는 연산을 하려고 할 때 실패할 텐데 그때는 원인을 찾기가 힘들 수 있음
-
타입 힌트를 사용하더라도 조기 실패가 필요할 수 있음
- 힌트는 힌트일 뿐이고 런타임에서 강제되지 않기 때문에
def multiply(x: int, y: int) -> int:
return x * y
print(multiply("3", 4)) # "3333"
#mypy를 쓰면 "3"이 int가 아니라고 경고, 하지만 런타임에서 그냥 실행은 되어버림
def multiply(x: int, y: int) -> int:
if not isinstance(x, int) or not isinstance(y, int):
raise TypeError("x와 y는 정수여야 합니다.") #조기실패
return x * y
구스 타이핑 (ABC)
추상 베이스 클래스(ABC)로 인터페이스를 정의하고 상속/등록한 뒤 런타임 검사(isinstance, issubclass)하기!
-
추상 클래스(Abstrac Class) 🆚 구상 클래스(Concrete Class)
- 추상 클래스: 인스턴스를 생성할 수 없고 메서드의 목록(=인터페이스)만 가지고 있는 클래스
- 구상 클래스: 직접 인스턴스를 만들 수 있는 클래스
- 추상 클래스를 상속하여 클래스를 만들 때 추상 클래스에서 정의된 메서드의 구현이 강제됨
-
구상 클래스에 대해 런타임 검사(isinstance)를 하면 객체지향 프로그래밍의 특성인 다형성을 제한하게 됨
- 다형성(Polymorphism) 이란?
- 같은 인터페이스(메서드)를 가진 객체들이 서로 다른 방식으로 동작할 수 있음
- 계속 if/elif를 난사하면서 isinstance() 체크해서 각각 다르게 동작하도록 작성하는 것은 다형성과 어긋난다는 말
- 다형성(Polymorphism) 이란?
class Dog:
def speak(self):
return "멍멍!"
class Cat:
def speak(self):
return "야옹!"
def make_sound(animal):
if isinstance(animal, Dog) or isinstance(animal, Cat):
return animal.speak()
else:
raise TypeError("Animal이 아닙니다!")
- 구상 클래스에 대해 런타임 검사를 하면 새로운 클래스가 추가될 때마다 타입 검사의 if문을 계속 수정해야 함
- 다음과 같이 추상 클래스를 사용해 융통성 있게 검사할 수 있음
from abc import ABC, abstractmethod
class Animal(ABC):
@abstractmethod
def speak(self):
pass
def sleep(self):
print("zzz...")
class Dog(Animal):
def speak(self):
return "멍멍!"
class Cat(Animal):
def speak(self):
return "야옹!"
def make_sound(animal: Animal):
if isinstance(animal, Animal):
return animal.speak()
else:
raise TypeError("Animal이 아닙니다!")
- 추상 메서드와 일반 메서드 차이
- 추상 메서드(
@abstractmethod
)는 서브클래스에서 반드시 구현해야 함,speak
을 구현하지 않고 상속한 뒤 객체를 생성하면 TypeError 발생 - 일반 메서드는 기본적인 기능을 제공하며 서브클래스에서 구현하지 않고 그냥 바로 사용할 수 있음. 원한다면 오버라이딩(재정의)해도 됨
- 추상 메서드(
표준 라이브러리 ABC
- collections.abc 모듈에 대부분의 표준적인 ABC가 정의되어 있음
- 표준 ABC를 활용하는 것이 일반적으로 권장되며, 새로운 ABC를 정의해야 한다면 그 필요성이 명확해야 함
ABC 정의하고 상속하기
- 표준 ABC로 안 되는 기능, 새로운 개념이나 특화된 비즈니스 로직에 맞는 인터페이스가 필요하다면 ABC를 새로 정의
- 예를 들어,
아이템을 랜덤하게 보여주지만 목록에 있는 아이템을 다 보여줄 때까지 같은 아이템을 반복해서 보여주면 안 됨
import abc
class Tombola(abc.ABC):
@abc.abstractmethod
def load(self, iterable):
"""iterable의 항목들을 추가한다."""
@abc.abstractmethod
def pick(self):
"""무작위로 항목 하나를 제거하고 반환한다.
인스턴스가 비어 있으면 LookupError를 발생시킨다.
"""
def loaded(self):
"""항목이 최소 한 개 이상 있으면 True, 아니면 False 반환한다."""
return bool(self.inspect())
def inspect(self):
"""현재 항목들로 정렬된 튜플을 만들어 반환한다."""
items = []
while True:
try:
items.append(self.pick())
except LookupError:
break
self.load(items)
return tuple(items)
- ABC를 선언할 때는 abc.ABC나 다른 ABC를 상속하는 게 표준적인 방법임
- 구문상의 주의점은
@abstractmethod
데커레이터는 다른 데커레이터랑 같이 쓸 때 가장 뒤에, 즉 def 바로 앞에 와야 함 - 위 Tombola 라는 ABC는 2개의 추상 메서드와 2개의 일반 메서드가 정의되어 있음
class Fake(Tombola):
def pick(self):
return 13
f = Fake() #TypeError
- 위와 같이 Tombola를 상속했지만, 2개의 추상 메서드 중 하나를 구현하지 않았으므로 TypeError가 남
ABC를 상속하지 않고 가상 서브클래스로 등록하기
- 구스타이핑의 본질적 기능은 ABC를 상속하지 않고도 그 클래스의 가상 서브클래스로 등록할 수 있다는 것
- 등록할 경우,
- 객체를 생성할 때 ABC의 인터페이스를 따르는지 검사하지 않음 (mypy같은 도구도 이 가상 서브클래스를 검사하지 않음)
- issubclass()나 isinstance()함수에 의해 그 ABC로 인식됨
- 실제 상속이 아니기 때문에 ABC로부터 메서드나 속성은 전혀 상속받지 않음
- ABC 인터페이스의 구현이 강제되지 않기 때문에 유연하지만 런타임 검사만 가능 (메서드를 구현하지 않아서 실행 중 오류가 발생할 수 있음)
- 등록할 경우,
class Car:
pass
Animal.register(Car) #이렇게 해도 되고
@Animal.register
class Car:
pass #이렇게 해도 됨
####
issubclass(Car, Animal) #True
car = Car() #인터페이스 구현을 안했는데도 잘 생성됨
print(isinstance(car, Animal)) #True
#하지만 abstractmethod인 speak을 구현하지 않았기 때문에..
print(car.speak()) # AttributeError: 'Car' object has no attribute 'speak'
정적 덕 타이핑 (=정적 프로토콜)
typing.Protocol 을 사용해서 특정 인터페이스(메서드/속성)만 가지만 해당 타입으로 인정하는 방식
from typing import Protocol
class Flyable(Protocol):
def fly(self) -> str:
...
class Bird:
def fly(self) -> str:
return "새가 난다!"
class Airplane:
def fly(self) -> str:
return "비행기가 난다!"
class Fish:
def swim(self) -> str:
return "물고기가 헤엄친다!"
def make_it_fly(flyer: Flyable):
print(flyer.fly())
bird = Bird()
plane = Airplane()
fish = Fish()
make_it_fly(bird)
make_it_fly(plane)
make_it_fly(fish) # 오류 발생
- Bird, Airplane이 모두 어떤 클래스를 상속할 필요 없이 fly()를 구현하는 것만으로도 Flyable로 인정됨
- Protocol은 런타임에 강제되지 않음 (런타임 검사 불가능)
- 정적 검사 방식으로 mypy등의 검사기를 사용해야 함
isinstance(bird, Flyable) #TypeError. Flyable은 런타임에서 클래스로 인식X
- 하지만
@runtime_checkable
데코레이터를 사용하면 Protocol도 isinstance()나 issubclass()로 검사할 수 있음
from typing import Protocol, runtime_checkable
@runtime_checkable
class Flyable(Protocol):
def fly(self) -> str:
...
class Bird:
def fly(self) -> str:
return "새가 날아갑니다!"
class Fish:
def swim(self) -> str:
return "물고기가 헤엄칩니다!"
b = Bird()
f = Fish()
print(isinstance(b, Flyable)) # True (Bird는 fly()를 구현했으므로)
print(isinstance(f, Flyable)) # False (Fish는 fly() 없음)
검사 방식 | 검사 시점 | 타입 체크 방식 | 상속 필요 여부 | isinstance() 사용 가능 | 강제되는 메서드 |
---|---|---|---|---|---|
동적 타입 검사 | 런타임 | isinstance() 등으로 직접 검사 | ❌ | ✅ | ❌ (아무 메서드나 가능) |
정적 타입 검사 | 정적 검사기(mypy ) | 타입 힌트 기반 검사 | ❌ | ❌ | ❌ (힌트만 제공) |
ABC 상속 | 런타임 | isinstance() 검사 가능 | ✅ | ✅ | ✅ (추상 메서드 구현 필수) |
ABC register() | 런타임 | isinstance() 검사 가능 (등록된 클래스만) | ❌ | ✅ | ❌ (구현 강제 안 함) |
Protocol (typing.Protocol ) | 정적 검사기(mypy ) | “해당 메서드만 있으면 타입 인정” | ❌ | 🔺 (데코레이터 사용하면 가능) | ✅ (정의된 메서드 필요) |