PEP 618 – zip에 선택적 길이 검사 추가
- Author:
- Brandt Bucher <brandt at python.org>
- Sponsor:
- Antoine Pitrou <antoine at python.org>
- BDFL-Delegate:
- Guido van Rossum <guido at python.org>
- Status:
- Final
- Type:
- Standards Track
- Created:
- 01-May-2020
- Python-Version:
- 3.10
- Post-History:
- 01-May-2020, 10-May-2020, 16-Jun-2020
- Resolution:
- Python-Dev message
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
이 PEP는 내장 zip에 선택적 불리언 키워드 매개변수 strict를 추가할 것을 제안합니다. 이 매개변수를 활성화하면 인자 중 하나가 다른 인자보다 먼저 소진될 때 ValueError가 발생합니다.
동기
저자의 개인적인 경험과 표준 라이브러리 조사를 보면, zip 사용의 상당수(대부분은 아니더라도)가 길이가 반드시 같아야 하는 이터러블을 다룬다는 것이 분명합니다. 때로는 주변 코드의 맥락을 통해 이 불변 조건이 참임이 증명되지만, zip으로 묶이는 데이터는 호출자로부터 전달되거나, 별도로 공급되거나, 어떤 방식으로든 생성되는 경우가 많습니다. 이러한 경우 어느 것이든, zip의 기본 동작 때문에 잘못된 리팩터링이나 논리 오류로 인해 데이터가 조용히 손실되기 쉽습니다. 이러한 버그는 진단하기 어려울 뿐만 아니라, 애초에 발견하는 것조차 어렵습니다.
이것이 문제가 될 수 있는 간단한 사례는 쉽게 떠올릴 수 있습니다. 예를 들어, 다음 코드는 items가 시퀀스일 때는 잘 동작할 수 있지만, 호출자가 items를 소비되는 이터레이터로 리팩터링하면 길이가 짧아지고 짝이 맞지 않는 결과를 조용히 만들어 내기 시작할 수 있습니다:
def apply_calculations(items):
transformed = transform(items)
for i, t in zip(items, transformed):
yield calculate(i, t)
zip이 흔히 사용되는 방식은 이 밖에도 여러 가지가 있습니다. 관용적인 기법은 특히 취약한데, 코드가 어떻게 동작하는지 완전히 이해하지 못한 사용자가 이를 사용하는 경우가 많기 때문입니다. 한 가지 예는 중첩된 이터러블을 지연 방식으로 “unzip”하거나 “전치(transpose)”하기 위해 zip에 언패킹하여 전달하는 것입니다:
>>> x = [[1, 2, 3], ["one" "two" "three"]]
>>> xt = list(zip(*x))
또 다른 예는 데이터를 같은 크기의 그룹으로 “청크 분할(chunking)”하는 것입니다:
>>> n = 3
>>> x = range(n ** 2),
>>> xn = list(zip(*[iter(x)] * n))
첫 번째 경우, 직사각형이 아닌 데이터는 대개 논리 오류입니다. 두 번째 경우에도 길이가 n의 배수가 아닌 데이터는 오류인 경우가 많습니다. 그러나 두 관용구 모두 잘못된 형식의 입력에서 끝부분 항목을 조용히 누락시킵니다.
아마도 가장 설득력 있는 사례는, 표준 라이브러리 ast 모듈에서 zip을 사용한 탓에 literal_eval에 잘못된 형식의 노드 일부를 조용히 누락시키는 버그가 생긴 일입니다:
>>> from ast import Constant, Dict, literal_eval
>>> nasty_dict = Dict(keys=[Constant(None)], values=[])
>>> literal_eval(nasty_dict) # Like eval("{None: }")
{}
실제로 저자는 Python 표준 라이브러리와 도구에서 이 새 기능을 즉시 활성화하는 것이 적절할 다른 호출 지점을 수십 곳 세어 보았습니다.
근거
일부 비평가는 상수 불리언 스위치가 “코드 스멜”이거나 Python의 설계 철학에 어긋난다고 주장합니다. 그러나 현재 Python에는 일반적으로 컴파일 타임 상수로 호출되는 불리언 키워드 매개변수를 가진 내장 함수의 예가 여러 개 있습니다:
compile(..., dont_inherit=True)open(..., closefd=False)print(..., flush=True)sorted(..., reverse=True)
표준 라이브러리에는 이런 사례가 훨씬 더 많습니다.
이 새 매개변수의 아이디어와 이름은 Ram Rachum이 처음 제안했습니다. 이 스레드에는 100개가 넘는 답글이 달렸으며, 대안인 “equal”도 비슷한 정도의 지지를 받았습니다.
저자는 두 선택지 중 어느 쪽도 강하게 선호하지 않지만, “equal equals”는 문장으로 쓰면 분명히 다소 어색합니다. 또한 이 이름은 zip으로 묶인 항목들 사이에 어떤 “동등성” 개념이 있음을 (잘못) 암시할 수도 있습니다:
>>> z = zip([2.0, 4.0, 6.0], [2, 4, 8], equal=True)
명세
내장 zip을 키워드 전용 인자 strict=True와 함께 호출하면, 인자들이 소진되는 길이가 서로 다를 경우 결과 이터레이터가 ValueError를 발생시킵니다. 이 오류는 지금이라면 반복이 정상적으로 멈추었을 시점에 발생합니다.
하위 호환성
이 변경은 하위 호환성을 완전히 유지합니다. zip은 현재 키워드 인자를 받지 않으며, strict를 생략했을 때의 “비엄격(non-strict)” 기본 동작은 그대로 유지됩니다.
참조 구현
저자는 C 구현의 초안을 작성했습니다.
대략적인 Python 번역은 다음과 같습니다:
def zip(*iterables, strict=False):
if not iterables:
return
iterators = tuple(iter(iterable) for iterable in iterables)
try:
while True:
items = []
for iterator in iterators:
items.append(next(iterator))
yield tuple(items)
except StopIteration:
if not strict:
return
if items:
i = len(items)
plural = " " if i == 1 else "s 1-"
msg = f"zip() argument {i+1} is shorter than argument{plural}{i}"
raise ValueError(msg)
sentinel = object()
for i, iterator in enumerate(iterators[1:], 1):
if next(iterator, sentinel) is not sentinel:
plural = " " if i == 1 else "s 1-"
msg = f"zip() argument {i+1} is longer than argument{plural}{i}"
raise ValueError(msg)
기각된 아이디어
itertools.zip_strict 추가
이것은 Python-Ideas 메일링 리스트에서 가장 많은 지지를 받은 대안이므로, 여기에서 어느 정도 자세히 논의할 가치가 있습니다. 이 대안에는 결격 사유가 될 만한 결함이 없으며, 이 PEP가 거절될 경우 대체안으로 충분히 잘 작동할 수 있습니다.
이를 염두에 두고, 이 절에서는 zip에 선택적 매개변수를 추가하는 것이 왜 더 작은 변경이면서도 결국 이 PEP의 동기가 된 문제들을 더 잘 해결하는지 개괄하고자 합니다.
선례
이 대안을 추진하는 동기의 상당 부분은 zip_longest가 이미 itertools에 존재한다는 점에 있는 것으로 보입니다. 그러나 zip_longest는 여러 면에서 훨씬 더 복잡하고 특화된 유틸리티입니다. 이 함수는 누락된 값을 채우는 책임을 떠맡는데, 이는 다른 두 변형 중 어느 쪽도 신경 쓸 필요가 없는 일입니다.
zip과 zip_longest가 itertools 안에 나란히 있거나 둘 다 내장으로 존재한다면, 같은 위치에 zip_strict를 추가하자는 것은 실제로 훨씬 더 강력한 인자가 될 것입니다. 그러나 새로운 “엄격(strict)” 변형은 인터페이스와 동작 면에서 개념적으로 zip_longest보다 zip에 훨씬 더 가까우면서도, 독자적인 내장 함수가 될 만큼 높은 기준은 충족하지 못합니다. 이러한 상황을 고려하면, zip이 이 새로운 옵션을 직접 갖추는 것이 가장 자연스러워 보입니다.
사용성
zip이 이런 부류의 버그를 방지할 수 있다면, 사용자가 이러한 속성을 가진 호출 지점에서 검사를 활성화하기가 훨씬 간단해집니다. 이를 내장 유틸리티의 드롭인 대체품을 임포트하는 방식과 비교해 보십시오. 그 방식은 “항상” 참이어야 하는 까다로운 조건 하나를 검사하려는 것치고는 다소 무겁게 느껴집니다.
또한 일부는 표준 라이브러리 깊숙이 묻힌 새 함수가 내장 함수 자체의 키워드 매개변수보다 어쩐지 더 “발견하기 쉽다”고 주장하기도 했습니다. 저자는 이 평가에 동의하지 않습니다.
유지 보수 비용
사용성을 개선할 때 구현은 부차적인 고려 사항이어야 하지만, 새로운 유틸리티를 추가하는 것이 기존 유틸리티를 수정하는 것보다 훨씬 더 복잡하다는 점은 인식해야 합니다. 이 PEP에 수반되는 CPython 구현은 단순하며 기본 zip 동작에 측정 가능한 성능 영향을 주지 않는 반면, itertools에 완전히 새로운 유틸리티를 추가하려면 다음 중 하나가 필요합니다:
zip_longest가 이미 그러하듯이, 기존zip로직의 상당 부분을 복제하는 것.- 공통 구현 또는 상속된 구현을 공유하도록
zip,zip_longest또는 둘 다를 대폭 리팩터링하는 것(이는 성능에 영향을 줄 수 있습니다).
전환 가능한 여러 “모드” 추가
이 선택지는 세 가지 이상의 모드가 생길 것으로 예상하는 경우에만 이진 플래그보다 더 타당합니다. 이러한 열거형 또는 상수 모드의 “뻔한” 세 가지 선택지는 “shortest”(현재 zip 동작), “strict”(제안된 동작), “longest”(itertools.zip_longest 동작)가 될 것입니다.
하지만 현재의 기본 동작과 제안된 “strict” 모드 외의 다른 동작은 그로 인해 늘어나는 복잡성을 감수할 만큼 가치가 있어 보이지 않습니다. 가장 분명한 후보인 “longest”는 새로운 fillvalue 매개변수를 필요로 합니다(이 매개변수는 다른 두 모드에서는 의미가 없습니다). 또한 이 모드는 이미 itertools.zip_longest가 완벽하게 처리하고 있으며, 이를 추가하면 같은 일을 하는 방법이 두 가지가 됩니다. 내장 zip의 mode 매개변수와 itertools에 오래전부터 있던 같은 이름의 유틸리티 중 어느 쪽이 “명백한” 선택인지 분명하지 않습니다.
zip 타입에 메서드나 대체 생성자 추가하기
둘 다 제안된 바 있는 다음 두 가지 선택지를 살펴보십시오:
>>> zm = zip(*iters).strict()
>>> zd = zip.strict(*iters)
어느 쪽이 성공할지, 그리고 다른 쪽이 어떻게 실패할지 분명하지 않습니다. zip.strict가 메서드로 구현되면 zm은 성공하지만, zd는 다음과 같은 여러 혼란스러운 방식 중 하나로 실패합니다:
- 튜플로 감싸지지 않은 결과를 산출합니다(
iters에zip이터레이터 하나만 들어 있는 경우). - 잘못된 인자 타입에 대해
TypeError를 발생시킵니다(iters에zip이터레이터가 아닌 항목 하나만 들어 있는 경우). - 잘못된 인자 개수에 대해
TypeError를 발생시킵니다(그 밖의 경우).
zip.strict가 classmethod나 staticmethod로 구현되면 zd는 성공하고, zm은 조용히 아무것도 산출하지 않습니다(이것이 바로 애초에 우리가 피하려는 문제입니다).
이 제안은 CPython의 실제 zip 타입이 현재 문서화되지 않은 구현 세부 사항이라는 사실 때문에 더욱 복잡해집니다. 이는 위의 동작 중 하나를 선택하면 앞으로 현재 구현이 사실상 “잠금”된다는(또는 적어도 그 구현을 에뮬레이션해야 한다는) 뜻입니다.
zip의 기본 동작 변경하기
zip의 기본 동작에는 “잘못된” 점이 없습니다. 크기가 서로 다른 입력을 처리하는 올바른 방법이 실제로 이 동작인 경우가 많기 때문입니다. 예를 들어 무한 이터레이터를 다룰 때 매우 유용합니다.
“여분의” 끝부분 데이터가 여전히 필요한 경우를 위해서는 이미 itertools.zip_longest가 있습니다.
남은 항목을 처리하는 콜백 받기
이 해법은 사용자가 필요로 할 만한 거의 모든 일을 할 수 있지만, 더 흔한 경우(길이가 맞지 않을 때 거부하는 것 등)를 처리하는 일을 불필요하게 복잡하고 직관적이지 않게 만듭니다.
AssertionError 발생시키기
API의 일부로 AssertionError를 발생시키는 내장 함수나 타입은 없습니다. 게다가 공식 문서에는 (전문이) 다음과 같이 적혀 있을 뿐입니다:
assert문이 실패할 때 발생합니다.
이 기능은 Python의 assert 문과 아무 관련이 없으므로, 여기서 AssertionError를 발생시키는 것은 적절하지 않습니다. (assert 문처럼) 최적화 모드에서 비활성화되는 검사를 원하는 사용자는 대신 strict=__debug__를 사용할 수 있습니다.
map에 비슷한 기능 추가하기
여러 이터러블 인자와 함께 map을 사용하는 경우는 매우 드물기 때문에, 이 PEP는 map에 대한 어떠한 변경도 제안하지 않습니다. 다만 이 PEP의 결정은 (그러한 논의가 일어난다면) 향후 논의의 선례가 될 것입니다.
거부된다면 이 기능은 현실적으로 추진할 가치가 없습니다. 수락된다면 map에 대한 그러한 변경에는 별도의 PEP가 필요하지 않을 것입니다(물론 모든 개선 사항과 마찬가지로 그 유용성은 신중하게 검토해야 합니다). 일관성을 위해 여기서 zip에 대해 논의한 것과 같은 API와 의미 체계를 따라야 합니다.
아무것도 하지 않기
이 선택지는 아마도 가장 매력이 떨어지는 선택지일 것입니다.
아무 경고 없이 잘려 나간 데이터는 특히 고약한 종류의 버그이며, 이를 올바르게 처리하는 견고한 해결책을 직접 작성하는 일은 간단하지 않습니다. Python 자체의 표준 라이브러리에서 가져온 실제 동기 사례들은 이 기능이 피하려는 종류의 함정에 빠지기가 매우 쉽다는 증거입니다.
참고 문헌
예시
Note
이 목록이 전부는 아닙니다.
- https://github.com/python/cpython/blob/27c0d9b54abaa4112d5a317b8aa78b39ad60a808/Lib/_pydecimal.py#L3394
- https://github.com/python/cpython/blob/27c0d9b54abaa4112d5a317b8aa78b39ad60a808/Lib/_pydecimal.py#L3418
- https://github.com/python/cpython/blob/27c0d9b54abaa4112d5a317b8aa78b39ad60a808/Lib/_pydecimal.py#L3435
- https://github.com/python/cpython/blob/27c0d9b54abaa4112d5a317b8aa78b39ad60a808/Lib/ast.py#L94-L95
- https://github.com/python/cpython/blob/27c0d9b54abaa4112d5a317b8aa78b39ad60a808/Lib/ast.py#L1184
- https://github.com/python/cpython/blob/27c0d9b54abaa4112d5a317b8aa78b39ad60a808/Lib/ast.py#L1275
- https://github.com/python/cpython/blob/27c0d9b54abaa4112d5a317b8aa78b39ad60a808/Lib/ast.py#L1363
- https://github.com/python/cpython/blob/27c0d9b54abaa4112d5a317b8aa78b39ad60a808/Lib/ast.py#L1391
- https://github.com/python/cpython/blob/27c0d9b54abaa4112d5a317b8aa78b39ad60a808/Lib/copy.py#L217
- https://github.com/python/cpython/blob/27c0d9b54abaa4112d5a317b8aa78b39ad60a808/Lib/csv.py#L142
- https://github.com/python/cpython/blob/27c0d9b54abaa4112d5a317b8aa78b39ad60a808/Lib/dis.py#L462
- https://github.com/python/cpython/blob/27c0d9b54abaa4112d5a317b8aa78b39ad60a808/Lib/filecmp.py#L142
- https://github.com/python/cpython/blob/27c0d9b54abaa4112d5a317b8aa78b39ad60a808/Lib/filecmp.py#L143
- https://github.com/python/cpython/blob/27c0d9b54abaa4112d5a317b8aa78b39ad60a808/Lib/inspect.py#L1440
- https://github.com/python/cpython/blob/27c0d9b54abaa4112d5a317b8aa78b39ad60a808/Lib/inspect.py#L2095
- https://github.com/python/cpython/blob/27c0d9b54abaa4112d5a317b8aa78b39ad60a808/Lib/os.py#L510
- https://github.com/python/cpython/blob/27c0d9b54abaa4112d5a317b8aa78b39ad60a808/Lib/plistlib.py#L577
- https://github.com/python/cpython/blob/27c0d9b54abaa4112d5a317b8aa78b39ad60a808/Lib/tarfile.py#L1317
- https://github.com/python/cpython/blob/27c0d9b54abaa4112d5a317b8aa78b39ad60a808/Lib/tarfile.py#L1323
- https://github.com/python/cpython/blob/27c0d9b54abaa4112d5a317b8aa78b39ad60a808/Lib/tarfile.py#L1339
- https://github.com/python/cpython/blob/27c0d9b54abaa4112d5a317b8aa78b39ad60a808/Lib/turtle.py#L3015
- https://github.com/python/cpython/blob/27c0d9b54abaa4112d5a317b8aa78b39ad60a808/Lib/turtle.py#L3071
- https://github.com/python/cpython/blob/27c0d9b54abaa4112d5a317b8aa78b39ad60a808/Lib/turtle.py#L3901
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.