[Withdrawn] PEP 558 - Defined semantics for locals()

원문 링크: PEP 558 - Defined semantics for locals()

상태: Withdrawn 유형: Standards Track 작성일: 08-Sep-2017

PEP 558은 locals() 내장 함수의 동작을 명확히 정의하려는 제안이었으나, 2021년 12월에 PEP 667과의 내용 통합으로 인해 철회되었습니다. 이 문서는 locals()의 의미론적 정의와 관련된 배경, 문제점 및 제안된 해결책을 담고 있습니다. 특히, 함수 스코프에서의 locals() 동작을 예측 가능하게 만들고, 트레이싱 함수 유무에 따른 동작 차이를 없애는 데 중점을 두었습니다.


PEP 558 – locals()의 정의된 의미론

PEP 철회 (PEP Withdrawal)

2021년 12월, 이 PEP 558과 PEP 667은 locals() 내장 함수의 Python 레벨 의미론 변경에 대한 공통 정의로 수렴되었으며, 남아있는 유일한 차이점은 제안된 C API 변경 사항과 다양한 내부 구현 세부 사항이었습니다.

이러한 차이점 중 가장 중요한 것은 PEP 667이 당시 PyEval_GetLocals() API에 대한 즉각적인 하위 호환성 단절을 제안했다는 점입니다. 그러나 PEP 667은 이후 PyEval_GetLocals() API에 대해 충분한 deprecation 기간을 제안하도록 변경되었으며, 새로운 PyEval_GetFrameLocals() API가 제공하는 개선된 의미론과 함께 이를 계속 지원합니다.

결과적으로 PEP 558은 PEP 667의 진행을 지지하며 철회되었습니다.

참고: PEP 667을 구현하는 과정에서, 최적화된 스코프에서 locals()가 독립적인 스냅샷을 반환하도록 업데이트되는 것에 대한 근거와 영향이 두 PEP 모두에서 완전히 명확하지 않다는 점이 드러났습니다. 이에 따라 이 PEP의 “Motivation” 및 “Rationale” 섹션이 업데이트되었습니다 (해당 내용은 채택된 PEP 667에도 동일하게 적용됩니다).

Abstract (개요)

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에 여러 지원 함수 및 타입 정의를 추가할 것을 제안합니다.

Motivation (동기)

locals() 내장 함수의 정확한 의미론은 명목상 정의되지 않았지만, 실제로는 많은 Python 프로그램이 CPython에서의 동작 방식에 의존합니다 (최소한 트레이싱 함수가 설치되지 않았을 때). PyPy와 같은 다른 구현체들도 현재 그 동작을 복제하고 있으며, 트레이스 훅이 설치되었을 때 발생할 수 있는 지역 변수 변형 버그를 재현하는 것까지 포함합니다.

이 PEP는 트레이스 훅이 설치되지 않은 CPython의 현재 동작은 대체로 허용 가능하다고 보지만, 트레이스 훅이 설치되었을 때의 현재 동작은 문제가 있다고 판단합니다. 이는 pdb와 같은 디버거가 지역 변수를 변경할 수 있도록 하는 원하는 기능을 안정적으로 활성화하지 못한 채 버그를 유발하기 때문입니다.

초기 PEP 및 초안 구현 검토 결과, 함수 레벨 locals() 동작의 문서화와 구현을 단순화할 기회가 확인되었습니다. 이는 locals()가 역사적으로 CPython에서 반환했던 반동적이고 간헐적으로 업데이트되는 공유 복사본을 계속 반환하는 대신, 각 호출에서 함수 지역 변수 및 클로저 변수의 독립적인 스냅샷을 반환하도록 업데이트하는 것입니다.

특히, 이 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에서는 exec() 호출과 이어지는 locals() 호출이 프레임 객체에 캐시된 동일한 공유 딕셔너리를 사용하는 대신, 지역 변수의 독립적인 딕셔너리 스냅샷을 사용하므로 두 예제 모두 None을 출력하게 됩니다.

Proposal (제안)

locals() 내장 함수의 예상 의미론은 현재 실행 스코프에 따라 변경됩니다. 이를 위해 정의된 실행 스코프는 다음과 같습니다.

  • 모듈 스코프 (module scope): 최상위 모듈 코드 및 단일 네임스페이스로 exec() 또는 eval()을 사용하여 실행되는 모든 코드.
  • 클래스 스코프 (class scope): 클래스 문 본문의 코드 및 별도의 로컬 및 전역 네임스페이스로 exec() 또는 eval()을 사용하여 실행되는 모든 코드.
  • 함수 스코프 (function scope): def 또는 async def 문 본문의 코드 또는 CPython에서 최적화된 코드 블록을 생성하는 다른 모든 구조 (예: comprehensions, lambda 함수).

이 PEP는 CPython 레퍼런스 구현의 현재 동작 대부분을 언어 사양의 일부로 승격할 것을 제안합니다. 단, 함수 스코프에서 locals()를 호출할 때마다 새로운 딕셔너리 객체를 생성하며, 각 호출이 업데이트하고 반환하는 공통 dict 인스턴스를 프레임 객체에 캐시하지 않습니다.

또한, 이 PEP는 CPython 레퍼런스 구현에서 별도의 “트레이싱(tracing)” 모드 개념을 대부분 제거할 것을 제안합니다. Python 3.10 이하 버전에서는 CPython 인터프리터가 sys.settrace() 또는 C API의 PyEval_SetTrace()와 같은 구현 의존적인 메커니즘을 통해 하나 이상의 스레드에 트레이스 훅이 등록되었을 때 다르게 동작했습니다. 이 PEP가 채택되면, 트레이스 훅이 설치되었을 때 남아있는 유일한 동작 차이는 트레이싱 로직이 각 opcode 후에 실행되어야 할 때 인터프리터의 평가 루프(eval loop)에서 일부 최적화가 비활성화된다는 것입니다.

이 PEP는 함수 스코프에서 CPython의 동작에 대한 변경을 제안하여, 트레이스 훅이 등록되었을 때의 locals() 내장 함수의 의미론을 트레이스 훅이 등록되지 않았을 때와 동일하게 만들고, 관련 프레임 API 의미론을 더 명확하고 상호작용형 디버거가 의존하기 쉽게 만듭니다.

제안된 트레이싱 모드 제거는 traceback이나 sys._getframe() API와 같은 다른 수단을 통해 얻은 프레임 객체 참조의 의미론에 영향을 미칩니다. 트레이스 훅 지원에 필요한 write-through 의미론은 런타임 상태에 의존하지 않고 항상 프레임 객체의 f_locals 속성으로 제공되기 때문입니다.

New locals() documentation (새로운 locals() 문서화)

이 제안의 핵심은 locals() 내장 함수의 문서를 다음과 같이 수정하는 것입니다.

현재 지역 심볼 테이블을 나타내는 매핑 객체를 반환하며, 변수 이름을 키로, 현재 바인딩된 참조를 값으로 가집니다.

모듈 스코프에서, 그리고 단일 네임스페이스로 exec() 또는 eval()을 사용할 때, 이 함수는 globals()와 동일한 네임스페이스를 반환합니다.

클래스 스코프에서는 메타클래스 생성자에 전달될 네임스페이스를 반환합니다.

별도의 로컬 및 전역 네임스페이스로 exec() 또는 eval()을 사용할 때는 함수 호출에 전달된 로컬 네임스페이스를 반환합니다.

위 모든 경우에, 특정 실행 프레임에서 locals()를 호출할 때마다 동일한 매핑 객체를 반환합니다. locals()에서 반환된 매핑 객체를 통해 이루어진 변경 사항은 바인딩, 재바인딩 또는 삭제된 지역 변수로 표시되며, 지역 변수를 바인딩, 재바인딩 또는 삭제하면 반환된 매핑 객체의 내용에 즉시 영향을 미칩니다.

