Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

PEP 483 – 타입 힌트의 이론

Author:
Guido van Rossum <guido at python.org>, Ivan Levkivskyi <levkivskyi at gmail.com>
Discussions-To:
Python-Ideas list
Status:
Final
Type:
Informational
Topic:
Typing
Created:
19-Dec-2014
Post-History:


Table of Contents

번역·라이선스 안내

이 문서는 Open Publication License v1.0 이상에 따라 만든 수정된 한국어 번역본입니다. 수정자: yeokja/yeokja 프로젝트. 수정일: 2026-08-29. 변경 내용: 영어 원문을 한국어로 번역했습니다. 원저자와 저작권 표시는 위 Author 필드와 아래 Copyright 절에 유지했으며, 이 번역은 원저자의 승인이나 보증을 뜻하지 않습니다. 수정되지 않은 기준 원문 · 공식 최신판 · Open Publication License v1.0

초록

이 PEP는 PEP 484에서 참조하는 이론을 설명합니다.

소개

이 문서는 Python 3.5를 위한 새로운 타입 힌트 제안의 이론을 설명합니다. 해결해야 할 세부 사항이 많기 때문에 완전한 제안이나 명세라고 하기는 어렵습니다. 하지만 이 문서가 설명하는 이론이 없으면 더 자세한 명세를 논의하기 어렵습니다. 먼저 타입 이론의 기본 개념을 짚어 보고, 점진적 타이핑(gradual typing)을 설명합니다. 이어서 몇 가지 일반 규칙을 제시하고 어노테이션에 사용할 수 있는 새로운 특수 타입(Union 등)을 정의합니다. 마지막으로 제네릭 타입에 대한 접근 방식과 타입 힌트의 실용적인 측면을 정의합니다.

표기 규약

  • t1, t2 등과 u1, u2 등은 타입입니다. “t1, t2 등 중 아무것이나”를 가리킬 때는 ti 또는 tj로 쓰기도 합니다.
  • T, U 등은 타입 변수입니다(TypeVar()로 정의하며, 아래를 참조하십시오).
  • 객체, class 문으로 정의한 클래스, 인스턴스는 표준 PEP 8 규약에 따라 표기합니다.
  • 이 PEP에서 타입에 == 기호를 쓰면 두 표현식이 같은 타입을 나타낸다는 뜻입니다.
  • PEP 484는 타입과 클래스를 구분한다는 점에 유의하십시오(타입은 타입 검사기의 개념이고, 클래스는 런타임 개념입니다). 이 PEP에서는 이 구분을 명확히 합니다. 다만 타입 검사기를 더 유연하게 구현할 수 있도록 불필요하게 엄격한 규정은 피합니다.

배경

문헌에는 타입이라는 개념에 대한 정의가 많이 있습니다. 여기서는 타입을 값의 집합과 그 값에 적용할 수 있는 함수의 집합으로 봅니다.

특정 타입을 정의하는 방법에는 여러 가지가 있습니다:

  • 모든 값을 명시적으로 나열하는 방법입니다. 예를 들어, True와 False는 bool 타입을 이룹니다.
  • 어떤 타입의 변수에 사용할 수 있는 함수를 지정하는 방법입니다. 예를 들어, __len__ 메서드를 가진 모든 객체는 Sized 타입을 이룹니다. [1, 2, 3]과 'abc' 모두 len을 호출할 수 있으므로 이 타입에 속합니다:
    len([1, 2, 3])  # OK
    len('abc')      # also OK
    len(42)         # not a member of Sized
    
  • 단순한 클래스 정의를 이용하는 방법입니다. 예를 들어 다음과 같이 클래스를 정의하면:
    class UserID(int):
        pass
    

    이 클래스의 모든 인스턴스도 하나의 타입을 이룹니다.

  • 더 복잡한 타입도 있습니다. 예를 들어, int, str 또는 그 서브클래스의 인스턴스만 담은 모든 리스트를 FancyList 타입으로 정의할 수 있습니다. [1, 'abc', UserID(42)] 값은 이 타입에 속합니다.

사용자가 타입 검사기가 이해할 수 있는 형식으로 타입을 정의할 수 있는 것이 중요합니다. 이 PEP의 목표는 PEP 3107 문법을 사용하여 변수와 함수의 타입 어노테이션에 쓰일 타입을 체계적으로 정의하는 방법을 제안하는 것입니다. 이러한 어노테이션은 여러 종류의 버그를 방지하는 데 사용할 수 있고, 문서화 목적으로도 사용할 수 있으며, 어쩌면 프로그램 실행 속도를 높이는 데에도 사용할 수 있습니다. 여기서는 정적 타입 검사기를 사용하여 버그를 방지하는 것에만 초점을 맞춥니다.

