질문 역사:
Python 2.5 이전에 finally 블록의 return 문과 활성 예외 사이의 상호작용은 모호하고 플랫폼에 따라 달랐습니다. PEP 341은 예외 계층 구조를 표준화하고 함수 종료 전에 finally 블록이 실행된다는 규칙을 확립했지만, 정리 코드를 실행하는 동안 인터프리터가 보류 중인 반환 값이나 예외를 어떻게 보존하는지는 내부 컴파일러 세부 사항으로 남았습니다. 이 메커니즘은 자원을 예측 가능하게 해제하면서 함수가 값을 반환해야 하는지, 예외를 전파해야 하는지, 제어를 양도해야 하는지를 추적하는 데 문제가 없도록 보장합니다.
문제:
CPython이 try-finally 문을 컴파일할 때, 세 가지 별도의 종료 경로를 수용해야 합니다: 정상적인 연속, 스택에 값이 있는 명시적인 return, 그리고 전파되고 있는 활성 예외. 도전 과제는 모든 경우에 finally 블록이 실행되도록 보장하면서 종료 상태를 덮어쓸 수 있도록 하는 것입니다(예: finally의 return이 try의 예외를 억제함), 값 스택을 손상시키거나 보류 중인 예외 정보를 잃지 않도록 하는 것입니다. 이는 컴파일러가 finally 블록의 바이트코드를 여러 위치에 생성하고 프레임의 블록 스택을 사용하여 실행 컨텍스트를 임시로 저장해야 함을 의미합니다.
해결책:
컴파일러는 try 블록 끝에서 finally 블록을 한 번 생성한 다음, 예외 처리 및 반환 경로에 대해 특정 오프셋에서 이를 중복해 (또는 해당 위치로 점프하여) 실행합니다. SETUP_FINALLY 연산 코드는 프레임의 블록 스택에 finally 코드의 예외 처리기 버전을 가리키는 블록을 푸시합니다. 예외가 발생하면 인터프리터는 이 스택 항목을 사용하여 처리기로 점프합니다. 정상적인 반환의 경우 POP_BLOCK이 핸들러를 제거하지만, try 내부에서 return이 발생하면 인터프리터는 반환 값을 저장하고, finally 블록을 실행하며, 해당 블록이 새로운 return 없이 완료되면 원래 반환 값을 복원합니다. finally 블록에 자체 return이 포함되어 있으면 RETURN_VALUE를 실행하여 보류 중인 반환 값을 덮어쓰거나 예외 상태를 지워 새로운 값을 반환합니다.
import dis def example(): try: return "try_value" finally: return "finally_value" # 바이트코드는 예외 처리와 정상 반환을 위한 오프셋에서 finally 논리가 중복된 것을 보여줍니다. dis.dis(example)
문제 설명:
재무 거래 처리 시스템에서 process_withdrawal() 함수는 원자적 잔액 업데이트를 보장하기 위해 스레드 잠금을 획득합니다. try 블록은 새 잔액을 계산하고 반환할 거래 기록을 준비합니다. 그러나 finally 블록의 준수 검사에서 계좌에 의심스러운 플래그가 감지됩니다. 요구 사항은 항상 잠금을 해제해야 하며(정리), 플래그가 설정되면 거래 기록 대신 거절 통지를 반환해야 합니다. 이는 성공적으로 계산된 내용을 억제하는 것입니다.
고려된 여러 가지 해결책:
한 가지 접근 방식은 finally 블록 내부에서 return을 완전히 피하는 것이었습니다. 대신 계산된 결과를 로컬 변수 result에 저장하고, finally에서 준수 검사를 수행하며, 필요한 경우 result를 거절 통지로 수정하고 finally 블록 이후에 단일 return result 문을 배치하는 것이었습니다. 이 방법의 장점은 주니어 개발자가 이해하고 디버그하기 쉬운 명확한 제어 흐름을 제공하며, 반환 억제의 미묘한 동작을 피할 수 있다는 것입니다. 단점으로는 코드 장황성이 증가하고 finally 블록 이후에 변수를 반환하는 것을 잊을 위험이 있으며, 이 경우 함수가 암시적으로 None을 반환하게 됩니다.
고려된 또 다른 해결책은 잠금 획득을 위한 컨텍스트 관리자를 사용하고 준수 논리를 예외로 처리하는 것이었습니다. 플래그가 감지되면 finally 블록(또는 중첩된 함수)에서 사용자 정의 ComplianceError를 발생시키고 이를 외부에서 캐치하여 예외 처리기에서 거절 통지를 반환하는 방식입니다. 이 방법의 장점은 finally가 정리 전용이어야 하며 비즈니스 로직이 아닌 제어 흐름을 위한 Python의 예외 메커니즘을 활용하는 것입니다. 단점으로는 예외 생성의 오버헤드와 다른 예외가 활성 상황에서 새로운 예외를 발생시키면 원래 오류를 마스킹하는 문제로 디버깅이 복잡해질 수 있다는 점입니다.
선택된 해결책 (그리고 이유):
팀은 장황성에도 불구하고 첫 번째 해결책(사후 finally 반환이 있는 로컬 변수)을 선택했습니다. 그 이유는 값 억제를 위한 finally 내부에서 return을 사용하는 것이 기술적으로 유효하더라도, 향후 유지보수가 이루어질 때 개발자가 finally 블록에 로깅이나 메트릭스를 추가하면서 return 문을 추가하게 되면 우연히 예외나 반환 값을 억제할 수 있는 "footgun"이 될 수 있기 때문입니다. 명시적 변수 접근 방식은 데이터 흐름을 투명하게 만들고 정적 분석 검사를 보다 신뢰성 있게 통과하게 했습니다.
결과:
구현은 항상 finally 블록을 통해 잠금이 해제되도록 보장하여 교착 상태를 성공적으로 방지했으며, 준수 논리는 계산된 거래 데이터를 누출하지 않고 거절 통지를 올바르게 반환했습니다. 명시적 구조로 인해 특정 지점에서 모의 주입을 허용하여 암시적 반환 경로를 걱정할 필요 없이 단위 테스트를 단순화하였고, 코드 리뷰 또한 제어 흐름이 선형적이기 때문에 더 빨라졌습니다.
왜 finally 블록 내부의 break 또는 continue 문이 활성 예외를 억제하는가, 그리고 스택 정리 측면에서 return과 어떤 차이가 있는가?
활성 예외로 인해 finally 블록이 실행될 때 인터프리터는 프레임 상태에 예외 유형, 값 및 추적 정보를 저장합니다. 만약 finally 블록이 break 또는 continue를 실행하면, CPython은 이를 위해 예외 상태를 명시적으로 지웁니다(즉, POP_BLOCK 사용 및 예외 변수를 리셋) 그리고 루프 제어 흐름 타겟으로 점프합니다. 이 과정에서 예외가 손실됩니다. return과의 차이는 미세하지만 중요합니다: return은 스택에 값을 두고 프레임에 종료 신호를 보냅니다. 반면 break/continue는 바이트코드 오프셋으로 점프합니다. 두 작업 모두 블록 스택 언와인딩을 트리거하고 예외 상태를 지우는 것을 포함하지만, return은 반환 값 보존을 처리하는 반면, break는 단순히 보류 중인 예외 정보를 잃어버립니다.
try-finally 블록 내부의 yield 표현식이 정리를 위한 바이트코드 생성에 어떤 영향을 미치며, 특히 생성기 일시 정지와 관련하여.
CPython이 finally와 연결된 try 블록 내에 yield를 감지하면 YIELD_VALUE 연산 코드를 생성하고 END_FINALLY에서 특별 처리를 합니다. 문제는 생성기가 yield 지점에서 일시 정지될 수 있다는 것입니다. 생성기가 나중에 닫히면(예를 들어 close() 호출이나 가비지 컬렉션을 통해) 인터프리터는 finally 블록을 실행하기 위해 생성기를 재개해야 합니다. 이는 GENERATOR_RETURN (또는 최신 버전에서는 RETURN_GENERATOR) 및 YIELD_FROM 논리로 처리됩니다. 컴파일러는 SETUP_FINALLY를 추가하지만 프레임의 f_lasti(마지막 명령어) 포인터가 재진입을 허용합니다. 만약 생성기가 닫히면, Python은 일시 정지 지점에서 GeneratorExit 예외를 발생시켜 생성기가 실제로 종료되기 전에 finally 블록을 실행하게 합니다. 후보자들은 종종 yield가 finally 코드를 재진입으로부터 보호해야 하며, 생성기 객체가 프레임 참조를 유지하여 일시 정지 이후에도 finally 블록을 실행 가능하게 한다는 사실을 놓칩니다.
finally 블록이 기존 예외를 처리하는 동안 새로운 예외를 발생시킬 때 예외 컨텍스트(__context__ 및 __cause__)에 무슨 일이 발생하는가?
finally 블록이 활성 예외(예를 들어 try 블록에서 발생한 예외)를 처리하는 동안 새로운 예외를 발생시키면 새로운 예외가 "현재" 예외가 되며, 이전 예외는 이러한 __context__ 속성에 연결됩니다. 만약 finally 블록이 raise NewException() from None을 사용하여 체인을 명시적으로 끊는다면, __suppress_context__를 True로 설정합니다. 그러나 finally 블록이 예외를 발생시키지 않고 대신 return을 실행하면 예외가 완전히 억제되며(주요 답변에 따라), 함수가 종료되기 전에 프레임에서 예외 상태가 지워지기 때문에 체인이 발생하지 않습니다. 후보자들은 종종 except 블록 내부의 동작과 혼동하여 from 없이 raise하면 자동으로 체인이 발생하는 것을 알아차리지 못하며, finally 블록도 다른 코드 블록과 동일하게 이 체인 메커니즘에 참여하지만 스택 언와인딩 중에 실행될 수 있다는 점에서 복잡성이 추가된다는 것을 이해하지 못합니다.