함수 스코프에서 (제너레이터(Generator) 및 코루틴(Coroutine) 포함), locals()를 호출할 때마다 함수의 지역 변수와 모든 비지역(nonlocal) 셀 참조의 현재 바인딩을 포함하는 새로운 딕셔너리를 반환합니다. 이 경우, 반환된 dict를 통해 이루어진 이름 바인딩 변경 사항은 해당 지역 변수 또는 비지역 셀 참조에 다시 기록되지 않으며, 지역 변수 및 비지역 셀 참조의 바인딩, 재바인딩 또는 삭제는 이전에 반환된 딕셔너리의 내용에 영향을 미치지 않습니다.

또한, 이 변경 사항이 적용되는 릴리스에 대한 versionchanged 노트를 포함할 것입니다.

이전 버전에서는 locals()에서 반환된 매핑 객체를 변경하는 의미론이 공식적으로 정의되지 않았습니다. 특히 CPython에서는 함수 스코프에서 반환된 매핑이 locals()를 다시 호출하거나 인터프리터가 Python 레벨 트레이스 함수를 암시적으로 호출하는 등의 다른 작업에 의해 암시적으로 새로 고쳐질 수 있었습니다. 레거시 CPython 동작을 얻으려면 이제 locals()에 대한 후속 호출 결과를 사용하여 초기 반환된 딕셔너리를 업데이트하는 명시적 호출이 필요합니다.

참고로, 이 내장 함수의 현재 문서는 다음과 같습니다.

현재 지역 심볼 테이블을 나타내는 딕셔너리를 업데이트하고 반환합니다. locals()가 함수 블록에서 호출될 때 자유 변수(free variables)가 반환되지만, 클래스 블록에서는 반환되지 않습니다.

주의: 이 딕셔너리의 내용은 수정되어서는 안 됩니다. 변경 사항이 인터프리터가 사용하는 지역 변수 및 자유 변수의 값에 영향을 미치지 않을 수 있습니다.

(즉, 현 상태에서는 locals()의 의미론과 동작이 공식적으로 구현 정의(implementation defined)이며, 이 PEP 이후 제안된 상태에서는 유일하게 구현 정의된 동작은 구현이 CPython 프레임 API를 에뮬레이션하는지 여부와 관련된 것이며, 다른 모든 경우의 동작은 언어 및 라이브러리 참조에 의해 정의됩니다).

Module scope (모듈 스코프)

모듈 스코프에서, 그리고 단일 네임스페이스로 exec() 또는 eval()을 사용할 때, locals()globals()와 동일한 객체를 반환해야 합니다. 이 객체는 실제 실행 네임스페이스여야 합니다 (프레임 객체에 대한 접근을 제공하는 구현에서는 inspect.currentframe().f_locals로 사용 가능).

동일한 스코프에서 후속 코드 실행 중 변수 할당은 반환된 매핑의 내용을 동적으로 변경해야 하며, 반환된 매핑에 대한 변경은 실행 환경에서 지역 변수 이름에 바인딩된 값을 변경해야 합니다.

이러한 기대를 언어 사양의 일부로 포함하기 위해, locals() 문서에 다음 단락이 추가될 것입니다.

모듈 스코프에서, 그리고 단일 네임스페이스로 exec() 또는 eval()을 사용할 때, 이 함수는 globals()와 동일한 네임스페이스를 반환합니다.

이 제안의 이 부분은 레퍼런스 구현에 어떤 변경도 필요하지 않습니다. 이는 현재 동작의 표준화입니다.

Class scope (클래스 스코프)

클래스 스코프에서, 그리고 별도의 전역 및 지역 네임스페이스로 exec() 또는 eval()을 사용할 때, locals()는 지정된 지역 네임스페이스를 반환해야 합니다 (클래스의 경우 메타클래스(metaclass)의 __prepare__ 메서드에 의해 제공될 수 있음). 모듈 스코프와 마찬가지로, 이는 실제 실행 네임스페이스에 대한 직접 참조여야 합니다 (프레임 객체에 대한 접근을 제공하는 구현에서는 inspect.currentframe().f_locals로 사용 가능).

동일한 스코프에서 후속 코드 실행 중 변수 할당은 반환된 매핑의 내용을 변경해야 하며, 반환된 매핑에 대한 변경은 실행 환경에서 지역 변수 이름에 바인딩된 값을 변경해야 합니다.

locals()에 의해 반환된 매핑은 정의된 클래스의 실제 클래스 네임스페이스로 사용되지 않습니다 (클래스 생성 프로세스는 내용을 클래스 메커니즘을 통해서만 접근 가능한 새로운 딕셔너리로 복사합니다).

함수 내부에 정의된 중첩 클래스의 경우, 클래스 스코프에서 참조되는 비지역 셀은 locals() 매핑에 포함되지 않습니다.

이러한 기대를 언어 사양의 일부로 포함하기 위해, locals() 문서에 다음 두 단락이 추가될 것입니다.

별도의 로컬 및 전역 네임스페이스로 exec() 또는 eval()을 사용할 때, [이 함수는] 주어진 로컬 네임스페이스를 반환합니다.

클래스 스코프에서는 메타클래스 생성자에 전달될 네임스페이스를 반환합니다.

이 제안의 이 부분은 레퍼런스 구현에 어떤 변경도 필요하지 않습니다. 이는 현재 동작의 표준화입니다.

Function scope (함수 스코프)

함수 스코프에서는 인터프리터 구현이 지역 변수 접근을 최적화하는 데 상당한 자유가 부여되므로, locals()에서 반환된 매핑을 통해 지역 및 비지역 변수 바인딩을 임의로 수정하는 것을 요구하지 않습니다.

역사적으로 이러한 관용은 언어 사양에 “이 딕셔너리의 내용은 수정되어서는 안 됩니다; 변경 사항이 인터프리터가 사용하는 지역 변수 및 자유 변수의 값에 영향을 미치지 않을 수 있습니다”라는 문구로 설명되어 왔습니다.

이 PEP는 해당 텍스트를 다음과 같이 변경할 것을 제안합니다.

함수 스코프에서 (제너레이터 및 코루틴 포함), locals()를 호출할 때마다 함수의 지역 변수와 모든 비지역 셀 참조의 현재 바인딩을 포함하는 새로운 딕셔너리를 반환합니다. 이 경우, 반환된 dict를 통해 이루어진 이름 바인딩 변경 사항은 해당 지역 변수 또는 비지역 셀 참조에 다시 기록되지 않으며, 지역 변수 및 비지역 셀 참조의 바인딩, 재바인딩 또는 삭제는 이전에 반환된 딕셔너리의 내용에 영향을 미치지 않습니다.

이 제안의 이 부분은 CPython 레퍼런스 구현에 변경이 필요합니다. CPython은 현재 locals()에 대한 추가 호출에 의해 암시적으로 새로 고쳐질 수 있는 공유 매핑 객체를 반환하며, 트레이스 함수로부터의 네임스페이스 변경을 지원하기 위해 현재 사용되는 “write back” 전략도 이를 준수하지 않습니다 (그리고 위 “Motivation”에서 언급된 기묘한 동작 문제를 야기합니다).

CPython Implementation Changes (CPython 구현 변경)

