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가 제공하는 향상된 의미 체계와 병행하여 해당 API를 계속 지원하도록 했습니다.
남아 있는 C API 설계상의 우려는 필요하다고 판단될 경우 나중에 추가할 수 있는 새로운 정보 제공 API와 관련된 것이며, 프레임 로컬 뷰 구현의 정확한 성능 특성에 대한 잠재적인 우려는 실현 가능한 참조 구현이 제공된다는 점으로 상쇄됩니다.
따라서 이 PEP는 PEP 667을 진행하는 방향으로 철회되었습니다.
참고: PEP 667을 구현하는 과정에서 optimized scopes에서 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() 내장 함수의 정확한 의미 체계는 명목상 정의되지 않았지만, 실제로 많은 Python 프로그램은 추적 함수가 설치되지 않은 경우 적어도 CPython에서와 정확히 동일하게 동작하는 것에 의존합니다.
PyPy와 같은 다른 구현도 현재 이러한 동작을 재현하고 있으며, 추적 훅이 설치되었을 때 발생할 수 있는 지역 변수 변경 버그의 재현까지 포함합니다 [1].
이 PEP에서는 추적 후크가 설치되지 않은 경우 CPython의 현재 동작을 대체로 허용할 수 있는 것으로 보지만, 추적 후크가 설치된 경우의 현재 동작은 문제가 있다고 봅니다. 원하는 기능인 pdb와 같은 디버거가 지역 변수를 변경하도록 허용하는 기능조차 안정적으로 활성화하지 못한 채 [1] 없이와 같은 버그를 발생시키기 때문입니다 [3].
초기 PEP와 초안 구현을 검토한 결과, CPython에서 역사적으로 반환해 온 반동적이며 간헐적으로 업데이트되는 공유 복사본을 계속 반환하는 대신, 함수 수준의 locals() 동작을 호출할 때마다 함수 지역 변수와 클로저 변수를 독립적인 스냅샷으로 반환하도록 업데이트함으로써 문서와 구현을 모두 단순화할 기회가 확인되었습니다.
구체적으로 이 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()를 호출할 때마다 각 호출이 업데이트하여 반환하는 공통 딕셔너리 인스턴스를 프레임 객체에 캐시하는 대신 새로운 딕셔너리 객체를 생성하도록 합니다.
이 PEP는 CPython 참조 구현에서 별도의 “tracing” 모드라는 개념을 대부분 제거할 것을 제안합니다. Python 3.10까지의 릴리스에서 CPython 인터프리터는 CPython의 sys 모듈에 있는 sys.settrace ([4]) 또는 CPython C API의 PyEval_SetTrace ([5])와 같은 구현 의존적 메커니즘을 통해 하나 이상의 스레드에 추적 훅이 등록되면 다르게 동작합니다. 이 PEP가 수용되면, 추적 훅이 설치되었을 때 남는 유일한 동작상의 차이는 추적 로직을 각 바이트코드 뒤에서 실행해야 할 때 인터프리터 평가 루프의 일부 최적화가 비활성화된다는 점입니다.
이 PEP는 함수 범위에서 CPython의 동작을 변경하여, 추적 훅이 등록된 경우 locals() 내장 함수의 의미론이 추적 훅이 등록되지 않은 경우와 동일해지도록 하며, 관련 프레임 API 의미론을 더 명확하게 만들어 대화형 디버거가 더 쉽게 의존할 수 있도록 할 것을 제안합니다.
제안된 tracing 모드 제거는 트레이스백이나 sys._getframe() API 등을 통해 다른 방식으로 얻은 프레임 객체 참조의 의미론에 영향을 줍니다. 추적 훅 지원에 필요한 쓰기 전달 의미론은 런타임 상태에 따라 달라지는 대신 항상 프레임 객체의 f_locals 속성을 통해 제공되기 때문입니다.
새로운 locals() 문서
이 제안의 핵심은 locals() 내장 함수의 문서를 다음과 같이 개정하는 것입니다.
현재 지역 심볼 테이블을 나타내는 매핑 객체를 반환하며, 변수 이름이 키이고 현재 바인딩된 참조가 값입니다.모듈 범위에서, 그리고 단일 네임스페이스와 함께
exec()또는eval()을 사용할 때 이 함수는globals()와 동일한 네임스페이스를 반환합니다.클래스 범위에서는 메타클래스 생성자에 전달될 네임스페이스를 반환합니다.
서로 다른 지역 및 전역 네임스페이스와 함께
exec()또는eval()을 사용할 때는 함수 호출에 전달된 지역 네임스페이스를 반환합니다.위의 모든 경우에 특정 실행 프레임에서
locals()를 호출할 때마다 동일한 매핑 객체를 반환합니다.locals()에서 반환된 매핑 객체를 통해 변경한 내용은 바인딩되거나 다시 바인딩되거나 삭제된 지역 변수에 반영되며, 지역 변수를 바인딩하거나 다시 바인딩하거나 삭제하면 반환된 매핑 객체의 내용에 즉시 영향을 줍니다.함수 범위에서는 제너레이터와 코루틴을 포함하여
locals()를 호출할 때마다 함수의 지역 변수와 비지역 셀 참조의 현재 바인딩을 포함하는 새로운 딕셔너리를 대신 반환합니다. 이 경우 반환된 딕셔너리를 통해 이루어진 이름 바인딩 변경은 해당 로컬 변수 또는 비로컬 셀 참조에 기록되지 않으며, 로컬 변수와 비로컬 셀 참조를 바인딩하거나 다시 바인딩하거나 삭제해도 이전에 반환된 딕셔너리의 내용에는 영향을 주지 않습니다.
이 변경이 이루어지는 릴리스에는 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()가 반환하는 매핑은 정의된 클래스의 기반이 되는 실제 클래스 네임스페이스로 사용되지 않으며, 클래스 생성 과정에서 해당 내용을 새 딕셔너리로 복사하고, 이 딕셔너리는 클래스 처리 과정을 통해서만 접근할 수 있습니다.
함수 내부에 정의된 중첩 클래스의 경우, 클래스 스코프에서 참조되는 비로컬 셀은 locals() 매핑에 포함되지 않습니다.
이러한 기대 사항을 언어 사양의 일부로 명시하기 위해 다음 두 단락을 locals()문서에 추가합니다.
별도의 지역 및 전역 네임스페이스와 함께exec()또는eval()을 사용할 때 [이 함수]는 주어진 지역 네임스페이스를 반환합니다.클래스 스코프에서는 메타클래스 생성자에 전달될 네임스페이스를 반환합니다.
제안의 이 부분에는 참조 구현의 변경이 필요하지 않으며, 현재 동작을 표준화하는 것입니다.
함수 스코프
함수 스코프에서는 인터프리터 구현이 지역 변수 액세스를 최적화할 상당한 자유를 가지므로, locals()이 반환하는 매핑을 통해 지역 및 비지역 변수 바인딩을 임의로 수정하는 것을 반드시 허용할 필요는 없습니다.
역사적으로 이러한 관용은 언어 사양에서 다음 문구로 설명되어 왔습니다. “이 딕셔너리의 내용은 수정하지 않아야 하며, 변경 사항이 인터프리터에서 사용하는 지역 변수 및 자유 변수의 값에 영향을 주지 않을 수 있습니다.”
이 PEP는 해당 문구를 다음과 같이 변경할 것을 제안합니다.
함수 스코프(제너레이터와 코루틴을 포함)에서는locals()을 호출할 때마다 대신 함수의 지역 변수와 모든 비지역 셀 참조의 현재 바인딩을 포함하는 새 딕셔너리를 반환합니다. 이 경우 반환된 딕셔너리를 통해 이루어진 이름 바인딩 변경은 해당 로컬 변수 또는 비로컬 셀 참조에 기록되지 않으며, 로컬 변수와 비로컬 셀 참조를 바인딩하거나 다시 바인딩하거나 삭제해도 이전에 반환된 딕셔너리의 내용에는 영향을 주지 않습니다.
제안의 이 부분은 CPython 참조 구현의 변경을 분명히 요구합니다. 현재 CPython은 추가로 locals()를 호출하면 암묵적으로 새로 고쳐질 수 있는 공유 매핑 객체를 반환하며, 추적 함수에서 네임스페이스 변경을 지원하기 위해 현재 사용되는 “다시 기록” 전략도 이를 준수하지 않기 때문입니다. 또한 이 전략은 위의 동기 부여에서 언급한 특이한 동작 문제를 일으킵니다.
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 수준 프레임 지역 변수 데이터 저장 필드를 사용하여 값 캐시를 보관하며, 이 캐시는 임의의 추가 키도 저장할 수 있습니다. 이러한 빠른 지역 변수 프록시 객체의 예상 동작에 대한 추가 세부 사항은 아래에서 설명합니다. - 지역 네임스페이스에 대한 가변 매핑에 액세스하는 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은 현재 함수 프레임(코드 객체가 “빠른 로컬 변수” 의미론을 사용하는 프레임)에 대해 다음 작업을 수행합니다.
- 프레임 값 캐시를 업데이트하기 위해
PyFrame_FastToLocals를 호출합니다. - 트레이스 훅을 호출합니다(훅 자체에 대한 트레이싱은 비활성화됨).
- 프레임 값 캐시에 적용된 변경 사항을 캡처하기 위해
PyFrame_LocalsToFast를 호출합니다.
이 접근 방식은 몇 가지 이유로 문제가 있습니다.
- 트레이스 함수가 값 캐시를 변경하지 않더라도, 마지막 단계에서 모든 셀 참조가 트레이스 함수 호출 전의 상태로 재설정됩니다(이것이 [1]의 버그 보고서의 근본 원인입니다).
- 트레이스 함수가 값 캐시를 실제로 변경하더라도, 이후 값 캐시가 프레임에서 새로 고쳐지도록 하는 작업을 수행하면 해당 변경 사항은 손실됩니다([3]의 버그 보고서에서 설명한 문제의 한 측면입니다).
- 트레이스 함수가 트레이싱 중인 프레임이 아닌 다른 프레임의 로컬 변수를 변경하려고 하면(예:
frame.f_back.f_locals), 해당 변경 사항은 거의 확실히 손실됩니다(이는 [3]의 버그 보고서에서 또 다른 측면에 해당합니다). - 프레임 값 캐시에 대한 참조(예:
locals()를 통해 가져온 참조)가 다른 함수에 전달되고 그 함수가 값 캐시를 변경하면, 추적 훅이 설치된 경우에 그러한 변경 사항이 실행 프레임에 다시 기록될 수도 있습니다.
이 문제에 대해 제안된 해결책은 일반적으로 함수가 언어에서 정의한 locals() 내장 함수를 사용하여 자체 네임스페이스에 접근하는 반면, 트레이스 함수는 반드시 구현에 종속적인 frame.f_locals 인터페이스를 사용한다는 사실을 활용하는 것입니다. 훅 구현에 전달되는 것이 프레임 참조이기 때문입니다.
Python 수준의 frame.f_locals는 과거에 locals() 내장 함수가 반환하던 내부 프레임 값 캐시에 대한 직접 참조가 아니라, 기반 프레임의 빠른 로컬 변수 배열에서 값을 직접 읽고 쓰는 전용 빠른 로컬 변수 프록시 타입의 인스턴스를 반환하도록 변경됩니다. 이 속성에 접근할 때마다 프록시의 새 인스턴스가 생성됩니다(따라서 프록시 인스턴스 생성은 의도적으로 비용이 적은 연산입니다).
새로운 프록시 타입이 최적화된 프레임에서 로컬 변수에 접근하는 기본 방식이 되더라도, 프레임에 저장된 내부 값 캐시는 다음 두 가지 핵심 목적을 위해 계속 유지됩니다.
PyEval_GetLocals()C API에 대한 하위 호환성과 상호 운용성 유지- 빠른 로컬 변수 배열에 슬롯이 없는 추가 키를 위한 저장 공간 제공(예: 디버깅 목적으로 코드 실행을 트레이싱할 때
pdb가 설정하는__return__및__exception__키)
이 PEP의 변경 사항에 따라 이 내부 프레임 값 캐시는 더 이상 Python 코드에서 직접 접근할 수 없습니다(과거에는 locals() 내장 함수가 반환했으며 frame.f_locals 속성으로도 사용할 수 있었습니다). 대신 값 캐시는 PyEval_GetLocals() C API를 통해서만, 그리고 프레임 객체의 내부 저장 공간에 직접 접근하는 방식으로만 접근할 수 있습니다.
빠른 로컬 변수 프록시 객체와 PyEval_GetLocals()가 반환하는 내부 프레임 값 캐시는 다음과 같은 동작 보장을 제공합니다.
- 빠른 로컬 변수 프록시를 통해 적용된 변경 사항은 프레임 자체, 동일한 프레임에 대한 다른 빠른 로컬 변수 프록시 객체, 그리고 프레임에 저장된 내부 값 캐시에서 즉시 확인할 수 있습니다(이 마지막 항목이
PyEval_GetLocals()과의 상호 운용성을 제공합니다). - 내부 프레임 값 캐시에 직접 적용된 변경 사항은 프레임 자체에서는 절대 확인할 수 없으며, 변경 사항이 프레임의 빠른 로컬 변수 배열에 슬롯이 없는 추가 변수와 관련된 경우에만 동일한 프레임에 대한 빠른 로컬 변수 프록시를 통해 안정적으로 확인할 수 있습니다.
- 프레임에서 코드를 실행하여 적용된 변경 사항은 해당 프레임의 모든 빠른 로컬 변수 프록시 객체(기존 프록시와 새로 생성된 프록시 모두)에서 즉시 확인할 수 있습니다.
PyEval_GetLocals()가 반환하는 내부 프레임 값 캐시에서의 가시성은 다음 절에서 설명하는 캐시 업데이트 지침의 적용을 받습니다.
따라서 이러한 사항의 결과로 PyEval_GetLocals(), PyLocals_Get() 또는 PyLocals_GetCopy()를 사용하는 코드만 프레임 값 캐시가 오래된 상태가 될 가능성에 주의해야 합니다. 새로운 프레임 빠른 로컬 변수 프록시 API를 사용하는 코드(Python에서 사용하든 C에서 사용하든)는 항상 프레임의 현재 상태를 확인합니다.
빠른 로컬 변수 프록시 구현 세부 사항
각 빠른 로컬 변수 프록시 인스턴스에는 Python 런타임 API의 일부로 노출되지 않는 단일 내부 속성이 있습니다.
- frame: 프록시가 접근을 제공하는 기반 최적화 프레임
또한 프록시 인스턴스는 기반 프레임 또는 코드 객체에 저장된 다음 속성을 사용하고 업데이트합니다.
- _name_to_offset_mapping: 변수 이름에서 빠른 로컬 저장소 오프셋으로 매핑하는 숨겨진 매핑입니다. 이 매핑은 첫 번째 빠른 로컬 프록시가 생성되는 즉시 미리 채워지는 대신, 빠른 로컬 프록시를 통한 첫 번째 프레임 읽기 또는 쓰기 접근 시 지연 초기화됩니다. 주어진 코드 객체를 실행하는 모든 프레임에서 매핑이 동일하므로, 각 프레임 객체가 자체 매핑을 채우도록 하는 대신 코드 객체에 사본 하나만 저장합니다.
- 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 사용자에게 표시되도록 내부 프레임 값 캐시에도 즉시 기록됩니다.
기본 프레임에서 로컬 변수 또는 클로저 변수로 정의되지 않은 키도 최적화된 프레임에서는 내부 값 캐시에 기록됩니다. 이를 통해 프레임의 f_locals매핑에 __return__및 __exception__값을 기록하는 pdb와 같은 유틸리티가 이전과 동일하게 계속 작동할 수 있습니다. 프레임의 로컬 변수 또는 클로저 변수에 해당하지 않는 이러한 추가 키는 향후 캐시 동기화 작업에서도 그대로 유지됩니다. 이러한 추가 키만 보유하는 새 매핑을 정의하는 대신 프레임 값 캐시를 사용해 추가 키를 저장하면 기존 PyEval_GetLocals() C API와 완전히 상호 운용할 수 있습니다. 두 API의 사용자는 어느 API의 사용자든 추가한 추가 키를 볼 수 있지만, 새로운 빠른 로컬 프록시 API의 사용자만 해당 API를 통해 추가된 키를 볼 수 있게 되지는 않기 때문입니다.
프레임에 프록시 타입의 인스턴스를 저장하는 대신 변수 값 캐시만 저장하면 프레임에서 자기 자신으로 되돌아가는 참조 순환이 생성되지 않는다는 추가적인 이점이 있습니다. 따라서 다른 객체가 프록시 인스턴스에 대한 참조를 유지하는 경우에만 프레임이 계속 살아 있게 됩니다.
참고: proxy.clear()메서드를 호출하는 것은 이전 버전에서 빈 프레임 값 캐시에 PyFrame_LocalsToFast()를 호출하는 것과 마찬가지로 광범위한 영향을 줍니다. 프레임의 로컬 변수뿐만 아니라 프레임에서 접근할 수 있는 모든 셀 변수도 삭제됩니다(해당 셀이 프레임 자체에 소유되었는지 외부 프레임에 소유되었는지와 관계없이 삭제됩니다). 이 방법은 인자 없는 super() 구문을 사용하거나(또는 그 밖의 방식으로 __class__를 참조하는) 메서드의 프레임에서 호출될 경우 클래스의 __class__ 셀을 삭제할 수 있습니다. 이는 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()내장 함수와 직접적으로 동등합니다. 모듈 및 클래스 스코프에서 활성 Python 프레임의 로컬 네임스페이스 매핑에 대한 새 참조를 반환하며, exec()또는 eval()을 사용하는 경우에도 동일합니다. 함수/코루틴/제너레이터 스코프에서는 활성 네임스페이스의 얕은 복사본을 반환합니다.
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()는 현재 지역 네임스페이스의 내용으로 채워진 새 딕셔너리 인스턴스를 반환합니다. 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()와 결합)을 사용하십시오. 이 사용 방식에서는 최적화된 프레임에서 복사본이 오래되어 최신 상태가 아닐 수 있다는 점을 고려해야 합니다. - 현재 지역 네임스페이스의 복사본을 포함하지만 활성 프레임과 지속적인 연결이 없는 일반적인 변경 가능 딕셔너리가 필요하면
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()를 호출하는 경우입니다. - 구현의 일부로 공유 매핑을 업데이트하는 fast locals 프록시 객체에 대한 모든 연산입니다. 초기 참조 구현에서 이러한 연산은 본질적으로
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가 항상 예외를 발생시키므로, PEP는 추적 훅에서 반환할 때 PyFrame_LocalsToFast()를 암시적으로 호출하는 것도 필연적으로 제거합니다.
근거 및 설계 논의
함수 범위에서 locals()가 독립적인 스냅샷을 반환하도록 변경하기
locals()내장 함수는 언어의 필수 구성 요소이며, 참조 구현에서는 역사적으로 다음과 같은 특성을 가진 변경 가능한 매핑을 반환해 왔습니다.
locals()를 호출할 때마다 동일한 매핑 객체를 반환합니다.locals()가 실제 로컬 실행 네임스페이스가 아닌 다른 무언가에 대한 참조를 반환하는 네임스페이스에서는,locals()를 호출할 때마다 로컬 변수와 참조된 모든 비로컬 셀의 현재 상태로 매핑 객체를 업데이트합니다.- 반환된 매핑의 변경 사항은 일반적으로 로컬 변수 바인딩이나 비로컬 셀 참조에 기록되지 않지만, 다음 중 하나를 수행하면 기록이 발생할 수 있습니다.
- Python 수준의 추적 훅 설치하기(그러면 추적 훅이 호출될 때마다 기록이 발생합니다)
- 함수 수준의 와일드카드 임포트 실행하기(Py3에서는 바이트코드 주입이 필요합니다)
- 함수의 범위에서
exec문 실행하기(exec가 Python 3에서 일반적인 내장 함수가 되었으므로 Py2에서만 해당합니다)
원래 이 PEP는 이 두 특성 중 처음 두 가지를 유지하면서, 세 번째 특성을 변경하여 이 특성으로 인해 발생할 수 있는 명백한 동작 버그를 해결하고자 했습니다.
[7]에서 Nathaniel Smith는 두 번째 특성만 유지하고 함수 범위에서 locals()를 호출할 때마다 암시적으로 공유되는 스냅샷을 업데이트하는 대신 로컬 변수와 클로저 참조의 독립적인 스냅샷을 반환하도록 하면 함수 범위에서 locals()의 동작을 훨씬 덜 혼란스럽게 만들 수 있다는 설득력 있는 주장을 제시했습니다.
이 수정된 설계는 구현을 훨씬 더 쉽게 이해할 수 있도록 했으므로, PEP는 과거의 공유 스냅샷을 유지하는 대신 이러한 동작 변경을 제안하도록 업데이트되었습니다.
함수 범위에서 locals()를 스냅샷으로 유지하기
[7]에서 논의했듯이, locals()내장 함수의 의미를 변경하여 독립적인 스냅샷을 반환하도록 하는 대신 함수 범위에서 쓰기 반영 프록시를 반환하게 하는 것도 이론적으로는 가능합니다.
이 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]에서 보고된 버그입니다.
Python 2.1에서 중첩 스코프를 도입한 PEP 227이후 유지되어 온 사실상의 의미를 보존하는 것 외에도, 쓰기 관통 프록시 지원을 구현 정의 프레임 객체 API로 제한하면 전체 프레임 API를 에뮬레이트하는 인터프리터 구현만 쓰기 관통 기능을 제공하면 된다는 또 다른 이점이 있습니다. 또한 JIT 컴파일 구현은 함수 스코프에서 locals()에 액세스할 때마다가 아니라, 프레임 introspection 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()가 문장에서 내장 함수로 변환되었을 때(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()로 쉽게 수정할 수 없다는 의미입니다(가능하기는 하지만, 프레임에 다시 쓰기를 활성화하려면 frame.f_locals속성을 가져온 다음 PyFrame_LocalsToFast()를 호출해야 하며, 위에서 ctypes를 사용하여 보인 것과 같습니다).
관련 동기 절에서 언급했듯이, 이 혼란스러운 부작용은 지역 변수가 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와 유사하게).locals()가 진정으로 독립적인 스냅샷을 반환하도록 하여,exec()를 통해 지역 변수의 값을 변경하려는 시도가 위에서 언급한 예외 사항 없이 일관되게 무시되도록 합니다.
PEP에서는 다음과 같은 이유로 두 번째 선택지를 채택합니다.
- 최적화된 스코프에서 독립적인 스냅샷을 반환하면, 대부분의 경우
exec()를 통해 지역 변수를 변경하려는 시도가 무시되도록 한 Python 3.0의exec()변경 사항이 유지됩니다. - “최적화된 스코프에서
locals()이 지역 변수의 순간 스냅샷을 제공하고 다른 스코프에서는 읽기/쓰기 액세스를 제공한다”는 것과 “frame.f_locals은 최적화된 스코프를 포함한 모든 스코프에서 지역 변수에 대한 읽기/쓰기 액세스를 제공한다”는 것의 구분을 통해, 두 API가 최적화된 스코프에서 완전한 읽기/쓰기 액세스를 제공하는 경우보다 코드의 의도를 더 명확하게 표현할 수 있습니다. 이는 쓰기 액세스가 필요하지 않거나 바람직하지 않은 경우에도 마찬가지입니다. - 사람 독자의 명확성을 높이는 것뿐만 아니라, 프레임 introspection API에 액세스하지 않는 한 최적화된 스코프에서 이름 재바인딩이 코드에 어휘적으로 표시되도록 보장하면 컴파일러와 인터프리터가 관련 성능 최적화를 더 일관되게 적용할 수 있습니다.
- 선택적 프레임 introspection API를 지원하는 Python 구현만 최적화된 프레임에 새로운 쓰기 관통 프록시 지원을 제공해야 합니다.
이 PEP에서 locals()의 의미가 변경되면 exec()와 eval()의 동작을 훨씬 쉽게 설명할 수 있습니다. 최적화된 범위에서는 이들이 지역 변수에 절대로 암묵적인 영향을 주지 않으며, 그 밖의 범위에서는 지역 변수에 항상 암묵적인 영향을 줍니다. 최적화된 범위에서 지역 변수에 대한 암묵적인 할당은 코드 실행 API가 반환될 때 폐기되는데, 호출할 때마다 지역 변수의 새 복사본이 사용되기 때문입니다. 최적화된 스코프에서 지역 변수에 대한 모든 암묵적 할당은 코드 실행 API가 반환될 때 폐기됩니다. 호출마다 지역 변수의 새 복사본이 사용되기 때문입니다.
내부 프레임 값 캐시 유지
내부 프레임 값 캐시를 유지하면 프레임 프록시 인스턴스를 보존하고 프레임에서 이름 바인딩 및 바인딩 해제 작업을 수행한 후 재사용할 때 몇 가지 눈에 보이는 특이한 동작이 발생합니다.
프레임 값 캐시를 유지하는 주된 이유는 PyEval_GetLocals() API와의 하위 호환성을 유지하기 위해서입니다. 해당 API는 빌린 참조를 반환하므로 프레임 객체에 저장된 지속적인 상태를 참조해야 합니다. 프레임에 빠른 지역 변수 프록시 객체를 저장하면 문제가 되는 참조 순환이 생성되므로, 가장 깔끔한 선택지는 최적화된 프레임이 처음 도입된 이래 이 함수가 해 온 것처럼 프레임 값 캐시를 계속 반환하는 것입니다.
어차피 프레임 값 캐시를 유지하게 되었으므로, 이를 활용하여 빠른 지역 변수 프록시 매핑 구현을 단순화하는 것이 더욱 합리적이었습니다.
참고: PEP 667이 쓰기 관통 프록시 구현의 일부로 내부 프레임 값 캐시를 사용하지 않는다는 사실이 두 PEP 사이의 Python 수준 핵심 차이점입니다.
일반적인 작업에서 프레임 API 의미 변경
참고: 이 PEP가 처음 작성되었을 때는 추적 함수가 설치될 때마다 프레임 지역 변수의 암묵적 기록을 삭제하도록 한 Python 3.11의 변경 이전이었으므로, 해당 변경을 적용하는 것이 제안의 일부로 포함되었습니다.
이 PEP의 이전 버전에서는 프레임 f_locals 속성의 의미가 추적 후크가 현재 설치되어 있는지 여부에 따라 달라지도록 제안했습니다. 즉, 추적 후크가 활성화된 경우에만 쓰기 관통 프록시 동작을 제공하고, 그렇지 않은 경우에는 기존의 locals() 내장 함수와 동일하게 동작하도록 했습니다.
이는 실용적인 이유와 보다 철학적인 이유라는 두 가지 핵심 이유로 원래 설계 제안으로 채택되었습니다.
- 객체 할당과 메서드 래퍼는 공짜가 아니며, 추적 함수만이 함수 외부에서 프레임 지역 변수에 액세스하는 유일한 작업도 아닙니다. 변경 사항을 추적 모드로 제한하면 이러한 변경으로 인한 추가 메모리 및 실행 시간 오버헤드를 일반적인 작업에서 가능한 한 0에 가깝게 만들 수 있었습니다.
- “고장 나지 않은 것은 변경하지 마십시오”: 현재 추적 모드의 문제는 추적 모드에 특화된 요구 사항(함수 지역 변수 참조의 외부 재바인딩 지원)으로 인해 발생하므로, 관련 수정 사항도 추적 모드로 제한하는 것이 타당했습니다.
그러나 실제로 이러한 동적 접근 방식을 구현하고 문서화하려고 시도하면서, 이 방식이 frame.f_locals이 작동하는 방식에 대해 런타임 상태에 따라 달라지는 매우 미묘한 동작 구분을 만들고, 추적 함수가 추가 및 제거될 때 f_locals가 동작하는 방식과 관련된 몇 가지 새로운 예외 사례를 만든다는 사실이 드러났습니다.
따라서 설계를 현재 방식으로 전환했습니다. 즉, frame.f_locals은 항상 쓰기 관통 프록시이고 locals()은 항상 스냅샷이 되도록 했으며, 이 방식은 구현과 설명이 모두 더 간단합니다.
CPython 참조 구현이 이를 어떻게 처리하기로 선택하든, 최적화 컴파일러와 인터프리터는 디버거에 추가적인 제한을 부과할 자유도 여전히 갖습니다. 예를 들어 프레임 객체를 통한 지역 변수 변경을 선택적 기능으로 만들어 일부 최적화를 비활성화할 수 있습니다(일부 Python 구현에서 CPython의 프레임 API 에뮬레이션이 이미 선택적 플래그인 것과 같습니다).
최적화된 프레임에 추가 데이터를 저장하는 기능의 지속적인 지원
이 PEP의 초안 버전 중 하나에서는 기반 프레임의 지역 변수 또는 클로저 변수 이름에 해당하지 않는 frame.f_locals 키에 기록하여 최적화된 프레임에 추가 데이터를 저장하는 기능을 제거할 것을 제안했습니다.
이 아이디어는 빠른 지역 변수 프록시 구현을 매력적으로 단순화할 수 있었지만, pdb는 임의의 프레임에 __return__및 __exception__값을 저장하므로 해당 기능이 더 이상 작동하지 않으면 표준 라이브러리 테스트 모음이 실패합니다.
따라서 임의의 키를 저장하는 기능은 유지되었으며, 그 대가로 프록시 객체에 대한 일부 연산은 그렇지 않은 경우보다 느려집니다(코드 객체에 정의된 이름만 프록시를 통해 접근할 수 있다고 가정할 수 없기 때문입니다).
개선 기회가 확인됨에 따라 빠른 지역 변수 프록시와 기반 프레임의 f_locals값 캐시 간 상호 작용의 정확한 세부 사항은 시간이 지나면서 발전할 것으로 예상됩니다.
함수 범위에서의 역사적 의미론
CPython에서 locals()및 frame.f_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 디스크립터처럼 동작하는 또 다른 함수(필요한 경우 쓰기 연동 프록시를 생성하고 반환함)를 제안했습니다.
해당 버전의 C API에 관한 피드백 [8]은 이것이 파이썬 수준의 의미 체계가 구현된 방식에 지나치게 의존하며, 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()가 차용 참조를 반환하기 때문에 수정할 수 없다는 최초의 주장은 단순히 사실이 아니었습니다. PEP 558의 참조 구현에서도 여전히 작동하고 있기 때문입니다. 이를 계속 작동하게 유지하는 데 필요한 것은 내부 프레임 값 캐시를 유지하고, 캐시가 필요하지 않을 때 상당한 런타임 오버헤드를 발생시키지 않으면서 프레임 상태의 변경 사항에 따라 캐시를 최신 상태로 유지하기가 합리적으로 간단하도록 빠른 로컬 프록시를 설계하는 것뿐입니다. 이 주장이 사실이 아니므로,PyEval_GetLocals()API를 사용하는 모든 코드를 서로 다른 참조 카운팅 의미론을 가진 새로운 API를 사용하도록 다시 작성해야 한다는 제안은 API 호환성 중단이 중단으로 인한 손실에 비해 큰 이익을 가져야 한다는 PEP 387의 요구 사항을 충족하지 못합니다(캐시를 제거해도 얻는 상당한 이익이 없으므로 코드 중단을 정당화할 수 없습니다). 진정으로 수정할 수 없는 유일한 공개 API는PyFrame_LocalsToFast()입니다(따라서 두 PEP 모두 이 API를 중단할 것을 제안합니다).- 어떤 형태로든 내부 값 캐시가 없으면 빠른 로컬 프록시 매핑의 API 성능 특성이 상당히 직관에 어긋나게 됩니다. 예를 들어
len(proxy)는 프레임에 정의된 변수 수에 대해 일관되게 O(n)이 됩니다. 프록시가 답을 결정하기 전에 전체 빠른 로컬 배열을 순회하여 현재 어떤 이름이 값에 바인딩되어 있는지 확인해야 하기 때문입니다. 이와 대조적으로 내부 프레임 값 캐시를 유지하면 알고리즘 복잡도 관점에서 프록시를 대체로 일반 딕셔너리처럼 처리할 수 있으며, 캐시 최신 상태에 의존하는 연산이 처음 실행될 때 수행되는 최초의 암시적 O(n) 캐시 갱신만 예외로 고려하면 됩니다. - 캐시가 없는 구현이 더 간단할 것이라는 주장은 매우 의심스럽습니다. PEP 667에는 최적화된 프레임의 기반 데이터 저장소와 통합된 새로운 매핑 유형의 완전한 C 구현이 아니라, 변경 가능한 매핑 구현의 일부에 대한 순수 Python 개요만 포함되어 있기 때문입니다. PEP 558의 빠른 로컬 프록시 구현은 변경 가능한 매핑 API를 완전히 구현하는 데 필요한 연산을 프레임 값 캐시에 크게 위임하므로, 다음 연산에 대해 기존 딕셔너리 구현을 재사용할 수 있습니다:
__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와 구현이 채택되든, 빠른 로컬 프록시 객체는 지역 변수의 현재 상태를 항상 일관되게 보여 줍니다. 일반 딕셔너리에서는 O(1)인 일부 연산이 O(n)이 되더라도 마찬가지입니다. 구체적으로 len(proxy)는 현재 어떤 이름이 바인딩되어 있는지 확인해야 하므로 O(n)이 되며, 프록시 매핑 비교에서는 일반 매핑에서 저장된 키 수의 차이를 빠르게 감지할 수 있도록 하는 길이 확인 최적화에 의존하지 않습니다.
프록시 구현에서 이러한 비표준 성능 특성을 채택함에 따라, PyLocals_GetView()및 PyFrame_GetLocalsView()C API도 이 PEP의 제안에서 제거되었습니다.
이에 따라 두 PEP 사이에 남은 유일한 차이점은 C API와 구체적으로 관련된 사항입니다:
- 적절하게 설계된 빠른 로컬 프록시 구현이 있다면 이러한 API를 무기한(그리고 상호 운용 가능하게) 계속 작동시킬 수 있음에도 불구하고, PEP 667은 정당화 없이 완전히 불필요한 C API 중단(
PyEval_GetLocals(),PyFrame_FastToLocalsWithError(),PyFrame_FastToLocals()의 프로그래밍 방식의 사용 중단 및 최종 제거)을 여전히 제안합니다. - 이 PEP에서는 빠른 로컬 프록시의 추가 변수 처리를 기존
PyEval_GetLocals()API와 완전히 상호 운용되도록 정의합니다. 새 프레임 API 사용자는 PEP 667에서 제안된 프록시 구현에서 기존 API 사용자가 추가 변수에 적용한 변경 사항을 볼 수 없으며, 기존 API를 통해 추가 변수에 적용한 변경 사항은 이후PyEval_GetLocals()호출에서 덮어쓰입니다. - 이 PEP의
PyLocals_Get()API는 PEP 667에서PyEval_Locals()라고 부릅니다. 이 함수 이름은 동사가 없어서 데이터 액세스 API라기보다 유형 이름처럼 보이므로 다소 이상합니다. - 이 PEP는
PyLocals_GetCopy()및PyFrame_GetLocalsCopy()API를 추가하여,PyLocals_Get()가 이미 복사본을 생성하는 프레임에서 확장 모듈이 이중 복사 연산의 발생을 쉽게 피할 수 있도록 합니다. - 이 PEP는 확장 모듈이 이식 불가능한 프레임 및 코드 객체 API를 검사하지 않고도 코드가 함수 스코프에서 실행 중인지 식별할 수 있도록
PyLocals_Kind,PyLocals_GetKind(),PyFrame_GetLocalsKind()를 추가합니다(제안된 쿼리 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에서 제안된 것처럼 해당 내장 딕셔너리 헬퍼 타입을 위한 사용자 지정 대체 구현을 작성해야 합니다. 위에서 설명한 것처럼 구현은 PEP 667에 제시된 의사 코드와 유사하지만 동일하지는 않습니다(이 PEP가 제공하는 향상된 PyEval_GetLocals() 상호 운용성으로 인해 추가 변수를 저장하는 방식이 영향을 받기 때문입니다).
향상된 순회 API에 의존하는 사용자 지정 구현을 만들면 popitem()을 “항상 O(n)”에서 “최악의 경우 O(n)”으로 개선할 수 있습니다.
오래된 프레임 정보가 Python fast locals 프록시 API에 표시되지 않도록 하려면 병합 전에 참조 구현에 이러한 변경 사항을 구현해야 합니다.
작성 시점(2021-10-24)의 현재 구현은 여전히 기본 코드 객체에 단일 인스턴스를 저장하는 대신 각 프레임에 fast refs 매핑의 복사본을 저장합니다(각 fast locals 배열 액세스에서 셀을 확인하는 대신 셀 참조도 여전히 직접 저장하기 때문입니다). 이를 수정하는 작업도 병합 전에 필요합니다.
구현
참조 구현 업데이트는 GitHub의 초안 풀 리퀘스트로 개발 중입니다([6]).
감사의 말
Nathaniel J.에게 감사드립니다. Smith는 [1]에서 write-through 프록시 아이디어를 제안하고 이러한 프록시의 도입을 피하려 했던 PEP 초기 버전에서 몇 가지 중요한 설계 결함을 지적했습니다.
제안된 C API 추가 사항 [8] [13]의 개발자 경험에 더 많은 관심을 기울여 달라고 요청해 주신 Steve Dower와 Petr Viktorin에게 감사드립니다.
안정 ABI에서 임의의 정수로부터의 타입캐스팅을 안전하게 지원하도록 보장하면서 enum을 사용하는 방법을 제안해 준 Larry Hastings에게 감사드립니다.
C 레벨 API와 시맨틱스의 추가적인 단순화를, 그리고 PEP 텍스트의 상당한 명확화를 강력히 추진해 준 것에 대해 (그리고 한 해 더 이어진 활동 중단 이후 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.