Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

PEP 698 – 정적 타이핑을 위한 오버라이드 데코레이터

Author:
Steven Troxler <steven.troxler at gmail.com>, Joshua Xu <jxu425 at fb.com>, Shannon Zhu <szhu at fb.com>
Sponsor:
Jelle Zijlstra <jelle.zijlstra at gmail.com>
Discussions-To:
Discourse thread
Status:
Final
Type:
Standards Track
Topic:
Typing
Created:
05-Sep-2022
Python-Version:
3.12
Post-History:
20-May-2022, 17-Aug-2022, 11-Oct-2022, 07-Nov-2022
Resolution:
Discourse message

Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

Important

This PEP is a historical document: see @override and @typing.override for up-to-date specs and documentation. Canonical typing specs are maintained at the typing specs site; runtime typing behaviour is described in the CPython documentation.

×

See the typing specification update process for how to propose changes to the typing spec.

초록

이 PEP는 Python 타입 시스템에 @override 데코레이터를 추가할 것을 제안합니다. 이를 통해 타입 검사기는 베이스 클래스가 파생 클래스에 상속되는 메서드를 변경할 때 발생하는 버그 유형을 방지할 수 있습니다.

동기

타입 검사기의 주요 목적은 코드에 이미 존재하는 의미 구조를 리팩터링이나 변경으로 인해 깨뜨리는 경우를 표시하는 것입니다. 이를 통해 사용자는 코드를 수동으로 감사하지 않고도 프로젝트 전반에서 문제를 식별하고 수정할 수 있습니다.

안전한 리팩터링

Python의 타입 시스템은 오버라이드된 함수 API가 변경될 때 일관성을 유지하기 위해 변경해야 하는 호출 지점을 식별할 방법을 제공하지 않습니다. 이로 인해 코드의 리팩터링과 변환이 더 위험해집니다.

다음과 같은 간단한 상속 구조를 생각해 보십시오.

class Parent:
    def foo(self, x: int) -> int:
        return x

class Child(Parent):
    def foo(self, x: int) -> int:
        return x + 1

def parent_callsite(parent: Parent) -> None:
    parent.foo(1)

def child_callsite(child: Child) -> None:
    child.foo(1)

슈퍼클래스의 오버라이드된 메서드 이름이 변경되거나 삭제되면, 타입 검사기는 베이스 타입을 직접 다루는 호출 지점을 업데이트해야 한다고만 알려 줍니다. 그러나 타입 검사기는 우리가 변경한 내용이 아니라 새 코드만 볼 수 있으므로, 자식 클래스의 동일한 메서드 이름도 변경해야 했을 가능성이 있다는 사실을 알 방법이 없습니다.

버그를 도입하고 있을 가능성이 높은데도 타입 검사기는 이 코드를 아무 문제 없이 받아들입니다.

class Parent:
    # Rename this method
    def new_foo(self, x: int) -> int:
        return x

class Child(Parent):
    # This (unchanged) method used to override `foo` but is unrelated to `new_foo`
    def foo(self, x: int) -> int:
        return x + 1

def parent_callsite(parent: Parent) -> None:
    # If we pass a Child instance we’ll now run Parent.new_foo - likely a bug
    parent.new_foo(1)

def child_callsite(child: Child) -> None:
    # We probably wanted to invoke new_foo here. Instead, we forked the method
    child.foo(1)

이 코드는 타입 검사를 통과하지만, 버그의 잠재적 원인이 두 가지 있습니다.

  • Child 인스턴스를 parent_callsite 함수에 전달하면 Child.foo가 아니라 Parent.new_foo의 구현을 호출합니다. 이는 아마도 버그일 것입니다. 사용자 지정 동작이 필요하지 않았다면 애초에 Child.foo를 작성하지 않았을 것이기 때문입니다.
  • 우리 시스템은 아마도 Child.fooParent.foo와 유사한 방식으로 동작할 것이라고 가정하고 있었을 것입니다. 그러나 이를 조기에 발견하지 않으면 메서드가 이제 분기된 상태가 되며, 향후 리팩터링에서 누군가 new_foo의 동작에 대한 주요 변경 사항을 Child.foo에도 업데이트해야 한다는 사실을 알아차리지 못할 가능성이 높고, 이는 나중에 큰 버그로 이어질 수 있습니다.

