TypeVar 파헤치기

class typing.TypeVar(name, *constraints, bound=None, covariant=False, contravariant=False)

- TypeVar는 어떨 때 사용하는지, 각 파라미터가 무슨 역할을 하는지 알아보자

왜 사용해?

def repeat(x: str, n: int) -> list[str]:
    """Return a list containing n references to x."""
    return [x] * n

- 위와 같은 함수를 고려해보자

- 입력으로 x가 들어오면 xn개 담은 리스트를 반환한다 (참고로 참조라 원소의 id는 동일함)

- 이 때 x의 타입은 str이길 기대한다

- 물론 str이 아니어도 함수는 문제없이 작동하긴 한다

- 그런데 만약 x의 타입으로 int도 가능하게 하고 싶으면 어떻게 해야 할까?

def repeat(x: str | int, n: int) -> list[str | int]:
    """Return a list containing n references to x."""
    return [x] * n

- 위와 같이 | 또는 typing.Union[str, int]를 사용해 나타내는 걸 생각해볼 수 있다

- 그런데 이러한 표기법엔 조금의 문제가 존재한다

- x 타입이 str이라면 반환 타입은 당연히 list[str]여야 될 것 같지만 list[int]여도 문제 없다 (mypy같은 타입 검사기에 오류가 발생하지 않음)

- 표기법의 의미 그대로 str 또는 int이기 때문이다

- 물론 위 함수는 동작상 반환 값의 타입이 list[x]의 타입을 따라가지만 다른 경우엔 문제가 될 수 있다

def add(a: str | int, b: str | int) -> str | int:
    return a + b

- 위의 add 함수를 고려하자

- a, b, 반환 값의 타입 모두 str이거나 int이라는 첫 번째 경우의 의도와 같이 작성한 것이지만

- 실제론 a와 b의 타입이 달라도 타입 검사기에 문제가 생기지 않는다

- 하지만 둘의 타입이 다르면 실행 시 오류가 발생한다

- 즉, a의 타입이 str이면 b도 str이면 좋겠고 a가 int라면 b도 int이면 좋겠다

- 이러한 소망은 Union을 사용해선 이룰 수 없다

- 이를 가능하게 하는 것이 TypeVar이다

들어가기에 앞서

서브 타입에 대해 알아보자

def greeting(name: str) -> str:
    return "Hello " + name


class MyStr(str): ...


greeting("abc")  # Possible
greeting(MyStr("abc"))  # Also possible

- 기본적으로 특정 타입의 서브 타입도 허용된다 (name은 str 타입뿐만 아니라 str의 서브 타입도 가질 수 있다)

- 그렇다면 서브 타입이란게 정확히 무엇일까?

- first_varfirst_type 타입을 가지고 second_varsecond_type 타입을 가진다고 해보자

- first_varsecond_var를 할당해도 문제가 없을까? (즉, first_var = second_var가 문제 없이 가능한가?)

- 다음 두 조건을 만족하면 문제 없이 가능하다고 하자

1. second_type의 모든 값은 first_type의 값 집합에도 존재한다

2. first_type의 모든 함수는 second_type의 함수 집합에도 존재한다

- 이 두 조건을 만족한다면 second_typefirst_type의 서브 타입으로 정의된다

- 위 두 조건에 의해 아래 두 문장이 성립한다

1. 모든 타입은 자기 자신의 서브 타입이다

2. 값 집합은 서브 타입으로 대체하는 과정에서 더 작아지지만 함수 집합은 더 커진다

- 직관적인 예시로 Dog 클래스와 Animal 클래스를 생각해보자

- 모든 Dog는 Animal이므로 당연하게도 Dog는 Animal보다 더 많은 함수를 가지고 있다 (2번째 정의)

- 예컨대 Dog가 bark 함수를 가지고 있을 수 있는데 Animal은 bark 함수를 가지고 있지 않다 (모든 Animal이 짖을 수 있는 건 아니다)

  • 또 다른 예시

- intfloat의 서브 타입이다 (직관적으로 당연해 보인다)

- 모든 정수는 실수에 속하며 더욱 많은 연산자를 지원한다 (예컨대 비트 쉬프트 연산이 있다)

lucky_number = 3.14    # type: float
lucky_number = 42      # Safe
lucky_number * 2       # This works
lucky_number << 5      # Fails

unlucky_number = 13    # type: int
unlucky_number << 5    # This works
unlucky_number = 2.72  # Unsafe

- lucky_number는 float 타입이다

- int는 float의 서브 타입이므로 할당 가능하다

- 하지만 float 타입 변수에는 int 타입 변수에만 적용할 수 있는 비트 쉬프트 연산을 적용할 수 없다

- unluck_number는 int 타입이다

- 비트 쉬프트 연산은 당연히 작동한다

- 하지만 float은 int의 서브 타입이 아니므로 값을 할당할 수 없다

  • 또 다른 예시 2
def append_pi(lst: list[float]) -> None:
    lst += [3.14]


my_list = [1, 3, 5]  # type: list[int]
append_pi(my_list)   # Naively, this should be safe...
my_list[-1] << 5     # ... but this fails

- intfloat의 서브 타입이지만 list[int]list[float]의 서브 타입이 아니다

- list[int]list[float]의 서브 타입이려면 위에서 언급했던 두 조건을 만족해야 한다

- list[float]로부터 파생될 수 있는 모든 값은 list[int]를 포함하므로 첫 번째 조건은 만족한다

- 하지만 append_pi 함수에 list[int]를 인자로 전달하면 기존의 가능한 연산을 적용할 수 없다

- my_list의 타입은 list[int]이므로 각 원소에 대해 비트 연산이 가능해야 한다

- 하지만 append_pi 함수로 $\pi$를 마지막 원소에 추가하면 해당 원소에 대해 비트 연산이 불가능해진다

- 즉, 원해 가능한 연산자를 적용하지 못해 함수 집합이 오히려 더 작아졌으므로 두 번째 조건을 만족하지 못한다

기본 용법

  • 첫 번째 예시
T = TypeVar("T")  # Can be anything


def repeat(x: T, n: int) -> list[T]:
    return [x] * n


def multiply(x: T, n: int) -> T:
    return x * n

- T = TypeVar("T")와 같이 변수명과 name 인자명을 동일하게 작성해야 한다

- 이제부터 T는 함수 내에서 임의의 타입을 의미한다

- 만약 repeat 함수에서 x의 타입이 int라면 반환값의 타입은 list[int]이다

- 참고로 T의 타입은 동일한 함수내에서만 일치하면 된다

- repeat 함수에서 T의 타입으로 int를 사용했다고 multiply 함수에서도 int로만 사용해야 된다는 것은 아니다

- 그런데 모든 타입이 가능하게 하기 보단 특정 타입만 가능하게 하고 싶을 수 있다

- 이런 경우엔 제한하고 싶은 타입을 *constraints로 전달하면 된다

  • 두 번째 예시
A = TypeVar("A", str, bytes)  # Must be exactly str or bytes


class MyStr(str):
    ...


def concatenate(x: A, y: A) -> A:
    return x + y


def do_nothing(x: A) -> A:
    return x


res1 = concatenate("a", "b")  # Type of res1 is str
res2 = concatenate(MyStr("a"), "b")  # Type of res2 is str
res3 = concatenate(b"a", b"b")  # Type of res3 is bytes
res4 = concatenate("a", b"b")  # Error, type variable "A" can not be object
res5 = do_nothing(MyStr("ab"))
reveal_type(res5)  # Type of res5 is str

- A = TypeVar("A", str, bytes)라면 A의 타입으론 str 또는 bytes만 가능하다

- 여기서 Must be exactly str or bytes란 표현이 오해를 불러올 수 있다

- 파이썬은 기본적으로 서브 타입을 허용한다

- 즉, A 타입을 가지는 변수에 str의 서브 타입인 MyStr을 대입해도 문제없다

- 대신 str의 서브 타입인 MyStr을 사용하더라도 타입 검사기는 해당 변수의 타입이 MyStr이 아닌 str인 것으로 간주한다

- res5 = do_nothing(MyStr("ab"))의 경우 res5의 타입을 검사하면 MyStr여야 될 것 같지만 실제로는 str이다

- concatenate("a", b"b")는 오류를 발생시킨다

- 왜냐하면 str과 bytes의 공통 슈퍼 타입은 object인데 TypeVar를 사용해 str과 bytes만 가능하도록 제한을 걸었기 때문이다

- 한편, 제한하는 타입은 2개 이상부터 가능하다 (예컨대 S = TypeVar("S", str)과 같이 할 수 없다)

  • 세 번째 예시
S = TypeVar("S", bound=str)  # Can be any subtype of str


def print_capitalized(x: S) -> S:
    print(x.capitalize())
    return x


res1 = print_capitalized(MyStr("abc"))  # Type of res1 is MyStr

- 대신 bound를 사용할 수 있다

- S = TypeVar("S", bound=str)와 같이 할당하면 S는 타입으로 str 또는 str의 sub type을 취할 수 있다

- constraints와 달리 bound는 서브 타입을 허용한다

- res1 = print_capitalized(MyStr("abc"))에서 타입 검사기로 res1의 타입을 검사하면 str이 아닌 MyStr이다

- 하위 타입 말고 정확히 str 타입으로 강제하는 것은 불가능하다 (이런 경우라면 런타입에서 타입이 str인지 검사하자)

  • 네 번째 예시
T = TypeVar("T")  # Can be anything