Summary of proposed implementation-specific changes (제안된 구현별 변경 사항 요약)

  • 업데이트된 Python 레벨 의미론을 제공하기 위해 필요한 변경 사항이 적용됩니다.
  • Python locals() 내장 함수의 업데이트된 동작을 복제하기 위해 안정적인 ABI에 두 가지 새로운 함수가 추가됩니다.
    • PyObject * PyLocals_Get();
    • PyLocals_Kind PyLocals_GetKind();
  • 실행 중인 프레임의 지역 네임스페이스 스냅샷을 효율적으로 가져오기 위해 안정적인 ABI에 새로운 함수 하나가 추가됩니다.
    • PyObject * PyLocals_GetCopy();
  • 이러한 새로운 공개 API에 대한 해당 프레임 접근자 함수가 CPython 프레임 C API에 추가됩니다.
  • 최적화된 프레임에서 Python 레벨 f_locals API는 프레임의 지역 및 클로저 변수 저장소에 직접 접근하는 동적으로 생성된 읽기/쓰기 프록시 객체를 반환합니다. 기존 PyEval_GetLocals() API와의 상호 운용성을 제공하기 위해 프록시 객체는 C 레벨 프레임 locals 데이터 저장 필드를 계속 사용하여 임의의 추가 키 저장도 허용하는 값 캐시를 유지합니다. 이러한 빠른 locals 프록시 객체의 예상 동작에 대한 추가 세부 정보는 아래에서 다룹니다.
  • 지역 네임스페이스에 대한 변경 가능한 매핑에 접근하기 위한 C API 함수는 추가되지 않습니다. 대신, Python 코드에서 사용되는 것과 동일한 API인 PyObject_GetAttrString(frame, "f_locals")가 사용됩니다.
  • PyEval_GetLocals()는 계속 지원되며 프로그램적 경고를 발생시키지 않지만, 빌린 참조(borrowed reference)를 반환하지 않는 새로운 API를 선호하여 문서에서 deprecated될 것입니다.
  • PyFrame_FastToLocals()PyFrame_FastToLocalsWithError()는 계속 지원되며 프로그램적 경고를 발생시키지 않지만, 프레임 객체의 내부 데이터 저장 레이아웃에 직접 접근할 필요가 없는 새로운 API를 선호하여 문서에서 deprecated될 것입니다.
  • PyFrame_LocalsToFast()는 항상 RuntimeError()를 발생시키며, 지역 변수에 대한 변경 가능한 읽기/쓰기 매핑을 얻기 위해 PyObject_GetAttrString(frame, "f_locals")를 사용해야 함을 나타냅니다.
  • 트레이스 훅 구현은 더 이상 PyFrame_FastToLocals()를 암시적으로 호출하지 않습니다.
  • 버전 포팅 가이드는 읽기 전용 접근을 위해 PyFrame_GetLocals()로, 읽기/쓰기 접근을 위해 PyObject_GetAttrString(frame, "f_locals")로 마이그레이션할 것을 권장할 것입니다.

Providing the updated Python level semantics (업데이트된 Python 레벨 의미론 제공)

locals() 내장 함수의 구현은 최적화된 프레임에 대해 내부 프레임 값 캐시(cache)에 대한 직접 참조가 아니라, 지역 네임스페이스의 별도 복사본을 반환하도록 수정됩니다. 이 캐시는 PyFrame_FastToLocals() C API에 의해 업데이트되고 PyEval_GetLocals() C API에 의해 반환됩니다.

Resolving the issues with tracing mode behaviour (트레이싱 모드 동작 문제 해결)

CPython의 트레이싱 모드에서 발생하는 기묘한 동작 (트레이싱 함수를 단순히 설치하는 것만으로 발생하는 부작용과 함수 지역 변수에 값을 다시 쓰는 것이 트레이스되는 특정 함수에서만 작동하는 사실)의 현재 원인은 트레이스 훅에 대한 locals 변형 지원이 현재 구현되는 방식, 즉 PyFrame_LocalsToFast 함수 때문입니다.

트레이스 함수가 설치되면, CPython은 현재 함수 프레임 (코드 객체가 “fast locals” 의미론을 사용하는 프레임)에 대해 다음을 수행합니다.

  • PyFrame_FastToLocals를 호출하여 프레임 값 캐시를 업데이트합니다.
  • 트레이스 훅을 호출합니다 (훅 자체의 트레이싱은 비활성화됩니다).
  • PyFrame_LocalsToFast를 호출하여 프레임 값 캐시에 대한 모든 변경 사항을 캡처합니다.

이 접근 방식은 몇 가지 다른 이유로 문제가 됩니다.

  • 트레이스 함수가 값 캐시를 변경하지 않더라도, 마지막 단계는 트레이스 함수가 호출되기 전의 상태로 모든 셀 참조를 재설정합니다 (이것이의 버그 보고서의 근본 원인입니다).
  • 트레이스 함수가 값 캐시를 변경하지만, 이후 값 캐시를 프레임에서 새로 고치게 하는 작업을 수행하면 해당 변경 사항이 손실됩니다 (이것이의 버그 보고서의 한 측면입니다).
  • 트레이스 함수가 트레이스되는 프레임이 아닌 다른 프레임의 지역 변수를 변경하려고 시도하면 (예: frame.f_back.f_locals), 해당 변경 사항은 거의 확실히 손실됩니다 (이것이의 버그 보고서의 또 다른 측면입니다).
  • 프레임 값 캐시 (예: 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()를 사용하는 코드만이 프레임 값 캐시가 오래될(stale) 가능성에 대해 걱정할 필요가 있습니다. 새로운 프레임 fast locals 프록시 API를 사용하는 코드 (Python에서든 C에서든)는 항상 프레임의 라이브 상태를 볼 것입니다.

Fast locals proxy implementation details (Fast locals 프록시 구현 세부 사항)

각 fast locals 프록시 인스턴스는 Python 런타임 API의 일부로 노출되지 않는 하나의 내부 속성을 가집니다.

  • frame: 프록시가 접근을 제공하는 기본 최적화된 프레임.

또한, 프록시 인스턴스는 기본 프레임 또는 코드 객체에 저장된 다음 속성들을 사용하고 업데이트합니다.

  • _name_to_offset_mapping: 변수 이름에서 fast local 저장 오프셋으로의 숨겨진 매핑. 이 매핑은 첫 번째 fast locals 프록시가 생성되자마자 즉시 채워지는 대신, fast locals 프록시를 통한 첫 번째 프레임 읽기 또는 쓰기 접근 시 지연 초기화됩니다. 이 매핑은 주어진 코드 객체를 실행하는 모든 프레임에 대해 동일하므로, 각 프레임 객체가 자체 매핑을 채우는 대신 단일 복사본이 코드 객체에 저장됩니다.
  • _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() 결과에서 생략되는 것과 동일).

프레임 저장소는 항상 직접 접근되므로, 함수가 실행됨에 따라 발생하는 이름 바인딩 및 언바인딩 연산을 프록시가 자동으로 반영합니다. 개별 변수가 프레임 상태에서 읽힐 때 내부 값 캐시가 암시적으로 업데이트됩니다 (이름이 현재 바인딩되었는지 또는 언바인딩되었는지 확인해야 하는 포함(containment) 검사 포함).

마찬가지로, 프록시의 __setitem____delitem__ 연산은 기본 프레임의 해당 fast local 또는 셀 참조에 직접 영향을 미쳐, 변경 사항이 실행 중인 Python 코드에 즉시 표시되도록 합니다. 이러한 변경 사항은 PyEval_GetLocals() C API 사용자에게 표시되도록 내부 프레임 값 캐시에도 즉시 기록됩니다.

기본 프레임에 지역 또는 클로저(closure) 변수로 정의되지 않은 키는 최적화된 프레임의 내부 값 캐시에 계속 기록됩니다. 이는 pdb와 같은 유틸리티 (프레임의 f_locals 매핑에 __return____exception__ 값을 쓰는)가 항상 작동했던 것처럼 계속 작동하도록 허용합니다. 프레임의 지역 또는 클로저 변수에 해당하지 않는 이러한 추가 키는 향후 캐시 동기화 작업에서 영향을 받지 않습니다. 이러한 추가 키를 저장하기 위해 프레임 값 캐시를 사용하는 것은 기존 PyEval_GetLocals() API와의 완전한 상호 운용성을 제공합니다 (새로운 fast locals 프록시 API 사용자가 해당 API를 통해 추가된 키만 보는 대신, 두 API 사용자 모두 서로 추가된 추가 키를 볼 수 있기 때문입니다).