잘못 리팩터링된 코드는 타입 안전하지만, 우리가 의도한 것이 아닐 가능성이 높으며 시스템이 잘못 동작하게 만들 수 있습니다. 새 코드가 예외를 발생시키지 않고 실행될 가능성이 높기 때문에 버그를 추적하기가 어려울 수 있습니다. 테스트에서 이 문제를 발견할 가능성은 낮고, 조용히 발생하는 오류는 프로덕션에서 추적하는 데 더 오랜 시간이 걸릴 수 있습니다.

이러한 잘못된 리팩터링으로 인해 여러 타입 적용 코드베이스에서 발생한 여러 프로덕션 장애를 알고 있습니다. 이것이 타입 시스템에 @override 데코레이터를 추가하는 주된 동기입니다. 이 데코레이터를 사용하면 개발자가 Parent.fooChild.foo 사이의 관계를 표현할 수 있으므로 타입 검사기가 문제를 감지할 수 있습니다.

근거

서브클래스 구현을 더 명시적으로 만들기

명시적인 오버라이드가 암시적인 오버라이드보다 익숙하지 않은 코드를 더 쉽게 읽을 수 있게 한다고 생각합니다. @override를 사용하는 서브클래스의 구현을 읽는 개발자는 어떤 메서드가 일부 베이스 클래스의 기능을 오버라이드하는지 즉시 알 수 있습니다. 이 데코레이터가 없다면 빠르게 알아내는 유일한 방법은 정적 분석 도구를 사용하는 것입니다.

다른 언어와 런타임 라이브러리의 선례

다른 언어의 정적 오버라이드 검사

널리 사용되는 많은 프로그래밍 언어가 오버라이드 검사를 지원합니다. 예를 들어 다음과 같습니다.

Python의 런타임 오버라이드 검사

현재 Overrides 라이브러리가 있으며, @overrides [sic] 및 @final 데코레이터를 제공하고 런타임에 이를 강제합니다.

PEP 591은 Overrides 라이브러리와 동일한 의미를 갖는 @final 데코레이터를 추가했습니다. 그러나 런타임 라이브러리의 오버라이드 구성 요소는 정적으로는 전혀 지원되지 않으므로, mix/matched 지원을 둘러싸고 혼란이 생겼습니다.

정적 검사에서 @override를 지원하면 다음과 같은 가치가 있습니다.

  • 버그를 더 일찍, 흔히 편집기 안에서 발견할 수 있습니다.
  • 정적 검사는 런타임 검사와 달리 성능 오버헤드가 없습니다.
  • 거의 사용되지 않는 모듈에서도 버그를 빠르게 발견할 수 있지만, 런타임 검사를 사용하면 모든 가져오기를 자동으로 테스트하지 않는 한 이러한 버그가 한동안 발견되지 않을 수 있습니다.

단점

@override를 사용하면 코드가 더 장황해집니다.

사양

타입 검사기가 @typing.override로 데코레이트된 메서드를 만나면, 해당 메서드가 어떤 조상 클래스의 호환 가능한 메서드나 속성을 재정의하는 경우가 아닌 한 타입 오류로 처리해야 합니다.

from typing import override

class Parent:
    def foo(self) -> int:
        return 1

    def bar(self, x: str) -> str:
        return x

class Child(Parent):
    @override
    def foo(self) -> int:
        return 2

    @override
    def baz(self) -> int:  # Type check error: no matching signature in ancestor
        return 1

타입 검사기가 메서드를 유효한 오버라이드로 간주하는 모든 곳에서 @override 데코레이터를 허용해야 하며, 여기에는 일반 메서드뿐 아니라 @property, @staticmethod@classmethod도 일반적으로 포함됩니다.

