PEP 577 – 복합 대입 표현식
- Author:
- Alyssa Coghlan <ncoghlan at gmail.com>
- Status:
- Withdrawn
- Type:
- Standards Track
- Created:
- 14-May-2018
- Python-Version:
- 3.8
- Post-History:
- 22-May-2018
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
PEP 철회
이 PEP를 작업하면서, 이 PEP가 이전에 참조되지 않은 대입 대상에 대해 PEP 572가 제안한 스코프 규칙에서 실제로 저를 불편하게 했던 점을 제대로 해결하지 못한다는 것을 깨달았습니다. 또한 이 PEP에는 상당히 바람직하지 않은 결과도 몇 가지 있었습니다(가장 두드러진 것은, >>=와 <<=를 인라인 복합 대입 연산자로 허용하게 되는데, 이것이 >= 및 <= 비교 연산자와는 완전히 다른 의미를 갖게 된다는 점이었습니다).
또한 저는 전용 문법이 없더라도 PEP 572가 기술적으로는 operator 모듈을 사용하여 인라인 복합 대입을 작성할 수 있게 한다는 것도 깨달았습니다:
from operator import iadd
if (target := iadd(target, value)) < limit:
...
인라인 대입 대상을 단순한 이름으로 제한한다는 것은 대상 표현식을 항상 부작용 없이 반복할 수 있다는 의미이며, 따라서 실제로 내장된 복합 대입을 허용할 때 발생할 모호성을 피할 수 있습니다(그래도 여전히 나쁜 아이디어입니다. 사람이 읽기 어려울 것이 거의 확실하기 때문입니다. 이 메모는 단지 언어 수준 표현력의 이론적 한계에 관한 것입니다).
이에 따라 저는 이 PEP를 결정을 위해 제출하지 않고 철회했습니다. 당시 저는 현재 스코프에서 아직 지역 변수로 선언되지 않은 할당 대상의 처리(일반 블록 스코프와 스코프를 가진 표현식 모두에 대해)에 특별히 초점을 맞춘 대체 PEP도 작성하기 시작했지만, 그 초안은 최종적으로 채택된 PEP 572의 제안보다 제가 더 마음에 들어 하는 단계에조차 이르지 못했기 때문에 어디에도 게시되지 않았고 PEP 번호도 부여받지 않았습니다.
초록
이것은 x += 1과 같은 복합 대입을 문(statement)뿐만 아니라 표현식으로도 사용할 수 있도록 허용하자는 제안입니다.
그 일환으로, NAME := EXPR를 인라인 대입 표현식으로 제안합니다. 이 표현식은 기존의 이름 바인딩 문처럼 새 지역 변수 이름을 암묵적으로 정의하는 대신, 새로운 복합 대입 스코프 규칙을 사용합니다. 함수 스코프에서 표현식 수준의 지역 변수 선언을 허용할지에 대한 문제는 표현식 수준의 이름 바인딩을 허용할지에 대한 문제와 의도적으로 분리하여 이후의 PEP로 미룹니다.
이 PEP는 PEP 572의 직접적인 경쟁 제안입니다(비록 해당 PEP의 동기를 많이 차용하고, 인라인 할당에 대해 제안된 문법까지 공유하지만 말입니다). 두 PEP 간의 연결에 대한 자세한 내용은 Relationship with PEP 572를 참조하십시오.
새 표현식의 사용성을 개선하기 위해, 일반 블록 스코프(모듈, 클래스, 함수)에서의 복합 대입 처리와 스코프를 가진 표현식(람다 표현식, 제너레이터 표현식, 컴프리헨션)에서의 복합 대입 처리를 의미상 분리하여, 모든 인라인 대입이 기본적으로 가장 가까운 상위 블록 스코프를 대상으로 하도록 할 것을 제안합니다.
인라인 할당이 어떤 대상을 다시 바인딩할 것으로 기대되는지 현재 불분명하다고 판단되는 경우나, 인라인 할당의 대상 스코프가 다른 이유로 유효하지 않다고 간주되는 경우를 처리하기 위해, SyntaxError의 서브클래스로 새로운 컴파일 타임 TargetNameError를 추가합니다.
문법과 의미
복합 대입 표현식
언어 문법을 조정하여 복합 대입이 표현식으로 나타날 수 있도록 하며, 이때 복합 대입 표현식의 결과는 주어진 대상에 바인딩되는 것과 동일한 계산 후 참조입니다.
예를 들어:
>>> n = 0
>>> n += 5
5
>>> n -= 2
3
>>> n *= 3
9
>>> n
9
가변 대상의 경우, 이는 결과가 항상 원래 객체 그 자체라는 뜻입니다:
>>> seq = []
>>> seq_id = id(seq)
>>> seq += range(3)
[0, 1, 2]
>>> seq_id == id(seq)
True
속성 및 컨테이너 첨자에 대한 복합 대입도 허용되며, 단순한 이름 대상의 경우와 마찬가지로 그 결과는 대상에 바인딩되는 계산 후 참조입니다:
def increment(self, step=1):
return self._value += step
이러한 경우, 할당이 이미 이루어진 후에는 __getitem__과 __getattribute__가 호출되지 않습니다 (이들은 제자리 연산을 평가하는 데 필요한 경우에만 호출됩니다).
인라인 할당 연산자 추가
복합 대입 표현식만 추가된다면, 다음과 같이 동작하는 Target 래퍼 타입을 정의하여 |= 같은 기호를 범용 대입 연산자로 남용할 수 있게 됩니다:
>>> class Target:
... def __init__(self, value):
... self.value = value
... def __or__(self, other):
... return Target(other)
...
>>> x = Target(10)
>>> x.value
10
>>> x |= 42
<__main__.Target object at 0x7f608caa8048>
>>> x.value
42
이는 nonlocal 키워드가 없는 문제를 우회하기 위해 리스트에 단일 참조를 저장하는 방식이 오랫동안 사용되었던 것과 비슷하며, 이 방식은 오늘날에도 (operator.itemsetter와 결합하여) 표현식 수준 할당이 없는 문제를 우회하는 데 사용할 수 있습니다.
이 PEP는 그런 우회 방법을 요구하는 대신, PEP 572의 “NAME := EXPR” 문법을 아래에 설명하는 복합 대입 스코프 규칙을 사용하는 새로운 인라인 대입 표현식으로 채택할 것을 제안합니다.
이는 새 값에만 관심이 있고 이전에 바인딩된 값(있는 경우)은 완전히 버려도 되는 경우를 깔끔하게 처리합니다.
단순한 이름과 복잡한 할당 대상 모두에 대해, 인라인 할당 연산자는 새 참조를 할당하기 전에 이전 참조를 읽지 않는다는 점에 유의하십시오. 그러나 함수 스코프에서 (직접 또는 스코프를 가진 표현식 내부에서) 사용될 때는 새 지역 변수를 암묵적으로 정의하지 않으며, 대신 (아래에서 복합 대입에 대해 설명하는 것처럼) TargetNameError를 발생시킵니다.
할당 연산자 우선순위
복합 대입 문의 기존 의미를 유지하기 위해, 인라인 대입 연산자는 쉼표 의사 연산자를 포함한 다른 모든 연산자보다 우선순위가 낮은 것으로 정의합니다. 이를 통해 최상위 표현식으로 사용될 때에도 표현식의 우변 전체가 (그 값이 괄호 없는 튜플인 경우에도) 여전히 처리될 값으로 해석되도록 보장합니다.
PEP 572와 비교했을 때 이 제안이 만드는 차이는 다음과 같습니다. (n := first, second)는 PEP 572에서 n = first를 수행하지만 이 PEP에서는 n = (first, second)를 수행하며, 앞의 의미를 얻으려면 괄호를 한 쌍 더 써야 합니다(((n := first), second)).
PEP 572는 대입 표현식을 함수 호출 인자로 사용할 때 이 때문에 모호성이 생긴다는 점을 매우 타당하게 지적합니다. 이 PEP는 대입 표현식을 함수 호출의 인자로 사용할 때(유일한 인자인 경우는 제외) 괄호로 감싸도록 요구하여 그 우려를 다른 방식으로 해결합니다.
이는 제너레이터 표현식에 적용되는 제약(함수 호출의 유일한 인자인 경우를 제외하면 항상 괄호가 필요함)을 더 완화한 버전입니다.
블록 스코프에서 이름에 대한 복합 대입
모듈이나 클래스 스코프에서의 복합 대입에 대해서는 대상 이름 바인딩 변경을 제안하지 않습니다(여기에는 “exec”나 “eval”을 사용하여 실행되는 코드도 포함됩니다). 이러한 증강 대입은 지금과 마찬가지로 새 지역 변수를 바인딩 대상으로 암묵적으로 선언하며, (필요하면) 지역에 바인딩하기 전에 바깥 스코프에서 이름을 결정할 수 있습니다.
함수 스코프에서는 복합 대입이 대상 이름을 함수의 지역 이름으로 명시적으로 확정하는 선행 이름 바인딩이나 변수 선언, 또는 명시적인 global 또는 nonlocal 선언 중 하나를 요구하도록 변경됩니다. 그러한 바인딩이나 선언이 없으면 새로운 SyntaxError 서브클래스인 TargetNameError가 컴파일 시점에 발생합니다.
예를 들어 다음 코드는 지금과 마찬가지로 컴파일되고 실행됩니다:
x = 0
x += 1 # Sets global "x" to 1
class C:
x += 1 # Sets local "x" to 2, leaves global "x" alone
def local_target():
x = 0
x += 1 # Sets local "x" to 1, leaves global "x" alone
def global_target():
global x
x += 1 # Increments global "x" each time this runs
def nonlocal_target():
x = 0
def g():
nonlocal x
x += 1 # Increments "x" in outer scope each time this runs
return x
return g
다음 예제는 모두 현재와 마찬가지로 여전히 컴파일된 뒤 런타임에 오류를 발생시킵니다:
n += 1 # Raises NameError at runtime
class C:
n += 1 # Raises NameError at runtime
def missing_global():
global n
n += 1 # Raises NameError at runtime
def delayed_nonlocal_initialisation():
def f():
nonlocal n
n += 1
f() # Raises NameError at runtime
n = 0
def skipped_conditional_initialisation():
if False:
n = 0
n += 1 # Raises UnboundLocalError at runtime
def local_declaration_without_initial_assignment():
n: typing.Any
n += 1 # Raises UnboundLocalError at runtime
반면 다음 코드는 처음에는 컴파일 시점에 DeprecationWarning을 발생시키고, 결국에는 컴파일 시점에 TargetNameError를 보고하도록 바뀝니다:
def missing_target():
x += 1 # Compile time TargetNameError due to ambiguous target scope
# Is there a missing initialisation of "x" here? Or a missing
# global or nonlocal declaration?
보수적인 구현 방식으로서, 컴파일 시점의 함수 이름 결정 변경은 Python 3.8에서 DeprecationWarning으로 도입한 다음 Python 3.9에서 TargetNameError로 전환합니다. 이렇게 하면 사용되지 않는 함수 때문에 생길 수 있는 문제를 피할 수 있습니다. 그런 함수는 실제로 호출된다면 현재 UnboundLocalError를 발생시키겠지만, 실제로는 사용되지 않습니다. 이처럼 잠재된 런타임 결함을 컴파일 시점 에러로 바꾸는 것은 폐지 예고 기간이 필요한, 하위 호환성을 깨는 변경에 해당합니다.
함수 스코프에서 복합 대입이 (독립된 문이 아니라) 표현식으로 사용될 때는 하위 호환성 문제가 전혀 없으므로, 컴파일 시점 이름 바인딩 검사가 Python 3.8에서 즉시 적용됩니다.
마찬가지로 새로운 인라인 대입 표현식을 함수 안에서 사용할 때는 적어도 Python 3.8에서는 항상 대상 스코프를 명시적으로 미리 선언해야 합니다. (향후 이 제약을 다시 검토할 가능성에 관해서는 설계 논의 절을 참조하십시오).
스코프를 가진 표현식에서 이름에 대한 복합 대입
스코프 표현식은 새로운 중첩 실행 스코프를 도입하는 표현식을 가리키기 위해 새로 제안하는 총칭입니다. 이런 표현식은 동작의 본질적인 일부로 스코프를 도입하거나(람다 표현식, 제너레이터 표현식), 이름 바인딩 연산을 둘러싼 스코프로부터 숨기기 위한 방법으로 스코프를 도입합니다(컨테이너 컴프리헨션).
일반 함수와 달리 이러한 스코프 표현식에는 바깥 스코프의 이름을 직접 다시 바인딩하기 위한 명시적인 global 또는 nonlocal 선언을 넣을 수 없습니다.
대신, 복합 대입 표현식에 대한 이름 바인딩 의미는 다음과 같이 정의됩니다:
- 스코프를 가진 표현식에서 사용되는 복합 대입 대상은 상위 블록 스코프에 이미 바인딩되어 있거나, 상위 블록 스코프에서 그 스코프가 명시적으로 선언되어 있어야 합니다. 해당 스코프에서 적절한 이름 바인딩이나 선언을 찾을 수 없으면 (스코프 표현식 안에 새 바인딩을 만드는 대신) 컴파일 시점에
TargetNameError가 발생합니다. - 둘러싼 블록 스코프가 함수 스코프이고 대상 이름이
global또는nonlocal로 명시적으로 선언되어 있으면, 스코프 표현식 본문에서도 같은 스코프 선언을 사용합니다 - 둘러싼 블록 스코프가 함수 스코프이고 대상 이름이 그 함수의 지역 변수이면, 스코프 표현식 본문에서 그 이름은 암묵적으로
nonlocal로 선언됩니다 - 상위 블록 스코프가 클래스 스코프이면
TargetNameError가 항상 발생하며, 클래스 스코프와 스코프를 가진 표현식에서의 복합 대입을 결합하는 것은 현재 허용되지 않는다는 전용 메시지가 함께 표시됩니다. - 이름이 형식 매개변수(람다 표현식)나 반복 변수(제너레이터 표현식, 컴프리헨션)로 선언된 경우, 그 이름은 해당 스코프를 가진 표현식의 지역 이름으로 간주되며, 그 스코프나 중첩된 스코프를 가진 표현식에서 그 이름을 복합 대입 연산의 대상으로 사용하려고 하면
TargetNameError가 발생합니다(이것은 나중에 해제될 수도 있는 제한이지만, 언어 레퍼런스에서 다루고 컴파일러와 인터프리터가 처리해야 하는 컴파일 시점 및 런타임 의미의 초기 집합을 단순화하기 위해 현재로서는 이렇게 제안합니다)
예를 들어 다음 코드는 보이는 대로 동작합니다:
>>> global_target = 0
>>> incr_global_target = lambda: global_target += 1
>>> incr_global_target()
1
>>> incr_global_target()
2
>>> global_target
2
>>> def cumulative_sums(data, start=0)
... total = start
... yield from (total += value for value in data)
... return total
...
>>> print(list(cumulative_sums(range(5))))
[0, 1, 3, 6, 10]
반면 다음 예제들은 모두 TargetNameError를 발생시킵니다:
class C:
cls_target = 0
incr_cls_target = lambda: cls_target += 1 # Error due to class scope
def missing_target():
incr_x = lambda: x += 1 # Error due to missing target "x"
def late_target():
incr_x = lambda: x += 1 # Error due to "x" being declared after use
x = 1
lambda arg: arg += 1 # Error due to attempt to target formal parameter
[x += 1 for x in data] # Error due to attempt to target iteration variable
현재 복합 대입은 스코프를 가진 표현식 내부에 나타날 수 없으므로, 위의 컴파일 시점 이름 결정 예외는 하위 호환성을 깨뜨릴 수 있는 변경으로서 단계적으로 도입할 필요 없이 초기 구현의 일부로 포함됩니다.
설계 논의
복잡한 대입 대상 허용
이 PEP의 초기 초안에서는 복합 대입이 표현식으로 사용될 때 PEP 572의 단일 이름 대상 제한을 유지하고, 속성 및 첨자 대상은 문 형태에서만 허용했습니다.
그러나 이를 강제하려면 복합 대입이 최상위 표현식인지 여부에 따라 허용되는 대상을 달리해야 했고, 또한 n += 1, (n += 1), self.n += 1은 모두 합법인데 (self.n += 1)은 금지되는 이유를 설명해야 했습니다. 그래서 제안을 단순화하여 표현식 형태에서도 기존의 모든 복합 대입 대상을 허용하도록 했습니다.
이 PEP는 TARGET := EXPR를 복합 대입의 변형으로 정의하므로, 이 역시 대입 및 첨자 대상을 지원하게 되었습니다.
복합 대입인가, 이름 바인딩만인가?
PEP 572는 인라인 복합 대입의 잠재적 사용 사례가 일반적인 인라인 대입의 사용 사례보다 눈에 띄게 약하므로, 제자리(in-place) 복합 대입 메서드를 우회하여 x := x + 1로 작성하도록 요구해도 괜찮다는 타당한 주장을 펼칩니다.
이는 적어도 내장 타입에 대해서는 어느 정도 사실이라고 할 수 있지만(내장 타입의 경우 잠재적 반례는 아마도 PEP 작성자가 개인적으로 겪어 보지 않은 집합 조작 사용 사례에 초점을 맞춰야 할 것입니다), NumPy 배열 조작처럼 메모리를 더 많이 사용하는 사용 사례도 배제하게 됩니다. 이러한 사례에서는 제자리가 아닌(out-of-place) 연산에 수반되는 데이터 복사 때문에 그러한 연산이 제자리 연산의 대안으로서 비현실적일 수 있습니다.
그렇긴 하지만, 이 PEP가 존재하는 주된 이유는 PEP 작성자가 인라인 대입 제안을 “+=와 같되, 덧셈 단계만 건너뛰는 것”으로 이해하는 편이 훨씬 쉽다고 느꼈기 때문이며, 또한 그러한 관점이 함수 스코프에서 NAME = EXPR과 NAME := EXPR 사이에 실제적인 의미상의 차이를 제공한다는 점이 마음에 들었기 때문입니다.
이러한 대상 스코프 동작의 차이로 인해 NAME := EXPR 문법은 두 가지 주요 사용 사례를 가질 것으로 예상됩니다:
if문이나while문 안에, 또는 스코프가 있는 표현식의 일부로 대입을 표현식으로서 포함할 수 있도록 하는 방법- 대상 이름이 현재 함수 스코프에서 이전에 선언되었거나 바인딩되었는지를 컴파일 시간에 검사하도록 요청하는 방법
모듈 스코프나 클래스 스코프에서는 컴파일러가 런타임에 해석 가능한 이름의 집합을 알 수 없으므로 NAME = EXPR과 NAME := EXPR이 의미상 동등하겠지만, 코드 린터와 정적 타입 검사기는 컴파일러가 함수 스코프에서 강제하는 것과 동일한 “사용 전에 선언 또는 대입 필요” 동작을 NAME := EXPR에 대해서도 강제하도록 권장됩니다.
표현식 수준 대상 선언에 대한 결정 연기
적어도 Python 3.8에서는, 함수 스코프에서 인라인 대입(증강 대입 여부와 관계없이)을 사용할 때 TargetNameError가 발생하지 않도록 하려면, 스코프가 있는 표현식 밖에서 사용하는 경우에도 항상 선행하는 이름 바인딩이나 스코프 선언이 필요합니다.
이 요구 사항의 의도는 다음 두 가지 언어 설계 질문을 명확하게 분리하는 것입니다:
- 표현식이 현재 스코프에서 이름을 다시 바인딩할 수 있습니까?
- 표현식이 현재 스코프에서 새 이름을 선언할 수 있습니까?
모듈 전역 스코프의 경우 두 질문에 대한 답은 모두 분명히 “예”입니다. globals() 딕셔너리를 변경하면 런타임 모듈 스코프에 즉시 영향을 미친다는 것은 언어 수준에서 보장되는 사항이며, 함수 안의 global NAME 선언도 같은 효과를 낼 수 있기 때문입니다(현재 실행 중인 모듈을 임포트하여 그 속성을 수정하는 것도 마찬가지입니다).
클래스 스코프의 경우에도 실제로는 두 질문에 대한 답이 모두 “예”이지만, locals()의 의미가 현재 공식적으로 명세되어 있지 않으므로 그만큼 명백하지는 않습니다. 그러나 (PEP 558이 제안하는 것처럼) 클래스 스코프에서의 현재 locals() 동작을 규범으로 받아들인다면, 이는 locals()를 대신 사용한다는 점만 다를 뿐 본질적으로 모듈 전역 변수를 조작하는 것과 같은 시나리오입니다.
그러나 함수 스코프의 경우, 이 두 질문에 대한 현재의 답은 각각 “예”와 “아니요”입니다. 함수 지역 변수의 표현식 수준 재바인딩은 어휘적으로 중첩된 스코프와 명시적인 nonlocal NAME 표현식 덕분에 이미 가능합니다. 이 PEP로 인해 표현식 수준 재바인딩이 지금보다 더 흔해질 가능성이 높지만, 이는 언어에 근본적으로 새로운 개념은 아닙니다.
이와 대조적으로, 새로운 함수 지역 변수를 선언하는 것은 현재 문 수준의 동작이며, 다음 중 하나를 수반합니다:
- 대입문(
NAME = EXPR,OTHER_TARGET = NAME = EXPR등) - 변수 선언(
NAME : EXPR) - 중첩된 함수 정의
- 중첩된 클래스 정의
for루프with문except절(접근 범위가 제한됨)
언어의 역사적 추세는 실제로 함수 지역 이름의 표현식 수준 선언에 대한 지원을 제거하는 방향이었습니다. 처음에는 “빠른 지역 변수(fast locals)” 의미 체계의 도입으로(이로 인해 함수 스코프에서는 locals()를 통한 이름 도입이 지원되지 않게 되었습니다), 그리고 다시 Python 3.0에서 컴프리헨션 반복 변수를 숨기는 것으로 그러했습니다.
이제 Python 3.9에서는 Python 3.8의 표현식 수준 이름 바인딩 경험을 바탕으로 이 질문을 다시 검토하기로 하고, 표현식 수준 함수 지역 변수 선언도 정말로 원하며 이를 NAME := EXPR로 작성하기를 원한다고 결정할 수도 있습니다(예를 들어 인라인 선언을 NAME := EXPR given NAME처럼 더 명시적으로 작성하는 대신 말입니다. 이렇게 작성하면 인라인 선언에 타입 어노테이션을 붙일 수 있고, 또한 이를 포함하는 스코프의 네임스페이스를 오염시킬 필요 없이 스코프가 있는 표현식 안에서 새 지역 변수를 선언할 수도 있습니다).
하지만 이 PEP의 제안은 우리가 그 기능을 얼마나 원하는지, 그리고 정확히 어디에서 그 기능의 부재가 불편하게 느껴지는지를 결정하기 위해 릴리스 한 번 전체의 기간을 명시적으로 갖자는 것입니다. Python은 수십 년 동안 표현식 수준의 이름 바인딩 또는 선언 없이도 잘 지내 왔으므로, 우리가 정말로 그 둘 다를 원하는지, 아니면 표현식 수준 바인딩만으로 충분한지를 결정하는 데 몇 년의 시간을 들일 여유가 있습니다.
복합 대입 대상을 결정할 때 스코프가 있는 표현식 무시하기
PEP 572의 대입 표현식에 적용할 수 있는 바인딩 의미론을 논의하면서, Tim Peters는 대입 표현식이 중간에 끼어 있는 스코프를 가진 표현식을 사실상 모두 무시하고 자신을 포함하는 블록 스코프를 대상으로 삼아야 한다는 설득력 있는 주장을 펼쳤습니다 [1], [2], [3].
이 접근 방식을 사용하면 누적 합계나 제너레이터 표현식에서 최종 값을 추출하는 것과 같은 사용 사례를 비교적 간단하게 작성할 수 있습니다:
total = 0
partial_sums = [total := total + value for value in data]
factor = 1
while any(n % (factor := p) == 0 for p in small_primes):
n //= factor
Guido도 이 전반적인 접근 방식에 찬성 의사를 밝혔습니다 [4].
이 PEP의 제안은 세 가지 주요 측면에서 Tim의 원래 제안과 다릅니다:
- 이 제안을 하나의 새로운 이름 바인딩 연산자에만 적용하는 것이 아니라 모든 복합 대입 연산자에 적용합니다
- 실용적인 범위 내에서, 이름이 이미 정의되어 있어야 한다는 복합 대입의 요구 사항을 새로운 이름 바인딩 연산자로 확장합니다(함수 스코프에서 새 지역 변수를 암묵적으로 선언하는 대신
TargetNameError를 발생시킵니다) - 대상 이름 바인딩에서 무시되는 스코프의 집합에 람다 표현식을 포함하여, 대입에 대한 이러한 투명성이 컴프리헨션과 제너레이터 표현식에만 국한되지 않고 스코프를 가진 모든 표현식에 공통으로 적용되도록 합니다
바인딩 대상을 계산할 때 스코프를 가진 표현식이 무시되므로, 제너레이터 표현식 및 컴프리헨션에서 가장 바깥쪽 이터러블 표현식의 스코프 차이를 감지하기가 다시 어려워집니다 (이를 감지하려면 클래스 스코프를 이리저리 다루거나 반복 변수를 다시 바인딩하려고 시도해야 합니다). 따라서 그 부분을 손볼 필요도 없습니다.
인라인 대입을 복합 대입의 변형으로 취급하기
PEP 572의 과제 중 하나는 NAME = EXPR와 NAME := EXPR가 모든 스코프에서 의미상 완전히 동등하다는 사실입니다. 이 때문에 두 형식을 가르치기가 어렵습니다. 문장 수준에서 어느 한쪽을 선택하도록 자연스럽게 유도하는 요소가 없어서, 결국 “NAME = EXPR가 더 오래전부터 있었으므로 선호된다”는 설명에 기댈 수밖에 없게 됩니다 (그리고 PEP 572는 이러한 역사적 특이점을 컴파일러 수준에서 강제하도록 제안합니다).
if NAME := EXPR:와 while NAME := EXPR:가 합리적으로 동작하도록 유지하면서 모듈 및 클래스 스코프에서 그러한 의미상의 동등성을 피하기는 어렵습니다. 그러나 함수 스코프에서는 컴파일러가 모든 지역 이름을 포괄적으로 파악하고 있으므로, 이름이 사용되기 전에 대입되거나 선언되어 있도록 요구할 수 있습니다. 이는 가능한 경우 계속해서 NAME = EXPR 형식을 기본으로 사용할 합리적인 동기를 제공하는 동시에, NAME := EXPR를 일종의 간단한 컴파일 시점 단언으로 사용할 수 있게 합니다 (즉, 대상 이름이 이미 바인딩되었거나 선언되었으므로 컴파일러가 이미 알고 있어야 함을 명시적으로 나타냅니다).
만약 Guido가 인라인 선언 지원이 필수 설계 요구 사항이라고 선언한다면, 이 PEP는 임의의 표현식 뒤에서 인라인 이름 선언을 지원하는 방법으로 EXPR given NAME도 도입하도록 제안하는 방향으로 갱신될 것입니다 (이렇게 하면 인라인 이름 선언을 복잡한 표현식의 중간에 끼워 넣을 필요 없이 표현식의 끝까지 미룰 수 있으며, PEP 8에는 그러한 스타일을 권장하는 권고 사항이 추가될 것입니다).
클래스 수준의 스코프가 있는 표현식에서 복합 대입 금지하기
최신 클래스는 메서드 구현에서 볼 수 있는 암묵적인 클로저를 실제로 정의하지만 (인자 없는 super() 호출에서 __class__를 사용할 수 있도록 하기 위함입니다), 사용자 수준 코드가 해당 스코프에 이름을 명시적으로 추가할 방법은 없습니다.
한편, 클래스 본문에 정의된 속성은 메서드의 렉시컬 클로저를 정의할 때 무시되므로, 그곳에 속성을 추가하는 것은 구현 수준에서 동작하지 않습니다.
이 PEP는 그러한 본질적인 모호성을 해결하려 하기보다는 그러한 사용을 단순히 금지하며, 영향을 받는 로직은 모두 클래스 본문에 직접 인라인으로 작성하지 말고 다른 곳(예: 별도의 헬퍼 함수)에 작성하도록 요구합니다.
비교 연산자 대 대입 연산자
현재 표현식으로서의 OP= 구문은 비교 연산을 나타냅니다:
x == y # Equals
x >= y # Greater-than-or-equal-to
x <= y # Less-than-or-equal-to
이 PEP와 PEP 572 모두 외관은 다소 비슷하지만 실제로는 대입을 정의하는 연산자를 적어도 하나 추가하도록 제안합니다:
x := y # Becomes
이 PEP는 여기서 훨씬 더 나아가 13개의 복합 대입 기호 모두를 이항 연산자로 사용할 수 있도록 합니다:
x += y # In-place add
x -= y # In-place minus
x *= y # In-place multiply
x @= y # In-place matrix multiply
x /= y # In-place division
x //= y # In-place int division
x %= y # In-place mod
x &= y # In-place bitwise and
x |= y # In-place bitwise or
x ^= y # In-place bitwise xor
x <<= y # In-place left shift
x >>= y # In-place right shift
x **= y # In-place power
이렇게 추가되는 이항 연산자 중 가장 문제의 소지가 큰 것은 비트 시프트 대입 연산자일 것입니다. 각 연산자가 경계를 포함하는 순서 비교 연산자 중 하나와 문자 하나가 중복되어 있다는 차이밖에 없기 때문입니다.
예시
재시도 루프 단순화하기
현재 재시도 루프를 작성하는 방법에는 다음을 비롯한 몇 가지 선택지가 있습니다:
# Post-decrementing a counter
remaining_attempts = MAX_ATTEMPTS
while remaining_attempts:
remaining_attempts -= 1
try:
result = attempt_operation()
except Exception as exc:
continue # Failed, so try again
log.debug(f"Succeeded after {attempts} attempts")
break # Success!
else:
raise OperationFailed(f"Failed after {MAX_ATTEMPTS} attempts") from exc
# Loop-and-a-half with a pre-incremented counter
attempt = 0
while True:
attempts += 1
if attempts > MAX_ATTEMPTS:
raise OperationFailed(f"Failed after {MAX_ATTEMPTS} attempts") from exc
try:
result = attempt_operation()
except Exception as exc:
continue # Failed, so try again
log.debug(f"Succeeded after {attempts} attempts")
break # Success!
사용할 수 있는 각 선택지는 상태 변경이든, 종료 조건이든, 또는 둘 다이든, 의도한 루프 구조의 일부를 루프 본문 안에 숨깁니다.
이 PEP의 제안을 사용하면 상태 변경과 종료 조건을 모두 루프 헤더에 직접 포함할 수 있습니다:
attempt = 0
while (attempt += 1) <= MAX_ATTEMPTS:
try:
result = attempt_operation()
except Exception as exc:
continue # Failed, so try again
log.debug(f"Succeeded after {attempts} attempts")
break # Success!
else:
raise OperationFailed(f"Failed after {MAX_ATTEMPTS} attempts") from exc
if-elif 체인 단순화하기
검사하는 조건을 다시 바인딩해야 하는 if-elif 체인은 현재 중첩된 if-else 문을 사용하여 작성해야 합니다:
m = pattern.match(data)
if m:
...
else:
m = other_pattern.match(data)
if m:
...
else:
m = yet_another_pattern.match(data)
if m:
...
else:
...
PEP 572와 마찬가지로, 이 PEP는 해당 체인의 else/if 부분을 압축할 수 있게 하여, 체인의 일관되고 상호 배타적인 구조를 더 쉽게 알아볼 수 있게 합니다:
m = pattern.match(data)
if m:
...
elif m := other_pattern.match(data):
...
elif m := yet_another_pattern.match(data):
...
else:
...
PEP 572와 달리, 이 PEP는 대입 대상이 := 대상으로 처음 사용되기 전에 지역 변수임을 명시적으로 나타내도록 요구합니다. 이는 (위에서 보인 것처럼) 값에 바인딩하거나, 적절한 명시적 타입 선언을 포함하는 방식으로 할 수 있습니다:
m: typing.re.Match
if m := pattern.match(data):
...
elif m := other_pattern.match(data):
...
elif m := yet_another_pattern.match(data):
...
else:
...
컴프리헨션에서 중간값 포착하기
이 PEP의 제안을 따르면 컴프리헨션과 제너레이터 표현식의 중간값을 이를 둘러싼 블록 스코프로 내보내어 간단하게 포착하고 재사용할 수 있습니다:
factor: int
while any(n % (factor := p) == 0 for p in small_primes):
n //= factor
total = 0
partial_sums = [total += value for value in data]
람다 표현식이 재사용 가능한 코드 썽크(thunk)처럼 동작하도록 허용하기
이 PEP를 따르면 다음과 같은 고전적인 클로저 사용 예를:
def make_counter(start=0):
x = start
def counter(step=1):
nonlocal x
x += step
return x
return counter
다음과 같이 축약할 수 있습니다:
def make_counter(start=0):
x = start
return lambda step=1: x += step
후자의 형태도 여전히 개념적으로 밀도 높은 코드입니다. 그래도 상용구 코드가 없다는 점(“def”, “nonlocal”, “return” 키워드와 두 번 더 반복되는 “x” 변수 이름이 “lambda” 키워드로 대체되었다는 점) 덕분에 실제로는 더 읽기 쉬울 수 있다고 충분히 주장할 수 있습니다.
PEP 572와의 관계
애초에 인라인 대입을 허용해야 하는 이유는 PEP 572에 제시되어 있습니다. 이 경쟁 PEP는 처음에는 PEP 572의 표현식 의미는 유지하면서 대체 표면 문법(EXPR given NAME = EXPR)을 제안하려고 했습니다. 그러나 애초에 내장된 대입을 허용하려는 초기 동기가 된 사용 사례 중 하나, 즉 컴프리헨션과 제너레이터 표현식에서 누적 합계를 쉽게 계산할 수 있게 하는 것을 논의하면서 방향이 바뀌었습니다.
그 결과, PEP 572와 달리 이 PEP는 주로 인라인 복합 대입의 사용 사례에 초점을 맞춥니다. 또한 현재는 함수 호출 시점에 필연적으로 UnboundLocalError를 발생시키는 경우가, 컴파일 시점에 새로운 TargetNameError를 보고하도록 바뀌는 효과도 있습니다.
그런 다음 이름 재바인딩 표현식(NAME := TARGET)을 위한 새로운 문법이 추가됩니다. 이 문법은 제안되는 유일한 변경 사항이 아니며, PEP 572에서 식별된 것과 같은 사용 사례를 처리하기 위해서일 뿐만 아니라 새로운 복합 대입 의미를 설명하고 구현하고 해설하는 데 도움이 되는 저수준 기본 요소로서 추가됩니다.
이 PEP의 작성자는 이 접근 방식이 이름 재바인딩의 새로운 유연성이 지닌 가치를 더 분명하게 보여 준다고 믿습니다. 동시에 언제 NAME := EXPR 대신 NAME = EXPR을 사용해야 하는지(그리고 그 반대의 경우)를 설명하는 문제를 두고 PEP 572에 제기된 여러 잠재적 우려도 덜어 줍니다. 그러면서도 NAME := EXPR의 단독 문장 형태를 아예 금지하는 방법(즉 NAME := EXPR은 컴파일 오류이지만 (NAME := EXPR)은 허용되는 방식)에 기대지 않습니다.
감사의 글
PEP 작성자는 PEP 572에 대한 작업과 python-ideas 및 python-dev 양쪽에서 생겨난 수많은 방대한 논의를 일관성 있게 요약하려고 노력한 Chris Angelico에게, 그리고 스코프가 있는 표현식 내부의 복합 대입에 대한 위의 스코프 제안을 이끌어 낸 부모 지역 스코프에 관한 심도 있는 논의를 해 준 Tim Peters에게 감사를 전합니다.
이 PEP의 사전 릴리스 버전에 대한 Eric Snow의 피드백은 이 PEP의 가독성을 크게 높이는 데 도움이 되었습니다.
참고 문헌
Copyright
This document has been placed in the public domain.