본문 바로가기

개발

파이썬 Typing 파헤치기 - 심화편

반응형

지난 글 '파이썬 Typing 파헤치기 - 기초편'에서는 Typing의 기본적인 내용과 8개의 타입에 대해 다루어봤습니다. 이번 포스트에서는 조금 더 심화된 타입들에 대해 다뤄보도록 하겠습니다.

심화 타입 알아보기

1. tying.Callable[..., ReturnType]

지난 포스트에서 typing.Callable에 대해서 간단하게 다뤄봤습니다. 함수를 인자로 받는 경우에 Callable 타입을 활용하면 된다고 했습니다.

def on_some_event_happened(callback: Callable[[int, str, str], int]) -> None:
    ...

def do_this(a: int, b: str, c:str) -> int:
    ...

on_some_event_happened(do_this)

해당 타입에 대해서 조금 더 깊게 다뤄보겠습니다. 콜백 함수의 인자를 신경 쓰지 않고, 리턴 타입만을 명시해주고 싶은 경우가 있습니다. 그럴 때는 해당 타입의 인자 자리에 ... (Ellipsis)를 넣어주면 됩니다.

from typing import Callable

def calculate(fn: Callable[..., float], *args: float) -> float:
    return fn(args)

def multiply(*args: float) -> float:
    v = 1
    for arg in args:
        v *= arg
    return v

def sum(*args: float) -> float:
    v = 0
    for arg in args:
        v += arg
    return v


calculate(multiply, 1, 2, 3)

2. typing.TypeVar

여러 타입을 일반화한 것을 제너릭 타입이라고 합니다. typing.TypeVar을 사용하면 제너릭 타입을 나타낼 수 있습니다.

from typing import Sequence, TypeVar, Iterable

T = TypeVar("T")  # T 대신 다른 문자/단어를 써도 되지만 일반적으로 T를 사용합니다.


def batch_iter(data: Sequence[T], size: int) -> Iterable[Sequence[T]]:
    for i in range(0, len(data), size):
        yield data[i:i + size]

위의 코드는 Sequence 타입의 data를 주어진 size 만큼 잘라서 iterate 하는 함수입니다. 제너릭 타입을 썼기 때문에 Sequence[int], Sequence[str], Sequence[Person]T 자리에 어떤 타입도 올 수 있습니다.

TypeVar를 선언할 때 bound를 명시하면 타입을 제한할 수도 있습니다.

from typing import Sequence, TypeVar, Iterable, Union

T = TypeVar("T", bound=Union[int, str, bytes])


def batch_iter(data: Sequence[T], size: int) -> Iterable[Sequence[T]]:
    for i in range(0, len(data), size):
        yield data[i:i + size]

위의 경우에는 T로 대체될 수 있는 타입이 int, str, bytes 또는 해당 타입을 상속한 타입으로 제한됩니다.

따라서 아래의 경우 SomeInteger 클래스가 int를 상속받았기 때문에 타입 검사가 성공하고,

class SomeInteger(int):
    pass


batch_iter([SomeInteger(1), SomeInteger(2.5), SomeInteger(3.3)], 2)

아래의 경우는 float타입을 받을 수 없기 때문에 타입 검사에 실패합니다.

batch_iter([1.1, 1.3, 2.5, 4.2, 5.5], 2)

그렇다면 Any나 Union타입을 사용하는 것과는 어떤 차이가 있을까요?

중요한 것은 타입의 의존관계입니다. 위의 함수 같은 경우는 리턴 타입 Iterable[Sequence[T]]와 인풋 타입 Sequence[T]가 의존관계에 있습니다. 즉, 인풋 타입의 타입이 리턴 타입을 결정합니다. Iterable[Sequence[int]]의 경우에는 리턴 타입이 Sequence[int]로 타입이 결정되고 Iterable[Sequence[str]]의 경우 리턴 타입이 Sequence[str]로 결정됩니다.

3. typing.Generic