오버라이드 호환성에 대한 새로운 규칙 없음

이 PEP는 새로운 @override 데코레이터의 처리에만 관련되며, 이 데코레이터는 데코레이트된 메서드가 조상 클래스의 어떤 속성을 재정의해야 함을 지정합니다. 이 PEP는 이러한 메서드의 타입 시그니처에 관한 새로운 규칙을 제안하지 않습니다.

프로젝트별 엄격한 적용

검사기가 개발자가 부모 클래스를 재정의하는 메서드에 데코레이터를 사용하도록 요구하는 엄격한 모드를 선택할 수 있게 할 때 @override가 가장 유용하다고 생각합니다. 하위 호환성을 위해 엄격한 적용은 선택 사항이어야 합니다.

동기

@override를 요구하는 엄격한 모드가 필요한 주된 이유는 개발자가 프로젝트 전체에서 @override 데코레이터가 사용된다는 사실을 알아야만 리팩터링이 오버라이드에 안전하다고 신뢰할 수 있기 때문입니다.

오버라이드와 관련된 또 다른 종류의 버그가 있으며, 이는 엄격한 모드를 사용해야만 발견할 수 있습니다.

다음 코드를 고려하십시오.

class Parent:
    pass

class Child(Parent):
    def foo(self) -> int:
        return 2

다음과 같이 리팩터링한다고 가정하십시오.

class Parent:
    def foo(self) -> int:   # This method is new
        return 1

class Child(Parent):
    def foo(self) -> int:  # This is now an override!
        return 2

def call_foo(parent: Parent) -> int:
    return parent.foo()  # This could invoke Child.foo, which may be surprising.

여기서 코드의 의미론이 변경되어, 두 가지 문제가 발생할 수 있습니다:

  • 코드 변경의 작성자가 Child.foo가 이미 존재한다는 사실을 몰랐다면(대규모 코드베이스에서는 충분히 가능한 일입니다), call_foo가 항상 Parent.foo를 호출하지 않는다는 사실에 놀랄 수 있습니다.
  • 코드베이스 작성자들이 서브클래스에서 오버라이드를 작성할 때 모든 곳에 수동으로 @override를 적용하려 했다면, 여기서 Child.foo에도 이 데코레이터가 필요하다는 사실을 놓치기 쉽습니다.

처음에는 이러한 종류의 변경이 일어날 가능성이 낮아 보일 수 있지만, 하나 이상의 서브클래스에 개발자들이 나중에 베이스 클래스에 속한다고 깨닫게 되는 기능이 있다면 실제로 자주 발생할 수 있습니다.

엄격한 모드에서는 이러한 상황이 발생할 때마다 개발자에게 알립니다.

선례

살펴본 대부분의 타입 지정 객체 지향 프로그래밍 언어에는 프로젝트 전반에서 명시적 오버라이드를 요구하는 쉬운 방법이 있습니다:

  • C#, Kotlin, Scala, Swift에서는 항상 명시적 오버라이드를 요구합니다.
  • TypeScript에는 명시적 오버라이드를 강제하는 –no-implicit-override 플래그가 있습니다.
  • Hack과 Java에서는 타입 검사기가 항상 오버라이드를 선택 사항으로 취급하지만, 널리 사용되는 린터는 명시적 오버라이드가 누락된 경우 경고할 수 있습니다.

하위 호환성

기본적으로 @override데코레이터는 선택 사항입니다. 이를 사용하지 않는 코드베이스는 추가적인 타입 안전성 없이 이전과 동일하게 타입 검사를 통과합니다.

런타임 동작

가능하면 __override__ = True로 설정

런타임에서 @typing.override는 인자에 값이 True__override__속성을 추가하기 위해 최선을 다합니다. 여기서 “최선을 다한다”는 것은 속성 추가를 시도하지만, 실패하는 경우(예를 들어 입력이 고정 슬롯을 가진 디스크립터 타입이기 때문인 경우) 인자를 조용히 있는 그대로 반환한다는 의미입니다.