프레임에 변수 값 캐시만 저장하는 것의 추가적인 이점은 프레임에서 자신으로 다시 참조 사이클을 생성하는 것을 피할 수 있다는 것입니다. 따라서 프레임은 다른 객체가 프록시 인스턴스에 대한 참조를 유지할 경우에만 유지됩니다.

참고: proxy.clear() 메서드를 호출하는 것은 이전 버전에서 빈 프레임 값 캐시에 대해 PyFrame_LocalsToFast()를 호출하는 것과 유사하게 광범위한 영향을 미칩니다. 프레임 지역 변수뿐만 아니라 프레임에서 접근 가능한 모든 셀 변수 (해당 셀이 프레임 자체에 속하든 외부 프레임에 속하든)도 지워집니다. 이는 zero-arg super() 생성자를 사용하거나 __class__를 참조하는 메서드의 프레임에서 호출될 경우 클래스의 __class__ 셀을 지울 수 있습니다. 이는 frame.clear()를 호출하는 범위를 초과합니다. frame.clear()는 단순히 프레임의 셀 변수에 대한 참조를 삭제할 뿐 셀 자체를 지우지는 않습니다. 이 PEP는 외부 프레임에 속하는 셀을 그대로 두고, 프록시의 기본 프레임에 직접 속하는 지역 변수 및 셀만 지움으로써 프레임 변수를 직접 지우려는 시도의 범위를 좁힐 수 있는 잠재적인 기회가 될 수 있습니다 (이 문제는 PEP 667에도 영향을 미칩니다. 질문이 셀 변수 처리에 관한 것이며, 내부 프레임 값 캐시와는 완전히 독립적이기 때문입니다).

Changes to the stable C API/ABI (안정적인 C API/ABI 변경 사항)

Python 코드와 달리, Python C API를 호출하는 확장 모듈(extension module) 함수는 어떤 종류의 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 프레임의 지역 네임스페이스 매핑에 대한 새로운 참조를 반환합니다. 함수/코루틴/제너레이터 스코프에서는 활성 네임스페이스의 얕은 복사본(shallow copy)을 반환합니다.

PyLocals_GetKind()는 새로 정의된 PyLocals_Kind 열거형(enum)에서 값을 반환하며, 다음 옵션이 사용 가능합니다.

  • PyLocals_DIRECT_REFERENCE: PyLocals_Get()은 실행 중인 프레임의 지역 네임스페이스에 대한 직접 참조를 반환합니다.
  • PyLocals_SHALLOW_COPY: PyLocals_Get()은 실행 중인 프레임의 지역 네임스페이스의 얕은 복사본을 반환합니다.
  • PyLocals_UNDEFINED: 오류가 발생했습니다 (예: 활성 Python 스레드 상태 없음). 이 값이 반환되면 Python 예외가 설정됩니다.

열거형이 안정적인 ABI에서 사용되므로, 임의의 부호 있는 32비트 정수를 PyLocals_Kind 값으로 안전하게 형변환할 수 있도록 추가 31비트 값이 설정됩니다.

이 쿼리 API를 통해 확장 모듈 코드는 실행 중인 프레임 객체의 세부 사항에 접근할 필요 없이 PyLocals_Get()이 반환한 매핑을 변경할 경우의 잠재적 영향을 판단할 수 있습니다. Python 코드는 어휘 스코핑(lexical scoping)을 통해 시각적으로 동등한 정보를 얻습니다 (새로운 locals() 내장 함수 문서에 설명된 대로).

확장 모듈 코드가 활성 Python 스코프와 관계없이 일관되게 동작하도록 하기 위해 안정적인 C ABI는 다음 새로운 함수를 얻게 될 것입니다.

  • PyObject * PyLocals_GetCopy();

PyLocals_GetCopy()는 현재 지역 네임스페이스에서 채워진 새로운 dict 인스턴스를 반환합니다. Python 코드의 dict(locals())와 대략적으로 동일하지만, locals()가 이미 얕은 복사본을 반환하는 경우 이중 복사(double-copy)를 방지합니다. 다음 코드와 유사하지만, locals() 결과가 두 종류만 있다고 가정하지는 않습니다.

locals = PyLocals_Get();
if (PyLocals_GetKind() == PyLocals_DIRECT_REFERENCE) {
    locals = PyDict_Copy(locals);
}

기존 PyEval_GetLocals() API는 CPython에서 기존 동작을 유지합니다 (클래스 및 모듈 스코프에서는 변경 가능한 locals, 그렇지 않으면 공유 동적 스냅샷). 그러나 그 문서에는 공유 동적 스냅샷이 업데이트되는 조건이 변경되었음을 명시하도록 업데이트될 것입니다.

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"))를 사용합니다.

Changes to the public CPython C API (공개 CPython C API 변경 사항)

기존 PyEval_GetLocals() API는 빌린 참조(borrowed reference)를 반환하므로, 함수 스코프에서 새로운 얕은 복사본을 반환하도록 직접 변경할 수 없습니다. 대신, 프레임 객체에 저장된 내부 동적 스냅샷에 대한 빌린 참조를 계속 반환할 것입니다. 이 공유 매핑은 Python 3.10 이하의 기존 공유 매핑과 유사하게 동작하지만, 새로 고침되는 정확한 조건은 다를 것입니다. 특히, 다음 상황에서만 업데이트됩니다.

  • 프레임이 실행 중일 때 PyEval_GetLocals(), PyLocals_Get(), PyLocals_GetCopy(), 또는 Python locals() 내장 함수에 대한 모든 호출.
  • 해당 프레임에 대한 PyFrame_GetLocals(), PyFrame_GetLocalsCopy(), _PyFrame_BorrowLocals(), PyFrame_FastToLocals(), 또는 PyFrame_FastToLocalsWithError()에 대한 모든 호출.
  • 구현의 일부로 공유 매핑을 업데이트하는 fast locals 프록시 객체에 대한 모든 연산. 초기 레퍼런스 구현에서는 이러한 연산이 본질적으로 O(n) 연산 ( len(flp), 매핑 비교, flp.copy(), 문자열로 렌더링)뿐만 아니라 개별 키에 대한 캐시 항목을 새로 고치는 연산입니다.

fast locals 프록시를 요청해도 공유 동적 스냅샷이 암시적으로 업데이트되지 않으며, 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() 구현에서 프레임 구조의 내부(internals)에 접근할 필요를 피하기 위해 문서화되어 있으며 링커(linker)에 표시됩니다.

PyFrame_LocalsToFast() 함수는 항상 RuntimeError를 발생시키도록 변경될 것이며, 더 이상 지원되지 않는 연산임을 설명하고, 영향을 받는 코드는 대신 읽기/쓰기 프록시를 얻기 위해 PyObject_GetAttrString(frame, "f_locals")를 사용하도록 업데이트되어야 합니다.

위에서 문서화된 인터페이스 외에도, 초안 레퍼런스 구현은 다음 문서화되지 않은 인터페이스도 노출합니다.

PyTypeObject _PyFastLocalsProxy_Type;
#define _PyFastLocalsProxy_CheckExact(self) Py_IS_TYPE(op, &_PyFastLocalsProxy_Type)

이 타입은 레퍼런스 구현이 최적화된 프레임에 대해 PyObject_GetAttrString(frame, "f_locals")에서 실제로 반환하는 것입니다 (즉, PyFrame_GetLocalsKind()PyLocals_SHALLOW_COPY를 반환할 때).

Reducing the runtime overhead of trace hooks (트레이스 훅의 런타임 오버헤드 감소)

에서 언급했듯이, Python 트레이스 훅 지원에서 PyFrame_FastToLocals()에 대한 암시적 호출은 비용이 발생하며, 프레임 프록시가 매핑에서 값을 가져오는 대신 프레임에서 직접 값을 읽는다면 불필요하게 될 수 있습니다.