위의 TypeVar를 이용하면 함수 내에서 제너릭 타입의 의존관계를 나타낼 수 있습니다. 하지만 클래스를 선언할 때 타입이 결정되는 제너릭 클래스를 표현하기에는 부족합니다. 이 경우에는 typing.Generictyping.TypeVar를 함께 이용하면 됩니다.

# https://docs.python.org/3/library/typing.html#user-defined-generic-types

from typing import TypeVar, Generic
from logging import Logger

T = TypeVar('T')


class LoggedVar(Generic[T]):
    def __init__(self, value: T, name: str, logger: Logger) -> None:
        self.name = name
        self.logger = logger
        self.value = value

    def set(self, new: T) -> None:
        self.log('Set ' + repr(self.value))
        self.value = new

    def get(self) -> T:
        self.log('Get ' + repr(self.value))
        return self.value

    def log(self, message: str) -> None:
        self.logger.info('%s: %s', self.name, message)

위의 클래스의 경우 __init__ 매직메소드의 인자 value 타입에 의해, set 메소드의 인풋타입, 그리고 get 메소드의 리턴 타입이 결정됩니다. 즉, set메소드와 get메소드가 value 타입에 의존적이라고 할 수 있습니다. 이런 경우에 typing.Generic을 활용하여 제너릭 클래스를 정의하면 됩니다.

# https://docs.python.org/3/library/typing.html#user-defined-generic-types

from collections.abc import Iterable

def zero_all_vars(vars: Iterable[LoggedVar[int]]) -> None:
    for var in vars:
        var.set(0)

제너릭 클래스 뒤에 [<타입>]을 붙이면 특정 타입의 제너릭 클래스로 타입을 명시할 수 있습니다. 위의 코드는 vars 인자로 Iterable[LoggedVar[int]]만을 받을 수 있으며, Iterable[LoggedVar[str]]등은 타입 검사에 실패합니다.

참고

4. typing.ParamSpec

또한, Callable타입의 콜백 함수들을 인자로 가지는 경우에는 해당 콜백 함수의 인자의 타입에 이를 호출하는 함수가 의존하는 경우가 있습니다. 데코레이터가 대표적인데요. 이럴 때는 typing.ParamSpec을 사용해주실 수 있습니다. (이 역시 Generic 타입의 일종으로 볼 수 있습니다.)

from typing import TypeVar, Callable, ParamSpec
import logging


T = TypeVar('T')
P = ParamSpec('P')


def add_logging(f: Callable[P, T]) -> Callable[P, T]:
    '''A type-safe decorator to add logging to a function.'''
    def inner(*args: P.args, **kwargs: P.kwargs) -> T:
        logging.info(f'{f.__name__} was called')
        return f(*args, **kwargs)
    return inner


@add_logging
def add_two(x: float, y: float) -> float:
    '''Add two numbers together.'''
    return x + y


@add_logging
def send_msg(msg: str) -> None:
    print(f"I Sent {msg}")

위는 파이썬 공식문서의 예제를 살짝 변형한 것입니다. add_logging이라는 데코레이터는 호출되는 함수에 로그를 남길 수 있는 역할을 합니다. 데코레이터의 특성상 보통 내부 함수 (위의 코드에서 inner)는 콜백 함수와 같은 인자와 리턴 값을 가집니다. 이때 위에서 정의한 P.argsP.kwargs를 사용하면 이를 표현할 수 있습니다.

참고

  • 해당 타입은 파이썬 3.10부터 사용할 수 있습니다. 그 이전의 파이썬 버전에서 사용하기 위해서는 type-extensions 라이브러리를 설치해주셔야 합니다.
  • 데코레이터에 대해 잘 모르신다면 파이썬 코딩 도장의 데코레이터 섹션을 먼저 공부해주세요.

5. typing.Protocol

지금까지 살펴본 타입들은 모두 정적인 상태를 기준으로 타입을 정의했습니다 어떤 인자가 int, str 등의 타입인지 혹은 어떤 클래스 (제너릭 클래스)의 객체인지 등을 나타냈습니다. 하지만 '행위/책임'을 중심으로 타입을 정의할 수 있습니다. 프로그래밍에서 행위는 함수(메소드)로 나타낼 수 있으니 어떤 함수를 가지고 있는지 (즉, 어떤 행위/책임을 수행할 수 있는지)를 하나의 타입으로 바라보는 것입니다. golang, typescript, java 등의 언어에서는 이를 인터페이스(interface)라고 합니다.

