PEP 585 – 표준 컬렉션의 타입 힌트 제네릭
- Author:
- Łukasz Langa <lukasz at python.org>
- Discussions-To:
- Typing-SIG list
- Status:
- Final
- Type:
- Standards Track
- Topic:
- Typing
- Created:
- 03-Mar-2019
- Python-Version:
- 3.9
- Resolution:
- Python-Dev thread
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
PEP 484, 526, 544, 560, 563에서 정의한 정적 타이핑은 기존 Python 런타임 위에 점진적으로 구축되었으며, 기존 문법과 런타임 동작의 제약을 받았습니다. 이로 인해 제네릭 때문에 typing 모듈에 중복된 컬렉션 계층 구조가 존재하게 되었습니다(예: typing.List와 내장 list).
이 PEP는 현재 typing 모듈에서 사용할 수 있는 모든 표준 컬렉션에서 제네릭 문법을 지원하도록 할 것을 제안합니다.
근거와 목표
이 변경은 typing 모듈에 병렬 타입 계층 구조를 둘 필요를 없애 주므로, 사용자는 프로그램에 어노테이션을 달기가 더 쉬워지고 교육자는 Python을 가르치기가 더 쉬워집니다.
용어
제네릭(명사) – 매개변수화할 수 있는 타입으로, 일반적으로 컨테이너입니다. 매개변수적 타입 또는 제네릭 타입이라고도 합니다. 예: dict.
매개변수화된 제네릭 – 컨테이너 요소에 대해 기대하는 타입이 제공된 제네릭의 구체적인 인스턴스입니다. 매개변수화된 타입이라고도 합니다. 예: dict[str, int].
하위 호환성
타입 검사기와 린터를 포함한 도구들은 표준 컬렉션을 제네릭으로 인식하도록 수정되어야 합니다.
소스 수준에서 새로 설명한 기능을 사용하려면 Python 3.9가 필요합니다. 타입 어노테이션에 한정된 사용 사례에서는 “annotations” future-import(Python 3.7부터 사용 가능)가 있는 Python 파일에서 내장 타입을 포함한 표준 컬렉션을 매개변수화할 수 있습니다. 다시 말하지만, 이는 외부 도구가 이것이 유효하다는 점을 이해하는지에 달려 있습니다.
구현
Python 3.7부터는 from __future__ import annotations를 사용하면 함수와 변수 어노테이션에서 표준 컬렉션을 직접 매개변수화할 수 있습니다. 예시:
from __future__ import annotations
def find(haystack: dict[str, list[int]]) -> int:
...
Mypy와 같은 외부 도구가 표준 컬렉션을 제네릭으로 인식하지 않기 때문에, PEP 585 이전에는 이 문법의 유용성이 제한적입니다. 게다가 타입 별칭이나 캐스팅과 같은 타이핑의 특정 기능은 타입을 어노테이션 바깥, 즉 런타임 컨텍스트에 두어야 합니다. 이러한 경우는 타입 어노테이션에 비해 상대적으로 드물지만, 모든 컨텍스트에서 동일한 타입 문법을 사용할 수 있도록 하는 것이 중요합니다. 이 때문에 Python 3.9부터는 다음 컬렉션들이 __class_getitem__()을 사용하여 포함된 타입을 매개변수화하는 제네릭이 됩니다:
tuple# typing.Tuplelist# typing.Listdict# typing.Dictset# typing.Setfrozenset# typing.FrozenSettype# typing.Typecollections.dequecollections.defaultdictcollections.OrderedDictcollections.Countercollections.ChainMapcollections.abc.Awaitablecollections.abc.Coroutinecollections.abc.AsyncIterablecollections.abc.AsyncIteratorcollections.abc.AsyncGeneratorcollections.abc.Iterablecollections.abc.Iteratorcollections.abc.Generatorcollections.abc.Reversiblecollections.abc.Containercollections.abc.Collectioncollections.abc.Callablecollections.abc.Set# typing.AbstractSetcollections.abc.MutableSetcollections.abc.Mappingcollections.abc.MutableMappingcollections.abc.Sequencecollections.abc.MutableSequencecollections.abc.ByteStringcollections.abc.MappingViewcollections.abc.KeysViewcollections.abc.ItemsViewcollections.abc.ValuesViewcontextlib.AbstractContextManager# typing.ContextManagercontextlib.AbstractAsyncContextManager# typing.AsyncContextManagerre.Pattern# typing.Pattern, typing.re.Patternre.Match# typing.Match, typing.re.Match
typing에서 이것들을 임포트하는 것은 폐지되었습니다. PEP 563과 타입 지정이 런타임에 미치는 영향을 최소화하려는 의도에 따라, 이 폐지는 DeprecationWarning을 발생시키지 않습니다. 대신, 검사 대상 프로그램의 목표 버전이 Python 3.9 이상으로 지정된 경우 타입 검사기가 이러한 폐지된 사용법에 대해 경고할 수 있습니다. 이러한 경고를 프로젝트 전체 단위로 끌 수 있도록 허용할 것을 권장합니다.
폐지된 기능은 결국 typing 모듈에서 제거될 수 있습니다. 제거는 2025년 10월로 예정된 Python 3.9의 지원 종료 이전에는 이루어지지 않습니다.
제네릭의 매개변수를 런타임에 사용할 수 있습니다
런타임에 제네릭 타입을 보존하면 타입을 인트로스펙션할 수 있으며, 이는 API 생성이나 런타임 타입 검사에 활용될 수 있습니다. 이러한 사용 방식은 이미 실제로 쓰이고 있습니다.
현재의 typing 모듈과 마찬가지로, 앞 절에 나열된 매개변수화된 제네릭 타입은 모두 런타임에 타입 매개변수를 보존합니다:
>>> list[str]
list[str]
>>> tuple[int, ...]
tuple[int, ...]
>>> ChainMap[str, list[str]]
collections.ChainMap[str, list[str]]
이는 얇은 프록시 타입을 사용하여 구현되며, 이 타입은 다음 예외를 제외한 모든 메서드 호출과 속성 접근을 매개변수화되지 않은 원래 타입으로 전달합니다:
__repr__은 매개변수화된 타입을 보여줍니다;__origin__속성은 매개변수화되지 않은 제네릭 클래스를 가리킵니다.__args__속성은 원래의__class_getitem__에 전달된 제네릭 타입의 튜플입니다(길이가 1일 수도 있습니다).__parameters__속성은__args__에서 발견된 고유한 타입 변수의 튜플로, 지연 계산되며 비어 있을 수도 있습니다.__getitem__은dict[str][str]같은 실수를 막기 위해 예외를 발생시킵니다. 그러나 예를 들어dict[str, T][int]같은 형태는 허용하며, 이 경우dict[str, int]를 반환합니다.
이 설계는 다음과 같이 매개변수화된 컬렉션의 인스턴스를 생성할 수 있음을 의미합니다:
>>> l = list[str]()
[]
>>> list is list[str]
False
>>> list == list[str]
False
>>> list[str] == list[str]
True
>>> list[str] == list[int]
False
>>> isinstance([1, 2, 3], list[str])
TypeError: isinstance() arg 2 cannot be a parameterized generic
>>> issubclass(list, list[str])
TypeError: issubclass() arg 2 cannot be a parameterized generic
>>> isinstance(list[str], types.GenericAlias)
True
매개변수화되지 않은 타입으로 생성한 객체와 매개변수화된 타입으로 생성한 객체는 완전히 같습니다. 매개변수화된 타입으로 생성한 인스턴스에는 제네릭 매개변수가 보존되지 않습니다. 다시 말해, 제네릭 타입은 객체를 생성할 때 타입 매개변수를 소거합니다.
이로 인한 중요한 결과 중 하나는, 인터프리터가 매개변수화된 타입으로 생성한 컬렉션에 대한 연산의 타입 검사를 시도하지 않는다는 점입니다. 이는 다음 코드와:
l: list[str] = []
다음 코드 사이에 대칭성을 제공합니다:
l = list[str]()
Python 코드에서 프록시 타입에 접근할 수 있도록, 이 타입은 types 모듈에서 GenericAlias라는 이름으로 공개됩니다.
GenericAlias 인스턴스를 피클링하거나 (얕은 또는 깊은) 복사하면 타입, 원본, 속성, 매개변수가 보존됩니다.
상위 호환성
향후의 표준 컬렉션도 같은 동작을 구현해야 합니다.
참조 구현
개념 증명 또는 프로토타입 구현이 존재합니다.
기각된 대안
아무것도 하지 않기
현재 상태를 유지하면 Python 프로그래머는 표준 컬렉션을 위해 typing 모듈에서 가져온 임포트를 계속 관리해야 하며, 가장 단순한 어노테이션을 제외한 모든 어노테이션의 유지 관리가 번거로워집니다. 같은 역할을 하는 타입이 나란히 존재하면 초보자는 혼란을 겪습니다(왜 list와 List가 둘 다 있는 것입니까?).
위의 문제들은 사용자가 만든 제네릭 클래스에는 존재하지 않습니다. 이러한 클래스는 런타임 기능을 제공하면서 제네릭 타입 어노테이션으로도 사용할 수 있습니다. 표준 컬렉션은 사용자 클래스보다 타입 힌트에 사용하기가 더 어려웠고, 이 때문에 타이핑의 도입과 사용성이 저해되었습니다.
제네릭 소거
나열된 표준 컬렉션에 __class_getitem__을 제네릭 타입을 보존하지 않는 방식으로 구현하는 편이 더 쉬웠을 것입니다. 다시 말해:
>>> list[str]
<class 'list'>
>>> tuple[int, ...]
<class 'tuple'>
>>> collections.ChainMap[str, list[str]]
<class 'collections.ChainMap'>
이 방식은 하위 호환성을 깨뜨리므로 문제가 됩니다. typing 모듈에 있는 해당 타입들의 현재 대응물은 제네릭 타입을 실제로 보존합니다:
>>> from typing import List, Tuple, ChainMap
>>> List[str]
typing.List[str]
>>> Tuple[int, ...]
typing.Tuple[int, ...]
>>> ChainMap[str, List[str]]
typing.ChainMap[str, typing.List[str]]
“구현” 절에서 언급했듯이, 런타임에 제네릭 타입을 보존하면 그 타입을 런타임에 인트로스펙션할 수 있습니다. 이러한 인트로스펙션은 API 생성이나 런타임 타입 검사에 사용할 수 있습니다. 이러한 사용법은 이미 실제로 쓰이고 있습니다.
또한 첨자를 항등 함수로 구현하면 Python이 초보자에게 덜 친숙해집니다. 예를 들어 사용자가 실수로 리스트 객체 대신 리스트 타입을 함수에 전달하고 그 함수가 전달받은 객체를 인덱싱한다면, 코드는 더 이상 오류를 발생시키지 않습니다.
현재:
>>> l = list
>>> l[-1]
TypeError: 'type' object is not subscriptable
__class_getitem__이 항등 함수인 경우:
>>> l = list
>>> l[-1]
list
여기서 인덱싱이 성공하면 결국 엉뚱한 곳에서 예외가 발생하여 사용자를 혼란스럽게 할 가능성이 큽니다.
매개변수화된 타입의 인스턴스화 금지
__origin__과 __args__를 보존하는 프록시 타입은 주로 런타임 인트로스펙션에 유용합니다. 따라서 매개변수화된 타입의 인스턴스화를 금지하는 방안도 가능했습니다.
실제로 현재 typing 모듈은 내장 컬렉션에 대응하는 타입에 대해 매개변수화된 타입의 인스턴스화를 금지하고 있습니다(다른 매개변수화된 타입의 인스턴스화는 허용됩니다).
이렇게 결정한 원래 이유는 불필요한 매개변수화를 억제하기 위해서였습니다. 이러한 매개변수화를 쓰면 해당 내장 컬렉션에 제공되는 특수 문법보다 객체 생성이 최대 100배까지 느려졌습니다.
이 근거만으로 내장 타입을 예외적으로 취급하기에는 충분하지 않습니다. 표준 라이브러리에 있는 컬렉션 대응물을 포함하여, 다른 모든 매개변수화된 타입은 인스턴스화할 수 있습니다. 게다가 Python은 list()를 사용하여 리스트를 인스턴스화할 수 있으며, 일부 내장 컬렉션은 인스턴스화를 위한 특별한 문법을 제공하지 않습니다.
isinstance(obj, list[str])가 제네릭을 무시하고 검사하도록 만들기
이 PEP의 이전 버전에서는 isinstance()와 issubclass()에서 list[str] 같은 매개변수화된 제네릭을 list 같은 매개변수화되지 않은 형태와 동등하게 취급하자고 제안했습니다. 이 방식은 list[str]()가 일반 리스트를 생성하는 방식과 대칭을 이룹니다.
매개변수화된 제네릭으로 하는 isinstance()와 issubclass() 검사는 요소 하나하나를 확인하는 런타임 타입 검사처럼 읽힙니다. 이 때문에 이 설계는 기각되었습니다. 이러한 검사의 결과는 예상 밖일 것입니다. 예를 들어:
>>> isinstance([1, 2, 3], list[str])
True
객체가 주어진 제네릭 타입과 일치하지 않는데도 isinstance()는 객체가 리스트인지만 확인하므로 여전히 True를 반환한다는 점에 유의하십시오.
라이브러리가 매개변수화된 제네릭을 받아 베이스 타입으로 isinstance() 검사를 하려는 경우에는 매개변수화된 제네릭의 __origin__ 속성으로 그 타입을 얻을 수 있습니다.
isinstance(obj, list[str])가 런타임 타입 검사를 수행하도록 만들기
이 기능은 컬렉션을 순회해야 하는데, 일부 컬렉션에서는 순회가 파괴적인 연산입니다. 이 기능이 유용했을 수도 있지만, 복잡한 타입, 중첩된 타입 검사, 타입 변수, 문자열 전방 참조 등을 처리하는 타입 검사기를 Python 내부에 구현하는 것은 이 PEP의 범위를 벗어납니다.
타입 이름을 GenericAlias 대신 GenericType으로 짓기
이 타입에 다른 이름을 붙이는 것도 고려했지만, GenericAlias가 더 낫다고 결론지었습니다. 이것들은 실제 타입이 아니라 몇 가지 추가 메타데이터가 붙은 해당 컨테이너 타입의 별칭이기 때문입니다.
초기 초안에 관한 참고 사항
이 PEP의 초기 버전에서는 표준 컬렉션의 제네릭 외의 사안도 논의했습니다. 명확성을 위해 이와 관련 없는 주제는 제거하였습니다.
감사의 글
Python을 위해 해 주신 작업, 특히 이 PEP를 구현해 주신 데 대해 Guido van Rossum에게 감사드립니다.
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.