새로운 프레임 locals 프록시 타입은 별도의 데이터 새로 고침 단계가 필요 없으므로, 이 PEP는 Python으로 구현된 트레이스 훅을 호출하기 전에 PyFrame_FastToLocalsWithError()를 더 이상 암시적으로 호출하지 않도록 하는 Victor Stinner의 제안을 통합합니다.

새로운 fast locals 프록시 객체를 사용하는 코드는 필요한 메서드에 접근할 때 동적 locals 스냅샷이 암시적으로 새로 고쳐지며, PyEval_GetLocals() API를 사용하는 코드는 해당 호출을 할 때 암시적으로 새로 고쳐집니다.

이 PEP는 트레이스 훅에서 반환할 때 PyFrame_LocalsToFast()에 대한 암시적 호출도 반드시 삭제합니다. 해당 API는 이제 항상 예외를 발생시키기 때문입니다.

Rationale and Design Discussion (근거 및 설계 논의)

Changing locals() to return independent snapshots at function scope (함수 스코프에서 locals()가 독립적인 스냅샷을 반환하도록 변경)

locals() 내장 함수는 언어의 필수적인 부분이며, 레퍼런스 구현에서 역사적으로 다음과 같은 특성을 가진 변경 가능한 매핑을 반환했습니다.

  • locals()에 대한 각 호출은 동일한 매핑 객체를 반환합니다.
  • locals()가 실제 지역 실행 네임스페이스가 아닌 다른 것에 대한 참조를 반환하는 네임스페이스의 경우, locals()에 대한 각 호출은 지역 변수 및 참조된 비지역 셀의 현재 상태로 매핑 객체를 업데이트합니다.
  • 반환된 매핑에 대한 변경은 일반적으로 지역 변수 바인딩 또는 비지역 셀 참조에 다시 기록되지 않지만, 다음 중 하나를 수행하여 다시 기록을 트리거할 수 있습니다.
    • Python 레벨 트레이스 훅 설치 (트레이스 훅이 호출될 때마다 다시 기록이 발생).
    • 함수 레벨 와일드카드 import 실행 (Py3에서 바이트코드 삽입 필요).
    • 함수의 스코프에서 exec 문 실행 (Python 3에서 exec가 일반적인 내장 함수가 된 이후 Py2에서만 해당).

원래 이 PEP는 위의 두 가지 속성을 유지하면서 세 번째 속성을 변경하여 야기할 수 있는 직접적인 동작 버그를 해결할 것을 제안했습니다.

에서 Nathaniel Smith는 함수 스코프에서 locals()의 동작을 두 번째 속성만 유지하고 함수 스코프에서 locals()에 대한 각 호출이 암시적으로 공유된 스냅샷을 업데이트하는 대신 지역 변수 및 클로저 참조의 독립적인 스냅샷을 반환하도록 함으로써 훨씬 덜 혼란스럽게 만들 수 있다는 설득력 있는 주장을 했습니다.

이 수정된 설계는 구현을 훨씬 더 쉽게 따르게 했으므로, 이 PEP는 역사적인 공유 스냅샷을 유지하는 대신 이러한 동작 변경을 제안하도록 업데이트되었습니다.

Keeping locals() as a snapshot at function scope (함수 스코프에서 locals()를 스냅샷으로 유지)

에서 논의된 바와 같이, locals() 내장 함수의 의미론을 변경하여 독립적인 스냅샷을 반환하도록 전환하는 대신, 함수 스코프에서 write-through 프록시를 반환하도록 이론적으로 가능합니다.

이 PEP는 이를 제안하지 않으며 (제안하지 않을 것입니다), 이는 현재 동작에 의존하는 코드가 기술적으로 언어 사양의 미정의 영역에서 작동하고 있음에도 불구하고 실제로는 하위 호환성(backwards incompatible)을 깨뜨리는 변경이기 때문입니다.

다음 코드 스니펫을 고려해 보세요.

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() 내장 함수에 (명시적으로든 암시적으로든) 전달될 수 있으며, 지역 변수 또는 클로저 참조의 예상치 못한 재바인딩 위험 없이 사용할 수 있습니다.

레퍼런스 인터프리터가 지역 변수 상태를 잘못 변경하도록 유도하려면 중첩 함수가 외부 함수에서 재바인딩되는 변수를 클로저로 캡처하고, 스레드, 제너레이터 또는 코루틴의 사용으로 인해 외부 함수의 재바인딩 연산 전에 중첩 함수에 대한 트레이스 함수가 실행되기 시작했지만, 재바인딩 연산이 발생한 후에 실행이 완료될 수 있는 더 복잡한 설정이 필요합니다 (이 경우 재바인딩이 되돌려지며, 이것이에 보고된 버그입니다).

Python 2.1에서 중첩 스코프를 도입한 PEP 227 이후로 존재해 온 사실상의 의미론을 보존하는 것 외에도, write-through 프록시 지원을 구현 정의된 프레임 객체 API로 제한하는 또 다른 이점은 전체 프레임 API를 에뮬레이션하는 인터프리터 구현만이 write-through 기능을 제공해야 하며, JIT 컴파일된 구현은 프레임 인트로스펙션(introspection) API가 호출되거나 트레이스 훅이 설치될 때만 이를 활성화하면 된다는 것입니다. 즉, 함수 스코프에서 locals()에 접근할 때마다 활성화할 필요가 없습니다.

함수 스코프에서 locals()가 스냅샷을 반환한다는 것은 함수 레벨 코드에 대한 정적 분석(static analysis)이 더 신뢰할 수 있다는 것을 의미합니다. 프레임 메커니즘에 대한 접근만이 정적 분석에서 숨겨진 방식으로 지역 및 비지역 변수 참조를 재바인딩할 수 있도록 하기 때문입니다.

What happens with the default args for eval() and exec() ? (eval()exec()의 기본 인수는 어떻게 되는가?)

이들은 기본적으로 호출 스코프에서 globals()locals()를 상속하도록 공식적으로 정의되어 있습니다.

PEP가 이러한 기본값을 변경할 필요가 없으므로 변경하지 않으며, exec()eval()locals()가 반환하는 것이 얕은 복사본일 때 지역 네임스페이스의 얕은 복사본에서 실행되기 시작합니다.

이러한 동작은 잠재적인 성능 영향을 미칠 수 있습니다. 특히 많은 수의 지역 변수를 가진 함수 (예: 이러한 함수가 루프에서 호출될 경우, 루프 전에 globals()locals()를 한 번 호출하고 네임스페이스를 함수에 명시적으로 전달하면 현 상태와 동일한 의미론 및 성능 특성을 제공하지만, 암시적인 기본값에 의존하면 각 반복마다 지역 네임스페이스의 새로운 얕은 복사본을 생성합니다).

(참고: 레퍼런스 구현 초안 PR은 locals()vars(), eval(), exec() 내장 함수를 PyLocals_Get()을 사용하도록 업데이트했습니다. dir() 내장 함수는 여전히 PyEval_GetLocals()를 사용합니다. 이는 키에서 목록을 만드는 데만 사용되기 때문입니다).

Additional considerations for eval() and exec() in optimized scopes (최적화된 스코프에서 eval()exec()에 대한 추가 고려 사항)

참고: PEP 667을 구현하는 동안, locals() 변경 사항이 exec()eval()과 같은 코드 실행 API에 미칠 영향을 두 PEP 모두에서 명확하게 설명하지 않았다는 점이 지적되었습니다. 이 섹션은 변경의 영향과 의도된 이점을 더 잘 설명하기 위해 이 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_globalsframe.f_locals 속성을 사용하는 것과 동일했지만, 이 PEP는 최적화된 스코프에서 지역 네임스페이스에 대한 쓰기 시도를 무시하는 기본 속성을 보존하기 위해 exec()eval()의 기본 네임스페이스 인수를 호출하는 프레임의 globals()locals()에 매핑합니다.