서브타입 관계

정적 타입 검사기에서 매우 중요한 개념은 서브타입 관계입니다. 이 개념은 다음 질문에서 비롯됩니다. first_var가 first_type 타입이고 second_var가 second_type 타입일 때, first_var = second_var로 대입하는 것이 안전합니까?

대입이 안전해야 하는 경우를 판단하는 강력한 기준은 다음과 같습니다.

  • second_type의 모든 값이 first_type의 값 집합에도 속하고,
  • first_type의 모든 함수가 second_type의 함수 집합에도 속합니다.

이와 같이 정의한 관계를 서브타입 관계라고 합니다.

이 정의에 따르면 다음과 같습니다.

  • 모든 타입은 자기 자신의 서브타입입니다.
  • 서브타이핑 과정에서 값의 집합은 작아지는 반면, 함수의 집합은 커집니다.

직관적인 예를 들어 보겠습니다. 모든 Dog는 Animal입니다. 또한 Dog에는 짖는 기능처럼 더 많은 함수가 있습니다. 따라서 Dog는 Animal의 서브타입입니다. 반대로 Animal은 Dog의 서브타입이 아닙니다.

좀 더 형식적인 예를 들어 보겠습니다. 정수는 실수의 서브타입입니다. 실제로 모든 정수는 당연히 실수이기도 하며, 정수는 비트 시프트 <<와 >> 같은 더 많은 연산을 지원합니다.:

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

까다로운 예도 살펴봅시다. List[int]가 정수만 담은 모든 리스트로 이루어진 타입을 나타낸다면, 이 타입은 실수만 담은 모든 리스트로 이루어진 List[float]의 서브타입이 아닙니다. 서브타이핑의 첫 번째 조건은 성립하지만, 실수를 추가하는 작업은 List[float]에서만 가능하므로 두 번째 조건은 성립하지 않습니다.:

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

타입 검사기에 서브타입 정보를 선언하는 방식으로는 널리 쓰이는 두 가지가 있습니다.

명목적 서브타이핑에서는 타입 트리가 클래스 트리를 기반으로 합니다. 즉, UserID는 int의 서브타입으로 간주됩니다. Python에서는 속성을 호환되지 않는 방식으로 재정의할 수 있으므로, 이 방식은 타입 검사기의 통제 아래에서 사용해야 합니다.:

class Base:
    answer = '42' # type: str

class Derived(Base):
    answer = 5 # should be marked as error by type checker

구조적 서브타이핑에서는 선언된 메서드로부터 서브타입 관계를 추론합니다. 즉, UserID와 int는 같은 타입으로 간주됩니다. 이 방식은 때때로 혼란을 일으킬 수 있지만, 구조적 서브타이핑이 더 유연하다고 여겨집니다. 저희는 명목적 서브타이핑과 함께 구조적 정보도 사용할 수 있도록 두 방식을 모두 지원하고자 합니다.

점진적 타이핑 요약

점진적 타이핑을 사용하면 프로그램의 일부에만 어노테이션을 달 수 있으므로, 동적 타이핑과 정적 타이핑의 바람직한 측면을 모두 활용할 수 있습니다.

저희는 is-consistent-with라는 새로운 관계를 정의합니다. 이 관계는 is-subtype-of와 비슷하지만, 새로운 타입 Any가 관련되면 추이적이지 않다는 점이 다릅니다. (두 관계 모두 대칭적이지 않습니다.) a_value의 타입이 a_variable의 타입과 일관되면 a_value를 a_variable에 대입해도 됩니다. (객체 지향 프로그래밍의 기본 원칙 중 하나를 나타내는 “… a_value의 타입이 a_variable의 타입의 서브타입이면”과 비교해 보십시오.) is-consistent-with 관계는 다음 세 가지 규칙으로 정의됩니다.

  • t1이 t2의 서브타입이면 타입 t1은 타입 t2와 일관됩니다. (그러나 그 반대는 성립하지 않습니다.)
  • Any는 모든 타입과 일관됩니다. (그러나 Any가 모든 타입의 서브타입인 것은 아닙니다.)
  • 모든 타입은 Any와 일관됩니다. (그러나 모든 타입이 Any의 서브타입인 것은 아닙니다.)

이것이 전부입니다! 더 자세한 설명과 동기는 Jeremy Siek의 블로그 글 What is Gradual Typing을 참고하십시오. Any는 모든 값과 모든 메서드를 가진 타입으로 볼 수 있습니다. 이를 위의 서브타이핑 정의와 결합하면, Any는 타입 계층의 최상위(모든 값을 가짐)와 최하위(모든 메서드를 가짐)에 부분적으로 놓이게 됩니다. 이를 object와 대조해 보십시오. object는 대부분의 타입과 일관되지 않습니다(예를 들어 int가 기대되는 곳에 object() 인스턴스를 사용할 수 없습니다). 다시 말해 Any와 object는 인자에 어노테이션을 달 때 모두 “어떤 타입이든 허용됨”을 의미합니다. 하지만 어떤 타입이 기대되든 상관없이 전달될 수 있는 것은 Any뿐입니다(본질적으로 Any는 동적 타이핑으로의 대체를 선언하고 정적 검사기의 불만을 잠재웁니다).

다음은 이 규칙들이 실제로 어떻게 작동하는지 보여 주는 예입니다:

Employee 클래스와 그 서브클래스 Manager가 있다고 합시다.:

class Employee: ...
class Manager(Employee): ...

변수 worker가 Employee 타입으로 선언되었다고 합시다.:

worker = Employee()  # type: Employee

이제 Manager 인스턴스를 worker에 할당해도 됩니다(규칙 1).:

worker = Manager()

Employee 인스턴스를 Manager 타입으로 선언된 변수에 할당하는 것은 허용되지 않습니다.:

boss = Manager()  # type: Manager
boss = Employee()  # Fails static check

하지만 타입이 Any인 변수가 있다고 가정합시다.:

something = some_func()  # type: Any

이제 something을 worker에 할당해도 됩니다(규칙 2).:

worker = something  # OK

물론 worker를 something에 할당하는 것도 괜찮지만(규칙 3), 이를 위해 일관성 개념이 필요하지는 않았습니다.:

something = worker  # OK

타입 대 클래스

Python에서 클래스는 class 문으로 정의되고 type(obj) 내장 함수가 반환하는 객체 팩토리입니다. 클래스는 동적인 런타임 개념입니다.

타입 개념은 위에서 설명했습니다. 타입은 변수와 함수의 타입 어노테이션에 나타나고, 아래에서 설명하는 기본 구성 요소로 만들 수 있으며, 정적 타입 검사기가 사용합니다.

위에서 논의한 것처럼 모든 클래스는 타입입니다. 그러나 주어진 타입의 의미를 정확하게 나타내는 클래스를 구현하는 것은 까다롭고 오류가 발생하기 쉬우며, 이는 PEP 484의 목표가 아닙니다. 여기서 말하는 정적 타입은 PEP 484에서 설명하는 타입이며, 런타임 클래스와 혼동해서는 안 됩니다. 예시는 다음과 같습니다:

  • int는 클래스이자 타입입니다.
  • UserID는 클래스이자 타입입니다.
  • Union[str, int]는 타입이지만 제대로 된 클래스는 아닙니다.:
    class MyUnion(Union[str, int]): ...  # raises TypeError
    
    Union[str, int]()  # raises TypeError
    

타이핑 인터페이스는 클래스로 구현되어 있습니다. 즉, 런타임에 예를 들어 Generic[T].__bases__를 평가할 수 있습니다. 하지만 클래스와 타입의 구별을 강조하기 위해 다음과 같은 일반 규칙이 적용됩니다:

  • 아래에서 정의하는 타입(즉, Any, Union 등)은 어느 것도 인스턴스화할 수 없으며, 그렇게 하려고 하면 TypeError가 발생합니다. (하지만 Generic의 추상이 아닌 서브클래스는 인스턴스화할 수 있습니다.)
  • Generic과 그로부터 파생된 클래스를 제외하고, 아래에서 정의하는 타입은 어느 것도 서브클래스를 만들 수 없습니다.
  • 이들은 모두 isinstance나 issubclass에 나타나면 TypeError를 발생시킵니다(매개변수화되지 않은 제네릭은 예외입니다).

기본 구성 요소

  • Any. 모든 타입은 Any와 일관되며, Any 역시 모든 타입과 일관됩니다(위 참조).
  • Union[t1, t2, …]. t1 등 중 적어도 하나의 서브타입인 타입은 이 타입의 서브타입입니다.
    • 구성 요소가 모두 t1 등의 서브타입인 유니언은 이것의 서브타입입니다. 예: Union[int, str]는 Union[int, float, str]의 서브타입입니다.
    • 인자의 순서는 중요하지 않습니다. 예: Union[int, str] == Union[str, int]입니다.
    • ti 자체가 Union이면 결과는 평탄화됩니다. 예: Union[int, Union[float, str]] == Union[int, float, str]입니다.
    • ti와 tj가 서브타입 관계에 있으면 덜 구체적인 타입이 남습니다. 예: Union[Employee, Manager] == Union[Employee]입니다.
    • Union[t1]은 그냥 t1을 반환합니다. Union[]은 허용되지 않으며, Union[()]도 마찬가지입니다.
    • 따름정리: Union[..., object, ...]는 object를 반환합니다.
  • Optional[t1]. Union[t1, None], 즉 Union[t1, type(None)]의 별칭입니다.
  • Tuple[t1, t2, …, tn](튜플). 항목이 t1 등의 인스턴스인 튜플입니다. 예: Tuple[int, float]는 항목이 두 개인 튜플로, 첫 번째 항목은 int이고 두 번째 항목은 float임을 뜻합니다. 예를 들면 (42, 3.14)입니다.
    • 길이가 n==m으로 같고 각 ui가 ti의 서브타입이면 Tuple[u1, u2, ..., um]은 Tuple[t1, t2, ..., tn]의 서브타입입니다.
    • 빈 튜플의 타입을 표기하려면 Tuple[()]를 사용하십시오.
    • 가변 길이 동종 튜플 타입은 Tuple[t1, ...]로 작성할 수 있습니다. (점 세 개, 즉 리터럴 생략 부호입니다. 그리고 네, 이것은 Python 문법에서 유효한 토큰입니다.)
  • Callable[[t1, t2, …, tn], tr] (호출 가능 객체). 위치 인자의 타입이 t1 등이고 반환 타입이 tr인 함수입니다. 인자 목록은 비어 있을 수 있습니다(n==0). 선택적 인자나 키워드 인자, 가변 인자를 나타낼 방법은 없지만, Callable[..., tr]로 써서 인자 목록을 전혀 검사하지 않는다고 지정할 수 있습니다(이것도 리터럴 생략 부호입니다).

다음을 추가할 수도 있습니다:

  • Intersection[t1, t2, …]. t1 등 각각의 서브타입인 타입은 이것의 서브타입입니다. (정의에 각각 대신 적어도 하나가 들어 있는 Union과 비교해 보십시오.)
    • 인자의 순서는 중요하지 않습니다. 중첩된 인터섹션은 평탄화됩니다. 예: Intersection[int, Intersection[float, str]] == Intersection[int, float, str]입니다.
    • 더 적은 타입의 인터섹션은 더 많은 타입의 인터섹션의 슈퍼타입입니다. 예를 들어 Intersection[int, str]는 Intersection[int, float, str]의 슈퍼타입입니다.
    • 인자가 하나인 인터섹션은 그 인자 자체입니다. 예를 들어 Intersection[int]는 int입니다.
    • 인자들이 서브타입 관계에 있으면 더 구체적인 타입이 남습니다. 예를 들어 Intersection[str, Employee, Manager]는 Intersection[str, Manager]입니다.
    • Intersection[]은 허용되지 않으며, Intersection[()]도 마찬가지입니다.
    • 따름정리: Any는 인자 목록에서 사라집니다. 예: Intersection[int, str, Any] == Intersection[int, str]입니다. Intersection[Any, object]는 object입니다.
    • Intersection과 Union 사이의 상호작용은 복잡하지만, 일반 집합의 교집합과 합집합 사이의 상호작용을 이해한다면 놀랄 일은 아닐 것입니다(새 서브클래스의 수에는 제한이 없으므로 타입의 집합은 크기가 무한할 수 있다는 점에 유의하십시오).

제네릭 타입

앞에서 정의한 기본 구성 요소를 사용하면 제네릭한 방식으로 새로운 타입을 구성할 수 있습니다. 예를 들어, Tuple은 구체 타입 float을 받아 구체 타입 Vector = Tuple[float, ...]를 만들 수도 있고, 다른 타입 UserID를 받아 또 다른 구체 타입 Registry = Tuple[UserID, ...]를 만들 수도 있습니다. 이러한 의미 체계를 제네릭 타입 생성자라고 합니다. 이는 함수의 의미 체계와 비슷하지만, 함수는 값을 받아 값을 반환하는 반면 제네릭 타입 생성자는 타입을 받아 타입을 “반환”합니다.

특정 클래스나 함수가 이처럼 타입 제네릭한 방식으로 동작하는 경우는 흔합니다. 두 가지 예를 살펴보겠습니다:

  • list나 dict와 같은 컨테이너 클래스는 일반적으로 특정 타입의 값만 담습니다. 따라서 사용자는 이들에 그에 맞는 타입 어노테이션을 달고 싶을 수 있습니다:
    users = [] # type: List[UserID]
    users.append(UserID(42)) # OK
    users.append('Some guy') # Should be rejected by the type checker
    
    examples = {} # type: Dict[str, Any]
    examples['first example'] = object() # OK
    examples[2] = None                   # rejected by the type checker
    
  • 다음 함수는 int 타입의 인자 두 개를 받아 int를 반환하거나, float 타입의 인자 두 개를 받아 float를 반환하는 등의 동작을 할 수 있습니다.:
    def add(x, y):
        return x + y
    
    add(1, 2) == 3
    add('1', '2') == '12'
    add(2.7, 3.5) == 6.2
    

첫 번째 예와 같은 상황에서 타입 어노테이션을 사용할 수 있도록, 내장 컨테이너와 컨테이너 추상 베이스 클래스는 타입 매개변수로 확장되어 제네릭 타입 생성자로 동작합니다. 제네릭 타입 생성자로 동작하는 클래스를 제네릭 타입이라고 합니다. 예시:

from typing import Iterable

class Task:
    ...

def work(todo_list: Iterable[Task]) -> None:
    ...

여기서 Iterable은 구체 타입 Task를 받아 구체 타입 Iterable[Task]를 반환하는 제네릭 타입입니다.

(두 번째 예처럼) 타입 제네릭한 방식으로 동작하는 함수를 제네릭 함수라고 합니다. 제네릭 함수의 타입 어노테이션은 타입 변수를 통해 가능합니다. 제네릭 타입에 대한 타입 변수의 의미 체계는 함수에서 매개변수가 갖는 의미 체계와 어느 정도 비슷합니다. 하지만 타입 변수에 구체 타입을 직접 할당하지는 않습니다. 타입 변수가 가질 수 있는 값을 찾아내고, 찾지 못하면 사용자에게 경고하는 것은 정적 타입 검사기의 역할입니다. 예시:

def take_first(seq: Sequence[T]) -> T: # a generic function
    return seq[0]

accumulator = 0 # type: int

accumulator += take_first([1, 2, 3])   # Safe, T deduced to be int
accumulator += take_first((2.7, 3.5))  # Unsafe

타입 변수는 타입 어노테이션에서 폭넓게 사용되며, 타입 검사기의 타입 추론 내부 메커니즘도 일반적으로 타입 변수를 기반으로 만들어집니다. 따라서 타입 변수를 자세히 살펴봅시다.

타입 변수

X = TypeVar('X')는 고유한 타입 변수를 선언합니다. 이름은 변수 이름과 일치해야 합니다. 기본적으로 타입 변수는 가능한 모든 타입을 범위로 가집니다. 예시:

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

Y = TypeVar('Y', t1, t2, ...). 위와 같지만 t1 등으로 제약됩니다. Union[t1, t2, ...]와 비슷하게 동작합니다. 제약된 타입 변수는 제약 조건 t1 등만을 정확히 범위로 가집니다. 제약 조건의 서브클래스는 t1 등 가운데 가장 많이 파생된 베이스 클래스로 대체됩니다. 예제:

  • 제약된 타입 변수를 사용한 함수 타입 어노테이션:
    AnyStr = TypeVar('AnyStr', str, bytes)
    
    def longest(first: AnyStr, second: AnyStr) -> AnyStr:
        return first if len(first) >= len(second) else second
    
    result = longest('a', 'abc')  # The inferred type for result is str
    
    result = longest('a', b'abc')  # Fails static type check
    

    이 예제에서 longest()에 전달되는 두 인자는 반드시 같은 타입(str 또는 bytes)이어야 하며, 더 나아가 인자들이 공통 str 서브클래스의 인스턴스이더라도 반환 타입은 그 서브클래스가 아니라 여전히 str입니다(다음 예제를 참고하십시오).

  • 비교를 위해 말씀드리면, 타입 변수에 제약이 없다면 공통 서브클래스가 반환 타입으로 선택됩니다. 예를 들면 다음과 같습니다.:
    S = TypeVar('S')
    
    def longest(first: S, second: S) -> S:
        return first if len(first) >= len(second) else second
    
    class MyStr(str): ...
    
    result = longest(MyStr('a'), MyStr('abc'))
    

    result의 추론된 타입은 MyStr입니다(반면 AnyStr 예제에서는 str이 됩니다).

  • 또한 비교하자면, Union을 사용하는 경우 반환 타입 역시 Union이어야 합니다:
    U = Union[str, bytes]
    
    def longest(first: U, second: U) -> U:
        return first if len(first) >= len(second) else second
    
    result = longest('a', 'abc')
    

    두 인자가 모두 str인데도 result의 추론된 타입은 여전히 Union[str, bytes]입니다.

    타입 검사기는 이 함수를 거부한다는 점에 유의하십시오:

    def concat(first: U, second: U) -> U:
        return first + second  # Error: can't concatenate str and bytes
    

    이처럼 매개변수들의 타입이 동시에만 바뀔 수 있는 경우에는 제약된 타입 변수를 사용해야 합니다.

제네릭 타입의 정의와 사용

사용자는 특별한 구성 요소인 Generic을 사용하여 자신의 클래스를 제네릭 타입으로 선언할 수 있습니다. class MyGeneric(Generic[X, Y, ...]): ... 정의는 타입 변수 X 등에 대한 제네릭 타입 MyGeneric을 정의합니다. MyGeneric 자체는 매개변수화할 수 있게 되며, 예를 들어 MyGeneric[int, str, ...]는 X -> int 등의 치환이 적용된 구체적인 타입입니다. 예시:

class CustomQueue(Generic[T]):

    def put(self, task: T) -> None:
        ...
    def get(self) -> T:
        ...

def communicate(queue: CustomQueue[str]) -> Optional[str]:
    ...

제네릭 타입에서 파생된 클래스는 제네릭이 됩니다. 클래스는 여러 제네릭 타입을 서브클래스로 삼을 수 있습니다. 그러나 제네릭이 반환한 구체적인 타입에서 파생된 클래스는 제네릭이 아닙니다. 예시:

class TodoList(Iterable[T], Container[T]):
    def check(self, item: T) -> None:
        ...

def check_all(todo: TodoList[T]) -> None:  # TodoList is generic
    ...

class URLList(Iterable[bytes]):
    def scrape_all(self) -> None:
        ...

def search(urls: URLList) -> Optional[bytes]  # URLList is not generic
    ...

제네릭 타입을 서브클래싱하면 해당하는 구체적인 타입들 사이에 서브타입 관계가 부여되므로, 위 예시에서 TodoList[t1]은 Iterable[t1]의 서브타입이 됩니다.

제네릭 타입은 여러 단계에 걸쳐 특수화(인덱싱)될 수 있습니다. 모든 타입 변수는 구체적인 타입이나 다른 제네릭 타입으로 치환될 수 있습니다. 베이스 클래스 목록에 Generic이 나타나면 여기에는 모든 타입 변수가 포함되어야 하며, 타입 매개변수의 순서는 타입 변수가 Generic에 나타나는 순서에 따라 결정됩니다. 예시:

Table = Dict[int, T]     # Table is generic
Messages = Table[bytes]  # Same as Dict[int, bytes]

class BaseGeneric(Generic[T, S]):
    ...

class DerivedGeneric(BaseGeneric[int, T]): # DerivedGeneric has one parameter
    ...

SpecificType = DerivedGeneric[int]         # OK

class MyDictView(Generic[S, T, U], Iterable[Tuple[U, T]]):
    ...

Example = MyDictView[list, int, str]       # S -> list, T -> int, U -> str

타입 변수가 생략된 채로 제네릭 타입이 타입 어노테이션에 나타나면 Any로 간주됩니다. 이러한 형태는 동적 타이핑으로의 대체로 사용할 수 있으며, issubclass 및 isinstance와 함께 사용하는 것이 허용됩니다. 인스턴스의 모든 타입 정보는 런타임에 지워집니다. 예시:

def count(seq: Sequence) -> int:      # Same as Sequence[Any]
    ...

class FrameworkBase(Generic[S, T]):
    ...

class UserClass:
    ...

issubclass(UserClass, FrameworkBase)  # This is OK

class Node(Generic[T]):
   ...

IntNode = Node[int]
my_node = IntNode()  # at runtime my_node.__class__ is Node
                     # inferred static type of my_node is Node[int]

공변성과 반공변성

t2가 t1의 서브타입일 때, 제네릭 타입 생성자 GenType은 다음과 같이 불립니다:

  • 공변(covariant): 그러한 모든 t1과 t2에 대해 GenType[t2]가 GenType[t1]의 서브타입인 경우입니다.
  • 반공변(contravariant): 그러한 모든 t1과 t2에 대해 GenType[t1]이 GenType[t2]의 서브타입인 경우입니다.
  • 위의 어느 것도 참이 아니면 불변 조건(invariant)입니다.

이 정의를 더 잘 이해하기 위해 일반 함수에 빗대어 봅시다. 다음과 같은 함수가 있다고 가정합시다:

def cov(x: float) -> float:
    return 2*x

def contra(x: float) -> float:
    return -x

def inv(x: float) -> float:
    return x*x

x1 < x2이면 항상 cov(x1) < cov(x2)이고 contra(x2) < contra(x1)이지만, inv에 대해서는 아무것도 말할 수 없습니다. <를 서브타입 관계(is-subtype-of)로, 함수를 제네릭 타입 생성자로 바꾸면 공변(covariant), 반공변(contravariant), 불변 조건(invariant) 동작의 예를 얻을 수 있습니다. 이제 실용적인 예를 살펴봅시다:

  • Union은 모든 인자에 대해 공변적으로 동작합니다. 실제로 앞서 논의한 것처럼, t1이 u1의 서브타입이고 나머지도 마찬가지이면 Union[t1, t2, ...]는 Union[u1, u2, ...]의 서브타입입니다.
  • FrozenSet[T]도 공변입니다. T 대신 int와 float를 넣어 생각해 봅시다. 첫째, int는 float의 서브타입입니다. 둘째, FrozenSet[int]의 값 집합은 분명히 FrozenSet[float]의 값 집합의 부분집합이며, FrozenSet[float]를 인자로 받는 함수의 집합은 FrozenSet[int]를 인자로 받는 함수의 집합의 부분집합입니다. 따라서 정의에 의해 FrozenSet[int]는 FrozenSet[float]의 서브타입입니다.
  • List[T]는 불변 조건(invariant)을 따릅니다. 실제로 List[int]의 값 집합은 List[float]의 값 집합의 부분집합이지만, “배경” 절에서 논의했듯이 List[int]에는 int만 추가할 수 있습니다. 따라서 List[int]는 List[float]의 서브타입이 아닙니다. 이는 가변 타입에서 흔히 볼 수 있는 상황이며, 가변 타입은 일반적으로 불변 조건(invariant)을 따릅니다.

(다소 직관에 반하는) 반공변 동작을 보여 주는 가장 좋은 예 중 하나는 호출 가능 객체 타입입니다. 호출 가능 객체 타입은 반환 타입에 대해서는 공변이지만, 인자에 대해서는 반공변입니다. 반환 타입만 다른 두 호출 가능 객체 타입의 경우, 호출 가능 객체 타입 간의 서브타입 관계는 반환 타입 간의 서브타입 관계를 따릅니다. 예시:

  • Callable[[], int]는 Callable[[], float]의 서브타입입니다.
  • Callable[[], Manager]는 Callable[[], Employee]의 서브타입입니다.

반면 인자 하나의 타입만 다른 두 호출 가능 객체 타입의 경우, 호출 가능 객체 타입 간의 서브타입 관계는 인자 타입 간의 관계와 반대 방향으로 성립합니다. 예시:

  • Callable[[float], None]은 Callable[[int], None]의 서브타입입니다.
  • Callable[[Employee], None]은 Callable[[Manager], None]의 서브타입입니다.

네, 제대로 읽으신 것이 맞습니다. 실제로, 관리자의 급여를 계산할 수 있는 함수가 필요한 곳이라면:

def calculate_all(lst: List[Manager], salary: Callable[[Manager], Decimal]):
    ...

모든 직원의 급여를 계산할 수 있는 Callable[[Employee], Decimal]도 허용됩니다.

Callable 예제는 함수에 더 정확한 타입 어노테이션을 다는 방법을 보여 줍니다. 모든 인자에는 가장 일반적인 타입을, 반환값에는 가장 구체적인 타입을 선택하십시오.