이는 @typing.final데코레이터가 수행하는 것과 정확히 같으며, 동기도 비슷합니다. 이를 통해 런타임 라이브러리가 @override를 사용할 수 있습니다. 구체적인 예로, 런타임 라이브러리는 __override__를 확인하여 부모 메서드의 독스트링을 사용해 자식 클래스 메서드의 __doc__속성을 자동으로 채울 수 있습니다.

__override__설정의 제한 사항

위에서 설명한 것처럼 런타임에 __override__를 추가하는 작업은 실패할 수 있으며, 이 경우 인자를 있는 그대로 반환합니다.

또한 작동하는 경우에도 사용자가 여러 데코레이터를 올바르게 사용하기 어려울 수 있습니다. 최종 출력에 __override__속성이 설정되도록 보장하려면 각 데코레이터의 구현을 이해해야 하기 때문입니다:

  • @override데코레이터는 래퍼 함수를 사용하는 after 일반 데코레이터(예: @functools.lru_cache) 이후에 실행되어야 합니다. 가장 바깥쪽 래퍼에 __override__를 설정하려 하기 때문입니다. 이는 다른 모든 데코레이터의 above 위치에 배치해야 한다는 의미입니다.
  • 그러나 @override@property, @staticmethod, @classmethod와 같은 많은 특수 디스크립터 기반 데코레이터보다 먼저 실행되어야 합니다.
  • 위에서 설명한 것처럼 일부 경우(예를 들어 고정 슬롯을 가진 디스크립터나 래핑도 수행하는 디스크립터)에는 __override__속성을 전혀 설정하지 못할 수 있습니다.

따라서 __override__를 설정하는 런타임 지원은 최선의 시도만 제공하며, 타입 검사기가 데코레이터의 순서를 검증할 것으로 기대하지 않습니다.

거부된 대안

안전성을 위해 통합 개발 환경에 의존하기

최신 통합 개발 환경(IDE)은 메서드 이름을 변경할 때 서브클래스를 자동으로 업데이트하는 기능을 제공하는 경우가 많습니다. 그러나 다음과 같은 몇 가지 이유로 이를 충분하지 않다고 봅니다:

  • 코드베이스가 여러 프로젝트로 분할되어 있으면 IDE는 도움이 되지 않으며, 의존성을 업그레이드할 때 버그가 나타납니다. 타입 검사기는 의존성의 호환성을 깨뜨리는 변경 사항을 빠르게 포착하는 방법입니다.
  • 모든 개발자가 그러한 IDE를 사용하는 것은 아닙니다. 또한 라이브러리 유지 관리자는 IDE를 사용하더라도 풀 리퀘스트 작성자가 동일한 IDE를 사용한다고 가정할 필요가 없습니다. 개발자가 어떤 편집기를 선택했는지와 무관하게 지속적 통합에서 문제를 감지할 수 있는 방식을 선호합니다.

런타임 강제

@typing.override가 런타임에서 재정의 안전성을 강제하도록 하는 방안을 고려했으며, 이는 @overrides.overrides오늘날 수행하는 방식과 유사합니다.