이것은 일부 코드에 잠재적인 호환성 문제를 제기합니다. 이전 구현에서는 함수 스코프에서 locals()가 여러 번 호출될 때 동일한 dict를 반환했기 때문에 다음 코드는 암시적으로 공유된 지역 변수 네임스페이스로 인해 일반적으로 작동했습니다.

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()가 각 호출에 대해 동일한 공유 dict를 반환할 때, 그 dict에 추가 “가짜 지역 변수(fake 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()

print(a)None을 출력할 것입니다. exec()의 암시적 locals() 호출이 캐시된 dict를 프레임의 실제 값으로 새로 고치기 때문입니다. 이는 locals()에 다시 쓰기 (이전 exec() 호출을 통해 포함)로 생성된 “가짜” 지역 변수와 달리, 컴파일러가 아는 실제 지역 변수는 exec()로 쉽게 수정될 수 없음을 의미합니다 (가능하지만, 프레임에 다시 쓰기를 가능하게 하려면 frame.f_locals 속성을 검색하고, ctypes를 사용하여 위에서 보았듯이 PyFrame_LocalsToFast()를 호출해야 합니다).

“Motivation” 섹션에서 언급했듯이, 이 혼란스러운 부작용은 지역 변수가 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 변경을 위해 프레임이 준비되었는지 여부)에 따라 달라집니다.

위에서 설명한 바와 같이, 이 혼란스러운 동작을 대체하기 위해 두 가지 옵션이 고려되었습니다.

  • locals()가 write-through 프록시 인스턴스를 반환하도록 만듭니다 ( frame.f_locals와 유사).
  • locals()가 진정으로 독립적인 스냅샷을 반환하도록 하여, exec()를 통해 지역 변수 값을 변경하려는 시도가 위에서 언급된 어떤 주의 사항도 없이 일관되게 무시되도록 합니다.

PEP는 다음 이유로 두 번째 옵션을 선택합니다.

  • 최적화된 스코프에서 독립적인 스냅샷을 반환하는 것은 대부분의 경우 exec()를 통해 지역 변수를 변경하려는 시도가 무시되도록 한 Python 3.0의 exec() 변경 사항을 보존합니다.
  • locals()는 최적화된 스코프에서 지역 변수의 즉각적인 스냅샷을 제공하고, 다른 스코프에서는 읽기/쓰기 접근을 제공한다”는 것과 “frame.f_locals는 최적화된 스코프를 포함한 모든 스코프에서 지역 변수에 대한 읽기/쓰기 접근을 제공한다”는 구분은 두 API 모두 최적화된 스코프에서 완전한 읽기/쓰기 접근 권한을 부여하는 경우보다 코드의 의도를 더 명확하게 할 수 있습니다. 읽기 접근이 필요하거나 원치 않을 때도 마찬가지입니다.
  • 인간 독자를 위한 명확성을 향상시키는 것 외에도, 최적화된 스코프에서 이름 재바인딩이 코드에서 어휘적으로 표시되도록 보장하는 것 (프레임 인트로스펙션 API에 접근하지 않는 한)은 컴파일러와 인터프리터가 관련 성능 최적화를 더 일관되게 적용할 수 있도록 합니다.
  • 선택적 프레임 인트로스펙션 API를 지원하는 Python 구현만이 최적화된 프레임에 대한 새로운 write-through 프록시 지원을 제공해야 합니다.

이 PEP의 locals()에 대한 의미론적 변경 사항을 통해 exec()eval()의 동작을 훨씬 쉽게 설명할 수 있습니다. 최적화된 스코프에서는 암시적으로 지역 변수에 영향을 미치지 않으며, 다른 스코프에서는 항상 암시적으로 지역 변수에 영향을 미칩니다. 최적화된 스코프에서는 코드 실행 API가 반환될 때마다 지역 변수의 새로운 복사본이 사용되므로, 지역 변수에 대한 모든 암시적 할당은 폐기됩니다.

Retaining the internal frame value cache (내부 프레임 값 캐시 유지)

내부 프레임 값 캐시를 유지하면 이름 바인딩 및 언바인딩 연산이 프레임에서 실행된 후 프레임 프록시 인스턴스가 유지되고 재사용될 때 일부 눈에 띄는 기묘한 동작이 발생합니다.

프레임 값 캐시를 유지하는 주된 이유는 PyEval_GetLocals() API와의 하위 호환성을 유지하기 위함입니다. 해당 API는 빌린 참조를 반환하므로 프레임 객체에 저장된 영구 상태를 참조해야 합니다. 프레임에 fast locals 프록시 객체를 저장하면 문제가 되는 참조 사이클이 생성되므로, 가장 깔끔한 옵션은 최적화된 프레임이 처음 도입된 이래로 이 함수가 해왔던 것처럼 프레임 값 캐시를 계속 반환하는 것입니다.

프레임 값 캐시가 어쨌든 유지되므로, 이를 활용하여 fast locals 프록시 매핑 구현을 단순화하는 것이 합리적이었습니다.

참고: PEP 667이 write-through 프록시 구현의 일부로 내부 프레임 값 캐시를 사용하지 않는다는 사실은 두 PEP 간의 핵심 Python 레벨 차이점입니다.

Changing the frame API semantics in regular operation (일반적인 작업에서 프레임 API 의미론 변경)

참고: 이 PEP가 처음 작성될 때, 트레이싱 함수가 설치될 때마다 프레임 지역 변수의 암시적 writeback을 삭제하는 Python 3.11 변경 사항 이전에 작성되었으므로, 해당 변경 사항이 제안의 일부로 포함되었습니다.

이 PEP의 초기 버전은 프레임 f_locals 속성의 의미론이 트레이싱 훅이 현재 설치되어 있는지 여부에 따라 달라지도록 제안했습니다. 즉, 트레이싱 훅이 활성화되었을 때만 write-through 프록시 동작을 제공하고, 그렇지 않으면 역사적인 locals() 내장 함수와 동일하게 동작하는 방식이었습니다.

이것은 몇 가지 주요 이유로 원래 설계 제안으로 채택되었습니다. 하나는 실용적이고 다른 하나는 철학적이었습니다.

  • 객체 할당과 메서드 래퍼(wrapper)는 무료가 아니며, 트레이싱 함수만이 함수 외부에서 프레임 locals에 접근하는 유일한 연산이 아닙니다. 변경 사항을 트레이싱 모드로 제한한다는 것은 이러한 변경 사항의 추가 메모리 및 실행 시간 오버헤드를 일반적인 작업에서 가능한 한 0에 가깝게 만들 수 있음을 의미했습니다.
  • “고장 나지 않은 것을 변경하지 마십시오”: 현재 트레이싱 모드 문제는 트레이싱 모드에 특정한 요구 사항 (함수 지역 변수 참조의 외부 재바인딩 지원)으로 인해 발생하므로, 관련 수정 사항도 트레이싱 모드로 제한하는 것이 합리적이었습니다.

그러나 이러한 동적 접근 방식을 실제로 구현하고 문서화하려고 시도하면서, frame.f_locals가 작동하는 방식에 있어서 정말 미묘한 런타임 상태 의존적 동작 구분을 만들고, 트레이스 함수가 추가되거나 제거될 때 f_locals가 동작하는 방식과 관련된 몇 가지 새로운 엣지 케이스를 생성한다는 사실이 드러났습니다.

따라서 설계는 현재의 방식으로 변경되었습니다. frame.f_locals는 항상 write-through 프록시이고, locals()는 항상 스냅샷입니다. 이는 구현하기 더 간단하고 설명하기 더 쉽습니다.