매개변수로 사용되는 타입 변수를 정의할 때 특수 키워드 covariant와 contravariant를 사용하여 사용자 정의 제네릭 타입의 변성을 선언할 수 있습니다. 타입은 기본적으로 불변 조건(invariant)을 따릅니다. 예시:

T = TypeVar('T')
T_co = TypeVar('T_co', covariant=True)
T_contra = TypeVar('T_contra', contravariant=True)

class LinkedList(Generic[T]):  # invariant by default
    ...
    def append(self, element: T) -> None:
        ...

class Box(Generic[T_co]):      #  this type is declared covariant
    def __init__(self, content: T_co) -> None:
        self._content = content
    def get_content(self) -> T_co:
        return self._content

class Sink(Generic[T_contra]): # this type is declared contravariant
    def send_to_nowhere(self, data: T_contra) -> None:
        with open(os.devnull, 'w') as devnull:
            print(data, file=devnull)

변성은 타입 변수를 통해 정의되지만, 타입 변수의 속성이 아니라 제네릭 타입의 속성이라는 점에 유의하십시오. 파생 제네릭의 복잡한 정의에서 변성은 사용된 타입 변수로부터만 결정됩니다. 복잡한 예:

T_co = TypeVar('T_co', Employee, Manager, covariant=True)
T_contra = TypeVar('T_contra', Employee, Manager, contravariant=True)

class Base(Generic[T_contra]):
    ...

class Derived(Base[T_co]):
    ...

타입 검사기는 두 번째 선언으로부터 Derived[Manager]가 Derived[Employee]의 서브타입이고, Derived[t1]이 Base[t1]의 서브타입이라는 것을 알아냅니다. 서브타입 관계를 <로 나타내면, 이 경우의 전체 서브타이핑 관계도는 다음과 같습니다.:

Base[Manager]    >  Base[Employee]
    v                   v
Derived[Manager] <  Derived[Employee]

따라서 타입 검사기는 예를 들어 Derived[Manager]가 Base[Employee]의 서브타입이라는 것도 알아냅니다.

타입 변수, 제네릭 타입, 변성에 대한 자세한 내용은 PEP 484, 제네릭에 관한 mypy 문서, 위키백과를 참고하십시오.

실용적 측면

이론과는 관계없지만 실제 사용을 더 편리하게 해 주는 것들이 있습니다. (전체 목록은 아닙니다. 몇 가지는 빠뜨렸을 수 있으며, 일부는 아직 논란이 있거나 완전히 명세되지 않았습니다.)

  • 타입이 와야 하는 곳에는 type(None) 대신 None을 쓸 수 있습니다. 예: Union[t1, None] == Union[t1, type(None)].
  • 타입 별칭. 예::
    Point = Tuple[float, float]
    def distance(point: Point) -> float: ...
    
  • 문자열을 사용한 전방 참조. 예::
    class MyComparable:
        def compare(self, other: 'MyComparable') -> int: ...
    
  • 타입 변수는 제약이 없는(unconstrained) 형태, 제약이 있는(constrained) 형태, 상한이 있는(bounded) 형태로 선언할 수 있습니다. 특수한 키워드 인자로 선언한 타입 변수를 사용해 제네릭 타입의 변성을 나타낼 수도 있으며, 이렇게 하면 별도의 특수 문법이 필요하지 않습니다. 예::
    T = TypeVar('T', bound=complex)
    
    def add(x: T, y: T) -> T:
        return x + y
    
    T_co = TypeVar('T_co', covariant=True)
    
    class ImmutableList(Generic[T_co]): ...
    
  • 주석을 사용한 타입 선언. 예::
    lst = []  # type: Sequence[int]
    
  • cast(T, obj)를 사용한 캐스트. 예::
    zork = cast(Any, frobozz())
    
  • 그 밖의 사항(예: 오버로딩, 스텁 모듈)은 PEP 484를 참고하십시오.

typing.py에 미리 정의된 제네릭 타입과 프로토콜

(typing.py 모듈도 참고하십시오.)

  • collections.abc에 있는 모든 것(단, Set은 AbstractSet으로 이름이 바뀌었습니다).
  • Dict, List, Set, FrozenSet 외 몇 가지.
  • re.Pattern[AnyStr], re.Match[AnyStr].
  • io.IO[AnyStr], io.TextIO ~ io.IO[str], io.BinaryIO ~ io.IO[bytes].