PEP 558 – locals()의 의미 정의
- Author:
- Alyssa Coghlan <ncoghlan at gmail.com>
- BDFL-Delegate:
- Nathaniel J. Smith
- Discussions-To:
- Python-Dev list
- Status:
- Withdrawn
- Type:
- Standards Track
- Created:
- 08-Sep-2017
- Python-Version:
- 3.13
- Post-History:
- 08-Sep-2017, 22-May-2019, 30-May-2019, 30-Dec-2019, 18-Jul-2021, 26-Aug-2021
Table of Contents
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
PEP 철회
2021년 12월, 이 PEP와 PEP 667은 locals() 내장 함수의 Python 수준 의미에 대해 제안된 변경 사항의 공통 정의에 합의했습니다(아래 PEP 본문에 기술된 대로). 남은 차이점은 제안된 C API 변경 사항과 여러 내부 구현 세부 사항뿐입니다.
남은 차이 가운데 가장 중요한 것은, 당시 PEP 667이 PEP가 수락되어 구현되는 즉시 PyEval_GetLocals() API의 하위 호환성을 곧바로 깨뜨리자고 여전히 제안하고 있었다는 점입니다.
이후 PEP 667은 PyEval_GetLocals() API에 넉넉한 폐지 예고 기간을 두어, 새 PyEval_GetFrameLocals() API가 제공하는 개선된 의미와 병행하여 계속 지원하자고 제안하도록 변경되었습니다.
남아 있는 C API 설계 관련 우려는 필요하다고 판단될 경우 나중에 추가할 수 있는 새로운 정보 제공용 API에 관한 것이며, 프레임 지역 변수 뷰 구현의 정확한 성능 특성에 대한 잠재적 우려보다는 실용적인 참조 구현이 존재한다는 이점이 더 큽니다.
따라서 이 PEP는 PEP 667을 진행하기로 하고 철회되었습니다.
참고: PEP 667을 구현하는 과정에서, 최적화된 스코프에서 locals()가 독립적인 스냅숏을 반환하도록 변경된 근거와 그 영향이 두 PEP 어디에서도 완전히 명확하지 않았다는 점이 드러났습니다. 이에 따라 이 PEP의 동기 및 근거 절이 갱신되었습니다(이러한 측면은 수락된 PEP 667에도 동일하게 적용되기 때문입니다).
초록
locals() 내장 함수의 의미는 역사적으로 충분히 명세되지 않았으며, 따라서 구현에 따라 달라졌습니다.
이 PEP는 대부분의 실행 스코프에 대해 CPython 3.10 참조 구현의 동작을 공식 표준으로 삼되, 함수 스코프에서의 동작을 일부 조정하여 더 예측 가능하고 추적 함수의 존재 여부와 무관하게 만들 것을 제안합니다.
또한 다음 함수들을 안정 Python C API/ABI에 추가할 것을 제안합니다.
typedef enum {
PyLocals_UNDEFINED = -1,
PyLocals_DIRECT_REFERENCE = 0,
PyLocals_SHALLOW_COPY = 1,
_PyLocals_ENSURE_32BIT_ENUM = 2147483647
} PyLocals_Kind;
PyLocals_Kind PyLocals_GetKind();
PyObject * PyLocals_Get();
PyObject * PyLocals_GetCopy();
아울러 CPython C API에 여러 보조 함수와 타입 정의를 추가할 것도 제안합니다.
동기
locals() 내장 함수의 정확한 의미는 명목상 정의되어 있지 않지만, 실제로는 많은 파이썬 프로그램이 이 함수가 CPython에서와 정확히 똑같이 동작한다는 데 의존합니다(적어도 추적 함수가 설치되지 않은 경우에는).
PyPy 같은 다른 구현들은 현재 그 동작을 복제하고 있으며, 심지어 추적 훅이 설치되었을 때 발생할 수 있는 지역 변수 변경 버그까지 복제하고 있습니다 [1].
이 PEP는 추적 훅이 설치되지 않았을 때 CPython의 현재 동작은 대체로 수용할 만하다고 보지만, 추적 훅이 설치되었을 때의 현재 동작은 문제가 있다고 봅니다. 이 동작은 [1] 같은 버그를 일으키면서도, pdb 같은 디버거가 지역 변수를 변경할 수 있게 한다는 원래 의도한 기능조차 안정적으로 제공하지 못하기 때문입니다 [3].
이후 초기 PEP와 초안 구현을 검토하는 과정에서, 함수 수준 locals()가 CPython에서 역사적으로 반환해 온 반동적(semi-dynamic)이고 간헐적으로 갱신되는 공유 사본을 계속 반환하는 대신, 호출할 때마다 함수 지역 변수와 클로저 변수의 독립적인 스냅숏을 반환하도록 동작을 변경하면 문서와 구현을 모두 단순화할 수 있다는 점이 확인되었습니다.
구체적으로, 이 PEP의 제안은 새로운 지역 변수를 추가하면 함수 스코프에서 exec()로 실행되는 코드의 동작이 바뀔 수 있었던 역사적인 동작을 제거합니다. 이는 해당 코드가 그 지역 변수가 정의되기 전에 실행되더라도 마찬가지였습니다.
예를 들어:
def f():
exec("x = 1")
print(locals().get("x"))
f()
1을 출력하지만,:
def f():
exec("x = 1")
print(locals().get("x"))
x = 0
f()
None을 출력합니다(.get() 호출의 기본값입니다).
이 PEP에서는 두 예제 모두 None을 출력합니다. exec() 호출과 그 뒤의 locals() 호출이 프레임 객체에 캐시된 동일한 공유 딕셔너리를 사용하는 대신, 지역 변수의 독립적인 딕셔너리 스냅숏을 사용하기 때문입니다.
제안
locals() 내장 함수에 기대되는 의미는 현재 실행 스코프에 따라 달라집니다. 이를 위해 정의하는 실행 스코프는 다음과 같습니다:
- 모듈 스코프: 최상위 모듈 코드, 그리고 단일 네임스페이스로
exec()또는eval()을 사용하여 실행되는 그 밖의 모든 코드 - 클래스 스코프:
class문 본문의 코드, 그리고 지역 네임스페이스와 전역 네임스페이스를 따로 두고exec()또는eval()을 사용하여 실행되는 그 밖의 모든 코드 - 함수 스코프:
def또는async def문 본문의 코드, 또는 CPython에서 최적화된 코드 블록을 만드는 그 밖의 모든 구문(예: 컴프리헨션, 람다 함수)
이 PEP는 CPython 참조 구현의 현재 동작 대부분을 언어 명세의 일부로 격상할 것을 제안합니다. 단, 함수 스코프에서는 locals()를 호출할 때마다 새로운 딕셔너리 객체를 생성한다는 점이 예외입니다. 매 호출이 갱신하여 반환하는 공통 dict 인스턴스를 프레임 객체에 캐시하지 않습니다.
이 PEP는 또한 CPython 참조 구현에서 별도의 “추적(tracing)” 모드라는 개념을 대부분 제거할 것을 제안합니다. Python 3.10까지의 릴리스에서 CPython 인터프리터는 CPython sys 모듈의 sys.settrace ([4])나 CPython C API의 PyEval_SetTrace ([5]) 같은 구현 의존적 메커니즘을 통해 하나 이상의 스레드에 추적 훅이 등록되었을 때 다르게 동작합니다. 이 PEP가 수락되면, 추적 훅이 설치되었을 때 남는 유일한 동작 차이는 추적 로직이 각 옵코드 이후에 실행되어야 할 때 인터프리터 평가 루프의 일부 최적화가 비활성화된다는 점뿐입니다.
이 PEP는 함수 스코프에서의 CPython 동작을 변경하여, 추적 훅이 등록되었을 때의 locals() 내장 함수 의미 체계를 추적 훅이 등록되지 않았을 때 사용되는 의미 체계와 동일하게 만드는 한편, 관련 프레임 API 의미 체계를 더 명확하게 하고 대화형 디버거가 더 쉽게 의존할 수 있도록 할 것을 제안합니다.
추적 모드를 제거하자는 제안은 트레이스백이나 sys._getframe() API 등 다른 수단으로 얻은 프레임 객체 참조의 의미에도 영향을 줍니다. 추적 훅 지원에 필요한 write-through 의미를 런타임 상태와 관계없이 항상 프레임 객체의 f_locals 속성이 제공하기 때문입니다.
새로운 locals() 문서
이 제안의 핵심은 locals() 내장 함수의 문서를 다음과 같이 수정하는 것입니다:
현재 지역 심볼 테이블을 나타내는 매핑 객체를 반환합니다. 변수 이름이 키이고, 현재 바인딩된 참조가 값입니다.모듈 스코프에서, 그리고 단일 네임스페이스로
exec()나eval()을 사용할 때, 이 함수는globals()와 같은 네임스페이스를 반환합니다.클래스 스코프에서는 메타클래스 생성자에 전달될 네임스페이스를 반환합니다.
별도의 지역 및 전역 네임스페이스로
exec()나eval()을 사용할 때는 함수 호출에 전달된 지역 네임스페이스를 반환합니다.위의 모든 경우에, 주어진 실행 프레임에서
locals()를 호출할 때마다 같은 매핑 객체를 반환합니다.locals()에서 반환된 매핑 객체를 통해 이루어진 변경은 바인딩, 재바인딩 또는 삭제된 지역 변수로 나타나며, 지역 변수를 바인딩, 재바인딩 또는 삭제하면 반환된 매핑 객체의 내용에 즉시 영향을 미칩니다.반면 함수 스코프에서는(제너레이터와 코루틴 포함)
locals()를 호출할 때마다 함수의 지역 변수와 비지역 셀 참조의 현재 바인딩을 담은 새 딕셔너리를 반환합니다. 이 경우 반환된 dict를 통해 이루어진 이름 바인딩 변경은 해당 지역 변수나 비지역 셀 참조에 다시 기록되지 않으며, 지역 변수와 비지역 셀 참조를 바인딩, 재바인딩 또는 삭제해도 이전에 반환된 딕셔너리의 내용에 영향을 미치지 않습니다.
이 변경을 적용하는 릴리스에 대해 versionchanged 노트도 추가됩니다:
이전 버전에서는locals()에서 반환된 매핑 객체를 변경하는 것의 의미 체계가 공식적으로 정의되지 않았습니다. 특히 CPython에서는 함수 스코프에서 반환된 매핑이locals()를 다시 호출하거나 인터프리터가 파이썬 수준 추적 함수를 암묵적으로 호출하는 등의 다른 연산에 의해 암묵적으로 갱신될 수 있었습니다. 이제 기존 CPython 동작을 얻으려면 처음 반환된 딕셔너리를 이후locals()호출 결과로 갱신하는 호출을 명시적으로 수행해야 합니다.
참고로, 이 내장 함수의 현재 문서는 다음과 같습니다:
현재 지역 심볼 테이블을 나타내는 딕셔너리를 갱신하고 반환합니다. 자유 변수는 함수 블록에서 locals()를 호출할 때는 반환되지만, 클래스 블록에서는 반환되지 않습니다.참고: 이 딕셔너리의 내용은 수정해서는 안 됩니다. 변경 사항이 인터프리터가 사용하는 지역 변수와 자유 변수의 값에 영향을 미치지 않을 수 있습니다.
(다시 말해, 현재 상태는 locals()의 의미 체계와 동작이 공식적으로 구현 정의인 반면, 이 PEP 이후 제안되는 상태에서는 구현이 CPython 프레임 API를 에뮬레이션하는지 여부와 관련된 동작만이 유일한 구현 정의 동작이 되며, 그 밖의 모든 경우의 동작은 언어 레퍼런스와 라이브러리 레퍼런스에 의해 정의됩니다)
모듈 스코프
모듈 스코프에서, 그리고 단일 네임스페이스로 exec()나 eval()을 사용할 때, locals()는 globals()와 같은 객체를 반환해야 하며, 이 객체는 실제 실행 네임스페이스여야 합니다(프레임 객체에 대한 접근을 제공하는 구현에서는 inspect.currentframe().f_locals로 사용할 수 있습니다).
같은 스코프에서 이후 코드가 실행되는 동안의 변수 할당은 반환된 매핑의 내용을 동적으로 변경해야 하며, 반환된 매핑에 대한 변경은 실행 환경에서 지역 변수 이름에 바인딩된 값을 변경해야 합니다.
이 기대 사항을 언어 명세의 일부로 반영하기 위해 locals() 문서에 다음 단락이 추가됩니다:
모듈 스코프에서, 그리고 단일 네임스페이스로exec()나eval()을 사용할 때, 이 함수는globals()와 같은 네임스페이스를 반환합니다.
제안의 이 부분은 참조 구현에 어떠한 변경도 요구하지 않습니다. 이는 현재 동작을 표준화하는 것입니다.
클래스 스코프
클래스 스코프에서, 그리고 전역 네임스페이스와 지역 네임스페이스를 별도로 지정하여 exec() 또는 eval()을 사용할 때, locals()는 지정된 지역 네임스페이스를 반환해야 합니다(클래스의 경우 이 네임스페이스는 메타클래스의 __prepare__ 메서드가 제공할 수 있습니다). 모듈 스코프의 경우, 이는 실제 실행 네임스페이스에 대한 직접 참조여야 합니다(프레임 객체에 대한 접근을 제공하는 구현에서는 inspect.currentframe().f_locals로 사용할 수 있습니다).
같은 스코프에서 이후 코드가 실행되는 동안 이루어지는 변수 대입은 반환된 매핑의 내용을 바꿔야 하며, 반환된 매핑에 대한 변경은 실행 환경에서 지역 변수 이름에 바인딩된 값을 바꿔야 합니다.
locals()가 반환하는 매핑은 정의된 클래스의 바탕이 되는 실제 클래스 네임스페이스로 사용되지 않습니다(클래스 생성 과정에서 그 내용이 새 딕셔너리로 복사되며, 이 딕셔너리는 클래스 메커니즘을 거쳐야만 접근할 수 있습니다).
함수 안에서 정의된 중첩 클래스의 경우, 클래스 스코프에서 참조하는 nonlocal 셀은 locals() 매핑에 포함되지 않습니다.
이 기대 사항을 언어 명세의 일부로 반영하기 위해, locals() 문서에 다음 두 단락을 추가합니다:
지역 네임스페이스와 전역 네임스페이스를 별도로 지정하여exec()또는eval()을 사용할 때, [이 함수]는 주어진 지역 네임스페이스를 반환합니다.클래스 스코프에서는 메타클래스 생성자에 전달될 네임스페이스를 반환합니다.
제안의 이 부분은 참조 구현을 전혀 변경할 필요가 없습니다. 현재 동작을 표준화하는 것이기 때문입니다.
함수 스코프
함수 스코프에서는 인터프리터 구현에 지역 변수 접근을 최적화할 상당한 재량이 주어지므로, locals()가 반환한 매핑을 통해 지역 변수와 nonlocal 변수의 바인딩을 임의로 수정하도록 허용할 필요가 없습니다.
지금까지 언어 명세는 이 관대함을 “이 딕셔너리의 내용은 수정해서는 안 됩니다. 변경 사항은 인터프리터가 사용하는 지역 변수와 자유 변수의 값에 영향을 주지 않을 수 있습니다.”라는 문구로 설명해 왔습니다.
이 PEP는 이 문구를 다음과 같이 바꿀 것을 제안합니다:
함수 스코프에서는(제너레이터와 코루틴을 포함하여)locals()를 호출할 때마다 함수의 지역 변수와 모든 nonlocal 셀 참조의 현재 바인딩을 담은 새 딕셔너리를 대신 반환합니다. 이 경우, 반환된 딕셔너리를 통해 이루어진 이름 바인딩 변경은 해당 지역 변수나 nonlocal 셀 참조에 다시 기록되지 않으며, 지역 변수와 nonlocal 셀 참조를 바인딩하거나, 다시 바인딩하거나, 삭제해도 이전에 반환된 딕셔너리의 내용에는 영향을 주지 않습니다.
제안의 이 부분은 CPython 참조 구현의 변경을 실제로 필요로 합니다. CPython은 현재 locals()를 추가로 호출하면 암묵적으로 갱신될 수 있는 공유 매핑 객체를 반환하며, 트레이스 함수에서 이루어지는 네임스페이스 변경을 지원하기 위해 현재 사용하는 “되쓰기(write back)” 전략도 이 명세를 따르지 않기 때문입니다(그리고 이 전략은 위의 동기 절에서 언급한 기이한 동작 문제를 일으킵니다).
CPython 구현 변경 사항
제안하는 구현별 변경 사항 요약
- 갱신된 Python 수준 의미 체계를 제공하는 데 필요한 만큼 변경을 가합니다
- Python
locals()내장 함수의 갱신된 동작을 재현하기 위해 안정 ABI에 두 개의 새 함수를 추가합니다:
PyObject * PyLocals_Get();
PyLocals_Kind PyLocals_GetKind();
- 실행 중인 프레임의 지역 네임스페이스 스냅숏을 효율적으로 얻기 위해 안정 ABI에 새 함수 하나를 추가합니다:
PyObject * PyLocals_GetCopy();
- 이 새 공개 API들에 대응하는 프레임 접근자 함수를 CPython 프레임 C API에 추가합니다
- 최적화된 프레임에서 Python 수준
f_localsAPI는 프레임의 지역 변수 및 클로저 변수 저장소에 직접 접근하는, 동적으로 생성된 읽기/쓰기 프록시 객체를 반환합니다. 기존PyEval_GetLocals()API와의 상호 운용성을 제공하기 위해, 프록시 객체는 C 수준의 프레임 locals 데이터 저장 필드를 계속 사용하여 값 캐시를 보관하며, 이 캐시에는 임의의 추가 키도 저장할 수 있습니다. 이 fast locals 프록시 객체에 기대되는 동작에 관한 추가 세부 사항은 아래에서 다룹니다. - 지역 네임스페이스에 대한 변경 가능한 매핑에 접근하기 위한 C API 함수는 추가하지 않습니다. 대신 Python 코드에서 사용하는 것과 같은 API인
PyObject_GetAttrString(frame, "f_locals")를 사용합니다. PyEval_GetLocals()는 계속 지원되며 프로그램 수준의 경고를 발생시키지 않지만, 빌린 참조를 반환하는 방식에 의존하지 않는 새 API를 권장하면서 문서에서는 폐지 예정으로 표시됩니다PyFrame_FastToLocals()와PyFrame_FastToLocalsWithError()는 계속 지원되며 프로그램 수준의 경고를 발생시키지 않지만, 프레임 객체의 내부 데이터 저장 구조에 직접 접근할 필요가 없는 새 API를 권장하면서 문서에서는 폐지 예정으로 표시됩니다PyFrame_LocalsToFast()는 항상RuntimeError()를 발생시키며, 이는 지역 변수에 대한 변경 가능한 읽기/쓰기 매핑을 얻으려면PyObject_GetAttrString(frame, "f_locals")를 사용해야 함을 나타냅니다.- 트레이스 훅 구현은 더 이상
PyFrame_FastToLocals()를 암묵적으로 호출하지 않습니다. 버전 이식 가이드에서는 읽기 전용 접근에는PyFrame_GetLocals()로, 읽기/쓰기 접근에는PyObject_GetAttrString(frame, "f_locals")로 이전할 것을 권장합니다.
갱신된 Python 수준 의미 제공
locals() 내장 함수의 구현은 최적화된 프레임에 대해 PyFrame_FastToLocals() C API가 갱신하고 PyEval_GetLocals() C API가 반환하는 내부 프레임 값 캐시에 대한 직접 참조 대신, 로컬 네임스페이스의 별도 사본을 반환하도록 변경됩니다.
추적 모드 동작 문제 해결
CPython 추적 모드의 이상 동작(추적 함수를 설치하기만 해도 생기는 부작용과, 함수 로컬 변수에 값을 되쓰는 작업이 추적 중인 특정 함수에서만 동작한다는 점 모두)의 현재 원인은 트레이스 훅을 위한 로컬 변수 변경 지원이 현재 구현된 방식, 즉 PyFrame_LocalsToFast 함수입니다.
추적 함수가 설치되면 CPython은 현재 함수 프레임(코드 객체가 “fast locals” 의미 체계를 사용하는 프레임)에 대해 다음을 수행합니다:
- 프레임 값 캐시를 갱신하기 위해
PyFrame_FastToLocals를 호출합니다 - 트레이스 훅을 호출합니다(훅 자체에 대한 추적은 비활성화한 상태로 호출합니다)
- 프레임 값 캐시에 가해진 변경 사항을 반영하기 위해
PyFrame_LocalsToFast를 호출합니다
이 접근 방식에는 몇 가지 이유로 문제가 있습니다:
- 추적 함수가 값 캐시를 변경하지 않더라도, 마지막 단계에서 모든 셀 참조가 추적 함수 호출 이전의 상태로 되돌아갑니다(이것이 [1]의 버그 보고의 근본 원인입니다)
- 추적 함수가 값 캐시를 실제로 변경하더라도, 이후에 값 캐시가 프레임으로부터 새로 고쳐지게 만드는 작업을 수행하면 그 변경 사항은 사라집니다(이것은 [3]의 버그 보고의 한 측면입니다)
- 추적 함수가 추적 중인 프레임이 아닌 다른 프레임의 지역 변수(예:
frame.f_back.f_locals)를 변경하려고 하면, 그 변경 사항은 거의 확실하게 손실됩니다(이는 [3]의 버그 보고서가 다루는 또 다른 측면입니다). - 프레임 값 캐시에 대한 참조(예:
locals()를 통해 얻은 참조)가 다른 함수에 전달되고 그 함수가 값 캐시를 변경하면, 트레이스 훅이 설치되어 있는 경우에 그 변경 사항이 실행 프레임에 되쓰여질 수도 있습니다
이 문제에 대해 제안하는 해결책은, 함수는 일반적으로 언어가 정의한 locals() 내장 함수를 사용하여 자신의 네임스페이스에 접근하는 반면, 추적 함수는 훅 구현에 전달되는 것이 프레임 참조이므로 반드시 구현에 의존적인 frame.f_locals 인터페이스를 사용한다는 사실을 활용하는 것입니다.
지금까지 locals() 내장 함수가 반환해 온 내부 프레임 값 캐시에 대한 직접 참조 대신, Python 수준의 frame.f_locals는 하부 프레임의 fast locals 배열에 값을 직접 쓰고 읽는 전용 fast locals 프록시 타입의 인스턴스를 반환하도록 변경됩니다. 이 속성에 접근할 때마다 프록시의 새 인스턴스가 생성됩니다(따라서 프록시 인스턴스 생성은 의도적으로 비용이 적은 연산입니다).
새 프록시 타입이 최적화된 프레임에서 지역 변수에 접근하는 권장 방법이 되었지만, 프레임에 저장된 내부 값 캐시는 두 가지 주요 목적을 위해 여전히 유지됩니다.
PyEval_GetLocals()C API에 대한 하위 호환성 및 상호 운용성 유지- fast locals 배열에 슬롯이 없는 추가 키를 위한 저장 공간 제공(예: 디버깅 목적으로 코드 실행을 추적할 때
pdb가 설정하는__return__및__exception__키)
이 PEP의 변경으로 인해, 이 내부 프레임 값 캐시는 더 이상 Python 코드에서 직접 접근할 수 없습니다(지금까지는 locals() 내장 함수가 이 캐시를 반환했고 frame.f_locals 속성으로도 사용할 수 있었습니다). 대신 값 캐시는 PyEval_GetLocals() C API를 통해서, 그리고 프레임 객체의 내부 저장소에 직접 접근하는 방법으로만 접근할 수 있습니다.
fast locals 프록시 객체와 PyEval_GetLocals()가 반환하는 내부 프레임 값 캐시는 다음과 같은 동작을 보장합니다:
- fast locals 프록시를 통해 이루어진 변경 사항은 프레임 자체, 같은 프레임에 대한 다른 fast locals 프록시 객체, 그리고 프레임에 저장된 내부 값 캐시에서 즉시 보입니다(
PyEval_GetLocals()상호 운용성을 제공하는 것이 바로 이 마지막 사항입니다) - 내부 프레임 값 캐시에 직접 이루어진 변경 사항은 프레임 자체에는 절대 보이지 않으며, 그 변경 사항이 프레임의 fast locals 배열에 슬롯이 없는 추가 변수에 관한 것일 때에만 같은 프레임에 대한 fast locals 프록시를 통해 확실하게 보입니다
- 프레임에서 코드를 실행하여 이루어진 변경 사항은 해당 프레임에 대한 모든 fast locals 프록시 객체(기존 프록시와 새로 생성된 프록시 모두)에서 즉시 보입니다.
PyEval_GetLocals()가 반환하는 내부 프레임 값 캐시에서 보이는지 여부는 다음 절에서 논의하는 캐시 갱신 지침을 따릅니다
이러한 점들의 결과로 PyEval_GetLocals(), PyLocals_Get() 또는 PyLocals_GetCopy()를 사용하는 코드만 프레임 값 캐시가 오래된 상태가 될 가능성을 염려하면 됩니다. 새 프레임 fast locals 프록시 API를 사용하는 코드는 (Python에서든 C에서든) 항상 프레임의 실시간 상태를 보게 됩니다.
fast locals 프록시 구현 세부 사항
각 fast locals 프록시 인스턴스에는 Python 런타임 API의 일부로 노출되지 않는 내부 속성이 하나 있습니다.
- frame: 프록시가 접근을 제공하는 기반 최적화 프레임
또한 프록시 인스턴스는 하부 프레임이나 코드 객체에 저장된 다음 속성을 사용하고 갱신합니다.
- _name_to_offset_mapping: 변수 이름을 빠른 지역 변수(fast local) 저장소 오프셋에 대응시키는 숨겨진 매핑입니다. 이 매핑은 첫 번째 빠른 지역 변수 프록시가 생성되자마자 미리 채워지지 않습니다. 빠른 지역 변수 프록시를 통해 프레임을 처음 읽거나 쓸 때 지연 초기화됩니다. 이 매핑은 주어진 코드 객체를 실행하는 모든 프레임에서 동일하므로, 각 프레임 객체가 자신의 매핑을 따로 채우는 대신 코드 객체에 사본 하나만 저장됩니다
- locals:
PyEval_GetLocals()C API가 반환하고PyFrame_FastToLocals()C API가 갱신하는 내부 프레임 값 캐시입니다. 이것은 Python 3.10 및 그 이전 버전에서locals()내장 함수가 반환하는 매핑입니다.
프록시에 대한 __getitem__ 연산은 (아직 채워지지 않았다면) 코드 객체의 _name_to_offset_mapping을 채웁니다. 그다음 (키가 _name_to_offset_mapping 매핑이나 내부 프레임 값 캐시 중 어느 한쪽에서 발견되면) 해당 값을 반환하고, 그렇지 않으면 KeyError를 발생시킵니다. 프레임에 정의되어 있지만 현재 바인딩되지 않은 변수도 KeyError를 발생시킵니다(locals()의 결과에서 이러한 변수가 생략되는 것과 마찬가지입니다).
프레임 저장소에는 항상 직접 접근하므로, 프록시는 함수가 실행되는 동안 일어나는 이름 바인딩 및 바인딩 해제 연산을 자동으로 반영합니다. 내부 값 캐시는 개별 변수를 프레임 상태에서 읽을 때 암묵적으로 갱신됩니다(이름이 현재 바인딩되어 있는지 확인해야 하는 포함 여부 검사도 여기에 해당합니다).
마찬가지로, 프록시에 대한 __setitem__ 및 __delitem__ 연산은 기반 프레임의 해당 빠른 지역 변수 또는 셀 참조에 직접 영향을 미칩니다. 따라서 변경 사항을 나중에 런타임 저장소에 다시 기록할 필요 없이 실행 중인 Python 코드에서 즉시 볼 수 있습니다. 이러한 변경 사항은 PyEval_GetLocals() C API 사용자에게도 보이도록 내부 프레임 값 캐시에도 즉시 기록됩니다.
기반 프레임에서 지역 변수나 클로저 변수로 정의되지 않은 키도 최적화된 프레임에서는 여전히 내부 값 캐시에 기록됩니다. 이를 통해 pdb와 같은 유틸리티(프레임의 f_locals 매핑에 __return__ 및 __exception__ 값을 기록합니다)가 지금까지처럼 계속 동작할 수 있습니다. 프레임의 지역 변수나 클로저 변수에 대응하지 않는 이러한 추가 키는 이후의 캐시 동기화 연산에서 건드리지 않고 그대로 남겨집니다. (추가 키만 담는 새로운 매핑을 정의하는 대신) 프레임 값 캐시를 사용해 이러한 추가 키를 저장하면 기존 PyEval_GetLocals() API와 완전히 상호 운용할 수 있습니다. 새로운 빠른 지역 변수 프록시 API 사용자가 그 API를 통해 추가된 키만 보는 것이 아니라, 두 API 중 어느 쪽을 쓰든 양쪽 API 사용자가 추가한 키를 모두 볼 수 있기 때문입니다.
(프록시 타입의 인스턴스를 저장하는 대신) 프레임에 변수 값 캐시만 저장하면 프레임에서 자기 자신으로 돌아오는 참조 순환이 생기지 않는다는 이점도 있습니다. 따라서 프레임은 다른 객체가 프록시 인스턴스에 대한 참조를 유지하는 경우에만 살아 있게 됩니다.
참고: proxy.clear() 메서드를 호출하면 이전 버전에서 빈 프레임 값 캐시에 대해 PyFrame_LocalsToFast()를 호출하는 것과 비슷하게 광범위한 영향을 미칩니다. 프레임의 지역 변수뿐만 아니라, 프레임에서 접근할 수 있는 모든 셀 변수도(그 셀을 프레임 자신이 소유하든 바깥 프레임이 소유하든) 지워집니다. 인자 없는 super() 구문을 사용하는(또는 다른 방식으로 __class__를 참조하는) 메서드의 프레임에서 호출하면 클래스의 __class__ 셀이 지워질 수 있습니다. 이는 frame.clear()를 호출할 때의 범위를 넘어섭니다. frame.clear()는 셀 변수에 대한 프레임의 참조만 끊을 뿐 셀 자체를 지우지는 않습니다. 이 PEP를 계기로 프레임 변수를 직접 지우려는 시도의 범위를 좁힐 수도 있습니다. 바깥 프레임에 속한 셀은 그대로 두고, 프록시의 기반 프레임에 직접 속한 지역 변수와 셀만 지우는 방식입니다(이 문제는 셀 변수의 처리와 관련된 것이며 내부 프레임 값 캐시와는 전혀 무관하므로 PEP 667에도 영향을 미칩니다).
안정 C API/ABI의 변경 사항
Python 코드와 달리, Python C API를 호출하는 확장 모듈 함수는 어떤 종류의 Python 스코프에서도 호출될 수 있습니다. 따라서 locals()가 스냅숏을 반환할지 여부는 문맥만으로는 분명하지 않습니다. 이는 C 코드 자체가 아니라 호출하는 Python 코드의 스코프에 따라 달라지기 때문입니다.
따라서 예측 가능하고 스코프에 독립적인 동작을 제공하는 C API를 제공하는 것이 바람직합니다. 하지만 C 코드가 같은 스코프에 있는 Python 코드의 동작을 정확히 모방할 수 있도록 하는 것도 바람직합니다.
Python 코드의 동작을 모방할 수 있도록, 안정 C ABI에 다음과 같은 새로운 함수가 추가됩니다:
PyObject * PyLocals_Get();
PyLocals_Kind PyLocals_GetKind();
PyLocals_Get()은 Python locals() 내장 함수와 직접적으로 동등합니다. 모듈 및 클래스 스코프에서, 그리고 exec() 또는 eval()을 사용할 때에는 활성 Python 프레임의 지역 네임스페이스 매핑에 대한 새 참조를 반환합니다. 함수/코루틴/제너레이터 스코프에서는 활성 네임스페이스의 얕은 복사본을 반환합니다.
PyLocals_GetKind()는 새로 정의된 PyLocals_Kind 열거형의 값을 반환하며, 사용할 수 있는 옵션은 다음과 같습니다:
PyLocals_DIRECT_REFERENCE:PyLocals_Get()이 실행 중인 프레임의 지역 네임스페이스에 대한 직접 참조를 반환합니다.PyLocals_SHALLOW_COPY:PyLocals_Get()은 실행 중인 프레임의 지역 네임스페이스에 대한 얕은 복사본을 반환합니다.PyLocals_UNDEFINED: 오류가 발생했습니다(예: 활성 Python 스레드 상태가 없는 경우). 이 값이 반환되면 Python 예외가 설정됩니다.
이 열거형은 안정 ABI에서 사용되므로, 부호 있는 임의의 32비트 정수를 PyLocals_Kind 값으로 캐스트해도 안전하도록 추가적인 31비트 값이 설정됩니다.
이 조회 API를 사용하면 확장 모듈 코드는 실행 중인 프레임 객체의 세부 사항에 접근하지 않고도 PyLocals_Get()이 반환한 매핑을 변경했을 때의 잠재적 영향을 판단할 수 있습니다. Python 코드는 렉시컬 스코핑을 통해 동등한 정보를 시각적으로 얻습니다(새 locals() 내장 함수 문서에서 다루는 바와 같습니다).
활성 Python 스코프와 관계없이 확장 모듈 코드가 일관되게 동작할 수 있도록, 안정 C ABI에 다음과 같은 새 함수가 추가됩니다:
PyObject * PyLocals_GetCopy();
PyLocals_GetCopy()는 현재 지역 네임스페이스의 내용으로 채워진 새 dict 인스턴스를 반환합니다. Python 코드의 dict(locals())와 대략 동등하지만, locals()가 이미 얕은 복사본을 반환하는 경우에는 이중 복사를 피합니다. 다음 코드와 유사하지만, 지역 변수 결과의 종류가 항상 두 가지뿐이라고 가정하지는 않습니다:
locals = PyLocals_Get();
if (PyLocals_GetKind() == PyLocals_DIRECT_REFERENCE) {
locals = PyDict_Copy(locals);
}
기존 PyEval_GetLocals() API는 CPython에서 기존 동작을 유지합니다(클래스 및 모듈 스코프에서는 변경 가능한 지역 변수, 그 밖의 경우에는 공유된 동적 스냅숏). 다만, 공유된 동적 스냅숏이 갱신되는 조건이 변경되었음을 명시하도록 해당 문서가 갱신됩니다.
또한 PyEval_GetLocals() 문서는 이 API의 사용을 새 API 중 사용 사례에 가장 적합한 것으로 대체하도록 권장하는 내용으로 갱신됩니다:
- 현재 지역 네임스페이스에 읽기 전용으로 접근하려면
PyLocals_Get()을 사용하십시오(선택적으로PyDictProxy_New()와 함께 사용할 수 있습니다). 이러한 사용 형태에서는 최적화된 프레임에서 복사본이 최신 상태가 아니게 될 수 있다는 점을 알고 있어야 합니다. - 현재 지역 네임스페이스의 복사본을 담고 있지만 활성 프레임과는 지속적인 연결이 없는 일반적인 변경 가능한 dict가 필요하다면
PyLocals_GetCopy()를 사용하십시오. - Python 수준의
locals()내장 함수의 의미와 정확히 일치시키려면PyLocals_Get()을 사용하십시오. PyLocals_Get()이 지역 네임스페이스에 대한 읽기/쓰기 접근 권한을 부여하는 대신 얕은 복사본을 반환하게 되는 스코프에 대해 사용자 정의 처리(예: 의미 있는 예외 발생)를 구현하려면PyLocals_GetKind()를 명시적으로 조회하십시오.- 프레임에 대한 읽기/쓰기 접근이 필요하고
PyLocals_GetKind()가PyLocals_DIRECT_REFERENCE이외의 값을 반환한다면, 구현별 API(예:PyObject_GetAttrString(frame, "f_locals"))를 사용하십시오.
공개 CPython C API의 변경 사항
기존 PyEval_GetLocals() API는 빌린 참조를 반환하므로, 함수 스코프에서 새로운 얕은 복사본을 반환하도록 갱신할 수 없습니다. 대신, 프레임 객체에 저장된 내부 동적 스냅숏에 대한 빌린 참조를 계속 반환합니다. 이 공유 매핑은 Python 3.10 및 이전 버전의 기존 공유 매핑과 유사하게 동작하지만, 새로 고쳐지는 정확한 조건은 달라집니다. 구체적으로, 다음과 같은 상황에서만 갱신됩니다:
- 프레임이 실행 중인 동안
PyEval_GetLocals(),PyLocals_Get(),PyLocals_GetCopy()또는 Pythonlocals()내장 함수를 호출하는 경우 - 해당 프레임에 대해
PyFrame_GetLocals(),PyFrame_GetLocalsCopy(),_PyFrame_BorrowLocals(),PyFrame_FastToLocals()또는PyFrame_FastToLocalsWithError()를 호출하는 경우 - 구현의 일부로 공유 매핑을 갱신하는 빠른 지역 변수 프록시 객체에 대한 모든 연산. 초기 참조 구현에서 이러한 연산은 본질적으로
O(n)연산인 것들(len(flp), 매핑 비교,flp.copy()및 문자열로 렌더링)과 개별 키의 캐시 항목을 새로 고치는 연산입니다.
빠른 지역 변수 프록시를 요청하는 것은 공유된 동적 스냅숏을 암묵적으로 갱신하지 않으며, CPython 추적 훅 처리 또한 더 이상 이를 암묵적으로 갱신하지 않습니다.
(참고: PyEval_GetLocals()가 안정 C API/ABI의 일부이기는 하지만, 이 함수가 반환하는 네임스페이스가 언제 새로 고쳐지는지에 대한 구체적인 사항은 여전히 인터프리터 구현 세부 사항입니다)
공개 CPython C API에 추가되는 사항은 안정 C API/ABI 갱신을 지원하는 데 필요한 프레임 수준의 개선 사항입니다:
PyLocals_Kind PyFrame_GetLocalsKind(frame);
PyObject * PyFrame_GetLocals(frame);
PyObject * PyFrame_GetLocalsCopy(frame);
PyObject * _PyFrame_BorrowLocals(frame);
PyFrame_GetLocalsKind(frame)은 PyLocals_GetKind()의 기반 API입니다.
PyFrame_GetLocals(frame)은 PyLocals_Get()의 기반 API입니다.
PyFrame_GetLocalsCopy(frame)은 PyLocals_GetCopy()의 기반 API입니다.
_PyFrame_BorrowLocals(frame)은 PyEval_GetLocals()의 기반 API입니다. 밑줄 접두사는 사용을 자제시키고, 이를 사용하는 코드가 여러 구현 사이에서 이식되기 어렵다는 점을 나타내기 위한 것입니다. 그러나 PyEval_GetLocals() 구현에서 프레임 구조체의 내부에 접근하지 않아도 되도록, 이 함수는 문서화되어 있으며 링커에 노출됩니다.
PyFrame_LocalsToFast() 함수는 더 이상 지원되지 않는 작업이라는 설명과 함께 항상 RuntimeError를 발생시키도록 변경됩니다. 영향을 받는 코드는 대신 PyObject_GetAttrString(frame, "f_locals")를 사용하여 읽기/쓰기 프록시를 얻도록 수정해야 합니다.
위에서 문서화한 인터페이스 외에도, 초안 참조 구현은 다음과 같은 문서화되지 않은 인터페이스도 노출합니다:
PyTypeObject _PyFastLocalsProxy_Type;
#define _PyFastLocalsProxy_CheckExact(self) Py_IS_TYPE(op, &_PyFastLocalsProxy_Type)
이 타입은 최적화된 프레임에 대해(즉, PyFrame_GetLocalsKind()가 PyLocals_SHALLOW_COPY를 반환할 때) 참조 구현이 PyObject_GetAttrString(frame, "f_locals")에서 실제로 반환하는 타입입니다.
트레이스 훅의 런타임 오버헤드 줄이기
[9]에서 언급했듯이, Python 트레이스 훅 지원에서 PyFrame_FastToLocals()를 암묵적으로 호출하는 데에는 비용이 들며, 프레임 프록시가 매핑에서 값을 가져오는 대신 프레임에서 직접 값을 읽는다면 이 호출은 불필요해질 수 있습니다.
새 프레임 지역 변수 프록시 타입에는 별도의 데이터 갱신 단계가 필요하지 않으므로, 이 PEP는 Python으로 구현된 트레이스 훅을 호출하기 전에 더 이상 PyFrame_FastToLocalsWithError()를 암묵적으로 호출하지 않도록 하자는 Victor Stinner의 제안을 반영합니다.
새 고속 지역 변수 프록시 객체를 사용하는 코드에서는 동적 지역 변수 스냅숏이 필요한 메서드에 접근할 때 그 스냅숏이 암묵적으로 갱신되며, PyEval_GetLocals() API를 사용하는 코드에서는 이 API를 호출할 때 스냅숏이 암묵적으로 갱신됩니다.
또한 PyFrame_LocalsToFast() API는 이제 항상 예외를 발생시키므로, 이 PEP는 트레이스 훅에서 반환할 때 이 API를 암묵적으로 호출하던 동작도 필연적으로 제거합니다.
근거 및 설계 논의
함수 스코프에서 locals()가 독립적인 스냅숏을 반환하도록 변경하기
locals() 내장 함수는 언어의 필수 요소이며, 참조 구현에서는 역사적으로 다음과 같은 특성을 지닌 변경 가능한 매핑을 반환해 왔습니다:
locals()를 호출할 때마다 동일한 매핑 객체를 반환합니다locals()가 실제 지역 실행 네임스페이스가 아닌 다른 것에 대한 참조를 반환하는 네임스페이스에서는,locals()를 호출할 때마다 지역 변수와 참조된 모든 비지역 셀의 현재 상태로 매핑 객체를 갱신합니다- 반환된 매핑에 대한 변경 사항은 대개 지역 변수 바인딩이나 비지역 셀 참조에 다시 기록되지 않지만, 다음 중 하나를 수행하면 다시 기록되도록 할 수 있습니다:
- Python 수준의 트레이스 훅 설치(이 경우 트레이스 훅이 호출될 때마다 다시 기록됩니다)
- 함수 수준의 와일드카드 임포트 실행(Py3에서는 바이트코드 주입이 필요합니다)
- 함수 스코프에서
exec문 실행(Python 3에서는exec이 일반 내장 함수가 되었으므로 Py2에만 해당합니다)
원래 이 PEP는 이러한 특성 중 처음 두 가지는 유지하고, 세 번째 특성이 일으킬 수 있는 명백한 동작 버그를 해결하기 위해 세 번째 특성만 변경할 것을 제안했습니다.
[7]에서 Nathaniel Smith는 두 번째 특성만 유지하고, 함수 스코프에서 locals()를 호출할 때마다 암묵적으로 공유되는 스냅숏을 갱신하는 대신 지역 변수와 클로저 참조의 독립적인 스냅숏을 반환하도록 하면 함수 스코프에서 locals()의 동작을 훨씬 덜 혼란스럽게 만들 수 있다는 설득력 있는 주장을 펼쳤습니다.
이렇게 수정한 설계는 구현도 눈에 띄게 이해하기 쉽게 만들었으므로, PEP는 기존의 공유 스냅숏을 유지하는 대신 이러한 동작 변경을 제안하도록 갱신되었습니다.
함수 스코프에서 locals()를 스냅숏으로 유지하기
[7]에서 논의했듯이, 이론적으로는 locals() 내장 함수가 독립적인 스냅숏을 반환하도록 바꾸는 대신 함수 스코프에서 즉시 기록(write-through) 프록시를 반환하도록 그 의미를 변경하는 것도 가능합니다.
현재 동작에 의존하는 코드는 엄밀히 말해 언어 명세에서 정의되지 않은 영역에서 동작하고 있지만, 이러한 변경은 실제로는 하위 호환성을 깨뜨리는 변경이므로 이 PEP는 이를 제안하지 않으며 앞으로도 제안하지 않을 것입니다.
다음 코드 조각을 살펴보십시오:
def example():
x = 1
locals()["x"] = 2
print(x)
트레이스 훅이 설치되어 있더라도, 이 함수는 현재 참조 인터프리터 구현에서 일관되게 1을 출력합니다:
>>> example()
1
>>> import sys
>>> def basic_hook(*args):
... return basic_hook
...
>>> sys.settrace(basic_hook)
>>> example()
1
마찬가지로, 함수 스코프에서 locals()를 exec() 및 eval() 내장 함수에 (명시적으로든 암묵적으로든) 전달해도 지역 변수나 클로저 참조가 예기치 않게 다시 바인딩될 위험이 없습니다.
참조 인터프리터가 지역 변수 상태를 잘못 변경하도록 유발하려면 더 복잡한 설정이 필요합니다. 즉, 중첩 함수가 외부 함수에서 다시 바인딩되는 변수를 클로저로 캡처해야 하고, 스레드, 제너레이터, 코루틴 중 하나를 사용하기 때문에 트레이스 함수가 외부 함수의 재바인딩 작업 전에 중첩 함수에 대해 실행을 시작했다가 재바인딩 작업이 이루어진 후에 실행을 마칠 수 있어야 합니다(이 경우 재바인딩이 되돌려지며, 이것이 바로 [1]에서 보고된 버그입니다).
PEP 227이 Python 2.1에서 중첩 스코프를 도입한 이래 유지되어 온 사실상의 의미 체계를 보존한다는 점 외에도, 쓰기 관통(write-through) 프록시 지원을 구현 정의 프레임 객체 API로 제한하면 또 다른 이점이 있습니다. 전체 프레임 API를 에뮬레이션하는 인터프리터 구현만 쓰기 관통 기능을 제공하면 되고, JIT 컴파일 구현은 함수 스코프에서 locals()에 접근할 때마다가 아니라 프레임 인트로스펙션 API가 호출되거나 트레이스 훅이 설치될 때만 이 기능을 활성화하면 됩니다.
함수 스코프에서 locals()가 스냅숏을 반환하면 함수 수준 코드에 대한 정적 분석도 더 신뢰할 수 있게 됩니다. 정적 분석에 드러나지 않는 방식으로 지역 변수와 비지역 변수 참조를 다시 바인딩할 수 있는 수단은 프레임 메커니즘에 대한 접근뿐이기 때문입니다.
eval()과 exec()의 기본 인자는 어떻게 됩니까?
이 인자들은 기본적으로 호출한 스코프의 globals()와 locals()를 상속하도록 공식적으로 정의되어 있습니다.
이 PEP에서 이러한 기본값을 변경할 필요는 없으므로 변경하지 않습니다. 따라서 locals()가 지역 네임스페이스의 얕은 복사본을 반환하는 경우에는 exec()와 eval()도 그 얕은 복사본에서 실행되기 시작합니다.
이 동작은 특히 지역 변수가 많은 함수에서 성능에 영향을 줄 수 있습니다(예를 들어 이러한 함수가 루프 안에서 호출되는 경우, 루프 전에 globals()와 locals()를 한 번 호출한 뒤 그 네임스페이스를 함수에 명시적으로 전달하면 현재와 같은 의미 체계와 성능 특성을 얻을 수 있지만, 암묵적 기본값에 의존하면 반복할 때마다 지역 네임스페이스의 얕은 복사본이 새로 생성됩니다).
(참고: 참조 구현 초안 PR은 locals(), vars(), eval(), exec() 내장 함수가 PyLocals_Get()을 사용하도록 수정했습니다. dir() 내장 함수는 키로 리스트를 만드는 데에만 사용하므로 여전히 PyEval_GetLocals()를 사용합니다.)
최적화된 스코프에서 eval()과 exec()에 대한 추가 고려 사항
참고: PEP 667을 구현하는 과정에서, 그 PEP와 이 PEP 모두 locals() 변경이 exec()와 eval() 같은 코드 실행 API에 미칠 영향을 명확히 설명하지 않았다는 점이 지적되었습니다. 이 섹션은 그 영향을 더 잘 설명하고 이 변경으로 의도한 이점을 설명하기 위해 이 PEP의 근거 부분에 추가되었습니다.
Python 3.0에서 exec()가 문(statement)에서 내장 함수로 바뀌었을 때(PEP 3100의 핵심 언어 변경의 일부), 이에 수반되던 PyFrame_LocalsToFast() 암묵적 호출이 제거되었습니다. 그래서 최적화된 프레임에서 exec()로 지역 변수에 쓰려는 시도는 보통 무시되는 것처럼 보입니다:
>>> def f():
... x = 0
... exec("x = 1")
... print(x)
... print(locals()["x"])
...
>>> f()
0
0
실제로는 쓰기가 무시되는 것이 아니라, 딕셔너리 캐시에서 최적화된 지역 변수 배열로 다시 복사되지 않을 뿐입니다. 그런 다음 딕셔너리 캐시가 배열로부터 다시 갱신될 때 딕셔너리에 대한 변경 사항이 덮어써집니다:
>>> def f():
... x = 0
... locals_cache = locals()
... exec("x = 1")
... print(x)
... print(locals_cache["x"])
... print(locals()["x"])
...
>>> f()
0
1
0
캐시가 다음에 갱신되기 전에 트레이싱 함수나 다른 코드가 PyFrame_LocalsToFast()를 호출하면 동작은 더욱 이상해집니다. 그러한 경우에는 변경 사항이 최적화된 지역 변수 배열에 실제로 다시 기록됩니다:
>>> from sys import _getframe
>>> from ctypes import pythonapi, py_object, c_int
>>> _locals_to_fast = pythonapi.PyFrame_LocalsToFast
>>> _locals_to_fast.argtypes = [py_object, c_int]
>>> def f():
... _frame = _getframe()
... _f_locals = _frame.f_locals
... x = 0
... exec("x = 1")
... _locals_to_fast(_frame, 0)
... print(x)
... print(locals()["x"])
... print(_f_locals["x"])
...
>>> f()
1
1
1
이 상황은 Python 3.10 및 그 이전 버전에서 더 흔했습니다. 트레이싱 함수를 설치하기만 해도 Python 코드의 매 줄 이후에 PyFrame_LocalsToFast()가 암묵적으로 호출되었기 때문입니다. 그러나 Python 3.11 이상에서도 정확히 어떤 트레이싱 함수가 활성화되어 있는지에 따라 여전히 이러한 일이 발생할 수 있습니다(예를 들어 대화형 디버거는 디버깅 프롬프트에서 만든 변경 사항이 코드 실행이 재개될 때 반영되도록 의도적으로 이렇게 합니다).
exec()와 관련된 위의 모든 설명은 최적화된 스코프에서 locals()의 결과를 변경하려는 모든 시도에 적용되며, locals() 내장 함수 문서에 다음과 같은 주의 사항이 들어 있는 주된 이유이기도 합니다:
참고: 이 딕셔너리의 내용은 수정해서는 안 됩니다. 변경 사항이 인터프리터가 사용하는 지역 변수와 자유 변수의 값에 영향을 주지 않을 수 있습니다.
라이브러리 레퍼런스의 정확한 문구가 완전히 명시적이지는 않지만, exec()와 eval()은 모두 오랫동안 호출한 Python 프레임에서 globals()와 locals()를 호출한 결과를 기본 실행 네임스페이스로 사용해 왔습니다.
이는 역사적으로 호출한 프레임의 frame.f_globals 및 frame.f_locals 속성을 사용하는 것과도 동등했지만, 이 PEP는 최적화된 스코프에서 지역 네임스페이스에 대한 쓰기 시도를 기본적으로 무시하는 특성을 보존하기 위해 exec()와 eval()의 기본 네임스페이스 인자를 호출한 프레임의 globals()와 locals()에 대응시킵니다.
이는 일부 코드에 잠재적인 호환성 문제를 일으킵니다. 함수 스코프에서 locals()를 여러 번 호출할 때 같은 딕셔너리를 반환하던 이전 구현에서는, 암묵적으로 공유되는 지역 변수 네임스페이스 덕분에 다음 코드가 대개 동작했기 때문입니다:
def f():
exec('a = 0') # equivalent to exec('a = 0', globals(), locals())
exec('print(a)') # equivalent to exec('print(a)', globals(), locals())
print(locals()) # {'a': 0}
# However, print(a) will not work here
f()
최적화된 스코프에서 locals()가 호출할 때마다 같은 공유 딕셔너리를 반환했기 때문에, 그 딕셔너리에 추가적인 “가짜 지역 변수”를 저장할 수 있었습니다. 이것들은 컴파일러가 알고 있는 실제 지역 변수가 아니지만(따라서 print(a) 같은 코드로 출력할 수 없습니다), 여전히 locals()를 통해 접근할 수 있고 같은 함수 스코프 안의 여러 exec() 호출 사이에서 공유될 수 있습니다. 게다가 이것들은 실제 지역 변수가 아니기 때문에, 공유 캐시가 지역 변수 저장 배열로부터 갱신될 때 암묵적으로 갱신되거나 제거되지 않습니다.
exec() 안의 코드가 기존 지역 변수에 쓰려고 하면 런타임 동작을 예측하기가 더 어려워집니다:
def f():
a = None
exec('a = 0') # equivalent to exec('a = 0', globals(), locals())
exec('print(a)') # equivalent to exec('print(a)', globals(), locals())
print(locals()) # {'a': None}
f()
exec() 안의 암묵적 locals() 호출이 캐시된 딕셔너리를 프레임의 실제 값으로 갱신하므로 print(a)는 None을 출력합니다. 즉, locals()에 다시 쓰는 방식으로(이전의 exec() 호출을 통한 경우도 포함하여) 만들어진 “가짜” 지역 변수와 달리, 컴파일러가 알고 있는 실제 지역 변수는 exec()로 쉽게 수정할 수 없습니다(수정할 수는 있지만, 위에서 ctypes를 사용해 보인 것처럼 프레임에 다시 쓸 수 있도록 frame.f_locals 속성을 가져온 다음 PyFrame_LocalsToFast()를 호출해야 합니다).
동기 섹션에서 언급했듯이, 이 혼란스러운 부작용은 지역 변수가 exec() 호출 이후에야 정의되는 경우에도 발생합니다:
>>> def f():
... exec("a = 0")
... exec("print('a' in locals())") # Printing 'a' directly won't work
... print(locals())
... a = None
... print(locals())
...
>>> f()
False
{}
{'a': None}
a는 현재 값에 바인딩되지 않은 실제 지역 변수이므로, a = None 줄보다 앞에서 locals()가 호출될 때마다 locals()가 반환하는 딕셔너리에서 명시적으로 제거됩니다. 이 제거는 의도된 것입니다. 이전에 바인딩된 지역 변수를 삭제하기 위해 del 문을 사용할 때 최적화된 스코프에서 locals()의 내용이 올바르게 갱신될 수 있게 해 주기 때문입니다.
ctypes 예제에서 언급했듯이, 프레임이 아직 실행 중일 때 CPython PyFrame_LocalsToFast() API가 호출되면 위의 동작 설명이 성립하지 않을 수 있습니다. 이 경우 a에 대한 변경 사항은 해당 API가 정확히 언제 호출되는지에 따라(그리고 frame.f_locals 속성에 접근하여 프레임이 지역 변수 수정을 위해 준비되었는지에 따라) 실행 중인 코드에 보이게 될 수도 있습니다.
앞에서 설명한 것처럼, 이 혼란스러운 동작을 대체하기 위해 두 가지 방안이 검토되었습니다:
locals()가 (frame.f_locals와 비슷하게) write-through 프록시 인스턴스를 반환하도록 합니다locals()가 진정으로 독립적인 스냅숏을 반환하도록 하여,exec()를 통해 지역 변수의 값을 변경하려는 시도가 위에서 언급한 어떤 단서도 없이 일관되게 무시되도록 합니다.
이 PEP는 다음과 같은 이유로 두 번째 방안을 선택합니다:
- 최적화된 스코프에서 독립적인 스냅숏을 반환하면,
exec()를 통해 지역 변수를 변경하려는 시도가 대부분의 경우 무시되도록 한 Python 3.0의exec()변경 사항이 유지됩니다. - “
locals()는 최적화된 스코프에서는 지역 변수의 순간 스냅숏을 제공하고, 다른 스코프에서는 읽기/쓰기 접근을 제공함”과 “frame.f_locals는 최적화된 스코프를 포함한 모든 스코프에서 지역 변수에 대한 읽기/쓰기 접근을 제공함”을 구분합니다. 이 구분 덕분에, 쓰기 접근이 필요하지 않거나 바라지 않는 경우에도 두 API 모두 최적화된 스코프에서 완전한 읽기/쓰기 접근을 허용할 때보다 코드의 의도가 더 명확해집니다. - 사람이 읽을 때의 명확성을 높이는 것 외에도, (프레임 인트로스펙션 API에 접근하지 않는 한) 최적화된 스코프에서의 이름 재바인딩이 코드상에서 어휘적으로 드러나도록 보장하면 컴파일러와 인터프리터가 관련 성능 최적화를 더 일관되게 적용할 수 있습니다
- 선택 사항인 프레임 인트로스펙션 API를 지원하는 Python 구현만 최적화된 프레임에 대한 새로운 write-through 프록시 지원을 제공하면 됩니다
이 PEP에서 locals()의 의미가 변경되면서 exec()와 eval()의 동작을 설명하기가 훨씬 쉬워집니다: 최적화된 스코프에서는 이들이 지역 변수에 암묵적으로 영향을 절대 주지 않으며, 다른 스코프에서는 항상 지역 변수에 암묵적으로 영향을 줍니다. 최적화된 스코프에서는 호출할 때마다 지역 변수의 새 복사본이 사용되므로, 지역 변수에 대한 암묵적인 할당은 코드 실행 API가 반환될 때 모두 폐기됩니다.
내부 프레임 값 캐시 유지
내부 프레임 값 캐시를 유지하면, 프레임 프록시 인스턴스를 보관해 두었다가 프레임에서 이름 바인딩 및 언바인딩 연산이 실행된 뒤에 재사용할 때 눈에 띄는 몇 가지 특이한 동작이 발생합니다.
프레임 값 캐시를 유지하는 주된 이유는 PyEval_GetLocals() API와의 하위 호환성을 유지하기 위해서입니다. 이 API는 빌린 참조를 반환하므로, 프레임 객체에 저장된 영속적인 상태를 참조해야 합니다. 프레임에 fast locals 프록시 객체를 저장하면 문제가 되는 참조 순환이 생기므로, 가장 깔끔한 방안은 최적화된 프레임이 처음 도입된 이후 이 함수가 해 온 것처럼 대신 프레임 값 캐시를 계속 반환하는 것입니다.
어차피 프레임 값 캐시를 유지하게 되었으므로, fast locals 프록시 매핑 구현을 단순화하는 데에도 이를 활용하는 것이 더욱 타당해졌습니다.
참고: PEP 667이 write-through 프록시 구현의 일부로 내부 프레임 값 캐시를 사용하지 않는다는 점이 두 PEP 사이의 핵심적인 Python 수준 차이입니다.
일반 동작에서 프레임 API 의미 변경
참고: 이 PEP가 처음 작성되었을 때는 트레이싱 함수가 설치될 때마다 프레임 지역 변수를 암묵적으로 다시 기록하던 동작을 없앤 Python 3.11의 변경 이전이었으므로, 그 변경도 제안의 일부로 포함되었습니다.
이 PEP의 이전 버전에서는 프레임 f_locals 속성의 의미가 현재 추적 훅이 설치되어 있는지 여부에 따라 달라지도록 제안했습니다. 즉, 추적 훅이 활성화되어 있을 때만 write-through 프록시 동작을 제공하고, 그렇지 않을 때는 기존 locals() 내장 함수와 똑같이 동작하도록 했습니다.
이는 두 가지 핵심적인 이유로 원래의 설계 제안으로 채택되었는데, 하나는 실용적인 이유였고 다른 하나는 좀 더 철학적인 이유였습니다:
- 객체 할당과 메서드 래퍼에는 비용이 따르며, 함수 외부에서 프레임 지역 변수에 접근하는 연산이 트레이싱 함수만 있는 것도 아닙니다. 변경 사항을 트레이싱 모드로 제한하면, 이러한 변경으로 인한 추가 메모리 및 실행 시간 오버헤드를 일반 동작에서 가능한 한 0에 가깝게 만들 수 있었습니다.
- “고장 나지 않은 것은 바꾸지 마십시오”: 현재 트레이싱 모드의 문제는 트레이싱 모드에만 해당하는 요구 사항(함수 지역 변수 참조의 외부 재바인딩 지원)으로 인해 발생하므로, 관련 수정 사항도 트레이싱 모드로 제한하는 것이 타당했습니다
그러나 실제로 이 동적 방식을 구현하고 문서화하려고 시도해 보니, 이 방식이 frame.f_locals의 동작 방식에 런타임 상태에 따라 달라지는 매우 미묘한 동작 차이를 만들어 내고, 트레이스 함수가 추가되고 제거될 때 f_locals가 동작하는 방식과 관련하여 여러 새로운 엣지 케이스를 만든다는 사실이 드러났습니다.
이에 따라 설계는 frame.f_locals가 항상 write-through 프록시이고 locals()가 항상 스냅숏인 현재 방식으로 바뀌었습니다. 이 방식은 구현하기도 더 간단하고 설명하기도 더 쉽습니다.
CPython 참조 구현이 이를 어떻게 처리하기로 하든, 최적화 컴파일러와 인터프리터는 디버거에 추가적인 제약을 둘 자유도 여전히 있습니다. 예를 들어 프레임 객체를 통한 지역 변수 변경을 일부 최적화를 비활성화할 수 있는 옵트인 동작으로 만들 수 있습니다(일부 Python 구현에서 CPython의 프레임 API 에뮬레이션이 이미 옵트인 플래그인 것과 마찬가지입니다).
최적화된 프레임에 추가 데이터를 저장하는 기능의 지속 지원
이 PEP의 초안 중 하나에서는 기반 프레임의 지역 변수나 클로저 변수 이름에 해당하지 않는 frame.f_locals 키에 값을 써서 최적화된 프레임에 추가 데이터를 저장하는 기능을 제거하자고 제안했습니다.
이 아이디어는 fast locals 프록시 구현을 매력적으로 단순화할 수 있었지만, pdb가 임의의 프레임에 __return__과 __exception__ 값을 저장하므로 이 기능이 더 이상 동작하지 않으면 표준 라이브러리 테스트 스위트가 실패합니다.
따라서 임의의 키를 저장하는 기능은 유지되었으며, 그 대가로 프록시 객체의 일부 연산은 그렇지 않았을 경우보다 느려졌습니다(프록시를 통해 접근할 수 있는 이름이 코드 객체에 정의된 이름뿐이라고 가정할 수 없기 때문입니다).
빠른 지역 변수 프록시와 하부 프레임의 f_locals 값 캐시 사이의 상호 작용에 관한 정확한 세부 사항은 개선의 여지가 발견됨에 따라 시간이 지나면서 발전할 것으로 예상됩니다.
함수 스코프에서의 역사적 의미 체계
CPython에서 locals()와 frame.f_locals를 변경할 때의 현재 의미 체계는 역사적인 구현 세부 사항 때문에 다소 기이합니다:
- 실제 실행에서는 지역 변수 바인딩에 빠른 지역 변수 배열을 사용하고, 비지역 변수에는 셀 참조를 사용합니다
- fast locals 배열과 참조되는 셀의 현재 상태를 바탕으로 프레임의
f_locals속성을 채우는PyFrame_FastToLocals연산이 있습니다. 이 연산이 존재하는 이유는 세 가지입니다:- 추적 함수가 지역 변수의 상태를 읽을 수 있도록 하기 위해서입니다
- 트레이스백 처리기가 지역 변수의 상태를 읽을 수 있도록 하기 위해서입니다
locals()가 지역 변수의 상태를 읽을 수 있도록 하기 위해서입니다
locals()는frame.f_locals에 대한 직접 참조를 반환하므로, 여러 개의 동시 참조를 넘겨주면 그 참조들은 모두 정확히 같은 딕셔너리를 가리킵니다- 역방향 연산인
PyFrame_LocalsToFast를 호출하던 두 가지 흔한 경우는 Python 3으로 이전하면서 제거되었습니다:exec는 더 이상 문이 아니며(따라서 더 이상 함수의 지역 네임스페이스에 영향을 줄 수 없습니다), 컴파일러는 이제 함수 스코프에서from module import *연산의 사용을 허용하지 않습니다 - 그러나 잘 알려지지 않은 두 가지 호출 경로가 남아 있습니다:
PyFrame_LocalsToFast는 추적 함수에서 반환하는 과정의 일부로 호출되며(이를 통해 디버거가 지역 변수 상태를 변경할 수 있습니다), 컴파일러를 거치지 않고 코드 객체로부터 함수를 직접 생성할 때는 여전히IMPORT_STAR옵코드를 주입할 수도 있습니다
이 제안은 이러한 의미 체계를 있는 그대로 공식화하지 않기로 의도적으로 결정했습니다. 이 의미 체계는 의도적으로 설계된 것이 아니라, 언어와 참조 구현의 역사적 발전이라는 관점에서만 납득이 되기 때문입니다.
안정 C API/ABI에 몇 가지 항목 추가 제안
역사적으로 CPython C API(그리고 이후의 안정 ABI)는 Python locals 내장 함수와 관련된 API 함수로 PyEval_GetLocals() 하나만을 노출해 왔습니다. 그러나 이 함수는 빌린 참조를 반환하므로, 이 PEP에서 제안하는 새로운 locals() 의미 체계를 지원하도록 해당 인터페이스를 직접 조정할 수는 없습니다.
이 PEP의 이전 버전은 새로운 의미 체계에 맞춘 최소한의 조정을 제안했습니다: 하나는 Python locals() 내장 함수처럼 동작하는 C API 함수이고, 다른 하나는 frame.f_locals 디스크립터처럼 동작하는(필요하면 write-through 프록시를 생성하여 반환하는) C API 함수였습니다.
해당 버전의 C API에 대한 피드백 [8]은 Python 수준의 의미 체계가 구현된 방식에 지나치게 의존하고 있으며, C 확장 작성자에게 필요할 가능성이 높은 동작을 고려하지 않았다는 것이었습니다.
현재 제안하는 더 넓은 범위의 API는 확장 모듈에서 Python locals() 네임스페이스에 접근하려는 잠재적인 이유들을 다음과 같은 경우로 분류한 데서 나왔습니다:
- Python 수준
locals()연산의 의미 체계를 정확히 재현해야 하는 경우입니다. 이것이PyLocals_Get()API입니다. PyLocals_Get()의 결과에 대한 쓰기가 Python 코드에 보이는지 여부에 따라 다르게 동작해야 하는 경우입니다. 이는PyLocals_GetKind()조회 API가 처리합니다.- 현재 Python
locals()네임스페이스로 미리 채워진 변경 가능한 네임스페이스를 항상 원하지만, 변경 사항이 Python 코드에 보이는 것은 원하지 않는 경우입니다. 이것이PyLocals_GetCopy()API입니다. - 매번 전체 복사본을 만드는 런타임 오버헤드 없이 현재 지역 네임스페이스의 읽기 전용 뷰를 항상 원하는 경우입니다. 최적화된 프레임에서는 이름이 현재 바인딩되어 있는지를 확인해야 하므로 이 기능을 쉽게 제공할 수 없으며, 따라서 이를 위한 별도의 API는 추가하지 않습니다.
역사적으로 이러한 종류의 확인과 연산은 Python 구현이 CPython 프레임 API 전체를 에뮬레이션해야만 가능했습니다. 제안된 API를 사용하면 확장 모듈이 실제로 필요한 의미 체계를 더 명확하게 요청할 수 있으므로, Python 구현은 이러한 기능을 제공하는 방식에서 더 많은 유연성을 얻게 됩니다.
PEP 667과의 비교
참고: 아래 비교는 2021년 12월 당시의 PEP 667을 기준으로 합니다. 2024년 4월 시점(PEP 667을 진행하기로 하면서 이 PEP가 철회된 시점)의 PEP 667 상태는 반영하지 않습니다.
PEP 667은 이 PEP와 부분적으로 경쟁하는 제안으로, 최적화된 프레임의 내부 프레임 값 캐시를 완전히 제거하는 것이 합리적이라고 제안합니다.
이 변경 사항은 원래 PEP 558에 대한 수정안으로 제시되었으며, PEP 작성자는 다음 세 가지 주요 이유로 이를 거부했습니다:
PyEval_GetLocals()가 빌린 참조(borrowed reference)를 반환하기 때문에 고칠 수 없다는 초기 주장은 전혀 사실이 아니었습니다. PEP 558 참조 구현에서 여전히 동작하고 있기 때문입니다. 이를 계속 동작하게 하는 데 필요한 것은 내부 프레임 값 캐시를 유지하고, 캐시가 필요하지 않을 때 상당한 런타임 오버헤드를 발생시키지 않으면서도 프레임 상태의 변경에 맞춰 캐시를 최신 상태로 유지하기가 상당히 간단하도록 빠른 지역 변수 프록시를 설계하는 것뿐입니다. 이 주장이 사실이 아니므로,PyEval_GetLocals()API를 사용하는 모든 코드를 참조 카운팅 의미 체계가 다른 새 API를 사용하도록 다시 작성하게 하자는 제안은 API 호환성을 깨뜨리는 변경은 깨지는 정도에 비해 이점이 커야 한다는 PEP 387의 요구 사항을 충족하지 못합니다(캐시를 없애서 얻는 의미 있는 이점이 없으므로 어떤 코드 손상도 정당화될 수 없습니다). 진정으로 고칠 수 없는 유일한 공개 API는PyFrame_LocalsToFast()입니다(그래서 두 PEP 모두 이를 깨뜨리자고 제안합니다).- 어떤 형태로든 내부 값 캐시가 없으면 빠른 지역 변수 프록시 매핑의 API 성능 특성이 상당히 직관적이지 않게 됩니다. 예를 들어
len(proxy)는 프레임에 정의된 변수 개수에 대해 항상 O(n)이 됩니다. 프록시가 답을 결정하기 전에 현재 어떤 이름이 값에 바인딩되어 있는지 확인하려면 빠른 지역 변수 배열 전체를 순회해야 하기 때문입니다. 반면에 내부 프레임 값 캐시를 유지하면 알고리즘 복잡도 관점에서 프록시를 대체로 일반 딕셔너리처럼 취급할 수 있게 되며, 캐시가 최신 상태라는 것에 의존하는 연산이 처음 실행될 때 수행되는 초기의 암묵적인 O(n) 캐시 갱신만 감안하면 됩니다. - 캐시 없는 구현이 더 간단하리라는 주장은 매우 의심스럽습니다. PEP 667에는 최적화된 프레임의 하부 데이터 저장소와 통합된 새 매핑 타입의 완전한 C 구현이 아니라, 변경 가능한 매핑 구현의 일부에 대한 순수 Python 스케치만 포함되어 있기 때문입니다. PEP 558의 fast locals 프록시 구현은 변경 가능한 매핑 API를 완전히 구현하는 데 필요한 연산을 프레임 값 캐시에 크게 위임하므로, 다음 연산에 대해 기존 dict 구현을 재사용할 수 있습니다.
__len____str____or__(딕셔너리 합집합)__iter__(dict_keyiterator타입을 재사용할 수 있음)__reversed__(dict_reversekeyiterator타입을 재사용할 수 있음)keys()(dict_keys타입을 재사용할 수 있음)values()(dict_values타입을 재사용할 수 있음)items()(dict_items타입을 재사용할 수 있음)copy()popitem()- 값 비교 연산
세 가지 이유 중 첫 번째가 가장 중요합니다(API 하위 호환성을 깨뜨리려면 설득력 있는 이유가 필요한데, 그런 이유가 없기 때문입니다).
그러나 PEP 667에서 제안한 Python 수준 의미를 검토한 뒤, 이 PEP의 작성자는 결국 그 의미가 Python locals() API 사용자에게 실제로 더 간단하다는 데 동의했습니다. 따라서 두 PEP 사이의 이 차이점은 제거되었습니다. 어느 PEP와 구현이 채택되든, fast locals 프록시 객체는 항상 지역 변수의 현재 상태에 대한 일관된 뷰를 제공합니다. 이로 인해 일반 딕셔너리에서는 O(1)인 일부 연산이 O(n)이 되더라도 마찬가지입니다(구체적으로, len(proxy)는 현재 어떤 이름이 바인딩되어 있는지 확인해야 하므로 O(n)이 됩니다. 또한 프록시 매핑 비교는 길이 검사 최적화에 의존하지 않습니다. 이 최적화는 일반 매핑에서 저장된 키 개수의 차이를 빠르게 감지할 수 있게 해 줍니다).
프록시 구현에 이러한 비표준 성능 특성을 채택했기 때문에, PyLocals_GetView()와 PyFrame_GetLocalsView() C API도 이 PEP의 제안에서 제거되었습니다.
따라서 두 PEP 사이에 남은 차이점은 C API에 특별히 관련된 것뿐입니다:
- PEP 667은 적절히 설계된 빠른 지역 변수 프록시 구현이 있으면 이 API들을 무기한으로(그리고 상호 운용 가능하게) 계속 동작시키는 것이 충분히 가능한데도, 정당한 근거 없이 전혀 불필요한 C API 손상(
PyEval_GetLocals(),PyFrame_FastToLocalsWithError(),PyFrame_FastToLocals()의 프로그래밍 방식 폐지(deprecation)와 궁극적인 제거)을 여전히 제안합니다 - 이 PEP에서는 빠른 지역 변수 프록시가 추가 변수를 처리하는 방식을 기존
PyEval_GetLocals()API와 완전히 상호 운용 가능하도록 정의합니다. PEP 667에서 제안한 프록시 구현에서는 새 프레임 API의 사용자가 이전 API의 사용자가 추가 변수에 가한 변경을 볼 수 없으며, 이전 API를 통해 추가 변수에 가한 변경은 이후PyEval_GetLocals()를 호출할 때 덮어쓰입니다. - 이 PEP의
PyLocals_Get()API는 PEP 667에서PyEval_Locals()라고 불립니다. 이 함수 이름은 동사가 없어 데이터 접근 API라기보다 타입 이름처럼 보이므로 다소 이상합니다. - 이 PEP는
PyLocals_Get()이 이미 복사본을 만드는 프레임에서 확장 모듈이 이중 복사 연산을 쉽게 피할 수 있도록PyLocals_GetCopy()와PyFrame_GetLocalsCopy()API를 추가합니다 - 이 PEP는
PyLocals_Kind,PyLocals_GetKind(),PyFrame_GetLocalsKind()를 추가하여, 확장 모듈이 이식성 없는 프레임 및 코드 객체 API를 검사하지 않고도 코드가 함수 스코프에서 실행 중인지 식별할 수 있도록 합니다(제안된 조회 API가 없으면, 새로운PyLocals_GetKind() == PyLocals_SHALLOW_COPY검사에 해당하는 기존 방법은 CPython 내부 프레임 API 헤더를 포함하고_PyFrame_GetCode(PyEval_GetFrame())->co_flags & CO_OPTIMIZED가 설정되어 있는지 확인하는 것입니다)
아래의 Python 의사 코드는 이 글을 작성하는 시점(2021-10-24)에 PEP 667에 제시된 구현 스케치를 기반으로 합니다. 새로운 fast locals 프록시 API와 기존 PyEval_GetLocals() API 간의 향상된 상호 운용성을 제공하는 차이점은 주석으로 표시되어 있습니다.
PEP 667에서와 마찬가지로, 밑줄로 시작하는 모든 속성은 보이지 않으며 직접 접근할 수 없습니다. 이들은 제안된 설계를 설명하기 위한 용도로만 사용됩니다.
단순화를 위해(그리고 PEP 667에서와 마찬가지로) 모듈 및 클래스 수준 프레임의 처리는 생략합니다(_locals가 바로 실행 네임스페이스이므로 변환이 필요하지 않아 훨씬 단순합니다).
NULL: Object # NULL is a singleton representing the absence of a value.
class CodeType:
_name_to_offset_mapping_impl: dict | NULL
...
def __init__(self, ...):
self._name_to_offset_mapping_impl = NULL
self._variable_names = deduplicate(
self.co_varnames + self.co_cellvars + self.co_freevars
)
...
def _is_cell(self, offset):
... # How the interpreter identifies cells is an implementation detail
@property
def _name_to_offset_mapping(self):
"Mapping of names to offsets in local variable array."
if self._name_to_offset_mapping_impl is NULL:
self._name_to_offset_mapping_impl = {
name: index for (index, name) in enumerate(self._variable_names)
}
return self._name_to_offset_mapping_impl
class FrameType:
_fast_locals : array[Object] # The values of the local variables, items may be NULL.
_locals: dict | NULL # Dictionary returned by PyEval_GetLocals()
def __init__(self, ...):
self._locals = NULL
...
@property
def f_locals(self):
return FastLocalsProxy(self)
class FastLocalsProxy:
__slots__ "_frame"
def __init__(self, frame:FrameType):
self._frame = frame
def _set_locals_entry(self, name, val):
f = self._frame
if f._locals is NULL:
f._locals = {}
f._locals[name] = val
def __getitem__(self, name):
f = self._frame
co = f.f_code
if name in co._name_to_offset_mapping:
index = co._name_to_offset_mapping[name]
val = f._fast_locals[index]
if val is NULL:
raise KeyError(name)
if co._is_cell(offset)
val = val.cell_contents
if val is NULL:
raise KeyError(name)
# PyEval_GetLocals() interop: implicit frame cache refresh
self._set_locals_entry(name, val)
return val
# PyEval_GetLocals() interop: frame cache may contain additional names
if f._locals is NULL:
raise KeyError(name)
return f._locals[name]
def __setitem__(self, name, value):
f = self._frame
co = f.f_code
if name in co._name_to_offset_mapping:
index = co._name_to_offset_mapping[name]
kind = co._local_kinds[index]
if co._is_cell(offset)
cell = f._locals[index]
cell.cell_contents = val
else:
f._fast_locals[index] = val
# PyEval_GetLocals() interop: implicit frame cache update
# even for names that are part of the fast locals array
self._set_locals_entry(name, val)
def __delitem__(self, name):
f = self._frame
co = f.f_code
if name in co._name_to_offset_mapping:
index = co._name_to_offset_mapping[name]
kind = co._local_kinds[index]
if co._is_cell(offset)
cell = f._locals[index]
cell.cell_contents = NULL
else:
f._fast_locals[index] = NULL
# PyEval_GetLocals() interop: implicit frame cache update
# even for names that are part of the fast locals array
if f._locals is not NULL:
del f._locals[name]
def __iter__(self):
f = self._frame
co = f.f_code
for index, name in enumerate(co._variable_names):
val = f._fast_locals[index]
if val is NULL:
continue
if co._is_cell(offset):
val = val.cell_contents
if val is NULL:
continue
yield name
for name in f._locals:
# Yield any extra names not defined on the frame
if name in co._name_to_offset_mapping:
continue
yield name
def popitem(self):
f = self._frame
co = f.f_code
for name in self:
val = self[name]
# PyEval_GetLocals() interop: implicit frame cache update
# even for names that are part of the fast locals array
del name
return name, val
def _sync_frame_cache(self):
# This method underpins PyEval_GetLocals, PyFrame_FastToLocals
# PyFrame_GetLocals, PyLocals_Get, mapping comparison, etc
f = self._frame
co = f.f_code
res = 0
if f._locals is NULL:
f._locals = {}
for index, name in enumerate(co._variable_names):
val = f._fast_locals[index]
if val is NULL:
f._locals.pop(name, None)
continue
if co._is_cell(offset):
if val.cell_contents is NULL:
f._locals.pop(name, None)
continue
f._locals[name] = val
def __len__(self):
self._sync_frame_cache()
return len(self._locals)
참고: 이전 버전의 PEP 558 참조 구현을 현재 제안된 의미의 예비 구현으로 바꾸는 가장 간단한 방법은 영향을 받는 연산에서 frame_cache_updated 검사를 제거하고, 대신 해당 메서드에서 항상 프레임 캐시를 동기화하는 것입니다. 이 접근 방식을 채택하면 다음 연산들의 알고리즘 복잡도가 아래와 같이 바뀝니다(여기서 n은 프레임에 정의된 지역 변수 및 셀 변수의 개수입니다):
__len__: O(1) -> O(n)- 값 비교 연산: 더 이상 O(1) 길이 검사 지름길의 이점을 누리지 못합니다
__iter__: O(1) -> O(n)__reversed__: O(1) -> O(n)keys(): O(1) -> O(n)values(): O(1) -> O(n)items(): O(1) -> O(n)popitem(): O(1) -> O(n)
길이 검사와 값 비교 연산은 개선의 여지가 비교적 제한적입니다. 오래되었을 수 있는 캐시의 사용을 허용하지 않는 한, 현재 몇 개의 변수가 바인딩되어 있는지 알 수 있는 유일한 방법은 모든 변수를 순회하며 확인하는 것이며, 어차피 구현이 연산에 그만큼의 사이클을 소비해야 한다면 그 사이클을 프레임 값 캐시를 갱신하고 그 결과를 사용하는 데 쓰는 편이 낫습니다. 이 연산들은 이 PEP와 PEP 667 모두에서 O(n)입니다. 프레임 캐시를 갱신하는 것보다 실제로 더 빠른 맞춤 구현을 제공할 수도 있지만, 알고리즘 복잡도의 개선이 아니라 선형적인 성능 개선만을 제공하는 상황에서 이 연산들의 속도를 높이는 데 필요한 추가적인 코드 복잡성이 그만한 가치가 있을지는 전혀 분명하지 않습니다.
다른 연산들의 O(1) 특성은 값 캐시가 최신 상태인지에 의존하지 않는 구현 코드를 추가함으로써 복원할 수 있습니다.
이터레이터/이터러블 조회 메서드를 O(1)로 유지하려면, PEP 667에서 제안된 것과 마찬가지로 해당하는 내장 dict 헬퍼 타입을 대체하는 맞춤 구현을 작성해야 합니다. 위에서 설명한 것처럼, 그 구현은 PEP 667에 제시된 의사 코드와 유사하겠지만 동일하지는 않을 것입니다(이 PEP가 제공하는 향상된 PyEval_GetLocals() 상호 운용성이 추가 변수를 저장하는 방식에 영향을 주기 때문입니다).
popitem()은 향상된 순회 API에 의존하는 맞춤 구현을 만들어 “항상 O(n)”에서 “최악의 경우 O(n)”으로 개선할 수 있습니다.
Python fast locals 프록시 API에 오래된 프레임 정보가 절대 노출되지 않도록 하려면, 참조 구현의 이러한 변경 사항을 병합 전에 구현해야 합니다.
이 글을 작성하는 시점(2021-10-24)의 현재 구현은 또한 기반 코드 객체에 단일 인스턴스를 저장하는 대신 여전히 각 프레임마다 fast refs 매핑의 사본을 저장합니다(fast locals 배열에 접근할 때마다 셀인지 확인하는 대신 여전히 셀 참조를 직접 저장하기 때문입니다). 이 문제를 수정하는 것 역시 병합 전에 필요합니다.
구현
참조 구현 업데이트는 GitHub에서 초안 풀 리퀘스트로 개발 중입니다([6]).
감사의 글
[1]에서 write-through 프록시 아이디어를 제안하고, 그러한 프록시의 도입을 피하려 했던 이전 버전 PEP의 몇 가지 중대한 설계 결함을 지적해 준 Nathaniel J. Smith에게 감사드립니다.
제안된 C API 추가 사항의 개발자 경험에 더 많은 주의를 기울여 달라고 요청해 준 Steve Dower와 Petr Viktorin에게 감사드립니다 [8] [13].
안정 ABI에서 열거형(enum)을 사용하면서도 임의의 정수로부터의 타입 캐스팅을 안전하게 지원하도록 보장하는 방법을 제안해 준 Larry Hastings에게 감사드립니다.
C 수준 API와 의미론을 더욱 단순화하고 PEP 본문을 크게 명확히 하도록 적극적으로 추진해 준 것(그리고 또다시 1년 동안 활동이 없던 끝에 2021년 초에 PEP에 대한 논의를 재개해 준 것)에 대해 Mark Shannon에게 감사드립니다 [10] [11] [12]. 최종적으로 PEP 667로 발표된 Mark의 의견은 관련 매핑이 사용되지 않을 때 불필요한 O(n) 매핑 갱신 작업 비용이 들지 않도록 하는 여러 구현 효율 개선으로 직접 이어졌으며, Python 수준의 f_locals API를 통해 보고되는 상태가 결코 낡은 상태가 되지 않도록 보장하는 변경으로도 이어졌습니다.
참고 문헌
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.