CPython 레퍼런스 구현이 이를 어떻게 처리하든 상관없이, 최적화 컴파일러와 인터프리터는 디버거에 추가적인 제한을 가할 수 있습니다. 예를 들어, 프레임 객체를 통한 지역 변수 변경을 일부 최적화를 비활성화할 수 있는 옵트인(opt-in) 동작으로 만들 수 있습니다 (CPython의 프레임 API 에뮬레이션이 일부 Python 구현에서 이미 옵트인 플래그인 것처럼).

Continuing to support storing additional data on optimised frames (최적화된 프레임에 추가 데이터 저장 계속 지원)

이 PEP의 초안 반복 중 하나는 기본 프레임에 지역 또는 클로저 변수 이름에 해당하지 않는 frame.f_locals 키에 쓰기 방식으로 최적화된 프레임에 추가 데이터를 저장하는 기능을 제거할 것을 제안했습니다.

이 아이디어는 fast locals 프록시 구현을 일부 매력적으로 단순화했지만, pdb는 임의의 프레임에 __return____exception__ 값을 저장하므로, 해당 기능이 더 이상 작동하지 않으면 표준 라이브러리 테스트 스위트가 실패합니다.

따라서 임의의 키를 저장하는 기능은 유지되었으며, 프록시 객체의 특정 연산이 그렇지 않은 경우보다 느려지는 대가를 치렀습니다 (코드 객체에 정의된 이름만 프록시를 통해 접근 가능하다고 가정할 수 없기 때문입니다).

fast locals 프록시와 기본 프레임의 f_locals 값 캐시 간의 상호 작용에 대한 정확한 세부 사항은 개선 기회가 식별됨에 따라 시간이 지남에 따라 진화할 것으로 예상됩니다.

Historical semantics at function scope (함수 스코프에서의 역사적 의미론)

CPython에서 locals()frame.f_locals를 변경하는 현재 의미론은 역사적 구현 세부 사항으로 인해 다소 기묘합니다.

  • 실제 실행은 지역 변수 바인딩에 fast locals 배열을 사용하고 비지역 변수에 셀 참조(cell references)를 사용합니다.
  • PyFrame_FastToLocals 연산은 fast locals 배열 및 참조된 모든 셀의 현재 상태를 기반으로 프레임의 f_locals 속성을 채웁니다. 이는 세 가지 이유로 존재합니다.
    • 트레이스 함수가 지역 변수의 상태를 읽을 수 있도록 허용.
    • 트레이스백(traceback) 처리기가 지역 변수의 상태를 읽을 수 있도록 허용.
    • locals()가 지역 변수의 상태를 읽을 수 있도록 허용.
  • locals()에서 frame.f_locals에 대한 직접 참조가 반환되므로, 여러 동시 참조를 전달하면 모든 참조가 정확히 동일한 딕셔너리를 가리킵니다.
  • 역방향 연산인 PyFrame_LocalsToFast에 대한 두 가지 일반적인 호출은 Python 3로의 마이그레이션에서 제거되었습니다. exec는 더 이상 문이 아니며 (따라서 함수 지역 네임스페이스에 더 이상 영향을 미칠 수 없음), 컴파일러는 이제 함수 스코프에서 from module import * 연산 사용을 허용하지 않습니다.
  • 그러나 두 가지 모호한 호출 경로가 남아 있습니다. PyFrame_LocalsToFast는 트레이스 함수에서 반환될 때 호출되며 (디버거가 지역 변수 상태를 변경할 수 있도록 함), 컴파일러를 통하지 않고 코드 객체에서 직접 함수를 생성할 때 IMPORT_STAR opcode를 여전히 주입할 수 있습니다.

이 제안은 이러한 의미론을 그대로 공식화하지 않습니다. 의도적으로 설계된 것이 아니라 언어와 레퍼런스 구현의 역사적 진화 측면에서만 의미가 있기 때문입니다.

Proposing several additions to the stable C API/ABI (안정적인 C API/ABI에 여러 추가 제안)

역사적으로 CPython C API (및 이후 안정적인 ABI)는 Python locals 내장 함수와 관련된 단일 API 함수인 PyEval_GetLocals()만 노출했습니다. 그러나 이는 빌린 참조를 반환하므로, 이 PEP에서 제안된 새로운 locals() 의미론을 직접 지원하도록 이 인터페이스를 조정하는 것은 불가능합니다.

이 PEP의 초기 버전은 새로운 의미론에 대한 최소한의 적응을 제안했습니다. Python locals() 내장 함수처럼 동작하는 하나의 C API 함수와 frame.f_locals 디스크립터처럼 동작하는 다른 C API 함수 (필요한 경우 write-through 프록시를 생성하고 반환)였습니다.

해당 C API 버전에 대한 피드백은 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 구현에 이러한 기능을 제공하는 방법에 대한 더 많은 유연성을 제공합니다.

Comparison with PEP 667 (PEP 667과의 비교)

참고: 아래 비교는 2021년 12월 당시의 PEP 667과의 비교입니다. 2024년 4월 (이 PEP가 PEP 667 진행을 지지하며 철회되었을 때)의 PEP 667 상태를 반영하지 않습니다.

PEP 667은 최적화된 프레임에서 내부 프레임 값 캐시를 완전히 제거하는 것이 합리적이라고 제안하는 부분적으로 경쟁적인 제안을 제시했습니다.

이러한 변경 사항은 원래 PEP 558에 대한 수정 사항으로 제안되었으며, PEP 작성자는 세 가지 주요 이유로 이를 거부했습니다.

  • PyEval_GetLocals()가 빌린 참조를 반환하기 때문에 고칠 수 없다는 초기 주장은 단순히 거짓이었습니다. PEP 558 레퍼런스 구현에서 여전히 작동하기 때문입니다. 이를 계속 작동시키기 위해 필요한 것은 내부 프레임 값 캐시를 유지하고, 캐시가 필요하지 않을 때 상당한 런타임 오버헤드를 발생시키지 않고 프레임 상태 변경으로 캐시를 최신 상태로 유지하는 것이 합리적으로 간단하도록 fast locals 프록시를 설계하는 것입니다. 이 주장이 거짓임을 감안할 때, PyEval_GetLocals() API를 사용하는 모든 코드를 다른 참조 카운팅 의미론을 가진 새 API를 사용하도록 다시 작성하도록 요구하는 제안은 API 호환성 파괴가 큰 이점을 가져야 한다는 PEP 387의 요구 사항을 충족하지 못합니다 (캐시를 삭제함으로써 얻는 상당한 이점이 없으므로 코드 파괴는 정당화될 수 없습니다). 진정으로 고칠 수 없는 유일한 공개 API는 PyFrame_LocalsToFast()입니다 (그래서 두 PEP 모두 이를 파괴할 것을 제안합니다).
  • 어떤 형태의 내부 값 캐시도 없으면 fast locals 프록시 매핑의 API 성능 특성이 상당히 직관적이지 않게 됩니다. 예를 들어, len(proxy)는 프레임에 정의된 변수 수에 대해 일관되게 O(n)이 됩니다. 프록시는 현재 어떤 이름이 값에 바인딩되었는지 확인하기 위해 전체 fast locals 배열을 반복해야 답을 결정할 수 있기 때문입니다. 대조적으로, 내부 프레임 값 캐시를 유지하면 프록시를 알고리즘 복잡성 관점에서 주로 일반 딕셔너리로 취급할 수 있으며, 캐시가 최신 상태여야 하는 연산이 처음 실행될 때 발생하는 초기 암시적 O(n) 캐시 새로 고침에 대해서만 허용하면 됩니다.
  • 캐시 없는 구현이 더 간단할 것이라는 주장은 매우 의심스럽습니다. PEP 667은 최적화된 프레임에 대한 기본 데이터 저장소와 통합된 새로운 매핑 타입의 완전한 C 구현이 아니라, 변경 가능한 매핑 구현의 부분 집합에 대한 순수 Python 스케치만을 포함하기 때문입니다. PEP 558의 fast locals 프록시 구현은 변경 가능한 매핑 API를 완전히 구현하는 데 필요한 연산을 위해 프레임 값 캐시에 크게 위임하여, 다음 연산의 기존 dict 구현을 재사용할 수 있도록 합니다.
    • __len__
    • __str__
    • __or__ (dict union)
    • __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은 여전히 불필요한 C API 파괴 ( PyEval_GetLocals(), PyFrame_FastToLocalsWithError(), PyFrame_FastToLocals()의 프로그램적 deprecated 및 최종 제거)를 정당성 없이 제안합니다. 적절하게 설계된 fast locals 프록시 구현이 주어진다면 이들을 무기한 (그리고 상호 운용적으로) 계속 작동시키는 것이 완전히 가능하기 때문입니다.
  • 추가 변수에 대한 fast locals 프록시 처리는 기존 PyEval_GetLocals() API와 완전히 상호 운용되도록 이 PEP에서 정의됩니다. PEP 667에서 제안된 프록시 구현에서는 새로운 프레임 API 사용자는 이전 API 사용자가 추가 변수에 대해 변경한 내용을 볼 수 없으며, 이전 API를 통해 추가 변수에 대해 변경된 내용은 PyEval_GetLocals()에 대한 후속 호출에서 덮어쓰여집니다.
  • 이 PEP의 PyLocals_Get() API는 PEP 667에서 PyEval_Locals()라고 불립니다. 이 함수 이름은 동사가 없어서 데이터 접근 API라기보다는 타입 이름처럼 보여 다소 이상합니다.
  • 이 PEP는 PyLocals_GetCopy()PyFrame_GetLocalsCopy() API를 추가하여 확장 모듈이 PyLocals_Get()이 이미 복사본을 만드는 프레임에서 이중 복사 연산을 쉽게 피할 수 있도록 합니다.
  • 이 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 의사 코드(pseudo-code)는 작성 시점 (2021-10-24)의 PEP 667에 제시된 구현 스케치를 기반으로 합니다. 새로운 fast locals 프록시 API와 기존 PyEval_GetLocals() API 간의 개선된 상호 운용성을 제공하는 차이점은 주석으로 표시되어 있습니다.