class UserID(int):
    ...


def do_nothing(one_arg: T, other_arg: T) -> None:
    pass


do_nothing(1, 2)               # Ok, T is int
do_nothing("abc", UserID(42))  # Also Ok, T is object


def do_something1(one_arg: T, other_arg: T) -> None:  # Error
    print(one_arg.jungsanghwa())
    print(other_arg.letsgo())


def do_something2(one_arg: Any, other_arg: Any) -> None:  # OK
    print(one_arg.jungsanghwa())
    print(other_arg.letsgo())

- T = TypeVar("T")는 정말 모든 것이 될 수 있을 것 같지만 그렇지는 않다

covariant, contravariant

- covariant (공변성): 타입이 상속 계층을 따라 동일한 방향으로 변경된다

- contravariant (반공변성): 타입이 상속 계층을 따라 반대 방향으로 변경된다

- 위 두 파라미터를 사용한 타입 변수는 단독으로는 사용 불가능하고 list와 같은 제네릭 타입에만 사용할 수 있다

- 위 두 파라미터는 타입 변수의 파라미터가 아니라 제네릭의 파라미터이다

- 그렇기 때문에 일반 함수에는 사용 불가능하다

T_co = TypeVar("T_co", covariant=True)


def f(x: list[T_co]) -> T_co:  # Error
    return x[0]
class Animal:
    def speak(self) -> str:
        return "Animal sound."


class Dog(Animal):
    def speak(self) -> str:
        return "Woof!"


class Cat(Animal):
    def speak(self) -> str:
        return "Meow!"


class SuperCutyCat(Cat):
    def speak(self) -> str:
        return "Purr! I'm super cute!"

covariant

- 공변성은 상위 타입이 필요한 곳에 하위 타입을 사용할 수 있음을 의미한다

from typing import TypeVar, Generic

Animal_co = TypeVar("Animal_co", bound=Animal, covariant=True)


class AnimalContainer(Generic[Animal_co]):
    def __init__(self, animal: Animal_co) -> None:
        self.animal = animal
    
    def get_animal(self) -> Animal_co:
        return self.animal
dog_container = AnimalContainer(Dog())
cat_container = AnimalContainer(Cat())

# Covariance allows assigning AnimalContainer[Dog] to AnimalContainer[Animal]
animal_container: AnimalContainer[Animal] = dog_container
print(animal_container.get_animal().speak())  # Output: "Woof!"
Woof!

- AnimalContainer[Animal]AnimalContainer[Dog] 또는 AnimalContainer[Cat]도 대입할 수 있다

- AnimalContainer는 공변성을 가지므로 AnimalContainer[Dog]AnimalContainer[Cat]AnimalContainer[Animal]의 서브 타입이기 때문이다

contravariant

- 반공변성은 상위 타입을 필요로 하는 곳에 하위 타입을 사용할 수 없다는 것을 의미한다

- 대신, 하위 타입이 필요한 곳에 상위 타입을 사용할 수 있다.

Animal_contra = TypeVar("Animal_contra", bound=Animal, contravariant=True)


class AnimalHandler(Generic[Animal_contra]):
    def handle(self, animal: Animal_contra) -> None:
        print(f"Handling {animal.speak()}")


# Create an instance of a handler that can handle any Animal
wrong_animal_handler: AnimalHandler[Cat] = AnimalHandler[SuperCutyCat]()  # Error
animal_handler: AnimalHandler[Cat] = AnimalHandler[Animal]()  # Contravariance allows this

# Handle a Cat
animal_handler.handle(Dog())  # Error, Dog is not a subtype of Cat
animal_handler.handle(Animal())  # Error, Animal is not a subtype of Cat
animal_handler.handle(Cat())  # Output: "Woof!"
animal_handler.handle(SuperCutyCat())  # Output: "Purr! I'm super cute!"
Handling Woof!
Handling Animal sound.
Handling Meow!
Handling Purr! I'm super cute!

- AnimalHandler[Cat]AnimalHandler[Animal]도 대입할 수 있다

- 하지만 AnimalHandler[Cat]AnimalHandler[SuperCutyCat]을 대입할 수는 없다

- AnimalHandler는 반공변성을 가지므로 AnimalHandler[SuperCutyCat]AnimalHandler[Cat]의 서브 타입이 아니기 때문이다

부록

- int는 float의 서브 타입일 뿐 인스턴스는 아니다

x = 1
print(isinstance(x, float))
# False
False

- 참고로 bool은 int의 인스턴스이다 (True는 1, False는 0)

x = True
print(isinstance(x, int))
# True
True

- bool은 int를 상속 받은 클래스이므로 당연하게도 int의 서브 타입이다

- int는 float의 서브 타입이고 bool은 int의 서브 타입이므로 bool은 float의 서브 타입이다