예제를 통해 살펴보겠습니다.

from typing import Protocol


class Flyable(Protocol):
    def fly(self): ...


class Bird:
    def fly(self):
        print("Bird is flying")


class Plane:
    def fly(self):
        print("Plane is flying")


class Dog:
    def walk(self):
        print("Dog is walking")


def take_off(flyable: Flyable):
    flyable.fly()

위의 코드와 같이 Flyable 프로토콜(인터페이스)을 정의합니다. 특정 객체가 fly메소드를 가지고 있다면 해당 프로토콜을 만족하는 것이고, take_off 함수의 인자로 사용될 수 있습니다. 만약 fly 메소드를 가지고 있지 않다면 타입 검사에 실패합니다.

이에 따라 Bird, Plane 클래스는 해당 프로토콜을 준수하고, Dog 클래스는 해당 프로토콜을 준수하지 못합니다.

이처럼 프로토콜을 통해 파이썬에서도 다른 언어의 인터페이스와 유사한 형태를 보다 안전하게 사용할 수 있습니다.

우리가 사용하는 라이브러리에 타입이 없는 경우

물론 우리가 사용하는 라이브러리들이 모두 Typing이 잘 되어 있으면 좋겠지만 현실은 그렇지 않습니다. 파이썬은 역사가 굉장히 오래된 언어이며 긴 시간 동안 만들어진 대부분의 라이브러리들이 모두 Typing이 잘되어 있지는 않습니다. 이런 경우에는 에러를 어떻게 해결할 수 있을까요? 정답이라고는 볼 수 없지만, 제가 typing 작업을 하면서 느낀 것들을 공유해볼까 합니다.

  1. type stub 생성하기

pyright에서는 타입이 명시가 되지 않은 라이브러리에 대해서 stub을 생성해서 별도로 타입을 명시해줄 수 있습니다. 아래의 명령어를 입력하면 우리가 설정한 path에 pyi파일이 생성되게 되고 필요한 부분에 타입을 명시해주면 됩니다.

pyright --createstub django

2, 3번 방법을 사용하지 않고도 typing 에러를 무시할 수 있지만, 가장 시간과 비용이 많이 드는 작업입니다. 개인적으로는 이렇게 명시하는 것이 파이썬의 빠른 프로그래밍/프로토타이핑의 장점을 많이 훼손하기 때문에 그렇게 좋아하지는 않는 방법입니다.

  1. 해당 부분 타입 무시하기 (# type: ignore)

에러가 난 부분을 무시하는 방법입니다. 타입 검사에 실패하는 부분의 타입을 명시할 수 없는 경우에 # type: ignore라는 주석을 추가합니다. (pyright, mypy 동일) 잘못된 타입을 명시하거나 명시해야할 타입을 무시할 리스크도 존재합니다.

  1. Any 타입 사용하기

가장 지양하고 있는 방법입니다. Any 타입을 한 번 쓰게 되면 다른 곳에도 영향을 줘 Any 지옥에 빠지게 될 확률이 높습니다.

현재 많은 라이브러리들이 버전업 되면서 타입 힌트를 도입하고 있습니다. 대표적으로 pandas도 1.1.0부터 typing이 도입되었고 sqlalchemy도 배포는 되지 않았지만 타입힌트 작업이 활발히 진행되고 있습니다. 시간이 지나면서 해당 이슈는 점차 줄어들 것으로 보입니다.

마치며

지난 포스트에 이어 이번 포스트까지 파이썬의 타입 힌트에 대해서 알아보았습니다. 설명을 최대한 잘해보려고 했지만 생각만큼 쉽지 않았던 것 같습니다. 제 설명이 부족한 부분은 공식문서를 참고해주시면 감사하겠습니다. 읽어주셔서 감사합니다.

오타나 잘못된 내용에 대한 피드백은 언제나 환영입니다. :)

참고자료

반응형