PEP 667과 마찬가지로, 밑줄로 시작하는 모든 속성은 보이지 않으며 직접 접근할 수 없습니다. 이는 제안된 설계를 설명하기 위한 용도로만 사용됩니다.

단순화를 위해 (PEP 667과 마찬가지로), 모듈 및 클래스 레벨 프레임 처리는 생략되었습니다 (이들은 _locals가 실행 네임스페이스이므로 훨씬 간단하며, 번역이 필요하지 않습니다).

NULL: Object # NULL은 값의 부재를 나타내는 싱글톤입니다.
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):
        ... # 인터프리터가 셀을 식별하는 방법은 구현 세부 사항입니다.
    @property
    def _name_to_offset_mapping(self):
        "이름에서 지역 변수 배열의 오프셋으로의 매핑."
        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] # 지역 변수의 값, 항목은 NULL일 수 있습니다.
    _locals: dict | NULL # 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() 상호 운용성: 암시적 프레임 캐시 새로 고침
            self._set_locals_entry(name, val)
            return val
        # PyEval_GetLocals() 상호 운용성: 프레임 캐시는 추가 이름을 포함할 수 있습니다.
        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() 상호 운용성: 암시적 프레임 캐시 업데이트
            # fast locals 배열의 일부인 이름에 대해서도 마찬가지입니다.
            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() 상호 운용성: 암시적 프레임 캐시 업데이트
            # fast locals 배열의 일부인 이름에 대해서도 마찬가지입니다.
            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:
            # 프레임에 정의되지 않은 추가 이름을 반환합니다.
            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() 상호 운용성: 암시적 프레임 캐시 업데이트
            # fast locals 배열의 일부인 이름에 대해서도 마찬가지입니다.
            del name
            return name, val

    def _sync_frame_cache(self):
        # 이 메서드는 PyEval_GetLocals, PyFrame_FastToLocals
        # PyFrame_GetLocals, PyLocals_Get, 매핑 비교 등을 뒷받침합니다.
        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) 특성은 값 캐시가 최신 상태여야 한다는 점에 의존하지 않는 구현 코드를 추가함으로써 복원될 수 있습니다.

이터레이터/이터러블(iterator/iterable) 검색 메서드를 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 배열 접근 시 셀을 확인하는 대신 셀 참조를 직접 저장하기 때문입니다). 이를 수정하는 것도 병합 전에 필요할 것입니다.

Implementation (구현)

레퍼런스 구현 업데이트는 GitHub의 초안 Pull Request로 개발 중입니다.

Acknowledgements (감사)

write-through 프록시 아이디어를 제안하고, 그러한 프록시 도입을 피하려 했던 이 PEP의 초기 반복에서 일부 중요한 설계 결함을 지적해 준 Nathaniel J. Smith에게 감사드립니다.

제안된 C API 추가 사항의 개발자 경험에 더 많은 관심을 기울여 달라고 요청한 Steve Dower와 Petr Viktorin에게 감사드립니다,.

안정적인 ABI에서 열거형(enums)을 사용하여 임의의 정수로부터 안전하게 타입 캐스팅을 지원하는 방법에 대한 제안을 해 준 Larry Hastings에게 감사드립니다.

C 레벨 API 및 의미론의 추가 단순화와 PEP 텍스트의 상당한 명확화를 추진한 Mark Shannon에게 감사드립니다 (그리고 1년 더 활동이 없었던 2021년 초에 PEP에 대한 논의를 다시 시작한 것에도 감사드립니다),,. Mark의 의견은 궁극적으로 PEP 667로 발표되었으며, 관련 매핑이 사용되지 않을 때 중복된 O(n) 매핑 새로 고침 연산 비용을 피하고, Python 레벨 f_locals API를 통해 보고되는 상태가 절대 오래되지 않도록 보장하는 여러 구현 효율성 개선으로 직접 이어졌습니다.

References (참조)

(1, 2, 3, 4, 5) 스레드 + 트레이스 훅 + 클로저가 주어졌을 때 깨진 지역 변수 할당 (Broken local variable assignment given threads + trace hook + closure). (1, 2, 3) pdb에서 함수 지역 변수 업데이트가 신뢰할 수 없음 (Updating function local variables from pdb is unreliable). 트레이스 훅 설치를 위한 CPython의 Python API. 트레이스 훅 설치를 위한 CPython의 C API. PEP 558 레퍼런스 구현 (PEP 558 reference implementation). (1, 2) locals()에 대한 가능한 함수 레벨 의미론에 대한 Nathaniel의 검토 (Nathaniel’s review of possible function level semantics for locals()). (1, 2) 더 의도적으로 설계된 C API 개선 사항에 대한 논의 (Discussion of more intentionally designed C API enhancements). 트레이싱 중 프레임 locals의 자동 업데이트 비활성화 (Disable automatic update of frame locals during tracing). python-dev 스레드: PEP 558 부활 (locals()의 정의된 의미론) (Resurrecting PEP 558 (Defined semantics for locals())). python-dev 스레드: PEP 558에 대한 의견 (Comments on PEP 558). python-dev 스레드: PEP 558에 대한 추가 의견 (More comments on PEP 558). PyLocals_Get의 동작에 대해 열거형을 사용하자는 Petr Viktorin의 제안.

이 문서는 퍼블릭 도메인 또는 CC0-1.0-Universal 라이선스 중 더 관대한 라이선스에 따라 제공됩니다.

⚠️ 알림: 이 문서는 AI를 활용하여 번역되었으며, 기술적 정확성을 보장하지 않습니다. 정확한 내용은 반드시 원문을 확인하시기 바랍니다.

Comments