다음 네 가지 이유로 이를 채택하지 않았습니다.

  • 정적 타입 검사를 사용하는 사용자에게는 이것이 어떤 이점을 제공하는지 명확하지 않습니다.
  • 적어도 어느 정도의 성능 오버헤드가 발생하여, 런타임 강제를 사용할 경우 프로젝트를 더 느리게 임포트하게 됩니다. @overrides.overrides 구현에는 약 100마이크로초가 걸리는 것으로 추정하며, 이는 빠른 편이지만 백만 줄이 넘는 코드베이스에서는 초기화 시간이 1초 이상 추가될 수도 있습니다. 바로 이러한 곳에서 @typing.override가 가장 유용할 것으로 생각합니다.
  • 구현에는 제대로 작동하지 않는 가장자리 사례가 있을 수 있으며, 실제로 그러한 폐쇄형 소스 라이브러리의 유지 관리자로부터 이것이 문제가 된 적이 있다는 이야기를 들었습니다. 정적 강제는 단순하고 안정적일 것으로 예상합니다.
  • 우리가 알고 있는 구현 방식은 단순하지 않습니다. 데코레이터는 클래스 평가가 완료되기 전에 실행되므로, 우리가 알고 있는 선택지는 호출자의 바이트코드를 검사하는 방식(@overrides.overrides가 수행하는 방식)이나 메타클래스 기반 접근 방식을 사용하는 것입니다. 어느 접근 방식도 이상적으로 보이지 않습니다.

서브클래스에서 명시적 재정의를 강제하도록 베이스 클래스를 표시합니다.

베이스 클래스가 모든 서브클래스에 메서드 재정의 시 @override 데코레이터를 사용하도록 선언하는 방법을 제공하는 클래스 데코레이터 @require_explicit_overrides를 포함하는 방안을 고려했습니다. Overrides 라이브러리에는 런타임 검사에서 유사한 동작을 제공하는 믹스인 클래스 EnforceExplicitOverrides가 있습니다.

대규모 코드베이스의 소유자가 @override로부터 가장 큰 혜택을 얻을 것으로 예상하기 때문에 이를 채택하지 않았으며, 이러한 사용 사례에서는 명시적인 @override가 필요한 엄격한 모드(하위 호환성 절 참조)가 베이스 클래스를 표시하는 방법보다 더 많은 이점을 제공합니다.

또한 @override를 사용하는 데 필요한 추가적인 상용구 코드가 추가 타입 안전성만큼 가치 있다고 여기지 않는 프로젝트 작성자에게 이를 강제해서는 안 된다고 생각합니다. 선택적 엄격한 모드를 사용하면 결정권이 프로젝트 소유자에게 있지만, 라이브러리에서 @require_explicit_overrides를 사용하면 프로젝트 소유자가 원하지 않더라도 @override를 사용하도록 강제하게 됩니다.

재정의되는 조상 클래스의 이름을 포함합니다.

@override의 호출자가 재정의된 메서드가 정의되어야 하는 특정 조상 클래스를 지정하도록 허용하는 방안을 고려했습니다.

class Parent0:
    def foo(self) -> int:
        return 1


class Parent1:
    def bar(self) -> int:
        return 1


class Child(Parent0, Parent1):
    @override(Parent0)  # okay, Parent0 defines foo
    def foo(self) -> int:
        return 2

    @override(Parent0)  # type error, Parent0 does not define bar
    def bar(self) -> int:
        return 2

이는 깊은 상속 트리에서 재정의 구조를 더 명시적으로 만들어 코드 가독성에 유용할 수 있습니다. 또한 재정의되는 메서드가 한 베이스 클래스에서 다른 베이스 클래스로 이동할 때마다 개발자가 재정의 구현이 여전히 타당한지 확인하도록 유도하여 버그를 포착할 수도 있습니다.

다음과 같은 이유로 이를 채택하지 않았습니다.

  • 이를 지원하면 @override의 구현과 이를 지원하는 타입 검사기 양쪽에 복잡성이 추가되므로, 상당한 이점이 있어야 합니다.
  • 이를 사용하는 경우는 드물고 포착할 수 있는 버그도 비교적 적을 것으로 생각합니다.
    • Overrides 패키지의 작성자는 자신의 라이브러리 초기 버전에 이 기능이 포함되어 있었지만 거의 유용하지 않았고 이점도 거의 없어 보였다고 언급했습니다. 이 기능이 제거된 후에는 사용자가 해당 기능을 요청한 적이 전혀 없습니다.

참조 구현

Pyre: 개념 증명이 Pyre에 구현되어 있습니다: